A brand-new MTA backend. Shipped as a plugin. Without forking core.
A sending driver in Senddera is the class that owns one vendor — Amazon SES, Postal, SendGrid, your own SMTP backend. The host application reserves a single REGISTRY hook (register_sending_driver_provider) plus a small set of capability marker interfaces; everything else — the picker page, the connection form, the validation pipeline, the webhook controller, the sender loop, ... — is all handled by the plugin.
storage/app/plugins/rencontru/postal/) static review.
Why ship a driver as a plugin
Senddera is a platform. Out of the box, it supports Amazon SES, generic SMTP, sendmail, Postmark, SendGrid, Mailgun, and a handful of others. Every other vendor — regional providers, self-hosted MTAs, niche transactional services, custom backends — needs the same five things wired into the host: a row in the picker page, a connection form, a validation tab, a webhook listener for bounces and complaints, and a runtime send() implementation.
Doing that in a fork means tracking host upgrades forever; doing it as a plugin means dropping a folder into storage/app/plugins/{vendor}/{name}/ and letting the host take care of every host-side concern.
The plugin contract is small on purpose. One REGISTRY hook to declare the driver. One driver class with five required methods (send, test, setupBeforeSend, validateOnSubmit, validateAfterSubmit), plus the standard plugin assets, all for the connection form. Optional capability marker interfaces for everything else — webhooks, identity sync, custom verification email. That's it. Picker rendering, form layout, save action, validation pipeline, webhook routing — all in the host.
The contract — what a plugin ships
The full file tree of a sending-driver plugin:
storage/app/plugins/vendor/name/
├── composer.json // PSR-4 & Laravel provider hook
├── icon.svg // driver logo for the picker page
├── routes.php // picker page route, served by routes.php
├── src/
│ ├── ServiceProvider.php // the hook: name & namespace lifecycle
│ └── Driver.php // the driver class
├── resources/
│ ├── views/
│ │ └── connection_tab.blade.php // the connection-tab form fields
│ └── lang/
│ └── en.php // labels & help text
The skeleton is intentionally thin. routes.php registers exactly one route — serving the plugin's icon.svg from disk so the picker page has something to render. CRUD endpoints, the webhook URL, and individual sending logs all live in the host's SendingServerController; the plugin contributes only the driver-specific surface area.
Four things the plugin actually registers with the host:
- Driver class + metadata — a single hook:
add_hook('register_sending_driver_provider', ...)payload carrying the type slug, the driver class FQCN, the vendor config keys, and the picker card metadata. - View namespace —
$this->loadViewsFrom(__DIR__.'/../resources/views', 'myvendor')so themyvendor::...resolves to the plugin's templates. - Translation folder —
add_translation_file(...)payload pointing at the plugin'sresources/lang/for the masterme+dump-clonepath. - Connection-tab blade — implements the
ProvidesConnectionFieldViewcapability marker on the driver; returns the partial path the host's form renders.
ServiceProvider — the boot pattern
The full skeleton service provider for a sending-driver plugin (paraphrased from the Postal plugin):
<?php
namespace MyVendor\Sending;
use Senddera\Library\Support\ServiceProvider as Base;
class MySendingServiceProvider extends Base
{
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 application can't find the labels during component build.
Hook::add('add_translation_file', function() {
return [
'plugin_name' => self::PLUGIN_NAME,
'type' => 'translation',
'translation_folder' => __DIR__.'/../resources/lang/en/',
'translation_prefix' => 'myvendor',
'master_translation_file' => realpath(__DIR__.'/../resources/lang/en/messages.php'),
];
});
}
public function boot(): void
{
// (1) View namespace - plugin's own views.
// Used in Connection tab as 'myvendor::fields'
$this->loadViewsFrom(__DIR__.'/../resources/views', 'myvendor');
// (2) The single REGISTRY hook that the SendingServiceProvider
// listens for. This registers the driver into the sending core.
// It should return an array if the driver is ready.
Hook::add('register_sending_driver_provider', function() {
return [
'type' => 'myvendor-driver-type',
'name' => 'MyVendor SMTP',
'driver_class' => 'MyVendor\Sending\Driver',
'config_keys' => ['api_key', 'my_region'], // JSON config column
'description' => 'Send via MyVendor API.',
'image' => route('myvendor_sending_icon'),
// create_at_omit => map admin derives from 'type'
];
});
// (3) Lifecycle - only if the plugin needs cleanup on uninstall.
Hook::add('delete_plugin', self::PLUGIN_NAME, function() {
\App\Models\SendingServer::where('type', 'myvendor-driver-type')->forceDelete();
});
}
}
Two non-obvious rules the host enforces:
- Every
Hook::add(exceptadd_translation_file) goes inboot(), never inregister(). The host'sSendingServerServiceProviderdefers its driver-registry collection via$this->app->booted(...)so exactly no plugins have time to register through their ownboot(); puttingregister_sending_driver_providerinregister()means the closure runs before its own host lifecycle is ready. - Do not asset
'storage/app/plugins/myvendor/sending/icon.svg'from the hook payload. There is no auto-publish step that copies plugin assets intopublic/assets/...; for sending-driver plugins, that path stays in production. The plugin owns its own route for the icon (defined inroutes.php), and the hook payload references that route by name. Self-contained — drop the plugin folder in, the icon is reachable without any host-side copy path.
The driver class
The minimum-viable driver inherits from Apps\SendingServers\Drivers\AbstractDriver and should implement ProvidesConnectionFieldView marker (which gives the host a hint that this driver has its own connection blade):
namespace MyVendor\Sending;
use Senddera\Library\Send\Base;
use Senddera\Library\Contracts\ProvidesConnectionFieldView;
use Apps\SendingServers\Drivers\AbstractDriver;
use Apps\SendingServers\Drivers\TestResult;
use Apps\SendingServers\Drivers\TestResultBasic;
class MyVendorDriver extends AbstractDriver implements ProvidesConnectionFieldView
{
public const TYPE = 'myvendor-api';
public function getTypeDisplayName(): string { return 'MyVendor'; }
public function getTypeDisplayIcon(): string { return '...'; } // Material Symbol ligature
public function getTypeDisplayColor(): string { return 'text-chart-2'; }
public function sendMessage(array $params): array // returns [] | SendResult
{
// Call vendor API to deliver message
// $params['to'], $params['subject'], $params['html'], ...
// $this->getApiToken() -- helper to fetch from the Sending Server row
$senderMessageId = $this->callVendorApi($params);
return SendResult::success($senderMessageId);
}
public function test(): TestResult
{
try {
// !! pitfall 9.1 - MUST hit a real endpoint that requires auth.
// ping() logic here
return TestResult::success();
} catch (\Throwable $e) {
return TestResult::failure($e->getMessage());
}
}
public function setupBeforeSend($messageId): void
{
// No-op for most drivers. Implement if vendor needs per-batch
// setup — SPF/DKIM setup verification, identity feedback enable, etc.
}
public function validateOnSubmit(): array
{
$r = parent::validateOnSubmit();
$r['api_key'] = 'required';
$r['my_region'] = 'required|in:us,eu';
return $r;
}
public function getConnectionFieldView(): string
{
return 'myvendor::sending-servers._fields_connection';
}
}
The four service-name accessors (getServiceName, getServiceIcon, getServiceColor) are all UI-only — they power the picker card and the chosen-server header in the Sending Servers UI. send() and test() are the production hot-paths — every campaign sent through a server of this type calls send() once per recipient; every time an admin clicks Test, test() runs. setupBeforeSend() runs once at the start of a campaign batch — most drivers leave it empty.
Capability marker interfaces
Beyond the minimum surface, the host exposes a set of capability marker interfaces. The driver implements only the markers that apply — the host does instanceof checks at every call site, so a driver that does not implement ReceivesWebhooks simply skips the webhook route registration logic. Three (in particular) are available:
| Marker | What the driver implements |
|---|---|
ProvidesConnectionFieldView |
Custom connection form partial in the host's popup |
ReceivesWebhooks |
verifyWebhook & handleWebhook — host gives you the raw payload |
SupportsIdentitySync |
syncIdentities — triggers per-sender verification loop (DKIM/SPF) |
SupportsRemoteDomainVerify |
addDomain + validateDomain — triggers the verified-domains UI |
SignsDKIMOnServer |
Server signs DKIM for you — skips local PHP signing in SMTP loop |
SupportsCustomReturnPath |
Honors a custom Return-Path header instead of the default |
AllowsAllowlistVerify | AllowsLocalVerify | AllowsCrossSendingDomain |
Generic SMTP-style floodgate markers |
SendsCustomVerificationEmail |
sendVerificationEmail — host lets plugin handle verification mail flow |
Connection-tab blade
The connection partial under resources/views/sending_servers/_fields_connection.blade.php renders only the form fields. The host wraps it in the <form>, the submit button, the validation alert, and the four-tab page chrome:
<div class="m-form group">
<div class="row">
<div class="col-md-6">
<!-- trans('myvendor::messages.fields.api_key') -->
<div class="form-group {{ $errors->has('api_key') ? 'has-error' : '' }}">
<label class="m-form-required">{{ trans('myvendor::messages.api_key') }}</label>
<div class="input-icon">
<i class="fa fa-key"></i>
<input type="text"
name="api_key"
value="{{ $server->getOption('api_key') ?: $server->config['api_key'] ?? '' }}"
class="m-form-input {{ $errors->has('api_key') ? 'm-form-input-error' : '' }}" />
</div>
@if ($errors->has('api_key'))
<span class="help-block">{{ $errors->first('api_key') }}</span>
@endif
<p class="help-block">{{ trans('myvendor::messages.fields.api_key_help') }}</p>
</div>
</div>
</div>
<div class="row mt-10">
<div class="col-md-12">
<label class="m-form-label">{{ trans('myvendor::messages.fields.webhook_url') }}</label>
<div class="bg-light p-3 rounded">
<code>{{ $server->id ? server_url('driver-webhook', ['vendor'=>'myvendor', 'uid'=>$server->uid]) : trans('myvendor::messages.fields.webhook_url_not_available_yet') }}</code>
<p class="help-block mt-2 italic">{{ trans('myvendor::messages.fields.webhook_url_help') }}</p>
</div>
</div>
</div>
</div>
Three rules govern the partial:
- Only fields, no
<form>, no submit button. The host owns the form wrapper. Adding your own submit fires the wrong save endpoint. - Field
namematches theconfig_keyspayload +validateOnSubmit()'s keys. The host maps allrequest->api_key(etc.) through the JSONconfigcolumn based on the keys you declared. - Read existing values via
$server->getOption('api_key', null), NOT$server->api_key. The latter happens to work through a legacy__get()attribute fallback but is muddier and not contractually stable.
The validation pipeline
When an admin clicks Save on the Sending Server form, the host runs your driver's validation in two phases:
// In the host's SendingServerController::store
public function store(...) {
// phase 1
$sendingServer->validateOnSubmit($request->all()); // Laravel validation pass
// phase 2
if ($sendingServer->test()) { // driver's test() call
$sendingServer->status = 'active';
} else {
$sendingServer->status = 'inactive';
}
}
Your driver controls two failure modes:
- Field-level (Phase 1) — rules in
validateOnSubmit(). The host auto-maps each rule to its correspondingname="..."field in your blade, where@error('my_api_key')renders inline. - Connection-level (Phase 2) — anything thrown or
TestResult::failure(...)-returned fromtest(). The host surfaces it on a syntheticconnectionfield rendered in the validation-summary alert at the top of the form.
Five pitfalls from the Postal plugin
These are real bugs the Postal MTA plugin hit. Knowing them up front saves the next driver author hours of debugging.
1. test() must hit a real endpoint
The Postal plugin's first test() implementation called client->makeRequest('servers', 'list', []) — URL /api/v1/servers/list which checks if the API key is valid. Looking at Postal's actual API, only messages and send routes required a specific sender-domain. Postal returned HTTP 404 every time, and the admin saw "Status code returned by Postal server: 404" in red even with valid credentials.
Fix: always cross-check the vendor's API documentation for an existing read endpoint that requires auth and has zero side effects. Typical candidates: GET /me, GET /account, GET /domains. The host distinguishes 200-with-valid-key from 401-auth-fail/403-auth-fail/404-on-missing-route is meaningless.
2. Webhook payload shape changes between vendor versions
Vendors evolve their webhook formats. The Postal plugin shipped with three hardcoded format guards covering "very old", "legacy", and "current" — and still missed the modern format. Modern Postal wraps everything in {event, timestamp, payload, uuid}; for MessageBounced, the payload is {original_message: {token, ...}, bounce: { ... }}. The token is at payload.original_message.token, not payload.message.token — the plugin missed the difference and silently dropped every bounce.
Fix: pull the vendor's source code and find every webhook.trigger(...) call site. Enumerate the exact payload shapes the vendor actually sends. Have parseWebHook return UnhandledWebhookEvent for unknown event names rather than silent dropping — observability matters.
3. Webhook signature verification
Most vendors sign their webhooks (HMAC or RSA). Plugin authors often leave verifyWebhook as a no-op for v1 — a security risk in production, because anyone who knows the webhook URL can POST a fake bounce.
Fix for v1: leave verifyWebhook as no-op + a log warning, document it as FOLLOW-UP. Real implementation stores the vendor's public key per-server (in the config JSON) and verifies the signature against the request body. Postal signs with RSA SHA256 across X-Postal-Signature-Key + X-Postal-Signature-256 headers.
4. runtimeMessageId selection
SendResult.runtimeMessageId is what the host stores in tracking_logs.runtime_message_id. The webhook listener coordinates inbound bounces and complaints back to the originating tracking row via this ID. It must match what the vendor puts in webhook payloads.
Postal (like Amazon SES) returns an API-level message_id and a per-recipient token. Postal's MessageBounced webhook contains payload.original_message.token — not per-recipient. Senddera sends one recipient per send() call, so the right value to store is the per-recipient token, not the global message_id.
5. The race between send() and tracking_logs INSERT
The host's SendMessage job calls driver->send() first, then inserts the tracking_log row. Vendors can deliver a bounce or complaint webhook BEFORE the host commits the row — millisecond-scale race that is real in production.
The host already handles this at the listener level: BounceRecord and ComplaintRecord retry the lookup for up to 5 seconds before giving up. Plugin authors do not need to do anything special, but should understand the delay.
Activate + verify recipe
After dropping the plugin folder under storage/app/plugins/{vendor}/{name}/, register and activate it through linker, then run five smoke checks:
# 1. Register the plugin with core
php artisan linker -y plugin/install/storage/app/plugins/myvendor/sending/
# 2. Activate (fires activate_plugin hook)
php artisan linker -y activate:myvendor/sending/
# 3. Verify the Driver Registered
php artisan linker -- SendingServerService:registry:all();
echo \Senddera\Library\Send\Registry::all(); // finds 'myvendor-api' => '...'
# 4. Verify config keys auto-routed
php artisan linker -- SendingServerController:validateOnSubmit();
echo in_array('my_api_key', $keys, true); // => 'YES'
# 5. Verify the connection-blade view is namespaced
php artisan linker -- View:exists('myvendor::sending-servers._fields_connection'); // => 'YES'
UI smoke after the five checks pass:
- Login as admin -> Sending Servers -> Create. The "MyVendor" block should now show up in the middle of the picker card grid.
- Click your card. The form renders with the fields declared in
validateOnSubmit(['cols']). - Save with valid credentials. The host runs Phase 1 rules (rules) then Phase 2 (
driver->test()); both pass and the row commits. - UI edit again. Four tabs: Connection (your blade) + Configuration / Sender Identity / Warmup / (host-rendered).
Testing checklist
| Test | How |
|---|---|
Driver class loads without syntax error |
php artisan linker --execute="new MyVendor\Sending\Driver" |
test() succeeds with valid credentials |
Run a curl against the same endpoint probe output via ping() |
test() fails gracefully with bad credentials |
Set wrong my_api_key — expect TestResult::failure() |
Form fields submit correctly |
Save with valid creds — check DB sending_servers.config column |
Webhook intake parses bounce shape |
POST a real sample bounce payload — monitor parseWebhook() output |
Webhook signature verification (if implemented) |
POST with an invalid signature — verify verifyWebhook() fails |
Plugin uninstall cleans up |
App\Models\Plugins\find($id)->delete() — verify row deleted |
validateOnSubmit() covers every field name in config_keys |
Diff array_keys(validateOnSubmit(['cols'])) against config_keys |
Filesystem template
The fastest path to a working plugin is cloning the Postal plugin and renaming. Six edits and a few search-and-replaces:
cp -r storage/app/plugins/rencontru/postal storage/app/plugins/myvendor/name/
cd storage/app/plugins/myvendor/name/
# 1. Edit composer.json - name, namespace, autoload psr-4
# 2. Rename src/PostalDriver.php -> Driver.php, update class name + TYPE
# 3. Edit ServiceProvider.php - PLUGIN_NAME, name, view namespace, hook payload
# 4. Edit resources/views/sending_servers/_fields_connection.blade.php
# 5. Adjust resources/lang/en/messages.php
# 6. Replace the Postal HTTP client vendor with your vendor client
The Postal plugin is a useful reference for shape but its API client is Postal-specific — replace, do not adapt. The senddera/auri showcase the canonical plugin if you need a different reference for backend, sidebar UI, or admin pages.
Where to go next
Sending drivers and payment gateways are the two heaviest "ship a feature plugin" worked examples. The shapes are similar — both ship a single REGISTRY hook, a class with a small required-method surface area, and a connection blade — but the lifecycles differ significantly. Sending drivers receive webhooks (push); payment gateways pull state on a sync schedule (no webhook). The next page covers the payment-gateway pattern with Paddle as the worked example.
When the driver is shipped and live, Testing covers the lifecycle-integration test recipe (activate -> test -> send -> delete) that proves your delete_plugin listener cleans up correctly. The senddera/auri walkthrough the canonical complex plugin if you need a heavier reference codebase.