@remit/mailbox-service 0.0.111 → 0.0.112

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/package.json +21 -6
  2. package/src/body-part-mapper.ts +51 -1
  3. package/src/body-sync-queue.test.ts +39 -99
  4. package/src/body-sync-terminal.test.ts +115 -277
  5. package/src/body-sync-terminal.ts +10 -12
  6. package/src/body-sync.test.ts +925 -0
  7. package/src/body-sync.ts +156 -235
  8. package/src/classification-backfill.test.ts +197 -527
  9. package/src/classify-message.test.ts +11 -74
  10. package/src/external-move.test.ts +102 -209
  11. package/src/external-move.ts +13 -0
  12. package/src/filter-operations.test.ts +319 -0
  13. package/src/filter-operations.ts +377 -0
  14. package/src/filter-update.test.ts +313 -0
  15. package/src/filters/filter-config.test.ts +55 -197
  16. package/src/filters/filter-config.ts +4 -39
  17. package/src/filters/match.test.ts +1 -38
  18. package/src/filters/match.ts +3 -52
  19. package/src/filters/pipeline.test.ts +158 -289
  20. package/src/filters/pipeline.ts +56 -170
  21. package/src/flag-push-terminal.ts +9 -11
  22. package/src/flag-push.test.ts +76 -372
  23. package/src/flag-queue.test.ts +197 -0
  24. package/src/folder-operations.test.ts +273 -0
  25. package/src/folder-operations.ts +217 -0
  26. package/src/forwarded-message.test.ts +122 -0
  27. package/src/imapflow-connect-retry.test.ts +76 -113
  28. package/src/imapflow-connection.test.ts +34 -0
  29. package/src/imapflow-connection.ts +5 -4
  30. package/src/index.ts +59 -12
  31. package/src/list-id-backfill.test.ts +133 -499
  32. package/src/mailbox-cursor.test.ts +81 -189
  33. package/src/mailbox-delete-intent.test.ts +78 -205
  34. package/src/mailbox-intent.ts +5 -3
  35. package/src/mailbox-management.test.ts +107 -220
  36. package/src/mailbox-management.ts +1 -2
  37. package/src/mailbox-presence.test.ts +36 -34
  38. package/src/mailbox-queue.ts +2 -20
  39. package/src/mailbox-rename-intent.test.ts +156 -325
  40. package/src/mailbox-rename-subtree.test.ts +205 -582
  41. package/src/mailbox-sync.test.ts +182 -744
  42. package/src/message-move.test.ts +726 -0
  43. package/src/message-move.ts +227 -57
  44. package/src/message-read.ts +87 -0
  45. package/src/message-save-retry.test.ts +90 -0
  46. package/src/message-sync-flags.test.ts +10 -1
  47. package/src/message-sync.test.ts +762 -0
  48. package/src/message-sync.ts +409 -239
  49. package/src/mime-walker.ts +16 -15
  50. package/src/organize-job.ts +93 -0
  51. package/src/outbox-attachment.test.ts +97 -209
  52. package/src/outbox-failed-recovery.test.ts +198 -269
  53. package/src/outbox-queue.test.ts +24 -0
  54. package/src/outbox-queue.ts +25 -8
  55. package/src/ownership.ts +140 -0
  56. package/src/placement-move-terminal.ts +9 -12
  57. package/src/placement-move.ts +33 -6
  58. package/src/placement-settled.ts +28 -4
  59. package/src/placement.test.ts +404 -0
  60. package/src/quarantine.test.ts +90 -148
  61. package/src/quarantine.ts +3 -2
  62. package/src/refusal.ts +29 -0
  63. package/src/restore-source-placement.ts +0 -4
  64. package/src/spam-report.test.ts +238 -710
  65. package/src/sync-warning.test.ts +213 -0
  66. package/src/sync-warning.ts +56 -0
  67. package/src/test-helpers/fake-imap.ts +690 -0
  68. package/src/test-helpers/test-queue.ts +56 -0
  69. package/src/test-helpers/test-store.ts +299 -0
  70. package/src/thread-root.test.ts +29 -0
  71. package/src/thread-root.ts +51 -0
  72. package/src/types/mailparser-augment.d.ts +8 -0
  73. package/src/adversarial-mime.e2e.test.ts +0 -307
  74. package/src/body-sync-auto-read-writeonce.test.ts +0 -219
  75. package/src/body-sync-calendar-suggestion.test.ts +0 -433
  76. package/src/body-sync-category-denormalize.test.ts +0 -352
  77. package/src/body-sync-category-override.test.ts +0 -501
  78. package/src/body-sync-category-writeonce.test.ts +0 -234
  79. package/src/body-sync-filter.test.ts +0 -237
  80. package/src/body-sync-message-gone.test.ts +0 -139
  81. package/src/body-sync-placement-flags.test.ts +0 -347
  82. package/src/body-sync-placement-writeonce.test.ts +0 -337
  83. package/src/body-sync-quarantine.test.ts +0 -276
  84. package/src/body-sync-resync.test.ts +0 -605
  85. package/src/body-sync-unclassifiable-once.test.ts +0 -260
  86. package/src/body-sync-unsubscribed-auto-read.test.ts +0 -176
  87. package/src/filters/anchor-drift.ts +0 -55
  88. package/src/filters/message-embedder.test.ts +0 -39
  89. package/src/filters/message-embedder.ts +0 -33
  90. package/src/flag-push-terminal.test.ts +0 -209
  91. package/src/flag-queue-stars.test.ts +0 -171
  92. package/src/imapflow-connection.e2e.test.ts +0 -335
  93. package/src/imapflow-connection.integ.test.ts +0 -316
  94. package/src/message-delete-junk-reconcile.test.ts +0 -117
  95. package/src/message-delete-no-trash.test.ts +0 -245
  96. package/src/message-delete-unsettled-placement.test.ts +0 -396
  97. package/src/message-move-copy.test.ts +0 -562
  98. package/src/message-move-empty-trash.test.ts +0 -326
  99. package/src/message-move-same-mailbox.test.ts +0 -139
  100. package/src/message-move-unsettled-placement.test.ts +0 -378
  101. package/src/message-sync-address-spoof.test.ts +0 -293
  102. package/src/message-sync-category.test.ts +0 -142
  103. package/src/message-sync-changedsince.test.ts +0 -840
  104. package/src/message-sync-junk-harvest.test.ts +0 -244
  105. package/src/message-sync-quarantine.test.ts +0 -416
  106. package/src/message-sync-repoint.test.ts +0 -419
  107. package/src/outbox-queue-settled-row.test.ts +0 -140
  108. package/src/pass-through-unit-of-work.ts +0 -17
  109. package/src/placement-move-terminal.test.ts +0 -300
  110. package/src/placement-move-unsettled.test.ts +0 -275
  111. package/src/placement-transition-race.test.ts +0 -482
  112. package/src/stale-message-reconcile.test.ts +0 -82
  113. package/src/stale-message-reconcile.ts +0 -55
  114. package/src/test-helpers/isolated-mailbox.ts +0 -44
  115. package/src/test-helpers/mailfuzz-connection.ts +0 -27
  116. package/test/fixtures/mime-adversarial-e2e/01-octet-stream-pdf.eml +0 -21
  117. package/test/fixtures/mime-adversarial-e2e/02-nested-related-cid-image.eml +0 -31
  118. package/test/fixtures/mime-adversarial-e2e/03-calendar-invite.eml +0 -33
  119. package/test/fixtures/mime-adversarial-e2e/04-forwarded-rfc822-with-attachments.eml +0 -42
  120. package/test/fixtures/mime-adversarial-e2e/05-tnef-winmail.eml +0 -25
  121. package/test/fixtures/mime-adversarial-e2e/06-8bit-non-ascii.eml +0 -15
  122. package/test/fixtures/mime-adversarial-e2e/07-quoted-printable-soft-breaks.eml +0 -17
  123. package/test/fixtures/mime-adversarial-e2e/08-alternative-empty-text.eml +0 -18
@@ -1,6 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
- import { describe, it, mock } 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 { MailboxCursorState } from "@remit/domain-enums";
5
5
  import {
6
6
  guardConnectionCursor,
@@ -8,229 +8,121 @@ import {
8
8
  isCursorRebuildNeeded,
9
9
  MailboxCursorPausedError,
10
10
  } from "./mailbox-cursor.js";
11
- import type { IImapConnection, ImapBoxStatus } from "./types.js";
12
-
13
- const mailboxId = "mbx-1";
14
- const accountId = "acc-1";
15
-
16
- const baseMailbox: Pick<MailboxItem, "mailboxId" | "uidValidity"> & {
17
- cursorState: MailboxItem["cursorState"] | undefined;
18
- } = {
19
- mailboxId,
20
- uidValidity: 100,
21
- // A pre-migration row genuinely lacks this attribute at runtime despite the
22
- // type saying it's total — see mailbox-cursor.ts for why `undefined` is
23
- // accepted here.
24
- cursorState: undefined,
25
- };
26
-
27
- describe("guardMailboxCursor", () => {
28
- it("is ok and writes nothing when the served UIDVALIDITY matches the stored value", async () => {
29
- const update = mock.fn(async () => ({}) as MailboxItem);
30
- const mailboxService = {
31
- update,
32
- } as unknown as Pick<IMailboxRepository, "update">;
33
-
34
- const result = await guardMailboxCursor(
35
- { mailboxService },
36
- accountId,
37
- baseMailbox,
38
- 100,
39
- );
40
-
41
- assert.deepEqual(result, { ok: true });
42
- assert.equal(update.mock.calls.length, 0);
11
+ import { eml, FakeImapServer } from "./test-helpers/fake-imap.js";
12
+ import {
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
+ let inbox: MailboxItem;
23
+
24
+ beforeEach(async () => {
25
+ store = await openStore();
26
+ imap = new FakeImapServer();
27
+ imap.addFolder("INBOX", { uidValidity: 100 });
28
+ account = await seedAccount(store);
29
+ inbox = await seedMailbox(store, account, "INBOX", {
30
+ overrides: { uidValidity: 100 },
43
31
  });
32
+ });
44
33
 
45
- it("treats an absent cursorState as normal", async () => {
46
- const update = mock.fn(async () => ({}) as MailboxItem);
47
- const mailboxService = { update } as unknown as Pick<
48
- IMailboxRepository,
49
- "update"
50
- >;
51
-
52
- const result = await guardMailboxCursor(
53
- { mailboxService },
54
- accountId,
55
- { ...baseMailbox, cursorState: undefined },
56
- 100,
57
- );
34
+ afterEach(() => store.close());
58
35
 
59
- assert.deepEqual(result, { ok: true });
60
- });
36
+ const stored = () => store.mailbox.get(account.accountId, inbox.mailboxId);
61
37
 
62
- it("trips cursor_invalid and persists it when the served UIDVALIDITY disagrees", async () => {
63
- const update = mock.fn(async () => ({}) as MailboxItem);
64
- const mailboxService = { update } as unknown as Pick<
65
- IMailboxRepository,
66
- "update"
67
- >;
68
-
69
- const result = await guardMailboxCursor(
70
- { mailboxService },
71
- accountId,
72
- { ...baseMailbox, uidValidity: 100 },
73
- 200,
74
- );
38
+ const pause = (cursorState: MailboxItem["cursorState"]) =>
39
+ store.mailbox.update(account.accountId, inbox.mailboxId, { cursorState });
75
40
 
76
- assert.deepEqual(result, { ok: false, state: "cursor_invalid" });
77
- assert.equal(update.mock.calls.length, 1);
78
- assert.deepEqual(update.mock.calls[0].arguments, [
79
- accountId,
80
- mailboxId,
81
- { cursorState: MailboxCursorState.cursor_invalid },
82
- ]);
83
- });
41
+ const guard = async (served: number) =>
42
+ guardMailboxCursor(
43
+ { mailboxService: store.mailbox },
44
+ account.accountId,
45
+ await stored(),
46
+ served,
47
+ );
84
48
 
85
- it("short-circuits without a write when already cursor_invalid, even if UIDVALIDITY now matches", async () => {
86
- const update = mock.fn(async () => ({}) as MailboxItem);
87
- const mailboxService = { update } as unknown as Pick<
88
- IMailboxRepository,
89
- "update"
90
- >;
91
-
92
- const result = await guardMailboxCursor(
93
- { mailboxService },
94
- accountId,
95
- { ...baseMailbox, cursorState: MailboxCursorState.cursor_invalid },
96
- 100,
97
- );
49
+ describe("guardMailboxCursor", () => {
50
+ it("passes a mailbox whose served UIDVALIDITY matches", async () => {
51
+ assert.deepEqual(await guard(100), { ok: true });
52
+ assert.equal((await stored()).cursorState, MailboxCursorState.normal);
53
+ });
98
54
 
99
- assert.deepEqual(result, { ok: false, state: "cursor_invalid" });
55
+ it("trips and persists cursor_invalid when the served UIDVALIDITY disagrees", async () => {
56
+ assert.deepEqual(await guard(200), { ok: false, state: "cursor_invalid" });
100
57
  assert.equal(
101
- update.mock.calls.length,
102
- 0,
103
- "paused mailboxes must not be re-tripped on every call (frugal — invariant 6)",
58
+ (await stored()).cursorState,
59
+ MailboxCursorState.cursor_invalid,
104
60
  );
105
61
  });
106
62
 
107
- it("short-circuits without a write when rebuilding", async () => {
108
- const update = mock.fn(async () => ({}) as MailboxItem);
109
- const mailboxService = { update } as unknown as Pick<
110
- IMailboxRepository,
111
- "update"
112
- >;
113
-
114
- const result = await guardMailboxCursor(
115
- { mailboxService },
116
- accountId,
117
- { ...baseMailbox, cursorState: MailboxCursorState.rebuilding },
118
- 999,
119
- );
120
-
121
- assert.deepEqual(result, { ok: false, state: "rebuilding" });
122
- assert.equal(update.mock.calls.length, 0);
123
- });
63
+ for (const state of [
64
+ MailboxCursorState.cursor_invalid,
65
+ MailboxCursorState.rebuilding,
66
+ ]) {
67
+ it(`stays paused while ${state}, whatever the server serves`, async () => {
68
+ await pause(state);
69
+
70
+ assert.deepEqual(await guard(100), { ok: false, state });
71
+ assert.equal((await stored()).cursorState, state);
72
+ });
73
+ }
124
74
  });
125
75
 
126
76
  describe("guardConnectionCursor", () => {
127
- const fakeBoxStatus = (uidvalidity: number): ImapBoxStatus =>
128
- ({
129
- uidvalidity,
130
- }) as unknown as ImapBoxStatus;
131
-
132
- it("delegates to the real openBox and returns normally when normal and matching", async () => {
133
- const openBox = mock.fn(async () => fakeBoxStatus(100));
134
- const connection = { openBox } as unknown as IImapConnection;
135
- const update = mock.fn(async () => ({}) as MailboxItem);
136
- const mailboxService = { update } as unknown as Pick<
137
- IMailboxRepository,
138
- "update"
139
- >;
140
-
141
- const guarded = guardConnectionCursor(
142
- connection,
143
- { mailboxService },
144
- accountId,
145
- { ...baseMailbox, uidValidity: 100 },
77
+ const guarded = async () =>
78
+ guardConnectionCursor(
79
+ imap.connection(),
80
+ { mailboxService: store.mailbox },
81
+ account.accountId,
82
+ await stored(),
146
83
  );
147
84
 
148
- const result = await guarded.openBox("INBOX", false);
85
+ it("opens the box and passes other calls through when the cursor holds", async () => {
86
+ imap.deliver("INBOX", eml({}));
87
+ const connection = await guarded();
149
88
 
150
- assert.deepEqual(result, fakeBoxStatus(100));
151
- assert.equal(openBox.mock.calls.length, 1);
152
- assert.equal(update.mock.calls.length, 0);
153
- });
89
+ const box = await connection.openBox("INBOX");
90
+ const rows = await connection.fetchMessages([1]);
154
91
 
155
- it("throws MailboxCursorPausedError and never calls the real openBox when already paused", async () => {
156
- const openBox = mock.fn(async () => fakeBoxStatus(100));
157
- const connection = { openBox } as unknown as IImapConnection;
158
- const mailboxService = {
159
- update: mock.fn(async () => ({}) as MailboxItem),
160
- } as unknown as Pick<IMailboxRepository, "update">;
161
-
162
- const guarded = guardConnectionCursor(
163
- connection,
164
- { mailboxService },
165
- accountId,
166
- { ...baseMailbox, cursorState: MailboxCursorState.cursor_invalid },
92
+ assert.equal(box.uidvalidity, 100);
93
+ assert.deepEqual(
94
+ rows.map((row) => row.uid),
95
+ [1],
167
96
  );
97
+ });
98
+
99
+ it("refuses to open a mailbox already paused", async () => {
100
+ await pause(MailboxCursorState.cursor_invalid);
101
+ const connection = await guarded();
168
102
 
169
103
  await assert.rejects(
170
- () => guarded.openBox("INBOX"),
104
+ connection.openBox("INBOX"),
171
105
  (err: unknown) =>
172
106
  err instanceof MailboxCursorPausedError &&
173
107
  err.state === "cursor_invalid",
174
108
  );
175
- assert.equal(
176
- openBox.mock.calls.length,
177
- 0,
178
- "a mailbox already known paused must not touch the network (frugal — invariant 6)",
179
- );
180
109
  });
181
110
 
182
- it("trips and throws MailboxCursorPausedError when the served UIDVALIDITY disagrees", async () => {
183
- const openBox = mock.fn(async () => fakeBoxStatus(200));
184
- const connection = { openBox } as unknown as IImapConnection;
185
- const update = mock.fn(async () => ({}) as MailboxItem);
186
- const mailboxService = { update } as unknown as Pick<
187
- IMailboxRepository,
188
- "update"
189
- >;
190
-
191
- const guarded = guardConnectionCursor(
192
- connection,
193
- { mailboxService },
194
- accountId,
195
- { ...baseMailbox, uidValidity: 100 },
196
- );
111
+ it("trips the mailbox and refuses when the server renumbered it", async () => {
112
+ const connection = await guarded();
113
+ imap.renumber("INBOX", 200);
197
114
 
198
115
  await assert.rejects(
199
- () => guarded.openBox("INBOX"),
116
+ connection.openBox("INBOX"),
200
117
  (err: unknown) =>
201
118
  err instanceof MailboxCursorPausedError &&
202
119
  err.state === "cursor_invalid",
203
120
  );
204
121
  assert.equal(
205
- openBox.mock.calls.length,
206
- 1,
207
- "the real openBox must run once to learn the served UIDVALIDITY",
208
- );
209
- assert.equal(
210
- update.mock.calls.length,
211
- 1,
212
- "the mismatch must trip the mailbox",
122
+ (await stored()).cursorState,
123
+ MailboxCursorState.cursor_invalid,
213
124
  );
214
125
  });
215
-
216
- it("passes other methods through untouched (e.g. fetchMessages)", async () => {
217
- const fetchMessages = mock.fn(async () => []);
218
- const connection = {
219
- openBox: async () => fakeBoxStatus(100),
220
- fetchMessages,
221
- } as unknown as IImapConnection;
222
-
223
- const guarded = guardConnectionCursor(
224
- connection,
225
- { mailboxService: { update: async () => ({}) as MailboxItem } },
226
- accountId,
227
- { ...baseMailbox, uidValidity: 100 },
228
- );
229
-
230
- await guarded.fetchMessages([1, 2, 3]);
231
- assert.equal(fetchMessages.mock.calls.length, 1);
232
- assert.deepEqual(fetchMessages.mock.calls[0].arguments, [[1, 2, 3]]);
233
- });
234
126
  });
235
127
 
236
128
  describe("isCursorRebuildNeeded", () => {
@@ -1,245 +1,118 @@
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
6
  import { MailboxQueueService } from "./mailbox-queue.js";
7
- import type { IImapConnection } from "./types.js";
8
-
9
- const row = (over: Partial<MailboxItem> = {}): MailboxItem =>
10
- ({
11
- mailboxId: "mbx-1",
12
- accountId: "acc-1",
13
- fullPath: "Receipts",
14
- hierarchyDelimiter: "/",
15
- syncStatus: MailboxSyncStatus.synced,
16
- ...over,
17
- }) as MailboxItem;
18
-
19
- const store = (rows: MailboxItem[]) => {
20
- const byId = new Map(rows.map((r) => [r.mailboxId, { ...r }]));
21
- const removed: string[] = [];
22
-
23
- const repo: Pick<
24
- IMailboxRepository,
25
- "get" | "transition" | "findByPathPrefix" | "deleteMailboxWithMail"
26
- > = {
27
- get: (async (_accountId: string, mailboxId: string) => {
28
- const found = byId.get(mailboxId as string);
29
- if (!found) {
30
- throw Object.assign(new Error(`Mailbox not found: ${mailboxId}`), {
31
- name: "NotFoundError",
32
- });
33
- }
34
- return found;
35
- }) as IMailboxRepository["get"],
36
- findByPathPrefix: async () => [],
37
- transition: async (_accountId, mailboxId, intent) => {
38
- const current = byId.get(mailboxId);
39
- if (!current) return null;
40
- if (!intent.from.includes(current.syncStatus)) return null;
41
- const next = { ...current, syncStatus: intent.to } as MailboxItem;
42
- byId.set(mailboxId, next);
43
- return next;
44
- },
45
- deleteMailboxWithMail: async (_accountId, mailboxId) => {
46
- removed.push(mailboxId);
47
- byId.delete(mailboxId);
48
- },
49
- };
50
-
51
- return { repo: repo as IMailboxRepository, byId, removed };
52
- };
53
-
54
- const queueOver = (repo: IMailboxRepository) => {
55
- const sent: string[] = [];
56
- const service = new MailboxQueueService({
57
- mailboxService: repo,
58
- sqsQueueUrl: "http://localhost:4566/000000000000/mailbox",
7
+ import {
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
+ let receipts: MailboxItem;
22
+
23
+ beforeEach(async () => {
24
+ capture = captureQueue();
25
+ queue = capture.sent;
26
+ store = await openStore();
27
+ account = await seedAccount(store);
28
+ receipts = await seedMailbox(store, account, "Receipts");
29
+ });
30
+
31
+ afterEach(async () => {
32
+ capture.restore();
33
+ await store.close();
34
+ });
35
+
36
+ const markAs = (syncStatus: MailboxItem["syncStatus"], pendingPath?: string) =>
37
+ store.mailbox.transition(account.accountId, receipts.mailboxId, {
38
+ from: [MailboxSyncStatus.synced],
39
+ to: syncStatus,
40
+ set: pendingPath ? { pendingPath } : {},
59
41
  });
60
- (
61
- service as unknown as {
62
- sqs: {
63
- send: (command: { input: { MessageBody: string } }) => Promise<void>;
64
- };
65
- }
66
- ).sqs = {
67
- send: async (command) => {
68
- sent.push(command.input.MessageBody);
69
- },
70
- };
71
- return { service, sent };
72
- };
73
-
74
- describe("MailboxQueueService.deleteMailbox — the recorded intent", () => {
75
- it("records `deleting` and enqueues one DELETE", async () => {
76
- const { repo, byId } = store([row()]);
77
- const { service, sent } = queueOver(repo);
78
-
79
- await service.deleteMailbox("mbx-1", "acc-1");
80
-
81
- assert.equal(byId.get("mbx-1")?.syncStatus, MailboxSyncStatus.deleting);
82
- assert.equal(sent.length, 1);
83
- assert.equal(
84
- (JSON.parse(sent[0]) as { path: string }).path,
85
- "Receipts",
86
- "the event names the path the server holds",
42
+
43
+ const requestDelete = () =>
44
+ new MailboxQueueService({
45
+ mailboxService: store.mailbox,
46
+ sqsQueueUrl: "http://localhost:4566/000000000000/mailbox",
47
+ }).deleteMailbox(receipts.mailboxId, account.accountId);
48
+
49
+ const current = () => store.mailbox.get(account.accountId, receipts.mailboxId);
50
+
51
+ const management = () => new MailboxManagementService(store.mailbox);
52
+
53
+ describe("MailboxQueueService.deleteMailbox records the intent", () => {
54
+ it("records deleting and enqueues one DELETE naming the server's path", async () => {
55
+ await requestDelete();
56
+
57
+ assert.equal((await current()).syncStatus, MailboxSyncStatus.deleting);
58
+ assert.deepEqual(
59
+ queue.map((event) => event.body.path),
60
+ ["Receipts"],
87
61
  );
88
62
  });
89
63
 
90
64
  it("accepts a retry from a failed delete", async () => {
91
- const { repo, byId } = store([
92
- row({ syncStatus: MailboxSyncStatus.failed }),
93
- ]);
94
- const { service, sent } = queueOver(repo);
65
+ await markAs(MailboxSyncStatus.failed);
95
66
 
96
- await service.deleteMailbox("mbx-1", "acc-1");
67
+ await requestDelete();
97
68
 
98
- assert.equal(byId.get("mbx-1")?.syncStatus, MailboxSyncStatus.deleting);
99
- assert.equal(sent.length, 1);
69
+ assert.equal((await current()).syncStatus, MailboxSyncStatus.deleting);
70
+ assert.equal(queue.length, 1);
100
71
  });
101
72
 
102
- it("gives the loser a 409 naming the delete already in flight", async () => {
103
- const { repo } = store([row({ syncStatus: MailboxSyncStatus.deleting })]);
104
- const { service, sent } = queueOver(repo);
73
+ it("answers a second delete with a 409 and enqueues nothing", async () => {
74
+ await requestDelete();
105
75
 
106
76
  await assert.rejects(
107
- service.deleteMailbox("mbx-1", "acc-1"),
77
+ requestDelete(),
108
78
  (error: unknown) =>
109
79
  (error as { statusCode?: number }).statusCode === 409 &&
110
80
  (error as Error).message ===
111
81
  'A delete is already in progress for "Receipts".',
112
82
  );
113
- assert.equal(sent.length, 0, "the loser enqueues nothing");
83
+ assert.equal(queue.length, 1);
114
84
  });
115
85
 
116
- it("gives the loser a 409 naming a rename in flight", async () => {
117
- const { repo } = store([
118
- row({
119
- syncStatus: MailboxSyncStatus.pending,
120
- pendingPath: "Bonnen",
121
- }),
122
- ]);
123
- const { service } = queueOver(repo);
86
+ it("answers a delete during a rename with a 409 naming the rename", async () => {
87
+ await markAs(MailboxSyncStatus.pending, "Bonnen");
124
88
 
125
89
  await assert.rejects(
126
- service.deleteMailbox("mbx-1", "acc-1"),
90
+ requestDelete(),
127
91
  (error: unknown) =>
128
92
  (error as Error).message === 'A rename is in progress for "Receipts".',
129
93
  );
130
94
  });
131
95
  });
132
96
 
133
- describe("MailboxManagementService.syncDelete — settling the intent", () => {
134
- const deleting = () =>
135
- store([row({ syncStatus: MailboxSyncStatus.deleting })]);
136
-
137
- const connection = (calls: string[]): IImapConnection =>
138
- ({
139
- deleteMailbox: async (path: string) => {
140
- calls.push(path);
141
- return { path };
142
- },
143
- }) as unknown as IImapConnection;
144
-
145
- it("takes the folder's mail with it, not the row alone", async () => {
146
- // Removing the row alone leaves every message and thread_message row keyed
147
- // to a dead mailboxId — out of every reader, and still in the search index
148
- // because nothing writes the `message.removed` outbox event (D8).
149
- const { repo, removed, byId } = deleting();
150
- const service = new MailboxManagementService(repo);
151
- const calls: string[] = [];
152
-
153
- const result = await service.syncDelete(
154
- "acc-1",
155
- "mbx-1",
156
- "Receipts",
157
- async () => connection(calls),
158
- );
159
-
160
- assert.equal(result.success, true);
161
- assert.deepEqual(calls, ["Receipts"]);
162
- assert.deepEqual(removed, ["mbx-1"]);
163
- assert.equal(byId.has("mbx-1"), false);
164
- });
165
-
166
- it("issues no DELETE when the row is not deleting", async () => {
167
- const { repo, removed } = store([row()]);
168
- const service = new MailboxManagementService(repo);
169
- const calls: string[] = [];
170
-
171
- const result = await service.syncDelete(
172
- "acc-1",
173
- "mbx-1",
174
- "Receipts",
175
- async () => connection(calls),
176
- );
177
-
178
- assert.equal(result.success, true);
179
- assert.deepEqual(calls, [], "the connection is never asked");
180
- assert.deepEqual(removed, []);
181
- });
182
-
183
- it("resolves when the row is already gone", async () => {
184
- const { repo } = store([]);
185
- const service = new MailboxManagementService(repo);
186
- const calls: string[] = [];
187
-
188
- const result = await service.syncDelete(
189
- "acc-1",
190
- "mbx-1",
191
- "Receipts",
192
- async () => connection(calls),
193
- );
194
-
195
- assert.equal(result.success, true);
196
- assert.deepEqual(calls, []);
197
- });
97
+ describe("MailboxManagementService.failDelete", () => {
98
+ it("marks the row failed and keeps its path, with no rename target", async () => {
99
+ await requestDelete();
198
100
 
199
- it("resumes an interrupted removal on a redelivery", async () => {
200
- // The row stays `deleting` until the final commit, so a redelivery
201
- // re-enters, passes the guard and continues where it stopped.
202
- const { repo, removed } = deleting();
203
- const service = new MailboxManagementService(repo);
204
- const calls: string[] = [];
205
-
206
- await service.settleDelete("acc-1", "mbx-1");
207
- const result = await service.syncDelete(
208
- "acc-1",
209
- "mbx-1",
210
- "Receipts",
211
- async () => connection(calls),
101
+ await management().failDelete(
102
+ account.accountId,
103
+ receipts.mailboxId,
104
+ "Permission denied",
212
105
  );
213
106
 
214
- assert.equal(result.success, true);
215
- assert.deepEqual(calls, [], "no second IMAP DELETE is issued");
216
- assert.deepEqual(removed, ["mbx-1"]);
217
- });
218
- });
219
-
220
- describe("MailboxManagementService.failDelete", () => {
221
- it("marks the row failed, unwinding nothing", async () => {
222
- // DELETE is all-or-nothing, so fullPath is still what the server holds,
223
- // and no rename target is written — which is how the client tells a failed
224
- // delete from a failed rename (T9).
225
- const { repo, byId } = store([
226
- row({ syncStatus: MailboxSyncStatus.deleting }),
227
- ]);
228
- const service = new MailboxManagementService(repo);
229
-
230
- await service.failDelete("acc-1", "mbx-1", "Permission denied");
231
-
232
- assert.equal(byId.get("mbx-1")?.syncStatus, MailboxSyncStatus.failed);
233
- assert.equal(byId.get("mbx-1")?.fullPath, "Receipts");
234
- assert.equal(byId.get("mbx-1")?.pendingPath, undefined);
107
+ const failed = await current();
108
+ assert.equal(failed.syncStatus, MailboxSyncStatus.failed);
109
+ assert.equal(failed.fullPath, "Receipts");
110
+ assert.equal(failed.pendingPath, undefined);
235
111
  });
236
112
 
237
113
  it("raises a NotFoundError when the row is gone", async () => {
238
- const { repo } = store([]);
239
- const service = new MailboxManagementService(repo);
240
-
241
114
  await assert.rejects(
242
- service.failDelete("acc-1", "mbx-1", "Permission denied"),
115
+ management().failDelete(account.accountId, "mbx-gone", "denied"),
243
116
  (error: unknown) => (error as Error).name === "NotFoundError",
244
117
  );
245
118
  });