← 返回博客

用 HTTP API 发送带 PDF 附件的发票邮件(2026)

用 Rovela POST /emails 的 attachments[](base64、filename、content_type)发送发票 PDF,Idempotency-Key 用发票号,并去掉 base64 换行——对齐线上文档的 curl 与 Node fetch。

更新于 2026年11月4日
用 HTTP API 发送带 PDF 附件的发票邮件(2026)

为什么发票 PDF 要单独设计发送路径

财务系统生成 PDF 后,通常要在发票落账的同一分钟把文件送到客户邮箱。Rovela 的路径是一次 HTTP 调用:POST https://api.rovela.dev/emails,在 attachments[] 里带上 filename、base64 的 content、content_type,并用发票号作为 Idempotency-Key,让重试任务不会重复投递。

本文对齐线上文档 带附件的发票邮件 与 发送邮件 中的 attachments 字段,示例使用文档同款的 curl 与 Node fetch。本流程不需要 rovela.emails.send 这类 SDK 封装。

它不是总览文 邮件附件指南(格式、各家上限、链接 vs 附件),也不是 欢迎邮件最佳实践。相关事务邮件长尾:Next.js 魔法登录、Express OTP、Bun 事务邮件。

你负责什么,Rovela 负责什么

你的计费 / ERP 任务负责:

  • 生成或定位发票 PDF(本地路径或先放到对象存储再读入)
  • 发票号、金额、到期日与客户账单邮箱
  • 选定 Idempotency-Key(文档用发票号,例如 invoice-1042)
  • 把字节编成单行 base64(去掉换行),并设置 content_type: "application/pdf"
  • 在 HTML / 纯文本正文里写清编号、金额、到期日(手机通知往往打不开 PDF)
  • 每张带附件的发票优先用一次 POST /emails,而不是 /emails/batch

Rovela 负责:

  • 接收 JSON、解码附件、组装 MIME multipart、DKIM 签名、入队、重试与事件记录
  • 执行文档中的请求体大小上限(见下)

返回 200 与 {"object":"email","id":"..."} 表示已接受并入队,不等于「已在 Outlook 打开」。

前置条件

  1. 在 Rovela 控制台完成发信域名验证。未验证域名的 from 会直接 400(快速开始)。
  2. 形如 re_… 的 API Key。生产环境发票发送建议 sending_access(Authentication)。
  3. 调用 API 的进程能读到发票 PDF(下文示例为本地路径)。

大小限制(来自发票示例文档)

线上发票示例写明:整个 JSON 请求上限为 2 MB。Base64 大约多占三分之一体积,附件合计大约只剩 1.5 MB。超限会在发送前返回 413。

普通发票 / 回执通常够用。更大的扫描件或报表,请放到自有存储并在正文放链接——也更友好,因为大附件会被复制进每封收件箱,部分企业网关还会剥离。更广的格式与安全讨论见 邮件附件指南 与 Invoice with an attachment。

curl:先去掉换行,再 POST

GNU base64 默认按 76 字符换行。Rovela 的解码器拒绝 base64 里的换行,Linux / macOS 都可以这样剥掉:

PDF=$(base64 < invoice-1042.pdf | tr -d '\n')

curl -X POST https://api.rovela.dev/emails \
  -H "Authorization: Bearer re_your_sending_key" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: invoice-1042" \
  -d @- <<EOF
{
  "from": "Acme Billing <billing@mail.acme.com>",
  "to": "accounts@customer.com",
  "subject": "Invoice INV-1042 from Acme",
  "html": "<p>Hi,</p><p>Your invoice for September is attached. Total due: <strong>\$480.00</strong> by October 15.</p>",
  "text": "Hi,\n\nYour invoice for September is attached. Total due: \$480.00 by October 15.",
  "attachments": [
    {
      "filename": "INV-1042.pdf",
      "content": "$PDF",
      "content_type": "application/pdf"
    }
  ]
}
EOF

为什么必须写 content_type

不写 content_type 时,该 part 会按 application/octet-stream 发出,很多客户端只会显示「未知文件」而不是 PDF 预览。发票请固定 "content_type": "application/pdf"。

为什么要用 Idempotency-Key

队列、定时重试、Webhook 驱动的任务,一旦首次响应超时就会再发一次。带上 Idempotency-Key: invoice-1042(最长 256 字符;24 小时内重试返回原响应),客户只会收到一封发票邮件。见 发送邮件的幂等说明。

Node.js:fetch + 用发票号做幂等键

import { readFile } from "node:fs/promises";

export async function sendInvoice(invoice, pdfPath) {
  const pdf = await readFile(pdfPath);

  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": "invoice-" + invoice.number,
    },
    body: JSON.stringify({
      from: "Acme Billing <billing@mail.acme.com>",
      to: invoice.billingEmail,
      reply_to: "billing@acme.com",
      subject: "Invoice " + invoice.number + " from Acme",
      html: "<p>Your invoice " + invoice.number + " is attached.</p>",
      text: "Your invoice " + invoice.number + " is attached.",
      attachments: [
        {
          filename: invoice.number + ".pdf",
          content: pdf.toString("base64"),
          content_type: "application/pdf",
        },
      ],
    }),
  });
  if (!res.ok) throw new Error("invoice email failed: " + res.status);
  return (await res.json()).id;
}

Buffer#toString("base64") 不会插入换行,可避开 curl 默认折行问题。把 ROVELA_API_KEY 放在服务端环境变量,切勿打进浏览器包。

reply_to 指向真人会看的邮箱,同时 from 仍使用已验证的发信子域(文档)。

发票邮件的好习惯

  • 正文写清要点 — 编号、金额、到期日。预览窗和锁屏通知打不开 PDF。
  • 干净文件名 — 不要空格或个人敏感信息;名字会出现在每个客户端与转发链(INV-1042.pdf,而不是 张三 发票 最终版 (2).pdf)。
  • 一单一请求 — 带 PDF 的发票不要走 /emails/batch:批处理共用同一个 2 MB 请求体,一条非法会拖垮整批。见 API 参考中的批量说明与发票示例的 Good habits。
  • 异步确认投递 — 调试可用 GET /emails/:id / events;生产用 Webhook(快速开始)。

你会真正碰到的错误

来自 Send Email 与发票示例:

状态码常见原因
400缺必填字段、base64 无法解码(常是残留换行)、from 域名未验证
401Key 缺失或无效
413整个 JSON 超过 2 MB
429触发限流

日志里直接打印 API 返回的 message 字符串即可。

检查清单

  1. 域名已验证;from 使用该域名(或已验证子域)。
  2. PDF 为单行 base64;content_type 为 application/pdf。
  3. Idempotency-Key 按发票号稳定。
  4. HTML 与 text 正文含编号 / 金额 / 到期日。
  5. 请求低于 2 MB JSON 上限;否则改发链接。
  6. 每张带附件发票单独一次 POST /emails。

相关链接