Symfony Mailer 集成 Resend:从 SMTP/API 双通道配置到 Webhook 事件解析

原创2026-10-01 19:06:201,879 阅读
文章标签:后端Web框架

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 从发送到回传提供了闭环能力,相关实现细节均可在仓库源码与测试用例中找到对应证据。

登录后查看全文
symfony