@open-workspace/email 0.0.0-stage → 0.1.1

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.
Files changed (96) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +29 -2
  3. package/alchemy.ts +126 -0
  4. package/containers/office-preview/Dockerfile +10 -0
  5. package/index.ts +3 -0
  6. package/migrations/0000_email.sql +53 -0
  7. package/migrations/meta/0000_snapshot.json +408 -0
  8. package/migrations/meta/_journal.json +13 -0
  9. package/package.json +91 -4
  10. package/shared/attachment-preview.ts +67 -0
  11. package/shared/mail-authentication.ts +145 -0
  12. package/shared/mail-feedback.ts +90 -0
  13. package/shared/mail-headers.ts +250 -0
  14. package/shared/mail-received.ts +298 -0
  15. package/shared/mail-remote.ts +17 -0
  16. package/shared/mail-rules.ts +156 -0
  17. package/shared/mail-sender-intel.ts +125 -0
  18. package/shared/mail-translation.ts +2 -0
  19. package/shared/mail.ts +179 -0
  20. package/shared/office-preview.ts +26 -0
  21. package/worker/admin/report-routes.ts +164 -0
  22. package/worker/bindings.d.ts +11 -0
  23. package/worker/db/schema.ts +97 -0
  24. package/worker/mail/access.ts +30 -0
  25. package/worker/mail/ai-classification.ts +119 -0
  26. package/worker/mail/ai-translation.ts +31 -0
  27. package/worker/mail/auth-headers.ts +557 -0
  28. package/worker/mail/bimi.ts +172 -0
  29. package/worker/mail/classification-signals.ts +116 -0
  30. package/worker/mail/conversation-cursor.ts +43 -0
  31. package/worker/mail/delivery-events.ts +99 -0
  32. package/worker/mail/delivery-facts.ts +122 -0
  33. package/worker/mail/demo-routes.ts +33 -0
  34. package/worker/mail/doh.ts +74 -0
  35. package/worker/mail/downloads.ts +47 -0
  36. package/worker/mail/header-metadata.ts +77 -0
  37. package/worker/mail/impersonation.ts +175 -0
  38. package/worker/mail/inbound-delivery.ts +22 -0
  39. package/worker/mail/inbound-threading.ts +48 -0
  40. package/worker/mail/inbound.ts +255 -0
  41. package/worker/mail/ip-intel.ts +135 -0
  42. package/worker/mail/link-domains.ts +57 -0
  43. package/worker/mail/mailbox-deletion.ts +18 -0
  44. package/worker/mail/mailbox-directory.ts +73 -0
  45. package/worker/mail/mailing-list.ts +120 -0
  46. package/worker/mail/message-security.ts +190 -0
  47. package/worker/mail/message-view.ts +63 -0
  48. package/worker/mail/office-conversion.ts +38 -0
  49. package/worker/mail/office-preview-container.ts +57 -0
  50. package/worker/mail/office-preview-queue.ts +49 -0
  51. package/worker/mail/org-domain.ts +15 -0
  52. package/worker/mail/outbound-content.ts +208 -0
  53. package/worker/mail/outbound-delivery.ts +83 -0
  54. package/worker/mail/outbound-service.ts +145 -0
  55. package/worker/mail/outbound-threading.ts +70 -0
  56. package/worker/mail/outbound.ts +446 -0
  57. package/worker/mail/outgoing-context.ts +122 -0
  58. package/worker/mail/participants.ts +115 -0
  59. package/worker/mail/push-notifications.ts +484 -0
  60. package/worker/mail/quote-parser.ts +50 -0
  61. package/worker/mail/r2-presigned-urls.ts +69 -0
  62. package/worker/mail/receive.ts +59 -0
  63. package/worker/mail/recipients.ts +34 -0
  64. package/worker/mail/remote-proxy.ts +140 -0
  65. package/worker/mail/reply-plan.ts +136 -0
  66. package/worker/mail/reported-messages.ts +68 -0
  67. package/worker/mail/rfc.ts +73 -0
  68. package/worker/mail/routes.ts +1434 -0
  69. package/worker/mail/safety-routes.ts +350 -0
  70. package/worker/mail/schemas.ts +339 -0
  71. package/worker/mail/search.ts +14 -0
  72. package/worker/mail/security-settings.ts +38 -0
  73. package/worker/mail/sender-intel.ts +189 -0
  74. package/worker/mail/spamhaus.ts +97 -0
  75. package/worker/mail/unsubscribe.ts +118 -0
  76. package/worker/mail/uploads.ts +537 -0
  77. package/worker/mail/webhook-source.ts +71 -0
  78. package/worker/mail/workspace-safety.ts +92 -0
  79. package/worker/mailbox/demo-html.ts +136 -0
  80. package/worker/mailbox/demo-seed.ts +79 -0
  81. package/worker/mailbox/demo.ts +978 -0
  82. package/worker/mailbox/folder-model.ts +72 -0
  83. package/worker/mailbox/index.ts +2508 -0
  84. package/worker/mailbox/legacy-migrations.ts +41 -0
  85. package/worker/mailbox/migrations.ts +10 -0
  86. package/worker/mailbox/model.ts +65 -0
  87. package/worker/mailbox/office-preview-jobs.ts +73 -0
  88. package/worker/mailbox/rules.ts +110 -0
  89. package/worker/mailbox/schema.ts +353 -0
  90. package/worker/mailbox/senders.ts +240 -0
  91. package/worker/mcp/mail-action-tools.ts +210 -0
  92. package/worker/mcp/mail-compose-tools.ts +196 -0
  93. package/worker/mcp/mail-query-tools.ts +196 -0
  94. package/worker/mcp/mail-safety-tools.ts +452 -0
  95. package/worker/mcp/tools.ts +15 -0
  96. package/worker/module.ts +59 -0
@@ -0,0 +1,557 @@
1
+ import {
2
+ authResults,
3
+ type ArcSealer,
4
+ type AuthResult,
5
+ type DkimResult,
6
+ type DmarcPolicy,
7
+ type InboundAuthentication,
8
+ type ListUnsubscribe,
9
+ type SenderVerdict,
10
+ type SenderVerdictReason,
11
+ type UpstreamAuthenticationClaim,
12
+ } from "../../shared/mail-authentication";
13
+ import {
14
+ addressDomain,
15
+ headerFields,
16
+ scanComments,
17
+ splitTopLevel,
18
+ unquote,
19
+ type HeaderField,
20
+ } from "../../shared/mail-headers";
21
+ import {
22
+ isCloudflareHop,
23
+ normalizeIp,
24
+ parseReceived,
25
+ } from "../../shared/mail-received";
26
+ import { relaxedAligned } from "./org-domain";
27
+
28
+ export const CLOUDFLARE_AUTHSERV_ID = "mx.cloudflare.net";
29
+ const CLOUDFLARE_ARC_DOMAIN = "cloudflare-email.net";
30
+ const MAX_TRUSTED_HEADER_LENGTH = 4_096;
31
+ /** Cloudflare prepends 7 headers; the cap only matters when the score header is missing. */
32
+ const MAX_TRUSTED_BLOCK_WITHOUT_SCORE = 10;
33
+ const MAX_UPSTREAM_CLAIMS = 5;
34
+
35
+ /**
36
+ * Sealers whose ARC results we accept to soften a DMARC failure on
37
+ * forwarded or list mail, as Gmail and Microsoft do. Cloudflare validates the
38
+ * chain itself; this list only decides how much the oldest results matter.
39
+ */
40
+ const TRUSTED_ARC_SEALERS = new Set([
41
+ "google.com",
42
+ "microsoft.com",
43
+ "outlook.com",
44
+ "icloud.com",
45
+ "yahoo.com",
46
+ "messagingengine.com",
47
+ "zoho.com",
48
+ "protonmail.ch",
49
+ "fastmail.com",
50
+ ]);
51
+
52
+ // --- RFC 8601 Authentication-Results --------------------------------------
53
+
54
+ export type AuthResInfo = {
55
+ method: string;
56
+ result: string;
57
+ reason: string | null;
58
+ comments: string[];
59
+ /** `ptype.property` (lowercased) to value. */
60
+ props: Record<string, string>;
61
+ };
62
+
63
+ export type ParsedAuthenticationResults = {
64
+ authservId: string;
65
+ results: AuthResInfo[];
66
+ };
67
+
68
+ const VALUE = String.raw`"(?:[^"\\]|\\.)*"|[^\s;]+`;
69
+
70
+ function parseResInfo(segment: string): AuthResInfo | null {
71
+ const { text, comments } = scanComments(segment);
72
+ const method = /^\s*([a-z0-9_-]+)(?:\/\d+)?\s*=\s*([a-z0-9_-]+)/iu.exec(text);
73
+ if (!method) return null;
74
+ let rest = text.slice(method[0].length);
75
+ let reason: string | null = null;
76
+ const reasonMatch = new RegExp(String.raw`\breason\s*=\s*(${VALUE})`, "iu").exec(rest);
77
+ if (reasonMatch) {
78
+ reason = unquote(reasonMatch[1]!);
79
+ rest = rest.slice(0, reasonMatch.index) + rest.slice(reasonMatch.index + reasonMatch[0].length);
80
+ }
81
+ const props: Record<string, string> = {};
82
+ for (const match of rest.matchAll(new RegExp(String.raw`([a-z0-9_-]+)\s*\.\s*([a-z0-9_-]+)\s*=\s*(${VALUE})`, "giu"))) {
83
+ const key = `${match[1]}.${match[2]}`.toLocaleLowerCase();
84
+ props[key] ??= unquote(match[3]!);
85
+ }
86
+ return {
87
+ method: method[1]!.toLocaleLowerCase(),
88
+ result: method[2]!.toLocaleLowerCase(),
89
+ reason,
90
+ comments,
91
+ props,
92
+ };
93
+ }
94
+
95
+ /** Parses an Authentication-Results value (RFC 8601 §2.2), comments and all. */
96
+ export function parseAuthenticationResults(value: string): ParsedAuthenticationResults | null {
97
+ const segments = splitTopLevel(value, ";");
98
+ const authservId = scanComments(segments[0] ?? "").text.trim().split(/\s+/u)[0]?.toLocaleLowerCase();
99
+ if (!authservId) return null;
100
+ return {
101
+ authservId,
102
+ results: segments.slice(1).flatMap((segment) => {
103
+ const info = parseResInfo(segment);
104
+ return info ? [info] : [];
105
+ }),
106
+ };
107
+ }
108
+
109
+ /** ARC-Authentication-Results carries `i=N;` before the A-R payload. */
110
+ export function parseArcAuthenticationResults(value: string) {
111
+ const instance = /^\s*i\s*=\s*(\d+)\s*;/u.exec(value);
112
+ if (!instance) return null;
113
+ const parsed = parseAuthenticationResults(value.slice(instance[0].length));
114
+ return parsed ? { instance: Number(instance[1]), ...parsed } : null;
115
+ }
116
+
117
+ // --- RFC 7208 Received-SPF -------------------------------------------------
118
+
119
+ export type ParsedReceivedSpf = {
120
+ result: string;
121
+ comment: string | null;
122
+ pairs: Record<string, string>;
123
+ };
124
+
125
+ export function parseReceivedSpf(value: string): ParsedReceivedSpf | null {
126
+ const result = /^\s*([a-z]+)/iu.exec(value);
127
+ if (!result) return null;
128
+ const { text, comments } = scanComments(value.slice(result[0].length));
129
+ const pairs: Record<string, string> = {};
130
+ for (const part of splitTopLevel(text, ";")) {
131
+ const pair = new RegExp(String.raw`^\s*([a-z-]+)\s*=\s*(${VALUE})\s*$`, "iu").exec(part);
132
+ if (pair) pairs[pair[1]!.toLocaleLowerCase()] ??= unquote(pair[2]!);
133
+ }
134
+ return {
135
+ result: result[1]!.toLocaleLowerCase(),
136
+ comment: comments[0] ?? null,
137
+ pairs,
138
+ };
139
+ }
140
+
141
+ // --- Tag lists (DKIM-Signature, ARC-Seal) ----------------------------------
142
+
143
+ export function parseTagList(value: string) {
144
+ const tags: Record<string, string> = {};
145
+ for (const part of value.split(";")) {
146
+ const equals = part.indexOf("=");
147
+ if (equals < 1) continue;
148
+ const name = part.slice(0, equals).trim().toLocaleLowerCase();
149
+ if (!/^[a-z][a-z0-9_]*$/u.test(name) || name in tags) continue;
150
+ tags[name] = part.slice(equals + 1).replace(/\s+/gu, name === "h" ? "" : " ").trim();
151
+ }
152
+ return tags;
153
+ }
154
+
155
+ type DkimSignatureTags = { domain: string; selector: string | null; b: string; signedHeaders: string[] };
156
+
157
+ function dkimSignatureTags(fields: HeaderField[]): DkimSignatureTags[] {
158
+ return fields.flatMap((field) => {
159
+ if (field.name !== "dkim-signature") return [];
160
+ const tags = parseTagList(field.value);
161
+ const domain = addressDomain(tags.d);
162
+ if (!domain) return [];
163
+ return [{
164
+ domain,
165
+ selector: tags.s?.trim() ?? null,
166
+ b: (tags.b ?? "").replace(/\s+/gu, ""),
167
+ signedHeaders: (tags.h ?? "")
168
+ .split(":")
169
+ .map((name) => name.trim().toLocaleLowerCase())
170
+ .filter(Boolean),
171
+ }];
172
+ });
173
+ }
174
+
175
+ // --- Trusted block --------------------------------------------------------
176
+
177
+ /**
178
+ * The run of headers Cloudflare prepended: from its Received line down to and
179
+ * including X-CF-SpamH-Score. Everything below came from the sending side.
180
+ */
181
+ export function trustedHeaderBlock(fields: HeaderField[]) {
182
+ const first = fields[0];
183
+ if (first?.name !== "received" || !isCloudflareHop(parseReceived(first.value))) return null;
184
+ const score = fields.findIndex((field) => field.name === "x-cf-spamh-score");
185
+ if (score >= 0) return { trusted: fields.slice(0, score + 1), below: fields.slice(score + 1) };
186
+ const nextReceived = fields.findIndex((field, index) => index > 0 && field.name === "received");
187
+ const end = Math.min(
188
+ nextReceived >= 0 ? nextReceived : fields.length,
189
+ MAX_TRUSTED_BLOCK_WITHOUT_SCORE,
190
+ );
191
+ return { trusted: fields.slice(0, end), below: fields.slice(end) };
192
+ }
193
+
194
+ function authResult(value: string | null | undefined): AuthResult | null {
195
+ return authResults.find((result) => result === value) ?? null;
196
+ }
197
+
198
+ function claimFrom(parsed: ParsedAuthenticationResults): UpstreamAuthenticationClaim {
199
+ const first = (method: string) =>
200
+ authResult(parsed.results.find((result) =>
201
+ result.method === method && (method !== "spf" || !result.props["smtp.helo"])
202
+ )?.result);
203
+ return {
204
+ authservId: parsed.authservId,
205
+ spf: first("spf"),
206
+ dkim: first("dkim"),
207
+ dmarc: first("dmarc"),
208
+ };
209
+ }
210
+
211
+ function dmarcPolicy(value: string | undefined): DmarcPolicy | null {
212
+ const normalized = value?.toLocaleLowerCase();
213
+ return normalized === "none" || normalized === "quarantine" || normalized === "reject"
214
+ ? normalized
215
+ : null;
216
+ }
217
+
218
+ function matchSignature(result: AuthResInfo, domain: string, signatures: DkimSignatureTags[]) {
219
+ const bPrefix = result.props["header.b"]?.replace(/\s+/gu, "");
220
+ const selector = result.props["header.s"];
221
+ const sameDomain = (signature: DkimSignatureTags) =>
222
+ signature.domain === domain || domain.endsWith(`.${signature.domain}`);
223
+ if (bPrefix) {
224
+ const match = signatures.find((signature) => sameDomain(signature) && signature.b.startsWith(bPrefix));
225
+ if (match) return match;
226
+ }
227
+ return signatures.find((signature) =>
228
+ sameDomain(signature) && (!selector || signature.selector === selector)
229
+ ) ?? null;
230
+ }
231
+
232
+ function verdictFor(input: Pick<InboundAuthentication, "spf" | "dkim" | "dmarc" | "arc">): {
233
+ verdict: SenderVerdict;
234
+ verdictReason: SenderVerdictReason;
235
+ } {
236
+ const alignedDkimPass = input.dkim.some((dkim) => dkim.result === "pass" && dkim.aligned);
237
+ const alignedSpfPass = input.spf?.result === "pass" && input.spf.aligned;
238
+ if (input.dmarc?.result === "pass") return { verdict: "verified", verdictReason: "dmarc_pass" };
239
+ const failing = input.dmarc?.result === "fail"
240
+ ? "dmarc_fail" as const
241
+ : !alignedDkimPass && !alignedSpfPass
242
+ ? "no_aligned_pass" as const
243
+ : null;
244
+ if (failing) {
245
+ const arc = input.arc;
246
+ const oldestSealer = arc?.sealers[0]?.domain;
247
+ if (
248
+ arc?.result === "pass"
249
+ && arc.oldest?.dmarc === "pass"
250
+ && oldestSealer
251
+ && TRUSTED_ARC_SEALERS.has(oldestSealer)
252
+ ) {
253
+ return { verdict: "unverified", verdictReason: "forwarded_arc" };
254
+ }
255
+ return { verdict: "suspicious", verdictReason: failing };
256
+ }
257
+ if (alignedDkimPass && (!input.dmarc || input.dmarc.result === "none")) {
258
+ return { verdict: "verified", verdictReason: "aligned_dkim" };
259
+ }
260
+ return { verdict: "unverified", verdictReason: "partial" };
261
+ }
262
+
263
+ function emptyAuthentication(
264
+ now: number,
265
+ fromDomain: string | null,
266
+ reason: "not_cloudflare" | "missing_results",
267
+ envelopeFrom: string | null,
268
+ ): InboundAuthentication {
269
+ return {
270
+ source: "headers",
271
+ version: 1,
272
+ authservId: null,
273
+ parsedAt: now,
274
+ remoteIp: null,
275
+ helo: null,
276
+ envelopeFrom,
277
+ fromDomain,
278
+ spf: null,
279
+ spfHelo: null,
280
+ dkim: [],
281
+ dmarc: null,
282
+ arc: null,
283
+ spamScore: null,
284
+ verdict: "unavailable",
285
+ verdictReason: reason,
286
+ upstream: [],
287
+ ignoredCloudflareClaims: 0,
288
+ trustedHeader: null,
289
+ };
290
+ }
291
+
292
+ function claimsCloudflare(field: HeaderField) {
293
+ if (field.name === "authentication-results") {
294
+ return parseAuthenticationResults(field.value)?.authservId === CLOUDFLARE_AUTHSERV_ID;
295
+ }
296
+ if (field.name === "arc-authentication-results") {
297
+ return parseArcAuthenticationResults(field.value)?.authservId === CLOUDFLARE_AUTHSERV_ID;
298
+ }
299
+ if (field.name === "received-spf") {
300
+ return parseReceivedSpf(field.value)?.pairs.receiver?.toLocaleLowerCase() === CLOUDFLARE_AUTHSERV_ID;
301
+ }
302
+ if (field.name === "x-cf-spamh-score") return true;
303
+ return false;
304
+ }
305
+
306
+ function arcSummary(
307
+ trusted: HeaderField[],
308
+ all: HeaderField[],
309
+ arcResult: AuthResInfo | undefined,
310
+ ): InboundAuthentication["arc"] {
311
+ const ownSeal = trusted.find((field) =>
312
+ field.name === "arc-seal" && addressDomain(parseTagList(field.value).d) === CLOUDFLARE_ARC_DOMAIN
313
+ );
314
+ const ownInstance = Number(ownSeal ? parseTagList(ownSeal.value).i : Number.NaN);
315
+ const result = arcResult?.result === "pass" || arcResult?.result === "fail"
316
+ ? arcResult.result
317
+ : "none";
318
+ if (!arcResult && !ownSeal) return null;
319
+ const chainLength = Number.isInteger(ownInstance) && ownInstance > 0 ? ownInstance : 1;
320
+ const sealers = new Map<number, ArcSealer>();
321
+ for (const field of all) {
322
+ if (field.name !== "arc-seal") continue;
323
+ const tags = parseTagList(field.value);
324
+ const instance = Number(tags.i);
325
+ if (!Number.isInteger(instance) || instance < 1 || instance >= chainLength || sealers.has(instance)) continue;
326
+ sealers.set(instance, {
327
+ instance,
328
+ domain: addressDomain(tags.d),
329
+ cv: tags.cv?.toLocaleLowerCase() ?? null,
330
+ });
331
+ }
332
+ let oldest: UpstreamAuthenticationClaim | null = null;
333
+ if (result === "pass" && chainLength > 1) {
334
+ const first = all.flatMap((field) => {
335
+ if (field.name !== "arc-authentication-results") return [];
336
+ const parsed = parseArcAuthenticationResults(field.value);
337
+ return parsed?.instance === 1 ? [parsed] : [];
338
+ })[0];
339
+ oldest = first ? claimFrom(first) : null;
340
+ }
341
+ return {
342
+ result,
343
+ chainLength,
344
+ sealers: [...sealers.values()].sort((left, right) => left.instance - right.instance),
345
+ oldest,
346
+ };
347
+ }
348
+
349
+ /**
350
+ * Header-derived sender authentication. Only the Authentication-Results,
351
+ * Received-SPF and ARC headers Cloudflare prepended are trusted; anything
352
+ * below its block is sender-controlled and is kept only as display claims.
353
+ */
354
+ export function parseInboundAuthentication(input: {
355
+ raw: string;
356
+ fromAddress: string | null;
357
+ envelopeFrom?: string | null;
358
+ now?: number;
359
+ }): InboundAuthentication {
360
+ const now = input.now ?? Date.now();
361
+ const fields = headerFields(input.raw);
362
+ const block = trustedHeaderBlock(fields);
363
+ const fromDomainHint = addressDomain(input.fromAddress);
364
+ if (!block) {
365
+ return emptyAuthentication(now, fromDomainHint, "not_cloudflare", input.envelopeFrom ?? null);
366
+ }
367
+ const authenticationResults = block.trusted.flatMap((field) => {
368
+ if (field.name !== "authentication-results") return [];
369
+ const parsed = parseAuthenticationResults(field.value);
370
+ return parsed?.authservId === CLOUDFLARE_AUTHSERV_ID ? [{ field, parsed }] : [];
371
+ })[0];
372
+ const receivedSpf = block.trusted.flatMap((field) => {
373
+ if (field.name !== "received-spf") return [];
374
+ const parsed = parseReceivedSpf(field.value);
375
+ return parsed?.pairs.receiver?.toLocaleLowerCase() === CLOUDFLARE_AUTHSERV_ID ? [parsed] : [];
376
+ })[0];
377
+ const scoreField = block.trusted.find((field) => field.name === "x-cf-spamh-score");
378
+ const score = scoreField && /^\s*\d+(?:\.\d+)?\s*$/u.test(scoreField.value)
379
+ ? Number(scoreField.value)
380
+ : null;
381
+ const upstream = block.below.flatMap((field) => {
382
+ if (field.name !== "authentication-results") return [];
383
+ const parsed = parseAuthenticationResults(field.value);
384
+ return parsed ? [claimFrom(parsed)] : [];
385
+ }).slice(0, MAX_UPSTREAM_CLAIMS);
386
+ const ignoredCloudflareClaims = block.below.filter(claimsCloudflare).length;
387
+ const cloudflareHop = parseReceived(fields[0]!.value);
388
+
389
+ if (!authenticationResults) {
390
+ return {
391
+ ...emptyAuthentication(now, fromDomainHint, "missing_results", input.envelopeFrom ?? null),
392
+ remoteIp: normalizeIp(receivedSpf?.pairs["client-ip"]) ?? cloudflareHop.ip,
393
+ helo: receivedSpf?.pairs.helo ?? cloudflareHop.from,
394
+ envelopeFrom: receivedSpf?.pairs["envelope-from"] || input.envelopeFrom || null,
395
+ spamScore: score,
396
+ upstream,
397
+ ignoredCloudflareClaims,
398
+ };
399
+ }
400
+
401
+ const { results } = authenticationResults.parsed;
402
+ const dmarcResult = results.find((result) => result.method === "dmarc");
403
+ const fromDomain = fromDomainHint ?? addressDomain(dmarcResult?.props["header.from"]);
404
+ const spfResults = results.filter((result) => result.method === "spf");
405
+ const spfMailFrom = spfResults.find((result) => result.props["smtp.mailfrom"])
406
+ ?? spfResults.find((result) => !result.props["smtp.helo"]);
407
+ const spfHelo = spfResults.find((result) => result.props["smtp.helo"]);
408
+ const envelopeFrom = spfMailFrom?.props["smtp.mailfrom"]
409
+ || receivedSpf?.pairs["envelope-from"]
410
+ || input.envelopeFrom
411
+ || null;
412
+ const remoteIp = normalizeIp(
413
+ results.find((result) => result.props["smtp.remote-ip"])?.props["smtp.remote-ip"],
414
+ ) ?? normalizeIp(receivedSpf?.pairs["client-ip"]) ?? cloudflareHop.ip;
415
+
416
+ const signatures = dkimSignatureTags(fields);
417
+ const dkim: DkimResult[] = results.flatMap((result) => {
418
+ if (result.method !== "dkim") return [];
419
+ const verdict = authResult(result.result);
420
+ const identity = result.props["header.i"] ?? null;
421
+ const domain = addressDomain(result.props["header.d"]) ?? addressDomain(identity);
422
+ if (!verdict || !domain) return [];
423
+ const signature = matchSignature(result, domain, signatures);
424
+ return [{
425
+ result: verdict,
426
+ domain,
427
+ identity,
428
+ selector: result.props["header.s"] ?? null,
429
+ bPrefix: result.props["header.b"] ?? null,
430
+ aligned: relaxedAligned(domain, fromDomain),
431
+ signedHeaders: signature?.signedHeaders ?? null,
432
+ }];
433
+ });
434
+
435
+ const spfVerdict = authResult(spfMailFrom?.result);
436
+ const spfDomain = addressDomain(envelopeFrom);
437
+ const spf = spfVerdict
438
+ ? { result: spfVerdict, domain: spfDomain, aligned: relaxedAligned(spfDomain, fromDomain) }
439
+ : null;
440
+ const spfHeloVerdict = authResult(spfHelo?.result);
441
+ const dmarcVerdict = authResult(dmarcResult?.result);
442
+ const dmarc = dmarcVerdict
443
+ ? {
444
+ result: dmarcVerdict,
445
+ headerFrom: addressDomain(dmarcResult?.props["header.from"]),
446
+ policy: dmarcPolicy(dmarcResult?.props["policy.dmarc"]),
447
+ alignedVia: [
448
+ ...(dkim.some((item) => item.result === "pass" && item.aligned) ? ["dkim" as const] : []),
449
+ ...(spf?.result === "pass" && spf.aligned ? ["spf" as const] : []),
450
+ ],
451
+ }
452
+ : null;
453
+ const arc = arcSummary(block.trusted, fields, results.find((result) => result.method === "arc"));
454
+ const parts = { spf, dkim, dmarc, arc };
455
+ return {
456
+ source: "headers",
457
+ version: 1,
458
+ authservId: CLOUDFLARE_AUTHSERV_ID,
459
+ parsedAt: now,
460
+ remoteIp,
461
+ helo: spfHelo?.props["smtp.helo"] ?? receivedSpf?.pairs.helo ?? cloudflareHop.from,
462
+ envelopeFrom,
463
+ fromDomain,
464
+ ...parts,
465
+ spfHelo: spfHeloVerdict
466
+ ? { result: spfHeloVerdict, domain: addressDomain(spfHelo?.props["smtp.helo"]) }
467
+ : null,
468
+ spamScore: score,
469
+ ...verdictFor(parts),
470
+ upstream,
471
+ ignoredCloudflareClaims,
472
+ trustedHeader: authenticationResults.field.value.slice(0, MAX_TRUSTED_HEADER_LENGTH),
473
+ };
474
+ }
475
+
476
+ // --- List-Unsubscribe (RFC 2369 / RFC 8058) --------------------------------
477
+
478
+ function httpsUnsubscribeUrl(value: string) {
479
+ if (value.length > 2_048) return null;
480
+ try {
481
+ const url = new URL(value);
482
+ if (url.protocol !== "https:" || url.username || url.password) return null;
483
+ const host = url.hostname.replace(/^\[|\]$/gu, "");
484
+ if (normalizeIp(host) || !host.includes(".")) return null;
485
+ return url.toString();
486
+ } catch {
487
+ return null;
488
+ }
489
+ }
490
+
491
+ function mailtoUnsubscribeUrl(value: string) {
492
+ if (value.length > 2_048 || !/^mailto:/iu.test(value)) return null;
493
+ try {
494
+ const url = new URL(value);
495
+ const recipient = decodeURIComponent(url.pathname);
496
+ return /^[^\s@,]+@[^\s@,]+\.[^\s@,]+$/u.test(recipient) ? url.toString() : null;
497
+ } catch {
498
+ return null;
499
+ }
500
+ }
501
+
502
+ /**
503
+ * Unsubscribe methods a client may offer. One-click requires RFC 8058's
504
+ * Post header and an aligned passing DKIM signature whose `h=` covers both
505
+ * list headers; the alignment requirement is our own extra safeguard.
506
+ */
507
+ export function parseListUnsubscribe(
508
+ raw: string,
509
+ authentication: Pick<InboundAuthentication, "dkim">,
510
+ ): ListUnsubscribe | null {
511
+ const fields = headerFields(raw);
512
+ const header = fields.find((field) => field.name === "list-unsubscribe")?.value;
513
+ if (!header) return null;
514
+ const uris = [...header.matchAll(/<([^>]*)>/gu)].map((match) => match[1]!.replace(/\s+/gu, ""));
515
+ const httpsUrl = uris.map(httpsUnsubscribeUrl).find(Boolean) ?? null;
516
+ const mailto = uris.map(mailtoUnsubscribeUrl).find(Boolean) ?? null;
517
+ const result = (reason: ListUnsubscribe["reason"]): ListUnsubscribe => ({
518
+ httpsUrl,
519
+ mailto,
520
+ oneClick: reason === null,
521
+ reason,
522
+ });
523
+ if (!httpsUrl && !mailto) return result("invalid-uri");
524
+ if (!httpsUrl) return result("no-https");
525
+ const post = fields.find((field) => field.name === "list-unsubscribe-post")?.value;
526
+ if (post?.replace(/\s+/gu, "").toLocaleLowerCase() !== "list-unsubscribe=one-click") {
527
+ return result("no-post");
528
+ }
529
+ const covering = authentication.dkim.filter((signature) =>
530
+ signature.result === "pass" && signature.signedHeaders?.includes("list-unsubscribe")
531
+ );
532
+ if (!covering.length) return result("unsigned");
533
+ const coveringBoth = covering.filter((signature) =>
534
+ signature.signedHeaders?.includes("list-unsubscribe-post")
535
+ );
536
+ if (!coveringBoth.length) return result("post-not-signed");
537
+ return result(coveringBoth.some((signature) => signature.aligned) ? null : "unaligned");
538
+ }
539
+
540
+ /** Header-derived authentication and unsubscribe data stored on the email row. */
541
+ export function inboundHeaderMetadata(input: {
542
+ rawHeaders: string;
543
+ fromAddress: string | null;
544
+ envelopeFrom?: string | null;
545
+ now?: number;
546
+ }) {
547
+ const authentication = parseInboundAuthentication({
548
+ raw: input.rawHeaders,
549
+ fromAddress: input.fromAddress,
550
+ envelopeFrom: input.envelopeFrom,
551
+ now: input.now,
552
+ });
553
+ return {
554
+ authenticationResultsJson: authentication,
555
+ listUnsubscribeJson: parseListUnsubscribe(input.rawHeaders, authentication),
556
+ };
557
+ }