@remit/mailbox-service 0.0.84 → 0.0.86
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 +5 -1
- package/src/body-sync-category-denormalize.test.ts +41 -36
- package/src/body-sync-category-override.test.ts +54 -10
- package/src/body-sync-quarantine.test.ts +86 -35
- package/src/body-sync-unclassifiable-once.test.ts +256 -0
- package/src/body-sync.ts +43 -140
- package/src/imapflow-connect-retry.test.ts +26 -0
- package/src/imapflow-connection.ts +21 -1
- package/src/list-id-backfill.ts +8 -5
- package/src/message-move-copy.test.ts +39 -0
- package/src/message-move.ts +9 -0
- package/src/body-sync-backfill.test.ts +0 -292
|
@@ -49,6 +49,7 @@ interface ThreadRow {
|
|
|
49
49
|
messageIdHeader: string;
|
|
50
50
|
isRead?: boolean;
|
|
51
51
|
hasStars?: boolean;
|
|
52
|
+
listId?: string;
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
const buildWorld = () => {
|
|
@@ -66,6 +67,7 @@ const buildWorld = () => {
|
|
|
66
67
|
rootBodyPartId: "body-1",
|
|
67
68
|
bodyStorageKey: "s3://body-1",
|
|
68
69
|
category: "primary",
|
|
70
|
+
classificationState: "Examined",
|
|
69
71
|
hasListUnsubscribe: false,
|
|
70
72
|
},
|
|
71
73
|
],
|
|
@@ -81,6 +83,7 @@ const buildWorld = () => {
|
|
|
81
83
|
messageIdHeader: HEADER,
|
|
82
84
|
isRead: false,
|
|
83
85
|
hasStars: false,
|
|
86
|
+
listId: "invitations.linkedin.com",
|
|
84
87
|
},
|
|
85
88
|
];
|
|
86
89
|
|
|
@@ -144,6 +147,7 @@ const buildWorld = () => {
|
|
|
144
147
|
messageId,
|
|
145
148
|
mailboxId: input.mailboxId as string,
|
|
146
149
|
messageIdHeader: input.messageIdHeader as string,
|
|
150
|
+
listId: input.listId as string | undefined,
|
|
147
151
|
};
|
|
148
152
|
threadRows.push(row);
|
|
149
153
|
return row;
|
|
@@ -338,6 +342,41 @@ describe("MessageMoveService.copyMessage — deterministic per-folder identity (
|
|
|
338
342
|
assert.equal(copyRow.messageId, expected);
|
|
339
343
|
});
|
|
340
344
|
|
|
345
|
+
it("carries the source row's list id onto the copy", async () => {
|
|
346
|
+
// The copy inherits its source's stored body, so no classifying pass ever
|
|
347
|
+
// runs over it to derive a list id of its own (issue #331).
|
|
348
|
+
const { service, threadRows } = buildWorld();
|
|
349
|
+
|
|
350
|
+
await service.copyMessages(
|
|
351
|
+
ACCOUNT_CONFIG,
|
|
352
|
+
[SOURCE_ID],
|
|
353
|
+
DEST_MAILBOX,
|
|
354
|
+
ACCOUNT,
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
const copyRow = threadRows.find((r) => r.mailboxId === DEST_MAILBOX);
|
|
358
|
+
assert.equal(copyRow?.listId, "invitations.linkedin.com");
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("carries the source's classification state onto the copy", async () => {
|
|
362
|
+
// Defaulting the copy to `NotExamined` would put a row no classifier will
|
|
363
|
+
// ever reach — its body is already stored — into the cohort a backfill
|
|
364
|
+
// selects as never examined (issue #331).
|
|
365
|
+
const { service, messages } = buildWorld();
|
|
366
|
+
|
|
367
|
+
await service.copyMessages(
|
|
368
|
+
ACCOUNT_CONFIG,
|
|
369
|
+
[SOURCE_ID],
|
|
370
|
+
DEST_MAILBOX,
|
|
371
|
+
ACCOUNT,
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
const copy = [...messages.values()].find(
|
|
375
|
+
(m) => m.mailboxId === DEST_MAILBOX,
|
|
376
|
+
);
|
|
377
|
+
assert.equal(copy?.classificationState, "Examined");
|
|
378
|
+
});
|
|
379
|
+
|
|
341
380
|
it("a replayed copy converges on one row, no duplicate", async () => {
|
|
342
381
|
const { service, threadRows, messages } = buildWorld();
|
|
343
382
|
|
package/src/message-move.ts
CHANGED
|
@@ -716,6 +716,11 @@ export class MessageMoveService {
|
|
|
716
716
|
// permanently `uncategorized` while its body says it is fully synced
|
|
717
717
|
// (issue #45).
|
|
718
718
|
category: sourceMessage.category,
|
|
719
|
+
// The record of whether that category was the classifier's answer travels
|
|
720
|
+
// with it (issue #331). Defaulting the copy to `NotExamined` would put a
|
|
721
|
+
// row no classifier will ever reach — its body is already stored — into
|
|
722
|
+
// the cohort a backfill selects as never examined.
|
|
723
|
+
classificationState: sourceMessage.classificationState,
|
|
719
724
|
hasListUnsubscribe: sourceMessage.hasListUnsubscribe,
|
|
720
725
|
});
|
|
721
726
|
|
|
@@ -746,6 +751,10 @@ export class MessageMoveService {
|
|
|
746
751
|
isDeleted: false,
|
|
747
752
|
snippet: sourceThreadMessage.snippet,
|
|
748
753
|
category: sourceThreadMessage.category,
|
|
754
|
+
// Same reason as `category` above, and the same source row: the copy
|
|
755
|
+
// inherits a stored body, so no classifying pass ever runs over it to
|
|
756
|
+
// derive a `listId` of its own (issue #331).
|
|
757
|
+
listId: sourceThreadMessage.listId,
|
|
749
758
|
});
|
|
750
759
|
|
|
751
760
|
this.log.info(
|
|
@@ -1,292 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Body-sync's skip guard keys on `bodyStorageKey`, but classification is a
|
|
3
|
-
* separate derived field written by the same pass. A message that got its body
|
|
4
|
-
* before it got a classifier — or whose classifying pass failed after the body
|
|
5
|
-
* landed — is skipped forever and stays `uncategorized` (issue #45).
|
|
6
|
-
*
|
|
7
|
-
* These tests pin the backfill that closes that gap: it reads the stored body
|
|
8
|
-
* rather than IMAP, writes only the classification, and never silently absorbs
|
|
9
|
-
* a storage failure.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import assert from "node:assert/strict";
|
|
13
|
-
import { describe, it } from "node:test";
|
|
14
|
-
import type {
|
|
15
|
-
IAddressRepository,
|
|
16
|
-
IEnvelopeRepository,
|
|
17
|
-
IMessageRepository,
|
|
18
|
-
IThreadMessageRepository,
|
|
19
|
-
MessageItem,
|
|
20
|
-
UpdateMessageInput,
|
|
21
|
-
UpdateThreadMessageInput,
|
|
22
|
-
} from "@remit/data-ports";
|
|
23
|
-
import { NotFoundError } from "@remit/data-ports/errors";
|
|
24
|
-
import { MessageCategory } from "@remit/domain-enums";
|
|
25
|
-
import type { StorageService } from "@remit/storage-service";
|
|
26
|
-
import { BodySyncService } from "./body-sync.js";
|
|
27
|
-
|
|
28
|
-
const LINKEDIN_EML = Buffer.from(
|
|
29
|
-
[
|
|
30
|
-
"From: LinkedIn <messages-noreply@linkedin.com>",
|
|
31
|
-
"To: me@example.com",
|
|
32
|
-
"Subject: You have a new invitation",
|
|
33
|
-
"List-Unsubscribe: <https://www.linkedin.com/e/unsub>",
|
|
34
|
-
"Content-Type: text/plain",
|
|
35
|
-
"",
|
|
36
|
-
"invitation",
|
|
37
|
-
].join("\r\n"),
|
|
38
|
-
);
|
|
39
|
-
|
|
40
|
-
interface Harness {
|
|
41
|
-
service: BodySyncService;
|
|
42
|
-
messageUpdates: Array<{ messageId: string; input: UpdateMessageInput }>;
|
|
43
|
-
threadUpdates: UpdateThreadMessageInput[];
|
|
44
|
-
retrieved: string[];
|
|
45
|
-
loggedErrors: Array<Record<string, unknown>>;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
type MessageFixture = Partial<MessageItem> & Pick<MessageItem, "messageId">;
|
|
49
|
-
|
|
50
|
-
const buildHarness = (
|
|
51
|
-
messages: MessageFixture[],
|
|
52
|
-
retrieve: (key: string) => Promise<Buffer> = async () => LINKEDIN_EML,
|
|
53
|
-
): Harness => {
|
|
54
|
-
const messageUpdates: Array<{
|
|
55
|
-
messageId: string;
|
|
56
|
-
input: UpdateMessageInput;
|
|
57
|
-
}> = [];
|
|
58
|
-
const threadUpdates: UpdateThreadMessageInput[] = [];
|
|
59
|
-
const retrieved: string[] = [];
|
|
60
|
-
const loggedErrors: Array<Record<string, unknown>> = [];
|
|
61
|
-
|
|
62
|
-
const byId = new Map(messages.map((m) => [m.messageId, m]));
|
|
63
|
-
|
|
64
|
-
const messageService = {
|
|
65
|
-
get: async (messageId: string) => {
|
|
66
|
-
const message = byId.get(messageId);
|
|
67
|
-
if (!message) throw new Error(`no fixture for ${messageId}`);
|
|
68
|
-
return { uid: 1, category: MessageCategory.uncategorized, ...message };
|
|
69
|
-
},
|
|
70
|
-
update: async (messageId: string, input: UpdateMessageInput) => {
|
|
71
|
-
messageUpdates.push({ messageId, input });
|
|
72
|
-
},
|
|
73
|
-
} as unknown as IMessageRepository;
|
|
74
|
-
|
|
75
|
-
const threadMessageService = {
|
|
76
|
-
findAllByMessageId: async () => [
|
|
77
|
-
{
|
|
78
|
-
threadMessageId: "tm-1",
|
|
79
|
-
sentDate: 1,
|
|
80
|
-
mailboxId: "mb-1",
|
|
81
|
-
isRead: false,
|
|
82
|
-
isDeleted: false,
|
|
83
|
-
hasStars: false,
|
|
84
|
-
hasAttachment: false,
|
|
85
|
-
},
|
|
86
|
-
],
|
|
87
|
-
update: async (
|
|
88
|
-
_accountConfigId: string,
|
|
89
|
-
_threadMessageId: string,
|
|
90
|
-
input: UpdateThreadMessageInput,
|
|
91
|
-
) => {
|
|
92
|
-
threadUpdates.push(input);
|
|
93
|
-
},
|
|
94
|
-
} as unknown as IThreadMessageRepository;
|
|
95
|
-
|
|
96
|
-
const storageService = {
|
|
97
|
-
retrieve: async (key: string) => {
|
|
98
|
-
retrieved.push(key);
|
|
99
|
-
return retrieve(key);
|
|
100
|
-
},
|
|
101
|
-
} as unknown as StorageService;
|
|
102
|
-
|
|
103
|
-
const service = new BodySyncService(
|
|
104
|
-
messageService,
|
|
105
|
-
storageService,
|
|
106
|
-
threadMessageService,
|
|
107
|
-
{
|
|
108
|
-
getAddress: async () => {
|
|
109
|
-
throw new NotFoundError("Address not found");
|
|
110
|
-
},
|
|
111
|
-
} as unknown as IAddressRepository,
|
|
112
|
-
{} as unknown as IEnvelopeRepository,
|
|
113
|
-
{
|
|
114
|
-
info: () => {},
|
|
115
|
-
error: (obj: Record<string, unknown>) => {
|
|
116
|
-
loggedErrors.push(obj);
|
|
117
|
-
},
|
|
118
|
-
},
|
|
119
|
-
);
|
|
120
|
-
|
|
121
|
-
return { service, messageUpdates, threadUpdates, retrieved, loggedErrors };
|
|
122
|
-
};
|
|
123
|
-
|
|
124
|
-
const failingConnection = async () => {
|
|
125
|
-
throw new Error("body-sync must not open IMAP to backfill a classification");
|
|
126
|
-
};
|
|
127
|
-
|
|
128
|
-
const stored = (messageId: string) => ({
|
|
129
|
-
messageId,
|
|
130
|
-
bodyStorageKey: `s3://bodies/${messageId}`,
|
|
131
|
-
category: MessageCategory.uncategorized,
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
describe("body-sync classification backfill", () => {
|
|
135
|
-
it("classifies a skipped message whose body is stored but category is uncategorized", async () => {
|
|
136
|
-
const harness = buildHarness([stored("m-1")]);
|
|
137
|
-
|
|
138
|
-
const result = await harness.service.syncBodies(
|
|
139
|
-
["m-1"],
|
|
140
|
-
"acc-1",
|
|
141
|
-
"cfg-1",
|
|
142
|
-
"INBOX",
|
|
143
|
-
failingConnection,
|
|
144
|
-
);
|
|
145
|
-
|
|
146
|
-
assert.equal(result.skippedCount, 1);
|
|
147
|
-
assert.equal(harness.messageUpdates.length, 1);
|
|
148
|
-
assert.equal(
|
|
149
|
-
harness.messageUpdates[0].input.category,
|
|
150
|
-
MessageCategory.social,
|
|
151
|
-
);
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
it("denormalizes the backfilled category onto the ThreadMessage", async () => {
|
|
155
|
-
const harness = buildHarness([stored("m-1")]);
|
|
156
|
-
|
|
157
|
-
await harness.service.syncBodies(
|
|
158
|
-
["m-1"],
|
|
159
|
-
"acc-1",
|
|
160
|
-
"cfg-1",
|
|
161
|
-
"INBOX",
|
|
162
|
-
failingConnection,
|
|
163
|
-
);
|
|
164
|
-
|
|
165
|
-
assert.equal(harness.threadUpdates.length, 1);
|
|
166
|
-
assert.equal(harness.threadUpdates[0].category, MessageCategory.social);
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
it("reads the stored body instead of reconnecting to IMAP", async () => {
|
|
170
|
-
const harness = buildHarness([stored("m-1")]);
|
|
171
|
-
|
|
172
|
-
await harness.service.syncBodies(
|
|
173
|
-
["m-1"],
|
|
174
|
-
"acc-1",
|
|
175
|
-
"cfg-1",
|
|
176
|
-
"INBOX",
|
|
177
|
-
failingConnection,
|
|
178
|
-
);
|
|
179
|
-
|
|
180
|
-
assert.deepEqual(harness.retrieved, ["s3://bodies/m-1"]);
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
it("classifies a message whose category field is absent entirely", async () => {
|
|
184
|
-
// Rows written before the column existed carry no value at all. Reading
|
|
185
|
-
// that as "already classified" would strand the oldest mail — exactly
|
|
186
|
-
// what this backfill exists to reach.
|
|
187
|
-
const harness = buildHarness([
|
|
188
|
-
{
|
|
189
|
-
messageId: "m-1",
|
|
190
|
-
bodyStorageKey: "s3://bodies/m-1",
|
|
191
|
-
category: undefined,
|
|
192
|
-
},
|
|
193
|
-
]);
|
|
194
|
-
|
|
195
|
-
await harness.service.syncBodies(
|
|
196
|
-
["m-1"],
|
|
197
|
-
"acc-1",
|
|
198
|
-
"cfg-1",
|
|
199
|
-
"INBOX",
|
|
200
|
-
failingConnection,
|
|
201
|
-
);
|
|
202
|
-
|
|
203
|
-
assert.equal(harness.messageUpdates.length, 1);
|
|
204
|
-
assert.equal(
|
|
205
|
-
harness.messageUpdates[0].input.category,
|
|
206
|
-
MessageCategory.social,
|
|
207
|
-
);
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
it("leaves an already-classified message untouched", async () => {
|
|
211
|
-
const harness = buildHarness([
|
|
212
|
-
{ ...stored("m-1"), category: MessageCategory.marketing },
|
|
213
|
-
]);
|
|
214
|
-
|
|
215
|
-
const result = await harness.service.syncBodies(
|
|
216
|
-
["m-1"],
|
|
217
|
-
"acc-1",
|
|
218
|
-
"cfg-1",
|
|
219
|
-
"INBOX",
|
|
220
|
-
failingConnection,
|
|
221
|
-
);
|
|
222
|
-
|
|
223
|
-
assert.equal(result.skippedCount, 1);
|
|
224
|
-
assert.deepEqual(harness.messageUpdates, []);
|
|
225
|
-
assert.deepEqual(harness.retrieved, []);
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
it("requeues the message when its stored body cannot be read", async () => {
|
|
229
|
-
// An unreadable body object is an infra fault. Absorbing it would leave
|
|
230
|
-
// the message permanently unclassified with nothing to show for it.
|
|
231
|
-
const harness = buildHarness([stored("m-1")], async () => {
|
|
232
|
-
throw new Error("AccessDenied");
|
|
233
|
-
});
|
|
234
|
-
|
|
235
|
-
const result = await harness.service.syncBodies(
|
|
236
|
-
["m-1"],
|
|
237
|
-
"acc-1",
|
|
238
|
-
"cfg-1",
|
|
239
|
-
"INBOX",
|
|
240
|
-
failingConnection,
|
|
241
|
-
);
|
|
242
|
-
|
|
243
|
-
assert.deepEqual(result.failedMessageIds, ["m-1"]);
|
|
244
|
-
assert.equal(result.skippedCount, 0);
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
it("logs a backfill failure rather than swallowing it", async () => {
|
|
248
|
-
const harness = buildHarness([stored("m-1")], async () => {
|
|
249
|
-
throw new Error("AccessDenied");
|
|
250
|
-
});
|
|
251
|
-
|
|
252
|
-
await harness.service.syncBodies(
|
|
253
|
-
["m-1"],
|
|
254
|
-
"acc-1",
|
|
255
|
-
"cfg-1",
|
|
256
|
-
"INBOX",
|
|
257
|
-
failingConnection,
|
|
258
|
-
);
|
|
259
|
-
|
|
260
|
-
assert.equal(harness.loggedErrors.length, 1);
|
|
261
|
-
assert.equal(harness.loggedErrors[0].messageId, "m-1");
|
|
262
|
-
});
|
|
263
|
-
|
|
264
|
-
it("does not abort the batch when one message's backfill fails", async () => {
|
|
265
|
-
// The failure happens in the message-resolution loop, before any body is
|
|
266
|
-
// fetched. Letting it escape would strand every other message in the
|
|
267
|
-
// batch — including ones that need a genuine IMAP fetch — behind one
|
|
268
|
-
// unreadable S3 object.
|
|
269
|
-
const harness = buildHarness(
|
|
270
|
-
[stored("m-bad"), stored("m-good")],
|
|
271
|
-
async (key) => {
|
|
272
|
-
if (key.endsWith("m-bad")) throw new Error("AccessDenied");
|
|
273
|
-
return LINKEDIN_EML;
|
|
274
|
-
},
|
|
275
|
-
);
|
|
276
|
-
|
|
277
|
-
const result = await harness.service.syncBodies(
|
|
278
|
-
["m-bad", "m-good"],
|
|
279
|
-
"acc-1",
|
|
280
|
-
"cfg-1",
|
|
281
|
-
"INBOX",
|
|
282
|
-
failingConnection,
|
|
283
|
-
);
|
|
284
|
-
|
|
285
|
-
assert.deepEqual(result.failedMessageIds, ["m-bad"]);
|
|
286
|
-
assert.equal(result.skippedCount, 1);
|
|
287
|
-
assert.deepEqual(
|
|
288
|
-
harness.messageUpdates.map((u) => u.messageId),
|
|
289
|
-
["m-good"],
|
|
290
|
-
);
|
|
291
|
-
});
|
|
292
|
-
});
|