Symfony Sweego Mailer Bridge 集成指南:SMTP/API 双通道发送与 Webhook 事件接收
Symfony Sweego Mailer Bridge 集成指南:SMTP/API 双通道发送与 Webhook 事件接收
本文以 Symfony 官方仓库中的 Sweego Bridge 文档 为主体,结合 Sweego 桥接组件 的源码与测试,系统讲解如何在 Symfony Mailer 中接入 Sweego 邮件服务:通过 MAILER_DSN 一行配置切换 SMTP 或 HTTP API 通道发送邮件、携带普通附件与内嵌图片,并利用框架级 Webhook 组件接收投递/退信/点击/打开等事件回调。读完本文,你将能独立完成 Sweego 通道的配置、发信与事件消费三个环节的落地。
一、桥接组件概览与适用环境
Sweego Bridge 是 Symfony Mailer 的官方第三方通道集成,代码位于 src/Symfony/Component/Mailer/Bridge/Sweego,以 symfony/sweego-mailer 包名发布(见 composer.json)。其核心职责是把 Symfony 标准的 Email 对象翻译为 Sweego 服务可识别的请求:
- SMTP 通道:
SweegoSmtpTransport基于标准 ESMTP 传输实现,复用 Symfony Mailer 的 SMTP 能力; - API 通道:
SweegoApiTransport继承AbstractApiTransport,通过 HTTP 请求调用 Sweego 的/send接口; - 事件回传:
SweegoRequestParser与SweegoPayloadConverter配合 Symfony Webhook/RemoteEvent 组件,把 Sweego 的回调请求转成标准邮件事件对象。
根据 composer.json,使用本桥接需要 PHP >= 8.4.1,并要求 symfony/mailer 版本为 ^7.4 或 ^8.0;Webhook 功能额外依赖 symfony/http-client、symfony/http-foundation 与 symfony/webhook(均需 ^7.4 或 ^8.0)。从 CHANGELOG.md 可以看出其演进:7.2 加入桥接、7.3 支持附件、8.2 支持 CC/BCC 收件人。
二、DSN 配置:一行切换 SMTP 与 API 通道
桥接的入口是 SweegoTransportFactory(源码见 SweegoTransportFactory.php),它通过解析 MAILER_DSN 的 scheme 来决定实例化哪种传输:
| Scheme | 通道 | 参数 |
|---|---|---|
sweego / sweego+smtp |
SMTP | LOGIN、PASSWORD、HOST、PORT |
sweego+api |
HTTP API | API_KEY(置于 user 段)、host 可选 |
1. SMTP 通道配置
# SMTP
MAILER_DSN=sweego+smtp://LOGIN:PASSWORD@HOST:PORT
各占位符含义:
LOGIN:你的 Sweego SMTP 登录名;PASSWORD:你的 Sweego SMTP 密码;HOST:你的 Sweego SMTP 主机名;PORT:你的 Sweego SMTP 端口。
从源码看,SweegoSmtpTransport 构造时固定以 SSL/TLS 模式连接(parent::__construct($host, $port, true, ...),第三个参数 true 表示启用 TLS),并把 LOGIN/PASSWORD 分别写入 ESMTP 的用户名与密码(见 SweegoSmtpTransport.php),因此 DSN 中的 PORT 通常应填写 465 这类 SSL 端口。该通道本质上是 EsmtpTransport 的定制子类,Symfony Mailer 的投递、日志、事件派发能力全部保留。
2. API 通道配置
# API
MAILER_DSN=sweego+api://API_KEY@default
各占位符含义:
API_KEY:你的 Sweego API Key(会被当作 DSN 的 user 段解析,传给SweegoApiTransport构造函数的$apiKey参数)。
这里有几个值得注意的解析细节(见 SweegoTransportFactory.php):
- 当 host 为字面量
default时,工厂会把 host 置空,SweegoApiTransport随即回退到默认端点api.sweego.io(见 SweegoApiTransport.php); - 你也可以在 DSN 中显式指定 host 与端口(如
sweego+api://API_KEY@example.com:8984),适用于接入自建代理或测试环境; - 从
SweegoTransportFactoryTest(SweegoTransportFactoryTest.php)可见,sweego、sweego+smtp、sweego+api三个 scheme 均受支持,而sweego+foo这类未知 scheme 会被拒绝并抛出UnsupportedSchemeException;SMTP 缺少密码或 API 缺少 Key 的 DSN 会被判定为不完整。
三、发信流程与 API 通道底层实现
1. 发送请求的构造
SweegoApiTransport::doSendApi() 会向 https://<endpoint>/send 发起 POST 请求,并在请求头中携带 Api-Key(见 SweegoApiTransport.php)。请求体(payload)的组装逻辑集中在 getPayload() 中,关键映射如下(见 SweegoApiTransport.php):
recipients:收件人列表,每个地址格式化为{ "email": ..., "name": ... };from:发件人(取自 Envelope 的 sender);subject:邮件主题;campaign-type:固定为transac(事务型邮件);channel:固定为email;cc/bcc:仅在邮件含抄送/密送时写入(8.2 起支持);reply-to:Sweego API 仅支持单个回复地址,因此只取第一个Reply-To;message-txt/message-html:分别写入文本与 HTML 正文;attachments:存在附件时写入(详见下一节);headers:邮件自定义头部(详见下文限制);provider:固定为sweego。
发信成功后,响应体中的 transaction_id 会被回填为 SentMessage 的消息 ID($sentMessage->setMessageId($result['transaction_id'])),方便后续与 Webhook 事件中的 x-transaction-id 对应追踪。
2. 请求失败与异常处理
发送逻辑对响应做了严格校验(SweegoApiTransport.php):
- 非 200 状态码(如 4xx/5xx)会抛出
HttpTransportException,错误信息包含服务端返回内容与状态码; - 响应体不是合法 JSON 时同样抛出
HttpTransportException; - 网络层面无法连接 Sweego 服务器时抛出"无法到达远端服务器"的传输异常。
SweegoApiTransportTest.php 中的 testSendThrowsForErrorResponse 用 MockHttpClient 模拟了 418 响应,断言异常信息为 Unable to send an email: {"message":"i'm a teapot"} (code 418).,可作为排查发信失败时的参考。
3. 自定义头部的限制
Sweego API 对自定义头部的数量与名称均有约束(见 SweegoApiTransport.php):
To、Cc、Bcc、From、Subject、Reply-To这六个头部会被主动剔除,因为它们已由 payload 的专用字段承载;- 其余头部原样写入,但总数不能超过 5 个,否则抛出
InvalidArgumentException; - 测试
testCustomHeader(见 SweegoApiTransportTest.php)验证了MetadataHeader、文本头、参数化头(如params; param1=foo; param2=bar)都能正确传递。
四、附件支持:普通附件与内嵌图片
桥接同时支持普通附件(attachment)与内嵌附件(inline attachment,用于在 HTML 邮件中嵌入图片)。README 给出的完整示例:
use Symfony\Component\Mime\Email;
$email = new Email();
$email
->to('to@example.com')
->from('from@example.com')
->subject('Email with attachments')
->text('Here is the text version')
->html('<p>Here is the HTML content</p>')
// Regular attachment
->attach('Hello world!', 'test.txt', 'text/plain')
// Inline attachment (embedded image)
->embed(fopen('image.jpg', 'r'), 'image.jpg', 'image/jpeg')
;
底层映射逻辑在 getAttachments() 中(见 SweegoApiTransport.php):
- 每个附件序列化为
content(文件内容字符串)、filename(从Content-Disposition头解析)与disposition(attachment / inline)三个字段; - 当 disposition 为
inline时,额外写入content_id:优先取附件的 Content-ID,未设置时回退为文件名。这意味着 HTML 中可通过cid:引用内嵌图片; - 注意
getAttachments()返回的是一个数组(索引键从 0 开始),与 Sweego API 期望的附件字段格式一致。
五、Webhook:接收投递与互动事件
1. 路由配置
在 Symfony 应用中启用 Webhook 路由(配置于 config/packages/webhook.php 或对应 YAML):
framework:
webhook:
routing:
sweego_mailer:
service: mailer.webhook.request_parser.sweego
secret: '%env(SWEEGO_WEBHOOK_SECRET)%'
要点:
service指向框架自动注册的mailer.webhook.request_parser.sweego服务(即SweegoRequestParser实例);secret从环境变量SWEEGO_WEBHOOK_SECRET读取,用于校验 Sweego 回调请求的 HMAC 签名;sweego_mailer是路由名称,也是下面事件消费者的name标识。
2. 编写事件消费者
收到回调后,框架会把载荷转换为 RemoteEvent|AbstractMailerEvent,交给注册的消费者处理:
#[AsRemoteEventConsumer(name: 'sweego_mailer')]
class SweegoMailEventConsumer implements ConsumerInterface
{
public function consume(RemoteEvent|AbstractMailerEvent $event): void
{
// your code
}
}
SweegoPayloadConverter(见 SweegoPayloadConverter.php)负责把 Sweego 的 event_type 映射为 Symfony RemoteEvent 的标准化事件类型:
Sweego event_type |
转换结果 | 事件类别 |
|---|---|---|
email_sent |
MailerDeliveryEvent::RECEIVED(已接收) |
投递事件 |
delivered |
MailerDeliveryEvent::DELIVERED(已送达) |
投递事件 |
soft-bounce / hard_bounce |
MailerDeliveryEvent::BOUNCE(退信) |
投递事件 |
list_unsub |
MailerEngagementEvent::UNSUBSCRIBE(退订) |
互动事件 |
complaint |
MailerEngagementEvent::SPAM(垃圾投诉) |
互动事件 |
email_clicked |
MailerEngagementEvent::CLICK(点击) |
互动事件 |
email_opened |
MailerEngagementEvent::OPEN(打开) |
互动事件 |
| 其他值 | 抛出 ParseException |
— |
转换后的事件会统一设置:
- 事件时间:从 payload 的
timestamp字段解析,支持Y-m-d\TH:i:s.uP(微秒精度)与标准DATE_ATOM两种格式,解析失败会抛ParseException; - 收件人邮箱:
setRecipientEmail($payload['recipient']); - 元数据:
setMetadata($payload['headers']),即 Sweego 回调中headers对象内的全部字段。
一个真实回调载荷示例(取自测试夹具 delivered.json):
{
"event_type": "delivered",
"timestamp": "2024-08-15T16:05:59+00:00",
"channel": "email",
"headers": {
"x-transaction-id": "d4fbec9d-eed9-44d5-af47-c1126467a5ca",
"x-mailer": "Sweego",
"x-campaign-type": "default"
},
"recipient": "recipient@example.com",
"domain_from": "example.org"
}
其中的 x-transaction-id 正是 API 发信时回填的 transaction_id,可据此将回执事件与发出的邮件一一关联。
3. 请求校验与安全机制
SweegoRequestParser(见 SweegoRequestParser.php)对入站请求做了多层校验:
- 请求匹配:必须是
POST方法、JSON 内容类型,且携带webhook-id、webhook-timestamp、webhook-signature三个头部; - 密钥非空:
secret为空时直接抛InvalidArgumentException(测试testRequestSignedWithAnEmptySecretIsRejected验证了这一点,见 SweegoRequestParserTest.php); - 时间窗口校验:
webhook-timestamp与服务器当前时间的偏差超过 300 秒即拒绝(返回 403),防止重放攻击; - 签名校验:按
webhook-id.webhook-timestamp.<原始请求体>拼接待签名内容,使用密钥的 Base64 解码结果作为 HMAC-SHA256 的 key,计算后与webhook-signature做常量时间比较(hash_equals),不匹配返回 403Invalid signature.; - 载荷结构校验:必须包含
event_type、timestamp、headers(含x-transaction-id)、recipient,否则返回 406Payload is malformed.; - 事件转换校验:
SweegoPayloadConverter抛出的ParseException会被转换为 406 拒绝响应。
测试 SweegoRequestParserTest.php 中的 testRejectForgedSignatureBeforeParsingThePayload 展示了伪造签名(如全 A 的签名值)会在解析载荷之前就被拒绝,验证了"先验签、后解析"的安全顺序。
六、从 DSN 到传输的完整链路小结
综合以上实现,一条 MAILER_DSN 的解析与使用链路可以概括为:
- Symfony Mailer 通过
SweegoTransportFactory::create()解析 DSN scheme,分发到SweegoSmtpTransport或SweegoApiTransport; - 发送时,
Email对象被转换为对应通道的请求:SMTP 走标准 ESMTP TLS 会话,API 走POST https://api.sweego.io/send; - 附件、CC/BCC、Reply-To、自定义头部等均按 Sweego 的字段约定映射;
- 投递结果回调经 Webhook 路由进入
SweegoRequestParser,完成签名与结构校验后由SweegoPayloadConverter转为标准化事件,最终送达标记了name: 'sweego_mailer'的消费者。
这一设计让业务代码完全不必感知底层通道差异——切换 SMTP 与 API 只需修改环境变量,事件处理只需实现一个消费者类,其余交给框架完成。如需在本地验证桥接行为,可参考 SweegoApiTransportTest.php 与 SweegoRequestParserTest.php 中的 Mock 测试写法,在不真实连接 Sweego 服务的前提下复现签名生成、请求组装与错误处理逻辑。