diff --git a/app/api/push/action/route.ts b/app/api/push/action/route.ts index 169b7f7e8..7ad419076 100644 --- a/app/api/push/action/route.ts +++ b/app/api/push/action/route.ts @@ -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 | undefined, emailId: string): 'updated' | 'notFound' | 'refused' { + const { updated, notUpdated } = (set ?? {}) as { + updated?: Record | null; + notUpdated?: Record | 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 { const cookieStore = await cookies(); @@ -94,15 +105,14 @@ async function loginsNamed(login: string): Promise { } /** - * 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 { +async function policyFor(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; + 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 => { 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; - 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'], + const readFolders = async (): Promise => { + 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 }[] | 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 = '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 = { 'keywords/$seen': true }; + let create: Record> = {}; + 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 | null; + notCreated?: Record | 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 | 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'); + 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) { diff --git a/components/mail/__tests__/blob-routing.test.tsx b/components/mail/__tests__/blob-routing.test.tsx index 59b96c183..29c180bbb 100644 --- a/components/mail/__tests__/blob-routing.test.tsx +++ b/components/mail/__tests__/blob-routing.test.tsx @@ -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(); + await waitFor(() => expect(composeTabs()).toHaveLength(before + 1)); + expect(composeTabs().at(-1)?.composeData).toMatchObject({ mode: 'reply', sourceEmailId: 'email', initialDraftText: 'Typed on the toast' }); + + view.rerender(); + 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); diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 3f6cf4e1d..136093b22 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -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. diff --git a/components/pro/pro-email-tab-body.tsx b/components/pro/pro-email-tab-body.tsx index 6040ba126..01b0693c9 100644 --- a/components/pro/pro-email-tab-body.tsx +++ b/components/pro/pro-email-tab-body.tsx @@ -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({ diff --git a/components/providers/__tests__/notification-actions-host.test.tsx b/components/providers/__tests__/notification-actions-host.test.tsx index 99fa14b6f..f786d3e39 100644 --- a/components/providers/__tests__/notification-actions-host.test.tsx +++ b/components/providers/__tests__/notification-actions-host.test.tsx @@ -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>, 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 } }); 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(); + expect(writeNotificationActionPrefs.mock.calls.at(-1)?.[0]).toMatchObject({ pluginsMayVeto: true }); + state.plugins = [{ enabled: false, permissions: ['email:write'] }]; + rerender(); + 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(); }); + 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; + await act(async () => { view = render(); }); + await vi.waitFor(() => expect(postMessage).toHaveBeenCalledTimes(1)); + state.accounts = []; + await act(async () => { view.rerender(); }); + 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(); diff --git a/components/providers/notification-actions-host.tsx b/components/providers/notification-actions-host.tsx index 6231869f9..4077951d3 100644 --- a/components/providers/notification-actions-host.tsx +++ b/components/providers/notification-actions-host.tsx @@ -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; diff --git a/integration/tests/23-do-pwa-notify.spec.ts b/integration/tests/23-do-pwa-notify.spec.ts index 99298571c..5867c9c29 100644 --- a/integration/tests/23-do-pwa-notify.spec.ts +++ b/integration/tests/23-do-pwa-notify.spec.ts @@ -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); diff --git a/lib/__tests__/plugin-post-denylist.test.ts b/lib/__tests__/plugin-post-denylist.test.ts index 5d0fad219..1c266ee1b 100644 --- a/lib/__tests__/plugin-post-denylist.test.ts +++ b/lib/__tests__/plugin-post-denylist.test.ts @@ -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); }); diff --git a/lib/__tests__/push-action-route.test.ts b/lib/__tests__/push-action-route.test.ts index 3658b03b2..c120db3d0 100644 --- a/lib/__tests__/push-action-route.test.ts +++ b/lib/__tests__/push-action-route.test.ts @@ -34,6 +34,11 @@ let sessionStatus = 200; let mailboxes: Array>; let email: Record | null; let emailSetResult: Record | null = null; +// The answer to each Mailbox/set in turn; `undefined`: everything was created. +let mailboxSetResults: Array> = []; +let mailboxReads = 0; +let mailboxesOnSecondRead: Array> | null = null; +let emailSetResults: Array> = []; 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)[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, headers: Record 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 = {}) => ({ 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'); + const call = writes.at(-1)!.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; @@ -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); }); diff --git a/lib/__tests__/sw-notification-actions.test.ts b/lib/__tests__/sw-notification-actions.test.ts index f452c79b3..96e94949f 100644 --- a/lib/__tests__/sw-notification-actions.test.ts +++ b/lib/__tests__/sw-notification-actions.test.ts @@ -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, 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 }); diff --git a/lib/pwa-notify/__tests__/login-fingerprint.test.ts b/lib/pwa-notify/__tests__/login-fingerprint.test.ts new file mode 100644 index 000000000..82cab2ad3 --- /dev/null +++ b/lib/pwa-notify/__tests__/login-fingerprint.test.ts @@ -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)); + }); +}); diff --git a/lib/pwa-notify/__tests__/page-actions.test.ts b/lib/pwa-notify/__tests__/page-actions.test.ts index 0351fcbfb..39287cd2f 100644 --- a/lib/pwa-notify/__tests__/page-actions.test.ts +++ b/lib/pwa-notify/__tests__/page-actions.test.ts @@ -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>; let activeAccountId = 'alice@one'; -let archiveEnabled = true; +const gateOff = new Set(); +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 = {}) { const request: NotificationActionRequest = { @@ -39,6 +51,7 @@ async function run(over: Partial = {}) { 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); diff --git a/lib/pwa-notify/__tests__/plugin-veto.test.ts b/lib/pwa-notify/__tests__/plugin-veto.test.ts new file mode 100644 index 000000000..1dfcf9292 --- /dev/null +++ b/lib/pwa-notify/__tests__/plugin-veto.test.ts @@ -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); + }); +}); diff --git a/lib/pwa-notify/__tests__/tab-reply.test.ts b/lib/pwa-notify/__tests__/tab-reply.test.ts new file mode 100644 index 000000000..99f35a448 --- /dev/null +++ b/lib/pwa-notify/__tests__/tab-reply.test.ts @@ -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(); + }); +}); diff --git a/lib/pwa-notify/__tests__/worker-state.test.ts b/lib/pwa-notify/__tests__/worker-state.test.ts index 0a175d5b7..69e384f09 100644 --- a/lib/pwa-notify/__tests__/worker-state.test.ts +++ b/lib/pwa-notify/__tests__/worker-state.test.ts @@ -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(''); }); }); diff --git a/lib/pwa-notify/login-fingerprint.ts b/lib/pwa-notify/login-fingerprint.ts index 7debefe5d..13eeaa0f4 100644 --- a/lib/pwa-notify/login-fingerprint.ts +++ b/lib/pwa-notify/login-fingerprint.ts @@ -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 { - 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(''); } diff --git a/lib/pwa-notify/page-actions.ts b/lib/pwa-notify/page-actions.ts index 43cc96f95..96b1db101 100644 --- a/lib/pwa-notify/page-actions.ts +++ b/lib/pwa-notify/page-actions.ts @@ -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 { 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'); } diff --git a/lib/pwa-notify/plugin-veto.ts b/lib/pwa-notify/plugin-veto.ts new file mode 100644 index 000000000..48f1f2b52 --- /dev/null +++ b/lib/pwa-notify/plugin-veto.ts @@ -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); +} diff --git a/lib/pwa-notify/tab-reply.ts b/lib/pwa-notify/tab-reply.ts new file mode 100644 index 000000000..ad8ff34eb --- /dev/null +++ b/lib/pwa-notify/tab-reply.ts @@ -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(() => ({ 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; +} diff --git a/lib/pwa-notify/worker-state.ts b/lib/pwa-notify/worker-state.ts index 0fb48cdb6..161f3885c 100644 --- a/lib/pwa-notify/worker-state.ts +++ b/lib/pwa-notify/worker-state.ts @@ -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 }; } diff --git a/public/sw.js b/public/sw.js index 8ce60bb8b..72b22cdfd 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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); } diff --git a/stores/__tests__/email-store-batch-target.test.ts b/stores/__tests__/email-store-batch-target.test.ts index b2ad83e0f..589c90b2f 100644 --- a/stores/__tests__/email-store-batch-target.test.ts +++ b/stores/__tests__/email-store-batch-target.test.ts @@ -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 })], diff --git a/stores/email-store.ts b/stores/email-store.ts index 97d75caca..4ff2b33b7 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -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];