Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 10 additions & 1 deletion config/mailbox.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'),

Expand Down Expand Up @@ -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'),
],

],

];
6 changes: 6 additions & 0 deletions docs/drivers/drivers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
149 changes: 149 additions & 0 deletions docs/drivers/mailtrap.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 16 additions & 0 deletions src/Drivers/Mailtrap.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

namespace BeyondCode\Mailbox\Drivers;

use BeyondCode\Mailbox\Http\Controllers\MailtrapController;
use Illuminate\Support\Facades\Route;

class Mailtrap implements DriverInterface
{
public function register()
{
Route::prefix(config('mailbox.path'))->group(function () {
Route::post('/mailtrap', MailtrapController::class);
});
}
}
147 changes: 147 additions & 0 deletions src/Http/Controllers/MailtrapController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
<?php

namespace BeyondCode\Mailbox\Http\Controllers;

use BeyondCode\Mailbox\Jobs\ProcessMailtrapEmail;
use Illuminate\Contracts\Bus\Dispatcher;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Log;
use JsonException;
use stdClass;
use Throwable;

class MailtrapController extends Controller
{
public function __invoke(Request $request, Dispatcher $dispatcher): Response
{
$configuration = config('mailbox.services.mailtrap');
$secret = $configuration['signing_secret'] ?? null;
$token = $configuration['api_token'] ?? null;

if (! is_string($secret) || trim($secret) === '' || ! is_string($token) || trim($token) === '') {
return response('Mailtrap credentials are not configured.', 503);
}

$body = $request->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;
}
}
Loading