Un backend MTA nuovo di zecca. Shippato come plugin. Senza forkare il core.

Un sending driver in AcelleMail è la classe che detiene un vendor — Amazon SES, Postal, SendGrid, il tuo backend SMTP. L'applicazione host riserva un singolo hook REGISTRY (register_sending_server_driver) più un piccolo set di interface capability marker; tutto il resto — la pagina picker, il form di connessione, la validation pipeline, il webhook controller, il tab Sender Identity, il tab Warmup — è incluso nell'host. Questa pagina è l'esempio pratico per arrivare da plugin:init a un live-send che passa attraverso il tuo driver, distillato dalla static review del plugin Postal MTA (storage/app/plugins/rencontru/postal/).

Perché shippare un driver come plugin

AcelleMail viene fornito con un set stabile di driver di prima parte — Amazon SES, SMTP generico, sendmail, Postmark, SendGrid, Mailgun e una manciata di altri. Ogni altro vendor — provider regionali, MTA self-hosted, servizi transactional di nicchia, backend custom — ha bisogno delle stesse cinque cose cablate nell'host: una riga nella pagina picker, un form di connessione, uno step di validation, un listener webhook per bounces e complaints, e un'implementazione send() runtime. Farlo in un fork significa tracciare gli upgrade dell'host per sempre; farlo come plugin significa droppare una cartella in storage/app/plugins/{vendor}/{name}/ e lasciare che l'host si occupi di ogni preoccupazione lato host.

Il contratto del plugin è piccolo di proposito. Un hook REGISTRY per dichiarare il driver. Una classe driver con cinque metodi richiesti (send, test, setupBeforeSend, validationRules, più i normali accessor del nome servizio). Un partial Blade per il form di connessione. Interface capability marker opzionali per tutto il resto — webhook, identity sync, custom verification email. Tutto qui. Picker rendering, layout del form, save action, validation pipeline, routing webhook — tutto nell'host.

Il contratto — cosa il plugin shippa

L'albero file completo di un 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

Lo skeleton è intenzionalmente sottile. routes.php registra esattamente una route — serve l'icon.svg del plugin da disco così la pagina picker ha qualcosa da renderizzare. Endpoint CRUD, URL webhook, save action del form vivono tutti nel Refactor\Admin\SendingServerController dell'host; il plugin contribuisce solo all'area di superficie driver-specifica.

Quattro cose che il plugin registra effettivamente con l'host:

  1. Classe driver + metadati — un singolo payload Hook::add('register_sending_server_driver', ...) che porta il type slug, l'FQCN della classe driver, le chiavi di config vendor, e i metadati della picker card.
  2. Namespace view$this->loadViewsFrom($path, 'myvendor') così view('myvendor::...') si risolve ai template del plugin.
  3. File traduzioni — un payload Hook::add('add_translation_file', ...) che punta al resources/lang/ del plugin per il master + il percorso del dump-clone.
  4. Blade del tab Connection — implementa il capability marker ProvidesConnectionFieldsView sul driver, restituisce il percorso partial che il form dell'host renderizza.

ServiceProvider — il pattern di boot

Lo skeleton completo del service provider per un plugin sending-driver (parafrasato dal 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();
        });
    }
}

Due regole non ovvie che l'host applica:

  • Ogni Hook::add tranne add_translation_file va in boot(), mai in register(). Il SendingServerServiceProvider dell'host differisce la sua collection di driver-registry via $this->app->booted(...) proprio così che i plugin abbiano tempo di registrarsi attraverso il loro boot(); mettere register_sending_server_driver in register() significa che la closure può girare prima che le sue dipendenze (route() in particolare) siano disponibili.
  • Non fare asset('plugins/myvendor/sending/icon.svg') dal payload dell'hook. Non c'è un passo di auto-publish che copia gli asset del plugin in public/plugins/... per plugin sending-driver; quel percorso va in 404 in produzione. Il plugin detiene la propria route per l'icona (definita in routes.php), e il payload dell'hook referenzia quella route per nome. Autocontenuto — droppa la cartella del plugin dentro, l'icona è raggiungibile senza alcun code path host.

La classe driver

Il driver minimum-viable eredita da App\SendingServers\Drivers\AbstractDriver e implementa il marker ProvidesConnectionFieldsView (che dà all'host un hint che questo driver ha il proprio blade di connessione):

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';
    }
}

I quattro accessor del nome servizio (getServiceName, getServiceIcon, getServiceColor) sono tutto ciò che serve all'host per renderizzare la picker card e l'header del server scelto nella UI Sending Servers. send() e test() sono gli hot path di produzione — ogni campaign inviata attraverso un server di questo tipo chiama send() una volta per recipient; ogni click "Test connection" sulla pagina admin chiama test(). setupBeforeSend() gira una volta all'inizio di un campaign batch — la maggior parte dei driver lo lascia vuoto.

Interface capability marker

Oltre la superficie minima, l'host espone un set di interface capability marker. Il driver implementa solo i marker che si applicano — l'host fa check instanceof a ogni callsite, quindi un driver che non implementa ReceivesWebhooks salta semplicemente la registrazione della route webhook senza lanciare.

MarkerCosa il driver implementa
ProvidesConnectionFieldsViewBlade custom del tab Connection — connectionFieldsView(): string restituisce il percorso view namespaced.
ReceivesWebhooksverifyWebhook + parseWebhook + webhookUrl — il vendor fa POST del feedback a /webhook/{type}/{uid}; l'host instrada il payload al tuo driver.
SupportsIdentitySyncsyncIdentities + verifyIdentity — l'host renderizza un tab Sender Identity per questo driver.
SupportsRemoteDomainVerifyaddDomain + validateDomain + checkDomainVerificationStatus — il vendor gestisce la verifica DNS, non l'host.
SignsDkimOnServerIl server firma DKIM — l'host salta il proprio layer di signing.
SupportsCustomReturnPathOnora un header custom Return-Path sulla mail in uscita.
AllowsLocalDomainVerify / AllowsLocalEmailVerify / AllowsCrossSendingDomainFlag di flessibilità generici SMTP-style.
SendsCustomVerificationEmailsendVerificationEmail(Sender) — il driver renderizza + invia il proprio messaggio di verifica invece di quello di default dell'host.

Blade del tab Connection

Il partial di connessione sotto resources/views/sending-servers/_fields_connection.blade.php renderizza solo i campi del form. L'host lo wrappa nel <form>, il bottone submit, l'alert di validation, e il chrome della pagina a quattro 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>

Tre regole governano il partial:

  • Solo campi, niente <form>, niente bottone submit. L'host detiene il form wrapper. Aggiungere il tuo submit attiva il save endpoint sbagliato.
  • Il name del campo corrisponde al payload config_keys + chiavi validationRules()['cols']. L'host auto-instrada $server->fill($request->all()) attraverso la colonna JSON config in base alle chiavi dichiarate.
  • Leggi i valori esistenti via $server->getConfig('my_api_key'), non $server->my_api_key. Quest'ultimo funziona per via di un fallback legacy getAttribute ma è più torbido e non contrattualmente stabile.

La validation pipeline

Quando un admin clicca Save sul form Sending Server, l'host esegue la validation del tuo driver in due fasi:

[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

Il tuo driver controlla due failure mode:

  • A livello di campo (Fase 1) — regole in validationRules()['cols']. L'host auto-mappa ogni rule failure al suo corrispondente campo name=... nel tuo blade, dove @error('my_api_key') renderizza inline.
  • A livello di connessione (Fase 2) — qualsiasi cosa lanciata o restituita come TestResult::failure(...) da test(). L'host la fa emergere su un campo sintetico connection renderizzato nell'alert validation-summary in cima al form.

Cinque insidie dal plugin Postal

Questi sono bug reali che il plugin Postal MTA ha incontrato. Conoscerli in anticipo risparmia al prossimo autore di driver ore di debugging.

1. test() deve colpire un endpoint reale

La prima implementazione test() del plugin Postal chiamava client->makeRequest('servers', 'list', []) — URL /api/v1/servers/list. Guardando l'API reale di Postal, esistono solo controller messages e send; non c'è alcun endpoint servers. Postal restituiva HTTP 404 ogni volta, e l'admin vedeva "Status code returned by Postal server: 404" in rosso anche con credenziali valide.

Fix: incrocia sempre la documentazione API del vendor per un endpoint read esistente che richieda auth e abbia zero side effect. Candidati tipici: GET /me, GET /account, GET /domains. La probe deve distinguere 200-con-chiave-valida da 401/403-con-chiave-sbagliata — 404-su-route-mancante è privo di significato.

2. La shape del payload webhook cambia tra le versioni del vendor

I vendor evolvono i loro formati webhook. Il plugin Postal è stato shippato con tre guardia di formato hardcoded che coprivano "molto vecchio", "legacy" e "corrente" — e ancora mancava il formato moderno. Postal moderno wrappa tutto in {event, timestamp, payload, uuid}; per MessageBounced, il payload è {original_message: {token, ...}, bounce: {...}}. Il token è in payload.original_message.token, non in payload.message.token — il plugin mancava la differenza e droppava silenziosamente ogni bounce.

Fix: tira il codice sorgente del vendor e trova ogni call site webhook.trigger(...). Enumera le esatte shape di payload che il vendor invia realmente. Fai sì che parseWebhook restituisca IgnorableWebhookEvent per nomi di evento sconosciuti anziché droppare silenziosamente — l'osservabilità conta.

3. Verifica della firma webhook

La maggior parte dei vendor firma i propri webhook (HMAC o RSA). Gli autori di plugin spesso lasciano verifyWebhook come no-op per v1 — rischio di sicurezza in produzione, perché chiunque conosca l'URL del webhook può fare POST di un bounce fake.

Fix per v1: lascia verifyWebhook come no-op + logga un warning, documentalo come FOLLOW-UP. L'implementazione reale memorizza la public key del vendor per-server (nel JSON config) e verifica la firma contro il body della request. Postal firma con RSA SHA256 attraverso gli header X-Postal-Signature-KID + X-Postal-Signature-256.

4. Selezione di runtimeMessageId

SendResult.runtimeMessageId è ciò che l'host memorizza in tracking_logs.runtime_message_id. Il listener webhook correla bounces e complaints in entrata indietro alla riga di tracking originaria via questo id. Deve corrispondere a ciò che il vendor mette nei payload webhook.

La risposta di /api/v1/send/raw di Postal restituisce sia un message_id globalmente unico sia un token per-recipient. Il webhook MessageBounced di Postal contiene payload.original_message.token — per-recipient. I driver della piattaforma inviano un recipient per chiamata send(), quindi il valore giusto da memorizzare è il token per-recipient, non il message_id globale.

Fix: se il vendor invia identificatori per-recipient nei suoi webhook, memorizza l'identificatore per-recipient in runtimeMessageId. Scegliere quello sbagliato significa che ogni BounceLog finisce con tracking_log_id NULL — il bounce arriva ma niente nella UI dell'host lo mostrerà mai.

5. La race tra send() e l'INSERT di tracking_logs

Il job SendMessage dell'host chiama prima driver->send(), poi inserisce la riga TrackingLog. I vendor possono consegnare un webhook di bounce o complaint prima che l'INSERT committi — race a scala di millisecondi che è reale in produzione.

L'host gestisce già questo a livello di listener: RecordBounce e RecordComplaint ritentano il lookup fino a 5 secondi prima di arrendersi. Gli autori di plugin non hanno bisogno di fare nulla di speciale, ma NON dovrebbero:

  • Fare pre-INSERT di un TrackingLog prima di send() — ogni send diventa due round-trip DB anche sul fast path.
  • Eseguire send() dentro una outer transaction — l'INSERT di TrackingLog diventa invisibile fino al commit esterno, allargando la finestra di race.

Ricetta activate + verifica

Dopo aver droppato la cartella del plugin sotto storage/app/plugins/<vendor>/<name>/, registralo e attivalo tramite tinker, poi esegui cinque 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';
"

UI smoke dopo che i cinque check passano:

  1. Login come admin → Sending Servers → Create. Il blocco "Plugin Servers" ora dovrebbe mostrare la tua card.
  2. Clicca la tua card. Il form renderizza con i campi dichiarati in validationRules()['cols'].
  3. Salva con credenziali valide. L'host esegue la Fase 1 (regole) poi la Fase 2 (driver->test()); entrambe passano e la riga committa.
  4. Pagina edit. Quattro tab: Connection (il tuo blade) + Configuration / Sender Identity / Warmup (renderizzati dall'host).

Checklist di testing

TestCome
La classe driver si carica senza syntax errorphp artisan tinker --execute="new MyVendor\Sending\MyVendorDriver(new App\Model\SendingServer(['type' => 'myvendor-api']))"
test() ha successo con credenziali valideEsegui un curl contro lo stesso endpoint probe con la stessa chiave — entrambi devono restituire la stessa shape
test() fallisce in modo grazioso con credenziali sbagliateImposta una my_api_key sbagliata → aspettati TestResult::failure con il messaggio di errore reale del vendor, non una stack trace Laravel
I campi del form si inviano correttamenteSalva con cred valide → il JSON config della riga DB ha ogni chiave elencata in config_keys
L'intake del webhook parsa la shape del bouncePOST di un payload bounce sample reale → parseWebhook produce BounceReceived con il runtimeMessageId corretto
Verifica firma webhook (se implementata)POST con una firma invalida → verifyWebhook lancia
Uninstall del plugin fa cleanupApp\Model\Plugin::find($id)->delete() → nessuna riga orfana sending_servers di questo type
validationRules() copre ogni nome di campo in config_keysDiff array_keys(validationRules()['cols']) contro il payload config_keys — devono corrispondere esattamente

Per test live end-to-end, l'host shippa un test harness aws-e2e/ che bootstrappa una riga SendingServer reale, crea una test list, esegue una campaign e fa assert sul flow bounce/complaint. Specchia il suo pattern a tre script (10-bootstrap-emma-{vendor}.php + 11-create-test-list.php + 12-run-test-campaign.php) per copertura di regressione live.

Template di filesystem

Il percorso più veloce verso un plugin funzionante è clonare il plugin Postal e rinominarlo. Sei edit e qualche 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

Il plugin Postal è un riferimento utile per la shape ma il suo API client è Postal-specifico — sostituisci, non adattare. Lo showcase acelle/ai copre il plugin complesso canonico se ti serve un riferimento diverso per pattern di testing, UI sidebar, o pagine admin.

Dove andare poi

Sending driver e gateway di pagamento sono i due esempi più pesanti di "shippa una feature come plugin". Le shape sono simili — entrambi shippano un singolo hook REGISTRY, una classe con una piccola superficie di metodi richiesti, e un blade di connessione — ma i lifecycle differiscono significativamente. I sending driver ricevono webhook (push); i gateway di pagamento pullano lo stato su una schedule di sync (niente webhook). La prossima pagina copre il pattern payment-gateway con Paddle come esempio pratico.

Quando il driver è shippato e live, Testing copre la ricetta di integration test di lifecycle (activate → test-send → delete) che prova che il tuo listener delete_plugin_* fa cleanup correttamente. Lo showcase acelle/ai percorre il plugin complesso canonico se ti serve un codebase di riferimento più pesante.