@remit/mailbox-service 0.0.111 → 0.0.112

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/package.json +21 -6
  2. package/src/body-part-mapper.ts +51 -1
  3. package/src/body-sync-queue.test.ts +39 -99
  4. package/src/body-sync-terminal.test.ts +115 -277
  5. package/src/body-sync-terminal.ts +10 -12
  6. package/src/body-sync.test.ts +925 -0
  7. package/src/body-sync.ts +156 -235
  8. package/src/classification-backfill.test.ts +197 -527
  9. package/src/classify-message.test.ts +11 -74
  10. package/src/external-move.test.ts +102 -209
  11. package/src/external-move.ts +13 -0
  12. package/src/filter-operations.test.ts +319 -0
  13. package/src/filter-operations.ts +377 -0
  14. package/src/filter-update.test.ts +313 -0
  15. package/src/filters/filter-config.test.ts +55 -197
  16. package/src/filters/filter-config.ts +4 -39
  17. package/src/filters/match.test.ts +1 -38
  18. package/src/filters/match.ts +3 -52
  19. package/src/filters/pipeline.test.ts +158 -289
  20. package/src/filters/pipeline.ts +56 -170
  21. package/src/flag-push-terminal.ts +9 -11
  22. package/src/flag-push.test.ts +76 -372
  23. package/src/flag-queue.test.ts +197 -0
  24. package/src/folder-operations.test.ts +273 -0
  25. package/src/folder-operations.ts +217 -0
  26. package/src/forwarded-message.test.ts +122 -0
  27. package/src/imapflow-connect-retry.test.ts +76 -113
  28. package/src/imapflow-connection.test.ts +34 -0
  29. package/src/imapflow-connection.ts +5 -4
  30. package/src/index.ts +59 -12
  31. package/src/list-id-backfill.test.ts +133 -499
  32. package/src/mailbox-cursor.test.ts +81 -189
  33. package/src/mailbox-delete-intent.test.ts +78 -205
  34. package/src/mailbox-intent.ts +5 -3
  35. package/src/mailbox-management.test.ts +107 -220
  36. package/src/mailbox-management.ts +1 -2
  37. package/src/mailbox-presence.test.ts +36 -34
  38. package/src/mailbox-queue.ts +2 -20
  39. package/src/mailbox-rename-intent.test.ts +156 -325
  40. package/src/mailbox-rename-subtree.test.ts +205 -582
  41. package/src/mailbox-sync.test.ts +182 -744
  42. package/src/message-move.test.ts +726 -0
  43. package/src/message-move.ts +227 -57
  44. package/src/message-read.ts +87 -0
  45. package/src/message-save-retry.test.ts +90 -0
  46. package/src/message-sync-flags.test.ts +10 -1
  47. package/src/message-sync.test.ts +762 -0
  48. package/src/message-sync.ts +409 -239
  49. package/src/mime-walker.ts +16 -15
  50. package/src/organize-job.ts +93 -0
  51. package/src/outbox-attachment.test.ts +97 -209
  52. package/src/outbox-failed-recovery.test.ts +198 -269
  53. package/src/outbox-queue.test.ts +24 -0
  54. package/src/outbox-queue.ts +25 -8
  55. package/src/ownership.ts +140 -0
  56. package/src/placement-move-terminal.ts +9 -12
  57. package/src/placement-move.ts +33 -6
  58. package/src/placement-settled.ts +28 -4
  59. package/src/placement.test.ts +404 -0
  60. package/src/quarantine.test.ts +90 -148
  61. package/src/quarantine.ts +3 -2
  62. package/src/refusal.ts +29 -0
  63. package/src/restore-source-placement.ts +0 -4
  64. package/src/spam-report.test.ts +238 -710
  65. package/src/sync-warning.test.ts +213 -0
  66. package/src/sync-warning.ts +56 -0
  67. package/src/test-helpers/fake-imap.ts +690 -0
  68. package/src/test-helpers/test-queue.ts +56 -0
  69. package/src/test-helpers/test-store.ts +299 -0
  70. package/src/thread-root.test.ts +29 -0
  71. package/src/thread-root.ts +51 -0
  72. package/src/types/mailparser-augment.d.ts +8 -0
  73. package/src/adversarial-mime.e2e.test.ts +0 -307
  74. package/src/body-sync-auto-read-writeonce.test.ts +0 -219
  75. package/src/body-sync-calendar-suggestion.test.ts +0 -433
  76. package/src/body-sync-category-denormalize.test.ts +0 -352
  77. package/src/body-sync-category-override.test.ts +0 -501
  78. package/src/body-sync-category-writeonce.test.ts +0 -234
  79. package/src/body-sync-filter.test.ts +0 -237
  80. package/src/body-sync-message-gone.test.ts +0 -139
  81. package/src/body-sync-placement-flags.test.ts +0 -347
  82. package/src/body-sync-placement-writeonce.test.ts +0 -337
  83. package/src/body-sync-quarantine.test.ts +0 -276
  84. package/src/body-sync-resync.test.ts +0 -605
  85. package/src/body-sync-unclassifiable-once.test.ts +0 -260
  86. package/src/body-sync-unsubscribed-auto-read.test.ts +0 -176
  87. package/src/filters/anchor-drift.ts +0 -55
  88. package/src/filters/message-embedder.test.ts +0 -39
  89. package/src/filters/message-embedder.ts +0 -33
  90. package/src/flag-push-terminal.test.ts +0 -209
  91. package/src/flag-queue-stars.test.ts +0 -171
  92. package/src/imapflow-connection.e2e.test.ts +0 -335
  93. package/src/imapflow-connection.integ.test.ts +0 -316
  94. package/src/message-delete-junk-reconcile.test.ts +0 -117
  95. package/src/message-delete-no-trash.test.ts +0 -245
  96. package/src/message-delete-unsettled-placement.test.ts +0 -396
  97. package/src/message-move-copy.test.ts +0 -562
  98. package/src/message-move-empty-trash.test.ts +0 -326
  99. package/src/message-move-same-mailbox.test.ts +0 -139
  100. package/src/message-move-unsettled-placement.test.ts +0 -378
  101. package/src/message-sync-address-spoof.test.ts +0 -293
  102. package/src/message-sync-category.test.ts +0 -142
  103. package/src/message-sync-changedsince.test.ts +0 -840
  104. package/src/message-sync-junk-harvest.test.ts +0 -244
  105. package/src/message-sync-quarantine.test.ts +0 -416
  106. package/src/message-sync-repoint.test.ts +0 -419
  107. package/src/outbox-queue-settled-row.test.ts +0 -140
  108. package/src/pass-through-unit-of-work.ts +0 -17
  109. package/src/placement-move-terminal.test.ts +0 -300
  110. package/src/placement-move-unsettled.test.ts +0 -275
  111. package/src/placement-transition-race.test.ts +0 -482
  112. package/src/stale-message-reconcile.test.ts +0 -82
  113. package/src/stale-message-reconcile.ts +0 -55
  114. package/src/test-helpers/isolated-mailbox.ts +0 -44
  115. package/src/test-helpers/mailfuzz-connection.ts +0 -27
  116. package/test/fixtures/mime-adversarial-e2e/01-octet-stream-pdf.eml +0 -21
  117. package/test/fixtures/mime-adversarial-e2e/02-nested-related-cid-image.eml +0 -31
  118. package/test/fixtures/mime-adversarial-e2e/03-calendar-invite.eml +0 -33
  119. package/test/fixtures/mime-adversarial-e2e/04-forwarded-rfc822-with-attachments.eml +0 -42
  120. package/test/fixtures/mime-adversarial-e2e/05-tnef-winmail.eml +0 -25
  121. package/test/fixtures/mime-adversarial-e2e/06-8bit-non-ascii.eml +0 -15
  122. package/test/fixtures/mime-adversarial-e2e/07-quoted-printable-soft-breaks.eml +0 -17
  123. package/test/fixtures/mime-adversarial-e2e/08-alternative-empty-text.eml +0 -18
@@ -1,25 +1,25 @@
1
1
  import assert from "node:assert/strict";
2
- import { describe, it } from "node:test";
3
- import type {
4
- IMailboxRepository,
5
- IMailboxSpecialUseRepository,
6
- } from "@remit/data-ports";
7
- import { NotFoundError } from "@remit/data-ports/errors";
2
+ import { afterEach, beforeEach, describe, it } from "node:test";
3
+ import type { AccountItem, MailboxItem } from "@remit/data-ports";
8
4
  import {
9
5
  MailboxCursorState,
10
6
  MailboxSpecialUse,
11
7
  MailboxSyncStatus,
12
8
  } from "@remit/domain-enums";
13
9
  import { parseImapAttributes } from "./attribute-mapper.js";
14
- import { type MailboxSyncLogger, MailboxSyncService } from "./mailbox-sync.js";
15
-
16
- const silentLogger: MailboxSyncLogger = {
17
- info: () => {},
18
- debug: () => {},
19
- error: () => {},
20
- };
21
-
22
- import type { IImapConnection, ImapNamespaces } from "./types.js";
10
+ import { MailboxSyncService } from "./mailbox-sync.js";
11
+ import {
12
+ eml,
13
+ type FakeImapConnection,
14
+ FakeImapServer,
15
+ } from "./test-helpers/fake-imap.js";
16
+ import {
17
+ openStore,
18
+ type Store,
19
+ seedAccount,
20
+ seedMailbox,
21
+ silentLog,
22
+ } from "./test-helpers/test-store.js";
23
23
 
24
24
  describe("parseImapAttributes – locale invariance (#194)", () => {
25
25
  it("recognizes \\Sent regardless of folder name", () => {
@@ -31,14 +31,11 @@ describe("parseImapAttributes – locale invariance (#194)", () => {
31
31
  });
32
32
 
33
33
  it("recognizes \\Drafts on a localized 'Concepten' folder", () => {
34
- // IMAP server tells us the flag — the folder name is irrelevant.
35
34
  const parsed = parseImapAttributes(["\\Drafts"]);
36
35
  assert.deepEqual(parsed.specialUse, [MailboxSpecialUse.Drafts]);
37
36
  });
38
37
 
39
38
  it("returns an empty list when no flag is present", () => {
40
- // A real user folder like Outlook NL's "Nieuwsbrieven" carries no
41
- // SPECIAL-USE attributes — leave it untouched downstream.
42
39
  const parsed = parseImapAttributes(["\\HasNoChildren"]);
43
40
  assert.deepEqual(parsed.specialUse, []);
44
41
  });
@@ -49,799 +46,240 @@ describe("parseImapAttributes – locale invariance (#194)", () => {
49
46
  });
50
47
  });
51
48
 
52
- describe("MailboxSyncService.syncMailboxes — UIDVALIDITY cursor detection (#1272)", () => {
53
- const namespaces: ImapNamespaces = {
54
- personal: [{ prefix: "", delimiter: "/" }],
55
- other: [],
56
- shared: [],
57
- };
58
-
59
- const buildConnection = (
60
- uidValidity: number,
61
- highestModseq = "0",
62
- ): IImapConnection =>
63
- ({
64
- getNamespaces: async () => namespaces,
65
- listMailboxes: async () => [
66
- {
67
- fullPath: "INBOX",
68
- name: "INBOX",
69
- delimiter: "/",
70
- attributes: [],
71
- parentPath: null,
72
- },
73
- ],
74
- getMailboxStatus: async () => ({
75
- messages: 5,
76
- recent: 0,
77
- unseen: 1,
78
- uidNext: 100,
79
- uidValidity,
80
- highestModseq,
81
- deletedCount: 0,
82
- }),
83
- }) as unknown as IImapConnection;
84
-
85
- const buildServices = (
86
- existingUidValidity: number,
87
- existingCursorState?: string,
88
- ) => {
89
- const updateCalls: Array<Record<string, unknown>> = [];
90
- const mailboxService = {
91
- listByAccount: async () => ({
92
- items: [
93
- {
94
- mailboxId: "mbx-1",
95
- fullPath: "INBOX",
96
- uidNext: 100,
97
- uidValidity: existingUidValidity,
98
- messageCount: 5,
99
- unseenCount: 1,
100
- deletedCount: 0,
101
- highestModseq: "0",
102
- specialUse: undefined,
103
- cursorState: existingCursorState,
104
- },
105
- ],
106
- continuationToken: undefined,
107
- }),
108
- update: async (
109
- _accountId: string,
110
- _mailboxId: string,
111
- patch: Record<string, unknown>,
112
- ) => {
113
- updateCalls.push(patch);
114
- return {};
115
- },
116
- delete: async () => undefined,
117
- create: async () => ({
118
- outcome: "Created",
119
- mailbox: { mailboxId: "new" },
120
- }),
121
- } as unknown as IMailboxRepository;
122
-
123
- const specialUseService = {
124
- listByMailboxId: async () => [],
125
- deleteByMailboxId: async () => undefined,
126
- createMany: async () => undefined,
127
- } as unknown as IMailboxSpecialUseRepository;
128
-
129
- return { mailboxService, specialUseService, updateCalls };
130
- };
131
-
132
- it("trips cursor_invalid when the STATUS sweep observes a changed UIDVALIDITY", async () => {
133
- const { mailboxService, specialUseService, updateCalls } = buildServices(1);
134
- const service = new MailboxSyncService(
135
- mailboxService,
136
- specialUseService,
137
- silentLogger,
138
- );
139
- const connection = buildConnection(2);
49
+ let store: Store;
50
+ let imap: FakeImapServer;
51
+ let account: AccountItem;
140
52
 
141
- await service.syncMailboxes({ accountId: "acc-1" }, connection);
53
+ beforeEach(async () => {
54
+ store = await openStore();
55
+ imap = new FakeImapServer();
56
+ account = await seedAccount(store);
57
+ });
142
58
 
143
- const uidValidityUpdate = updateCalls.find((c) => "uidValidity" in c);
144
- assert.ok(uidValidityUpdate, "expected the sweep to write the mailbox");
145
- assert.equal(
146
- uidValidityUpdate?.cursorState,
147
- MailboxCursorState.cursor_invalid,
148
- );
149
- });
59
+ afterEach(() => store.close());
150
60
 
151
- it("does not write anything when UIDVALIDITY (and everything else) is unchanged", async () => {
152
- const { mailboxService, specialUseService, updateCalls } = buildServices(1);
153
- const service = new MailboxSyncService(
154
- mailboxService,
155
- specialUseService,
156
- silentLogger,
157
- );
158
- const connection = buildConnection(1);
61
+ const sweep = (connection: FakeImapConnection = imap.connection()) =>
62
+ new MailboxSyncService(
63
+ store.mailbox,
64
+ store.mailboxSpecialUse,
65
+ silentLog,
66
+ ).syncMailboxes({ accountId: account.accountId }, connection);
159
67
 
160
- await service.syncMailboxes({ accountId: "acc-1" }, connection);
68
+ const rows = async () =>
69
+ (await store.mailbox.listAllByAccount(account.accountId)).sort((a, b) =>
70
+ a.fullPath.localeCompare(b.fullPath),
71
+ );
161
72
 
162
- assert.equal(updateCalls.length, 0);
163
- });
73
+ const paths = async () => (await rows()).map((mailbox) => mailbox.fullPath);
164
74
 
165
- it("never writes the message-sync cursor, whatever the server reports", async () => {
166
- // `highestModseq` on the mailbox row is message sync's own cursor over
167
- // applied changes, not a status projection. The sweep overwriting it with
168
- // the server's current value would step it over every change message sync
169
- // had not yet applied.
170
- const { mailboxService, specialUseService, updateCalls } = buildServices(1);
171
- const service = new MailboxSyncService(
172
- mailboxService,
173
- specialUseService,
174
- silentLogger,
175
- );
176
- const connection = buildConnection(2, "99999");
75
+ const row = (mailbox: MailboxItem) =>
76
+ store.mailbox.get(account.accountId, mailbox.mailboxId);
177
77
 
178
- await service.syncMailboxes({ accountId: "acc-1" }, connection);
78
+ const byPath = async (fullPath: string) => {
79
+ const found = await store.mailbox.findByPath(account.accountId, fullPath);
80
+ if (!found) throw new Error(`no row at ${fullPath}`);
81
+ return found;
82
+ };
179
83
 
180
- for (const call of updateCalls) {
181
- assert.equal("highestModseq" in call, false);
182
- }
84
+ const inState = async (
85
+ fullPath: string,
86
+ syncStatus: MailboxItem["syncStatus"],
87
+ pendingPath?: string,
88
+ ) => {
89
+ const mailbox = await seedMailbox(store, account, fullPath, {
90
+ overrides: { syncStatus },
183
91
  });
92
+ if (!pendingPath) return mailbox;
93
+ const recorded = await store.mailbox.transition(
94
+ account.accountId,
95
+ mailbox.mailboxId,
96
+ { from: [syncStatus], to: syncStatus, set: { pendingPath } },
97
+ );
98
+ if (!recorded) throw new Error(`could not record ${pendingPath}`);
99
+ return recorded;
100
+ };
184
101
 
185
- it("does not sweep-write a mailbox whose only difference is the server mod-sequence", async () => {
186
- const { mailboxService, specialUseService, updateCalls } = buildServices(1);
187
- const service = new MailboxSyncService(
188
- mailboxService,
189
- specialUseService,
190
- silentLogger,
191
- );
192
- const connection = buildConnection(1, "4242");
102
+ describe("the STATUS sweep and the mailbox cursor", () => {
103
+ const swept = async () => {
104
+ imap.addFolder("INBOX", { uidValidity: 1 });
105
+ imap.deliver("INBOX", eml({}));
106
+ await sweep();
107
+ return byPath("INBOX");
108
+ };
193
109
 
194
- await service.syncMailboxes({ accountId: "acc-1" }, connection);
110
+ it("trips cursor_invalid without touching the message-sync cursor", async () => {
111
+ const inbox = await swept();
112
+ imap.renumber("INBOX", 2);
195
113
 
196
- assert.equal(updateCalls.length, 0);
114
+ await sweep();
115
+
116
+ const after = await row(inbox);
117
+ assert.equal(after.cursorState, MailboxCursorState.cursor_invalid);
118
+ assert.equal(after.highestModseq, inbox.highestModseq);
197
119
  });
198
120
 
199
- it("does not re-trip (no cursorState write) when the mailbox is already cursor_invalid", async () => {
200
- const { mailboxService, specialUseService, updateCalls } = buildServices(
201
- 1,
202
- MailboxCursorState.cursor_invalid,
203
- );
204
- const service = new MailboxSyncService(
205
- mailboxService,
206
- specialUseService,
207
- silentLogger,
208
- );
209
- const connection = buildConnection(2);
121
+ it("writes nothing when only the server mod-sequence moved", async () => {
122
+ const inbox = await swept();
123
+ imap.store("INBOX", [1], ["\\Flagged"]);
210
124
 
211
- await service.syncMailboxes({ accountId: "acc-1" }, connection);
125
+ await sweep();
212
126
 
213
- const uidValidityUpdate = updateCalls.find((c) => "uidValidity" in c);
214
- assert.ok(uidValidityUpdate);
215
- assert.equal("cursorState" in (uidValidityUpdate ?? {}), false);
127
+ assert.deepEqual(await row(inbox), inbox);
216
128
  });
217
129
  });
218
130
 
219
- describe("MailboxSyncService.syncMailboxes — reconcile does not delete pending folders (#290)", () => {
220
- const namespaces: ImapNamespaces = {
221
- personal: [{ prefix: "", delimiter: "/" }],
222
- other: [],
223
- shared: [],
224
- };
225
-
226
- // The server lists only INBOX unless a test says otherwise: neither user
227
- // folder is on it yet.
228
- const serverConnection = (
229
- folders: Array<{ fullPath: string; attributes?: string[] }> = [
230
- { fullPath: "INBOX" },
231
- ],
232
- ): IImapConnection =>
233
- ({
234
- getNamespaces: async () => namespaces,
235
- listMailboxes: async () =>
236
- folders.map((folder) => ({
237
- fullPath: folder.fullPath,
238
- name: folder.fullPath.split("/").pop() ?? folder.fullPath,
239
- delimiter: "/",
240
- attributes: folder.attributes ?? [],
241
- parentPath: null,
242
- })),
243
- getMailboxStatus: async () => ({
244
- messages: 0,
245
- recent: 0,
246
- unseen: 0,
247
- uidNext: 1,
248
- uidValidity: 1,
249
- highestModseq: "0",
250
- deletedCount: 0,
251
- }),
252
- openBox: async () => ({
253
- uidvalidity: 1,
254
- uidnext: 1,
255
- messages: { total: 0 },
256
- }),
257
- closeBox: async () => undefined,
258
- }) as unknown as IImapConnection;
259
-
260
- const buildServices = (
261
- existing: Array<{
262
- mailboxId: string;
263
- fullPath: string;
264
- syncStatus?: string;
265
- pendingPath?: string;
266
- }>,
267
- createOutcome: "Created" | "PathTaken" = "Created",
268
- ) => {
269
- const deleted: string[] = [];
270
- const created: Array<Record<string, unknown>> = [];
271
- const mailboxService = {
272
- listByAccount: async () => ({
273
- items: existing.map((m) => ({
274
- mailboxId: m.mailboxId,
275
- fullPath: m.fullPath,
276
- uidNext: 1,
277
- uidValidity: 1,
278
- messageCount: 0,
279
- unseenCount: 0,
280
- deletedCount: 0,
281
- highestModseq: "0",
282
- specialUse: undefined,
283
- syncStatus: m.syncStatus,
284
- pendingPath: m.pendingPath,
285
- })),
286
- continuationToken: undefined,
287
- }),
288
- update: async () => ({}),
289
- delete: async (_accountId: string, mailboxId: string) => {
290
- deleted.push(mailboxId);
291
- },
292
- create: async (input: Record<string, unknown>) => {
293
- created.push(input);
294
- if (createOutcome === "PathTaken") return { outcome: "PathTaken" };
295
- return {
296
- outcome: "Created",
297
- mailbox: { ...input, mailboxId: `new-${created.length}` },
298
- };
299
- },
300
- } as unknown as IMailboxRepository;
301
-
302
- const specialUseService = {
303
- listByMailboxId: async () => [],
304
- deleteByMailboxId: async () => undefined,
305
- createMany: async () => undefined,
306
- } as unknown as IMailboxSpecialUseRepository;
307
-
308
- return { mailboxService, specialUseService, deleted, created };
309
- };
310
-
131
+ describe("reconciling the folder list", () => {
311
132
  for (const syncStatus of [
312
133
  MailboxSyncStatus.pending,
313
134
  MailboxSyncStatus.deleting,
314
135
  ]) {
315
- it(`keeps a \`${syncStatus}\` folder the server has not listed`, async () => {
316
- // `pending`: the folder was just created locally and MAILBOX_CREATE has
317
- // not reached the server, so the LIST omits it — deleting the row races
318
- // the create and wedges the account's FIFO group for a visibility
319
- // window (#290).
320
- //
321
- // `deleting`: the delete has landed on the server and the worker has not
322
- // written back. Hard-deleting the row removes it under the client
323
- // holding its id — the "Mailbox not found" of #333 — and removes it
324
- // without the folder's mail, which the worker owns (D8).
325
- const { mailboxService, specialUseService, deleted } = buildServices([
326
- {
327
- mailboxId: "inbox",
328
- fullPath: "INBOX",
329
- syncStatus: MailboxSyncStatus.synced,
330
- },
331
- {
332
- mailboxId: "in-flight",
333
- fullPath: "New Folder",
334
- syncStatus,
335
- },
336
- ]);
337
- const service = new MailboxSyncService(
338
- mailboxService,
339
- specialUseService,
340
- silentLogger,
341
- );
342
-
343
- const result = await service.syncMailboxes(
344
- { accountId: "acc-1" },
345
- serverConnection(),
346
- );
347
-
348
- assert.deepEqual(deleted, []);
349
- assert.equal(result.deleted, 0);
350
- });
136
+ it(`keeps a ${syncStatus} folder the server omits or lists as \\Noselect`, async () => {
137
+ imap.addFolder("INBOX");
138
+ imap.addFolder("Half", { attributes: ["\\Noselect"] });
139
+ await inState("New Folder", syncStatus);
140
+ await inState("Half", syncStatus);
141
+
142
+ const result = await sweep();
351
143
 
352
- it(`keeps a \`${syncStatus}\` folder the server lists as \\Noselect`, async () => {
353
- // A folder mid-create can transiently LIST as `\Noselect`, and the
354
- // non-selectable branch deleted the row before looking at its state.
355
- const { mailboxService, specialUseService, deleted } = buildServices([
356
- {
357
- mailboxId: "in-flight",
358
- fullPath: "New Folder",
359
- syncStatus,
360
- },
361
- ]);
362
- const service = new MailboxSyncService(
363
- mailboxService,
364
- specialUseService,
365
- silentLogger,
366
- );
367
-
368
- await service.syncMailboxes(
369
- { accountId: "acc-1" },
370
- serverConnection([
371
- { fullPath: "INBOX" },
372
- { fullPath: "New Folder", attributes: ["\\Noselect"] },
373
- ]),
374
- );
375
-
376
- assert.deepEqual(deleted, []);
144
+ assert.equal(result.deleted, 0);
145
+ assert.deepEqual(await paths(), ["Half", "INBOX", "New Folder"]);
377
146
  });
378
147
  }
379
148
 
380
- it("does not insert a second row for a path a recorded rename claims", async () => {
381
- // D14. `fullPath` stays the path the server held when the intent was
382
- // recorded, so once the server executes RENAME the LIST returns a path no
383
- // row is keyed to. Without the pendingPath index the insert branch makes a
384
- // second row under a fresh mailboxId and initial-syncs the folder's mail
385
- // into it, leaving the original as a permanent phantom.
386
- const { mailboxService, specialUseService, deleted, created } =
387
- buildServices([
388
- {
389
- mailboxId: "inbox",
390
- fullPath: "INBOX",
391
- syncStatus: MailboxSyncStatus.synced,
392
- },
393
- {
394
- mailboxId: "renaming",
395
- fullPath: "Archive/2025",
396
- syncStatus: MailboxSyncStatus.pending,
397
- pendingPath: "Records/2025",
398
- },
399
- ]);
400
- const service = new MailboxSyncService(
401
- mailboxService,
402
- specialUseService,
403
- silentLogger,
404
- );
405
-
406
- const result = await service.syncMailboxes(
407
- { accountId: "acc-1" },
408
- serverConnection([{ fullPath: "INBOX" }, { fullPath: "Records/2025" }]),
409
- );
410
-
411
- assert.deepEqual(created, []);
412
- assert.equal(result.created, 0);
413
- assert.deepEqual(deleted, []);
414
- });
149
+ it("deletes a synced folder that has left the server", async () => {
150
+ imap.addFolder("INBOX");
151
+ await seedMailbox(store, account, "Old Folder");
415
152
 
416
- it("counts no insert and keeps the account sweep going when the path is already taken", async () => {
417
- const { mailboxService, specialUseService, created } = buildServices(
418
- [
419
- {
420
- mailboxId: "inbox",
421
- fullPath: "INBOX",
422
- syncStatus: MailboxSyncStatus.synced,
423
- },
424
- ],
425
- "PathTaken",
426
- );
427
- const errors: Array<Record<string, unknown>> = [];
428
- const service = new MailboxSyncService(mailboxService, specialUseService, {
429
- ...silentLogger,
430
- error: (obj) => {
431
- errors.push(obj);
432
- },
433
- });
434
-
435
- const result = await service.syncMailboxes(
436
- { accountId: "acc-1" },
437
- serverConnection([{ fullPath: "INBOX" }, { fullPath: "Projects" }]),
438
- );
153
+ const result = await sweep();
439
154
 
440
- assert.equal(created.length, 1);
441
- assert.equal(result.created, 0);
442
- assert.deepEqual(errors, [{ accountId: "acc-1", fullPath: "Projects" }]);
155
+ assert.equal(result.deleted, 1);
156
+ assert.deepEqual(await paths(), ["INBOX"]);
443
157
  });
444
158
 
445
- it("lets a failed rename's target be discovered as a folder of its own", async () => {
446
- // A failed rename keeps its target so the UI can name what it was aiming
447
- // at (T6), and nothing clears it until the user retries or dismisses. If
448
- // the guard honoured that claim, one stuck folder would block the sweep
449
- // from ever seeing a real folder another client later created at that
450
- // path — permanently, and silently.
451
- const { mailboxService, specialUseService, created } = buildServices([
452
- {
453
- mailboxId: "stuck",
454
- fullPath: "Archive/2025",
455
- syncStatus: MailboxSyncStatus.failed,
456
- pendingPath: "Records/2025",
457
- },
458
- ]);
459
- const service = new MailboxSyncService(
460
- mailboxService,
461
- specialUseService,
462
- silentLogger,
463
- );
159
+ it("discovers a new folder as synced", async () => {
160
+ imap.addFolder("INBOX");
464
161
 
465
- await service.syncMailboxes(
466
- { accountId: "acc-1" },
467
- serverConnection([
468
- { fullPath: "INBOX" },
469
- { fullPath: "Archive/2025" },
470
- { fullPath: "Records/2025" },
471
- ]),
472
- );
162
+ await sweep();
473
163
 
474
- assert.deepEqual(
475
- created.map((input) => input.fullPath),
476
- ["INBOX", "Records/2025"],
477
- );
164
+ assert.equal((await byPath("INBOX")).syncStatus, MailboxSyncStatus.synced);
478
165
  });
479
166
 
480
- it("writes syncStatus synced on the row it discovers", async () => {
481
- const { mailboxService, specialUseService, created } = buildServices([]);
482
- const service = new MailboxSyncService(
483
- mailboxService,
484
- specialUseService,
485
- silentLogger,
486
- );
167
+ it("inserts no second row for the path a pending rename is on its way to", async () => {
168
+ imap.addFolder("INBOX");
169
+ imap.addFolder("Records/2025");
170
+ await inState("Archive/2025", MailboxSyncStatus.pending, "Records/2025");
487
171
 
488
- await service.syncMailboxes(
489
- { accountId: "acc-1" },
490
- serverConnection([{ fullPath: "INBOX" }]),
491
- );
172
+ const result = await sweep();
492
173
 
493
- assert.equal(created.length, 1);
494
- assert.equal(created[0].syncStatus, MailboxSyncStatus.synced);
174
+ assert.equal(result.created, 1);
175
+ assert.deepEqual(await paths(), ["Archive/2025", "INBOX"]);
495
176
  });
496
177
 
497
- it("still deletes a synced folder that has left the server", async () => {
498
- // A folder that was confirmed on the server and is now gone is a genuine
499
- // server-side deletion; the reconcile must still reap it.
500
- const { mailboxService, specialUseService, deleted } = buildServices([
501
- {
502
- mailboxId: "inbox",
503
- fullPath: "INBOX",
504
- syncStatus: MailboxSyncStatus.synced,
505
- },
506
- {
507
- mailboxId: "synced-gone",
508
- fullPath: "Old Folder",
509
- syncStatus: MailboxSyncStatus.synced,
510
- },
511
- ]);
512
- const service = new MailboxSyncService(
513
- mailboxService,
514
- specialUseService,
515
- silentLogger,
516
- );
178
+ it("discovers a failed rename's target as a folder of its own", async () => {
179
+ imap.addFolder("INBOX");
180
+ imap.addFolder("Archive/2025");
181
+ imap.addFolder("Records/2025");
182
+ await inState("Archive/2025", MailboxSyncStatus.failed, "Records/2025");
517
183
 
518
- const result = await service.syncMailboxes(
519
- { accountId: "acc-1" },
520
- serverConnection(),
521
- );
184
+ await sweep();
522
185
 
523
- assert.deepEqual(deleted, ["synced-gone"]);
524
- assert.equal(result.deleted, 1);
186
+ assert.deepEqual(await paths(), ["Archive/2025", "INBOX", "Records/2025"]);
525
187
  });
526
188
  });
527
189
 
528
- describe("MailboxSyncService.syncMailboxes — a lookalike is not a folder the user keeps mail in (#837)", () => {
529
- const namespaces: ImapNamespaces = {
530
- personal: [{ prefix: "", delimiter: "/" }],
531
- other: [],
532
- shared: [],
533
- };
190
+ describe("a lookalike of a flagged folder", () => {
191
+ it("keeps folders called Deleted and Bin beside the flagged Trash", async () => {
192
+ imap.addFolder("INBOX");
193
+ imap.addFolder("Trash", { attributes: ["\\Trash"] });
194
+ imap.addFolder("Deleted");
195
+ imap.addFolder("Bin");
534
196
 
535
- const serverConnection = (
536
- folders: Array<{ fullPath: string; attributes: string[] }>,
537
- ): IImapConnection =>
538
- ({
539
- getNamespaces: async () => namespaces,
540
- listMailboxes: async () =>
541
- folders.map((folder) => ({
542
- fullPath: folder.fullPath,
543
- name: folder.fullPath.split("/").pop() ?? folder.fullPath,
544
- delimiter: "/",
545
- attributes: folder.attributes,
546
- parentPath: null,
547
- })),
548
- getMailboxStatus: async () => ({
549
- messages: 0,
550
- recent: 0,
551
- unseen: 0,
552
- uidNext: 1,
553
- uidValidity: 1,
554
- highestModseq: "0",
555
- deletedCount: 0,
556
- }),
557
- }) as unknown as IImapConnection;
558
-
559
- const buildServices = (
560
- existing: Array<{ mailboxId: string; fullPath: string }>,
561
- ) => {
562
- const deleted: string[] = [];
563
- const mailboxService = {
564
- listByAccount: async () => ({
565
- items: existing.map((m) => ({
566
- mailboxId: m.mailboxId,
567
- fullPath: m.fullPath,
568
- uidNext: 1,
569
- uidValidity: 1,
570
- messageCount: 0,
571
- unseenCount: 0,
572
- deletedCount: 0,
573
- highestModseq: "0",
574
- specialUse: undefined,
575
- syncStatus: MailboxSyncStatus.synced,
576
- })),
577
- continuationToken: undefined,
578
- }),
579
- update: async () => ({}),
580
- delete: async (_accountId: string, mailboxId: string) => {
581
- deleted.push(mailboxId);
582
- },
583
- create: async () => ({
584
- outcome: "Created",
585
- mailbox: { mailboxId: "new" },
586
- }),
587
- } as unknown as IMailboxRepository;
588
-
589
- const specialUseService = {
590
- listByMailboxId: async () => [],
591
- deleteByMailboxId: async () => undefined,
592
- createMany: async () => undefined,
593
- } as unknown as IMailboxSpecialUseRepository;
594
-
595
- return { mailboxService, specialUseService, deleted };
596
- };
597
-
598
- const syncWith = async (
599
- folders: Array<{ fullPath: string; attributes: string[] }>,
600
- existing: Array<{ mailboxId: string; fullPath: string }>,
601
- ): Promise<string[]> => {
602
- const { mailboxService, specialUseService, deleted } =
603
- buildServices(existing);
604
- const service = new MailboxSyncService(
605
- mailboxService,
606
- specialUseService,
607
- silentLogger,
608
- );
609
- await service.syncMailboxes(
610
- { accountId: "acc-1" },
611
- serverConnection(folders),
612
- );
613
- return deleted;
614
- };
197
+ await sweep();
615
198
 
616
- it("keeps a folder called Deleted beside the folder the server flagged", async () => {
617
- // `deleted` and `bin` were Trash names in this file's own copy of the hint
618
- // table long after #843 dropped them from the shared one. Every sync of an
619
- // account holding a flagged Trash and a user folder called `Deleted` took
620
- // the user's folder, and its mail, out of the client.
621
- const deleted = await syncWith(
622
- [
623
- { fullPath: "INBOX", attributes: [] },
624
- { fullPath: "Trash", attributes: ["\\Trash"] },
625
- { fullPath: "Deleted", attributes: [] },
626
- { fullPath: "Bin", attributes: [] },
627
- ],
628
- [
629
- { mailboxId: "inbox", fullPath: "INBOX" },
630
- { mailboxId: "trash", fullPath: "Trash" },
631
- { mailboxId: "keepsakes", fullPath: "Deleted" },
632
- { mailboxId: "bin", fullPath: "Bin" },
633
- ],
634
- );
635
-
636
- assert.deepEqual(deleted, []);
199
+ assert.deepEqual(await paths(), ["Bin", "Deleted", "INBOX", "Trash"]);
637
200
  });
638
201
 
639
- it("still hides the unflagged twin of a folder the server flagged", async () => {
640
- const deleted = await syncWith(
641
- [
642
- { fullPath: "INBOX", attributes: [] },
643
- { fullPath: "[Gmail]/Trash", attributes: ["\\Trash"] },
644
- { fullPath: "Trash", attributes: [] },
645
- ],
646
- [
647
- { mailboxId: "inbox", fullPath: "INBOX" },
648
- { mailboxId: "gmail-trash", fullPath: "[Gmail]/Trash" },
649
- { mailboxId: "lookalike", fullPath: "Trash" },
650
- ],
651
- );
202
+ it("hides the unflagged twin of a folder the server flagged", async () => {
203
+ imap.addFolder("INBOX");
204
+ imap.addFolder("[Gmail]/Trash", { attributes: ["\\Trash"] });
205
+ imap.addFolder("Trash");
652
206
 
653
- assert.deepEqual(deleted, ["lookalike"]);
207
+ await sweep();
208
+
209
+ assert.deepEqual(await paths(), ["[Gmail]/Trash", "INBOX"]);
654
210
  });
655
211
 
656
212
  it("keeps a folder that holds a role of its own", async () => {
657
- // `all mail` is a conventional name for Archive as well as for All, and the
658
- // account already has a flagged Archive. The folder holds the All role, so
659
- // it is a folder in its own right rather than a lookalike of Archive.
660
- const deleted = await syncWith(
661
- [
662
- { fullPath: "INBOX", attributes: [] },
663
- { fullPath: "Archive", attributes: ["\\Archive"] },
664
- { fullPath: "INBOX/All Mail", attributes: [] },
665
- ],
666
- [
667
- { mailboxId: "inbox", fullPath: "INBOX" },
668
- { mailboxId: "archive", fullPath: "Archive" },
669
- { mailboxId: "all-mail", fullPath: "INBOX/All Mail" },
670
- ],
671
- );
213
+ imap.addFolder("INBOX");
214
+ imap.addFolder("Archive", { attributes: ["\\Archive"] });
215
+ imap.addFolder("INBOX/All Mail");
672
216
 
673
- assert.deepEqual(deleted, []);
217
+ await sweep();
218
+
219
+ assert.deepEqual(await paths(), ["Archive", "INBOX", "INBOX/All Mail"]);
674
220
  });
675
221
  });
676
222
 
677
- /**
678
- * The folder set can also change under a running sweep: a delete asked for while
679
- * the account is enumerating lands between the LIST and one folder's STATUS.
680
- * Failing the whole account's enumeration over that one folder stalls every
681
- * later sync for the account, on a per-account FIFO queue, for the queue's whole
682
- * visibility window (issue #339).
683
- */
684
- describe("MailboxSyncService.syncMailboxes — a folder leaving mid-sweep (#339)", () => {
685
- const namespaces: ImapNamespaces = {
686
- personal: [{ prefix: "", delimiter: "/" }],
687
- other: [],
688
- shared: [],
223
+ describe("a folder leaving mid-sweep", () => {
224
+ const statusFailingFor = (
225
+ path: string,
226
+ before: () => Promise<unknown>,
227
+ message: string,
228
+ ) => {
229
+ const connection = imap.connection();
230
+ const status = connection.getMailboxStatus;
231
+ connection.getMailboxStatus = async (asked) => {
232
+ if (asked !== path) return status(asked);
233
+ await before();
234
+ throw new Error(message);
235
+ };
236
+ return connection;
689
237
  };
690
238
 
691
- const status = () => ({
692
- messages: 3,
693
- recent: 0,
694
- unseen: 0,
695
- uidNext: 100,
696
- uidValidity: 1,
697
- highestModseq: "0",
698
- deletedCount: 0,
239
+ it("asks no STATUS of a folder mid-mutation and leaves its row alone", async () => {
240
+ imap.addFolder("INBOX");
241
+ imap.addFolder("Doomed");
242
+ const doomed = await inState("Doomed", MailboxSyncStatus.deleting);
243
+ const connection = imap.connection();
244
+ const asked: string[] = [];
245
+ const status = connection.getMailboxStatus;
246
+ connection.getMailboxStatus = (path) => {
247
+ asked.push(path);
248
+ return status(path);
249
+ };
250
+
251
+ await sweep(connection);
252
+
253
+ assert.deepEqual(asked, ["INBOX"]);
254
+ assert.deepEqual(await row(doomed), doomed);
699
255
  });
700
256
 
701
- const buildConnection = (
702
- statusFor: (path: string) => Promise<ReturnType<typeof status>>,
703
- ): { connection: IImapConnection; statusPaths: string[] } => {
704
- const statusPaths: string[] = [];
705
- const connection = {
706
- getNamespaces: async () => namespaces,
707
- listMailboxes: async () =>
708
- ["INBOX", "Doomed"].map((fullPath) => ({
709
- fullPath,
710
- name: fullPath,
711
- delimiter: "/",
712
- attributes: [],
713
- parentPath: null,
714
- })),
715
- getMailboxStatus: async (path: string) => {
716
- statusPaths.push(path);
717
- return statusFor(path);
718
- },
719
- } as unknown as IImapConnection;
720
- return { connection, statusPaths };
721
- };
722
-
723
- const buildServices = (options: {
724
- doomedSyncStatus?: string;
725
- getDoomed?: () => Promise<unknown>;
726
- }) => {
727
- const updatedIds: string[] = [];
728
- const row = (mailboxId: string, fullPath: string, syncStatus?: string) => ({
729
- mailboxId,
730
- fullPath,
731
- uidNext: 1,
732
- uidValidity: 1,
733
- messageCount: 0,
734
- unseenCount: 0,
735
- deletedCount: 0,
736
- highestModseq: "0",
737
- specialUse: undefined,
738
- syncStatus,
739
- });
740
-
741
- const mailboxService = {
742
- listByAccount: async () => ({
743
- items: [
744
- row("mbx-inbox", "INBOX", MailboxSyncStatus.synced),
745
- row("mbx-doomed", "Doomed", options.doomedSyncStatus),
746
- ],
747
- continuationToken: undefined,
748
- }),
749
- get: async (_accountId: string, mailboxId: string) => {
750
- if (mailboxId === "mbx-doomed" && options.getDoomed) {
751
- return options.getDoomed();
752
- }
753
- return row(mailboxId, mailboxId, MailboxSyncStatus.synced);
754
- },
755
- update: async (
756
- _accountId: string,
757
- mailboxId: string,
758
- _patch: Record<string, unknown>,
759
- ) => {
760
- updatedIds.push(mailboxId);
761
- return {};
762
- },
763
- delete: async () => undefined,
764
- create: async () => ({
765
- outcome: "Created",
766
- mailbox: { mailboxId: "new" },
767
- }),
768
- } as unknown as IMailboxRepository;
769
-
770
- const specialUseService = {
771
- listByMailboxId: async () => [],
772
- deleteByMailboxId: async () => undefined,
773
- createMany: async () => undefined,
774
- } as unknown as IMailboxSpecialUseRepository;
775
-
776
- return { mailboxService, specialUseService, updatedIds };
777
- };
778
-
779
- for (const syncStatus of [
780
- MailboxSyncStatus.pending,
781
- MailboxSyncStatus.deleting,
782
- ]) {
783
- it(`leaves a \`${syncStatus}\` folder untouched — no STATUS, no write`, async () => {
784
- const { mailboxService, specialUseService, updatedIds } = buildServices({
785
- doomedSyncStatus: syncStatus,
786
- });
787
- const { connection, statusPaths } = buildConnection(async () => status());
788
- const service = new MailboxSyncService(
789
- mailboxService,
790
- specialUseService,
791
- silentLogger,
792
- );
793
-
794
- await service.syncMailboxes({ accountId: "acc-1" }, connection);
795
-
796
- assert.deepEqual(statusPaths, ["INBOX"]);
797
- assert.deepEqual(updatedIds, ["mbx-inbox"]);
798
- });
799
- }
800
-
801
- it("finishes the sweep when a folder's STATUS fails and that folder has since been deleted", async () => {
802
- const { mailboxService, specialUseService, updatedIds } = buildServices({
803
- doomedSyncStatus: MailboxSyncStatus.synced,
804
- getDoomed: async () => {
805
- throw new NotFoundError("Mailbox not found: mbx-doomed");
806
- },
807
- });
808
- const { connection } = buildConnection(async (path) => {
809
- if (path === "Doomed") throw new Error("Mailbox doesn't exist: Doomed");
810
- return status();
811
- });
812
- const service = new MailboxSyncService(
813
- mailboxService,
814
- specialUseService,
815
- silentLogger,
816
- );
257
+ it("finishes the sweep when the folder whose STATUS failed has been deleted", async () => {
258
+ imap.addFolder("INBOX");
259
+ imap.addFolder("Doomed");
260
+ await sweep();
261
+ const doomed = await byPath("Doomed");
817
262
 
818
263
  await assert.doesNotReject(
819
- service.syncMailboxes({ accountId: "acc-1" }, connection),
264
+ sweep(
265
+ statusFailingFor(
266
+ "Doomed",
267
+ () => store.mailbox.delete(account.accountId, doomed.mailboxId),
268
+ "Mailbox doesn't exist: Doomed",
269
+ ),
270
+ ),
820
271
  );
821
- assert.deepEqual(updatedIds, ["mbx-inbox"]);
822
272
  });
823
273
 
824
- it("fails the sweep when a folder's STATUS fails and the folder is still live", async () => {
825
- const { mailboxService, specialUseService } = buildServices({
826
- doomedSyncStatus: MailboxSyncStatus.synced,
827
- getDoomed: async () => ({
828
- mailboxId: "mbx-doomed",
829
- fullPath: "Doomed",
830
- syncStatus: MailboxSyncStatus.synced,
831
- }),
832
- });
833
- const { connection } = buildConnection(async (path) => {
834
- if (path === "Doomed") throw new Error("connection reset by peer");
835
- return status();
836
- });
837
- const service = new MailboxSyncService(
838
- mailboxService,
839
- specialUseService,
840
- silentLogger,
841
- );
274
+ it("fails the sweep when the folder whose STATUS failed is still live", async () => {
275
+ imap.addFolder("INBOX");
276
+ imap.addFolder("Doomed");
277
+ await sweep();
842
278
 
843
279
  await assert.rejects(
844
- service.syncMailboxes({ accountId: "acc-1" }, connection),
280
+ sweep(
281
+ statusFailingFor("Doomed", async () => {}, "connection reset by peer"),
282
+ ),
845
283
  /connection reset by peer/,
846
284
  );
847
285
  });