cboxdk / laravel-postal
Postal mail server integration for Laravel — a typed, multi-server send transport plus a verified, event-driven webhook observability spine (delivery, bounce, open, click, DNS).
Requires
- php: ^8.4
- ext-openssl: *
- illuminate/console: ^12.0 || ^13.0
- illuminate/contracts: ^12.0 || ^13.0
- illuminate/database: ^12.0 || ^13.0
- illuminate/http: ^12.0 || ^13.0
- illuminate/mail: ^12.0 || ^13.0
- illuminate/queue: ^12.0 || ^13.0
- illuminate/support: ^12.0 || ^13.0
- symfony/mailer: ^7.4 || ^8.0
Requires (Dev)
- cboxdk/laravel-health: ^2.0
- larastan/larastan: ^3.0
- laravel/pint: ^1.18
- orchestra/testbench: ^10.0 || ^11.0
- pestphp/pest: ^3.5 || ^4.0
- pestphp/pest-plugin-laravel: ^3.0 || ^4.0
Suggests
- cboxdk/laravel-health: Register Cbox\LaravelPostal\Health\PostalConnectionCheck in health.checks to monitor every configured Postal server
README
Postal mail server integration for Laravel — a typed, multi-server send transport plus a verified, event-driven webhook observability spine (delivery, bounce, open, click, DNS).
Postal is a self-hosted mail server with an HTTP send API and signed webhooks. This package treats it as a live, typed delivery bus rather than a fire-and-forget mail driver:
- Send through a fully-typed client (
Postal::send(...)) or transparently viaMAIL_MAILER=postal, with retries, typed exceptions and correct handling of Postal's HTTP-200-with-error envelopes. Three connection types per server:api(structured JSON),smtp-api(raw RFC 2822 over the HTTP API) andsmtp(classic SMTP submission). - Observe through verified webhooks: RSA signatures are checked over the raw request body, deliveries are deduplicated on Postal's webhook uuid, an idempotent per-message status row is maintained, and one typed Laravel event fires per Postal event type.
- Receive inbound email: Postal routes deliver to a signed HTTP
endpoint that becomes a typed
PostalInboundMessageevent — bodies, headers, attachments and raw source included. - Multiple servers are first-class: one config entry per Postal mail
server, addressed by name — or provisioned dynamically from a database
via the
ServerRegistrycontract /Postal::connect().
Installation
composer require cboxdk/laravel-postal php artisan postal:install php artisan migrate
Configure at least one server in .env:
POSTAL_URL=https://postal.example.com POSTAL_KEY=your-server-api-key
Then verify everything — servers, keys, routes, tables, connectivity:
php artisan postal:doctor
Install with your AI assistant
Working with Claude Code, Cursor or similar? Paste the setup prompt from
docs/getting-started/llm-install.md
into your assistant — it performs the whole installation, pauses for the
two Postal-UI steps, and finishes with a green postal:doctor run.
Sending
As a mail transport — add a mailer and point MAIL_MAILER at it:
// config/mail.php 'mailers' => [ 'postal' => ['transport' => 'postal'], // optional: 'server' => 'cbox-billing' ],
Or through the typed API:
use Cbox\LaravelPostal\Dto\SendMessage; use Cbox\LaravelPostal\Facades\Postal; $result = Postal::send( SendMessage::create() ->to('alice@example.com') ->from('Cbox <no-reply@cboxid.com>') ->subject('Welcome') ->tag('onboarding') ->html('<p>Hello!</p>'), ); $result->messageId; // RFC message id Postal assigned $result->recipient('alice@example.com')->id; // Postal's per-recipient id
Or per server: Postal::server('cbox-billing')->send(...).
Observing
Point each Postal mail server's webhook at your app (Postal admin → Webhooks; the URL suffix names the server the delivery belongs to):
https://your-app.example/postal/webhook/cbox-billing
Fetch the signing key and set it in .env:
php artisan postal:webhook-key
# → POSTAL_WEBHOOK_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n..."
Then subscribe to the typed events:
use Cbox\LaravelPostal\Events\PostalMessageBounced; Event::listen(function (PostalMessageBounced $event) { $event->server(); // 'cbox-billing' $event->payload->originalMessage->to; // who bounced });
One event class exists per Postal event type: PostalMessageSent,
PostalMessageDelayed, PostalMessageDeliveryFailed, PostalMessageHeld,
PostalMessageBounced, PostalMessageLinkClicked, PostalMessageLoaded
(= opened), PostalDomainDnsError, PostalSendLimitApproaching,
PostalSendLimitExceeded.
The store keeps one idempotent row per message in postal_messages
(latest status, opens/clicks counters, owning model) plus a deduplicated
event log in postal_message_events. Watch it live:
php artisan postal:tail
Receiving
Point a Postal route's HTTP endpoint at
https://your-app.example/postal/inbound/{server} and listen:
use Cbox\LaravelPostal\Events\PostalInboundMessage; Event::listen(function (PostalInboundMessage $event) { $event->payload->subject; $event->payload->plainBody; $event->payload->attachments; // decoded content included });
Dynamic servers
// Database-backed: bind your own registry… $this->app->singleton(ServerRegistry::class, DatabaseServerRegistry::class); // …or connect ad hoc without any registration: Postal::connect(new ServerConfig(name: 'tenant-42', url: $url, key: $key))->send($message);
Testing your app
use Cbox\LaravelPostal\Facades\Postal; $fake = Postal::fake(); // ... code that sends ... $fake->assertSent(fn ($message, $server) => $server === 'cbox-billing');
Console DX
| Command | Purpose |
|---|---|
postal:install |
Publish config + setup checklist |
postal:doctor |
Full diagnosis — CI-friendly exit codes |
postal:ping |
Connectivity + credential check per server |
postal:tail |
Live-tail incoming webhook/inbound events |
postal:message |
Inspect one message + its delivery attempts |
postal:webhook-key |
Fetch the webhook signing key as PEM |
Documentation
Full documentation lives in docs/: quickstart, multi-server configuration, connection types (api / smtp-api / smtp), the webhook spine, inbound mail, the event catalog, notifications, broadcasting, dynamic server provisioning, extension points and the security model.
Requirements
PHP 8.4+, Laravel 12 or 13. Tested against Postal 3.x.
License
MIT — see LICENSE.md.