@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,145 @@
1
+ /**
2
+ * Sender authentication as reported by Cloudflare Email Routing in the
3
+ * headers it prepends to every inbound message. Parsed at ingestion (or
4
+ * lazily from the stored original) by `worker/mail/auth-headers.ts`.
5
+ */
6
+
7
+ export const authResults = [
8
+ "pass",
9
+ "fail",
10
+ "softfail",
11
+ "neutral",
12
+ "none",
13
+ "temperror",
14
+ "permerror",
15
+ "policy",
16
+ ] as const;
17
+ export type AuthResult = (typeof authResults)[number];
18
+
19
+ export type DmarcPolicy = "none" | "quarantine" | "reject";
20
+
21
+ export type DkimResult = {
22
+ result: AuthResult;
23
+ /** Signing domain: `header.d`, or the domain of `header.i`. */
24
+ domain: string;
25
+ identity: string | null;
26
+ selector: string | null;
27
+ /** RFC 6008 `header.b` prefix used to find the matching DKIM-Signature. */
28
+ bPrefix: string | null;
29
+ /** Organizational domain equals the From organizational domain. */
30
+ aligned: boolean;
31
+ /** Lowercased `h=` list of the matching DKIM-Signature, when found. */
32
+ signedHeaders: string[] | null;
33
+ };
34
+
35
+ /** One sender-reported Authentication-Results header, shown but never trusted. */
36
+ export type UpstreamAuthenticationClaim = {
37
+ authservId: string;
38
+ spf: AuthResult | null;
39
+ dkim: AuthResult | null;
40
+ dmarc: AuthResult | null;
41
+ };
42
+
43
+ export type ArcSealer = {
44
+ instance: number;
45
+ domain: string | null;
46
+ cv: string | null;
47
+ };
48
+
49
+ export type SenderVerdict = "verified" | "unverified" | "suspicious" | "unavailable";
50
+
51
+ export type SenderVerdictReason =
52
+ | "dmarc_pass"
53
+ | "aligned_dkim"
54
+ | "forwarded_arc"
55
+ | "dmarc_fail"
56
+ | "no_aligned_pass"
57
+ | "partial"
58
+ | "not_cloudflare"
59
+ | "missing_results";
60
+
61
+ export type InboundAuthentication = {
62
+ source: "headers";
63
+ version: 1;
64
+ /** authserv-id of the trusted Authentication-Results (mx.cloudflare.net). */
65
+ authservId: string | null;
66
+ parsedAt: number;
67
+ /** smtp.remote-ip, else Received-SPF client-ip, else the Cloudflare hop IP. */
68
+ remoteIp: string | null;
69
+ helo: string | null;
70
+ /** SMTP MAIL FROM (smtp.mailfrom / Received-SPF envelope-from). */
71
+ envelopeFrom: string | null;
72
+ /** Domain of the RFC 5322 From address the results were aligned against. */
73
+ fromDomain: string | null;
74
+ spf: { result: AuthResult; domain: string | null; aligned: boolean } | null;
75
+ spfHelo: { result: AuthResult; domain: string | null } | null;
76
+ dkim: DkimResult[];
77
+ dmarc: {
78
+ result: AuthResult;
79
+ headerFrom: string | null;
80
+ policy: DmarcPolicy | null;
81
+ alignedVia: Array<"spf" | "dkim">;
82
+ } | null;
83
+ arc: {
84
+ result: "pass" | "fail" | "none";
85
+ /** Instance of Cloudflare's own ARC set; i > 1 means an upstream chain exists. */
86
+ chainLength: number;
87
+ /** Earlier sealers (display only; Cloudflare validated the chain when result is pass). */
88
+ sealers: ArcSealer[];
89
+ /** Results recorded by the oldest sealer, when ARC passed. */
90
+ oldest: UpstreamAuthenticationClaim | null;
91
+ } | null;
92
+ /** X-CF-SpamH-Score, 0..5. Experimental and passive per Cloudflare. */
93
+ spamScore: number | null;
94
+ verdict: SenderVerdict;
95
+ verdictReason: SenderVerdictReason;
96
+ /** Authentication-Results headers below the trusted block (sender-supplied). */
97
+ upstream: UpstreamAuthenticationClaim[];
98
+ /** Headers below the trusted block that claimed to come from Cloudflare. */
99
+ ignoredCloudflareClaims: number;
100
+ /** Raw trusted Authentication-Results value, capped. */
101
+ trustedHeader: string | null;
102
+ };
103
+
104
+ export type ListUnsubscribeReason =
105
+ | "invalid-uri"
106
+ | "no-https"
107
+ | "no-post"
108
+ | "unsigned"
109
+ | "unaligned"
110
+ | "post-not-signed";
111
+
112
+ export type ListUnsubscribe = {
113
+ httpsUrl: string | null;
114
+ mailto: string | null;
115
+ /** RFC 8058 one-click, and the signature covering both headers is aligned with From. */
116
+ oneClick: boolean;
117
+ /** Why one-click is not offered; null when it is. */
118
+ reason: ListUnsubscribeReason | null;
119
+ };
120
+
121
+ /** Compact sender summary returned with every message. */
122
+ export type MessageSecurity = {
123
+ verdict: SenderVerdict;
124
+ verdictReason: SenderVerdictReason;
125
+ fromDomain: string | null;
126
+ /** Domain of the SMTP envelope sender (Gmail's "mailed-by"). */
127
+ mailedBy: string | null;
128
+ /** Passing DKIM domain, preferring the aligned one (Gmail's "signed-by"). */
129
+ signedBy: string | null;
130
+ /** Gmail's "via": shown when neither mailed-by nor signed-by aligns with From. */
131
+ via: string | null;
132
+ /** Organization that forwarded the message to this mailbox, when evident. */
133
+ forwardedVia: string | null;
134
+ /** The From domain is not one of this workspace's domains. */
135
+ external: boolean;
136
+ listUnsubscribe: ListUnsubscribe | null;
137
+ /** Most serious first; the reader merges them into one banner. */
138
+ warnings: import("./mail-sender-intel").SecurityWarning[];
139
+ /** First message from this external address, and this mailbox never wrote to it. */
140
+ firstTimeSender: boolean;
141
+ /** Reply-To points at another organization than From (and the message is not from a list). */
142
+ replyToMismatch: { address: string; domain: string } | null;
143
+ /** Someone in this mailbox marked the sender as trusted; caution hints are hidden. */
144
+ trusted: boolean;
145
+ };
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Per-mailbox sender decisions (block, trust, reports) and unsubscribe
3
+ * records, as returned with every incoming message.
4
+ */
5
+
6
+ export type ReportKind = "spam" | "phishing";
7
+
8
+ export type FeedbackLabel = ReportKind | "not_spam";
9
+
10
+ export type SenderBlock = {
11
+ /** The stored pattern: an address, or `@domain` for the whole domain. */
12
+ pattern: string;
13
+ scope: "address" | "domain";
14
+ /** Workspace-wide blocks are set by an administrator. */
15
+ workspace: boolean;
16
+ at: number;
17
+ };
18
+
19
+ export type SenderStatus = {
20
+ block: SenderBlock | null;
21
+ trustedAt: number | null;
22
+ reportedAt: number | null;
23
+ };
24
+
25
+ export type UnsubscribeMethod = "one_click" | "mailto" | "link";
26
+
27
+ export type UnsubscribeStatus =
28
+ /** The sender's server accepted the one-click request. */
29
+ | "done"
30
+ /** An unsubscribe email was sent; the list processes it on its own schedule. */
31
+ | "requested"
32
+ /** The person opened the sender's unsubscribe page. */
33
+ | "opened"
34
+ | "failed";
35
+
36
+ export type Unsubscription = {
37
+ listKey: string;
38
+ method: UnsubscribeMethod;
39
+ status: UnsubscribeStatus;
40
+ /** Host of the https endpoint or the mailto address. */
41
+ target: string | null;
42
+ at: number;
43
+ };
44
+
45
+ export type ReportedMessageStatus = "open" | "resolved" | "dismissed";
46
+
47
+ /** Point-in-time copy of what the reporter saw, kept with an admin report. */
48
+ export type ReportSnapshot = {
49
+ fromName: string | null;
50
+ replyTo: string[];
51
+ to: string[];
52
+ verdict: string;
53
+ verdictReason: string;
54
+ warnings: string[];
55
+ spamScore: number | null;
56
+ ip: string | null;
57
+ network: string | null;
58
+ country: string | null;
59
+ ai: { spam: boolean; spamConfidence: number; reason: string } | null;
60
+ linkDomains: string[];
61
+ preview: string;
62
+ };
63
+
64
+ const DOMAIN_PATTERN = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9-]{2,63}$/u;
65
+
66
+ /**
67
+ * Normalizes a block entry: `alex@x.com` stays an address, `x.com` and
68
+ * `@x.com` become `@x.com` (the domain and its subdomains). Null if invalid.
69
+ */
70
+ export function normalizeBlockPattern(value: string) {
71
+ const input = value.trim().toLocaleLowerCase();
72
+ if (input.length > 320) return null;
73
+ const at = input.lastIndexOf("@");
74
+ if (at > 0) {
75
+ const domain = input.slice(at + 1);
76
+ return DOMAIN_PATTERN.test(domain) && !/[\s<>,;"]/u.test(input.slice(0, at)) ? input : null;
77
+ }
78
+ const domain = at === 0 ? input.slice(1) : input;
79
+ return DOMAIN_PATTERN.test(domain) ? `@${domain}` : null;
80
+ }
81
+
82
+ /** The block entries that cover an address: the address itself and each parent domain. */
83
+ export function blockPatternsFor(address: string) {
84
+ const value = address.trim().toLocaleLowerCase();
85
+ const at = value.lastIndexOf("@");
86
+ if (at <= 0) return [];
87
+ const labels = value.slice(at + 1).split(".");
88
+ const domains = labels.slice(0, -1).map((_, index) => `@${labels.slice(index).join(".")}`);
89
+ return [value, ...domains];
90
+ }
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Low-level RFC 5322 header helpers shared by ingestion (worker) and the
3
+ * "Show original" panel (browser). Everything here is pure and works on the
4
+ * raw message text, so header order is preserved exactly as received.
5
+ */
6
+
7
+ const MAX_HEADER_BYTES = 256 * 1024;
8
+
9
+ export type HeaderField = {
10
+ /** Lowercased field name. */
11
+ name: string;
12
+ /** Unfolded value with surrounding whitespace removed. */
13
+ value: string;
14
+ };
15
+
16
+ export function headerBlock(raw: string) {
17
+ const separator = /\r?\n\r?\n/u.exec(raw);
18
+ const end = separator?.index ?? raw.length;
19
+ return raw.slice(0, Math.min(end, MAX_HEADER_BYTES));
20
+ }
21
+
22
+ /** Header fields of the top-level entity, in their original (topmost first) order. */
23
+ export function headerFields(raw: string): HeaderField[] {
24
+ const unfolded = headerBlock(raw).replace(/\r?\n[\t ]+/gu, " ");
25
+ return unfolded.split(/\r?\n/gu).flatMap((line) => {
26
+ const colon = line.indexOf(":");
27
+ if (colon < 1) return [];
28
+ const name = line.slice(0, colon).trim().toLocaleLowerCase();
29
+ if (!/^[\x21-\x39\x3b-\x7e]+$/u.test(name)) return [];
30
+ return [{ name, value: line.slice(colon + 1).trim() }];
31
+ });
32
+ }
33
+
34
+ export function headerValues(raw: string, name: string) {
35
+ const expected = name.toLocaleLowerCase();
36
+ return headerFields(raw).flatMap((field) =>
37
+ field.name === expected && field.value ? [field.value] : []
38
+ );
39
+ }
40
+
41
+ export type CommentScan = {
42
+ /** The value with every top-level comment replaced by a single space. */
43
+ text: string;
44
+ /** Text of each outermost comment, without its parentheses. */
45
+ comments: string[];
46
+ };
47
+
48
+ /**
49
+ * Removes RFC 5322 comments (which may nest and contain quoted pairs) while
50
+ * leaving quoted strings intact.
51
+ */
52
+ export function scanComments(value: string): CommentScan {
53
+ let text = "";
54
+ let comment = "";
55
+ const comments: string[] = [];
56
+ let depth = 0;
57
+ let quoted = false;
58
+ for (let index = 0; index < value.length; index += 1) {
59
+ const char = value[index]!;
60
+ if (char === "\\" && (quoted || depth > 0) && index + 1 < value.length) {
61
+ const pair = value.slice(index, index + 2);
62
+ if (depth > 0) comment += pair;
63
+ else text += pair;
64
+ index += 1;
65
+ continue;
66
+ }
67
+ if (depth === 0 && char === "\"") quoted = !quoted;
68
+ if (!quoted && char === "(") {
69
+ if (depth > 0) comment += char;
70
+ depth += 1;
71
+ continue;
72
+ }
73
+ if (!quoted && char === ")" && depth > 0) {
74
+ depth -= 1;
75
+ if (depth === 0) {
76
+ comments.push(comment.trim());
77
+ comment = "";
78
+ text += " ";
79
+ } else {
80
+ comment += char;
81
+ }
82
+ continue;
83
+ }
84
+ if (depth > 0) comment += char;
85
+ else text += char;
86
+ }
87
+ if (depth > 0 && comment) comments.push(comment.trim());
88
+ return { text, comments };
89
+ }
90
+
91
+ /**
92
+ * Splits on a delimiter that appears outside comments and quoted strings.
93
+ * Each part keeps its comments so callers can still read them.
94
+ */
95
+ export function splitTopLevel(value: string, delimiter: string) {
96
+ const parts: string[] = [];
97
+ let current = "";
98
+ let depth = 0;
99
+ let quoted = false;
100
+ for (let index = 0; index < value.length; index += 1) {
101
+ const char = value[index]!;
102
+ if (char === "\\" && (quoted || depth > 0) && index + 1 < value.length) {
103
+ current += value.slice(index, index + 2);
104
+ index += 1;
105
+ continue;
106
+ }
107
+ if (depth === 0 && char === "\"") quoted = !quoted;
108
+ else if (!quoted && char === "(") depth += 1;
109
+ else if (!quoted && char === ")" && depth > 0) depth -= 1;
110
+ if (char === delimiter && depth === 0 && !quoted) {
111
+ parts.push(current);
112
+ current = "";
113
+ continue;
114
+ }
115
+ current += char;
116
+ }
117
+ parts.push(current);
118
+ return parts;
119
+ }
120
+
121
+ export function unquote(value: string) {
122
+ const trimmed = value.trim();
123
+ if (trimmed.length >= 2 && trimmed.startsWith("\"") && trimmed.endsWith("\"")) {
124
+ return trimmed.slice(1, -1).replace(/\\(.)/gu, "$1");
125
+ }
126
+ return trimmed;
127
+ }
128
+
129
+ /** Domain part of an address or a bare domain, lowercased without a trailing dot. */
130
+ export function addressDomain(value: string | null | undefined) {
131
+ if (!value) return null;
132
+ const trimmed = value.trim().replace(/^<|>$/gu, "");
133
+ const at = trimmed.lastIndexOf("@");
134
+ const domain = (at >= 0 ? trimmed.slice(at + 1) : trimmed)
135
+ .replace(/\.$/u, "")
136
+ .toLocaleLowerCase();
137
+ return /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$/u.test(domain)
138
+ ? domain
139
+ : null;
140
+ }
141
+
142
+ // --- Dates ---------------------------------------------------------------
143
+
144
+ const MONTHS = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
145
+
146
+ /** RFC 5322 §4.3 obsolete zones; military zones are treated as unknown (-0000). */
147
+ const OBSOLETE_ZONES: Record<string, number> = {
148
+ ut: 0, gmt: 0, z: 0, utc: 0,
149
+ est: -5 * 60, edt: -4 * 60,
150
+ cst: -6 * 60, cdt: -5 * 60,
151
+ mst: -7 * 60, mdt: -6 * 60,
152
+ pst: -8 * 60, pdt: -7 * 60,
153
+ };
154
+
155
+ export const RFC5322_DATE_PATTERN =
156
+ /(?:(?:mon|tue|wed|thu|fri|sat|sun)[a-z]*,?\s*)?(\d{1,2})\s+([a-z]{3})[a-z]*\s+(\d{2,4})\s+(\d{1,2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?\s*([+-]\d{4}|[a-z]{1,3}\b)?/iu;
157
+
158
+ export const ISO_DATE_PATTERN =
159
+ /(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(?:\.\d+)?\s*([+-]\d{2}:?\d{2}|Z)?/iu;
160
+
161
+ function zoneOffsetMinutes(zone: string | undefined) {
162
+ if (!zone) return 0;
163
+ const numeric = /^([+-])(\d{2}):?(\d{2})$/u.exec(zone);
164
+ if (numeric) {
165
+ const minutes = Number(numeric[2]) * 60 + Number(numeric[3]);
166
+ return numeric[1] === "-" ? -minutes : minutes;
167
+ }
168
+ return OBSOLETE_ZONES[zone.toLocaleLowerCase()] ?? 0;
169
+ }
170
+
171
+ function utcDate(
172
+ year: number,
173
+ month: number,
174
+ day: number,
175
+ hour: number,
176
+ minute: number,
177
+ second: number,
178
+ offsetMinutes: number,
179
+ ) {
180
+ if (
181
+ month < 0 || month > 11 || day < 1 || day > 31
182
+ || hour > 23 || minute > 59 || second > 60
183
+ ) {
184
+ return null;
185
+ }
186
+ const time = Date.UTC(year, month, day, hour, minute, Math.min(second, 59))
187
+ - offsetMinutes * 60_000;
188
+ return Number.isFinite(time) ? new Date(time) : null;
189
+ }
190
+
191
+ function rfc5322Match(match: RegExpExecArray) {
192
+ const month = MONTHS.indexOf(match[2]!.toLocaleLowerCase());
193
+ let year = Number(match[3]);
194
+ // RFC 5322 §4.3: two-digit years are 19xx when >= 50, 20xx otherwise.
195
+ if (match[3]!.length <= 2) year += year >= 50 ? 1900 : 2000;
196
+ else if (match[3]!.length === 3) year += 1900;
197
+ return utcDate(
198
+ year,
199
+ month,
200
+ Number(match[1]),
201
+ Number(match[4]),
202
+ Number(match[5]),
203
+ Number(match[6] ?? 0),
204
+ zoneOffsetMinutes(match[7]),
205
+ );
206
+ }
207
+
208
+ function isoMatch(match: RegExpExecArray) {
209
+ return utcDate(
210
+ Number(match[1]),
211
+ Number(match[2]) - 1,
212
+ Number(match[3]),
213
+ Number(match[4]),
214
+ Number(match[5]),
215
+ Number(match[6]),
216
+ zoneOffsetMinutes(match[7]),
217
+ );
218
+ }
219
+
220
+ /**
221
+ * Strict date parser for header dates. Accepts RFC 5322 dates (day name
222
+ * optional, fractional seconds, obsolete zones) and ISO / Go timestamps such
223
+ * as SendGrid's `2026-10-29 15:24:22.353912098 +0000 UTC m=+…`. Never falls
224
+ * back to `new Date(string)`, whose non-ISO parsing is implementation-defined.
225
+ */
226
+ export function parseMailDate(value: string | null | undefined) {
227
+ if (!value) return null;
228
+ const text = scanComments(value).text.trim();
229
+ const rfc = RFC5322_DATE_PATTERN.exec(text);
230
+ if (rfc && rfc.index <= 12) return rfc5322Match(rfc);
231
+ const iso = ISO_DATE_PATTERN.exec(text);
232
+ if (iso && iso.index <= 12) return isoMatch(iso);
233
+ return null;
234
+ }
235
+
236
+ /** Finds a date at the end of a header that has no `;` before it. */
237
+ export function trailingMailDate(value: string) {
238
+ const rfc = new RegExp(RFC5322_DATE_PATTERN.source, "giu");
239
+ const iso = new RegExp(ISO_DATE_PATTERN.source, "giu");
240
+ let best: { index: number; date: Date } | null = null;
241
+ for (const [pattern, parse] of [[rfc, rfc5322Match], [iso, isoMatch]] as const) {
242
+ for (const match of value.matchAll(pattern)) {
243
+ const date = parse(match as RegExpExecArray);
244
+ if (date && (!best || match.index > best.index)) {
245
+ best = { index: match.index, date };
246
+ }
247
+ }
248
+ }
249
+ return best;
250
+ }