ojessecruz / resend-inbox
Framework-agnostic core of a Resend-powered shared inbox: webhook parsing, conversation threading, reply headers and auto-reply detection.
Requires
- php: ^8.3
- egulias/email-validator: ^4.0
- league/commonmark: ^2.4
- resend/resend-php: ^1.3
- symfony/mime: ^6.4||^7.0||^8.0
Requires (Dev)
- laravel/pint: ^1.14
- mockery/mockery: ^1.6
- pestphp/pest: ^3.0||^4.0
- pestphp/pest-plugin-arch: ^3.0||^4.0
- phpstan/phpstan: ^2.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Framework-agnostic core of a shared inbox on top of Resend inbound email.
It decides; your framework integration stores, queues and renders:
- Webhooks: verifies the Svix signature and turns the body into a typed event.
- Threading: finds the conversation an inbound email belongs to, by
In-Reply-To/References, falling back to the same subject with the same correspondent. - Replies: builds an email from Markdown with our own
Message-IDand the threading headers, allowing only configured senders. - Auto-replies: recognizes auto-responders, bounces and bulk mail, so they never raise alerts.
- Delivery status: orders
sent → delivered → bounced/complainedso late webhooks never move it back. - Resend API: fetches received emails and attachment URLs, and sends.
Depends only on resend/resend-php, symfony/mime (address parsing), egulias/email-validator and league/commonmark. No framework.
For Laravel, use ojessecruz/laravel-resend-inbox, which builds on this package.
Installation
composer require ojessecruz/resend-inbox
Usage
Webhooks
use Jessecruz\ResendInbox\Exceptions\InvalidWebhook; use Jessecruz\ResendInbox\Webhook\DeliveryStatusChanged; use Jessecruz\ResendInbox\Webhook\EmailReceived; use Jessecruz\ResendInbox\Webhook\WebhookParser; $parser = new WebhookParser($signingSecret); // "whsec_..." from the Resend dashboard try { $event = $parser->parse($rawBody, $requestHeaders); } catch (InvalidWebhook) { // respond 401 / 400 } match (true) { $event instanceof EmailReceived => $queue->push($event->emailId), $event instanceof DeliveryStatusChanged => $store->recordStatus($event), default => null, };
Resend redelivers webhooks on failure: handle each email id idempotently.
Storing a received email
use Jessecruz\ResendInbox\Mailbox; use Jessecruz\ResendInbox\ResendMailbox; use Jessecruz\ResendInbox\Threading\ThreadResolver; $mailbox = new Mailbox('example.com', ['hello@example.com', 'support@example.com'], 'Example Team'); $resend = new ResendMailbox(\Resend::client($apiKey)); $email = $resend->receivedEmail($emailId); if (! $mailbox->hasOwnRecipient($email->recipients())) { return; // addressed to another domain on the same Resend account } $threadId = (new ThreadResolver($lookup))->resolveFor($email); // null: start a new conversation $address = $mailbox->mailboxFor($email->recipients()); // which of our addresses received it $notify = ! $email->isAutoGenerated();
$lookup is your implementation of Jessecruz\ResendInbox\Threading\MessageLookup: two queries over your stored messages.
Replying
use Jessecruz\ResendInbox\Composing\Conversation; use Jessecruz\ResendInbox\Composing\ReplyBuilder; $composed = (new ReplyBuilder($mailbox))->build( from: 'support@example.com', to: ['maria@acme.com'], subject: 'Re: Pricing', markdown: "Hi Maria,\n\n...", conversation: new Conversation($storedMessageIds, $latestMessageId, $mailboxesThatReceivedMail), ); $resendId = $resend->send($composed->email); // store $composed->messageId, ->inReplyTo and ->references with the message
Testing
composer test
composer analyse
Contributing
Please read CONTRIBUTING.md before opening an issue or a pull request. Security problems are reported privately: see SECURITY.md.
License
The MIT License (MIT). Please see License File for more information.