@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,60 +1,31 @@
1
- /**
2
- * ListId is only written at body-sync time, going forward (issue #263). A row
3
- * synced before header extraction shipped keeps `listId` unset forever, so a
4
- * `ListId` filter clause silently under-matches the back catalogue.
5
- *
6
- * These tests pin the one-time, resumable pass that closes that gap: it reads
7
- * each candidate's already-stored raw source (never IMAP), extracts
8
- * `List-Id`, and writes only that field.
9
- */
10
-
11
1
  import assert from "node:assert/strict";
12
- import { describe, it } from "node:test";
13
- import type {
14
- AccountConfigItem,
15
- IAccountConfigRepository,
16
- IMessageRepository,
17
- IThreadMessageRepository,
18
- MessageItem,
19
- ResultList,
20
- ThreadMessageItem,
21
- UpdateThreadMessageInput,
22
- } from "@remit/data-ports";
2
+ import { afterEach, beforeEach, describe, it } from "node:test";
3
+ import type { AccountItem, MailboxItem } from "@remit/data-ports";
23
4
  import type { StorageService } from "@remit/storage-service";
24
5
  import {
25
6
  backfillListIds,
26
7
  type ListIdBackfillCheckpoint,
27
- type ListIdBackfillCheckpointStore,
28
- type ListIdBackfillProgress,
29
8
  } from "./list-id-backfill.js";
9
+ import { eml, FakeImapServer } from "./test-helpers/fake-imap.js";
10
+ import {
11
+ arriveAt,
12
+ openStore,
13
+ type Store,
14
+ seedAccount,
15
+ seedMailbox,
16
+ storeBody,
17
+ } from "./test-helpers/test-store.js";
18
+
19
+ const LIST = eml({
20
+ From: "Weekly News <news@example.com>",
21
+ "List-Id": "Weekly News <weekly.news.example.com>",
22
+ });
30
23
 
31
- const LIST_EML = Buffer.from(
32
- [
33
- "From: Weekly News <news@example.com>",
34
- "To: me@example.com",
35
- "Subject: This week",
36
- "List-Id: Weekly News <weekly.news.example.com>",
37
- "Content-Type: text/plain",
38
- "",
39
- "news",
40
- ].join("\r\n"),
41
- );
42
-
43
- const PLAIN_EML = Buffer.from(
44
- [
45
- "From: Alice <alice@example.com>",
46
- "To: me@example.com",
47
- "Subject: Hi",
48
- "Content-Type: text/plain",
49
- "",
50
- "hi",
51
- ].join("\r\n"),
52
- );
24
+ const PLAIN = eml({ From: "Alice <alice@example.com>" });
53
25
 
54
- const MALFORMED_LIST_ID_EML = Buffer.from(
26
+ const MALFORMED = Buffer.from(
55
27
  [
56
28
  "From: Weird <weird@example.com>",
57
- "To: me@example.com",
58
29
  "Subject: Odd header",
59
30
  "List-Id:",
60
31
  "Content-Type: text/plain",
@@ -63,511 +34,174 @@ const MALFORMED_LIST_ID_EML = Buffer.from(
63
34
  ].join("\r\n"),
64
35
  );
65
36
 
66
- const row = (overrides: Partial<ThreadMessageItem>): ThreadMessageItem =>
67
- ({
68
- threadMessageId: "tm-1",
69
- accountConfigId: "acc-1",
70
- threadId: "thread-1",
71
- messageId: "m-1",
72
- mailboxId: "mb-1",
73
- uid: 1,
74
- referenceOrder: 0,
75
- internalDate: 1,
76
- sentDate: 1,
77
- isRead: false,
78
- hasAttachment: false,
79
- star: "none",
80
- hasStars: false,
81
- isDeleted: false,
82
- category: "uncategorized",
83
- createdAt: 1,
84
- updatedAt: 1,
85
- ...overrides,
86
- }) as unknown as ThreadMessageItem;
87
-
88
- const message = (overrides: Partial<MessageItem>): MessageItem =>
89
- ({
90
- messageId: "m-1",
91
- mailboxId: "mb-1",
92
- uid: 1,
93
- status: "active",
94
- syncStatus: "synced",
95
- category: "uncategorized",
96
- hasListUnsubscribe: false,
97
- movedByRemit: false,
98
- createdAt: 1,
99
- updatedAt: 1,
100
- ...overrides,
101
- }) as unknown as MessageItem;
37
+ let store: Store;
38
+ let storage: StorageService;
39
+ let imap: FakeImapServer;
40
+ let account: AccountItem;
41
+ let inbox: MailboxItem;
42
+
43
+ beforeEach(async () => {
44
+ store = await openStore();
45
+ storage = store.storage();
46
+ imap = new FakeImapServer();
47
+ imap.addFolder("INBOX");
48
+ account = await seedAccount(store);
49
+ inbox = await seedMailbox(store, account, "INBOX");
50
+ });
102
51
 
103
- const asAccount = (accountConfigId: string): AccountConfigItem =>
104
- ({ accountConfigId }) as unknown as AccountConfigItem;
52
+ afterEach(() => store.close());
105
53
 
106
- const inMemoryCheckpointStore = (): ListIdBackfillCheckpointStore => {
107
- let checkpoint: ListIdBackfillCheckpoint | undefined;
108
- return {
109
- load: async () => checkpoint,
110
- save: async (next) => {
111
- checkpoint = next;
112
- },
113
- clear: async () => {
114
- checkpoint = undefined;
115
- },
116
- };
54
+ const stored = async (
55
+ raw: Buffer,
56
+ owner: AccountItem = account,
57
+ mailbox: MailboxItem = inbox,
58
+ ): Promise<string> => {
59
+ const messageId = await arriveAt(store, imap, owner, mailbox, raw);
60
+ await storeBody(store, storage, owner, messageId, raw);
61
+ return messageId;
117
62
  };
118
63
 
119
- interface Harness {
120
- accountConfigService: Pick<IAccountConfigRepository, "listAll">;
121
- threadMessageService: Pick<
122
- IThreadMessageRepository,
123
- "listByAccount" | "update"
124
- >;
125
- messageService: Pick<IMessageRepository, "get">;
126
- storageService: Pick<StorageService, "retrieve">;
127
- updates: Array<{ threadMessageId: string; input: UpdateThreadMessageInput }>;
128
- retrieved: string[];
129
- }
130
-
131
- const buildHarness = (options: {
132
- accounts?: AccountConfigItem[];
133
- rows: ThreadMessageItem[];
134
- messages: MessageItem[];
135
- retrieve?: (key: string) => Promise<Buffer>;
136
- pageSize?: number;
137
- }): Harness => {
138
- const accounts = options.accounts ?? [
139
- { accountConfigId: "acc-1" } as unknown as AccountConfigItem,
140
- ];
141
- const messagesById = new Map(options.messages.map((m) => [m.messageId, m]));
142
- const updates: Array<{
143
- threadMessageId: string;
144
- input: UpdateThreadMessageInput;
145
- }> = [];
146
- const retrieved: string[] = [];
147
- const pageSize = options.pageSize ?? 200;
64
+ const deps = () => ({
65
+ accountConfigService: store.accountConfig,
66
+ threadMessageService: store.threadMessage,
67
+ messageService: store.message,
68
+ storageService: storage,
69
+ });
148
70
 
149
- const accountConfigService: Pick<IAccountConfigRepository, "listAll"> = {
150
- listAll: async () => accounts,
151
- };
71
+ const listIdOf = async (messageId: string, owner: AccountItem = account) =>
72
+ (
73
+ await store.threadMessage.findAllByMessageId(
74
+ owner.accountConfigId,
75
+ messageId,
76
+ )
77
+ )[0].listId;
152
78
 
153
- const threadMessageService: Pick<
154
- IThreadMessageRepository,
155
- "listByAccount" | "update"
156
- > = {
157
- listByAccount: async (
158
- accountConfigId: string,
159
- opts?: { limit?: number; continuationToken?: string },
160
- ): Promise<ResultList<ThreadMessageItem>> => {
161
- const scoped = options.rows.filter(
162
- (r) => r.accountConfigId === accountConfigId,
163
- );
164
- const start = opts?.continuationToken
165
- ? Number(opts.continuationToken)
166
- : 0;
167
- const limit = opts?.limit ?? pageSize;
168
- const page = scoped.slice(start, start + limit);
169
- const nextStart = start + page.length;
170
- return {
171
- items: page,
172
- continuationToken:
173
- nextStart < scoped.length ? String(nextStart) : undefined,
174
- };
175
- },
176
- update: async (
177
- _accountConfigId: string,
178
- threadMessageId: string,
179
- input: UpdateThreadMessageInput,
180
- ) => {
181
- updates.push({ threadMessageId, input });
182
- return row({ threadMessageId, ...input });
79
+ const memoryCheckpoint = () => {
80
+ let saved: ListIdBackfillCheckpoint | undefined;
81
+ return {
82
+ load: async () => saved,
83
+ save: async (next: ListIdBackfillCheckpoint) => {
84
+ saved = next;
183
85
  },
184
- };
185
-
186
- const messageService: Pick<IMessageRepository, "get"> = {
187
- get: (async (messageIds: string | string[]) => {
188
- if (Array.isArray(messageIds)) {
189
- return messageIds
190
- .map((id) => messagesById.get(id))
191
- .filter((m): m is MessageItem => m !== undefined);
192
- }
193
- const found = messagesById.get(messageIds);
194
- if (!found) throw new Error(`no fixture for ${messageIds}`);
195
- return found;
196
- }) as IMessageRepository["get"],
197
- };
198
-
199
- const storageService: Pick<StorageService, "retrieve"> = {
200
- retrieve: async (key: string) => {
201
- retrieved.push(key);
202
- return options.retrieve ? options.retrieve(key) : LIST_EML;
86
+ clear: async () => {
87
+ saved = undefined;
203
88
  },
204
89
  };
205
-
206
- return {
207
- accountConfigService,
208
- threadMessageService,
209
- messageService,
210
- storageService,
211
- updates,
212
- retrieved,
213
- };
214
90
  };
215
91
 
216
92
  describe("backfillListIds", () => {
217
- it("writes the extracted List-Id for a candidate row", async () => {
218
- const harness = buildHarness({
219
- rows: [row({ threadMessageId: "tm-1", messageId: "m-1" })],
220
- messages: [message({ messageId: "m-1", bodyStorageKey: "s3://m-1" })],
221
- });
93
+ it("writes the List-Id a stored body carries", async () => {
94
+ const messageId = await stored(LIST);
222
95
 
223
- const result = await backfillListIds(harness);
96
+ const result = await backfillListIds(deps());
224
97
 
225
98
  assert.equal(result.backfilled, 1);
226
- assert.equal(result.failed, 0);
227
- assert.equal(harness.updates.length, 1);
228
- assert.equal(harness.updates[0].input.listId, "weekly.news.example.com");
229
- });
230
-
231
- it("leaves listId empty, without error, when the message has no List-Id header", async () => {
232
- const harness = buildHarness({
233
- rows: [row({ threadMessageId: "tm-1", messageId: "m-1" })],
234
- messages: [message({ messageId: "m-1", bodyStorageKey: "s3://m-1" })],
235
- retrieve: async () => PLAIN_EML,
236
- });
237
-
238
- const result = await backfillListIds(harness);
239
-
240
- assert.equal(result.noListId, 1);
241
- assert.equal(result.failed, 0);
242
- assert.deepEqual(harness.updates, []);
99
+ assert.equal(await listIdOf(messageId), "weekly.news.example.com");
243
100
  });
244
101
 
245
- it("does not error on a malformed List-Id header", async () => {
246
- const harness = buildHarness({
247
- rows: [row({ threadMessageId: "tm-1", messageId: "m-1" })],
248
- messages: [message({ messageId: "m-1", bodyStorageKey: "s3://m-1" })],
249
- retrieve: async () => MALFORMED_LIST_ID_EML,
250
- });
102
+ it("leaves the field empty for mail with no or a malformed List-Id", async () => {
103
+ const plain = await stored(PLAIN);
104
+ const malformed = await stored(MALFORMED);
251
105
 
252
- const result = await backfillListIds(harness);
106
+ const result = await backfillListIds(deps());
253
107
 
108
+ assert.equal(result.noListId, 2);
254
109
  assert.equal(result.failed, 0);
255
- assert.equal(result.noListId, 1);
256
- assert.deepEqual(harness.updates, []);
110
+ assert.equal(await listIdOf(plain), undefined);
111
+ assert.equal(await listIdOf(malformed), undefined);
257
112
  });
258
113
 
259
- it("skips a row whose listId is already set, without reading storage", async () => {
260
- const harness = buildHarness({
261
- rows: [
262
- row({
263
- threadMessageId: "tm-1",
264
- messageId: "m-1",
265
- listId: "already.set",
266
- }),
267
- ],
268
- messages: [message({ messageId: "m-1", bodyStorageKey: "s3://m-1" })],
269
- });
114
+ it("skips rows already carrying a List-Id and rows with no stored body", async () => {
115
+ const set = await stored(LIST);
116
+ const [row] = await store.threadMessage.findAllByMessageId(
117
+ account.accountConfigId,
118
+ set,
119
+ );
120
+ await store.threadMessage.update(
121
+ account.accountConfigId,
122
+ row.threadMessageId,
123
+ { listId: "already.set" },
124
+ );
125
+ await arriveAt(store, imap, account, inbox, LIST);
270
126
 
271
- const result = await backfillListIds(harness);
127
+ const result = await backfillListIds(deps());
272
128
 
273
129
  assert.equal(result.alreadySet, 1);
274
- assert.equal(result.backfilled, 0);
275
- assert.deepEqual(harness.retrieved, []);
276
- assert.deepEqual(harness.updates, []);
277
- });
278
-
279
- it("skips a candidate whose body was never synced, without reading storage", async () => {
280
- const harness = buildHarness({
281
- rows: [row({ threadMessageId: "tm-1", messageId: "m-1" })],
282
- messages: [message({ messageId: "m-1", bodyStorageKey: undefined })],
283
- });
284
-
285
- const result = await backfillListIds(harness);
286
-
287
130
  assert.equal(result.skippedNoBody, 1);
288
- assert.deepEqual(harness.retrieved, []);
289
- assert.deepEqual(harness.updates, []);
131
+ assert.equal(await listIdOf(set), "already.set");
290
132
  });
291
133
 
292
134
  it("contains a storage failure to the one message and keeps going", async () => {
293
- const harness = buildHarness({
294
- rows: [
295
- row({ threadMessageId: "tm-bad", messageId: "m-bad" }),
296
- row({ threadMessageId: "tm-good", messageId: "m-good" }),
297
- ],
298
- messages: [
299
- message({ messageId: "m-bad", bodyStorageKey: "s3://m-bad" }),
300
- message({ messageId: "m-good", bodyStorageKey: "s3://m-good" }),
301
- ],
302
- retrieve: async (key) => {
303
- if (key === "s3://m-bad") throw new Error("AccessDenied");
304
- return LIST_EML;
305
- },
135
+ const lost = await arriveAt(store, imap, account, inbox, LIST);
136
+ await store.message.update(lost, {
137
+ bodyStorageKey: "file:///nonexistent/remit/body.eml",
306
138
  });
139
+ const fine = await stored(LIST);
307
140
 
308
- const result = await backfillListIds(harness);
141
+ const result = await backfillListIds(deps());
309
142
 
310
143
  assert.equal(result.failed, 1);
311
- assert.deepEqual(result.failedThreadMessageIds, ["tm-bad"]);
312
- assert.equal(result.backfilled, 1);
313
- assert.equal(harness.updates.length, 1);
314
- assert.equal(harness.updates[0].threadMessageId, "tm-good");
315
- });
316
-
317
- it("reports progress as pages are processed", async () => {
318
- const rows = Array.from({ length: 3 }, (_, i) =>
319
- row({ threadMessageId: `tm-${i}`, messageId: `m-${i}` }),
320
- );
321
- const messages = rows.map((r) =>
322
- message({
323
- messageId: r.messageId,
324
- bodyStorageKey: `s3://${r.messageId}`,
325
- }),
326
- );
327
- const harness = buildHarness({ rows, messages, pageSize: 2 });
328
- const progress: ListIdBackfillProgress[] = [];
329
-
330
- const result = await backfillListIds(harness, {
331
- batchSize: 2,
332
- onProgress: (p) => progress.push({ ...p }),
333
- });
334
-
335
- assert.equal(result.backfilled, 3);
336
- assert.equal(progress.length, 2);
337
- assert.equal(progress[0].scanned, 2);
338
- assert.equal(progress[1].scanned, 3);
144
+ assert.equal(await listIdOf(fine), "weekly.news.example.com");
339
145
  });
340
146
 
341
- it("scans every account returned by listAll", async () => {
342
- const harness = buildHarness({
343
- accounts: [
344
- { accountConfigId: "acc-1" } as unknown as AccountConfigItem,
345
- { accountConfigId: "acc-2" } as unknown as AccountConfigItem,
346
- ],
347
- rows: [
348
- row({
349
- threadMessageId: "tm-1",
350
- messageId: "m-1",
351
- accountConfigId: "acc-1",
352
- }),
353
- row({
354
- threadMessageId: "tm-2",
355
- messageId: "m-2",
356
- accountConfigId: "acc-2",
357
- }),
358
- ],
359
- messages: [
360
- message({ messageId: "m-1", bodyStorageKey: "s3://m-1" }),
361
- message({ messageId: "m-2", bodyStorageKey: "s3://m-2" }),
362
- ],
363
- });
364
-
365
- const result = await backfillListIds(harness);
366
-
367
- assert.equal(result.backfilled, 2);
368
- assert.equal(harness.updates.length, 2);
369
- });
370
-
371
- it("scans accounts in account-id order, whatever order listAll returns", async () => {
372
- const accountIds = ["acc-1", "acc-2", "acc-3"];
373
- const rows = accountIds.map((accountConfigId) =>
374
- row({
375
- threadMessageId: `tm-${accountConfigId}`,
376
- messageId: `m-${accountConfigId}`,
377
- accountConfigId,
378
- }),
379
- );
380
- const harness = buildHarness({
381
- accounts: ["acc-3", "acc-1", "acc-2"].map(asAccount),
382
- rows,
383
- messages: rows.map((r) =>
384
- message({
385
- messageId: r.messageId,
386
- bodyStorageKey: `s3://${r.messageId}`,
387
- }),
388
- ),
389
- });
390
- const progress: ListIdBackfillProgress[] = [];
391
-
392
- await backfillListIds(harness, {
393
- onProgress: (p) => progress.push({ ...p }),
394
- });
395
-
396
- assert.deepEqual(
397
- progress.map((p) => p.accountConfigId),
398
- accountIds,
399
- );
400
- });
401
-
402
- it("checkpoints after each page and clears it on completion", async () => {
403
- const rows = Array.from({ length: 3 }, (_, i) =>
404
- row({ threadMessageId: `tm-${i}`, messageId: `m-${i}` }),
405
- );
406
- const messages = rows.map((r) =>
407
- message({
408
- messageId: r.messageId,
409
- bodyStorageKey: `s3://${r.messageId}`,
410
- }),
411
- );
412
- const harness = buildHarness({ rows, messages, pageSize: 2 });
413
-
414
- const saved: ListIdBackfillCheckpoint[] = [];
415
- let cleared = false;
416
-
417
- await backfillListIds(harness, {
418
- batchSize: 2,
419
- checkpointStore: {
420
- load: async () => undefined,
421
- save: async (checkpoint) => {
422
- saved.push(checkpoint);
423
- },
424
- clear: async () => {
425
- cleared = true;
426
- },
427
- },
428
- });
429
-
430
- assert.equal(saved.length, 2);
431
- assert.equal(saved[0].accountConfigId, "acc-1");
432
- assert.equal(saved[0].continuationToken, "2");
433
- assert.equal(saved[1].continuationToken, undefined);
434
- assert.equal(cleared, true);
435
- });
436
-
437
- it("resumes from a saved checkpoint instead of rescanning from the start", async () => {
438
- const rows = Array.from({ length: 3 }, (_, i) =>
439
- row({ threadMessageId: `tm-${i}`, messageId: `m-${i}` }),
440
- );
441
- const messages = rows.map((r) =>
442
- message({
443
- messageId: r.messageId,
444
- bodyStorageKey: `s3://${r.messageId}`,
445
- }),
446
- );
447
- const harness = buildHarness({ rows, messages, pageSize: 2 });
448
-
449
- const result = await backfillListIds(harness, {
450
- batchSize: 2,
451
- checkpointStore: {
452
- load: async () => ({
453
- accountConfigId: "acc-1",
454
- continuationToken: "2",
455
- }),
456
- save: async () => {},
457
- clear: async () => {},
458
- },
459
- });
460
-
461
- assert.equal(result.scanned, 1);
462
- assert.equal(result.backfilled, 1);
463
- assert.deepEqual(
464
- harness.updates.map((u) => u.threadMessageId),
465
- ["tm-2"],
466
- );
467
- });
468
-
469
- it("resumes the account it was working through after an earlier account is removed", async () => {
470
- const accountIds = ["acc-1", "acc-2", "acc-3"];
471
- const rows = [
472
- row({
473
- threadMessageId: "tm-1",
474
- messageId: "m-1",
475
- accountConfigId: "acc-1",
476
- }),
477
- row({
478
- threadMessageId: "tm-2a",
479
- messageId: "m-2a",
480
- accountConfigId: "acc-2",
481
- }),
482
- row({
483
- threadMessageId: "tm-2b",
484
- messageId: "m-2b",
485
- accountConfigId: "acc-2",
486
- }),
487
- row({
488
- threadMessageId: "tm-3",
489
- messageId: "m-3",
490
- accountConfigId: "acc-3",
491
- }),
147
+ it("resumes the account it was in after an earlier account is removed", async () => {
148
+ const owners = [
149
+ await seedAccount(store, { accountConfigId: "cfg-1" }),
150
+ await seedAccount(store, { accountConfigId: "cfg-2" }),
151
+ await seedAccount(store, { accountConfigId: "cfg-3" }),
492
152
  ];
493
- const messages = rows.map((r) =>
494
- message({
495
- messageId: r.messageId,
496
- bodyStorageKey: `s3://${r.messageId}`,
497
- }),
498
- );
499
-
500
- const store = inMemoryCheckpointStore();
501
-
502
- const interrupted = buildHarness({
503
- accounts: accountIds.map(asAccount),
504
- rows,
505
- messages,
506
- pageSize: 1,
507
- });
508
- const listByAccount = interrupted.threadMessageService.listByAccount;
509
- interrupted.threadMessageService.listByAccount = async (
510
- accountConfigId,
511
- opts,
512
- ) => {
513
- if (accountConfigId === "acc-2" && opts?.continuationToken) {
514
- throw new Error("interrupted");
515
- }
516
- return listByAccount(accountConfigId, opts);
517
- };
153
+ const ids = new Map<string, string[]>();
154
+ for (const owner of owners) {
155
+ imap.addFolder(`INBOX-${owner.accountConfigId}`);
156
+ const mailbox = await seedMailbox(
157
+ store,
158
+ owner,
159
+ `INBOX-${owner.accountConfigId}`,
160
+ );
161
+ const count = owner.accountConfigId === "cfg-2" ? 2 : 1;
162
+ const mine: string[] = [];
163
+ for (let i = 0; i < count; i++)
164
+ mine.push(await stored(LIST, owner, mailbox));
165
+ ids.set(owner.accountConfigId, mine);
166
+ }
167
+ await store.accountConfig.delete(account.accountConfigId);
168
+ const checkpointStore = memoryCheckpoint();
169
+ let pages = 0;
518
170
 
519
171
  await assert.rejects(
520
- backfillListIds(interrupted, { batchSize: 1, checkpointStore: store }),
521
- /interrupted/,
172
+ backfillListIds(deps(), {
173
+ batchSize: 1,
174
+ checkpointStore,
175
+ onProgress: (progress) => {
176
+ if (progress.accountConfigId === "cfg-2" && ++pages === 1) {
177
+ throw new Error("killed");
178
+ }
179
+ },
180
+ }),
522
181
  );
523
-
524
- const resumed = buildHarness({
525
- accounts: ["acc-2", "acc-3"].map(asAccount),
526
- rows,
527
- messages,
528
- pageSize: 1,
529
- });
530
-
531
- const result = await backfillListIds(resumed, {
182
+ await store.accountConfig.delete("cfg-1");
183
+ const resumed = await backfillListIds(deps(), {
532
184
  batchSize: 1,
533
- checkpointStore: store,
185
+ checkpointStore,
534
186
  });
535
187
 
536
- assert.deepEqual(
537
- resumed.updates.map((u) => u.threadMessageId),
538
- ["tm-2b", "tm-3"],
539
- );
540
- assert.equal(result.backfilled, 2);
188
+ assert.equal(resumed.backfilled, 2);
189
+ assert.equal(resumed.scanned, 2);
541
190
  });
542
191
 
543
192
  it("restarts the pass when the checkpointed account no longer exists", async () => {
544
- const rows = Array.from({ length: 3 }, (_, i) =>
545
- row({ threadMessageId: `tm-${i}`, messageId: `m-${i}` }),
546
- );
547
- const messages = rows.map((r) =>
548
- message({
549
- messageId: r.messageId,
550
- bodyStorageKey: `s3://${r.messageId}`,
551
- }),
552
- );
553
- const harness = buildHarness({ rows, messages, pageSize: 2 });
193
+ for (let i = 0; i < 3; i++) await stored(LIST);
554
194
 
555
- const result = await backfillListIds(harness, {
195
+ const result = await backfillListIds(deps(), {
556
196
  batchSize: 2,
557
197
  checkpointStore: {
558
- load: async () => ({
559
- accountConfigId: "acc-removed",
560
- continuationToken: "2",
561
- }),
198
+ load: async () => ({ accountConfigId: "cfg-removed" }),
562
199
  save: async () => {},
563
200
  clear: async () => {},
564
201
  },
565
202
  });
566
203
 
567
204
  assert.equal(result.scanned, 3);
568
- assert.deepEqual(
569
- harness.updates.map((u) => u.threadMessageId),
570
- ["tm-0", "tm-1", "tm-2"],
571
- );
205
+ assert.equal(result.backfilled, 3);
572
206
  });
573
207
  });