AWS EventBridge Scheduler 事务邮件 + Rovela(2026):Lambda cron 与按窗口的幂等键
用 Amazon EventBridge Scheduler(cron/rate/at)调用 Lambda,从环境变量或 Secrets Manager 读取 ROVELA_API_KEY,并用对齐调度窗口的 Idempotency-Key 调用 POST /emails。与 Netlify Scheduled Functions、Fly Scheduled Machines、Render/Railway/Vercel cron、GitHub Actions 区分。
EventBridge Scheduler 负责 cron — Rovela 负责投递
Amazon EventBridge Scheduler 可按 cron、rate 或一次性 at(...) 表达式调用目标——包括 AWS Lambda。这种形态适合摘要、运维报告,以及正文依赖 触发时刻实时状态(注册数、失败任务、未付发票)的事务邮件——而不是你能提前几小时用 Rovela scheduled_at 冻住的内容。
当 Lambda 调用 POST https://api.rovela.dev/emails 时,Rovela 负责入队、DKIM 签名与投递(Send Email API、Quick Start、Authentication)。AWS 负责调度、重试与密钥注入(Lambda 环境变量或 Secrets Manager)。你的函数负责聚合数据,以及按调度窗口稳定的 Idempotency-Key。Rovela 只负责投递。
本指南会接好 EventBridge Scheduler → Lambda → Rovela,让它:
- 从 Lambda 环境变量和/或 Secrets Manager 读取
ROVELA_API_KEY、MAIL_FROM与收件人(绝不写死在仓库) - 按当前调度窗口聚合摘要 / 报告
- 用对齐该窗口的
Idempotency-Key调用 Rovela 发送 - 在 Scheduler 重试与 Lambda 异步重试下仍不重复发信
Rovela 不会创建 EventBridge 调度、IAM 角色或 Lambda。 Scheduler 管 cron → 调用;你的 handler 管查询 → 映射字段 → POST /emails;Rovela 管投递。
与下列路径区分:
- Netlify Scheduled Functions 事务邮件 — Netlify UTC cron + 30 秒函数限制,不是 AWS Scheduler
- Fly Scheduled Machines 事务邮件 — 粗粒度
hourly/daily/weekly/monthly,不是六段 AWS cron - Render Cron 事务邮件 — 必须退出的 Cron Job 进程
- Railway Cron 事务邮件 — Railway cron 服务;重叠触发会被跳过
- Vercel Cron 摘要邮件 — 对生产路由发 HTTP
GET+CRON_SECRET - GitHub Actions 事务邮件 — 工作流 /
run_id在 CI 事件上通知 - 定时发送邮件 — 正文已知时用 Rovela
scheduled_at
也不同于较旧的 EventBridge Rules 定时规则(经典 “CloudWatch Events / EventBridge scheduled rule” 路径)。那些规则在许多账户上仍可用,但调度表达式只按 UTC 求值,且没有 Scheduler 的 IANA 时区、灵活时间窗与按调度的重试 / DLQ。新邮件任务请优先用 EventBridge Scheduler;下面的 Lambda → Rovela 约定两种触发方式通用。
Scheduler 做什么,Rovela 发什么
| 关注点 | 归属 |
|---|---|
cron(...) / rate(...) / at(...) 表达式 | EventBridge Scheduler(schedule types) |
IANA 时区(ScheduleExpressionTimezone) | EventBridge Scheduler |
| 灵活时间窗 / 重试策略 / DLQ | EventBridge Scheduler |
可 lambda:InvokeFunction 的 IAM 角色 | 你的调度执行角色 |
ROVELA_API_KEY 的环境变量或 Secrets Manager | Lambda + AWS secrets |
| 按窗口查询 DB / API | 你的 Lambda |
| 渲染 HTML + text | 你的 Lambda |
POST /emails、DKIM、队列、退信 | Rovela |
Idempotency-Key = aws-{kind}-{yyyy-mm-dd}(或小时 / 周) | 你的 handler |
Rovela 返回 200 表示 已接受并入队,不等于「已经进收件箱」(Quick Start)。
决定邮件任务形态的 AWS 约束
结合 AWS 文档,发信调度请把下面当作硬约束:
- 三种表达式。 周期性:
rate(value unit),单位为minutes|hours|days;或六段cron(minutes hours day-of-month month day-of-week year)。一次性:at(yyyy-mm-ddThh:mm:ss)(schedule types、CreateSchedule)。 - 日与星期字段。 两个 day 字段不能同时用
*;不约束的那一侧填?。 - 约 60 秒精度。 灵活窗为
OFF时,Scheduler 在目标分钟内调用(schedule types)。 - Scheduler 支持 IANA 时区。 设置
ScheduleExpressionTimezone(如Europe/London、America/New_York),让「09:00」在夏令时切换后仍对齐本地。春季拨快会跳过不存在的本地时刻;秋季拨慢对重复小时只调用 一次(DST 说明)。 - 灵活时间窗。
FLEXIBLE+MaximumWindowInMinutes会把调用打散到名义时间之后——适合大规模分散,不适合「必须卡在 09:00 的摘要」。要准时就用OFF(flexible windows)。 - 异步调用 + 重试。 Scheduler 异步调用 Lambda。Scheduler 可按重试策略(可选 DLQ,事件最长保留 24 小时,最多 185 次)重试失败目标;Lambda 异步错误路径默认再试 两次(Lambda retries、Scheduler getting started)。按 至少一次 进入 handler 来设计——这正是 Rovela
Idempotency-Key必需的原因。 - 出站 SMTP 不合适。 Lambda(以及许多 serverless 主机)会封锁或刁难 25 端口;请用 443 上的 HTTPS
POST /emails(Email API vs SMTP)。 - 密钥只留在服务端。 把 Rovela
sending_access密钥放进 Lambda 环境变量(控制台 / SAM / Terraform)或 Secrets Manager;绝不要塞进浏览器包(Authentication、Secrets Manager + Lambda)。
何时用 Scheduler + POST /emails(而不是 scheduled_at)
在这些情况下用 EventBridge Scheduler → Lambda → 即时发送:
- HTML 必须包含触发时刻的 新鲜 指标
- 你已在 AWS 跑计算,希望 IAM、CloudWatch Logs 与调度同属一个账户
- 需要真正的 crontab(
Europe/London工作日早上、每月最后一个周五、每 15 分钟),而不是主机粗粒度间隔桶 - 一封(或少量)事务邮件发给运行时查出的运维 / 创始人 / 客户地址
正文已经正确、只需要「稍后再投」且不必跑任务时,用 Rovela scheduled_at(定时发送邮件)。Scheduler 与 scheduled_at 解决不同问题;本文是 AWS 路径。
前置条件
- Rovela 中已 验证发信域名。未验证的
from会400(Quick Start)。 - 形如
re_…的 API 密钥。Lambda 上优先用sending_access(Authentication)。 - 一台能出站 HTTPS、超时足够覆盖查询 + 一次 Rovela 往返的 Lambda(Node.js、Python 或容器镜像)。
- 密钥:
ROVELA_API_KEY的 Lambda 环境变量和/或 Secrets Manager,以及MAIL_FROM、DIGEST_TO(或在代码里查收件人)。 - Scheduler 可承担的 IAM 角色,具备对该函数的
lambda:InvokeFunction(Lambda + Scheduler)。 - 调度表达式——例如时区
Europe/London下的cron(0 9 ? * MON-FRI *),或rate(1 hour)。
在 Lambda 上放密钥
最简单路径——控制台 / AWS CLI / SAM / Terraform 设置的 Lambda 环境变量(静态加密)。不要把值提交进 Git:
aws lambda update-function-configuration \
--function-name daily-digest-email \
--environment "Variables={ROVELA_API_KEY=re_your_sending_access_key,MAIL_FROM=Acme Reports <reports@mail.example.com>,DIGEST_TO=founders@example.com,DIGEST_KIND=daily}"
若要轮换密钥而不改代码,把 Rovela 密钥放进 Secrets Manager,然后:
- 在冷启动时用
GetSecretValue解析(函数角色需secretsmanager:GetSecretValue),或 - 使用 AWS Parameters and Secrets Lambda extension,经本地 HTTP 端点拉取
生产发信只用 sending_access,不要用 full_access(Authentication)。绝不要 console.log API 密钥。轮换顺序:创建 → 更新 secret / env → 下一次成功调用 → 吊销旧密钥。
创建调度
控制台(快路径)
- 打开 Lambda 函数 → Add trigger → Scheduler(文档)。
- 选择周期性调度、cron 或 rate、时区,可选 JSON payload。
- 让 Scheduler 创建执行角色,或挂上你已有的角色。
AWS CLI(本地时区 cron)
aws scheduler create-schedule \
--name daily-digest-london \
--schedule-expression "cron(0 9 ? * MON-FRI *)" \
--schedule-expression-timezone "Europe/London" \
--flexible-time-window '{"Mode":"OFF"}' \
--target '{
"Arn": "arn:aws:lambda:eu-west-2:111122223333:function:daily-digest-email",
"RoleArn": "arn:aws:iam::111122223333:role/scheduler-invoke-digest",
"Input": "{\"kind\":\"daily\",\"market\":\"uk\"}"
}'
基于 AWS 文档的要点:
- 六段 cron:分、时、日、月、星期、年——此处 day-of-month 用
?,因为 day-of-week 是MON-FRI(cron 语法)。 --flexible-time-window '{"Mode":"OFF"}'让调用靠近本地 09:00,而不是摊到一个窗口里。- 若要 Scheduler 级重试(与 Lambda 异步重试分开),在控制台或 API 配置 retry policy 与可选 DLQ(getting started)。
- 在函数的 CloudWatch Logs 中确认触发。
按小时间隔的 rate 示例:
aws scheduler create-schedule \
--name hourly-ops-snapshot \
--schedule-expression "rate(1 hour)" \
--flexible-time-window '{"Mode":"OFF"}' \
--target '{
"Arn": "arn:aws:lambda:eu-west-2:111122223333:function:daily-digest-email",
"RoleArn": "arn:aws:iam::111122223333:role/scheduler-invoke-digest",
"Input": "{\"kind\":\"hourly\"}"
}'
最小 Node.js Lambda handler
// index.mjs — Node.js 20+
const ROVELA_URL = 'https://api.rovela.dev/emails'
function requireEnv(name) {
const v = process.env[name]
if (!v) throw new Error(`missing env ${name}`)
return v
}
function utcDayKey(d = new Date()) {
return d.toISOString().slice(0, 10) // YYYY-MM-DD
}
function utcHourKey(d = new Date()) {
return d.toISOString().slice(0, 13) // YYYY-MM-DDTHH
}
async function buildDailyDigest(day) {
// 换成对 RDS / DynamoDB / API / CloudWatch Metrics 的真实查询
const stats = {
signups: 12,
paid: 3,
incidents: 1,
failedJobs: 0,
}
const subject = `Acme daily digest — ${day}`
const text = [
`Signups: ${stats.signups}`,
`Paid: ${stats.paid}`,
`Open incidents: ${stats.incidents}`,
`Failed jobs: ${stats.failedJobs}`,
].join('\n')
const html = `
<h1>Daily digest — ${day}</h1>
<ul>
<li>Signups: <b>${stats.signups}</b></li>
<li>Paid: <b>${stats.paid}</b></li>
<li>Open incidents: <b>${stats.incidents}</b></li>
<li>Failed jobs: <b>${stats.failedJobs}</b></li>
</ul>
<p><a href="https://app.example.com/admin">Open admin</a></p>
`
return { subject, html, text, stats }
}
async function sendTransactional({ to, subject, html, text, idempotencyKey, tags }) {
const res = await fetch(ROVELA_URL, {
method: 'POST',
headers: {
Authorization: `Bearer ${requireEnv('ROVELA_API_KEY')}`,
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify({
from: requireEnv('MAIL_FROM'),
to,
subject,
html,
text,
tags,
}),
})
const body = await res.json().catch(() => ({}))
if (!res.ok) {
throw new Error(`Rovela ${res.status}: ${body.message ?? 'send failed'}`)
}
return body // { object: 'email', id }
}
export async function handler(event) {
const kind = event?.kind || process.env.DIGEST_KIND || 'daily'
const day = utcDayKey()
const digest = await buildDailyDigest(day)
const to = requireEnv('DIGEST_TO').split(',').map((s) => s.trim())
// 窗口键 — 不要用 Date.now() / request id — 这样 Scheduler + Lambda 重试仍是同一封逻辑邮件
const idempotencyKey =
kind === 'hourly'
? `aws-hourly-ops:${utcHourKey()}`
: `aws-daily-digest:${day}`
const result = await sendTransactional({
to,
subject: digest.subject,
html: digest.html,
text: digest.text,
idempotencyKey,
tags: { kind: `aws-${kind}-digest`, day, platform: 'aws-eventbridge-scheduler' },
})
console.log(JSON.stringify({ ok: true, day, emailId: result.id, stats: digest.stats }))
return { ok: true, emailId: result.id }
}
Rovela 的 Idempotency-Key(最长 256 字符)让 同一 发送在 24 小时内重试时返回原结果而不再发一封(Send Email API)。在 AWS 上尤其重要,因为:
- Scheduler 可能按重试策略再次调用
- Lambda 异步调用默认对失败再试两次
- 灵活时间窗或控制台手动 Invoke 可能再次进入同一日历窗口
- 请按 日历窗口(以及 fan-out 时的收件人)做键,而不是按 Lambda request id
推荐键:
| 键 | 适用 |
|---|---|
aws-daily-digest:{yyyy-mm-dd} | 每个 UTC 日一封运维摘要(每日 cron(0 9 …)) |
aws-hourly-ops:{yyyy-mm-dd}T{HH} | 每小时快照(rate(1 hour) 或小时 cron) |
aws-weekly-report:{yyyy}-W{ww} | 每个 ISO 周一封创始人报告 |
aws-customer-digest:{userId}:{yyyy-mm-dd} | 按用户摘要(每个收件人窗口一把键) |
若要发 大量个性化摘要,按收件人循环并使用不同键(或对最多 100 封非模板消息用 POST /emails/batch——batch 按设计拒绝 template / topic_id(Send Email API))。控制并发;遇到 429 退避并用 同一 键重试。
Python 备选
# handler.py
import json
import os
import urllib.request
from datetime import datetime, timezone
ROVELA_URL = "https://api.rovela.dev/emails"
def require_env(name: str) -> str:
v = os.environ.get(name)
if not v:
raise RuntimeError(f"missing env {name}")
return v
def utc_day_key() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%d")
def build_daily_digest(day: str) -> dict:
stats = {"signups": 12, "paid": 3, "incidents": 1, "failedJobs": 0}
subject = f"Acme daily digest — {day}"
text = "\n".join(
[
f"Signups: {stats['signups']}",
f"Paid: {stats['paid']}",
f"Open incidents: {stats['incidents']}",
f"Failed jobs: {stats['failedJobs']}",
]
)
html = f"""
<h1>Daily digest — {day}</h1>
<ul>
<li>Signups: <b>{stats['signups']}</b></li>
<li>Paid: <b>{stats['paid']}</b></li>
<li>Open incidents: <b>{stats['incidents']}</b></li>
<li>Failed jobs: <b>{stats['failedJobs']}</b></li>
</ul>
"""
return {"subject": subject, "html": html, "text": text, "stats": stats}
def send_transactional(*, to, subject, html, text, idempotency_key, tags):
payload = {
"from": require_env("MAIL_FROM"),
"to": to,
"subject": subject,
"html": html,
"text": text,
"tags": tags,
}
req = urllib.request.Request(
ROVELA_URL,
data=json.dumps(payload).encode(),
headers={
"Authorization": f"Bearer {require_env('ROVELA_API_KEY')}",
"Content-Type": "application/json",
"Idempotency-Key": idempotency_key,
},
method="POST",
)
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read().decode())
def handler(event, context):
day = utc_day_key()
digest = build_daily_digest(day)
to = [s.strip() for s in require_env("DIGEST_TO").split(",") if s.strip()]
result = send_transactional(
to=to,
subject=digest["subject"],
html=digest["html"],
text=digest["text"],
idempotency_key=f"aws-daily-digest:{day}",
tags={"kind": "aws-daily-digest", "day": day, "platform": "aws-eventbridge-scheduler"},
)
print(json.dumps({"ok": True, "day": day, "emailId": result.get("id"), "stats": digest["stats"]}))
return {"ok": True, "emailId": result.get("id")}
Scheduler 与经典 EventBridge / CloudWatch 定时规则
| EventBridge Scheduler | 经典定时规则 | |
|---|---|---|
| 服务 | 独立的 Scheduler API | EventBridge Rules(旧 CloudWatch Events 调度) |
| 表达式 | cron / rate / at | cron / rate |
| 时区 | 经 ScheduleExpressionTimezone 的 IANA | 仅 UTC 求值 |
| 灵活时间窗 | 有 | 无 |
| 按调度重试 / DLQ | 有 | 能力有限 / 模型不同 |
一次性 at(...) | 有 | 需用其他一次性模式 |
在 AWS 上新建事务摘要,从 Scheduler 开始。若你已有指向 Lambda 的 Rule cron(0 13 * * ? *),可以保留——同样用窗口级 Idempotency-Key 调用 Rovela。
故障排查
| 现象 | 检查 |
|---|---|
Rovela 400 提到域名 | MAIL_FROM 域名未在 Rovela 验证 |
Rovela 401 | 环境变量或 Secrets Manager 中的 ROVELA_API_KEY 缺失 / 错误 |
Rovela 429 | 退避;用 同一 Idempotency-Key 重试 |
| 重试后重复邮件 | 键必须按窗口稳定(aws-daily-digest:{day}),不要每次 invoke 用 uuid() |
| 多账户场景下调度偏晚 | 灵活窗可能是 ON — 要紧的摘要设 Mode: OFF |
| 春季夏令时「少了」09:00 | 不存在的本地时刻会被跳过 — 换安全整点或接受跳过 |
| Lambda 在 Rovela 返回前超时 | 提高超时;缩小查询;不要用新键再发一封 |
| 代码里要投递状态 | 优先 Rovela webhooks;GET /emails/:id/events 需要 full_access,而 sending_access 只能发信 |
非 2xx 时原样记录 Rovela 的 message(Quick Start)。生产投递状态优先 webhook,少用轮询(Send Email API、Authentication)。
检查清单
- 已验证 Rovela 发信域名;
sending_access密钥在 Lambda env 或 Secrets Manager,不在 Git - 有意识地选择 EventBridge Scheduler 表达式与时区(要紧摘要用
OFF灵活窗) - IAM:Scheduler 角色可调用 Lambda;若用 Secrets Manager,Lambda 角色可读 secret
- 触发时刻做实时聚合;尽量同时设置 HTML + text
Idempotency-Key对齐调度窗口(aws-…:{day})——在至少一次重试下是硬要求- 用 CloudWatch Logs 看函数健康;用 Rovela 控制台或 webhook 看投递