Tại sao ship driver dưới dạng plugin
AcelleMail ship sẵn một bộ first-party driver ổn định — Amazon SES, generic SMTP, sendmail, Postmark, SendGrid, Mailgun, và vài cái khác. Mọi vendor còn lại — provider theo khu vực, MTA self-hosted, transactional service ngách, custom backend — đều cần năm thứ giống nhau wire vào host: một dòng trong picker page, một connection form, một validation step, một webhook listener cho bounce + complaint, và một send() implementation runtime. Làm trong fork nghĩa là phải track upgrade host mãi mãi; làm dưới dạng plugin nghĩa là chỉ cần drop một folder vào storage/app/plugins/{vendor}/{name}/ và để host lo mọi mối quan tâm phía host.
Contract của plugin nhỏ một cách cố tình. Một REGISTRY hook để khai báo driver. Một driver class với năm method bắt buộc (send, test, setupBeforeSend, validationRules, cộng các accessor service-name chuẩn). Một Blade partial cho connection form. Capability marker interface tuỳ chọn cho mọi thứ khác — webhook, identity sync, custom verification email. Hết. Render picker, layout form, save action, validation pipeline, routing webhook — đều nằm trong host.
Contract — plugin ship cái gì
Cây file đầy đủ của một plugin sending-driver:
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
Skeleton mỏng một cách cố tình. routes.php đăng ký đúng một route — serve icon.svg của plugin từ disk để picker page có gì đó để render. CRUD endpoint, webhook URL, save action của form đều sống trong Refactor\Admin\SendingServerController của host; plugin chỉ đóng góp surface area riêng cho driver.
Bốn thứ plugin thực sự đăng ký với host:
- Driver class + metadata — một payload
Hook::add('register_sending_server_driver', ...) duy nhất mang theo type slug, FQCN của driver class, các config key của vendor, và metadata cho picker card.
- View namespace —
$this->loadViewsFrom($path, 'myvendor') để view('myvendor::...') resolve tới template của plugin.
- Translation file — một payload
Hook::add('add_translation_file', ...) trỏ vào resources/lang/ của plugin cho master + path dump-clone.
- Blade tab Connection — implement capability marker
ProvidesConnectionFieldsView trên driver, trả về path partial mà form của host render.
ServiceProvider — pattern boot
Service provider skeleton đầy đủ cho một plugin sending-driver (paraphrase từ plugin 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();
});
}
}
Hai rule không hiển nhiên mà host enforce:
- Mọi
Hook::add trừ add_translation_file đặt trong boot(), không bao giờ trong register(). SendingServerServiceProvider của host defer việc gom driver-registry qua $this->app->booted(...) đúng để plugin có thời gian đăng ký qua boot() của riêng nó; đặt register_sending_server_driver trong register() nghĩa là closure có thể chạy trước khi dependency của chính nó (đặc biệt là route()) sẵn sàng.
- Đừng
asset('plugins/myvendor/sending/icon.svg') từ hook payload. Không có auto-publish step nào copy asset plugin vào public/plugins/... cho plugin sending-driver; path đó 404 trong production. Plugin tự sở hữu route cho icon (define trong routes.php), và hook payload tham chiếu route đó bằng tên. Self-contained — drop folder plugin vào, icon reachable mà không cần bất kỳ code path nào của host.
Driver class
Driver tối thiểu khả thi kế thừa App\SendingServers\Drivers\AbstractDriver và implement marker ProvidesConnectionFieldsView (gợi ý cho host rằng driver này có connection blade của riêng nó):
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';
}
}
Bốn accessor service-name (getServiceName, getServiceIcon, getServiceColor) là tất cả host cần để render picker card và header server đã chọn trong UI Sending Servers. send() và test() là hot path production — mọi campaign gửi qua server type này gọi send() một lần per-recipient; mọi click "Test connection" trên admin page gọi test(). setupBeforeSend() chạy một lần ở đầu một batch campaign — phần lớn driver để trống.
Capability marker interface
Ngoài surface tối thiểu, host expose một bộ capability marker interface. Driver chỉ implement marker nào áp dụng được — host làm check instanceof ở mỗi callsite, nên driver không implement ReceivesWebhooks đơn giản skip phần đăng ký route webhook mà không throw.
| Marker | Driver implement gì |
ProvidesConnectionFieldsView | Blade tab Connection riêng — connectionFieldsView(): string trả về view path đã namespaced. |
ReceivesWebhooks | verifyWebhook + parseWebhook + webhookUrl — vendor POST feedback tới /webhook/{type}/{uid}; host route payload tới driver của bạn. |
SupportsIdentitySync | syncIdentities + verifyIdentity — host render tab Sender Identity cho driver này. |
SupportsRemoteDomainVerify | addDomain + validateDomain + checkDomainVerificationStatus — vendor xử lý verify DNS, không phải host. |
SignsDkimOnServer | Server tự sign DKIM — host skip lớp sign của chính nó. |
SupportsCustomReturnPath | Tôn trọng custom header Return-Path trên mail outgoing. |
AllowsLocalDomainVerify / AllowsLocalEmailVerify / AllowsCrossSendingDomain | Flag flexibility kiểu generic SMTP. |
SendsCustomVerificationEmail | sendVerificationEmail(Sender) — driver tự render + gửi verification message của nó thay vì default của host. |
Blade tab Connection
Connection partial dưới resources/views/sending-servers/_fields_connection.blade.php chỉ render các field form. Host wrap nó trong <form>, submit button, validation alert, và chrome của page bốn-tab:
<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>
Ba rule chi phối partial:
- Chỉ field, không
<form>, không submit button. Host sở hữu form wrapper. Tự thêm submit của bạn sẽ bắn nhầm endpoint save.
- Field
name match với payload config_keys + key validationRules()['cols']. Host auto-route $server->fill($request->all()) qua cột JSON config dựa trên key bạn khai báo.
- Đọc value hiện có qua
$server->getConfig('my_api_key'), không phải $server->my_api_key. Cách sau happens-to-work qua fallback getAttribute legacy nhưng lờ mờ và không ổn định về mặt contract.
Validation pipeline
Khi admin click Save trên form Sending Server, host chạy validation của driver bạn theo hai phase:
[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
Driver của bạn control hai failure mode:
- Field-level (Phase 1) — rule trong
validationRules()['cols']. Host auto-map mỗi rule failure tới field name=... tương ứng trong blade của bạn, nơi @error('my_api_key') render inline.
- Connection-level (Phase 2) — bất cứ thứ gì throw hoặc
TestResult::failure(...)-return từ test(). Host surface nó lên field synthetic connection render trong validation-summary alert ở đỉnh form.
Năm cái bẫy từ plugin Postal
Đây là bug thật mà plugin Postal MTA đã dẫm. Biết trước tiết kiệm cho author driver kế tiếp hàng giờ debug.
1. test() phải hit endpoint thật
Implementation test() đầu tiên của plugin Postal gọi client->makeRequest('servers', 'list', []) — URL /api/v1/servers/list. Nhìn vào API thật của Postal, chỉ có controller messages và send; không có endpoint servers. Postal trả HTTP 404 mỗi lần, và admin thấy "Status code returned by Postal server: 404" chữ đỏ ngay cả với credential hợp lệ.
Fix: luôn cross-check API documentation của vendor để tìm read endpoint tồn tại, yêu cầu auth, và có zero side effect. Ứng viên điển hình: GET /me, GET /account, GET /domains. Probe phải phân biệt 200-với-key-hợp-lệ với 401/403-với-key-sai — 404-on-missing-route vô nghĩa.
2. Hình dạng payload webhook đổi giữa các version của vendor
Vendor evolve format webhook. Plugin Postal ship với ba format guard hardcoded cover "very old", "legacy", và "current" — và vẫn miss format hiện đại. Postal hiện đại wrap mọi thứ trong {event, timestamp, payload, uuid}; cho MessageBounced, payload là {original_message: {token, ...}, bounce: {...}}. Token nằm ở payload.original_message.token, không phải payload.message.token — plugin miss chỗ khác biệt và silently drop mọi bounce.
Fix: pull source code của vendor và tìm mọi callsite webhook.trigger(...). Liệt kê exact payload shape vendor thực sự gửi. Cho parseWebhook return IgnorableWebhookEvent cho event name không biết thay vì silent drop — observability quan trọng.
3. Verify chữ ký webhook
Phần lớn vendor sign webhook của họ (HMAC hoặc RSA). Plugin author hay để verifyWebhook là no-op cho v1 — risk bảo mật trong production, vì ai biết webhook URL đều có thể POST một bounce giả.
Fix cho v1: để verifyWebhook no-op + log một warning, document là FOLLOW-UP. Implementation thật store public key của vendor per-server (trong JSON config) và verify chữ ký so với request body. Postal sign bằng RSA SHA256 qua header X-Postal-Signature-KID + X-Postal-Signature-256.
4. Chọn runtimeMessageId
SendResult.runtimeMessageId là cái host store vào tracking_logs.runtime_message_id. Webhook listener correlate bounce + complaint inbound về tracking row gốc qua id này. Nó phải match cái vendor đặt trong webhook payload.
Response /api/v1/send/raw của Postal trả về cả message_id globally-unique và token per-recipient. Webhook MessageBounced của Postal chứa payload.original_message.token — per-recipient. Driver của platform gửi một recipient mỗi call send(), nên giá trị đúng để store là token per-recipient, không phải message_id global.
Fix: nếu vendor gửi identifier per-recipient trong webhook của họ, store identifier per-recipient trong runtimeMessageId. Chọn nhầm nghĩa là mọi BounceLog kết thúc với tracking_log_id NULL — bounce đến nhưng không có gì trong UI host bao giờ show.
5. Race giữa send() và INSERT tracking_logs
Job SendMessage của host gọi driver->send() trước, rồi insert dòng TrackingLog. Vendor có thể deliver webhook bounce hoặc complaint trước khi INSERT commit — race scale milliseconds, thật trong production.
Host đã handle cái này ở mức listener: RecordBounce và RecordComplaint retry lookup tới 5 giây trước khi bỏ cuộc. Plugin author không cần làm gì đặc biệt, nhưng KHÔNG NÊN:
- Pre-INSERT một TrackingLog trước
send() — mỗi send thành hai round-trip DB ngay cả trên fast path.
- Chạy
send() bên trong transaction ngoài — INSERT TrackingLog thành invisible cho tới khi commit ngoài, mở rộng window race.
Recipe activate + verify
Sau khi drop folder plugin dưới storage/app/plugins/<vendor>/<name>/, đăng ký và activate qua tinker, rồi chạy năm smoke check:
# 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';
"
Smoke UI sau khi năm check pass:
- Login admin → Sending Servers → Create. Block "Plugin Servers" giờ phải show card của bạn.
- Click card của bạn. Form render với các field khai báo trong
validationRules()['cols'].
- Save với credential hợp lệ. Host chạy Phase 1 (rule) rồi Phase 2 (
driver->test()); cả hai pass và row commit.
- Edit page. Bốn tab: Connection (blade của bạn) + Configuration / Sender Identity / Warmup (host-rendered).
Testing checklist
| Test | Cách |
| Driver class load không lỗi syntax | php artisan tinker --execute="new MyVendor\Sending\MyVendorDriver(new App\Model\SendingServer(['type' => 'myvendor-api']))" |
test() succeed với credential hợp lệ | Chạy curl đến cùng probe endpoint với cùng key — cả hai phải trả về cùng shape |
test() fail êm với credential sai | Set sai my_api_key → expect TestResult::failure với error message thật của vendor, không phải trace exception Laravel |
| Field form submit đúng | Save với cred hợp lệ → JSON config của row DB có mọi key liệt kê trong config_keys |
| Intake webhook parse shape bounce | POST một payload bounce sample thật → parseWebhook yield BounceReceived với runtimeMessageId đúng |
| Verify chữ ký webhook (nếu đã implement) | POST với chữ ký không hợp lệ → verifyWebhook throw |
| Uninstall plugin clean up | App\Model\Plugin::find($id)->delete() → không có row sending_servers orphan với type này |
validationRules() cover mọi field name trong config_keys | Diff array_keys(validationRules()['cols']) với payload config_keys — phải match chính xác |
Cho live test end-to-end, host ship harness aws-e2e/ bootstrap một row SendingServer thật, tạo test list, chạy campaign, và assert trên flow bounce/complaint. Mirror pattern ba script của nó (10-bootstrap-emma-{vendor}.php + 11-create-test-list.php + 12-run-test-campaign.php) cho live regression coverage.
Template filesystem
Đường nhanh nhất đến một plugin chạy được là clone plugin Postal và rename. Sáu edit và vài search-and-replace:
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
Plugin Postal là reference hữu ích về shape nhưng API client của nó Postal-specific — replace, đừng adapt. Showcase acelle/ai cover plugin phức tạp chính tắc nếu bạn cần reference khác cho pattern testing, UI sidebar, hoặc admin page.
Đi tiếp đâu
Sending drivers và payment gateways là hai worked example "ship một feature plugin" nặng nhất. Shape tương tự — cả hai ship một REGISTRY hook duy nhất, một class với required-method surface nhỏ, và một connection blade — nhưng lifecycle khác đáng kể. Sending driver nhận webhook (push); payment gateway pull state theo lịch sync (không webhook). Trang kế tiếp cover pattern payment-gateway với Paddle là worked example.
Khi driver đã ship và live, Testing cover recipe lifecycle-integration test (activate → test-send → delete) chứng minh listener delete_plugin_* của bạn clean up đúng. Showcase acelle/ai đi qua plugin phức tạp chính tắc nếu bạn cần codebase reference nặng hơn.