fix: send-time editor readback, base-view restore after search, Stalwart recurrence #9, #10, #13

This commit is contained in:
Linus Rath
2026-07-22 22:17:40 +02:00
parent 2427bdbd79
commit 802e1e1c71
10 changed files with 562 additions and 30 deletions
+1
View File
@@ -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?",
+93 -3
View File
@@ -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', () => {
+93 -10
View File
@@ -37,18 +37,94 @@ function buildInCalendarFilter(calendarIds: string[]): Record<string, unknown> {
};
}
// 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<T extends Partial<CalendarEvent>>(event: T): T {
const raw = event as Record<string, unknown>;
let patched = false;
const updates: Partial<CalendarEvent> = {};
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<string, unknown>).recurrenceRule;
delete (result as Record<string, unknown>).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<string, unknown>): void {
const keyMap: Record<string, string> = {
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<string, unknown>;
const cleaned: Record<string, unknown> = {};
for (const [k, v] of Object.entries(rule)) {
if (v !== null) cleaned[k] = v;
}
event[singularKey] = cleaned;
}
}
function methodResult<T = any>(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<Cale
USING,
);
const list = methodResult<{ list: CalendarEvent[] }>(res).list ?? [];
all.push(...list);
all.push(...list.map(normalizeRecurrenceProperties));
}
return all;
}
@@ -176,11 +252,11 @@ export async function createEvent(
targetAccountId?: string,
): Promise<CalendarEvent> {
const accountId = targetAccountId || jmapClient.accountId;
const payload: Record<string, unknown> = { ...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<string, Partial<CalendarEvent>> = {};
events.forEach((e, i) => {
create[`evt-${i}`] = { ...e, calendarIds: { [calendarId]: true } };
const payload: Record<string, unknown> = { ...e, calendarIds: { [calendarId]: true } };
cleanRecurrenceRules(payload);
create[`evt-${i}`] = payload as Partial<CalendarEvent>;
});
const res = await jmapClient.request(
[['CalendarEvent/set', { accountId, create }, '0']],
@@ -223,8 +303,10 @@ export async function updateEvent(
targetAccountId?: string,
): Promise<void> {
const accountId = targetAccountId || jmapClient.accountId;
const patch: Record<string, unknown> = { ...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<string, { description?: string; type?: string }> }>(res);
@@ -285,7 +367,8 @@ export async function parseCalendarBlob(blobId: string): Promise<Partial<Calenda
if (result.notFound?.includes(blobId)) throw new Error('Uploaded file not found');
const parsed = result.parsed?.[blobId];
if (!parsed) return [];
return Array.isArray(parsed) ? parsed : [parsed];
const list = Array.isArray(parsed) ? parsed : [parsed];
return list.map(normalizeRecurrenceProperties);
}
export async function createCalendar(
+48
View File
@@ -51,6 +51,13 @@ export interface RichTextEditorHandle {
unsetLink(): void;
insertImage(src: string, cid?: string, alt?: string): void;
setHtml(html: string): void;
/**
* Read the live editor DOM content. Resolves with the current innerHTML or
* rejects after `timeoutMs` when the page script is dead or the bridge is
* broken — callers must treat that as "content unknown", never fall back to
* possibly-stale state (issue #9: doing so silently sent blank replies).
*/
getHtml(timeoutMs?: number): Promise<string>;
focus(): void;
}
@@ -94,6 +101,27 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(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<number, {
resolve: (html: string) => void;
reject: (err: Error) => void;
timer: ReturnType<typeof setTimeout>;
}>(),
);
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<RichTextEditorHandle, Props>(function Ri
setHtml: (next) => {
call(`window.__rne && window.__rne.setHtml(${JSON.stringify(next)})`);
},
getHtml: (timeoutMs = 2000) =>
new Promise<string>((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<RichTextEditorHandle, Props>(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;
+8
View File
@@ -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 })");
});
});
@@ -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 };
+6
View File
@@ -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(); },
};
+25 -4
View File
@@ -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<boolean> => {
const passesAttachmentReminder = async (html: string): Promise<boolean> => {
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 <script>/<style>/etc. Strip them before sending.
const safeHtml = stripDangerousTags(rewrittenHtml);
+144
View File
@@ -111,6 +111,7 @@ import { useEmailStore } from '../email-store';
const mockGetMailboxesWithState = emailApi.getMailboxesWithState as ReturnType<typeof vi.fn>;
const mockQueryEmails = emailApi.queryEmails as ReturnType<typeof vi.fn>;
const mockGetEmailQueryChanges = emailApi.getEmailQueryChanges as ReturnType<typeof vi.fn>;
const mockGetEmails = emailApi.getEmails as ReturnType<typeof vi.fn>;
const mockGetEmailsWithState = emailApi.getEmailsWithState as ReturnType<typeof vi.fn>;
const mockGetFullEmail = emailApi.getFullEmail as ReturnType<typeof vi.fn>;
@@ -313,6 +314,149 @@ describe('email-store', () => {
});
});
// Issue #6: the "Unread" tri-state filter must reach Email/query as a
// notKeyword condition and force the full re-query path (the incremental
// path only serves the unfiltered base view).
describe('filters (issue #6)', () => {
it('applies the unread filter to the mailbox query', async () => {
useEmailStore.setState({
currentMailboxId: 'mb-1',
emails: [],
totalEmails: 0,
searchQuery: '',
filters: { isUnread: true },
mailboxSnapshots: {
'mb-1': { emails: [{ id: 'e9' } as any], total: 1, queryState: 'q-base' },
},
});
mockQueryEmails.mockResolvedValue({ ids: ['e1'], total: 1, queryState: 'q-unread' });
mockGetEmailsWithState.mockResolvedValue({ list: [{ id: 'e1' } as any], state: 'em-1' });
await useEmailStore.getState().refreshEmails();
expect(mockGetEmailQueryChanges).not.toHaveBeenCalled();
const [mailboxId, opts] = mockQueryEmails.mock.calls[0];
expect(mailboxId).toBe('mb-1');
expect(opts.filter).toEqual({
operator: 'AND',
conditions: [{ inMailbox: 'mb-1' }, { notKeyword: '$seen' }],
});
// Filter results must not leak into the base-view snapshot.
expect(useEmailStore.getState().mailboxSnapshots['mb-1'].emails).toEqual([{ id: 'e9' }]);
});
});
// Issue #10: after searching, the inbox stayed stuck on the search results.
// Root cause: the incremental Email/queryChanges refresh diffed against the
// on-screen list (search results) instead of the cached base view, then
// wrote the result back into the persisted mailbox snapshot.
describe('search / return to inbox (issue #10)', () => {
const base = [
{ id: 'e1', subject: 'One' } as any,
{ id: 'e2', subject: 'Two' } as any,
];
it('incremental refresh diffs against the snapshot, not on-screen search results', async () => {
// Screen still shows a (stale) search-result list, but searchQuery is
// already '' — the cold-start / just-cleared-search shape.
useEmailStore.setState({
currentMailboxId: 'mb-1',
emails: [base[1]],
totalEmails: 1,
searchQuery: '',
filters: {},
mailboxSnapshots: { 'mb-1': { emails: base, total: 2, queryState: 'q-base' } },
});
mockGetEmailQueryChanges.mockResolvedValue({
oldQueryState: 'q-base',
newQueryState: 'q-2',
total: 2,
removed: [],
added: [],
});
await useEmailStore.getState().refreshEmails();
const state = useEmailStore.getState();
expect(state.emails).toEqual(base);
expect(state.mailboxSnapshots['mb-1'].emails).toEqual(base);
expect(mockQueryEmails).not.toHaveBeenCalled();
});
it('does not write search results into the mailbox snapshot', async () => {
useEmailStore.setState({
currentMailboxId: 'mb-1',
emails: base,
totalEmails: 2,
searchQuery: 'two',
filters: {},
queryState: 'q-base',
mailboxSnapshots: { 'mb-1': { emails: base, total: 2, queryState: 'q-base' } },
});
mockQueryEmails.mockResolvedValue({ ids: ['e2'], total: 1, queryState: 'q-search' });
mockGetEmailsWithState.mockResolvedValue({ list: [base[1]], state: 'em-1' });
await useEmailStore.getState().refreshEmails();
const state = useEmailStore.getState();
expect(state.emails).toEqual([base[1]]);
// Base-view cache must survive the search untouched.
expect(state.mailboxSnapshots['mb-1'].emails).toEqual(base);
expect(state.queryState).toBe('q-base');
expect(mockGetEmailQueryChanges).not.toHaveBeenCalled();
});
it('clearSearchAndFilters restores the cached base view immediately', () => {
useEmailStore.setState({
currentMailboxId: 'mb-1',
emails: [base[1]],
totalEmails: 1,
searchQuery: 'two',
filters: {},
mailboxSnapshots: { 'mb-1': { emails: base, total: 2, queryState: 'q-base' } },
});
mockGetEmailQueryChanges.mockResolvedValue({
oldQueryState: 'q-base',
newQueryState: 'q-2',
total: 2,
removed: [],
added: [],
});
useEmailStore.getState().clearSearchAndFilters();
// Synchronously back on the base view — no waiting for the network.
const state = useEmailStore.getState();
expect(state.searchQuery).toBe('');
expect(state.emails).toEqual(base);
expect(state.totalEmails).toBe(2);
});
it('falls back to a full re-query when the snapshot window is incomplete', async () => {
// A pre-fix install could have a poisoned snapshot: a handful of search
// results stored against the base view's total. It can't be patched
// incrementally — the refresh must rebuild it from the server.
useEmailStore.setState({
currentMailboxId: 'mb-1',
emails: [base[1]],
totalEmails: 1,
searchQuery: '',
filters: {},
mailboxSnapshots: { 'mb-1': { emails: [base[1]], total: 50, queryState: 'q-base' } },
});
mockQueryEmails.mockResolvedValue({ ids: ['e1', 'e2'], total: 50, queryState: 'q-new' });
mockGetEmailsWithState.mockResolvedValue({ list: base, state: 'em-2' });
await useEmailStore.getState().refreshEmails();
const state = useEmailStore.getState();
expect(mockGetEmailQueryChanges).not.toHaveBeenCalled();
expect(state.emails).toEqual(base);
expect(state.mailboxSnapshots['mb-1'].emails).toEqual(base);
expect(state.mailboxSnapshots['mb-1'].queryState).toBe('q-new');
});
});
describe('getEmailDetail', () => {
it('should fetch full email', async () => {
const fullEmail = { id: 'e1', subject: 'Test', bodyValues: { html: { value: '<p>Hi</p>' } } };
+96 -13
View File
@@ -223,6 +223,17 @@ function isBaseView(searchQuery: string, filters: EmailFilters): boolean {
return !searchQuery.trim() && Object.keys(filters).length === 0;
}
// View fields to apply when returning from a search/filter to the base view:
// the cached base-view snapshot, shown immediately so the list doesn't keep
// displaying search results while the refresh is in flight (issue #10).
function restoredBaseView(state: EmailState): Partial<EmailState> {
const snap = state.currentMailboxId
? state.mailboxSnapshots[state.currentMailboxId]
: undefined;
if (!snap) return {};
return { emails: snap.emails, totalEmails: snap.total, queryState: snap.queryState };
}
function snapshotFromActive(state: EmailState): AccountSnapshot {
// Persist the currently-visible mailbox into its snapshot before tucking
// the whole account away.
@@ -642,7 +653,7 @@ export const useEmailStore = create<EmailState>()(
refreshEmails: async () => {
const state = get();
const { currentMailboxId, searchQuery, filters, emails: existing, queryState, emailState, activeAccountId } = state;
const { currentMailboxId, searchQuery, filters, emails: existing, emailState, activeAccountId } = state;
if (!currentMailboxId) return;
if (!jmapClientServesActiveAccount(activeAccountId)) return;
set({ loading: true, error: null });
@@ -651,13 +662,37 @@ export const useEmailStore = create<EmailState>()(
const limit = useSettingsStore.getState().emailsPerPage;
const baseView = isBaseView(searchQuery, filters);
// A response that lands after the user switched account/mailbox or
// changed search/filters must not overwrite the newer view.
const viewChanged = () =>
get().activeAccountId !== activeAccountId ||
get().currentMailboxId !== currentMailboxId ||
get().searchQuery !== searchQuery ||
get().filters !== filters;
// The incremental path diffs against the *base-view* list, which lives in
// the per-mailbox snapshot — NOT `emails`, which may still hold search or
// filter results (right after clearing a search, or after a cold start
// that rehydrated a persisted search-result list). Diffing against a
// non-base list lets Email/queryChanges "confirm" the search results as
// the whole mailbox and bakes them into the snapshot (issue #10). The
// snapshot is only trusted when its window is plausibly complete —
// anything shorter can't be patched incrementally and needs the full
// re-query below to rebuild it.
const snap = state.mailboxSnapshots[currentMailboxId];
try {
// Incremental sync path: requires the base unfiltered view AND a known
// queryState (so Email/queryChanges has something to diff against).
// Anything else — search, filter active, first-ever load — falls
// through to a full re-query.
if (baseView && queryState) {
const queryChanges = await getEmailQueryChanges(currentMailboxId, queryState, {
if (
baseView &&
snap?.queryState &&
snap.emails.length >= Math.min(limit, snap.total)
) {
const baseEmails = snap.emails;
const queryChanges = await getEmailQueryChanges(currentMailboxId, snap.queryState, {
filter: undefined,
});
if (queryChanges) {
@@ -687,9 +722,9 @@ export const useEmailStore = create<EmailState>()(
}
// Fetch only what we don't already have. `addedIds` are new to the
// window; `updatedIds` may already be in `existing` but their
// window; `updatedIds` may already be in the base list but their
// keywords/mailboxIds need refreshing.
const existingById = new Map(existing.map((e) => [e.id, e]));
const existingById = new Map(baseEmails.map((e) => [e.id, e]));
const idsToFetch = [
...addedIds.filter((id) => !existingById.has(id)),
...updatedIds.filter((id) => existingById.has(id)),
@@ -705,7 +740,7 @@ export const useEmailStore = create<EmailState>()(
// Rebuild the visible window order: start with existing emails,
// drop removed/destroyed, then splice added at their indices.
const allDestroyed = new Set([...destroyedExtra, ...removed]);
const kept = existing.filter((e) => !allDestroyed.has(e.id));
const kept = baseEmails.filter((e) => !allDestroyed.has(e.id));
// Map updated entries onto kept array
const fetchedById = new Map(fetched.map((e) => [e.id, e]));
const updatedKept = kept.map((e) => fetchedById.get(e.id) ?? e);
@@ -727,7 +762,7 @@ export const useEmailStore = create<EmailState>()(
const nextQueryState = queryChanges.newQueryState;
const nextTotal = queryChanges.total;
if (get().activeAccountId !== activeAccountId || get().currentMailboxId !== currentMailboxId) return;
if (viewChanged()) return;
set({
emails: trimmed,
@@ -758,7 +793,7 @@ export const useEmailStore = create<EmailState>()(
? await getEmailsWithState(queryRes.ids)
: { list: [], state: undefined as string | undefined };
if (get().currentMailboxId !== currentMailboxId) return;
if (viewChanged()) return;
const updates: Partial<EmailState> = {
emails: fetched.list,
@@ -836,19 +871,31 @@ export const useEmailStore = create<EmailState>()(
},
setSearchQuery: (query) => {
set({ searchQuery: query });
const state = get();
const backToBase =
!isBaseView(state.searchQuery, state.filters) && isBaseView(query, state.filters);
set({
searchQuery: query,
...(backToBase ? restoredBaseView(state) : {}),
});
void get().refreshEmails();
},
setFilters: (filters) => {
set({ filters });
const state = get();
const backToBase =
!isBaseView(state.searchQuery, state.filters) && isBaseView(state.searchQuery, filters);
set({
filters,
...(backToBase ? restoredBaseView(state) : {}),
});
void get().refreshEmails();
},
clearSearchAndFilters: () => {
const { searchQuery, filters } = get();
if (!searchQuery && Object.keys(filters).length === 0) return;
set({ searchQuery: '', filters: {} });
const state = get();
if (!state.searchQuery && Object.keys(state.filters).length === 0) return;
set({ searchQuery: '', filters: {}, ...restoredBaseView(state) });
void get().refreshEmails();
},
@@ -1330,6 +1377,42 @@ export const useEmailStore = create<EmailState>()(
// refresh once the session is ready.
name: 'email-cache',
storage: createJSONStorage(() => AsyncStorage),
version: 1,
// v0 → v1: drop every cached queryState. Pre-v1 builds could persist a
// search-result list next to the base view's queryState, and the
// incremental sync path would then "confirm" those search results as
// the whole mailbox and bake them into the snapshot (issue #10).
// Without a queryState the next refresh does a full re-query, which
// rebuilds any poisoned window from the server.
migrate: (persisted, version) => {
if (version >= 1) return persisted as EmailState;
const s = persisted as Pick<
EmailState,
'accountSnapshots' | 'mailboxSnapshots' | 'queryState'
> & Record<string, unknown>;
const stripQueryStates = (
snaps: Record<string, MailboxSnapshot> | undefined,
): Record<string, MailboxSnapshot> =>
Object.fromEntries(
Object.entries(snaps ?? {}).map(([id, snap]) => [
id,
{ ...snap, queryState: undefined },
]),
);
const accountSnapshots: Record<string, AccountSnapshot> = {};
for (const [id, acc] of Object.entries(s.accountSnapshots ?? {})) {
accountSnapshots[id] = {
...acc,
mailboxSnapshots: stripQueryStates(acc.mailboxSnapshots),
};
}
return {
...s,
accountSnapshots,
mailboxSnapshots: stripQueryStates(s.mailboxSnapshots),
queryState: undefined,
} as unknown as EmailState;
},
partialize: (state) => ({
accountSnapshots: state.accountSnapshots,
activeAccountId: state.activeAccountId,