@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 +1 -1
- package/src/body-sync-category-writeonce.test.ts +228 -0
- package/src/body-sync.ts +26 -6
- 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
|
@@ -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
|
+
};
|