@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,298 @@
|
|
|
1
|
+
import {
|
|
2
|
+
headerFields,
|
|
3
|
+
parseMailDate,
|
|
4
|
+
splitTopLevel,
|
|
5
|
+
trailingMailDate,
|
|
6
|
+
} from "./mail-headers";
|
|
7
|
+
|
|
8
|
+
/** The `by` host Cloudflare Email Routing writes on the hop it adds. */
|
|
9
|
+
export const CLOUDFLARE_RECEIVED_BY = "cloudflare-email.net";
|
|
10
|
+
|
|
11
|
+
export type ReceivedHop = {
|
|
12
|
+
/** HELO name the sending server announced. */
|
|
13
|
+
from: string | null;
|
|
14
|
+
/** Reverse DNS name the receiving server resolved, when it differs. */
|
|
15
|
+
reverseDns: string | null;
|
|
16
|
+
/** Connecting IP, taken from the `from` clause only. */
|
|
17
|
+
ip: string | null;
|
|
18
|
+
by: string | null;
|
|
19
|
+
protocol: string | null;
|
|
20
|
+
tls: string | null;
|
|
21
|
+
id: string | null;
|
|
22
|
+
at: Date | null;
|
|
23
|
+
/** Time since the nearest earlier hop with a parsed date. */
|
|
24
|
+
delayMs: number | null;
|
|
25
|
+
/** Negative clock difference absorbed into a zero delay. */
|
|
26
|
+
skewMs: number | null;
|
|
27
|
+
/**
|
|
28
|
+
* The hop Cloudflare added at the top of the message. Every other hop was
|
|
29
|
+
* written by the sending side and is only a claim.
|
|
30
|
+
*/
|
|
31
|
+
trusted: boolean;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
type Clause = {
|
|
35
|
+
words: string[];
|
|
36
|
+
comments: string[];
|
|
37
|
+
/** Number of words seen before the first comment of the clause. */
|
|
38
|
+
wordsBeforeComment: number | null;
|
|
39
|
+
};
|
|
40
|
+
const CLAUSE_KEYWORDS = new Set(["from", "by", "via", "with", "id", "for"]);
|
|
41
|
+
|
|
42
|
+
/** Tokenizes the route part into keyword clauses at comment depth 0. */
|
|
43
|
+
function routeClauses(route: string) {
|
|
44
|
+
const clauses = new Map<string, Clause>();
|
|
45
|
+
const state: { current: Clause | null; token: string; comment: string; depth: number } = {
|
|
46
|
+
current: null,
|
|
47
|
+
token: "",
|
|
48
|
+
comment: "",
|
|
49
|
+
depth: 0,
|
|
50
|
+
};
|
|
51
|
+
const flushToken = () => {
|
|
52
|
+
if (!state.token) return;
|
|
53
|
+
const keyword = state.token.toLocaleLowerCase();
|
|
54
|
+
if (CLAUSE_KEYWORDS.has(keyword) && !clauses.has(keyword)) {
|
|
55
|
+
state.current = { words: [], comments: [], wordsBeforeComment: null };
|
|
56
|
+
clauses.set(keyword, state.current);
|
|
57
|
+
} else {
|
|
58
|
+
state.current?.words.push(state.token);
|
|
59
|
+
}
|
|
60
|
+
state.token = "";
|
|
61
|
+
};
|
|
62
|
+
for (let index = 0; index < route.length; index += 1) {
|
|
63
|
+
const char = route[index]!;
|
|
64
|
+
if (char === "\\" && state.depth > 0) {
|
|
65
|
+
state.comment += route.slice(index, index + 2);
|
|
66
|
+
index += 1;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (char === "(") {
|
|
70
|
+
if (state.depth === 0) flushToken();
|
|
71
|
+
else state.comment += char;
|
|
72
|
+
state.depth += 1;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (char === ")" && state.depth > 0) {
|
|
76
|
+
state.depth -= 1;
|
|
77
|
+
if (state.depth === 0) {
|
|
78
|
+
const clause = state.current;
|
|
79
|
+
if (clause) {
|
|
80
|
+
clause.comments.push(state.comment.trim());
|
|
81
|
+
clause.wordsBeforeComment ??= clause.words.length;
|
|
82
|
+
}
|
|
83
|
+
state.comment = "";
|
|
84
|
+
} else {
|
|
85
|
+
state.comment += char;
|
|
86
|
+
}
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (state.depth > 0) {
|
|
90
|
+
state.comment += char;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (/\s/u.test(char)) flushToken();
|
|
94
|
+
else state.token += char;
|
|
95
|
+
}
|
|
96
|
+
flushToken();
|
|
97
|
+
return clauses;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// --- IP addresses ---------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
function ipv4(value: string) {
|
|
103
|
+
const octets = value.split(".");
|
|
104
|
+
if (octets.length !== 4) return null;
|
|
105
|
+
if (!octets.every((octet) => /^\d{1,3}$/u.test(octet) && Number(octet) <= 255)) return null;
|
|
106
|
+
return octets.map(Number).join(".");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function ipv6(value: string) {
|
|
110
|
+
let text = value.toLocaleLowerCase().replace(/^ipv6:/u, "").replace(/%.*$/u, "");
|
|
111
|
+
let tail: number[] = [];
|
|
112
|
+
const v4 = /:(\d{1,3}(?:\.\d{1,3}){3})$/u.exec(text);
|
|
113
|
+
if (v4) {
|
|
114
|
+
const embedded = ipv4(v4[1]!);
|
|
115
|
+
if (!embedded) return null;
|
|
116
|
+
const [a, b, c, d] = embedded.split(".").map(Number) as [number, number, number, number];
|
|
117
|
+
tail = [(a << 8) | b, (c << 8) | d];
|
|
118
|
+
text = `${text.slice(0, v4.index)}:`;
|
|
119
|
+
if (text.endsWith("::")) text = text.slice(0, -1);
|
|
120
|
+
else text = text.slice(0, -1);
|
|
121
|
+
}
|
|
122
|
+
const halves = text.split("::");
|
|
123
|
+
if (halves.length > 2) return null;
|
|
124
|
+
const parse = (part: string) =>
|
|
125
|
+
part ? part.split(":").map((group) => (/^[0-9a-f]{1,4}$/u.test(group) ? Number.parseInt(group, 16) : Number.NaN)) : [];
|
|
126
|
+
const head = parse(halves[0]!);
|
|
127
|
+
const rest = halves.length === 2 ? parse(halves[1]!) : [];
|
|
128
|
+
const total = 8 - tail.length;
|
|
129
|
+
if ([...head, ...rest].some(Number.isNaN)) return null;
|
|
130
|
+
let groups: number[];
|
|
131
|
+
if (halves.length === 2) {
|
|
132
|
+
const missing = total - head.length - rest.length;
|
|
133
|
+
if (missing < 1) return null;
|
|
134
|
+
groups = [...head, ...Array<number>(missing).fill(0), ...rest];
|
|
135
|
+
} else {
|
|
136
|
+
if (head.length !== total) return null;
|
|
137
|
+
groups = head;
|
|
138
|
+
}
|
|
139
|
+
groups = [...groups, ...tail];
|
|
140
|
+
// RFC 5952: compress the longest run (length >= 2) of zero groups.
|
|
141
|
+
let bestStart = -1;
|
|
142
|
+
let bestLength = 0;
|
|
143
|
+
for (let start = 0; start < 8;) {
|
|
144
|
+
if (groups[start] !== 0) {
|
|
145
|
+
start += 1;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
let end = start;
|
|
149
|
+
while (end < 8 && groups[end] === 0) end += 1;
|
|
150
|
+
if (end - start > bestLength && end - start >= 2) {
|
|
151
|
+
bestStart = start;
|
|
152
|
+
bestLength = end - start;
|
|
153
|
+
}
|
|
154
|
+
start = end;
|
|
155
|
+
}
|
|
156
|
+
const hex = groups.map((group) => group.toString(16));
|
|
157
|
+
if (bestStart < 0) return hex.join(":");
|
|
158
|
+
return `${hex.slice(0, bestStart).join(":")}::${hex.slice(bestStart + bestLength).join(":")}`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Validates an IPv4 or IPv6 literal and returns its canonical form. */
|
|
162
|
+
export function normalizeIp(value: string | null | undefined) {
|
|
163
|
+
if (!value) return null;
|
|
164
|
+
const trimmed = value.trim().replace(/^\[|\]$/gu, "");
|
|
165
|
+
return trimmed.includes(":") ? ipv6(trimmed) : ipv4(trimmed);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function fromClauseIp(clause: Clause) {
|
|
169
|
+
const parts = [clause.words.join(" "), ...clause.comments];
|
|
170
|
+
for (const part of parts) {
|
|
171
|
+
for (const match of part.matchAll(/\[(?:IPv6:)?([0-9a-f:.]+)\]/giu)) {
|
|
172
|
+
const ip = normalizeIp(match[1]);
|
|
173
|
+
if (ip) return { ip, part, literal: match[0] };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
// Bare literals in parentheses: Cloudflare "(192.0.2.1)", Microsoft "(2603:…::12)".
|
|
177
|
+
for (const comment of clause.comments) {
|
|
178
|
+
const ip = normalizeIp(comment);
|
|
179
|
+
if (ip) return { ip, part: comment, literal: comment };
|
|
180
|
+
}
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function heloName(clause: Clause) {
|
|
185
|
+
for (const comment of clause.comments) {
|
|
186
|
+
const helo = /\bhelo[= ]\s*([^\s)\]]+)/iu.exec(comment)?.[1];
|
|
187
|
+
if (helo) return helo;
|
|
188
|
+
}
|
|
189
|
+
const value = clause.words[0];
|
|
190
|
+
return value && !/^\[.*\]$/u.test(value) ? value : null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function reverseDnsName(found: { part: string; literal: string } | null) {
|
|
194
|
+
if (!found || found.part === found.literal) return null;
|
|
195
|
+
const before = found.part.slice(0, found.part.indexOf(found.literal)).trim().split(/\s+/u).at(-1);
|
|
196
|
+
if (!before) return null;
|
|
197
|
+
const name = before.replace(/\.$/u, "");
|
|
198
|
+
if (name.toLocaleLowerCase() === "unknown" || !/^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}$/iu.test(name)) {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
return name;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function tlsDetails(value: string) {
|
|
205
|
+
const version = /\bversion=(TLS1_[0-3])\b/u.exec(value)?.[1]?.replace("_", ".")
|
|
206
|
+
?? /\busing (TLSv1\.[0-3])\b/u.exec(value)?.[1]
|
|
207
|
+
?? /\((TLS1\.[0-3])\)/u.exec(value)?.[1];
|
|
208
|
+
if (version) return version.replace(/^TLSv?/u, "TLS ");
|
|
209
|
+
if (/\bwith\s+(?:E?SMTPS|ESMTPSA|UTF8SMTPS)\b/iu.test(value)) return "TLS";
|
|
210
|
+
if (/\(Google Transport Security\)/u.test(value)) return "TLS";
|
|
211
|
+
if (/\bcipher[= ]/iu.test(value)) return "TLS";
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
type ParsedHop = Omit<ReceivedHop, "delayMs" | "skewMs" | "trusted">;
|
|
216
|
+
|
|
217
|
+
export function parseReceived(value: string): ParsedHop {
|
|
218
|
+
const segments = splitTopLevel(value, ";");
|
|
219
|
+
let route: string;
|
|
220
|
+
let at: Date | null = null;
|
|
221
|
+
if (segments.length > 1) {
|
|
222
|
+
route = segments.slice(0, -1).join(";");
|
|
223
|
+
at = parseMailDate(segments.at(-1));
|
|
224
|
+
} else {
|
|
225
|
+
// SendGrid and some HTTP submission hops omit the `;` before the date.
|
|
226
|
+
const trailing = trailingMailDate(value);
|
|
227
|
+
at = trailing?.date ?? null;
|
|
228
|
+
route = trailing ? value.slice(0, trailing.index) : value;
|
|
229
|
+
}
|
|
230
|
+
const clauses = routeClauses(route);
|
|
231
|
+
const fromClause = clauses.get("from");
|
|
232
|
+
const found = fromClause ? fromClauseIp(fromClause) : null;
|
|
233
|
+
const from = fromClause ? heloName(fromClause) : null;
|
|
234
|
+
const reverseDns = reverseDnsName(found);
|
|
235
|
+
const withClause = clauses.get("with");
|
|
236
|
+
return {
|
|
237
|
+
from,
|
|
238
|
+
reverseDns: reverseDns && reverseDns.toLocaleLowerCase() !== from?.toLocaleLowerCase()
|
|
239
|
+
? reverseDns
|
|
240
|
+
: null,
|
|
241
|
+
ip: found?.ip ?? null,
|
|
242
|
+
by: clauses.get("by")?.words[0] ?? null,
|
|
243
|
+
protocol: withClause?.words.length
|
|
244
|
+
? withClause.words.slice(0, Math.min(withClause.wordsBeforeComment ?? withClause.words.length, 4)).join(" ") || null
|
|
245
|
+
: null,
|
|
246
|
+
tls: tlsDetails(value),
|
|
247
|
+
id: clauses.get("id")?.words[0] ?? null,
|
|
248
|
+
at,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function isCloudflareHop(hop: Pick<ParsedHop, "by">) {
|
|
253
|
+
return hop.by?.toLocaleLowerCase() === CLOUDFLARE_RECEIVED_BY;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const MAX_SKEW_MS = 10 * 60_000;
|
|
257
|
+
|
|
258
|
+
/** Received headers in delivery order: the sender's first relay first. */
|
|
259
|
+
export function receivedHops(raw: string): ReceivedHop[] {
|
|
260
|
+
const values = headerFields(raw).flatMap((field) => field.name === "received" ? [field.value] : []);
|
|
261
|
+
const parsed = values.map(parseReceived);
|
|
262
|
+
const hops = parsed.map((hop, index) => ({
|
|
263
|
+
...hop,
|
|
264
|
+
trusted: index === 0 && isCloudflareHop(hop),
|
|
265
|
+
})).reverse();
|
|
266
|
+
let previous: Date | null = null;
|
|
267
|
+
return hops.map((hop) => {
|
|
268
|
+
let delayMs: number | null = null;
|
|
269
|
+
let skewMs: number | null = null;
|
|
270
|
+
if (hop.at && previous) {
|
|
271
|
+
const delta = hop.at.getTime() - previous.getTime();
|
|
272
|
+
if (delta >= 0) delayMs = delta;
|
|
273
|
+
else if (-delta < MAX_SKEW_MS) {
|
|
274
|
+
delayMs = 0;
|
|
275
|
+
skewMs = delta;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
// A skewed hop keeps the later reference so the next delay is not inflated.
|
|
279
|
+
if (hop.at && (!previous || (delayMs !== null && skewMs === null))) previous = hop.at;
|
|
280
|
+
return { ...hop, delayMs, skewMs };
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export type IpKind = "public" | "private" | "loopback" | "reserved";
|
|
285
|
+
|
|
286
|
+
/** Rough classification; enough to tell an internal hop from the internet. */
|
|
287
|
+
export function ipKind(ip: string): IpKind {
|
|
288
|
+
const lower = ip.toLowerCase();
|
|
289
|
+
if (lower === "::1" || lower.startsWith("127.")) return "loopback";
|
|
290
|
+
if (/^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|f[cd][0-9a-f]{2}:|fe80:)/u.test(lower)) {
|
|
291
|
+
return "private";
|
|
292
|
+
}
|
|
293
|
+
// Documentation, shared and link-local ranges never appear on the open internet.
|
|
294
|
+
if (/^(192\.0\.2\.|198\.51\.100\.|203\.0\.113\.|100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.|169\.254\.|2001:db8:)/u.test(lower)) {
|
|
295
|
+
return "reserved";
|
|
296
|
+
}
|
|
297
|
+
return "public";
|
|
298
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Same-origin proxy path used inside sanitized HTML iframes. */
|
|
2
|
+
export function mailRemoteProxyPath(mailboxId: string, remoteUrl: string) {
|
|
3
|
+
const params = new URLSearchParams({
|
|
4
|
+
mailboxId,
|
|
5
|
+
url: remoteUrl,
|
|
6
|
+
});
|
|
7
|
+
return `/api/mail/remote?${params.toString()}`;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function isRemoteHttpUrl(value: string) {
|
|
11
|
+
try {
|
|
12
|
+
const url = new URL(value);
|
|
13
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
14
|
+
} catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic per-mailbox filter rules ("Filter messages like this").
|
|
3
|
+
* Pure so the worker (ingestion, "apply to existing") and the browser (rule
|
|
4
|
+
* preview) share one definition of a match.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export const MAX_MAIL_RULES = 100;
|
|
8
|
+
|
|
9
|
+
export type MailRuleConditions = {
|
|
10
|
+
/** An address (exact) or a domain / `@domain` (domain and its subdomains). */
|
|
11
|
+
from?: string;
|
|
12
|
+
/** Matches any To or Cc recipient, with the same address/domain semantics. */
|
|
13
|
+
to?: string;
|
|
14
|
+
/** Case-insensitive substring of the subject. */
|
|
15
|
+
subjectContains?: string;
|
|
16
|
+
/** RFC 2919 list identifier, compared without angle brackets. */
|
|
17
|
+
listId?: string;
|
|
18
|
+
hasAttachment?: boolean;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type MailRuleActions = {
|
|
22
|
+
/** Custom folder to file matching conversations in. */
|
|
23
|
+
folderId?: string;
|
|
24
|
+
archive?: boolean;
|
|
25
|
+
spam?: boolean;
|
|
26
|
+
markRead?: boolean;
|
|
27
|
+
/** Overrides the AI spam verdict (blocked senders still go to Spam). */
|
|
28
|
+
neverSpam?: boolean;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type MailRule = {
|
|
32
|
+
id: string;
|
|
33
|
+
name: string;
|
|
34
|
+
enabled: boolean;
|
|
35
|
+
conditions: MailRuleConditions;
|
|
36
|
+
actions: MailRuleActions;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** The facts a rule can look at, normalized by `ruleSubject`. */
|
|
40
|
+
export type RuleSubject = {
|
|
41
|
+
from: string;
|
|
42
|
+
recipients: string[];
|
|
43
|
+
subject: string;
|
|
44
|
+
listId: string | null;
|
|
45
|
+
hasAttachment: boolean;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type RuleDestination =
|
|
49
|
+
| { kind: "folder"; folderId: string }
|
|
50
|
+
| { kind: "archive" }
|
|
51
|
+
| { kind: "spam" };
|
|
52
|
+
|
|
53
|
+
export type RuleOutcome = {
|
|
54
|
+
ruleIds: string[];
|
|
55
|
+
/** Set by the first matching rule that names one. */
|
|
56
|
+
destination: RuleDestination | null;
|
|
57
|
+
markRead: boolean;
|
|
58
|
+
neverSpam: boolean;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
function lower(value: string | null | undefined) {
|
|
62
|
+
return (value ?? "").trim().toLocaleLowerCase();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function normalizeListId(value: string | null | undefined) {
|
|
66
|
+
return lower(value).replace(/^.*<([^>]*)>.*$/u, "$1").trim() || null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function ruleSubject(input: {
|
|
70
|
+
from: string | null | undefined;
|
|
71
|
+
recipients: Array<string | null | undefined>;
|
|
72
|
+
subject: string | null | undefined;
|
|
73
|
+
listId: string | null | undefined;
|
|
74
|
+
hasAttachment: boolean;
|
|
75
|
+
}): RuleSubject {
|
|
76
|
+
return {
|
|
77
|
+
from: lower(input.from),
|
|
78
|
+
recipients: input.recipients.map(lower).filter(Boolean),
|
|
79
|
+
subject: lower(input.subject),
|
|
80
|
+
listId: normalizeListId(input.listId),
|
|
81
|
+
hasAttachment: input.hasAttachment,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** `alex@x.com` matches exactly; `x.com` or `@x.com` matches x.com and *.x.com. */
|
|
86
|
+
export function addressMatches(pattern: string, address: string) {
|
|
87
|
+
const value = lower(pattern);
|
|
88
|
+
if (!value || !address) return false;
|
|
89
|
+
if (value.includes("@") && !value.startsWith("@")) return address === value;
|
|
90
|
+
const domain = value.replace(/^@/u, "");
|
|
91
|
+
const at = address.lastIndexOf("@");
|
|
92
|
+
const host = at >= 0 ? address.slice(at + 1) : address;
|
|
93
|
+
return host === domain || host.endsWith(`.${domain}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function hasConditions(conditions: MailRuleConditions) {
|
|
97
|
+
return Boolean(
|
|
98
|
+
lower(conditions.from)
|
|
99
|
+
|| lower(conditions.to)
|
|
100
|
+
|| lower(conditions.subjectContains)
|
|
101
|
+
|| lower(conditions.listId)
|
|
102
|
+
|| conditions.hasAttachment,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function hasActions(actions: MailRuleActions) {
|
|
107
|
+
return Boolean(actions.folderId || actions.archive || actions.spam || actions.markRead || actions.neverSpam);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Every present condition must hold; a rule without conditions never matches. */
|
|
111
|
+
export function ruleMatches(conditions: MailRuleConditions, subject: RuleSubject) {
|
|
112
|
+
if (!hasConditions(conditions)) return false;
|
|
113
|
+
if (lower(conditions.from) && !addressMatches(conditions.from!, subject.from)) return false;
|
|
114
|
+
if (lower(conditions.to) && !subject.recipients.some((address) => addressMatches(conditions.to!, address))) {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
const needle = lower(conditions.subjectContains);
|
|
118
|
+
if (needle && !subject.subject.includes(needle)) return false;
|
|
119
|
+
const listId = normalizeListId(conditions.listId);
|
|
120
|
+
if (listId && subject.listId !== listId) return false;
|
|
121
|
+
if (conditions.hasAttachment && !subject.hasAttachment) return false;
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function ruleDestination(actions: MailRuleActions): RuleDestination | null {
|
|
126
|
+
if (actions.spam) return { kind: "spam" };
|
|
127
|
+
if (actions.archive) return { kind: "archive" };
|
|
128
|
+
if (actions.folderId) return { kind: "folder", folderId: actions.folderId };
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Rules run in order; all matches contribute flags, the first destination wins. */
|
|
133
|
+
export function evaluateRules(rules: readonly MailRule[], subject: RuleSubject): RuleOutcome {
|
|
134
|
+
const outcome: RuleOutcome = { ruleIds: [], destination: null, markRead: false, neverSpam: false };
|
|
135
|
+
for (const rule of rules) {
|
|
136
|
+
if (!rule.enabled || !ruleMatches(rule.conditions, subject)) continue;
|
|
137
|
+
outcome.ruleIds.push(rule.id);
|
|
138
|
+
outcome.destination ??= ruleDestination(rule.actions);
|
|
139
|
+
outcome.markRead ||= Boolean(rule.actions.markRead);
|
|
140
|
+
outcome.neverSpam ||= Boolean(rule.actions.neverSpam);
|
|
141
|
+
}
|
|
142
|
+
// "Never spam" from any matching rule overrides a later rule's Spam action.
|
|
143
|
+
if (outcome.neverSpam && outcome.destination?.kind === "spam") outcome.destination = null;
|
|
144
|
+
return outcome;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Short human description, e.g. "From @hireline.example · Subject contains "alert"". */
|
|
148
|
+
export function describeConditions(conditions: MailRuleConditions) {
|
|
149
|
+
const parts: string[] = [];
|
|
150
|
+
if (lower(conditions.from)) parts.push(`From ${conditions.from!.trim()}`);
|
|
151
|
+
if (lower(conditions.to)) parts.push(`To ${conditions.to!.trim()}`);
|
|
152
|
+
if (lower(conditions.subjectContains)) parts.push(`Subject contains “${conditions.subjectContains!.trim()}”`);
|
|
153
|
+
if (lower(conditions.listId)) parts.push(`List ${normalizeListId(conditions.listId)}`);
|
|
154
|
+
if (conditions.hasAttachment) parts.push("Has attachment");
|
|
155
|
+
return parts.join(" · ");
|
|
156
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Point-in-time facts about the server that delivered an inbound message,
|
|
3
|
+
* gathered over DNS-over-HTTPS at ingestion (or lazily on first view) by
|
|
4
|
+
* `worker/mail/sender-intel.ts`. Every lookup fails open with its own status.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type LookupStatus = "ok" | "nxdomain" | "timeout" | "error" | "skipped";
|
|
8
|
+
|
|
9
|
+
export type SpamhausZone = "zen" | "dbl";
|
|
10
|
+
|
|
11
|
+
export type SpamhausListing = {
|
|
12
|
+
zone: SpamhausZone;
|
|
13
|
+
/** The IP (ZEN) or registrable domain (DBL) that is listed. */
|
|
14
|
+
target: string;
|
|
15
|
+
/** Return code, e.g. 127.0.0.2 or 127.0.1.4. */
|
|
16
|
+
code: string;
|
|
17
|
+
/** Human-readable list name, e.g. "SBL" or "DBL phishing domain". */
|
|
18
|
+
list: string;
|
|
19
|
+
/** "policy" listings (PBL) describe address space, not abuse. */
|
|
20
|
+
severity: "abuse" | "policy";
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type SpamhausCheck = {
|
|
24
|
+
status: LookupStatus;
|
|
25
|
+
listings: SpamhausListing[];
|
|
26
|
+
/** Set when Spamhaus answered with a 127.255.255.x error code. */
|
|
27
|
+
error: string | null;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type SpamhausResult = {
|
|
31
|
+
zen: SpamhausCheck;
|
|
32
|
+
dbl: SpamhausCheck & { domains: string[] };
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type BimiStatus = "ok" | "none" | "invalid" | "error";
|
|
36
|
+
|
|
37
|
+
export type BimiResult = {
|
|
38
|
+
/** Domain whose BIMI record was used (From domain or its organizational domain). */
|
|
39
|
+
domain: string;
|
|
40
|
+
status: BimiStatus;
|
|
41
|
+
/** Why no logo is shown, for "Show original". */
|
|
42
|
+
reason: string | null;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type SenderIntel = {
|
|
46
|
+
version: 1;
|
|
47
|
+
/** "demo" rows carry pre-computed values instead of live lookups. */
|
|
48
|
+
source: "live" | "demo";
|
|
49
|
+
checkedAt: number;
|
|
50
|
+
ip: string | null;
|
|
51
|
+
ipVersion: 4 | 6 | null;
|
|
52
|
+
ptr: {
|
|
53
|
+
status: LookupStatus;
|
|
54
|
+
names: string[];
|
|
55
|
+
/** The PTR name whose A/AAAA records contain the IP. */
|
|
56
|
+
confirmed: string | null;
|
|
57
|
+
/** Forward-confirmed reverse DNS. */
|
|
58
|
+
fcrdns: boolean | null;
|
|
59
|
+
/** The name looks like an auto-generated access-network name. */
|
|
60
|
+
generic: boolean | null;
|
|
61
|
+
};
|
|
62
|
+
/** HELO equals a PTR name or shares its organizational domain. */
|
|
63
|
+
heloMatchesPtr: boolean | null;
|
|
64
|
+
network: {
|
|
65
|
+
status: LookupStatus;
|
|
66
|
+
asn: number | null;
|
|
67
|
+
asName: string | null;
|
|
68
|
+
prefix: string | null;
|
|
69
|
+
/** ISO 3166-1 alpha-2, uppercase. */
|
|
70
|
+
country: string | null;
|
|
71
|
+
registry: string | null;
|
|
72
|
+
};
|
|
73
|
+
/** Present only when an administrator configured a Spamhaus DQS key. */
|
|
74
|
+
spamhaus: SpamhausResult | null;
|
|
75
|
+
/** Present only when DMARC passed with an enforcing policy. */
|
|
76
|
+
bimi: BimiResult | null;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export type SecurityWarning =
|
|
80
|
+
| {
|
|
81
|
+
kind: "dbl_listed";
|
|
82
|
+
severity: "danger";
|
|
83
|
+
domains: string[];
|
|
84
|
+
lists: string[];
|
|
85
|
+
/** The From domain itself is listed, not only a link domain. */
|
|
86
|
+
senderDomain: boolean;
|
|
87
|
+
}
|
|
88
|
+
| { kind: "zen_listed"; severity: "danger" | "caution"; ip: string; lists: string[] }
|
|
89
|
+
| { kind: "lookalike_domain"; severity: "danger"; domain: string; resembles: string }
|
|
90
|
+
| { kind: "display_name_impersonation"; severity: "danger"; name: string }
|
|
91
|
+
| { kind: "embedded_address"; severity: "caution"; claimed: string }
|
|
92
|
+
| { kind: "unverified_sender"; severity: "caution"; domain: string | null }
|
|
93
|
+
/** Replies go to another organization than the one in From. */
|
|
94
|
+
| { kind: "reply_to_mismatch"; severity: "caution"; address: string; domain: string }
|
|
95
|
+
/**
|
|
96
|
+
* Written for another address and forwarded here. Danger when it is an
|
|
97
|
+
* account notice from a large provider about someone else's account.
|
|
98
|
+
*/
|
|
99
|
+
| {
|
|
100
|
+
kind: "addressed_elsewhere";
|
|
101
|
+
severity: "danger" | "caution";
|
|
102
|
+
addressedTo: string;
|
|
103
|
+
forwardedVia: string | null;
|
|
104
|
+
accountNotice: boolean;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/** Who a message was written for and how it reached this mailbox, recorded at ingestion. */
|
|
108
|
+
export type DeliveryFacts = {
|
|
109
|
+
/** The RCPT TO this mailbox received the message at. */
|
|
110
|
+
deliveredTo: string | null;
|
|
111
|
+
/** First To/Cc address when none of them is this mailbox or a workspace address. */
|
|
112
|
+
addressedTo: string | null;
|
|
113
|
+
/** Mail to that address reached this mailbox from several senders before (the person's own forwarding). */
|
|
114
|
+
knownAddressee: boolean;
|
|
115
|
+
/** Organization that relayed the message after the author's server, when forwarding is evident. */
|
|
116
|
+
forwardedVia: string | null;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export type SecurityWarningKind = SecurityWarning["kind"];
|
|
120
|
+
|
|
121
|
+
/** Country flag emoji from an ISO 3166-1 alpha-2 code. */
|
|
122
|
+
export function countryFlag(code: string | null | undefined) {
|
|
123
|
+
if (!code || !/^[A-Z]{2}$/u.test(code)) return null;
|
|
124
|
+
return String.fromCodePoint(...[...code].map((char) => 0x1f1e6 + char.charCodeAt(0) - 65));
|
|
125
|
+
}
|