← 返回博客

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 签名验证。

更新于 2026年11月3日
Node.js 发送邮件(2026):https、fetch、axios、Express 与 Next.js 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 中,在控制台和部署里同步更换。