Add Cloudflare catch-all mail worker

This commit is contained in:
2026-07-22 13:40:09 +08:00
parent 0d98ffaf89
commit 5e993b5961
10 changed files with 1979 additions and 1 deletions
+3
View File
@@ -0,0 +1,3 @@
node_modules/
.wrangler/
.dev.vars
+53
View File
@@ -0,0 +1,53 @@
# Cloud Mail Worker
This Worker receives every address at `yyggslive.cc.cd`, stores messages in D1,
and exposes the API expected by this repository's `cloudmail` provider.
## Deploy
1. Install Node.js 20+ and authenticate:
```sh
npm install
npx wrangler login
```
2. Create the D1 database and copy the returned `database_id` into
`wrangler.toml`:
```sh
npm run db:create
npm run db:migrate
```
3. Generate a long random API token and store it as a Worker secret:
```sh
npx wrangler secret put API_TOKEN
npm run deploy
```
4. In the Cloudflare dashboard, enable Email Routing for `yyggslive.cc.cd`.
Add an Email Worker route for `*@yyggslive.cc.cd` that targets this Worker.
Cloudflare will show the MX records that must be present in DNS.
5. Put the deployed Worker URL and the token in the root `config.json`:
```json
{
"email_provider": "cloudmail",
"cloudmail_api_base": "https://cloudmail-inbox.<your-subdomain>.workers.dev",
"cloudmail_public_token": "the API_TOKEN value",
"cloudmail_domains": "yyggslive.cc.cd",
"cloudmail_path_messages": "/api/public/emailList"
}
```
## API
`POST /api/public/emailList` requires an `Authorization` header containing the
configured token. It accepts `{ "toEmail": "name@yyggslive.cc.cd", "size": 20 }`
and returns `{ "code": 200, "data": [...] }`.
The Worker stores message bodies in D1. Add a scheduled cleanup query before
using it long-term, for example deleting messages older than seven days.
@@ -0,0 +1,12 @@
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
to_email TEXT NOT NULL,
from_email TEXT NOT NULL DEFAULT '',
subject TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL DEFAULT '',
verification_code TEXT NOT NULL DEFAULT '',
received_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_messages_to_received
ON messages(to_email, received_at DESC);
+1576
View File
File diff suppressed because it is too large Load Diff
+15
View File
@@ -0,0 +1,15 @@
{
"name": "cloudmail-worker",
"private": true,
"version": "1.0.0",
"scripts": {
"deploy": "wrangler deploy",
"db:create": "wrangler d1 create cloudmail-inbox",
"db:migrate": "wrangler d1 migrations apply cloudmail-inbox --remote"
},
"devDependencies": {
"@cloudflare/workers-types": "^5.20260721.1",
"typescript": "^5.8.3",
"wrangler": "^4.25.0"
}
}
+273
View File
@@ -0,0 +1,273 @@
export interface Env {
API_TOKEN: string;
DB: D1Database;
LEGACY_DB: D1Database;
INBOX_PASSWORD: string;
MAIL_DOMAIN: string;
}
type MailRecord = {
id: string;
to_email: string;
from_email: string;
subject: string;
body: string;
verification_code: string;
received_at: number;
};
type ListRequest = {
toEmail?: unknown;
num?: unknown;
size?: unknown;
};
function header(raw: string, name: string): string {
const head = raw.split(/\r?\n\r?\n/, 1)[0] || "";
const unfolded = head.replace(/\r?\n[ \t]+/g, " ");
const match = unfolded.match(new RegExp(`^${name}:\\s*(.+)$`, "im"));
return match ? match[1].trim() : "";
}
function textBody(raw: string): string {
const body = raw.split(/\r?\n\r?\n/, 2)[1] || "";
return body
.replace(/^--[^\r\n]+(?:--)?$/gm, "")
.replace(/<[^>]+>/g, " ")
.replace(/=([A-Fa-f0-9]{2})/g, (_, hex: string) =>
String.fromCharCode(Number.parseInt(hex, 16)),
)
.replace(/\s+/g, " ")
.trim()
.slice(0, 100_000);
}
function codeFrom(subject: string, body: string): string {
for (const source of [subject, body]) {
const match = source.match(
/(?:confirmation|verification)\s+code\s*[:]\s*([A-Z0-9]{3}-[A-Z0-9]{3})/i,
);
if (match) return match[1];
}
const subjectCode = subject.match(/\b([A-Z0-9]{3}-[A-Z0-9]{3})\b/);
if (subjectCode) return subjectCode[1];
for (const source of [subject, body]) {
const match = source.match(
/(?:verification|confirmation)\s+code\s*[:]\s*(\d{4,8})/i,
);
if (match) return match[1];
}
return "";
}
function validAddress(address: string, domain: string): boolean {
const normalized = address.trim().toLowerCase();
return normalized.endsWith(`@${domain.trim().toLowerCase()}`) && !normalized.startsWith("@");
}
function authorized(request: Request, token: string): boolean {
const provided = request.headers.get("Authorization") || "";
return provided === token || provided === `Bearer ${token}`;
}
function inboxUnauthorized(): Response {
return new Response("Authentication required", {
status: 401,
headers: { "WWW-Authenticate": 'Basic realm="Cloud Mail Inbox", charset="UTF-8"' },
});
}
function inboxAuthorized(request: Request, password: string): boolean {
if (!password) return false;
const supplied = request.headers.get("Authorization") || "";
const expected = `Basic ${btoa(`admin:${password}`)}`;
return supplied === expected;
}
function json(value: unknown, status = 200): Response {
return new Response(JSON.stringify(value), {
status,
headers: { "Content-Type": "application/json; charset=utf-8" },
});
}
const INBOX_HTML = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Cloud Mail Inbox</title>
<style>
body { background: #f7f8fa; color: #18212f; font: 14px system-ui, sans-serif; margin: 0; }
main { margin: 32px auto; max-width: 1080px; padding: 0 20px; }
h1 { font-size: 22px; margin: 0 0 20px; }
form { display: flex; gap: 8px; margin-bottom: 16px; }
input { border: 1px solid #c8d0da; border-radius: 4px; font: inherit; padding: 9px; width: 360px; }
button { background: #146ef5; border: 0; border-radius: 4px; color: white; cursor: pointer; font: inherit; padding: 9px 14px; }
#status { color: #526170; margin: 12px 0; }
article { background: white; border: 1px solid #dfe5eb; margin: 8px 0; padding: 14px; }
.meta { color: #526170; font-size: 12px; margin: 6px 0 12px; }
pre { font: 13px ui-monospace, SFMono-Regular, monospace; margin: 0; overflow-wrap: anywhere; white-space: pre-wrap; }
</style>
</head>
<body>
<main>
<h1>Cloud Mail Inbox</h1>
<form id="filter"><input id="address" placeholder="Filter by recipient (optional)"><button>Refresh</button></form>
<div id="status">Loading messages...</div><section id="messages"></section>
</main>
<script>
const status = document.querySelector('#status');
const list = document.querySelector('#messages');
const address = document.querySelector('#address');
async function refresh() {
status.textContent = 'Loading messages...'; list.replaceChildren();
const query = address.value.trim() ? '?toEmail=' + encodeURIComponent(address.value.trim()) : '';
const response = await fetch('/api/inbox/messages' + query);
if (!response.ok) { status.textContent = 'Unable to load messages (' + response.status + ')'; return; }
const data = await response.json();
status.textContent = data.data.length + ' message(s)';
for (const item of data.data) {
const article = document.createElement('article');
const subject = document.createElement('strong'); subject.textContent = item.subject || '(no subject)';
const meta = document.createElement('div'); meta.className = 'meta';
meta.textContent = 'To: ' + item.toEmail + ' | From: ' + item.fromEmail + ' | ' + new Date(item.receivedAt).toLocaleString();
const body = document.createElement('pre'); body.textContent = item.text || '';
article.append(subject, meta, body); list.append(article);
}
}
document.querySelector('#filter').addEventListener('submit', (event) => { event.preventDefault(); refresh(); });
refresh();
</script>
</body>
</html>`;
export default {
async email(message, env): Promise<void> {
const recipient = message.to.toLowerCase();
if (!validAddress(recipient, env.MAIL_DOMAIN)) {
message.setReject("Recipient domain is not accepted");
return;
}
const raw = await new Response(message.raw).text();
const subject = header(raw, "Subject").slice(0, 1_000);
const body = textBody(raw);
const now = Date.now();
const record: MailRecord = {
id: crypto.randomUUID(),
to_email: recipient,
from_email: message.from.slice(0, 1_000),
subject,
body,
verification_code: codeFrom(subject, body),
received_at: now,
};
await env.DB.prepare(
`INSERT INTO messages
(id, to_email, from_email, subject, body, verification_code, received_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
record.id,
record.to_email,
record.from_email,
record.subject,
record.body,
record.verification_code,
record.received_at,
)
.run();
// The legacy service stores original MIME messages in raw_mails.
// A mirror failure must not prevent the primary inbox from receiving mail.
try {
await env.LEGACY_DB.prepare(
"INSERT INTO raw_mails (source, address, raw, message_id) VALUES (?, ?, ?, ?)",
)
.bind(message.from, recipient, raw, message.headers.get("Message-ID"))
.run();
} catch (error) {
console.error("legacy mail_db mirror failed", error);
}
},
async fetch(request, env): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === "/inbox") {
if (!env.INBOX_PASSWORD) return new Response("INBOX_PASSWORD is not configured", { status: 503 });
if (!inboxAuthorized(request, env.INBOX_PASSWORD)) return inboxUnauthorized();
return new Response(INBOX_HTML, {
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}
if (request.method === "GET" && url.pathname === "/api/inbox/messages") {
if (!env.INBOX_PASSWORD) return new Response("INBOX_PASSWORD is not configured", { status: 503 });
if (!inboxAuthorized(request, env.INBOX_PASSWORD)) return inboxUnauthorized();
const toEmail = (url.searchParams.get("toEmail") || "").trim().toLowerCase();
if (toEmail && !validAddress(toEmail, env.MAIL_DOMAIN)) {
return json({ code: 400, message: "Invalid toEmail" }, 400);
}
const query = toEmail
? env.DB.prepare(
`SELECT id, to_email, from_email, subject, body, verification_code, received_at
FROM messages WHERE to_email = ? ORDER BY received_at DESC LIMIT 100`,
).bind(toEmail)
: env.DB.prepare(
`SELECT id, to_email, from_email, subject, body, verification_code, received_at
FROM messages ORDER BY received_at DESC LIMIT 100`,
);
const result = await query.all<MailRecord>();
return json({
code: 200,
data: (result.results || []).map((item) => ({
emailId: item.id,
toEmail: item.to_email,
fromEmail: item.from_email,
subject: item.subject,
text: item.body,
receivedAt: item.received_at,
})),
});
}
if (request.method !== "POST" || url.pathname !== "/api/public/emailList") {
return json({ code: 404, message: "Not found" }, 404);
}
if (!authorized(request, env.API_TOKEN)) {
return json({ code: 401, message: "Unauthorized" }, 401);
}
let payload: ListRequest;
try {
payload = (await request.json()) as ListRequest;
} catch {
return json({ code: 400, message: "Invalid JSON" }, 400);
}
const toEmail = typeof payload.toEmail === "string" ? payload.toEmail.trim().toLowerCase() : "";
if (!validAddress(toEmail, env.MAIL_DOMAIN)) {
return json({ code: 400, message: "Invalid toEmail" }, 400);
}
const requestedSize = Number(payload.size ?? payload.num ?? 20);
const size = Number.isFinite(requestedSize) ? Math.max(1, Math.min(Math.floor(requestedSize), 50)) : 20;
const result = await env.DB.prepare(
`SELECT id, to_email, from_email, subject, body, verification_code, received_at
FROM messages WHERE to_email = ? ORDER BY received_at DESC LIMIT ?`,
)
.bind(toEmail, size)
.all<MailRecord>();
return json({
code: 200,
data: (result.results || []).map((item) => ({
emailId: item.id,
toEmail: item.to_email,
fromEmail: item.from_email,
subject: item.subject,
text: item.body,
code: item.verification_code,
receivedAt: item.received_at,
})),
});
},
} satisfies ExportedHandler<Env>;
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"lib": ["ES2022"],
"module": "ESNext",
"moduleResolution": "Bundler",
"noEmit": true,
"strict": true,
"types": ["@cloudflare/workers-types"]
},
"include": ["src/**/*.ts"]
}
+19
View File
@@ -0,0 +1,19 @@
name = "cloudmail-inbox"
main = "src/index.ts"
compatibility_date = "2026-07-22"
# Create the database first, then replace this placeholder with its database_id.
[[d1_databases]]
binding = "DB"
database_name = "cloudmail-inbox"
database_id = "5cbdac91-52b5-41a1-9794-4b904897fe4b"
# Append-only mirror for the existing mail service. Existing mail_db records
# and schema are left intact.
[[d1_databases]]
binding = "LEGACY_DB"
database_name = "mail_db"
database_id = "01321ac1-5d9c-4b1a-bc99-7375726d546c"
[vars]
MAIL_DOMAIN = "yyggslive.cc.cd"