如何从 Cloudflare Workers 发送邮件

Worker 不能运行邮件服务器,但可以和邮件服务器通信。下面是我们在构建 MakiSend(一个完全运行在 Workers 上的邮件 API)时总结的经验:哪些可行,哪些会失败,以及原因。

最后更新 · Markdown 版本(英文)

三种发送方式

Worker 有三条出路。按你想依赖谁来选,而不是按代码选:每种方式的代码都很短。

方式Worker 如何发送你需要什么注意事项
HTTP 邮件 API用 fetch() 调用服务商的 API该服务商的账户和 API key只适用于一家服务商的请求格式,以及它按封计费的价格
基于 TCP 的 SMTPnodemailer(开启 nodejs_compat)运行在 Cloudflare 的 TCP socket 上任意服务商或邮箱的 SMTP 凭据允许的端口、TLS 模式,以及每条 SMTP 命令一次往返
Cloudflare Email Servicesend_email 绑定一个已接入 Email Sending 的发件域名向任意收件人发送需要 Workers Paid

SMTP 让你不被绑定:同一段代码可以通过 Google Workspace、Zoho、Microsoft 365、Amazon SES、SendGrid、Mailgun、Postmark、Brevo 或 Resend 自己的 SMTP 发送。它也是坑最多的一种。

Worker 能用哪些 SMTP 端口

Cloudflare 封锁了 25 端口的出站连接,所以 Worker 无法直接投递到收件人的 MX 服务器。它必须把邮件交给提交服务器,使用以下端口之一:

端口安全方式含义
465隐式 TLS(tls)从第一个字节起就是 TLS
587STARTTLS(starttls)先以明文开始,认证前升级为 TLS
2465隐式 TLS(tls)部分服务商提供的 465 替代端口
2525, 2587STARTTLS(starttls)部分服务商提供的 587 替代端口
25无在 Workers 上被封锁

端口要配对正确的模式。465 配 STARTTLS,或 587 配隐式 TLS,都会在 TLS 握手阶段失败(wrong version number),此时还没有发送任何凭据。MakiSend 在保存配置时拒绝 25 端口,并根据端口推断模式。

在 Workers 上运行 nodemailer

只要打开 nodejs_compat 兼容性标志,nodemailer 就能在 Workers 上运行:node:net 和 node:tls 映射到 Cloudflare 的 TCP socket。但仍有三个问题让我们踩了坑。

  1. nodemailer 会先解析主机名,再连接 IP 地址。workerd 拒绝在 465 端口上用隐式 TLS 连接 IP 地址(proxy request failed, cannot connect to the specified address),而同一台服务器用主机名就能正常连接。这个错误完全没有说明真正的原因。
  2. 本地开发时 dns.resolve4 和 dns.resolve6 没有实现,所以默认的 transport 在 wrangler dev 下可能会卡住。
  3. 如果没有 family: 4,workerd 每次发送都会记录网络警告。

前两个问题的解决办法是 nodemailer 的 getSocket 选项:自己按主机名打开 socket,再交给 nodemailer。需要时 nodemailer 仍会执行 STARTTLS,也仍会按主机名校验证书。

smtp-transport.tsts
import * as net from "node:net";
import * as tls from "node:tls";
import nodemailer from "nodemailer";

export function createTransport(env: { SMTP_USER: string; SMTP_PASSWORD: string }) {
  const host = "smtp.example.com";
  const port = 465; // 465 or 2465: implicit TLS. 587, 2525, 2587: STARTTLS.
  const implicitTls = port === 465 || port === 2465;

  return nodemailer.createTransport({
    host,
    port,
    secure: implicitTls,
    requireTLS: !implicitTls,
    family: 4,
    tls: { rejectUnauthorized: true },
    auth: { user: env.SMTP_USER, pass: env.SMTP_PASSWORD },
    // Open the socket by name: workerd refuses implicit TLS to a bare IP address.
    getSocket(_options, callback) {
      const socket = implicitTls
        ? tls.connect({ host, port, servername: host, rejectUnauthorized: true })
        : net.connect({ host, port });
      socket.once(implicitTls ? "secureConnect" : "connect", () =>
        callback(null, implicitTls ? { connection: socket, secured: true } : { connection: socket }),
      );
      socket.once("error", (error) => callback(error, false));
    },
  });
}

MakiSend 的版本增加了超时,并被连接检查、测试发送和正式发送共用:smtp-connection.ts。

Cloudflare Email Service 也在 smtp.mx.cloudflare.net 提供 SMTP,但 Worker 无法向该主机打开 socket。在 Worker 中请改用 send_email 绑定发送。

不要在请求中发送

SMTP 的每条命令都要等待回复:TCP、TLS、EHLO、AUTH、MAIL FROM、RCPT TO、DATA、QUIT。在 Workers 上,一次发送最快需要 300 到 400 ms,最慢约 2 秒,而且 Worker 每封邮件都要新建连接。用户不应该等这么久,发送失败的邮件也不应该丢失。

  • 邮件一保存就返回 202 Accepted,然后把它的 id 放进 Cloudflare Queue。
  • 从 Cloudflare Workflow 中发送:每个步骤都是持久化的,重试会从中断处继续。
  • 只重试以后可能成功的情况。MakiSend 首次尝试一次,再间隔 30 秒重试三次;认证、TLS、DNS、配置错误和 5xx 会立即停止,用完重试次数的邮件进入死信队列。
  • 接受 Idempotency-Key 请求头,这样客户端重试自己的请求时不会重复发送。

等待网络的时间不计入 CPU 限制(Workers Free 上每次调用 10 ms),所以发送本身在免费计划内就能完成。架构页面跟随一封邮件走完这些步骤。

固定 IP 与收件箱

  • Workers 从共享且会变化的 Cloudflare 地址发起连接。只接受白名单 IP 的服务商会拒绝连接:请关闭白名单、依靠 SMTP 凭据,或者从有固定 IP 的服务器发送。
  • 邮件能否进入收件箱取决于发件域名(SPF、DKIM 和 DMARC)和服务商的信誉,与 Worker 无关。在我们的测试中,设置好这三条记录后,MakiSend 的邮件都进入了收件箱。
  • Gmail 和 Google Workspace 限制单个邮箱每天的发送量。大量发送请使用事务邮件服务商。

或者交给 MakiSend

MakiSend 把本页的内容全部打包好了:带认证的 POST /send-email、队列、带重试的持久化 Workflow、通过主机名打开 socket 的 nodemailer、Cloudflare Email Service 绑定,以及带日志的 dashboard。它是开源的,部署在你自己的 Cloudflare 账户上,Workers Free 账户也可以。

常见问题

Cloudflare Worker 能通过 SMTP 发送邮件吗?

能。开启 nodejs_compat 标志后,nodemailer 运行在 Cloudflare 的 TCP socket 上,可以通过 465、587、2465、2525 或 2587 端口连接任何 SMTP 服务商。25 端口被封锁。

为什么 Worker 无法使用 25 端口?

Cloudflare 封锁了 25 端口的出站连接。请改用服务商的提交端口:465 配隐式 TLS,或 587 配 STARTTLS。

为什么 nodemailer 在 465 端口报错“proxy request failed”?

nodemailer 连接的是解析后的 IP 地址,而 workerd 拒绝对裸 IP 使用隐式 TLS。用 nodemailer 的 getSocket 选项按主机名打开 socket 即可。

免费计划能用 Cloudflare Email Service 吗?

不能向任意收件人发送:这需要 Workers Paid(每月 $5)。在 Workers Free 上,请通过其他 SMTP 服务商发送。