Documentation menu

添加到日历模块

一个一键"添加到我的日历"模块,也是解决跨时区邀请问题的实用方案。已发送的邮件没有 JavaScript 可以检测收件人自己所在的时区,因此该模块将活动的开始和结束时间编码为一个绝对时刻,而不是预先计算好的本地时间字符串。这样一来,每位与会者自己的日历应用都会自动以其本地时间正确显示。

工作原理

该模块和活动场地一样是纯内容:没有需要回答的内容,因此它从不会出现在响应仪表盘或 CSV 导出中。它渲染出的全部三个链接都走同一个通用点击追踪插槽——所有带链接的普通模块都在用的那个,而不是专属能力。

这些链接的渲染取决于是否有有效的 startsAt:没有填写开始时间的活动只会渲染标题和描述文字,不会出现指向空处的失效链接。如果设置了 startsAt 但没有设置 endsAt(或者设置的值早于 startsAt),模块会将时长默认设为开始之后一小时——只填写"何时开始"被当作最常见的情况处理,而每一个下游消费方(.ics 文件、Google Calendar、Outlook.com)都需要存在某个结束时刻。location 是一个纯手动输入的自由文本字段,需要你根据活动场地或会议链接模块自身的文字自行填写;在目前这一版中,这三个模块之间并未自动关联,因此场地一旦变更,需要你自己去更新它。

基于这些属性,会渲染出三个链接,它们出于点击追踪的目的共用同一个 blockId(热力图是按模块分桶,而不是按单个链接分桶,这与社交链接模块处理多个图标时使用的约定相同):

  • 下载 .ics 文件 — 一个签名链接,指向 GET /api/calendar,以流式方式返回一个手工构建的 RFC 5545(iCalendar)文件。它背后没有任何 npm 依赖——一个 .ics 文件就是纯文本,采用与倒计时 GIF 编码器相同的"纯手工从零构建"方式生成。文件中的 DTSTART/DTEND 以绝对 UTC 时刻的形式给出(末尾带 Z,无需 TZID/VTIMEZONE 组件)——这正是解决跨时区问题的关键所在,它并非在渲染时刻计算得出,而只是该模块的 startsAt/endsAt 从一开始就以纪元毫秒数存储所带来的自然结果。事件的 UID 是对其标题/描述/地点/开始/结束信息取的确定性 sha256 哈希值,因此重复下载同一个签名链接会更新同一条日历条目,而不会产生重复项。手动输入的文本会按照 RFC 5545 §3.3.11 的规定进行转义(反斜杠、逗号、分号、字面换行符),超长行会按规范要求的 75 字节上限进行折行,因此组织者填写的长描述不会生成一个技术上无效的文件。这个 URL 本身的签名方式与倒计时/公式/图表接口相同——整个事件负载都被序列化进查询字符串(GET 路径上不涉及 Firestore 读取),并通过 HMAC 输入中固定的 "calendar:" 前缀,与应用中其他每一族签名 URL 相互隔离,因此为该接口铸造的签名永远无法在其他接口上验证通过。
  • 添加到 Google Calendar — 一个在生成邮件时构建好的普通 calendar.google.com/calendar/render?action=TEMPLATE&... 深层链接,不涉及服务器往返请求。
  • 添加到 Outlook.com — 一个同样在生成邮件时构建好的普通 outlook.live.com/calendar/0/action/compose?... 深层链接。

标题、描述和地点在这三个链接构建之前,都会被防御性地截断到固定的最大长度(分别为 200 / 2,000 / 300 个字符),因此一个非常长的手动输入字段不会产生损坏的 .ics 链接,也不会让两个网页链接的查询字符串过长。

可配置字段:

  • 活动标题
  • 描述(可选)
  • 开始时间 — 任何链接要渲染出来都必须填写。
  • 结束时间(默认为开始时间之后一小时)
  • 地点(可选,自由文本)
  • 卡片的背景色内边距边框圆角半径
  • 活动标题的颜色/字号,以及三个链接共用的颜色/背景色覆盖项。

示例

Subject

Save the date: our fall meetup

每个"活动"用途的项目一开始就已经配好了一个添加到日历模块,与会议链接、活动场地和 RSVP 并列在一起——关于这个模块所属的完整"邀请到提醒"模式,参见活动与网络研讨会。体育俱乐部可以周复一周地为训练和比赛时间复用同一个模块,这样每个家庭的日历都能正确反映日程安排,无论他们身处哪个时区旅行——参见体育团队与俱乐部。网络研讨会系列可以把它放进报名成功后立即发送的确认邮件中,让收件人在报名的同一时刻就把活动加入日历,而不必依赖他们事后自己记得。

静态回退效果是什么样子

<div style="margin:12px 0;background-color:transparent">
  <p style="margin:0 0 8px;font-weight:600;font-size:16px;color:#111827">Q3 Product Launch</p>
  <a href="https://mailinapp.com/api/calendar?d=eyJ0aXRsZSI6...&s=abc123..."
     style="display:inline-block;padding:10px 20px;background-color:#4f46e5;color:#ffffff;
            border-radius:6px;text-decoration:none;font-size:14px;font-weight:600;margin:0 8px 8px 0">
    Download .ics
  </a>
  <a href="https://calendar.google.com/calendar/render?action=TEMPLATE&text=Q3+Product+Launch&dates=20260901T170000Z%2F20260901T180000Z"
     style="display:inline-block;padding:10px 20px;background-color:#4f46e5;color:#ffffff;
            border-radius:6px;text-decoration:none;font-size:14px;font-weight:600;margin:0 8px 8px 0">
    Add to Google Calendar
  </a>
</div>

这就是整个已发送邮件中的模块:三个基于同一份活动信息构建出的普通链接(为简洁起见,这里省略了第三个 Outlook.com 链接),不涉及任何图片或需要限制的内容。

相关内容