fix: never let a notification's buttons go round plugins, session limits or Trash

Review of the notification actions. With a plugin that may stop a move
or read change, or a silent tab holding the login, the message now
opens instead of the route acting. The route answers 401 under a
session limit, and neither path touches a message since moved to Trash
or Junk. A tab that thawed declines an old click, Archive follows the
gate of the notification's login and files into a shared-out Archive
as the app's button does, Reply opens a composer in the Pro shell, the
route retries once when another client made the folder first and
leaves no empty folder behind, and logins match however the server
address is written.

Two assertions of this feature's own tests change: the route answers
401, not 403, when a login's scopes can't be worked out, since that now
also covers session limits; a tab's message to the page now carries
`at`.

The Pro shell's mail surface now reads a parked link's query from the
handoff: the address bar may have none by its first render, which lost
`?slot=` and `?owner=` there too.
This commit is contained in:
Linus Rath
2026-11-03 06:28:04 +01:00
parent 2bb0797996
commit 51c09a1a69
23 changed files with 952 additions and 137 deletions
+87 -45
View File
@@ -12,6 +12,7 @@ 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 type { SettingsPolicy } from '@/lib/admin/types';
import { fetchJmapServer, isTrustedJmapServerUrl } from '@/lib/stalwart/server-fetch';
export const runtime = 'nodejs';
@@ -22,6 +23,7 @@ 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}$/;
const MAILBOX_PROPERTIES = ['id', 'name', 'parentId', 'role', 'myRights'];
type ArchiveMode = 'single' | 'year' | 'month';
@@ -82,6 +84,15 @@ async function readActionRequest(request: Request): Promise<ActionRequest | null
};
}
function emailSetOutcome(set: Record<string, unknown> | undefined, emailId: string): 'updated' | 'notFound' | 'refused' {
const { updated, notUpdated } = (set ?? {}) as {
updated?: Record<string, unknown> | null;
notUpdated?: Record<string, { type?: string }> | null;
};
if (updated && Object.prototype.hasOwnProperty.call(updated, emailId)) return 'updated';
return notUpdated?.[emailId]?.type === 'notFound' ? 'notFound' : 'refused';
}
/** The signed-in logins this browser's cookies name as `login`: the notification's own, or none. */
async function loginsNamed(login: string): Promise<StalwartAuthContext[]> {
const cookieStore = await cookies();
@@ -94,15 +105,14 @@ async function loginsNamed(login: string): Promise<StalwartAuthContext[]> {
}
/**
* 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.
* The policy for this login's scopes. The worker's copies of the settings
* decide what a toast offers; this decides what works. Null when the scopes
* can't be worked out, and then nothing is done from here.
*/
async function archiveAllowed(ctx: StalwartAuthContext, trusted: boolean): Promise<boolean> {
async function policyFor(ctx: StalwartAuthContext, trusted: boolean): Promise<SettingsPolicy | null> {
await configManager.ensureLoaded();
const subject = await strictDeviceSubject({ ...ctx, trusted });
if (!subject) return false;
return resolvePolicy(configManager.getPolicy(), subject).policy.features?.archiveActionEnabled !== false;
return subject ? resolvePolicy(configManager.getPolicy(), subject).policy : null;
}
/** The year and month a message falls in where the user is. */
@@ -162,8 +172,8 @@ function archiveDestination(
*
* 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.
* refuse any other. Plugins run in pages, so the worker doesn't come here while
* an enabled plugin may stop or watch these actions.
*/
export async function POST(request: NextRequest) {
const rejected = rejectCrossOriginRequest(request);
@@ -202,6 +212,13 @@ export async function POST(request: NextRequest) {
return fail(403, 'Account is not in this login');
}
// A session limit is the page's to apply (activity, sign-in time): acting
// from here would keep a login going past it. The app opens and applies it.
const policy = await policyFor(ctx, trusted);
const limits = policy?.organisation?.session;
if (!policy || (limits?.maxHours ?? 0) > 0 || (limits?.idleMinutes ?? 0) > 0) return fail(401, 'Not authenticated');
if (body.action === 'archive' && policy.features?.archiveActionEnabled === false) return fail(403, 'Archive is turned off');
const jmap = async (methodCalls: unknown[]): Promise<MethodResponse[] | null> => {
const res = await fetchJmapServer(apiUrl, {
method: 'POST',
@@ -215,47 +232,72 @@ export async function POST(request: NextRequest) {
const result = (responses: MethodResponse[], name: string, id: string) =>
responses.find(([method, , callId]) => method === name && callId === id)?.[1];
let update: Record<string, unknown>;
let create: Record<string, Record<string, unknown>> = {};
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'],
const readFolders = async (): Promise<JmapMailbox[] | null> => {
const read = await jmap([['Mailbox/get', { accountId, ids: null, properties: MAILBOX_PROPERTIES }, 'mailboxes']]);
const list = read && result(read, 'Mailbox/get', 'mailboxes')?.list;
return Array.isArray(list) ? (list as JmapMailbox[]) : null;
};
const read = await jmap([
['Mailbox/get', { accountId, ids: null, properties: MAILBOX_PROPERTIES }, 'mailboxes'],
['Email/get', { accountId, ids: [emailId], properties: ['id', 'receivedAt', 'mailboxIds'] }, 'email'],
]);
if (!read) return fail(502, 'JMAP request failed');
let mailboxes = result(read, 'Mailbox/get', 'mailboxes')?.list as JmapMailbox[] | undefined;
const emails = result(read, 'Email/get', 'email')?.list as { receivedAt?: string; mailboxIds?: Record<string, boolean> }[] | undefined;
if (!Array.isArray(mailboxes) || !Array.isArray(emails)) return fail(502, 'JMAP request failed');
const email = emails[0];
if (!email?.receivedAt) return fail(404, 'Message not found');
// Since the notification it may have gone to Trash or Junk: not for a toast
// to bring back or to mark. The app opens it.
const bins = new Set(mailboxes.filter((m) => m.role === 'trash' || m.role === 'junk').map((m) => m.id));
if (Object.keys(email.mailboxIds ?? {}).some((id) => bins.has(id))) return fail(409, 'Message is in Trash or Junk');
let answer: ReturnType<typeof emailSetOutcome> = 'refused';
const createdFolders: string[] = [];
// Twice at most: another client may make the same folder between our read
// and our write, and then the folder is there to file into.
for (let attempt = 0; attempt < 2; attempt++) {
let update: Record<string, unknown> = { 'keywords/$seen': true };
let create: Record<string, Record<string, unknown>> = {};
if (body.action === 'archive') {
const planned = archiveDestination(mailboxes, body.archiveMode, email.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 (!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 (!written) return fail(502, 'JMAP request failed');
const folders = result(written, 'Mailbox/set', 'folders') as {
created?: Record<string, { id?: string }> | null;
notCreated?: Record<string, unknown> | null;
} | undefined;
// Deepest first, for removing them again.
for (const cid of ['month', 'year', 'archive']) {
const id = folders?.created?.[cid]?.id;
if (id) createdFolders.push(id);
}
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 } };
answer = emailSetOutcome(result(written, 'Email/set', 'write'), emailId);
if (answer !== 'refused' || Object.keys(folders?.notCreated ?? {}).length === 0 || attempt > 0) break;
const fresh = await readFolders();
if (!fresh) break;
mailboxes = fresh;
}
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<string, unknown> | null;
notUpdated?: Record<string, { type?: string }> | 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');
if (answer === 'updated') return NextResponse.json({ ok: true }, { headers: { 'Cache-Control': 'no-store' } });
// Nothing went in: no empty folder left behind. The server refuses to
// destroy one someone else has filed into since.
if (createdFolders.length > 0) await jmap([['Mailbox/set', { accountId, destroy: createdFolders }, 'cleanup']]).catch(() => null);
if (answer === 'notFound') return fail(404, 'Message not found');
return fail(502, 'JMAP server refused the change');
} catch (error) {
if (error instanceof DisallowedUrlError) {
@@ -800,6 +800,25 @@ function expectProBlob(route: typeof proRoutes[number], action: ProBlobAction, i
}
describe('Pro blob routing', () => {
it("opens a compose tab with the typed text for a notification's Reply, once, when the message is in", async () => {
const { requestTabReply } = await import('@/lib/pwa-notify/tab-reply');
const { useProTabStore } = await import('@/stores/pro-tab-store');
const { a } = await conversationClients();
vi.spyOn(a, 'getEmail').mockResolvedValue(message());
useEmailStore.setState({ emails: [], selectedEmail: null, isUnifiedView: false, selectedMailbox: 'inbox' });
useAuthStore.setState({ client: a, activeAccountId: 'login-primary' });
const composeTabs = () => useProTabStore.getState().tabs.filter((tab) => tab.kind === 'compose');
const before = composeTabs().length;
requestTabReply('email', 'Typed on the toast');
const view = render(<ProEmailTabBody tabId="reader" data={{ accountId: '', emailId: 'email', mailboxId: null, title: 'Loading' }} />);
await waitFor(() => expect(composeTabs()).toHaveLength(before + 1));
expect(composeTabs().at(-1)?.composeData).toMatchObject({ mode: 'reply', sourceEmailId: 'email', initialDraftText: 'Typed on the toast' });
view.rerender(<ProEmailTabBody tabId="reader" data={{ accountId: '', emailId: 'email', mailboxId: null, title: 'Hello' }} />);
expect(composeTabs()).toHaveLength(before + 1);
});
it.each(proRoutes.flatMap(route => proBlobActions.map(action => ({ ...route, action }))))(
'$label: $action uses the message owner and authenticated client', async route => {
const { origin, fetchMock } = await openProMessage(route, route.action);
+13 -9
View File
@@ -143,6 +143,7 @@ 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 { requestTabReply } from "@/lib/pwa-notify/tab-reply";
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";
@@ -173,7 +174,7 @@ import {
resolveFolderRef,
type MailDeepLink,
} from "@/lib/deep-links";
import { consumePendingDeepLink, subscribePendingDeepLink } from "@/lib/deep-link-handoff";
import { consumePendingDeepLinkEntry, subscribePendingDeepLink } from "@/lib/deep-link-handoff";
import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect";
import type { QuoteHeader } from "@/lib/plugin-types";
import { initialLinkSearch, useLiteLinkSegments } from "@/hooks/use-lite-link-segments";
@@ -1846,6 +1847,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// A shared account's message is fetched from that account through
// the active login (#1351).
const loginId = link.ownerAccountId ? useAuthStore.getState().activeAccountId ?? undefined : undefined;
// A notification's Reply: the tab opens its composer once it has the message.
if (link.reply) requestTabReply(link.id, opts?.replyText ?? await consumePendingReplyText(link.id));
useProTabStore.getState().openEmailTab({
accountId: link.ownerAccountId ?? '',
...(loginId ? { clientAccountId: loginId } : {}),
@@ -1931,8 +1934,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// briefly and must not steal the link parked for the Pro one.
useEffect(() => {
if (!isEmbedded) return;
return subscribePendingDeepLink('mail', (segments) => {
const link = parseMailPath(segments, new URLSearchParams(window.location.search));
return subscribePendingDeepLink('mail', (segments, search) => {
const link = parseMailPath(segments, new URLSearchParams(search ?? window.location.search));
if (link) void applyMailDeepLinkRef.current(link);
});
}, [isEmbedded]);
@@ -1983,10 +1986,13 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}
if (!initialMailLoadDone) return;
// Inside the Pro shell the route is /pro, so the link arrives through the
// handoff the redirect parked it in rather than as route params. Its query
// comes with it: the address bar may have none left by the first render.
const parked = linkSegments ? null : consumePendingDeepLinkEntry('mail');
if (parked?.search) initialSearchRef.current = parked.search;
const params = new URLSearchParams(initialSearchRef.current ?? window.location.search);
// Inside the Pro shell the route is /pro, so the segments arrive through
// the handoff the redirect parked them in rather than as route params.
const segments = linkSegments ?? consumePendingDeepLink('mail') ?? [];
const segments = linkSegments ?? parked?.segments ?? [];
const link = parseMailPath(segments, params);
const openLatestUnread = params.get('openLatestUnread') === '1';
@@ -2782,11 +2788,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
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 },
@@ -2797,7 +2801,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
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.
+9
View File
@@ -28,6 +28,7 @@ import { labelSourceHeaders, reopenedLabelChoice } from '@/lib/outgoing/classifi
import { useArchiveActionEnabled } from '@/lib/archive-gate';
import { confirmMailRemoval } from '@/lib/mail-removal-confirm';
import { useConfirmDialog } from '@/hooks/use-confirm-dialog';
import { takeTabReply, useNotificationTabReply } from '@/lib/pwa-notify/tab-reply';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { saveQuickReplyDraft } from '@/lib/outgoing/quick-reply-envelope';
import { canDownload } from '@/lib/device-policy';
@@ -262,6 +263,14 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
}, { source: email, body: draftText ?? '' });
}, [email, openComposeTab, t, writeSource]);
// A notification's Reply, once this tab has the message (mail-app.tsx).
const pendingTabReply = useNotificationTabReply((s) => s.pending);
useEffect(() => {
if (!email || pendingTabReply?.emailId !== email.id) return;
const text = takeTabReply(email.id);
if (text !== null) handleReply(text);
}, [email, pendingTabReply, handleReply]);
const handleReplyAll = useCallback(() => {
if (!email) return;
openComposeTab({
@@ -1,5 +1,6 @@
import { act, render } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint';
import { NotificationActionsHost } from '../notification-actions-host';
// The tab's end of the service worker: it keeps the worker's copy of the
@@ -9,7 +10,7 @@ import { NotificationActionsHost } from '../notification-actions-host';
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 };
let state = { isAuthenticated: true, archiveMode: 'month', archiveEnabled: true, plugins: [] as Array<Record<string, unknown>>, accounts: [] as Array<{ serverUrl: string; username: string }> };
vi.mock('@/lib/pwa-notify/worker-state', () => ({
writeNotificationActionPrefs: (...args: unknown[]) => writeNotificationActionPrefs(...args),
@@ -26,10 +27,17 @@ vi.mock('@/stores/auth-store', () => ({
vi.mock('@/stores/settings-store', () => ({
useSettingsStore: (select: (s: { archiveMode: string }) => unknown) => select({ archiveMode: state.archiveMode }),
}));
vi.mock('@/stores/plugin-store', () => ({
usePluginStore: (select: (s: { plugins: unknown[] }) => unknown) => select({ plugins: state.plugins }),
}));
vi.mock('@/stores/email-store', () => ({ useEmailStore: { getState: () => ({}) } }));
vi.mock('@/stores/account-store', () => ({ useAccountStore: { getState: () => ({ accounts: [] }) } }));
vi.mock('@/stores/account-store', () => {
const useAccountStore = (select: (s: { accounts: unknown[] }) => unknown) => select({ accounts: state.accounts });
useAccountStore.getState = () => ({ accounts: state.accounts });
return { useAccountStore };
});
const worker = new EventTarget();
const worker = Object.assign(new EventTarget(), { controller: null as null | { postMessage: ReturnType<typeof vi.fn> } });
const fromWorker = (data: unknown, ports: unknown[] = []) => act(async () => {
const event = new Event('message');
Object.assign(event, { data, ports });
@@ -38,7 +46,8 @@ const fromWorker = (data: unknown, ports: unknown[] = []) => act(async () => {
beforeEach(() => {
vi.clearAllMocks();
state = { isAuthenticated: true, archiveMode: 'month', archiveEnabled: true };
state = { isAuthenticated: true, archiveMode: 'month', archiveEnabled: true, plugins: [], accounts: [] };
worker.controller = null;
Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: worker });
});
@@ -52,6 +61,7 @@ describe('the mirror for the worker', () => {
expect(writeNotificationActionPrefs).toHaveBeenCalledWith({
archiveEnabled: true,
archiveMode: 'month',
pluginsMayVeto: false,
labels: { archive: 'action_archive', markRead: 'action_mark_read', reply: 'action_reply', replyPlaceholder: 'reply_placeholder' },
});
});
@@ -84,8 +94,44 @@ describe('the mirror for the worker', () => {
});
});
describe('what the worker hears about plugins and logins', () => {
it('says when an enabled plugin may stop these actions, and when that ends', () => {
state.plugins = [{ enabled: true, permissions: ['email:write'] }];
const { rerender } = render(<NotificationActionsHost />);
expect(writeNotificationActionPrefs.mock.calls.at(-1)?.[0]).toMatchObject({ pluginsMayVeto: true });
state.plugins = [{ enabled: false, permissions: ['email:write'] }];
rerender(<NotificationActionsHost />);
expect(writeNotificationActionPrefs.mock.calls.at(-1)?.[0]).toMatchObject({ pluginsMayVeto: false });
});
it('tells the worker which logins this tab holds, as their fingerprints', async () => {
const postMessage = vi.fn();
worker.controller = { postMessage };
state.accounts = [{ serverUrl: 'https://one.example', username: 'alice@example.org' }];
await act(async () => { render(<NotificationActionsHost />); });
await vi.waitFor(() => expect(postMessage).toHaveBeenCalled());
expect(postMessage).toHaveBeenCalledWith({
type: 'client-logins',
logins: [await loginFingerprint('https://one.example', 'alice@example.org')],
});
});
it('tells it again when the logins change', async () => {
const postMessage = vi.fn();
worker.controller = { postMessage };
state.accounts = [{ serverUrl: 'https://one.example', username: 'alice@example.org' }];
let view!: ReturnType<typeof render>;
await act(async () => { view = render(<NotificationActionsHost />); });
await vi.waitFor(() => expect(postMessage).toHaveBeenCalledTimes(1));
state.accounts = [];
await act(async () => { view.rerender(<NotificationActionsHost />); });
await vi.waitFor(() => expect(postMessage).toHaveBeenCalledTimes(2));
expect(postMessage).toHaveBeenLastCalledWith({ type: 'client-logins', logins: [] });
});
});
describe('messages from the worker', () => {
const request = { kind: 'notification-action', action: 'mark-read', emailId: 'e1', accountId: 'a', slot: 0, login: 'fp' };
const request = { kind: 'notification-action', action: 'mark-read', emailId: 'e1', accountId: 'a', slot: 0, login: 'fp', at: Date.now() };
it('runs a notification action and answers on its port', async () => {
render(<NotificationActionsHost />);
@@ -1,25 +1,31 @@
'use client';
import { useEffect } from 'react';
import { useEffect, useMemo } from 'react';
import { useTranslations } from 'next-intl';
import { useArchiveActionEnabled } from '@/lib/archive-gate';
import { scheduleFlushAll } from '@/lib/offline/offline-mode';
import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint';
import { isNotificationActionRequest, runNotificationAction } from '@/lib/pwa-notify/page-actions';
import { pluginsMayVeto } from '@/lib/pwa-notify/plugin-veto';
import { writeNotificationActionPrefs } from '@/lib/pwa-notify/worker-state';
import { useAccountStore } from '@/stores/account-store';
import { useAuthStore } from '@/stores/auth-store';
import { usePluginStore } from '@/stores/plugin-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).
* worker's copy of the labels and archive settings current, says which logins
* it holds, 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 mayVeto = usePluginStore((s) => pluginsMayVeto(s.plugins));
const accounts = useAccountStore((s) => s.accounts);
const archive = t('action_archive');
const markRead = t('action_mark_read');
const reply = t('action_reply');
@@ -31,9 +37,30 @@ export function NotificationActionsHost() {
void writeNotificationActionPrefs({
archiveEnabled,
archiveMode,
pluginsMayVeto: mayVeto,
labels: { archive, markRead, reply, replyPlaceholder },
});
}, [isAuthenticated, archiveEnabled, archiveMode, archive, markRead, reply, replyPlaceholder]);
}, [isAuthenticated, archiveEnabled, archiveMode, mayVeto, archive, markRead, reply, replyPlaceholder]);
// Which logins this tab holds: if it stays silent on a click for one of them,
// the worker opens the message rather than go round its plugins.
const loginKey = useMemo(() => accounts.map((a) => `${a.serverUrl}\n${a.username}`).join('\n\n'), [accounts]);
useEffect(() => {
const worker = navigator.serviceWorker;
if (!worker) return;
let stale = false;
void (async () => {
try {
const held = await Promise.all(useAccountStore.getState().accounts.map((a) => loginFingerprint(a.serverUrl, a.username)));
// Without a worker `ready` never settles, and nothing needs telling.
const target = worker.controller ?? (await worker.ready).active;
if (!stale) target?.postMessage({ type: 'client-logins', logins: held });
} catch {
// The worker counts this tab as not holding them.
}
})();
return () => { stale = true; };
}, [loginKey, isAuthenticated]);
useEffect(() => {
const worker = navigator.serviceWorker;
+87 -1
View File
@@ -1,6 +1,6 @@
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';
import { ENT, adminContext, deliver, jmapAs, requireEntEnv, userPage, withPolicy } from './helpers/ent';
/**
* A notification's Archive, Mark read and Reply, and Background Sync, in
@@ -395,6 +395,35 @@ test.describe('with no tab open', () => {
}
});
test('with an enabled plugin that may stop it, the route is skipped and the message opens', 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, pluginsMayVeto: true })));
});
await closeTheApp();
await routePosts(sw);
await click(sw, 'mark-read', shown.data);
expect(await routePosts(sw)).toEqual([]);
expect((await opened(sw))[0]).toContain(`/mail/message/${mail.id}`);
expect((await serverCopy(mail.id)).keywords.$seen).toBeFalsy();
});
test('a message since moved to Trash stays there, and opens in the app', async () => {
const mail = await oneUnread();
const shown = await pushed(sw, mail.accountId, mail.id);
await closeTheApp();
const client = await jmapAs(USER);
const trash = await client.mailboxByRole('trash');
await client.moveEmail(mail.id, trash!.id);
await click(sw, 'archive', shown.data);
expect((await opened(sw))[0]).toContain(`/mail/message/${mail.id}`);
expect(Object.keys((await serverCopy(mail.id)).mailboxIds)).toEqual([trash!.id]);
});
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);
@@ -448,6 +477,63 @@ test.describe('with no tab open', () => {
});
});
test.describe('Reply in the Pro shell', () => {
test('opens the composer in the message tab, with the typed text, from the live tab and from the link', async ({ browser }) => {
const page = await userPage(browser, USER);
const context = page.context();
await context.grantPermissions(['notifications']);
await page.evaluate(() => {
const raw = localStorage.getItem('settings-storage');
const stored = raw ? JSON.parse(raw) : { state: {} };
stored.state = { ...stored.state, proInterface: true };
localStorage.setItem('settings-storage', JSON.stringify(stored));
});
await page.goto('/pro');
await expect(page.locator('[data-pro-takeover]')).toHaveCount(0, { timeout: 60_000 });
const sw = await workerOf(context, page);
const replyIsOpen = (subject: string) => page.evaluate((text) => {
const inputs = [...document.querySelectorAll('input')] as HTMLInputElement[];
return inputs.some((input) => input.value === `Re: ${text}`);
}, subject);
const live = await oneUnread();
const liveShown = await pushed(sw, live.accountId, live.id);
// Until the shell's mail surface is up the tab declines, and the worker
// falls back to the link: ask again. Taken once, no window opens.
await expect.poll(async () => {
await click(sw, 'reply', liveShown.data, `Pro typed ${RUN}`);
return (await opened(sw)).length;
}, { timeout: 45_000, intervals: [3_000] }).toBe(0);
await expect.poll(() => replyIsOpen(live.subject), { timeout: 30_000 }).toBe(true);
await expect(page.getByText(`Pro typed ${RUN}`).first()).toBeVisible({ timeout: 10_000 });
const linked = await oneUnread();
await page.goto(`/mail/message/${linked.id}?owner=${linked.accountId}&reply=1`);
await expect.poll(() => replyIsOpen(linked.subject), { timeout: 60_000 }).toBe(true);
await context.close();
});
});
test.describe('session limits', () => {
test('under a maximum session age the route answers 401 and writes nothing', async ({ browser }) => {
const admin = await adminContext(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);
await withPolicy(admin, { organisation: { session: { idleMinutes: 0, maxHours: 8, warnSeconds: 60 } } }, async () => {
const res = await page.request.post('/api/push/action', {
data: { action: 'mark-read', accountId: mail.accountId, emailId: mail.id, login: shown.data.login },
});
expect(res.status()).toBe(401);
});
expect((await serverCopy(mail.id)).keywords.$seen).toBeFalsy();
await page.context().close();
await admin.close();
});
});
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);
@@ -29,6 +29,7 @@ describe('routes no plugin may POST to', () => {
'/api/%70lugin-proxy',
'/api//plugin-secrets',
'/api/account/stalwart/jmap',
'/api/push/action',
])('%s is denied', (path) => {
expect(isPluginPostDenied(path)).toBe(true);
});
+121 -6
View File
@@ -34,6 +34,11 @@ let sessionStatus = 200;
let mailboxes: Array<Record<string, unknown>>;
let email: Record<string, unknown> | null;
let emailSetResult: Record<string, unknown> | null = null;
// The answer to each Mailbox/set in turn; `undefined`: everything was created.
let mailboxSetResults: Array<Record<string, unknown>> = [];
let mailboxReads = 0;
let mailboxesOnSecondRead: Array<Record<string, unknown>> | null = null;
let emailSetResults: Array<Record<string, unknown>> = [];
let sessionsAsked = 0;
const json = (data: unknown, status = 200) => new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json' } });
@@ -50,11 +55,19 @@ vi.mock('@/lib/stalwart/server-fetch', () => ({
}
return json({
methodResponses: calls.map(([name, args, id]) => {
if (name === 'Mailbox/get') return [name, { list: mailboxes }, id];
if (name === 'Mailbox/get') {
mailboxReads++;
return [name, { list: mailboxReads >= 2 && mailboxesOnSecondRead ? mailboxesOnSecondRead : mailboxes }, id];
}
if (name === 'Email/get') return [name, { list: email ? [email] : [] }, id];
if (name === 'Mailbox/set') return [name, { created: {} }, id];
if (name === 'Mailbox/set') {
if (args.destroy) return [name, { destroyed: args.destroy }, id];
return [name, mailboxSetResults.shift() ?? {
created: Object.fromEntries(Object.keys(args.create as object).map((cid) => [cid, { id: `new-${cid}` }])),
}, id];
}
const emailId = Object.keys(args.update as Record<string, unknown>)[0];
return [name, emailSetResult ?? { updated: { [emailId]: null } }, id];
return [name, emailSetResults.shift() ?? emailSetResult ?? { updated: { [emailId]: null } }, id];
}),
});
},
@@ -74,12 +87,13 @@ async function call(body: Record<string, unknown>, headers: Record<string, strin
}
const fingerprint = (login: Login) => loginFingerprint(login.serverUrl, login.username);
const destroyed = () => writes.flatMap((w) => w.calls).find(([name, args]) => name === 'Mailbox/set' && args.destroy)?.[1].destroy;
const markRead = async (over: Record<string, unknown> = {}) => ({
action: 'mark-read', accountId: 'a', emailId: 'e1', login: await fingerprint(ALICE), ...over,
});
const archive = async (over: Record<string, unknown> = {}) => ({ ...(await markRead()), action: 'archive', ...over });
const emailSet = () => {
const call = writes[0].calls.find(([name]) => name === 'Email/set');
const call = writes.at(-1)!.calls.find(([name]) => name === 'Email/set');
return call?.[1] as { accountId: string; update: Record<string, Record<string, unknown>> };
};
const mailboxSet = () => writes[0].calls.find(([name]) => name === 'Mailbox/set')?.[1] as { create: Record<string, Record<string, unknown>> } | undefined;
@@ -97,8 +111,12 @@ beforeEach(() => {
accounts: { a: { name: 'alice@example.org', accountCapabilities: { 'urn:ietf:params:jmap:mail': {} } } },
};
mailboxes = [INBOX, ARCHIVE];
email = { id: 'e1', receivedAt: '2026-03-31T23:30:00Z' };
email = { id: 'e1', receivedAt: '2026-03-31T23:30:00Z', mailboxIds: { inbox: true } };
emailSetResult = null;
mailboxSetResults = [];
mailboxReads = 0;
mailboxesOnSecondRead = null;
emailSetResults = [];
});
describe('who may ask', () => {
@@ -154,6 +172,13 @@ describe('which login', () => {
expect(writes).toHaveLength(0);
});
it('matches a server address written differently on either side', async () => {
LOGINS = [{ ...ALICE, serverUrl: 'https://one.example' }];
const login = await loginFingerprint('https://ONE.example:443/', ALICE.username);
expect((await call(await markRead({ login }))).status).toBe(200);
expect(writes).toHaveLength(1);
});
it('answers 401 for an expired token: only a page refreshes it', async () => {
sessionStatus = 401;
expect((await call(await markRead())).status).toBe(401);
@@ -179,6 +204,53 @@ describe('which login', () => {
});
});
describe('session limits', () => {
// The page applies them, from activity and sign-in time: an action from here
// would keep the login going past them.
it.each([
['a maximum session age', { maxHours: 8 }],
['an idle limit', { idleMinutes: 30 }],
])('answers 401 under %s, so the app opens and applies it', async (_name, session) => {
policy = { organisation: { session } };
expect((await call(await markRead())).status).toBe(401);
expect((await call(await archive())).status).toBe(401);
expect(writes).toHaveLength(0);
});
it('follows the scope this login is in', async () => {
policy = { scopes: [{ id: 'team', label: 'Team', match: { domains: ['example.org'] }, policy: { organisation: { session: { maxHours: 8 } } } }] };
expect((await call(await markRead())).status).toBe(401);
expect(writes).toHaveLength(0);
});
it("isn't troubled by a scope this login is not in", async () => {
policy = { scopes: [{ id: 'other', label: 'Other', match: { domains: ['other.test'] }, policy: { organisation: { session: { maxHours: 8 } } } }] };
expect((await call(await markRead())).status).toBe(200);
});
it("answers 401 when the login's scopes can't be worked out", async () => {
subject = null;
expect((await call(await markRead())).status).toBe(401);
expect(writes).toHaveLength(0);
});
});
describe('a message since moved to Trash or Junk', () => {
it.each(['trash', 'junk'])('stays as it is in %s, and the app opens it', async (role) => {
mailboxes = [INBOX, ARCHIVE, { id: 'bin', name: 'Bin', role }];
email = { id: 'e1', receivedAt: '2026-03-31T23:30:00Z', mailboxIds: { bin: true } };
expect((await call(await markRead())).status).toBe(409);
expect((await call(await archive())).status).toBe(409);
expect(writes).toHaveLength(0);
});
it('answers 404 for Mark read on a message that is gone, writing nothing', async () => {
email = null;
expect((await call(await markRead())).status).toBe(404);
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 } });
@@ -286,6 +358,48 @@ describe('Archive', () => {
expect(writes).toHaveLength(0);
});
it('files into a folder another client made first, on one more try', async () => {
mailboxes = [INBOX, ARCHIVE];
mailboxesOnSecondRead = [INBOX, ARCHIVE, { id: 'y26', name: '2026', parentId: 'arch' }];
mailboxSetResults = [{ notCreated: { year: { type: 'alreadyExists' } } }];
emailSetResults = [{ notUpdated: { e1: { type: 'invalidProperties' } } }];
expect((await call(await archive({ archiveMode: 'year', timeZone: 'UTC' }))).status).toBe(200);
expect(mailboxReads).toBe(2);
expect(writes).toHaveLength(2);
expect(emailSet().update.e1).toEqual({ mailboxIds: { y26: true } });
expect(destroyed()).toBeUndefined();
});
it('tries only once more, and stops when the second try fails too', async () => {
mailboxes = [INBOX, ARCHIVE];
mailboxSetResults = [
{ notCreated: { year: { type: 'alreadyExists' } } },
{ notCreated: { year: { type: 'alreadyExists' } } },
];
emailSetResult = { notUpdated: { e1: { type: 'invalidProperties' } } };
expect((await call(await archive({ archiveMode: 'year', timeZone: 'UTC' }))).status).toBe(502);
expect(writes).toHaveLength(2);
});
it('leaves no empty folder when the message then is refused, deepest first', async () => {
mailboxes = [INBOX];
emailSetResult = { notUpdated: { e1: { type: 'forbidden' } } };
expect((await call(await archive({ archiveMode: 'month', timeZone: 'UTC' }))).status).toBe(502);
expect(destroyed()).toEqual(['new-month', 'new-year', 'new-archive']);
});
it('keeps the folders it made when the message went in', async () => {
mailboxes = [INBOX];
expect((await call(await archive({ archiveMode: 'month', timeZone: 'UTC' }))).status).toBe(200);
expect(destroyed()).toBeUndefined();
});
it("files into an Archive shared with others, as the app's own button does", async () => {
mailboxes = [INBOX, { ...ARCHIVE, shareWith: { u2: { mayReadItems: true } } }];
expect((await call(await archive())).status).toBe(200);
expect(emailSet().update.e1).toEqual({ mailboxIds: { arch: true } });
});
describe('the admin gate', () => {
it('refuses when the admin turned Archive off, whatever the worker believes', async () => {
policy = { features: { archiveActionEnabled: false } };
@@ -299,9 +413,10 @@ describe('Archive', () => {
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((await call(await archive())).status).toBe(401);
expect(writes).toHaveLength(0);
});
+84 -1
View File
@@ -10,6 +10,7 @@ const PUSH_CACHE = 'bulwark-push-state-v1';
const PREFS = {
archiveEnabled: true,
archiveMode: 'month',
pluginsMayVeto: false,
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' };
@@ -210,7 +211,7 @@ describe('Mark read and Archive', () => {
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',
kind: 'notification-action', action: 'mark-read', emailId: 'e1', threadId: 't1', accountId: 'a', slot: 2, login: 'fp', at: expect.any(Number),
});
expect(w.posts()).toHaveLength(0);
expect(client.navigate).not.toHaveBeenCalled();
@@ -256,6 +257,77 @@ describe('Mark read and Archive', () => {
expect(body.timeZone).not.toBe('');
});
it('send when they asked, so a tab that thaws later can tell', async () => {
const client = tab('t', ['done']);
const before = Date.now();
const w = worker({ caches: await cachesWith(PREFS), clients: [client] });
await w.click('mark-read');
const at = client.messages.find((m) => m.kind === 'notification-action')?.at as number;
expect(at).toBeGreaterThanOrEqual(before);
expect(at).toBeLessThanOrEqual(Date.now());
});
it('leave the route alone while an enabled plugin may stop them, and open the message', async () => {
const w = worker({ caches: await cachesWith({ ...PREFS, pluginsMayVeto: true }) });
await w.click('mark-read');
expect(w.posts()).toHaveLength(0);
expect(w.openWindow).toHaveBeenCalledWith(MESSAGE_URL);
});
it('leave the route alone before any page has said what plugins there are', async () => {
const w = worker({ caches: await cachesWith() });
await w.click('mark-read');
expect(w.posts()).toHaveLength(0);
expect(w.openWindow).toHaveBeenCalledWith(MESSAGE_URL);
});
it('open the message when a tab holding this login stays silent, without the route', async () => {
const silent = tab('held', []);
const w = worker({ caches: await cachesWith(PREFS), clients: [silent] });
await w.dispatch('message', { data: { type: 'client-logins', logins: ['fp'] }, source: { id: 'held' } });
await w.click('archive');
expect(w.posts()).toHaveLength(0);
expect(silent.focus).toHaveBeenCalled();
expect(silent.navigate).toHaveBeenCalledWith(MESSAGE_URL);
});
it('give a busy tab holding this login longer than the others', async () => {
const busy = tab('held', []);
const post = busy.postMessage;
busy.postMessage = (message: Record<string, unknown>, ports?: MessagePort[]) => {
post(message, ports);
if (message.kind === 'notification-action') setTimeout(() => ports?.[0]?.postMessage({ status: 'done' }), 1_500);
};
const w = worker({ caches: await cachesWith(PREFS), clients: [busy] });
await w.dispatch('message', { data: { type: 'client-logins', logins: ['fp'] }, source: { id: 'held' } });
await w.click('mark-read');
expect(busy.navigate).not.toHaveBeenCalled();
expect(w.posts()).toHaveLength(0);
expect(w.openWindow).not.toHaveBeenCalled();
});
it('take another tab that answers over a silent one holding this login', async () => {
const silent = tab('held', [], { visibilityState: 'visible', focused: true });
const alive = tab('alive', ['done']);
const w = worker({ caches: await cachesWith(PREFS), clients: [silent, alive] });
await w.dispatch('message', { data: { type: 'client-logins', logins: ['fp'] }, source: { id: 'held' } });
await w.click('mark-read');
expect(alive.messages.some((m) => m.kind === 'notification-action')).toBe(true);
expect(silent.navigate).not.toHaveBeenCalled();
expect(w.posts()).toHaveLength(0);
});
it('forget tabs that are gone when another reports its logins', async () => {
const caches = await cachesWith(PREFS);
const clients = [tab('one', []), tab('two', [])];
const w = worker({ caches, clients });
await w.dispatch('message', { data: { type: 'client-logins', logins: ['fp'] }, source: { id: 'one' } });
clients.shift();
await w.dispatch('message', { data: { type: 'client-logins', logins: ['other'] }, source: { id: 'two' } });
const cache = await caches.open(PUSH_CACHE);
expect(await (await cache.match(`${ORIGIN}/__push-state/client-logins`))!.json()).toEqual({ two: ['other'] });
});
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] });
@@ -330,6 +402,17 @@ describe('Reply', () => {
expect(kept).toMatchObject({ emailId: 'e1', text: 'Private words' });
});
it('keeps typed text and the reply link when a tab holding this login stays silent', async () => {
const caches = await cachesWith(PREFS);
const silent = tab('held', []);
const w = worker({ caches, clients: [silent] });
await w.dispatch('message', { data: { type: 'client-logins', logins: ['fp'] }, source: { id: 'held' } });
await w.click('reply', { reply: 'Private words' });
expect(silent.navigate).toHaveBeenCalledWith(`${MESSAGE_URL}&reply=1`);
const cache = await caches.open(PUSH_CACHE);
expect(await (await cache.match(`${ORIGIN}/__push-state/pending-reply`))!.json()).toMatchObject({ text: 'Private words' });
});
it('leaves nothing in the store without typed text', async () => {
const caches = await cachesWith(PREFS);
const w = worker({ caches });
@@ -0,0 +1,42 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest';
import { loginFingerprint } from '../login-fingerprint';
// The registry keeps the server address as typed, the context cookie as the
// login route normalised it. Both must name the same login.
describe('loginFingerprint', () => {
const base = 'https://mail.example.org';
const USER = 'alice@example.org';
it.each([
'https://Mail.Example.org',
'https://MAIL.EXAMPLE.ORG/',
'https://mail.example.org:443',
'https://mail.example.org:443/',
'https://mail.example.org//',
])('reads %s as the same server', async (raw) => {
expect(await loginFingerprint(raw, USER)).toBe(await loginFingerprint(base, USER));
});
it('drops the default port of http too', async () => {
expect(await loginFingerprint('http://mail.example.org:80', USER)).toBe(await loginFingerprint('http://mail.example.org', USER));
});
it('ignores the case of the username and stray spaces', async () => {
expect(await loginFingerprint(base, ' Alice@Example.org ')).toBe(await loginFingerprint(base, USER));
});
it.each([
['another host', 'https://other.example.org', USER],
['another port', 'https://mail.example.org:8443', USER],
['another scheme', 'http://mail.example.org', USER],
['another path', 'https://mail.example.org/jmap', USER],
['another user', base, 'bob@example.org'],
])('tells %s apart', async (_name, server, user) => {
expect(await loginFingerprint(server, user)).not.toBe(await loginFingerprint(base, USER));
});
it('keeps going on an address that is no URL', async () => {
expect(await loginFingerprint('not a url/', USER)).toBe(await loginFingerprint('not a url', USER));
});
});
+117 -12
View File
@@ -11,11 +11,13 @@ const accounts = [
];
const makeClient = (accountId: string) => ({
getAccountId: () => accountId,
getEmail: vi.fn(async (id: string) => (id === 'gone' ? null : { id, receivedAt: '2026-01-01T00:00:00Z' })),
getEmail: vi.fn(async (id: string) => (id === 'gone' ? null : { id, receivedAt: '2026-01-01T00:00:00Z', mailboxIds: { [id === 'binned' ? 'bin' : 'inbox']: true } })),
getMailboxes: vi.fn(async () => [{ id: 'inbox', role: 'inbox' }, { id: 'x', originalId: 'bin', role: 'trash' }]),
});
let clients: Record<string, ReturnType<typeof makeClient>>;
let activeAccountId = 'alice@one';
let archiveEnabled = true;
const gateOff = new Set<string>();
let hooksRegistered = false;
const batchMarkAsRead = vi.fn(async (..._args: unknown[]) => true);
const batchArchive = vi.fn(async (..._args: unknown[]) => true);
@@ -30,7 +32,17 @@ vi.mock('@/stores/auth-store', () => ({
},
}));
vi.mock('@/stores/email-store', () => ({ useEmailStore: { getState: () => ({ batchMarkAsRead, batchArchive }) } }));
vi.mock('@/lib/archive-gate', () => ({ isArchiveActionEnabled: () => archiveEnabled }));
// Each login's own subject, and a policy that reads it: the gate of the login
// the notification is for, not of the one that's active here.
vi.mock('@/lib/org/account-subjects', () => ({ subjectForAccount: (id: string) => ({ email: id, groups: [] }) }));
vi.mock('@/stores/policy-store', () => ({
usePolicyStore: {
getState: () => ({
resolveFor: (subject: { email: string }) => ({ features: { archiveActionEnabled: !gateOff.has(subject.email) } }),
}),
},
}));
vi.mock('../plugin-veto', () => ({ vetoHooksRegistered: () => hooksRegistered }));
async function run(over: Partial<NotificationActionRequest> = {}) {
const request: NotificationActionRequest = {
@@ -39,6 +51,7 @@ async function run(over: Partial<NotificationActionRequest> = {}) {
accountId: 'a',
slot: 0,
login: await loginFingerprint('https://one.example', 'alice@example.org'),
at: Date.now(),
...over,
};
const answers: NotificationActionStatus[] = [];
@@ -50,7 +63,8 @@ beforeEach(() => {
vi.clearAllMocks();
clients = { 'alice@one': makeClient('a'), 'bob@two': makeClient('b') };
activeAccountId = 'alice@one';
archiveEnabled = true;
gateOff.clear();
hooksRegistered = false;
batchMarkAsRead.mockResolvedValue(true);
batchArchive.mockResolvedValue(true);
});
@@ -62,6 +76,11 @@ describe('which notifications a tab takes', () => {
expect(batchMarkAsRead).not.toHaveBeenCalled();
});
it('takes a notification whose server address was written differently', async () => {
const answers = await run({ login: await loginFingerprint('https://ONE.example:443/', 'alice@example.org') });
expect(answers).toEqual(['accepted', 'done']);
});
it('declines a slot it has no login for', async () => {
expect(await run({ slot: 7 })).toEqual(['declined']);
});
@@ -77,15 +96,55 @@ describe('which notifications a tab takes', () => {
expect(await run()).toEqual(['declined']);
});
it('declines a click from long ago: a tab that thawed has lost it', async () => {
expect(await run({ at: Date.now() - 6_000 })).toEqual(['declined']);
expect(batchMarkAsRead).not.toHaveBeenCalled();
const opener = vi.fn();
const remove = setNotificationReplyOpener(opener);
expect(await run({ action: 'reply', at: Date.now() - 6_000 })).toEqual(['declined']);
expect(opener).not.toHaveBeenCalled();
remove();
});
it('takes one from a moment ago', async () => {
expect(await run({ at: Date.now() - 2_000 })).toEqual(['accepted', 'done']);
});
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);
const at = 1;
expect(isNotificationActionRequest({ action: 'archive', emailId: 'e', accountId: 'a', at })).toBe(true);
expect(isNotificationActionRequest({ action: 'archive', emailId: 'e', accountId: 'a' })).toBe(false);
expect(isNotificationActionRequest({ action: 'delete', emailId: 'e', accountId: 'a', at })).toBe(false);
expect(isNotificationActionRequest({ action: 'archive', emailId: '', accountId: 'a', at })).toBe(false);
expect(isNotificationActionRequest({ action: 'archive', emailId: 'e', at })).toBe(false);
expect(isNotificationActionRequest(null)).toBe(false);
});
});
describe('with plugins listening on a veto hook', () => {
beforeEach(() => { hooksRegistered = true; });
it("doesn't pass on a shared account's Archive to the route, which has no plugins", async () => {
expect(await run({ action: 'archive', accountId: 'team' })).toEqual(['failed']);
expect(batchArchive).not.toHaveBeenCalled();
});
it("doesn't pass on a login that isn't connected here", async () => {
delete clients['alice@one'];
activeAccountId = 'bob@two';
expect(await run()).toEqual(['failed']);
});
it('still declines a notification of a login this tab does not hold', async () => {
expect(await run({ slot: 7 })).toEqual(['declined']);
});
it('runs what it can itself, through the store, where the hooks are', async () => {
expect(await run()).toEqual(['accepted', 'done']);
expect(batchMarkAsRead).toHaveBeenCalled();
});
});
describe('Mark read', () => {
it('goes through the store, with the login of the notification', async () => {
expect(await run()).toEqual(['accepted', 'done']);
@@ -106,12 +165,30 @@ describe('Mark read', () => {
it('names a shared account', async () => {
await run({ accountId: 'team' });
expect((batchMarkAsRead.mock.calls[0][2] as { accountId?: string }).accountId).toBe('team');
expect(clients['alice@one'].getEmail).toHaveBeenCalledWith('e1', 'team');
});
it('reports a failure, so the worker opens the message', async () => {
batchMarkAsRead.mockRejectedValue(new Error('forbidden'));
expect(await run()).toEqual(['accepted', 'failed']);
});
it('reports a plugin stopping it', async () => {
batchMarkAsRead.mockResolvedValue(false);
expect(await run()).toEqual(['accepted', 'failed']);
});
it('still queues with the mail server out of reach, like the app\'s own button', async () => {
clients['alice@one'].getEmail.mockRejectedValue(new Error('offline'));
clients['alice@one'].getMailboxes.mockRejectedValue(new Error('offline'));
expect(await run()).toEqual(['accepted', 'done']);
expect(batchMarkAsRead).toHaveBeenCalled();
});
it('leaves a message since moved to Trash alone, by the server id of the folder', async () => {
expect(await run({ emailId: 'binned' })).toEqual(['accepted', 'failed']);
expect(batchMarkAsRead).not.toHaveBeenCalled();
});
});
describe('Archive', () => {
@@ -122,16 +199,28 @@ describe('Archive', () => {
expect(target).toEqual({
emailIds: ['e1'],
client: clients['alice@one'],
emails: [{ id: 'e1', receivedAt: '2026-01-01T00:00:00Z' }],
emails: [{ id: 'e1', receivedAt: '2026-01-01T00:00:00Z', mailboxIds: { inbox: true } }],
userArchive: true,
});
});
it('declines when the admin turned it off', async () => {
archiveEnabled = false;
expect(await run({ action: 'archive' })).toEqual(['declined']);
it('fails when the admin turned it off for this login, and the message opens', async () => {
gateOff.add('alice@one');
expect(await run({ action: 'archive' })).toEqual(['failed']);
expect(batchArchive).not.toHaveBeenCalled();
});
it("goes by the notification's login, not the active one", async () => {
const bob = { slot: 1, accountId: 'b', login: await loginFingerprint('https://two.example', 'bob@example.org') };
gateOff.add('bob@two');
expect(await run({ action: 'archive', ...bob })).toEqual(['failed']);
expect(batchArchive).not.toHaveBeenCalled();
gateOff.clear();
gateOff.add('alice@one');
expect(await run({ action: 'archive', ...bob })).toEqual(['accepted', 'done']);
});
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();
@@ -142,6 +231,17 @@ describe('Archive', () => {
expect(batchArchive).not.toHaveBeenCalled();
});
it('reports a message it can\'t read with the mail server out of reach', async () => {
clients['alice@one'].getEmail.mockRejectedValue(new Error('offline'));
expect(await run({ action: 'archive' })).toEqual(['accepted', 'failed']);
expect(batchArchive).not.toHaveBeenCalled();
});
it('leaves a message since moved to Trash alone', async () => {
expect(await run({ action: 'archive', emailId: 'binned' })).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']);
@@ -153,6 +253,11 @@ describe('Reply', () => {
expect(await run({ action: 'reply' })).toEqual(['declined']);
});
it('declines on such a page with plugins too: a link opens the composer, nothing is skipped', async () => {
hooksRegistered = true;
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);
@@ -0,0 +1,36 @@
import { afterEach, describe, expect, it } from 'vitest';
import { hookSpec } from '@/lib/plugin-hook-catalog';
import { emailHooks } from '@/lib/plugin-hooks';
import { pluginsMayVeto, vetoHooksRegistered } from '../plugin-veto';
describe('pluginsMayVeto', () => {
it('reads the permission off the catalogue, which is email:write for all three today', () => {
for (const name of ['onBeforeEmailMove', 'onBeforeEmailArchive', 'onBeforeEmailReadStateChange']) {
expect(hookSpec(name)?.permission).toBe('email:write');
}
});
it('is true for an enabled plugin with that permission', () => {
expect(pluginsMayVeto([{ enabled: true, permissions: ['email:read', 'email:write'] }])).toBe(true);
});
it('is false for a disabled one, for other permissions and for none', () => {
expect(pluginsMayVeto([{ enabled: false, permissions: ['email:write'] }])).toBe(false);
expect(pluginsMayVeto([{ enabled: true, permissions: ['email:read'] }])).toBe(false);
expect(pluginsMayVeto([{ enabled: true }])).toBe(false);
expect(pluginsMayVeto([])).toBe(false);
});
});
describe('vetoHooksRegistered', () => {
const stops: Array<() => void> = [];
afterEach(() => { stops.splice(0).forEach((stop) => stop()); });
it.each(['onBeforeEmailMove', 'onBeforeEmailArchive', 'onBeforeEmailReadStateChange'] as const)('sees a listener on %s', (name) => {
expect(vetoHooksRegistered()).toBe(false);
const bus = emailHooks[name] as unknown as { register(id: string, fn: () => boolean): { dispose(): void } };
const handle = bus.register('test-plugin', () => true);
stops.push(() => handle.dispose());
expect(vetoHooksRegistered()).toBe(true);
});
});
@@ -0,0 +1,33 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { requestTabReply, takeTabReply, useNotificationTabReply } from '../tab-reply';
afterEach(() => {
vi.useRealTimers();
useNotificationTabReply.setState({ pending: null });
});
describe('a notification reply waiting for its Pro tab', () => {
it('reaches that message once, with the text', () => {
requestTabReply('e1', 'Thanks');
expect(takeTabReply('e1')).toBe('Thanks');
expect(takeTabReply('e1')).toBeNull();
});
it('keeps an empty reply apart from none', () => {
requestTabReply('e1', '');
expect(takeTabReply('e1')).toBe('');
});
it('stays for its own message when another tab loads', () => {
requestTabReply('e1', 'Thanks');
expect(takeTabReply('e2')).toBeNull();
expect(takeTabReply('e1')).toBe('Thanks');
});
it("doesn't open a composer minutes later", () => {
vi.useFakeTimers();
requestTabReply('e1', 'Thanks');
vi.advanceTimersByTime(3 * 60 * 1000);
expect(takeTabReply('e1')).toBeNull();
});
});
@@ -23,7 +23,7 @@ afterEach(() => {
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' } };
const prefs = { archiveEnabled: true, archiveMode: 'year' as const, pluginsMayVeto: true, 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));
@@ -41,7 +41,7 @@ describe('the worker store', () => {
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(writeNotificationActionPrefs({ archiveEnabled: false, archiveMode: 'single', pluginsMayVeto: false, labels: { archive: '', markRead: '', reply: '', replyPlaceholder: '' } })).resolves.toBeUndefined();
await expect(consumePendingReplyText('e1')).resolves.toBe('');
});
});
+13 -1
View File
@@ -6,8 +6,20 @@
*
* Runs in the page and on the server: both ends compare the result.
*/
function canonicalServerUrl(serverUrl: string): string {
// The registry keeps what the user typed, the cookie what the login route
// normalised: `https://Mail.Example.org:443/` and `https://mail.example.org`
// are the same server.
try {
const url = new URL(serverUrl);
return `${url.origin}${url.pathname}`.replace(/\/+$/, '');
} catch {
return serverUrl.replace(/\/+$/, '');
}
}
export async function loginFingerprint(serverUrl: string, username: string | undefined): Promise<string> {
const text = `${serverUrl.replace(/\/+$/, '')}\n${(username ?? '').trim().toLowerCase()}`;
const text = `${canonicalServerUrl(serverUrl)}\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('');
}
+73 -35
View File
@@ -1,14 +1,18 @@
import { isArchiveActionEnabled } from '@/lib/archive-gate';
import { subjectForAccount } from '@/lib/org/account-subjects';
import { useAccountStore } from '@/stores/account-store';
import { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
import { usePolicyStore } from '@/stores/policy-store';
import { loginFingerprint } from './login-fingerprint';
import { vetoHooksRegistered } from './plugin-veto';
/** 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;
/** When the worker asked. */
at: number;
threadId?: string;
slot?: number;
login?: string;
@@ -18,7 +22,8 @@ export interface NotificationActionRequest {
/**
* `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.
* `accepted`: this tab is on it. `done` and `failed`: how it ended; on
* `failed` the message opens, and the route isn't tried.
*/
export type NotificationActionStatus = 'declined' | 'accepted' | 'done' | 'failed';
@@ -29,6 +34,13 @@ export interface NotificationReplyRequest {
text?: string;
}
/**
* A tab that was frozen and thaws after this has lost the click: the worker has
* moved on, maybe to the route or to opening the message. Past every wait in
* sw.js for a tab to take an action.
*/
const REQUEST_MAX_AGE_MS = 5_000;
type ReplyOpener = (request: NotificationReplyRequest) => void;
let replyOpener: ReplyOpener | null = null;
@@ -46,37 +58,48 @@ export function isNotificationActionRequest(value: unknown): value is Notificati
return !!request
&& (request.action === 'archive' || request.action === 'mark-read' || request.action === 'reply')
&& typeof request.emailId === 'string' && !!request.emailId
&& typeof request.accountId === 'string' && !!request.accountId;
&& typeof request.accountId === 'string' && !!request.accountId
&& typeof request.at === 'number';
}
/** The login the notification is for, if this tab has it signed in right now. */
/** The account of this tab's registry the notification is for, if it still means that login. */
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;
return (await loginFingerprint(entry.serverUrl, entry.username)) === request.login ? entry : null;
}
/** The Archive gate as it applies to this login, whichever login is active here. */
function archiveEnabledFor(accountEntryId: string): boolean {
return usePolicyStore.getState().resolveFor(subjectForAccount(accountEntryId)).features?.archiveActionEnabled !== false;
}
/**
* 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.
* apply, and the list on screen follows. What this tab can't do while plugins
* listen ends in `failed`, not `declined`: the route has no plugins.
*/
export async function runNotificationAction(
request: NotificationActionRequest,
answer: (status: NotificationActionStatus) => void,
): Promise<void> {
try {
const login = await loginOf(request);
const activeClient = useAuthStore.getState().client;
if (!login || !activeClient) {
if (Date.now() - request.at > REQUEST_MAX_AGE_MS) {
answer('declined');
return;
}
const entry = await loginOf(request);
if (!entry) {
answer('declined');
return;
}
const passOn = () => answer(vetoHooksRegistered() ? 'failed' : 'declined');
const auth = useAuthStore.getState();
const client = auth.getClientForAccount(entry.id) ?? (entry.id === auth.activeAccountId ? auth.client : null);
const activeClient = auth.client;
if (request.action === 'reply') {
if (!replyOpener || request.slot === undefined) {
if (!client || !replyOpener || request.slot === undefined) {
answer('declined');
return;
}
@@ -84,37 +107,52 @@ export async function runNotificationAction(
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');
if (!client || !activeClient) {
passOn();
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;
const own = request.accountId === client.getAccountId();
if (request.action === 'archive') {
// The route would refuse it too.
if (!archiveEnabledFor(entry.id)) {
answer('failed');
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) {
passOn();
return;
}
}
answer('accepted');
const email = await login.client.getEmail(request.emailId);
if (!email) {
const accountId = own ? undefined : request.accountId;
// Unknown with the mail server out of reach. Mark read then queues like the
// app's own button; Archive files by date and can't go without the message.
const [email, mailboxes] = await Promise.all([
client.getEmail(request.emailId, accountId).catch(() => null),
client.getMailboxes(accountId).catch(() => []),
]);
if (request.action === 'archive' && !email) {
answer('failed');
return;
}
const archived = await useEmailStore.getState().batchArchive(activeClient, {
emailIds: [request.emailId],
client: login.client,
emails: [email],
// Since the notification it may have gone to Trash or Junk: not for a toast
// to bring back or to mark. The app opens it.
const inBin = Object.keys(email?.mailboxIds ?? {}).some((id) => {
const mailbox = mailboxes.find((m) => m.id === id || m.originalId === id);
return mailbox?.role === 'trash' || mailbox?.role === 'junk';
});
answer(archived ? 'done' : 'failed');
if (inBin) {
answer('failed');
return;
}
const done = request.action === 'mark-read'
? await useEmailStore.getState().batchMarkAsRead(activeClient, true, { emailIds: [request.emailId], client, accountId })
: await useEmailStore.getState().batchArchive(activeClient, { emailIds: [request.emailId], client, emails: email ? [email] : [], userArchive: true });
answer(done ? 'done' : 'failed');
} catch {
answer('failed');
}
+16
View File
@@ -0,0 +1,16 @@
import { hookSpec } from '@/lib/plugin-hook-catalog';
import { emailHooks } from '@/lib/plugin-hooks';
/** The hooks that can stop what a notification's buttons do. */
const VETO_HOOKS = ['onBeforeEmailMove', 'onBeforeEmailArchive', 'onBeforeEmailReadStateChange'] as const;
/** Whether one of these plugins could register one of those hooks, today or after a page load. */
export function pluginsMayVeto(plugins: ReadonlyArray<{ enabled?: boolean; permissions?: readonly unknown[] }>): boolean {
const needed = new Set(VETO_HOOKS.map((name) => hookSpec(name)?.permission));
return plugins.some((plugin) => plugin.enabled && plugin.permissions?.some((permission) => needed.has(permission as never)));
}
/** Whether a plugin in this tab listens on one of those hooks now. */
export function vetoHooksRegistered(): boolean {
return VETO_HOOKS.some((name) => emailHooks[name].size > 0);
}
+27
View File
@@ -0,0 +1,27 @@
import { create } from 'zustand';
/** A tab that hasn't got the message by then has been left behind. */
const MAX_AGE_MS = 2 * 60 * 1000;
interface TabReplyState {
pending: { emailId: string; text: string; at: number } | null;
}
/**
* A notification's Reply in the Pro shell, where a message lives in a tab of
* its own that fetches it. Waits here, not in the tab store: that one is kept
* in localStorage, and a reply being typed is not for keeping.
*/
export const useNotificationTabReply = create<TabReplyState>(() => ({ pending: null }));
export function requestTabReply(emailId: string, text: string): void {
useNotificationTabReply.setState({ pending: { emailId, text, at: Date.now() } });
}
/** The text for a reply to this message, once; null when none waits. */
export function takeTabReply(emailId: string): string | null {
const { pending } = useNotificationTabReply.getState();
if (!pending || pending.emailId !== emailId) return null;
useNotificationTabReply.setState({ pending: null });
return Date.now() - pending.at < MAX_AGE_MS ? pending.text : null;
}
+2
View File
@@ -9,6 +9,8 @@ import { PUSH_STATE_CACHE } from '@/lib/push-preferences';
export interface NotificationActionPrefs {
archiveEnabled: boolean;
archiveMode: 'single' | 'year' | 'month';
/** An enabled plugin may stop or watch these actions: the worker leaves the route alone. */
pluginsMayVeto: boolean;
labels: { archive: string; markRead: string; reply: string; replyPlaceholder: string };
}
+72 -14
View File
@@ -351,6 +351,10 @@ self.addEventListener("message", (event) => {
event.waitUntil(recordClientBuild(event.source && event.source.id, data.buildId).then(deleteStaleShellCaches));
return;
}
if (data.type === "client-logins") {
event.waitUntil(recordClientLogins(event.source && event.source.id, data.logins));
return;
}
if (data.type === "offline-shell") {
// The switch is kept before the event ends. The shell's files are
// fetched without holding the event open: a worker busy with an event
@@ -970,12 +974,16 @@ function buildClickUrl(data, reply) {
// 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.
// How long a tab has to take an action. A frozen tab doesn't answer. A tab that
// holds the notification's login gets longer, since a busy page may still run
// the action with its plugins; if it stays silent, the message opens and the
// route isn't tried, which would go round them.
const CLIENT_TAKE_MS = 1_000;
const CLIENTS_TAKE_TOTAL_MS = 2_500;
const LOGIN_HOLDER_TAKE_MS = 3_000;
const CLIENTS_TAKE_TOTAL_MS = 4_000;
// 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;
const ACTION_ROUTE_DEADLINE_MS = 4_500;
// 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.
@@ -989,10 +997,44 @@ function pendingReplyKey() {
return `${self.location.origin}${BASE_PATH}/__push-state/pending-reply`;
}
function clientLoginsKey() {
return `${self.location.origin}${BASE_PATH}/__push-state/client-logins`;
}
async function readClientLogins() {
try {
const cache = await caches.open(PUSH_STATE_CACHE);
const res = await cache.match(clientLoginsKey());
const map = res ? await res.json() : null;
return map && typeof map === "object" ? map : {};
} catch (_) {
return {};
}
}
/** Notes which logins an open tab holds, and forgets tabs that are gone. Kept across worker restarts. */
async function recordClientLogins(clientId, logins) {
if (!clientId || !Array.isArray(logins)) return;
try {
const open = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
const openIds = new Set(open.map((c) => c.id));
const known = await readClientLogins();
const next = {};
for (const [id, held] of Object.entries(known)) if (openIds.has(id)) next[id] = held;
next[clientId] = logins.filter((login) => typeof login === "string");
const cache = await caches.open(PUSH_STATE_CACHE);
await cache.put(clientLoginsKey(), new Response(JSON.stringify(next), { headers: { "Content-Type": "application/json" } }));
} catch (_) {
// Such a tab counts as not holding the login.
}
}
/**
* 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.
* labels in the user's language, Archive on or off, how Archive files mail,
* and whether an enabled plugin may veto these actions. Nothing stored: no
* Archive button, since the admin may have turned it off, and no route, since
* a plugin may be there.
*/
async function readNotificationActionPrefs() {
let stored = null;
@@ -1007,6 +1049,7 @@ async function readNotificationActionPrefs() {
return {
archiveEnabled: !!stored && stored.archiveEnabled === true,
archiveMode: stored && typeof stored.archiveMode === "string" ? stored.archiveMode : "single",
pluginsMayVeto: !stored || stored.pluginsMayVeto !== false,
labels: {
archive: labels.archive || "Archive",
markRead: labels.markRead || "Mark as read",
@@ -1063,8 +1106,8 @@ function notificationActionOf(event, data) {
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) {
/** Asks one tab. Resolves "done", "failed" (it tried), "declined" (it didn't take it) or "silent" (no answer). */
function askClient(client, message, takeMs) {
return new Promise((resolve) => {
if (typeof MessageChannel !== "function") {
resolve("declined");
@@ -1077,7 +1120,7 @@ function askClient(client, message) {
channel.port1.onmessage = null;
resolve(result);
};
timer = setTimeout(() => finish("declined"), CLIENT_TAKE_MS);
timer = setTimeout(() => finish("silent"), takeMs);
channel.port1.onmessage = (answer) => {
const status = answer.data && answer.data.status;
if (status === "accepted") {
@@ -1102,13 +1145,22 @@ async function askOpenClients(message, allClients) {
const ordered = allClients
.filter((client) => !client.frameType || client.frameType === "top-level")
.sort((a, b) => score(a) - score(b));
const held = await readClientLogins();
const holdsLogin = (client) => Array.isArray(held[client.id]) && held[client.id].includes(message.login);
const giveUpAt = Date.now() + CLIENTS_TAKE_TOTAL_MS;
let silentHolder = false;
for (const client of ordered) {
if (Date.now() >= giveUpAt) break;
const result = await askClient(client, message);
if (result !== "declined") return { result, client };
if (Date.now() >= giveUpAt) {
silentHolder = silentHolder || holdsLogin(client);
continue;
}
const holder = holdsLogin(client);
const result = await askClient(client, message, holder ? LOGIN_HOLDER_TAKE_MS : CLIENT_TAKE_MS);
if (result === "silent") silentHolder = silentHolder || holder;
else if (result !== "declined") return { result, client };
}
return { result: "declined", client: null };
// A silent tab with this login may have plugins that would stop the action.
return { result: silentHolder ? "failed" : "declined", client: null };
}
/** Keeps a typed reply for the page the click is about to open; it's not for a URL. */
@@ -1129,6 +1181,9 @@ async function keepPendingReply(emailId, text) {
async function postNotificationAction(action, data) {
const prefs = await readNotificationActionPrefs();
if (action === "archive" && !prefs.archiveEnabled) return false;
// Plugins run in pages. One that may stop or watch this action can't from
// the route, so the app opens instead.
if (prefs.pluginsMayVeto) return false;
const body = { action, accountId: data.accountId, emailId: data.emailId, login: data.login };
if (action === "archive") {
body.archiveMode = prefs.archiveMode;
@@ -1165,6 +1220,8 @@ async function runNotificationAction(action, data, reply, allClients) {
accountId: data.accountId,
slot: data.slot,
login: data.login,
// A frozen tab that thaws later must not act on a click long since handled.
at: Date.now(),
...(text ? { text } : {}),
}, allClients);
if (result === "done") {
@@ -1177,12 +1234,13 @@ async function runNotificationAction(action, data, reply, allClients) {
}
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;
}
// A tab with this login tried and failed, or stayed silent: the route won't
// do better and would go round its plugins.
if (result === "failed") return false;
return postNotificationAction(action, data);
}
@@ -73,6 +73,14 @@ describe('batch functions with an explicit target', () => {
expect(client.batchArchiveEmails).not.toHaveBeenCalled();
});
it("files into an Archive someone else reads when the user pressed Archive, as the app's own button does", async () => {
const getMailboxShareWith = vi.fn(async (id: string) => (id === 'arch' ? { u2: { mayReadItems: true } } : null));
Object.assign(client, { supportsMailboxSharing: () => true, getMailboxShareWith });
await useEmailStore.getState().batchArchive(other, { emailIds: ['m1'], client, emails: [email('m1')], userArchive: true });
expect(client.batchArchiveEmails).toHaveBeenCalled();
expect(getMailboxShareWith).not.toHaveBeenCalled();
});
it('tags the named messages through the target\'s client and skips those already tagged', async () => {
const ok = await useEmailStore.getState().batchSetTag(other, 'work', true, {
emailIds: ['m1', 'm2'], client, emails: [email('m1'), email('m2', { '$label:work': true })],
+7 -1
View File
@@ -208,6 +208,12 @@ export interface BatchActionTarget {
* wanted state. batchArchive and batchSetTag act on these only.
*/
emails?: Email[];
/**
* The user pressed Archive for these, as on the app's own button, which
* files into an Archive shared with others too. Rules and plugins acting
* on their own are refused one.
*/
userArchive?: boolean;
}
interface EmailStore {
@@ -1403,7 +1409,7 @@ async function archiveTarget(client: IJMAPClient, target: BatchActionTarget): Pr
if (!archive) return false;
// Never into an Archive someone else reads (or a year or month folder of
// it): the share map isn't in the folder list, so the server is asked.
if (await isSharedOut(target.client, [...mailboxes, archive], archive.originalId || archive.id, target.accountId, { descendants: true })) {
if (!target.userArchive && await isSharedOut(target.client, [...mailboxes, archive], archive.originalId || archive.id, target.accountId, { descendants: true })) {
throw new Error('The Archive folder is shared with other people');
}
const hookMailboxes = [...mailboxes.filter(m => m.id !== archive.id), archive];