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

Node.js 发邮件:Nodemailer 与 HTTP API 的取舍
Node 服务发信有两条现实路径:用 Nodemailer 打开 SMTP 连接,或者向 HTTP API 发 POST 请求。最终投递出去的是同一封邮件,区别在于你要维护什么、错误以什么形式暴露,以及发送量从十封涨到一万封时会发生什么。
这个区别在 Node 里格外重要,因为 Node 的并发模型是单线程加事件循环。发信本身几乎不消耗 CPU,真正的成本在等待——等 TCP 握手、等 TLS 协商、等对方回 250 OK。用正确的异步写法,等待不会阻塞事件循环;但如果你把一个连接池开得太大,或者用固定间隔的循环去发,问题会以「内存涨、延迟升、请求超时」的形式冒出来,而且很难和发信这件事联系起来。
本文覆盖真正决定选型的那几个生产细节——连接池、并发、重试、幂等和投递事件——示例可直接用在 Node 20+ 项目里。API 方式使用内置的 fetch,不需要安装任何 SDK。
两种方式
| HTTP API | Nodemailer + 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 的文件,更好的做法是上传到对象存储,在正文里放一个带签名的临时链接。链接可以设过期时间,可以统计下载次数,也不会因为附件太大被收件方拒收。
上线前的检查清单
把这份清单当成发信服务上线前的最后一道关:
from地址的域名已经验证,SPF、DKIM、DMARC 全部生效。- 所有发送都经过同一个封装函数,没有散落在业务代码里的裸
fetch。 - 每一条可能重复的发送都带了幂等键,键由业务事件推导。
- 发送函数有超时,失败有退避重试,4xx 不重试。
- 批量发送有并发上限,不是
Promise.all一把梭。 - Webhook 路由在解析器之前注册,用原始请求体验签,处理幂等。
- 发信失败会记录日志和消息 id,能定位到具体是哪一封。
- 退信和投诉事件会更新抑制名单,后续发送自动跳过。
这八条里最容易漏的是第一条和第八条。域名没验证时错误很明确,但 DMARC 没配不会报错——邮件照样发出去,只是慢慢落进垃圾箱,等到发现时已经损失了一批用户。抑制名单没接上则相反:每一封发往已退信地址的邮件都在拉低整个域名的声誉,而日志里看不到任何异常。
错误处理参考
| 状态码 | 含义 | 处理方式 |
|---|---|---|
| 200/201 | 已接受投递 | 保存 id,等待 Webhook |
| 400 | 请求格式错误、附件 base64 无效、域名未验证 | 修正请求 |
| 401 | Key 无效,或用 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 发送邮件;发送超时后如何安全重试见 幂等键的使用。