diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx
index 9164a715f..764ae5547 100644
--- a/app/(main)/[locale]/layout.tsx
+++ b/app/(main)/[locale]/layout.tsx
@@ -27,6 +27,7 @@ import { PluginOAuthCallbackListener } from "@/components/providers/plugin-oauth
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
import { AppUpdatePrompt } from "@/components/app-update-prompt";
import { OfflineHost } from "@/components/offline/offline-host";
+import { NotificationActionsHost } from "@/components/providers/notification-actions-host";
import { DevicePolicyHost } from "@/components/device/device-policy-host";
import { PushNotificationPrompt } from "@/components/push-notification-prompt";
import { AccountArchiveRestorePrompt } from "@/components/account-archive-prompt";
@@ -103,6 +104,7 @@ export default async function LocaleLayout({
{/* Offline mode isn't in Lite (no service worker there). */}
{!IS_LITE && }
+ {!IS_LITE && }
{/* Lite is a static bundle: no server keeps an archive there. */}
diff --git a/app/api/push/action/route.ts b/app/api/push/action/route.ts
new file mode 100644
index 000000000..169b7f7e8
--- /dev/null
+++ b/app/api/push/action/route.ts
@@ -0,0 +1,273 @@
+import { cookies } from 'next/headers';
+import { NextRequest, NextResponse } from 'next/server';
+import { logger } from '@/lib/logger';
+import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
+import { configManager } from '@/lib/admin/config-manager';
+import { resolvePolicy } from '@/lib/admin/policy-scopes';
+import { strictDeviceSubject } from '@/lib/admin/server-device-policy';
+import { forwardedClientHeaders, loginClientAddress } from '@/lib/auth/client-address';
+import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint';
+import { UPSTREAM_JSON_MAX_BYTES, UpstreamBodyTooLargeError, readBodyCapped, readJsonCapped } from '@/lib/security/capped-body';
+import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
+import { DisallowedUrlError } from '@/lib/security/url-guard';
+import { readStalwartAuthContextFromStore, type StalwartAuthContext } from '@/lib/stalwart/auth-context';
+import { rebaseApiUrl } from '@/lib/stalwart/jmap-api';
+import { fetchJmapServer, isTrustedJmapServerUrl } from '@/lib/stalwart/server-fetch';
+
+export const runtime = 'nodejs';
+export const dynamic = 'force-dynamic';
+
+const USING = ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'];
+const MAIL_CAPABILITY = 'urn:ietf:params:jmap:mail';
+const MAX_BODY_BYTES = 4096;
+/** RFC 8620 ids, which is all an email or account id can be. */
+const JMAP_ID = /^[A-Za-z0-9_-]{1,255}$/;
+
+type ArchiveMode = 'single' | 'year' | 'month';
+
+interface ActionRequest {
+ action: 'mark-read' | 'archive';
+ accountId: string;
+ emailId: string;
+ login: string;
+ archiveMode: ArchiveMode;
+ timeZone?: string;
+}
+
+interface JmapMailbox {
+ id: string;
+ name: string;
+ parentId?: string | null;
+ role?: string | null;
+ myRights?: { mayAddItems?: boolean };
+}
+
+type MethodResponse = [string, Record, string];
+
+const fail = (status: number, error: string) =>
+ NextResponse.json({ error }, { status, headers: { 'Cache-Control': 'no-store' } });
+
+function validTimeZone(value: unknown): string | undefined {
+ if (typeof value !== 'string' || value.length > 64) return undefined;
+ try {
+ new Intl.DateTimeFormat('en-US', { timeZone: value });
+ return value;
+ } catch {
+ return undefined;
+ }
+}
+
+async function readActionRequest(request: Request): Promise {
+ const bytes = await readBodyCapped(request, MAX_BODY_BYTES);
+ if (!bytes) return null;
+ let body: Record;
+ try {
+ body = JSON.parse(new TextDecoder().decode(bytes)) as Record;
+ } catch {
+ return null;
+ }
+ if (!body || typeof body !== 'object') return null;
+ const { action, accountId, emailId, login, archiveMode } = body;
+ if (action !== 'mark-read' && action !== 'archive') return null;
+ if (typeof accountId !== 'string' || !JMAP_ID.test(accountId)) return null;
+ if (typeof emailId !== 'string' || !JMAP_ID.test(emailId)) return null;
+ if (typeof login !== 'string' || !login) return null;
+ return {
+ action,
+ accountId,
+ emailId,
+ login,
+ archiveMode: archiveMode === 'year' || archiveMode === 'month' ? archiveMode : 'single',
+ timeZone: validTimeZone(body.timeZone),
+ };
+}
+
+/** The signed-in logins this browser's cookies name as `login`: the notification's own, or none. */
+async function loginsNamed(login: string): Promise {
+ const cookieStore = await cookies();
+ const found: StalwartAuthContext[] = [];
+ for (let slot = 0; slot < MAX_ACCOUNT_SLOTS; slot++) {
+ const ctx = readStalwartAuthContextFromStore(cookieStore, slot);
+ if (ctx && (await loginFingerprint(ctx.serverUrl, ctx.username)) === login) found.push(ctx);
+ }
+ return found;
+}
+
+/**
+ * The Archive gate for this login's scopes (#1257). The worker's copy of the
+ * setting decides whether a button shows; this decides whether it works. When
+ * the login's scopes can't be worked out, the action is refused.
+ */
+async function archiveAllowed(ctx: StalwartAuthContext, trusted: boolean): Promise {
+ await configManager.ensureLoaded();
+ const subject = await strictDeviceSubject({ ...ctx, trusted });
+ if (!subject) return false;
+ return resolvePolicy(configManager.getPolicy(), subject).policy.features?.archiveActionEnabled !== false;
+}
+
+/** The year and month a message falls in where the user is. */
+function dateParts(receivedAt: string, timeZone: string | undefined): { year: string; month: string } {
+ const parts = new Intl.DateTimeFormat('en-CA', { timeZone: timeZone ?? 'UTC', year: 'numeric', month: '2-digit' })
+ .formatToParts(new Date(receivedAt));
+ return {
+ year: parts.find((part) => part.type === 'year')?.value ?? '',
+ month: parts.find((part) => part.type === 'month')?.value ?? '',
+ };
+}
+
+/**
+ * Where Archive files a message, as the app's own Archive does
+ * (JMAPClient.archiveEmails, ensureArchiveMailbox): the Archive folder, or a
+ * year or year/month folder in it, creating what isn't there. Creation ids
+ * resolve in the request that carries the Mailbox/set.
+ */
+function archiveDestination(
+ mailboxes: JmapMailbox[],
+ mode: ArchiveMode,
+ receivedAt: string,
+ timeZone: string | undefined,
+): { destination: string; create: Record> } {
+ const create: Record> = {};
+ const archive = mailboxes.find((m) => m.role === 'archive')
+ ?? mailboxes.find((m) => m.name.toLowerCase() === 'archive');
+ let destination = archive?.id ?? '#archive';
+ if (!archive) create.archive = { name: 'Archive', parentId: null, role: 'archive' };
+
+ const descend = (name: string, cid: string) => {
+ const existing = destination.startsWith('#')
+ ? undefined
+ : mailboxes.find((m) => m.parentId === destination && m.name === name);
+ if (existing) {
+ destination = existing.id;
+ return;
+ }
+ create[cid] = { name, parentId: destination };
+ destination = `#${cid}`;
+ };
+ if (mode !== 'single') {
+ const { year, month } = dateParts(receivedAt, timeZone);
+ descend(year, 'year');
+ if (mode === 'month') descend(month, 'month');
+ }
+ return { destination, create };
+}
+
+/**
+ * POST /api/push/action
+ *
+ * A notification's Mark read or Archive when no tab is open to do it. The
+ * service worker has no login of its own, only the context cookie, whose
+ * token a page refreshes and which ends with the browser session. Every
+ * refusal here means the same to the worker: open the message in the app.
+ *
+ * Like /api/push/preview it never hands the worker a credential. The cookie
+ * is sent because the request is same-origin; the proxy and this handler both
+ * refuse any other. Plugins run in pages, so their move and read hooks don't
+ * see an action taken here.
+ */
+export async function POST(request: NextRequest) {
+ const rejected = rejectCrossOriginRequest(request);
+ if (rejected) return rejected;
+ try {
+ const body = await readActionRequest(request);
+ if (!body) return fail(400, 'Invalid request');
+
+ const logins = await loginsNamed(body.login);
+ if (logins.length === 0) return fail(401, 'Not authenticated');
+ if (logins.length > 1) return fail(409, 'Login is in more than one cookie slot');
+ const ctx = logins[0];
+
+ const serverUrl = ctx.serverUrl.replace(/\/+$/, '');
+ const trusted = await isTrustedJmapServerUrl(serverUrl);
+ const forwarded = forwardedClientHeaders(loginClientAddress(request));
+ const sessionRes = await fetchJmapServer(`${serverUrl}/.well-known/jmap`, {
+ headers: { Authorization: ctx.authHeader, ...forwarded },
+ }, trusted);
+ // An expired token (only a page refreshes it), or a password changed since.
+ if (sessionRes.status === 401 || sessionRes.status === 403) return fail(401, 'Not authenticated');
+ if (!sessionRes.ok) return fail(502, 'JMAP session failed');
+ const session = (await readJsonCapped(sessionRes, UPSTREAM_JSON_MAX_BYTES)) as {
+ apiUrl?: string;
+ primaryAccounts?: Record;
+ accounts?: Record }>;
+ };
+ const apiUrl = rebaseApiUrl(session, serverUrl);
+ if (!apiUrl) return fail(502, 'JMAP session failed');
+ // The worker named this account; it has to be one this login can reach.
+ const { accountId, emailId } = body;
+ const account = session.accounts && Object.prototype.hasOwnProperty.call(session.accounts, accountId)
+ ? session.accounts[accountId]
+ : undefined;
+ if (!account && session.primaryAccounts?.[MAIL_CAPABILITY] !== accountId) {
+ return fail(403, 'Account is not in this login');
+ }
+
+ const jmap = async (methodCalls: unknown[]): Promise => {
+ const res = await fetchJmapServer(apiUrl, {
+ method: 'POST',
+ headers: { Authorization: ctx.authHeader, 'Content-Type': 'application/json', ...forwarded },
+ body: JSON.stringify({ using: USING, methodCalls }),
+ }, trusted);
+ if (!res.ok) return null;
+ const data = (await readJsonCapped(res, UPSTREAM_JSON_MAX_BYTES)) as { methodResponses?: MethodResponse[] };
+ return Array.isArray(data.methodResponses) ? data.methodResponses : null;
+ };
+ const result = (responses: MethodResponse[], name: string, id: string) =>
+ responses.find(([method, , callId]) => method === name && callId === id)?.[1];
+
+ let update: Record;
+ let create: Record> = {};
+ if (body.action === 'mark-read') {
+ update = { 'keywords/$seen': true };
+ } else {
+ if (!(await archiveAllowed(ctx, trusted))) return fail(403, 'Archive is turned off');
+ const read = await jmap([
+ ['Mailbox/get', { accountId, ids: null, properties: ['id', 'name', 'parentId', 'role', 'myRights'] }, 'mailboxes'],
+ ['Email/get', { accountId, ids: [emailId], properties: ['id', 'receivedAt'] }, 'email'],
+ ]);
+ if (!read) return fail(502, 'JMAP request failed');
+ const mailboxes = result(read, 'Mailbox/get', 'mailboxes')?.list as JmapMailbox[] | undefined;
+ const emails = result(read, 'Email/get', 'email')?.list as { receivedAt?: string }[] | undefined;
+ if (!Array.isArray(mailboxes) || !Array.isArray(emails)) return fail(502, 'JMAP request failed');
+ const receivedAt = emails[0]?.receivedAt;
+ if (!receivedAt) return fail(404, 'Message not found');
+ const planned = archiveDestination(mailboxes, body.archiveMode, receivedAt, body.timeZone);
+ // Only an explicit refusal stops it, as in the app.
+ if (planned.create.archive && account?.accountCapabilities?.[MAIL_CAPABILITY]?.mayCreateTopLevelMailbox === false) {
+ return fail(403, 'Archive folder is missing');
+ }
+ if (mailboxes.find((m) => m.id === planned.destination)?.myRights?.mayAddItems === false) {
+ return fail(403, 'Archive folder is read-only');
+ }
+ create = planned.create;
+ update = { mailboxIds: { [planned.destination]: true } };
+ }
+
+ const written = await jmap([
+ ...(Object.keys(create).length > 0 ? [['Mailbox/set', { accountId, create }, 'folders']] : []),
+ ['Email/set', { accountId, update: { [emailId]: update } }, 'write'],
+ ]);
+ if (!written) return fail(502, 'JMAP request failed');
+ const set = result(written, 'Email/set', 'write') as {
+ updated?: Record | null;
+ notUpdated?: Record | null;
+ } | undefined;
+ if (set?.updated && Object.prototype.hasOwnProperty.call(set.updated, emailId)) {
+ return NextResponse.json({ ok: true }, { headers: { 'Cache-Control': 'no-store' } });
+ }
+ if (set?.notUpdated?.[emailId]?.type === 'notFound') return fail(404, 'Message not found');
+ return fail(502, 'JMAP server refused the change');
+ } catch (error) {
+ if (error instanceof DisallowedUrlError) {
+ logger.warn('push action refused non-public server address', { error: error.message });
+ return fail(502, 'JMAP server address is not allowed');
+ }
+ if (error instanceof UpstreamBodyTooLargeError) {
+ logger.warn('push action: JMAP response too large', { maxBytes: error.maxBytes });
+ return fail(502, 'JMAP server response is too large');
+ }
+ const err = error as Error & { cause?: { code?: string; message?: string } };
+ logger.error('push action failed', { error: err?.message ?? 'Unknown error', causeCode: err?.cause?.code });
+ return fail(500, 'Internal error');
+ }
+}
diff --git a/app/api/push/preview/route.ts b/app/api/push/preview/route.ts
index 8e48073ba..c862eeb99 100644
--- a/app/api/push/preview/route.ts
+++ b/app/api/push/preview/route.ts
@@ -14,6 +14,7 @@ import { forwardedClientHeaders, loginClientAddress } from '@/lib/auth/client-ad
import { DisallowedUrlError } from '@/lib/security/url-guard';
import { UPSTREAM_JSON_MAX_BYTES, UpstreamBodyTooLargeError, readJsonCapped } from '@/lib/security/capped-body';
import { serverDevicePolicy } from '@/lib/admin/server-device-policy';
+import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
@@ -359,6 +360,9 @@ export async function GET(request: NextRequest) {
unreadTotal,
...(redacted ? { redacted: true } : {}),
...(target.slot !== undefined ? { slot: target.slot } : {}),
+ // For the notification's buttons, which must not land on whoever signs
+ // into this slot later (app/api/push/action).
+ ...(target.slot !== undefined && email ? { login: await loginFingerprint(target.serverUrl, target.username) } : {}),
}, {
headers: {
// SW already gates on its own logic - don't let push events get
diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx
index c69d7c2c7..3f6cf4e1d 100644
--- a/components/mail/mail-app.tsx
+++ b/components/mail/mail-app.tsx
@@ -142,6 +142,8 @@ import { usePluginStore } from "@/stores/plugin-store";
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
import { canDownload } from "@/lib/device-policy";
import { consumePendingMailto, subscribeToPendingMailto } from "@/lib/protocol-handlers/session";
+import { setNotificationReplyOpener } from "@/lib/pwa-notify/page-actions";
+import { consumePendingReplyText } from "@/lib/pwa-notify/worker-state";
import { INTERNAL_MAILTO_EVENT, type ParsedMailto } from "@/lib/protocol-handlers/mailto";
import { PLUGIN_REPLY_EVENT, type PluginReplyRequest } from "@/lib/plugin-sandbox/host-api-compose";
import { onOpenEmail, type OpenEmailHandler, type OpenEmailOutcome } from "@/lib/plugin-sandbox/open-email";
@@ -257,6 +259,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const [showComposer, setShowComposer] = useState(false);
const [composerMode, setComposerMode] = useState<'compose' | 'reply' | 'replyAll' | 'forward'>('compose');
const [composerDraftText, setComposerDraftText] = useState("");
+ // A notification's Reply, waiting for its message to be the open one.
+ const [pendingNotificationReply, setPendingNotificationReply] = useState<{ emailId: string; text: string } | null>(null);
const [pendingDraft, setPendingDraft] = useState(null);
const [composerSessionId, setComposerSessionId] = useState(0);
// Fields a plugin filled in through onBeforeCompose for the composer about
@@ -1768,7 +1772,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}
}
};
- const applyMailDeepLink = async (link: MailDeepLink, opts?: { onLoad?: boolean }) => {
+ const applyMailDeepLink = async (link: MailDeepLink, opts?: { onLoad?: boolean; replyText?: string }) => {
// The folder list before an account switch, if one happened: until the
// new mailbox's folders arrive the store still holds these (or nothing).
let switchedFrom: Mailbox[] | null = null;
@@ -1867,6 +1871,12 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}
if (isMobile) setActiveView('viewer');
if (isTablet) setTabletListVisible(false);
+ if (link.reply) {
+ // A notification's Reply. The branch above may have skipped
+ // selecting, and the reply has to wait for the message to be open.
+ selectEmail(email);
+ setPendingNotificationReply({ emailId: email.id, text: opts?.replyText ?? await consumePendingReplyText(email.id) });
+ }
} finally {
setLoadingEmail(false);
}
@@ -2764,6 +2774,31 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
return () => window.removeEventListener(PLUGIN_REPLY_EVENT, onPluginReply);
}, []);
+ // A notification's Reply, from a `?reply=1` link or from the service worker
+ // when this tab is open. Both come through applyMailDeepLink and end here,
+ // once the message is the open one.
+ useEffect(() => {
+ if (!pendingNotificationReply || selectedEmail?.id !== pendingNotificationReply.emailId) return;
+ setPendingNotificationReply(null);
+ void pluginReplyRef.current.handleReply(pendingNotificationReply.text);
+ }, [pendingNotificationReply, selectedEmail?.id]);
+ // The Pro shell opens messages in tabs of its own, so it takes the link instead.
+ const composerOpenRef = useRef(showComposer);
+ composerOpenRef.current = showComposer;
+ useEffect(() => {
+ if (isEmbedded) return;
+ return setNotificationReplyOpener(({ emailId, accountId, slot, text }) => {
+ const open = () => void applyMailDeepLinkRef.current(
+ { kind: 'message', id: emailId, slot, ownerAccountId: accountId, reply: true },
+ { replyText: text ?? '' },
+ );
+ // A live composer's text goes through its own "Save or discard?" first.
+ const requestClose = composerRequestCloseRef.current;
+ if (composerOpenRef.current && requestClose) requestClose(open);
+ else open();
+ });
+ }, [isEmbedded]);
+
// A plugin opening a message (api.ui.openEmail), as a click on a search
// result entry naming one does: handleEmailSelect, set once it is defined.
// Not inside the Pro shell, which keeps this tab mounted while hidden and
diff --git a/components/providers/__tests__/notification-actions-host.test.tsx b/components/providers/__tests__/notification-actions-host.test.tsx
new file mode 100644
index 000000000..99fa14b6f
--- /dev/null
+++ b/components/providers/__tests__/notification-actions-host.test.tsx
@@ -0,0 +1,124 @@
+import { act, render } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { NotificationActionsHost } from '../notification-actions-host';
+
+// The tab's end of the service worker: it keeps the worker's copy of the
+// labels and archive settings, runs notification buttons and flushes the
+// outbox when the worker says so.
+
+const writeNotificationActionPrefs = vi.fn(async (..._args: unknown[]) => {});
+const runNotificationAction = vi.fn(async (_request: unknown, answer: (status: string) => void) => answer('done'));
+const scheduleFlushAll = vi.fn();
+let state = { isAuthenticated: true, archiveMode: 'month', archiveEnabled: true };
+
+vi.mock('@/lib/pwa-notify/worker-state', () => ({
+ writeNotificationActionPrefs: (...args: unknown[]) => writeNotificationActionPrefs(...args),
+}));
+vi.mock('@/lib/pwa-notify/page-actions', async () => {
+ const actual = await vi.importActual('@/lib/pwa-notify/page-actions');
+ return { isNotificationActionRequest: actual.isNotificationActionRequest, runNotificationAction: (...args: Parameters) => runNotificationAction(...args) };
+});
+vi.mock('@/lib/offline/offline-mode', () => ({ scheduleFlushAll: (...args: unknown[]) => scheduleFlushAll(...args) }));
+vi.mock('@/lib/archive-gate', () => ({ useArchiveActionEnabled: () => state.archiveEnabled }));
+vi.mock('@/stores/auth-store', () => ({
+ useAuthStore: (select: (s: { isAuthenticated: boolean }) => unknown) => select({ isAuthenticated: state.isAuthenticated }),
+}));
+vi.mock('@/stores/settings-store', () => ({
+ useSettingsStore: (select: (s: { archiveMode: string }) => unknown) => select({ archiveMode: state.archiveMode }),
+}));
+vi.mock('@/stores/email-store', () => ({ useEmailStore: { getState: () => ({}) } }));
+vi.mock('@/stores/account-store', () => ({ useAccountStore: { getState: () => ({ accounts: [] }) } }));
+
+const worker = new EventTarget();
+const fromWorker = (data: unknown, ports: unknown[] = []) => act(async () => {
+ const event = new Event('message');
+ Object.assign(event, { data, ports });
+ worker.dispatchEvent(event);
+});
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ state = { isAuthenticated: true, archiveMode: 'month', archiveEnabled: true };
+ Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: worker });
+});
+
+afterEach(() => {
+ Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: undefined });
+});
+
+describe('the mirror for the worker', () => {
+ it('writes the labels and archive settings once signed in', () => {
+ render();
+ expect(writeNotificationActionPrefs).toHaveBeenCalledWith({
+ archiveEnabled: true,
+ archiveMode: 'month',
+ labels: { archive: 'action_archive', markRead: 'action_mark_read', reply: 'action_reply', replyPlaceholder: 'reply_placeholder' },
+ });
+ });
+
+ it('writes nothing while signed out', () => {
+ state.isAuthenticated = false;
+ render();
+ expect(writeNotificationActionPrefs).not.toHaveBeenCalled();
+ });
+
+ it('writes again on signing in, since signing out deleted the store', () => {
+ state.isAuthenticated = false;
+ const { rerender } = render();
+ state.isAuthenticated = true;
+ rerender();
+ expect(writeNotificationActionPrefs).toHaveBeenCalledTimes(1);
+ });
+
+ it('writes again when the archive mode or the gate changes, and not otherwise', () => {
+ const { rerender } = render();
+ rerender();
+ expect(writeNotificationActionPrefs).toHaveBeenCalledTimes(1);
+ state.archiveMode = 'year';
+ rerender();
+ expect(writeNotificationActionPrefs).toHaveBeenCalledTimes(2);
+ state.archiveEnabled = false;
+ rerender();
+ expect(writeNotificationActionPrefs).toHaveBeenCalledTimes(3);
+ expect(writeNotificationActionPrefs.mock.calls[2][0]).toMatchObject({ archiveEnabled: false, archiveMode: 'year' });
+ });
+});
+
+describe('messages from the worker', () => {
+ const request = { kind: 'notification-action', action: 'mark-read', emailId: 'e1', accountId: 'a', slot: 0, login: 'fp' };
+
+ it('runs a notification action and answers on its port', async () => {
+ render();
+ const port = { postMessage: vi.fn() };
+ await fromWorker(request, [port]);
+ expect(runNotificationAction.mock.calls[0][0]).toMatchObject({ action: 'mark-read', emailId: 'e1' });
+ expect(port.postMessage).toHaveBeenCalledWith({ status: 'done' });
+ });
+
+ it('ignores anything else', async () => {
+ render();
+ await fromWorker({ kind: 'notificationclick', tag: 't' });
+ await fromWorker({ ...request, action: 'delete' });
+ await fromWorker(null);
+ expect(runNotificationAction).not.toHaveBeenCalled();
+ expect(scheduleFlushAll).not.toHaveBeenCalled();
+ });
+
+ it('flushes the outbox on the word of a sync event', async () => {
+ render();
+ await fromWorker({ kind: 'outbox-flush' });
+ expect(scheduleFlushAll).toHaveBeenCalledWith(0);
+ });
+
+ it('stops listening when it goes', async () => {
+ const { unmount } = render();
+ unmount();
+ await fromWorker({ kind: 'outbox-flush' });
+ expect(scheduleFlushAll).not.toHaveBeenCalled();
+ });
+
+ it('does nothing in a browser without service workers', () => {
+ Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: undefined });
+ expect(() => render()).not.toThrow();
+ });
+});
diff --git a/components/providers/notification-actions-host.tsx b/components/providers/notification-actions-host.tsx
new file mode 100644
index 000000000..6231869f9
--- /dev/null
+++ b/components/providers/notification-actions-host.tsx
@@ -0,0 +1,56 @@
+'use client';
+
+import { useEffect } from 'react';
+import { useTranslations } from 'next-intl';
+import { useArchiveActionEnabled } from '@/lib/archive-gate';
+import { scheduleFlushAll } from '@/lib/offline/offline-mode';
+import { isNotificationActionRequest, runNotificationAction } from '@/lib/pwa-notify/page-actions';
+import { writeNotificationActionPrefs } from '@/lib/pwa-notify/worker-state';
+import { useAuthStore } from '@/stores/auth-store';
+import { useSettingsStore } from '@/stores/settings-store';
+
+/**
+ * This tab's end of what the service worker can't do alone: it keeps the
+ * worker's copy of the labels and archive settings current, runs a
+ * notification's buttons and flushes the outbox on the worker's word. Not
+ * mounted in Lite (no service worker there).
+ */
+export function NotificationActionsHost() {
+ const t = useTranslations('pwaNotify');
+ const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
+ const archiveMode = useSettingsStore((s) => s.archiveMode);
+ const archiveEnabled = useArchiveActionEnabled();
+ const archive = t('action_archive');
+ const markRead = t('action_mark_read');
+ const reply = t('action_reply');
+ const replyPlaceholder = t('reply_placeholder');
+
+ // Signing out deletes the worker's store, so signing in writes it again.
+ useEffect(() => {
+ if (!isAuthenticated) return;
+ void writeNotificationActionPrefs({
+ archiveEnabled,
+ archiveMode,
+ labels: { archive, markRead, reply, replyPlaceholder },
+ });
+ }, [isAuthenticated, archiveEnabled, archiveMode, archive, markRead, reply, replyPlaceholder]);
+
+ useEffect(() => {
+ const worker = navigator.serviceWorker;
+ if (!worker) return;
+ const onMessage = (event: MessageEvent) => {
+ const message = event.data as { kind?: string } | null;
+ if (message?.kind === 'outbox-flush') {
+ scheduleFlushAll(0);
+ return;
+ }
+ if (message?.kind !== 'notification-action' || !isNotificationActionRequest(message)) return;
+ const port = event.ports[0];
+ void runNotificationAction(message, (status) => port?.postMessage({ status }));
+ };
+ worker.addEventListener('message', onMessage);
+ return () => worker.removeEventListener('message', onMessage);
+ }, []);
+
+ return null;
+}
diff --git a/integration/tests/23-do-pwa-notify.spec.ts b/integration/tests/23-do-pwa-notify.spec.ts
new file mode 100644
index 000000000..99298571c
--- /dev/null
+++ b/integration/tests/23-do-pwa-notify.spec.ts
@@ -0,0 +1,480 @@
+import { test, expect, type BrowserContext, type Page, type Worker } from '@playwright/test';
+import { JMAP_URL } from './helpers/config';
+import { ENT, deliver, jmapAs, requireEntEnv, userPage } from './helpers/ent';
+
+/**
+ * A notification's Archive, Mark read and Reply, and Background Sync, in
+ * Chromium with the real service worker of a production build, the real
+ * /api/push/preview and /api/push/action, and a VM 106 test Stalwart.
+ *
+ * Headless Chromium has no toasts to click, and a page can't make a browser
+ * send a trusted notificationclick. So the spec dispatches the event inside
+ * the real worker, on its real listener: an Event carrying `action`, the
+ * `notification` the browser really shows, and a waitUntil of its own
+ * (ExtendableEvent.waitUntil refuses a synthetic event). Everything after
+ * that is real: matchAll, postMessage to the page, the cookies on the route.
+ * What can't run this way is openWindow, focus and navigate, which want the
+ * browser's user activation, so those are recorded on the worker's side and
+ * the spec then opens the recorded URL itself.
+ *
+ * The account is ent-dave, whom the other specs leave alone: the one-message
+ * notification needs an Inbox with exactly one unread message.
+ */
+
+test.describe.configure({ mode: 'serial' });
+test.setTimeout(180_000);
+// The headless shell denies notifications whatever is granted; full Chromium
+// in headless mode really shows them, buttons and all, to getNotifications.
+test.use({ channel: 'chromium' });
+
+const RUN = Date.now().toString(36);
+let serial = 0;
+const USER = ENT.dave;
+
+interface ShownNotification {
+ title: string;
+ data: Record;
+ actions: Array<{ action: string; title: string; type?: string }>;
+}
+
+async function workerOf(context: BrowserContext, page: Page): Promise {
+ // A production build registers the worker; the dev server removes it.
+ await page.evaluate(() => navigator.serviceWorker.ready.then(() => undefined));
+ await expect.poll(() => context.serviceWorkers().length, { timeout: 30_000 }).toBeGreaterThan(0);
+ const sw = context.serviceWorkers().at(-1)!;
+ // What needs the user activation a dispatched click doesn't have.
+ await sw.evaluate(() => {
+ const self_ = globalThis as unknown as { __opened: string[]; __posts: string[]; clients: Record; fetch: typeof fetch };
+ if (self_.__opened) return;
+ self_.__opened = [];
+ self_.__posts = [];
+ self_.clients.openWindow = async (url: string) => { self_.__opened.push(url); return null; };
+ const realFetch = self_.fetch.bind(globalThis);
+ self_.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
+ if (init?.method === 'POST') self_.__posts.push(String(input));
+ return realFetch(input, init);
+ }) as typeof fetch;
+ });
+ return sw;
+}
+
+/** The real listener, with what the browser would pass. Resolves when its waitUntil settled. */
+async function click(sw: Worker, action: string, data: Record, reply?: string): Promise {
+ await sw.evaluate(async ({ action: a, data: d, reply: r }) => {
+ const waits: Promise[] = [];
+ const event = new Event('notificationclick') as Event & Record;
+ Object.assign(event, {
+ action: a,
+ reply: r ?? null,
+ notification: { data: d, tag: 'spec', close() {} },
+ waitUntil: (p: Promise) => waits.push(p),
+ });
+ self.dispatchEvent(event);
+ await Promise.all(waits);
+ }, { action, data, reply });
+}
+
+async function pushed(sw: Worker, accountId: string, emailId: string): Promise {
+ return sw.evaluate(async ({ accountId: account, emailId: id }) => {
+ const waits: Promise[] = [];
+ const event = new Event('push') as Event & Record;
+ Object.assign(event, {
+ data: { json: () => ({ kind: 'jmap-email-push', accountId: account, emailIds: [id] }) },
+ waitUntil: (p: Promise) => waits.push(p),
+ });
+ self.dispatchEvent(event);
+ await Promise.all(waits);
+ const registration = (self as unknown as { registration: ServiceWorkerRegistration }).registration;
+ const [shown] = await registration.getNotifications({ tag: `bulwark-mail:${account}` });
+ return { title: shown.title, data: shown.data, actions: [...(shown as unknown as { actions: ShownNotification['actions'] }).actions] };
+ }, { accountId, emailId });
+}
+
+async function opened(sw: Worker): Promise {
+ return sw.evaluate(() => (globalThis as unknown as { __opened: string[] }).__opened.splice(0));
+}
+
+async function routePosts(sw: Worker): Promise {
+ return sw.evaluate(() => (globalThis as unknown as { __posts: string[] }).__posts.splice(0));
+}
+
+/** Everything unread in the Inbox read, then one new message: what makes a notification name it. */
+async function oneUnread(): Promise<{ id: string; subject: string; accountId: string }> {
+ const client = await jmapAs(USER);
+ const inbox = await client.mailboxByRole('inbox');
+ const query = await client.request([
+ ['Email/query', { accountId: client.accountId, filter: { inMailbox: inbox!.id, notKeyword: '$seen' } }, 'q'],
+ ]);
+ const unread: string[] = query.methodResponses[0][1].ids;
+ if (unread.length > 0) {
+ await client.request([['Email/set', {
+ accountId: client.accountId,
+ update: Object.fromEntries(unread.map((id) => [id, { 'keywords/$seen': true }])),
+ }, 's']]);
+ }
+ const subject = `Notify ${RUN} ${++serial}`;
+ await deliver({ from: ENT.bob, to: [USER], subject, text: 'Please answer.' });
+ const email = await client.waitForEmail(subject, { mailboxId: inbox!.id, timeoutMs: 30_000 });
+ return { id: email.id, subject, accountId: client.accountId };
+}
+
+async function serverCopy(emailId: string): Promise<{ keywords: Record; mailboxIds: Record; receivedAt: string }> {
+ const client = await jmapAs(USER);
+ const res = await client.request([['Email/get', { accountId: client.accountId, ids: [emailId], properties: ['keywords', 'mailboxIds', 'receivedAt'] }, 'g']]);
+ return res.methodResponses[0][1].list[0];
+}
+
+async function archiveFolderId(): Promise {
+ const client = await jmapAs(USER);
+ return (await client.mailboxByRole('archive'))?.id;
+}
+
+test.beforeAll(() => requireEntEnv());
+
+test.describe('with the app open', () => {
+ let page: Page;
+ let context: BrowserContext;
+ let sw: Worker;
+
+ test.beforeAll(async ({ browser }) => {
+ page = await userPage(browser, USER);
+ context = page.context();
+ await context.grantPermissions(['notifications']);
+ sw = await workerOf(context, page);
+ // The mirror reaches the worker's store after the app has started.
+ await expect.poll(() => sw.evaluate(async () => {
+ const cache = await caches.open('bulwark-push-state-v1');
+ const hit = await cache.match(`${self.location.origin}/__push-state/actions`);
+ return hit ? (await hit.json()).labels?.archive : null;
+ }), { timeout: 30_000 }).toBe('Archive');
+ });
+
+ test.afterAll(async () => {
+ await context.close();
+ });
+
+ test('a push for one message shows the buttons the browser has room for', async () => {
+ const mail = await oneUnread();
+ const shown = await pushed(sw, mail.accountId, mail.id);
+ expect(shown.title).toContain(ENT.bob.split('@')[0]);
+ expect(shown.data).toMatchObject({ kind: 'email', emailId: mail.id, accountId: mail.accountId });
+ expect(typeof shown.data.login).toBe('string');
+ const max = await sw.evaluate(() => (Notification as unknown as { maxActions: number }).maxActions);
+ const names = shown.actions.map((a) => a.action);
+ expect(names.length).toBe(Math.min(3, max));
+ expect(names).toContain('reply');
+ expect(names).toContain('archive');
+ expect(shown.actions.find((a) => a.action === 'archive')?.title).toBe('Archive');
+ // Chromium has `reply` on the event: the text field.
+ expect(shown.actions.find((a) => a.action === 'reply')?.type).toBe('text');
+ });
+
+ test('a group of messages gets no buttons', async () => {
+ const client = await jmapAs(USER);
+ const first = await oneUnread();
+ const subject = `Notify ${RUN} second`;
+ await deliver({ from: ENT.bob, to: [USER], subject, text: 'Another.' });
+ const second = await client.waitForEmail(subject, { timeoutMs: 30_000 });
+ const shown = await pushed(sw, first.accountId, second.id);
+ expect(shown.data.kind).toBe('mail-list');
+ expect(shown.actions).toEqual([]);
+ });
+
+ test('Mark read goes to the open tab, which marks it in the list too, not to the route', async () => {
+ const mail = await oneUnread();
+ const shown = await pushed(sw, mail.accountId, mail.id);
+ await routePosts(sw);
+ await click(sw, 'mark-read', shown.data);
+ await expect.poll(async () => (await serverCopy(mail.id)).keywords.$seen === true, { timeout: 15_000 }).toBe(true);
+ expect(await routePosts(sw)).toEqual([]);
+ expect(await opened(sw)).toEqual([]);
+ });
+
+ test('Archive goes to the open tab and files the message by the store', async () => {
+ const mail = await oneUnread();
+ const shown = await pushed(sw, mail.accountId, mail.id);
+ await routePosts(sw);
+ await click(sw, 'archive', shown.data);
+ const client = await jmapAs(USER);
+ const inbox = await client.mailboxByRole('inbox');
+ await expect.poll(async () => !(await serverCopy(mail.id)).mailboxIds[inbox!.id], { timeout: 20_000 }).toBe(true);
+ const archive = await archiveFolderId();
+ expect(archive).toBeTruthy();
+ expect(Object.keys((await serverCopy(mail.id)).mailboxIds)).toEqual([archive]);
+ expect(await routePosts(sw)).toEqual([]);
+ });
+
+ test('a body click still opens the message and runs nothing', async () => {
+ const mail = await oneUnread();
+ const shown = await pushed(sw, mail.accountId, mail.id);
+ await routePosts(sw);
+ // focus() needs the activation a dispatched click lacks, so this ends in openWindow.
+ await click(sw, '', shown.data);
+ const urls = await opened(sw);
+ expect(urls).toHaveLength(1);
+ expect(urls[0]).toContain(`/mail/message/${mail.id}`);
+ expect(urls[0]).not.toContain('reply=1');
+ expect(await routePosts(sw)).toEqual([]);
+ expect((await serverCopy(mail.id)).keywords.$seen).toBeFalsy();
+ });
+
+ test('Reply opens the composer in the open tab, with the text typed on the notification', async () => {
+ const mail = await oneUnread();
+ const shown = await pushed(sw, mail.accountId, mail.id);
+ await routePosts(sw);
+ await click(sw, 'reply', shown.data, `Typed on the toast ${RUN}`);
+ await expect.poll(async () => page.evaluate((subjectText) => {
+ const inputs = [...document.querySelectorAll('input')] as HTMLInputElement[];
+ return inputs.some((input) => input.value === `Re: ${subjectText}`);
+ }, mail.subject), { timeout: 20_000 }).toBe(true);
+ await expect(page.getByText(`Typed on the toast ${RUN}`).first()).toBeVisible({ timeout: 10_000 });
+ expect(await routePosts(sw)).toEqual([]);
+ expect(await opened(sw)).toEqual([]);
+ });
+});
+
+test.describe('Background Sync', () => {
+ test('a queued change registers the tag, and a sync event makes a tab that missed `online` flush it', async ({ browser }) => {
+ const page = await userPage(browser, USER);
+ const context = page.context();
+ await context.grantPermissions(['notifications', 'background-sync']);
+ const sw = await workerOf(context, page);
+ await page.addInitScript(() => {
+ const w = window as unknown as { __syncTags: string[] };
+ w.__syncTags = [];
+ const register = SyncManager.prototype.register;
+ SyncManager.prototype.register = function patched(tag: string) {
+ w.__syncTags.push(tag);
+ return register.call(this, tag);
+ };
+ });
+ await page.goto('/settings/backup/offline');
+ const toggle = page.getByTestId('offline-switch');
+ await expect(toggle).toBeVisible({ timeout: 30_000 });
+ if ((await toggle.getAttribute('aria-checked')) !== 'true') await toggle.click();
+ await expect(page.getByTestId('offline-status')).toContainText('updated', { timeout: 180_000 });
+ await page.goto('/mail');
+ await expect(page.locator('[data-testid="email-list-item"]').first()).toBeVisible({ timeout: 60_000 });
+
+ const mail = await oneUnread();
+ const shown = await pushed(sw, mail.accountId, mail.id);
+
+ // The mail server is gone, the browser still online: no `online` event will come.
+ const block = `${new URL(JMAP_URL).origin}/**`;
+ await context.route(block, (route) => route.abort('internetdisconnected'));
+ await page.keyboard.press('Shift+G');
+ await expect(page.getByTestId('offline-banner')).toContainText("The mail server can't be reached", { timeout: 60_000 });
+
+ // The tab queues the change, as it does for its own Mark read button.
+ await click(sw, 'mark-read', shown.data);
+ await expect.poll(() => page.evaluate(() => (window as unknown as { __syncTags: string[] }).__syncTags)).toContain('bulwark-outbox');
+ expect((await serverCopy(mail.id)).keywords.$seen).toBeFalsy();
+
+ await context.unroute(block);
+ await sw.evaluate(async () => {
+ const waits: Promise[] = [];
+ const event = new Event('sync') as Event & Record;
+ Object.assign(event, { tag: 'bulwark-outbox', waitUntil: (p: Promise) => waits.push(p) });
+ self.dispatchEvent(event);
+ await Promise.all(waits);
+ });
+ await expect.poll(async () => (await serverCopy(mail.id)).keywords.$seen === true, { timeout: 30_000 }).toBe(true);
+
+ // Offline mode stays local to this context, which ends here.
+ await context.close();
+ });
+});
+
+test.describe('with no tab open', () => {
+ let page: Page;
+ let context: BrowserContext;
+ let sw: Worker;
+
+ /** A blank tab keeps the context alive: it's outside the worker's scope. */
+ async function closeTheApp(): Promise {
+ const blank = await context.newPage();
+ await page.close();
+ await expect.poll(() => sw.evaluate(async () => (await (self as unknown as { clients: Clients }).clients.matchAll({ type: 'window', includeUncontrolled: true })).length)).toBe(0);
+ return blank;
+ }
+
+ test.beforeEach(async ({ browser }) => {
+ page = await userPage(browser, USER);
+ context = page.context();
+ await context.grantPermissions(['notifications']);
+ sw = await workerOf(context, page);
+ await expect.poll(() => sw.evaluate(async () => {
+ const cache = await caches.open('bulwark-push-state-v1');
+ return !!(await cache.match(`${self.location.origin}/__push-state/actions`));
+ }), { timeout: 30_000 }).toBe(true);
+ });
+
+ test.afterEach(async () => {
+ await context.close();
+ });
+
+ test('Mark read goes through the route on the context cookie', async () => {
+ const mail = await oneUnread();
+ const shown = await pushed(sw, mail.accountId, mail.id);
+ await closeTheApp();
+ await routePosts(sw);
+ await click(sw, 'mark-read', shown.data);
+ expect((await routePosts(sw)).at(-1)).toContain('/api/push/action');
+ expect((await serverCopy(mail.id)).keywords.$seen).toBe(true);
+ expect(await opened(sw)).toEqual([]);
+ });
+
+ test('Archive goes through the route, to the folder the app uses', async () => {
+ const mail = await oneUnread();
+ const shown = await pushed(sw, mail.accountId, mail.id);
+ await closeTheApp();
+ await click(sw, 'archive', shown.data);
+ const archive = await archiveFolderId();
+ expect(archive).toBeTruthy();
+ expect(Object.keys((await serverCopy(mail.id)).mailboxIds)).toEqual([archive]);
+ expect(await opened(sw)).toEqual([]);
+ });
+
+ test('Archive follows the mirrored mode into year and month folders', async () => {
+ const mail = await oneUnread();
+ const shown = await pushed(sw, mail.accountId, mail.id);
+ await sw.evaluate(async () => {
+ const cache = await caches.open('bulwark-push-state-v1');
+ const key = `${self.location.origin}/__push-state/actions`;
+ const prefs = await (await cache.match(key))!.json();
+ await cache.put(key, new Response(JSON.stringify({ ...prefs, archiveMode: 'month' })));
+ });
+ await closeTheApp();
+ await click(sw, 'archive', shown.data);
+ const client = await jmapAs(USER);
+ const copy = await serverCopy(mail.id);
+ const [folderId] = Object.keys(copy.mailboxIds);
+ const folders = await client.mailboxes();
+ const month = folders.find((f) => f.id === folderId)!;
+ const year = folders.find((f) => f.id === month.parentId)!;
+ const when = new Date(copy.receivedAt);
+ expect(month.name).toBe(String(when.getMonth() + 1).padStart(2, '0'));
+ expect(year.name).toBe(String(when.getFullYear()));
+ expect(year.parentId).toBe(await archiveFolderId());
+ });
+
+ test('Archive makes the Archive, year and month folders on an account without them', async () => {
+ const client = await jmapAs(USER);
+ const mail = await oneUnread();
+ const shown = await pushed(sw, mail.accountId, mail.id);
+ await sw.evaluate(async () => {
+ const cache = await caches.open('bulwark-push-state-v1');
+ const key = `${self.location.origin}/__push-state/actions`;
+ const prefs = await (await cache.match(key))!.json();
+ await cache.put(key, new Response(JSON.stringify({ ...prefs, archiveMode: 'month' })));
+ });
+ await closeTheApp();
+ // Out of the way, so the account has no Archive for the moment.
+ const old = await client.mailboxByRole('archive');
+ const oldName = `Old archive ${RUN}`;
+ if (old) {
+ const renamed = await client.request([['Mailbox/set', { accountId: client.accountId, update: { [old.id]: { name: oldName, role: null } } }, 'r']]);
+ expect(renamed.methodResponses[0][1].updated).toHaveProperty(old.id);
+ }
+
+ await click(sw, 'archive', shown.data);
+
+ expect(await opened(sw)).toEqual([]);
+ const created = await client.mailboxByRole('archive');
+ expect(created?.name).toBe('Archive');
+ expect(created?.id).not.toBe(old?.id);
+ const copy = await serverCopy(mail.id);
+ const folders = await client.mailboxes();
+ const month = folders.find((f) => f.id === Object.keys(copy.mailboxIds)[0])!;
+ const year = folders.find((f) => f.id === month.parentId)!;
+ expect(year.parentId).toBe(created!.id);
+
+ if (old) {
+ // Only this spec's messages live in ent-dave's Archive.
+ await client.request([['Mailbox/set', { accountId: client.accountId, destroy: [old.id], onDestroyRemoveEmails: true }, 'd']]);
+ }
+ });
+
+ test('with the context cookie gone the message opens instead, and stays as it was', async () => {
+ const mail = await oneUnread();
+ const shown = await pushed(sw, mail.accountId, mail.id);
+ await closeTheApp();
+ const jar = await context.cookies();
+ await context.clearCookies({ name: /^jmap_stalwart_ctx/ });
+ expect(jar.some((c) => c.name.startsWith('jmap_stalwart_ctx'))).toBe(true);
+ await routePosts(sw);
+ await click(sw, 'mark-read', shown.data);
+ expect((await routePosts(sw)).at(-1)).toContain('/api/push/action');
+ const urls = await opened(sw);
+ expect(urls).toHaveLength(1);
+ expect(urls[0]).toContain(`/mail/message/${mail.id}`);
+ expect((await serverCopy(mail.id)).keywords.$seen).toBeFalsy();
+ });
+
+ test('a notification from a login no longer signed in acts on nothing', async () => {
+ const mail = await oneUnread();
+ const shown = await pushed(sw, mail.accountId, mail.id);
+ await closeTheApp();
+ await click(sw, 'mark-read', { ...shown.data, login: 'someone-else' });
+ expect(await opened(sw)).toHaveLength(1);
+ expect((await serverCopy(mail.id)).keywords.$seen).toBeFalsy();
+ });
+
+ test('Reply opens the link, the composer opens once with the typed text, and a reload leaves it shut', async () => {
+ const mail = await oneUnread();
+ const shown = await pushed(sw, mail.accountId, mail.id);
+ const blank = await closeTheApp();
+ await click(sw, 'reply', shown.data, `Typed while closed ${RUN}`);
+ const [url] = await opened(sw);
+ expect(url).toContain(`/mail/message/${mail.id}`);
+ expect(url).toContain('reply=1');
+ expect(url).not.toContain('Typed');
+
+ page = await context.newPage();
+ await page.goto(url);
+ await blank.close();
+ const replyInput = () => page.evaluate((subjectText) => {
+ const inputs = [...document.querySelectorAll('input')] as HTMLInputElement[];
+ return inputs.some((input) => input.value === `Re: ${subjectText}`);
+ }, mail.subject);
+ await expect.poll(replyInput, { timeout: 45_000 }).toBe(true);
+ await expect(page.getByText(`Typed while closed ${RUN}`).first()).toBeVisible({ timeout: 10_000 });
+ expect(page.url()).not.toContain('reply=1');
+
+ // Leave the draft and come back: the link's job is done.
+ await page.reload();
+ await expect(page.getByText('Please answer.').first()).toBeVisible({ timeout: 45_000 });
+ expect(await replyInput()).toBe(false);
+ });
+});
+
+test.describe('the route, as a page or another site would call it', () => {
+ test('refuses a cross-site POST at the gate, with the cookies on it', async ({ browser }) => {
+ const page = await userPage(browser, USER);
+ const mail = await oneUnread();
+ const res = await page.request.post('/api/push/action', {
+ headers: { 'Sec-Fetch-Site': 'cross-site' },
+ data: { action: 'mark-read', accountId: mail.accountId, emailId: mail.id, login: 'x' },
+ });
+ expect(res.status()).toBe(403);
+ expect((await serverCopy(mail.id)).keywords.$seen).toBeFalsy();
+ await page.context().close();
+ });
+
+ test('refuses another account of the same login, and answers 401 without cookies', async ({ browser }) => {
+ const page = await userPage(browser, USER);
+ await page.context().grantPermissions(['notifications']);
+ const sw = await workerOf(page.context(), page);
+ const mail = await oneUnread();
+ const shown = await pushed(sw, mail.accountId, mail.id);
+ const post = (over: Record) => page.request.post('/api/push/action', {
+ data: { action: 'mark-read', accountId: mail.accountId, emailId: mail.id, login: shown.data.login, ...over },
+ });
+ expect((await post({ accountId: 'zzzzzz' })).status()).toBe(403);
+ expect((await serverCopy(mail.id)).keywords.$seen).toBeFalsy();
+ await page.context().clearCookies({ name: /^jmap_stalwart_ctx/ });
+ expect((await post({})).status()).toBe(401);
+ expect((await serverCopy(mail.id)).keywords.$seen).toBeFalsy();
+ await page.context().close();
+ });
+});
diff --git a/lib/__tests__/deep-links.test.ts b/lib/__tests__/deep-links.test.ts
index c9696f162..0dc59db84 100644
--- a/lib/__tests__/deep-links.test.ts
+++ b/lib/__tests__/deep-links.test.ts
@@ -185,6 +185,16 @@ describe('mail paths', () => {
expect(parseMailPath(['thread', 't1'])).toEqual({ kind: 'thread', id: 't1', accountId: undefined });
});
+ it("reads a notification's reply flag on a message link, and only there", () => {
+ expect(parseMailPath(['message', 'm1'], new URLSearchParams('slot=1&owner=a&reply=1'))).toMatchObject({
+ kind: 'message', id: 'm1', slot: 1, ownerAccountId: 'a', reply: true,
+ });
+ expect(parseMailPath(['message', 'm1'], new URLSearchParams('reply=0'))).not.toHaveProperty('reply');
+ expect(parseMailPath(['message', 'm1'])).not.toHaveProperty('reply');
+ expect(parseMailPath(['folder', 'inbox'], new URLSearchParams('reply=1'))).not.toHaveProperty('reply');
+ expect(parseMailPath(['thread', 't1'], new URLSearchParams('reply=1'))).not.toHaveProperty('reply');
+ });
+
it('reads the fullscreen marker on message links', () => {
expect(parseMailPath(['message', 'm1'], new URLSearchParams('view=fullscreen'))).toEqual({
kind: 'message',
diff --git a/lib/__tests__/push-action-route.test.ts b/lib/__tests__/push-action-route.test.ts
new file mode 100644
index 000000000..3658b03b2
--- /dev/null
+++ b/lib/__tests__/push-action-route.test.ts
@@ -0,0 +1,313 @@
+// @vitest-environment node
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { NextRequest } from 'next/server';
+import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint';
+
+// POST /api/push/action: a notification's Mark read and Archive with no tab
+// open. Every refusal must leave the mail alone: the worker opens the message
+// in the app instead.
+
+vi.mock('next/headers', () => ({ cookies: async () => ({ get: () => undefined }) }));
+vi.mock('@/lib/logger', () => ({ logger: { warn: vi.fn(), error: vi.fn(), debug: vi.fn(), info: vi.fn() } }));
+vi.mock('@/lib/account-utils', () => ({ MAX_ACCOUNT_SLOTS: 3 }));
+
+type Login = { serverUrl: string; username: string; authHeader: string };
+let LOGINS: Array = [];
+vi.mock('@/lib/stalwart/auth-context', () => ({
+ readStalwartAuthContextFromStore: (_store: unknown, slot: number) => LOGINS[slot] ?? null,
+}));
+
+const ALICE: Login = { serverUrl: 'https://one.example/', username: 'alice@example.org', authHeader: 'Bearer alice' };
+const BOB: Login = { serverUrl: 'https://one.example', username: 'bob@example.org', authHeader: 'Bearer bob' };
+
+let policy: Record = {};
+vi.mock('@/lib/admin/config-manager', () => ({
+ configManager: { ensureLoaded: async () => {}, getPolicy: () => policy },
+}));
+let subject: { email: string; groups: string[] } | null = { email: 'alice@example.org', groups: [] };
+vi.mock('@/lib/admin/server-device-policy', () => ({ strictDeviceSubject: async () => subject }));
+
+type Calls = Array<[string, Record, string]>;
+const writes: Array<{ auth: string; url: string; calls: Calls }> = [];
+let session: Record;
+let sessionStatus = 200;
+let mailboxes: Array>;
+let email: Record | null;
+let emailSetResult: Record | null = null;
+let sessionsAsked = 0;
+
+const json = (data: unknown, status = 200) => new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json' } });
+vi.mock('@/lib/stalwart/server-fetch', () => ({
+ isTrustedJmapServerUrl: async () => true,
+ fetchJmapServer: async (url: string, init?: { headers?: Record; body?: string }) => {
+ if (url.endsWith('/.well-known/jmap')) {
+ sessionsAsked++;
+ return sessionStatus === 200 ? json(session) : new Response('', { status: sessionStatus });
+ }
+ const calls = (JSON.parse(init?.body ?? '{}') as { methodCalls: Calls }).methodCalls;
+ if (calls.some(([name]) => name.endsWith('/set'))) {
+ writes.push({ auth: init?.headers?.Authorization ?? '', url, calls });
+ }
+ return json({
+ methodResponses: calls.map(([name, args, id]) => {
+ if (name === 'Mailbox/get') return [name, { list: mailboxes }, id];
+ if (name === 'Email/get') return [name, { list: email ? [email] : [] }, id];
+ if (name === 'Mailbox/set') return [name, { created: {} }, id];
+ const emailId = Object.keys(args.update as Record)[0];
+ return [name, emailSetResult ?? { updated: { [emailId]: null } }, id];
+ }),
+ });
+ },
+}));
+
+const INBOX = { id: 'inbox', name: 'Inbox', role: 'inbox' };
+const ARCHIVE = { id: 'arch', name: 'Archive', role: 'archive' };
+
+async function call(body: Record, headers: Record = { 'sec-fetch-site': 'same-origin' }) {
+ const { POST } = await import('@/app/api/push/action/route');
+ const res = await POST(new NextRequest('https://mail.example.com/api/push/action', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', ...headers },
+ body: JSON.stringify(body),
+ }));
+ return { status: res.status, body: await res.json() };
+}
+
+const fingerprint = (login: Login) => loginFingerprint(login.serverUrl, login.username);
+const markRead = async (over: Record = {}) => ({
+ action: 'mark-read', accountId: 'a', emailId: 'e1', login: await fingerprint(ALICE), ...over,
+});
+const archive = async (over: Record = {}) => ({ ...(await markRead()), action: 'archive', ...over });
+const emailSet = () => {
+ const call = writes[0].calls.find(([name]) => name === 'Email/set');
+ return call?.[1] as { accountId: string; update: Record> };
+};
+const mailboxSet = () => writes[0].calls.find(([name]) => name === 'Mailbox/set')?.[1] as { create: Record> } | undefined;
+
+beforeEach(() => {
+ LOGINS = [ALICE];
+ policy = {};
+ subject = { email: 'alice@example.org', groups: [] };
+ writes.length = 0;
+ sessionsAsked = 0;
+ sessionStatus = 200;
+ session = {
+ apiUrl: 'https://one.example/jmap/',
+ primaryAccounts: { 'urn:ietf:params:jmap:mail': 'a' },
+ accounts: { a: { name: 'alice@example.org', accountCapabilities: { 'urn:ietf:params:jmap:mail': {} } } },
+ };
+ mailboxes = [INBOX, ARCHIVE];
+ email = { id: 'e1', receivedAt: '2026-03-31T23:30:00Z' };
+ emailSetResult = null;
+});
+
+describe('who may ask', () => {
+ it('refuses a request from another site before reading anything', async () => {
+ const res = await call(await markRead(), { 'sec-fetch-site': 'same-site' });
+ expect(res.status).toBe(403);
+ expect(sessionsAsked).toBe(0);
+ expect(writes).toHaveLength(0);
+ });
+
+ it('accepts what a service worker sends', async () => {
+ expect((await call(await markRead())).status).toBe(200);
+ expect(writes).toHaveLength(1);
+ });
+
+ it.each([
+ ['an unknown action', { action: 'delete' }],
+ ['an id that is no JMAP id', { emailId: '../../x' }],
+ ['no account', { accountId: undefined }],
+ ['no login', { login: undefined }],
+ ])('refuses %s', async (_name, over) => {
+ const res = await call(await markRead(over));
+ expect(res.status).toBe(400);
+ expect(sessionsAsked).toBe(0);
+ });
+});
+
+describe('which login', () => {
+ it('answers 401 with no cookie, without asking the mail server', async () => {
+ LOGINS = [];
+ expect((await call(await markRead())).status).toBe(401);
+ expect(sessionsAsked).toBe(0);
+ expect(writes).toHaveLength(0);
+ });
+
+ it('answers 401 when the slot went to someone else since the notification', async () => {
+ // Account ids repeat, so bob's session would accept account `a` as well.
+ LOGINS = [BOB];
+ expect((await call(await markRead())).status).toBe(401);
+ expect(sessionsAsked).toBe(0);
+ expect(writes).toHaveLength(0);
+ });
+
+ it('finds the login in whichever slot it holds now and uses its credential', async () => {
+ LOGINS = [BOB, null, ALICE];
+ expect((await call(await markRead())).status).toBe(200);
+ expect(writes[0].auth).toBe('Bearer alice');
+ });
+
+ it('answers 409 when the login is in two slots', async () => {
+ LOGINS = [ALICE, { ...ALICE, authHeader: 'Bearer stale' }];
+ expect((await call(await markRead())).status).toBe(409);
+ expect(writes).toHaveLength(0);
+ });
+
+ it('answers 401 for an expired token: only a page refreshes it', async () => {
+ sessionStatus = 401;
+ expect((await call(await markRead())).status).toBe(401);
+ expect(writes).toHaveLength(0);
+ });
+
+ it('answers 502 when the mail server is down, which says nothing about the token', async () => {
+ sessionStatus = 503;
+ expect((await call(await markRead())).status).toBe(502);
+ expect(writes).toHaveLength(0);
+ });
+
+ it('refuses an account this login has no part in', async () => {
+ const res = await call(await markRead({ accountId: 'stranger' }));
+ expect(res.status).toBe(403);
+ expect(writes).toHaveLength(0);
+ });
+
+ it("doesn't take a name from Object.prototype for an account", async () => {
+ const res = await call(await markRead({ accountId: 'constructor' }));
+ expect(res.status).toBe(403);
+ expect(writes).toHaveLength(0);
+ });
+});
+
+describe('Mark read', () => {
+ it('sets $seen on that message, at the rebased API address', async () => {
+ expect(await call(await markRead())).toEqual({ status: 200, body: { ok: true } });
+ expect(writes[0].url).toBe('https://one.example/jmap/');
+ expect(emailSet()).toEqual({ accountId: 'a', update: { e1: { 'keywords/$seen': true } } });
+ expect(mailboxSet()).toBeUndefined();
+ });
+
+ it('acts in a shared account the login reaches', async () => {
+ session.accounts = { ...(session.accounts as object), team: { name: 'team@example.org' } };
+ expect((await call(await markRead({ accountId: 'team' }))).status).toBe(200);
+ expect(emailSet().accountId).toBe('team');
+ });
+
+ it('answers 404 for a message that is gone', async () => {
+ emailSetResult = { notUpdated: { e1: { type: 'notFound' } } };
+ expect((await call(await markRead())).status).toBe(404);
+ });
+
+ it('answers 502 when the server refuses', async () => {
+ emailSetResult = { notUpdated: { e1: { type: 'forbidden' } } };
+ expect((await call(await markRead())).status).toBe(502);
+ });
+});
+
+describe('Archive', () => {
+ it('moves the message into the Archive folder', async () => {
+ expect((await call(await archive())).status).toBe(200);
+ expect(emailSet().update.e1).toEqual({ mailboxIds: { arch: true } });
+ expect(mailboxSet()).toBeUndefined();
+ });
+
+ it('finds an Archive by name when no folder has the role, as the app does', async () => {
+ mailboxes = [INBOX, { id: 'named', name: 'archive' }];
+ await call(await archive());
+ expect(emailSet().update.e1).toEqual({ mailboxIds: { named: true } });
+ });
+
+ it('creates the Archive folder when the account has none, in the same request', async () => {
+ mailboxes = [INBOX];
+ expect((await call(await archive())).status).toBe(200);
+ expect(mailboxSet()?.create).toEqual({ archive: { name: 'Archive', parentId: null, role: 'archive' } });
+ expect(emailSet().update.e1).toEqual({ mailboxIds: { '#archive': true } });
+ });
+
+ it("doesn't try when the account may not create one", async () => {
+ mailboxes = [INBOX];
+ session.accounts = { a: { accountCapabilities: { 'urn:ietf:params:jmap:mail': { mayCreateTopLevelMailbox: false } } } };
+ expect((await call(await archive())).status).toBe(403);
+ expect(writes).toHaveLength(0);
+ });
+
+ it("doesn't try an Archive that takes no mail", async () => {
+ mailboxes = [INBOX, { ...ARCHIVE, myRights: { mayAddItems: false } }];
+ expect((await call(await archive())).status).toBe(403);
+ expect(writes).toHaveLength(0);
+ });
+
+ it('files by year into an existing year folder', async () => {
+ mailboxes = [INBOX, ARCHIVE, { id: 'y26', name: '2026', parentId: 'arch' }];
+ await call(await archive({ archiveMode: 'year', timeZone: 'UTC' }));
+ expect(emailSet().update.e1).toEqual({ mailboxIds: { y26: true } });
+ expect(mailboxSet()).toBeUndefined();
+ });
+
+ it('ignores a folder named like the year elsewhere', async () => {
+ mailboxes = [INBOX, ARCHIVE, { id: 'other', name: '2026', parentId: 'inbox' }];
+ await call(await archive({ archiveMode: 'year', timeZone: 'UTC' }));
+ expect(mailboxSet()?.create).toEqual({ year: { name: '2026', parentId: 'arch' } });
+ expect(emailSet().update.e1).toEqual({ mailboxIds: { '#year': true } });
+ });
+
+ it("files by month in the user's time zone, not the server's", async () => {
+ mailboxes = [INBOX, ARCHIVE, { id: 'y26', name: '2026', parentId: 'arch' }, { id: 'm03', name: '03', parentId: 'y26' }];
+ // 23:30 UTC on 31 March is 1 April in Berlin.
+ await call(await archive({ archiveMode: 'month', timeZone: 'Europe/Berlin' }));
+ expect(mailboxSet()?.create).toEqual({ month: { name: '04', parentId: 'y26' } });
+ expect(emailSet().update.e1).toEqual({ mailboxIds: { '#month': true } });
+
+ writes.length = 0;
+ await call(await archive({ archiveMode: 'month', timeZone: 'UTC' }));
+ expect(emailSet().update.e1).toEqual({ mailboxIds: { m03: true } });
+ });
+
+ it('creates the whole path in month mode on an account without an Archive', async () => {
+ mailboxes = [INBOX];
+ await call(await archive({ archiveMode: 'month', timeZone: 'UTC' }));
+ expect(mailboxSet()?.create).toEqual({
+ archive: { name: 'Archive', parentId: null, role: 'archive' },
+ year: { name: '2026', parentId: '#archive' },
+ month: { name: '03', parentId: '#year' },
+ });
+ expect(emailSet().update.e1).toEqual({ mailboxIds: { '#month': true } });
+ });
+
+ it('survives a time zone that is no time zone', async () => {
+ mailboxes = [INBOX, ARCHIVE, { id: 'y26', name: '2026', parentId: 'arch' }];
+ expect((await call(await archive({ archiveMode: 'year', timeZone: 'Mars/Olympus' }))).status).toBe(200);
+ expect(emailSet().update.e1).toEqual({ mailboxIds: { y26: true } });
+ });
+
+ it('answers 404 for a message that is gone', async () => {
+ email = null;
+ expect((await call(await archive())).status).toBe(404);
+ expect(writes).toHaveLength(0);
+ });
+
+ describe('the admin gate', () => {
+ it('refuses when the admin turned Archive off, whatever the worker believes', async () => {
+ policy = { features: { archiveActionEnabled: false } };
+ expect((await call(await archive())).status).toBe(403);
+ expect(writes).toHaveLength(0);
+ });
+
+ it('follows the scope this login is in', async () => {
+ policy = { scopes: [{ id: 'team', label: 'Team', match: { domains: ['example.org'] }, policy: { features: { archiveActionEnabled: false } } }] };
+ expect((await call(await archive())).status).toBe(403);
+ expect(writes).toHaveLength(0);
+ });
+
+ it("refuses when the login's scopes can't be worked out", async () => {
+ subject = null;
+ expect((await call(await archive())).status).toBe(403);
+ expect(writes).toHaveLength(0);
+ });
+
+ it('leaves Mark read alone', async () => {
+ policy = { features: { archiveActionEnabled: false } };
+ expect((await call(await markRead())).status).toBe(200);
+ });
+ });
+});
diff --git a/lib/__tests__/push-preview-route.test.ts b/lib/__tests__/push-preview-route.test.ts
index 3880595cb..a7abae5ca 100644
--- a/lib/__tests__/push-preview-route.test.ts
+++ b/lib/__tests__/push-preview-route.test.ts
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { endlessResponse } from './helpers/endless-body';
+import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint';
vi.mock('next/server', () => ({
NextResponse: {
@@ -112,6 +113,11 @@ describe('push preview route account resolution', () => {
expect(mailboxQuery?.methodCalls[0][1].accountId).toBe('g');
});
+ it('names the login for a message, as the action route knows it', async () => {
+ const { body } = await callRoute('a');
+ expect(body.login).toBe(await loginFingerprint('https://mail.example.com', undefined));
+ });
+
it('names the login (cookie slot) the account belongs to', async () => {
// The worker only knows the JMAP account id, which can repeat across
// servers; the click must open the message in this login.
@@ -186,7 +192,7 @@ describe('push preview JMAP failures', () => {
['Email/get', { list: [{ id: 'd', threadId: 'td' }] }, 'delivered'],
] }));
expect(await callRoute('a', 'd')).toEqual({
- status: 200, body: { email: { id: 'd', threadId: 'td' }, unreadTotal: 1, slot: 0 },
+ status: 200, body: { email: { id: 'd', threadId: 'td' }, unreadTotal: 1, slot: 0, login: expect.any(String) },
});
});
});
@@ -202,7 +208,7 @@ it('previews a delivered message outside an empty Inbox', async () => {
['Email/get', { list: [{ id: 'filed', threadId: 't2' }] }, 'delivered'],
] }));
expect(await callRoute('a', 'filed')).toEqual({
- status: 200, body: { email: { id: 'filed', threadId: 't2' }, unreadTotal: 1, slot: 0 },
+ status: 200, body: { email: { id: 'filed', threadId: 't2' }, unreadTotal: 1, slot: 0, login: expect.any(String) },
});
});
diff --git a/lib/__tests__/sw-harness.ts b/lib/__tests__/sw-harness.ts
index d54631a62..d945c3322 100644
--- a/lib/__tests__/sw-harness.ts
+++ b/lib/__tests__/sw-harness.ts
@@ -29,17 +29,40 @@ export class FakeCaches {
}
}
-export function loadWorker(opts: { buildId: string; caches: FakeCaches; clients: Array<{ id: string }>; fetch?: (input: unknown) => Promise }) {
+export interface ShownNotification {
+ title: string;
+ options: Record;
+}
+
+export function loadWorker(opts: {
+ buildId: string;
+ caches: FakeCaches;
+ clients: Array<{ id: string }>;
+ fetch?: (input: unknown, init?: RequestInit) => Promise;
+ /** More globals of a real worker: Notification, NotificationEvent, MessageChannel. */
+ globals?: Record;
+ openWindow?: (url: string) => Promise;
+ /** What showNotification does, after it was recorded in `shown`. */
+ showNotification?: (title: string, options: Record) => Promise;
+ notifications?: Array>;
+}) {
const listeners = new Map void>();
+ const shown: ShownNotification[] = [];
let skipped = 0;
const self = {
location: { href: `https://mail.example.com/sw.js?v=${opts.buildId}`, origin: 'https://mail.example.com' },
addEventListener: (type: string, fn: (event: unknown) => void) => listeners.set(type, fn),
skipWaiting: () => { skipped++; },
- clients: { claim: async () => {}, matchAll: async () => opts.clients },
- registration: { showNotification: async () => {}, getNotifications: async () => [] },
+ clients: { claim: async () => {}, matchAll: async () => opts.clients, openWindow: opts.openWindow },
+ registration: {
+ showNotification: async (title: string, options: Record) => {
+ shown.push({ title, options });
+ await opts.showNotification?.(title, options);
+ },
+ getNotifications: async () => opts.notifications ?? [],
+ },
};
- const context = vm.createContext({ self, caches: opts.caches, fetch: opts.fetch ?? (async () => { throw new TypeError('offline'); }), URL, URLSearchParams, Request, Response, Headers, Date, console, AbortController, setTimeout, clearTimeout });
+ const context = vm.createContext({ self, caches: opts.caches, fetch: opts.fetch ?? (async () => { throw new TypeError('offline'); }), URL, URLSearchParams, Request, Response, Headers, Date, console, AbortController, setTimeout, clearTimeout, ...opts.globals });
vm.runInContext(SW_SOURCE, context);
async function dispatch(type: string, event: Record = {}) {
const waits: Promise[] = [];
@@ -52,5 +75,5 @@ export function loadWorker(opts: { buildId: string; caches: FakeCaches; clients:
listeners.get('fetch')?.({ request: { method: 'GET', mode: 'cors', ...req }, respondWith: (p: Promise) => { answer = p; } });
return answer ? await answer : null;
}
- return { dispatch, listeners, request, skipped: () => skipped };
+ return { dispatch, listeners, request, shown, skipped: () => skipped };
}
diff --git a/lib/__tests__/sw-notification-actions.test.ts b/lib/__tests__/sw-notification-actions.test.ts
new file mode 100644
index 000000000..f452c79b3
--- /dev/null
+++ b/lib/__tests__/sw-notification-actions.test.ts
@@ -0,0 +1,363 @@
+import { describe, expect, it, vi } from 'vitest';
+import { FakeCaches, loadWorker } from './sw-harness';
+
+// A notification's Archive, Mark read and Reply buttons (public/sw.js). The
+// worker has no login: an open tab goes first, then the cookie route, then the
+// message opens in the app.
+
+const ORIGIN = 'https://mail.example.com';
+const PUSH_CACHE = 'bulwark-push-state-v1';
+const PREFS = {
+ archiveEnabled: true,
+ archiveMode: 'month',
+ labels: { archive: 'Archivieren', markRead: 'Als gelesen markieren', reply: 'Antworten', replyPlaceholder: 'Antwort schreiben' },
+};
+const ONE_MESSAGE = { kind: 'email', emailId: 'e1', threadId: 't1', accountId: 'a', slot: 2, login: 'fp' };
+const MESSAGE_URL = '/mail/message/e1?slot=2&owner=a';
+
+async function cachesWith(prefs?: unknown) {
+ const caches = new FakeCaches();
+ if (prefs) {
+ const cache = await caches.open(PUSH_CACHE);
+ await cache.put(`${ORIGIN}/__push-state/actions`, new Response(JSON.stringify(prefs)));
+ }
+ return caches;
+}
+
+/** A window client that answers a posted action with the given statuses. */
+function tab(id: string, statuses: string[], extra: Record = {}) {
+ const messages: Array> = [];
+ const client = {
+ id,
+ url: `${ORIGIN}/mail/folder/inbox`,
+ messages,
+ focus: vi.fn(async function focus(this: unknown) { return client; }),
+ navigate: vi.fn(async () => client),
+ postMessage(message: Record, ports?: MessagePort[]) {
+ messages.push(message);
+ if (message.kind !== 'notification-action') return;
+ const port = ports?.[0];
+ for (const status of statuses) port?.postMessage({ status });
+ },
+ ...extra,
+ };
+ return client;
+}
+
+function worker(opts: {
+ caches: FakeCaches;
+ clients?: Array<{ id: string }>;
+ fetch?: (input: unknown, init?: RequestInit) => Promise;
+ max?: number | null;
+ inline?: boolean;
+ showNotification?: (title: string, options: Record) => Promise;
+ notifications?: Array>;
+}) {
+ const openWindow = vi.fn(async () => null);
+ const fetch = vi.fn(opts.fetch ?? (async () => new Response('{}', { status: 200 })));
+ const max = opts.max === undefined ? 3 : opts.max;
+ const loaded = loadWorker({
+ buildId: 'b1',
+ caches: opts.caches,
+ clients: opts.clients ?? [],
+ fetch,
+ openWindow,
+ showNotification: opts.showNotification,
+ notifications: opts.notifications,
+ globals: {
+ MessageChannel,
+ ...(max === null ? {} : { Notification: { maxActions: max } }),
+ ...(opts.inline ? { NotificationEvent: class { get reply() { return null; } } } : {}),
+ },
+ });
+ const click = (action: string, extra: Record = {}, data: Record = ONE_MESSAGE) =>
+ loaded.dispatch('notificationclick', { action, ...extra, notification: { data, tag: 'bulwark-mail:a', close() {} } });
+ const posts = () => fetch.mock.calls.filter(([, init]) => init?.method === 'POST');
+ return { ...loaded, click, openWindow, fetch, posts };
+}
+
+const pushFor = (preview: Record) => async (w: ReturnType) => {
+ await w.dispatch('push', { data: { json: () => ({ kind: 'jmap-email-push', accountId: 'a', emailIds: ['e1'] }) } });
+ return preview;
+};
+
+const previewOf = (unreadTotal: number) => JSON.stringify({
+ unreadTotal,
+ slot: 2,
+ login: 'fp',
+ email: { id: 'e1', threadId: 't1', subject: 'Hello', from: [{ name: 'Bob' }] },
+});
+
+describe('the buttons a notification gets', () => {
+ const buttonsFor = async (opts: { prefs?: unknown; max?: number | null; inline?: boolean; unreadTotal?: number }) => {
+ const w = worker({
+ caches: await cachesWith(opts.prefs),
+ max: opts.max,
+ inline: opts.inline,
+ fetch: async () => new Response(previewOf(opts.unreadTotal ?? 1), { status: 200 }),
+ });
+ await pushFor({})(w);
+ expect(w.shown).toHaveLength(1);
+ return w.shown[0].options.actions as Array> | undefined;
+ };
+
+ it('offers the three in the labels the page left, on one message', async () => {
+ expect(await buttonsFor({ prefs: PREFS })).toEqual([
+ { action: 'archive', title: 'Archivieren' },
+ { action: 'mark-read', title: 'Als gelesen markieren' },
+ { action: 'reply', title: 'Antworten' },
+ ]);
+ });
+
+ it('keeps Archive and Reply where the browser has two buttons', async () => {
+ const buttons = await buttonsFor({ prefs: PREFS, max: 2 });
+ expect(buttons?.map((b) => b.action)).toEqual(['archive', 'reply']);
+ });
+
+ it('leaves Archive out when the admin turned it off', async () => {
+ const buttons = await buttonsFor({ prefs: { ...PREFS, archiveEnabled: false }, max: 2 });
+ expect(buttons?.map((b) => b.action)).toEqual(['mark-read', 'reply']);
+ });
+
+ it('offers no Archive, in English, before any page has spoken', async () => {
+ expect(await buttonsFor({})).toEqual([
+ { action: 'mark-read', title: 'Mark as read' },
+ { action: 'reply', title: 'Reply' },
+ ]);
+ });
+
+ it('adds nothing in a browser without notification buttons', async () => {
+ expect(await buttonsFor({ prefs: PREFS, max: null })).toBeUndefined();
+ expect(await buttonsFor({ prefs: PREFS, max: 0 })).toBeUndefined();
+ });
+
+ it('gives the group notification none: it names no message', async () => {
+ expect(await buttonsFor({ prefs: PREFS, unreadTotal: 4 })).toBeUndefined();
+ });
+
+ it('gives a message without its login none', async () => {
+ const w = worker({
+ caches: await cachesWith(PREFS),
+ fetch: async () => new Response(JSON.stringify({ unreadTotal: 1, slot: 2, email: { id: 'e1', threadId: 't1' } }), { status: 200 }),
+ });
+ await pushFor({})(w);
+ expect(w.shown[0].options.actions).toBeUndefined();
+ });
+
+ it('makes Reply a text field only where the browser can hand the text back', async () => {
+ const buttons = await buttonsFor({ prefs: PREFS, inline: true });
+ expect(buttons?.find((b) => b.action === 'reply')).toEqual({
+ action: 'reply', title: 'Antworten', type: 'text', placeholder: 'Antwort schreiben',
+ });
+ });
+
+ it('still shows the notification when the browser refuses the buttons', async () => {
+ const w = worker({
+ caches: await cachesWith(PREFS),
+ fetch: async () => new Response(previewOf(1), { status: 200 }),
+ showNotification: async (_title, options) => {
+ if (options.actions) throw new TypeError('bad action');
+ },
+ });
+ await pushFor({})(w);
+ expect(w.shown).toHaveLength(2);
+ expect(w.shown[1].options.actions).toBeUndefined();
+ expect(w.shown[1].options.data).toMatchObject({ emailId: 'e1', login: 'fp' });
+ });
+
+ it('keeps the buttons when a redelivered push shows the same notification again', async () => {
+ const existing = { title: 'Bob', body: 'Hello', data: ONE_MESSAGE };
+ const caches = await cachesWith(PREFS);
+ const cache = await caches.open(PUSH_CACHE);
+ await cache.put(`${ORIGIN}/__push-state/notified/a`, new Response(JSON.stringify({ ids: ['e1'] })));
+ const w = worker({ caches, notifications: [existing] });
+ await pushFor({})(w);
+ expect(w.shown).toHaveLength(1);
+ expect((w.shown[0].options.actions as unknown[]).length).toBe(3);
+ });
+});
+
+describe('a click', () => {
+ it('on the body works as before: it opens the message and runs nothing', async () => {
+ const client = tab('t', ['done']);
+ const w = worker({ caches: await cachesWith(PREFS), clients: [client] });
+ await w.click('');
+ expect(client.navigate).toHaveBeenCalledWith(MESSAGE_URL);
+ expect(client.messages.map((m) => m.kind)).toEqual(['notificationclick']);
+ expect(w.posts()).toHaveLength(0);
+ });
+
+ it('on an unknown button counts as a click on the body', async () => {
+ const client = tab('t', ['done']);
+ const w = worker({ caches: await cachesWith(PREFS), clients: [client] });
+ await w.click('explode');
+ expect(client.navigate).toHaveBeenCalledWith(MESSAGE_URL);
+ expect(w.posts()).toHaveLength(0);
+ });
+
+ it('on a button of a group notification opens the inbox and runs nothing', async () => {
+ const client = tab('t', ['done']);
+ const w = worker({ caches: await cachesWith(PREFS), clients: [client] });
+ await w.click('archive', {}, { kind: 'mail-list', accountId: 'a', slot: 2 });
+ expect(client.navigate).toHaveBeenCalledWith('/mail/folder/inbox?slot=2&owner=a');
+ expect(w.posts()).toHaveLength(0);
+ });
+});
+
+describe('Mark read and Archive', () => {
+ it('go to an open tab first, which leaves nothing else to do', async () => {
+ const client = tab('t', ['accepted', 'done']);
+ const w = worker({ caches: await cachesWith(PREFS), clients: [client] });
+ await w.click('mark-read');
+ expect(client.messages.find((m) => m.kind === 'notification-action')).toEqual({
+ kind: 'notification-action', action: 'mark-read', emailId: 'e1', threadId: 't1', accountId: 'a', slot: 2, login: 'fp',
+ });
+ expect(w.posts()).toHaveLength(0);
+ expect(client.navigate).not.toHaveBeenCalled();
+ expect(client.focus).not.toHaveBeenCalled();
+ expect(w.openWindow).not.toHaveBeenCalled();
+ });
+
+ it('go to the tab in front first', async () => {
+ const hidden = tab('hidden', ['done'], { visibilityState: 'hidden' });
+ const front = tab('front', ['done'], { focused: true, visibilityState: 'visible' });
+ const w = worker({ caches: await cachesWith(PREFS), clients: [hidden, front] });
+ await w.click('mark-read');
+ expect(front.messages.some((m) => m.kind === 'notification-action')).toBe(true);
+ expect(hidden.messages.some((m) => m.kind === 'notification-action')).toBe(false);
+ });
+
+ it('try the next tab when one declines', async () => {
+ const other = tab('other', ['declined']);
+ const right = tab('right', ['done']);
+ const w = worker({ caches: await cachesWith(PREFS), clients: [other, right] });
+ await w.click('mark-read');
+ expect(right.messages.some((m) => m.kind === 'notification-action')).toBe(true);
+ expect(w.posts()).toHaveLength(0);
+ });
+
+ it('use the route with no tab open, and nothing opens when it works', async () => {
+ const w = worker({ caches: await cachesWith(PREFS) });
+ await w.click('mark-read');
+ expect(w.posts()).toHaveLength(1);
+ const [url, init] = w.posts()[0];
+ expect(url).toBe('/api/push/action');
+ expect(init?.credentials).toBe('include');
+ expect(JSON.parse(String(init?.body))).toEqual({ action: 'mark-read', accountId: 'a', emailId: 'e1', login: 'fp' });
+ expect(w.openWindow).not.toHaveBeenCalled();
+ });
+
+ it('send the mirrored archive mode and the time zone with Archive', async () => {
+ const w = worker({ caches: await cachesWith(PREFS) });
+ await w.click('archive');
+ const body = JSON.parse(String(w.posts()[0][1]?.body));
+ expect(body).toMatchObject({ action: 'archive', archiveMode: 'month', accountId: 'a', emailId: 'e1', login: 'fp' });
+ expect(typeof body.timeZone).toBe('string');
+ expect(body.timeZone).not.toBe('');
+ });
+
+ it('use the route when a tab never answers, so a frozen tab costs a moment only', async () => {
+ const frozen = tab('frozen', []);
+ const w = worker({ caches: await cachesWith(PREFS), clients: [frozen] });
+ await w.click('mark-read');
+ expect(w.posts()).toHaveLength(1);
+ expect(frozen.navigate).not.toHaveBeenCalled();
+ });
+
+ it.each([401, 403, 404, 409, 502])('open the message when the route answers %i', async (status) => {
+ const w = worker({ caches: await cachesWith(PREFS), fetch: async () => new Response('{}', { status }) });
+ await w.click('archive');
+ expect(w.posts()).toHaveLength(1);
+ expect(w.openWindow).toHaveBeenCalledWith(MESSAGE_URL);
+ });
+
+ it('open the message when the route is out of reach', async () => {
+ const w = worker({ caches: await cachesWith(PREFS), fetch: async () => { throw new TypeError('offline'); } });
+ await w.click('mark-read');
+ expect(w.openWindow).toHaveBeenCalledWith(MESSAGE_URL);
+ });
+
+ it('open the message when a tab with a live login tried and failed, without the route', async () => {
+ const client = tab('t', ['accepted', 'failed']);
+ const w = worker({ caches: await cachesWith(PREFS), clients: [client] });
+ await w.click('archive');
+ expect(w.posts()).toHaveLength(0);
+ expect(client.focus).toHaveBeenCalled();
+ expect(client.navigate).toHaveBeenCalledWith(MESSAGE_URL);
+ });
+
+ it('never post an Archive the mirror says is off', async () => {
+ const w = worker({ caches: await cachesWith({ ...PREFS, archiveEnabled: false }) });
+ await w.click('archive');
+ expect(w.posts()).toHaveLength(0);
+ expect(w.openWindow).toHaveBeenCalledWith(MESSAGE_URL);
+ });
+});
+
+describe('Reply', () => {
+ it('opens the composer in an open tab and brings it to the front', async () => {
+ const client = tab('t', ['done']);
+ const w = worker({ caches: await cachesWith(PREFS), clients: [client] });
+ await w.click('reply', { reply: ' Thanks! ' });
+ expect(client.messages.find((m) => m.kind === 'notification-action')).toMatchObject({ action: 'reply', emailId: 'e1', text: 'Thanks!' });
+ expect(client.focus).toHaveBeenCalled();
+ expect(client.navigate).not.toHaveBeenCalled();
+ expect(w.posts()).toHaveLength(0);
+ });
+
+ it('navigates a tab that has no mail page to the reply link', async () => {
+ const client = tab('t', ['declined']);
+ const w = worker({ caches: await cachesWith(PREFS), clients: [client] });
+ await w.click('reply');
+ expect(client.focus).toHaveBeenCalled();
+ expect(client.navigate).toHaveBeenCalledWith(`${MESSAGE_URL}&reply=1`);
+ });
+
+ it('opens a window on the reply link with no tab open, never through the route', async () => {
+ const w = worker({ caches: await cachesWith(PREFS) });
+ await w.click('reply');
+ expect(w.openWindow).toHaveBeenCalledWith(`${MESSAGE_URL}&reply=1`);
+ expect(w.posts()).toHaveLength(0);
+ });
+
+ it('keeps typed text in the store for the page, out of the URL', async () => {
+ const caches = await cachesWith(PREFS);
+ const w = worker({ caches });
+ await w.click('reply', { reply: 'Private words' });
+ expect(w.openWindow).toHaveBeenCalledWith(`${MESSAGE_URL}&reply=1`);
+ const cache = await caches.open(PUSH_CACHE);
+ const kept = await (await cache.match(`${ORIGIN}/__push-state/pending-reply`))!.json();
+ expect(kept).toMatchObject({ emailId: 'e1', text: 'Private words' });
+ });
+
+ it('leaves nothing in the store without typed text', async () => {
+ const caches = await cachesWith(PREFS);
+ const w = worker({ caches });
+ await w.click('reply', { reply: ' ' });
+ const cache = await caches.open(PUSH_CACHE);
+ expect(await cache.match(`${ORIGIN}/__push-state/pending-reply`)).toBeUndefined();
+ });
+});
+
+describe('Background Sync', () => {
+ it('tells every open tab to flush the outbox', async () => {
+ const one = tab('one', []);
+ const two = tab('two', []);
+ const w = worker({ caches: await cachesWith(), clients: [one, two] });
+ await w.dispatch('sync', { tag: 'bulwark-outbox' });
+ expect(one.messages).toEqual([{ kind: 'outbox-flush' }]);
+ expect(two.messages).toEqual([{ kind: 'outbox-flush' }]);
+ });
+
+ it('ignores other tags', async () => {
+ const one = tab('one', []);
+ const w = worker({ caches: await cachesWith(), clients: [one] });
+ await w.dispatch('sync', { tag: 'something-else' });
+ expect(one.messages).toEqual([]);
+ });
+
+ it('settles with no tab open', async () => {
+ const w = worker({ caches: await cachesWith(), clients: [] });
+ await expect(w.dispatch('sync', { tag: 'bulwark-outbox' })).resolves.toBeUndefined();
+ });
+});
diff --git a/lib/deep-links.ts b/lib/deep-links.ts
index 483ad33a9..9e890e58e 100644
--- a/lib/deep-links.ts
+++ b/lib/deep-links.ts
@@ -137,7 +137,7 @@ const ALIASED_ROLES = new Set(['inbox', 'sent', 'drafts', 'trash', 'archive', 'j
*/
export type MailDeepLink =
| { kind: 'folder'; ref: string; accountId?: string; slot?: number; ownerAccountId?: string }
- | { kind: 'message'; id: string; accountId?: string; slot?: number; fullscreen?: boolean; ownerAccountId?: string }
+ | { kind: 'message'; id: string; accountId?: string; slot?: number; fullscreen?: boolean; ownerAccountId?: string; reply?: boolean }
| { kind: 'thread'; id: string; accountId?: string; slot?: number; ownerAccountId?: string };
export interface MailLinkState {
@@ -286,12 +286,15 @@ export function parseMailPath(
// opens message links as fullscreen email tabs and ignores the flag.
const fullscreen = search?.get('view') === 'fullscreen' || undefined;
const ownerAccountId = search?.get('owner') || undefined;
+ // `?reply=1`, a notification's Reply: opens the reply composer once. Only
+ // opens it: nothing a link says can send or change mail.
+ const reply = search?.get('reply') === '1' || undefined;
const [kind, value] = segments;
if (kind && value) {
const id = decodeSegment(value);
if (id) {
- if (kind === 'message') return { kind: 'message', id, accountId, slot, fullscreen, ownerAccountId };
+ if (kind === 'message') return { kind: 'message', id, accountId, slot, fullscreen, ownerAccountId, ...(reply ? { reply } : {}) };
if (kind === 'thread') return { kind: 'thread', id, accountId, slot, ownerAccountId };
if (kind === 'folder') return { kind: 'folder', ref: id, accountId, slot, ownerAccountId };
}
diff --git a/lib/offline/__tests__/queue-sync.test.ts b/lib/offline/__tests__/queue-sync.test.ts
new file mode 100644
index 000000000..a31f5616e
--- /dev/null
+++ b/lib/offline/__tests__/queue-sync.test.ts
@@ -0,0 +1,35 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { queuePatch, queueSend } from '../queue';
+
+// Whatever goes into the queue asks for a Background Sync, once it's stored.
+
+const requestOutboxSync = vi.fn();
+const stored: unknown[] = [];
+vi.mock('@/lib/pwa-notify/outbox-sync', () => ({ requestOutboxSync: () => requestOutboxSync(stored.length) }));
+vi.mock('../db', () => ({
+ idbAdd: vi.fn(async (_store: string, record: unknown) => { stored.push(record); return stored.length; }),
+ idbDeleteAccount: vi.fn(), idbGet: vi.fn(), idbGetAll: vi.fn(), idbGetAllByAccount: vi.fn(), idbUpdate: vi.fn(), idbWrite: vi.fn(),
+}));
+vi.mock('../crypto', () => ({
+ sealJson: vi.fn(async () => ({ iv: 'i', data: 'd' })),
+ openJson: vi.fn(), openBytes: vi.fn(), sealBytes: vi.fn(),
+}));
+
+beforeEach(() => {
+ stored.length = 0;
+ requestOutboxSync.mockClear();
+});
+
+describe('the offline queue', () => {
+ it('asks for a sync after storing a change', async () => {
+ await queuePatch('acc', 'jmap', {} as never);
+ expect(requestOutboxSync).toHaveBeenCalledTimes(1);
+ expect(requestOutboxSync).toHaveBeenCalledWith(1);
+ });
+
+ it('asks for a sync after storing a message, and still returns its id', async () => {
+ const id = await queueSend('acc', {} as never, {} as never);
+ expect(id).toBe(1);
+ expect(requestOutboxSync).toHaveBeenCalledWith(1);
+ });
+});
diff --git a/lib/offline/queue.ts b/lib/offline/queue.ts
index 61e53091a..4da2d5bf0 100644
--- a/lib/offline/queue.ts
+++ b/lib/offline/queue.ts
@@ -18,6 +18,7 @@
import { idbAdd, idbDeleteAccount, idbGet, idbGetAll, idbGetAllByAccount, idbUpdate, idbWrite } from './db';
import { openBytes, openJson, sealBytes, sealJson, type Sealed } from './crypto';
import { generateUUID } from '@/lib/utils';
+import { requestOutboxSync } from '@/lib/pwa-notify/outbox-sync';
export interface PatchItem {
kind: 'patch';
@@ -104,11 +105,14 @@ interface SendPayload {
export async function queuePatch(account: string, jmap: string, update: PatchItem['update']): Promise {
const item: PatchItem = { kind: 'patch', jmap, update };
await idbAdd('queue', { account, kind: 'patch', createdAt: Date.now(), ...(await sealJson(item)) } satisfies QueueRecord);
+ requestOutboxSync();
}
export async function queueSend(account: string, summary: OutboxSummary, item: SendItem, fingerprint?: PolicyFingerprint): Promise {
const payload: SendPayload = { summary, item, fingerprint };
- return Number(await idbAdd('queue', { account, kind: 'send', createdAt: Date.now(), status: 'waiting', ...(await sealJson(payload)) } satisfies QueueRecord));
+ const id = Number(await idbAdd('queue', { account, kind: 'send', createdAt: Date.now(), status: 'waiting', ...(await sealJson(payload)) } satisfies QueueRecord));
+ requestOutboxSync();
+ return id;
}
export interface QueuedPatch { id: number; account: string; item: PatchItem }
diff --git a/lib/pwa-notify/__tests__/outbox-sync.test.ts b/lib/pwa-notify/__tests__/outbox-sync.test.ts
new file mode 100644
index 000000000..0e2ae151d
--- /dev/null
+++ b/lib/pwa-notify/__tests__/outbox-sync.test.ts
@@ -0,0 +1,63 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { requestOutboxSync } from '../outbox-sync';
+
+// Background Sync where the browser has it. Elsewhere, and in Electron where
+// register rejects, nothing changes and nothing throws.
+
+const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
+
+function browserWith(opts: { syncManager?: boolean; registration?: unknown; worker?: boolean }) {
+ if (opts.syncManager) vi.stubGlobal('SyncManager', class {});
+ Object.defineProperty(navigator, 'serviceWorker', {
+ configurable: true,
+ value: opts.worker === false ? undefined : { getRegistration: async () => opts.registration },
+ });
+}
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+ Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: undefined });
+});
+
+describe('requestOutboxSync', () => {
+ it('registers the tag the worker listens for', async () => {
+ const register = vi.fn(async () => {});
+ browserWith({ syncManager: true, registration: { sync: { register } } });
+ requestOutboxSync();
+ await settle();
+ expect(register).toHaveBeenCalledWith('bulwark-outbox');
+ });
+
+ it('does nothing in a browser without Background Sync', async () => {
+ const register = vi.fn(async () => {});
+ browserWith({ syncManager: false, registration: { sync: { register } } });
+ requestOutboxSync();
+ await settle();
+ expect(register).not.toHaveBeenCalled();
+ });
+
+ it('does nothing with no worker registered', async () => {
+ browserWith({ syncManager: true, registration: undefined });
+ expect(() => requestOutboxSync()).not.toThrow();
+ await settle();
+ });
+
+ it('does nothing without service workers', async () => {
+ browserWith({ syncManager: true, worker: false });
+ expect(() => requestOutboxSync()).not.toThrow();
+ await settle();
+ });
+
+ it('swallows the rejection Electron answers with', async () => {
+ const rejected = vi.fn();
+ process.on('unhandledRejection', rejected);
+ const register = vi.fn(async () => { throw new DOMException('Background Sync is disabled', 'UnknownError'); });
+ browserWith({ syncManager: true, registration: { sync: { register } } });
+ requestOutboxSync();
+ await settle();
+ await settle();
+ process.off('unhandledRejection', rejected);
+ expect(register).toHaveBeenCalled();
+ expect(rejected).not.toHaveBeenCalled();
+ });
+});
diff --git a/lib/pwa-notify/__tests__/page-actions.test.ts b/lib/pwa-notify/__tests__/page-actions.test.ts
new file mode 100644
index 000000000..0351fcbfb
--- /dev/null
+++ b/lib/pwa-notify/__tests__/page-actions.test.ts
@@ -0,0 +1,175 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { loginFingerprint } from '../login-fingerprint';
+import { isNotificationActionRequest, runNotificationAction, setNotificationReplyOpener, type NotificationActionRequest, type NotificationActionStatus } from '../page-actions';
+
+// A notification's button in an open tab: only for the login it was shown for,
+// through the store actions, with the tab's answer telling the worker what's next.
+
+const accounts = [
+ { id: 'alice@one', cookieSlot: 0, serverUrl: 'https://one.example', username: 'alice@example.org' },
+ { id: 'bob@two', cookieSlot: 1, serverUrl: 'https://two.example', username: 'bob@example.org' },
+];
+const makeClient = (accountId: string) => ({
+ getAccountId: () => accountId,
+ getEmail: vi.fn(async (id: string) => (id === 'gone' ? null : { id, receivedAt: '2026-01-01T00:00:00Z' })),
+});
+let clients: Record>;
+let activeAccountId = 'alice@one';
+let archiveEnabled = true;
+const batchMarkAsRead = vi.fn(async (..._args: unknown[]) => true);
+const batchArchive = vi.fn(async (..._args: unknown[]) => true);
+
+vi.mock('@/stores/account-store', () => ({ useAccountStore: { getState: () => ({ accounts }) } }));
+vi.mock('@/stores/auth-store', () => ({
+ useAuthStore: {
+ getState: () => ({
+ activeAccountId,
+ client: clients[activeAccountId] ?? null,
+ getClientForAccount: (id: string) => clients[id],
+ }),
+ },
+}));
+vi.mock('@/stores/email-store', () => ({ useEmailStore: { getState: () => ({ batchMarkAsRead, batchArchive }) } }));
+vi.mock('@/lib/archive-gate', () => ({ isArchiveActionEnabled: () => archiveEnabled }));
+
+async function run(over: Partial = {}) {
+ const request: NotificationActionRequest = {
+ action: 'mark-read',
+ emailId: 'e1',
+ accountId: 'a',
+ slot: 0,
+ login: await loginFingerprint('https://one.example', 'alice@example.org'),
+ ...over,
+ };
+ const answers: NotificationActionStatus[] = [];
+ await runNotificationAction(request, (status) => answers.push(status));
+ return answers;
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ clients = { 'alice@one': makeClient('a'), 'bob@two': makeClient('b') };
+ activeAccountId = 'alice@one';
+ archiveEnabled = true;
+ batchMarkAsRead.mockResolvedValue(true);
+ batchArchive.mockResolvedValue(true);
+});
+
+describe('which notifications a tab takes', () => {
+ it('declines when the slot holds someone else now', async () => {
+ const answers = await run({ login: await loginFingerprint('https://other.example', 'alice@example.org') });
+ expect(answers).toEqual(['declined']);
+ expect(batchMarkAsRead).not.toHaveBeenCalled();
+ });
+
+ it('declines a slot it has no login for', async () => {
+ expect(await run({ slot: 7 })).toEqual(['declined']);
+ });
+
+ it('declines without a login to compare', async () => {
+ expect(await run({ login: undefined })).toEqual(['declined']);
+ expect(batchMarkAsRead).not.toHaveBeenCalled();
+ });
+
+ it('declines when that login is not connected in this tab', async () => {
+ delete clients['alice@one'];
+ activeAccountId = 'bob@two';
+ expect(await run()).toEqual(['declined']);
+ });
+
+ it('checks a message from the worker for the shape it needs', () => {
+ expect(isNotificationActionRequest({ action: 'archive', emailId: 'e', accountId: 'a' })).toBe(true);
+ expect(isNotificationActionRequest({ action: 'delete', emailId: 'e', accountId: 'a' })).toBe(false);
+ expect(isNotificationActionRequest({ action: 'archive', emailId: '', accountId: 'a' })).toBe(false);
+ expect(isNotificationActionRequest({ action: 'archive', emailId: 'e' })).toBe(false);
+ expect(isNotificationActionRequest(null)).toBe(false);
+ });
+});
+
+describe('Mark read', () => {
+ it('goes through the store, with the login of the notification', async () => {
+ expect(await run()).toEqual(['accepted', 'done']);
+ const [active, read, target] = batchMarkAsRead.mock.calls[0];
+ expect(active).toBe(clients['alice@one']);
+ expect(read).toBe(true);
+ expect(target).toEqual({ emailIds: ['e1'], client: clients['alice@one'], accountId: undefined });
+ });
+
+ it('reaches a login that is not the active one through its own client', async () => {
+ const answers = await run({ slot: 1, accountId: 'b', login: await loginFingerprint('https://two.example', 'bob@example.org') });
+ expect(answers).toEqual(['accepted', 'done']);
+ const [active, , target] = batchMarkAsRead.mock.calls[0];
+ expect(active).toBe(clients['alice@one']);
+ expect((target as { client: unknown }).client).toBe(clients['bob@two']);
+ });
+
+ it('names a shared account', async () => {
+ await run({ accountId: 'team' });
+ expect((batchMarkAsRead.mock.calls[0][2] as { accountId?: string }).accountId).toBe('team');
+ });
+
+ it('reports a failure, so the worker opens the message', async () => {
+ batchMarkAsRead.mockRejectedValue(new Error('forbidden'));
+ expect(await run()).toEqual(['accepted', 'failed']);
+ });
+});
+
+describe('Archive', () => {
+ it('goes through the store with the message, which it files by date', async () => {
+ expect(await run({ action: 'archive' })).toEqual(['accepted', 'done']);
+ const [active, target] = batchArchive.mock.calls[0];
+ expect(active).toBe(clients['alice@one']);
+ expect(target).toEqual({
+ emailIds: ['e1'],
+ client: clients['alice@one'],
+ emails: [{ id: 'e1', receivedAt: '2026-01-01T00:00:00Z' }],
+ });
+ });
+
+ it('declines when the admin turned it off', async () => {
+ archiveEnabled = false;
+ expect(await run({ action: 'archive' })).toEqual(['declined']);
+ expect(batchArchive).not.toHaveBeenCalled();
+ });
+
+ it("leaves a shared account's Archive to the route: the store reads own folders only", async () => {
+ expect(await run({ action: 'archive', accountId: 'team' })).toEqual(['declined']);
+ expect(batchArchive).not.toHaveBeenCalled();
+ });
+
+ it('reports a message that is gone', async () => {
+ expect(await run({ action: 'archive', emailId: 'gone' })).toEqual(['accepted', 'failed']);
+ expect(batchArchive).not.toHaveBeenCalled();
+ });
+
+ it('reports a plugin or the store stopping it', async () => {
+ batchArchive.mockResolvedValue(false);
+ expect(await run({ action: 'archive' })).toEqual(['accepted', 'failed']);
+ });
+});
+
+describe('Reply', () => {
+ it('declines on a page without the mail app', async () => {
+ expect(await run({ action: 'reply' })).toEqual(['declined']);
+ });
+
+ it('hands the request to the mail page, typed text included', async () => {
+ const opener = vi.fn();
+ const remove = setNotificationReplyOpener(opener);
+ expect(await run({ action: 'reply', text: 'Thanks' })).toEqual(['done']);
+ expect(opener).toHaveBeenCalledWith({ emailId: 'e1', accountId: 'a', slot: 0, text: 'Thanks' });
+ remove();
+ expect(await run({ action: 'reply' })).toEqual(['declined']);
+ expect(opener).toHaveBeenCalledTimes(1);
+ });
+
+ it('keeps a newer opener when an older one is removed', async () => {
+ const remove = setNotificationReplyOpener(vi.fn());
+ const current = vi.fn();
+ const removeCurrent = setNotificationReplyOpener(current);
+ remove();
+ await run({ action: 'reply' });
+ expect(current).toHaveBeenCalled();
+ removeCurrent();
+ });
+});
diff --git a/lib/pwa-notify/__tests__/worker-state.test.ts b/lib/pwa-notify/__tests__/worker-state.test.ts
new file mode 100644
index 000000000..0a175d5b7
--- /dev/null
+++ b/lib/pwa-notify/__tests__/worker-state.test.ts
@@ -0,0 +1,70 @@
+import { readFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { FakeCaches } from '@/lib/__tests__/sw-harness';
+import { PUSH_STATE_CACHE } from '@/lib/push-preferences';
+import { consumePendingReplyText, notificationActionPrefsKey, pendingReplyKey, writeNotificationActionPrefs } from '../worker-state';
+
+let caches: FakeCaches;
+
+const keep = async (value: unknown) => {
+ const cache = await caches.open(PUSH_STATE_CACHE);
+ await cache.put(pendingReplyKey(window.location.origin), new Response(JSON.stringify(value)));
+};
+
+beforeEach(() => {
+ caches = new FakeCaches();
+ vi.stubGlobal('caches', caches);
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+describe('the worker store', () => {
+ it('holds the prefs where sw.js reads them', async () => {
+ const prefs = { archiveEnabled: true, archiveMode: 'year' as const, labels: { archive: 'A', markRead: 'M', reply: 'R', replyPlaceholder: 'P' } };
+ await writeNotificationActionPrefs(prefs);
+ const cache = await caches.open(PUSH_STATE_CACHE);
+ const stored = await cache.match(notificationActionPrefsKey(window.location.origin));
+ expect(await stored!.json()).toEqual(prefs);
+ });
+
+ it('uses the same keys and cache as sw.js, which can import nothing', () => {
+ const worker = readFileSync(join(process.cwd(), 'public', 'sw.js'), 'utf8');
+ expect(worker).toContain(`const PUSH_STATE_CACHE = "${PUSH_STATE_CACHE}"`);
+ expect(worker).toContain('/__push-state/actions`');
+ expect(worker).toContain('/__push-state/pending-reply`');
+ expect(notificationActionPrefsKey('https://x')).toBe('https://x/__push-state/actions');
+ expect(pendingReplyKey('https://x')).toBe('https://x/__push-state/pending-reply');
+ });
+
+ it('survives a browser without the Cache API', async () => {
+ vi.stubGlobal('caches', undefined);
+ await expect(writeNotificationActionPrefs({ archiveEnabled: false, archiveMode: 'single', labels: { archive: '', markRead: '', reply: '', replyPlaceholder: '' } })).resolves.toBeUndefined();
+ await expect(consumePendingReplyText('e1')).resolves.toBe('');
+ });
+});
+
+describe('a reply typed on a notification', () => {
+ it('reaches the page once', async () => {
+ await keep({ emailId: 'e1', text: 'Thanks', at: Date.now() });
+ expect(await consumePendingReplyText('e1')).toBe('Thanks');
+ expect(await consumePendingReplyText('e1')).toBe('');
+ });
+
+ it("doesn't go into a reply to another message, and is gone after", async () => {
+ await keep({ emailId: 'e1', text: 'Thanks', at: Date.now() });
+ expect(await consumePendingReplyText('e2')).toBe('');
+ expect(await consumePendingReplyText('e1')).toBe('');
+ });
+
+ it("doesn't go into a reply much later", async () => {
+ await keep({ emailId: 'e1', text: 'Thanks', at: Date.now() - 6 * 60 * 1000 });
+ expect(await consumePendingReplyText('e1')).toBe('');
+ });
+
+ it('is empty when there is none', async () => {
+ expect(await consumePendingReplyText('e1')).toBe('');
+ });
+});
diff --git a/lib/pwa-notify/login-fingerprint.ts b/lib/pwa-notify/login-fingerprint.ts
new file mode 100644
index 000000000..7debefe5d
--- /dev/null
+++ b/lib/pwa-notify/login-fingerprint.ts
@@ -0,0 +1,13 @@
+/**
+ * Names a login (server and user) in a notification's data, without naming
+ * either. A cookie slot goes to whoever signs in next, and JMAP account ids
+ * repeat across servers, so neither says that an action on a notification
+ * from yesterday still means the same mailbox.
+ *
+ * Runs in the page and on the server: both ends compare the result.
+ */
+export async function loginFingerprint(serverUrl: string, username: string | undefined): Promise {
+ const text = `${serverUrl.replace(/\/+$/, '')}\n${(username ?? '').trim().toLowerCase()}`;
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
+ return Array.from(new Uint8Array(digest).slice(0, 16), (byte) => byte.toString(16).padStart(2, '0')).join('');
+}
diff --git a/lib/pwa-notify/outbox-sync.ts b/lib/pwa-notify/outbox-sync.ts
new file mode 100644
index 000000000..4e2fd8db4
--- /dev/null
+++ b/lib/pwa-notify/outbox-sync.ts
@@ -0,0 +1,24 @@
+/** Same tag as sw.js's OUTBOX_SYNC_TAG. */
+const OUTBOX_SYNC_TAG = 'bulwark-outbox';
+
+/**
+ * Something went into the outbox: asks the browser to wake the service worker
+ * when the network is back, which tells the open tabs to flush. Only a tab
+ * can flush (the worker has no login), so this helps a tab that missed the
+ * `online` event.
+ *
+ * Chromium only. Without it, or without a worker (Lite, development), or in
+ * Electron, where register rejects, the outbox flushes as it always did.
+ */
+export function requestOutboxSync(): void {
+ void (async () => {
+ try {
+ if (typeof window === 'undefined' || !('SyncManager' in window) || !navigator.serviceWorker) return;
+ const registration = await navigator.serviceWorker.getRegistration();
+ const sync = (registration as (ServiceWorkerRegistration & { sync?: { register(tag: string): Promise } }) | undefined)?.sync;
+ await sync?.register(OUTBOX_SYNC_TAG);
+ } catch {
+ // Nothing to do: the outbox doesn't depend on this.
+ }
+ })();
+}
diff --git a/lib/pwa-notify/page-actions.ts b/lib/pwa-notify/page-actions.ts
new file mode 100644
index 000000000..43cc96f95
--- /dev/null
+++ b/lib/pwa-notify/page-actions.ts
@@ -0,0 +1,121 @@
+import { isArchiveActionEnabled } from '@/lib/archive-gate';
+import { useAccountStore } from '@/stores/account-store';
+import { useAuthStore } from '@/stores/auth-store';
+import { useEmailStore } from '@/stores/email-store';
+import { loginFingerprint } from './login-fingerprint';
+
+/** What the service worker posts when a notification's button is clicked (public/sw.js). */
+export interface NotificationActionRequest {
+ action: 'archive' | 'mark-read' | 'reply';
+ emailId: string;
+ accountId: string;
+ threadId?: string;
+ slot?: number;
+ login?: string;
+ /** A reply typed on the notification, where the browser has a text field there. */
+ text?: string;
+}
+
+/**
+ * `declined`: not for this tab, the worker tries the next tab or the route.
+ * `accepted`: this tab is on it. `done` and `failed`: how it ended.
+ */
+export type NotificationActionStatus = 'declined' | 'accepted' | 'done' | 'failed';
+
+export interface NotificationReplyRequest {
+ emailId: string;
+ accountId: string;
+ slot: number;
+ text?: string;
+}
+
+type ReplyOpener = (request: NotificationReplyRequest) => void;
+
+let replyOpener: ReplyOpener | null = null;
+
+/** The mail page opens replies. On other pages the worker navigates to it instead. */
+export function setNotificationReplyOpener(opener: ReplyOpener): () => void {
+ replyOpener = opener;
+ return () => {
+ if (replyOpener === opener) replyOpener = null;
+ };
+}
+
+export function isNotificationActionRequest(value: unknown): value is NotificationActionRequest {
+ const request = value as Partial | null;
+ return !!request
+ && (request.action === 'archive' || request.action === 'mark-read' || request.action === 'reply')
+ && typeof request.emailId === 'string' && !!request.emailId
+ && typeof request.accountId === 'string' && !!request.accountId;
+}
+
+/** The login the notification is for, if this tab has it signed in right now. */
+async function loginOf(request: NotificationActionRequest) {
+ const entry = useAccountStore.getState().accounts.find((account) => account.cookieSlot === request.slot);
+ if (!entry || !request.login) return null;
+ if ((await loginFingerprint(entry.serverUrl, entry.username)) !== request.login) return null;
+ const auth = useAuthStore.getState();
+ const client = auth.getClientForAccount(entry.id) ?? (entry.id === auth.activeAccountId ? auth.client : null);
+ return client ? { client } : null;
+}
+
+/**
+ * Runs a notification's button in this tab, through the same store actions as
+ * the app's own buttons: plugin hooks, archive mode and the Archive gate all
+ * apply, and the list on screen follows.
+ */
+export async function runNotificationAction(
+ request: NotificationActionRequest,
+ answer: (status: NotificationActionStatus) => void,
+): Promise {
+ try {
+ const login = await loginOf(request);
+ const activeClient = useAuthStore.getState().client;
+ if (!login || !activeClient) {
+ answer('declined');
+ return;
+ }
+ if (request.action === 'reply') {
+ if (!replyOpener || request.slot === undefined) {
+ answer('declined');
+ return;
+ }
+ replyOpener({ emailId: request.emailId, accountId: request.accountId, slot: request.slot, text: request.text });
+ answer('done');
+ return;
+ }
+
+ const own = request.accountId === login.client.getAccountId();
+ if (request.action === 'mark-read') {
+ answer('accepted');
+ await useEmailStore.getState().batchMarkAsRead(activeClient, true, {
+ emailIds: [request.emailId],
+ client: login.client,
+ accountId: own ? undefined : request.accountId,
+ });
+ answer('done');
+ return;
+ }
+
+ // The store's targeted archive reads a login's own folders only. A shared
+ // account's Archive is the route's to find.
+ if (!own || !isArchiveActionEnabled()) {
+ answer('declined');
+ return;
+ }
+ answer('accepted');
+ const email = await login.client.getEmail(request.emailId);
+ if (!email) {
+ answer('failed');
+ return;
+ }
+ const archived = await useEmailStore.getState().batchArchive(activeClient, {
+ emailIds: [request.emailId],
+ client: login.client,
+ emails: [email],
+ });
+ answer(archived ? 'done' : 'failed');
+ } catch {
+ answer('failed');
+ }
+}
diff --git a/lib/pwa-notify/worker-state.ts b/lib/pwa-notify/worker-state.ts
new file mode 100644
index 000000000..0fb48cdb6
--- /dev/null
+++ b/lib/pwa-notify/worker-state.ts
@@ -0,0 +1,58 @@
+import { PUSH_STATE_CACHE } from '@/lib/push-preferences';
+
+/**
+ * What the service worker needs for a notification's buttons. It has no
+ * settings store and no translations, so the page mirrors these into the
+ * worker's Cache store, as it does the sound setting (lib/push-preferences.ts).
+ * Signing out deletes the store with the rest of the push state.
+ */
+export interface NotificationActionPrefs {
+ archiveEnabled: boolean;
+ archiveMode: 'single' | 'year' | 'month';
+ labels: { archive: string; markRead: string; reply: string; replyPlaceholder: string };
+}
+
+const BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? '').replace(/\/+$/, '');
+
+/** A reply typed on a notification waits this long for the page it opened. */
+const PENDING_REPLY_MAX_AGE_MS = 5 * 60 * 1000;
+
+/** The cache key sw.js reads the prefs from. */
+export function notificationActionPrefsKey(origin: string): string {
+ return `${origin}${BASE_PATH}/__push-state/actions`;
+}
+
+/** The cache key sw.js keeps a reply typed on a notification under. */
+export function pendingReplyKey(origin: string): string {
+ return `${origin}${BASE_PATH}/__push-state/pending-reply`;
+}
+
+export async function writeNotificationActionPrefs(prefs: NotificationActionPrefs): Promise {
+ try {
+ if (typeof caches === 'undefined') return;
+ const cache = await caches.open(PUSH_STATE_CACHE);
+ await cache.put(
+ notificationActionPrefsKey(window.location.origin),
+ new Response(JSON.stringify(prefs), { headers: { 'Content-Type': 'application/json' } }),
+ );
+ } catch {
+ // Without the store the worker offers no Archive, in English.
+ }
+}
+
+/** The text typed on this message's notification, once. */
+export async function consumePendingReplyText(emailId: string): Promise {
+ try {
+ if (typeof caches === 'undefined') return '';
+ const cache = await caches.open(PUSH_STATE_CACHE);
+ const key = pendingReplyKey(window.location.origin);
+ const res = await cache.match(key);
+ if (!res) return '';
+ const pending = (await res.json()) as { emailId?: unknown; text?: unknown; at?: unknown };
+ await cache.delete(key);
+ const fresh = typeof pending.at === 'number' && Date.now() - pending.at < PENDING_REPLY_MAX_AGE_MS;
+ return fresh && pending.emailId === emailId && typeof pending.text === 'string' ? pending.text : '';
+ } catch {
+ return '';
+ }
+}
diff --git a/locales/ar/common.json b/locales/ar/common.json
index cc8420d14..a1cb16ab2 100644
--- a/locales/ar/common.json
+++ b/locales/ar/common.json
@@ -6350,6 +6350,12 @@
"sign_language": "لغة الإشارة",
"new_tab": "يُفتح في علامة تبويب جديدة"
},
+ "pwaNotify": {
+ "action_archive": "أرشفة",
+ "action_mark_read": "تمييز كمقروءة",
+ "action_reply": "رد",
+ "reply_placeholder": "اكتب ردًا"
+ },
"structured_mail_calendar": {
"add_to_calendar": "إضافة إلى التقويم",
"update_in_calendar": "تحديث في التقويم",
diff --git a/locales/ca/common.json b/locales/ca/common.json
index 6e15f53fc..9458ade33 100644
--- a/locales/ca/common.json
+++ b/locales/ca/common.json
@@ -6350,6 +6350,12 @@
"sign_language": "Llengua de signes",
"new_tab": "s'obre en una pestanya nova"
},
+ "pwaNotify": {
+ "action_archive": "Arxiva",
+ "action_mark_read": "Marca com a llegit",
+ "action_reply": "Respon",
+ "reply_placeholder": "Escriu una resposta"
+ },
"structured_mail_calendar": {
"add_to_calendar": "Afegeix al calendari",
"update_in_calendar": "Actualitza al calendari",
diff --git a/locales/cs/common.json b/locales/cs/common.json
index d8426dce2..aaeebd622 100644
--- a/locales/cs/common.json
+++ b/locales/cs/common.json
@@ -6350,6 +6350,12 @@
"sign_language": "Znakový jazyk",
"new_tab": "otevře se na nové kartě"
},
+ "pwaNotify": {
+ "action_archive": "Archivovat",
+ "action_mark_read": "Označit jako přečtené",
+ "action_reply": "Odpovědět",
+ "reply_placeholder": "Napište odpověď"
+ },
"structured_mail_calendar": {
"add_to_calendar": "Přidat do kalendáře",
"update_in_calendar": "Aktualizovat v kalendáři",
diff --git a/locales/da/common.json b/locales/da/common.json
index cf66a7294..8e5180c10 100644
--- a/locales/da/common.json
+++ b/locales/da/common.json
@@ -6350,6 +6350,12 @@
"sign_language": "Tegnsprog",
"new_tab": "åbner i en ny fane"
},
+ "pwaNotify": {
+ "action_archive": "Arkivér",
+ "action_mark_read": "Markér som læst",
+ "action_reply": "Svar",
+ "reply_placeholder": "Skriv et svar"
+ },
"structured_mail_calendar": {
"add_to_calendar": "Tilføj til kalender",
"update_in_calendar": "Opdater i kalender",
diff --git a/locales/de/common.json b/locales/de/common.json
index 3772dbab8..d24c812bc 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -6350,6 +6350,12 @@
"sign_language": "Gebärdensprache",
"new_tab": "öffnet in einem neuen Tab"
},
+ "pwaNotify": {
+ "action_archive": "Archivieren",
+ "action_mark_read": "Als gelesen markieren",
+ "action_reply": "Antworten",
+ "reply_placeholder": "Antwort schreiben"
+ },
"structured_mail_calendar": {
"add_to_calendar": "Zum Kalender hinzufügen",
"update_in_calendar": "Im Kalender aktualisieren",
diff --git a/locales/en/common.json b/locales/en/common.json
index 57a58dab4..e43553012 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -6350,6 +6350,12 @@
"sign_language": "Sign language",
"new_tab": "opens in a new tab"
},
+ "pwaNotify": {
+ "action_archive": "Archive",
+ "action_mark_read": "Mark as read",
+ "action_reply": "Reply",
+ "reply_placeholder": "Write a reply"
+ },
"structured_mail_calendar": {
"add_to_calendar": "Add to calendar",
"update_in_calendar": "Update in calendar",
diff --git a/locales/es/common.json b/locales/es/common.json
index cc786a268..4dc8c717f 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -6350,6 +6350,12 @@
"sign_language": "Lengua de signos",
"new_tab": "se abre en una pestaña nueva"
},
+ "pwaNotify": {
+ "action_archive": "Archivar",
+ "action_mark_read": "Marcar como leído",
+ "action_reply": "Responder",
+ "reply_placeholder": "Escribe una respuesta"
+ },
"structured_mail_calendar": {
"add_to_calendar": "Añadir al calendario",
"update_in_calendar": "Actualizar en el calendario",
diff --git a/locales/et/common.json b/locales/et/common.json
index 2743f7f83..df266cdb2 100644
--- a/locales/et/common.json
+++ b/locales/et/common.json
@@ -6350,6 +6350,12 @@
"sign_language": "Viipekeel",
"new_tab": "avaneb uuel vahekaardil"
},
+ "pwaNotify": {
+ "action_archive": "Arhiveeri",
+ "action_mark_read": "Märgi loetuks",
+ "action_reply": "Vasta",
+ "reply_placeholder": "Kirjuta vastus"
+ },
"structured_mail_calendar": {
"add_to_calendar": "Lisa kalendrisse",
"update_in_calendar": "Uuenda kalendris",
diff --git a/locales/fa/common.json b/locales/fa/common.json
index 539f81c1a..20f3bd264 100644
--- a/locales/fa/common.json
+++ b/locales/fa/common.json
@@ -6350,6 +6350,12 @@
"sign_language": "زبان اشاره",
"new_tab": "در زبانهای تازه باز میشود"
},
+ "pwaNotify": {
+ "action_archive": "بایگانی",
+ "action_mark_read": "علامتگذاری خوانده شده",
+ "action_reply": "پاسخ",
+ "reply_placeholder": "پاسخ بنویسید"
+ },
"structured_mail_calendar": {
"add_to_calendar": "افزودن به تقویم",
"update_in_calendar": "بهروزرسانی در تقویم",
diff --git a/locales/fr/common.json b/locales/fr/common.json
index fb7d148ec..019a6c00c 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -6350,6 +6350,12 @@
"sign_language": "Langue des signes",
"new_tab": "s'ouvre dans un nouvel onglet"
},
+ "pwaNotify": {
+ "action_archive": "Archiver",
+ "action_mark_read": "Marquer comme lu",
+ "action_reply": "Répondre",
+ "reply_placeholder": "Écrire une réponse"
+ },
"structured_mail_calendar": {
"add_to_calendar": "Ajouter au calendrier",
"update_in_calendar": "Mettre à jour dans le calendrier",
diff --git a/locales/he/common.json b/locales/he/common.json
index 73d75426a..52650b64a 100644
--- a/locales/he/common.json
+++ b/locales/he/common.json
@@ -6351,6 +6351,12 @@
"sign_language": "שפת סימנים",
"new_tab": "נפתח בכרטיסייה חדשה"
},
+ "pwaNotify": {
+ "action_archive": "ארכיון",
+ "action_mark_read": "סמן כנקרא",
+ "action_reply": "השב",
+ "reply_placeholder": "כתיבת תשובה"
+ },
"structured_mail_calendar": {
"add_to_calendar": "הוסף ליומן",
"update_in_calendar": "עדכן ביומן",
diff --git a/locales/hu/common.json b/locales/hu/common.json
index b45fe495e..22c05e7e8 100644
--- a/locales/hu/common.json
+++ b/locales/hu/common.json
@@ -6350,6 +6350,12 @@
"sign_language": "Jelnyelv",
"new_tab": "új lapon nyílik meg"
},
+ "pwaNotify": {
+ "action_archive": "Archiválás",
+ "action_mark_read": "Megjelölés olvasottként",
+ "action_reply": "Válasz",
+ "reply_placeholder": "Válasz írása"
+ },
"structured_mail_calendar": {
"add_to_calendar": "Hozzáadás a naptárhoz",
"update_in_calendar": "Frissítés a naptárban",
diff --git a/locales/it/common.json b/locales/it/common.json
index a021b7397..3b87ba410 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -6350,6 +6350,12 @@
"sign_language": "Lingua dei segni",
"new_tab": "si apre in una nuova scheda"
},
+ "pwaNotify": {
+ "action_archive": "Archivia",
+ "action_mark_read": "Segna come letto",
+ "action_reply": "Rispondi",
+ "reply_placeholder": "Scrivi una risposta"
+ },
"structured_mail_calendar": {
"add_to_calendar": "Aggiungi al calendario",
"update_in_calendar": "Aggiorna nel calendario",
diff --git a/locales/ja/common.json b/locales/ja/common.json
index f2ea41909..4833b9215 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -6350,6 +6350,12 @@
"sign_language": "手話",
"new_tab": "新しいタブで開きます"
},
+ "pwaNotify": {
+ "action_archive": "アーカイブ",
+ "action_mark_read": "既読にする",
+ "action_reply": "返信",
+ "reply_placeholder": "返信を入力"
+ },
"structured_mail_calendar": {
"add_to_calendar": "カレンダーに追加",
"update_in_calendar": "カレンダーを更新",
diff --git a/locales/ko/common.json b/locales/ko/common.json
index cbde91d32..f403b389e 100644
--- a/locales/ko/common.json
+++ b/locales/ko/common.json
@@ -6350,6 +6350,12 @@
"sign_language": "수어",
"new_tab": "새 탭에서 열림"
},
+ "pwaNotify": {
+ "action_archive": "보관",
+ "action_mark_read": "읽은 상태로 표시",
+ "action_reply": "답장",
+ "reply_placeholder": "답장 쓰기"
+ },
"structured_mail_calendar": {
"add_to_calendar": "캘린더에 추가",
"update_in_calendar": "캘린더에서 업데이트",
diff --git a/locales/lv/common.json b/locales/lv/common.json
index 02d30c2f9..5ee617996 100644
--- a/locales/lv/common.json
+++ b/locales/lv/common.json
@@ -6350,6 +6350,12 @@
"sign_language": "Zīmju valoda",
"new_tab": "atveras jaunā cilnē"
},
+ "pwaNotify": {
+ "action_archive": "Arhivēt",
+ "action_mark_read": "Atzīmēt kā izlasītu",
+ "action_reply": "Atbildēt",
+ "reply_placeholder": "Rakstiet atbildi"
+ },
"structured_mail_calendar": {
"add_to_calendar": "Pievienot kalendāram",
"update_in_calendar": "Atjaunināt kalendārā",
diff --git a/locales/mn/common.json b/locales/mn/common.json
index 4dd86dea2..bd940ff26 100644
--- a/locales/mn/common.json
+++ b/locales/mn/common.json
@@ -6350,6 +6350,12 @@
"sign_language": "Дохионы хэл",
"new_tab": "шинэ табад нээгдэнэ"
},
+ "pwaNotify": {
+ "action_archive": "Архив",
+ "action_mark_read": "Уншсан гэж тэмдэглэх",
+ "action_reply": "Хариулах",
+ "reply_placeholder": "Хариу бичих"
+ },
"structured_mail_calendar": {
"add_to_calendar": "Хуанлид нэмэх",
"update_in_calendar": "Хуанлид шинэчлэх",
diff --git a/locales/nb/common.json b/locales/nb/common.json
index d98bd1756..1362f1e01 100644
--- a/locales/nb/common.json
+++ b/locales/nb/common.json
@@ -6350,6 +6350,12 @@
"sign_language": "Tegnspråk",
"new_tab": "åpnes i en ny fane"
},
+ "pwaNotify": {
+ "action_archive": "Arkiver",
+ "action_mark_read": "Merk som lest",
+ "action_reply": "Svar",
+ "reply_placeholder": "Skriv et svar"
+ },
"structured_mail_calendar": {
"add_to_calendar": "Legg til i kalenderen",
"update_in_calendar": "Oppdater i kalenderen",
diff --git a/locales/nl/common.json b/locales/nl/common.json
index 4d541f358..d6694f868 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -6350,6 +6350,12 @@
"sign_language": "Gebarentaal",
"new_tab": "opent in een nieuw tabblad"
},
+ "pwaNotify": {
+ "action_archive": "Archiveren",
+ "action_mark_read": "Markeren als gelezen",
+ "action_reply": "Beantwoorden",
+ "reply_placeholder": "Schrijf een antwoord"
+ },
"structured_mail_calendar": {
"add_to_calendar": "Toevoegen aan agenda",
"update_in_calendar": "Bijwerken in agenda",
diff --git a/locales/pl/common.json b/locales/pl/common.json
index a6a7cd520..45e0db29b 100644
--- a/locales/pl/common.json
+++ b/locales/pl/common.json
@@ -6350,6 +6350,12 @@
"sign_language": "Język migowy",
"new_tab": "otwiera się w nowej karcie"
},
+ "pwaNotify": {
+ "action_archive": "Archiwizuj",
+ "action_mark_read": "Oznacz jako przeczytane",
+ "action_reply": "Odpowiedz",
+ "reply_placeholder": "Napisz odpowiedź"
+ },
"structured_mail_calendar": {
"add_to_calendar": "Dodaj do kalendarza",
"update_in_calendar": "Zaktualizuj w kalendarzu",
diff --git a/locales/pt/common.json b/locales/pt/common.json
index 685a98aea..7c5723df2 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -6350,6 +6350,12 @@
"sign_language": "Língua de sinais",
"new_tab": "abre em uma nova aba"
},
+ "pwaNotify": {
+ "action_archive": "Arquivar",
+ "action_mark_read": "Marcar como lido",
+ "action_reply": "Responder",
+ "reply_placeholder": "Escreva uma resposta"
+ },
"structured_mail_calendar": {
"add_to_calendar": "Adicionar ao calendário",
"update_in_calendar": "Atualizar no calendário",
diff --git a/locales/ro/common.json b/locales/ro/common.json
index 45c3fefed..e62685ff1 100644
--- a/locales/ro/common.json
+++ b/locales/ro/common.json
@@ -6350,6 +6350,12 @@
"sign_language": "Limbaj mimico-gestual",
"new_tab": "se deschide într-o filă nouă"
},
+ "pwaNotify": {
+ "action_archive": "Arhivează",
+ "action_mark_read": "Marcați ca citit",
+ "action_reply": "Răspunde",
+ "reply_placeholder": "Scrieți un răspuns"
+ },
"structured_mail_calendar": {
"add_to_calendar": "Adaugă în calendar",
"update_in_calendar": "Actualizează în calendar",
diff --git a/locales/ru/common.json b/locales/ru/common.json
index b40069b61..ed4a27405 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -6350,6 +6350,12 @@
"sign_language": "Жестовый язык",
"new_tab": "открывается в новой вкладке"
},
+ "pwaNotify": {
+ "action_archive": "Архивировать",
+ "action_mark_read": "Отметить прочитанным",
+ "action_reply": "Ответить",
+ "reply_placeholder": "Напишите ответ"
+ },
"structured_mail_calendar": {
"add_to_calendar": "Добавить в календарь",
"update_in_calendar": "Обновить в календаре",
diff --git a/locales/sk/common.json b/locales/sk/common.json
index aa0304452..5d154e4c3 100644
--- a/locales/sk/common.json
+++ b/locales/sk/common.json
@@ -6350,6 +6350,12 @@
"sign_language": "Posunkový jazyk",
"new_tab": "otvorí sa na novej karte"
},
+ "pwaNotify": {
+ "action_archive": "Archivovať",
+ "action_mark_read": "Označiť ako prečítané",
+ "action_reply": "Odpovedať",
+ "reply_placeholder": "Napíšte odpoveď"
+ },
"structured_mail_calendar": {
"add_to_calendar": "Pridať do kalendára",
"update_in_calendar": "Aktualizovať v kalendári",
diff --git a/locales/th/common.json b/locales/th/common.json
index 4d2c52754..ec2e1774f 100644
--- a/locales/th/common.json
+++ b/locales/th/common.json
@@ -6358,6 +6358,12 @@
"sign_language": "ภาษามือ",
"new_tab": "เปิดในแท็บใหม่"
},
+ "pwaNotify": {
+ "action_archive": "เก็บถาวร",
+ "action_mark_read": "ทำเครื่องหมายว่าอ่านแล้ว",
+ "action_reply": "ตอบกลับ",
+ "reply_placeholder": "เขียนคำตอบ"
+ },
"structured_mail_calendar": {
"add_to_calendar": "เพิ่มลงในปฏิทิน",
"update_in_calendar": "อัปเดตในปฏิทิน",
diff --git a/locales/tr/common.json b/locales/tr/common.json
index 4f25997fa..eeebfa2c1 100644
--- a/locales/tr/common.json
+++ b/locales/tr/common.json
@@ -6350,6 +6350,12 @@
"sign_language": "İşaret dili",
"new_tab": "yeni sekmede açılır"
},
+ "pwaNotify": {
+ "action_archive": "Arşivle",
+ "action_mark_read": "Okundu olarak işaretle",
+ "action_reply": "Yanıtla",
+ "reply_placeholder": "Yanıt yazın"
+ },
"structured_mail_calendar": {
"add_to_calendar": "Takvime ekle",
"update_in_calendar": "Takvimde güncelle",
diff --git a/locales/uk/common.json b/locales/uk/common.json
index 408e920da..a74e6aef0 100644
--- a/locales/uk/common.json
+++ b/locales/uk/common.json
@@ -6350,6 +6350,12 @@
"sign_language": "Жестова мова",
"new_tab": "відкривається в новій вкладці"
},
+ "pwaNotify": {
+ "action_archive": "Архів",
+ "action_mark_read": "Позначити як прочитане",
+ "action_reply": "Відповісти",
+ "reply_placeholder": "Напишіть відповідь"
+ },
"structured_mail_calendar": {
"add_to_calendar": "Додати в календар",
"update_in_calendar": "Оновити в календарі",
diff --git a/locales/vi/common.json b/locales/vi/common.json
index 6952aec2e..583d0db87 100644
--- a/locales/vi/common.json
+++ b/locales/vi/common.json
@@ -6362,6 +6362,12 @@
"sign_language": "Ngôn ngữ ký hiệu",
"new_tab": "Mở trong một tab mới"
},
+ "pwaNotify": {
+ "action_archive": "Lưu trữ",
+ "action_mark_read": "Đánh dấu là đã đọc",
+ "action_reply": "Trả lời",
+ "reply_placeholder": "Viết trả lời"
+ },
"structured_mail_calendar": {
"add_to_calendar": "Thêm vào lịch",
"update_in_calendar": "Cập nhật trong lịch",
diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json
index 1236173cf..5b52b6b63 100644
--- a/locales/zh-TW/common.json
+++ b/locales/zh-TW/common.json
@@ -6350,6 +6350,12 @@
"sign_language": "手語",
"new_tab": "在新分頁中開啟"
},
+ "pwaNotify": {
+ "action_archive": "封存",
+ "action_mark_read": "標示為已讀",
+ "action_reply": "回覆",
+ "reply_placeholder": "撰寫回覆"
+ },
"structured_mail_calendar": {
"add_to_calendar": "新增到行事曆",
"update_in_calendar": "在行事曆中更新",
diff --git a/locales/zh/common.json b/locales/zh/common.json
index f51d10c28..c306c55cf 100644
--- a/locales/zh/common.json
+++ b/locales/zh/common.json
@@ -6350,6 +6350,12 @@
"sign_language": "手语",
"new_tab": "在新标签页中打开"
},
+ "pwaNotify": {
+ "action_archive": "归档",
+ "action_mark_read": "标记为已读",
+ "action_reply": "回复",
+ "reply_placeholder": "撰写回复"
+ },
"structured_mail_calendar": {
"add_to_calendar": "添加到日历",
"update_in_calendar": "在日历中更新",
diff --git a/public/sw.js b/public/sw.js
index 3dc7ff7b8..8ce60bb8b 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -337,6 +337,14 @@ self.addEventListener("pushsubscriptionchange", (event) => {
event.waitUntil(handlePushSubscriptionChange(event));
});
+// Background Sync, thin: this worker has no login and can't reach the outbox,
+// so it tells the open tabs to flush theirs. With no tab open, the next one
+// flushes when it starts.
+self.addEventListener("sync", (event) => {
+ if (event.tag !== OUTBOX_SYNC_TAG) return;
+ event.waitUntil(tellClientsToFlushOutbox());
+});
+
self.addEventListener("message", (event) => {
const data = event.data || {};
if (data.type === "client-build") {
@@ -486,6 +494,9 @@ async function handlePush(event) {
// The login (cookie slot) that owns this account, so the click opens the
// message there and not in whichever account happens to be active.
const slot = preview && Number.isInteger(preview.slot) ? preview.slot : undefined;
+ // Which login that slot held when the message came: an action on this
+ // notification must not land on whoever signed into the slot since.
+ const login = preview && typeof preview.login === "string" ? preview.login : undefined;
// Push subscription is scoped to EmailDelivery, but stragglers from the
// older broader-types subscription, marking-as-read races and verification
@@ -521,7 +532,7 @@ async function handlePush(event) {
// (Organisation > Devices): the route sent neither.
title = accountLabel ? `New mail (${accountLabel})` : "New mail";
body = unreadTotal > 1 ? `${unreadTotal} unread messages` : "You have new mail";
- if (unreadTotal <= 1) data = { kind: "email", emailId: email.id, threadId: email.threadId, accountId, slot };
+ if (unreadTotal <= 1) data = { kind: "email", emailId: email.id, threadId: email.threadId, accountId, slot, login };
} else if (email) {
const sender = email.from && email.from[0];
const senderName = (sender && sender.name) || (sender && sender.email) || "New mail";
@@ -534,7 +545,7 @@ async function handlePush(event) {
body += "\n" + (more === 1 ? "+1 more message" : `+${more} more messages`);
} else {
// Exactly one unread: deep-link straight to that message on click.
- data = { kind: "email", emailId: email.id, threadId: email.threadId, accountId, slot };
+ data = { kind: "email", emailId: email.id, threadId: email.threadId, accountId, slot, login };
}
} else {
title = accountLabel ? `New mail (${accountLabel})` : "New mail";
@@ -543,7 +554,7 @@ async function handlePush(event) {
// With the sound setting off, nothing rings and no quiet window starts.
const audible = (await soundPreference()) && !(await withinQuietWindow(accountId));
- await self.registration.showNotification(title, {
+ await showWithActions(title, {
body,
// Shared per-account tag: each new push replaces the account's single
// notification rather than adding another.
@@ -587,7 +598,7 @@ async function showWithoutAlert(groupTag) {
try {
const existing = (await self.registration.getNotifications({ tag: groupTag }))[0];
if (existing) {
- await self.registration.showNotification(existing.title, {
+ await showWithActions(existing.title, {
...options,
body: existing.body,
data: existing.data,
@@ -732,7 +743,7 @@ async function handleNotificationClick(event) {
return handleMailtoFocusNotificationClick();
}
- const targetUrl = buildClickUrl(data);
+ let targetUrl = buildClickUrl(data);
const allClients = await self.clients.matchAll({
type: "window",
@@ -748,6 +759,14 @@ async function handleNotificationClick(event) {
}
}
+ // A button on the notification. When it can't be done from here, the click
+ // carries on below and opens the message, like a click on the body.
+ const action = notificationActionOf(event, data);
+ if (action) {
+ if (await runNotificationAction(action, data, event.reply, allClients)) return;
+ if (action === "reply") targetUrl = buildClickUrl(data, true);
+ }
+
for (const client of allClients) {
// Reuse an existing tab whenever possible - users on desktop browsers
// get annoyed when each notification opens a fresh window.
@@ -920,11 +939,12 @@ function getReusableClientScore(state) {
return 3;
}
-function buildClickUrl(data) {
+function buildClickUrl(data, reply) {
if (!data) return `${BASE_PATH}/`;
const params = new URLSearchParams();
if (Number.isInteger(data.slot)) params.set("slot", String(data.slot));
if (typeof data.accountId === "string" && data.accountId) params.set("owner", data.accountId);
+ if (reply) params.set("reply", "1");
// Not params.size: Safari before 17 (iOS 16.4+ web push) lacks it.
const qs = params.toString();
const targetQuery = qs ? `?${qs}` : "";
@@ -941,3 +961,238 @@ function buildClickUrl(data) {
// app shell, so the click still feels purposeful.
return `${BASE_PATH}/?openLatestUnread=1`;
}
+
+// ---- Notification actions: Archive, Mark read, Reply ----
+//
+// A worker has no login. An open tab has one, so it goes first. With none, a
+// same-origin route acts with the login's context cookie (app/api/push/action).
+// That cookie goes with the browser session and holds a token only a page
+// refreshes, so the route can fail; the message then opens in the app.
+
+const NOTIFICATION_ACTIONS = ["archive", "mark-read", "reply"];
+// How long a tab has to take an action. A frozen tab doesn't answer.
+const CLIENT_TAKE_MS = 1_000;
+const CLIENTS_TAKE_TOTAL_MS = 2_500;
+// How long a tab that took it has before this worker stops waiting for the outcome.
+const CLIENT_RESULT_MS = 5_000;
+const ACTION_ROUTE_DEADLINE_MS = 6_000;
+// All of the above stay inside the 10 s a browser allows openWindow and focus
+// after a notification click, so the fallback can still open the message.
+
+const OUTBOX_SYNC_TAG = "bulwark-outbox";
+
+function notificationActionPrefsKey() {
+ return `${self.location.origin}${BASE_PATH}/__push-state/actions`;
+}
+
+function pendingReplyKey() {
+ return `${self.location.origin}${BASE_PATH}/__push-state/pending-reply`;
+}
+
+/**
+ * What the page mirrors into this store (lib/pwa-notify/worker-state.ts): the
+ * labels in the user's language, Archive on or off, how Archive files mail.
+ * Nothing stored: no Archive button, since the admin may have turned it off.
+ */
+async function readNotificationActionPrefs() {
+ let stored = null;
+ try {
+ const cache = await caches.open(PUSH_STATE_CACHE);
+ const res = await cache.match(notificationActionPrefsKey());
+ stored = res ? await res.json() : null;
+ } catch (_) {
+ stored = null;
+ }
+ const labels = (stored && stored.labels) || {};
+ return {
+ archiveEnabled: !!stored && stored.archiveEnabled === true,
+ archiveMode: stored && typeof stored.archiveMode === "string" ? stored.archiveMode : "single",
+ labels: {
+ archive: labels.archive || "Archive",
+ markRead: labels.markRead || "Mark as read",
+ reply: labels.reply || "Reply",
+ replyPlaceholder: labels.replyPlaceholder || "Write a reply",
+ },
+ };
+}
+
+function maxNotificationActions() {
+ if (typeof Notification === "undefined") return 0;
+ return Number.isInteger(Notification.maxActions) ? Notification.maxActions : 0;
+}
+
+function inlineReplySupported() {
+ return typeof NotificationEvent !== "undefined" && "reply" in NotificationEvent.prototype;
+}
+
+/** The buttons for a notification. Only one message, with its login known, gets any. */
+async function notificationActions(data) {
+ if (!data || data.kind !== "email" || !data.emailId || !data.accountId || !data.login) return [];
+ const max = maxNotificationActions();
+ if (max < 1) return [];
+ const prefs = await readNotificationActionPrefs();
+ const offered = {
+ "mark-read": { action: "mark-read", title: prefs.labels.markRead },
+ reply: inlineReplySupported()
+ ? { action: "reply", title: prefs.labels.reply, type: "text", placeholder: prefs.labels.replyPlaceholder }
+ : { action: "reply", title: prefs.labels.reply },
+ };
+ if (prefs.archiveEnabled) offered.archive = { action: "archive", title: prefs.labels.archive };
+ // Chromium allows two. Mark read goes first then: opening the message does
+ // that too, and nothing else on the toast archives or starts a reply.
+ const kept = ["archive", "reply", "mark-read"].filter((name) => offered[name]).slice(0, max);
+ return NOTIFICATION_ACTIONS.filter((name) => kept.includes(name)).map((name) => offered[name]);
+}
+
+/** A notification must show, whatever a browser thinks of its buttons. */
+async function showWithActions(title, options) {
+ const actions = await notificationActions(options.data);
+ if (actions.length > 0) {
+ try {
+ await self.registration.showNotification(title, { ...options, actions });
+ return;
+ } catch (_) {
+ // Shown without buttons below.
+ }
+ }
+ await self.registration.showNotification(title, options);
+}
+
+function notificationActionOf(event, data) {
+ if (!NOTIFICATION_ACTIONS.includes(event.action)) return null;
+ return data.kind === "email" && data.emailId && data.accountId ? event.action : null;
+}
+
+/** Asks one tab. Resolves "done", "failed" (it tried), or "declined" (it didn't take it). */
+function askClient(client, message) {
+ return new Promise((resolve) => {
+ if (typeof MessageChannel !== "function") {
+ resolve("declined");
+ return;
+ }
+ const channel = new MessageChannel();
+ let timer = null;
+ const finish = (result) => {
+ clearTimeout(timer);
+ channel.port1.onmessage = null;
+ resolve(result);
+ };
+ timer = setTimeout(() => finish("declined"), CLIENT_TAKE_MS);
+ channel.port1.onmessage = (answer) => {
+ const status = answer.data && answer.data.status;
+ if (status === "accepted") {
+ // It's on it. A slow server shouldn't send the action round twice.
+ clearTimeout(timer);
+ timer = setTimeout(() => finish("done"), CLIENT_RESULT_MS);
+ return;
+ }
+ finish(status === "done" || status === "failed" ? status : "declined");
+ };
+ try {
+ client.postMessage(message, [channel.port2]);
+ } catch (_) {
+ finish("declined");
+ }
+ });
+}
+
+/** The first open tab that takes the action, the one in front first. */
+async function askOpenClients(message, allClients) {
+ const score = (client) => (client.focused ? 0 : client.visibilityState === "visible" ? 1 : 2);
+ const ordered = allClients
+ .filter((client) => !client.frameType || client.frameType === "top-level")
+ .sort((a, b) => score(a) - score(b));
+ const giveUpAt = Date.now() + CLIENTS_TAKE_TOTAL_MS;
+ for (const client of ordered) {
+ if (Date.now() >= giveUpAt) break;
+ const result = await askClient(client, message);
+ if (result !== "declined") return { result, client };
+ }
+ return { result: "declined", client: null };
+}
+
+/** Keeps a typed reply for the page the click is about to open; it's not for a URL. */
+async function keepPendingReply(emailId, text) {
+ try {
+ const cache = await caches.open(PUSH_STATE_CACHE);
+ await cache.put(
+ pendingReplyKey(),
+ new Response(JSON.stringify({ emailId, text, at: Date.now() }), {
+ headers: { "Content-Type": "application/json" },
+ }),
+ );
+ } catch (_) {
+ // The composer opens empty.
+ }
+}
+
+async function postNotificationAction(action, data) {
+ const prefs = await readNotificationActionPrefs();
+ if (action === "archive" && !prefs.archiveEnabled) return false;
+ const body = { action, accountId: data.accountId, emailId: data.emailId, login: data.login };
+ if (action === "archive") {
+ body.archiveMode = prefs.archiveMode;
+ // Year and month folders go by the date the user sees.
+ if (typeof Intl !== "undefined") body.timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
+ }
+ const controller = typeof AbortController === "function" ? new AbortController() : null;
+ const timer = controller ? setTimeout(() => controller.abort(), ACTION_ROUTE_DEADLINE_MS) : null;
+ try {
+ const res = await fetch(`${BASE_PATH}/api/push/action`, {
+ method: "POST",
+ credentials: "include",
+ cache: "no-store",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ ...(controller ? { signal: controller.signal } : {}),
+ });
+ return res.ok;
+ } catch (_) {
+ return false;
+ } finally {
+ if (timer) clearTimeout(timer);
+ }
+}
+
+/** True when the action is done and nothing has to open. */
+async function runNotificationAction(action, data, reply, allClients) {
+ const text = action === "reply" && typeof reply === "string" ? reply.trim() : "";
+ const { result, client } = await askOpenClients({
+ kind: "notification-action",
+ action,
+ emailId: data.emailId,
+ threadId: data.threadId,
+ accountId: data.accountId,
+ slot: data.slot,
+ login: data.login,
+ ...(text ? { text } : {}),
+ }, allClients);
+ if (result === "done") {
+ if (action === "reply" && "focus" in client) {
+ try {
+ await client.focus();
+ } catch (_) {
+ // The composer is open in that tab; focusing can be blocked by the browser.
+ }
+ }
+ return true;
+ }
+ // A tab with a live login tried and failed: the route won't do better.
+ if (result === "failed") return false;
+ if (action === "reply") {
+ if (text) await keepPendingReply(data.emailId, text);
+ return false;
+ }
+ return postNotificationAction(action, data);
+}
+
+async function tellClientsToFlushOutbox() {
+ const allClients = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
+ for (const client of allClients) {
+ try {
+ client.postMessage({ kind: "outbox-flush" });
+ } catch (_) {
+ // Closed or detached client - ignore.
+ }
+ }
+}