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

Grüße von Frau Müller &lt;Chefin&gt;

'), + }]); + expect(await query({ body: 'Müller' })).toEqual(['html']); + expect(await query({ body: 'grüße' })).toEqual(['html']); + expect(await query({ body: 'Vorlage' })).toEqual([]); + // Decoded once: the encoded text stays text. + expect(await query({ body: '"<Chefin>"' })).toEqual(['html']); + }); + + it('answers a very common word', async () => { + const emails: Email[] = []; + for (let i = 0; i < 300; i += 1) emails.push({ subject: 'die Rechnung für die Firma' }); + const { query } = await storeWith(emails); + expect((await query({ text: 'die' })).length).toBe(300); + }); +}); + +describe('hostile input', () => { + const HOSTILE = [ + '"', '""', '"""', '" "', '"unterminated', 'odd " quote', '*', '**', '*foo', 'foo*bar', 'a*b*c*', + '-', '--', '-foo', 'foo-', 'foo - bar', ':', 'subject:', 'subject:foo', 'nope:foo', 'from_addr : x', + '(', ')', '()', '(foo', 'foo)', '((((', '))))', '(foo OR bar)', + 'AND', 'OR', 'NOT', 'NEAR', 'NEAR(a b)', 'NEAR(a b, 2)', 'a AND', 'OR b', 'NOT c', 'a OR OR b', + '^', '^foo', '+', 'foo + bar', '{', '{subject body}: foo', '}', '[', ']', '\\', '\\"', "'", "'foo'", '%', '_', ';', + "'; DROP TABLE email; --", 'foo\0bar', '\0', '\x01\x02', '\n', '\t', '\ud83d', '\udc00', '🎉', '🎉🎉🎉', '', + 'x'.repeat(100_000), '"'.repeat(5001), '('.repeat(5001), 'a '.repeat(5000), '"a" '.repeat(2000), 'a* '.repeat(2000), + ]; + + it('never throws, for any field and any input', async () => { + const { query } = await storeWith([{ subject: 'foo bar' }]); + for (const field of ['text', 'subject', 'from', 'to', 'cc', 'bcc', 'body']) { + for (const input of HOSTILE) { + await expect(query({ [field]: input }), `${field}: ${input.slice(0, 30)}`).resolves.toBeInstanceOf(Array); + } + } + }); + + it('reads the punctuation as text around the words', async () => { + const { query } = await storeWith([ + { id: 'hit', subject: 'foo bar' }, + { id: 'miss', subject: 'other words' }, + ]); + for (const input of ['foo - bar', '(foo)', '(foo bar)', 'foo: bar:', '"foo" -', '-foo', 'foo*', 'foo-bar']) { + expect(await query({ text: input }), input).toEqual(['hit']); + } + }); + + it('finds nothing, rather than everything, when no term is left', async () => { + const { query } = await storeWith([{ subject: 'foo bar' }]); + for (const input of ['', ' ', '*', '-', '"', '""', '()', '🎉']) { + expect(await query({ text: input }), input).toEqual([]); + } + }); + + it('caps what one value and one filter can ask, widening rather than failing', async () => { + const { query } = await storeWith([{ id: 'hit', subject: 'foo' }]); + // The terms past the limit are ignored. + expect(await query({ text: `${'foo '.repeat(16)} nothere` })).toEqual(['hit']); + const many = Array.from({ length: 1000 }, (_, i) => ({ text: i < 32 ? 'foo' : 'nothere' })); + expect(await query({ operator: 'AND', conditions: many })).toEqual(['hit']); + }); + + it('refuses too many terms where dropping one would change the meaning', async () => { + const { query } = await storeWith([{ id: 'hit', subject: 'foo' }]); + const many = Array.from({ length: 33 }, () => ({ text: 'foo' })); + await expect(query({ operator: 'OR', conditions: many })).rejects.toThrow('unsupportedFilter'); + await expect(query({ operator: 'NOT', conditions: many })).rejects.toThrow('unsupportedFilter'); + await expect(query({ operator: 'OR', conditions: many.slice(0, 32) })).resolves.toEqual(['hit']); + }); +}); + +describe('SearchSnippet/get', () => { + it('marks the terms in the subject and the body, as the server would', async () => { + const { snippets } = await storeWith([{ id: 'one', subject: 'Die Rechnung', ...withBody('Hier ist die Rechnung von heute.') }]); + const { list, notFound } = await snippets({ text: 'rechnung' }, ['one', 'gone']); + expect(list).toEqual([{ + emailId: 'one', + subject: 'Die Rechnung', + preview: 'Hier ist die Rechnung von heute.', + }]); + expect(notFound).toEqual(['gone']); + }); + + it('escapes everything except its own marks', async () => { + const hostile = 'Tom & fake it\'s Rechnung & more'; + const { snippets } = await storeWith([{ id: 'one', subject: hostile, ...withBody(hostile) }]); + const { list } = await snippets({ text: 'rechnung' }, ['one']); + const escaped = 'Tom & <script>alert("x")</script> <mark>fake</mark> it's Rechnung &amp; more'; + expect(list[0].subject).toBe(escaped); + // The preview starts near the match, and is escaped the same way. + expect(list[0].preview).toBe(escaped.slice(escaped.indexOf('<mark>'))); + // What the list shows: the markup as plain text, one highlighted word. + expect(parseSearchSnippet(list[0].subject as string)).toEqual([ + { text: 'Tom & fake it\'s ', marked: false }, + { text: 'Rechnung', marked: true }, + { text: ' & more', marked: false }, + ]); + }); + + it('escapes a term that holds markup', async () => { + const { snippets } = await storeWith([{ id: 'one', subject: 'a c' }]); + const { list } = await snippets({ subject: '""' }, ['one']); + expect(list[0].subject).toBe('a <b> c'); + }); + + it('marks the original text when diacritics or spelling differ', async () => { + const { snippets } = await storeWith([ + { id: 'umlaut', subject: 'Frau Müller, MÜLLER' }, + { id: 'written', subject: 'Herr Mueller' }, + ]); + expect((await snippets({ text: 'muller' }, ['umlaut'])).list[0].subject).toBe('Frau Müller, MÜLLER'); + expect((await snippets({ text: 'Mueller' }, ['umlaut'])).list[0].subject).toBe('Frau Müller, MÜLLER'); + expect((await snippets({ text: 'Müller' }, ['written'])).list[0].subject).toBe('Herr Mueller'); + }); + + it('marks only where the index would have matched', async () => { + const { snippets } = await storeWith([{ id: 'one', subject: 'zu zum Stromrechnung Rechnung' }]); + expect((await snippets({ subject: 'zu' }, ['one'])).list[0].subject).toBe('zu zum Stromrechnung Rechnung'); + expect((await snippets({ subject: 'rechnung' }, ['one'])).list[0].subject).toBe('zu zum Stromrechnung Rechnung'); + expect((await snippets({ subject: 'rech*' }, ['one'])).list[0].subject).toBe('zu zum Stromrechnung Rechnung'); + }); + + it('marks the words of the loosened filter the client sends', async () => { + const { snippets } = await storeWith([{ id: 'one', subject: 'annual and report, budget' }]); + const filter = buildJMAPFilter('"annual report" budg*', DEFAULT_SEARCH_FILTERS, 'inbox'); + const { list } = await snippets(snippetFilterFor(filter), ['one']); + expect(list[0].subject).toBe('annual and report, budget'); + }); + + it('gives null for the part with no match, and for filters with nothing to mark', async () => { + const { snippets } = await storeWith([{ id: 'one', subject: 'Betreff', from: alice, ...withBody('Inhalt hier') }]); + expect((await snippets({ text: 'inhalt' }, ['one'])).list[0]).toEqual({ emailId: 'one', subject: null, preview: 'Inhalt hier' }); + expect((await snippets({ text: 'betreff' }, ['one'])).list[0]).toEqual({ emailId: 'one', subject: 'Betreff', preview: null }); + // subject and body only mark their own part. + expect((await snippets({ subject: 'inhalt' }, ['one'])).list[0]).toEqual({ emailId: 'one', subject: null, preview: null }); + expect((await snippets({ body: 'betreff' }, ['one'])).list[0]).toEqual({ emailId: 'one', subject: null, preview: null }); + expect((await snippets({ from: 'alice' }, ['one'])).list[0]).toEqual({ emailId: 'one', subject: null, preview: null }); + expect((await snippets({ inMailbox: 'inbox' }, ['one'])).list[0]).toEqual({ emailId: 'one', subject: null, preview: null }); + expect((await snippets(null, ['one'])).list[0]).toEqual({ emailId: 'one', subject: null, preview: null }); + }); + + it('does not mark terms the filter excludes', async () => { + const { snippets } = await storeWith([{ id: 'one', subject: 'foo bar' }]); + const { list } = await snippets({ operator: 'AND', conditions: [{ text: 'foo' }, { operator: 'NOT', conditions: [{ text: 'bar' }] }] }, ['one']); + expect(list[0].subject).toBe('foo bar'); + }); + + it('keeps a preview within 255 bytes, cuts no entity or mark, and starts near the match', async () => { + const before = 'Wörter füllen den Anfang dieser Nachricht ganz ohne Treffer. '.repeat(20); + const after = '& < > " \' ü '.repeat(200); + const { snippets } = await storeWith([{ id: 'one', subject: 'x', ...withBody(`${before}\n\nDie Rechnung ${after} Rechnung`) }]); + const preview = (await snippets({ body: 'rechnung' }, ['one'])).list[0].preview as string; + expect(new TextEncoder().encode(preview).length).toBeLessThanOrEqual(255); + expect(preview.length).toBeGreaterThan(200); + expect(preview).toContain('Die Rechnung & <'); + expect(preview.indexOf('')).toBeLessThan(60); + // Whatever is left over parses back with no half entity. + expect(preview.replace(/<\/?mark>/g, '')).not.toMatch(/&(?!amp;|lt;|gt;|quot;|#39;)/); + expect(preview.split('').length).toBe(preview.split('').length); + }); + + it('never leaves an empty or open mark when the budget runs out on one', async () => { + const { snippets } = await storeWith([{ id: 'one', subject: 'x', ...withBody(`foo ${'abcd '.repeat(48)}foo foo foo foo foo foo foo foo foo`) }]); + const preview = (await snippets({ body: 'foo' }, ['one'])).list[0].preview as string; + expect(new TextEncoder().encode(preview).length).toBeLessThanOrEqual(255); + expect(preview).not.toContain(''); + expect(preview.split('').length).toBe(preview.split('').length); + }); + + it('builds the preview from an HTML body as plain text', async () => { + const { snippets } = await storeWith([{ + id: 'one', + subject: 'x', + textBody: [], + htmlBody: [{ partId: '1', type: 'text/html' }], + ...withBody('

Ihre Rechnung von Müller

'), + }]); + expect((await snippets({ body: 'rechnung' }, ['one'])).list[0].preview).toBe('Ihre Rechnung von Müller'); + }); + + it('works chained onto the query, as the client asks for it', async () => { + const { engine } = await storeWith([{ id: 'one', subject: 'Rechnung' }, { id: 'two', subject: 'Hallo' }]); + const answer = await engine.answer([ + ['Email/query', { filter: { text: 'rechnung' } }, '0'], + ['SearchSnippet/get', { filter: { text: 'rechnung' }, '#emailIds': { resultOf: '0', name: 'Email/query', path: '/ids' } }, 'snippets'], + ]); + expect(answer?.methodResponses[1][0]).toBe('SearchSnippet/get'); + expect(answer?.methodResponses[1][1].list).toEqual([{ emailId: 'one', subject: 'Rechnung', preview: null }]); + }); + + it('answers per-call errors for bad arguments', async () => { + const { engine } = await storeWith([]); + const answer = await engine.answer([ + ['SearchSnippet/get', { filter: { text: 'x' } }, 'a'], + ['SearchSnippet/get', { filter: { text: 'x' }, emailIds: Array.from({ length: 501 }, (_, i) => `e${i}`) }, 'b'], + ['Mailbox/query', {}, 'c'], + ]); + expect(answer?.methodResponses[0]).toEqual(['error', expect.objectContaining({ type: 'invalidArguments' }), 'a']); + expect(answer?.methodResponses[1]).toEqual(['error', expect.objectContaining({ type: 'requestTooLarge' }), 'b']); + }); + + it('survives hostile search terms', async () => { + const { snippets } = await storeWith([{ id: 'one', subject: 'foo (bar) [baz]', ...withBody('foo (bar) [baz] \\ ^ $ . * + ? | /') }]); + for (const text of ['(', ')', '[', ']', '\\', '^', '$', '.', '*', '+', '?', '|', '/', '(bar)', '[baz', 'a|b', '.*', '(?', '\ud83d', 'foo\0bar']) { + await expect(snippets({ text }, ['one']), text).resolves.toHaveProperty('list'); + } + expect((await snippets({ text: '.*' }, ['one'])).list[0].preview).toBeNull(); + expect((await snippets({ text: '(bar)' }, ['one'])).list[0].subject).toBe('foo (bar) [baz]'); + }); +}); diff --git a/lib/store/mail/index.ts b/lib/store/mail/index.ts index e76d61d28..0c7749864 100644 --- a/lib/store/mail/index.ts +++ b/lib/store/mail/index.ts @@ -1,11 +1,12 @@ import type { StoreArea } from '../area'; import { MAIL_METHODS } from './methods'; import { MAIL_MIGRATIONS } from './schema'; +import { SEARCH_MIGRATIONS } from './search-schema'; import { mailSyncer } from './sync'; export const mailArea: StoreArea = { name: 'mail', - migrations: MAIL_MIGRATIONS, + migrations: [...MAIL_MIGRATIONS, ...SEARCH_MIGRATIONS], methods: MAIL_METHODS, syncers: [mailSyncer], }; diff --git a/lib/store/mail/methods.ts b/lib/store/mail/methods.ts index e91bdd45c..34a191df5 100644 --- a/lib/store/mail/methods.ts +++ b/lib/store/mail/methods.ts @@ -3,14 +3,12 @@ import { MethodError, type MethodContext, type MethodHandler, type MethodResult } from '../area'; import { listObjects } from '../engine/core-schema'; import type { SqlValue } from '../storage'; -import { textCondition, type TextField } from './search'; +import { limitTextConditions, searchSnippetGet, textCondition, TEXT_FIELDS, type TextField } from './search'; type Filter = Record; interface Sql { sql: string; params: SqlValue[] } -const TEXT_FIELDS: readonly TextField[] = ['text', 'subject', 'from', 'to', 'body']; - function jmapOf(ctx: MethodContext, args: Record): string { return (args.accountId as string | undefined) ?? ctx.defaultJmap; } @@ -78,7 +76,7 @@ function filterSql(filter: Filter, jmap: string): Sql { params.push(...text.params); break; } - // cc, bcc, header and the thread keyword filters: the server's job. + // header and the thread keyword filters: the server's job. throw new MethodError('unsupportedFilter', `${key} isn't available offline`); } } @@ -132,7 +130,7 @@ function drivingMailbox(filter: Filter | undefined): string | null { const emailQuery: MethodHandler = (ctx, args) => { const jmap = jmapOf(ctx, args); - const filter = (args.filter ?? undefined) as Filter | undefined; + const filter = limitTextConditions((args.filter ?? undefined) as Filter | undefined); if (args.anchor !== undefined) throw new MethodError('unsupportedFilter', 'anchor needs the server'); const where = filter ? filterSql(filter, jmap) : { sql: '1', params: [] }; const order = orderSql(ctx, (args.sort as Comparator[]) ?? []); @@ -270,4 +268,5 @@ export const MAIL_METHODS: Readonly> = { 'Thread/get': threadGet, 'Mailbox/get': mailboxGet, 'Mailbox/query': mailboxQuery, + 'SearchSnippet/get': searchSnippetGet, }; diff --git a/lib/store/mail/records.ts b/lib/store/mail/records.ts index b0e292718..f8496fb9c 100644 --- a/lib/store/mail/records.ts +++ b/lib/store/mail/records.ts @@ -101,6 +101,20 @@ export function deleteEmailRow(db: StorageAdapter, rowid: number): void { db.run('DELETE FROM email WHERE rowid = ?', [rowid]); } +/** German HTML mail often writes its umlauts as entities: unread, "Müller" would never be found. */ +const NAMED_ENTITIES: Record = { + amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', shy: '', + auml: 'ä', ouml: 'ö', uuml: 'ü', Auml: 'Ä', Ouml: 'Ö', Uuml: 'Ü', szlig: 'ß', + eacute: 'é', egrave: 'è', agrave: 'à', ccedil: 'ç', ndash: '-', mdash: '-', hellip: '...', euro: '€', + bdquo: '"', ldquo: '"', rdquo: '"', lsquo: "'", rsquo: "'", +}; + +function decodeEntity(whole: string, name: string): string { + if (name[0] !== '#') return NAMED_ENTITIES[name] ?? whole; + const code = /^#x/i.test(name) ? parseInt(name.slice(2), 16) : parseInt(name.slice(1), 10); + return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : whole; +} + /** Plain text of a message for the search index: the text part, or the HTML part without its tags. */ export function bodyText(email: Record): string { const values = (email.bodyValues ?? {}) as Record; @@ -113,25 +127,25 @@ export function bodyText(email: Record): string { return parts('htmlBody') .map((part) => values[part.partId ?? '']?.value ?? '') .join('\n') - .replace(/<(style|script)[\s\S]*?<\/\1>/gi, ' ') + .replace(//g, ' ') + .replace(/<(style|script|head)\b[\s\S]*?<\/\1>/gi, ' ') .replace(/<[^>]+>/g, ' ') - .replace(/ /g, ' ') - .replace(/&/g, '&') - .replace(/</g, '<') - .replace(/>/g, '>') + .replace(/&(#\d+|#x[\da-f]+|[a-z]+\d*);/gi, decodeEntity) .replace(/\s+/g, ' '); } /** A long message past this is rare and only bloats the index. */ -const INDEX_BODY_CHARS = 200_000; +export const INDEX_BODY_CHARS = 200_000; export function indexEmail(db: StorageAdapter, rowid: number, email: Record, body: string): void { const subject = typeof email.subject === 'string' ? email.subject : ''; const from = addressText(email.from); - const to = [addressText(email.to), addressText(email.cc), addressText(email.bcc)].filter(Boolean).join(' '); + const to = addressText(email.to); + const cc = addressText(email.cc); + const bcc = addressText(email.bcc); const text = body.slice(0, INDEX_BODY_CHARS); for (const table of ['email_fts_word', 'email_fts_tri']) { - db.run(`INSERT INTO ${table} (rowid, subject, from_addr, to_addr, body) VALUES (?, ?, ?, ?, ?)`, [rowid, subject, from, to, text]); + db.run(`INSERT INTO ${table} (rowid, subject, from_addr, to_addr, cc_addr, bcc_addr, body) VALUES (?, ?, ?, ?, ?, ?, ?)`, [rowid, subject, from, to, cc, bcc, text]); } } diff --git a/lib/store/mail/search-schema.ts b/lib/store/mail/search-schema.ts new file mode 100644 index 000000000..d30613b72 --- /dev/null +++ b/lib/store/mail/search-schema.ts @@ -0,0 +1,38 @@ +import type { Migration } from '../storage'; + +const FTS_COLUMNS = 'subject, from_addr, to_addr, cc_addr, bcc_addr, body'; + +/** + * Gives To, Cc and Bcc their own index columns. `to` used to find Cc and Bcc + * too, and `cc` (the client's recipient search asks for it) had no answer. + * + * FTS5 can't add a column and contentless tables have nothing to copy, so the + * indexes are made again and the mail syncs again to fill them. No released + * version has data here: this only touches a development database. + */ +export const SEARCH_MIGRATIONS: readonly Migration[] = [ + { + id: 300, + name: 'search-address-columns', + sql: ` + DROP TABLE email_fts_word; + DROP TABLE email_fts_tri; + CREATE VIRTUAL TABLE email_fts_word USING fts5( + ${FTS_COLUMNS}, + content = '', contentless_delete = 1, + tokenize = 'unicode61 remove_diacritics 2' + ); + CREATE VIRTUAL TABLE email_fts_tri USING fts5( + ${FTS_COLUMNS}, + content = '', contentless_delete = 1, + tokenize = 'trigram remove_diacritics 1' + ); + DELETE FROM email_keyword; + DELETE FROM email_mailbox; + DELETE FROM email_body; + DELETE FROM thread; + DELETE FROM email; + DELETE FROM sync_state WHERE type IN ('Email', 'Thread'); + `, + }, +]; diff --git a/lib/store/mail/search.ts b/lib/store/mail/search.ts index 57b94f849..dba103b4d 100644 --- a/lib/store/mail/search.ts +++ b/lib/store/mail/search.ts @@ -1,59 +1,398 @@ -/** Full-text matching over the two FTS indexes. */ +/** + * Full-text matching and SearchSnippet/get over the two FTS indexes. + * + * What a search value means here (the shapes buildJMAPFilter sends): + * - `rechnung`: a word of three or more characters is found anywhere, also + * inside a longer word, through the trigram index ("Rechnung" in + * "Zahlungserinnerungsrechnung"). + * - `zu`: one or two characters can't use trigrams, so only the whole word + * matches, through the word index. "zu" doesn't find "zum". + * - `rech*`: the start of a word, through the word index. It needs two + * characters: `a*` is the word "a", since reading every word that starts + * with one letter is the most expensive thing FTS5 can be asked. + * - `"annual report"`: these exact words in this order, through the word index. + * - Several words have to all match, each in any field `text` covers. That's a + * little wider than Stalwart, which wants them in one field. + * - Case and diacritics don't count: Müller and muller find each other. + * + * Input never reaches FTS5 as syntax: every term goes in as a quoted string, so + * `"`, `*`, `-`, `:` and parentheses are text. A term without a letter or digit + * has nothing to search for and is left out; a value left with no terms matches + * nothing, because leaving it out would widen the search to every message. + * + * Limits: MAX_TERMS per value, MAX_TERM_CHARS per term, MAX_TEXT_CONDITIONS per + * filter. Past them the extra terms are ignored, which only widens the result, + * so a pasted page of text still gets an answer and can't keep the worker busy. + * A very common word costs one read of its list in each index, at most the size + * of the account. + */ +import { MethodError, type MethodHandler } from '../area'; import type { SqlValue } from '../storage'; +import { bodyText, INDEX_BODY_CHARS } from './records'; -export type TextField = 'text' | 'subject' | 'from' | 'to' | 'body'; +export const TEXT_FIELDS = ['text', 'subject', 'from', 'to', 'cc', 'bcc', 'body'] as const; +export type TextField = (typeof TEXT_FIELDS)[number]; const COLUMNS: Record = { text: null, subject: 'subject', from: 'from_addr', to: 'to_addr', + cc: 'cc_addr', + bcc: 'bcc_addr', body: 'body', }; +const MAX_INPUT_CHARS = 8192; +const MAX_TERMS = 16; +const MAX_TERM_CHARS = 200; +const MAX_TEXT_CONDITIONS = 32; +const MIN_TRIGRAM_CHARS = 3; +const MIN_PREFIX_CHARS = 2; +const MIN_PLAIN_SPELLING_CHARS = 5; +const MAX_SNIPPET_IDS = 500; +/** RFC 8621 §5: a preview is at most 255 octets. */ +const PREVIEW_BYTES = 255; +const PREVIEW_CONTEXT_CHARS = 40; + +const WORD_CHAR = '[\\p{L}\\p{N}\\p{M}]'; +const NOT_WORD_CHAR = '[^\\p{L}\\p{N}\\p{M}]'; +const EDGE_PUNCTUATION = new RegExp(`^${NOT_WORD_CHAR}+|${NOT_WORD_CHAR}+$`, 'gu'); + +interface Term { + needle: string; + kind: 'word' | 'prefix' | 'phrase'; +} + +/** One lookup in one index. A term matches when any of its lookups does. */ +interface Probe { + index: 'word' | 'tri'; + text: string; + prefix: boolean; +} + +function chars(text: string): number { + return [...text].length; +} + +function parseTerms(input: string): Term[] { + const clean = input.slice(0, MAX_INPUT_CHARS).normalize('NFC').replace(/\p{Cc}/gu, ' '); + const terms: Term[] = []; + for (const match of clean.matchAll(/"([^"]*)"|(\S+)/g)) { + const quoted = match[1] !== undefined; + const raw = match[1] ?? match[2]; + const needle = [...raw.replace(EDGE_PUNCTUATION, '')].slice(0, MAX_TERM_CHARS).join(''); + if (!needle) continue; + terms.push({ needle, kind: quoted ? 'phrase' : raw.endsWith('*') ? 'prefix' : 'word' }); + if (terms.length === MAX_TERMS) break; + } + return terms; +} + +/** + * The other German spellings of a term, worked out when searching so the + * indexes stay as they are. The indexes store ü as u, so: + * - Müller also looks for "mueller", the way the term itself is looked for; + * - Mueller also looks for "muller", as a whole word only: Treue must not find + * "Betreuung" through "treu". Short words stay out for the same reason (Poet, + * blue, neue). + * ß and ss work the same way. + */ +function spellings(needle: string): { written: string | null; plain: string | null } { + const lower = needle.toLowerCase(); + const written = lower.replace(/[äöüß]/g, (c) => ({ ä: 'ae', ö: 'oe', ü: 'ue', ß: 'ss' })[c] as string); + const plain = chars(lower) >= MIN_PLAIN_SPELLING_CHARS + ? lower.replace(/ae|oe|ue/g, (pair) => pair[0]).replace(/ss/g, 'ß') + : lower; + return { written: written === lower ? null : written, plain: plain === lower ? null : plain }; +} + +function probesFor(term: Term): Probe[] { + const probes: Probe[] = []; + const addWord = (text: string) => { + probes.push({ index: 'word', text, prefix: term.kind === 'prefix' && chars(text) >= MIN_PREFIX_CHARS }); + }; + const addAll = (text: string) => { + addWord(text); + if (term.kind === 'word' && chars(text) >= MIN_TRIGRAM_CHARS) probes.push({ index: 'tri', text, prefix: false }); + }; + addAll(term.needle); + const { written, plain } = spellings(term.needle); + if (written) addAll(written); + if (plain) addWord(plain); + return probes; +} + function quote(term: string): string { return `"${term.replace(/"/g, '""')}"`; } -/** The terms of a search box value: quoted phrases stay whole, `word*` keeps its star. */ -export function splitTerms(input: string): string[] { - const out: string[] = []; - for (const match of input.matchAll(/"([^"]+)"|(\S+)/g)) out.push(match[1] ?? match[2]); - return out; -} - -/** - * One term as SQL over rowids. The word index finds words and word starts, - * the trigram index finds a term inside a longer word, which is how German - * compounds are found ("Rechnung" in "Zahlungserinnerungsrechnung"). - */ -function termRowids(field: TextField, term: string): { sql: string; params: SqlValue[] } { - const column = COLUMNS[field]; - const star = term.endsWith('*'); - const clean = star ? term.slice(0, -1) : term; - const scope = column ? `${column} : ` : ''; - const word = `${scope}${quote(clean)}${' *'.slice(1)}`; - const parts = ['SELECT rowid FROM email_fts_word WHERE email_fts_word MATCH ?']; - const params: SqlValue[] = [word]; - // Trigrams need three characters. Shorter terms use the word index alone. - if ([...clean].length >= 3) { - parts.push('SELECT rowid FROM email_fts_tri WHERE email_fts_tri MATCH ?'); - params.push(`${scope}${quote(clean)}`); - } - return { sql: parts.join(' UNION '), params }; -} - /** SQL for a text condition, as a condition on `e.rowid`. Every term has to match. */ export function textCondition(field: TextField, input: string): { sql: string; params: SqlValue[] } { - const terms = splitTerms(input); - if (terms.length === 0) return { sql: '1', params: [] }; + const terms = parseTerms(input); + if (terms.length === 0) return { sql: '0', params: [] }; + const column = COLUMNS[field]; + const scope = column ? `${column} : ` : ''; const conditions: string[] = []; const params: SqlValue[] = []; for (const term of terms) { - const part = termRowids(field, term); - conditions.push(`e.rowid IN (${part.sql})`); - params.push(...part.params); + const selects: string[] = []; + for (const probe of probesFor(term)) { + const table = probe.index === 'word' ? 'email_fts_word' : 'email_fts_tri'; + selects.push(`SELECT rowid FROM ${table} WHERE ${table} MATCH ?`); + params.push(`${scope}${quote(probe.text)}${probe.prefix ? '*' : ''}`); + } + conditions.push(`e.rowid IN (${selects.join(' UNION ')})`); } return { sql: conditions.join(' AND '), params }; } + +// ---- Limiting a whole filter ---- + +type Filter = Record; + +function isOperator(filter: Filter): boolean { + return typeof filter.operator === 'string'; +} + +function conditionsOf(filter: Filter): Filter[] { + return Array.isArray(filter.conditions) ? filter.conditions as Filter[] : []; +} + +function textKeysOf(filter: Filter): TextField[] { + return TEXT_FIELDS.filter((field) => field in filter); +} + +function countTextConditions(filter: Filter): number { + if (isOperator(filter)) return conditionsOf(filter).reduce((sum, condition) => sum + countTextConditions(condition), 0); + return textKeysOf(filter).length; +} + +/** + * Keeps a filter within MAX_TEXT_CONDITIONS. Under AND a text condition past + * the limit is dropped, which only widens the result. Under OR or NOT that + * would narrow it or turn it around, so such a branch has to fit or the call + * gets an error. + */ +export function limitTextConditions(filter: Filter | undefined): Filter | undefined { + if (!filter) return filter; + let left = MAX_TEXT_CONDITIONS; + const walk = (node: Filter): Filter | null => { + if (isOperator(node)) { + if (node.operator === 'AND') { + const kept = conditionsOf(node).map(walk).filter((condition): condition is Filter => condition !== null); + return kept.length > 0 || conditionsOf(node).length === 0 ? { ...node, conditions: kept } : null; + } + const count = countTextConditions(node); + if (count > left) throw new MethodError('unsupportedFilter', 'This search has too many terms'); + left -= count; + return node; + } + const keys = textKeysOf(node); + if (keys.length <= left) { + left -= keys.length; + return node; + } + const kept = { ...node }; + for (const key of keys.slice(left)) delete kept[key]; + left = 0; + return Object.keys(kept).length > 0 ? kept : null; + }; + return walk(filter) ?? {}; +} + +// ---- SearchSnippet/get ---- + +const FOLD_CACHE = new Map(); + +function foldChar(ch: string): string { + const code = ch.charCodeAt(0); + if (code < 128) return code >= 65 && code <= 90 ? String.fromCharCode(code + 32) : ch; + let folded = FOLD_CACHE.get(ch); + if (folded === undefined) { + folded = ch.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase(); + if (FOLD_CACHE.size < 4096) FOLD_CACHE.set(ch, folded); + } + return folded; +} + +function fold(text: string): string { + let out = ''; + for (const ch of text) out += foldChar(ch); + return out; +} + +/** Text without case and diacritics, and where each of its positions came from. */ +function foldWithMap(text: string): { folded: string; map: number[] } { + const map: number[] = []; + let folded = ''; + let at = 0; + for (const ch of text) { + const piece = foldChar(ch); + for (let i = 0; i < piece.length; i += 1) map.push(at); + folded += piece; + at += ch.length; + } + map.push(text.length); + return { folded, map }; +} + +function escapeRegExp(text: string): string { + return text.replace(/[\\^$.*+?()[\]{}|/]/g, '\\$&'); +} + +/** What a probe finds, as a pattern over folded text, so a mark lands where the index matched. */ +function probePattern(probe: Probe): string | null { + const text = fold(probe.text); + if (probe.index === 'tri') return escapeRegExp(text); + const words = text.split(new RegExp(`${NOT_WORD_CHAR}+`, 'u')).filter(Boolean).map(escapeRegExp); + if (words.length === 0) return null; + return `(?(); + for (const term of terms) { + for (const probe of probesFor(term)) { + const pattern = probePattern(probe); + if (pattern) patterns.add(pattern); + } + } + if (patterns.size === 0) return null; + // Longest first, so a match isn't cut short by a shorter spelling of it. + return new RegExp([...patterns].sort((a, b) => b.length - a.length).join('|'), 'gu'); +} + +/** The terms a snippet marks. Anything under NOT was searched to be absent, so it isn't marked. */ +function snippetTerms(filter: Filter | null | undefined): { subject: Term[]; body: Term[] } { + const subject: Term[] = []; + const body: Term[] = []; + let left = MAX_TEXT_CONDITIONS; + const walk = (node: Filter) => { + if (isOperator(node)) { + if (node.operator !== 'NOT') conditionsOf(node).forEach(walk); + return; + } + for (const key of ['text', 'subject', 'body'] as const) { + const value = node[key]; + if (typeof value !== 'string' || left === 0) continue; + left -= 1; + const terms = parseTerms(value); + if (key !== 'body') subject.push(...terms); + if (key !== 'subject') body.push(...terms); + } + }; + if (filter) walk(filter); + return { subject, body }; +} + +const ENTITIES: Record = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; + +function escapeHtml(text: string): string { + return text.replace(/[&<>"']/g, (c) => ENTITIES[c]); +} + +function utf8Bytes(text: string): number { + let bytes = 0; + for (const ch of text) { + const code = ch.codePointAt(0) as number; + bytes += code < 0x80 ? 1 : code < 0x800 ? 2 : code < 0x10000 ? 3 : 4; + } + return bytes; +} + +/** The whole text, escaped, with its matches marked; null when nothing matches. */ +function markAll(text: string, pattern: RegExp): string | null { + const { folded, map } = foldWithMap(text); + let out = ''; + let at = 0; + for (const match of folded.matchAll(pattern)) { + const start = map[match.index]; + const end = map[match.index + match[0].length]; + out += `${escapeHtml(text.slice(at, start))}${escapeHtml(text.slice(start, end))}`; + at = end; + } + return out === '' ? null : out + escapeHtml(text.slice(at)); +} + +/** A section around the first match, escaped and marked, never past PREVIEW_BYTES; null when nothing matches. */ +function excerpt(raw: string, pattern: RegExp): string | null { + const text = raw.replace(/\s+/g, ' ').trim(); + const { folded, map } = foldWithMap(text); + let out = ''; + let used = 0; + // Stops before the character that wouldn't fit, keeping room for what must still follow. + const put = (segment: string, reserve: number): boolean => { + for (const ch of segment) { + const piece = escapeHtml(ch); + const size = utf8Bytes(piece); + if (used + size + reserve > PREVIEW_BYTES) return false; + out += piece; + used += size; + } + return true; + }; + let at = -1; + for (const match of folded.matchAll(pattern)) { + const start = map[match.index]; + const end = map[match.index + match[0].length]; + if (at < 0) { + const earliest = Math.max(0, start - PREVIEW_CONTEXT_CHARS); + const space = earliest === 0 ? -1 : text.indexOf(' ', earliest); + at = earliest === 0 ? 0 : space >= 0 && space < start ? space + 1 : start; + } + if (!put(text.slice(at, start), 0)) return out; + const before = out; + const usedBefore = used; + out += ''; + used += ''.length; + const opened = out.length; + const whole = put(text.slice(start, end), ''.length); + if (out.length === opened) { + // Not even one character fit: no empty mark. + out = before; + used = usedBefore; + return out; + } + out += ''; + used += '
'.length; + if (!whole) return out; + at = end; + } + if (at < 0) return null; + put(text.slice(at), 0); + return out; +} + +/** The indexes are contentless, so snippets come from the stored message itself. */ +export const searchSnippetGet: MethodHandler = (ctx, args) => { + const jmap = (args.accountId as string | undefined) ?? ctx.defaultJmap; + const ids = args.emailIds; + if (!Array.isArray(ids)) throw new MethodError('invalidArguments', 'emailIds is required'); + if (ids.length > MAX_SNIPPET_IDS) throw new MethodError('requestTooLarge', `At most ${MAX_SNIPPET_IDS} messages at a time`); + const terms = snippetTerms(args.filter as Filter | null | undefined); + const subjectPattern = highlighter(terms.subject); + const bodyPattern = highlighter(terms.body); + const list: Array<{ emailId: string; subject: string | null; preview: string | null }> = []; + const notFound: string[] = []; + for (const id of ids as string[]) { + const row = ctx.db.get<{ rowid: number; json: string }>('SELECT rowid, json FROM email WHERE jmap = ? AND id = ?', [jmap, id]); + if (!row) { + notFound.push(id); + continue; + } + const email = JSON.parse(row.json) as { subject?: unknown; preview?: unknown }; + const subject = subjectPattern && typeof email.subject === 'string' ? markAll(email.subject, subjectPattern) : null; + let preview: string | null = null; + if (bodyPattern) { + const body = ctx.db.get<{ json: string }>('SELECT json FROM email_body WHERE email_rowid = ?', [row.rowid]); + // Before its body has arrived, a message only has its preview to show. + const text = body ? bodyText(JSON.parse(body.json) as Record) : ''; + const source = text.trim() ? text.slice(0, INDEX_BODY_CHARS) : typeof email.preview === 'string' ? email.preview : ''; + preview = excerpt(source, bodyPattern); + } + list.push({ emailId: id, subject, preview }); + } + return { accountId: jmap, list, notFound }; +}; From a4451dc900f16600fed5aa50791502a3c7f4bf73 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 05:31:35 +0100 Subject: [PATCH 3/9] feat: add results from the local store to online searches --- lib/jmap/__tests__/search-merge.test.ts | 523 ++++++++++++++++++++++++ lib/jmap/client.ts | 205 +++++----- lib/jmap/offline-hooks.ts | 6 + lib/jmap/search-merge.ts | 203 +++++++++ 4 files changed, 838 insertions(+), 99 deletions(-) create mode 100644 lib/jmap/__tests__/search-merge.test.ts create mode 100644 lib/jmap/search-merge.ts diff --git a/lib/jmap/__tests__/search-merge.test.ts b/lib/jmap/__tests__/search-merge.test.ts new file mode 100644 index 000000000..4bfa9135b --- /dev/null +++ b/lib/jmap/__tests__/search-merge.test.ts @@ -0,0 +1,523 @@ +// @vitest-environment node +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { DEFAULT_SCOPE } from '@/lib/store/area'; +import { AccountEngine } from '@/lib/store/engine/account-engine'; +import { mailArea } from '@/lib/store/mail'; +import { FAKE_SESSION, FakeJmap, JMAP_ACCOUNT } from '@/lib/store/__tests__/helpers/fake-jmap'; +import { openMemoryStore } from '@/lib/store/__tests__/helpers/node-adapter'; +import { JMAPClient } from '../client'; +import type { SearchEmailsResult } from '../client-interface'; +import { setOfflineHooks, type OfflineHooks } from '../offline-hooks'; +import { filterHasTextTerms, LocalSearchMerge } from '../search-merge'; +import type { Email } from '../types'; + +function mail(id: string, minute: number, extra: Partial = {}): Email { + return { id, receivedAt: new Date(Date.UTC(2026, 9, 1, 0, minute)).toISOString(), ...extra } as Email; +} + +/** A side of the search: the whole result list, answered a page at a time, newest first. */ +function side(rows: Email[], total: number = rows.length) { + const asked: number[] = []; + const answer = async (position: number, limit: number): Promise => { + asked.push(position); + const emails = rows.slice(position, position + limit); + return { emails, total, hasMore: position + emails.length < total }; + }; + return { asked, answer }; +} + +function pager(server: ReturnType, local: ReturnType | null, limit: number) { + const merge = new LocalSearchMerge(); + return (position: number) => merge.run({ + key: 'search', + limit, + position, + server: (at) => server.answer(at, limit), + local: async (at) => (local ? local.answer(at, limit) : null), + }); +} + +const ids = (result: SearchEmailsResult) => result.emails.map((email) => email.id); + +describe('merging one page', () => { + it('sorts newest first and has no duplicates', async () => { + const server = side([mail('s4', 40), mail('both3', 30), mail('s1', 10)]); + const local = side([mail('l5', 50), mail('both3', 30), mail('l2', 20)]); + const result = await pager(server, local, 50)(0); + expect(ids(result)).toEqual(['l5', 's4', 'both3', 'l2', 's1']); + expect(result.hasMore).toBe(false); + expect(result.total).toBe(5); + }); + + it('keeps the server copy when both have a message', async () => { + const server = side([mail('both', 10, { subject: 'server copy' })]); + const local = side([mail('both', 10, { subject: 'local copy' })]); + const result = await pager(server, local, 50)(0); + expect(result.emails.map((email) => email.subject)).toEqual(['server copy']); + expect(result.total).toBe(1); + }); + + it('keeps the server copy when the local row sorts earlier than it', async () => { + // The local copy is out of date and looks newer: still one row, the server's. + const server = side([mail('s2', 20), mail('both', 10, { subject: 'server copy' })]); + const local = side([mail('both', 30, { subject: 'local copy' })]); + const result = await pager(server, local, 50)(0); + expect(ids(result)).toEqual(['s2', 'both']); + expect(result.emails[1].subject).toBe('server copy'); + }); + + it('passes on what the server added to its result, such as its deferred snippets', async () => { + const snippets = Promise.resolve([]); + const merge = new LocalSearchMerge(); + const result = await merge.run({ + key: 'k', limit: 10, position: 0, + server: async () => ({ emails: [mail('s', 1)], total: 1, hasMore: false, snippets }), + local: async () => ({ emails: [mail('l', 2)], total: 1, hasMore: false }), + }); + expect(result.snippets).toBe(snippets); + }); +}); + +describe('paging', () => { + it('walks both lists with no row lost or repeated, counting by merged rows', async () => { + const serverRows = Array.from({ length: 25 }, (_, i) => mail(`s${i}`, 1000 - i * 10)); + const localOnly = [mail('l-a', 995), mail('l-b', 905), mail('l-c', 805), mail('l-d', 500)]; + const server = side(serverRows); + const local = side([...localOnly, serverRows[1], serverRows[12]].sort((a, b) => Date.parse(b.receivedAt) - Date.parse(a.receivedAt))); + const page = pager(server, local, 10); + + const seen: string[] = []; + let result = await page(0); + seen.push(...ids(result)); + expect(result.emails.length).toBe(10); + expect(result.hasMore).toBe(true); + let guard = 0; + while (result.hasMore && guard < 10) { + // As the store does: the next position is how many rows it holds. + result = await page(seen.length); + seen.push(...ids(result)); + guard += 1; + } + expect(seen.length).toBe(29); + expect(new Set(seen).size).toBe(29); + expect(seen).toEqual(expect.arrayContaining([...serverRows.map((e) => e.id), ...localOnly.map((e) => e.id)])); + const times = [...serverRows, ...localOnly].sort((a, b) => Date.parse(b.receivedAt) - Date.parse(a.receivedAt)).map((e) => e.id); + expect(seen).toEqual(times); + expect(result.total).toBe(29); + }); + + it('asks the server for rows a local row pushed off the page', async () => { + const server = side([mail('s3', 30), mail('s2', 20), mail('s1', 10)]); + const local = side([mail('l4', 40)]); + const page = pager(server, local, 2); + expect(ids(await page(0))).toEqual(['l4', 's3']); + expect(server.asked).toEqual([0]); + // Merged position 2, but the server has only given one row so far. + expect(ids(await page(2))).toEqual(['s2', 's1']); + expect(server.asked).toEqual([0, 1]); + }); + + it('holds back a local row older than what the server has sent, in case the server has it later', async () => { + const serverRows = [mail('s4', 40), mail('s3', 30), mail('both', 20), mail('s1', 10)]; + const server = side(serverRows); + const local = side([mail('both', 20)]); + const page = pager(server, local, 2); + const first = await page(0); + expect(ids(first)).toEqual(['s4', 's3']); + const second = await page(2); + expect(ids(second)).toEqual(['both', 's1']); + expect(second.hasMore).toBe(false); + expect(second.total).toBe(4); + }); + + it('keeps paging through local rows when the server has none', async () => { + const server = side([]); + const local = side([mail('l3', 30), mail('l2', 20), mail('l1', 10)]); + const page = pager(server, local, 2); + const first = await page(0); + expect(ids(first)).toEqual(['l3', 'l2']); + expect(first.hasMore).toBe(true); + expect(first.total).toBe(3); + const second = await page(2); + expect(ids(second)).toEqual(['l1']); + expect(second.hasMore).toBe(false); + expect(second.total).toBe(3); + }); + + it('keeps total at or above what has been returned and what either side knows of', async () => { + const server = side([mail('s2', 20), mail('s1', 10)], 40); + const local = side([mail('l3', 30)], 50); + const result = await pager(server, local, 2)(0); + expect(result.total).toBe(50); + const noLocalLead = await pager(side([mail('s2', 20)], 40), side([mail('l3', 30)], 1), 2)(0); + expect(noLocalLead.total).toBe(41); + }); + + it('never repeats a local row the server later returns', async () => { + // Same time on both sides, split by the page edge: it shows once. + const server = side([mail('s', 20), mail('tie', 20)]); + const local = side([mail('tie', 20), mail('l', 5)]); + const page = pager(server, local, 1); + const seen = [...ids(await page(0))]; + seen.push(...ids(await page(seen.length))); + seen.push(...ids(await page(seen.length))); + expect(seen).toEqual(['s', 'tie', 'l']); + }); + + it('drops a row the server returns later if the store already showed it, and holds older local rows until the server catches up', async () => { + // The local copy of x looks newer than the server's, so it goes out first. + const server = side([mail('a', 30), mail('b', 25), mail('x', 20), mail('c', 10)]); + const local = side([mail('x', 40), mail('l', 5)]); + const page = pager(server, local, 2); + const first = await page(0); + expect(ids(first)).toEqual(['x', 'a']); + // The server's page is b and x. x has been shown, and the server has more, + // so l can't be placed yet: it may be on the server's next page. + const second = await page(2); + expect(ids(second)).toEqual(['b']); + expect(second.hasMore).toBe(true); + const third = await page(3); + expect(ids(third)).toEqual(['c', 'l']); + expect(third.hasMore).toBe(false); + }); + + it('follows a caller whose position has moved since the last page', async () => { + const server = side([mail('s4', 40), mail('s3', 30), mail('s2', 20), mail('s1', 10)]); + const local = side([mail('l5', 50)]); + const page = pager(server, local, 2); + await page(0); + // The caller added a row of its own to the list, as plugins can: one past. + await page(3); + expect(server.asked).toEqual([0, 2]); + }); + + it('starts over at position 0', async () => { + const server = side([mail('s2', 20), mail('s1', 10)]); + const local = side([mail('l3', 30)]); + const page = pager(server, local, 2); + await page(0); + expect(ids(await page(0))).toEqual(['l3', 's2']); + }); + + it('sends a position it has no cursor for to the server alone', async () => { + const server = side([mail('s2', 20), mail('s1', 10)]); + const local = side([mail('l3', 30)]); + const result = await pager(server, local, 2)(1); + expect(ids(result)).toEqual(['s1']); + expect(local.asked).toEqual([]); + }); + + it('keeps searches apart by key', async () => { + const merge = new LocalSearchMerge(); + const server = (rows: Email[]) => async (at: number) => ({ emails: rows.slice(at, at + 1), total: rows.length, hasMore: at + 1 < rows.length }); + const a = { key: 'a', limit: 1, server: server([mail('a2', 20), mail('a1', 10)]), local: async () => ({ emails: [mail('la', 30)], total: 1, hasMore: false }) }; + const b = { key: 'b', limit: 1, server: server([mail('b1', 10)]), local: async () => null }; + expect(ids(await merge.run({ ...a, position: 0 }))).toEqual(['la']); + expect(ids(await merge.run({ ...b, position: 0 }))).toEqual(['b1']); + expect(ids(await merge.run({ ...a, position: 1 }))).toEqual(['a2']); + }); +}); + +describe('errors', () => { + it('keeps the server result when the store fails', async () => { + const merge = new LocalSearchMerge(); + const result = await merge.run({ + key: 'k', limit: 10, position: 0, + server: async () => ({ emails: [mail('s', 1)], total: 1, hasMore: false }), + local: async () => { throw new Error('store broke'); }, + }); + expect(ids(result)).toEqual(['s']); + }); + + it('survives a store that fails before it can return a promise', async () => { + const merge = new LocalSearchMerge(); + const result = await merge.run({ + key: 'k', limit: 10, position: 0, + server: async () => ({ emails: [mail('s', 1)], total: 1, hasMore: false }), + local: () => { throw new Error('store broke'); }, + }); + expect(ids(result)).toEqual(['s']); + }); + + it('keeps the local result when the server fails', async () => { + const merge = new LocalSearchMerge(); + const result = await merge.run({ + key: 'k', limit: 10, position: 0, + server: async () => { throw new Error('server broke'); }, + local: async () => ({ emails: [mail('l', 1)], total: 1, hasMore: false }), + }); + expect(ids(result)).toEqual(['l']); + expect(result.total).toBe(1); + expect(result.hasMore).toBe(false); + }); + + it('asks the server again on the next page after it failed', async () => { + let fail = true; + const server = side([mail('s1', 10)]); + const merge = new LocalSearchMerge(); + const run = (position: number) => merge.run({ + key: 'k', limit: 1, position, + server: async (at) => { + if (fail) throw new Error('server broke'); + return server.answer(at, 1); + }, + local: async (at) => side([mail('l2', 20), mail('l1', 5)]).answer(at, 1), + }); + expect(ids(await run(0))).toEqual(['l2']); + fail = false; + expect(ids(await run(1))).toEqual(['s1']); + }); + + it('keeps the server error when the store has nothing to show', async () => { + const failing = async () => { throw new Error('server broke'); }; + await expect(new LocalSearchMerge().run({ + key: 'k', limit: 10, position: 0, server: failing, local: async () => ({ emails: [], total: 0, hasMore: false }), + })).rejects.toThrow('server broke'); + await expect(new LocalSearchMerge().run({ + key: 'k', limit: 10, position: 0, server: failing, local: async () => null, + })).rejects.toThrow('server broke'); + await expect(new LocalSearchMerge().run({ + key: 'k', limit: 10, position: 0, server: failing, local: async () => { throw new Error('store broke too'); }, + })).rejects.toThrow('server broke'); + }); + + it('keeps the error of a later page, so the user can try again', async () => { + let fail = false; + const merge = new LocalSearchMerge(); + const run = (position: number) => merge.run({ + key: 'k', limit: 1, position, + server: async (at) => { + if (fail) throw new Error('server broke'); + return side([mail('s2', 20), mail('s1', 10)]).answer(at, 1); + }, + local: async (at) => side([mail('l3', 30)]).answer(at, 1), + }); + expect(ids(await run(0))).toEqual(['l3']); + fail = true; + await expect(run(1)).rejects.toThrow('server broke'); + fail = false; + expect(ids(await run(1))).toEqual(['s2']); + }); + + describe('with a store that never answers', () => { + beforeEach(() => { vi.useFakeTimers(); }); + afterEach(() => { vi.useRealTimers(); }); + + it('does not hold the server result back for long', async () => { + const merge = new LocalSearchMerge(); + const answer = merge.run({ + key: 'k', limit: 10, position: 0, + server: async () => ({ emails: [mail('s', 1)], total: 1, hasMore: false }), + local: () => new Promise(() => {}), + }); + await vi.advanceTimersByTimeAsync(1_000); + expect(ids(await answer)).toEqual(['s']); + }); + + it('gives the server error once it has waited for the store', async () => { + const merge = new LocalSearchMerge(); + const answer = merge.run({ + key: 'k', limit: 10, position: 0, + server: async () => { throw new Error('server broke'); }, + local: () => new Promise(() => {}), + }); + const failed = expect(answer).rejects.toThrow('server broke'); + await vi.advanceTimersByTimeAsync(5_000); + await failed; + }); + }); +}); + +describe('filterHasTextTerms', () => { + it('is true for searches for text, in any shape', () => { + for (const filter of [ + { text: 'x' }, { from: 'x' }, { to: 'x' }, { cc: 'x' }, { bcc: 'x' }, { subject: 'x' }, { body: 'x' }, + { operator: 'AND', conditions: [{ inMailbox: 'i' }, { text: 'x' }] }, + { operator: 'OR', conditions: [{ to: 'x' }, { cc: 'x' }] }, + { operator: 'AND', conditions: [{ operator: 'OR', conditions: [{ hasKeyword: 'a' }, { body: 'x' }] }] }, + ]) expect(filterHasTextTerms(filter), JSON.stringify(filter)).toBe(true); + }); + + it('is false when nothing is searched for', () => { + for (const filter of [ + {}, { inMailbox: 'i' }, { hasKeyword: '$seen' }, { text: '' }, { text: ' ' }, + { operator: 'AND', conditions: [{ inMailbox: 'i' }, { hasAttachment: true }] }, + { operator: 'NOT', conditions: [{ text: 'x' }] }, + { operator: 'AND', conditions: [] }, + ]) expect(filterHasTextTerms(filter), JSON.stringify(filter)).toBe(false); + }); +}); + +describe('through the client, with a real store', () => { + const SERVER = 'https://mail.example.org'; + const SESSION = { + apiUrl: `${SERVER}/jmap/`, + downloadUrl: `${SERVER}/jmap/download/{accountId}/{blobId}/{name}?accept={type}`, + uploadUrl: `${SERVER}/jmap/upload/{accountId}/`, + accounts: { [JMAP_ACCOUNT]: { name: 'alice', isPersonal: true, accountCapabilities: {} } }, + primaryAccounts: { 'urn:ietf:params:jmap:mail': JMAP_ACCOUNT }, + capabilities: { 'urn:ietf:params:jmap:core': { maxObjectsInGet: 500 } }, + username: 'alice@example.org', + }; + + let serverRows: Array>; + let serverFails: boolean; + let requests: Array, string]>>; + + function fakeServerFetch() { + return vi.fn(async (url: string, init?: RequestInit) => { + if (String(url).endsWith('/.well-known/jmap')) return new Response(JSON.stringify(SESSION), { status: 200 }); + const { methodCalls } = JSON.parse(String(init?.body)) as { methodCalls: Array<[string, Record, string]> }; + requests.push(methodCalls); + if (serverFails) return new Response('broken', { status: 500 }); + const position = (methodCalls[0][1].position as number) ?? 0; + const limit = methodCalls[0][1].limit as number; + const page = serverRows.slice(position, position + limit); + return new Response(JSON.stringify({ + methodResponses: methodCalls.map(([name, , id]) => { + if (name === 'Email/query') return [name, { ids: page.map((row) => row.id), total: serverRows.length, position }, id]; + if (name === 'Email/get') return [name, { list: page, notFound: [] }, id]; + return [name, { list: [], notFound: [] }, id]; + }), + }), { status: 200 }); + }); + } + + async function storeEngine() { + const fake = new FakeJmap(); + fake.addEmail({ id: 'both', subject: 'Stromrechnung', receivedAt: '2026-10-05T00:00:00Z' }); + fake.addEmail({ id: 'only-local', subject: 'Zahlungserinnerungsrechnung', receivedAt: '2026-10-03T00:00:00Z', mailboxIds: { inbox: true } }); + fake.addEmail({ id: 'not-a-hit', subject: 'Hallo', receivedAt: '2026-10-02T00:00:00Z' }); + const engine = new AccountEngine({ + account: 'a@fake.test', + db: openMemoryStore(), + session: FAKE_SESSION, + transport: fake, + areas: [mailArea], + scope: { ...DEFAULT_SCOPE, days: null }, + now: () => Date.UTC(2026, 10, 3), + sleep: async () => {}, + }); + await engine.requestSync(); + return engine; + } + + function hooksWith(searchLocal: OfflineHooks['searchLocal'], skipNetwork = false): OfflineHooks { + return { + skipNetwork: () => skipNetwork, + answer: async () => null, + record: () => {}, + noteNetwork: () => {}, + serverGone: async () => false, + blob: async () => null, + recordBlob: () => {}, + holdUpload: async () => null, + searchLocal, + }; + } + + async function connected(): Promise { + const client = new JMAPClient(SERVER, 'alice@example.org', 'pw'); + await client.connect(); + return client; + } + + beforeEach(() => { + serverFails = false; + requests = []; + serverRows = [ + { id: 'newer', subject: 'Rechnung', receivedAt: '2026-10-06T00:00:00Z', mailboxIds: { inbox: true } }, + { id: 'both', subject: 'Stromrechnung, server copy', receivedAt: '2026-10-05T00:00:00Z', mailboxIds: { inbox: true } }, + ]; + vi.stubGlobal('fetch', fakeServerFetch()); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + setOfflineHooks(null); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('merges what the store finds into searchEmails, with its highlights', async () => { + const engine = await storeEngine(); + setOfflineHooks(hooksWith((_client, calls) => engine.answer(calls))); + const client = await connected(); + const result = await client.searchEmails('rechnung'); + expect(result.emails.map((email) => email.id)).toEqual(['newer', 'both', 'only-local']); + expect(result.emails[1].subject).toBe('Stromrechnung, server copy'); + expect(result.emails[2].searchSnippet?.subject).toBe('Zahlungserinnerungsrechnung'); + expect(result.total).toBe(3); + expect(result.hasMore).toBe(false); + }); + + it('merges into advancedSearchEmails too, and leaves the server request as it was', async () => { + const engine = await storeEngine(); + setOfflineHooks(hooksWith((_client, calls) => engine.answer(calls))); + const client = await connected(); + const filter = { operator: 'AND', conditions: [{ inMailbox: 'inbox' }, { text: 'rechnung' }] }; + const result = await client.advancedSearchEmails(filter); + expect(result.emails.map((email) => email.id)).toEqual(['newer', 'both', 'only-local']); + const query = requests[0][0]; + expect(query[0]).toBe('Email/query'); + expect(query[1]).toMatchObject({ filter, limit: 50, position: 0, calculateTotal: true, sort: [{ property: 'receivedAt', isAscending: false }] }); + }); + + it('shows what the store found when the server fails', async () => { + const engine = await storeEngine(); + setOfflineHooks(hooksWith((_client, calls) => engine.answer(calls))); + const client = await connected(); + serverFails = true; + const result = await client.searchEmails('rechnung'); + expect(result.emails.map((email) => email.id).sort()).toEqual(['both', 'only-local']); + }); + + it('shows what the server found when the store fails', async () => { + setOfflineHooks(hooksWith(async () => { throw new Error('store broke'); })); + const client = await connected(); + const result = await client.searchEmails('rechnung'); + expect(result.emails.map((email) => email.id)).toEqual(['newer', 'both']); + }); + + it('changes nothing when the store is off for the account', async () => { + const searchLocal = vi.fn(async () => null); + setOfflineHooks(hooksWith(searchLocal)); + const client = await connected(); + const result = await client.searchEmails('rechnung'); + expect(searchLocal).toHaveBeenCalledTimes(1); + expect(result).toEqual({ emails: expect.any(Array), total: 2, hasMore: false }); + expect(result.emails.map((email) => email.id)).toEqual(['newer', 'both']); + expect(requests.length).toBe(1); + }); + + it('changes nothing without a store', async () => { + setOfflineHooks(hooksWith(undefined)); + const client = await connected(); + const result = await client.searchEmails('rechnung', undefined, undefined, 50, 7); + expect(requests[0][0][1].position).toBe(7); + expect(result.total).toBe(2); + }); + + it('leaves filters without text to the server', async () => { + const searchLocal = vi.fn(async () => null); + setOfflineHooks(hooksWith(searchLocal)); + const client = await connected(); + await client.advancedSearchEmails({ inMailbox: 'inbox' }); + await client.advancedSearchEmails({}); + expect(searchLocal).not.toHaveBeenCalled(); + }); + + it('does not ask twice when the device is answering anyway', async () => { + const searchLocal = vi.fn(async () => null); + const hooks = hooksWith(searchLocal, true); + hooks.answer = async (_client, calls) => ({ + methodResponses: calls.map(([name, , id]) => (name === 'Email/query' + ? [name, { ids: [], total: 0, position: 0 }, id] + : [name, { list: [], notFound: [] }, id])), + }); + setOfflineHooks(hooks); + const client = await connected(); + await client.searchEmails('rechnung'); + expect(searchLocal).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 6d35dcdfa..6563320f0 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -9,6 +9,7 @@ import type { SieveScript, SieveCapabilities } from "./sieve-types"; import type { BlobBatch, CalendarEventUpdateOptions, CalendarQueryOptions, FileNodeListings, IJMAPClient, KeywordDiscoveryResult, KeywordInfo, KeywordMigration, SearchEmailsResult, SearchRequestOptions, UploadedEmailImport, UploadedEmailImportResult } from "./client-interface"; import { attachSearchSnippets, filterHasSnippetTerms, snippetFilterFor, type SearchSnippetResult } from "@/lib/search-snippet"; import { textConditions, textFilter } from "./search-utils"; +import { filterHasTextTerms, LocalSearchMerge } from "./search-merge"; import { batched, batchedBySize, itemsPerRequest } from "./request-limits"; import { keywordPointer, pointerToken } from "./patch-pointer"; import { FirstTouchGate } from "./first-touch-gate"; @@ -1638,6 +1639,7 @@ export class JMAPClient implements IJMAPClient { * can be reached again (a reload signs in as usual). */ private offlineOnly = false; + private readonly searchMerge = new LocalSearchMerge(); constructor(serverUrl: string, username: string, password: string) { this.serverUrl = serverUrl.replace(/\/$/, ''); @@ -4392,61 +4394,111 @@ export class JMAPClient implements IJMAPClient { } async searchEmails(query: string, mailboxId?: string, accountId?: string, limit: number = 50, position: number = 0, options: SearchRequestOptions = {}): Promise { + // The JMAP "text" filter searches from, to, cc, bcc, subject and body. + // Quoted phrases and `word*` prefixes go out as conditions of their + // own (see splitSearchText). inMailbox joins them in the same AND: a + // FilterOperator cannot also carry property keys. + const filter: Record = mailboxId + ? { operator: "AND", conditions: [{ inMailbox: mailboxId }, ...textConditions(query)] } + : textFilter(query); + return this.searchWithLocal(filter, accountId, limit, position, options); + } + + /** + * A search on the server, merged with what the local store finds (see + * search-merge.ts). Without a store for this client, or when the request + * would be answered on this device anyway, it's the server's answer alone. + */ + private searchWithLocal( + filter: Record, + accountId: string | undefined, + limit: number, + position: number, + options: SearchRequestOptions, + ): Promise { + const server = (at: number) => this.serverSearch(filter, accountId, limit, at, options); + const hooks = getOfflineHooks(); + const searchLocal = hooks?.searchLocal; + if (!searchLocal || this.offlineOnly || hooks.skipNetwork(this) || !filterHasTextTerms(filter)) { + return server(position); + } + return this.searchMerge.run({ + key: JSON.stringify([accountId ?? null, filter]), + limit, + position, + server, + local: async (at) => { + const targetAccountId = accountId || this.accountId; + const answer = await searchLocal.call(hooks, this, this.searchCalls(targetAccountId, filter, limit, at, false)); + return answer ? this.searchResultFrom(answer as JMAPResponse, accountId, limit, at) : null; + }, + }); + } + + private searchCalls( + targetAccountId: string, + filter: Record, + limit: number, + position: number, + deferSnippets: boolean | undefined, + ): JMAPMethodCall[] { + // Searching all folders with no criteria yields an empty FilterCondition, + // which servers reject; omit the key entirely to mean "no filter". + const hasFilter = Object.keys(filter).length > 0; + return [ + ["Email/query", { + accountId: targetAccountId, + ...(hasFilter ? { filter } : {}), + sort: [{ property: "receivedAt", isAscending: false }], + limit, + position, + calculateTotal: true, + }, "0"], + ["Email/get", { + accountId: targetAccountId, + "#ids": { resultOf: "0", name: "Email/query", path: "/ids" }, + properties: [...EMAIL_LIST_PROPERTIES], + }, "1"], + ...(deferSnippets ? [] : [this.searchSnippetCall(targetAccountId, hasFilter ? filter : undefined)]), + ]; + } + + private searchResultFrom(response: JMAPResponse, accountId: string | undefined, limit: number, position: number): SearchEmailsResult { + assertQuerySucceeded(response, 'Search'); + + const queryResponse = response.methodResponses?.[0]?.[1]; + const emails = applyListAuthenticationResults((response.methodResponses?.[1]?.[1]?.list || []) as Email[]); + emails.sort((a: Email, b: Email) => + new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime() + ); + const total = queryResponse?.total || 0; + const hasMore = computeHasMore(position, emails.length, total, limit); + attachSearchSnippets(emails, this.searchSnippetsFrom(response)); + + // Mirror getEmails: emails fetched from a delegated/shared account carry + // bare owner mailbox ids; namespace them to `${ownerId}:${id}` so they line + // up with the namespaced ids the store holds for shared mailboxes. The + // cross-account views (All mail / Unread / Starred) browse via + // advancedSearchEmails, so they need this too. (#281 V3) + if (accountId && accountId !== this.accountId) { + namespaceMailboxIds(emails, accountId); + } + return { emails, hasMore, total }; + } + + private async serverSearch( + filter: Record, + accountId: string | undefined, + limit: number, + position: number, + options: SearchRequestOptions, + ): Promise { try { const targetAccountId = accountId || this.accountId; - - // The JMAP "text" filter searches from, to, cc, bcc, subject and body. - // Quoted phrases and `word*` prefixes go out as conditions of their - // own (see splitSearchText). inMailbox joins them in the same AND: a - // FilterOperator cannot also carry property keys. - let filter: Record; - if (mailboxId) { - filter = { - operator: "AND", - conditions: [ - { inMailbox: mailboxId }, - ...textConditions(query), - ], - }; - } else { - filter = textFilter(query); - } - - const response = await this.request([ - ["Email/query", { - accountId: targetAccountId, - filter, - sort: [{ property: "receivedAt", isAscending: false }], - limit, - position, - calculateTotal: true, - }, "0"], - ["Email/get", { - accountId: targetAccountId, - "#ids": { resultOf: "0", name: "Email/query", path: "/ids" }, - properties: [...EMAIL_LIST_PROPERTIES], - }, "1"], - ...(options.deferSnippets ? [] : [this.searchSnippetCall(targetAccountId, filter)]), - ]); - assertQuerySucceeded(response, 'Search'); - - const queryResponse = response.methodResponses?.[0]?.[1]; - const emails = applyListAuthenticationResults((response.methodResponses?.[1]?.[1]?.list || []) as Email[]); - emails.sort((a: Email, b: Email) => - new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime() - ); - const total = queryResponse?.total || 0; - const hasMore = computeHasMore(position, emails.length, total, limit); - attachSearchSnippets(emails, this.searchSnippetsFrom(response)); - - // Mirror getEmails: emails fetched from a delegated/shared account carry - // bare owner mailbox ids; namespace them to `${ownerId}:${id}` so they line - // up with the namespaced ids the store holds for shared mailboxes. (#281 V3) - if (accountId && accountId !== this.accountId) { - namespaceMailboxIds(emails, accountId); - } - - return { emails, hasMore, total, ...this.deferredSnippets(options, targetAccountId, filter, emails) }; + const hasFilter = Object.keys(filter).length > 0; + const response = await this.request(this.searchCalls(targetAccountId, filter, limit, position, options.deferSnippets)); + const result = this.searchResultFrom(response, accountId, limit, position); + return { ...result, ...this.deferredSnippets(options, targetAccountId, hasFilter ? filter : undefined, result.emails) }; } catch (error) { // Rethrow: an empty result would read as "No results found". console.error('Search failed:', error); @@ -4518,52 +4570,7 @@ export class JMAPClient implements IJMAPClient { position: number = 0, options: SearchRequestOptions = {}, ): Promise { - try { - const targetAccountId = accountId || this.accountId; - - // Searching all folders with no criteria yields an empty FilterCondition, - // which servers reject; omit the key entirely to mean "no filter". - const hasFilter = Object.keys(filter).length > 0; - - const response = await this.request([ - ["Email/query", { - accountId: targetAccountId, - ...(hasFilter ? { filter } : {}), - sort: [{ property: "receivedAt", isAscending: false }], - limit, - position, - calculateTotal: true, - }, "0"], - ["Email/get", { - accountId: targetAccountId, - "#ids": { resultOf: "0", name: "Email/query", path: "/ids" }, - properties: [...EMAIL_LIST_PROPERTIES], - }, "1"], - ...(options.deferSnippets ? [] : [this.searchSnippetCall(targetAccountId, hasFilter ? filter : undefined)]), - ]); - assertQuerySucceeded(response, 'Search'); - - const queryResponse = response.methodResponses?.[0]?.[1]; - const emails = applyListAuthenticationResults((response.methodResponses?.[1]?.[1]?.list || []) as Email[]); - emails.sort((a: Email, b: Email) => - new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime() - ); - const total = queryResponse?.total || 0; - const hasMore = computeHasMore(position, emails.length, total, limit); - attachSearchSnippets(emails, this.searchSnippetsFrom(response)); - - // Namespace shared/delegated-account mailbox ids (see searchEmails). The - // cross-account views (All mail / Unread / Starred) browse via this method, - // so without it shared emails would carry bare owner ids there. (#281 V3) - if (accountId && accountId !== this.accountId) { - namespaceMailboxIds(emails, accountId); - } - - return { emails, hasMore, total, ...this.deferredSnippets(options, targetAccountId, hasFilter ? filter : undefined, emails) }; - } catch (error) { - console.error('Advanced search failed:', error); - throw error; - } + return this.searchWithLocal(filter, accountId, limit, position, options); } async getEmailFields( diff --git a/lib/jmap/offline-hooks.ts b/lib/jmap/offline-hooks.ts index ab0477fc9..b96dc1818 100644 --- a/lib/jmap/offline-hooks.ts +++ b/lib/jmap/offline-hooks.ts @@ -45,6 +45,12 @@ export interface OfflineHooks { recordBlob(client: OfflineClientRef, blobId: string, accountId: string, blob: Blob): void; /** Keep a file to upload later; the stand-in blob id, or null when offline mode doesn't cover the client. */ holdUpload(client: OfflineClientRef, file: File, accountId: string): Promise<{ blobId: string; size: number; type: string } | null>; + /** + * Search calls answered from the local store while the server is asked too + * (lib/jmap/search-merge.ts). Null when the store doesn't cover the client. + * Left out when there is no store, and searches then go to the server alone. + */ + searchLocal?(client: OfflineClientRef, methodCalls: OfflineMethodCall[]): Promise; } const KEY = '__bulwarkOfflineHooks'; diff --git a/lib/jmap/search-merge.ts b/lib/jmap/search-merge.ts new file mode 100644 index 000000000..a38cb53d1 --- /dev/null +++ b/lib/jmap/search-merge.ts @@ -0,0 +1,203 @@ +/** + * Online, a search also asks the local store and merges the two answers. The + * store finds what the server's index can't, such as a word inside a German + * compound. This module knows nothing about the store: it merges two lists. + * + * The merge walks both lists newest first, as a search sorts them. When both + * have a message, the server's copy is kept. A page never holds more than + * `limit` rows, so a local row can push a server row to the next page. + * + * Callers page by counting the rows they have, so `position` counts merged + * rows and means nothing to the server once a local row is among them. A cursor + * for each search remembers how far each side was read. A request past 0 with + * no cursor goes to the server alone, exactly as it would without a store. + * + * `hasMore` is true while either side has rows this search hasn't returned. + * A local list made only of rows the server also has can therefore cost one + * extra page that comes back empty, and then it's false. + * + * `total` is the server's total plus the local-only rows returned so far, and + * never less than the local total or the rows already returned. It can grow + * while paging, because how many local rows the server also has only shows + * when the lists are read. + * + * Local rows older than the last row the server sent wait for the next page: + * they may still be on the server's next page. + * + * An error on one side keeps the other side's rows. When the server fails and + * the store has nothing, the server's error stands, since an empty list would + * read as "no results". A slow store never holds up the server's answer for + * longer than LOCAL_GRACE_MS. + */ + +import { debug } from '@/lib/debug'; +import type { SearchEmailsResult } from './client-interface'; +import type { Email } from './types'; + +const LOCAL_GRACE_MS = 1_000; +const LOCAL_ALONE_MS = 5_000; +const MAX_CURSORS = 8; + +const TEXT_FILTER_KEYS = ['text', 'from', 'to', 'cc', 'bcc', 'subject', 'body']; + +/** + * Whether a filter searches for text. Other filters (a folder, a flag, a date) + * have the same answer on both sides, so only a stale local row could come of + * merging them. Terms under NOT are searched to be absent, so they don't count. + */ +export function filterHasTextTerms(filter: Record): boolean { + if (typeof filter.operator === 'string') { + if (filter.operator === 'NOT' || !Array.isArray(filter.conditions)) return false; + return (filter.conditions as Array>).some(filterHasTextTerms); + } + return TEXT_FILTER_KEYS.some((key) => typeof filter[key] === 'string' && (filter[key] as string).trim().length > 0); +} + +interface MergeCursor { + /** Merged rows returned so far, which is where the next page should ask from. */ + next: number; + serverPosition: number; + localPosition: number; + serverTotal: number; + /** Local-only rows returned so far. */ + extras: number; + /** Every row returned so far, so none appears twice. */ + shown: Set; +} + +function receivedTime(email: Email): number { + return Date.parse(email.receivedAt) || 0; +} + +interface PageMerge { + emails: Email[]; + serverRead: number; + localRead: number; + extras: number; +} + +/** One merged page. Rows are added to `shown`. */ +export function mergeSearchPages( + server: readonly Email[], + serverHasMore: boolean, + local: readonly Email[], + limit: number, + shown: Set, +): PageMerge { + const serverIds = new Set(server.map((email) => email.id)); + const emails: Email[] = []; + let s = 0; + let l = 0; + let extras = 0; + while (emails.length < limit) { + const fromServer = server[s]; + const fromLocal = local[l]; + if (fromLocal && (serverIds.has(fromLocal.id) || shown.has(fromLocal.id))) { + l += 1; + continue; + } + if (fromServer && shown.has(fromServer.id)) { + s += 1; + continue; + } + if (!fromServer && (!fromLocal || serverHasMore)) break; + if (fromServer && (!fromLocal || receivedTime(fromLocal) <= receivedTime(fromServer))) { + emails.push(fromServer); + shown.add(fromServer.id); + s += 1; + } else { + emails.push(fromLocal); + shown.add(fromLocal.id); + l += 1; + extras += 1; + } + } + return { emails, serverRead: s, localRead: l, extras }; +} + +/** The promise's value, or null if it fails or takes longer than `ms`. */ +function within(promise: Promise, ms: number): Promise { + return new Promise((resolve) => { + const timer = setTimeout(() => resolve(null), ms); + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (error) => { + clearTimeout(timer); + debug.warn('jmap', 'Local search failed:', error); + resolve(null); + }, + ); + }); +} + +export interface MergedSearchRequest { + /** The same for every page of one search. */ + key: string; + limit: number; + position: number; + server(position: number): Promise; + /** Null when the store doesn't cover this account. */ + local(position: number): Promise; +} + +export class LocalSearchMerge { + private readonly cursors = new Map(); + + private startCursor(key: string): MergeCursor { + const cursor: MergeCursor = { next: 0, serverPosition: 0, localPosition: 0, serverTotal: 0, extras: 0, shown: new Set() }; + this.cursors.delete(key); + this.cursors.set(key, cursor); + if (this.cursors.size > MAX_CURSORS) this.cursors.delete(this.cursors.keys().next().value as string); + return cursor; + } + + async run(request: MergedSearchRequest): Promise { + const { key, limit, position } = request; + const cursor = position === 0 ? this.startCursor(key) : this.cursors.get(key); + if (!cursor) return request.server(position); + + // A caller that removed rows or added its own asks from a little off + // where the last page ended, and the server's side moves with it. + const serverPosition = Math.max(0, cursor.serverPosition + position - cursor.next); + const localAnswer = Promise.resolve().then(() => request.local(cursor.localPosition)); + + let serverPage: SearchEmailsResult | null = null; + let serverError: unknown = null; + try { + serverPage = await request.server(serverPosition); + } catch (error) { + serverError = error; + } + const localPage = await within(localAnswer, serverPage ? LOCAL_GRACE_MS : LOCAL_ALONE_MS); + + if (!serverPage && !localPage?.emails.length) throw serverError; + if (serverPage && !localPage && cursor.extras === 0) { + // Nothing local in this search: the server's answer, untouched. + this.cursors.delete(key); + return serverPage; + } + + const server = serverPage?.emails ?? []; + const serverHasMore = serverPage?.hasMore ?? false; + const local = localPage?.emails ?? []; + const merged = mergeSearchPages(server, serverHasMore, local, limit, cursor.shown); + + cursor.serverPosition = serverPosition + merged.serverRead; + cursor.localPosition += merged.localRead; + cursor.next = position + merged.emails.length; + cursor.extras += merged.extras; + if (serverPage) cursor.serverTotal = serverPage.total; + + const serverLeft = merged.serverRead < server.length || serverHasMore; + const localLeft = localPage ? merged.localRead < local.length || localPage.hasMore : false; + return { + ...serverPage, + emails: merged.emails, + hasMore: serverLeft || localLeft, + total: Math.max(cursor.serverTotal + cursor.extras, localPage?.total ?? 0, cursor.next), + }; + } +} From fdeb7963bab486358179f1e1861cff6c6c0abe38 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 05:41:35 +0100 Subject: [PATCH 4/9] refactor: read event durations without loading the calendar UI --- components/calendar/event-card.tsx | 15 +-------------- lib/calendar-alerts.ts | 2 +- lib/calendar-event-normalization.ts | 15 +++++++++++++++ lib/calendar-utils.ts | 2 +- stores/calendar-store.ts | 2 +- 5 files changed, 19 insertions(+), 17 deletions(-) diff --git a/components/calendar/event-card.tsx b/components/calendar/event-card.tsx index d53f88b36..b7618622e 100644 --- a/components/calendar/event-card.tsx +++ b/components/calendar/event-card.tsx @@ -4,6 +4,7 @@ import { useCallback, useRef, useState, type CSSProperties, type DragEvent } fro import { useTranslations } from "next-intl"; import { cn } from "@/lib/utils"; import type { CalendarEvent, Calendar } from "@/lib/jmap/types"; +import { parseDuration } from "@/lib/calendar-event-normalization"; import { format } from "date-fns"; import { ChevronDown, ChevronUp, Users } from "@/components/icons"; import { getParticipantCount, isDeclinedByUser } from "@/lib/calendar-participants"; @@ -101,20 +102,6 @@ export function eventFillClass(tinted: boolean, inactive: boolean): string | und return tinted && !inactive ? "start-bar" : undefined; } -function parseDuration(duration: string | undefined): number { - if (!duration) return 0; - let totalMinutes = 0; - const weekMatch = duration.match(/(\d+)W/); - const hourMatch = duration.match(/(\d+)H/); - const minMatch = duration.match(/(\d+)M/); - const dayMatch = duration.match(/(\d+)D/); - if (weekMatch) totalMinutes += parseInt(weekMatch[1]) * 7 * 24 * 60; - if (dayMatch) totalMinutes += parseInt(dayMatch[1]) * 24 * 60; - if (hourMatch) totalMinutes += parseInt(hourMatch[1]) * 60; - if (minMatch) totalMinutes += parseInt(minMatch[1]); - return totalMinutes; -} - function createEventDragPreview(title: string, timeRange: string, color: string): HTMLElement { const el = document.createElement("div"); el.style.cssText = ` diff --git a/lib/calendar-alerts.ts b/lib/calendar-alerts.ts index bef0151e1..2c5e8ff96 100644 --- a/lib/calendar-alerts.ts +++ b/lib/calendar-alerts.ts @@ -7,7 +7,7 @@ import type { Calendar, CalendarTask, } from '@/lib/jmap/types'; -import { parseDuration } from '@/components/calendar/event-card'; +import { parseDuration } from '@/lib/calendar-event-normalization'; export interface PendingAlert { eventId: string; diff --git a/lib/calendar-event-normalization.ts b/lib/calendar-event-normalization.ts index 6dd31bf83..5305faa8e 100644 --- a/lib/calendar-event-normalization.ts +++ b/lib/calendar-event-normalization.ts @@ -39,6 +39,21 @@ export function parseDurationSeconds(duration: string | undefined): number | nul return (((weeks * 7 + days) * 24 + hours) * 60 + minutes) * 60 + seconds; } +/** A duration in whole minutes, 0 when there is none. */ +export function parseDuration(duration: string | undefined): number { + if (!duration) return 0; + let totalMinutes = 0; + const weekMatch = duration.match(/(\d+)W/); + const hourMatch = duration.match(/(\d+)H/); + const minMatch = duration.match(/(\d+)M/); + const dayMatch = duration.match(/(\d+)D/); + if (weekMatch) totalMinutes += parseInt(weekMatch[1]) * 7 * 24 * 60; + if (dayMatch) totalMinutes += parseInt(dayMatch[1]) * 24 * 60; + if (hourMatch) totalMinutes += parseInt(hourMatch[1]) * 60; + if (minMatch) totalMinutes += parseInt(minMatch[1]); + return totalMinutes; +} + export function normalizeAllDayDurationValue(duration: string | undefined): string | undefined { const totalSeconds = parseDurationSeconds(duration); if (totalSeconds === null || totalSeconds < 86400 || totalSeconds % 86400 !== 0) { diff --git a/lib/calendar-utils.ts b/lib/calendar-utils.ts index 2299fb54c..faff279c4 100644 --- a/lib/calendar-utils.ts +++ b/lib/calendar-utils.ts @@ -1,5 +1,5 @@ import { addDays, differenceInCalendarDays, parseISO, startOfDay, subMilliseconds } from "date-fns"; -import { parseDuration } from "@/components/calendar/event-card"; +import { parseDuration } from "@/lib/calendar-event-normalization"; import type { CalendarEvent } from "@/lib/jmap/types"; import { fromWallClock, isValidTimeZone, parseLocalDateTime, toDisplayDate } from "@/lib/timezone"; diff --git a/stores/calendar-store.ts b/stores/calendar-store.ts index d7f9c53f7..fceb9c4e7 100644 --- a/stores/calendar-store.ts +++ b/stores/calendar-store.ts @@ -5,7 +5,7 @@ import type { Calendar, CalendarEvent, CalendarParticipant, CalendarParticipantI import { debug } from '@/lib/debug'; import { getEventStartDate, normalizeAllDayDuration } from '@/lib/calendar-utils'; import { displayNow } from '@/lib/timezone'; -import { parseDuration } from '@/components/calendar/event-card'; +import { parseDuration } from '@/lib/calendar-event-normalization'; import { sanitizeOutgoingCalendarEventData } from '@/lib/calendar-event-normalization'; import { feedChanged, feedFields } from '@/lib/calendar-feed-diff'; import { expandRecurringEvents } from '@/lib/recurrence-expansion'; From 877a7a93b208cd33bd91cbcab5887a474d2cd266 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 05:42:01 +0100 Subject: [PATCH 5/9] feat: keep calendars on the device and queue calendar changes while offline Adds a `calendar` area to the local store. It syncs calendars, participant identities, events and tasks as masters, through CalendarEvent/changes, and answers Calendar/get, CalendarEvent/query, /get and /set from SQLite when the server can't be reached. - A range query gets series in both client modes, with their overrides, and the calendar store expands them as it does for an older server. - The synthetic-id probe gets the verdict from the last sync, so a client started offline behaves the same once the server is back. - Create, edit, delete and RSVP queue in `calendar_pending`, one merged row per event, and go out through `calendar.flush` or the next sync. A create finds itself by uid after a lost response. - A write the server refuses, or one on a value the server changed, goes to `calendar.conflicts`. - `lib/store/calendar/reminders.ts` lists upcoming alerts with keys that don't depend on synthetic ids. --- lib/recurrence-expansion.ts | 8 +- lib/recurrence-instances.ts | 2 +- .../__tests__/calendar-client-modes.test.ts | 212 ++++ lib/store/__tests__/calendar-no-dom.test.ts | 32 + lib/store/__tests__/calendar-sync.test.ts | 955 ++++++++++++++++++ lib/store/__tests__/helpers/client-wire.ts | 79 ++ lib/store/__tests__/helpers/fake-calendar.ts | 365 +++++++ lib/store/__tests__/helpers/fake-jmap.ts | 2 +- lib/store/area.ts | 5 +- lib/store/areas.ts | 3 +- lib/store/calendar/filter.ts | 220 ++++ lib/store/calendar/flush.ts | 229 +++++ lib/store/calendar/index.ts | 19 + lib/store/calendar/methods.ts | 197 ++++ lib/store/calendar/records.ts | 162 +++ lib/store/calendar/reminders.ts | 160 +++ lib/store/calendar/schema.ts | 77 ++ lib/store/calendar/sync.ts | 264 +++++ lib/store/calendar/writes.ts | 259 +++++ lib/store/engine/account-engine.ts | 2 +- .../calendar-store-offline-occurrence.test.ts | 117 +++ 21 files changed, 3359 insertions(+), 10 deletions(-) create mode 100644 lib/store/__tests__/calendar-client-modes.test.ts create mode 100644 lib/store/__tests__/calendar-no-dom.test.ts create mode 100644 lib/store/__tests__/calendar-sync.test.ts create mode 100644 lib/store/__tests__/helpers/client-wire.ts create mode 100644 lib/store/__tests__/helpers/fake-calendar.ts create mode 100644 lib/store/calendar/filter.ts create mode 100644 lib/store/calendar/flush.ts create mode 100644 lib/store/calendar/index.ts create mode 100644 lib/store/calendar/methods.ts create mode 100644 lib/store/calendar/records.ts create mode 100644 lib/store/calendar/reminders.ts create mode 100644 lib/store/calendar/schema.ts create mode 100644 lib/store/calendar/sync.ts create mode 100644 lib/store/calendar/writes.ts create mode 100644 stores/__tests__/calendar-store-offline-occurrence.test.ts diff --git a/lib/recurrence-expansion.ts b/lib/recurrence-expansion.ts index 90897c253..f8e0f4a33 100644 --- a/lib/recurrence-expansion.ts +++ b/lib/recurrence-expansion.ts @@ -210,7 +210,7 @@ function validDate(value: string | null | undefined): Date | null { } /** The wall-clock fields of a parsed LocalDateTime, read as UTC. */ -function wallAsUtc(wall: Date): number { +export function wallClockFieldsAsUtc(wall: Date): number { return Date.UTC( wall.getFullYear(), wall.getMonth(), wall.getDate(), wall.getHours(), wall.getMinutes(), wall.getSeconds(), @@ -236,7 +236,7 @@ function plausibleInstants( ): { start: Date; end: Date | null } | null { const wall = validDate(event.start); if (!wall) return null; - const wallStart = wallAsUtc(wall); + const wallStart = wallClockFieldsAsUtc(wall); const start = instantNear(event.utcStart, wallStart); if (!start) return null; const end = instantNear(event.utcEnd, wallStart + (parseDurationSeconds(event.duration) ?? 0) * 1000); @@ -288,10 +288,10 @@ function wallClockAsUtcTimestamp(formatter: Intl.DateTimeFormat, instant: number * and return the corresponding UTC instant. Two fixup iterations converge * for all real offsets, including across DST transitions. */ -function zonedWallTimeToUtc(wall: Date, timeZone: string): Date | null { +export function zonedWallTimeToUtc(wall: Date, timeZone: string): Date | null { const formatter = getTzFormatter(timeZone); if (!formatter) return null; - const wallMs = wallAsUtc(wall); + const wallMs = wallClockFieldsAsUtc(wall); let guess = wallMs; for (let i = 0; i < 2; i++) { guess = wallMs - (wallClockAsUtcTimestamp(formatter, guess) - guess); diff --git a/lib/recurrence-instances.ts b/lib/recurrence-instances.ts index 715895d11..562e7b9e7 100644 --- a/lib/recurrence-instances.ts +++ b/lib/recurrence-instances.ts @@ -341,7 +341,7 @@ export function applyOverridePatch>(event: T, p * replaces whatever the entry patches beneath it, and one beneath a value the * entry sets goes into that value. */ -function mergeOverrideEntry(entry: Record, updates: Record): Record { +export function mergeOverrideEntry(entry: Record, updates: Record): Record { const merged = { ...entry }; for (const [key, value] of Object.entries(updates)) { for (const existing of Object.keys(merged)) { diff --git a/lib/store/__tests__/calendar-client-modes.test.ts b/lib/store/__tests__/calendar-client-modes.test.ts new file mode 100644 index 000000000..35cb3ef7a --- /dev/null +++ b/lib/store/__tests__/calendar-client-modes.test.ts @@ -0,0 +1,212 @@ +// @vitest-environment node +import { afterEach, describe, expect, it } from 'vitest'; +import type { JMAPClient } from '@/lib/jmap/client'; +import type { CalendarEvent } from '@/lib/jmap/types'; +import { expandRecurringEvents } from '@/lib/recurrence-expansion'; +import { isBrowserExpandedOccurrence, isServerRecurrenceInstance } from '@/lib/recurrence-instances'; +import { calendarArea } from '../calendar'; +import { FakeCalendarJmap, type StalwartVersion } from './helpers/fake-calendar'; +import { wire, type Wire } from './helpers/client-wire'; + +const NOW = Date.UTC(2026, 10, 3, 12); +const RANGE = { after: '2026-11-02T00:00:00', before: '2026-11-23T00:00:00' }; + +let current: Wire | null = null; + +afterEach(() => { + current?.restore(); + current = null; +}); + +function seeded(version: StalwartVersion): Wire { + const server = new FakeCalendarJmap(version); + server.addEvent({ + id: 'series', + title: 'Weekly', + start: '2026-01-05T10:00:00', + recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'weekly' }, + recurrenceOverrides: { + '2026-11-09T10:00:00': { title: 'Special' }, + '2026-11-16T10:00:00': { excluded: true }, + }, + }); + server.addEvent({ id: 'single', title: 'One off', start: '2026-11-10T09:00:00' }); + current = wire(server, [calendarArea], NOW); + return current; +} + +/** What the calendar grid shows for a range: the client's query, then the store's expansion. */ +async function grid(client: JMAPClient, range = RANGE): Promise { + const raw = await client.queryAllCalendarEvents(range); + return expandRecurringEvents(raw, range.after, range.before); +} + +function summary(events: CalendarEvent[]): Array<[string, string]> { + return events.map((event): [string, string] => [event.start, event.title]).sort(); +} + +const SHOWN = [ + ['2026-11-02T10:00:00', 'Weekly'], + ['2026-11-09T10:00:00', 'Special'], + ['2026-11-10T09:00:00', 'One off'], +]; + +const expandedAsked = (w: Wire) => w.sent.some(([name, args]) => name === 'CalendarEvent/query' && args.expandRecurrences === true); + +describe('a client that probed online and keeps its mode', () => { + for (const version of ['1.0', '0.16'] as const) { + it(`synthetic-id mode on ${version}: the expanded query gets a correct grid`, async () => { + const w = seeded(version); + await w.engine.requestSync(); + const client = await w.newClient(); + await client.getCalendars(); + expect(await client.supportsSyntheticCalendarIds()).toBe(true); + const online = await grid(client); + expect(online.some(isServerRecurrenceInstance)).toBe(true); + expect(summary(online)).toEqual(SHOWN); + + w.online = false; + w.sent.length = 0; + const offline = await grid(client); + expect(expandedAsked(w)).toBe(true); + expect(summary(offline)).toEqual(SHOWN); + // Occurrences the calendar store can write back as overrides on the master. + const occurrences = offline.filter((event) => event.recurrenceId); + expect(occurrences.length).toBe(2); + for (const occurrence of occurrences) { + expect(isBrowserExpandedOccurrence(occurrence)).toBe(true); + expect(occurrence.originalId).toBe('series'); + } + // Back online, the same client still asks the server to expand. + w.online = true; + expect((await grid(client)).some(isServerRecurrenceInstance)).toBe(true); + }); + } + + it('client-expansion mode on an old server: never asks to expand, same grid', async () => { + const w = seeded('0.16.19'); + await w.engine.requestSync(); + const client = await w.newClient(); + await client.getCalendars(); + expect(await client.supportsSyntheticCalendarIds()).toBe(false); + w.online = false; + w.sent.length = 0; + expect(summary(await grid(client))).toEqual(SHOWN); + expect(expandedAsked(w)).toBe(false); + // The second get with the overrides went out, as the online path sends it. + const gets = w.sent.filter(([name]) => name === 'CalendarEvent/get'); + expect(gets.some(([, args]) => (args.properties as string[]).join() === 'id,recurrenceOverrides')).toBe(true); + }); + + it('gives the grid across a range longer than one expanded query may span', async () => { + const w = seeded('1.0'); + await w.engine.requestSync(); + const client = await w.newClient(); + await client.getCalendars(); + w.online = false; + w.sent.length = 0; + const range = { after: '2026-11-02T00:00:00', before: '2028-11-02T00:00:00' }; + const events = await grid(client, range); + const queries = w.sent.filter(([name]) => name === 'CalendarEvent/query'); + expect(queries.length).toBeGreaterThan(1); + expect(new Set(events.map((event) => event.id)).size).toBe(events.length); + expect(events.filter((event) => event.title === 'Weekly').length).toBeGreaterThan(90); + expect(events.filter((event) => event.title === 'Special')).toHaveLength(1); + }); +}); + +describe('a client started offline', () => { + it('takes the mode the server had at the last sync, so it behaves the same once the server is back', async () => { + const w = seeded('1.0'); + await w.engine.requestSync(); + w.online = false; + const client = await w.newClient(); + await client.getCalendars(); + expect(await client.supportsSyntheticCalendarIds()).toBe(true); + expect(summary(await grid(client))).toEqual(SHOWN); + w.online = true; + expect((await grid(client)).some(isServerRecurrenceInstance)).toBe(true); + }); + + it('does not ask to expand on a server that had no synthetic ids', async () => { + const w = seeded('0.16.19'); + await w.engine.requestSync(); + w.online = false; + w.sent.length = 0; + const client = await w.newClient(); + await client.getCalendars(); + expect(await client.supportsSyntheticCalendarIds()).toBe(false); + expect(summary(await grid(client))).toEqual(SHOWN); + expect(expandedAsked(w)).toBe(false); + }); + + it('uses client-side expansion before any sync, and a failed read does not look like an empty calendar', async () => { + const w = seeded('1.0'); + w.online = false; + const client = await w.newClient(); + expect(await client.supportsSyntheticCalendarIds()).toBe(false); + await expect(client.queryAllCalendarEvents(RANGE)).rejects.toThrow(); + }); +}); + +describe('reads and writes through the real client', () => { + it('reads back a created event under its stand-in id, then edits and deletes it', async () => { + const w = seeded('1.0'); + await w.engine.requestSync(); + const client = await w.newClient(); + await client.getCalendars(); + w.online = false; + + const created = await client.createCalendarEvent({ + title: 'Offline', + start: '2026-11-11T08:00:00', + duration: 'PT1H', + timeZone: 'Europe/Berlin', + calendarIds: { cal1: true }, + } as Partial); + expect(created.id).toMatch(/^local-/); + expect(created.title).toBe('Offline'); + expect(created.utcStart).toBe('2026-11-11T07:00:00Z'); + expect(summary(await grid(client))).toContainEqual(['2026-11-11T08:00:00', 'Offline']); + + await client.updateCalendarEvent(created.id, { title: 'Renamed' }); + expect(summary(await grid(client))).toContainEqual(['2026-11-11T08:00:00', 'Renamed']); + + await client.deleteCalendarEvent(created.id); + expect(summary(await grid(client))).toEqual(SHOWN); + expect(await client.getCalendarEvent(created.id)).toBeNull(); + expect(w.server.events.size).toBe(2); + }); + + it('fetches overrides with its one-event read', async () => { + const w = seeded('1.0'); + await w.engine.requestSync(); + const client = await w.newClient(); + w.online = false; + const event = await client.getCalendarEvent('series'); + expect(event?.recurrenceRules).toEqual([{ '@type': 'RecurrenceRule', frequency: 'weekly' }]); + expect(Object.keys(event?.recurrenceOverrides ?? {})).toHaveLength(2); + expect(event?.utcStart).toBe('2026-01-05T09:00:00Z'); + }); + + it('lists tasks through the shape fetchTaskObjects reads', async () => { + const w = seeded('1.0'); + w.server.addEvent({ id: 'task', '@type': 'Task', title: 'To do', start: undefined, due: '2026-11-12T10:00:00', progress: 'needs-action' }); + await w.engine.requestSync(); + const client = await w.newClient(); + w.online = false; + const tasks = await client.getCalendarTasks(); + expect(tasks.map((task) => [task.id, task.title, task.due])).toEqual([['task', 'To do', '2026-11-12T10:00:00']]); + expect(summary(await grid(client)).map(([, title]) => title)).not.toContain('To do'); + }); + + it('marks tasks-only calendars and reads identities from the last sync', async () => { + const w = seeded('1.0'); + await w.engine.requestSync(); + const client = await w.newClient(); + w.online = false; + const calendars = await client.getCalendars(); + expect(calendars.map((calendar) => [calendar.id, calendar.isTasksOnly])).toEqual([['cal1', false]]); + expect((await client.getParticipantIdentities())[0].calendarAddress).toBe('mailto:a@example.org'); + }); +}); diff --git a/lib/store/__tests__/calendar-no-dom.test.ts b/lib/store/__tests__/calendar-no-dom.test.ts new file mode 100644 index 000000000..9ee189b68 --- /dev/null +++ b/lib/store/__tests__/calendar-no-dom.test.ts @@ -0,0 +1,32 @@ +// @vitest-environment node +import path from 'node:path'; +import { build } from 'esbuild'; +import { describe, expect, it } from 'vitest'; + +const root = path.resolve(__dirname, '../../..'); + +async function inputsOf(entry: string): Promise { + const result = await build({ + entryPoints: [path.join(root, entry)], + bundle: true, + write: false, + format: 'esm', + platform: 'browser', + alias: { '@': root }, + metafile: true, + logLevel: 'silent', + // Stay away from the packages' own graph: only our files matter here. + packages: 'external', + }); + return Object.keys(result.metafile.inputs); +} + +describe('what the worker pulls in', () => { + for (const entry of ['lib/store/calendar/index.ts', 'lib/store/calendar/reminders.ts']) { + it(`${entry} reaches no UI code`, async () => { + const inputs = await inputsOf(entry); + expect(inputs.length).toBeGreaterThan(3); + expect(inputs.filter((file) => /(^|\/)(components|stores|hooks|app)\//.test(file))).toEqual([]); + }); + } +}); diff --git a/lib/store/__tests__/calendar-sync.test.ts b/lib/store/__tests__/calendar-sync.test.ts new file mode 100644 index 000000000..45ba37796 --- /dev/null +++ b/lib/store/__tests__/calendar-sync.test.ts @@ -0,0 +1,955 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; +import { DEFAULT_SCOPE, type StoreEvent } from '../area'; +import { calendarArea } from '../calendar'; +import { listUpcomingAlerts } from '../calendar/reminders'; +import { AccountEngine } from '../engine/account-engine'; +import { TransportError, type MethodCall } from '../engine/transport'; +import type { CalendarConflict } from '../calendar/writes'; +import { CAL, CALENDAR_SESSION, FakeCalendarJmap, type StalwartVersion } from './helpers/fake-calendar'; +import { JMAP_ACCOUNT } from './helpers/fake-jmap'; +import { openMemoryStore } from './helpers/node-adapter'; + +const NOW = Date.UTC(2026, 10, 3, 12); +const WEEKLY = { '@type': 'RecurrenceRule', frequency: 'weekly' }; + +function setup(version: StalwartVersion = '1.0', server = new FakeCalendarJmap(version)) { + const db = openMemoryStore(); + const events: StoreEvent[] = []; + const engine = new AccountEngine({ + account: 'a@fake.test', + db, + session: CALENDAR_SESSION, + transport: server, + areas: [calendarArea], + scope: DEFAULT_SCOPE, + emit: (event) => events.push(event), + now: () => NOW, + sleep: async () => {}, + }); + return { server, db, engine, events }; +} + +type Setup = ReturnType; + +async function call(s: Setup, name: string, args: Record) { + // A companion call: the engine answers null when nothing at all could be answered. + const response = await s.engine.answer([ + [name, { accountId: JMAP_ACCOUNT, ...args }, 'x'], + ['CalendarEventNotification/get', { accountId: JMAP_ACCOUNT, ids: [] }, 'y'], + ]); + return response!.methodResponses[0]; +} + +async function queryIds(s: Setup, args: Record): Promise { + const [name, result] = await call(s, 'CalendarEvent/query', args); + expect(name, JSON.stringify(result)).toBe('CalendarEvent/query'); + return result.ids; +} + +async function getOne(s: Setup, id: string, properties?: string[], timeZone?: string): Promise | undefined> { + const [, result] = await call(s, 'CalendarEvent/get', { ids: [id], ...(properties ? { properties } : {}), ...(timeZone ? { timeZone } : {}) }); + return result.list[0]; +} + +async function setEvent(s: Setup, args: Record) { + const [name, result] = await call(s, 'CalendarEvent/set', args); + expect(name).toBe('CalendarEvent/set'); + return result; +} + +const flush = (s: Setup) => s.engine.areaCall('calendar.flush', undefined) as Promise<{ sent: number; conflicts: number; pending: number }>; +const conflicts = (s: Setup) => s.engine.areaCall('calendar.conflicts', undefined) as Promise; +const pendingRows = (s: Setup) => s.db.all<{ kind: string; patch: string; record_id: string }>('SELECT kind, patch, record_id FROM calendar_pending'); +const setCalls = (server: FakeCalendarJmap): MethodCall[] => server.calls.filter(([name]) => name === 'CalendarEvent/set') + .filter(([, args]) => !(args.update && 'h333333' in (args.update as object))); + +describe('calendar sync', () => { + it('keeps a recurring event as its master across sync, with its overrides', async () => { + const s = setup(); + s.server.addEvent({ + id: 'series', + recurrenceRule: WEEKLY, + start: '2026-01-05T10:00:00', + recurrenceOverrides: { '2026-11-09T10:00:00': { title: 'Moved' }, '2026-11-16T10:00:00': { excluded: true } }, + }); + await s.engine.requestSync(); + expect(s.db.all('SELECT id FROM calendar_event').map((row) => row.id)).toEqual(['series']); + const stored = await getOne(s, 'series'); + expect(stored?.recurrenceRule).toEqual(WEEKLY); + expect(stored?.recurrenceOverrides).toEqual({ '2026-11-09T10:00:00': { title: 'Moved' }, '2026-11-16T10:00:00': { excluded: true } }); + }); + + it('asks for overrides without the utc properties, which 1.0 refuses next to them', async () => { + const s = setup('1.0'); + s.server.addEvent({ id: 'series', recurrenceRule: WEEKLY, recurrenceOverrides: { '2026-11-17T10:00:00': { title: 'x' } } }); + await s.engine.requestSync(); + const events = s.server.calls.filter(([name]) => name === 'CalendarEvent/get'); + const withOverrides = events.filter(([, args]) => (args.properties as string[]).includes('recurrenceOverrides')); + expect(withOverrides.length).toBeGreaterThan(0); + for (const [, args] of withOverrides) expect(args.properties).not.toContain('utcStart'); + expect(await getOne(s, 'series')).toMatchObject({ recurrenceOverrides: { '2026-11-17T10:00:00': { title: 'x' } } }); + }); + + it('never runs an expanded query or stores a synthetic id', async () => { + const s = setup('0.16'); + s.server.addEvent({ id: 'series', recurrenceRule: WEEKLY }); + s.server.addEvent({ id: 'single' }); + await s.engine.requestSync(); + expect(s.server.calls.filter(([, args]) => args.expandRecurrences)).toEqual([]); + expect(s.db.all('SELECT id FROM calendar_event ORDER BY id').map((row) => row.id)).toEqual(['series', 'single']); + }); + + it('pages past 500 objects', async () => { + const s = setup(); + for (let i = 0; i < 1234; i += 1) s.server.addEvent(); + await s.engine.requestSync(); + expect(Number(s.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM calendar_event')?.n)).toBe(1234); + expect(s.server.calls.filter(([name]) => name === 'CalendarEvent/query').length).toBe(3); + }); + + it('takes an edit of one occurrence from /changes as an update of the master', async () => { + const s = setup('1.0'); + s.server.addEvent({ id: 'series', recurrenceRule: WEEKLY, start: '2026-11-02T10:00:00' }); + await s.engine.requestSync(); + // Someone edits one occurrence through its synthetic id. + const [, expanded] = (await s.server.request([['CalendarEvent/query', { + accountId: JMAP_ACCOUNT, filter: { after: '2026-11-08T00:00:00', before: '2026-11-10T00:00:00' }, expandRecurrences: true, + }, 'q']])).methodResponses[0]; + expect(expanded.canCalculateChanges).toBe(false); + await s.server.request([['CalendarEvent/set', { accountId: JMAP_ACCOUNT, update: { [expanded.ids[0]]: { title: 'One off' } } }, 'u']]); + await s.engine.requestSync(); + expect(s.db.all('SELECT id FROM calendar_event').map((row) => row.id)).toEqual(['series']); + expect(await getOne(s, 'series')).toMatchObject({ + recurrenceOverrides: { '2026-11-09T10:00:00': { title: 'One off' } }, + }); + }); + + it('keeps old single events out and old series and tasks in, whatever their start', async () => { + const s = setup(); + s.server.addEvent({ id: 'ancient', start: '2020-01-01T10:00:00' }); + s.server.addEvent({ id: 'recent', start: '2025-06-01T10:00:00' }); + s.server.addEvent({ id: 'future', start: '2040-01-01T10:00:00' }); + s.server.addEvent({ id: 'old-series', start: '2015-01-01T10:00:00', recurrenceRule: WEEKLY }); + s.server.addEvent({ id: 'old-task', '@type': 'Task', start: undefined, due: '2015-01-01T10:00:00' }); + await s.engine.requestSync(); + expect(s.db.all('SELECT id FROM calendar_event ORDER BY id').map((row) => row.id)) + .toEqual(['future', 'old-series', 'old-task', 'recent']); + }); + + it('starts over when the log is gone, and drops what the server no longer has', async () => { + const s = setup(); + s.server.addEvent({ id: 'stays' }); + s.server.addEvent({ id: 'goes' }); + await s.engine.requestSync(); + s.server.events.delete('goes'); + s.server.eventLog.push('destroyed', 'goes'); + s.server.eventLog.truncatedBelow = s.server.eventLog.log.length; + await s.engine.requestSync(); + expect(s.db.all('SELECT id FROM calendar_event').map((row) => row.id)).toEqual(['stays']); + }); + + it('keeps ids of two JMAP accounts apart', async () => { + const s = setup(); + s.db.run( + "INSERT INTO calendar_event (jmap, id, json) VALUES ('other', 'same', ?)", + [JSON.stringify({ id: 'same', title: 'Theirs', start: '2026-11-10T10:00:00' })], + ); + s.server.addEvent({ id: 'same', title: 'Mine' }); + await s.engine.requestSync(); + expect((await getOne(s, 'same'))?.title).toBe('Mine'); + const row = s.db.get<{ json: string }>("SELECT json FROM calendar_event WHERE jmap = 'other' AND id = 'same'"); + expect(JSON.parse(row!.json).title).toBe('Theirs'); + }); + + it('stores calendars and participant identities and answers them with the real state', async () => { + const s = setup(); + await s.engine.requestSync(); + const [, calendars] = await call(s, 'Calendar/get', { properties: ['id', 'name'] }); + expect(calendars.list).toEqual([{ id: 'cal1', name: 'Personal' }]); + expect(calendars.state).toBe('cs1'); + const [, identities] = await call(s, 'ParticipantIdentity/get', {}); + expect(identities.list[0].calendarAddress).toBe('mailto:a@example.org'); + }); + + it('refuses to read before anything was kept, instead of saying there is nothing', async () => { + const s = setup(); + const [name, result] = await call(s, 'CalendarEvent/query', {}); + expect(name).toBe('error'); + expect(result.type).toBe('serverUnavailable'); + expect((await call(s, 'Calendar/get', {}))[0]).toBe('error'); + }); +}); + +describe('queries from the store', () => { + async function seeded() { + const s = setup(); + s.server.addEvent({ id: 'late', title: 'Late meeting', start: '2026-11-10T23:30:00', duration: 'PT1H', timeZone: 'Europe/Berlin' }); + s.server.addEvent({ + id: 'lunch', + title: 'Lünch with Bob', + description: 'Bring the slides', + start: '2026-11-11T12:00:00', + calendarIds: { cal2: true }, + locations: { l: { '@type': 'Location', name: 'Room Seven' } }, + organizerCalendarAddress: 'mailto:owner@example.org', + participants: { + p1: { '@type': 'Participant', name: 'Bob', calendarAddress: 'mailto:bob@example.org', roles: { attendee: true }, participationStatus: 'accepted' }, + p2: { '@type': 'Participant', name: 'Olga', calendarAddress: 'mailto:olga@example.org', roles: { owner: true }, participationStatus: 'declined' }, + }, + }); + s.server.addEvent({ id: 'series', title: 'Weekly', start: '2026-01-05T10:00:00', recurrenceRule: WEEKLY }); + s.server.addEvent({ id: 'ended', title: 'Ended', start: '2026-01-05T10:00:00', recurrenceRule: { ...WEEKLY, count: 2 } }); + s.server.addEvent({ id: 'task', '@type': 'Task', title: 'Task', start: undefined, due: '2026-11-12T10:00:00' }); + await s.engine.requestSync(); + return s; + } + + const ids = async (s: Setup, filter: unknown, extra: Record = {}) => (await queryIds(s, { filter, ...extra })).sort(); + + it('answers each filter the client can send', async () => { + const s = await seeded(); + expect(await ids(s, { inCalendar: 'cal2' })).toEqual(['lunch']); + expect(await ids(s, { inCalendars: ['cal2', 'nope'] })).toEqual(['lunch']); + expect(await ids(s, { title: 'lunch' })).toEqual(['lunch']); + expect(await ids(s, { description: 'slides' })).toEqual(['lunch']); + expect(await ids(s, { location: 'seven' })).toEqual(['lunch']); + expect(await ids(s, { owner: 'olga' })).toEqual(['lunch']); + expect(await ids(s, { owner: 'bob' })).toEqual([]); + expect(await ids(s, { attendee: 'bob' })).toEqual(['lunch']); + expect(await ids(s, { attendee: 'olga' })).toEqual([]); + expect(await ids(s, { participationStatus: 'declined' })).toEqual(['lunch']); + expect(await ids(s, { attendee: 'bob', participationStatus: 'declined' })).toEqual([]); + expect(await ids(s, { uid: 'lunch@example.org' })).toEqual(['lunch']); + expect(await ids(s, { uid: 'LUNCH@example.org' })).toEqual([]); + expect(await ids(s, { types: ['Task'] })).toEqual(['task']); + expect(await ids(s, { text: 'BOB' })).toEqual(['lunch']); + expect(await ids(s, { text: 'lunch bob' })).toEqual(['lunch']); + expect(await ids(s, { text: 'lunch nothing' })).toEqual([]); + }); + + it('answers operators', async () => { + const s = await seeded(); + expect(await ids(s, { operator: 'OR', conditions: [{ uid: 'late@example.org' }, { uid: 'task@example.org' }] })).toEqual(['late', 'task']); + expect(await ids(s, { operator: 'AND', conditions: [{ text: 'bob' }, { inCalendar: 'cal2' }] })).toEqual(['lunch']); + expect(await ids(s, { operator: 'NOT', conditions: [{ title: 'weekly' }, { title: 'ended' }, { title: 'late' }, { types: ['Task'] }] })).toEqual(['lunch']); + }); + + it('gives a per-call error for a filter it does not know', async () => { + const s = await seeded(); + const [name, result] = await call(s, 'CalendarEvent/query', { filter: { nonsense: 1 } }); + expect(name).toBe('error'); + expect(result.type).toBe('unsupportedFilter'); + }); + + it('reads bounds as wall clock in the request zone', async () => { + const s = await seeded(); + // 23:30 in Berlin on the 10th is 17:30 in New York, and 22:30 UTC. + const day10 = { after: '2026-11-10T00:00:00', before: '2026-11-11T00:00:00' }; + expect(await ids(s, day10, { timeZone: 'Europe/Berlin' })).toContain('late'); + const night = { after: '2026-11-10T22:45:00', before: '2026-11-10T23:00:00' }; + expect(await ids(s, night, { timeZone: 'Europe/Berlin' })).not.toContain('late'); + expect(await ids(s, night)).toContain('late'); + // A Z says nothing: Stalwart drops it and reads the wall clock. + expect(await ids(s, { after: '2026-11-10T22:45:00Z', before: '2026-11-10T23:00:00Z' }, { timeZone: 'Europe/Berlin' })).not.toContain('late'); + expect(await ids(s, { after: '2026-11-10T17:00:00', before: '2026-11-10T18:00:00' }, { timeZone: 'America/New_York' })).toContain('late'); + }); + + it('matches a series by any occurrence, once, and not when it has none in range', async () => { + const s = await seeded(); + const later = { after: '2026-11-16T00:00:00', before: '2026-11-17T00:00:00' }; + expect(await ids(s, later)).toEqual(['series']); + const bare = { operator: 'AND', conditions: [{ after: later.after }, { before: later.before }] }; + expect(await ids(s, bare)).toEqual(['series']); + // `ended` has two occurrences in January and none after. + expect(await ids(s, { after: '2026-01-05T00:00:00', before: '2026-01-06T00:00:00' })).toEqual(['ended', 'series']); + expect(await ids(s, { after: '2026-02-02T00:00:00', before: '2026-02-03T00:00:00' })).toEqual(['series']); + }); + + it('sorts, pages and counts', async () => { + const s = await seeded(); + const sort = [{ property: 'start', isAscending: false }]; + const [, first] = await call(s, 'CalendarEvent/query', { filter: { types: ['Event'] }, sort, limit: 2, calculateTotal: true }); + expect(first.ids).toEqual(['lunch', 'late']); + expect(first.total).toBe(4); + const [, second] = await call(s, 'CalendarEvent/query', { filter: { types: ['Event'] }, sort, limit: 2, position: 2 }); + expect(second.ids.sort()).toEqual(['ended', 'series']); + expect(first.canCalculateChanges).toBe(false); + }); + + it('returns utcStart for the request zone, and reads a floating event in it', async () => { + const s = setup(); + s.server.addEvent({ id: 'floating', start: '2026-11-10T10:00:00', timeZone: null, duration: 'PT1H' }); + s.server.addEvent({ id: 'zoned', start: '2026-11-10T10:00:00', timeZone: 'Europe/Berlin', duration: 'PT1H' }); + await s.engine.requestSync(); + expect(await getOne(s, 'floating', ['utcStart', 'utcEnd'], 'America/New_York')).toEqual({ + id: 'floating', utcStart: '2026-11-10T15:00:00Z', utcEnd: '2026-11-10T16:00:00Z', + }); + expect((await getOne(s, 'zoned', ['utcStart'], 'America/New_York'))?.utcStart).toBe('2026-11-10T09:00:00Z'); + }); + + it('answers the task list like the client reads it', async () => { + const s = await seeded(); + const list = await ids(s, undefined, { limit: 1000 }); + expect(list).toContain('task'); + const task = await getOne(s, 'task', ['id', '@type', 'due', 'progress', 'title']); + expect(task).toMatchObject({ '@type': 'Task', due: '2026-11-12T10:00:00', title: 'Task' }); + }); + + it('answers notifications with nothing rather than failing', async () => { + const s = await seeded(); + const response = await s.engine.answer([ + ['CalendarEventNotification/query', { accountId: JMAP_ACCOUNT, sort: [] }, '0'], + ['CalendarEventNotification/get', { accountId: JMAP_ACCOUNT, '#ids': { resultOf: '0', name: 'CalendarEventNotification/query', path: '/ids' } }, '1'], + ]); + expect(response!.methodResponses[1][1].list).toEqual([]); + }); + + it('refuses an expanded query without both bounds, as both servers do', async () => { + const s = await seeded(); + const [name, result] = await call(s, 'CalendarEvent/query', { filter: { after: '2026-11-16T00:00:00' }, expandRecurrences: true }); + expect(name).toBe('error'); + expect(result.type).toBe('invalidArguments'); + }); +}); + +describe('writes while offline', () => { + async function synced(version: StalwartVersion = '1.0') { + const s = setup(version); + s.server.addEvent({ + id: 'e1', + title: 'Meeting', + participants: { + me: { '@type': 'Participant', calendarAddress: 'mailto:a@example.org', participationStatus: 'needs-action', roles: { attendee: true } }, + bob: { '@type': 'Participant', calendarAddress: 'mailto:bob@example.org', participationStatus: 'accepted', roles: { attendee: true } }, + }, + }); + await s.engine.requestSync(); + s.server.calls.length = 0; + return s; + } + + it('merges edits of one event into one patch, and shows them at once', async () => { + const s = await synced(); + await setEvent(s, { update: { e1: { title: 'First' } } }); + await setEvent(s, { update: { e1: { description: 'Added' } } }); + await setEvent(s, { update: { e1: { title: 'Second' } } }); + expect(pendingRows(s)).toHaveLength(1); + expect(await getOne(s, 'e1')).toMatchObject({ title: 'Second', description: 'Added' }); + + await flush(s); + const sent = setCalls(s.server); + expect(sent).toHaveLength(1); + expect((sent[0][1].update as Record).e1).toEqual({ title: 'Second', description: 'Added' }); + expect(s.server.events.get('e1')).toMatchObject({ title: 'Second', description: 'Added' }); + expect(pendingRows(s)).toEqual([]); + }); + + it('merges a pointer into a value set whole, and a whole value over pointers beneath it', async () => { + const s = await synced(); + await setEvent(s, { update: { e1: { locations: { a: { name: 'One' } } } } }); + await setEvent(s, { update: { e1: { 'locations/b': { name: 'Two' } } } }); + expect(JSON.parse(pendingRows(s)[0].patch)).toEqual({ locations: { a: { name: 'One' }, b: { name: 'Two' } } }); + + const other = await synced(); + await setEvent(other, { update: { e1: { 'participants/me/participationStatus': 'accepted' } } }); + await setEvent(other, { update: { e1: { participants: { only: { name: 'Solo' } } } } }); + expect(JSON.parse(pendingRows(other)[0].patch)).toEqual({ participants: { only: { name: 'Solo' } } }); + }); + + it('queues an RSVP as a participant patch and sends it with scheduling on', async () => { + const s = await synced(); + const answer = await setEvent(s, { + update: { e1: { 'participants/me/participationStatus': 'accepted' } }, + sendSchedulingMessages: true, + }); + expect(answer.updated).toEqual({ e1: null }); + expect(s.server.events.get('e1')?.participants).toMatchObject({ me: { participationStatus: 'needs-action' } }); + const shown = await getOne(s, 'e1') as { participants: Record }; + expect(shown.participants.me.participationStatus).toBe('accepted'); + expect(shown.participants.bob.participationStatus).toBe('accepted'); + + await flush(s); + const [, args] = setCalls(s.server)[0]; + expect(args.sendSchedulingMessages).toBe(true); + expect(args.update).toEqual({ e1: { 'participants/me/participationStatus': 'accepted' } }); + expect(s.server.events.get('e1')?.participants).toMatchObject({ me: { participationStatus: 'accepted' }, bob: { participationStatus: 'accepted' } }); + }); + + it('keeps scheduling on when a later edit does not ask for it', async () => { + const s = await synced(); + await setEvent(s, { update: { e1: { 'participants/me/participationStatus': 'accepted' } }, sendSchedulingMessages: true }); + await setEvent(s, { update: { e1: { description: 'Note' } }, sendSchedulingMessages: false }); + await flush(s); + expect(setCalls(s.server)[0][1].sendSchedulingMessages).toBe(true); + }); + + it('survives a sync while pending: the server copy moves on underneath, the edit stays on top', async () => { + const s = await synced(); + await setEvent(s, { update: { e1: { title: 'Mine' } } }); + s.server.patchEvent('e1', { description: 'Theirs' }); + // The delta gets through, the write doesn't. + const realRequest = s.server.request.bind(s.server); + let refuseWrites = true; + s.server.request = async (calls: MethodCall[]) => { + if (refuseWrites && calls.some(([name, args]) => name === 'CalendarEvent/set' && args.update && 'e1' in (args.update as object))) { + throw new TransportError('network', 'Failed to fetch'); + } + return realRequest(calls); + }; + await s.engine.requestSync(); + expect(pendingRows(s)).toHaveLength(1); + expect(s.server.events.get('e1')).toMatchObject({ title: 'Meeting', description: 'Theirs' }); + expect(await getOne(s, 'e1')).toMatchObject({ title: 'Mine', description: 'Theirs' }); + + refuseWrites = false; + await s.engine.requestSync(); + expect(await conflicts(s)).toEqual([]); + expect(s.server.events.get('e1')).toMatchObject({ title: 'Mine', description: 'Theirs' }); + expect(await getOne(s, 'e1')).toMatchObject({ title: 'Mine', description: 'Theirs' }); + }); + + it('sends an edit that came in during a send, and takes its own first write for its own', async () => { + const s = await synced(); + await setEvent(s, { update: { e1: { title: 'One' } } }); + const realRequest = s.server.request.bind(s.server); + let once = true; + s.server.request = async (calls: MethodCall[]) => { + const response = await realRequest(calls); + if (once && calls.some(([name]) => name === 'CalendarEvent/set')) { + once = false; + await setEvent(s, { update: { e1: { title: 'Two', description: 'During' } } }); + } + return response; + }; + await flush(s); + expect(await conflicts(s)).toEqual([]); + expect(s.server.events.get('e1')).toMatchObject({ title: 'Two', description: 'During' }); + expect(pendingRows(s)).toEqual([]); + }); + + it('does not take an edit whose answer got lost for a conflict when it comes back through sync', async () => { + const s = await synced(); + await setEvent(s, { update: { e1: { title: 'Sent once' } } }); + s.server.loseNextSetResponse = true; + expect(await flush(s)).toMatchObject({ sent: 0, pending: 1 }); + expect(s.server.events.get('e1')?.title).toBe('Sent once'); + expect(await flush(s)).toMatchObject({ sent: 1, pending: 0 }); + expect(await conflicts(s)).toEqual([]); + }); + + it('gives out a stand-in id on create that later edits and deletes can use', async () => { + const s = await synced(); + const result = await setEvent(s, { create: { 'new-event': { title: 'Fresh', calendarIds: { cal1: true }, start: '2026-11-20T09:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin' } } }); + const id = result.created['new-event'].id as string; + expect(await getOne(s, id, ['title', 'utcStart', 'uid'])).toMatchObject({ title: 'Fresh', utcStart: '2026-11-20T08:00:00Z' }); + expect(await queryIds(s, { filter: { after: '2026-11-20T00:00:00', before: '2026-11-21T00:00:00' } })).toEqual([id]); + + await setEvent(s, { update: { [id]: { title: 'Fresher', 'locations/a': { name: 'Here' } } } }); + expect(pendingRows(s)).toHaveLength(1); + expect(pendingRows(s)[0].kind).toBe('create'); + expect(await getOne(s, id)).toMatchObject({ title: 'Fresher', locations: { a: { name: 'Here' } } }); + + await flush(s); + const creates = setCalls(s.server).filter(([, args]) => args.create); + expect(creates).toHaveLength(1); + expect(s.server.events.size).toBe(2); + const onServer = [...s.server.events.values()].find((event) => event.title === 'Fresher'); + expect(onServer?.locations).toEqual({ a: { name: 'Here' } }); + expect(await getOne(s, id)).toMatchObject({ id: onServer?.id, title: 'Fresher' }); + expect(s.db.get('SELECT 1 AS x FROM calendar_event WHERE id = ?', [id])).toBeUndefined(); + }); + + it('sends nothing for a create deleted again before it left', async () => { + const s = await synced(); + const result = await setEvent(s, { create: { c: { title: 'Gone', start: '2026-11-20T09:00:00', calendarIds: { cal1: true } } } }); + const id = result.created.c.id as string; + const gone = await setEvent(s, { destroy: [id] }); + expect(gone.destroyed).toEqual([id]); + expect(await getOne(s, id)).toBeUndefined(); + await flush(s); + expect(setCalls(s.server)).toEqual([]); + expect(s.server.events.size).toBe(1); + }); + + it('sends an edit then delete as just the delete, and hides it meanwhile', async () => { + const s = await synced(); + await setEvent(s, { update: { e1: { title: 'Doomed' } } }); + await setEvent(s, { destroy: ['e1'] }); + expect(pendingRows(s)[0].kind).toBe('destroy'); + expect(await getOne(s, 'e1')).toBeUndefined(); + expect(await queryIds(s, {})).toEqual([]); + await flush(s); + expect(setCalls(s.server).map(([, args]) => args.destroy)).toEqual([['e1']]); + expect(s.server.events.has('e1')).toBe(false); + expect(s.db.get('SELECT 1 AS x FROM calendar_event WHERE id = ?', ['e1'])).toBeUndefined(); + }); + + it('takes a delete of an event already gone as done', async () => { + const s = await synced(); + await setEvent(s, { destroy: ['e1'] }); + s.server.removeEvent('e1'); + await flush(s); + expect(await conflicts(s)).toEqual([]); + expect(pendingRows(s)).toEqual([]); + }); + + it('keeps writes across a run without a network and sends them after', async () => { + const s = await synced(); + await setEvent(s, { update: { e1: { title: 'Later' } } }); + s.server.offline = true; + const offline = await flush(s); + expect(offline).toMatchObject({ sent: 0, pending: 1 }); + expect(await getOne(s, 'e1')).toMatchObject({ title: 'Later' }); + s.server.offline = false; + expect(await flush(s)).toMatchObject({ sent: 1, pending: 0 }); + expect(s.server.events.get('e1')?.title).toBe('Later'); + }); + + it('flushes during a normal sync pass too', async () => { + const s = await synced(); + await setEvent(s, { update: { e1: { title: 'By sync' } } }); + await s.engine.requestSync(); + expect(s.server.events.get('e1')?.title).toBe('By sync'); + expect(pendingRows(s)).toEqual([]); + }); + + it('tells the page something changed', async () => { + const s = await synced(); + s.events.length = 0; + await setEvent(s, { update: { e1: { title: 'x' } } }); + expect(s.events).toContainEqual({ type: 'changed', account: 'a@fake.test', collections: ['CalendarEvent'] }); + }); +}); + +describe('a create and a lost response', () => { + const NEW = { title: 'Once', calendarIds: { cal1: true }, start: '2026-11-20T09:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin' }; + + async function queued() { + const s = setup(); + await s.engine.requestSync(); + s.server.calls.length = 0; + const result = await setEvent(s, { create: { c: NEW } }); + return { s, id: result.created.c.id as string, uid: result.created.c.uid as string }; + } + + it('gives the event a uid of its own before it goes out', async () => { + const { s, uid } = await queued(); + expect(uid).toBeTruthy(); + await flush(s); + expect([...s.server.events.values()][0].uid).toBe(uid); + }); + + it('keeps a uid the client chose', async () => { + const s = setup(); + await s.engine.requestSync(); + const result = await setEvent(s, { create: { c: { ...NEW, uid: 'mine@example.org' } } }); + expect(result.created.c.uid).toBe('mine@example.org'); + }); + + it('finds the event by uid instead of creating it again', async () => { + const { s, id, uid } = await queued(); + s.server.loseNextSetResponse = true; + expect(await flush(s)).toMatchObject({ sent: 0, pending: 1 }); + expect(s.server.events.size).toBe(1); + + expect(await flush(s)).toMatchObject({ sent: 1, pending: 0 }); + expect(s.server.events.size).toBe(1); + expect(setCalls(s.server).filter(([, args]) => args.create)).toHaveLength(1); + expect(s.server.calls.some(([name, args]) => name === 'CalendarEvent/query' && (args.filter as { uid?: string })?.uid === uid)).toBe(true); + const real = [...s.server.events.keys()][0]; + expect(await queryIds(s, {})).toEqual([real]); + expect(await getOne(s, id)).toMatchObject({ id: real }); + expect(await conflicts(s)).toEqual([]); + }); + + it('shows one event when a sync finds the created event before the flush does', async () => { + const { s } = await queued(); + s.server.loseNextSetResponse = true; + await flush(s); + await s.engine.requestSync(); + expect((await queryIds(s, {}))).toHaveLength(1); + expect(s.server.events.size).toBe(1); + }); + + it('creates it after all when the request never reached the server', async () => { + const { s } = await queued(); + // The attempt is marked, the network drops before it arrives. + const realRequest = s.server.request.bind(s.server); + let dropped = false; + s.server.request = async (calls: MethodCall[]) => { + if (!dropped && calls.some(([name, args]) => name === 'CalendarEvent/set' && args.create)) { + dropped = true; + throw new TransportError('network', 'Failed to fetch'); + } + return realRequest(calls); + }; + await flush(s); + expect(s.server.events.size).toBe(0); + expect(await flush(s)).toMatchObject({ sent: 1, pending: 0 }); + expect(s.server.events.size).toBe(1); + }); + + it('treats the uid refusal as "it exists already", should the mark have been lost', async () => { + const { s, uid } = await queued(); + s.db.run('UPDATE calendar_pending SET attempted = 0'); + s.server.addEvent({ ...NEW, uid, id: 'already' }); + await flush(s); + expect(s.server.events.size).toBe(1); + expect(await conflicts(s)).toEqual([]); + expect(await queryIds(s, {})).toEqual(['already']); + }); + + it('follows a create with its delete when it went out before the delete came', async () => { + const { s, id } = await queued(); + s.server.loseNextSetResponse = true; + await flush(s); + expect(s.server.events.size).toBe(1); + await setEvent(s, { destroy: [id] }); + expect(await queryIds(s, {})).toEqual([]); + await flush(s); + expect(s.server.events.size).toBe(0); + expect(pendingRows(s)).toEqual([]); + }); + + it('applies a write that came in during the send as a write of its own', async () => { + const { s, id } = await queued(); + const realRequest = s.server.request.bind(s.server); + s.server.request = async (calls: MethodCall[]) => { + const response = await realRequest(calls); + if (calls.some(([name, args]) => name === 'CalendarEvent/set' && args.create)) { + await setEvent(s, { update: { [id]: { title: 'Changed meanwhile' } } }); + } + return response; + }; + await flush(s); + expect([...s.server.events.values()][0].title).toBe('Changed meanwhile'); + expect(pendingRows(s)).toEqual([]); + }); +}); + +describe('conflicts', () => { + async function synced(version: StalwartVersion = '1.0') { + const s = setup(version); + s.server.addEvent({ id: 'e1', title: 'Meeting', recurrenceRule: WEEKLY }); + await s.engine.requestSync(); + s.server.calls.length = 0; + return s; + } + + it('lists a patch on a value the server changed too, and does not send it', async () => { + const s = await synced(); + await setEvent(s, { update: { e1: { title: 'Mine' } } }); + s.server.patchEvent('e1', { title: 'Theirs' }); + const result = await flush(s); + expect(setCalls(s.server)).toEqual([]); + expect(result).toMatchObject({ sent: 0, pending: 0 }); + const [conflict] = await conflicts(s); + expect(conflict).toMatchObject({ recordId: 'e1', kind: 'update', reason: 'changedOnServer', local: { title: 'Mine' } }); + expect(conflict.server).toMatchObject({ title: 'Theirs' }); + expect(s.server.events.get('e1')?.title).toBe('Theirs'); + expect(await getOne(s, 'e1')).toMatchObject({ title: 'Theirs' }); + }); + + it('lets an edit through when the server changed something else', async () => { + const s = await synced(); + await setEvent(s, { update: { e1: { title: 'Mine' } } }); + s.server.patchEvent('e1', { description: 'Theirs' }); + await flush(s); + expect(await conflicts(s)).toEqual([]); + expect(s.server.events.get('e1')).toMatchObject({ title: 'Mine', description: 'Theirs' }); + }); + + it('does not take the same value on both sides for a conflict', async () => { + const s = await synced(); + await setEvent(s, { update: { e1: { title: 'Same' } } }); + s.server.patchEvent('e1', { title: 'Same' }); + await flush(s); + expect(await conflicts(s)).toEqual([]); + }); + + it('lets an RSVP through when the organizer changed the title', async () => { + const s = setup(); + s.server.addEvent({ id: 'e1', participants: { me: { participationStatus: 'needs-action' } } }); + await s.engine.requestSync(); + await setEvent(s, { update: { e1: { 'participants/me/participationStatus': 'accepted' } } }); + s.server.patchEvent('e1', { title: 'New title' }); + await flush(s); + expect(await conflicts(s)).toEqual([]); + expect(s.server.events.get('e1')).toMatchObject({ title: 'New title', participants: { me: { participationStatus: 'accepted' } } }); + }); + + it('lists a patch the server refuses, with its reason, and goes on with the others', async () => { + const s = await synced(); + s.server.addEvent({ id: 'e2', title: 'Other' }); + await s.engine.requestSync(); + await setEvent(s, { update: { e1: { title: 'Refused' } } }); + await setEvent(s, { update: { e2: { title: 'Fine' } } }); + s.server.refuse.e1 = { type: 'forbidden', description: 'No rights' }; + const result = await flush(s); + expect(result).toMatchObject({ sent: 1, conflicts: 1, pending: 0 }); + const [conflict] = await conflicts(s); + expect(conflict).toMatchObject({ recordId: 'e1', reason: 'refused', errorType: 'forbidden', description: 'No rights', local: { title: 'Refused' } }); + expect(await getOne(s, 'e1')).toMatchObject({ title: 'Meeting' }); + expect(s.server.events.get('e2')?.title).toBe('Fine'); + }); + + it('lists an edit of an event the server deleted, and drops the event', async () => { + const s = await synced(); + await setEvent(s, { update: { e1: { title: 'Too late' } } }); + s.server.removeEvent('e1'); + await flush(s); + const [conflict] = await conflicts(s); + expect(conflict).toMatchObject({ reason: 'destroyedOnServer', local: { title: 'Too late' }, server: null }); + expect(await getOne(s, 'e1')).toBeUndefined(); + }); + + it('lists a delete of an event the server changed, and brings the event back', async () => { + const s = await synced(); + await setEvent(s, { destroy: ['e1'] }); + s.server.patchEvent('e1', { title: 'Still wanted' }); + await flush(s); + expect(s.server.events.has('e1')).toBe(true); + expect((await conflicts(s))[0]).toMatchObject({ kind: 'destroy', reason: 'changedOnServer' }); + expect(await getOne(s, 'e1')).toMatchObject({ title: 'Still wanted' }); + }); + + it('lists a create the server refuses and takes the stand-in away', async () => { + const s = await synced(); + const result = await setEvent(s, { create: { c: { title: 'Bad', calendarIds: { nope: true }, start: '2026-11-20T09:00:00' } } }); + s.server.refuse.c = { type: 'invalidProperties', description: 'calendarIds', properties: ['calendarIds'] }; + await flush(s); + const [conflict] = await conflicts(s); + expect(conflict).toMatchObject({ kind: 'create', reason: 'refused', local: { title: 'Bad' } }); + expect(await getOne(s, result.created.c.id)).toBeUndefined(); + expect(pendingRows(s)).toEqual([]); + }); + + it('retries without series keys when 1.0 refuses them inside an override, instead of a conflict', async () => { + const s = setup('1.0'); + s.server.addEvent({ id: 'e1', recurrenceRule: WEEKLY, privacy: 'private' }); + await s.engine.requestSync(); + s.server.calls.length = 0; + await setEvent(s, { update: { e1: { recurrenceOverrides: { '2026-11-17T10:00:00': { title: 'One', privacy: 'private' } } } } }); + await flush(s); + expect(await conflicts(s)).toEqual([]); + expect(setCalls(s.server)).toHaveLength(2); + expect(s.server.events.get('e1')?.recurrenceOverrides).toEqual({ '2026-11-17T10:00:00': { title: 'One' } }); + }); + + it('keeps a write for later when the server has a bad moment', async () => { + const s = await synced(); + await setEvent(s, { update: { e1: { title: 'Soon' } } }); + const realRequest = s.server.request.bind(s.server); + let failed = false; + s.server.request = async (calls: MethodCall[]) => { + if (!failed && calls.some(([name]) => name === 'CalendarEvent/set')) { + failed = true; + return { methodResponses: [['error', { type: 'serverFail' }, 's']] }; + } + return realRequest(calls); + }; + expect(await flush(s)).toMatchObject({ sent: 0, conflicts: 0, pending: 1 }); + expect(await conflicts(s)).toEqual([]); + expect(await flush(s)).toMatchObject({ sent: 1, pending: 0 }); + }); + + it('lists a method error that means the write will not work', async () => { + const s = await synced(); + await setEvent(s, { update: { e1: { title: 'x' } } }); + const realRequest = s.server.request.bind(s.server); + s.server.request = async (calls: MethodCall[]) => { + if (calls.some(([name]) => name === 'CalendarEvent/set')) return { methodResponses: [['error', { type: 'accountReadOnly' }, 's']] }; + return realRequest(calls); + }; + expect(await flush(s)).toMatchObject({ conflicts: 1, pending: 0 }); + expect((await conflicts(s))[0].errorType).toBe('accountReadOnly'); + }); + + it('can dismiss a conflict', async () => { + const s = await synced(); + await setEvent(s, { update: { e1: { title: 'Mine' } } }); + s.server.patchEvent('e1', { title: 'Theirs' }); + await flush(s); + const [conflict] = await conflicts(s); + await s.engine.areaCall('calendar.dismissConflict', { id: conflict.id }); + expect(await conflicts(s)).toEqual([]); + }); + + it('keeps a pending edit through a resync, and lists it when the event is gone', async () => { + const s = await synced(); + s.server.addEvent({ id: 'e2', title: 'Stays' }); + await s.engine.requestSync(); + await setEvent(s, { update: { e1: { title: 'Mine' } } }); + await setEvent(s, { update: { e2: { title: 'Mine too' } } }); + s.server.events.delete('e1'); + s.server.eventLog.push('destroyed', 'e1'); + s.server.eventLog.truncatedBelow = s.server.eventLog.log.length; + await s.engine.requestSync(); + const [conflict] = await conflicts(s); + expect(conflict).toMatchObject({ recordId: 'e1', reason: 'destroyedOnServer' }); + expect(s.server.events.get('e2')?.title).toBe('Mine too'); + }); +}); + +describe('the synthetic-id probe', () => { + const probe = { update: { h333333: {} } }; + + it('tells a client what the server said at the last sync', async () => { + for (const version of ['1.0', '0.16'] as const) { + const s = setup(version); + await s.engine.requestSync(); + expect((await setEvent(s, probe)).notUpdated.h333333.type).toBe('notFound'); + } + const old = setup('0.16.19'); + await old.engine.requestSync(); + const refusal = (await setEvent(old, probe)).notUpdated.h333333; + expect(refusal.type).toBe('invalidProperties'); + expect(refusal.description).toMatch(/synthetic ids is not yet supported/); + }); + + it('says not supported before any sync, because client-side expansion works on every server', async () => { + const s = setup(); + const refusal = (await setEvent(s, probe)).notUpdated.h333333; + expect(refusal.type).toBe('invalidProperties'); + }); + + it('queues nothing for the probe', async () => { + const s = setup(); + await s.engine.requestSync(); + await setEvent(s, probe); + expect(pendingRows(s)).toEqual([]); + }); + + it('runs the probe on the sync request, and follows a server that was upgraded', async () => { + const s = setup('0.16.19'); + await s.engine.requestSync(); + expect(s.server.requests).toBeGreaterThan(0); + expect((await setEvent(s, probe)).notUpdated.h333333.type).toBe('invalidProperties'); + Object.defineProperty(s.server, 'version', { value: '1.0' }); + await s.engine.requestSync(); + expect((await setEvent(s, probe)).notUpdated.h333333.type).toBe('notFound'); + }); +}); + +describe('occurrence writes for ids the store does not hold', () => { + it('refuses them the way an old server does, so the client writes on the base event', async () => { + const s = setup(); + s.server.addEvent({ id: 'series', recurrenceRule: WEEKLY }); + await s.engine.requestSync(); + const update = await setEvent(s, { update: { h20261110series: { title: 'x' } } }); + expect(update.notUpdated.h20261110series.description).toMatch(/synthetic ids?\b.*\bnot (?:yet )?supported/i); + const destroy = await setEvent(s, { destroy: ['h20261110series'] }); + expect(destroy.notDestroyed.h20261110series.description).toMatch(/synthetic ids?\b.*\bnot (?:yet )?supported/i); + expect(pendingRows(s)).toEqual([]); + }); +}); + +describe('reminders from stored events', () => { + const ALERT = { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } }; + const FROM = Date.UTC(2026, 10, 10); + const TO = Date.UTC(2026, 10, 11); + const OPTIONS = { from: FROM, to: TO, timeZone: 'Europe/Berlin' }; + + it('lists an occurrence of a series, keyed by the master, with the time from its own zone', async () => { + const s = setup(); + s.server.addEvent({ id: 'series', start: '2026-01-06T10:00:00', recurrenceRule: WEEKLY, alerts: { a: ALERT } }); + await s.engine.requestSync(); + const alerts = listUpcomingAlerts(s.db, OPTIONS); + expect(alerts).toHaveLength(1); + const fire = Date.UTC(2026, 10, 10, 8, 45); + expect(alerts[0]).toMatchObject({ + baseEventId: 'series', alertId: 'a', fireTimeMs: fire, recurrenceId: '2026-11-10T10:00:00', key: `series:a:${fire}`, kind: 'event', jmap: JMAP_ACCOUNT, + }); + }); + + it('follows a moved occurrence and skips an excluded one', async () => { + const s = setup(); + s.server.addEvent({ + id: 'moved', + start: '2026-01-06T10:00:00', + recurrenceRule: WEEKLY, + alerts: { a: ALERT }, + recurrenceOverrides: { '2026-11-10T10:00:00': { start: '2026-11-10T15:00:00' } }, + }); + s.server.addEvent({ + id: 'excluded', + start: '2026-01-06T10:00:00', + recurrenceRule: WEEKLY, + alerts: { a: ALERT }, + recurrenceOverrides: { '2026-11-10T10:00:00': { excluded: true } }, + }); + await s.engine.requestSync(); + const alerts = listUpcomingAlerts(s.db, OPTIONS); + expect(alerts.map((alert) => [alert.baseEventId, alert.fireTimeMs])).toEqual([['moved', Date.UTC(2026, 10, 10, 13, 45)]]); + }); + + it('finds an alert days before its occurrence, and a fixed-time alert of a series', async () => { + const s = setup(); + s.server.addEvent({ + id: 'days', + start: '2026-11-13T10:00:00', + alerts: { a: { ...ALERT, trigger: { '@type': 'OffsetTrigger', offset: '-P3D' } } }, + }); + s.server.addEvent({ + id: 'days-series', + start: '2026-01-06T10:00:00', + recurrenceRule: { frequency: 'yearly', byMonth: ['11'], byMonthDay: [13] }, + alerts: { a: { ...ALERT, trigger: { '@type': 'OffsetTrigger', offset: '-P3D' } } }, + }); + s.server.addEvent({ + id: 'fixed', + start: '2026-01-06T10:00:00', + recurrenceRule: WEEKLY, + alerts: { a: { ...ALERT, trigger: { '@type': 'AbsoluteTrigger', when: '2026-11-10T06:00:00Z' } } }, + }); + await s.engine.requestSync(); + const alerts = listUpcomingAlerts(s.db, OPTIONS); + expect(alerts.map((alert) => alert.baseEventId).sort()).toEqual(['days', 'days-series', 'fixed']); + expect(alerts.filter((alert) => alert.baseEventId === 'fixed')).toHaveLength(1); + }); + + it('uses the calendar defaults while the event does, and reads a floating event in the given zone', async () => { + const s = setup(); + s.server.addEvent({ id: 'def', start: '2026-11-10T10:00:00', timeZone: null, useDefaultAlerts: true }); + await s.engine.requestSync(); + const [alert] = listUpcomingAlerts(s.db, OPTIONS); + expect(alert).toMatchObject({ baseEventId: 'def', alertId: 'd1', calendarName: 'Personal', fireTimeMs: Date.UTC(2026, 10, 10, 8, 45) }); + expect(listUpcomingAlerts(s.db, { ...OPTIONS, timeZone: 'UTC' })[0].fireTimeMs).toBe(Date.UTC(2026, 10, 10, 9, 45)); + }); + + it('leaves out what should not fire, and what is outside the window', async () => { + const s = setup(); + s.server.addEvent({ id: 'email', alerts: { a: { ...ALERT, action: 'email' } } }); + s.server.addEvent({ id: 'seen', alerts: { a: { ...ALERT, acknowledged: '2026-11-01T00:00:00Z' } } }); + s.server.addEvent({ id: 'off', status: 'cancelled', alerts: { a: ALERT } }); + s.server.addEvent({ id: 'later', start: '2026-12-10T10:00:00', alerts: { a: ALERT } }); + s.server.addEvent({ id: 'task-done', '@type': 'Task', start: undefined, due: '2026-11-10T10:00:00', progress: 'completed', alerts: { a: ALERT } }); + await s.engine.requestSync(); + expect(listUpcomingAlerts(s.db, OPTIONS)).toEqual([]); + }); + + it('lists a task, and an edit or a delete made offline', async () => { + const s = setup(); + s.server.addEvent({ id: 'task', '@type': 'Task', start: undefined, due: '2026-11-10T10:00:00Z', alerts: { a: ALERT } }); + s.server.addEvent({ id: 'moved', start: '2026-12-10T10:00:00', alerts: { a: ALERT } }); + s.server.addEvent({ id: 'dropped', start: '2026-11-10T11:00:00', alerts: { a: ALERT } }); + await s.engine.requestSync(); + await setEvent(s, { update: { moved: { start: '2026-11-10T09:00:00' } } }); + await setEvent(s, { destroy: ['dropped'] }); + expect(listUpcomingAlerts(s.db, OPTIONS).map((alert) => [alert.baseEventId, alert.kind])).toEqual([['moved', 'event'], ['task', 'task']]); + }); +}); + +it('uses only capabilities the session lists', async () => { + const s = setup(); + s.server.addEvent(); + const usings: string[][] = []; + const realRequest = s.server.request.bind(s.server) as (calls: MethodCall[]) => ReturnType; + s.server.request = ((calls: MethodCall[], using: string[]) => { + usings.push(using); + return realRequest(calls); + }) as FakeCalendarJmap['request']; + await s.engine.requestSync(); + for (const using of usings) expect(using).toEqual(['urn:ietf:params:jmap:core', CAL]); +}); diff --git a/lib/store/__tests__/helpers/client-wire.ts b/lib/store/__tests__/helpers/client-wire.ts new file mode 100644 index 000000000..d3c9860b7 --- /dev/null +++ b/lib/store/__tests__/helpers/client-wire.ts @@ -0,0 +1,79 @@ +/** + * A real JMAPClient talking to a FakeCalendarJmap while "online" and to the + * store's answers while "offline", through a stubbed fetch. + */ + +import { vi } from 'vitest'; +import { JMAPClient } from '@/lib/jmap/client'; +import { DEFAULT_SCOPE, type StoreArea } from '../../area'; +import { AccountEngine } from '../../engine/account-engine'; +import type { MethodCall } from '../../engine/transport'; +import { CALENDAR_SESSION, FakeCalendarJmap } from './fake-calendar'; +import { JMAP_ACCOUNT } from './fake-jmap'; +import { openMemoryStore } from './node-adapter'; + +const CLIENT_SESSION = { + ...CALENDAR_SESSION, + primaryAccounts: { ...CALENDAR_SESSION.primaryAccounts, 'urn:ietf:params:jmap:mail': JMAP_ACCOUNT }, + accounts: { + [JMAP_ACCOUNT]: { + name: 'a@example.org', + isPersonal: true, + accountCapabilities: { ...CALENDAR_SESSION.accounts[JMAP_ACCOUNT].accountCapabilities, 'urn:ietf:params:jmap:mail': {} }, + }, + }, + capabilities: { ...CALENDAR_SESSION.capabilities, 'urn:ietf:params:jmap:mail': {} }, + eventSourceUrl: '', +}; + +function json(body: unknown): Response { + return new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } }); +} + +export interface Wire { + server: FakeCalendarJmap; + engine: AccountEngine; + db: ReturnType; + /** Everything the client sent, wherever it went. */ + sent: MethodCall[]; + online: boolean; + newClient(): Promise; + restore(): void; +} + +export function wire(server: FakeCalendarJmap, areas: readonly StoreArea[], now: number): Wire { + const db = openMemoryStore(); + const engine = new AccountEngine({ + account: 'a@fake.test', + db, + session: CALENDAR_SESSION, + transport: server, + areas, + scope: DEFAULT_SCOPE, + now: () => now, + sleep: async () => {}, + }); + const spy = vi.spyOn(globalThis, 'fetch'); + const state: Wire = { + server, + engine, + db, + sent: [], + online: true, + newClient: async () => { + const client = new JMAPClient('https://fake.test', 'a@example.org', 'pw'); + await client.connect(); + return client; + }, + restore: () => spy.mockRestore(), + }; + spy.mockImplementation(async (_url: RequestInfo | URL, init?: RequestInit) => { + if (!init?.body) return json(CLIENT_SESSION); + const calls = (JSON.parse(String(init.body)) as { methodCalls: MethodCall[] }).methodCalls; + state.sent.push(...calls); + const answer = state.online ? await server.request(calls) : await engine.answer(calls); + if (!answer) throw new TypeError('Failed to fetch'); + return json(answer); + }); + return state; +} diff --git a/lib/store/__tests__/helpers/fake-calendar.ts b/lib/store/__tests__/helpers/fake-calendar.ts new file mode 100644 index 000000000..752017d57 --- /dev/null +++ b/lib/store/__tests__/helpers/fake-calendar.ts @@ -0,0 +1,365 @@ +/** + * FakeJmap with calendars, modelled on what was measured on both Stalwarts + * (local-data/desktop-offline-facts-parts/stalwart-changes-prf.md section 7, and + * the probe run in desktop-offline-decisions.md): + * + * - /changes lists masters only. An edit through a synthetic id gives + * `updated: [master]`. + * - A plain query returns masters. An expanded one needs both bounds, says + * canCalculateChanges false on 1.0 and true on 0.16, refuses more than P52W1D + * on 1.0, and gives a single event a synthetic id on 0.16 only. + * - 1.0 refuses recurrenceOverrides next to utcStart in one get, and series keys + * inside an override. `recurrenceRules` is an invalid property on both. + * - A pointer into overrides fails while a series has none. + * - A second event with one uid is refused, uid filters match exactly. + */ + +import { normalizeCalendarEventLike } from '@/lib/calendar-event-normalization'; +import { applyPatchObject } from '@/lib/jmap/patch-object'; +import type { CalendarEvent } from '@/lib/jmap/types'; +import { expandRecurringEvents } from '@/lib/recurrence-expansion'; +import { SYNTHETIC_ID_PROBE } from '@/lib/recurrence-instances'; +import { TransportError, type JmapResponse, type MethodCall, type MethodResponse, type StoreSession } from '../../engine/transport'; +import { Collection, FakeJmap, JMAP_ACCOUNT } from './fake-jmap'; + +export const CAL = 'urn:ietf:params:jmap:calendars'; + +export const CALENDAR_SESSION: StoreSession = { + apiUrl: 'https://fake.test/jmap/', + downloadUrl: 'https://fake.test/d/{accountId}/{blobId}/{name}?type={type}', + uploadUrl: 'https://fake.test/u/{accountId}', + primaryAccounts: { [CAL]: JMAP_ACCOUNT }, + accounts: { + [JMAP_ACCOUNT]: { name: 'a@example.org', isPersonal: true, accountCapabilities: { [CAL]: { maxExpandedQueryDuration: 'P52W1D' } } }, + }, + capabilities: { 'urn:ietf:params:jmap:core': {}, [CAL]: {} }, +}; + +export type FakeEvent = Record & { id: string }; +export type StalwartVersion = '1.0' | '0.16' | '0.16.19'; + +function jmapError(type: string, description?: string): Error { + return Object.assign(new Error(type), { jmapError: type, description }); +} + +const SERIES_KEYS_IN_OVERRIDE = ['privacy', 'organizerCalendarAddress']; + +interface Instance { + id: string; + master: FakeEvent; + recurrenceId: string | null; +} + +export class FakeCalendarJmap extends FakeJmap { + readonly events = new Map(); + readonly calendars = new Map & { id: string }>(); + readonly identities: Array> = [{ id: 'pi1', name: 'A', calendarAddress: 'mailto:a@example.org', isDefault: true }]; + readonly eventLog = new Collection(); + private readonly instances = new Map(); + private nextEvent = 1; + /** The next create or update reaches the server, but its answer doesn't reach the client. */ + loseNextSetResponse = false; + /** The next CalendarEvent/set refuses this id like a server would. */ + refuse: Record = {}; + + constructor(readonly version: StalwartVersion = '1.0') { + super(); + this.calendars.set('cal1', { + id: 'cal1', name: 'Personal', isDefault: true, myRights: { mayWriteAll: true }, + defaultAlertsWithTime: { d1: { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } } }, + }); + } + + addEvent(partial: Partial = {}): FakeEvent { + const id = partial.id ?? `ev${this.nextEvent++}`; + const event: FakeEvent = { + '@type': 'Event', + uid: `${id}@example.org`, + calendarIds: { cal1: true }, + title: `Event ${id}`, + start: '2026-11-10T10:00:00', + duration: 'PT1H', + timeZone: 'Europe/Berlin', + baseEventId: null, + isOrigin: true, + useDefaultAlerts: false, + ...partial, + id, + }; + this.events.set(id, event); + this.eventLog.push('created', id); + return event; + } + + /** Someone else changes an event. */ + patchEvent(id: string, patch: Record): void { + this.events.set(id, applyPatchObject(this.events.get(id) as FakeEvent, patch)); + this.eventLog.push('updated', id); + } + + removeEvent(id: string): void { + this.events.delete(id); + this.eventLog.push('destroyed', id); + } + + private expanded(after: string, before: string): Instance[] { + const out: Instance[] = []; + for (const master of this.events.values()) { + const normal = normalizeCalendarEventLike(master as unknown as CalendarEvent); + if (!normal.recurrenceRules?.length) { + const start = String(master.start); + if (start < after || start >= before) continue; + out.push({ id: this.version === '1.0' ? master.id : `e${master.id}`, master, recurrenceId: null }); + continue; + } + for (const occurrence of expandRecurringEvents([normal], after, before)) { + out.push({ + id: `h${String(occurrence.recurrenceId).replace(/\D/g, '')}${master.id}`, + master, + recurrenceId: occurrence.recurrenceId as string, + }); + } + } + for (const instance of out) this.instances.set(instance.id, instance); + return out; + } + + private instanceView(instance: Instance): FakeEvent { + const { master, recurrenceId } = instance; + if (!recurrenceId) return { ...master, id: instance.id, baseEventId: this.version === '1.0' ? null : master.id }; + const override = ((master.recurrenceOverrides ?? {}) as Record>)[recurrenceId] ?? {}; + const { recurrenceRule: _r, recurrenceOverrides: _o, ...rest } = master; + return { + ...rest, + start: recurrenceId, + ...override, + id: instance.id, + baseEventId: master.id, + recurrenceId, + recurrenceRule: null, + }; + } + + private query(args: Record): Record { + const filter = (args.filter ?? {}) as { uid?: string; after?: string; before?: string; inCalendar?: string; operator?: string }; + const position = Number(args.position ?? 0); + const limit = Number(args.limit ?? 5000) || 5000; + const page = (ids: string[], canCalculateChanges: boolean) => ({ + accountId: args.accountId, + queryState: this.eventLog.state(), + canCalculateChanges, + position, + ids: ids.slice(position, position + limit), + }); + if (args.expandRecurrences) { + if (!filter.after || !filter.before) throw jmapError('invalidArguments', 'Expansion needs after and before'); + const days = (Date.parse(`${filter.before}Z`) - Date.parse(`${filter.after}Z`)) / 86_400_000; + if (this.version === '1.0' && days > 365) throw jmapError('expandDurationTooLarge'); + const list = this.expanded(filter.after, filter.before); + return page(list.map((instance) => instance.id), this.version !== '1.0'); + } + const masters = [...this.events.values()].filter((event) => { + if (filter.uid !== undefined && event.uid !== filter.uid) return false; + if (filter.inCalendar && !(event.calendarIds as Record)[filter.inCalendar]) return false; + return true; + }); + return page(masters.map((event) => event.id), true); + } + + private get(args: Record): Record { + const properties = args.properties as string[] | undefined; + if (properties?.includes('recurrenceRules')) throw jmapError('invalidArguments', 'recurrenceRules'); + if (this.version === '1.0' && properties?.includes('recurrenceOverrides') + && (properties.includes('utcStart') || properties.includes('utcEnd'))) { + throw jmapError('invalidArguments', 'recurrenceOverrides cannot be requested together with utcStart or utcEnd'); + } + const ids = (args.ids as string[] | null) ?? [...this.events.keys()].slice(0, 500); + if (ids.length > 500) throw jmapError('requestTooLarge'); + const list: Record[] = []; + const notFound: string[] = []; + for (const id of ids) { + const instance = this.instances.get(id); + const event = instance && this.events.has(instance.master.id) + ? this.instanceView({ ...instance, master: this.events.get(instance.master.id) as FakeEvent }) + : this.events.get(id); + if (!event) { + notFound.push(id); + continue; + } + const full = { ...event, utcStart: `${String(event.start)}Z`, utcEnd: `${String(event.start)}Z` }; + if (!properties) { + list.push({ ...event }); + continue; + } + const out: Record = { id }; + for (const name of properties) if (name in full) out[name] = (full as Record)[name]; + list.push(out); + } + return { accountId: args.accountId, state: this.eventLog.state(), list, notFound }; + } + + private seriesKeyInOverride(patch: Record): string | null { + if (this.version !== '1.0') return null; + for (const [key, value] of Object.entries(patch)) { + if (key !== 'recurrenceOverrides' && !key.startsWith('recurrenceOverrides/')) continue; + const entries = key === 'recurrenceOverrides' ? Object.values(value as object) : [value]; + for (const entry of entries) { + for (const name of SERIES_KEYS_IN_OVERRIDE) { + if (entry && typeof entry === 'object' && name in entry) return name; + } + } + } + return null; + } + + private update(id: string, patch: Record): { type: string; description?: string; properties?: string[] } | null { + if (this.refuse[id]) { + const refusal = this.refuse[id]; + delete this.refuse[id]; + return refusal; + } + if (id === SYNTHETIC_ID_PROBE) { + return this.version === '0.16.19' + ? { type: 'invalidProperties', description: 'Updating synthetic ids is not yet supported.', properties: ['id'] } + : { type: 'notFound' }; + } + const instance = this.instances.get(id); + if (instance?.recurrenceId) { + if (this.version === '0.16.19') { + return { type: 'invalidProperties', description: 'Updating synthetic ids is not yet supported.', properties: ['id'] }; + } + const master = this.events.get(instance.master.id); + if (!master) return { type: 'notFound' }; + const overrides = { ...(master.recurrenceOverrides as object | undefined) } as Record>; + overrides[instance.recurrenceId] = { ...overrides[instance.recurrenceId], ...patch }; + this.patchEvent(master.id, { recurrenceOverrides: overrides }); + return null; + } + const event = this.events.get(id); + if (!event) return { type: 'notFound' }; + const seriesKey = this.seriesKeyInOverride(patch); + if (seriesKey) { + return { type: 'invalidProperties', description: `${seriesKey} cannot be set inside recurrenceOverrides.`, properties: ['recurrenceOverrides'] }; + } + const pointsIntoNothing = Object.keys(patch).some((key) => key.startsWith('recurrenceOverrides/')) && !event.recurrenceOverrides; + if (pointsIntoNothing) return { type: this.version === '1.0' ? 'invalidPatch' : 'invalidProperties', description: 'Patch operation failed.' }; + this.patchEvent(id, patch); + return null; + } + + private set(args: Record): Record { + const out: Record = { accountId: args.accountId, oldState: this.eventLog.state() }; + const created: Record = {}; + const notCreated: Record = {}; + for (const [key, event] of Object.entries((args.create ?? {}) as Record>)) { + const seriesKey = this.seriesKeyInOverride(event); + if (seriesKey) { + notCreated[key] = { type: 'invalidProperties', description: `${seriesKey} cannot be set inside recurrenceOverrides.`, properties: ['recurrenceOverrides'] }; + } else if (typeof event.uid === 'string' && [...this.events.values()].some((other) => other.uid === event.uid)) { + notCreated[key] = { type: 'invalidProperties', description: `An event with UID ${event.uid} already exists.`, properties: ['uid'] }; + } else if (this.refuse[key]) { + notCreated[key] = this.refuse[key]; + delete this.refuse[key]; + } else { + created[key] = { id: this.addEvent(event).id }; + } + } + const updated: Record = {}; + const notUpdated: Record = {}; + for (const [id, patch] of Object.entries((args.update ?? {}) as Record>)) { + const refusal = this.update(id, patch); + if (refusal) notUpdated[id] = refusal; + else updated[id] = null; + } + const destroyed: string[] = []; + const notDestroyed: Record = {}; + for (const id of (args.destroy ?? []) as string[]) { + const instance = this.instances.get(id); + if (instance?.recurrenceId && this.events.has(instance.master.id)) { + const master = this.events.get(instance.master.id) as FakeEvent; + this.patchEvent(master.id, { + recurrenceOverrides: { ...(master.recurrenceOverrides as object | undefined), [instance.recurrenceId]: { excluded: true } }, + }); + destroyed.push(id); + } else if (this.refuse[id]) { + notDestroyed[id] = this.refuse[id]; + delete this.refuse[id]; + } else if (this.events.has(id)) { + this.removeEvent(id); + destroyed.push(id); + } else { + notDestroyed[id] = { type: 'notFound' }; + } + } + if (Object.keys(created).length > 0) out.created = created; + if (Object.keys(notCreated).length > 0) out.notCreated = notCreated; + if (Object.keys(updated).length > 0) out.updated = updated; + if (Object.keys(notUpdated).length > 0) out.notUpdated = notUpdated; + if (destroyed.length > 0) out.destroyed = destroyed; + if (Object.keys(notDestroyed).length > 0) out.notDestroyed = notDestroyed; + out.newState = this.eventLog.state(); + return out; + } + + private answerCalendarCall(name: string, args: Record): Record { + switch (name) { + case 'Calendar/get': + return { accountId: args.accountId, state: 'cs1', list: [...this.calendars.values()], notFound: [] }; + case 'ParticipantIdentity/get': + return { accountId: args.accountId, list: this.identities, notFound: [] }; + case 'CalendarEvent/query': + return this.query(args); + case 'CalendarEvent/get': + return this.get(args); + case 'CalendarEvent/set': + return this.set(args); + case 'CalendarEvent/changes': { + const since = String(args.sinceState); + if (!/^s\d+$/.test(since)) throw jmapError('invalidArguments'); + const from = Number(since.slice(1)); + if (from < this.eventLog.truncatedBelow || from > this.eventLog.log.length) throw jmapError('cannotCalculateChanges'); + const maxChanges = Math.min(Number(args.maxChanges ?? 5000) || 5000, 5000); + return { accountId: args.accountId, oldState: since, ...this.eventLog.changes(since, maxChanges) }; + } + default: + throw jmapError('unknownMethod'); + } + } + + override async request(calls: MethodCall[]): Promise { + if (!calls.every(([name]) => /^(Calendar|CalendarEvent|ParticipantIdentity)\//.test(name))) return super.request(calls); + this.requests += 1; + this.beforeRequest?.(this.requests); + if (this.offline) throw new TransportError('network', 'Failed to fetch'); + if (calls.length > 16) throw new TransportError('http', 'too many calls', 400, 'urn:ietf:params:jmap:error:limit'); + const done: MethodResponse[] = []; + let lose = false; + for (const call of calls) { + this.calls.push(call); + const [name, raw, id] = call; + const args: Record = {}; + for (const [key, value] of Object.entries(raw)) { + if (key.startsWith('#')) { + const ref = value as { resultOf: string; path: string }; + const source = done.find(([, , callId]) => callId === ref.resultOf); + args[key.slice(1)] = source?.[1][ref.path.slice(1)]; + } else { + args[key] = value; + } + } + try { + done.push([name, this.answerCalendarCall(name, args), id]); + if (name === 'CalendarEvent/set' && (raw.create || raw.update) && this.loseNextSetResponse) lose = true; + } catch (error) { + const type = (error as { jmapError?: string }).jmapError; + if (!type) throw error; + done.push(['error', { type, description: (error as { description?: string }).description }, id]); + } + } + if (lose) { + this.loseNextSetResponse = false; + throw new TransportError('network', 'The connection dropped before the answer came'); + } + return { methodResponses: done }; + } +} diff --git a/lib/store/__tests__/helpers/fake-jmap.ts b/lib/store/__tests__/helpers/fake-jmap.ts index 9aa917be5..bac572400 100644 --- a/lib/store/__tests__/helpers/fake-jmap.ts +++ b/lib/store/__tests__/helpers/fake-jmap.ts @@ -25,7 +25,7 @@ type ChangeKind = 'created' | 'updated' | 'destroyed'; interface Log { kind: ChangeKind; id: string } -class Collection { +export class Collection { log: Log[] = []; /** Below this log position, states are refused as truncated. */ truncatedBelow = 0; diff --git a/lib/store/area.ts b/lib/store/area.ts index fceed110e..b73740b5b 100644 --- a/lib/store/area.ts +++ b/lib/store/area.ts @@ -96,9 +96,10 @@ export type MethodHandler = (ctx: MethodContext, args: Record, /** * A call from the page that isn't JMAP: outbox rows, usage, clearing. Reached * from any tab with `store.area(account, 'outbox.list', params)`. Names are - * unique across areas and start with the area's name. + * unique across areas and start with the area's name. It gets the sync context, + * because some calls talk to the server (`calendar.flush`). */ -export type AreaCall = (ctx: StoreContext, params: never) => unknown | Promise; +export type AreaCall = (ctx: SyncContext, params: never) => unknown | Promise; export interface StoreArea { name: string; diff --git a/lib/store/areas.ts b/lib/store/areas.ts index 6bff665ca..30a6541ff 100644 --- a/lib/store/areas.ts +++ b/lib/store/areas.ts @@ -1,6 +1,7 @@ /** Every part of the local store. New areas go here. */ import type { StoreArea } from './area'; +import { calendarArea } from './calendar'; import { mailArea } from './mail'; -export const STORE_AREAS: readonly StoreArea[] = [mailArea]; +export const STORE_AREAS: readonly StoreArea[] = [mailArea, calendarArea]; diff --git a/lib/store/calendar/filter.ts b/lib/store/calendar/filter.ts new file mode 100644 index 000000000..41a29ffd3 --- /dev/null +++ b/lib/store/calendar/filter.ts @@ -0,0 +1,220 @@ +/** + * CalendarEvent/query filters, read the way Stalwart reads them: after and + * before are LocalDateTimes in the request's time zone, and a series matches + * when any occurrence is in the range. Text matches parts of words, where the + * server only matches whole ones: offline search finds more, not less. + */ + +import { addDays, format, parseISO } from 'date-fns'; +import { isTaskLikeObject } from '@/lib/calendar-component-detection'; +import { normalizeCalendarEventLike, parseDurationSeconds } from '@/lib/calendar-event-normalization'; +import type { CalendarEvent, CalendarEventFilter } from '@/lib/jmap/types'; +import { expandRecurringEvents } from '@/lib/recurrence-expansion'; +import { MethodError } from '../area'; +import { instantOf, instantsOf, isSeries, wallClock, type CalendarObject } from './records'; + +export interface RangeBounds { + after?: string; + before?: string; +} + +/** The range of a filter, in the two shapes the client sends it: plain keys, or an AND of one-key conditions. */ +export function boundsOf(filter: CalendarEventFilter): RangeBounds { + if (filter.operator === 'AND') { + const bounds: RangeBounds = {}; + for (const condition of filter.conditions ?? []) { + if (condition.operator || Object.keys(condition).length !== 1) continue; + if (condition.after) bounds.after = condition.after; + if (condition.before) bounds.before = condition.before; + } + return bounds; + } + if (filter.operator) return {}; + return { after: filter.after, before: filter.before }; +} + +function fold(text: string): string { + return text.normalize('NFKD').replace(/\p{M}/gu, '').toLowerCase(); +} + +function containsWords(haystack: Array, needle: string): boolean { + const text = fold(haystack.filter((part): part is string => typeof part === 'string').join('\n')); + return fold(needle).split(/\s+/).filter(Boolean).every((word) => text.includes(word)); +} + +type Participant = { + name?: string; + email?: string; + calendarAddress?: string; + participationStatus?: string; + roles?: Record; +}; + +function participantsOf(object: CalendarObject): Participant[] { + return Object.values((object.participants ?? {}) as Record); +} + +function placeTexts(object: CalendarObject): unknown[] { + const places = Object.values((object.locations ?? {}) as Record); + const virtual = Object.values((object.virtualLocations ?? {}) as Record); + return [...places, ...virtual].flatMap((place) => [place.name, place.description]); +} + +function personTexts(participant: Participant): unknown[] { + return [participant.name, participant.email, participant.calendarAddress]; +} + +function isOwner(participant: Participant): boolean { + return participant.roles?.owner === true; +} + +function isAttendee(participant: Participant): boolean { + return !participant.roles || participant.roles.attendee === true; +} + +function ownerTexts(object: CalendarObject): unknown[] { + return [object.organizerCalendarAddress, ...participantsOf(object).filter(isOwner).flatMap(personTexts)]; +} + +// An occurrence's wall clock is in the series' zone and the bounds in the +// request's: up to 26 hours apart. The client narrows the rest when it expands. +const SERIES_SLACK_DAYS = 2; +const OPEN_RANGE_DAYS = 5 * 366; + +function seriesOccursIn(object: CalendarObject, after: string | undefined, before: string | undefined): boolean { + const seriesStart = typeof object.start === 'string' ? parseISO(object.start) : null; + if (!seriesStart || Number.isNaN(seriesStart.getTime())) return false; + const lengthMs = (parseDurationSeconds(typeof object.duration === 'string' ? object.duration : undefined) ?? 0) * 1000; + const from = after + ? new Date(addDays(parseISO(wallClock(after)), -SERIES_SLACK_DAYS).getTime() - lengthMs) + : seriesStart; + const to = before ? addDays(parseISO(wallClock(before)), SERIES_SLACK_DAYS) : addDays(from, OPEN_RANGE_DAYS); + if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime()) || from >= to) return false; + const stamp = (date: Date) => format(date, "yyyy-MM-dd'T'HH:mm:ss"); + const event = normalizeCalendarEventLike(object as unknown as CalendarEvent); + return expandRecurringEvents([event], stamp(from), stamp(to)).length > 0; +} + +function inRange(object: CalendarObject, bounds: RangeBounds, zone: string): boolean { + if (isSeries(object)) return seriesOccursIn(object, bounds.after, bounds.before); + const { start, end } = instantsOf(object, zone); + if (start === null || end === null) return false; + const after = bounds.after ? instantOf(bounds.after, zone) : null; + const before = bounds.before ? instantOf(bounds.before, zone) : null; + if (before !== null && start >= before) return false; + if (after === null) return true; + return end > after || (end === start && start >= after); +} + +function matchesCondition(object: CalendarObject, condition: CalendarEventFilter, zone: string): boolean { + const bounds: RangeBounds = {}; + for (const [key, value] of Object.entries(condition)) { + if (value === undefined) continue; + switch (key) { + case 'after': + case 'before': + bounds[key as 'after' | 'before'] = value as string; + break; + case 'inCalendar': + if (!(object.calendarIds as Record | undefined)?.[value as string]) return false; + break; + case 'inCalendars': + if (!(value as string[]).some((id) => (object.calendarIds as Record | undefined)?.[id])) return false; + break; + case 'uid': + if (object.uid !== value) return false; + break; + case 'title': + if (!containsWords([object.title], value as string)) return false; + break; + case 'description': + if (!containsWords([object.description], value as string)) return false; + break; + case 'location': + if (!containsWords(placeTexts(object), value as string)) return false; + break; + case 'owner': + if (!containsWords(ownerTexts(object), value as string)) return false; + break; + case 'attendee': { + // With participationStatus in the same condition: the same person. + const status = condition.participationStatus; + const found = participantsOf(object).some((person) => isAttendee(person) + && containsWords(personTexts(person), value as string) + && (!status || person.participationStatus === status)); + if (!found) return false; + break; + } + case 'participationStatus': + if (!participantsOf(object).some((person) => person.participationStatus === value)) return false; + break; + case 'text': + if (!containsWords([ + object.title, + object.description, + ...placeTexts(object), + ...ownerTexts(object), + ...participantsOf(object).flatMap(personTexts), + ], value as string)) return false; + break; + case 'types': { + const type = isTaskLikeObject(object) ? 'task' : 'event'; + if (!(value as string[]).some((name) => name.toLowerCase() === type)) return false; + break; + } + default: + throw new MethodError('unsupportedFilter', `${key} isn't available offline`); + } + } + return bounds.after || bounds.before ? inRange(object, bounds, zone) : true; +} + +export function matchesFilter(object: CalendarObject, filter: CalendarEventFilter, zone: string): boolean { + const conditions = filter.conditions ?? []; + switch (filter.operator) { + case 'AND': { + // A range split over two conditions is one range: a series must have one + // occurrence inside it, not one after the start and another before the end. + const bounds = boundsOf(filter); + const isBound = (c: CalendarEventFilter) => !c.operator && Object.keys(c).length === 1 && Boolean(c.after || c.before); + if ((bounds.after || bounds.before) && !inRange(object, bounds, zone)) return false; + return conditions.every((c) => isBound(c) || matchesFilter(object, c, zone)); + } + case 'OR': + return conditions.some((c) => matchesFilter(object, c, zone)); + case 'NOT': + return !conditions.some((c) => matchesFilter(object, c, zone)); + case undefined: + return matchesCondition(object, filter, zone); + default: + throw new MethodError('unsupportedFilter', `Unknown operator ${String(filter.operator)}`); + } +} + +export interface EventComparator { + property?: string; + isAscending?: boolean; +} + +function sortValue(object: CalendarObject, property: string | undefined, zone: string): number | string { + if (property === 'start') return instantsOf(object, zone).start ?? 0; + const value = property ? object[property] : undefined; + return typeof value === 'number' || typeof value === 'string' ? value : ''; +} + +/** By start when the request names no order. Ties go by id, so paging is stable. */ +export function sortEvents(objects: CalendarObject[], sort: readonly EventComparator[], zone: string): CalendarObject[] { + const levels = sort.length > 0 ? sort : [{ property: 'start', isAscending: true }]; + const keyed = objects.map((object) => ({ object, keys: levels.map((level) => sortValue(object, level.property, zone)) })); + keyed.sort((a, b) => { + for (let i = 0; i < levels.length; i += 1) { + const x = a.keys[i]; + const y = b.keys[i]; + if (x === y) continue; + const order = x < y ? -1 : 1; + return levels[i].isAscending === false ? -order : order; + } + return a.object.id < b.object.id ? -1 : a.object.id > b.object.id ? 1 : 0; + }); + return keyed.map((entry) => entry.object); +} diff --git a/lib/store/calendar/flush.ts b/lib/store/calendar/flush.ts new file mode 100644 index 000000000..b4266a473 --- /dev/null +++ b/lib/store/calendar/flush.ts @@ -0,0 +1,229 @@ +/** + * Sends pending calendar writes, one event per request, so a lost response + * leaves one event's outcome open and not a batch's. + * + * Only a create can go wrong by being repeated. It's marked `attempted` in its + * own transaction before it goes out, and carries a uid the device chose. A + * create with that mark first looks for its uid on the server. An edit sent + * twice writes the same values twice, and a delete sent twice finds nothing the + * second time. + */ + +import { jmapError } from '@/lib/jmap/jmap-error'; +import { isOverrideSeriesKeyRejection, stableJson, withoutOverrideSeriesKeys } from '@/lib/recurrence-instances'; +import type { CalendarEvent } from '@/lib/jmap/types'; +import { applyPatchObject } from '@/lib/jmap/patch-object'; +import type { SyncContext } from '../area'; +import { JmapMethodError, responseFor } from '../engine/transport'; +import { calendarUsing, currentGen, deleteEventRow, EVENT_PROPERTIES, eventRow, valueAtPointer, writeEvent, type CalendarObject } from './records'; +import { recordConflict, type PendingRow } from './writes'; + +export interface FlushTally { + sent: number; + conflicts: number; +} + +interface SetError { + type?: string; + description?: string; + properties?: string[]; +} + +interface SetResult { + created?: Record | null; + updated?: Record | null; + destroyed?: string[] | null; + notCreated?: Record | null; + notUpdated?: Record | null; + notDestroyed?: Record | null; +} + +// The server had a bad moment, nothing is wrong with this write: try again later. +const PASSING_ERRORS = new Set(['serverFail', 'serverPartialFail', 'serverUnavailable', 'rateLimit', 'tooManyRequests']); + +export function isPassingError(error: unknown): boolean { + return error instanceof JmapMethodError && PASSING_ERRORS.has(error.type); +} + +type Outcome = 'sent' | 'conflict' | 'again'; + +async function sendSet(ctx: SyncContext, row: PendingRow, args: Record): Promise { + const response = await ctx.transport.request([['CalendarEvent/set', { + accountId: row.jmap, + ...(row.send_scheduling === null ? {} : { sendSchedulingMessages: row.send_scheduling === 1 }), + ...args, + }, 's']], calendarUsing(ctx.session)); + return responseFor(response, 's')[1] as SetResult; +} + +/** Stalwart 1.0 refuses what 0.16 expects inside an override. The client normally learns that from the refusal. */ +function isSeriesKeyRefusal(error: SetError | undefined): boolean { + return Boolean(error) && isOverrideSeriesKeyRejection(jmapError(error?.description ?? '', error)); +} + +async function findByUid(ctx: SyncContext, jmap: string, uid: string | null): Promise { + if (!uid) return null; + const response = await ctx.transport.request([['CalendarEvent/query', { accountId: jmap, filter: { uid }, limit: 1 }, 'u']], calendarUsing(ctx.session)); + const ids = responseFor(response, 'u')[1].ids as string[]; + return ids[0] ?? null; +} + +async function fetchEvent(ctx: SyncContext, jmap: string, id: string): Promise { + const response = await ctx.transport.request([['CalendarEvent/get', { accountId: jmap, ids: [id], properties: EVENT_PROPERTIES }, 'g']], calendarUsing(ctx.session)); + return (responseFor(response, 'g')[1].list as CalendarObject[])[0] ?? null; +} + +/** The stand-in becomes the real event. What came in while the create was on its way goes on as a write of its own. */ +function finishCreate(ctx: SyncContext, row: PendingRow, sent: Record, realId: string, event: CalendarObject): Outcome { + const { db } = ctx; + return db.transaction(() => { + const current = db.get('SELECT * FROM calendar_pending WHERE seq = ?', [row.seq]); + const gen = currentGen(db, row.jmap); + deleteEventRow(db, row.jmap, row.record_id); + db.run( + 'INSERT OR REPLACE INTO calendar_id_map (jmap, local_id, server_id) VALUES (?, ?, ?)', + [row.jmap, row.record_id, realId], + ); + ctx.emit({ type: 'changed', account: ctx.account, collections: ['CalendarEvent'] }); + if (current?.kind === 'destroy') { + writeEvent(db, row.jmap, { shown: event, server: event, hidden: true, gen }); + db.run('UPDATE calendar_pending SET record_id = ?, uid = NULL, attempted = 0 WHERE seq = ?', [realId, row.seq]); + return 'again'; + } + if (current && current.rev !== row.rev) { + const now = JSON.parse(current.patch) as Record; + const patch: Record = {}; + for (const key of new Set([...Object.keys(sent), ...Object.keys(now)])) { + if (stableJson(sent[key]) !== stableJson(now[key])) patch[key] = now[key] ?? null; + } + const base = Object.fromEntries(Object.keys(patch).map((key) => [key, valueAtPointer(event, key) ?? null])); + db.run( + `UPDATE calendar_pending SET kind = 'update', record_id = ?, patch = ?, base = ?, uid = NULL, attempted = 0 WHERE seq = ?`, + [realId, JSON.stringify(patch), JSON.stringify(base), row.seq], + ); + writeEvent(db, row.jmap, { shown: applyPatchObject(event, patch), server: event, gen }); + return 'again'; + } + db.run('DELETE FROM calendar_pending WHERE seq = ?', [row.seq]); + writeEvent(db, row.jmap, { shown: event, gen }); + return 'sent'; + }); +} + +async function sendCreate(ctx: SyncContext, row: PendingRow): Promise { + const payload = JSON.parse(row.patch) as Record; + let realId = row.attempted ? await findByUid(ctx, row.jmap, row.uid) : null; + if (!realId) { + ctx.db.transaction(() => ctx.db.run('UPDATE calendar_pending SET attempted = 1 WHERE seq = ?', [row.seq])); + let result = await sendSet(ctx, row, { create: { c: payload } }); + if (isSeriesKeyRefusal(result.notCreated?.c)) { + const without = withoutOverrideSeriesKeys(payload as Partial); + result = await sendSet(ctx, row, { create: { c: without } }); + } + realId = result.created?.c?.id ?? null; + const refusal = result.notCreated?.c; + // Both Stalwarts refuse a second event with one uid, which also covers a + // create that got through when the device didn't know it had tried. + if (!realId && refusal?.type === 'invalidProperties' && refusal.properties?.includes('uid')) { + realId = await findByUid(ctx, row.jmap, row.uid); + } + if (!realId) { + ctx.db.transaction(() => recordConflict(ctx, row, 'refused', refusal ?? { type: 'serverFail' })); + return 'conflict'; + } + } + const event = await fetchEvent(ctx, row.jmap, realId) ?? ({ ...payload, id: realId } as CalendarObject); + return finishCreate(ctx, row, payload, realId, event); +} + +function finishUpdate(ctx: SyncContext, row: PendingRow, sent: Record): Outcome { + const { db } = ctx; + return db.transaction(() => { + const current = db.get('SELECT * FROM calendar_pending WHERE seq = ?', [row.seq]); + const event = eventRow(db, row.jmap, row.record_id); + ctx.emit({ type: 'changed', account: ctx.account, collections: ['CalendarEvent'] }); + if (!current || !event) return 'sent'; + if (current.rev === row.rev) { + db.run('DELETE FROM calendar_pending WHERE seq = ?', [row.seq]); + writeEvent(db, row.jmap, { shown: JSON.parse(event.json) as CalendarObject, gen: event.gen }); + return 'sent'; + } + // A newer write came in during the send. What went out is the server's now, + // so the next sync must not take it for someone else's change. + const server = applyPatchObject(JSON.parse(event.server_json ?? event.json) as CalendarObject, sent); + const base = JSON.parse(current.base) as Record; + for (const key of Object.keys(sent)) if (key in base) base[key] = sent[key]; + db.run('UPDATE calendar_pending SET base = ? WHERE seq = ?', [JSON.stringify(base), row.seq]); + writeEvent(db, row.jmap, { shown: JSON.parse(event.json) as CalendarObject, server, hidden: Boolean(event.hidden), gen: event.gen }); + return 'again'; + }); +} + +async function sendUpdate(ctx: SyncContext, row: PendingRow): Promise { + let patch = JSON.parse(row.patch) as Record; + const id = row.record_id; + let result = await sendSet(ctx, row, { update: { [id]: patch } }); + if (isSeriesKeyRefusal(result.notUpdated?.[id])) { + patch = withoutOverrideSeriesKeys(patch as Partial) as Record; + result = await sendSet(ctx, row, { update: { [id]: patch } }); + } + const refusal = result.notUpdated?.[id]; + if (refusal) { + ctx.db.transaction(() => recordConflict(ctx, row, refusal.type === 'notFound' ? 'destroyedOnServer' : 'refused', refusal)); + return 'conflict'; + } + return finishUpdate(ctx, row, patch); +} + +async function sendDestroy(ctx: SyncContext, row: PendingRow): Promise { + // A create turned delete while its create was out: find out what the server has. + const id = row.uid ? await findByUid(ctx, row.jmap, row.uid) : row.record_id; + const finish = () => ctx.db.transaction(() => { + ctx.db.run('DELETE FROM calendar_pending WHERE seq = ?', [row.seq]); + deleteEventRow(ctx.db, row.jmap, row.record_id); + ctx.emit({ type: 'changed', account: ctx.account, collections: ['CalendarEvent'] }); + }); + if (!id) { + finish(); + return 'sent'; + } + const result = await sendSet(ctx, row, { destroy: [id] }); + const refusal = result.notDestroyed?.[id]; + if (refusal && refusal.type !== 'notFound') { + ctx.db.transaction(() => recordConflict(ctx, row, 'refused', refusal)); + return 'conflict'; + } + finish(); + return 'sent'; +} + +/** + * Sends what's pending for one JMAP account. Network failures and a server's + * bad moment are thrown, with the rest still pending. Any other method error + * means this write won't work as it is, so it goes to the conflict list and the + * others still go out. + */ +export async function sendPending(ctx: SyncContext, jmap: string): Promise { + const tally: FlushTally = { sent: 0, conflicts: 0 }; + const finished = new Set(); + for (;;) { + if (ctx.stopped()) return tally; + const row = ctx.db.all('SELECT * FROM calendar_pending WHERE jmap = ? ORDER BY seq', [jmap]) + .find((candidate) => !finished.has(Number(candidate.seq))); + if (!row) return tally; + let outcome: Outcome; + try { + if (row.kind === 'create') outcome = await sendCreate(ctx, row); + else if (row.kind === 'update') outcome = await sendUpdate(ctx, row); + else outcome = await sendDestroy(ctx, row); + } catch (error) { + if (!(error instanceof JmapMethodError) || isPassingError(error)) throw error; + ctx.db.transaction(() => recordConflict(ctx, row, 'refused', { type: error.type, description: error.message })); + outcome = 'conflict'; + } + if (outcome === 'again') continue; + finished.add(Number(row.seq)); + if (outcome === 'sent') tally.sent += 1; + else tally.conflicts += 1; + } +} diff --git a/lib/store/calendar/index.ts b/lib/store/calendar/index.ts new file mode 100644 index 000000000..da9115b78 --- /dev/null +++ b/lib/store/calendar/index.ts @@ -0,0 +1,19 @@ +import type { StoreArea } from '../area'; +import { CALENDAR_METHODS } from './methods'; +import { CALENDAR_MIGRATIONS } from './schema'; +import { calendarSyncer, flushCalendar } from './sync'; +import { listConflicts } from './writes'; + +export const calendarArea: StoreArea = { + name: 'calendar', + migrations: CALENDAR_MIGRATIONS, + methods: CALENDAR_METHODS, + syncers: [calendarSyncer], + calls: { + 'calendar.flush': (ctx) => flushCalendar(ctx), + 'calendar.conflicts': (ctx) => listConflicts(ctx.db), + 'calendar.dismissConflict': (ctx, params: { id: number }) => { + ctx.db.run('DELETE FROM calendar_conflict WHERE id = ?', [params.id]); + }, + }, +}; diff --git a/lib/store/calendar/methods.ts b/lib/store/calendar/methods.ts new file mode 100644 index 000000000..2443584dc --- /dev/null +++ b/lib/store/calendar/methods.ts @@ -0,0 +1,197 @@ +/** + * Calendar calls answered from the store. + * + * Series, not occurrences, in both client modes. A client in synthetic-id mode + * sends `expandRecurrences: true` and gets the masters, with their overrides + * (see projectEvent). hydrateRecurrenceInstances leaves those alone, and + * expandRecurringEvents in the calendar store makes the occurrences, the same as + * for a client in the other mode. Occurrence writes then arrive as overrides on + * the master, which is what a stand-in id and the pending queue can work with. + */ + +import type { CalendarEventFilter } from '@/lib/jmap/types'; +import { SYNTHETIC_ID_PROBE } from '@/lib/recurrence-instances'; +import { MethodError, type MethodContext, type MethodHandler, type MethodResult } from '../area'; +import { listObjects } from '../engine/core-schema'; +import type { SqlValue, StorageAdapter } from '../storage'; +import { boundsOf, matchesFilter, sortEvents, type EventComparator } from './filter'; +import { CALENDARS_CAPABILITY, instantOf, projectEvent, type CalendarObject } from './records'; +import { syntheticIdVerdict } from './sync'; +import { queueCreate, queueDestroy, queueUpdate, resolveId } from './writes'; + +function jmapOf(ctx: MethodContext, args: Record): string { + return (args.accountId as string | undefined) ?? ctx.session.primaryAccounts[CALENDARS_CAPABILITY] ?? ctx.defaultJmap; +} + +function stateOf(db: StorageAdapter, jmap: string, type: string): string | undefined { + return db.get<{ state: string | null }>('SELECT state FROM sync_state WHERE jmap = ? AND type = ?', [jmap, type])?.state ?? undefined; +} + +/** An account never synced has no answer yet. An empty one would read as "no events". */ +function assertSynced(db: StorageAdapter, jmap: string, type: string): void { + const row = db.get<{ state: string | null; phase: string | null }>('SELECT state, phase FROM sync_state WHERE jmap = ? AND type = ?', [jmap, type]); + if (!row?.state && !row?.phase) throw new MethodError('serverUnavailable', `${type} hasn't been kept on this device yet`); +} + +// An event's start_ms reads a floating event in UTC, and a request's bounds are +// in its own zone: up to 14 hours apart. This only narrows, the filter decides. +const ZONE_SLACK_MS = 15 * 3_600_000; + +function candidates(db: StorageAdapter, jmap: string, filter: CalendarEventFilter, zone: string): CalendarObject[] { + const where = ['jmap = ?', 'hidden = 0']; + const params: SqlValue[] = [jmap]; + if (typeof filter.uid === 'string' && !filter.operator) { + where.push('uid = ?'); + params.push(filter.uid); + } + const { after, before } = boundsOf(filter); + const afterMs = after ? instantOf(after, zone) : null; + const beforeMs = before ? instantOf(before, zone) : null; + if (afterMs !== null || beforeMs !== null) { + where.push(`(is_series = 1 OR start_ms IS NULL OR (start_ms < ? AND end_ms >= ?))`); + params.push(beforeMs === null ? Number.MAX_SAFE_INTEGER : beforeMs + ZONE_SLACK_MS); + params.push(afterMs === null ? Number.MIN_SAFE_INTEGER : afterMs - ZONE_SLACK_MS); + } + return db.all<{ json: string }>(`SELECT json FROM calendar_event WHERE ${where.join(' AND ')}`, params) + .map((row) => JSON.parse(row.json) as CalendarObject); +} + +const calendarGet: MethodHandler = (ctx, args) => { + const jmap = jmapOf(ctx, args); + assertSynced(ctx.db, jmap, 'Calendar'); + const ids = args.ids as string[] | null | undefined; + const properties = args.properties as string[] | undefined; + const all = listObjects<{ id: string } & Record>(ctx.db, jmap, 'Calendar'); + const chosen = ids ? all.filter((calendar) => ids.includes(calendar.id)) : all; + const state = stateOf(ctx.db, jmap, 'Calendar'); + return { + accountId: jmap, + ...(state ? { state } : {}), + list: chosen.map((calendar) => (properties + ? Object.fromEntries(['id', ...properties].filter((name) => name in calendar).map((name) => [name, calendar[name]])) + : calendar)), + notFound: ids ? ids.filter((id) => !all.some((calendar) => calendar.id === id)) : [], + }; +}; + +const eventQuery: MethodHandler = (ctx, args) => { + const jmap = jmapOf(ctx, args); + assertSynced(ctx.db, jmap, 'CalendarEvent'); + const filter = (args.filter ?? {}) as CalendarEventFilter; + const zone = typeof args.timeZone === 'string' && args.timeZone ? args.timeZone : 'UTC'; + const { after, before } = boundsOf(filter); + if (args.expandRecurrences && !(after && before)) { + throw new MethodError('invalidArguments', 'expandRecurrences needs both after and before'); + } + const matching = candidates(ctx.db, jmap, filter, zone).filter((object) => matchesFilter(object, filter, zone)); + const sorted = sortEvents(matching, (args.sort ?? []) as EventComparator[], zone); + const position = Math.max(0, Number(args.position ?? 0) || 0); + const limit = Number(args.limit); + const ids = sorted.map((object) => object.id); + return { + accountId: jmap, + // No queryState: the client doesn't read it, and the store has no real one. + canCalculateChanges: false, + position, + ids: ids.slice(position, limit > 0 ? position + limit : undefined), + ...(args.calculateTotal ? { total: ids.length } : {}), + }; +}; + +const eventGet: MethodHandler = (ctx, args) => { + const jmap = jmapOf(ctx, args); + const requested = args.ids as string[] | null | undefined; + // A page's first calendar list carries `ids: []` to check a time zone, also before any sync. + if (!(Array.isArray(requested) && requested.length === 0)) assertSynced(ctx.db, jmap, 'CalendarEvent'); + const properties = (args.properties as string[] | undefined) ?? null; + const zone = typeof args.timeZone === 'string' && args.timeZone ? args.timeZone : 'UTC'; + const list: Record[] = []; + const notFound: string[] = []; + if (requested) { + for (const id of requested) { + const row = ctx.db.get<{ json: string }>('SELECT json FROM calendar_event WHERE jmap = ? AND id = ? AND hidden = 0', [jmap, resolveId(ctx.db, jmap, id)]); + if (row) list.push(projectEvent(JSON.parse(row.json) as CalendarObject, properties, zone)); + else notFound.push(id); + } + } else { + const rows = ctx.db.all<{ json: string }>('SELECT json FROM calendar_event WHERE jmap = ? AND hidden = 0', [jmap]); + for (const row of rows) list.push(projectEvent(JSON.parse(row.json) as CalendarObject, properties, zone)); + } + const state = stateOf(ctx.db, jmap, 'CalendarEvent'); + return { accountId: jmap, ...(state ? { state } : {}), list, notFound }; +}; + +// What a server without synthetic-id writes says. It's the answer here too, for +// an id the store doesn't hold, which is an occurrence still on screen from +// before the connection went: the client catches this (updateOccurrence, +// destroyOccurrence in stores/calendar-store.ts) and writes the change as an +// override on the base event, which the store does hold. +function syntheticRefusal(verb: 'Updating' | 'Deleting'): { type: string; description: string; properties: string[] } { + return { type: 'invalidProperties', description: `${verb} synthetic ids is not yet supported.`, properties: ['id'] }; +} + +const eventSet: MethodHandler = (ctx, args) => { + const jmap = jmapOf(ctx, args); + const send = typeof args.sendSchedulingMessages === 'boolean' ? args.sendSchedulingMessages : undefined; + const created: Record = {}; + const updated: Record = {}; + const destroyed: string[] = []; + const notUpdated: Record = {}; + const notDestroyed: Record = {}; + let wrote = false; + ctx.db.transaction(() => { + for (const [key, event] of Object.entries((args.create ?? {}) as Record>)) { + created[key] = queueCreate(ctx, jmap, event, send); + wrote = true; + } + for (const [id, patch] of Object.entries((args.update ?? {}) as Record>)) { + if (id === SYNTHETIC_ID_PROBE) { + // A client that started offline asks here. Tell it what the server said + // at the last sync, so it behaves the same when the server is back. With + // no answer yet: client-side expansion, which works on every server. + notUpdated[id] = syntheticIdVerdict(ctx, jmap) === true ? { type: 'notFound' } : syntheticRefusal('Updating'); + continue; + } + if (queueUpdate(ctx, jmap, resolveId(ctx.db, jmap, id), patch, send)) { + updated[id] = null; + wrote = true; + } else { + notUpdated[id] = syntheticRefusal('Updating'); + } + } + for (const id of (args.destroy ?? []) as string[]) { + if (queueDestroy(ctx, jmap, resolveId(ctx.db, jmap, id), send)) { + destroyed.push(id); + wrote = true; + } else { + notDestroyed[id] = syntheticRefusal('Deleting'); + } + } + }); + if (wrote) ctx.emit({ type: 'changed', account: ctx.account, collections: ['CalendarEvent'] }); + const result: MethodResult = { accountId: jmap }; + if (Object.keys(created).length > 0) result.created = created; + if (Object.keys(updated).length > 0) result.updated = updated; + if (destroyed.length > 0) result.destroyed = destroyed; + if (Object.keys(notUpdated).length > 0) result.notUpdated = notUpdated; + if (Object.keys(notDestroyed).length > 0) result.notDestroyed = notDestroyed; + return result; +}; + +const identityGet: MethodHandler = (ctx, args) => { + const jmap = jmapOf(ctx, args); + const list = listObjects(ctx.db, jmap, 'ParticipantIdentity'); + if (list.length === 0) throw new MethodError('serverUnavailable', 'No participant identities kept yet'); + return { accountId: jmap, list, notFound: [] }; +}; + +export const CALENDAR_METHODS: Readonly> = { + 'Calendar/get': calendarGet, + 'CalendarEvent/query': eventQuery, + 'CalendarEvent/get': eventGet, + 'CalendarEvent/set': eventSet, + 'ParticipantIdentity/get': identityGet, + // Notifications about others' changes wait for the server. + 'CalendarEventNotification/query': (ctx, args) => ({ accountId: jmapOf(ctx, args), canCalculateChanges: false, position: 0, ids: [] }), + 'CalendarEventNotification/get': (ctx, args) => ({ accountId: jmapOf(ctx, args), list: [], notFound: [] }), +}; diff --git a/lib/store/calendar/records.ts b/lib/store/calendar/records.ts new file mode 100644 index 000000000..84cdcb0cc --- /dev/null +++ b/lib/store/calendar/records.ts @@ -0,0 +1,162 @@ +/** How calendar objects are stored and read back. */ + +import { parseISO } from 'date-fns'; +import { isTaskLikeObject } from '@/lib/calendar-component-detection'; +import { parseDurationSeconds } from '@/lib/calendar-event-normalization'; +import { pointerTokenValue } from '@/lib/jmap/patch-pointer'; +import { wallClockFieldsAsUtc, zonedWallTimeToUtc } from '@/lib/recurrence-expansion'; +import type { StoreSession } from '../engine/transport'; +import type { SqlValue, StorageAdapter } from '../storage'; + +export const CALENDARS_CAPABILITY = 'urn:ietf:params:jmap:calendars'; +const PRINCIPALS_OWNER = 'urn:ietf:params:jmap:principals:owner'; + +export type CalendarObject = Record & { id: string }; + +export function calendarUsing(session: StoreSession): string[] { + const using = ['urn:ietf:params:jmap:core', CALENDARS_CAPABILITY]; + if (PRINCIPALS_OWNER in session.capabilities) using.push(PRINCIPALS_OWNER); + return using; +} + +/** Stalwart's default list for Calendar/get leaves out sharing and default alerts. */ +export const CALENDAR_PROPERTIES = [ + 'id', 'name', 'description', 'color', 'sortOrder', 'isSubscribed', 'isVisible', 'isDefault', + 'includeInAvailability', 'defaultAlertsWithTime', 'defaultAlertsWithoutTime', 'timeZone', 'shareWith', 'myRights', +] as const; + +/** + * Everything the client's event and task property lists ask for, in one get. + * No utcStart or utcEnd: Stalwart 1.0 refuses those next to recurrenceOverrides, + * and they depend on the zone of the request, so answers work them out. + */ +export const EVENT_PROPERTIES = [ + 'id', '@type', 'uid', 'calendarIds', 'title', 'description', 'descriptionContentType', 'created', 'updated', + 'sequence', 'start', 'due', 'duration', 'timeZone', 'showWithoutTime', 'status', 'freeBusyStatus', 'privacy', + 'color', 'keywords', 'categories', 'locale', 'replyTo', 'organizerCalendarAddress', 'participants', + 'mayInviteSelf', 'mayInviteOthers', 'hideAttendees', 'recurrenceId', 'recurrenceIdTimeZone', 'recurrenceRule', + 'excludedRecurrenceRule', 'recurrenceOverrides', 'useDefaultAlerts', 'alerts', 'locations', 'virtualLocations', + 'links', 'relatedTo', 'isDraft', 'isOrigin', 'baseEventId', 'progress', 'priority', 'percentComplete', +] as const; + +export function isSeries(object: Record): boolean { + const rule = object.recurrenceRule ?? object.recurrenceRules; + return Array.isArray(rule) ? rule.length > 0 : Boolean(rule); +} + +/** The value a PatchObject key points at. */ +export function valueAtPointer(object: unknown, pointer: string): unknown { + let value = object; + for (const token of pointer.split('/')) { + if (value === null || typeof value !== 'object') return undefined; + value = (value as Record)[pointerTokenValue(token)]; + } + return value; +} + +/** A LocalDateTime without any `Z` or offset: Stalwart drops those instead of honouring them. */ +export function wallClock(value: string): string { + return value.replace(/(?:Z|[+-]\d{2}:?\d{2})$/, ''); +} + +/** The instant a wall clock names in `zone`. A zone the runtime doesn't know reads as UTC, as 0.16 does. */ +export function instantOf(value: string, zone: string | null | undefined): number | null { + const wall = parseISO(wallClock(value)); + if (Number.isNaN(wall.getTime())) return null; + const zoned = zone ? zonedWallTimeToUtc(wall, zone) : null; + return zoned ? zoned.getTime() : wallClockFieldsAsUtc(wall); +} + +/** An object's start and end as instants. A floating one is read in `zone`. A task without a start goes by its due. */ +export function instantsOf(object: Record, zone: string | null | undefined): { start: number | null; end: number | null } { + const at = typeof object.start === 'string' ? object.start : typeof object.due === 'string' ? object.due : null; + if (!at) return { start: null, end: null }; + const start = instantOf(at, typeof object.timeZone === 'string' ? object.timeZone : zone); + if (start === null) return { start: null, end: null }; + const seconds = typeof object.duration === 'string' ? parseDurationSeconds(object.duration) : null; + return { start, end: start + (seconds ?? 0) * 1000 }; +} + +function utcString(ms: number): string { + return new Date(ms).toISOString().replace('.000Z', 'Z'); +} + +/** + * An object as a CalendarEvent/get asked for it. utcStart and utcEnd are worked + * out here, so they hold for the request's zone and for events edited offline. + * A series keeps its overrides even when not asked for: the client's + * synthetic-id mode reads occurrences without them, and it gets series from the + * store, which it can only expand correctly with them. + */ +export function projectEvent(object: CalendarObject, properties: readonly string[] | null, zone: string | null | undefined): Record { + if (!properties) return { ...object }; + const out: Record = { id: object.id }; + for (const name of properties) { + if (name === 'utcStart' || name === 'utcEnd') continue; + if (name in object) out[name] = object[name]; + } + if (properties.includes('utcStart') || properties.includes('utcEnd')) { + const { start, end } = typeof object.start === 'string' ? instantsOf(object, zone) : { start: null, end: null }; + if (start !== null && properties.includes('utcStart')) out.utcStart = utcString(start); + if (end !== null && properties.includes('utcEnd')) out.utcEnd = utcString(end); + } + if (properties.includes('recurrenceRule') && object.recurrenceOverrides && isSeries(object)) { + out.recurrenceOverrides = object.recurrenceOverrides; + } + return out; +} + +export interface EventRow { + jmap: string; + id: string; + json: string; + server_json: string | null; + hidden: number; + gen: number; +} + +export function eventRow(db: StorageAdapter, jmap: string, id: string): EventRow | undefined { + return db.get('SELECT jmap, id, json, server_json, hidden, gen FROM calendar_event WHERE jmap = ? AND id = ?', [jmap, id]); +} + +export function currentGen(db: StorageAdapter, jmap: string): number { + return Number(db.get<{ gen: number | null }>('SELECT MAX(gen) AS gen FROM calendar_event WHERE jmap = ?', [jmap])?.gen ?? 0); +} + +export interface EventWrite { + /** What answers show. */ + shown: CalendarObject; + /** The server's copy while a write is pending, otherwise null. */ + server?: CalendarObject | null; + hidden?: boolean; + gen: number; +} + +export function writeEvent(db: StorageAdapter, jmap: string, write: EventWrite): void { + const { shown } = write; + const { start, end } = instantsOf(shown, 'UTC'); + const params: SqlValue[] = [ + jmap, + shown.id, + typeof shown.uid === 'string' ? shown.uid : null, + isTaskLikeObject(shown) ? 1 : 0, + isSeries(shown) ? 1 : 0, + start, + end, + JSON.stringify(shown), + write.server ? JSON.stringify(write.server) : null, + write.hidden ? 1 : 0, + write.gen, + ]; + db.run( + `INSERT INTO calendar_event (jmap, id, uid, is_task, is_series, start_ms, end_ms, json, server_json, hidden, gen) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (jmap, id) DO UPDATE SET uid = excluded.uid, is_task = excluded.is_task, is_series = excluded.is_series, start_ms = excluded.start_ms, + end_ms = excluded.end_ms, json = excluded.json, server_json = excluded.server_json, hidden = excluded.hidden, gen = excluded.gen`, + params, + ); +} + +export function deleteEventRow(db: StorageAdapter, jmap: string, id: string): void { + db.run('DELETE FROM calendar_event WHERE jmap = ? AND id = ?', [jmap, id]); +} diff --git a/lib/store/calendar/reminders.ts b/lib/store/calendar/reminders.ts new file mode 100644 index 000000000..4ab5e3227 --- /dev/null +++ b/lib/store/calendar/reminders.ts @@ -0,0 +1,160 @@ +/** + * Which alerts fire in a time window, from what's stored. No DOM, no stores, no + * React: desktop reminders call this while the window is hidden. + * + * Keys use the master's id and the fire time, never an occurrence id. Synthetic + * ids can change, and browser occurrences don't exist on the server. A moved + * occurrence gets a new key, as it should: it's a new time to remind. + */ + +import { format, parseISO } from 'date-fns'; +import { buildAlertKey, computeFireTime, computeTaskFireTime, getEffectiveAlerts, parseAlertOffset } from '@/lib/calendar-alerts'; +import { isTaskLikeObject } from '@/lib/calendar-component-detection'; +import { normalizeCalendarEventLike, parseDurationSeconds } from '@/lib/calendar-event-normalization'; +import type { Calendar, CalendarEvent, CalendarEventAlert, CalendarTask } from '@/lib/jmap/types'; +import { expandRecurringEvents } from '@/lib/recurrence-expansion'; +import { listObjects } from '../engine/core-schema'; +import type { StorageAdapter } from '../storage'; +import { instantsOf, isSeries, type CalendarObject } from './records'; + +export interface UpcomingAlert { + /** Stable for this alert at this time. Use it to remember what was shown. */ + key: string; + jmap: string; + kind: 'event' | 'task'; + /** The stored event or task: the master of a series. */ + baseEventId: string; + /** Which occurrence of the series, when it's one. */ + recurrenceId: string | null; + alertId: string; + fireTimeMs: number; + title: string; + /** The start as a wall clock, or a task's due when it has no start. */ + start: string | null; + timeZone: string | null; + calendarId: string | null; + calendarName: string | null; +} + +export interface UpcomingAlertsOptions { + /** Alerts firing at or after this. */ + from: number; + /** Alerts firing before this. */ + to: number; + /** Reads floating events. The user's zone. */ + timeZone: string; +} + +type AlertMap = Record | null | undefined; + +// An alert fires up to its offset away from its occurrence, and the occurrence's +// wall clock is up to 26 hours from the instant. Expand wide, filter by instant. +function offsetReachMs(alerts: AlertMap): number { + let reach = 0; + for (const alert of Object.values(alerts ?? {})) { + if (alert.trigger?.['@type'] === 'AbsoluteTrigger') continue; + reach = Math.max(reach, Math.abs(parseAlertOffset(alert.trigger?.offset ?? '') ?? 0)); + } + return reach; +} + +function alertsInOverrides(event: CalendarEvent): AlertMap[] { + return Object.values(event.recurrenceOverrides ?? {}).map((override) => (override as { alerts?: AlertMap }).alerts); +} + +const ZONE_REACH_MS = 27 * 3_600_000; + +function occurrencesNear(event: CalendarEvent, calendars: Calendar[], options: UpcomingAlertsOptions): CalendarEvent[] { + const reach = ZONE_REACH_MS + + Math.max(offsetReachMs(getEffectiveAlerts(event, calendars)), ...alertsInOverrides(event).map(offsetReachMs), 0) + + (parseDurationSeconds(event.duration) ?? 0) * 1000; + const stamp = (ms: number) => format(new Date(ms), "yyyy-MM-dd'T'HH:mm:ss"); + return expandRecurringEvents([event], stamp(options.from - reach), stamp(options.to + reach)); +} + +function withInstants(object: CalendarObject, zone: string): CalendarObject { + const { start, end } = instantsOf(object, zone); + return { + ...object, + ...(start !== null ? { utcStart: new Date(start).toISOString() } : {}), + ...(end !== null ? { utcEnd: new Date(end).toISOString() } : {}), + }; +} + +function firstCalendar(calendarIds: unknown, calendars: Calendar[]): { id: string | null; name: string | null } { + const id = Object.keys((calendarIds ?? {}) as Record)[0] ?? null; + return { id, name: calendars.find((calendar) => calendar.id === id)?.name ?? null }; +} + +/** Every `display` alert of the stored events and tasks that fires in the window, soonest first. */ +export function listUpcomingAlerts(db: StorageAdapter, options: UpcomingAlertsOptions): UpcomingAlert[] { + const found = new Map(); + const add = (alert: UpcomingAlert) => { + if (alert.fireTimeMs >= options.from && alert.fireTimeMs < options.to) found.set(alert.key, alert); + }; + const rows = db.all<{ jmap: string; json: string }>('SELECT jmap, json FROM calendar_event WHERE hidden = 0'); + const calendarsOf = new Map(); + for (const row of rows) { + if (!calendarsOf.has(row.jmap)) calendarsOf.set(row.jmap, listObjects(db, row.jmap, 'Calendar')); + const calendars = calendarsOf.get(row.jmap)!; + const object = JSON.parse(row.json) as CalendarObject; + const calendar = firstCalendar(object.calendarIds, calendars); + + if (isTaskLikeObject(object)) { + const task = object as unknown as CalendarTask; + if (task.progress === 'completed' || task.progress === 'cancelled') continue; + for (const [alertId, alert] of Object.entries(task.alerts ?? {})) { + if (alert.action !== 'display' || alert.acknowledged) continue; + const fireTimeMs = computeTaskFireTime(task, alert.trigger); + if (fireTimeMs === null) continue; + add({ + key: buildAlertKey(object.id, alertId, fireTimeMs), + jmap: row.jmap, + kind: 'task', + baseEventId: object.id, + recurrenceId: null, + alertId, + fireTimeMs, + title: task.title ?? '', + start: task.start ?? task.due ?? null, + timeZone: task.timeZone ?? null, + calendarId: calendar.id, + calendarName: calendar.name, + }); + } + continue; + } + + const master = normalizeCalendarEventLike(withInstants(object, options.timeZone) as unknown as CalendarEvent); + if (typeof master.start !== 'string' || Number.isNaN(parseISO(master.start).getTime())) continue; + const series = isSeries(object); + const occurrences = series ? occurrencesNear(master, calendars, options) : [master]; + // A fixed time doesn't need an occurrence nearby. + const candidates = series ? [master, ...occurrences] : occurrences; + for (const occurrence of candidates) { + if (occurrence.status === 'cancelled') continue; + const isMasterOfSeries = series && occurrence === master; + for (const [alertId, alert] of Object.entries(getEffectiveAlerts(occurrence, calendars) ?? {})) { + if (alert.action !== 'display' || alert.acknowledged) continue; + if (isMasterOfSeries && alert.trigger['@type'] !== 'AbsoluteTrigger') continue; + const fireTimeMs = computeFireTime(occurrence, alert.trigger); + if (fireTimeMs === null) continue; + add({ + key: buildAlertKey(object.id, alertId, fireTimeMs), + jmap: row.jmap, + kind: 'event', + baseEventId: object.id, + recurrenceId: occurrence.recurrenceId ?? null, + alertId, + fireTimeMs, + title: occurrence.title ?? '', + start: occurrence.start, + timeZone: occurrence.timeZone ?? null, + calendarId: calendar.id, + calendarName: calendar.name, + }); + } + } + } + return [...found.values()].sort((a, b) => a.fireTimeMs - b.fireTimeMs || a.key.localeCompare(b.key)); +} diff --git a/lib/store/calendar/schema.ts b/lib/store/calendar/schema.ts new file mode 100644 index 000000000..1d56d505e --- /dev/null +++ b/lib/store/calendar/schema.ts @@ -0,0 +1,77 @@ +import type { Migration } from '../storage'; + +export const CALENDAR_MIGRATIONS: readonly Migration[] = [ + { + id: 500, + name: 'calendar', + sql: ` + CREATE TABLE calendar_event ( + jmap TEXT NOT NULL, + id TEXT NOT NULL, + uid TEXT, + is_task INTEGER NOT NULL DEFAULT 0, + -- 1 for a series master. Occurrences are never stored: /changes only + -- lists masters, and the client expands them. + is_series INTEGER NOT NULL DEFAULT 0, + -- Instants to narrow a query with. A floating event is read in UTC here. + start_ms INTEGER, + end_ms INTEGER, + -- What answers show: the server's copy with any pending write applied. + json TEXT NOT NULL, + -- The server's copy while a write is pending on it, otherwise NULL. + server_json TEXT, + -- 1 while a delete is pending. + hidden INTEGER NOT NULL DEFAULT 0, + gen INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (jmap, id) + ) WITHOUT ROWID; + CREATE INDEX calendar_event_uid ON calendar_event (jmap, uid); + CREATE INDEX calendar_event_start ON calendar_event (jmap, start_ms); + CREATE INDEX calendar_event_end ON calendar_event (jmap, end_ms); + + -- One row per event: a later write merges into it (see writes.ts). + CREATE TABLE calendar_pending ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + jmap TEXT NOT NULL, + record_id TEXT NOT NULL, + kind TEXT NOT NULL CHECK (kind IN ('create', 'update', 'destroy')), + -- create: the whole event. update: a PatchObject. destroy: empty. + patch TEXT NOT NULL, + -- The server's value at each patched path when it was queued. + base TEXT NOT NULL DEFAULT '{}', + -- For a create: how to find it again after a lost response. + uid TEXT, + -- sendSchedulingMessages: NULL when no write named it. + send_scheduling INTEGER, + -- 1 once a create went out, so the server may hold it already. + attempted INTEGER NOT NULL DEFAULT 0, + -- Moves on every merge, to see a write that came in during a send. + rev INTEGER NOT NULL DEFAULT 0, + queued_at INTEGER NOT NULL, + UNIQUE (jmap, record_id) + ); + + -- What a stand-in id became, for a page still holding the stand-in. + CREATE TABLE calendar_id_map ( + jmap TEXT NOT NULL, + local_id TEXT NOT NULL, + server_id TEXT NOT NULL, + PRIMARY KEY (jmap, local_id) + ) WITHOUT ROWID; + + CREATE TABLE calendar_conflict ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + jmap TEXT NOT NULL, + record_id TEXT NOT NULL, + kind TEXT NOT NULL, + reason TEXT NOT NULL, + error_type TEXT, + description TEXT, + title TEXT, + local TEXT NOT NULL, + server TEXT, + created_at INTEGER NOT NULL + ); + `, + }, +]; diff --git a/lib/store/calendar/sync.ts b/lib/store/calendar/sync.ts new file mode 100644 index 000000000..8463a0896 --- /dev/null +++ b/lib/store/calendar/sync.ts @@ -0,0 +1,264 @@ +/** + * Calendar sync for one JMAP account: calendars, the last participant + * identities, then every event and task. + * + * Masters only. CalendarEvent/changes lists nothing else on either Stalwart, an + * instance edit shows up as `updated: [master]`, and 1.0's expanded queries + * can't give changes and refuse long ranges. Occurrences are made when read. + */ + +import { isTaskLikeObject } from '@/lib/calendar-component-detection'; +import { SYNTHETIC_ID_PROBE } from '@/lib/recurrence-instances'; +import type { StoreContext, SyncContext, Syncer } from '../area'; +import { runChangesLoop } from '../engine/changes'; +import { metaGet, metaSet, putObject } from '../engine/core-schema'; +import { JmapMethodError, responseFor, TransportError } from '../engine/transport'; +import type { StorageAdapter } from '../storage'; +import { isPassingError, sendPending, type FlushTally } from './flush'; +import { + CALENDAR_PROPERTIES, + CALENDARS_CAPABILITY, + calendarUsing, + currentGen, + EVENT_PROPERTIES, + eventRow, + instantsOf, + isSeries, + type CalendarObject, +} from './records'; +import { applyServerDestroy, applyServerEvent, isStandIn, pendingFor } from './writes'; + +const PAGE = 500; +/** How far back single events are kept. A series stays whatever its start, a task whatever its date. */ +const PAST_WINDOW_MS = 2 * 366 * 86_400_000; + +function syntheticIdsKey(jmap: string): string { + return `calendar.syntheticIds.${jmap}`; +} + +/** + * What this account's server said to the synthetic-id probe at the last sync. + * Null before it ever answered. A client that probed online holds its verdict + * for life and never asks again; only one started offline asks the store. + */ +export function syntheticIdVerdict(ctx: StoreContext, jmap: string): boolean | null { + const value = metaGet(ctx.db, syntheticIdsKey(jmap)); + return value === null ? null : value === '1'; +} + +// A shared account probed on suspicion that grants no calendar access (the +// client treats these the same way). +function isAccessDenial(error: unknown): boolean { + return error instanceof JmapMethodError + && (error.type === 'forbidden' || error.type === 'accountNotFound' || error.type === 'accountNotSupportedByMethod'); +} + +/** False when the account grants no calendar access. */ +async function syncCalendars(ctx: SyncContext, jmap: string): Promise { + const response = await ctx.transport.request([ + ['Calendar/get', { accountId: jmap, ids: null, properties: CALENDAR_PROPERTIES }, 'c'], + ['CalendarEvent/set', { accountId: jmap, update: { [SYNTHETIC_ID_PROBE]: {} } }, 'p'], + ['ParticipantIdentity/get', { accountId: jmap }, 'i'], + ], calendarUsing(ctx.session)); + let result: { list: Array<{ id: string }>; state: unknown }; + try { + result = responseFor(response, 'c')[1]; + } catch (error) { + if (isAccessDenial(error)) return false; + throw error; + } + const probe = response.methodResponses.find(([, , id]) => id === 'p'); + const identities = response.methodResponses.find(([, , id]) => id === 'i'); + const before = ctx.syncState(jmap, 'Calendar').state; + ctx.db.transaction(() => { + ctx.db.run("DELETE FROM object WHERE jmap = ? AND type = 'Calendar'", [jmap]); + for (const calendar of result.list) putObject(ctx.db, jmap, 'Calendar', calendar.id, calendar); + ctx.setSyncState(jmap, 'Calendar', { state: String(result.state) }); + if (probe?.[0] === 'CalendarEvent/set') { + const failure = probe[1]?.notUpdated?.[SYNTHETIC_ID_PROBE] as { type?: string } | undefined; + metaSet(ctx.db, syntheticIdsKey(jmap), failure?.type === 'notFound' ? '1' : '0'); + } + // Only ever read as "the last value". 0.16 has no usable /changes for it. + if (identities?.[0] === 'ParticipantIdentity/get') { + ctx.db.run("DELETE FROM object WHERE jmap = ? AND type = 'ParticipantIdentity'", [jmap]); + for (const identity of identities[1].list as Array<{ id: string }>) putObject(ctx.db, jmap, 'ParticipantIdentity', identity.id, identity); + } + }); + if (before !== String(result.state)) ctx.emit({ type: 'changed', account: ctx.account, collections: ['Calendar'] }); + return true; +} + +function inWindow(ctx: SyncContext, event: CalendarObject): boolean { + if (isSeries(event) || isTaskLikeObject(event)) return true; + const { end } = instantsOf(event, 'UTC'); + return end === null || end >= ctx.now() - PAST_WINDOW_MS; +} + +interface ListCursor { position: number; gen: number; captured: string } + +async function startFullList(ctx: SyncContext, jmap: string): Promise { + const response = await ctx.transport.request([['CalendarEvent/get', { accountId: jmap, ids: [], properties: ['id'] }, 's']], calendarUsing(ctx.session)); + const captured = String(responseFor(response, 's')[1].state); + const cursor: ListCursor = { position: 0, gen: currentGen(ctx.db, jmap) + 1, captured }; + ctx.db.transaction(() => ctx.setSyncState(jmap, 'CalendarEvent', { state: null, phase: 'list', cursor: { ...cursor } })); + return cursor; +} + +function storeEvent(ctx: SyncContext, jmap: string, event: CalendarObject, gen: number): void { + // An old event changing doesn't pull it back into the window. + if (!inWindow(ctx, event) && !eventRow(ctx.db, jmap, event.id) && !pendingFor(ctx.db, jmap, event.id)) return; + applyServerEvent(ctx, jmap, event, gen); +} + +/** Pages through every object: the query has no filter for tasks, and old series must stay. False when stopped. */ +async function runFullList(ctx: SyncContext, jmap: string, start: ListCursor): Promise { + const cursor = { ...start }; + for (;;) { + if (ctx.stopped()) return false; + const response = await ctx.transport.request([ + ['CalendarEvent/query', { accountId: jmap, position: cursor.position, limit: PAGE }, 'q'], + ['CalendarEvent/get', { + accountId: jmap, + '#ids': { resultOf: 'q', name: 'CalendarEvent/query', path: '/ids' }, + properties: EVENT_PROPERTIES, + }, 'g'], + ], calendarUsing(ctx.session)); + const ids = responseFor(response, 'q')[1].ids as string[]; + const list = responseFor(response, 'g')[1].list as CalendarObject[]; + const done = ids.length < PAGE; + cursor.position += ids.length; + ctx.db.transaction(() => { + for (const event of list) storeEvent(ctx, jmap, event, cursor.gen); + if (done) { + // Whatever this pass didn't see is gone from the server. + const stale = ctx.db.all<{ id: string }>('SELECT id FROM calendar_event WHERE jmap = ? AND gen < ?', [jmap, cursor.gen]); + for (const { id } of stale) if (!isStandIn(id)) applyServerDestroy(ctx, jmap, id); + ctx.setSyncState(jmap, 'CalendarEvent', { state: cursor.captured, phase: null, cursor: null }); + } else { + ctx.setSyncState(jmap, 'CalendarEvent', { cursor: { ...cursor } }); + } + }); + ctx.emit({ type: 'progress', account: ctx.account, area: 'calendar', phase: 'list', done: cursor.position, total: null }); + if (done) { + ctx.emit({ type: 'changed', account: ctx.account, collections: ['CalendarEvent'] }); + return true; + } + } +} + +async function syncDelta(ctx: SyncContext, jmap: string, since: string, target: string | null): Promise<'done' | 'resync' | 'stopped'> { + const outcome = await runChangesLoop({ + transport: ctx.transport, + type: 'CalendarEvent', + jmap, + using: calendarUsing(ctx.session), + since, + target, + stopped: ctx.stopped, + apply: async (page) => { + const ids = [...page.created, ...page.updated]; + const fetched: CalendarObject[] = []; + for (let i = 0; i < ids.length; i += PAGE) { + const response = await ctx.transport.request([['CalendarEvent/get', { + accountId: jmap, + ids: ids.slice(i, i + PAGE), + properties: EVENT_PROPERTIES, + }, 'g']], calendarUsing(ctx.session)); + fetched.push(...responseFor(response, 'g')[1].list as CalendarObject[]); + } + ctx.db.transaction(() => { + const gen = currentGen(ctx.db, jmap); + for (const event of fetched) storeEvent(ctx, jmap, event, gen); + for (const id of page.destroyed) applyServerDestroy(ctx, jmap, id); + ctx.setSyncState(jmap, 'CalendarEvent', { state: page.newState }); + }); + if (ids.length + page.destroyed.length > 0) ctx.emit({ type: 'changed', account: ctx.account, collections: ['CalendarEvent'] }); + }, + }); + if (outcome.outcome === 'resync') return 'resync'; + return outcome.outcome === 'stopped' ? 'stopped' : 'done'; +} + +/** False when stopped before catching up. */ +async function syncEvents(ctx: SyncContext, jmap: string): Promise { + const sync = ctx.syncState(jmap, 'CalendarEvent'); + let cursor = sync.phase === 'list' ? sync.cursor as unknown as ListCursor : null; + if (!cursor && !sync.state) cursor = await startFullList(ctx, jmap); + if (!cursor && sync.state) { + const outcome = await syncDelta(ctx, jmap, sync.state, sync.target); + if (outcome === 'stopped') return false; + if (outcome === 'resync') cursor = await startFullList(ctx, jmap); + } + if (cursor) { + if (!(await runFullList(ctx, jmap, cursor))) return false; + // What changed while paging. + const after = ctx.syncState(jmap, 'CalendarEvent'); + if (after.state && (await syncDelta(ctx, jmap, after.state, after.target)) === 'stopped') return false; + } + return true; +} + +/** Single events that aged out of the window. The window moves on without any change on the server. */ +function pruneOld(ctx: SyncContext, jmap: string): void { + ctx.db.run( + `DELETE FROM calendar_event WHERE jmap = ? AND is_series = 0 AND is_task = 0 AND end_ms IS NOT NULL AND end_ms < ? + AND NOT EXISTS (SELECT 1 FROM calendar_pending p WHERE p.jmap = calendar_event.jmap AND p.record_id = calendar_event.id)`, + [jmap, ctx.now() - PAST_WINDOW_MS], + ); +} + +// A sync and a flush must not interleave. Our own write coming back through +// /changes while it's still pending would read as someone else's change. +const queues = new WeakMap>(); + +function oneAtATime(db: StorageAdapter, work: () => Promise): Promise { + const next = (queues.get(db) ?? Promise.resolve()).catch(() => {}).then(work); + queues.set(db, next); + return next; +} + +async function syncAndSend(ctx: SyncContext, jmap: string): Promise { + const none: FlushTally = { sent: 0, conflicts: 0 }; + if (!(await syncEvents(ctx, jmap)) || ctx.stopped()) return none; + const tally = await sendPending(ctx, jmap); + if (tally.sent > 0 && !ctx.stopped()) await syncEvents(ctx, jmap); + return tally; +} + +export const calendarSyncer: Syncer = { + name: 'calendar', + capability: CALENDARS_CAPABILITY, + async run(ctx, jmap) { + if (!(await syncCalendars(ctx, jmap)) || ctx.stopped()) return; + await oneAtATime(ctx.db, () => syncAndSend(ctx, jmap)); + pruneOld(ctx, jmap); + }, +}; + +export interface FlushResult extends FlushTally { + /** Writes still waiting: the network went away, or the server had a bad moment. */ + pending: number; +} + +/** + * `calendar.flush`: catches up with the server, so a write that collides with + * a change there is found, then sends every pending write. Doesn't throw for a + * lost connection: what wasn't sent stays pending. + */ +export function flushCalendar(ctx: SyncContext): Promise { + return oneAtATime(ctx.db, async () => { + const accounts = ctx.db.all<{ jmap: string }>('SELECT DISTINCT jmap FROM calendar_pending').map((row) => row.jmap); + const total: FlushTally = { sent: 0, conflicts: 0 }; + try { + for (const jmap of accounts) { + const tally = await syncAndSend(ctx, jmap); + total.sent += tally.sent; + total.conflicts += tally.conflicts; + } + } catch (error) { + if (!(error instanceof TransportError) && !isPassingError(error)) throw error; + } + const pending = Number(ctx.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM calendar_pending')?.n ?? 0); + return { ...total, pending }; + }); +} diff --git a/lib/store/calendar/writes.ts b/lib/store/calendar/writes.ts new file mode 100644 index 000000000..80d6b005b --- /dev/null +++ b/lib/store/calendar/writes.ts @@ -0,0 +1,259 @@ +/** + * Calendar writes made while the server can't be reached. + * + * Each event has at most one pending row. A later write merges into it, so a + * flush sends one change per event: edit + edit is one patch, create + edit is + * one create, create + delete is nothing, edit + delete is a delete. + * + * Answers read calendar_event.json, which is the server's copy with the pending + * write applied. server_json keeps the server's copy underneath, so sync can + * move it on and reapply the write, or notice that the two collide. + */ + +import { applyPatchObject } from '@/lib/jmap/patch-object'; +import { mergeOverrideEntry, stableJson } from '@/lib/recurrence-instances'; +import type { StoreContext } from '../area'; +import type { StorageAdapter } from '../storage'; +import { currentGen, deleteEventRow, eventRow, valueAtPointer, writeEvent, type CalendarObject, type EventRow } from './records'; + +export interface PendingRow { + seq: number; + jmap: string; + record_id: string; + kind: 'create' | 'update' | 'destroy'; + patch: string; + base: string; + uid: string | null; + send_scheduling: number | null; + attempted: number; + rev: number; + queued_at: number; +} + +export type ConflictReason = 'refused' | 'changedOnServer' | 'destroyedOnServer'; + +export interface CalendarConflict { + id: number; + jmap: string; + recordId: string; + kind: PendingRow['kind']; + reason: ConflictReason; + errorType: string | null; + description: string | null; + title: string | null; + /** The write that wasn't made: the whole event for a create, otherwise the patch. */ + local: Record; + /** The event as the server has it, when it still does. */ + server: Record | null; + createdAt: number; +} + +const STAND_IN_PREFIX = 'local-'; + +export function isStandIn(id: string): boolean { + return id.startsWith(STAND_IN_PREFIX); +} + +export function pendingFor(db: StorageAdapter, jmap: string, id: string): PendingRow | undefined { + return db.get('SELECT * FROM calendar_pending WHERE jmap = ? AND record_id = ?', [jmap, id]); +} + +/** What a stand-in id became, once its create reached the server. */ +export function resolveId(db: StorageAdapter, jmap: string, id: string): string { + if (!isStandIn(id)) return id; + return db.get<{ server_id: string }>('SELECT server_id FROM calendar_id_map WHERE jmap = ? AND local_id = ?', [jmap, id])?.server_id ?? id; +} + +function mergedScheduling(old: number | null, next: boolean | undefined): number | null { + if (next === undefined) return old; + return old === 1 || next ? 1 : 0; +} + +function serverCopy(row: EventRow): CalendarObject { + return JSON.parse(row.server_json ?? row.json) as CalendarObject; +} + +function changed(ctx: StoreContext, ...collections: string[]): void { + ctx.emit({ type: 'changed', account: ctx.account, collections }); +} + +/** Creates a local event under a stand-in id. The uid is what finds it again after a lost response. */ +export function queueCreate(ctx: StoreContext, jmap: string, event: Record, sendScheduling: boolean | undefined): { id: string; uid: string } { + const id = `${STAND_IN_PREFIX}${crypto.randomUUID()}`; + const uid = typeof event.uid === 'string' && event.uid ? event.uid : crypto.randomUUID(); + const payload = { '@type': 'Event', ...event, uid }; + ctx.db.run( + `INSERT INTO calendar_pending (jmap, record_id, kind, patch, uid, send_scheduling, queued_at) VALUES (?, ?, 'create', ?, ?, ?, ?)`, + [jmap, id, JSON.stringify(payload), uid, sendScheduling === undefined ? null : Number(sendScheduling), ctx.now()], + ); + writeEvent(ctx.db, jmap, { shown: { ...payload, id, isOrigin: true }, gen: currentGen(ctx.db, jmap) }); + return { id, uid }; +} + +/** False when the store has no such event. */ +export function queueUpdate(ctx: StoreContext, jmap: string, id: string, patch: Record, sendScheduling: boolean | undefined): boolean { + const row = eventRow(ctx.db, jmap, id); + if (!row || row.hidden) return false; + const pending = pendingFor(ctx.db, jmap, id); + const gen = row.gen; + if (pending?.kind === 'create') { + const payload = applyPatchObject(JSON.parse(pending.patch) as Record, patch); + ctx.db.run( + 'UPDATE calendar_pending SET patch = ?, send_scheduling = ?, rev = rev + 1 WHERE seq = ?', + [JSON.stringify(payload), mergedScheduling(pending.send_scheduling, sendScheduling), pending.seq], + ); + writeEvent(ctx.db, jmap, { shown: { ...payload, id, isOrigin: true } as CalendarObject, gen }); + return true; + } + const server = serverCopy(row); + const earlier = pending ? JSON.parse(pending.patch) as Record : {}; + const earlierBase = pending ? JSON.parse(pending.base) as Record : {}; + const merged = mergeOverrideEntry(earlier, patch); + const base: Record = {}; + for (const key of Object.keys(merged)) { + base[key] = key in earlierBase ? earlierBase[key] : valueAtPointer(server, key) ?? null; + } + if (pending) { + ctx.db.run( + 'UPDATE calendar_pending SET patch = ?, base = ?, send_scheduling = ?, rev = rev + 1 WHERE seq = ?', + [JSON.stringify(merged), JSON.stringify(base), mergedScheduling(pending.send_scheduling, sendScheduling), pending.seq], + ); + } else { + ctx.db.run( + `INSERT INTO calendar_pending (jmap, record_id, kind, patch, base, send_scheduling, queued_at) VALUES (?, ?, 'update', ?, ?, ?, ?)`, + [jmap, id, JSON.stringify(merged), JSON.stringify(base), sendScheduling === undefined ? null : Number(sendScheduling), ctx.now()], + ); + } + writeEvent(ctx.db, jmap, { shown: applyPatchObject(server, merged), server, gen }); + return true; +} + +/** False when the store has no such event. */ +export function queueDestroy(ctx: StoreContext, jmap: string, id: string, sendScheduling: boolean | undefined): boolean { + const row = eventRow(ctx.db, jmap, id); + if (!row || row.hidden) return false; + const pending = pendingFor(ctx.db, jmap, id); + if (pending?.kind === 'create' && !pending.attempted) { + // It never left the device. + ctx.db.run('DELETE FROM calendar_pending WHERE seq = ?', [pending.seq]); + deleteEventRow(ctx.db, jmap, id); + return true; + } + if (pending) { + // A create that went out stays findable by its uid, so the delete can follow it. + ctx.db.run( + `UPDATE calendar_pending SET kind = 'destroy', patch = '{}', base = '{}', send_scheduling = ?, rev = rev + 1 WHERE seq = ?`, + [mergedScheduling(pending.send_scheduling, sendScheduling), pending.seq], + ); + } else { + ctx.db.run( + `INSERT INTO calendar_pending (jmap, record_id, kind, patch, send_scheduling, queued_at) VALUES (?, ?, 'destroy', '{}', ?, ?)`, + [jmap, id, sendScheduling === undefined ? null : Number(sendScheduling), ctx.now()], + ); + } + const shown = JSON.parse(row.json) as CalendarObject; + writeEvent(ctx.db, jmap, { + shown, + server: pending?.kind === 'create' ? null : serverCopy(row), + hidden: true, + gen: row.gen, + }); + return true; +} + +/** Whether the server moved a value this patch writes, to something other than what the patch writes. */ +function collides(pending: PendingRow, server: Record): boolean { + const base = JSON.parse(pending.base) as Record; + const patch = JSON.parse(pending.patch) as Record; + return Object.keys(patch).some((key) => { + const now = stableJson(valueAtPointer(server, key)); + return now !== stableJson(base[key]) && now !== stableJson(patch[key]); + }); +} + +/** + * Puts a write on the conflict list and takes it off the event, which goes back + * to the server's copy. A refused create leaves nothing behind. + */ +export function recordConflict( + ctx: StoreContext, + pending: PendingRow, + reason: ConflictReason, + error?: { type?: string; description?: string }, +): void { + const { db } = ctx; + const row = eventRow(db, pending.jmap, pending.record_id); + const shown = row ? JSON.parse(row.json) as Record : null; + const known = row && pending.kind !== 'create' && !(pending.kind === 'destroy' && pending.uid); + const server = known && reason !== 'destroyedOnServer' ? serverCopy(row) : null; + db.run( + `INSERT INTO calendar_conflict (jmap, record_id, kind, reason, error_type, description, title, local, server, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + pending.jmap, pending.record_id, pending.kind, reason, error?.type ?? null, error?.description ?? null, + typeof shown?.title === 'string' ? shown.title : null, pending.patch, server ? JSON.stringify(server) : null, ctx.now(), + ], + ); + db.run('DELETE FROM calendar_pending WHERE seq = ?', [pending.seq]); + if (row) { + if (server) writeEvent(db, pending.jmap, { shown: server, gen: row.gen }); + else deleteEventRow(db, pending.jmap, pending.record_id); + } + changed(ctx, 'CalendarEvent', 'CalendarConflict'); +} + +/** The server's copy of an event arriving during sync. Any pending write goes back on top, or to the conflict list. */ +export function applyServerEvent(ctx: StoreContext, jmap: string, event: CalendarObject, gen: number): void { + const { db } = ctx; + const pending = pendingFor(db, jmap, event.id); + if (!pending) { + writeEvent(db, jmap, { shown: event, gen }); + return; + } + const row = eventRow(db, jmap, event.id); + if (pending.kind === 'destroy') { + const previous = row ? stableJson(serverCopy(row)) : null; + if (previous !== null && previous !== stableJson(event)) { + // Deleting what someone just changed is the one write that can't be taken + // back, so the change wins and the user decides. + writeEvent(db, jmap, { shown: JSON.parse(row!.json) as CalendarObject, server: event, hidden: true, gen }); + recordConflict(ctx, pending, 'changedOnServer'); + return; + } + writeEvent(db, jmap, { shown: event, server: event, hidden: true, gen }); + return; + } + writeEvent(db, jmap, { shown: applyPatchObject(event, JSON.parse(pending.patch) as Record), server: event, gen }); + if (collides(pending, event)) recordConflict(ctx, pending, 'changedOnServer'); +} + +/** The server no longer has this event. A pending delete is done; a pending edit has nothing left to change. */ +export function applyServerDestroy(ctx: StoreContext, jmap: string, id: string): void { + const pending = pendingFor(ctx.db, jmap, id); + if (pending?.kind === 'update') { + recordConflict(ctx, pending, 'destroyedOnServer'); + return; + } + if (pending) ctx.db.run('DELETE FROM calendar_pending WHERE seq = ?', [pending.seq]); + deleteEventRow(ctx.db, jmap, id); +} + +export function listConflicts(db: StorageAdapter): CalendarConflict[] { + const rows = db.all<{ + id: number; jmap: string; record_id: string; kind: PendingRow['kind']; reason: ConflictReason; error_type: string | null; + description: string | null; title: string | null; local: string; server: string | null; created_at: number; + }>('SELECT * FROM calendar_conflict ORDER BY id'); + return rows.map((row) => ({ + id: Number(row.id), + jmap: row.jmap, + recordId: row.record_id, + kind: row.kind, + reason: row.reason, + errorType: row.error_type, + description: row.description, + title: row.title, + local: JSON.parse(row.local) as Record, + server: row.server ? JSON.parse(row.server) as Record : null, + createdAt: Number(row.created_at), + })); +} diff --git a/lib/store/engine/account-engine.ts b/lib/store/engine/account-engine.ts index 6281afc71..ab3a2b9b5 100644 --- a/lib/store/engine/account-engine.ts +++ b/lib/store/engine/account-engine.ts @@ -217,7 +217,7 @@ export class AccountEngine { async areaCall(name: string, params: unknown): Promise { const call = this.calls.get(name); if (!call) throw new Error(`Unknown store call ${name}`); - return call(this.storeContext(), params as never); + return call(this.syncContext(), params as never); } /** An answer from the device. Null when nothing in the request could be answered. */ diff --git a/stores/__tests__/calendar-store-offline-occurrence.test.ts b/stores/__tests__/calendar-store-offline-occurrence.test.ts new file mode 100644 index 000000000..4cccb256a --- /dev/null +++ b/stores/__tests__/calendar-store-offline-occurrence.test.ts @@ -0,0 +1,117 @@ +// @vitest-environment node +import { afterEach, describe, expect, it } from 'vitest'; +import { calendarArea } from '@/lib/store/calendar'; +import type { CalendarConflict } from '@/lib/store/calendar/writes'; +import { FakeCalendarJmap } from '@/lib/store/__tests__/helpers/fake-calendar'; +import { wire, type Wire } from '@/lib/store/__tests__/helpers/client-wire'; +import { isBrowserExpandedOccurrence, isServerRecurrenceInstance } from '@/lib/recurrence-instances'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import { useCalendarStore } from '../calendar-store'; + +const NOW = Date.UTC(2026, 10, 3, 12); +const START = '2026-11-02T00:00:00'; +const END = '2026-11-23T00:00:00'; +const ME = { '@type': 'Participant', calendarAddress: 'mailto:a@example.org', participationStatus: 'needs-action', roles: { attendee: true } }; +const ORGANIZER = { '@type': 'Participant', calendarAddress: 'mailto:boss@example.org', participationStatus: 'accepted', roles: { owner: true } }; + +let current: Wire | null = null; + +afterEach(() => { + current?.restore(); + current = null; + useCalendarStore.setState({ events: [], calendars: [] }); +}); + +async function online() { + const server = new FakeCalendarJmap('1.0'); + server.addEvent({ + id: 'series', + title: 'Weekly', + start: '2026-11-02T10:00:00', + recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'weekly' }, + organizerCalendarAddress: 'mailto:boss@example.org', + participants: { me: ME, boss: ORGANIZER }, + }); + const w = wire(server, [calendarArea], NOW); + current = w; + await w.engine.requestSync(); + const client = await w.newClient() as unknown as IJMAPClient; + await client.getCalendars(); + await useCalendarStore.getState().fetchEvents(client, START, END); + return { w, client }; +} + +const overrides = (w: Wire) => w.server.events.get('series')?.recurrenceOverrides as Record> | undefined; +const flush = (w: Wire) => w.engine.areaCall('calendar.flush', undefined) as Promise<{ sent: number; conflicts: number; pending: number }>; +const conflicts = (w: Wire) => w.engine.areaCall('calendar.conflicts', undefined) as Promise; +const occurrenceOn = (day: string) => useCalendarStore.getState().events.find((event) => event.start.startsWith(day)); + +describe('an occurrence on screen from before the connection went', () => { + it('RSVP: goes in as an override on the master, shows after the refetch, reaches the server', async () => { + const { w, client } = await online(); + const instance = occurrenceOn('2026-11-09'); + expect(instance && isServerRecurrenceInstance(instance)).toBe(true); + w.online = false; + + await useCalendarStore.getState().rsvpEvent(client, instance!.id, 'me', 'accepted', undefined, 'occurrence'); + expect(overrides(w)).toBeUndefined(); + + // The refetch after the answer came from the store, with occurrences of its own. + const again = occurrenceOn('2026-11-09'); + expect(again && isBrowserExpandedOccurrence(again)).toBe(true); + expect(again?.participants?.me.participationStatus).toBe('accepted'); + expect(occurrenceOn('2026-11-16')?.participants?.me.participationStatus).toBe('needs-action'); + + w.online = true; + expect(await flush(w)).toMatchObject({ sent: 1, pending: 0 }); + expect(await conflicts(w)).toEqual([]); + const entry = overrides(w)?.['2026-11-09T10:00:00'] as { participants?: Record }; + expect(entry.participants?.me.participationStatus).toBe('accepted'); + }); + + it('RSVP for the whole series: a pointer patch on the master, every occurrence shows it', async () => { + const { w, client } = await online(); + const instance = occurrenceOn('2026-11-09'); + w.online = false; + await useCalendarStore.getState().rsvpEvent(client, instance!.id, 'me', 'accepted'); + expect(occurrenceOn('2026-11-16')?.participants?.me.participationStatus).toBe('accepted'); + w.online = true; + expect(await flush(w)).toMatchObject({ sent: 1, pending: 0 }); + const master = w.server.events.get('series') as unknown as { participants: Record }; + expect(master.participants.me.participationStatus).toBe('accepted'); + expect(master.participants.boss.participationStatus).toBe('accepted'); + }); + + it('delete of one occurrence: excluded on the master, gone from the grid', async () => { + const { w, client } = await online(); + const instance = occurrenceOn('2026-11-16'); + w.online = false; + await useCalendarStore.getState().deleteEvent(client, instance!.id); + expect(occurrenceOn('2026-11-16')).toBeUndefined(); + expect(occurrenceOn('2026-11-09')).toBeTruthy(); + w.online = true; + await flush(w); + expect(overrides(w)?.['2026-11-16T10:00:00']).toMatchObject({ excluded: true }); + }); + + it('edit of one occurrence, then another, of the same series: both reach the server in one write', async () => { + const { w, client } = await online(); + w.online = false; + await useCalendarStore.getState().updateEvent(client, occurrenceOn('2026-11-09')!.id, { title: 'First change' }); + expect(occurrenceOn('2026-11-09')?.title).toBe('First change'); + await useCalendarStore.getState().updateEvent(client, occurrenceOn('2026-11-16')!.id, { title: 'Second change' }); + expect(occurrenceOn('2026-11-09')?.title).toBe('First change'); + expect(occurrenceOn('2026-11-16')?.title).toBe('Second change'); + expect(occurrenceOn('2026-11-02')?.title).toBe('Weekly'); + + w.online = true; + const before = w.server.calls.length; + await flush(w); + const writes = w.server.calls.slice(before).filter(([name]) => name === 'CalendarEvent/set'); + expect(writes).toHaveLength(2); // series keys refused once by 1.0, then without them + expect(await conflicts(w)).toEqual([]); + expect(overrides(w)?.['2026-11-09T10:00:00']).toMatchObject({ title: 'First change' }); + expect(overrides(w)?.['2026-11-16T10:00:00']).toMatchObject({ title: 'Second change' }); + expect(overrides(w)?.['2026-11-09T10:00:00']).not.toHaveProperty('organizerCalendarAddress'); + }); +}); From 41987cb5cfcdac68770e0596672e284678186935 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 06:16:44 +0100 Subject: [PATCH 6/9] fix: stop hostile HTML stalling mail sync and index each message only once --- lib/store/__tests__/search.test.ts | 171 ++++++++++++++++++++++++++++- lib/store/mail/records.ts | 114 +++++++++++++++---- lib/store/mail/search-schema.ts | 59 +++++++--- lib/store/mail/search.ts | 29 +++-- 4 files changed, 321 insertions(+), 52 deletions(-) diff --git a/lib/store/__tests__/search.test.ts b/lib/store/__tests__/search.test.ts index 478c277f4..711d48539 100644 --- a/lib/store/__tests__/search.test.ts +++ b/lib/store/__tests__/search.test.ts @@ -5,6 +5,7 @@ import { parseSearchSnippet, snippetFilterFor } from '@/lib/search-snippet'; import { DEFAULT_SCOPE } from '../area'; import { AccountEngine } from '../engine/account-engine'; import { mailArea } from '../mail'; +import { bodyText, deleteEmail } from '../mail/records'; import { FAKE_SESSION, FakeJmap } from './helpers/fake-jmap'; import { openMemoryStore } from './helpers/node-adapter'; @@ -14,12 +15,12 @@ function withBody(value: string): Email { return { bodyValues: { 1: { value, isTruncated: false, isEncodingProblem: false } } }; } -async function storeWith(emails: Email[]) { +async function storeWith(emails: Email[], db = openMemoryStore()) { const server = new FakeJmap(); for (const email of emails) server.addEmail(email); const engine = new AccountEngine({ account: 'a@fake.test', - db: openMemoryStore(), + db, session: FAKE_SESSION, transport: server, areas: [mailArea], @@ -45,7 +46,19 @@ async function storeWith(emails: Email[]) { notFound: string[]; }; }; - return { engine, query, snippets }; + return { engine, query, snippets, db }; +} + +/** A store that records the statements it runs. */ +function recordingStore() { + const db = openMemoryStore(); + const statements: string[] = []; + const run = db.run.bind(db); + db.run = (sql, params) => { + statements.push(sql); + return run(sql, params); + }; + return { db, statements }; } const alice = [{ name: 'Alice Adler', email: 'alice@example.org' }]; @@ -197,6 +210,140 @@ describe('matching', () => { }); }); +describe('the split indexes', () => { + it('writes each index once for a message, with no delete when its body arrives', async () => { + const { db, statements } = recordingStore(); + await storeWith([{ id: 'one', subject: 'Hallo', ...withBody('Inhalt') }], db); + const fts = statements.filter((sql) => sql.includes('email_fts')); + expect(fts.filter((sql) => sql.startsWith('DELETE'))).toEqual([]); + for (const table of ['email_fts_word', 'email_fts_tri', 'email_fts_body_word', 'email_fts_body_tri']) { + expect(fts.filter((sql) => sql.startsWith(`INSERT INTO ${table} `)).length, table).toBe(1); + } + }); + + it('touches the head row again only for a message with Bcc', async () => { + const { db, statements } = recordingStore(); + const { query } = await storeWith([{ id: 'one', subject: 'Hallo', bcc: dave }], db); + const fts = statements.filter((sql) => sql.includes('email_fts')); + expect(fts.filter((sql) => sql.startsWith('DELETE FROM email_fts_word')).length).toBe(1); + expect(fts.filter((sql) => sql.startsWith('DELETE FROM email_fts_body'))).toEqual([]); + // Nothing of the head was lost in writing it again. + expect(await query({ subject: 'hallo' })).toEqual(['one']); + expect(await query({ bcc: 'dave' })).toEqual(['one']); + }); + + it('finds head fields only in the head and body text only in the body', async () => { + const { query } = await storeWith([ + { id: 'in-subject', subject: 'Stromrechnung', ...withBody('nichts') }, + { id: 'in-body', subject: 'Hallo', ...withBody('Die Stromrechnung') }, + { id: 'from-word', subject: 'Hallo', from: [{ name: 'Stromrechnung', email: 'x@example.org' }], ...withBody('nichts') }, + ]); + expect(await query({ text: 'rechnung' })).toEqual(['in-subject', 'in-body', 'from-word']); + expect(await query({ text: 'stromrechnung' })).toEqual(['in-subject', 'in-body', 'from-word']); + expect(await query({ subject: 'rechnung' })).toEqual(['in-subject']); + expect(await query({ body: 'rechnung' })).toEqual(['in-body']); + expect(await query({ from: 'rechnung' })).toEqual(['from-word']); + }); + + it('keeps each word of a search free to match in the head or the body', async () => { + const { query } = await storeWith([{ id: 'one', subject: 'Rechnung', ...withBody('Oktober') }, { id: 'two', subject: 'Rechnung' }]); + expect(await query({ text: 'rechnung oktober' })).toEqual(['one']); + }); + + it('caps the trigrams of a body at 32 KB and leaves its words to 200 KB', async () => { + const filler = 'fuellwort '.repeat(4000); + const { query } = await storeWith([ + { id: 'early', subject: 'x', ...withBody('Stromrechnung am Anfang') }, + { id: 'late', subject: 'x', ...withBody(`${filler} Gasrechnung am Ende`) }, + { id: 'past', subject: 'x', ...withBody(`${filler.repeat(6)} Wasserrechnung`) }, + ]); + expect(await query({ body: 'rechnung' })).toEqual(['early']); + // Past the trigram cap the whole word and its start are still found. + expect(await query({ body: 'gasrechnung' })).toEqual(['late']); + expect(await query({ body: 'gasrech*' })).toEqual(['late']); + // Past the word cap too: not indexed at all. + expect(await query({ body: 'wasserrechnung' })).toEqual([]); + }); + + it('removes a message from all four indexes', async () => { + const { db, query } = await storeWith([{ id: 'one', subject: 'Hallo', ...withBody('Inhalt') }]); + const jmap = String(db.get<{ jmap: string }>('SELECT jmap FROM email')?.jmap); + deleteEmail(db, jmap, 'one'); + for (const table of ['email_fts_word', 'email_fts_tri']) expect(db.all(`SELECT rowid FROM ${table} WHERE ${table} MATCH ?`, ['"hallo"'])).toEqual([]); + for (const table of ['email_fts_body_word', 'email_fts_body_tri']) expect(db.all(`SELECT rowid FROM ${table} WHERE ${table} MATCH ?`, ['"inhalt"'])).toEqual([]); + expect(await query({ text: 'inhalt' })).toEqual([]); + }); + + it('sets the options for bulk writes', async () => { + const { db } = await storeWith([]); + const option = (table: string, key: string) => Number(db.get<{ v: number }>(`SELECT v FROM ${table}_config WHERE k = ?`, [key])?.v); + expect(option('email_fts_body_word', 'hashsize')).toBe(8 * 1024 * 1024); + expect(option('email_fts_body_tri', 'hashsize')).toBe(16 * 1024 * 1024); + for (const table of ['email_fts_word', 'email_fts_tri', 'email_fts_body_word', 'email_fts_body_tri']) { + expect(option(table, 'automerge'), table).toBe(8); + } + }); +}); + +describe('bodyText', () => { + const html = (value: string) => ({ + textBody: [], + htmlBody: [{ partId: '1', type: 'text/html' }], + bodyValues: { 1: { value } }, + }); + const MEGABYTE = 1_000_000; + const HOSTILE: Record = { + 'opening brackets': '<'.repeat(MEGABYTE), + 'comment openers': 'b
kept

text

', + )).trim()).toBe('kept text'); + }); + + it('leaves the text of a block that never closes', () => { + expect(bodyText(html('before