# 从 Postmark 迁移

本页将 Postmark 的发送载荷、屏蔽列表导出和 webhook 映射到 Bird。按照[主迁移指南](/docs/guides/email/migrate)的顺序操作，并将这些映射用于步骤 1、3 和 4。

Postmark 的文档声明其 "does not currently support HMAC webhook signature verification"（查阅于 2026 年 9 月），因此步骤 4 会添加你的处理程序目前没有的验证逻辑。在规划切换之前，请先阅读[转换 webhook 事件](#转换-webhook-事件)。

## 将本页交给你的 agent

将本页粘贴到 Claude Code、Cursor 或 Codex 中。agent 会根据你的仓库逐步执行本页内容，使用它已有的任何 Bird 接口：已连接的 MCP 服务器，或已安装并登录的 CLI。

```text
I am moving an email integration from Postmark to Bird. Route through it with me.
1. Check what you already have before setting anything up. If Bird's MCP server is connected, use its tools. If the Bird CLI is installed and signed in, use that. Either one is enough, and every step below is an action you take with whichever you have. Only if neither is present, follow https://bird.com/docs/ai/set-up-your-agent.md to set one up and sign me in. Every Bird docs page serves Markdown at its own URL with `.md` appended, so fetch that rather than the HTML.
2. Read https://bird.com/docs/guides/email/migrate/postmark.md for the payload, suppression and webhook mapping, and https://bird.com/docs/guides/email/migrate.md for the order the steps go in.
3. Find and list my Postmark usage in this repository before you change anything: calls to /email and /email/withTemplate and any SDK wrappers around them, every MessageStream name I send on, the webhook handler and the URL it is registered at, and every domain I send from. Tell me the list before you edit anything.
4. Register each of those sending domains with Bird and give me the DNS records to publish, following https://bird.com/docs/guides/email/sending-domains.md. Leave every DNS record Postmark uses exactly as it is: Bird's records are published alongside them and both providers authenticate side by side until I switch traffic. Publishing DNS affects mail for the whole domain, so show me the records and let me publish them.
5. Export my suppressions and import them into Bird before any production traffic goes through Bird, so my first sends do not reach addresses that already bounced or complained. Postmark keeps suppressions per message stream, so dump GET /message-streams/{stream_id}/suppressions/dump once for every stream I send on rather than only the default outbound stream. The Bird import takes one address per request and is idempotent, so a partial re-run is safe. https://bird.com/docs/guides/email/suppressions.md has the reason taxonomy.
6. Port the send call and the webhook handler using the mapping tables on the provider page. Postmark's docs say it does not currently support HMAC webhook signature verification, so my handler probably has no signature check and adding one is new code rather than a swap. If my registered Postmark webhook URL carries HTTP Basic credentials, take them out and tell me to rotate that pair rather than reusing it on the Bird endpoint: a URL-embedded credential should be treated as exposed. https://bird.com/docs/guides/webhooks.md and https://bird.com/docs/guides/email/events.md.
7. Run my whole integration against Bird's mail sandbox before any production traffic, following https://bird.com/docs/guides/email/testing-sandbox.md. Sandbox sends run the real pipeline without reaching an inbox or touching my sending reputation.
8. Stop and ask me wherever a step needs a decision. Do not point production traffic at Bird until I have seen the sandbox results and replied with the words cut over to Bird. Retiring the Postmark path is a separate step that comes later: ask me again and wait for me to reply with the words retire the Postmark path. A reply that agrees without naming what it is authorising is not authorisation. Finish by telling me what is left that only a person can do.
```

## 映射发送调用

Postmark 将发送拆分为两个端点：`POST /email` 用于自组消息，`POST /email/withTemplate` 用于已存储的模板。我们的 [`POST /v1/email/messages`](/docs/api/reference/create-email-message) 是一个同时支持两者的端点，模板通过字段引用。

| 功能         | Postmark                                                   | Bird                                              |
| ------------ | ---------------------------------------------------------- | ------------------------------------------------- |
| 认证         | `X-Postmark-Server-Token` 请求头                           | `Authorization: Bearer`                           |
| 发件人       | `From`                                                     | `from`                                            |
| 收件人       | `To` / `Cc` / `Bcc`（逗号分隔，最多 50 个）                | `to` / `cc` / `bcc`（数组）                       |
| 主题         | `Subject`                                                  | `subject`                                         |
| 正文         | `HtmlBody` / `TextBody`                                    | `html` / `text`（至少一个）                       |
| 回复地址     | `ReplyTo`（逗号分隔）                                      | `reply_to`（数组）                                |
| 自定义请求头 | `Headers`（`Name`/`Value` 对象）                           | `headers`（string → string 对象）                 |
| 可筛选标签   | `Tag`（每条消息一个）                                      | `tags`: `{name, value}` 键值对                    |
| 往返上下文   | `Metadata`                                                 | `metadata`: 任意 JSON                             |
| 已存储模板   | `TemplateId` / `TemplateAlias` + `TemplateModel`           | `template` + `template.parameters`                |
| 打开追踪     | `TrackOpens`                                               | `track_opens`（默认 `true`）                      |
| 点击追踪     | `TrackLinks`（`None`/`HtmlAndText`/`HtmlOnly`/`TextOnly`） | `track_clicks`（布尔值，见下文）                  |
| 附件         | `Attachments`（`Name`、`Content`、`ContentType`）          | `attachments`                                     |
| 流量隔离     | `MessageStream`                                            | （无对应项，见下文）                              |
| 类别         | （无）                                                     | `category`: `marketing`（默认）或 `transactional` |
| 定时发送     | （无）                                                     | `scheduled_at`                                    |

字段上限和默认值（收件人数量、标签和元数据限制）详见[发送邮件](/docs/guides/email/sending-email)。

迁移说明：

- **收件人在 Postmark 中是字符串，在这里是数组。** `"a@x.com, b@x.com"` 变为 `["a@x.com", "b@x.com"]`。如果你的代码通过 join 列表来构建该字符串，删除 join 而不是列表本身。
- **`Tag` 是每条消息一个字符串。我们的 `tags` 是键值对，可以有多个。** 类似 `"welcome"` 的标签变为 `{"name": "category", "value": "welcome"}`。选择一个稳定的 `name`，以便你的仪表板筛选方式与 Postmark 标签统计一致。
- **`Metadata` 可直接移植，我们会将其回传。** 我们在每个 webhook 事件中连同 `email_id`/`recipient_id` 一起返回你的 `metadata`（和 `tags`），因此你的处理程序无需额外查询即可拿回上下文。
- **点击追踪在 Postmark 是枚举值，在这里是布尔值。** `TrackLinks: "None"` 对应 `track_clicks: false`；三个启用值都变为 `track_clicks: true`，因为我们不分别追踪 HTML 和纯文本部分。
- **模板合并到同一个调用中。** 没有单独的模板端点：`TemplateId` 或 `TemplateAlias` 变为 `template`（按 ID 或 slug），`TemplateModel` 变为 `template.parameters`，都在 `POST /v1/email/messages` 上。参见[使用模板发送](/docs/guides/email/sending-email#sending-with-a-template)。
- **Message stream 没有对应项，`category` 也不是。** Stream 是一个容器，拥有独立的屏蔽列表、独立的统计和独立的 webhook。我们的[类别](/docs/guides/email/categories)是一个逐消息标志，只有一个作用：决定哪些屏蔽记录和退订偏好可以阻止该消息。因为消息来自事务性 stream 就设置 `category: transactional`，大多数时候是正确的，但这是对发送目的的声明，而不是对 stream 的移植。这里不会复现按 stream 的统计或按 stream 的屏蔽作用域；使用[标签](/docs/guides/email/sending-email#tags-vs-metadata)来实现报告拆分。

## 导出屏蔽列表

Postmark 的屏蔽列表按 **message stream** 存储，因此没有单一的账户级列表可供拉取。对你仍在使用的每个 stream，导出其屏蔽列表并通过[导入流程](/docs/guides/email/migrate#3-import-suppressions)处理结果：

- `GET /message-streams/{stream_id}/suppressions/dump`

先枚举你的 stream，然后导出你仍在发送的每一个。将默认的 `outbound` stream 当作完整列表，只会带上事务性屏蔽而遗漏广播类屏蔽，你会在向已退订的人发信时才发现。`SuppressionReason` 的值为 `HardBounce`、`SpamComplaint` 和 `ManualSuppression`，分别映射到我们的 `hard_bounce`、`complaint` 和 `manual` 原因。[屏蔽列表](/docs/guides/email/suppressions)有完整的分类说明。

## 转换 webhook 事件

Postmark 为每个事件发送一种 webhook 类型，并通过载荷中的 `RecordType` 字段进行标识。

| 结果          | Postmark                     | Bird                                             |
| ------------- | ---------------------------- | ------------------------------------------------ |
| 已接受/已处理 | （API 响应）                 | `email.accepted` → `email.processed`             |
| 已投递        | `Delivery`                   | `email.delivered`                                |
| 临时失败      | `Bounce`，瞬时 `Type`        | `email.deferred`                                 |
| 永久退信      | `Bounce`，`Type: HardBounce` | `email.bounced` / `email.out_of_band_bounce`     |
| 垃圾邮件投诉  | `SpamComplaint`              | `email.complained`                               |
| 已阻止/已屏蔽 | （无）                       | `email.rejected`                                 |
| 打开          | `Open`                       | `email.opened`                                   |
| 点击          | `Click`                      | `email.clicked`                                  |
| 退订          | `SubscriptionChange`         | `email.unsubscribed` / `email.list_unsubscribed` |

两个差异决定了你的处理程序需要改动多少。

**验证是新增代码，而非替换。** Postmark 的文档声明其 "does not currently support HMAC webhook signature verification"（查阅于 2026 年 9 月），并推荐使用嵌入在注册 URL 中的 HTTP Basic 凭据（`https://<username>:<password>@example.com/webhook`）加上防火墙中的 IP 范围。我们按照 [Standard Webhooks](https://www.standardwebhooks.com) HMAC 方案对每次投递进行签名，因此你的处理程序会新增一个之前没有的验证步骤。具体方法见 [Webhooks and events](/docs/guides/webhooks)。请先做这一步：接受未签名请求的处理程序是迁移唯一不应保留的东西。

**轮换凭据，不要复用旧的。** 嵌入在 webhook URL 中的用户名和密码应视为已暴露，因为 URL 会出现在访问日志、配置导出和供应商控制台中。将它们从端点中移除，如果其他地方仍需要则签发新的一对；不要将旧凭据带到 Bird 端点上，该端点通过签名进行认证。

**Postmark 将硬退信和软退信作为一条 `Bounce` 记录报告，并附带 `Type` 字段。我们将它们报告为不同的事件。** 在退信载荷中根据 `Type` 分支的处理程序，在这里改为根据事件名称分支：瞬时失败以 `email.deferred` 到达，永久失败以 `email.bounced` 到达。Postmark 区分的每种退信类型都在其 Bounce API 参考文档中；迁移时重要的是每种类型落在拆分的哪一侧。

我们的投递事件是按收件人粒度的（`recipient_id` 与 `email_id` 并列），因此发送给三个收件人会产生三个投递结果，而非一个。

## 执行切换

按照主指南完成[域名和 DNS](/docs/guides/email/migrate#2-re-point-domains-and-dns) 以及[沙盒冒烟测试](/docs/guides/email/migrate#5-verify-in-the-sandbox-before-cutover)。两者都与服务商无关。

## 后续步骤

- [发送域名](/docs/guides/email/sending-domains)：注册、验证生命周期，以及你正在发布的 DNS 记录
- [Webhooks and events](/docs/guides/webhooks)：端点配置和 Standard Webhooks 验证
- [测试沙盒](/docs/guides/email/testing-sandbox)：在切换之前对新集成进行冒烟测试
- [屏蔽列表](/docs/guides/email/suppressions)：确认已导入的列表以及我们后续如何维护它

## Related resources

- [Getting started with email](/learn/email/getting-started-with-email) (video)
- [Email](/products/email) (product)
- [Build your first integration](/learn/paths/integration) (course)
- [Send your first email](/docs/get-started/send-your-first-email) (docs)

[Get an implementation brief](/learn/workspace?topic=email)
