From a0611ae97e1f8459c5722bb38c24b4c07cb19407 Mon Sep 17 00:00:00 2001 From: Toms Sokolovs Date: Mon, 5 Oct 2026 16:45:33 +0300 Subject: [PATCH] Add Mailtrap inbound email support --- README.md | 2 + composer.json | 5 + config/mailbox.php | 11 +- docs/drivers/drivers.md | 6 + docs/drivers/mailtrap.md | 149 +++++ src/Drivers/Mailtrap.php | 16 + src/Http/Controllers/MailtrapController.php | 147 +++++ src/Jobs/ProcessMailtrapEmail.php | 91 ++++ src/MailboxManager.php | 6 + src/MailboxServiceProvider.php | 19 + src/Mailtrap/Client.php | 72 +++ tests/Controllers/MailtrapCustomRouteTest.php | 19 + tests/Controllers/MailtrapQueueTest.php | 147 +++++ tests/Controllers/MailtrapTest.php | 509 ++++++++++++++++++ tests/Jobs/ProcessMailtrapEmailTest.php | 435 +++++++++++++++ tests/Mailtrap/ClientTest.php | 217 ++++++++ tests/Mailtrap/ConfigurationTest.php | 118 ++++ tests/Support/MailtrapMessage.php | 68 +++ tests/Support/MailtrapTestCase.php | 73 +++ 19 files changed, 2109 insertions(+), 1 deletion(-) create mode 100644 docs/drivers/mailtrap.md create mode 100644 src/Drivers/Mailtrap.php create mode 100644 src/Http/Controllers/MailtrapController.php create mode 100644 src/Jobs/ProcessMailtrapEmail.php create mode 100644 src/Mailtrap/Client.php create mode 100644 tests/Controllers/MailtrapCustomRouteTest.php create mode 100644 tests/Controllers/MailtrapQueueTest.php create mode 100644 tests/Controllers/MailtrapTest.php create mode 100644 tests/Jobs/ProcessMailtrapEmailTest.php create mode 100644 tests/Mailtrap/ClientTest.php create mode 100644 tests/Mailtrap/ConfigurationTest.php create mode 100644 tests/Support/MailtrapMessage.php create mode 100644 tests/Support/MailtrapTestCase.php diff --git a/README.md b/README.md index 0c29d89..38b0ba4 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,8 @@ composer require beyondcode/laravel-mailbox Take a look at the [official documentation](https://docs.beyondco.de/laravel-mailbox). +For Mailtrap Inbound Email, follow the [Mailtrap driver setup](docs/drivers/mailtrap.md). + ## Catch, test and debug application mails with Laravel Herd Laravel Herd provides an integrated local email service, streamlining the process of testing and debugging application emails. diff --git a/composer.json b/composer.json index a904a75..b6bf03e 100644 --- a/composer.json +++ b/composer.json @@ -17,9 +17,14 @@ ], "require": { "php": "^8.1", + "guzzlehttp/guzzle": "^7.0", + "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..a04f073 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", "mailcare", "mailtrap" */ 'driver' => env('MAILBOX_DRIVER', 'log'), @@ -63,6 +63,15 @@ 'key' => env('MAILBOX_MAILGUN_KEY'), ], + 'mailtrap' => [ + 'api_token' => env('MAILBOX_MAILTRAP_API_TOKEN'), + 'signing_secret' => env('MAILBOX_MAILTRAP_SIGNING_SECRET'), + 'inbox_id' => env('MAILBOX_MAILTRAP_INBOX_ID'), + 'queue_connection' => env('MAILBOX_MAILTRAP_QUEUE_CONNECTION', 'sync'), + 'queue' => env('MAILBOX_MAILTRAP_QUEUE'), + 'cache_store' => env('MAILBOX_MAILTRAP_CACHE_STORE'), + ], + ], ]; diff --git a/docs/drivers/drivers.md b/docs/drivers/drivers.md index 1136e5f..93c7a24 100644 --- a/docs/drivers/drivers.md +++ b/docs/drivers/drivers.md @@ -66,6 +66,12 @@ Next you will need to configure MailCare, to send incoming emails to your applic See ["MailCare"](https://mailcare.io) for more information. +## Mailtrap + +The Mailtrap driver receives email through [Mailtrap Inbound Email](https://docs.mailtrap.io/inbound-email/receiving-emails). +It authenticates incoming notifications and downloads the original email, including attachments. +See the [Mailtrap setup instructions](mailtrap.md) for credentials, background processing, and retries. + ## 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/docs/drivers/mailtrap.md b/docs/drivers/mailtrap.md new file mode 100644 index 0000000..9da0ba5 --- /dev/null +++ b/docs/drivers/mailtrap.md @@ -0,0 +1,149 @@ +# Mailtrap + +The Mailtrap driver connects [Mailtrap Inbound Email](https://docs.mailtrap.io/inbound-email/receiving-emails) to your Laravel mailboxes. +A webhook is an HTTP notification from Mailtrap. +The driver authenticates each webhook, downloads the original email, and passes it to your mailbox handlers. + +## Setup + +Mailtrap sends message identifiers in its notifications. +The driver uses an API token to retrieve the complete email, including attachments. +The webhook signing secret authenticates the original notification. + +1. Create an inbound inbox in Mailtrap. +2. Create an API token with access to that inbox. +3. Create a webhook for the inbox with the JSON payload format. +4. Set the webhook URL to `https://YOUR-APPLICATION/laravel-mailbox/mailtrap`. +5. Copy the signing secret from the webhook details. +6. Add the following values to your application's `.env` file. + +```dotenv +MAILBOX_DRIVER=mailtrap +MAILBOX_MAILTRAP_API_TOKEN=your-api-token +MAILBOX_MAILTRAP_SIGNING_SECRET=your-webhook-signing-secret +MAILBOX_MAILTRAP_INBOX_ID=your-inbound-inbox-id +``` + +Replace each placeholder with your value. +If you change `mailbox.path`, use that prefix in the webhook URL. +The driver uses the `Mailtrap-Signature` header and does not use `MAILBOX_HTTP_USERNAME` or `MAILBOX_HTTP_PASSWORD`. +It supports the JSON `events` array format. +It does not accept JSON Lines. + +The inbox filter accepts one positive inbox ID without leading zeros. +If you omit `MAILBOX_MAILTRAP_INBOX_ID`, the driver accepts notifications for all inboxes available through the token. +The driver still requires a valid webhook signature. + +The configuration lives under `services.mailtrap` in `config/mailbox.php`. +Older published configuration files receive missing Mailtrap defaults automatically. +If your application caches its configuration, rebuild it after these changes: + +```bash +php artisan config:cache +``` + +If your application caches routes, rebuild them after you enable Mailtrap or change `mailbox.path`: + +```bash +php artisan route:cache +``` + +## Background processing + +The default `sync` connection processes messages during the HTTP request. +A queue worker processes messages in the background. +Use a background queue for production batches because message downloads and application handlers delay synchronous responses. +One job processes each accepted message. + +The supported background queue drivers are `database`, `redis`, `sqs`, and `beanstalkd`. +On Laravel versions with `failover`, each fallback must also use a supported persistent queue. +The driver rejects `deferred`, `background`, `null`, and custom queue drivers because it cannot establish persistent retries for those connections. +Run a worker for every underlying connection in a failover setup, including nested fallback connections. +Each worker must consume the configured Mailtrap queue. +The `queue:work failover` command consumes only the first connection. + +This example uses a configured `database` queue connection and a configured `redis` cache store. +Create the queue tables for your Laravel version before you start a worker. +See the [Laravel queue documentation](https://laravel.com/docs/queues) for the database prerequisites. + +1. Set `retry_after` to `240` for the `database` connection in `config/queue.php`. +2. Configure the `redis` store in `config/cache.php`. +3. Add these values to your application's `.env` file. + +```dotenv +MAILBOX_MAILTRAP_QUEUE_CONNECTION=database +MAILBOX_MAILTRAP_QUEUE=mailtrap +MAILBOX_MAILTRAP_CACHE_STORE=redis +``` + +If your application caches its configuration, rebuild it before you start the worker: + +```bash +php artisan config:cache +``` + +Start the worker on the host that runs your application: + +```bash +php artisan queue:work database --queue=mailtrap --timeout=120 +``` + +If you change configuration or deploy new code, restart the workers: + +```bash +php artisan queue:restart +``` + +The queue name defaults to the selected connection's queue. +The cache store defaults to the application's cache store. +The cache store must support atomic locks, which permit one worker to process a message at a time. +All web servers and workers must use the same persistent cache for duplicate protection. +The `array` cache store does not retain records between requests. + +Jobs allow forty attempts, with five minutes between failed attempts. +Each job has a 120-second timeout and a 180-second processing lock. +A worker timeout moves the job to failed-job storage. +For other queue connections, set the retry interval or visibility timeout above 180 seconds. +Keep mailbox handlers below the job timeout. +The synchronous connection does not enforce a worker timeout, so configure its host to stop requests before the lock expires. + +## Retries and repeated messages + +The driver records successful messages in the cache for 24 hours. +It identifies each message by its Mailtrap inbox ID and message ID. +It writes the record after the mailbox handlers and any email storage complete. +Changes to email retention do not change these completion records. + +If Mailtrap sends a completed message again, the driver skips its download and handlers. +If another worker holds its lock, a queued job waits thirty seconds before another attempt. +A synchronous request reports that case as a failure. +The driver attempts the other valid events in a batch even when one event fails. + +Duplicate protection depends on the cache and the lock lifetime. +A cache reset, an expired record, or a failure after an application action can cause that action to repeat. +For example, a handler can send a reply and then fail before the driver records completion. +Use a persistent application key to prevent repeated actions that affect users. +The driver does not guarantee that each application action runs only once. + +## Failures + +Mailtrap retries unsuccessful webhook deliveries. +After the driver accepts a background job, Laravel handles that job's retries. +Configure Laravel's failed-job storage and monitor it alongside the application log. +Use `php artisan queue:failed` to inspect failed jobs before you retry them. + +The webhook responses are: + +- `200`: All valid events completed or entered the queue. Unrelated events, other inboxes, and malformed individual events are skipped. +- `401`: The signature is missing or invalid. +- `422`: The request does not contain a JSON object with an `events` array. +- `503`: Required configuration is invalid, a queue submission failed, or synchronous processing failed. + +The driver logs a warning when it skips malformed events or invalid emails. +A missing raw download URL, an unsuccessful HTTP response, or a connection failure causes a failure that can be retried. +Each attempt retrieves a fresh download URL from Mailtrap. +The API token stays on the metadata request and does not go to the download host. + +Each HTTP request has a five-second connection timeout and a twenty-second total timeout. +Downloads require HTTPS, reject embedded credentials, and do not follow redirects. +See [Mailtrap inbound webhooks](https://docs.mailtrap.io/inbound-email/webhooks) for the signature and delivery rules. diff --git a/src/Drivers/Mailtrap.php b/src/Drivers/Mailtrap.php new file mode 100644 index 0000000..1839afd --- /dev/null +++ b/src/Drivers/Mailtrap.php @@ -0,0 +1,16 @@ +group(function () { + Route::post('/mailtrap', MailtrapController::class); + }); + } +} diff --git a/src/Http/Controllers/MailtrapController.php b/src/Http/Controllers/MailtrapController.php new file mode 100644 index 0000000..bc4a2f2 --- /dev/null +++ b/src/Http/Controllers/MailtrapController.php @@ -0,0 +1,147 @@ +getContent(); + $signature = $request->header('Mailtrap-Signature', ''); + + if (! is_string($signature) || strlen($signature) !== 64 + || ! hash_equals(hash_hmac('sha256', $body, $secret), $signature)) { + return response('Invalid signature.', 401); + } + + $inboxId = $configuration['inbox_id'] ?? null; + + if ($inboxId !== null && $inboxId !== '' && ! $this->isPositiveId($inboxId)) { + return response('The Mailtrap inbox ID must be a positive integer.', 503); + } + + $connection = $configuration['queue_connection'] ?? 'sync'; + $queue = $configuration['queue'] ?? null; + $cacheStore = $configuration['cache_store'] ?? null; + + if (! is_string($connection) || trim($connection) === '' + || ($queue !== null && (! is_string($queue) || trim($queue) === '')) + || ($cacheStore !== null && (! is_string($cacheStore) || trim($cacheStore) === ''))) { + return response('Invalid Mailtrap queue or cache configuration.', 503); + } + + if (! $this->isSupportedConnection($connection)) { + return response('Use a persistent Mailtrap queue connection or sync.', 503); + } + + try { + $payload = json_decode($body, false, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $exception) { + return response('The request must contain a JSON events array.', 422); + } + + if (! $payload instanceof stdClass || ! is_array($payload->events ?? null)) { + return response('The request must contain a JSON events array.', 422); + } + + $failed = false; + + foreach ($payload->events as $event) { + if (! $event instanceof stdClass || ! is_string($event->event ?? null)) { + Log::warning('A Mailtrap event has no event type. The event was skipped.'); + + continue; + } + + $event = (array) $event; + + if ($event['event'] !== 'inbound.message_received') { + continue; + } + + if (! $this->isPositiveId($event['inbox_id'] ?? null) + || ! is_string($event['message_id'] ?? null) || trim($event['message_id']) === '') { + Log::warning('A Mailtrap event has invalid message identifiers. The event was skipped.'); + + continue; + } + + if ($inboxId !== null && $inboxId !== '' && (int) $inboxId !== (int) $event['inbox_id']) { + continue; + } + + $job = (new ProcessMailtrapEmail((int) $event['inbox_id'], $event['message_id'])) + ->onConnection($connection) + ->onQueue($queue); + + try { + $dispatcher->dispatch($job); + } catch (Throwable $exception) { + $failed = true; + Log::error('A Mailtrap message could not be processed or queued.', [ + 'inbox_id' => $job->inboxId, + 'message_id' => $job->messageId, + 'exception_type' => get_class($exception), + ]); + } + } + + return $failed ? response('Some Mailtrap messages need another attempt.', 503) : response('OK', 200); + } + + protected function isPositiveId($value): bool + { + return (is_int($value) || is_string($value)) + && preg_match('/^[1-9][0-9]*$/D', (string) $value) === 1 + && filter_var($value, FILTER_VALIDATE_INT) !== false; + } + + protected function isSupportedConnection(string $name, array $visited = []): bool + { + if (in_array($name, $visited, true)) { + return false; + } + + $connection = config('queue.connections.'.$name); + $driver = $connection['driver'] ?? null; + + if ($driver === 'sync') { + return $visited === []; + } + + if (in_array($driver, ['database', 'redis', 'sqs', 'beanstalkd'], true)) { + return true; + } + + if ($driver !== 'failover' || ! is_array($connection['connections'] ?? null) + || $connection['connections'] === [] || ! array_is_list($connection['connections'])) { + return false; + } + + foreach ($connection['connections'] as $fallback) { + if (! is_string($fallback) || ! $this->isSupportedConnection($fallback, [...$visited, $name])) { + return false; + } + } + + return true; + } +} diff --git a/src/Jobs/ProcessMailtrapEmail.php b/src/Jobs/ProcessMailtrapEmail.php new file mode 100644 index 0000000..3b2be88 --- /dev/null +++ b/src/Jobs/ProcessMailtrapEmail.php @@ -0,0 +1,91 @@ +store(config('mailbox.services.mailtrap.cache_store')); + $store = $repository->getStore(); + + if (! $store instanceof LockProvider) { + throw new RuntimeException('The Mailtrap cache store must support atomic locks.'); + } + + $lock = $store->lock($this->lockKey(), 180); + + if (! $lock->get()) { + if ($this->job && ! $this->job instanceof SyncJob) { + $this->release(30); + + return; + } + + throw new RuntimeException('Another worker is processing this Mailtrap message.'); + } + + try { + if ($repository->has($this->completionKey())) { + return; + } + + /** @var class-string $modelClass */ + $modelClass = config('mailbox.model'); + $email = $modelClass::fromMessage($client->fetchMessage($this->inboxId, $this->messageId)); + + if (! $email->isValid()) { + Log::warning('Mailtrap delivered an invalid email. The message was skipped.', [ + 'inbox_id' => $this->inboxId, + 'message_id' => $this->messageId, + ]); + + return; + } + + Mailbox::callMailboxes($email); + + if (! $repository->put($this->completionKey(), true, 86400)) { + throw new RuntimeException('The Mailtrap completion record could not be stored.'); + } + } finally { + $lock->release(); + } + } + + public function completionKey(): string + { + return 'laravel-mailbox:mailtrap:'.hash('sha256', $this->inboxId.':'.$this->messageId).':completed'; + } + + public function lockKey(): string + { + return 'laravel-mailbox:mailtrap:'.hash('sha256', $this->inboxId.':'.$this->messageId).':lock'; + } +} diff --git a/src/MailboxManager.php b/src/MailboxManager.php index afd4901..d8b728a 100644 --- a/src/MailboxManager.php +++ b/src/MailboxManager.php @@ -5,6 +5,7 @@ use BeyondCode\Mailbox\Drivers\Log; use BeyondCode\Mailbox\Drivers\MailCare; use BeyondCode\Mailbox\Drivers\Mailgun; +use BeyondCode\Mailbox\Drivers\Mailtrap; use BeyondCode\Mailbox\Drivers\Postmark; use BeyondCode\Mailbox\Drivers\SendGrid; use Illuminate\Support\Manager; @@ -41,6 +42,11 @@ public function createPostmarkDriver() return new Postmark; } + public function createMailtrapDriver() + { + return new Mailtrap; + } + public function getDefaultDriver() { return $this->container['config']['mailbox.driver']; diff --git a/src/MailboxServiceProvider.php b/src/MailboxServiceProvider.php index 1d1363d..4e2cc69 100644 --- a/src/MailboxServiceProvider.php +++ b/src/MailboxServiceProvider.php @@ -40,6 +40,7 @@ public function boot() public function register() { $this->mergeConfigFrom(__DIR__.'/../config/mailbox.php', 'mailbox'); + $this->mergeMailtrapConfiguration(); $this->app->singleton('mailbox', function () { return new Router($this->app); @@ -54,4 +55,22 @@ protected function registerDriver() { Mailbox::mailbox()->register(); } + + protected function mergeMailtrapConfiguration() + { + if ($this->app->configurationIsCached()) { + return; + } + + // Laravel's top-level merge does not add services to published configuration. + $defaults = require __DIR__.'/../config/mailbox.php'; + $configured = $this->app['config']->get('mailbox.services.mailtrap', []); + + if (is_array($configured)) { + $this->app['config']->set('mailbox.services.mailtrap', array_replace( + $defaults['services']['mailtrap'], + $configured + )); + } + } } diff --git a/src/Mailtrap/Client.php b/src/Mailtrap/Client.php new file mode 100644 index 0000000..7ac1b3d --- /dev/null +++ b/src/Mailtrap/Client.php @@ -0,0 +1,72 @@ +http + ->withToken($token) + ->acceptJson() + ->connectTimeout(5) + ->timeout(20) + ->withOptions(['allow_redirects' => false]) + ->get('https://mailtrap.io/api/inbound/inboxes/'.$inboxId.'/messages/'.rawurlencode($messageId)); + } catch (ConnectionException|TransferException $exception) { + throw new RuntimeException('The Mailtrap message request failed during transfer.'); + } + + if (! $response->successful()) { + throw new RuntimeException('The Mailtrap message request returned HTTP '.$response->status().'.'); + } + + $message = $response->json(); + $rawUrl = is_array($message) ? ($message['raw_message_url'] ?? null) : null; + + if (! is_string($rawUrl) || $rawUrl === '') { + throw new RuntimeException('The Mailtrap message does not have a raw download URL.'); + } + + $url = parse_url($rawUrl); + + if ($url === false || ($url['scheme'] ?? '') !== 'https' || empty($url['host']) + || isset($url['user']) || isset($url['pass'])) { + throw new RuntimeException('The Mailtrap raw download URL must use HTTPS without credentials.'); + } + + try { + // Use a separate request so the API token cannot reach the download host. + $download = $this->http + ->connectTimeout(5) + ->timeout(20) + ->withOptions(['allow_redirects' => false]) + ->get($rawUrl); + } catch (ConnectionException|TransferException $exception) { + // Signed URLs contain credentials. Do not include the transport exception. + throw new RuntimeException('The Mailtrap raw download failed during transfer.'); + } + + if (! $download->successful()) { + throw new RuntimeException('The Mailtrap raw download returned HTTP '.$download->status().'.'); + } + + return $download->body(); + } +} diff --git a/tests/Controllers/MailtrapCustomRouteTest.php b/tests/Controllers/MailtrapCustomRouteTest.php new file mode 100644 index 0000000..4cdc225 --- /dev/null +++ b/tests/Controllers/MailtrapCustomRouteTest.php @@ -0,0 +1,19 @@ +postEvents([])->assertOk(); + $this->get('/webhooks/incoming/mailtrap')->assertStatus(405); + $this->post('/laravel-mailbox/mailtrap')->assertNotFound(); + } +} diff --git a/tests/Controllers/MailtrapQueueTest.php b/tests/Controllers/MailtrapQueueTest.php new file mode 100644 index 0000000..2677367 --- /dev/null +++ b/tests/Controllers/MailtrapQueueTest.php @@ -0,0 +1,147 @@ + 'mailtrap-delivery', + 'mailbox.services.mailtrap.queue' => 'inbound', + 'queue.connections' => $connections, + ]); + + $this->postEvents([$this->event()])->assertOk(); + + Queue::assertPushed(ProcessMailtrapEmail::class, 1); + Queue::assertPushedOn('inbound', ProcessMailtrapEmail::class, function ($job) { + return $job->connection === 'mailtrap-delivery' && $job->inboxId === 123 && $job->messageId === 'm1'; + }); + Http::assertNothingSent(); + } + + public static function supportedConnections(): array + { + $connections = []; + foreach (['sync', 'database', 'redis', 'sqs', 'beanstalkd'] as $driver) { + $connections[$driver] = [['mailtrap-delivery' => ['driver' => $driver]]]; + } + + $connections['persistent failover'] = [[ + 'mailtrap-delivery' => ['driver' => 'failover', 'connections' => ['primary', 'backup']], + 'primary' => ['driver' => 'redis'], + 'backup' => ['driver' => 'database'], + ]]; + $connections['nested persistent failover'] = [[ + 'mailtrap-delivery' => ['driver' => 'failover', 'connections' => ['primary', 'backup']], + 'primary' => ['driver' => 'redis'], + 'backup' => ['driver' => 'failover', 'connections' => ['remote', 'local']], + 'remote' => ['driver' => 'sqs'], + 'local' => ['driver' => 'beanstalkd'], + ]]; + $connections['shared persistent target is not a cycle'] = [[ + 'mailtrap-delivery' => ['driver' => 'failover', 'connections' => ['primary', 'backup']], + 'primary' => ['driver' => 'failover', 'connections' => ['shared']], + 'backup' => ['driver' => 'failover', 'connections' => ['shared']], + 'shared' => ['driver' => 'database'], + ]]; + + return $connections; + } + + #[Test] + #[DataProvider('unsupportedConnections')] + public function it_rejects_nonpersistent_unknown_and_invalid_connections_before_dispatch(array $connections) + { + Queue::fake(); + config([ + 'mailbox.services.mailtrap.queue_connection' => 'mailtrap-delivery', + 'queue.connections' => $connections, + ]); + + $this->postEvents([$this->event(123, 'first'), $this->event(123, 'last')])->assertStatus(503); + + Queue::assertNothingPushed(); + Http::assertNothingSent(); + } + + public static function unsupportedConnections(): array + { + $connections = [ + 'unknown connection' => [[]], + 'missing driver' => [['mailtrap-delivery' => []]], + ]; + foreach (['deferred', 'background', 'null', 'custom', '', null, 123, true] as $index => $driver) { + $connections['unsupported driver '.$index] = [['mailtrap-delivery' => ['driver' => $driver]]]; + } + + foreach ([ + 'null targets' => null, + 'empty targets' => [], + 'string targets' => 'primary', + 'boolean targets' => true, + 'nonzero first index' => [1 => 'primary'], + 'named indexes' => ['first' => 'primary'], + 'null target' => ['primary', null], + 'integer target' => ['primary', 123], + 'array target' => ['primary', ['backup']], + 'empty target name' => ['primary', ''], + 'unknown target' => ['primary', 'missing'], + ] as $name => $targets) { + $connections[$name] = [[ + 'mailtrap-delivery' => ['driver' => 'failover', 'connections' => $targets], + 'primary' => ['driver' => 'database'], + ]]; + } + + $connections['missing targets'] = [['mailtrap-delivery' => ['driver' => 'failover']]]; + $connections['self cycle'] = [[ + 'mailtrap-delivery' => ['driver' => 'failover', 'connections' => ['mailtrap-delivery']], + ]]; + $connections['nested cycle'] = [[ + 'mailtrap-delivery' => ['driver' => 'failover', 'connections' => ['primary', 'backup']], + 'primary' => ['driver' => 'redis'], + 'backup' => ['driver' => 'failover', 'connections' => ['last']], + 'last' => ['driver' => 'failover', 'connections' => ['backup']], + ]]; + + foreach (['sync', 'deferred', 'background', 'null', 'custom'] as $driver) { + $connections['mixed failover with '.$driver] = [[ + 'mailtrap-delivery' => ['driver' => 'failover', 'connections' => ['primary', 'backup']], + 'primary' => ['driver' => 'redis'], + 'backup' => ['driver' => $driver], + ]]; + $connections['nested failover with '.$driver] = [[ + 'mailtrap-delivery' => ['driver' => 'failover', 'connections' => ['primary', 'backup']], + 'primary' => ['driver' => 'redis'], + 'backup' => ['driver' => 'failover', 'connections' => ['remote', 'last']], + 'remote' => ['driver' => 'sqs'], + 'last' => ['driver' => $driver], + ]]; + } + + $connections['nested empty targets'] = [[ + 'mailtrap-delivery' => ['driver' => 'failover', 'connections' => ['primary', 'backup']], + 'primary' => ['driver' => 'database'], + 'backup' => ['driver' => 'failover', 'connections' => []], + ]]; + $connections['nested unknown target'] = [[ + 'mailtrap-delivery' => ['driver' => 'failover', 'connections' => ['primary', 'backup']], + 'primary' => ['driver' => 'database'], + 'backup' => ['driver' => 'failover', 'connections' => ['missing']], + ]]; + + return $connections; + } +} diff --git a/tests/Controllers/MailtrapTest.php b/tests/Controllers/MailtrapTest.php new file mode 100644 index 0000000..3a33e63 --- /dev/null +++ b/tests/Controllers/MailtrapTest.php @@ -0,0 +1,509 @@ +fakeMessageDownloads(); + $handled = 0; + Mailbox::to('support@example.test', function () use (&$handled) { + $handled++; + }); + $raw = "{\n \"events\": [ {\"message_id\":\"m1\", \"inbox_id\":123, \"event\":\"inbound.message_received\"} ]\n}"; + + $this->postRaw($raw)->assertOk()->assertSeeText('OK'); + + $this->assertSame(1, $handled); + Http::assertSentCount(2); + } + + #[Test] + public function it_rejects_a_signature_for_reserialized_json() + { + $raw = "{ \"events\": [] }\n"; + $signature = hash_hmac('sha256', '{"events":[]}', 'test-signing-secret'); + + $this->postRaw($raw, $signature)->assertUnauthorized(); + + Http::assertNothingSent(); + } + + #[Test] + #[DataProvider('invalidSignatures')] + public function it_rejects_invalid_signatures(string $signature) + { + $this->postRaw('{"events":[]}', $signature)->assertUnauthorized(); + + Http::assertNothingSent(); + } + + public static function invalidSignatures(): array + { + $valid = hash_hmac('sha256', '{"events":[]}', 'test-signing-secret'); + + return [ + 'empty' => [''], + 'wrong secret' => [hash_hmac('sha256', '{"events":[]}', 'wrong-secret')], + 'uppercase' => [strtoupper($valid)], + 'algorithm prefix' => ['sha256='.$valid], + 'truncated' => [substr($valid, 0, 63)], + 'non hex' => [str_repeat('z', 64)], + ]; + } + + #[Test] + public function it_rejects_a_missing_signature_before_processing() + { + $this->call('POST', '/laravel-mailbox/mailtrap', [], [], [], [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_ACCEPT' => 'application/json', + ], '{"events":[]}')->assertUnauthorized(); + + Http::assertNothingSent(); + } + + #[Test] + public function it_returns_service_unavailable_when_an_old_configuration_has_no_mailtrap_service() + { + config(['mailbox.services' => ['mailgun' => ['key' => 'old-key']]]); + + $this->postEvents([$this->event()])->assertStatus(503); + + Http::assertNothingSent(); + } + + #[Test] + public function it_uses_sync_and_default_cache_when_a_partial_configuration_contains_only_credentials() + { + config(['queue.default' => 'redis']); + config(['mailbox.services.mailtrap' => [ + 'api_token' => 'test-api-token', + 'signing_secret' => 'test-signing-secret', + ]]); + $this->fakeMessageDownloads(); + $handled = 0; + Mailbox::to('support@example.test', function () use (&$handled) { + $handled++; + }); + + $this->postEvents([$this->event()])->assertOk(); + $this->postEvents([$this->event()])->assertOk(); + + $this->assertSame(1, $handled); + Http::assertSentCount(2); + } + + #[Test] + #[DataProvider('missingCredentials')] + public function it_returns_service_unavailable_for_missing_credentials(string $key, $value) + { + config(['mailbox.services.mailtrap.'.$key => $value]); + + $this->postEvents([$this->event()])->assertStatus(503); + + Http::assertNothingSent(); + } + + public static function missingCredentials(): array + { + return [ + 'null API token' => ['api_token', null], + 'empty API token' => ['api_token', ''], + 'whitespace API token' => ['api_token', ' '], + 'non string API token' => ['api_token', 123], + 'null signing secret' => ['signing_secret', null], + 'empty signing secret' => ['signing_secret', ''], + 'whitespace signing secret' => ['signing_secret', ' '], + 'non string signing secret' => ['signing_secret', ['secret']], + ]; + } + + #[Test] + #[DataProvider('invalidEnvelopes')] + public function it_rejects_invalid_json_and_non_list_envelopes(string $body) + { + $this->postRaw($body)->assertStatus(422); + + Http::assertNothingSent(); + } + + public static function invalidEnvelopes(): array + { + return [ + 'empty body' => [''], + 'invalid JSON' => ['{"events":'], + 'null' => ['null'], + 'scalar' => ['"hello"'], + 'top level list' => ['[]'], + 'missing events' => ['{}'], + 'null events' => ['{"events":null}'], + 'scalar events' => ['{"events":"hello"}'], + 'empty object events' => ['{"events":{}}'], + 'numeric object events' => ['{"events":{"0":{"event":"inbound.message_received","inbox_id":123,"message_id":"m1"}}}'], + 'named object events' => ['{"events":{"first":{}}}'], + ]; + } + + #[Test] + public function it_authenticates_before_reporting_malformed_json() + { + $this->postRaw('{"events":', str_repeat('0', 64))->assertUnauthorized(); + + Http::assertNothingSent(); + } + + #[Test] + public function it_accepts_an_empty_event_list() + { + $this->postEvents([])->assertOk(); + + Http::assertNothingSent(); + } + + #[Test] + public function it_skips_unrelated_and_malformed_events_and_attempts_valid_events() + { + Queue::fake(); + Log::spy(); + config(['mailbox.services.mailtrap.queue_connection' => 'redis']); + + $this->postEvents([ + ['event' => 'outbound.message_delivered'], + null, + 'invalid', + [], + ['event' => 'inbound.message_received', 'inbox_id' => 123], + $this->event(123, 'valid'), + ])->assertOk(); + + Queue::assertPushed(ProcessMailtrapEmail::class, 1); + Queue::assertPushed(ProcessMailtrapEmail::class, fn ($job) => $job->messageId === 'valid'); + Log::shouldHaveReceived('warning')->atLeast()->once(); + Http::assertNothingSent(); + } + + #[Test] + #[DataProvider('invalidEventObjects')] + public function it_requires_each_event_to_be_a_json_object_with_a_string_event_type(string $invalidEvent) + { + Queue::fake(); + Log::spy(); + config(['mailbox.services.mailtrap.queue_connection' => 'redis']); + $valid = json_encode($this->event(), JSON_THROW_ON_ERROR); + + $this->postRaw('{"events":['.$invalidEvent.','.$valid.']}')->assertOk(); + + Queue::assertPushed(ProcessMailtrapEmail::class, 1); + Queue::assertPushed(ProcessMailtrapEmail::class, fn ($job) => $job->messageId === 'm1'); + Log::shouldHaveReceived('warning')->atLeast()->once(); + Http::assertNothingSent(); + } + + public static function invalidEventObjects(): array + { + return [ + 'null' => ['null'], + 'boolean' => ['true'], + 'string' => ['"inbound.message_received"'], + 'empty list' => ['[]'], + 'populated list' => ['["inbound.message_received",123,"m1"]'], + 'empty object' => ['{}'], + 'null type' => ['{"event":null,"inbox_id":123,"message_id":"m1"}'], + 'array type' => ['{"event":["inbound.message_received"],"inbox_id":123,"message_id":"m1"}'], + 'object type' => ['{"event":{},"inbox_id":123,"message_id":"m1"}'], + 'integer type' => ['{"event":123,"inbox_id":123,"message_id":"m1"}'], + ]; + } + + #[Test] + #[DataProvider('invalidInboxIds')] + public function it_logs_and_skips_invalid_event_inbox_ids($inboxId) + { + Queue::fake(); + Log::spy(); + + $this->postEvents([$this->event($inboxId)])->assertOk(); + + Queue::assertNothingPushed(); + Http::assertNothingSent(); + Log::shouldHaveReceived('warning')->atLeast()->once(); + } + + public static function invalidInboxIds(): array + { + return [ + 'null' => [null], + 'zero' => [0], + 'negative' => [-1], + 'float' => [123.5], + 'whole number float' => [123.0], + 'true' => [true], + 'false' => [false], + 'empty' => [''], + 'only whitespace' => [' '], + 'zero string' => ['0'], + 'leading zeroes' => ['000123'], + 'negative string' => ['-1'], + 'signed string' => ['+123'], + 'decimal string' => ['123.0'], + 'exponent' => ['1e2'], + 'whitespace' => [' 123'], + 'suffix' => ['123oops'], + 'array' => [[123]], + 'overflow' => [(string) PHP_INT_MAX.'0'], + ]; + } + + #[Test] + #[DataProvider('invalidMessageIds')] + public function it_logs_and_skips_invalid_message_ids($messageId) + { + Queue::fake(); + Log::spy(); + + $this->postEvents([$this->event(123, $messageId)])->assertOk(); + + Queue::assertNothingPushed(); + Http::assertNothingSent(); + Log::shouldHaveReceived('warning')->atLeast()->once(); + } + + public static function invalidMessageIds(): array + { + return [ + 'null' => [null], + 'empty' => [''], + 'whitespace' => [' '], + 'integer' => [123], + 'boolean' => [true], + 'array' => [['m1']], + ]; + } + + #[Test] + #[DataProvider('validInboxIds')] + public function it_accepts_positive_integer_and_digit_string_inbox_ids($inboxId, int $expected) + { + Queue::fake(); + config(['mailbox.services.mailtrap.queue_connection' => 'redis']); + + $this->postEvents([$this->event($inboxId)])->assertOk(); + + Queue::assertPushed(ProcessMailtrapEmail::class, fn ($job) => $job->inboxId === $expected); + Http::assertNothingSent(); + } + + public static function validInboxIds(): array + { + return [ + 'integer' => [123, 123], + 'digit string' => ['123', 123], + 'largest integer' => [PHP_INT_MAX, PHP_INT_MAX], + 'largest integer string' => [(string) PHP_INT_MAX, PHP_INT_MAX], + ]; + } + + #[Test] + #[DataProvider('acceptAllFilters')] + public function it_accepts_all_inboxes_when_the_filter_is_empty($filter) + { + Queue::fake(); + config([ + 'mailbox.services.mailtrap.inbox_id' => $filter, + 'mailbox.services.mailtrap.queue_connection' => 'redis', + ]); + + $this->postEvents([$this->event(123, 'first'), $this->event(456, 'second')])->assertOk(); + + Queue::assertPushed(ProcessMailtrapEmail::class, 2); + } + + public static function acceptAllFilters(): array + { + return ['null' => [null], 'empty string' => ['']]; + } + + #[Test] + #[DataProvider('matchingFilters')] + public function it_only_processes_the_configured_inbox($filter, $eventInbox) + { + Queue::fake(); + config([ + 'mailbox.services.mailtrap.inbox_id' => $filter, + 'mailbox.services.mailtrap.queue_connection' => 'redis', + ]); + + $this->postEvents([$this->event(456, 'skip'), $this->event($eventInbox, 'keep')])->assertOk(); + + Queue::assertPushed(ProcessMailtrapEmail::class, 1); + Queue::assertPushed(ProcessMailtrapEmail::class, fn ($job) => $job->inboxId === 123 && $job->messageId === 'keep'); + } + + public static function matchingFilters(): array + { + return [ + 'integer filter' => [123, '123'], + 'string filter' => ['123', 123], + ]; + } + + #[Test] + #[DataProvider('invalidFilters')] + public function it_returns_service_unavailable_for_an_invalid_configured_filter($filter) + { + config(['mailbox.services.mailtrap.inbox_id' => $filter]); + + $this->postEvents([$this->event()])->assertStatus(503); + + Http::assertNothingSent(); + } + + public static function invalidFilters(): array + { + $values = self::invalidInboxIds(); + unset($values['null'], $values['empty']); + + return $values; + } + + #[Test] + public function it_dispatches_background_jobs_with_the_selected_connection_and_queue_without_http() + { + Queue::fake(); + config([ + 'mailbox.services.mailtrap.queue_connection' => 'redis', + 'mailbox.services.mailtrap.queue' => 'inbound-mail', + ]); + + $this->postEvents([$this->event()])->assertOk(); + + Queue::assertPushedOn('inbound-mail', ProcessMailtrapEmail::class, function ($job) { + return $job->connection === 'redis' && $job->inboxId === 123 && $job->messageId === 'm1'; + }); + Http::assertNothingSent(); + $this->assertSame(0, InboundEmail::query()->count()); + } + + #[Test] + public function it_attempts_the_rest_of_the_batch_after_an_enqueue_failure() + { + config(['mailbox.services.mailtrap.queue_connection' => 'redis']); + $attempted = []; + $dispatcher = Mockery::mock(Dispatcher::class); + $dispatcher->shouldReceive('dispatch')->times(3)->andReturnUsing(function ($job) use (&$attempted) { + $attempted[] = $job->messageId; + if ($job->messageId === 'broken') { + throw new RuntimeException('Queue unavailable'); + } + }); + $this->app->instance(Dispatcher::class, $dispatcher); + + $this->postEvents([ + $this->event(123, 'first'), + $this->event(123, 'broken'), + $this->event(123, 'last'), + ])->assertStatus(503); + + $this->assertSame(['first', 'broken', 'last'], $attempted); + Http::assertNothingSent(); + } + + #[Test] + public function it_retries_a_partial_batch_without_repeating_successful_handlers() + { + $attempted = []; + $fail = true; + $client = Mockery::mock(Client::class); + $client->shouldReceive('fetchMessage')->andReturnUsing(function ($inboxId, $messageId) use (&$attempted, &$fail) { + $attempted[] = $messageId; + if ($messageId === 'broken' && $fail) { + throw new RuntimeException('Temporary download failure'); + } + + return str_replace('invoice-42@example.test', $messageId.'@example.test', MailtrapMessage::rich()); + }); + $this->app->instance(Client::class, $client); + $handled = []; + Mailbox::to('support@example.test', function (InboundEmail $email) use (&$handled) { + $handled[] = $email->id(); + }); + $events = [$this->event(123, 'first'), $this->event(123, 'broken'), $this->event(123, 'last')]; + + $this->postEvents($events)->assertStatus(503); + $this->assertSame(['first', 'broken', 'last'], $attempted); + $this->assertSame(['first@example.test', 'last@example.test'], $handled); + $fail = false; + + $this->postEvents($events)->assertOk(); + + $this->assertSame(['first', 'broken', 'last', 'broken'], $attempted); + $this->assertSame(['first@example.test', 'last@example.test', 'broken@example.test'], $handled); + $this->assertSame(3, InboundEmail::query()->count()); + } + + #[Test] + public function it_routes_and_stores_only_once_for_duplicate_events_and_deliveries() + { + $this->fakeMessageDownloads(); + $handled = 0; + Mailbox::to('support@example.test', function () use (&$handled) { + $handled++; + }); + + $this->postEvents([$this->event(), $this->event()])->assertOk(); + $this->postEvents([$this->event()])->assertOk(); + + $this->assertSame(1, $handled); + $this->assertSame(1, InboundEmail::query()->count()); + Http::assertSentCount(2); + } + + #[Test] + #[DataProvider('upstreamFailures')] + public function it_returns_service_unavailable_for_authenticated_upstream_failures(string $stage, int $status) + { + Http::fake([ + 'https://mailtrap.io/api/*' => Http::response([ + 'raw_message_url' => 'https://raw.example.test/m.eml?signature=download-secret', + ], $stage === 'api' ? $status : 200), + 'https://raw.example.test/*' => Http::response('Download failed', $status), + ]); + + $response = $this->postEvents([$this->event()])->assertStatus(503); + + $response->assertDontSee('test-api-token')->assertDontSee('download-secret'); + Http::assertSentCount($stage === 'api' ? 1 : 2); + $this->assertSame(0, InboundEmail::query()->count()); + } + + public static function upstreamFailures(): array + { + return [ + 'API unauthorized' => ['api', 401], + 'API forbidden' => ['api', 403], + 'API rate limited' => ['api', 429], + 'API unavailable' => ['api', 503], + 'download forbidden' => ['download', 403], + 'download not found' => ['download', 404], + 'download unavailable' => ['download', 503], + ]; + } +} diff --git a/tests/Jobs/ProcessMailtrapEmailTest.php b/tests/Jobs/ProcessMailtrapEmailTest.php new file mode 100644 index 0000000..f264535 --- /dev/null +++ b/tests/Jobs/ProcessMailtrapEmailTest.php @@ -0,0 +1,435 @@ +handle($this->app->make(Client::class), $this->app->make(CacheFactory::class)); + } + + protected function fakeClient(int $calls = 1): void + { + $client = Mockery::mock(Client::class); + $client->shouldReceive('fetchMessage')->with(123, 'm1')->times($calls)->andReturn(MailtrapMessage::rich()); + $this->app->instance(Client::class, $client); + } + + #[Test] + public function it_preserves_the_mime_message_and_uses_the_configured_model() + { + config(['mailbox.model' => MailtrapInboundEmail::class]); + $this->fakeClient(); + $received = null; + Mailbox::to('support@example.test', function (InboundEmail $email) use (&$received) { + $received = $email; + }); + + $this->handle(new ProcessMailtrapEmail(123, 'm1')); + + $this->assertInstanceOf(MailtrapInboundEmail::class, $received); + $this->assertSame('alice@example.test', $received->from()); + $this->assertSame('Alice Sender', $received->fromName()); + $this->assertSame('support@example.test', $received->to()[0]->getEmail()); + $this->assertSame('colleague@example.test', $received->cc()[0]->getEmail()); + $this->assertSame('Re: Invoice 42', $received->subject()); + $this->assertSame('invoice-42@example.test', $received->id()); + $this->assertSame('reply@example.test', $received->headerValue('Reply-To')); + $this->assertStringContainsString('original-42@example.test', $received->headerValue('In-Reply-To')); + $this->assertSame( + ' ', + $received->message()->getHeader('References')->getRawValue() + ); + $this->assertStringContainsString('Thanks for the invoice.', $received->text()); + $this->assertStringContainsString('> Please review invoice 42.', $received->text()); + $this->assertSame('Thanks for the invoice.', trim($received->visibleText())); + $this->assertStringContainsString('

Thanks for the invoice.

', $received->html()); + $this->assertStringContainsString('src="cid:logo@example.test"', $received->html()); + + $attachments = []; + foreach ($received->attachments() as $attachment) { + $attachments[$attachment->getFilename()] = $attachment; + } + $this->assertCount(2, $attachments); + $this->assertSame('application/pdf', $attachments['invoice.pdf']->getContentType()); + $this->assertSame(MailtrapMessage::PDF, $attachments['invoice.pdf']->getContent()); + $this->assertSame('image/png', $attachments['logo.png']->getContentType()); + $this->assertSame(base64_decode(MailtrapMessage::PNG), $attachments['logo.png']->getContent()); + $this->assertStringContainsString('logo@example.test', $attachments['logo.png']->getHeaderValue('Content-ID')); + $this->assertSame(1, MailtrapInboundEmail::query()->count()); + $this->assertSame(MailtrapMessage::rich(), MailtrapInboundEmail::query()->firstOrFail()->message); + } + + #[Test] + public function it_preserves_reply_address_and_thread_headers_when_a_handler_replies() + { + $this->fakeClient(); + config(['mail.default' => 'log']); + $sent = null; + Event::listen(MessageSending::class, function (MessageSending $event) use (&$sent) { + $sent = $event->message; + }); + Mailbox::to('support@example.test', function (InboundEmail $email) { + $email->reply(new MailtrapReplyMail); + }); + + $this->handle(new ProcessMailtrapEmail(123, 'm1')); + + $this->assertNotNull($sent); + $this->assertSame('reply@example.test', $sent->getTo()[0]->getAddress()); + $this->assertSame('', $sent->getHeaders()->get('In-Reply-To')->getBodyAsString()); + } + + #[Test] + #[DataProvider('storagePolicies')] + public function it_preserves_existing_routing_and_storage_policies(bool $matching, bool $onlyMatching, int $retention, int $stored) + { + $this->fakeClient(); + config([ + 'mailbox.only_store_matching_emails' => $onlyMatching, + 'mailbox.store_incoming_emails_for_days' => $retention, + ]); + $handled = 0; + Mailbox::to($matching ? 'support@example.test' : 'other@example.test', function () use (&$handled) { + $handled++; + }); + + $this->handle(new ProcessMailtrapEmail(123, 'm1')); + + $this->assertSame($matching ? 1 : 0, $handled); + $this->assertSame($stored, InboundEmail::query()->count()); + } + + public static function storagePolicies(): array + { + return [ + 'matching messages' => [true, true, 7, 1], + 'unmatched excluded' => [false, true, 7, 0], + 'all messages' => [false, false, 7, 1], + 'storage disabled' => [true, true, 0, 0], + ]; + } + + #[Test] + public function it_preserves_fallback_and_catch_all_handlers() + { + $this->fakeClient(); + $calls = []; + Mailbox::fallback(function () use (&$calls) { + $calls[] = 'fallback'; + }); + Mailbox::catchAll(function () use (&$calls) { + $calls[] = 'catch-all'; + }); + + $this->handle(new ProcessMailtrapEmail(123, 'm1')); + + $this->assertSame(['fallback', 'catch-all'], $calls); + $this->assertSame(1, InboundEmail::query()->count()); + } + + #[Test] + public function it_marks_completion_only_after_the_handler_succeeds_and_skips_completed_jobs() + { + $this->fakeClient(); + $job = new ProcessMailtrapEmail(123, 'm1'); + $cache = $this->app->make(CacheFactory::class)->store(); + $handled = 0; + Mailbox::to('support@example.test', function () use ($cache, $job, &$handled) { + $this->assertFalse($cache->has($job->completionKey())); + $handled++; + }); + + $this->handle($job); + $this->assertTrue($cache->has($job->completionKey())); + $this->handle(new ProcessMailtrapEmail(123, 'm1')); + + $this->assertSame(1, $handled); + $this->assertSame(1, InboundEmail::query()->count()); + } + + #[Test] + public function it_expires_completion_after_twenty_four_hours() + { + $this->fakeClient(2); + // Do not make this test depend on the application's Message-ID database index. + config(['mailbox.store_incoming_emails_for_days' => 0]); + $job = new ProcessMailtrapEmail(123, 'm1'); + $cache = $this->app->make(CacheFactory::class)->store(); + $handled = 0; + Mailbox::to('support@example.test', function () use (&$handled) { + $handled++; + }); + + try { + $this->handle($job); + $this->travel(86399)->seconds(); + $this->assertTrue($cache->has($job->completionKey())); + $this->handle(new ProcessMailtrapEmail(123, 'm1')); + $this->assertSame(1, $handled); + + $this->travel(2)->seconds(); + $this->assertFalse($cache->has($job->completionKey())); + $this->handle(new ProcessMailtrapEmail(123, 'm1')); + $this->assertSame(2, $handled); + } finally { + $this->travelBack(); + } + } + + #[Test] + #[DataProvider('failureStages')] + public function it_propagates_worker_failures_releases_the_lock_and_does_not_mark_completion(string $stage) + { + $job = new ProcessMailtrapEmail(123, 'm1'); + $cache = $this->app->make(CacheFactory::class)->store(); + $queueJob = Mockery::mock(QueueJob::class); + $queueJob->shouldNotReceive('release'); + $job->setJob($queueJob); + $fail = true; + $client = Mockery::mock(Client::class); + $client->shouldReceive('fetchMessage')->with(123, 'm1')->twice()->andReturnUsing(function () use ($stage, &$fail) { + if ($stage === 'fetch' && $fail) { + throw new RuntimeException('Temporary fetch failure'); + } + + return MailtrapMessage::rich(); + }); + $this->app->instance(Client::class, $client); + $handled = 0; + Mailbox::to('support@example.test', function () use ($stage, &$fail, &$handled) { + if ($stage === 'handler' && $fail) { + throw new RuntimeException('Temporary handler failure'); + } + $handled++; + }); + + try { + $this->handle($job); + $this->fail('Worker exceptions must reach the queue worker.'); + } catch (RuntimeException $exception) { + $this->assertSame('Temporary '.$stage.' failure', $exception->getMessage()); + } + + $this->assertFalse($cache->has($job->completionKey())); + $this->assertSame(0, InboundEmail::query()->count()); + $probe = $cache->lock($job->lockKey(), 180); + $this->assertTrue($probe->get(), 'The failed attempt must release its lock.'); + $probe->release(); + $fail = false; + $this->handle(new ProcessMailtrapEmail(123, 'm1')); + + $this->assertSame(1, $handled); + $this->assertTrue($cache->has($job->completionKey())); + } + + public static function failureStages(): array + { + return ['download failure' => ['fetch'], 'handler failure' => ['handler']]; + } + + #[Test] + public function it_releases_a_queued_job_for_thirty_seconds_when_another_worker_holds_the_lock() + { + $job = new ProcessMailtrapEmail(123, 'm1'); + $cache = $this->app->make(CacheFactory::class)->store(); + $lock = $cache->lock($job->lockKey(), 180); + $this->assertTrue($lock->get()); + $queueJob = Mockery::mock(QueueJob::class); + $queueJob->shouldReceive('release')->once()->with(30); + $job->setJob($queueJob); + $client = Mockery::mock(Client::class); + $client->shouldNotReceive('fetchMessage'); + $this->app->instance(Client::class, $client); + + try { + $this->handle($job); + + $this->assertFalse($cache->has($job->completionKey())); + $this->assertFalse($cache->lock($job->lockKey(), 180)->get(), 'A competing job must not release the owner lock.'); + $this->assertSame(0, InboundEmail::query()->count()); + } finally { + $lock->release(); + } + } + + #[Test] + #[DataProvider('synchronousJobs')] + public function it_throws_a_retryable_exception_when_a_synchronous_job_encounters_a_busy_lock(bool $useSyncQueue) + { + $job = new ProcessMailtrapEmail(123, 'm1'); + if ($useSyncQueue) { + $queueJob = Mockery::mock(SyncJob::class); + $queueJob->shouldNotReceive('release'); + $job->setJob($queueJob); + } + $client = Mockery::mock(Client::class); + $client->shouldNotReceive('fetchMessage'); + $this->app->instance(Client::class, $client); + $cache = $this->app->make(CacheFactory::class)->store(); + $lock = $cache->lock($job->lockKey(), 180); + $this->assertTrue($lock->get()); + + try { + $this->handle($job); + $this->fail('Synchronous lock contention must be retryable.'); + } catch (RuntimeException $exception) { + $this->assertSame(RuntimeException::class, get_class($exception)); + $this->assertFalse($cache->has($job->completionKey())); + $this->assertFalse($cache->lock($job->lockKey(), 180)->get()); + } finally { + $lock->release(); + } + } + + public static function synchronousJobs(): array + { + return ['direct invocation' => [false], 'sync queue' => [true]]; + } + + #[Test] + public function it_holds_an_atomic_lock_during_processing_and_expires_it_after_three_minutes() + { + $job = new ProcessMailtrapEmail(123, 'm1'); + $cache = $this->app->make(CacheFactory::class)->store(); + $this->fakeClient(); + Mailbox::to('support@example.test', function () use ($cache, $job) { + $this->assertFalse($cache->lock($job->lockKey(), 180)->get()); + $this->travel(179)->seconds(); + $this->assertFalse($cache->lock($job->lockKey(), 180)->get()); + $this->travel(2)->seconds(); + $probe = $cache->lock($job->lockKey(), 180); + $this->assertTrue($probe->get()); + $probe->release(); + }); + + try { + $this->handle($job); + } finally { + $this->travelBack(); + } + } + + #[Test] + public function it_uses_the_selected_cache_store_for_completion_and_locks() + { + config([ + 'cache.stores.mailtrap-tests' => ['driver' => 'array'], + 'mailbox.services.mailtrap.cache_store' => 'mailtrap-tests', + ]); + $this->fakeClient(); + $job = new ProcessMailtrapEmail(123, 'm1'); + $cache = $this->app->make(CacheFactory::class); + Mailbox::to('support@example.test', function () use ($cache, $job) { + $this->assertFalse($cache->store('mailtrap-tests')->lock($job->lockKey(), 180)->get()); + }); + + $this->handle($job); + $this->handle(new ProcessMailtrapEmail(123, 'm1')); + + $this->assertTrue($cache->store('mailtrap-tests')->has($job->completionKey())); + $this->assertFalse($cache->store('array')->has($job->completionKey())); + $probe = $cache->store('mailtrap-tests')->lock($job->lockKey(), 180); + $this->assertTrue($probe->get()); + $probe->release(); + } + + #[Test] + public function it_logs_and_skips_invalid_mime_without_running_handlers() + { + Log::spy(); + $client = Mockery::mock(Client::class); + $client->shouldReceive('fetchMessage')->once()->with(123, 'm1')->andReturn('This is not an email.'); + $this->app->instance(Client::class, $client); + $handled = 0; + Mailbox::catchAll(function () use (&$handled) { + $handled++; + }); + + $this->handle(new ProcessMailtrapEmail(123, 'm1')); + + $this->assertSame(0, $handled); + $this->assertSame(0, InboundEmail::query()->count()); + Log::shouldHaveReceived('warning')->atLeast()->once(); + $probe = $this->app->make(CacheFactory::class)->store()->lock((new ProcessMailtrapEmail(123, 'm1'))->lockKey(), 180); + $this->assertTrue($probe->get(), 'Skipped MIME must release its lock.'); + $probe->release(); + } + + #[Test] + public function it_keeps_message_and_inbox_identifiers_separate_in_cache_keys() + { + $first = new ProcessMailtrapEmail(12, '3:m1'); + $otherInbox = new ProcessMailtrapEmail(123, 'm1'); + $otherMessage = new ProcessMailtrapEmail(12, '3:m2'); + $same = new ProcessMailtrapEmail(12, '3:m1'); + + $this->assertSame($first->completionKey(), $same->completionKey()); + $this->assertSame($first->lockKey(), $same->lockKey()); + $this->assertNotSame($first->completionKey(), $first->lockKey()); + $this->assertNotSame($first->completionKey(), $otherInbox->completionKey()); + $this->assertNotSame($first->lockKey(), $otherInbox->lockKey()); + $this->assertNotSame($first->completionKey(), $otherMessage->completionKey()); + } + + #[Test] + public function it_serializes_identifiers_and_queue_metadata_without_secrets_or_transport_objects() + { + $job = (new ProcessMailtrapEmail(123, 'm1'))->onConnection('redis')->onQueue('inbound-mail'); + $this->assertInstanceOf(ShouldQueue::class, $job); + $this->assertSame(40, $job->tries); + $this->assertSame(300, $job->backoff); + $this->assertSame(120, $job->timeout); + $this->assertTrue($job->failOnTimeout); + + $serialized = serialize($job); + $restored = unserialize($serialized); + + $this->assertSame(123, $restored->inboxId); + $this->assertSame('m1', $restored->messageId); + $this->assertSame('redis', $restored->connection); + $this->assertSame('inbound-mail', $restored->queue); + $this->assertStringNotContainsString('test-api-token', $serialized); + $this->assertStringNotContainsString('test-signing-secret', $serialized); + $this->assertStringNotContainsString('https://', $serialized); + $this->assertStringNotContainsString(Client::class, $serialized); + Http::assertNothingSent(); + + $this->fakeClient(); + $this->handle($restored); + $this->assertTrue($this->app->make(CacheFactory::class)->store()->has($job->completionKey())); + } +} + +class MailtrapInboundEmail extends InboundEmail +{ +} + +class MailtrapReplyMail extends Mailable +{ + public function build() + { + return $this->from('support@example.test')->subject('Re: Invoice 42')->html('

Received.

'); + } +} diff --git a/tests/Mailtrap/ClientTest.php b/tests/Mailtrap/ClientTest.php new file mode 100644 index 0000000..b77c01f --- /dev/null +++ b/tests/Mailtrap/ClientTest.php @@ -0,0 +1,217 @@ +url(); + $this->assertSame('GET', $request->method()); + $this->assertSame(5, $options['connect_timeout']); + $this->assertSame(20, $options['timeout']); + + if ($request->url() === $apiUrl) { + $this->assertTrue($request->hasHeader('Authorization', 'Bearer test-api-token')); + + return Http::response(['raw_message_url' => $rawUrl], 200, [ + 'Set-Cookie' => 'api_session=private-cookie; Path=/; Secure', + ]); + } + + $this->assertSame($rawUrl, $request->url()); + $this->assertFalse($request->hasHeader('Authorization')); + $this->assertFalse($request->hasHeader('Cookie')); + $this->assertFalse($options['allow_redirects']); + $this->assertEmpty($options['auth'] ?? null); + + return Http::response(MailtrapMessage::rich(), 200, ['Content-Type' => 'message/rfc822']); + }); + + $message = $this->app->make(Client::class)->fetchMessage(123, 'id/with ?#%+'); + + $this->assertSame(MailtrapMessage::rich(), $message); + $this->assertSame([$apiUrl, $rawUrl], $requests); + } + + #[Test] + #[DataProvider('missingOrUnsafeRawUrls')] + public function it_rejects_missing_and_unsafe_raw_message_urls(array $metadata) + { + Http::fake(['https://mailtrap.io/api/*' => Http::response($metadata)]); + + try { + $this->app->make(Client::class)->fetchMessage(123, 'm1'); + $this->fail('Missing or unsafe raw_message_url must fail.'); + } catch (RuntimeException $exception) { + $this->assertSame(RuntimeException::class, get_class($exception)); + Http::assertSentCount(1); + } + } + + public static function missingOrUnsafeRawUrls(): array + { + return [ + 'missing' => [[]], + 'null' => [['raw_message_url' => null]], + 'empty' => [['raw_message_url' => '']], + 'non string' => [['raw_message_url' => ['https://raw.example.test/m.eml']]], + 'HTTP' => [['raw_message_url' => 'http://raw.example.test/m.eml']], + 'protocol relative' => [['raw_message_url' => '//raw.example.test/m.eml']], + 'relative path' => [['raw_message_url' => '/m.eml']], + 'file scheme' => [['raw_message_url' => 'file:///etc/passwd']], + 'missing host' => [['raw_message_url' => 'https:///m.eml']], + 'username' => [['raw_message_url' => 'https://user@raw.example.test/m.eml']], + 'username and password' => [['raw_message_url' => 'https://user:secret@raw.example.test/m.eml']], + ]; + } + + #[Test] + #[DataProvider('failedStatuses')] + public function it_throws_on_unsuccessful_authenticated_api_requests(int $status) + { + Http::fake(['https://mailtrap.io/api/*' => Http::response([ + 'raw_message_url' => 'https://raw.example.test/m.eml', + ], $status)]); + + try { + $this->app->make(Client::class)->fetchMessage(123, 'm1'); + $this->fail('A failed API request must remain retryable.'); + } catch (RuntimeException $exception) { + $this->assertSame(RuntimeException::class, get_class($exception)); + Http::assertSentCount(1); + Http::assertSent(fn (Request $request) => $request->hasHeader('Authorization', 'Bearer test-api-token')); + } + } + + #[Test] + #[DataProvider('failedStatuses')] + public function it_throws_on_unsuccessful_raw_downloads_including_redirects(int $status) + { + Http::fake([ + 'https://mailtrap.io/api/*' => Http::response([ + 'raw_message_url' => 'https://raw.example.test/m.eml', + ]), + 'https://raw.example.test/*' => Http::response('Download failed', $status, [ + 'Location' => 'https://redirect.example.test/m.eml', + ]), + ]); + + try { + $this->app->make(Client::class)->fetchMessage(123, 'm1'); + $this->fail('A failed or redirected download must remain retryable.'); + } catch (RuntimeException $exception) { + $this->assertSame(RuntimeException::class, get_class($exception)); + Http::assertSentCount(2); + Http::assertNotSent(fn (Request $request) => str_contains($request->url(), 'redirect.example.test')); + } + } + + public static function failedStatuses(): array + { + return [ + 'redirect' => [302], + 'unauthorized' => [401], + 'forbidden' => [403], + 'not found' => [404], + 'rate limited' => [429], + 'server error' => [500], + 'unavailable' => [503], + ]; + } + + #[Test] + public function it_throws_when_the_api_response_is_not_json() + { + Http::fake(['https://mailtrap.io/api/*' => Http::response('Unavailable')]); + + $this->expectException(RuntimeException::class); + + $this->app->make(Client::class)->fetchMessage(123, 'm1'); + } + + #[Test] + #[DataProvider('connectionFailureStages')] + public function it_throws_runtime_exceptions_for_connection_failures(bool $failDownload) + { + Http::fake(function (Request $request) use ($failDownload) { + if ($failDownload && str_starts_with($request->url(), 'https://mailtrap.io/api/')) { + return Http::response(['raw_message_url' => 'https://raw.example.test/m.eml']); + } + + throw new ConnectionException('Timed out: https://raw.example.test/m.eml?signature=download-secret; Bearer test-api-token'); + }); + + try { + $this->app->make(Client::class)->fetchMessage(123, 'm1'); + $this->fail('Connection failures must remain retryable.'); + } catch (RuntimeException $exception) { + $this->assertSame(RuntimeException::class, get_class($exception)); + $this->assertStringNotContainsString('download-secret', (string) $exception); + $this->assertStringNotContainsString('test-api-token', (string) $exception); + $this->assertStringNotContainsString('raw.example.test', (string) $exception); + $this->assertNull($exception->getPrevious(), 'Previous transport exceptions can expose signed URLs.'); + } + } + + public static function connectionFailureStages(): array + { + return ['API' => [false], 'raw download' => [true]]; + } + + #[Test] + #[DataProvider('connectionFailureStages')] + public function it_sanitizes_guzzle_request_exceptions_from_metadata_and_raw_downloads(bool $failDownload) + { + $rawUrl = 'https://raw.example.test/m.eml?signature=guzzle-download-secret'; + $attempted = []; + Http::fake(function (Request $request) use ($failDownload, $rawUrl, &$attempted) { + $attempted[] = $request->url(); + if ($failDownload && str_starts_with($request->url(), 'https://mailtrap.io/api/')) { + return Http::response(['raw_message_url' => $rawUrl]); + } + + throw new RequestException( + 'Transfer failed for '.$rawUrl.' with Bearer test-api-token', + new PsrRequest('GET', $rawUrl, ['Authorization' => 'Bearer test-api-token']) + ); + }); + + $failure = null; + try { + $this->app->make(Client::class)->fetchMessage(123, 'm1'); + } catch (RuntimeException $exception) { + $failure = $exception; + } + + $this->assertInstanceOf(RuntimeException::class, $failure); + $this->assertNotInstanceOf(RequestException::class, $failure); + $this->assertNull($failure->getPrevious(), 'The original request contains a signed URL and API token.'); + $this->assertStringNotContainsString($rawUrl, (string) $failure); + $this->assertStringNotContainsString('guzzle-download-secret', (string) $failure); + $this->assertStringNotContainsString('test-api-token', (string) $failure); + $this->assertSame( + $failDownload + ? ['https://mailtrap.io/api/inbound/inboxes/123/messages/m1', $rawUrl] + : ['https://mailtrap.io/api/inbound/inboxes/123/messages/m1'], + $attempted + ); + } +} diff --git a/tests/Mailtrap/ConfigurationTest.php b/tests/Mailtrap/ConfigurationTest.php new file mode 100644 index 0000000..91c2b06 --- /dev/null +++ b/tests/Mailtrap/ConfigurationTest.php @@ -0,0 +1,118 @@ +makePartial(); + $app->shouldReceive('configurationIsCached')->andReturn($cached); + $app->instance('config', new Repository(['mailbox' => $mailbox])); + + (new MailboxServiceProvider($app))->register(); + + $result = $app['config']->get('mailbox'); + if ($cached) { + $this->assertSame($mailbox, $result, 'Cached configuration must remain authoritative.'); + + return; + } + + $mailtrap = $result['services']['mailtrap']; + $this->assertSame('custom-mailbox', $result['path']); + $this->assertSame('old-mailgun-key', $result['services']['mailgun']['key']); + $this->assertSame('application-value', $result['services']['custom']['key']); + $this->assertSame(0, $result['store_incoming_emails_for_days']); + $this->assertFalse($result['only_store_matching_emails']); + $this->assertArrayHasKey('api_token', $mailtrap); + $this->assertArrayHasKey('signing_secret', $mailtrap); + $this->assertArrayHasKey('inbox_id', $mailtrap); + $this->assertArrayHasKey('cache_store', $mailtrap); + $this->assertArrayHasKey('queue', $mailtrap); + $this->assertSame($mailbox['services']['mailtrap']['queue_connection'] ?? 'sync', $mailtrap['queue_connection']); + + foreach ($mailbox['services']['mailtrap'] ?? [] as $key => $value) { + $this->assertSame($value, $mailtrap[$key], 'Published Mailtrap value changed: '.$key); + } + } + + public static function publishedConfigurations(): array + { + $old = [ + 'driver' => 'mailgun', + 'path' => 'custom-mailbox', + 'store_incoming_emails_for_days' => 0, + 'only_store_matching_emails' => false, + 'services' => [ + 'mailgun' => ['key' => 'old-mailgun-key'], + 'custom' => ['key' => 'application-value'], + ], + ]; + $partial = $old; + $partial['driver'] = 'mailtrap'; + $partial['services']['mailtrap'] = [ + 'api_token' => 'published-api-token', + 'signing_secret' => 'published-signing-secret', + ]; + $complete = $partial; + $complete['services']['mailtrap'] += [ + 'inbox_id' => '123', + 'queue_connection' => 'redis', + 'queue' => 'inbound', + 'cache_store' => 'mailtrap-cache', + ]; + $explicitNulls = $partial; + $explicitNulls['services']['mailtrap'] += [ + 'inbox_id' => null, + 'queue' => null, + 'cache_store' => null, + ]; + + return [ + 'old' => [$old, false], + 'old cached' => [$old, true], + 'partial' => [$partial, false], + 'partial cached' => [$partial, true], + 'complete' => [$complete, false], + 'complete cached' => [$complete, true], + 'explicit nulls' => [$explicitNulls, false], + 'explicit nulls cached' => [$explicitNulls, true], + ]; + } + + #[Test] + public function it_reads_the_mailbox_driver_from_the_environment() + { + $environment = Env::getRepository(); + $previous = $environment->get('MAILBOX_DRIVER'); + $environment->set('MAILBOX_DRIVER', 'mailtrap'); + + try { + $configuration = require __DIR__.'/../../config/mailbox.php'; + + $this->assertSame('mailtrap', $configuration['driver']); + $this->assertSame('sync', $configuration['services']['mailtrap']['queue_connection']); + $this->assertNull($configuration['services']['mailtrap']['inbox_id']); + $this->assertNull($configuration['services']['mailtrap']['queue']); + $this->assertNull($configuration['services']['mailtrap']['cache_store']); + } finally { + if ($previous === null) { + $environment->clear('MAILBOX_DRIVER'); + } else { + $environment->set('MAILBOX_DRIVER', $previous); + } + } + } +} diff --git a/tests/Support/MailtrapMessage.php b/tests/Support/MailtrapMessage.php new file mode 100644 index 0000000..cf5f260 --- /dev/null +++ b/tests/Support/MailtrapMessage.php @@ -0,0 +1,68 @@ +>\nendobj\n%%EOF\n"; + + public const PNG = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII='; + + public static function rich(): string + { + // Original test data. MIME parts use CRLF, as messages downloaded from Mailtrap do. + return implode("\r\n", [ + 'From: Alice Sender ', + 'To: Support ', + 'Cc: Colleague ', + 'Reply-To: reply@example.test', + 'Subject: Re: Invoice 42', + 'Date: Mon, 05 Oct 2026 09:30:00 +0000', + 'Message-ID: ', + 'In-Reply-To: ', + 'References: ', + 'MIME-Version: 1.0', + 'Content-Type: multipart/mixed; boundary="mailtrap-mixed"', + '', + '--mailtrap-mixed', + 'Content-Type: multipart/related; boundary="mailtrap-related"', + '', + '--mailtrap-related', + 'Content-Type: multipart/alternative; boundary="mailtrap-alternative"', + '', + '--mailtrap-alternative', + 'Content-Type: text/plain; charset=UTF-8', + 'Content-Transfer-Encoding: quoted-printable', + '', + 'Thanks for the invoice.', + '', + 'On Sun, Oct 4, 2026 at 9:00 AM Support wrote:', + '> Please review invoice 42.', + '', + '--mailtrap-alternative', + 'Content-Type: text/html; charset=UTF-8', + 'Content-Transfer-Encoding: quoted-printable', + '', + '

Thanks for the invoice.

', + '--mailtrap-alternative--', + '', + '--mailtrap-related', + 'Content-Type: image/png; name="logo.png"', + 'Content-Disposition: inline; filename="logo.png"', + 'Content-ID: ', + 'Content-Transfer-Encoding: base64', + '', + self::PNG, + '--mailtrap-related--', + '', + '--mailtrap-mixed', + 'Content-Type: application/pdf; name="invoice.pdf"', + 'Content-Disposition: attachment; filename="invoice.pdf"', + 'Content-Transfer-Encoding: base64', + '', + base64_encode(self::PDF), + '--mailtrap-mixed--', + '', + ]); + } +} diff --git a/tests/Support/MailtrapTestCase.php b/tests/Support/MailtrapTestCase.php new file mode 100644 index 0000000..67a955e --- /dev/null +++ b/tests/Support/MailtrapTestCase.php @@ -0,0 +1,73 @@ +mailboxPath; + $app['config']['mailbox.services.mailtrap'] = [ + 'api_token' => 'test-api-token', + 'signing_secret' => 'test-signing-secret', + 'inbox_id' => null, + 'queue_connection' => 'sync', + 'queue' => null, + 'cache_store' => null, + ]; + $app['config']['cache.default'] = 'array'; + $app['config']['cache.stores.array'] = ['driver' => 'array']; + } + + protected function setUp(): void + { + parent::setUp(); + + Http::preventStrayRequests(); + Http::fake([]); + } + + protected function event($inboxId = 123, $messageId = 'm1'): array + { + return [ + 'event' => 'inbound.message_received', + 'inbox_id' => $inboxId, + 'message_id' => $messageId, + ]; + } + + protected function postEvents(array $events): TestResponse + { + return $this->postRaw(json_encode(['events' => $events], JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION)); + } + + protected function postRaw(string $body, ?string $signature = null): TestResponse + { + return $this->call('POST', '/'.$this->mailboxPath.'/mailtrap', [], [], [], [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_ACCEPT' => 'application/json', + 'HTTP_MAILTRAP_SIGNATURE' => $signature ?? hash_hmac('sha256', $body, 'test-signing-secret'), + ], $body); + } + + protected function fakeMessageDownloads(): void + { + Http::fake([ + 'https://mailtrap.io/api/inbound/inboxes/*/messages/*' => Http::response([ + 'raw_message_url' => 'https://raw.example.test/mail/message.eml?signature=download-secret', + ]), + 'https://raw.example.test/*' => Http::response(MailtrapMessage::rich(), 200, [ + 'Content-Type' => 'message/rfc822', + ]), + ]); + } +}