Node.js 发送邮件(2026):https、fetch、axios、Express 与 Next.js Webhook 验签
用 Node.js 内置 https 模块、全局 fetch(undici)和 axios 发送事务邮件;含 p-limit 并发批量发送、幂等重试、文件附件,以及 Express、Next.js App Router 和 Pages Router 的 Webhook 签名验证。

Node.js 仅靠标准库就能发送事务邮件。对大多数项目而言,内置的 https 模块或 Node 18 自带的 undici(即全局 fetch)已经够用。本文覆盖这两种方式,以及 axios、幂等重试、文件附件、用 p-limit 并发批量发送,还有在 Express 和 Next.js 中验证 Webhook 签名,最后是上线前检查清单。
前置条件
- Node.js 18 及以上
- Rovela 控制台里的 API Key
undici已随 Node 18 内置;axios和p-limit需要单独安装
内置 https:发送单封邮件
https 模块无需安装。把 API Key 放在环境变量里,不要硬编码。
// rovela.js
const https = require('https');
const ROVELA_URL = 'https://api.rovela.dev/emails';
class RovelaError extends Error {
constructor(statusCode, name, message) {
super(message);
this.statusCode = statusCode;
this.name = name;
}
}
function sendEmail(apiKey, payload, idempotencyKey = '') {
return new Promise((resolve, reject) => {
const body = JSON.stringify(payload);
const headers = {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
};
if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey;
const url = new URL(ROVELA_URL);
const req = https.request(
{ hostname: url.hostname, path: url.pathname, method: 'POST', headers },
(res) => {
let data = '';
res.on('data', (c) => (data += c));
res.on('end', () => {
const parsed = JSON.parse(data);
if (res.statusCode >= 400) {
reject(new RovelaError(res.statusCode, parsed.name, parsed.message));
} else {
resolve(parsed.id);
}
});
},
);
req.on('error', reject);
req.write(body);
req.end();
});
}
module.exports = { sendEmail, RovelaError };
调用示例:
const { sendEmail } = require('./rovela');
(async () => {
const id = await sendEmail(process.env.ROVELA_API_KEY, {
from: 'hello@yourcompany.com',
to: ['customer@example.com'],
subject: '欢迎使用平台',
html: '<h1>您已成功注册。</h1><p>点击下方按钮开始使用。</p>',
});
console.log('已发送:', id);
})();
fetch / undici:现代异步语法
Node 18 的全局 fetch 由 undici 支撑,async/await 写法更简洁,避免回调嵌套。
async function sendEmail(apiKey, payload, idempotencyKey = '') {
const headers = {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
};
if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey;
const res = await fetch(ROVELA_URL, {
method: 'POST',
headers,
body: JSON.stringify(payload),
});
const data = await res.json();
if (!res.ok) throw new RovelaError(res.status, data.name, data.message);
return data.id;
}
带幂等键的重试
Rovela API 在 POST /emails 上接受 Idempotency-Key 请求头。24 小时窗口内,相同的 key 返回缓存响应而不重复发送。在循环之前生成一次 key,每次重试复用同一个值。只对 429 Too Many Requests 重试。
const crypto = require('crypto');
async function sendWithRetry(apiKey, payload, maxAttempts = 3) {
// 生成一次,每次重试复用,保证幂等
const idempotencyKey = crypto.randomUUID();
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
return await sendEmail(apiKey, payload, idempotencyKey);
} catch (err) {
if (err.statusCode !== 429) throw err;
await new Promise((r) => setTimeout(r, 2 ** attempt * 1000));
}
}
throw new Error(`超过最大重试次数 ${maxAttempts}`);
}
文件附件
在 payload 里加入 attachments 数组。每个附件需要 filename、base64 编码的 content 和 content_type。
const fs = require('fs');
const path = require('path');
function buildInvoiceEmail(recipient, invoicePath) {
const content = fs.readFileSync(invoicePath).toString('base64');
return {
from: 'billing@yourcompany.com',
to: [recipient],
subject: '您的发票',
html: '<p>请查收附件中的发票。</p>',
attachments: [
{
filename: path.basename(invoicePath),
content,
content_type: 'application/pdf',
},
],
};
}
p-limit:并发批量发送
Promise.all 无限并发,p-limit 限制最大并发数,控制在 Rovela 速率限制以内。在 map 之前为每个请求生成幂等键,保证 sendWithRetry 内部的重试稳定。
// npm install p-limit
const pLimit = require('p-limit');
async function sendBatch(apiKey, payloads, concurrency = 5) {
const limit = pLimit(concurrency);
// 在启动任务前生成幂等键,每次重试复用同一个
const keys = payloads.map(() => crypto.randomUUID());
return Promise.all(
payloads.map((payload, i) =>
limit(() => sendWithRetry(apiKey, payload, 3, keys[i])),
),
);
}
批量接口(POST /emails/batch)支持单次最多 100 个收件人,适合所有收件人共享同一正文的场景。只有 payload 因人而异时,才用上面的并发方式。
axios
对于已经依赖 axios 的项目:
const axios = require('axios');
async function sendEmailAxios(apiKey, payload, idempotencyKey = '') {
const headers = { Authorization: `Bearer ${apiKey}` };
if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey;
try {
const { data } = await axios.post(ROVELA_URL, payload, { headers });
return data.id;
} catch (err) {
const { status, data } = err.response;
throw new RovelaError(status, data.name, data.message);
}
}
Webhook 签名验证
Rovela 用 HMAC-SHA256 对 Webhook 投递签名。签名在 x-resend-signature 头中,格式为 t=<时间戳>,v1=<十六进制签名>。签名原文是时间戳、一个英文句号、原始请求体拼接而成:<时间戳>.<原始请求体>。
Node 的 crypto.timingSafeEqual 做常量时间比较,防止时序攻击。
const crypto = require('crypto');
function verifyWebhook(rawBody, signatureHeader, secret) {
const parts = Object.fromEntries(
signatureHeader.split(',').map((seg) => seg.split('=')),
);
const { t: timestamp, v1 } = parts;
if (!timestamp || !v1) throw new Error('x-resend-signature 头格式错误');
// 签名原文 = "<时间戳>.<原始请求体>"
const signed = Buffer.from(`${timestamp}.${rawBody}`);
const expected = crypto.createHmac('sha256', secret).update(signed).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(v1);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
throw new Error('签名不匹配');
}
return JSON.parse(rawBody);
}
Express Webhook 路由
在 Express JSON 中间件处理之前读取原始请求体。只对这条路由挂载 express.raw。
app.post(
'/webhooks/rovela',
express.raw({ type: 'application/json' }),
(req, res) => {
const header = req.headers['x-resend-signature'] ?? '';
try {
const event = verifyWebhook(req.body, header, process.env.ROVELA_WEBHOOK_SECRET);
// queue.add(event)
res.sendStatus(204);
} catch {
res.sendStatus(401);
}
},
);
如果全局挂载了 express.json(),它会在这条路由之前消费 req.body,签名验证会失败。在 Webhook 路由之前挂载 express.raw,或者把这个路径从全局解析器中排除。
Next.js App Router Webhook 路由
在 app/api/webhooks/rovela/route.ts 里,用 request.text() 读取原始请求体,在任何 JSON 解析之前执行。
import crypto from 'crypto';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
const rawBody = await request.text();
const header = request.headers.get('x-resend-signature') ?? '';
try {
const event = verifyWebhook(rawBody, header, process.env.ROVELA_WEBHOOK_SECRET!);
// await queue.add(event);
return new NextResponse(null, { status: 204 });
} catch {
return new NextResponse('Unauthorized', { status: 401 });
}
}
Next.js Pages Router Webhook 路由
// pages/api/webhooks/rovela.ts
export const config = { api: { bodyParser: false } };
export default async function handler(req, res) {
if (req.method !== 'POST') return res.status(405).end();
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(chunk);
const rawBody = Buffer.concat(chunks).toString();
const header = (req.headers['x-resend-signature'] as string) ?? '';
try {
const event = verifyWebhook(rawBody, header, process.env.ROVELA_WEBHOOK_SECRET!);
// await queue.add(event);
res.status(204).end();
} catch {
res.status(401).end();
}
}
bodyParser: false 阻止 Next.js 在读取原始字节之前解析 body。
上线前检查清单
- API Key 放在环境变量里:从
ROVELA_API_KEY读取,不要硬编码,不要提交到版本控制。 - 幂等键在循环外生成:调用
crypto.randomUUID()一次在for循环之前,循环内复用同一个值。 - 只对 429 重试:API 在超限时返回
{"statusCode":429,"name":"Too Many Requests"};因为有幂等键防止重复,对 4xx 和 5xx 重试也是安全的。 - Webhook 用原始请求体:Express 用
express.raw,Next.js App Router 用request.text(),Pages Router 设置bodyParser: false,在任何框架变换之前读取。 - 使用常量时间比较:签名字节必须用
crypto.timingSafeEqual,不能用===比较十六进制字符串。 - p-limit 低于速率限制:并发数不要超过 Rovela 发送上限除以预期单封延迟的结果。
- Webhook 密钥放在环境变量里:存在
ROVELA_WEBHOOK_SECRET中,在控制台和部署里同步更换。