> ## 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-TW/mail.md)，提供者的準備請參閱[套件開發](https://kawax.biz/zh-TW/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-TW/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-TW/advanced/package-discovery.md)等方式載入。

* Laravel 的 `MailServiceProvider` 是延遲提供者，會將 `mail.manager` 註冊為 singleton。`Mail` Facade 也會解析這個鍵。
* `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。設計上不要在註冊時就寄送，且在多個 Mailer 使用同一個 Transport 名稱時，不要讓設定或投遞目標混在一起。

### 在使用端應用程式中新增 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` 中指定的是 **`receipts` 這個 Mailer 名稱**，而不是 Transport 名稱。若個別指定，就能在不變更既有預設值的情況下使用。

```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-TW/advanced/package-service-lifetimes.md)。

## 不只用 Mail::fake，也要測試實際的建立路徑

`Mail::fake()` 是用來記錄 Mailable 的寄送與佇列登錄。`MailFake::mailer()` 不會解析實際的 Mailer，而是記錄 Mailer 名稱並回傳 fake 本身。`raw()` 也不會執行寄送處理。即使沒有註冊自訂 Transport，Mailable 的寄送斷言也可能成功。

在[套件的測試環境](https://kawax.biz/zh-TW/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-TW/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 Facade 與 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-TW/mail.md)
- [進階主題](https://kawax.biz/zh-TW/advanced/index.md)
- [套件的 HTTP 用戶端與測試](https://kawax.biz/zh-TW/advanced/package-http-client.md)
- [套件的 Storage 驅動程式註冊與重新解析](https://kawax.biz/zh-TW/advanced/package-filesystem-drivers.md)
- [套件的事件監聽器註冊與快取](https://kawax.biz/zh-TW/advanced/package-events.md)


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