From 93761e67ef67c9d5f8c5a0694378a90ea252a26c Mon Sep 17 00:00:00 2001 From: lucletoffe <15689941+lucletoffe@users.noreply.github.com> Date: Fri, 31 Jul 2026 11:26:43 +0200 Subject: [PATCH 1/4] fix(reply): send the parent's RFC msg-id in In-Reply-To, not its JMAP id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replying put `email.id` — the server-local JMAP handle, e.g. `gos2aabtux` — into In-Reply-To/References. No recipient can match that against any Message-ID, so the conversation splits in two: the original sits alone in its thread while the reply (and everything answering it) starts a fresh one. Proven against a live Stalwart account: the reply carried `In-Reply-To: gos2aabtux` and landed in thread `btuz` while its parent stayed in `btux` — Stalwart hashes the referenced msg-ids at ingest and found no match. Fetch `messageId`/`inReplyTo`/`references` with the full email and build the headers from them (bracketed, ancestors first, parent closing the chain). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FTRyrFCR2N9qkpKK37GgWb --- src/api/email.ts | 2 + src/api/types.ts | 4 ++ src/lib/__tests__/email-threading.test.ts | 41 ++++++++++++++++++ src/lib/email-threading.ts | 51 +++++++++++++++++++++++ src/screens/EmailThreadScreen.tsx | 5 ++- 5 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 src/lib/__tests__/email-threading.test.ts create mode 100644 src/lib/email-threading.ts diff --git a/src/api/email.ts b/src/api/email.ts index b24be026..fe9dacc2 100644 --- a/src/api/email.ts +++ b/src/api/email.ts @@ -12,6 +12,8 @@ const EMAIL_FULL_PROPERTIES = [ ...EMAIL_LIST_PROPERTIES, 'bodyStructure', 'textBody', 'htmlBody', 'bodyValues', 'attachments', 'blobId', 'bcc', 'replyTo', 'sentAt', + // Needed to thread replies (In-Reply-To/References) — see lib/email-threading. + 'messageId', 'inReplyTo', 'references', ]; export async function getMailboxes(): Promise { diff --git a/src/api/types.ts b/src/api/types.ts index a686639d..81f2b6c0 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -84,6 +84,10 @@ export interface Email { attachments?: Attachment[]; blobId?: string; bodyStructure?: BodyPart; + /** RFC 5322 msg-ids, bare (no angle brackets) per RFC 8621 §4.1.2.3. */ + messageId?: string[]; + inReplyTo?: string[]; + references?: string[]; } export interface MailboxRights { diff --git a/src/lib/__tests__/email-threading.test.ts b/src/lib/__tests__/email-threading.test.ts new file mode 100644 index 00000000..fdc12089 --- /dev/null +++ b/src/lib/__tests__/email-threading.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from 'vitest'; +import { computeReplyThreadingHeaders } from '../email-threading'; + +describe('computeReplyThreadingHeaders', () => { + it('returns undefined when the parent has no msg-id', () => { + expect(computeReplyThreadingHeaders(undefined)).toBeUndefined(); + expect(computeReplyThreadingHeaders({})).toBeUndefined(); + expect(computeReplyThreadingHeaders({ messageId: [] })).toBeUndefined(); + }); + + it('brackets the parent msg-id', () => { + expect(computeReplyThreadingHeaders({ messageId: ['abc@host'] })).toEqual({ + inReplyTo: '', + references: '', + }); + }); + + it('keeps the ancestor chain and closes it with the parent', () => { + expect( + computeReplyThreadingHeaders({ + messageId: ['c@host'], + references: ['a@host', 'b@host'], + }), + ).toEqual({ + inReplyTo: '', + references: ' ', + }); + }); + + it('de-dupes an ancestor that is already the parent', () => { + expect( + computeReplyThreadingHeaders({ messageId: ['a@host'], references: ['a@host'] }), + ).toEqual({ inReplyTo: '', references: '' }); + }); + + it('tolerates ids that already carry angle brackets', () => { + expect( + computeReplyThreadingHeaders({ messageId: [''], references: [''] }), + ).toEqual({ inReplyTo: '', references: ' ' }); + }); +}); diff --git a/src/lib/email-threading.ts b/src/lib/email-threading.ts new file mode 100644 index 00000000..47239478 --- /dev/null +++ b/src/lib/email-threading.ts @@ -0,0 +1,51 @@ +/** + * RFC 5322 §3.6.4 threading headers for replies. + * + * The JMAP `Email` id (`gos2aabtux`) is a server-local handle — it is NOT the + * RFC msg-id. Putting it in In-Reply-To/References breaks the conversation for + * every downstream client, including the account's own server: Stalwart hashes + * the referenced msg-ids at ingest time, finds nothing that matches, and files + * the reply under a brand-new thread. + * + * `messageId` / `references` come back from JMAP as bare ids (no angle + * brackets, RFC 8621 §4.1.2.3); the wire headers need them bracketed. + */ + +export interface ParentThreadingInfo { + messageId?: string[]; + references?: string[]; +} + +export interface ReplyThreadingHeaders { + /** In-Reply-To header value, e.g. `` */ + inReplyTo: string; + /** References header value: ancestors then parent, space separated */ + references: string; +} + +function stripBrackets(id: string): string { + return id.trim().replace(/^<|>$/g, ''); +} + +export function computeReplyThreadingHeaders( + parent: ParentThreadingInfo | undefined, +): ReplyThreadingHeaders | undefined { + const parentId = parent?.messageId?.map(stripBrackets).find(Boolean); + if (!parentId) return undefined; + + const ancestors = (parent?.references ?? []).map(stripBrackets).filter(Boolean); + + // De-dupe while preserving order; the parent's id closes the chain. + const seen = new Set(); + const chain: string[] = []; + for (const id of [...ancestors, parentId]) { + if (seen.has(id)) continue; + seen.add(id); + chain.push(id); + } + + return { + inReplyTo: `<${parentId}>`, + references: chain.map((id) => `<${id}>`).join(' '), + }; +} diff --git a/src/screens/EmailThreadScreen.tsx b/src/screens/EmailThreadScreen.tsx index 14632870..c22cbf7c 100644 --- a/src/screens/EmailThreadScreen.tsx +++ b/src/screens/EmailThreadScreen.tsx @@ -27,6 +27,7 @@ import { } from '../stores/settings-store'; import { setEmailKeywords } from '../api/email'; import { shareEmailEml, shareAttachment, downloadAttachment } from '../lib/email-export'; +import { computeReplyThreadingHeaders } from '../lib/email-threading'; import { useKeywordsStore, keywordToken, type KeywordDef } from '../stores/keywords-store'; import { useSheetDrag } from '../lib/use-sheet-drag'; import { useLocaleStore } from '../stores/locale-store'; @@ -347,6 +348,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) { if (!email) return; const from = email.from?.[0]; if (!from && mode !== 'forward') return; + const threading = computeReplyThreadingHeaders(email); navigation.navigate('Compose', { mode, replyTo: { @@ -356,7 +358,8 @@ export default function EmailThreadScreen({ route, navigation }: Props) { subject: email.subject ?? '', body: plainTextBody(email), receivedAt: email.receivedAt, - inReplyTo: email.id, + inReplyTo: threading?.inReplyTo, + references: threading?.references, }, }); }; From 71af9d105db83a6a7f7788f25e757b29b30ea2a0 Mon Sep 17 00:00:00 2001 From: Ninad Chandorkar Date: Wed, 5 Aug 2026 21:52:13 +0530 Subject: [PATCH 2/4] fix: camera pre-prompt wording (App Store 5.1.1(iv)) and drawer first open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes App Review and testers hit on the iOS build of this app. **Camera pre-prompt.** App Review rejects a custom pre-permission screen whose button says "Allow" under guideline 5.1.1(iv) — it reads as an imitation of the system dialog. Verbatim from the rejection: "A custom message appears before the permission request, and to proceed users press an 'Allow' button. Use words like 'Continue' or 'Next' on the button instead." The QR scanner's permission screen now says "Continue", and the copy above it explains what the camera is for instead of asking for access. When the OS stops allowing a re-prompt (canAskAgain is false) the button becomes "Open Settings" and calls Linking.openSettings(), which is the recovery path the same guideline suggests. **Drawer first open.** The sidebar drawers park at -screenWidth and start the slide-in from the effect that watches `visible`. That effect runs before the Modal's native view is attached, so the very first open loses its animation and the drawer renders almost entirely off-screen; opening it again works because the view now exists. The open animation also runs from the Modal's onShow, which commits the final offset once the modal is really on screen and is a no-op on later opens. Applied to the mail, calendar and contacts drawers, which all share the pattern. --- src/components/QrScanModal.tsx | 18 ++++++---- src/components/SidebarDrawer.tsx | 20 ++++++++--- .../calendar/CalendarSidebarDrawer.tsx | 36 +++++++++++-------- .../contacts/ContactsSidebarDrawer.tsx | 19 ++++++---- 4 files changed, 62 insertions(+), 31 deletions(-) diff --git a/src/components/QrScanModal.tsx b/src/components/QrScanModal.tsx index d50d08e0..b300a1e4 100644 --- a/src/components/QrScanModal.tsx +++ b/src/components/QrScanModal.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { View, Text, Pressable, StyleSheet, Modal, Platform } from 'react-native'; +import { View, Text, Pressable, StyleSheet, Modal, Platform, Linking } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { CameraView, useCameraPermissions, type BarcodeScanningResult } from 'expo-camera'; import { X } from 'lucide-react-native'; @@ -23,6 +23,8 @@ export function QrScanModal({ visible, onClose, onScanned }: QrScanModalProps) { // Guards against the camera firing onBarcodeScanned dozens of times for the // same code before the modal tears down. const handled = React.useRef(false); + // Once iOS/Android stops letting us re-prompt, the only path left is Settings. + const blocked = !!permission && !permission.granted && !permission.canAskAgain; React.useEffect(() => { if (visible) handled.current = false; @@ -82,12 +84,16 @@ export function QrScanModal({ visible, onClose, onScanned }: QrScanModalProps) { {!permission?.granted ? ( - {permission && !permission.canAskAgain - ? 'Camera access is disabled. Enable it in Settings to scan a sign-in QR code.' - : 'Bulwark Mail needs camera access to scan a sign-in QR code.'} + {blocked + ? 'Camera access is turned off. Turn it on in Settings to scan a sign-in QR code.' + : 'Scanning a sign-in QR code uses the camera. The QR code is read on this device only.'} - ) : null} diff --git a/src/components/SidebarDrawer.tsx b/src/components/SidebarDrawer.tsx index 46f27296..827e6271 100644 --- a/src/components/SidebarDrawer.tsx +++ b/src/components/SidebarDrawer.tsx @@ -234,19 +234,28 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps) const openDuration = useAnimDuration(240); const closeDuration = useAnimDuration(200); + // Runs the slide-in. Kicked from both the visible effect and the Modal's + // onShow: on the very first open the modal's native view is not attached yet + // when the effect fires, so that first animation is dropped and the drawer + // stays parked off-screen. Re-running it once the modal is on screen commits + // the final offset (a no-op on every subsequent open). + const runOpen = React.useCallback(() => { + Animated.parallel([ + Animated.timing(slideX, { toValue: 0, duration: openDuration, easing: Easing.out(Easing.cubic), useNativeDriver: true }), + Animated.timing(overlayOpacity, { toValue: 1, duration: openDuration, useNativeDriver: true }), + ]).start(); + }, [slideX, overlayOpacity, openDuration]); + React.useEffect(() => { if (visible) { - Animated.parallel([ - Animated.timing(slideX, { toValue: 0, duration: openDuration, easing: Easing.out(Easing.cubic), useNativeDriver: true }), - Animated.timing(overlayOpacity, { toValue: 1, duration: openDuration, useNativeDriver: true }), - ]).start(); + runOpen(); } else { Animated.parallel([ Animated.timing(slideX, { toValue: -Dimensions.get('window').width, duration: closeDuration, easing: Easing.in(Easing.cubic), useNativeDriver: true }), Animated.timing(overlayOpacity, { toValue: 0, duration: closeDuration, useNativeDriver: true }), ]).start(); } - }, [visible, slideX, overlayOpacity, openDuration, closeDuration]); + }, [visible, runOpen, slideX, overlayOpacity, closeDuration]); const accountEmail = username || ''; const initials = React.useMemo( @@ -274,6 +283,7 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps) animationType="none" statusBarTranslucent onRequestClose={onClose} + onShow={runOpen} > diff --git a/src/components/calendar/CalendarSidebarDrawer.tsx b/src/components/calendar/CalendarSidebarDrawer.tsx index a9d04fb4..f11603ea 100644 --- a/src/components/calendar/CalendarSidebarDrawer.tsx +++ b/src/components/calendar/CalendarSidebarDrawer.tsx @@ -54,21 +54,28 @@ export function CalendarSidebarDrawer({ const closeDuration = useAnimDuration(200); const [expandedId, setExpandedId] = React.useState(null); + // Also kicked from the Modal's onShow — the first open fires this effect + // before the modal's native view exists, so that animation is dropped and + // the drawer stays parked off-screen until the second open. + const runOpen = React.useCallback(() => { + Animated.parallel([ + Animated.timing(slideX, { + toValue: 0, + duration: openDuration, + easing: Easing.out(Easing.cubic), + useNativeDriver: true, + }), + Animated.timing(overlayOpacity, { + toValue: 1, + duration: openDuration, + useNativeDriver: true, + }), + ]).start(); + }, [slideX, overlayOpacity, openDuration]); + React.useEffect(() => { if (visible) { - Animated.parallel([ - Animated.timing(slideX, { - toValue: 0, - duration: openDuration, - easing: Easing.out(Easing.cubic), - useNativeDriver: true, - }), - Animated.timing(overlayOpacity, { - toValue: 1, - duration: openDuration, - useNativeDriver: true, - }), - ]).start(); + runOpen(); } else { setExpandedId(null); Animated.parallel([ @@ -85,7 +92,7 @@ export function CalendarSidebarDrawer({ }), ]).start(); } - }, [visible, slideX, overlayOpacity, openDuration, closeDuration]); + }, [visible, runOpen, slideX, overlayOpacity, closeDuration]); const hiddenSet = React.useMemo(() => new Set(hiddenCalendarIds), [hiddenCalendarIds]); @@ -114,6 +121,7 @@ export function CalendarSidebarDrawer({ animationType="none" statusBarTranslucent onRequestClose={onClose} + onShow={runOpen} > diff --git a/src/components/contacts/ContactsSidebarDrawer.tsx b/src/components/contacts/ContactsSidebarDrawer.tsx index 56339ab5..a2b817c7 100644 --- a/src/components/contacts/ContactsSidebarDrawer.tsx +++ b/src/components/contacts/ContactsSidebarDrawer.tsx @@ -67,19 +67,26 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) { const slideX = React.useRef(new Animated.Value(-Dimensions.get('window').width)).current; const overlay = React.useRef(new Animated.Value(0)).current; + // Also kicked from the Modal's onShow — the first open fires this effect + // before the modal's native view exists, so that animation is dropped and + // the drawer stays parked off-screen until the second open. + const runOpen = React.useCallback(() => { + Animated.parallel([ + Animated.timing(slideX, { toValue: 0, duration: 240, easing: Easing.out(Easing.cubic), useNativeDriver: true }), + Animated.timing(overlay, { toValue: 1, duration: 240, useNativeDriver: true }), + ]).start(); + }, [slideX, overlay]); + React.useEffect(() => { if (visible) { - Animated.parallel([ - Animated.timing(slideX, { toValue: 0, duration: 240, easing: Easing.out(Easing.cubic), useNativeDriver: true }), - Animated.timing(overlay, { toValue: 1, duration: 240, useNativeDriver: true }), - ]).start(); + runOpen(); } else { Animated.parallel([ Animated.timing(slideX, { toValue: -Dimensions.get('window').width, duration: 200, easing: Easing.in(Easing.cubic), useNativeDriver: true }), Animated.timing(overlay, { toValue: 0, duration: 200, useNativeDriver: true }), ]).start(); } - }, [visible, slideX, overlay]); + }, [visible, runOpen, slideX, overlay]); const select = (cat: ContactCategory) => { setSelectedCategory(cat); @@ -87,7 +94,7 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) { }; return ( - + From da1ff6272527e33526b8350a53cd566d86cee909 Mon Sep 17 00:00:00 2001 From: Ninad Chandorkar Date: Wed, 5 Aug 2026 23:34:53 +0530 Subject: [PATCH 3/4] fix(jmap): request /jmap/session directly to survive iOS auth-drop on redirect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signing in from the iOS build against a Stalwart server fails with "No account found in JMAP session". Stalwart's discovery endpoint /.well-known/jmap answers with a 307 to /jmap/session. iOS NSURLSession strips the Authorization header when it auto-follows that redirect, so the app gets an unauthenticated session back — HTTP 200, empty accounts — and resolveAccountId throws. Browsers keep the auth header on same-origin redirects, which is why the webmail never saw this; it only bites the native app on iOS. Request /jmap/session directly so the header stays attached, falling back to /.well-known/jmap on 404 for servers that don't expose the Stalwart path. Also treat a 200 with no accounts as an auth failure. Stalwart answers that way for missing or invalid credentials instead of sending a 401, so a user who fat-fingers their password currently gets "No account found in JMAP session" rather than "Invalid credentials". Tests updated for both behaviours; full suite passes (the pre-existing auth-store.test.ts parse failure on main is unrelated). --- src/api/__tests__/jmap-client.test.ts | 8 ++++--- src/api/jmap-client.ts | 32 ++++++++++++++++++++++----- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/src/api/__tests__/jmap-client.test.ts b/src/api/__tests__/jmap-client.test.ts index c1784b9b..fac0d7b5 100644 --- a/src/api/__tests__/jmap-client.test.ts +++ b/src/api/__tests__/jmap-client.test.ts @@ -90,7 +90,7 @@ describe('JMAPClient', () => { await client.connect('https://mail.example.com///', 'user', 'pass'); expect(global.fetch).toHaveBeenCalledWith( - 'https://mail.example.com/.well-known/jmap', + 'https://mail.example.com/jmap/session', expect.any(Object), ); }); @@ -281,7 +281,9 @@ describe('JMAPClient', () => { expect(client.accountId).toBe('only-acc'); }); - it('should throw if no accounts at all', async () => { + it('should throw on an empty (unauthenticated) session', async () => { + // Stalwart returns 200 with empty accounts for invalid credentials; + // the client surfaces that as an auth failure. const session = { ...MOCK_SESSION, primaryAccounts: {}, @@ -290,7 +292,7 @@ describe('JMAPClient', () => { global.fetch = mockFetch([{ status: 200, json: session }]) as any; await expect(client.connect('https://mail.example.com', 'user', 'pass')) - .rejects.toThrow('No account found'); + .rejects.toThrow('Invalid credentials'); }); }); }); diff --git a/src/api/jmap-client.ts b/src/api/jmap-client.ts index 8ee01333..2b8cd4f1 100644 --- a/src/api/jmap-client.ts +++ b/src/api/jmap-client.ts @@ -361,18 +361,30 @@ export class JMAPClient { // ── Session Discovery ───────────────────────────────── private async fetchSession(baseUrl: string): Promise { - const url = `${baseUrl}/.well-known/jmap`; await this.ensureFreshToken(); - const doFetch = () => + // Stalwart's discovery endpoint /.well-known/jmap 307-redirects to + // /jmap/session. On iOS, NSURLSession drops the Authorization header when it + // auto-follows that redirect, so the app receives an unauthenticated, + // empty-accounts session and fails with "No account found in JMAP session". + // Request the session endpoint directly so the auth header stays attached; + // fall back to the standard well-known path for non-Stalwart servers. + const doFetch = (url: string) => secureFetch(url, { headers: { Authorization: this.authHeader, Accept: 'application/json', }, }); - let response = await doFetch(); + const primary = `${baseUrl}/jmap/session`; + const fallback = `${baseUrl}/.well-known/jmap`; + const run = async () => { + const r = await doFetch(primary); + return r.status === 404 ? doFetch(fallback) : r; + }; + + let response = await run(); if (response.status === 401 && (await this.forceRefreshToken())) { - response = await doFetch(); + response = await run(); } if (response.status === 401) { @@ -382,7 +394,17 @@ export class JMAPClient { throw new Error(`Session discovery failed: ${response.status} ${response.statusText}`); } - return response.json(); + const session: JMAPSession = await response.json(); + // Stalwart returns HTTP 200 with empty accounts for missing/invalid + // credentials (rather than 401). Treat an empty session as an auth failure + // so the user sees "Invalid credentials" instead of "No account found". + const hasAccount = + Object.keys(session.primaryAccounts ?? {}).length > 0 || + Object.keys(session.accounts ?? {}).length > 0; + if (!hasAccount) { + throw new AuthenticationError('Invalid credentials'); + } + return session; } private resolveAccountId(session: JMAPSession): string { From 32b5298cdd66546b945f61a0ce556543662d4078 Mon Sep 17 00:00:00 2001 From: lucletoffe <15689941+lucletoffe@users.noreply.github.com> Date: Mon, 3 Aug 2026 11:19:50 +0200 Subject: [PATCH 4/4] fix(calendar): page a server-side date filter through CalendarEvent/query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit queryEvents used to ignore its after/before arguments and ask Stalwart for every event in the account with limit:1000. When an account holds more than one page of events, the response was silently capped and the grid rendered empty for the visible month — the missing events sat past the truncation, never fetched. Meanwhile any recurring master whose base start predates the window was returned exactly the same way, so the client-side recurrence expander (already wired) never saw it. Now the query sends after/before as a real filter ANDed with the inCalendar restriction, and iterates position by page size until a short page comes back (or 20 pages, as a bug backstop). Stalwart's matcher accepts a master when any occurrence overlaps the window, so the expander gets the raw event and produces the visible occurrences. The refactor factors buildEventFilter() so the calendar / no-calendar and window / no-window combinations produce the right filter shape (single condition, AND, or absent). Tests updated to lock the new filter shape and to cover the paging path (regression: 1000 events that used to become 1000, now 1002). Picked up from Jim Preston's uncommitted diff in the sister worktree during the 2026-08-03 diagnostic session (artifact PkOWkT8C). --- src/api/__tests__/calendar.test.ts | 42 ++++++++++++++--- src/api/calendar.ts | 72 ++++++++++++++++++++++++------ 2 files changed, 94 insertions(+), 20 deletions(-) diff --git a/src/api/__tests__/calendar.test.ts b/src/api/__tests__/calendar.test.ts index 82e97086..0abc1041 100644 --- a/src/api/__tests__/calendar.test.ts +++ b/src/api/__tests__/calendar.test.ts @@ -42,10 +42,12 @@ describe('calendar operations', () => { }); describe('queryEvents', () => { - it('should filter via singular inCalendar conditions (Stalwart-compatible)', async () => { - // Stalwart rejects the draft's plural `inCalendars` filter (and - // after/before); calendars are restricted via singular `inCalendar` - // conditions and date filtering stays client-side. + it('should AND the date window with the singular inCalendar condition', async () => { + // Stalwart rejects the draft's plural `inCalendars` filter, so calendars + // are restricted via singular `inCalendar` conditions. The date window + // must reach the server: without it the query asks for every event in + // the account and the response is capped at the page size, so the + // calendar renders empty for anyone with more than one page of history. mockRequest.mockResolvedValue({ methodResponses: [['CalendarEvent/query', { ids: ['ev1', 'ev2'] }, '0']], }); @@ -55,10 +57,38 @@ describe('calendar operations', () => { const call = mockRequest.mock.calls[0][0][0]; expect(call[0]).toBe('CalendarEvent/query'); - expect(call[1].filter).toEqual({ inCalendar: 'cal-1' }); + expect(call[1].filter).toEqual({ + operator: 'AND', + conditions: [ + { inCalendar: 'cal-1' }, + { after: '2026-03-01T00:00:00Z', before: '2026-03-31T23:59:59Z' }, + ], + }); expect(call[1].accountId).toBe('acc-1'); }); + it('should page through a result set larger than one page', async () => { + // Regression: a full page used to be returned as the complete answer, so + // an account with more events than the page size silently lost every + // event past it - the calendar rendered empty. + const first = Array.from({ length: 1000 }, (_, i) => `a${i}`); + mockRequest + .mockResolvedValueOnce({ + methodResponses: [['CalendarEvent/query', { ids: first }, '0']], + }) + .mockResolvedValueOnce({ + methodResponses: [['CalendarEvent/query', { ids: ['tail-1', 'tail-2'] }, '0']], + }); + + const result = await queryEvents(['cal-1'], '2026-03-01T00:00:00Z', '2026-03-31T23:59:59Z'); + + expect(result).toHaveLength(1002); + expect(result[1001]).toBe('tail-2'); + expect(mockRequest).toHaveBeenCalledTimes(2); + expect(mockRequest.mock.calls[0][0][0][1].position).toBe(0); + expect(mockRequest.mock.calls[1][0][0][1].position).toBe(1000); + }); + it('should OR multiple calendars and respect the target account', async () => { mockRequest.mockResolvedValue({ methodResponses: [['CalendarEvent/query', { ids: [] }, '0']], @@ -74,7 +104,7 @@ describe('calendar operations', () => { expect(call[1].accountId).toBe('acc-shared'); }); - it('should omit the filter when no calendars are given', async () => { + it('should omit the filter when no calendars are given and window is empty', async () => { mockRequest.mockResolvedValue({ methodResponses: [['CalendarEvent/query', { ids: [] }, '0']], }); diff --git a/src/api/calendar.ts b/src/api/calendar.ts index 544ad177..72d863e2 100644 --- a/src/api/calendar.ts +++ b/src/api/calendar.ts @@ -185,26 +185,70 @@ export async function getCalendars(): Promise { return [...own, ...shared.flat()]; } +const QUERY_PAGE_SIZE = 1000; +// Backstop against a server that keeps returning full pages; 20 pages is far +// beyond any real calendar window and keeps a bug from looping forever. +const QUERY_MAX_PAGES = 20; + +/** + * Combine the calendar restriction and the date window into a single filter. + * Stalwart matches an event when its whole time range - including every + * recurrence - overlaps the window, which is exactly the set the client-side + * expander needs (a recurring master whose base start predates the window + * still comes back if any occurrence falls inside). Either part may be absent + * (callers that want every event in an account pass empty bounds). + */ +function buildEventFilter( + calendarIds: string[], + after: string, + before: string, +): Record | undefined { + const conditions: Record[] = []; + if (calendarIds.length > 0) conditions.push(buildInCalendarFilter(calendarIds)); + const window: Record = {}; + if (after) window.after = after; + if (before) window.before = before; + if (Object.keys(window).length > 0) conditions.push(window); + if (conditions.length === 0) return undefined; + if (conditions.length === 1) return conditions[0]; + return { operator: 'AND', conditions }; +} + export async function queryEvents( calendarIds: string[], - _after: string, - _before: string, + after: string, + before: string, accountId?: string, ): Promise { - // Stalwart rejects after/before filters on CalendarEvent/query; date - // filtering is done client-side. Calendars are restricted via the singular - // `inCalendar` condition (the plural `inCalendars` fails the whole query - // with unsupportedFilter on Stalwart). + // The date window is applied server-side. Without it the query asks for + // every event in the account and the response is silently capped at the + // page size, so any account holding more events than one page loses + // everything past it - including the month being displayed. Calendars are + // restricted via the singular `inCalendar` condition (the plural + // `inCalendars` fails the whole query with unsupportedFilter on Stalwart). const account = accountId || jmapClient.accountId; - const args: Record = { accountId: account, limit: 1000 }; const timeZone = getUserTimeZone(); - if (timeZone) args.timeZone = timeZone; - if (calendarIds.length > 0) args.filter = buildInCalendarFilter(calendarIds); - const res = await jmapClient.request( - [['CalendarEvent/query', args, '0']], - USING, - ); - return methodResult<{ ids: string[] }>(res).ids ?? []; + const filter = buildEventFilter(calendarIds, after, before); + const ids: string[] = []; + // Page through the result set: a window can still hold more events than one + // page, and a truncated page must never pass for a complete answer. + for (let page = 0; page < QUERY_MAX_PAGES; page++) { + const args: Record = { + accountId: account, + limit: QUERY_PAGE_SIZE, + position: ids.length, + }; + if (timeZone) args.timeZone = timeZone; + if (filter) args.filter = filter; + const res = await jmapClient.request( + [['CalendarEvent/query', args, '0']], + USING, + ); + const batch = methodResult<{ ids: string[] }>(res).ids ?? []; + ids.push(...batch); + if (batch.length < QUERY_PAGE_SIZE) break; + } + return ids; } export async function getEvents(ids: string[], accountId?: string): Promise {