@remit/mailbox-service 0.0.31 → 0.0.33
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
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue #302 (RFC 039 Decision 3): `Address.flags.unsubscribed` is
|
|
3
|
+
* documented as "auto-mark-read until sender stops" and settable from
|
|
4
|
+
* `IntelligencePane.tsx`, but nothing consumed it — every message from an
|
|
5
|
+
* unsubscribed sender still arrived unread like any other. These tests drive
|
|
6
|
+
* `BodySyncService` end-to-end (read-path body materialization →
|
|
7
|
+
* `applyPostStoreSteps`) and assert on the actual `FlagQueueService.markAsRead`
|
|
8
|
+
* call, so a regression that drops the flag read — not just a helper in
|
|
9
|
+
* isolation — shows up here.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import { describe, it } from "node:test";
|
|
14
|
+
import type {
|
|
15
|
+
AddressItem,
|
|
16
|
+
IAddressRepository,
|
|
17
|
+
IEnvelopeRepository,
|
|
18
|
+
IMessageRepository,
|
|
19
|
+
IThreadMessageRepository,
|
|
20
|
+
} from "@remit/data-ports";
|
|
21
|
+
import type { StorageService } from "@remit/storage-service";
|
|
22
|
+
import { BodySyncService } from "./body-sync.js";
|
|
23
|
+
import type { FlagQueueService } from "./flag-queue.js";
|
|
24
|
+
import type { IImapConnection } from "./types.js";
|
|
25
|
+
|
|
26
|
+
const PLAIN_EML = (fromEmail: string) =>
|
|
27
|
+
Buffer.from(
|
|
28
|
+
[
|
|
29
|
+
`From: Sender <${fromEmail}>`,
|
|
30
|
+
"To: me@example.com",
|
|
31
|
+
"Subject: Hello",
|
|
32
|
+
"Content-Type: text/plain",
|
|
33
|
+
"",
|
|
34
|
+
"body",
|
|
35
|
+
].join("\r\n"),
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
interface MarkReadCall {
|
|
39
|
+
accountConfigId: string;
|
|
40
|
+
messageId: string;
|
|
41
|
+
accountId: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface Harness {
|
|
45
|
+
service: BodySyncService;
|
|
46
|
+
markReadCalls: MarkReadCall[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const buildHarness = (
|
|
50
|
+
flags: AddressItem["flags"],
|
|
51
|
+
withUnsubscribeConfig = true,
|
|
52
|
+
): Harness => {
|
|
53
|
+
const markReadCalls: MarkReadCall[] = [];
|
|
54
|
+
|
|
55
|
+
const messageService = {
|
|
56
|
+
get: async () => ({
|
|
57
|
+
messageId: "m-1",
|
|
58
|
+
mailboxId: "mb-inbox",
|
|
59
|
+
uid: 1,
|
|
60
|
+
}),
|
|
61
|
+
update: async () => {},
|
|
62
|
+
} as unknown as IMessageRepository;
|
|
63
|
+
|
|
64
|
+
const threadMessageService = {
|
|
65
|
+
findAllByMessageId: async () => [
|
|
66
|
+
{
|
|
67
|
+
threadMessageId: "tm-1",
|
|
68
|
+
sentDate: 1,
|
|
69
|
+
mailboxId: "mb-inbox",
|
|
70
|
+
isRead: false,
|
|
71
|
+
isDeleted: false,
|
|
72
|
+
hasStars: false,
|
|
73
|
+
hasAttachment: false,
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
update: async () => {},
|
|
77
|
+
} as unknown as IThreadMessageRepository;
|
|
78
|
+
|
|
79
|
+
const storageService = {
|
|
80
|
+
storeMessageBody: async () => ({ uri: "s3://bodies/m-1" }),
|
|
81
|
+
storeParsedBody: async () => {},
|
|
82
|
+
listBodyParts: async () => [],
|
|
83
|
+
} as unknown as StorageService;
|
|
84
|
+
|
|
85
|
+
const addressService = {
|
|
86
|
+
getAddress: async () => ({ flags }) as unknown as AddressItem,
|
|
87
|
+
incrementInboundCount: async () => {},
|
|
88
|
+
} as unknown as IAddressRepository;
|
|
89
|
+
|
|
90
|
+
const envelopeService = {
|
|
91
|
+
listBodyParts: async () => [],
|
|
92
|
+
} as unknown as IEnvelopeRepository;
|
|
93
|
+
|
|
94
|
+
const flagQueueService = {
|
|
95
|
+
markAsRead: async (
|
|
96
|
+
accountConfigId: string,
|
|
97
|
+
messageId: string,
|
|
98
|
+
accountId: string,
|
|
99
|
+
) => {
|
|
100
|
+
markReadCalls.push({ accountConfigId, messageId, accountId });
|
|
101
|
+
},
|
|
102
|
+
} as unknown as FlagQueueService;
|
|
103
|
+
|
|
104
|
+
const service = new BodySyncService(
|
|
105
|
+
messageService,
|
|
106
|
+
storageService,
|
|
107
|
+
threadMessageService,
|
|
108
|
+
addressService,
|
|
109
|
+
envelopeService,
|
|
110
|
+
{ info: () => {}, error: () => {} },
|
|
111
|
+
undefined,
|
|
112
|
+
undefined,
|
|
113
|
+
undefined,
|
|
114
|
+
withUnsubscribeConfig ? { flagQueueService } : undefined,
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
return { service, markReadCalls };
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const readBody = async (
|
|
121
|
+
service: BodySyncService,
|
|
122
|
+
fromEmail = "someone@example.com",
|
|
123
|
+
) => {
|
|
124
|
+
const connection = {
|
|
125
|
+
openBox: async () => {},
|
|
126
|
+
fetchMessageBody: async () => PLAIN_EML(fromEmail),
|
|
127
|
+
} as unknown as IImapConnection;
|
|
128
|
+
return service.fetchAndGetBody(
|
|
129
|
+
"m-1",
|
|
130
|
+
"acc-1",
|
|
131
|
+
"cfg-1",
|
|
132
|
+
"INBOX",
|
|
133
|
+
async () => connection,
|
|
134
|
+
);
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
describe("Address.flags.unsubscribed drives auto-mark-read (issue #302)", () => {
|
|
138
|
+
it("marks a message from an unsubscribed sender as read at sync time", async () => {
|
|
139
|
+
const harness = buildHarness({ unsubscribed: { value: true, setAt: 1 } });
|
|
140
|
+
|
|
141
|
+
await readBody(harness.service);
|
|
142
|
+
|
|
143
|
+
assert.deepEqual(harness.markReadCalls, [
|
|
144
|
+
{ accountConfigId: "cfg-1", messageId: "m-1", accountId: "acc-1" },
|
|
145
|
+
]);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("leaves read state alone for a sender without the flag", async () => {
|
|
149
|
+
const harness = buildHarness({});
|
|
150
|
+
|
|
151
|
+
await readBody(harness.service);
|
|
152
|
+
|
|
153
|
+
assert.deepEqual(harness.markReadCalls, []);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("leaves read state alone once the flag is unset (no caching of the decision)", async () => {
|
|
157
|
+
const harness = buildHarness({
|
|
158
|
+
unsubscribed: { value: false, setAt: 1 },
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
await readBody(harness.service);
|
|
162
|
+
|
|
163
|
+
assert.deepEqual(harness.markReadCalls, []);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("is a no-op when body sync was built without an UnsubscribeConfig", async () => {
|
|
167
|
+
const harness = buildHarness(
|
|
168
|
+
{ unsubscribed: { value: true, setAt: 1 } },
|
|
169
|
+
false,
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
await readBody(harness.service);
|
|
173
|
+
|
|
174
|
+
assert.deepEqual(harness.markReadCalls, []);
|
|
175
|
+
});
|
|
176
|
+
});
|
package/src/body-sync.ts
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
type FilterDecision,
|
|
38
38
|
FilterPipeline,
|
|
39
39
|
} from "./filters/pipeline.js";
|
|
40
|
+
import type { FlagQueueService } from "./flag-queue.js";
|
|
40
41
|
import {
|
|
41
42
|
classifyByHeaders,
|
|
42
43
|
extractAuthenticity,
|
|
@@ -47,6 +48,7 @@ import {
|
|
|
47
48
|
import {
|
|
48
49
|
classifyPlacement,
|
|
49
50
|
type FolderPlacement,
|
|
51
|
+
resolveBlockedVsTrust,
|
|
50
52
|
} from "./heuristics/classifyPlacement.js";
|
|
51
53
|
import type { PlacementMoveService } from "./placement-move.js";
|
|
52
54
|
import { type QuarantineService, shapeFromMessageData } from "./quarantine.js";
|
|
@@ -256,6 +258,17 @@ export interface QuarantineConfig {
|
|
|
256
258
|
attempts: number;
|
|
257
259
|
}
|
|
258
260
|
|
|
261
|
+
/**
|
|
262
|
+
* What body sync needs to auto-mark-read a message from an unsubscribed
|
|
263
|
+
* sender (issue #302, RFC 039 Decision 3). Reuses the same
|
|
264
|
+
* `FlagQueueService.markAsRead` a manual mark-as-read already goes through —
|
|
265
|
+
* local `\Seen` + `ThreadMessage.isRead` + a durable pending IMAP flag-push
|
|
266
|
+
* marker — so this fires the same round-trip, not a second primitive.
|
|
267
|
+
*/
|
|
268
|
+
export interface UnsubscribeConfig {
|
|
269
|
+
flagQueueService: FlagQueueService;
|
|
270
|
+
}
|
|
271
|
+
|
|
259
272
|
export class BodySyncService {
|
|
260
273
|
private log: BodySyncLogger;
|
|
261
274
|
private readonly filterPipeline?: FilterPipeline;
|
|
@@ -270,6 +283,7 @@ export class BodySyncService {
|
|
|
270
283
|
private readonly placementConfig?: PlacementConfig,
|
|
271
284
|
private readonly filterConfig?: FilterConfig,
|
|
272
285
|
private readonly quarantineConfig?: QuarantineConfig,
|
|
286
|
+
private readonly unsubscribeConfig?: UnsubscribeConfig,
|
|
273
287
|
) {
|
|
274
288
|
this.log = logger ?? noopLogger;
|
|
275
289
|
this.filterPipeline = filterConfig
|
|
@@ -812,6 +826,18 @@ export class BodySyncService {
|
|
|
812
826
|
});
|
|
813
827
|
}
|
|
814
828
|
|
|
829
|
+
// `flags.unsubscribed` (issue #302, RFC 039 Decision 3): auto-mark-read,
|
|
830
|
+
// reusing the same FlagQueueService.markAsRead round-trip a manual
|
|
831
|
+
// mark-as-read goes through — idempotent on a retry (flipFlag no-ops when
|
|
832
|
+
// the message is already \Seen), so a failure here safely re-fires on the
|
|
833
|
+
// next attempt rather than being lost behind the bodyStorageKey skip guard.
|
|
834
|
+
await this.applyUnsubscribedAutoRead(
|
|
835
|
+
messageId,
|
|
836
|
+
accountId,
|
|
837
|
+
accountConfigId,
|
|
838
|
+
parsed,
|
|
839
|
+
);
|
|
840
|
+
|
|
815
841
|
const moved = Boolean(resolved.move || filterMoved);
|
|
816
842
|
|
|
817
843
|
// ONE Message UpdateItem per synced message: bodyStorageKey + every
|
|
@@ -1071,26 +1097,121 @@ export class BodySyncService {
|
|
|
1071
1097
|
);
|
|
1072
1098
|
}
|
|
1073
1099
|
|
|
1074
|
-
|
|
1100
|
+
/**
|
|
1101
|
+
* The per-sender signals {@link computePlacement} needs, from ONE `Address`
|
|
1102
|
+
* fetch (RFC 039 Decision 3/3a, issue #300): the trust reads exactly as
|
|
1103
|
+
* `deriveSenderTrust` always did — `vip → wellknown → unknown`, untouched by
|
|
1104
|
+
* `blocked` — plus `blocked`/`autoArchive` off the same row. `trustSetAt` is
|
|
1105
|
+
* the `setAt` of whichever flag produced the trust value, needed by
|
|
1106
|
+
* {@link resolveBlockedVsTrust}'s tie-break; it stays local to placement and
|
|
1107
|
+
* never reaches `deriveSenderTrust`'s own contract (the trust badge).
|
|
1108
|
+
*/
|
|
1109
|
+
private async deriveSenderPlacementSignals(
|
|
1075
1110
|
accountConfigId: string,
|
|
1076
1111
|
fromEmail: string,
|
|
1077
|
-
): Promise<
|
|
1112
|
+
): Promise<{
|
|
1113
|
+
trust: (typeof SenderTrust)[keyof typeof SenderTrust];
|
|
1114
|
+
trustSetAt?: number;
|
|
1115
|
+
blocked: boolean;
|
|
1116
|
+
blockedSetAt?: number;
|
|
1117
|
+
autoArchive: boolean;
|
|
1118
|
+
}> {
|
|
1119
|
+
const unknown = {
|
|
1120
|
+
trust: SenderTrust.Unknown,
|
|
1121
|
+
blocked: false,
|
|
1122
|
+
autoArchive: false,
|
|
1123
|
+
} as const;
|
|
1078
1124
|
try {
|
|
1079
1125
|
const addressId = deriveAddressId(accountConfigId, fromEmail);
|
|
1080
1126
|
const address = await this.addressService.getAddress(
|
|
1081
1127
|
accountConfigId,
|
|
1082
1128
|
addressId,
|
|
1083
1129
|
);
|
|
1084
|
-
|
|
1085
|
-
if (
|
|
1086
|
-
return
|
|
1130
|
+
const flags = address.flags;
|
|
1131
|
+
if (flags?.vip?.value === true) {
|
|
1132
|
+
return {
|
|
1133
|
+
trust: SenderTrust.Vip,
|
|
1134
|
+
trustSetAt: flags.vip.setAt,
|
|
1135
|
+
blocked: flags.blocked?.value === true,
|
|
1136
|
+
blockedSetAt: flags.blocked?.setAt,
|
|
1137
|
+
autoArchive: flags.autoArchive?.value === true,
|
|
1138
|
+
};
|
|
1139
|
+
}
|
|
1140
|
+
if (flags?.wellknown?.value === true) {
|
|
1141
|
+
return {
|
|
1142
|
+
trust: SenderTrust.Wellknown,
|
|
1143
|
+
trustSetAt: flags.wellknown.setAt,
|
|
1144
|
+
blocked: flags.blocked?.value === true,
|
|
1145
|
+
blockedSetAt: flags.blocked?.setAt,
|
|
1146
|
+
autoArchive: flags.autoArchive?.value === true,
|
|
1147
|
+
};
|
|
1148
|
+
}
|
|
1149
|
+
return {
|
|
1150
|
+
...unknown,
|
|
1151
|
+
blocked: flags?.blocked?.value === true,
|
|
1152
|
+
blockedSetAt: flags?.blocked?.setAt,
|
|
1153
|
+
autoArchive: flags?.autoArchive?.value === true,
|
|
1154
|
+
};
|
|
1087
1155
|
} catch (err) {
|
|
1088
|
-
// A genuinely-absent address means "
|
|
1089
|
-
// (AccessDenied, throttle, infra) must NOT be silently downgraded
|
|
1090
|
-
//
|
|
1156
|
+
// A genuinely-absent address means "no signals". Any other failure
|
|
1157
|
+
// (AccessDenied, throttle, infra) must NOT be silently downgraded — let
|
|
1158
|
+
// it crash so the placement decision isn't made on bad data.
|
|
1091
1159
|
if (!(err instanceof NotFoundError)) throw err;
|
|
1092
1160
|
}
|
|
1093
|
-
return
|
|
1161
|
+
return unknown;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
/**
|
|
1165
|
+
* `flags.unsubscribed` (issue #302, RFC 039 Decision 3): "auto-mark-read
|
|
1166
|
+
* until sender stops" — fires on every new message from that sender for as
|
|
1167
|
+
* long as the flag stays set, per the flag's own doc comment; there is no
|
|
1168
|
+
* separate expiry mechanism, and no caching of the decision beyond this
|
|
1169
|
+
* per-message `Address` read. A no-op when body sync was built without an
|
|
1170
|
+
* {@link UnsubscribeConfig} or the message carries no `From` address.
|
|
1171
|
+
*/
|
|
1172
|
+
private async applyUnsubscribedAutoRead(
|
|
1173
|
+
messageId: string,
|
|
1174
|
+
accountId: string,
|
|
1175
|
+
accountConfigId: string,
|
|
1176
|
+
parsed: ParsedMail,
|
|
1177
|
+
): Promise<void> {
|
|
1178
|
+
if (!this.unsubscribeConfig) return;
|
|
1179
|
+
|
|
1180
|
+
const fromEmail = extractPrimaryFromEmail(parsed);
|
|
1181
|
+
if (!fromEmail) return;
|
|
1182
|
+
|
|
1183
|
+
const unsubscribed = await this.deriveSenderUnsubscribed(
|
|
1184
|
+
accountConfigId,
|
|
1185
|
+
fromEmail,
|
|
1186
|
+
);
|
|
1187
|
+
if (!unsubscribed) return;
|
|
1188
|
+
|
|
1189
|
+
await this.unsubscribeConfig.flagQueueService.markAsRead(
|
|
1190
|
+
accountConfigId,
|
|
1191
|
+
messageId,
|
|
1192
|
+
accountId,
|
|
1193
|
+
);
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
private async deriveSenderUnsubscribed(
|
|
1197
|
+
accountConfigId: string,
|
|
1198
|
+
fromEmail: string,
|
|
1199
|
+
): Promise<boolean> {
|
|
1200
|
+
try {
|
|
1201
|
+
const addressId = deriveAddressId(accountConfigId, fromEmail);
|
|
1202
|
+
const address = await this.addressService.getAddress(
|
|
1203
|
+
accountConfigId,
|
|
1204
|
+
addressId,
|
|
1205
|
+
);
|
|
1206
|
+
return address.flags?.unsubscribed?.value === true;
|
|
1207
|
+
} catch (err) {
|
|
1208
|
+
// A genuinely-absent address means "not unsubscribed". Any other
|
|
1209
|
+
// failure (AccessDenied, throttle, infra) must NOT be silently
|
|
1210
|
+
// downgraded — let it crash so the read-state decision isn't made on
|
|
1211
|
+
// bad data.
|
|
1212
|
+
if (!(err instanceof NotFoundError)) throw err;
|
|
1213
|
+
return false;
|
|
1214
|
+
}
|
|
1094
1215
|
}
|
|
1095
1216
|
|
|
1096
1217
|
/**
|
|
@@ -1199,19 +1320,42 @@ export class BodySyncService {
|
|
|
1199
1320
|
: "other";
|
|
1200
1321
|
|
|
1201
1322
|
const fromEmail = extractPrimaryFromEmail(parsed);
|
|
1202
|
-
const
|
|
1203
|
-
? await this.
|
|
1204
|
-
:
|
|
1323
|
+
const signals = fromEmail
|
|
1324
|
+
? await this.deriveSenderPlacementSignals(accountConfigId, fromEmail)
|
|
1325
|
+
: {
|
|
1326
|
+
trust: SenderTrust.Unknown,
|
|
1327
|
+
blocked: false,
|
|
1328
|
+
autoArchive: false,
|
|
1329
|
+
};
|
|
1330
|
+
|
|
1331
|
+
const { senderTrust, senderBlocked } = resolveBlockedVsTrust(
|
|
1332
|
+
{ trust: signals.trust, setAt: signals.trustSetAt },
|
|
1333
|
+
{ blocked: signals.blocked, setAt: signals.blockedSetAt },
|
|
1334
|
+
);
|
|
1205
1335
|
|
|
1206
1336
|
// The verdict needs the classification signals (providerSpam,
|
|
1207
1337
|
// authResult, authenticity) that this body-sync pass just derived; the
|
|
1208
1338
|
// stored row does not carry them yet, so overlay them onto the message.
|
|
1209
1339
|
const candidate = { ...message, ...classification };
|
|
1210
|
-
const verdict = classifyPlacement(
|
|
1340
|
+
const verdict = classifyPlacement(
|
|
1341
|
+
candidate,
|
|
1342
|
+
placement,
|
|
1343
|
+
senderTrust,
|
|
1344
|
+
senderBlocked,
|
|
1345
|
+
);
|
|
1211
1346
|
|
|
1212
|
-
// A `leave` verdict
|
|
1347
|
+
// A `leave` verdict — including "nothing confident to say" — carries no
|
|
1348
|
+
// audit record of its own. `flags.autoArchive` (issue #300) is a distinct,
|
|
1349
|
+
// lower-priority filing preference: it only files a message away when
|
|
1350
|
+
// `blocked`/DKIM/DMARC had nothing to say, never overriding a confident
|
|
1351
|
+
// junk/inbox verdict computed above.
|
|
1213
1352
|
if (verdict.action === "leave") {
|
|
1214
|
-
return
|
|
1353
|
+
return this.resolveAutoArchive(
|
|
1354
|
+
mailboxSpecialUseService,
|
|
1355
|
+
message,
|
|
1356
|
+
accountId,
|
|
1357
|
+
signals.autoArchive,
|
|
1358
|
+
);
|
|
1215
1359
|
}
|
|
1216
1360
|
|
|
1217
1361
|
// Audit verdict — recorded for every actionable verdict (both
|
|
@@ -1266,6 +1410,55 @@ export class BodySyncService {
|
|
|
1266
1410
|
};
|
|
1267
1411
|
}
|
|
1268
1412
|
|
|
1413
|
+
/**
|
|
1414
|
+
* `flags.autoArchive` (issue #300, RFC 039 Decision 3): file a message
|
|
1415
|
+
* straight to Archive, skipping Inbox. Only reached from
|
|
1416
|
+
* {@link computePlacement} when {@link classifyPlacement} had no confident
|
|
1417
|
+
* junk/inbox verdict of its own — `blocked`/DKIM/DMARC always take priority
|
|
1418
|
+
* over this filing preference.
|
|
1419
|
+
*
|
|
1420
|
+
* No {@link MessagePlacementVerdict} is recorded: `PlacementAction` (the
|
|
1421
|
+
* audit enum) has only `MoveToInbox`/`MoveToJunk` — issue #300 is scoped to
|
|
1422
|
+
* no TypeSpec change, so an archive move carries no audit verdict, same as
|
|
1423
|
+
* a matched filter's move. The move itself reuses the same
|
|
1424
|
+
* `placementMoveService.moveMessage` path as every other confident move.
|
|
1425
|
+
*
|
|
1426
|
+
* Idempotent the same way {@link classifyPlacement}'s own branches are: a
|
|
1427
|
+
* message already sitting in Archive is left alone, not moved again.
|
|
1428
|
+
*/
|
|
1429
|
+
private async resolveAutoArchive(
|
|
1430
|
+
mailboxSpecialUseService: IMailboxSpecialUseRepository,
|
|
1431
|
+
message: MessageItem,
|
|
1432
|
+
accountId: string,
|
|
1433
|
+
autoArchive: boolean,
|
|
1434
|
+
): Promise<PlacementOutcome> {
|
|
1435
|
+
if (!autoArchive) return {};
|
|
1436
|
+
|
|
1437
|
+
const archiveMailbox = await mailboxSpecialUseService.findBySpecialUse(
|
|
1438
|
+
accountId,
|
|
1439
|
+
MailboxSpecialUse.Archive,
|
|
1440
|
+
);
|
|
1441
|
+
if (!archiveMailbox || message.mailboxId === archiveMailbox.mailboxId) {
|
|
1442
|
+
return {};
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
this.log.info(
|
|
1446
|
+
{
|
|
1447
|
+
messageId: message.messageId,
|
|
1448
|
+
accountId,
|
|
1449
|
+
destinationMailboxId: archiveMailbox.mailboxId,
|
|
1450
|
+
},
|
|
1451
|
+
"Auto-archive verdict",
|
|
1452
|
+
);
|
|
1453
|
+
|
|
1454
|
+
return {
|
|
1455
|
+
move: {
|
|
1456
|
+
destinationMailboxId: archiveMailbox.mailboxId,
|
|
1457
|
+
destinationPath: archiveMailbox.fullPath,
|
|
1458
|
+
},
|
|
1459
|
+
};
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1269
1462
|
// The old `enqueuePlacementMove` (best-effort, catch-and-log) lived here.
|
|
1270
1463
|
// Issue #1271: it ran AFTER `bodyStorageKey` was already durable, so a
|
|
1271
1464
|
// 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",
|