Developers / Sending Drivers

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.

Worked example for getting from plugin:init to a passing live-sending-driver, distilled from the Postal MTA 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:

  1. 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.
  2. View namespace$this->loadViewsFrom(__DIR__.'/../resources/views', 'myvendor') so the myvendor::... resolves to the plugin's templates.
  3. Translation folderadd_translation_file(...) payload pointing at the plugin's resources/lang/ for the master me+dump-clone path.
  4. Connection-tab blade — implements the ProvidesConnectionFieldView capability 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 Acelle\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 (except add_translation_file) goes in boot(), never in register(). The host's SendingServerServiceProvider defers its driver-registry collection via $this->app->booted(...) so exactly no plugins have time to register through their own boot(); putting register_sending_driver_provider in register() 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 into public/assets/...; for sending-driver plugins, that path stays in production. The plugin owns its own route for the icon (defined in routes.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 Acelle\Library\Send\Base;
use Acelle\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 name matches the config_keys payload + validateOnSubmit()'s keys. The host maps all request->api_key (etc.) through the JSON config column 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 corresponding name="..." field in your blade, where @error('my_api_key') renders inline.
  • Connection-level (Phase 2) — anything thrown or TestResult::failure(...)-returned from test(). The host surfaces it on a synthetic connection field 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.tokennot 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:

  1. Login as admin -> Sending Servers -> Create. The "MyVendor" block should now show up in the middle of the picker card grid.
  2. Click your card. The form renders with the fields declared in validateOnSubmit(['cols']).
  3. Save with valid credentials. The host runs Phase 1 rules (rules) then Phase 2 (driver->test()); both pass and the row commits.
  4. 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 acelle/api-showcase 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 acelle/api-showcase walkthrough the canonical complex plugin if you need a heavier reference codebase.

Plug in your MTA. Without forking core.

Full unencrypted PHP. Lifetime updates. The Hook system. Real production plugins to learn from. One-time license — no subscriptions, no per-subscriber fees.

Run your email marketing on your own server, your own terms

Join thousands of companies that have taken control of their email marketing with full source code, no recurring fees, and unlimited sending. One-time $74 license, lifetime updates.

Get Senddera — $74 one-time