@remit/mailbox-service 0.0.30 → 0.0.32

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.30",
3
+ "version": "0.0.32",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -0,0 +1,228 @@
1
+ /**
2
+ * RFC 034 Decision 3.1: `Message.category` is written once and never mutated
3
+ * after — RFC 030's message-list GSI sort key depends on it never churning.
4
+ *
5
+ * `applyPostStoreSteps` computes a fresh classification on every pass and
6
+ * folds it into the single Message update, with no check for whether the
7
+ * message already carries a decided category. Two shipped paths re-enter it
8
+ * on an already-classified message — the `NoSuchKey` fallback in
9
+ * `fetchAndGetBody`, and `syncBodies(..., force: true)` — and both must leave
10
+ * `category` untouched. Each test below feeds a re-entrant pass a body that
11
+ * would classify differently from the message's existing category, so a
12
+ * regression that drops the guard shows up even though header classification
13
+ * is otherwise deterministic (issue #355).
14
+ */
15
+
16
+ import assert from "node:assert/strict";
17
+ import { Readable } from "node:stream";
18
+ import { describe, it } from "node:test";
19
+ import type {
20
+ IAddressRepository,
21
+ IEnvelopeRepository,
22
+ IMessageRepository,
23
+ IThreadMessageRepository,
24
+ MessageItem,
25
+ UpdateMessageInput,
26
+ } from "@remit/data-ports";
27
+ import { MessageCategory } from "@remit/domain-enums";
28
+ import type { StorageService } from "@remit/storage-service";
29
+ import { BodySyncService } from "./body-sync.js";
30
+ import type { IImapConnection } from "./types.js";
31
+
32
+ const LINKEDIN_EML = Buffer.from(
33
+ [
34
+ "From: LinkedIn <messages-noreply@linkedin.com>",
35
+ "To: me@example.com",
36
+ "Subject: You have a new invitation",
37
+ "Content-Type: text/plain",
38
+ "",
39
+ "invitation",
40
+ ].join("\r\n"),
41
+ );
42
+
43
+ const PERSONAL_EML = Buffer.from(
44
+ [
45
+ "From: Alex <alex@example.com>",
46
+ "To: me@example.com",
47
+ "Subject: Dinner Friday?",
48
+ "Content-Type: text/plain",
49
+ "",
50
+ "Are you free Friday night?",
51
+ ].join("\r\n"),
52
+ );
53
+
54
+ interface Harness {
55
+ service: BodySyncService;
56
+ message: MessageItem;
57
+ messageUpdates: Array<{ messageId: string; input: UpdateMessageInput }>;
58
+ }
59
+
60
+ const buildHarness = (
61
+ message: Partial<MessageItem> & Pick<MessageItem, "messageId">,
62
+ retrieve: () => Promise<Buffer>,
63
+ ): Harness => {
64
+ const messageUpdates: Array<{
65
+ messageId: string;
66
+ input: UpdateMessageInput;
67
+ }> = [];
68
+
69
+ const messageRow = {
70
+ uid: 1,
71
+ mailboxId: "mb-1",
72
+ ...message,
73
+ } as unknown as MessageItem;
74
+
75
+ const messageService = {
76
+ get: async () => messageRow,
77
+ update: async (messageId: string, input: UpdateMessageInput) => {
78
+ messageUpdates.push({ messageId, input });
79
+ Object.assign(messageRow, input);
80
+ },
81
+ } as unknown as IMessageRepository;
82
+
83
+ const threadMessageService = {
84
+ findAllByMessageId: async () => [
85
+ {
86
+ threadMessageId: "tm-1",
87
+ messageId: message.messageId,
88
+ mailboxId: "mb-1",
89
+ sentDate: 1,
90
+ isRead: false,
91
+ isDeleted: false,
92
+ hasStars: false,
93
+ hasAttachment: false,
94
+ category: MessageCategory.uncategorized,
95
+ },
96
+ ],
97
+ update: async () => {},
98
+ } as unknown as IThreadMessageRepository;
99
+
100
+ const storageService = {
101
+ retrieve,
102
+ storeMessageBody: async () => ({ uri: `s3://bodies/${message.messageId}` }),
103
+ storeMessageBodyStream: async () => ({
104
+ uri: `s3://bodies/${message.messageId}`,
105
+ }),
106
+ storeParsedBody: async () => {},
107
+ listBodyParts: async () => [],
108
+ } as unknown as StorageService;
109
+
110
+ const service = new BodySyncService(
111
+ messageService,
112
+ storageService,
113
+ threadMessageService,
114
+ { incrementInboundCount: async () => {} } as unknown as IAddressRepository,
115
+ { listBodyParts: async () => [] } as unknown as IEnvelopeRepository,
116
+ { info: () => {}, error: () => {} },
117
+ );
118
+
119
+ return { service, message: messageRow, messageUpdates };
120
+ };
121
+
122
+ const noSuchKeyError = () =>
123
+ Object.assign(new Error("missing"), {
124
+ name: "NoSuchKey",
125
+ });
126
+
127
+ describe("Message.category survives a re-entrant classification pass", () => {
128
+ it("keeps the existing category through the NoSuchKey IMAP re-fetch", async () => {
129
+ const harness = buildHarness(
130
+ {
131
+ messageId: "m-1",
132
+ bodyStorageKey: "s3://bodies/m-1",
133
+ category: MessageCategory.marketing,
134
+ },
135
+ async () => {
136
+ throw noSuchKeyError();
137
+ },
138
+ );
139
+
140
+ const connection = {
141
+ openBox: async () => {},
142
+ fetchMessageBody: async () => LINKEDIN_EML,
143
+ } as unknown as IImapConnection;
144
+
145
+ await harness.service.fetchAndGetBody(
146
+ "m-1",
147
+ "acc-1",
148
+ "cfg-1",
149
+ "INBOX",
150
+ async () => connection,
151
+ );
152
+
153
+ assert.equal(harness.messageUpdates.length, 1);
154
+ assert.equal(
155
+ harness.messageUpdates[0].input.category,
156
+ MessageCategory.marketing,
157
+ );
158
+ assert.equal(harness.message.category, MessageCategory.marketing);
159
+ });
160
+
161
+ it("keeps the existing category when syncBodies re-fetches with force", async () => {
162
+ const harness = buildHarness(
163
+ {
164
+ messageId: "m-1",
165
+ bodyStorageKey: "s3://bodies/m-1",
166
+ category: MessageCategory.marketing,
167
+ },
168
+ async () => {
169
+ throw new Error("force path must not retrieve from storage");
170
+ },
171
+ );
172
+
173
+ const connection = {
174
+ openBox: async () => {},
175
+ async *fetchMessageBodies(uids: number[]) {
176
+ for (const uid of uids) {
177
+ yield { uid, source: Readable.from([LINKEDIN_EML]) };
178
+ }
179
+ },
180
+ } as unknown as IImapConnection;
181
+
182
+ const result = await harness.service.syncBodies(
183
+ ["m-1"],
184
+ "acc-1",
185
+ "cfg-1",
186
+ "INBOX",
187
+ async () => connection,
188
+ true,
189
+ );
190
+
191
+ assert.deepEqual(result.syncedMessageIds, ["m-1"]);
192
+ assert.equal(harness.messageUpdates.length, 1);
193
+ assert.equal(
194
+ harness.messageUpdates[0].input.category,
195
+ MessageCategory.marketing,
196
+ );
197
+ assert.equal(harness.message.category, MessageCategory.marketing);
198
+ });
199
+
200
+ it("still writes the category on a first classification", async () => {
201
+ const harness = buildHarness(
202
+ { messageId: "m-1", category: MessageCategory.uncategorized },
203
+ async () => {
204
+ throw new Error("no body stored yet; must not retrieve");
205
+ },
206
+ );
207
+
208
+ const connection = {
209
+ openBox: async () => {},
210
+ fetchMessageBody: async () => PERSONAL_EML,
211
+ } as unknown as IImapConnection;
212
+
213
+ await harness.service.fetchAndGetBody(
214
+ "m-1",
215
+ "acc-1",
216
+ "cfg-1",
217
+ "INBOX",
218
+ async () => connection,
219
+ );
220
+
221
+ assert.equal(harness.messageUpdates.length, 1);
222
+ assert.equal(
223
+ harness.messageUpdates[0].input.category,
224
+ MessageCategory.personal,
225
+ );
226
+ assert.equal(harness.message.category, MessageCategory.personal);
227
+ });
228
+ });
@@ -0,0 +1,302 @@
1
+ /**
2
+ * Issue #300 (RFC 039 Decision 3/3a): `Address.flags.blocked`/`autoArchive`
3
+ * were written by the UI and promised in product copy ("Blocked senders...
4
+ * go straight to junk") but `classifyPlacement` never read them. These tests
5
+ * drive `BodySyncService` end-to-end (read-path body materialization →
6
+ * `applyPostStoreSteps` → `resolvePlacement` → the placement move) and assert
7
+ * on the actual move call, so a regression that drops the flag read — not
8
+ * just the `classifyPlacement` branch — shows up here.
9
+ */
10
+
11
+ import assert from "node:assert/strict";
12
+ import { describe, it } from "node:test";
13
+ import type {
14
+ AddressItem,
15
+ IAddressRepository,
16
+ IEnvelopeRepository,
17
+ IMailboxSpecialUseRepository,
18
+ IMessageRepository,
19
+ IThreadMessageRepository,
20
+ UpdateMessageInput,
21
+ } from "@remit/data-ports";
22
+ import { MailboxSpecialUse } from "@remit/domain-enums";
23
+ import type { StorageService } from "@remit/storage-service";
24
+ import type { PlacementConfig } from "./body-sync.js";
25
+ import { BodySyncService } from "./body-sync.js";
26
+ import type { PlacementMoveService } from "./placement-move.js";
27
+ import type { IImapConnection } from "./types.js";
28
+
29
+ const PLAIN_EML = Buffer.from(
30
+ [
31
+ "From: Sender <someone@example.com>",
32
+ "To: me@example.com",
33
+ "Subject: Hello",
34
+ "Content-Type: text/plain",
35
+ "",
36
+ "body",
37
+ ].join("\r\n"),
38
+ );
39
+
40
+ /** Carries the provider-spam + dmarc-pass signals the pre-existing junk→inbox rescue needs (independent of this issue). */
41
+ const SPAM_FLAGGED_DMARC_PASS_EML = Buffer.from(
42
+ [
43
+ "From: Sender <someone@example.com>",
44
+ "To: me@example.com",
45
+ "Subject: Hello",
46
+ "Authentication-Results: mx.example.com; dmarc=pass",
47
+ "X-Spam-Status: Yes, score=6.0",
48
+ "Content-Type: text/plain",
49
+ "",
50
+ "body",
51
+ ].join("\r\n"),
52
+ );
53
+
54
+ interface MoveCall {
55
+ messageId: string;
56
+ destinationMailboxId: string;
57
+ }
58
+
59
+ interface Harness {
60
+ service: BodySyncService;
61
+ moves: MoveCall[];
62
+ messageUpdates: Array<{ messageId: string; input: UpdateMessageInput }>;
63
+ }
64
+
65
+ const MAILBOXES = {
66
+ inbox: { mailboxId: "mb-inbox", fullPath: "INBOX" },
67
+ junk: { mailboxId: "mb-junk", fullPath: "Junk" },
68
+ archive: { mailboxId: "mb-archive", fullPath: "Archive" },
69
+ };
70
+
71
+ const buildHarness = (
72
+ message: { messageId: string; mailboxId: string },
73
+ flags: AddressItem["flags"],
74
+ ): Harness => {
75
+ const moves: MoveCall[] = [];
76
+ const messageUpdates: Array<{
77
+ messageId: string;
78
+ input: UpdateMessageInput;
79
+ }> = [];
80
+
81
+ const messageService = {
82
+ get: async () => ({
83
+ messageId: message.messageId,
84
+ mailboxId: message.mailboxId,
85
+ uid: 1,
86
+ }),
87
+ update: async (messageId: string, input: UpdateMessageInput) => {
88
+ messageUpdates.push({ messageId, input });
89
+ },
90
+ } as unknown as IMessageRepository;
91
+
92
+ const threadMessageService = {
93
+ findAllByMessageId: async () => [
94
+ {
95
+ threadMessageId: "tm-1",
96
+ sentDate: 1,
97
+ mailboxId: message.mailboxId,
98
+ isRead: false,
99
+ isDeleted: false,
100
+ hasStars: false,
101
+ hasAttachment: false,
102
+ },
103
+ ],
104
+ update: async () => {},
105
+ } as unknown as IThreadMessageRepository;
106
+
107
+ const storageService = {
108
+ storeMessageBody: async () => ({ uri: "s3://bodies/m-1" }),
109
+ storeParsedBody: async () => {},
110
+ listBodyParts: async () => [],
111
+ } as unknown as StorageService;
112
+
113
+ const addressService = {
114
+ getAddress: async () => ({ flags }) as unknown as AddressItem,
115
+ incrementInboundCount: async () => {},
116
+ } as unknown as IAddressRepository;
117
+
118
+ const envelopeService = {
119
+ listBodyParts: async () => [],
120
+ } as unknown as IEnvelopeRepository;
121
+
122
+ const mailboxSpecialUseService = {
123
+ findBySpecialUse: async (_accountId: string, specialUse: string) => {
124
+ if (specialUse === MailboxSpecialUse.Junk) return MAILBOXES.junk;
125
+ if (specialUse === MailboxSpecialUse.Archive) return MAILBOXES.archive;
126
+ return null;
127
+ },
128
+ findInboxMailbox: async () => MAILBOXES.inbox,
129
+ } as unknown as IMailboxSpecialUseRepository;
130
+
131
+ const placementMoveService = {
132
+ moveMessage: async (
133
+ _accountConfigId: string,
134
+ messageId: string,
135
+ destinationMailboxId: string,
136
+ ) => {
137
+ moves.push({ messageId, destinationMailboxId });
138
+ },
139
+ } as unknown as PlacementMoveService;
140
+
141
+ const placementConfig: PlacementConfig = {
142
+ mailboxSpecialUseService,
143
+ placementMoveService,
144
+ };
145
+
146
+ const service = new BodySyncService(
147
+ messageService,
148
+ storageService,
149
+ threadMessageService,
150
+ addressService,
151
+ envelopeService,
152
+ { info: () => {}, error: () => {} },
153
+ placementConfig,
154
+ );
155
+
156
+ return { service, moves, messageUpdates };
157
+ };
158
+
159
+ const readBody = async (
160
+ service: BodySyncService,
161
+ mailboxPath = "INBOX",
162
+ body: Buffer = PLAIN_EML,
163
+ ) => {
164
+ const connection = {
165
+ openBox: async () => {},
166
+ fetchMessageBody: async () => body,
167
+ } as unknown as IImapConnection;
168
+ return service.fetchAndGetBody(
169
+ "m-1",
170
+ "acc-1",
171
+ "cfg-1",
172
+ mailboxPath,
173
+ async () => connection,
174
+ );
175
+ };
176
+
177
+ const flagsAt = (
178
+ setAt: number,
179
+ ): { blocked: { value: true; setAt: number } } => ({
180
+ blocked: { value: true, setAt },
181
+ });
182
+
183
+ describe("Address.flags.blocked drives placement (issue #300)", () => {
184
+ it("moves an inbox message from a blocked sender to junk, with no DKIM/DMARC signal at all", async () => {
185
+ const harness = buildHarness(
186
+ { messageId: "m-1", mailboxId: MAILBOXES.inbox.mailboxId },
187
+ flagsAt(1_000),
188
+ );
189
+
190
+ await readBody(harness.service);
191
+
192
+ assert.deepEqual(harness.moves, [
193
+ { messageId: "m-1", destinationMailboxId: MAILBOXES.junk.mailboxId },
194
+ ]);
195
+ assert.equal(harness.messageUpdates[0]?.input.movedByRemit, true);
196
+ });
197
+
198
+ it("does not move a blocked sender's message already sitting in junk", async () => {
199
+ const harness = buildHarness(
200
+ { messageId: "m-1", mailboxId: MAILBOXES.junk.mailboxId },
201
+ flagsAt(1_000),
202
+ );
203
+
204
+ await readBody(harness.service, "Junk");
205
+
206
+ assert.deepEqual(harness.moves, []);
207
+ });
208
+
209
+ it("leaves an unflagged sender's inbox message alone", async () => {
210
+ const harness = buildHarness(
211
+ { messageId: "m-1", mailboxId: MAILBOXES.inbox.mailboxId },
212
+ {},
213
+ );
214
+
215
+ await readBody(harness.service);
216
+
217
+ assert.deepEqual(harness.moves, []);
218
+ });
219
+
220
+ describe("Decision 3a: setAt tie-break against vip/wellknown", () => {
221
+ it("demotes when blocked is set after vip", async () => {
222
+ const harness = buildHarness(
223
+ { messageId: "m-1", mailboxId: MAILBOXES.inbox.mailboxId },
224
+ {
225
+ vip: { value: true, setAt: 1_000 },
226
+ blocked: { value: true, setAt: 5_000 },
227
+ },
228
+ );
229
+
230
+ await readBody(harness.service);
231
+
232
+ assert.deepEqual(harness.moves, [
233
+ { messageId: "m-1", destinationMailboxId: MAILBOXES.junk.mailboxId },
234
+ ]);
235
+ });
236
+
237
+ it("rescues (does not demote) when vip is set after blocked, from junk", async () => {
238
+ const harness = buildHarness(
239
+ { messageId: "m-1", mailboxId: MAILBOXES.junk.mailboxId },
240
+ {
241
+ blocked: { value: true, setAt: 1_000 },
242
+ vip: { value: true, setAt: 5_000 },
243
+ },
244
+ );
245
+
246
+ // Provider-spam + dmarc-pass is the pre-existing rescue's own low bar,
247
+ // independent of this issue — carried by the fixture so the only thing
248
+ // under test is whether the newer `vip` correctly suppresses `blocked`'s
249
+ // demote.
250
+ await readBody(harness.service, "Junk", SPAM_FLAGGED_DMARC_PASS_EML);
251
+
252
+ assert.deepEqual(harness.moves, [
253
+ { messageId: "m-1", destinationMailboxId: MAILBOXES.inbox.mailboxId },
254
+ ]);
255
+ });
256
+ });
257
+ });
258
+
259
+ describe("Address.flags.autoArchive drives placement (issue #300)", () => {
260
+ it("files an inbox message from an autoArchive sender straight to Archive", async () => {
261
+ const harness = buildHarness(
262
+ { messageId: "m-1", mailboxId: MAILBOXES.inbox.mailboxId },
263
+ { autoArchive: { value: true, setAt: 1_000 } },
264
+ );
265
+
266
+ await readBody(harness.service);
267
+
268
+ assert.deepEqual(harness.moves, [
269
+ {
270
+ messageId: "m-1",
271
+ destinationMailboxId: MAILBOXES.archive.mailboxId,
272
+ },
273
+ ]);
274
+ });
275
+
276
+ it("does not move an autoArchive sender's message already sitting in Archive", async () => {
277
+ const harness = buildHarness(
278
+ { messageId: "m-1", mailboxId: MAILBOXES.archive.mailboxId },
279
+ { autoArchive: { value: true, setAt: 1_000 } },
280
+ );
281
+
282
+ await readBody(harness.service, "Archive");
283
+
284
+ assert.deepEqual(harness.moves, []);
285
+ });
286
+
287
+ it("a confident blocked-demote takes priority over autoArchive", async () => {
288
+ const harness = buildHarness(
289
+ { messageId: "m-1", mailboxId: MAILBOXES.inbox.mailboxId },
290
+ {
291
+ blocked: { value: true, setAt: 1_000 },
292
+ autoArchive: { value: true, setAt: 1_000 },
293
+ },
294
+ );
295
+
296
+ await readBody(harness.service);
297
+
298
+ assert.deepEqual(harness.moves, [
299
+ { messageId: "m-1", destinationMailboxId: MAILBOXES.junk.mailboxId },
300
+ ]);
301
+ });
302
+ });
package/src/body-sync.ts CHANGED
@@ -47,6 +47,7 @@ import {
47
47
  import {
48
48
  classifyPlacement,
49
49
  type FolderPlacement,
50
+ resolveBlockedVsTrust,
50
51
  } from "./heuristics/classifyPlacement.js";
51
52
  import type { PlacementMoveService } from "./placement-move.js";
52
53
  import { type QuarantineService, shapeFromMessageData } from "./quarantine.js";
@@ -169,6 +170,20 @@ const alreadyDenormalized = (
169
170
  (update.snippet === undefined || row.snippet === update.snippet) &&
170
171
  (update.listId === undefined || row.listId === update.listId);
171
172
 
173
+ /**
174
+ * RFC 034 Decision 3.1: `Message.category` is written once and never mutated
175
+ * after — RFC 030's message-list GSI sort key depends on it never churning.
176
+ * "Already decided" is any real category; `uncategorized` and the field being
177
+ * absent (rows written before the column existed) both mean "not yet decided"
178
+ * and must still classify. The one rule every re-entrant classification path
179
+ * shares — {@link BodySyncService.backfillClassification} and
180
+ * {@link BodySyncService.applyPostStoreSteps} both defer to it.
181
+ */
182
+ const hasDecidedCategory = (
183
+ category: ThreadMessageCategory | undefined,
184
+ ): boolean =>
185
+ category !== undefined && category !== MessageCategory.uncategorized;
186
+
172
187
  export const toParsedBody = (parsed: ParsedMail): ParsedBody => ({
173
188
  text: parsed.text ?? null,
174
189
  html: typeof parsed.html === "string" ? parsed.html : null,
@@ -808,9 +823,20 @@ export class BodySyncService {
808
823
  // a filter); the verdict is folded in whenever Remit decided to act.
809
824
  // Written LAST so bodyStorageKey — the skip-guard signal — is only durable
810
825
  // once the parsed cache AND the move (when any) are.
826
+ //
827
+ // `category` is RFC 034 D3.1's write-once field (RFC 030's message-list
828
+ // GSI sort key depends on it never churning). This step re-enters on an
829
+ // already-classified message through two shipped paths — the `NoSuchKey`
830
+ // fallback in `fetchAndGetBody` and `syncBodies(..., force: true)` — so a
831
+ // real, previously-decided category is carried forward unchanged instead
832
+ // of the just-recomputed one, the same rule `backfillClassification` uses.
833
+ const existingMessage = await this.messageService.get(messageId);
811
834
  const update: UpdateMessageInput = {
812
835
  bodyStorageKey: bodyRef.uri,
813
836
  ...classification,
837
+ category: hasDecidedCategory(existingMessage.category)
838
+ ? existingMessage.category
839
+ : classification.category,
814
840
  ...(moved ? { movedByRemit: true } : {}),
815
841
  ...(resolved.verdict ? { placementVerdict: resolved.verdict } : {}),
816
842
  ...(filterMove ? { filterMove } : {}),
@@ -962,12 +988,7 @@ export class BodySyncService {
962
988
  accountConfigId: string,
963
989
  ): Promise<void> {
964
990
  if (!message.bodyStorageKey) return;
965
- if (
966
- message.category !== undefined &&
967
- message.category !== MessageCategory.uncategorized
968
- ) {
969
- return;
970
- }
991
+ if (hasDecidedCategory(message.category)) return;
971
992
 
972
993
  const body = await this.storageService.retrieve(message.bodyStorageKey);
973
994
  const parsed = await parseMessageBody(body);
@@ -1051,26 +1072,68 @@ export class BodySyncService {
1051
1072
  );
1052
1073
  }
1053
1074
 
1054
- private async deriveSenderTrust(
1075
+ /**
1076
+ * The per-sender signals {@link computePlacement} needs, from ONE `Address`
1077
+ * fetch (RFC 039 Decision 3/3a, issue #300): the trust reads exactly as
1078
+ * `deriveSenderTrust` always did — `vip → wellknown → unknown`, untouched by
1079
+ * `blocked` — plus `blocked`/`autoArchive` off the same row. `trustSetAt` is
1080
+ * the `setAt` of whichever flag produced the trust value, needed by
1081
+ * {@link resolveBlockedVsTrust}'s tie-break; it stays local to placement and
1082
+ * never reaches `deriveSenderTrust`'s own contract (the trust badge).
1083
+ */
1084
+ private async deriveSenderPlacementSignals(
1055
1085
  accountConfigId: string,
1056
1086
  fromEmail: string,
1057
- ): Promise<(typeof SenderTrust)[keyof typeof SenderTrust]> {
1087
+ ): Promise<{
1088
+ trust: (typeof SenderTrust)[keyof typeof SenderTrust];
1089
+ trustSetAt?: number;
1090
+ blocked: boolean;
1091
+ blockedSetAt?: number;
1092
+ autoArchive: boolean;
1093
+ }> {
1094
+ const unknown = {
1095
+ trust: SenderTrust.Unknown,
1096
+ blocked: false,
1097
+ autoArchive: false,
1098
+ } as const;
1058
1099
  try {
1059
1100
  const addressId = deriveAddressId(accountConfigId, fromEmail);
1060
1101
  const address = await this.addressService.getAddress(
1061
1102
  accountConfigId,
1062
1103
  addressId,
1063
1104
  );
1064
- if (address.flags?.vip?.value === true) return SenderTrust.Vip;
1065
- if (address.flags?.wellknown?.value === true)
1066
- return SenderTrust.Wellknown;
1105
+ const flags = address.flags;
1106
+ if (flags?.vip?.value === true) {
1107
+ return {
1108
+ trust: SenderTrust.Vip,
1109
+ trustSetAt: flags.vip.setAt,
1110
+ blocked: flags.blocked?.value === true,
1111
+ blockedSetAt: flags.blocked?.setAt,
1112
+ autoArchive: flags.autoArchive?.value === true,
1113
+ };
1114
+ }
1115
+ if (flags?.wellknown?.value === true) {
1116
+ return {
1117
+ trust: SenderTrust.Wellknown,
1118
+ trustSetAt: flags.wellknown.setAt,
1119
+ blocked: flags.blocked?.value === true,
1120
+ blockedSetAt: flags.blocked?.setAt,
1121
+ autoArchive: flags.autoArchive?.value === true,
1122
+ };
1123
+ }
1124
+ return {
1125
+ ...unknown,
1126
+ blocked: flags?.blocked?.value === true,
1127
+ blockedSetAt: flags?.blocked?.setAt,
1128
+ autoArchive: flags?.autoArchive?.value === true,
1129
+ };
1067
1130
  } catch (err) {
1068
- // A genuinely-absent address means "unknown trust". Any other failure
1069
- // (AccessDenied, throttle, infra) must NOT be silently downgraded to
1070
- // Unknown — let it crash so the rescue decision isn't made on bad data.
1131
+ // A genuinely-absent address means "no signals". Any other failure
1132
+ // (AccessDenied, throttle, infra) must NOT be silently downgraded — let
1133
+ // it crash so the placement decision isn't made on bad data.
1071
1134
  if (!(err instanceof NotFoundError)) throw err;
1072
1135
  }
1073
- return SenderTrust.Unknown;
1136
+ return unknown;
1074
1137
  }
1075
1138
 
1076
1139
  /**
@@ -1179,19 +1242,42 @@ export class BodySyncService {
1179
1242
  : "other";
1180
1243
 
1181
1244
  const fromEmail = extractPrimaryFromEmail(parsed);
1182
- const senderTrust = fromEmail
1183
- ? await this.deriveSenderTrust(accountConfigId, fromEmail)
1184
- : SenderTrust.Unknown;
1245
+ const signals = fromEmail
1246
+ ? await this.deriveSenderPlacementSignals(accountConfigId, fromEmail)
1247
+ : {
1248
+ trust: SenderTrust.Unknown,
1249
+ blocked: false,
1250
+ autoArchive: false,
1251
+ };
1252
+
1253
+ const { senderTrust, senderBlocked } = resolveBlockedVsTrust(
1254
+ { trust: signals.trust, setAt: signals.trustSetAt },
1255
+ { blocked: signals.blocked, setAt: signals.blockedSetAt },
1256
+ );
1185
1257
 
1186
1258
  // The verdict needs the classification signals (providerSpam,
1187
1259
  // authResult, authenticity) that this body-sync pass just derived; the
1188
1260
  // stored row does not carry them yet, so overlay them onto the message.
1189
1261
  const candidate = { ...message, ...classification };
1190
- const verdict = classifyPlacement(candidate, placement, senderTrust);
1262
+ const verdict = classifyPlacement(
1263
+ candidate,
1264
+ placement,
1265
+ senderTrust,
1266
+ senderBlocked,
1267
+ );
1191
1268
 
1192
- // A `leave` verdict carries no audit record and no move.
1269
+ // A `leave` verdict including "nothing confident to say" — carries no
1270
+ // audit record of its own. `flags.autoArchive` (issue #300) is a distinct,
1271
+ // lower-priority filing preference: it only files a message away when
1272
+ // `blocked`/DKIM/DMARC had nothing to say, never overriding a confident
1273
+ // junk/inbox verdict computed above.
1193
1274
  if (verdict.action === "leave") {
1194
- return {};
1275
+ return this.resolveAutoArchive(
1276
+ mailboxSpecialUseService,
1277
+ message,
1278
+ accountId,
1279
+ signals.autoArchive,
1280
+ );
1195
1281
  }
1196
1282
 
1197
1283
  // Audit verdict — recorded for every actionable verdict (both
@@ -1246,6 +1332,55 @@ export class BodySyncService {
1246
1332
  };
1247
1333
  }
1248
1334
 
1335
+ /**
1336
+ * `flags.autoArchive` (issue #300, RFC 039 Decision 3): file a message
1337
+ * straight to Archive, skipping Inbox. Only reached from
1338
+ * {@link computePlacement} when {@link classifyPlacement} had no confident
1339
+ * junk/inbox verdict of its own — `blocked`/DKIM/DMARC always take priority
1340
+ * over this filing preference.
1341
+ *
1342
+ * No {@link MessagePlacementVerdict} is recorded: `PlacementAction` (the
1343
+ * audit enum) has only `MoveToInbox`/`MoveToJunk` — issue #300 is scoped to
1344
+ * no TypeSpec change, so an archive move carries no audit verdict, same as
1345
+ * a matched filter's move. The move itself reuses the same
1346
+ * `placementMoveService.moveMessage` path as every other confident move.
1347
+ *
1348
+ * Idempotent the same way {@link classifyPlacement}'s own branches are: a
1349
+ * message already sitting in Archive is left alone, not moved again.
1350
+ */
1351
+ private async resolveAutoArchive(
1352
+ mailboxSpecialUseService: IMailboxSpecialUseRepository,
1353
+ message: MessageItem,
1354
+ accountId: string,
1355
+ autoArchive: boolean,
1356
+ ): Promise<PlacementOutcome> {
1357
+ if (!autoArchive) return {};
1358
+
1359
+ const archiveMailbox = await mailboxSpecialUseService.findBySpecialUse(
1360
+ accountId,
1361
+ MailboxSpecialUse.Archive,
1362
+ );
1363
+ if (!archiveMailbox || message.mailboxId === archiveMailbox.mailboxId) {
1364
+ return {};
1365
+ }
1366
+
1367
+ this.log.info(
1368
+ {
1369
+ messageId: message.messageId,
1370
+ accountId,
1371
+ destinationMailboxId: archiveMailbox.mailboxId,
1372
+ },
1373
+ "Auto-archive verdict",
1374
+ );
1375
+
1376
+ return {
1377
+ move: {
1378
+ destinationMailboxId: archiveMailbox.mailboxId,
1379
+ destinationPath: archiveMailbox.fullPath,
1380
+ },
1381
+ };
1382
+ }
1383
+
1249
1384
  // The old `enqueuePlacementMove` (best-effort, catch-and-log) lived here.
1250
1385
  // Issue #1271: it ran AFTER `bodyStorageKey` was already durable, so a
1251
1386
  // failure was swallowed to avoid stranding the message behind the
@@ -0,0 +1,214 @@
1
+ /**
2
+ * `classifyPlacement` had no dedicated unit test — only the DKIM/DMARC paths
3
+ * were exercised indirectly through realistic-mail fixtures elsewhere. Issue
4
+ * #300 (RFC 039 Decision 3/3a) adds `senderBlocked` as a confident demote
5
+ * independent of every DKIM/DMARC/provider signal, plus a `setAt` tie-break
6
+ * against `vip`/`wellknown` (Decision 3a, `resolveBlockedVsTrust`). These tests
7
+ * cover both the new branch and the pre-existing DKIM/DMARC branches, so a
8
+ * regression in either shows up here.
9
+ */
10
+
11
+ import assert from "node:assert/strict";
12
+ import { describe, it } from "node:test";
13
+ import type { MessageItem } from "@remit/data-ports";
14
+ import { SenderTrust } from "@remit/domain-enums";
15
+ import {
16
+ classifyPlacement,
17
+ resolveBlockedVsTrust,
18
+ } from "./classifyPlacement.js";
19
+
20
+ const baseMessage = (overrides: Partial<MessageItem> = {}): MessageItem =>
21
+ ({
22
+ messageId: "m-1",
23
+ mailboxId: "mb-1",
24
+ uid: 1,
25
+ providerSpam: { classified: false },
26
+ authResult: { dmarc: "Pass" },
27
+ ...overrides,
28
+ }) as unknown as MessageItem;
29
+
30
+ describe("classifyPlacement", () => {
31
+ describe("senderBlocked (RFC 039 Decision 3)", () => {
32
+ it("demotes an inbox message from a blocked sender, independent of DKIM/DMARC", () => {
33
+ const message = baseMessage({
34
+ providerSpam: undefined,
35
+ authResult: undefined,
36
+ });
37
+ const verdict = classifyPlacement(
38
+ message,
39
+ "inbox",
40
+ SenderTrust.Unknown,
41
+ true,
42
+ );
43
+ assert.deepEqual(verdict, {
44
+ action: "move-to-junk",
45
+ confidence: "confident",
46
+ reasons: ["sender=blocked"],
47
+ });
48
+ });
49
+
50
+ it("demotes a message sitting outside junk/inbox (e.g. a custom folder) from a blocked sender", () => {
51
+ const verdict = classifyPlacement(
52
+ baseMessage(),
53
+ "other",
54
+ SenderTrust.Unknown,
55
+ true,
56
+ );
57
+ assert.equal(verdict.action, "move-to-junk");
58
+ assert.equal(verdict.confidence, "confident");
59
+ });
60
+
61
+ it("does not re-move a blocked sender's message already in junk", () => {
62
+ const verdict = classifyPlacement(
63
+ baseMessage(),
64
+ "junk",
65
+ SenderTrust.Unknown,
66
+ true,
67
+ );
68
+ assert.notEqual(verdict.action, "move-to-junk");
69
+ });
70
+
71
+ it("does not demote when the sender is not blocked, all else equal", () => {
72
+ const verdict = classifyPlacement(
73
+ baseMessage({ providerSpam: undefined, authResult: undefined }),
74
+ "inbox",
75
+ SenderTrust.Unknown,
76
+ false,
77
+ );
78
+ assert.deepEqual(verdict, {
79
+ action: "leave",
80
+ confidence: "unsure",
81
+ reasons: ["missing-signals"],
82
+ });
83
+ });
84
+
85
+ it("still leaves an already-Remit-moved message alone even when blocked", () => {
86
+ const verdict = classifyPlacement(
87
+ baseMessage({ movedByRemit: true }),
88
+ "inbox",
89
+ SenderTrust.Unknown,
90
+ true,
91
+ );
92
+ assert.deepEqual(verdict, {
93
+ action: "leave",
94
+ confidence: "confident",
95
+ reasons: ["already-moved-by-remit"],
96
+ });
97
+ });
98
+ });
99
+
100
+ describe("resolveBlockedVsTrust (Decision 3a tie-break)", () => {
101
+ it("blocked wins when set after vip", () => {
102
+ const result = resolveBlockedVsTrust(
103
+ { trust: SenderTrust.Vip, setAt: 1_000 },
104
+ { blocked: true, setAt: 5_000 },
105
+ );
106
+ assert.deepEqual(result, {
107
+ senderTrust: SenderTrust.Unknown,
108
+ senderBlocked: true,
109
+ });
110
+ });
111
+
112
+ it("vip wins when set after blocked", () => {
113
+ const result = resolveBlockedVsTrust(
114
+ { trust: SenderTrust.Vip, setAt: 5_000 },
115
+ { blocked: true, setAt: 1_000 },
116
+ );
117
+ assert.deepEqual(result, {
118
+ senderTrust: SenderTrust.Vip,
119
+ senderBlocked: false,
120
+ });
121
+ });
122
+
123
+ it("breaks a same-second tie in favor of blocked", () => {
124
+ const result = resolveBlockedVsTrust(
125
+ { trust: SenderTrust.Wellknown, setAt: 1_000 },
126
+ { blocked: true, setAt: 1_400 },
127
+ );
128
+ assert.deepEqual(result, {
129
+ senderTrust: SenderTrust.Unknown,
130
+ senderBlocked: true,
131
+ });
132
+ });
133
+
134
+ it("blocked applies outright when there is no competing trust flag", () => {
135
+ const result = resolveBlockedVsTrust(
136
+ { trust: SenderTrust.Unknown },
137
+ { blocked: true, setAt: 1_000 },
138
+ );
139
+ assert.deepEqual(result, {
140
+ senderTrust: SenderTrust.Unknown,
141
+ senderBlocked: true,
142
+ });
143
+ });
144
+
145
+ it("passes trust through unchanged when the sender isn't blocked", () => {
146
+ const result = resolveBlockedVsTrust(
147
+ { trust: SenderTrust.Wellknown, setAt: 1_000 },
148
+ { blocked: false },
149
+ );
150
+ assert.deepEqual(result, {
151
+ senderTrust: SenderTrust.Wellknown,
152
+ senderBlocked: false,
153
+ });
154
+ });
155
+ });
156
+
157
+ describe("existing DKIM/DMARC paths (unaffected by senderBlocked=false)", () => {
158
+ it("rescues a trusted sender's mail from junk on provider-spam + dmarc-pass", () => {
159
+ const verdict = classifyPlacement(
160
+ baseMessage({ providerSpam: { classified: true } }),
161
+ "junk",
162
+ SenderTrust.Vip,
163
+ false,
164
+ );
165
+ assert.equal(verdict.action, "move-to-inbox");
166
+ assert.equal(verdict.confidence, "confident");
167
+ });
168
+
169
+ it("does not rescue an untrusted sender's mail from junk", () => {
170
+ const verdict = classifyPlacement(
171
+ baseMessage({ providerSpam: { classified: true } }),
172
+ "junk",
173
+ SenderTrust.Unknown,
174
+ false,
175
+ );
176
+ assert.equal(verdict.action, "leave");
177
+ assert.equal(verdict.confidence, "unsure");
178
+ });
179
+
180
+ it("demotes an untrusted sender's inbox mail on dkim-mismatch + dmarc-fail", () => {
181
+ const verdict = classifyPlacement(
182
+ baseMessage({
183
+ authResult: { dmarc: "Fail" },
184
+ authenticity: { fromDomain: "example.com", dkimMismatch: true },
185
+ }),
186
+ "inbox",
187
+ SenderTrust.Unknown,
188
+ false,
189
+ );
190
+ assert.deepEqual(verdict, {
191
+ action: "move-to-junk",
192
+ confidence: "confident",
193
+ reasons: ["dkim-mismatch", "dmarc=fail", "sender=untrusted"],
194
+ });
195
+ });
196
+
197
+ it("defers a dkim-mismatch + dmarc-pass message to a later LLM tier", () => {
198
+ const verdict = classifyPlacement(
199
+ baseMessage({
200
+ authResult: { dmarc: "Pass" },
201
+ authenticity: { fromDomain: "example.com", dkimMismatch: true },
202
+ }),
203
+ "inbox",
204
+ SenderTrust.Unknown,
205
+ false,
206
+ );
207
+ assert.deepEqual(verdict, {
208
+ action: "leave",
209
+ confidence: "unsure",
210
+ reasons: ["dkim-mismatch", "dmarc=pass", "deferred-to-llm"],
211
+ });
212
+ });
213
+ });
214
+ });
@@ -16,6 +16,50 @@ export interface PlacementVerdict {
16
16
  const isTrusted = (senderTrust: SenderTrustValue): boolean =>
17
17
  senderTrust === SenderTrust.Vip || senderTrust === SenderTrust.Wellknown;
18
18
 
19
+ /** The trust signal plus the `setAt` of whichever flag (`vip`/`wellknown`) produced it, `undefined` when the sender is `Unknown`. */
20
+ export interface SenderTrustSignal {
21
+ trust: SenderTrustValue;
22
+ setAt?: number;
23
+ }
24
+
25
+ /** The sender's `blocked` flag value plus its `setAt`. */
26
+ export interface SenderBlockedSignal {
27
+ blocked: boolean;
28
+ setAt?: number;
29
+ }
30
+
31
+ /**
32
+ * RFC 039 Decision 3a: when a sender's `blocked` flag and their `vip`/
33
+ * `wellknown` flag disagree on placement, the one set most recently wins.
34
+ * Scoped to {@link classifyPlacement}'s own verdict only — it never touches
35
+ * `deriveSenderTrust` or the trust badge, which stay a plain `vip → wellknown
36
+ * → unknown` read with no `blocked` case.
37
+ *
38
+ * A same-second tie (both `setAt` floor to the same second) breaks in a
39
+ * fixed, arbitrary order: `blocked` before `vip` before `wellknown` — a
40
+ * determinism backstop, not a meaningful signal.
41
+ */
42
+ export const resolveBlockedVsTrust = (
43
+ trust: SenderTrustSignal,
44
+ blocked: SenderBlockedSignal,
45
+ ): { senderTrust: SenderTrustValue; senderBlocked: boolean } => {
46
+ if (!blocked.blocked) {
47
+ return { senderTrust: trust.trust, senderBlocked: false };
48
+ }
49
+ if (trust.trust === SenderTrust.Unknown || trust.setAt === undefined) {
50
+ return { senderTrust: SenderTrust.Unknown, senderBlocked: true };
51
+ }
52
+
53
+ const blockedSecond = Math.floor((blocked.setAt ?? 0) / 1000);
54
+ const trustSecond = Math.floor(trust.setAt / 1000);
55
+
56
+ // Newer (or a same-second tie, which `blocked` wins) — blocked wins.
57
+ if (blockedSecond >= trustSecond) {
58
+ return { senderTrust: SenderTrust.Unknown, senderBlocked: true };
59
+ }
60
+ return { senderTrust: trust.trust, senderBlocked: false };
61
+ };
62
+
19
63
  /**
20
64
  * Tier 0 deterministic placement verdict (RFC 031, "Confident moves").
21
65
  *
@@ -24,11 +68,18 @@ const isTrusted = (senderTrust: SenderTrustValue): boolean =>
24
68
  * It generalizes `shouldRescueFromJunk` into a two-directional verdict and is
25
69
  * recall-biased — a confident move only fires when cheap, deterministic signals
26
70
  * agree; everything else is left in place for a later LLM tier.
71
+ *
72
+ * `senderBlocked` (RFC 039 Decision 3) is a confident demote independent of
73
+ * every DKIM/DMARC/provider signal below — a user's explicit block is not a
74
+ * heuristic. Already-tie-broken against `vip`/`wellknown` by
75
+ * {@link resolveBlockedVsTrust} in the caller; this function itself does not
76
+ * compare `setAt`.
27
77
  */
28
78
  export const classifyPlacement = (
29
79
  message: MessageItem,
30
80
  placement: FolderPlacement,
31
81
  senderTrust: SenderTrustValue,
82
+ senderBlocked: boolean,
32
83
  ): PlacementVerdict => {
33
84
  if (message.movedByRemit === true) {
34
85
  return {
@@ -38,6 +89,14 @@ export const classifyPlacement = (
38
89
  };
39
90
  }
40
91
 
92
+ if (senderBlocked && placement !== "junk") {
93
+ return {
94
+ action: "move-to-junk",
95
+ confidence: "confident",
96
+ reasons: ["sender=blocked"],
97
+ };
98
+ }
99
+
41
100
  if (!message.providerSpam || !message.authResult) {
42
101
  return {
43
102
  action: "leave",