首页
/ Coolify 邮件最佳实践:ShouldQueue、afterCommit 与 Mailable 测试断言的落地解析

Coolify 邮件最佳实践:ShouldQueue、afterCommit 与 Mailable 测试断言的落地解析

2026-09-04 19:38:44作者:蔡怀权

本文以 Coolify 仓库中的 Laravel 邮件最佳实践规则(.agents/skills/laravel-best-practices/rules/mail.md)为主体,逐条讲解五条核心规则:让 Mailable 类实现 ShouldQueue 使入队成为默认行为、在事务内使用 afterCommit() 避免竞态、对入队邮件使用 assertQueued() 而非 assertSent() 断言、为事务性邮件选用 Markdown Mailable,以及将“内容测试”与“发送测试”分离。结合 Coolify 的通知系统源码(app/Notifications/ 目录及其测试用例),读者可以掌握这些规则在多通道通知场景下的实际落点与验证方式。

规则一:在 Mailable 类上实现 ShouldQueue,让入队成为默认行为

原始规则说明:

Makes queueing the default regardless of how the mailable is dispatched. No need to remember Mail::queue() at every call site — Mail::send() also queues it.

核心思想是:入队与否应该由 Mailable 类自身声明,而不是依赖每个调用点记住 Mail::queue()。只要 Mailable(或 Notification)类实现了 Illuminate\Contracts\Queue\ShouldQueue,无论调用方使用 Mail::send()Mail::to()->send() 还是 notify(),消息都会被投递到队列,由 worker 异步执行。调用方代码因此被简化为统一的同步写法,而性能与削峰特性则始终生效。

Coolify 中的落地:一个基类收口全部邮件通知

Coolify 把这个规则做成了架构级约定。所有自定义邮件通知都继承自 CustomEmailNotification,该基类一次性声明了队列行为与失败重试策略:

class CustomEmailNotification extends Notification implements ShouldQueue
{
    use Queueable;

    public $backoff = [10, 20, 30, 40, 50];

    public $tries = 5;

    public $maxExceptions = 5;
}

从源码结构看,这一设计带来三层收益:

  1. 所有子类天然入队app/Notifications/ 下的 DeploymentFailed.phpInvitationLink.phpEmailChangeVerification.phpRestartLimitReached.php 等数十个通知均继承该基类,无需逐个标注 ShouldQueue
  2. 统一的重试退避$backoff = [10, 20, 30, 40, 50] 表示失败后按 10/20/30/40/50 秒递增延迟重试,$tries = 5$maxExceptions = 5 共同约束最大尝试次数,避免 SMTP 瞬时故障(如 DNS 抖动、端口被限流)导致通知直接丢失。
  3. 发送方完全解耦:以 TransactionalEmailChannel 为例,其 send() 方法内部使用的是同步的 Mail::send()——但因为 Notification 已实现 ShouldQueue,整个 channel 的发送逻辑实际运行在队列 worker 进程中,HTTP 请求不会阻塞等待 SMTP 握手:
// app/Notifications/Channels/TransactionalEmailChannel.php
Mail::send(
    [],
    [],
    fn (Message $message) => mail_from_message($message, $settings)
        ->to($email)
        ->subject($mailMessage->subject)
        ->html((string) $mailMessage->render())
);

此外,Coolify 还通过 onQueue('high') 把事务性邮件路由到独立命名队列(仓库 app/ 目录下共有 55 处 onQueue('high') 调用),例如 InvitationLink 构造函数中的 $this->onQueue('high');邀请邮件、改邮验证邮件这类时效性强的消息不会与低优先级任务争抢 worker。另一个细节是 Test.php 通知类额外引入了 Illuminate\Queue\Middleware\RateLimited 队列中间件,对“发送测试邮件”这类可被滥用的入口做了速率保护。

规则二:在事务内使用 afterCommit() 派发 Mailable

原始规则说明:

A queued mailable dispatched inside a transaction may process before the commit. Use $this->afterCommit() in the constructor.

这是一个典型的竞态问题:如果 notify()/Mail::queue() 发生在未提交的事务中,队列 job 可能在 commit() 之前被 worker 执行。此时 job 内读取数据库会看到事务前的旧状态(甚至查不到待创建的行),导致邮件内容基于不成立的数据生成,或者依赖外键的操作直接失败。解决方案是在 Mailable/Notification 构造函数中调用 $this->afterCommit()(需要类使用 Queueable trait),把 job 的推送推迟到当前事务提交之后。

Coolify 的实际用例:RestartLimitReached

RestartLimitReached 通知在应用因超过重启上限被停止时发出,其派发链路运行在可能包含事务的请求/事件处理流程中,构造函数开头即为:

// app/Notifications/Application/RestartLimitReached.php(构造函数节选)
public function __construct(public BaseModel $resource)
{
    $this->onQueue('high');
    $this->afterCommit();
    $environment = data_get($resource, 'environment')
        ?? data_get($resource, 'application.environment')
        ?? data_get($resource, 'service.environment');
    // ... 从资源中提取 project_uuid / environment_uuid / resource_url 等
}

注意它把 afterCommit() 放在构造函数最前面,随后才读取 $resource 的关联数据——因为 resource_urlrestart_count 等字段都来自可能尚未持久化的模型状态,推迟到提交后执行才能保证 job 序列化时数据自洽。

仓库中还存在一种等价但更粗粒度的替代写法:让监听器实现 ShouldQueueAfterCommit。例如 ProxyStatusChangedNotification

class ProxyStatusChangedNotification implements ShouldQueueAfterCommit
{
    public function __construct() {}
}

两者取舍:afterCommit() 是 per-Mailable 的显式声明,适合“同一 Mailable 有时在事务中、有时不在”的场景;ShouldQueueAfterCommit 则是类级约定,适合几乎总在事件/事务流中触发的监听器。

规则三:入队 Mailable 应使用 assertQueued() 而不是 assertSent()

原始规则说明:

Mail::assertSent() only catches synchronous mail. Queued mailables fail assertSent with a "Did you mean to use assertQueued()?" hint.

  • 错误写法(Mailable 实现了 ShouldQueue 时):Mail::assertSent(OrderShipped::class);
  • 正确写法:Mail::assertQueued(OrderShipped::class);

原理是:Mail::fake() 之后,同步 send 会进入 “sent” 记录集,入队 queue 会进入 “queued” 记录集,二者互不相通。对入队邮件使用 assertSent(),断言必然失败,且 Laravel 会返回 “Did you mean to use assertQueued()?” 的提示——这个提示本身就是框架在为这条规则兜底。

对应到 Coolify 的测试层

Coolify 的测试主要面向 Notification 层(因为邮件只是其多通道通知之一),使用的是同构的 Notification::fake() / assertSentTo* API。例如 ApiTokenExpirationWarningTest 验证了“发送次数”这一关键维度:

Notification::fake();
// ...
Notification::assertSentTo($this->team, ApiTokenExpiringNotification::class);
Notification::assertSentToTimes($this->team, ApiTokenExpiringNotification::class, 1);
// 第二次触发后
Notification::assertSentToTimes($this->team, ApiTokenExpiringNotification::class, 2);

[EmailChangeVerificationTest](https://gitcode.com/GitHub_Trending/co/coolify/blob/adb255dc5caa5a174582cc11f93a98f097c902e9/tests/Feature/EmailChangeVerificationTest.php?utm_source=gitcode_repo_files) 同样在每个用例开头 Notification::fake(),从而让“生成 6 位验证码”“确认改邮成功”等业务断言与“通知是否真的发出”完全隔离。如果你的自定义邮件直接通过 Mail facade 派发(不经过 Notification 通道),则应遵循文档规则:入队场景断言 Mail::assertQueued(),同步场景断言 Mail::assertSent(),不要混用。

规则四:事务性邮件优先使用 Markdown Mailable

原始规则说明:

Markdown mailables auto-generate both HTML and plain-text versions, use responsive components, and allow global style customization. Generate with --markdown flag.

Markdown Mailable(php artisan make:mail Xxx --markdown=emails/xxx)的价值在于:只维护一份 Markdown 模板,Laravel 自动渲染出 HTML 与纯文本两个版本;内置 headerbuttontable 等响应式组件;并支持通过主题路径统一调整全部事务邮件的视觉风格。对“验证邮件、邀请邮件、密码重置”这类高频、低定制度的事务邮件,这是维护成本最低的选择。

Coolify 的运行时配置与现状对照

config/mail.php 保留了完整的 Markdown 邮件配置段:

'markdown' => [
    'theme' => 'default',

    'paths' => [
        resource_path('views/vendor/mail'),
    ],
],

'default' => env('MAIL_MAILER', 'array') 还说明本地/测试环境默认走 array transport(邮件只收集不落盘、不发送),这为规则五中的测试隔离提供了前提。

需要如实说明的是:从源码看,Coolify 当前的邮件正文实现走的是 Blade 视图 + MailMessage->view() 路线,而非 Markdown Mailable。例如 EmailChangeVerificationtoMail()

$mail = new MailMessage;
$mail->subject('Coolify: Verify Your New Email Address');
$mail->view('emails.email-change-verification', [
    'newEmail' => $this->newEmail,
    'verificationCode' => $this->verificationCode,
    'expiryMinutes' => $expiryMinutes,
]);

对应的视图模板位于 resources/views/emails/(含 email-change-verification.blade.phpinvitation-link.blade.phpreset-password.blade.phpapplication-restart-limit-reached.blade.php 等)。可以推断,这一选择源于 Coolify 邮件需要携带按钮、多语言文案与自定义发件人头等较重的定制需求;而文档推荐的 Markdown Mailable 模式仍适合新增的简单事务邮件——config/mail.php 中的主题与 views/vendor/mail 组件路径配置即为该路线预留了全局样式定制入口。

规则五:将“内容测试”与“发送测试”分离

原始规则说明:

Content tests: instantiate the mailable directly, call assertSeeInHtml(). Sending tests: use Mail::fake() and assertSent()/assertQueued(). Don't mix them — it conflates concerns and makes tests brittle.

两类测试回答的是两个不同问题,混在一起会让任何一个变化(改模板文案、改派发通道)都牵连另一类用例:

测试类型 回答的问题 典型写法
内容测试 邮件正文是否包含期望内容、变量是否正确填充 直接 new Mailable,调用 assertSeeInHtml() / assertDontSeeInHtml()
发送测试 该 Mailable 是否按预期被派发(发送/入队)、次数是否正确 Mail::fake()(或 Notification::fake())+ assertSent() / assertQueued()

内容测试示例(可直接复制的骨架):

it('renders the verification code and expiry in the email body', function () {
    $user = User::factory()->create();

    $notification = new EmailChangeVerification($user, '123456', 'new@example.com', now()->addMinutes(10));

    $mail = $notification->toMail($user);
    expect((string) $mail->render())
        ->toContain('123456')
        ->toContain('new@example.com');
});

发送测试示例(与 EmailChangeVerificationTest 风格一致):

it('generates a 6-digit verification code when requesting email change', function () {
    Notification::fake();

    $user = User::factory()->create();
    $user->requestEmailChange('newemail@example.com');

    $user->refresh();
    expect($user->pending_email)->toBe('newemail@example.com')
        ->and($user->email_change_code)->toMatch('/^\d{6}$/');
});

注意第二个用例只断言业务状态(验证码生成、过期时间写入),完全不关心邮件“是否发出”——这正是规则强调的“不混用”:发送链路的变化不会使内容断言变脆,反之亦然。

支撑链路速览:从规则到 Coolify 的完整发送管线

为了让上述五条规则可以对照验证,这里补充 Coolify 邮件发送的运行时链路(均可在仓库中直接查看):

  1. 运行时发件人配置set_transanctional_email_settings() 依据 instanceSettings()resend_enabled/smtp_enabled 决定走 Resend 还是 SMTP,并通过 ConfigurationRepository::updateMailConfig() 更新运行期 mail 配置;mail_from_message() 负责设置 From 头,并对 ProtonMail 服务器做了 From 头换行长度特判(prevent_mail_from_header_folding,上限 998 字符)。
  2. SMTP 传输构建SmtpTransportFactory 根据 smtp_host/smtp_port/smtp_encryptionnone/starttls/tls)构建 EsmtpTransportnone 模式下显式 setAutoTls(false),并支持 smtp_ehlo_domain(EHLO 本地域)与 smtp_timeout 超时设置。
  3. 邮箱归一化normalize_email_identity() 对 gmail.com/googlemail.com 地址去除 + 后缀与 .,用于收件人身份比对——这是与发送测试(“发给谁”)直接相关的边界逻辑。
  4. 通道兜底TransactionalEmailChannelsmtp_enabledresend_enabled 均未开启时静默返回,并支持 newEmail 属性覆盖默认收件人(改邮验证邮件需发给新地址而非账号当前地址)。

小结

  • 规则一(ShouldQueue)在 Coolify 中收敛为基类 CustomEmailNotification 的统一实现,附带退避重试参数,配合 onQueue('high') 实现优先级分离;
  • 规则二(afterCommit())可见于 RestartLimitReached 等事务敏感通知的构造函数;
  • 规则三(assertQueued())与规则五(内容/发送测试分离)直接对应 tests/Feature/ApiTokenExpirationWarningTestEmailChangeVerificationTest 等用例的写法;
  • 规则四(Markdown Mailable)在本仓库中体现为 config/mail.php 的主题配置预留,现有正文仍以 Blade 视图实现,属于可按项目风格取舍的选项而非硬性现状。

五条规则共同指向同一个工程目标:让“发不发、何时发、发到哪”这些决策从调用点与测试细节中抽离,集中到 Mailable 类、队列配置与测试分层这三个可审查的位置。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
528
588
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
906
1.83 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
docsdocs
暂无描述
Markdown
891
5.78 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.53 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.34 K
1.45 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
987
506
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384