Um backend MTA novo em folha. Entregue como plugin. Sem dar fork no core.

Um driver de envio no AcelleMail é a classe que cuida de um vendor — Amazon SES, Postal, SendGrid, seu próprio backend SMTP. A aplicação host reserva um único hook REGISTRY (register_sending_server_driver) mais um pequeno conjunto de capability marker interfaces; todo o resto — a página do picker, o formulário de conexão, o pipeline de validação, o controller de webhook, a aba Sender Identity, a aba de warmup — vem embarcado no host. Esta página é o exemplo trabalhado para sair de plugin:init até um live-send passando através do seu driver, destilado da revisão estática do plugin Postal MTA (storage/app/plugins/rencontru/postal/).

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:

  1. 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.
  2. View namespace$this->loadViewsFrom($path, 'myvendor') para que view('myvendor::...') resolva para os templates do plugin.
  3. 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.
  4. 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.

MarcadorO que o driver implementa
ProvidesConnectionFieldsViewBlade customizado da aba Connection — connectionFieldsView(): string retorna o caminho da view com namespace.
ReceivesWebhooksverifyWebhook + parseWebhook + webhookUrl — o vendor faz POST de feedback para /webhook/{type}/{uid}; o host roteia o payload para seu driver.
SupportsIdentitySyncsyncIdentities + verifyIdentity — o host renderiza uma aba Sender Identity para este driver.
SupportsRemoteDomainVerifyaddDomain + validateDomain + checkDomainVerificationStatus — o vendor cuida da verificação DNS, não o host.
SignsDkimOnServerO servidor assina DKIM — o host pula sua própria camada de assinatura.
SupportsCustomReturnPathHonra um header Return-Path custom nas mensagens enviadas.
AllowsLocalDomainVerify / AllowsLocalEmailVerify / AllowsCrossSendingDomainFlags genéricas de flexibilidade estilo SMTP.
SendsCustomVerificationEmailsendVerificationEmail(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:

  1. Login como admin → Sending Servers → Create. O bloco "Plugin Servers" agora deve mostrar seu card.
  2. Clique no seu card. O formulário renderiza com os campos declarados em validationRules()['cols'].
  3. Salve com credenciais válidas. O host roda a Fase 1 (regras) depois a Fase 2 (driver->test()); ambas passam e a linha commita.
  4. Página de edição. Quatro abas: Connection (seu blade) + Configuration / Sender Identity / Warmup (renderizadas pelo host).

Checklist de testes

TesteComo
Classe do driver carrega sem erro de sintaxephp artisan tinker --execute="new MyVendor\Sending\MyVendorDriver(new App\Model\SendingServer(['type' => 'myvendor-api']))"
test() tem sucesso com credenciais válidasRode um curl contra o mesmo endpoint de probe com a mesma key — ambos devem retornar a mesma shape
test() falha graciosamente com credenciais ruinsDefina 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 corretamenteSalve com creds válidas → JSON config da linha do DB tem toda chave listada em config_keys
Intake de webhook parseia o shape do bouncePOST 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 tudoApp\Model\Plugin::find($id)->delete() → nenhuma linha órfã de sending_servers com este type
validationRules() cobre todo nome de campo em config_keysDiff 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.