@remit/mailbox-service 0.0.29 → 0.0.30

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.30",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
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
+ };