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..7ad419076 --- /dev/null +++ b/app/api/push/action/route.ts @@ -0,0 +1,315 @@ +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 type { SettingsPolicy } from '@/lib/admin/types'; +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}$/; +const MAILBOX_PROPERTIES = ['id', 'name', 'parentId', 'role', 'myRights']; + +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), + }; +} + +function emailSetOutcome(set: Record | undefined, emailId: string): 'updated' | 'notFound' | 'refused' { + const { updated, notUpdated } = (set ?? {}) as { + updated?: Record | null; + notUpdated?: Record | null; + }; + if (updated && Object.prototype.hasOwnProperty.call(updated, emailId)) return 'updated'; + return notUpdated?.[emailId]?.type === 'notFound' ? 'notFound' : 'refused'; +} + +/** The signed-in logins this browser's cookies name as `login`: the notification's own, or none. */ +async function loginsNamed(login: string): Promise { + const cookieStore = await cookies(); + 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 policy for this login's scopes. The worker's copies of the settings + * decide what a toast offers; this decides what works. Null when the scopes + * can't be worked out, and then nothing is done from here. + */ +async function policyFor(ctx: StalwartAuthContext, trusted: boolean): Promise { + await configManager.ensureLoaded(); + const subject = await strictDeviceSubject({ ...ctx, trusted }); + return subject ? resolvePolicy(configManager.getPolicy(), subject).policy : null; +} + +/** 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 the worker doesn't come here while + * an enabled plugin may stop or watch these actions. + */ +export async function POST(request: NextRequest) { + const rejected = rejectCrossOriginRequest(request); + 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'); + } + + // A session limit is the page's to apply (activity, sign-in time): acting + // from here would keep a login going past it. The app opens and applies it. + const policy = await policyFor(ctx, trusted); + const limits = policy?.organisation?.session; + if (!policy || (limits?.maxHours ?? 0) > 0 || (limits?.idleMinutes ?? 0) > 0) return fail(401, 'Not authenticated'); + if (body.action === 'archive' && policy.features?.archiveActionEnabled === false) return fail(403, 'Archive is turned off'); + + const jmap = async (methodCalls: unknown[]): Promise => { + const res = await fetchJmapServer(apiUrl, { + method: 'POST', + 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]; + + const readFolders = async (): Promise => { + const read = await jmap([['Mailbox/get', { accountId, ids: null, properties: MAILBOX_PROPERTIES }, 'mailboxes']]); + const list = read && result(read, 'Mailbox/get', 'mailboxes')?.list; + return Array.isArray(list) ? (list as JmapMailbox[]) : null; + }; + + const read = await jmap([ + ['Mailbox/get', { accountId, ids: null, properties: MAILBOX_PROPERTIES }, 'mailboxes'], + ['Email/get', { accountId, ids: [emailId], properties: ['id', 'receivedAt', 'mailboxIds'] }, 'email'], + ]); + if (!read) return fail(502, 'JMAP request failed'); + let mailboxes = result(read, 'Mailbox/get', 'mailboxes')?.list as JmapMailbox[] | undefined; + const emails = result(read, 'Email/get', 'email')?.list as { receivedAt?: string; mailboxIds?: Record }[] | undefined; + if (!Array.isArray(mailboxes) || !Array.isArray(emails)) return fail(502, 'JMAP request failed'); + const email = emails[0]; + if (!email?.receivedAt) return fail(404, 'Message not found'); + // Since the notification it may have gone to Trash or Junk: not for a toast + // to bring back or to mark. The app opens it. + const bins = new Set(mailboxes.filter((m) => m.role === 'trash' || m.role === 'junk').map((m) => m.id)); + if (Object.keys(email.mailboxIds ?? {}).some((id) => bins.has(id))) return fail(409, 'Message is in Trash or Junk'); + + let answer: ReturnType = 'refused'; + const createdFolders: string[] = []; + // Twice at most: another client may make the same folder between our read + // and our write, and then the folder is there to file into. + for (let attempt = 0; attempt < 2; attempt++) { + let update: Record = { 'keywords/$seen': true }; + let create: Record> = {}; + if (body.action === 'archive') { + const planned = archiveDestination(mailboxes, body.archiveMode, email.receivedAt, body.timeZone); + // Only an explicit refusal stops it, as in the app. + if (planned.create.archive && account?.accountCapabilities?.[MAIL_CAPABILITY]?.mayCreateTopLevelMailbox === false) { + return fail(403, 'Archive folder is missing'); + } + if (mailboxes.find((m) => m.id === planned.destination)?.myRights?.mayAddItems === false) { + return fail(403, 'Archive folder is read-only'); + } + create = planned.create; + update = { mailboxIds: { [planned.destination]: true } }; + } + const written = await jmap([ + ...(Object.keys(create).length > 0 ? [['Mailbox/set', { accountId, create }, 'folders']] : []), + ['Email/set', { accountId, update: { [emailId]: update } }, 'write'], + ]); + if (!written) return fail(502, 'JMAP request failed'); + const folders = result(written, 'Mailbox/set', 'folders') as { + created?: Record | null; + notCreated?: Record | null; + } | undefined; + // Deepest first, for removing them again. + for (const cid of ['month', 'year', 'archive']) { + const id = folders?.created?.[cid]?.id; + if (id) createdFolders.push(id); + } + answer = emailSetOutcome(result(written, 'Email/set', 'write'), emailId); + if (answer !== 'refused' || Object.keys(folders?.notCreated ?? {}).length === 0 || attempt > 0) break; + const fresh = await readFolders(); + if (!fresh) break; + mailboxes = fresh; + } + + if (answer === 'updated') return NextResponse.json({ ok: true }, { headers: { 'Cache-Control': 'no-store' } }); + // Nothing went in: no empty folder left behind. The server refuses to + // destroy one someone else has filed into since. + if (createdFolders.length > 0) await jmap([['Mailbox/set', { accountId, destroy: createdFolders }, 'cleanup']]).catch(() => null); + if (answer === 'notFound') return fail(404, 'Message not found'); + return fail(502, 'JMAP server refused the change'); + } catch (error) { + if (error instanceof DisallowedUrlError) { + 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/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index 1bccba921..6e766ab98 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -155,6 +155,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = // The server's reason for a refused change, where the user can act on it. const describeError = useCalendarErrorMessage(); const tSidebar = useTranslations("sidebar"); + const tOffline = useTranslations("calendar_offline"); useDocumentTitle(tSidebar("calendar")); const skeletonLabels = useSkeletonLabels(); const tWebcalAction = useTranslations("calendar.webcal_action"); @@ -1446,9 +1447,13 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = debug.error("Failed to refresh calendar after scope operation"); } } catch (error) { + if (error instanceof Error && error.name === "SeriesSplitNeedsServerError") { + toast.error(tOffline("split_needs_server")); + return; + } toast.error(describeError(error, t(type === "delete" ? "notifications.event_delete_error" : "notifications.event_error"))); } - }, [client, pendingScopeAction, updateEvent, deleteEvent, createEvent, findMasterEvent, currentUserEmails, resolveContactName, refetchCurrentRange, submitRsvp, describeError, t]); + }, [client, pendingScopeAction, updateEvent, deleteEvent, createEvent, findMasterEvent, currentUserEmails, resolveContactName, refetchCurrentRange, submitRsvp, describeError, t, tOffline]); // A drag or resize. A series asks which occurrences it covers, as the // editor does; one event moves at once and can be moved back. 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/components/mail/__tests__/blob-routing.test.tsx b/components/mail/__tests__/blob-routing.test.tsx index 59b96c183..29c180bbb 100644 --- a/components/mail/__tests__/blob-routing.test.tsx +++ b/components/mail/__tests__/blob-routing.test.tsx @@ -800,6 +800,25 @@ function expectProBlob(route: typeof proRoutes[number], action: ProBlobAction, i } describe('Pro blob routing', () => { + it("opens a compose tab with the typed text for a notification's Reply, once, when the message is in", async () => { + const { requestTabReply } = await import('@/lib/pwa-notify/tab-reply'); + const { useProTabStore } = await import('@/stores/pro-tab-store'); + const { a } = await conversationClients(); + vi.spyOn(a, 'getEmail').mockResolvedValue(message()); + useEmailStore.setState({ emails: [], selectedEmail: null, isUnifiedView: false, selectedMailbox: 'inbox' }); + useAuthStore.setState({ client: a, activeAccountId: 'login-primary' }); + const composeTabs = () => useProTabStore.getState().tabs.filter((tab) => tab.kind === 'compose'); + const before = composeTabs().length; + + requestTabReply('email', 'Typed on the toast'); + const view = render(); + await waitFor(() => expect(composeTabs()).toHaveLength(before + 1)); + expect(composeTabs().at(-1)?.composeData).toMatchObject({ mode: 'reply', sourceEmailId: 'email', initialDraftText: 'Typed on the toast' }); + + view.rerender(); + expect(composeTabs()).toHaveLength(before + 1); + }); + it.each(proRoutes.flatMap(route => proBlobActions.map(action => ({ ...route, action }))))( '$label: $action uses the message owner and authenticated client', async route => { const { origin, fetchMock } = await openProMessage(route, route.action); diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index c69d7c2c7..136093b22 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -142,6 +142,9 @@ 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 { requestTabReply } from "@/lib/pwa-notify/tab-reply"; +import { consumePendingReplyText } from "@/lib/pwa-notify/worker-state"; import { INTERNAL_MAILTO_EVENT, type ParsedMailto } from "@/lib/protocol-handlers/mailto"; import { PLUGIN_REPLY_EVENT, type PluginReplyRequest } from "@/lib/plugin-sandbox/host-api-compose"; import { onOpenEmail, type OpenEmailHandler, type OpenEmailOutcome } from "@/lib/plugin-sandbox/open-email"; @@ -171,7 +174,7 @@ import { resolveFolderRef, type MailDeepLink, } from "@/lib/deep-links"; -import { consumePendingDeepLink, subscribePendingDeepLink } from "@/lib/deep-link-handoff"; +import { consumePendingDeepLinkEntry, subscribePendingDeepLink } from "@/lib/deep-link-handoff"; import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect"; import type { QuoteHeader } from "@/lib/plugin-types"; import { initialLinkSearch, useLiteLinkSegments } from "@/hooks/use-lite-link-segments"; @@ -257,6 +260,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 +1773,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; @@ -1842,6 +1847,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // A shared account's message is fetched from that account through // the active login (#1351). const loginId = link.ownerAccountId ? useAuthStore.getState().activeAccountId ?? undefined : undefined; + // A notification's Reply: the tab opens its composer once it has the message. + if (link.reply) requestTabReply(link.id, opts?.replyText ?? await consumePendingReplyText(link.id)); useProTabStore.getState().openEmailTab({ accountId: link.ownerAccountId ?? '', ...(loginId ? { clientAccountId: loginId } : {}), @@ -1867,6 +1874,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); } @@ -1921,8 +1934,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // briefly and must not steal the link parked for the Pro one. useEffect(() => { if (!isEmbedded) return; - return subscribePendingDeepLink('mail', (segments) => { - const link = parseMailPath(segments, new URLSearchParams(window.location.search)); + return subscribePendingDeepLink('mail', (segments, search) => { + const link = parseMailPath(segments, new URLSearchParams(search ?? window.location.search)); if (link) void applyMailDeepLinkRef.current(link); }); }, [isEmbedded]); @@ -1973,10 +1986,13 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { } if (!initialMailLoadDone) return; + // Inside the Pro shell the route is /pro, so the link arrives through the + // handoff the redirect parked it in rather than as route params. Its query + // comes with it: the address bar may have none left by the first render. + const parked = linkSegments ? null : consumePendingDeepLinkEntry('mail'); + if (parked?.search) initialSearchRef.current = parked.search; const params = new URLSearchParams(initialSearchRef.current ?? window.location.search); - // Inside the Pro shell the route is /pro, so the segments arrive through - // the handoff the redirect parked them in rather than as route params. - const segments = linkSegments ?? consumePendingDeepLink('mail') ?? []; + const segments = linkSegments ?? parked?.segments ?? []; const link = parseMailPath(segments, params); const openLatestUnread = params.get('openLatestUnread') === '1'; @@ -2764,6 +2780,29 @@ 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]); + const composerOpenRef = useRef(showComposer); + composerOpenRef.current = showComposer; + useEffect(() => { + 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(); + }); + }, []); + // 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/pro/pro-email-tab-body.tsx b/components/pro/pro-email-tab-body.tsx index 6040ba126..01b0693c9 100644 --- a/components/pro/pro-email-tab-body.tsx +++ b/components/pro/pro-email-tab-body.tsx @@ -28,6 +28,7 @@ import { labelSourceHeaders, reopenedLabelChoice } from '@/lib/outgoing/classifi import { useArchiveActionEnabled } from '@/lib/archive-gate'; import { confirmMailRemoval } from '@/lib/mail-removal-confirm'; import { useConfirmDialog } from '@/hooks/use-confirm-dialog'; +import { takeTabReply, useNotificationTabReply } from '@/lib/pwa-notify/tab-reply'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { saveQuickReplyDraft } from '@/lib/outgoing/quick-reply-envelope'; import { canDownload } from '@/lib/device-policy'; @@ -262,6 +263,14 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId, }, { source: email, body: draftText ?? '' }); }, [email, openComposeTab, t, writeSource]); + // A notification's Reply, once this tab has the message (mail-app.tsx). + const pendingTabReply = useNotificationTabReply((s) => s.pending); + useEffect(() => { + if (!email || pendingTabReply?.emailId !== email.id) return; + const text = takeTabReply(email.id); + if (text !== null) handleReply(text); + }, [email, pendingTabReply, handleReply]); + const handleReplyAll = useCallback(() => { if (!email) return; openComposeTab({ diff --git a/components/providers/__tests__/notification-actions-host.test.tsx b/components/providers/__tests__/notification-actions-host.test.tsx new file mode 100644 index 000000000..f786d3e39 --- /dev/null +++ b/components/providers/__tests__/notification-actions-host.test.tsx @@ -0,0 +1,170 @@ +import { act, render } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint'; +import { NotificationActionsHost } from '../notification-actions-host'; + +// The tab's end of the service worker: it keeps the worker's copy of the +// 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, plugins: [] as Array>, accounts: [] as Array<{ serverUrl: string; username: string }> }; + +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/plugin-store', () => ({ + usePluginStore: (select: (s: { plugins: unknown[] }) => unknown) => select({ plugins: state.plugins }), +})); +vi.mock('@/stores/email-store', () => ({ useEmailStore: { getState: () => ({}) } })); +vi.mock('@/stores/account-store', () => { + const useAccountStore = (select: (s: { accounts: unknown[] }) => unknown) => select({ accounts: state.accounts }); + useAccountStore.getState = () => ({ accounts: state.accounts }); + return { useAccountStore }; +}); + +const worker = Object.assign(new EventTarget(), { controller: null as null | { postMessage: ReturnType } }); +const fromWorker = (data: unknown, ports: unknown[] = []) => act(async () => { + const event = new Event('message'); + Object.assign(event, { data, ports }); + worker.dispatchEvent(event); +}); + +beforeEach(() => { + vi.clearAllMocks(); + state = { isAuthenticated: true, archiveMode: 'month', archiveEnabled: true, plugins: [], accounts: [] }; + worker.controller = null; + 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', + pluginsMayVeto: false, + 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('what the worker hears about plugins and logins', () => { + it('says when an enabled plugin may stop these actions, and when that ends', () => { + state.plugins = [{ enabled: true, permissions: ['email:write'] }]; + const { rerender } = render(); + expect(writeNotificationActionPrefs.mock.calls.at(-1)?.[0]).toMatchObject({ pluginsMayVeto: true }); + state.plugins = [{ enabled: false, permissions: ['email:write'] }]; + rerender(); + expect(writeNotificationActionPrefs.mock.calls.at(-1)?.[0]).toMatchObject({ pluginsMayVeto: false }); + }); + + it('tells the worker which logins this tab holds, as their fingerprints', async () => { + const postMessage = vi.fn(); + worker.controller = { postMessage }; + state.accounts = [{ serverUrl: 'https://one.example', username: 'alice@example.org' }]; + await act(async () => { render(); }); + await vi.waitFor(() => expect(postMessage).toHaveBeenCalled()); + expect(postMessage).toHaveBeenCalledWith({ + type: 'client-logins', + logins: [await loginFingerprint('https://one.example', 'alice@example.org')], + }); + }); + + it('tells it again when the logins change', async () => { + const postMessage = vi.fn(); + worker.controller = { postMessage }; + state.accounts = [{ serverUrl: 'https://one.example', username: 'alice@example.org' }]; + let view!: ReturnType; + await act(async () => { view = render(); }); + await vi.waitFor(() => expect(postMessage).toHaveBeenCalledTimes(1)); + state.accounts = []; + await act(async () => { view.rerender(); }); + await vi.waitFor(() => expect(postMessage).toHaveBeenCalledTimes(2)); + expect(postMessage).toHaveBeenLastCalledWith({ type: 'client-logins', logins: [] }); + }); +}); + +describe('messages from the worker', () => { + const request = { kind: 'notification-action', action: 'mark-read', emailId: 'e1', accountId: 'a', slot: 0, login: 'fp', at: Date.now() }; + + 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..4077951d3 --- /dev/null +++ b/components/providers/notification-actions-host.tsx @@ -0,0 +1,83 @@ +'use client'; + +import { useEffect, useMemo } from 'react'; +import { useTranslations } from 'next-intl'; +import { useArchiveActionEnabled } from '@/lib/archive-gate'; +import { scheduleFlushAll } from '@/lib/offline/offline-mode'; +import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint'; +import { isNotificationActionRequest, runNotificationAction } from '@/lib/pwa-notify/page-actions'; +import { pluginsMayVeto } from '@/lib/pwa-notify/plugin-veto'; +import { writeNotificationActionPrefs } from '@/lib/pwa-notify/worker-state'; +import { useAccountStore } from '@/stores/account-store'; +import { useAuthStore } from '@/stores/auth-store'; +import { usePluginStore } from '@/stores/plugin-store'; +import { useSettingsStore } from '@/stores/settings-store'; + +/** + * This tab's end of what the service worker can't do alone: it keeps the + * worker's copy of the labels and archive settings current, says which logins + * it holds, runs a notification's buttons and flushes the outbox on the + * worker's word. Not mounted in Lite (no service worker there). + */ +export function NotificationActionsHost() { + const t = useTranslations('pwaNotify'); + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + const archiveMode = useSettingsStore((s) => s.archiveMode); + const archiveEnabled = useArchiveActionEnabled(); + const mayVeto = usePluginStore((s) => pluginsMayVeto(s.plugins)); + const accounts = useAccountStore((s) => s.accounts); + const archive = t('action_archive'); + const markRead = t('action_mark_read'); + const reply = t('action_reply'); + 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, + pluginsMayVeto: mayVeto, + labels: { archive, markRead, reply, replyPlaceholder }, + }); + }, [isAuthenticated, archiveEnabled, archiveMode, mayVeto, archive, markRead, reply, replyPlaceholder]); + + // Which logins this tab holds: if it stays silent on a click for one of them, + // the worker opens the message rather than go round its plugins. + const loginKey = useMemo(() => accounts.map((a) => `${a.serverUrl}\n${a.username}`).join('\n\n'), [accounts]); + useEffect(() => { + const worker = navigator.serviceWorker; + if (!worker) return; + let stale = false; + void (async () => { + try { + const held = await Promise.all(useAccountStore.getState().accounts.map((a) => loginFingerprint(a.serverUrl, a.username))); + // Without a worker `ready` never settles, and nothing needs telling. + const target = worker.controller ?? (await worker.ready).active; + if (!stale) target?.postMessage({ type: 'client-logins', logins: held }); + } catch { + // The worker counts this tab as not holding them. + } + })(); + return () => { stale = true; }; + }, [loginKey, isAuthenticated]); + + useEffect(() => { + const worker = navigator.serviceWorker; + 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..5867c9c29 --- /dev/null +++ b/integration/tests/23-do-pwa-notify.spec.ts @@ -0,0 +1,566 @@ +import { test, expect, type BrowserContext, type Page, type Worker } from '@playwright/test'; +import { JMAP_URL } from './helpers/config'; +import { ENT, adminContext, deliver, jmapAs, requireEntEnv, userPage, withPolicy } 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 an enabled plugin that may stop it, the route is skipped and the message opens', async () => { + const mail = await oneUnread(); + const shown = await pushed(sw, mail.accountId, mail.id); + await sw.evaluate(async () => { + const cache = await caches.open('bulwark-push-state-v1'); + const key = `${self.location.origin}/__push-state/actions`; + const prefs = await (await cache.match(key))!.json(); + await cache.put(key, new Response(JSON.stringify({ ...prefs, pluginsMayVeto: true }))); + }); + await closeTheApp(); + await routePosts(sw); + await click(sw, 'mark-read', shown.data); + expect(await routePosts(sw)).toEqual([]); + expect((await opened(sw))[0]).toContain(`/mail/message/${mail.id}`); + expect((await serverCopy(mail.id)).keywords.$seen).toBeFalsy(); + }); + + test('a message since moved to Trash stays there, and opens in the app', async () => { + const mail = await oneUnread(); + const shown = await pushed(sw, mail.accountId, mail.id); + await closeTheApp(); + const client = await jmapAs(USER); + const trash = await client.mailboxByRole('trash'); + await client.moveEmail(mail.id, trash!.id); + await click(sw, 'archive', shown.data); + expect((await opened(sw))[0]).toContain(`/mail/message/${mail.id}`); + expect(Object.keys((await serverCopy(mail.id)).mailboxIds)).toEqual([trash!.id]); + }); + + test('with the context cookie gone the message opens instead, and stays as it was', async () => { + const mail = await oneUnread(); + const shown = await pushed(sw, mail.accountId, mail.id); + 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('Reply in the Pro shell', () => { + test('opens the composer in the message tab, with the typed text, from the live tab and from the link', async ({ browser }) => { + const page = await userPage(browser, USER); + const context = page.context(); + await context.grantPermissions(['notifications']); + await page.evaluate(() => { + const raw = localStorage.getItem('settings-storage'); + const stored = raw ? JSON.parse(raw) : { state: {} }; + stored.state = { ...stored.state, proInterface: true }; + localStorage.setItem('settings-storage', JSON.stringify(stored)); + }); + await page.goto('/pro'); + await expect(page.locator('[data-pro-takeover]')).toHaveCount(0, { timeout: 60_000 }); + const sw = await workerOf(context, page); + const replyIsOpen = (subject: string) => page.evaluate((text) => { + const inputs = [...document.querySelectorAll('input')] as HTMLInputElement[]; + return inputs.some((input) => input.value === `Re: ${text}`); + }, subject); + + const live = await oneUnread(); + const liveShown = await pushed(sw, live.accountId, live.id); + // Until the shell's mail surface is up the tab declines, and the worker + // falls back to the link: ask again. Taken once, no window opens. + await expect.poll(async () => { + await click(sw, 'reply', liveShown.data, `Pro typed ${RUN}`); + return (await opened(sw)).length; + }, { timeout: 45_000, intervals: [3_000] }).toBe(0); + await expect.poll(() => replyIsOpen(live.subject), { timeout: 30_000 }).toBe(true); + await expect(page.getByText(`Pro typed ${RUN}`).first()).toBeVisible({ timeout: 10_000 }); + + const linked = await oneUnread(); + await page.goto(`/mail/message/${linked.id}?owner=${linked.accountId}&reply=1`); + await expect.poll(() => replyIsOpen(linked.subject), { timeout: 60_000 }).toBe(true); + await context.close(); + }); +}); + +test.describe('session limits', () => { + test('under a maximum session age the route answers 401 and writes nothing', async ({ browser }) => { + const admin = await adminContext(browser); + const page = await userPage(browser, USER); + await page.context().grantPermissions(['notifications']); + const sw = await workerOf(page.context(), page); + const mail = await oneUnread(); + const shown = await pushed(sw, mail.accountId, mail.id); + await withPolicy(admin, { organisation: { session: { idleMinutes: 0, maxHours: 8, warnSeconds: 60 } } }, async () => { + const res = await page.request.post('/api/push/action', { + data: { action: 'mark-read', accountId: mail.accountId, emailId: mail.id, login: shown.data.login }, + }); + expect(res.status()).toBe(401); + }); + expect((await serverCopy(mail.id)).keywords.$seen).toBeFalsy(); + await page.context().close(); + await admin.close(); + }); +}); + +test.describe('the route, as a page or another site would call it', () => { + test('refuses a cross-site POST at the gate, with the cookies on it', async ({ browser }) => { + const page = await userPage(browser, USER); + 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__/plugin-post-denylist.test.ts b/lib/__tests__/plugin-post-denylist.test.ts index 5d0fad219..1c266ee1b 100644 --- a/lib/__tests__/plugin-post-denylist.test.ts +++ b/lib/__tests__/plugin-post-denylist.test.ts @@ -29,6 +29,7 @@ describe('routes no plugin may POST to', () => { '/api/%70lugin-proxy', '/api//plugin-secrets', '/api/account/stalwart/jmap', + '/api/push/action', ])('%s is denied', (path) => { expect(isPluginPostDenied(path)).toBe(true); }); diff --git a/lib/__tests__/push-action-route.test.ts b/lib/__tests__/push-action-route.test.ts new file mode 100644 index 000000000..c120db3d0 --- /dev/null +++ b/lib/__tests__/push-action-route.test.ts @@ -0,0 +1,428 @@ +// @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; +// The answer to each Mailbox/set in turn; `undefined`: everything was created. +let mailboxSetResults: Array> = []; +let mailboxReads = 0; +let mailboxesOnSecondRead: Array> | null = null; +let emailSetResults: Array> = []; +let sessionsAsked = 0; + +const json = (data: unknown, status = 200) => new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json' } }); +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') { + mailboxReads++; + return [name, { list: mailboxReads >= 2 && mailboxesOnSecondRead ? mailboxesOnSecondRead : mailboxes }, id]; + } + if (name === 'Email/get') return [name, { list: email ? [email] : [] }, id]; + if (name === 'Mailbox/set') { + if (args.destroy) return [name, { destroyed: args.destroy }, id]; + return [name, mailboxSetResults.shift() ?? { + created: Object.fromEntries(Object.keys(args.create as object).map((cid) => [cid, { id: `new-${cid}` }])), + }, id]; + } + const emailId = Object.keys(args.update as Record)[0]; + return [name, emailSetResults.shift() ?? 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 destroyed = () => writes.flatMap((w) => w.calls).find(([name, args]) => name === 'Mailbox/set' && args.destroy)?.[1].destroy; +const markRead = async (over: Record = {}) => ({ + action: 'mark-read', accountId: 'a', emailId: 'e1', login: await fingerprint(ALICE), ...over, +}); +const archive = async (over: Record = {}) => ({ ...(await markRead()), action: 'archive', ...over }); +const emailSet = () => { + const call = writes.at(-1)!.calls.find(([name]) => name === 'Email/set'); + return call?.[1] as { accountId: string; update: Record> }; +}; +const mailboxSet = () => writes[0].calls.find(([name]) => name === 'Mailbox/set')?.[1] as { create: Record> } | undefined; + +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', mailboxIds: { inbox: true } }; + emailSetResult = null; + mailboxSetResults = []; + mailboxReads = 0; + mailboxesOnSecondRead = null; + emailSetResults = []; +}); + +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('matches a server address written differently on either side', async () => { + LOGINS = [{ ...ALICE, serverUrl: 'https://one.example' }]; + const login = await loginFingerprint('https://ONE.example:443/', ALICE.username); + expect((await call(await markRead({ login }))).status).toBe(200); + expect(writes).toHaveLength(1); + }); + + it('answers 401 for an expired token: only a page refreshes it', async () => { + sessionStatus = 401; + expect((await call(await markRead())).status).toBe(401); + 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('session limits', () => { + // The page applies them, from activity and sign-in time: an action from here + // would keep the login going past them. + it.each([ + ['a maximum session age', { maxHours: 8 }], + ['an idle limit', { idleMinutes: 30 }], + ])('answers 401 under %s, so the app opens and applies it', async (_name, session) => { + policy = { organisation: { session } }; + expect((await call(await markRead())).status).toBe(401); + expect((await call(await archive())).status).toBe(401); + expect(writes).toHaveLength(0); + }); + + it('follows the scope this login is in', async () => { + policy = { scopes: [{ id: 'team', label: 'Team', match: { domains: ['example.org'] }, policy: { organisation: { session: { maxHours: 8 } } } }] }; + expect((await call(await markRead())).status).toBe(401); + expect(writes).toHaveLength(0); + }); + + it("isn't troubled by a scope this login is not in", async () => { + policy = { scopes: [{ id: 'other', label: 'Other', match: { domains: ['other.test'] }, policy: { organisation: { session: { maxHours: 8 } } } }] }; + expect((await call(await markRead())).status).toBe(200); + }); + + it("answers 401 when the login's scopes can't be worked out", async () => { + subject = null; + expect((await call(await markRead())).status).toBe(401); + expect(writes).toHaveLength(0); + }); +}); + +describe('a message since moved to Trash or Junk', () => { + it.each(['trash', 'junk'])('stays as it is in %s, and the app opens it', async (role) => { + mailboxes = [INBOX, ARCHIVE, { id: 'bin', name: 'Bin', role }]; + email = { id: 'e1', receivedAt: '2026-03-31T23:30:00Z', mailboxIds: { bin: true } }; + expect((await call(await markRead())).status).toBe(409); + expect((await call(await archive())).status).toBe(409); + expect(writes).toHaveLength(0); + }); + + it('answers 404 for Mark read on a message that is gone, writing nothing', async () => { + email = null; + expect((await call(await markRead())).status).toBe(404); + expect(writes).toHaveLength(0); + }); +}); + +describe('Mark read', () => { + it('sets $seen on that message, at the rebased API address', async () => { + expect(await call(await markRead())).toEqual({ status: 200, body: { ok: true } }); + 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); + }); + + it('files into a folder another client made first, on one more try', async () => { + mailboxes = [INBOX, ARCHIVE]; + mailboxesOnSecondRead = [INBOX, ARCHIVE, { id: 'y26', name: '2026', parentId: 'arch' }]; + mailboxSetResults = [{ notCreated: { year: { type: 'alreadyExists' } } }]; + emailSetResults = [{ notUpdated: { e1: { type: 'invalidProperties' } } }]; + expect((await call(await archive({ archiveMode: 'year', timeZone: 'UTC' }))).status).toBe(200); + expect(mailboxReads).toBe(2); + expect(writes).toHaveLength(2); + expect(emailSet().update.e1).toEqual({ mailboxIds: { y26: true } }); + expect(destroyed()).toBeUndefined(); + }); + + it('tries only once more, and stops when the second try fails too', async () => { + mailboxes = [INBOX, ARCHIVE]; + mailboxSetResults = [ + { notCreated: { year: { type: 'alreadyExists' } } }, + { notCreated: { year: { type: 'alreadyExists' } } }, + ]; + emailSetResult = { notUpdated: { e1: { type: 'invalidProperties' } } }; + expect((await call(await archive({ archiveMode: 'year', timeZone: 'UTC' }))).status).toBe(502); + expect(writes).toHaveLength(2); + }); + + it('leaves no empty folder when the message then is refused, deepest first', async () => { + mailboxes = [INBOX]; + emailSetResult = { notUpdated: { e1: { type: 'forbidden' } } }; + expect((await call(await archive({ archiveMode: 'month', timeZone: 'UTC' }))).status).toBe(502); + expect(destroyed()).toEqual(['new-month', 'new-year', 'new-archive']); + }); + + it('keeps the folders it made when the message went in', async () => { + mailboxes = [INBOX]; + expect((await call(await archive({ archiveMode: 'month', timeZone: 'UTC' }))).status).toBe(200); + expect(destroyed()).toBeUndefined(); + }); + + it("files into an Archive shared with others, as the app's own button does", async () => { + mailboxes = [INBOX, { ...ARCHIVE, shareWith: { u2: { mayReadItems: true } } }]; + expect((await call(await archive())).status).toBe(200); + expect(emailSet().update.e1).toEqual({ mailboxIds: { arch: true } }); + }); + + describe('the admin gate', () => { + it('refuses when the admin turned Archive off, whatever the worker believes', async () => { + policy = { features: { archiveActionEnabled: false } }; + 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(401); + 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..96e94949f --- /dev/null +++ b/lib/__tests__/sw-notification-actions.test.ts @@ -0,0 +1,446 @@ +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', + pluginsMayVeto: false, + labels: { archive: 'Archivieren', markRead: 'Als gelesen markieren', reply: 'Antworten', replyPlaceholder: 'Antwort schreiben' }, +}; +const ONE_MESSAGE = { kind: 'email', emailId: 'e1', threadId: 't1', accountId: 'a', slot: 2, login: 'fp' }; +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', at: expect.any(Number), + }); + 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('send when they asked, so a tab that thaws later can tell', async () => { + const client = tab('t', ['done']); + const before = Date.now(); + const w = worker({ caches: await cachesWith(PREFS), clients: [client] }); + await w.click('mark-read'); + const at = client.messages.find((m) => m.kind === 'notification-action')?.at as number; + expect(at).toBeGreaterThanOrEqual(before); + expect(at).toBeLessThanOrEqual(Date.now()); + }); + + it('leave the route alone while an enabled plugin may stop them, and open the message', async () => { + const w = worker({ caches: await cachesWith({ ...PREFS, pluginsMayVeto: true }) }); + await w.click('mark-read'); + expect(w.posts()).toHaveLength(0); + expect(w.openWindow).toHaveBeenCalledWith(MESSAGE_URL); + }); + + it('leave the route alone before any page has said what plugins there are', async () => { + const w = worker({ caches: await cachesWith() }); + await w.click('mark-read'); + expect(w.posts()).toHaveLength(0); + expect(w.openWindow).toHaveBeenCalledWith(MESSAGE_URL); + }); + + it('open the message when a tab holding this login stays silent, without the route', async () => { + const silent = tab('held', []); + const w = worker({ caches: await cachesWith(PREFS), clients: [silent] }); + await w.dispatch('message', { data: { type: 'client-logins', logins: ['fp'] }, source: { id: 'held' } }); + await w.click('archive'); + expect(w.posts()).toHaveLength(0); + expect(silent.focus).toHaveBeenCalled(); + expect(silent.navigate).toHaveBeenCalledWith(MESSAGE_URL); + }); + + it('give a busy tab holding this login longer than the others', async () => { + const busy = tab('held', []); + const post = busy.postMessage; + busy.postMessage = (message: Record, ports?: MessagePort[]) => { + post(message, ports); + if (message.kind === 'notification-action') setTimeout(() => ports?.[0]?.postMessage({ status: 'done' }), 1_500); + }; + const w = worker({ caches: await cachesWith(PREFS), clients: [busy] }); + await w.dispatch('message', { data: { type: 'client-logins', logins: ['fp'] }, source: { id: 'held' } }); + await w.click('mark-read'); + expect(busy.navigate).not.toHaveBeenCalled(); + expect(w.posts()).toHaveLength(0); + expect(w.openWindow).not.toHaveBeenCalled(); + }); + + it('take another tab that answers over a silent one holding this login', async () => { + const silent = tab('held', [], { visibilityState: 'visible', focused: true }); + const alive = tab('alive', ['done']); + const w = worker({ caches: await cachesWith(PREFS), clients: [silent, alive] }); + await w.dispatch('message', { data: { type: 'client-logins', logins: ['fp'] }, source: { id: 'held' } }); + await w.click('mark-read'); + expect(alive.messages.some((m) => m.kind === 'notification-action')).toBe(true); + expect(silent.navigate).not.toHaveBeenCalled(); + expect(w.posts()).toHaveLength(0); + }); + + it('forget tabs that are gone when another reports its logins', async () => { + const caches = await cachesWith(PREFS); + const clients = [tab('one', []), tab('two', [])]; + const w = worker({ caches, clients }); + await w.dispatch('message', { data: { type: 'client-logins', logins: ['fp'] }, source: { id: 'one' } }); + clients.shift(); + await w.dispatch('message', { data: { type: 'client-logins', logins: ['other'] }, source: { id: 'two' } }); + const cache = await caches.open(PUSH_CACHE); + expect(await (await cache.match(`${ORIGIN}/__push-state/client-logins`))!.json()).toEqual({ two: ['other'] }); + }); + + it('use the route when a tab never answers, so a frozen tab costs a moment only', async () => { + const frozen = tab('frozen', []); + const w = worker({ caches: await cachesWith(PREFS), clients: [frozen] }); + 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('keeps typed text and the reply link when a tab holding this login stays silent', async () => { + const caches = await cachesWith(PREFS); + const silent = tab('held', []); + const w = worker({ caches, clients: [silent] }); + await w.dispatch('message', { data: { type: 'client-logins', logins: ['fp'] }, source: { id: 'held' } }); + await w.click('reply', { reply: 'Private words' }); + expect(silent.navigate).toHaveBeenCalledWith(`${MESSAGE_URL}&reply=1`); + const cache = await caches.open(PUSH_CACHE); + expect(await (await cache.match(`${ORIGIN}/__push-state/pending-reply`))!.json()).toMatchObject({ text: 'Private words' }); + }); + + it('leaves nothing in the store without typed text', async () => { + const caches = await cachesWith(PREFS); + const w = worker({ caches }); + 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/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/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/global-search/__tests__/providers.test.ts b/lib/global-search/__tests__/providers.test.ts index 111bf736c..fe54a5f7e 100644 --- a/lib/global-search/__tests__/providers.test.ts +++ b/lib/global-search/__tests__/providers.test.ts @@ -163,7 +163,7 @@ describe('mail provider', () => { const result = await mailProvider.remote(parseSearchQuery('zebra'), account('login-a'), { limit: 25, signal }); expect(buildUnifiedAccountClients).toHaveBeenCalledWith({ includeGroup: true, scopeToClientAccountId: 'login-a' }); // The stub login has no trash/junk role folders, so no exclusion is added. - expect(advancedSearchEmails).toHaveBeenCalledWith({ text: 'zebra' }, undefined, 25, 0); + expect(advancedSearchEmails).toHaveBeenCalledWith({ text: 'zebra' }, undefined, 25, 0, { palette: true }); expect(result.hasMore).toBe(true); expect(result.hits[0]).toMatchObject({ id: 'm9', localAccountId: 'login-a', jmapAccountId: 'jmap-a', source: 'remote', subtitle: 'Inbox', diff --git a/lib/global-search/providers/mail.ts b/lib/global-search/providers/mail.ts index 14850795c..d52db2eab 100644 --- a/lib/global-search/providers/mail.ts +++ b/lib/global-search/providers/mail.ts @@ -119,6 +119,7 @@ export const mailProvider: SearchProvider = { entry.isShared ? entry.jmapAccountId : undefined, limit, position, + { palette: true }, ); if (result.hasMore) hasMore = true; for (const raw of result.emails) { diff --git a/lib/jmap/__tests__/search-merge.test.ts b/lib/jmap/__tests__/search-merge.test.ts new file mode 100644 index 000000000..4ef295281 --- /dev/null +++ b/lib/jmap/__tests__/search-merge.test.ts @@ -0,0 +1,903 @@ +// @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, mailboxesFitFilter } 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[], fixedTotal?: number) { + const asked: number[] = []; + const answer = async (position: number, limit: number): Promise => { + asked.push(position); + const total = fixedTotal ?? rows.length; + 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('walks a search again when it has no cursor, so no server row is skipped', async () => { + const server = side([mail('s4', 40), mail('s3', 30), mail('s2', 20), mail('s1', 10)]); + const local = side([mail('l5', 50), mail('l25', 25)]); + const merge = new LocalSearchMerge(); + const ask = (key: string, position: number) => merge.run({ + key, limit: 2, position, + server: (at) => server.answer(at, 2), + local: (at) => local.answer(at, 2), + }); + // The caller holds l5, s4, s3. As a merged position 3 would skip s2 on the server. + const result = await ask('evicted', 3); + expect(ids(result)).toEqual(['l25', 's2']); + }); + + it('sends a position with no cursor straight to the server when the store is off', async () => { + const server = side([mail('s2', 20), mail('s1', 10)]); + const result = await pager(server, null, 2)(1); + expect(ids(result)).toEqual(['s1']); + expect(server.asked).toEqual([0, 1]); + }); + + it('ends the list, and never guesses, past how far it will walk again', async () => { + const rows = Array.from({ length: 100 }, (_, i) => mail(`s${i}`, 1000 - i)); + const server = side(rows); + const local = side([mail('l', 2000)]); + const result = await pager(server, local, 2)(90); + expect(result.emails).toEqual([]); + expect(result.hasMore).toBe(false); + expect(server.asked).not.toContain(90); + }); + + describe('a refresh of a longer list', () => { + function paged() { + const serverRows = [mail('s6', 60), mail('s5', 50), mail('s4', 40), mail('s3', 30), mail('s2', 20), mail('s1', 10)]; + const server = side(serverRows); + const local = side([mail('l7', 70)]); + return { server, local, page: pager(server, local, 2) }; + } + + it('leaves the next load-more where the list had got to', async () => { + const { page } = paged(); + await page(0); + await page(2); + const refreshed = await page(0); + expect(ids(refreshed)).toEqual(['l7', 's6']); + const more = await page(4); + expect(ids(more)).toEqual(['s3', 's2']); + }); + + it('keeps total the same when nothing changed', async () => { + const { page } = paged(); + const first = await page(0); + expect(first.total).toBe(7); + await page(2); + expect((await page(0)).total).toBe(7); + }); + + it('does not forget a local row that was on a later page', async () => { + const server = side([mail('s6', 60), mail('s5', 50), mail('s4', 40), mail('s3', 30), mail('s2', 20), mail('s1', 10)]); + const local = side([mail('l45', 45)]); + const page = pager(server, local, 2); + expect(ids(await page(0))).toEqual(['s6', 's5']); + expect(ids(await page(2))).toEqual(['l45', 's4']); + // This first page has no local row, so it knows nothing of l45 by itself. + const refreshed = await page(0); + expect(ids(refreshed)).toEqual(['s6', 's5']); + expect(refreshed.total).toBe(7); + // The list holds four rows, three of them the server's: s3 is next. + const more = await page(4); + expect(ids(more)).toEqual(['s3', 's2']); + expect(more.total).toBe(7); + }); + + it('still lets a new search of the same words page from its own start', async () => { + const { page } = paged(); + await page(0); + await page(2); + await page(0); + // The list was replaced by that page, so the next one starts right after it. + expect(ids(await page(2))).toEqual(['s5', 's4']); + }); + + it('moves on past a new row the refresh brought in', async () => { + const serverRows = [mail('s4', 40), mail('s3', 30), mail('s2', 20), mail('s1', 10)]; + const server = side(serverRows); + const local = side([mail('l5', 50)]); + const page = pager(server, local, 2); + await page(0); + await page(2); + serverRows.unshift(mail('new', 60)); + const refreshed = await page(0); + expect(refreshed.total).toBe(6); + // The caller's list grew by one: l5 s4 s3 s2 plus the new row. + expect(ids(await page(5))).toEqual(['s1']); + }); + }); + + describe('how long a cursor lasts', () => { + function search(merge: LocalSearchMerge, key: string, position: number, rows: ReturnType, local: ReturnType) { + return merge.run({ key, limit: 1, position, server: (at) => rows.answer(at, 1), local: (at) => local.answer(at, 1) }); + } + const other = () => [side([mail('o', 1)]), side([])] as const; + + it('keeps the search in use when many others come and go', async () => { + const merge = new LocalSearchMerge(); + const server = side([mail('s2', 20), mail('s1', 10)]); + const local = side([mail('l3', 30)]); + await search(merge, 'mine', 0, server, local); + for (let i = 0; i < 60; i += 1) await search(merge, `other-${i}`, 0, ...other()); + await search(merge, 'mine', 1, server, local); + for (let i = 60; i < 120; i += 1) await search(merge, `other-${i}`, 0, ...other()); + server.asked.length = 0; + expect(ids(await search(merge, 'mine', 2, server, local))).toEqual(['s1']); + // The cursor was there: no walking from the start again. + expect(server.asked).toEqual([1]); + }); + + it('still returns the right rows once a cursor is gone', async () => { + const merge = new LocalSearchMerge(); + const server = side([mail('s2', 20), mail('s1', 10)]); + const local = side([mail('l3', 30)]); + await search(merge, 'mine', 0, server, local); + for (let i = 0; i < 70; i += 1) await search(merge, `other-${i}`, 0, ...other()); + expect(ids(await search(merge, 'mine', 1, server, local))).toEqual(['s2']); + }); + }); + + it('counts a row both sides have once in total', async () => { + const server = side([mail('s1', 10)]); + const local = side([mail('s1', 10)]); + const result = await pager(server, local, 5)(0); + expect(result.total).toBe(1); + }); + + 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('rows only the store has', () => { + function run( + serverRows: Email[], + localRows: Email[], + verify: (rows: Email[]) => Promise, + limit = 5, + ) { + const server = side(serverRows); + const local = side(localRows); + return new LocalSearchMerge().run({ + key: 'k', limit, position: 0, + server: (at) => server.answer(at, limit), + local: (at) => local.answer(at, limit), + verify, + }); + } + + it('drops what the server no longer has, and fills the page with the rest', async () => { + const verify = vi.fn(async (rows: Email[]) => rows.filter((row) => row.id !== 'deleted')); + const result = await run([mail('s1', 10)], [mail('deleted', 30), mail('kept', 20)], verify); + expect(ids(result)).toEqual(['kept', 's1']); + expect(result.total).toBe(2); + expect(verify.mock.calls[0][0].map((row) => row.id)).toEqual(['deleted', 'kept']); + }); + + it('shows the current copy of what the server still has', async () => { + const result = await run([], [mail('kept', 20, { subject: 'old', keywords: {} })], async () => [mail('kept', 20, { subject: 'current', keywords: { $seen: true } })]); + expect(result.emails[0].subject).toBe('current'); + expect(result.emails[0].keywords).toEqual({ $seen: true }); + }); + + it('marks them, so nothing takes them for new mail', async () => { + const result = await run([mail('s', 30)], [mail('s', 30), mail('kept', 20)], async (rows) => rows); + expect(result.emails.map((email) => email.foundLocally ?? false)).toEqual([false, true]); + }); + + it('does not ask when the store has nothing of its own', async () => { + const verify = vi.fn(async (rows: Email[]) => rows); + await run([mail('both', 10)], [mail('both', 10)], verify); + expect(verify).not.toHaveBeenCalled(); + }); + + it('does not ask about a row too old for this page', async () => { + const verify = vi.fn(async (rows: Email[]) => rows); + await run([mail('s3', 30), mail('s2', 20), mail('s1', 10)], [mail('old', 5)], verify, 2); + expect(verify).not.toHaveBeenCalled(); + }); + + it('keeps them when the check fails', async () => { + const result = await run([mail('s1', 10)], [mail('kept', 20)], async () => { throw new Error('offline again'); }); + expect(ids(result)).toEqual(['kept', 's1']); + }); + + it('keeps them, unchecked, when the search itself failed', async () => { + const verify = vi.fn(async () => []); + const result = await new LocalSearchMerge().run({ + key: 'k', limit: 5, position: 0, + server: async () => { throw new Error('server broke'); }, + local: async () => ({ emails: [mail('kept', 20)], total: 1, hasMore: false }), + verify, + }); + expect(ids(result)).toEqual(['kept']); + expect(verify).not.toHaveBeenCalled(); + }); +}); + +describe('mailboxesFitFilter', () => { + const inbox = { inbox: true }; + it.each([ + [{ inMailbox: 'inbox' }, inbox, true], + [{ inMailbox: 'inbox' }, { trash: true }, false], + [{ inMailbox: 'inbox' }, { inbox: false }, false], + [{ inMailbox: 'inbox' }, undefined, false], + [{ inMailboxOtherThan: ['trash', 'junk'] }, inbox, true], + [{ inMailboxOtherThan: ['trash', 'junk'] }, { trash: true }, false], + [{ inMailboxOtherThan: ['trash', 'junk'] }, { trash: true, inbox: true }, true], + [{ text: 'x' }, { trash: true }, true], + [{ operator: 'AND', conditions: [{ inMailbox: 'inbox' }, { text: 'x' }] }, { trash: true }, false], + [{ operator: 'AND', conditions: [{ inMailbox: 'inbox' }, { text: 'x' }] }, inbox, true], + [{ operator: 'OR', conditions: [{ inMailbox: 'a' }, { inMailbox: 'b' }] }, { b: true }, true], + [{ operator: 'OR', conditions: [{ inMailbox: 'a' }, { inMailbox: 'b' }] }, inbox, false], + [{ operator: 'OR', conditions: [{ inMailbox: 'a' }, { text: 'x' }] }, inbox, true], + [{ operator: 'NOT', conditions: [{ inMailbox: 'trash' }] }, inbox, true], + [{ operator: 'NOT', conditions: [{ inMailbox: 'trash' }] }, { trash: true }, false], + [{ operator: 'NOT', conditions: [{ text: 'x' }] }, inbox, true], + [{ inMailbox: 'trash', text: 'x' }, inbox, false], + ])('%j with %j is %s', (filter, boxes, expected) => { + expect(mailboxesFitFilter(filter, boxes as Record | undefined)).toBe(expected); + }); +}); + +describe('errors', () => { + it('leaves no unhandled rejection when the store fails while the server is still working', async () => { + const unhandled: unknown[] = []; + const note = (reason: unknown) => { unhandled.push(reason); }; + process.on('unhandledRejection', note); + try { + const result = await new LocalSearchMerge().run({ + key: 'k', limit: 10, position: 0, + server: async () => { + await new Promise((resolve) => setTimeout(resolve, 30)); + return { emails: [mail('s', 1)], total: 1, hasMore: false }; + }, + local: async () => { throw new Error('store broke'); }, + }); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(ids(result)).toEqual(['s']); + expect(unhandled).toEqual([]); + } finally { + process.off('unhandledRejection', note); + } + }); + + it('keeps the list open when the server fails on a later page', async () => { + let fail = false; + const rows = side([mail('s4', 40), mail('s3', 30), mail('s2', 20), mail('s1', 10)]); + const local = side([mail('l5', 50), mail('l25', 25)]); + const merge = new LocalSearchMerge(); + const run = (position: number) => merge.run({ + key: 'k', limit: 2, position, + server: async (at) => { + if (fail) throw new Error('server broke'); + return rows.answer(at, 2); + }, + local: (at) => local.answer(at, 2), + }); + expect((await run(0)).hasMore).toBe(true); + fail = true; + const during = await run(2); + expect(ids(during)).toEqual(['l25']); + // Local rows ran out, but the server said it had more: the list isn't over. + expect(during.hasMore).toBe(true); + fail = false; + expect(ids(await run(3))).toEqual(['s3', 's2']); + }); + + 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('counts the grace period from when the server answered', async () => { + const merge = new LocalSearchMerge(); + let local: (value: SearchEmailsResult) => void = () => {}; + const answer = merge.run({ + key: 'k', limit: 10, position: 0, + server: async () => { + await new Promise((resolve) => setTimeout(resolve, 5_000)); + return { emails: [mail('s', 1)], total: 1, hasMore: false }; + }, + local: () => new Promise((resolve) => { local = resolve; }), + }); + await vi.advanceTimersByTimeAsync(5_000); + // The store answers soon after a slow server, and is still in time. + await vi.advanceTimersByTimeAsync(500); + local({ emails: [mail('l', 2)], total: 1, hasMore: false }); + expect(ids(await answer)).toEqual(['l', 's']); + }); + + it('waits only as long as it was told to', async () => { + const merge = new LocalSearchMerge({ graceMs: 200 }); + let done = false; + const answer = merge.run({ + key: 'k', limit: 10, position: 0, + server: async () => ({ emails: [mail('s', 1)], total: 1, hasMore: false }), + local: () => new Promise(() => {}), + }).then((result) => { + done = true; + return result; + }); + await vi.advanceTimersByTimeAsync(199); + expect(done).toBe(false); + await vi.advanceTimersByTimeAsync(1); + expect(ids(await answer)).toEqual(['s']); + }); + + 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]>>; + /** What Email/get by id finds on the server, past the search's own rows. */ + let serverKnows: Array>; + + 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 [firstName, firstArgs, firstId] = methodCalls[0]; + if (firstName === 'Email/get' && Array.isArray(firstArgs.ids)) { + const wanted = firstArgs.ids as string[]; + const list = [...serverRows, ...serverKnows].filter((row) => wanted.includes(row.id as string)); + return new Response(JSON.stringify({ + methodResponses: [[firstName, { list, notFound: wanted.filter((id) => !list.some((row) => row.id === id)) }, firstId]], + }), { status: 200 }); + } + 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 = []; + serverKnows = [ + { id: 'only-local', subject: 'Zahlungserinnerungsrechnung', receivedAt: '2026-10-03T00:00:00Z', mailboxIds: { inbox: true } }, + ]; + 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('drops a row deleted on the server since the store last synced', async () => { + const engine = await storeEngine(); + setOfflineHooks(hooksWith((_client, calls) => engine.answer(calls))); + serverKnows = []; + const client = await connected(); + const result = await client.searchEmails('rechnung'); + expect(result.emails.map((email) => email.id)).toEqual(['newer', 'both']); + expect(result.total).toBe(2); + }); + + it('drops a row moved out of the folder searched', async () => { + const engine = await storeEngine(); + setOfflineHooks(hooksWith((_client, calls) => engine.answer(calls))); + serverKnows = [{ id: 'only-local', subject: 'x', receivedAt: '2026-10-03T00:00:00Z', mailboxIds: { trash: true } }]; + const client = await connected(); + const inFolder = await client.searchEmails('rechnung', 'inbox'); + expect(inFolder.emails.map((email) => email.id)).toEqual(['newer', 'both']); + const everywhere = await client.searchEmails('rechnung'); + expect(everywhere.emails.map((email) => email.id)).toEqual(['newer', 'both', 'only-local']); + const notInTrash = await client.advancedSearchEmails({ operator: 'AND', conditions: [{ text: 'rechnung' }, { inMailboxOtherThan: ['trash'] }] }); + expect(notInTrash.emails.map((email) => email.id)).toEqual(['newer', 'both']); + }); + + it('searches the store without snippets, and asks only for those of its own rows', async () => { + const engine = await storeEngine(); + const asked: string[][] = []; + const snippetIds: unknown[] = []; + setOfflineHooks(hooksWith((_client, calls) => { + asked.push(calls.map(([name]) => name)); + for (const [name, args] of calls) if (name === 'SearchSnippet/get') snippetIds.push(args.emailIds); + return engine.answer(calls); + })); + const client = await connected(); + await client.searchEmails('rechnung'); + expect(asked).toEqual([['Email/query', 'Email/get'], ['SearchSnippet/get']]); + expect(snippetIds).toEqual([['only-local']]); + }); + + it('adds the snippets of its own rows to the deferred ones', async () => { + const engine = await storeEngine(); + setOfflineHooks(hooksWith((_client, calls) => engine.answer(calls))); + const client = await connected(); + const result = await client.searchEmails('rechnung', undefined, undefined, 50, 0, { deferSnippets: true }); + expect(result.emails.every((email) => !email.searchSnippet)).toBe(true); + const snippets = await result.snippets; + expect(snippets?.find((s) => s.emailId === 'only-local')?.subject).toBe('Zahlungserinnerungsrechnung'); + }); + + it('keeps the palette from pushing out the paging of the mail list', async () => { + const engine = await storeEngine(); + setOfflineHooks(hooksWith((_client, calls) => engine.answer(calls))); + const client = await connected(); + await client.searchEmails('rechnung', undefined, undefined, 2); + for (let i = 0; i < 100; i += 1) await client.searchEmails(`wort${i}`, undefined, undefined, 2, 0, { palette: true }); + requests = []; + await client.searchEmails('rechnung', undefined, undefined, 2, 2); + // With its cursor, the next page starts where the server left off, and nothing is walked again. + const queries = requests.flatMap((calls) => calls.filter(([name]) => name === 'Email/query')); + expect(queries.map(([, args]) => args.position)).toEqual([2]); + }); + + it('waits less for the store in the palette', async () => { + setOfflineHooks(hooksWith(() => new Promise(() => {}))); + const client = await connected(); + const started = Date.now(); + const result = await client.searchEmails('rechnung', undefined, undefined, 50, 0, { palette: true }); + expect(result.emails.length).toBe(2); + expect(Date.now() - started).toBeLessThan(900); + }); + + 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-interface.ts b/lib/jmap/client-interface.ts index d3b99d523..0c6e9c5fb 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -94,6 +94,11 @@ export interface SearchRequestOptions { * where the list must not wait for the highlighting. */ deferSnippets?: boolean; + /** + * For the search palette. It searches on every few keystrokes, so it keeps its + * paging apart from the mail list's, and waits less for the local store. + */ + palette?: boolean; } export interface SearchEmailsResult { @@ -635,6 +640,8 @@ export interface IJMAPClient { options?: CalendarEventUpdateOptions, ): Promise; deleteCalendarEvent(eventId: string, sendSchedulingMessages?: boolean, targetAccountId?: string): Promise; + /** Whether the last request was answered from this device instead of the server. */ + answeredFromDevice?(): boolean; batchDeleteCalendarEvents(eventIds: string[], targetAccountId?: string): Promise<{ destroyed: string[]; notDestroyed: Record }>; queryCalendarEvents(filter: CalendarEventFilter, sort?: Array<{ property: string; isAscending: boolean }>, limit?: number, targetAccountId?: string, options?: CalendarQueryOptions): Promise; queryAllCalendarEvents(filter: CalendarEventFilter, sort?: Array<{ property: string; isAscending: boolean }>, limit?: number, options?: CalendarQueryOptions): Promise; diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 70bdfedb3..1282c7c15 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -4,11 +4,13 @@ import { IncompleteDeleteError } from "./delete-error"; import { downloadAllowed, saveBlobAs, type DownloadKind } from "@/lib/download-guard"; import { ArchiveEmailsError } from "./archive-error"; import { generateUUID } from '@/lib/utils'; +import { standInsIn, withRealIds, withStandInsBack } from './stand-in-ids'; import type { Attachment, Email, Mailbox, MailboxRights, StateChange, AccountStates, CollectionChanges, ShareNotification, BusyPeriod, CalendarParticipantIdentity, CalendarEventNotification, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarComponentType, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, CreateCalendarOptions, FileNode, FileNodeFilter, FileNodeRights, Principal, PrincipalAvailability, PrincipalListing, PushSubscription, EmailPushConfig, DeliveryStatus, EmailSubmission, RejectedRecipient, ScheduledEmail, SendEmailResult, SharedAccount } from "./types"; 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, mailboxesFitFilter } from "./search-merge"; import { batched, batchedBySize, itemsPerRequest } from "./request-limits"; import { keywordPointer, pointerToken } from "./patch-pointer"; import { FirstTouchGate } from "./first-touch-gate"; @@ -1640,6 +1642,8 @@ export class JMAPClient implements IJMAPClient { * can be reached again (a reload signs in as usual). */ private offlineOnly = false; + private readonly searchMerge = new LocalSearchMerge(); + private readonly paletteSearchMerge = new LocalSearchMerge({ graceMs: 200 }); constructor(serverUrl: string, username: string, password: string) { this.serverUrl = serverUrl.replace(/\/$/, ''); @@ -2199,9 +2203,30 @@ export class JMAPClient implements IJMAPClient { const offline = getOfflineHooks(); if (this.offlineOnly || offline?.skipNetwork(this)) { const local = offline && !opts?.networkOnly ? await offline.answer(this, methodCalls) : null; - if (local) return local as JMAPResponse; + if (local) { + this.lastAnswerFromDevice = true; + return local as JMAPResponse; + } throw new OfflineUnavailableError(); } + // A stand-in id from an event created offline, still held by an open dialog + // or a list: the server knows it by its real id, or not yet at all. + const standIns = offline?.resolveStandIns ? standInsIn(methodCalls) : []; + let realIds: Record | null = null; + if (standIns.length > 0) { + const resolved = await offline!.resolveStandIns!(this, standIns); + if (standIns.some((id) => !resolved[id])) { + const local = opts?.networkOnly ? null : await offline!.answer(this, methodCalls); + if (local) { + this.lastAnswerFromDevice = true; + return local as JMAPResponse; + } + throw new OfflineUnavailableError(); + } + realIds = resolved as Record; + methodCalls = withRealIds(methodCalls, realIds); + } + const asAsked = (response: JMAPResponse) => (realIds ? withStandInsBack(response, realIds) : response); let answer: JMAPResponse; try { answer = await this.networkRequest(methodCalls, using, opts); @@ -2210,15 +2235,25 @@ export class JMAPClient implements IJMAPClient { // headers looks the same to the browser and must stay an error. if (offline && isNetworkFailure(error) && await offline.serverGone(this, error)) { const local = opts?.networkOnly ? null : await offline.answer(this, methodCalls); - if (local) return local as JMAPResponse; + if (local) { + this.lastAnswerFromDevice = true; + return asAsked(local as JMAPResponse); + } } throw error; } + this.lastAnswerFromDevice = false; if (offline) { offline.noteNetwork(this, true); if (!opts?.skipOfflineRecord) offline.record(this, methodCalls, answer); } - return answer; + return asAsked(answer); + } + + private lastAnswerFromDevice = false; + + answeredFromDevice(): boolean { + return this.lastAnswerFromDevice; } /** A request only the server may answer: for the outbox, which must see outages as outages. */ @@ -4405,61 +4440,144 @@ 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); + } + const targetAccountId = accountId || this.accountId; + const merge = options.palette ? this.paletteSearchMerge : this.searchMerge; + return merge.run({ + key: JSON.stringify([accountId ?? null, filter]), + limit, + position, + server, + local: async (at) => { + // No snippets here: excerpting every hit is the slow part, and most of + // them are the server's to show. See localSnippets. + const answer = await searchLocal.call(hooks, this, this.searchCalls(targetAccountId, filter, limit, at, true)); + return answer ? this.searchResultFrom(answer as JMAPResponse, accountId, limit, at) : null; + }, + verify: (rows) => this.currentSearchRows(rows, filter, accountId), + localSnippets: async (ids) => { + if (!filterHasSnippetTerms(filter)) return null; + const answer = await searchLocal.call(hooks, this, [ + ["SearchSnippet/get", { accountId: targetAccountId, filter: snippetFilterFor(filter), emailIds: ids }, "snippets"], + ]); + return answer ? this.searchSnippetsFrom(answer as JMAPResponse) ?? null : null; + }, + deferSnippets: options.deferSnippets, + }); + } + + /** + * The server's copies of rows only the local store found, without those it + * no longer has or has outside the filter's folders. The filter is checked + * before the ids get their shared-account namespace, since it has bare ones. + */ + private async currentSearchRows(rows: Email[], filter: Record, accountId: string | undefined): Promise { + const response = await this.request([ + ["Email/get", { + accountId: accountId || this.accountId, + ids: rows.map((row) => row.id), + properties: [...EMAIL_LIST_PROPERTIES], + }, "0"], + ]); + const [name, result] = response.methodResponses?.[0] ?? []; + if (name !== "Email/get" || !result) throw new Error(methodErrorMessage(response, 'Failed to get emails')); + const list = applyListAuthenticationResults((result.list || []) as Email[]) + .filter((email) => mailboxesFitFilter(filter, email.mailboxIds)); + if (accountId && accountId !== this.accountId) namespaceMailboxIds(list, accountId); + return list; + } + + 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); @@ -4531,52 +4649,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( @@ -9437,6 +9510,8 @@ export class JMAPClient implements IJMAPClient { // Strip client-only shared fields before sending to JMAP const { originalId: _oi, baseEventId: _be, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...cleanEvent } = event as CalendarEvent; cleanRecurrenceRules(cleanEvent as unknown as Record); + // Chosen here, not by the server: if the answer is lost, this finds the event again. + cleanEvent.uid ||= generateUUID(); debug.group('CalendarEvent/create', 'calendar'); debug.log('calendar', 'CalendarEvent/create outgoing payload', { @@ -9543,6 +9618,7 @@ export class JMAPClient implements IJMAPClient { for (const { event, index } of batch) { const { originalId: _oi, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...clean } = event as CalendarEvent; cleanRecurrenceRules(clean as unknown as Record); + clean.uid ||= generateUUID(); createMap[`new-${index}`] = clean; } @@ -9967,7 +10043,7 @@ export class JMAPClient implements IJMAPClient { accountId = resolved.accountId; taskData.calendarIds = resolved.calendarIds; } - const cleanTask = { ...completeBareTaskDates(taskData), '@type': 'Task' }; + const cleanTask = { ...completeBareTaskDates(taskData), '@type': 'Task', uid: taskData.uid || generateUUID() }; debug.group('CalendarTask/create', 'tasks'); debug.log('tasks', 'CalendarTask/create accountId', accountId); @@ -11161,7 +11237,7 @@ export class JMAPClient implements IJMAPClient { if (eventType === 'state' && dataLines.length > 0) { try { const change = JSON.parse(dataLines.join('\n')) as StateChange; - getOfflineHooks()?.stateChange(this, change); + getOfflineHooks()?.stateChange?.(this, change); this.stateChangeCallback?.(change); } catch { // Malformed SSE data - ignore @@ -11337,7 +11413,7 @@ export class JMAPClient implements IJMAPClient { } if (Object.keys(changedByAccount).length > 0) { - getOfflineHooks()?.stateChange(this, { changed: changedByAccount }); + getOfflineHooks()?.stateChange?.(this, { changed: changedByAccount }); this.stateChangeCallback?.({ '@type': 'StateChange', changed: changedByAccount }); } } catch { diff --git a/lib/jmap/offline-hooks.ts b/lib/jmap/offline-hooks.ts index 60998d86e..b908eafb7 100644 --- a/lib/jmap/offline-hooks.ts +++ b/lib/jmap/offline-hooks.ts @@ -40,7 +40,7 @@ export interface OfflineHooks { */ serverGone(client: OfflineClientRef, error?: unknown): Promise; /** The server pushed new states, for the local store to catch up. */ - stateChange(client: OfflineClientRef, change: { changed?: Record> }): void; + stateChange?(client: OfflineClientRef, change: { changed?: Record> }): void; /** A blob kept on the device, or null. */ blob(client: OfflineClientRef, blobId: string, accountId: string): Promise; /** A blob the server sent, for the cache. */ @@ -53,6 +53,14 @@ export interface OfflineHooks { * Left out when there is no store, and searches then go to the server alone. */ searchLocal?(client: OfflineClientRef, methodCalls: OfflineMethodCall[]): Promise; + /** + * Real ids for stand-in ids the page still holds (lib/jmap/stand-in-ids.ts), or + * null for one that hasn't reached the server yet. Without it the client sends + * ids as they are. + */ + resolveStandIns?(client: OfflineClientRef, ids: string[]): Promise>; + /** The user opened this file: the local store keeps recently opened files. */ + fileOpened?(client: OfflineClientRef, accountId: string, id: string): void; } const KEY = '__bulwarkOfflineHooks'; diff --git a/lib/jmap/search-merge.ts b/lib/jmap/search-merge.ts new file mode 100644 index 000000000..a7200b0f9 --- /dev/null +++ b/lib/jmap/search-merge.ts @@ -0,0 +1,382 @@ +/** + * 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. + * + * A row only the store has may be stale: deleted or moved since the store last + * synced. So the server is asked for those rows before they're shown. What it + * no longer has, or has outside the filter's folders, is dropped, and what it + * still has is shown as its current copy. It takes a request of its own, since + * these ids are only known once both answers are in. If that request fails, or + * the search itself did, the rows stay: an error must not lose results. + * + * 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: + * - Position 0 can be a new search or a refresh of a longer list, and only the + * next page tells which. So it runs on a cursor of its own next to the one + * already there. The next page takes whichever ended closest to its position. + * - With no cursor (it was evicted), the search is walked again from the start + * up to the position. A merged position never goes to the server as it is. + * - When the store has nothing to say for this search, no merged row exists and + * positions are the server's own, so requests pass straight through. + * + * `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. When the server fails + * on a later page, what it last said about having more still counts. + * + * `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 holds up the server's answer for at most + * the grace period, counted from when the server answered. Snippets for local + * rows are asked for separately, so excerpting bodies in the worker doesn't + * hold up the list either. + */ + +import { debug } from '@/lib/debug'; +import { attachSearchSnippets, type SearchSnippetResult } from '@/lib/search-snippet'; +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 = 64; +/** How far back an evicted search is walked again. Past this, the list ends. */ +const MAX_WALK_PAGES = 20; + +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); +} + +/** True, false, or null when the filter's answer depends on more than the folders. */ +function mailboxAnswer(filter: Record, boxes: readonly string[]): boolean | null { + if (typeof filter.operator === 'string') { + const answers = (Array.isArray(filter.conditions) ? filter.conditions as Array> : []) + .map((condition) => mailboxAnswer(condition, boxes)); + const unknown = answers.includes(null); + if (filter.operator === 'AND') return answers.includes(false) ? false : unknown ? null : true; + const any = answers.includes(true) ? true : unknown ? null : false; + if (filter.operator === 'OR') return any; + if (filter.operator === 'NOT') return any === null ? null : !any; + return null; + } + const answers: boolean[] = []; + if (typeof filter.inMailbox === 'string') answers.push(boxes.includes(filter.inMailbox)); + if (Array.isArray(filter.inMailboxOtherThan)) { + const excluded = filter.inMailboxOtherThan as string[]; + answers.push(boxes.some((box) => !excluded.includes(box))); + } + if (answers.includes(false)) return false; + return answers.length > 0 && Object.keys(filter).length === answers.length ? true : null; +} + +/** Whether a message's folders still fit the filter. Conditions on anything else don't count against it. */ +export function mailboxesFitFilter(filter: Record, mailboxIds: Record | undefined): boolean { + const boxes = Object.entries(mailboxIds ?? {}).filter(([, on]) => on).map(([id]) => id); + return mailboxAnswer(filter, boxes) !== false; +} + +interface MergeCursor { + /** Merged rows returned so far, which is where the next page should ask from. */ + next: number; + serverPosition: number; + localPosition: number; + serverTotal: number; + serverHasMore: boolean; + /** Local-only rows returned so far. */ + extras: number; + /** Local rows the server said are gone. */ + rejected: number; + /** Every row returned so far, so none appears twice. */ + shown: Set; + /** The store has nothing for this search: positions are the server's. */ + passthrough: boolean; +} + +function newCursor(): MergeCursor { + return { + next: 0, serverPosition: 0, localPosition: 0, serverTotal: 0, serverHasMore: false, + extras: 0, rejected: 0, shown: new Set(), passthrough: false, + }; +} + +interface CursorEntry { + cursor: MergeCursor; + /** A position 0 that came after later pages: a new search or a refresh. */ + restarted: MergeCursor | null; +} + +function receivedTime(email: Email): number { + return Date.parse(email.receivedAt) || 0; +} + +interface PageMerge { + emails: Email[]; + serverRead: number; + localRead: number; + extraIds: string[]; +} + +/** 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[] = []; + const extraIds: string[] = []; + let s = 0; + let l = 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, foundLocally: true }); + shown.add(fromLocal.id); + extraIds.push(fromLocal.id); + l += 1; + } + } + return { emails, serverRead: s, localRead: l, extraIds }; +} + +/** The promise's value, or null if it takes longer than `ms`. The promise must not reject. */ +function within(promise: Promise, ms: number): Promise { + return new Promise((resolve) => { + const timer = setTimeout(() => resolve(null), ms); + void promise.then((value) => { + clearTimeout(timer); + resolve(value); + }); + }); +} + +/** Runs a store call with its failure handled right away, so it can't go unhandled while the server is awaited. */ +function safely(what: string, call: () => Promise): Promise { + return Promise.resolve().then(call).catch((error: unknown) => { + debug.warn('jmap', `${what} failed:`, error); + return 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; + /** The server's current copies of these rows, leaving out what's gone or no longer fits the search. */ + verify?(rows: Email[]): Promise; + /** Highlights for rows only the store has. */ + localSnippets?(ids: string[]): Promise; + /** Hand local snippets back as `snippets` instead of waiting for them. */ + deferSnippets?: boolean; +} + +export interface LocalSearchMergeOptions { + /** How long a slow store may hold up the server's answer. */ + graceMs?: number; +} + +export class LocalSearchMerge { + private readonly cursors = new Map(); + private readonly graceMs: number; + + constructor(options: LocalSearchMergeOptions = {}) { + this.graceMs = options.graceMs ?? LOCAL_GRACE_MS; + } + + /** Least recently used goes first, so a search being paged through stays. */ + private remember(key: string, entry: CursorEntry): void { + this.cursors.delete(key); + this.cursors.set(key, entry); + if (this.cursors.size > MAX_CURSORS) this.cursors.delete(this.cursors.keys().next().value as string); + } + + async run(request: MergedSearchRequest): Promise { + const { key, limit, position } = request; + const entry = this.cursors.get(key); + if (position === 0) { + const fresh = newCursor(); + const alongside = entry && entry.cursor.next > 0 ? entry.cursor : null; + this.remember(key, alongside ? { cursor: alongside, restarted: fresh } : { cursor: fresh, restarted: null }); + return this.page(fresh, request, 0, limit, { alongside }); + } + if (!entry) return this.walkTo(request); + this.remember(key, entry); + const cursor = this.choose(entry, position); + if (cursor.passthrough) return request.server(position); + return this.page(cursor, request, position, limit, {}); + } + + /** Which cursor the page after a position 0 continues from. Only that one is kept from then on. */ + private choose(entry: CursorEntry, position: number): MergeCursor { + const { cursor, restarted } = entry; + if (!restarted) return cursor; + entry.restarted = null; + const restartedIsCloser = Math.abs(position - restarted.next) <= Math.abs(position - cursor.next); + if (cursor.passthrough || restartedIsCloser) { + entry.cursor = restarted; + return restarted; + } + for (const id of restarted.shown) cursor.shown.add(id); + return cursor; + } + + /** No cursor: read the search again from the start, so the rows the caller holds are accounted for. */ + private async walkTo(request: MergedSearchRequest): Promise { + const { key, limit, position } = request; + const cursor = newCursor(); + let last: SearchEmailsResult | null = null; + for (let page = 0; cursor.next < position && !cursor.passthrough; page += 1) { + if (page === MAX_WALK_PAGES) return { emails: [], hasMore: false, total: last?.total ?? position }; + last = await this.page(cursor, request, cursor.next, Math.min(limit, position - cursor.next), { walking: true }); + if (last.emails.length === 0 || !last.hasMore) break; + } + this.remember(key, { cursor, restarted: null }); + if (cursor.passthrough) return request.server(position); + // The search ended before the position: the caller holds rows that are gone. + cursor.next = position; + return this.page(cursor, request, position, limit, {}); + } + + private async page( + cursor: MergeCursor, + request: MergedSearchRequest, + position: number, + limit: number, + { alongside = null, walking = false }: { alongside?: MergeCursor | null; walking?: boolean }, + ): Promise { + // 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 = safely('Local search', () => 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 ? this.graceMs : 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. + cursor.passthrough = true; + cursor.next = position + serverPage.emails.length; + return serverPage; + } + + const server = serverPage?.emails ?? []; + const serverHasMore = serverPage?.hasMore ?? false; + const local = serverPage + ? await this.verified(request, cursor, server, serverHasMore, localPage?.emails ?? []) + : 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.extraIds.length; + if (serverPage) { + cursor.serverTotal = serverPage.total; + cursor.serverHasMore = merged.serverRead < server.length || serverHasMore; + } + + const localLeft = localPage ? merged.localRead < local.length || localPage.hasMore : false; + // A refresh of a longer list counts the local rows already in that list. + const extras = Math.max(cursor.extras, alongside?.extras ?? 0); + const rejected = Math.max(cursor.rejected, alongside?.rejected ?? 0); + const result: SearchEmailsResult = { + ...serverPage, + emails: merged.emails, + hasMore: cursor.serverHasMore || localLeft, + total: Math.max(cursor.serverTotal + extras, (localPage?.total ?? 0) - rejected, cursor.next), + }; + if (!walking && merged.extraIds.length > 0 && request.localSnippets) await this.addLocalSnippets(request, merged, result); + return result; + } + + /** The local rows, with what the server no longer has marked as read past, and the rest as its current copies. */ + private async verified( + request: MergedSearchRequest, + cursor: MergeCursor, + server: readonly Email[], + serverHasMore: boolean, + local: Email[], + ): Promise { + if (!request.verify) return local; + const serverIds = new Set(server.map((email) => email.id)); + const oldestPlaceable = serverHasMore && server.length > 0 ? receivedTime(server[server.length - 1]) : -Infinity; + const candidates = local.filter((row) => !serverIds.has(row.id) && !cursor.shown.has(row.id) && receivedTime(row) >= oldestPlaceable); + if (candidates.length === 0) return local; + let current: Email[]; + try { + current = await request.verify(candidates); + } catch (error) { + debug.warn('jmap', 'Checking local search results failed:', error); + return local; + } + const byId = new Map(current.map((row) => [row.id, row])); + for (const row of candidates) { + if (byId.has(row.id)) continue; + cursor.shown.add(row.id); + cursor.rejected += 1; + } + return local.map((row) => byId.get(row.id) ?? row); + } + + private async addLocalSnippets(request: MergedSearchRequest, merged: PageMerge, result: SearchEmailsResult): Promise { + const asked = safely('Local snippets', () => (request.localSnippets as NonNullable)(merged.extraIds)); + if (!request.deferSnippets) { + attachSearchSnippets(result.emails, (await within(asked, this.graceMs)) ?? undefined); + return; + } + const fromServer = result.snippets; + result.snippets = Promise.all([fromServer, asked]).then(([a, b]) => (a || b ? [...(a ?? []), ...(b ?? [])] : undefined)); + } +} diff --git a/lib/jmap/stand-in-ids.ts b/lib/jmap/stand-in-ids.ts new file mode 100644 index 000000000..00da263e6 --- /dev/null +++ b/lib/jmap/stand-in-ids.ts @@ -0,0 +1,76 @@ +/** + * Stand-in ids: what the local store hands out for an event created offline. + * + * A page can hold one long after the event reached the server: an open dialog, + * a list not refetched yet, a plugin. So the client swaps them for the real ids + * on the way to the network, and swaps them back in the answer, because callers + * look up the outcome under the id they asked about. + */ + +import type { OfflineMethodCall, OfflineResponse } from './offline-hooks'; + +const STAND_IN_PREFIX = 'local-'; + +export function isStandInId(id: string): boolean { + return id.startsWith(STAND_IN_PREFIX); +} + +export function newStandInId(): string { + return `${STAND_IN_PREFIX}${crypto.randomUUID()}`; +} + +/** The stand-in ids a request names. */ +export function standInsIn(calls: readonly OfflineMethodCall[]): string[] { + const found = new Set(); + for (const [name, args] of calls) { + if (name === 'CalendarEvent/set') { + for (const id of Object.keys((args.update ?? {}) as object)) if (isStandInId(id)) found.add(id); + for (const id of (args.destroy ?? []) as string[]) if (isStandInId(id)) found.add(id); + } else if (name === 'CalendarEvent/get') { + for (const id of (args.ids ?? []) as string[]) if (isStandInId(id)) found.add(id); + } + } + return [...found]; +} + +function renamedKeys(map: Record, rename: (id: string) => string): Record { + return Object.fromEntries(Object.entries(map).map(([id, value]) => [rename(id), value])); +} + +/** The request with every stand-in swapped for its real id. */ +export function withRealIds(calls: readonly T[], real: Readonly>): T[] { + const rename = (id: string) => real[id] ?? id; + return calls.map((call) => { + const [name, args, callId] = call; + if (name === 'CalendarEvent/set') { + const next = { ...args }; + if (args.update) next.update = renamedKeys(args.update as Record, rename); + if (args.destroy) next.destroy = (args.destroy as string[]).map(rename); + return [name, next, callId] as unknown as T; + } + if (name === 'CalendarEvent/get' && args.ids) { + return [name, { ...args, ids: (args.ids as string[]).map(rename) }, callId] as unknown as T; + } + return call; + }); +} + +/** The answer as if the caller's ids had gone out. The objects in a get's list keep their real id. */ +export function withStandInsBack(response: T, real: Readonly>): T { + const back = new Map(Object.entries(real).map(([standIn, id]) => [id, standIn])); + const rename = (id: string) => back.get(id) ?? id; + return { + ...response, + methodResponses: response.methodResponses.map(([name, result, callId]) => { + if (name !== 'CalendarEvent/set' && name !== 'CalendarEvent/get') return [name, result, callId]; + const next = { ...result }; + for (const key of ['updated', 'notUpdated', 'notDestroyed']) { + if (next[key]) next[key] = renamedKeys(next[key], rename); + } + for (const key of ['destroyed', 'notFound']) { + if (next[key]) next[key] = next[key].map(rename); + } + return [name, next, callId]; + }), + }; +} diff --git a/lib/jmap/types.ts b/lib/jmap/types.ts index 647a57309..6a19f659f 100644 --- a/lib/jmap/types.ts +++ b/lib/jmap/types.ts @@ -51,6 +51,8 @@ export interface Email { // subject / body excerpt with the matched terms in , set on search // hits only. See lib/search-snippet.ts. searchSnippet?: { subject: string | null; preview: string | null }; + /** A search row only the local store found. It's no sign of new mail, whatever its date (lib/jmap/search-merge.ts). */ + foundLocally?: boolean; textBody?: EmailBodyPart[]; htmlBody?: EmailBodyPart[]; bodyValues?: Record; diff --git a/lib/offline/__tests__/queue-sync.test.ts b/lib/offline/__tests__/queue-sync.test.ts new file mode 100644 index 000000000..da3099e06 --- /dev/null +++ b/lib/offline/__tests__/queue-sync.test.ts @@ -0,0 +1,53 @@ +import 'fake-indexeddb/auto'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { setStoreBackend } from '@/lib/store/client/api'; +import { InProcessStore } from '@/lib/store/__tests__/helpers/in-process-store'; +import { getOfflineHooks } from '@/lib/jmap/offline-hooks'; +import { usePolicyStore } from '@/stores/policy-store'; +import { useAccountStore } from '@/stores/account-store'; +import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy'; +import { queueSend, readQueue } from '../queue'; +import { registerOfflineHooks } from '../offline-mode'; +import { writeEnabled } from '../state'; + +// Whatever goes into the outbox asks for a Background Sync, once it's stored. + +const seen = vi.hoisted(() => ({ calls: 0, onCall: (() => {}) as () => void })); +vi.mock('@/lib/pwa-notify/outbox-sync', () => ({ requestOutboxSync: () => { seen.calls += 1; seen.onCall(); } })); + +let store: InProcessStore; + +beforeEach(() => { + store = new InProcessStore(); + setStoreBackend(store); + seen.calls = 0; + seen.onCall = () => {}; + usePolicyStore.setState({ loaded: true, subject: null, policy: { ...usePolicyStore.getState().policy, organisation: DEFAULT_ORGANISATION_POLICY } }); + useAccountStore.setState({ accounts: [{ id: 'acc', serverUrl: 'https://mail.example.org', username: 'alice@example.org' } as never] }); +}); + +afterEach(() => { + setStoreBackend(null); + writeEnabled([]); +}); + +describe('the outbox', () => { + it('asks for a sync after storing a message, and still returns its id', async () => { + let storedWhenAsked = -1; + seen.onCall = () => { void readQueue('acc').then(({ outbox }) => { storedWhenAsked = outbox.length; }); }; + const id = await queueSend('acc', { subject: 'S', to: [] }, { kind: 'email', args: [] }); + expect(id).toBe(1); + expect(seen.calls).toBe(1); + await vi.waitFor(() => expect(storedWhenAsked).toBe(1)); + }); + + it('asks for a sync after keeping a change the client made, not after a read', async () => { + writeEnabled(['acc']); + registerOfflineHooks(); + const client = { getServerUrl: () => 'https://mail.example.org', getUsername: () => 'alice@example.org', getAccountId: () => 'acc1' }; + await getOfflineHooks()?.answer(client, [['Email/set', { update: { m: { 'keywords/$seen': true } } }, '0']]); + expect(seen.calls).toBe(1); + await getOfflineHooks()?.answer(client, [['Email/get', { ids: [] }, '0']]); + expect(seen.calls).toBe(1); + }); +}); diff --git a/lib/pwa-notify/__tests__/login-fingerprint.test.ts b/lib/pwa-notify/__tests__/login-fingerprint.test.ts new file mode 100644 index 000000000..82cab2ad3 --- /dev/null +++ b/lib/pwa-notify/__tests__/login-fingerprint.test.ts @@ -0,0 +1,42 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; +import { loginFingerprint } from '../login-fingerprint'; + +// The registry keeps the server address as typed, the context cookie as the +// login route normalised it. Both must name the same login. +describe('loginFingerprint', () => { + const base = 'https://mail.example.org'; + const USER = 'alice@example.org'; + + it.each([ + 'https://Mail.Example.org', + 'https://MAIL.EXAMPLE.ORG/', + 'https://mail.example.org:443', + 'https://mail.example.org:443/', + 'https://mail.example.org//', + ])('reads %s as the same server', async (raw) => { + expect(await loginFingerprint(raw, USER)).toBe(await loginFingerprint(base, USER)); + }); + + it('drops the default port of http too', async () => { + expect(await loginFingerprint('http://mail.example.org:80', USER)).toBe(await loginFingerprint('http://mail.example.org', USER)); + }); + + it('ignores the case of the username and stray spaces', async () => { + expect(await loginFingerprint(base, ' Alice@Example.org ')).toBe(await loginFingerprint(base, USER)); + }); + + it.each([ + ['another host', 'https://other.example.org', USER], + ['another port', 'https://mail.example.org:8443', USER], + ['another scheme', 'http://mail.example.org', USER], + ['another path', 'https://mail.example.org/jmap', USER], + ['another user', base, 'bob@example.org'], + ])('tells %s apart', async (_name, server, user) => { + expect(await loginFingerprint(server, user)).not.toBe(await loginFingerprint(base, USER)); + }); + + it('keeps going on an address that is no URL', async () => { + expect(await loginFingerprint('not a url/', USER)).toBe(await loginFingerprint('not a url', USER)); + }); +}); diff --git a/lib/pwa-notify/__tests__/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..39287cd2f --- /dev/null +++ b/lib/pwa-notify/__tests__/page-actions.test.ts @@ -0,0 +1,280 @@ +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', mailboxIds: { [id === 'binned' ? 'bin' : 'inbox']: true } })), + getMailboxes: vi.fn(async () => [{ id: 'inbox', role: 'inbox' }, { id: 'x', originalId: 'bin', role: 'trash' }]), +}); +let clients: Record>; +let activeAccountId = 'alice@one'; +const gateOff = new Set(); +let hooksRegistered = false; +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 }) } })); +// Each login's own subject, and a policy that reads it: the gate of the login +// the notification is for, not of the one that's active here. +vi.mock('@/lib/org/account-subjects', () => ({ subjectForAccount: (id: string) => ({ email: id, groups: [] }) })); +vi.mock('@/stores/policy-store', () => ({ + usePolicyStore: { + getState: () => ({ + resolveFor: (subject: { email: string }) => ({ features: { archiveActionEnabled: !gateOff.has(subject.email) } }), + }), + }, +})); +vi.mock('../plugin-veto', () => ({ vetoHooksRegistered: () => hooksRegistered })); + +async function run(over: Partial = {}) { + const request: NotificationActionRequest = { + action: 'mark-read', + emailId: 'e1', + accountId: 'a', + slot: 0, + login: await loginFingerprint('https://one.example', 'alice@example.org'), + at: Date.now(), + ...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'; + gateOff.clear(); + hooksRegistered = false; + 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('takes a notification whose server address was written differently', async () => { + const answers = await run({ login: await loginFingerprint('https://ONE.example:443/', 'alice@example.org') }); + expect(answers).toEqual(['accepted', 'done']); + }); + + it('declines a slot it has no login for', async () => { + expect(await run({ slot: 7 })).toEqual(['declined']); + }); + + 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('declines a click from long ago: a tab that thawed has lost it', async () => { + expect(await run({ at: Date.now() - 6_000 })).toEqual(['declined']); + expect(batchMarkAsRead).not.toHaveBeenCalled(); + const opener = vi.fn(); + const remove = setNotificationReplyOpener(opener); + expect(await run({ action: 'reply', at: Date.now() - 6_000 })).toEqual(['declined']); + expect(opener).not.toHaveBeenCalled(); + remove(); + }); + + it('takes one from a moment ago', async () => { + expect(await run({ at: Date.now() - 2_000 })).toEqual(['accepted', 'done']); + }); + + it('checks a message from the worker for the shape it needs', () => { + const at = 1; + expect(isNotificationActionRequest({ action: 'archive', emailId: 'e', accountId: 'a', at })).toBe(true); + expect(isNotificationActionRequest({ action: 'archive', emailId: 'e', accountId: 'a' })).toBe(false); + expect(isNotificationActionRequest({ action: 'delete', emailId: 'e', accountId: 'a', at })).toBe(false); + expect(isNotificationActionRequest({ action: 'archive', emailId: '', accountId: 'a', at })).toBe(false); + expect(isNotificationActionRequest({ action: 'archive', emailId: 'e', at })).toBe(false); + expect(isNotificationActionRequest(null)).toBe(false); + }); +}); + +describe('with plugins listening on a veto hook', () => { + beforeEach(() => { hooksRegistered = true; }); + + it("doesn't pass on a shared account's Archive to the route, which has no plugins", async () => { + expect(await run({ action: 'archive', accountId: 'team' })).toEqual(['failed']); + expect(batchArchive).not.toHaveBeenCalled(); + }); + + it("doesn't pass on a login that isn't connected here", async () => { + delete clients['alice@one']; + activeAccountId = 'bob@two'; + expect(await run()).toEqual(['failed']); + }); + + it('still declines a notification of a login this tab does not hold', async () => { + expect(await run({ slot: 7 })).toEqual(['declined']); + }); + + it('runs what it can itself, through the store, where the hooks are', async () => { + expect(await run()).toEqual(['accepted', 'done']); + expect(batchMarkAsRead).toHaveBeenCalled(); + }); +}); + +describe('Mark read', () => { + it('goes through the store, with the login of the notification', async () => { + expect(await run()).toEqual(['accepted', 'done']); + 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'); + expect(clients['alice@one'].getEmail).toHaveBeenCalledWith('e1', 'team'); + }); + + it('reports a failure, so the worker opens the message', async () => { + batchMarkAsRead.mockRejectedValue(new Error('forbidden')); + expect(await run()).toEqual(['accepted', 'failed']); + }); + + it('reports a plugin stopping it', async () => { + batchMarkAsRead.mockResolvedValue(false); + expect(await run()).toEqual(['accepted', 'failed']); + }); + + it('still queues with the mail server out of reach, like the app\'s own button', async () => { + clients['alice@one'].getEmail.mockRejectedValue(new Error('offline')); + clients['alice@one'].getMailboxes.mockRejectedValue(new Error('offline')); + expect(await run()).toEqual(['accepted', 'done']); + expect(batchMarkAsRead).toHaveBeenCalled(); + }); + + it('leaves a message since moved to Trash alone, by the server id of the folder', async () => { + expect(await run({ emailId: 'binned' })).toEqual(['accepted', 'failed']); + expect(batchMarkAsRead).not.toHaveBeenCalled(); + }); +}); + +describe('Archive', () => { + 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', mailboxIds: { inbox: true } }], + userArchive: true, + }); + }); + + it('fails when the admin turned it off for this login, and the message opens', async () => { + gateOff.add('alice@one'); + expect(await run({ action: 'archive' })).toEqual(['failed']); + expect(batchArchive).not.toHaveBeenCalled(); + }); + + it("goes by the notification's login, not the active one", async () => { + const bob = { slot: 1, accountId: 'b', login: await loginFingerprint('https://two.example', 'bob@example.org') }; + gateOff.add('bob@two'); + expect(await run({ action: 'archive', ...bob })).toEqual(['failed']); + expect(batchArchive).not.toHaveBeenCalled(); + + gateOff.clear(); + gateOff.add('alice@one'); + expect(await run({ action: 'archive', ...bob })).toEqual(['accepted', 'done']); + }); + + it("leaves a shared account's Archive to the route: the store reads own folders only", async () => { + expect(await run({ action: 'archive', accountId: 'team' })).toEqual(['declined']); + expect(batchArchive).not.toHaveBeenCalled(); + }); + + 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 message it can\'t read with the mail server out of reach', async () => { + clients['alice@one'].getEmail.mockRejectedValue(new Error('offline')); + expect(await run({ action: 'archive' })).toEqual(['accepted', 'failed']); + expect(batchArchive).not.toHaveBeenCalled(); + }); + + it('leaves a message since moved to Trash alone', async () => { + expect(await run({ action: 'archive', emailId: 'binned' })).toEqual(['accepted', 'failed']); + expect(batchArchive).not.toHaveBeenCalled(); + }); + + it('reports a plugin or the store stopping it', async () => { + batchArchive.mockResolvedValue(false); + expect(await run({ action: 'archive' })).toEqual(['accepted', 'failed']); + }); +}); + +describe('Reply', () => { + it('declines on a page without the mail app', async () => { + expect(await run({ action: 'reply' })).toEqual(['declined']); + }); + + it('declines on such a page with plugins too: a link opens the composer, nothing is skipped', async () => { + hooksRegistered = true; + expect(await run({ action: 'reply' })).toEqual(['declined']); + }); + + it('hands the request to the mail page, typed text included', async () => { + const opener = vi.fn(); + const remove = setNotificationReplyOpener(opener); + 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__/plugin-veto.test.ts b/lib/pwa-notify/__tests__/plugin-veto.test.ts new file mode 100644 index 000000000..1dfcf9292 --- /dev/null +++ b/lib/pwa-notify/__tests__/plugin-veto.test.ts @@ -0,0 +1,36 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { hookSpec } from '@/lib/plugin-hook-catalog'; +import { emailHooks } from '@/lib/plugin-hooks'; +import { pluginsMayVeto, vetoHooksRegistered } from '../plugin-veto'; + +describe('pluginsMayVeto', () => { + it('reads the permission off the catalogue, which is email:write for all three today', () => { + for (const name of ['onBeforeEmailMove', 'onBeforeEmailArchive', 'onBeforeEmailReadStateChange']) { + expect(hookSpec(name)?.permission).toBe('email:write'); + } + }); + + it('is true for an enabled plugin with that permission', () => { + expect(pluginsMayVeto([{ enabled: true, permissions: ['email:read', 'email:write'] }])).toBe(true); + }); + + it('is false for a disabled one, for other permissions and for none', () => { + expect(pluginsMayVeto([{ enabled: false, permissions: ['email:write'] }])).toBe(false); + expect(pluginsMayVeto([{ enabled: true, permissions: ['email:read'] }])).toBe(false); + expect(pluginsMayVeto([{ enabled: true }])).toBe(false); + expect(pluginsMayVeto([])).toBe(false); + }); +}); + +describe('vetoHooksRegistered', () => { + const stops: Array<() => void> = []; + afterEach(() => { stops.splice(0).forEach((stop) => stop()); }); + + it.each(['onBeforeEmailMove', 'onBeforeEmailArchive', 'onBeforeEmailReadStateChange'] as const)('sees a listener on %s', (name) => { + expect(vetoHooksRegistered()).toBe(false); + const bus = emailHooks[name] as unknown as { register(id: string, fn: () => boolean): { dispose(): void } }; + const handle = bus.register('test-plugin', () => true); + stops.push(() => handle.dispose()); + expect(vetoHooksRegistered()).toBe(true); + }); +}); diff --git a/lib/pwa-notify/__tests__/tab-reply.test.ts b/lib/pwa-notify/__tests__/tab-reply.test.ts new file mode 100644 index 000000000..99f35a448 --- /dev/null +++ b/lib/pwa-notify/__tests__/tab-reply.test.ts @@ -0,0 +1,33 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { requestTabReply, takeTabReply, useNotificationTabReply } from '../tab-reply'; + +afterEach(() => { + vi.useRealTimers(); + useNotificationTabReply.setState({ pending: null }); +}); + +describe('a notification reply waiting for its Pro tab', () => { + it('reaches that message once, with the text', () => { + requestTabReply('e1', 'Thanks'); + expect(takeTabReply('e1')).toBe('Thanks'); + expect(takeTabReply('e1')).toBeNull(); + }); + + it('keeps an empty reply apart from none', () => { + requestTabReply('e1', ''); + expect(takeTabReply('e1')).toBe(''); + }); + + it('stays for its own message when another tab loads', () => { + requestTabReply('e1', 'Thanks'); + expect(takeTabReply('e2')).toBeNull(); + expect(takeTabReply('e1')).toBe('Thanks'); + }); + + it("doesn't open a composer minutes later", () => { + vi.useFakeTimers(); + requestTabReply('e1', 'Thanks'); + vi.advanceTimersByTime(3 * 60 * 1000); + expect(takeTabReply('e1')).toBeNull(); + }); +}); diff --git a/lib/pwa-notify/__tests__/worker-state.test.ts b/lib/pwa-notify/__tests__/worker-state.test.ts new file mode 100644 index 000000000..69e384f09 --- /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, pluginsMayVeto: true, labels: { archive: 'A', markRead: 'M', reply: 'R', replyPlaceholder: 'P' } }; + await writeNotificationActionPrefs(prefs); + const cache = await caches.open(PUSH_STATE_CACHE); + const stored = await cache.match(notificationActionPrefsKey(window.location.origin)); + 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', pluginsMayVeto: false, 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..13eeaa0f4 --- /dev/null +++ b/lib/pwa-notify/login-fingerprint.ts @@ -0,0 +1,25 @@ +/** + * 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. + */ +function canonicalServerUrl(serverUrl: string): string { + // The registry keeps what the user typed, the cookie what the login route + // normalised: `https://Mail.Example.org:443/` and `https://mail.example.org` + // are the same server. + try { + const url = new URL(serverUrl); + return `${url.origin}${url.pathname}`.replace(/\/+$/, ''); + } catch { + return serverUrl.replace(/\/+$/, ''); + } +} + +export async function loginFingerprint(serverUrl: string, username: string | undefined): Promise { + const text = `${canonicalServerUrl(serverUrl)}\n${(username ?? '').trim().toLowerCase()}`; + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text)); + return Array.from(new Uint8Array(digest).slice(0, 16), (byte) => byte.toString(16).padStart(2, '0')).join(''); +} diff --git a/lib/pwa-notify/page-actions.ts b/lib/pwa-notify/page-actions.ts new file mode 100644 index 000000000..96b1db101 --- /dev/null +++ b/lib/pwa-notify/page-actions.ts @@ -0,0 +1,159 @@ +import { subjectForAccount } from '@/lib/org/account-subjects'; +import { useAccountStore } from '@/stores/account-store'; +import { useAuthStore } from '@/stores/auth-store'; +import { useEmailStore } from '@/stores/email-store'; +import { usePolicyStore } from '@/stores/policy-store'; +import { loginFingerprint } from './login-fingerprint'; +import { vetoHooksRegistered } from './plugin-veto'; + +/** What the service worker posts when a notification's button is clicked (public/sw.js). */ +export interface NotificationActionRequest { + action: 'archive' | 'mark-read' | 'reply'; + emailId: string; + accountId: string; + /** When the worker asked. */ + at: number; + threadId?: string; + slot?: number; + login?: string; + /** 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; on + * `failed` the message opens, and the route isn't tried. + */ +export type NotificationActionStatus = 'declined' | 'accepted' | 'done' | 'failed'; + +export interface NotificationReplyRequest { + emailId: string; + accountId: string; + slot: number; + text?: string; +} + +/** + * A tab that was frozen and thaws after this has lost the click: the worker has + * moved on, maybe to the route or to opening the message. Past every wait in + * sw.js for a tab to take an action. + */ +const REQUEST_MAX_AGE_MS = 5_000; + +type ReplyOpener = (request: NotificationReplyRequest) => void; + +let replyOpener: ReplyOpener | null = null; + +/** 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 + && typeof request.at === 'number'; +} + +/** The account of this tab's registry the notification is for, if it still means that login. */ +async function loginOf(request: NotificationActionRequest) { + const entry = useAccountStore.getState().accounts.find((account) => account.cookieSlot === request.slot); + if (!entry || !request.login) return null; + return (await loginFingerprint(entry.serverUrl, entry.username)) === request.login ? entry : null; +} + +/** The Archive gate as it applies to this login, whichever login is active here. */ +function archiveEnabledFor(accountEntryId: string): boolean { + return usePolicyStore.getState().resolveFor(subjectForAccount(accountEntryId)).features?.archiveActionEnabled !== false; +} + +/** + * Runs a notification's button in this tab, through the same store actions as + * the app's own buttons: plugin hooks, archive mode and the Archive gate all + * apply, and the list on screen follows. What this tab can't do while plugins + * listen ends in `failed`, not `declined`: the route has no plugins. + */ +export async function runNotificationAction( + request: NotificationActionRequest, + answer: (status: NotificationActionStatus) => void, +): Promise { + try { + if (Date.now() - request.at > REQUEST_MAX_AGE_MS) { + answer('declined'); + return; + } + const entry = await loginOf(request); + if (!entry) { + answer('declined'); + return; + } + const passOn = () => answer(vetoHooksRegistered() ? 'failed' : 'declined'); + const auth = useAuthStore.getState(); + const client = auth.getClientForAccount(entry.id) ?? (entry.id === auth.activeAccountId ? auth.client : null); + const activeClient = auth.client; + if (request.action === 'reply') { + if (!client || !replyOpener || request.slot === undefined) { + answer('declined'); + return; + } + replyOpener({ emailId: request.emailId, accountId: request.accountId, slot: request.slot, text: request.text }); + answer('done'); + return; + } + if (!client || !activeClient) { + passOn(); + return; + } + + const own = request.accountId === client.getAccountId(); + if (request.action === 'archive') { + // The route would refuse it too. + if (!archiveEnabledFor(entry.id)) { + answer('failed'); + return; + } + // The store's targeted archive reads a login's own folders only. A shared + // account's Archive is the route's to find. + if (!own) { + passOn(); + return; + } + } + + answer('accepted'); + const accountId = own ? undefined : request.accountId; + // Unknown with the mail server out of reach. Mark read then queues like the + // app's own button; Archive files by date and can't go without the message. + const [email, mailboxes] = await Promise.all([ + client.getEmail(request.emailId, accountId).catch(() => null), + client.getMailboxes(accountId).catch(() => []), + ]); + if (request.action === 'archive' && !email) { + answer('failed'); + return; + } + // Since the notification it may have gone to Trash or Junk: not for a toast + // to bring back or to mark. The app opens it. + const inBin = Object.keys(email?.mailboxIds ?? {}).some((id) => { + const mailbox = mailboxes.find((m) => m.id === id || m.originalId === id); + return mailbox?.role === 'trash' || mailbox?.role === 'junk'; + }); + if (inBin) { + answer('failed'); + return; + } + const done = request.action === 'mark-read' + ? await useEmailStore.getState().batchMarkAsRead(activeClient, true, { emailIds: [request.emailId], client, accountId }) + : await useEmailStore.getState().batchArchive(activeClient, { emailIds: [request.emailId], client, emails: email ? [email] : [], userArchive: true }); + answer(done ? 'done' : 'failed'); + } catch { + answer('failed'); + } +} diff --git a/lib/pwa-notify/plugin-veto.ts b/lib/pwa-notify/plugin-veto.ts new file mode 100644 index 000000000..48f1f2b52 --- /dev/null +++ b/lib/pwa-notify/plugin-veto.ts @@ -0,0 +1,16 @@ +import { hookSpec } from '@/lib/plugin-hook-catalog'; +import { emailHooks } from '@/lib/plugin-hooks'; + +/** The hooks that can stop what a notification's buttons do. */ +const VETO_HOOKS = ['onBeforeEmailMove', 'onBeforeEmailArchive', 'onBeforeEmailReadStateChange'] as const; + +/** Whether one of these plugins could register one of those hooks, today or after a page load. */ +export function pluginsMayVeto(plugins: ReadonlyArray<{ enabled?: boolean; permissions?: readonly unknown[] }>): boolean { + const needed = new Set(VETO_HOOKS.map((name) => hookSpec(name)?.permission)); + return plugins.some((plugin) => plugin.enabled && plugin.permissions?.some((permission) => needed.has(permission as never))); +} + +/** Whether a plugin in this tab listens on one of those hooks now. */ +export function vetoHooksRegistered(): boolean { + return VETO_HOOKS.some((name) => emailHooks[name].size > 0); +} diff --git a/lib/pwa-notify/tab-reply.ts b/lib/pwa-notify/tab-reply.ts new file mode 100644 index 000000000..ad8ff34eb --- /dev/null +++ b/lib/pwa-notify/tab-reply.ts @@ -0,0 +1,27 @@ +import { create } from 'zustand'; + +/** A tab that hasn't got the message by then has been left behind. */ +const MAX_AGE_MS = 2 * 60 * 1000; + +interface TabReplyState { + pending: { emailId: string; text: string; at: number } | null; +} + +/** + * A notification's Reply in the Pro shell, where a message lives in a tab of + * its own that fetches it. Waits here, not in the tab store: that one is kept + * in localStorage, and a reply being typed is not for keeping. + */ +export const useNotificationTabReply = create(() => ({ pending: null })); + +export function requestTabReply(emailId: string, text: string): void { + useNotificationTabReply.setState({ pending: { emailId, text, at: Date.now() } }); +} + +/** The text for a reply to this message, once; null when none waits. */ +export function takeTabReply(emailId: string): string | null { + const { pending } = useNotificationTabReply.getState(); + if (!pending || pending.emailId !== emailId) return null; + useNotificationTabReply.setState({ pending: null }); + return Date.now() - pending.at < MAX_AGE_MS ? pending.text : null; +} diff --git a/lib/pwa-notify/worker-state.ts b/lib/pwa-notify/worker-state.ts new file mode 100644 index 000000000..161f3885c --- /dev/null +++ b/lib/pwa-notify/worker-state.ts @@ -0,0 +1,60 @@ +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'; + /** An enabled plugin may stop or watch these actions: the worker leaves the route alone. */ + pluginsMayVeto: boolean; + labels: { archive: string; markRead: string; reply: string; replyPlaceholder: string }; +} + +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/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/recurrence-scope-edit.ts b/lib/recurrence-scope-edit.ts index 4de698447..7c45c9db5 100644 --- a/lib/recurrence-scope-edit.ts +++ b/lib/recurrence-scope-edit.ts @@ -645,6 +645,19 @@ function raisesSequences(series: Partial, master: CalendarEvent, */ export type OccurrenceOutcome = boolean | 'stopped'; +/** + * "This and all following" splits a series: ends the old one, creates the rest as + * a new one. Queued on a device these are two writes the server takes or refuses + * apart, with no one there to put the series back when only one goes through, so + * it waits for the server. + */ +export class SeriesSplitNeedsServerError extends Error { + constructor() { + super('Splitting a series needs the server'); + this.name = 'SeriesSplitNeedsServerError'; + } +} + /** The store actions resolve to false when a plugin stopped the write. */ function stopped(result: boolean | void): boolean { return result === false; @@ -1035,6 +1048,11 @@ export async function saveOccurrenceEdit( // From the first occurrence on is the whole series; ended before its // first occurrence, the series would be left with none. if (isFirstOccurrence(event, master)) return saveOccurrenceEdit('all', edit, actions); + if (client.answeredFromDevice) { + // Whether the server is there right now: the last answer may be minutes old. + await client.getCalendarEvent(master.originalId ?? master.id, master.accountId); + if (client.answeredFromDevice()) throw new SeriesSplitNeedsServerError(); + } const truncated = await truncateSeries(master, event, actions, { sendScheduling, userEmails: edit.userEmails }); if (!truncated) return 'stopped'; const { restore, announced, removed } = truncated; 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..8cd3d10a1 --- /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([]); + }, 30_000); + } +}); diff --git a/lib/store/__tests__/calendar-sync.test.ts b/lib/store/__tests__/calendar-sync.test.ts new file mode 100644 index 000000000..2b3e90c65 --- /dev/null +++ b/lib/store/__tests__/calendar-sync.test.ts @@ -0,0 +1,948 @@ +// @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), session = CALENDAR_SESSION) { + const db = openMemoryStore(); + const events: StoreEvent[] = []; + const engine = new AccountEngine({ + account: 'a@fake.test', + db, + 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('1.0', undefined, { + ...CALENDAR_SESSION, + accounts: { ...CALENDAR_SESSION.accounts, other: { name: 'other', isPersonal: false, accountCapabilities: { [CAL]: {} } } }, + }); + s.server.addEvent({ id: 'same', title: 'Mine' }); + await s.engine.requestSync(); + await call(s, 'CalendarEvent/set', { accountId: 'other', update: { same: { title: 'Theirs' } } }); + 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('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'); + // The next sync reads it back, so there's nothing left to send. + expect(await flush(s)).toMatchObject({ 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({ pending: 0 }); + expect(s.server.events.size).toBe(1); + expect(setCalls(s.server).filter(([, args]) => args.create)).toHaveLength(1); + expect(uid).toBe([...s.server.events.values()][0].uid); + 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: `${JMAP_ACCOUNT}:series@example.org: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__/calendar-writes-review.test.ts b/lib/store/__tests__/calendar-writes-review.test.ts new file mode 100644 index 000000000..9ab9cfc37 --- /dev/null +++ b/lib/store/__tests__/calendar-writes-review.test.ts @@ -0,0 +1,520 @@ +// @vitest-environment node +import { afterEach, describe, expect, it } from 'vitest'; +import type { CalendarEvent } from '@/lib/jmap/types'; +import { DEFAULT_SCOPE } from '../area'; +import { calendarArea } from '../calendar'; +import { listUpcomingAlerts } from '../calendar/reminders'; +import type { CalendarConflict } from '../calendar/writes'; +import { AccountEngine } from '../engine/account-engine'; +import { TransportError, type JmapResponse, type MethodCall, type StoreSession } from '../engine/transport'; +import { wire, type Wire } from './helpers/client-wire'; +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' }; +const K1 = '2026-11-17T10:00:00'; +const K2 = '2026-11-24T10:00:00'; + +function setup(version: StalwartVersion = '1.0', session: StoreSession = CALENDAR_SESSION) { + const server = new FakeCalendarJmap(version); + const db = openMemoryStore(); + const engine = new AccountEngine({ + account: 'a@fake.test', db, session, transport: server, areas: [calendarArea], scope: DEFAULT_SCOPE, now: () => NOW, sleep: async () => {}, + }); + return { server, db, engine }; +} +type Setup = ReturnType; + +async function write(s: Setup, args: Record, jmap = JMAP_ACCOUNT) { + const response = await s.engine.answer([['CalendarEvent/set', { accountId: jmap, ...args }, 'x']]); + return response!.methodResponses[0][1]; +} + +async function read(s: Setup, id: string, properties?: string[]) { + const response = await s.engine.answer([['CalendarEvent/get', { accountId: JMAP_ACCOUNT, ids: [id], ...(properties ? { properties } : {}) }, 'x']]); + return response!.methodResponses[0][1].list[0] as Record | undefined; +} + +async function query(s: Setup, args: Record = {}) { + const response = await s.engine.answer([['CalendarEvent/query', { accountId: JMAP_ACCOUNT, ...args }, 'x']]); + return response!.methodResponses[0][1].ids as string[]; +} + +const flush = (s: Pick) => s.engine.areaCall('calendar.flush', undefined) as Promise<{ sent: number; conflicts: number; pending: number }>; +const conflicts = (s: Pick) => s.engine.areaCall('calendar.conflicts', undefined) as Promise; +const sets = (server: FakeCalendarJmap) => server.calls.filter(([name, args]) => name === 'CalendarEvent/set' && !(args.update && 'h333333' in (args.update as object))); +const pending = (s: Pick) => s.db.all<{ kind: string; patch: string; record_id: string; send_scheduling: number | null }>('SELECT kind, patch, record_id, send_scheduling FROM calendar_pending ORDER BY seq'); +const NEW = { title: 'Fresh', calendarIds: { cal1: true }, start: '2026-11-20T09:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin' }; + +async function synced(version: StalwartVersion = '1.0', add?: (server: FakeCalendarJmap) => void) { + const s = setup(version); + s.server.addEvent({ id: 'e1', title: 'Meeting', description: 'Old' }); + add?.(s.server); + await s.engine.requestSync(); + s.server.calls.length = 0; + return s; +} + +function failSets(s: Setup, decide: (args: Record) => JmapResponse | Error | null) { + const real = s.server.request.bind(s.server); + s.server.request = async (calls: MethodCall[]) => { + for (const [name, args] of calls) { + if (name !== 'CalendarEvent/set' || (args.update && 'h333333' in (args.update as object))) continue; + const outcome = decide(args); + if (outcome instanceof Error) throw outcome; + if (outcome) return outcome; + } + return real(calls); + }; +} + +describe('an edit after a create whose answer got lost', () => { + it('reaches the server as an update, without a second create', async () => { + const s = await synced(); + const id = (await write(s, { create: { c: NEW } })).created.c.id as string; + s.server.loseNextSetResponse = true; + await flush(s); + await write(s, { update: { [id]: { title: 'Edited after the attempt' } } }); + expect(await flush(s)).toMatchObject({ pending: 0 }); + expect(s.server.events.size).toBe(2); + expect(sets(s.server).filter(([, args]) => args.create)).toHaveLength(1); + const onServer = [...s.server.events.values()].find((event) => event.uid !== 'e1@example.org'); + expect(onServer?.title).toBe('Edited after the attempt'); + expect(await read(s, onServer!.id)).toMatchObject({ title: 'Edited after the attempt' }); + }); + + it('also removes what the edit removed', async () => { + const s = await synced(); + const id = (await write(s, { create: { c: { ...NEW, description: 'To go' } } })).created.c.id as string; + s.server.loseNextSetResponse = true; + await flush(s); + await write(s, { update: { [id]: { description: null } } }); + await flush(s); + const onServer = [...s.server.events.values()].find((event) => event.title === 'Fresh'); + expect(onServer?.description ?? null).toBeNull(); + }); + + it('applies an import whose uid the server already has as an update', async () => { + const s = await synced(); + await write(s, { create: { c: { ...NEW, uid: 'e1@example.org', title: 'Imported', description: 'Old' } } }); + await flush(s); + expect(sets(s.server).filter(([, args]) => args.create)).toEqual([]); + expect(s.server.events.size).toBe(1); + expect(s.server.events.get('e1')).toMatchObject({ title: 'Imported', description: 'Old' }); + expect(await query(s)).toEqual(['e1']); + }); + + it('sends no update when the server already holds the same event', async () => { + const s = await synced(); + const held = s.server.events.get('e1')!; + await write(s, { create: { c: { uid: 'e1@example.org', title: held.title, calendarIds: held.calendarIds, start: held.start, duration: held.duration, timeZone: held.timeZone, '@type': 'Event' } } }); + await flush(s); + expect(sets(s.server)).toEqual([]); + expect(pending(s)).toEqual([]); + }); +}); + +describe('writes with different scheduling flags', () => { + it('stay separate and go out in order', async () => { + const s = await synced(); + await write(s, { update: { e1: { title: 'Quiet edit' } }, sendSchedulingMessages: false }); + await write(s, { update: { e1: { 'participants/me/participationStatus': 'accepted' } }, sendSchedulingMessages: true }); + expect(pending(s).map((row) => row.send_scheduling)).toEqual([0, 1]); + expect(await read(s, 'e1')).toMatchObject({ title: 'Quiet edit', participants: { me: { participationStatus: 'accepted' } } }); + + await flush(s); + const sent = sets(s.server); + expect(sent.map(([, args]) => args.sendSchedulingMessages)).toEqual([false, true]); + expect((sent[0][1].update as Record).e1).toEqual({ title: 'Quiet edit' }); + expect((sent[1][1].update as Record).e1).toEqual({ 'participants/me/participationStatus': 'accepted' }); + }); + + it('still merge when the flags are the same', async () => { + const s = await synced(); + await write(s, { update: { e1: { title: 'One' } }, sendSchedulingMessages: true }); + await write(s, { update: { e1: { description: 'Two' } }, sendSchedulingMessages: true }); + expect(pending(s)).toHaveLength(1); + }); + + it('create and a later edit that notifies go out as two writes, the second under the real id', async () => { + const s = await synced(); + const id = (await write(s, { create: { c: NEW }, sendSchedulingMessages: false })).created.c.id as string; + await write(s, { update: { [id]: { title: 'Told everyone' } }, sendSchedulingMessages: true }); + expect(pending(s).map((row) => row.kind)).toEqual(['create', 'update']); + await flush(s); + const sent = sets(s.server); + expect(sent).toHaveLength(2); + expect(sent[0][1].sendSchedulingMessages).toBe(false); + const real = Object.keys(sent[1][1].update as object)[0]; + expect(s.server.events.get(real)).toMatchObject({ title: 'Told everyone' }); + expect(sent[1][1].sendSchedulingMessages).toBe(true); + expect(await flush(s)).toMatchObject({ pending: 0 }); + }); + + it('a create deleted before it left takes its later rows with it', async () => { + const s = await synced(); + const id = (await write(s, { create: { c: NEW }, sendSchedulingMessages: true })).created.c.id as string; + await write(s, { update: { [id]: { title: 'Quiet' } }, sendSchedulingMessages: false }); + await write(s, { destroy: [id] }); + expect(pending(s)).toEqual([]); + await flush(s); + expect(sets(s.server)).toEqual([]); + }); + + it('a conflict on the first takes the ones built on it along', async () => { + const s = await synced(); + await write(s, { update: { e1: { title: 'Mine' } }, sendSchedulingMessages: false }); + await write(s, { update: { e1: { description: 'Mine too' } }, sendSchedulingMessages: true }); + s.server.patchEvent('e1', { title: 'Theirs' }); + await flush(s); + expect(sets(s.server)).toEqual([]); + expect((await conflicts(s)).map((conflict) => conflict.local)).toEqual([{ title: 'Mine' }, { description: 'Mine too' }]); + expect(await read(s, 'e1')).toMatchObject({ title: 'Theirs', description: 'Old' }); + }); +}); + +describe('a whole recurrenceOverrides map from stale state', () => { + const series = (server: FakeCalendarJmap) => server.addEvent({ id: 's', title: 'Weekly', start: '2026-11-03T10:00:00', recurrenceRule: WEEKLY }); + + it('does not erase an override someone else added', async () => { + const s = await synced('1.0', series); + await write(s, { update: { s: { recurrenceOverrides: { [K1]: { title: 'Mine' } } } } }); + s.server.patchEvent('s', { recurrenceOverrides: { [K2]: { title: 'Theirs' } } }); + await flush(s); + expect(await conflicts(s)).toEqual([]); + expect(s.server.events.get('s')?.recurrenceOverrides).toEqual({ [K1]: { title: 'Mine' }, [K2]: { title: 'Theirs' } }); + }); + + it('goes out as a whole map only when the server has none', async () => { + const s = await synced('1.0', series); + await write(s, { update: { s: { recurrenceOverrides: { [K1]: { title: 'Mine' } } } } }); + await flush(s); + expect((sets(s.server)[0][1].update as Record).s).toEqual({ recurrenceOverrides: { [K1]: { title: 'Mine' } } }); + expect(s.server.events.get('s')?.recurrenceOverrides).toEqual({ [K1]: { title: 'Mine' } }); + }); + + it('removes only the entries the sender knew and left out', async () => { + const s = await synced('1.0', (server) => { + series(server); + server.patchEvent('s', { recurrenceOverrides: { [K1]: { title: 'Dropped' } } }); + }); + await write(s, { update: { s: { recurrenceOverrides: { [K2]: { title: 'Kept' } } } } }); + s.server.patchEvent('s', { [`recurrenceOverrides/2026-12-01T10:00:00`]: { title: 'Theirs' } }); + await flush(s); + expect(s.server.events.get('s')?.recurrenceOverrides).toEqual({ [K2]: { title: 'Kept' }, '2026-12-01T10:00:00': { title: 'Theirs' } }); + }); + + it('collides on the same entry', async () => { + const s = await synced('1.0', series); + await write(s, { update: { s: { recurrenceOverrides: { [K1]: { title: 'Mine' } } } } }); + s.server.patchEvent('s', { recurrenceOverrides: { [K1]: { title: 'Theirs' } } }); + await flush(s); + expect((await conflicts(s))[0]).toMatchObject({ reason: 'changedOnServer' }); + }); +}); + +describe('one bad write', () => { + it('a 4xx refuses that write and the next one still goes', async () => { + const s = await synced('1.0', (server) => server.addEvent({ id: 'e2', title: 'Other' })); + await write(s, { update: { e1: { title: 'Bad' } } }); + await write(s, { update: { e2: { title: 'Good' } } }); + failSets(s, (args) => (args.update && 'e1' in (args.update as object) ? new TransportError('http', 'Bad request', 400) : null)); + expect(await flush(s)).toMatchObject({ sent: 1, conflicts: 1, pending: 0 }); + expect((await conflicts(s))[0]).toMatchObject({ recordId: 'e1', reason: 'refused' }); + expect(s.server.events.get('e2')?.title).toBe('Good'); + expect(await read(s, 'e1')).toMatchObject({ title: 'Meeting' }); + }); + + it('401, 429 and a 5xx stay pending', async () => { + for (const error of [new TransportError('auth', 'x', 401), new TransportError('rate', 'x', 429), new TransportError('http', 'x', 500)]) { + const s = await synced(); + await write(s, { update: { e1: { title: 'Later' } } }); + failSets(s, () => error); + expect(await flush(s)).toMatchObject({ conflicts: 0, pending: 1 }); + expect(await conflicts(s)).toEqual([]); + } + }); + + it('an answer without a verdict, or a passing refusal, retries instead of deleting or finishing', async () => { + const s = await synced(); + const id = (await write(s, { create: { c: NEW } })).created.c.id as string; + await write(s, { update: { e1: { title: 'Later' } } }); + let round = 0; + failSets(s, (args) => { + if (round > 3) return null; + if (args.create) { + const notCreated = round === 1 ? { c: { type: 'serverFail' } } : round === 2 ? { c: { type: 'rateLimit' } } : undefined; + return { methodResponses: [['CalendarEvent/set', notCreated ? { notCreated } : {}, 's']] }; + } + return { methodResponses: [['CalendarEvent/set', {}, 's']] }; + }); + for (round = 1; round <= 3; round += 1) { + await flush(s); + expect(await conflicts(s)).toEqual([]); + expect(pending(s)).toHaveLength(2); + expect(await read(s, id)).toMatchObject({ title: 'Fresh' }); + } + expect(await flush(s)).toMatchObject({ pending: 0 }); + expect(s.server.events.get('e1')?.title).toBe('Later'); + expect([...s.server.events.values()].filter((event) => event.title === 'Fresh')).toHaveLength(1); + }); +}); + +describe('a lost answer on an edit', () => { + const series = (server: FakeCalendarJmap) => server.addEvent({ + id: 's', + title: 'Weekly', + description: 'Series', + start: '2026-11-03T10:00:00', + recurrenceRule: WEEKLY, + participants: { + me: { '@type': 'Participant', calendarAddress: 'mailto:a@example.org', participationStatus: 'needs-action', roles: { attendee: true } }, + boss: { '@type': 'Participant', calendarAddress: 'mailto:boss@example.org', participationStatus: 'accepted', roles: { owner: true } }, + }, + }); + + for (const version of ['1.0', '0.16'] as const) { + it(`an occurrence answer that read back changed on ${version} is not a conflict, and isn't sent twice`, async () => { + const s = await synced(version, series); + const master = s.server.events.get('s') as unknown as { participants: Record> } & Record; + const entry = { + title: 'Weekly', + description: 'Series', + start: K1, + duration: 'PT1H', + sequence: 0, + participants: { ...master.participants, me: { ...master.participants.me, participationStatus: 'accepted' } }, + }; + await write(s, { update: { s: { recurrenceOverrides: { [K1]: entry } } }, sendSchedulingMessages: true }); + s.server.loseNextSetResponse = true; + expect(await flush(s)).toMatchObject({ pending: 1 }); + expect(await flush(s)).toMatchObject({ conflicts: 0, pending: 0 }); + expect(await conflicts(s)).toEqual([]); + expect(sets(s.server)).toHaveLength(1); + }); + + it(`a change to another override on ${version} is not a conflict on this one`, async () => { + const s = await synced(version, (server) => { + series(server); + server.patchEvent('s', { recurrenceOverrides: { [K1]: { title: 'One' }, [K2]: { title: 'Two' } } }); + }); + await write(s, { update: { s: { [`recurrenceOverrides/${K1}`]: { title: 'Mine' } } } }); + s.server.patchEvent('s', { [`recurrenceOverrides/${K2}`]: { title: 'Theirs' } }); + await flush(s); + expect(await conflicts(s)).toEqual([]); + expect((s.server.events.get('s')?.recurrenceOverrides as Record)[K1].title).toBe('Mine'); + }); + } + + it('an edit made after the lost one reads the lost value as ours, and goes out', async () => { + const s = await synced(); + await write(s, { update: { e1: { title: 'First' } } }); + s.server.loseNextSetResponse = true; + await flush(s); + await write(s, { update: { e1: { title: 'Second' } } }); + await flush(s); + expect(await conflicts(s)).toEqual([]); + expect(s.server.events.get('e1')?.title).toBe('Second'); + }); + + it('drops an RSVP whose value the server already holds, without sending', async () => { + const s = await synced('1.0', (server) => server.addEvent({ id: 'r', participants: { me: { participationStatus: 'accepted' } } })); + await write(s, { update: { r: { 'participants/me/participationStatus': 'accepted' } }, sendSchedulingMessages: true }); + await flush(s); + expect(sets(s.server)).toEqual([]); + expect(pending(s)).toEqual([]); + expect(await conflicts(s)).toEqual([]); + }); + + it('drops it during sync too when someone answered the same from elsewhere', async () => { + const s = await synced('1.0', (server) => server.addEvent({ id: 'r', participants: { me: { participationStatus: 'needs-action' } } })); + await write(s, { update: { r: { 'participants/me/participationStatus': 'accepted' } }, sendSchedulingMessages: true }); + s.server.patchEvent('r', { 'participants/me/participationStatus': 'accepted' }); + await flush(s); + expect(sets(s.server)).toEqual([]); + }); +}); + +describe('sync finding a created event', () => { + it('adopts the stand-in by uid during the delta, without a flush', async () => { + const s = await synced(); + const id = (await write(s, { create: { c: NEW } })).created.c.id as string; + s.server.loseNextSetResponse = true; + await flush(s); + expect(await query(s)).toHaveLength(2); + // Only the delta gets through: no lookup by uid, no writes. + const real = s.server.request.bind(s.server); + s.server.request = async (calls: MethodCall[]) => { + if (calls.some(([name, args]) => (name === 'CalendarEvent/set' && !(args.update && 'h333333' in (args.update as object))) + || (name === 'CalendarEvent/query' && (args.filter as { uid?: string } | undefined)?.uid))) { + throw new TransportError('network', 'Failed to fetch'); + } + return real(calls); + }; + await s.engine.requestSync(); + const ids = await query(s); + expect(ids).toHaveLength(2); + expect(ids.filter((value) => value.startsWith('local-'))).toEqual([]); + expect(pending(s)).toEqual([]); + expect((await read(s, id))?.id).toBe(ids.find((value) => value !== 'e1')); + }); +}); + +describe('a full list', () => { + it('keeps a live event the paging skipped, and drops only what is really gone', async () => { + const s = setup(); + for (let i = 0; i < 620; i += 1) s.server.addEvent({ id: `e${String(i).padStart(3, '0')}` }); + await s.engine.requestSync(); + // The stored state can't be used, so this pass lists everything again. + s.db.run("UPDATE sync_state SET state = 'not usable!' WHERE type = 'CalendarEvent'"); + let pages = 0; + const real = s.server.request.bind(s.server); + s.server.request = async (calls: MethodCall[]) => { + const answer = await real(calls); + if (calls.some(([name]) => name === 'CalendarEvent/query') && (pages += 1) === 1) { + // A delete before the page boundary moves one event across it. + s.server.removeEvent('e000'); + } + return answer; + }; + await s.engine.requestSync(); + const ids = new Set(s.db.all<{ id: string }>('SELECT id FROM calendar_event').map((row) => row.id)); + expect(ids.has('e000')).toBe(false); + expect(ids.has('e500')).toBe(true); + expect(ids.size).toBe(619); + }); +}); + +describe('flush around other accounts', () => { + const session: StoreSession = { + ...CALENDAR_SESSION, + accounts: { + ...CALENDAR_SESSION.accounts, + shared: { name: 'shared', isPersonal: false, accountCapabilities: { [CAL]: {} } }, + }, + }; + + it('does not touch the network for a never-synced account once stopped', async () => { + const s = setup(); + await write(s, { create: { c: NEW } }); + s.engine.setOnline(false); + const before = s.server.requests; + await flush(s); + expect(s.server.requests).toBe(before); + expect(pending(s)).toHaveLength(1); + }); + + it('goes on with the next account after one answers accountNotFound', async () => { + const s = setup('1.0', session); + s.server.addEvent({ id: 'e1', title: 'Meeting' }); + await s.engine.requestSync(); + // The primary account comes first and fails, the shared one must still go. + await write(s, { update: { e1: { title: 'Primary' } } }); + await write(s, { update: { e1: { title: 'Shared' } } }, 'shared'); + const real = s.server.request.bind(s.server); + s.server.request = async (calls: MethodCall[]) => { + if (calls[0][1].accountId === JMAP_ACCOUNT && calls[0][0] === 'CalendarEvent/changes') { + return { methodResponses: [['error', { type: 'accountNotFound' }, calls[0][2]]] }; + } + return real(calls); + }; + await flush(s); + expect(s.server.events.get('e1')?.title).toBe('Shared'); + expect(pending(s).map((row) => JSON.parse(row.patch))).toEqual([{ title: 'Primary' }]); + }); + + it('forgets an account the session no longer lists, and its reminders', async () => { + const s = setup('1.0', session); + s.server.addEvent({ + id: 'e1', + start: '2026-11-10T10:00:00', + alerts: { a: { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } } }, + }); + await s.engine.requestSync(); + const options = { from: Date.UTC(2026, 10, 10), to: Date.UTC(2026, 10, 11), timeZone: 'Europe/Berlin' }; + expect(listUpcomingAlerts(s.db, options).map((alert) => alert.jmap).sort()).toEqual([JMAP_ACCOUNT, 'shared'].sort()); + s.engine.setSession(CALENDAR_SESSION); + await s.engine.requestSync(); + expect(s.db.all("SELECT 1 FROM calendar_event WHERE jmap = 'shared'")).toEqual([]); + expect(s.db.all("SELECT 1 FROM object WHERE jmap = 'shared'")).toEqual([]); + expect(listUpcomingAlerts(s.db, options).map((alert) => alert.jmap)).toEqual([JMAP_ACCOUNT]); + }); +}); + +describe('reminder keys', () => { + it('stay the same when a stand-in becomes real', async () => { + const s = await synced(); + await write(s, { create: { c: { ...NEW, alerts: { a: { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } } } } } }); + const options = { from: Date.UTC(2026, 10, 20), to: Date.UTC(2026, 10, 21), timeZone: 'Europe/Berlin' }; + const [before] = listUpcomingAlerts(s.db, options); + await flush(s); + const [after] = listUpcomingAlerts(s.db, options); + expect(before.baseEventId).toMatch(/^local-/); + expect(after.baseEventId).not.toMatch(/^local-/); + expect(after.key).toBe(before.key); + }); + + it('differ between two accounts holding the same id', async () => { + const s = setup('1.0', { + ...CALENDAR_SESSION, + accounts: { ...CALENDAR_SESSION.accounts, shared: { name: 'shared', isPersonal: false, accountCapabilities: { [CAL]: {} } } }, + }); + s.server.addEvent({ id: 'e1', start: '2026-11-10T10:00:00', alerts: { a: { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } } } }); + await s.engine.requestSync(); + expect(listUpcomingAlerts(s.db, { from: Date.UTC(2026, 10, 10), to: Date.UTC(2026, 10, 11), timeZone: 'Europe/Berlin' })).toHaveLength(2); + }); +}); + +describe('creates through the real client', () => { + let w: Wire; + afterEach(() => w?.restore()); + + async function online() { + w = wire(new FakeCalendarJmap('1.0'), [calendarArea], NOW); + await w.engine.requestSync(); + return w.newClient(); + } + const created = () => w.sent.flatMap(([name, args]) => (name === 'CalendarEvent/set' ? Object.values((args.create ?? {}) as Record) : [])); + + it('createCalendarEvent has a uid on its first send, and a lost answer does not duplicate', async () => { + const client = await online(); + w.server.loseNextSetResponse = true; + const event = await client.createCalendarEvent({ ...NEW } as Partial); + expect(created()[0].uid).toBeTruthy(); + expect(event.id).toMatch(/^local-/); + expect(w.server.events.size).toBe(1); + await flush(w); + expect(w.server.events.size).toBe(1); + expect(await conflicts(w)).toEqual([]); + expect(pending(w)).toEqual([]); + }); + + it('batchCreateCalendarEvents does the same, which covers import', async () => { + const client = await online(); + w.server.loseNextSetResponse = true; + const result = await client.batchCreateCalendarEvents([{ ...NEW } as Partial, { ...NEW, title: 'Two' } as Partial]); + expect(result.created).toHaveLength(2); + expect(created().every((event) => event.uid)).toBe(true); + expect(w.server.events.size).toBe(2); + await flush(w); + expect(w.server.events.size).toBe(2); + expect(await conflicts(w)).toEqual([]); + }); + + it('createCalendarTask does the same', async () => { + const client = await online(); + w.server.loseNextSetResponse = true; + await client.createCalendarTask({ title: 'Task', calendarIds: { cal1: true }, due: '2026-12-01T10:00:00' }); + expect(created()[0].uid).toBeTruthy(); + await flush(w); + expect(w.server.events.size).toBe(1); + }); + + it('keeps a uid the caller chose', async () => { + const client = await online(); + await client.createCalendarEvent({ ...NEW, uid: 'chosen@example.org' } as Partial); + expect(created()[0].uid).toBe('chosen@example.org'); + }); +}); diff --git a/lib/store/__tests__/helpers/client-wire.ts b/lib/store/__tests__/helpers/client-wire.ts new file mode 100644 index 000000000..f46fb343f --- /dev/null +++ b/lib/store/__tests__/helpers/client-wire.ts @@ -0,0 +1,101 @@ +/** + * 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 { setOfflineHooks, type OfflineMethodCall, type OfflineResponse } from '@/lib/jmap/offline-hooks'; +import { DEFAULT_SCOPE, type StoreArea } from '../../area'; +import { AccountEngine } from '../../engine/account-engine'; +import { TransportError, 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(); + setOfflineHooks(null); + }, + }; + 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); + if (!state.online) throw new TypeError('Failed to fetch'); + try { + return json(await server.request(calls)); + } catch (error) { + if (error instanceof TransportError && error.kind === 'network') throw new TypeError('Failed to fetch'); + throw error; + } + }); + setOfflineHooks({ + skipNetwork: () => !state.online, + answer: async (_client, calls: OfflineMethodCall[]) => { + state.sent.push(...calls); + return engine.answer(calls) as Promise; + }, + record: () => {}, + noteNetwork: () => {}, + serverGone: async () => true, + blob: async () => null, + recordBlob: () => {}, + holdUpload: async () => null, + resolveStandIns: (_client, ids) => engine.areaCall('calendar.resolveStandIns', { ids }) as Promise>, + }); + 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..795f42b50 --- /dev/null +++ b/lib/store/__tests__/helpers/fake-calendar.ts @@ -0,0 +1,408 @@ +/** + * 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. + * - Overrides read back differently from what was written. 1.0 keeps only + * what differs from the series, a participant as a pointer key. 0.16 keeps + * the entry, adds `updated` to every entry on any change of the series, and + * drops an attendee-only `roles` from participants. + */ + +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'; + +/** One override as the server hands it back (measured on both, 2026-11-03). */ +function readBack(version: StalwartVersion, master: FakeEvent, key: string, entry: Record, tick: number): Record { + if (entry.excluded) return entry; + if (version === '1.0') { + const out: Record = {}; + for (const [name, value] of Object.entries(entry)) { + if (name === 'updated') continue; + if (name === 'start' && value === key) continue; + if (name === 'participants') { + const series = (master.participants ?? {}) as Record>; + for (const [id, person] of Object.entries(value as Record>)) { + for (const [field, fieldValue] of Object.entries(person)) { + if (JSON.stringify(series[id]?.[field]) !== JSON.stringify(fieldValue)) out[`participants/${id}/${field}`] = fieldValue; + } + } + continue; + } + if (name !== 'sequence' && JSON.stringify(master[name]) === JSON.stringify(value)) continue; + out[name] = value; + } + return out; + } + const out: Record = { ...entry, updated: `2026-11-03T00:00:${String(tick % 60).padStart(2, '0')}Z` }; + if (out.participants) { + out.participants = Object.fromEntries(Object.entries(out.participants as Record>).map(([id, person]) => { + const { roles, ...rest } = person; + const attendeeOnly = roles && Object.keys(roles).length === 1 && (roles as Record).attendee; + return [id, attendeeOnly ? rest : person]; + })); + } + return out; +} + +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 { + const next = applyPatchObject(this.events.get(id) as FakeEvent, patch); + if (next.recurrenceOverrides) { + const tick = this.eventLog.log.length; + next.recurrenceOverrides = Object.fromEntries(Object.entries(next.recurrenceOverrides as Record>) + .map(([key, entry]) => [key, readBack(this.version, next, key, entry, tick)])); + } + this.events.set(id, next); + 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 0bd810e22..b218138f8 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/__tests__/mail-answers.test.ts b/lib/store/__tests__/mail-answers.test.ts index 94346f231..d27aaca1a 100644 --- a/lib/store/__tests__/mail-answers.test.ts +++ b/lib/store/__tests__/mail-answers.test.ts @@ -283,7 +283,7 @@ describe('the rest of the mail methods', () => { expect(response?.methodResponses[1]).toEqual(['error', expect.objectContaining({ type: 'cannotCalculateChanges' }), 'm']); }); - it('gives a per-call error for SearchSnippet/get and keeps the search', async () => { + it('answers a search with its snippets in one request', async () => { const server = new FakeJmapMail(); server.addEmail({ subject: 'Findme' }); const { engine } = engineFor(server); @@ -293,7 +293,7 @@ describe('the rest of the mail methods', () => { ['SearchSnippet/get', { filter: { text: 'findme' }, '#emailIds': { resultOf: '0', name: 'Email/query', path: '/ids' } }, 'snippets'], ]); expect(response?.methodResponses[0][1].ids).toHaveLength(1); - expect(response?.methodResponses[1][0]).toBe('error'); + expect(response?.methodResponses[1][0]).toBe('SearchSnippet/get'); }); it('counts keywords for Keyword/get', async () => { diff --git a/lib/store/__tests__/search.test.ts b/lib/store/__tests__/search.test.ts new file mode 100644 index 000000000..711d48539 --- /dev/null +++ b/lib/store/__tests__/search.test.ts @@ -0,0 +1,557 @@ +// @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 { bodyText, deleteEmail } from '../mail/records'; +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[], db = openMemoryStore()) { + const server = new FakeJmap(); + for (const email of emails) server.addEmail(email); + const engine = new AccountEngine({ + account: 'a@fake.test', + db, + 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, 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' }]; +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('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

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('caps the patterns one highlight can use, keeping it working', async () => { + const words = Array.from({ length: 512 }, (_, i) => `wort${String(i).padStart(3, '0')}`); + const { snippets } = await storeWith([{ id: 'one', subject: words.join(' ').slice(0, 1000) }]); + const conditions = Array.from({ length: 32 }, (_, i) => ({ subject: words.slice(i * 16, i * 16 + 16).join(' ') })); + const { list } = await snippets({ operator: 'AND', conditions }, ['one']); + const marks = (list[0].subject as string).split('').length - 1; + expect(marks).toBeGreaterThan(0); + // The subject holds about 125 of the words, so more than this would be uncapped. + expect(marks).toBeLessThanOrEqual(64); + }); + + it('caps how much of a subject it reads', async () => { + const { snippets } = await storeWith([{ id: 'one', subject: `foo ${'x'.repeat(100_000)}` }]); + const subject = (await snippets({ subject: 'foo' }, ['one'])).list[0].subject as string; + expect(subject.startsWith('foo')).toBe(true); + expect(subject.length).toBeLessThan(2_000); + }); + + 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/area.ts b/lib/store/area.ts index 9393d05a4..c435a4bce 100644 --- a/lib/store/area.ts +++ b/lib/store/area.ts @@ -109,9 +109,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 ef64b3775..08fe2c607 100644 --- a/lib/store/areas.ts +++ b/lib/store/areas.ts @@ -1,7 +1,8 @@ /** Every part of the local store. New areas go here. */ import type { StoreArea } from './area'; +import { calendarArea } from './calendar'; import { mailArea } from './mail'; import { outboxArea } from './outbox'; -export const STORE_AREAS: readonly StoreArea[] = [mailArea, outboxArea]; +export const STORE_AREAS: readonly StoreArea[] = [mailArea, outboxArea, 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..550930279 --- /dev/null +++ b/lib/store/calendar/flush.ts @@ -0,0 +1,201 @@ +/** + * Sends pending calendar writes, one write per request, so a lost response leaves + * one write's outcome open and not a batch's. An event's writes go in order. + * + * A row is marked before it goes out. A create so marked first looks for its uid + * on the server, because repeating it could duplicate the event. A marked edit + * whose values read back from the server is done, and one that's repeated writes + * the same values. A delete sent twice finds nothing the second time. + * + * A write that can't work goes to the conflict list and the others still go out. + * Anything that may pass stays pending: an answer with no verdict for it, a set + * error such as serverFail or rateLimit, and for the whole run a network failure, + * 401, 429 or 5xx. + */ + +import { jmapError } from '@/lib/jmap/jmap-error'; +import type { CalendarEvent } from '@/lib/jmap/types'; +import { isOverrideSeriesKeyRejection, withoutOverrideSeriesKeys } from '@/lib/recurrence-instances'; +import type { SyncContext } from '../area'; +import { JmapMethodError, responseFor, TransportError } from '../engine/transport'; +import { holdsPatch, patchForServer } from './patches'; +import { calendarUsing, EVENT_PROPERTIES, eventRow, type CalendarObject } from './records'; +import { adoptStandIn, finishWrite, markSent, pendingBySeq, 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); +} + +function passes(refusal: SetError | undefined): boolean { + return !refusal || PASSING_ERRORS.has(refusal.type ?? ''); +} + +/** A 4xx other than 401 and 429, which the transport reports as their own kinds: this request won't work. */ +function isRefusedRequest(error: unknown): boolean { + return error instanceof TransportError && error.kind === 'http' && (error.status ?? 0) >= 400 && (error.status ?? 0) < 500; +} + +type Outcome = 'sent' | 'conflict' | 'again' | 'retry'; + +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; +} + +function conflict(ctx: SyncContext, row: PendingRow, reason: 'refused' | 'destroyedOnServer', error: SetError): Outcome { + ctx.db.transaction(() => recordConflict(ctx, row, reason, error)); + return 'conflict'; +} + +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) { + markSent(ctx, row, payload); + let result = await sendSet(ctx, row, { create: { c: payload } }); + if (isSeriesKeyRefusal(result.notCreated?.c)) { + result = await sendSet(ctx, row, { create: { c: withoutOverrideSeriesKeys(payload as Partial) } }); + } + realId = result.created?.c?.id ?? null; + const refusal = result.notCreated?.c; + if (!realId) { + // Both Stalwarts refuse a second event with one uid. Nothing of ours went + // in then, so what the server holds is compared as a whole. + if (refusal?.type === 'invalidProperties' && refusal.properties?.includes('uid')) { + ctx.db.transaction(() => ctx.db.run('UPDATE calendar_pending SET sent = NULL WHERE seq = ?', [row.seq])); + realId = await findByUid(ctx, row.jmap, row.uid); + } + if (!realId) return passes(refusal) ? 'retry' : conflict(ctx, row, 'refused', refusal as SetError); + } + } + const event = await fetchEvent(ctx, row.jmap, realId) ?? ({ ...payload, id: realId } as CalendarObject); + const left = ctx.db.transaction(() => adoptStandIn(ctx, row, event)); + return left ? 'again' : 'sent'; +} + +async function sendUpdate(ctx: SyncContext, row: PendingRow): Promise { + const id = row.record_id; + const stored = eventRow(ctx.db, row.jmap, id); + const server = JSON.parse(stored?.server_json ?? stored?.json ?? '{}') as Record; + const queued = JSON.parse(row.patch) as Record; + let patch = patchForServer(queued, server); + // Nothing to change, and an answer sent twice would mail the organizer twice. + if (holdsPatch(server, queued) || Object.keys(patch).length === 0) { + return ctx.db.transaction(() => finishWrite(ctx, row, {})) ? 'again' : 'sent'; + } + markSent(ctx, row, patch); + 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 && !passes(refusal)) return conflict(ctx, row, refusal.type === 'notFound' ? 'destroyedOnServer' : 'refused', refusal); + if (refusal || !result.updated || !(id in result.updated)) return 'retry'; + return ctx.db.transaction(() => finishWrite(ctx, row, patch)) ? 'again' : 'sent'; +} + +async function sendDestroy(ctx: SyncContext, row: PendingRow): Promise { + // A create turned delete: only the server knows whether the create got there. + const id = row.uid ? await findByUid(ctx, row.jmap, row.uid) : row.record_id; + const finish = (): Outcome => ctx.db.transaction(() => { + ctx.db.run('DELETE FROM calendar_pending WHERE seq = ?', [row.seq]); + ctx.db.run('DELETE FROM calendar_event WHERE jmap = ? AND id = ?', [row.jmap, row.record_id]); + ctx.emit({ type: 'changed', account: ctx.account, collections: ['CalendarEvent'] }); + return 'sent' as const; + }); + if (!id) return finish(); + const result = await sendSet(ctx, row, { destroy: [id] }); + const refusal = result.notDestroyed?.[id]; + if (refusal?.type === 'notFound' || result.destroyed?.includes(id)) return finish(); + return passes(refusal) ? 'retry' : conflict(ctx, row, 'refused', refusal as SetError); +} + +function conflictCount(ctx: SyncContext): number { + return Number(ctx.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM calendar_conflict')?.n ?? 0); +} + +/** + * Sends what's pending for one JMAP account. Network failures and a server's bad + * moment are thrown, with the rest still pending. + */ +export async function sendPending(ctx: SyncContext, jmap: string): Promise { + const tally: FlushTally = { sent: 0, conflicts: 0 }; + const finished = new Set(); + // An event whose write has to wait: its later writes wait with it. + const waiting = 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)) && !waiting.has(candidate.record_id)); + if (!row) return tally; + const conflictsBefore = conflictCount(ctx); + 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) { + const current = pendingBySeq(ctx.db, row.seq) ?? row; + if (isRefusedRequest(error)) { + outcome = conflict(ctx, current, 'refused', { type: 'http', description: (error as Error).message }); + } else if (error instanceof JmapMethodError && !isPassingError(error)) { + outcome = conflict(ctx, current, 'refused', { type: error.type, description: error.message }); + } else { + throw error; + } + } + if (outcome === 'again') continue; + if (outcome === 'retry') { + waiting.add(row.record_id); + continue; + } + finished.add(Number(row.seq)); + if (outcome === 'sent') tally.sent += 1; + else tally.conflicts += conflictCount(ctx) - conflictsBefore; + } +} diff --git a/lib/store/calendar/index.ts b/lib/store/calendar/index.ts new file mode 100644 index 000000000..12d908660 --- /dev/null +++ b/lib/store/calendar/index.ts @@ -0,0 +1,20 @@ +import type { StoreArea } from '../area'; +import { CALENDAR_METHODS } from './methods'; +import { CALENDAR_MIGRATIONS } from './schema'; +import { calendarSyncer, flushCalendar } from './sync'; +import { listConflicts, resolveStandIns } 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.resolveStandIns': (ctx, params: { ids: string[] }) => resolveStandIns(ctx.db, params.ids), + '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/patches.ts b/lib/store/calendar/patches.ts new file mode 100644 index 000000000..8fb751de3 --- /dev/null +++ b/lib/store/calendar/patches.ts @@ -0,0 +1,141 @@ +/** + * Reading a PatchObject against the server's copy of an event. + * + * What goes in doesn't read back the same (measured on both Stalwarts, + * 2026-11-03). 1.0 keeps only what an override changes against its series, a + * participant as a pointer key such as `participants/me/participationStatus`. + * 0.16 keeps the whole entry, moves `updated` in every entry on any change of the + * series, and drops an attendee-only `roles`. Both drop `@type` from a recurrence + * rule and add `iCalendar` to a location. So a value counts as held when the + * server's has what was sent, and an override is read as its occurrence. + */ + +import { applyPatchObject } from '@/lib/jmap/patch-object'; +import { pointerToken, pointerTokenValue } from '@/lib/jmap/patch-pointer'; +import { applyOverridePatch, stableJson } from '@/lib/recurrence-instances'; +import { valueAtPointer } from './records'; + +type Patch = Record; + +const OVERRIDE_POINTER = /^recurrenceOverrides\/([^/]+)(?:\/(.+))?$/; +const IGNORED_MEMBERS = new Set(['@type']); + +function isEmptyDefault(value: unknown): boolean { + if (value === null || value === undefined || value === false || value === 0 || value === '') return true; + return typeof value === 'object' && Object.keys(value as object).length === 0; +} + +/** Whether `actual` has what `sent` says. Extra members in `actual` don't matter. */ +function contains(actual: unknown, sent: unknown, member?: string): boolean { + if (actual === null || actual === undefined) { + if (isEmptyDefault(sent)) return true; + return member === 'roles' && stableJson(sent) === stableJson({ attendee: true }); + } + if (Array.isArray(sent)) { + return Array.isArray(actual) && actual.length === sent.length && sent.every((item, i) => contains(actual[i], item)); + } + if (sent !== null && typeof sent === 'object') { + if (typeof actual !== 'object' || Array.isArray(actual)) return false; + return Object.entries(sent).every(([name, value]) => IGNORED_MEMBERS.has(name) + || contains((actual as Record)[name], value, name)); + } + return actual === sent; +} + +function overrideEntry(server: Patch, key: string): Patch | undefined { + return (server.recurrenceOverrides as Record | null | undefined)?.[key]; +} + +/** Whether the server's copy holds `value` at PatchObject key `key`. */ +export function holdsValue(server: Patch, key: string, value: unknown): boolean { + if (key === 'recurrenceOverrides') { + const entries = Object.entries((value ?? {}) as Record); + if (entries.length === 0) return Object.keys((server.recurrenceOverrides ?? {}) as object).length === 0; + return entries.every(([id, entry]) => holdsValue(server, `recurrenceOverrides/${pointerToken(id)}`, entry)); + } + const match = OVERRIDE_POINTER.exec(key); + if (!match) return contains(valueAtPointer(server, key), value); + const id = pointerTokenValue(match[1]); + const entry = overrideEntry(server, id); + const inEntry = match[2]; + if (value === null && !inEntry) return !entry; + if (!entry) return false; + if (!inEntry && (entry.excluded || (value as Patch).excluded)) return Boolean(entry.excluded) === Boolean((value as Patch).excluded); + // `start` as the rule puts it: a server leaves it out of an unmoved override. + const occurrence = applyOverridePatch({ ...server, start: id } as Patch, entry); + return contains(inEntry ? valueAtPointer(occurrence, inEntry) : occurrence, value); +} + +/** Whether a row's write is already on the server, whoever put it there. */ +export function holdsPatch(server: Patch, patch: Patch): boolean { + return Object.entries(patch).every(([key, value]) => holdsValue(server, key, value)); +} + +// `updated` inside an override moves on 0.16 when any part of the series changes. +function comparable(key: string, value: unknown): string { + if (OVERRIDE_POINTER.test(key) && value && typeof value === 'object') { + const { updated: _updated, ...rest } = value as Patch; + return stableJson(rest); + } + return stableJson(value); +} + +export interface PatchState { + patch: Patch; + /** The server's value at each key when this was queued. */ + base: Patch; + /** Values of ours that may have reached the server: a send whose answer was lost, an earlier row of the same event. */ + sent: Patch | null; +} + +/** Whether the server moved a value this patch writes, to something that isn't ours. */ +export function collides({ patch, base, sent }: PatchState, server: Patch): boolean { + return Object.keys(patch).some((key) => { + if (holdsValue(server, key, patch[key])) return false; + if (sent && key in sent && holdsValue(server, key, sent[key])) return false; + return comparable(key, valueAtPointer(server, key)) !== comparable(key, base[key]); + }); +} + +/** + * A whole overrides map becomes one key per entry that differs from what the + * writer could see. Written whole, it would erase any override someone else + * added while the write waited; this way that one collides only on the same entry. + */ +export function asOverridePointers(patch: Patch, shown: Patch): Patch { + if (!('recurrenceOverrides' in patch)) return patch; + const { recurrenceOverrides: map, ...rest } = patch; + const entries = (map ?? {}) as Record; + const known = (shown.recurrenceOverrides ?? {}) as Record; + const out: Patch = { ...rest }; + for (const [id, entry] of Object.entries(entries)) { + if (stableJson(entry) !== stableJson(known[id])) out[`recurrenceOverrides/${pointerToken(id)}`] = entry; + } + for (const id of Object.keys(known)) { + if (!(id in entries)) out[`recurrenceOverrides/${pointerToken(id)}`] = null; + } + return out; +} + +/** + * The patch as it can go out now. A pointer into overrides fails while a series + * has none, on both Stalwarts, so they go as a whole map then, which erases + * nothing. Removing an entry the server doesn't have is nothing to send. + */ +export function patchForServer(patch: Patch, server: Patch): Patch { + const pointers = Object.keys(patch).filter((key) => OVERRIDE_POINTER.test(key)); + if (pointers.length === 0) return patch; + const out: Patch = {}; + for (const [key, value] of Object.entries(patch)) if (!OVERRIDE_POINTER.test(key)) out[key] = value; + if (Object.keys((server.recurrenceOverrides ?? {}) as object).length === 0) { + const built = applyPatchObject({ recurrenceOverrides: {} }, Object.fromEntries(pointers.map((key) => [key, patch[key]]))); + if (Object.keys(built.recurrenceOverrides).length > 0) out.recurrenceOverrides = built.recurrenceOverrides; + return out; + } + for (const key of pointers) { + const entry = overrideEntry(server, pointerTokenValue((OVERRIDE_POINTER.exec(key) as RegExpExecArray)[1])); + if (patch[key] === null && !entry) continue; + out[key] = patch[key]; + } + return out; +} 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..b42307346 --- /dev/null +++ b/lib/store/calendar/reminders.ts @@ -0,0 +1,163 @@ +/** + * 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 series' uid and the fire time, never an id. Synthetic ids can + * change, browser occurrences don't exist on the server, and a stand-in id becomes + * a real one. 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); + // With the account: Stalwart's ids repeat across accounts, and one meeting can be in two. + const stableId = `${row.jmap}:${typeof object.uid === 'string' && object.uid ? object.uid : object.id}`; + + 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(stableId, 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(stableId, 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..3bf7e82ba --- /dev/null +++ b/lib/store/calendar/schema.ts @@ -0,0 +1,81 @@ +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); + + -- An event's writes in order. A later one merges when the flags match (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 it went out, or when the server may hold it for another reason. + attempted INTEGER NOT NULL DEFAULT 0, + -- What went out last, or for a later row what its earlier rows wrote: + -- values of ours the server may hold. + sent TEXT, + -- 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 + ); + CREATE INDEX calendar_pending_record ON calendar_pending (jmap, record_id, seq); + CREATE INDEX calendar_pending_uid ON calendar_pending (jmap, uid); + + -- 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..71ad94a0c --- /dev/null +++ b/lib/store/calendar/sync.ts @@ -0,0 +1,324 @@ +/** + * 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, pendingChain, recordConflict, type PendingRow } 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) && pendingChain(ctx.db, jmap, event.id).length === 0 + && !ctx.db.get('SELECT 1 AS x FROM calendar_pending WHERE jmap = ? AND uid = ?', [jmap, typeof event.uid === 'string' ? event.uid : ''])) 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) ctx.setSyncState(jmap, 'CalendarEvent', { cursor: { ...cursor } }); + }); + if (done && !(await finishFullList(ctx, jmap, cursor))) return false; + 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; + } + } +} + +/** + * A delete during paging moves later objects across a page boundary, so what this + * pass didn't see may still be there. Asks for it by id, and only what the server + * says it doesn't have goes. False when stopped. + */ +async function finishFullList(ctx: SyncContext, jmap: string, cursor: ListCursor): Promise { + const unseen = ctx.db.all<{ id: string }>('SELECT id FROM calendar_event WHERE jmap = ? AND gen < ?', [jmap, cursor.gen]) + .map((row) => row.id) + .filter((id) => !isStandIn(id)); + const found: CalendarObject[] = []; + const gone: string[] = []; + for (let i = 0; i < unseen.length; i += PAGE) { + if (ctx.stopped()) return false; + const response = await ctx.transport.request([['CalendarEvent/get', { + accountId: jmap, + ids: unseen.slice(i, i + PAGE), + properties: EVENT_PROPERTIES, + }, 'g']], calendarUsing(ctx.session)); + const [, result] = responseFor(response, 'g'); + found.push(...result.list as CalendarObject[]); + gone.push(...result.notFound as string[]); + } + ctx.db.transaction(() => { + for (const event of found) storeEvent(ctx, jmap, event, cursor.gen); + for (const id of gone) applyServerDestroy(ctx, jmap, id); + ctx.setSyncState(jmap, 'CalendarEvent', { state: cursor.captured, phase: null, cursor: null }); + }); + 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 { + if (ctx.stopped()) return false; + 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 share taken away: the session stops listing the account, and nothing of it + * stays on the device, reminders included. Its waiting writes can't go anywhere. + */ +function forgetUnlistedAccounts(ctx: SyncContext): void { + const listed = new Set(Object.keys(ctx.session.accounts)); + if (listed.size === 0) return; + const kept = ctx.db.all<{ jmap: string }>( + `SELECT jmap FROM calendar_event UNION SELECT jmap FROM calendar_pending + UNION SELECT jmap FROM object WHERE type IN ('Calendar', 'ParticipantIdentity')`, + ).map((row) => row.jmap); + for (const jmap of kept) { + if (listed.has(jmap)) continue; + ctx.db.transaction(() => { + for (;;) { + const row = ctx.db.get('SELECT * FROM calendar_pending WHERE jmap = ? ORDER BY seq LIMIT 1', [jmap]); + if (!row) break; + recordConflict(ctx, row, 'destroyedOnServer', { type: 'accountNotFound' }); + } + ctx.db.run('DELETE FROM calendar_event WHERE jmap = ?', [jmap]); + ctx.db.run('DELETE FROM calendar_id_map WHERE jmap = ?', [jmap]); + ctx.db.run("DELETE FROM object WHERE jmap = ? AND type IN ('Calendar', 'ParticipantIdentity')", [jmap]); + ctx.db.run("DELETE FROM sync_state WHERE jmap = ? AND type IN ('Calendar', 'CalendarEvent')", [jmap]); + metaSet(ctx.db, syntheticIdsKey(jmap), null); + }); + ctx.emit({ type: 'changed', account: ctx.account, collections: ['Calendar', 'CalendarEvent'] }); + } +} + +// 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) { + forgetUnlistedAccounts(ctx); + 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 () => { + forgetUnlistedAccounts(ctx); + const accounts = ctx.db.all<{ jmap: string }>('SELECT DISTINCT jmap FROM calendar_pending').map((row) => row.jmap); + const total: FlushTally = { sent: 0, conflicts: 0 }; + for (const jmap of accounts) { + try { + const tally = await syncAndSend(ctx, jmap); + total.sent += tally.sent; + total.conflicts += tally.conflicts; + } catch (error) { + // No network, no credentials: no point in trying the other accounts. + if (error instanceof TransportError && (error.kind === 'network' || error.kind === 'auth')) break; + // Anything else is about this account, whose writes stay pending. + if (!(error instanceof TransportError) && !(error instanceof JmapMethodError) && !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..d47ff1eca --- /dev/null +++ b/lib/store/calendar/writes.ts @@ -0,0 +1,398 @@ +/** + * Calendar writes made while the server can't be reached. + * + * An event has a chain of pending rows, sent in order. A later write merges into + * the last row when both have the same scheduling flag: edit + edit is one patch, + * create + edit is one create. Different flags stay apart, because they mean + * different things to attendees. The change to one occurrence that must reach + * those who answered is a quiet write and then one with messages, and a change + * made with "don't notify" must not go out with a later answer that does. + * + * Answers read calendar_event.json, which is the server's copy with the chain + * applied. server_json keeps the server's copy underneath, so sync can move it on + * and reapply the chain, or notice that the two collide. + */ + +import { applyPatchObject } from '@/lib/jmap/patch-object'; +import { isStandInId, newStandInId } from '@/lib/jmap/stand-in-ids'; +import { mergeOverrideEntry, stableJson } from '@/lib/recurrence-instances'; +import type { StoreContext } from '../area'; +import type { StorageAdapter } from '../storage'; +import { asOverridePointers, collides, holdsValue } from './patches'; +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; + sent: string | null; + 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; +} + +export const isStandIn = isStandInId; + +/** An event's pending writes, in the order they go out. */ +export function pendingChain(db: StorageAdapter, jmap: string, id: string): PendingRow[] { + return db.all('SELECT * FROM calendar_pending WHERE jmap = ? AND record_id = ? ORDER BY seq', [jmap, id]); +} + +export function pendingBySeq(db: StorageAdapter, seq: number): PendingRow | undefined { + return db.get('SELECT * FROM calendar_pending WHERE seq = ?', [seq]); +} + +/** 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; +} + +/** For the page's client: the real id, or null while it's still only on this device. */ +export function resolveStandIns(db: StorageAdapter, ids: readonly string[]): Record { + const out: Record = {}; + for (const id of ids) { + out[id] = db.get<{ server_id: string }>('SELECT server_id FROM calendar_id_map WHERE local_id = ?', [id])?.server_id ?? null; + } + return out; +} + +function flagOf(sendScheduling: boolean | undefined): number | null { + return sendScheduling === undefined ? null : Number(sendScheduling); +} + +function parse(json: string): Record { + return JSON.parse(json) as Record; +} + +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 }); +} + +/** Writes what answers show: `server` with the event's chain on top. */ +function rewriteShown(db: StorageAdapter, jmap: string, id: string, server: CalendarObject | null, gen: number): void { + const chain = pendingChain(db, jmap, id); + if (chain.length === 0) { + if (server) writeEvent(db, jmap, { shown: server, gen }); + else deleteEventRow(db, jmap, id); + return; + } + const row = eventRow(db, jmap, id); + let shown: CalendarObject | null = server ?? (row ? JSON.parse(row.json) as CalendarObject : null); + let hidden = false; + for (const pending of chain) { + if (pending.kind === 'create') shown = { ...parse(pending.patch), id, isOrigin: true }; + else if (pending.kind === 'update') shown = applyPatchObject(shown as CalendarObject, parse(pending.patch)); + else hidden = true; + } + if (shown) writeEvent(db, jmap, { shown, server, hidden, gen }); +} + +/** + * Creates a local event under a stand-in id. The uid is what finds it again. One + * the device chose can't be on the server yet. One from the caller might: the + * client's request may have got through before its answer was lost, or an import + * names an event that exists. That counts as attempted, so a flush looks first. + */ +export function queueCreate(ctx: StoreContext, jmap: string, event: Record, sendScheduling: boolean | undefined): { id: string; uid: string } { + const id = newStandInId(); + const given = typeof event.uid === 'string' && event.uid ? event.uid : null; + const uid = given ?? crypto.randomUUID(); + const payload = { '@type': 'Event', ...event, uid }; + ctx.db.run( + `INSERT INTO calendar_pending (jmap, record_id, kind, patch, uid, send_scheduling, attempted, queued_at) VALUES (?, ?, 'create', ?, ?, ?, ?, ?)`, + [jmap, id, JSON.stringify(payload), uid, flagOf(sendScheduling), given ? 1 : 0, ctx.now()], + ); + rewriteShown(ctx.db, jmap, id, null, currentGen(ctx.db, jmap)); + return { id, uid }; +} + +function baseFor(server: Record, patch: Record, earlier: Record): Record { + const base: Record = {}; + for (const key of Object.keys(patch)) base[key] = key in earlier ? earlier[key] : valueAtPointer(server, key) ?? null; + return base; +} + +/** False when the store has no such event. */ +export function queueUpdate(ctx: StoreContext, jmap: string, id: string, rawPatch: Record, sendScheduling: boolean | undefined): boolean { + const { db } = ctx; + const row = eventRow(db, jmap, id); + if (!row || row.hidden) return false; + const chain = pendingChain(db, jmap, id); + const last = chain.at(-1); + const flag = flagOf(sendScheduling); + const local = chain[0]?.kind === 'create'; + const server = local ? null : serverCopy(row); + if (last?.kind === 'create' && last.send_scheduling === flag) { + db.run( + 'UPDATE calendar_pending SET patch = ?, rev = rev + 1 WHERE seq = ?', + [JSON.stringify(applyPatchObject(parse(last.patch), rawPatch)), last.seq], + ); + rewriteShown(db, jmap, id, null, row.gen); + return true; + } + const patch = asOverridePointers(rawPatch, JSON.parse(row.json) as Record); + if (Object.keys(patch).length === 0) return true; + if (last?.kind === 'update' && last.send_scheduling === flag) { + const merged = mergeOverrideEntry(parse(last.patch), patch); + const base = baseFor(server ?? {}, merged, parse(last.base)); + db.run('UPDATE calendar_pending SET patch = ?, base = ?, rev = rev + 1 WHERE seq = ?', [JSON.stringify(merged), JSON.stringify(base), last.seq]); + } else { + db.run( + `INSERT INTO calendar_pending (jmap, record_id, kind, patch, base, send_scheduling, queued_at) VALUES (?, ?, 'update', ?, ?, ?, ?)`, + [jmap, id, JSON.stringify(patch), JSON.stringify(baseFor(server ?? {}, patch, {})), flag, ctx.now()], + ); + } + rewriteShown(db, jmap, id, server, row.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 { db } = ctx; + const row = eventRow(db, jmap, id); + if (!row || row.hidden) return false; + const chain = pendingChain(db, jmap, id); + const flag = flagOf(sendScheduling); + const head = chain[0]; + if (head?.kind === 'create') { + db.run('DELETE FROM calendar_pending WHERE jmap = ? AND record_id = ? AND seq <> ?', [jmap, id, head.seq]); + if (!head.attempted) { + // It never left the device. + db.run('DELETE FROM calendar_pending WHERE seq = ?', [head.seq]); + deleteEventRow(db, jmap, id); + return true; + } + // It may be on the server. Its uid stays, so the delete can follow it. + db.run( + `UPDATE calendar_pending SET kind = 'destroy', patch = '{}', base = '{}', send_scheduling = ?, rev = rev + 1 WHERE seq = ?`, + [flag, head.seq], + ); + rewriteShown(db, jmap, id, null, row.gen); + return true; + } + const last = chain.at(-1); + if (last?.kind === 'update' && last.send_scheduling === flag) { + db.run(`UPDATE calendar_pending SET kind = 'destroy', patch = '{}', base = '{}', sent = NULL, rev = rev + 1 WHERE seq = ?`, [last.seq]); + } else { + db.run( + `INSERT INTO calendar_pending (jmap, record_id, kind, patch, send_scheduling, queued_at) VALUES (?, ?, 'destroy', '{}', ?, ?)`, + [jmap, id, flag, ctx.now()], + ); + } + rewriteShown(db, jmap, id, serverCopy(row), row.gen); + return true; +} + +/** + * Puts a write on the conflict list with every later write of that event, which + * were built on it. The event goes back to the server's copy plus what's left. 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 { jmap, record_id: id } = pending; + const row = eventRow(db, jmap, id); + const gone = reason === 'destroyedOnServer'; + const local = pending.kind === 'create' || (pending.kind === 'destroy' && pending.uid !== null); + const server = row && !local && !gone ? serverCopy(row) : null; + const title = row ? (parse(row.json).title as string | undefined) ?? null : null; + const chain = pendingChain(db, jmap, id).filter((other) => gone || other.seq >= pending.seq); + for (const other of chain) { + db.run( + `INSERT INTO calendar_conflict (jmap, record_id, kind, reason, error_type, description, title, local, server, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [jmap, id, other.kind, reason, error?.type ?? null, error?.description ?? null, title, other.patch, server ? JSON.stringify(server) : null, ctx.now()], + ); + db.run('DELETE FROM calendar_pending WHERE seq = ?', [other.seq]); + } + if (row) { + if (local || gone) deleteEventRow(db, jmap, id); + else rewriteShown(db, jmap, id, serverCopy(row), row.gen); + } + changed(ctx, 'CalendarEvent', 'CalendarConflict'); +} + +/** Before a row goes out: what it says may be on the server from now on. */ +export function markSent(ctx: StoreContext, row: PendingRow, sent: Record): void { + ctx.db.transaction(() => ctx.db.run('UPDATE calendar_pending SET attempted = 1, sent = ? WHERE seq = ?', [JSON.stringify(sent), row.seq])); +} + +/** + * The server's event for a create found again, or just made: the stand-in becomes + * it, and later rows go on under the real id. What the server doesn't hold of the + * create goes on as an update: what came in after the last send, or, when nothing + * of ours was sent, whatever differs. True when writes are left. + */ +export function adoptStandIn(ctx: StoreContext, row: PendingRow, event: CalendarObject): boolean { + const { db } = ctx; + const { jmap, record_id: standIn } = row; + const head = pendingBySeq(db, row.seq); + const gen = currentGen(db, jmap); + deleteEventRow(db, jmap, standIn); + db.run('INSERT OR REPLACE INTO calendar_id_map (jmap, local_id, server_id) VALUES (?, ?, ?)', [jmap, standIn, event.id]); + db.run('UPDATE calendar_pending SET record_id = ? WHERE jmap = ? AND record_id = ?', [event.id, jmap, standIn]); + if (head?.kind === 'destroy') { + db.run('UPDATE calendar_pending SET uid = NULL, attempted = 0, sent = NULL WHERE seq = ?', [head.seq]); + } else if (head) { + const payload = parse(head.patch); + const sent = head.sent ? parse(head.sent) : null; + const patch: Record = {}; + if (sent) { + for (const key of new Set([...Object.keys(sent), ...Object.keys(payload)])) { + if (stableJson(sent[key]) !== stableJson(payload[key])) patch[key] = payload[key] ?? null; + } + } else { + for (const [key, value] of Object.entries(payload)) { + if (key !== '@type' && key !== 'uid' && !holdsValue(event, key, value)) patch[key] = value; + } + } + const pointers = asOverridePointers(patch, event); + if (Object.keys(pointers).length === 0) { + db.run('DELETE FROM calendar_pending WHERE seq = ?', [head.seq]); + } else { + db.run( + `UPDATE calendar_pending SET kind = 'update', patch = ?, base = ?, uid = NULL, attempted = 0, sent = NULL WHERE seq = ?`, + [JSON.stringify(pointers), JSON.stringify(baseFor(event, pointers, {})), head.seq], + ); + } + } + const rest = pendingChain(db, jmap, event.id); + for (const later of rest) { + if (later.kind === 'update' && later.seq !== head?.seq) { + db.run('UPDATE calendar_pending SET base = ? WHERE seq = ?', [JSON.stringify(baseFor(event, parse(later.patch), {})), later.seq]); + } + } + rewriteShown(db, jmap, event.id, event, gen); + changed(ctx, 'CalendarEvent'); + return rest.length > 0; +} + +/** + * A write went through: `sent` is the server's now. True when this row has to go + * again, because a newer write merged into it during the send. Later rows read + * their overlap with `sent` as ours, not as someone's change. + */ +export function finishWrite(ctx: StoreContext, row: PendingRow, sent: Record): boolean { + const { db } = ctx; + const current = pendingBySeq(db, row.seq); + const event = eventRow(db, row.jmap, row.record_id); + if (!current || !event) return false; + const server = applyPatchObject(serverCopy(event), sent); + const stays = current.rev !== row.rev; + if (stays) { + db.run('UPDATE calendar_pending SET attempted = 0, sent = ? WHERE seq = ?', [JSON.stringify(sent), row.seq]); + } else { + db.run('DELETE FROM calendar_pending WHERE seq = ?', [row.seq]); + } + for (const later of pendingChain(db, row.jmap, row.record_id)) { + if (later.kind !== 'update') continue; + const earlierSent = later.sent ? parse(later.sent) : {}; + db.run( + 'UPDATE calendar_pending SET base = ?, sent = ? WHERE seq = ?', + [JSON.stringify(baseFor(server, parse(later.patch), {})), JSON.stringify({ ...earlierSent, ...sent }), later.seq], + ); + } + rewriteShown(db, row.jmap, row.record_id, server, event.gen); + changed(ctx, 'CalendarEvent'); + return stays; +} + +/** The server's copy of an event arriving during sync. Any pending writes go back on top, or to the conflict list. */ +export function applyServerEvent(ctx: StoreContext, jmap: string, event: CalendarObject, gen: number): void { + const { db } = ctx; + if (typeof event.uid === 'string') { + // A create of ours that no answer told us about. + const waiting = db.get('SELECT * FROM calendar_pending WHERE jmap = ? AND uid = ? ORDER BY seq LIMIT 1', [jmap, event.uid]); + if (waiting && waiting.record_id !== event.id) { + adoptStandIn(ctx, waiting, event); + return; + } + } + const before = eventRow(db, jmap, event.id); + const previous = before ? stableJson(serverCopy(before)) : null; + const chain = pendingChain(db, jmap, event.id); + if (chain.length === 0) { + writeEvent(db, jmap, { shown: event, gen }); + return; + } + writeEvent(db, jmap, { shown: event, server: event, gen }); + for (const pending of chain) { + if (pending.kind === 'destroy') { + // Deleting what someone just changed is the one write that can't be taken + // back, so the change wins and the user decides. + if (previous !== null && previous !== stableJson(event)) recordConflict(ctx, pending, 'changedOnServer'); + break; + } + if (collides({ patch: parse(pending.patch), base: parse(pending.base), sent: pending.sent ? parse(pending.sent) : null }, event)) { + recordConflict(ctx, pending, 'changedOnServer'); + break; + } + } + rewriteShown(db, jmap, event.id, event, gen); +} + +/** 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 { db } = ctx; + const chain = pendingChain(db, jmap, id); + const edit = chain.find((row) => row.kind === 'update'); + if (edit) { + recordConflict(ctx, edit, 'destroyedOnServer'); + return; + } + db.run('DELETE FROM calendar_pending WHERE jmap = ? AND record_id = ?', [jmap, id]); + deleteEventRow(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 acedaf252..df9452248 100644 --- a/lib/store/engine/account-engine.ts +++ b/lib/store/engine/account-engine.ts @@ -257,7 +257,7 @@ export class AccountEngine { const call = this.calls.get(name); if (!call) throw new Error(`Unknown store call ${name}`); try { - return await call(this.storeContext(), params as never); + return await call(this.syncContext(), params as never); } catch (error) { if (isStorageFailure(error)) this.noteStorageFailure(error); throw error; diff --git a/lib/store/mail/index.ts b/lib/store/mail/index.ts index 13dae2381..aa0ba6e6b 100644 --- a/lib/store/mail/index.ts +++ b/lib/store/mail/index.ts @@ -2,11 +2,12 @@ import type { StoreArea } from '../area'; import { MAIL_CALLS } from './calls'; import { MAIL_METHODS } from './methods'; import { MAIL_MIGRATIONS } from './schema'; +import { SEARCH_MIGRATIONS } from './search-schema'; import { identitySyncer, mailSyncer, quotaSyncer } from './sync'; export const mailArea: StoreArea = { name: 'mail', - migrations: MAIL_MIGRATIONS, + migrations: [...MAIL_MIGRATIONS, ...SEARCH_MIGRATIONS], methods: MAIL_METHODS, syncers: [mailSyncer, identitySyncer, quotaSyncer], calls: MAIL_CALLS, diff --git a/lib/store/mail/methods.ts b/lib/store/mail/methods.ts index b5e80c9fb..1b28508aa 100644 --- a/lib/store/mail/methods.ts +++ b/lib/store/mail/methods.ts @@ -8,14 +8,12 @@ import { MethodError, type MethodContext, type MethodHandler, type MethodResult import { listObjects } from '../engine/core-schema'; import type { SqlValue } from '../storage'; import { headerValue, parseHeaderProperty, type RawHeader } from './headers'; -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; } @@ -93,14 +91,6 @@ function filterSql(filter: Filter, jmap: string, drivenBy: string | null = null, conditions.push(`e.id IN (${(value as string[]).map(() => '?').join(',') || "''"})`); params.push(...(value as string[]).map(String)); break; - case 'cc': - conditions.push("EXISTS (SELECT 1 FROM json_each(e.json, '$.cc') a WHERE a.value LIKE ? ESCAPE '\\')"); - params.push(likeTerm(value)); - break; - case 'bcc': - conditions.push("EXISTS (SELECT 1 FROM email_body b, json_each(b.json, '$.bcc') a WHERE b.email_rowid = e.rowid AND a.value LIKE ? ESCAPE '\\')"); - params.push(likeTerm(value)); - break; case 'header': { // Stalwart finds nothing for this on either version, plugins may still // ask. Answered from the raw headers of the messages kept with a body. @@ -296,7 +286,7 @@ function keywordOrderedPage( 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 box = drivingMailbox(filter); const where = filter ? filterSql(filter, jmap, box) : { sql: '1', params: [] }; @@ -544,4 +534,5 @@ export const MAIL_METHODS: Readonly> = { 'Identity/get': (ctx, args) => objectGet(ctx, args, 'Identity', 'Identities'), 'Quota/get': (ctx, args) => objectGet(ctx, args, 'Quota', 'Quotas'), 'Keyword/get': keywordGet, + 'SearchSnippet/get': searchSnippetGet, }; diff --git a/lib/store/mail/records.ts b/lib/store/mail/records.ts index d3528efa8..147b5d5f2 100644 --- a/lib/store/mail/records.ts +++ b/lib/store/mail/records.ts @@ -100,8 +100,8 @@ export function deleteEmail(db: StorageAdapter, jmap: string, id: string): void } export function deleteEmailRow(db: StorageAdapter, rowid: number): void { - db.run('DELETE FROM email_fts_word WHERE rowid = ?', [rowid]); - db.run('DELETE FROM email_fts_tri WHERE rowid = ?', [rowid]); + deleteHeadIndex(db, rowid); + deleteBodyIndex(db, rowid); forgetParts(db, rowid); // Explicit rather than ON DELETE CASCADE: foreign keys stay off for bulk speed. db.run('DELETE FROM email_mailbox WHERE email_rowid = ?', [rowid]); @@ -110,6 +110,58 @@ 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 = new Map(Object.entries({ + 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.get(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; +} + +/** More than a server sends for one part. Everything below is only linear up to a point of patience. */ +const RAW_BODY_CHARS = 1_000_000; + +/** + * Takes out comments and style, script and head blocks. A single scan: each + * search for a closer starts past the last one, and a kind of block with no + * closer left isn't looked for again, so hostile HTML that only ever opens them + * can't make this quadratic. An unclosed block stays as text. + */ +function stripBlocks(html: string): string { + const opener = /', opener.lastIndex); + end = closer < 0 ? -1 : closer + '-->'.length; + } else { + const closer = new RegExp(``, 'gi'); + closer.lastIndex = opener.lastIndex; + end = closer.exec(html) ? closer.lastIndex : -1; + } + if (end < 0) { + unclosed.add(name); + continue; + } + kept.push(html.slice(at, match.index)); + at = end; + opener.lastIndex = end; + } + kept.push(html.slice(at)); + return kept.join(' '); +} + /** 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; @@ -117,31 +169,55 @@ export function bodyText(email: Record): string { const text = parts('textBody') .filter((part) => part.type !== 'text/html') .map((part) => values[part.partId ?? '']?.value ?? '') - .join('\n'); + .join('\n') + .slice(0, RAW_BODY_CHARS); if (text.trim()) return text; - return parts('htmlBody') + const html = parts('htmlBody') .map((part) => values[part.partId ?? '']?.value ?? '') .join('\n') - .replace(/<(style|script)[\s\S]*?<\/\1>/gi, ' ') - .replace(/<[^>]+>/g, ' ') - .replace(/ /g, ' ') - .replace(/&/g, '&') - .replace(/</g, '<') - .replace(/>/g, '>') + .slice(0, RAW_BODY_CHARS); + return stripBlocks(html) + .replace(/<[^<>]*>/g, ' ') + .replace(/&(#\d{1,7}|#x[\da-f]{1,6}|[a-z]{1,31}\d{0,2});/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; +/** + * Trigrams cost several times what words do, and a term deep in a long mail is + * rarely part of a compound someone remembers. Past this, a mail is found by its + * whole words and word starts only. + */ +export const TRIGRAM_BODY_CHARS = 32 * 1024; + +function deleteHeadIndex(db: StorageAdapter, rowid: number): void { + db.run('DELETE FROM email_fts_word WHERE rowid = ?', [rowid]); + db.run('DELETE FROM email_fts_tri WHERE rowid = ?', [rowid]); +} + +function deleteBodyIndex(db: StorageAdapter, rowid: number): void { + db.run('DELETE FROM email_fts_body_word WHERE rowid = ?', [rowid]); + db.run('DELETE FROM email_fts_body_tri WHERE rowid = ?', [rowid]); +} + +function indexHead(db: StorageAdapter, rowid: number, email: Record): void { + const subject = typeof email.subject === 'string' ? email.subject : ''; + const values = [rowid, subject, addressText(email.from), addressText(email.to), addressText(email.cc), addressText(email.bcc)]; + for (const table of ['email_fts_word', 'email_fts_tri']) { + db.run(`INSERT INTO ${table} (rowid, subject, from_addr, to_addr, cc_addr, bcc_addr) VALUES (?, ?, ?, ?, ?, ?)`, values); + } +} + +function indexBody(db: StorageAdapter, rowid: number, body: string): void { + if (!body) return; + db.run('INSERT INTO email_fts_body_word (rowid, body) VALUES (?, ?)', [rowid, body.slice(0, INDEX_BODY_CHARS)]); + db.run('INSERT INTO email_fts_body_tri (rowid, body) VALUES (?, ?)', [rowid, body.slice(0, TRIGRAM_BODY_CHARS)]); +} 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 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]); - } + indexHead(db, rowid, email); + indexBody(db, rowid, body); } /** A message's files go with it, unless another kept message has the same one. */ @@ -173,10 +249,16 @@ export function storeBody(db: StorageAdapter, rowid: number, listJson: string, f const list = JSON.parse(listJson) as Record; const body: Record = {}; for (const [key, value] of Object.entries(full)) if (!(key in list)) body[key] = value; + const hadBody = Number(db.get<{ body_state: number }>('SELECT body_state FROM email WHERE rowid = ?', [rowid])?.body_state) === 1; rememberParts(db, rowid, body.attachments); db.run('INSERT INTO email_body (email_rowid, json) VALUES (?, ?) ON CONFLICT (email_rowid) DO UPDATE SET json = excluded.json', [rowid, JSON.stringify(body)]); db.run('UPDATE email SET body_state = 1 WHERE rowid = ?', [rowid]); - db.run('DELETE FROM email_fts_word WHERE rowid = ?', [rowid]); - db.run('DELETE FROM email_fts_tri WHERE rowid = ?', [rowid]); - indexEmail(db, rowid, { ...list, ...body }, bodyText(full)); + // A first body is one insert. Only a body stored again has an old row to replace. + if (hadBody) deleteBodyIndex(db, rowid); + indexBody(db, rowid, bodyText(full)); + // Bcc isn't a list property, so a message that has one gets its head row again. + if (addressText(full.bcc)) { + deleteHeadIndex(db, rowid); + indexHead(db, rowid, { ...list, ...body }); + } } diff --git a/lib/store/mail/search-schema.ts b/lib/store/mail/search-schema.ts new file mode 100644 index 000000000..3c4d943da --- /dev/null +++ b/lib/store/mail/search-schema.ts @@ -0,0 +1,65 @@ +import type { Migration } from '../storage'; + +const INDEX_OPTIONS = "content = '', contentless_delete = 1"; +const WORD = "tokenize = 'unicode61 remove_diacritics 2'"; +const TRIGRAM = "tokenize = 'trigram remove_diacritics 1'"; +const HEAD_COLUMNS = 'subject, from_addr, to_addr, cc_addr, bcc_addr'; + +/** Bytes of pending terms a body index holds before it writes a segment. */ +const BODY_WORD_HASH = 8 * 1024 * 1024; +const BODY_TRIGRAM_HASH = 16 * 1024 * 1024; +const AUTOMERGE = 8; + +function setOption(table: string, option: string, value: number): string { + return `INSERT INTO ${table} (${table}, rank) VALUES ('${option}', ${value});`; +} + +/** + * The final search tables. Migration 100's two indexes held everything, so a + * message went in twice: its head at list time, then a delete and a second + * insert when its body arrived. Now the head tables are written once at list + * time and the body tables once at body time. Only a message with Bcc touches + * its head row again, since Bcc isn't known before the body. To, Cc and Bcc + * have their own columns: `to` used to find Cc and Bcc too, and `cc` (the + * client's recipient search asks for it) had no answer. + * + * Options for the bulk writes of a first sync, which trade memory and some + * reading speed for less writing: + * - `hashsize` on the body tables: FTS5 writes a new segment when this much is + * pending (1 MiB by default), and at every commit. A larger value turns a + * batch of bodies into one segment, not many that have to be merged later. + * The memory is only held during a batch. Trigrams produce the most terms. + * - `automerge` 8 (4 by default): merge when eight segments of a size exist, so + * each term is rewritten less often on its way up. Queries read a few more + * segments until then, and `crisismerge` (16) still bounds how many. + * + * FTS5 can't add a column and contentless tables have nothing to copy, so 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-indexes', + sql: ` + DROP TABLE email_fts_word; + DROP TABLE email_fts_tri; + CREATE VIRTUAL TABLE email_fts_word USING fts5(${HEAD_COLUMNS}, ${INDEX_OPTIONS}, ${WORD}); + CREATE VIRTUAL TABLE email_fts_tri USING fts5(${HEAD_COLUMNS}, ${INDEX_OPTIONS}, ${TRIGRAM}); + CREATE VIRTUAL TABLE email_fts_body_word USING fts5(body, ${INDEX_OPTIONS}, ${WORD}); + CREATE VIRTUAL TABLE email_fts_body_tri USING fts5(body, ${INDEX_OPTIONS}, ${TRIGRAM}); + ${setOption('email_fts_body_word', 'hashsize', BODY_WORD_HASH)} + ${setOption('email_fts_body_tri', 'hashsize', BODY_TRIGRAM_HASH)} + ${setOption('email_fts_word', 'automerge', AUTOMERGE)} + ${setOption('email_fts_tri', 'automerge', AUTOMERGE)} + ${setOption('email_fts_body_word', 'automerge', AUTOMERGE)} + ${setOption('email_fts_body_tri', 'automerge', AUTOMERGE)} + 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..f90c9b062 100644 --- a/lib/store/mail/search.ts +++ b/lib/store/mail/search.ts @@ -1,59 +1,407 @@ -/** 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. Body text past 32 KB has no trigrams (see records.ts), so deep + * in a long mail only whole words and word starts are found. + */ +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, +const HEAD_COLUMNS: Partial> = { subject: 'subject', from: 'from_addr', to: 'to_addr', - body: 'body', + cc: 'cc_addr', + bcc: 'bcc_addr', }; +const HEAD_TABLES = { word: 'email_fts_word', tri: 'email_fts_tri' }; +const BODY_TABLES = { word: 'email_fts_body_word', tri: 'email_fts_body_tri' }; + +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; +const MAX_HIGHLIGHT_PATTERNS = 64; +const MAX_SNIPPET_SUBJECT_CHARS = 1024; +/** 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 = HEAD_COLUMNS[field]; + const scope = column ? `${column} : ` : ''; + const tables = field === 'text' ? [HEAD_TABLES, BODY_TABLES] : field === 'body' ? [BODY_TABLES] : [HEAD_TABLES]; 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)) { + for (const pair of tables) { + const table = pair[probe.index]; + 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).slice(0, MAX_HIGHLIGHT_PATTERNS).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.slice(0, MAX_SNIPPET_SUBJECT_CHARS), 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 }; +}; diff --git a/locales/ar/common.json b/locales/ar/common.json index d902c5f72..84e8fd68b 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -4909,6 +4909,9 @@ "mini_has_events": "فيه أحداث", "mini_week_number": "الأسبوع {week}" }, + "calendar_offline": { + "split_needs_server": "يحتاج هذا التغيير إلى اتصال بالخادم. غيّر هذا الحدث فقط الآن، أو حاول مرة أخرى عند عودة الاتصال." + }, "calendar_event_notifications": { "someone": "شخص ما", "invited": "دعاك {name} إلى \"{title}\"", @@ -6363,6 +6366,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 c27bef7c1..aaaa3d81c 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -4909,6 +4909,9 @@ "mini_has_events": "té esdeveniments", "mini_week_number": "Setmana {week}" }, + "calendar_offline": { + "split_needs_server": "Aquest canvi necessita connexió amb el servidor. Canvia només aquest esdeveniment de moment, o torna-ho a provar quan tornis a estar connectat." + }, "calendar_event_notifications": { "someone": "Algú", "invited": "{name} t'ha convidat a \"{title}\"", @@ -6363,6 +6366,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 fbc6190ad..49f357d9e 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -4909,6 +4909,9 @@ "mini_has_events": "má události", "mini_week_number": "Týden {week}" }, + "calendar_offline": { + "split_needs_server": "Tato změna vyžaduje připojení k serveru. Prozatím změňte jen tuto událost, nebo to zkuste znovu po obnovení připojení." + }, "advanced_search": { "title": "Pokročilé hledání", "from": "Od", @@ -6363,6 +6366,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 b2aba243e..c07288995 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -4909,6 +4909,9 @@ "mini_has_events": "har begivenheder", "mini_week_number": "Uge {week}" }, + "calendar_offline": { + "split_needs_server": "Denne ændring kræver forbindelse til serveren. Ret kun denne begivenhed for nu, eller prøv igen, når du er online." + }, "calendar_event_notifications": { "someone": "Nogen", "invited": "{name} har inviteret dig til \"{title}\"", @@ -6363,6 +6366,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 b4816bd33..a63b7b318 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -4909,6 +4909,9 @@ "mini_has_events": "hat Termine", "mini_week_number": "Woche {week}" }, + "calendar_offline": { + "split_needs_server": "Für diese Änderung ist eine Verbindung zum Server nötig. Ändern Sie vorerst nur diesen Termin, oder versuchen Sie es erneut, wenn Sie wieder online sind." + }, "advanced_search": { "title": "Erweiterte Suche", "from": "Von", @@ -6363,6 +6366,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 854565eb4..3c3dae475 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -4909,6 +4909,9 @@ "mini_has_events": "has events", "mini_week_number": "Week {week}" }, + "calendar_offline": { + "split_needs_server": "This change needs a connection to the server. Change only this event for now, or try again when you're back online." + }, "calendar_event_notifications": { "someone": "Someone", "invited": "{name} invited you to \"{title}\"", @@ -6363,6 +6366,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 71dfb63c2..bc61030ce 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -4909,6 +4909,9 @@ "mini_has_events": "tiene eventos", "mini_week_number": "Semana {week}" }, + "calendar_offline": { + "split_needs_server": "Este cambio necesita conexión con el servidor. Cambia solo este evento por ahora o inténtalo de nuevo cuando vuelvas a tener conexión." + }, "advanced_search": { "title": "Búsqueda avanzada", "from": "De", @@ -6363,6 +6366,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 4dd87b1df..00e18999f 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -4909,6 +4909,9 @@ "mini_has_events": "on sündmusi", "mini_week_number": "Nädal {week}" }, + "calendar_offline": { + "split_needs_server": "See muudatus vajab ühendust serveriga. Muuda praegu ainult seda sündmust või proovi uuesti, kui oled taas võrgus." + }, "calendar_event_notifications": { "someone": "Keegi", "invited": "{name} kutsus sind sündmusele \"{title}\"", @@ -6363,6 +6366,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 631f343fa..166cb476a 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -4909,6 +4909,9 @@ "mini_has_events": "رویداد دارد", "mini_week_number": "هفته {week}" }, + "calendar_offline": { + "split_needs_server": "این تغییر به اتصال به سرور نیاز دارد. فعلاً فقط همین رویداد را تغییر دهید، یا پس از برقراری اتصال دوباره تلاش کنید." + }, "calendar_event_notifications": { "someone": "کسی", "invited": "{name} شما را به «{title}» دعوت کرد", @@ -6363,6 +6366,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 39f105d91..79facef88 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -4909,6 +4909,9 @@ "mini_has_events": "a des événements", "mini_week_number": "Semaine {week}" }, + "calendar_offline": { + "split_needs_server": "Cette modification nécessite une connexion au serveur. Modifiez seulement cet événement pour l'instant, ou réessayez une fois de nouveau en ligne." + }, "advanced_search": { "title": "Recherche avancée", "from": "De", @@ -6363,6 +6366,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 c908f32e3..a23cd181d 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -4828,6 +4828,9 @@ "mini_has_events": "יש אירועים", "mini_week_number": "שבוע {week}" }, + "calendar_offline": { + "split_needs_server": "שינוי זה דורש חיבור לשרת. שנו כרגע רק את האירוע הזה, או נסו שוב כשהחיבור יחזור." + }, "advanced_search": { "title": "חיפוש מתקדם", "from": "מ", @@ -6364,6 +6367,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 88aaa37ff..04c13bb12 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -4909,6 +4909,9 @@ "mini_has_events": "vannak események", "mini_week_number": "{week}. hét" }, + "calendar_offline": { + "split_needs_server": "Ehhez a módosításhoz kapcsolat kell a kiszolgálóval. Egyelőre csak ezt az eseményt módosítsa, vagy próbálja újra, ha ismét online lesz." + }, "calendar_event_notifications": { "someone": "Valaki", "invited": "{name} meghívott a(z) „{title}” eseményre", @@ -6363,6 +6366,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 518c392b8..f9790541d 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -4909,6 +4909,9 @@ "mini_has_events": "ha eventi", "mini_week_number": "Settimana {week}" }, + "calendar_offline": { + "split_needs_server": "Questa modifica richiede una connessione al server. Per ora modifica solo questo evento, oppure riprova quando sei di nuovo online." + }, "advanced_search": { "title": "Ricerca avanzata", "from": "Da", @@ -6363,6 +6366,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 bf8e1052d..f944d85f8 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -4909,6 +4909,9 @@ "mini_has_events": "イベントあり", "mini_week_number": "第{week}週" }, + "calendar_offline": { + "split_needs_server": "この変更にはサーバーへの接続が必要です。今はこの予定だけを変更するか、オンラインに戻ってからもう一度お試しください。" + }, "advanced_search": { "title": "詳細検索", "from": "差出人", @@ -6363,6 +6366,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 d4f951a5f..134cfcdf0 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -4909,6 +4909,9 @@ "mini_has_events": "일정 있음", "mini_week_number": "{week}주차" }, + "calendar_offline": { + "split_needs_server": "이 변경에는 서버 연결이 필요합니다. 지금은 이 일정만 변경하거나 다시 온라인이 되면 다시 시도하세요." + }, "advanced_search": { "title": "상세 검색", "from": "보낸 사람", @@ -6363,6 +6366,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 05fcb26c4..293448291 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -4909,6 +4909,9 @@ "mini_has_events": "ir notikumi", "mini_week_number": "{week}. nedēļa" }, + "calendar_offline": { + "split_needs_server": "Šīm izmaiņām nepieciešams savienojums ar serveri. Pagaidām mainiet tikai šo notikumu vai mēģiniet vēlreiz, kad būsiet tiešsaistē." + }, "advanced_search": { "title": "Izvērstā meklēšana", "from": "No", @@ -6363,6 +6366,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 760bc1a03..75783c417 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -4909,6 +4909,9 @@ "mini_has_events": "үйл явдалтай", "mini_week_number": "{week}-р долоо хоног" }, + "calendar_offline": { + "split_needs_server": "Энэ өөрчлөлтөд сервертэй холболт шаардлагатай. Одоохондоо зөвхөн энэ үйл явдлыг өөрчлөх эсвэл онлайн болсны дараа дахин оролдоно уу." + }, "calendar_event_notifications": { "someone": "Хэн нэгэн", "invited": "{name} таныг \"{title}\"-д урилаа", @@ -6363,6 +6366,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 619d79e9f..f1b65c372 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -4909,6 +4909,9 @@ "mini_has_events": "har hendelser", "mini_week_number": "Uke {week}" }, + "calendar_offline": { + "split_needs_server": "Denne endringen krever tilkobling til serveren. Endre bare denne hendelsen for nå, eller prøv igjen når du er tilkoblet." + }, "calendar_event_notifications": { "someone": "Noen", "invited": "{name} har invitert deg til «{title}»", @@ -6363,6 +6366,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 55829f868..a1fdafc93 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -4909,6 +4909,9 @@ "mini_has_events": "heeft afspraken", "mini_week_number": "Week {week}" }, + "calendar_offline": { + "split_needs_server": "Voor deze wijziging is een verbinding met de server nodig. Wijzig voorlopig alleen deze afspraak, of probeer het opnieuw zodra je weer online bent." + }, "advanced_search": { "title": "Geavanceerd zoeken", "from": "Van", @@ -6363,6 +6366,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 441641f50..015725022 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -4909,6 +4909,9 @@ "mini_has_events": "ma wydarzenia", "mini_week_number": "Tydzień {week}" }, + "calendar_offline": { + "split_needs_server": "Ta zmiana wymaga połączenia z serwerem. Na razie zmień tylko to wydarzenie albo spróbuj ponownie, gdy wrócisz do trybu online." + }, "advanced_search": { "title": "Wyszukiwanie zaawansowane", "from": "Od", @@ -6363,6 +6366,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 eb5f1ecb7..810f03a0e 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -4909,6 +4909,9 @@ "mini_has_events": "tem eventos", "mini_week_number": "Semana {week}" }, + "calendar_offline": { + "split_needs_server": "Esta alteração precisa de ligação ao servidor. Altere apenas este evento por agora, ou tente de novo quando voltar a estar online." + }, "advanced_search": { "title": "Pesquisa avançada", "from": "De", @@ -6363,6 +6366,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 65ce649b0..862d1f97b 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -4909,6 +4909,9 @@ "mini_has_events": "are evenimente", "mini_week_number": "Săptămâna {week}" }, + "calendar_offline": { + "split_needs_server": "Această modificare are nevoie de conexiune la server. Modifică deocamdată doar acest eveniment sau încearcă din nou când ești din nou online." + }, "calendar_event_notifications": { "someone": "Cineva", "invited": "{name} te-a invitat la „{title}”", @@ -6363,6 +6366,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 858f407b7..d02a57b91 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -4909,6 +4909,9 @@ "mini_has_events": "есть события", "mini_week_number": "Неделя {week}" }, + "calendar_offline": { + "split_needs_server": "Для этого изменения нужно подключение к серверу. Пока измените только это событие или повторите попытку, когда снова будете в сети." + }, "advanced_search": { "title": "Расширенный поиск", "from": "От", @@ -6363,6 +6366,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 8f172e8a7..290d560e0 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -4909,6 +4909,9 @@ "mini_has_events": "má udalosti", "mini_week_number": "Týždeň {week}" }, + "calendar_offline": { + "split_needs_server": "Táto zmena vyžaduje pripojenie k serveru. Zatiaľ zmeňte iba túto udalosť alebo to skúste znova, keď budete online." + }, "calendar_event_notifications": { "someone": "Niekto", "invited": "{name} vás pozval(a) na „{title}“", @@ -6363,6 +6366,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 1b026d356..957c73fb2 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -4105,6 +4105,9 @@ "mini_has_events": "มีกิจกรรม", "mini_week_number": "สัปดาห์ที่ {week}" }, + "calendar_offline": { + "split_needs_server": "การเปลี่ยนแปลงนี้ต้องเชื่อมต่อกับเซิร์ฟเวอร์ ตอนนี้เปลี่ยนเฉพาะกิจกรรมนี้ หรือลองอีกครั้งเมื่อกลับมาออนไลน์" + }, "settings": { "title": "การตั้งค่า", "org_locked": "กำหนดโดยองค์กรของคุณ", @@ -6371,6 +6374,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 4fd4bb488..2b0907a43 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -4909,6 +4909,9 @@ "mini_has_events": "etkinlik var", "mini_week_number": "{week}. hafta" }, + "calendar_offline": { + "split_needs_server": "Bu değişiklik için sunucu bağlantısı gerekir. Şimdilik yalnızca bu etkinliği değiştirin veya yeniden çevrimiçi olduğunuzda tekrar deneyin." + }, "calendar_event_notifications": { "someone": "Birisi", "invited": "{name} sizi \"{title}\" etkinliğine davet etti", @@ -6363,6 +6366,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 9cba0da86..047c63670 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -4909,6 +4909,9 @@ "mini_has_events": "є події", "mini_week_number": "Тиждень {week}" }, + "calendar_offline": { + "split_needs_server": "Для цієї зміни потрібне з'єднання із сервером. Поки що змініть лише цю подію або спробуйте ще раз, коли знову будете в мережі." + }, "advanced_search": { "title": "Розширений пошук", "from": "Від", @@ -6363,6 +6366,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 24d1f4cf7..b442189e7 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -4921,6 +4921,9 @@ "mini_has_events": "Có sự kiện", "mini_week_number": "Tuần {week}" }, + "calendar_offline": { + "split_needs_server": "Thay đổi này cần kết nối với máy chủ. Hiện chỉ thay đổi sự kiện này, hoặc thử lại khi bạn trực tuyến trở lại." + }, "calendar_event_notifications": { "someone": "Ai đó", "invited": "{name} đã mời bạn tham gia \"{title}\"", @@ -6375,6 +6378,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 1ee613765..e25711446 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -4909,6 +4909,9 @@ "mini_has_events": "有活動", "mini_week_number": "第 {week} 週" }, + "calendar_offline": { + "split_needs_server": "此變更需要連線到伺服器。請暫時只變更此活動,或在恢復連線後再試一次。" + }, "calendar_event_notifications": { "someone": "有人", "invited": "{name} 邀請您參加「{title}」", @@ -6363,6 +6366,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 78bce507b..6feac163d 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -4909,6 +4909,9 @@ "mini_has_events": "有事件", "mini_week_number": "第 {week} 周" }, + "calendar_offline": { + "split_needs_server": "此更改需要连接到服务器。请暂时只更改此日程,或在恢复联网后重试。" + }, "advanced_search": { "title": "高级搜索", "from": "发件人", @@ -6363,6 +6366,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 21601b2f0..b9a976e68 100644 --- a/public/sw.js +++ b/public/sw.js @@ -340,12 +340,24 @@ 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") { event.waitUntil(recordClientBuild(event.source && event.source.id, data.buildId).then(deleteStaleShellCaches)); return; } + if (data.type === "client-logins") { + event.waitUntil(recordClientLogins(event.source && event.source.id, data.logins)); + return; + } if (data.type === "offline-shell") { // The switch is kept before the event ends. The shell's files are // fetched without holding the event open: a worker busy with an event @@ -489,6 +501,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 @@ -524,7 +539,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"; @@ -537,7 +552,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"; @@ -546,7 +561,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. @@ -590,7 +605,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, @@ -735,7 +750,7 @@ async function handleNotificationClick(event) { return handleMailtoFocusNotificationClick(); } - const targetUrl = buildClickUrl(data); + let targetUrl = buildClickUrl(data); const allClients = await self.clients.matchAll({ type: "window", @@ -751,6 +766,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. @@ -923,11 +946,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}` : ""; @@ -944,3 +968,292 @@ 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. A tab that +// holds the notification's login gets longer, since a busy page may still run +// the action with its plugins; if it stays silent, the message opens and the +// route isn't tried, which would go round them. +const CLIENT_TAKE_MS = 1_000; +const LOGIN_HOLDER_TAKE_MS = 3_000; +const CLIENTS_TAKE_TOTAL_MS = 4_000; +// How long a tab that took it has before this worker stops waiting for the outcome. +const CLIENT_RESULT_MS = 5_000; +const ACTION_ROUTE_DEADLINE_MS = 4_500; +// All of the above stay inside the 10 s a browser allows openWindow and focus +// after a notification click, so the fallback can still open the message. + +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`; +} + +function clientLoginsKey() { + return `${self.location.origin}${BASE_PATH}/__push-state/client-logins`; +} + +async function readClientLogins() { + try { + const cache = await caches.open(PUSH_STATE_CACHE); + const res = await cache.match(clientLoginsKey()); + const map = res ? await res.json() : null; + return map && typeof map === "object" ? map : {}; + } catch (_) { + return {}; + } +} + +/** Notes which logins an open tab holds, and forgets tabs that are gone. Kept across worker restarts. */ +async function recordClientLogins(clientId, logins) { + if (!clientId || !Array.isArray(logins)) return; + try { + const open = await self.clients.matchAll({ type: "window", includeUncontrolled: true }); + const openIds = new Set(open.map((c) => c.id)); + const known = await readClientLogins(); + const next = {}; + for (const [id, held] of Object.entries(known)) if (openIds.has(id)) next[id] = held; + next[clientId] = logins.filter((login) => typeof login === "string"); + const cache = await caches.open(PUSH_STATE_CACHE); + await cache.put(clientLoginsKey(), new Response(JSON.stringify(next), { headers: { "Content-Type": "application/json" } })); + } catch (_) { + // Such a tab counts as not holding the login. + } +} + +/** + * What the page mirrors into this store (lib/pwa-notify/worker-state.ts): the + * labels in the user's language, Archive on or off, how Archive files mail, + * and whether an enabled plugin may veto these actions. Nothing stored: no + * Archive button, since the admin may have turned it off, and no route, since + * a plugin may be there. + */ +async function readNotificationActionPrefs() { + let stored = null; + 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", + pluginsMayVeto: !stored || stored.pluginsMayVeto !== false, + 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), "declined" (it didn't take it) or "silent" (no answer). */ +function askClient(client, message, takeMs) { + return new Promise((resolve) => { + if (typeof MessageChannel !== "function") { + resolve("declined"); + return; + } + const channel = new MessageChannel(); + let timer = null; + const finish = (result) => { + clearTimeout(timer); + channel.port1.onmessage = null; + resolve(result); + }; + timer = setTimeout(() => finish("silent"), takeMs); + 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 held = await readClientLogins(); + const holdsLogin = (client) => Array.isArray(held[client.id]) && held[client.id].includes(message.login); + const giveUpAt = Date.now() + CLIENTS_TAKE_TOTAL_MS; + let silentHolder = false; + for (const client of ordered) { + if (Date.now() >= giveUpAt) { + silentHolder = silentHolder || holdsLogin(client); + continue; + } + const holder = holdsLogin(client); + const result = await askClient(client, message, holder ? LOGIN_HOLDER_TAKE_MS : CLIENT_TAKE_MS); + if (result === "silent") silentHolder = silentHolder || holder; + else if (result !== "declined") return { result, client }; + } + // A silent tab with this login may have plugins that would stop the action. + return { result: silentHolder ? "failed" : "declined", client: null }; +} + +/** Keeps a typed reply for the page the click is about to open; it's not for a URL. */ +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; + // Plugins run in pages. One that may stop or watch this action can't from + // the route, so the app opens instead. + if (prefs.pluginsMayVeto) return false; + const body = { action, accountId: data.accountId, emailId: data.emailId, login: data.login }; + if (action === "archive") { + body.archiveMode = prefs.archiveMode; + // 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, + // A frozen tab that thaws later must not act on a click long since handled. + at: Date.now(), + ...(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; + } + if (action === "reply") { + if (text) await keepPendingReply(data.emailId, text); + return false; + } + // A tab with this login tried and failed, or stayed silent: the route won't + // do better and would go round its plugins. + if (result === "failed") return false; + return postNotificationAction(action, data); +} + +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. + } + } +} 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..84b926f51 --- /dev/null +++ b/stores/__tests__/calendar-store-offline-occurrence.test.ts @@ -0,0 +1,248 @@ +// @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 type { CalendarEvent } from '@/lib/jmap/types'; +import { saveOccurrenceEdit } from '@/lib/recurrence-scope-edit'; +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([]); + // As 1.0 reads it back: only what differs from the series. + expect(overrides(w)?.['2026-11-09T10:00:00']).toMatchObject({ 'participants/me/participationStatus': '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'); + }); +}); + +describe('a change that should reach everyone who answered', () => { + async function organizer() { + 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:a@example.org', + participants: { + me: { '@type': 'Participant', calendarAddress: 'mailto:a@example.org', participationStatus: 'accepted', roles: { owner: true } }, + bob: { '@type': 'Participant', calendarAddress: 'mailto:bob@example.org', participationStatus: 'accepted', roles: { attendee: true } }, + }, + }); + 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 writes = (w: Wire) => w.server.calls.filter(([name, args]) => name === 'CalendarEvent/set' && !(args.update && 'h333333' in (args.update as object))); + + it('keeps its two steps: the override quietly first, then the change with messages', async () => { + const { w, client } = await organizer(); + w.online = false; + await useCalendarStore.getState().updateEvent(client, occurrenceOn('2026-11-09')!.id, { title: 'Moved room' }, true, { asOrganizer: true }); + expect(w.db.all('SELECT send_scheduling FROM calendar_pending ORDER BY seq').map((row) => row.send_scheduling)).toEqual([0, 1]); + expect(occurrenceOn('2026-11-09')?.title).toBe('Moved room'); + + w.online = true; + w.server.calls.length = 0; + await flush(w); + expect(await conflicts(w)).toEqual([]); + const flags = writes(w).map(([, args]) => args.sendSchedulingMessages); + expect(flags[0]).toBe(false); + expect(flags[flags.length - 1]).toBe(true); + const lastPatch = (writes(w).at(-1)?.[1].update as Record>).series; + expect(JSON.stringify(lastPatch)).toContain('Moved room'); + const firstPatch = (writes(w)[0][1].update as Record>).series; + expect(JSON.stringify(firstPatch)).not.toContain('Moved room'); + expect(overrides(w)?.['2026-11-09T10:00:00']).toMatchObject({ title: 'Moved room' }); + }); +}); + +describe('a stand-in id still held by the page once the server is back', () => { + async function created() { + const { w, client } = await online(); + w.online = false; + const event = await useCalendarStore.getState().createEvent(client, { + title: 'Offline', + start: '2026-11-11T08:00:00', + duration: 'PT1H', + timeZone: 'Europe/Berlin', + calendarIds: { cal1: true }, + } as Partial); + expect(event?.id).toMatch(/^local-/); + return { w, client, standIn: event!.id }; + } + const realId = (w: Wire) => [...w.server.events.values()].find((event) => event.title !== 'Weekly')!.id; + + it('an open dialog saves after the flush: the change reaches the real event', async () => { + const { w, client, standIn } = await created(); + w.online = true; + await flush(w); + const real = realId(w); + await useCalendarStore.getState().updateEvent(client, standIn, { title: 'Saved from the dialog' }); + expect(w.server.events.get(real)?.title).toBe('Saved from the dialog'); + expect(w.sent.flatMap(([, args]) => Object.keys((args.update ?? {}) as object))).not.toContain(`${standIn}x`); + const local = w.server.calls.filter(([, args]) => JSON.stringify(args).includes(standIn)); + expect(local).toEqual([]); + }); + + it('a delete and a read work too, and a refusal still reads as a refusal', async () => { + const { w, client, standIn } = await created(); + w.online = true; + await flush(w); + const real = realId(w); + expect((await client.getCalendarEvent(standIn))?.id).toBe(real); + w.server.refuse[real] = { type: 'forbidden', description: 'No rights' }; + await expect(client.updateCalendarEvent(standIn, { title: 'x' })).rejects.toThrow('No rights'); + await client.deleteCalendarEvent(standIn); + expect(w.server.events.has(real)).toBe(false); + }); + + it('before the flush the change joins the queue instead of asking the server for an unknown id', async () => { + const { w, client, standIn } = await created(); + w.online = true; + w.server.calls.length = 0; + await client.updateCalendarEvent(standIn, { title: 'Still queued' }); + expect(w.server.calls).toEqual([]); + await flush(w); + expect(w.server.events.get(realId(w))?.title).toBe('Still queued'); + }); +}); + +describe('this and future while offline', () => { + it('is refused before anything is written', async () => { + const { w, client } = await online(); + w.online = false; + await useCalendarStore.getState().fetchEvents(client, START, END); + const event = occurrenceOn('2026-11-09')!; + const updateEvent = useCalendarStore.getState().updateEvent; + const createEvent = useCalendarStore.getState().createEvent; + await expect(saveOccurrenceEdit('this_and_future', { + event, + updates: { title: 'From here on' }, + sendScheduling: false, + userEmails: ['a@example.org'], + }, { client, updateEvent, createEvent, findMasterEvent: async () => client.getCalendarEvent('series') })).rejects.toMatchObject({ name: 'SeriesSplitNeedsServerError' }); + expect(w.db.all('SELECT 1 FROM calendar_pending')).toEqual([]); + }); + + it('still works online', async () => { + const { w, client } = await online(); + const event = occurrenceOn('2026-11-09')!; + const { updateEvent, createEvent } = useCalendarStore.getState(); + await saveOccurrenceEdit('this_and_future', { + event, + updates: { title: 'From here on' }, + sendScheduling: false, + userEmails: ['a@example.org'], + }, { client, updateEvent, createEvent, findMasterEvent: async () => client.getCalendarEvent('series') }); + expect([...w.server.events.values()].some((e) => e.title === 'From here on')).toBe(true); + }); +}); diff --git a/stores/__tests__/email-store-batch-target.test.ts b/stores/__tests__/email-store-batch-target.test.ts index b2ad83e0f..589c90b2f 100644 --- a/stores/__tests__/email-store-batch-target.test.ts +++ b/stores/__tests__/email-store-batch-target.test.ts @@ -73,6 +73,14 @@ describe('batch functions with an explicit target', () => { expect(client.batchArchiveEmails).not.toHaveBeenCalled(); }); + it("files into an Archive someone else reads when the user pressed Archive, as the app's own button does", async () => { + const getMailboxShareWith = vi.fn(async (id: string) => (id === 'arch' ? { u2: { mayReadItems: true } } : null)); + Object.assign(client, { supportsMailboxSharing: () => true, getMailboxShareWith }); + await useEmailStore.getState().batchArchive(other, { emailIds: ['m1'], client, emails: [email('m1')], userArchive: true }); + expect(client.batchArchiveEmails).toHaveBeenCalled(); + expect(getMailboxShareWith).not.toHaveBeenCalled(); + }); + it('tags the named messages through the target\'s client and skips those already tagged', async () => { const ok = await useEmailStore.getState().batchSetTag(other, 'work', true, { emailIds: ['m1', 'm2'], client, emails: [email('m1'), email('m2', { '$label:work': true })], diff --git a/stores/__tests__/email-store-search-refresh-toast.test.ts b/stores/__tests__/email-store-search-refresh-toast.test.ts new file mode 100644 index 000000000..7eba711f7 --- /dev/null +++ b/stores/__tests__/email-store-search-refresh-toast.test.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useEmailStore } from '../email-store'; +import { useSettingsStore } from '../settings-store'; +import { useAuthStore } from '../auth-store'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; + +// A refresh during a search in the Inbox can bring a row only the local store +// found, for example once an old message's body has arrived. It isn't new mail, +// even when it's the newest row on the page and not in the list yet. + +const inbox = { id: 'inbox', role: 'inbox', isShared: false } as Mailbox; + +function email(id: string, receivedAt: string, extra: Partial = {}): Email { + return { + id, threadId: `thread-${id}`, mailboxIds: { inbox: true }, keywords: {}, + subject: id, receivedAt, from: [{ email: 'sender@example.com' }], to: [], + preview: '', size: 1, hasAttachment: false, ...extra, + } as Email; +} + +function client(rows: Email[]) { + return { + advancedSearchEmails: vi.fn(async () => ({ emails: rows, total: rows.length, hasMore: false })), + getThreads: vi.fn(async () => []), + getAccountId: () => 'me', + } as unknown as IJMAPClient; +} + +async function refreshWith(row: Email) { + const listed = email('listed', '2026-10-01T00:00:00Z'); + useEmailStore.setState({ emails: [listed], totalEmails: 1 }); + await useEmailStore.getState().refreshCurrentMailbox(client([row, listed])); + return useEmailStore.getState(); +} + +describe('the new-mail notification during a search', () => { + beforeEach(() => { + useAuthStore.setState(useAuthStore.getInitialState()); + useSettingsStore.setState({ + emailsPerPage: 25, messageListOrder: [], messageListOrderScope: 'inbox', + notificationMutes: { accounts: [], mailboxes: {} }, + }); + useEmailStore.setState({ + ...useEmailStore.getInitialState(), + selectedMailbox: 'inbox', mailboxes: [inbox], searchQuery: 'rechnung', searchMailboxId: 'inbox', + }); + }); + + it('stays quiet for a row only the local store found, and lists it', async () => { + const state = await refreshWith(email('old-find', '2026-10-15T00:00:00Z', { foundLocally: true })); + expect(state.newEmailNotification).toBeNull(); + expect(state.emails.map((row) => row.id)).toContain('old-find'); + }); + + it('still notifies for a new row from the server', async () => { + const state = await refreshWith(email('new', '2026-10-15T00:00:00Z')); + expect(state.newEmailNotification?.id).toBe('new'); + }); +}); 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'; diff --git a/stores/email-store.ts b/stores/email-store.ts index 97d75caca..0f1b60161 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -208,6 +208,12 @@ export interface BatchActionTarget { * wanted state. batchArchive and batchSetTag act on these only. */ emails?: Email[]; + /** + * The user pressed Archive for these, as on the app's own button, which + * files into an Archive shared with others too. Rules and plugins acting + * on their own are refused one. + */ + userArchive?: boolean; } interface EmailStore { @@ -1403,7 +1409,7 @@ async function archiveTarget(client: IJMAPClient, target: BatchActionTarget): Pr if (!archive) return false; // Never into an Archive someone else reads (or a year or month folder of // it): the share map isn't in the folder list, so the server is asked. - if (await isSharedOut(target.client, [...mailboxes, archive], archive.originalId || archive.id, target.accountId, { descendants: true })) { + if (!target.userArchive && await isSharedOut(target.client, [...mailboxes, archive], archive.originalId || archive.id, target.accountId, { descendants: true })) { throw new Error('The Archive folder is shared with other people'); } const hookMailboxes = [...mailboxes.filter(m => m.id !== archive.id), archive]; @@ -7054,6 +7060,7 @@ export const useEmailStore = create((set, get) => ({ !selectedKeyword && mailbox?.role === 'inbox' && !currentEmails.some(e => e.id === newFirst.id) && + !newFirst.foundLocally && (!keepOpenThread || insertedCount > 0) ) { notifyNewInboxMail(client, mailbox, newFirst, get);