Coolify 邮件最佳实践:ShouldQueue、afterCommit 与 Mailable 测试断言的落地解析
2026/9/5 22:33:33 网站建设 项目流程

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

【免费下载链接】coolifyAn open-source, self-hostable PaaS alternative to Vercel, Heroku & Netlify that lets you easily deploy static sites, databases, full-stack applications and 280+ one-click services on your own servers.项目地址: https://gitcode.com/GitHub_Trending/co/coolify

本文以 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 rememberMail::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.php、InvitationLink.php、EmailChangeVerification.php、RestartLimitReached.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()(需要类使用Queueabletrait),把 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 failassertSentwith 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://link.gitcode.com/i/dfb206bc4cb177e540020c23f0df3b08)同样在每个用例开头Notification::fake(),从而让“生成 6 位验证码”“确认改邮成功”等业务断言与“通知是否真的发出”完全隔离。如果你的自定义邮件直接通过Mailfacade 派发(不经过 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--markdownflag.

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')还说明本地/测试环境默认走arraytransport(邮件只收集不落盘、不发送),这为规则五中的测试隔离提供了前提。

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

$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, callassertSeeInHtml(). Sending tests: useMail::fake()andassertSent()/assertQueued(). Don't mix them — it conflates concerns and makes tests brittle.

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

测试类型回答的问题典型写法
内容测试邮件正文是否包含期望内容、变量是否正确填充直接newMailable,调用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. 通道兜底:TransactionalEmailChannel 在smtp_enabledresend_enabled均未开启时静默返回,并支持newEmail属性覆盖默认收件人(改邮验证邮件需发给新地址而非账号当前地址)。

小结

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

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

【免费下载链接】coolifyAn open-source, self-hostable PaaS alternative to Vercel, Heroku & Netlify that lets you easily deploy static sites, databases, full-stack applications and 280+ one-click services on your own servers.项目地址: https://gitcode.com/GitHub_Trending/co/coolify

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询