fix: stop mark-read, star, pin and tag from erasing other keywords

Every keyword change sent the message's whole `keywords` map, which the
server takes as a replacement. Opening a message that wasn't in the
loaded list sent `{"$seen": true}` and wiped its star and tags; starring
a listed message sent the list's stale map and reverted keywords another
client had changed since the list loaded.

Keyword changes now go out as `keywords/<name>` patch pointers (`true`
to set, `null` to clear) everywhere:
- mark read/unread, star, pin and tagging in the email store
- spam / not spam, trash-and-read, and the spam undo, which now puts
  back only the keywords the action touched
- star, tag and unread from the message viewer
- the offline outbox, whose keyword ops carry a patch and merge per
  keyword when coalesced

Ops queued by earlier builds still hold a whole map. They replay as a
patch that only sets the keywords in it (clearing the other half of
`$junk`/`$notjunk`), so an unread or unstar queued before the update is
lost but nothing gets erased. The list and the offline cache apply the
same patch locally instead of overwriting their keyword maps.
This commit is contained in:
Linus Rath
2026-09-24 18:44:28 +02:00
parent 859770b814
commit 50418971f4
11 changed files with 455 additions and 172 deletions
+34 -7
View File
@@ -21,7 +21,8 @@ import {
getEmails, getEmails,
getFullEmail, getFullEmail,
getThread, getThread,
setEmailKeywords, patchKeywordsForEmails,
patchKeywordsPerEmail,
moveEmail, moveEmail,
deleteEmail, deleteEmail,
searchEmails, searchEmails,
@@ -257,14 +258,40 @@ describe('email operations', () => {
}); });
}); });
describe('setEmailKeywords', () => { describe('keyword patches', () => {
it('should update email keywords', async () => { // A whole `keywords` map on update replaces it and erased stars and tags
mockRequest.mockResolvedValue({ methodResponses: [['Email/set', { updated: {} }, '0']] }); // the local copy didn't have; only per-keyword pointers may go out.
it('patches only the named keywords, clearing with null', async () => {
mockRequest.mockResolvedValue({ methodResponses: [['Email/set', { updated: { e1: null, e2: null } }, '0']] });
await setEmailKeywords('e1', { $seen: true, $flagged: true }); await patchKeywordsForEmails(['e1', 'e2'], { $seen: true, $flagged: false, '$label:work/clients': null }, 'grp-1');
const call = mockRequest.mock.calls[0][0][0]; expect(mockRequest).toHaveBeenCalledTimes(1);
expect(call[1].update).toEqual({ e1: { keywords: { $seen: true, $flagged: true } } }); expect(mockRequest.mock.calls[0][0]).toEqual([['Email/set', {
accountId: 'grp-1',
update: {
e1: { 'keywords/$seen': true, 'keywords/$flagged': null, 'keywords/$label:work~1clients': null },
e2: { 'keywords/$seen': true, 'keywords/$flagged': null, 'keywords/$label:work~1clients': null },
},
}, '0']]);
});
it('applies a separate patch to each message in one Email/set', async () => {
mockRequest.mockResolvedValue({ methodResponses: [['Email/set', { updated: { e1: null, e2: null } }, '0']] });
await patchKeywordsPerEmail([
{ id: 'e1', patch: { $junk: null, $notjunk: true } },
{ id: 'e2', patch: { $junk: null, $notjunk: null } },
]);
expect(mockRequest).toHaveBeenCalledTimes(1);
expect(mockRequest.mock.calls[0][0]).toEqual([['Email/set', {
accountId: 'acc-1',
update: {
e1: { 'keywords/$junk': null, 'keywords/$notjunk': true },
e2: { 'keywords/$junk': null, 'keywords/$notjunk': null },
},
}, '0']]);
}); });
}); });
+17 -16
View File
@@ -599,13 +599,16 @@ export async function getThreadEmails(threadId: string, accountIdOverride?: stri
return emails.sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)); return emails.sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0));
} }
export async function setEmailKeywords( // One `keywords/<name>` pointer per changed keyword. Never send the whole
emailId: string, // `keywords` map on update: the server replaces it, erasing every keyword the
keywords: Record<string, boolean>, // local copy didn't have (a star or tag set elsewhere, or all of them on a
accountIdOverride?: string, // message that was never loaded).
): Promise<void> { function keywordPointers(patch: Record<string, boolean | null>): Record<string, unknown> {
const accountId = accountIdOverride ?? jmapClient.accountId; const pointers: Record<string, unknown> = {};
await emailSetBatched(accountId, { update: { [emailId]: { keywords } } }); for (const [keyword, value] of Object.entries(patch)) {
pointers[keywordPointer(keyword)] = value === false ? null : value;
}
return pointers;
} }
/** Patch individual keywords (`{ $seen: true, $junk: null }`) on many messages. */ /** Patch individual keywords (`{ $seen: true, $junk: null }`) on many messages. */
@@ -616,10 +619,7 @@ export async function patchKeywordsForEmails(
): Promise<void> { ): Promise<void> {
if (ids.length === 0) return; if (ids.length === 0) return;
const accountId = accountIdOverride ?? jmapClient.accountId; const accountId = accountIdOverride ?? jmapClient.accountId;
const pointerPatch: Record<string, unknown> = {}; const pointerPatch = keywordPointers(patch);
for (const [keyword, value] of Object.entries(patch)) {
pointerPatch[keywordPointer(keyword)] = value === false ? null : value;
}
const update: Record<string, Record<string, unknown>> = {}; const update: Record<string, Record<string, unknown>> = {};
for (const id of ids) update[id] = { ...pointerPatch }; for (const id of ids) update[id] = { ...pointerPatch };
await emailSetBatched(accountId, { update }); await emailSetBatched(accountId, { update });
@@ -713,15 +713,16 @@ export async function deleteEmails(
} }
} }
// Apply keyword maps to several emails in one round-trip (chunked). // Apply a different keyword patch to each of several emails in one
export async function setKeywordsForEmails( // round-trip (chunked). Undo uses it to put back each message's own keywords.
updates: Array<{ id: string; keywords: Record<string, boolean> }>, export async function patchKeywordsPerEmail(
updates: Array<{ id: string; patch: Record<string, boolean | null> }>,
accountIdOverride?: string, accountIdOverride?: string,
): Promise<void> { ): Promise<void> {
if (updates.length === 0) return; if (updates.length === 0) return;
const accountId = accountIdOverride ?? jmapClient.accountId; const accountId = accountIdOverride ?? jmapClient.accountId;
const update: Record<string, { keywords: Record<string, boolean> }> = {}; const update: Record<string, Record<string, unknown>> = {};
for (const u of updates) update[u.id] = { keywords: u.keywords }; for (const u of updates) update[u.id] = keywordPointers(u.patch);
await emailSetBatched(accountId, { update }); await emailSetBatched(accountId, { update });
} }
+26
View File
@@ -0,0 +1,26 @@
import { describe, it, expect } from 'vitest';
import { applyKeywordPatch, revertKeywordPatch } from '../keyword-patch';
describe('applyKeywordPatch', () => {
it('sets and clears only the keywords the patch names', () => {
const before = { $seen: true, $flagged: true, '$label:work': true };
expect(applyKeywordPatch(before, { $seen: null, $pinned: true, $flagged: false })).toEqual({
'$label:work': true,
$pinned: true,
});
expect(before).toEqual({ $seen: true, $flagged: true, '$label:work': true });
});
it('starts from nothing when the message has no keywords yet', () => {
expect(applyKeywordPatch(undefined, { $seen: true })).toEqual({ $seen: true });
});
});
describe('revertKeywordPatch', () => {
it('restores each touched keyword to its original value and nothing else', () => {
expect(revertKeywordPatch(
{ $junk: true, $notjunk: null, $seen: true },
{ $notjunk: true, $flagged: true },
)).toEqual({ $junk: null, $notjunk: true, $seen: null });
});
});
+39
View File
@@ -0,0 +1,39 @@
/**
* Keyword changes as an `Email/set` update sends them: one
* `keywords/<name>` pointer per keyword, `true` to set it and `null` (or
* `false`) to clear it. Keywords the patch does not name are left alone.
*
* Sending the whole `keywords` map instead replaces it on the server, which
* erases whatever the local copy lacked: a star or tag another client set
* since the list loaded, or everything on a message that was never loaded.
*/
export type KeywordPatch = Record<string, boolean | null>;
/** `keywords` with `patch` applied: the local mirror of the server update. */
export function applyKeywordPatch(
keywords: Record<string, boolean> | undefined,
patch: KeywordPatch,
): Record<string, boolean> {
const next = { ...keywords };
for (const [keyword, value] of Object.entries(patch)) {
if (value) next[keyword] = true;
else delete next[keyword];
}
return next;
}
/**
* The patch that undoes `patch` on a message whose keywords were `original`
* before it: each keyword the patch touched goes back to what it was, and
* nothing else changes.
*/
export function revertKeywordPatch(
patch: KeywordPatch,
original: Record<string, boolean> | undefined,
): KeywordPatch {
const revert: KeywordPatch = {};
for (const keyword of Object.keys(patch)) {
revert[keyword] = original?.[keyword] ? true : null;
}
return revert;
}
+4 -4
View File
@@ -26,7 +26,7 @@ import {
REPLY_QUICK_ACTIONS, REPLY_QUICK_ACTIONS,
type QuickAction, type QuickAction,
} from '../stores/settings-store'; } from '../stores/settings-store';
import { setEmailKeywords, getThreadEmails } from '../api/email'; import { patchKeywordsForEmails, getThreadEmails } from '../api/email';
import { shareEmailEml } from '../lib/email-export'; import { shareEmailEml } from '../lib/email-export';
import { useKeywordsStore, keywordToken, type KeywordDef } from '../stores/keywords-store'; import { useKeywordsStore, keywordToken, type KeywordDef } from '../stores/keywords-store';
import { useSheetDrag } from '../lib/use-sheet-drag'; import { useSheetDrag } from '../lib/use-sheet-drag';
@@ -319,7 +319,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
if (next[token]) delete next[token]; if (next[token]) delete next[token];
else next[token] = true; else next[token] = true;
updateLocalKeywords(email.id, next); updateLocalKeywords(email.id, next);
void setEmailKeywords(email.id, next, ownerAccountId); void patchKeywordsForEmails([email.id], { [token]: next[token] ?? null }, ownerAccountId);
}; };
// Toggle the star on a specific message — used both by the toolbar (current // Toggle the star on a specific message — used both by the toolbar (current
@@ -329,7 +329,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
if (next.$flagged) delete next.$flagged; if (next.$flagged) delete next.$flagged;
else next.$flagged = true; else next.$flagged = true;
updateLocalKeywords(target.id, next); updateLocalKeywords(target.id, next);
void setEmailKeywords(target.id, next, ownerAccountId); void patchKeywordsForEmails([target.id], { $flagged: next.$flagged ?? null }, ownerAccountId);
}, [updateLocalKeywords, ownerAccountId]); }, [updateLocalKeywords, ownerAccountId]);
const onToggleStar = () => { if (email) toggleStarFor(email); }; const onToggleStar = () => { if (email) toggleStarFor(email); };
@@ -343,7 +343,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
const next = { ...email.keywords }; const next = { ...email.keywords };
delete next.$seen; delete next.$seen;
updateLocalKeywords(email.id, next); updateLocalKeywords(email.id, next);
void setEmailKeywords(email.id, next, ownerAccountId); void patchKeywordsForEmails([email.id], { $seen: null }, ownerAccountId);
} }
}; };
+96 -16
View File
@@ -15,8 +15,8 @@ vi.mock('../../api/email', () => ({
getEmailChanges: vi.fn(async () => null), getEmailChanges: vi.fn(async () => null),
getFullEmail: vi.fn(), getFullEmail: vi.fn(),
importEmailBlob: vi.fn(async () => 'imported-1'), importEmailBlob: vi.fn(async () => 'imported-1'),
setEmailKeywords: vi.fn(), patchKeywordsForEmails: vi.fn(),
setKeywordsForEmails: vi.fn(), patchKeywordsPerEmail: vi.fn(),
moveEmail: vi.fn(), moveEmail: vi.fn(),
moveEmails: vi.fn(), moveEmails: vi.fn(),
archiveEmails: vi.fn(), archiveEmails: vi.fn(),
@@ -49,8 +49,8 @@ vi.mock('../locale-store', () => ({
// without pulling in network-store / NetInfo. // without pulling in network-store / NetInfo.
vi.mock('../outbox-store', async () => { vi.mock('../outbox-store', async () => {
const api = await import('../../api/email') as unknown as Record<string, (...args: unknown[]) => Promise<unknown>>; const api = await import('../../api/email') as unknown as Record<string, (...args: unknown[]) => Promise<unknown>>;
const runOp = async (op: { kind: string; emailId: string; accountId?: string; keywords?: unknown; mailboxIds?: unknown }) => { const runOp = async (op: { kind: string; emailId: string; accountId?: string; patch?: unknown; mailboxIds?: unknown }) => {
if (op.kind === 'keywords') return api.setEmailKeywords(op.emailId, op.keywords, op.accountId); if (op.kind === 'keywords') return api.patchKeywordsForEmails([op.emailId], op.patch, op.accountId);
if (op.kind === 'mailboxes') return api.setEmailMailboxes(op.emailId, op.mailboxIds, op.accountId); if (op.kind === 'mailboxes') return api.setEmailMailboxes(op.emailId, op.mailboxIds, op.accountId);
if (op.kind === 'destroy') return api.destroyEmails([op.emailId], op.accountId); if (op.kind === 'destroy') return api.destroyEmails([op.emailId], op.accountId);
}; };
@@ -155,7 +155,7 @@ const mockGetEmailQueryChanges = emailApi.getEmailQueryChanges as ReturnType<typ
const mockGetEmails = emailApi.getEmails as ReturnType<typeof vi.fn>; const mockGetEmails = emailApi.getEmails as ReturnType<typeof vi.fn>;
const mockGetEmailsWithState = emailApi.getEmailsWithState as ReturnType<typeof vi.fn>; const mockGetEmailsWithState = emailApi.getEmailsWithState as ReturnType<typeof vi.fn>;
const mockGetFullEmail = emailApi.getFullEmail as ReturnType<typeof vi.fn>; const mockGetFullEmail = emailApi.getFullEmail as ReturnType<typeof vi.fn>;
const mockSetKeywords = emailApi.setEmailKeywords as ReturnType<typeof vi.fn>; const mockPatchKeywords = emailApi.patchKeywordsForEmails as ReturnType<typeof vi.fn>;
const mockMoveEmail = emailApi.moveEmail as ReturnType<typeof vi.fn>; const mockMoveEmail = emailApi.moveEmail as ReturnType<typeof vi.fn>;
const mockDeleteEmail = emailApi.deleteEmail as ReturnType<typeof vi.fn>; const mockDeleteEmail = emailApi.deleteEmail as ReturnType<typeof vi.fn>;
const mockSearchEmails = emailApi.searchEmails as ReturnType<typeof vi.fn>; const mockSearchEmails = emailApi.searchEmails as ReturnType<typeof vi.fn>;
@@ -261,11 +261,11 @@ describe('email-store', () => {
useEmailStore.setState({ useEmailStore.setState({
emails: [{ id: 'e1', keywords: {} } as any], emails: [{ id: 'e1', keywords: {} } as any],
}); });
mockSetKeywords.mockResolvedValue(undefined); mockPatchKeywords.mockResolvedValue(undefined);
await useEmailStore.getState().markRead('e1'); await useEmailStore.getState().markRead('e1');
expect(mockSetKeywords).toHaveBeenCalledWith('e1', { $seen: true }, undefined); expect(mockPatchKeywords).toHaveBeenCalledWith(['e1'], { $seen: true }, undefined);
expect(useEmailStore.getState().emails[0].keywords.$seen).toBe(true); expect(useEmailStore.getState().emails[0].keywords.$seen).toBe(true);
}); });
}); });
@@ -275,12 +275,12 @@ describe('email-store', () => {
useEmailStore.setState({ useEmailStore.setState({
emails: [{ id: 'e1', keywords: { $seen: true, $flagged: true } } as any], emails: [{ id: 'e1', keywords: { $seen: true, $flagged: true } } as any],
}); });
mockSetKeywords.mockResolvedValue(undefined); mockPatchKeywords.mockResolvedValue(undefined);
await useEmailStore.getState().markUnread('e1'); await useEmailStore.getState().markUnread('e1');
expect(mockSetKeywords).toHaveBeenCalledWith('e1', { $flagged: true }, undefined); expect(mockPatchKeywords).toHaveBeenCalledWith(['e1'], { $seen: null }, undefined);
expect(useEmailStore.getState().emails[0].keywords.$seen).toBeUndefined(); expect(useEmailStore.getState().emails[0].keywords).toEqual({ $flagged: true });
}); });
}); });
@@ -289,10 +289,11 @@ describe('email-store', () => {
useEmailStore.setState({ useEmailStore.setState({
emails: [{ id: 'e1', keywords: { $seen: true } } as any], emails: [{ id: 'e1', keywords: { $seen: true } } as any],
}); });
mockSetKeywords.mockResolvedValue(undefined); mockPatchKeywords.mockResolvedValue(undefined);
await useEmailStore.getState().toggleStar('e1', true); await useEmailStore.getState().toggleStar('e1', true);
expect(mockPatchKeywords).toHaveBeenCalledWith(['e1'], { $flagged: true }, undefined);
expect(useEmailStore.getState().emails[0].keywords.$flagged).toBe(true); expect(useEmailStore.getState().emails[0].keywords.$flagged).toBe(true);
}); });
@@ -300,10 +301,11 @@ describe('email-store', () => {
useEmailStore.setState({ useEmailStore.setState({
emails: [{ id: 'e1', keywords: { $seen: true, $flagged: true } } as any], emails: [{ id: 'e1', keywords: { $seen: true, $flagged: true } } as any],
}); });
mockSetKeywords.mockResolvedValue(undefined); mockPatchKeywords.mockResolvedValue(undefined);
await useEmailStore.getState().toggleStar('e1', false); await useEmailStore.getState().toggleStar('e1', false);
expect(mockPatchKeywords).toHaveBeenCalledWith(['e1'], { $flagged: null }, undefined);
expect(useEmailStore.getState().emails[0].keywords.$flagged).toBeUndefined(); expect(useEmailStore.getState().emails[0].keywords.$flagged).toBeUndefined();
}); });
}); });
@@ -736,13 +738,86 @@ describe('email-store', () => {
}); });
}); });
// A keyword change must reach the server as `keywords/<name>` pointers:
// sending the list's keyword map replaced the server's and erased stars and
// tags the list didn't know about (audit B4).
describe('keyword changes send only the keywords they change', () => {
it('marks a message outside the loaded list read without touching its other keywords', async () => {
useEmailStore.setState({ emails: [] });
await useEmailStore.getState().markRead('e9', 'grp-1');
await useEmailStore.getState().markRead('e8');
expect(mockPatchKeywords).toHaveBeenNthCalledWith(1, ['e9'], { $seen: true }, 'grp-1');
expect(mockPatchKeywords).toHaveBeenNthCalledWith(2, ['e8'], { $seen: true }, undefined);
});
it('stars from a stale list row by sending $flagged alone, keeping keywords that changed meanwhile', async () => {
useEmailStore.setState({ emails: [{ id: 'e1', keywords: { $notjunk: true, $seen: true } } as any] });
// The list refreshes while the request is in flight and picks up a tag
// another client added.
mockPatchKeywords.mockImplementationOnce(async () => {
useEmailStore.setState({ emails: [{ id: 'e1', keywords: { $notjunk: true, $seen: true, '$label:work': true } } as any] });
});
await useEmailStore.getState().toggleStar('e1', true);
expect(mockPatchKeywords).toHaveBeenCalledTimes(1);
expect(mockPatchKeywords).toHaveBeenCalledWith(['e1'], { $flagged: true }, undefined);
expect(useEmailStore.getState().emails[0].keywords).toEqual({
$notjunk: true, $seen: true, '$label:work': true, $flagged: true,
});
});
it('tags and untags a selection with one patch naming only that tag', async () => {
useEmailStore.setState({
emails: [
{ id: 'e1', keywords: { $seen: true } } as any,
{ id: 'e2', keywords: { $flagged: true, '$label:work': true } } as any,
],
});
await useEmailStore.getState().setKeywordForEmails(['e1', 'e2'], '$label:work', true);
expect(mockPatchKeywords).toHaveBeenLastCalledWith(['e1', 'e2'], { '$label:work': true }, undefined);
expect(useEmailStore.getState().emails.map((e) => e.keywords)).toEqual([
{ $seen: true, '$label:work': true },
{ $flagged: true, '$label:work': true },
]);
await useEmailStore.getState().setKeywordForEmails(['e2'], '$label:work', false);
expect(mockPatchKeywords).toHaveBeenLastCalledWith(['e2'], { '$label:work': null }, undefined);
expect(useEmailStore.getState().emails[1].keywords).toEqual({ $flagged: true });
});
it('batch delete with trash-and-read marks only the unread messages read with a $seen patch', async () => {
useSettingsStore.getState().updateSetting('deleteAction', 'trash-and-read');
useEmailStore.setState({
mailboxes: [
{ id: 'mb-1', name: 'Inbox', role: 'inbox', isShared: false } as any,
{ id: 'mb-trash', name: 'Trash', role: 'trash', isShared: false } as any,
],
currentMailboxId: 'mb-1',
emails: [
{ id: 'e1', keywords: { $flagged: true }, mailboxIds: { 'mb-1': true } } as any,
{ id: 'e2', keywords: { $seen: true }, mailboxIds: { 'mb-1': true } } as any,
],
});
await useEmailStore.getState().deleteEmailsBatch(['e1', 'e2'], 'mb-trash', 'mb-1');
expect(mockPatchKeywords).toHaveBeenCalledTimes(1);
expect(mockPatchKeywords).toHaveBeenCalledWith(['e1'], { $seen: true }, undefined);
useSettingsStore.getState().updateSetting('deleteAction', 'trash');
});
});
describe('pin and spam keywords', () => { describe('pin and spam keywords', () => {
it('togglePin writes $pinned, not $important', async () => { it('togglePin writes $pinned, not $important', async () => {
useEmailStore.setState({ emails: [{ id: 'e1', keywords: {} } as any] }); useEmailStore.setState({ emails: [{ id: 'e1', keywords: {} } as any] });
await useEmailStore.getState().togglePin('e1', true); await useEmailStore.getState().togglePin('e1', true);
expect(mockSetKeywords).toHaveBeenCalledWith('e1', { $pinned: true }, undefined); expect(mockPatchKeywords).toHaveBeenCalledWith(['e1'], { $pinned: true }, undefined);
await useEmailStore.getState().togglePin('e1', false); await useEmailStore.getState().togglePin('e1', false);
expect(mockSetKeywords).toHaveBeenLastCalledWith('e1', {}, undefined); expect(mockPatchKeywords).toHaveBeenLastCalledWith(['e1'], { $pinned: null }, undefined);
}); });
const RIGHTS = {} as any; const RIGHTS = {} as any;
@@ -752,7 +827,7 @@ describe('email-store', () => {
it('markSpam files into Junk flipping $junk/$notjunk and offers an undo that restores keywords', async () => { it('markSpam files into Junk flipping $junk/$notjunk and offers an undo that restores keywords', async () => {
const mockMarkAsSpam = emailApi.markAsSpam as ReturnType<typeof vi.fn>; const mockMarkAsSpam = emailApi.markAsSpam as ReturnType<typeof vi.fn>;
const mockRestore = emailApi.restoreEmailMailboxes as ReturnType<typeof vi.fn>; const mockRestore = emailApi.restoreEmailMailboxes as ReturnType<typeof vi.fn>;
const mockSetKeywordsForEmails = emailApi.setKeywordsForEmails as ReturnType<typeof vi.fn>; const mockPatchPerEmail = emailApi.patchKeywordsPerEmail as ReturnType<typeof vi.fn>;
useSettingsStore.getState().updateSetting('deleteAction', 'trash-and-read'); useSettingsStore.getState().updateSetting('deleteAction', 'trash-and-read');
useEmailStore.setState({ useEmailStore.setState({
mailboxes: [inbox, junk], mailboxes: [inbox, junk],
@@ -770,7 +845,12 @@ describe('email-store', () => {
await useEmailStore.getState().undoLast(); await useEmailStore.getState().undoLast();
expect(mockRestore).toHaveBeenCalledWith([{ id: 'e1', mailboxIds: { 'mb-1': true } }], undefined); expect(mockRestore).toHaveBeenCalledWith([{ id: 'e1', mailboxIds: { 'mb-1': true } }], undefined);
expect(mockSetKeywordsForEmails).toHaveBeenCalledWith([{ id: 'e1', keywords: { $notjunk: true } }], undefined); // Only the keywords the spam action touched go back; `$seen` was set by
// trash-and-read on a message that was unread before.
expect(mockPatchPerEmail).toHaveBeenCalledWith(
[{ id: 'e1', patch: { $junk: null, $notjunk: true, $seen: null } }],
undefined,
);
expect(useEmailStore.getState().emails.map((e) => e.id)).toEqual(['e1']); expect(useEmailStore.getState().emails.map((e) => e.id)).toEqual(['e1']);
useSettingsStore.getState().updateSetting('deleteAction', 'trash'); useSettingsStore.getState().updateSetting('deleteAction', 'trash');
}); });
@@ -0,0 +1,40 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { useOfflineCacheStore } from '../offline-cache-store';
const ACCOUNT = 'acc-1';
beforeEach(async () => {
await useOfflineCacheStore.getState().setAccount(null);
await useOfflineCacheStore.getState().setAccount(ACCOUNT);
await useOfflineCacheStore.getState().clearAll();
});
describe('offline cache patch', () => {
it('applies a keyword patch to the cached copy instead of replacing its keywords', async () => {
const cache = useOfflineCacheStore.getState();
await cache.put({
id: 'e1',
receivedAt: '2026-01-01T00:00:00Z',
keywords: { $flagged: true, '$label:work': true },
mailboxIds: { inbox: true },
} as any, 100);
await useOfflineCacheStore.getState().patch('e1', { keywords: { $seen: true, $flagged: null } });
const cached = await useOfflineCacheStore.getState().get('e1');
expect(cached?.keywords).toEqual({ '$label:work': true, $seen: true });
expect(cached?.mailboxIds).toEqual({ inbox: true });
});
it('replaces mailboxIds wholesale', async () => {
await useOfflineCacheStore.getState().put({
id: 'e1', receivedAt: '2026-01-01T00:00:00Z', keywords: { $seen: true }, mailboxIds: { inbox: true },
} as any, 100);
await useOfflineCacheStore.getState().patch('e1', { mailboxIds: { trash: true } });
const cached = await useOfflineCacheStore.getState().get('e1');
expect(cached?.mailboxIds).toEqual({ trash: true });
expect(cached?.keywords).toEqual({ $seen: true });
});
});
+80 -34
View File
@@ -13,15 +13,16 @@ vi.mock('../../api/jmap-client', () => ({
jmapClient: { isConnected: true }, jmapClient: { isConnected: true },
})); }));
const setEmailKeywords = vi.fn(async (..._a: any[]) => undefined); const patchKeywordsForEmails = vi.fn(async (..._a: any[]) => undefined);
const setEmailMailboxes = vi.fn(async (..._a: any[]) => undefined); const setEmailMailboxes = vi.fn(async (..._a: any[]) => undefined);
const destroyEmails = vi.fn(async (..._a: any[]) => undefined); const destroyEmails = vi.fn(async (..._a: any[]) => undefined);
vi.mock('../../api/email', () => ({ vi.mock('../../api/email', () => ({
setEmailKeywords: (...a: any[]) => setEmailKeywords(...a), patchKeywordsForEmails: (...a: any[]) => patchKeywordsForEmails(...a),
setEmailMailboxes: (...a: any[]) => setEmailMailboxes(...a), setEmailMailboxes: (...a: any[]) => setEmailMailboxes(...a),
destroyEmails: (...a: any[]) => destroyEmails(...a), destroyEmails: (...a: any[]) => destroyEmails(...a),
})); }));
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useOutboxStore, applyOrQueue } from '../outbox-store'; import { useOutboxStore, applyOrQueue } from '../outbox-store';
import { useNetworkStore } from '../network-store'; import { useNetworkStore } from '../network-store';
@@ -38,26 +39,27 @@ beforeEach(async () => {
}); });
describe('outbox enqueue + coalescing', () => { describe('outbox enqueue + coalescing', () => {
it('coalesces repeated keyword ops for the same email (last-write-wins)', () => { it('merges repeated keyword patches for the same email (last write wins per keyword)', () => {
const store = useOutboxStore.getState(); const store = useOutboxStore.getState();
store.enqueue({ kind: 'keywords', emailId: 'e1', keywords: { $seen: true } }); store.enqueue({ kind: 'keywords', emailId: 'e1', patch: { $seen: true } });
store.enqueue({ kind: 'keywords', emailId: 'e1', keywords: { $seen: true, $flagged: true } }); store.enqueue({ kind: 'keywords', emailId: 'e1', patch: { $flagged: true } });
store.enqueue({ kind: 'keywords', emailId: 'e1', patch: { $seen: null } });
const entries = useOutboxStore.getState().entries; const entries = useOutboxStore.getState().entries;
expect(entries).toHaveLength(1); expect(entries).toHaveLength(1);
expect(entries[0].op).toEqual({ kind: 'keywords', emailId: 'e1', keywords: { $seen: true, $flagged: true } }); expect(entries[0].op).toEqual({ kind: 'keywords', emailId: 'e1', patch: { $seen: null, $flagged: true } });
}); });
it('keeps keyword and mailbox ops for the same email separate', () => { it('keeps keyword and mailbox ops for the same email separate', () => {
const store = useOutboxStore.getState(); const store = useOutboxStore.getState();
store.enqueue({ kind: 'keywords', emailId: 'e1', keywords: { $seen: true } }); store.enqueue({ kind: 'keywords', emailId: 'e1', patch: { $seen: true } });
store.enqueue({ kind: 'mailboxes', emailId: 'e1', mailboxIds: { archive: true } }); store.enqueue({ kind: 'mailboxes', emailId: 'e1', mailboxIds: { archive: true } });
expect(useOutboxStore.getState().entries).toHaveLength(2); expect(useOutboxStore.getState().entries).toHaveLength(2);
}); });
it('destroy supersedes pending edits for that email', () => { it('destroy supersedes pending edits for that email', () => {
const store = useOutboxStore.getState(); const store = useOutboxStore.getState();
store.enqueue({ kind: 'keywords', emailId: 'e1', keywords: { $seen: true } }); store.enqueue({ kind: 'keywords', emailId: 'e1', patch: { $seen: true } });
store.enqueue({ kind: 'mailboxes', emailId: 'e1', mailboxIds: { archive: true } }); store.enqueue({ kind: 'mailboxes', emailId: 'e1', mailboxIds: { archive: true } });
store.enqueue({ kind: 'destroy', emailId: 'e1' }); store.enqueue({ kind: 'destroy', emailId: 'e1' });
@@ -69,7 +71,7 @@ describe('outbox enqueue + coalescing', () => {
it('ignores further edits once a destroy is queued', () => { it('ignores further edits once a destroy is queued', () => {
const store = useOutboxStore.getState(); const store = useOutboxStore.getState();
store.enqueue({ kind: 'destroy', emailId: 'e1' }); store.enqueue({ kind: 'destroy', emailId: 'e1' });
store.enqueue({ kind: 'keywords', emailId: 'e1', keywords: { $seen: true } }); store.enqueue({ kind: 'keywords', emailId: 'e1', patch: { $seen: true } });
expect(useOutboxStore.getState().entries).toHaveLength(1); expect(useOutboxStore.getState().entries).toHaveLength(1);
expect(useOutboxStore.getState().entries[0].op.kind).toBe('destroy'); expect(useOutboxStore.getState().entries[0].op.kind).toBe('destroy');
}); });
@@ -77,9 +79,9 @@ describe('outbox enqueue + coalescing', () => {
describe('applyOrQueue', () => { describe('applyOrQueue', () => {
it('runs immediately when online with an empty queue', async () => { it('runs immediately when online with an empty queue', async () => {
const result = await applyOrQueue({ kind: 'keywords', emailId: 'e1', keywords: { $seen: true } }); const result = await applyOrQueue({ kind: 'keywords', emailId: 'e1', patch: { $seen: true } });
expect(result.queued).toBe(false); expect(result.queued).toBe(false);
expect(setEmailKeywords).toHaveBeenCalledWith('e1', { $seen: true }, undefined); expect(patchKeywordsForEmails).toHaveBeenCalledWith(['e1'], { $seen: true }, undefined);
expect(useOutboxStore.getState().entries).toHaveLength(0); expect(useOutboxStore.getState().entries).toHaveLength(0);
}); });
@@ -92,9 +94,9 @@ describe('applyOrQueue', () => {
it('queues instead of running when offline', async () => { it('queues instead of running when offline', async () => {
useNetworkStore.setState({ online: false }); useNetworkStore.setState({ online: false });
const result = await applyOrQueue({ kind: 'keywords', emailId: 'e1', keywords: { $seen: true } }); const result = await applyOrQueue({ kind: 'keywords', emailId: 'e1', patch: { $seen: true } });
expect(result.queued).toBe(true); expect(result.queued).toBe(true);
expect(setEmailKeywords).not.toHaveBeenCalled(); expect(patchKeywordsForEmails).not.toHaveBeenCalled();
expect(useOutboxStore.getState().entries).toHaveLength(1); expect(useOutboxStore.getState().entries).toHaveLength(1);
}); });
@@ -103,9 +105,9 @@ describe('applyOrQueue', () => {
await applyOrQueue({ kind: 'mailboxes', emailId: 'e1', mailboxIds: { a: true } }); await applyOrQueue({ kind: 'mailboxes', emailId: 'e1', mailboxIds: { a: true } });
useNetworkStore.setState({ online: true }); useNetworkStore.setState({ online: true });
// Now online, but an op is already queued for e1 → must queue, not run. // Now online, but an op is already queued for e1 → must queue, not run.
const result = await applyOrQueue({ kind: 'keywords', emailId: 'e1', keywords: { $seen: true } }); const result = await applyOrQueue({ kind: 'keywords', emailId: 'e1', patch: { $seen: true } });
expect(result.queued).toBe(true); expect(result.queued).toBe(true);
expect(setEmailKeywords).not.toHaveBeenCalled(); expect(patchKeywordsForEmails).not.toHaveBeenCalled();
}); });
it('surfaces a non-transient (server) error to the caller', async () => { it('surfaces a non-transient (server) error to the caller', async () => {
@@ -120,7 +122,7 @@ describe('applyOrQueue', () => {
const err = new Error('Network request failed'); const err = new Error('Network request failed');
err.name = 'NetworkError'; err.name = 'NetworkError';
const onlineRun = vi.fn(async () => { throw err; }); const onlineRun = vi.fn(async () => { throw err; });
const result = await applyOrQueue({ kind: 'keywords', emailId: 'e1', keywords: { $seen: true } }, onlineRun); const result = await applyOrQueue({ kind: 'keywords', emailId: 'e1', patch: { $seen: true } }, onlineRun);
expect(result.queued).toBe(true); expect(result.queued).toBe(true);
expect(useOutboxStore.getState().entries).toHaveLength(1); expect(useOutboxStore.getState().entries).toHaveLength(1);
}); });
@@ -130,14 +132,14 @@ describe('flush', () => {
it('replays queued ops in order and clears them', async () => { it('replays queued ops in order and clears them', async () => {
useNetworkStore.setState({ online: false }); useNetworkStore.setState({ online: false });
const store = useOutboxStore.getState(); const store = useOutboxStore.getState();
store.enqueue({ kind: 'keywords', emailId: 'e1', keywords: { $seen: true } }); store.enqueue({ kind: 'keywords', emailId: 'e1', patch: { $seen: true } });
store.enqueue({ kind: 'mailboxes', emailId: 'e2', mailboxIds: { trash: true } }); store.enqueue({ kind: 'mailboxes', emailId: 'e2', mailboxIds: { trash: true } });
store.enqueue({ kind: 'destroy', emailId: 'e3' }); store.enqueue({ kind: 'destroy', emailId: 'e3' });
useNetworkStore.setState({ online: true }); useNetworkStore.setState({ online: true });
await useOutboxStore.getState().flush(); await useOutboxStore.getState().flush();
expect(setEmailKeywords).toHaveBeenCalledWith('e1', { $seen: true }, undefined); expect(patchKeywordsForEmails).toHaveBeenCalledWith(['e1'], { $seen: true }, undefined);
expect(setEmailMailboxes).toHaveBeenCalledWith('e2', { trash: true }, undefined); expect(setEmailMailboxes).toHaveBeenCalledWith('e2', { trash: true }, undefined);
expect(destroyEmails).toHaveBeenCalledWith(['e3'], undefined); expect(destroyEmails).toHaveBeenCalledWith(['e3'], undefined);
expect(useOutboxStore.getState().entries).toHaveLength(0); expect(useOutboxStore.getState().entries).toHaveLength(0);
@@ -145,20 +147,20 @@ describe('flush', () => {
it('does nothing while offline', async () => { it('does nothing while offline', async () => {
useNetworkStore.setState({ online: false }); useNetworkStore.setState({ online: false });
useOutboxStore.getState().enqueue({ kind: 'keywords', emailId: 'e1', keywords: { $seen: true } }); useOutboxStore.getState().enqueue({ kind: 'keywords', emailId: 'e1', patch: { $seen: true } });
await useOutboxStore.getState().flush(); await useOutboxStore.getState().flush();
expect(setEmailKeywords).not.toHaveBeenCalled(); expect(patchKeywordsForEmails).not.toHaveBeenCalled();
expect(useOutboxStore.getState().entries).toHaveLength(1); expect(useOutboxStore.getState().entries).toHaveLength(1);
}); });
it('stops and retains the op on a transient failure', async () => { it('stops and retains the op on a transient failure', async () => {
useNetworkStore.setState({ online: false }); useNetworkStore.setState({ online: false });
useOutboxStore.getState().enqueue({ kind: 'keywords', emailId: 'e1', keywords: { $seen: true } }); useOutboxStore.getState().enqueue({ kind: 'keywords', emailId: 'e1', patch: { $seen: true } });
useNetworkStore.setState({ online: true }); useNetworkStore.setState({ online: true });
const err = new Error('Network request failed'); const err = new Error('Network request failed');
err.name = 'NetworkError'; err.name = 'NetworkError';
setEmailKeywords.mockRejectedValueOnce(err); patchKeywordsForEmails.mockRejectedValueOnce(err);
await useOutboxStore.getState().flush(); await useOutboxStore.getState().flush();
expect(useOutboxStore.getState().entries).toHaveLength(1); expect(useOutboxStore.getState().entries).toHaveLength(1);
@@ -167,10 +169,10 @@ describe('flush', () => {
it('drops a poison op after repeated server rejections', async () => { it('drops a poison op after repeated server rejections', async () => {
useNetworkStore.setState({ online: false }); useNetworkStore.setState({ online: false });
useOutboxStore.getState().enqueue({ kind: 'keywords', emailId: 'e1', keywords: { $seen: true } }); useOutboxStore.getState().enqueue({ kind: 'keywords', emailId: 'e1', patch: { $seen: true } });
useNetworkStore.setState({ online: true }); useNetworkStore.setState({ online: true });
setEmailKeywords.mockRejectedValue(new Error('JMAP request failed: 400')); patchKeywordsForEmails.mockRejectedValue(new Error('JMAP request failed: 400'));
// MAX_ATTEMPTS is 5; flush bumps one attempt per run. // MAX_ATTEMPTS is 5; flush bumps one attempt per run.
for (let i = 0; i < 5; i++) await useOutboxStore.getState().flush(); for (let i = 0; i < 5; i++) await useOutboxStore.getState().flush();
@@ -181,27 +183,27 @@ describe('flush', () => {
describe('failed ops and archive replay', () => { describe('failed ops and archive replay', () => {
it('parks a poison op in `failed` instead of dropping it, and retryFailed re-queues it', async () => { it('parks a poison op in `failed` instead of dropping it, and retryFailed re-queues it', async () => {
useNetworkStore.setState({ online: false }); useNetworkStore.setState({ online: false });
useOutboxStore.getState().enqueue({ kind: 'keywords', emailId: 'e1', keywords: { $seen: true } }); useOutboxStore.getState().enqueue({ kind: 'keywords', emailId: 'e1', patch: { $seen: true } });
useNetworkStore.setState({ online: true }); useNetworkStore.setState({ online: true });
setEmailKeywords.mockRejectedValue(new Error('JMAP request failed: 400')); patchKeywordsForEmails.mockRejectedValue(new Error('JMAP request failed: 400'));
for (let i = 0; i < 5; i++) await useOutboxStore.getState().flush(); for (let i = 0; i < 5; i++) await useOutboxStore.getState().flush();
expect(useOutboxStore.getState().entries).toHaveLength(0); expect(useOutboxStore.getState().entries).toHaveLength(0);
expect(useOutboxStore.getState().failed).toHaveLength(1); expect(useOutboxStore.getState().failed).toHaveLength(1);
expect(useOutboxStore.getState().failed[0].lastError).toContain('400'); expect(useOutboxStore.getState().failed[0].lastError).toContain('400');
setEmailKeywords.mockResolvedValue(undefined); patchKeywordsForEmails.mockResolvedValue(undefined);
await useOutboxStore.getState().retryFailed(); await useOutboxStore.getState().retryFailed();
expect(useOutboxStore.getState().failed).toHaveLength(0); expect(useOutboxStore.getState().failed).toHaveLength(0);
expect(useOutboxStore.getState().entries).toHaveLength(0); expect(useOutboxStore.getState().entries).toHaveLength(0);
expect(setEmailKeywords).toHaveBeenLastCalledWith('e1', { $seen: true }, undefined); expect(patchKeywordsForEmails).toHaveBeenLastCalledWith(['e1'], { $seen: true }, undefined);
}); });
it('discardFailed forgets parked ops', async () => { it('discardFailed forgets parked ops', async () => {
useNetworkStore.setState({ online: false }); useNetworkStore.setState({ online: false });
useOutboxStore.getState().enqueue({ kind: 'keywords', emailId: 'e1', keywords: { $seen: true } }); useOutboxStore.getState().enqueue({ kind: 'keywords', emailId: 'e1', patch: { $seen: true } });
useNetworkStore.setState({ online: true }); useNetworkStore.setState({ online: true });
setEmailKeywords.mockRejectedValue(new Error('JMAP request failed: 400')); patchKeywordsForEmails.mockRejectedValue(new Error('JMAP request failed: 400'));
for (let i = 0; i < 5; i++) await useOutboxStore.getState().flush(); for (let i = 0; i < 5; i++) await useOutboxStore.getState().flush();
expect(useOutboxStore.getState().failed).toHaveLength(1); expect(useOutboxStore.getState().failed).toHaveLength(1);
useOutboxStore.getState().discardFailed(); useOutboxStore.getState().discardFailed();
@@ -210,11 +212,11 @@ describe('failed ops and archive replay', () => {
it('pauses the queue on an authentication failure without counting an attempt', async () => { it('pauses the queue on an authentication failure without counting an attempt', async () => {
useNetworkStore.setState({ online: false }); useNetworkStore.setState({ online: false });
useOutboxStore.getState().enqueue({ kind: 'keywords', emailId: 'e1', keywords: { $seen: true } }); useOutboxStore.getState().enqueue({ kind: 'keywords', emailId: 'e1', patch: { $seen: true } });
useNetworkStore.setState({ online: true }); useNetworkStore.setState({ online: true });
const err = new Error('Session expired'); const err = new Error('Session expired');
err.name = 'AuthenticationError'; err.name = 'AuthenticationError';
setEmailKeywords.mockRejectedValueOnce(err); patchKeywordsForEmails.mockRejectedValueOnce(err);
await useOutboxStore.getState().flush(); await useOutboxStore.getState().flush();
expect(useOutboxStore.getState().paused).toBe(true); expect(useOutboxStore.getState().paused).toBe(true);
@@ -222,9 +224,9 @@ describe('failed ops and archive replay', () => {
expect(useOutboxStore.getState().entries[0].attempts).toBe(0); expect(useOutboxStore.getState().entries[0].attempts).toBe(0);
// A second flush is a no-op while paused; re-attaching the account lifts it. // A second flush is a no-op while paused; re-attaching the account lifts it.
setEmailKeywords.mockClear(); patchKeywordsForEmails.mockClear();
await useOutboxStore.getState().flush(); await useOutboxStore.getState().flush();
expect(setEmailKeywords).not.toHaveBeenCalled(); expect(patchKeywordsForEmails).not.toHaveBeenCalled();
await useOutboxStore.getState().setAccount(ACCOUNT); await useOutboxStore.getState().setAccount(ACCOUNT);
expect(useOutboxStore.getState().paused).toBe(false); expect(useOutboxStore.getState().paused).toBe(false);
}); });
@@ -239,3 +241,47 @@ describe('failed ops and archive replay', () => {
expect(entries[0].op.kind).toBe('archive'); expect(entries[0].op.kind).toBe('archive');
}); });
}); });
describe('keyword ops queued by earlier builds', () => {
beforeEach(async () => {
patchKeywordsForEmails.mockResolvedValue(undefined);
await AsyncStorage.removeItem('webmail:outbox:v1:acc-legacy');
await AsyncStorage.removeItem('webmail:outbox:v1:acc-legacy:failed');
});
it('replays a whole keyword map as a patch that sets its keywords and clears nothing else', async () => {
// Earlier builds queued the message's full keyword map, which replaced
// the server's: replaying it as-is would erase a star or tag set since.
const legacy = [
{ id: 'a', createdAt: 1, attempts: 0, op: { kind: 'keywords', emailId: 'e1', keywords: { $seen: true, '$label:work': true, $answered: false } } },
{ id: 'b', createdAt: 2, attempts: 0, op: { kind: 'keywords', emailId: 'e2', accountId: 'grp-1', keywords: { $junk: true } } },
{ id: 'c', createdAt: 3, attempts: 0, op: { kind: 'mailboxes', emailId: 'e3', mailboxIds: { trash: true } } },
];
await AsyncStorage.setItem('webmail:outbox:v1:acc-legacy', JSON.stringify(legacy));
await useOutboxStore.getState().setAccount('acc-legacy');
expect(useOutboxStore.getState().entries[0].op).toEqual({
kind: 'keywords', emailId: 'e1', accountId: undefined, patch: { $seen: true, '$label:work': true },
});
await useOutboxStore.getState().flush();
expect(patchKeywordsForEmails).toHaveBeenNthCalledWith(1, ['e1'], { $seen: true, '$label:work': true }, undefined);
// `$junk` and `$notjunk` exclude each other, so setting one clears the other.
expect(patchKeywordsForEmails).toHaveBeenNthCalledWith(2, ['e2'], { $junk: true, $notjunk: null }, 'grp-1');
expect(setEmailMailboxes).toHaveBeenCalledWith('e3', { trash: true }, undefined);
expect(useOutboxStore.getState().entries).toHaveLength(0);
});
it('upgrades parked failed ops too, so a retry sends a patch', async () => {
const legacy = [
{ id: 'a', createdAt: 1, attempts: 5, op: { kind: 'keywords', emailId: 'e1', keywords: { $notjunk: true, $flagged: true } } },
];
await AsyncStorage.setItem('webmail:outbox:v1:acc-legacy:failed', JSON.stringify(legacy));
await useOutboxStore.getState().setAccount('acc-legacy');
await useOutboxStore.getState().retryFailed();
expect(patchKeywordsForEmails).toHaveBeenCalledWith(['e1'], { $notjunk: true, $flagged: true, $junk: null }, undefined);
expect(useOutboxStore.getState().failed).toHaveLength(0);
});
});
+68 -79
View File
@@ -16,8 +16,8 @@ import {
getEmailChanges, getEmailChanges,
getFullEmail, getFullEmail,
importEmailBlob, importEmailBlob,
setEmailKeywords, patchKeywordsForEmails,
setKeywordsForEmails, patchKeywordsPerEmail,
moveEmail, moveEmail,
moveEmails as apiMoveEmails, moveEmails as apiMoveEmails,
archiveEmails as apiArchiveEmails, archiveEmails as apiArchiveEmails,
@@ -37,6 +37,7 @@ import { toWildcardQuery } from '../lib/search-utils';
import { orderForMailbox, sanitizeSortLevels, type SortLevel } from '../lib/message-list-order'; import { orderForMailbox, sanitizeSortLevels, type SortLevel } from '../lib/message-list-order';
import { buildListSort, markKeywordSortUnsupported } from '../lib/keyword-sort-polarity'; import { buildListSort, markKeywordSortUnsupported } from '../lib/keyword-sort-polarity';
import { generateAccountId } from '../lib/account-utils'; import { generateAccountId } from '../lib/account-utils';
import { applyKeywordPatch, revertKeywordPatch, type KeywordPatch } from '../lib/keyword-patch';
import { t } from './locale-store'; import { t } from './locale-store';
import { useSettingsStore } from './settings-store'; import { useSettingsStore } from './settings-store';
import { useOfflineCacheStore } from './offline-cache-store'; import { useOfflineCacheStore } from './offline-cache-store';
@@ -78,7 +79,7 @@ const provisionRetried = new Set<string>();
// re-opening a message while offline shows the change. Fire-and-forget. // re-opening a message while offline shows the change. Fire-and-forget.
function patchCache( function patchCache(
id: string, id: string,
changes: { keywords?: Record<string, boolean>; mailboxIds?: Record<string, boolean> }, changes: { keywords?: KeywordPatch; mailboxIds?: Record<string, boolean> },
accountId?: string, accountId?: string,
): void { ): void {
void useOfflineCacheStore.getState().patch(id, changes, accountId); void useOfflineCacheStore.getState().patch(id, changes, accountId);
@@ -222,6 +223,12 @@ export interface UndoEntry {
createdAt: number; createdAt: number;
/** JMAP account the messages live under; unset for the user's own mail. */ /** JMAP account the messages live under; unset for the user's own mail. */
accountId?: string; accountId?: string;
/**
* Keywords the action also changed (spam / not spam flip
* `$junk`/`$notjunk`). Undo puts just these back to each item's
* `originalKeywords`.
*/
keywordPatch?: KeywordPatch;
/** /**
* Each item is one email's pre-action mailboxIds, used to restore it. * Each item is one email's pre-action mailboxIds, used to restore it.
* `originalKeywords` is set when the action also changed keywords * `originalKeywords` is set when the action also changed keywords
@@ -963,70 +970,67 @@ export const useEmailStore = create<EmailState>()(
markRead: async (emailId, accountId) => { markRead: async (emailId, accountId) => {
const state = get(); const state = get();
const email = state.emails.find((e) => e.id === emailId); const email = state.emails.find((e) => e.id === emailId);
const nextKeywords = { ...(email?.keywords ?? {}), $seen: true }; // Only `$seen` goes to the server: the message may not be in the list at
// all, and a whole keyword map would erase its stars and tags.
const patch = { $seen: true };
// A group/shared message opened from the unified inbox lives under another // A group/shared message opened from the unified inbox lives under another
// JMAP account and isn't in the active list/cache or the (account-scoped) // JMAP account and isn't in the active list/cache or the (account-scoped)
// offline queue — mark it read directly against its owning account. // offline queue — mark it read directly against its owning account.
if (accountId && !email) { if (accountId && !email) {
await setEmailKeywords(emailId, nextKeywords, accountId); await patchKeywordsForEmails([emailId], patch, accountId);
return; return;
} }
const owner = accountId ?? currentAccountId(state); const owner = accountId ?? currentAccountId(state);
await applyOrQueue({ kind: 'keywords', emailId, accountId: owner, keywords: nextKeywords }); await applyOrQueue({ kind: 'keywords', emailId, accountId: owner, patch });
set({ set({
emails: get().emails.map((e) => emails: get().emails.map((e) =>
e.id === emailId ? { ...e, keywords: nextKeywords } : e, e.id === emailId ? { ...e, keywords: applyKeywordPatch(e.keywords, patch) } : e,
), ),
...(state.filters.isUnread === true ? { retainedIds: retain(get().retainedIds, [emailId]) } : {}), ...(state.filters.isUnread === true ? { retainedIds: retain(get().retainedIds, [emailId]) } : {}),
}); });
patchCache(emailId, { keywords: nextKeywords }, owner); patchCache(emailId, { keywords: patch }, owner);
}, },
markUnread: async (emailId) => { markUnread: async (emailId) => {
const state = get(); const state = get();
const email = state.emails.find((e) => e.id === emailId); const email = state.emails.find((e) => e.id === emailId);
if (!email) return; if (!email) return;
const { $seen, ...rest } = email.keywords; const patch = { $seen: null };
await applyOrQueue({ await applyOrQueue({
kind: 'keywords', kind: 'keywords',
emailId, emailId,
accountId: currentAccountId(state), accountId: currentAccountId(state),
keywords: rest, patch,
}); });
set({ set({
emails: get().emails.map((e) => emails: get().emails.map((e) =>
e.id === emailId ? { ...e, keywords: rest } : e, e.id === emailId ? { ...e, keywords: applyKeywordPatch(e.keywords, patch) } : e,
), ),
...(state.filters.isUnread === false ? { retainedIds: retain(get().retainedIds, [emailId]) } : {}), ...(state.filters.isUnread === false ? { retainedIds: retain(get().retainedIds, [emailId]) } : {}),
}); });
patchCache(emailId, { keywords: rest }, currentAccountId(state)); patchCache(emailId, { keywords: patch }, currentAccountId(state));
}, },
toggleStar: async (emailId, starred) => { toggleStar: async (emailId, starred) => {
const state = get(); const state = get();
const email = state.emails.find((e) => e.id === emailId); const email = state.emails.find((e) => e.id === emailId);
if (!email) return; if (!email) return;
const keywords = { ...email.keywords }; const patch = { $flagged: starred ? true : null };
if (starred) {
keywords.$flagged = true;
} else {
delete keywords.$flagged;
}
await applyOrQueue({ await applyOrQueue({
kind: 'keywords', kind: 'keywords',
emailId, emailId,
accountId: currentAccountId(state), accountId: currentAccountId(state),
keywords, patch,
}); });
set({ set({
emails: get().emails.map((e) => emails: get().emails.map((e) =>
e.id === emailId ? { ...e, keywords } : e, e.id === emailId ? { ...e, keywords: applyKeywordPatch(e.keywords, patch) } : e,
), ),
...(state.filters.isStarred !== undefined && state.filters.isStarred !== starred ...(state.filters.isStarred !== undefined && state.filters.isStarred !== starred
? { retainedIds: retain(get().retainedIds, [emailId]) } ? { retainedIds: retain(get().retainedIds, [emailId]) }
: {}), : {}),
}); });
patchCache(emailId, { keywords }, currentAccountId(state)); patchCache(emailId, { keywords: patch }, currentAccountId(state));
}, },
togglePin: async (emailId, pinned) => { togglePin: async (emailId, pinned) => {
@@ -1035,24 +1039,19 @@ export const useEmailStore = create<EmailState>()(
if (!email) return; if (!email) return;
// `$pinned` is what the webmail reads and writes; a pin set as // `$pinned` is what the webmail reads and writes; a pin set as
// `$important` was invisible to it (and vice versa). // `$important` was invisible to it (and vice versa).
const keywords = { ...email.keywords }; const patch = { $pinned: pinned ? true : null };
if (pinned) {
keywords.$pinned = true;
} else {
delete keywords.$pinned;
}
await applyOrQueue({ await applyOrQueue({
kind: 'keywords', kind: 'keywords',
emailId, emailId,
accountId: currentAccountId(state), accountId: currentAccountId(state),
keywords, patch,
}); });
set({ set({
emails: get().emails.map((e) => emails: get().emails.map((e) =>
e.id === emailId ? { ...e, keywords } : e, e.id === emailId ? { ...e, keywords: applyKeywordPatch(e.keywords, patch) } : e,
), ),
}); });
patchCache(emailId, { keywords }, currentAccountId(state)); patchCache(emailId, { keywords: patch }, currentAccountId(state));
}, },
markSpam: async (emailIds) => { markSpam: async (emailIds) => {
@@ -1068,10 +1067,7 @@ export const useEmailStore = create<EmailState>()(
const junk = refFor(state.mailboxes, junkMailbox.id); const junk = refFor(state.mailboxes, junkMailbox.id);
const markRead = useSettingsStore.getState().deleteAction === 'trash-and-read'; const markRead = useSettingsStore.getState().deleteAction === 'trash-and-read';
const junkTarget = { [junk.id]: true }; const junkTarget = { [junk.id]: true };
const nextKeywords = new Map(targets.map((e) => { const keywordPatch: KeywordPatch = { $junk: true, $notjunk: null, ...(markRead ? { $seen: true } : {}) };
const { $notjunk: _drop, ...rest } = e.keywords ?? {};
return [e.id, { ...rest, $junk: true, ...(markRead ? { $seen: true } : {}) }];
}));
const items = targets.map((e) => ({ const items = targets.map((e) => ({
email: e, email: e,
originalMailboxIds: { ...e.mailboxIds }, originalMailboxIds: { ...e.mailboxIds },
@@ -1081,7 +1077,7 @@ export const useEmailStore = create<EmailState>()(
await applyOrQueueBatch( await applyOrQueueBatch(
targets.flatMap((e): OutboxOp[] => [ targets.flatMap((e): OutboxOp[] => [
{ kind: 'mailboxes', emailId: e.id, accountId: junk.accountId, mailboxIds: junkTarget }, { kind: 'mailboxes', emailId: e.id, accountId: junk.accountId, mailboxIds: junkTarget },
{ kind: 'keywords', emailId: e.id, accountId: junk.accountId, keywords: nextKeywords.get(e.id)! }, { kind: 'keywords', emailId: e.id, accountId: junk.accountId, patch: keywordPatch },
]), ]),
() => apiMarkAsSpam(targets.map((e) => e.id), junk.id, junk.accountId, { markRead }), () => apiMarkAsSpam(targets.map((e) => e.id), junk.id, junk.accountId, { markRead }),
); );
@@ -1096,10 +1092,11 @@ export const useEmailStore = create<EmailState>()(
: t('email_list.marked_as_spam_count', `${targets.length} emails marked as spam`, { count: targets.length }), : t('email_list.marked_as_spam_count', `${targets.length} emails marked as spam`, { count: targets.length }),
createdAt: Date.now(), createdAt: Date.now(),
accountId: junk.accountId, accountId: junk.accountId,
keywordPatch,
items, items,
}, },
}); });
for (const e of targets) patchCache(e.id, { mailboxIds: junkTarget, keywords: nextKeywords.get(e.id) }, junk.accountId); for (const e of targets) patchCache(e.id, { mailboxIds: junkTarget, keywords: keywordPatch }, junk.accountId);
}, },
unmarkSpam: async (emailIds) => { unmarkSpam: async (emailIds) => {
@@ -1111,10 +1108,7 @@ export const useEmailStore = create<EmailState>()(
if (!inboxMailbox) return; if (!inboxMailbox) return;
const inbox = refFor(state.mailboxes, inboxMailbox.id); const inbox = refFor(state.mailboxes, inboxMailbox.id);
const inboxTarget = { [inbox.id]: true }; const inboxTarget = { [inbox.id]: true };
const nextKeywords = new Map(targets.map((e) => { const keywordPatch: KeywordPatch = { $junk: null, $notjunk: true };
const { $junk: _drop, ...rest } = e.keywords ?? {};
return [e.id, { ...rest, $notjunk: true }];
}));
const items = targets.map((e) => ({ const items = targets.map((e) => ({
email: e, email: e,
originalMailboxIds: { ...e.mailboxIds }, originalMailboxIds: { ...e.mailboxIds },
@@ -1124,7 +1118,7 @@ export const useEmailStore = create<EmailState>()(
await applyOrQueueBatch( await applyOrQueueBatch(
targets.flatMap((e): OutboxOp[] => [ targets.flatMap((e): OutboxOp[] => [
{ kind: 'mailboxes', emailId: e.id, accountId: inbox.accountId, mailboxIds: inboxTarget }, { kind: 'mailboxes', emailId: e.id, accountId: inbox.accountId, mailboxIds: inboxTarget },
{ kind: 'keywords', emailId: e.id, accountId: inbox.accountId, keywords: nextKeywords.get(e.id)! }, { kind: 'keywords', emailId: e.id, accountId: inbox.accountId, patch: keywordPatch },
]), ]),
() => apiUndoSpam(targets.map((e) => e.id), inbox.id, inbox.accountId), () => apiUndoSpam(targets.map((e) => e.id), inbox.id, inbox.accountId),
); );
@@ -1139,10 +1133,11 @@ export const useEmailStore = create<EmailState>()(
: t('email_list.marked_not_spam_count', `${targets.length} emails marked as not spam`, { count: targets.length }), : t('email_list.marked_not_spam_count', `${targets.length} emails marked as not spam`, { count: targets.length }),
createdAt: Date.now(), createdAt: Date.now(),
accountId: inbox.accountId, accountId: inbox.accountId,
keywordPatch,
items, items,
}, },
}); });
for (const e of targets) patchCache(e.id, { mailboxIds: inboxTarget, keywords: nextKeywords.get(e.id) }, inbox.accountId); for (const e of targets) patchCache(e.id, { mailboxIds: inboxTarget, keywords: keywordPatch }, inbox.accountId);
}, },
moveToMailbox: async (emailId, fromMailboxId, toMailboxId) => { moveToMailbox: async (emailId, fromMailboxId, toMailboxId) => {
@@ -1290,14 +1285,14 @@ export const useEmailStore = create<EmailState>()(
// "Move to Trash and mark as read" (#323): when the user picked that // "Move to Trash and mark as read" (#323): when the user picked that
// delete action, also clear unread state for messages moved to trash. // delete action, also clear unread state for messages moved to trash.
if (settings.deleteAction === 'trash-and-read' && email && !email.keywords?.$seen) { if (settings.deleteAction === 'trash-and-read' && email && !email.keywords?.$seen) {
const nextKeywords = { ...email.keywords, $seen: true }; const patch = { $seen: true };
await applyOrQueue({ await applyOrQueue({
kind: 'keywords', kind: 'keywords',
emailId, emailId,
accountId: source.accountId, accountId: source.accountId,
keywords: nextKeywords, patch,
}); });
patchCache(emailId, { mailboxIds: target, keywords: nextKeywords }, source.accountId); patchCache(emailId, { mailboxIds: target, keywords: patch }, source.accountId);
} else { } else {
patchCache(emailId, { mailboxIds: target }, source.accountId); patchCache(emailId, { mailboxIds: target }, source.accountId);
} }
@@ -1462,9 +1457,8 @@ export const useEmailStore = create<EmailState>()(
settings.deleteAction === 'trash-and-read' settings.deleteAction === 'trash-and-read'
? toTrash.filter((e) => !e.keywords?.$seen) ? toTrash.filter((e) => !e.keywords?.$seen)
: []; : [];
const markReadKeywords = new Map( const markReadIds = new Set(toMarkRead.map((e) => e.id));
toMarkRead.map((e) => [e.id, { ...e.keywords, $seen: true }]), const markReadPatch = { $seen: true };
);
const ops: OutboxOp[] = [ const ops: OutboxOp[] = [
...toDestroy.map((e): OutboxOp => ({ ...toDestroy.map((e): OutboxOp => ({
@@ -1482,7 +1476,7 @@ export const useEmailStore = create<EmailState>()(
kind: 'keywords', kind: 'keywords',
emailId: e.id, emailId: e.id,
accountId: source.accountId, accountId: source.accountId,
keywords: markReadKeywords.get(e.id)!, patch: markReadPatch,
})), })),
]; ];
await applyOrQueueBatch(ops, async () => { await applyOrQueueBatch(ops, async () => {
@@ -1493,10 +1487,7 @@ export const useEmailStore = create<EmailState>()(
await apiMoveEmails(toTrash.map((e) => e.id), source.id, trash.id, source.accountId); await apiMoveEmails(toTrash.map((e) => e.id), source.id, trash.id, source.accountId);
} }
if (toMarkRead.length > 0) { if (toMarkRead.length > 0) {
await setKeywordsForEmails( await patchKeywordsForEmails([...markReadIds], markReadPatch, source.accountId);
toMarkRead.map((e) => ({ id: e.id, keywords: markReadKeywords.get(e.id)! })),
source.accountId,
);
} }
}); });
@@ -1504,7 +1495,7 @@ export const useEmailStore = create<EmailState>()(
for (const e of toTrash) { for (const e of toTrash) {
patchCache(e.id, { patchCache(e.id, {
mailboxIds: mailboxesAfterMove(e.mailboxIds, source.id, trash.id), mailboxIds: mailboxesAfterMove(e.mailboxIds, source.id, trash.id),
...(markReadKeywords.has(e.id) ? { keywords: markReadKeywords.get(e.id) } : {}), ...(markReadIds.has(e.id) ? { keywords: markReadPatch } : {}),
}, source.accountId); }, source.accountId);
} }
@@ -1532,32 +1523,28 @@ export const useEmailStore = create<EmailState>()(
const targets = state.emails.filter((e) => emailIds.includes(e.id)); const targets = state.emails.filter((e) => emailIds.includes(e.id));
if (targets.length === 0) return; if (targets.length === 0) return;
const owner = currentAccountId(state); const owner = currentAccountId(state);
const updates = targets.map((e) => { const ids = targets.map((e) => e.id);
const keywords = { ...e.keywords }; const patch = { [token]: on ? true : null };
if (on) keywords[token] = true;
else delete keywords[token];
return { id: e.id, keywords };
});
await applyOrQueueBatch( await applyOrQueueBatch(
updates.map((u): OutboxOp => ({ ids.map((id): OutboxOp => ({
kind: 'keywords', kind: 'keywords',
emailId: u.id, emailId: id,
accountId: owner, accountId: owner,
keywords: u.keywords, patch,
})), })),
() => setKeywordsForEmails(updates, owner), () => patchKeywordsForEmails(ids, patch, owner),
); );
const byId = new Map(updates.map((u) => [u.id, u.keywords])); const touched = new Set(ids);
set({ set({
emails: get().emails.map((e) => emails: get().emails.map((e) =>
byId.has(e.id) ? { ...e, keywords: byId.get(e.id)! } : e, touched.has(e.id) ? { ...e, keywords: applyKeywordPatch(e.keywords, patch) } : e,
), ),
// Untagging inside that tag's view keeps the rows until it's re-opened. // Untagging inside that tag's view keeps the rows until it's re-opened.
...(!on && state.filters.keyword === token ...(!on && state.filters.keyword === token
? { retainedIds: retain(get().retainedIds, updates.map((u) => u.id)) } ? { retainedIds: retain(get().retainedIds, ids) }
: {}), : {}),
}); });
for (const u of updates) patchCache(u.id, { keywords: u.keywords }, owner); for (const id of ids) patchCache(id, { keywords: patch }, owner);
}, },
undoLast: async () => { undoLast: async () => {
@@ -1579,26 +1566,28 @@ export const useEmailStore = create<EmailState>()(
), ),
); );
// Spam / not-spam also flipped `$junk`/`$notjunk` (and maybe `$seen`): // Spam / not-spam also flipped `$junk`/`$notjunk` (and maybe `$seen`):
// put the keywords back as they were. // put those keywords back as they were, leaving the rest alone.
const withKeywords = entry.items.filter((it) => it.originalKeywords); const keywordPatch = entry.keywordPatch;
if (withKeywords.length > 0) { const keywordUndo = keywordPatch
? entry.items.map((it) => ({ id: it.email.id, patch: revertKeywordPatch(keywordPatch, it.originalKeywords) }))
: [];
if (keywordUndo.length > 0) {
await applyOrQueueBatch( await applyOrQueueBatch(
withKeywords.map((it): OutboxOp => ({ keywordUndo.map((u): OutboxOp => ({
kind: 'keywords', kind: 'keywords',
emailId: it.email.id, emailId: u.id,
accountId: entry.accountId, accountId: entry.accountId,
keywords: it.originalKeywords!, patch: u.patch,
})), })),
() => setKeywordsForEmails( () => patchKeywordsPerEmail(keywordUndo, entry.accountId),
withKeywords.map((it) => ({ id: it.email.id, keywords: it.originalKeywords! })),
entry.accountId,
),
); );
} }
const undoById = new Map(keywordUndo.map((u) => [u.id, u.patch]));
for (const it of entry.items) { for (const it of entry.items) {
const patch = undoById.get(it.email.id);
patchCache(it.email.id, { patchCache(it.email.id, {
mailboxIds: it.originalMailboxIds, mailboxIds: it.originalMailboxIds,
...(it.originalKeywords ? { keywords: it.originalKeywords } : {}), ...(patch ? { keywords: patch } : {}),
}, entry.accountId); }, entry.accountId);
} }
} catch (err) { } catch (err) {
+15 -5
View File
@@ -12,6 +12,7 @@
import { create } from 'zustand'; import { create } from 'zustand';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import type { Email } from '../api/types'; import type { Email } from '../api/types';
import { applyKeywordPatch, type KeywordPatch } from '../lib/keyword-patch';
const INDEX_KEY_PREFIX = 'webmail:offline-cache:index:v2:'; const INDEX_KEY_PREFIX = 'webmail:offline-cache:index:v2:';
const ENTRY_KEY_PREFIX = 'webmail:offline-cache:entry:v2:'; const ENTRY_KEY_PREFIX = 'webmail:offline-cache:entry:v2:';
@@ -78,10 +79,15 @@ interface OfflineCacheState {
has: (id: string, jmapAccountId?: string) => boolean; has: (id: string, jmapAccountId?: string) => boolean;
get: (id: string, jmapAccountId?: string) => Promise<Email | null>; get: (id: string, jmapAccountId?: string) => Promise<Email | null>;
put: (email: Email, approxBytes: number, jmapAccountId?: string) => Promise<void>; put: (email: Email, approxBytes: number, jmapAccountId?: string) => Promise<void>;
// Shallow-merge a change into a cached email (no-op when not cached). Used to // Apply a change to a cached email (no-op when not cached). Used to keep the
// keep the cached body consistent with an optimistic/queued mutation so a // cached body consistent with an optimistic/queued mutation so a re-open
// re-open while offline reflects the new keywords / mailboxIds. // while offline reflects the new keywords / mailboxIds. `keywords` is a
patch: (id: string, changes: Partial<Pick<Email, 'keywords' | 'mailboxIds'>>, jmapAccountId?: string) => Promise<void>; // patch (only the keywords it names change); `mailboxIds` replaces the map.
patch: (
id: string,
changes: { keywords?: KeywordPatch; mailboxIds?: Record<string, boolean> },
jmapAccountId?: string,
) => Promise<void>;
remove: (ids: string[], jmapAccountId?: string) => Promise<void>; remove: (ids: string[], jmapAccountId?: string) => Promise<void>;
// Evict oldest-received entries until the cache fits within maxBytes. Keeps // Evict oldest-received entries until the cache fits within maxBytes. Keeps
// the persisted cache from growing without bound on a noisy account. // the persisted cache from growing without bound on a noisy account.
@@ -227,7 +233,11 @@ export const useOfflineCacheStore = create<OfflineCacheState>((set, get) => ({
const raw = await AsyncStorage.getItem(entryKey(accountId, key)); const raw = await AsyncStorage.getItem(entryKey(accountId, key));
if (!raw) return; if (!raw) return;
const email = JSON.parse(raw) as Email; const email = JSON.parse(raw) as Email;
const updated: Email = { ...email, ...changes }; const updated: Email = {
...email,
...(changes.mailboxIds ? { mailboxIds: changes.mailboxIds } : {}),
...(changes.keywords ? { keywords: applyKeywordPatch(email.keywords, changes.keywords) } : {}),
};
if (get().activeAccountId !== accountId) return; if (get().activeAccountId !== accountId) return;
await AsyncStorage.setItem(entryKey(accountId, key), JSON.stringify(updated)); await AsyncStorage.setItem(entryKey(accountId, key), JSON.stringify(updated));
} catch (err) { } catch (err) {
+36 -11
View File
@@ -2,15 +2,16 @@
// //
// Every mutation the app performs on a message reduces to one of four // Every mutation the app performs on a message reduces to one of four
// idempotent primitives: // idempotent primitives:
// - keywords: replace the full keyword map (read/unread, flag, pin, …) // - keywords: set or clear individual keywords (read/unread, flag, pin, …)
// - mailboxes: replace the full mailboxIds map (move, trash) // - mailboxes: replace the full mailboxIds map (move, trash)
// - archive: file into Archive with the user's year/month auto-foldering // - archive: file into Archive with the user's year/month auto-foldering
// - destroy: permanently delete the message // - destroy: permanently delete the message
// //
// Because each primitive assigns the *whole* target state rather than a delta, // Because each primitive assigns a target state rather than a delta, replaying
// replaying it is safe regardless of the server's current state, so we can // it is safe regardless of the server's current state, so we can coalesce
// coalesce repeated edits to the same message (last-write-wins) and retry on a // repeated edits to the same message (last-write-wins) and retry on a flaky
// flaky connection without corrupting anything. // connection without corrupting anything. Keyword ops name only the keywords
// the user changed, so replay never touches ones another client set meanwhile.
// //
// When the device is online and nothing is already queued for a message, the // When the device is online and nothing is already queued for a message, the
// op runs immediately (preserving today's behaviour, including surfacing real // op runs immediately (preserving today's behaviour, including surfacing real
@@ -25,8 +26,9 @@ import { isTransientNetworkError, isAuthError } from '../lib/network-error';
import { useNetworkStore } from './network-store'; import { useNetworkStore } from './network-store';
import { jmapClient } from '../api/jmap-client'; import { jmapClient } from '../api/jmap-client';
import { import {
setEmailKeywords, setEmailMailboxes, destroyEmails, archiveEmails, unprefixMailboxId, patchKeywordsForEmails, setEmailMailboxes, destroyEmails, archiveEmails, unprefixMailboxId,
} from '../api/email'; } from '../api/email';
import type { KeywordPatch } from '../lib/keyword-patch';
import type { ArchiveMode } from './settings-store'; import type { ArchiveMode } from './settings-store';
const KEY_PREFIX = 'webmail:outbox:v1:'; const KEY_PREFIX = 'webmail:outbox:v1:';
@@ -47,7 +49,7 @@ function storageKey(accountId: string): string {
// user's own mail (the client's primary account); set when the message belongs // user's own mail (the client's primary account); set when the message belongs
// to a shared/group account, so replay after a reconnect still targets it. // to a shared/group account, so replay after a reconnect still targets it.
export type OutboxOp = export type OutboxOp =
| { kind: 'keywords'; emailId: string; accountId?: string; keywords: Record<string, boolean> } | { kind: 'keywords'; emailId: string; accountId?: string; patch: KeywordPatch }
| { kind: 'mailboxes'; emailId: string; accountId?: string; mailboxIds: Record<string, boolean> } | { kind: 'mailboxes'; emailId: string; accountId?: string; mailboxIds: Record<string, boolean> }
| { | {
/** /**
@@ -111,12 +113,30 @@ function persistFailed(accountId: string, failed: OutboxEntry[]): void {
}); });
} }
// Keyword ops queued by earlier builds carried the message's whole keyword
// map, which the server took as a replacement. That map can't tell which
// keyword the user changed, so it replays as a patch that only sets the
// keywords it holds (clearing the other half of the exclusive `$junk` /
// `$notjunk` pair) and clears nothing else: an unread or unstar queued back
// then is lost, but no star or tag another client set gets erased.
type LegacyKeywordsOp = { kind: 'keywords'; emailId: string; accountId?: string; keywords: Record<string, boolean> };
function upgradeEntry(entry: OutboxEntry): OutboxEntry {
const op = entry.op as OutboxOp | LegacyKeywordsOp;
if (op.kind !== 'keywords' || !('keywords' in op)) return entry;
const patch: KeywordPatch = {};
for (const [keyword, on] of Object.entries(op.keywords ?? {})) if (on) patch[keyword] = true;
if (patch.$junk && !patch.$notjunk) patch.$notjunk = null;
if (patch.$notjunk && !patch.$junk) patch.$junk = null;
return { ...entry, op: { kind: 'keywords', emailId: op.emailId, accountId: op.accountId, patch } };
}
async function load(accountId: string, suffix = ''): Promise<OutboxEntry[]> { async function load(accountId: string, suffix = ''): Promise<OutboxEntry[]> {
try { try {
const raw = await AsyncStorage.getItem(storageKey(accountId) + suffix); const raw = await AsyncStorage.getItem(storageKey(accountId) + suffix);
if (raw) { if (raw) {
const parsed = JSON.parse(raw); const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) return parsed as OutboxEntry[]; if (Array.isArray(parsed)) return (parsed as OutboxEntry[]).map(upgradeEntry);
} }
} catch (err) { } catch (err) {
console.warn('[outbox] hydrate failed', err); console.warn('[outbox] hydrate failed', err);
@@ -127,7 +147,7 @@ async function load(accountId: string, suffix = ''): Promise<OutboxEntry[]> {
async function runOp(op: OutboxOp): Promise<void> { async function runOp(op: OutboxOp): Promise<void> {
switch (op.kind) { switch (op.kind) {
case 'keywords': case 'keywords':
return setEmailKeywords(op.emailId, op.keywords, op.accountId); return patchKeywordsForEmails([op.emailId], op.patch, op.accountId);
case 'mailboxes': case 'mailboxes':
return setEmailMailboxes(op.emailId, op.mailboxIds, op.accountId); return setEmailMailboxes(op.emailId, op.mailboxIds, op.accountId);
case 'archive': { case 'archive': {
@@ -251,11 +271,16 @@ export const useOutboxStore = create<OutboxState>((set, get) => ({
// A queued destroy wins; further edits to a doomed message are pointless. // A queued destroy wins; further edits to a doomed message are pointless.
if (entries.some((e) => sameMessage(e.op) && e.op.kind === 'destroy')) return; if (entries.some((e) => sameMessage(e.op) && e.op.kind === 'destroy')) return;
// Coalesce: replace any pending op of the same family for this message // Coalesce: replace any pending op of the same family for this message
// (full-state replace makes the latest one authoritative). Keep its // (full-state replace makes the latest one authoritative; keyword
// patches merge, the later value winning per keyword). Keep its
// position so creation order is preserved for replay. // position so creation order is preserved for replay.
const idx = entries.findIndex((e) => sameMessage(e.op) && opFamily(e.op) === opFamily(op)); const idx = entries.findIndex((e) => sameMessage(e.op) && opFamily(e.op) === opFamily(op));
if (idx >= 0) { if (idx >= 0) {
entries[idx] = { ...entries[idx], op, attempts: 0, lastError: undefined }; const prev = entries[idx].op;
const merged: OutboxOp = prev.kind === 'keywords' && op.kind === 'keywords'
? { ...op, patch: { ...prev.patch, ...op.patch } }
: op;
entries[idx] = { ...entries[idx], op: merged, attempts: 0, lastError: undefined };
} else { } else {
entries.push({ id: generateUUID(), op, createdAt: Date.now(), attempts: 0 }); entries.push({ id: generateUUID(), op, createdAt: Date.now(), attempts: 0 });
} }