Un tout nouveau backend MTA. Livré comme plugin. Sans forker le cœur.

Dans AcelleMail, un driver d'envoi est la classe qui prend en charge un seul prestataire — Amazon SES, Postal, SendGrid, votre propre backend SMTP. L'application hôte réserve un unique hook REGISTRY (register_sending_server_driver) ainsi qu'un petit jeu d'interfaces marqueurs de capacité ; tout le reste — la page de sélection, le formulaire de connexion, le pipeline de validation, le contrôleur webhook, l'onglet Sender Identity, l'onglet warmup — est fourni par l'hôte. Cette page est l'exemple détaillé pour passer de plugin:init à un envoi réel réussi via votre driver, distillé à partir de la revue statique du plugin Postal MTA (storage/app/plugins/rencontru/postal/).

Pourquoi livrer un driver comme plugin

AcelleMail est livré avec un ensemble stable de drivers natifs — Amazon SES, SMTP générique, sendmail, Postmark, SendGrid, Mailgun et quelques autres. Tout autre prestataire — fournisseurs régionaux, MTA auto-hébergés, services transactionnels de niche, backends personnalisés — a besoin des cinq mêmes éléments câblés dans l'hôte : une ligne sur la page de sélection, un formulaire de connexion, une étape de validation, un listener webhook pour les bounces et plaintes, et une implémentation send() à l'exécution. Le faire dans un fork signifie suivre les mises à jour de l'hôte pour toujours ; le faire en tant que plugin signifie déposer un dossier dans storage/app/plugins/{vendor}/{name}/ et laisser l'hôte gérer toutes les préoccupations côté hôte.

Le contrat du plugin est petit à dessein. Un hook REGISTRY pour déclarer le driver. Une classe driver avec cinq méthodes requises (send, test, setupBeforeSend, validationRules, plus les accesseurs standards de nom de service). Un partial Blade pour le formulaire de connexion. Des interfaces marqueurs de capacité optionnelles pour tout le reste — webhooks, synchronisation d'identité, e-mail de vérification personnalisé. C'est tout. Le rendu de la sélection, la mise en page du formulaire, l'action d'enregistrement, le pipeline de validation, le routage des webhooks — tout est dans l'hôte.

Le contrat — ce que le plugin livre

L'arborescence complète d'un plugin driver d'envoi :

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

Le squelette est volontairement mince. routes.php enregistre exactement une route — qui sert l'icon.svg du plugin depuis le disque pour que la page de sélection ait quelque chose à afficher. Les endpoints CRUD, l'URL webhook, l'action de sauvegarde du formulaire vivent tous dans le Refactor\Admin\SendingServerController de l'hôte ; le plugin ne contribue qu'à la surface spécifique au driver.

Quatre choses que le plugin enregistre réellement auprès de l'hôte :

  1. Classe driver + métadonnées — un unique payload Hook::add('register_sending_server_driver', ...) portant le slug de type, le FQCN de la classe driver, les clés de config du prestataire et les métadonnées de la carte de sélection.
  2. Namespace de vues$this->loadViewsFrom($path, 'myvendor') pour que view('myvendor::...') se résolve vers les templates du plugin.
  3. Fichier de traduction — un payload Hook::add('add_translation_file', ...) pointant vers le resources/lang/ du plugin pour le master + le chemin du dump-clone.
  4. Blade de l'onglet Connexion — implémente le marqueur de capacité ProvidesConnectionFieldsView sur le driver, renvoie le chemin du partial que le formulaire de l'hôte rendra.

ServiceProvider — le pattern de boot

Le squelette complet du service provider pour un plugin driver d'envoi (paraphrasé depuis le 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();
        });
    }
}

Deux règles non évidentes que l'hôte applique :

  • Tout Hook::add sauf add_translation_file va dans boot(), jamais dans register(). Le SendingServerServiceProvider de l'hôte diffère la collecte du registre de drivers via $this->app->booted(...) précisément pour que les plugins aient le temps de s'enregistrer via leur propre boot() ; placer register_sending_server_driver dans register() signifie que la closure peut s'exécuter avant que ses propres dépendances (route() en particulier) soient disponibles.
  • Ne faites pas asset('plugins/myvendor/sending/icon.svg') depuis le payload du hook. Il n'existe pas d'étape d'auto-publication qui copie les assets de plugin dans public/plugins/... pour les plugins driver d'envoi ; ce chemin renvoie 404 en production. Le plugin possède sa propre route pour l'icône (définie dans routes.php) et le payload du hook référence cette route par son nom. Auto-contenu — déposez le dossier du plugin, l'icône est joignable sans aucun chemin de code hôte.

La classe driver

Le driver minimum viable hérite de App\SendingServers\Drivers\AbstractDriver et implémente le marqueur ProvidesConnectionFieldsView (qui indique à l'hôte que ce driver a son propre blade de connexion) :

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

Les quatre accesseurs de nom de service (getServiceName, getServiceIcon, getServiceColor) sont tout ce dont l'hôte a besoin pour afficher la carte de sélection et l'en-tête du serveur choisi dans l'UI Sending Servers. send() et test() sont les chemins critiques en production — chaque campagne envoyée via un serveur de ce type appelle send() une fois par destinataire ; chaque clic sur "Test connection" dans la page admin appelle test(). setupBeforeSend() s'exécute une fois au début d'un batch de campagne — la plupart des drivers le laissent vide.

Interfaces marqueurs de capacité

Au-delà de la surface minimale, l'hôte expose un ensemble d'interfaces marqueurs de capacité. Le driver n'implémente que les marqueurs qui s'appliquent — l'hôte fait des vérifications instanceof à chaque callsite, donc un driver qui n'implémente pas ReceivesWebhooks saute simplement l'enregistrement de la route webhook sans lever d'exception.

MarqueurCe que le driver implémente
ProvidesConnectionFieldsViewBlade personnalisé pour l'onglet Connexion — connectionFieldsView(): string renvoie le chemin de vue namespacé.
ReceivesWebhooksverifyWebhook + parseWebhook + webhookUrl — le prestataire POSTe les retours vers /webhook/{type}/{uid} ; l'hôte route le payload vers votre driver.
SupportsIdentitySyncsyncIdentities + verifyIdentity — l'hôte affiche un onglet Sender Identity pour ce driver.
SupportsRemoteDomainVerifyaddDomain + validateDomain + checkDomainVerificationStatus — le prestataire gère la vérification DNS, pas l'hôte.
SignsDkimOnServerLe serveur signe DKIM — l'hôte saute sa propre couche de signature.
SupportsCustomReturnPathHonore un en-tête Return-Path personnalisé sur le courrier sortant.
AllowsLocalDomainVerify / AllowsLocalEmailVerify / AllowsCrossSendingDomainFlags de souplesse de type SMTP générique.
SendsCustomVerificationEmailsendVerificationEmail(Sender) — le driver rend et envoie son propre message de vérification au lieu de celui par défaut de l'hôte.

Le blade de l'onglet Connexion

Le partial de connexion sous resources/views/sending-servers/_fields_connection.blade.php ne rend que les champs du formulaire. L'hôte l'enveloppe dans le <form>, le bouton de soumission, l'alerte de validation et le chrome de page à quatre onglets :

<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>

Trois règles régissent le partial :

  • Uniquement des champs, pas de <form>, pas de bouton de soumission. L'hôte possède le wrapper du formulaire. Ajouter votre propre bouton de soumission déclenche le mauvais endpoint d'enregistrement.
  • Le name du champ correspond au payload config_keys + aux clés validationRules()['cols']. L'hôte route automatiquement $server->fill($request->all()) à travers la colonne JSON config en fonction des clés que vous avez déclarées.
  • Lisez les valeurs existantes via $server->getConfig('my_api_key'), et non $server->my_api_key. Cette dernière forme fonctionne par hasard via un fallback hérité de getAttribute mais elle est plus trouble et n'est pas contractuellement stable.

Le pipeline de validation

Quand un admin clique sur Save dans le formulaire Sending Server, l'hôte exécute la validation de votre driver en deux phases :

[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

Votre driver contrôle deux modes d'échec :

  • Niveau champ (Phase 1) — règles dans validationRules()['cols']. L'hôte associe automatiquement chaque échec de règle au champ name=... correspondant dans votre blade, où @error('my_api_key') rend en ligne.
  • Niveau connexion (Phase 2) — toute exception lancée ou tout TestResult::failure(...) renvoyé depuis test(). L'hôte le fait remonter sur un champ connection synthétique rendu dans l'alerte récapitulative de validation en haut du formulaire.

Cinq pièges issus du plugin Postal

Ce sont de vrais bugs rencontrés par le plugin Postal MTA. Les connaître d'avance épargnera des heures de debug au prochain auteur de driver.

1. test() doit appeler un véritable endpoint

La première implémentation de test() du plugin Postal appelait client->makeRequest('servers', 'list', []) — URL /api/v1/servers/list. En regardant l'API réelle de Postal, seuls les contrôleurs messages et send existent ; il n'y a pas d'endpoint servers. Postal renvoyait HTTP 404 à chaque fois, et l'admin voyait "Status code returned by Postal server: 404" en rouge même avec des identifiants valides.

Correctif : vérifiez toujours dans la documentation API du prestataire un endpoint de lecture existant qui exige l'authentification et qui n'a aucun effet de bord. Candidats typiques : GET /me, GET /account, GET /domains. La sonde doit distinguer 200-avec-clé-valide de 401/403-avec-mauvaise-clé — un 404-sur-route-manquante n'a aucun sens.

2. La forme du payload webhook change entre les versions du prestataire

Les prestataires font évoluer leurs formats de webhook. Le plugin Postal a été livré avec trois gardes de format codés en dur couvrant "très ancien", "legacy" et "actuel" — et a quand même raté le format moderne. Le Postal moderne enveloppe tout dans {event, timestamp, payload, uuid} ; pour MessageBounced, le payload est {original_message: {token, ...}, bounce: {...}}. Le token est à payload.original_message.token, pas à payload.message.token — le plugin a raté la différence et a silencieusement perdu chaque bounce.

Correctif : récupérez le code source du prestataire et trouvez chaque callsite webhook.trigger(...). Énumérez les formes exactes de payload que le prestataire envoie réellement. Faites en sorte que parseWebhook renvoie IgnorableWebhookEvent pour les noms d'événements inconnus plutôt que de les perdre en silence — l'observabilité compte.

3. Vérification de signature de webhook

La plupart des prestataires signent leurs webhooks (HMAC ou RSA). Les auteurs de plugins laissent souvent verifyWebhook en no-op pour la v1 — risque de sécurité en production, car quiconque connaît l'URL webhook peut POSTer un faux bounce.

Correctif pour la v1 : laissez verifyWebhook en no-op + loggez un warning, documentez-le comme FOLLOW-UP. L'implémentation réelle stocke la clé publique du prestataire par serveur (dans le JSON config) et vérifie la signature contre le corps de la requête. Postal signe avec RSA SHA256 sur les en-têtes X-Postal-Signature-KID + X-Postal-Signature-256.

4. Sélection de runtimeMessageId

SendResult.runtimeMessageId est ce que l'hôte stocke dans tracking_logs.runtime_message_id. Le listener de webhook corrèle les bounces et plaintes entrants à la ligne de tracking d'origine via cet identifiant. Il doit correspondre à ce que le prestataire place dans les payloads webhook.

La réponse de /api/v1/send/raw de Postal renvoie à la fois un message_id globalement unique et un token par destinataire. Le webhook MessageBounced de Postal contient payload.original_message.token — par destinataire. Les drivers de la plateforme envoient un destinataire par appel send(), donc la bonne valeur à stocker est le token par destinataire, pas le message_id global.

Correctif : si le prestataire envoie des identifiants par destinataire dans ses webhooks, stockez l'identifiant par destinataire dans runtimeMessageId. Choisir le mauvais signifie que chaque BounceLog se retrouve avec tracking_log_id NULL — le bounce arrive mais rien dans l'UI de l'hôte ne l'affichera jamais.

5. La course entre send() et l'INSERT de tracking_logs

Le job SendMessage de l'hôte appelle driver->send() d'abord, puis insère la ligne TrackingLog. Les prestataires peuvent livrer un webhook de bounce ou de plainte avant que l'INSERT ne soit committé — une course à l'échelle de la milliseconde qui est bien réelle en production.

L'hôte gère déjà cela au niveau du listener : RecordBounce et RecordComplaint réessayent le lookup pendant 5 secondes avant d'abandonner. Les auteurs de plugins n'ont rien de spécial à faire, mais doivent NE PAS :

  • Pré-INSÉRER un TrackingLog avant send() — chaque envoi devient deux allers-retours DB même sur le chemin rapide.
  • Exécuter send() à l'intérieur d'une transaction externe — l'INSERT de TrackingLog devient invisible jusqu'au commit externe, élargissant la fenêtre de course.

Recette d'activation et de vérification

Après avoir déposé le dossier du plugin sous storage/app/plugins/<vendor>/<name>/, enregistrez-le et activez-le via tinker, puis exécutez cinq smoke checks :

# 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 après que les cinq vérifications soient passées :

  1. Connectez-vous comme admin → Sending Servers → Create. Le bloc "Plugin Servers" doit maintenant afficher votre carte.
  2. Cliquez sur votre carte. Le formulaire s'affiche avec les champs déclarés dans validationRules()['cols'].
  3. Enregistrez avec des identifiants valides. L'hôte exécute la Phase 1 (règles) puis la Phase 2 (driver->test()) ; les deux passent et la ligne est committée.
  4. Page d'édition. Quatre onglets : Connection (votre blade) + Configuration / Sender Identity / Warmup (rendus par l'hôte).

Checklist de tests

TestComment
La classe driver se charge sans erreur de syntaxephp artisan tinker --execute="new MyVendor\Sending\MyVendorDriver(new App\Model\SendingServer(['type' => 'myvendor-api']))"
test() réussit avec des identifiants validesLancez un curl contre le même endpoint de sonde avec la même clé — les deux doivent renvoyer la même forme
test() échoue proprement avec de mauvais identifiantsMettez un mauvais my_api_key → attendez-vous à TestResult::failure avec le message d'erreur réel du prestataire, pas une trace d'exception Laravel
Les champs du formulaire se soumettent correctementEnregistrez avec des identifiants valides → le JSON config de la ligne DB contient chaque clé listée dans config_keys
L'admission de webhook parse correctement la forme du bouncePOSTez un véritable payload de bounce d'exemple → parseWebhook produit BounceReceived avec le bon runtimeMessageId
Vérification de signature webhook (si implémentée)POSTez avec une signature invalide → verifyWebhook lève une exception
La désinstallation du plugin nettoieApp\Model\Plugin::find($id)->delete() → aucune ligne sending_servers orpheline de ce type
validationRules() couvre chaque nom de champ dans config_keysDiffez array_keys(validationRules()['cols']) contre le payload config_keys — doit correspondre exactement

Pour les tests live de bout en bout, l'hôte livre un harness de test aws-e2e/ qui amorce une véritable ligne SendingServer, crée une liste de test, lance une campagne et fait des assertions sur le flux bounce/plainte. Reproduisez son pattern à trois scripts (10-bootstrap-emma-{vendor}.php + 11-create-test-list.php + 12-run-test-campaign.php) pour une couverture de régression live.

Modèle de système de fichiers

Le chemin le plus rapide vers un plugin fonctionnel est de cloner le plugin Postal et de le renommer. Six éditions et quelques rechercher-remplacer :

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

Le plugin Postal est une référence utile pour la forme mais son client API est spécifique à Postal — remplacez-le, n'essayez pas de l'adapter. La vitrine acelle/ai couvre le plugin complexe canonique si vous avez besoin d'une référence différente pour les patterns de test, l'UI de sidebar ou les pages admin.

Où aller ensuite

Les drivers d'envoi et les passerelles de paiement sont les deux exemples détaillés les plus lourds de "livrer une fonctionnalité comme plugin". Les formes sont similaires — les deux livrent un unique hook REGISTRY, une classe avec une petite surface de méthodes requises, et un blade de connexion — mais les cycles de vie diffèrent significativement. Les drivers d'envoi reçoivent des webhooks (push) ; les passerelles de paiement tirent l'état selon une planification de sync (pas de webhook). La page suivante couvre le pattern passerelle de paiement avec Paddle comme exemple détaillé.

Quand le driver est livré et en production, Tests couvre la recette de test d'intégration de cycle de vie (activer → envoi-test → supprimer) qui prouve que votre listener delete_plugin_* nettoie correctement. La vitrine acelle/ai détaille le plugin complexe canonique si vous avez besoin d'une base de code de référence plus volumineuse.