Add Gmail+Email Worker hybrid mail path and private runtime config
Prefer Cloudflare Worker KV for verification codes with optional short IMAP fallback disabled for private deploy; track config.json/.env for private repo. Also add worker project, protocol mint backoff, and fail-fast Turnstile/mail timeouts from batch runs.
This commit is contained in:
1 parent
187792d6be
commit
55f56d27c9
19 files changed
+1225
-85
No files matched your search
@@ -0,0 +1,98 @@
|
||||
# Grok xAI 验证码 Email Worker
|
||||
|
||||
```
|
||||
xAI 发信
|
||||
→ Cloudflare Email Routing(catch-all)
|
||||
→ 本 Worker:提取验证码写入 KV
|
||||
→ (可选)forward 到 Gmail 作 IMAP 兜底
|
||||
→ 注册机 HTTP GET Worker /code?to=alias@domain&key=...
|
||||
```
|
||||
|
||||
## 1. 前置
|
||||
|
||||
- 域名 DNS 在 Cloudflare
|
||||
- 开通 **Email Routing**
|
||||
- 安装 Node:`npm i -g wrangler` 或用 `npx wrangler`
|
||||
|
||||
## 2. 创建 KV + 配置
|
||||
|
||||
```bash
|
||||
cd cf-email-worker
|
||||
npx wrangler login
|
||||
npx wrangler kv namespace create GROK_MAIL_CODES
|
||||
# 把返回的 id 填进 wrangler.toml 的 kv_namespaces.id
|
||||
```
|
||||
|
||||
编辑 `wrangler.toml`,或在 Dashboard → Workers → Settings → Variables 设置:
|
||||
|
||||
| 变量 | 含义 |
|
||||
|------|------|
|
||||
| `CODE_API_KEY` | 注册机查询密钥(必填) |
|
||||
| `GMAIL_FORWARD_TO` | 如 `you@gmail.com`,保留 IMAP 兜底 |
|
||||
| `CODE_TTL_SEC` | KV 过期秒数,默认 600 |
|
||||
|
||||
```bash
|
||||
npx wrangler secret put CODE_API_KEY
|
||||
# 可选:也可用 vars;secret 更安全
|
||||
```
|
||||
|
||||
## 3. 部署
|
||||
|
||||
```bash
|
||||
npx wrangler deploy
|
||||
# 记下 workers.dev 地址,例如:
|
||||
# https://grok-xai-mail-codes.<subdomain>.workers.dev
|
||||
```
|
||||
|
||||
## 4. Email Routing 绑定
|
||||
|
||||
Cloudflare Dashboard → **Email** → **Email Routing** → **Routing rules**:
|
||||
|
||||
1. 启用 Catch-all
|
||||
2. Action = **Send to a Worker** → 选择 `grok-xai-mail-codes`
|
||||
3. 域名 MX 按 Cloudflare 提示配置(若尚未)
|
||||
|
||||
> 若 catch-all 已转发到 Gmail,可改为:Worker 处理 + Worker 内 `GMAIL_FORWARD_TO` 再转到 Gmail。
|
||||
> 不要只转 Gmail 而不进 Worker,否则没有秒级路径。
|
||||
|
||||
## 5. 注册机配置
|
||||
|
||||
`config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"email_provider": "gmail",
|
||||
"defaultDomains": "your-domain.com",
|
||||
"gmail_imap_user": "you@gmail.com",
|
||||
"gmail_imap_password": "app-password",
|
||||
"gmail_worker_enabled": true,
|
||||
"gmail_worker_url": "https://grok-xai-mail-codes.<subdomain>.workers.dev",
|
||||
"gmail_worker_api_key": "与 CODE_API_KEY 相同",
|
||||
"gmail_worker_timeout_sec": 25,
|
||||
"gmail_worker_poll_interval": 0.4,
|
||||
"gmail_imap_fallback": true
|
||||
}
|
||||
```
|
||||
|
||||
## 6. 自测
|
||||
|
||||
```bash
|
||||
# 健康检查
|
||||
curl -sS "https://YOUR_WORKER/health"
|
||||
|
||||
# 模拟入库
|
||||
curl -sS -X POST "https://YOUR_WORKER/ingest?key=YOUR_KEY" \
|
||||
-H 'content-type: application/json' \
|
||||
-d '{"to":"test@your-domain.com","code":"ABC-DEF","subject":"ABC-DEF xAI confirmation code"}'
|
||||
|
||||
# 取码(consume=1 用后即删)
|
||||
curl -sS "https://YOUR_WORKER/code?to=test@your-domain.com&key=YOUR_KEY"
|
||||
```
|
||||
|
||||
真实链路:给 `random@your-domain.com` 发一封带 `ABC-DEF xAI confirmation code` 主题的信,几秒内应能 GET 到。
|
||||
|
||||
## 7. 安全
|
||||
|
||||
- `CODE_API_KEY` 用足够长的随机串
|
||||
- 不要把 key 提交进 git(写 config.json / .env,二者已在 .gitignore)
|
||||
- KV 中验证码默认约 10 分钟过期,且 `/code` 默认 consume-once
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "grok-xai-mail-codes",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"deploy": "wrangler deploy",
|
||||
"dev": "wrangler dev"
|
||||
},
|
||||
"devDependencies": {
|
||||
"wrangler": "^3.99.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* grok-xai-mail-codes — hardened for clearer errors (no bare 1101 if possible)
|
||||
*
|
||||
* Bindings:
|
||||
* KV name MUST be: CODES
|
||||
* Secrets/vars:
|
||||
* CODE_API_KEY (required)
|
||||
* GMAIL_FORWARD_TO (optional)
|
||||
* CODE_TTL_SEC (optional, default 600)
|
||||
*/
|
||||
|
||||
const CODE_PATTERNS = [
|
||||
/^([A-Z0-9]{3}-[A-Z0-9]{3})\s+xAI/i,
|
||||
/\b([A-Z0-9]{3}-[A-Z0-9]{3})\b/i,
|
||||
/verification\s+code[:\s]+(\d{4,8})/i,
|
||||
/your\s+code[:\s]+(\d{4,8})/i,
|
||||
/confirm(?:ation)?\s+code[:\s]+(\d{4,8})/i,
|
||||
/验证码[::\s]+(\d{4,8})/,
|
||||
];
|
||||
|
||||
const KEYWORDS = ["x.ai", "xai", "grok", "verification", "code", "confirm", "验证码", "确认"];
|
||||
|
||||
function json(data, status = 200) {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: { "content-type": "application/json; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
|
||||
function extractCode(text, subject = "") {
|
||||
const blob = `${subject || ""}\n${text || ""}`;
|
||||
for (const re of CODE_PATTERNS) {
|
||||
const m = blob.match(re);
|
||||
if (m && m[1]) return String(m[1]).trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function emailsFromList(list) {
|
||||
if (!list) return [];
|
||||
const arr = Array.isArray(list) ? list : [list];
|
||||
const out = [];
|
||||
for (const item of arr) {
|
||||
if (!item) continue;
|
||||
if (typeof item === "string") {
|
||||
const m = item.match(/[\w.+-]+@[\w.-]+\.\w+/g);
|
||||
if (m) out.push(...m.map((x) => x.toLowerCase()));
|
||||
continue;
|
||||
}
|
||||
if (item.address) out.push(String(item.address).toLowerCase());
|
||||
if (item.email) out.push(String(item.email).toLowerCase());
|
||||
}
|
||||
return [...new Set(out.filter(Boolean))];
|
||||
}
|
||||
|
||||
async function parseEmail(message) {
|
||||
const raw = await new Response(message.raw).arrayBuffer();
|
||||
const bytes = new Uint8Array(raw);
|
||||
let rawText = "";
|
||||
try {
|
||||
rawText = new TextDecoder("utf-8", { fatal: false }).decode(bytes);
|
||||
} catch {
|
||||
const n = Math.min(bytes.length, 500000);
|
||||
let s = "";
|
||||
for (let i = 0; i < n; i++) s += String.fromCharCode(bytes[i]);
|
||||
rawText = s;
|
||||
}
|
||||
|
||||
const headerBlob = rawText.split(/\r?\n\r?\n/, 1)[0] || "";
|
||||
let subject = "";
|
||||
const subjMatch = headerBlob.match(/^Subject:\s*(.+)$/im);
|
||||
if (subjMatch) subject = subjMatch[1].trim();
|
||||
|
||||
let from = "";
|
||||
const fromMatch = headerBlob.match(/^From:\s*(.+)$/im);
|
||||
if (fromMatch) from = fromMatch[1].trim();
|
||||
|
||||
const recipients = new Set();
|
||||
for (const h of ["To", "Cc", "Delivered-To", "X-Original-To", "Envelope-To"]) {
|
||||
const re = new RegExp(`^${h}:\\s*(.+)$`, "gim");
|
||||
let m;
|
||||
while ((m = re.exec(headerBlob))) {
|
||||
const found = m[1].match(/[\w.+-]+@[\w.-]+\.\w+/g) || [];
|
||||
for (const e of found) recipients.add(e.toLowerCase());
|
||||
}
|
||||
}
|
||||
try {
|
||||
for (const e of emailsFromList(message.to)) recipients.add(e);
|
||||
} catch (_) {}
|
||||
|
||||
return {
|
||||
subject,
|
||||
from,
|
||||
recipients: [...recipients],
|
||||
rawText: rawText.slice(0, 200000),
|
||||
};
|
||||
}
|
||||
|
||||
function isXaiMail(subject, from, body) {
|
||||
const blob = `${subject}\n${from}\n${body}`.toLowerCase();
|
||||
return KEYWORDS.some((k) => blob.includes(k));
|
||||
}
|
||||
|
||||
function kv(env) {
|
||||
// Accept common mistaken binding names
|
||||
return env.CODES || env.GROK_MAIL_CODES || env.CODE || null;
|
||||
}
|
||||
|
||||
async function storeCode(env, toEmail, payload) {
|
||||
const ns = kv(env);
|
||||
if (!ns || typeof ns.put !== "function") {
|
||||
throw new Error("KV binding missing: add KV namespace with variable name CODES");
|
||||
}
|
||||
const ttl = Math.max(60, parseInt(String(env.CODE_TTL_SEC || "600"), 10) || 600);
|
||||
const key = `code:${String(toEmail).toLowerCase().trim()}`;
|
||||
await ns.put(key, JSON.stringify(payload), { expirationTtl: ttl });
|
||||
try {
|
||||
const prev = JSON.parse((await ns.get("recent")) || "[]");
|
||||
prev.unshift({
|
||||
to: toEmail,
|
||||
code: payload.code,
|
||||
ts: payload.ts,
|
||||
subject: payload.subject,
|
||||
});
|
||||
await ns.put("recent", JSON.stringify(prev.slice(0, 30)), { expirationTtl: ttl });
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function checkKey(request, env, url) {
|
||||
const key = url.searchParams.get("key") || request.headers.get("x-api-key") || "";
|
||||
const expected = env.CODE_API_KEY || "";
|
||||
return Boolean(expected) && key === expected;
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request, env, ctx) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const path = url.pathname.replace(/\/+$/, "") || "/";
|
||||
|
||||
if (path === "/health") {
|
||||
const ns = kv(env);
|
||||
return json({
|
||||
ok: true,
|
||||
service: "grok-xai-mail-codes",
|
||||
kv_bound: Boolean(ns && typeof ns.get === "function"),
|
||||
has_api_key: Boolean(env.CODE_API_KEY),
|
||||
});
|
||||
}
|
||||
|
||||
if (!checkKey(request, env, url)) {
|
||||
return json({ error: "unauthorized" }, 401);
|
||||
}
|
||||
|
||||
if (path === "/code" && request.method === "GET") {
|
||||
const to = (url.searchParams.get("to") || url.searchParams.get("target") || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!to) return json({ error: "missing to" }, 400);
|
||||
|
||||
const ns = kv(env);
|
||||
if (!ns || typeof ns.get !== "function") {
|
||||
return json(
|
||||
{
|
||||
error: "kv not bound",
|
||||
hint: "Workers → grok-xai-mail-codes → Settings → Bindings → add KV, Variable name must be CODES",
|
||||
},
|
||||
500,
|
||||
);
|
||||
}
|
||||
|
||||
const raw = await ns.get(`code:${to}`);
|
||||
if (!raw) return json({ to, error: "not found" }, 404);
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(raw);
|
||||
} catch {
|
||||
return json({ to, error: "bad kv value" }, 500);
|
||||
}
|
||||
|
||||
const consume = (url.searchParams.get("consume") || "1") !== "0";
|
||||
if (consume) {
|
||||
try {
|
||||
await ns.delete(`code:${to}`);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
return json({
|
||||
ok: true,
|
||||
to,
|
||||
code: data.code,
|
||||
subject: data.subject || "",
|
||||
from: data.from || "",
|
||||
ts: data.ts || 0,
|
||||
source: "worker-kv",
|
||||
});
|
||||
}
|
||||
|
||||
if (path === "/recent" && request.method === "GET") {
|
||||
const ns = kv(env);
|
||||
if (!ns) return json({ items: [], kv_bound: false });
|
||||
const raw = await ns.get("recent");
|
||||
return json({ items: raw ? JSON.parse(raw) : [], kv_bound: true });
|
||||
}
|
||||
|
||||
if (path === "/ingest" && request.method === "POST") {
|
||||
let body;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return json({ error: "invalid json" }, 400);
|
||||
}
|
||||
const to = String(body.to || body.target || "").toLowerCase().trim();
|
||||
const code = String(body.code || "").trim();
|
||||
if (!to || !code) return json({ error: "missing to/code" }, 400);
|
||||
try {
|
||||
await storeCode(env, to, {
|
||||
code,
|
||||
subject: body.subject || `${code} xAI confirmation code`,
|
||||
from: body.from || "",
|
||||
ts: Date.now(),
|
||||
});
|
||||
} catch (e) {
|
||||
return json({ error: String(e && e.message ? e.message : e) }, 500);
|
||||
}
|
||||
return json({ ok: true, to, code });
|
||||
}
|
||||
|
||||
return json({ error: "not found" }, 404);
|
||||
} catch (e) {
|
||||
return json(
|
||||
{
|
||||
error: "worker exception",
|
||||
message: String(e && e.message ? e.message : e),
|
||||
},
|
||||
500,
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
async email(message, env, ctx) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = await parseEmail(message);
|
||||
} catch (_) {
|
||||
const forwardTo = String(env.GMAIL_FORWARD_TO || "").trim();
|
||||
if (forwardTo) {
|
||||
try {
|
||||
await message.forward(forwardTo);
|
||||
} catch (_) {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const { subject, from, recipients, rawText } = parsed;
|
||||
const code = extractCode(rawText, subject);
|
||||
const interesting = isXaiMail(subject, from, rawText) || !!code;
|
||||
|
||||
if (interesting && code && recipients.length) {
|
||||
const payload = { code, subject, from, ts: Date.now() };
|
||||
try {
|
||||
await Promise.all(recipients.map((to) => storeCode(env, to, payload)));
|
||||
} catch (_) {
|
||||
// still forward
|
||||
}
|
||||
}
|
||||
|
||||
const forwardTo = String(env.GMAIL_FORWARD_TO || "").trim();
|
||||
if (forwardTo) {
|
||||
try {
|
||||
await message.forward(forwardTo);
|
||||
} catch (e) {
|
||||
try {
|
||||
message.setReject?.(`forward failed: ${e}`);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
# Cloudflare Email Worker for xAI verification codes
|
||||
# Deploy: cd cf-email-worker && npx wrangler deploy
|
||||
#
|
||||
# Required:
|
||||
# 1) Cloudflare Email Routing enabled for your domain
|
||||
# 2) Routing rule: catch-all → this Worker
|
||||
# 3) Optional Gmail forward destination (keeps IMAP fallback)
|
||||
|
||||
name = "grok-xai-mail-codes"
|
||||
main = "src/worker.js"
|
||||
compatibility_date = "2024-11-01"
|
||||
|
||||
# KV namespace for short-lived codes (create once, then bind id)
|
||||
# npx wrangler kv namespace create GROK_MAIL_CODES
|
||||
[[kv_namespaces]]
|
||||
binding = "CODES"
|
||||
id = "REPLACE_WITH_KV_NAMESPACE_ID"
|
||||
preview_id = "REPLACE_WITH_KV_NAMESPACE_ID"
|
||||
|
||||
[vars]
|
||||
# Shared secret for GET /code (also set via dashboard secrets if preferred)
|
||||
# CODE_API_KEY = "change-me"
|
||||
# Forward original mail to Gmail so IMAP fallback still works
|
||||
# GMAIL_FORWARD_TO = "you@gmail.com"
|
||||
# Code TTL seconds in KV
|
||||
CODE_TTL_SEC = "600"
|
||||
Reference in new issue
Block a user