Symfony Sweego Mailer Bridge 集成指南:SMTP/API 双通道发送与 Webhook 事件接收

原创2026-10-01 23:40:471,767 阅读
文章标签:后端Web框架

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)对入站请求做了多层校验:

  1. 请求匹配:必须是 POST 方法、JSON 内容类型,且携带 webhook-id、webhook-timestamp、webhook-signature 三个头部;
  2. 密钥非空:secret 为空时直接抛 InvalidArgumentException(测试 testRequestSignedWithAnEmptySecretIsRejected 验证了这一点,见 SweegoRequestParserTest.php);
  3. 时间窗口校验:webhook-timestamp 与服务器当前时间的偏差超过 300 秒即拒绝(返回 403),防止重放攻击;
  4. 签名校验:按 webhook-id.webhook-timestamp.<原始请求体> 拼接待签名内容,使用密钥的 Base64 解码结果作为 HMAC-SHA256 的 key,计算后与 webhook-signature 做常量时间比较(hash_equals),不匹配返回 403 Invalid signature.;
  5. 载荷结构校验:必须包含 event_type、timestamp、headers(含 x-transaction-id)、recipient,否则返回 406 Payload is malformed.;
  6. 事件转换校验:SweegoPayloadConverter 抛出的 ParseException 会被转换为 406 拒绝响应。

测试 SweegoRequestParserTest.php 中的 testRejectForgedSignatureBeforeParsingThePayload 展示了伪造签名(如全 A 的签名值)会在解析载荷之前就被拒绝,验证了"先验签、后解析"的安全顺序。

六、从 DSN 到传输的完整链路小结

综合以上实现,一条 MAILER_DSN 的解析与使用链路可以概括为:

  1. Symfony Mailer 通过 SweegoTransportFactory::create() 解析 DSN scheme,分发到 SweegoSmtpTransport 或 SweegoApiTransport;
  2. 发送时,Email 对象被转换为对应通道的请求:SMTP 走标准 ESMTP TLS 会话,API 走 POST https://api.sweego.io/send;
  3. 附件、CC/BCC、Reply-To、自定义头部等均按 Sweego 的字段约定映射;
  4. 投递结果回调经 Webhook 路由进入 SweegoRequestParser,完成签名与结构校验后由 SweegoPayloadConverter 转为标准化事件,最终送达标记了 name: 'sweego_mailer' 的消费者。

这一设计让业务代码完全不必感知底层通道差异——切换 SMTP 与 API 只需修改环境变量,事件处理只需实现一个消费者类,其余交给框架完成。如需在本地验证桥接行为,可参考 SweegoApiTransportTest.php 与 SweegoRequestParserTest.php 中的 Mock 测试写法,在不真实连接 Sweego 服务的前提下复现签名生成、请求组装与错误处理逻辑。

登录后查看全文
symfony