← 返回博客

定时发送:让邮件在用户时区的正确时刻到达

Rovela 的 scheduled_at 字段让邮件在指定时间才投递,并支持改时间和取消。本文演示排期、改时间、取消的完整流程,以及按用户时区计算发送时间的实战写法。

更新于 2026年10月13日
定时发送:让邮件在用户时区的正确时刻到达

很多邮件不该在代码执行的那一刻发出去:试用到期前三天的提醒、按用户时区早上九点送达的周报、活动开始前一小时的通知。常见的做法是自己写一个定时任务,扫表、到点调用发信接口。这样能用,但要多维护一张表、一个 cron 和一套失败重试。

Rovela 的发信接口自带定时发送。请求里带上 scheduled_at,邮件会先进入「已排期」状态,到点才投递。到点之前可以改时间,也可以取消。

排一封定时邮件

scheduled_at 是 ISO 8601 时间,建议带上时区偏移或者直接用 UTC:

curl https://api.rovela.dev/emails \
  -H "Authorization: Bearer $ROVELA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "Acme <hello@yourdomain.com>",
    "to": "user@example.com",
    "subject": "你的试用还剩 3 天",
    "html": "<p>试用将在 3 天后结束,现在升级可以保留所有数据。</p>",
    "scheduled_at": "2026-10-20T01:00:00Z"
  }'

返回的邮件 ID 和立即发送时一样。用 GET /emails/:id 查询,状态是 scheduled。如果配置了 Webhook,会收到一个 email.scheduled 事件,到点发出后再收到 email.sent、email.delivered。

批量接口 POST /emails/batch 里的每一封都可以单独带 scheduled_at,一次请求就能排好一批不同时间的邮件。

改时间

用户改了预约、活动推迟了,用 PATCH /emails/:id 更新时间:

curl -X PATCH https://api.rovela.dev/emails/$EMAIL_ID \
  -H "Authorization: Bearer $ROVELA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "scheduled_at": "2026-10-21T01:00:00Z" }'

只有状态还是 scheduled 的邮件能改。已经发出的邮件会返回错误「email not found or is not scheduled」。

取消

用户在提醒发出前就已经升级了,这封信就不该再发:

curl -X POST https://api.rovela.dev/emails/$EMAIL_ID/cancel \
  -H "Authorization: Bearer $ROVELA_API_KEY"

成功返回 {"object":"email","id":"...","cancelled":true}。scheduled 和还在队列里没发出的 queued 邮件都能取消,已经交给收件服务器的取消不了。

实战:按用户时区早上九点送达

服务端存的是 UTC,用户感受的是本地时间。算发送时间时把时区转换做对:

// 用户所在时区的「明天 09:00」,转成 UTC 的 ISO 字符串
function nextNineAm(timeZone) {
  const now = new Date()
  const local = new Date(now.toLocaleString('en-US', { timeZone }))
  const offset = local.getTime() - now.getTime()
  const target = new Date(local)
  target.setDate(target.getDate() + 1)
  target.setHours(9, 0, 0, 0)
  return new Date(target.getTime() - offset).toISOString()
}

await fetch('https://api.rovela.dev/emails', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.ROVELA_API_KEY}`,
    'Content-Type': 'application/json',
    'Idempotency-Key': `weekly-report-${user.id}-${weekId}`,
  },
  body: JSON.stringify({
    from: 'Acme <reports@yourdomain.com>',
    to: user.email,
    subject: '你的本周报告',
    html: renderReport(user),
    scheduled_at: nextNineAm(user.timeZone),
  }),
})

几个要点:

  • 把邮件 ID 存下来。取消和改时间都要用到它。比如在订阅表里加一列 reminder_email_id。
  • 配合幂等键。定时任务本身也可能重跑,带上 weekly-report-<用户>-<周> 这样的键,重跑时不会排出两封。
  • 内容在排期时就固定了。邮件正文在请求那一刻生成,到点时不会重新渲染。像「剩余 3 天」这种会变的数字,要按发送时刻来算,或者等临近了再排。

什么时候不该用定时发送

排期适合「时间点确定、内容可以提前生成」的邮件。如果内容依赖发送那一刻的状态,比如库存、余额,还是在到点时由你的系统现查现发更稳妥。

接口细节见 发送邮件 API 文档,批量排期的写法见 批量与定时发送示例。