Laravel e Symfony
Um cliente no contentor de serviços, uma tarefa que não pode enviar duas vezes, um controlador de webhooks e testes que nunca chegam à rede.
Configuração inicial
O pacote não tem integração própria com frameworks: nenhum service provider, nenhum bundle e nenhum transporte de correio. Regista um cliente no contentor e chama a API a partir de uma tarefa, de um controlador ou de um serviço, da mesma forma que a partir de qualquer programa PHP. É seguro manter o cliente durante toda a vida de um processo, por isso registe-o uma vez como serviço partilhado.
<?php return [ 'openemail' => [ 'key' => env('OPENEMAIL_API_KEY'), 'webhook_secret' => env('OPENEMAIL_WEBHOOK_SECRET'), ],];<?php namespace App\Providers; use Illuminate\Support\ServiceProvider;use OpenEmail\OpenEmail; final class AppServiceProvider extends ServiceProvider{ public function register(): void { $this->app->singleton(OpenEmail::class, fn(): OpenEmail => new OpenEmail( apiKey: (string) config('services.openemail.key'), timeout: 15, )); }}No Laravel, guarde a chave em .env e leia-a através de config/services.php, para que sobreviva a php artisan config:cache. O singleton é construído na primeira vez que algo o pede, e é por isso que um comando como php artisan migrate corre sem problemas sem a chave. A partir daí, qualquer construtor ou método handle que indique OpenEmail recebe o mesmo cliente.
services: OpenEmail\OpenEmail: arguments: $apiKey: '%env(OPENEMAIL_API_KEY)%' $timeout: 15No Symfony, declare o cliente como serviço com o nome da sua classe, e o autowiring entrega-o a todos os construtores que peçam OpenEmail. Os serviços são partilhados por omissão, por isso todo o contentor usa um único cliente.
Os exemplos usam o Laravel 12 ou posterior e o Symfony 6.4 ou posterior. O próprio cliente não depende de nenhum framework, por isso também funciona em versões mais antigas, com a forma própria de cada uma de registar um serviço.
Enviar a partir de uma tarefa
Envie a partir de uma tarefa em fila e não a partir do pedido, para que um envio lento ou falhado nunca atrase uma página. Derive idempotencyKey: do registo a que o envio diz respeito. Assim, uma tarefa que corre de novo, porque a fila a repetiu ou porque um worker morreu depois de a API responder, envia com a mesma chave, e a API reproduz a mensagem que já enviou em vez de enviar uma segunda.
<?php namespace App\Jobs; use App\Models\Invoice;use Illuminate\Contracts\Queue\ShouldQueue;use Illuminate\Foundation\Queue\Queueable;use Illuminate\Support\Facades\Storage;use OpenEmail\Exception\ValidationException;use OpenEmail\OpenEmail; final class SendInvoiceEmail implements ShouldQueue{ use Queueable; public int $tries = 5; public int $backoff = 30; public function __construct(public Invoice $invoice) {} public function handle(OpenEmail $openemail): void { try { $sent = $openemail->emails->send([ 'from' => 'Acme Billing <[email protected]>', 'to' => $this->invoice->customer_email, 'subject' => 'Invoice ' . $this->invoice->number, 'html' => '<p>Your invoice ' . $this->invoice->number . ' is attached.</p>', 'attachments' => [[ 'filename' => $this->invoice->number . '.pdf', 'content' => OpenEmail::toBase64((string) Storage::get($this->invoice->pdf_path)), 'contentType' => 'application/pdf', ]], ], idempotencyKey: 'invoice:' . $this->invoice->id . ':email'); } catch (ValidationException $error) { $this->fail($error); return; } $this->invoice->update(['email_id' => $sent['id'], 'email_status' => $sent['status']]); }}Um NetworkException é deixado escapar, por isso a fila volta a tentar a tarefa ao fim de $backoff segundos, e a mesma chave transforma a execução seguinte numa reprodução se a primeira chegou de facto à API. Um ValidationException faz falhar a tarefa de imediato, já que uma mensagem que a API recusou tal como estava escrita não pode ter sucesso se for enviada de novo. Quando um NetworkException chega à tarefa, o cliente já voltou a tentar o envio por si próprio, duas vezes por omissão, com a mesma chave.
Mantenha a chave estável para o registo e para o propósito, como invoice:42:email. Uma chave construída a partir de uma marca temporal ou de uniqid() é nova em cada execução, e uma tarefa repetida enviaria então duas vezes. Reutilizar uma chave com um corpo diferente, como uma fatura que mudou entre duas execuções, é recusado com um 422 idempotency_key_reuse em vez de enviado.
Um handler do Symfony Messenger funciona da mesma forma. O Messenger repete por si uma mensagem falhada, e lançar UnrecoverableMessageHandlingException diz-lhe que não o faça.
<?php namespace App\MessageHandler; use App\Message\SendWelcomeEmail;use OpenEmail\Exception\ValidationException;use OpenEmail\OpenEmail;use Symfony\Component\Messenger\Attribute\AsMessageHandler;use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException; #[AsMessageHandler]final class SendWelcomeEmailHandler{ public function __construct(private readonly OpenEmail $openemail) {} public function __invoke(SendWelcomeEmail $message): void { try { $this->openemail->emails->send([ 'from' => 'Acme <[email protected]>', 'to' => $message->email, 'subject' => 'Welcome to Acme', 'text' => 'Glad you are here.', ], idempotencyKey: 'welcome:' . $message->userId); } catch (ValidationException $error) { throw new UnrecoverableMessageHandlingException($error->getMessage(), 0, $error); } }}O Laravel tem um ValidationException próprio. Quando um ficheiro precisa dos dois, importe um com outro nome, como use OpenEmail\Exception\ValidationException as OpenEmailValidationException;.
Receber webhooks
Verifique cada entrega antes de agir com base nela. A assinatura cobre os bytes em bruto do corpo, por isso passe $request->getContent() ao verificador, e não uma cópia analisada ou recodificada, juntamente com $request->headers, de onde ele lê o cabeçalho da assinatura.
<?php namespace App\Http\Controllers; use App\Jobs\HandleOpenEmailEvent;use Illuminate\Http\Request;use Illuminate\Http\Response;use OpenEmail\Exception\WebhookSignatureException;use OpenEmail\OpenEmail; final class OpenEmailWebhookController{ public function __invoke(Request $request): Response { try { $event = OpenEmail::verifyWebhookSignature( $request->getContent(), $request->headers, (string) config('services.openemail.webhook_secret'), ); } catch (WebhookSignatureException) { return response('bad signature', 400); } HandleOpenEmailEvent::dispatch($event); return response()->noContent(); }}<?php use App\Http\Controllers\OpenEmailWebhookController;use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;use Illuminate\Support\Facades\Route; Route::post('/webhooks/openemail', OpenEmailWebhookController::class) ->withoutMiddleware([ValidateCsrfToken::class]);Uma entrega não traz nenhum token CSRF, por isso uma rota em routes/web.php tem de deixar esse middleware de fora, ou o Laravel recusa o POST com um 419 antes de o seu controlador correr. Uma rota em routes/api.php não tem verificação CSRF à partida.
<?php namespace App\Controller; use App\Message\OpenEmailEvent;use OpenEmail\Exception\WebhookSignatureException;use OpenEmail\OpenEmail;use Symfony\Component\DependencyInjection\Attribute\Autowire;use Symfony\Component\HttpFoundation\Request;use Symfony\Component\HttpFoundation\Response;use Symfony\Component\HttpKernel\Attribute\AsController;use Symfony\Component\Messenger\MessageBusInterface;use Symfony\Component\Routing\Attribute\Route; #[AsController]final class OpenEmailWebhookController{ public function __construct( private readonly MessageBusInterface $bus, #[Autowire(env: 'OPENEMAIL_WEBHOOK_SECRET')] private readonly string $webhookSecret, ) {} #[Route('/webhooks/openemail', methods: ['POST'])] public function __invoke(Request $request): Response { try { $event = OpenEmail::verifyWebhookSignature($request->getContent(), $request->headers, $this->webhookSecret); } catch (WebhookSignatureException) { return new Response('bad signature', Response::HTTP_BAD_REQUEST); } $this->bus->dispatch(new OpenEmailEvent($event)); return new Response(null, Response::HTTP_NO_CONTENT); }}Passe o evento à fila e responda de imediato: uma entrega que não recebe resposta em 5 segundos conta como falhada e é enviada de novo mais tarde. O id do evento é o mesmo em cada repetição e reenvio, por isso guarde os ids que já tratou e ignore os que já viu.
Responda 400 a uma entrega falsificada ou expirada e a mais nada. A falta do segredo é uma falha diferente: OpenEmail::verifyWebhookSignature() lança InvalidArgumentException nesse caso, que os controladores acima não apanham, por isso uma aplicação mal configurada responde 500 e a entrega é tentada de novo depois de a corrigir, em vez de todos os eventos serem rejeitados como falsificados.
Workers de longa duração
Com PHP-FPM, o contentor é construído em cada pedido, e o cliente também. Com Laravel Octane, RoadRunner, FrankenPHP em modo worker ou um worker de filas, o singleton vive tanto quanto o worker, por isso um cliente e a sua ligação aberta servem todos os pedidos que o worker trata. Não guarda nenhum estado que pertença a um único pedido: um apiKey: por chamada substitui a credencial apenas para essa chamada.
Um worker que faz fork, como pcntl_fork faz, dá ao processo filho uma ligação própria: o filho deixa de usar a que herdou e abre uma nova no primeiro pedido. Ainda assim, um filho que termina fecha a ligação herdada também para o pai, por isso chame $client->close() antes do fork, e o pai abre uma ligação nova que nenhum filho partilha.
Testes
Ponha no contentor, durante um teste, um cliente com um cliente HTTP falso e nada sai da máquina. Qualquer coisa que implemente OpenEmail\Http\HttpClient serve, incluindo uma classe anónima, e vê exatamente o que teria saído.
<?php namespace Tests\Feature; use App\Jobs\SendInvoiceEmail;use App\Models\Invoice;use Illuminate\Foundation\Testing\RefreshDatabase;use OpenEmail\Http\HttpClient;use OpenEmail\Http\HttpRequest;use OpenEmail\Http\HttpResponse;use OpenEmail\OpenEmail;use Tests\TestCase; final class SendInvoiceEmailTest extends TestCase{ use RefreshDatabase; public function testARetriedJobSendsUnderOneKey(): void { $api = new class implements HttpClient { public array $requests = []; public function send(HttpRequest $request): HttpResponse { $this->requests[] = $request; return new HttpResponse(200, ['content-type' => 'application/json'], '{"id":"msg_test","status":"sent"}'); } }; $this->app->instance(OpenEmail::class, new OpenEmail( apiKey: 'oe_test_fake', httpClient: $api, maxRetries: 0, disableUpdateNotice: true, )); $invoice = Invoice::factory()->create(); SendInvoiceEmail::dispatchSync($invoice); SendInvoiceEmail::dispatchSync($invoice); $keys = array_map(fn(HttpRequest $request): ?string => $request->header('Idempotency-Key'), $api->requests); $this->assertSame(array_fill(0, 2, 'invoice:' . $invoice->id . ':email'), $keys); $this->assertSame('/emails', parse_url($api->requests[0]->url, PHP_URL_PATH)); }}Cada pedido registado é um OpenEmail\Http\HttpRequest com method, url, headers, body e timeout, e o seu body é o JSON que teria sido enviado, por isso json_decode($request->body, true) mostra a própria mensagem. Devolva um estado de erro com o envelope de erro da API para testar como o seu código trata uma recusa. No Symfony, defina de novo o serviço para o ambiente de teste em config/services_test.yaml, com um cliente HTTP falso como argumento $httpClient.
<?php namespace App\Tests; use OpenEmail\Http\HttpClient;use OpenEmail\Http\HttpRequest;use OpenEmail\Http\HttpResponse; final class FakeOpenEmailHttpClient implements HttpClient{ public array $requests = []; public function send(HttpRequest $request): HttpResponse { $this->requests[] = $request; return new HttpResponse(200, ['content-type' => 'application/json'], '{"id":"msg_test","status":"sent"}'); }}services: App\Tests\FakeOpenEmailHttpClient: ~ OpenEmail\OpenEmail: arguments: $apiKey: 'oe_test_fake' $httpClient: '@App\Tests\FakeOpenEmailHttpClient' $maxRetries: 0 $disableUpdateNotice: true