@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,47 @@
1
+ import { eq } from "drizzle-orm";
2
+ import { Hono } from "hono";
3
+ import { createDb } from "@open-workspace/core/worker/db/client.ts";
4
+ import { mailboxes } from "@open-workspace/core/worker/db/schema.ts";
5
+ import type { AppEnv } from "@open-workspace/core/worker/env.ts";
6
+ import { hashToken } from "@open-workspace/core/worker/lib/crypto.ts";
7
+ import { apiError } from "@open-workspace/core/worker/lib/http.ts";
8
+ import { mailboxStub } from "../mailbox";
9
+
10
+ export const mailDownloadRoutes = new Hono<AppEnv>().get("/mail/:token", async (c) => {
11
+ const token = c.req.param("token");
12
+ const [mailboxId, messageId, attachmentId, secret, ...extra] = token.split(".");
13
+ if (!mailboxId || !messageId || !attachmentId || !secret || extra.length) {
14
+ return apiError(c, 404, "NOT_FOUND", "Download link is invalid or expired");
15
+ }
16
+
17
+ // Check D1 first so an arbitrary public token cannot instantiate new DOs.
18
+ const [mailbox] = await createDb(c.env.DB)
19
+ .select({ id: mailboxes.id })
20
+ .from(mailboxes)
21
+ .where(eq(mailboxes.id, mailboxId))
22
+ .limit(1);
23
+ if (!mailbox) {
24
+ return apiError(c, 404, "NOT_FOUND", "Download link is invalid or expired");
25
+ }
26
+
27
+ const file = await mailboxStub(c.env, mailboxId).getAttachment(messageId, attachmentId);
28
+ const valid = file?.delivery === "download_link"
29
+ && file.downloadExpiresAt !== null
30
+ && file.downloadExpiresAt > Date.now()
31
+ && file.downloadTokenHash !== null
32
+ && file.downloadTokenHash === await hashToken(token);
33
+ if (!file || !valid) {
34
+ return apiError(c, 404, "NOT_FOUND", "Download link is invalid or expired");
35
+ }
36
+
37
+ const object = await c.env.MAIL_STORAGE.get(file.r2Key);
38
+ if (!object) return apiError(c, 404, "NOT_FOUND", "Attachment data is missing");
39
+ return new Response(object.body, {
40
+ headers: {
41
+ "cache-control": "private, no-store",
42
+ "content-type": file.contentType,
43
+ "content-disposition": `attachment; filename*=UTF-8''${encodeURIComponent(file.filename)}`,
44
+ "x-content-type-options": "nosniff",
45
+ },
46
+ });
47
+ });
@@ -0,0 +1,77 @@
1
+ import type { InboundAuthentication } from "../../shared/mail-authentication";
2
+ import type { mailboxStub } from "../mailbox";
3
+ import type { Email } from "../mailbox/schema";
4
+ import { inboundHeaderMetadata } from "./auth-headers";
5
+ import { rawHeaderText } from "./inbound";
6
+ import { linkDomains } from "./link-domains";
7
+ import { collectSenderIntel, senderIntelWorthStoring } from "./sender-intel";
8
+
9
+ const HEADER_RANGE_BYTES = 256 * 1024;
10
+
11
+ export function headerAuthentication(email: Email): InboundAuthentication | null {
12
+ const stored = email.authenticationResultsJson;
13
+ return stored?.source === "headers" && stored.version === 1 ? stored : null;
14
+ }
15
+
16
+ /**
17
+ * Messages stored before header parsing (or with legacy Cloudflare analytics)
18
+ * are parsed from the original's header section on first view, then persisted.
19
+ */
20
+ export async function withHeaderMetadata(
21
+ env: Env,
22
+ mailbox: ReturnType<typeof mailboxStub>,
23
+ email: Email,
24
+ ): Promise<Email> {
25
+ if (email.direction !== "incoming" || !email.rawMimeR2Key || headerAuthentication(email)) {
26
+ return email;
27
+ }
28
+ try {
29
+ const object = await env.MAIL_STORAGE.get(email.rawMimeR2Key, {
30
+ range: { offset: 0, length: HEADER_RANGE_BYTES },
31
+ });
32
+ if (!object) return email;
33
+ const metadata = inboundHeaderMetadata({
34
+ rawHeaders: rawHeaderText(await object.arrayBuffer()),
35
+ fromAddress: email.fromJson[0]?.address ?? null,
36
+ envelopeFrom: email.envelopeFrom,
37
+ });
38
+ await mailbox.setEmailHeaderMetadata(email.id, metadata);
39
+ return { ...email, ...metadata };
40
+ } catch (error) {
41
+ console.error(`Could not parse authentication headers for ${email.id}`, error);
42
+ return email;
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Messages stored before sender intelligence existed get it on first view,
48
+ * using the same lookups as ingestion. A result where every lookup failed is
49
+ * returned for display but not stored, so a later view retries.
50
+ */
51
+ export async function withSenderIntel(
52
+ env: Env,
53
+ mailbox: ReturnType<typeof mailboxStub>,
54
+ email: Email,
55
+ context: { bodyHtml: string | null; workspaceDomains: readonly string[]; dqsKey: string | null },
56
+ ): Promise<Email> {
57
+ if (email.direction !== "incoming" || email.senderIntelJson || !headerAuthentication(email)) return email;
58
+ try {
59
+ const intel = await collectSenderIntel({
60
+ authentication: headerAuthentication(email),
61
+ fromAddress: email.fromJson[0]?.address ?? "",
62
+ linkDomains: linkDomains({
63
+ html: context.bodyHtml,
64
+ text: [email.bodyText, email.quotedText],
65
+ workspaceDomains: context.workspaceDomains,
66
+ }),
67
+ workspaceDomains: context.workspaceDomains,
68
+ dqsKey: context.dqsKey,
69
+ bucket: env.MAIL_STORAGE,
70
+ });
71
+ if (senderIntelWorthStoring(intel)) await mailbox.setEmailSenderIntel(email.id, intel);
72
+ return { ...email, senderIntelJson: intel };
73
+ } catch (error) {
74
+ console.error(`Could not collect sender intelligence for ${email.id}`, error);
75
+ return email;
76
+ }
77
+ }
@@ -0,0 +1,175 @@
1
+ import { getDomainWithoutSuffix } from "tldts";
2
+ import { organizationalDomain } from "./org-domain";
3
+
4
+ // --- Punycode (RFC 3492 decoding only) ------------------------------------
5
+
6
+ const BASE = 36;
7
+ const T_MIN = 1;
8
+ const T_MAX = 26;
9
+ const SKEW = 38;
10
+ const DAMP = 700;
11
+
12
+ function adapt(delta: number, points: number, first: boolean) {
13
+ let value = first ? Math.floor(delta / DAMP) : delta >> 1;
14
+ value += Math.floor(value / points);
15
+ let k = 0;
16
+ while (value > ((BASE - T_MIN) * T_MAX) >> 1) {
17
+ value = Math.floor(value / (BASE - T_MIN));
18
+ k += BASE;
19
+ }
20
+ return k + Math.floor(((BASE - T_MIN + 1) * value) / (value + SKEW));
21
+ }
22
+
23
+ function digit(code: number) {
24
+ if (code >= 48 && code <= 57) return code - 22;
25
+ if (code >= 65 && code <= 90) return code - 65;
26
+ if (code >= 97 && code <= 122) return code - 97;
27
+ return BASE;
28
+ }
29
+
30
+ export function decodePunycodeLabel(input: string) {
31
+ const delimiter = input.lastIndexOf("-");
32
+ const output = delimiter > 0 ? [...input.slice(0, delimiter)].map((char) => char.codePointAt(0)!) : [];
33
+ let n = 128;
34
+ let i = 0;
35
+ let bias = 72;
36
+ for (let index = delimiter > 0 ? delimiter + 1 : 0; index < input.length;) {
37
+ const oldI = i;
38
+ for (let w = 1, k = BASE; ; k += BASE) {
39
+ if (index >= input.length) return null;
40
+ const value = digit(input.charCodeAt(index++));
41
+ if (value >= BASE) return null;
42
+ i += value * w;
43
+ const t = k <= bias ? T_MIN : k >= bias + T_MAX ? T_MAX : k - bias;
44
+ if (value < t) break;
45
+ w *= BASE - t;
46
+ }
47
+ bias = adapt(i - oldI, output.length + 1, oldI === 0);
48
+ n += Math.floor(i / (output.length + 1));
49
+ i %= output.length + 1;
50
+ if (n > 0x10ffff) return null;
51
+ output.splice(i++, 0, n);
52
+ }
53
+ return String.fromCodePoint(...output);
54
+ }
55
+
56
+ /** `xn--` labels decoded to Unicode; other labels unchanged. */
57
+ export function domainToUnicode(domain: string) {
58
+ return domain.split(".").map((label) =>
59
+ label.toLocaleLowerCase().startsWith("xn--") ? decodePunycodeLabel(label.slice(4)) ?? label : label
60
+ ).join(".");
61
+ }
62
+
63
+ // --- Confusable skeleton (a practical subset of UTS #39) ------------------
64
+
65
+ const CONFUSABLES: Record<string, string> = {
66
+ // Cyrillic
67
+ "а": "a", "в": "b", "е": "e", "ё": "e", "к": "k", "м": "m", "н": "h", "о": "o", "р": "p", "с": "c",
68
+ "т": "t", "у": "y", "х": "x", "ѕ": "s", "і": "i", "ї": "i", "ј": "j", "ԁ": "d", "һ": "h", "ӏ": "l",
69
+ "ԛ": "q", "ԝ": "w", "ɡ": "g",
70
+ // Greek
71
+ "α": "a", "β": "b", "ε": "e", "η": "n", "ι": "i", "κ": "k", "ν": "v", "ο": "o", "ρ": "p", "τ": "t",
72
+ "υ": "u", "χ": "x", "ω": "w",
73
+ // Look-alike digits and symbols
74
+ "0": "o", "1": "l", "3": "e", "5": "s", "|": "l", "ı": "i", "ł": "l", "ø": "o", "đ": "d",
75
+ };
76
+
77
+ /** Lowercased, accent-free, confusables folded, with multi-letter look-alikes merged. */
78
+ export function confusableSkeleton(value: string) {
79
+ const folded = [...value.normalize("NFKD").toLocaleLowerCase()]
80
+ .filter((char) => !/\p{M}/u.test(char))
81
+ .map((char) => CONFUSABLES[char] ?? char)
82
+ .join("");
83
+ return folded.replaceAll("rn", "m").replaceAll("vv", "w").replaceAll("cl", "d").replace(/i/gu, "l");
84
+ }
85
+
86
+ export function levenshtein(left: string, right: string) {
87
+ if (left === right) return 0;
88
+ let previous = Array.from({ length: right.length + 1 }, (_, index) => index);
89
+ for (let i = 1; i <= left.length; i += 1) {
90
+ const current = [i];
91
+ for (let j = 1; j <= right.length; j += 1) {
92
+ current[j] = Math.min(
93
+ current[j - 1]! + 1,
94
+ previous[j]! + 1,
95
+ previous[j - 1]! + (left[i - 1] === right[j - 1] ? 0 : 1),
96
+ );
97
+ }
98
+ previous = current;
99
+ }
100
+ return previous[right.length]!;
101
+ }
102
+
103
+ /** Registrable label without the public suffix, e.g. `demo` for `mail.demo.example`. */
104
+ function registrableLabel(domain: string) {
105
+ return getDomainWithoutSuffix(domain) ?? domain.split(".").at(-2) ?? domain;
106
+ }
107
+
108
+ function maxEdits(length: number) {
109
+ if (length < 4) return 0;
110
+ return length < 7 ? 1 : 2;
111
+ }
112
+
113
+ /**
114
+ * The workspace domain that `senderDomain` imitates, if any. Compares the
115
+ * registrable labels after Unicode/punycode confusable folding, and the
116
+ * whole registrable name without separators (`demo-examp1e.com` vs `demo.example`).
117
+ */
118
+ export function lookalikeOf(senderDomain: string | null, workspaceDomains: readonly string[]) {
119
+ if (!senderDomain) return null;
120
+ const unicode = domainToUnicode(senderDomain.toLocaleLowerCase());
121
+ const senderOrg = organizationalDomain(senderDomain) ?? senderDomain;
122
+ const senderLabel = confusableSkeleton(registrableLabel(domainToUnicode(senderOrg)));
123
+ const senderFlat = senderLabel.replace(/[^\p{L}\p{N}]/gu, "");
124
+ for (const workspace of workspaceDomains) {
125
+ const own = workspace.toLocaleLowerCase();
126
+ const ownOrg = organizationalDomain(own) ?? own;
127
+ if (senderOrg === ownOrg) continue;
128
+ const ownLabel = confusableSkeleton(registrableLabel(ownOrg));
129
+ const ownFlat = confusableSkeleton(ownOrg).replace(/[^\p{L}\p{N}]/gu, "");
130
+ if (ownLabel.length < 3) continue;
131
+ // Same name under another suffix, or a homoglyph/punycode spelling of it.
132
+ if (senderLabel === ownLabel || senderFlat === ownFlat) return { domain: unicode, resembles: own };
133
+ if (levenshtein(senderLabel, ownLabel) <= maxEdits(ownLabel.length)) return { domain: unicode, resembles: own };
134
+ if (ownFlat.length >= 6 && levenshtein(senderFlat, ownFlat) <= maxEdits(ownFlat.length)) {
135
+ return { domain: unicode, resembles: own };
136
+ }
137
+ }
138
+ return null;
139
+ }
140
+
141
+ // --- Display names ----------------------------------------------------------
142
+
143
+ export function normalizedPersonName(value: string | null | undefined) {
144
+ if (!value) return "";
145
+ return value
146
+ .normalize("NFKC")
147
+ .toLocaleLowerCase()
148
+ .replace(/[^\p{L}\p{N}\s]/gu, " ")
149
+ .replace(/\s+/gu, " ")
150
+ .trim();
151
+ }
152
+
153
+ /** Internal names worth protecting: at least two words, so "Support" alone never matches. */
154
+ export function protectedNames(names: Array<string | null | undefined>) {
155
+ const result = new Map<string, string>();
156
+ for (const name of names) {
157
+ const normalized = normalizedPersonName(name);
158
+ if (normalized.split(" ").length >= 2 && normalized.length >= 5) result.set(normalized, name!.trim().replace(/\s+/gu, " "));
159
+ }
160
+ return result;
161
+ }
162
+
163
+ /** The internal person an external From display name copies, if any. */
164
+ export function impersonatedName(displayName: string | null | undefined, names: ReadonlyMap<string, string>) {
165
+ const normalized = normalizedPersonName(displayName);
166
+ if (!normalized) return null;
167
+ return names.get(normalized) ?? null;
168
+ }
169
+
170
+ /** `"ceo@ourco.com" <x@elsewhere.example>`: an address in the name that is not the sender. */
171
+ export function embeddedAddress(displayName: string | null | undefined, fromAddress: string) {
172
+ const match = displayName?.match(/[^\s<>"'()@]+@[a-z0-9.-]+\.[a-z]{2,}/iu)?.[0];
173
+ if (!match) return null;
174
+ return match.toLocaleLowerCase() === fromAddress.toLocaleLowerCase() ? null : match;
175
+ }
@@ -0,0 +1,22 @@
1
+ import { bytesToBase64Url } from "@open-workspace/core/worker/lib/crypto.ts";
2
+ import { normalizeEmail, normalizeMailboxAddress } from "@open-workspace/core/worker/lib/ids.ts";
3
+
4
+ export async function inboundDeliveryId(input: {
5
+ mailboxId: string;
6
+ envelopeFrom: string;
7
+ envelopeTo: string;
8
+ raw: ArrayBuffer;
9
+ }) {
10
+ const prefix = new TextEncoder().encode([
11
+ input.mailboxId,
12
+ normalizeEmail(input.envelopeFrom),
13
+ normalizeMailboxAddress(input.envelopeTo),
14
+ "",
15
+ ].join("\0"));
16
+ const rawDigest = new Uint8Array(await crypto.subtle.digest("SHA-256", input.raw));
17
+ const digestInput = new Uint8Array(prefix.byteLength + rawDigest.byteLength);
18
+ digestInput.set(prefix);
19
+ digestInput.set(rawDigest, prefix.byteLength);
20
+ const digest = await crypto.subtle.digest("SHA-256", digestInput);
21
+ return `in_${bytesToBase64Url(new Uint8Array(digest))}`;
22
+ }
@@ -0,0 +1,48 @@
1
+ import { normalizeEmail } from "@open-workspace/core/worker/lib/ids.ts";
2
+ import type { MailAddress } from "../mailbox/model";
3
+ import type { Email } from "../mailbox/schema";
4
+ import { normalizedParticipantSet } from "./participants";
5
+
6
+ type ThreadParent = Pick<
7
+ Email,
8
+ "listId" | "fromJson" | "toJson" | "ccJson" | "bccJson"
9
+ >;
10
+
11
+ /**
12
+ * RFC references still point at the source message, but a private answer to a
13
+ * visible group belongs to a separate local chat in the messenger model.
14
+ */
15
+ export function shouldDetachInboundReply(input: {
16
+ ownAddress: string;
17
+ parent: ThreadParent;
18
+ from: MailAddress[];
19
+ to: MailAddress[];
20
+ cc: MailAddress[];
21
+ listId: string | null;
22
+ }) {
23
+ const { parent } = input;
24
+ const normalizedListId = input.listId?.trim().toLocaleLowerCase() ?? null;
25
+ const parentListId = parent.listId?.trim().toLocaleLowerCase() ?? null;
26
+ if (parent.listId && parentListId !== normalizedListId) return true;
27
+
28
+ const parentParticipants = normalizedParticipantSet(
29
+ [...parent.fromJson, ...parent.toJson, ...parent.ccJson].map(
30
+ (participant) => participant.address,
31
+ ),
32
+ input.ownAddress,
33
+ );
34
+ const incomingParticipants = normalizedParticipantSet(
35
+ [...input.from, ...input.to, ...input.cc].map(
36
+ (participant) => participant.address,
37
+ ),
38
+ input.ownAddress,
39
+ );
40
+ const parentWasGroup = Boolean(parent.listId) || parentParticipants.size > 1;
41
+ const incomingIsDirect = input.listId === null && incomingParticipants.size === 1;
42
+ if (parentWasGroup && incomingIsDirect) return true;
43
+
44
+ const hiddenRecipients = new Set(
45
+ parent.bccJson.map((recipient) => normalizeEmail(recipient.address)),
46
+ );
47
+ return input.from.some((sender) => hiddenRecipients.has(normalizeEmail(sender.address)));
48
+ }
@@ -0,0 +1,255 @@
1
+ import PostalMime, { type Address, type Email as ParsedEmail } from "postal-mime";
2
+ import { convert } from "html-to-text";
3
+ import { headerValues } from "../../shared/mail-headers";
4
+ import { createId, normalizeMailboxAddress } from "@open-workspace/core/worker/lib/ids.ts";
5
+ import type { MailAddress, StoredAttachment } from "../mailbox/model";
6
+ import type { Email, NewEmail, PendingInbound } from "../mailbox/schema";
7
+ import { inboundHeaderMetadata } from "./auth-headers";
8
+ import { shouldDetachInboundReply } from "./inbound-threading";
9
+ import { linkDomains, MAX_LINK_DOMAINS } from "./link-domains";
10
+ import { parseReplyText } from "./quote-parser";
11
+ import {
12
+ address,
13
+ headerValue,
14
+ listId,
15
+ listPostAddress,
16
+ messageIds,
17
+ } from "./rfc";
18
+
19
+ function flattenAddresses(values: Address[] | undefined): MailAddress[] {
20
+ return (values ?? []).flatMap((value) =>
21
+ value.group
22
+ ? value.group.map((mailbox) => address(mailbox.address, mailbox.name))
23
+ : value.address
24
+ ? [address(value.address, value.name)]
25
+ : [],
26
+ );
27
+ }
28
+
29
+ function attachmentBytes(content: ArrayBuffer | Uint8Array | string) {
30
+ if (typeof content === "string") return new TextEncoder().encode(content);
31
+ return content instanceof Uint8Array ? content : new Uint8Array(content);
32
+ }
33
+
34
+ export class UnprocessableInboundEmailError extends Error {
35
+ constructor(message: string, options?: ErrorOptions) {
36
+ super(message, options);
37
+ this.name = "UnprocessableInboundEmailError";
38
+ }
39
+ }
40
+
41
+ export class MissingRawMimeError extends Error {
42
+ constructor() {
43
+ super("The original message is not available in storage yet");
44
+ this.name = "MissingRawMimeError";
45
+ }
46
+ }
47
+
48
+ const MAX_HEADER_SCAN_BYTES = 256 * 1024;
49
+
50
+ /** The header section of a raw message as text; bodies are never needed here. */
51
+ export function rawHeaderText(rawMime: ArrayBuffer | Uint8Array) {
52
+ const bytes = rawMime instanceof Uint8Array ? rawMime : new Uint8Array(rawMime);
53
+ return new TextDecoder().decode(bytes.subarray(0, MAX_HEADER_SCAN_BYTES));
54
+ }
55
+
56
+ /** The Message-ID of a raw message, read from its headers alone. */
57
+ export function rawMessageId(rawMime: ArrayBuffer | Uint8Array) {
58
+ return messageIds(headerValues(rawHeaderText(rawMime), "message-id")[0])[0] ?? null;
59
+ }
60
+
61
+ /** RFC 2919 list identifier: design@example.com is design.example.com. */
62
+ export function listIdFor(address: string) {
63
+ return normalizeMailboxAddress(address).replace("@", ".");
64
+ }
65
+
66
+ export function inboundMessageId(deliveryId: string) {
67
+ return `msg_${deliveryId.replaceAll(/[^a-zA-Z0-9_-]/gu, "_")}`;
68
+ }
69
+
70
+ async function parseMime(bytes: ArrayBuffer): Promise<ParsedEmail> {
71
+ try {
72
+ return await PostalMime.parse(bytes);
73
+ } catch (error) {
74
+ throw new UnprocessableInboundEmailError(
75
+ "The original message could not be parsed",
76
+ { cause: error },
77
+ );
78
+ }
79
+ }
80
+
81
+ export async function readInboundRawMime(env: Env, job: PendingInbound) {
82
+ const raw = await env.MAIL_STORAGE.get(job.rawObjectKey);
83
+ if (!raw) {
84
+ // The durable queue is committed before the R2 upload, so an alarm can
85
+ // briefly win that race. Missing storage remains retryable.
86
+ throw new MissingRawMimeError();
87
+ }
88
+ return raw.arrayBuffer();
89
+ }
90
+
91
+ export function prepareUnprocessableInboundEmail(input: {
92
+ job: PendingInbound;
93
+ reason: string;
94
+ rawMimeR2Key: string | null;
95
+ }): NewEmail {
96
+ const messageId = inboundMessageId(input.job.id);
97
+ const fromAddress = input.job.envelopeFrom || "unknown@invalid";
98
+ return {
99
+ id: messageId,
100
+ conversationId: createId("conv"),
101
+ direction: "incoming",
102
+ messageIdHeader: null,
103
+ inReplyToJson: [],
104
+ referencesJson: [],
105
+ fromJson: [address(fromAddress)],
106
+ replyToJson: [],
107
+ toJson: [address(input.job.envelopeTo)],
108
+ ccJson: [],
109
+ bccJson: [],
110
+ subject: "(unreadable message)",
111
+ preview: input.reason,
112
+ bodyText: [
113
+ input.reason,
114
+ "",
115
+ input.rawMimeR2Key
116
+ ? "The original MIME message was preserved for diagnostics."
117
+ : "The original MIME message is not available.",
118
+ ].join("\n"),
119
+ quotedText: null,
120
+ bodyHtmlR2Key: null,
121
+ rawMimeR2Key: input.rawMimeR2Key,
122
+ attachmentsJson: [],
123
+ listId: null,
124
+ listPostAddress: null,
125
+ envelopeFrom: input.job.envelopeFrom || null,
126
+ envelopeTo: input.job.envelopeTo || null,
127
+ timelineAt: input.job.receivedAt,
128
+ transportState: "received",
129
+ deliveryStatusJson: [],
130
+ };
131
+ }
132
+
133
+ export async function prepareInboundEmail(input: {
134
+ env: Env;
135
+ mailboxId: string;
136
+ job: PendingInbound;
137
+ rawMime: ArrayBuffer;
138
+ resolveParent: (inReplyTo: string[], references: string[]) => Email | null;
139
+ }): Promise<{ email: NewEmail; linkDomains: string[] }> {
140
+ const { job } = input;
141
+ const parsed = await parseMime(input.rawMime);
142
+ const inReplyTo = messageIds(parsed.inReplyTo);
143
+ const references = messageIds(parsed.references);
144
+ const parent = input.resolveParent(inReplyTo, references);
145
+ const recipient = normalizeMailboxAddress(job.envelopeTo);
146
+ const from = flattenAddresses(parsed.from ? [parsed.from] : []);
147
+ if (!from.length) from.push(address(job.envelopeFrom || "unknown@invalid"));
148
+ const to = flattenAddresses(parsed.to);
149
+ const cc = flattenAddresses(parsed.cc);
150
+ const bcc = flattenAddresses(parsed.bcc);
151
+ const replyTo = flattenAddresses(parsed.replyTo);
152
+ // A workspace mailing list is the latest list hop, so it names the list.
153
+ const parsedListId = job.listAddress
154
+ ? listIdFor(job.listAddress)
155
+ : listId(parsed.headers);
156
+ const parsedListPost = job.listAddress ?? listPostAddress(
157
+ headerValue(parsed.headers, "list-post"),
158
+ );
159
+ const detached = parent
160
+ ? shouldDetachInboundReply({
161
+ ownAddress: recipient,
162
+ parent,
163
+ from,
164
+ to,
165
+ cc,
166
+ listId: parsedListId,
167
+ })
168
+ : false;
169
+ const messageId = inboundMessageId(job.id);
170
+ const plainBody = parsed.text?.trim()
171
+ ? parsed.text
172
+ : parsed.html
173
+ ? convert(parsed.html, {
174
+ wordwrap: false,
175
+ selectors: [
176
+ { selector: "img", format: "skip" },
177
+ { selector: "style", format: "skip" },
178
+ ],
179
+ })
180
+ : "";
181
+ const replyText = parseReplyText(plainBody);
182
+ const bodyHtmlR2Key = parsed.html
183
+ ? `mailboxes/${input.mailboxId}/messages/${messageId}/body.html`
184
+ : null;
185
+ if (bodyHtmlR2Key && parsed.html) {
186
+ await input.env.MAIL_STORAGE.put(bodyHtmlR2Key, parsed.html, {
187
+ httpMetadata: { contentType: "text/html; charset=utf-8" },
188
+ });
189
+ }
190
+
191
+ const attachments: StoredAttachment[] = [];
192
+ for (const [index, file] of parsed.attachments.entries()) {
193
+ const id = `att_${messageId}_${index}`;
194
+ const r2Key =
195
+ `mailboxes/${input.mailboxId}/messages/${messageId}/attachments/${id}`;
196
+ const bytes = attachmentBytes(file.content);
197
+ await input.env.MAIL_STORAGE.put(r2Key, bytes, {
198
+ httpMetadata: { contentType: file.mimeType },
199
+ });
200
+ attachments.push({
201
+ id,
202
+ r2Key,
203
+ filename: file.filename || "attachment",
204
+ contentType: file.mimeType,
205
+ size: bytes.byteLength,
206
+ contentId: file.contentId ?? null,
207
+ disposition: file.disposition === "inline" ? "inline" : "attachment",
208
+ delivery: "attached",
209
+ downloadTokenHash: null,
210
+ downloadExpiresAt: null,
211
+ });
212
+ }
213
+
214
+ const email: NewEmail = {
215
+ id: messageId,
216
+ conversationId: parent && !detached ? parent.conversationId : createId("conv"),
217
+ direction: "incoming",
218
+ messageIdHeader: messageIds(parsed.messageId)[0] ?? null,
219
+ inReplyToJson: inReplyTo,
220
+ referencesJson: references,
221
+ fromJson: from,
222
+ replyToJson: replyTo,
223
+ toJson: to,
224
+ ccJson: cc,
225
+ bccJson: bcc,
226
+ subject: parsed.subject?.trim() || "(no subject)",
227
+ preview: replyText.bodyText.replace(/\s+/gu, " ").trim().slice(0, 220),
228
+ bodyText: replyText.bodyText,
229
+ quotedText: replyText.quotedText,
230
+ bodyHtmlR2Key,
231
+ rawMimeR2Key: job.rawObjectKey,
232
+ attachmentsJson: attachments,
233
+ listId: parsedListId,
234
+ listPostAddress: parsedListPost,
235
+ ...inboundHeaderMetadata({
236
+ rawHeaders: rawHeaderText(input.rawMime),
237
+ fromAddress: from[0]?.address ?? null,
238
+ envelopeFrom: job.envelopeFrom || null,
239
+ }),
240
+ envelopeFrom: job.envelopeFrom || null,
241
+ envelopeTo: job.envelopeTo || null,
242
+ timelineAt: job.receivedAt,
243
+ transportState: "received",
244
+ deliveryStatusJson: [],
245
+ };
246
+ return {
247
+ email,
248
+ // Over-collect: workspace domains are filtered out once D1 has been read.
249
+ linkDomains: linkDomains({
250
+ html: parsed.html,
251
+ text: [parsed.text],
252
+ limit: MAX_LINK_DOMAINS * 2,
253
+ }),
254
+ };
255
+ }