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 最小粒度是一分钟。 不要设计需要秒级调度的路由器。

验证清单

  1. 为某个已知租户通过 Worker 发一封,然后重跑同一个任务——第二次应该是重放第一次的响应,而不是再发一封。
  2. 用一个签名有效的抓包请求体重放:第一次应返回 200,第二次应被忽略。
  3. 用被篡改的请求体或过期时间戳重放:应返回 401。
  4. 把测试端点指向 Worker,触发一次退信,确认抑制 Key 落在正确的 projectId 下。

发送侧请看 Send API 快速上手 和项目与 API Key。如果中继用 SES,接入 Amazon SES 覆盖了 SMTP 那一半。

免费试用 GQ CRM

连接你的 SMTP,验证收件人列表,今天就发出第一封营销邮件。