@remit/mailbox-service 0.0.10 → 0.0.12
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-backfill.test.ts +285 -0
- package/src/body-sync.ts +131 -18
- package/src/heuristics/classifyByHeaders.test.ts +2 -2
- package/src/heuristics/classifyByHeaders.ts +49 -16
- package/src/heuristics/classifyRealMail.test.ts +242 -0
- package/src/heuristics/machineSenders.test.ts +101 -0
- package/src/heuristics/machineSenders.ts +90 -0
- package/src/imapflow-connection.test.ts +85 -0
- package/src/imapflow-connection.ts +59 -12
- package/src/mailbox-sync.test.ts +31 -2
- package/src/mailbox-sync.ts +11 -11
- package/src/message-move.ts +6 -0
- package/src/message-sync-changedsince.test.ts +742 -0
- package/src/message-sync.ts +494 -108
- package/src/sync-watermarks.test.ts +418 -0
- package/src/sync-watermarks.ts +322 -0
- package/src/types.ts +22 -0
package/package.json
CHANGED
|
@@ -0,0 +1,285 @@
|
|
|
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 { MessageCategory } from "@remit/domain-enums";
|
|
24
|
+
import type { StorageService } from "@remit/storage-service";
|
|
25
|
+
import { BodySyncService } from "./body-sync.js";
|
|
26
|
+
|
|
27
|
+
const LINKEDIN_EML = Buffer.from(
|
|
28
|
+
[
|
|
29
|
+
"From: LinkedIn <messages-noreply@linkedin.com>",
|
|
30
|
+
"To: me@example.com",
|
|
31
|
+
"Subject: You have a new invitation",
|
|
32
|
+
"List-Unsubscribe: <https://www.linkedin.com/e/unsub>",
|
|
33
|
+
"Content-Type: text/plain",
|
|
34
|
+
"",
|
|
35
|
+
"invitation",
|
|
36
|
+
].join("\r\n"),
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
interface Harness {
|
|
40
|
+
service: BodySyncService;
|
|
41
|
+
messageUpdates: Array<{ messageId: string; input: UpdateMessageInput }>;
|
|
42
|
+
threadUpdates: UpdateThreadMessageInput[];
|
|
43
|
+
retrieved: string[];
|
|
44
|
+
loggedErrors: Array<Record<string, unknown>>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type MessageFixture = Partial<MessageItem> & Pick<MessageItem, "messageId">;
|
|
48
|
+
|
|
49
|
+
const buildHarness = (
|
|
50
|
+
messages: MessageFixture[],
|
|
51
|
+
retrieve: (key: string) => Promise<Buffer> = async () => LINKEDIN_EML,
|
|
52
|
+
): Harness => {
|
|
53
|
+
const messageUpdates: Array<{
|
|
54
|
+
messageId: string;
|
|
55
|
+
input: UpdateMessageInput;
|
|
56
|
+
}> = [];
|
|
57
|
+
const threadUpdates: UpdateThreadMessageInput[] = [];
|
|
58
|
+
const retrieved: string[] = [];
|
|
59
|
+
const loggedErrors: Array<Record<string, unknown>> = [];
|
|
60
|
+
|
|
61
|
+
const byId = new Map(messages.map((m) => [m.messageId, m]));
|
|
62
|
+
|
|
63
|
+
const messageService = {
|
|
64
|
+
get: async (messageId: string) => {
|
|
65
|
+
const message = byId.get(messageId);
|
|
66
|
+
if (!message) throw new Error(`no fixture for ${messageId}`);
|
|
67
|
+
return { uid: 1, category: MessageCategory.uncategorized, ...message };
|
|
68
|
+
},
|
|
69
|
+
update: async (messageId: string, input: UpdateMessageInput) => {
|
|
70
|
+
messageUpdates.push({ messageId, input });
|
|
71
|
+
},
|
|
72
|
+
} as unknown as IMessageRepository;
|
|
73
|
+
|
|
74
|
+
const threadMessageService = {
|
|
75
|
+
getByMessageId: async () => ({
|
|
76
|
+
threadMessageId: "tm-1",
|
|
77
|
+
sentDate: 1,
|
|
78
|
+
mailboxId: "mb-1",
|
|
79
|
+
isRead: false,
|
|
80
|
+
isDeleted: false,
|
|
81
|
+
hasStars: false,
|
|
82
|
+
hasAttachment: false,
|
|
83
|
+
}),
|
|
84
|
+
update: async (
|
|
85
|
+
_accountConfigId: string,
|
|
86
|
+
_threadMessageId: string,
|
|
87
|
+
input: UpdateThreadMessageInput,
|
|
88
|
+
) => {
|
|
89
|
+
threadUpdates.push(input);
|
|
90
|
+
},
|
|
91
|
+
} as unknown as IThreadMessageRepository;
|
|
92
|
+
|
|
93
|
+
const storageService = {
|
|
94
|
+
retrieve: async (key: string) => {
|
|
95
|
+
retrieved.push(key);
|
|
96
|
+
return retrieve(key);
|
|
97
|
+
},
|
|
98
|
+
} as unknown as StorageService;
|
|
99
|
+
|
|
100
|
+
const service = new BodySyncService(
|
|
101
|
+
messageService,
|
|
102
|
+
storageService,
|
|
103
|
+
threadMessageService,
|
|
104
|
+
{} as unknown as IAddressRepository,
|
|
105
|
+
{} as unknown as IEnvelopeRepository,
|
|
106
|
+
{
|
|
107
|
+
info: () => {},
|
|
108
|
+
error: (obj: Record<string, unknown>) => {
|
|
109
|
+
loggedErrors.push(obj);
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
return { service, messageUpdates, threadUpdates, retrieved, loggedErrors };
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const failingConnection = async () => {
|
|
118
|
+
throw new Error("body-sync must not open IMAP to backfill a classification");
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const stored = (messageId: string) => ({
|
|
122
|
+
messageId,
|
|
123
|
+
bodyStorageKey: `s3://bodies/${messageId}`,
|
|
124
|
+
category: MessageCategory.uncategorized,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("body-sync classification backfill", () => {
|
|
128
|
+
it("classifies a skipped message whose body is stored but category is uncategorized", async () => {
|
|
129
|
+
const harness = buildHarness([stored("m-1")]);
|
|
130
|
+
|
|
131
|
+
const result = await harness.service.syncBodies(
|
|
132
|
+
["m-1"],
|
|
133
|
+
"acc-1",
|
|
134
|
+
"cfg-1",
|
|
135
|
+
"INBOX",
|
|
136
|
+
failingConnection,
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
assert.equal(result.skippedCount, 1);
|
|
140
|
+
assert.equal(harness.messageUpdates.length, 1);
|
|
141
|
+
assert.equal(
|
|
142
|
+
harness.messageUpdates[0].input.category,
|
|
143
|
+
MessageCategory.social,
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("denormalizes the backfilled category onto the ThreadMessage", async () => {
|
|
148
|
+
const harness = buildHarness([stored("m-1")]);
|
|
149
|
+
|
|
150
|
+
await harness.service.syncBodies(
|
|
151
|
+
["m-1"],
|
|
152
|
+
"acc-1",
|
|
153
|
+
"cfg-1",
|
|
154
|
+
"INBOX",
|
|
155
|
+
failingConnection,
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
assert.equal(harness.threadUpdates.length, 1);
|
|
159
|
+
assert.equal(harness.threadUpdates[0].category, MessageCategory.social);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("reads the stored body instead of reconnecting to IMAP", async () => {
|
|
163
|
+
const harness = buildHarness([stored("m-1")]);
|
|
164
|
+
|
|
165
|
+
await harness.service.syncBodies(
|
|
166
|
+
["m-1"],
|
|
167
|
+
"acc-1",
|
|
168
|
+
"cfg-1",
|
|
169
|
+
"INBOX",
|
|
170
|
+
failingConnection,
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
assert.deepEqual(harness.retrieved, ["s3://bodies/m-1"]);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("classifies a message whose category field is absent entirely", async () => {
|
|
177
|
+
// Rows written before the column existed carry no value at all. Reading
|
|
178
|
+
// that as "already classified" would strand the oldest mail — exactly
|
|
179
|
+
// what this backfill exists to reach.
|
|
180
|
+
const harness = buildHarness([
|
|
181
|
+
{
|
|
182
|
+
messageId: "m-1",
|
|
183
|
+
bodyStorageKey: "s3://bodies/m-1",
|
|
184
|
+
category: undefined,
|
|
185
|
+
},
|
|
186
|
+
]);
|
|
187
|
+
|
|
188
|
+
await harness.service.syncBodies(
|
|
189
|
+
["m-1"],
|
|
190
|
+
"acc-1",
|
|
191
|
+
"cfg-1",
|
|
192
|
+
"INBOX",
|
|
193
|
+
failingConnection,
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
assert.equal(harness.messageUpdates.length, 1);
|
|
197
|
+
assert.equal(
|
|
198
|
+
harness.messageUpdates[0].input.category,
|
|
199
|
+
MessageCategory.social,
|
|
200
|
+
);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("leaves an already-classified message untouched", async () => {
|
|
204
|
+
const harness = buildHarness([
|
|
205
|
+
{ ...stored("m-1"), category: MessageCategory.marketing },
|
|
206
|
+
]);
|
|
207
|
+
|
|
208
|
+
const result = await harness.service.syncBodies(
|
|
209
|
+
["m-1"],
|
|
210
|
+
"acc-1",
|
|
211
|
+
"cfg-1",
|
|
212
|
+
"INBOX",
|
|
213
|
+
failingConnection,
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
assert.equal(result.skippedCount, 1);
|
|
217
|
+
assert.deepEqual(harness.messageUpdates, []);
|
|
218
|
+
assert.deepEqual(harness.retrieved, []);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("requeues the message when its stored body cannot be read", async () => {
|
|
222
|
+
// An unreadable body object is an infra fault. Absorbing it would leave
|
|
223
|
+
// the message permanently unclassified with nothing to show for it.
|
|
224
|
+
const harness = buildHarness([stored("m-1")], async () => {
|
|
225
|
+
throw new Error("AccessDenied");
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
const result = await harness.service.syncBodies(
|
|
229
|
+
["m-1"],
|
|
230
|
+
"acc-1",
|
|
231
|
+
"cfg-1",
|
|
232
|
+
"INBOX",
|
|
233
|
+
failingConnection,
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
assert.deepEqual(result.failedMessageIds, ["m-1"]);
|
|
237
|
+
assert.equal(result.skippedCount, 0);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("logs a backfill failure rather than swallowing it", async () => {
|
|
241
|
+
const harness = buildHarness([stored("m-1")], async () => {
|
|
242
|
+
throw new Error("AccessDenied");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
await harness.service.syncBodies(
|
|
246
|
+
["m-1"],
|
|
247
|
+
"acc-1",
|
|
248
|
+
"cfg-1",
|
|
249
|
+
"INBOX",
|
|
250
|
+
failingConnection,
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
assert.equal(harness.loggedErrors.length, 1);
|
|
254
|
+
assert.equal(harness.loggedErrors[0].messageId, "m-1");
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("does not abort the batch when one message's backfill fails", async () => {
|
|
258
|
+
// The failure happens in the message-resolution loop, before any body is
|
|
259
|
+
// fetched. Letting it escape would strand every other message in the
|
|
260
|
+
// batch — including ones that need a genuine IMAP fetch — behind one
|
|
261
|
+
// unreadable S3 object.
|
|
262
|
+
const harness = buildHarness(
|
|
263
|
+
[stored("m-bad"), stored("m-good")],
|
|
264
|
+
async (key) => {
|
|
265
|
+
if (key.endsWith("m-bad")) throw new Error("AccessDenied");
|
|
266
|
+
return LINKEDIN_EML;
|
|
267
|
+
},
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
const result = await harness.service.syncBodies(
|
|
271
|
+
["m-bad", "m-good"],
|
|
272
|
+
"acc-1",
|
|
273
|
+
"cfg-1",
|
|
274
|
+
"INBOX",
|
|
275
|
+
failingConnection,
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
assert.deepEqual(result.failedMessageIds, ["m-bad"]);
|
|
279
|
+
assert.equal(result.skippedCount, 1);
|
|
280
|
+
assert.deepEqual(
|
|
281
|
+
harness.messageUpdates.map((u) => u.messageId),
|
|
282
|
+
["m-good"],
|
|
283
|
+
);
|
|
284
|
+
});
|
|
285
|
+
});
|
package/src/body-sync.ts
CHANGED
|
@@ -6,6 +6,8 @@ import type {
|
|
|
6
6
|
IMailboxSpecialUseRepository,
|
|
7
7
|
IMessageRepository,
|
|
8
8
|
IThreadMessageRepository,
|
|
9
|
+
MessageItem,
|
|
10
|
+
ThreadMessageItem,
|
|
9
11
|
UpdateMessageInput,
|
|
10
12
|
} from "@remit/data-ports";
|
|
11
13
|
import { NotFoundError } from "@remit/data-ports/errors";
|
|
@@ -13,6 +15,7 @@ import { deriveAddressId } from "@remit/data-ports/id";
|
|
|
13
15
|
import { isBulkSender } from "@remit/data-ports/wellknown";
|
|
14
16
|
import {
|
|
15
17
|
MailboxSpecialUse,
|
|
18
|
+
MessageCategory,
|
|
16
19
|
PlacementAction,
|
|
17
20
|
PlacementConfidence,
|
|
18
21
|
SenderTrust,
|
|
@@ -52,6 +55,8 @@ type MessagePlacementVerdict = NonNullable<
|
|
|
52
55
|
UpdateMessageInput["placementVerdict"]
|
|
53
56
|
>;
|
|
54
57
|
|
|
58
|
+
type ThreadMessageCategory = ThreadMessageItem["category"];
|
|
59
|
+
|
|
55
60
|
/**
|
|
56
61
|
* Outcome of {@link BodySyncService.resolvePlacement}: the audit `verdict` to
|
|
57
62
|
* persist on the Message (present whenever Remit decided to act, i.e. action
|
|
@@ -240,10 +245,45 @@ export class BodySyncService {
|
|
|
240
245
|
// messageId so we can match FETCH rows back and re-enqueue any UID the
|
|
241
246
|
// server never returns.
|
|
242
247
|
const pending = new Map<number, string>();
|
|
248
|
+
// Messages that were skipped (body already stored) but whose backfill
|
|
249
|
+
// classification failed. They are NOT in `pending` — nothing about them
|
|
250
|
+
// needs fetching — so they are merged into failedMessageIds separately.
|
|
251
|
+
const backfillFailedMessageIds: string[] = [];
|
|
243
252
|
for (const messageId of messageIds) {
|
|
244
253
|
const message = await this.messageService.get(messageId);
|
|
245
254
|
if (message.bodyStorageKey && !force) {
|
|
246
255
|
this.log.debug?.({ messageId }, "Body already stored, skipping");
|
|
256
|
+
// The skip guard keys on the body, but classification is a separate
|
|
257
|
+
// derived field written by the same pass. A message that got its body
|
|
258
|
+
// before it got a classifier — or whose classifying pass failed after
|
|
259
|
+
// the body landed — is skipped here forever and stays `uncategorized`
|
|
260
|
+
// (issue #45). Classify it from the stored bytes: no IMAP, no
|
|
261
|
+
// placement/filter side effects, and it skips cleanly once done.
|
|
262
|
+
//
|
|
263
|
+
// Contained per-message: one unreadable body object must not abort a
|
|
264
|
+
// batch that has not fetched anything yet. The failure is loud and
|
|
265
|
+
// the id is requeued, but the other messages still get their bodies.
|
|
266
|
+
const backfillError = await this.backfillClassification(
|
|
267
|
+
message,
|
|
268
|
+
accountConfigId,
|
|
269
|
+
).then(
|
|
270
|
+
() => null,
|
|
271
|
+
(error: unknown) => error,
|
|
272
|
+
);
|
|
273
|
+
if (backfillError !== null) {
|
|
274
|
+
this.log.error?.(
|
|
275
|
+
{
|
|
276
|
+
messageId,
|
|
277
|
+
storageKey: message.bodyStorageKey,
|
|
278
|
+
errorName: (backfillError as { name?: string }).name,
|
|
279
|
+
errorCode: (backfillError as { Code?: string }).Code,
|
|
280
|
+
error: inspect(backfillError),
|
|
281
|
+
},
|
|
282
|
+
"Classification backfill failed for an already-stored body; leaving for requeue",
|
|
283
|
+
);
|
|
284
|
+
backfillFailedMessageIds.push(messageId);
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
247
287
|
skippedCount++;
|
|
248
288
|
continue;
|
|
249
289
|
}
|
|
@@ -251,7 +291,11 @@ export class BodySyncService {
|
|
|
251
291
|
}
|
|
252
292
|
|
|
253
293
|
if (pending.size === 0) {
|
|
254
|
-
return this.buildResult(
|
|
294
|
+
return this.buildResult(
|
|
295
|
+
syncedMessageIds,
|
|
296
|
+
skippedCount,
|
|
297
|
+
backfillFailedMessageIds,
|
|
298
|
+
);
|
|
255
299
|
}
|
|
256
300
|
|
|
257
301
|
const connection = await getConnection();
|
|
@@ -321,7 +365,7 @@ export class BodySyncService {
|
|
|
321
365
|
|
|
322
366
|
// Anything still pending was never yielded (mid-stream drop or a UID the
|
|
323
367
|
// server silently omitted) — re-enqueue it.
|
|
324
|
-
const failedMessageIds = [...pending.values()];
|
|
368
|
+
const failedMessageIds = [...pending.values(), ...backfillFailedMessageIds];
|
|
325
369
|
|
|
326
370
|
this.log.info(
|
|
327
371
|
{
|
|
@@ -671,13 +715,65 @@ export class BodySyncService {
|
|
|
671
715
|
return connection.fetchMessageBody(uid);
|
|
672
716
|
}
|
|
673
717
|
|
|
718
|
+
/**
|
|
719
|
+
* Classify a message whose body is already stored but which carries no
|
|
720
|
+
* decided category, reading the body from storage instead of IMAP.
|
|
721
|
+
*
|
|
722
|
+
* "No decided category" is `uncategorized` OR the field being absent: rows
|
|
723
|
+
* written before the column existed have no value at all, and treating that
|
|
724
|
+
* as already-classified would strand exactly the oldest mail this backfill
|
|
725
|
+
* exists to reach.
|
|
726
|
+
*
|
|
727
|
+
* Deliberately narrower than {@link applyPostStoreSteps}: it writes the
|
|
728
|
+
* derived classification fields and the denormalized ThreadMessage category,
|
|
729
|
+
* and nothing else. Placement moves and filter actions are index-time
|
|
730
|
+
* decisions that already ran (or were declined) when the body first landed;
|
|
731
|
+
* re-running them here would move mail the user has since filed by hand.
|
|
732
|
+
*
|
|
733
|
+
* A storage or write failure propagates to the caller, which contains it per
|
|
734
|
+
* message: the id lands in `failedMessageIds` and SQS requeues it, while the
|
|
735
|
+
* rest of the batch still gets its bodies. An unreadable body object is an
|
|
736
|
+
* infra fault, never absorbed — but it is also not a reason to abort a batch
|
|
737
|
+
* that has fetched nothing yet.
|
|
738
|
+
*/
|
|
739
|
+
private async backfillClassification(
|
|
740
|
+
message: MessageItem,
|
|
741
|
+
accountConfigId: string,
|
|
742
|
+
): Promise<void> {
|
|
743
|
+
if (!message.bodyStorageKey) return;
|
|
744
|
+
if (
|
|
745
|
+
message.category !== undefined &&
|
|
746
|
+
message.category !== MessageCategory.uncategorized
|
|
747
|
+
) {
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const body = await this.storageService.retrieve(message.bodyStorageKey);
|
|
752
|
+
const parsed = await simpleParser(body);
|
|
753
|
+
const classification = this.classifyMessage(parsed);
|
|
754
|
+
|
|
755
|
+
await this.messageService.update(message.messageId, classification);
|
|
756
|
+
await this.denormalizeCategory(
|
|
757
|
+
accountConfigId,
|
|
758
|
+
message.messageId,
|
|
759
|
+
classification.category,
|
|
760
|
+
);
|
|
761
|
+
|
|
762
|
+
this.log.info(
|
|
763
|
+
{ messageId: message.messageId, category: classification.category },
|
|
764
|
+
"Backfilled classification for an already-stored body",
|
|
765
|
+
);
|
|
766
|
+
}
|
|
767
|
+
|
|
674
768
|
/**
|
|
675
769
|
* Pure header classification. Returns the subset of the Message update that
|
|
676
770
|
* carries the derived fields; the caller folds it into a single UpdateItem
|
|
677
771
|
* alongside `bodyStorageKey`. Optional signals are omitted when absent so we
|
|
678
772
|
* never overwrite an existing value with `undefined`.
|
|
679
773
|
*/
|
|
680
|
-
private classifyMessage(
|
|
774
|
+
private classifyMessage(
|
|
775
|
+
parsed: ParsedMail,
|
|
776
|
+
): UpdateMessageInput & { category: ThreadMessageCategory } {
|
|
681
777
|
const category = classifyByHeaders(parsed);
|
|
682
778
|
const authenticity = extractAuthenticity(parsed);
|
|
683
779
|
const authResult = extractAuthResult(parsed);
|
|
@@ -1163,19 +1259,43 @@ export class BodySyncService {
|
|
|
1163
1259
|
|
|
1164
1260
|
const category = classifyByHeaders(parsed);
|
|
1165
1261
|
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1262
|
+
await this.denormalizeCategory(
|
|
1263
|
+
accountConfigId,
|
|
1264
|
+
messageId,
|
|
1265
|
+
category,
|
|
1266
|
+
snippet,
|
|
1267
|
+
);
|
|
1268
|
+
|
|
1269
|
+
this.log.debug?.(
|
|
1270
|
+
{ messageId, category, snippetLength: snippet?.length ?? 0 },
|
|
1271
|
+
"ThreadMessage snippet + category updated",
|
|
1272
|
+
);
|
|
1273
|
+
|
|
1274
|
+
return parsed;
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
/**
|
|
1278
|
+
* Write the denormalized `category` (and optionally the snippet) onto the
|
|
1279
|
+
* message's ThreadMessage row — the copy the list/search read path serves
|
|
1280
|
+
* without a per-row Message fetch.
|
|
1281
|
+
*
|
|
1282
|
+
* The ThreadMessage is looked up by messageId (GSI), so it does not depend
|
|
1283
|
+
* on the RFC822 Message-ID header — a headerless message still gets
|
|
1284
|
+
* denormalized, matching the unconditional Message.category write. The full
|
|
1285
|
+
* composite set is passed so that a future key-attribute addition touching
|
|
1286
|
+
* the lsi3/lsi4/lsi5/gsi2 sort keys keeps the index rows consistent.
|
|
1287
|
+
*/
|
|
1288
|
+
private async denormalizeCategory(
|
|
1289
|
+
accountConfigId: string,
|
|
1290
|
+
messageId: string,
|
|
1291
|
+
category: ThreadMessageCategory,
|
|
1292
|
+
snippet?: string,
|
|
1293
|
+
): Promise<void> {
|
|
1170
1294
|
const threadMessage = await this.threadMessageService.getByMessageId(
|
|
1171
1295
|
accountConfigId,
|
|
1172
1296
|
messageId,
|
|
1173
1297
|
);
|
|
1174
1298
|
|
|
1175
|
-
// Update ThreadMessage snippet + denormalized category.
|
|
1176
|
-
// Pass the full composite set so that if a future key-attribute addition
|
|
1177
|
-
// touches lsi3/lsi4/lsi5/gsi2 sort keys, the index rows remain consistent.
|
|
1178
|
-
// The threadMessage was fetched just above, so the values are already in scope.
|
|
1179
1299
|
await this.threadMessageService.update(
|
|
1180
1300
|
accountConfigId,
|
|
1181
1301
|
threadMessage.threadMessageId,
|
|
@@ -1191,12 +1311,5 @@ export class BodySyncService {
|
|
|
1191
1311
|
},
|
|
1192
1312
|
},
|
|
1193
1313
|
);
|
|
1194
|
-
|
|
1195
|
-
this.log.debug?.(
|
|
1196
|
-
{ messageId, category, snippetLength: snippet?.length ?? 0 },
|
|
1197
|
-
"ThreadMessage snippet + category updated",
|
|
1198
|
-
);
|
|
1199
|
-
|
|
1200
|
-
return parsed;
|
|
1201
1314
|
}
|
|
1202
1315
|
}
|
|
@@ -256,7 +256,7 @@ describe("classifyByHeaders", () => {
|
|
|
256
256
|
assert.equal(classifyByHeaders(parsed), MessageCategory.transactional);
|
|
257
257
|
});
|
|
258
258
|
|
|
259
|
-
it("
|
|
259
|
+
it("List-Unsubscribe wins over Auto-Submitted", async () => {
|
|
260
260
|
const parsed = await parse([
|
|
261
261
|
"From: alice@example.com",
|
|
262
262
|
"To: bob@example.com",
|
|
@@ -266,7 +266,7 @@ describe("classifyByHeaders", () => {
|
|
|
266
266
|
"",
|
|
267
267
|
"body",
|
|
268
268
|
]);
|
|
269
|
-
assert.equal(classifyByHeaders(parsed), MessageCategory.
|
|
269
|
+
assert.equal(classifyByHeaders(parsed), MessageCategory.marketing);
|
|
270
270
|
});
|
|
271
271
|
});
|
|
272
272
|
|
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
ParsedMail,
|
|
7
7
|
StructuredHeader,
|
|
8
8
|
} from "mailparser";
|
|
9
|
+
import { hasMachineHeader, isMachineLocalPart } from "./machineSenders.js";
|
|
9
10
|
import { SOCIAL_DOMAINS } from "./socialDomains.js";
|
|
10
11
|
import { TRANSACTIONAL_DOMAINS } from "./transactionalDomains.js";
|
|
11
12
|
|
|
@@ -46,24 +47,32 @@ export interface MessageAuthenticity {
|
|
|
46
47
|
* Header-only classification. Pure function. First match wins. Falls through
|
|
47
48
|
* to `personal` so misclassification stays in the safest bucket.
|
|
48
49
|
*
|
|
49
|
-
*
|
|
50
|
+
* Rules are ordered most-specific-signal first. Two properties drive the
|
|
51
|
+
* order, and both were violated by the original table (issue #45):
|
|
50
52
|
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
53
|
+
* - A signal that identifies WHO sent the mail (calendar part, allow-listed
|
|
54
|
+
* domain) outranks a signal that only says the mail was sent in bulk.
|
|
55
|
+
* - `Precedence` and `Auto-Submitted` say "a machine sent this", which is true
|
|
56
|
+
* of nearly every newsletter, marketing blast, and platform notification.
|
|
57
|
+
* Ranking them first collapsed `newsletter`, `marketing`, `social` and
|
|
58
|
+
* `transactional` into `automated`, leaving those buckets empty.
|
|
59
|
+
*
|
|
60
|
+
* 1. `Content-Type: text/calendar` part anywhere → `transactional`
|
|
61
|
+
* 2. From-domain in TRANSACTIONAL_DOMAINS → `transactional`
|
|
62
|
+
* 3. From-domain in SOCIAL_DOMAINS → `social`
|
|
63
|
+
* 4. `List-Unsubscribe` AND `List-Id` → `newsletter`
|
|
64
|
+
* 5. `List-Unsubscribe` only → `marketing`
|
|
65
|
+
* 6. `Auto-Submitted: auto-generated|auto-replied` → `automated`
|
|
66
|
+
* 7. `Precedence: bulk|list|junk` → `automated`
|
|
67
|
+
* 8. Machine sender (no-reply local-part, `Feedback-ID`,
|
|
68
|
+
* `X-Auto-Response-Suppress`) → `automated`
|
|
69
|
+
* 9. DKIM `d=` differs from From domain → `automated`
|
|
70
|
+
* 10. fallback → `personal`
|
|
60
71
|
*/
|
|
61
72
|
export const classifyByHeaders = (parsed: ParsedMail): Category => {
|
|
62
73
|
const headers = parsed.headers;
|
|
63
74
|
const lines = parsed.headerLines;
|
|
64
75
|
|
|
65
|
-
if (matchesAutoSubmitted(headers)) return MessageCategory.automated;
|
|
66
|
-
if (matchesPrecedence(headers)) return MessageCategory.automated;
|
|
67
76
|
if (hasCalendarPart(parsed.attachments)) return MessageCategory.transactional;
|
|
68
77
|
|
|
69
78
|
const fromDomain = getFromDomain(parsed);
|
|
@@ -72,20 +81,24 @@ export const classifyByHeaders = (parsed: ParsedMail): Category => {
|
|
|
72
81
|
return MessageCategory.transactional;
|
|
73
82
|
}
|
|
74
83
|
|
|
84
|
+
if (fromDomain && domainMatches(fromDomain, SOCIAL_DOMAINS)) {
|
|
85
|
+
return MessageCategory.social;
|
|
86
|
+
}
|
|
87
|
+
|
|
75
88
|
const hasListUnsubscribe = hasHeaderLine(lines, "list-unsubscribe");
|
|
76
89
|
const hasListId = hasHeaderLine(lines, "list-id");
|
|
77
90
|
|
|
78
91
|
if (hasListUnsubscribe && hasListId) return MessageCategory.newsletter;
|
|
79
92
|
if (hasListUnsubscribe) return MessageCategory.marketing;
|
|
80
93
|
|
|
94
|
+
if (matchesAutoSubmitted(headers)) return MessageCategory.automated;
|
|
95
|
+
if (matchesPrecedence(headers)) return MessageCategory.automated;
|
|
96
|
+
if (isMachineSender(parsed, lines)) return MessageCategory.automated;
|
|
97
|
+
|
|
81
98
|
if (fromDomain && dkimMismatchResult(headers, lines, fromDomain).mismatch) {
|
|
82
99
|
return MessageCategory.automated;
|
|
83
100
|
}
|
|
84
101
|
|
|
85
|
-
if (fromDomain && domainMatches(fromDomain, SOCIAL_DOMAINS)) {
|
|
86
|
-
return MessageCategory.social;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
102
|
return MessageCategory.personal;
|
|
90
103
|
};
|
|
91
104
|
|
|
@@ -242,6 +255,26 @@ const hasCalendarPart = (attachments: Attachment[] | undefined): boolean => {
|
|
|
242
255
|
return false;
|
|
243
256
|
};
|
|
244
257
|
|
|
258
|
+
/**
|
|
259
|
+
* Whether the sender is a machine that does not read replies — a no-reply
|
|
260
|
+
* local-part, or a header only bulk/notification infrastructure sets. This is
|
|
261
|
+
* what routes platform notifications (npm, CI, password resets) to `automated`
|
|
262
|
+
* instead of the `personal` fallback; they carry no list or bulk headers.
|
|
263
|
+
*/
|
|
264
|
+
const isMachineSender = (parsed: ParsedMail, lines: HeaderLines): boolean => {
|
|
265
|
+
if (hasMachineHeader(lines.map((line) => line.key))) return true;
|
|
266
|
+
const localPart = getFromLocalPart(parsed);
|
|
267
|
+
return localPart !== null && isMachineLocalPart(localPart);
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const getFromLocalPart = (parsed: ParsedMail): string | null => {
|
|
271
|
+
const address = parsed.from?.value?.[0]?.address;
|
|
272
|
+
if (!address) return null;
|
|
273
|
+
const at = address.lastIndexOf("@");
|
|
274
|
+
if (at <= 0) return null;
|
|
275
|
+
return address.slice(0, at);
|
|
276
|
+
};
|
|
277
|
+
|
|
245
278
|
const getFromDomain = (parsed: ParsedMail): string | null => {
|
|
246
279
|
const from = parsed.from;
|
|
247
280
|
if (!from || !from.value || from.value.length === 0) return null;
|