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 |
mail.destination.0 |
eventType, lowercased (default bounce) |
mail.headersTruncated.X-Odden-Token |
postmark |
Recipient or Email |
RecordType, lowercased (default bounce) |
Metadata.odden_token |
sendgrid |
email |
event: bounce and dropped become bounce, spamreport becomes complaint, unsubscribe becomes unsubscribed |
odden_token |
resend |
data.to.0 |
type: email.bounced, email.complained, and email.delivered become bounce, complaint, and delivered |
data.tags.odden_token |
generic |
email or recipient |
event_type, type, or event (default bounce) |
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 |
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 |
| 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:
- Postmark's
SpamComplaintrecord type is stored but doesn't suppress anyone. - A temporary Mailgun failure (
soft_bounce) is stored but doesn't suppress anyone; only a permanent one does. - Every
bounceis treated as permanent. SES and Postmark soft (transient) bounces and SendGriddroppedevents suppress the address too. - With the
generic,ses, andpostmarkformats, an event with no type is treated as a bounce.
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 by its tracking token if the payload carries one. Otherwise, it's linked to the most recent recipient with the same email address.
To match exactly, pass the recipient's tracking_token to your provider as metadata named odden_token when you send (Mailgun user variables, Postmark metadata, SendGrid custom args, Resend tags). Campaign messages carry the token in an X-Odden-Tracking-Token header, so you can copy it into your provider's metadata in a MessageSending listener or your provider's header-mapping settings. For SES, the configured path (mail.headersTruncated) is a boolean in SES events, so SES events always fall back to matching by address.
Amazon SES
The ses format reads the SES event object itself (eventType, mail, bounce). Amazon SNS HTTP subscriptions wrap that object in a JSON string inside an SNS envelope, send it as text/plain, and require a subscription confirmation. The endpoint handles none of that, so receive SNS notifications in your own route and forward the inner message.
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 whose last_marketing_email_sent_at is --days or more in the past. It skips anyone already suppressed, anyone with fewer than --min-sends campaign recipients, and anyone with an open or click in the window. 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 task 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)setsis_unengaged,unengaged_since, andsunset_stage = 'flagged'on contacts not emailed for that many days.ExecuteSunsetPolicyAction::execute(Contact $contact, bool $forceSuppress = false)moves aflagged(or unengaged) contact toreengagement_sent, and areengagement_sentcontact (or any contact, with$forceSuppress) tosuppressed. Each step logs a task on the contact.
The reengagement_sent stage doesn't send a re-engagement email; send one yourself. A suppressed stage only stops campaign email while fatigue protection is enabled. It doesn't add the address to the suppression list.