Por que entregar um driver como plugin
O AcelleMail vem com um conjunto estável de drivers first-party — Amazon SES, SMTP genérico, sendmail, Postmark, SendGrid, Mailgun e mais alguns. Todos os outros vendors — provedores regionais, MTAs auto-hospedados, serviços transacionais de nicho, backends customizados — precisam das mesmas cinco coisas conectadas ao host: uma linha na página do picker, um formulário de conexão, uma etapa de validação, um listener de webhook para bounces e complaints e uma implementação runtime de send(). Fazer isso num fork significa rastrear upgrades do host para sempre; fazer isso como plugin significa colocar uma pasta em storage/app/plugins/{vendor}/{name}/ e deixar o host cuidar de cada preocupação host-side.
O contrato do plugin é pequeno por design. Um hook REGISTRY para declarar o driver. Uma classe de driver com cinco métodos obrigatórios (send, test, setupBeforeSend, validationRules, mais os accessors de service-name padrão). Um partial Blade para o formulário de conexão. Capability marker interfaces opcionais para tudo o mais — webhooks, sync de identidade, email de verificação custom. Só isso. Renderização do picker, layout do formulário, ação de salvar, pipeline de validação, roteamento de webhook — tudo no host.
O contrato — o que um plugin entrega
A árvore de arquivos completa de um plugin de 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
O esqueleto é intencionalmente enxuto. routes.php registra exatamente uma rota — servindo o icon.svg do plugin a partir do disco para que a página do picker tenha algo para renderizar. Endpoints CRUD, a URL de webhook, a ação de salvar do formulário, tudo vive no Refactor\Admin\SendingServerController do host; o plugin contribui apenas com a área de superfície específica do driver.
Quatro coisas que o plugin de fato registra com o host:
- Classe do driver + metadata — um único payload
Hook::add('register_sending_server_driver', ...) carregando o type slug, o FQCN da classe do driver, as config keys do vendor e a metadata do card do picker.
- View namespace —
$this->loadViewsFrom($path, 'myvendor') para que view('myvendor::...') resolva para os templates do plugin.
- Arquivo de tradução — um payload
Hook::add('add_translation_file', ...) apontando para o resources/lang/ do plugin para o master + o caminho do dump-clone.
- Blade da aba Connection — implementa o capability marker
ProvidesConnectionFieldsView no driver, retorna o caminho do partial que o formulário do host renderiza.
ServiceProvider — o padrão de boot
O service provider esqueleto completo para um plugin de sending driver (parafraseado do 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();
});
}
}
Duas regras não-óbvias que o host impõe:
- Todo
Hook::add exceto add_translation_file vai em boot(), nunca em register(). O SendingServerServiceProvider do host adia sua coleta do driver-registry via $this->app->booted(...) exatamente para que plugins tenham tempo de se registrar pelo seu próprio boot(); colocar register_sending_server_driver em register() significa que a closure pode rodar antes que suas próprias dependências (route() em particular) estejam disponíveis.
- Não faça
asset('plugins/myvendor/sending/icon.svg') a partir do payload do hook. Não há nenhum passo de auto-publish que copie assets de plugin para public/plugins/... em plugins de sending driver; esse path retorna 404 em produção. O plugin é dono da sua própria rota para o ícone (definida em routes.php), e o payload do hook referencia essa rota pelo nome. Autocontido — solta a pasta do plugin, e o ícone fica acessível sem nenhum code path do host.
A classe do driver
O driver mínimo viável herda de App\SendingServers\Drivers\AbstractDriver e implementa o marcador ProvidesConnectionFieldsView (que dá ao host um hint de que este driver tem seu próprio blade de conexão):
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';
}
}
Os quatro accessors de service-name (getServiceName, getServiceIcon, getServiceColor) são tudo o que o host precisa para renderizar o card do picker e o header do servidor escolhido na UI de Sending Servers. send() e test() são os hot paths de produção — toda campanha enviada através de um servidor desse tipo chama send() uma vez por destinatário; todo clique em "Test connection" na página admin chama test(). setupBeforeSend() roda uma vez no início de um batch de campanha — a maioria dos drivers deixa vazio.
Capability marker interfaces
Além da superfície mínima, o host expõe um conjunto de capability marker interfaces. O driver implementa apenas os marcadores que se aplicam — o host faz checagens instanceof em todo call-site, então um driver que não implementa ReceivesWebhooks simplesmente pula o registro da rota de webhook sem quebrar.
| Marcador | O que o driver implementa |
ProvidesConnectionFieldsView | Blade customizado da aba Connection — connectionFieldsView(): string retorna o caminho da view com namespace. |
ReceivesWebhooks | verifyWebhook + parseWebhook + webhookUrl — o vendor faz POST de feedback para /webhook/{type}/{uid}; o host roteia o payload para seu driver. |
SupportsIdentitySync | syncIdentities + verifyIdentity — o host renderiza uma aba Sender Identity para este driver. |
SupportsRemoteDomainVerify | addDomain + validateDomain + checkDomainVerificationStatus — o vendor cuida da verificação DNS, não o host. |
SignsDkimOnServer | O servidor assina DKIM — o host pula sua própria camada de assinatura. |
SupportsCustomReturnPath | Honra um header Return-Path custom nas mensagens enviadas. |
AllowsLocalDomainVerify / AllowsLocalEmailVerify / AllowsCrossSendingDomain | Flags genéricas de flexibilidade estilo SMTP. |
SendsCustomVerificationEmail | sendVerificationEmail(Sender) — o driver renderiza + envia sua própria mensagem de verificação em vez da default do host. |
Blade da aba Connection
O partial de conexão em resources/views/sending-servers/_fields_connection.blade.php renderiza apenas os campos do formulário. O host envolve isso no <form>, no botão submit, no alerta de validação e no chrome de quatro abas da 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>
Três regras governam o partial:
- Apenas campos, sem
<form>, sem botão submit. O host é dono do wrapper do form. Adicionar seu próprio submit dispara o endpoint de salvar errado.
- O
name do campo corresponde às chaves do payload config_keys + validationRules()['cols']. O host auto-roteia $server->fill($request->all()) através da coluna JSON config baseado nas chaves que você declarou.
- Leia valores existentes via
$server->getConfig('my_api_key'), não $server->my_api_key. O segundo por acaso funciona através de um fallback legado em getAttribute mas é mais turvo e não é contratualmente estável.
O pipeline de validação
Quando um admin clica Save no formulário de Sending Server, o host roda a validação do seu driver em duas 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
Seu driver controla dois modos de falha:
- Field-level (Fase 1) — regras em
validationRules()['cols']. O host auto-mapeia cada falha de regra para o campo name=... correspondente no seu blade, onde @error('my_api_key') renderiza inline.
- Connection-level (Fase 2) — qualquer coisa lançada ou retornada como
TestResult::failure(...) a partir de test(). O host exibe isso num campo sintético connection renderizado no alerta de resumo de validação no topo do formulário.
Cinco armadilhas do plugin Postal
Esses são bugs reais que o plugin Postal MTA encontrou. Conhecê-los de antemão economiza horas de debugging para o próximo autor de driver.
1. test() precisa atingir um endpoint real
A primeira implementação de test() do plugin Postal chamou client->makeRequest('servers', 'list', []) — URL /api/v1/servers/list. Olhando a API real do Postal, só existem os controllers messages e send; não existe nenhum endpoint servers. O Postal retornou HTTP 404 toda vez, e o admin viu "Status code returned by Postal server: 404" em vermelho mesmo com credenciais válidas.
Fix: sempre cruze com a documentação da API do vendor para achar um endpoint de leitura existente que exija auth e tenha zero efeitos colaterais. Candidatos típicos: GET /me, GET /account, GET /domains. O probe precisa distinguir 200-com-key-válida de 401/403-com-key-ruim — 404-em-rota-faltante não significa nada.
2. O shape do payload de webhook muda entre versões do vendor
Vendors evoluem seus formatos de webhook. O plugin Postal saiu com três guards de formato hardcoded cobrindo "muito antigo", "legado" e "atual" — e ainda errou o formato moderno. O Postal moderno envolve tudo em {event, timestamp, payload, uuid}; para MessageBounced, o payload é {original_message: {token, ...}, bounce: {...}}. O token está em payload.original_message.token, não em payload.message.token — o plugin errou a diferença e silenciosamente derrubou todo bounce.
Fix: baixe o código-fonte do vendor e ache todo call site de webhook.trigger(...). Enumere as shapes exatas que o vendor de fato envia. Faça parseWebhook retornar IgnorableWebhookEvent para nomes de evento desconhecidos em vez de descartar silenciosamente — observabilidade importa.
3. Verificação de assinatura de webhook
A maioria dos vendors assina seus webhooks (HMAC ou RSA). Autores de plugin frequentemente deixam verifyWebhook como no-op no v1 — risco de segurança em produção, porque qualquer um que sabe a URL do webhook pode fazer POST de um bounce falso.
Fix para v1: deixe verifyWebhook como no-op + log de warning, documente como FOLLOW-UP. Implementação real guarda a chave pública do vendor por servidor (no JSON config) e verifica a assinatura contra o corpo da requisição. O Postal assina com RSA SHA256 através dos headers X-Postal-Signature-KID + X-Postal-Signature-256.
4. Escolha do runtimeMessageId
SendResult.runtimeMessageId é o que o host guarda em tracking_logs.runtime_message_id. O listener de webhook correlaciona bounces e complaints recebidos de volta à linha de tracking originadora via esse id. Ele precisa bater com o que o vendor coloca nos payloads de webhook.
A resposta do /api/v1/send/raw do Postal retorna tanto um message_id globalmente único quanto um token por destinatário. O webhook MessageBounced do Postal contém payload.original_message.token — por destinatário. Os drivers da plataforma enviam um destinatário por chamada de send(), então o valor certo para guardar é o token por destinatário, não o message_id global.
Fix: se o vendor envia identificadores por destinatário nos seus webhooks, guarde o identificador por destinatário em runtimeMessageId. Escolher errado significa que todo BounceLog acaba com tracking_log_id NULL — o bounce chega mas nada na UI do host vai mostrar.
5. A race entre send() e o INSERT em tracking_logs
O job SendMessage do host chama driver->send() primeiro, depois insere a linha de TrackingLog. Vendors podem entregar um webhook de bounce ou complaint antes que o INSERT commite — race na escala de milissegundos que é real em produção.
O host já lida com isso no nível do listener: RecordBounce e RecordComplaint retentam o lookup por até 5 segundos antes de desistir. Autores de plugin não precisam fazer nada especial, mas NÃO devem:
- Pré-INSERT de um TrackingLog antes do
send() — todo envio vira dois round-trips de DB mesmo no fast path.
- Rodar
send() dentro de uma transação externa — o INSERT do TrackingLog fica invisível até o commit externo, alargando a janela da race.
Receita para ativar + verificar
Depois de colocar a pasta do plugin em storage/app/plugins/<vendor>/<name>/, registre e ative pelo tinker, depois rode cinco 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 de UI depois que os cinco checks passarem:
- Login como admin → Sending Servers → Create. O bloco "Plugin Servers" agora deve mostrar seu card.
- Clique no seu card. O formulário renderiza com os campos declarados em
validationRules()['cols'].
- Salve com credenciais válidas. O host roda a Fase 1 (regras) depois a Fase 2 (
driver->test()); ambas passam e a linha commita.
- Página de edição. Quatro abas: Connection (seu blade) + Configuration / Sender Identity / Warmup (renderizadas pelo host).
Checklist de testes
| Teste | Como |
| Classe do driver carrega sem erro de sintaxe | php artisan tinker --execute="new MyVendor\Sending\MyVendorDriver(new App\Model\SendingServer(['type' => 'myvendor-api']))" |
test() tem sucesso com credenciais válidas | Rode um curl contra o mesmo endpoint de probe com a mesma key — ambos devem retornar a mesma shape |
test() falha graciosamente com credenciais ruins | Defina my_api_key errada → espere TestResult::failure com a mensagem de erro real do vendor, não um trace de exception do Laravel |
| Campos do formulário submetem corretamente | Salve com creds válidas → JSON config da linha do DB tem toda chave listada em config_keys |
| Intake de webhook parseia o shape do bounce | POST de um payload de bounce sample real → parseWebhook retorna BounceReceived com o runtimeMessageId correto |
| Verificação de assinatura de webhook (se implementada) | POST com assinatura inválida → verifyWebhook lança |
| Uninstall do plugin limpa tudo | App\Model\Plugin::find($id)->delete() → nenhuma linha órfã de sending_servers com este type |
validationRules() cobre todo nome de campo em config_keys | Diff array_keys(validationRules()['cols']) contra o payload config_keys — devem bater exatamente |
Para testes ao vivo de ponta a ponta, o host entrega um harness de teste aws-e2e/ que bootstrapa uma linha real de SendingServer, cria uma lista de teste, roda uma campanha e faz asserções no fluxo de bounce/complaint. Espelhe seu padrão de três scripts (10-bootstrap-emma-{vendor}.php + 11-create-test-list.php + 12-run-test-campaign.php) para cobertura de regressão ao vivo.
Template de filesystem
O caminho mais rápido para um plugin funcionando é clonar o plugin Postal e renomear. Seis edições e alguns 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
O plugin Postal é uma referência útil para o shape mas seu cliente de API é específico do Postal — substitua, não adapte. O showcase do acelle/ai cobre o plugin complexo canônico se você precisar de uma referência diferente para padrões de teste, sidebar UI ou páginas admin.
Para onde ir em seguida
Drivers de envio e gateways de pagamento são os dois exemplos trabalhados mais pesados de "entregar uma feature como plugin". As shapes são similares — ambos entregam um único hook REGISTRY, uma classe com uma pequena superfície de métodos obrigatórios e um blade de conexão — mas os ciclos de vida diferem significativamente. Drivers de envio recebem webhooks (push); gateways de pagamento puxam estado num cronograma de sync (sem webhook). A próxima página cobre o padrão de payment gateway com o Paddle como exemplo trabalhado.
Quando o driver está entregue e ao vivo, Testes cobre a receita de teste de integração de ciclo de vida (ativar → test-send → deletar) que prova que seu listener delete_plugin_* limpa corretamente. O showcase do acelle/ai percorre o plugin complexo canônico se você precisar de um codebase de referência mais pesado.