@remit/mailbox-service 0.0.110 → 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 +226 -200
  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 +19 -15
  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/types.ts +1 -1
  74. package/src/adversarial-mime.e2e.test.ts +0 -307
  75. package/src/body-sync-auto-read-writeonce.test.ts +0 -219
  76. package/src/body-sync-calendar-suggestion.test.ts +0 -433
  77. package/src/body-sync-category-denormalize.test.ts +0 -352
  78. package/src/body-sync-category-override.test.ts +0 -501
  79. package/src/body-sync-category-writeonce.test.ts +0 -234
  80. package/src/body-sync-filter.test.ts +0 -237
  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,389 +1,220 @@
1
1
  import assert from "node:assert/strict";
2
- import { describe, it } from "node:test";
3
- import type { IMailboxRepository, MailboxItem } from "@remit/data-ports";
2
+ import { afterEach, beforeEach, describe, it } from "node:test";
3
+ import type { AccountItem, MailboxItem } from "@remit/data-ports";
4
4
  import { MailboxSyncStatus } from "@remit/domain-enums";
5
5
  import { MailboxQueueService } from "./mailbox-queue.js";
6
+ import {
7
+ beforeNext,
8
+ captureQueue,
9
+ openStore,
10
+ type QueueCapture,
11
+ type QueuedEvent,
12
+ type Store,
13
+ seedAccount,
14
+ seedMailbox,
15
+ } from "./test-helpers/test-store.js";
16
+
17
+ let store: Store;
18
+ let capture: QueueCapture;
19
+ let queue: QueuedEvent[];
20
+ let account: AccountItem;
21
+
22
+ beforeEach(async () => {
23
+ capture = captureQueue();
24
+ queue = capture.sent;
25
+ store = await openStore();
26
+ account = await seedAccount(store);
27
+ });
28
+
29
+ afterEach(async () => {
30
+ capture.restore();
31
+ await store.close();
32
+ });
6
33
 
7
- const row = (
8
- mailboxId: string,
34
+ const folder = async (
9
35
  fullPath: string,
10
- over: Partial<MailboxItem> = {},
11
- ): MailboxItem =>
12
- ({
13
- mailboxId,
14
- accountId: "acc-1",
15
- fullPath,
16
- hierarchyDelimiter: "/",
17
- syncStatus: MailboxSyncStatus.synced,
18
- ...over,
19
- }) as MailboxItem;
20
-
21
- /**
22
- * A repository that resolves the subtree and applies `rowSet` the way the real
23
- * one does, so the paths the intent records are the paths under test.
24
- */
25
- const store = (rows: MailboxItem[]) => {
26
- const byId = new Map(rows.map((r) => [r.mailboxId, { ...r }]));
27
- const subtreeOf = (root: MailboxItem) => {
28
- const prefix = `${root.fullPath}${root.hierarchyDelimiter}`;
29
- return [...byId.values()].filter(
30
- (r) => r.mailboxId !== root.mailboxId && r.fullPath.startsWith(prefix),
31
- );
32
- };
33
-
34
- const repo: Pick<
35
- IMailboxRepository,
36
- | "get"
37
- | "transition"
38
- | "transitionSubtree"
39
- | "findByPathPrefix"
40
- | "findBySyncStatus"
41
- > = {
42
- get: (async (_accountId: string, mailboxId: string) => {
43
- const found = byId.get(mailboxId as string);
44
- if (!found) {
45
- throw Object.assign(new Error(`Mailbox not found: ${mailboxId}`), {
46
- name: "NotFoundError",
47
- });
48
- }
49
- return found;
50
- }) as IMailboxRepository["get"],
51
- findByPathPrefix: async (_accountId, pathPrefix) => {
52
- const root = [...byId.values()].find((r) => r.fullPath === pathPrefix);
53
- return root ? subtreeOf(root) : [];
36
+ state?: { syncStatus: MailboxItem["syncStatus"]; pendingPath?: string },
37
+ ): Promise<MailboxItem> => {
38
+ const mailbox = await seedMailbox(store, account, fullPath);
39
+ if (!state) return mailbox;
40
+ const moved = await store.mailbox.transition(
41
+ account.accountId,
42
+ mailbox.mailboxId,
43
+ {
44
+ from: [MailboxSyncStatus.synced],
45
+ to: state.syncStatus,
46
+ set: state.pendingPath ? { pendingPath: state.pendingPath } : {},
54
47
  },
55
- findBySyncStatus: async (_accountId, syncStatus) =>
56
- [...byId.values()].filter((r) => r.syncStatus === syncStatus),
57
- transition: async (_accountId, mailboxId, intent) => {
58
- const current = byId.get(mailboxId);
59
- if (!current) return null;
60
- if (!intent.from.includes(current.syncStatus)) return null;
61
- if (
62
- intent.wherePendingPath !== undefined &&
63
- (intent.wherePendingPath ?? undefined) !== current.pendingPath
64
- ) {
65
- return null;
66
- }
67
- const keepsTarget =
68
- intent.to === MailboxSyncStatus.pending ||
69
- intent.to === MailboxSyncStatus.failed;
70
- const next = {
71
- ...current,
72
- ...(intent.set?.fullPath !== undefined
73
- ? { fullPath: intent.set.fullPath }
74
- : {}),
75
- syncStatus: intent.to,
76
- } as MailboxItem;
77
- if (!keepsTarget) delete (next as { pendingPath?: string }).pendingPath;
78
- byId.set(mailboxId, next);
79
- return next;
80
- },
81
- transitionSubtree: async (_accountId, mailboxId, intent) => {
82
- const root = byId.get(mailboxId);
83
- if (!root) return null;
84
- const subtree = [root, ...subtreeOf(root)];
85
- // All-or-nothing, the way the real transaction is: one row that is not
86
- // in an accepted from-state refuses the whole intent and writes nothing.
87
- if (subtree.some((c) => !intent.from.includes(c.syncStatus))) return null;
88
- const written: MailboxItem[] = [];
89
- for (const candidate of subtree) {
90
- const next = {
91
- ...candidate,
92
- ...intent.rowSet(candidate),
93
- syncStatus: intent.to,
94
- } as MailboxItem;
95
- byId.set(candidate.mailboxId, next);
96
- written.push(next);
97
- }
98
- // The port promises no ordering beyond "the named folder first", so
99
- // hand the caller the least convenient order it is allowed to return.
100
- return written.reverse();
101
- },
102
- };
103
-
104
- return {
105
- repo: repo as IMailboxRepository,
106
- rowOf: (id: string) => byId.get(id),
107
- };
48
+ );
49
+ if (!moved) throw new Error(`could not put ${fullPath} in its state`);
50
+ return moved;
108
51
  };
109
52
 
110
- const queueOver = (repo: IMailboxRepository) => {
111
- const sent: string[] = [];
112
- const service = new MailboxQueueService({
113
- mailboxService: repo,
53
+ const queueService = () =>
54
+ new MailboxQueueService({
55
+ mailboxService: store.mailbox,
114
56
  sqsQueueUrl: "http://localhost:4566/000000000000/mailbox",
115
57
  });
116
- (
117
- service as unknown as {
118
- sqs: {
119
- send: (command: { input: { MessageBody: string } }) => Promise<void>;
120
- };
121
- }
122
- ).sqs = {
123
- send: async (command) => {
124
- sent.push(command.input.MessageBody);
125
- },
126
- };
127
- return { service, sent };
128
- };
129
58
 
130
- describe("MailboxQueueService.renameMailbox — the recorded intent", () => {
131
- it("records the target and leaves every confirmed path alone", async () => {
132
- // D2: `fullPath` is always a path the server holds. Writing the target onto
133
- // the row is what made a failed rename strand a subtree at paths the server
134
- // never had, which the next sweep then inserted second rows for.
135
- const { repo, rowOf } = store([
136
- row("mbx-parent", "Work"),
137
- row("mbx-child", "Work/2026"),
138
- ]);
139
- const { service } = queueOver(repo);
140
-
141
- await service.renameMailbox("mbx-parent", "Projects", "acc-1");
142
-
143
- assert.equal(rowOf("mbx-parent")?.fullPath, "Work");
144
- assert.equal(rowOf("mbx-parent")?.pendingPath, "Projects");
145
- assert.equal(rowOf("mbx-child")?.fullPath, "Work/2026");
146
- assert.equal(rowOf("mbx-child")?.pendingPath, "Projects/2026");
147
- assert.equal(rowOf("mbx-child")?.syncStatus, MailboxSyncStatus.pending);
148
- });
59
+ const rename = (mailbox: MailboxItem, newPath: string) =>
60
+ queueService().renameMailbox(mailbox.mailboxId, newPath, account.accountId);
61
+
62
+ const dismiss = (mailbox: MailboxItem) =>
63
+ queueService().dismissMailboxIntent(mailbox.mailboxId, account.accountId);
64
+
65
+ const current = (mailbox: MailboxItem) =>
66
+ store.mailbox.get(account.accountId, mailbox.mailboxId);
67
+
68
+ const rejectsWith409 = (message: string) => (error: unknown) =>
69
+ (error as { statusCode?: number }).statusCode === 409 &&
70
+ (error as Error).message === message;
71
+
72
+ describe("MailboxQueueService.renameMailbox records the intent", () => {
73
+ it("records the target over the subtree and leaves every confirmed path alone", async () => {
74
+ const parent = await folder("Work");
75
+ const child = await folder("Work/2026");
76
+ const lookalike = await folder("Workshop");
149
77
 
150
- it("records the path the RENAME will carry, `$` patterns and all", async () => {
151
- // `String.prototype.replace` expands `$&`, "$`", `$'` and `$$` in the
152
- // replacement, so a folder renamed to a name containing one of them was
153
- // stored under a different path from the one the event carries to the
154
- // server. The row and the server then disagree.
155
- const { repo, rowOf } = store([
156
- row("mbx-parent", "Work"),
157
- row("mbx-child", "Work/2026"),
158
- ]);
159
- const { service, sent } = queueOver(repo);
160
-
161
- await service.renameMailbox("mbx-parent", "Q1$&Q2", "acc-1");
162
-
163
- assert.equal(rowOf("mbx-parent")?.pendingPath, "Q1$&Q2");
164
- assert.equal(rowOf("mbx-child")?.pendingPath, "Q1$&Q2/2026");
78
+ const renamed = await rename(parent, "Projects");
79
+
80
+ assert.equal(renamed.mailboxId, parent.mailboxId);
81
+ assert.equal((await current(parent)).fullPath, "Work");
82
+ assert.equal((await current(parent)).pendingPath, "Projects");
83
+ const movedChild = await current(child);
84
+ assert.equal(movedChild.fullPath, "Work/2026");
85
+ assert.equal(movedChild.pendingPath, "Projects/2026");
86
+ assert.equal(movedChild.syncStatus, MailboxSyncStatus.pending);
165
87
  assert.equal(
166
- (JSON.parse(sent[0]) as { newPath: string }).newPath,
167
- "Q1$&Q2",
168
- "the event and the row name the same folder",
88
+ (await current(lookalike)).syncStatus,
89
+ MailboxSyncStatus.synced,
169
90
  );
91
+ assert.equal((await current(lookalike)).pendingPath, undefined);
170
92
  });
171
93
 
172
- it("moves only what is under the renamed branch", async () => {
173
- const { repo, rowOf } = store([
174
- row("mbx-parent", "Work"),
175
- row("mbx-lookalike", "Workshop"),
176
- ]);
177
- const { service } = queueOver(repo);
178
-
179
- await service.renameMailbox("mbx-parent", "Projects", "acc-1");
94
+ it("records the path the RENAME carries, `$` patterns and all", async () => {
95
+ const parent = await folder("Work");
96
+ const child = await folder("Work/2026");
180
97
 
181
- assert.equal(rowOf("mbx-parent")?.pendingPath, "Projects");
182
- assert.equal(rowOf("mbx-lookalike")?.pendingPath, undefined);
183
- assert.equal(rowOf("mbx-lookalike")?.syncStatus, MailboxSyncStatus.synced);
184
- });
98
+ await rename(parent, "Q1$&Q2");
185
99
 
186
- it("returns the folder that was asked for, not whichever row came back first", async () => {
187
- const { repo } = store([
188
- row("mbx-parent", "Work"),
189
- row("mbx-child", "Work/2026"),
190
- ]);
191
- const { service } = queueOver(repo);
192
-
193
- const renamed = await service.renameMailbox(
194
- "mbx-parent",
195
- "Projects",
196
- "acc-1",
100
+ assert.equal((await current(child)).pendingPath, "Q1$&Q2/2026");
101
+ assert.deepEqual(
102
+ queue.map((event) => event.body.newPath),
103
+ ["Q1$&Q2"],
197
104
  );
198
-
199
- assert.equal(renamed.mailboxId, "mbx-parent");
200
- assert.equal(renamed.pendingPath, "Projects");
201
105
  });
202
106
 
203
107
  it("records a retry from a failed rename onto the same row", async () => {
204
- const { repo, rowOf } = store([
205
- row("mbx-parent", "Work", {
206
- syncStatus: MailboxSyncStatus.failed,
207
- pendingPath: "Projects",
208
- }),
209
- ]);
210
- const { service } = queueOver(repo);
211
-
212
- await service.renameMailbox("mbx-parent", "Projects", "acc-1");
213
-
214
- assert.equal(rowOf("mbx-parent")?.syncStatus, MailboxSyncStatus.pending);
215
- assert.equal(rowOf("mbx-parent")?.pendingPath, "Projects");
108
+ const parent = await folder("Work", {
109
+ syncStatus: MailboxSyncStatus.failed,
110
+ pendingPath: "Projects",
111
+ });
112
+
113
+ await rename(parent, "Projects");
114
+
115
+ const retried = await current(parent);
116
+ assert.equal(retried.syncStatus, MailboxSyncStatus.pending);
117
+ assert.equal(retried.pendingPath, "Projects");
216
118
  });
217
119
 
218
120
  it("refuses the whole subtree with a 409 naming the descendant that blocks it", async () => {
219
- // A subtree cannot be half-renamed (D6), so a descendant mid-mutation
220
- // refuses the rename of everything above it — and the user has to be told
221
- // which folder, because it is not the one they named.
222
- const { repo, rowOf } = store([
223
- row("mbx-parent", "Archive"),
224
- row("mbx-child", "Archive/2025/Receipts", {
225
- syncStatus: MailboxSyncStatus.pending,
226
- pendingPath: "Archive/2025/Bonnen",
227
- }),
228
- ]);
229
- const { service, sent } = queueOver(repo);
121
+ const parent = await folder("Archive");
122
+ await folder("Archive/2025/Receipts", {
123
+ syncStatus: MailboxSyncStatus.pending,
124
+ pendingPath: "Archive/2025/Bonnen",
125
+ });
230
126
 
231
127
  await assert.rejects(
232
- service.renameMailbox("mbx-parent", "Archief", "acc-1"),
233
- (error: unknown) =>
234
- (error as { statusCode?: number }).statusCode === 409 &&
235
- (error as Error).message ===
236
- 'A rename is in progress for "Archive/2025/Receipts", inside this folder.',
128
+ rename(parent, "Archief"),
129
+ rejectsWith409(
130
+ 'A rename is in progress for "Archive/2025/Receipts", inside this folder.',
131
+ ),
237
132
  );
238
133
 
239
- assert.equal(rowOf("mbx-parent")?.syncStatus, MailboxSyncStatus.synced);
240
- assert.equal(rowOf("mbx-parent")?.pendingPath, undefined);
241
- assert.equal(sent.length, 0, "nothing is enqueued for a refused intent");
134
+ assert.equal((await current(parent)).syncStatus, MailboxSyncStatus.synced);
135
+ assert.equal(queue.length, 0);
242
136
  });
243
137
 
244
- it("refuses with a 409 naming the folder's own delete when it is the blocker", async () => {
245
- const { repo } = store([
246
- row("mbx-parent", "Archive", {
247
- syncStatus: MailboxSyncStatus.deleting,
248
- }),
249
- ]);
250
- const { service } = queueOver(repo);
138
+ it("refuses with a 409 naming the folder's own delete", async () => {
139
+ const parent = await folder("Archive", {
140
+ syncStatus: MailboxSyncStatus.deleting,
141
+ });
251
142
 
252
143
  await assert.rejects(
253
- service.renameMailbox("mbx-parent", "Archief", "acc-1"),
254
- (error: unknown) =>
255
- (error as { statusCode?: number }).statusCode === 409 &&
256
- (error as Error).message ===
257
- 'A delete is already in progress for "Archive".',
144
+ rename(parent, "Archief"),
145
+ rejectsWith409('A delete is already in progress for "Archive".'),
258
146
  );
259
147
  });
260
148
 
261
149
  it("answers 404 when the folder went between the read and the intent", async () => {
262
- const { repo } = store([row("mbx-parent", "Work")]);
263
- let read = 0;
264
- // The read that establishes existence sees the row; the classifying read
265
- // on the lost predicate does not, so the answer is 404, not a conflict.
266
- const vanishing = {
267
- ...repo,
268
- get: (async (accountId: string, mailboxId: string) => {
269
- read += 1;
270
- if (read > 1) {
271
- throw Object.assign(new Error(`Mailbox not found: ${mailboxId}`), {
272
- name: "NotFoundError",
273
- });
274
- }
275
- return repo.get(accountId, mailboxId as string);
276
- }) as IMailboxRepository["get"],
277
- transitionSubtree: async () => null,
278
- } as IMailboxRepository;
279
- const { service } = queueOver(vanishing);
150
+ const parent = await folder("Work");
151
+ beforeNext(store.mailbox, "transitionSubtree", () =>
152
+ store.mailbox.delete(account.accountId, parent.mailboxId),
153
+ );
280
154
 
281
155
  await assert.rejects(
282
- service.renameMailbox("mbx-parent", "Projects", "acc-1"),
156
+ rename(parent, "Projects"),
283
157
  (error: unknown) => (error as Error).name === "NotFoundError",
284
158
  );
285
159
  });
286
160
  });
287
161
 
288
162
  describe("MailboxQueueService.dismissMailboxIntent", () => {
289
- it("clears a failed rename's target and settles the row, enqueuing nothing", async () => {
290
- const { repo, rowOf } = store([
291
- row("mbx-1", "Work", {
292
- syncStatus: MailboxSyncStatus.failed,
293
- pendingPath: "Projects",
294
- }),
295
- ]);
296
- const { service, sent } = queueOver(repo);
297
-
298
- const dismissed = await service.dismissMailboxIntent("mbx-1", "acc-1");
163
+ it("clears a failed rename over its whole subtree and enqueues nothing", async () => {
164
+ const parent = await folder("Work", {
165
+ syncStatus: MailboxSyncStatus.failed,
166
+ pendingPath: "Projects",
167
+ });
168
+ const child = await folder("Work/2026", {
169
+ syncStatus: MailboxSyncStatus.failed,
170
+ pendingPath: "Projects/2026",
171
+ });
172
+ const other = await folder("Other", {
173
+ syncStatus: MailboxSyncStatus.failed,
174
+ pendingPath: "Projects/Old",
175
+ });
176
+
177
+ const dismissed = await dismiss(parent);
299
178
 
300
179
  assert.equal(dismissed.syncStatus, MailboxSyncStatus.synced);
301
- assert.equal(rowOf("mbx-1")?.pendingPath, undefined);
302
- assert.equal(rowOf("mbx-1")?.fullPath, "Work");
303
- assert.equal(sent.length, 0);
304
- });
305
-
306
- it("clears the whole subtree the failed rename was recorded over", async () => {
307
- // The refusal marks every row the intent recorded, so dismissing the named
308
- // folder alone leaves each descendant `failed` with a stale target on it,
309
- // offering a retry of a rename the user has already dropped and with
310
- // nothing left that would ever clear it.
311
- const { repo, rowOf } = store([
312
- row("mbx-parent", "Work", {
313
- syncStatus: MailboxSyncStatus.failed,
314
- pendingPath: "Projects",
315
- }),
316
- row("mbx-child", "Work/2026", {
317
- syncStatus: MailboxSyncStatus.failed,
318
- pendingPath: "Projects/2026",
319
- }),
320
- ]);
321
- const { service } = queueOver(repo);
322
-
323
- await service.dismissMailboxIntent("mbx-parent", "acc-1");
324
-
325
- for (const id of ["mbx-parent", "mbx-child"]) {
326
- assert.equal(rowOf(id)?.syncStatus, MailboxSyncStatus.synced);
327
- assert.equal(rowOf(id)?.pendingPath, undefined);
180
+ for (const mailbox of [parent, child]) {
181
+ const row = await current(mailbox);
182
+ assert.equal(row.syncStatus, MailboxSyncStatus.synced);
183
+ assert.equal(row.pendingPath, undefined);
328
184
  }
329
- });
330
-
331
- it("leaves a failed folder a different rename recorded alone", async () => {
332
- const { repo, rowOf } = store([
333
- row("mbx-parent", "Work", {
334
- syncStatus: MailboxSyncStatus.failed,
335
- pendingPath: "Projects",
336
- }),
337
- row("mbx-other", "Other", {
338
- syncStatus: MailboxSyncStatus.failed,
339
- pendingPath: "Projects/Old",
340
- }),
341
- ]);
342
- const { service } = queueOver(repo);
343
-
344
- await service.dismissMailboxIntent("mbx-parent", "acc-1");
345
-
346
- assert.equal(rowOf("mbx-other")?.syncStatus, MailboxSyncStatus.failed);
347
- assert.equal(rowOf("mbx-other")?.pendingPath, "Projects/Old");
185
+ assert.equal((await current(other)).pendingPath, "Projects/Old");
186
+ assert.equal(queue.length, 0);
348
187
  });
349
188
 
350
189
  it("dismisses a failed delete on the named folder alone", async () => {
351
- // No recorded target, so there is no subtree the intent was written over.
352
- const { repo, rowOf } = store([
353
- row("mbx-parent", "Work", { syncStatus: MailboxSyncStatus.failed }),
354
- row("mbx-child", "Work/2026", { syncStatus: MailboxSyncStatus.failed }),
355
- ]);
356
- const { service } = queueOver(repo);
190
+ const parent = await folder("Work", {
191
+ syncStatus: MailboxSyncStatus.failed,
192
+ });
193
+ const child = await folder("Work/2026", {
194
+ syncStatus: MailboxSyncStatus.failed,
195
+ });
357
196
 
358
- await service.dismissMailboxIntent("mbx-parent", "acc-1");
197
+ await dismiss(parent);
359
198
 
360
- assert.equal(rowOf("mbx-parent")?.syncStatus, MailboxSyncStatus.synced);
361
- assert.equal(rowOf("mbx-child")?.syncStatus, MailboxSyncStatus.failed);
199
+ assert.equal((await current(parent)).syncStatus, MailboxSyncStatus.synced);
200
+ assert.equal((await current(child)).syncStatus, MailboxSyncStatus.failed);
362
201
  });
363
202
 
364
- it("is a no-op on a folder with nothing to dismiss", async () => {
365
- const { repo } = store([row("mbx-1", "Work")]);
366
- const { service } = queueOver(repo);
367
-
368
- const dismissed = await service.dismissMailboxIntent("mbx-1", "acc-1");
203
+ it("leaves a folder with nothing to dismiss as it is", async () => {
204
+ const work = await folder("Work");
369
205
 
370
- assert.equal(dismissed.syncStatus, MailboxSyncStatus.synced);
206
+ assert.equal((await dismiss(work)).syncStatus, MailboxSyncStatus.synced);
371
207
  });
372
208
 
373
209
  it("refuses with a 409 while a mutation is still in flight", async () => {
374
- const { repo } = store([
375
- row("mbx-1", "Work", {
376
- syncStatus: MailboxSyncStatus.pending,
377
- pendingPath: "Projects",
378
- }),
379
- ]);
380
- const { service } = queueOver(repo);
210
+ const work = await folder("Work", {
211
+ syncStatus: MailboxSyncStatus.pending,
212
+ pendingPath: "Projects",
213
+ });
381
214
 
382
215
  await assert.rejects(
383
- service.dismissMailboxIntent("mbx-1", "acc-1"),
384
- (error: unknown) =>
385
- (error as { statusCode?: number }).statusCode === 409 &&
386
- (error as Error).message === 'A rename is in progress for "Work".',
216
+ dismiss(work),
217
+ rejectsWith409('A rename is in progress for "Work".'),
387
218
  );
388
219
  });
389
220
  });