Coolify 邮件最佳实践:ShouldQueue、afterCommit 与 Mailable 测试断言的落地解析
本文以 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;
}
从源码结构看,这一设计带来三层收益:
- 所有子类天然入队。
app/Notifications/下的 DeploymentFailed.php、InvitationLink.php、EmailChangeVerification.php、RestartLimitReached.php 等数十个通知均继承该基类,无需逐个标注ShouldQueue。 - 统一的重试退避:
$backoff = [10, 20, 30, 40, 50]表示失败后按 10/20/30/40/50 秒递增延迟重试,$tries = 5与$maxExceptions = 5共同约束最大尝试次数,避免 SMTP 瞬时故障(如 DNS 抖动、端口被限流)导致通知直接丢失。 - 发送方完全解耦:以 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_url、restart_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://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
--markdownflag.
Markdown Mailable(php artisan make:mail Xxx --markdown=emails/xxx)的价值在于:只维护一份 Markdown 模板,Laravel 自动渲染出 HTML 与纯文本两个版本;内置 header、button、table 等响应式组件;并支持通过主题路径统一调整全部事务邮件的视觉风格。对“验证邮件、邀请邮件、密码重置”这类高频、低定制度的事务邮件,这是维护成本最低的选择。
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。例如 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.php、invitation-link.blade.php、reset-password.blade.php、application-restart-limit-reached.blade.php 等)。可以推断,这一选择源于 Coolify 邮件需要携带按钮、多语言文案与自定义发件人头等较重的定制需求;而文档推荐的 Markdown Mailable 模式仍适合新增的简单事务邮件——config/mail.php 中的主题与 views/vendor/mail 组件路径配置即为该路线预留了全局样式定制入口。
规则五:将“内容测试”与“发送测试”分离
原始规则说明:
Content tests: instantiate the mailable directly, call
assertSeeInHtml(). Sending tests: useMail::fake()andassertSent()/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 邮件发送的运行时链路(均可在仓库中直接查看):
- 运行时发件人配置: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 字符)。 - SMTP 传输构建:SmtpTransportFactory 根据
smtp_host/smtp_port/smtp_encryption(none/starttls/tls)构建EsmtpTransport,none模式下显式setAutoTls(false),并支持smtp_ehlo_domain(EHLO 本地域)与smtp_timeout超时设置。 - 邮箱归一化:normalize_email_identity() 对 gmail.com/googlemail.com 地址去除
+后缀与.,用于收件人身份比对——这是与发送测试(“发给谁”)直接相关的边界逻辑。 - 通道兜底:TransactionalEmailChannel 在
smtp_enabled与resend_enabled均未开启时静默返回,并支持newEmail属性覆盖默认收件人(改邮验证邮件需发给新地址而非账号当前地址)。
小结
- 规则一(
ShouldQueue)在 Coolify 中收敛为基类 CustomEmailNotification 的统一实现,附带退避重试参数,配合onQueue('high')实现优先级分离; - 规则二(
afterCommit())可见于 RestartLimitReached 等事务敏感通知的构造函数; - 规则三(
assertQueued())与规则五(内容/发送测试分离)直接对应tests/Feature/下 ApiTokenExpirationWarningTest、EmailChangeVerificationTest 等用例的写法; - 规则四(Markdown Mailable)在本仓库中体现为 config/mail.php 的主题配置预留,现有正文仍以 Blade 视图实现,属于可按项目风格取舍的选项而非硬性现状。
五条规则共同指向同一个工程目标:让“发不发、何时发、发到哪”这些决策从调用点与测试细节中抽离,集中到 Mailable 类、队列配置与测试分层这三个可审查的位置。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00