全新的 MTA 后端。作为插件发布。无需 fork 核心。

AcelleMail 中的发送驱动是拥有某一个厂商的类 — Amazon SES、Postal、SendGrid 或您自己的 SMTP 后端。宿主应用预留了一个 REGISTRY 钩子(register_sending_server_driver)以及一小组能力标记接口;其余一切 — 选择页、连接表单、校验流水线、webhook 控制器、Sender Identity 选项卡、预热选项卡 — 都由宿主提供。本页是从 plugin:init 走到通过驱动完成一次实时发送的实操示例,提炼自 Postal MTA 插件(storage/app/plugins/rencontru/postal/)的静态评审。

为何把驱动作为插件发布

AcelleMail 内置一组稳定的第一方驱动 — Amazon SES、通用 SMTP、sendmail、Postmark、SendGrid、Mailgun 等。其他任何厂商 — 地区供应商、自托管 MTA、小众事务型服务、自定义后端 — 都需要把同样五件事接入宿主:选择页中的一行、连接表单、校验步骤、用于退信与投诉的 webhook 监听器,以及运行时的 send() 实现。在 fork 中实现意味着永远跟踪宿主升级;以插件实现意味着把一个文件夹放进 storage/app/plugins/{vendor}/{name}/,由宿主负责所有宿主侧关注点。

插件契约刻意做得很小。一个 REGISTRY 钩子声明驱动。一个驱动类,五个必需方法(sendtestsetupBeforeSendvalidationRules,加上标准的服务名访问器)。一个连接表单 Blade partial。可选的能力标记接口处理其余一切 — webhook、身份同步、自定义验证邮件。就这些。选择页渲染、表单布局、保存动作、校验流水线、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 恰好注册一个路由 — 从磁盘提供插件的 icon.svg,以便选择页能渲染出某些东西。CRUD 端点、webhook URL、表单保存动作都位于宿主的 Refactor\Admin\SendingServerController 中;插件只贡献驱动特有的部分。

插件实际向宿主注册的四样东西:

  1. 驱动类 + 元数据 — 一个 Hook::add('register_sending_server_driver', ...) 载荷,携带类型 slug、驱动类 FQCN、厂商配置键以及选择卡元数据。
  2. 视图命名空间$this->loadViewsFrom($path, 'myvendor'),使 view('myvendor::...') 解析到插件模板。
  3. 翻译文件 — 一个 Hook::add('add_translation_file', ...) 载荷,指向插件 resources/lang/ 用于 master + dump-clone 路径。
  4. 连接选项卡 blade — 在驱动上实现 ProvidesConnectionFieldsView 能力标记,返回宿主表单要渲染的 partial 路径。

ServiceProvider — 启动模式

发送驱动插件的完整脚手架 service provider(按 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();
        });
    }
}

宿主强制的两条不太显然的规则:

  • 除了 add_translation_file,每一个 Hook::add 都放在 boot(),而不是 register()。宿主的 SendingServerServiceProvider 通过 $this->app->booted(...) 推迟其驱动注册表收集,正是为了给插件留出通过自己的 boot() 注册的时间;把 register_sending_server_driver 放进 register() 意味着闭包可能在它自己的依赖(特别是 route())可用之前运行。
  • 不要在 hook 载荷里 asset('plugins/myvendor/sending/icon.svg')。发送驱动插件没有自动发布步骤把插件资源复制到 public/plugins/...;该路径在生产中会 404。插件自带图标路由(定义在 routes.php),hook 载荷按名称引用该路由。自包含 — 把插件文件夹放进去,图标无需任何宿主代码路径即可访问。

驱动类

最小可用驱动继承自 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';
    }
}

四个服务名访问器(getServiceNamegetServiceIcongetServiceColor)已足够宿主在 Sending Servers UI 中渲染选择卡和所选服务器表头。send()test() 是生产热路径 — 每一次通过此类型服务器发送的营销活动,每个收件人都会调用一次 send();管理员页面每一次点击「测试连接」都会调用 test()setupBeforeSend() 在每个营销活动批次开始时运行一次 — 多数驱动让它保持为空。

能力标记接口

在最小界面之外,宿主暴露一组能力标记接口。驱动只实现适用的标记 — 宿主在每个调用点都做 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 的连接 partial 只渲染表单字段。宿主负责包裹 <form>、提交按钮、校验提示与四选项卡页面框架:

<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>

三条规则管辖此 partial:

  • 只放字段,不放 <form>,不放提交按钮。表单包装由宿主拥有。自行添加 submit 会触发错误的保存端点。
  • 字段 name 要与 config_keys 载荷 + validationRules()['cols'] 的键匹配。宿主根据您声明的键自动通过 JSON config 列路由 $server->fill($request->all())
  • 通过 $server->getConfig('my_api_key') 读取既有值,而非 $server->my_api_key。后者恰好通过遗留的 getAttribute 回退能用,但语义混乱且不在契约中稳定。

校验流水线

当管理员在 Sending Server 表单点击保存,宿主会分两阶段运行您的驱动校验:

[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

您的驱动控制两种失败模式:

  • 字段级(阶段 1)validationRules()['cols'] 中的规则。宿主自动把每条规则失败映射到 blade 中对应的 name=... 字段,@error('my_api_key') 会在那里行内渲染。
  • 连接级(阶段 2) — 从 test() 抛出或 TestResult::failure(...) 返回的任何内容。宿主会把它呈现在表单顶部校验汇总提示中一个合成的 connection 字段上。

Postal 插件的五个陷阱

这些都是 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 插件发布时硬编码了三个格式守卫,覆盖「很旧」「遗留」与「当前」 — 却依然错过了现代格式。现代 Postal 把一切包装为 {event, timestamp, payload, uuid};对于 MessageBounced,载荷是 {original_message: {token, ...}, bounce: {...}}。token 在 payload.original_message.token,而不是 payload.message.token — 插件没看出差别,静默丢弃了每一次退信。

修复:拉取厂商源码,找到每一处 webhook.trigger(...) 调用点。枚举厂商实际发送的准确载荷形状。让 parseWebhook 对未知事件名返回 IgnorableWebhookEvent,而不是静默丢弃 — 可观测性很重要。

3. Webhook 签名校验

大多数厂商会对其 webhook 签名(HMAC 或 RSA)。插件作者常常在 v1 把 verifyWebhook 留作空操作 — 在生产中是安全风险,因为任何知道 webhook URL 的人都能 POST 一次伪造退信。

v1 修复:verifyWebhook 留作空操作 + 记一条警告,作为 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 把入站退信与投诉关联回原始跟踪行。它必须与厂商在 webhook 载荷中放的值一致。

Postal 的 /api/v1/send/raw 响应同时返回一个全局唯一的 message_id 和按收件人的 token。Postal 的 MessageBounced webhook 含 payload.original_message.token — 按收件人。平台的驱动每次 send() 只发一个收件人,所以正确存储的是按收件人的 token,而不是全局 message_id

修复:如果厂商在 webhook 中发送按收件人的标识符,请在 runtimeMessageId 中存储按收件人的标识符。选错了会导致每条 BounceLog 的 tracking_log_id 都为 NULL — 退信到达,但宿主 UI 中永远不会显示。

5. send()tracking_logs INSERT 之间的竞态

宿主的 SendMessage job 先调用 driver->send(),然后插入 TrackingLog 行。厂商可能在 INSERT 提交之前送达一次退信或投诉 webhook — 毫秒级的竞态,在生产中真实存在。

宿主已经在监听器层面处理了这一点RecordBounceRecordComplaint 会重试查找最多 5 秒后才放弃。插件作者无需做任何特殊处理,但不要

  • send() 之前预插入 TrackingLog — 每次发送会变成两次数据库往返,即便在快路径上。
  • 在外层事务里运行 send() — TrackingLog INSERT 在外层提交前不可见,会扩大竞态窗口。

激活 + 验证配方

把插件文件夹放到 storage/app/plugins/<vendor>/<name>/ 之后,通过 tinker 注册并激活,然后跑五项冒烟检查:

# 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';
"

五项检查通过后,进行 UI 冒烟:

  1. 以管理员登录 → Sending Servers → Create。「Plugin Servers」区块应能显示您的卡片。
  2. 点击您的卡片。表单按 validationRules()['cols'] 中声明的字段渲染。
  3. 使用有效凭证保存。宿主跑阶段 1(规则)然后阶段 2(driver->test()),都通过,记录提交。
  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 设错 → 期望 TestResult::failure,携带厂商真实错误消息,而非 Laravel 异常堆栈
表单字段正确提交用有效凭证保存 → 数据库行的 config JSON 包含 config_keys 中列出的每一个键
Webhook 接收解析退信形状POST 一个真实退信样例载荷 → parseWebhook 产出带正确 runtimeMessageIdBounceReceived
Webhook 签名校验(若已实现)用无效签名 POST → verifyWebhook 抛错
插件卸载完成清理App\Model\Plugin::find($id)->delete() → 无此 type 的孤儿 sending_servers
validationRules() 覆盖 config_keys 中每一个字段名对照 array_keys(validationRules()['cols'])config_keys 载荷 — 必须完全一致

对于端到端实时测试,宿主提供 aws-e2e/ 测试夹具,启动真实的 SendingServer 行、创建测试名单、运行营销活动并对退信/投诉流程进行断言。可以镜像其三脚本模式(10-bootstrap-emma-{vendor}.php + 11-create-test-list.php + 12-run-test-campaign.php)以获得实时回归覆盖。

文件系统模板

最快获得可用插件的路径是克隆 Postal 插件然后重命名。六处编辑加一些查找替换:

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 showcase 中的规范复杂插件。

下一步阅读

发送驱动与支付网关是「发布一个功能插件」最重的两个实操示例。形状相似 — 都发布一个 REGISTRY 钩子、一个带小型必需方法集的类以及一个连接 blade — 但生命周期差异显著。发送驱动接收 webhook(推送);支付网关按同步计划拉取状态(无 webhook)。下一页以 Paddle 为示例讲解支付网关模式。

当驱动发布并上线后,测试会讲解证明您的 delete_plugin_* 监听器正确清理的生命周期集成测试配方(activate → 测试发送 → delete)。如果您需要一个更厚的参考代码库,acelle/ai showcase 走读了规范的复杂插件。