> ## Documentation Index
> Fetch the complete documentation index at: https://kawax.biz/llms.txt
> Use this file to discover all available pages before exploring further.

# 包的邮件传输注册与测试

> 解读 Laravel 13 的 MailManager，讲解从包中注册传输、传递配置、Mailer 的保留与重新解析，以及 fake 无法验证的发送路径。

要从包中提供自己的邮件发送方式，只需将**创建 Symfony Transport 的处理**注册到 Laravel。无需重新实现 Mailable 或 Laravel 的 Mailer。

本页面基于对 Laravel Framework `v13.39.0` 实现的确认。基本的发送方式请参阅[发送邮件](https://kawax.biz/zh-CN/mail.md)，服务提供者的准备请参阅[包开发](https://kawax.biz/zh-CN/advanced/package-development.md)。

## 区分 Mailer 名称与 Transport 名称

| 要素 | 本页示例 | 作用 |
| - | - | - |
| Transport 名称 | `acme-memory-mail` | 通过 `extend()` 注册的创建方式 |
| Mailer 名称 | `receipts` / `alerts` | `mail.mailers` 中的配置名称，以及已创建 Mailer 的标识符 |
| Symfony Transport | `MemoryTransport` | 邮件的投递处理。本例仅记录在内存中 |
| Laravel Mailer | `Illuminate\Mail\Mailer` | 生成正文、发送事件、传递给 Transport 等 |

`Mail::mailer('receipts')` 会读取 `mail.mailers.receipts`，并调用与 `transport` 对应的创建处理。`MailManager::build()` 会将该 Transport 传给 Laravel 的 Mailer，在常规的按名称解析中还会设置全局发件人等。

```mermaid theme={null}
flowchart TD
    A["Mail::mailer('receipts')"] --> B{"receipts 是否已解析？"}
    B -->|是| C["返回已保留的 Mailer"]
    B -->|否| D["读取 mail.mailers.receipts"]
    D --> E["acme-memory-mail 的创建处理<br>参数仅为配置数组"]
    E --> F["返回 Symfony Transport"]
    F --> G["创建 Laravel 的 Mailer<br>并以 receipts 名称保留"]
    G --> H["发送时调用 Transport::send()"]
    C --> H
```

自定义创建处理会优先于内置 Transport 被选中。不要覆盖 `smtp` 或 `ses`，而应使用包专属的 Transport 名称。

## 在不对外发送的情况下构建注册路径

首先确认的不是投递目标的 API，而是配置与解析路径。在 `Acme\Postbox` 包的 `src/Transport/MemoryTransport.php` 中，创建一个继承标准 `ArrayTransport` 的类。

```php theme={null}
<?php

namespace Acme\Postbox\Transport;

use Illuminate\Mail\Transport\ArrayTransport;

class MemoryTransport extends ArrayTransport
{
    public function __construct(
        private readonly string $label,
    ) {
        parent::__construct();
    }

    public function __toString(): string
    {
        return 'acme-memory-mail:'.$this->label;
    }
}
```

`ArrayTransport::send()` 会将 Symfony 的 `SentMessage` 保存到内部的 Collection 中。可以通过 `messages()` 查看，并通过 `flush()` 清除该实例的记录。标签用于确认配置已传递到创建处理。

> [!WARNING]
>
> 本例不会将邮件投递到外部，仅用于学习注册与测试。内存中的记录不会跨进程共享或持久化，也不能用来确认邮件已送达收件人。如果目的只是记录到内存，内置的 `array` Transport 就足够了。

### 注册到延迟加载的 Manager

官方文档在 `boot()` 中调用 `Mail::extend()`。在包中，为了避免在不使用邮件的应用中多余地创建 Manager，可以结合使用[服务解析后钩子](https://kawax.biz/zh-CN/advanced/package-service-resolution.md)。

创建 `src/PostboxServiceProvider.php`。

```php theme={null}
<?php

namespace Acme\Postbox;

use Acme\Postbox\Transport\MemoryTransport;
use Illuminate\Mail\MailManager;
use Illuminate\Support\ServiceProvider;
use Symfony\Component\Mailer\Transport\TransportInterface;

class PostboxServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        $this->callAfterResolving('mail.manager', static function (
            MailManager $manager,
        ): void {
            $manager->extend('acme-memory-mail', static function (
                array $config,
            ): TransportInterface {
                return new MemoryTransport($config['label'] ?? 'mail');
            });
        });
    }
}
```

服务提供者可通过[包自动发现](https://kawax.biz/zh-CN/advanced/package-discovery.md)等方式加载。

* Laravel 的 `MailServiceProvider` 是延迟提供者，会将 `mail.manager` 注册为 singleton。`Mail` 门面解析的也是这个键。
* `callAfterResolving()` 在尚未解析时会等待之后的解析，对已解析的 Manager 则会立即应用扩展。**它并不会替换已创建的 Mailer。**
* 避免将注册扩展的自己的服务提供者也简单地设为 `DeferrableProvider`。要确保在钩子注册之前不会请求自定义 Transport。
* 本例在自己的 `boot()` 中注册钩子。不保证其他服务提供者在此之前使用自定义 Mailer 的启动顺序。请设计好注册顺序，不要在 `register()` 中发送邮件。

### 不要将 extend 的参数与其他 Manager 混淆

`MailManager::createSymfonyTransport()` 调用自定义创建处理时**只传入一个配置数组**。这与 `Cache::extend()` 那样传入应用和配置两个参数不同。返回值是实现了 `TransportInterface` 的 Transport，而不是 Laravel 的 Mailer 或发送结果。

此外，`MailManager::extend()` 只是保存闭包，不会重新绑定到 Manager。示例中使用 static 闭包和显式参数，以免依赖 `$this` 的解释。

如果需要外部 API 的客户端，应在该创建处理中应用配置并传给 Transport。注册时不要执行发送，并在设计上确保使用同一 Transport 名称的多个 Mailer 之间不会混用配置或目标地址。

### 在使用方应用中添加 Mailer

在 `config/mail.php` 现有的 `mailers` 数组中添加以下两项。不要从包一侧覆盖使用者的默认值或整个 `mailers`。

```php theme={null}
'receipts' => [
    'transport' => 'acme-memory-mail',
    'label' => 'receipts-v1',
],

'alerts' => [
    'transport' => 'acme-memory-mail',
    'label' => 'alerts',
],
```

如果想设为默认，在 `MAIL_MAILER` 中指定的不是 Transport 名称，而是 **`receipts` 这个 Mailer 名称**。如果单独指定，则无需更改现有默认值即可使用。

```php theme={null}
use Illuminate\Mail\Message;
use Illuminate\Support\Facades\Mail;

Mail::mailer('receipts')->raw('Receipt body', static function (Message $message): void {
    $message->from('noreply@example.com')
        ->to('reader@example.com')
        ->subject('Receipt');
});

$transport = Mail::mailer('receipts')->getSymfonyTransport();

$recorded = $transport->messages()->sole();
```

这里的 `raw()` 是一个无需准备视图、用于确认同步发送路径的示例。使用 Mailable 的常规发送也一样，Mailer 生成正文等内容后会传给同一个 Transport。

## 创建实际投递 Transport 时的边界

在投递到外部服务的实现中，可以按照官方文档继承 `Symfony\Component\Mailer\Transport\AbstractTransport`，并实现 `doSend(SentMessage $message): void` 和 `__toString(): string`。上述注册方式可以直接沿用。

| 需要确认的内容 | 包负责的范围 |
| - | - |
| 消息与收件人 | 不仅是正文，还要将 HTML、附件、CC/BCC 以及 Envelope 中的收件人正确传给投递目标 API |
| 错误 | 不要将 API 失败吞掉当作成功，而应传递给调用方 |
| 配置隔离 | 将每个 Mailer 各自的连接目标与认证信息应用到 Transport |
| 字符串表示 | `__toString()` 中不要包含认证信息或邮件正文 |
| 测试 | 除了注册、配置、重新解析的测试外，还要验证到 API 的转换与错误处理 |

Laravel 的 `Mailer::sendSymfonyMessage()` 会调用 Transport 的 `send()`。如果 `MessageSending` 监听器中止了发送，或 Transport 返回了 `null`，Laravel 的 `send()` 也不会返回发送结果。**不要仅凭没有异常就判定投递成功。** 即使返回了 `SentMessage`，也不能保证邮件已到达收件人的邮箱。

## 区分配置变更与 Mailer 的重新解析

`MailManager` 会按 Mailer 名称保留 Mailer 及其 Transport。仅变更配置或重新注册 `extend()`，并不会更新已创建的对象。

```php theme={null}
use Illuminate\Support\Facades\Mail;

$before = Mail::mailer('receipts');

config(['mail.mailers.receipts.label' => 'receipts-v2']);

$same = Mail::mailer('receipts');

Mail::purge('receipts');

$after = Mail::mailer('receipts');
```

`$same` 与 `$before` 是同一个 Mailer。在 `purge('receipts')` 之后获取的 `$after` 中，会根据新的配置创建 Transport。

| 操作 | 对象 |
| - | - |
| `Mail::purge('receipts')` | 移除 Manager 所保留的指定 Mailer 的引用 |
| `Mail::forgetMailers()` | 移除 Manager 所保留的所有 Mailer 的引用。扩展的注册会保留 |
| `$transport->flush()` | 清除本例中 MemoryTransport 所保留的消息记录 |

经确认，`purge()` 的实现只是从 Manager 内部数组中移除引用。旧的 `$before` 以及已注入该 Mailer 的服务不会被替换。它也不是显式关闭外部连接的处理。

### failover 等父 Mailer 也会被单独保留

`failover` 和 `roundrobin` 会读取子 Mailer 名称的配置，**直接创建 Transport** 并将其嵌入父 Transport 中。这并不是共享子 `Mail::mailer()` 实例的机制。

因此，即使变更子配置并调用 `purge('receipts')`，已解析的父 Mailer 的内部 Transport 也不会更新。需要更新的父 Mailer 也必须重新解析。即使使用 `forgetMailers()`，也无法替换使用方已持有的父 Mailer 引用。

在生产环境中，应将配置缓存的更新与长驻 worker 的重启纳入常规部署流程。不要将重新解析视为对所有进程的统一更新，还请参阅[服务的生命周期](https://kawax.biz/zh-CN/advanced/package-service-lifetimes.md)。

## 不仅使用 Mail::fake，还要测试实际的创建路径

`Mail::fake()` 用于记录 Mailable 的发送与入队。`MailFake::mailer()` 不会解析实际的 Mailer，而是记录 Mailer 名称并返回 fake 自身。`raw()` 也不会执行发送处理。即使没有注册自定义 Transport，Mailable 的发送断言也可能成功。

在[包的测试环境](https://kawax.biz/zh-CN/advanced/package-testing.md)中，按如下方式**不使用 fake**，让流程经过实际的 Manager 和 Transport。本例的 Transport 不会对外发送，因此可以在没有实际投递风险的情况下确认注册路径。先加载辅助服务提供者，以便同时验证 Manager 已被解析的情况。

```php theme={null}
<?php

namespace Acme\Postbox\Tests;

use Acme\Postbox\PostboxServiceProvider;
use Acme\Postbox\Transport\MemoryTransport;
use Illuminate\Mail\Message;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\ServiceProvider;
use Orchestra\Testbench\TestCase;
use Symfony\Component\Mime\Email;

class MailTransportTest extends TestCase
{
    protected function getPackageProviders($app): array
    {
        return [EarlyMailManagerProvider::class, PostboxServiceProvider::class];
    }

    protected function defineEnvironment($app): void
    {
        $app['config']->set('mail.default', 'receipts');
        $app['config']->set('mail.from', [
            'address' => 'noreply@example.com',
            'name' => 'Postbox',
        ]);
        $app['config']->set('mail.mailers.receipts', [
            'transport' => 'acme-memory-mail',
            'label' => 'receipts-v1',
        ]);
        $app['config']->set('mail.mailers.alerts', [
            'transport' => 'acme-memory-mail',
            'label' => 'alerts',
        ]);
    }

    public function test_registers_on_an_already_resolved_manager(): void
    {
        $this->assertSame(
            $this->app->make('postbox.test.early-manager'),
            $this->app->make('mail.manager'),
        );

        $transport = Mail::mailer('receipts')->getSymfonyTransport();

        $this->assertInstanceOf(MemoryTransport::class, $transport);
        $this->assertSame('acme-memory-mail:receipts-v1', (string) $transport);
    }

    public function test_message_reaches_the_transport(): void
    {
        $mailer = Mail::mailer('receipts');

        $sent = $mailer->raw('Receipt body', static function (Message $message): void {
            $message->to('reader@example.com')->subject('Receipt');
        });

        $transport = $mailer->getSymfonyTransport();

        $this->assertNotNull($sent);
        $this->assertCount(1, $transport->messages());

        $recorded = $transport->messages()->sole();
        $email = $recorded->getOriginalMessage();

        $this->assertInstanceOf(Email::class, $email);
        $this->assertSame('Receipt', $email->getSubject());
        $this->assertSame('Receipt body', $email->getTextBody());
        $this->assertSame('noreply@example.com', $email->getFrom()[0]->getAddress());
        $this->assertSame(
            'reader@example.com',
            $recorded->getEnvelope()->getRecipients()[0]->getAddress(),
        );
    }

    public function test_mailer_names_have_separate_transports(): void
    {
        $receipts = Mail::mailer('receipts');
        $alerts = Mail::mailer('alerts');

        $this->assertNotSame($receipts->getSymfonyTransport(), $alerts->getSymfonyTransport());
        $this->assertSame('acme-memory-mail:alerts', (string) $alerts->getSymfonyTransport());

        $receipts->raw('Receipt body', static function (Message $message): void {
            $message->to('reader@example.com')->subject('Receipt');
        });

        $this->assertCount(1, $receipts->getSymfonyTransport()->messages());
        $this->assertCount(0, $alerts->getSymfonyTransport()->messages());
    }

    public function test_config_change_requires_purging_the_mailer(): void
    {
        $before = Mail::mailer('receipts');

        config(['mail.mailers.receipts.label' => 'receipts-v2']);

        $this->assertSame($before, Mail::mailer('receipts'));
        $this->assertSame('acme-memory-mail:receipts-v1', (string) $before->getSymfonyTransport());

        Mail::purge('receipts');

        $after = Mail::mailer('receipts');

        $this->assertNotSame($before, $after);
        $this->assertSame('acme-memory-mail:receipts-v2', (string) $after->getSymfonyTransport());
        $this->assertSame('acme-memory-mail:receipts-v1', (string) $before->getSymfonyTransport());
    }
}

class EarlyMailManagerProvider extends ServiceProvider
{
    public function boot(): void
    {
        $this->app->instance('postbox.test.early-manager', $this->app->make('mail.manager'));
    }
}
```

该测试用于确认注册、配置、同步发送以及已保留对象的边界。对于实际投递用的 Transport，需要另外使用 API 客户端的 mock 或 fake 来验证载荷与失败响应。如果提供队列发送，请将[队列](https://kawax.biz/zh-CN/queues.md)的测试也分开，并确认 worker 端同样会加载服务提供者。

## 参考的一手资料

* [Laravel 官方文档：邮件的自定义 Transport 与测试（确认时的版本）](https://github.com/laravel/docs/blob/960eedf0911155ad16326ade49c0ea5d18eda0b8/mail.md#custom-transports)
* [Laravel 官方文档：包开发](https://github.com/laravel/docs/blob/13.x/packages.md)
* [MailManager：创建处理、Mailer 的保留、purge 与复合 Transport](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Mail/MailManager.php)
* [MailServiceProvider：MailManager 的绑定](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Mail/MailServiceProvider.php)
* [ServiceProvider：callAfterResolving](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Support/ServiceProvider.php)
* [Mailer：向 Transport 发送与事件](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Mail/Mailer.php)
* [ArrayTransport：消息的记录](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Mail/Transport/ArrayTransport.php)
* [Mail 门面与 MailFake](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Support/Facades/Mail.php)
* [MailFake：Mailer 的选择与 Mailable 的记录](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Support/Testing/Fakes/MailFake.php)
* [Laravel 官方测试：使用 Mailer 与 ArrayTransport 验证发送](https://github.com/laravel/framework/blob/v13.39.0/tests/Mail/MailMailerTest.php)


## Related topics

- [邮件发送](https://kawax.biz/zh-CN/mail.md)
- [进阶主题](https://kawax.biz/zh-CN/advanced/index.md)
- [包的 HTTP 客户端与测试](https://kawax.biz/zh-CN/advanced/package-http-client.md)
- [包的 Storage 驱动注册与重新解析](https://kawax.biz/zh-CN/advanced/package-filesystem-drivers.md)
- [包的 Artisan 命令注册与依赖解析](https://kawax.biz/zh-CN/advanced/package-commands.md)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.