{"id":50126090,"url":"https://github.com/stechstudio/laravel-postmaster","last_synced_at":"2026-07-07T19:00:26.312Z","repository":{"id":359192337,"uuid":"1244941584","full_name":"stechstudio/laravel-postmaster","owner":"stechstudio","description":"Verify, normalize, and track email delivery webhooks from SendGrid, Postmark, Mailgun, Amazon SES, and Resend.","archived":false,"fork":false,"pushed_at":"2026-07-07T14:12:26.000Z","size":950,"stargazers_count":5,"open_issues_count":1,"forks_count":0,"subscribers_count":0,"default_branch":"master","last_synced_at":"2026-07-07T15:15:32.816Z","etag":null,"topics":["email","laravel","mailgun","postmark","resend","sendgrid","ses"],"latest_commit_sha":null,"homepage":"","language":"PHP","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/stechstudio.png","metadata":{"files":{"readme":"readme.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-05-20T18:49:59.000Z","updated_at":"2026-07-07T14:15:26.000Z","dependencies_parsed_at":null,"dependency_job_id":"a585d275-161a-4425-ac06-c848a3b16bb7","html_url":"https://github.com/stechstudio/laravel-postmaster","commit_stats":null,"previous_names":["stechstudio/laravel-postmaster"],"tags_count":35,"template":false,"template_full_name":null,"purl":"pkg:github/stechstudio/laravel-postmaster","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/stechstudio%2Flaravel-postmaster","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/stechstudio%2Flaravel-postmaster/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/stechstudio%2Flaravel-postmaster/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/stechstudio%2Flaravel-postmaster/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/stechstudio","download_url":"https://codeload.github.com/stechstudio/laravel-postmaster/tar.gz/refs/heads/master","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/stechstudio%2Flaravel-postmaster/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":35239467,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-07-07T02:00:07.222Z","response_time":90,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["email","laravel","mailgun","postmark","resend","sendgrid","ses"],"created_at":"2026-05-23T20:02:15.117Z","updated_at":"2026-07-07T19:00:26.298Z","avatar_url":"https://github.com/stechstudio.png","language":"PHP","funding_links":[],"categories":[],"sub_categories":[],"readme":"\u003cimg width=\"1500\" height=\"676\" alt=\"postmaster-gh\" src=\"https://github.com/user-attachments/assets/f13e7990-61d2-4364-91ad-9a4a331b1350\" /\u003e\n\n\n# Postmaster\n\n[![Latest Version on Packagist](https://img.shields.io/packagist/v/stechstudio/laravel-postmaster.svg?style=flat-square)](https://packagist.org/packages/stechstudio/laravel-postmaster)\n[![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE.md)\n\n**Provider-agnostic email delivery tracking for Laravel.**\n\nYour app sends mail through SendGrid, Postmark, Mailgun, Amazon SES, or\nResend. Postmaster handles everything those providers send back. It verifies\neach inbound webhook and normalizes them into one event your app listens for —\nso switching providers, running several at once, or failing over between them\nnever touches your code.\n\nRun the migrations and it does more than dispatch events. It records every\noutbound email into a queryable delivery history and keeps each record current\nas events arrive. On top of that history it keeps a self-maintaining\nsuppression list, links each email back to your own models, and ships a gated\nsupport dashboard to browse it all.\n\n## What you get\n\n- **One event for every provider.** Every webhook arrives as the same\n  `EmailEvent`, no matter which of the five providers sent it. There's no\n  provider-specific parsing anywhere in your app.\n- **Provider independence.** Your code only ever sees the normalized event, so\n  you can switch providers, run several at once, or fail over between them\n  without changing a line of it.\n- **Verified by default.** Every inbound webhook is authenticated (by\n  signature, token, or basic auth, depending on the provider), and anything it\n  can't trust is rejected.\n- **A queryable delivery history.** Run the migrations and Postmaster records\n  every send and keeps it current from the webhook stream. You get\n  `delivered()`, `bounced()`, and `failed()` query scopes, a full per-message\n  timeline, and the ability to replay any recorded email.\n- **A self-maintaining suppression list.** A hard bounce, complaint, or drop\n  suppresses an address automatically, and a later delivery clears it. Two-way\n  sync reconciles the list against each provider's own.\n- **Emails linked to your models.** Tie a send to an `Order` or a `User` and\n  read its delivery state straight off the model.\n- **A support dashboard.** A gated, cross-tenant UI for searching messages,\n  watching events arrive live, and inspecting any stored email.\n- **Sandbox delivery.** Intercept every outbound email in staging — recorded\n  in your app's history but never actually sent — and release individual\n  messages for real from the dashboard when you're ready.\n\n## Requirements\n\n- PHP 8.3+\n- Laravel 12 or 13\n\n## Installation\n\n```bash\ncomposer require stechstudio/laravel-postmaster\n```\n\nThe webhook route registers itself, and there is nothing else to publish until\nyou opt into a feature that needs it. For everything that's left, run the\ninstall wizard:\n\n```bash\nphp artisan postmaster:install\n```\n\nIt walks through the rest in one pass:\n\n- detects which provider your `mail` config points at and gathers its webhook\n  verification credential,\n- optionally sets up suppression sync (collecting an API key for the providers\n  that need one; see [Two-way sync with your provider](#two-way-sync-with-your-provider)),\n- offers to enable persistence, content storage, or sandbox delivery,\n- publishes and runs the package's migrations,\n- runs `postmaster:verify` at the end to confirm the round trip works.\n\nEverything goes into your `.env`, with the previous version backed up to\n`.env.backup`. Re-running the wizard later is safe: it edits values in place\nand offers to clean up entries left over from earlier choices, and it leaves\nnon-Postmaster lines alone. If you'd rather wire things up by hand,\n[Securing webhooks](#securing-webhooks) below has each provider's credential.\n\n### Non-interactive environments\n\nOn a platform without a TTY — Laravel Cloud, CI, a deploy hook — the wizard\ncan't prompt, so `postmaster:install` runs in report mode instead (also\nforced with `-n` / `--no-interaction`). It reads the already-configured\nprovider from your environment and prints the webhook URL to register, how to\npoint the provider at it, whether the webhook-auth credential is set, and the\ncurrent feature flags — writing nothing. Pass `--provider=` to override\ndetection.\n\n```bash\nphp artisan postmaster:install --no-interaction --provider=sendgrid\n```\n\n## Getting started\n\nThe wizard handles everything inside your app. Two things still need your hand:\n\n### 1. Point your provider at the webhook\n\nPostmaster serves `POST /webhooks/postmaster/{provider}`. In your email\nprovider's dashboard, set the webhook URL to:\n\n```\nhttps://your-app.com/webhooks/postmaster/{provider}\n```\n\n…where `{provider}` is `sendgrid`, `postmark`, `mailgun`, `ses`, or `resend`.\n\n### 2. Listen for the event\n\nEvery webhook becomes a normalized event you can listen for in a service\nprovider's `boot()`. The common path is a targeted event for what you\ncare about: `EmailBounced`, `EmailComplained`, `EmailDelivered`,\n`EmailOpened`, `EmailClicked`, or `EmailDropped`.\n\n```php\nuse Illuminate\\Support\\Facades\\Event;\nuse STS\\Postmaster\\EmailBounced;\n\nEvent::listen(function (EmailBounced $event) {\n    // The address bounced. Pause sends, flag the account, alert the team.\n    logger()-\u003ewarning(\"Email permanently failed for {$event-\u003etoAddress()}\");\n});\n```\n\nEach targeted event carries the same API (`$event-\u003etoAddress()`,\n`$event-\u003eprovider()`, `$event-\u003ebounceType()`, and so on). See\n[The EmailEvent](#the-emailevent) for the full list.\n\nFor cross-cutting concerns (logging, audit feeds, anything that wants\nevery webhook regardless of status) listen on the umbrella `EmailEvent`,\nfired alongside the targeted one:\n\n```php\nuse STS\\Postmaster\\EmailEvent;\n\nEvent::listen(function (EmailEvent $event) {\n    logger()-\u003einfo(\"[{$event-\u003eprovider()}] {$event-\u003estatus()} for {$event-\u003etoAddress()}\");\n});\n```\n\nFor anything beyond a few lines, use a dedicated listener class. Laravel\nauto-discovers it, and it can implement `Illuminate\\Contracts\\Queue\\ShouldQueue`\nto process webhooks off the request cycle.\n\n\u003e **High-volume installs.** By default, parsing the webhook and dispatching\n\u003e the event(s) runs inline before the response returns to the provider.\n\u003e That's fine at low volume. Set `POSTMASTER_QUEUE_WEBHOOKS=true` to instead\n\u003e push a `ProcessWebhook` job onto the queue and respond `202 Accepted`\n\u003e immediately. Webhook signature verification stays inline either way.\n\u003e Optional `POSTMASTER_QUEUE_CONNECTION` / `POSTMASTER_QUEUE_NAME` isolate\n\u003e the job onto its own queue.\n\nFor the common \"alert ops when a hard bounce lands\" case the package ships\na drop-in notification:\n\n```php\nuse Illuminate\\Support\\Facades\\Notification;\nuse STS\\Postmaster\\EmailBounced;\nuse STS\\Postmaster\\Notifications\\EmailDeliveryFailed;\n\nEvent::listen(function (EmailBounced $event) {\n    if ($event-\u003eisPermanent()) {\n        Notification::route('mail', config('ops.alerts_to'))\n            -\u003enotify(new EmailDeliveryFailed($event));\n    }\n});\n```\n\nIt renders a short summary (address, status, bounce type, the provider's\nreason). Subclass it to customise the body or to add `database`/`slack`\nchannels.\n\n## Securing webhooks\n\nPostmaster authenticates every inbound webhook and rejects anything it can't\ntrust. Each provider proves authenticity differently, so configure the one\ncredential yours needs. These are all `.env` values. Nothing to publish.\n\n### SendGrid\n\nEnable the Signed Event Webhook in SendGrid and copy the verification key:\n\n```\nPOSTMASTER_SENDGRID_VERIFICATION_KEY=...\n```\n\n### Mailgun\n\n```\nPOSTMASTER_MAILGUN_SIGNING_KEY=...   # falls back to MAILGUN_SECRET\n```\n\n### Amazon SES\n\nSES delivers events through SNS. Subscribe an SNS topic to\n`webhooks/postmaster/ses`. The package verifies the SNS message signature and\nautomatically completes the subscription-confirmation handshake. No secret to\nconfigure.\n\n### Resend\n\n```\nPOSTMASTER_RESEND_SIGNING_SECRET=whsec_...\n```\n\n### Postmark\n\nPostmark does not sign webhook payloads. Use HTTP basic auth (the default) or a\nURL token:\n\n```\nPOSTMASTER_AUTH_USERNAME=...\nPOSTMASTER_AUTH_PASSWORD=...\n```\n\n### Token or basic auth\n\nAny provider can instead use a shared URL token or HTTP basic auth by setting\nits `auth` to `token` or `basic`:\n\n```\nPOSTMASTER_AUTH_TOKEN=mysecrettoken\n# then append ?auth=mysecrettoken to the webhook URL\n```\n\nEach provider's verification method is its `auth` key in\n`config/postmaster.php`: a built-in authorizer (`token`, `basic`,\n`user-agent`) or a fully-qualified authorizer class. Providers default to\nsignature verification where the provider supports it.\n\n## Verify your setup\n\nThe install wizard offers to run this at the end, but you can run it any time\nto re-check a round trip after a config or provider change:\n\n```bash\nphp artisan postmaster:verify\n```\n\nIt detects your provider from the mail config, shows the exact webhook URL to\nregister, sends a real test email to an address you supply, then watches live\nfor the delivery webhook to come back. It reports each event the instant it\nlands.\n\nThe live watch needs a cache store shared between your CLI and web processes\n(`file`, `redis`, `database`, and so on). With the per-process `array` store\nthe command sends the test email and stops there.\n\nIt runs non-interactively too (no TTY, or `-n` / `--no-interaction`) — for a\ndeploy hook or CI check. Pass the recipient with `--to=`, and `--provider=` if\nit can't be detected from your mail config; the \"have you set the webhook?\"\nconfirmation is skipped, the live watch prints without a spinner, and the exit\ncode reflects the result (0 on a delivery, non-zero on a timeout or failure).\n\n```bash\nphp artisan postmaster:verify --no-interaction --to=you@example.com --provider=postmark\n```\n\nIt runs even while [sandbox delivery](#sandbox-delivery) is on: it warns that\noutbound mail is sandboxed, then sends one test email that bypasses the sandbox\nso you can confirm the whole round trip before switching `POSTMASTER_DELIVERY`\nto `normal`. Your delivery setting is left unchanged.\n\n## The EmailEvent\n\nEvery webhook becomes an `EmailEvent` with a normalized API. The methods are\nthe same whatever the provider:\n\n```php\n$event-\u003eprovider();           // \"SendGrid\", \"Postmark\", \"Mailgun\", \"SES\", \"Resend\"\n$event-\u003estatus();             // one of the EmailEvent::STATUS_* constants\n$event-\u003etoAddress();          // the recipient email address\n$event-\u003eproviderMessageId();  // the provider's message id\n$event-\u003eoccurredAt();         // when the event happened (DateTimeImmutable, UTC)\n$event-\u003ebounceType();         // normalized bounce severity, or null\n$event-\u003eisPermanent();        // true for a hard bounce or a block\n$event-\u003eresponse();           // the provider's response/diagnostic detail\n$event-\u003ereason();             // the provider's reason string\n$event-\u003ecode();               // the provider's status code\n$event-\u003eclickedUrl();         // the URL clicked on a click event (else null)\n$event-\u003etags();               // Collection of tags/categories\n$event-\u003edata();               // Collection of custom data\n$event-\u003epayload();            // the raw provider payload\n$event-\u003etoArray();            // everything above as an array\n```\n\n\u003e **A note on provider casing.** Config keys are lowercase identifiers\n\u003e (`sendgrid`, `postmark`, `mailgun`, `ses`, `resend`). Stored and surfaced\n\u003e values are the canonical product name (`SendGrid`, `Postmark`, …). The\n\u003e `provider()` method, the `provider` column, and the dashboard all use the\n\u003e latter.\n\n### Statuses\n\n`status()` returns one of:\n\n`EmailEvent::STATUS_ACCEPTED`, `STATUS_DEFERRED`, `STATUS_DELIVERED`,\n`STATUS_BOUNCED`, `STATUS_DROPPED`, `STATUS_COMPLAINED`, `STATUS_OPENED`,\n`STATUS_CLICKED`. There are five more for outbound records the package\nwrites itself: `STATUS_SENT`, `STATUS_SANDBOXED`, `STATUS_BLOCKED`,\n`STATUS_LOGGED` (sends through Laravel's `log` driver), and `STATUS_CAPTURED`\n(sends through the `array` driver). The last four are terminal; no webhook\nwill follow.\n\nFor comparing against a single value, every status has a matching `is*()`\npredicate. They make a status check read clearly and they autocomplete:\n\n```php\nif ($event-\u003eisBounced())    { /* … */ }\nif ($event-\u003eisDelivered())  { /* … */ }\nif ($event-\u003eisFailed())     { /* bounced, dropped, or complained */ }\n```\n\nThe same predicates are available on `EmailMessage` (where they answer\nagainst the latest recorded status):\n\n```php\nif ($message-\u003eisFailed())   { /* the latest event was a failure */ }\n```\n\n### Targeted event classes\n\nFor the six lifecycle statuses worth dedicated listeners, a targeted event\nclass fires alongside the umbrella `EmailEvent` and lets you skip the\npredicate:\n\n| Targeted class | Fires for |\n|---|---|\n| `EmailDelivered` | `STATUS_DELIVERED` |\n| `EmailBounced` | `STATUS_BOUNCED` |\n| `EmailComplained` | `STATUS_COMPLAINED` |\n| `EmailDropped` | `STATUS_DROPPED` |\n| `EmailOpened` | `STATUS_OPENED` |\n| `EmailClicked` | `STATUS_CLICKED` |\n\nEvery targeted class extends `EmailEvent`, so the API is the same. You get\nall the accessors, predicates, and the correlated `emailMessage()` record\nwithout needing to know which class fired:\n\n```php\nuse STS\\Postmaster\\EmailBounced;\n\nEvent::listen(function (EmailBounced $event) {\n    $event-\u003etoAddress();       // works\n    $event-\u003ebounceType();      // works\n    $event-\u003eemailMessage();    // same EmailMessage the umbrella listener saw\n});\n```\n\nStatuses without a dedicated class (`STATUS_ACCEPTED`, `STATUS_DEFERRED`,\nthe outbound `STATUS_SENT`/`SANDBOXED`/`BLOCKED`/`LOGGED`/`CAPTURED`)\nfire only the umbrella; listen on `EmailEvent` and use `is*()` if you\nneed them.\n\n### Bounce classification\n\nBeyond the action, bounces are normalized into a severity, so you can answer\n\"should I stop mailing this address?\" without provider-specific knowledge:\n\n- `EmailEvent::BOUNCE_HARD`: permanent, and safe to suppress.\n- `EmailEvent::BOUNCE_SOFT`: transient, so retry later.\n- `EmailEvent::BOUNCE_BLOCK`: blocked by reputation or policy.\n\n`bounceType()` returns one of these (or `null` when the event is not a\nbounce). `isPermanent()` is a shortcut for \"hard or block\".\n\n## Invalid payloads\n\nIf a payload can't be turned into a valid event, the `on_invalid` config\nsetting decides what happens: `log` (default), `throw`, or `ignore`.\n\n```\nPOSTMASTER_ON_INVALID=log\n```\n\n## Tracking delivery\n\nEverything above is the core: a verified webhook endpoint and a normalized\nevent.\n\nPostmaster also **records every outbound email** and keeps each record current\nfrom the webhook stream, matching them up by provider message id, so you end\nup with a queryable delivery history. Publish and run the migrations:\n\n```bash\nphp artisan vendor:publish --tag=postmaster.migrations\nphp artisan migrate\n```\n\nThat's it. Persistence is on. To run the package as a pure event dispatcher\nwith no database writes, set:\n\n```\nPOSTMASTER_PERSISTENCE=false\n```\n\nThis creates an `email_messages` table. Each row tracks a message's\n`status`, `bounce_type`, `sent_at`, and `last_event_at`. The model\n(`STS\\Postmaster\\Models\\EmailMessage`) is swappable via the\n`postmaster.persistence.message_model` config key.\n\nIt ships query scopes for the common lookups: `delivered()`, `bounced()`,\n`complained()`, `opened()`, `clicked()`, `sent()`, `accepted()`, `deferred()`,\n`dropped()`, the aggregate `failed()` (bounced, dropped, or complained), and\nthe generic `withStatus()`.\n\n```php\nuse STS\\Postmaster\\Models\\EmailMessage;\n\nEmailMessage::bounced()-\u003ecount();\nEmailMessage::delivered()-\u003ewhere('sent_at', '\u003e', now()-\u003esubDay())-\u003eget();\n```\n\nThe package still dispatches `EmailEvent` in all modes. Persistence is just a\nfirst-party listener layered on top.\n\nWith persistence on, each `EmailEvent` also carries the record it was\ncorrelated to, so a listener can walk straight back to the originating message,\nand through it to your own model:\n\n```php\nuse Illuminate\\Support\\Facades\\Event;\nuse STS\\Postmaster\\EmailEvent;\n\nEvent::listen(function (EmailEvent $event) {\n    $order = $event-\u003eemailMessage?-\u003erelated;   // the Order, User, ... it was sent for\n});\n```\n\n`$event-\u003eemailMessage` is set by the package's own listener, which is\nregistered first, so it is populated for any listener of your own. It is null\nwhen persistence is disabled or the webhook carries no message id to correlate\non.\n\n### Recording the full timeline\n\nThe summary record above keeps only a message's *latest* status. That's enough\nfor \"is this delivered?\" but it can't represent a message that was opened three\ntimes, and it overwrites the history as new events arrive.\n\nWith persistence on, the package also keeps every event as its own row in an\n`email_activity` table (the initial send and each webhook alike), so a\nmessage retains its complete delivery history. This is on by default; set\n`POSTMASTER_RECORD_EVENTS=false` to keep only the summary record.\n\n\u003e **A note on naming.** \"Event\" is the live signal a webhook becomes (an\n\u003e `EmailEvent` value object you `Event::listen` for). \"Activity\" is the\n\u003e historical record we keep of those events in `email_activity`, plus\n\u003e address-level entries (manual suppress, unsuppress, sync add) that\n\u003e don't tie to a specific message. Same idea, two abstractions.\n\nEach `EmailMessage` exposes its timeline, oldest first, via the `activity()`\nrelationship. `EmailAddress` exposes a symmetric `activity()` of every entry\nthat touched it (message lifecycle events sent to it, plus any address-level\nentries):\n\n```php\nforeach ($message-\u003eactivity as $entry) {\n    // $entry-\u003estatus:      sent, delivered, opened, bounced, ...\n    // $entry-\u003eoccurred_at: when it happened\n    // $entry-\u003ebounce_type, $entry-\u003eresponse, $entry-\u003ereason, $entry-\u003ecode\n}\n\nforeach ($address-\u003eactivity as $entry) {\n    // Every event that touched this address: message lifecycle events\n    // from messages sent to it, plus suppressed/unsuppressed entries\n    // for the address itself.\n}\n```\n\nThe summary record is still maintained alongside the timeline, and still\nadvances only on the newest event, so out-of-order webhooks can't make its\nstatus regress. Query `EmailMessage` for current state, walk `events()` for\nhistory.\n\nTimeline rows accumulate one per event, so the package prunes them on a\nschedule. There are two windows, because a six-month-old open is noise but\na six-month-old bounce is still evidence. Routine activity (sent, delivered,\nopened, clicked, …) and failures (bounced, dropped, complained) are pruned\nseparately:\n\n| Bucket | Default | `.env` |\n|---|---|---|\n| Routine | **90 days** | `POSTMASTER_PRUNE_ROUTINE_ACTIVITY_AFTER_DAYS` |\n| Failures | **365 days** | `POSTMASTER_PRUNE_FAILED_ACTIVITY_AFTER_DAYS` |\n\nSet either to `0` to disable that bucket. The pruner deletes whole rows;\nsummary records are left untouched.\n\n### Tracking address suppression\n\nThe projections so far answer \"what happened to this *message*?\". Suppression\nanswers a different question: should I send to this *address* at all? The\nmessage tables can't answer that cleanly, because a bad address poisons every\nfuture send, not just the message that bounced.\n\nWith persistence on, the package keeps an `email_addresses` table: one row per\nrecipient with a current `status` of `active` or `suppressed`. This is on by\ndefault; set `POSTMASTER_TRACK_ADDRESSES=false` to disable it.\n\nAn address is suppressed automatically on a hard bounce, a spam complaint, or a\ndrop. Soft bounces don't count, since they're transient.\n\nSuppression is sticky against opens and clicks. A later delivery is the one\nexception: if a `delivered` webhook arrives for an automatically-suppressed\naddress, the package flips it back to active. The reasoning matches what\n`postmaster:sync` does for the provider side — a successful delivery is hard\nproof the address works now, so the local row should reflect that. Manual\nsuppressions (operator-asserted via `Postmaster::suppress()`) are never auto-\ncleared by any webhook; only `Postmaster::unsuppress()` lifts a manual one.\n\nCheck it before sending:\n\n```php\nuse STS\\Postmaster\\Facades\\Postmaster;\n\nif (! Postmaster::isSuppressed($email)) {\n    Mail::to($email)-\u003esend(new Invoice($order));\n}\n```\n\nAn address you've never sent to is treated as sendable. You can also manage\nsuppression yourself, for unsubscribes, abuse reports, anything:\n\n```php\nPostmaster::suppress($email);     // optional second arg: a reason string\nPostmaster::unsuppress($email);\n```\n\nThe `EmailAddress` model carries `active()` / `suppressed()` query scopes and\nthe `reason` / `suppressed_at` columns for the rest.\n\n**Suppression is global, never per tenant.** A provider suppresses a\nhard-bouncing address across your whole account regardless of which tenant sent\nthe mail, so a per-tenant view would just disagree with reality.\n\n#### Block suppressed sends automatically\n\nThe check above is opt-in per send. To make every outbound to a suppressed\naddress fail safely at the source, set:\n\n```\nPOSTMASTER_BLOCK_SUPPRESSED=true\n```\n\nAnything addressed to a suppressed recipient is intercepted before it reaches\nthe mail transport, recorded with status `blocked` (so the attempt is visible\nin the dashboard), and dropped. Bypass it per send by lifting the suppression\nor by skipping the check yourself. There's no per-message bypass flag.\n\n#### Two-way sync with your provider\n\nThe webhook stream is one feed into the suppression table. Every time a\nprovider tells us about a bounce or complaint, we record it. But it's not\nthe only source of truth. The provider has its own authoritative list, and\nadmins can clear suppressions in the provider's dashboard or via their API.\nIf you only listen to webhooks you can't see those clearances and your\nlocal table drifts out of date.\n\n`postmaster:sync` pulls each configured provider's current suppression list\nand reconciles it with our local table. New provider suppressions land\nhere, and addresses the provider no longer holds are cleared locally\n(unless they're **manual** suppressions, which are operator decisions and\nnever auto-cleared). It runs daily at 04:00 once persistence is on, and\ncan be invoked by hand:\n\n```bash\nphp artisan postmaster:sync                    # all configured providers\nphp artisan postmaster:sync --provider=sendgrid\nphp artisan postmaster:sync --dry-run          # report without writing\n```\n\nEach provider needs two things: its official SDK installed (suggested in\n`composer.json`, not required) and an API key configured. With either\nmissing, that provider is skipped with an informative line:\n\n| Provider | SDK | Config key |\n|---|---|---|\n| SendGrid | `composer require sendgrid/sendgrid` | `POSTMASTER_SENDGRID_API_KEY` (or `SENDGRID_API_KEY`) |\n| Postmark | `composer require wildbit/postmark-php` | `POSTMASTER_POSTMARK_SERVER_TOKEN` (or `POSTMARK_TOKEN`) |\n| Mailgun  | `composer require mailgun/mailgun-php` | `POSTMASTER_MAILGUN_API_KEY` (or `MAILGUN_SECRET`) + `POSTMASTER_MAILGUN_DOMAIN` |\n| Amazon SES | `composer require aws/aws-sdk-php` | Uses the standard AWS credential chain |\n| Resend | — | Resend has a full API but no suppression-list resource (suppressions are dashboard-only); sync is a no-op for Resend, and the local table is fed entirely by the webhook stream |\n\n**Unsuppress is two-way too.** Each suppression row records which\nprovider(s) put it on the list (via webhook events or sync). When you\ncall `Postmaster::unsuppress($address)`, or click Unsuppress in the\ndashboard, the local row is lifted and every recorded provider with\nAPI support is asked to clear theirs. The method returns an array\nwith `cleared` (providers we successfully called) and `manual`\n(providers without API support, where the suppression has to be\ncleared in the provider's own dashboard).\n\nThe dashboard reflects this: the Unsuppress button appears only when\nat least one of an address's recorded providers has a usable API. For\nrows whose only source is a provider without one (Resend today), the\nbutton is replaced with a \"Manage in {Provider}\" hint instead of\nimplying an action that can't actually do what it suggests.\n\n### Storing message content\n\nBy default a record holds only delivery metadata. Enable content storage and\neach record also keeps a full representation of the email: sender, recipients\n(to/cc/bcc), subject, HTML and text bodies, and attachment filenames. This is\ncaptured from the message itself at send time, so it works the same for every\nprovider.\n\n```\nPOSTMASTER_STORE_CONTENT=true\n```\n\n\u003e Message bodies are large and routinely contain personal data or secrets\n\u003e (password-reset links, magic-login tokens). This is why it's off by default.\n\u003e Attachment **contents** are never stored, only their filenames. And because\n\u003e content is captured before sending, it won't reflect the click-tracking link\n\u003e rewriting some providers apply afterward.\n\nBecause of the size and sensitivity, content carries a short retention window\nby default (30 days), after which the daily prune clears the content columns\nand leaves the record itself in place. Adjust or disable from `.env`:\n\n```\nPOSTMASTER_PRUNE_CONTENT_AFTER_DAYS=14   # tighter\nPOSTMASTER_PRUNE_CONTENT_AFTER_DAYS=0    # disable pruning entirely (not advised for content)\n```\n\nStored content and timeline events share one daily prune command. Run it by\nhand any time:\n\n```bash\nphp artisan postmaster:prune              # both content and events\nphp artisan postmaster:prune --content    # only stored content\nphp artisan postmaster:prune --activity   # only timeline activity\n```\n\nA single email can override the global setting. A Mailable's `Tracking` carries\na `storeContent` field, and the notification `MailMessage` has fluent\n`storeContent()` / `dontStoreContent()` methods. So a password-reset or MFA\nemail can keep its body out of the database even when storage is on, and a\nspecific email can be captured even when it's off:\n\n```php\n// in a Mailable's postmaster() method\nreturn new Tracking(related: $this-\u003euser, storeContent: false);\n\n// on a notification MailMessage\nreturn (new MailMessage)-\u003esubject('Your login code')-\u003edontStoreContent();\n```\n\nSome sensitive mail you never construct yourself — Fortify and similar\npackages send password-reset, verification, and MFA emails for you, so there's\nno Mailable or `MailMessage` to call `dontStoreContent()` on. Register a global\nresolver to make the decision per message instead. It's the global equivalent\nof the per-message methods: it receives the Symfony message (subject, headers,\nrecipients) and returns whether to store content.\n\n```php\nPostmaster::storeContentWhen(\n    fn ($message) =\u003e ! str_contains($message-\u003egetSubject(), 'Reset Password')\n);\n```\n\nThe resolver runs once per message — so it keys off message-level signals, not\na single recipient. Precedence is: a per-message `storeContent()` /\n`dontStoreContent()` override wins, then this resolver, then the\n`POSTMASTER_STORE_CONTENT` flag. Note that the originating Mailable/Notification\nclass isn't available at this point (only the compiled message is), so match on\nthe subject or a header rather than the class; where you need class-level\nprecision, Laravel's own `ResetPassword::toMailUsing()` hook runs early enough\nto call `dontStoreContent()` directly.\n\n### Resending a recorded email\n\nAny recorded `EmailMessage` with stored content can be replayed through the\nconfigured mailer:\n\n```php\n$message-\u003eresend();\n\n// or equivalently\nPostmaster::resend($message);\nPostmaster::resend($messageId);\n```\n\nThe new send carries over everything we can reconstruct from the recorded\nrow — sender, To/Cc/Bcc envelope, subject, html and text bodies, related /\nrecipient / tenant context, tags — and gets a `resent` tag of its own. The\nnew row's `resent_from_id` points back to the original so the chain is\nqueryable:\n\n```php\n$message-\u003eresentFrom;                  // BelongsTo — the original, or null\n$message-\u003eresends;                     // HasMany — direct resends of this row\n$message-\u003eresendChain();               // the whole lineage, ordered by sent_at\n\n// Did any retry of this bounced message eventually deliver?\n$message-\u003eresends()-\u003edelivered()-\u003eexists();\n```\n\nAttachments are not restored — the package only persists their filenames,\nnever their bytes. Resend throws `RuntimeException` when there's no stored\ncontent to replay; enable `POSTMASTER_STORE_CONTENT` ahead of the original\nsend.\n\nApp code that builds its own resend outside `Postmaster::resend()` (e.g. a\ncustom Mailable for a specific retry workflow) can declare the link via\n`Tracking`:\n\n```php\nreturn new Tracking(\n    related:     $this-\u003eorder,\n    recipient:   $this-\u003ecustomer,\n    resent_from: $originalMessage,\n);\n```\n\nThat populates the FK on the new row so the dashboard's chain card and the\nrelationship helpers see the resend the same way they see one from\n`Postmaster::resend()`.\n\n### Relating emails to your models\n\nRecorded emails can be linked back to two of your models: the one the email\nis **about** (an `Order`, an `Invoice`) and the one the email is **for** (the\n`User` it was sent to). Keeping these distinct means a user can list every\nemail they've ever received without having to traverse every business record\nthey touch.\n\nAdd the `TracksMailable` trait to a Mailable and declare both with a\n`postmaster()` method that returns a `Tracking` object. It works the same way\nas Laravel's own `envelope()` and `content()`:\n\n```php\nuse Illuminate\\Mail\\Mailable;\nuse STS\\Postmaster\\Concerns\\TracksMailable;\nuse STS\\Postmaster\\Tracking;\n\nclass OrderConfirmation extends Mailable\n{\n    use TracksMailable;\n\n    public function __construct(public Order $order) {}\n\n    public function postmaster(): Tracking\n    {\n        return new Tracking(\n            related: $this-\u003eorder,              // what the email is about\n            recipient: $this-\u003eorder-\u003ecustomer,  // who the email is for\n            tenant: $this-\u003eorder-\u003eaccount_id,   // optional; see Multitenancy below\n            tags: ['billing'],                  // optional; see below\n        );\n    }\n\n    public function envelope(): Envelope { /* ... */ }\n    public function content(): Content { /* ... */ }\n}\n```\n\nPostmaster reads `postmaster()` when the mailable is sent, after a queued job\nis dequeued (so it's queue-safe), and records what the `Tracking` declares.\nEvery field is optional, so declare only the ones that apply.\n\n\u003e Need to set something dynamically instead? `TracksMailable` also exposes\n\u003e `relatedTo($model)`, `forRecipient($model)`, `forTenant($tenant)`,\n\u003e `storeContent()` and `dontStoreContent()`. Call them anywhere before the\n\u003e mailable is sent.\n\nFor apps where every email is to a known `User`, the recipient can be\nresolved from the to-address automatically. Declare a resolver once in a\nservice provider and skip `recipient:` on every Mailable:\n\n```php\nuse STS\\Postmaster\\Facades\\Postmaster;\n\nPostmaster::resolveRecipientByEmail(User::class);\n```\n\nThat's the one-liner for the \"look up the model by its `email` column\"\ncase. Pass `column: 'whatever'` to match against a different column. The\naddress is normalized (lower-cased, trimmed) before the lookup, so a\nmixed-case webhook still finds the row.\n\nFor anything else (a derived column, a join, a custom query), drop down to\nthe lower-level form:\n\n```php\nPostmaster::resolveRecipientUsing(\n    fn ($address) =\u003e Contact::query()\n        -\u003ewhere('primary_email', $address)\n        -\u003eorWhere('billing_email', $address)\n        -\u003efirst()\n);\n```\n\nAn explicit `Tracking(recipient: …)` declaration always wins over either\nform of the resolver, which is useful when an email about User A is sent\nto User B.\n\n#### Multi-recipient sends\n\nEach envelope recipient (To, Cc, Bcc) gets its own `email_messages` row,\nall sharing the provider message id and the related/tenant/tags. That's\nbecause providers fire delivery and bounce webhooks **per recipient**, and\none row per address keeps each delivery state accurate. A bounce for\n`bob@x` lands on bob's row; alice's stays untouched.\n\nFor sends where each recipient maps to a different user, declare the map\ninline with `Tracking(recipients: [...])`:\n\n```php\nreturn new Tracking(\n    related: $this-\u003eorder,\n    recipients: [\n        'alice@example.com' =\u003e $alice,\n        'bob@example.com'   =\u003e $bob,\n    ],\n);\n```\n\nLookup is case-insensitive. Addresses not in the map fall through to\n`Postmaster::resolveRecipientUsing()`, so you only need to declare the\nones the resolver wouldn't find.\n\nThe dashboard's message list shows a small `cc` / `bcc` tag next to the\naddress for non-To rows. The message detail page lists the other rows\nof the same outbound submission under an \"Also sent to\" block, each\nlinking to its own detail page.\n\n### Tagging\n\n`Tracking`'s `tags` are Laravel's own mailable tags. Postmaster records them on\nthe message so you can categorise and query your recorded mail:\n\n```php\nEmailMessage::taggedWith('billing')-\u003ebounced()-\u003eget();\n```\n\nBecause they're Laravel's tags, a notification's `MailMessage` sets them with\nits native `tag()` method, and Symfony forwards them to providers whose\ntransport supports tags. Postmaster reads and records whatever is there, so a\nplain Mailable calling `tag()` directly is recorded just the same.\n\nAdd `HasEmailMessages` to the business-record model and `IsEmailRecipient` to\nthe User-side model:\n\n```php\nuse STS\\Postmaster\\Concerns\\HasEmailMessages;\nuse STS\\Postmaster\\Concerns\\IsEmailRecipient;\n\nclass Order extends Model\n{\n    use HasEmailMessages;   // emails this order is *about*\n}\n\nclass User extends Model\n{\n    use IsEmailRecipient;   // emails this user has *received*\n}\n```\n\nBoth traits expose the same shape (`emailMessages()`, `latestEmailMessage()`,\n`emailDeliveryFailed()`) but key off different polymorphic links, so each\nmodel only sees the emails it owns:\n\n```php\n$order-\u003eemailMessages;                        // every email about this order\n$order-\u003eemailMessages()-\u003efailed()-\u003eexists();  // did any of them fail?\n\n$user-\u003eemailMessages;                         // every email this user received\n$user-\u003elatestEmailMessage();                  // the most recent one, or null\n```\n\nBoth associations are carried on the message in-process only, written as\nheaders and read and stripped *before* the email is transmitted, so nothing\nabout the related or recipient model is ever exposed in the outbound email.\n\n\u003e Both use polymorphic relationships. If your models use UUID/ULID primary\n\u003e keys, change `nullableMorphs('related')` and `nullableMorphs('recipient')`\n\u003e to the matching variants in the published migration.\n\n### From a notification\n\nNotifications send through the same mailer, so recording, content capture, and\nstatus correlation all work for notification emails with no extra setup. A\nnotification's `toMail()` returns a `MailMessage` rather than a Mailable, so to\n*associate* one, swap Laravel's `MailMessage` for Postmaster's. It's a drop-in\nsubclass with the same fluent `relatedTo()` and `forTenant()` methods:\n\n```php\nuse STS\\Postmaster\\Notifications\\TrackedMailMessage;\n\npublic function toMail($notifiable)\n{\n    return (new MailMessage)\n        -\u003esubject('Your order shipped')\n        -\u003eline('Your order is on its way.')\n        -\u003erelatedTo($this-\u003eorder)\n        -\u003eforTenant($this-\u003eorder-\u003etenant);\n}\n```\n\nOnly the import changes. Postmaster's `MailMessage` is Laravel's with the\n`WithTracking` trait applied, so every notification builder method\n(`line()`, `action()`, and so on) works unchanged.\n\nAlready maintain your own `MailMessage` subclass? Add the `WithTracking`\ntrait to it directly. It works on anything exposing `withSymfonyMessage()`.\n\nOr, to skip subclassing entirely, pass the `Postmaster` builders straight to\n`withSymfonyMessage()` on a plain `MailMessage`:\n\n```php\nuse STS\\Postmaster\\Facades\\Postmaster;\n\nreturn (new MailMessage)\n    -\u003esubject('Your order shipped')\n    -\u003eline('Your order is on its way.')\n    -\u003ewithSymfonyMessage(Postmaster::relatedTo($this-\u003eorder))\n    -\u003ewithSymfonyMessage(Postmaster::forTenant($this-\u003eorder-\u003etenant));\n```\n\n### Multitenancy\n\nIn a multitenant app you'll often want every recorded email tagged with its\nowning tenant, including emails that aren't tied to any `related` model, so a\ntenant can see all of its delivery activity at once.\n\nRegister a tenant resolver, typically in a service provider:\n\n```php\nuse STS\\Postmaster\\Facades\\Postmaster;\n\nPostmaster::resolveTenantUsing(fn () =\u003e tenant());\n```\n\nThe resolver may return a tenant model or its key, and is called lazily when\neach email is recorded, so it resolves correctly per request or queued job.\n\nIf tenant context isn't available globally (e.g. inside a queued job that\ndoesn't bootstrap tenancy), a Mailable can declare its tenant explicitly in its\n`Tracking`. That always takes precedence over the resolver:\n\n```php\nclass OrderConfirmation extends Mailable\n{\n    use TracksMailable;\n\n    public function postmaster(): Tracking\n    {\n        return new Tracking(\n            related: $this-\u003eorder,\n            tenant: $this-\u003eorder-\u003etenant,\n        );\n    }\n}\n```\n\nQuery a tenant's activity:\n\n```php\nEmailMessage::forTenant($tenant)-\u003ebounced()-\u003eget();\n```\n\nTo get a `tenant()` relationship on `EmailMessage` (and tenant labels in the\ndashboard), tell Postmaster your tenant model. Register it in a service\nprovider, with no need to publish the config file:\n\n```php\nuse STS\\Postmaster\\Facades\\Postmaster;\n\nPostmaster::useTenantModel(App\\Models\\Tenant::class);\n```\n\nOr, if you publish the config, set `persistence.tenant_model` there instead.\n\nA few notes for multitenant setups:\n\n- **Inbound webhooks have no tenant context.** Providers POST to one global\n  URL. Correlation runs by provider message id and deliberately ignores global\n  scopes, so a tenant-scoped model is still updated correctly.\n- **Database-per-tenant:** point `persistence.connection` at a shared\n  connection. The webhook handler can't know which tenant database to write to,\n  so the table must live somewhere globally reachable.\n- The tenant column defaults to `tenant_id` (configurable via\n  `persistence.tenant_column`) and is an `unsignedBigInteger`. Apps with\n  UUID/ULID tenant keys should change its type in the published migration.\n\n## Dashboard\n\nA gated, cross-tenant superadmin view of all recorded email activity. Browse\nand search messages, watch events stream in live, manage suppression. It's\nbuilt for support, and every screen is a linkable URL.\n\nIt's off by default. Enable it, and it mounts at `/postmaster`:\n\n```\nPOSTMASTER_DASHBOARD=true\n```\n\nThe dashboard reads the persistence tables, so it requires the\n[persistence layer](#tracking-delivery) to be enabled.\n\n### Authorization\n\nThe dashboard deliberately shows email across *every* tenant. It's the one\nplace tenant isolation is bypassed by design, so access must be gated. Register\nan authorization callback, Telescope-style, in a service provider:\n\n```php\nuse STS\\Postmaster\\Facades\\Postmaster;\n\nPostmaster::auth(fn ($request) =\u003e $request-\u003euser()?-\u003eisSuperAdmin());\n```\n\nWith no callback registered, access is allowed **only in the `local`\nenvironment**, so the dashboard is never unguarded in production by accident.\n\n### Screens\n\n- **Overview.** Headline counts and an activity chart over a selectable\n  timeframe, plus recent-messages and live recent-activity cards.\n- **Messages.** A filterable inbox (status, provider, tag, tenant, recipient,\n  subject, date range). Each message opens to its delivery timeline and stored\n  content, rendered in a sandboxed, CSP-restricted frame. Click events show\n  the URL the recipient clicked, inline on the timeline.\n- **Person view.** Click the Recipient row on a message detail page to land\n  on a page listing every email recorded against that recipient model. The\n  \"all the email a user has received\" view.\n- **Resend.** A button on the message detail page replays the stored email\n  through the configured mailer, keeping the original's related model,\n  recipient, tenant, and tags, plus a `resent` tag of its own. The new row\n  links back to the original via `resent_from_id`, and the message detail\n  shows a **Resend chain** sidebar card walking the lineage so a support\n  reviewer can see at a glance whether a retry-after-unsuppress ever\n  delivered. Requires stored content; attachments are not restored.\n\n  The button is hidden when the recipient is currently suppressed — clear\n  the suppression first (the Addresses screen has the unsuppress action) — and\n  on a sandboxed message, which shows **Release** instead (a sandboxed message\n  was never sent, so there's nothing to resend). A message that was released,\n  and so is genuinely sent, is resendable like any other. Rapid duplicate\n  clicks are throttled per-message\n  (`POSTMASTER_DASHBOARD_RESEND_THROTTLE_SECONDS=60` by default).\n- **Release.** On a *sandboxed* message, a Release button sends that one email\n  for real — the deliberate opt-out from sandbox mode for a single message.\n  Unlike Resend it doesn't create a new record: it sends the stored content,\n  then flips the existing row from `sandboxed` to sent with the real provider\n  message id (so its webhooks correlate from then on). Because the row is no\n  longer sandboxed it can't be released twice, and the button disappears.\n  Requires stored content; shown in place of Resend on sandboxed rows.\n- **Delete.** A Delete button on the message detail page removes a record from\n  the stored history — for scrubbing PII or purging something that shouldn't\n  have been kept. It deletes the message row and its timeline; any resends of\n  it survive (their link is nulled), and other envelope recipients of the same\n  email are separate records, left untouched. The confirm dialog is explicit\n  that this only removes Postmaster's record — it does **not** recall or unsend\n  an email that already went out.\n- **Activity.** A filterable, paginated stream of every recorded event, drawn\n  from the timeline (on by default with persistence).\n- **Addresses.** The suppression list.\n\nEvery datetime is stored UTC and displayed in the viewer's browser timezone\nby default. A small clock toggle in the header swaps between that and UTC;\nthe choice is per-browser (localStorage). The chart's daily buckets stay\nUTC-anchored either way.\n\nThere are no assets to publish and no CDN. The dashboard serves its own\nstylesheet and its one client-side dependency (Alpine) straight from the\npackage. The path and middleware are configurable under the `dashboard` config\nkey.\n\n## Sandbox delivery\n\nIn a staging environment you often want emails to *appear* in your app, so you\ncan see what was sent, to whom, and with what content, without anything\nactually landing in a real inbox. Sandbox delivery does exactly that:\n\n```dotenv\nPOSTMASTER_DELIVERY=sandbox\n```\n\nWith this set, every outbound email is intercepted before it reaches the mail\ntransport and **never sent**. With persistence enabled it is still recorded,\nwith a `sandbox` status, so it shows up in your app's email history exactly\nlike a real send, including its related model, tenant, and (if content storage\nis on) its rendered body.\n\n```php\nEmailMessage::sandbox()-\u003eget();   // everything intercepted in sandbox mode\n```\n\nA sandboxed message is **terminal**: it never reached a provider, so no\ndelivery/open/bounce webhooks will ever follow. Render the `sandboxed` status\ndistinctly in your UI rather than as a pending send.\n\n### Releasing a sandboxed email\n\nSometimes one sandboxed message really should go out — a support reply, a\none-off you want to test end to end. The dashboard's **Release** button (and\nits API) sends that specific message for real and flips its record from\n`sandboxed` to sent, keeping the same row so its history stays intact. Once\nreleased it can't be released again.\n\n```php\n$message-\u003erelease();               // send this sandboxed message for real\nPostmaster::release($message);\nPostmaster::release($messageId);\n```\n\nRelease requires stored content (there's nothing to send otherwise) and throws\n`RuntimeException` if the message isn't sandboxed — already released, or never\nsandboxed to begin with. The real provider message id lands on the row, so the\nusual delivery/open/bounce webhooks correlate to it from then on.\n\n\u003e Sandbox is provider-agnostic. It works the same no matter which provider you\n\u003e send through. It needs persistence on to record anything (the default).\n\u003e Without persistence, mail is still suppressed but nothing is stored, at\n\u003e which point Laravel's `log` mailer is the simpler tool.\n\nBecause sandbox silently drops *all* mail, enabling it in `production` is almost\nnever intended. Postmaster logs a warning at boot if it sees that.\n\n`postmaster:verify` still runs the full round trip while sandbox is on — it\nwarns that delivery is sandboxed, then sends a single test email that bypasses\nthe sandbox (the same escape hatch as Release) so you can confirm your webhook\nsetup works before flipping `POSTMASTER_DELIVERY` to `normal`. Your delivery\nsetting is left unchanged.\n\nThe `POSTMASTER_DELIVERY` setting is an enum, and `normal` is the default. A\n`redirect` mode, which would send every email to a single catch-all address, is\nreserved for a future release.\n\n## Configuration\n\nThe defaults work out of the box. To change the webhook path, adjust\nper-provider settings, or tweak persistence, publish the config file:\n\n```bash\nphp artisan vendor:publish --tag=postmaster.config\n```\n\nThe webhook route is registered for you. To register it yourself instead, say\non a custom domain or prefix or with your own middleware, set\n`POSTMASTER_REGISTER_ROUTE=false` and call `Postmaster::routes()` from your own\nroute file.\n\n## Custom providers\n\nRegister your own provider at runtime with a resolver closure:\n\n```php\nuse STS\\Postmaster\\Facades\\Postmaster;\nuse STS\\Postmaster\\Provider;\n\nPostmaster::extend('myprovider', function (array $config) {\n    return new Provider('myprovider', MyAdapter::class, fn ($request) =\u003e true);\n});\n```\n\nAn adapter implements `STS\\Postmaster\\Contracts\\Adapter` (extending\n`STS\\Postmaster\\Providers\\AbstractAdapter` covers most of it).\n\n## License\n\nMIT. See [LICENSE.md](LICENSE.md).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fstechstudio%2Flaravel-postmaster","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fstechstudio%2Flaravel-postmaster","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fstechstudio%2Flaravel-postmaster/lists"}