@remit/mailbox-service 0.0.100 → 0.0.102
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/heuristics/authenticationResults.test.ts +189 -0
- package/src/heuristics/authenticationResults.ts +212 -0
- package/src/heuristics/classifyByHeaders.test.ts +313 -2
- package/src/heuristics/classifyByHeaders.ts +95 -27
- package/src/mailbox-management.test.ts +48 -3
- package/src/mailbox-management.ts +25 -0
- package/src/mailbox-queue.ts +7 -5
- package/src/mailbox-sync.test.ts +52 -5
- package/src/mailbox-sync.ts +13 -4
package/package.json
CHANGED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
type AuthenticationResults,
|
|
5
|
+
parseAuthenticationResults,
|
|
6
|
+
} from "./authenticationResults.js";
|
|
7
|
+
|
|
8
|
+
const parse = (value: string): AuthenticationResults => {
|
|
9
|
+
const parsed = parseAuthenticationResults(value);
|
|
10
|
+
assert.ok(parsed, `expected ${JSON.stringify(value)} to parse`);
|
|
11
|
+
return parsed;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const summary = (value: string): string[] =>
|
|
15
|
+
parse(value).results.map((r) =>
|
|
16
|
+
[`${r.method}=${r.result}`, ...r.properties].flat().join(" "),
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
describe("parseAuthenticationResults", () => {
|
|
20
|
+
it("reads the authserv-id and every resinfo", () => {
|
|
21
|
+
const parsed = parse(
|
|
22
|
+
"mx.example.com; dkim=pass header.d=acme.example; spf=pass smtp.mailfrom=acme.example; dmarc=pass header.from=acme.example",
|
|
23
|
+
);
|
|
24
|
+
assert.equal(parsed.authservId, "mx.example.com");
|
|
25
|
+
assert.deepEqual(summary("mx.example.com; dkim=pass header.d=a.example"), [
|
|
26
|
+
"dkim=pass header.d a.example",
|
|
27
|
+
]);
|
|
28
|
+
assert.deepEqual(
|
|
29
|
+
parsed.results.map((r) => r.method),
|
|
30
|
+
["dkim", "spf", "dmarc"],
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("keeps a semicolon inside a comment within its own resinfo", () => {
|
|
35
|
+
assert.deepEqual(
|
|
36
|
+
summary(
|
|
37
|
+
"gw.example; dkim=fail (1024-bit key; unprotected) header.d=acme.example; dkim=fail header.d=relay.example; dmarc=fail (p=REJECT) header.from=acme.example",
|
|
38
|
+
),
|
|
39
|
+
[
|
|
40
|
+
"dkim=fail header.d acme.example",
|
|
41
|
+
"dkim=fail header.d relay.example",
|
|
42
|
+
"dmarc=fail header.from acme.example",
|
|
43
|
+
],
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("never reads a result out of another method's comment", () => {
|
|
48
|
+
assert.deepEqual(
|
|
49
|
+
summary(
|
|
50
|
+
"mx.example.net; arc=fail (i=1 spf=pass; dkim=fail\r\n header.d=arcsigner.example; dmarc=fail); spf=pass smtp.mailfrom=acme.example;\r\n dmarc=fail header.from=acme.example",
|
|
51
|
+
),
|
|
52
|
+
[
|
|
53
|
+
"arc=fail",
|
|
54
|
+
"spf=pass smtp.mailfrom acme.example",
|
|
55
|
+
"dmarc=fail header.from acme.example",
|
|
56
|
+
],
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("strips nested comments and escaped parentheses", () => {
|
|
61
|
+
assert.deepEqual(
|
|
62
|
+
summary(
|
|
63
|
+
"mx.example (outer (inner; dkim=pass) \\) still; comment); dkim=fail (a (b; c) d) header.d=acme.example",
|
|
64
|
+
),
|
|
65
|
+
["dkim=fail header.d acme.example"],
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("keeps a semicolon inside a quoted reason", () => {
|
|
70
|
+
assert.deepEqual(
|
|
71
|
+
summary(
|
|
72
|
+
'gw.example; dkim=fail reason="verification failed; body hash mismatch" header.d=acme.example; dmarc=fail header.from=acme.example',
|
|
73
|
+
),
|
|
74
|
+
[
|
|
75
|
+
"dkim=fail reason verification failed; body hash mismatch header.d acme.example",
|
|
76
|
+
"dmarc=fail header.from acme.example",
|
|
77
|
+
],
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("lower-cases methods, results and property names", () => {
|
|
82
|
+
const parsed = parse(
|
|
83
|
+
"GW.EXAMPLE; DKIM=FAIL HEADER.D=EVIL.EXAMPLE; DMARC=FAIL",
|
|
84
|
+
);
|
|
85
|
+
assert.equal(parsed.authservId, "gw.example");
|
|
86
|
+
assert.deepEqual(
|
|
87
|
+
summary("GW.EXAMPLE; DKIM=FAIL HEADER.D=EVIL.EXAMPLE; DMARC=FAIL"),
|
|
88
|
+
["dkim=fail header.d EVIL.EXAMPLE", "dmarc=fail"],
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("accepts a comment before the method name", () => {
|
|
93
|
+
assert.deepEqual(
|
|
94
|
+
summary(
|
|
95
|
+
"mx.example.com; (a comment) dkim=pass header.d=acme.example; dmarc=fail",
|
|
96
|
+
),
|
|
97
|
+
["dkim=pass header.d acme.example", "dmarc=fail"],
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("accepts whitespace around = and .", () => {
|
|
102
|
+
assert.deepEqual(
|
|
103
|
+
summary(
|
|
104
|
+
"mx.example.com; dkim = fail header . d = evil.example; dmarc=fail",
|
|
105
|
+
),
|
|
106
|
+
["dkim=fail header.d evil.example", "dmarc=fail"],
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("reads folded continuation lines", () => {
|
|
111
|
+
assert.deepEqual(
|
|
112
|
+
summary(
|
|
113
|
+
"mx.example.com;\r\n\tdkim=pass header.d=acme.example;\r\n\tdmarc=fail header.from=acme.example",
|
|
114
|
+
),
|
|
115
|
+
[
|
|
116
|
+
"dkim=pass header.d acme.example",
|
|
117
|
+
"dmarc=fail header.from acme.example",
|
|
118
|
+
],
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("finds a result after hundreds of filler properties", () => {
|
|
123
|
+
const filler = Array.from(
|
|
124
|
+
{ length: 400 },
|
|
125
|
+
(_, n) => `x-filler.p${n}=v${n}`,
|
|
126
|
+
).join(" ");
|
|
127
|
+
const parsed = parse(
|
|
128
|
+
`mx.example.com; spf=none ${filler}; dkim=fail header.d=acme.example`,
|
|
129
|
+
);
|
|
130
|
+
assert.equal(parsed.results[0].properties.size, 400);
|
|
131
|
+
assert.equal(parsed.results[1].properties.get("header.d"), "acme.example");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("reads a result that carries no properties", () => {
|
|
135
|
+
assert.deepEqual(summary("gw.example; dkim=fail; dmarc=fail"), [
|
|
136
|
+
"dkim=fail",
|
|
137
|
+
"dmarc=fail",
|
|
138
|
+
]);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("reads method versions, an authres-version and a trailing semicolon", () => {
|
|
142
|
+
const parsed = parse("mx.example 1; dkim/1=pass header.d=acme.example;");
|
|
143
|
+
assert.equal(parsed.authservId, "mx.example");
|
|
144
|
+
assert.deepEqual(
|
|
145
|
+
summary("mx.example 1; dkim/1=pass header.d=acme.example;"),
|
|
146
|
+
["dkim=pass header.d acme.example"],
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("reads the explicit no-result form", () => {
|
|
151
|
+
assert.deepEqual(parse("mx.example.com; none"), {
|
|
152
|
+
authservId: "mx.example.com",
|
|
153
|
+
results: [],
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("reads a header that omits the authserv-id", () => {
|
|
158
|
+
const parsed = parse(
|
|
159
|
+
"spf=pass (sender IP is 192.0.2.1) smtp.mailfrom=acme.example; dkim=pass (signature was verified) header.d=acme.example;dmarc=pass action=none header.from=acme.example;compauth=pass reason=100",
|
|
160
|
+
);
|
|
161
|
+
assert.equal(parsed.authservId, "");
|
|
162
|
+
assert.deepEqual(
|
|
163
|
+
parsed.results.map((r) => `${r.method}=${r.result}`),
|
|
164
|
+
["spf=pass", "dkim=pass", "dmarc=pass", "compauth=pass"],
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
for (const [label, value] of [
|
|
169
|
+
["an empty header", " "],
|
|
170
|
+
["an authserv-id alone", "mx.example.com"],
|
|
171
|
+
["an authserv-id with an empty resinfo", "mx.example.com;"],
|
|
172
|
+
["an unterminated comment", "mx.example; dmarc=pass (oops"],
|
|
173
|
+
["an unbalanced closing parenthesis", "mx.example; dmarc=pass oops)"],
|
|
174
|
+
["an unterminated quoted string", 'mx.example; dkim=fail reason="oops'],
|
|
175
|
+
["a word with no value", "mx.example; dmarc=pass stray"],
|
|
176
|
+
["a key with no value", "mx.example; dmarc=pass header.from="],
|
|
177
|
+
["a resinfo with no method", "mx.example; header.d=acme.example"],
|
|
178
|
+
["a quoted result", 'mx.example; dmarc="pass"'],
|
|
179
|
+
[
|
|
180
|
+
"two words where a key belongs",
|
|
181
|
+
"mx.example; dmarc=pass extra header.from=a.example",
|
|
182
|
+
],
|
|
183
|
+
["no-result mixed with a result", "mx.example; none; dmarc=pass"],
|
|
184
|
+
] as const) {
|
|
185
|
+
it(`yields no result for ${label}`, () => {
|
|
186
|
+
assert.equal(parseAuthenticationResults(value), null);
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
});
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
export interface AuthenticationResult {
|
|
2
|
+
method: string;
|
|
3
|
+
result: string;
|
|
4
|
+
properties: ReadonlyMap<string, string>;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface AuthenticationResults {
|
|
8
|
+
authservId: string;
|
|
9
|
+
results: AuthenticationResult[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type Token =
|
|
13
|
+
| { kind: "word"; text: string }
|
|
14
|
+
| { kind: "quoted"; text: string }
|
|
15
|
+
| { kind: "semicolon" }
|
|
16
|
+
| { kind: "equals" };
|
|
17
|
+
|
|
18
|
+
type ValueToken = Extract<Token, { kind: "word" | "quoted" }>;
|
|
19
|
+
|
|
20
|
+
const DELIMITERS = new Set([";", "=", "(", ")", '"']);
|
|
21
|
+
const KEYWORD = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
22
|
+
const METHOD = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\/[0-9]+)?$/;
|
|
23
|
+
const PROPERTY_KEY =
|
|
24
|
+
/^[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?)?$/;
|
|
25
|
+
const VERSION = /^[0-9]+$/;
|
|
26
|
+
|
|
27
|
+
const isWhitespace = (c: string): boolean =>
|
|
28
|
+
c === " " || c === "\t" || c === "\r" || c === "\n";
|
|
29
|
+
|
|
30
|
+
const skipComment = (input: string, start: number): number | null => {
|
|
31
|
+
let depth = 0;
|
|
32
|
+
for (let i = start; i < input.length; i++) {
|
|
33
|
+
const c = input[i];
|
|
34
|
+
if (c === "\\") {
|
|
35
|
+
i++;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (c === "(") depth++;
|
|
39
|
+
if (c === ")") depth--;
|
|
40
|
+
if (depth === 0) return i + 1;
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const readQuoted = (
|
|
46
|
+
input: string,
|
|
47
|
+
start: number,
|
|
48
|
+
): { text: string; end: number } | null => {
|
|
49
|
+
let text = "";
|
|
50
|
+
for (let i = start + 1; i < input.length; i++) {
|
|
51
|
+
const c = input[i];
|
|
52
|
+
if (c === "\\") {
|
|
53
|
+
if (i + 1 >= input.length) return null;
|
|
54
|
+
text += input[i + 1];
|
|
55
|
+
i++;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (c === '"') return { text, end: i + 1 };
|
|
59
|
+
text += c;
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const tokenize = (input: string): Token[] | null => {
|
|
65
|
+
const tokens: Token[] = [];
|
|
66
|
+
let i = 0;
|
|
67
|
+
while (i < input.length) {
|
|
68
|
+
const c = input[i];
|
|
69
|
+
if (isWhitespace(c)) {
|
|
70
|
+
i++;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (c === "(") {
|
|
74
|
+
const end = skipComment(input, i);
|
|
75
|
+
if (end === null) return null;
|
|
76
|
+
i = end;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (c === ")") return null;
|
|
80
|
+
if (c === ";") {
|
|
81
|
+
tokens.push({ kind: "semicolon" });
|
|
82
|
+
i++;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (c === "=") {
|
|
86
|
+
tokens.push({ kind: "equals" });
|
|
87
|
+
i++;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (c === '"') {
|
|
91
|
+
const quoted = readQuoted(input, i);
|
|
92
|
+
if (!quoted) return null;
|
|
93
|
+
tokens.push({ kind: "quoted", text: quoted.text });
|
|
94
|
+
i = quoted.end;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
let end = i;
|
|
98
|
+
while (
|
|
99
|
+
end < input.length &&
|
|
100
|
+
!isWhitespace(input[end]) &&
|
|
101
|
+
!DELIMITERS.has(input[end])
|
|
102
|
+
) {
|
|
103
|
+
end++;
|
|
104
|
+
}
|
|
105
|
+
tokens.push({ kind: "word", text: input.slice(i, end) });
|
|
106
|
+
i = end;
|
|
107
|
+
}
|
|
108
|
+
return tokens;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const splitStatements = (tokens: Token[]): Token[][] => {
|
|
112
|
+
const statements: Token[][] = [[]];
|
|
113
|
+
for (const token of tokens) {
|
|
114
|
+
if (token.kind === "semicolon") {
|
|
115
|
+
statements.push([]);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
statements[statements.length - 1].push(token);
|
|
119
|
+
}
|
|
120
|
+
return statements;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const isValue = (token: Token | undefined): token is ValueToken =>
|
|
124
|
+
token !== undefined && (token.kind === "word" || token.kind === "quoted");
|
|
125
|
+
|
|
126
|
+
const joinsKey = (previous: string, next: string): boolean =>
|
|
127
|
+
previous.endsWith(".") ||
|
|
128
|
+
previous.endsWith("/") ||
|
|
129
|
+
next.startsWith(".") ||
|
|
130
|
+
next.startsWith("/");
|
|
131
|
+
|
|
132
|
+
const readPairs = (statement: Token[]): [string, ValueToken][] | null => {
|
|
133
|
+
const pairs: [string, ValueToken][] = [];
|
|
134
|
+
let key: string[] = [];
|
|
135
|
+
for (let i = 0; i < statement.length; i++) {
|
|
136
|
+
const token = statement[i];
|
|
137
|
+
if (token.kind === "equals") {
|
|
138
|
+
const value = statement[i + 1];
|
|
139
|
+
if (key.length === 0 || !isValue(value)) return null;
|
|
140
|
+
pairs.push([key.join("").toLowerCase(), value]);
|
|
141
|
+
key = [];
|
|
142
|
+
i++;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (token.kind !== "word") return null;
|
|
146
|
+
const previous = key[key.length - 1];
|
|
147
|
+
if (previous !== undefined && !joinsKey(previous, token.text)) return null;
|
|
148
|
+
key.push(token.text);
|
|
149
|
+
}
|
|
150
|
+
if (key.length > 0) return null;
|
|
151
|
+
return pairs;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const parseResult = (statement: Token[]): AuthenticationResult | null => {
|
|
155
|
+
const pairs = readPairs(statement);
|
|
156
|
+
if (!pairs || pairs.length === 0) return null;
|
|
157
|
+
const [[methodKey, resultToken], ...rest] = pairs;
|
|
158
|
+
if (!METHOD.test(methodKey)) return null;
|
|
159
|
+
if (resultToken.kind !== "word") return null;
|
|
160
|
+
const result = resultToken.text.toLowerCase();
|
|
161
|
+
if (!KEYWORD.test(result)) return null;
|
|
162
|
+
|
|
163
|
+
const properties = new Map<string, string>();
|
|
164
|
+
for (const [key, value] of rest) {
|
|
165
|
+
if (!PROPERTY_KEY.test(key)) return null;
|
|
166
|
+
if (!properties.has(key)) properties.set(key, value.text);
|
|
167
|
+
}
|
|
168
|
+
return { method: methodKey.split("/")[0], result, properties };
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const parseAuthservId = (statement: Token[]): string | null => {
|
|
172
|
+
const [id, version, ...extra] = statement;
|
|
173
|
+
if (!isValue(id) || extra.length > 0) return null;
|
|
174
|
+
if (version !== undefined) {
|
|
175
|
+
if (version.kind !== "word" || !VERSION.test(version.text)) return null;
|
|
176
|
+
}
|
|
177
|
+
const authservId = id.text.toLowerCase();
|
|
178
|
+
return authservId.length > 0 ? authservId : null;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const isNoResult = (statement: Token[]): boolean =>
|
|
182
|
+
statement.length === 1 &&
|
|
183
|
+
statement[0].kind === "word" &&
|
|
184
|
+
statement[0].text.toLowerCase() === "none";
|
|
185
|
+
|
|
186
|
+
export const parseAuthenticationResults = (
|
|
187
|
+
value: string,
|
|
188
|
+
): AuthenticationResults | null => {
|
|
189
|
+
const tokens = tokenize(value);
|
|
190
|
+
if (!tokens) return null;
|
|
191
|
+
|
|
192
|
+
const [head, ...tail] = splitStatements(tokens);
|
|
193
|
+
const headIsResult = head.some((token) => token.kind === "equals");
|
|
194
|
+
const authservId = headIsResult ? "" : parseAuthservId(head);
|
|
195
|
+
if (authservId === null) return null;
|
|
196
|
+
|
|
197
|
+
const statements = (headIsResult ? [head, ...tail] : tail).filter(
|
|
198
|
+
(statement) => statement.length > 0,
|
|
199
|
+
);
|
|
200
|
+
if (statements.length === 0) return null;
|
|
201
|
+
if (statements.length === 1 && isNoResult(statements[0])) {
|
|
202
|
+
return { authservId, results: [] };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const results: AuthenticationResult[] = [];
|
|
206
|
+
for (const statement of statements) {
|
|
207
|
+
const result = parseResult(statement);
|
|
208
|
+
if (!result) return null;
|
|
209
|
+
results.push(result);
|
|
210
|
+
}
|
|
211
|
+
return { authservId, results };
|
|
212
|
+
};
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { describe, it } from "node:test";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import type { MessageItem } from "@remit/data-ports";
|
|
4
|
+
import {
|
|
5
|
+
MessageCategory,
|
|
6
|
+
SenderOverride,
|
|
7
|
+
SenderTrust,
|
|
8
|
+
} from "@remit/domain-enums";
|
|
9
|
+
import { type ParsedMail, simpleParser } from "mailparser";
|
|
5
10
|
import {
|
|
6
11
|
classifyByHeaders,
|
|
7
12
|
extractAuthenticity,
|
|
@@ -9,6 +14,7 @@ import {
|
|
|
9
14
|
extractHasListUnsubscribe,
|
|
10
15
|
extractProviderSpam,
|
|
11
16
|
} from "./classifyByHeaders.js";
|
|
17
|
+
import { classifyPlacement } from "./classifyPlacement.js";
|
|
12
18
|
|
|
13
19
|
const buildEml = (lines: string[]): Buffer => Buffer.from(lines.join("\r\n"));
|
|
14
20
|
|
|
@@ -576,3 +582,308 @@ describe("extractHasListUnsubscribe", () => {
|
|
|
576
582
|
assert.equal(extractHasListUnsubscribe(parsed), true);
|
|
577
583
|
});
|
|
578
584
|
});
|
|
585
|
+
|
|
586
|
+
const messageFrom = (parsed: ParsedMail): MessageItem => ({
|
|
587
|
+
messageId: "m-1",
|
|
588
|
+
mailboxId: "mb-1",
|
|
589
|
+
uid: 1,
|
|
590
|
+
sequenceNumber: 1,
|
|
591
|
+
rfc822Size: 1,
|
|
592
|
+
internalDate: 1,
|
|
593
|
+
envelopeId: "e-1",
|
|
594
|
+
rootBodyPartId: "bp-1",
|
|
595
|
+
status: "active",
|
|
596
|
+
syncStatus: "synced",
|
|
597
|
+
abandonedMutation: "none",
|
|
598
|
+
category: "uncategorized",
|
|
599
|
+
classificationState: "NotExamined",
|
|
600
|
+
authenticityVerdict: "NotEvaluated",
|
|
601
|
+
hasListUnsubscribe: false,
|
|
602
|
+
movedByRemit: false,
|
|
603
|
+
createdAt: 1,
|
|
604
|
+
updatedAt: 1,
|
|
605
|
+
providerSpam: extractProviderSpam(parsed) ?? undefined,
|
|
606
|
+
authResult: extractAuthResult(parsed) ?? undefined,
|
|
607
|
+
authenticity: extractAuthenticity(parsed) ?? undefined,
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
const inboxVerdict = (parsed: ParsedMail) =>
|
|
611
|
+
classifyPlacement(
|
|
612
|
+
messageFrom(parsed),
|
|
613
|
+
"inbox",
|
|
614
|
+
SenderTrust.Unknown,
|
|
615
|
+
SenderOverride.None,
|
|
616
|
+
);
|
|
617
|
+
|
|
618
|
+
const withBody = (lines: string[]): string[] => [...lines, "", "body"];
|
|
619
|
+
|
|
620
|
+
describe("Authentication-Results adversarial findings (#657)", () => {
|
|
621
|
+
it("keeps the aligned dkim result when a comment carries a semicolon", async () => {
|
|
622
|
+
const parsed = await parse(
|
|
623
|
+
withBody([
|
|
624
|
+
"From: Alice <alice@acme.example>",
|
|
625
|
+
"Authentication-Results: gw.example; dkim=fail (1024-bit key; unprotected) header.d=acme.example; dkim=pass header.d=relay.example; dmarc=fail (p=REJECT) header.from=acme.example",
|
|
626
|
+
"X-Spam-Status: No, score=0.1",
|
|
627
|
+
]),
|
|
628
|
+
);
|
|
629
|
+
assert.deepEqual(extractAuthResult(parsed), {
|
|
630
|
+
dmarc: "Fail",
|
|
631
|
+
spf: undefined,
|
|
632
|
+
dkim: "Fail",
|
|
633
|
+
});
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
it("checks alignment even when an unrelated dkim=pass is present", async () => {
|
|
637
|
+
const header = (extra: string) =>
|
|
638
|
+
parse(
|
|
639
|
+
withBody([
|
|
640
|
+
"From: Alice <alice@acme.example>",
|
|
641
|
+
`Authentication-Results: gw.corp.example; ${extra}dkim=fail (body hash did not verify) header.d=acme.example; dmarc=fail (p=REJECT) header.from=acme.example`,
|
|
642
|
+
"X-Spam-Status: No, score=0.1",
|
|
643
|
+
]),
|
|
644
|
+
);
|
|
645
|
+
const without = await header("");
|
|
646
|
+
const withEsp = await header("dkim=pass header.d=esp-mailer.example; ");
|
|
647
|
+
assert.deepEqual(extractAuthResult(withEsp), extractAuthResult(without));
|
|
648
|
+
assert.deepEqual(inboxVerdict(withEsp), inboxVerdict(without));
|
|
649
|
+
assert.equal(inboxVerdict(withEsp).action, "leave");
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
it("never junks list mail whose relay stripped the original signature", async () => {
|
|
653
|
+
const parsed = await parse(
|
|
654
|
+
withBody([
|
|
655
|
+
"From: Alice <alice@acme.example>",
|
|
656
|
+
"To: list@googlegroups.example",
|
|
657
|
+
"Authentication-Results: mx.google.example; dkim=pass header.d=googlegroups.example; spf=pass smtp.mailfrom=googlegroups.example; dmarc=fail (p=REJECT) header.from=acme.example",
|
|
658
|
+
"List-Id: <list.googlegroups.example>",
|
|
659
|
+
"X-Spam-Status: No, score=0.2",
|
|
660
|
+
]),
|
|
661
|
+
);
|
|
662
|
+
assert.equal(extractAuthenticity(parsed), null);
|
|
663
|
+
assert.equal(inboxVerdict(parsed).action, "leave");
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
it("never lets a prepended header override the raw signing domain", async () => {
|
|
667
|
+
const parsed = await parse(
|
|
668
|
+
withBody([
|
|
669
|
+
"Authentication-Results: anything-i-like; dkim=pass header.d=bank.example",
|
|
670
|
+
"From: Security <security@bank.example>",
|
|
671
|
+
"To: victim@example.com",
|
|
672
|
+
"Subject: Verify your account",
|
|
673
|
+
"DKIM-Signature: v=1; a=rsa-sha256; d=phish-host.example; s=sel; b=xxx",
|
|
674
|
+
"X-Spam-Status: No, score=0.1",
|
|
675
|
+
]),
|
|
676
|
+
);
|
|
677
|
+
assert.deepEqual(extractAuthenticity(parsed), {
|
|
678
|
+
fromDomain: "bank.example",
|
|
679
|
+
dkimDomain: "phish-host.example",
|
|
680
|
+
dkimMismatch: true,
|
|
681
|
+
});
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
it("never lets a prepended header hide the receiving MTA's dmarc=fail", async () => {
|
|
685
|
+
const lines = (order: "forged-first" | "real-first") => {
|
|
686
|
+
const forged =
|
|
687
|
+
"Authentication-Results: whatever; dkim=pass header.d=evil-mimic.example; dmarc=pass header.from=evil-mimic.example";
|
|
688
|
+
const real = "Authentication-Results: mx.example.com; dmarc=fail";
|
|
689
|
+
return withBody([
|
|
690
|
+
...(order === "forged-first" ? [forged, real] : [real, forged]),
|
|
691
|
+
"From: Support <support@evil-mimic.example>",
|
|
692
|
+
"To: me@example.com",
|
|
693
|
+
"Subject: Verify your account",
|
|
694
|
+
"DKIM-Signature: v=1; a=rsa-sha256; d=relay.example.net; s=sel; b=xxx",
|
|
695
|
+
"X-Spam-Status: No, score=0.1",
|
|
696
|
+
]);
|
|
697
|
+
};
|
|
698
|
+
for (const order of ["forged-first", "real-first"] as const) {
|
|
699
|
+
const parsed = await parse(lines(order));
|
|
700
|
+
assert.equal(extractAuthResult(parsed)?.dmarc, "Fail", order);
|
|
701
|
+
assert.equal(inboxVerdict(parsed).action, "move-to-junk", order);
|
|
702
|
+
}
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
it("never lets a sender-added aligned dkim=pass override the receiver's verdict", async () => {
|
|
706
|
+
const receivers = [
|
|
707
|
+
"Authentication-Results: mx.me.example; dkim=fail header.d=phish.example; dmarc=fail header.from=bank.example",
|
|
708
|
+
"Authentication-Results: mx.me.example; dkim=none; dmarc=fail header.from=bank.example",
|
|
709
|
+
];
|
|
710
|
+
const forged = "Authentication-Results: x; dkim=pass header.d=bank.example";
|
|
711
|
+
for (const [receiver, expected] of [
|
|
712
|
+
[receivers[0], "Fail"],
|
|
713
|
+
[receivers[1], "None"],
|
|
714
|
+
] as const) {
|
|
715
|
+
for (const headers of [
|
|
716
|
+
[receiver, forged],
|
|
717
|
+
[forged, receiver],
|
|
718
|
+
]) {
|
|
719
|
+
const parsed = await parse(
|
|
720
|
+
withBody([...headers, "From: B <s@bank.example>"]),
|
|
721
|
+
);
|
|
722
|
+
assert.equal(
|
|
723
|
+
extractAuthResult(parsed)?.dkim,
|
|
724
|
+
expected,
|
|
725
|
+
headers.join(" | "),
|
|
726
|
+
);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
it("never reads dkim=fail out of another method's comment", async () => {
|
|
732
|
+
const parsed = await parse(
|
|
733
|
+
withBody([
|
|
734
|
+
"From: Alice <alice@acme.example>",
|
|
735
|
+
"Authentication-Results: mx.example.net; arc=fail (i=1 spf=fail; dkim=fail",
|
|
736
|
+
" header.d=arcsigner.example; dmarc=fail); spf=pass smtp.mailfrom=acme.example;",
|
|
737
|
+
" dmarc=pass header.from=acme.example",
|
|
738
|
+
]),
|
|
739
|
+
);
|
|
740
|
+
assert.deepEqual(extractAuthResult(parsed), {
|
|
741
|
+
dmarc: "Pass",
|
|
742
|
+
spf: "Pass",
|
|
743
|
+
dkim: undefined,
|
|
744
|
+
});
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
it("reads dmarc from its own resinfo, not from an arc comment", async () => {
|
|
748
|
+
const parsed = await parse(
|
|
749
|
+
withBody([
|
|
750
|
+
"From: Alice <alice@acme.example>",
|
|
751
|
+
"Authentication-Results: gw.example; arc=pass (i=1 dmarc=fail fromdomain=acme.example); dmarc=pass header.from=acme.example",
|
|
752
|
+
]),
|
|
753
|
+
);
|
|
754
|
+
assert.equal(extractAuthResult(parsed)?.dmarc, "Pass");
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
describe("grammar edge cases", () => {
|
|
758
|
+
const dkimFor = async (value: string) =>
|
|
759
|
+
extractAuthResult(
|
|
760
|
+
await parse(
|
|
761
|
+
withBody([
|
|
762
|
+
"From: Alice <alice@acme.example>",
|
|
763
|
+
`Authentication-Results: ${value}`,
|
|
764
|
+
]),
|
|
765
|
+
),
|
|
766
|
+
)?.dkim;
|
|
767
|
+
|
|
768
|
+
it("aligns a signing domain written with a trailing root dot", async () => {
|
|
769
|
+
assert.equal(
|
|
770
|
+
await dkimFor(
|
|
771
|
+
"gw.example; dkim=fail header.d=esp.example; dkim=pass header.d=acme.example.",
|
|
772
|
+
),
|
|
773
|
+
"Pass",
|
|
774
|
+
);
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
it("never aligns a header.i without an @", async () => {
|
|
778
|
+
assert.equal(
|
|
779
|
+
await dkimFor(
|
|
780
|
+
"gw.example; dkim=fail header.d=esp.example; dkim=pass header.i=acme.example",
|
|
781
|
+
),
|
|
782
|
+
"Fail",
|
|
783
|
+
);
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
it("never aligns a result whose header.i falls outside header.d", async () => {
|
|
787
|
+
assert.equal(
|
|
788
|
+
await dkimFor(
|
|
789
|
+
"gw.example; dkim=fail header.d=esp.example; dkim=pass header.d=acme.example header.i=@evil.example",
|
|
790
|
+
),
|
|
791
|
+
"Fail",
|
|
792
|
+
);
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
it("aligns through header.i when header.d is absent", async () => {
|
|
796
|
+
assert.equal(
|
|
797
|
+
await dkimFor(
|
|
798
|
+
"gw.example; dkim=fail header.d=esp.example; dkim=pass header.i=alice@mail.acme.example",
|
|
799
|
+
),
|
|
800
|
+
"Pass",
|
|
801
|
+
);
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
it("reads upper-case tokens", async () => {
|
|
805
|
+
const parsed = await parse(
|
|
806
|
+
withBody([
|
|
807
|
+
"From: Alice <alice@acme.example>",
|
|
808
|
+
"Authentication-Results: GW.EXAMPLE; DKIM=FAIL HEADER.D=EVIL.EXAMPLE; DMARC=FAIL",
|
|
809
|
+
]),
|
|
810
|
+
);
|
|
811
|
+
assert.deepEqual(extractAuthResult(parsed), {
|
|
812
|
+
dmarc: "Fail",
|
|
813
|
+
spf: undefined,
|
|
814
|
+
dkim: "Fail",
|
|
815
|
+
});
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
it("reads a folded header whose continuation starts with dkim=", async () => {
|
|
819
|
+
const parsed = await parse(
|
|
820
|
+
withBody([
|
|
821
|
+
"From: Alice <alice@acme.example>",
|
|
822
|
+
"Authentication-Results: mx.example.com;",
|
|
823
|
+
"\tdkim=pass header.d=acme.example;",
|
|
824
|
+
"\tdmarc=fail header.from=acme.example",
|
|
825
|
+
]),
|
|
826
|
+
);
|
|
827
|
+
assert.deepEqual(extractAuthResult(parsed), {
|
|
828
|
+
dmarc: "Fail",
|
|
829
|
+
spf: undefined,
|
|
830
|
+
dkim: "Pass",
|
|
831
|
+
});
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
it("prefers the aligned result wherever it is listed", async () => {
|
|
835
|
+
assert.equal(
|
|
836
|
+
await dkimFor(
|
|
837
|
+
"gw.example; dkim=fail header.d=esp-mailer.example; dkim=pass header.d=acme.example; dmarc=fail",
|
|
838
|
+
),
|
|
839
|
+
"Pass",
|
|
840
|
+
);
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
it("reads dkim=fail with no domain", async () => {
|
|
844
|
+
assert.equal(await dkimFor("gw.example; dkim=fail; dmarc=fail"), "Fail");
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
it("never reads an unrecognised result as a pass", async () => {
|
|
848
|
+
assert.equal(
|
|
849
|
+
await dkimFor(
|
|
850
|
+
"gw.example; dkim=pass header.d=acme.example; dkim=permerror header.d=acme.example",
|
|
851
|
+
),
|
|
852
|
+
undefined,
|
|
853
|
+
);
|
|
854
|
+
});
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
describe("fail closed", () => {
|
|
858
|
+
it("yields no result for an empty header", async () => {
|
|
859
|
+
const parsed = await parse(
|
|
860
|
+
withBody([
|
|
861
|
+
"From: Alice <alice@acme.example>",
|
|
862
|
+
"Authentication-Results: ",
|
|
863
|
+
]),
|
|
864
|
+
);
|
|
865
|
+
assert.equal(extractAuthResult(parsed), null);
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
it("yields no result for a header that does not parse", async () => {
|
|
869
|
+
const parsed = await parse(
|
|
870
|
+
withBody([
|
|
871
|
+
"From: Alice <alice@acme.example>",
|
|
872
|
+
"Authentication-Results: mx.example; dmarc=pass (unterminated",
|
|
873
|
+
]),
|
|
874
|
+
);
|
|
875
|
+
assert.equal(extractAuthResult(parsed), null);
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
it("ignores an unparseable header beside one that parses", async () => {
|
|
879
|
+
const parsed = await parse(
|
|
880
|
+
withBody([
|
|
881
|
+
"Authentication-Results: forged; dmarc=pass )",
|
|
882
|
+
"From: Alice <alice@acme.example>",
|
|
883
|
+
"Authentication-Results: mx.example.com; dmarc=fail header.from=acme.example",
|
|
884
|
+
]),
|
|
885
|
+
);
|
|
886
|
+
assert.equal(extractAuthResult(parsed)?.dmarc, "Fail");
|
|
887
|
+
});
|
|
888
|
+
});
|
|
889
|
+
});
|
|
@@ -6,6 +6,11 @@ import type {
|
|
|
6
6
|
ParsedMail,
|
|
7
7
|
StructuredHeader,
|
|
8
8
|
} from "mailparser";
|
|
9
|
+
import {
|
|
10
|
+
type AuthenticationResult,
|
|
11
|
+
type AuthenticationResults,
|
|
12
|
+
parseAuthenticationResults,
|
|
13
|
+
} from "./authenticationResults.js";
|
|
9
14
|
import { hasMachineHeader, isMachineLocalPart } from "./machineSenders.js";
|
|
10
15
|
import { SOCIAL_DOMAINS } from "./socialDomains.js";
|
|
11
16
|
import { TRANSACTIONAL_DOMAINS } from "./transactionalDomains.js";
|
|
@@ -139,41 +144,104 @@ export const extractAuthenticity = (
|
|
|
139
144
|
};
|
|
140
145
|
};
|
|
141
146
|
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
147
|
+
const RESULTS_BY_FAVOURABILITY = [
|
|
148
|
+
"fail",
|
|
149
|
+
"softfail",
|
|
150
|
+
"neutral",
|
|
151
|
+
"none",
|
|
152
|
+
"unrecognised",
|
|
153
|
+
"pass",
|
|
154
|
+
] as const;
|
|
155
|
+
|
|
156
|
+
type RankedResult = (typeof RESULTS_BY_FAVOURABILITY)[number];
|
|
157
|
+
|
|
158
|
+
const VERDICT_BY_RESULT: ReadonlyMap<RankedResult, AuthVerdictValue> = new Map([
|
|
159
|
+
["fail", AuthResultVerdict.Fail],
|
|
160
|
+
["softfail", AuthResultVerdict.Softfail],
|
|
161
|
+
["neutral", AuthResultVerdict.Neutral],
|
|
162
|
+
["none", AuthResultVerdict.None],
|
|
163
|
+
["pass", AuthResultVerdict.Pass],
|
|
164
|
+
]);
|
|
165
|
+
|
|
166
|
+
const isRankedResult = (result: string): result is RankedResult =>
|
|
167
|
+
(RESULTS_BY_FAVOURABILITY as readonly string[]).includes(result);
|
|
168
|
+
|
|
169
|
+
const rankOf = (result: string): number =>
|
|
170
|
+
RESULTS_BY_FAVOURABILITY.indexOf(
|
|
171
|
+
isRankedResult(result) ? result : "unrecognised",
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
const leastFavourableVerdict = (
|
|
175
|
+
results: AuthenticationResult[],
|
|
145
176
|
): AuthVerdictValue | undefined => {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
177
|
+
if (results.length === 0) return undefined;
|
|
178
|
+
const worst = Math.min(...results.map((r) => rankOf(r.result)));
|
|
179
|
+
return VERDICT_BY_RESULT.get(RESULTS_BY_FAVOURABILITY[worst]);
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const normaliseDomain = (value: string | undefined): string | null => {
|
|
183
|
+
if (value === undefined) return null;
|
|
184
|
+
const domain = value.trim().toLowerCase().replace(/\.$/, "");
|
|
185
|
+
return domain.length > 0 ? domain : null;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const identityDomain = (value: string | undefined): string | null => {
|
|
189
|
+
if (value === undefined) return null;
|
|
190
|
+
const at = value.lastIndexOf("@");
|
|
191
|
+
if (at < 0) return null;
|
|
192
|
+
return normaliseDomain(value.slice(at + 1));
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const dkimSigningDomain = (result: AuthenticationResult): string | null => {
|
|
196
|
+
const signing = normaliseDomain(result.properties.get("header.d"));
|
|
197
|
+
const hasIdentity = result.properties.has("header.i");
|
|
198
|
+
const identity = identityDomain(result.properties.get("header.i"));
|
|
199
|
+
if (hasIdentity && identity === null) return null;
|
|
200
|
+
if (signing === null) return identity;
|
|
201
|
+
if (identity === null) return signing;
|
|
202
|
+
const identityWithinSigning =
|
|
203
|
+
identity === signing || identity.endsWith(`.${signing}`);
|
|
204
|
+
return identityWithinSigning ? signing : null;
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const dkimResultsForFrom = (
|
|
208
|
+
results: AuthenticationResult[],
|
|
209
|
+
fromDomain: string | null,
|
|
210
|
+
): AuthenticationResult[] => {
|
|
211
|
+
if (!fromDomain) return results;
|
|
212
|
+
const aligned = results.filter((r) => {
|
|
213
|
+
const domain = dkimSigningDomain(r);
|
|
214
|
+
return domain !== null && domainsAligned(domain, fromDomain);
|
|
215
|
+
});
|
|
216
|
+
return aligned.length > 0 ? aligned : results;
|
|
157
217
|
};
|
|
158
218
|
|
|
159
|
-
/**
|
|
160
|
-
* Extract provider authentication-results verdict from the Authentication-Results header.
|
|
161
|
-
* Returns null when the header is absent.
|
|
162
|
-
*/
|
|
163
219
|
export const extractAuthResult = (
|
|
164
220
|
parsed: ParsedMail,
|
|
165
221
|
): MessageAuthResult | null => {
|
|
166
|
-
const
|
|
167
|
-
(l) => l.key.toLowerCase() === "authentication-results"
|
|
168
|
-
|
|
169
|
-
|
|
222
|
+
const headers = parsed.headerLines
|
|
223
|
+
.filter((l) => l.key.toLowerCase() === "authentication-results")
|
|
224
|
+
.map((l) => parseAuthenticationResults(stripHeaderName(l.line)))
|
|
225
|
+
.filter((h): h is AuthenticationResults => h !== null);
|
|
226
|
+
if (headers.length === 0) return null;
|
|
170
227
|
|
|
171
|
-
const
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
228
|
+
const fromDomain = getFromDomain(parsed);
|
|
229
|
+
const byMethod = (
|
|
230
|
+
results: AuthenticationResult[],
|
|
231
|
+
method: string,
|
|
232
|
+
): AuthenticationResult[] => results.filter((r) => r.method === method);
|
|
233
|
+
const acrossHeaders = (method: string): AuthenticationResult[] =>
|
|
234
|
+
headers.flatMap((h) => byMethod(h.results, method));
|
|
175
235
|
|
|
176
|
-
return {
|
|
236
|
+
return {
|
|
237
|
+
dmarc: leastFavourableVerdict(acrossHeaders("dmarc")),
|
|
238
|
+
spf: leastFavourableVerdict(acrossHeaders("spf")),
|
|
239
|
+
dkim: leastFavourableVerdict(
|
|
240
|
+
headers.flatMap((h) =>
|
|
241
|
+
dkimResultsForFrom(byMethod(h.results, "dkim"), fromDomain),
|
|
242
|
+
),
|
|
243
|
+
),
|
|
244
|
+
};
|
|
177
245
|
};
|
|
178
246
|
|
|
179
247
|
/**
|
|
@@ -2,6 +2,7 @@ import assert from "node:assert";
|
|
|
2
2
|
import { describe, it } from "node:test";
|
|
3
3
|
import type {
|
|
4
4
|
IMailboxRepository,
|
|
5
|
+
MailboxItem,
|
|
5
6
|
MailboxTransitionIntent,
|
|
6
7
|
} from "@remit/data-ports";
|
|
7
8
|
import { MailboxSyncStatus } from "@remit/domain-enums";
|
|
@@ -99,7 +100,10 @@ const boxStatus = (path: string): ImapBoxStatus => ({
|
|
|
99
100
|
newKeywords: false,
|
|
100
101
|
});
|
|
101
102
|
|
|
102
|
-
const recordingRepo = (
|
|
103
|
+
const recordingRepo = (
|
|
104
|
+
held: Pick<MailboxItem, "mailboxId" | "fullPath">[] = [],
|
|
105
|
+
) => {
|
|
106
|
+
const removed: string[] = [];
|
|
103
107
|
const updates: Array<{ mailboxId: string; patch: Record<string, unknown> }> =
|
|
104
108
|
[];
|
|
105
109
|
const settles: Array<{
|
|
@@ -108,7 +112,11 @@ const recordingRepo = () => {
|
|
|
108
112
|
}> = [];
|
|
109
113
|
const repo: Pick<
|
|
110
114
|
IMailboxRepository,
|
|
111
|
-
|
|
115
|
+
| "update"
|
|
116
|
+
| "transition"
|
|
117
|
+
| "findByPathPrefix"
|
|
118
|
+
| "findByPath"
|
|
119
|
+
| "deleteMailboxWithMail"
|
|
112
120
|
> = {
|
|
113
121
|
update: async (
|
|
114
122
|
_accountId: string,
|
|
@@ -127,8 +135,13 @@ const recordingRepo = () => {
|
|
|
127
135
|
return {} as never;
|
|
128
136
|
},
|
|
129
137
|
findByPathPrefix: async () => [],
|
|
138
|
+
findByPath: async (_accountId: string, fullPath: string) =>
|
|
139
|
+
(held.find((row) => row.fullPath === fullPath) as MailboxItem) ?? null,
|
|
140
|
+
deleteMailboxWithMail: async (_accountId: string, mailboxId: string) => {
|
|
141
|
+
removed.push(mailboxId);
|
|
142
|
+
},
|
|
130
143
|
};
|
|
131
|
-
return { repo: repo as IMailboxRepository, updates, settles };
|
|
144
|
+
return { repo: repo as IMailboxRepository, updates, settles, removed };
|
|
132
145
|
};
|
|
133
146
|
|
|
134
147
|
const stubConnection = (
|
|
@@ -185,6 +198,38 @@ describe("MailboxManagementService.syncCreate — server path normalization", ()
|
|
|
185
198
|
assert.strictEqual(settles[0].intent.to, MailboxSyncStatus.synced);
|
|
186
199
|
});
|
|
187
200
|
|
|
201
|
+
it("drops the pending row when the prefixed path already has a folder (#318)", async () => {
|
|
202
|
+
const { repo, settles, updates, removed } = recordingRepo([
|
|
203
|
+
{ mailboxId: "mbx-existing", fullPath: "INBOX/Notifications" },
|
|
204
|
+
]);
|
|
205
|
+
const connection = {
|
|
206
|
+
createMailbox: async (_path: string) => ({
|
|
207
|
+
path: "INBOX/Notifications",
|
|
208
|
+
created: false,
|
|
209
|
+
}),
|
|
210
|
+
subscribeMailbox: async () => undefined,
|
|
211
|
+
listMailboxes: async (): Promise<FlatMailboxInfo[]> => [],
|
|
212
|
+
openBox: async (path: string) => boxStatus(path),
|
|
213
|
+
closeBox: async () => undefined,
|
|
214
|
+
} as unknown as IImapConnection;
|
|
215
|
+
const service = new MailboxManagementService(repo);
|
|
216
|
+
|
|
217
|
+
const result = await service.syncCreate(
|
|
218
|
+
"acc-1",
|
|
219
|
+
"mbx-recreated",
|
|
220
|
+
"Notifications",
|
|
221
|
+
async () => connection,
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
assert.deepStrictEqual(result, {
|
|
225
|
+
success: true,
|
|
226
|
+
existing: { mailboxId: "mbx-existing", fullPath: "INBOX/Notifications" },
|
|
227
|
+
});
|
|
228
|
+
assert.deepStrictEqual(removed, ["mbx-recreated"]);
|
|
229
|
+
assert.deepStrictEqual(settles, []);
|
|
230
|
+
assert.deepStrictEqual(updates, []);
|
|
231
|
+
});
|
|
232
|
+
|
|
188
233
|
it("leaves the path untouched when the server keeps it as requested", async () => {
|
|
189
234
|
const { repo, settles } = recordingRepo();
|
|
190
235
|
const { connection } = stubConnection("Work", ["Work"]);
|
|
@@ -48,6 +48,7 @@ export interface MailboxManagementSyncResult {
|
|
|
48
48
|
* which is how a caller tells the two apart without a second read.
|
|
49
49
|
*/
|
|
50
50
|
renamed?: { oldPath: string; newPath: string; delimiter: string };
|
|
51
|
+
existing?: { mailboxId: string; fullPath: string };
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
/**
|
|
@@ -304,6 +305,30 @@ export class MailboxManagementService {
|
|
|
304
305
|
this.log.info({ mailboxId, path: serverPath }, "Subscribed to mailbox");
|
|
305
306
|
}
|
|
306
307
|
|
|
308
|
+
const holder =
|
|
309
|
+
confirmedPath === undefined
|
|
310
|
+
? null
|
|
311
|
+
: await this.mailboxService.findByPath(accountId, confirmedPath);
|
|
312
|
+
if (holder && holder.mailboxId !== mailboxId) {
|
|
313
|
+
await this.mailboxService.deleteMailboxWithMail(accountId, mailboxId);
|
|
314
|
+
this.log.info(
|
|
315
|
+
{
|
|
316
|
+
accountId,
|
|
317
|
+
mailboxId,
|
|
318
|
+
intent: "create",
|
|
319
|
+
path,
|
|
320
|
+
serverPath,
|
|
321
|
+
existingMailboxId: holder.mailboxId,
|
|
322
|
+
outcome: "already-exists",
|
|
323
|
+
},
|
|
324
|
+
"Folder already exists at the path the server resolved",
|
|
325
|
+
);
|
|
326
|
+
return {
|
|
327
|
+
success: true,
|
|
328
|
+
existing: { mailboxId: holder.mailboxId, fullPath: holder.fullPath },
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
307
332
|
// Refresh mailbox list to get UIDVALIDITY and other attributes
|
|
308
333
|
const mailboxes = await connection.listMailboxes();
|
|
309
334
|
const mailboxInfo = mailboxes.find((m) => m.fullPath === serverPath);
|
package/src/mailbox-queue.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { SendMessageCommand, type SQSClient } from "@aws-sdk/client-sqs";
|
|
3
3
|
import type {
|
|
4
4
|
CreateMailboxInput,
|
|
5
|
+
CreateMailboxResult,
|
|
5
6
|
IMailboxRepository,
|
|
6
7
|
MailboxItem,
|
|
7
8
|
} from "@remit/data-ports";
|
|
@@ -120,18 +121,19 @@ export class MailboxQueueService {
|
|
|
120
121
|
* @param input - The mailbox creation input (without syncStatus)
|
|
121
122
|
* @param accountId - The account ID for the IMAP sync event
|
|
122
123
|
* @param subscribe - Whether to subscribe to the mailbox after creation
|
|
123
|
-
* @returns The created mailbox
|
|
124
|
+
* @returns The created mailbox, or `PathTaken` when the account already has a folder at that path
|
|
124
125
|
*/
|
|
125
126
|
createMailbox = async (
|
|
126
127
|
input: CreateMailboxQueueInput,
|
|
127
128
|
accountId: string,
|
|
128
129
|
subscribe?: boolean,
|
|
129
|
-
): Promise<
|
|
130
|
-
|
|
131
|
-
const mailbox = await this.mailboxService.create({
|
|
130
|
+
): Promise<CreateMailboxResult> => {
|
|
131
|
+
const created = await this.mailboxService.create({
|
|
132
132
|
...input,
|
|
133
133
|
syncStatus: MailboxSyncStatus.pending,
|
|
134
134
|
});
|
|
135
|
+
if (created.outcome === "PathTaken") return created;
|
|
136
|
+
const { mailbox } = created;
|
|
135
137
|
|
|
136
138
|
this.log.info(
|
|
137
139
|
{ mailboxId: mailbox.mailboxId, path: mailbox.fullPath },
|
|
@@ -149,7 +151,7 @@ export class MailboxQueueService {
|
|
|
149
151
|
subscribe,
|
|
150
152
|
});
|
|
151
153
|
|
|
152
|
-
return
|
|
154
|
+
return created;
|
|
153
155
|
};
|
|
154
156
|
|
|
155
157
|
/**
|
package/src/mailbox-sync.test.ts
CHANGED
|
@@ -13,7 +13,11 @@ import {
|
|
|
13
13
|
import { parseImapAttributes } from "./attribute-mapper.js";
|
|
14
14
|
import { type MailboxSyncLogger, MailboxSyncService } from "./mailbox-sync.js";
|
|
15
15
|
|
|
16
|
-
const silentLogger: MailboxSyncLogger = {
|
|
16
|
+
const silentLogger: MailboxSyncLogger = {
|
|
17
|
+
info: () => {},
|
|
18
|
+
debug: () => {},
|
|
19
|
+
error: () => {},
|
|
20
|
+
};
|
|
17
21
|
|
|
18
22
|
import type { IImapConnection, ImapNamespaces } from "./types.js";
|
|
19
23
|
|
|
@@ -110,7 +114,10 @@ describe("MailboxSyncService.syncMailboxes — UIDVALIDITY cursor detection (#12
|
|
|
110
114
|
return {};
|
|
111
115
|
},
|
|
112
116
|
delete: async () => undefined,
|
|
113
|
-
create: async () => ({
|
|
117
|
+
create: async () => ({
|
|
118
|
+
outcome: "Created",
|
|
119
|
+
mailbox: { mailboxId: "new" },
|
|
120
|
+
}),
|
|
114
121
|
} as unknown as IMailboxRepository;
|
|
115
122
|
|
|
116
123
|
const specialUseService = {
|
|
@@ -257,6 +264,7 @@ describe("MailboxSyncService.syncMailboxes — reconcile does not delete pending
|
|
|
257
264
|
syncStatus?: string;
|
|
258
265
|
pendingPath?: string;
|
|
259
266
|
}>,
|
|
267
|
+
createOutcome: "Created" | "PathTaken" = "Created",
|
|
260
268
|
) => {
|
|
261
269
|
const deleted: string[] = [];
|
|
262
270
|
const created: Array<Record<string, unknown>> = [];
|
|
@@ -283,7 +291,11 @@ describe("MailboxSyncService.syncMailboxes — reconcile does not delete pending
|
|
|
283
291
|
},
|
|
284
292
|
create: async (input: Record<string, unknown>) => {
|
|
285
293
|
created.push(input);
|
|
286
|
-
return {
|
|
294
|
+
if (createOutcome === "PathTaken") return { outcome: "PathTaken" };
|
|
295
|
+
return {
|
|
296
|
+
outcome: "Created",
|
|
297
|
+
mailbox: { ...input, mailboxId: `new-${created.length}` },
|
|
298
|
+
};
|
|
287
299
|
},
|
|
288
300
|
} as unknown as IMailboxRepository;
|
|
289
301
|
|
|
@@ -401,6 +413,35 @@ describe("MailboxSyncService.syncMailboxes — reconcile does not delete pending
|
|
|
401
413
|
assert.deepEqual(deleted, []);
|
|
402
414
|
});
|
|
403
415
|
|
|
416
|
+
it("counts no insert and keeps the account sweep going when the path is already taken", async () => {
|
|
417
|
+
const { mailboxService, specialUseService, created } = buildServices(
|
|
418
|
+
[
|
|
419
|
+
{
|
|
420
|
+
mailboxId: "inbox",
|
|
421
|
+
fullPath: "INBOX",
|
|
422
|
+
syncStatus: MailboxSyncStatus.synced,
|
|
423
|
+
},
|
|
424
|
+
],
|
|
425
|
+
"PathTaken",
|
|
426
|
+
);
|
|
427
|
+
const errors: Array<Record<string, unknown>> = [];
|
|
428
|
+
const service = new MailboxSyncService(mailboxService, specialUseService, {
|
|
429
|
+
...silentLogger,
|
|
430
|
+
error: (obj) => {
|
|
431
|
+
errors.push(obj);
|
|
432
|
+
},
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
const result = await service.syncMailboxes(
|
|
436
|
+
{ accountId: "acc-1" },
|
|
437
|
+
serverConnection([{ fullPath: "INBOX" }, { fullPath: "Projects" }]),
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
assert.equal(created.length, 1);
|
|
441
|
+
assert.equal(result.created, 0);
|
|
442
|
+
assert.deepEqual(errors, [{ accountId: "acc-1", fullPath: "Projects" }]);
|
|
443
|
+
});
|
|
444
|
+
|
|
404
445
|
it("lets a failed rename's target be discovered as a folder of its own", async () => {
|
|
405
446
|
// A failed rename keeps its target so the UI can name what it was aiming
|
|
406
447
|
// at (T6), and nothing clears it until the user retries or dismisses. If
|
|
@@ -539,7 +580,10 @@ describe("MailboxSyncService.syncMailboxes — a lookalike is not a folder the u
|
|
|
539
580
|
delete: async (_accountId: string, mailboxId: string) => {
|
|
540
581
|
deleted.push(mailboxId);
|
|
541
582
|
},
|
|
542
|
-
create: async () => ({
|
|
583
|
+
create: async () => ({
|
|
584
|
+
outcome: "Created",
|
|
585
|
+
mailbox: { mailboxId: "new" },
|
|
586
|
+
}),
|
|
543
587
|
} as unknown as IMailboxRepository;
|
|
544
588
|
|
|
545
589
|
const specialUseService = {
|
|
@@ -717,7 +761,10 @@ describe("MailboxSyncService.syncMailboxes — a folder leaving mid-sweep (#339)
|
|
|
717
761
|
return {};
|
|
718
762
|
},
|
|
719
763
|
delete: async () => undefined,
|
|
720
|
-
create: async () => ({
|
|
764
|
+
create: async () => ({
|
|
765
|
+
outcome: "Created",
|
|
766
|
+
mailbox: { mailboxId: "new" },
|
|
767
|
+
}),
|
|
721
768
|
} as unknown as IMailboxRepository;
|
|
722
769
|
|
|
723
770
|
const specialUseService = {
|
package/src/mailbox-sync.ts
CHANGED
|
@@ -82,6 +82,7 @@ export interface SyncAccountInfo {
|
|
|
82
82
|
export interface MailboxSyncLogger {
|
|
83
83
|
info(obj: Record<string, unknown>, msg: string): void;
|
|
84
84
|
debug(obj: Record<string, unknown>, msg: string): void;
|
|
85
|
+
error(obj: Record<string, unknown>, msg: string): void;
|
|
85
86
|
}
|
|
86
87
|
|
|
87
88
|
/**
|
|
@@ -263,13 +264,13 @@ export class MailboxSyncService {
|
|
|
263
264
|
result.updated++;
|
|
264
265
|
}
|
|
265
266
|
} else if (!claimedByPendingRename.has(mailboxInfo.fullPath)) {
|
|
266
|
-
await this.createMailbox(
|
|
267
|
+
const created = await this.createMailbox(
|
|
267
268
|
account.accountId,
|
|
268
269
|
mailboxInfo,
|
|
269
270
|
namespaces,
|
|
270
271
|
connection,
|
|
271
272
|
);
|
|
272
|
-
result.created++;
|
|
273
|
+
if (created) result.created++;
|
|
273
274
|
}
|
|
274
275
|
},
|
|
275
276
|
{ concurrency: 3 },
|
|
@@ -432,7 +433,7 @@ export class MailboxSyncService {
|
|
|
432
433
|
},
|
|
433
434
|
_namespaces: ImapNamespaces,
|
|
434
435
|
connection: IImapConnection,
|
|
435
|
-
): Promise<MailboxItem> => {
|
|
436
|
+
): Promise<MailboxItem | null> => {
|
|
436
437
|
// Fetch mailbox status using STATUS command (doesn't require SELECT/EXAMINE)
|
|
437
438
|
// This gets us message counts including unseen without opening the mailbox
|
|
438
439
|
const status = await connection.getMailboxStatus(mailboxInfo.fullPath);
|
|
@@ -472,7 +473,15 @@ export class MailboxSyncService {
|
|
|
472
473
|
// parentMailboxId would need to be resolved from parentPath
|
|
473
474
|
};
|
|
474
475
|
|
|
475
|
-
const
|
|
476
|
+
const created = await this.mailboxService.create(input);
|
|
477
|
+
if (created.outcome === "PathTaken") {
|
|
478
|
+
this.log.error(
|
|
479
|
+
{ accountId, fullPath: mailboxInfo.fullPath },
|
|
480
|
+
"Sweep found no row for a folder path the account already holds",
|
|
481
|
+
);
|
|
482
|
+
return null;
|
|
483
|
+
}
|
|
484
|
+
const { mailbox } = created;
|
|
476
485
|
|
|
477
486
|
// Keep the MailboxSpecialUseEntry table in sync — every backend and worker
|
|
478
487
|
// special-folder lookup reads it through MailboxSpecialUseRepo. Denormalized
|