Skip to content

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().

php
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 deleted

suppress(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=:

text
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:

bash
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"}]'
json
{"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 Email 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):

json
{
    "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 SendGrid blocked) 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 bounce is treated as permanent. Send soft_bounce yourself from the generic format for temporary failures.
  • An event with no type is stored as unknown and 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):

ini
ODDEN_MARKETING_MAILGUN_SIGNING_KEY=your-mailgun-http-webhook-signing-key

Then point Mailgun's permanent_fail and complained webhooks (plus unsubscribed, if you use it) at the plain URL, with no token:

text
https://example.com/marketing/webhooks/esp/mailgun

With 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

php
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 strings

execute(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.

php
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():

php
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=quarantine or p=reject; p=none is a warning.
  • overall_status is pass when SPF, DMARC, and MX pass. It's fail if SPF or MX is missing, and warning otherwise. DKIM doesn't affect it.
  • Domains ending in .test, and localhost, 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.

php
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.

bash
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 sets properties.is_sunset_dormant to true and properties.sunset_dormant_detected_at.
  • With --suppress: it unsubscribes the address globally, sets properties.sunset_suppressed and properties.sunset_suppressed_at, and logs a completed system_event activity on the contact.

It prints a table with the number of candidates, dormant contacts, and suppressed contacts. You can also call the action yourself:

php
use Odden\Marketing\Actions\ProcessSubscriberSunsetPolicyAction;

$result = app(ProcessSubscriberSunsetPolicyAction::class)->execute(
    inactivityDays: 90,
    minSendsReceived: 3,
    autoSuppress: false,
);

$result['contact_ids']; // the dormant contacts

Only 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) sets is_unengaged, unengaged_since, and sunset_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 a flagged (or unengaged) contact to reengagement_sent and queues a re-engagement email (with a link to their preference center), and a reengagement_sent contact (or any contact, with $forceSuppress) to suppressed and unsubscribes the address globally. Each step logs a completed system_event activity 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.