所有文章
通过 HTTPS 在本地测试 Postmark Webhook
Postmarkemail webhookslocalhostwebhook security

通过 HTTPS 在本地测试 Postmark Webhook

要在 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 会包含 TypeTypeCodeInactiveCanActivate 等分类字段。 官方 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

Webhook 401/403 排查指南

处理成功后仍在重试

没有请求时检查隧道进程、完整 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。

本地 Webhook 调试指南

常见问题

如何在 localhost 测试 Postmark Webhook?
运行本地处理器,用 npx portpreview PORT 暴露端口,把 Webhook 路由拼到生成的 HTTPS URL,并配置到正确的 Message Stream。
Postmark 会用 HMAC 签名 Webhook 吗?
不会。当前文档不支持 HMAC Webhook 签名。请使用 HTTPS、Basic Authentication,可选配最新 IP 白名单,并校验每个 payload。
为什么 Postmark 会重复发送同一个 Webhook?
未收到 HTTP 200 时会重试,timeout 或失败响应可能重复已处理事件。请用稳定事件键和唯一约束去重。
Postmark Delivery Webhook 代表邮件已读吗?
不代表。Delivery 仅说明目标邮件服务器已接收邮件,不能证明进入收件箱、打开或阅读。