@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 +1 -1
- package/src/index.ts +11 -0
- package/src/list-id-backfill.test.ts +416 -0
- package/src/list-id-backfill.ts +252 -0
package/package.json
CHANGED
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
|
+
};
|