API
用 Cloudflare Workers 做 GQ CRM 的多项目邮件路由
一个 Worker 管多个项目:用按项目划分的 API Key 分发发送、校验 GQ CRM 的 Webhook 签名、按投递 ID 去重,并按 projectId 路由事件。也包含真正会踩到的限制。
发布于 2026年9月21日
当你同时跑多个发送项目——每个客户、每个品牌或每个环境一个——你迟早会想要一个统一的地方来决定:一次发送、一个事件,该归到哪个项目。Cloudflare Worker 很适合干这件事:它在边缘、便宜,并且可以把每个项目一把 API Key 作为加密 secret 存着。
这里有两条互不依赖的链路:
- 出站: cron 触发唤醒 Worker,由它挑出正确项目的 Key,调用 GQ CRM 的 Send API。
- 入站: GQ CRM 把
email.sent/bounced/complained/unsubscribed事件推送到同一个 Worker 地址,由 Worker 校验签名并按projectId路由。
因为一个工作空间最多允许 10 个 Webhook 端点,「一个端点、按项目分流」通常才是你想要的形态,而不是每个项目一个端点。
开始之前
- 每个发送身份一个 GQ CRM 项目——各自独立的 SMTP 配置、模板和 API Key。API 权限需要 Starter 及以上套餐。
- 每个项目一把 API Key,最好带上
send:email作用域、过期时间,以及(仅当你具备固定出口 IP 时)IP 白名单。 - 一个装好 Wrangler 的 Cloudflare 账号,以及存放路由状态的地方(起步用 KV 就够,需要强一致再上 D1)。
第 1 步:每个项目一把 Key,存为 Worker secret
不要把 Key 提交进 wrangler.toml 或源码。把它们存成 secret,再通过绑定名引用:
wrangler secret put GQ_KEY_ACME
wrangler secret put GQ_KEY_NORTHWIND
然后维护一张「租户 → 绑定名」的路由表,而不是「租户 → Key」,这样轮换密钥永远不需要改代码:
name = "mail-router"
main = "src/index.js"
compatibility_date = "2026-09-01"
[triggers]
crons = ["*/5 * * * *"]
[[kv_namespaces]]
binding = "ROUTING"
id = "<你的 KV namespace id>"
第 2 步:按项目分发发送
Worker 要做的事很少:查出租户、拿到它的 Key、发送。有两个细节让它可以被安全地重试——按项目划分的 Key,以及由业务事件(而不是时间戳)推导出的幂等 Key。
async function sendForProject(env, tenant, message) {
const binding = await env.ROUTING.get(`tenant:${tenant}`);
if (!binding) throw new Error(`unknown tenant: ${tenant}`);
const res = await fetch('https://gq-crm.com/api/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': env[binding],
'Idempotency-Key': message.businessKey, // 例如 invoice-2026-0042-reminder
},
body: JSON.stringify({ to: message.to, subject: message.subject, html: message.html }),
});
if (res.status === 429) throw new Error('rate limited — back off and retry');
if (!res.ok) throw new Error(`send failed: ${res.status} ${await res.text()}`);
return res.json();
}
限流按「项目 + 分钟」计数(Free 60、Starter 300、Professional 1000),所以只要每个租户的流量各自受控,跨租户的批量循环天然不会越过上限。在 24 小时内复用同一个 Idempotency-Key 会重放原始响应而不是重发——这正是 cron 在部分失败后再次运行仍然安全的原因。
第 3 步:在同一个地址接收 Webhook
GQ CRM 给每次投递签名:X-Webhook-Signature: t=<unix>,v1=<hex hmac-sha256(secret, "t.rawBody")>。校验必须针对原始请求体,并拒绝过期时间戳:
async function verify(request, secret, rawBody) {
const parts = Object.fromEntries(
(request.headers.get('X-Webhook-Signature') || '').split(',').map((p) => p.split('=')),
);
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false; // 5 分钟窗口
const key = await crypto.subtle.importKey(
'raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'],
);
const mac = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(`${t}.${rawBody}`));
const expected = [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, '0')).join('');
const got = (parts.v1 || '').toLowerCase();
if (expected.length !== got.length) return false;
let diff = 0;
for (let i = 0; i < expected.length; i += 1) diff |= expected.charCodeAt(i) ^ got.charCodeAt(i);
return diff === 0;
}
export default {
async fetch(request, env, ctx) {
const rawBody = await request.text(); // 必须在 JSON 解析之前
if (!(await verify(request, env.GQ_WEBHOOK_SECRET, rawBody))) {
return new Response('bad signature', { status: 401 });
}
const deliveryId = request.headers.get('X-Webhook-Delivery-Id');
if (deliveryId && (await env.ROUTING.get(`seen:${deliveryId}`))) {
return new Response('ok', { status: 200 }); // 投递是 at-least-once
}
if (deliveryId) ctx.waitUntil(env.ROUTING.put(`seen:${deliveryId}`, '1', { expirationTtl: 604800 }));
ctx.waitUntil(handle(JSON.parse(rawBody), env)); // 先快速应答,再干活
return new Response('ok', { status: 200 });
},
};
这里有三条要紧的规矩。先校验再解析,因为签名覆盖的是精确的字节。用定时安全的方式比较,因为会短路的 === 会泄露信息。按 X-Webhook-Delivery-Id 去重,因为投递是 at-least-once,响应慢一点触发一次重试是常态。
第 4 步:按项目路由
每个事件负载都带 data.projectId,所以一个 handler 就能按项目分发:
async function handle(event, env) {
const { projectId } = event.data;
switch (event.event) {
case 'email.bounced':
case 'email.complained':
return env.ROUTING.put(`suppress:${projectId}:${event.data.to}`, '1');
case 'email.unsubscribed':
return env.ROUTING.put(`unsub:${projectId}:${event.data.to}`, '1');
case 'email.sent':
return env.ROUTING.put(`last-sent:${projectId}`, event.createdAt);
default:
return null;
}
}
在自己的存储里维护按项目的抑制名单,值这几行代码:它让你在调用 API 之前就能检查,而不是在退信发生之后才发现。如果你不希望正文落到远端存储,GQ CRM 可以加密 email.sent 和 email.failed 的负载正文——密钥由端点 secret 推导,Worker 用手里已有的 secret 就能解密。
第 5 步(可选):用 Email Routing 处理入站邮件
上面的链路覆盖的是出站发送和平台事件。如果你还想要按项目的入站地址,Cloudflare Email Routing 可以接住某个域名的来信并交给 Worker 的 email handler,你在那里按收件人地址路由到对应项目的收件箱或存储。GQ CRM 负责出站发送,入站属于 Cloudflare 那一侧,两件事就此分开。
真正会踩到的限制
- Worker 默认没有稳定的出口 IP。 API Key 上的 CIDR 白名单会直接拒绝来自 Worker 的调用,所以要么在前面加一层有固定 IP 的转发,要么改用「每项目一把 Key + 作用域 + 过期时间」来兜底。
- KV 是最终一致的。 两次并发投递可能都查不到那个
seen:Key。如果去重必须精确,请用 D1 或 Durable Object。 - Webhook 重试会走到头。 失败按 1m/5m/30m/2h/6h 退避重试,之后被标记为
DEAD——大约 8.6 小时的缓冲。要对 DEAD 投递告警,而不是假定重试总会成功。 - 接收端必须快。 投递 10 秒超时、不跟随重定向、只有 2xx 算成功。先应答,把活交给
waitUntil。 - 端点必须是公网 HTTPS。 创建端点时 GQ CRM 会解析域名并拒绝私网、回环和链路本地地址,所以
workers.dev地址或自定义域名都可以,指向你笔记本的隧道不行。 - cron 最小粒度是一分钟。 不要设计需要秒级调度的路由器。
验证清单
- 为某个已知租户通过 Worker 发一封,然后重跑同一个任务——第二次应该是重放第一次的响应,而不是再发一封。
- 用一个签名有效的抓包请求体重放:第一次应返回 200,第二次应被忽略。
- 用被篡改的请求体或过期时间戳重放:应返回 401。
- 把测试端点指向 Worker,触发一次退信,确认抑制 Key 落在正确的
projectId下。
发送侧请看 Send API 快速上手 和项目与 API Key。如果中继用 SES,接入 Amazon SES 覆盖了 SMTP 那一半。