@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,172 @@
1
+ import type { BimiResult, BimiStatus } from "../../shared/mail-sender-intel";
2
+ import { isAbortError, type DnsResolver } from "./doh";
3
+ import { assertProxyableRemoteUrl } from "./remote-proxy";
4
+
5
+ /**
6
+ * BIMI logo lookup without VMC/CMC validation: we show the domain's own logo
7
+ * only after DMARC passed under an enforcing policy, but never a checkmark.
8
+ * https://datatracker.ietf.org/doc/draft-brand-indicators-for-message-identification/
9
+ */
10
+
11
+ export const MAX_BIMI_SVG_BYTES = 32 * 1024;
12
+ const BIMI_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
13
+ const BIMI_ERROR_TTL_MS = 60 * 60 * 1000;
14
+
15
+ export function bimiObjectKey(domain: string) {
16
+ return `bimi/v1/${domain}.svg`;
17
+ }
18
+
19
+ export function bimiLogoPath(domain: string) {
20
+ return `/api/mail/bimi/${encodeURIComponent(domain)}`;
21
+ }
22
+
23
+ /** Strict CSP for serving stored logos: no scripts, no external loads. */
24
+ export const BIMI_LOGO_HEADERS = {
25
+ "content-type": "image/svg+xml",
26
+ "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox",
27
+ "x-content-type-options": "nosniff",
28
+ "cross-origin-resource-policy": "same-origin",
29
+ "cache-control": "private, max-age=86400",
30
+ } as const;
31
+
32
+ /** `v=BIMI1; l=https://…/logo.svg; a=…` → the logo location, or null when declined. */
33
+ export function parseBimiRecord(value: string) {
34
+ const tags = new Map<string, string>();
35
+ for (const part of value.split(";")) {
36
+ const index = part.indexOf("=");
37
+ if (index < 0) continue;
38
+ tags.set(part.slice(0, index).trim().toLocaleLowerCase(), part.slice(index + 1).trim());
39
+ }
40
+ if (tags.get("v") !== "BIMI1") return null;
41
+ const location = tags.get("l")?.split(",")[0]?.trim() ?? "";
42
+ return { location: location || null };
43
+ }
44
+
45
+ const FORBIDDEN_ELEMENTS =
46
+ /<\s*(?:script|foreignobject|iframe|object|embed|image|a|animate\w*|set|audio|video|canvas|handler|listener|discard)\b/iu;
47
+
48
+ /**
49
+ * Conservative SVG Tiny PS check. Rejects anything that could run code or load
50
+ * another resource; returns the trimmed document, or a reason it was refused.
51
+ */
52
+ export function validateBimiSvg(source: string): { svg: string } | { error: string } {
53
+ if (new TextEncoder().encode(source).byteLength > MAX_BIMI_SVG_BYTES) return { error: "The logo is larger than 32 KB" };
54
+ const svg = source.replace(/^/u, "").trim();
55
+ const body = svg.replace(/^<\?xml[^>]*\?>\s*/iu, "").replace(/<!--[\s\S]*?-->/gu, "").trim();
56
+ if (/<!DOCTYPE|<!ENTITY|<!\[CDATA\[/iu.test(body)) return { error: "The logo declares a DTD, entity or CDATA section" };
57
+ if (!/^<svg[\s>]/iu.test(body) || !/<\/svg>\s*$/iu.test(body)) return { error: "The file is not an SVG document" };
58
+ if (!/xmlns\s*=\s*["']http:\/\/www\.w3\.org\/2000\/svg["']/u.test(body)) return { error: "The SVG namespace is missing" };
59
+ if (FORBIDDEN_ELEMENTS.test(body)) return { error: "The logo contains scripts, links, images or animation" };
60
+ if (/\son[a-z]+\s*=/iu.test(body)) return { error: "The logo contains event handlers" };
61
+ if (/javascript:|data:|@import/iu.test(body)) return { error: "The logo references executable or embedded content" };
62
+ for (const match of body.matchAll(/\b(?:xlink:)?href\s*=\s*["']([^"']*)["']/giu)) {
63
+ if (!match[1]!.trim().startsWith("#")) return { error: "The logo references an external resource" };
64
+ }
65
+ for (const match of body.matchAll(/url\(\s*['"]?([^'")]*)/giu)) {
66
+ if (!match[1]!.trim().startsWith("#")) return { error: "The logo references an external resource" };
67
+ }
68
+ return { svg };
69
+ }
70
+
71
+ async function readCapped(response: Response, limit: number) {
72
+ const reader = response.body?.getReader();
73
+ if (!reader) return null;
74
+ const chunks: Uint8Array[] = [];
75
+ let total = 0;
76
+ for (;;) {
77
+ const { done, value } = await reader.read();
78
+ if (done) break;
79
+ total += value.byteLength;
80
+ if (total > limit) {
81
+ await reader.cancel();
82
+ return null;
83
+ }
84
+ chunks.push(value);
85
+ }
86
+ const bytes = new Uint8Array(total);
87
+ let offset = 0;
88
+ for (const chunk of chunks) {
89
+ bytes.set(chunk, offset);
90
+ offset += chunk.byteLength;
91
+ }
92
+ return new TextDecoder().decode(bytes);
93
+ }
94
+
95
+ type Lookup = { status: BimiStatus; reason: string | null; svg?: string };
96
+
97
+ async function lookup(input: {
98
+ resolve: DnsResolver;
99
+ fetch: typeof fetch;
100
+ domain: string;
101
+ signal?: AbortSignal;
102
+ }): Promise<Lookup> {
103
+ const answer = await input.resolve(`default._bimi.${input.domain}`, "TXT");
104
+ if (answer.status === "timeout" || answer.status === "error") {
105
+ return { status: "error", reason: "The BIMI record could not be looked up" };
106
+ }
107
+ const record = answer.records.map(parseBimiRecord).find(Boolean);
108
+ if (!record) return { status: "none", reason: "The domain publishes no BIMI record" };
109
+ if (!record.location) return { status: "none", reason: "The domain declined to show a logo" };
110
+ let url: URL;
111
+ try {
112
+ url = assertProxyableRemoteUrl(record.location);
113
+ } catch {
114
+ return { status: "invalid", reason: "The BIMI logo location is not a valid public URL" };
115
+ }
116
+ if (url.protocol !== "https:") return { status: "invalid", reason: "The BIMI logo is not served over HTTPS" };
117
+ try {
118
+ const response = await input.fetch(url, {
119
+ redirect: "manual",
120
+ headers: { accept: "image/svg+xml" },
121
+ signal: input.signal,
122
+ });
123
+ if (!response.ok) return { status: "error", reason: `The logo server answered ${response.status}` };
124
+ const declared = Number(response.headers.get("content-length") ?? 0);
125
+ if (declared > MAX_BIMI_SVG_BYTES) return { status: "invalid", reason: "The logo is larger than 32 KB" };
126
+ const text = await readCapped(response, MAX_BIMI_SVG_BYTES);
127
+ if (text === null) return { status: "invalid", reason: "The logo is larger than 32 KB" };
128
+ const checked = validateBimiSvg(text);
129
+ if ("error" in checked) return { status: "invalid", reason: checked.error };
130
+ return { status: "ok", reason: null, svg: checked.svg };
131
+ } catch (error) {
132
+ return { status: "error", reason: isAbortError(error) ? "The logo download timed out" : "The logo could not be downloaded" };
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Per-domain logo, cached in R2 for a day (an hour after a failure). The
138
+ * From domain's record wins; the organizational domain is the fallback.
139
+ */
140
+ export async function resolveBimi(input: {
141
+ resolve: DnsResolver;
142
+ fetch?: typeof fetch;
143
+ bucket: R2Bucket;
144
+ fromDomain: string;
145
+ orgDomain: string | null;
146
+ signal?: AbortSignal;
147
+ now?: number;
148
+ }): Promise<BimiResult> {
149
+ const now = input.now ?? Date.now();
150
+ const domains = [...new Set([input.fromDomain, input.orgDomain].filter((value): value is string => Boolean(value)))];
151
+ let last: BimiResult | null = null;
152
+ for (const domain of domains) {
153
+ const key = bimiObjectKey(domain);
154
+ const cached = await input.bucket.head(key).catch(() => null);
155
+ const checkedAt = Number(cached?.customMetadata?.checkedAt ?? 0);
156
+ const cachedStatus = cached?.customMetadata?.status as BimiStatus | undefined;
157
+ const ttl = cachedStatus === "error" ? BIMI_ERROR_TTL_MS : BIMI_CACHE_TTL_MS;
158
+ let result: Lookup;
159
+ if (cached && cachedStatus && now - checkedAt < ttl) {
160
+ result = { status: cachedStatus, reason: cached.customMetadata?.reason || null };
161
+ } else {
162
+ result = await lookup({ resolve: input.resolve, fetch: input.fetch ?? fetch, domain, signal: input.signal });
163
+ await input.bucket.put(key, result.svg ?? "", {
164
+ httpMetadata: { contentType: "image/svg+xml" },
165
+ customMetadata: { status: result.status, checkedAt: String(now), reason: result.reason ?? "" },
166
+ }).catch(() => undefined);
167
+ }
168
+ last = { domain, status: result.status, reason: result.reason };
169
+ if (result.status !== "none") return last;
170
+ }
171
+ return last ?? { domain: input.fromDomain, status: "none", reason: "The domain publishes no BIMI record" };
172
+ }
@@ -0,0 +1,116 @@
1
+ import type { InboundAuthentication } from "../../shared/mail-authentication";
2
+ import type { DeliveryFacts, SecurityWarning, SenderIntel } from "../../shared/mail-sender-intel";
3
+
4
+ /** Compact, model-facing summary of what the receiver measured for a message. */
5
+ export type ClassificationSignals = {
6
+ authentication: {
7
+ verdict: InboundAuthentication["verdict"];
8
+ spf: string | null;
9
+ dkimAlignedPass: boolean;
10
+ dmarc: string | null;
11
+ dmarcPolicy: string | null;
12
+ } | null;
13
+ cloudflareSpamScore: number | null;
14
+ sendingServer: {
15
+ ip: string;
16
+ ptr: string | null;
17
+ forwardConfirmed: boolean | null;
18
+ genericPtr: boolean | null;
19
+ heloMatchesPtr: boolean | null;
20
+ asn: number | null;
21
+ asOrganization: string | null;
22
+ country: string | null;
23
+ } | null;
24
+ blocklists: {
25
+ zen: string[];
26
+ dbl: Array<{ domain: string; list: string }>;
27
+ } | null;
28
+ firstTimeSender: boolean | null;
29
+ warnings: Array<SecurityWarning["kind"]>;
30
+ /** What people in this mailbox reported about the sender before (counts). */
31
+ userFeedback: {
32
+ spamReports: number;
33
+ phishingReports: number;
34
+ notSpam: number;
35
+ /** Spam or phishing reports about other addresses at the same domain. */
36
+ domainReports: number;
37
+ } | null;
38
+ /** Who the message was written for, the address it reached, and who forwarded it. */
39
+ delivery: {
40
+ deliveredTo: string | null;
41
+ addressedToThisMailbox: boolean;
42
+ addressedTo: string | null;
43
+ knownAddressee: boolean;
44
+ forwardedVia: string | null;
45
+ } | null;
46
+ /** The same counts from every other mailbox in the workspace. */
47
+ workspaceFeedback: {
48
+ spamReports: number;
49
+ phishingReports: number;
50
+ notSpam: number;
51
+ domainReports: number;
52
+ reportingMailboxes: number;
53
+ } | null;
54
+ };
55
+
56
+ export function classificationSignals(input: {
57
+ authentication: InboundAuthentication | null;
58
+ intel: SenderIntel | null;
59
+ firstTimeSender: boolean | null;
60
+ warnings: SecurityWarning[];
61
+ feedback?: ClassificationSignals["userFeedback"];
62
+ workspaceFeedback?: ClassificationSignals["workspaceFeedback"];
63
+ delivery?: DeliveryFacts | null;
64
+ }): ClassificationSignals {
65
+ const auth = input.authentication;
66
+ const intel = input.intel;
67
+ return {
68
+ authentication: auth && auth.verdict !== "unavailable"
69
+ ? {
70
+ verdict: auth.verdict,
71
+ spf: auth.spf?.result ?? null,
72
+ dkimAlignedPass: auth.dkim.some((dkim) => dkim.result === "pass" && dkim.aligned),
73
+ dmarc: auth.dmarc?.result ?? null,
74
+ dmarcPolicy: auth.dmarc?.policy ?? null,
75
+ }
76
+ : null,
77
+ cloudflareSpamScore: auth?.spamScore ?? null,
78
+ sendingServer: intel?.ip
79
+ ? {
80
+ ip: intel.ip,
81
+ ptr: intel.ptr.confirmed ?? intel.ptr.names[0] ?? null,
82
+ forwardConfirmed: intel.ptr.fcrdns,
83
+ genericPtr: intel.ptr.generic,
84
+ heloMatchesPtr: intel.heloMatchesPtr,
85
+ asn: intel.network.asn,
86
+ asOrganization: intel.network.asName,
87
+ country: intel.network.country,
88
+ }
89
+ : null,
90
+ blocklists: intel?.spamhaus
91
+ ? {
92
+ zen: intel.spamhaus.zen.listings.map((listing) => listing.list),
93
+ dbl: intel.spamhaus.dbl.listings.map((listing) => ({ domain: listing.target, list: listing.list })),
94
+ }
95
+ : null,
96
+ firstTimeSender: input.firstTimeSender,
97
+ warnings: input.warnings.map((warning) => warning.kind),
98
+ userFeedback: input.feedback
99
+ && Object.values(input.feedback).some((count) => count > 0)
100
+ ? input.feedback
101
+ : null,
102
+ delivery: input.delivery
103
+ ? {
104
+ deliveredTo: input.delivery.deliveredTo,
105
+ addressedToThisMailbox: !input.delivery.addressedTo,
106
+ addressedTo: input.delivery.addressedTo,
107
+ knownAddressee: input.delivery.knownAddressee,
108
+ forwardedVia: input.delivery.forwardedVia,
109
+ }
110
+ : null,
111
+ workspaceFeedback: input.workspaceFeedback
112
+ && Object.values(input.workspaceFeedback).some((count) => count > 0)
113
+ ? input.workspaceFeedback
114
+ : null,
115
+ };
116
+ }
@@ -0,0 +1,43 @@
1
+ import type { ConversationCursorPosition } from "../mailbox";
2
+
3
+ const CURSOR_VERSION = 1;
4
+
5
+ function toBase64Url(value: string) {
6
+ const bytes = new TextEncoder().encode(value);
7
+ const binary = String.fromCharCode(...bytes);
8
+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/u, "");
9
+ }
10
+
11
+ function fromBase64Url(value: string) {
12
+ const base64 = value.replaceAll("-", "+").replaceAll("_", "/");
13
+ const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "=");
14
+ const bytes = Uint8Array.from(atob(padded), (character) => character.charCodeAt(0));
15
+ return new TextDecoder().decode(bytes);
16
+ }
17
+
18
+ export function encodeConversationCursor(position: ConversationCursorPosition) {
19
+ return toBase64Url(JSON.stringify([
20
+ CURSOR_VERSION,
21
+ position.timelineAt,
22
+ position.emailId,
23
+ ]));
24
+ }
25
+
26
+ export function decodeConversationCursor(value: string) {
27
+ try {
28
+ const decoded: unknown = JSON.parse(fromBase64Url(value));
29
+ if (
30
+ !Array.isArray(decoded)
31
+ || decoded.length !== 3
32
+ || decoded[0] !== CURSOR_VERSION
33
+ || !Number.isSafeInteger(decoded[1])
34
+ || typeof decoded[2] !== "string"
35
+ || decoded[2].length === 0
36
+ ) {
37
+ return null;
38
+ }
39
+ return { timelineAt: decoded[1], emailId: decoded[2] };
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
@@ -0,0 +1,99 @@
1
+ import { z } from "zod";
2
+ import { createDb } from "@open-workspace/core/worker/db/client.ts";
3
+ import { domains, mailboxes } from "@open-workspace/core/worker/db/schema.ts";
4
+ import { mailboxAddressPredicate } from "@open-workspace/core/worker/db/mailboxes.ts";
5
+ import { normalizeMailboxAddress } from "@open-workspace/core/worker/lib/ids.ts";
6
+ import { mailboxStub } from "../mailbox";
7
+ import {
8
+ deliveryStatuses,
9
+ type RecipientDeliveryStatus,
10
+ } from "../mailbox/model";
11
+
12
+ const deliveryEventSchema = z.object({
13
+ type: z.string().startsWith("cf.email.sending.message."),
14
+ source: z.object({
15
+ type: z.literal("email.sending"),
16
+ }).passthrough(),
17
+ payload: z.object({
18
+ eventId: z.string().min(1),
19
+ messageId: z.string().min(1),
20
+ sender: z.string().min(1),
21
+ recipient: z.string().min(1),
22
+ delivery: z.object({
23
+ status: z.enum(deliveryStatuses),
24
+ smtpStatusCode: z.string().optional(),
25
+ }).passthrough(),
26
+ bounce: z.object({ reason: z.string().optional() }).optional(),
27
+ failure: z.object({ reason: z.string().optional() }).optional(),
28
+ rejection: z.object({
29
+ reason: z.string().optional(),
30
+ detail: z.string().optional(),
31
+ }).optional(),
32
+ complaint: z.object({ type: z.string().optional() }).optional(),
33
+ }).passthrough(),
34
+ metadata: z.object({
35
+ eventTimestamp: z.string().datetime(),
36
+ }).passthrough(),
37
+ }).passthrough();
38
+
39
+ type DeliveryEvent = z.infer<typeof deliveryEventSchema>;
40
+
41
+ function deliveryDetail(event: DeliveryEvent) {
42
+ return event.payload.bounce?.reason
43
+ ?? event.payload.failure?.reason
44
+ ?? event.payload.rejection?.detail
45
+ ?? event.payload.rejection?.reason
46
+ ?? event.payload.complaint?.type
47
+ ?? null;
48
+ }
49
+
50
+ function recipientStatus(event: DeliveryEvent): RecipientDeliveryStatus {
51
+ return {
52
+ recipient: normalizeMailboxAddress(event.payload.recipient),
53
+ status: event.payload.delivery.status,
54
+ eventId: event.payload.eventId,
55
+ eventAt: new Date(event.metadata.eventTimestamp).getTime(),
56
+ smtpCode: event.payload.delivery.smtpStatusCode ?? null,
57
+ detail: deliveryDetail(event),
58
+ };
59
+ }
60
+
61
+ async function processDeliveryEvent(env: Env, event: DeliveryEvent) {
62
+ const sender = normalizeMailboxAddress(event.payload.sender);
63
+ const [mailbox] = await createDb(env.DB)
64
+ .select({ id: mailboxes.id })
65
+ .from(mailboxes)
66
+ .innerJoin(domains, mailboxAddressPredicate(sender))
67
+ .limit(1);
68
+ if (!mailbox) return "ignored" as const;
69
+ return mailboxStub(env, mailbox.id).recordDeliveryStatus(
70
+ event.payload.messageId,
71
+ recipientStatus(event),
72
+ );
73
+ }
74
+
75
+ export async function consumeDeliveryEvents(
76
+ batch: MessageBatch<unknown>,
77
+ env: Env,
78
+ ) {
79
+ await Promise.all(batch.messages.map(async (message) => {
80
+ const parsed = deliveryEventSchema.safeParse(message.body);
81
+ if (!parsed.success) {
82
+ console.error("Ignoring invalid Email Service event", parsed.error);
83
+ message.ack();
84
+ return;
85
+ }
86
+ try {
87
+ const result = await processDeliveryEvent(env, parsed.data);
88
+ if (result === "not_found") {
89
+ // Delivery events can beat the write that stores EmailSendResult.messageId.
90
+ message.retry({ delaySeconds: 10 });
91
+ return;
92
+ }
93
+ message.ack();
94
+ } catch (error) {
95
+ console.error("Could not persist Email Service event", error);
96
+ message.retry({ delaySeconds: 10 });
97
+ }
98
+ }));
99
+ }
@@ -0,0 +1,122 @@
1
+ import type { InboundAuthentication } from "../../shared/mail-authentication";
2
+ import { addressDomain } from "../../shared/mail-headers";
3
+ import type { DeliveryFacts } from "../../shared/mail-sender-intel";
4
+ import { isWorkspaceDomain } from "./message-security";
5
+ import { organizationalDomain } from "./org-domain";
6
+
7
+ /**
8
+ * Providers whose account and security notices are worth forging or
9
+ * replaying: their alerts are trusted on sight and can carry text a user
10
+ * typed into an app, device, or invoice name.
11
+ */
12
+ const ACCOUNT_NOTICE_DOMAINS = new Set([
13
+ "google.com",
14
+ "apple.com",
15
+ "icloud.com",
16
+ "microsoft.com",
17
+ "microsoftonline.com",
18
+ "live.com",
19
+ "outlook.com",
20
+ "paypal.com",
21
+ "amazon.com",
22
+ "facebookmail.com",
23
+ "instagram.com",
24
+ "github.com",
25
+ "docusign.net",
26
+ "dropbox.com",
27
+ "linkedin.com",
28
+ "x.com",
29
+ "coinbase.com",
30
+ "binance.com",
31
+ ]);
32
+
33
+ export function isAccountNoticeSender(fromDomain: string | null | undefined) {
34
+ const org = organizationalDomain(fromDomain);
35
+ return Boolean(org && ACCOUNT_NOTICE_DOMAINS.has(org));
36
+ }
37
+
38
+ /**
39
+ * The organization that forwarded a message, when the envelope or ARC chain
40
+ * shows a hop after the author's own servers: an SRS-rewritten envelope
41
+ * sender, an upstream ARC chain with another organization's envelope (Gmail
42
+ * forwarding rewrites it to `name+caf_=…@gmail.com`), or an upstream ARC set
43
+ * sealed by someone other than the author.
44
+ */
45
+ export function forwardingRelay(auth: InboundAuthentication | null) {
46
+ if (!auth) return null;
47
+ const fromOrg = organizationalDomain(auth.fromDomain);
48
+ const envelope = auth.envelopeFrom?.toLocaleLowerCase() ?? "";
49
+ const envelopeOrg = organizationalDomain(addressDomain(envelope));
50
+ const foreignEnvelope = envelopeOrg && envelopeOrg !== fromOrg ? envelopeOrg : null;
51
+ if (/^srs[01][=+-]/u.test(envelope) && foreignEnvelope) return foreignEnvelope;
52
+ if (auth.arc && auth.arc.chainLength > 1) {
53
+ if (foreignEnvelope) return foreignEnvelope;
54
+ const sealer = auth.arc.sealers
55
+ .map((item) => organizationalDomain(item.domain))
56
+ .find((domain) => domain && domain !== fromOrg);
57
+ if (sealer) return sealer;
58
+ }
59
+ return null;
60
+ }
61
+
62
+ /**
63
+ * The first To/Cc address when the message names neither the address it was
64
+ * delivered to nor any workspace address. Lists and undisclosed recipients
65
+ * are left alone: they never name the reader.
66
+ */
67
+ export function addressedElsewhere(input: {
68
+ deliveredTo: string | null;
69
+ to: readonly string[];
70
+ cc: readonly string[];
71
+ listId: string | null;
72
+ workspaceDomains: readonly string[];
73
+ }) {
74
+ if (input.listId) return null;
75
+ const recipients = [...input.to, ...input.cc].map((address) => address.toLocaleLowerCase()).filter((address) => address.includes("@"));
76
+ if (!recipients.length) return null;
77
+ const deliveredTo = input.deliveredTo?.toLocaleLowerCase();
78
+ if (deliveredTo && recipients.includes(deliveredTo)) return null;
79
+ if (recipients.some((address) => isWorkspaceDomain(addressDomain(address), input.workspaceDomains))) return null;
80
+ return recipients[0]!;
81
+ }
82
+
83
+ export function deliveryFacts(input: {
84
+ authentication: InboundAuthentication | null;
85
+ deliveredTo: string | null;
86
+ to: readonly string[];
87
+ cc: readonly string[];
88
+ listId: string | null;
89
+ workspaceDomains: readonly string[];
90
+ /** Whether earlier mail to the addressee already reached this mailbox from several senders. */
91
+ isKnownAddressee: (address: string) => boolean;
92
+ }): DeliveryFacts {
93
+ const addressedTo = addressedElsewhere(input);
94
+ return {
95
+ deliveredTo: input.deliveredTo?.toLocaleLowerCase() ?? null,
96
+ addressedTo,
97
+ knownAddressee: addressedTo ? input.isKnownAddressee(addressedTo) : false,
98
+ forwardedVia: forwardingRelay(input.authentication),
99
+ };
100
+ }
101
+
102
+ /**
103
+ * The warning for mail written for someone else. Plain Bcc and newsletters
104
+ * are not forwarded and stay quiet; a provider's account notice is suspicious
105
+ * even without visible forwarding, because those are never Bcc'd.
106
+ */
107
+ export function addressedElsewhereWarning(delivery: DeliveryFacts | null, fromDomain: string | null) {
108
+ if (!delivery?.addressedTo || delivery.knownAddressee) return null;
109
+ const accountNotice = isAccountNoticeSender(fromDomain);
110
+ if (!delivery.forwardedVia && !accountNotice) return null;
111
+ // A provider forwarding its own user's mail (Gmail auto-forwarding needs the
112
+ // target to confirm) is likely the person's own setup.
113
+ const ownForwarding = delivery.forwardedVia !== null
114
+ && delivery.forwardedVia === organizationalDomain(delivery.addressedTo);
115
+ return {
116
+ kind: "addressed_elsewhere" as const,
117
+ severity: accountNotice && !ownForwarding ? "danger" as const : "caution" as const,
118
+ addressedTo: delivery.addressedTo,
119
+ forwardedVia: delivery.forwardedVia,
120
+ accountNotice,
121
+ };
122
+ }
@@ -0,0 +1,33 @@
1
+ import { Hono } from "hono";
2
+ import type { AppEnv } from "@open-workspace/core/worker/env.ts";
3
+ import { apiError } from "@open-workspace/core/worker/lib/http.ts";
4
+ import type { OneClickFetcher } from "./unsubscribe";
5
+
6
+ /**
7
+ * Demo newsletters point their RFC 8058 endpoint at this reserved host. The
8
+ * unsubscribe route answers it in-process (never over the network), and
9
+ * only while mock authentication is enabled.
10
+ */
11
+ export const DEMO_UNSUBSCRIBE_HOST = "unsubscribe.openworkspace-demo.invalid";
12
+
13
+ export const demoRoutes = new Hono<AppEnv>()
14
+ .post("/list-unsubscribe/:token", async (c) => {
15
+ if (c.env.ALLOW_MOCK_AUTH !== "true") return apiError(c, 404, "NOT_FOUND", "Not found");
16
+ const body = new TextDecoder().decode(await c.req.arrayBuffer());
17
+ if (body.trim() !== "List-Unsubscribe=One-Click") {
18
+ return apiError(c, 400, "BAD_REQUEST", "Expected an RFC 8058 one-click body");
19
+ }
20
+ return c.text("You have been unsubscribed.");
21
+ });
22
+
23
+ /** Real endpoints go to the network; the demo host is served by `demoRoutes`. */
24
+ export function oneClickFetcher(env: Env): OneClickFetcher {
25
+ return (request) => {
26
+ const url = new URL(request.url);
27
+ if (env.ALLOW_MOCK_AUTH === "true" && url.hostname === DEMO_UNSUBSCRIBE_HOST) {
28
+ const local = new URL(`/list-unsubscribe${url.pathname.replace(/^\/[^/]*/u, "")}`, "http://demo.local");
29
+ return Promise.resolve(demoRoutes.fetch(new Request(local, request), env));
30
+ }
31
+ return fetch(request);
32
+ };
33
+ }
@@ -0,0 +1,74 @@
1
+ import type { LookupStatus } from "../../shared/mail-sender-intel";
2
+
3
+ /**
4
+ * DNS-over-HTTPS (JSON API) client. Workers have no raw UDP, so every DNS
5
+ * lookup in sender intelligence goes through this one mockable function.
6
+ * https://developers.cloudflare.com/1.1.1.1/encryption/dns-over-https/make-api-requests/dns-json/
7
+ */
8
+
9
+ export const DOH_ENDPOINT = "https://cloudflare-dns.com/dns-query";
10
+
11
+ export type DnsRecordType = "A" | "AAAA" | "PTR" | "TXT";
12
+
13
+ const recordTypeCodes: Record<DnsRecordType, number> = { A: 1, AAAA: 28, PTR: 12, TXT: 16 };
14
+
15
+ export type DnsAnswer = {
16
+ status: Exclude<LookupStatus, "skipped">;
17
+ /** Answer data of the requested type; TXT strings are unquoted and joined. */
18
+ records: string[];
19
+ };
20
+
21
+ export type DnsResolver = (name: string, type: DnsRecordType) => Promise<DnsAnswer>;
22
+
23
+ type DohJson = {
24
+ Status?: number;
25
+ Answer?: Array<{ type?: number; data?: string }>;
26
+ };
27
+
28
+ /** `"v=BIMI1; " "l=https://…"` → `v=BIMI1; l=https://…` (RFC 7208 §3.3 joining). */
29
+ export function txtData(value: string) {
30
+ const chunks = [...value.matchAll(/"((?:[^"\\]|\\.)*)"/gu)].map((match) =>
31
+ match[1]!.replace(/\\(.)/gu, "$1")
32
+ );
33
+ return chunks.length ? chunks.join("") : value;
34
+ }
35
+
36
+ export function isAbortError(error: unknown) {
37
+ return error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError");
38
+ }
39
+
40
+ export function createDohResolver(options: {
41
+ fetch?: typeof fetch;
42
+ signal?: AbortSignal;
43
+ endpoint?: string;
44
+ } = {}): DnsResolver {
45
+ const fetcher = options.fetch ?? fetch;
46
+ const endpoint = options.endpoint ?? DOH_ENDPOINT;
47
+ return async (name, type) => {
48
+ if (options.signal?.aborted) return { status: "timeout", records: [] };
49
+ const url = new URL(endpoint);
50
+ url.searchParams.set("name", name);
51
+ url.searchParams.set("type", type);
52
+ try {
53
+ const response = await fetcher(url, {
54
+ headers: { accept: "application/dns-json" },
55
+ signal: options.signal,
56
+ });
57
+ if (!response.ok) return { status: "error", records: [] };
58
+ const body = await response.json() as DohJson;
59
+ if (body.Status === 3) return { status: "nxdomain", records: [] };
60
+ if (body.Status !== 0) return { status: "error", records: [] };
61
+ const code = recordTypeCodes[type];
62
+ const records = (body.Answer ?? [])
63
+ .filter((answer) => answer.type === code && typeof answer.data === "string")
64
+ .map((answer) => {
65
+ const data = answer.data!.trim();
66
+ if (type === "TXT") return txtData(data);
67
+ return data.replace(/\.$/u, "").toLocaleLowerCase();
68
+ });
69
+ return { status: "ok", records };
70
+ } catch (error) {
71
+ return { status: isAbortError(error) || options.signal?.aborted ? "timeout" : "error", records: [] };
72
+ }
73
+ };
74
+ }