From 0b60e35563f2ea9a5f8a391ba964f287e09dc3e3 Mon Sep 17 00:00:00 2001 From: Jason Wang Date: Tue, 28 Jul 2026 14:40:37 +0800 Subject: [PATCH 01/14] docs: design Resend inbound driver --- ...2026-07-28-resend-inbound-driver-design.md | 422 ++++++++++++++++++ 1 file changed, 422 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-28-resend-inbound-driver-design.md diff --git a/docs/superpowers/specs/2026-07-28-resend-inbound-driver-design.md b/docs/superpowers/specs/2026-07-28-resend-inbound-driver-design.md new file mode 100644 index 0000000..5677944 --- /dev/null +++ b/docs/superpowers/specs/2026-07-28-resend-inbound-driver-design.md @@ -0,0 +1,422 @@ +# Resend Inbound Driver Design + +Date: 2026-07-28 + +## Summary + +Add a first-party Resend inbound driver to `beyondcode/laravel-mailbox`. +The driver will verify Resend's Svix-signed `email.received` webhook, queue +processing on a configurable Laravel queue connection, retrieve the received +email's raw MIME through the Resend Receiving API, and pass that MIME to the +package's configured `InboundEmail` model. + +The implementation targets the current `master` compatibility matrix: +PHP 8.1 and Laravel 10 through 13. It does not target or preserve source +compatibility with laravel-mailbox 3.1 or Laravel 9. + +## Goals + +- Support `MAILBOX_DRIVER=resend`. +- Verify the exact raw webhook body using Resend's Svix headers and signing + secret before trusting any payload field. +- Retrieve the complete raw MIME, including attachments, instead of + reconstructing a message from Resend's parsed HTML and text fields. +- Support zero-worker local use through Laravel's `sync` queue connection. +- Support bursty and high-volume production use through a configurable + asynchronous queue connection and per-second API rate limiting. +- Preserve the existing configured-model path through + `InboundEmail::fromMessage()` and `Mailbox::callMailboxes()`. +- Cover the feature with deterministic tests that make no real network calls. +- Document setup, operational limits, retry behavior, and at-least-once + delivery semantics. + +## Non-goals + +- Supporting laravel-mailbox 3.1, Laravel 9, or PHP 8.0. +- Adding the official Resend PHP SDK or the Svix PHP SDK. +- Reconstructing MIME from Resend's parsed response fields. +- Adding a new database table or persistent webhook-event ledger. +- Providing exactly-once mailbox execution. +- Adding generic queue support to the existing Mailgun, Postmark, SendGrid, or + MailCare drivers. +- Changing the public mailbox routing API. +- Adding a hard maximum MIME size that differs from the existing drivers. + +## Dependencies + +Add these Illuminate components with the same version matrix already used by +the package: + +- `illuminate/http`: `^10.0|^11.0|^12.0|^13.0` +- `illuminate/bus`: `^10.0|^11.0|^12.0|^13.0` +- `illuminate/queue`: `^10.0|^11.0|^12.0|^13.0` +- `illuminate/cache`: `^10.0|^11.0|^12.0|^13.0` + +Laravel applications already receive these components through +`laravel/framework`. Declaring them explicitly keeps the package's component +dependencies accurate. No Resend-specific or Svix-specific Composer package +will be added. + +## Configuration + +Add `resend` to the documented list of supported drivers and add: + +```php +'services' => [ + // ... + '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), + ], +], +``` + +`queue_connection` deliberately defaults to `sync`. This preserves +zero-configuration behavior and propagates processing failures to the webhook +response. Production applications expecting bursts can select `redis`, +`database`, `sqs`, or another configured Laravel queue connection. + +`rate_limit` defaults to five Receiving API requests per second because +Resend's default API limit is five requests per second per team. Applications +that share the team-level limit with other workloads can configure a lower +value. Applications with a Resend-approved higher limit can configure a higher +value. + +The driver will throw a server error when an API key, webhook secret, queue +connection, or positive rate limit is not configured correctly. A deployment +configuration error must not be presented as an invalid third-party webhook. + +## Components + +### `Drivers\Resend` + +Register: + +```text +POST /{mailbox.path}/resend +``` + +The default endpoint is `POST /laravel-mailbox/resend`. The driver does not use +the package's HTTP basic-authentication middleware because authenticity comes +from the Svix signature. + +### `MailboxManager` + +Add `createResendDriver()` and import the new driver, matching the existing +driver factory pattern. + +### `MailboxServiceProvider` + +Register a package-specific queue rate limiter backed by Laravel's cache rate +limiter. The limiter reads `mailbox.services.resend.rate_limit` and permits +that many jobs per second. It is used only when the Resend job is dispatched +to an asynchronous connection. + +### `Http\Requests\ResendRequest` + +The request is the inbound security boundary. It will: + +1. Read the exact raw body with `getContent()`. +2. require the `svix-id`, `svix-timestamp`, and `svix-signature` headers; +3. call `Support\ResendWebhookSignature` before trusting parsed JSON; +4. parse the JSON payload; +5. expose the signed event type, `svix-id`, and `data.email_id`; +6. require a non-empty string `data.email_id` only for `email.received`. + +A correctly signed event with a different type is valid but irrelevant to the +inbound driver. It will be acknowledged with HTTP 204 so a mistakenly broad +Resend webhook subscription does not retry forever. + +### `Support\ResendWebhookSignature` + +Implement the small symmetric Svix verification surface without a vendor SDK. +The verifier will: + +- require a non-empty `whsec_` signing secret; +- Base64-decode the secret suffix strictly; +- require an integer Unix timestamp no more than 300 seconds in the past or + future; +- build the signed content as + `{svix-id}.{svix-timestamp}.{raw-body}`; +- calculate a raw HMAC-SHA256 digest and Base64-encode it; +- parse the space-delimited signature header; +- accept the request when any `v1,` entry matches; +- compare signatures with `hash_equals()`. + +The verifier will be isolated from HTTP and queue concerns so fixed vectors and +boundary conditions can be tested directly. + +### `Http\Controllers\ResendController` + +After request validation: + +- return 204 for a signed non-`email.received` event; +- create `Jobs\ProcessResendEmail` with only the signed `email_id` and + `svix-id`; +- select the configured queue connection; +- dispatch the job; +- return 200 after successful dispatch. + +When the configured connection is `sync`, dispatch executes the job in the +request and propagates exceptions as HTTP 5xx. With an asynchronous connection, +HTTP 200 means the job was accepted by Laravel's queue, not that mailbox +processing has completed. + +### `Jobs\ProcessResendEmail` + +The job will implement `ShouldQueue` and `ShouldBeUnique`. + +- `uniqueId()` returns the `svix-id`. +- The unique lock prevents the same delivery from being queued or processed + concurrently. +- The lock does not promise durable exactly-once behavior after completion. +- The job carries only scalar identifiers; it never serializes the request, + API key, webhook secret, MIME, or signed download URL. + +For asynchronous connections, the job applies Laravel queue rate-limiting +middleware under a package-specific limiter. The limiter reads +`mailbox.services.resend.rate_limit`. The `sync` connection relies on Resend's +webhook backoff instead of releasing a synchronous job. + +The job gets a fresh signed raw download URL during each attempt by invoking +`Clients\ResendClient` with the `email_id`. It then: + +1. resolves the configured mailbox model; +2. calls `::fromMessage($mime)`; +3. passes the resulting inbound email to `Mailbox::callMailboxes()`. + +The job uses a 24-hour `retryUntil()` window instead of a fixed attempt count. +Laravel's queue rate-limit middleware increments a job's attempt count when it +releases the job, so a small hard attempt limit could fail healthy jobs merely +because they waited for rate-limit capacity. + +Processing exceptions use this backoff sequence: + +```text +5 seconds, 1 minute, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours +``` + +After the final value, subsequent processing failures continue using the +10-hour delay until the 24-hour retry window ends. Rate-limit middleware +releases use short capacity-based delays instead of this exception backoff. +A job timeout of 180 seconds leaves room for a large MIME download and mailbox +parsing while still bounding a stuck worker. + +### `Clients\ResendClient` + +The client performs two separate requests. + +First, retrieve metadata: + +```text +GET https://api.resend.com/emails/receiving/{rawurlencoded-email-id} +Authorization: Bearer {api-key} +User-Agent: beyondcode/laravel-mailbox +``` + +The host and scheme for this authenticated request are fixed in code. The +request uses a five-second connection timeout and a 15-second total timeout. +It must return a successful JSON response with a non-empty +`raw.download_url`. + +Second, download the raw MIME: + +- require an HTTPS URL; +- do not send the Resend API key or package credentials; +- do not follow redirects; +- use a five-second connection timeout and a 120-second total timeout; +- require a successful response with a non-empty body. + +The signed URL is treated as a credential and must not be logged. The entire +MIME becomes a string because the existing configured-model contract accepts +the whole raw message and stores it in a `longText` column when matched. +Queue concurrency and rate limiting bound aggregate pressure; individual +workers still need enough memory for the largest accepted inbound email. + +Raw-download transport errors will be wrapped in a sanitized package exception +that does not include the signed URL, its query string, or the response body. +The original URL-bearing exception will not be chained into the new exception, +because Laravel's default failed-job logging may render chained exception +messages. + +## Request and Processing Flow + +```text +Resend + -> POST signed metadata webhook + -> ResendRequest verifies exact raw body + -> ResendController dispatches ProcessResendEmail + -> queue connection (sync or asynchronous) + -> ResendClient calls authenticated Receiving API + -> ResendClient downloads unauthenticated signed raw MIME URL + -> configured InboundEmail model::fromMessage(raw MIME) + -> Mailbox::callMailboxes() +``` + +The raw download URL is obtained inside the job, never before queueing, so its +short expiration does not make a delayed job unusable. + +## HTTP and Failure Semantics + +| Condition | Behavior | +| --- | --- | +| Missing Svix header | HTTP 401; do not dispatch | +| Invalid signature | HTTP 401; do not dispatch | +| Timestamp outside the 300-second window | HTTP 401; do not dispatch | +| Malformed JSON | HTTP 400; do not dispatch | +| `email.received` without a valid `data.email_id` | HTTP 400; do not dispatch | +| Valid signed non-`email.received` event | HTTP 204; do not dispatch | +| Invalid package configuration | HTTP 5xx | +| Queue dispatch failure | HTTP 5xx, allowing Resend to retry | +| Successful asynchronous dispatch | HTTP 200 | +| Successful synchronous processing | HTTP 200 | +| Receiving API, raw download, MIME conversion, or mailbox failure on `sync` | HTTP 5xx, allowing Resend to retry | +| The same failures on an asynchronous queue | Throw from the job and apply queue retry/backoff | + +All non-successful Receiving API responses, including 429 and 5xx, are +retriable processing failures. A malformed successful API response is also a +processing failure; silently rebuilding a partial email is not allowed. + +## Delivery and Idempotency + +Resend webhooks and Laravel queued jobs are at-least-once systems. +`ShouldBeUnique` keyed by `svix-id` prevents concurrent duplicate work, but it +does not create a permanent processed-event ledger. A manual Resend replay or +a failure after a mailbox handler has already produced a side effect can cause +the handler to run again. + +Documentation will tell consumers to make side-effecting handlers idempotent, +preferably by using the raw email's stable `Message-Id` as the business +deduplication key. The driver will not alter the MIME or replace its +`Message-Id` with Resend's API identifier. + +## Security + +- Verify the exact raw body before reading `type` or `data.email_id`. +- Reject secrets that are missing, malformed, or not valid strict Base64 after + the `whsec_` prefix. +- Bound both old and future timestamps to prevent replay and clock-skew abuse. +- Use constant-time signature comparison. +- Never log the API key, webhook secret, raw signature header, or signed + download URL. +- Sanitize raw-download exceptions before they can reach failed-job logging. +- Send the Bearer token only to the fixed Resend API origin. +- Require HTTPS for the returned download URL. +- Disable download redirects so a signed URL cannot redirect the client to an + unexpected destination. +- URL-encode the `email_id` path segment. +- Do not add HTTP basic authentication to the Resend route. + +## Testing + +All tests use Laravel, bus, queue, cache, and HTTP fakes. No test calls Resend. + +### Driver and route tests + +- Selecting `resend` creates and registers the driver. +- The configured mailbox path prefixes the Resend endpoint. +- The endpoint accepts POST and does not use basic-authentication middleware. + +### Signature tests + +- Verify a fixed, independently calculated Svix-compatible vector. +- Reject a one-byte raw-body change. +- Reject a JSON parse-and-reserialize change. +- Accept a matching `v1` entry among multiple signature entries. +- Reject unsupported signature versions without a matching `v1`. +- Reject a wrong secret or signature. +- Reject missing and malformed `whsec_` secrets. +- Reject malformed Base64. +- Reject missing headers. +- Accept timestamps on the permitted boundary. +- Reject old and future timestamps outside the 300-second window. + +### Request and controller tests + +- Missing or invalid signatures return 401 and dispatch nothing. +- Malformed JSON and missing `email_id` return 400 and dispatch nothing. +- Signed unrelated events return 204 and dispatch nothing. +- A signed received event dispatches the expected scalar identifiers. +- Dispatch uses the configured `sync` or asynchronous connection. +- A dispatch exception becomes HTTP 5xx. + +### Client tests + +- The metadata request uses the fixed Resend API origin, URL-encoded email ID, + Bearer token, and package User-Agent. +- The raw request uses the returned HTTPS URL without Authorization. +- Redirects are disabled for the raw request. +- A raw MIME fixture is returned byte-for-byte. +- Metadata 401, 404, 429, and 5xx responses throw. +- Raw-download failures throw. +- Missing, empty, malformed, or non-HTTPS `raw.download_url` values throw. +- An empty raw MIME response throws. +- Failure output and exceptions do not expose configured secrets or the signed + URL. + +### Job tests + +- The job's unique ID is the `svix-id`. +- The job exposes the approved 24-hour retry window, timeout, and exception + backoff sequence. +- Asynchronous processing supplies the configured rate-limit middleware. +- `sync` exceptions propagate to the request. +- Asynchronous exceptions remain queue failures. +- A realistic MIME fixture with an attachment reaches the configured model and + exposes the expected headers, body, and attachment after processing. +- The configured custom model is honored. +- `Mailbox::callMailboxes()` is invoked only after both HTTP requests succeed. + +Run the complete package test suite under the existing PHP 8.1 through 8.5 and +Laravel 10 through 13 CI matrix. + +## Documentation + +Update `config/mailbox.php` and `docs/drivers/drivers.md`. + +The Resend driver documentation will include: + +- required environment variables; +- `MAILBOX_DRIVER=resend`; +- the default `/laravel-mailbox/resend` endpoint; +- selecting only the `email.received` webhook event; +- copying the endpoint-specific Resend webhook signing secret; +- the distinction between the API key and webhook secret; +- why the driver retrieves raw MIME after receiving metadata; +- the default `sync` behavior; +- configuring an asynchronous queue and running workers for high volume; +- the default team-level five-request-per-second API limit and its config; +- retry behavior for sync and asynchronous modes; +- configuring the queue's `retry_after` above the job's 180-second timeout and + keeping the worker timeout below `retry_after`; +- at-least-once delivery and handler idempotency; +- worker memory considerations for MIME messages containing attachments. + +No README example, migration, changelog, or unrelated driver documentation +will be changed. + +## Success Criteria + +- A valid Resend `email.received` event produces the same configured + `InboundEmail` behavior as existing raw-MIME drivers, including attachments. +- Forged, stale, future-dated, or malformed webhook requests cannot cause an + API call or queue dispatch. +- The API key is never sent to the raw download origin. +- Local users can run with the default `sync` connection and no queue worker. +- High-volume users can select an asynchronous queue and cap Receiving API + calls at their configured per-second limit. +- Temporary API and download failures are retried through Resend in sync mode + or Laravel's queue in asynchronous mode. +- Tests pass across the repository's supported PHP and Laravel versions. +- The PR remains a single Resend inbound-driver feature with no unrelated + refactoring. + +## References + +- [Resend: Receiving Emails](https://resend.com/docs/dashboard/receiving/introduction) +- [Resend: Verify Webhook Requests](https://resend.com/docs/webhooks/verify-webhooks-requests) +- [Resend: Retrieve Received Email](https://resend.com/docs/api-reference/emails/retrieve-received-email) +- [Resend: Webhook Retries and Replays](https://resend.com/docs/webhooks/retries-and-replays) +- [Resend: API Usage Limits](https://resend.com/docs/api-reference/rate-limit) From d941c96e053814ee732697ed6ed2b1d46420b83c Mon Sep 17 00:00:00 2001 From: Jason Wang Date: Tue, 28 Jul 2026 15:43:23 +0800 Subject: [PATCH 02/14] docs: refine Resend queue rate limiting --- ...2026-07-28-resend-inbound-driver-design.md | 43 +++++++++++++------ 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/docs/superpowers/specs/2026-07-28-resend-inbound-driver-design.md b/docs/superpowers/specs/2026-07-28-resend-inbound-driver-design.md index 5677944..309ecc2 100644 --- a/docs/superpowers/specs/2026-07-28-resend-inbound-driver-design.md +++ b/docs/superpowers/specs/2026-07-28-resend-inbound-driver-design.md @@ -107,13 +107,6 @@ from the Svix signature. Add `createResendDriver()` and import the new driver, matching the existing driver factory pattern. -### `MailboxServiceProvider` - -Register a package-specific queue rate limiter backed by Laravel's cache rate -limiter. The limiter reads `mailbox.services.resend.rate_limit` and permits -that many jobs per second. It is used only when the Resend job is dispatched -to an asynchronous connection. - ### `Http\Requests\ResendRequest` The request is the inbound security boundary. It will: @@ -156,13 +149,14 @@ After request validation: - create `Jobs\ProcessResendEmail` with only the signed `email_id` and `svix-id`; - select the configured queue connection; -- dispatch the job; +- dispatch the job through Laravel's `dispatch()` helper; - return 200 after successful dispatch. When the configured connection is `sync`, dispatch executes the job in the request and propagates exceptions as HTTP 5xx. With an asynchronous connection, HTTP 200 means the job was accepted by Laravel's queue, not that mailbox -processing has completed. +processing has completed. Using the `dispatch()` helper also ensures Laravel +acquires the `ShouldBeUnique` lock before pushing the job. ### `Jobs\ProcessResendEmail` @@ -175,9 +169,8 @@ The job will implement `ShouldQueue` and `ShouldBeUnique`. - The job carries only scalar identifiers; it never serializes the request, API key, webhook secret, MIME, or signed download URL. -For asynchronous connections, the job applies Laravel queue rate-limiting -middleware under a package-specific limiter. The limiter reads -`mailbox.services.resend.rate_limit`. The `sync` connection relies on Resend's +For asynchronous connections, the job applies +`Queue\Middleware\ResendRateLimited`. The `sync` connection relies on Resend's webhook backoff instead of releasing a synchronous job. The job gets a fresh signed raw download URL during each attempt by invoking @@ -204,6 +197,27 @@ releases use short capacity-based delays instead of this exception backoff. A job timeout of 180 seconds leaves room for a large MIME download and mailbox parsing while still bounding a stuck worker. +### `Queue\Middleware\ResendRateLimited` + +Laravel 10's named rate-limit definition object supports minute windows but +does not expose `Limit::perSecond()`. To preserve the package's Laravel 10 +floor, a small package middleware will use the cross-version +`Illuminate\Cache\RateLimiter` API directly. + +For each asynchronous job attempt, the middleware will: + +1. read and validate the positive integer + `mailbox.services.resend.rate_limit`; +2. check a package-specific cache key against that maximum; +3. release an over-limit job for `availableIn($key) + 1` seconds; +4. otherwise call `hit($key, 1)` to consume one slot in a one-second window; +5. pass the job to the next middleware or handler. + +The middleware resolves `RateLimiter` from Laravel's container inside +`handle()`, so the queued middleware object contains no unserializable cache +service. It deliberately applies only to asynchronous connections because +releasing a job on the `sync` connection cannot defer work. + ### `Clients\ResendClient` The client performs two separate requests. @@ -361,7 +375,10 @@ All tests use Laravel, bus, queue, cache, and HTTP fakes. No test calls Resend. - The job's unique ID is the `svix-id`. - The job exposes the approved 24-hour retry window, timeout, and exception backoff sequence. -- Asynchronous processing supplies the configured rate-limit middleware. +- Asynchronous processing supplies `ResendRateLimited`; `sync` processing + supplies no queue middleware. +- The custom middleware consumes at most the configured number of cache + limiter hits in a one-second window and releases excess jobs. - `sync` exceptions propagate to the request. - Asynchronous exceptions remain queue failures. - A realistic MIME fixture with an attachment reaches the configured model and From 8367cdd4d0e368c4176fe135891f9caed82cdf88 Mon Sep 17 00:00:00 2001 From: Jason Wang Date: Tue, 28 Jul 2026 16:03:32 +0800 Subject: [PATCH 03/14] docs: plan Resend inbound implementation --- .../plans/2026-07-28-resend-inbound-driver.md | 1835 +++++++++++++++++ ...2026-07-28-resend-inbound-driver-design.md | 24 +- 2 files changed, 1850 insertions(+), 9 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-28-resend-inbound-driver.md diff --git a/docs/superpowers/plans/2026-07-28-resend-inbound-driver.md b/docs/superpowers/plans/2026-07-28-resend-inbound-driver.md new file mode 100644 index 0000000..4644ccb --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-resend-inbound-driver.md @@ -0,0 +1,1835 @@ +# Resend Inbound Driver Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a secure, queue-aware Resend inbound driver that verifies Svix webhooks, retrieves the complete raw MIME from Resend, and processes it through the existing mailbox model and router. + +**Architecture:** A Resend route validates the exact raw webhook body before dispatching a unique queue job. The job applies a Laravel 10-compatible one-second cache rate limiter on asynchronous connections, retrieves a fresh signed raw-MIME URL through a small Laravel HTTP client, and passes the MIME to the configured inbound model and mailbox router. + +**Tech Stack:** PHP 8.1+, Laravel/Illuminate 10–13, Orchestra Testbench, PHPUnit attributes, Laravel HTTP/Bus/Queue/Cache, zbateson/mail-mime-parser. + +## Global Constraints + +- Work only in `/Users/wangjiangyu/backend-projects/laravel-mailbox`; never touch `/private/var/www/auto-compliance`. +- Develop on `codex/resend-inbound-driver` and push only to `origin` (`jasonfish568/laravel-mailbox`); `upstream` is read-only. +- Support PHP `^8.1` and Illuminate `^10.0|^11.0|^12.0|^13.0`. +- Do not add `resend/resend-php`, `svix/svix`, or any other vendor SDK. +- Do not add a migration, webhook ledger, exactly-once claim, or queue behavior to existing drivers. +- Register `POST /{mailbox.path}/resend`; the default endpoint is `/laravel-mailbox/resend`. +- Verify `svix-id`, `svix-timestamp`, and `svix-signature` against the exact raw body with a 300-second past/future tolerance before trusting payload fields. +- Use `MAILBOX_RESEND_API_KEY`, `MAILBOX_RESEND_WEBHOOK_SECRET`, `MAILBOX_RESEND_QUEUE_CONNECTION`, and `MAILBOX_RESEND_RATE_LIMIT`. +- Default the queue connection to `sync` and the team-level API rate limit to five requests per second. +- Retrieve the raw MIME inside the job, not before dispatch; never reconstruct MIME from parsed HTML/text. +- Send the Bearer token only to the fixed `https://api.resend.com` origin and disable redirects on that request. +- Require HTTPS for the signed raw URL, disable redirects, and never leak the URL or credentials through exceptions or logs. +- Preserve at-least-once semantics and document handler idempotency. +- Follow existing PSR-2-style formatting and PHPUnit `#[Test]` attributes. +- Use TDD for every task: failing test, observed failure, minimal implementation, passing focused tests, then commit. + +## File Map + +### Create + +- `src/Drivers/Resend.php` — register the Resend webhook route. +- `src/Support/ResendWebhookSignature.php` — verify the symmetric Svix signature and timestamp. +- `src/Clients/ResendClient.php` — call the Receiving API and download sanitized raw MIME. +- `src/Queue/Middleware/ResendRateLimited.php` — enforce the configured one-second API window on asynchronous jobs. +- `src/Jobs/ProcessResendEmail.php` — unique, retryable job that converts MIME and invokes mailboxes. +- `src/Http/Requests/ResendRequest.php` — authenticate the raw webhook and validate its payload. +- `src/Http/Controllers/ResendController.php` — acknowledge unrelated events or dispatch the processing job. +- `tests/Drivers/ResendTest.php` — driver factory and route tests. +- `tests/Support/ResendWebhookSignatureTest.php` — fixed-vector and boundary tests. +- `tests/Clients/ResendClientTest.php` — authenticated metadata and unauthenticated raw-download tests. +- `tests/Queue/Middleware/ResendRateLimitedTest.php` — one-second limiter and invalid-config tests. +- `tests/Jobs/ProcessResendEmailTest.php` — job metadata, middleware, model, and mailbox tests. +- `tests/Concerns/SignsResendWebhooks.php` — deterministic signed-request test helper. +- `tests/Controllers/ResendTest.php` — request status, dispatch, sync integration, and attachment tests. +- `tests/Fixtures/resend-email.eml` — multipart MIME with a text attachment. + +### Modify + +- `composer.json` — explicitly require Illuminate HTTP, Bus, Queue, and Cache components. +- `config/mailbox.php` — expose the Resend driver, credentials, queue connection, and rate. +- `src/MailboxManager.php` — add `createResendDriver()`. +- `docs/drivers/drivers.md` — document setup, queues, limits, retries, and idempotency. + +--- + +### Task 1: Package Wiring and Resend Route + +**Files:** +- Create: `src/Drivers/Resend.php` +- Create: `tests/Drivers/ResendTest.php` +- Modify: `composer.json:18-27` +- Modify: `config/mailbox.php:5-11,57-66` +- Modify: `src/MailboxManager.php:5-10,39-47` + +**Interfaces:** +- Consumes: Existing `DriverInterface::register()` and `MailboxManager` driver factory convention. +- Produces: `BeyondCode\Mailbox\Drivers\Resend`, `MailboxManager::createResendDriver()`, the `/laravel-mailbox/resend` route, and `mailbox.services.resend.*` configuration used by all later tasks. + +- [ ] **Step 1: Install the baseline dependencies and prove the current suite is green** + +Run: + +```bash +composer install --no-interaction +composer test +``` + +Expected: Composer installs into ignored `vendor/` and the existing suite passes before feature changes. + +- [ ] **Step 2: Write the failing driver and route tests** + +Create `tests/Drivers/ResendTest.php`: + +```php +assertInstanceOf( + Resend::class, + $this->app->make(MailboxManager::class)->driver('resend') + ); + } + + #[Test] + public function it_registers_the_resend_webhook_route() + { + $route = collect($this->app['router']->getRoutes())->first(function ($route) { + return $route->uri() === 'laravel-mailbox/resend'; + }); + + $this->assertNotNull($route); + $this->assertContains('POST', $route->methods()); + $this->assertSame(ResendController::class, $route->getAction('uses')); + $this->assertNotContains('laravel-mailbox', $route->gatherMiddleware()); + } + + #[Test] + public function it_uses_the_configured_mailbox_path() + { + config(['mailbox.path' => 'custom-mailbox']); + + (new Resend)->register(); + + $route = collect($this->app['router']->getRoutes())->first(function ($route) { + return $route->uri() === 'custom-mailbox/resend'; + }); + + $this->assertNotNull($route); + } +} +``` + +- [ ] **Step 3: Run the test and observe the missing-driver failure** + +Run: + +```bash +vendor/bin/phpunit tests/Drivers/ResendTest.php +``` + +Expected: FAIL during package boot or driver resolution because `createResendDriver()` and `Drivers\Resend` do not exist. + +- [ ] **Step 4: Add the explicit component requirements** + +Extend `composer.json` `require` in sorted order: + +```json +"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" +``` + +Run: + +```bash +composer update illuminate/bus illuminate/cache illuminate/http illuminate/queue --with-all-dependencies --no-interaction +composer validate --strict +``` + +Expected: dependency resolution succeeds for the current environment; `composer.lock` remains ignored. + +- [ ] **Step 5: Add the configuration and driver factory** + +Add this service configuration to `config/mailbox.php` and include `"resend"` in the supported-driver comment: + +```php +'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), +], +``` + +Import the driver and add to `MailboxManager`: + +```php +use BeyondCode\Mailbox\Drivers\Resend; + +public function createResendDriver() +{ + return new Resend; +} +``` + +- [ ] **Step 6: Implement the route-only driver** + +Create `src/Drivers/Resend.php`: + +```php +group(function () { + Route::post('/resend', ResendController::class); + }); + } +} +``` + +- [ ] **Step 7: Run focused and package tests** + +Run: + +```bash +vendor/bin/phpunit tests/Drivers/ResendTest.php +composer test +``` + +Expected: both pass. Referencing `ResendController::class` is safe before its file is created because the route stores the class name as a string and this task does not invoke the endpoint. + +- [ ] **Step 8: Commit the wiring** + +```bash +git add composer.json config/mailbox.php src/Drivers/Resend.php src/MailboxManager.php tests/Drivers/ResendTest.php +git commit -m "feat: register Resend inbound driver" +``` + +--- + +### Task 2: Svix Webhook Signature Verification + +**Files:** +- Create: `src/Support/ResendWebhookSignature.php` +- Create: `tests/Support/ResendWebhookSignatureTest.php` + +**Interfaces:** +- Consumes: `Carbon\Carbon::now()` for deterministic clock control. +- Produces: `ResendWebhookSignature::verify(string $payload, ?string $id, ?string $timestamp, ?string $signatures, ?string $secret): bool` for `ResendRequest`. + +- [ ] **Step 1: Write all fixed-vector, raw-body, and edge tests** + +Create `tests/Support/ResendWebhookSignatureTest.php` with the independently +calculated signature `d36LOzwlBe0bKqKR8+qDaX65GyQsZLnNBqd0FeDwie8=`: + +```php +assertTrue((new ResendWebhookSignature)->verify( + self::PAYLOAD, + 'msg_test', + self::TIMESTAMP, + 'v1,'.self::SIGNATURE, + self::SECRET + )); + } + + #[Test] + public function it_rejects_any_raw_body_change() + { + $this->assertFalse((new ResendWebhookSignature)->verify( + self::PAYLOAD." ", + 'msg_test', + self::TIMESTAMP, + 'v1,'.self::SIGNATURE, + self::SECRET + )); + } + + #[Test] + public function it_rejects_json_that_was_parsed_and_reserialized() + { + $reserialized = json_encode( + json_decode(self::PAYLOAD, true), + JSON_PRETTY_PRINT + ); + + $this->assertFalse((new ResendWebhookSignature)->verify( + $reserialized, + 'msg_test', + self::TIMESTAMP, + 'v1,'.self::SIGNATURE, + self::SECRET + )); + } + + #[Test] + public function it_accepts_any_matching_v1_signature() + { + $header = 'v1,invalid v2,invalid v1,'.self::SIGNATURE; + + $this->assertTrue((new ResendWebhookSignature)->verify( + self::PAYLOAD, + 'msg_test', + self::TIMESTAMP, + $header, + self::SECRET + )); + } + + #[Test] + #[DataProvider('invalidInputs')] + public function it_rejects_invalid_signature_inputs( + ?string $id, + ?string $timestamp, + ?string $signature, + ?string $secret + ) { + $this->assertFalse((new ResendWebhookSignature)->verify( + self::PAYLOAD, + $id, + $timestamp, + $signature, + $secret + )); + } + + public static function invalidInputs(): array + { + return [ + 'missing id' => [null, self::TIMESTAMP, 'v1,'.self::SIGNATURE, self::SECRET], + 'missing timestamp' => ['msg_test', null, 'v1,'.self::SIGNATURE, self::SECRET], + 'missing signature' => ['msg_test', self::TIMESTAMP, null, self::SECRET], + 'missing secret' => ['msg_test', self::TIMESTAMP, 'v1,'.self::SIGNATURE, null], + 'wrong prefix' => ['msg_test', self::TIMESTAMP, 'v1,'.self::SIGNATURE, 'secret'], + 'bad base64' => ['msg_test', self::TIMESTAMP, 'v1,'.self::SIGNATURE, 'whsec_%%%'], + 'empty decoded secret' => ['msg_test', self::TIMESTAMP, 'v1,'.self::SIGNATURE, 'whsec_'], + 'wrong secret' => ['msg_test', self::TIMESTAMP, 'v1,'.self::SIGNATURE, 'whsec_b3RoZXItc2VjcmV0'], + 'malformed timestamp' => ['msg_test', 'not-an-integer', 'v1,'.self::SIGNATURE, self::SECRET], + 'wrong signature' => ['msg_test', self::TIMESTAMP, 'v1,invalid', self::SECRET], + 'unsupported version' => ['msg_test', self::TIMESTAMP, 'v2,'.self::SIGNATURE, self::SECRET], + ]; + } + + #[Test] + public function it_enforces_both_timestamp_boundaries() + { + $verifier = new ResendWebhookSignature; + $pastBoundary = (string) (now()->timestamp - 300); + $futureBoundary = (string) (now()->timestamp + 300); + $tooOld = (string) (now()->timestamp - 301); + $tooFarInFuture = (string) (now()->timestamp + 301); + + $this->assertTrue($verifier->verify( + self::PAYLOAD, + 'msg_test', + $pastBoundary, + 'v1,'.$this->signature($pastBoundary), + self::SECRET + )); + $this->assertTrue($verifier->verify( + self::PAYLOAD, + 'msg_test', + $futureBoundary, + 'v1,'.$this->signature($futureBoundary), + self::SECRET + )); + $this->assertFalse($verifier->verify( + self::PAYLOAD, + 'msg_test', + $tooOld, + 'v1,'.$this->signature($tooOld), + self::SECRET + )); + $this->assertFalse($verifier->verify( + self::PAYLOAD, + 'msg_test', + $tooFarInFuture, + 'v1,'.$this->signature($tooFarInFuture), + self::SECRET + )); + } + + private function signature(string $timestamp): string + { + return base64_encode(hash_hmac( + 'sha256', + 'msg_test.'.$timestamp.'.'.self::PAYLOAD, + 'test-secret', + true + )); + } +} +``` + +- [ ] **Step 2: Run the test and observe the missing-class failure** + +Run: + +```bash +vendor/bin/phpunit tests/Support/ResendWebhookSignatureTest.php +``` + +Expected: FAIL because `BeyondCode\Mailbox\Support\ResendWebhookSignature` does not exist. + +- [ ] **Step 3: Implement the minimal verifier** + +Create `src/Support/ResendWebhookSignature.php`: + +```php +timestamp; + + if ($timestamp < $now - static::TOLERANCE || $timestamp > $now + static::TOLERANCE) { + return false; + } + + $expected = base64_encode(hash_hmac( + 'sha256', + $id.'.'.$timestamp.'.'.$payload, + $decodedSecret, + true + )); + + foreach (preg_split('/\s+/', trim($signatures)) as $signature) { + [$version, $value] = array_pad(explode(',', $signature, 2), 2, null); + + if ($version === 'v1' && is_string($value) && hash_equals($expected, $value)) { + return true; + } + } + + return false; + } +} +``` + +- [ ] **Step 4: Run focused and package tests** + +Run: + +```bash +vendor/bin/phpunit tests/Support/ResendWebhookSignatureTest.php +composer test +``` + +Expected: all signature cases and the full package suite pass. + +- [ ] **Step 5: Commit the verifier** + +```bash +git add src/Support/ResendWebhookSignature.php tests/Support/ResendWebhookSignatureTest.php +git commit -m "feat: verify Resend webhook signatures" +``` + +--- + +### Task 3: Resend Receiving API Client + +**Files:** +- Create: `src/Clients/ResendClient.php` +- Create: `tests/Clients/ResendClientTest.php` + +**Interfaces:** +- Consumes: `mailbox.services.resend.api_key` and Laravel's `Http` facade. +- Produces: `ResendClient::rawEmail(string $emailId): string` for `ProcessResendEmail`. + +- [ ] **Step 1: Write successful, failure, and sanitization tests** + +Create `tests/Clients/ResendClientTest.php`: + +```php + 're_test']); + Http::preventStrayRequests(); + } + + #[Test] + public function it_retrieves_raw_email_without_forwarding_the_api_key() + { + $downloadUrl = 'https://inbound-cdn.resend.com/raw/email?signature=secret'; + $mime = "From: sender@example.com\r\nTo: inbox@example.com\r\n\r\nHello"; + + Http::fake([ + ResendClient::API_URL.'/email%2F123' => Http::response([ + 'raw' => ['download_url' => $downloadUrl], + ]), + $downloadUrl => Http::response($mime), + ]); + + $this->assertSame($mime, (new ResendClient)->rawEmail('email/123')); + + Http::assertSent(function (Request $request) { + return $request->url() === ResendClient::API_URL.'/email%2F123' + && $request->hasHeader('Authorization', 'Bearer re_test') + && $request->hasHeader('User-Agent', ResendClient::USER_AGENT); + }); + + Http::assertSent(function (Request $request) use ($downloadUrl) { + return $request->url() === $downloadUrl + && ! $request->hasHeader('Authorization'); + }); + } + + #[Test] + public function it_requires_an_api_key() + { + config(['mailbox.services.resend.api_key' => null]); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Resend API key is not configured.'); + + (new ResendClient)->rawEmail('email_123'); + } + + #[Test] + #[DataProvider('metadataFailureStatuses')] + public function it_throws_for_metadata_failures_without_exposing_the_api_key(int $status) + { + Http::fake([ + ResendClient::API_URL.'/email_123' => Http::response('', $status), + ]); + + $exception = null; + + try { + (new ResendClient)->rawEmail('email_123'); + } catch (Throwable $caught) { + $exception = $caught; + } + + $this->assertNotNull($exception); + $this->assertStringNotContainsString('re_test', (string) $exception); + Http::assertSentCount(1); + } + + public static function metadataFailureStatuses(): array + { + return [ + 'redirect' => [302], + 'unauthorized' => [401], + 'not found' => [404], + 'rate limited' => [429], + 'server error' => [500], + ]; + } + + #[Test] + #[DataProvider('invalidDownloadUrls')] + public function it_rejects_invalid_raw_download_urls($downloadUrl) + { + Http::fake([ + ResendClient::API_URL.'/email_123' => Http::response([ + 'raw' => ['download_url' => $downloadUrl], + ]), + ]); + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Resend did not return a valid raw email download URL.'); + + (new ResendClient)->rawEmail('email_123'); + } + + public static function invalidDownloadUrls(): array + { + return [ + 'missing' => [null], + 'empty' => [''], + 'malformed' => ['not a url'], + 'http' => ['http://example.com/raw'], + 'credentials' => ['https://user:pass@example.com/raw'], + ]; + } + + #[Test] + #[DataProvider('rawDownloadFailures')] + public function it_sanitizes_raw_download_failures(int $status, string $body) + { + $downloadUrl = 'https://inbound-cdn.resend.com/raw/email?signature=secret'; + + Http::fake([ + ResendClient::API_URL.'/email_123' => Http::response([ + 'raw' => ['download_url' => $downloadUrl], + ]), + $downloadUrl => Http::response( + $body, + $status, + $status === 302 ? ['Location' => 'https://example.com/redirected'] : [] + ), + ]); + + try { + (new ResendClient)->rawEmail('email_123'); + $this->fail('Expected raw download failure.'); + } catch (RuntimeException $exception) { + $this->assertSame('Unable to download the raw Resend email.', $exception->getMessage()); + $this->assertNull($exception->getPrevious()); + $this->assertStringNotContainsString('signature=secret', (string) $exception); + } + + Http::assertSentCount(2); + } + + public static function rawDownloadFailures(): array + { + return [ + 'empty success' => [200, ''], + 'redirect' => [302, 'redirect'], + 'server error' => [500, 'download failed'], + ]; + } +} +``` + +- [ ] **Step 2: Run the test and observe the missing-client failure** + +Run: + +```bash +vendor/bin/phpunit tests/Clients/ResendClientTest.php +``` + +Expected: FAIL because `BeyondCode\Mailbox\Clients\ResendClient` does not exist. + +- [ ] **Step 3: Implement the authenticated metadata request** + +Start `src/Clients/ResendClient.php` with: + +```php +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); + } +``` + +- [ ] **Step 4: Implement the isolated raw download and URL validation** + +Complete the class with: + +```php + 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']); + } +} +``` + +- [ ] **Step 5: Run focused and package tests** + +Run: + +```bash +vendor/bin/phpunit tests/Clients/ResendClientTest.php +composer test +``` + +Expected: all client cases pass; no request escapes `Http::fake()`. + +- [ ] **Step 6: Commit the client** + +```bash +git add src/Clients/ResendClient.php tests/Clients/ResendClientTest.php +git commit -m "feat: retrieve Resend raw emails" +``` + +--- + +### Task 4: Queue Rate Limiting and Email Processing Job + +**Files:** +- Create: `src/Queue/Middleware/ResendRateLimited.php` +- Create: `src/Jobs/ProcessResendEmail.php` +- Create: `tests/Queue/Middleware/ResendRateLimitedTest.php` +- Create: `tests/Jobs/ProcessResendEmailTest.php` + +**Interfaces:** +- Consumes: `ResendClient::rawEmail(string): string`, `mailbox.model`, `mailbox.services.resend.rate_limit`, `Mailbox::callMailboxes()`. +- Produces: `ProcessResendEmail::__construct(string $emailId, string $webhookId)`, `uniqueId(): string`, `backoff(): array`, `retryUntil(): DateTimeInterface`, `middleware(): array`, and `handle(ResendClient): void` for the controller and queue worker. + +- [ ] **Step 1: Write the one-second middleware tests** + +Create `tests/Queue/Middleware/ResendRateLimitedTest.php`: + +```php +app->make(RateLimiter::class)->clear(ResendRateLimited::KEY); + + parent::tearDown(); + } + + #[Test] + public function it_releases_jobs_over_the_per_second_limit() + { + config(['mailbox.services.resend.rate_limit' => 1]); + + $job = Mockery::mock(); + $job->shouldReceive('release') + ->once() + ->with(Mockery::on(fn ($seconds) => $seconds >= 1 && $seconds <= 2)) + ->andReturn(false); + + $handled = 0; + $next = function () use (&$handled) { + $handled++; + }; + + $middleware = new ResendRateLimited; + $middleware->handle($job, $next); + $middleware->handle($job, $next); + + $this->assertSame(1, $handled); + } + + #[Test] + public function it_rejects_a_non_positive_limit() + { + config(['mailbox.services.resend.rate_limit' => 0]); + + $this->expectException(LogicException::class); + + (new ResendRateLimited)->handle(Mockery::mock(), fn () => null); + } +} +``` + +- [ ] **Step 2: Write the job metadata and handler tests** + +Create `tests/Jobs/ProcessResendEmailTest.php`: + +```php +onConnection('sync'); + $queuedJob = (new ProcessResendEmail('email_123', 'msg_123'))->onConnection('redis'); + + $this->assertSame('msg_123', $queuedJob->uniqueId()); + $this->assertSame([5, 60, 300, 1800, 7200, 18000, 36000], $queuedJob->backoff()); + $this->assertSame(now()->addDay()->timestamp, $queuedJob->retryUntil()->timestamp); + $this->assertSame(180, $queuedJob->timeout); + $this->assertSame([], $syncJob->middleware()); + $this->assertInstanceOf(ResendRateLimited::class, $queuedJob->middleware()[0]); + } + + #[Test] + public function it_builds_the_configured_model_and_calls_mailboxes() + { + config(['mailbox.model' => TestResendInboundEmail::class]); + + $client = Mockery::mock(ResendClient::class); + $client->shouldReceive('rawEmail')->once()->with('email_123')->andReturn('raw mime'); + + Mailbox::shouldReceive('callMailboxes') + ->once() + ->with(Mockery::type(TestResendInboundEmail::class)); + + (new ProcessResendEmail('email_123', 'msg_123'))->handle($client); + + $this->assertSame('raw mime', TestResendInboundEmail::$rawMessage); + } + + #[Test] + public function it_does_not_call_mailboxes_when_retrieval_fails() + { + $expected = new RuntimeException('api failed'); + $client = Mockery::mock(ResendClient::class); + $client->shouldReceive('rawEmail') + ->once() + ->with('email_123') + ->andThrow($expected); + + Mailbox::shouldReceive('callMailboxes')->never(); + + try { + (new ProcessResendEmail('email_123', 'msg_123'))->handle($client); + $this->fail('Expected the client exception.'); + } catch (RuntimeException $exception) { + $this->assertSame($expected, $exception); + } + } +} + +class TestResendInboundEmail extends InboundEmail +{ + public static ?string $rawMessage = null; + + public static function fromMessage($message) + { + static::$rawMessage = $message; + + return parent::fromMessage($message); + } +} +``` + +- [ ] **Step 3: Run the tests and observe both missing-class failures** + +Run: + +```bash +vendor/bin/phpunit tests/Queue/Middleware/ResendRateLimitedTest.php tests/Jobs/ProcessResendEmailTest.php +``` + +Expected: FAIL because the middleware and job do not exist. + +- [ ] **Step 4: Implement the Laravel 10-compatible one-second middleware** + +Create `src/Queue/Middleware/ResendRateLimited.php`: + +```php +tooManyAttempts(static::KEY, $maxAttempts)) { + return $job->release($limiter->availableIn(static::KEY) + 1); + } + + $limiter->hit(static::KEY, 1); + + return $next($job); + } +} +``` + +- [ ] **Step 5: Implement the unique processing job** + +Create `src/Jobs/ProcessResendEmail.php`: + +```php +emailId = $emailId; + $this->webhookId = $webhookId; + } + + public function uniqueId(): string + { + return $this->webhookId; + } + + 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->connection === 'sync' ? [] : [new ResendRateLimited]; + } + + public function handle(ResendClient $client): void + { + /** @var class-string $modelClass */ + $modelClass = config('mailbox.model'); + $email = $modelClass::fromMessage($client->rawEmail($this->emailId)); + + Mailbox::callMailboxes($email); + } +} +``` + +- [ ] **Step 6: Run focused and package tests** + +Run: + +```bash +vendor/bin/phpunit tests/Queue/Middleware/ResendRateLimitedTest.php tests/Jobs/ProcessResendEmailTest.php +composer test +``` + +Expected: middleware and job tests pass, followed by the complete suite. + +- [ ] **Step 7: Commit queue processing** + +```bash +git add src/Queue/Middleware/ResendRateLimited.php src/Jobs/ProcessResendEmail.php tests/Queue/Middleware/ResendRateLimitedTest.php tests/Jobs/ProcessResendEmailTest.php +git commit -m "feat: process Resend emails through queues" +``` + +--- + +### Task 5: Authenticated Webhook Endpoint and Sync Integration + +**Files:** +- Create: `src/Http/Requests/ResendRequest.php` +- Create: `src/Http/Controllers/ResendController.php` +- Create: `tests/Concerns/SignsResendWebhooks.php` +- Create: `tests/Controllers/ResendTest.php` +- Create: `tests/Fixtures/resend-email.eml` + +**Interfaces:** +- Consumes: `ResendWebhookSignature::verify(...)`, `ProcessResendEmail`, `dispatch()`, the Resend route from Task 1, and the client/job from Tasks 3–4. +- Produces: HTTP 401/400/204/200/5xx behavior and the complete webhook-to-mailbox flow. + +- [ ] **Step 1: Create the deterministic signed-request test helper** + +Create `tests/Concerns/SignsResendWebhooks.php`: + +```php +timestamp; + $secret = 'test-secret'; + + if ($configureSecret) { + config([ + 'mailbox.services.resend.webhook_secret' => 'whsec_'.base64_encode($secret), + ]); + } + + $signature = $signature ?? base64_encode(hash_hmac( + 'sha256', + $webhookId.'.'.$timestamp.'.'.$payload, + $secret, + true + )); + + return $this->call('POST', '/laravel-mailbox/resend', [], [], [], [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_SVIX_ID' => $webhookId, + 'HTTP_SVIX_TIMESTAMP' => (string) $timestamp, + 'HTTP_SVIX_SIGNATURE' => 'v1,'.$signature, + ], $payload); + } +} +``` + +- [ ] **Step 2: Write authentication, validation, and dispatch tests** + +Create `tests/Controllers/ResendTest.php`: + +```php + 'email.received', + 'data' => ['email_id' => 'email_123'], + ]); + + $this->postResendWebhook($payload, signature: 'invalid')->assertStatus(401); + + Bus::assertNothingDispatched(); + } + + #[Test] + public function it_rejects_missing_svix_headers_without_dispatching() + { + Bus::fake(); + config([ + 'mailbox.services.resend.webhook_secret' => 'whsec_'.base64_encode('test-secret'), + ]); + + $this->call( + 'POST', + '/laravel-mailbox/resend', + [], + [], + [], + ['CONTENT_TYPE' => 'application/json'], + '{"type":"email.received","data":{"email_id":"email_123"}}' + )->assertStatus(401); + + Bus::assertNothingDispatched(); + } + + #[Test] + public function it_rejects_stale_webhooks_without_dispatching() + { + Bus::fake(); + + $this->postResendWebhook( + '{"type":"email.received","data":{"email_id":"email_123"}}', + timestamp: now()->timestamp - 301 + )->assertStatus(401); + + Bus::assertNothingDispatched(); + } + + #[Test] + public function it_rejects_malformed_signed_json_without_dispatching() + { + Bus::fake(); + + $this->postResendWebhook('{"type":') + ->assertStatus(400); + + Bus::assertNothingDispatched(); + } + + #[Test] + public function it_rejects_invalid_received_payloads() + { + Bus::fake(); + + $this->postResendWebhook('{"type":"email.received","data":{}}') + ->assertStatus(400); + + Bus::assertNothingDispatched(); + } + + #[Test] + public function it_acknowledges_signed_unrelated_events() + { + Bus::fake(); + + $this->postResendWebhook('{"type":"email.delivered","data":{}}') + ->assertNoContent(); + + Bus::assertNothingDispatched(); + } + + #[Test] + public function it_dispatches_received_emails_to_the_configured_connection() + { + Bus::fake(); + config(['mailbox.services.resend.queue_connection' => 'redis']); + + $this->postResendWebhook( + '{"type":"email.received","data":{"email_id":"email_123"}}', + 'msg_123' + )->assertOk(); + + Bus::assertDispatched(ProcessResendEmail::class, function ($job) { + return $job->emailId === 'email_123' + && $job->webhookId === 'msg_123' + && $job->connection === 'redis'; + }); + } + + #[Test] + #[DataProvider('invalidQueueConnections')] + public function it_rejects_invalid_queue_configuration($connection) + { + Bus::fake(); + config(['mailbox.services.resend.queue_connection' => $connection]); + + $this->postResendWebhook( + '{"type":"email.received","data":{"email_id":"email_123"}}' + )->assertStatus(500); + + Bus::assertNothingDispatched(); + } + + public static function invalidQueueConnections(): array + { + return [ + 'null' => [null], + 'empty string' => [''], + ]; + } + + #[Test] + #[DataProvider('invalidRateLimits')] + public function it_rejects_invalid_rate_limit_configuration($rateLimit) + { + Bus::fake(); + config(['mailbox.services.resend.rate_limit' => $rateLimit]); + + $this->postResendWebhook( + '{"type":"email.received","data":{"email_id":"email_123"}}' + )->assertStatus(500); + + Bus::assertNothingDispatched(); + } + + public static function invalidRateLimits(): array + { + return [ + 'zero' => [0], + 'negative' => [-1], + 'non-numeric' => ['invalid'], + ]; + } + + #[Test] + public function it_reports_a_missing_webhook_secret_as_server_configuration() + { + Bus::fake(); + $payload = '{"type":"email.received","data":{"email_id":"email_123"}}'; + + $this->postResendWebhook( + $payload, + configureSecret: false + )->assertStatus(500); + + Bus::assertNothingDispatched(); + } + + #[Test] + public function it_releases_the_unique_lock_when_dispatch_fails() + { + $original = $this->app->make(Dispatcher::class); + $dispatcher = Mockery::mock(Dispatcher::class); + $dispatcher->shouldReceive('dispatch') + ->once() + ->andThrow(new RuntimeException('queue unavailable')); + + $this->app->instance(Dispatcher::class, $dispatcher); + + $payload = '{"type":"email.received","data":{"email_id":"email_123"}}'; + + try { + $this->postResendWebhook($payload, 'msg_retry')->assertStatus(500); + } finally { + $this->app->instance(Dispatcher::class, $original); + } + + Bus::fake(); + + $this->postResendWebhook($payload, 'msg_retry')->assertOk(); + Bus::assertDispatched(ProcessResendEmail::class); + } +} +``` + +- [ ] **Step 3: Add a realistic multipart MIME fixture** + +Create `tests/Fixtures/resend-email.eml`: + +```text +From: Resend Sender +To: inbox@example.com +Subject: Inbound from Resend +Message-ID: +Date: Tue, 28 Jul 2026 10:00:00 +0800 +MIME-Version: 1.0 +Content-Type: multipart/mixed; boundary="resend-boundary" + +--resend-boundary +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 7bit + +Hello from Resend. +--resend-boundary +Content-Type: text/plain; name="note.txt" +Content-Disposition: attachment; filename="note.txt" +Content-Transfer-Encoding: base64 + +YXR0YWNobWVudCBjb250ZW50 +--resend-boundary-- +``` + +- [ ] **Step 4: Add synchronous integration, failure-ordering, and custom-model tests** + +Insert these methods into `ResendTest` before its closing brace: + +```php + #[Test] + public function it_processes_raw_mime_and_attachments_on_the_sync_connection() + { + $downloadUrl = 'https://inbound-cdn.resend.com/raw/email?signature=test'; + $mime = file_get_contents(__DIR__.'/../Fixtures/resend-email.eml'); + $handled = false; + + config([ + 'mailbox.services.resend.api_key' => 're_test', + 'mailbox.services.resend.queue_connection' => 'sync', + ]); + + Http::fake([ + ResendClient::API_URL.'/email_123' => Http::response([ + 'raw' => ['download_url' => $downloadUrl], + ]), + $downloadUrl => Http::response($mime), + ]); + + Mailbox::to('inbox@example.com', function (InboundEmail $email) use (&$handled) { + $handled = true; + + $this->assertSame('Inbound from Resend', $email->subject()); + $this->assertSame('Hello from Resend.', trim($email->text())); + $this->assertCount(1, $email->attachments()); + }); + + $this->postResendWebhook( + '{"type":"email.received","data":{"email_id":"email_123"}}', + 'msg_123' + )->assertOk(); + + $this->assertTrue($handled); + Http::assertSentCount(2); + } + + #[Test] + public function it_returns_a_server_error_without_calling_mailboxes_when_sync_retrieval_fails() + { + $handled = false; + + config([ + 'mailbox.services.resend.api_key' => 're_test', + 'mailbox.services.resend.queue_connection' => 'sync', + ]); + + Http::fake([ + ResendClient::API_URL.'/email_123' => Http::response('', 500), + ]); + + Mailbox::to('inbox@example.com', function () use (&$handled) { + $handled = true; + }); + + $this->postResendWebhook( + '{"type":"email.received","data":{"email_id":"email_123"}}', + 'msg_failure' + )->assertStatus(500); + + $this->assertFalse($handled); + Http::assertSentCount(1); + } + + #[Test] + public function it_honors_the_configured_model() + { + $downloadUrl = 'https://inbound-cdn.resend.com/raw/email?signature=test'; + + config([ + 'mailbox.model' => TestResendControllerInboundEmail::class, + 'mailbox.services.resend.api_key' => 're_test', + 'mailbox.services.resend.queue_connection' => 'sync', + ]); + + Http::fake([ + ResendClient::API_URL.'/email_123' => Http::response([ + 'raw' => ['download_url' => $downloadUrl], + ]), + $downloadUrl => Http::response('raw mime'), + ]); + + Mailbox::shouldReceive('callMailboxes') + ->once() + ->with(Mockery::type(TestResendControllerInboundEmail::class)); + + $this->postResendWebhook( + '{"type":"email.received","data":{"email_id":"email_123"}}', + 'msg_model' + )->assertOk(); + + $this->assertSame('raw mime', TestResendControllerInboundEmail::$rawMessage); + } +``` + +Append the controller-local model after `ResendTest`: + +```php +class TestResendControllerInboundEmail extends InboundEmail +{ + public static ?string $rawMessage = null; + + public static function fromMessage($message) + { + static::$rawMessage = $message; + + return parent::fromMessage($message); + } +} +``` + +- [ ] **Step 5: Run endpoint tests and observe the missing request/controller failure** + +Run: + +```bash +vendor/bin/phpunit tests/Controllers/ResendTest.php +``` + +Expected: FAIL because the registered route's request and controller classes do not exist. + +- [ ] **Step 6: Implement the authenticated FormRequest** + +Create `src/Http/Requests/ResendRequest.php`: + +```php +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.'); + } + + public function rules() + { + return [ + 'type' => ['required', 'string'], + 'data' => ['required', '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->input('type'); + } + + public function emailId(): ?string + { + return $this->input('data.email_id'); + } + + public function webhookId(): string + { + return $this->header('svix-id'); + } +} +``` + +- [ ] **Step 7: Implement the dispatching controller** + +Create `src/Http/Controllers/ResendController.php`: + +```php +eventType() !== 'email.received') { + return response('', 204); + } + + $connection = config('mailbox.services.resend.queue_connection', 'sync'); + $rateLimit = (int) config('mailbox.services.resend.rate_limit', 5); + + 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.'); + } + + $job = (new ProcessResendEmail($request->emailId(), $request->webhookId())) + ->onConnection($connection); + + try { + dispatch($job); + } catch (Throwable $exception) { + (new UniqueLock(app(Cache::class)))->release($job); + + throw $exception; + } + + return response('', 200); + } +} +``` + +- [ ] **Step 8: Run all Resend tests and the package suite** + +Run: + +```bash +vendor/bin/phpunit tests/Drivers/ResendTest.php tests/Support/ResendWebhookSignatureTest.php tests/Clients/ResendClientTest.php tests/Queue/Middleware/ResendRateLimitedTest.php tests/Jobs/ProcessResendEmailTest.php tests/Controllers/ResendTest.php +composer test +``` + +Expected: all Resend-focused tests and the complete existing suite pass. + +- [ ] **Step 9: Commit the endpoint** + +```bash +git add src/Http/Requests/ResendRequest.php src/Http/Controllers/ResendController.php tests/Concerns/SignsResendWebhooks.php tests/Controllers/ResendTest.php tests/Fixtures/resend-email.eml +git commit -m "feat: accept Resend inbound webhooks" +``` + +--- + +### Task 6: Driver Documentation and Final Verification + +**Files:** +- Modify: `docs/drivers/drivers.md` + +**Interfaces:** +- Consumes: Every public configuration key and operational behavior implemented in Tasks 1–5. +- Produces: User-facing setup and operations guidance; no new runtime API. + +- [ ] **Step 1: Add the Resend driver documentation** + +Insert this section before `## Local development / log driver` in +`docs/drivers/drivers.md`: + +````markdown +## 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 +``` + +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. Configure the queue connection's `retry_after` +above 180 seconds and keep the worker's `--timeout` below `retry_after`. +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. +```` + +- [ ] **Step 2: Run documentation and code hygiene checks** + +Run: + +```bash +composer validate --strict +git diff --check +``` + +Expected: both exit successfully. + +- [ ] **Step 3: Run the complete test suite** + +Run: + +```bash +composer test +``` + +Expected: every existing and new test passes. + +- [ ] **Step 4: Review the final feature diff** + +Run: + +```bash +git status --short +git diff --stat upstream/master...HEAD +git diff upstream/master...HEAD -- composer.json config/mailbox.php src tests docs/drivers/drivers.md +``` + +Expected: + +- only Resend feature, tests, configuration, dependencies, design/plan, and + driver documentation are present; +- no migration or unrelated driver behavior changed; +- no secrets, signed URLs, generated dependency trees, or ignored artifacts + are staged. + +- [ ] **Step 5: Commit the documentation** + +```bash +git add docs/drivers/drivers.md +git commit -m "docs: document Resend inbound driver" +``` + +- [ ] **Step 6: Run the completion verification required before publishing** + +Invoke `superpowers:verification-before-completion`, then re-run every command +it requires. Record the exact test counts and environment in the handoff. + +- [ ] **Step 7: Request code review before push/PR** + +Invoke `superpowers:requesting-code-review`. Address any correctness, +compatibility, security, or scope findings before publishing. + +- [ ] **Step 8: Publish only after review passes** + +Invoke `github:yeet` to confirm scope, push +`codex/resend-inbound-driver` to `origin`, and open a draft pull request from: + +```text +jasonfish568:codex/resend-inbound-driver +``` + +to: + +```text +beyondcode:master +``` + +The PR description must summarize signature verification, Receiving API/raw +MIME retrieval, sync/async queue behavior, rate limiting, at-least-once +semantics, documentation, and the verified PHP/Laravel test coverage. diff --git a/docs/superpowers/specs/2026-07-28-resend-inbound-driver-design.md b/docs/superpowers/specs/2026-07-28-resend-inbound-driver-design.md index 309ecc2..09a2d5a 100644 --- a/docs/superpowers/specs/2026-07-28-resend-inbound-driver-design.md +++ b/docs/superpowers/specs/2026-07-28-resend-inbound-driver-design.md @@ -148,7 +148,7 @@ After request validation: - return 204 for a signed non-`email.received` event; - create `Jobs\ProcessResendEmail` with only the signed `email_id` and `svix-id`; -- select the configured queue connection; +- validate the configured queue connection and positive API rate limit; - dispatch the job through Laravel's `dispatch()` helper; - return 200 after successful dispatch. @@ -156,7 +156,10 @@ When the configured connection is `sync`, dispatch executes the job in the request and propagates exceptions as HTTP 5xx. With an asynchronous connection, HTTP 200 means the job was accepted by Laravel's queue, not that mailbox processing has completed. Using the `dispatch()` helper also ensures Laravel -acquires the `ShouldBeUnique` lock before pushing the job. +acquires the `ShouldBeUnique` lock before pushing the job. If dispatch or +synchronous processing throws, the controller releases that unique lock before +rethrowing. Otherwise a failure after lock acquisition but before successful +queue acceptance could suppress Resend's next webhook retry. ### `Jobs\ProcessResendEmail` @@ -231,9 +234,9 @@ User-Agent: beyondcode/laravel-mailbox ``` The host and scheme for this authenticated request are fixed in code. The -request uses a five-second connection timeout and a 15-second total timeout. -It must return a successful JSON response with a non-empty -`raw.download_url`. +request does not follow redirects, uses a five-second connection timeout, and +uses a 15-second total timeout. It must return a successful JSON response with +a non-empty `raw.download_url`. Second, download the raw MIME: @@ -283,7 +286,7 @@ short expiration does not make a delayed job unusable. | `email.received` without a valid `data.email_id` | HTTP 400; do not dispatch | | Valid signed non-`email.received` event | HTTP 204; do not dispatch | | Invalid package configuration | HTTP 5xx | -| Queue dispatch failure | HTTP 5xx, allowing Resend to retry | +| Queue dispatch failure | Release the delivery's unique lock and return HTTP 5xx, allowing Resend to retry | | Successful asynchronous dispatch | HTTP 200 | | Successful synchronous processing | HTTP 200 | | Receiving API, raw download, MIME conversion, or mailbox failure on `sync` | HTTP 5xx, allowing Resend to retry | @@ -316,7 +319,8 @@ deduplication key. The driver will not alter the MIME or replace its - Never log the API key, webhook secret, raw signature header, or signed download URL. - Sanitize raw-download exceptions before they can reach failed-job logging. -- Send the Bearer token only to the fixed Resend API origin. +- Send the Bearer token only to the fixed Resend API origin and disable + redirects on that authenticated request. - Require HTTPS for the returned download URL. - Disable download redirects so a signed URL cannot redirect the client to an unexpected destination. @@ -354,7 +358,9 @@ All tests use Laravel, bus, queue, cache, and HTTP fakes. No test calls Resend. - Signed unrelated events return 204 and dispatch nothing. - A signed received event dispatches the expected scalar identifiers. - Dispatch uses the configured `sync` or asynchronous connection. -- A dispatch exception becomes HTTP 5xx. +- Invalid queue or non-positive rate-limit configuration returns HTTP 5xx. +- A dispatch exception releases the unique lock and becomes HTTP 5xx. +- The same delivery can be dispatched when Resend retries after that exception. ### Client tests @@ -363,7 +369,7 @@ All tests use Laravel, bus, queue, cache, and HTTP fakes. No test calls Resend. - The raw request uses the returned HTTPS URL without Authorization. - Redirects are disabled for the raw request. - A raw MIME fixture is returned byte-for-byte. -- Metadata 401, 404, 429, and 5xx responses throw. +- Metadata redirects, 401, 404, 429, and 5xx responses throw. - Raw-download failures throw. - Missing, empty, malformed, or non-HTTPS `raw.download_url` values throw. - An empty raw MIME response throws. From d245171c502b7ec9a382764909a9f59b5a59f2c1 Mon Sep 17 00:00:00 2001 From: Jason Wang Date: Tue, 28 Jul 2026 16:35:33 +0800 Subject: [PATCH 04/14] feat: register Resend inbound driver --- composer.json | 4 +++ config/mailbox.php | 9 +++++- src/Drivers/Resend.php | 16 +++++++++++ src/MailboxManager.php | 6 ++++ tests/Drivers/ResendTest.php | 55 ++++++++++++++++++++++++++++++++++++ 5 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 src/Drivers/Resend.php create mode 100644 tests/Drivers/ResendTest.php diff --git a/composer.json b/composer.json index a904a75..b3220ee 100644 --- a/composer.json +++ b/composer.json @@ -17,9 +17,13 @@ ], "require": { "php": "^8.1", + "illuminate/bus": "^10.0|^11.0|^12.0|^13.0", + "illuminate/cache": "^10.0|^11.0|^12.0|^13.0", "illuminate/container": "^10.0|^11.0|^12.0|^13.0", "illuminate/database": "^10.0|^11.0|^12.0|^13.0", + "illuminate/http": "^10.0|^11.0|^12.0|^13.0", "illuminate/log": "^10.0|^11.0|^12.0|^13.0", + "illuminate/queue": "^10.0|^11.0|^12.0|^13.0", "illuminate/routing": "^10.0|^11.0|^12.0|^13.0", "illuminate/support": "^10.0|^11.0|^12.0|^13.0", "willdurand/email-reply-parser": "^2.8", diff --git a/config/mailbox.php b/config/mailbox.php index b5e6ebb..b5765c4 100644 --- a/config/mailbox.php +++ b/config/mailbox.php @@ -6,7 +6,7 @@ * The driver to use when listening for incoming emails. * It defaults to the mail driver that you are using. * - * Supported drivers: "log", "mailgun", "sendgrid", "postmark" + * Supported drivers: "log", "mailgun", "sendgrid", "postmark", "resend" */ 'driver' => env('MAILBOX_DRIVER', 'log'), @@ -63,6 +63,13 @@ 'key' => env('MAILBOX_MAILGUN_KEY'), ], + 'resend' => [ + 'api_key' => env('MAILBOX_RESEND_API_KEY'), + 'webhook_secret' => env('MAILBOX_RESEND_WEBHOOK_SECRET'), + 'queue_connection' => env('MAILBOX_RESEND_QUEUE_CONNECTION', 'sync'), + 'rate_limit' => (int) env('MAILBOX_RESEND_RATE_LIMIT', 5), + ], + ], ]; diff --git a/src/Drivers/Resend.php b/src/Drivers/Resend.php new file mode 100644 index 0000000..29d26c1 --- /dev/null +++ b/src/Drivers/Resend.php @@ -0,0 +1,16 @@ +group(function () { + Route::post('/resend', [ResendController::class, '__invoke']); + }); + } +} diff --git a/src/MailboxManager.php b/src/MailboxManager.php index afd4901..ddc7d11 100644 --- a/src/MailboxManager.php +++ b/src/MailboxManager.php @@ -6,6 +6,7 @@ use BeyondCode\Mailbox\Drivers\MailCare; use BeyondCode\Mailbox\Drivers\Mailgun; use BeyondCode\Mailbox\Drivers\Postmark; +use BeyondCode\Mailbox\Drivers\Resend; use BeyondCode\Mailbox\Drivers\SendGrid; use Illuminate\Support\Manager; @@ -41,6 +42,11 @@ public function createPostmarkDriver() return new Postmark; } + public function createResendDriver() + { + return new Resend; + } + public function getDefaultDriver() { return $this->container['config']['mailbox.driver']; diff --git a/tests/Drivers/ResendTest.php b/tests/Drivers/ResendTest.php new file mode 100644 index 0000000..a66ae05 --- /dev/null +++ b/tests/Drivers/ResendTest.php @@ -0,0 +1,55 @@ +assertInstanceOf( + Resend::class, + $this->app->make(MailboxManager::class)->driver('resend') + ); + } + + #[Test] + public function it_registers_the_resend_webhook_route() + { + $route = collect($this->app['router']->getRoutes())->first(function ($route) { + return $route->uri() === 'laravel-mailbox/resend'; + }); + + $this->assertNotNull($route); + $this->assertContains('POST', $route->methods()); + $this->assertSame(ResendController::class.'@__invoke', $route->getAction('uses')); + $this->assertNotContains('laravel-mailbox', $route->gatherMiddleware()); + } + + #[Test] + public function it_uses_the_configured_mailbox_path() + { + config(['mailbox.path' => 'custom-mailbox']); + + (new Resend)->register(); + + $route = collect($this->app['router']->getRoutes())->first(function ($route) { + return $route->uri() === 'custom-mailbox/resend'; + }); + + $this->assertNotNull($route); + } +} From 5ae53e89e20870ad2cc44e70191c71d0967cb58c Mon Sep 17 00:00:00 2001 From: Jason Wang Date: Tue, 28 Jul 2026 16:40:19 +0800 Subject: [PATCH 05/14] feat: verify Resend webhook signatures --- src/Support/ResendWebhookSignature.php | 57 +++++++ tests/Support/ResendWebhookSignatureTest.php | 169 +++++++++++++++++++ 2 files changed, 226 insertions(+) create mode 100644 src/Support/ResendWebhookSignature.php create mode 100644 tests/Support/ResendWebhookSignatureTest.php diff --git a/src/Support/ResendWebhookSignature.php b/src/Support/ResendWebhookSignature.php new file mode 100644 index 0000000..f3d72c3 --- /dev/null +++ b/src/Support/ResendWebhookSignature.php @@ -0,0 +1,57 @@ +timestamp; + + if ($timestamp < $now - static::TOLERANCE || $timestamp > $now + static::TOLERANCE) { + return false; + } + + $expected = base64_encode(hash_hmac( + 'sha256', + $id.'.'.$timestamp.'.'.$payload, + $decodedSecret, + true + )); + + foreach (preg_split('/\s+/', trim($signatures)) as $signature) { + [$version, $value] = array_pad(explode(',', $signature, 2), 2, null); + + if ($version === 'v1' && is_string($value) && hash_equals($expected, $value)) { + return true; + } + } + + return false; + } +} diff --git a/tests/Support/ResendWebhookSignatureTest.php b/tests/Support/ResendWebhookSignatureTest.php new file mode 100644 index 0000000..8d6f217 --- /dev/null +++ b/tests/Support/ResendWebhookSignatureTest.php @@ -0,0 +1,169 @@ +assertTrue((new ResendWebhookSignature)->verify( + self::PAYLOAD, + 'msg_test', + self::TIMESTAMP, + 'v1,'.self::SIGNATURE, + self::SECRET + )); + } + + #[Test] + public function it_rejects_any_raw_body_change() + { + $this->assertFalse((new ResendWebhookSignature)->verify( + self::PAYLOAD." ", + 'msg_test', + self::TIMESTAMP, + 'v1,'.self::SIGNATURE, + self::SECRET + )); + } + + #[Test] + public function it_rejects_json_that_was_parsed_and_reserialized() + { + $reserialized = json_encode( + json_decode(self::PAYLOAD, true), + JSON_PRETTY_PRINT + ); + + $this->assertFalse((new ResendWebhookSignature)->verify( + $reserialized, + 'msg_test', + self::TIMESTAMP, + 'v1,'.self::SIGNATURE, + self::SECRET + )); + } + + #[Test] + public function it_accepts_any_matching_v1_signature() + { + $header = 'v1,invalid v2,invalid v1,'.self::SIGNATURE; + + $this->assertTrue((new ResendWebhookSignature)->verify( + self::PAYLOAD, + 'msg_test', + self::TIMESTAMP, + $header, + self::SECRET + )); + } + + #[Test] + #[DataProvider('invalidInputs')] + public function it_rejects_invalid_signature_inputs( + ?string $id, + ?string $timestamp, + ?string $signature, + ?string $secret + ) { + $this->assertFalse((new ResendWebhookSignature)->verify( + self::PAYLOAD, + $id, + $timestamp, + $signature, + $secret + )); + } + + public static function invalidInputs(): array + { + return [ + 'missing id' => [null, self::TIMESTAMP, 'v1,'.self::SIGNATURE, self::SECRET], + 'missing timestamp' => ['msg_test', null, 'v1,'.self::SIGNATURE, self::SECRET], + 'missing signature' => ['msg_test', self::TIMESTAMP, null, self::SECRET], + 'missing secret' => ['msg_test', self::TIMESTAMP, 'v1,'.self::SIGNATURE, null], + 'wrong prefix' => ['msg_test', self::TIMESTAMP, 'v1,'.self::SIGNATURE, 'secret'], + 'bad base64' => ['msg_test', self::TIMESTAMP, 'v1,'.self::SIGNATURE, 'whsec_%%%'], + 'empty decoded secret' => ['msg_test', self::TIMESTAMP, 'v1,'.self::SIGNATURE, 'whsec_'], + 'wrong secret' => ['msg_test', self::TIMESTAMP, 'v1,'.self::SIGNATURE, 'whsec_b3RoZXItc2VjcmV0'], + 'malformed timestamp' => ['msg_test', 'not-an-integer', 'v1,'.self::SIGNATURE, self::SECRET], + 'wrong signature' => ['msg_test', self::TIMESTAMP, 'v1,invalid', self::SECRET], + 'unsupported version' => ['msg_test', self::TIMESTAMP, 'v2,'.self::SIGNATURE, self::SECRET], + ]; + } + + #[Test] + public function it_enforces_both_timestamp_boundaries() + { + $verifier = new ResendWebhookSignature; + $pastBoundary = (string) (now()->timestamp - 300); + $futureBoundary = (string) (now()->timestamp + 300); + $tooOld = (string) (now()->timestamp - 301); + $tooFarInFuture = (string) (now()->timestamp + 301); + + $this->assertTrue($verifier->verify( + self::PAYLOAD, + 'msg_test', + $pastBoundary, + 'v1,'.$this->signature($pastBoundary), + self::SECRET + )); + $this->assertTrue($verifier->verify( + self::PAYLOAD, + 'msg_test', + $futureBoundary, + 'v1,'.$this->signature($futureBoundary), + self::SECRET + )); + $this->assertFalse($verifier->verify( + self::PAYLOAD, + 'msg_test', + $tooOld, + 'v1,'.$this->signature($tooOld), + self::SECRET + )); + $this->assertFalse($verifier->verify( + self::PAYLOAD, + 'msg_test', + $tooFarInFuture, + 'v1,'.$this->signature($tooFarInFuture), + self::SECRET + )); + } + + private function signature(string $timestamp): string + { + return base64_encode(hash_hmac( + 'sha256', + 'msg_test.'.$timestamp.'.'.self::PAYLOAD, + 'test-secret', + true + )); + } +} From d71d08c9f3a2d536254678b495e162d5f193e188 Mon Sep 17 00:00:00 2001 From: Jason Wang Date: Tue, 28 Jul 2026 16:44:26 +0800 Subject: [PATCH 06/14] feat: retrieve Resend raw emails --- src/Clients/ResendClient.php | 78 ++++++++++++++ tests/Clients/ResendClientTest.php | 160 +++++++++++++++++++++++++++++ 2 files changed, 238 insertions(+) create mode 100644 src/Clients/ResendClient.php create mode 100644 tests/Clients/ResendClientTest.php diff --git a/src/Clients/ResendClient.php b/src/Clients/ResendClient.php new file mode 100644 index 0000000..bc549ca --- /dev/null +++ b/src/Clients/ResendClient.php @@ -0,0 +1,78 @@ +withToken($apiKey) + ->withHeaders(['User-Agent' => static::USER_AGENT]) + ->withOptions(['allow_redirects' => false]) + ->connectTimeout(5) + ->timeout(15) + ->get(static::API_URL.'/'.rawurlencode($emailId)) + ->throw(); + + if (! $response->successful()) { + throw new RuntimeException('Unable to retrieve the Resend email.'); + } + + $downloadUrl = $response->json('raw.download_url'); + + if (! $this->isHttpsUrl($downloadUrl)) { + throw new UnexpectedValueException('Resend did not return a valid raw email download URL.'); + } + + return $this->download($downloadUrl); + } + + protected function download(string $url): string + { + try { + $response = Http::withOptions(['allow_redirects' => false]) + ->connectTimeout(5) + ->timeout(120) + ->get($url); + + if (! $response->successful() || $response->body() === '') { + throw new RuntimeException; + } + + return $response->body(); + } catch (Throwable) { + throw new RuntimeException('Unable to download the raw Resend email.'); + } + } + + protected function isHttpsUrl($url): bool + { + if (! is_string($url) || filter_var($url, FILTER_VALIDATE_URL) === false) { + return false; + } + + $parts = parse_url($url); + + return is_array($parts) + && ($parts['scheme'] ?? null) === 'https' + && ! empty($parts['host']) + && ! isset($parts['user']) + && ! isset($parts['pass']); + } +} diff --git a/tests/Clients/ResendClientTest.php b/tests/Clients/ResendClientTest.php new file mode 100644 index 0000000..193aefb --- /dev/null +++ b/tests/Clients/ResendClientTest.php @@ -0,0 +1,160 @@ + 're_test']); + Http::preventStrayRequests(); + } + + #[Test] + public function it_retrieves_raw_email_without_forwarding_the_api_key() + { + $downloadUrl = 'https://inbound-cdn.resend.com/raw/email?signature=secret'; + $mime = "From: sender@example.com\r\nTo: inbox@example.com\r\n\r\nHello"; + + Http::fake([ + ResendClient::API_URL.'/email%2F123' => Http::response([ + 'raw' => ['download_url' => $downloadUrl], + ]), + $downloadUrl => Http::response($mime), + ]); + + $this->assertSame($mime, (new ResendClient)->rawEmail('email/123')); + + Http::assertSent(function (Request $request) { + return $request->url() === ResendClient::API_URL.'/email%2F123' + && $request->hasHeader('Authorization', 'Bearer re_test') + && $request->hasHeader('User-Agent', ResendClient::USER_AGENT); + }); + + Http::assertSent(function (Request $request) use ($downloadUrl) { + return $request->url() === $downloadUrl + && ! $request->hasHeader('Authorization'); + }); + } + + #[Test] + public function it_requires_an_api_key() + { + config(['mailbox.services.resend.api_key' => null]); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Resend API key is not configured.'); + + (new ResendClient)->rawEmail('email_123'); + } + + #[Test] + #[DataProvider('metadataFailureStatuses')] + public function it_throws_for_metadata_failures_without_exposing_the_api_key(int $status) + { + Http::fake([ + ResendClient::API_URL.'/email_123' => Http::response('', $status), + ]); + + $exception = null; + + try { + (new ResendClient)->rawEmail('email_123'); + } catch (Throwable $caught) { + $exception = $caught; + } + + $this->assertNotNull($exception); + $this->assertStringNotContainsString('re_test', (string) $exception); + Http::assertSentCount(1); + } + + public static function metadataFailureStatuses(): array + { + return [ + 'redirect' => [302], + 'unauthorized' => [401], + 'not found' => [404], + 'rate limited' => [429], + 'server error' => [500], + ]; + } + + #[Test] + #[DataProvider('invalidDownloadUrls')] + public function it_rejects_invalid_raw_download_urls($downloadUrl) + { + Http::fake([ + ResendClient::API_URL.'/email_123' => Http::response([ + 'raw' => ['download_url' => $downloadUrl], + ]), + ]); + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Resend did not return a valid raw email download URL.'); + + (new ResendClient)->rawEmail('email_123'); + } + + public static function invalidDownloadUrls(): array + { + return [ + 'missing' => [null], + 'empty' => [''], + 'malformed' => ['not a url'], + 'http' => ['http://example.com/raw'], + 'credentials' => ['https://user:pass@example.com/raw'], + ]; + } + + #[Test] + #[DataProvider('rawDownloadFailures')] + public function it_sanitizes_raw_download_failures(int $status, string $body) + { + $downloadUrl = 'https://inbound-cdn.resend.com/raw/email?signature=secret'; + + Http::fake([ + ResendClient::API_URL.'/email_123' => Http::response([ + 'raw' => ['download_url' => $downloadUrl], + ]), + $downloadUrl => Http::response( + $body, + $status, + $status === 302 ? ['Location' => 'https://example.com/redirected'] : [] + ), + ]); + + try { + (new ResendClient)->rawEmail('email_123'); + $this->fail('Expected raw download failure.'); + } catch (RuntimeException $exception) { + $this->assertSame('Unable to download the raw Resend email.', $exception->getMessage()); + $this->assertNull($exception->getPrevious()); + $this->assertStringNotContainsString('signature=secret', (string) $exception); + } + + Http::assertSentCount(2); + } + + public static function rawDownloadFailures(): array + { + return [ + 'empty success' => [200, ''], + 'redirect' => [302, 'redirect'], + 'server error' => [500, 'download failed'], + ]; + } +} From 571ab68d69367002521c17eff4a21d770464efb8 Mon Sep 17 00:00:00 2001 From: Jason Wang Date: Tue, 28 Jul 2026 16:49:46 +0800 Subject: [PATCH 07/14] feat: process Resend emails through queues --- src/Jobs/ProcessResendEmail.php | 57 ++++++++++++ src/Queue/Middleware/ResendRateLimited.php | 31 +++++++ tests/Jobs/ProcessResendEmailTest.php | 90 +++++++++++++++++++ .../Middleware/ResendRateLimitedTest.php | 53 +++++++++++ 4 files changed, 231 insertions(+) create mode 100644 src/Jobs/ProcessResendEmail.php create mode 100644 src/Queue/Middleware/ResendRateLimited.php create mode 100644 tests/Jobs/ProcessResendEmailTest.php create mode 100644 tests/Queue/Middleware/ResendRateLimitedTest.php diff --git a/src/Jobs/ProcessResendEmail.php b/src/Jobs/ProcessResendEmail.php new file mode 100644 index 0000000..2c0bd27 --- /dev/null +++ b/src/Jobs/ProcessResendEmail.php @@ -0,0 +1,57 @@ +emailId = $emailId; + $this->webhookId = $webhookId; + } + + public function uniqueId(): string + { + return $this->webhookId; + } + + 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->connection === 'sync' ? [] : [new ResendRateLimited]; + } + + public function handle(ResendClient $client): void + { + /** @var class-string $modelClass */ + $modelClass = config('mailbox.model'); + $email = $modelClass::fromMessage($client->rawEmail($this->emailId)); + + Mailbox::callMailboxes($email); + } +} diff --git a/src/Queue/Middleware/ResendRateLimited.php b/src/Queue/Middleware/ResendRateLimited.php new file mode 100644 index 0000000..233041f --- /dev/null +++ b/src/Queue/Middleware/ResendRateLimited.php @@ -0,0 +1,31 @@ +tooManyAttempts(static::KEY, $maxAttempts)) { + return $job->release($limiter->availableIn(static::KEY) + 1); + } + + $limiter->hit(static::KEY, 1); + + return $next($job); + } +} diff --git a/tests/Jobs/ProcessResendEmailTest.php b/tests/Jobs/ProcessResendEmailTest.php new file mode 100644 index 0000000..b56ac3d --- /dev/null +++ b/tests/Jobs/ProcessResendEmailTest.php @@ -0,0 +1,90 @@ +onConnection('sync'); + $queuedJob = (new ProcessResendEmail('email_123', 'msg_123'))->onConnection('redis'); + + $this->assertSame('msg_123', $queuedJob->uniqueId()); + $this->assertSame([5, 60, 300, 1800, 7200, 18000, 36000], $queuedJob->backoff()); + $this->assertSame(now()->addDay()->timestamp, $queuedJob->retryUntil()->timestamp); + $this->assertSame(180, $queuedJob->timeout); + $this->assertSame([], $syncJob->middleware()); + $this->assertInstanceOf(ResendRateLimited::class, $queuedJob->middleware()[0]); + } + + #[Test] + public function it_builds_the_configured_model_and_calls_mailboxes() + { + config(['mailbox.model' => TestResendInboundEmail::class]); + + $client = Mockery::mock(ResendClient::class); + $client->shouldReceive('rawEmail')->once()->with('email_123')->andReturn('raw mime'); + + Mailbox::shouldReceive('callMailboxes') + ->once() + ->with(Mockery::type(TestResendInboundEmail::class)); + + (new ProcessResendEmail('email_123', 'msg_123'))->handle($client); + + $this->assertSame('raw mime', TestResendInboundEmail::$rawMessage); + } + + #[Test] + public function it_does_not_call_mailboxes_when_retrieval_fails() + { + $expected = new RuntimeException('api failed'); + $client = Mockery::mock(ResendClient::class); + $client->shouldReceive('rawEmail') + ->once() + ->with('email_123') + ->andThrow($expected); + + Mailbox::shouldReceive('callMailboxes')->never(); + + try { + (new ProcessResendEmail('email_123', 'msg_123'))->handle($client); + $this->fail('Expected the client exception.'); + } catch (RuntimeException $exception) { + $this->assertSame($expected, $exception); + } + } +} + +class TestResendInboundEmail extends InboundEmail +{ + public static ?string $rawMessage = null; + + public static function fromMessage($message) + { + static::$rawMessage = $message; + + return parent::fromMessage($message); + } +} diff --git a/tests/Queue/Middleware/ResendRateLimitedTest.php b/tests/Queue/Middleware/ResendRateLimitedTest.php new file mode 100644 index 0000000..fc11c55 --- /dev/null +++ b/tests/Queue/Middleware/ResendRateLimitedTest.php @@ -0,0 +1,53 @@ +app->make(RateLimiter::class)->clear(ResendRateLimited::KEY); + + parent::tearDown(); + } + + #[Test] + public function it_releases_jobs_over_the_per_second_limit() + { + config(['mailbox.services.resend.rate_limit' => 1]); + + $job = Mockery::mock(); + $job->shouldReceive('release') + ->once() + ->with(Mockery::on(fn ($seconds) => $seconds >= 1 && $seconds <= 2)) + ->andReturn(false); + + $handled = 0; + $next = function () use (&$handled) { + $handled++; + }; + + $middleware = new ResendRateLimited; + $middleware->handle($job, $next); + $middleware->handle($job, $next); + + $this->assertSame(1, $handled); + } + + #[Test] + public function it_rejects_a_non_positive_limit() + { + config(['mailbox.services.resend.rate_limit' => 0]); + + $this->expectException(LogicException::class); + + (new ResendRateLimited)->handle(Mockery::mock(), fn () => null); + } +} From a076c80721ba6794604d76238a26df42a69c5960 Mon Sep 17 00:00:00 2001 From: Jason Wang Date: Tue, 28 Jul 2026 16:56:11 +0800 Subject: [PATCH 08/14] feat: accept Resend inbound webhooks --- src/Http/Controllers/ResendController.php | 44 +++ src/Http/Requests/ResendRequest.php | 62 +++++ tests/Concerns/SignsResendWebhooks.php | 39 +++ tests/Controllers/ResendTest.php | 318 ++++++++++++++++++++++ tests/Fixtures/resend-email.eml | 20 ++ 5 files changed, 483 insertions(+) create mode 100644 src/Http/Controllers/ResendController.php create mode 100644 src/Http/Requests/ResendRequest.php create mode 100644 tests/Concerns/SignsResendWebhooks.php create mode 100644 tests/Controllers/ResendTest.php create mode 100644 tests/Fixtures/resend-email.eml diff --git a/src/Http/Controllers/ResendController.php b/src/Http/Controllers/ResendController.php new file mode 100644 index 0000000..132012e --- /dev/null +++ b/src/Http/Controllers/ResendController.php @@ -0,0 +1,44 @@ +eventType() !== 'email.received') { + return response('', 204); + } + + $connection = config('mailbox.services.resend.queue_connection', 'sync'); + $rateLimit = (int) config('mailbox.services.resend.rate_limit', 5); + + 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.'); + } + + $job = (new ProcessResendEmail($request->emailId(), $request->webhookId())) + ->onConnection($connection); + + try { + dispatch($job); + } catch (Throwable $exception) { + (new UniqueLock(app(Cache::class)))->release($job); + + throw $exception; + } + + return response('', 200); + } +} diff --git a/src/Http/Requests/ResendRequest.php b/src/Http/Requests/ResendRequest.php new file mode 100644 index 0000000..eae657a --- /dev/null +++ b/src/Http/Requests/ResendRequest.php @@ -0,0 +1,62 @@ +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.'); + } + + 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->input('type'); + } + + public function emailId(): ?string + { + return $this->input('data.email_id'); + } + + public function webhookId(): string + { + return $this->header('svix-id'); + } +} diff --git a/tests/Concerns/SignsResendWebhooks.php b/tests/Concerns/SignsResendWebhooks.php new file mode 100644 index 0000000..566a1f3 --- /dev/null +++ b/tests/Concerns/SignsResendWebhooks.php @@ -0,0 +1,39 @@ +timestamp; + $secret = 'test-secret'; + + if ($configureSecret) { + config([ + 'mailbox.services.resend.webhook_secret' => 'whsec_'.base64_encode($secret), + ]); + } + + $signature = $signature ?? base64_encode(hash_hmac( + 'sha256', + $webhookId.'.'.$timestamp.'.'.$payload, + $secret, + true + )); + + return $this->call('POST', '/laravel-mailbox/resend', [], [], [], [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_SVIX_ID' => $webhookId, + 'HTTP_SVIX_TIMESTAMP' => (string) $timestamp, + 'HTTP_SVIX_SIGNATURE' => 'v1,'.$signature, + ], $payload); + } +} diff --git a/tests/Controllers/ResendTest.php b/tests/Controllers/ResendTest.php new file mode 100644 index 0000000..379d5f2 --- /dev/null +++ b/tests/Controllers/ResendTest.php @@ -0,0 +1,318 @@ + 'email.received', + 'data' => ['email_id' => 'email_123'], + ]); + + $this->postResendWebhook($payload, signature: 'invalid')->assertStatus(401); + + Bus::assertNothingDispatched(); + } + + #[Test] + public function it_rejects_missing_svix_headers_without_dispatching() + { + Bus::fake(); + config([ + 'mailbox.services.resend.webhook_secret' => 'whsec_'.base64_encode('test-secret'), + ]); + + $this->call( + 'POST', + '/laravel-mailbox/resend', + [], + [], + [], + ['CONTENT_TYPE' => 'application/json'], + '{"type":"email.received","data":{"email_id":"email_123"}}' + )->assertStatus(401); + + Bus::assertNothingDispatched(); + } + + #[Test] + public function it_rejects_stale_webhooks_without_dispatching() + { + Bus::fake(); + + $this->postResendWebhook( + '{"type":"email.received","data":{"email_id":"email_123"}}', + timestamp: now()->timestamp - 301 + )->assertStatus(401); + + Bus::assertNothingDispatched(); + } + + #[Test] + public function it_rejects_malformed_signed_json_without_dispatching() + { + Bus::fake(); + + $this->postResendWebhook('{"type":') + ->assertStatus(400); + + Bus::assertNothingDispatched(); + } + + #[Test] + public function it_rejects_invalid_received_payloads() + { + Bus::fake(); + + $this->postResendWebhook('{"type":"email.received","data":{}}') + ->assertStatus(400); + + Bus::assertNothingDispatched(); + } + + #[Test] + public function it_acknowledges_signed_unrelated_events() + { + Bus::fake(); + + $this->postResendWebhook('{"type":"email.delivered","data":{}}') + ->assertNoContent(); + + Bus::assertNothingDispatched(); + } + + #[Test] + public function it_dispatches_received_emails_to_the_configured_connection() + { + Bus::fake(); + config(['mailbox.services.resend.queue_connection' => 'redis']); + + $this->postResendWebhook( + '{"type":"email.received","data":{"email_id":"email_123"}}', + 'msg_123' + )->assertOk(); + + Bus::assertDispatched(ProcessResendEmail::class, function ($job) { + return $job->emailId === 'email_123' + && $job->webhookId === 'msg_123' + && $job->connection === 'redis'; + }); + } + + #[Test] + #[DataProvider('invalidQueueConnections')] + public function it_rejects_invalid_queue_configuration($connection) + { + Bus::fake(); + config(['mailbox.services.resend.queue_connection' => $connection]); + + $this->postResendWebhook( + '{"type":"email.received","data":{"email_id":"email_123"}}' + )->assertStatus(500); + + Bus::assertNothingDispatched(); + } + + public static function invalidQueueConnections(): array + { + return [ + 'null' => [null], + 'empty string' => [''], + ]; + } + + #[Test] + #[DataProvider('invalidRateLimits')] + public function it_rejects_invalid_rate_limit_configuration($rateLimit) + { + Bus::fake(); + config(['mailbox.services.resend.rate_limit' => $rateLimit]); + + $this->postResendWebhook( + '{"type":"email.received","data":{"email_id":"email_123"}}' + )->assertStatus(500); + + Bus::assertNothingDispatched(); + } + + public static function invalidRateLimits(): array + { + return [ + 'zero' => [0], + 'negative' => [-1], + 'non-numeric' => ['invalid'], + ]; + } + + #[Test] + public function it_reports_a_missing_webhook_secret_as_server_configuration() + { + Bus::fake(); + $payload = '{"type":"email.received","data":{"email_id":"email_123"}}'; + + $this->postResendWebhook( + $payload, + configureSecret: false + )->assertStatus(500); + + Bus::assertNothingDispatched(); + } + + #[Test] + public function it_releases_the_unique_lock_when_dispatch_fails() + { + $original = $this->app->make(Dispatcher::class); + $dispatcher = Mockery::mock(Dispatcher::class); + $dispatcher->shouldReceive('dispatch') + ->once() + ->andThrow(new RuntimeException('queue unavailable')); + + $this->app->instance(Dispatcher::class, $dispatcher); + + $payload = '{"type":"email.received","data":{"email_id":"email_123"}}'; + + try { + $this->postResendWebhook($payload, 'msg_retry')->assertStatus(500); + } finally { + $this->app->instance(Dispatcher::class, $original); + } + + Bus::fake(); + + $this->postResendWebhook($payload, 'msg_retry')->assertOk(); + Bus::assertDispatched(ProcessResendEmail::class); + } + + #[Test] + public function it_processes_raw_mime_and_attachments_on_the_sync_connection() + { + $downloadUrl = 'https://inbound-cdn.resend.com/raw/email?signature=test'; + $mime = file_get_contents(__DIR__.'/../Fixtures/resend-email.eml'); + $handled = false; + + config([ + 'mailbox.services.resend.api_key' => 're_test', + 'mailbox.services.resend.queue_connection' => 'sync', + ]); + + Http::fake([ + ResendClient::API_URL.'/email_123' => Http::response([ + 'raw' => ['download_url' => $downloadUrl], + ]), + $downloadUrl => Http::response($mime), + ]); + + Mailbox::to('inbox@example.com', function (InboundEmail $email) use (&$handled) { + $handled = true; + + $this->assertSame('Inbound from Resend', $email->subject()); + $this->assertSame('Hello from Resend.', trim($email->text())); + $this->assertCount(1, $email->attachments()); + }); + + $this->postResendWebhook( + '{"type":"email.received","data":{"email_id":"email_123"}}', + 'msg_123' + )->assertOk(); + + $this->assertTrue($handled); + Http::assertSentCount(2); + } + + #[Test] + public function it_returns_a_server_error_without_calling_mailboxes_when_sync_retrieval_fails() + { + $handled = false; + + config([ + 'mailbox.services.resend.api_key' => 're_test', + 'mailbox.services.resend.queue_connection' => 'sync', + ]); + + Http::fake([ + ResendClient::API_URL.'/email_123' => Http::response('', 500), + ]); + + Mailbox::to('inbox@example.com', function () use (&$handled) { + $handled = true; + }); + + $this->postResendWebhook( + '{"type":"email.received","data":{"email_id":"email_123"}}', + 'msg_failure' + )->assertStatus(500); + + $this->assertFalse($handled); + Http::assertSentCount(1); + } + + #[Test] + public function it_honors_the_configured_model() + { + $downloadUrl = 'https://inbound-cdn.resend.com/raw/email?signature=test'; + + config([ + 'mailbox.model' => TestResendControllerInboundEmail::class, + 'mailbox.services.resend.api_key' => 're_test', + 'mailbox.services.resend.queue_connection' => 'sync', + ]); + + Http::fake([ + ResendClient::API_URL.'/email_123' => Http::response([ + 'raw' => ['download_url' => $downloadUrl], + ]), + $downloadUrl => Http::response('raw mime'), + ]); + + Mailbox::shouldReceive('callMailboxes') + ->once() + ->with(Mockery::type(TestResendControllerInboundEmail::class)); + + $this->postResendWebhook( + '{"type":"email.received","data":{"email_id":"email_123"}}', + 'msg_model' + )->assertOk(); + + $this->assertSame('raw mime', TestResendControllerInboundEmail::$rawMessage); + } +} + +class TestResendControllerInboundEmail extends InboundEmail +{ + public static ?string $rawMessage = null; + + public static function fromMessage($message) + { + static::$rawMessage = $message; + + return parent::fromMessage($message); + } +} diff --git a/tests/Fixtures/resend-email.eml b/tests/Fixtures/resend-email.eml new file mode 100644 index 0000000..a596241 --- /dev/null +++ b/tests/Fixtures/resend-email.eml @@ -0,0 +1,20 @@ +From: Resend Sender +To: inbox@example.com +Subject: Inbound from Resend +Message-ID: +Date: Tue, 28 Jul 2026 10:00:00 +0800 +MIME-Version: 1.0 +Content-Type: multipart/mixed; boundary="resend-boundary" + +--resend-boundary +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 7bit + +Hello from Resend. +--resend-boundary +Content-Type: text/plain; name="note.txt" +Content-Disposition: attachment; filename="note.txt" +Content-Transfer-Encoding: base64 + +YXR0YWNobWVudCBjb250ZW50 +--resend-boundary-- From 9aa1997b83d76b0a2a0078103c3c91ce0efad9b3 Mon Sep 17 00:00:00 2001 From: Jason Wang Date: Tue, 28 Jul 2026 17:03:11 +0800 Subject: [PATCH 09/14] fix: validate signed Resend webhook payload --- src/Http/Requests/ResendRequest.php | 28 ++++++++++++++++++-- tests/Controllers/ResendTest.php | 40 +++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/src/Http/Requests/ResendRequest.php b/src/Http/Requests/ResendRequest.php index eae657a..25d6bf0 100644 --- a/src/Http/Requests/ResendRequest.php +++ b/src/Http/Requests/ResendRequest.php @@ -6,10 +6,13 @@ 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'); @@ -27,6 +30,27 @@ protected function prepareForValidation() ); 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() @@ -47,12 +71,12 @@ protected function failedValidation(Validator $validator) public function eventType(): string { - return $this->input('type'); + return $this->validated('type'); } public function emailId(): ?string { - return $this->input('data.email_id'); + return $this->validated('data.email_id'); } public function webhookId(): string diff --git a/tests/Controllers/ResendTest.php b/tests/Controllers/ResendTest.php index 379d5f2..16a269a 100644 --- a/tests/Controllers/ResendTest.php +++ b/tests/Controllers/ResendTest.php @@ -87,6 +87,46 @@ public function it_rejects_malformed_signed_json_without_dispatching() Bus::assertNothingDispatched(); } + #[Test] + public function it_does_not_allow_unsigned_request_parameters_to_repair_malformed_json() + { + Bus::fake(); + + $payload = '{"type":'; + $webhookId = 'msg_unsigned_query'; + $timestamp = now()->timestamp; + $secret = 'test-secret'; + config([ + 'mailbox.services.resend.webhook_secret' => 'whsec_'.base64_encode($secret), + ]); + $signature = base64_encode(hash_hmac( + 'sha256', + $webhookId.'.'.$timestamp.'.'.$payload, + $secret, + true + )); + + $this->call( + 'POST', + '/laravel-mailbox/resend?type=email.received&data%5Bemail_id%5D=email_unsigned', + [ + 'type' => 'email.received', + 'data' => ['email_id' => 'email_unsigned'], + ], + [], + [], + [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_SVIX_ID' => $webhookId, + 'HTTP_SVIX_TIMESTAMP' => (string) $timestamp, + 'HTTP_SVIX_SIGNATURE' => 'v1,'.$signature, + ], + $payload + )->assertStatus(400); + + Bus::assertNothingDispatched(); + } + #[Test] public function it_rejects_invalid_received_payloads() { From baf37951626b94b3223037cef86be2a91ef43995 Mon Sep 17 00:00:00 2001 From: Jason Wang Date: Tue, 28 Jul 2026 17:06:32 +0800 Subject: [PATCH 10/14] docs: document Resend inbound driver --- docs/drivers/drivers.md | 49 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/docs/drivers/drivers.md b/docs/drivers/drivers.md index 1136e5f..81a2aae 100644 --- a/docs/drivers/drivers.md +++ b/docs/drivers/drivers.md @@ -66,6 +66,55 @@ 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 +``` + +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. Configure the queue connection's `retry_after` +above 180 seconds and keep the worker's `--timeout` below `retry_after`. +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. From e1a574098c7b220493268b9a0f59564a7c16d736 Mon Sep 17 00:00:00 2001 From: Jason Wang Date: Tue, 28 Jul 2026 17:52:47 +0800 Subject: [PATCH 11/14] fix: harden Resend queue processing --- docs/drivers/drivers.md | 18 +- .../plans/2026-07-28-resend-inbound-driver.md | 1835 ----------------- ...2026-07-28-resend-inbound-driver-design.md | 445 ---- src/Http/Controllers/ResendController.php | 10 +- src/Http/Requests/ResendRequest.php | 7 +- src/Jobs/ProcessResendEmail.php | 17 +- src/MailboxServiceProvider.php | 29 +- src/Queue/Middleware/ResendRateLimited.php | 5 +- src/Support/ResendWebhookSignature.php | 24 +- tests/Controllers/ResendTest.php | 117 +- tests/Jobs/ProcessResendEmailTest.php | 13 + tests/MailboxServiceProviderTest.php | 72 + .../Middleware/ResendRateLimitedTest.php | 100 +- 13 files changed, 381 insertions(+), 2311 deletions(-) delete mode 100644 docs/superpowers/plans/2026-07-28-resend-inbound-driver.md delete mode 100644 docs/superpowers/specs/2026-07-28-resend-inbound-driver-design.md create mode 100644 tests/MailboxServiceProviderTest.php diff --git a/docs/drivers/drivers.md b/docs/drivers/drivers.md index 81a2aae..0084c76 100644 --- a/docs/drivers/drivers.md +++ b/docs/drivers/drivers.md @@ -84,6 +84,11 @@ 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 @@ -106,10 +111,15 @@ Resend's default API limit is five requests per second per team, so share the team's allowance. Only raise it after Resend has approved a higher limit for the team. -The job timeout is 180 seconds. Configure the queue connection's `retry_after` -above 180 seconds and keep the worker's `--timeout` below `retry_after`. -Workers must also have enough memory for the complete MIME message, including -attachments. +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. + +Unique-job locking and per-second throttling both use Laravel's cache. Workers +on multiple nodes must use the same shared cache store, and that store must +support atomic locks and 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 diff --git a/docs/superpowers/plans/2026-07-28-resend-inbound-driver.md b/docs/superpowers/plans/2026-07-28-resend-inbound-driver.md deleted file mode 100644 index 4644ccb..0000000 --- a/docs/superpowers/plans/2026-07-28-resend-inbound-driver.md +++ /dev/null @@ -1,1835 +0,0 @@ -# Resend Inbound Driver Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add a secure, queue-aware Resend inbound driver that verifies Svix webhooks, retrieves the complete raw MIME from Resend, and processes it through the existing mailbox model and router. - -**Architecture:** A Resend route validates the exact raw webhook body before dispatching a unique queue job. The job applies a Laravel 10-compatible one-second cache rate limiter on asynchronous connections, retrieves a fresh signed raw-MIME URL through a small Laravel HTTP client, and passes the MIME to the configured inbound model and mailbox router. - -**Tech Stack:** PHP 8.1+, Laravel/Illuminate 10–13, Orchestra Testbench, PHPUnit attributes, Laravel HTTP/Bus/Queue/Cache, zbateson/mail-mime-parser. - -## Global Constraints - -- Work only in `/Users/wangjiangyu/backend-projects/laravel-mailbox`; never touch `/private/var/www/auto-compliance`. -- Develop on `codex/resend-inbound-driver` and push only to `origin` (`jasonfish568/laravel-mailbox`); `upstream` is read-only. -- Support PHP `^8.1` and Illuminate `^10.0|^11.0|^12.0|^13.0`. -- Do not add `resend/resend-php`, `svix/svix`, or any other vendor SDK. -- Do not add a migration, webhook ledger, exactly-once claim, or queue behavior to existing drivers. -- Register `POST /{mailbox.path}/resend`; the default endpoint is `/laravel-mailbox/resend`. -- Verify `svix-id`, `svix-timestamp`, and `svix-signature` against the exact raw body with a 300-second past/future tolerance before trusting payload fields. -- Use `MAILBOX_RESEND_API_KEY`, `MAILBOX_RESEND_WEBHOOK_SECRET`, `MAILBOX_RESEND_QUEUE_CONNECTION`, and `MAILBOX_RESEND_RATE_LIMIT`. -- Default the queue connection to `sync` and the team-level API rate limit to five requests per second. -- Retrieve the raw MIME inside the job, not before dispatch; never reconstruct MIME from parsed HTML/text. -- Send the Bearer token only to the fixed `https://api.resend.com` origin and disable redirects on that request. -- Require HTTPS for the signed raw URL, disable redirects, and never leak the URL or credentials through exceptions or logs. -- Preserve at-least-once semantics and document handler idempotency. -- Follow existing PSR-2-style formatting and PHPUnit `#[Test]` attributes. -- Use TDD for every task: failing test, observed failure, minimal implementation, passing focused tests, then commit. - -## File Map - -### Create - -- `src/Drivers/Resend.php` — register the Resend webhook route. -- `src/Support/ResendWebhookSignature.php` — verify the symmetric Svix signature and timestamp. -- `src/Clients/ResendClient.php` — call the Receiving API and download sanitized raw MIME. -- `src/Queue/Middleware/ResendRateLimited.php` — enforce the configured one-second API window on asynchronous jobs. -- `src/Jobs/ProcessResendEmail.php` — unique, retryable job that converts MIME and invokes mailboxes. -- `src/Http/Requests/ResendRequest.php` — authenticate the raw webhook and validate its payload. -- `src/Http/Controllers/ResendController.php` — acknowledge unrelated events or dispatch the processing job. -- `tests/Drivers/ResendTest.php` — driver factory and route tests. -- `tests/Support/ResendWebhookSignatureTest.php` — fixed-vector and boundary tests. -- `tests/Clients/ResendClientTest.php` — authenticated metadata and unauthenticated raw-download tests. -- `tests/Queue/Middleware/ResendRateLimitedTest.php` — one-second limiter and invalid-config tests. -- `tests/Jobs/ProcessResendEmailTest.php` — job metadata, middleware, model, and mailbox tests. -- `tests/Concerns/SignsResendWebhooks.php` — deterministic signed-request test helper. -- `tests/Controllers/ResendTest.php` — request status, dispatch, sync integration, and attachment tests. -- `tests/Fixtures/resend-email.eml` — multipart MIME with a text attachment. - -### Modify - -- `composer.json` — explicitly require Illuminate HTTP, Bus, Queue, and Cache components. -- `config/mailbox.php` — expose the Resend driver, credentials, queue connection, and rate. -- `src/MailboxManager.php` — add `createResendDriver()`. -- `docs/drivers/drivers.md` — document setup, queues, limits, retries, and idempotency. - ---- - -### Task 1: Package Wiring and Resend Route - -**Files:** -- Create: `src/Drivers/Resend.php` -- Create: `tests/Drivers/ResendTest.php` -- Modify: `composer.json:18-27` -- Modify: `config/mailbox.php:5-11,57-66` -- Modify: `src/MailboxManager.php:5-10,39-47` - -**Interfaces:** -- Consumes: Existing `DriverInterface::register()` and `MailboxManager` driver factory convention. -- Produces: `BeyondCode\Mailbox\Drivers\Resend`, `MailboxManager::createResendDriver()`, the `/laravel-mailbox/resend` route, and `mailbox.services.resend.*` configuration used by all later tasks. - -- [ ] **Step 1: Install the baseline dependencies and prove the current suite is green** - -Run: - -```bash -composer install --no-interaction -composer test -``` - -Expected: Composer installs into ignored `vendor/` and the existing suite passes before feature changes. - -- [ ] **Step 2: Write the failing driver and route tests** - -Create `tests/Drivers/ResendTest.php`: - -```php -assertInstanceOf( - Resend::class, - $this->app->make(MailboxManager::class)->driver('resend') - ); - } - - #[Test] - public function it_registers_the_resend_webhook_route() - { - $route = collect($this->app['router']->getRoutes())->first(function ($route) { - return $route->uri() === 'laravel-mailbox/resend'; - }); - - $this->assertNotNull($route); - $this->assertContains('POST', $route->methods()); - $this->assertSame(ResendController::class, $route->getAction('uses')); - $this->assertNotContains('laravel-mailbox', $route->gatherMiddleware()); - } - - #[Test] - public function it_uses_the_configured_mailbox_path() - { - config(['mailbox.path' => 'custom-mailbox']); - - (new Resend)->register(); - - $route = collect($this->app['router']->getRoutes())->first(function ($route) { - return $route->uri() === 'custom-mailbox/resend'; - }); - - $this->assertNotNull($route); - } -} -``` - -- [ ] **Step 3: Run the test and observe the missing-driver failure** - -Run: - -```bash -vendor/bin/phpunit tests/Drivers/ResendTest.php -``` - -Expected: FAIL during package boot or driver resolution because `createResendDriver()` and `Drivers\Resend` do not exist. - -- [ ] **Step 4: Add the explicit component requirements** - -Extend `composer.json` `require` in sorted order: - -```json -"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" -``` - -Run: - -```bash -composer update illuminate/bus illuminate/cache illuminate/http illuminate/queue --with-all-dependencies --no-interaction -composer validate --strict -``` - -Expected: dependency resolution succeeds for the current environment; `composer.lock` remains ignored. - -- [ ] **Step 5: Add the configuration and driver factory** - -Add this service configuration to `config/mailbox.php` and include `"resend"` in the supported-driver comment: - -```php -'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), -], -``` - -Import the driver and add to `MailboxManager`: - -```php -use BeyondCode\Mailbox\Drivers\Resend; - -public function createResendDriver() -{ - return new Resend; -} -``` - -- [ ] **Step 6: Implement the route-only driver** - -Create `src/Drivers/Resend.php`: - -```php -group(function () { - Route::post('/resend', ResendController::class); - }); - } -} -``` - -- [ ] **Step 7: Run focused and package tests** - -Run: - -```bash -vendor/bin/phpunit tests/Drivers/ResendTest.php -composer test -``` - -Expected: both pass. Referencing `ResendController::class` is safe before its file is created because the route stores the class name as a string and this task does not invoke the endpoint. - -- [ ] **Step 8: Commit the wiring** - -```bash -git add composer.json config/mailbox.php src/Drivers/Resend.php src/MailboxManager.php tests/Drivers/ResendTest.php -git commit -m "feat: register Resend inbound driver" -``` - ---- - -### Task 2: Svix Webhook Signature Verification - -**Files:** -- Create: `src/Support/ResendWebhookSignature.php` -- Create: `tests/Support/ResendWebhookSignatureTest.php` - -**Interfaces:** -- Consumes: `Carbon\Carbon::now()` for deterministic clock control. -- Produces: `ResendWebhookSignature::verify(string $payload, ?string $id, ?string $timestamp, ?string $signatures, ?string $secret): bool` for `ResendRequest`. - -- [ ] **Step 1: Write all fixed-vector, raw-body, and edge tests** - -Create `tests/Support/ResendWebhookSignatureTest.php` with the independently -calculated signature `d36LOzwlBe0bKqKR8+qDaX65GyQsZLnNBqd0FeDwie8=`: - -```php -assertTrue((new ResendWebhookSignature)->verify( - self::PAYLOAD, - 'msg_test', - self::TIMESTAMP, - 'v1,'.self::SIGNATURE, - self::SECRET - )); - } - - #[Test] - public function it_rejects_any_raw_body_change() - { - $this->assertFalse((new ResendWebhookSignature)->verify( - self::PAYLOAD." ", - 'msg_test', - self::TIMESTAMP, - 'v1,'.self::SIGNATURE, - self::SECRET - )); - } - - #[Test] - public function it_rejects_json_that_was_parsed_and_reserialized() - { - $reserialized = json_encode( - json_decode(self::PAYLOAD, true), - JSON_PRETTY_PRINT - ); - - $this->assertFalse((new ResendWebhookSignature)->verify( - $reserialized, - 'msg_test', - self::TIMESTAMP, - 'v1,'.self::SIGNATURE, - self::SECRET - )); - } - - #[Test] - public function it_accepts_any_matching_v1_signature() - { - $header = 'v1,invalid v2,invalid v1,'.self::SIGNATURE; - - $this->assertTrue((new ResendWebhookSignature)->verify( - self::PAYLOAD, - 'msg_test', - self::TIMESTAMP, - $header, - self::SECRET - )); - } - - #[Test] - #[DataProvider('invalidInputs')] - public function it_rejects_invalid_signature_inputs( - ?string $id, - ?string $timestamp, - ?string $signature, - ?string $secret - ) { - $this->assertFalse((new ResendWebhookSignature)->verify( - self::PAYLOAD, - $id, - $timestamp, - $signature, - $secret - )); - } - - public static function invalidInputs(): array - { - return [ - 'missing id' => [null, self::TIMESTAMP, 'v1,'.self::SIGNATURE, self::SECRET], - 'missing timestamp' => ['msg_test', null, 'v1,'.self::SIGNATURE, self::SECRET], - 'missing signature' => ['msg_test', self::TIMESTAMP, null, self::SECRET], - 'missing secret' => ['msg_test', self::TIMESTAMP, 'v1,'.self::SIGNATURE, null], - 'wrong prefix' => ['msg_test', self::TIMESTAMP, 'v1,'.self::SIGNATURE, 'secret'], - 'bad base64' => ['msg_test', self::TIMESTAMP, 'v1,'.self::SIGNATURE, 'whsec_%%%'], - 'empty decoded secret' => ['msg_test', self::TIMESTAMP, 'v1,'.self::SIGNATURE, 'whsec_'], - 'wrong secret' => ['msg_test', self::TIMESTAMP, 'v1,'.self::SIGNATURE, 'whsec_b3RoZXItc2VjcmV0'], - 'malformed timestamp' => ['msg_test', 'not-an-integer', 'v1,'.self::SIGNATURE, self::SECRET], - 'wrong signature' => ['msg_test', self::TIMESTAMP, 'v1,invalid', self::SECRET], - 'unsupported version' => ['msg_test', self::TIMESTAMP, 'v2,'.self::SIGNATURE, self::SECRET], - ]; - } - - #[Test] - public function it_enforces_both_timestamp_boundaries() - { - $verifier = new ResendWebhookSignature; - $pastBoundary = (string) (now()->timestamp - 300); - $futureBoundary = (string) (now()->timestamp + 300); - $tooOld = (string) (now()->timestamp - 301); - $tooFarInFuture = (string) (now()->timestamp + 301); - - $this->assertTrue($verifier->verify( - self::PAYLOAD, - 'msg_test', - $pastBoundary, - 'v1,'.$this->signature($pastBoundary), - self::SECRET - )); - $this->assertTrue($verifier->verify( - self::PAYLOAD, - 'msg_test', - $futureBoundary, - 'v1,'.$this->signature($futureBoundary), - self::SECRET - )); - $this->assertFalse($verifier->verify( - self::PAYLOAD, - 'msg_test', - $tooOld, - 'v1,'.$this->signature($tooOld), - self::SECRET - )); - $this->assertFalse($verifier->verify( - self::PAYLOAD, - 'msg_test', - $tooFarInFuture, - 'v1,'.$this->signature($tooFarInFuture), - self::SECRET - )); - } - - private function signature(string $timestamp): string - { - return base64_encode(hash_hmac( - 'sha256', - 'msg_test.'.$timestamp.'.'.self::PAYLOAD, - 'test-secret', - true - )); - } -} -``` - -- [ ] **Step 2: Run the test and observe the missing-class failure** - -Run: - -```bash -vendor/bin/phpunit tests/Support/ResendWebhookSignatureTest.php -``` - -Expected: FAIL because `BeyondCode\Mailbox\Support\ResendWebhookSignature` does not exist. - -- [ ] **Step 3: Implement the minimal verifier** - -Create `src/Support/ResendWebhookSignature.php`: - -```php -timestamp; - - if ($timestamp < $now - static::TOLERANCE || $timestamp > $now + static::TOLERANCE) { - return false; - } - - $expected = base64_encode(hash_hmac( - 'sha256', - $id.'.'.$timestamp.'.'.$payload, - $decodedSecret, - true - )); - - foreach (preg_split('/\s+/', trim($signatures)) as $signature) { - [$version, $value] = array_pad(explode(',', $signature, 2), 2, null); - - if ($version === 'v1' && is_string($value) && hash_equals($expected, $value)) { - return true; - } - } - - return false; - } -} -``` - -- [ ] **Step 4: Run focused and package tests** - -Run: - -```bash -vendor/bin/phpunit tests/Support/ResendWebhookSignatureTest.php -composer test -``` - -Expected: all signature cases and the full package suite pass. - -- [ ] **Step 5: Commit the verifier** - -```bash -git add src/Support/ResendWebhookSignature.php tests/Support/ResendWebhookSignatureTest.php -git commit -m "feat: verify Resend webhook signatures" -``` - ---- - -### Task 3: Resend Receiving API Client - -**Files:** -- Create: `src/Clients/ResendClient.php` -- Create: `tests/Clients/ResendClientTest.php` - -**Interfaces:** -- Consumes: `mailbox.services.resend.api_key` and Laravel's `Http` facade. -- Produces: `ResendClient::rawEmail(string $emailId): string` for `ProcessResendEmail`. - -- [ ] **Step 1: Write successful, failure, and sanitization tests** - -Create `tests/Clients/ResendClientTest.php`: - -```php - 're_test']); - Http::preventStrayRequests(); - } - - #[Test] - public function it_retrieves_raw_email_without_forwarding_the_api_key() - { - $downloadUrl = 'https://inbound-cdn.resend.com/raw/email?signature=secret'; - $mime = "From: sender@example.com\r\nTo: inbox@example.com\r\n\r\nHello"; - - Http::fake([ - ResendClient::API_URL.'/email%2F123' => Http::response([ - 'raw' => ['download_url' => $downloadUrl], - ]), - $downloadUrl => Http::response($mime), - ]); - - $this->assertSame($mime, (new ResendClient)->rawEmail('email/123')); - - Http::assertSent(function (Request $request) { - return $request->url() === ResendClient::API_URL.'/email%2F123' - && $request->hasHeader('Authorization', 'Bearer re_test') - && $request->hasHeader('User-Agent', ResendClient::USER_AGENT); - }); - - Http::assertSent(function (Request $request) use ($downloadUrl) { - return $request->url() === $downloadUrl - && ! $request->hasHeader('Authorization'); - }); - } - - #[Test] - public function it_requires_an_api_key() - { - config(['mailbox.services.resend.api_key' => null]); - - $this->expectException(LogicException::class); - $this->expectExceptionMessage('Resend API key is not configured.'); - - (new ResendClient)->rawEmail('email_123'); - } - - #[Test] - #[DataProvider('metadataFailureStatuses')] - public function it_throws_for_metadata_failures_without_exposing_the_api_key(int $status) - { - Http::fake([ - ResendClient::API_URL.'/email_123' => Http::response('', $status), - ]); - - $exception = null; - - try { - (new ResendClient)->rawEmail('email_123'); - } catch (Throwable $caught) { - $exception = $caught; - } - - $this->assertNotNull($exception); - $this->assertStringNotContainsString('re_test', (string) $exception); - Http::assertSentCount(1); - } - - public static function metadataFailureStatuses(): array - { - return [ - 'redirect' => [302], - 'unauthorized' => [401], - 'not found' => [404], - 'rate limited' => [429], - 'server error' => [500], - ]; - } - - #[Test] - #[DataProvider('invalidDownloadUrls')] - public function it_rejects_invalid_raw_download_urls($downloadUrl) - { - Http::fake([ - ResendClient::API_URL.'/email_123' => Http::response([ - 'raw' => ['download_url' => $downloadUrl], - ]), - ]); - - $this->expectException(UnexpectedValueException::class); - $this->expectExceptionMessage('Resend did not return a valid raw email download URL.'); - - (new ResendClient)->rawEmail('email_123'); - } - - public static function invalidDownloadUrls(): array - { - return [ - 'missing' => [null], - 'empty' => [''], - 'malformed' => ['not a url'], - 'http' => ['http://example.com/raw'], - 'credentials' => ['https://user:pass@example.com/raw'], - ]; - } - - #[Test] - #[DataProvider('rawDownloadFailures')] - public function it_sanitizes_raw_download_failures(int $status, string $body) - { - $downloadUrl = 'https://inbound-cdn.resend.com/raw/email?signature=secret'; - - Http::fake([ - ResendClient::API_URL.'/email_123' => Http::response([ - 'raw' => ['download_url' => $downloadUrl], - ]), - $downloadUrl => Http::response( - $body, - $status, - $status === 302 ? ['Location' => 'https://example.com/redirected'] : [] - ), - ]); - - try { - (new ResendClient)->rawEmail('email_123'); - $this->fail('Expected raw download failure.'); - } catch (RuntimeException $exception) { - $this->assertSame('Unable to download the raw Resend email.', $exception->getMessage()); - $this->assertNull($exception->getPrevious()); - $this->assertStringNotContainsString('signature=secret', (string) $exception); - } - - Http::assertSentCount(2); - } - - public static function rawDownloadFailures(): array - { - return [ - 'empty success' => [200, ''], - 'redirect' => [302, 'redirect'], - 'server error' => [500, 'download failed'], - ]; - } -} -``` - -- [ ] **Step 2: Run the test and observe the missing-client failure** - -Run: - -```bash -vendor/bin/phpunit tests/Clients/ResendClientTest.php -``` - -Expected: FAIL because `BeyondCode\Mailbox\Clients\ResendClient` does not exist. - -- [ ] **Step 3: Implement the authenticated metadata request** - -Start `src/Clients/ResendClient.php` with: - -```php -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); - } -``` - -- [ ] **Step 4: Implement the isolated raw download and URL validation** - -Complete the class with: - -```php - 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']); - } -} -``` - -- [ ] **Step 5: Run focused and package tests** - -Run: - -```bash -vendor/bin/phpunit tests/Clients/ResendClientTest.php -composer test -``` - -Expected: all client cases pass; no request escapes `Http::fake()`. - -- [ ] **Step 6: Commit the client** - -```bash -git add src/Clients/ResendClient.php tests/Clients/ResendClientTest.php -git commit -m "feat: retrieve Resend raw emails" -``` - ---- - -### Task 4: Queue Rate Limiting and Email Processing Job - -**Files:** -- Create: `src/Queue/Middleware/ResendRateLimited.php` -- Create: `src/Jobs/ProcessResendEmail.php` -- Create: `tests/Queue/Middleware/ResendRateLimitedTest.php` -- Create: `tests/Jobs/ProcessResendEmailTest.php` - -**Interfaces:** -- Consumes: `ResendClient::rawEmail(string): string`, `mailbox.model`, `mailbox.services.resend.rate_limit`, `Mailbox::callMailboxes()`. -- Produces: `ProcessResendEmail::__construct(string $emailId, string $webhookId)`, `uniqueId(): string`, `backoff(): array`, `retryUntil(): DateTimeInterface`, `middleware(): array`, and `handle(ResendClient): void` for the controller and queue worker. - -- [ ] **Step 1: Write the one-second middleware tests** - -Create `tests/Queue/Middleware/ResendRateLimitedTest.php`: - -```php -app->make(RateLimiter::class)->clear(ResendRateLimited::KEY); - - parent::tearDown(); - } - - #[Test] - public function it_releases_jobs_over_the_per_second_limit() - { - config(['mailbox.services.resend.rate_limit' => 1]); - - $job = Mockery::mock(); - $job->shouldReceive('release') - ->once() - ->with(Mockery::on(fn ($seconds) => $seconds >= 1 && $seconds <= 2)) - ->andReturn(false); - - $handled = 0; - $next = function () use (&$handled) { - $handled++; - }; - - $middleware = new ResendRateLimited; - $middleware->handle($job, $next); - $middleware->handle($job, $next); - - $this->assertSame(1, $handled); - } - - #[Test] - public function it_rejects_a_non_positive_limit() - { - config(['mailbox.services.resend.rate_limit' => 0]); - - $this->expectException(LogicException::class); - - (new ResendRateLimited)->handle(Mockery::mock(), fn () => null); - } -} -``` - -- [ ] **Step 2: Write the job metadata and handler tests** - -Create `tests/Jobs/ProcessResendEmailTest.php`: - -```php -onConnection('sync'); - $queuedJob = (new ProcessResendEmail('email_123', 'msg_123'))->onConnection('redis'); - - $this->assertSame('msg_123', $queuedJob->uniqueId()); - $this->assertSame([5, 60, 300, 1800, 7200, 18000, 36000], $queuedJob->backoff()); - $this->assertSame(now()->addDay()->timestamp, $queuedJob->retryUntil()->timestamp); - $this->assertSame(180, $queuedJob->timeout); - $this->assertSame([], $syncJob->middleware()); - $this->assertInstanceOf(ResendRateLimited::class, $queuedJob->middleware()[0]); - } - - #[Test] - public function it_builds_the_configured_model_and_calls_mailboxes() - { - config(['mailbox.model' => TestResendInboundEmail::class]); - - $client = Mockery::mock(ResendClient::class); - $client->shouldReceive('rawEmail')->once()->with('email_123')->andReturn('raw mime'); - - Mailbox::shouldReceive('callMailboxes') - ->once() - ->with(Mockery::type(TestResendInboundEmail::class)); - - (new ProcessResendEmail('email_123', 'msg_123'))->handle($client); - - $this->assertSame('raw mime', TestResendInboundEmail::$rawMessage); - } - - #[Test] - public function it_does_not_call_mailboxes_when_retrieval_fails() - { - $expected = new RuntimeException('api failed'); - $client = Mockery::mock(ResendClient::class); - $client->shouldReceive('rawEmail') - ->once() - ->with('email_123') - ->andThrow($expected); - - Mailbox::shouldReceive('callMailboxes')->never(); - - try { - (new ProcessResendEmail('email_123', 'msg_123'))->handle($client); - $this->fail('Expected the client exception.'); - } catch (RuntimeException $exception) { - $this->assertSame($expected, $exception); - } - } -} - -class TestResendInboundEmail extends InboundEmail -{ - public static ?string $rawMessage = null; - - public static function fromMessage($message) - { - static::$rawMessage = $message; - - return parent::fromMessage($message); - } -} -``` - -- [ ] **Step 3: Run the tests and observe both missing-class failures** - -Run: - -```bash -vendor/bin/phpunit tests/Queue/Middleware/ResendRateLimitedTest.php tests/Jobs/ProcessResendEmailTest.php -``` - -Expected: FAIL because the middleware and job do not exist. - -- [ ] **Step 4: Implement the Laravel 10-compatible one-second middleware** - -Create `src/Queue/Middleware/ResendRateLimited.php`: - -```php -tooManyAttempts(static::KEY, $maxAttempts)) { - return $job->release($limiter->availableIn(static::KEY) + 1); - } - - $limiter->hit(static::KEY, 1); - - return $next($job); - } -} -``` - -- [ ] **Step 5: Implement the unique processing job** - -Create `src/Jobs/ProcessResendEmail.php`: - -```php -emailId = $emailId; - $this->webhookId = $webhookId; - } - - public function uniqueId(): string - { - return $this->webhookId; - } - - 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->connection === 'sync' ? [] : [new ResendRateLimited]; - } - - public function handle(ResendClient $client): void - { - /** @var class-string $modelClass */ - $modelClass = config('mailbox.model'); - $email = $modelClass::fromMessage($client->rawEmail($this->emailId)); - - Mailbox::callMailboxes($email); - } -} -``` - -- [ ] **Step 6: Run focused and package tests** - -Run: - -```bash -vendor/bin/phpunit tests/Queue/Middleware/ResendRateLimitedTest.php tests/Jobs/ProcessResendEmailTest.php -composer test -``` - -Expected: middleware and job tests pass, followed by the complete suite. - -- [ ] **Step 7: Commit queue processing** - -```bash -git add src/Queue/Middleware/ResendRateLimited.php src/Jobs/ProcessResendEmail.php tests/Queue/Middleware/ResendRateLimitedTest.php tests/Jobs/ProcessResendEmailTest.php -git commit -m "feat: process Resend emails through queues" -``` - ---- - -### Task 5: Authenticated Webhook Endpoint and Sync Integration - -**Files:** -- Create: `src/Http/Requests/ResendRequest.php` -- Create: `src/Http/Controllers/ResendController.php` -- Create: `tests/Concerns/SignsResendWebhooks.php` -- Create: `tests/Controllers/ResendTest.php` -- Create: `tests/Fixtures/resend-email.eml` - -**Interfaces:** -- Consumes: `ResendWebhookSignature::verify(...)`, `ProcessResendEmail`, `dispatch()`, the Resend route from Task 1, and the client/job from Tasks 3–4. -- Produces: HTTP 401/400/204/200/5xx behavior and the complete webhook-to-mailbox flow. - -- [ ] **Step 1: Create the deterministic signed-request test helper** - -Create `tests/Concerns/SignsResendWebhooks.php`: - -```php -timestamp; - $secret = 'test-secret'; - - if ($configureSecret) { - config([ - 'mailbox.services.resend.webhook_secret' => 'whsec_'.base64_encode($secret), - ]); - } - - $signature = $signature ?? base64_encode(hash_hmac( - 'sha256', - $webhookId.'.'.$timestamp.'.'.$payload, - $secret, - true - )); - - return $this->call('POST', '/laravel-mailbox/resend', [], [], [], [ - 'CONTENT_TYPE' => 'application/json', - 'HTTP_SVIX_ID' => $webhookId, - 'HTTP_SVIX_TIMESTAMP' => (string) $timestamp, - 'HTTP_SVIX_SIGNATURE' => 'v1,'.$signature, - ], $payload); - } -} -``` - -- [ ] **Step 2: Write authentication, validation, and dispatch tests** - -Create `tests/Controllers/ResendTest.php`: - -```php - 'email.received', - 'data' => ['email_id' => 'email_123'], - ]); - - $this->postResendWebhook($payload, signature: 'invalid')->assertStatus(401); - - Bus::assertNothingDispatched(); - } - - #[Test] - public function it_rejects_missing_svix_headers_without_dispatching() - { - Bus::fake(); - config([ - 'mailbox.services.resend.webhook_secret' => 'whsec_'.base64_encode('test-secret'), - ]); - - $this->call( - 'POST', - '/laravel-mailbox/resend', - [], - [], - [], - ['CONTENT_TYPE' => 'application/json'], - '{"type":"email.received","data":{"email_id":"email_123"}}' - )->assertStatus(401); - - Bus::assertNothingDispatched(); - } - - #[Test] - public function it_rejects_stale_webhooks_without_dispatching() - { - Bus::fake(); - - $this->postResendWebhook( - '{"type":"email.received","data":{"email_id":"email_123"}}', - timestamp: now()->timestamp - 301 - )->assertStatus(401); - - Bus::assertNothingDispatched(); - } - - #[Test] - public function it_rejects_malformed_signed_json_without_dispatching() - { - Bus::fake(); - - $this->postResendWebhook('{"type":') - ->assertStatus(400); - - Bus::assertNothingDispatched(); - } - - #[Test] - public function it_rejects_invalid_received_payloads() - { - Bus::fake(); - - $this->postResendWebhook('{"type":"email.received","data":{}}') - ->assertStatus(400); - - Bus::assertNothingDispatched(); - } - - #[Test] - public function it_acknowledges_signed_unrelated_events() - { - Bus::fake(); - - $this->postResendWebhook('{"type":"email.delivered","data":{}}') - ->assertNoContent(); - - Bus::assertNothingDispatched(); - } - - #[Test] - public function it_dispatches_received_emails_to_the_configured_connection() - { - Bus::fake(); - config(['mailbox.services.resend.queue_connection' => 'redis']); - - $this->postResendWebhook( - '{"type":"email.received","data":{"email_id":"email_123"}}', - 'msg_123' - )->assertOk(); - - Bus::assertDispatched(ProcessResendEmail::class, function ($job) { - return $job->emailId === 'email_123' - && $job->webhookId === 'msg_123' - && $job->connection === 'redis'; - }); - } - - #[Test] - #[DataProvider('invalidQueueConnections')] - public function it_rejects_invalid_queue_configuration($connection) - { - Bus::fake(); - config(['mailbox.services.resend.queue_connection' => $connection]); - - $this->postResendWebhook( - '{"type":"email.received","data":{"email_id":"email_123"}}' - )->assertStatus(500); - - Bus::assertNothingDispatched(); - } - - public static function invalidQueueConnections(): array - { - return [ - 'null' => [null], - 'empty string' => [''], - ]; - } - - #[Test] - #[DataProvider('invalidRateLimits')] - public function it_rejects_invalid_rate_limit_configuration($rateLimit) - { - Bus::fake(); - config(['mailbox.services.resend.rate_limit' => $rateLimit]); - - $this->postResendWebhook( - '{"type":"email.received","data":{"email_id":"email_123"}}' - )->assertStatus(500); - - Bus::assertNothingDispatched(); - } - - public static function invalidRateLimits(): array - { - return [ - 'zero' => [0], - 'negative' => [-1], - 'non-numeric' => ['invalid'], - ]; - } - - #[Test] - public function it_reports_a_missing_webhook_secret_as_server_configuration() - { - Bus::fake(); - $payload = '{"type":"email.received","data":{"email_id":"email_123"}}'; - - $this->postResendWebhook( - $payload, - configureSecret: false - )->assertStatus(500); - - Bus::assertNothingDispatched(); - } - - #[Test] - public function it_releases_the_unique_lock_when_dispatch_fails() - { - $original = $this->app->make(Dispatcher::class); - $dispatcher = Mockery::mock(Dispatcher::class); - $dispatcher->shouldReceive('dispatch') - ->once() - ->andThrow(new RuntimeException('queue unavailable')); - - $this->app->instance(Dispatcher::class, $dispatcher); - - $payload = '{"type":"email.received","data":{"email_id":"email_123"}}'; - - try { - $this->postResendWebhook($payload, 'msg_retry')->assertStatus(500); - } finally { - $this->app->instance(Dispatcher::class, $original); - } - - Bus::fake(); - - $this->postResendWebhook($payload, 'msg_retry')->assertOk(); - Bus::assertDispatched(ProcessResendEmail::class); - } -} -``` - -- [ ] **Step 3: Add a realistic multipart MIME fixture** - -Create `tests/Fixtures/resend-email.eml`: - -```text -From: Resend Sender -To: inbox@example.com -Subject: Inbound from Resend -Message-ID: -Date: Tue, 28 Jul 2026 10:00:00 +0800 -MIME-Version: 1.0 -Content-Type: multipart/mixed; boundary="resend-boundary" - ---resend-boundary -Content-Type: text/plain; charset=UTF-8 -Content-Transfer-Encoding: 7bit - -Hello from Resend. ---resend-boundary -Content-Type: text/plain; name="note.txt" -Content-Disposition: attachment; filename="note.txt" -Content-Transfer-Encoding: base64 - -YXR0YWNobWVudCBjb250ZW50 ---resend-boundary-- -``` - -- [ ] **Step 4: Add synchronous integration, failure-ordering, and custom-model tests** - -Insert these methods into `ResendTest` before its closing brace: - -```php - #[Test] - public function it_processes_raw_mime_and_attachments_on_the_sync_connection() - { - $downloadUrl = 'https://inbound-cdn.resend.com/raw/email?signature=test'; - $mime = file_get_contents(__DIR__.'/../Fixtures/resend-email.eml'); - $handled = false; - - config([ - 'mailbox.services.resend.api_key' => 're_test', - 'mailbox.services.resend.queue_connection' => 'sync', - ]); - - Http::fake([ - ResendClient::API_URL.'/email_123' => Http::response([ - 'raw' => ['download_url' => $downloadUrl], - ]), - $downloadUrl => Http::response($mime), - ]); - - Mailbox::to('inbox@example.com', function (InboundEmail $email) use (&$handled) { - $handled = true; - - $this->assertSame('Inbound from Resend', $email->subject()); - $this->assertSame('Hello from Resend.', trim($email->text())); - $this->assertCount(1, $email->attachments()); - }); - - $this->postResendWebhook( - '{"type":"email.received","data":{"email_id":"email_123"}}', - 'msg_123' - )->assertOk(); - - $this->assertTrue($handled); - Http::assertSentCount(2); - } - - #[Test] - public function it_returns_a_server_error_without_calling_mailboxes_when_sync_retrieval_fails() - { - $handled = false; - - config([ - 'mailbox.services.resend.api_key' => 're_test', - 'mailbox.services.resend.queue_connection' => 'sync', - ]); - - Http::fake([ - ResendClient::API_URL.'/email_123' => Http::response('', 500), - ]); - - Mailbox::to('inbox@example.com', function () use (&$handled) { - $handled = true; - }); - - $this->postResendWebhook( - '{"type":"email.received","data":{"email_id":"email_123"}}', - 'msg_failure' - )->assertStatus(500); - - $this->assertFalse($handled); - Http::assertSentCount(1); - } - - #[Test] - public function it_honors_the_configured_model() - { - $downloadUrl = 'https://inbound-cdn.resend.com/raw/email?signature=test'; - - config([ - 'mailbox.model' => TestResendControllerInboundEmail::class, - 'mailbox.services.resend.api_key' => 're_test', - 'mailbox.services.resend.queue_connection' => 'sync', - ]); - - Http::fake([ - ResendClient::API_URL.'/email_123' => Http::response([ - 'raw' => ['download_url' => $downloadUrl], - ]), - $downloadUrl => Http::response('raw mime'), - ]); - - Mailbox::shouldReceive('callMailboxes') - ->once() - ->with(Mockery::type(TestResendControllerInboundEmail::class)); - - $this->postResendWebhook( - '{"type":"email.received","data":{"email_id":"email_123"}}', - 'msg_model' - )->assertOk(); - - $this->assertSame('raw mime', TestResendControllerInboundEmail::$rawMessage); - } -``` - -Append the controller-local model after `ResendTest`: - -```php -class TestResendControllerInboundEmail extends InboundEmail -{ - public static ?string $rawMessage = null; - - public static function fromMessage($message) - { - static::$rawMessage = $message; - - return parent::fromMessage($message); - } -} -``` - -- [ ] **Step 5: Run endpoint tests and observe the missing request/controller failure** - -Run: - -```bash -vendor/bin/phpunit tests/Controllers/ResendTest.php -``` - -Expected: FAIL because the registered route's request and controller classes do not exist. - -- [ ] **Step 6: Implement the authenticated FormRequest** - -Create `src/Http/Requests/ResendRequest.php`: - -```php -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.'); - } - - public function rules() - { - return [ - 'type' => ['required', 'string'], - 'data' => ['required', '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->input('type'); - } - - public function emailId(): ?string - { - return $this->input('data.email_id'); - } - - public function webhookId(): string - { - return $this->header('svix-id'); - } -} -``` - -- [ ] **Step 7: Implement the dispatching controller** - -Create `src/Http/Controllers/ResendController.php`: - -```php -eventType() !== 'email.received') { - return response('', 204); - } - - $connection = config('mailbox.services.resend.queue_connection', 'sync'); - $rateLimit = (int) config('mailbox.services.resend.rate_limit', 5); - - 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.'); - } - - $job = (new ProcessResendEmail($request->emailId(), $request->webhookId())) - ->onConnection($connection); - - try { - dispatch($job); - } catch (Throwable $exception) { - (new UniqueLock(app(Cache::class)))->release($job); - - throw $exception; - } - - return response('', 200); - } -} -``` - -- [ ] **Step 8: Run all Resend tests and the package suite** - -Run: - -```bash -vendor/bin/phpunit tests/Drivers/ResendTest.php tests/Support/ResendWebhookSignatureTest.php tests/Clients/ResendClientTest.php tests/Queue/Middleware/ResendRateLimitedTest.php tests/Jobs/ProcessResendEmailTest.php tests/Controllers/ResendTest.php -composer test -``` - -Expected: all Resend-focused tests and the complete existing suite pass. - -- [ ] **Step 9: Commit the endpoint** - -```bash -git add src/Http/Requests/ResendRequest.php src/Http/Controllers/ResendController.php tests/Concerns/SignsResendWebhooks.php tests/Controllers/ResendTest.php tests/Fixtures/resend-email.eml -git commit -m "feat: accept Resend inbound webhooks" -``` - ---- - -### Task 6: Driver Documentation and Final Verification - -**Files:** -- Modify: `docs/drivers/drivers.md` - -**Interfaces:** -- Consumes: Every public configuration key and operational behavior implemented in Tasks 1–5. -- Produces: User-facing setup and operations guidance; no new runtime API. - -- [ ] **Step 1: Add the Resend driver documentation** - -Insert this section before `## Local development / log driver` in -`docs/drivers/drivers.md`: - -````markdown -## 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 -``` - -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. Configure the queue connection's `retry_after` -above 180 seconds and keep the worker's `--timeout` below `retry_after`. -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. -```` - -- [ ] **Step 2: Run documentation and code hygiene checks** - -Run: - -```bash -composer validate --strict -git diff --check -``` - -Expected: both exit successfully. - -- [ ] **Step 3: Run the complete test suite** - -Run: - -```bash -composer test -``` - -Expected: every existing and new test passes. - -- [ ] **Step 4: Review the final feature diff** - -Run: - -```bash -git status --short -git diff --stat upstream/master...HEAD -git diff upstream/master...HEAD -- composer.json config/mailbox.php src tests docs/drivers/drivers.md -``` - -Expected: - -- only Resend feature, tests, configuration, dependencies, design/plan, and - driver documentation are present; -- no migration or unrelated driver behavior changed; -- no secrets, signed URLs, generated dependency trees, or ignored artifacts - are staged. - -- [ ] **Step 5: Commit the documentation** - -```bash -git add docs/drivers/drivers.md -git commit -m "docs: document Resend inbound driver" -``` - -- [ ] **Step 6: Run the completion verification required before publishing** - -Invoke `superpowers:verification-before-completion`, then re-run every command -it requires. Record the exact test counts and environment in the handoff. - -- [ ] **Step 7: Request code review before push/PR** - -Invoke `superpowers:requesting-code-review`. Address any correctness, -compatibility, security, or scope findings before publishing. - -- [ ] **Step 8: Publish only after review passes** - -Invoke `github:yeet` to confirm scope, push -`codex/resend-inbound-driver` to `origin`, and open a draft pull request from: - -```text -jasonfish568:codex/resend-inbound-driver -``` - -to: - -```text -beyondcode:master -``` - -The PR description must summarize signature verification, Receiving API/raw -MIME retrieval, sync/async queue behavior, rate limiting, at-least-once -semantics, documentation, and the verified PHP/Laravel test coverage. diff --git a/docs/superpowers/specs/2026-07-28-resend-inbound-driver-design.md b/docs/superpowers/specs/2026-07-28-resend-inbound-driver-design.md deleted file mode 100644 index 09a2d5a..0000000 --- a/docs/superpowers/specs/2026-07-28-resend-inbound-driver-design.md +++ /dev/null @@ -1,445 +0,0 @@ -# Resend Inbound Driver Design - -Date: 2026-07-28 - -## Summary - -Add a first-party Resend inbound driver to `beyondcode/laravel-mailbox`. -The driver will verify Resend's Svix-signed `email.received` webhook, queue -processing on a configurable Laravel queue connection, retrieve the received -email's raw MIME through the Resend Receiving API, and pass that MIME to the -package's configured `InboundEmail` model. - -The implementation targets the current `master` compatibility matrix: -PHP 8.1 and Laravel 10 through 13. It does not target or preserve source -compatibility with laravel-mailbox 3.1 or Laravel 9. - -## Goals - -- Support `MAILBOX_DRIVER=resend`. -- Verify the exact raw webhook body using Resend's Svix headers and signing - secret before trusting any payload field. -- Retrieve the complete raw MIME, including attachments, instead of - reconstructing a message from Resend's parsed HTML and text fields. -- Support zero-worker local use through Laravel's `sync` queue connection. -- Support bursty and high-volume production use through a configurable - asynchronous queue connection and per-second API rate limiting. -- Preserve the existing configured-model path through - `InboundEmail::fromMessage()` and `Mailbox::callMailboxes()`. -- Cover the feature with deterministic tests that make no real network calls. -- Document setup, operational limits, retry behavior, and at-least-once - delivery semantics. - -## Non-goals - -- Supporting laravel-mailbox 3.1, Laravel 9, or PHP 8.0. -- Adding the official Resend PHP SDK or the Svix PHP SDK. -- Reconstructing MIME from Resend's parsed response fields. -- Adding a new database table or persistent webhook-event ledger. -- Providing exactly-once mailbox execution. -- Adding generic queue support to the existing Mailgun, Postmark, SendGrid, or - MailCare drivers. -- Changing the public mailbox routing API. -- Adding a hard maximum MIME size that differs from the existing drivers. - -## Dependencies - -Add these Illuminate components with the same version matrix already used by -the package: - -- `illuminate/http`: `^10.0|^11.0|^12.0|^13.0` -- `illuminate/bus`: `^10.0|^11.0|^12.0|^13.0` -- `illuminate/queue`: `^10.0|^11.0|^12.0|^13.0` -- `illuminate/cache`: `^10.0|^11.0|^12.0|^13.0` - -Laravel applications already receive these components through -`laravel/framework`. Declaring them explicitly keeps the package's component -dependencies accurate. No Resend-specific or Svix-specific Composer package -will be added. - -## Configuration - -Add `resend` to the documented list of supported drivers and add: - -```php -'services' => [ - // ... - '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), - ], -], -``` - -`queue_connection` deliberately defaults to `sync`. This preserves -zero-configuration behavior and propagates processing failures to the webhook -response. Production applications expecting bursts can select `redis`, -`database`, `sqs`, or another configured Laravel queue connection. - -`rate_limit` defaults to five Receiving API requests per second because -Resend's default API limit is five requests per second per team. Applications -that share the team-level limit with other workloads can configure a lower -value. Applications with a Resend-approved higher limit can configure a higher -value. - -The driver will throw a server error when an API key, webhook secret, queue -connection, or positive rate limit is not configured correctly. A deployment -configuration error must not be presented as an invalid third-party webhook. - -## Components - -### `Drivers\Resend` - -Register: - -```text -POST /{mailbox.path}/resend -``` - -The default endpoint is `POST /laravel-mailbox/resend`. The driver does not use -the package's HTTP basic-authentication middleware because authenticity comes -from the Svix signature. - -### `MailboxManager` - -Add `createResendDriver()` and import the new driver, matching the existing -driver factory pattern. - -### `Http\Requests\ResendRequest` - -The request is the inbound security boundary. It will: - -1. Read the exact raw body with `getContent()`. -2. require the `svix-id`, `svix-timestamp`, and `svix-signature` headers; -3. call `Support\ResendWebhookSignature` before trusting parsed JSON; -4. parse the JSON payload; -5. expose the signed event type, `svix-id`, and `data.email_id`; -6. require a non-empty string `data.email_id` only for `email.received`. - -A correctly signed event with a different type is valid but irrelevant to the -inbound driver. It will be acknowledged with HTTP 204 so a mistakenly broad -Resend webhook subscription does not retry forever. - -### `Support\ResendWebhookSignature` - -Implement the small symmetric Svix verification surface without a vendor SDK. -The verifier will: - -- require a non-empty `whsec_` signing secret; -- Base64-decode the secret suffix strictly; -- require an integer Unix timestamp no more than 300 seconds in the past or - future; -- build the signed content as - `{svix-id}.{svix-timestamp}.{raw-body}`; -- calculate a raw HMAC-SHA256 digest and Base64-encode it; -- parse the space-delimited signature header; -- accept the request when any `v1,` entry matches; -- compare signatures with `hash_equals()`. - -The verifier will be isolated from HTTP and queue concerns so fixed vectors and -boundary conditions can be tested directly. - -### `Http\Controllers\ResendController` - -After request validation: - -- return 204 for a signed non-`email.received` event; -- create `Jobs\ProcessResendEmail` with only the signed `email_id` and - `svix-id`; -- validate the configured queue connection and positive API rate limit; -- dispatch the job through Laravel's `dispatch()` helper; -- return 200 after successful dispatch. - -When the configured connection is `sync`, dispatch executes the job in the -request and propagates exceptions as HTTP 5xx. With an asynchronous connection, -HTTP 200 means the job was accepted by Laravel's queue, not that mailbox -processing has completed. Using the `dispatch()` helper also ensures Laravel -acquires the `ShouldBeUnique` lock before pushing the job. If dispatch or -synchronous processing throws, the controller releases that unique lock before -rethrowing. Otherwise a failure after lock acquisition but before successful -queue acceptance could suppress Resend's next webhook retry. - -### `Jobs\ProcessResendEmail` - -The job will implement `ShouldQueue` and `ShouldBeUnique`. - -- `uniqueId()` returns the `svix-id`. -- The unique lock prevents the same delivery from being queued or processed - concurrently. -- The lock does not promise durable exactly-once behavior after completion. -- The job carries only scalar identifiers; it never serializes the request, - API key, webhook secret, MIME, or signed download URL. - -For asynchronous connections, the job applies -`Queue\Middleware\ResendRateLimited`. The `sync` connection relies on Resend's -webhook backoff instead of releasing a synchronous job. - -The job gets a fresh signed raw download URL during each attempt by invoking -`Clients\ResendClient` with the `email_id`. It then: - -1. resolves the configured mailbox model; -2. calls `::fromMessage($mime)`; -3. passes the resulting inbound email to `Mailbox::callMailboxes()`. - -The job uses a 24-hour `retryUntil()` window instead of a fixed attempt count. -Laravel's queue rate-limit middleware increments a job's attempt count when it -releases the job, so a small hard attempt limit could fail healthy jobs merely -because they waited for rate-limit capacity. - -Processing exceptions use this backoff sequence: - -```text -5 seconds, 1 minute, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours -``` - -After the final value, subsequent processing failures continue using the -10-hour delay until the 24-hour retry window ends. Rate-limit middleware -releases use short capacity-based delays instead of this exception backoff. -A job timeout of 180 seconds leaves room for a large MIME download and mailbox -parsing while still bounding a stuck worker. - -### `Queue\Middleware\ResendRateLimited` - -Laravel 10's named rate-limit definition object supports minute windows but -does not expose `Limit::perSecond()`. To preserve the package's Laravel 10 -floor, a small package middleware will use the cross-version -`Illuminate\Cache\RateLimiter` API directly. - -For each asynchronous job attempt, the middleware will: - -1. read and validate the positive integer - `mailbox.services.resend.rate_limit`; -2. check a package-specific cache key against that maximum; -3. release an over-limit job for `availableIn($key) + 1` seconds; -4. otherwise call `hit($key, 1)` to consume one slot in a one-second window; -5. pass the job to the next middleware or handler. - -The middleware resolves `RateLimiter` from Laravel's container inside -`handle()`, so the queued middleware object contains no unserializable cache -service. It deliberately applies only to asynchronous connections because -releasing a job on the `sync` connection cannot defer work. - -### `Clients\ResendClient` - -The client performs two separate requests. - -First, retrieve metadata: - -```text -GET https://api.resend.com/emails/receiving/{rawurlencoded-email-id} -Authorization: Bearer {api-key} -User-Agent: beyondcode/laravel-mailbox -``` - -The host and scheme for this authenticated request are fixed in code. The -request does not follow redirects, uses a five-second connection timeout, and -uses a 15-second total timeout. It must return a successful JSON response with -a non-empty `raw.download_url`. - -Second, download the raw MIME: - -- require an HTTPS URL; -- do not send the Resend API key or package credentials; -- do not follow redirects; -- use a five-second connection timeout and a 120-second total timeout; -- require a successful response with a non-empty body. - -The signed URL is treated as a credential and must not be logged. The entire -MIME becomes a string because the existing configured-model contract accepts -the whole raw message and stores it in a `longText` column when matched. -Queue concurrency and rate limiting bound aggregate pressure; individual -workers still need enough memory for the largest accepted inbound email. - -Raw-download transport errors will be wrapped in a sanitized package exception -that does not include the signed URL, its query string, or the response body. -The original URL-bearing exception will not be chained into the new exception, -because Laravel's default failed-job logging may render chained exception -messages. - -## Request and Processing Flow - -```text -Resend - -> POST signed metadata webhook - -> ResendRequest verifies exact raw body - -> ResendController dispatches ProcessResendEmail - -> queue connection (sync or asynchronous) - -> ResendClient calls authenticated Receiving API - -> ResendClient downloads unauthenticated signed raw MIME URL - -> configured InboundEmail model::fromMessage(raw MIME) - -> Mailbox::callMailboxes() -``` - -The raw download URL is obtained inside the job, never before queueing, so its -short expiration does not make a delayed job unusable. - -## HTTP and Failure Semantics - -| Condition | Behavior | -| --- | --- | -| Missing Svix header | HTTP 401; do not dispatch | -| Invalid signature | HTTP 401; do not dispatch | -| Timestamp outside the 300-second window | HTTP 401; do not dispatch | -| Malformed JSON | HTTP 400; do not dispatch | -| `email.received` without a valid `data.email_id` | HTTP 400; do not dispatch | -| Valid signed non-`email.received` event | HTTP 204; do not dispatch | -| Invalid package configuration | HTTP 5xx | -| Queue dispatch failure | Release the delivery's unique lock and return HTTP 5xx, allowing Resend to retry | -| Successful asynchronous dispatch | HTTP 200 | -| Successful synchronous processing | HTTP 200 | -| Receiving API, raw download, MIME conversion, or mailbox failure on `sync` | HTTP 5xx, allowing Resend to retry | -| The same failures on an asynchronous queue | Throw from the job and apply queue retry/backoff | - -All non-successful Receiving API responses, including 429 and 5xx, are -retriable processing failures. A malformed successful API response is also a -processing failure; silently rebuilding a partial email is not allowed. - -## Delivery and Idempotency - -Resend webhooks and Laravel queued jobs are at-least-once systems. -`ShouldBeUnique` keyed by `svix-id` prevents concurrent duplicate work, but it -does not create a permanent processed-event ledger. A manual Resend replay or -a failure after a mailbox handler has already produced a side effect can cause -the handler to run again. - -Documentation will tell consumers to make side-effecting handlers idempotent, -preferably by using the raw email's stable `Message-Id` as the business -deduplication key. The driver will not alter the MIME or replace its -`Message-Id` with Resend's API identifier. - -## Security - -- Verify the exact raw body before reading `type` or `data.email_id`. -- Reject secrets that are missing, malformed, or not valid strict Base64 after - the `whsec_` prefix. -- Bound both old and future timestamps to prevent replay and clock-skew abuse. -- Use constant-time signature comparison. -- Never log the API key, webhook secret, raw signature header, or signed - download URL. -- Sanitize raw-download exceptions before they can reach failed-job logging. -- Send the Bearer token only to the fixed Resend API origin and disable - redirects on that authenticated request. -- Require HTTPS for the returned download URL. -- Disable download redirects so a signed URL cannot redirect the client to an - unexpected destination. -- URL-encode the `email_id` path segment. -- Do not add HTTP basic authentication to the Resend route. - -## Testing - -All tests use Laravel, bus, queue, cache, and HTTP fakes. No test calls Resend. - -### Driver and route tests - -- Selecting `resend` creates and registers the driver. -- The configured mailbox path prefixes the Resend endpoint. -- The endpoint accepts POST and does not use basic-authentication middleware. - -### Signature tests - -- Verify a fixed, independently calculated Svix-compatible vector. -- Reject a one-byte raw-body change. -- Reject a JSON parse-and-reserialize change. -- Accept a matching `v1` entry among multiple signature entries. -- Reject unsupported signature versions without a matching `v1`. -- Reject a wrong secret or signature. -- Reject missing and malformed `whsec_` secrets. -- Reject malformed Base64. -- Reject missing headers. -- Accept timestamps on the permitted boundary. -- Reject old and future timestamps outside the 300-second window. - -### Request and controller tests - -- Missing or invalid signatures return 401 and dispatch nothing. -- Malformed JSON and missing `email_id` return 400 and dispatch nothing. -- Signed unrelated events return 204 and dispatch nothing. -- A signed received event dispatches the expected scalar identifiers. -- Dispatch uses the configured `sync` or asynchronous connection. -- Invalid queue or non-positive rate-limit configuration returns HTTP 5xx. -- A dispatch exception releases the unique lock and becomes HTTP 5xx. -- The same delivery can be dispatched when Resend retries after that exception. - -### Client tests - -- The metadata request uses the fixed Resend API origin, URL-encoded email ID, - Bearer token, and package User-Agent. -- The raw request uses the returned HTTPS URL without Authorization. -- Redirects are disabled for the raw request. -- A raw MIME fixture is returned byte-for-byte. -- Metadata redirects, 401, 404, 429, and 5xx responses throw. -- Raw-download failures throw. -- Missing, empty, malformed, or non-HTTPS `raw.download_url` values throw. -- An empty raw MIME response throws. -- Failure output and exceptions do not expose configured secrets or the signed - URL. - -### Job tests - -- The job's unique ID is the `svix-id`. -- The job exposes the approved 24-hour retry window, timeout, and exception - backoff sequence. -- Asynchronous processing supplies `ResendRateLimited`; `sync` processing - supplies no queue middleware. -- The custom middleware consumes at most the configured number of cache - limiter hits in a one-second window and releases excess jobs. -- `sync` exceptions propagate to the request. -- Asynchronous exceptions remain queue failures. -- A realistic MIME fixture with an attachment reaches the configured model and - exposes the expected headers, body, and attachment after processing. -- The configured custom model is honored. -- `Mailbox::callMailboxes()` is invoked only after both HTTP requests succeed. - -Run the complete package test suite under the existing PHP 8.1 through 8.5 and -Laravel 10 through 13 CI matrix. - -## Documentation - -Update `config/mailbox.php` and `docs/drivers/drivers.md`. - -The Resend driver documentation will include: - -- required environment variables; -- `MAILBOX_DRIVER=resend`; -- the default `/laravel-mailbox/resend` endpoint; -- selecting only the `email.received` webhook event; -- copying the endpoint-specific Resend webhook signing secret; -- the distinction between the API key and webhook secret; -- why the driver retrieves raw MIME after receiving metadata; -- the default `sync` behavior; -- configuring an asynchronous queue and running workers for high volume; -- the default team-level five-request-per-second API limit and its config; -- retry behavior for sync and asynchronous modes; -- configuring the queue's `retry_after` above the job's 180-second timeout and - keeping the worker timeout below `retry_after`; -- at-least-once delivery and handler idempotency; -- worker memory considerations for MIME messages containing attachments. - -No README example, migration, changelog, or unrelated driver documentation -will be changed. - -## Success Criteria - -- A valid Resend `email.received` event produces the same configured - `InboundEmail` behavior as existing raw-MIME drivers, including attachments. -- Forged, stale, future-dated, or malformed webhook requests cannot cause an - API call or queue dispatch. -- The API key is never sent to the raw download origin. -- Local users can run with the default `sync` connection and no queue worker. -- High-volume users can select an asynchronous queue and cap Receiving API - calls at their configured per-second limit. -- Temporary API and download failures are retried through Resend in sync mode - or Laravel's queue in asynchronous mode. -- Tests pass across the repository's supported PHP and Laravel versions. -- The PR remains a single Resend inbound-driver feature with no unrelated - refactoring. - -## References - -- [Resend: Receiving Emails](https://resend.com/docs/dashboard/receiving/introduction) -- [Resend: Verify Webhook Requests](https://resend.com/docs/webhooks/verify-webhooks-requests) -- [Resend: Retrieve Received Email](https://resend.com/docs/api-reference/emails/retrieve-received-email) -- [Resend: Webhook Retries and Replays](https://resend.com/docs/webhooks/retries-and-replays) -- [Resend: API Usage Limits](https://resend.com/docs/api-reference/rate-limit) diff --git a/src/Http/Controllers/ResendController.php b/src/Http/Controllers/ResendController.php index 132012e..46361cc 100644 --- a/src/Http/Controllers/ResendController.php +++ b/src/Http/Controllers/ResendController.php @@ -19,6 +19,7 @@ public function __invoke(ResendRequest $request) $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.'); @@ -28,13 +29,20 @@ public function __invoke(ResendRequest $request) 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(), $request->webhookId())) ->onConnection($connection); + $synchronous = $job->usesSynchronousQueue(); try { dispatch($job); } catch (Throwable $exception) { - (new UniqueLock(app(Cache::class)))->release($job); + if (! $synchronous) { + (new UniqueLock(app(Cache::class)))->release($job); + } throw $exception; } diff --git a/src/Http/Requests/ResendRequest.php b/src/Http/Requests/ResendRequest.php index 25d6bf0..817339d 100644 --- a/src/Http/Requests/ResendRequest.php +++ b/src/Http/Requests/ResendRequest.php @@ -16,12 +16,13 @@ class ResendRequest extends FormRequest protected function prepareForValidation() { $secret = config('mailbox.services.resend.webhook_secret'); + $signature = app(ResendWebhookSignature::class); - if (! is_string($secret) || trim($secret) === '') { - throw new LogicException('Resend webhook secret is not configured.'); + if (! is_string($secret) || ! $signature->isValidSecret($secret)) { + throw new LogicException('Resend webhook secret is not configured correctly.'); } - $signed = app(ResendWebhookSignature::class)->verify( + $signed = $signature->verify( $this->getContent(), $this->header('svix-id'), $this->header('svix-timestamp'), diff --git a/src/Jobs/ProcessResendEmail.php b/src/Jobs/ProcessResendEmail.php index 2c0bd27..51d3ce5 100644 --- a/src/Jobs/ProcessResendEmail.php +++ b/src/Jobs/ProcessResendEmail.php @@ -10,10 +10,11 @@ use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeUnique; use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\Queue\InteractsWithQueue; class ProcessResendEmail implements ShouldQueue, ShouldBeUnique { - use Queueable; + use InteractsWithQueue, Queueable; public $timeout = 180; @@ -43,7 +44,19 @@ public function backoff(): array public function middleware(): array { - return $this->connection === 'sync' ? [] : [new ResendRateLimited]; + 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 diff --git a/src/MailboxServiceProvider.php b/src/MailboxServiceProvider.php index 1d1363d..c222331 100644 --- a/src/MailboxServiceProvider.php +++ b/src/MailboxServiceProvider.php @@ -5,6 +5,7 @@ use BeyondCode\Mailbox\Facades\Mailbox; use BeyondCode\Mailbox\Http\Middleware\MailboxBasicAuthentication; use BeyondCode\Mailbox\Routing\Router; +use Illuminate\Contracts\Foundation\CachesConfiguration; use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider; @@ -39,7 +40,10 @@ public function boot() */ public function register() { - $this->mergeConfigFrom(__DIR__.'/../config/mailbox.php', 'mailbox'); + $configPath = __DIR__.'/../config/mailbox.php'; + + $this->mergeConfigFrom($configPath, 'mailbox'); + $this->mergeResendConfigFrom($configPath); $this->app->singleton('mailbox', function () { return new Router($this->app); @@ -50,6 +54,29 @@ public function register() }); } + protected function mergeResendConfigFrom(string $path) + { + if ($this->app instanceof CachesConfiguration && $this->app->configurationIsCached()) { + return; + } + + $config = $this->app->make('config'); + $defaults = require $path; + $services = $config->get('mailbox.services', []); + + if (! is_array($services) || + (array_key_exists('resend', $services) && ! is_array($services['resend']))) { + return; + } + + $services['resend'] = array_merge( + $defaults['services']['resend'], + $services['resend'] ?? [] + ); + + $config->set('mailbox.services', $services); + } + protected function registerDriver() { Mailbox::mailbox()->register(); diff --git a/src/Queue/Middleware/ResendRateLimited.php b/src/Queue/Middleware/ResendRateLimited.php index 233041f..0a0c47a 100644 --- a/src/Queue/Middleware/ResendRateLimited.php +++ b/src/Queue/Middleware/ResendRateLimited.php @@ -19,13 +19,12 @@ public function handle($job, $next) /** @var RateLimiter $limiter */ $limiter = app(RateLimiter::class); + $reservations = $limiter->hit(static::KEY, 1); - if ($limiter->tooManyAttempts(static::KEY, $maxAttempts)) { + if ($reservations > $maxAttempts) { return $job->release($limiter->availableIn(static::KEY) + 1); } - $limiter->hit(static::KEY, 1); - return $next($job); } } diff --git a/src/Support/ResendWebhookSignature.php b/src/Support/ResendWebhookSignature.php index f3d72c3..c70b6e1 100644 --- a/src/Support/ResendWebhookSignature.php +++ b/src/Support/ResendWebhookSignature.php @@ -20,14 +20,10 @@ public function verify( return false; } - if (! str_starts_with($secret, static::PREFIX)) { - return false; - } - - $decodedSecret = base64_decode(substr($secret, strlen(static::PREFIX)), true); + $decodedSecret = $this->decodeSecret($secret); $timestamp = filter_var($timestamp, FILTER_VALIDATE_INT); - if ($decodedSecret === false || $decodedSecret === '' || $timestamp === false) { + if ($decodedSecret === null || $timestamp === false) { return false; } @@ -54,4 +50,20 @@ public function verify( return false; } + + public function isValidSecret(?string $secret): bool + { + return $this->decodeSecret($secret) !== null; + } + + protected function decodeSecret(?string $secret): ?string + { + if (! is_string($secret) || ! str_starts_with($secret, static::PREFIX)) { + return null; + } + + $decoded = base64_decode(substr($secret, strlen(static::PREFIX)), true); + + return $decoded === false || $decoded === '' ? null : $decoded; + } } diff --git a/tests/Controllers/ResendTest.php b/tests/Controllers/ResendTest.php index 16a269a..d194432 100644 --- a/tests/Controllers/ResendTest.php +++ b/tests/Controllers/ResendTest.php @@ -8,8 +8,13 @@ use BeyondCode\Mailbox\Jobs\ProcessResendEmail; use BeyondCode\Mailbox\Tests\Concerns\SignsResendWebhooks; use BeyondCode\Mailbox\Tests\TestCase; +use Illuminate\Bus\UniqueLock; use Illuminate\Contracts\Bus\Dispatcher; +use Illuminate\Contracts\Cache\Repository as Cache; +use Illuminate\Contracts\Cache\Lock; +use Illuminate\Queue\Events\JobFailed; use Illuminate\Support\Facades\Bus; +use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Http; use Mockery; use PHPUnit\Framework\Attributes\DataProvider; @@ -25,6 +30,7 @@ protected function getEnvironmentSetUp($app) parent::getEnvironmentSetUp($app); $app['config']['mailbox.driver'] = 'resend'; + $app['config']['mailbox.services.resend.api_key'] = 're_test'; } #[Test] @@ -213,9 +219,11 @@ public static function invalidRateLimits(): array } #[Test] - public function it_reports_a_missing_webhook_secret_as_server_configuration() + #[DataProvider('invalidWebhookSecrets')] + public function it_reports_invalid_webhook_secret_configuration_as_a_server_error($secret) { Bus::fake(); + config(['mailbox.services.resend.webhook_secret' => $secret]); $payload = '{"type":"email.received","data":{"email_id":"email_123"}}'; $this->postResendWebhook( @@ -226,9 +234,67 @@ public function it_reports_a_missing_webhook_secret_as_server_configuration() Bus::assertNothingDispatched(); } + public static function invalidWebhookSecrets(): array + { + return [ + 'missing' => [null], + 'blank' => [' '], + 'missing prefix' => ['test-secret'], + 'bad Base64' => ['whsec_%%%'], + 'empty decoded value' => ['whsec_'], + ]; + } + + #[Test] + public function it_reports_a_well_formed_nonmatching_webhook_secret_as_unauthorized() + { + Bus::fake(); + config([ + 'mailbox.services.resend.webhook_secret' => 'whsec_'.base64_encode('other-secret'), + ]); + + $this->postResendWebhook( + '{"type":"email.received","data":{"email_id":"email_123"}}', + configureSecret: false + )->assertStatus(401); + + Bus::assertNothingDispatched(); + } + + #[Test] + #[DataProvider('missingApiKeys')] + public function it_rejects_missing_api_keys_before_acknowledging_an_async_webhook($apiKey) + { + Bus::fake(); + config([ + 'mailbox.services.resend.api_key' => $apiKey, + 'mailbox.services.resend.queue_connection' => 'redis', + ]); + + $this->postResendWebhook( + '{"type":"email.received","data":{"email_id":"email_123"}}' + )->assertStatus(500); + + Bus::assertNothingDispatched(); + } + + public static function missingApiKeys(): array + { + return [ + 'missing' => [null], + 'empty' => [''], + 'blank' => [' '], + ]; + } + #[Test] public function it_releases_the_unique_lock_when_dispatch_fails() { + config([ + 'mailbox.services.resend.queue_connection' => 'resend-async', + 'queue.connections.resend-async' => ['driver' => 'null'], + ]); + $original = $this->app->make(Dispatcher::class); $dispatcher = Mockery::mock(Dispatcher::class); $dispatcher->shouldReceive('dispatch') @@ -251,6 +317,55 @@ public function it_releases_the_unique_lock_when_dispatch_fails() Bus::assertDispatched(ProcessResendEmail::class); } + #[Test] + public function it_does_not_release_a_retry_lock_after_a_named_sync_connection_fails() + { + config([ + 'mailbox.services.resend.queue_connection' => 'resend-inline', + 'queue.connections.resend-inline' => ['driver' => 'sync'], + ]); + + Http::fake([ + ResendClient::API_URL.'/email_123' => Http::response('', 500), + ]); + + $job = new ProcessResendEmail('email_123', 'msg_sync_interleaving'); + $lockKey = UniqueLock::getKey($job); + $retryLock = null; + $retryLockAcquired = false; + + Event::listen(JobFailed::class, function () use ( + $lockKey, + &$retryLock, + &$retryLockAcquired + ) { + $retryLock = $this->app->make(Cache::class)->lock($lockKey, 60); + $retryLockAcquired = $retryLock->get(); + }); + + $this->postResendWebhook( + '{"type":"email.received","data":{"email_id":"email_123"}}', + 'msg_sync_interleaving' + )->assertStatus(500); + + $this->assertTrue($retryLockAcquired); + $this->assertInstanceOf(Lock::class, $retryLock); + + $probe = $this->app->make(Cache::class)->lock($lockKey, 60); + $thirdRequestAcquired = $probe->get(); + + if ($thirdRequestAcquired) { + $probe->release(); + } + + $retryLock->release(); + + $this->assertFalse( + $thirdRequestAcquired, + 'The sync exception path released the lock acquired by a concurrent retry.' + ); + } + #[Test] public function it_processes_raw_mime_and_attachments_on_the_sync_connection() { diff --git a/tests/Jobs/ProcessResendEmailTest.php b/tests/Jobs/ProcessResendEmailTest.php index b56ac3d..bd70744 100644 --- a/tests/Jobs/ProcessResendEmailTest.php +++ b/tests/Jobs/ProcessResendEmailTest.php @@ -39,6 +39,19 @@ public function it_exposes_unique_retry_and_middleware_configuration() $this->assertInstanceOf(ResendRateLimited::class, $queuedJob->middleware()[0]); } + #[Test] + public function it_skips_rate_limiting_for_a_named_sync_queue_connection() + { + config([ + 'queue.connections.resend-inline' => ['driver' => 'sync'], + ]); + + $job = (new ProcessResendEmail('email_123', 'msg_123')) + ->onConnection('resend-inline'); + + $this->assertSame([], $job->middleware()); + } + #[Test] public function it_builds_the_configured_model_and_calls_mailboxes() { diff --git a/tests/MailboxServiceProviderTest.php b/tests/MailboxServiceProviderTest.php new file mode 100644 index 0000000..f05e0a6 --- /dev/null +++ b/tests/MailboxServiceProviderTest.php @@ -0,0 +1,72 @@ +registerProviderWith([ + 'driver' => 'log', + 'services' => [ + 'mailgun' => ['key' => 'published-mailgun-key'], + ], + ]); + + $this->assertSame( + ['key' => 'published-mailgun-key'], + $config->get('mailbox.services.mailgun') + ); + + $resend = $config->get('mailbox.services.resend'); + + $this->assertIsArray($resend); + $this->assertSame( + ['api_key', 'webhook_secret', 'queue_connection', 'rate_limit'], + array_keys($resend) + ); + } + + #[Test] + public function it_preserves_explicit_resend_values_while_filling_missing_defaults() + { + $config = $this->registerProviderWith([ + 'driver' => 'log', + 'services' => [ + 'resend' => [ + 'api_key' => 'published-api-key', + 'queue_connection' => 'published-sync', + 'rate_limit' => 2, + ], + ], + ]); + + $this->assertSame( + 'published-api-key', + $config->get('mailbox.services.resend.api_key') + ); + $this->assertSame( + 'published-sync', + $config->get('mailbox.services.resend.queue_connection') + ); + $this->assertSame(2, $config->get('mailbox.services.resend.rate_limit')); + $this->assertTrue($config->has('mailbox.services.resend.webhook_secret')); + } + + private function registerProviderWith(array $mailboxConfig): Repository + { + $container = new Container; + $config = new Repository(['mailbox' => $mailboxConfig]); + $container->instance('config', $config); + + (new MailboxServiceProvider($container))->register(); + + return $config; + } +} diff --git a/tests/Queue/Middleware/ResendRateLimitedTest.php b/tests/Queue/Middleware/ResendRateLimitedTest.php index fc11c55..192a085 100644 --- a/tests/Queue/Middleware/ResendRateLimitedTest.php +++ b/tests/Queue/Middleware/ResendRateLimitedTest.php @@ -2,11 +2,12 @@ namespace BeyondCode\Mailbox\Tests\Queue\Middleware; +use BeyondCode\Mailbox\Jobs\ProcessResendEmail; use BeyondCode\Mailbox\Queue\Middleware\ResendRateLimited; use BeyondCode\Mailbox\Tests\TestCase; use Illuminate\Cache\RateLimiter; +use Illuminate\Queue\Jobs\SyncJob; use LogicException; -use Mockery; use PHPUnit\Framework\Attributes\Test; class ResendRateLimitedTest extends TestCase @@ -19,15 +20,12 @@ protected function tearDown(): void } #[Test] - public function it_releases_jobs_over_the_per_second_limit() + public function it_releases_a_real_job_through_its_queue_interactions() { config(['mailbox.services.resend.rate_limit' => 1]); - $job = Mockery::mock(); - $job->shouldReceive('release') - ->once() - ->with(Mockery::on(fn ($seconds) => $seconds >= 1 && $seconds <= 2)) - ->andReturn(false); + $first = $this->queuedJob('msg_first'); + $second = $this->queuedJob('msg_second'); $handled = 0; $next = function () use (&$handled) { @@ -35,10 +33,39 @@ public function it_releases_jobs_over_the_per_second_limit() }; $middleware = new ResendRateLimited; - $middleware->handle($job, $next); - $middleware->handle($job, $next); + $middleware->handle($first, $next); + $middleware->handle($second, $next); $this->assertSame(1, $handled); + $this->assertFalse($first->job->isReleased()); + $this->assertTrue($second->job->isReleased()); + $this->assertGreaterThanOrEqual(1, $second->job->releaseDelay); + $this->assertLessThanOrEqual(2, $second->job->releaseDelay); + } + + #[Test] + public function atomic_reservations_cannot_admit_more_than_the_configured_limit() + { + config(['mailbox.services.resend.rate_limit' => 1]); + + $limiter = new InterleavingResendRateLimiter($this->app['cache']->store()); + $this->app->instance(RateLimiter::class, $limiter); + + $first = $this->queuedJob('msg_first'); + $second = $this->queuedJob('msg_second'); + $handled = 0; + $next = function () use (&$handled) { + $handled++; + }; + + $middleware = new ResendRateLimited; + $middleware->handle($first, $next); + $middleware->handle($second, $next); + + $this->assertSame(2, $limiter->reservations); + $this->assertSame(1, $handled); + $this->assertFalse($first->job->isReleased()); + $this->assertTrue($second->job->isReleased()); } #[Test] @@ -48,6 +75,59 @@ public function it_rejects_a_non_positive_limit() $this->expectException(LogicException::class); - (new ResendRateLimited)->handle(Mockery::mock(), fn () => null); + (new ResendRateLimited)->handle( + new ProcessResendEmail('email_123', 'msg_invalid'), + fn () => null + ); + } + + private function queuedJob(string $webhookId): ProcessResendEmail + { + $job = new ProcessResendEmail('email_123', $webhookId); + $job->setJob(new RecordingResendQueueJob( + $this->app, + '{}', + 'test', + 'default' + )); + + return $job; + } +} + +class RecordingResendQueueJob extends SyncJob +{ + public $releaseDelay; + + public function release($delay = 0) + { + $this->releaseDelay = $delay; + + parent::release($delay); + } +} + +class InterleavingResendRateLimiter extends RateLimiter +{ + public int $reservations = 0; + + public function tooManyAttempts($key, $maxAttempts) + { + return false; + } + + public function hit($key, $decaySeconds = 60) + { + return ++$this->reservations; + } + + public function increment($key, $decaySeconds = 60, $amount = 1) + { + return $this->reservations += $amount; + } + + public function availableIn($key) + { + return 0; } } From b36a7657a3791f48fce1fa01edcfcdb49c6d774e Mon Sep 17 00:00:00 2001 From: Jason Wang Date: Wed, 29 Jul 2026 12:30:01 +0800 Subject: [PATCH 12/14] fix: ensure Resend Laravel 10 compatibility --- composer.json | 1 + src/Http/Controllers/ResendController.php | 2 +- src/Jobs/ProcessResendEmail.php | 5 +++++ tests/Controllers/ResendTest.php | 14 ++++++++++++-- tests/Jobs/ProcessResendEmailTest.php | 9 +++++++++ 5 files changed, 28 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index b3220ee..3b5eb35 100644 --- a/composer.json +++ b/composer.json @@ -17,6 +17,7 @@ ], "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", diff --git a/src/Http/Controllers/ResendController.php b/src/Http/Controllers/ResendController.php index 46361cc..9c5cd89 100644 --- a/src/Http/Controllers/ResendController.php +++ b/src/Http/Controllers/ResendController.php @@ -14,7 +14,7 @@ class ResendController public function __invoke(ResendRequest $request) { if ($request->eventType() !== 'email.received') { - return response('', 204); + return response('', 200); } $connection = config('mailbox.services.resend.queue_connection', 'sync'); diff --git a/src/Jobs/ProcessResendEmail.php b/src/Jobs/ProcessResendEmail.php index 51d3ce5..db7b1d7 100644 --- a/src/Jobs/ProcessResendEmail.php +++ b/src/Jobs/ProcessResendEmail.php @@ -32,6 +32,11 @@ public function uniqueId(): string return $this->webhookId; } + public function uniqueFor(): int + { + return 25 * 60 * 60; + } + public function retryUntil(): DateTimeInterface { return now()->addDay(); diff --git a/tests/Controllers/ResendTest.php b/tests/Controllers/ResendTest.php index d194432..7df889d 100644 --- a/tests/Controllers/ResendTest.php +++ b/tests/Controllers/ResendTest.php @@ -150,7 +150,7 @@ public function it_acknowledges_signed_unrelated_events() Bus::fake(); $this->postResendWebhook('{"type":"email.delivered","data":{}}') - ->assertNoContent(); + ->assertOk(); Bus::assertNothingDispatched(); } @@ -330,7 +330,9 @@ public function it_does_not_release_a_retry_lock_after_a_named_sync_connection_f ]); $job = new ProcessResendEmail('email_123', 'msg_sync_interleaving'); - $lockKey = UniqueLock::getKey($job); + $lockKey = (new InspectableUniqueLock( + $this->app->make(Cache::class) + ))->key($job); $retryLock = null; $retryLockAcquired = false; @@ -471,3 +473,11 @@ public static function fromMessage($message) return parent::fromMessage($message); } } + +class InspectableUniqueLock extends UniqueLock +{ + public function key($job): string + { + return $this->getKey($job); + } +} diff --git a/tests/Jobs/ProcessResendEmailTest.php b/tests/Jobs/ProcessResendEmailTest.php index bd70744..a8d844a 100644 --- a/tests/Jobs/ProcessResendEmailTest.php +++ b/tests/Jobs/ProcessResendEmailTest.php @@ -39,6 +39,15 @@ public function it_exposes_unique_retry_and_middleware_configuration() $this->assertInstanceOf(ResendRateLimited::class, $queuedJob->middleware()[0]); } + #[Test] + public function it_expires_unique_locks_after_the_retry_window() + { + $job = new ProcessResendEmail('email_123', 'msg_123'); + + $this->assertTrue(is_callable([$job, 'uniqueFor'])); + $this->assertSame(25 * 60 * 60, $job->uniqueFor()); + } + #[Test] public function it_skips_rate_limiting_for_a_named_sync_queue_connection() { From c5e00b6c478100a29686e2415ee95eb73962dec2 Mon Sep 17 00:00:00 2001 From: Jason Wang Date: Wed, 29 Jul 2026 15:19:28 +0800 Subject: [PATCH 13/14] style: apply StyleCI fixes --- tests/Controllers/ResendTest.php | 2 +- tests/Support/ResendWebhookSignatureTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Controllers/ResendTest.php b/tests/Controllers/ResendTest.php index 7df889d..ed412ff 100644 --- a/tests/Controllers/ResendTest.php +++ b/tests/Controllers/ResendTest.php @@ -10,8 +10,8 @@ use BeyondCode\Mailbox\Tests\TestCase; use Illuminate\Bus\UniqueLock; use Illuminate\Contracts\Bus\Dispatcher; -use Illuminate\Contracts\Cache\Repository as Cache; use Illuminate\Contracts\Cache\Lock; +use Illuminate\Contracts\Cache\Repository as Cache; use Illuminate\Queue\Events\JobFailed; use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Event; diff --git a/tests/Support/ResendWebhookSignatureTest.php b/tests/Support/ResendWebhookSignatureTest.php index 8d6f217..b5c4ad1 100644 --- a/tests/Support/ResendWebhookSignatureTest.php +++ b/tests/Support/ResendWebhookSignatureTest.php @@ -45,7 +45,7 @@ public function it_verifies_a_fixed_svix_signature() public function it_rejects_any_raw_body_change() { $this->assertFalse((new ResendWebhookSignature)->verify( - self::PAYLOAD." ", + self::PAYLOAD.' ', 'msg_test', self::TIMESTAMP, 'v1,'.self::SIGNATURE, From 3103663fd2e7f66e94c404188891bf1b03b8ad2b Mon Sep 17 00:00:00 2001 From: Jason Wang Date: Wed, 29 Jul 2026 16:34:33 +0800 Subject: [PATCH 14/14] refactor: simplify Resend webhook processing --- docs/drivers/drivers.md | 8 +- src/Drivers/Resend.php | 2 +- src/Http/Controllers/ResendController.php | 16 +--- src/Http/Requests/ResendRequest.php | 5 - src/Jobs/ProcessResendEmail.php | 17 +--- tests/Controllers/ResendTest.php | 91 ++++--------------- tests/Jobs/ProcessResendEmailTest.php | 22 ++--- .../Middleware/ResendRateLimitedTest.php | 6 +- 8 files changed, 35 insertions(+), 132 deletions(-) diff --git a/docs/drivers/drivers.md b/docs/drivers/drivers.md index 0084c76..20770cd 100644 --- a/docs/drivers/drivers.md +++ b/docs/drivers/drivers.md @@ -116,10 +116,10 @@ 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. -Unique-job locking and per-second throttling both use Laravel's cache. Workers -on multiple nodes must use the same shared cache store, and that store must -support atomic locks and increments, such as Redis. Workers must also have -enough memory for the complete MIME message, including attachments. +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 diff --git a/src/Drivers/Resend.php b/src/Drivers/Resend.php index 29d26c1..f0d311c 100644 --- a/src/Drivers/Resend.php +++ b/src/Drivers/Resend.php @@ -10,7 +10,7 @@ class Resend implements DriverInterface public function register() { Route::prefix(config('mailbox.path'))->group(function () { - Route::post('/resend', [ResendController::class, '__invoke']); + Route::post('/resend', ResendController::class); }); } } diff --git a/src/Http/Controllers/ResendController.php b/src/Http/Controllers/ResendController.php index 9c5cd89..52d3c3e 100644 --- a/src/Http/Controllers/ResendController.php +++ b/src/Http/Controllers/ResendController.php @@ -4,10 +4,7 @@ use BeyondCode\Mailbox\Http\Requests\ResendRequest; use BeyondCode\Mailbox\Jobs\ProcessResendEmail; -use Illuminate\Bus\UniqueLock; -use Illuminate\Contracts\Cache\Repository as Cache; use LogicException; -use Throwable; class ResendController { @@ -33,19 +30,10 @@ public function __invoke(ResendRequest $request) throw new LogicException('Resend API key is not configured.'); } - $job = (new ProcessResendEmail($request->emailId(), $request->webhookId())) + $job = (new ProcessResendEmail($request->emailId())) ->onConnection($connection); - $synchronous = $job->usesSynchronousQueue(); - try { - dispatch($job); - } catch (Throwable $exception) { - if (! $synchronous) { - (new UniqueLock(app(Cache::class)))->release($job); - } - - throw $exception; - } + dispatch($job); return response('', 200); } diff --git a/src/Http/Requests/ResendRequest.php b/src/Http/Requests/ResendRequest.php index 817339d..ee5aa7f 100644 --- a/src/Http/Requests/ResendRequest.php +++ b/src/Http/Requests/ResendRequest.php @@ -79,9 +79,4 @@ public function emailId(): ?string { return $this->validated('data.email_id'); } - - public function webhookId(): string - { - return $this->header('svix-id'); - } } diff --git a/src/Jobs/ProcessResendEmail.php b/src/Jobs/ProcessResendEmail.php index db7b1d7..41eab53 100644 --- a/src/Jobs/ProcessResendEmail.php +++ b/src/Jobs/ProcessResendEmail.php @@ -8,33 +8,20 @@ use BeyondCode\Mailbox\Queue\Middleware\ResendRateLimited; use DateTimeInterface; use Illuminate\Bus\Queueable; -use Illuminate\Contracts\Queue\ShouldBeUnique; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Queue\InteractsWithQueue; -class ProcessResendEmail implements ShouldQueue, ShouldBeUnique +class ProcessResendEmail implements ShouldQueue { use InteractsWithQueue, Queueable; public $timeout = 180; public string $emailId; - public string $webhookId; - public function __construct(string $emailId, string $webhookId) + public function __construct(string $emailId) { $this->emailId = $emailId; - $this->webhookId = $webhookId; - } - - public function uniqueId(): string - { - return $this->webhookId; - } - - public function uniqueFor(): int - { - return 25 * 60 * 60; } public function retryUntil(): DateTimeInterface diff --git a/tests/Controllers/ResendTest.php b/tests/Controllers/ResendTest.php index ed412ff..dea4ea0 100644 --- a/tests/Controllers/ResendTest.php +++ b/tests/Controllers/ResendTest.php @@ -8,13 +8,8 @@ use BeyondCode\Mailbox\Jobs\ProcessResendEmail; use BeyondCode\Mailbox\Tests\Concerns\SignsResendWebhooks; use BeyondCode\Mailbox\Tests\TestCase; -use Illuminate\Bus\UniqueLock; use Illuminate\Contracts\Bus\Dispatcher; -use Illuminate\Contracts\Cache\Lock; -use Illuminate\Contracts\Cache\Repository as Cache; -use Illuminate\Queue\Events\JobFailed; use Illuminate\Support\Facades\Bus; -use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Http; use Mockery; use PHPUnit\Framework\Attributes\DataProvider; @@ -168,11 +163,23 @@ public function it_dispatches_received_emails_to_the_configured_connection() Bus::assertDispatched(ProcessResendEmail::class, function ($job) { return $job->emailId === 'email_123' - && $job->webhookId === 'msg_123' && $job->connection === 'redis'; }); } + #[Test] + public function it_dispatches_repeated_webhooks_for_business_level_idempotency() + { + Bus::fake(); + config(['mailbox.services.resend.queue_connection' => 'redis']); + $payload = '{"type":"email.received","data":{"email_id":"email_123"}}'; + + $this->postResendWebhook($payload, 'msg_repeated')->assertOk(); + $this->postResendWebhook($payload, 'msg_repeated')->assertOk(); + + Bus::assertDispatchedTimes(ProcessResendEmail::class, 2); + } + #[Test] #[DataProvider('invalidQueueConnections')] public function it_rejects_invalid_queue_configuration($connection) @@ -288,7 +295,7 @@ public static function missingApiKeys(): array } #[Test] - public function it_releases_the_unique_lock_when_dispatch_fails() + public function it_reports_queue_dispatch_failures_as_server_errors() { config([ 'mailbox.services.resend.queue_connection' => 'resend-async', @@ -303,69 +310,13 @@ public function it_releases_the_unique_lock_when_dispatch_fails() $this->app->instance(Dispatcher::class, $dispatcher); - $payload = '{"type":"email.received","data":{"email_id":"email_123"}}'; - try { - $this->postResendWebhook($payload, 'msg_retry')->assertStatus(500); + $this->postResendWebhook( + '{"type":"email.received","data":{"email_id":"email_123"}}' + )->assertStatus(500); } finally { $this->app->instance(Dispatcher::class, $original); } - - Bus::fake(); - - $this->postResendWebhook($payload, 'msg_retry')->assertOk(); - Bus::assertDispatched(ProcessResendEmail::class); - } - - #[Test] - public function it_does_not_release_a_retry_lock_after_a_named_sync_connection_fails() - { - config([ - 'mailbox.services.resend.queue_connection' => 'resend-inline', - 'queue.connections.resend-inline' => ['driver' => 'sync'], - ]); - - Http::fake([ - ResendClient::API_URL.'/email_123' => Http::response('', 500), - ]); - - $job = new ProcessResendEmail('email_123', 'msg_sync_interleaving'); - $lockKey = (new InspectableUniqueLock( - $this->app->make(Cache::class) - ))->key($job); - $retryLock = null; - $retryLockAcquired = false; - - Event::listen(JobFailed::class, function () use ( - $lockKey, - &$retryLock, - &$retryLockAcquired - ) { - $retryLock = $this->app->make(Cache::class)->lock($lockKey, 60); - $retryLockAcquired = $retryLock->get(); - }); - - $this->postResendWebhook( - '{"type":"email.received","data":{"email_id":"email_123"}}', - 'msg_sync_interleaving' - )->assertStatus(500); - - $this->assertTrue($retryLockAcquired); - $this->assertInstanceOf(Lock::class, $retryLock); - - $probe = $this->app->make(Cache::class)->lock($lockKey, 60); - $thirdRequestAcquired = $probe->get(); - - if ($thirdRequestAcquired) { - $probe->release(); - } - - $retryLock->release(); - - $this->assertFalse( - $thirdRequestAcquired, - 'The sync exception path released the lock acquired by a concurrent retry.' - ); } #[Test] @@ -473,11 +424,3 @@ public static function fromMessage($message) return parent::fromMessage($message); } } - -class InspectableUniqueLock extends UniqueLock -{ - public function key($job): string - { - return $this->getKey($job); - } -} diff --git a/tests/Jobs/ProcessResendEmailTest.php b/tests/Jobs/ProcessResendEmailTest.php index a8d844a..06c9dfc 100644 --- a/tests/Jobs/ProcessResendEmailTest.php +++ b/tests/Jobs/ProcessResendEmailTest.php @@ -24,14 +24,13 @@ protected function tearDown(): void } #[Test] - public function it_exposes_unique_retry_and_middleware_configuration() + public function it_exposes_retry_and_middleware_configuration() { Carbon::setTestNow('2026-07-28 00:00:00'); - $syncJob = (new ProcessResendEmail('email_123', 'msg_123'))->onConnection('sync'); - $queuedJob = (new ProcessResendEmail('email_123', 'msg_123'))->onConnection('redis'); + $syncJob = (new ProcessResendEmail('email_123'))->onConnection('sync'); + $queuedJob = (new ProcessResendEmail('email_123'))->onConnection('redis'); - $this->assertSame('msg_123', $queuedJob->uniqueId()); $this->assertSame([5, 60, 300, 1800, 7200, 18000, 36000], $queuedJob->backoff()); $this->assertSame(now()->addDay()->timestamp, $queuedJob->retryUntil()->timestamp); $this->assertSame(180, $queuedJob->timeout); @@ -39,15 +38,6 @@ public function it_exposes_unique_retry_and_middleware_configuration() $this->assertInstanceOf(ResendRateLimited::class, $queuedJob->middleware()[0]); } - #[Test] - public function it_expires_unique_locks_after_the_retry_window() - { - $job = new ProcessResendEmail('email_123', 'msg_123'); - - $this->assertTrue(is_callable([$job, 'uniqueFor'])); - $this->assertSame(25 * 60 * 60, $job->uniqueFor()); - } - #[Test] public function it_skips_rate_limiting_for_a_named_sync_queue_connection() { @@ -55,7 +45,7 @@ public function it_skips_rate_limiting_for_a_named_sync_queue_connection() 'queue.connections.resend-inline' => ['driver' => 'sync'], ]); - $job = (new ProcessResendEmail('email_123', 'msg_123')) + $job = (new ProcessResendEmail('email_123')) ->onConnection('resend-inline'); $this->assertSame([], $job->middleware()); @@ -73,7 +63,7 @@ public function it_builds_the_configured_model_and_calls_mailboxes() ->once() ->with(Mockery::type(TestResendInboundEmail::class)); - (new ProcessResendEmail('email_123', 'msg_123'))->handle($client); + (new ProcessResendEmail('email_123'))->handle($client); $this->assertSame('raw mime', TestResendInboundEmail::$rawMessage); } @@ -91,7 +81,7 @@ public function it_does_not_call_mailboxes_when_retrieval_fails() Mailbox::shouldReceive('callMailboxes')->never(); try { - (new ProcessResendEmail('email_123', 'msg_123'))->handle($client); + (new ProcessResendEmail('email_123'))->handle($client); $this->fail('Expected the client exception.'); } catch (RuntimeException $exception) { $this->assertSame($expected, $exception); diff --git a/tests/Queue/Middleware/ResendRateLimitedTest.php b/tests/Queue/Middleware/ResendRateLimitedTest.php index 192a085..e892950 100644 --- a/tests/Queue/Middleware/ResendRateLimitedTest.php +++ b/tests/Queue/Middleware/ResendRateLimitedTest.php @@ -76,14 +76,14 @@ public function it_rejects_a_non_positive_limit() $this->expectException(LogicException::class); (new ResendRateLimited)->handle( - new ProcessResendEmail('email_123', 'msg_invalid'), + new ProcessResendEmail('email_123'), fn () => null ); } - private function queuedJob(string $webhookId): ProcessResendEmail + private function queuedJob(string $emailId): ProcessResendEmail { - $job = new ProcessResendEmail('email_123', $webhookId); + $job = new ProcessResendEmail($emailId); $job->setJob(new RecordingResendQueueJob( $this->app, '{}',