diff --git a/locales/en/common.json b/locales/en/common.json index 42357a66..df031736 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -497,6 +497,7 @@ "drop_files": "Drop files to attach", "show_less": "Show less", "send_failed": "Failed to send email", + "editor_unavailable": "Could not read the message content. Copy your text, then close and reopen the composer.", "continue_draft": "Continue draft", "close_draft_title": "Save or discard draft?", "close_draft_message": "You have unsaved changes. Would you like to save this as a draft or discard it?", diff --git a/src/api/__tests__/calendar.test.ts b/src/api/__tests__/calendar.test.ts index 62cb31ce..82e97086 100644 --- a/src/api/__tests__/calendar.test.ts +++ b/src/api/__tests__/calendar.test.ts @@ -87,7 +87,10 @@ describe('calendar operations', () => { }); describe('getEvents', () => { - it('should fetch events by id with expected properties', async () => { + it('should fetch events by id with singular JSCalendar 2.0 recurrence properties', async () => { + // Stalwart (calcard) names the property `recurrenceRule` (singular, + // single object) — requesting the RFC 8984 plural form returns no + // recurrence data and repeating events vanish from the grid (#13). const events = [{ id: 'ev1', title: 'Meeting', start: '2026-03-29T10:00:00' }]; mockRequest.mockResolvedValue({ methodResponses: [['CalendarEvent/get', { list: events }, '0']], @@ -99,7 +102,48 @@ describe('calendar operations', () => { const call = mockRequest.mock.calls[0][0][0]; expect(call[1].properties).toContain('title'); expect(call[1].properties).toContain('start'); - expect(call[1].properties).toContain('recurrenceRules'); + expect(call[1].properties).toContain('recurrenceRule'); + expect(call[1].properties).toContain('excludedRecurrenceRule'); + expect(call[1].properties).not.toContain('recurrenceRules'); + expect(call[1].properties).not.toContain('excludedRecurrenceRules'); + }); + + it('should normalize a singular recurrenceRule object to the plural array form', async () => { + const events = [{ + id: 'ev1', + title: 'Every 5 weeks', + start: '2026-03-02T10:00:00', + recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'weekly', interval: 5 }, + excludedRecurrenceRule: { frequency: 'weekly', interval: 10 }, + }]; + mockRequest.mockResolvedValue({ + methodResponses: [['CalendarEvent/get', { list: events }, '0']], + }); + + const [event] = await getEvents(['ev1']); + expect(event.recurrenceRules).toEqual([ + { '@type': 'RecurrenceRule', frequency: 'weekly', interval: 5 }, + ]); + expect(event.excludedRecurrenceRules).toEqual([ + { frequency: 'weekly', interval: 10 }, + ]); + expect(event).not.toHaveProperty('recurrenceRule'); + expect(event).not.toHaveProperty('excludedRecurrenceRule'); + }); + + it('should keep an array-valued recurrenceRule as-is (JMAP-created events)', async () => { + const events = [{ + id: 'ev1', + title: 'Weekly', + start: '2026-03-02T10:00:00', + recurrenceRule: [{ frequency: 'weekly', interval: 2 }], + }]; + mockRequest.mockResolvedValue({ + methodResponses: [['CalendarEvent/get', { list: events }, '0']], + }); + + const [event] = await getEvents(['ev1']); + expect(event.recurrenceRules).toEqual([{ frequency: 'weekly', interval: 2 }]); }); }); @@ -115,10 +159,39 @@ describe('calendar operations', () => { 'cal-1', ); - expect(result).toEqual(created); + // The /set response echoes only server-set properties; the return value + // merges them over the submitted payload. + expect(result).toEqual({ + id: 'ev-new', + title: 'Lunch', + start: '2026-03-30T12:00:00', + duration: 'PT1H', + calendarIds: { 'cal-1': true }, + }); const call = mockRequest.mock.calls[0][0][0]; expect(call[1].create['new-event'].calendarIds).toEqual({ 'cal-1': true }); }); + + it('should send recurrence as a singular cleaned object', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [['CalendarEvent/set', { created: { 'new-event': { id: 'ev-new' } } }, '0']], + }); + + const result = await createEvent( + { + title: 'Series', + start: '2026-03-30T12:00:00', + recurrenceRules: [{ frequency: 'weekly', interval: 5, until: null as unknown as string }], + }, + 'cal-1', + ); + + const sent = mockRequest.mock.calls[0][0][0][1].create['new-event']; + expect(sent.recurrenceRule).toEqual({ frequency: 'weekly', interval: 5 }); + expect(sent).not.toHaveProperty('recurrenceRules'); + // The returned event exposes the internal plural form again. + expect(result.recurrenceRules).toEqual([{ frequency: 'weekly', interval: 5 }]); + }); }); describe('updateEvent', () => { @@ -132,6 +205,23 @@ describe('calendar operations', () => { const call = mockRequest.mock.calls[0][0][0]; expect(call[1].update).toEqual({ ev1: { title: 'Updated Meeting' } }); }); + + it('should convert recurrenceRules updates to the singular property', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [['CalendarEvent/set', { updated: {} }, '0']], + }); + + await updateEvent('ev1', { recurrenceRules: [{ frequency: 'monthly' }] }); + expect(mockRequest.mock.calls[0][0][0][1].update).toEqual({ + ev1: { recurrenceRule: { frequency: 'monthly' } }, + }); + + // Empty array means "remove recurrence" — sent as an explicit null. + await updateEvent('ev1', { recurrenceRules: [] }); + expect(mockRequest.mock.calls[1][0][0][1].update).toEqual({ + ev1: { recurrenceRule: null }, + }); + }); }); describe('deleteEvents', () => { diff --git a/src/api/calendar.ts b/src/api/calendar.ts index 3581033d..544ad177 100644 --- a/src/api/calendar.ts +++ b/src/api/calendar.ts @@ -37,18 +37,94 @@ function buildInCalendarFilter(calendarIds: string[]): Record { }; } +// Stalwart's calcard crate implements JSCalendar 2.0 (jscalendarbis) property +// names: singular `recurrenceRule` / `excludedRecurrenceRule` holding a single +// object, not RFC 8984's plural array forms. Requesting the plural names +// returns no recurrence data at all, so recurring events silently render as +// one-off events (#13). Request the singular names and normalize below. const CALENDAR_EVENT_PROPERTIES = [ 'id', '@type', 'uid', 'calendarIds', 'title', 'description', 'start', 'duration', 'timeZone', 'showWithoutTime', 'utcStart', 'utcEnd', 'status', 'freeBusyStatus', - 'participants', 'alerts', 'useDefaultAlerts', 'recurrenceRules', - 'recurrenceOverrides', 'excludedRecurrenceRules', + 'participants', 'alerts', 'useDefaultAlerts', 'recurrenceRule', + 'recurrenceOverrides', 'excludedRecurrenceRule', 'recurrenceId', 'replyTo', 'organizerCalendarAddress', 'sequence', 'locations', 'virtualLocations', 'progress', 'due', 'priority', 'percentComplete', 'links', 'created', 'updated', ]; +/** + * Normalize Stalwart's singular recurrence property names to the RFC 8984 + * plural array forms the client uses internally. JSCalendar 2.0 defines + * recurrenceRule as a single object, but Stalwart may also return an array + * (for events created via JMAP), so both forms are handled. Mirrors webmail's + * normalizeStalwartPropertyNames. + */ +export function normalizeRecurrenceProperties>(event: T): T { + const raw = event as Record; + let patched = false; + const updates: Partial = {}; + + if ('recurrenceRule' in raw && !('recurrenceRules' in raw)) { + const val = raw.recurrenceRule; + if (val != null && !Array.isArray(val) && typeof val === 'object') { + updates.recurrenceRules = [val] as CalendarEvent['recurrenceRules']; + } else { + updates.recurrenceRules = val as CalendarEvent['recurrenceRules']; + } + patched = true; + } + if ('excludedRecurrenceRule' in raw && !('excludedRecurrenceRules' in raw)) { + const val = raw.excludedRecurrenceRule; + if (val != null && !Array.isArray(val) && typeof val === 'object') { + updates.excludedRecurrenceRules = [val] as CalendarEvent['excludedRecurrenceRules']; + } else { + updates.excludedRecurrenceRules = val as CalendarEvent['excludedRecurrenceRules']; + } + patched = true; + } + + if (!patched) return event; + + const result = { ...event, ...updates } as T; + delete (result as Record).recurrenceRule; + delete (result as Record).excludedRecurrenceRule; + return result; +} + +/** + * Convert the client's plural recurrence arrays to the singular JSCalendar 2.0 + * properties Stalwart expects on write, dropping null rule fields. An empty + * array (or null) becomes an explicit null so "remove recurrence" round-trips. + * Mirrors webmail's cleanRecurrenceRules. + */ +function cleanRecurrenceRules(event: Record): void { + const keyMap: Record = { + recurrenceRules: 'recurrenceRule', + excludedRecurrenceRules: 'excludedRecurrenceRule', + }; + for (const [pluralKey, singularKey] of Object.entries(keyMap)) { + const rules = event[pluralKey]; + if (rules === undefined) continue; + delete event[pluralKey]; + if (!Array.isArray(rules)) { + event[singularKey] = rules; + continue; + } + if (rules.length === 0) { + event[singularKey] = null; + continue; + } + const rule = rules[0] as Record; + const cleaned: Record = {}; + for (const [k, v] of Object.entries(rule)) { + if (v !== null) cleaned[k] = v; + } + event[singularKey] = cleaned; + } +} + function methodResult(res: any, index = 0): T { const entry = res?.methodResponses?.[index]; if (!entry) throw new Error('JMAP: empty method response'); @@ -149,7 +225,7 @@ export async function getEvents(ids: string[], accountId?: string): Promise(res).list ?? []; - all.push(...list); + all.push(...list.map(normalizeRecurrenceProperties)); } return all; } @@ -176,11 +252,11 @@ export async function createEvent( targetAccountId?: string, ): Promise { const accountId = targetAccountId || jmapClient.accountId; + const payload: Record = { ...event, calendarIds: { [calendarId]: true } }; + cleanRecurrenceRules(payload); const res = await jmapClient.request( [['CalendarEvent/set', setArgs(accountId, { - create: { - 'new-event': { ...event, calendarIds: { [calendarId]: true } }, - }, + create: { 'new-event': payload }, }, sendSchedulingMessages), '0']], USING, ); @@ -193,7 +269,9 @@ export async function createEvent( const err = result.notCreated?.['new-event']; throw new Error(err?.description || err?.type || 'Failed to create event'); } - return created; + // The /set response echoes only server-set properties; merge them over the + // submitted payload so the store's optimistic insert keeps recurrence data. + return normalizeRecurrenceProperties({ ...payload, ...created } as CalendarEvent); } // Batch-create many events in one CalendarEvent/set. Returns the number created. @@ -206,7 +284,9 @@ export async function batchCreateEvents( const accountId = targetAccountId || jmapClient.accountId; const create: Record> = {}; events.forEach((e, i) => { - create[`evt-${i}`] = { ...e, calendarIds: { [calendarId]: true } }; + const payload: Record = { ...e, calendarIds: { [calendarId]: true } }; + cleanRecurrenceRules(payload); + create[`evt-${i}`] = payload as Partial; }); const res = await jmapClient.request( [['CalendarEvent/set', { accountId, create }, '0']], @@ -223,8 +303,10 @@ export async function updateEvent( targetAccountId?: string, ): Promise { const accountId = targetAccountId || jmapClient.accountId; + const patch: Record = { ...changes }; + cleanRecurrenceRules(patch); const res = await jmapClient.request( - [['CalendarEvent/set', setArgs(accountId, { update: { [id]: changes } }, sendSchedulingMessages), '0']], + [['CalendarEvent/set', setArgs(accountId, { update: { [id]: patch } }, sendSchedulingMessages), '0']], USING, ); const result = methodResult<{ notUpdated?: Record }>(res); @@ -285,7 +367,8 @@ export async function parseCalendarBlob(blobId: string): Promise; focus(): void; } @@ -94,6 +101,27 @@ const RichTextEditor = React.forwardRef(function Ri webViewRef.current?.injectJavaScript(`${script}; true;`); }, []); + // Pending getHtml() readbacks, keyed by request id. Timers reject requests + // the page never answers (dead script / broken bridge). + const snapshotSeq = React.useRef(0); + const pendingSnapshots = React.useRef( + new Map void; + reject: (err: Error) => void; + timer: ReturnType; + }>(), + ); + React.useEffect(() => { + const pending = pendingSnapshots.current; + return () => { + for (const { timer, reject } of pending.values()) { + clearTimeout(timer); + reject(new Error('Editor unmounted')); + } + pending.clear(); + }; + }, []); + React.useImperativeHandle(ref, () => ({ exec: (command) => { const [name, value] = command.split(':'); @@ -112,6 +140,16 @@ const RichTextEditor = React.forwardRef(function Ri setHtml: (next) => { call(`window.__rne && window.__rne.setHtml(${JSON.stringify(next)})`); }, + getHtml: (timeoutMs = 2000) => + new Promise((resolve, reject) => { + const id = ++snapshotSeq.current; + const timer = setTimeout(() => { + pendingSnapshots.current.delete(id); + reject(new Error('Editor did not answer')); + }, timeoutMs); + pendingSnapshots.current.set(id, { resolve, reject, timer }); + call(`window.__rne && window.__rne.getHtml(${id})`); + }), focus: () => { call(`window.__rne && window.__rne.focus()`); }, @@ -133,6 +171,16 @@ const RichTextEditor = React.forwardRef(function Ri if (!Number.isNaN(h) && h > 0) setHeight(Math.max(MIN_EDITOR_HEIGHT, h)); break; } + case 'htmlSnapshot': { + const p = data.payload as { id?: number; html?: string } | null; + const entry = typeof p?.id === 'number' ? pendingSnapshots.current.get(p.id) : undefined; + if (entry) { + pendingSnapshots.current.delete(p!.id!); + clearTimeout(entry.timer); + entry.resolve(typeof p?.html === 'string' ? p.html : ''); + } + break; + } case 'selection': onSelectionRef.current?.((data.payload as RichTextSelectionState) ?? EMPTY_STATE); break; diff --git a/src/lib/__tests__/editor-html.test.ts b/src/lib/__tests__/editor-html.test.ts index b69f1808..d5d0acbc 100644 --- a/src/lib/__tests__/editor-html.test.ts +++ b/src/lib/__tests__/editor-html.test.ts @@ -47,4 +47,12 @@ describe('buildEditorHtml', () => { expect(script).toContain('window.__rne'); expect(script).toContain('ReactNativeWebView.postMessage'); }); + + // Regression for issue #9: sends must be able to read the live DOM back + // instead of trusting async `change` messages that can lag or be lost. + it('exposes the send-time getHtml readback on the bridge', () => { + const script = extractScript(buildEditorHtml(base)); + expect(script).toContain('getHtml: function (id)'); + expect(script).toContain("post('htmlSnapshot', { id: id, html: editor.innerHTML })"); + }); }); diff --git a/src/lib/__tests__/recurrence-expansion.test.ts b/src/lib/__tests__/recurrence-expansion.test.ts index 5241ba3c..29fa8348 100644 --- a/src/lib/__tests__/recurrence-expansion.test.ts +++ b/src/lib/__tests__/recurrence-expansion.test.ts @@ -74,6 +74,54 @@ describe('expandRecurringEvents - weekly', () => { }); }); +describe('expandRecurringEvents - interval > 1 (#13)', () => { + it('expands an every-5-weeks rule', () => { + const rule: RecurrenceRule = { frequency: 'weekly', interval: 5 }; + const result = expandRecurringEvents( + [ev({ recurrenceRules: [rule] })], // starts Mon 2026-04-06 + '2026-04-01T00:00:00', + '2026-08-01T00:00:00', + ); + expect(result.map((e) => e.start)).toEqual([ + '2026-04-06T09:00:00', + '2026-05-11T09:00:00', + '2026-06-15T09:00:00', + '2026-07-20T09:00:00', + ]); + }); + + it('expands an every-5-weeks series started long before the visible range', () => { + const event = ev({ + start: '2025-01-06T09:00:00', // Monday + recurrenceRules: [{ frequency: 'weekly', interval: 5 }], + }); + // Occurrences fall every 35 days from 2025-01-06 (…2026-04-06, 05-11, + // 06-15, 07-20…) — only those inside the window are returned. + const result = expandRecurringEvents([event], '2026-05-01T00:00:00', '2026-08-01T00:00:00'); + expect(result.map((e) => e.start)).toEqual([ + '2026-05-11T09:00:00', + '2026-06-15T09:00:00', + '2026-07-20T09:00:00', + ]); + }); + + it('expands an every-2-months rule', () => { + const rule: RecurrenceRule = { frequency: 'monthly', interval: 2 }; + const result = expandRecurringEvents( + [ev({ recurrenceRules: [rule] })], + '2026-04-01T00:00:00', + '2026-12-31T00:00:00', + ); + expect(result.map((e) => e.start)).toEqual([ + '2026-04-06T09:00:00', + '2026-06-06T09:00:00', + '2026-08-06T09:00:00', + '2026-10-06T09:00:00', + '2026-12-06T09:00:00', + ]); + }); +}); + describe('expandRecurringEvents - monthly', () => { it('expands monthly on the 6th', () => { const rule: RecurrenceRule = { frequency: 'monthly', count: 3 }; diff --git a/src/lib/editor-html.ts b/src/lib/editor-html.ts index 92ba55c2..a6214516 100644 --- a/src/lib/editor-html.ts +++ b/src/lib/editor-html.ts @@ -263,6 +263,12 @@ export function buildEditorHtml(opts: { reportChange(); reportHeight(); }, + // Send-time readback (issue #9): RN asks for the live DOM content instead + // of trusting the last change message, which can lag typing or be lost + // entirely if the bridge breaks. The id lets RN match reply to request. + getHtml: function (id) { + post('htmlSnapshot', { id: id, html: editor.innerHTML }); + }, focus: function () { editor.focus(); }, }; diff --git a/src/screens/ComposeScreen.tsx b/src/screens/ComposeScreen.tsx index 5a80fab9..af89659d 100644 --- a/src/screens/ComposeScreen.tsx +++ b/src/screens/ComposeScreen.tsx @@ -758,13 +758,13 @@ export default function ComposeScreen({ route, navigation }: Props) { // Catch the classic "I forgot the attachment" footgun. Only file attachments // count - inline images don't satisfy the user's intent of attaching a file. // Returns true if it's safe to proceed, false if the user cancelled. - const passesAttachmentReminder = async (): Promise => { + const passesAttachmentReminder = async (html: string): Promise => { if (!attachmentReminderEnabled) return true; const hasFileAttachment = attachments.some( (a) => !a.inline && a.blobId && !a.error, ); if (hasFileAttachment) return true; - const haystack = `${subject}\n${htmlToPlainText(bodyHtml)}`.toLowerCase(); + const haystack = `${subject}\n${htmlToPlainText(html)}`.toLowerCase(); const matchedKeyword = attachmentReminderKeywords.find((kw) => { const k = kw.trim().toLowerCase(); if (!k) return false; @@ -878,12 +878,33 @@ export default function ComposeScreen({ route, navigation }: Props) { const performSend = async (holdForSeconds?: number, scheduledAt?: Date) => { if (!canSend || !primaryIdentity || !sentMailbox) return; - if (!(await passesAttachmentReminder())) return; + + // Read the body straight from the editor DOM at send time. The `change` + // messages that feed `bodyHtml` are async and best-effort — trusting them + // here once shipped replies with the typed text silently missing when the + // page script had died (issue #9). If the editor doesn't answer we abort + // loudly rather than send possibly-stale content. + let liveBodyHtml: string; + try { + liveBodyHtml = (await editorRef.current?.getHtml()) ?? bodyHtml; + } catch { + Alert.alert( + t('email_composer.send_failed', 'Send failed'), + t( + 'email_composer.editor_unavailable', + 'Could not read the message content. Copy your text, then close and reopen the composer.', + ), + ); + return; + } + if (liveBodyHtml !== bodyHtml) setBodyHtml(liveBodyHtml); + + if (!(await passesAttachmentReminder(liveBodyHtml))) return; setSending(true); try { const from: EmailAddress[] = [{ name: primaryIdentity.name, email: primaryIdentity.email }]; - const { html: rewrittenHtml, usedCids } = rewriteInlineImages(bodyHtml); + const { html: rewrittenHtml, usedCids } = rewriteInlineImages(liveBodyHtml); // Belt-and-suspenders sanitization: the editor uses execCommand which // can preserve pasted