← 返回博客

Node.js 发邮件:Nodemailer 与 HTTP API 的取舍

Node 发邮件的两条路径:直接调 HTTP API,或用 Nodemailer 走 SMTP。覆盖连接池、并发控制、重试、幂等键、Webhook 与错误处理。

更新于 2026年10月21日
Node.js 发邮件:Nodemailer 与 HTTP API 的取舍

Node.js 发邮件:Nodemailer 与 HTTP API 的取舍

Node 服务发信有两条现实路径:用 Nodemailer 打开 SMTP 连接,或者向 HTTP API 发 POST 请求。最终投递出去的是同一封邮件,区别在于你要维护什么、错误以什么形式暴露,以及发送量从十封涨到一万封时会发生什么。

这个区别在 Node 里格外重要,因为 Node 的并发模型是单线程加事件循环。发信本身几乎不消耗 CPU,真正的成本在等待——等 TCP 握手、等 TLS 协商、等对方回 250 OK。用正确的异步写法,等待不会阻塞事件循环;但如果你把一个连接池开得太大,或者用固定间隔的循环去发,问题会以「内存涨、延迟升、请求超时」的形式冒出来,而且很难和发信这件事联系起来。

本文覆盖真正决定选型的那几个生产细节——连接池、并发、重试、幂等和投递事件——示例可直接用在 Node 20+ 项目里。API 方式使用内置的 fetch,不需要安装任何 SDK。

两种方式

HTTP APINodemailer + SMTP
连接每次发送一次 HTTPS 请求长连接 TCP
凭据Bearer Token用户名 + 密码
投递事件按消息 ID 推送的 Webhook退信邮箱、解析 Return-Path
幂等Idempotency-Key 请求头没有
Serverless 友好是差——连接无法复用
运维成本无连接池、TLS、重连、超时

一句话概括:应用代码走 API;只有必须说 SMTP 时才用 Nodemailer,比如要接入现有的中继、企业邮件服务器,或者某个只接受 transport 的库。

Serverless 这一行值得展开。在函数计算环境里,每次冷启动都是新进程,SMTP 连接池根本来不及建立就被回收了。每次调用都要重新走一遍 TCP、TLS 和认证,两三秒的启动开销会直接算进函数的计费时长和用户的等待时间。HTTP API 天然是短连接,冷启动后第一封就能发,这是它在无服务器架构里几乎没有对手的原因。

用 fetch 发送

const res = await fetch('https://api.rovela.dev/emails', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.ROVELA_API_KEY}`,
    'Content-Type': 'application/json',
    'Idempotency-Key': `welcome-${userId}`,
  },
  body: JSON.stringify({
    from: 'Acme <hello@yourdomain.com>',
    to: 'user@example.com',
    subject: '欢迎加入',
    html: '<p>你的账号已就绪。</p>',
    text: '你的账号已就绪。',
  }),
})

if (!res.ok) {
  const err = await res.json().catch(() => ({}))
  throw new Error(`发送失败: ${res.status} ${err.message ?? ''}`)
}

const { id } = await res.json()

三处细节值得照抄。from 支持带显示名的写法,多数客户端会显示显示名而不是原始地址,而原始地址必须属于已经验证过的域名,否则请求会被拒绝。错误响应里带 message 字段,记它而不是记整个响应体——响应体可能有几百行 HTML 错误页,日志里塞满了反而找不到关键信息。id 是以后查询这封邮件投递情况的凭据,落库保存。

始终同时提供 text 和 html。纯文本部分能提高送达率,也照顾到屏蔽 HTML 的客户端。这两段内容应当表达同一件事,而不是把 HTML 原样塞进 text 字段。

to 既可以是单个字符串,也可以是数组。一次最多 50 个收件人,cc 和 bcc 也计入这个上限,并且按收件人数量计费——所以不要为了「少发几次请求」把不相关的用户塞进同一封邮件,那样既泄露地址,也让每个人看到的退订链接都指向同一个位置。

封装一个类型安全的发送函数

TypeScript 项目里,把请求封装一次,而不是在每个调用点重复拼请求头:

type Message = {
  to: string | string[]
  subject: string
  html?: string
  text?: string
  cc?: string | string[]
  bcc?: string | string[]
  replyTo?: string
  scheduledAt?: string
  tags?: Record<string, string>
}

export async function sendEmail(msg: Message, idempotencyKey?: string) {
  const res = await fetch('https://api.rovela.dev/emails', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.ROVELA_API_KEY!}`,
      'Content-Type': 'application/json',
      ...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
    },
    body: JSON.stringify({
      from: process.env.MAIL_FROM,
      to: msg.to,
      subject: msg.subject,
      html: msg.html,
      text: msg.text,
      cc: msg.cc,
      bcc: msg.bcc,
      reply_to: msg.replyTo,
      scheduled_at: msg.scheduledAt,
      tags: msg.tags,
    }),
    signal: AbortSignal.timeout(10_000),
  })

  if (!res.ok) {
    const err = await res.json().catch(() => ({}))
    throw new Error(`Rovela ${res.status}: ${err.message ?? '发送失败'}`)
  }
  return (await res.json()) as { id: string }
}

AbortSignal.timeout 是最容易被忽略的一处。没有超时,一个挂住的连接会让请求和它的数据库事务一直占着不放;在 Serverless 环境里,函数会一直活到平台强制的超时上限,你为这几分钟的等待付费。十秒对一次 HTTPS 请求来说已经非常宽裕。

注意 template 和 html/text 是互斥的。如果用模板,就不要同时传正文,服务端会按冲突处理。模板变量通过 variables 对象传入,值的类型保持简单——字符串和数字——避免在模板里做复杂逻辑。

Nodemailer:必须走 SMTP 时

Nodemailer 成熟、在任何 Node 环境都能跑。配置连接池,而不是每条消息新建连接:

import nodemailer from 'nodemailer'

const transport = nodemailer.createTransport({
  host: process.env.SMTP_HOST,
  port: 465,
  secure: true,
  auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
  pool: true,
  maxConnections: 5,
  maxMessages: 100,
})

await transport.verify()

await transport.sendMail({
  from: 'Acme <hello@yourdomain.com>',
  to: 'user@example.com',
  subject: '欢迎加入',
  text: '你的账号已就绪。',
  html: '<p>你的账号已就绪。</p>',
})

有量的时候 pool: true 是必须的。不开池,每次 sendMail 都要建 TCP、协商 TLS、重新认证——每封信几百毫秒的额外开销和一次新的登录。开池后 maxConnections 限制同时保持的连接数,也避免撞上服务端对单 IP 的连接数限制。maxMessages 让连接在发够一定数量后重建,绕过部分服务器对单连接发信量的限制。

启动时调用一次 transport.verify()。密码错误、端口被挡这类问题会立刻暴露,而不是等到第一封真实邮件。更好的做法是把它放进健康检查,这样凭据轮换失效时你能在用户投诉之前知道。

secure: true 对应 465 端口,连接一建立就走 TLS。587 端口要写 secure: false 并让 Nodemailer 自动 STARTTLS——写成 true 会直接卡到超时,而且错误信息不会告诉你是端口配错了。

自己限制并发

两种方式都不会替你的循环兜底。对 5000 个收件人直接 Promise.all,就会同时开 5000 个连接,然后被限流(HTTP 429 或 SMTP 的 421),真正的错误淹没在成千上万次重试里。更糟的是,这 5000 个 promise 同时持有请求体和响应流的缓冲,内存会在一瞬间冲高。

async function mapLimit(items, limit, fn) {
  const out = []
  const queue = items.entries()
  const workers = Array.from({ length: limit }, async () => {
    for (const [i, item] of queue) out[i] = await fn(item, i)
  })
  await Promise.all(workers)
  return out
}

await mapLimit(recipients, 10, (r) => sendEmail({ to: r.email, subject, html, text }))

queue.entries() 返回的是迭代器,多个 worker 共享同一个迭代器时,每次 next() 拿到的是不同的一项——这样每个 worker 都从队列里领任务,而不是预先切片。这种朴素的做法在 JavaScript 单线程模型下是安全的,不存在两个 worker 拿到同一项的情况。

单封发送保持在并发 10 左右是稳妥的默认值。如果一次要发很多封不同的邮件,批量接口单次最多接收 100 封,并按顺序返回每封的 id。批量请求里每封邮件独立处理,一封失败不影响其他封,返回结果里要逐条检查。注意批量请求里的 template 和 topic_id 会被拒绝而不是静默忽略——正文自己渲染,这两类请求走单封接口。

并发数不是越大越好。收件服务商按 IP 分配连接和速率配额,超过之后的表现不是「慢一点」,而是拒信、延迟、甚至临时拉黑。从 10 开始,观察 429 和延迟率,再决定要不要往上调。

重试与幂等

只重试「再试一次有可能成功」的错误:网络错误、429、5xx。400 和 401 重试多少次结果都一样,只会把日志刷满。

async function sendWithRetry(msg, key, attempts = 3) {
  for (let i = 0; i < attempts; i++) {
    try {
      return await sendEmail(msg, key)
    } catch (err) {
      const status = Number(/Rovela (\\d+)/.exec(err.message)?.[1])
      if (status && status < 500 && status !== 429) throw err
      if (i === attempts - 1) throw err
      await new Promise((r) => setTimeout(r, 250 * 2 ** i + Math.random() * 100))
    }
  }
}

退避里加一点随机抖动,是为了避免大量实例在同一个时刻一起重试,形成第二轮尖峰。这是分布式系统里的常规做法,在发信场景里同样适用。

重试要配一个由事件本身推导出来的稳定 Idempotency-Key,不要用 Date.now()。24 小时内相同 key 的请求会返回原始响应而不是再发一封,因此「超时 + 重试」最终只会产生一封邮件。key 最长 256 字符;对 order-shipped:{orderId} 做一次哈希是理想做法,短、稳定、不会因为参数顺序变化而变。

幂等键的价值在超时这种边界情况下最明显:请求其实已经到了服务器,邮件也发出去了,但响应在网络里丢了。没有幂等键,客户端会认为失败并重试,用户收到两封一模一样的邮件。有幂等键,第二次请求拿回第一次的结果,用户可以放心地重试到成功。

接收投递事件

发送接口返回 200 只代表已接受,不代表已投递。要了解后续情况,需要注册 Webhook 并处理 email.delivered、email.bounced、email.complained 和 email.failed。

Express 里验签需要原始请求体,所以这条路由要注册在 JSON 解析器之前,或者单独用 express.raw:

app.post('/webhooks/rovela', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.get('rovela-signature')
  if (!verifySignature(req.body, signature, process.env.WEBHOOK_SECRET)) {
    return res.sendStatus(400)
  }
  const event = JSON.parse(req.body.toString())
  queue.push(event)
  res.sendStatus(200)
})

验签要用原始字节,不能用解析后的对象——重新序列化会改变键顺序和空白,签名就对不上了。这是接入 Webhook 最常见的错误,症状是「别人的示例能跑,我的永远验签失败」。

事件入队后尽快返回 200;处理太慢会触发重推,造成重复投递。真正的业务处理——更新订单状态、写抑制名单——放到队列里异步做。处理逻辑本身也要幂等,按事件 ID 去重,因为网络抖动导致的重推无法完全避免。

定时发送与附件

schedule_at 接受 ISO 8601 时间字符串,必须是未来时间。常见用法是「下单后一小时发评价邀请」或者「在订阅者本地时间上午九点发」,而不是在自己的服务里开一个 setTimeout 等着——进程一重启,定时任务就没了。把时间交给服务端,客户端只负责记录返回的 id,需要时用取消接口撤回。

附件通过 attachments 数组传入,每项包含 filename、content 和 content_type,其中 content 是 base64 编码后的内容。Node 里用 fs.readFileSync(...).toString('base64') 即可。两个现实约束:

  • 单封邮件总大小有上限,通常 10 MB 左右,base64 编码还会让原始文件膨胀约三分之一。一个 8 MB 的 PDF 编码后接近 11 MB,会直接超限。
  • 附件会拉低送达率。带附件的邮件更容易被当作可疑内容,尤其是可执行文件、压缩包和没有文本正文的纯附件邮件。

超过几 MB 的文件,更好的做法是上传到对象存储,在正文里放一个带签名的临时链接。链接可以设过期时间,可以统计下载次数,也不会因为附件太大被收件方拒收。

上线前的检查清单

把这份清单当成发信服务上线前的最后一道关:

  1. from 地址的域名已经验证,SPF、DKIM、DMARC 全部生效。
  2. 所有发送都经过同一个封装函数,没有散落在业务代码里的裸 fetch。
  3. 每一条可能重复的发送都带了幂等键,键由业务事件推导。
  4. 发送函数有超时,失败有退避重试,4xx 不重试。
  5. 批量发送有并发上限,不是 Promise.all 一把梭。
  6. Webhook 路由在解析器之前注册,用原始请求体验签,处理幂等。
  7. 发信失败会记录日志和消息 id,能定位到具体是哪一封。
  8. 退信和投诉事件会更新抑制名单,后续发送自动跳过。

这八条里最容易漏的是第一条和第八条。域名没验证时错误很明确,但 DMARC 没配不会报错——邮件照样发出去,只是慢慢落进垃圾箱,等到发现时已经损失了一批用户。抑制名单没接上则相反:每一封发往已退信地址的邮件都在拉低整个域名的声誉,而日志里看不到任何异常。

错误处理参考

状态码含义处理方式
200/201已接受投递保存 id,等待 Webhook
400请求格式错误、附件 base64 无效、域名未验证修正请求
401Key 无效,或用 sending_access Key 去读取/取消检查 Key 的权限范围
429触发限流退避,降低并发
5xx服务端故障带幂等键重试

401 那一行值得注意。Key 分两种权限范围:full_access 能发信、能查询、能取消;sending_access 只能发信。用后者去查投递状态会被拒绝,报错看起来像「Key 无效」,实际是权限不够。生产环境里给发信服务单独发一个 sending_access Key,是缩小泄露影响面的简单做法。

怎么选

除非有硬性理由,否则用 HTTP API。它消掉了一整类运维问题——连接池、TLS 版本、重连风暴、以 SMTP 回复码形式表达的服务端限流——同时提供 Nodemailer 给不了的 Webhook 和幂等。

把 Nodemailer 留给确实需要邮件传输的场景:公司内部已经有 SMTP 中继、需要连到某个只对企业网开放的邮件服务器、或者某个框架只接受一个 transport 对象。即便如此,也建议把两者藏在同一个 sendEmail 函数后面,业务代码不必知道底下用的是哪一种——将来迁移时只改一个文件。

完整的字段说明见 API 参考;在框架内部调用同一套 API 的写法见 Next.js 发送邮件;发送超时后如何安全重试见 幂等键的使用。