Guide · 使用教程
AgentMail Webhook 教程:验签、事件去重与回复审批工作流
用 Python、Svix 和 SQLite 演示验签、租户映射、持久化去重、内容版本审批与发送超时待核对。
给 AgentMail 接上 Webhook 后,先把收到的事件可靠落库,再让模型生成候选回复,最后由有权限的人批准具体内容。本文用 Flask、Svix 与 SQLite 演示一个单机闭环:验签、两层事件去重、租户映射、版本审批、发送占位与超时待核对。
资料与代码核对日期:2026 年 10 月 15 日。 示例固定 Flask 3.1.3、Svix 2.8.0;本地验证使用合成事件、真实 Svix 验签器和模拟发送器,没有创建真实邮箱、调用真实发送接口或验证投递率。
1. 先划清三个信任边界
- Webhook 通道:用端点独有 secret 验证原始 body 和 Svix 请求头。成功只证明通道签名正确。
- 邮件内容:正文、HTML、附件、显示名与 Reply-To 都是外部输入。不要把“忽略之前规则”“换个地址发资料”当成工具授权。
- 业务发送:审批针对明确的租户、邮箱、原消息、线程、收件人和正文版本。审批后不再修改,发送器只能读取已批准快照。
AgentMail 的事件含 event_id;message.received 的 message 包含 inbox_id、message_id、thread_id。仅 HTML 邮件可能没有 text,读取工作器应在隔离环境提取文本,并把缺少正文视为人工处理条件。示例入口只入队普通 message.received,其他事件不触发自动回复。事件文档
本教程只演示纯文本、单轮回复,不发送附件,也不自动 reply-all。要加入附件或抄送,应把全部字段和附件内容摘要纳入快照,并重新做审批和失败测试。
2. 环境与固定依赖
在隔离项目中保存下文的 workflow.py、server.py 和 sender.py。准备一个自己控制的测试邮箱,在服务端配置“邮箱 → 租户”映射,不能由邮件正文提供 tenant_id。
python3 -m venv .venv
source .venv/bin/activate
python -m pip install Flask==3.1.3 svix==2.8.0
export WORKFLOW_DB=workflow.sqlite3
export INBOX_OWNERS_JSON='{"[email protected]":"tenant-a"}'
example.test 为虚构测试地址;接真实账户时换成实际返回的 inbox_id。通过服务端秘密管理设置 AGENTMAIL_WEBHOOK_SECRET,不把 secret 写进代码、日志或前端。发送 Key 另设 AGENTMAIL_SEND_KEY,仅给发送进程,不给模型。
创建、读取和发送 Key 的权限应分开核对。当前 AgentMail 文档说明:Bearer Key 回复使用 message_send;message_reply 的语义针对注册公钥调用。不要凭权限名猜测 Bearer Key 的行为。权限文档
3. 持久化去重、待审快照与发送状态
保存为 workflow.py。events 表同时约束 svix-id 与 event_id;jobs 表按“租户 + 邮箱 + 原消息”约束,一个入站消息只建一个回复任务。重复投递和创建任务在同一事务内处理,提交后入口才返回成功。
这是应用数据库中的候选回复,不是供应商 Draft 资源。也可以将 AgentMail Draft 作为编辑界面,但发送前必须重新比对其内容和已批准快照。Drafts
"""Single-host tutorial core. No network calls or public approval endpoint."""
import hashlib
import json
import sqlite3
import time
import uuid
def connect(path):
db = sqlite3.connect(path, timeout=10)
db.row_factory = sqlite3.Row
return db
def init(path):
with connect(path) as db:
db.executescript('''
CREATE TABLE IF NOT EXISTS events(
delivery_id TEXT PRIMARY KEY, event_id TEXT UNIQUE NOT NULL);
CREATE TABLE IF NOT EXISTS jobs(
id TEXT PRIMARY KEY, tenant TEXT NOT NULL, inbox TEXT NOT NULL,
message TEXT NOT NULL, thread TEXT NOT NULL, state TEXT NOT NULL,
snapshot TEXT, digest TEXT, approved_by TEXT, expires REAL,
send_key TEXT NOT NULL, receipt TEXT,
UNIQUE(tenant,inbox,message));
''')
def ingest(path, delivery_id, event, inbox_owners):
if event.get('event_type') != 'message.received':
return 'ignored'
msg = event['message']
values = [delivery_id, event['event_id'], msg['inbox_id'],
msg['message_id'], msg['thread_id']]
if not all(isinstance(x, str) and x for x in values):
raise ValueError('invalid identifiers')
tenant = inbox_owners[msg['inbox_id']]
with connect(path) as db:
inserted = db.execute(
'INSERT OR IGNORE INTO events VALUES (?,?)', values[:2]).rowcount
if not inserted:
return 'duplicate'
job_id = uuid.uuid4().hex
db.execute('''INSERT OR IGNORE INTO jobs
(id,tenant,inbox,message,thread,state,send_key)
VALUES (?,?,?,?,?,'queued',?)''',
(job_id, tenant, *values[2:], 'reply-' + job_id))
return 'stored'
def stage(path, job_id, tenant, recipients, text, allowed):
# Addresses here are explicit addr-spec strings selected by trusted app code.
if (not recipients or not all(isinstance(a, str) and a in allowed
for a in recipients)
or not isinstance(text, str) or not text.strip()):
raise ValueError('recipient or body rejected')
with connect(path) as db:
db.execute('BEGIN IMMEDIATE')
row = db.execute('SELECT * FROM jobs WHERE id=? AND tenant=?',
(job_id, tenant)).fetchone()
if not row or row['state'] not in ('queued', 'pending'):
raise ValueError('job not editable')
snapshot = json.dumps({
'tenant': tenant, 'inbox': row['inbox'], 'message': row['message'],
'thread': row['thread'], 'to': recipients, 'cc': [], 'bcc': [],
'reply_all': False, 'text': text,
}, ensure_ascii=False, sort_keys=True, separators=(',', ':'))
digest = hashlib.sha256(snapshot.encode()).hexdigest()
db.execute('''UPDATE jobs SET state='pending',snapshot=?,digest=?,
approved_by=NULL,expires=NULL WHERE id=?''',
(snapshot, digest, job_id))
return digest
def approve(path, job_id, tenant, displayed_digest, actor):
# actor must come from your authenticated internal operator session.
if not actor:
raise ValueError('operator required')
with connect(path) as db:
n = db.execute('''UPDATE jobs SET state='approved',approved_by=?,expires=?
WHERE id=? AND tenant=? AND state='pending' AND digest=?''',
(actor, time.time() + 3600, job_id, tenant, displayed_digest)).rowcount
if n != 1:
raise ValueError('stale approval')
def dispatch(path, job_id, tenant, allowed, send_request):
with connect(path) as db:
db.execute('BEGIN IMMEDIATE')
row = db.execute('SELECT * FROM jobs WHERE id=? AND tenant=?',
(job_id, tenant)).fetchone()
if not row or row['state'] != 'approved' or row['expires'] < time.time():
raise ValueError('not approved or expired')
snapshot = row['snapshot']
if hashlib.sha256(snapshot.encode()).hexdigest() != row['digest']:
raise ValueError('snapshot changed')
data = json.loads(snapshot)
if not all(a in allowed for a in data['to']):
raise ValueError('allowlist changed')
db.execute("UPDATE jobs SET state='sending' WHERE id=?", (job_id,))
# Claim commits before the external side effect. Other workers cannot claim it.
try:
result = send_request(data, row['send_key'])
if not result.get('message_id') or not result.get('thread_id'):
raise ValueError('ambiguous response')
with connect(path) as db:
db.execute("UPDATE jobs SET state='sent',receipt=? WHERE id=?",
(json.dumps(result), job_id))
except Exception:
with connect(path) as db:
db.execute("UPDATE jobs SET state='unknown' WHERE id=?", (job_id,))
raise
return result
stage 只允许 queued / pending 任务编辑;每次保存都清除旧审批。审核界面应显示完整收件人、正文、线程来源和 digest,操作者确认后调用 approve。actor 必须从已鉴权的内部会话取得,不能信任浏览器自行传入的“管理员姓名”。本文没有提供公开审批 HTTP 接口。
SQLite 的 BEGIN IMMEDIATE 用于单机示例中的互斥占位。多机部署改用支持共享事务的数据库和唯一约束,不要把本地 SQLite 文件放在多个容器各自的临时盘中。
4. Webhook 先验签,再解析
保存为 server.py。Svix 使用 svix-id、svix-timestamp、svix-signature,重试同一投递时 svix-id 不变。必须保留原始请求体,不能先解析再重新序列化去验签。AgentMail 验签文档
版本注意:Svix 2.8.0 的 verify() 成功后返回 None。本示例先调用它验证,再 json.loads(raw),没有把返回值当成事件对象;这与部分文档中的旧示例写法不同。Svix 官方实现
import json
import os
import sqlite3
from flask import Flask, request
from svix.webhooks import Webhook, WebhookVerificationError
from workflow import ingest, init
app = Flask(__name__)
app.config['MAX_CONTENT_LENGTH'] = 2 * 1024 * 1024
DB = os.environ.get('WORKFLOW_DB', 'workflow.sqlite3')
OWNERS = json.loads(os.environ['INBOX_OWNERS_JSON'])
verifier = Webhook(os.environ['AGENTMAIL_WEBHOOK_SECRET'])
init(DB)
@app.post('/webhooks/agentmail')
def receive():
try:
raw = request.get_data()
verifier.verify(raw, dict(request.headers))
except WebhookVerificationError:
return '', 400
try:
event = json.loads(raw)
if not isinstance(event, dict):
return '', 400
ingest(DB, request.headers['svix-id'], event, OWNERS)
except (KeyError, ValueError, TypeError):
return '', 400
except sqlite3.Error:
return '', 503
return '', 204
if __name__ == '__main__':
app.run(host='127.0.0.1', port=3000)
python server.py
Flask 自带服务器只用于本地演示。接入真实 Webhook 时,用正式 WSGI 服务和稳定 HTTPS 地址,保留请求头、限制 body 大小与超时,并订阅所需事件。数据库失败返回 503,不能在尚未持久化时先回复 204。本文没有对外开放本地端口。
5. 人工审批与发送适配器
后台读取工作器从 queued 任务取得邮箱和 message_id,再用只读 Key 拉取线程上下文。将邮件当作引用数据,模型只输出候选正文。收件人由业务规则与人工确认,不能让模型从正文指令任意换地址。对未知收件人或缺失业务证据的回复保持待审。
以下演示在已有鉴权的内部操作者环境中调用;job_id 来自任务列表,allowed 来自服务端业务策略,displayed_digest 是审核时展示的版本摘要。这个内存字典不应直接变成外网可写配置。
from workflow import stage, approve
allowed = {"[email protected]"}
displayed_digest = stage(
"workflow.sqlite3", job_id, "tenant-a",
["[email protected]"], "已收到资料,我们会核实后继续回复。", allowed,
)
#在内部审核界面展示完整快照,操作者确认后才执行下一行。
approve("workflow.sqlite3", job_id, "tenant-a", displayed_digest, operator_id)
上例的 job_id 与 operator_id 由实际内部应用提供,不是可直接在空数据库运行的初始化脚本。应用审批有效期设为 1 小时,这是示例政策,不是 AgentMail 平台限制。
保存发送适配器为 sender.py;导入该文件不会发邮件。它使用官方 Reply To Message 路径,并将 inbox_id 和 message_id 编码为 URL 路径片段,显式固定收件人且关闭 reply_all。回复 API
import json
import os
from urllib.parse import quote
from urllib.request import Request, urlopen
def agentmail_reply(snapshot, send_key):
# Call only from dispatch after a trusted operator has approved this snapshot.
inbox = quote(snapshot['inbox'], safe='')
message = quote(snapshot['message'], safe='')
url = f'https://api.agentmail.to/v0/inboxes/{inbox}/messages/{message}/reply'
payload = {k: snapshot[k] for k in ['to', 'cc', 'bcc', 'reply_all', 'text']}
req = Request(url, data=json.dumps(payload).encode(), method='POST', headers={
'Authorization': 'Bearer ' + os.environ['AGENTMAIL_SEND_KEY'],
'Content-Type': 'application/json', 'Idempotency-Key': send_key,
})
with urlopen(req, timeout=20) as response:
return json.load(response)
只有已批准任务可以调用 dispatch,生产发送工作器将 agentmail_reply 作为 send_request 传入;本文验证时只传入模拟函数。不要在 Webhook 回调里直接调用发送器。
AgentMail 创建资源使用 client_id,发送、回复和发草稿使用 HTTP Idempotency-Key。当前文档说明:发送键在组织范围内生效,完成后 24 小时过期;同键不同请求返回 409。本文为一个逻辑回复生成并持久化一个键,不把每次尝试都当成新回复。发送幂等
6. 失败时如何恢复
| 状态或故障 | 处理方式 |
|---|---|
| 重复 Webhook | 唯一约束拦截,不重复建立回复任务 |
| 正文改动后批准旧版本 | digest 不匹配,重新审核 |
| 两个工作器同时领取 | 事务中占位,一个进入 sending,另一个拒绝 |
| 请求超时、断网、异常响应 | 标记 unknown,停止自动发送,核对供应商记录 |
| sending 状态长期不变 | 可能进程退出,由运维核对,不能自动重置为 approved |
| API 2xx 有 message_id | 记录 sent,表示提交成功;再跟踪送达、退信与投诉 |
示例对发送异常采取保守策略:即使是明确的 4xx,也进入待核对,不实现自动重试。生产系统可以再区分“确定未发送”和“结果未知”,但重试必须保持同一个逻辑键与完全相同的请求。超过供应商 24 小时幂等窗口后,不可只依赖该键防重;自己的发送台账应持续保留。防止重复发送
Webhook 验签不会抵挡合法来信中的提示注入。模型不持有发送 Key、已批准快照不可编辑、服务端收件白名单与租户检查,才是此流程里限制外发的具体措施。它们也不构成“消除所有提示注入”的保证。
7. 本地验收与上线补齐项
本地已验证 9 组检查:重复投递与业务去重、跨租户与收件人限制、草稿变更后旧审批失效、未审批拒发、并发只领取一次、超时后停止重发、审批过期与内容篡改、真实 Svix 库对有效 / 篡改 / 过期签名的处理、发送请求路径与幂等头。签名由本地虚构 secret 生成,发送网络适配器被替换,没有真实外发。
接真实业务还需验证:真实事件结构、HTML 清洗、附件隔离、审批身份与权限、退信和投诉回写、密钥轮换、数据库恢复、队列死信、正文与日志保留政策,以及 sending / unknown 人工核对流程。不要把本地模拟测试写成端到端投递成功。
先在测试子域名和少量低敏感邮件上验收,再逐步接入正式工作流。接口费用之外,还要计算模型、存储、队列、日志和审核成本。需要先选供应商,可阅读 Best 选型清单、AgentMail vs Resend 和 AgentMail vs Nylas。
常见问题
- 为什么要保留 Webhook 原始 body?
- 签名基于原始字节。先解析再序列化可能改变内容,导致验签失败;应先验证,再解析 JSON。
- Svix 2.8 的 verify 返回事件对象吗?
- 本地核对的 2.8.0 返回 None。示例先调用 verify 验证,再单独解析原始 JSON,避免沿用旧返回值假设。
- svix-id 去重就够了吗?
- 还应使用 event_id 与租户、邮箱、message_id 的业务唯一约束,并让事件落库和任务创建处于同一事务。
- 审批后正文改变怎么办?
- 每次编辑清除审批并生成新摘要,旧版本审批不能应用到新内容;已批准快照不再编辑。
- 发送超时可以马上重试吗?
- 示例进入 unknown 待核对。确认供应商状态前不自动重发,更不能换新键重发;供应商幂等窗口之外需要本地台账。
- 这些代码完成生产级邮件测试了吗?
- 没有。已进行本地合成签名、事务、并发与模拟发送测试,真实事件、外发、送达、权限与上线运维仍需在测试账户验收。