@remit/mailbox-service 0.0.29 → 0.0.31

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/mailbox-service",
3
- "version": "0.0.29",
3
+ "version": "0.0.31",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -0,0 +1,228 @@
1
+ /**
2
+ * RFC 034 Decision 3.1: `Message.category` is written once and never mutated
3
+ * after — RFC 030's message-list GSI sort key depends on it never churning.
4
+ *
5
+ * `applyPostStoreSteps` computes a fresh classification on every pass and
6
+ * folds it into the single Message update, with no check for whether the
7
+ * message already carries a decided category. Two shipped paths re-enter it
8
+ * on an already-classified message — the `NoSuchKey` fallback in
9
+ * `fetchAndGetBody`, and `syncBodies(..., force: true)` — and both must leave
10
+ * `category` untouched. Each test below feeds a re-entrant pass a body that
11
+ * would classify differently from the message's existing category, so a
12
+ * regression that drops the guard shows up even though header classification
13
+ * is otherwise deterministic (issue #355).
14
+ */
15
+
16
+ import assert from "node:assert/strict";
17
+ import { Readable } from "node:stream";
18
+ import { describe, it } from "node:test";
19
+ import type {
20
+ IAddressRepository,
21
+ IEnvelopeRepository,
22
+ IMessageRepository,
23
+ IThreadMessageRepository,
24
+ MessageItem,
25
+ UpdateMessageInput,
26
+ } from "@remit/data-ports";
27
+ import { MessageCategory } from "@remit/domain-enums";
28
+ import type { StorageService } from "@remit/storage-service";
29
+ import { BodySyncService } from "./body-sync.js";
30
+ import type { IImapConnection } from "./types.js";
31
+
32
+ const LINKEDIN_EML = Buffer.from(
33
+ [
34
+ "From: LinkedIn <messages-noreply@linkedin.com>",
35
+ "To: me@example.com",
36
+ "Subject: You have a new invitation",
37
+ "Content-Type: text/plain",
38
+ "",
39
+ "invitation",
40
+ ].join("\r\n"),
41
+ );
42
+
43
+ const PERSONAL_EML = Buffer.from(
44
+ [
45
+ "From: Alex <alex@example.com>",
46
+ "To: me@example.com",
47
+ "Subject: Dinner Friday?",
48
+ "Content-Type: text/plain",
49
+ "",
50
+ "Are you free Friday night?",
51
+ ].join("\r\n"),
52
+ );
53
+
54
+ interface Harness {
55
+ service: BodySyncService;
56
+ message: MessageItem;
57
+ messageUpdates: Array<{ messageId: string; input: UpdateMessageInput }>;
58
+ }
59
+
60
+ const buildHarness = (
61
+ message: Partial<MessageItem> & Pick<MessageItem, "messageId">,
62
+ retrieve: () => Promise<Buffer>,
63
+ ): Harness => {
64
+ const messageUpdates: Array<{
65
+ messageId: string;
66
+ input: UpdateMessageInput;
67
+ }> = [];
68
+
69
+ const messageRow = {
70
+ uid: 1,
71
+ mailboxId: "mb-1",
72
+ ...message,
73
+ } as unknown as MessageItem;
74
+
75
+ const messageService = {
76
+ get: async () => messageRow,
77
+ update: async (messageId: string, input: UpdateMessageInput) => {
78
+ messageUpdates.push({ messageId, input });
79
+ Object.assign(messageRow, input);
80
+ },
81
+ } as unknown as IMessageRepository;
82
+
83
+ const threadMessageService = {
84
+ findAllByMessageId: async () => [
85
+ {
86
+ threadMessageId: "tm-1",
87
+ messageId: message.messageId,
88
+ mailboxId: "mb-1",
89
+ sentDate: 1,
90
+ isRead: false,
91
+ isDeleted: false,
92
+ hasStars: false,
93
+ hasAttachment: false,
94
+ category: MessageCategory.uncategorized,
95
+ },
96
+ ],
97
+ update: async () => {},
98
+ } as unknown as IThreadMessageRepository;
99
+
100
+ const storageService = {
101
+ retrieve,
102
+ storeMessageBody: async () => ({ uri: `s3://bodies/${message.messageId}` }),
103
+ storeMessageBodyStream: async () => ({
104
+ uri: `s3://bodies/${message.messageId}`,
105
+ }),
106
+ storeParsedBody: async () => {},
107
+ listBodyParts: async () => [],
108
+ } as unknown as StorageService;
109
+
110
+ const service = new BodySyncService(
111
+ messageService,
112
+ storageService,
113
+ threadMessageService,
114
+ { incrementInboundCount: async () => {} } as unknown as IAddressRepository,
115
+ { listBodyParts: async () => [] } as unknown as IEnvelopeRepository,
116
+ { info: () => {}, error: () => {} },
117
+ );
118
+
119
+ return { service, message: messageRow, messageUpdates };
120
+ };
121
+
122
+ const noSuchKeyError = () =>
123
+ Object.assign(new Error("missing"), {
124
+ name: "NoSuchKey",
125
+ });
126
+
127
+ describe("Message.category survives a re-entrant classification pass", () => {
128
+ it("keeps the existing category through the NoSuchKey IMAP re-fetch", async () => {
129
+ const harness = buildHarness(
130
+ {
131
+ messageId: "m-1",
132
+ bodyStorageKey: "s3://bodies/m-1",
133
+ category: MessageCategory.marketing,
134
+ },
135
+ async () => {
136
+ throw noSuchKeyError();
137
+ },
138
+ );
139
+
140
+ const connection = {
141
+ openBox: async () => {},
142
+ fetchMessageBody: async () => LINKEDIN_EML,
143
+ } as unknown as IImapConnection;
144
+
145
+ await harness.service.fetchAndGetBody(
146
+ "m-1",
147
+ "acc-1",
148
+ "cfg-1",
149
+ "INBOX",
150
+ async () => connection,
151
+ );
152
+
153
+ assert.equal(harness.messageUpdates.length, 1);
154
+ assert.equal(
155
+ harness.messageUpdates[0].input.category,
156
+ MessageCategory.marketing,
157
+ );
158
+ assert.equal(harness.message.category, MessageCategory.marketing);
159
+ });
160
+
161
+ it("keeps the existing category when syncBodies re-fetches with force", async () => {
162
+ const harness = buildHarness(
163
+ {
164
+ messageId: "m-1",
165
+ bodyStorageKey: "s3://bodies/m-1",
166
+ category: MessageCategory.marketing,
167
+ },
168
+ async () => {
169
+ throw new Error("force path must not retrieve from storage");
170
+ },
171
+ );
172
+
173
+ const connection = {
174
+ openBox: async () => {},
175
+ async *fetchMessageBodies(uids: number[]) {
176
+ for (const uid of uids) {
177
+ yield { uid, source: Readable.from([LINKEDIN_EML]) };
178
+ }
179
+ },
180
+ } as unknown as IImapConnection;
181
+
182
+ const result = await harness.service.syncBodies(
183
+ ["m-1"],
184
+ "acc-1",
185
+ "cfg-1",
186
+ "INBOX",
187
+ async () => connection,
188
+ true,
189
+ );
190
+
191
+ assert.deepEqual(result.syncedMessageIds, ["m-1"]);
192
+ assert.equal(harness.messageUpdates.length, 1);
193
+ assert.equal(
194
+ harness.messageUpdates[0].input.category,
195
+ MessageCategory.marketing,
196
+ );
197
+ assert.equal(harness.message.category, MessageCategory.marketing);
198
+ });
199
+
200
+ it("still writes the category on a first classification", async () => {
201
+ const harness = buildHarness(
202
+ { messageId: "m-1", category: MessageCategory.uncategorized },
203
+ async () => {
204
+ throw new Error("no body stored yet; must not retrieve");
205
+ },
206
+ );
207
+
208
+ const connection = {
209
+ openBox: async () => {},
210
+ fetchMessageBody: async () => PERSONAL_EML,
211
+ } as unknown as IImapConnection;
212
+
213
+ await harness.service.fetchAndGetBody(
214
+ "m-1",
215
+ "acc-1",
216
+ "cfg-1",
217
+ "INBOX",
218
+ async () => connection,
219
+ );
220
+
221
+ assert.equal(harness.messageUpdates.length, 1);
222
+ assert.equal(
223
+ harness.messageUpdates[0].input.category,
224
+ MessageCategory.personal,
225
+ );
226
+ assert.equal(harness.message.category, MessageCategory.personal);
227
+ });
228
+ });
package/src/body-sync.ts CHANGED
@@ -169,6 +169,20 @@ const alreadyDenormalized = (
169
169
  (update.snippet === undefined || row.snippet === update.snippet) &&
170
170
  (update.listId === undefined || row.listId === update.listId);
171
171
 
172
+ /**
173
+ * RFC 034 Decision 3.1: `Message.category` is written once and never mutated
174
+ * after — RFC 030's message-list GSI sort key depends on it never churning.
175
+ * "Already decided" is any real category; `uncategorized` and the field being
176
+ * absent (rows written before the column existed) both mean "not yet decided"
177
+ * and must still classify. The one rule every re-entrant classification path
178
+ * shares — {@link BodySyncService.backfillClassification} and
179
+ * {@link BodySyncService.applyPostStoreSteps} both defer to it.
180
+ */
181
+ const hasDecidedCategory = (
182
+ category: ThreadMessageCategory | undefined,
183
+ ): boolean =>
184
+ category !== undefined && category !== MessageCategory.uncategorized;
185
+
172
186
  export const toParsedBody = (parsed: ParsedMail): ParsedBody => ({
173
187
  text: parsed.text ?? null,
174
188
  html: typeof parsed.html === "string" ? parsed.html : null,
@@ -808,9 +822,20 @@ export class BodySyncService {
808
822
  // a filter); the verdict is folded in whenever Remit decided to act.
809
823
  // Written LAST so bodyStorageKey — the skip-guard signal — is only durable
810
824
  // once the parsed cache AND the move (when any) are.
825
+ //
826
+ // `category` is RFC 034 D3.1's write-once field (RFC 030's message-list
827
+ // GSI sort key depends on it never churning). This step re-enters on an
828
+ // already-classified message through two shipped paths — the `NoSuchKey`
829
+ // fallback in `fetchAndGetBody` and `syncBodies(..., force: true)` — so a
830
+ // real, previously-decided category is carried forward unchanged instead
831
+ // of the just-recomputed one, the same rule `backfillClassification` uses.
832
+ const existingMessage = await this.messageService.get(messageId);
811
833
  const update: UpdateMessageInput = {
812
834
  bodyStorageKey: bodyRef.uri,
813
835
  ...classification,
836
+ category: hasDecidedCategory(existingMessage.category)
837
+ ? existingMessage.category
838
+ : classification.category,
814
839
  ...(moved ? { movedByRemit: true } : {}),
815
840
  ...(resolved.verdict ? { placementVerdict: resolved.verdict } : {}),
816
841
  ...(filterMove ? { filterMove } : {}),
@@ -962,12 +987,7 @@ export class BodySyncService {
962
987
  accountConfigId: string,
963
988
  ): Promise<void> {
964
989
  if (!message.bodyStorageKey) return;
965
- if (
966
- message.category !== undefined &&
967
- message.category !== MessageCategory.uncategorized
968
- ) {
969
- return;
970
- }
990
+ if (hasDecidedCategory(message.category)) return;
971
991
 
972
992
  const body = await this.storageService.retrieve(message.bodyStorageKey);
973
993
  const parsed = await parseMessageBody(body);
package/src/index.ts CHANGED
@@ -122,6 +122,17 @@ export {
122
122
  createImapFlowConnectionWithCredentials,
123
123
  ImapFlowConnection,
124
124
  } from "./imapflow-connection.js";
125
+ export {
126
+ backfillListIds,
127
+ type ListIdBackfillCheckpoint,
128
+ type ListIdBackfillCheckpointStore,
129
+ type ListIdBackfillDeps,
130
+ type ListIdBackfillLogger,
131
+ type ListIdBackfillOptions,
132
+ type ListIdBackfillProgress,
133
+ type ListIdBackfillResult,
134
+ type ListIdBackfillTotals,
135
+ } from "./list-id-backfill.js";
125
136
  export {
126
137
  guardConnectionCursor,
127
138
  guardMailboxCursor,
@@ -0,0 +1,416 @@
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
+ 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";
23
+ import type { StorageService } from "@remit/storage-service";
24
+ import {
25
+ backfillListIds,
26
+ type ListIdBackfillCheckpoint,
27
+ type ListIdBackfillProgress,
28
+ } from "./list-id-backfill.js";
29
+
30
+ const LIST_EML = Buffer.from(
31
+ [
32
+ "From: Weekly News <news@example.com>",
33
+ "To: me@example.com",
34
+ "Subject: This week",
35
+ "List-Id: Weekly News <weekly.news.example.com>",
36
+ "Content-Type: text/plain",
37
+ "",
38
+ "news",
39
+ ].join("\r\n"),
40
+ );
41
+
42
+ const PLAIN_EML = Buffer.from(
43
+ [
44
+ "From: Alice <alice@example.com>",
45
+ "To: me@example.com",
46
+ "Subject: Hi",
47
+ "Content-Type: text/plain",
48
+ "",
49
+ "hi",
50
+ ].join("\r\n"),
51
+ );
52
+
53
+ const MALFORMED_LIST_ID_EML = Buffer.from(
54
+ [
55
+ "From: Weird <weird@example.com>",
56
+ "To: me@example.com",
57
+ "Subject: Odd header",
58
+ "List-Id:",
59
+ "Content-Type: text/plain",
60
+ "",
61
+ "body",
62
+ ].join("\r\n"),
63
+ );
64
+
65
+ const row = (overrides: Partial<ThreadMessageItem>): ThreadMessageItem =>
66
+ ({
67
+ threadMessageId: "tm-1",
68
+ accountConfigId: "acc-1",
69
+ threadId: "thread-1",
70
+ messageId: "m-1",
71
+ mailboxId: "mb-1",
72
+ uid: 1,
73
+ referenceOrder: 0,
74
+ internalDate: 1,
75
+ sentDate: 1,
76
+ isRead: false,
77
+ hasAttachment: false,
78
+ star: "none",
79
+ hasStars: false,
80
+ isDeleted: false,
81
+ category: "uncategorized",
82
+ createdAt: 1,
83
+ updatedAt: 1,
84
+ ...overrides,
85
+ }) as unknown as ThreadMessageItem;
86
+
87
+ const message = (overrides: Partial<MessageItem>): MessageItem =>
88
+ ({
89
+ messageId: "m-1",
90
+ mailboxId: "mb-1",
91
+ uid: 1,
92
+ status: "active",
93
+ syncStatus: "synced",
94
+ category: "uncategorized",
95
+ hasListUnsubscribe: false,
96
+ movedByRemit: false,
97
+ createdAt: 1,
98
+ updatedAt: 1,
99
+ ...overrides,
100
+ }) as unknown as MessageItem;
101
+
102
+ interface Harness {
103
+ accountConfigService: Pick<IAccountConfigRepository, "listAll">;
104
+ threadMessageService: Pick<
105
+ IThreadMessageRepository,
106
+ "listByAccount" | "update"
107
+ >;
108
+ messageService: Pick<IMessageRepository, "get">;
109
+ storageService: Pick<StorageService, "retrieve">;
110
+ updates: Array<{ threadMessageId: string; input: UpdateThreadMessageInput }>;
111
+ retrieved: string[];
112
+ }
113
+
114
+ const buildHarness = (options: {
115
+ accounts?: AccountConfigItem[];
116
+ rows: ThreadMessageItem[];
117
+ messages: MessageItem[];
118
+ retrieve?: (key: string) => Promise<Buffer>;
119
+ pageSize?: number;
120
+ }): Harness => {
121
+ const accounts = options.accounts ?? [
122
+ { accountConfigId: "acc-1" } as unknown as AccountConfigItem,
123
+ ];
124
+ const messagesById = new Map(options.messages.map((m) => [m.messageId, m]));
125
+ const updates: Array<{
126
+ threadMessageId: string;
127
+ input: UpdateThreadMessageInput;
128
+ }> = [];
129
+ const retrieved: string[] = [];
130
+ const pageSize = options.pageSize ?? 200;
131
+
132
+ const accountConfigService: Pick<IAccountConfigRepository, "listAll"> = {
133
+ listAll: async () => accounts,
134
+ };
135
+
136
+ const threadMessageService: Pick<
137
+ IThreadMessageRepository,
138
+ "listByAccount" | "update"
139
+ > = {
140
+ listByAccount: async (
141
+ accountConfigId: string,
142
+ opts?: { limit?: number; continuationToken?: string },
143
+ ): Promise<ResultList<ThreadMessageItem>> => {
144
+ const scoped = options.rows.filter(
145
+ (r) => r.accountConfigId === accountConfigId,
146
+ );
147
+ const start = opts?.continuationToken
148
+ ? Number(opts.continuationToken)
149
+ : 0;
150
+ const limit = opts?.limit ?? pageSize;
151
+ const page = scoped.slice(start, start + limit);
152
+ const nextStart = start + page.length;
153
+ return {
154
+ items: page,
155
+ continuationToken:
156
+ nextStart < scoped.length ? String(nextStart) : undefined,
157
+ };
158
+ },
159
+ update: async (
160
+ _accountConfigId: string,
161
+ threadMessageId: string,
162
+ input: UpdateThreadMessageInput,
163
+ ) => {
164
+ updates.push({ threadMessageId, input });
165
+ return row({ threadMessageId, ...input });
166
+ },
167
+ };
168
+
169
+ const messageService: Pick<IMessageRepository, "get"> = {
170
+ get: (async (messageIds: string | string[]) => {
171
+ if (Array.isArray(messageIds)) {
172
+ return messageIds
173
+ .map((id) => messagesById.get(id))
174
+ .filter((m): m is MessageItem => m !== undefined);
175
+ }
176
+ const found = messagesById.get(messageIds);
177
+ if (!found) throw new Error(`no fixture for ${messageIds}`);
178
+ return found;
179
+ }) as IMessageRepository["get"],
180
+ };
181
+
182
+ const storageService: Pick<StorageService, "retrieve"> = {
183
+ retrieve: async (key: string) => {
184
+ retrieved.push(key);
185
+ return options.retrieve ? options.retrieve(key) : LIST_EML;
186
+ },
187
+ };
188
+
189
+ return {
190
+ accountConfigService,
191
+ threadMessageService,
192
+ messageService,
193
+ storageService,
194
+ updates,
195
+ retrieved,
196
+ };
197
+ };
198
+
199
+ describe("backfillListIds", () => {
200
+ it("writes the extracted List-Id for a candidate row", async () => {
201
+ const harness = buildHarness({
202
+ rows: [row({ threadMessageId: "tm-1", messageId: "m-1" })],
203
+ messages: [message({ messageId: "m-1", bodyStorageKey: "s3://m-1" })],
204
+ });
205
+
206
+ const result = await backfillListIds(harness);
207
+
208
+ assert.equal(result.backfilled, 1);
209
+ assert.equal(result.failed, 0);
210
+ assert.equal(harness.updates.length, 1);
211
+ assert.equal(harness.updates[0].input.listId, "weekly.news.example.com");
212
+ });
213
+
214
+ it("leaves listId empty, without error, when the message has no List-Id header", async () => {
215
+ const harness = buildHarness({
216
+ rows: [row({ threadMessageId: "tm-1", messageId: "m-1" })],
217
+ messages: [message({ messageId: "m-1", bodyStorageKey: "s3://m-1" })],
218
+ retrieve: async () => PLAIN_EML,
219
+ });
220
+
221
+ const result = await backfillListIds(harness);
222
+
223
+ assert.equal(result.noListId, 1);
224
+ assert.equal(result.failed, 0);
225
+ assert.deepEqual(harness.updates, []);
226
+ });
227
+
228
+ it("does not error on a malformed List-Id header", async () => {
229
+ const harness = buildHarness({
230
+ rows: [row({ threadMessageId: "tm-1", messageId: "m-1" })],
231
+ messages: [message({ messageId: "m-1", bodyStorageKey: "s3://m-1" })],
232
+ retrieve: async () => MALFORMED_LIST_ID_EML,
233
+ });
234
+
235
+ const result = await backfillListIds(harness);
236
+
237
+ assert.equal(result.failed, 0);
238
+ assert.equal(result.noListId, 1);
239
+ assert.deepEqual(harness.updates, []);
240
+ });
241
+
242
+ it("skips a row whose listId is already set, without reading storage", async () => {
243
+ const harness = buildHarness({
244
+ rows: [
245
+ row({
246
+ threadMessageId: "tm-1",
247
+ messageId: "m-1",
248
+ listId: "already.set",
249
+ }),
250
+ ],
251
+ messages: [message({ messageId: "m-1", bodyStorageKey: "s3://m-1" })],
252
+ });
253
+
254
+ const result = await backfillListIds(harness);
255
+
256
+ assert.equal(result.alreadySet, 1);
257
+ assert.equal(result.backfilled, 0);
258
+ assert.deepEqual(harness.retrieved, []);
259
+ assert.deepEqual(harness.updates, []);
260
+ });
261
+
262
+ it("skips a candidate whose body was never synced, without reading storage", async () => {
263
+ const harness = buildHarness({
264
+ rows: [row({ threadMessageId: "tm-1", messageId: "m-1" })],
265
+ messages: [message({ messageId: "m-1", bodyStorageKey: undefined })],
266
+ });
267
+
268
+ const result = await backfillListIds(harness);
269
+
270
+ assert.equal(result.skippedNoBody, 1);
271
+ assert.deepEqual(harness.retrieved, []);
272
+ assert.deepEqual(harness.updates, []);
273
+ });
274
+
275
+ it("contains a storage failure to the one message and keeps going", async () => {
276
+ const harness = buildHarness({
277
+ rows: [
278
+ row({ threadMessageId: "tm-bad", messageId: "m-bad" }),
279
+ row({ threadMessageId: "tm-good", messageId: "m-good" }),
280
+ ],
281
+ messages: [
282
+ message({ messageId: "m-bad", bodyStorageKey: "s3://m-bad" }),
283
+ message({ messageId: "m-good", bodyStorageKey: "s3://m-good" }),
284
+ ],
285
+ retrieve: async (key) => {
286
+ if (key === "s3://m-bad") throw new Error("AccessDenied");
287
+ return LIST_EML;
288
+ },
289
+ });
290
+
291
+ const result = await backfillListIds(harness);
292
+
293
+ assert.equal(result.failed, 1);
294
+ assert.deepEqual(result.failedThreadMessageIds, ["tm-bad"]);
295
+ assert.equal(result.backfilled, 1);
296
+ assert.equal(harness.updates.length, 1);
297
+ assert.equal(harness.updates[0].threadMessageId, "tm-good");
298
+ });
299
+
300
+ it("reports progress as pages are processed", async () => {
301
+ const rows = Array.from({ length: 3 }, (_, i) =>
302
+ row({ threadMessageId: `tm-${i}`, messageId: `m-${i}` }),
303
+ );
304
+ const messages = rows.map((r) =>
305
+ message({
306
+ messageId: r.messageId,
307
+ bodyStorageKey: `s3://${r.messageId}`,
308
+ }),
309
+ );
310
+ const harness = buildHarness({ rows, messages, pageSize: 2 });
311
+ const progress: ListIdBackfillProgress[] = [];
312
+
313
+ const result = await backfillListIds(harness, {
314
+ batchSize: 2,
315
+ onProgress: (p) => progress.push({ ...p }),
316
+ });
317
+
318
+ assert.equal(result.backfilled, 3);
319
+ assert.equal(progress.length, 2);
320
+ assert.equal(progress[0].scanned, 2);
321
+ assert.equal(progress[1].scanned, 3);
322
+ });
323
+
324
+ it("scans every account returned by listAll", async () => {
325
+ const harness = buildHarness({
326
+ accounts: [
327
+ { accountConfigId: "acc-1" } as unknown as AccountConfigItem,
328
+ { accountConfigId: "acc-2" } as unknown as AccountConfigItem,
329
+ ],
330
+ rows: [
331
+ row({
332
+ threadMessageId: "tm-1",
333
+ messageId: "m-1",
334
+ accountConfigId: "acc-1",
335
+ }),
336
+ row({
337
+ threadMessageId: "tm-2",
338
+ messageId: "m-2",
339
+ accountConfigId: "acc-2",
340
+ }),
341
+ ],
342
+ messages: [
343
+ message({ messageId: "m-1", bodyStorageKey: "s3://m-1" }),
344
+ message({ messageId: "m-2", bodyStorageKey: "s3://m-2" }),
345
+ ],
346
+ });
347
+
348
+ const result = await backfillListIds(harness);
349
+
350
+ assert.equal(result.backfilled, 2);
351
+ assert.equal(harness.updates.length, 2);
352
+ });
353
+
354
+ it("checkpoints after each page and clears it on completion", async () => {
355
+ const rows = Array.from({ length: 3 }, (_, i) =>
356
+ row({ threadMessageId: `tm-${i}`, messageId: `m-${i}` }),
357
+ );
358
+ const messages = rows.map((r) =>
359
+ message({
360
+ messageId: r.messageId,
361
+ bodyStorageKey: `s3://${r.messageId}`,
362
+ }),
363
+ );
364
+ const harness = buildHarness({ rows, messages, pageSize: 2 });
365
+
366
+ const saved: ListIdBackfillCheckpoint[] = [];
367
+ let cleared = false;
368
+
369
+ await backfillListIds(harness, {
370
+ batchSize: 2,
371
+ checkpointStore: {
372
+ load: async () => undefined,
373
+ save: async (checkpoint) => {
374
+ saved.push(checkpoint);
375
+ },
376
+ clear: async () => {
377
+ cleared = true;
378
+ },
379
+ },
380
+ });
381
+
382
+ assert.equal(saved.length, 2);
383
+ assert.equal(saved[0].continuationToken, "2");
384
+ assert.equal(saved[1].continuationToken, undefined);
385
+ assert.equal(cleared, true);
386
+ });
387
+
388
+ it("resumes from a saved checkpoint instead of rescanning from the start", async () => {
389
+ const rows = Array.from({ length: 3 }, (_, i) =>
390
+ row({ threadMessageId: `tm-${i}`, messageId: `m-${i}` }),
391
+ );
392
+ const messages = rows.map((r) =>
393
+ message({
394
+ messageId: r.messageId,
395
+ bodyStorageKey: `s3://${r.messageId}`,
396
+ }),
397
+ );
398
+ const harness = buildHarness({ rows, messages, pageSize: 2 });
399
+
400
+ const result = await backfillListIds(harness, {
401
+ batchSize: 2,
402
+ checkpointStore: {
403
+ load: async () => ({ accountIndex: 0, continuationToken: "2" }),
404
+ save: async () => {},
405
+ clear: async () => {},
406
+ },
407
+ });
408
+
409
+ assert.equal(result.scanned, 1);
410
+ assert.equal(result.backfilled, 1);
411
+ assert.deepEqual(
412
+ harness.updates.map((u) => u.threadMessageId),
413
+ ["tm-2"],
414
+ );
415
+ });
416
+ });
@@ -0,0 +1,252 @@
1
+ import type {
2
+ IAccountConfigRepository,
3
+ IMessageRepository,
4
+ IThreadMessageRepository,
5
+ ThreadMessageItem,
6
+ } from "@remit/data-ports";
7
+ import type { StorageService } from "@remit/storage-service";
8
+ import { parseMessageBody } from "./body-parse.js";
9
+ import { extractListId } from "./filters/list-id.js";
10
+
11
+ const DEFAULT_BATCH_SIZE = 200;
12
+
13
+ /**
14
+ * Where the full-corpus pass left off: the index into the account list (in
15
+ * `listAll()` order) it was working through, and the page cursor within that
16
+ * account. Resuming skips every account before it entirely and re-opens the
17
+ * in-flight one at its saved cursor, rather than re-scanning the whole corpus
18
+ * from the top after an interruption.
19
+ */
20
+ export interface ListIdBackfillCheckpoint {
21
+ accountIndex: number;
22
+ continuationToken?: string;
23
+ }
24
+
25
+ /**
26
+ * Persistence for {@link ListIdBackfillCheckpoint}, injected so the pass stays
27
+ * testable with an in-memory fake; the real entrypoint backs it with a file.
28
+ * `clear()` runs once the whole corpus has been scanned, so a later run starts
29
+ * fresh rather than reading a stale finished-run checkpoint.
30
+ */
31
+ export interface ListIdBackfillCheckpointStore {
32
+ load(): Promise<ListIdBackfillCheckpoint | undefined>;
33
+ save(checkpoint: ListIdBackfillCheckpoint): Promise<void>;
34
+ clear(): Promise<void>;
35
+ }
36
+
37
+ export interface ListIdBackfillDeps {
38
+ accountConfigService: Pick<IAccountConfigRepository, "listAll">;
39
+ threadMessageService: Pick<
40
+ IThreadMessageRepository,
41
+ "listByAccount" | "update"
42
+ >;
43
+ messageService: Pick<IMessageRepository, "get">;
44
+ storageService: Pick<StorageService, "retrieve">;
45
+ }
46
+
47
+ export interface ListIdBackfillLogger {
48
+ info(obj: Record<string, unknown>, msg: string): void;
49
+ error?(obj: Record<string, unknown>, msg: string): void;
50
+ }
51
+
52
+ export interface ListIdBackfillTotals {
53
+ scanned: number;
54
+ alreadySet: number;
55
+ skippedNoBody: number;
56
+ backfilled: number;
57
+ noListId: number;
58
+ failed: number;
59
+ }
60
+
61
+ export interface ListIdBackfillResult extends ListIdBackfillTotals {
62
+ failedThreadMessageIds: string[];
63
+ }
64
+
65
+ export interface ListIdBackfillProgress extends ListIdBackfillTotals {
66
+ accountConfigId: string;
67
+ }
68
+
69
+ export interface ListIdBackfillOptions {
70
+ /** Rows fetched per `listByAccount` page. */
71
+ batchSize?: number;
72
+ checkpointStore?: ListIdBackfillCheckpointStore;
73
+ logger?: ListIdBackfillLogger;
74
+ /** Called once per page, after that page's rows are settled. */
75
+ onProgress?: (progress: ListIdBackfillProgress) => void;
76
+ }
77
+
78
+ const emptyTotals = (): ListIdBackfillTotals => ({
79
+ scanned: 0,
80
+ alreadySet: 0,
81
+ skippedNoBody: 0,
82
+ backfilled: 0,
83
+ noListId: 0,
84
+ failed: 0,
85
+ });
86
+
87
+ /**
88
+ * Read the stored raw source, derive `List-Id`, and — only when one is
89
+ * present — write it. Kept as its own function (rather than inline in a
90
+ * try/catch) so the caller can contain a failure with `.then(fulfilled,
91
+ * rejected)` instead of a block catch, matching how `BodySyncService`
92
+ * contains a per-message backfill failure.
93
+ */
94
+ const deriveAndApplyListId = async (
95
+ deps: ListIdBackfillDeps,
96
+ accountConfigId: string,
97
+ row: ThreadMessageItem,
98
+ bodyStorageKey: string,
99
+ ): Promise<string> => {
100
+ const body = await deps.storageService.retrieve(bodyStorageKey);
101
+ const parsed = await parseMessageBody(body);
102
+ const listId = extractListId(parsed);
103
+
104
+ if (listId) {
105
+ await deps.threadMessageService.update(
106
+ accountConfigId,
107
+ row.threadMessageId,
108
+ { listId },
109
+ {
110
+ composites: {
111
+ sentDate: row.sentDate,
112
+ mailboxId: row.mailboxId,
113
+ isRead: row.isRead,
114
+ isDeleted: row.isDeleted,
115
+ hasStars: row.hasStars,
116
+ hasAttachment: row.hasAttachment,
117
+ },
118
+ },
119
+ );
120
+ }
121
+
122
+ return listId;
123
+ };
124
+
125
+ /**
126
+ * One-time, resumable pass that derives `ThreadMessage.listId` for rows synced
127
+ * before header extraction shipped (issue #263). Read-only against the stored
128
+ * raw source: it never opens IMAP and never touches anything but the single
129
+ * `listId` field.
130
+ *
131
+ * A row is a candidate when `listId` is `undefined` — written before the
132
+ * column existed, or written since by a path that found no `List-Id` header
133
+ * (body-sync only sets the field when the header is present, so "no header"
134
+ * and "not yet backfilled" look the same on the row; re-checking an
135
+ * already-correct empty row is wasted work, never a wrong answer). A
136
+ * candidate whose Message has no `bodyStorageKey` yet is left alone — its
137
+ * body was never synced, so there is nothing local to read, and the ordinary
138
+ * sync path will populate `listId` for it once the body lands.
139
+ *
140
+ * Chunked by `listByAccount`'s existing keyset pagination, one account at a
141
+ * time in `listAll()` order. A failure reading or parsing one message's
142
+ * stored body is contained to that message — logged, counted, and the pass
143
+ * continues — the same containment `BodySyncService`'s classification
144
+ * backfill uses for the same reason: one unreadable object must not strand
145
+ * the rest of the corpus.
146
+ */
147
+ export const backfillListIds = async (
148
+ deps: ListIdBackfillDeps,
149
+ options: ListIdBackfillOptions = {},
150
+ ): Promise<ListIdBackfillResult> => {
151
+ const batchSize = options.batchSize ?? DEFAULT_BATCH_SIZE;
152
+ const { logger, checkpointStore } = options;
153
+
154
+ const accounts = await deps.accountConfigService.listAll();
155
+ const startingCheckpoint = await checkpointStore?.load();
156
+ const startIndex = startingCheckpoint?.accountIndex ?? 0;
157
+
158
+ const totals = emptyTotals();
159
+ const failedThreadMessageIds: string[] = [];
160
+
161
+ for (
162
+ let accountIndex = startIndex;
163
+ accountIndex < accounts.length;
164
+ accountIndex++
165
+ ) {
166
+ const account = accounts[accountIndex];
167
+ let continuationToken: string | undefined =
168
+ accountIndex === startIndex
169
+ ? startingCheckpoint?.continuationToken
170
+ : undefined;
171
+
172
+ do {
173
+ const page = await deps.threadMessageService.listByAccount(
174
+ account.accountConfigId,
175
+ { limit: batchSize, continuationToken },
176
+ );
177
+
178
+ totals.scanned += page.items.length;
179
+ const candidates = page.items.filter((row) => row.listId === undefined);
180
+ totals.alreadySet += page.items.length - candidates.length;
181
+
182
+ if (candidates.length > 0) {
183
+ const messages = await deps.messageService.get(
184
+ candidates.map((row) => row.messageId),
185
+ );
186
+ const messageByMessageId = new Map(
187
+ messages.map((message) => [message.messageId, message]),
188
+ );
189
+
190
+ for (const row of candidates) {
191
+ const message = messageByMessageId.get(row.messageId);
192
+ if (!message?.bodyStorageKey) {
193
+ totals.skippedNoBody++;
194
+ continue;
195
+ }
196
+
197
+ const outcome = await deriveAndApplyListId(
198
+ deps,
199
+ account.accountConfigId,
200
+ row,
201
+ message.bodyStorageKey,
202
+ ).then(
203
+ (listId) => ({ error: null, listId }) as const,
204
+ (error: unknown) => ({ error, listId: null }) as const,
205
+ );
206
+
207
+ if (outcome.error !== null) {
208
+ totals.failed++;
209
+ failedThreadMessageIds.push(row.threadMessageId);
210
+ logger?.error?.(
211
+ {
212
+ threadMessageId: row.threadMessageId,
213
+ messageId: row.messageId,
214
+ error:
215
+ outcome.error instanceof Error
216
+ ? outcome.error.message
217
+ : String(outcome.error),
218
+ },
219
+ "ListId backfill failed for a message; leaving it for a later pass",
220
+ );
221
+ continue;
222
+ }
223
+
224
+ if (outcome.listId) {
225
+ totals.backfilled++;
226
+ } else {
227
+ totals.noListId++;
228
+ }
229
+ }
230
+ }
231
+
232
+ continuationToken = page.continuationToken;
233
+ await checkpointStore?.save({
234
+ accountIndex,
235
+ continuationToken,
236
+ });
237
+
238
+ logger?.info(
239
+ { accountConfigId: account.accountConfigId, ...totals },
240
+ "ListId backfill progress",
241
+ );
242
+ options.onProgress?.({
243
+ accountConfigId: account.accountConfigId,
244
+ ...totals,
245
+ });
246
+ } while (continuationToken);
247
+ }
248
+
249
+ await checkpointStore?.clear();
250
+
251
+ return { ...totals, failedThreadMessageIds };
252
+ };