用 HTTP API 发送带 PDF 附件的发票邮件(2026)
用 Rovela POST /emails 的 attachments[](base64、filename、content_type)发送发票 PDF,Idempotency-Key 用发票号,并去掉 base64 换行——对齐线上文档的 curl 与 Node fetch。
为什么发票 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 打开」。
前置条件
- 在 Rovela 控制台完成发信域名验证。未验证域名的
from会直接400(快速开始)。 - 形如
re_…的 API Key。生产环境发票发送建议sending_access(Authentication)。 - 调用 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 域名未验证 |
401 | Key 缺失或无效 |
413 | 整个 JSON 超过 2 MB |
429 | 触发限流 |
日志里直接打印 API 返回的 message 字符串即可。
检查清单
- 域名已验证;
from使用该域名(或已验证子域)。 - PDF 为单行 base64;
content_type为application/pdf。 Idempotency-Key按发票号稳定。- HTML 与 text 正文含编号 / 金额 / 到期日。
- 请求低于 2 MB JSON 上限;否则改发链接。
- 每张带附件发票单独一次
POST /emails。