diff --git a/composer.json b/composer.json index a904a75..3b5eb35 100644 --- a/composer.json +++ b/composer.json @@ -17,9 +17,14 @@ ], "require": { "php": "^8.1", + "guzzlehttp/guzzle": "^7.5", + "illuminate/bus": "^10.0|^11.0|^12.0|^13.0", + "illuminate/cache": "^10.0|^11.0|^12.0|^13.0", "illuminate/container": "^10.0|^11.0|^12.0|^13.0", "illuminate/database": "^10.0|^11.0|^12.0|^13.0", + "illuminate/http": "^10.0|^11.0|^12.0|^13.0", "illuminate/log": "^10.0|^11.0|^12.0|^13.0", + "illuminate/queue": "^10.0|^11.0|^12.0|^13.0", "illuminate/routing": "^10.0|^11.0|^12.0|^13.0", "illuminate/support": "^10.0|^11.0|^12.0|^13.0", "willdurand/email-reply-parser": "^2.8", diff --git a/config/mailbox.php b/config/mailbox.php index b5e6ebb..b5765c4 100644 --- a/config/mailbox.php +++ b/config/mailbox.php @@ -6,7 +6,7 @@ * The driver to use when listening for incoming emails. * It defaults to the mail driver that you are using. * - * Supported drivers: "log", "mailgun", "sendgrid", "postmark" + * Supported drivers: "log", "mailgun", "sendgrid", "postmark", "resend" */ 'driver' => env('MAILBOX_DRIVER', 'log'), @@ -63,6 +63,13 @@ 'key' => env('MAILBOX_MAILGUN_KEY'), ], + 'resend' => [ + 'api_key' => env('MAILBOX_RESEND_API_KEY'), + 'webhook_secret' => env('MAILBOX_RESEND_WEBHOOK_SECRET'), + 'queue_connection' => env('MAILBOX_RESEND_QUEUE_CONNECTION', 'sync'), + 'rate_limit' => (int) env('MAILBOX_RESEND_RATE_LIMIT', 5), + ], + ], ]; diff --git a/docs/drivers/drivers.md b/docs/drivers/drivers.md index 1136e5f..20770cd 100644 --- a/docs/drivers/drivers.md +++ b/docs/drivers/drivers.md @@ -66,6 +66,65 @@ Next you will need to configure MailCare, to send incoming emails to your applic See ["MailCare"](https://mailcare.io) for more information. +## Resend + +Configure Resend to send the `email.received` event to: + +```text +https://your-app.example/laravel-mailbox/resend +``` + +Then configure the driver: + +```dotenv +MAILBOX_DRIVER=resend +MAILBOX_RESEND_API_KEY=re_xxxxxxxxx +MAILBOX_RESEND_WEBHOOK_SECRET=whsec_xxxxxxxxx +MAILBOX_RESEND_QUEUE_CONNECTION=sync +MAILBOX_RESEND_RATE_LIMIT=5 +``` + +When upgrading an application with a previously published `config/mailbox.php`, +the package fills in missing Resend service keys without replacing explicit +Resend values. Rebuild Laravel's cached configuration after the upgrade so the +new environment variables are captured. + +The Resend API key and the endpoint-specific `whsec_` webhook signing secret +are different credentials. The incoming webhook contains metadata rather than +the complete message. Laravel Mailbox uses the signed `email_id` to call +Resend's Receiving API, then downloads the raw MIME so original headers, +bodies, and attachments remain intact. + +### Queue processing + +The `sync` connection is the default and does not need a queue worker. API, +download, MIME parsing, and mailbox exceptions return HTTP 5xx so Resend can +retry the webhook. + +For high volume, set `MAILBOX_RESEND_QUEUE_CONNECTION` to `redis`, `database`, +`sqs`, or another configured Laravel queue connection and run queue workers. A +successful enqueue returns HTTP 200; later processing failures are retried by +Laravel. + +Resend's default API limit is five requests per second per team, so +`MAILBOX_RESEND_RATE_LIMIT` defaults to `5`. Lower it when other applications +share the team's allowance. Only raise it after Resend has approved a higher +limit for the team. + +The job timeout is 180 seconds. For Redis, database, and other connections with +a `retry_after` setting, configure `retry_after` above 180 seconds. For SQS, +set the queue's Default Visibility Timeout above 180 seconds instead. Keep the +worker's `--timeout` below the applicable retry or visibility window. + +Per-second throttling uses Laravel's cache. Workers on multiple nodes must use +the same shared cache store, and that store must support atomic increments, +such as Redis. Workers must also have enough memory for the complete MIME +message, including attachments. + +Webhook and queue delivery are at least once. Make mailbox handlers with side +effects idempotent, preferably using the raw email's stable `Message-Id` as the +business deduplication key. + ## Local development / log driver When working locally, you might not want to use real incoming emails while testing your application. Out of the box, this package supports Laravel's "log" mail driver for incoming emails. diff --git a/src/Clients/ResendClient.php b/src/Clients/ResendClient.php new file mode 100644 index 0000000..bc549ca --- /dev/null +++ b/src/Clients/ResendClient.php @@ -0,0 +1,78 @@ +withToken($apiKey) + ->withHeaders(['User-Agent' => static::USER_AGENT]) + ->withOptions(['allow_redirects' => false]) + ->connectTimeout(5) + ->timeout(15) + ->get(static::API_URL.'/'.rawurlencode($emailId)) + ->throw(); + + if (! $response->successful()) { + throw new RuntimeException('Unable to retrieve the Resend email.'); + } + + $downloadUrl = $response->json('raw.download_url'); + + if (! $this->isHttpsUrl($downloadUrl)) { + throw new UnexpectedValueException('Resend did not return a valid raw email download URL.'); + } + + return $this->download($downloadUrl); + } + + protected function download(string $url): string + { + try { + $response = Http::withOptions(['allow_redirects' => false]) + ->connectTimeout(5) + ->timeout(120) + ->get($url); + + if (! $response->successful() || $response->body() === '') { + throw new RuntimeException; + } + + return $response->body(); + } catch (Throwable) { + throw new RuntimeException('Unable to download the raw Resend email.'); + } + } + + protected function isHttpsUrl($url): bool + { + if (! is_string($url) || filter_var($url, FILTER_VALIDATE_URL) === false) { + return false; + } + + $parts = parse_url($url); + + return is_array($parts) + && ($parts['scheme'] ?? null) === 'https' + && ! empty($parts['host']) + && ! isset($parts['user']) + && ! isset($parts['pass']); + } +} diff --git a/src/Drivers/Resend.php b/src/Drivers/Resend.php new file mode 100644 index 0000000..f0d311c --- /dev/null +++ b/src/Drivers/Resend.php @@ -0,0 +1,16 @@ +group(function () { + Route::post('/resend', ResendController::class); + }); + } +} diff --git a/src/Http/Controllers/ResendController.php b/src/Http/Controllers/ResendController.php new file mode 100644 index 0000000..52d3c3e --- /dev/null +++ b/src/Http/Controllers/ResendController.php @@ -0,0 +1,40 @@ +eventType() !== 'email.received') { + return response('', 200); + } + + $connection = config('mailbox.services.resend.queue_connection', 'sync'); + $rateLimit = (int) config('mailbox.services.resend.rate_limit', 5); + $apiKey = config('mailbox.services.resend.api_key'); + + if (! is_string($connection) || trim($connection) === '') { + throw new LogicException('Resend queue connection is not configured.'); + } + + if ($rateLimit < 1) { + throw new LogicException('Resend API rate limit must be a positive integer.'); + } + + if (! is_string($apiKey) || trim($apiKey) === '') { + throw new LogicException('Resend API key is not configured.'); + } + + $job = (new ProcessResendEmail($request->emailId())) + ->onConnection($connection); + + dispatch($job); + + return response('', 200); + } +} diff --git a/src/Http/Requests/ResendRequest.php b/src/Http/Requests/ResendRequest.php new file mode 100644 index 0000000..ee5aa7f --- /dev/null +++ b/src/Http/Requests/ResendRequest.php @@ -0,0 +1,82 @@ +isValidSecret($secret)) { + throw new LogicException('Resend webhook secret is not configured correctly.'); + } + + $signed = $signature->verify( + $this->getContent(), + $this->header('svix-id'), + $this->header('svix-timestamp'), + $this->header('svix-signature'), + $secret + ); + + abort_unless($signed, 401, 'Invalid Resend signature or timestamp.'); + + try { + $payload = json_decode($this->getContent(), true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException) { + throw new HttpResponseException(response()->json([ + 'message' => 'Invalid Resend webhook payload.', + ], 400)); + } + + if (! is_array($payload)) { + throw new HttpResponseException(response()->json([ + 'message' => 'Invalid Resend webhook payload.', + ], 400)); + } + + $this->payload = $payload; + } + + public function validationData() + { + return $this->payload; + } + + public function rules() + { + return [ + 'type' => ['required', 'string'], + 'data' => ['present', 'array'], + 'data.email_id' => ['required_if:type,email.received', 'string', 'min:1'], + ]; + } + + protected function failedValidation(Validator $validator) + { + throw new HttpResponseException(response()->json([ + 'message' => 'Invalid Resend webhook payload.', + ], 400)); + } + + public function eventType(): string + { + return $this->validated('type'); + } + + public function emailId(): ?string + { + return $this->validated('data.email_id'); + } +} diff --git a/src/Jobs/ProcessResendEmail.php b/src/Jobs/ProcessResendEmail.php new file mode 100644 index 0000000..41eab53 --- /dev/null +++ b/src/Jobs/ProcessResendEmail.php @@ -0,0 +1,62 @@ +emailId = $emailId; + } + + public function retryUntil(): DateTimeInterface + { + return now()->addDay(); + } + + public function backoff(): array + { + return [5, 60, 300, 1800, 7200, 18000, 36000]; + } + + public function middleware(): array + { + return $this->usesSynchronousQueue() ? [] : [new ResendRateLimited]; + } + + public function usesSynchronousQueue(): bool + { + $connection = $this->connection ?: config('queue.default'); + $connections = config('queue.connections', []); + + return is_string($connection) + && is_array($connections) + && isset($connections[$connection]) + && is_array($connections[$connection]) + && ($connections[$connection]['driver'] ?? null) === 'sync'; + } + + public function handle(ResendClient $client): void + { + /** @var class-string $modelClass */ + $modelClass = config('mailbox.model'); + $email = $modelClass::fromMessage($client->rawEmail($this->emailId)); + + Mailbox::callMailboxes($email); + } +} diff --git a/src/MailboxManager.php b/src/MailboxManager.php index afd4901..ddc7d11 100644 --- a/src/MailboxManager.php +++ b/src/MailboxManager.php @@ -6,6 +6,7 @@ use BeyondCode\Mailbox\Drivers\MailCare; use BeyondCode\Mailbox\Drivers\Mailgun; use BeyondCode\Mailbox\Drivers\Postmark; +use BeyondCode\Mailbox\Drivers\Resend; use BeyondCode\Mailbox\Drivers\SendGrid; use Illuminate\Support\Manager; @@ -41,6 +42,11 @@ public function createPostmarkDriver() return new Postmark; } + public function createResendDriver() + { + return new Resend; + } + public function getDefaultDriver() { return $this->container['config']['mailbox.driver']; diff --git a/src/MailboxServiceProvider.php b/src/MailboxServiceProvider.php index 1d1363d..c222331 100644 --- a/src/MailboxServiceProvider.php +++ b/src/MailboxServiceProvider.php @@ -5,6 +5,7 @@ use BeyondCode\Mailbox\Facades\Mailbox; use BeyondCode\Mailbox\Http\Middleware\MailboxBasicAuthentication; use BeyondCode\Mailbox\Routing\Router; +use Illuminate\Contracts\Foundation\CachesConfiguration; use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider; @@ -39,7 +40,10 @@ public function boot() */ public function register() { - $this->mergeConfigFrom(__DIR__.'/../config/mailbox.php', 'mailbox'); + $configPath = __DIR__.'/../config/mailbox.php'; + + $this->mergeConfigFrom($configPath, 'mailbox'); + $this->mergeResendConfigFrom($configPath); $this->app->singleton('mailbox', function () { return new Router($this->app); @@ -50,6 +54,29 @@ public function register() }); } + protected function mergeResendConfigFrom(string $path) + { + if ($this->app instanceof CachesConfiguration && $this->app->configurationIsCached()) { + return; + } + + $config = $this->app->make('config'); + $defaults = require $path; + $services = $config->get('mailbox.services', []); + + if (! is_array($services) || + (array_key_exists('resend', $services) && ! is_array($services['resend']))) { + return; + } + + $services['resend'] = array_merge( + $defaults['services']['resend'], + $services['resend'] ?? [] + ); + + $config->set('mailbox.services', $services); + } + protected function registerDriver() { Mailbox::mailbox()->register(); diff --git a/src/Queue/Middleware/ResendRateLimited.php b/src/Queue/Middleware/ResendRateLimited.php new file mode 100644 index 0000000..0a0c47a --- /dev/null +++ b/src/Queue/Middleware/ResendRateLimited.php @@ -0,0 +1,30 @@ +hit(static::KEY, 1); + + if ($reservations > $maxAttempts) { + return $job->release($limiter->availableIn(static::KEY) + 1); + } + + return $next($job); + } +} diff --git a/src/Support/ResendWebhookSignature.php b/src/Support/ResendWebhookSignature.php new file mode 100644 index 0000000..c70b6e1 --- /dev/null +++ b/src/Support/ResendWebhookSignature.php @@ -0,0 +1,69 @@ +decodeSecret($secret); + $timestamp = filter_var($timestamp, FILTER_VALIDATE_INT); + + if ($decodedSecret === null || $timestamp === false) { + return false; + } + + $now = Carbon::now()->timestamp; + + if ($timestamp < $now - static::TOLERANCE || $timestamp > $now + static::TOLERANCE) { + return false; + } + + $expected = base64_encode(hash_hmac( + 'sha256', + $id.'.'.$timestamp.'.'.$payload, + $decodedSecret, + true + )); + + foreach (preg_split('/\s+/', trim($signatures)) as $signature) { + [$version, $value] = array_pad(explode(',', $signature, 2), 2, null); + + if ($version === 'v1' && is_string($value) && hash_equals($expected, $value)) { + return true; + } + } + + return false; + } + + public function isValidSecret(?string $secret): bool + { + return $this->decodeSecret($secret) !== null; + } + + protected function decodeSecret(?string $secret): ?string + { + if (! is_string($secret) || ! str_starts_with($secret, static::PREFIX)) { + return null; + } + + $decoded = base64_decode(substr($secret, strlen(static::PREFIX)), true); + + return $decoded === false || $decoded === '' ? null : $decoded; + } +} diff --git a/tests/Clients/ResendClientTest.php b/tests/Clients/ResendClientTest.php new file mode 100644 index 0000000..193aefb --- /dev/null +++ b/tests/Clients/ResendClientTest.php @@ -0,0 +1,160 @@ + 're_test']); + Http::preventStrayRequests(); + } + + #[Test] + public function it_retrieves_raw_email_without_forwarding_the_api_key() + { + $downloadUrl = 'https://inbound-cdn.resend.com/raw/email?signature=secret'; + $mime = "From: sender@example.com\r\nTo: inbox@example.com\r\n\r\nHello"; + + Http::fake([ + ResendClient::API_URL.'/email%2F123' => Http::response([ + 'raw' => ['download_url' => $downloadUrl], + ]), + $downloadUrl => Http::response($mime), + ]); + + $this->assertSame($mime, (new ResendClient)->rawEmail('email/123')); + + Http::assertSent(function (Request $request) { + return $request->url() === ResendClient::API_URL.'/email%2F123' + && $request->hasHeader('Authorization', 'Bearer re_test') + && $request->hasHeader('User-Agent', ResendClient::USER_AGENT); + }); + + Http::assertSent(function (Request $request) use ($downloadUrl) { + return $request->url() === $downloadUrl + && ! $request->hasHeader('Authorization'); + }); + } + + #[Test] + public function it_requires_an_api_key() + { + config(['mailbox.services.resend.api_key' => null]); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Resend API key is not configured.'); + + (new ResendClient)->rawEmail('email_123'); + } + + #[Test] + #[DataProvider('metadataFailureStatuses')] + public function it_throws_for_metadata_failures_without_exposing_the_api_key(int $status) + { + Http::fake([ + ResendClient::API_URL.'/email_123' => Http::response('', $status), + ]); + + $exception = null; + + try { + (new ResendClient)->rawEmail('email_123'); + } catch (Throwable $caught) { + $exception = $caught; + } + + $this->assertNotNull($exception); + $this->assertStringNotContainsString('re_test', (string) $exception); + Http::assertSentCount(1); + } + + public static function metadataFailureStatuses(): array + { + return [ + 'redirect' => [302], + 'unauthorized' => [401], + 'not found' => [404], + 'rate limited' => [429], + 'server error' => [500], + ]; + } + + #[Test] + #[DataProvider('invalidDownloadUrls')] + public function it_rejects_invalid_raw_download_urls($downloadUrl) + { + Http::fake([ + ResendClient::API_URL.'/email_123' => Http::response([ + 'raw' => ['download_url' => $downloadUrl], + ]), + ]); + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Resend did not return a valid raw email download URL.'); + + (new ResendClient)->rawEmail('email_123'); + } + + public static function invalidDownloadUrls(): array + { + return [ + 'missing' => [null], + 'empty' => [''], + 'malformed' => ['not a url'], + 'http' => ['http://example.com/raw'], + 'credentials' => ['https://user:pass@example.com/raw'], + ]; + } + + #[Test] + #[DataProvider('rawDownloadFailures')] + public function it_sanitizes_raw_download_failures(int $status, string $body) + { + $downloadUrl = 'https://inbound-cdn.resend.com/raw/email?signature=secret'; + + Http::fake([ + ResendClient::API_URL.'/email_123' => Http::response([ + 'raw' => ['download_url' => $downloadUrl], + ]), + $downloadUrl => Http::response( + $body, + $status, + $status === 302 ? ['Location' => 'https://example.com/redirected'] : [] + ), + ]); + + try { + (new ResendClient)->rawEmail('email_123'); + $this->fail('Expected raw download failure.'); + } catch (RuntimeException $exception) { + $this->assertSame('Unable to download the raw Resend email.', $exception->getMessage()); + $this->assertNull($exception->getPrevious()); + $this->assertStringNotContainsString('signature=secret', (string) $exception); + } + + Http::assertSentCount(2); + } + + public static function rawDownloadFailures(): array + { + return [ + 'empty success' => [200, ''], + 'redirect' => [302, 'redirect'], + 'server error' => [500, 'download failed'], + ]; + } +} diff --git a/tests/Concerns/SignsResendWebhooks.php b/tests/Concerns/SignsResendWebhooks.php new file mode 100644 index 0000000..566a1f3 --- /dev/null +++ b/tests/Concerns/SignsResendWebhooks.php @@ -0,0 +1,39 @@ +timestamp; + $secret = 'test-secret'; + + if ($configureSecret) { + config([ + 'mailbox.services.resend.webhook_secret' => 'whsec_'.base64_encode($secret), + ]); + } + + $signature = $signature ?? base64_encode(hash_hmac( + 'sha256', + $webhookId.'.'.$timestamp.'.'.$payload, + $secret, + true + )); + + return $this->call('POST', '/laravel-mailbox/resend', [], [], [], [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_SVIX_ID' => $webhookId, + 'HTTP_SVIX_TIMESTAMP' => (string) $timestamp, + 'HTTP_SVIX_SIGNATURE' => 'v1,'.$signature, + ], $payload); + } +} diff --git a/tests/Controllers/ResendTest.php b/tests/Controllers/ResendTest.php new file mode 100644 index 0000000..dea4ea0 --- /dev/null +++ b/tests/Controllers/ResendTest.php @@ -0,0 +1,426 @@ + 'email.received', + 'data' => ['email_id' => 'email_123'], + ]); + + $this->postResendWebhook($payload, signature: 'invalid')->assertStatus(401); + + Bus::assertNothingDispatched(); + } + + #[Test] + public function it_rejects_missing_svix_headers_without_dispatching() + { + Bus::fake(); + config([ + 'mailbox.services.resend.webhook_secret' => 'whsec_'.base64_encode('test-secret'), + ]); + + $this->call( + 'POST', + '/laravel-mailbox/resend', + [], + [], + [], + ['CONTENT_TYPE' => 'application/json'], + '{"type":"email.received","data":{"email_id":"email_123"}}' + )->assertStatus(401); + + Bus::assertNothingDispatched(); + } + + #[Test] + public function it_rejects_stale_webhooks_without_dispatching() + { + Bus::fake(); + + $this->postResendWebhook( + '{"type":"email.received","data":{"email_id":"email_123"}}', + timestamp: now()->timestamp - 301 + )->assertStatus(401); + + Bus::assertNothingDispatched(); + } + + #[Test] + public function it_rejects_malformed_signed_json_without_dispatching() + { + Bus::fake(); + + $this->postResendWebhook('{"type":') + ->assertStatus(400); + + Bus::assertNothingDispatched(); + } + + #[Test] + public function it_does_not_allow_unsigned_request_parameters_to_repair_malformed_json() + { + Bus::fake(); + + $payload = '{"type":'; + $webhookId = 'msg_unsigned_query'; + $timestamp = now()->timestamp; + $secret = 'test-secret'; + config([ + 'mailbox.services.resend.webhook_secret' => 'whsec_'.base64_encode($secret), + ]); + $signature = base64_encode(hash_hmac( + 'sha256', + $webhookId.'.'.$timestamp.'.'.$payload, + $secret, + true + )); + + $this->call( + 'POST', + '/laravel-mailbox/resend?type=email.received&data%5Bemail_id%5D=email_unsigned', + [ + 'type' => 'email.received', + 'data' => ['email_id' => 'email_unsigned'], + ], + [], + [], + [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_SVIX_ID' => $webhookId, + 'HTTP_SVIX_TIMESTAMP' => (string) $timestamp, + 'HTTP_SVIX_SIGNATURE' => 'v1,'.$signature, + ], + $payload + )->assertStatus(400); + + Bus::assertNothingDispatched(); + } + + #[Test] + public function it_rejects_invalid_received_payloads() + { + Bus::fake(); + + $this->postResendWebhook('{"type":"email.received","data":{}}') + ->assertStatus(400); + + Bus::assertNothingDispatched(); + } + + #[Test] + public function it_acknowledges_signed_unrelated_events() + { + Bus::fake(); + + $this->postResendWebhook('{"type":"email.delivered","data":{}}') + ->assertOk(); + + Bus::assertNothingDispatched(); + } + + #[Test] + public function it_dispatches_received_emails_to_the_configured_connection() + { + Bus::fake(); + config(['mailbox.services.resend.queue_connection' => 'redis']); + + $this->postResendWebhook( + '{"type":"email.received","data":{"email_id":"email_123"}}', + 'msg_123' + )->assertOk(); + + Bus::assertDispatched(ProcessResendEmail::class, function ($job) { + return $job->emailId === 'email_123' + && $job->connection === 'redis'; + }); + } + + #[Test] + public function it_dispatches_repeated_webhooks_for_business_level_idempotency() + { + Bus::fake(); + config(['mailbox.services.resend.queue_connection' => 'redis']); + $payload = '{"type":"email.received","data":{"email_id":"email_123"}}'; + + $this->postResendWebhook($payload, 'msg_repeated')->assertOk(); + $this->postResendWebhook($payload, 'msg_repeated')->assertOk(); + + Bus::assertDispatchedTimes(ProcessResendEmail::class, 2); + } + + #[Test] + #[DataProvider('invalidQueueConnections')] + public function it_rejects_invalid_queue_configuration($connection) + { + Bus::fake(); + config(['mailbox.services.resend.queue_connection' => $connection]); + + $this->postResendWebhook( + '{"type":"email.received","data":{"email_id":"email_123"}}' + )->assertStatus(500); + + Bus::assertNothingDispatched(); + } + + public static function invalidQueueConnections(): array + { + return [ + 'null' => [null], + 'empty string' => [''], + ]; + } + + #[Test] + #[DataProvider('invalidRateLimits')] + public function it_rejects_invalid_rate_limit_configuration($rateLimit) + { + Bus::fake(); + config(['mailbox.services.resend.rate_limit' => $rateLimit]); + + $this->postResendWebhook( + '{"type":"email.received","data":{"email_id":"email_123"}}' + )->assertStatus(500); + + Bus::assertNothingDispatched(); + } + + public static function invalidRateLimits(): array + { + return [ + 'zero' => [0], + 'negative' => [-1], + 'non-numeric' => ['invalid'], + ]; + } + + #[Test] + #[DataProvider('invalidWebhookSecrets')] + public function it_reports_invalid_webhook_secret_configuration_as_a_server_error($secret) + { + Bus::fake(); + config(['mailbox.services.resend.webhook_secret' => $secret]); + $payload = '{"type":"email.received","data":{"email_id":"email_123"}}'; + + $this->postResendWebhook( + $payload, + configureSecret: false + )->assertStatus(500); + + Bus::assertNothingDispatched(); + } + + public static function invalidWebhookSecrets(): array + { + return [ + 'missing' => [null], + 'blank' => [' '], + 'missing prefix' => ['test-secret'], + 'bad Base64' => ['whsec_%%%'], + 'empty decoded value' => ['whsec_'], + ]; + } + + #[Test] + public function it_reports_a_well_formed_nonmatching_webhook_secret_as_unauthorized() + { + Bus::fake(); + config([ + 'mailbox.services.resend.webhook_secret' => 'whsec_'.base64_encode('other-secret'), + ]); + + $this->postResendWebhook( + '{"type":"email.received","data":{"email_id":"email_123"}}', + configureSecret: false + )->assertStatus(401); + + Bus::assertNothingDispatched(); + } + + #[Test] + #[DataProvider('missingApiKeys')] + public function it_rejects_missing_api_keys_before_acknowledging_an_async_webhook($apiKey) + { + Bus::fake(); + config([ + 'mailbox.services.resend.api_key' => $apiKey, + 'mailbox.services.resend.queue_connection' => 'redis', + ]); + + $this->postResendWebhook( + '{"type":"email.received","data":{"email_id":"email_123"}}' + )->assertStatus(500); + + Bus::assertNothingDispatched(); + } + + public static function missingApiKeys(): array + { + return [ + 'missing' => [null], + 'empty' => [''], + 'blank' => [' '], + ]; + } + + #[Test] + public function it_reports_queue_dispatch_failures_as_server_errors() + { + config([ + 'mailbox.services.resend.queue_connection' => 'resend-async', + 'queue.connections.resend-async' => ['driver' => 'null'], + ]); + + $original = $this->app->make(Dispatcher::class); + $dispatcher = Mockery::mock(Dispatcher::class); + $dispatcher->shouldReceive('dispatch') + ->once() + ->andThrow(new RuntimeException('queue unavailable')); + + $this->app->instance(Dispatcher::class, $dispatcher); + + try { + $this->postResendWebhook( + '{"type":"email.received","data":{"email_id":"email_123"}}' + )->assertStatus(500); + } finally { + $this->app->instance(Dispatcher::class, $original); + } + } + + #[Test] + public function it_processes_raw_mime_and_attachments_on_the_sync_connection() + { + $downloadUrl = 'https://inbound-cdn.resend.com/raw/email?signature=test'; + $mime = file_get_contents(__DIR__.'/../Fixtures/resend-email.eml'); + $handled = false; + + config([ + 'mailbox.services.resend.api_key' => 're_test', + 'mailbox.services.resend.queue_connection' => 'sync', + ]); + + Http::fake([ + ResendClient::API_URL.'/email_123' => Http::response([ + 'raw' => ['download_url' => $downloadUrl], + ]), + $downloadUrl => Http::response($mime), + ]); + + Mailbox::to('inbox@example.com', function (InboundEmail $email) use (&$handled) { + $handled = true; + + $this->assertSame('Inbound from Resend', $email->subject()); + $this->assertSame('Hello from Resend.', trim($email->text())); + $this->assertCount(1, $email->attachments()); + }); + + $this->postResendWebhook( + '{"type":"email.received","data":{"email_id":"email_123"}}', + 'msg_123' + )->assertOk(); + + $this->assertTrue($handled); + Http::assertSentCount(2); + } + + #[Test] + public function it_returns_a_server_error_without_calling_mailboxes_when_sync_retrieval_fails() + { + $handled = false; + + config([ + 'mailbox.services.resend.api_key' => 're_test', + 'mailbox.services.resend.queue_connection' => 'sync', + ]); + + Http::fake([ + ResendClient::API_URL.'/email_123' => Http::response('', 500), + ]); + + Mailbox::to('inbox@example.com', function () use (&$handled) { + $handled = true; + }); + + $this->postResendWebhook( + '{"type":"email.received","data":{"email_id":"email_123"}}', + 'msg_failure' + )->assertStatus(500); + + $this->assertFalse($handled); + Http::assertSentCount(1); + } + + #[Test] + public function it_honors_the_configured_model() + { + $downloadUrl = 'https://inbound-cdn.resend.com/raw/email?signature=test'; + + config([ + 'mailbox.model' => TestResendControllerInboundEmail::class, + 'mailbox.services.resend.api_key' => 're_test', + 'mailbox.services.resend.queue_connection' => 'sync', + ]); + + Http::fake([ + ResendClient::API_URL.'/email_123' => Http::response([ + 'raw' => ['download_url' => $downloadUrl], + ]), + $downloadUrl => Http::response('raw mime'), + ]); + + Mailbox::shouldReceive('callMailboxes') + ->once() + ->with(Mockery::type(TestResendControllerInboundEmail::class)); + + $this->postResendWebhook( + '{"type":"email.received","data":{"email_id":"email_123"}}', + 'msg_model' + )->assertOk(); + + $this->assertSame('raw mime', TestResendControllerInboundEmail::$rawMessage); + } +} + +class TestResendControllerInboundEmail extends InboundEmail +{ + public static ?string $rawMessage = null; + + public static function fromMessage($message) + { + static::$rawMessage = $message; + + return parent::fromMessage($message); + } +} diff --git a/tests/Drivers/ResendTest.php b/tests/Drivers/ResendTest.php new file mode 100644 index 0000000..a66ae05 --- /dev/null +++ b/tests/Drivers/ResendTest.php @@ -0,0 +1,55 @@ +assertInstanceOf( + Resend::class, + $this->app->make(MailboxManager::class)->driver('resend') + ); + } + + #[Test] + public function it_registers_the_resend_webhook_route() + { + $route = collect($this->app['router']->getRoutes())->first(function ($route) { + return $route->uri() === 'laravel-mailbox/resend'; + }); + + $this->assertNotNull($route); + $this->assertContains('POST', $route->methods()); + $this->assertSame(ResendController::class.'@__invoke', $route->getAction('uses')); + $this->assertNotContains('laravel-mailbox', $route->gatherMiddleware()); + } + + #[Test] + public function it_uses_the_configured_mailbox_path() + { + config(['mailbox.path' => 'custom-mailbox']); + + (new Resend)->register(); + + $route = collect($this->app['router']->getRoutes())->first(function ($route) { + return $route->uri() === 'custom-mailbox/resend'; + }); + + $this->assertNotNull($route); + } +} diff --git a/tests/Fixtures/resend-email.eml b/tests/Fixtures/resend-email.eml new file mode 100644 index 0000000..a596241 --- /dev/null +++ b/tests/Fixtures/resend-email.eml @@ -0,0 +1,20 @@ +From: Resend Sender +To: inbox@example.com +Subject: Inbound from Resend +Message-ID: +Date: Tue, 28 Jul 2026 10:00:00 +0800 +MIME-Version: 1.0 +Content-Type: multipart/mixed; boundary="resend-boundary" + +--resend-boundary +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 7bit + +Hello from Resend. +--resend-boundary +Content-Type: text/plain; name="note.txt" +Content-Disposition: attachment; filename="note.txt" +Content-Transfer-Encoding: base64 + +YXR0YWNobWVudCBjb250ZW50 +--resend-boundary-- diff --git a/tests/Jobs/ProcessResendEmailTest.php b/tests/Jobs/ProcessResendEmailTest.php new file mode 100644 index 0000000..06c9dfc --- /dev/null +++ b/tests/Jobs/ProcessResendEmailTest.php @@ -0,0 +1,102 @@ +onConnection('sync'); + $queuedJob = (new ProcessResendEmail('email_123'))->onConnection('redis'); + + $this->assertSame([5, 60, 300, 1800, 7200, 18000, 36000], $queuedJob->backoff()); + $this->assertSame(now()->addDay()->timestamp, $queuedJob->retryUntil()->timestamp); + $this->assertSame(180, $queuedJob->timeout); + $this->assertSame([], $syncJob->middleware()); + $this->assertInstanceOf(ResendRateLimited::class, $queuedJob->middleware()[0]); + } + + #[Test] + public function it_skips_rate_limiting_for_a_named_sync_queue_connection() + { + config([ + 'queue.connections.resend-inline' => ['driver' => 'sync'], + ]); + + $job = (new ProcessResendEmail('email_123')) + ->onConnection('resend-inline'); + + $this->assertSame([], $job->middleware()); + } + + #[Test] + public function it_builds_the_configured_model_and_calls_mailboxes() + { + config(['mailbox.model' => TestResendInboundEmail::class]); + + $client = Mockery::mock(ResendClient::class); + $client->shouldReceive('rawEmail')->once()->with('email_123')->andReturn('raw mime'); + + Mailbox::shouldReceive('callMailboxes') + ->once() + ->with(Mockery::type(TestResendInboundEmail::class)); + + (new ProcessResendEmail('email_123'))->handle($client); + + $this->assertSame('raw mime', TestResendInboundEmail::$rawMessage); + } + + #[Test] + public function it_does_not_call_mailboxes_when_retrieval_fails() + { + $expected = new RuntimeException('api failed'); + $client = Mockery::mock(ResendClient::class); + $client->shouldReceive('rawEmail') + ->once() + ->with('email_123') + ->andThrow($expected); + + Mailbox::shouldReceive('callMailboxes')->never(); + + try { + (new ProcessResendEmail('email_123'))->handle($client); + $this->fail('Expected the client exception.'); + } catch (RuntimeException $exception) { + $this->assertSame($expected, $exception); + } + } +} + +class TestResendInboundEmail extends InboundEmail +{ + public static ?string $rawMessage = null; + + public static function fromMessage($message) + { + static::$rawMessage = $message; + + return parent::fromMessage($message); + } +} diff --git a/tests/MailboxServiceProviderTest.php b/tests/MailboxServiceProviderTest.php new file mode 100644 index 0000000..f05e0a6 --- /dev/null +++ b/tests/MailboxServiceProviderTest.php @@ -0,0 +1,72 @@ +registerProviderWith([ + 'driver' => 'log', + 'services' => [ + 'mailgun' => ['key' => 'published-mailgun-key'], + ], + ]); + + $this->assertSame( + ['key' => 'published-mailgun-key'], + $config->get('mailbox.services.mailgun') + ); + + $resend = $config->get('mailbox.services.resend'); + + $this->assertIsArray($resend); + $this->assertSame( + ['api_key', 'webhook_secret', 'queue_connection', 'rate_limit'], + array_keys($resend) + ); + } + + #[Test] + public function it_preserves_explicit_resend_values_while_filling_missing_defaults() + { + $config = $this->registerProviderWith([ + 'driver' => 'log', + 'services' => [ + 'resend' => [ + 'api_key' => 'published-api-key', + 'queue_connection' => 'published-sync', + 'rate_limit' => 2, + ], + ], + ]); + + $this->assertSame( + 'published-api-key', + $config->get('mailbox.services.resend.api_key') + ); + $this->assertSame( + 'published-sync', + $config->get('mailbox.services.resend.queue_connection') + ); + $this->assertSame(2, $config->get('mailbox.services.resend.rate_limit')); + $this->assertTrue($config->has('mailbox.services.resend.webhook_secret')); + } + + private function registerProviderWith(array $mailboxConfig): Repository + { + $container = new Container; + $config = new Repository(['mailbox' => $mailboxConfig]); + $container->instance('config', $config); + + (new MailboxServiceProvider($container))->register(); + + return $config; + } +} diff --git a/tests/Queue/Middleware/ResendRateLimitedTest.php b/tests/Queue/Middleware/ResendRateLimitedTest.php new file mode 100644 index 0000000..e892950 --- /dev/null +++ b/tests/Queue/Middleware/ResendRateLimitedTest.php @@ -0,0 +1,133 @@ +app->make(RateLimiter::class)->clear(ResendRateLimited::KEY); + + parent::tearDown(); + } + + #[Test] + public function it_releases_a_real_job_through_its_queue_interactions() + { + config(['mailbox.services.resend.rate_limit' => 1]); + + $first = $this->queuedJob('msg_first'); + $second = $this->queuedJob('msg_second'); + + $handled = 0; + $next = function () use (&$handled) { + $handled++; + }; + + $middleware = new ResendRateLimited; + $middleware->handle($first, $next); + $middleware->handle($second, $next); + + $this->assertSame(1, $handled); + $this->assertFalse($first->job->isReleased()); + $this->assertTrue($second->job->isReleased()); + $this->assertGreaterThanOrEqual(1, $second->job->releaseDelay); + $this->assertLessThanOrEqual(2, $second->job->releaseDelay); + } + + #[Test] + public function atomic_reservations_cannot_admit_more_than_the_configured_limit() + { + config(['mailbox.services.resend.rate_limit' => 1]); + + $limiter = new InterleavingResendRateLimiter($this->app['cache']->store()); + $this->app->instance(RateLimiter::class, $limiter); + + $first = $this->queuedJob('msg_first'); + $second = $this->queuedJob('msg_second'); + $handled = 0; + $next = function () use (&$handled) { + $handled++; + }; + + $middleware = new ResendRateLimited; + $middleware->handle($first, $next); + $middleware->handle($second, $next); + + $this->assertSame(2, $limiter->reservations); + $this->assertSame(1, $handled); + $this->assertFalse($first->job->isReleased()); + $this->assertTrue($second->job->isReleased()); + } + + #[Test] + public function it_rejects_a_non_positive_limit() + { + config(['mailbox.services.resend.rate_limit' => 0]); + + $this->expectException(LogicException::class); + + (new ResendRateLimited)->handle( + new ProcessResendEmail('email_123'), + fn () => null + ); + } + + private function queuedJob(string $emailId): ProcessResendEmail + { + $job = new ProcessResendEmail($emailId); + $job->setJob(new RecordingResendQueueJob( + $this->app, + '{}', + 'test', + 'default' + )); + + return $job; + } +} + +class RecordingResendQueueJob extends SyncJob +{ + public $releaseDelay; + + public function release($delay = 0) + { + $this->releaseDelay = $delay; + + parent::release($delay); + } +} + +class InterleavingResendRateLimiter extends RateLimiter +{ + public int $reservations = 0; + + public function tooManyAttempts($key, $maxAttempts) + { + return false; + } + + public function hit($key, $decaySeconds = 60) + { + return ++$this->reservations; + } + + public function increment($key, $decaySeconds = 60, $amount = 1) + { + return $this->reservations += $amount; + } + + public function availableIn($key) + { + return 0; + } +} diff --git a/tests/Support/ResendWebhookSignatureTest.php b/tests/Support/ResendWebhookSignatureTest.php new file mode 100644 index 0000000..b5c4ad1 --- /dev/null +++ b/tests/Support/ResendWebhookSignatureTest.php @@ -0,0 +1,169 @@ +assertTrue((new ResendWebhookSignature)->verify( + self::PAYLOAD, + 'msg_test', + self::TIMESTAMP, + 'v1,'.self::SIGNATURE, + self::SECRET + )); + } + + #[Test] + public function it_rejects_any_raw_body_change() + { + $this->assertFalse((new ResendWebhookSignature)->verify( + self::PAYLOAD.' ', + 'msg_test', + self::TIMESTAMP, + 'v1,'.self::SIGNATURE, + self::SECRET + )); + } + + #[Test] + public function it_rejects_json_that_was_parsed_and_reserialized() + { + $reserialized = json_encode( + json_decode(self::PAYLOAD, true), + JSON_PRETTY_PRINT + ); + + $this->assertFalse((new ResendWebhookSignature)->verify( + $reserialized, + 'msg_test', + self::TIMESTAMP, + 'v1,'.self::SIGNATURE, + self::SECRET + )); + } + + #[Test] + public function it_accepts_any_matching_v1_signature() + { + $header = 'v1,invalid v2,invalid v1,'.self::SIGNATURE; + + $this->assertTrue((new ResendWebhookSignature)->verify( + self::PAYLOAD, + 'msg_test', + self::TIMESTAMP, + $header, + self::SECRET + )); + } + + #[Test] + #[DataProvider('invalidInputs')] + public function it_rejects_invalid_signature_inputs( + ?string $id, + ?string $timestamp, + ?string $signature, + ?string $secret + ) { + $this->assertFalse((new ResendWebhookSignature)->verify( + self::PAYLOAD, + $id, + $timestamp, + $signature, + $secret + )); + } + + public static function invalidInputs(): array + { + return [ + 'missing id' => [null, self::TIMESTAMP, 'v1,'.self::SIGNATURE, self::SECRET], + 'missing timestamp' => ['msg_test', null, 'v1,'.self::SIGNATURE, self::SECRET], + 'missing signature' => ['msg_test', self::TIMESTAMP, null, self::SECRET], + 'missing secret' => ['msg_test', self::TIMESTAMP, 'v1,'.self::SIGNATURE, null], + 'wrong prefix' => ['msg_test', self::TIMESTAMP, 'v1,'.self::SIGNATURE, 'secret'], + 'bad base64' => ['msg_test', self::TIMESTAMP, 'v1,'.self::SIGNATURE, 'whsec_%%%'], + 'empty decoded secret' => ['msg_test', self::TIMESTAMP, 'v1,'.self::SIGNATURE, 'whsec_'], + 'wrong secret' => ['msg_test', self::TIMESTAMP, 'v1,'.self::SIGNATURE, 'whsec_b3RoZXItc2VjcmV0'], + 'malformed timestamp' => ['msg_test', 'not-an-integer', 'v1,'.self::SIGNATURE, self::SECRET], + 'wrong signature' => ['msg_test', self::TIMESTAMP, 'v1,invalid', self::SECRET], + 'unsupported version' => ['msg_test', self::TIMESTAMP, 'v2,'.self::SIGNATURE, self::SECRET], + ]; + } + + #[Test] + public function it_enforces_both_timestamp_boundaries() + { + $verifier = new ResendWebhookSignature; + $pastBoundary = (string) (now()->timestamp - 300); + $futureBoundary = (string) (now()->timestamp + 300); + $tooOld = (string) (now()->timestamp - 301); + $tooFarInFuture = (string) (now()->timestamp + 301); + + $this->assertTrue($verifier->verify( + self::PAYLOAD, + 'msg_test', + $pastBoundary, + 'v1,'.$this->signature($pastBoundary), + self::SECRET + )); + $this->assertTrue($verifier->verify( + self::PAYLOAD, + 'msg_test', + $futureBoundary, + 'v1,'.$this->signature($futureBoundary), + self::SECRET + )); + $this->assertFalse($verifier->verify( + self::PAYLOAD, + 'msg_test', + $tooOld, + 'v1,'.$this->signature($tooOld), + self::SECRET + )); + $this->assertFalse($verifier->verify( + self::PAYLOAD, + 'msg_test', + $tooFarInFuture, + 'v1,'.$this->signature($tooFarInFuture), + self::SECRET + )); + } + + private function signature(string $timestamp): string + { + return base64_encode(hash_hmac( + 'sha256', + 'msg_test.'.$timestamp.'.'.self::PAYLOAD, + 'test-secret', + true + )); + } +}