← 返回博客

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。

更新于 2026年11月5日
Azure Functions Timer Trigger 事务邮件 + Rovela(2026):NCRONTAB、Key Vault 与按窗口的幂等键

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,让它:

  1. 从 App Settings / Key Vault 读取 ROVELA_API_KEY、MAIL_FROM 与收件人(绝不写死在仓库)
  2. 按当前调度窗口聚合摘要 / 报告
  3. 用对齐该窗口的 Idempotency-Key 调用 Rovela 发送(也可派生自 ScheduleStatus)
  4. 在迟到(IsPastDue)运行与手动管理调用下仍不重复发信

Rovela 不会创建 Function App、timer 绑定、存储账户或 Key Vault 访问策略。 Azure 管 NCRONTAB → 调用;你的函数管查询 → 映射字段 → POST /emails;Rovela 管投递。

与下列路径区分:

也不同于 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 路径。

前置条件

  1. Rovela 上已 验证发信域名。未验证的 from 域名会 400(Quick Start)。
  2. 形如 re_… 的 API key。函数侧优先 sending_access(Authentication)。
  3. 具备出站 HTTPS、超时足够覆盖查询 + 一次 Rovela 往返的 Function App(Node.js v4、Python、.NET isolated 等)。
  4. 密钥:ROVELA_API_KEY 的 App Settings 和/或 Key Vault 引用,以及 MAIL_FROM、DIGEST_TO(或在代码里查收件人)。托管标识需要 Key Vault Secrets User(RBAC)或 secrets 的 Get(访问策略)。
  5. 已配置 AzureWebJobsStorage(timer 协调)。
  6. 一条 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)

  1. 为 Function App 启用 系统分配(或用户分配)托管标识。
  2. 授予该标识 Key Vault Secrets User(RBAC)或 secrets 的 Get(访问策略)。
  3. 把应用设置的值写成引用——代码仍像普通字符串一样读 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

NCRONTABTimeSpan
格式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)。

清单

  1. 已验证 Rovela 发信域名;sending_access 在 App Settings 或 Key Vault 引用中,不在 Git
  2. NCRONTAB 写在 %TIMER_SCHEDULE%;时区有意选择(Linux Consumption / Flex 用 UTC)
  3. runOnStartup=false;AzureWebJobsStorage 正常;共享存储时 host id 唯一
  4. 触发时实时聚合;尽量同时设置 HTML + text
  5. Idempotency-Key 对齐调度窗口(azure-… 或 ScheduleStatus 时间戳)
  6. 理解 timer 默认不自动重试,以及你启用的任何显式重试属性
  7. Application Insights 看函数健康;Rovela 控制台或 webhook 看投递

相关阅读