mcpField
T
twilio-email

twilio-email

github.com/alphaonedev开发43 次安装
代码仓库

关于 twilio-email

twilio-email 是面向工程师的 OpenClaw 技能,用于构建和维护基于 SendGrid 的事务性邮件系统。它涵盖邮件发送、模板、事件 Webhook、抑制名单和域名认证等生产运维环节。

主要功能

  1. 01通过 SendGrid v3 API 发送邮件
  2. 02安全使用并管理动态模板
  3. 03验证事件 Webhook 签名并去重
  4. 04管理退信、退订等抑制名单
  5. 05配置 SPF、DKIM、DMARC 和 IP 预热

适用场景

  1. 01发送密码重置和收据等事务性邮件
  2. 02处理并验证投递与退信事件
  3. 03依据退订和投诉状态阻止发送
  4. 04为发送域配置邮件认证

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/alphaonedev/openclaw-graph --skill twilio-email

安装后在 Claude Code 中输入/twilio-email确认已加载。

详细介绍

twilio-email

用途

帮助 OpenClaw 在生产环境中实现、运行和排查 SendGrid(Twilio)事务性邮件问题:

  • 通过 SendGrid v3 API 发送事务性邮件,并正确处理身份验证、重试、幂等性和可观测性。
  • 安全地使用动态模板(Handlebars),包括转义、条件语句和循环;对模板进行版本管理并逐步发布更改。
  • 处理 SendGrid Event Webhook(delivered/open/click/bounce/spamreport/dropped/deferred),并验证签名、防止重放。
  • 管理抑制项(退信、阻止、垃圾邮件举报、退订),并正确实现 list-unsubscribe。
  • 配置域名身份验证(SPF/DKIM/DMARC)、专用 IP、IP 预热,以及退信和垃圾邮件处理。
  • 集成 Twilio 集群模式(速率限制、Webhook 重试逻辑、错误分类、成本与吞吐量权衡)。

此技能面向构建可靠邮件投递管道,并在真实流量、合规要求和投递能力要求下维护这些管道的工程师。


先决条件

账户与访问权限
  • 已启用 Mail Send 的 Twilio SendGrid 账户。
  • 具有最低所需权限范围的 API 密钥:
    • mail.send(必需)
    • templates.read、templates.write(管理模板时需要)
    • suppression.read、suppression.write(管理抑制项时需要)
    • eventwebhook.read、eventwebhook.write(管理 Webhook 设置时需要)
  • 已验证的发件人身份或已通过身份验证的域名。
运行时版本(已测试)
  • Node.js 20.11.1(LTS)+ npm 10.2.4
  • Python 3.11.7(或 3.12.1)+ pip 23.3.2
  • Go 1.22.1(用于实现 Webhook 验证器或高吞吐量发件程序)
  • OpenSSL 3.0.2(Ubuntu 22.04)、3.2.1(macOS 14)、3.0.12(Fedora 39)
SDK / 库(推荐)
网络 / 基础设施
  • 向外连接 https://api.sendgrid.com 的 HTTPS 流量(TCP 443)。
  • 用于 Event Webhook 的 HTTPS 入站端点(可从公网访问),并具备:
    • TLS 1.2+(首选 TLS 1.3)
    • 稳定的主机名
    • 处理流量突发的能力(SendGrid 会批量发送事件)
身份验证设置(确切步骤)
  1. 创建 API 密钥:
    • SendGrid Dashboard → Settings → API Keys → Create API Key
    • 名称:prod-mail-send-2026-02
    • 权限:Restricted Access → 启用 Mail Send(以及所需的其他权限)
  2. 将密钥存储在密钥管理器中:
    • AWS Secrets Manager:prod/sendgrid/api_key
    • GCP Secret Manager:prod-sendgrid-api-key
    • Vault:secret/data/prod/sendgrid
  3. 在本地导出以供测试(切勿提交):
    export SENDGRID_API_KEY='SG.xxxxxx.yyyyyy'
    

核心概念

事务性邮件与营销邮件
  • 事务性邮件:由用户或系统操作触发(密码重置、收据、提醒)。必须及时且一致;根据司法管辖区和内容,通常不受营销许可规则约束。
  • 营销邮件:营销活动、简报。使用 SendGrid Marketing Campaigns;其合规要求和抑制规则有所不同。

此技能侧重于通过 /v3/mail/send 和动态模板发送事务性邮件。

消息模型(SendGrid v3)

发送请求是一个 JSON 负载,包含:

  • from(必须已验证或通过域名身份验证)
  • personalizations[]:
    • to[]、cc[]、bcc[]
    • dynamic_template_data(用于动态模板)
    • custom_args(用于关联 ID;会显示在 Event Webhook 中)
  • template_id(动态模板)
  • categories[](用于分析分组)
  • asm(退订组)
  • mail_settings、tracking_settings
动态模板(Handlebars)
  • 模板存储在 SendGrid 中;通过 template_id 引用。
  • Handlebars 功能:
    • {{var}} 默认进行 HTML 转义
    • {{{var}}} 不转义(危险;除非已清理,否则避免使用)
    • {{#if}}、{{#each}}、{{else}}
  • 版本管理:模板可以有多个版本;你可以激活某个版本。
事件 Webhook

SendGrid 会向你的端点发送批量 JSON 事件,例如:

  • delivered、open、click、bounce、dropped、deferred、spamreport、unsubscribe、group_unsubscribe、group_resubscribe、processed

生产环境的关键要求:

  • 使用 SendGrid 的公钥验证签名(ECDSA)。
  • 处理重试(SendGrid 在收到非 2xx 响应时会重试)。
  • 幂等性:事件可能重复;通过 (sg_event_id) 或 (sg_message_id, event, timestamp) 去重。
抑制列表

抑制列表可阻止邮件送达:

  • 全局退订
  • 群组退订(ASM 群组)
  • 退信
  • 阻止
  • 垃圾邮件举报

你的系统必须:

  • 尊重退订要求(视具体情况遵守 CAN-SPAM、GDPR/PECR)。
  • 在适当情况下提供一键退订的 list-unsubscribe 标头。
  • 监控退信率和垃圾邮件举报率;自动停止向无效地址发送邮件。
投递能力:SPF/DKIM/DMARC
  • SPF:授权你的域名使用哪些 IP 地址发送邮件。
  • DKIM:加密签名;SendGrid 会为域名身份验证提供 CNAME 记录。
  • DMARC:用于对齐和报告的策略;从 p=none 开始,再改为 quarantine/reject。
错误分类(SendGrid API)
  • 4xx:请求或身份验证问题;不要盲目重试。
  • 429:请求频率受限;使用退避策略重试。
  • 5xx:暂时性错误;使用随机抖动重试。

安装与设置

官方 Python SDK — Email(SendGrid)

代码库: https://github.com/twilio/twilio-python
PyPI: pip install twilio sendgrid · 支持: Python 3.7–3.13

# SendGrid is the email layer (separate package, same Twilio ecosystem)
import sendgrid
from sendgrid.helpers.mail import Mail
import os

sg = sendgrid.SendGridAPIClient(api_key=os.environ["SENDGRID_API_KEY"])

message = Mail(
    from_email="[email protected]",
    to_emails="[email protected]",
    subject="Hello from Python!",
    html_content="<strong>Hello!</strong>"
)
response = sg.send(message)
print(response.status_code)  # 202 = queued

来源:sendgrid/sendgrid-python(官方 SendGrid Python SDK,属于 Twilio 家族)

Ubuntu 22.04 / 24.04

安装 Node 20、Python 3.11 和相关工具:

sudo apt-get update
sudo apt-get install -y ca-certificates curl gnupg jq python3.11 python3.11-venv python3-pip

# NodeSource Node 20
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt-get install -y nodejs

node -v   # v20.11.1 (or later 20.x)
npm -v    # 10.2.4 (or later)

项目依赖(Node):

mkdir -p services/email-sender
cd services/email-sender
npm init -y
npm install @sendgrid/[email protected] @sendgrid/[email protected] [email protected] [email protected]

Python 虚拟环境:

mkdir -p services/email-worker
cd services/email-worker
python3.11 -m venv .venv
source .venv/bin/activate
pip install --upgrade pip==23.3.2
pip install sendgrid==6.11.0 fastapi==0.109.2 uvicorn==0.27.1 pydantic==2.6.1
Fedora 39 / 40
sudo dnf install -y nodejs-20.11.1 npm jq python3.11 python3.11-pip python3.11-virtualenv openssl
node -v
python3.11 -V
macOS 14(Intel + Apple Silicon)

使用 Homebrew:

brew update
brew install node@20 jq [email protected] openssl@3
echo 'export PATH="/opt/homebrew/opt/node@20/bin:$PATH"' >> ~/.zshrc  # Apple Silicon
echo 'export PATH="/usr/local/opt/node@20/bin:$PATH"' >> ~/.zshrc     # Intel
source ~/.zshrc

node -v
python3.11 -V
环境变量(本地开发)

创建 .env(不要提交):

路径:services/email-sender/.env

SENDGRID_API_KEY=SG.xxxxxx.yyyyyy
[email protected]
SENDGRID_FROM_NAME=Example Notifications
SENDGRID_TEMPLATE_PASSWORD_RESET=d-2f3c4b5a6d7e8f90123456789abcdeff
SENDGRID_TEMPLATE_RECEIPT=d-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
SENDGRID_EVENT_WEBHOOK_PUBLIC_KEY=MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...
EMAIL_ENV=prod

加载它(bash):

set -a
source .env
set +a

主要功能

发送事务性邮件(v3 Mail Send)

要求:

  • 使用动态模板,确保渲染效果一致。
  • 通过 custom_args 添加关联 ID。
  • 仅针对 429/5xx 实现重试策略。
  • 对每个收件人进行验证,以减少退信。

Node 发送示例(适用于生产环境的基础框架):

路径:services/email-sender/src/send.ts

import sgMail from "@sendgrid/mail";
import { z } from "zod";
import pino from "pino";
import crypto from "crypto";

const log = pino({ level: process.env.LOG_LEVEL ?? "info" });

const Env = z.object({
  SENDGRID_API_KEY: z.string().min(20),
  SENDGRID_FROM_EMAIL: z.string().email(),
  SENDGRID_FROM_NAME: z.string().min(1),
  EMAIL_ENV: z.enum(["dev", "staging", "prod"]).default("prod"),
});
const env = Env.parse(process.env);

sgMail.setApiKey(env.SENDGRID_API_KEY);

export type SendTemplateEmailInput = {
  to: string;
  templateId: string;
  dynamicTemplateData: Record<string, unknown>;
  categories?: string[];
  customArgs?: Record<string, string>;
};

function makeIdempotencyKey(input: SendTemplateEmailInput): string {
  // Stable key for same logical send (avoid duplicates on retries)
  const h = crypto.createHash("sha256");
  h.update(input.to);
  h.update(input.templateId);
  h.update(JSON.stringify(input.dynamicTemplateData));
  return h.digest("hex");
}

export async function sendTemplateEmail(input: SendTemplateEmailInput) {
  const idempotencyKey = makeIdempotencyKey(input);

  const msg = {
    to: input.to,
    from: { email: env.SENDGRID_FROM_EMAIL, name: env.SENDGRID_FROM_NAME },
    templateId: input.templateId,
    dynamicTemplateData: input.dynamicTemplateData,
    categories: input.categories ?? ["transactional"],
    customArgs: {
      ...input.customArgs,
      idempotency_key: idempotencyKey,
      env: env.EMAIL_ENV,
    },
    mailSettings: {
      sandboxMode: { enable: env.EMAIL_ENV !== "prod" },
    },
  };

  // SendGrid does not support an explicit idempotency header for /mail/send.
  // You must implement idempotency in your app (e.g., outbox table).
  try {
    const [resp] = await sgMail.send(msg as any, false);
    log.info(
      { statusCode: resp.statusCode, headers: resp.headers, to: input.to, templateId: input.templateId },
      "sendgrid mail.send accepted"
    );
    return { accepted: true, statusCode: resp.statusCode, idempotencyKey };
  } catch (err: any) {
    const statusCode = err?.code ?? err?.response?.statusCode;
    const body = err?.response?.body;
    log.error({ err, statusCode, body, to: input.to, templateId: input.templateId }, "sendgrid mail.send failed");
    throw err;
  }
}

运行说明:

  • mailSettings.sandboxMode.enable=true 可阻止实际投递(用于开发/预发布环境)。
  • 如需真正实现幂等性,请使用以 (idempotency_key) 为键的发件箱表,并确保只发送一次。
动态模板:安全使用 Handlebars

规则:

  • 用户提供的内容优先使用 {{var}}(已转义)。
  • 除非内容是已清理的 HTML,否则避免使用 {{{var}}}。
  • 保持模板逻辑精简;在代码中计算复杂的值。

模板数据契约示例:

{
  "app_name": "Example",
  "reset_url": "https://app.example.com/reset?token=9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
  "support_email": "[email protected]",
  "expires_minutes": 30
}

Handlebars 代码片段:

<p>Reset your {{app_name}} password:</p>
<p><a href="{{reset_url}}">Reset password</a></p>
<p>This link expires in {{expires_minutes}} minutes.</p>
事件 Webhook:验证与幂等处理

SendGrid Event Webhook 使用 ECDSA 签名:

  • 标头:
    • X-Twilio-Email-Event-Webhook-Signature
    • X-Twilio-Email-Event-Webhook-Timestamp
  • 验证签名时使用:timestamp + payload(原始请求正文)

FastAPI 示例(原始正文 + 验证):

路径:services/email-worker/app/webhook.py

import base64
import hashlib
from fastapi import APIRouter, Request, HTTPException
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.exceptions import InvalidSignature

router = APIRouter()

def verify_sendgrid_signature(public_key_pem: str, signature_b64: str, timestamp: str, payload: bytes) -> None:
    public_key = serialization.load_pem_public_key(public_key_pem.encode("utf-8"))
    if not isinstance(public_key, ec.EllipticCurvePublicKey):
        raise ValueError("public key is not EC")

    signed = timestamp.encode("utf-8") + payload
    sig = base64.b64decode(signature_b64)

    try:
        public_key.verify(sig, signed, ec.ECDSA(hashes.SHA256()))
    except InvalidSignature as e:
        raise

@router.post("/webhooks/sendgrid/events")
async def sendgrid_events(request: Request):
    sig = request.headers.get("X-Twilio-Email-Event-Webhook-Signature")
    ts = request.headers.get("X-Twilio-Email-Event-Webhook-Timestamp")
    if not sig or not ts:
        raise HTTPException(status_code=400, detail="missing signature headers")

    raw = await request.body()

    public_key_pem = request.app.state.sendgrid_event_public_key_pem
    try:
        verify_sendgrid_signature(public_key_pem, sig, ts, raw)
    except Exception:
        raise HTTPException(status_code=401, detail="invalid signature")

    events = await request.json()
    # events is a list of dicts
    # Idempotency: dedupe by sg_event_id
    # Persist first, then ack 2xx.
    return {"ok": True, "count": len(events)}

重放保护:

  • 拒绝时间戳早于例如 5 分钟的请求:
    • 将 ts 与当前时间比较;允许小幅时钟偏差。
  • 使用唯一约束存储 (sg_event_id);忽略重复事件。
抑制管理

使用抑制端点执行以下操作:

  • 发送前查询地址是否已被抑制(可选;可能成本较高)。
  • 仅在用户明确操作且经过合规审核后,才将地址从抑制列表中移除。

常用端点:

  • 全局退订:/v3/asm/suppressions/global
  • 群组退订:/v3/asm/groups/{group_id}/suppressions
  • 退信:/v3/suppression/bounces
  • 屏蔽:/v3/suppression/blocks
  • 垃圾邮件报告:/v3/suppression/spam_reports
域名身份验证(SPF/DKIM/DMARC)

生产环境基线:

  • 使用 SendGrid 域名身份验证(基于 CNAME 的 DKIM)。
  • SPF:如果使用 SendGrid 的共享 IP,请添加 SendGrid;对于专用 IP,请遵循 SendGrid 指南。
  • DMARC:
    • 起始策略:v=DMARC1; p=none; rua=mailto:[email protected]; ruf=mailto:[email protected]; fo=1; adkim=s; aspf=s
    • 监控后,将策略调整为 quarantine,然后调整为 reject。
IP 预热(专用 IP)

如果使用专用 IP:

  • 逐步增加发送量(持续数天或数周)。
  • 保持较低的投诉率;避免发送量突然激增。
  • 对流量进行细分:从互动度最高的收件人开始。

命令参考

本节介绍通过 curl 调用 SendGrid API(不假设有官方 SendGrid CLI)以及常见的运维命令。

身份验证标头

所有 API 调用:

  • 标头:Authorization: Bearer $SENDGRID_API_KEY
  • Content-Type:application/json
发送邮件:POST /v3/mail/send
curl -sS -D /tmp/sg_headers.txt -o /tmp/sg_body.txt \
  -X POST "https://api.sendgrid.com/v3/mail/send" \
  -H "Authorization: Bearer ${SENDGRID_API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary @- <<'JSON'
{
  "from": { "email": "[email protected]", "name": "Example Notifications" },
  "personalizations": [
    {
      "to": [{ "email": "[email protected]", "name": "Alice" }],
      "dynamic_template_data": {
        "app_name": "Example",
        "reset_url": "https://app.example.com/reset?token=9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
        "expires_minutes": 30
      },
      "custom_args": {
        "user_id": "u_12345",
        "request_id": "req_01HPQ7ZK8Z7WQ2J9R8D2E6M3QK"
      }
    }
  ],
  "template_id": "d-2f3c4b5a6d7e8f90123456789abcdeff",
  "categories": ["password_reset", "transactional"],
  "tracking_settings": {
    "click_tracking": { "enable": true, "enable_text": true },
    "open_tracking": { "enable": true }
  }
}
JSON

相关行为:

  • 成功:HTTP 202 Accepted,响应正文为空。
  • 失败:HTTP 4xx/5xx,并返回 JSON 错误详情。
模板
列出模板:GET /v3/templates

参数:

  • ?generations=dynamic 筛选条件(如果你的账户支持)
  • ?page_size=... 分页
curl -sS "https://api.sendgrid.com/v3/templates?generations=dynamic&page_size=50" \
  -H "Authorization: Bearer ${SENDGRID_API_KEY}" | jq .
获取模板:GET /v3/templates/{template_id}
curl -sS "https://api.sendgrid.com/v3/templates/d-2f3c4b5a6d7e8f90123456789abcdeff" \
  -H "Authorization: Bearer ${SENDGRID_API_KEY}" | jq .
创建模板:POST /v3/templates
curl -sS -X POST "https://api.sendgrid.com/v3/templates" \
  -H "Authorization: Bearer ${SENDGRID_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"name":"prod_password_reset","generation":"dynamic"}' | jq .
添加版本:POST /v3/templates/{template_id}/versions
curl -sS -X POST "https://api.sendgrid.com/v3/templates/d-2f3c4b5a6d7e8f90123456789abcdeff/versions" \
  -H "Authorization: Bearer ${SENDGRID_API_KEY}" \
  -H "Content-Type: application/json" \
  -d @- <<'JSON' | jq .
{
  "active": 0,
  "name": "v2026-02-21",
  "subject": "Reset your password",
  "html_content": "<p>Reset your password: <a href=\"{{reset_url}}\">Reset</a></p>",
  "plain_content": "Reset your password: {{reset_url}}"
}
JSON
激活版本:PATCH /v3/templates/{template_id}/versions/{version_id}
curl -sS -X PATCH "https://api.sendgrid.com/v3/templates/d-2f3c4b5a6d7e8f90123456789abcdeff/versions/3c1b2a9d-0f2a-4c7b-9d2a-1a2b3c4d5e6f" \
  -H "Authorization: Bearer ${SENDGRID_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"active":1}' | jq .
事件 Webhook 配置
获取设置:GET /v3/user/webhooks/event/settings
curl -sS "https://api.sendgrid.com/v3/user/webhooks/event/settings" \
  -H "Authorization: Bearer ${SENDGRID_API_KEY}" | jq .
更新设置:PATCH /v3/user/webhooks/event/settings

关键字段:

  • enabled(布尔值)
  • url(字符串)
  • group_resubscribe、group_unsubscribe、spam_report、bounce、deferred、delivered、dropped、open、click、processed、unsubscribe(布尔值)
  • oauth_client_id、oauth_client_secret、oauth_token_url(使用 OAuth 时;较少见)
curl -sS -X PATCH "https://api.sendgrid.com/v3/user/webhooks/event/settings" \
  -H "Authorization: Bearer ${SENDGRID_API_KEY}" \
  -H "Content-Type: application/json" \
  -d @- <<'JSON' | jq .
{
  "enabled": true,
  "url": "https://email-hooks.example.com/webhooks/sendgrid/events",
  "delivered": true,
  "bounce": true,
  "dropped": true,
  "deferred": true,
  "spam_report": true,
  "unsubscribe": true,
  "group_unsubscribe": true,
  "group_resubscribe": true,
  "open": true,
  "click": true,
  "processed": true
}
JSON
抑制列表
全局退订:列出:GET /v3/asm/suppressions/global

分页:

  • ?offset=0&limit=500
curl -sS "https://api.sendgrid.com/v3/asm/suppressions/global?offset=0&limit=500" \
  -H "Authorization: Bearer ${SENDGRID_API_KEY}" | jq .
添加全局退订:POST /v3/asm/suppressions/global
curl -sS -X POST "https://api.sendgrid.com/v3/asm/suppressions/global" \
  -H "Authorization: Bearer ${SENDGRID_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"recipient_emails":["[email protected]"]}' | jq .
删除全局退订:DELETE /v3/asm/suppressions/global/{email}
curl -sS -X DELETE "https://api.sendgrid.com/v3/asm/suppressions/global/alice%40example.net" \
  -H "Authorization: Bearer ${SENDGRID_API_KEY}" -i
退信:GET /v3/suppression/bounces

筛选条件:

curl -sS "https://api.sendgrid.com/v3/suppression/[email protected]" \
  -H "Authorization: Bearer ${SENDGRID_API_KEY}" | jq .
删除退信记录:DELETE /v3/suppression/bounces/{email}
curl -sS -X DELETE "https://api.sendgrid.com/v3/suppression/bounces/alice%40example.net" \
  -H "Authorization: Bearer ${SENDGRID_API_KEY}" -i
诊断
检查 DNS 记录(SPF/DKIM/DMARC)
dig +short TXT example.com
dig +short TXT _dmarc.example.com
dig +short CNAME s1._domainkey.mg.example.com
dig +short CNAME s2._domainkey.mg.example.com
TLS 端点检查(Webhook 接收器)
openssl s_client -connect email-hooks.example.com:443 -servername email-hooks.example.com -tls1_2 </dev/null 2>/dev/null | openssl x509 -noout -issuer -subject -dates

配置参考

Node 服务配置

路径:services/email-sender/config/email.config.toml

[sendgrid]
api_base_url = "https://api.sendgrid.com"
from_email = "[email protected]"
from_name = "Example Notifications"

[templates]
password_reset = "d-2f3c4b5a6d7e8f90123456789abcdeff"
receipt = "d-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"

[webhook]
event_public_key_pem_path = "/etc/email-sender/sendgrid_event_webhook_public_key.pem"

[delivery]
categories = ["transactional"]
sandbox_mode = false

[retry]
max_attempts = 5
base_delay_ms = 200
max_delay_ms = 5000
retry_on_status = [429, 500, 502, 503, 504]
Systemd 单元(Linux)

路径:/etc/systemd/system/email-sender.service

[Unit]
Description=Email Sender Service (SendGrid)
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=email
Group=email
WorkingDirectory=/opt/email-sender
Environment=NODE_ENV=production
EnvironmentFile=/etc/email-sender/email-sender.env
ExecStart=/usr/bin/node /opt/email-sender/dist/index.js
Restart=on-failure
RestartSec=2
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/email-sender
AmbientCapabilities=
CapabilityBoundingSet=
LockPersonality=true
MemoryDenyWriteExecute=true

[Install]
WantedBy=multi-user.target

路径:/etc/email-sender/email-sender.env

SENDGRID_API_KEY=SG.xxxxxx.yyyyyy
[email protected]
SENDGRID_FROM_NAME=Example Notifications
LOG_LEVEL=info
EMAIL_ENV=prod
Webhook 接收器的 NGINX 反向代理

路径:/etc/nginx/conf.d/sendgrid-webhook.conf

server {
  listen 443 ssl http2;
  server_name email-hooks.example.com;

  ssl_certificate     /etc/letsencrypt/live/email-hooks.example.com/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/email-hooks.example.com/privkey.pem;
  ssl_protocols TLSv1.2 TLSv1.3;
  ssl_ciphers HIGH:!aNULL:!MD5;

  client_max_body_size 2m;

  location /webhooks/sendgrid/events {
    proxy_pass http://127.0.0.1:8080/webhooks/sendgrid/events;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;

    proxy_connect_timeout 2s;
    proxy_read_timeout 5s;
    proxy_send_timeout 5s;
  }
}

集成模式

Outbox 模式(基于数据库的幂等性)

在由数据库事务触发邮件发送时使用。

模式:

  1. 在与业务事件相同的数据库事务中,插入一条记录到 email_outbox。
  2. 工作进程读取待处理记录,通过 SendGrid 发送邮件,将其标记为已发送并设置 sent_at,同时存储 provider_response。
  3. 每条逻辑邮件对应唯一的 Outbox 记录,因此重试是安全的。

PostgreSQL 架构:

CREATE TABLE email_outbox (
  id bigserial PRIMARY KEY,
  idempotency_key text NOT NULL UNIQUE,
  to_email text NOT NULL,
  template_id text NOT NULL,
  dynamic_template_data jsonb NOT NULL,
  categories text[] NOT NULL DEFAULT ARRAY['transactional'],
  created_at timestamptz NOT NULL DEFAULT now(),
  sent_at timestamptz,
  last_error text,
  attempt_count int NOT NULL DEFAULT 0
);

CREATE INDEX email_outbox_pending_idx ON email_outbox (created_at) WHERE sent_at IS NULL;
Twilio 集群中的关联 ID
  • 对于 SMS/语音/Verify 和电子邮件,统一使用:
    • request_id(入口)
    • user_id
    • notification_id(逻辑通知)
  • 对于 SendGrid:
    • 将这些值放入 custom_args,使其显示在 Event Webhook 负载中。
Webhook 接收管道

推荐流程:

  • 公共端点接收 webhook → 验证签名 → 将原始事件加入 Kafka/SQS/PubSub 队列 → 异步消费者更新数据库/指标。

优点:

  • 避免 webhook 超时。
  • 支持重放和回填。
  • 集中处理去重。

SQS 入队示例(伪代码):

  • HTTP 处理程序:
    • 验证签名
    • 使用原始 JSON 行调用 SendMessageBatch
    • 快速返回 200
CI/CD 模板晋级

将模板视为代码:

  • 将模板源文件存储在仓库中:templates/sendgrid/password_reset/v2026-02-21.html
  • CI 作业:
    • 通过 API 创建新版本
    • 运行渲染测试(编译 Handlebars + 示例数据)
    • 审批后激活该版本

错误处理与故障排查

请包含确切的错误文本和处理方法。

  1. 401 Unauthorized(API 密钥无效)
    症状(curl):
HTTP/2 401
{"errors":[{"message":"Permission denied, wrong credentials","field":null,"help":null}]}

根本原因:

  • SENDGRID_API_KEY 无效/已撤销,或缺少必需的作用域。

解决方法:

  • 使用 mail.send 重新生成 API 密钥。
  • 确认运行时加载了正确的密钥(检查环境变量来源、密钥挂载)。
  1. 403 Forbidden(缺少作用域)
HTTP/2 403
{"errors":[{"message":"access forbidden","field":null,"help":null}]}

根本原因:

  • API 密钥没有该端点所需的权限(例如,模板写入权限)。

解决方法:

  • 更新 API 密钥权限,或创建单独的模板管理密钥。
  1. 400 Bad Request:电子邮件地址无效
{"errors":[{"message":"The email address is invalid.","field":"personalizations.0.to.0.email","help":"http://sendgrid.com/docs/API_Reference/Web_API_v3/Mail/errors.html#message.personalizations.to.email"}]}

根本原因:

  • to 地址格式错误。

解决方法:

  • 入队前验证电子邮件地址。
  • 如果地址来自用户输入,则要求验证并规范化。
  1. 400 Bad Request:未找到模板 / 模板 ID 错误
{"errors":[{"message":"The template_id is not a valid template ID.","field":"template_id","help":"http://sendgrid.com/docs/API_Reference/Web_API_v3/Mail/errors.html"}]}

根本原因:

  • 使用了旧版模板 ID,或使用了错误环境中的模板。

解决方法:

  • 通过 GET /v3/templates/{id} 确认模板存在。
  • 确认使用的是以 d- 开头的动态模板 ID。
  1. 429 Too Many Requests
HTTP/2 429
{"errors":[{"message":"Too many requests","field":null,"help":null}]}

根本原因:

  • 超出账户或 IP 速率限制;流量突增。

解决方法:

  • 实现带抖动的指数退避。
  • 尽可能批量发送。
  • 如果持续发生,请申请提高限制,或使用多个 IP(专用 IP)并进行预热。
  1. 413 Payload Too Large(webhook 接收器)

NGINX:

413 Request Entity Too Large

根本原因:

  • SendGrid 会批量发送事件;负载超出 client_max_body_size 限制。

解决方法:

  • 增大 client_max_body_size(例如,设为 2m 或 5m)。
  • 确保应用能够高效解析大型 JSON 数组。
  1. 401 invalid signature(你的 webhook 验证器)

服务日志:

HTTP 401 {"detail":"invalid signature"}

根本原因:

  • 使用解析后的 JSON,而不是原始请求正文进行签名验证。
  • 公钥错误(已轮换或复制错误)。
  • 如果包含重放检查,则可能是时间戳不匹配。

解决方法:

  • 使用收到的原始请求字节进行验证。
  • 从 SendGrid Event Webhook 设置中获取正确的公钥。
  • 允许小幅时钟偏差;确保 NTP 正常工作。
  1. 事件丢弃:地址被抑制

Event Webhook 包含:

{
  "event":"dropped",
  "reason":"Bounced Address",
  "email":"[email protected]"
}

根本原因:

  • 收件人位于退信/阻止/垃圾邮件抑制名单中。

解决方法:

  • 停止向该地址发送邮件。
  • 提供用户补救流程(更新电子邮件地址、确认地址)。
  • 只有在有正当理由且用户已确认的情况下,才移除抑制状态。
  1. 550 5.1.1 退信(邮箱不存在)

Event Webhook 退信:

{
  "event":"bounce",
  "status":"5.1.1",
  "reason":"550 5.1.1 The email account that you tried to reach does not exist."
}

根本原因:

  • 收件人地址无效或已删除。

解决方法:

  • 在用户数据库中将该地址标记为无效。
  • 要求用户更新电子邮件地址;不要重试。
  1. Deferred(临时失败)
{
  "event":"deferred",
  "response":"451 4.7.1 Try again later",
  "attempt":"2"
}

根本原因:

  • 接收服务器临时限流。

解决方法:

  • 无需采取措施;SendGrid 会重试。
  • 如果某个域名持续出现此问题,请考虑针对该域名进行限流并检查内容。

安全加固

密钥处理
  • 仅将 SENDGRID_API_KEY 存储在密钥管理器中;切勿将其存入代码仓库或容器镜像。
  • 每季度轮换 API 密钥(或在发生事件时轮换)。
  • 每个环境和每项能力使用不同的密钥:
    • prod-mail-send(仅限 mail.send)
    • prod-templates-admin(仅限 templates.*,限制为 CI 使用)
Webhook 端点加固
  • 要求使用 HTTPS;将 HTTP 重定向到 HTTPS。
  • 验证 ECDSA 签名和时间戳。
  • 限制请求大小(但上限要足够容纳批次)。
  • 按 IP 限速(注意:SendGrid 使用多个 IP;允许列表容易失效)。
  • 尽量少记录个人身份信息(PII);在日志中尽可能对电子邮件地址进行哈希处理。
操作系统 / 运行时加固(符合 CIS 标准)
  • Linux:
    • 以非 root 用户运行(User=email)。
    • 在 systemd 中设置 NoNewPrivileges=true、ProtectSystem=strict 和 ProtectHome=true。
    • 如果使用容器,保持基础镜像精简(distroless 或 slim)。
  • TLS:
    • 禁用 TLS 1.0/1.1(CIS 建议使用 TLS 1.2 及以上版本)。
  • 依赖项卫生:
    • Node:在 CI 中运行 npm audit;使用 lockfile 固定版本。
    • Python:在生产环境中使用 pip-audit 和通过哈希锁定的 requirements。
电子邮件内容安全
  • 切勿将未经清理的 HTML 注入模板。
  • 避免在 URL 中包含密钥;使用短期令牌。
  • 在适用时使用 List-Unsubscribe;对于事务性电子邮件,仍可考虑设置偏好中心。

性能调优

吞吐量:批处理与连接复用
  • SendGrid /v3/mail/send 支持在一个请求中包含多个 personalizations。
  • 对于高流量,按模板和载荷结构对收件人进行批处理。

预期效果:

  • 之前:每封电子邮件发送 1 个请求 → 开销高,出现更多 429。
  • 之后:每 100 个 personalizations 发送 1 个请求(内容允许时)→ 请求更少,延迟波动更小。

限制:

  • 每位收件人的 personalization 数据各不相同;如果使用相同模板,且数据按 personalization 提供,仍可批处理。
Worker 并发
  • 使用有界并发(例如,同时处理 50 个请求),避免自行触发 429。
  • 遇到 429 时采用自适应退避。
Webhook 接收
  • 如果载荷较大,使用流式方式解析 JSON(取决于语言)。
  • 入队后尽快确认;不要在请求内执行繁重的数据库操作。
投递性能
  • 在收集地址时验证电子邮件地址,以减少退信。
  • 使用域名身份验证;这有助于邮件进入收件箱并减少延迟投递。

高级主题

处理“打开”和“点击”事件
  • 打开事件不可靠(Apple Mail 隐私保护、图像代理)。
  • 点击事件更可靠,但仍可能受到机器人扫描的影响。
  • 将事件用于汇总分析;除非进行机器人过滤,否则不要用它们严格跟踪每位用户的状态。
Custom Args 与 Categories
  • custom_args:键值数据,会出现在事件 webhook 中;最适合用于关联 ID。
  • categories:字符串数组;用于 SendGrid 统计 / 分析分组。

生产环境建议:

  • 保持 categories 的基数较低(例如 password_reset、receipt),不要为每位用户单独设置。
Inbound Parse Webhook(如果接收电子邮件)

如果使用 SendGrid Inbound Parse:

  • 将 MX 记录配置为指向 SendGrid parse 主机。
  • 端点会接收 multipart form-data,其中包含 from、to、subject、text、html、attachments 等字段。

加固措施:

  • 如果提供了 SPF/DKIM/DMARC 结果,则进行验证;否则将输入视为不可信数据。
  • 限制附件的大小和类型。
多租户发送

如果代表多个品牌 / 域名发送电子邮件:

  • 使用不同的经过身份验证的域名和发件人地址。
  • 考虑使用子用户(SendGrid 功能)实现隔离。
  • 确保符合各租户的抑制规则。
专用 IP 池与地理位置考量
  • 如果在多个区域运营,可考虑为每个区域使用独立的 IP 池,以隔离声誉。
  • 分别为每个 IP 池预热。

使用示例

1)密码重置电子邮件(动态模板 + outbox)
  1. API 服务端写入 outbox 行:
INSERT INTO email_outbox (idempotency_key, to_email, template_id, dynamic_template_data, categories)
VALUES (
  'email:password_reset:u_12345:req_01HPQ7ZK8Z7WQ2J9R8D2E6M3QK',
  '[email protected]',
  'd-2f3c4b5a6d7e8f90123456789abcdeff',
  '{"app_name":"Example","reset_url":"https://app.example.com/reset?token=9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d","expires_minutes":30}',
  ARRAY['password_reset','transactional']
);
  1. Worker 读取待处理行,调用 sendTemplateEmail,并将其标记为已发送。

  2. 事件 webhook 根据 custom_args.request_id 更新投递状态。

2) 附带 PDF 附件的收据邮件

SendGrid 支持附件(base64)。请控制文件大小;大型附件会影响邮件送达率。

PDF_B64="$(base64 -w 0 receipt_2026-02-21.pdf)"
curl -sS -X POST "https://api.sendgrid.com/v3/mail/send" \
  -H "Authorization: Bearer ${SENDGRID_API_KEY}" \
  -H "Content-Type: application/json" \
  -d @- <<JSON
{
  "from": { "email": "[email protected]", "name": "Example Billing" },
  "personalizations": [
    {
      "to": [{ "email": "[email protected]" }],
      "dynamic_template_data": {
        "amount": "49.00",
        "currency": "USD",
        "receipt_id": "rcpt_9f3a2c1d"
      },
      "custom_args": { "receipt_id": "rcpt_9f3a2c1d" }
    }
  ],
  "template_id": "d-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
  "attachments": [
    {
      "content": "${PDF_B64}",
      "type": "application/pdf",
      "filename": "receipt_rcpt_9f3a2c1d.pdf",
      "disposition": "attachment"
    }
  ],
  "categories": ["receipt","transactional"]
}
JSON
3) 使用去重机制的 Webhook 接收器(Postgres 唯一约束)

架构:

CREATE TABLE sendgrid_events (
  sg_event_id text PRIMARY KEY,
  sg_message_id text,
  event text NOT NULL,
  email text NOT NULL,
  ts bigint NOT NULL,
  payload jsonb NOT NULL,
  received_at timestamptz NOT NULL DEFAULT now()
);

使用冲突时忽略的方式插入:

INSERT INTO sendgrid_events (sg_event_id, sg_message_id, event, email, ts, payload)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (sg_event_id) DO NOTHING;
4) 发送时检查抑制名单(对高价值邮件进行预检)

对于重要邮件(例如法律通知),可以预先检查抑制名单:

curl -sS "https://api.sendgrid.com/v3/asm/suppressions/global/alice%40example.net" \
  -H "Authorization: Bearer ${SENDGRID_API_KEY}" -i
  • 如果返回 200 OK,表示用户已被全局退订 → 不要发送。
  • 如果返回 404 Not Found,表示用户未被全局抑制 → 继续发送(但仍可能已退信或被拦截)。

请谨慎使用;这会增加延迟和 API 负载。

5) 域名身份验证检查清单
  1. 确认 DKIM CNAME 已解析:
dig +short CNAME s1._domainkey.mg.example.com
dig +short CNAME s2._domainkey.mg.example.com
  1. 确认 DMARC 记录存在:
dig +short TXT _dmarc.example.com
  1. 向 Gmail 发送测试邮件并检查“Authentication-Results”:
  • spf=pass
  • dkim=pass
  • dmarc=pass
6) 具备速率限制恢复能力的发送器(重试策略)

伪策略:

  • 以下状态码触发重试:429、500、502、503、504
  • 退避:min(max_delay, base * 2^attempt) + random(0, 100ms)
  • 最大尝试次数:5
  • 不要对 400/401/403 重试。

快速参考

任务命令 / 端点关键标志 / 字段
发送邮件POST /v3/mail/sendtemplate_id、personalizations[]、dynamic_template_data、custom_args、categories、mail_settings.sandboxMode
列出模板GET /v3/templatesgenerations=dynamic、page_size
创建模板POST /v3/templatesname、generation:"dynamic"
添加模板版本POST /v3/templates/{id}/versionsname、subject、html_content、plain_content、active
激活版本PATCH /v3/templates/{id}/versions/{vid}active:1
获取 Webhook 设置GET /v3/user/webhooks/event/settings不适用
更新 Webhook 设置PATCH /v3/user/webhooks/event/settingsenabled、url、事件开关
列出全局退订用户GET /v3/asm/suppressions/globaloffset、limit
添加全局退订用户POST /v3/asm/suppressions/globalrecipient_emails[]
移除全局退订用户DELETE /v3/asm/suppressions/global/{email}对 email 进行 URL 编码
列出退信GET /v3/suppression/bouncesemail、start_time、end_time
移除退信记录DELETE /v3/suppression/bounces/{email}对 email 进行 URL 编码
DNS 检查dig_dmarc、_domainkey、SPF TXT

图关系

DEPENDS_ON
  • twilio-email DEPENDS_ON:
    • 安全的密钥存储(Vault/AWS Secrets Manager/GCP Secret Manager)
    • 用于 Webhook 的 HTTPS 入口(NGINX/ALB/API Gateway)
    • 用于幂等处理和去重的持久化存储(PostgreSQL/DynamoDB)
    • 可观测性技术栈(结构化日志 + 指标 + 链路追踪)
COMPOSES
  • twilio-email COMPOSES with:
    • twilio-messaging(邮件退信或被抑制时的 SMS 备用方案)
    • twilio-verify(用于 OTP 的邮件渠道;协调验证与事务性邮件)
    • twilio-studio(触发同时发送邮件和 SMS 的流程;由 Webhook 驱动的编排)
    • 用于接收 Webhook 和运行 outbox 工作进程的队列系统(SQS/Kafka/PubSub)
SIMILAR_TO
  • SIMILAR_TO:
    • AWS SES 事务性邮件模式(Webhook / 事件驱动的退信和投诉处理)
    • Mailgun 事务性邮件模式(Webhook + 抑制名单)
    • Postmark 事务性邮件模式(模板 + 消息流)

评论

支持 Markdown