Warum einen Driver als Plugin ausliefern
AcelleMail wird mit einem stabilen Satz an First-Party-Drivern ausgeliefert — Amazon SES, generisches SMTP, sendmail, Postmark, SendGrid, Mailgun und einigen weiteren. Jeder andere Vendor — regionale Anbieter, selbstgehostete MTAs, Nischen-Transaktionsdienste, Custom Backends — benötigt dieselben fünf Dinge im Host verdrahtet: eine Zeile auf der Picker-Seite, ein Connection-Formular, einen Validierungsschritt, einen Webhook-Listener für Bounces und Beschwerden sowie eine Laufzeit-Implementierung von send(). Das in einem Fork zu tun bedeutet, Host-Upgrades für immer nachzuziehen; es als Plugin zu tun bedeutet, einen Ordner in storage/app/plugins/{vendor}/{name}/ abzulegen und den Host sich um jeden hostseitigen Belang kümmern zu lassen.
Der Plugin-Vertrag ist bewusst klein. Ein REGISTRY-Hook, um den Driver anzumelden. Eine Driver-Klasse mit fünf erforderlichen Methoden (send, test, setupBeforeSend, validationRules, plus die Standard-Service-Name-Accessoren). Ein Blade-Partial für das Connection-Formular. Optionale Capability-Marker-Interfaces für alles andere — Webhooks, Identity-Sync, Custom-Verification-Email. Das war's. Picker-Rendering, Formular-Layout, Save-Action, Validierungs-Pipeline, Webhook-Routing — alles im Host.
Der Vertrag — was ein Plugin liefert
Der vollständige Dateibaum eines Sending-Driver-Plugins:
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
Das Skeleton ist bewusst dünn. routes.php registriert genau eine Route — die das icon.svg des Plugins von der Platte ausliefert, sodass die Picker-Seite etwas zum Rendern hat. CRUD-Endpoints, die Webhook-URL und die Form-Save-Action leben alle im hostseitigen Refactor\Admin\SendingServerController; das Plugin trägt nur die treiberspezifische Oberfläche bei.
Vier Dinge, die das Plugin tatsächlich beim Host registriert:
- Driver-Klasse + Metadaten — ein einzelner
Hook::add('register_sending_server_driver', ...)-Payload, der den Type-Slug, den FQCN der Driver-Klasse, die Vendor-Config-Keys und die Picker-Card-Metadaten transportiert.
- View-Namespace —
$this->loadViewsFrom($path, 'myvendor'), sodass view('myvendor::...') in die Templates des Plugins auflöst.
- Übersetzungsdatei — ein
Hook::add('add_translation_file', ...)-Payload, der auf resources/lang/ des Plugins für den Master + den Dump-Clone-Pfad zeigt.
- Connection-Tab-Blade — implementiert den Capability-Marker
ProvidesConnectionFieldsView auf dem Driver und gibt den Partial-Pfad zurück, den das hostseitige Formular rendert.
ServiceProvider — das Boot-Muster
Das vollständige Skeleton des Service Providers für ein Sending-Driver-Plugin (paraphrasiert vom Postal-Plugin):
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();
});
}
}
Zwei nicht offensichtliche Regeln, die der Host durchsetzt:
- Jedes
Hook::add außer add_translation_file gehört in boot(), niemals in register(). Der hostseitige SendingServerServiceProvider verzögert seine Driver-Registry-Sammlung über $this->app->booted(...) genau, damit Plugins Zeit haben, sich über ihren eigenen boot() zu registrieren; register_sending_server_driver in register() zu legen, bedeutet, dass die Closure möglicherweise läuft, bevor ihre eigenen Abhängigkeiten (insbesondere route()) verfügbar sind.
- Vermeiden Sie
asset('plugins/myvendor/sending/icon.svg') aus dem Hook-Payload. Für Sending-Driver-Plugins gibt es keinen Auto-Publish-Schritt, der Plugin-Assets nach public/plugins/... kopiert; dieser Pfad liefert in Produktion 404. Das Plugin besitzt seine eigene Route für das Icon (definiert in routes.php), und der Hook-Payload referenziert diese Route per Name. Self-contained — Plugin-Ordner abgelegt, das Icon ist ohne irgendeinen hostseitigen Code-Pfad erreichbar.
Die Driver-Klasse
Der minimal lauffähige Driver erbt von App\SendingServers\Drivers\AbstractDriver und implementiert den Marker ProvidesConnectionFieldsView (der dem Host signalisiert, dass dieser Driver sein eigenes Connection-Blade hat):
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';
}
}
Die vier Service-Name-Accessoren (getServiceName, getServiceIcon, getServiceColor) sind alles, was der Host braucht, um die Picker-Card und den Chosen-Server-Header in der Sending-Servers-UI zu rendern. send() und test() sind die produktiven Hot-Paths — jede Kampagne, die über einen Server dieses Typs versendet wird, ruft send() einmal pro Empfänger auf; jeder „Test connection"-Klick auf der Admin-Seite ruft test() auf. setupBeforeSend() läuft einmal zu Beginn einer Kampagnen-Batch — die meisten Driver lassen sie leer.
Capability-Marker-Interfaces
Über die Mindestoberfläche hinaus stellt der Host eine Reihe von Capability-Marker-Interfaces bereit. Der Driver implementiert nur die Marker, die zutreffen — der Host führt an jeder Callsite instanceof-Checks durch, sodass ein Driver, der ReceivesWebhooks nicht implementiert, einfach die Webhook-Routen-Registrierung überspringt, ohne zu werfen.
| Marker | Was der Driver implementiert |
ProvidesConnectionFieldsView | Custom Connection-Tab-Blade — connectionFieldsView(): string liefert den namespaced View-Pfad. |
ReceivesWebhooks | verifyWebhook + parseWebhook + webhookUrl — der Vendor sendet POST auf /webhook/{type}/{uid}; der Host routet das Payload an Ihren Driver. |
SupportsIdentitySync | syncIdentities + verifyIdentity — der Host rendert einen Sender-Identity-Tab für diesen Driver. |
SupportsRemoteDomainVerify | addDomain + validateDomain + checkDomainVerificationStatus — der Vendor übernimmt die DNS-Verifizierung, nicht der Host. |
SignsDkimOnServer | Server signiert DKIM — der Host überspringt seine eigene Signing-Schicht. |
SupportsCustomReturnPath | Berücksichtigt einen custom Return-Path-Header auf ausgehenden Mails. |
AllowsLocalDomainVerify / AllowsLocalEmailVerify / AllowsCrossSendingDomain | Generische SMTP-Flexibilitäts-Flags. |
SendsCustomVerificationEmail | sendVerificationEmail(Sender) — der Driver rendert + sendet seine eigene Verification-Mail statt der Default-Mail des Hosts. |
Connection-Tab-Blade
Das Connection-Partial unter resources/views/sending-servers/_fields_connection.blade.php rendert ausschließlich die Formularfelder. Der Host umschließt es mit dem <form>, dem Submit-Button, dem Validierungs-Alert und dem Vier-Tab-Seiten-Chrome:
<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>
Drei Regeln gelten für das Partial:
- Nur Felder, kein
<form>, kein Submit-Button. Der Host besitzt den Form-Wrapper. Ein eigener Submit feuert den falschen Save-Endpoint.
- Feld-
name stimmt mit dem config_keys-Payload + den Keys aus validationRules()['cols'] überein. Der Host routet $server->fill($request->all()) automatisch durch die JSON-Spalte config basierend auf den deklarierten Keys.
- Bestehende Werte lesen Sie über
$server->getConfig('my_api_key'), nicht über $server->my_api_key. Letzteres funktioniert zufällig über einen Legacy-getAttribute-Fallback, ist aber unklarer und vertraglich nicht stabil.
Die Validierungs-Pipeline
Wenn ein Admin auf Save im Sending-Server-Formular klickt, führt der Host die Validierung Ihres Drivers in zwei Phasen aus:
[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
Ihr Driver kontrolliert zwei Fehlermodi:
- Feldebene (Phase 1) — Regeln in
validationRules()['cols']. Der Host mappt automatisch jeden Regel-Fehlschlag auf das entsprechende name=...-Feld in Ihrem Blade, wo @error('my_api_key') inline rendert.
- Connection-Ebene (Phase 2) — alles, was aus
test() geworfen oder als TestResult::failure(...) zurückgegeben wird. Der Host zeigt es auf einem synthetischen connection-Feld an, das im Validierungs-Summary-Alert oben im Formular gerendert wird.
Fünf Stolperfallen aus dem Postal-Plugin
Das sind reale Bugs, die im Postal-MTA-Plugin auftraten. Sie im Voraus zu kennen, erspart dem nächsten Driver-Autor Stunden des Debuggens.
1. test() muss einen echten Endpoint treffen
Die erste test()-Implementierung des Postal-Plugins rief client->makeRequest('servers', 'list', []) auf — URL /api/v1/servers/list. Schaut man in die tatsächliche Postal-API, gibt es nur die Controller messages und send; es gibt keinen servers-Endpoint. Postal lieferte jedes Mal HTTP 404 zurück, und der Admin sah „Status code returned by Postal server: 404" in Rot, selbst mit gültigen Credentials.
Fix: Gleichen Sie immer die API-Dokumentation des Vendors ab und finden Sie einen existierenden Read-Endpoint, der Auth verlangt und keine Seiteneffekte hat. Typische Kandidaten: GET /me, GET /account, GET /domains. Der Probe-Aufruf muss zwischen 200-bei-gültigem-Key und 401/403-bei-falschem-Key unterscheiden können — 404-bei-fehlendem-Pfad ist bedeutungslos.
2. Webhook-Payload-Form ändert sich zwischen Vendor-Versionen
Vendors entwickeln ihre Webhook-Formate weiter. Das Postal-Plugin wurde mit drei hartkodierten Format-Guards ausgeliefert, die „sehr alt", „legacy" und „aktuell" abdeckten — und verfehlte trotzdem das moderne Format. Modernes Postal umschließt alles in {event, timestamp, payload, uuid}; bei MessageBounced ist das Payload {original_message: {token, ...}, bounce: {...}}. Das Token liegt unter payload.original_message.token, nicht unter payload.message.token — das Plugin verfehlte den Unterschied und ließ jeden Bounce still verloren gehen.
Fix: Ziehen Sie sich den Quellcode des Vendors und finden Sie jede webhook.trigger(...)-Callsite. Listen Sie die exakten Payload-Formen auf, die der Vendor tatsächlich sendet. Lassen Sie parseWebhook für unbekannte Event-Namen ein IgnorableWebhookEvent zurückgeben statt sie stillschweigend zu droppen — Observability zählt.
3. Webhook-Signatur-Verifizierung
Die meisten Vendors signieren ihre Webhooks (HMAC oder RSA). Plugin-Autoren lassen verifyWebhook in v1 oft als No-op — ein Sicherheitsrisiko in Produktion, da jeder, der die Webhook-URL kennt, einen gefälschten Bounce posten kann.
Fix für v1: Lassen Sie verifyWebhook als No-op + protokollieren Sie eine Warnung, dokumentieren Sie das als FOLLOW-UP. Die echte Implementierung speichert den Public Key des Vendors pro Server (im JSON-config) und verifiziert die Signatur gegen den Request-Body. Postal signiert mit RSA SHA256 über die Header X-Postal-Signature-KID + X-Postal-Signature-256.
4. Auswahl von runtimeMessageId
SendResult.runtimeMessageId ist das, was der Host in tracking_logs.runtime_message_id speichert. Der Webhook-Listener korreliert eingehende Bounces und Beschwerden über diese ID zurück zur ursprünglichen Tracking-Zeile. Sie muss zu dem passen, was der Vendor in Webhook-Payloads ablegt.
Die Antwort von Postals /api/v1/send/raw liefert sowohl eine global eindeutige message_id als auch ein per-Empfänger-token zurück. Postals MessageBounced-Webhook enthält payload.original_message.token — per Empfänger. Die Driver der Plattform senden einen Empfänger pro send()-Aufruf, also ist der richtige Wert zum Speichern der per-Empfänger-token, nicht die globale message_id.
Fix: Wenn der Vendor per-Empfänger-Identifier in seinen Webhooks sendet, speichern Sie den per-Empfänger-Identifier in runtimeMessageId. Die falsche Wahl bedeutet, dass jeder BounceLog mit NULL-tracking_log_id endet — der Bounce kommt an, aber nichts in der Host-UI wird ihn jemals anzeigen.
5. Der Race zwischen send() und tracking_logs-INSERT
Der SendMessage-Job des Hosts ruft zuerst driver->send() auf, dann fügt er die TrackingLog-Zeile ein. Vendors können einen Bounce- oder Beschwerde-Webhook liefern, bevor das INSERT committet — ein Race im Millisekundenbereich, der in Produktion real ist.
Der Host handhabt das bereits auf Listener-Ebene: RecordBounce und RecordComplaint wiederholen den Lookup bis zu 5 Sekunden lang, bevor sie aufgeben. Plugin-Autoren müssen nichts Besonderes tun, aber sollten NICHT:
- Ein TrackingLog vor
send() per Pre-INSERT erstellen — jeder Send wird selbst auf dem Fast-Path zu zwei DB-Roundtrips.
send() innerhalb einer äußeren Transaktion ausführen — das TrackingLog-INSERT wird bis zum äußeren Commit unsichtbar, was das Race-Fenster vergrößert.
Activate-und-verifizieren-Rezept
Nach dem Ablegen des Plugin-Ordners unter storage/app/plugins/<vendor>/<name>/ registrieren und aktivieren Sie es über tinker, dann führen Sie fünf Smoke-Checks aus:
# 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 nach den fünf bestandenen Checks:
- Login als Admin → Sending Servers → Create. Der Block „Plugin Servers" sollte jetzt Ihre Card zeigen.
- Klicken Sie auf Ihre Card. Das Formular rendert mit den in
validationRules()['cols'] deklarierten Feldern.
- Save mit gültigen Credentials. Der Host führt Phase 1 (Regeln) und dann Phase 2 (
driver->test()) aus; beide bestehen und die Zeile wird committet.
- Edit-Seite. Vier Tabs: Connection (Ihr Blade) + Configuration / Sender Identity / Warmup (vom Host gerendert).
Testing-Checkliste
| Test | Wie |
| Driver-Klasse lädt ohne Syntaxfehler | php artisan tinker --execute="new MyVendor\Sending\MyVendorDriver(new App\Model\SendingServer(['type' => 'myvendor-api']))" |
test() gelingt mit gültigen Credentials | Führen Sie einen curl gegen denselben Probe-Endpoint mit demselben Key aus — beide sollten dieselbe Form liefern |
test() scheitert sauber bei schlechten Credentials | Falschen my_api_key setzen → erwarten Sie TestResult::failure mit der tatsächlichen Fehlermeldung des Vendors, nicht mit einem Laravel-Exception-Trace |
| Formularfelder werden korrekt übermittelt | Save mit gültigen Credentials → die config-JSON der DB-Zeile enthält jeden in config_keys gelisteten Key |
| Webhook-Intake parst die Bounce-Form | POSTen Sie ein echtes Beispiel-Bounce-Payload → parseWebhook liefert BounceReceived mit der korrekten runtimeMessageId |
| Webhook-Signatur-Verifizierung (falls implementiert) | POST mit einer ungültigen Signatur → verifyWebhook wirft |
| Plugin-Uninstall räumt auf | App\Model\Plugin::find($id)->delete() → keine verwaisten sending_servers-Zeilen dieses type |
validationRules() deckt jeden Feldnamen in config_keys ab | Diff array_keys(validationRules()['cols']) gegen das config_keys-Payload — muss exakt übereinstimmen |
Für Ende-zu-Ende-Live-Tests liefert der Host ein Test-Harness aws-e2e/ aus, das eine echte SendingServer-Zeile aufsetzt, eine Test-Liste erstellt, eine Kampagne ausführt und gegen den Bounce-/Beschwerde-Flow prüft. Spiegeln Sie sein Drei-Skript-Muster (10-bootstrap-emma-{vendor}.php + 11-create-test-list.php + 12-run-test-campaign.php) für eine Live-Regressionsabdeckung.
Dateisystem-Template
Der schnellste Weg zu einem lauffähigen Plugin ist, das Postal-Plugin zu klonen und umzubenennen. Sechs Edits und ein paar Search-and-replaces:
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
Das Postal-Plugin ist eine nützliche Referenz für die Form, aber sein API-Client ist Postal-spezifisch — ersetzen, nicht adaptieren. Die acelle/ai-Showcase behandelt das kanonische komplexe Plugin, falls Sie eine andere Referenz für Testmuster, Sidebar-UI oder Admin-Seiten brauchen.
Wie es weitergeht
Sending Driver und Payment Gateways sind die zwei schwergewichtigsten „Feature-Plugin ausliefern"-Beispiele. Die Formen sind ähnlich — beide liefern einen einzelnen REGISTRY-Hook, eine Klasse mit einer kleinen Pflichtmethoden-Oberfläche und ein Connection-Blade — aber die Lifecycles unterscheiden sich deutlich. Sending Driver empfangen Webhooks (Push); Payment Gateways pullen Zustand nach Sync-Plan (kein Webhook). Die nächste Seite behandelt das Payment-Gateway-Muster mit Paddle als ausgearbeitetem Beispiel.
Wenn der Driver ausgeliefert und live ist, behandelt Testing das Rezept für den Lifecycle-Integrationstest (activate → test-send → delete), das beweist, dass Ihr delete_plugin_*-Listener sauber aufräumt. Die acelle/ai-Showcase behandelt das kanonische komplexe Plugin, falls Sie eine schwergewichtigere Referenz-Codebasis brauchen.