@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/mailbox-service",
3
- "version": "0.0.10",
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;