Paid add-on. Marketing is licensed separately from the open-source packages. See pricing
Deliverability
Keep bounced and complaining addresses off your lists with the suppression list and ESP webhooks, lint campaigns before sending, check sender domains, and sunset unengaged subscribers.
Mailbox providers judge your sender reputation by bounces, complaints, and engagement. This page covers the tools the package gives you to protect it: a global suppression list fed by your email provider's webhooks, content linting, a DNS check for your sending domain, and a sunset policy for subscribers who stopped opening.
The suppression list
Odden\Marketing\Models\EmailSuppression is a do-not-send list keyed by email address. Campaign dispatch skips every address on it, through MarketingSubscription::isSuppressed().
use Odden\Marketing\Models\EmailSuppression;
EmailSuppression::suppress('blocked@example.com', 'manual_blocklist', 'manual', ['note' => 'Requested by legal']);
EmailSuppression::isSuppressed('Blocked@Example.com'); // true
EmailSuppression::remove('blocked@example.com'); // true if a row was deletedsuppress(string $email, string $reason = 'hard_bounce', ?string $source = null, ?array $metadata = null): EmailSuppression lowercases the address and creates the row only if it doesn't exist; an existing row keeps its original reason.
The package writes these reasons: hard_bounce, spam_complaint, and unsubscribe, with a source of esp_webhook:{provider}. Use any other string, such as manual_blocklist, for your own entries.
The transactional API doesn't check this list.
ESP webhooks
Point your email provider's bounce and complaint webhooks at the package, and it suppresses those addresses automatically. There are two endpoints, both requiring the API token. Most providers only let you set a URL, so pass the token as ?token=:
https://example.com/marketing/webhooks/esp/postmark?token=YOUR_TOKEN
https://example.com/api/marketing/webhooks/deliverability?token=YOUR_TOKEN| Endpoint | Route name | Provider |
|---|---|---|
POST /marketing/webhooks/esp/{provider} (web group) |
odden.marketing.webhooks.esp |
From the URL |
POST /api/marketing/webhooks/deliverability (api group) |
odden.marketing.webhooks.deliverability |
From a provider field in the body, default generic |
Both are CSRF exempt and limited by throttle:odden-api. The API token is the only check, with one exception: Mailgun webhooks can be authenticated by Mailgun's own signature instead.
The body can be a single event object or a JSON array of events:
curl -X POST "https://example.com/marketing/webhooks/esp/sendgrid" \
-H "X-Odden-Token: $ODDEN_MARKETING_API_TOKEN" \
-H "Content-Type: application/json" \
-d '[{"email": "bounce@example.com", "event": "bounce", "status": "5.1.1", "reason": "User unknown"}]'{"status": "received", "count": 1, "event_ids": [42]}A single object returns {"status": "received", "event_id": 42, "event_type": "bounce"}.
Supported providers
{provider} selects how the payload is read. Anything not listed uses the generic format.
| Provider | Event type | Tracking token | |
|---|---|---|---|
mailgun |
event-data.recipient |
event-data.event: failed with event-data.severity of permanent becomes hard_bounce, any other failed becomes soft_bounce (stored only), and complained becomes complaint |
event-data.user-variables.odden_token |
ses |
The bounced or complaining recipient (bounce.bouncedRecipients.0.emailAddress, complaint.complainedRecipients.0.emailAddress), else mail.destination.0 |
eventType (or notificationType): a Bounce with bounceType Permanent becomes hard_bounce, any other bounce soft_bounce (stored only), Complaint becomes complaint, Delivery becomes delivered; no type is unknown |
The X-Odden-Tracking-Token entry of mail.headers, or mail.tags.odden_token |
postmark |
Recipient or Email |
RecordType: SpamComplaint becomes complaint; a Bounce whose Type is HardBounce, BadEmailAddress or ManuallyDeactivated becomes hard_bounce, SpamNotification becomes complaint, any other bounce soft_bounce (stored only); Delivery becomes delivered; no type is unknown |
Metadata.odden_token |
sendgrid |
email |
event: bounce becomes bounce (permanent), or soft_bounce (stored only) when type is blocked; dropped stays dropped (stored only); spamreport becomes complaint; unsubscribe becomes unsubscribed |
odden_token |
resend |
data.to.0 |
type: email.bounced becomes hard_bounce when data.bounce.type is Permanent and soft_bounce otherwise (Transient, Undetermined); email.complained and email.delivered become complaint and delivered |
data.tags.odden_token |
generic |
email or recipient |
event_type, type, or event (default unknown) |
tracking_token |
The generic format also reads error_code (or code) and error_message (or reason):
{
"provider": "generic",
"email": "spam@example.com",
"event_type": "complaint",
"error_message": "Marked as spam",
"tracking_token": "the recipient's tracking_token"
}What each event does
Every event is stored as an EspEvent (provider, event_type, email, campaign_id, recipient_id, error_code, error_message, and the raw payload). Then:
| Event type | Effect |
|---|---|
bounce, hard_bounce |
MarketingSubscription status Bounced; suppression reason hard_bounce; recipient status Bounced; campaign bounces_count + 1; the contact's email status becomes invalid (reason bounced) |
complaint, spam |
Status Unsubscribed; reason spam_complaint; recipient status Unsubscribed; campaign unsubscribes_count + 1 |
unsubscribed |
Status Unsubscribed; reason unsubscribe; recipient status Unsubscribed; campaign unsubscribes_count + 1 |
delivered |
A Pending recipient becomes Sent; a contact whose email status was empty or unknown becomes valid |
| anything else | Stored only |
For the first three, the contact also gets an Unsubscribed lead scoring event, if a recipient was matched.
Check how your provider names its events before relying on this. Only the names in the table above have an effect:
- A temporary failure (
soft_bounce: Mailgun, SES transient, Postmark and SendGridblocked) is stored and doesn't suppress anyone on its own; only permanent ones do. An address that keeps soft-bouncing is treated as a hard bounce. - A plain
bounceis treated as permanent. Sendsoft_bounceyourself from thegenericformat for temporary failures. - An event with no type is stored as
unknownand has no effect.
Authenticating Mailgun webhooks
Mailgun can't send an API token header, and putting ?token= in its webhook URL leaves a secret in Mailgun's settings and in your access logs. Instead, set the HTTP webhook signing key from the Mailgun dashboard (Webhooks):
ODDEN_MARKETING_MAILGUN_SIGNING_KEY=your-mailgun-http-webhook-signing-keyThen point Mailgun's permanent_fail and complained webhooks (plus unsubscribed, if you use it) at the plain URL, with no token:
https://example.com/marketing/webhooks/esp/mailgunWith a signing key set, POST /marketing/webhooks/esp/mailgun accepts a request only if Mailgun's signature block is valid: the HMAC-SHA256 of the timestamp and token values, keyed with the signing key. The API token no longer works on that URL, a signature older than 15 minutes (ODDEN_MARKETING_MAILGUN_SIGNATURE_TOLERANCE, in seconds) is rejected, and each signature token is accepted once, so a captured request can't be replayed. A failed check returns 401 and records nothing. Replay protection uses your application cache, so use a shared cache store when you run several servers.
Without a signing key, Mailgun webhooks use the API token like every other provider. The /api/marketing/webhooks/deliverability endpoint always uses the API token.
Matching events to recipients
An event is linked to a CampaignRecipient only by the tracking token the message carried. An event without a token is stored and still acts on the address (a hard bounce or complaint suppresses it, a delivery updates its email status), but it changes no recipient and no campaign counter: the address alone can't tell which campaign, if any, the message belonged to.
Campaign messages carry the token two ways: an X-Odden-Tracking-Token header, and mail metadata named odden_token, which Laravel's Mailgun, Postmark and SendGrid transports send as user variables, Metadata and custom_args, and the SES v2 transport as a message tag. Each provider's events then bring it back (event-data.user-variables.odden_token, Metadata.odden_token, odden_token, mail.tags.odden_token). For SES, the X-Odden-Tracking-Token entry in mail.headers also works when you publish events with headers enabled. Laravel's Resend transport doesn't send tags, so for Resend add an odden_token tag yourself (for example in a MessageSending listener) for events to be matched.
Amazon SES
The ses format reads the SES event object itself (eventType, mail, bounce), and also accepts the SNS envelope that wraps it in an HTTP subscription (sent as text/plain). Point the subscription at /marketing/webhooks/esp/ses?token={your API token} (SNS can only be given a URL). A SubscriptionConfirmation is confirmed automatically when its SubscribeURL is an https://sns.<region>.amazonaws.com endpoint (anything else gets 422), a Notification is unwrapped and processed like a bare SES event, and other envelope types are acknowledged and ignored. The envelope's SNS signature isn't verified; the API token is what authenticates the request.
Email status and sending
Core records an email status on every contact: valid, risky, invalid or unknown (see Core's email validation). Marketing uses it in two ways.
Sending. A campaign or workflow email is not sent to a contact whose status is invalid: a malformed address, a domain that accepts no mail, or an address that bounced. A queued recipient that turns invalid before it is sent is marked Suppressed, and a workflow email step logs it as skipped. Risky addresses (a typo in a well-known domain, a disposable provider, a shared mailbox such as info@) are sent to unless you opt in to skipping them. Contacts that have never been checked are not held back.
Delivery results. Your provider's events feed back into the status, because a delivery attempt is better evidence than any lookup: a hard bounce makes the contact invalid with the reason bounced, and a re-check or an import never clears that; only a change of the contact's address does. A first delivery makes an unchecked or unknown contact valid. A spam complaint suppresses the address but says nothing about whether it can receive mail, so the status is left alone.
Under deliverability in config/odden-marketing.php:
| Key | Default | What it does |
|---|---|---|
skip_invalid |
true (MARKETING_SKIP_INVALID_ADDRESSES) |
Skip contacts whose status is invalid |
skip_risky |
false (MARKETING_SKIP_RISKY_ADDRESSES) |
Also skip risky contacts |
soft_bounce_limit |
5 (MARKETING_SOFT_BOUNCE_LIMIT) |
Soft bounces that make an address undeliverable; 0 never escalates |
soft_bounce_window_days |
30 (MARKETING_SOFT_BOUNCE_WINDOW_DAYS) |
How far back soft bounces are counted |
This needs getodden/crm-core 0.10.2 or later, which records the email status.
Repeated soft bounces
A full mailbox or a flaky server produces temporary bounces that suppress nobody, so an address that never recovers would be mailed forever. When an address soft-bounces soft_bounce_limit times within soft_bounce_window_days, with no delivery in between, the next one is handled as a hard bounce, with the suppression reason soft_bounce_limit. A message a provider reports twice counts once.
Linting a campaign
Two actions score a campaign's content before you send it. Neither runs automatically, and neither blocks dispatch.
LintCampaignDeliverabilityAction
use Odden\Marketing\Actions\LintCampaignDeliverabilityAction;
$report = app(LintCampaignDeliverabilityAction::class)->execute($campaign);
$report['score']; // 0–100
$report['status']; // 'excellent' (90+), 'good' (75+), 'warning' (50+), or 'critical'
$report['warnings']; // list of ['rule' => ..., 'message' => ..., 'severity' => 'critical'|'warning']
$report['passed_checks']; // list of strings
$report['recommendations']; // list of stringsexecute(Campaign $campaign, ?string $overrideHtml = null, ?string $overrideSubject = null): array checks the campaign's subject, preview text, and sender, and its template's body_html (or the HTML you pass). It starts at 100 and deducts:
| Rule | Deduction | Fails when |
|---|---|---|
sender_email_valid |
30 | The sender address is missing or invalid |
sender_domain_authenticated |
25 | The sender uses a free mail domain (gmail.com, yahoo.com, outlook.com, …) |
subject_required |
30 | The subject is blank |
subject_length |
10 | The subject is over 60 or under 8 characters |
subject_punctuation |
10 | The subject has !!, ??, or $$ |
subject_spam_words |
15 | The subject contains a phrase like "100% free", "act now", or "buy now" |
preview_text_provided |
10 | There's no preview text |
unsubscribe_compliance |
30 | The body has neither {{unsubscribe_url}} nor the word "unsubscribe" |
broken_placeholder_links |
10 | The body has href="" or href="#" |
text_to_image_ratio |
15 | The body has images and fewer than 100 characters of text |
AuditCampaignDeliverabilityAction
The Filament plugin's audit modal uses this one. It returns score, rating (Excellent, Good, Fair (Needs Review), or High Spam Risk), and checks, a list of ['name', 'passed', 'severity', 'message'].
It checks for an unsubscribe link, unbalanced {{/}} merge tags, spam phrases in the subject or body, a long run of capitals or repeated punctuation in the subject, a consumer sender domain, and a body with fewer than 30 characters of text.
use Odden\Marketing\Actions\AuditCampaignDeliverabilityAction;
$audit = app(AuditCampaignDeliverabilityAction::class)->execute($campaign);Checking a sending domain
DomainHealthCheckService::diagnose() looks up a domain's SPF, DKIM, DMARC, and MX records with dns_get_record():
use Odden\Marketing\Services\DomainHealthCheckService;
$health = app(DomainHealthCheckService::class)->diagnose('acme.com', 'odden');
$health['overall_status']; // 'pass', 'warning', or 'fail'
$health['dmarc']['policy']; // e.g. 'quarantine'The second argument is the DKIM selector (default odden), looked up at {selector}._domainkey.{domain}. Each of spf, dkim, dmarc, and mx has a status, a label, what was found, a recommendation, and a note.
- DMARC passes with
p=quarantineorp=reject;p=noneis a warning. overall_statusispasswhen SPF, DMARC, and MX pass. It'sfailif SPF or MX is missing, andwarningotherwise. DKIM doesn't affect it.- Domains ending in
.test, andlocalhost, always return a passing result with sample records, without a DNS lookup.
Throttling by domain
DomainThrottler plans sends so no mailbox provider gets too many messages a minute. It only calculates a plan; it doesn't delay or send anything.
use Odden\Marketing\Services\DomainThrottler;
$plan = DomainThrottler::calculateThrottledBatches(
recipients: [['to' => 'a@gmail.com'], ['to' => 'b@yahoo.com']],
customDomainLimits: ['acme.com' => 30],
defaultPerMinute: 120,
);
$plan['waves']; // [['wave_index' => 0, 'offset_seconds' => 0, 'count' => 2, 'recipients' => [...]]]Built-in limits per minute: Yahoo, Ymail and AOL 60; Gmail and Googlemail 120; Hotmail, Outlook, Live and MSN 100; iCloud and me.com 80. The plan also has total_recipients, domain_distribution, and estimated_dispatch_duration_seconds. The transactional batch endpoint returns one with throttle_domains; see Transactional email.
Fatigue protection
Fatigue protection caps how many campaign emails one contact gets. It's off by default; see Campaigns.
Sunset policy
Sending to people who never open hurts your reputation. The sunset policy finds them and, if you choose, unsubscribes them.
php artisan marketing:sunset-subscribers --days=90 --min-sends=3
php artisan marketing:sunset-subscribers --days=90 --min-sends=3 --suppress| Option | Default | Meaning |
|---|---|---|
--days |
90 |
Inactivity window |
--min-sends |
3 |
Campaign emails the contact must have been sent before they can be sunset |
--suppress |
off | Unsubscribe dormant contacts instead of only flagging them |
The command runs ProcessSubscriberSunsetPolicyAction, which considers contacts who are being mailed but never engage: at least --min-sends campaign emails sent, the first one --days or more ago, still receiving mail (a send inside the window), and no open or click inside the window. Contacts you stopped mailing are not sunset, and anyone already suppressed is skipped. For each remaining contact:
- Without
--suppress: it setsproperties.is_sunset_dormanttotrueandproperties.sunset_dormant_detected_at. - With
--suppress: it unsubscribes the address globally, setsproperties.sunset_suppressedandproperties.sunset_suppressed_at, and logs a completedsystem_eventactivity on the contact.
It prints a table with the number of candidates, dormant contacts, and suppressed contacts. You can also call the action yourself:
use Odden\Marketing\Actions\ProcessSubscriberSunsetPolicyAction;
$result = app(ProcessSubscriberSunsetPolicyAction::class)->execute(
inactivityDays: 90,
minSendsReceived: 3,
autoSuppress: false,
);
$result['contact_ids']; // the dormant contactsOnly contacts you haven't emailed for --days are candidates. A contact you keep sending to who never opens has a recent last_marketing_email_sent_at, so the policy never reaches them. The installation guide schedules the command daily without --suppress, so by default it only flags.
Sunset stages
A second set of actions tracks a contact through stages in the sunset_stage column. Nothing in the package runs them on a schedule:
DetectUnengagedContactsAction::execute(int $daysInactive = 90, int $minSends = 3)setsis_unengaged,unengaged_since, andsunset_stage = 'flagged'on the same contacts the command considers (mailed, never engaged), skipping suppressed and unsubscribed ones.candidates()returns that query without flagging anyone.ExecuteSunsetPolicyAction::execute(Contact $contact, bool $forceSuppress = false)moves aflagged(or unengaged) contact toreengagement_sentand queues a re-engagement email (with a link to their preference center), and areengagement_sentcontact (or any contact, with$forceSuppress) tosuppressedand unsubscribes the address globally. Each step logs a completedsystem_eventactivity on the contact.
The suppressed stage also adds the address to the suppression list, so it stops all marketing email whether or not fatigue protection is enabled.