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
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.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",
Expand Down
9 changes: 8 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", "resend"
*/
'driver' => env('MAILBOX_DRIVER', 'log'),

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

],

];
59 changes: 59 additions & 0 deletions docs/drivers/drivers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
78 changes: 78 additions & 0 deletions src/Clients/ResendClient.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<?php

namespace BeyondCode\Mailbox\Clients;

use Illuminate\Support\Facades\Http;
use LogicException;
use RuntimeException;
use Throwable;
use UnexpectedValueException;

class ResendClient
{
public const API_URL = 'https://api.resend.com/emails/receiving';
public const USER_AGENT = 'beyondcode/laravel-mailbox';

public function rawEmail(string $emailId): string
{
$apiKey = config('mailbox.services.resend.api_key');

if (! is_string($apiKey) || trim($apiKey) === '') {
throw new LogicException('Resend API key is not configured.');
}

$response = Http::acceptJson()
->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']);
}
}
16 changes: 16 additions & 0 deletions src/Drivers/Resend.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\ResendController;
use Illuminate\Support\Facades\Route;

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

namespace BeyondCode\Mailbox\Http\Controllers;

use BeyondCode\Mailbox\Http\Requests\ResendRequest;
use BeyondCode\Mailbox\Jobs\ProcessResendEmail;
use LogicException;

class ResendController
{
public function __invoke(ResendRequest $request)
{
if ($request->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);
}
}
82 changes: 82 additions & 0 deletions src/Http/Requests/ResendRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<?php

namespace BeyondCode\Mailbox\Http\Requests;

use BeyondCode\Mailbox\Support\ResendWebhookSignature;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Http\Exceptions\HttpResponseException;
use JsonException;
use LogicException;

class ResendRequest extends FormRequest
{
protected array $payload = [];

protected function prepareForValidation()
{
$secret = config('mailbox.services.resend.webhook_secret');
$signature = app(ResendWebhookSignature::class);

if (! is_string($secret) || ! $signature->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');
}
}
62 changes: 62 additions & 0 deletions src/Jobs/ProcessResendEmail.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?php

namespace BeyondCode\Mailbox\Jobs;

use BeyondCode\Mailbox\Clients\ResendClient;
use BeyondCode\Mailbox\Facades\Mailbox;
use BeyondCode\Mailbox\InboundEmail;
use BeyondCode\Mailbox\Queue\Middleware\ResendRateLimited;
use DateTimeInterface;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;

class ProcessResendEmail implements ShouldQueue
{
use InteractsWithQueue, Queueable;

public $timeout = 180;

public string $emailId;

public function __construct(string $emailId)
{
$this->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<InboundEmail> $modelClass */
$modelClass = config('mailbox.model');
$email = $modelClass::fromMessage($client->rawEmail($this->emailId));

Mailbox::callMailboxes($email);
}
}
Loading