Symfony Mailer 集成 Resend:从 SMTP/API 双通道配置到 Webhook 事件解析
Symfony Mailer 集成 Resend:从 SMTP/API 双通道配置到 Webhook 事件解析
本文以 Symfony 官方仓库中的 Resend Mailer Bridge(src/Symfony/Component/Mailer/Bridge/Resend)为对象,讲解如何在 Symfony 应用中通过 MAILER_DSN 接入 Resend 邮件服务,覆盖 SMTP 与 API 两种发送通道的配置差异、底层传输实现原理、远程模板(RemoteTemplateEmail)用法,以及配套的 Webhook 签名校验与投递事件解析。读完本文,你可以直接完成 Resend 的 DSN 配置、写出可运行的邮件发送代码,并了解事件回传如何被消费。
一、Resend Bridge 是什么
Resend Bridge 是 Symfony Mailer 的官方邮件桥接组件,为 Resend)。
从 CHANGELOG.md 可以看到它的演进历史:
- 7.1:新增该 Bridge,提供最基础的 Resend 集成;
- 8.2:为
ResendApiTransport增加RemoteTemplateEmail支持,允许直接引用 Resend 平台上的远程模板。
桥接组件的核心价值在于:开发者不需要关心 Resend 的 HTTP 接口细节或 SMTP 握手过程,只需配置一行 DSN,然后用 Symfony Mailer 的标准 MailerInterface 发送邮件,底层由 ResendTransportFactory 自动实例化对应传输器。
二、环境配置与依赖要求
在使用之前,需要先安装依赖。当前仓库中 composer.json 声明的约束为:
| 依赖项 | 版本要求 |
|---|---|
| PHP | >= 8.4.1 |
symfony/mailer |
^8.2 |
symfony/http-client(dev) |
^7.4 | ^8.0 |
symfony/http-foundation(dev) |
^7.4 | ^8.0 |
symfony/webhook(dev) |
^7.4 | ^8.0 |
实际使用时通过 Composer 安装即可:
composer require symfony/resend-mailer
其中 API 通道依赖 symfony/http-client 发起 REST 请求,Webhook 场景则依赖 symfony/webhook 组件处理事件回传(这两者在 dev 依赖中声明,说明它们服务于测试与可选功能)。
三、DSN 配置:SMTP 与 API 两种通道
Resend Bridge 的核心配置集中在环境变量 MAILER_DSN。原文档给出的两种配置方式如下:
# SMTP
MAILER_DSN=resend+smtp://resend:API_KEY@default
# API
MAILER_DSN=resend+api://API_KEY@default
其中 API_KEY 是你在 Resend 后台创建的 API Key。
对照 ResendTransportFactory 的源码,这两种 DSN 的解析逻辑为:
resend+smtp://resend:API_KEY@default:取API_KEY作为密码,构造ResendSmtpTransport;resend+api://API_KEY@default:取API_KEY作为用户名,构造ResendApiTransport,当 host 为default时置空,最终回落到默认 API 端点。
工厂支持的 scheme 有三种:resend、resend+smtp、resend+api。其中 resend 与 resend+smtp 等价,都会走 SMTP 通道;若传入 resend+foo 之类的未知 scheme,会抛出 UnsupportedSchemeException,异常消息会列出全部受支持 scheme(见 ResendTransportFactoryTest 中的 unsupportedSchemeProvider)。同时该测试还验证了:resend+smtp 缺少密码、resend+api 缺少用户时会判定 DSN 不完整。
注意:
resend+api的API_KEY必须放在 DSN 的 userinfo(用户名)位置,resend+smtp则放在密码位置,两者位置不同,配置时不要搞混。
四、SMTP 通道:ResendSmtpTransport
resend+smtp(或 resend)scheme 会创建 ResendSmtpTransport。该类继承自 Mailer 的 EsmtpTransport,构造时硬编码连接参数:
parent::__construct('smtp.resend.com', 465, true, $dispatcher, $logger);
$this->setUsername('resend');
$this->setPassword($password);
即:
- 服务器:
smtp.resend.com - 端口:
465(SSL/TLS 加密) - 用户名:固定为
resend - 密码:DSN 中配置的
API_KEY
因此 SMTP 通道的完整语义是:以 resend 为用户名、API_KEY 为密码,通过 TLS 465 端口连接 Resend 的 SMTP 服务器。这也是 DSN 写成 resend+smtp://resend:API_KEY@default 的原因——resend 是固定用户名,冒号后才是 API Key。
从测试 ResendTransportFactoryTest 的 createProvider 可见,即使 DSN 中显式指定端口 465,工厂也只会读取密码构造 ResendSmtpTransport,端口参数不影响 SMTP 实例(连接参数由类内部硬编码)。
五、API 通道:ResendApiTransport 的请求细节
resend+api scheme 创建 ResendApiTransport,它继承 AbstractApiTransport 并通过 HTTP 客户端直接调用 Resend REST API。核心流程如下:
5.1 请求端点与鉴权
$response = $this->client->request('POST', 'https://'.$this->getEndpoint().'/emails', [
'json' => $this->getPayload($email, $envelope),
'headers' => [
'Authorization' => 'Bearer '.$this->apiKey,
],
]);
- 请求方法:
POST - 端点:
https://{host}:{port}/emails,其中 host 默认api.resend.com,可在 DSN 中通过 host/port 覆盖(getEndpoint()会拼接自定义 host 与 port,见 ResendApiTransport.php) - 鉴权:请求头
Authorization: Bearer <API_KEY>
5.2 Payload 字段映射
getPayload() 方法把 Symfony Email 对象转换为 Resend API 所需的 JSON 结构,映射关系如下:
| Symfony 邮件属性 | Resend API 字段 |
|---|---|
| 发件人(Envelope sender) | from |
| 收件人(to) | to(数组) |
| 主题 | subject |
| 文本正文 | text |
| HTML 正文 | html |
| 回复地址 | reply_to |
| 抄送 | cc |
| 密送 | bcc |
| 附件 | attachments(filename + content) |
| 自定义 Header | headers |
| Resend 标签 | tags |
| 远程模板 | template(id + variables) |
地址格式化为 "姓名 <邮箱>" 的形式;收件人数量上限为 50,超过会抛出 InvalidArgumentException(提示 "Resend API does not support more than 50 recipients.")。
5.3 自定义 Header 与 Tag 的处理
邮件中除 from/to/cc/bcc/subject/reply_to 之外的自定义 Header 会被收集到 headers 字段,而 TagHeader 则被转换为 tags 数组。测试 ResendApiTransportTest 的 testCustomHeader 验证了以下行为:
MetadataHeader('custom', $json)会以X-Metadata-custom的 Header 名进入headers;TagHeader('TagInHeaders')生成tags: [['X-Tag' => 'TagInHeaders']];- 普通文本 Header(如
x-custom-id、foo)原样进入headers; - 带参数的 Header(如
x-custom-params)会序列化为custom; param1=foo; param2=bar形式。
5.4 响应处理与 MessageId
请求返回后,传输器校验 HTTP 状态码:
- 返回
200:从响应体的id字段取出邮件 ID,通过$sentMessage->setMessageId($result['id'])写入SentMessage,这会让MessageEvent与日志中的 MessageId 保持与 Resend 平台一致; - 非
200:抛出HttpTransportException,异常信息包含 Resend 返回的响应正文与状态码(如Unable to send an email: {"message":"i'm a teapot"} (code 418).,见测试testSendThrowsForErrorResponse); - 网络层失败:抛出
HttpTransportException,提示 "Could not reach the remote Resend server."。
5.5 IDN 域名自动转码
测试 testSendForIdnDomains 印证了一个细节:国际化域名(IDN)会被转换为 ACE(ASCII Compatible Encoding)形式再发送,例如 kältetechnik-xyz.de 转成 xn--kltetechnik-xyz-0kb.de,否则 Resend API 会以 400 拒绝。这一转换由 Mime 组件的地址编码机制完成,使用 Resend Bridge 时无需手工处理。
六、远程模板:RemoteTemplateEmail
8.2 版本为 API 通道新增了远程模板支持。通过 RemoteTemplateEmail,可以直接引用 Resend 平台上已经建好的模板,而不必在代码里拼 HTML:
use Symfony\Component\Mailer\RemoteTemplateEmail;
$email = (new RemoteTemplateEmail())
->template('tpl_123', ['firstName' => 'Fabien'])
// 可选:仍可覆盖 subject
->subject('Hello!')
;
底层实现(见 ResendApiTransport.php)的逻辑是:
- 若邮件是
RemoteTemplateEmail且未显式设置 subject,则 payload 中不包含subject(主题交给模板定义); - 模板以
template: {id: 'tpl_123', variables: {...}}形式进入 payload; - 使用远程模板时,
text与html字段也不再发送(测试testRemoteTemplate验证了subject、text、html均不出现在 payload 中)。
如果既设置了远程模板又显式给了 subject,则两者同时生效(测试 testRemoteTemplateWithSubject 验证 payload 同时包含 subject: 'Hello!' 与 template: {id: 'tpl_123'})。
七、发送邮件:完整用法示例
配置好 DSN 后,即可用标准的 Symfony Mailer 发送。以下示例覆盖 API 通道的常用能力:
use Symfony\Component\Mailer\Mailer;
use Symfony\Component\Mailer\Transport;
use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Email;
use Symfony\Component\Mime\Part\DataPart;
$transport = Transport::fromDsn($_ENV['MAILER_DSN']);
$mailer = new Mailer($transport);
$email = (new Email())
->from(new Address('noreply@example.com', 'Example App'))
->to('user@example.com')
->replyTo('support@example.com')
->subject('Welcome!')
->text('Hello, welcome aboard.')
->html('<h1>Welcome!</h1><p>Hello, welcome aboard.</p>')
->addCc('team@example.com')
->addPart(new DataPart('body', 'report.txt'))
;
// 可选:添加自定义 Header 与 Resend Tag
$email->getHeaders()
->addTextHeader('X-Request-Id', 'req-123')
;
$mailer->send($email);
对应 SMTP 通道,只需将 DSN 换成 MAILER_DSN=resend+smtp://resend:API_KEY@default,其余代码不变——这是 Symfony Mailer Bridge 设计的一致体验。
在框架项目中,通常把 DSN 写入 .env:
MAILER_DSN=resend+api://re_xxxxxxxxxxxxxxxx@default
然后在服务中注入 MailerInterface 即可发送。
八、Webhook 事件回传:签名校验与事件转换
除了发送,Resend Bridge 还提供了 Webhook 支持,用于接收 Resend 平台回传的邮件状态事件(投递、打开、点击、退信等)。该能力由 ResendRequestParser 与 ResendPayloadConverter 两个类协作完成,配合 Symfony 的 symfony/webhook 组件使用。
8.1 请求匹配
getRequestMatcher() 要求回传请求必须同时满足:
- HTTP 方法为
POST; - 请求体是 JSON;
- 携带
svix-id、svix-timestamp、svix-signature三个 Header。
不满足条件时,请求会在解析前被拒绝。
8.2 签名校验(Svix 规范)
Resend 的 Webhook 签名遵循 Svix 规范,validateSignature() 的实现要点:
- 时间窗口:
svix-timestamp与当前时间差超过 300 秒(TIMESTAMP_TOLERANCE = 300)即拒绝,返回 "Timestamp is outside the allowed time window."; - HMAC 计算:以
"{svix-id}.{svix-timestamp}.{payload}"为输入、whsec_前缀的 secret(base64 解码后)为密钥,做hash_hmac('sha256', ...),再 base64 编码,得到v1,<signature>; - 版本匹配:遍历请求头中的多个签名,仅接受
v1版本,并使用hash_equals做常量时间比较,防止时序攻击; - secret 格式:支持带
whsec_前缀的 base64 secret(见decodeSecret())。
测试 ResendRequestParserTest 覆盖了多种攻击与边界场景:时间戳偏移 ±301 秒被拒、±300 秒被接受;伪造签名、空签名、无版本、未知版本(v2)均被拒;缺失签名 Header 直接不匹配请求。
8.3 事件类型到 Symfony 事件的映射
通过签名校验后,ResendPayloadConverter 把 Resend 的原始事件转换为 Symfony RemoteEvent 组件的事件对象。支持的映射关系:
| Resend 事件类型 | Symfony 事件 | 类别 |
|---|---|---|
email.sent |
MailerDeliveryEvent::RECEIVED |
投递 |
email.delivered |
MailerDeliveryEvent::DELIVERED |
投递 |
email.delivery_delayed |
MailerDeliveryEvent::DEFERRED |
投递 |
email.bounced |
MailerDeliveryEvent::BOUNCE |
投递 |
email.failed |
MailerDeliveryEvent::DROPPED |
投递 |
email.suppressed |
MailerDeliveryEvent::DROPPED |
投递 |
email.clicked |
MailerEngagementEvent::CLICK |
互动 |
email.opened |
MailerEngagementEvent::OPEN |
互动 |
email.complained |
MailerEngagementEvent::SPAM |
互动 |
转换后的 MailerDeliveryEvent / MailerEngagementEvent 会携带:
- 事件时间(从
created_at解析,格式Y-m-d\TH:i:s.uP); - 收件人邮箱(
data.to以逗号拼接); - 完整元数据(
data字段整体作为 metadata); - 退信/丢弃原因(
email.failed取data.failed.reason,email.suppressed取data.suppressed.message)。
未知事件类型或非法时间格式会抛出 ParseException,最终由 ResendRequestParser 转成 RejectWebhookException(HTTP 406)返回给 Resend 平台。Webhook 的测试夹具位于 Tests/Webhook/Fixtures(如 sent.json),可作为理解事件 payload 结构的参考样例。
九、配置参数速查
| DSN 组成部分 | 取值 | 说明 |
|---|---|---|
| scheme | resend / resend+smtp |
走 SMTP 通道(smtp.resend.com:465 TLS) |
| scheme | resend+api |
走 HTTP API 通道(api.resend.com) |
| userinfo(SMTP) | resend:API_KEY |
用户名固定 resend,冒号后为 API Key |
| userinfo(API) | API_KEY |
API Key 放在用户名位置 |
| host | default |
使用内置默认端点 |
| host | 自定义域名 | 仅 API 通道生效,覆盖 API 端点 |
十、常见问题与排查
- DSN 解析失败:检查 scheme 是否为
resend、resend+smtp、resend+api三者之一;resend+smtp必须提供密码,resend+api必须提供用户名,否则会被判定为不完整 DSN。 - 发送被拒(400):优先检查发件域名是否已在 Resend 完成域名验证;若收件人/发件人含国际化域名,Bridge 会自动转码为 ACE 形式,无需手工处理。
- Webhook 一直被拒(406):确认 secret 是否带
whsec_前缀、签名 Header 是否齐全(svix-id、svix-timestamp、svix-signature)、服务器时钟与 Resend 是否同步(时间容差 300 秒)。 - 想切换通道:仅修改
MAILER_DSN即可在 SMTP 与 API 之间切换,业务代码无需改动。
十一、小结
Resend Bridge 是 Symfony Mailer 生态中接入 Resend 服务的官方通道:DSN 一行配置即可切换 SMTP 与 API 两种传输实现;API 通道在底层完成了 payload 映射、50 收件人上限校验、IDN 转码、远程模板与自定义 Header/Tag 的序列化;配套的 Webhook 解析器则基于 Svix 签名规范做安全校验,并将投递/互动事件标准化为 Symfony RemoteEvent。对于需要在 Symfony 项目中使用 Resend 的团队,本 Bridge 从发送到回传提供了闭环能力,相关实现细节均可在仓库源码与测试用例中找到对应证据。