Add Cloudflare catch-all mail worker
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
.wrangler/
|
||||
.dev.vars
|
||||
@@ -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);
|
||||
Generated
+1576
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2022"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"types": ["@cloudflare/workers-types"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user