@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,14 @@
|
|
|
1
|
+
const SEARCH_TOKEN_PATTERN = /[\p{L}\p{N}]+/gu;
|
|
2
|
+
const MAX_SEARCH_TOKENS = 24;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* User input never reaches FTS5 query syntax directly. Each Unicode word is
|
|
6
|
+
* quoted and treated as a prefix, which supports partial names and addresses.
|
|
7
|
+
*/
|
|
8
|
+
export function buildEmailSearchQuery(value: string) {
|
|
9
|
+
const tokens = value.normalize("NFKC").match(SEARCH_TOKEN_PATTERN) ?? [];
|
|
10
|
+
const unique = [...new Set(tokens)].slice(0, MAX_SEARCH_TOKENS);
|
|
11
|
+
return unique.length
|
|
12
|
+
? unique.map((token) => `"${token}"*`).join(" AND ")
|
|
13
|
+
: null;
|
|
14
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { inArray, isNotNull } from "drizzle-orm";
|
|
2
|
+
import { createDb } from "@open-workspace/core/worker/db/client.ts";
|
|
3
|
+
import { domains, mailboxes, users } from "@open-workspace/core/worker/db/schema.ts";
|
|
4
|
+
import { protectedNames } from "./impersonation";
|
|
5
|
+
import { DQS_KEY_PATTERN } from "./spamhaus";
|
|
6
|
+
|
|
7
|
+
/** Workspace-wide inputs for sender checks, read from D1 once per request or delivery. */
|
|
8
|
+
export type WorkspaceIdentity = {
|
|
9
|
+
domains: string[];
|
|
10
|
+
/** Normalized internal person name → display form. */
|
|
11
|
+
names: Map<string, string>;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export async function workspaceIdentity(binding: D1Database): Promise<WorkspaceIdentity> {
|
|
15
|
+
const db = createDb(binding);
|
|
16
|
+
const [domainRows, userRows, mailboxRows] = await db.batch([
|
|
17
|
+
db.select({ name: domains.name }).from(domains),
|
|
18
|
+
db.select({ name: users.name }).from(users).where(inArray(users.status, ["active", "invited"])),
|
|
19
|
+
// Shared mailboxes ("Support", "Customer care") are generic names that
|
|
20
|
+
// vendors use too; only personal mailboxes name a person.
|
|
21
|
+
db.select({ name: mailboxes.displayName }).from(mailboxes).where(isNotNull(mailboxes.ownerUserId)),
|
|
22
|
+
]);
|
|
23
|
+
return {
|
|
24
|
+
domains: domainRows.map((row) => row.name.toLocaleLowerCase()),
|
|
25
|
+
names: protectedNames([...userRows, ...mailboxRows].map((row) => row.name)),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The Spamhaus DQS key from the SPAMHAUS_DQS_KEY secret, when set and well-formed. */
|
|
30
|
+
export function spamhausDqsKey(env: { SPAMHAUS_DQS_KEY?: string }) {
|
|
31
|
+
const key = env.SPAMHAUS_DQS_KEY?.trim().toLowerCase();
|
|
32
|
+
if (!key) return null;
|
|
33
|
+
if (!DQS_KEY_PATTERN.test(key)) {
|
|
34
|
+
console.error("SPAMHAUS_DQS_KEY is not a Spamhaus DQS key; blocklist checks are off");
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
return key;
|
|
38
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import type { InboundAuthentication } from "../../shared/mail-authentication";
|
|
2
|
+
import { ipKind, normalizeIp } from "../../shared/mail-received";
|
|
3
|
+
import type {
|
|
4
|
+
BimiResult,
|
|
5
|
+
LookupStatus,
|
|
6
|
+
SenderIntel,
|
|
7
|
+
SpamhausResult,
|
|
8
|
+
} from "../../shared/mail-sender-intel";
|
|
9
|
+
import { resolveBimi } from "./bimi";
|
|
10
|
+
import { createDohResolver, type DnsResolver } from "./doh";
|
|
11
|
+
import { heloMatchesPtr, ipIntelCacheTtl, lookupIpIntel, type IpIntel } from "./ip-intel";
|
|
12
|
+
import { MAX_LINK_DOMAINS } from "./link-domains";
|
|
13
|
+
import { isWorkspaceDomain } from "./message-security";
|
|
14
|
+
import { organizationalDomain } from "./org-domain";
|
|
15
|
+
import { checkSpamhaus } from "./spamhaus";
|
|
16
|
+
|
|
17
|
+
export const SENDER_INTEL_BUDGET_MS = 1_500;
|
|
18
|
+
const MEMORY_CACHE_LIMIT = 500;
|
|
19
|
+
const CACHE_ORIGIN = "https://sender-intel.openworkspace.invalid";
|
|
20
|
+
|
|
21
|
+
export type IntelCache = {
|
|
22
|
+
get(ip: string): Promise<IpIntel | null>;
|
|
23
|
+
put(ip: string, intel: IpIntel, ttlSeconds: number): Promise<void>;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const memory = new Map<string, { intel: IpIntel; expiresAt: number }>();
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Isolate memory first, then the colo-local Cache API under a synthetic URL.
|
|
30
|
+
* Both are best effort: a failing cache never fails ingestion.
|
|
31
|
+
*/
|
|
32
|
+
export function defaultIntelCache(): IntelCache {
|
|
33
|
+
const request = (ip: string) => new Request(`${CACHE_ORIGIN}/ip/v1/${encodeURIComponent(ip)}`);
|
|
34
|
+
const open = async () => {
|
|
35
|
+
try {
|
|
36
|
+
return await caches.open("openworkspace:sender-intel");
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
return {
|
|
42
|
+
async get(ip) {
|
|
43
|
+
const hit = memory.get(ip);
|
|
44
|
+
if (hit && hit.expiresAt > Date.now()) return hit.intel;
|
|
45
|
+
try {
|
|
46
|
+
const response = await (await open())?.match(request(ip));
|
|
47
|
+
return response ? await response.json() as IpIntel : null;
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
async put(ip, intel, ttlSeconds) {
|
|
53
|
+
if (memory.size >= MEMORY_CACHE_LIMIT) memory.delete(memory.keys().next().value!);
|
|
54
|
+
memory.set(ip, { intel, expiresAt: Date.now() + ttlSeconds * 1_000 });
|
|
55
|
+
try {
|
|
56
|
+
await (await open())?.put(request(ip), new Response(JSON.stringify(intel), {
|
|
57
|
+
headers: { "content-type": "application/json", "cache-control": `max-age=${ttlSeconds}` },
|
|
58
|
+
}));
|
|
59
|
+
} catch {
|
|
60
|
+
// Best effort only.
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function skippedIpIntel(status: LookupStatus): IpIntel {
|
|
67
|
+
return {
|
|
68
|
+
ptr: { status, names: [], confirmed: null, fcrdns: null, generic: null },
|
|
69
|
+
network: { status, asn: null, asName: null, prefix: null, country: null, registry: null },
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Resolves to `fallback` once the deadline passes, even if the work ignores the abort signal. */
|
|
74
|
+
function withDeadline<T>(work: Promise<T>, signal: AbortSignal, fallback: () => T): Promise<T> {
|
|
75
|
+
return new Promise((resolve) => {
|
|
76
|
+
const onAbort = () => resolve(fallback());
|
|
77
|
+
if (signal.aborted) return onAbort();
|
|
78
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
79
|
+
work.then(resolve, () => resolve(fallback())).finally(() => signal.removeEventListener("abort", onAbort));
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** BIMI applies only when DMARC passed under p=quarantine or p=reject. */
|
|
84
|
+
export function bimiEligible(authentication: InboundAuthentication | null) {
|
|
85
|
+
const dmarc = authentication?.dmarc;
|
|
86
|
+
return Boolean(
|
|
87
|
+
authentication?.fromDomain
|
|
88
|
+
&& dmarc?.result === "pass"
|
|
89
|
+
&& (dmarc.policy === "quarantine" || dmarc.policy === "reject"),
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Sending-server intelligence for one message: PTR + FCrDNS and Team Cymru
|
|
95
|
+
* ASN data for the trusted remote IP, Spamhaus ZEN/DBL when a DQS key is
|
|
96
|
+
* configured, and the BIMI logo. All lookups run in parallel under one
|
|
97
|
+
* budget and fail open with a per-field status.
|
|
98
|
+
*/
|
|
99
|
+
export async function collectSenderIntel(input: {
|
|
100
|
+
authentication: InboundAuthentication | null;
|
|
101
|
+
fromAddress: string;
|
|
102
|
+
linkDomains: string[];
|
|
103
|
+
workspaceDomains: readonly string[];
|
|
104
|
+
dqsKey: string | null;
|
|
105
|
+
bucket: R2Bucket;
|
|
106
|
+
fetch?: typeof fetch;
|
|
107
|
+
resolver?: DnsResolver;
|
|
108
|
+
cache?: IntelCache;
|
|
109
|
+
budgetMs?: number;
|
|
110
|
+
now?: number;
|
|
111
|
+
}): Promise<SenderIntel> {
|
|
112
|
+
const now = input.now ?? Date.now();
|
|
113
|
+
const signal = AbortSignal.timeout(input.budgetMs ?? SENDER_INTEL_BUDGET_MS);
|
|
114
|
+
const resolve = input.resolver ?? createDohResolver({ fetch: input.fetch, signal });
|
|
115
|
+
const cache = input.cache ?? defaultIntelCache();
|
|
116
|
+
const auth = input.authentication;
|
|
117
|
+
const ip = normalizeIp(auth?.remoteIp);
|
|
118
|
+
const publicIp = ip && ipKind(ip) === "public" ? ip : null;
|
|
119
|
+
|
|
120
|
+
const ipWork = publicIp
|
|
121
|
+
? withDeadline((async () => {
|
|
122
|
+
const cached = await cache.get(publicIp);
|
|
123
|
+
if (cached) return cached;
|
|
124
|
+
const fresh = await lookupIpIntel(resolve, publicIp);
|
|
125
|
+
const ttl = ipIntelCacheTtl(fresh);
|
|
126
|
+
if (ttl > 0) await cache.put(publicIp, fresh, ttl);
|
|
127
|
+
return fresh;
|
|
128
|
+
})(), signal, () => skippedIpIntel("timeout"))
|
|
129
|
+
: Promise.resolve(skippedIpIntel("skipped"));
|
|
130
|
+
|
|
131
|
+
const fromOrg = organizationalDomain(input.fromAddress);
|
|
132
|
+
const dblDomains = [...new Set([fromOrg, ...input.linkDomains])]
|
|
133
|
+
.filter((domain): domain is string => Boolean(domain) && !isWorkspaceDomain(domain, input.workspaceDomains))
|
|
134
|
+
.slice(0, MAX_LINK_DOMAINS);
|
|
135
|
+
const spamhausWork: Promise<SpamhausResult | null> = input.dqsKey
|
|
136
|
+
? withDeadline(
|
|
137
|
+
checkSpamhaus({ resolve, key: input.dqsKey, ip: publicIp, domains: dblDomains }),
|
|
138
|
+
signal,
|
|
139
|
+
() => ({
|
|
140
|
+
zen: { status: "timeout", listings: [], error: null },
|
|
141
|
+
dbl: { status: "timeout", listings: [], error: null, domains: dblDomains },
|
|
142
|
+
}),
|
|
143
|
+
)
|
|
144
|
+
: Promise.resolve(null);
|
|
145
|
+
|
|
146
|
+
const fromDomain = auth?.fromDomain ?? null;
|
|
147
|
+
const bimiWork: Promise<BimiResult | null> = bimiEligible(auth) && fromDomain
|
|
148
|
+
? withDeadline(
|
|
149
|
+
resolveBimi({
|
|
150
|
+
resolve,
|
|
151
|
+
fetch: input.fetch,
|
|
152
|
+
bucket: input.bucket,
|
|
153
|
+
fromDomain,
|
|
154
|
+
orgDomain: organizationalDomain(fromDomain),
|
|
155
|
+
signal,
|
|
156
|
+
now,
|
|
157
|
+
}),
|
|
158
|
+
signal,
|
|
159
|
+
() => ({ domain: fromDomain, status: "error", reason: "The BIMI lookup timed out" }),
|
|
160
|
+
)
|
|
161
|
+
: Promise.resolve(null);
|
|
162
|
+
|
|
163
|
+
const [ipIntel, spamhaus, bimi] = await Promise.all([ipWork, spamhausWork, bimiWork]);
|
|
164
|
+
return {
|
|
165
|
+
version: 1,
|
|
166
|
+
source: "live",
|
|
167
|
+
checkedAt: now,
|
|
168
|
+
ip,
|
|
169
|
+
ipVersion: ip ? (ip.includes(":") ? 6 : 4) : null,
|
|
170
|
+
...ipIntel,
|
|
171
|
+
heloMatchesPtr: heloMatchesPtr(auth?.helo ?? null, ipIntel.ptr),
|
|
172
|
+
spamhaus,
|
|
173
|
+
bimi,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Lazy fills skip persisting a result where every attempted lookup failed. */
|
|
178
|
+
export function senderIntelWorthStoring(intel: SenderIntel) {
|
|
179
|
+
const statuses: LookupStatus[] = [intel.ptr.status, intel.network.status];
|
|
180
|
+
if (intel.spamhaus) statuses.push(intel.spamhaus.zen.status, intel.spamhaus.dbl.status);
|
|
181
|
+
const attempted = statuses.filter((status) => status !== "skipped");
|
|
182
|
+
return !attempted.length || attempted.some((status) => status === "ok" || status === "nxdomain");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function storedSenderIntel(value: unknown): SenderIntel | null {
|
|
186
|
+
return value && typeof value === "object" && (value as { version?: unknown }).version === 1
|
|
187
|
+
? value as SenderIntel
|
|
188
|
+
: null;
|
|
189
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
SpamhausCheck,
|
|
3
|
+
SpamhausListing,
|
|
4
|
+
SpamhausResult,
|
|
5
|
+
} from "../../shared/mail-sender-intel";
|
|
6
|
+
import type { DnsAnswer, DnsResolver } from "./doh";
|
|
7
|
+
import { reversedIp } from "./ip-intel";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Spamhaus Data Query Service: the customer key is part of the zone name, so
|
|
11
|
+
* the lists answer through any resolver, including DoH. Public-resolver
|
|
12
|
+
* queries against the legacy zones only return 127.255.255.254.
|
|
13
|
+
* https://docs.spamhaus.com/datasets/docs/source/40-real-world-usage/PublicMirrors/MTAs/020-Postfix.html
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export const DQS_KEY_PATTERN = /^[a-z0-9]{20,40}$/u;
|
|
17
|
+
|
|
18
|
+
const zenCodes: Record<string, Pick<SpamhausListing, "list" | "severity">> = {
|
|
19
|
+
"127.0.0.2": { list: "SBL (spam source)", severity: "abuse" },
|
|
20
|
+
"127.0.0.3": { list: "SBL CSS (snowshoe spam)", severity: "abuse" },
|
|
21
|
+
"127.0.0.4": { list: "XBL (compromised host)", severity: "abuse" },
|
|
22
|
+
"127.0.0.5": { list: "XBL (compromised host)", severity: "abuse" },
|
|
23
|
+
"127.0.0.6": { list: "XBL (compromised host)", severity: "abuse" },
|
|
24
|
+
"127.0.0.7": { list: "XBL (compromised host)", severity: "abuse" },
|
|
25
|
+
"127.0.0.9": { list: "DROP (hijacked network)", severity: "abuse" },
|
|
26
|
+
"127.0.0.10": { list: "PBL (ISP end-user range)", severity: "policy" },
|
|
27
|
+
"127.0.0.11": { list: "PBL (end-user range)", severity: "policy" },
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const dblCodes: Record<string, string> = {
|
|
31
|
+
"127.0.1.2": "spam domain",
|
|
32
|
+
"127.0.1.4": "phishing domain",
|
|
33
|
+
"127.0.1.5": "malware domain",
|
|
34
|
+
"127.0.1.6": "botnet C&C domain",
|
|
35
|
+
"127.0.1.102": "abused legitimate domain (spam)",
|
|
36
|
+
"127.0.1.103": "abused redirector or shortener",
|
|
37
|
+
"127.0.1.104": "abused legitimate domain (phishing)",
|
|
38
|
+
"127.0.1.105": "abused legitimate domain (malware)",
|
|
39
|
+
"127.0.1.106": "abused legitimate domain (botnet C&C)",
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const errorCodes: Record<string, string> = {
|
|
43
|
+
"127.255.255.252": "The query was malformed",
|
|
44
|
+
"127.255.255.254": "Queried through a public resolver without a DQS key",
|
|
45
|
+
"127.255.255.255": "Query limit exceeded for this key",
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** 127.255.255.x is never a listing: it reports a problem with the query itself. */
|
|
49
|
+
export function spamhausErrorCode(code: string) {
|
|
50
|
+
return code.startsWith("127.255.255.") ? errorCodes[code] ?? "Spamhaus returned an error code" : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function interpret(
|
|
54
|
+
zone: "zen" | "dbl",
|
|
55
|
+
target: string,
|
|
56
|
+
answer: DnsAnswer,
|
|
57
|
+
): Omit<SpamhausCheck, "status"> & { status: SpamhausCheck["status"] } {
|
|
58
|
+
if (answer.status === "nxdomain") return { status: "ok", listings: [], error: null };
|
|
59
|
+
if (answer.status !== "ok") return { status: answer.status, listings: [], error: null };
|
|
60
|
+
const error = answer.records.map(spamhausErrorCode).find(Boolean) ?? null;
|
|
61
|
+
if (error) return { status: "error", listings: [], error };
|
|
62
|
+
const listings = answer.records.flatMap((code): SpamhausListing[] => {
|
|
63
|
+
if (zone === "zen") {
|
|
64
|
+
const known = zenCodes[code];
|
|
65
|
+
return known ? [{ zone, target, code, ...known }] : [];
|
|
66
|
+
}
|
|
67
|
+
const list = dblCodes[code];
|
|
68
|
+
return list ? [{ zone, target, code, list: `DBL ${list}`, severity: "abuse" }] : [];
|
|
69
|
+
});
|
|
70
|
+
return { status: "ok", listings, error: null };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function merge(checks: SpamhausCheck[]): SpamhausCheck {
|
|
74
|
+
const failed = checks.find((check) => check.status !== "ok");
|
|
75
|
+
return {
|
|
76
|
+
status: checks.length === 0 ? "skipped" : failed && checks.every((check) => check.status !== "ok") ? failed.status : "ok",
|
|
77
|
+
listings: checks.flatMap((check) => check.listings),
|
|
78
|
+
error: checks.map((check) => check.error).find(Boolean) ?? null,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function checkSpamhaus(input: {
|
|
83
|
+
resolve: DnsResolver;
|
|
84
|
+
key: string;
|
|
85
|
+
ip: string | null;
|
|
86
|
+
domains: string[];
|
|
87
|
+
}): Promise<SpamhausResult> {
|
|
88
|
+
const zen = input.ip
|
|
89
|
+
? input.resolve(`${reversedIp(input.ip)}.${input.key}.zen.dq.spamhaus.net`, "A")
|
|
90
|
+
.then((answer) => interpret("zen", input.ip!, answer))
|
|
91
|
+
: Promise.resolve<SpamhausCheck>({ status: "skipped", listings: [], error: null });
|
|
92
|
+
const dbl = Promise.all(input.domains.map(async (domain) =>
|
|
93
|
+
interpret("dbl", domain, await input.resolve(`${domain}.${input.key}.dbl.dq.spamhaus.net`, "A"))
|
|
94
|
+
));
|
|
95
|
+
const [zenResult, dblResults] = await Promise.all([zen, dbl]);
|
|
96
|
+
return { zen: zenResult, dbl: { ...merge(dblResults), domains: input.domains } };
|
|
97
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { normalizeListId } from "../../shared/mail-rules";
|
|
2
|
+
import { organizationalDomain } from "./org-domain";
|
|
3
|
+
|
|
4
|
+
export const ONE_CLICK_TIMEOUT_MS = 10_000;
|
|
5
|
+
const USER_AGENT = "OpenWorkspace-Unsubscribe/1.0";
|
|
6
|
+
|
|
7
|
+
export type OneClickFetcher = (request: Request) => Promise<Response>;
|
|
8
|
+
|
|
9
|
+
export type OneClickResult =
|
|
10
|
+
| { ok: true; status: number; redirected: boolean }
|
|
11
|
+
| { ok: false; status: number | null; detail: string };
|
|
12
|
+
|
|
13
|
+
/** One unsubscribe record per list: its List-Id, else the sender's organizational domain. */
|
|
14
|
+
export function unsubscribeListKey(input: { listId: string | null; fromAddress: string | null }) {
|
|
15
|
+
const listId = normalizeListId(input.listId);
|
|
16
|
+
if (listId) return `list:${listId}`;
|
|
17
|
+
const domain = organizationalDomain(input.fromAddress);
|
|
18
|
+
return domain ? `domain:${domain}` : null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isOneClickUrl(value: string) {
|
|
22
|
+
try {
|
|
23
|
+
const url = new URL(value);
|
|
24
|
+
return url.protocol === "https:" && !url.username && !url.password;
|
|
25
|
+
} catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* RFC 8058 one-click: a single POST with the fixed form body. No cookies or
|
|
32
|
+
* credentials, no redirects followed, never a GET, bounded by a timeout.
|
|
33
|
+
* A 3xx violates the RFC but usually means the request was processed.
|
|
34
|
+
*/
|
|
35
|
+
export async function postOneClick(
|
|
36
|
+
url: string,
|
|
37
|
+
fetcher: OneClickFetcher = (request) => fetch(request),
|
|
38
|
+
timeoutMs = ONE_CLICK_TIMEOUT_MS,
|
|
39
|
+
): Promise<OneClickResult> {
|
|
40
|
+
if (!isOneClickUrl(url)) return { ok: false, status: null, detail: "The unsubscribe address is not HTTPS" };
|
|
41
|
+
const request = new Request(url, {
|
|
42
|
+
method: "POST",
|
|
43
|
+
body: "List-Unsubscribe=One-Click",
|
|
44
|
+
headers: {
|
|
45
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
46
|
+
"user-agent": USER_AGENT,
|
|
47
|
+
},
|
|
48
|
+
redirect: "manual",
|
|
49
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
50
|
+
});
|
|
51
|
+
try {
|
|
52
|
+
const response = await fetcher(request);
|
|
53
|
+
// Nothing in the body is used; release it promptly.
|
|
54
|
+
await response.body?.cancel().catch(() => undefined);
|
|
55
|
+
if (response.status >= 200 && response.status < 300) {
|
|
56
|
+
return { ok: true, status: response.status, redirected: false };
|
|
57
|
+
}
|
|
58
|
+
if (response.status >= 300 && response.status < 400) {
|
|
59
|
+
return { ok: true, status: response.status, redirected: true };
|
|
60
|
+
}
|
|
61
|
+
return { ok: false, status: response.status, detail: `The sender's server answered ${response.status}` };
|
|
62
|
+
} catch (error) {
|
|
63
|
+
const timedOut = error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError");
|
|
64
|
+
return {
|
|
65
|
+
ok: false,
|
|
66
|
+
status: null,
|
|
67
|
+
detail: timedOut ? "The sender's server did not answer in time" : "Could not reach the sender's server",
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type MailtoUnsubscribe = { to: string; subject: string; body: string };
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Recipient, subject and body of a `mailto:` unsubscribe URI. Other header
|
|
76
|
+
* fields (cc, bcc, …) are ignored so the request reaches only the list.
|
|
77
|
+
*/
|
|
78
|
+
export function parseMailtoUnsubscribe(uri: string): MailtoUnsubscribe | null {
|
|
79
|
+
let url: URL;
|
|
80
|
+
try {
|
|
81
|
+
url = new URL(uri);
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
if (url.protocol !== "mailto:") return null;
|
|
86
|
+
let to: string;
|
|
87
|
+
try {
|
|
88
|
+
to = decodeURIComponent(url.pathname).trim();
|
|
89
|
+
} catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
if (!/^[^\s@,<>]+@[^\s@,<>]+\.[^\s@,<>]+$/u.test(to) || to.length > 254) return null;
|
|
93
|
+
// RFC 6068 percent-encodes everything; "+" is a literal plus, not a space.
|
|
94
|
+
const pairs = url.search.slice(1).split("&").flatMap((pair) => {
|
|
95
|
+
const [key = "", value = ""] = pair.split("=", 2);
|
|
96
|
+
try {
|
|
97
|
+
return [[decodeURIComponent(key).toLocaleLowerCase(), decodeURIComponent(value)] as const];
|
|
98
|
+
} catch {
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
const field = (name: string) =>
|
|
103
|
+
pairs.find(([key]) => key === name)?.[1].replace(/[\r\n]+/gu, " ").trim() ?? "";
|
|
104
|
+
return {
|
|
105
|
+
to,
|
|
106
|
+
subject: field("subject").slice(0, 200) || "unsubscribe",
|
|
107
|
+
body: field("body").slice(0, 2_000) || "unsubscribe",
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function oneClickHost(url: string | null) {
|
|
112
|
+
if (!url) return null;
|
|
113
|
+
try {
|
|
114
|
+
return new URL(url).hostname;
|
|
115
|
+
} catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|