@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
@@ -15,7 +15,7 @@ export const INTENT_RECORDABLE_FROM = [
15
15
  MailboxSyncStatus.failed,
16
16
  ] as const;
17
17
 
18
- const carriesIntent = (row: Pick<MailboxItem, "syncStatus">): boolean =>
18
+ export const carriesIntent = (row: Pick<MailboxItem, "syncStatus">): boolean =>
19
19
  (INTENT_RECORDABLE_FROM as readonly string[]).includes(row.syncStatus);
20
20
 
21
21
  /**
@@ -32,7 +32,7 @@ const carriesIntent = (row: Pick<MailboxItem, "syncStatus">): boolean =>
32
32
  * The identity is the pair: the row sits at or under the path the rename is
33
33
  * leaving, **and** the target it recorded is exactly what this rename's own
34
34
  * rebase would have written for it. Nothing but this intent writes that pair,
35
- * because the intent is recorded over one subtree in one transaction (D6) and
35
+ * because the intent is recorded over one subtree in one batch (D6) and
36
36
  * the transition is the only writer of either field (D3).
37
37
  */
38
38
  export const recordedByRename = (
@@ -60,6 +60,8 @@ const inFlight = (row: MailboxItem): string => {
60
60
  return `"${row.fullPath}" is still being created`;
61
61
  };
62
62
 
63
+ export const folderInFlight = (row: MailboxItem): string => `${inFlight(row)}.`;
64
+
63
65
  /**
64
66
  * Say why a folder intent lost its compare-and-set (D3).
65
67
  *
@@ -86,7 +88,7 @@ export const refuseContestedIntent = async (
86
88
  });
87
89
  if (!root) throw new NotFoundError(`Mailbox not found: ${mailboxId}`);
88
90
 
89
- if (!carriesIntent(root)) throw new ConflictError(`${inFlight(root)}.`);
91
+ if (!carriesIntent(root)) throw new ConflictError(folderInFlight(root));
90
92
 
91
93
  const descendants =
92
94
  scope === "subtree"
@@ -1,10 +1,6 @@
1
1
  import assert from "node:assert";
2
- import { describe, it } from "node:test";
3
- import type {
4
- IMailboxRepository,
5
- MailboxItem,
6
- MailboxTransitionIntent,
7
- } from "@remit/data-ports";
2
+ import { afterEach, beforeEach, describe, it } from "node:test";
3
+ import type { AccountItem, MailboxItem } from "@remit/data-ports";
8
4
  import { MailboxSyncStatus } from "@remit/domain-enums";
9
5
  import {
10
6
  MailboxManagementService,
@@ -12,11 +8,16 @@ import {
12
8
  validateMailboxOperation,
13
9
  validateMailboxPath,
14
10
  } from "./mailbox-management.js";
15
- import type {
16
- FlatMailboxInfo,
17
- IImapConnection,
18
- ImapBoxStatus,
19
- } from "./types.js";
11
+ import {
12
+ type FakeImapConnection,
13
+ FakeImapServer,
14
+ } from "./test-helpers/fake-imap.js";
15
+ import {
16
+ openStore,
17
+ type Store,
18
+ seedAccount,
19
+ seedMailbox,
20
+ } from "./test-helpers/test-store.js";
20
21
 
21
22
  describe("parseMailboxPath", () => {
22
23
  it("parses simple path", () => {
@@ -88,239 +89,125 @@ describe("validateMailboxPath", () => {
88
89
  });
89
90
  });
90
91
 
91
- const boxStatus = (path: string): ImapBoxStatus => ({
92
- name: path,
93
- readOnly: true,
94
- uidvalidity: 42,
95
- uidnext: 7,
96
- flags: [],
97
- permFlags: [],
98
- persistentUIDs: true,
99
- messages: { total: 0, new: 0 },
100
- newKeywords: false,
92
+ let store: Store;
93
+ let imap: FakeImapServer;
94
+ let account: AccountItem;
95
+
96
+ beforeEach(async () => {
97
+ store = await openStore();
98
+ imap = new FakeImapServer();
99
+ account = await seedAccount(store);
101
100
  });
102
101
 
103
- const recordingRepo = (
104
- held: Pick<MailboxItem, "mailboxId" | "fullPath">[] = [],
105
- ) => {
106
- const removed: string[] = [];
107
- const updates: Array<{ mailboxId: string; patch: Record<string, unknown> }> =
108
- [];
109
- const settles: Array<{
110
- mailboxId: string;
111
- intent: MailboxTransitionIntent;
112
- }> = [];
113
- const repo: Pick<
114
- IMailboxRepository,
115
- | "update"
116
- | "transition"
117
- | "findByPathPrefix"
118
- | "findByPath"
119
- | "deleteMailboxWithMail"
120
- > = {
121
- update: async (
122
- _accountId: string,
123
- mailboxId: string,
124
- patch: Record<string, unknown>,
125
- ) => {
126
- updates.push({ mailboxId, patch });
127
- return {} as never;
128
- },
129
- transition: async (
130
- _accountId: string,
131
- mailboxId: string,
132
- intent: MailboxTransitionIntent,
133
- ) => {
134
- settles.push({ mailboxId, intent });
135
- return {} as never;
136
- },
137
- findByPathPrefix: async () => [],
138
- findByPath: async (_accountId: string, fullPath: string) =>
139
- (held.find((row) => row.fullPath === fullPath) as MailboxItem) ?? null,
140
- deleteMailboxWithMail: async (_accountId: string, mailboxId: string) => {
141
- removed.push(mailboxId);
142
- },
143
- };
144
- return { repo: repo as IMailboxRepository, updates, settles, removed };
145
- };
146
-
147
- const stubConnection = (
148
- createdPath: string,
149
- listed: string[],
150
- ): { connection: IImapConnection; opened: string[] } => {
151
- const opened: string[] = [];
152
- const connection = {
153
- createMailbox: async (_path: string) => ({
154
- path: createdPath,
155
- created: true,
156
- }),
157
- subscribeMailbox: async () => undefined,
158
- listMailboxes: async (): Promise<FlatMailboxInfo[]> =>
159
- listed.map((fullPath) => ({
160
- fullPath,
161
- name: fullPath.split("/").pop() ?? fullPath,
162
- delimiter: "/",
163
- attributes: [],
164
- parentPath: null,
165
- })),
166
- openBox: async (path: string) => {
167
- opened.push(path);
168
- return boxStatus(path);
169
- },
170
- closeBox: async () => undefined,
171
- } as unknown as IImapConnection;
172
- return { connection, opened };
173
- };
174
-
175
- describe("MailboxManagementService.syncCreate — server path normalization", () => {
176
- it("adopts the server-materialized path as the row's identity when it is prefixed", async () => {
177
- const { repo, settles } = recordingRepo();
178
- const { connection, opened } = stubConnection("INBOX/Notifications", [
179
- "INBOX/Notifications",
180
- ]);
181
- const service = new MailboxManagementService(repo);
182
-
183
- const result = await service.syncCreate(
184
- "acc-1",
185
- "mbx-1",
186
- "Notifications",
187
- async () => connection,
188
- );
102
+ afterEach(() => store.close());
103
+
104
+ const pending = (fullPath: string) =>
105
+ seedMailbox(store, account, fullPath, {
106
+ overrides: { syncStatus: MailboxSyncStatus.pending },
107
+ });
108
+
109
+ const syncCreate = (
110
+ mailbox: MailboxItem,
111
+ connection: FakeImapConnection = imap.connection(),
112
+ subscribe?: boolean,
113
+ ) =>
114
+ new MailboxManagementService(store.mailbox).syncCreate(
115
+ account.accountId,
116
+ mailbox.mailboxId,
117
+ mailbox.fullPath,
118
+ async () => connection,
119
+ subscribe,
120
+ );
121
+
122
+ const current = (mailbox: MailboxItem) =>
123
+ store.mailbox.get(account.accountId, mailbox.mailboxId);
124
+
125
+ describe("MailboxManagementService.syncCreate", () => {
126
+ it("adopts the path the server created under its prefix, keeping the row", async () => {
127
+ imap.namespacePrefix = "INBOX/";
128
+ const requested = await pending("Notifications");
129
+
130
+ const result = await syncCreate(requested);
189
131
 
190
132
  assert.strictEqual(result.success, true);
191
- // The mailbox is opened at the server's path, not the requested leaf.
192
- assert.deepStrictEqual(opened, ["INBOX/Notifications"]);
193
- assert.strictEqual(settles.length, 1);
194
- // The row keeps its id and takes on the prefixed path, so the next reconcile
195
- // updates it in place instead of insert+delete — every reference survives.
196
- assert.strictEqual(settles[0].mailboxId, "mbx-1");
197
- assert.strictEqual(settles[0].intent.set?.fullPath, "INBOX/Notifications");
198
- assert.strictEqual(settles[0].intent.to, MailboxSyncStatus.synced);
133
+ assert.ok(imap.hasFolder("INBOX/Notifications"));
134
+ const row = await current(requested);
135
+ assert.strictEqual(row.fullPath, "INBOX/Notifications");
136
+ assert.strictEqual(row.syncStatus, MailboxSyncStatus.synced);
199
137
  });
200
138
 
201
- it("drops the pending row when the prefixed path already has a folder (#318)", async () => {
202
- const { repo, settles, updates, removed } = recordingRepo([
203
- { mailboxId: "mbx-existing", fullPath: "INBOX/Notifications" },
204
- ]);
205
- const connection = {
206
- createMailbox: async (_path: string) => ({
207
- path: "INBOX/Notifications",
208
- created: false,
209
- }),
210
- subscribeMailbox: async () => undefined,
211
- listMailboxes: async (): Promise<FlatMailboxInfo[]> => [],
212
- openBox: async (path: string) => boxStatus(path),
213
- closeBox: async () => undefined,
214
- } as unknown as IImapConnection;
215
- const service = new MailboxManagementService(repo);
216
-
217
- const result = await service.syncCreate(
218
- "acc-1",
219
- "mbx-recreated",
220
- "Notifications",
221
- async () => connection,
222
- );
139
+ it("drops the pending row when the server path already has a folder (#318)", async () => {
140
+ imap.namespacePrefix = "INBOX/";
141
+ imap.addFolder("INBOX/Notifications");
142
+ const existing = await seedMailbox(store, account, "INBOX/Notifications");
143
+ const requested = await pending("Notifications");
144
+
145
+ const result = await syncCreate(requested);
223
146
 
224
147
  assert.deepStrictEqual(result, {
225
148
  success: true,
226
- existing: { mailboxId: "mbx-existing", fullPath: "INBOX/Notifications" },
149
+ existing: {
150
+ mailboxId: existing.mailboxId,
151
+ fullPath: "INBOX/Notifications",
152
+ },
227
153
  });
228
- assert.deepStrictEqual(removed, ["mbx-recreated"]);
229
- assert.deepStrictEqual(settles, []);
230
- assert.deepStrictEqual(updates, []);
154
+ await assert.rejects(current(requested));
231
155
  });
232
156
 
233
- it("leaves the path untouched when the server keeps it as requested", async () => {
234
- const { repo, settles } = recordingRepo();
235
- const { connection } = stubConnection("Work", ["Work"]);
236
- const service = new MailboxManagementService(repo);
157
+ it("keeps the requested path when the server does", async () => {
158
+ const requested = await pending("Work");
237
159
 
238
- await service.syncCreate("acc-1", "mbx-2", "Work", async () => connection);
160
+ await syncCreate(requested);
239
161
 
240
- assert.strictEqual(settles.length, 1);
241
- assert.deepStrictEqual(settles[0].intent.set, {});
242
- assert.strictEqual(settles[0].intent.to, MailboxSyncStatus.synced);
162
+ const row = await current(requested);
163
+ assert.strictEqual(row.fullPath, "Work");
164
+ assert.strictEqual(row.syncStatus, MailboxSyncStatus.synced);
243
165
  });
244
166
 
245
167
  it("still adopts the server path when the fresh list cannot resolve it", async () => {
246
- const { repo, settles } = recordingRepo();
247
- const { connection, opened } = stubConnection("INBOX/Notifications", []);
248
- const service = new MailboxManagementService(repo);
249
-
250
- await service.syncCreate(
251
- "acc-1",
252
- "mbx-3",
253
- "Notifications",
254
- async () => connection,
255
- );
168
+ imap.namespacePrefix = "INBOX/";
169
+ const requested = await pending("Notifications");
170
+ const connection = imap.connection();
171
+ connection.listMailboxes = async () => [];
256
172
 
257
- assert.deepStrictEqual(opened, []);
258
- assert.strictEqual(settles.length, 1);
259
- assert.strictEqual(settles[0].intent.set?.fullPath, "INBOX/Notifications");
260
- assert.strictEqual(settles[0].intent.to, MailboxSyncStatus.synced);
261
- });
173
+ await syncCreate(requested, connection);
262
174
 
263
- it("falls back to the requested path when the create result carries none", async () => {
264
- const { repo, settles } = recordingRepo();
265
- const opened: string[] = [];
266
- const subscribed: string[] = [];
267
- const connection = {
268
- createMailbox: async (_path: string) => ({ created: true }),
269
- subscribeMailbox: async (p: string) => {
270
- subscribed.push(p);
271
- },
272
- listMailboxes: async (): Promise<FlatMailboxInfo[]> => [
273
- {
274
- fullPath: "Archive",
275
- name: "Archive",
276
- delimiter: "/",
277
- attributes: [],
278
- parentPath: null,
279
- },
280
- ],
281
- openBox: async (p: string) => {
282
- opened.push(p);
283
- return boxStatus(p);
284
- },
285
- closeBox: async () => undefined,
286
- } as unknown as IImapConnection;
287
- const service = new MailboxManagementService(repo);
288
-
289
- await service.syncCreate(
290
- "acc-1",
291
- "mbx-4",
292
- "Archive",
293
- async () => connection,
294
- true,
175
+ assert.strictEqual(
176
+ (await current(requested)).fullPath,
177
+ "INBOX/Notifications",
295
178
  );
179
+ });
180
+
181
+ it("falls back to the requested path when the create result names none", async () => {
182
+ const requested = await pending("Archive");
183
+ const connection = imap.connection();
184
+ const create = connection.createMailbox;
185
+ connection.createMailbox = async (path) => {
186
+ await create(path);
187
+ return { created: true } as { path: string; created: boolean };
188
+ };
189
+
190
+ await syncCreate(requested, connection, true);
296
191
 
297
- // A thin result never blanks fullPath — the requested path is used throughout.
298
- assert.deepStrictEqual(subscribed, ["Archive"]);
299
- assert.deepStrictEqual(opened, ["Archive"]);
300
- assert.strictEqual(settles.length, 1);
301
- assert.deepStrictEqual(settles[0].intent.set, {});
302
- assert.strictEqual(settles[0].intent.to, MailboxSyncStatus.synced);
192
+ assert.strictEqual(imap.isSubscribed("Archive"), true);
193
+ const row = await current(requested);
194
+ assert.strictEqual(row.fullPath, "Archive");
195
+ assert.strictEqual(row.syncStatus, MailboxSyncStatus.synced);
303
196
  });
304
197
 
305
- it("settles only a row with no rename target on it", async () => {
306
- // The predicate the seventh state turns on (D3). A create redelivered
307
- // after a lost acknowledgement, against a row a rename has since claimed,
308
- // passes a `pending`-only guard, gets `{created: false}` — which #346
309
- // correctly reads as success — and would settle `synced` with the rename
310
- // target still on it, killing the rename silently.
311
- const { repo, settles } = recordingRepo();
312
- const { connection } = stubConnection("Archive", ["Archive"]);
313
- const service = new MailboxManagementService(repo);
314
-
315
- await service.syncCreate(
316
- "acc-1",
317
- "mbx-5",
318
- "Archive",
319
- async () => connection,
320
- );
198
+ it("does not settle a row a rename has claimed since", async () => {
199
+ const requested = await pending("Archive");
200
+ await store.mailbox.transition(account.accountId, requested.mailboxId, {
201
+ from: [MailboxSyncStatus.pending],
202
+ to: MailboxSyncStatus.pending,
203
+ set: { pendingPath: "Archief" },
204
+ });
205
+
206
+ await syncCreate(requested);
321
207
 
322
- assert.strictEqual(settles[0].intent.wherePendingPath, null);
323
- assert.deepStrictEqual(settles[0].intent.from, [MailboxSyncStatus.pending]);
208
+ const row = await current(requested);
209
+ assert.strictEqual(row.syncStatus, MailboxSyncStatus.pending);
210
+ assert.strictEqual(row.pendingPath, "Archief");
324
211
  });
325
212
  });
326
213
 
@@ -783,8 +783,7 @@ export class MailboxManagementService {
783
783
  /**
784
784
  * T8: the confirmed delete takes the folder's local mail with it (D8).
785
785
  * Removing the row alone leaves every `message` and `thread_message` row
786
- * keyed to a dead mailboxId — out of every reader, and still in the search
787
- * index, because nothing writes the `message.removed` outbox event.
786
+ * keyed to a dead mailboxId — out of every reader.
788
787
  *
789
788
  * The removal is batched and the mailbox row goes last, so the row stays
790
789
  * `deleting` throughout and an SQS redelivery re-enters the handler, passes
@@ -1,16 +1,15 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { describe, it } from "node:test";
3
- import type { IMailboxRepository } from "@remit/data-ports";
4
- import { NotFoundError } from "@remit/data-ports/errors";
5
3
  import { MailboxSyncStatus } from "@remit/domain-enums";
6
4
  import {
7
5
  isFolderMutationInFlight,
8
6
  isMailboxMutationInFlight,
9
7
  } from "./mailbox-presence.js";
10
-
11
- const reader = (
12
- get: IMailboxRepository["get"],
13
- ): Pick<IMailboxRepository, "get"> => ({ get });
8
+ import {
9
+ openStore,
10
+ seedAccount,
11
+ seedMailbox,
12
+ } from "./test-helpers/test-store.js";
14
13
 
15
14
  describe("isFolderMutationInFlight", () => {
16
15
  for (const [syncStatus, inFlight] of [
@@ -38,35 +37,38 @@ describe("isFolderMutationInFlight", () => {
38
37
  });
39
38
 
40
39
  describe("isMailboxMutationInFlight", () => {
41
- it("reads the row it is given", async () => {
42
- const service = reader((async () => ({
43
- syncStatus: MailboxSyncStatus.deleting,
44
- })) as unknown as IMailboxRepository["get"]);
45
-
46
- assert.equal(await isMailboxMutationInFlight(service, "acc", "mbx"), true);
47
- });
48
-
49
- it("treats an absent row as in flight", async () => {
50
- // The row's absence does not decide it at either end: on the way out the
51
- // row is removed only after the IMAP folder is, and on the way in the row
52
- // exists before the folder does.
53
- const service = reader((async () => {
54
- throw new NotFoundError("Mailbox not found: mbx");
55
- }) as unknown as IMailboxRepository["get"]);
56
-
57
- assert.equal(await isMailboxMutationInFlight(service, "acc", "mbx"), true);
58
- });
59
-
60
- it("rethrows a read that cannot answer", async () => {
61
- // The caller uses this to classify a failure in hand; a read that fails
62
- // for its own reasons must not be reported as "the folder is busy".
63
- const service = reader((async () => {
64
- throw new Error("connection reset by peer");
65
- }) as unknown as IMailboxRepository["get"]);
40
+ it("reads the stored row, and treats an absent one as in flight", async () => {
41
+ const store = await openStore();
42
+ const account = await seedAccount(store);
43
+ const deleting = await seedMailbox(store, account, "Receipts", {
44
+ overrides: { syncStatus: MailboxSyncStatus.deleting },
45
+ });
46
+ const settled = await seedMailbox(store, account, "Work");
66
47
 
67
- await assert.rejects(
68
- isMailboxMutationInFlight(service, "acc", "mbx"),
69
- /connection reset by peer/,
48
+ assert.equal(
49
+ await isMailboxMutationInFlight(
50
+ store.mailbox,
51
+ account.accountId,
52
+ deleting.mailboxId,
53
+ ),
54
+ true,
55
+ );
56
+ assert.equal(
57
+ await isMailboxMutationInFlight(
58
+ store.mailbox,
59
+ account.accountId,
60
+ settled.mailboxId,
61
+ ),
62
+ false,
63
+ );
64
+ assert.equal(
65
+ await isMailboxMutationInFlight(
66
+ store.mailbox,
67
+ account.accountId,
68
+ "mbx-gone",
69
+ ),
70
+ true,
70
71
  );
72
+ await store.close();
71
73
  });
72
74
  });
@@ -7,7 +7,6 @@ import type {
7
7
  MailboxItem,
8
8
  } from "@remit/data-ports";
9
9
  import { NotFoundError } from "@remit/data-ports/errors";
10
- import { rebaseMailboxPath } from "@remit/data-ports/mailbox-name";
11
10
  import { MailboxSyncStatus } from "@remit/domain-enums";
12
11
  import { createQueueProducer } from "@remit/sqs-client/producer";
13
12
  import {
@@ -178,7 +177,7 @@ export class MailboxQueueService {
178
177
  const mailbox = await this.mailboxService.get(accountId, mailboxId);
179
178
  const oldPath = mailbox.fullPath;
180
179
 
181
- // One intent over the folder and every descendant, in one transaction:
180
+ // One intent over the folder and every descendant, in one batch:
182
181
  // IMAP RENAME moves the subtree in one command, so a partially recorded
183
182
  // rename is the state that produced today's phantom rows (D6). A
184
183
  // descendant already mid-mutation refuses the whole rename.
@@ -188,24 +187,7 @@ export class MailboxQueueService {
188
187
  {
189
188
  from: INTENT_RECORDABLE_FROM,
190
189
  to: MailboxSyncStatus.pending,
191
- rowSet: (row) => {
192
- const target = rebaseMailboxPath(
193
- row.fullPath,
194
- oldPath,
195
- newPath,
196
- mailbox.hierarchyDelimiter,
197
- );
198
- // The subtree is the folder and the rows under its own prefix, so
199
- // every one of them rebases. A row that does not is a resolution
200
- // bug, and recording an intent with no target for it would strand
201
- // it `pending` with nothing able to settle it.
202
- if (target === undefined) {
203
- throw new Error(
204
- `Mailbox ${row.mailboxId} at "${row.fullPath}" is not under "${oldPath}"`,
205
- );
206
- }
207
- return { pendingPath: target };
208
- },
190
+ newPath,
209
191
  },
210
192
  );
211
193
  if (!written) {