feat: initial MailOne worker (latest email per recipient, 24h retention)
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
.wrangler/
|
||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
50
README.md
Normal file
50
README.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# MailOne
|
||||||
|
|
||||||
|
MailOne 是一个基于 Cloudflare Email Worker + D1 的临时邮箱 API:
|
||||||
|
|
||||||
|
- Catch-all 收件
|
||||||
|
- `/?to=<email>` 直接返回该收件箱最近一封邮件
|
||||||
|
- 同一收件箱只保留最新一封
|
||||||
|
- 邮件仅保留 24 小时
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GET https://<your-worker>.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 清理数据库中的过期记录
|
||||||
9
schema.sql
Normal file
9
schema.sql
Normal file
@@ -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
|
||||||
|
);
|
||||||
108
src/index.js
Normal file
108
src/index.js
Normal file
@@ -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();
|
||||||
|
}
|
||||||
|
};
|
||||||
11
wrangler.toml
Normal file
11
wrangler.toml
Normal file
@@ -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 * * * *"]
|
||||||
Reference in New Issue
Block a user