为何把驱动作为插件发布
AcelleMail 内置一组稳定的第一方驱动 — Amazon SES、通用 SMTP、sendmail、Postmark、SendGrid、Mailgun 等。其他任何厂商 — 地区供应商、自托管 MTA、小众事务型服务、自定义后端 — 都需要把同样五件事接入宿主:选择页中的一行、连接表单、校验步骤、用于退信与投诉的 webhook 监听器,以及运行时的 send() 实现。在 fork 中实现意味着永远跟踪宿主升级;以插件实现意味着把一个文件夹放进 storage/app/plugins/{vendor}/{name}/,由宿主负责所有宿主侧关注点。
插件契约刻意做得很小。一个 REGISTRY 钩子声明驱动。一个驱动类,五个必需方法(send、test、setupBeforeSend、validationRules,加上标准的服务名访问器)。一个连接表单 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 中;插件只贡献驱动特有的部分。
插件实际向宿主注册的四样东西:
- 驱动类 + 元数据 — 一个
Hook::add('register_sending_server_driver', ...) 载荷,携带类型 slug、驱动类 FQCN、厂商配置键以及选择卡元数据。
- 视图命名空间 —
$this->loadViewsFrom($path, 'myvendor'),使 view('myvendor::...') 解析到插件模板。
- 翻译文件 — 一个
Hook::add('add_translation_file', ...) 载荷,指向插件 resources/lang/ 用于 master + dump-clone 路径。
- 连接选项卡 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';
}
}
四个服务名访问器(getServiceName、getServiceIcon、getServiceColor)已足够宿主在 Sending Servers UI 中渲染选择卡和所选服务器表头。send() 与 test() 是生产热路径 — 每一次通过此类型服务器发送的营销活动,每个收件人都会调用一次 send();管理员页面每一次点击「测试连接」都会调用 test()。setupBeforeSend() 在每个营销活动批次开始时运行一次 — 多数驱动让它保持为空。
能力标记接口
在最小界面之外,宿主暴露一组能力标记接口。驱动只实现适用的标记 — 宿主在每个调用点都做 instanceof 检查,所以未实现 ReceivesWebhooks 的驱动只会跳过 webhook 路由注册,而不会抛错。
| 标记 | 驱动需实现什么 |
ProvidesConnectionFieldsView | 自定义连接选项卡 blade — connectionFieldsView(): string 返回命名空间视图路径。 |
ReceivesWebhooks | verifyWebhook + parseWebhook + webhookUrl — 厂商向 /webhook/{type}/{uid} 进行 POST 反馈;宿主将载荷路由到您的驱动。 |
SupportsIdentitySync | syncIdentities + verifyIdentity — 宿主会为此驱动渲染 Sender Identity 选项卡。 |
SupportsRemoteDomainVerify | addDomain + validateDomain + checkDomainVerificationStatus — DNS 验证由厂商处理,而非宿主。 |
SignsDkimOnServer | 服务器签 DKIM — 宿主跳过自己的签名层。 |
SupportsCustomReturnPath | 尊重出站邮件上的自定义 Return-Path 头。 |
AllowsLocalDomainVerify / AllowsLocalEmailVerify / AllowsCrossSendingDomain | 通用 SMTP 风格的灵活性标志。 |
SendsCustomVerificationEmail | sendVerificationEmail(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 后,只存在 messages 与 send 控制器;并没有 servers 端点。即便凭证有效,Postal 每次也返回 HTTP 404,管理员看到的是红色的 "Status code returned by Postal server: 404"。
修复:始终对照厂商 API 文档,寻找一个需要鉴权且无副作用的现有读端点。典型候选:GET /me、GET /account、GET /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 — 毫秒级的竞态,在生产中真实存在。
宿主已经在监听器层面处理了这一点:RecordBounce 与 RecordComplaint 会重试查找最多 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 冒烟:
- 以管理员登录 → Sending Servers → Create。「Plugin Servers」区块应能显示您的卡片。
- 点击您的卡片。表单按
validationRules()['cols'] 中声明的字段渲染。
- 使用有效凭证保存。宿主跑阶段 1(规则)然后阶段 2(
driver->test()),都通过,记录提交。
- 编辑页。四个选项卡: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 产出带正确 runtimeMessageId 的 BounceReceived |
| 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 走读了规范的复杂插件。