@remit/mailbox-service 0.0.94 → 0.0.96
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/body-sync.ts +11 -4
- package/src/external-move.test.ts +369 -0
- package/src/external-move.ts +272 -0
- package/src/heuristics/classifyPlacement.test.ts +183 -31
- package/src/heuristics/classifyPlacement.ts +64 -8
- package/src/message-sync-changedsince.test.ts +8 -1
- package/src/message-sync-quarantine.test.ts +8 -1
- package/src/message-sync-repoint.test.ts +128 -103
- package/src/message-sync.ts +209 -77
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
IMailboxRepository,
|
|
3
|
+
IMessageRepository,
|
|
4
|
+
MailboxItem,
|
|
5
|
+
MessageItem,
|
|
6
|
+
} from "@remit/data-ports";
|
|
7
|
+
import { isVirtualCopyMailbox } from "@remit/data-ports/virtual-copy";
|
|
8
|
+
import { MessageStatus } from "@remit/domain-enums";
|
|
9
|
+
import type { IImapConnection } from "./types.js";
|
|
10
|
+
|
|
11
|
+
export interface ExternalMoveLogger {
|
|
12
|
+
info(obj: Record<string, unknown>, msg: string): void;
|
|
13
|
+
warn(obj: Record<string, unknown>, msg: string): void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Whether a sighting of an already-stored message in this mailbox CONTESTS the
|
|
18
|
+
* placement the row holds (#859). Contesting is not deciding: the sighting is a
|
|
19
|
+
* candidate move, and {@link confirmDepartures} is what turns it into one.
|
|
20
|
+
*
|
|
21
|
+
* Three sightings do not even contest. The same mailbox is the row standing
|
|
22
|
+
* where it already is. Gmail's All Mail, Starred and Important hold every
|
|
23
|
+
* message that also lives in a real folder, so they are views rather than filing
|
|
24
|
+
* locations; following one would empty every other folder's listing. And a row
|
|
25
|
+
* whose own mutation has not settled is in flight, not stale
|
|
26
|
+
* (imap-mutations R1): the server's answer predates the move reader is still
|
|
27
|
+
* waiting on, so taking it would undo the optimistic write. The sync path
|
|
28
|
+
* reconciles rather than waits (imap-mutations R2): it never blocks on a
|
|
29
|
+
* mutation, it re-reads the row on the next sighting after the move settles.
|
|
30
|
+
*
|
|
31
|
+
* In flight is `status`, and `status` alone (#1096, imap-mutations R3). Every
|
|
32
|
+
* outbound mutation writes `moving` or `deleting` and only settling returns the
|
|
33
|
+
* row to `active`, so `active` is precisely the set nothing is coming for —
|
|
34
|
+
* whatever `syncStatus` says about how the row got there. A row put back by
|
|
35
|
+
* `abandonDelete` carries `abandoned`, and it needs this repair more than any
|
|
36
|
+
* other row does: nothing routine settles it, and reader shares its mailboxes,
|
|
37
|
+
* so refusing it left a message whose delete was refused unable to follow a
|
|
38
|
+
* move the user then made in another client.
|
|
39
|
+
*/
|
|
40
|
+
export const sightingContestsPlacement = (
|
|
41
|
+
mailbox: MailboxItem,
|
|
42
|
+
message: Pick<MessageItem, "mailboxId" | "status">,
|
|
43
|
+
): boolean => {
|
|
44
|
+
if (message.mailboxId === mailbox.mailboxId) return false;
|
|
45
|
+
if (isVirtualCopyMailbox(mailbox)) return false;
|
|
46
|
+
return message.status === MessageStatus.active;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The exact placement a verdict was reached for. The probe reads the row outside
|
|
51
|
+
* the transaction that acts on it, so the verdict names the mailbox and uid it
|
|
52
|
+
* was reached for; a row that moved in between matches no verdict and is left
|
|
53
|
+
* unsettled.
|
|
54
|
+
*/
|
|
55
|
+
export const placementKey = (
|
|
56
|
+
messageId: string,
|
|
57
|
+
mailboxId: string,
|
|
58
|
+
uid: number,
|
|
59
|
+
): string => `${messageId}:${mailboxId}:${uid}`;
|
|
60
|
+
|
|
61
|
+
export interface DepartureVerdicts {
|
|
62
|
+
/**
|
|
63
|
+
* Placements the source folder no longer holds. A sighting elsewhere of one
|
|
64
|
+
* of these is a move, and re-points the row.
|
|
65
|
+
*/
|
|
66
|
+
departed: Set<string>;
|
|
67
|
+
/**
|
|
68
|
+
* Placements a verdict was reached for at all, departed or still held. A
|
|
69
|
+
* contested sighting outside this set was not decided — the source could not
|
|
70
|
+
* be asked, or the row has moved since it was — and its uid is held back from
|
|
71
|
+
* the watermark so the next round serves the sighting again. Without that a
|
|
72
|
+
* single unanswerable round would leave the row mispointed until a cursor
|
|
73
|
+
* rebuild, which is the silent never-repair this whole change is about.
|
|
74
|
+
*/
|
|
75
|
+
settled: Set<string>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface DepartureProbeDeps {
|
|
79
|
+
connection: Pick<IImapConnection, "openBox" | "search">;
|
|
80
|
+
mailboxService: Pick<IMailboxRepository, "get">;
|
|
81
|
+
messageService: Pick<IMessageRepository, "get">;
|
|
82
|
+
log: ExternalMoveLogger;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Which of this batch's messages have really left the folder their row points at
|
|
87
|
+
* (#1146).
|
|
88
|
+
*
|
|
89
|
+
* A message sighted in a second real folder has not necessarily moved. Living in
|
|
90
|
+
* two folders at once is ordinary mail: a Gmail user label, a Sieve `fileinto`
|
|
91
|
+
* beside a `keep`, a Sent copy a mailing list echoes back. Reading every such
|
|
92
|
+
* sighting as a move takes the message out of the folder it is still in, and
|
|
93
|
+
* whichever folder the sync round enumerates last wins — which is how a labelled
|
|
94
|
+
* message disappeared from the Inbox on the next sync.
|
|
95
|
+
*
|
|
96
|
+
* What separates a move from a copy is the source folder: a move empties it, a
|
|
97
|
+
* copy leaves it alone. So the source is asked, and only a source that no longer
|
|
98
|
+
* holds the uid the row claims makes the sighting a move. A move another client
|
|
99
|
+
* made is still followed — that is the case where the source really is empty —
|
|
100
|
+
* and reader stops fighting a second client over mail neither of them moved.
|
|
101
|
+
*
|
|
102
|
+
* The question is asked with a UID SEARCH rather than a FETCH, for the reason
|
|
103
|
+
* `isMessageGoneFromOpenMailbox` documents: imapflow drops rows on back-to-back
|
|
104
|
+
* FETCHes (#408), and absence read off a FETCH would file live mail as departed.
|
|
105
|
+
* `IImapConnection.search` throws on a failed SEARCH, so an empty answer means
|
|
106
|
+
* the server matched nothing. The SEARCH names the contested uids rather than
|
|
107
|
+
* ALL: the answer needed is about those uids, and a folder's whole uid set is a
|
|
108
|
+
* page of traffic per batch for an account with a large Inbox.
|
|
109
|
+
*
|
|
110
|
+
* One SEARCH per source folder per batch, and no IMAP at all when nothing is
|
|
111
|
+
* contested — which is every round on an account whose folders do not overlap.
|
|
112
|
+
*/
|
|
113
|
+
export const confirmDepartures = async (
|
|
114
|
+
deps: DepartureProbeDeps,
|
|
115
|
+
accountId: string,
|
|
116
|
+
sightedIn: MailboxItem,
|
|
117
|
+
messageIds: string[],
|
|
118
|
+
): Promise<DepartureVerdicts> => {
|
|
119
|
+
const verdicts: DepartureVerdicts = {
|
|
120
|
+
departed: new Set<string>(),
|
|
121
|
+
settled: new Set<string>(),
|
|
122
|
+
};
|
|
123
|
+
if (messageIds.length === 0) return verdicts;
|
|
124
|
+
|
|
125
|
+
const stored = await deps.messageService.get(messageIds);
|
|
126
|
+
// A row carrying no uid names no placement the source can be asked about, so
|
|
127
|
+
// it reaches no verdict here and its sighting is served again next round. It
|
|
128
|
+
// is not reachable from a sync — `upsertWithStatus` writes the uid the FETCH
|
|
129
|
+
// returned — and a folder that somehow produces one stalls its cursor and
|
|
130
|
+
// raises the alert rather than repairing a row against nothing.
|
|
131
|
+
const contested = stored.filter(
|
|
132
|
+
(row) => row.uid > 0 && sightingContestsPlacement(sightedIn, row),
|
|
133
|
+
);
|
|
134
|
+
if (contested.length === 0) return verdicts;
|
|
135
|
+
|
|
136
|
+
const bySource = new Map<string, MessageItem[]>();
|
|
137
|
+
for (const row of contested) {
|
|
138
|
+
const group = bySource.get(row.mailboxId);
|
|
139
|
+
if (group === undefined) {
|
|
140
|
+
bySource.set(row.mailboxId, [row]);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
group.push(row);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
for (const [sourceMailboxId, rows] of bySource) {
|
|
147
|
+
await askSource(
|
|
148
|
+
deps,
|
|
149
|
+
accountId,
|
|
150
|
+
sightedIn,
|
|
151
|
+
sourceMailboxId,
|
|
152
|
+
rows,
|
|
153
|
+
verdicts,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return verdicts;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* What one source folder said about the rows pointed at it. Only `holds` decides
|
|
162
|
+
* anything; the other three are the folder declining to be evidence, each for
|
|
163
|
+
* its own reason and each leaving the sightings to come back next round.
|
|
164
|
+
*/
|
|
165
|
+
type SourceAnswer =
|
|
166
|
+
| { kind: "holds"; path: string; held: Set<number> }
|
|
167
|
+
| { kind: "copiesEverything"; path: string }
|
|
168
|
+
| { kind: "rekeyed"; path: string; stored: number; served: number }
|
|
169
|
+
| { kind: "unreachable"; reason: string };
|
|
170
|
+
|
|
171
|
+
const readSource = async (
|
|
172
|
+
deps: DepartureProbeDeps,
|
|
173
|
+
accountId: string,
|
|
174
|
+
sourceMailboxId: string,
|
|
175
|
+
rows: MessageItem[],
|
|
176
|
+
): Promise<SourceAnswer> => {
|
|
177
|
+
const source = await deps.mailboxService.get(accountId, sourceMailboxId);
|
|
178
|
+
|
|
179
|
+
// A row whose source is All Mail, Starred or Important is not filed there:
|
|
180
|
+
// those folders hold a copy of everything and never release anything, so
|
|
181
|
+
// asking them would answer "still here" forever and freeze the row on a view.
|
|
182
|
+
if (isVirtualCopyMailbox(source)) {
|
|
183
|
+
return { kind: "copiesEverything", path: source.fullPath };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const box = await deps.connection.openBox(source.fullPath, true);
|
|
187
|
+
|
|
188
|
+
// A source folder on a different UIDVALIDITY axis holds no uid these rows can
|
|
189
|
+
// be compared against, so its message set is evidence of nothing (RFC 9051
|
|
190
|
+
// 2.3.1.1). The cursor rebuild re-keys that folder; until it has, the
|
|
191
|
+
// sightings stay unsettled and come back next round.
|
|
192
|
+
if (box.uidvalidity !== source.uidValidity) {
|
|
193
|
+
return {
|
|
194
|
+
kind: "rekeyed",
|
|
195
|
+
path: source.fullPath,
|
|
196
|
+
stored: source.uidValidity,
|
|
197
|
+
served: box.uidvalidity,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const held = await deps.connection.search([
|
|
202
|
+
["UID", rows.map((row) => row.uid).join(",")],
|
|
203
|
+
]);
|
|
204
|
+
return { kind: "holds", path: source.fullPath, held: new Set(held) };
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const askSource = async (
|
|
208
|
+
deps: DepartureProbeDeps,
|
|
209
|
+
accountId: string,
|
|
210
|
+
sightedIn: MailboxItem,
|
|
211
|
+
sourceMailboxId: string,
|
|
212
|
+
rows: MessageItem[],
|
|
213
|
+
verdicts: DepartureVerdicts,
|
|
214
|
+
): Promise<void> => {
|
|
215
|
+
const settle = (row: MessageItem, departed: boolean): void => {
|
|
216
|
+
const key = placementKey(row.messageId, row.mailboxId, row.uid);
|
|
217
|
+
verdicts.settled.add(key);
|
|
218
|
+
if (departed) verdicts.departed.add(key);
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
// A source folder reader cannot reach decides nothing, and does not fail the
|
|
222
|
+
// round either. Another client renaming or deleting it between the row being
|
|
223
|
+
// written and now is ordinary — reader shares its mailboxes — and the sync of
|
|
224
|
+
// the unrelated folder that sighted these messages must survive it.
|
|
225
|
+
const answer = await readSource(
|
|
226
|
+
deps,
|
|
227
|
+
accountId,
|
|
228
|
+
sourceMailboxId,
|
|
229
|
+
rows,
|
|
230
|
+
).catch<SourceAnswer>((error: unknown) => ({
|
|
231
|
+
kind: "unreachable",
|
|
232
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
233
|
+
}));
|
|
234
|
+
|
|
235
|
+
const observed = {
|
|
236
|
+
sourceMailboxId,
|
|
237
|
+
sightedMailboxId: sightedIn.mailboxId,
|
|
238
|
+
contested: rows.length,
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
if (answer.kind === "unreachable") {
|
|
242
|
+
deps.log.warn(
|
|
243
|
+
{ ...observed, error: answer.reason },
|
|
244
|
+
"Could not ask the source folder whether it still holds these messages; leaving the sightings unsettled this round",
|
|
245
|
+
);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (answer.kind === "rekeyed") {
|
|
250
|
+
deps.log.warn(
|
|
251
|
+
{
|
|
252
|
+
...observed,
|
|
253
|
+
sourceMailboxPath: answer.path,
|
|
254
|
+
storedUidValidity: answer.stored,
|
|
255
|
+
servedUidValidity: answer.served,
|
|
256
|
+
},
|
|
257
|
+
"Source folder is on a different UIDVALIDITY axis; leaving these sightings unsettled this round",
|
|
258
|
+
);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (answer.kind === "copiesEverything") {
|
|
263
|
+
for (const row of rows) settle(row, true);
|
|
264
|
+
deps.log.info(
|
|
265
|
+
{ ...observed, sourceMailboxPath: answer.path },
|
|
266
|
+
"Row points at a folder that copies every message; the sighting in a real folder is where it lives",
|
|
267
|
+
);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
for (const row of rows) settle(row, !answer.held.has(row.uid));
|
|
272
|
+
};
|
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `classifyPlacement` had no dedicated unit test — only the DKIM/DMARC paths
|
|
3
3
|
* were exercised indirectly through realistic-mail fixtures elsewhere. Issue
|
|
4
|
-
* #300 (RFC 039 Decision 3/3a)
|
|
5
|
-
* independent of every DKIM/DMARC/provider signal, plus a
|
|
6
|
-
* against `vip`/`wellknown` (Decision 3a
|
|
7
|
-
*
|
|
8
|
-
* regression in
|
|
4
|
+
* #300 (RFC 039 Decision 3/3a) added the user's placement instruction as a
|
|
5
|
+
* confident move independent of every DKIM/DMARC/provider signal, plus a
|
|
6
|
+
* `setAt` tie-break against `vip`/`wellknown` (Decision 3a). Issue #605 adds
|
|
7
|
+
* the other direction, `neverSpam`. These tests cover both instruction
|
|
8
|
+
* branches and the pre-existing DKIM/DMARC branches, so a regression in any of
|
|
9
|
+
* them shows up here.
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
12
|
import assert from "node:assert/strict";
|
|
12
13
|
import { describe, it } from "node:test";
|
|
13
14
|
import type { MessageItem } from "@remit/data-ports";
|
|
14
|
-
import { SenderTrust } from "@remit/domain-enums";
|
|
15
|
+
import { SenderOverride, SenderTrust } from "@remit/domain-enums";
|
|
15
16
|
import {
|
|
16
17
|
classifyPlacement,
|
|
17
|
-
|
|
18
|
+
resolveSenderPlacement,
|
|
18
19
|
} from "./classifyPlacement.js";
|
|
19
20
|
|
|
20
21
|
const baseMessage = (overrides: Partial<MessageItem> = {}): MessageItem =>
|
|
@@ -28,7 +29,7 @@ const baseMessage = (overrides: Partial<MessageItem> = {}): MessageItem =>
|
|
|
28
29
|
}) as unknown as MessageItem;
|
|
29
30
|
|
|
30
31
|
describe("classifyPlacement", () => {
|
|
31
|
-
describe("
|
|
32
|
+
describe("SenderOverride.Blocked (RFC 039 Decision 3)", () => {
|
|
32
33
|
it("demotes an inbox message from a blocked sender, independent of DKIM/DMARC", () => {
|
|
33
34
|
const message = baseMessage({
|
|
34
35
|
providerSpam: undefined,
|
|
@@ -38,7 +39,7 @@ describe("classifyPlacement", () => {
|
|
|
38
39
|
message,
|
|
39
40
|
"inbox",
|
|
40
41
|
SenderTrust.Unknown,
|
|
41
|
-
|
|
42
|
+
SenderOverride.Blocked,
|
|
42
43
|
);
|
|
43
44
|
assert.deepEqual(verdict, {
|
|
44
45
|
action: "move-to-junk",
|
|
@@ -52,7 +53,7 @@ describe("classifyPlacement", () => {
|
|
|
52
53
|
baseMessage(),
|
|
53
54
|
"other",
|
|
54
55
|
SenderTrust.Unknown,
|
|
55
|
-
|
|
56
|
+
SenderOverride.Blocked,
|
|
56
57
|
);
|
|
57
58
|
assert.equal(verdict.action, "move-to-junk");
|
|
58
59
|
assert.equal(verdict.confidence, "confident");
|
|
@@ -63,17 +64,17 @@ describe("classifyPlacement", () => {
|
|
|
63
64
|
baseMessage(),
|
|
64
65
|
"junk",
|
|
65
66
|
SenderTrust.Unknown,
|
|
66
|
-
|
|
67
|
+
SenderOverride.Blocked,
|
|
67
68
|
);
|
|
68
69
|
assert.notEqual(verdict.action, "move-to-junk");
|
|
69
70
|
});
|
|
70
71
|
|
|
71
|
-
it("does not demote when the sender
|
|
72
|
+
it("does not demote when the sender carries no instruction, all else equal", () => {
|
|
72
73
|
const verdict = classifyPlacement(
|
|
73
74
|
baseMessage({ providerSpam: undefined, authResult: undefined }),
|
|
74
75
|
"inbox",
|
|
75
76
|
SenderTrust.Unknown,
|
|
76
|
-
|
|
77
|
+
SenderOverride.None,
|
|
77
78
|
);
|
|
78
79
|
assert.deepEqual(verdict, {
|
|
79
80
|
action: "leave",
|
|
@@ -87,7 +88,7 @@ describe("classifyPlacement", () => {
|
|
|
87
88
|
baseMessage({ movedByRemit: true }),
|
|
88
89
|
"inbox",
|
|
89
90
|
SenderTrust.Unknown,
|
|
90
|
-
|
|
91
|
+
SenderOverride.Blocked,
|
|
91
92
|
);
|
|
92
93
|
assert.deepEqual(verdict, {
|
|
93
94
|
action: "leave",
|
|
@@ -97,70 +98,221 @@ describe("classifyPlacement", () => {
|
|
|
97
98
|
});
|
|
98
99
|
});
|
|
99
100
|
|
|
100
|
-
describe("
|
|
101
|
+
describe("SenderOverride.NeverSpam (issue #605)", () => {
|
|
102
|
+
it("rescues mail the provider rated clean from a domain that publishes no DMARC", () => {
|
|
103
|
+
const verdict = classifyPlacement(
|
|
104
|
+
baseMessage({
|
|
105
|
+
providerSpam: { classified: false },
|
|
106
|
+
authResult: { dmarc: "None" },
|
|
107
|
+
}),
|
|
108
|
+
"junk",
|
|
109
|
+
SenderTrust.Unknown,
|
|
110
|
+
SenderOverride.NeverSpam,
|
|
111
|
+
);
|
|
112
|
+
assert.deepEqual(verdict, {
|
|
113
|
+
action: "move-to-inbox",
|
|
114
|
+
confidence: "confident",
|
|
115
|
+
reasons: ["sender=never-spam"],
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("rescues before the missing-signals return, so a message with no parsed signals still comes out of junk", () => {
|
|
120
|
+
const verdict = classifyPlacement(
|
|
121
|
+
baseMessage({ providerSpam: undefined, authResult: undefined }),
|
|
122
|
+
"junk",
|
|
123
|
+
SenderTrust.Unknown,
|
|
124
|
+
SenderOverride.NeverSpam,
|
|
125
|
+
);
|
|
126
|
+
assert.equal(verdict.action, "move-to-inbox");
|
|
127
|
+
assert.equal(verdict.confidence, "confident");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("leaves a never-spam sender's mail filed in a user folder where it is", () => {
|
|
131
|
+
const verdict = classifyPlacement(
|
|
132
|
+
baseMessage(),
|
|
133
|
+
"other",
|
|
134
|
+
SenderTrust.Unknown,
|
|
135
|
+
SenderOverride.NeverSpam,
|
|
136
|
+
);
|
|
137
|
+
assert.equal(verdict.action, "leave");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// The demote branch is reached only from `inbox`, so a spoof already in
|
|
141
|
+
// Junk never meets it. Without the guard on the rescue, the grant would
|
|
142
|
+
// carry mail impersonating the granted address out of Junk.
|
|
143
|
+
it("refuses to rescue a dkim-mismatch + dmarc-fail spoof sitting in junk", () => {
|
|
144
|
+
const verdict = classifyPlacement(
|
|
145
|
+
baseMessage({
|
|
146
|
+
providerSpam: { classified: true },
|
|
147
|
+
authResult: { dmarc: "Fail" },
|
|
148
|
+
authenticity: { fromDomain: "example.com", dkimMismatch: true },
|
|
149
|
+
}),
|
|
150
|
+
"junk",
|
|
151
|
+
SenderTrust.Unknown,
|
|
152
|
+
SenderOverride.NeverSpam,
|
|
153
|
+
);
|
|
154
|
+
assert.notEqual(verdict.action, "move-to-inbox");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("refuses to rescue on a hard DMARC failure even with DKIM aligned", () => {
|
|
158
|
+
const verdict = classifyPlacement(
|
|
159
|
+
baseMessage({
|
|
160
|
+
providerSpam: { classified: true },
|
|
161
|
+
authResult: { dmarc: "Fail" },
|
|
162
|
+
}),
|
|
163
|
+
"junk",
|
|
164
|
+
SenderTrust.Unknown,
|
|
165
|
+
SenderOverride.NeverSpam,
|
|
166
|
+
);
|
|
167
|
+
assert.notEqual(verdict.action, "move-to-inbox");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("refuses to rescue on a DKIM mismatch even where DMARC did not fail", () => {
|
|
171
|
+
const verdict = classifyPlacement(
|
|
172
|
+
baseMessage({
|
|
173
|
+
providerSpam: { classified: true },
|
|
174
|
+
authResult: { dmarc: "None" },
|
|
175
|
+
authenticity: { fromDomain: "example.com", dkimMismatch: true },
|
|
176
|
+
}),
|
|
177
|
+
"junk",
|
|
178
|
+
SenderTrust.Unknown,
|
|
179
|
+
SenderOverride.NeverSpam,
|
|
180
|
+
);
|
|
181
|
+
assert.notEqual(verdict.action, "move-to-inbox");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("still demotes a never-spam sender's inbox mail on dkim-mismatch + dmarc-fail", () => {
|
|
185
|
+
const verdict = classifyPlacement(
|
|
186
|
+
baseMessage({
|
|
187
|
+
authResult: { dmarc: "Fail" },
|
|
188
|
+
authenticity: { fromDomain: "example.com", dkimMismatch: true },
|
|
189
|
+
}),
|
|
190
|
+
"inbox",
|
|
191
|
+
SenderTrust.Unknown,
|
|
192
|
+
SenderOverride.NeverSpam,
|
|
193
|
+
);
|
|
194
|
+
assert.deepEqual(verdict, {
|
|
195
|
+
action: "move-to-junk",
|
|
196
|
+
confidence: "confident",
|
|
197
|
+
reasons: ["dkim-mismatch", "dmarc=fail", "sender=untrusted"],
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("still leaves an already-Remit-moved message alone", () => {
|
|
202
|
+
const verdict = classifyPlacement(
|
|
203
|
+
baseMessage({ movedByRemit: true }),
|
|
204
|
+
"junk",
|
|
205
|
+
SenderTrust.Unknown,
|
|
206
|
+
SenderOverride.NeverSpam,
|
|
207
|
+
);
|
|
208
|
+
assert.deepEqual(verdict, {
|
|
209
|
+
action: "leave",
|
|
210
|
+
confidence: "confident",
|
|
211
|
+
reasons: ["already-moved-by-remit"],
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe("resolveSenderPlacement (Decision 3a tie-break)", () => {
|
|
101
217
|
it("blocked wins when set after vip", () => {
|
|
102
|
-
const result =
|
|
218
|
+
const result = resolveSenderPlacement(
|
|
103
219
|
{ trust: SenderTrust.Vip, setAt: 1_000 },
|
|
104
220
|
{ blocked: true, setAt: 5_000 },
|
|
221
|
+
false,
|
|
105
222
|
);
|
|
106
223
|
assert.deepEqual(result, {
|
|
107
224
|
senderTrust: SenderTrust.Unknown,
|
|
108
|
-
|
|
225
|
+
senderOverride: SenderOverride.Blocked,
|
|
109
226
|
});
|
|
110
227
|
});
|
|
111
228
|
|
|
112
229
|
it("vip wins when set after blocked", () => {
|
|
113
|
-
const result =
|
|
230
|
+
const result = resolveSenderPlacement(
|
|
114
231
|
{ trust: SenderTrust.Vip, setAt: 5_000 },
|
|
115
232
|
{ blocked: true, setAt: 1_000 },
|
|
233
|
+
false,
|
|
116
234
|
);
|
|
117
235
|
assert.deepEqual(result, {
|
|
118
236
|
senderTrust: SenderTrust.Vip,
|
|
119
|
-
|
|
237
|
+
senderOverride: SenderOverride.None,
|
|
120
238
|
});
|
|
121
239
|
});
|
|
122
240
|
|
|
123
241
|
it("breaks a same-second tie in favor of blocked", () => {
|
|
124
|
-
const result =
|
|
242
|
+
const result = resolveSenderPlacement(
|
|
125
243
|
{ trust: SenderTrust.Wellknown, setAt: 1_000 },
|
|
126
244
|
{ blocked: true, setAt: 1_400 },
|
|
245
|
+
false,
|
|
127
246
|
);
|
|
128
247
|
assert.deepEqual(result, {
|
|
129
248
|
senderTrust: SenderTrust.Unknown,
|
|
130
|
-
|
|
249
|
+
senderOverride: SenderOverride.Blocked,
|
|
131
250
|
});
|
|
132
251
|
});
|
|
133
252
|
|
|
134
253
|
it("blocked applies outright when there is no competing trust flag", () => {
|
|
135
|
-
const result =
|
|
254
|
+
const result = resolveSenderPlacement(
|
|
136
255
|
{ trust: SenderTrust.Unknown },
|
|
137
256
|
{ blocked: true, setAt: 1_000 },
|
|
257
|
+
false,
|
|
138
258
|
);
|
|
139
259
|
assert.deepEqual(result, {
|
|
140
260
|
senderTrust: SenderTrust.Unknown,
|
|
141
|
-
|
|
261
|
+
senderOverride: SenderOverride.Blocked,
|
|
142
262
|
});
|
|
143
263
|
});
|
|
144
264
|
|
|
145
|
-
it("passes trust through unchanged when the sender
|
|
146
|
-
const result =
|
|
265
|
+
it("passes trust through unchanged when the sender carries no instruction", () => {
|
|
266
|
+
const result = resolveSenderPlacement(
|
|
147
267
|
{ trust: SenderTrust.Wellknown, setAt: 1_000 },
|
|
148
268
|
{ blocked: false },
|
|
269
|
+
false,
|
|
149
270
|
);
|
|
150
271
|
assert.deepEqual(result, {
|
|
151
272
|
senderTrust: SenderTrust.Wellknown,
|
|
152
|
-
|
|
273
|
+
senderOverride: SenderOverride.None,
|
|
153
274
|
});
|
|
154
275
|
});
|
|
276
|
+
|
|
277
|
+
it("reads never-spam as the instruction, leaving trust untouched", () => {
|
|
278
|
+
const result = resolveSenderPlacement(
|
|
279
|
+
{ trust: SenderTrust.Unknown },
|
|
280
|
+
{ blocked: false },
|
|
281
|
+
true,
|
|
282
|
+
);
|
|
283
|
+
assert.deepEqual(result, {
|
|
284
|
+
senderTrust: SenderTrust.Unknown,
|
|
285
|
+
senderOverride: SenderOverride.NeverSpam,
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("does not promote a never-spam sender's trust to vip or wellknown", () => {
|
|
290
|
+
const result = resolveSenderPlacement(
|
|
291
|
+
{ trust: SenderTrust.Vip, setAt: 1_000 },
|
|
292
|
+
{ blocked: false },
|
|
293
|
+
true,
|
|
294
|
+
);
|
|
295
|
+
assert.equal(result.senderTrust, SenderTrust.Vip);
|
|
296
|
+
assert.equal(result.senderOverride, SenderOverride.NeverSpam);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("resolves a row that somehow carries both instructions to blocked", () => {
|
|
300
|
+
const result = resolveSenderPlacement(
|
|
301
|
+
{ trust: SenderTrust.Unknown },
|
|
302
|
+
{ blocked: true, setAt: 1_000 },
|
|
303
|
+
true,
|
|
304
|
+
);
|
|
305
|
+
assert.equal(result.senderOverride, SenderOverride.Blocked);
|
|
306
|
+
});
|
|
155
307
|
});
|
|
156
308
|
|
|
157
|
-
describe("existing DKIM/DMARC paths (unaffected by
|
|
309
|
+
describe("existing DKIM/DMARC paths (unaffected by SenderOverride.None)", () => {
|
|
158
310
|
it("rescues a trusted sender's mail from junk on provider-spam + dmarc-pass", () => {
|
|
159
311
|
const verdict = classifyPlacement(
|
|
160
312
|
baseMessage({ providerSpam: { classified: true } }),
|
|
161
313
|
"junk",
|
|
162
314
|
SenderTrust.Vip,
|
|
163
|
-
|
|
315
|
+
SenderOverride.None,
|
|
164
316
|
);
|
|
165
317
|
assert.equal(verdict.action, "move-to-inbox");
|
|
166
318
|
assert.equal(verdict.confidence, "confident");
|
|
@@ -171,7 +323,7 @@ describe("classifyPlacement", () => {
|
|
|
171
323
|
baseMessage({ providerSpam: { classified: true } }),
|
|
172
324
|
"junk",
|
|
173
325
|
SenderTrust.Unknown,
|
|
174
|
-
|
|
326
|
+
SenderOverride.None,
|
|
175
327
|
);
|
|
176
328
|
assert.equal(verdict.action, "leave");
|
|
177
329
|
assert.equal(verdict.confidence, "unsure");
|
|
@@ -185,7 +337,7 @@ describe("classifyPlacement", () => {
|
|
|
185
337
|
}),
|
|
186
338
|
"inbox",
|
|
187
339
|
SenderTrust.Unknown,
|
|
188
|
-
|
|
340
|
+
SenderOverride.None,
|
|
189
341
|
);
|
|
190
342
|
assert.deepEqual(verdict, {
|
|
191
343
|
action: "move-to-junk",
|
|
@@ -202,7 +354,7 @@ describe("classifyPlacement", () => {
|
|
|
202
354
|
}),
|
|
203
355
|
"inbox",
|
|
204
356
|
SenderTrust.Unknown,
|
|
205
|
-
|
|
357
|
+
SenderOverride.None,
|
|
206
358
|
);
|
|
207
359
|
assert.deepEqual(verdict, {
|
|
208
360
|
action: "leave",
|