Laravel と Symfony
コンテナに 1 つのクライアント、二重送信しないジョブ、Webhook コントローラー、そしてネットワークに出ないテスト。
セットアップ
このパッケージには独自のフレームワーク統合はありません。サービスプロバイダーもバンドルもメールトランスポートもありません。コンテナにクライアントを 1 つ登録し、他の PHP プログラムと同じように、ジョブ、コントローラー、サービスから API を呼び出します。クライアントはプロセスが動いている間ずっと保持しても安全なので、共有サービスとして一度だけ登録してください。
<?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, )); }}Laravel では、キーを .env に置き、config/services.php を通して読み込みます。こうすると php artisan config:cache の後も値が失われません。シングルトンは何かが最初に要求したときに作られるため、php artisan migrate のようなコマンドはキーがなくても問題なく動きます。それ以降は、OpenEmail を指定したコンストラクターや handle メソッドはすべて同じクライアントを受け取ります。
services: OpenEmail\OpenEmail: arguments: $apiKey: '%env(OPENEMAIL_API_KEY)%' $timeout: 15Symfony では、クライアントをそのクラス名でサービスとして宣言すると、オートワイヤリングによって OpenEmail を要求するすべてのコンストラクターに渡されます。サービスは既定で共有されるため、コンテナ全体で 1 つのクライアントが使われます。
サンプルは Laravel 12 以降と Symfony 6.4 以降を前提としています。クライアント自体はどのフレームワークにも依存しないため、それより古いバージョンでも、それぞれのサービス登録の方法で使えます。
ジョブからの送信
送信はリクエストの中ではなくキューに入れたジョブから行い、遅い送信や失敗した送信がページの表示を妨げないようにします。idempotencyKey: は送信の対象となるレコードから導き出します。こうすると、キューがリトライした場合や API の応答後にワーカーが停止した場合など、ジョブが再実行されても同じキーで送信され、API は 2 通目を送る代わりに、すでに送ったメッセージをリプレイします。
<?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']]); }}NetworkException はそのまま外に出すため、キューは $backoff 秒後にジョブを再試行します。最初の実行が API に届いていた場合は、同じキーによって次の実行がリプレイになります。ValidationException ではジョブをすぐに失敗させます。API が内容を理由に拒否したメッセージは、再送しても成功しないからです。NetworkException がジョブに届く時点で、クライアントはすでに同じキーで送信を自ら再試行しています(既定では 2 回)。
invoice:42:email のように、キーはレコードと目的に対して一定に保ってください。タイムスタンプや uniqid() から作ったキーは実行のたびに変わるため、リトライされたジョブが 2 回送信してしまいます。2 回の実行の間に請求書が変わった場合のように、異なるボディでキーを再利用すると、送信されずに 422 idempotency_key_reuse で拒否されます。
Symfony Messenger のハンドラーも同じように動きます。Messenger は失敗したメッセージを自動的にリトライし、UnrecoverableMessageHandlingException をスローするとリトライしないよう指示できます。
<?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); } }}Laravel には独自の ValidationException があります。1 つのファイルで両方が必要なときは、use OpenEmail\Exception\ValidationException as OpenEmailValidationException; のように、一方を別名でインポートしてください。
Webhook の受信
配信を処理する前に、必ずすべての配信を検証してください。署名はボディの生のバイト列を対象とするため、パースや再エンコードをしたコピーではなく $request->getContent() を、署名ヘッダーの読み取り元となる $request->headers とともに検証関数に渡します。
<?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]);配信には CSRF トークンが含まれないため、routes/web.php のルートではそのミドルウェアを外す必要があります。そうしないと、コントローラーが実行される前に Laravel が POST を 419 で拒否します。routes/api.php のルートには、もともと CSRF チェックがありません。
<?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); }}イベントはキューに渡してすぐに応答してください。5 秒以内に応答がない配信は失敗とみなされ、後で再送されます。イベントの id はリトライでもリプレイでも同じなので、処理済みの id を保存し、見たことのあるものはスキップしてください。
400 を返すのは、偽造された配信か古い配信に対してだけにしてください。シークレットがないのは別の失敗です。その場合 OpenEmail::verifyWebhookSignature() は InvalidArgumentException をスローし、上のコントローラーはそれを捕捉しません。そのため設定に誤りのあるアプリは 500 を返し、すべてのイベントが偽造として拒否されるのではなく、設定を直した後に配信が再試行されます。
長時間動作するワーカー
PHP-FPM では、コンテナはリクエストごとに作られ、クライアントも同様です。Laravel Octane、RoadRunner、ワーカーモードの FrankenPHP、キューワーカーでは、シングルトンはワーカーが動いている間ずっと存在するため、1 つのクライアントとその開いた接続が、ワーカーが処理するすべてのリクエストに使われます。クライアントは特定のリクエストに属する状態を持ちません。呼び出しごとの apiKey: は、その呼び出しに限って資格情報を置き換えます。
pcntl_fork のようにフォークするワーカーでは、子プロセスが自分の接続を持ちます。子プロセスは継承した接続の使用をやめ、最初のリクエストで新しい接続を開きます。ただし、子プロセスが終了すると継承した接続は親プロセス側でも閉じられるため、フォークの前に $client->close() を呼んでください。そうすれば親プロセスは、どの子プロセスとも共有しない新しい接続を開きます。
テスト
テストの間、フェイクの HTTP クライアントを持つクライアントをコンテナに入れておけば、何もマシンの外に出ません。OpenEmail\Http\HttpClient を実装するものなら匿名クラスを含めて何でも使え、送信されるはずだった内容をそのまま確認できます。
<?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)); }}記録された各リクエストは method、url、headers、body、timeout を持つ OpenEmail\Http\HttpRequest で、その body は送信されるはずだった JSON です。そのため json_decode($request->body, true) でメッセージそのものを確認できます。コードが拒否をどう扱うかをテストするには、API のエラーエンベロープとともにエラーステータスを返してください。Symfony では、テスト環境用に config/services_test.yaml でサービスを定義し直し、$httpClient 引数にフェイクの HTTP クライアントを渡します。
<?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