Azure Functions Timer Trigger 事务邮件 + Rovela(2026):NCRONTAB、Key Vault 与按窗口的幂等键
用 Azure Functions Timer Trigger(NCRONTAB、App Settings / Key Vault 引用、支持处的 WEBSITE_TIME_ZONE)配合按窗口的 Idempotency-Key 调用 POST /emails 发送摘要与运维报告——区别于 GCP Scheduler、EventBridge 与 Netlify/Fly/Render cron。
Timer Trigger 负责 NCRONTAB — Rovela 负责投递
Azure Functions Timer Trigger 会在 Functions 主机内按 NCRONTAB(或 App Service Plan 上的 TimeSpan)调度运行你的函数。这种形态适合摘要、运维报告,以及正文依赖 触发时刻实时状态(注册数、失败任务、未付发票)的事务邮件——而不是你能提前几小时用 Rovela scheduled_at 冻住的内容。
当函数调用 POST https://api.rovela.dev/emails 时,Rovela 负责入队、DKIM 签名与投递(Send Email API、Quick Start、Authentication)。Azure 负责调度、扩缩容下的单实例行为,以及密钥注入(App Settings 或 Key Vault 引用)。你的函数负责聚合数据,以及按调度窗口稳定的 Idempotency-Key。Rovela 只负责投递。
本指南会接好 Azure Functions Timer Trigger → Rovela,让它:
- 从 App Settings / Key Vault 读取
ROVELA_API_KEY、MAIL_FROM与收件人(绝不写死在仓库) - 按当前调度窗口聚合摘要 / 报告
- 用对齐该窗口的
Idempotency-Key调用 Rovela 发送(也可派生自ScheduleStatus) - 在迟到(
IsPastDue)运行与手动管理调用下仍不重复发信
Rovela 不会创建 Function App、timer 绑定、存储账户或 Key Vault 访问策略。 Azure 管 NCRONTAB → 调用;你的函数管查询 → 映射字段 → POST /emails;Rovela 管投递。
与下列路径区分:
- GCP Cloud Scheduler 事务邮件 — 五段 unix-cron / groc + HTTP/OIDC 或 Pub/Sub 打到 Cloud Run / Functions
- AWS EventBridge Scheduler 事务邮件 — 六段 AWS cron /
rate/at+ Lambda(不是 Azure NCRONTAB) - Netlify Scheduled Functions 事务邮件 — Netlify UTC cron + 30 秒函数限制
- Fly Scheduled Machines 事务邮件 — 粗粒度
hourly/daily/weekly/monthly - Render Cron 事务邮件 — 必须退出的 Cron Job 进程
- Railway Cron 事务邮件 — Railway cron 服务;重叠触发会被跳过
- Vercel Cron 摘要邮件 — 对生产路由发 HTTP
GET+CRON_SECRET - GitHub Actions 事务邮件 — 工作流 /
run_id在 CI 事件上通知 - 定时发送邮件 — 正文已知时用 Rovela
scheduled_at
也不同于 Azure Logic Apps Recurrence 或 Azure Automation runbook:那些是另一套调度器。本文只讲 Function App 内的 Timer Trigger 绑定。
Timer Trigger 做什么,Rovela 发什么
| 关注点 | 归属 |
|---|---|
NCRONTAB / TimeSpan(%TIMER_SCHEDULE%) | Timer Trigger(bindings) |
WEBSITE_TIME_ZONE / 挂钟解释 | Function App 设置(时区) |
| 扩缩容下的单实例(存储锁) | Functions 主机 + AzureWebJobsStorage |
ROVELA_API_KEY 的 App Settings 或 Key Vault 引用 | Function App + Key Vault |
| 按窗口查询 DB / API | 你的函数 |
| 渲染 HTML + text | 你的函数 |
POST /emails、DKIM、队列、退信 | Rovela |
Idempotency-Key = azure-{kind}-{yyyy-mm-dd}(或小时 / 周 / ScheduleStatus) | 你的函数 |
Rovela 返回 200 表示 已接受并入队,不等于「已经进收件箱」(Quick Start)。
决定邮件任务形态的 Azure 约束
结合 Microsoft Learn 文档,发信定时请把下面当作硬约束:
- NCRONTAB 六段(秒在前)。
{second} {minute} {hour} {day} {month} {day-of-week},由 NCronTab 解析。也支持五段;有六段时秒字段在最前。例如:0 30 9 * * 1-5→ 每个工作日 09:30。 - 调度来自 App Settings。 把表达式放进应用设置,再用
%TIMER_SCHEDULE%引用,即可不改代码调整节拍(attributes / configuration)。 - 默认时区是 UTC。 若要用其他时区,设置
WEBSITE_TIME_ZONE——Windows:tzutil.exe /L名称如Eastern Standard Time;Linux Premium/Dedicated:tz 数据库名如America/New_York。Linux Flex Consumption 与 Consumption 目前不支持WEBSITE_TIME_ZONE/TZ——这些方案下 cron 请按 UTC 写(NCRONTAB 时区)。 IsPastDue/past_due。 当本次调用晚于计划时间(例如重启漏掉一次)时为true(Usage)。若摘要需要补发仍可发送——但要用 同一窗口 的 Rovela 键,避免迟到重入导致双发。- 扩缩容单实例。 扩出多实例时,timer 函数在全局只跑一个;仍有未完成调用时不会再次触发(Scale-out)。
- 失败后不自动重试。 与 queue trigger 不同,timer 失败后要等到 下一次计划时刻(Retry behavior)。若你额外启用函数级重试(例如 C#
FixedDelayRetry),请按至少一次投递设计,并保持 Idempotency-Key。 runOnStartup。 生产环境几乎永远不要true——冷启动、重启、扩容都会触发,容易刷爆摘要并抬高 Consumption 费用(Caution)。useMonitor。 间隔 ≥ 1 分钟时默认true(持久化发生次数,重启也不丢调度);亚分钟调度默认false(Configuration)。- 存储锁。 Timer 协调依赖
AzureWebJobsStorage的 blob。共享存储且 host id 相同的 Function App 会互相饿死 timer——共享存储时请给每个应用不同的 host id(Function apps sharing Storage)。 - TimeSpan 调度(
hh:mm:ss)仅限 App Service Plan,不是 Consumption(TimeSpan)。 - 出站 SMTP 很别扭。 优先用 443 上的 HTTPS
POST /emails(Email API vs SMTP)。 - 密钥留在服务端。 生产发送用 Rovela
sending_access,放进 App Settings 或 Key Vault 引用——绝不放进浏览器包(Authentication、Key Vault 引用)。
何时用 Timer Trigger + POST /emails(而不是 scheduled_at)
适合 Timer Trigger → 即时发送 的场景:
- HTML 必须在触发时包含 新鲜 指标
- 你已在 Azure Functions 上跑计算,希望 App Insights、App Settings 与调度同属一个 Function App
- 需要真正的 NCRONTAB(工作日早晨、每 15 分钟),而不是提前几小时冻住正文
- 一封(或少量)事务邮件发给运行时查到的运维 / 创始人 / 客户地址
当正文已经正确、只需要「稍后再投」且不必常驻任务时,用 Rovela scheduled_at(定时发送邮件)。Timer Trigger 与 scheduled_at 解决不同问题;本文是 Azure 路径。
前置条件
- Rovela 上已 验证发信域名。未验证的
from域名会400(Quick Start)。 - 形如
re_…的 API key。函数侧优先sending_access(Authentication)。 - 具备出站 HTTPS、超时足够覆盖查询 + 一次 Rovela 往返的 Function App(Node.js v4、Python、.NET isolated 等)。
- 密钥:
ROVELA_API_KEY的 App Settings 和/或 Key Vault 引用,以及MAIL_FROM、DIGEST_TO(或在代码里查收件人)。托管标识需要 Key Vault Secrets User(RBAC)或 secrets 的 Get(访问策略)。 - 已配置
AzureWebJobsStorage(timer 协调)。 - 一条 NCRONTAB——例如在支持的方案上设
0 30 9 * * 1-5+WEBSITE_TIME_ZONE;Linux Consumption / Flex Consumption 请用 UTC。
密钥:App Settings 与 Key Vault 引用
App Settings(快速路径)
在门户、Azure CLI、Bicep 或 Terraform 中设置。本地把值放进 local.settings.json 的 Values(不要提交真实密钥):
{
"IsEncrypted": false,
"Values": {
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"FUNCTIONS_WORKER_RUNTIME": "node",
"TIMER_SCHEDULE": "0 30 9 * * 1-5",
"ROVELA_API_KEY": "re_your_sending_access_key",
"MAIL_FROM": "Acme Reports <reports@mail.example.com>",
"DIGEST_TO": "founders@example.com",
"DIGEST_KIND": "daily"
}
}
生产 CLI 示意(勿把密钥打进 shell 历史——优先 Key Vault):
az functionapp config appsettings set \
--name daily-digest-email \
--resource-group rg-mail \
--settings \
TIMER_SCHEDULE="0 30 9 * * 1-5" \
MAIL_FROM="Acme Reports <reports@mail.example.com>" \
DIGEST_TO="founders@example.com" \
DIGEST_KIND=daily
Key Vault 引用(推荐用于 ROVELA_API_KEY)
- 为 Function App 启用 系统分配(或用户分配)托管标识。
- 授予该标识 Key Vault Secrets User(RBAC)或 secrets 的 Get(访问策略)。
- 把应用设置的值写成引用——代码仍像普通字符串一样读
process.env.ROVELA_API_KEY/os.environ["ROVELA_API_KEY"](Key Vault 引用):
@Microsoft.KeyVault(SecretUri=https://myvault.vault.azure.net/secrets/rovela-api-key)
或:
@Microsoft.KeyVault(VaultName=myvault;SecretName=rovela-api-key)
解析失败时设置会保持字面量 @Microsoft.KeyVault(...),发信会变成 401——到门户编辑对话框 / Key Vault Application Settings Diagnostics 排查。平台大约每 24 小时 刷新引用;配置变更会立即重新拉取。生产发送保留 sending_access——不要用 full_access(Authentication)。绝不把 API key 打进日志。
Timer 绑定(Node.js v4)
// src/functions/dailyDigestEmail.js
const { app } = require('@azure/functions');
const { sendDigest } = require('../lib/sendDigest');
app.timer('dailyDigestEmail', {
// 从 App Setting TIMER_SCHEDULE 读取 NCRONTAB
schedule: '%TIMER_SCHEDULE%',
// 邮件任务生产环境绝不要 true
runOnStartup: false,
handler: async (myTimer, context) => {
if (myTimer.isPastDue) {
context.log('Timer is past due — still sending with window-scoped Idempotency-Key');
}
const result = await sendDigest({
scheduleStatus: myTimer.scheduleStatus,
isPastDue: myTimer.isPastDue,
log: context.log.bind(context),
});
context.log(JSON.stringify({ ok: true, emailId: result.id }));
},
});
等价的 Python v2 装饰器形态(文档):
import azure.functions as func
app = func.FunctionApp()
@app.function_name(name="dailyDigestEmail")
@app.timer_trigger(
schedule="%TIMER_SCHEDULE%",
arg_name="mytimer",
run_on_startup=False,
)
def daily_digest_email(mytimer: func.TimerRequest) -> None:
# 调用与 Node 相同的 sendDigest 逻辑
...
最小 Node.js 发送逻辑(按窗口的幂等键)
// src/lib/sendDigest.js
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
}
/** 优先用 ScheduleStatus.Last / Next——对同一发生次数的迟到 / 手动重跑更稳定。 */
function windowKeyFromTimer(scheduleStatus, kind) {
const stampSrc =
(scheduleStatus && (scheduleStatus.last || scheduleStatus.Last)) ||
(scheduleStatus && (scheduleStatus.next || scheduleStatus.Next)) ||
null;
if (stampSrc) {
const stamp = String(stampSrc).replace(/[:.]/g, '-');
return `azure-${kind}:${stamp}`.slice(0, 256);
}
return kind === 'hourly'
? `azure-hourly-ops:${utcHourKey()}`
: `azure-daily-digest:${utcDayKey()}`;
}
async function buildDailyDigest(day) {
// 换成你对 Azure SQL / Cosmos DB / API 的查询
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 }
}
async function sendDigest({ scheduleStatus, isPastDue, log }) {
const kind = process.env.DIGEST_KIND || 'daily';
const day = utcDayKey();
const digest = await buildDailyDigest(day);
const to = requireEnv('DIGEST_TO')
.split(',')
.map((s) => s.trim())
.filter(Boolean);
const idempotencyKey = windowKeyFromTimer(scheduleStatus, kind);
log(
JSON.stringify({
kind,
day,
isPastDue: Boolean(isPastDue),
idempotencyKey,
}),
);
return sendTransactional({
to,
subject: digest.subject,
html: digest.html,
text: digest.text,
idempotencyKey,
tags: {
kind: `azure-${kind}-digest`,
day,
platform: 'azure-functions-timer',
past_due: String(Boolean(isPastDue)),
},
});
}
module.exports = { sendDigest, windowKeyFromTimer };
Rovela 的 Idempotency-Key(最长 256 字符)会让 同一 发送在 24 小时 内的重试返回原响应而不是再寄一封(Send Email API)。在 Azure 上这很重要,因为:
- 重启后的 past-due 运行可能再次进入同一日历窗口
- 管理端 / webhook 手动触发 可在 cron 之外冒烟测试
- 可选的函数级重试(
FixedDelayRetry、主机重试策略)会对 handler 形成至少一次投递
推荐键:
| 键 | 适用 |
|---|---|
azure-daily-digest:{yyyy-mm-dd} | 每个 UTC 日一封运维摘要 |
azure-hourly-ops:{yyyy-mm-dd}T{HH} | 每小时快照(0 0 * * * *) |
azure-{kind}:{ScheduleStatus.Last} | 最贴近主机记录的那一次 timer 发生 |
azure-weekly-report:{yyyy}-W{ww} | 每个 ISO 周一封创始人报告 |
azure-customer-digest:{userId}:{yyyy-mm-dd} | 按用户的摘要(每个收件人窗口一把键) |
若要发 大量 个性化摘要,请按收件人循环并各用一把新键(或对最多 100 封非模板消息用 POST /emails/batch——batch 会按设计拒绝 template / topic_id(Send Email API))。限制并发;遇到 429 退避并用 同一 键重试。
Python 备选
# function_app.py(节选)
import json
import os
import urllib.request
from datetime import datetime, timezone
import azure.functions as func
ROVELA_URL = "https://api.rovela.dev/emails"
app = func.FunctionApp()
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 window_key(mytimer: func.TimerRequest, kind: str) -> str:
status = getattr(mytimer, "schedule_status", None) or {}
stamp_src = None
if isinstance(status, dict):
stamp_src = status.get("last") or status.get("Last") or status.get("next") or status.get("Next")
else:
stamp_src = getattr(status, "last", None) or getattr(status, "next", None)
if stamp_src:
stamp = str(stamp_src).replace(":", "-").replace(".", "-")
return f"azure-{kind}:{stamp}"[:256]
return f"azure-daily-digest:{utc_day_key()}"
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())
@app.function_name(name="dailyDigestEmail")
@app.timer_trigger(schedule="%TIMER_SCHEDULE%", arg_name="mytimer", run_on_startup=False)
def daily_digest_email(mytimer: func.TimerRequest) -> None:
if mytimer.past_due:
# 仍可发送;Idempotency-Key 防止同窗口重复
pass
kind = os.environ.get("DIGEST_KIND") or "daily"
day = utc_day_key()
digest_body = build_daily_digest(day)
to = [s.strip() for s in require_env("DIGEST_TO").split(",") if s.strip()]
send_transactional(
to=to,
subject=digest_body["subject"],
html=digest_body["html"],
text=digest_body["text"],
idempotency_key=window_key(mytimer, kind),
tags={
"kind": f"azure-{kind}-digest",
"day": day,
"platform": "azure-functions-timer",
"past_due": str(bool(mytimer.past_due)),
},
)
发信任务:NCRONTAB 对比 TimeSpan
| NCRONTAB | TimeSpan | |
|---|---|---|
| 格式 | 0 30 9 * * 1-5(秒在前的六段,或五段) | 01:00:00 / 1.00:00:00 |
| 含义 | 挂钟时刻 | 两次调用之间的间隔 |
| 方案 | 支持 Timer Trigger 的方案 | 仅 App Service Plan |
| 时区 | 默认 UTC;支持处可用 WEBSITE_TIME_ZONE | 间隔,不是挂钟 |
| 适合 | 「工作日 09:30」摘要 | 专用方案上「上次结束后再隔一小时」 |
创始人 / 运维摘要优先用 NCRONTAB,触发时刻更可预期。
故障处理
| 现象 | 检查 |
|---|---|
Rovela 400 提到域名 | MAIL_FROM 域名未在 Rovela 验证 |
Rovela 401 | 缺少 / 错误的 ROVELA_API_KEY;未解析的 @Microsoft.KeyVault(...) 字面量 |
Rovela 429 | 退避;用 同一 Idempotency-Key 重试 |
| 迟到运行 / 手动触发后重复发信 | 键必须按窗口(或 ScheduleStatus)稳定,不能每次 uuid() |
| Linux Consumption 上 timer 从不按本地时区触发 | 预期行为:用 UTC NCRONTAB——Linux Consumption / Flex 不支持 WEBSITE_TIME_ZONE |
| 两个 Function App,只有一个 timer「赢了」 | 共享存储时使用不同的 AzureFunctionsWebHost__hostid |
| 函数失败且从不重试 | Timer 不自动重试——修好错误等下次计划,或显式加重试策略 + Idempotency-Key |
| 部署 / 扩容时意外发信 | 确认 runOnStartup / run_on_startup 为 false |
| 代码里需要投递状态 | 优先 Rovela webhooks;GET /emails/:id/events 需要 full_access,而 sending_access 只能发送 |
非 2xx 时原样记录 Rovela 的 message 字段(Quick Start)。生产投递状态优先 webhook,不要轮询(Send Email API、Authentication)。
清单
- 已验证 Rovela 发信域名;
sending_access在 App Settings 或 Key Vault 引用中,不在 Git - NCRONTAB 写在
%TIMER_SCHEDULE%;时区有意选择(Linux Consumption / Flex 用 UTC) runOnStartup=false;AzureWebJobsStorage正常;共享存储时 host id 唯一- 触发时实时聚合;尽量同时设置 HTML + text
Idempotency-Key对齐调度窗口(azure-…或ScheduleStatus时间戳)- 理解 timer 默认不自动重试,以及你启用的任何显式重试属性
- Application Insights 看函数健康;Rovela 控制台或 webhook 看投递
相关阅读
- GCP Cloud Scheduler 事务邮件
- AWS EventBridge Scheduler 事务邮件
- Netlify Scheduled Functions 事务邮件
- Fly Scheduled Machines 事务邮件
- Render Cron 事务邮件
- Railway Cron 事务邮件
- Vercel Cron 摘要邮件
- GitHub Actions 事务邮件
- 定时发送邮件
- Email API vs SMTP
- Send Email API
- Authentication
- Timer trigger for Azure Functions
- Run scheduled tasks using Azure Functions
- Key Vault references as app settings
- App settings reference for Azure Functions
- Manually run a non-HTTP-triggered function