新しい MTA バックエンドを、コアを fork せずにプラグインとして提供。

AcelleMail における送信ドライバーとは、Amazon SES、Postal、SendGrid、独自 SMTP バックエンドといった 1 つのベンダーを担うクラスです。ホストアプリケーションは単一の REGISTRY フック (register_sending_server_driver) と少数の capability マーカー Interface のみを予約しています。それ以外 — ピッカーページ、接続フォーム、バリデーションパイプライン、Webhook コントローラ、Sender Identity タブ、Warmup タブ — はすべてホストが提供します。本ページは plugin:init から自作ドライバー経由でのライブ送信成功までの実践例であり、Postal MTA プラグイン (storage/app/plugins/rencontru/postal/) の静的レビューを基にまとめています。

なぜドライバーをプラグインとして提供するのか

AcelleMail には安定したファーストパーティドライバー一式 — Amazon SES、汎用 SMTP、sendmail、Postmark、SendGrid、Mailgun、その他いくつか — が同梱されています。それ以外のベンダー — 地域プロバイダー、セルフホスト MTA、ニッチなトランザクションサービス、カスタムバックエンド — も、ホスト側で同じ 5 つの要素を結線する必要があります。ピッカーページの 1 行、接続フォーム、バリデーションステップ、バウンスと complaint 用 Webhook リスナー、そしてランタイムでの send() 実装です。これを fork で行えば、ホストのアップグレードに永続的に追従しなければなりません。プラグインとして行えば、storage/app/plugins/{vendor}/{name}/ にフォルダを置くだけで、ホスト側の責務はすべてホストが処理します。

プラグイン契約は意図的に小さく保たれています。ドライバー宣言用 REGISTRY フック 1 つ。5 つの必須メソッド (sendtestsetupBeforeSendvalidationRules、加えて標準のサービス名アクセサ) を持つドライバークラス 1 つ。接続フォーム用 Blade パーシャル 1 つ。Webhook、Identity 同期、カスタム検証メールなど、その他はすべてオプションの capability マーカー Interface で対応します。以上です。ピッカーの描画、フォームレイアウト、保存アクション、バリデーションパイプライン、Webhook ルーティングはすべてホスト側にあります。

契約 — プラグインが提供するもの

送信ドライバープラグインの全ファイルツリーは以下のとおりです。

storage/app/plugins/<vendor>/<name>/
├── composer.json                 # PSR-4 + Laravel provider hook
├── routes.php                    # icon route only (CRUD + webhook handled by host)
├── icon.svg                      # picker page icon, served by routes.php
├── src/
│   ├── ServiceProvider.php       # ONE hook + view namespace + lifecycle
│   └── <Vendor>Driver.php        # the driver class
└── resources/
    ├── views/sending-servers/
    │   └── _fields_connection.blade.php   # Connection-tab form fields
    └── lang/en/messages.php       # labels + help text

スケルトンは意図的に薄く作られています。routes.php はちょうど 1 本のルートのみを登録 — ピッカーページが描画できるよう、ディスク上の icon.svg を配信します。CRUD エンドポイント、Webhook URL、フォーム保存アクションはすべてホストの Refactor\Admin\SendingServerController に存在します。プラグインはドライバー固有の表面領域のみを提供します。

プラグインが実際にホストに登録するのは次の 4 つです。

  1. ドライバークラス + メタデータ — type スラッグ、ドライバークラスの FQCN、ベンダー設定キー、ピッカーカードのメタデータを乗せた単一の Hook::add('register_sending_server_driver', ...) ペイロード。
  2. View 名前空間$this->loadViewsFrom($path, 'myvendor') により view('myvendor::...') がプラグインのテンプレートを解決できるようにします。
  3. 翻訳ファイル — マスター + ダンプ複製先パスとしてプラグインの resources/lang/ を指す Hook::add('add_translation_file', ...) ペイロード。
  4. 接続タブの Blade — ドライバーで ProvidesConnectionFieldsView capability マーカーを実装し、ホスト側のフォームが描画するパーシャルのパスを返します。

ServiceProvider — boot パターン

送信ドライバープラグインの ServiceProvider のフルスケルトンは以下のとおりです (Postal プラグインから言い換えたもの)。

namespace MyVendor\Sending;

use App\Library\Facades\Hook;
use Illuminate\Support\ServiceProvider as Base;

class ServiceProvider extends Base
{
    public const PLUGIN_NAME = 'myvendor/sending';   // MUST match composer.json#name

    public function register(): void
    {
        // Translation file registration — see /developers/translations for
        // the full contract. MUST be in register(), never in boot(), or the
        // host's collect loop misses it.
        Hook::add('add_translation_file', fn () => [
            'id'                      => '#myvendor/sending_translation_file',
            'plugin_name'             => self::PLUGIN_NAME,
            'file_title'              => 'Translation for myvendor/sending plugin',
            'translation_folder'      => storage_path('app/data/plugins/myvendor/sending/lang/'),
            'translation_prefix'      => 'myvendor',
            'file_name'               => 'messages.php',
            'master_translation_file' => realpath(__DIR__ . '/../resources/lang/en/messages.php'),
        ]);
    }

    public function boot(): void
    {
        // (1) View namespace + plugin's own routes.
        $this->loadViewsFrom(__DIR__ . '/../resources/views', 'myvendor');
        $this->loadRoutesFrom(__DIR__ . '/../routes.php');

        // (2) The single REGISTRY hook that the host's SendingServerServiceProvider
        //     collects in its app->booted() phase. The closure body runs lazily
        //     at collect time — route() resolves correctly because all providers
        //     have already booted.
        Hook::add('register_sending_server_driver', fn () => [
            'type'         => MyVendorDriver::TYPE,            // slug -> DriverRegistry
            'driver'       => MyVendorDriver::class,
            'config_keys'  => ['my_api_key', 'my_region'],     // -> JSON config column
            'name'         => 'My Vendor',
            'description'  => 'Send via My Vendor API',
            'icon_url'     => route('plugin.myvendor.sending.icon'),
            // create_url omitted -> main app derives from `type`
        ]);

        // (3) Lifecycle — only if the plugin needs cleanup on uninstall.
        Hook::on('delete_plugin_' . self::PLUGIN_NAME, function () {
            \App\Model\SendingServer::where('type', MyVendorDriver::TYPE)->forceDelete();
        });
    }
}

ホストが強制する非自明なルールが 2 つあります。

  • add_translation_file を除くすべての Hook::addboot() に置くregister() には置かない。ホストの SendingServerServiceProvider はプラグインが自身の boot() 経由で登録する時間を確保するため、$this->app->booted(...) でドライバーレジストリの収集を遅延させます。register_sending_server_driverregister() に置くと、クロージャが自身の依存 (特に route()) が利用可能になる前に走る可能性があります。
  • フックペイロードから asset('plugins/myvendor/sending/icon.svg') を呼ばない。送信ドライバープラグインには、プラグインアセットを public/plugins/... にコピーする自動 publish ステップは存在せず、本番ではそのパスが 404 になります。プラグインはアイコン用のルートを自前で持ち (routes.php で定義)、フックペイロードはそのルートを名前で参照します。完全に self-contained — プラグインフォルダを置けば、ホスト側のコードパスを介さずアイコンに到達できます。

ドライバークラス

最小構成のドライバーは App\SendingServers\Drivers\AbstractDriver を継承し、ProvidesConnectionFieldsView マーカー (このドライバーが独自の接続 Blade を持つことをホストに伝えます) を実装します。

namespace MyVendor\Sending;

use App\SendingServers\Capabilities\ProvidesConnectionFieldsView;
use App\SendingServers\Drivers\AbstractDriver;
use App\SendingServers\Drivers\SendResult;
use App\SendingServers\Drivers\TestResult;

class MyVendorDriver extends AbstractDriver implements ProvidesConnectionFieldsView
{
    public const TYPE = 'myvendor-api';

    public function getServiceName(): string  { return 'My Vendor'; }
    public function getServiceIcon(): string  { return 'send'; }       // Material Symbols ligature
    public function getServiceColor(): string { return 'var(--chart-2)'; }

    public function send($message, array $params = []): SendResult
    {
        // Call vendor API to deliver $message.
        // MUST throw on failure — never return SendResult with a "failed" status.
        $vendorMessageId = $this->callVendorApi($message);
        return new SendResult(runtimeMessageId: $vendorMessageId);
    }

    public function test(): TestResult
    {
        try {
            // See pitfall §6.1 — must hit a REAL endpoint that requires auth.
            return TestResult::success();
        } catch (\Throwable $e) {
            return TestResult::failure($e->getMessage());
        }
    }

    public function setupBeforeSend(string $fromEmailAddress): void
    {
        // No-op for most drivers. Implement if vendor needs per-batch
        // setup — SNS topic subscribe, identity feedback enable, etc.
    }

    public function validationRules(): array
    {
        $r = parent::validationRules();
        $r['cols'] = [
            'my_api_key' => 'required|string|max:128',
            'my_region'  => 'required|in:us,eu,ap',
        ];
        return $r;
    }

    public function connectionFieldsView(): string
    {
        return 'myvendor::sending-servers._fields_connection';
    }
}

4 つのサービス名アクセサ (getServiceNamegetServiceIcongetServiceColor) は、Sending Servers UI でピッカーカードと選択中サーバーのヘッダーを描画するためだけにホストが必要とするものです。send()test() は本番のホットパスです — このタイプのサーバーで送信されるすべてのキャンペーンは受信者ごとに 1 度 send() を呼び、管理画面の "Test connection" クリックごとに test() が呼ばれます。setupBeforeSend() はキャンペーンバッチの開始時に 1 度だけ走ります — 多くのドライバーでは空のままです。

Capability マーカー Interface

最小限の表面を超えて、ホストは capability マーカー Interface の一式を公開しています。ドライバーは該当するマーカーだけを実装します。ホストは各呼び出し箇所で instanceof チェックを行うため、ReceivesWebhooks を実装していないドライバーは Webhook ルート登録を例外なくスキップします。

マーカードライバーが実装する内容
ProvidesConnectionFieldsViewカスタムの接続タブ Blade — connectionFieldsView(): string が名前空間付きビューパスを返します。
ReceivesWebhooksverifyWebhook + parseWebhook + webhookUrl — ベンダーが /webhook/{type}/{uid} にフィードバックを POST し、ホストがペイロードを当該ドライバーへルーティングします。
SupportsIdentitySyncsyncIdentities + verifyIdentity — ホストがこのドライバー向けに Sender Identity タブを描画します。
SupportsRemoteDomainVerifyaddDomain + validateDomain + checkDomainVerificationStatus — DNS 検証をホストではなくベンダーが処理します。
SignsDkimOnServerサーバー側で DKIM 署名を行う — ホストは自前の署名レイヤーをスキップします。
SupportsCustomReturnPath発信メールのカスタム Return-Path ヘッダーを尊重します。
AllowsLocalDomainVerify / AllowsLocalEmailVerify / AllowsCrossSendingDomain汎用 SMTP スタイルの柔軟性フラグ。
SendsCustomVerificationEmailsendVerificationEmail(Sender) — ホストのデフォルトの代わりに、ドライバーが独自の検証メッセージを描画・送信します。

接続タブの Blade

resources/views/sending-servers/_fields_connection.blade.php 配下の接続パーシャルはフォームフィールドのみを描画します。ホストはこれを <form>、送信ボタン、バリデーションアラート、4 タブのページクロムでラップします。

<div class="mc-form-group">
    <label class="mc-form-label">
        {{ trans('myvendor::messages.fields.api_key') }}
        <span class="mc-form-required">*</span>
    </label>
    <input type="password"
           name="my_api_key"
           value="{{ old('my_api_key', $server->getConfig('my_api_key')) }}"
           class="mc-form-input @error('my_api_key') mc-form-input-error @enderror"
           id="myvendor-key-input">
    @error('my_api_key') <div class="mc-form-error">{{ $message }}</div> @enderror
    <div class="mc-form-help">{{ trans('myvendor::messages.fields.api_key_help') }}</div>
</div>

@
<div class="mc-form-group">
    <label class="mc-form-label">{{ trans('myvendor::messages.fields.webhook_url') }}</label>
    <input type="text"
           value="{{ $server->id ? $server->driver()->webhookUrl() : trans('myvendor::messages.fields.webhook_url_after_save') }}"
           class="mc-form-input"
           readonly>
</div>

パーシャルには 3 つのルールがあります。

  • フィールドのみ。<form> も送信ボタンも書かない。 フォームラッパーはホストが所有します。独自の送信ボタンを追加すると、誤った保存エンドポイントが叩かれます。
  • フィールドの nameconfig_keys ペイロードおよび validationRules()['cols'] のキーと一致させる。 ホストは宣言されたキーに基づき $server->fill($request->all()) を JSON config カラム経由で自動ルーティングします。
  • 既存値の読み出しは $server->getConfig('my_api_key') を使い$server->my_api_key を使わない。後者はレガシーの getAttribute フォールバックでたまたま動作しますが、不明瞭で契約上安定していません。

バリデーションパイプライン

管理者が Sending Server フォームで Save をクリックすると、ホストはドライバーのバリデーションを 2 つのフェーズで実行します。

[admin clicks Save]
    │
    ▼
Refactor\Admin\SendingServerController::store
    │
    ▼
$server->validConnection($request->all())              # SendingServer.php
    ├─ Phase 1: Laravel validator with $this->getRules()
    │             └─ → $this->driver()->validationRules()['cols']  (your plugin)
    └─ Phase 2: $validator->after(fn() => $this->driver()->test())
                  ↓ if !ok → adds 'connection' error
    │
    ▼
fails? redirect back with errors → blade `@if($errors->any())` renders
otherwise $server->save() → row in DB

ドライバーが制御する失敗モードは 2 種類です。

  • フィールドレベル (Phase 1)validationRules()['cols'] のルール。ホストは各ルール失敗を Blade 内の対応する name=... フィールドへ自動マップし、そこで @error('my_api_key') がインライン描画されます。
  • 接続レベル (Phase 2)test() から throw されたもの、または TestResult::failure(...) で返されたもの。ホストはフォーム上部のバリデーションサマリーアラート内に描画される、合成された connection フィールドとしてこれを表面化します。

Postal プラグインから学ぶ 5 つの落とし穴

いずれも Postal MTA プラグインで実際に発生したバグです。事前に知っておくと、次のドライバー作者がデバッグに費やす時間を大幅に節約できます。

1. test() は実在するエンドポイントを叩くこと

Postal プラグインの最初の test() 実装は client->makeRequest('servers', 'list', []) — URL /api/v1/servers/list を呼んでいました。Postal の実 API を見ると、messagessend コントローラのみ存在し、servers エンドポイントは存在しません。Postal は毎回 HTTP 404 を返し、有効な認証情報でも管理者には "Status code returned by Postal server: 404" が赤字で表示されました。

修正: 必ずベンダーの API ドキュメントを参照し、認証必須かつ副作用ゼロの既存読み取りエンドポイントを確認します。典型的な候補は GET /meGET /accountGET /domains など。プローブは「200 + 有効キー」と「401/403 + 不正キー」を区別できる必要があります — 「ルート不在で 404」は無意味です。

2. Webhook ペイロードの形状はベンダーバージョン間で変わる

ベンダーは Webhook フォーマットを進化させます。Postal プラグインは「very old」「legacy」「current」をカバーする 3 つのハードコードフォーマットガード付きで出荷しましたが、それでも モダンフォーマットを取りこぼしました。モダンの Postal はすべてを {event, timestamp, payload, uuid} でラップし、MessageBounced のペイロードは {original_message: {token, ...}, bounce: {...}} です。トークンは payload.message.token ではなく payload.original_message.token に存在 — プラグインがこの差異を見落とし、すべてのバウンスを暗黙にドロップしていました。

修正: ベンダーのソースコードを取得し、webhook.trigger(...) の全呼び出し箇所を洗い出します。ベンダーが実際に送信するペイロード形状を漏れなく列挙してください。parseWebhook は未知のイベント名に対して暗黙ドロップせず IgnorableWebhookEvent を返すようにする — 可観測性が重要です。

3. Webhook 署名検証

ほとんどのベンダーは Webhook に署名 (HMAC または RSA) を付与します。プラグイン作者は v1 では verifyWebhook を no-op のまま残しがちですが、これは本番ではセキュリティリスクです。Webhook URL を知る者なら誰でも偽のバウンスを POST できてしまいます。

v1 での修正: verifyWebhook を no-op のまま + 警告ログを記録し、FOLLOW-UP として文書化します。本実装ではベンダーの公開鍵をサーバーごとに (config JSON に) 保存し、リクエストボディに対して署名を検証します。Postal は X-Postal-Signature-KID + X-Postal-Signature-256 ヘッダーで RSA SHA256 署名を行います。

4. runtimeMessageId の選び方

SendResult.runtimeMessageId は、ホストが tracking_logs.runtime_message_id に保存する値です。Webhook リスナーはこの id を介して、受信したバウンスや complaint を元の tracking 行に対応付けます。これはベンダーが Webhook ペイロードに乗せる値と一致しなければなりません。

Postal の /api/v1/send/raw レスポンスはグローバル一意な message_id と受信者ごとの token の両方を返します。Postal の MessageBounced Webhook には payload.original_message.token — 受信者ごと — が含まれます。プラットフォームのドライバーは send() 呼び出しごとに 1 受信者を送信するため、保存すべき正しい値はグローバルな message_id ではなく受信者ごとの token です。

修正: ベンダーが Webhook に受信者ごとの識別子を送っているなら、その受信者ごと識別子を runtimeMessageId に保存します。誤った方を選ぶと、すべての BounceLog が tracking_log_id NULL で終わり、バウンスは届くもののホスト UI には何も表示されません。

5. send()tracking_logs INSERT のレース

ホストの SendMessage Job は先に driver->send() を呼び、その後 TrackingLog 行を INSERT します。ベンダーは INSERT がコミットされる前にバウンスや complaint の Webhook を配送する可能性があります — ミリ秒オーダーのレースですが、本番では実在します。

ホストはすでにこれをリスナーレベルで処理していますRecordBounceRecordComplaint は最大 5 秒間ルックアップをリトライしてから諦めます。プラグイン作者は特別なことをする必要はありませんが、以下のことは 禁止 です。

  • send() 前に TrackingLog を先行 INSERT しない — 高速パスでもすべての送信で DB のラウンドトリップが 2 回になります。
  • send() を外側のトランザクション内で実行しない — 外側のコミットまで TrackingLog INSERT が不可視になり、レース窓が広がります。

有効化 + 検証レシピ

プラグインフォルダを storage/app/plugins/<vendor>/<name>/ 配下に配置したら、tinker で登録・有効化し、5 つのスモークチェックを実行します。

# 1. Register the plugin DB row
php artisan tinker --execute="App\Model\Plugin::register('vendor/name')"

# 2. Activate (fires activate_plugin_ hook)
php artisan tinker --execute="App\Model\Plugin::where('name','vendor/name')->first()->activate()"

# 3. Verify the driver registered
php artisan tinker --execute="
  \$registry = App\SendingServers\DriverRegistry::all();
  echo isset(\$registry['myvendor-api']) ? 'YES → '.\$registry['myvendor-api'] : 'NO';
"

# 4. Verify config keys auto-routed
php artisan tinker --execute="
  \$keys = App\Model\SendingServer::getVendorConfigKeys();
  echo in_array('my_api_key', \$keys, true) ? 'YES' : 'NO';
"

# 5. Verify the connection-blade view is namespaced
php artisan tinker --execute="
  echo Illuminate\Support\Facades\View::exists('myvendor::sending-servers._fields_connection') ? 'YES' : 'NO';
"

5 つのチェックが通過したあとの UI スモーク手順。

  1. 管理者でログイン → Sending Servers → Create。「Plugin Servers」ブロックにあなたのカードが表示されているはずです。
  2. カードをクリック。validationRules()['cols'] で宣言したフィールドでフォームが描画されます。
  3. 有効な認証情報で保存。ホストが Phase 1 (ルール) → Phase 2 (driver->test()) を順に実行し、いずれも通過して行がコミットされます。
  4. 編集ページ。4 つのタブ: Connection (あなたの Blade) + Configuration / Sender Identity / Warmup (ホスト描画) があります。

テストチェックリスト

テスト実施方法
ドライバークラスが構文エラーなしでロードされるphp artisan tinker --execute="new MyVendor\Sending\MyVendorDriver(new App\Model\SendingServer(['type' => 'myvendor-api']))"
test() が有効な認証情報で成功する同じプローブエンドポイントを同じキーで curl 実行 — 両者が同じ形状を返すこと
test() が不正な認証情報で適切に失敗する不正な my_api_key を設定 → Laravel の例外トレースではなく、ベンダーの実エラーメッセージ付きの TestResult::failure を期待
フォームフィールドが正しく送信される有効な認証情報で保存 → DB 行の config JSON に config_keys に列挙したすべてのキーが含まれる
Webhook 受信がバウンス形状を解析する実サンプルのバウンスペイロードを POST → parseWebhook が正しい runtimeMessageId 付きで BounceReceived を返す
Webhook 署名検証 (実装している場合)不正な署名で POST → verifyWebhook が throw する
プラグインアンインストールでクリーンアップされるApp\Model\Plugin::find($id)->delete() → この typesending_servers 孤児行が残らない
validationRules()config_keys の全フィールド名をカバーするarray_keys(validationRules()['cols'])config_keys ペイロードを diff — 完全一致が必須

エンドツーエンドのライブテストには、ホストに aws-e2e/ テストハーネスが同梱されており、実際の SendingServer 行をブートストラップし、テストリストを作成し、キャンペーンを実行し、バウンス/complaint のフローをアサートします。ライブの回帰カバレッジには、その 3 スクリプトパターン (10-bootstrap-emma-{vendor}.php + 11-create-test-list.php + 12-run-test-campaign.php) を踏襲してください。

ファイルシステムのテンプレート

動作するプラグインへの最短経路は Postal プラグインをクローンしてリネームすることです。6 つの編集と数回の検索置換で済みます。

cp -r storage/app/plugins/rencontru/postal storage/app/plugins/<vendor>/<name>
cd storage/app/plugins/<vendor>/<name>

# 1. Edit composer.json — name, namespace, autoload.psr-4
# 2. Rename src/PostalDriver.php → <Vendor>Driver.php, update class name + TYPE
# 3. Update src/ServiceProvider.php — PLUGIN_NAME, view namespace, hook payload
# 4. Adjust resources/views/sending-servers/_fields_connection.blade.php
# 5. Adjust resources/lang/en/messages.php
# 6. Replace the Postal HTTP client under src/Postal/* with your vendor client

Postal プラグインは形状の参照には有用ですが、API クライアントは Postal 固有 — 適応ではなく置換してください。テストパターン、サイドバー UI、管理ページの異なる参照が必要な場合は、標準的な複雑プラグインを示す acelle/ai ショーケースを参照してください。

次のステップ

送信ドライバーと 決済ゲートウェイ は、「機能プラグインを提供する」最も重い 2 つの実例です。形状は類似 — どちらも単一の REGISTRY フック、必須メソッドの少ないクラス、接続 Blade を提供 — しますが、ライフサイクルは大きく異なります。送信ドライバーは Webhook を受信する (push)、決済ゲートウェイは同期スケジュールで状態を取得する (no webhook) という違いです。次ページでは Paddle を題材に決済ゲートウェイパターンを扱います。

ドライバーが完成・稼働したら、テスト ページで、delete_plugin_* リスナーが正しくクリーンアップすることを証明するライフサイクル統合テストのレシピ (activate → test-send → delete) を解説しています。より重いリファレンスコードベースが必要なら、acelle/ai ショーケース が標準的な複雑プラグインを案内します。