@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/mailbox-service",
3
- "version": "0.0.11",
3
+ "version": "0.0.12",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -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(syncedMessageIds, skippedCount, []);
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(parsed: ParsedMail): UpdateMessageInput {
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
- // Get the ThreadMessage by messageId (efficient GSI lookup). The write is
1167
- // keyed on messageId, so it does not depend on the RFC822 Message-ID
1168
- // header — a headerless message still gets its category/snippet
1169
- // denormalized, matching the unconditional Message.category write.
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("Auto-Submitted wins over List-Unsubscribe", async () => {
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.automated);
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
- * Rule order matches the EDD heuristic table:
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
- * 1. `Auto-Submitted: auto-generated|auto-replied` `automated`
52
- * 2. `Precedence: bulk|list|junk` `automated`
53
- * 3. `Content-Type: text/calendar` part anywhere `transactional`
54
- * 4. From-domain in TRANSACTIONAL_DOMAINS `transactional`
55
- * 5. `List-Unsubscribe` AND `List-Id` `newsletter`
56
- * 6. `List-Unsubscribe` only `marketing`
57
- * 7. DKIM `d=` differs from From domain → `automated`
58
- * 8. From-domain in SOCIAL_DOMAINS → `social`
59
- * 9. fallback → `personal`
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
+ };
@@ -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