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"
|
||||||
+10
-1
@@ -493,7 +493,16 @@ def extract_verification_code(text, subject=""):
|
|||||||
match = re.search(r"^([A-Z0-9]{3}-[A-Z0-9]{3})\s+xAI", subject, re.IGNORECASE)
|
match = re.search(r"^([A-Z0-9]{3}-[A-Z0-9]{3})\s+xAI", subject, re.IGNORECASE)
|
||||||
if match:
|
if match:
|
||||||
return match.group(1)
|
return match.group(1)
|
||||||
match = re.search(r"\b([A-Z0-9]{3}-[A-Z0-9]{3})\b", text, re.IGNORECASE)
|
for source in (subject, text):
|
||||||
|
match = re.search(
|
||||||
|
r"(?:confirmation|verification)\s+code\s*[::]\s*([A-Z0-9]{3}-[A-Z0-9]{3})",
|
||||||
|
source,
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
if match:
|
||||||
|
return match.group(1)
|
||||||
|
# Keep this case-sensitive: lower-case CSS tokens such as `per-100` are not codes.
|
||||||
|
match = re.search(r"\b([A-Z0-9]{3}-[A-Z0-9]{3})\b", text)
|
||||||
if match:
|
if match:
|
||||||
return match.group(1)
|
return match.group(1)
|
||||||
patterns = [
|
patterns = [
|
||||||
|
|||||||
@@ -104,6 +104,13 @@ class MinimalBoundaryRegressionTests(unittest.TestCase):
|
|||||||
code = mail_service.yyds_get_oai_code("token", "user@example.com", timeout=1, poll_interval=0)
|
code = mail_service.yyds_get_oai_code("token", "user@example.com", timeout=1, poll_interval=0)
|
||||||
self.assertEqual(code, "ABC-123")
|
self.assertEqual(code, "ABC-123")
|
||||||
|
|
||||||
|
def test_confirmation_subject_code_beats_lowercase_css_token(self):
|
||||||
|
code = mail_service.extract_verification_code(
|
||||||
|
".mj-column-per-100 { width: 100%; }",
|
||||||
|
"SpaceXAI confirmation code: XOE-G02",
|
||||||
|
)
|
||||||
|
self.assertEqual(code, "XOE-G02")
|
||||||
|
|
||||||
def test_pending_recovery_acquires_pending_and_target_locks_in_fixed_order(self):
|
def test_pending_recovery_acquires_pending_and_target_locks_in_fixed_order(self):
|
||||||
acquired = []
|
acquired = []
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user