Por qué entregar un driver como plugin
AcelleMail viene con un conjunto estable de drivers de primera línea: Amazon SES, SMTP genérico, sendmail, Postmark, SendGrid, Mailgun y unos pocos más. Cualquier otro proveedor (proveedores regionales, MTAs autoalojados, servicios transaccionales de nicho, backends a medida) necesita las mismas cinco cosas conectadas al host: una fila en la página del picker, un formulario de conexión, un paso de validación, un listener de webhook para rebotes y quejas, y una implementación en runtime de send(). Hacerlo en un fork significa seguir las actualizaciones del host para siempre; hacerlo como plugin significa dejar una carpeta en storage/app/plugins/{vendor}/{name}/ y dejar que el host se ocupe de cada preocupación del lado del host.
El contrato del plugin es pequeño a propósito. Un hook REGISTRY para declarar el driver. Una clase de driver con cinco métodos obligatorios (send, test, setupBeforeSend, validationRules, más los accesores estándar de nombre de servicio). Un partial Blade para el formulario de conexión. Interfaces marker de capability opcionales para todo lo demás: webhooks, sync de identidades, email de verificación personalizado. Eso es todo. Renderizado del picker, layout del formulario, acción de guardar, pipeline de validación, enrutamiento de webhooks: todo en el host.
El contrato — qué entrega un plugin
El árbol completo de archivos de un plugin de driver de envío:
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
El esqueleto es intencionadamente fino. routes.php registra exactamente una ruta: servir el icon.svg del plugin desde disco para que la página del picker tenga algo que renderizar. Los endpoints CRUD, la URL del webhook y la acción de guardar del formulario viven todos en el Refactor\Admin\SendingServerController del host; el plugin aporta solo el área de superficie específica del driver.
Cuatro cosas que el plugin realmente registra con el host:
- Clase del driver + metadatos: un único payload
Hook::add('register_sending_server_driver', ...) que lleva el slug de tipo, el FQCN de la clase del driver, las claves de configuración del proveedor y los metadatos de la tarjeta del picker.
- Namespace de vistas:
$this->loadViewsFrom($path, 'myvendor') para que view('myvendor::...') resuelva a las plantillas del plugin.
- Archivo de traducción: un payload
Hook::add('add_translation_file', ...) que apunta a resources/lang/ del plugin para el maestro + la ruta del clon volcado.
- Blade de la pestaña de conexión: implementa el marker de capability
ProvidesConnectionFieldsView en el driver y devuelve la ruta del partial que renderiza el formulario del host.
ServiceProvider — el patrón de arranque
El esqueleto completo del service provider para un plugin de driver de envío (parafraseado del plugin de 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();
});
}
}
Dos reglas no obvias que el host hace cumplir:
- Cada
Hook::add excepto add_translation_file va en boot(), nunca en register(). El SendingServerServiceProvider del host difiere su recogida del registro de drivers vía $this->app->booted(...) exactamente para que los plugins tengan tiempo de registrarse a través de su propio boot(); poner register_sending_server_driver en register() significa que el closure puede ejecutarse antes de que sus propias dependencias (route() en particular) estén disponibles.
- No haga
asset('plugins/myvendor/sending/icon.svg') desde el payload del hook. No hay un paso de auto-publicación que copie los assets del plugin a public/plugins/... para los plugins de driver de envío; esa ruta da 404 en producción. El plugin es dueño de su propia ruta para el icono (definida en routes.php) y el payload del hook hace referencia a esa ruta por nombre. Autocontenido: coloque la carpeta del plugin y el icono es alcanzable sin ningún paso de código del host.
La clase del driver
El driver de viabilidad mínima hereda de App\SendingServers\Drivers\AbstractDriver e implementa el marker ProvidesConnectionFieldsView (que le da al host una pista de que este driver tiene su propia blade de conexión):
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';
}
}
Los cuatro accesores de nombre de servicio (getServiceName, getServiceIcon, getServiceColor) son todo lo que el host necesita para renderizar la tarjeta del picker y la cabecera del servidor elegido en la UI de Servidores de envío. send() y test() son las rutas calientes de producción: cada campaña enviada a través de un servidor de este tipo llama a send() una vez por destinatario; cada clic en «Probar conexión» en la página de administración llama a test(). setupBeforeSend() se ejecuta una vez al inicio de un batch de campaña; la mayoría de los drivers lo dejan vacío.
Interfaces marker de capability
Más allá de la superficie mínima, el host expone un conjunto de interfaces marker de capability. El driver implementa solo los markers que apliquen: el host hace comprobaciones instanceof en cada call-site, así que un driver que no implemente ReceivesWebhooks simplemente se salta el registro de la ruta del webhook sin lanzar excepción.
| Marker | Qué implementa el driver |
ProvidesConnectionFieldsView | Blade personalizada de la pestaña de conexión: connectionFieldsView(): string devuelve la ruta de vista con namespace. |
ReceivesWebhooks | verifyWebhook + parseWebhook + webhookUrl: el proveedor hace POST del feedback a /webhook/{type}/{uid}; el host enruta el payload a su driver. |
SupportsIdentitySync | syncIdentities + verifyIdentity: el host renderiza una pestaña de Sender Identity para este driver. |
SupportsRemoteDomainVerify | addDomain + validateDomain + checkDomainVerificationStatus: el proveedor gestiona la verificación de DNS, no el host. |
SignsDkimOnServer | El servidor firma DKIM: el host se salta su propia capa de firma. |
SupportsCustomReturnPath | Honra una cabecera Return-Path personalizada en el correo saliente. |
AllowsLocalDomainVerify / AllowsLocalEmailVerify / AllowsCrossSendingDomain | Flags de flexibilidad de estilo SMTP genérico. |
SendsCustomVerificationEmail | sendVerificationEmail(Sender): el driver renderiza y envía su propio mensaje de verificación en lugar del valor por defecto del host. |
Blade de la pestaña de conexión
El partial de conexión bajo resources/views/sending-servers/_fields_connection.blade.php renderiza solo los campos del formulario. El host lo envuelve en el <form>, el botón de submit, la alerta de validación y el chrome de cuatro pestañas de la página:
<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>
Tres reglas rigen el partial:
- Solo campos, sin
<form>, sin botón de submit. El host es dueño del envoltorio del form. Añadir su propio submit dispara el endpoint de guardado equivocado.
- El
name del campo coincide con el payload config_keys y con las claves de validationRules()['cols']. El host auto-enruta $server->fill($request->all()) a la columna JSON config según las claves que usted declaró.
- Lea los valores existentes vía
$server->getConfig('my_api_key'), no con $server->my_api_key. Esto último resulta que funciona a través de un fallback legacy de getAttribute, pero es más turbio y no es contractualmente estable.
El pipeline de validación
Cuando un admin hace clic en Guardar en el formulario del Servidor de envío, el host ejecuta la validación de su driver en dos fases:
[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
Su driver controla dos modos de fallo:
- A nivel de campo (Fase 1): reglas en
validationRules()['cols']. El host auto-mapea cada fallo de regla a su campo name=... correspondiente en su blade, donde @error('my_api_key') renderiza en línea.
- A nivel de conexión (Fase 2): cualquier excepción lanzada o cualquier
TestResult::failure(...) devuelto desde test(). El host lo muestra en un campo sintético connection renderizado en la alerta de resumen de validación en la parte superior del formulario.
Cinco trampas del plugin de Postal
Estos son bugs reales con los que tropezó el plugin de Postal MTA. Conocerlos por adelantado le ahorra al próximo autor de driver horas de depuración.
1. test() debe golpear un endpoint real
La primera implementación de test() del plugin de Postal llamaba a client->makeRequest('servers', 'list', []): URL /api/v1/servers/list. Mirando la API real de Postal, solo existen los controladores messages y send; no hay un endpoint servers. Postal devolvía HTTP 404 cada vez, y el admin veía «Status code returned by Postal server: 404» en rojo incluso con credenciales válidas.
Solución: verifique siempre con la documentación de la API del proveedor que exista un endpoint de lectura que requiera autenticación y tenga cero efectos secundarios. Candidatos típicos: GET /me, GET /account, GET /domains. La sonda debe distinguir 200-con-clave-válida de 401/403-con-clave-mala: un 404 por ruta inexistente no significa nada.
2. La forma del payload del webhook cambia entre versiones del proveedor
Los proveedores evolucionan sus formatos de webhook. El plugin de Postal se entregó con tres guardas de formato hardcodeadas que cubrían «muy antigua», «legacy» y «actual», y aun así se perdió el formato moderno. El Postal moderno envuelve todo en {event, timestamp, payload, uuid}; para MessageBounced, el payload es {original_message: {token, ...}, bounce: {...}}. El token está en payload.original_message.token, no en payload.message.token: el plugin se perdió la diferencia y dejó caer silenciosamente cada rebote.
Solución: tire del código fuente del proveedor y encuentre cada call-site de webhook.trigger(...). Enumere las formas exactas de payload que el proveedor envía realmente. Haga que parseWebhook devuelva IgnorableWebhookEvent para nombres de evento desconocidos en lugar de dejarlos caer silenciosamente: la observabilidad importa.
3. Verificación de firma del webhook
La mayoría de los proveedores firman sus webhooks (HMAC o RSA). Los autores de plugin a menudo dejan verifyWebhook como un no-op en la v1: riesgo de seguridad en producción, porque cualquiera que conozca la URL del webhook puede hacer POST de un rebote falso.
Solución para v1: deje verifyWebhook como no-op y registre un warning, documéntelo como FOLLOW-UP. La implementación real guarda la clave pública del proveedor por servidor (en el JSON de config) y verifica la firma contra el cuerpo de la petición. Postal firma con RSA SHA256 sobre las cabeceras X-Postal-Signature-KID y X-Postal-Signature-256.
4. Selección de runtimeMessageId
SendResult.runtimeMessageId es lo que el host guarda en tracking_logs.runtime_message_id. El listener del webhook correlaciona los rebotes y quejas entrantes de vuelta a la fila de tracking originadora mediante este id. Debe coincidir con lo que el proveedor pone en los payloads del webhook.
La respuesta de /api/v1/send/raw de Postal devuelve tanto un message_id globalmente único como un token por destinatario. El webhook MessageBounced de Postal contiene payload.original_message.token, que es por destinatario. Los drivers de la plataforma envían un destinatario por llamada a send(), así que el valor correcto a guardar es el token por destinatario, no el message_id global.
Solución: si el proveedor envía identificadores por destinatario en sus webhooks, guarde el identificador por destinatario en runtimeMessageId. Elegir el equivocado significa que cada BounceLog acaba con tracking_log_id NULL: el rebote llega pero nada en la UI del host lo mostrará jamás.
5. La carrera entre send() y el INSERT en tracking_logs
El job SendMessage del host llama primero a driver->send() y luego inserta la fila de TrackingLog. Los proveedores pueden entregar un webhook de rebote o queja antes de que el INSERT haga commit: una carrera a escala de milisegundos que es real en producción.
El host ya lo gestiona a nivel del listener: RecordBounce y RecordComplaint reintentan la consulta durante hasta 5 segundos antes de rendirse. Los autores de plugin no necesitan hacer nada especial, pero NO deben:
- Pre-INSERTAR un TrackingLog antes de
send(): cada envío se convierte en dos round-trips a la BD incluso en la ruta rápida.
- Ejecutar
send() dentro de una transacción externa: el INSERT del TrackingLog se vuelve invisible hasta el commit externo, ensanchando la ventana de la carrera.
Receta de activar + verificar
Después de dejar la carpeta del plugin bajo storage/app/plugins/<vendor>/<name>/, regístrelo y actívelo a través de tinker, y luego ejecute cinco comprobaciones rápidas:
# 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 de UI tras pasar las cinco comprobaciones:
- Inicie sesión como admin → Servidores de envío → Crear. El bloque «Plugin Servers» debería mostrar ya su tarjeta.
- Haga clic en su tarjeta. El formulario se renderiza con los campos declarados en
validationRules()['cols'].
- Guarde con credenciales válidas. El host ejecuta la Fase 1 (reglas) y luego la Fase 2 (
driver->test()); ambas pasan y la fila se confirma.
- Página de edición. Cuatro pestañas: Conexión (su blade) + Configuración / Sender Identity / Warmup (renderizadas por el host).
Checklist de pruebas
| Prueba | Cómo |
| La clase del driver carga sin errores de sintaxis | php artisan tinker --execute="new MyVendor\Sending\MyVendorDriver(new App\Model\SendingServer(['type' => 'myvendor-api']))" |
test() tiene éxito con credenciales válidas | Haga un curl contra el mismo endpoint de sonda con la misma clave: ambos deben devolver la misma forma |
test() falla con elegancia con credenciales malas | Ponga una my_api_key equivocada → espere TestResult::failure con el mensaje de error real del proveedor, no una traza de excepción de Laravel |
| Los campos del formulario se envían correctamente | Guarde con credenciales válidas → el JSON config de la fila en BD tiene cada clave listada en config_keys |
| La recepción del webhook parsea la forma del rebote | POST de un payload de rebote real → parseWebhook produce BounceReceived con el runtimeMessageId correcto |
| Verificación de firma del webhook (si está implementada) | POST con una firma inválida → verifyWebhook lanza una excepción |
| La desinstalación del plugin limpia | App\Model\Plugin::find($id)->delete() → no quedan filas huérfanas de sending_servers de este type |
validationRules() cubre cada nombre de campo en config_keys | Compare array_keys(validationRules()['cols']) con el payload config_keys: deben coincidir exactamente |
Para tests en vivo de extremo a extremo, el host entrega un harness de tests aws-e2e/ que crea una fila SendingServer real, crea una lista de prueba, ejecuta una campaña y verifica el flujo de rebotes/quejas. Replique su patrón de tres scripts (10-bootstrap-emma-{vendor}.php + 11-create-test-list.php + 12-run-test-campaign.php) para cobertura de regresión en vivo.
Plantilla del sistema de archivos
El camino más rápido a un plugin funcional es clonar el plugin de Postal y renombrarlo. Seis ediciones y unos cuantos buscar-y-reemplazar:
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
El plugin de Postal es una referencia útil de forma, pero su cliente de API es específico de Postal: reemplace, no adapte. La muestra de acelle/ai cubre el plugin complejo canónico si necesita otra referencia para patrones de testing, UI de barra lateral o páginas de administración.
A dónde ir después
Los drivers de envío y las pasarelas de pago son los dos ejemplos trabajados más pesados de «entregar un plugin de feature». Las formas son parecidas (ambos entregan un único hook REGISTRY, una clase con una pequeña superficie de métodos obligatorios y una blade de conexión), pero los ciclos de vida difieren significativamente. Los drivers de envío reciben webhooks (push); las pasarelas de pago extraen el estado en un calendario de sync (sin webhook). La siguiente página cubre el patrón de pasarela de pago con Paddle como ejemplo trabajado.
Cuando el driver esté entregado y en vivo, Testing cubre la receta de test de integración del ciclo de vida (activar → enviar-de-prueba → eliminar) que prueba que su listener delete_plugin_* limpia correctamente. La muestra de acelle/ai recorre el plugin complejo canónico si necesita un código fuente de referencia más pesado.