commit 93dfbc1e613946ee764b1bcb3e95f35d30c5f31f Author: xiaolan Date: Thu Feb 26 09:32:57 2026 +0800 feat: initial MailOne worker (latest email per recipient, 24h retention) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1cbee2a --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.wrangler/ +node_modules/ +.env +.env.* diff --git a/README.md b/README.md new file mode 100644 index 0000000..6b3c798 --- /dev/null +++ b/README.md @@ -0,0 +1,50 @@ +# MailOne + +MailOne 是一个基于 Cloudflare Email Worker + D1 的临时邮箱 API: + +- Catch-all 收件 +- `/?to=` 直接返回该收件箱最近一封邮件 +- 同一收件箱只保留最新一封 +- 邮件仅保留 24 小时 + +## API + +```bash +GET https://.workers.dev/?to=test@your-domain.com +``` + +返回: + +```json +{ + "id": "...", + "recipient": "test@your-domain.com", + "sender": "sender@example.com", + "nexthop": "your-domain.com", + "subject": "Hello", + "content": "raw email content", + "received_at": 1760000000000 +} +``` + +若无邮件或已过期(>24h),返回 `null`。 + +## Deploy + +1. 创建 D1 数据库并把 `database_id` 写入 `wrangler.toml` +2. 执行建表: + ```bash + wrangler d1 execute mailone --file=./schema.sql + ``` +3. 部署: + ```bash + wrangler deploy + ``` +4. Cloudflare Email Routing 设置 catch-all -> 该 Worker + +## Notes + +- 已移除 API key 鉴权(按需求) +- 清理策略: + - 读取时超过 24h 直接视为无数据 + - 每小时 cron 清理数据库中的过期记录 diff --git a/schema.sql b/schema.sql new file mode 100644 index 0000000..bd47167 --- /dev/null +++ b/schema.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS latest_emails ( + recipient TEXT PRIMARY KEY, + id TEXT, + sender TEXT, + nexthop TEXT, + subject TEXT, + content TEXT, + received_at INTEGER NOT NULL +); diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..2c3dc97 --- /dev/null +++ b/src/index.js @@ -0,0 +1,108 @@ +function json(data, status = 200) { + return new Response(JSON.stringify(data, null, 2), { + status, + headers: { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-store", + "access-control-allow-origin": "*" + } + }); +} + +async function streamToString(stream) { + const reader = stream.getReader(); + const chunks = []; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + } + const total = chunks.reduce((n, c) => n + c.length, 0); + const merged = new Uint8Array(total); + let offset = 0; + for (const c of chunks) { + merged.set(c, offset); + offset += c.length; + } + return new TextDecoder().decode(merged); +} + +function getHeader(headers, name) { + try { + return headers.get(name) || ""; + } catch { + return ""; + } +} + +const ONE_DAY_MS = 24 * 60 * 60 * 1000; + +export default { + async fetch(request, env) { + const url = new URL(request.url); + + if (request.method === "OPTIONS") { + return new Response(null, { + headers: { + "access-control-allow-origin": "*", + "access-control-allow-methods": "GET,OPTIONS", + "access-control-allow-headers": "content-type" + } + }); + } + + const to = (url.searchParams.get("to") || "").trim().toLowerCase(); + if (!to) return json({ error: "missing query param: to" }, 400); + + const row = await env.DB.prepare( + `SELECT recipient, id, sender, nexthop, subject, content, received_at + FROM latest_emails + WHERE recipient = ? + LIMIT 1` + ).bind(to).first(); + + if (!row) return json(null); + + if (Date.now() - row.received_at > ONE_DAY_MS) { + return json(null); + } + + return json({ + id: row.id, + recipient: row.recipient, + sender: row.sender, + nexthop: row.nexthop, + subject: row.subject, + content: row.content, + received_at: row.received_at + }); + }, + + async email(message, env) { + const recipient = (message.to || "").toLowerCase(); + const sender = message.from || ""; + const subject = getHeader(message.headers, "subject"); + const id = getHeader(message.headers, "message-id") || crypto.randomUUID(); + const nexthop = recipient.includes("@") ? recipient.split("@")[1] : ""; + const content = await streamToString(message.raw); + const received_at = Date.now(); + + await env.DB.prepare( + `INSERT INTO latest_emails + (recipient, id, sender, nexthop, subject, content, received_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(recipient) DO UPDATE SET + id = excluded.id, + sender = excluded.sender, + nexthop = excluded.nexthop, + subject = excluded.subject, + content = excluded.content, + received_at = excluded.received_at` + ).bind(recipient, id, sender, nexthop, subject, content, received_at).run(); + }, + + async scheduled(_event, env) { + const cutoff = Date.now() - ONE_DAY_MS; + await env.DB.prepare(`DELETE FROM latest_emails WHERE received_at < ?`).bind(cutoff).run(); + } +}; diff --git a/wrangler.toml b/wrangler.toml new file mode 100644 index 0000000..938757e --- /dev/null +++ b/wrangler.toml @@ -0,0 +1,11 @@ +name = "mailone" +main = "src/index.js" +compatibility_date = "2026-02-25" + +[[d1_databases]] +binding = "DB" +database_name = "mailone" +database_id = "YOUR_D1_DATABASE_ID" + +[triggers] +crons = ["0 * * * *"]