@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/mailbox-service",
3
- "version": "0.0.100",
3
+ "version": "0.0.102",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -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 { MessageCategory } from "@remit/domain-enums";
4
- import { simpleParser } from "mailparser";
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 extractVerdict = (
143
- text: string,
144
- mechanism: string,
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
- const match = text.match(new RegExp(`${mechanism}=(\\w+)`, "i"));
147
- if (!match) return undefined;
148
- const raw = match[1].toLowerCase();
149
- const map: Record<string, AuthVerdictValue> = {
150
- pass: AuthResultVerdict.Pass,
151
- fail: AuthResultVerdict.Fail,
152
- none: AuthResultVerdict.None,
153
- neutral: AuthResultVerdict.Neutral,
154
- softfail: AuthResultVerdict.Softfail,
155
- };
156
- return map[raw];
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 line = parsed.headerLines.find(
167
- (l) => l.key.toLowerCase() === "authentication-results",
168
- );
169
- if (!line) return null;
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 text = stripHeaderName(line.line);
172
- const dmarc = extractVerdict(text, "dmarc");
173
- const spf = extractVerdict(text, "spf");
174
- const dkim = extractVerdict(text, "dkim");
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 { dmarc, spf, dkim };
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
- "update" | "transition" | "findByPathPrefix"
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);
@@ -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<MailboxItem> => {
130
- // Create local mailbox with pending status
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 mailbox;
154
+ return created;
153
155
  };
154
156
 
155
157
  /**
@@ -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 = { info: () => {}, debug: () => {} };
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 { ...input, mailboxId: `new-${created.length}` };
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 = {
@@ -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 mailbox = await this.mailboxService.create(input);
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