要在 localhost 测试 Postmark webhook,请先运行本地处理程序,通过 npx portpreview PORT 暴露端口,再把生成的 HTTPS 路径登记到正确的 Postmark Message Stream。使用 Basic Authentication 或自定义密钥请求头保护接口,校验每个 JSON payload,幂等持久化后尽快返回 HTTP 200。
Postmark 会向 Webhook 发送什么
邮件事件发生后,Postmark 会发出 HTTP POST。Outbound Message Stream 可报告送达、退信、打开、点击、垃圾邮件投诉和订阅变更;Inbound Message Stream 则提交解析后的来信。字段随事件而变,应按 RecordType 分流并校验对应 schema。Delivery 只表示目标邮件服务器已接收,不代表进入收件箱;Bounce 会包含 Type、TypeCode、Inactive 和 CanActivate 等分类字段。 官方 Webhook 概览 · 退信 Webhook 参考
构建一个小型本地 Express 接收器
示例在 3000 端口运行 Express,先检查 Basic Auth,再接受 JSON、校验最小信封并持久写入去重键,写入成功后才确认。请替换为自己的事务或持久队列。限制请求大小;入站邮件可能含个人信息、登录链接、附件和机密内容,不要完整记录。
import crypto from 'node:crypto';
import express from 'express';
const app = express();
app.use('/webhooks/postmark', express.json({ limit: '2mb' }));
function safeEqual(actual, expected) {
const a = Buffer.from(actual);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
function authorized(req) {
const value = req.get('authorization') ?? '';
if (!value.startsWith('Basic ')) return false;
const decoded = Buffer.from(value.slice(6), 'base64').toString('utf8');
const separator = decoded.indexOf(':');
if (separator < 0) return false;
return safeEqual(decoded.slice(0, separator), process.env.POSTMARK_WEBHOOK_USER ?? '') &&
safeEqual(decoded.slice(separator + 1), process.env.POSTMARK_WEBHOOK_PASSWORD ?? '');
}
app.post('/webhooks/postmark', async (req, res) => {
if (!authorized(req)) return res.sendStatus(401);
const event = req.body;
if (typeof event?.RecordType !== 'string' ||
typeof event?.MessageID !== 'string') {
return res.status(400).json({ error: 'Invalid Postmark event' });
}
const deliveryKey = `${event.RecordType}:${event.MessageID}:${event.ID ?? ''}`;
await saveWebhookOnce({ provider: 'postmark', deliveryKey, event });
res.sendStatus(200);
});
app.listen(3000);
通过公网 HTTPS URL 暴露 localhost
启动接收器并确认本地端口,在另一个终端运行 npx portpreview 3000,将路由拼到生成的地址上,例如 https://YOUR-TUNNEL.portpreview.dev/webhooks/postmark。测试期间保持隧道运行。隧道只解决公网访问和可信 HTTPS,并不会认证 Postmark,因此仍须做应用层认证与 payload 校验。 localhost 隧道安全指南
配置正确的 Postmark Webhook
对出站事件,在 Postmark 中选对 Server 和 Message Stream,在 Webhooks 中添加 URL,只启用程序支持的触发器;入站邮件应设置 Inbound Message Stream 自己的 inbound URL。Webhooks API 可用代码配置 HttpAuth、可选 HttpHeaders 和触发器。管理接口使用的 X-Postmark-Server-Token 不会作为接收端凭据发送。 Webhooks API
Postmark 身份验证不是加密签名
Postmark 当前不支持 Webhook HMAC 签名验证,没有用来重算原始 body 摘要的签名密钥,也没有 X-Postmark-Signature。HTTP Basic Authentication、IP 白名单和自定义密钥请求头只能证明请求方持有共享凭据,并未把凭据与正文加密绑定。请配合 HTTPS 和 payload 校验;IP 段必须使用最新公布值。优先用 HttpAuth,如采用 https://username:[email protected]/path,应使用独立高强度凭据、正确编码并防止 URL 泄露。绝不能复用 Server API token。
按类型处理送达和退信事件
把业务逻辑移出 HTTP 请求路径,由 worker 幂等处理已保存事件。不要只凭字段名决定永久抑制,应遵循 Postmark 当前退信分类和自身发送策略。垃圾邮件投诉、订阅变更不是 Bounce,打开与点击也可能多次发生。
async function processPostmarkEvent(event) {
switch (event.RecordType) {
case 'Delivery':
await markAcceptedByRecipientServer({
messageId: event.MessageID,
deliveredAt: event.DeliveredAt
});
break;
case 'Bounce':
await recordBounce({
bounceId: String(event.ID),
messageId: event.MessageID,
type: event.Type,
inactive: event.Inactive,
canActivate: event.CanActivate
});
break;
default:
await recordUnhandledPostmarkType(event.RecordType);
}
}
为重试和重复投递做好设计
未收到 HTTP 200 时 Postmark 会重试;Bounce 和 Inbound 的序列较长,Click、Open、Delivery 和订阅变更较短,403 会停止重试。数据库 commit 后的 timeout 仍可能形成合法重复。给稳定键加唯一约束:以 MessageID 为基础,在混合端点中再加入 RecordType 和退信 ID 等事件标识。完成最小持久交接后返回 200,外部 API 工作异步执行。 Webhook 重试与幂等性
安全测试真实事件处理
先用 curl 发送合成 POST,检查路由、认证、校验和存储;再向自己控制的地址发送普通邮件测试 Delivery。测试 Bounce 时使用 Postmark 文档提供的设施,包括适用时的 black-hole test domain,不要反复向编造地址发送。记录原始发送返回的 MessageID,将脱敏 fixture 投递两次并断言副作用只发生一次。
排查常见的 Postmark Webhook 故障
请求未到达端点
没有请求时检查隧道进程、完整 URL 和端口。全部 401 时核对环境变量、重启应用,并检查反向代理是否移除了 Authorization,切勿打印其值。处理成功仍重试时,查看公网端点实际 status 和 latency,确保明确返回 200 且写入后不会变成 500。样例不匹配时核对 RecordType、触发器以及 Inbound/Outbound,拒绝缺失必填字段并容忍文档允许的新字段。
所有请求均返回 HTTP 401
处理成功后仍在重试
没有请求时检查隧道进程、完整 URL 和端口。全部 401 时核对环境变量、重启应用,并检查反向代理是否移除了 Authorization,切勿打印其值。处理成功仍重试时,查看公网端点实际 status 和 latency,确保明确返回 200 且写入后不会变成 500。样例不匹配时核对 RecordType、触发器以及 Inbound/Outbound,拒绝缺失必填字段并容忍文档允许的新字段。
Payload 与样例不符
没有请求时检查隧道进程、完整 URL 和端口。全部 401 时核对环境变量、重启应用,并检查反向代理是否移除了 Authorization,切勿打印其值。处理成功仍重试时,查看公网端点实际 status 和 latency,确保明确返回 200 且写入后不会变成 500。样例不匹配时核对 RecordType、触发器以及 Inbound/Outbound,拒绝缺失必填字段并容忍文档允许的新字段。
生产环境安全检查清单
使用 HTTPS 与独立高强度 Basic Auth 或密钥请求头;分离 API token、Webhook 凭据和生产密钥;测试后轮换并删除旧 URL;校验 content type、大小、事件类型、标识和必填字段;日志中脱敏邮件数据和密钥;实施最小权限;监控认证失败、处理延迟、重复率和 dead letter。
