@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,408 +1,191 @@
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 { MailboxManagementService } from "./mailbox-management.js";
6
+ import { FolderGoneUpstreamError } from "./mailbox-upstream.js";
6
7
  import {
7
- FolderGoneUpstreamError,
8
- FolderRenameSettleError,
9
- } from "./mailbox-upstream.js";
10
- import type { IImapConnection } from "./types.js";
11
-
12
- /**
13
- * A folder mid-rename: the confirmed path the server still holds, and the
14
- * target the intent recorded (D2). The settle's row set is exactly the rows
15
- * carrying such a target, which is what makes it independent of whatever the
16
- * subtree looks like by the time the RENAME lands (D15).
17
- */
18
- const renaming = (
19
- mailboxId: string,
20
- fullPath: string,
21
- pendingPath: string,
22
- ): MailboxItem =>
23
- ({
24
- mailboxId,
25
- accountId: "acc-1",
26
- fullPath,
27
- hierarchyDelimiter: "/",
28
- namespacePrefix: "",
29
- syncStatus: MailboxSyncStatus.pending,
30
- pendingPath,
31
- }) as MailboxItem;
32
-
33
- const settled = (
34
- mailboxId: string,
8
+ type FakeImapConnection,
9
+ FakeImapServer,
10
+ } from "./test-helpers/fake-imap.js";
11
+ import {
12
+ beforeNext,
13
+ openStore,
14
+ type Store,
15
+ seedAccount,
16
+ seedMailbox,
17
+ } from "./test-helpers/test-store.js";
18
+
19
+ let store: Store;
20
+ let imap: FakeImapServer;
21
+ let account: AccountItem;
22
+
23
+ beforeEach(async () => {
24
+ store = await openStore();
25
+ imap = new FakeImapServer();
26
+ account = await seedAccount(store);
27
+ });
28
+
29
+ afterEach(() => store.close());
30
+
31
+ const folder = async (
35
32
  fullPath: string,
36
- syncStatus: MailboxItem["syncStatus"] = MailboxSyncStatus.synced,
37
- ): MailboxItem =>
38
- ({
39
- mailboxId,
40
- accountId: "acc-1",
41
- fullPath,
42
- hierarchyDelimiter: "/",
43
- namespacePrefix: "",
44
- syncStatus,
45
- }) as MailboxItem;
46
-
47
- const store = (rows: MailboxItem[], vanishBeforeSettle: string[] = []) => {
48
- const byId = new Map(rows.map((r) => [r.mailboxId, { ...r }]));
49
-
50
- const repo: Pick<
51
- IMailboxRepository,
52
- "get" | "update" | "transition" | "findBySyncStatus"
53
- > = {
54
- get: (async (_accountId: string, mailboxId: string) => {
55
- const found = byId.get(mailboxId as string);
56
- if (!found) {
57
- throw Object.assign(new Error(`Mailbox not found: ${mailboxId}`), {
58
- name: "NotFoundError",
59
- });
60
- }
61
- return found;
62
- }) as IMailboxRepository["get"],
63
- update: async (_accountId, mailboxId, patch) => {
64
- const existing = byId.get(mailboxId);
65
- if (!existing) {
66
- throw Object.assign(new Error(`Mailbox not found: ${mailboxId}`), {
67
- name: "NotFoundError",
68
- });
69
- }
70
- const next = { ...existing, ...patch } as MailboxItem;
71
- byId.set(mailboxId, next);
72
- return next;
73
- },
74
- transition: async (_accountId, mailboxId, intent) => {
75
- const existing = byId.get(mailboxId);
76
- if (!existing) return null;
77
- if (!intent.from.includes(existing.syncStatus)) return null;
78
- if (
79
- intent.wherePendingPath !== undefined &&
80
- (intent.wherePendingPath ?? undefined) !== existing.pendingPath
81
- ) {
82
- return null;
83
- }
84
- const keepsTarget =
85
- intent.to === MailboxSyncStatus.pending ||
86
- intent.to === MailboxSyncStatus.failed;
87
- const next = {
88
- ...existing,
89
- ...(intent.set?.fullPath !== undefined
90
- ? { fullPath: intent.set.fullPath }
91
- : {}),
92
- syncStatus: intent.to,
93
- } as MailboxItem;
94
- if (!keepsTarget) delete (next as { pendingPath?: string }).pendingPath;
95
- byId.set(mailboxId, next);
96
- return next;
97
- },
98
- findBySyncStatus: async (_accountId, syncStatus) => {
99
- const found = [...byId.values()].filter(
100
- (r) => r.syncStatus === syncStatus,
101
- );
102
- for (const mailboxId of vanishBeforeSettle) byId.delete(mailboxId);
103
- return found;
33
+ pendingPath?: string,
34
+ overrides: Partial<MailboxItem> = {},
35
+ ): Promise<MailboxItem> => {
36
+ imap.addFolder(fullPath);
37
+ const mailbox = await seedMailbox(store, account, fullPath, { overrides });
38
+ if (!pendingPath) return mailbox;
39
+ const recorded = await store.mailbox.transition(
40
+ account.accountId,
41
+ mailbox.mailboxId,
42
+ {
43
+ from: [MailboxSyncStatus.synced],
44
+ to: MailboxSyncStatus.pending,
45
+ set: { pendingPath },
104
46
  },
105
- };
47
+ );
48
+ if (!recorded) throw new Error(`could not record a rename on ${fullPath}`);
49
+ return recorded;
50
+ };
106
51
 
107
- return {
108
- repo: repo as IMailboxRepository,
109
- rowOf: (mailboxId: string) => byId.get(mailboxId),
110
- };
52
+ const current = (mailbox: MailboxItem) =>
53
+ store.mailbox.get(account.accountId, mailbox.mailboxId);
54
+
55
+ const management = () => new MailboxManagementService(store.mailbox);
56
+
57
+ const renameWork = (connection: FakeImapConnection = imap.connection()) =>
58
+ management().syncRename(
59
+ account.accountId,
60
+ workId as string,
61
+ "Work",
62
+ "Projects",
63
+ async () => connection,
64
+ );
65
+
66
+ let workId: string | undefined;
67
+
68
+ const work = async (
69
+ pendingPath: string | null = "Projects",
70
+ overrides: Partial<MailboxItem> = {},
71
+ ) => {
72
+ const mailbox = await folder("Work", pendingPath ?? undefined, overrides);
73
+ workId = mailbox.mailboxId;
74
+ return mailbox;
111
75
  };
112
76
 
113
- const connection = (newPath?: string): IImapConnection =>
114
- ({
115
- renameMailbox: async (_old: string, requested: string) => ({
116
- path: _old,
117
- newPath: newPath ?? requested,
118
- }),
119
- listMailboxes: async () => [],
120
- }) as unknown as IImapConnection;
121
-
122
- const refusing = (
123
- error: Error,
124
- listed: string[] | "unreadable" = [],
125
- ): IImapConnection =>
126
- ({
127
- renameMailbox: async () => {
128
- throw error;
129
- },
130
- listMailboxes: async () => {
131
- if (listed === "unreadable") throw new Error("IMAP connection lost");
132
- return listed.map((fullPath) => ({ fullPath, delimiter: "/" }));
133
- },
134
- }) as unknown as IImapConnection;
77
+ const assertAt = async (
78
+ mailbox: MailboxItem,
79
+ fullPath: string,
80
+ syncStatus: MailboxItem["syncStatus"],
81
+ pendingPath?: string,
82
+ ) => {
83
+ const row = await current(mailbox);
84
+ assert.equal(row.fullPath, fullPath);
85
+ assert.equal(row.syncStatus, syncStatus);
86
+ assert.equal(row.pendingPath, pendingPath);
87
+ };
135
88
 
136
- const nonexistent = (): Error =>
137
- Object.assign(new Error("Command failed"), {
138
- serverResponseCode: "NONEXISTENT",
139
- responseText: "Mailbox doesn't exist: Work",
140
- });
89
+ describe("MailboxManagementService.syncRename settles one intent", () => {
90
+ it("adopts the path the server resolved over the one requested", async () => {
91
+ const parent = await work();
92
+ const child = await folder("Work/2026", "Projects/2026");
93
+ imap.namespacePrefix = "INBOX/";
141
94
 
142
- describe("MailboxManagementService.syncRename — settling one intent", () => {
143
- it("adopts the confirmed path on the folder and every row that carried the intent", async () => {
144
- const { repo, rowOf } = store([
145
- renaming("mbx-parent", "Work", "Projects"),
146
- renaming("mbx-child", "Work/2026", "Projects/2026"),
147
- renaming("mbx-grandchild", "Work/2026/Q1", "Projects/2026/Q1"),
148
- ]);
149
- const service = new MailboxManagementService(repo);
150
-
151
- const result = await service.syncRename(
152
- "acc-1",
153
- "mbx-parent",
154
- "Work",
155
- "Projects",
156
- async () => connection(),
157
- );
95
+ await renameWork();
158
96
 
159
- assert.equal(result.success, true);
160
- for (const [id, path] of [
161
- ["mbx-parent", "Projects"],
162
- ["mbx-child", "Projects/2026"],
163
- ["mbx-grandchild", "Projects/2026/Q1"],
164
- ] as const) {
165
- assert.equal(rowOf(id)?.fullPath, path);
166
- assert.equal(rowOf(id)?.syncStatus, MailboxSyncStatus.synced);
167
- assert.equal(rowOf(id)?.pendingPath, undefined);
168
- }
97
+ await assertAt(parent, "INBOX/Projects", MailboxSyncStatus.synced);
98
+ await assertAt(child, "INBOX/Projects/2026", MailboxSyncStatus.synced);
169
99
  });
170
100
 
171
- it("adopts the path ImapFlow resolved in preference to the requested one", async () => {
172
- // Not the server echoing a name back — IMAP's RENAME reply carries none.
173
- // It is ImapFlow's own normalization, with the namespace prefix applied,
174
- // and adopting it is what stops the row and the server drifting apart.
175
- const { repo, rowOf } = store([
176
- renaming("mbx-parent", "Work", "Projects"),
177
- renaming("mbx-child", "Work/2026", "Projects/2026"),
178
- ]);
179
- const service = new MailboxManagementService(repo);
180
-
181
- await service.syncRename(
182
- "acc-1",
183
- "mbx-parent",
184
- "Work",
185
- "Projects",
186
- async () => connection("INBOX/Projects"),
101
+ it("leaves rows it must not claim alone", async () => {
102
+ const parent = await work();
103
+ const interloper = await folder("Projects/Fresh");
104
+ const elsewhere = await folder("Admin", "Beheer");
105
+ const sameBranch = await folder("Other", "Projects/Old");
106
+ const offRebase = await folder("Work/2026", "Projects/Archief");
107
+
108
+ await renameWork();
109
+
110
+ await assertAt(parent, "Projects", MailboxSyncStatus.synced);
111
+ await assertAt(interloper, "Projects/Fresh", MailboxSyncStatus.synced);
112
+ await assertAt(elsewhere, "Admin", MailboxSyncStatus.pending, "Beheer");
113
+ await assertAt(
114
+ sameBranch,
115
+ "Other",
116
+ MailboxSyncStatus.pending,
117
+ "Projects/Old",
187
118
  );
188
-
189
- assert.equal(rowOf("mbx-parent")?.fullPath, "INBOX/Projects");
190
- assert.equal(rowOf("mbx-child")?.fullPath, "INBOX/Projects/2026");
191
- });
192
-
193
- it("leaves a folder the sweep inserted under the branch after the intent alone", async () => {
194
- // D15: the settle applies to the rows that recorded the intent, and those
195
- // rows identify themselves. Re-resolving the subtree would either throw —
196
- // poisoning the account's FIFO group — or leave every row pending forever.
197
- const { repo, rowOf } = store([
198
- renaming("mbx-parent", "Work", "Projects"),
199
- settled("mbx-interloper", "Projects/Fresh"),
200
- ]);
201
- const service = new MailboxManagementService(repo);
202
-
203
- const result = await service.syncRename(
204
- "acc-1",
205
- "mbx-parent",
206
- "Work",
207
- "Projects",
208
- async () => connection(),
119
+ await assertAt(
120
+ offRebase,
121
+ "Work/2026",
122
+ MailboxSyncStatus.pending,
123
+ "Projects/Archief",
209
124
  );
210
-
211
- assert.equal(result.success, true);
212
- assert.equal(rowOf("mbx-parent")?.syncStatus, MailboxSyncStatus.synced);
213
- assert.equal(rowOf("mbx-interloper")?.fullPath, "Projects/Fresh");
214
- assert.equal(rowOf("mbx-interloper")?.syncStatus, MailboxSyncStatus.synced);
215
125
  });
216
126
 
217
- it("settles the rest when a descendant is deleted after the intent", async () => {
218
- const { repo, rowOf } = store(
219
- [
220
- renaming("mbx-parent", "Work", "Projects"),
221
- renaming("mbx-gone", "Work/Gone", "Projects/Gone"),
222
- renaming("mbx-child", "Work/2026", "Projects/2026"),
223
- ],
224
- ["mbx-gone"],
127
+ it("settles the rest when a descendant is deleted mid-settle", async () => {
128
+ const parent = await work();
129
+ const gone = await folder("Work/Gone", "Projects/Gone");
130
+ const child = await folder("Work/2026", "Projects/2026");
131
+ beforeNext(store.mailbox, "transition", () =>
132
+ store.mailbox.delete(account.accountId, gone.mailboxId),
225
133
  );
226
- const service = new MailboxManagementService(repo);
227
134
 
228
- const result = await service.syncRename(
229
- "acc-1",
230
- "mbx-parent",
231
- "Work",
232
- "Projects",
233
- async () => connection(),
234
- );
135
+ const result = await renameWork();
235
136
 
236
137
  assert.equal(result.success, true);
237
- assert.equal(rowOf("mbx-parent")?.fullPath, "Projects");
238
- assert.equal(rowOf("mbx-child")?.fullPath, "Projects/2026");
138
+ await assertAt(parent, "Projects", MailboxSyncStatus.synced);
139
+ await assertAt(child, "Projects/2026", MailboxSyncStatus.synced);
239
140
  });
240
141
 
241
- it("does not touch a folder whose own rename is aimed somewhere else", async () => {
242
- const { repo, rowOf } = store([
243
- renaming("mbx-parent", "Work", "Projects"),
244
- renaming("mbx-other", "Admin", "Beheer"),
245
- ]);
246
- const service = new MailboxManagementService(repo);
142
+ for (const [label, pendingPath] of [
143
+ ["already settled", null],
144
+ ["pending for another target", "Beheer"],
145
+ ] as const) {
146
+ it(`issues no RENAME when the row is ${label}`, async () => {
147
+ await work(pendingPath);
247
148
 
248
- await service.syncRename(
249
- "acc-1",
250
- "mbx-parent",
251
- "Work",
252
- "Projects",
253
- async () => connection(),
254
- );
255
-
256
- assert.equal(rowOf("mbx-other")?.fullPath, "Admin");
257
- assert.equal(rowOf("mbx-other")?.pendingPath, "Beheer");
258
- assert.equal(rowOf("mbx-other")?.syncStatus, MailboxSyncStatus.pending);
259
- });
260
-
261
- it("issues no RENAME when the row has already settled", async () => {
262
- const { repo } = store([settled("mbx-parent", "Projects")]);
263
- const service = new MailboxManagementService(repo);
264
- let issued = 0;
265
-
266
- const result = await service.syncRename(
267
- "acc-1",
268
- "mbx-parent",
269
- "Work",
270
- "Projects",
271
- async () =>
272
- ({
273
- renameMailbox: async () => {
274
- issued += 1;
275
- return { path: "Work", newPath: "Projects" };
276
- },
277
- }) as unknown as IImapConnection,
278
- );
149
+ const result = await renameWork();
279
150
 
280
- assert.equal(result.success, true);
281
- assert.equal(issued, 0, "a redelivery must not re-issue the rename");
282
- });
283
-
284
- it("issues no RENAME when the row is pending for a different target", async () => {
285
- // A redelivered MAILBOX_RENAME against a folder a second rename has since
286
- // claimed. `pending` alone passes; the recorded target is what refuses it,
287
- // and re-issuing from a path that has already moved is what that prevents.
288
- const { repo, rowOf } = store([renaming("mbx-parent", "Work", "Beheer")]);
289
- const service = new MailboxManagementService(repo);
290
- let issued = 0;
291
-
292
- await service.syncRename(
293
- "acc-1",
294
- "mbx-parent",
295
- "Work",
296
- "Projects",
297
- async () =>
298
- ({
299
- renameMailbox: async () => {
300
- issued += 1;
301
- return { path: "Work", newPath: "Projects" };
302
- },
303
- }) as unknown as IImapConnection,
304
- );
305
-
306
- assert.equal(issued, 0);
307
- assert.equal(rowOf("mbx-parent")?.pendingPath, "Beheer");
308
- });
151
+ assert.equal(result.success, true);
152
+ assert.ok(imap.hasFolder("Work"));
153
+ assert.equal(imap.hasFolder("Projects"), false);
154
+ });
155
+ }
309
156
  });
310
157
 
311
- describe("MailboxManagementService.syncRename — rows it must not claim", () => {
312
- it("leaves a row a different rename recorded under the same branch alone", async () => {
313
- // Two renames aimed under one path. Matching on the recorded target alone
314
- // stamps `Other` onto a path this rename never asked for, leaves its own
315
- // rename with nothing left to settle, and hands the sweep two rows
316
- // claiming one path to reap and re-insert.
317
- const { repo, rowOf } = store([
318
- renaming("mbx-parent", "Work", "Projects"),
319
- renaming("mbx-other", "Other", "Projects/Old"),
320
- ]);
321
- const service = new MailboxManagementService(repo);
322
-
323
- await service.syncRename(
324
- "acc-1",
325
- "mbx-parent",
326
- "Work",
327
- "Projects",
328
- async () => connection(),
329
- );
330
-
331
- assert.equal(rowOf("mbx-parent")?.fullPath, "Projects");
332
- assert.equal(rowOf("mbx-other")?.fullPath, "Other");
333
- assert.equal(rowOf("mbx-other")?.pendingPath, "Projects/Old");
334
- assert.equal(rowOf("mbx-other")?.syncStatus, MailboxSyncStatus.pending);
335
- });
158
+ describe("MailboxManagementService.syncRename reads a NONEXISTENT answer", () => {
159
+ it("settles when the source has moved because the rename already landed", async () => {
160
+ const parent = await work();
161
+ await imap.connection().renameMailbox("Work", "Projects");
336
162
 
337
- it("leaves a descendant whose recorded target is not this rename's rebase", async () => {
338
- const { repo, rowOf } = store([
339
- renaming("mbx-parent", "Work", "Projects"),
340
- renaming("mbx-child", "Work/2026", "Projects/Archief"),
341
- ]);
342
- const service = new MailboxManagementService(repo);
163
+ const result = await renameWork();
343
164
 
344
- await service.syncRename(
345
- "acc-1",
346
- "mbx-parent",
347
- "Work",
348
- "Projects",
349
- async () => connection(),
350
- );
351
-
352
- assert.equal(rowOf("mbx-parent")?.fullPath, "Projects");
353
- assert.equal(rowOf("mbx-child")?.fullPath, "Work/2026");
354
- assert.equal(rowOf("mbx-child")?.syncStatus, MailboxSyncStatus.pending);
165
+ assert.equal(result.success, true);
166
+ await assertAt(parent, "Projects", MailboxSyncStatus.synced);
355
167
  });
356
168
 
357
- it("fails only the rows this rename recorded", async () => {
358
- const { repo, rowOf } = store([
359
- renaming("mbx-parent", "Work", "Projects"),
360
- renaming("mbx-other", "Other", "Projects/Old"),
361
- ]);
362
- const service = new MailboxManagementService(repo);
363
-
364
- await service.failRename(
365
- "acc-1",
366
- "mbx-parent",
367
- "Work",
368
- "Projects",
369
- "Permission denied",
370
- );
371
-
372
- assert.equal(rowOf("mbx-parent")?.syncStatus, MailboxSyncStatus.failed);
373
- assert.equal(rowOf("mbx-other")?.syncStatus, MailboxSyncStatus.pending);
374
- });
375
- });
169
+ it("reads the target through the account's namespace prefix", async () => {
170
+ const parent = await work("Projects", {
171
+ hierarchyDelimiter: ".",
172
+ namespacePrefix: "INBOX.",
173
+ });
174
+ imap.removeFolder("Work");
175
+ imap.addFolder("INBOX.Projects");
376
176
 
377
- describe("MailboxManagementService.syncRename — a rename that already landed", () => {
378
- it("settles rather than deleting when NONEXISTENT means the source has moved", async () => {
379
- // A redelivery after a lost settle re-issues RENAME from a path that has
380
- // moved. Reading that NONEXISTENT as an upstream delete drops the folder
381
- // and its mail while the folder is alive at the target.
382
- const { repo, rowOf } = store([renaming("mbx-parent", "Work", "Projects")]);
383
- const service = new MailboxManagementService(repo);
384
-
385
- const result = await service.syncRename(
386
- "acc-1",
387
- "mbx-parent",
388
- "Work",
389
- "Projects",
390
- async () => refusing(nonexistent(), ["Projects"]),
391
- );
177
+ const result = await renameWork();
392
178
 
393
179
  assert.equal(result.success, true);
394
- assert.equal(rowOf("mbx-parent")?.fullPath, "Projects");
395
- assert.equal(rowOf("mbx-parent")?.syncStatus, MailboxSyncStatus.synced);
180
+ assert.equal((await current(parent)).syncStatus, MailboxSyncStatus.synced);
396
181
  });
397
182
 
398
- it("reports the folder as gone, carrying what the server said, when neither path is listed", async () => {
399
- const { repo } = store([renaming("mbx-parent", "Work", "Projects")]);
400
- const service = new MailboxManagementService(repo);
183
+ it("reports the folder gone, carrying the server's answer, when neither path is listed", async () => {
184
+ await work();
185
+ imap.removeFolder("Work");
401
186
 
402
187
  await assert.rejects(
403
- service.syncRename("acc-1", "mbx-parent", "Work", "Projects", async () =>
404
- refusing(nonexistent(), ["Andere"]),
405
- ),
188
+ renameWork(),
406
189
  (error: unknown) =>
407
190
  error instanceof FolderGoneUpstreamError &&
408
191
  (error.cause as { serverResponseCode?: string })?.serverResponseCode ===
@@ -410,144 +193,17 @@ describe("MailboxManagementService.syncRename — a rename that already landed",
410
193
  );
411
194
  });
412
195
 
413
- it("raises a settle failure as its own kind, never as a refused rename", async () => {
414
- // The server executed the rename. Marking the rows failed here would offer
415
- // a retry of work already done, on a `fullPath` the server no longer holds.
416
- const { repo, rowOf } = store([renaming("mbx-parent", "Work", "Projects")]);
417
- const broken = {
418
- ...repo,
419
- transition: async () => {
420
- throw new Error("database is locked");
421
- },
422
- } as unknown as IMailboxRepository;
423
- const service = new MailboxManagementService(broken);
424
-
425
- await assert.rejects(
426
- service.syncRename("acc-1", "mbx-parent", "Work", "Projects", async () =>
427
- connection(),
428
- ),
429
- (error: unknown) => error instanceof FolderRenameSettleError,
430
- );
431
-
432
- assert.equal(rowOf("mbx-parent")?.syncStatus, MailboxSyncStatus.pending);
433
- assert.equal(
434
- rowOf("mbx-parent")?.pendingPath,
435
- "Projects",
436
- "the intent stands, so the redelivery finishes the settle",
437
- );
438
- });
439
- });
440
-
441
- describe("MailboxManagementService.syncRename — a settle that dies part-way", () => {
442
- /**
443
- * The named folder's row is the redelivery's only witness: the guard reads
444
- * it and nothing else. Settling it before its descendants would ack a run
445
- * that stopped half way, and those rows would stay `pending` forever —
446
- * skipped by message sync, and with no route out, because an intent may only
447
- * be recorded from `synced` or `failed`.
448
- */
449
- it("leaves the named folder carrying the intent when a descendant's write fails", async () => {
450
- const { repo, rowOf } = store([
451
- renaming("mbx-parent", "Work", "Projects"),
452
- renaming("mbx-child", "Work/2026", "Projects/2026"),
453
- ]);
454
- let broken = true;
455
- const flaky = {
456
- ...repo,
457
- transition: async (
458
- accountId: string,
459
- mailboxId: string,
460
- intent: Parameters<IMailboxRepository["transition"]>[2],
461
- ) => {
462
- if (broken && mailboxId === "mbx-child") {
463
- throw new Error("database is locked");
464
- }
465
- return repo.transition(accountId, mailboxId, intent);
466
- },
467
- } as unknown as IMailboxRepository;
468
- const service = new MailboxManagementService(flaky);
469
-
470
- await assert.rejects(
471
- service.syncRename("acc-1", "mbx-parent", "Work", "Projects", async () =>
472
- connection(),
473
- ),
474
- (error: unknown) => error instanceof FolderRenameSettleError,
475
- );
476
-
477
- assert.equal(rowOf("mbx-parent")?.syncStatus, MailboxSyncStatus.pending);
478
- assert.equal(rowOf("mbx-parent")?.pendingPath, "Projects");
479
- assert.equal(rowOf("mbx-child")?.syncStatus, MailboxSyncStatus.pending);
480
-
481
- // The redelivery re-enters against an intent still standing and finishes.
482
- broken = false;
483
- const redelivered = new MailboxManagementService(flaky);
484
- const result = await redelivered.syncRename(
485
- "acc-1",
486
- "mbx-parent",
487
- "Work",
488
- "Projects",
489
- async () => connection(),
490
- );
491
-
492
- assert.equal(result.success, true);
493
- assert.equal(rowOf("mbx-parent")?.fullPath, "Projects");
494
- assert.equal(rowOf("mbx-child")?.fullPath, "Projects/2026");
495
- assert.equal(rowOf("mbx-child")?.syncStatus, MailboxSyncStatus.synced);
496
- });
497
- });
498
-
499
- describe("MailboxManagementService.syncRename — reading NONEXISTENT", () => {
500
- const prefixed = (): MailboxItem =>
501
- ({
502
- mailboxId: "mbx-parent",
503
- accountId: "acc-1",
504
- fullPath: "Work",
505
- hierarchyDelimiter: ".",
506
- namespacePrefix: "INBOX.",
507
- syncStatus: MailboxSyncStatus.pending,
508
- pendingPath: "Projects",
509
- }) as MailboxItem;
510
-
511
- it("reads the target through the account's namespace prefix", async () => {
512
- // The requested string and the path the server keeps are not the same:
513
- // a Dovecot INBOX namespace stores `Projects` as `INBOX.Projects`.
514
- // Comparing the raw request against the listing reads a folder that is
515
- // plainly there as absent — and that answer deletes the user's mail.
516
- const { repo, rowOf } = store([prefixed()]);
517
- const service = new MailboxManagementService(repo);
518
-
519
- const result = await service.syncRename(
520
- "acc-1",
521
- "mbx-parent",
522
- "Work",
523
- "Projects",
524
- async () => refusing(nonexistent(), ["INBOX.Projects"]),
525
- );
526
-
527
- assert.equal(result.success, true);
528
- assert.equal(rowOf("mbx-parent")?.syncStatus, MailboxSyncStatus.synced);
529
- });
530
-
531
- it("raises the confirmed kind only when the listing holds neither path", async () => {
532
- const { repo } = store([renaming("mbx-parent", "Work", "Projects")]);
533
- const service = new MailboxManagementService(repo);
534
-
535
- await assert.rejects(
536
- service.syncRename("acc-1", "mbx-parent", "Work", "Projects", async () =>
537
- refusing(nonexistent(), ["Andere"]),
538
- ),
539
- (error: unknown) => error instanceof FolderGoneUpstreamError,
540
- );
541
- });
542
-
543
- it("refuses the rename rather than confirming a delete when the source is still listed", async () => {
544
- const { repo } = store([renaming("mbx-parent", "Work", "Projects")]);
545
- const service = new MailboxManagementService(repo);
196
+ it("refuses rather than confirming a delete when the source is still listed", async () => {
197
+ await work();
198
+ const connection = imap.connection();
199
+ connection.renameMailbox = async () => {
200
+ throw Object.assign(new Error("Command failed"), {
201
+ serverResponseCode: "NONEXISTENT",
202
+ });
203
+ };
546
204
 
547
205
  await assert.rejects(
548
- service.syncRename("acc-1", "mbx-parent", "Work", "Projects", async () =>
549
- refusing(nonexistent(), ["Work"]),
550
- ),
206
+ renameWork(connection),
551
207
  (error: unknown) =>
552
208
  !(error instanceof FolderGoneUpstreamError) &&
553
209
  (error as { serverResponseCode?: string }).serverResponseCode ===
@@ -555,110 +211,77 @@ describe("MailboxManagementService.syncRename — reading NONEXISTENT", () => {
555
211
  );
556
212
  });
557
213
 
558
- it("refuses the rename when the listing cannot be read at all", async () => {
559
- // A probe that answers nothing decides nothing. A refused rename costs a
560
- // retry; reading an unclear answer as a delete costs the folder's mail.
561
- const { repo } = store([renaming("mbx-parent", "Work", "Projects")]);
562
- const service = new MailboxManagementService(repo);
214
+ it("refuses rather than confirming a delete when the listing cannot be read", async () => {
215
+ await work();
216
+ imap.removeFolder("Work");
217
+ const connection = imap.connection();
218
+ connection.listMailboxes = async () => {
219
+ throw new Error("IMAP connection lost");
220
+ };
563
221
 
564
222
  await assert.rejects(
565
- service.syncRename("acc-1", "mbx-parent", "Work", "Projects", async () =>
566
- refusing(nonexistent(), "unreadable"),
567
- ),
223
+ renameWork(connection),
568
224
  (error: unknown) => !(error instanceof FolderGoneUpstreamError),
569
225
  );
570
226
  });
571
227
  });
572
228
 
573
229
  describe("MailboxManagementService.failRename", () => {
574
- it("marks every intent-carrying row failed, keeping its path and its target", async () => {
575
- const { repo, rowOf } = store([
576
- renaming("mbx-parent", "Work", "Projects"),
577
- renaming("mbx-child", "Work/2026", "Projects/2026"),
578
- ]);
579
- const service = new MailboxManagementService(repo);
580
-
581
- await service.failRename(
582
- "acc-1",
583
- "mbx-parent",
230
+ const fail = () =>
231
+ management().failRename(
232
+ account.accountId,
233
+ workId as string,
584
234
  "Work",
585
235
  "Projects",
586
236
  "Permission denied",
587
237
  );
588
238
 
589
- for (const [id, path, target] of [
590
- ["mbx-parent", "Work", "Projects"],
591
- ["mbx-child", "Work/2026", "Projects/2026"],
592
- ] as const) {
593
- assert.equal(rowOf(id)?.syncStatus, MailboxSyncStatus.failed);
594
- assert.equal(rowOf(id)?.fullPath, path, "nothing is restored (D2)");
595
- assert.equal(
596
- rowOf(id)?.pendingPath,
597
- target,
598
- "the target is kept so the client can name it and offer a retry",
599
- );
600
- }
239
+ it("marks every row this rename recorded failed, keeping path and target", async () => {
240
+ const parent = await work();
241
+ const child = await folder("Work/2026", "Projects/2026");
242
+ const other = await folder("Other", "Projects/Old");
243
+
244
+ await fail();
245
+
246
+ await assertAt(parent, "Work", MailboxSyncStatus.failed, "Projects");
247
+ await assertAt(
248
+ child,
249
+ "Work/2026",
250
+ MailboxSyncStatus.failed,
251
+ "Projects/2026",
252
+ );
253
+ await assertAt(other, "Other", MailboxSyncStatus.pending, "Projects/Old");
601
254
  });
602
255
 
603
- it("raises a NotFoundError when the folder row itself is gone", async () => {
604
- const { repo } = store([]);
605
- const service = new MailboxManagementService(repo);
256
+ it("raises a NotFoundError when the folder row is gone", async () => {
257
+ workId = "mbx-gone";
606
258
 
607
259
  await assert.rejects(
608
- service.failRename(
609
- "acc-1",
610
- "mbx-parent",
611
- "Work",
612
- "Projects",
613
- "Permission denied",
614
- ),
260
+ fail(),
615
261
  (error: unknown) => (error as Error).name === "NotFoundError",
616
262
  );
617
263
  });
618
264
 
619
- it("records nothing, quietly, when the row is there but carries no intent", async () => {
620
- // Never a NotFoundError: the caller reads that as the user having deleted
621
- // the folder and acks the message, which would swallow the IMAP failure
622
- // that brought us here and leave the subtree `pending` with no retry.
623
- const { repo } = store([settled("mbx-parent", "Work")]);
624
- const service = new MailboxManagementService(repo);
625
-
626
- await assert.doesNotReject(
627
- service.failRename(
628
- "acc-1",
629
- "mbx-parent",
630
- "Work",
631
- "Projects",
632
- "Permission denied",
633
- ),
634
- );
265
+ it("records nothing, quietly, for a row that carries no intent", async () => {
266
+ const parent = await work(null);
267
+
268
+ await fail();
269
+
270
+ await assertAt(parent, "Work", MailboxSyncStatus.synced);
635
271
  });
636
272
 
637
- it("settles a rename ImapFlow never issued as a refusal, not a TypeError", async () => {
638
- // `mailboxRename` resolves `undefined` when the connection is not
639
- // AUTHENTICATED or SELECTED. The settle reads the resolved path, so an
640
- // unguarded dereference would raise a TypeError the rename cannot classify.
641
- const { repo, rowOf } = store([renaming("mbx-parent", "Work", "Projects")]);
642
- const service = new MailboxManagementService(repo);
643
- const notReady = new Error(
644
- 'Rename of "Work" was not issued: the IMAP connection is not ready',
645
- );
273
+ it("fails a rename the connection never issued as a refusal", async () => {
274
+ const parent = await work();
275
+ const connection = imap.connection();
276
+ connection.renameMailbox = async () => {
277
+ throw new Error(
278
+ 'Rename of "Work" was not issued: the IMAP connection is not ready',
279
+ );
280
+ };
646
281
 
647
- await assert.rejects(
648
- service.syncRename("acc-1", "mbx-parent", "Work", "Projects", async () =>
649
- refusing(notReady),
650
- ),
651
- /not ready/,
652
- );
282
+ await assert.rejects(renameWork(connection), /not ready/);
283
+ await fail();
653
284
 
654
- await service.failRename(
655
- "acc-1",
656
- "mbx-parent",
657
- "Work",
658
- "Projects",
659
- "Permission denied",
660
- );
661
- assert.equal(rowOf("mbx-parent")?.syncStatus, MailboxSyncStatus.failed);
662
- assert.equal(rowOf("mbx-parent")?.fullPath, "Work");
285
+ await assertAt(parent, "Work", MailboxSyncStatus.failed, "Projects");
663
286
  });
664
287
  });