@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.
- package/LICENSE +202 -0
- package/README.md +29 -2
- package/alchemy.ts +126 -0
- package/containers/office-preview/Dockerfile +10 -0
- package/index.ts +3 -0
- package/migrations/0000_email.sql +53 -0
- package/migrations/meta/0000_snapshot.json +408 -0
- package/migrations/meta/_journal.json +13 -0
- package/package.json +91 -4
- package/shared/attachment-preview.ts +67 -0
- package/shared/mail-authentication.ts +145 -0
- package/shared/mail-feedback.ts +90 -0
- package/shared/mail-headers.ts +250 -0
- package/shared/mail-received.ts +298 -0
- package/shared/mail-remote.ts +17 -0
- package/shared/mail-rules.ts +156 -0
- package/shared/mail-sender-intel.ts +125 -0
- package/shared/mail-translation.ts +2 -0
- package/shared/mail.ts +179 -0
- package/shared/office-preview.ts +26 -0
- package/worker/admin/report-routes.ts +164 -0
- package/worker/bindings.d.ts +11 -0
- package/worker/db/schema.ts +97 -0
- package/worker/mail/access.ts +30 -0
- package/worker/mail/ai-classification.ts +119 -0
- package/worker/mail/ai-translation.ts +31 -0
- package/worker/mail/auth-headers.ts +557 -0
- package/worker/mail/bimi.ts +172 -0
- package/worker/mail/classification-signals.ts +116 -0
- package/worker/mail/conversation-cursor.ts +43 -0
- package/worker/mail/delivery-events.ts +99 -0
- package/worker/mail/delivery-facts.ts +122 -0
- package/worker/mail/demo-routes.ts +33 -0
- package/worker/mail/doh.ts +74 -0
- package/worker/mail/downloads.ts +47 -0
- package/worker/mail/header-metadata.ts +77 -0
- package/worker/mail/impersonation.ts +175 -0
- package/worker/mail/inbound-delivery.ts +22 -0
- package/worker/mail/inbound-threading.ts +48 -0
- package/worker/mail/inbound.ts +255 -0
- package/worker/mail/ip-intel.ts +135 -0
- package/worker/mail/link-domains.ts +57 -0
- package/worker/mail/mailbox-deletion.ts +18 -0
- package/worker/mail/mailbox-directory.ts +73 -0
- package/worker/mail/mailing-list.ts +120 -0
- package/worker/mail/message-security.ts +190 -0
- package/worker/mail/message-view.ts +63 -0
- package/worker/mail/office-conversion.ts +38 -0
- package/worker/mail/office-preview-container.ts +57 -0
- package/worker/mail/office-preview-queue.ts +49 -0
- package/worker/mail/org-domain.ts +15 -0
- package/worker/mail/outbound-content.ts +208 -0
- package/worker/mail/outbound-delivery.ts +83 -0
- package/worker/mail/outbound-service.ts +145 -0
- package/worker/mail/outbound-threading.ts +70 -0
- package/worker/mail/outbound.ts +446 -0
- package/worker/mail/outgoing-context.ts +122 -0
- package/worker/mail/participants.ts +115 -0
- package/worker/mail/push-notifications.ts +484 -0
- package/worker/mail/quote-parser.ts +50 -0
- package/worker/mail/r2-presigned-urls.ts +69 -0
- package/worker/mail/receive.ts +59 -0
- package/worker/mail/recipients.ts +34 -0
- package/worker/mail/remote-proxy.ts +140 -0
- package/worker/mail/reply-plan.ts +136 -0
- package/worker/mail/reported-messages.ts +68 -0
- package/worker/mail/rfc.ts +73 -0
- package/worker/mail/routes.ts +1434 -0
- package/worker/mail/safety-routes.ts +350 -0
- package/worker/mail/schemas.ts +339 -0
- package/worker/mail/search.ts +14 -0
- package/worker/mail/security-settings.ts +38 -0
- package/worker/mail/sender-intel.ts +189 -0
- package/worker/mail/spamhaus.ts +97 -0
- package/worker/mail/unsubscribe.ts +118 -0
- package/worker/mail/uploads.ts +537 -0
- package/worker/mail/webhook-source.ts +71 -0
- package/worker/mail/workspace-safety.ts +92 -0
- package/worker/mailbox/demo-html.ts +136 -0
- package/worker/mailbox/demo-seed.ts +79 -0
- package/worker/mailbox/demo.ts +978 -0
- package/worker/mailbox/folder-model.ts +72 -0
- package/worker/mailbox/index.ts +2508 -0
- package/worker/mailbox/legacy-migrations.ts +41 -0
- package/worker/mailbox/migrations.ts +10 -0
- package/worker/mailbox/model.ts +65 -0
- package/worker/mailbox/office-preview-jobs.ts +73 -0
- package/worker/mailbox/rules.ts +110 -0
- package/worker/mailbox/schema.ts +353 -0
- package/worker/mailbox/senders.ts +240 -0
- package/worker/mcp/mail-action-tools.ts +210 -0
- package/worker/mcp/mail-compose-tools.ts +196 -0
- package/worker/mcp/mail-query-tools.ts +196 -0
- package/worker/mcp/mail-safety-tools.ts +452 -0
- package/worker/mcp/tools.ts +15 -0
- package/worker/module.ts +59 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import type { SenderIntel } from "../../shared/mail-sender-intel";
|
|
2
|
+
import { normalizeIp } from "../../shared/mail-received";
|
|
3
|
+
import type { DnsResolver } from "./doh";
|
|
4
|
+
import { organizationalDomain } from "./org-domain";
|
|
5
|
+
|
|
6
|
+
const MAX_CONFIRMED_PTR_NAMES = 2;
|
|
7
|
+
|
|
8
|
+
export type IpIntel = Pick<SenderIntel, "ptr" | "network">;
|
|
9
|
+
|
|
10
|
+
function expandIpv6(ip: string) {
|
|
11
|
+
const [head = "", tail = ""] = ip.split("::");
|
|
12
|
+
const left = head ? head.split(":") : [];
|
|
13
|
+
const right = ip.includes("::") ? (tail ? tail.split(":") : []) : [];
|
|
14
|
+
const fill = Array.from({ length: 8 - left.length - right.length }, () => "0");
|
|
15
|
+
return [...left, ...fill, ...right].map((group) => group.padStart(4, "0")).join("");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** `1.2.3.4` → `4.3.2.1`; IPv6 → nibble-reversed hex, as used under in-addr.arpa / ip6.arpa. */
|
|
19
|
+
export function reversedIp(ip: string) {
|
|
20
|
+
if (ip.includes(":")) return [...expandIpv6(ip)].reverse().join(".");
|
|
21
|
+
return ip.split(".").reverse().join(".");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function reverseDnsName(ip: string) {
|
|
25
|
+
return `${reversedIp(ip)}.${ip.includes(":") ? "ip6.arpa" : "in-addr.arpa"}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const GENERIC_PTR_WORDS =
|
|
29
|
+
/(?:^|[.-])(?:dynamic|dyn|pool|dsl|adsl|xdsl|vdsl|dhcp|dialup|dial|ppp|pppoe|cable|broadband|customer|cust|clients|residential|cpe|fttx|ftth)(?:\d*[.-]|$)/u;
|
|
30
|
+
|
|
31
|
+
/** Auto-generated access-network names such as `1-2-3-4.dsl.example.net`. */
|
|
32
|
+
export function isGenericPtr(name: string, ip: string) {
|
|
33
|
+
const host = name.toLocaleLowerCase();
|
|
34
|
+
if (!ip.includes(":")) {
|
|
35
|
+
const octets = ip.split(".");
|
|
36
|
+
const forward = octets.join("[.-]");
|
|
37
|
+
const backward = [...octets].reverse().join("[.-]");
|
|
38
|
+
if (new RegExp(`(?:^|\\D)(?:${forward}|${backward})(?:\\D|$)`, "u").test(host)) return true;
|
|
39
|
+
const hex = octets.map((octet) => Number(octet).toString(16).padStart(2, "0")).join("");
|
|
40
|
+
if (host.includes(hex)) return true;
|
|
41
|
+
}
|
|
42
|
+
const firstLabels = host.split(".").slice(0, -2).join(".");
|
|
43
|
+
return GENERIC_PTR_WORDS.test(firstLabels);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** `"15169 | 8.8.8.0/24 | US | arin | 1992-12-01"` (first ASN wins for multi-origin prefixes). */
|
|
47
|
+
export function parseCymruOrigin(value: string) {
|
|
48
|
+
const [asns, prefix, country, registry] = value.split("|").map((part) => part.trim());
|
|
49
|
+
const asn = Number(asns?.split(/\s+/u)[0]);
|
|
50
|
+
if (!Number.isInteger(asn) || asn <= 0) return null;
|
|
51
|
+
return {
|
|
52
|
+
asn,
|
|
53
|
+
prefix: prefix || null,
|
|
54
|
+
country: country && /^[a-z]{2}$/iu.test(country) ? country.toUpperCase() : null,
|
|
55
|
+
registry: registry?.toLocaleLowerCase() || null,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** `"15169 | US | arin | 2000-03-30 | GOOGLE - Google LLC, US"` → `Google LLC`. */
|
|
60
|
+
export function parseCymruAsName(value: string) {
|
|
61
|
+
const raw = value.split("|").slice(4).join("|").trim();
|
|
62
|
+
if (!raw) return null;
|
|
63
|
+
const withoutCountry = raw.replace(/,\s*[A-Z]{2}$/u, "").trim();
|
|
64
|
+
const dash = withoutCountry.indexOf(" - ");
|
|
65
|
+
const name = dash >= 0 ? withoutCountry.slice(dash + 3).trim() : withoutCountry;
|
|
66
|
+
return name.slice(0, 120) || null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function ptrIntel(resolve: DnsResolver, ip: string): Promise<IpIntel["ptr"]> {
|
|
70
|
+
const answer = await resolve(reverseDnsName(ip), "PTR");
|
|
71
|
+
const names = [...new Set(answer.records)].filter(Boolean).slice(0, 5);
|
|
72
|
+
if (answer.status !== "ok" || !names.length) {
|
|
73
|
+
return {
|
|
74
|
+
status: answer.status === "ok" ? "nxdomain" : answer.status,
|
|
75
|
+
names: [],
|
|
76
|
+
confirmed: null,
|
|
77
|
+
fcrdns: answer.status === "ok" || answer.status === "nxdomain" ? false : null,
|
|
78
|
+
generic: null,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
const forwardType = ip.includes(":") ? "AAAA" : "A";
|
|
82
|
+
const checks = await Promise.all(names.slice(0, MAX_CONFIRMED_PTR_NAMES).map(async (name) => {
|
|
83
|
+
const forward = await resolve(name, forwardType);
|
|
84
|
+
const confirmed = forward.records.some((record) => normalizeIp(record) === ip);
|
|
85
|
+
return { name, status: forward.status, confirmed };
|
|
86
|
+
}));
|
|
87
|
+
const confirmed = checks.find((check) => check.confirmed)?.name ?? null;
|
|
88
|
+
const answered = checks.some((check) => check.status === "ok" || check.status === "nxdomain");
|
|
89
|
+
return {
|
|
90
|
+
status: "ok",
|
|
91
|
+
names,
|
|
92
|
+
confirmed,
|
|
93
|
+
fcrdns: confirmed ? true : answered ? false : null,
|
|
94
|
+
generic: isGenericPtr(confirmed ?? names[0]!, ip),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function networkIntel(resolve: DnsResolver, ip: string): Promise<IpIntel["network"]> {
|
|
99
|
+
const empty = { asn: null, asName: null, prefix: null, country: null, registry: null };
|
|
100
|
+
const zone = ip.includes(":") ? "origin6.asn.cymru.com" : "origin.asn.cymru.com";
|
|
101
|
+
const origin = await resolve(`${reversedIp(ip)}.${zone}`, "TXT");
|
|
102
|
+
const parsed = origin.records.map(parseCymruOrigin).find(Boolean) ?? null;
|
|
103
|
+
if (!parsed) {
|
|
104
|
+
return { status: origin.status === "ok" ? "nxdomain" : origin.status, ...empty };
|
|
105
|
+
}
|
|
106
|
+
const name = await resolve(`AS${parsed.asn}.asn.cymru.com`, "TXT");
|
|
107
|
+
return {
|
|
108
|
+
status: "ok",
|
|
109
|
+
...parsed,
|
|
110
|
+
asName: name.records.map(parseCymruAsName).find(Boolean) ?? null,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** PTR → forward confirmation and Team Cymru origin → AS name, in parallel. */
|
|
115
|
+
export async function lookupIpIntel(resolve: DnsResolver, ip: string): Promise<IpIntel> {
|
|
116
|
+
const [ptr, network] = await Promise.all([ptrIntel(resolve, ip), networkIntel(resolve, ip)]);
|
|
117
|
+
return { ptr, network };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function heloMatchesPtr(helo: string | null, ptr: IpIntel["ptr"]) {
|
|
121
|
+
if (!helo || !ptr.names.length) return null;
|
|
122
|
+
const name = helo.toLocaleLowerCase().replace(/\.$/u, "");
|
|
123
|
+
const org = organizationalDomain(name);
|
|
124
|
+
return ptr.names.some((candidate) =>
|
|
125
|
+
candidate === name || (org !== null && organizationalDomain(candidate) === org)
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Per-IP results are cacheable; timeouts and errors are not. */
|
|
130
|
+
export function ipIntelCacheTtl(intel: IpIntel) {
|
|
131
|
+
const failed = (status: string) => status === "timeout" || status === "error";
|
|
132
|
+
if (failed(intel.ptr.status) || failed(intel.network.status)) return 0;
|
|
133
|
+
if (intel.ptr.status === "nxdomain" || intel.network.status === "nxdomain") return 3_600;
|
|
134
|
+
return 86_400;
|
|
135
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { parse } from "tldts";
|
|
2
|
+
import { normalizeIp } from "../../shared/mail-received";
|
|
3
|
+
import { isWorkspaceDomain } from "./message-security";
|
|
4
|
+
import { organizationalDomain } from "./org-domain";
|
|
5
|
+
|
|
6
|
+
export const MAX_LINK_DOMAINS = 10;
|
|
7
|
+
const MAX_SCAN_CHARACTERS = 512 * 1024;
|
|
8
|
+
|
|
9
|
+
function hostDomain(value: string) {
|
|
10
|
+
try {
|
|
11
|
+
const url = new URL(value.replaceAll("&", "&"));
|
|
12
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
|
13
|
+
const host = url.hostname.replace(/^\[|\]$/gu, "");
|
|
14
|
+
if (normalizeIp(host)) return null;
|
|
15
|
+
return organizationalDomain(host);
|
|
16
|
+
} catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Registrable domains of the links in a message, in order of first
|
|
23
|
+
* appearance, without the workspace's own domains. Used for Spamhaus DBL.
|
|
24
|
+
*/
|
|
25
|
+
export function linkDomains(input: {
|
|
26
|
+
html?: string | null;
|
|
27
|
+
text?: Array<string | null | undefined>;
|
|
28
|
+
workspaceDomains?: readonly string[];
|
|
29
|
+
limit?: number;
|
|
30
|
+
}) {
|
|
31
|
+
const candidates: string[] = [];
|
|
32
|
+
const html = input.html?.slice(0, MAX_SCAN_CHARACTERS) ?? "";
|
|
33
|
+
for (const match of html.matchAll(/\bhref\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/giu)) {
|
|
34
|
+
candidates.push((match[1] ?? match[2] ?? match[3] ?? "").trim());
|
|
35
|
+
}
|
|
36
|
+
// Visible text only: image sources and other attributes are not links.
|
|
37
|
+
const visible = html.replace(/<[^>]*>/gu, " ");
|
|
38
|
+
const text = [visible, ...(input.text ?? [])].filter(Boolean).join("\n").slice(0, MAX_SCAN_CHARACTERS * 2);
|
|
39
|
+
for (const match of text.matchAll(/\bhttps?:\/\/[^\s<>"'()[\]{}]+/giu)) {
|
|
40
|
+
candidates.push(match[0]);
|
|
41
|
+
}
|
|
42
|
+
// Scheme-less links people still click, such as "goo.gl/0667" typed into a
|
|
43
|
+
// field a platform echoes back. A path is required so file names and plain
|
|
44
|
+
// domains in prose are left alone.
|
|
45
|
+
for (const match of text.matchAll(/(?<![@\w./-])((?:[a-z0-9-]+\.)+[a-z]{2,24})\/[^\s<>"'()[\]{}]*/giu)) {
|
|
46
|
+
if (parse(match[1]!).isIcann) candidates.push(`https://${match[0]}`);
|
|
47
|
+
}
|
|
48
|
+
const domains: string[] = [];
|
|
49
|
+
for (const candidate of candidates) {
|
|
50
|
+
const domain = hostDomain(candidate);
|
|
51
|
+
if (!domain || domains.includes(domain)) continue;
|
|
52
|
+
if (isWorkspaceDomain(domain, input.workspaceDomains ?? [])) continue;
|
|
53
|
+
domains.push(domain);
|
|
54
|
+
if (domains.length >= (input.limit ?? MAX_LINK_DOMAINS)) break;
|
|
55
|
+
}
|
|
56
|
+
return domains;
|
|
57
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { mailboxStub } from "../mailbox";
|
|
2
|
+
|
|
3
|
+
async function deleteMailboxObjects(bucket: R2Bucket, mailboxId: string) {
|
|
4
|
+
const prefix = `mailboxes/${mailboxId}/`;
|
|
5
|
+
while (true) {
|
|
6
|
+
const page = await bucket.list({ prefix, limit: 1_000 });
|
|
7
|
+
const keys = page.objects.map((object) => object.key);
|
|
8
|
+
if (!keys.length) return;
|
|
9
|
+
await bucket.delete(keys);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function deleteMailboxStorage(env: Env, mailboxId: string) {
|
|
14
|
+
await Promise.all([
|
|
15
|
+
mailboxStub(env, mailboxId).deleteMailboxData(),
|
|
16
|
+
deleteMailboxObjects(env.MAIL_STORAGE, mailboxId),
|
|
17
|
+
]);
|
|
18
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
type ActiveMailboxSessionRow = {
|
|
2
|
+
id: string;
|
|
3
|
+
token_hash: string;
|
|
4
|
+
user_id: string;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export type ActiveMailboxSession = {
|
|
8
|
+
id: string;
|
|
9
|
+
tokenHash: string;
|
|
10
|
+
userId: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type MailboxUserRow = {
|
|
14
|
+
id: string;
|
|
15
|
+
name: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function idList(ids: string[]) {
|
|
19
|
+
return JSON.stringify([...new Set(ids)]);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function listActiveMailboxSessions(
|
|
23
|
+
db: D1Database,
|
|
24
|
+
mailboxId: string,
|
|
25
|
+
sessionIds: string[],
|
|
26
|
+
now: number,
|
|
27
|
+
): Promise<ActiveMailboxSession[]> {
|
|
28
|
+
if (!sessionIds.length) return [];
|
|
29
|
+
// D1 allows only 100 bound parameters, so the complete ID set is bound as
|
|
30
|
+
// one JSON value and expanded by SQLite instead of generating a large IN.
|
|
31
|
+
const result = await db.prepare(`
|
|
32
|
+
with requested_sessions(id) as (
|
|
33
|
+
select cast(value as text) from json_each(?)
|
|
34
|
+
)
|
|
35
|
+
select
|
|
36
|
+
session.id,
|
|
37
|
+
session.token_hash,
|
|
38
|
+
session.user_id
|
|
39
|
+
from requested_sessions requested
|
|
40
|
+
inner join sessions session on session.id = requested.id
|
|
41
|
+
inner join users user on user.id = session.user_id
|
|
42
|
+
inner join mailbox_access member
|
|
43
|
+
on member.user_id = session.user_id
|
|
44
|
+
and member.mailbox_id = ?
|
|
45
|
+
where session.expires_at > ?
|
|
46
|
+
and user.status = 'active'
|
|
47
|
+
`).bind(idList(sessionIds), mailboxId, now).all<ActiveMailboxSessionRow>();
|
|
48
|
+
return result.results.map((session) => ({
|
|
49
|
+
id: session.id,
|
|
50
|
+
tokenHash: session.token_hash,
|
|
51
|
+
userId: session.user_id,
|
|
52
|
+
}));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function listMailboxUsersByIds(
|
|
56
|
+
db: D1Database,
|
|
57
|
+
mailboxId: string,
|
|
58
|
+
userIds: string[],
|
|
59
|
+
) {
|
|
60
|
+
if (!userIds.length) return [];
|
|
61
|
+
const result = await db.prepare(`
|
|
62
|
+
with requested_users(id) as (
|
|
63
|
+
select cast(value as text) from json_each(?)
|
|
64
|
+
)
|
|
65
|
+
select user.id, user.name
|
|
66
|
+
from requested_users requested
|
|
67
|
+
inner join mailbox_access member
|
|
68
|
+
on member.user_id = requested.id
|
|
69
|
+
and member.mailbox_id = ?
|
|
70
|
+
inner join users user on user.id = member.user_id
|
|
71
|
+
`).bind(idList(userIds), mailboxId).all<MailboxUserRow>();
|
|
72
|
+
return result.results;
|
|
73
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { and, eq, sql } from "drizzle-orm";
|
|
2
|
+
import PostalMime from "postal-mime";
|
|
3
|
+
import type { ListPostingPolicy } from "@open-workspace/core/shared/workspace.ts";
|
|
4
|
+
import { createDb } from "@open-workspace/core/worker/db/client.ts";
|
|
5
|
+
import { mailboxAddressSql } from "@open-workspace/core/worker/db/mailboxes.ts";
|
|
6
|
+
import { domains, mailboxAccess, mailboxes, teamMembership, users } from "@open-workspace/core/worker/db/schema.ts";
|
|
7
|
+
import { emailDomain, normalizeEmail, normalizeMailboxAddress } from "@open-workspace/core/worker/lib/ids.ts";
|
|
8
|
+
import { mailboxStub } from "../mailbox";
|
|
9
|
+
import { parseInboundAuthentication } from "./auth-headers";
|
|
10
|
+
import { inboundDeliveryId } from "./inbound-delivery";
|
|
11
|
+
import { rawHeaderText } from "./inbound";
|
|
12
|
+
|
|
13
|
+
export type MailingList = {
|
|
14
|
+
id: string;
|
|
15
|
+
teamId: string;
|
|
16
|
+
listPosting: ListPostingPolicy;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** The primary mailbox of each active member: where their copy lands. */
|
|
20
|
+
async function memberMailboxIds(db: D1Database, teamId: string) {
|
|
21
|
+
const rows = await createDb(db)
|
|
22
|
+
.selectDistinct({ id: mailboxes.id })
|
|
23
|
+
.from(teamMembership)
|
|
24
|
+
.innerJoin(users, and(eq(users.id, teamMembership.userId), eq(users.status, "active")))
|
|
25
|
+
.innerJoin(mailboxes, and(
|
|
26
|
+
eq(mailboxes.ownerUserId, teamMembership.userId),
|
|
27
|
+
eq(mailboxes.isPrimary, true),
|
|
28
|
+
))
|
|
29
|
+
.where(eq(teamMembership.teamId, teamId));
|
|
30
|
+
return rows.map((row) => row.id);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The verified sender of a message, when its From domain passed DMARC at
|
|
35
|
+
* Cloudflare. Anything else may be forged and grants no posting rights.
|
|
36
|
+
*/
|
|
37
|
+
async function verifiedSender(rawMime: ArrayBuffer) {
|
|
38
|
+
let from: string | null = null;
|
|
39
|
+
try {
|
|
40
|
+
from = (await PostalMime.parse(rawHeaderText(rawMime))).from?.address ?? null;
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
if (!from) return null;
|
|
45
|
+
const authentication = parseInboundAuthentication({ raw: rawHeaderText(rawMime), fromAddress: from });
|
|
46
|
+
const sender = normalizeEmail(from);
|
|
47
|
+
return authentication.dmarc?.result === "pass"
|
|
48
|
+
&& authentication.dmarc.headerFrom === emailDomain(sender)
|
|
49
|
+
? sender
|
|
50
|
+
: null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function mayPost(db: D1Database, list: MailingList, rawMime: ArrayBuffer) {
|
|
54
|
+
if (list.listPosting === "anyone") return true;
|
|
55
|
+
const sender = await verifiedSender(rawMime);
|
|
56
|
+
if (!sender) return false;
|
|
57
|
+
const orm = createDb(db);
|
|
58
|
+
const [mailbox] = await orm
|
|
59
|
+
.select({ id: mailboxes.id })
|
|
60
|
+
.from(mailboxes)
|
|
61
|
+
.innerJoin(domains, eq(domains.id, mailboxes.domainId))
|
|
62
|
+
.where(sql`${mailboxAddressSql} = ${sender}`)
|
|
63
|
+
.limit(1);
|
|
64
|
+
if (!mailbox) return false;
|
|
65
|
+
if (list.listPosting === "workspace") return true;
|
|
66
|
+
// Members post from any address they may send from.
|
|
67
|
+
const [member] = await orm
|
|
68
|
+
.select({ userId: mailboxAccess.userId })
|
|
69
|
+
.from(mailboxAccess)
|
|
70
|
+
.innerJoin(teamMembership, and(
|
|
71
|
+
eq(teamMembership.userId, mailboxAccess.userId),
|
|
72
|
+
eq(teamMembership.teamId, list.teamId),
|
|
73
|
+
))
|
|
74
|
+
.where(and(eq(mailboxAccess.mailboxId, mailbox.id), eq(mailboxAccess.canSend, true)))
|
|
75
|
+
.limit(1);
|
|
76
|
+
return Boolean(member);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Copies mail sent to a team's mailing list into each member's own mailbox.
|
|
81
|
+
* The copies share one stored original and never leave the workspace.
|
|
82
|
+
*/
|
|
83
|
+
export async function deliverToList(input: {
|
|
84
|
+
env: Env;
|
|
85
|
+
list: MailingList;
|
|
86
|
+
message: { from: string; to: string; setReject(reason: string): void };
|
|
87
|
+
raw: ArrayBuffer;
|
|
88
|
+
receivedAt: number;
|
|
89
|
+
}) {
|
|
90
|
+
const { env, list, message, raw } = input;
|
|
91
|
+
if (!(await mayPost(env.DB, list, raw))) {
|
|
92
|
+
message.setReject(`You are not allowed to post to ${normalizeMailboxAddress(message.to)}`);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const recipients = await memberMailboxIds(env.DB, list.teamId);
|
|
96
|
+
if (!recipients.length) return;
|
|
97
|
+
const listAddress = normalizeMailboxAddress(message.to);
|
|
98
|
+
const deliveryId = await inboundDeliveryId({
|
|
99
|
+
mailboxId: list.id,
|
|
100
|
+
envelopeFrom: message.from,
|
|
101
|
+
envelopeTo: message.to,
|
|
102
|
+
raw,
|
|
103
|
+
});
|
|
104
|
+
const objectKey = `lists/${list.id}/raw/${deliveryId}.eml`;
|
|
105
|
+
await Promise.all(recipients.map(async (mailboxId) => mailboxStub(env, mailboxId).enqueueInbound({
|
|
106
|
+
id: await inboundDeliveryId({
|
|
107
|
+
mailboxId,
|
|
108
|
+
envelopeFrom: message.from,
|
|
109
|
+
envelopeTo: message.to,
|
|
110
|
+
raw,
|
|
111
|
+
}),
|
|
112
|
+
mailboxId,
|
|
113
|
+
rawObjectKey: objectKey,
|
|
114
|
+
envelopeFrom: message.from,
|
|
115
|
+
envelopeTo: message.to,
|
|
116
|
+
listAddress,
|
|
117
|
+
receivedAt: input.receivedAt,
|
|
118
|
+
})));
|
|
119
|
+
await env.MAIL_STORAGE.put(objectKey, raw, { httpMetadata: { contentType: "message/rfc822" } });
|
|
120
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
InboundAuthentication,
|
|
3
|
+
ListUnsubscribe,
|
|
4
|
+
MessageSecurity,
|
|
5
|
+
} from "../../shared/mail-authentication";
|
|
6
|
+
import { addressDomain } from "../../shared/mail-headers";
|
|
7
|
+
import type { DeliveryFacts, SecurityWarning, SenderIntel } from "../../shared/mail-sender-intel";
|
|
8
|
+
import { addressedElsewhereWarning } from "./delivery-facts";
|
|
9
|
+
import { embeddedAddress, impersonatedName, lookalikeOf } from "./impersonation";
|
|
10
|
+
import { organizationalDomain, relaxedAligned } from "./org-domain";
|
|
11
|
+
|
|
12
|
+
export function isWorkspaceDomain(domain: string | null, workspaceDomains: readonly string[]) {
|
|
13
|
+
if (!domain) return false;
|
|
14
|
+
return workspaceDomains.some((candidate) => {
|
|
15
|
+
const name = candidate.toLocaleLowerCase();
|
|
16
|
+
return domain === name || domain.endsWith(`.${name}`);
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Mailbox-local history for the From address (`senders` table). */
|
|
21
|
+
export type SenderHistory = {
|
|
22
|
+
inboundCount: number;
|
|
23
|
+
outboundCount: number;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const warningOrder: Record<SecurityWarning["kind"], number> = {
|
|
27
|
+
dbl_listed: 0,
|
|
28
|
+
lookalike_domain: 1,
|
|
29
|
+
display_name_impersonation: 2,
|
|
30
|
+
addressed_elsewhere: 3,
|
|
31
|
+
zen_listed: 4,
|
|
32
|
+
unverified_sender: 5,
|
|
33
|
+
embedded_address: 6,
|
|
34
|
+
reply_to_mismatch: 7,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The first Reply-To address whose organizational domain differs from the
|
|
39
|
+
* From organizational domain. Mailing lists legitimately redirect replies.
|
|
40
|
+
*/
|
|
41
|
+
export function replyToMismatch(input: {
|
|
42
|
+
fromAddress: string;
|
|
43
|
+
replyTo: readonly string[];
|
|
44
|
+
listId: string | null;
|
|
45
|
+
}) {
|
|
46
|
+
if (input.listId) return null;
|
|
47
|
+
const fromOrg = organizationalDomain(input.fromAddress);
|
|
48
|
+
if (!fromOrg) return null;
|
|
49
|
+
for (const address of input.replyTo) {
|
|
50
|
+
const domain = organizationalDomain(address);
|
|
51
|
+
if (domain && domain !== fromOrg) return { address: address.toLocaleLowerCase(), domain };
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Prioritized warnings for one message, most serious first. The reader shows
|
|
58
|
+
* the first as the banner headline and folds the rest into it.
|
|
59
|
+
*/
|
|
60
|
+
export function securityWarnings(input: {
|
|
61
|
+
fromAddress: string;
|
|
62
|
+
fromName: string | null;
|
|
63
|
+
external: boolean;
|
|
64
|
+
fromDomain: string | null;
|
|
65
|
+
authentication: InboundAuthentication | null;
|
|
66
|
+
intel: SenderIntel | null;
|
|
67
|
+
workspaceDomains: readonly string[];
|
|
68
|
+
internalNames: ReadonlyMap<string, string>;
|
|
69
|
+
history: SenderHistory | null;
|
|
70
|
+
replyTo?: readonly string[];
|
|
71
|
+
listId?: string | null;
|
|
72
|
+
delivery?: DeliveryFacts | null;
|
|
73
|
+
/** Trusted senders keep only danger-level warnings. */
|
|
74
|
+
trusted?: boolean;
|
|
75
|
+
}): SecurityWarning[] {
|
|
76
|
+
const warnings: SecurityWarning[] = [];
|
|
77
|
+
const spamhaus = input.intel?.spamhaus;
|
|
78
|
+
const dbl = spamhaus?.dbl.listings ?? [];
|
|
79
|
+
if (dbl.length) {
|
|
80
|
+
const fromOrg = organizationalDomain(input.fromDomain);
|
|
81
|
+
warnings.push({
|
|
82
|
+
kind: "dbl_listed",
|
|
83
|
+
severity: "danger",
|
|
84
|
+
domains: [...new Set(dbl.map((listing) => listing.target))],
|
|
85
|
+
lists: [...new Set(dbl.map((listing) => listing.list))],
|
|
86
|
+
senderDomain: dbl.some((listing) => listing.target === fromOrg),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
const zen = spamhaus?.zen.listings ?? [];
|
|
90
|
+
if (zen.length && input.intel?.ip) {
|
|
91
|
+
warnings.push({
|
|
92
|
+
kind: "zen_listed",
|
|
93
|
+
severity: zen.some((listing) => listing.severity === "abuse") ? "danger" : "caution",
|
|
94
|
+
ip: input.intel.ip,
|
|
95
|
+
lists: [...new Set(zen.map((listing) => listing.list))],
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
if (input.external) {
|
|
99
|
+
const lookalike = lookalikeOf(input.fromDomain, input.workspaceDomains);
|
|
100
|
+
if (lookalike) warnings.push({ kind: "lookalike_domain", severity: "danger", ...lookalike });
|
|
101
|
+
// Someone this mailbox has written to before (a colleague's personal
|
|
102
|
+
// address) is an established correspondent, not an impersonator.
|
|
103
|
+
const name = impersonatedName(input.fromName, input.internalNames);
|
|
104
|
+
if (name && !(input.history && input.history.outboundCount > 0)) {
|
|
105
|
+
warnings.push({ kind: "display_name_impersonation", severity: "danger", name });
|
|
106
|
+
}
|
|
107
|
+
const claimed = embeddedAddress(input.fromName, input.fromAddress);
|
|
108
|
+
if (claimed) warnings.push({ kind: "embedded_address", severity: "caution", claimed });
|
|
109
|
+
}
|
|
110
|
+
if (input.authentication?.verdict === "suspicious") {
|
|
111
|
+
warnings.push({ kind: "unverified_sender", severity: "caution", domain: input.fromDomain });
|
|
112
|
+
}
|
|
113
|
+
const mismatch = replyToMismatch({
|
|
114
|
+
fromAddress: input.fromAddress,
|
|
115
|
+
replyTo: input.replyTo ?? [],
|
|
116
|
+
listId: input.listId ?? null,
|
|
117
|
+
});
|
|
118
|
+
if (mismatch) warnings.push({ kind: "reply_to_mismatch", severity: "caution", ...mismatch });
|
|
119
|
+
const elsewhere = addressedElsewhereWarning(input.delivery ?? null, input.fromDomain);
|
|
120
|
+
if (elsewhere) warnings.push(elsewhere);
|
|
121
|
+
return warnings
|
|
122
|
+
.filter((warning) => !input.trusted || warning.severity === "danger")
|
|
123
|
+
.sort((left, right) => warningOrder[left.kind] - warningOrder[right.kind]);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Gmail-style sender summary derived from the stored authentication results. */
|
|
127
|
+
export function messageSecurity(input: {
|
|
128
|
+
fromAddress: string;
|
|
129
|
+
fromName?: string | null;
|
|
130
|
+
authentication: InboundAuthentication | null;
|
|
131
|
+
listUnsubscribe: ListUnsubscribe | null;
|
|
132
|
+
workspaceDomains: readonly string[];
|
|
133
|
+
internalNames?: ReadonlyMap<string, string>;
|
|
134
|
+
intel?: SenderIntel | null;
|
|
135
|
+
history?: SenderHistory | null;
|
|
136
|
+
replyTo?: readonly string[];
|
|
137
|
+
listId?: string | null;
|
|
138
|
+
delivery?: DeliveryFacts | null;
|
|
139
|
+
trusted?: boolean;
|
|
140
|
+
}): MessageSecurity {
|
|
141
|
+
const auth = input.authentication;
|
|
142
|
+
const fromDomain = addressDomain(input.fromAddress) ?? auth?.fromDomain ?? null;
|
|
143
|
+
const passing = auth?.dkim.filter((dkim) => dkim.result === "pass") ?? [];
|
|
144
|
+
const alignedDkim = passing.find((dkim) => dkim.aligned);
|
|
145
|
+
const signedBy = (alignedDkim ?? passing[0])?.domain ?? null;
|
|
146
|
+
const mailedBy = auth?.spf?.domain ?? addressDomain(auth?.envelopeFrom) ?? null;
|
|
147
|
+
let via: string | null = null;
|
|
148
|
+
if (auth && auth.verdict !== "unavailable" && !alignedDkim) {
|
|
149
|
+
const spfAligned = auth.spf?.result === "pass" && auth.spf.aligned;
|
|
150
|
+
const candidate = signedBy ?? (spfAligned ? null : mailedBy);
|
|
151
|
+
via = candidate && !relaxedAligned(candidate, fromDomain) ? candidate : null;
|
|
152
|
+
}
|
|
153
|
+
const external = !isWorkspaceDomain(fromDomain, input.workspaceDomains);
|
|
154
|
+
const history = input.history ?? null;
|
|
155
|
+
return {
|
|
156
|
+
verdict: auth?.verdict ?? "unavailable",
|
|
157
|
+
verdictReason: auth?.verdictReason ?? "not_cloudflare",
|
|
158
|
+
fromDomain,
|
|
159
|
+
mailedBy,
|
|
160
|
+
signedBy,
|
|
161
|
+
via,
|
|
162
|
+
forwardedVia: input.delivery?.forwardedVia ?? null,
|
|
163
|
+
external,
|
|
164
|
+
listUnsubscribe: input.listUnsubscribe,
|
|
165
|
+
warnings: securityWarnings({
|
|
166
|
+
fromAddress: input.fromAddress,
|
|
167
|
+
fromName: input.fromName ?? null,
|
|
168
|
+
external,
|
|
169
|
+
fromDomain,
|
|
170
|
+
authentication: auth,
|
|
171
|
+
intel: input.intel ?? null,
|
|
172
|
+
workspaceDomains: input.workspaceDomains,
|
|
173
|
+
internalNames: input.internalNames ?? new Map(),
|
|
174
|
+
history,
|
|
175
|
+
replyTo: input.replyTo,
|
|
176
|
+
listId: input.listId,
|
|
177
|
+
delivery: input.delivery,
|
|
178
|
+
trusted: input.trusted,
|
|
179
|
+
}),
|
|
180
|
+
firstTimeSender: Boolean(
|
|
181
|
+
!input.trusted && external && history && history.inboundCount <= 1 && history.outboundCount === 0,
|
|
182
|
+
),
|
|
183
|
+
replyToMismatch: replyToMismatch({
|
|
184
|
+
fromAddress: input.fromAddress,
|
|
185
|
+
replyTo: input.replyTo ?? [],
|
|
186
|
+
listId: input.listId ?? null,
|
|
187
|
+
}),
|
|
188
|
+
trusted: Boolean(input.trusted),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { SenderStatus, Unsubscription } from "../../shared/mail-feedback";
|
|
2
|
+
import { workspaceBlockFor } from "../mailbox/senders";
|
|
3
|
+
import type { Email } from "../mailbox/schema";
|
|
4
|
+
import { headerAuthentication } from "./header-metadata";
|
|
5
|
+
import { messageSecurity, type SenderHistory } from "./message-security";
|
|
6
|
+
import type { WorkspaceIdentity } from "./security-settings";
|
|
7
|
+
import { storedSenderIntel } from "./sender-intel";
|
|
8
|
+
import { unsubscribeListKey } from "./unsubscribe";
|
|
9
|
+
|
|
10
|
+
export type MessageSafetyContext = {
|
|
11
|
+
senders: Record<string, SenderStatus>;
|
|
12
|
+
unsubscriptions: Record<string, Unsubscription>;
|
|
13
|
+
workspaceBlocks: ReadonlyMap<string, number>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const emptySafetyContext: MessageSafetyContext = {
|
|
17
|
+
senders: {},
|
|
18
|
+
unsubscriptions: {},
|
|
19
|
+
workspaceBlocks: new Map(),
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Block, trust and report state for the sender, with workspace blocks taking precedence. */
|
|
23
|
+
export function senderStatus(email: Email, safety: MessageSafetyContext): SenderStatus | null {
|
|
24
|
+
if (email.direction !== "incoming") return null;
|
|
25
|
+
const address = email.fromJson[0]?.address.toLocaleLowerCase() ?? "";
|
|
26
|
+
const local = safety.senders[address];
|
|
27
|
+
return {
|
|
28
|
+
block: workspaceBlockFor(address, safety.workspaceBlocks) ?? local?.block ?? null,
|
|
29
|
+
trustedAt: local?.trustedAt ?? null,
|
|
30
|
+
reportedAt: local?.reportedAt ?? null,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function emailListKey(email: Email) {
|
|
35
|
+
return email.direction === "incoming"
|
|
36
|
+
? unsubscribeListKey({ listId: email.listId, fromAddress: email.fromJson[0]?.address ?? null })
|
|
37
|
+
: null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function emailSecurity(
|
|
41
|
+
email: Email,
|
|
42
|
+
identity: WorkspaceIdentity,
|
|
43
|
+
history: Record<string, SenderHistory>,
|
|
44
|
+
safety: MessageSafetyContext = emptySafetyContext,
|
|
45
|
+
) {
|
|
46
|
+
if (email.direction !== "incoming") return null;
|
|
47
|
+
const from = email.fromJson[0] ?? { address: "", name: null };
|
|
48
|
+
const status = senderStatus(email, safety);
|
|
49
|
+
return messageSecurity({
|
|
50
|
+
fromAddress: from.address,
|
|
51
|
+
fromName: from.name,
|
|
52
|
+
authentication: headerAuthentication(email),
|
|
53
|
+
listUnsubscribe: email.listUnsubscribeJson ?? null,
|
|
54
|
+
workspaceDomains: identity.domains,
|
|
55
|
+
internalNames: identity.names,
|
|
56
|
+
intel: storedSenderIntel(email.senderIntelJson),
|
|
57
|
+
history: history[from.address.toLocaleLowerCase()] ?? null,
|
|
58
|
+
replyTo: email.replyToJson.map((item) => item.address),
|
|
59
|
+
listId: email.listId,
|
|
60
|
+
delivery: email.deliveryJson ?? null,
|
|
61
|
+
trusted: Boolean(status?.trustedAt) && !status?.block,
|
|
62
|
+
});
|
|
63
|
+
}
|