@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,604 +1,277 @@
1
- /**
2
- * Tests for the `backfillClassifications` pass (issue #1197) — the resumable,
3
- * checkpointed full-corpus pass that classifies rows still carrying the
4
- * `NotExamined` sentinel on `Message.classificationState`, from their stored
5
- * bodies, and marks them `Examined`.
6
- *
7
- * Mirrors `list-id-backfill.test.ts`: per-account pages, checkpoint after
8
- * every page, resume by account-id order, and per-message failure
9
- * containment via `.then(fulfilled, rejected)`.
10
- */
11
-
12
1
  import assert from "node:assert/strict";
13
- import { describe, it } from "node:test";
2
+ import { afterEach, beforeEach, describe, it } from "node:test";
14
3
  import type {
15
- AccountConfigItem,
16
4
  AccountItem,
17
- AddressItem,
18
- IAccountConfigRepository,
19
- IAccountRepository,
20
- IAddressRepository,
21
- IMessageRepository,
22
- IThreadMessageRepository,
23
- MessageItem,
24
- ResultList,
25
- ThreadMessageItem,
5
+ MailboxItem,
26
6
  UpdateMessageInput,
27
- UpdateThreadMessageInput,
28
7
  } from "@remit/data-ports";
29
- import { NotFoundError } from "@remit/data-ports/errors";
30
- import { DisplayNameCorrespondence } from "@remit/domain-enums";
8
+ import { deriveAddressId } from "@remit/data-ports/id";
9
+ import {
10
+ DisplayNameCorrespondence,
11
+ MessageCategory,
12
+ MessageClassificationState,
13
+ } from "@remit/domain-enums";
31
14
  import type { StorageService } from "@remit/storage-service";
32
- import { backfillClassifications } from "./classification-backfill.js";
33
- import type { FolderPlacementLookup } from "./folder-placement.js";
34
-
35
- const PLAIN_EML = Buffer.from(
36
- [
37
- "From: Alice <alice@example.com>",
38
- "To: me@example.com",
39
- "Subject: Hi",
40
- "Content-Type: text/plain",
41
- "",
42
- "hi",
43
- ].join("\r\n"),
15
+ import {
16
+ backfillClassifications,
17
+ type ClassificationBackfillCheckpoint,
18
+ } from "./classification-backfill.js";
19
+ import { eml, FakeImapServer } from "./test-helpers/fake-imap.js";
20
+ import {
21
+ arriveAt,
22
+ openStore,
23
+ type Store,
24
+ seedAccount,
25
+ seedMailbox,
26
+ storeBody,
27
+ } from "./test-helpers/test-store.js";
28
+
29
+ const PLAIN = eml({ From: "Alice <alice@example.com>", Subject: "Hi" }, "hi");
30
+
31
+ const LIST = eml(
32
+ {
33
+ From: "Alice <alice@example.com>",
34
+ Subject: "Newsletter",
35
+ "List-Id": "<news.example.com>",
36
+ "List-Unsubscribe": "<https://example.com/unsub>",
37
+ },
38
+ "this week in news",
44
39
  );
45
40
 
46
- const LIST_EML = Buffer.from(
47
- [
48
- "From: Alice <alice@example.com>",
49
- "To: me@example.com",
50
- "Subject: Newsletter",
51
- "List-Id: <news.example.com>",
52
- "List-Unsubscribe: <https://example.com/unsub>",
53
- "Content-Type: text/plain",
54
- "",
55
- "this week in news",
56
- ].join("\r\n"),
41
+ const IMPERSONATION = eml(
42
+ {
43
+ From: "InfoMedics <billing@serviceupdatebank.atlassian.net>",
44
+ Subject: "Openstaande factuur",
45
+ "DKIM-Signature": "v=1; a=rsa-sha256; d=atlassian.net; s=sel; b=xxx",
46
+ },
47
+ "Betaal uw factuur",
57
48
  );
58
49
 
59
- const IMPERSONATION_EML = Buffer.from(
60
- [
61
- "From: InfoMedics <billing@serviceupdatebank.atlassian.net>",
62
- "To: me@example.com",
63
- "Subject: Openstaande factuur",
64
- "DKIM-Signature: v=1; a=rsa-sha256; d=atlassian.net; s=sel; b=xxx",
65
- "Content-Type: text/plain",
66
- "",
67
- "Betaal uw factuur",
68
- ].join("\r\n"),
69
- );
50
+ let store: Store;
51
+ let storage: StorageService;
52
+ let imap: FakeImapServer;
53
+ let account: AccountItem;
54
+ let inbox: MailboxItem;
55
+ let junk: MailboxItem;
56
+
57
+ beforeEach(async () => {
58
+ store = await openStore();
59
+ storage = store.storage();
60
+ imap = new FakeImapServer();
61
+ imap.addFolder("INBOX");
62
+ imap.addFolder("Junk");
63
+ account = await seedAccount(store);
64
+ inbox = await seedMailbox(store, account, "INBOX");
65
+ junk = await seedMailbox(store, account, "Junk", { specialUse: "Junk" });
66
+ });
70
67
 
71
- const JUNK_MAILBOX_ID = "mb-junk";
72
-
73
- const row = (overrides: Partial<ThreadMessageItem>): ThreadMessageItem =>
74
- ({
75
- threadMessageId: "tm-1",
76
- accountConfigId: "acc-1",
77
- threadId: "thread-1",
78
- messageId: "m-1",
79
- mailboxId: "mb-1",
80
- uid: 1,
81
- referenceOrder: 0,
82
- internalDate: 1,
83
- sentDate: 1,
84
- isRead: false,
85
- hasAttachment: false,
86
- star: "none",
87
- hasStars: false,
88
- isDeleted: false,
89
- category: "uncategorized",
90
- createdAt: 1,
91
- updatedAt: 1,
92
- ...overrides,
93
- }) as unknown as ThreadMessageItem;
94
-
95
- const message = (overrides: Partial<MessageItem>): MessageItem =>
96
- ({
97
- messageId: "m-1",
98
- mailboxId: "mb-1",
99
- uid: 1,
100
- status: "active",
101
- syncStatus: "synced",
102
- category: "uncategorized",
103
- classificationState: "NotExamined",
104
- hasListUnsubscribe: false,
105
- movedByRemit: false,
106
- bodyStorageKey: "s3://m-1",
107
- authenticityVerdict: "NotEvaluated",
108
- createdAt: 1,
109
- updatedAt: 1,
110
- ...overrides,
111
- }) as unknown as MessageItem;
112
-
113
- const asAccount = (accountConfigId: string): AccountConfigItem =>
114
- ({ accountConfigId }) as unknown as AccountConfigItem;
115
-
116
- interface Harness {
117
- accountConfigService: Pick<IAccountConfigRepository, "listAll">;
118
- accountService: Pick<IAccountRepository, "listAllByAccountConfig">;
119
- mailboxSpecialUseService: FolderPlacementLookup;
120
- addressService: Pick<IAddressRepository, "getAddress">;
121
- threadMessageService: Pick<
122
- IThreadMessageRepository,
123
- "listByAccount" | "findAllByMessageId" | "update"
124
- >;
125
- messageService: Pick<IMessageRepository, "get" | "update">;
126
- storageService: Pick<StorageService, "retrieve">;
127
- messageUpdates: Array<{ messageId: string; input: UpdateMessageInput }>;
128
- threadUpdates: Array<{
129
- accountConfigId: string;
130
- threadMessageId: string;
131
- input: UpdateThreadMessageInput;
132
- }>;
133
- retrieved: string[];
134
- }
135
-
136
- const buildHarness = (options: {
137
- accounts?: AccountConfigItem[];
138
- rows: ThreadMessageItem[];
139
- messages: MessageItem[];
140
- retrieve?: (key: string) => Promise<Buffer>;
141
- pageSize?: number;
142
- addressFlags?: AddressItem["flags"];
143
- }): Harness => {
144
- const accounts = options.accounts ?? [
145
- { accountConfigId: "acc-1" } as unknown as AccountConfigItem,
146
- ];
147
- const messagesById = new Map(options.messages.map((m) => [m.messageId, m]));
148
- const messageUpdates: Harness["messageUpdates"] = [];
149
- const threadUpdates: Harness["threadUpdates"] = [];
150
- const retrieved: string[] = [];
151
- const pageSize = options.pageSize ?? 200;
152
-
153
- const accountConfigService: Pick<IAccountConfigRepository, "listAll"> = {
154
- listAll: async () => accounts,
155
- };
156
-
157
- const accountService: Pick<IAccountRepository, "listAllByAccountConfig"> = {
158
- listAllByAccountConfig: async (accountConfigId: string) => [
159
- { accountId: `account-${accountConfigId}` } as unknown as AccountItem,
160
- ],
161
- };
162
-
163
- const mailboxSpecialUseService: FolderPlacementLookup = {
164
- findJunkMailbox: async () => ({
165
- mailboxId: JUNK_MAILBOX_ID,
166
- fullPath: "Junk",
167
- }),
168
- findInboxMailbox: async () => ({ mailboxId: "mb-1", fullPath: "INBOX" }),
169
- };
170
-
171
- const addressService = {
172
- getAddress: (async () => {
173
- if (options.addressFlags === undefined)
174
- throw new NotFoundError("no Address row for this sender");
175
- return { flags: options.addressFlags } as AddressItem;
176
- }) as unknown as IAddressRepository["getAddress"],
177
- } as Pick<IAddressRepository, "getAddress">;
178
-
179
- const threadMessageService: Pick<
180
- IThreadMessageRepository,
181
- "listByAccount" | "findAllByMessageId" | "update"
182
- > = {
183
- listByAccount: async (
184
- accountConfigId: string,
185
- opts?: { limit?: number; continuationToken?: string },
186
- ): Promise<ResultList<ThreadMessageItem>> => {
187
- const scoped = options.rows.filter(
188
- (r) => r.accountConfigId === accountConfigId,
189
- );
190
- const start = opts?.continuationToken
191
- ? Number(opts.continuationToken)
192
- : 0;
193
- const limit = opts?.limit ?? pageSize;
194
- const page = scoped.slice(start, start + limit);
195
- const nextStart = start + page.length;
196
- return {
197
- items: page,
198
- continuationToken:
199
- nextStart < scoped.length ? String(nextStart) : undefined,
200
- };
201
- },
202
- findAllByMessageId: async (_accountConfigId, messageId) =>
203
- options.rows.filter((r) => r.messageId === messageId),
204
- update: async (accountConfigId, threadMessageId, input) => {
205
- threadUpdates.push({ accountConfigId, threadMessageId, input });
206
- const existing = options.rows.find(
207
- (r) => r.threadMessageId === threadMessageId,
208
- );
209
- return { ...existing, ...input } as ThreadMessageItem;
210
- },
211
- };
212
-
213
- const messageService: Pick<IMessageRepository, "get" | "update"> = {
214
- get: (async (messageIds: string | string[]) => {
215
- if (Array.isArray(messageIds)) {
216
- return messageIds
217
- .map((id) => messagesById.get(id))
218
- .filter((m): m is MessageItem => m !== undefined);
219
- }
220
- const found = messagesById.get(messageIds);
221
- if (!found) throw new Error(`no fixture for ${messageIds}`);
222
- return found;
223
- }) as IMessageRepository["get"],
224
- update: async (messageId, input) => {
225
- messageUpdates.push({ messageId, input });
226
- const existing = messagesById.get(messageId);
227
- return { ...existing, ...input } as MessageItem;
228
- },
229
- };
230
-
231
- const storageService: Pick<StorageService, "retrieve"> = {
232
- retrieve: async (key: string) => {
233
- retrieved.push(key);
234
- return options.retrieve ? options.retrieve(key) : PLAIN_EML;
235
- },
236
- };
237
-
238
- return {
239
- accountConfigService,
240
- accountService,
241
- mailboxSpecialUseService,
242
- addressService,
243
- threadMessageService,
244
- messageService,
245
- storageService,
246
- messageUpdates,
247
- threadUpdates,
248
- retrieved,
249
- };
68
+ afterEach(() => store.close());
69
+
70
+ const stored = async (
71
+ raw: Buffer,
72
+ mailbox: MailboxItem = inbox,
73
+ update: UpdateMessageInput = {},
74
+ owner: AccountItem = account,
75
+ ): Promise<string> => {
76
+ const messageId = await arriveAt(store, imap, owner, mailbox, raw);
77
+ await storeBody(store, storage, owner, messageId, raw);
78
+ await store.message.update(messageId, update);
79
+ return messageId;
250
80
  };
251
81
 
82
+ const deps = () => ({
83
+ accountConfigService: store.accountConfig,
84
+ accountService: store.account,
85
+ mailboxSpecialUseService: store.mailboxSpecialUse,
86
+ addressService: store.address,
87
+ threadMessageService: store.threadMessage,
88
+ messageService: store.message,
89
+ storageService: storage,
90
+ });
91
+
92
+ const threadRow = async (messageId: string) =>
93
+ (
94
+ await store.threadMessage.findAllByMessageId(
95
+ account.accountConfigId,
96
+ messageId,
97
+ )
98
+ )[0];
99
+
252
100
  describe("backfillClassifications", () => {
253
- it("classifies a NotExamined candidate from its stored body and marks it Examined", async () => {
254
- const harness = buildHarness({
255
- rows: [row({ threadMessageId: "tm-1", messageId: "m-1" })],
256
- messages: [message({ messageId: "m-1" })],
257
- });
101
+ it("classifies a never-examined body and denormalizes the answer", async () => {
102
+ const messageId = await stored(LIST);
258
103
 
259
- const result = await backfillClassifications(harness);
104
+ const result = await backfillClassifications(deps());
260
105
 
261
106
  assert.equal(result.backfilled, 1);
262
- assert.equal(result.failed, 0);
263
- assert.equal(result.alreadyExamined, 0);
264
- assert.deepEqual(harness.retrieved, ["s3://m-1"]);
265
- assert.equal(harness.messageUpdates.length, 1);
266
- assert.equal(harness.messageUpdates[0].messageId, "m-1");
267
- // The plain fixture carries no bulk markers, so the header rule table
268
- // answers `personal`.
269
- assert.equal(harness.messageUpdates[0].input.category, "personal");
270
- assert.equal(
271
- harness.messageUpdates[0].input.classificationState,
272
- "Examined",
273
- );
107
+ const row = await store.message.get(messageId);
108
+ assert.equal(row.category, MessageCategory.newsletter);
109
+ assert.equal(row.hasListUnsubscribe, true);
110
+ assert.equal(row.classificationState, MessageClassificationState.Examined);
111
+ const thread = await threadRow(messageId);
112
+ assert.equal(thread.category, MessageCategory.newsletter);
113
+ assert.equal(thread.listId, "news.example.com");
274
114
  });
275
115
 
276
- it("compares the sender of a Junk row the provider rated clean, and not of an inbox row", async () => {
277
- const harness = buildHarness({
278
- rows: [
279
- row({
280
- threadMessageId: "tm-junk",
281
- messageId: "m-junk",
282
- mailboxId: JUNK_MAILBOX_ID,
283
- }),
284
- row({ threadMessageId: "tm-inbox", messageId: "m-inbox" }),
285
- ],
286
- messages: [
287
- message({
288
- messageId: "m-junk",
289
- mailboxId: JUNK_MAILBOX_ID,
290
- bodyStorageKey: "s3://m-junk",
291
- }),
292
- message({ messageId: "m-inbox", bodyStorageKey: "s3://m-inbox" }),
293
- ],
294
- retrieve: async () => IMPERSONATION_EML,
295
- });
116
+ it("compares the sender of a Junk row the provider rated clean, not of an inbox row", async () => {
117
+ const inJunk = await stored(IMPERSONATION, junk);
118
+ const inInbox = await stored(IMPERSONATION);
296
119
 
297
- await backfillClassifications(harness);
120
+ await backfillClassifications(deps());
298
121
 
299
- const correspondence = new Map(
300
- harness.messageUpdates.map((update) => [
301
- update.messageId,
302
- update.input.authenticity?.displayNameCorrespondence,
303
- ]),
304
- );
305
122
  assert.equal(
306
- correspondence.get("m-junk"),
123
+ (await store.message.get(inJunk)).authenticity?.displayNameCorrespondence,
307
124
  DisplayNameCorrespondence.Unrelated,
308
125
  );
309
- assert.equal(correspondence.get("m-inbox"), undefined);
310
- });
311
-
312
- it("denormalizes the classifier's answer onto the thread row", async () => {
313
- const harness = buildHarness({
314
- rows: [row({ threadMessageId: "tm-1", messageId: "m-1" })],
315
- messages: [message({ messageId: "m-1" })],
316
- });
317
-
318
- await backfillClassifications(harness);
319
-
320
- // The thread row is written BEFORE the Message update, so a failure
321
- // between the two leaves both undone for the rerun (issue #320).
322
- assert.equal(harness.threadUpdates.length, 1);
323
- assert.equal(harness.threadUpdates[0].threadMessageId, "tm-1");
324
- assert.equal(harness.threadUpdates[0].input.category, "personal");
325
- });
326
-
327
- it("writes the derived bulk fields and the row's List-Id, from the same bytes", async () => {
328
- const harness = buildHarness({
329
- rows: [row({ threadMessageId: "tm-1", messageId: "m-1" })],
330
- messages: [message({ messageId: "m-1", bodyStorageKey: "s3://list" })],
331
- retrieve: async () => LIST_EML,
332
- });
333
-
334
- const result = await backfillClassifications(harness);
335
-
336
- assert.equal(result.backfilled, 1);
337
- assert.equal(harness.messageUpdates[0].input.category, "newsletter");
338
- assert.equal(harness.messageUpdates[0].input.hasListUnsubscribe, true);
339
- assert.equal(harness.threadUpdates[0].input.listId, "news.example.com");
126
+ assert.equal(
127
+ (await store.message.get(inInbox)).authenticity
128
+ ?.displayNameCorrespondence,
129
+ undefined,
130
+ );
340
131
  });
341
132
 
342
- it("applies the sender's Address.flags.category override over the header category", async () => {
343
- const harness = buildHarness({
344
- rows: [row({ threadMessageId: "tm-1", messageId: "m-1" })],
345
- messages: [message({ messageId: "m-1" })],
346
- addressFlags: {
347
- category: { value: "marketing", setAt: 1 },
348
- } as AddressItem["flags"],
349
- });
133
+ it("applies the sender's category override over the headers", async () => {
134
+ const messageId = await stored(PLAIN);
135
+ await store.address.updateAddress(
136
+ account.accountConfigId,
137
+ deriveAddressId(account.accountConfigId, "alice@example.com"),
138
+ { flags: { category: { value: MessageCategory.marketing, setAt: 1 } } },
139
+ );
350
140
 
351
- const result = await backfillClassifications(harness);
141
+ await backfillClassifications(deps());
352
142
 
353
- assert.equal(result.backfilled, 1);
354
- assert.equal(harness.messageUpdates[0].input.category, "marketing");
355
- assert.equal(harness.threadUpdates[0].input.category, "marketing");
143
+ assert.equal(
144
+ (await store.message.get(messageId)).category,
145
+ MessageCategory.marketing,
146
+ );
356
147
  });
357
148
 
358
- it("marks an already-categorized candidate Examined without re-deriving its category", async () => {
359
- const harness = buildHarness({
360
- rows: [row({ threadMessageId: "tm-1", messageId: "m-1" })],
361
- messages: [
362
- message({
363
- messageId: "m-1",
364
- category: "newsletter",
365
- classificationState: "NotExamined",
366
- }),
367
- ],
149
+ it("marks an already-categorized row examined and keeps its category", async () => {
150
+ const messageId = await stored(PLAIN, inbox, {
151
+ category: MessageCategory.newsletter,
368
152
  });
369
153
 
370
- const result = await backfillClassifications(harness);
154
+ const result = await backfillClassifications(deps());
371
155
 
372
- // Those rows were classified by a pass that predates the field; the
373
- // write-once category is carried forward, never recomputed (#355).
374
156
  assert.equal(result.alreadyCategorized, 1);
375
- assert.equal(result.backfilled, 0);
376
- assert.deepEqual(harness.retrieved, []);
377
- assert.equal(harness.messageUpdates.length, 1);
378
- assert.deepEqual(harness.messageUpdates[0].input, {
379
- classificationState: "Examined",
380
- });
381
- assert.deepEqual(harness.threadUpdates, []);
157
+ const row = await store.message.get(messageId);
158
+ assert.equal(row.category, MessageCategory.newsletter);
159
+ assert.equal(row.classificationState, MessageClassificationState.Examined);
382
160
  });
383
161
 
384
- it("skips a row already Examined, without reading storage", async () => {
385
- const harness = buildHarness({
386
- rows: [row({ threadMessageId: "tm-1", messageId: "m-1" })],
387
- messages: [
388
- message({ messageId: "m-1", classificationState: "Examined" }),
389
- ],
162
+ it("leaves examined rows and rows with no stored body alone", async () => {
163
+ await stored(PLAIN, inbox, {
164
+ classificationState: MessageClassificationState.Examined,
390
165
  });
166
+ const unsynced = await arriveAt(store, imap, account, inbox, PLAIN);
391
167
 
392
- const result = await backfillClassifications(harness);
168
+ const result = await backfillClassifications(deps());
393
169
 
394
170
  assert.equal(result.alreadyExamined, 1);
395
- assert.equal(result.backfilled, 0);
396
- assert.deepEqual(harness.retrieved, []);
397
- assert.deepEqual(harness.messageUpdates, []);
398
- });
399
-
400
- it("skips a candidate whose body was never synced, without reading storage", async () => {
401
- const harness = buildHarness({
402
- rows: [row({ threadMessageId: "tm-1", messageId: "m-1" })],
403
- messages: [message({ messageId: "m-1", bodyStorageKey: undefined })],
404
- });
405
-
406
- const result = await backfillClassifications(harness);
407
-
408
171
  assert.equal(result.skippedNoBody, 1);
409
- assert.deepEqual(harness.retrieved, []);
410
- assert.deepEqual(harness.messageUpdates, []);
172
+ assert.equal(
173
+ (await store.message.get(unsynced)).classificationState,
174
+ MessageClassificationState.NotExamined,
175
+ );
411
176
  });
412
177
 
413
- it("examines a message once, however many thread rows index it", async () => {
414
- const harness = buildHarness({
415
- rows: [
416
- row({ threadMessageId: "tm-1", messageId: "m-1" }),
417
- row({
418
- threadMessageId: "tm-2",
419
- messageId: "m-1",
420
- threadId: "thread-2",
421
- }),
422
- ],
423
- messages: [message({ messageId: "m-1" })],
178
+ it("examines a message once however many thread rows index it", async () => {
179
+ const messageId = await stored(PLAIN);
180
+ await store.threadMessage.create({
181
+ ...(await threadRow(messageId)),
182
+ threadId: "drifted-root",
424
183
  });
425
184
 
426
- const result = await backfillClassifications(harness);
185
+ const result = await backfillClassifications(deps());
427
186
 
428
187
  assert.equal(result.backfilled, 1);
429
188
  assert.equal(result.alreadyExamined, 1);
430
- assert.deepEqual(harness.retrieved, ["s3://m-1"]);
431
- assert.equal(harness.messageUpdates.length, 1);
432
189
  });
433
190
 
434
- it("contains a classify failure to the one message and keeps going", async () => {
435
- const harness = buildHarness({
436
- rows: [
437
- row({ threadMessageId: "tm-bad", messageId: "m-bad" }),
438
- row({ threadMessageId: "tm-good", messageId: "m-good" }),
439
- ],
440
- messages: [
441
- message({ messageId: "m-bad", bodyStorageKey: "s3://m-bad" }),
442
- message({ messageId: "m-good", bodyStorageKey: "s3://m-good" }),
443
- ],
444
- retrieve: async (key) => {
445
- if (key === "s3://m-bad") throw new Error("AccessDenied");
446
- return PLAIN_EML;
447
- },
191
+ it("contains a failure to the one message and keeps going", async () => {
192
+ const lost = await stored(PLAIN, inbox, {
193
+ bodyStorageKey: "file:///nonexistent/remit/body.eml",
448
194
  });
195
+ const fine = await stored(PLAIN);
449
196
 
450
- const result = await backfillClassifications(harness);
197
+ const result = await backfillClassifications(deps());
451
198
 
452
199
  assert.equal(result.failed, 1);
453
- assert.deepEqual(result.failedThreadMessageIds, ["tm-bad"]);
454
- assert.equal(result.backfilled, 1);
455
- assert.equal(harness.messageUpdates.length, 1);
456
- assert.equal(harness.messageUpdates[0].messageId, "m-good");
457
- });
458
-
459
- it("reports progress as pages are processed", async () => {
460
- const rows = Array.from({ length: 3 }, (_, i) =>
461
- row({ threadMessageId: `tm-${i}`, messageId: `m-${i}` }),
462
- );
463
- const messages = rows.map((r) =>
464
- message({
465
- messageId: r.messageId,
466
- bodyStorageKey: `s3://${r.messageId}`,
467
- }),
200
+ assert.deepEqual(result.failedThreadMessageIds, [
201
+ (await threadRow(lost)).threadMessageId,
202
+ ]);
203
+ assert.equal(
204
+ (await store.message.get(fine)).classificationState,
205
+ MessageClassificationState.Examined,
468
206
  );
469
- const harness = buildHarness({ rows, messages, pageSize: 2 });
470
- const progress: Array<{ accountConfigId: string; scanned: number }> = [];
471
-
472
- const result = await backfillClassifications(harness, {
473
- batchSize: 2,
474
- onProgress: (p) => progress.push({ ...p }),
475
- });
476
-
477
- assert.equal(result.backfilled, 3);
478
- assert.equal(progress.length, 2);
479
- assert.equal(progress[0].scanned, 2);
480
- assert.equal(progress[1].scanned, 3);
481
207
  });
482
208
 
483
- it("scans every account returned by listAll, in account-id order", async () => {
484
- const accountIds = ["acc-1", "acc-2", "acc-3"];
485
- const rows = accountIds.map((accountConfigId) =>
486
- row({
487
- threadMessageId: `tm-${accountConfigId}`,
488
- messageId: `m-${accountConfigId}`,
489
- accountConfigId,
490
- }),
491
- );
492
- const harness = buildHarness({
493
- accounts: ["acc-3", "acc-1", "acc-2"].map(asAccount),
494
- rows,
495
- messages: rows.map((r) =>
496
- message({
497
- messageId: r.messageId,
498
- bodyStorageKey: `s3://${r.messageId}`,
499
- }),
500
- ),
501
- });
502
- const progress: Array<{ accountConfigId: string; scanned: number }> = [];
209
+ it("scans accounts in account-config order whatever order they were created in", async () => {
210
+ const second = await seedAccount(store, { accountConfigId: "cfg-a" });
211
+ const third = await seedAccount(store, { accountConfigId: "cfg-b" });
212
+ for (const owner of [third, second]) {
213
+ imap.addFolder(`INBOX-${owner.accountConfigId}`);
214
+ await stored(
215
+ PLAIN,
216
+ await seedMailbox(store, owner, `INBOX-${owner.accountConfigId}`),
217
+ {},
218
+ owner,
219
+ );
220
+ }
221
+ const order: string[] = [];
503
222
 
504
- const result = await backfillClassifications(harness, {
505
- onProgress: (p) => progress.push({ ...p }),
223
+ await backfillClassifications(deps(), {
224
+ onProgress: (progress) => order.push(progress.accountConfigId),
506
225
  });
507
226
 
508
- assert.equal(result.backfilled, 3);
509
227
  assert.deepEqual(
510
- progress.map((p) => p.accountConfigId),
511
- accountIds,
228
+ order.filter((id) => id !== account.accountConfigId),
229
+ ["cfg-a", "cfg-b"],
512
230
  );
513
231
  });
514
232
 
515
- it("checkpoints after each page and clears it on completion", async () => {
516
- const rows = Array.from({ length: 3 }, (_, i) =>
517
- row({ threadMessageId: `tm-${i}`, messageId: `m-${i}` }),
518
- );
519
- const messages = rows.map((r) =>
520
- message({
521
- messageId: r.messageId,
522
- bodyStorageKey: `s3://${r.messageId}`,
523
- }),
524
- );
525
- const harness = buildHarness({ rows, messages, pageSize: 2 });
526
-
527
- const saved: Array<{
528
- accountConfigId: string;
529
- continuationToken?: string;
530
- }> = [];
233
+ it("resumes from its checkpoint after an interruption", async () => {
234
+ for (let i = 0; i < 3; i++) await stored(PLAIN);
235
+ let checkpoint: ClassificationBackfillCheckpoint | undefined;
531
236
  let cleared = false;
532
-
533
- await backfillClassifications(harness, {
534
- batchSize: 2,
535
- checkpointStore: {
536
- load: async () => undefined,
537
- save: async (checkpoint) => {
538
- saved.push(checkpoint);
539
- },
540
- clear: async () => {
541
- cleared = true;
542
- },
237
+ const checkpointStore = {
238
+ load: async () => checkpoint,
239
+ save: async (next: ClassificationBackfillCheckpoint) => {
240
+ checkpoint = next;
543
241
  },
544
- });
545
-
546
- assert.equal(saved.length, 2);
547
- assert.equal(saved[0].accountConfigId, "acc-1");
548
- assert.equal(saved[0].continuationToken, "2");
549
- assert.equal(saved[1].continuationToken, undefined);
550
- assert.equal(cleared, true);
551
- });
552
-
553
- it("resumes from a saved checkpoint instead of rescanning from the start", async () => {
554
- const rows = Array.from({ length: 3 }, (_, i) =>
555
- row({ threadMessageId: `tm-${i}`, messageId: `m-${i}` }),
556
- );
557
- const messages = rows.map((r) =>
558
- message({
559
- messageId: r.messageId,
560
- bodyStorageKey: `s3://${r.messageId}`,
242
+ clear: async () => {
243
+ checkpoint = undefined;
244
+ cleared = true;
245
+ },
246
+ };
247
+
248
+ await assert.rejects(
249
+ backfillClassifications(deps(), {
250
+ batchSize: 2,
251
+ checkpointStore,
252
+ onProgress: () => {
253
+ throw new Error("killed");
254
+ },
561
255
  }),
562
256
  );
563
- const harness = buildHarness({ rows, messages, pageSize: 2 });
564
-
565
- const result = await backfillClassifications(harness, {
257
+ const resumed = await backfillClassifications(deps(), {
566
258
  batchSize: 2,
567
- checkpointStore: {
568
- load: async () => ({
569
- accountConfigId: "acc-1",
570
- continuationToken: "2",
571
- }),
572
- save: async () => {},
573
- clear: async () => {},
574
- },
259
+ checkpointStore,
575
260
  });
576
261
 
577
- assert.equal(result.scanned, 1);
578
- assert.equal(result.backfilled, 1);
579
- assert.deepEqual(
580
- harness.messageUpdates.map((u) => u.messageId),
581
- ["m-2"],
582
- );
262
+ assert.equal(resumed.scanned, 1);
263
+ assert.equal(resumed.backfilled, 1);
264
+ assert.equal(cleared, true);
583
265
  });
584
266
 
585
267
  it("restarts the pass when the checkpointed account no longer exists", async () => {
586
- const rows = Array.from({ length: 3 }, (_, i) =>
587
- row({ threadMessageId: `tm-${i}`, messageId: `m-${i}` }),
588
- );
589
- const messages = rows.map((r) =>
590
- message({
591
- messageId: r.messageId,
592
- bodyStorageKey: `s3://${r.messageId}`,
593
- }),
594
- );
595
- const harness = buildHarness({ rows, messages, pageSize: 2 });
268
+ for (let i = 0; i < 3; i++) await stored(PLAIN);
596
269
 
597
- const result = await backfillClassifications(harness, {
270
+ const result = await backfillClassifications(deps(), {
598
271
  batchSize: 2,
599
272
  checkpointStore: {
600
273
  load: async () => ({
601
- accountConfigId: "acc-removed",
274
+ accountConfigId: "cfg-removed",
602
275
  continuationToken: "2",
603
276
  }),
604
277
  save: async () => {},
@@ -607,9 +280,6 @@ describe("backfillClassifications", () => {
607
280
  });
608
281
 
609
282
  assert.equal(result.scanned, 3);
610
- assert.deepEqual(
611
- harness.messageUpdates.map((u) => u.messageId),
612
- ["m-0", "m-1", "m-2"],
613
- );
283
+ assert.equal(result.backfilled, 3);
614
284
  });
615
285
  });