@remit/mailbox-service 0.0.11 → 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/message-move.ts +6 -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;
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Classification against realistic mail, drawn from the senders reported in
|
|
3
|
+
* issue #45 ("only personal and marketing seem to work").
|
|
4
|
+
*
|
|
5
|
+
* The rule table is easy to satisfy with synthetic one-header fixtures and
|
|
6
|
+
* still wrong on real mail, because real bulk senders set several signals at
|
|
7
|
+
* once. Every case here carries the full header set the sender actually emits,
|
|
8
|
+
* so the test fails when rule ORDER regresses even though each individual rule
|
|
9
|
+
* still works.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import { describe, it } from "node:test";
|
|
14
|
+
import { MessageCategory } from "@remit/domain-enums";
|
|
15
|
+
import { simpleParser } from "mailparser";
|
|
16
|
+
import { classifyByHeaders } from "./classifyByHeaders.js";
|
|
17
|
+
|
|
18
|
+
const classify = async (lines: string[]) =>
|
|
19
|
+
classifyByHeaders(await simpleParser(Buffer.from(lines.join("\r\n"))));
|
|
20
|
+
|
|
21
|
+
describe("classifyByHeaders on realistic mail", () => {
|
|
22
|
+
describe("platform notifications", () => {
|
|
23
|
+
it("classifies an npm publish notification as automated", async () => {
|
|
24
|
+
const category = await classify([
|
|
25
|
+
"From: npm <notifications@npmjs.com>",
|
|
26
|
+
"To: me@example.com",
|
|
27
|
+
"Subject: A new version of left-pad was published",
|
|
28
|
+
"DKIM-Signature: v=1; a=rsa-sha256; d=npmjs.com; s=s1; h=from:to",
|
|
29
|
+
"Content-Type: text/plain",
|
|
30
|
+
"",
|
|
31
|
+
"published",
|
|
32
|
+
]);
|
|
33
|
+
assert.equal(category, MessageCategory.automated);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("classifies an npm mail relayed through SES as automated", async () => {
|
|
37
|
+
const category = await classify([
|
|
38
|
+
"Return-Path: <bounces@amazonses.com>",
|
|
39
|
+
"From: npm <support@npmjs.com>",
|
|
40
|
+
"To: me@example.com",
|
|
41
|
+
"Subject: A new device signed in to your npm account",
|
|
42
|
+
"Feedback-ID: 1.eu-west-1.abc:AmazonSES",
|
|
43
|
+
"DKIM-Signature: v=1; a=rsa-sha256; d=amazonses.com; s=x; h=from:to",
|
|
44
|
+
"Content-Type: text/html",
|
|
45
|
+
"",
|
|
46
|
+
"<p>signed in</p>",
|
|
47
|
+
]);
|
|
48
|
+
assert.equal(category, MessageCategory.automated);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("classifies a no-reply notification with no bulk headers as automated", async () => {
|
|
52
|
+
// The reported npm case: one-to-one machine mail, aligned DKIM, and no
|
|
53
|
+
// List-* or Precedence header at all. It used to reach the `personal`
|
|
54
|
+
// fallback and sit among real correspondence.
|
|
55
|
+
const category = await classify([
|
|
56
|
+
"From: CircleCI <no-reply@circleci.com>",
|
|
57
|
+
"To: me@example.com",
|
|
58
|
+
"Subject: Your build failed",
|
|
59
|
+
"DKIM-Signature: v=1; a=rsa-sha256; d=circleci.com; s=s1; h=from:to",
|
|
60
|
+
"Content-Type: text/plain",
|
|
61
|
+
"",
|
|
62
|
+
"build failed",
|
|
63
|
+
]);
|
|
64
|
+
assert.equal(category, MessageCategory.automated);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("classifies X-Auto-Response-Suppress mail as automated", async () => {
|
|
68
|
+
const category = await classify([
|
|
69
|
+
"From: Helpdesk <ticketing@corp.example>",
|
|
70
|
+
"To: me@example.com",
|
|
71
|
+
"Subject: Ticket 4711 updated",
|
|
72
|
+
"X-Auto-Response-Suppress: All",
|
|
73
|
+
"Content-Type: text/plain",
|
|
74
|
+
"",
|
|
75
|
+
"updated",
|
|
76
|
+
]);
|
|
77
|
+
assert.equal(category, MessageCategory.automated);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("allow-listed sender domains outrank bulk headers", () => {
|
|
82
|
+
it("classifies a GitHub pull-request notification as transactional", async () => {
|
|
83
|
+
// GitHub sets List-ID, List-Unsubscribe AND Precedence: list. Before the
|
|
84
|
+
// reorder, Precedence matched first and every GitHub mail — security
|
|
85
|
+
// alerts and receipts included — landed in `automated`, contradicting
|
|
86
|
+
// the reason GitHub is on the transactional allow-list at all.
|
|
87
|
+
const category = await classify([
|
|
88
|
+
"From: contributor <notifications@github.com>",
|
|
89
|
+
"To: me@example.com",
|
|
90
|
+
"Subject: Re: [org/repo] Fix the thing (PR #45)",
|
|
91
|
+
"List-ID: org/repo <repo.org.github.com>",
|
|
92
|
+
"List-Unsubscribe: <https://github.com/unsub>",
|
|
93
|
+
"Precedence: list",
|
|
94
|
+
"DKIM-Signature: v=1; a=rsa-sha256; d=github.com; s=pf2014; h=from:to",
|
|
95
|
+
"Content-Type: text/plain",
|
|
96
|
+
"",
|
|
97
|
+
"comment",
|
|
98
|
+
]);
|
|
99
|
+
assert.equal(category, MessageCategory.transactional);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("classifies a GitHub security alert as transactional", async () => {
|
|
103
|
+
const category = await classify([
|
|
104
|
+
"From: GitHub <noreply@github.com>",
|
|
105
|
+
"To: me@example.com",
|
|
106
|
+
"Subject: [org/repo] Dependabot alert",
|
|
107
|
+
"Precedence: bulk",
|
|
108
|
+
"Content-Type: text/plain",
|
|
109
|
+
"",
|
|
110
|
+
"alert",
|
|
111
|
+
]);
|
|
112
|
+
assert.equal(category, MessageCategory.transactional);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("classifies a LinkedIn notification as social", async () => {
|
|
116
|
+
// The reported LinkedIn case. List-Unsubscribe matched before the social
|
|
117
|
+
// allow-list, so LinkedIn mail was filed as generic `marketing` and the
|
|
118
|
+
// Social bucket stayed empty.
|
|
119
|
+
const category = await classify([
|
|
120
|
+
"Return-Path: <s-hbhcfzp@bounce.linkedin.com>",
|
|
121
|
+
"From: LinkedIn <messages-noreply@linkedin.com>",
|
|
122
|
+
"To: me@example.com",
|
|
123
|
+
"Subject: You have a new invitation",
|
|
124
|
+
"List-Unsubscribe: <https://www.linkedin.com/e/unsub>",
|
|
125
|
+
"DKIM-Signature: v=1; a=rsa-sha256; d=linkedin.com; s=proddkim; h=from:to",
|
|
126
|
+
"Content-Type: text/html",
|
|
127
|
+
"",
|
|
128
|
+
"<p>invitation</p>",
|
|
129
|
+
]);
|
|
130
|
+
assert.equal(category, MessageCategory.social);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("classifies a LinkedIn job alert from a subdomain as social", async () => {
|
|
134
|
+
const category = await classify([
|
|
135
|
+
"From: LinkedIn Job Alerts <jobalerts-noreply@e.linkedin.com>",
|
|
136
|
+
"To: me@example.com",
|
|
137
|
+
"Subject: 20 new jobs for you",
|
|
138
|
+
"Precedence: bulk",
|
|
139
|
+
"List-Unsubscribe: <https://www.linkedin.com/e/unsub>",
|
|
140
|
+
"Content-Type: text/html",
|
|
141
|
+
"",
|
|
142
|
+
"<p>jobs</p>",
|
|
143
|
+
]);
|
|
144
|
+
assert.equal(category, MessageCategory.social);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("bulk mail keeps its intent bucket", () => {
|
|
149
|
+
it("classifies a marketing blast that also sets Precedence: bulk as marketing", async () => {
|
|
150
|
+
// Nearly every marketing platform sets Precedence: bulk. Matching it
|
|
151
|
+
// first swallowed the whole Marketing bucket into `automated`.
|
|
152
|
+
const category = await classify([
|
|
153
|
+
"From: Shop <deals@shop.example>",
|
|
154
|
+
"To: me@example.com",
|
|
155
|
+
"Subject: 50% off everything",
|
|
156
|
+
"Precedence: bulk",
|
|
157
|
+
"List-Unsubscribe: <https://shop.example/unsub>",
|
|
158
|
+
"List-Unsubscribe-Post: List-Unsubscribe=One-Click",
|
|
159
|
+
"Content-Type: text/html",
|
|
160
|
+
"",
|
|
161
|
+
"<p>sale</p>",
|
|
162
|
+
]);
|
|
163
|
+
assert.equal(category, MessageCategory.marketing);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("classifies a newsletter that also sets Precedence: list as newsletter", async () => {
|
|
167
|
+
const category = await classify([
|
|
168
|
+
"From: Some Writer <writer@substack.example>",
|
|
169
|
+
"To: me@example.com",
|
|
170
|
+
"Subject: This week's issue",
|
|
171
|
+
"Precedence: list",
|
|
172
|
+
"List-ID: <someletter.substack.example>",
|
|
173
|
+
"List-Unsubscribe: <https://substack.example/unsub>",
|
|
174
|
+
"Content-Type: text/html",
|
|
175
|
+
"",
|
|
176
|
+
"<p>news</p>",
|
|
177
|
+
]);
|
|
178
|
+
assert.equal(category, MessageCategory.newsletter);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("classifies a mailing-list post as newsletter, not automated", async () => {
|
|
182
|
+
const category = await classify([
|
|
183
|
+
"From: Contributor <dev@lists.example>",
|
|
184
|
+
"To: dev@lists.example",
|
|
185
|
+
"Subject: [PATCH v2] fix the parser",
|
|
186
|
+
"Precedence: list",
|
|
187
|
+
"List-ID: <dev.lists.example>",
|
|
188
|
+
"List-Unsubscribe: <mailto:dev-unsubscribe@lists.example>",
|
|
189
|
+
"Content-Type: text/plain",
|
|
190
|
+
"",
|
|
191
|
+
"patch",
|
|
192
|
+
]);
|
|
193
|
+
assert.equal(category, MessageCategory.newsletter);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe("personal mail stays personal", () => {
|
|
198
|
+
it("classifies a person writing from Gmail as personal", async () => {
|
|
199
|
+
const category = await classify([
|
|
200
|
+
"From: Alice <alice@gmail.com>",
|
|
201
|
+
"To: me@example.com",
|
|
202
|
+
"Subject: lunch?",
|
|
203
|
+
"DKIM-Signature: v=1; a=rsa-sha256; d=gmail.com; s=20230601; h=from:to",
|
|
204
|
+
"Content-Type: text/plain",
|
|
205
|
+
"",
|
|
206
|
+
"lunch tomorrow?",
|
|
207
|
+
]);
|
|
208
|
+
assert.equal(category, MessageCategory.personal);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("does not treat a human support mailbox as a machine sender", async () => {
|
|
212
|
+
// `support@` is answered by people. Adding it to the machine local-parts
|
|
213
|
+
// would quietly bury real correspondence in `automated`.
|
|
214
|
+
const category = await classify([
|
|
215
|
+
"From: Acme Support <support@acme.example>",
|
|
216
|
+
"To: me@example.com",
|
|
217
|
+
"Subject: Re: your question",
|
|
218
|
+
"Content-Type: text/plain",
|
|
219
|
+
"",
|
|
220
|
+
"answering your question",
|
|
221
|
+
]);
|
|
222
|
+
assert.equal(category, MessageCategory.personal);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("classifies a calendar invite from a colleague as transactional", async () => {
|
|
226
|
+
const category = await classify([
|
|
227
|
+
"From: Bob <bob@corp.example>",
|
|
228
|
+
"To: me@example.com",
|
|
229
|
+
"Subject: Invitation: standup",
|
|
230
|
+
'Content-Type: multipart/mixed; boundary="b1"',
|
|
231
|
+
"",
|
|
232
|
+
"--b1",
|
|
233
|
+
"Content-Type: text/calendar; method=REQUEST",
|
|
234
|
+
"",
|
|
235
|
+
"BEGIN:VCALENDAR",
|
|
236
|
+
"END:VCALENDAR",
|
|
237
|
+
"--b1--",
|
|
238
|
+
]);
|
|
239
|
+
assert.equal(category, MessageCategory.transactional);
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { hasMachineHeader, isMachineLocalPart } from "./machineSenders.js";
|
|
4
|
+
|
|
5
|
+
describe("isMachineLocalPart", () => {
|
|
6
|
+
it("matches the no-reply spellings", () => {
|
|
7
|
+
for (const localPart of [
|
|
8
|
+
"noreply",
|
|
9
|
+
"no-reply",
|
|
10
|
+
"no_reply",
|
|
11
|
+
"NoReply",
|
|
12
|
+
"donotreply",
|
|
13
|
+
"do-not-reply",
|
|
14
|
+
]) {
|
|
15
|
+
assert.equal(isMachineLocalPart(localPart), true, localPart);
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("matches no-reply prefixes used by platforms", () => {
|
|
20
|
+
for (const localPart of [
|
|
21
|
+
"noreply-github",
|
|
22
|
+
"no-reply+abc123",
|
|
23
|
+
"messages-noreply",
|
|
24
|
+
]) {
|
|
25
|
+
assert.equal(isMachineLocalPart(localPart), true, localPart);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("matches notification and bounce mailboxes", () => {
|
|
30
|
+
for (const localPart of [
|
|
31
|
+
"notifications",
|
|
32
|
+
"notify",
|
|
33
|
+
"alerts",
|
|
34
|
+
"bounces",
|
|
35
|
+
"mailer-daemon",
|
|
36
|
+
"postmaster",
|
|
37
|
+
]) {
|
|
38
|
+
assert.equal(isMachineLocalPart(localPart), true, localPart);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("does not match mailboxes a person answers", () => {
|
|
43
|
+
// A wrong entry here silently buries real correspondence in `automated`,
|
|
44
|
+
// which is the failure mode this whole change exists to remove.
|
|
45
|
+
for (const localPart of [
|
|
46
|
+
"support",
|
|
47
|
+
"info",
|
|
48
|
+
"contact",
|
|
49
|
+
"hello",
|
|
50
|
+
"sales",
|
|
51
|
+
"alice",
|
|
52
|
+
"team",
|
|
53
|
+
]) {
|
|
54
|
+
assert.equal(isMachineLocalPart(localPart), false, localPart);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("does not read a machine name across two words of a person's name", () => {
|
|
59
|
+
// `bruno.reply` strips to "brunoreply", which contains "noreply" spanning
|
|
60
|
+
// the boundary between the two words. Matching on whole words is what
|
|
61
|
+
// keeps Bruno out of `automated`.
|
|
62
|
+
for (const localPart of [
|
|
63
|
+
"bruno.reply",
|
|
64
|
+
"bruno-reply",
|
|
65
|
+
"bruno_reply",
|
|
66
|
+
"juno.replies",
|
|
67
|
+
"toni.fyi",
|
|
68
|
+
"bruno.reply+newsletter",
|
|
69
|
+
]) {
|
|
70
|
+
assert.equal(isMachineLocalPart(localPart), false, localPart);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("still matches the qualified machine forms", () => {
|
|
75
|
+
for (const localPart of [
|
|
76
|
+
"noreply-github",
|
|
77
|
+
"messages-noreply",
|
|
78
|
+
"jobalerts-noreply",
|
|
79
|
+
"mailer.daemon",
|
|
80
|
+
"do-not-reply",
|
|
81
|
+
"team.notifications",
|
|
82
|
+
]) {
|
|
83
|
+
assert.equal(isMachineLocalPart(localPart), true, localPart);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("hasMachineHeader", () => {
|
|
89
|
+
it("matches Feedback-ID regardless of case", () => {
|
|
90
|
+
assert.equal(hasMachineHeader(["from", "Feedback-ID"]), true);
|
|
91
|
+
assert.equal(hasMachineHeader(["feedback-id"]), true);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("matches X-Auto-Response-Suppress", () => {
|
|
95
|
+
assert.equal(hasMachineHeader(["x-auto-response-suppress"]), true);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("does not match ordinary headers", () => {
|
|
99
|
+
assert.equal(hasMachineHeader(["from", "to", "subject", "date"]), false);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signals that a message was sent by a machine that does not read replies.
|
|
3
|
+
*
|
|
4
|
+
* Distinct from the bulk signals (`Precedence`, `List-Unsubscribe`): a platform
|
|
5
|
+
* notification — an npm publish or 2FA mail, a CI result, a password reset —
|
|
6
|
+
* carries none of those. It is a one-to-one message with an aligned DKIM
|
|
7
|
+
* signature, so before issue #45 it reached the `personal` fallback and sat
|
|
8
|
+
* alongside actual human correspondence.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* From local-parts that mean "this mailbox is not read by a person". Matched
|
|
13
|
+
* case-insensitively against whole separator-delimited words, so `no-reply`,
|
|
14
|
+
* `no_reply` and `noreply` are one entry.
|
|
15
|
+
*
|
|
16
|
+
* Deliberately excludes ambiguous, human-reachable local-parts (`support`,
|
|
17
|
+
* `info`, `contact`, `hello`, `sales`): a person does answer those, and a
|
|
18
|
+
* wrong entry here silently buries real mail in `automated`.
|
|
19
|
+
*/
|
|
20
|
+
const MACHINE_LOCAL_PARTS = new Set([
|
|
21
|
+
"noreply",
|
|
22
|
+
"donotreply",
|
|
23
|
+
"notification",
|
|
24
|
+
"notifications",
|
|
25
|
+
"notify",
|
|
26
|
+
"automailer",
|
|
27
|
+
"automated",
|
|
28
|
+
"autoreply",
|
|
29
|
+
"mailerdaemon",
|
|
30
|
+
"postmaster",
|
|
31
|
+
"bounce",
|
|
32
|
+
"bounces",
|
|
33
|
+
"alert",
|
|
34
|
+
"alerts",
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Machine mailbox names that senders spell across separators. Joined into one
|
|
39
|
+
* word before the local-part is split, so `no-reply` and `mailer.daemon` reduce
|
|
40
|
+
* to a single token that {@link MACHINE_LOCAL_PARTS} can match.
|
|
41
|
+
*/
|
|
42
|
+
const SPELLED_OUT_MACHINE_NAMES: ReadonlyArray<[RegExp, string]> = [
|
|
43
|
+
[/do-not-reply/g, "donotreply"],
|
|
44
|
+
[/donot-reply/g, "donotreply"],
|
|
45
|
+
[/no-reply/g, "noreply"],
|
|
46
|
+
[/mailer-daemon/g, "mailerdaemon"],
|
|
47
|
+
[/auto-reply/g, "autoreply"],
|
|
48
|
+
[/auto-mailer/g, "automailer"],
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Headers only bulk/notification infrastructure sets. `Feedback-ID` is the
|
|
53
|
+
* per-campaign identifier SES, Google and other ESPs attach to programmatic
|
|
54
|
+
* sends; `X-Auto-Response-Suppress` tells the receiving client not to send
|
|
55
|
+
* vacation replies back, which only a machine sender asks for.
|
|
56
|
+
*/
|
|
57
|
+
const MACHINE_HEADERS = ["feedback-id", "x-auto-response-suppress"];
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Split a local-part into its separator-delimited words, with the spelled-out
|
|
61
|
+
* machine names joined back up first. The `+tag` suffix is dropped: it labels a
|
|
62
|
+
* subaddress, never the mailbox.
|
|
63
|
+
*/
|
|
64
|
+
const toWords = (localPart: string): string[] => {
|
|
65
|
+
let canonical = localPart.toLowerCase().split("+")[0].replace(/[._]/g, "-");
|
|
66
|
+
for (const [pattern, replacement] of SPELLED_OUT_MACHINE_NAMES) {
|
|
67
|
+
canonical = canonical.replace(pattern, replacement);
|
|
68
|
+
}
|
|
69
|
+
return canonical.split("-").filter(Boolean);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* True when any whole word of the From local-part is a known machine mailbox.
|
|
74
|
+
*
|
|
75
|
+
* Word-boundary, not substring: platforms qualify the mailbox on either side
|
|
76
|
+
* (`noreply-github`, `messages-noreply`, `jobalerts-noreply`), so a bare prefix
|
|
77
|
+
* test misses half of them — but a substring test reads `bruno.reply` as
|
|
78
|
+
* "bru|noreply" and files a real person as `automated`. Splitting on the
|
|
79
|
+
* separators the sender wrote catches the qualified forms without inventing a
|
|
80
|
+
* match that spans two words.
|
|
81
|
+
*/
|
|
82
|
+
export const isMachineLocalPart = (localPart: string): boolean =>
|
|
83
|
+
toWords(localPart).some((word) => MACHINE_LOCAL_PARTS.has(word));
|
|
84
|
+
|
|
85
|
+
export const hasMachineHeader = (headerKeys: readonly string[]): boolean => {
|
|
86
|
+
for (const key of headerKeys) {
|
|
87
|
+
if (MACHINE_HEADERS.includes(key.toLowerCase())) return true;
|
|
88
|
+
}
|
|
89
|
+
return false;
|
|
90
|
+
};
|
package/src/message-move.ts
CHANGED
|
@@ -467,6 +467,12 @@ export class MessageMoveService {
|
|
|
467
467
|
status: MessageStatus.moving,
|
|
468
468
|
syncStatus: MessageSyncStatus.pending,
|
|
469
469
|
bodyStorageKey: sourceMessage.bodyStorageKey,
|
|
470
|
+
// The copy inherits the source's stored body, so body-sync's skip guard
|
|
471
|
+
// will never re-derive these. Carry them across or the copy is
|
|
472
|
+
// permanently `uncategorized` while its body says it is fully synced
|
|
473
|
+
// (issue #45).
|
|
474
|
+
category: sourceMessage.category,
|
|
475
|
+
hasListUnsubscribe: sourceMessage.hasListUnsubscribe,
|
|
470
476
|
});
|
|
471
477
|
|
|
472
478
|
// Copy ThreadMessage entry
|