← 返回博客

用 Rovela 附件 API 发送日历邀请(.ics)邮件(2026)

生成 VEVENT .ics,以 text/calendar 经 base64 挂到 POST /emails,并用 Idempotency-Key = cal-invite-{bookingId}。会议/预约视角——区别于发票 PDF 与附件总览。Rovela 限制仅对齐文档。

更新于 2026年11月4日
用 Rovela 附件 API 发送日历邀请(.ics)邮件(2026)

为什么预约类产品发 .ics 而不是 PDF

用户约了产品演示、咨询或课程席位时,需要的是把事件写进自己的日历,而不是一份静态 PDF。开放格式是 iCalendar(RFC 5545):一小段 text/calendar,常见文件名 invite.ics,内含 VEVENT(UID、DTSTART、DTEND、SUMMARY、ORGANIZER、ATTENDEE)。

Rovela 不会替你生成 ICS。你的预约服务负责拼出 .ics 字节,再用与其它附件相同的方式发出:POST https://api.rovela.dev/emails,attachments[] = { filename, content (base64), content_type },并带上 Idempotency-Key,避免 webhook 重试时重复邀请。

本文对齐线上文档:带附件的发票邮件 与 发送邮件 的 attachments 字段。它不是用 HTTP API 发送带 PDF 附件的发票邮件(财务 PDF),也不是邮件附件指南(格式总览)。相关事务邮:Stripe webhook 收据、Shopify 订单 webhook、团队邀请链接邮件。

你负责什么,Rovela 负责什么

你的排期 / 预约任务负责:

  • 生成合法 ICS(BEGIN:VCALENDAR … END:VCALENDAR),每个预约使用稳定的 UID
  • 把标题、时间、时区、入会链接、地点同时写进 ICS 以及 HTML/纯文本正文(手机预览很少打开附件)
  • 将 .ics 编成单行 base64,并设置 content_type: "text/calendar"
  • 用预约 ID 作为 Idempotency-Key(例如 cal-invite-bk_8f3a2c)
  • 更倾向对每位受邀者发一次 POST /emails(或按产品设计使用多个 to),而不是把大包 ICS 塞进 /emails/batch

Rovela 负责:

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

返回 200 且带 {"object":"email","id":"..."} 只表示已接受并入队,不代表「已写入 Google 日历」。

前置条件

  1. 在 Rovela 控制台完成发信域名验证。未验证的 from 域名会返回 400(快速开始)。
  2. 形如 re_… 的 API Key。生产邀请发送建议用 sending_access(Authentication)。
  3. 可在 worker / webhook 重试时复用的预约 ID,用作幂等键。

大小限制(来自发票附件文档)

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

单个 .ics 通常只有几 KB,远低于上限。若还要附带演示文稿或录像,请记住同一 2 MB JSON 预算,或把大文件放到自有存储并在正文给链接。详见带附件的发票邮件。

拼一份最小的 VEVENT

在服务端生成 ICS。让 UID 对同一预约保持稳定,便于日后改期时客户端更新或取消。下面时间用 UTC(Z);若要用本地浮动时间,应自行加 VTIMEZONE——Rovela 只负责传输你附上的字节,不会改写 ICS。

function buildInviteIcs({ uid, summary, description, startUtc, endUtc, organizerEmail, attendeeEmail, location, url }) {
  const lines = [
    "BEGIN:VCALENDAR",
    "VERSION:2.0",
    "PRODID:-//Acme Booking//EN",
    "CALSCALE:GREGORIAN",
    "METHOD:REQUEST",
    "BEGIN:VEVENT",
    `UID:${uid}`,
    `DTSTAMP:${startUtc}`,
    `DTSTART:${startUtc}`,
    `DTEND:${endUtc}`,
    `SUMMARY:${summary}`,
    `DESCRIPTION:${description}`,
    `ORGANIZER:mailto:${organizerEmail}`,
    `ATTENDEE:mailto:${attendeeEmail}`,
    location ? `LOCATION:${location}` : null,
    url ? `URL:${url}` : null,
    "STATUS:CONFIRMED",
    "SEQUENCE:0",
    "END:VEVENT",
    "END:VCALENDAR",
  ].filter(Boolean);

  return lines.join("\r\n") + "\r\n";
}

示例值:

  • uid:bk_8f3a2c@mail.acme.com
  • startUtc / endUtc:20261105T150000Z / 20261105T153000Z
  • summary:Acme product demo
  • url:Meet / Zoom / 应用内会议室链接

若标题可能含逗号、分号或换行,按 RFC 5545 自行转义。转义是你的责任;Rovela 不会改写 ICS 内容。

curl:去掉换行再 POST

GNU base64 默认每 76 字符换行。Rovela 解码器拒绝 base64 中的换行,因此按发票文档同样方式剥离:

ICS=$(base64 < invite.ics | 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: cal-invite-bk_8f3a2c" \
  -d @- <<EOF
{
  "from": "Acme Scheduling <schedules@mail.acme.com>",
  "to": "alex@customer.com",
  "reply_to": "schedules@acme.com",
  "subject": "Calendar invite: Acme product demo — Nov 5",
  "html": "<p>Hi Alex,</p><p>Your <strong>Acme product demo</strong> is confirmed for <strong>Nov 5, 2026 · 15:00–15:30 UTC</strong>.</p><p>Join: <a href=\"https://meet.acme.com/bk_8f3a2c\">https://meet.acme.com/bk_8f3a2c</a></p><p>The attached <code>invite.ics</code> adds the event to your calendar.</p>",
  "text": "Hi Alex,\n\nYour Acme product demo is confirmed for Nov 5, 2026 · 15:00–15:30 UTC.\nJoin: https://meet.acme.com/bk_8f3a2c\n\nThe attached invite.ics adds the event to your calendar.\n",
  "attachments": [
    {
      "filename": "invite.ics",
      "content": "$ICS",
      "content_type": "text/calendar"
    }
  ]
}
EOF

为什么要 content_type: "text/calendar"

不设 content_type 时,部件会按 application/octet-stream 发出。许多客户端会显示「未知文件」,而不是「添加到日历」。请始终设置 "content_type": "text/calendar"——与文档对发票使用 application/pdf 的要求相同。

为什么要 Idempotency-Key

预约 webhook、队列 worker 与「重发邀请」按钮会在超时后再次 POST。使用 Idempotency-Key: cal-invite-bk_8f3a2c(最长 256 字符;24 小时内重试返回原响应),客人只会收到一封邀请。见发送邮件的幂等。

Node.js:生成 ICS → base64 → fetch

export async function sendCalendarInvite({ booking, attendeeEmail }) {
  const ics = buildInviteIcs({
    uid: `${booking.id}@mail.acme.com`,
    summary: booking.title,
    description: `Join: ${booking.joinUrl}`,
    startUtc: booking.startUtc,
    endUtc: booking.endUtc,
    organizerEmail: "schedules@acme.com",
    attendeeEmail,
    location: booking.location || booking.joinUrl,
    url: booking.joinUrl,
  });

  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": "cal-invite-" + booking.id,
    },
    body: JSON.stringify({
      from: "Acme Scheduling <schedules@mail.acme.com>",
      to: attendeeEmail,
      reply_to: "schedules@acme.com",
      subject: "Calendar invite: " + booking.title,
      html:
        "<p>Your <strong>" +
        booking.title +
        "</strong> is confirmed.</p><p>Join: <a href=\"" +
        booking.joinUrl +
        "\">" +
        booking.joinUrl +
        "</a></p><p>Open the attached invite.ics to add it to your calendar.</p>",
      text:
        "Your " +
        booking.title +
        " is confirmed.\nJoin: " +
        booking.joinUrl +
        "\n\nOpen the attached invite.ics to add it to your calendar.\n",
      attachments: [
        {
          filename: "invite.ics",
          content: Buffer.from(ics, "utf8").toString("base64"),
          content_type: "text/calendar",
        },
      ],
    }),
  });

  if (!res.ok) throw new Error("calendar invite email failed: " + res.status);
  return (await res.json()).id;
}

reply_to 把回复导向真人可读的邮箱,同时 from 仍使用已验证发信子域——与发票示例同一模式。

会议邀请的好习惯

  • 正文优先,ICS 其次。 把时间、时区、入会链接写进 HTML 与 text。预览窗和锁屏不会打开附件。
  • 文件名不要空格或个人数据。 用 invite.ics 或 demo-bk_8f3a2c.ics,避免 Alex Smith Meeting.ics。
  • 稳定 UID + SEQUENCE。 改期时递增 SEQUENCE,重建 ICS,并用新的幂等键重发(例如 cal-invite-bk_8f3a2c-seq-1),以免被当成原发送的重复请求吞掉。
  • 需要个性化时一人一封。 单封邮件 to/cc/bcc 合计最多 50 人(发送邮件),但日历客户端常期望单一 ATTENDEE。演示与 1:1 预约建议每位客人一次 POST。
  • 带附件的邀请慎用 /emails/batch。 发票文档指出 batch 共用同一 2 MB 体,一条非法条目可能整批拒绝;ICS 包请同样谨慎。见批量与定时发送。

你会真正碰到的错误

来自发送邮件与发票附件示例:

  • 400 — 缺字段、base64 无法解码(常见是残留换行)、或 from 域名不属于你的组织
  • 401 — Key 无效/缺失,或用 sending_access 去读/取消接口
  • 413 — JSON 体超过 2 MB 请求上限
  • 429 — 限流;退避后用同一个 Idempotency-Key 重试

本文不是什么

  • Rovela 不托管日历、忙闲或 RSVP 回调。Outlook/Google 里的接受/拒绝是客户端 ICS 行为;若要 RSVP webhook,在自己的产品里接。
  • 不要把 method=REQUEST 之类 MIME 参数、或 Content-Disposition: inline 编造成 Rovela API 字段——文档中的附件形状只有 filename、content、content_type。若客户端需要 METHOD,把 METHOD:REQUEST 写进 ICS 内部,如上所示。
  • 大附件或非日历文件,请从邮件附件指南与带附件的发票邮件入手。

下一步

  1. 验证发信域名并创建 sending_access Key。
  2. 为一次真实预约生成 .ics,用 content_type: "text/calendar" 发出。
  3. 确认返回 id,再在 Gmail / Apple Mail / Outlook 中打开并添加事件。
  4. 把 Idempotency-Key = cal-invite-{bookingId} 与预约行一起存,保证重试安全。

文档:发送邮件 · 带附件的发票邮件 · Authentication