Skip to main content

Documentation

No results found.
Features Members

Marketing (Email, SMS & Web Push Campaigns)

WebProCMS includes a built-in marketing feature — design branded broadcast emails in a familiar block editor, write plain-text SMS campaigns, or send browser push notifications; deliver them to your subscriber list or targeted CRM segments,...

WebProCMS includes a built-in marketing feature — design branded broadcast emails in a familiar block editor, write plain-text SMS campaigns, or send browser push notifications; deliver them to your subscriber list or targeted CRM segments, and measure results. There's no Mailchimp account to pay for, no embed scripts, and your list never leaves your own database.


What it does

  • Design emails visually in a block-based designer with a live preview — the layout mirrors the page builder (preview on the left, editable blocks on the right), so anyone who has edited a page already knows how to build an email.
  • Send to the right people: the whole subscriber list, every CRM contact, or a specific CRM group or status (e.g. "Customers" only).
  • Style it your way: one Style panel sets the whole email's look — colors, font, buttons, a header line above the card, colored bands, a footer note — so an email can match the one you already send.
  • Design with AI: describe the look you want, or paste a screenshot of an email you already send, and the AI builds it; keep asking for changes until it's right, and undo any change you don't like.
  • Edit the HTML when you need to: switch any email to HTML mode to hand-edit the code (or have the AI edit it), and switch back to the visual designer at any time.
  • Start from templates: seven built-in starting points — four layouts (Announcement, Monthly Update, Promotion, Simple Note) and three finished designs (Public Notice, Classic Newsletter, Bold Promotion) — plus save-your-own templates, style and all, for recurring sends.
  • Measure results: unique opens, unique clicks, and per-link click totals on every campaign — including link clicks in text messages, via short redirects on your own domain.
  • Set up automated drips: sequences send a timed series ("day 0 welcome, day 3 tips, day 7 offer") to each new subscriber or CRM-enrolled lead automatically — see "Sequences" below.
  • Stay compliant automatically: every email ends with your business name, mailing address, and an unsubscribe link; one-click unsubscribe headers (RFC 8058) are always sent; opt-outs are honored across every audience forever. The site's other marketing emails — the shop's abandoned-cart nudge, loyalty reminders and CRM automation emails — carry the same link and headers and honour the same list. Every signup records proof of consent (when, from which IP, through which form, and the wording the person was shown), and texts only ever go to people who agreed to texts. Optional double opt-in confirms new signups by email.
  • Send through anything: the server's own mail settings, or a bulk email service (Postmark, Resend, Mailgun, SendGrid) by pasting one API key — no extra packages or configuration files.
  • A/B test subject lines: try two subjects on a slice of the audience; the better opener automatically goes to everyone else.
  • Schedule at the best time: the scheduler recommends the weekday/hour your own readers actually open, from your send history.
  • Reach browsers with Web Push: a third channel with no per-message cost and no external account — visitors opt in via a small prompt, notifications deliver straight to their browser.
  • Check deliverability before you send: a domain wizard verifies SPF/DKIM/DMARC/MX records, and every email draft gets a one-click spam-score scan.
  • Automatic blog digests: a recurring "what we published" email assembled from your newest posts — sent (or drafted for review) on a schedule.

Find it under Dashboard → Marketing (enabled by default for members; toggle under Settings → Features). Campaigns come in three channels: Email (the block designer below), Text message (SMS) (see “SMS campaigns”), and Web Push (see “Web Push notifications”).

The email designer

Open an email campaign and you get a two-pane designer:

  • Left: live preview — the actual email HTML rendered in an iframe, refreshed as you type, with sample data filled into personalization tokens.
  • Right: the sidebar, in four tabs — Design (Design with AI and the blocks, or the HTML), Style (the look of the whole email), Settings (subject, preview text — the snippet inboxes show after the subject — audience picker with a live recipient count, link tagging, and the A/B subject test) and Send (a test send and the deliverability checks). Once a campaign has gone out, the last tab becomes Results with the Performance card, and the page opens on it. Text-message and push campaigns have two tabs: Message and Send.

Blocks

Emails use their own small library of email-safe blocks rather than the page builder's design library. Email clients (Outlook especially) can't render modern CSS, so each block compiles to bulletproof inline-styled table HTML behind the scenes:

Block What it does
Logo Your site logo (or any image), with width and alignment controls
Heading Large (H1) or medium (H2) headline
Text Body copy — supports paragraphs, basic inline HTML, and personalization tokens
Image Full-width image from the media library, optionally linked
Button Call-to-action button — defaults to your brand's primary color
Divider Thin horizontal rule
Spacer Vertical breathing room (16–64px)

Blocks can be duplicated, removed, and reordered with the same click-to-move pattern as the menus editor: click the move icon on a block, then click the block you want to drop it on (Esc cancels). Everything autosaves as you edit.

Images go through the standard media library picker. Buttons follow the email's Style — the site's primary brand color unless the Style sets another — and any single button can override it.

Style

Below the blocks, the Style panel sets how the whole email looks. Blocks say what the email contains; the style says how all of it looks, so one change (the card color, the font) restyles every block at once.

Setting What it controls
Colors & font Page background, card, headings, body text, links, dividers, and an email-safe font (Modern, Arial, Helvetica, Verdana, Trebuchet, Georgia, Times New Roman)
Card Corner rounding and an optional border around the 600px content card
Buttons Default button color (brand color unless set), label color, border, and corner shape (square to pill)
Header & bands A line of text above the card ({{site_name}} fills in the business name), and solid color bands directly above and below the card — the top band can be inset for a tab-like look
Footer A short note below the card (e.g. "Questions? Reply to this email") and the footer text and link colors

Every color is stored as hex, because email clients don't render modern color formats. A new email starts with the default look; Reset returns every setting to it. The style is saved with the email and with any template saved from it.

HTML mode

The Design / HTML switch at the top of the Content card turns an email into a raw HTML document. Switching to HTML starts from the current design, so nothing is lost; the left pane gains Code and Preview tabs, and the code autosaves as you type. You can keep asking the AI for changes in HTML mode — it edits the code directly.

Switching back to Design returns the email to its blocks and style. HTML edits don't carry back into the blocks, but the HTML is kept: the next switch to HTML offers to pick it up where you left off or start fresh from the design.

Two things stay automatic in HTML mode:

  • The compliance footer. The {{footer}} placeholder marks where the business name, address and unsubscribe link go. Delete it and the footer is added at the end anyway — an email can never go out without its opt-out.
  • Clean-up. Scripts, forms, embedded frames and javascript: links are stripped at send time. None of them work in an inbox, and spam filters count them against the sender.

The size readout warns past 100 KB, because Gmail cuts off longer messages behind a "View entire message" link.

Design with AI

With an AI provider connected, the Design with AI panel in the designer sidebar works like a conversation:

  1. Describe the look ("a dark blue card with white text and a gray band across the top"), or paste or attach a screenshot of an email you already send and ask the AI to recreate it.
  2. The AI builds the whole design — style and blocks — and the preview updates. When recreating from a screenshot it copies the text word for word and matches the colors.
  3. Keep asking for changes ("make the button white with a dark border", "add our phone number to the footer note"). Each turn works on the email as it is now, including any edits you made by hand in between.
  4. Undo puts back the design from before the last AI change.

The AI writes a whole email, so a turn can take up to a minute or two. It runs in the background: the panel shows a progress bar, and the design locks until the result arrives. If a request needs a layout the blocks can't do, such as two columns or a different background behind one section, the AI can switch the email to HTML mode. It says so when it does.

What the AI is held to:

  • It never writes the unsubscribe link, address or "you are receiving this" line. Those stay in the automatic footer.
  • It can't invent facts. Dates, times and addresses you haven't given come back as clear [placeholders].
  • It can't create pictures. An image block it adds stays empty for you to fill from the media library, and the only images it can place are ones the email already uses.
  • Everything it returns is checked before it reaches the email: unknown blocks and settings are dropped, colors must be valid hex, and markup is cut down to links and basic formatting.

Screenshots go to the AI provider for that one request and are deleted from the server when the turn ends. Screenshot support needs a provider that can read images; DeepSeek can't, so with DeepSeek the panel takes text only.

Personalization

{{name}} and {{email}} work in any text block and in the subject line. {{name}} falls back to "there" when the recipient has no name ("Hi there,"). The unsubscribe URL is unique per recipient and inserted automatically in the footer.

With the Reviews feature on, {{review_link}} becomes each recipient's own trackable review-funnel link — use it as a button's URL or in a text block or SMS body. At send time it mints (or reuses, while unanswered) a review request for that recipient, so the Reviews dashboard tracks who opened the funnel and who clicked through to a platform. Click tracking deliberately never rewrites it, and with Reviews off it degrades to the site home URL.

You never build the compliance footer — every email automatically ends with the business name, the mailing address from Marketing Settings, a "you're receiving this because you subscribed" line, and the recipient's unsubscribe link. Anti-spam laws (CAN-SPAM, GDPR) require the address and the opt-out link in marketing email.

Audiences & CRM targeting

The Send to picker offers:

  • All subscribers — everyone on the newsletter list who hasn't opted out (and has confirmed, when double opt-in is on).
  • Subscribers + all CRM contacts — the combined reach.
  • Any CRM group — e.g. a "VIP" or "Wholesale" group.
  • Any CRM status — e.g. only "Customer" contacts.

Recipients are deduped by email across sources, and the designer shows the live count after suppression. When a CRM contact is targeted, they get a subscriber record (source crm) at send time so their unsubscribe link works like anyone else's.

Opt-outs are global. Anyone who unsubscribes is suppressed from every future audience — including CRM-targeted sends — until they explicitly opt back in. Admins can also unsubscribe or fully remove a person from Marketing → Subscribers (e.g. on request).

Marketing emails outside campaigns. The abandoned-cart email (Shop), loyalty reminders (Loyalty) and CRM automation emails are marketing too, so each carries an unsubscribe link (with the Marketing mailing address above it) and the one-click headers, and skips anyone on the suppression list. Someone who already has a subscriber row gets their own link. Someone who doesn't — a shopper who never signed up — gets a link that names their address in a sealed token, and no subscriber row is created when the email is sent, so nobody lands on the newsletter list by receiving a cart reminder. Using the link creates their row already unsubscribed (shown with the source Unsubscribe link (never subscribed)), which suppresses them everywhere. Unsubscribe links work even while the Marketing feature is switched off: a link someone was emailed must keep working.

Subscribers

Dashboard → Marketing → Subscribers lists everyone with search and a status filter (Subscribed / Pending confirmation / Unsubscribed / Bounced / Everyone, plus Needs review while there is something to review). From here you can add people manually (optionally with a phone number and a recorded SMS opt-in), unsubscribe/resubscribe them, toggle their SMS consent, mark them bounced or clear a bounce, open their Consent record (row menu), or remove them entirely. Bounced addresses don't count as subscribed. See "Bounces" below for everything bounce-related on this page.

People join the list from the site through the newsletter signup rows in the page builder — Contact Newsletter, Hero Newsletter, CTA Newsletter Signup, the Blog Detail Newsletter CTA, and the sidebar Newsletter widget all post to the built-in signup endpoint. The forms work on statically cached pages (plain POST, no JavaScript required), are rate-limited, and carry an invisible honeypot field that silently drops naive bots. Successful signups land on a "You're subscribed!" confirmation page.

Every signup row also ships an optional phone + SMS opt-in block (off by default — enable it per form with the "Show Phone & SMS Opt-in" toggle in the page editor). It adds a phone field and an explicit "Also text me…" consent checkbox; the checked box is the express SMS consent (TCPA), so a phone submitted without it is stored but never texted. The checkbox's own wording is kept as the SMS consent proof (see Consent proof below).

CSV import: the Subscribers page has an Import CSV button (headers: email, name, phone, sms_opt_in, plus optional consent columns consent_date, consent_ip, consent_source, consent_text and sms_consent_text; sample file downloadable). Existing subscribers are matched by email and never resubscribed if they opted out — of email or of texts; sms_opt_in is only honored on rows that also carry a phone. Each new row records who imported it, from which file, plus whatever consent evidence its columns carried; an existing row keeps its own consent record. Pending (double-opt-in) subscribers get a Resend confirmation action in their row menu.

Double opt-in

Off by default; enable under Marketing → Settings → Signup. With it on:

  • New form signups are held as Pending and emailed a branded confirmation link (rendered through the same email engine, so it matches your branding).
  • Pending people are excluded from every send until they click the link.
  • A previously-unsubscribed person who signs up again must also reconfirm — nothing silently reactivates an opt-out.
  • Subscribers added manually from the dashboard are confirmed immediately (an admin adding someone is the consent check).

Double opt-in proves consent (recommended under GDPR), keeps typo'd and forged addresses off the list, and protects sender reputation.

Every path that adds someone to the list records proof of the opt-in on their subscriber row — for email and, separately, for texts — and the latest opt-in replaces the one before. Only an opt-in that takes effect counts: a public signup (signup form, popup, chat, lead form) for an address already on the list changes nothing and leaves that person's proof alone, because anyone can type an address into a form. A returning person's proof is replaced when the signup brings them back — or, when only the confirmation click can (double opt-in, or a bounced address), it is held under consent.email_held and becomes the record when they click. Staff see it under Consent record in the subscriber's row menu: when, the IP address, how (the signup path), the page, and the wording shown.

Path What is recorded
Signup rows, the sidebar widget The visitor's IP, the page, and the visible text of the signup block (headline, copy, button, privacy note) — captured in the browser at submit time, because the block's copy is per-site content in cached pages. The SMS record holds the SMS checkbox's own label.
Campaign popups The same, recorded as a popup.
Form-builder forms with Add to Marketing The submitter's IP (for a paid form, the IP saved with the submission), the form, and the label of the checkbox the form names as its opt-in (Only subscribe people who tick) — only people who tick it are added. A form set to subscribe everyone who submits records no wording, never another box's label.
AI chat The visitor's IP, the conversation, and the visitor's own message asking to subscribe.
Real-estate / automotive lead forms The visitor's IP and the opt-in box's label.
Resubscribe on the unsubscribe page The visitor's IP and the words beside the button.
Added by staff, imports, ticket buyers Who did it (and for an import, the file and any consent columns the CSV carried). No visitor IP — the staff member is vouching.
The REST API The consent evidence the caller sends (time, IP, where, wording) and the API token's name.
A START text reply The reply itself.

Rows the site creates with no opt-in at all — a campaign or drip sent to CRM contacts, a course or project drip — keep no consent record, and the Consent record says so. Proof never decides who can be mailed: that is still the subscriber's opt-in state.

Templates

Dashboard → Marketing → Templates holds the starting points (email campaigns only — SMS has no blocks to template):

  • Built-in layouts: Announcement, Monthly Update, Promotion, Simple Note — generic block layouts in the default look.
  • Built-in designs: Public Notice (organization name above a slate card with white text — for meeting agendas and formal notices), Classic Newsletter (serif type on a warm paper background), Bold Promotion (dark, high-contrast, with a bright call to action). Each is a finished style plus placeholder copy.
  • Your own: in the designer, Save as Template snapshots the current blocks and style — or the HTML, for an email in HTML mode — for reuse. Ideal for recurring formats like a monthly roundup or an agenda notice.

Every template card shows a live thumbnail of the email it starts. "Use Template" creates a fresh draft from the template's blocks, style and HTML. Built-ins can't be deleted; custom templates can.

Sending

  • Send now — a confirmation dialog shows the audience and exact recipient count before anything goes out.
  • Schedule — pick a date/time; a background task (LazyCron, 60-second cadence) dispatches due campaigns, so scheduled sends work even on hosts without a real cron. When there's enough open history (30+ opens in the last 90 days), the schedule dialog also shows the best send time for your list — the weekday and hour your own readers open most — with a one-click "use it" button. No history yet, no guess: the suggestion simply doesn't appear.
  • Send a test — email the current draft to yourself at any time (marked [Test], never tracked, doesn't touch the list).

A/B subject-line testing

On any email campaign (not SMS/push/sequence steps), flip on A/B Test the Subject in the designer sidebar:

  • Enter Subject B, pick the test group size (10–50% of the audience) and how long to wait before picking the winner (30 minutes–24 hours).
  • On send, the test slice gets subject A or B (split evenly, assigned deterministically so an interrupted send never reshuffles anyone); the rest of the audience is held back.
  • After the wait window, the subject with more unique opens wins (clicks break ties; a dead heat falls back to A) and the remainder sends automatically with the winning subject — no one has to come back and press anything.
  • The sidebar shows each variant's sent/opens/clicks live, plus the winner badge once decided. The campaign stays in "Sending" between phases; that's normal.

Guard rails: audiences under 10 recipients skip the test (everyone gets subject A — a split that small is noise), and the designer warns if open tracking is off, since the winner is measured by opens.

Sends run as a queued job with a per-recipient delivery log. Individual failures are recorded (visible on the campaign) without aborting the run, and an interrupted send can be retried without double-sending anyone. Campaign cards show delivered/failed counts live.

Sending providers

Marketing → Settings → Sending Provider picks how email leaves the server:

  • Server email (default) — whatever the site's mail settings already use. Fine for small lists.
  • Postmark / Resend / Mailgun / SendGrid — bulk email services with proper deliverability tooling. Paste the service's API key under Settings → API Keys → Email Sending, then select the provider here. The calls go straight to each provider's HTTP API — no extra server packages.

The settings page shows a "Configured / Needs API key" badge per provider. Sender identity (From name/email, Reply-to) is set on the same page; leave blank to inherit the site-wide mail settings.

For deliverability at any real volume: use a bulk provider, send from a domain you've verified with that provider (SPF/DKIM), and keep the postal address filled in.

Bounces

In Server email mode the server's own mail system accepts every message straight away, so the campaign records it as sent — and a refusal by the recipient's mail server a moment later used to be invisible, leaving dead addresses on the list forever. When the server is set up to hand its delivery-failure notices back to the site (see "Server setup" below), every one of them is recorded. With a bulk provider (Postmark, Resend, Mailgun, SendGrid) the provider sees the bounces instead, and bounce tracking has it report them — and spam complaints — back to the site (see "Bounce tracking with a bulk provider" below; it is switched off by default for now); the same rules then apply.

What gets recorded. Each failed recipient in a notice becomes one entry in the bounce log: the address, what kind of failure it was, the receiving server's status code and its own explanation, and — when the bounce can be matched to it — the campaign or sequence send it belongs to. That send's status changes to Bounced. Addresses that aren't subscribers are logged too, so a bouncing order or notification email is visible as well. A bounced send still counts toward the campaign's sent total (it did leave the server) and is never re-sent.

Every campaign and sequence email sent through Server email carries a private reference header that the notice returns, which is how a bounce finds its exact send.

A notice has to prove itself. A notice that arrives automatically — from the server, or from a bulk provider's bounce tracking — removes someone, or marks their send Bounced, only when it carries the private reference of a campaign or sequence email this site sent to that very address. Any other notice is logged and nothing more: nobody is removed, unsubscribed or held for review, and no send is touched. That covers a notice with no returned headers, one about a notification or order email (no reference), and one whose reference names no send or someone else's. A forged or misdirected notice looks exactly like that, and removing a real subscriber over one is worse than missing a dead address — a real dead mailbox bounces again on the next campaign, with its reference. A bounce is never matched to a send by address alone. Notices you paste into Add bounced addresses, and addresses you mark by hand, are exempt: you are the proof.

What happens to the subscriber — three outcomes (for a notice that proved itself, or one you entered):

  • Suppressed immediately — the mailbox itself is dead: it doesn't exist, or the account is disabled. The receiving server has to say so in words ("user unknown", "no such mailbox"…) or with a mailbox-specific code; the vague "other address status" code (5.1.0) on its own is not enough.
  • Suppressed after repeated soft bounces — mailbox full, temporarily unavailable, the server gave up after days of retrying, or the recipient's domain couldn't be found or reached. One of these proves little; the address is suppressed once it has bounced this way 3 times within 90 days. Unrecognised permanent failures count the same way. Only bounces that proved themselves (or that you entered) count — a pile of unproven notices can't bring an address to its third strike — and the count starts again whenever someone vouches for the address: they click a confirmation link or are resubscribed, or you clear the bounce, add them again, or keep them on the list from the review (see "Needs review" below). They get three new strikes, not one — also when they had only been held for review and never actually removed. Domain failures are deliberately here rather than above: when the sending server's own DNS lookups break, it reports "host not found" for every domain at once, and that must not purge the list — a domain that is really gone still drops off on its third bounce.
  • Never suppressed: policy refusals — the receiving server refused your sender, not the address: failed SPF/DKIM/DMARC, a blocklisted server, a spam or reputation block, or a server that checks senders and found no mailbox behind your From address ("sender verify failed", "sender address rejected", "domain of sender address does not exist" — such a server quotes its own "user unknown" about your address, which must not read as the recipient's). The address is very likely fine, and the fix is on the sending side (start with the Deliverability wizard). This always wins: a refusal that mentions authentication, spam, blocking, policy or the sender address is never treated as a dead address, even if it arrives with an address-style code — so a broken mail setup can't purge real subscribers.

A suppressed address is excluded from every email audience, including CRM-targeted sends, from drip email steps (the step is skipped, the drip carries on, so its text messages still go out), and from cart-recovery and loyalty emails. Text messages are unaffected — a dead mailbox says nothing about a phone. The suppression lifts when the person proves the mailbox works by clicking a confirmation link, when an admin adds the address again with Add Subscriber or clears the bounce, or when they are resubscribed; a CSV import never lifts it, just as it never resubscribes someone who opted out. Signing up again does not lift it by itself, double opt-in or not: anyone can type an address into a form, so when a bounced address is entered on the site's signup form or given to the AI chat assistant, the site sends the confirmation email instead and the visitor is asked to check their inbox (the chat assistant says a confirmation email is on its way rather than "you're on the list"). Clicking the link puts the address back on the list; a mailbox that really is dead simply bounces the confirmation, and nothing changes. The opt-in box on a real-estate or automotive lead form works the same way in spirit: it re-subscribes someone who had unsubscribed, but a bounce on that address stays until it is confirmed or cleared by a person. Repeat deliveries of the same notice are recognised and recorded once — and so are two copies of one failure (the mail server's own copy and the one that reached your From address and was pasted in by hand), because a notice is recognised by the message that failed, not by the notice itself.

Mass-bounce safety brake. If an unusually large number of subscribers would be removed in a short time — 25, or 10% of the email list if that is more, but never more than 250, within 24 hours — the site stops removing anyone automatically. Real addresses don't die in bulk overnight; a problem on the sending side (a broken mail server, a DNS outage, a big mailbox provider having a bad hour) makes them all look dead at once. Bounces that arrive while the brake is on are still logged and their sends still marked Bounced, but the people stay on the list and are flagged for review instead. Once the brake has held anyone back, automatic removal stays paused until a person has looked: until every held address has been marked as bounced or kept on the list, or the held bounces are more than 30 days old. It does not resume by itself when the 24-hour window passes — whatever caused the burst is usually still broken — so any bounce that would remove someone in the meantime is held for review too. Addresses you mark by hand neither count toward the brake nor are stopped by it; that is how the review is settled.

The bounce log keeps 12 months. Entries older than that are deleted once a day. Nothing that decides anything looks back that far — suppression is stored on the subscriber, not in the log, so nobody comes back onto the list when an old entry goes — and the newest report from the server and from each bulk provider is always kept, because the Deliverability page reads it as proof that reports arrive.

On the Subscribers page

  • Bounced badge — a bounced subscriber shows a red Bounced badge, and under the address the date it bounced and the receiving server's reason (hover for the full text). Reasons are tidied for reading — the status code, then the server's own words without the mail-protocol prefixes it repeats on every line — while the bounce log keeps the server's text exactly as it arrived. Pick Bounced in the status filter to list them, newest first. Bounced people are never counted as subscribed.

  • Clear bounce (the button on a bounced row) — puts the address back on the email list, for when you know it works again. An unsubscribe, if they also have one, stays.

  • Mark as bounced (row menu, subscribed rows) — removes the address from email sends straight away. If the address has a bounce waiting for review, the stored reason says who confirmed it and keeps the receiving server's explanation ("Confirmed by Jamie · …"); otherwise it records who marked it.

  • Add bounced addresses (top of the page) — for bounces reported somewhere else, such as a mailbox you check by hand. Paste either:

    • the addresses, separated by new lines, commas or semicolons. Every word must be an email address, or nothing is changed and the ones that aren't are listed back to you, each on its own. The box never picks addresses out of other text — a pasted email also contains its sender and everyone it was copied to, and none of them should be removed. Listed subscribers are marked bounced at once (the reason records who did it); addresses that aren't subscribers are reported by name (the first 20, then a count of the rest), never added; submitting the same list again the same day changes nothing.
    • a whole bounce notice. It is read exactly as one arriving from the server would be, so the usual rules decide: a refusal that blames your sending setup removes nobody, a mailbox-full bounce waits for its third time, and the safety brake applies. The one difference: a pasted notice needs no private reference — you are vouching for it. That holds even when the server already passed the same notice on and only logged it, because it couldn't prove itself: your paste applies it, and the log keeps one entry for it, now marked as yours. A notice the server already acted on, or one you pasted before, is reported as already recorded and changes nothing.

    Afterwards you see how many were marked, how many were already bounced, which addresses aren't on your list, and — for a notice — what happened to the rest: kept for now when the problem may be temporary, such as a full mailbox (the address is removed if it bounces like that 3 times within 90 days); not removed when the receiving server refused the message because of how it was sent — as spam, or for failing an authentication check — which points to a problem on the sending side rather than a dead address, with a link to the Deliverability page; or held for review by the safety brake.

  • Needs review — while the safety brake has held anything back in the last 30 days, a yellow box at the top of the page says how many addresses bounced but were not removed, why that usually means a sending problem, and that automatic removal is paused until they are reviewed. Review them switches the list to just those people, each showing the bounce that is waiting ("Bounce held for review · date · kind of failure · the server's reason"). Then settle each one:

    • Keep on list (row menu) — the address is fine. It leaves the review at once, stays on the list, and its count of temporary bounces starts again from zero. Keep all on the list (in the yellow box, with a confirmation) does the same for everyone under review — the usual answer once you've found and fixed the sending problem.
    • Mark as bounced (row menu), or Mark all as bounced (in the yellow box, with a confirmation) — the address really is dead. It is removed, keeping each server's reason and adding who confirmed it.

    Automatic removal resumes once nobody is left under review (unless a new burst trips the brake again). Anyone you leave alone drops out of review after 30 days — still on the list — and the pause ends with them. Clearing someone's bounce, or the person re-subscribing or confirming, also takes them out of review. Settling the review takes a Manager or above.

In the campaign designer

The Campaigns list shows a campaign's bounced count beside its sent figure. The sent figure counts every message that actually went out — including to someone a bounce has removed from the list since, part-way through a long send or between an A/B test and the rest of the list. A sent email campaign's Performance card shows Delivered as messages sent minus those that bounced, with the bounced count under it (shown when mail goes through the server, or whenever a campaign has bounces). Open and click rates are worked out against delivered messages, since a bounced message can't be opened. Show the bounced addresses expands a list of who bounced, the kind of failure and the receiving server's reason — the first 100, with a count of the rest. In an A/B subject test, a bounced test send still counts as sent for its subject line.

On the Deliverability page

Running the checks adds a Bounce reports row. In Server email mode it shows one of three things:

  • Pass — a bounce report has arrived from the server, with how long ago the last one came (and which mail server the automatic setup was installed for, when it was).
  • Info — automatic bounce reports were set up on the server (with the date), but nothing has arrived yet. That is normal until a send fails.
  • Check — they are not set up and nothing has ever arrived. Admins see the exact command to run on the server (see "Server setup" below) with a copy button, and the name of the server it downloads the installer from; Managers are asked to pass this on to the site's administrator. A site with no license-server address (CMS_MEMBERSHIP_API_URL empty) has nowhere to download the installer from, so it shows no command and says why.

With a bulk provider, what the row says depends on whether bounce tracking with a bulk provider is switched on for the site (see below — it is off by default).

While it is switched off, the row is always Info: the provider tracks bounces in its own dashboard and stops sending to dead addresses there, and this site only records bounces for mail sent through the server. There is no button and nothing to turn on.

Once it is switched on, the row is about bounce tracking instead:

  • Check — bounce tracking is off (or is on for a different provider than the one sending, or is not working — see below), so the site isn't told about bounces or spam complaints. Admins get a button to Marketing Settings; Managers are asked to pass this on.
  • Info — bounce tracking is on (with the date) but no report has arrived yet. Normal until an email bounces or someone marks one as spam.
  • Pass — a report has arrived from the provider since tracking was turned on, with how long ago.

Bounces you add by hand don't count as evidence of either setup.

Bounce tracking with a bulk provider

Switched off by default. Bounce tracking with a bulk provider has not yet been verified against live provider accounts, so it is off unless a site turns it on. While it is off, Marketing → Settings shows no Bounce tracking box, the report address doesn't exist (every request to it gets "not found" and nothing is recorded), and the Deliverability row only notes that the provider keeps its own bounce list. To turn it on for one site, set MARKETING_PROVIDER_BOUNCE_TRACKING=true in that site's .env, then clear the config cache (php artisan config:clear, or php artisan config:cache if the site caches its config). Setting it back to false hides everything again; if tracking was on with a provider, turn it off in the box first, so the site asks the provider to remove its report address. Everything below describes the feature switched on.

When campaigns go through Postmark, Resend, Mailgun or SendGrid, the provider sees every bounce and spam complaint — but unless it tells the site, the dashboard can't, and addresses that no longer work stay "subscribed" here. Once a bulk provider with an API key is selected, Marketing → Settings → Sending Provider (Admins) shows a Bounce tracking box under the provider list.

Turn on bounce tracking asks the provider — using the API key the site already sends with — to report bounces and spam complaints back to the site at a private address. Nothing else about sending changes. The box then reads On since the date, and when the last report arrived (or that none has yet). Turn off (with a confirmation) makes the site stop accepting reports at once and asks the provider to remove the report address; everything already recorded stays. If the provider can't be reached, refuses the key, or the site can't be reached from the internet, the box says so in plain words and nothing is changed. Only one change happens at a time: if tracking is being switched in another window, the box says so and nothing changes. While tracking is being turned on, a report that arrives from Resend, Mailgun or SendGrid is asked to come back a minute later (they all retry), so none is lost; Postmark's own test of the address gets the answer it needs.

Not working. The settings the site needs to check a provider's reports are stored encrypted. If they can no longer be read — the site's encryption key was changed, or the stored settings were lost — the box shows Not working: every report is refused and nothing is recorded. Turn on again sets tracking up afresh with a new report address. The site can't name the old one to the provider any more, so it can't remove it: turning on again, or Turn off, says to delete the old webhook in the provider's own account.

What gets recorded:

  • Bounces follow exactly the rules above — the report has to prove itself, then: suppressed at once when the mailbox is dead, after 3 soft bounces in 90 days, and never for a refusal that blames your sending — and the mass-bounce safety brake applies the same way. The receiving server's own words and status code decide; the provider's name for the bounce ("hard", "blocked", "Permanent"…) is only used when those say nothing, so a provider calling a spam block a hard bounce still removes nobody (and SendGrid's "Unclassified" counts like any other unclear bounce). The bounce log names the provider as the source.
  • Spam complaints — the person marked a message as spam, which means "stop emailing me". When the complaint carries the private reference of the email it is about, sent to that address, they are unsubscribed, exactly as if they had clicked the unsubscribe link (not marked bounced), and the log shows a Spam complaint entry. A complaint without that proof is only logged: a provider account shared with another site or app reports complaints about their mail too, and none of that may unsubscribe your subscribers. A complaint never counts toward the safety brake or a soft-bounce count, and the send it belongs to keeps its status — the message was delivered. A complaint about an address that isn't a subscriber is logged, never added.
  • Every campaign and sequence email sent through a provider carries a private reference the provider echoes back, which is how a report finds its exact send — and how it proves itself. A report without one (an order or notification email, mail sent before this release, or another sender's mail on a shared account), or with a reference that belongs to a different address, is logged only: no send is marked and nobody is removed.
  • Deliveries, opens and clicks are not requested; the same report delivered twice is recorded once.

Switching provider. If tracking is on for one provider and another is selected, the box says so and offers Turn on for the new one. That registers the new report address first and then asks the old provider to remove its own; if the removal fails the switch still happens, and the box says to delete the old one in that provider's dashboard. If registering the new one fails, the old provider's tracking keeps working.

Requirements. The site must be live at its public https:// address (the site URL in its settings): a provider can't reach a local or development address, so the button refuses before contacting anyone. Per provider:

  • Postmark — the Server API token of the Postmark server your campaigns are sent from. The report address is added to that server's broadcast message stream (the one campaigns use). Postmark tests the address before saving it, so if the site can't be reached the box says Postmark couldn't check it. Postmark doesn't sign its reports; the site gives it a generated username and password, and each report must carry them.
  • Resend — an API key with Full access; a Sending-access key can only send email.
  • Mailgun — your account's main (primary) API key; a domain sending key can only send email. The site uses Mailgun's US region (as it does for sending). Mailgun allows up to 3 report addresses per event type on a domain, so remove an old one if it refuses.
  • SendGrid — an API key with Full Access. Reports are signed by SendGrid and checked before anything is recorded.

Not yet verified live. Bounce tracking for each provider was built from that provider's published documentation and tested with sample reports built from it. It has not yet been verified against live provider accounts.

Limitations. Bounces and complaints from before tracking was turned on are not fetched. Addresses a provider had already put on its own suppression list are learned only when a later send to them is refused and reported (SendGrid's "dropped" events are not requested). The Performance card shows bounce figures for a provider-sent campaign once it has a bounce.

Server setup

This part is for whoever runs the server. A site can't set this up by itself — it runs as an ordinary user and can't change the mail server — so it takes one command, run once as root on the server that sends the site's email. The Deliverability page shows it with the real download address and this site's path filled in; it looks like this:

sudo sh -c 'umask 077; d=$(mktemp -d) && curl --proto =https --proto-redir =https -fsSL https://<vendor server>/installer/bounce-setup.sh -o "$d/bounce-setup.sh" && bash "$d/bounce-setup.sh" --site /path/to/site; s=$?; rm -rf "$d"; exit $s'

It downloads the installer over HTTPS from the CMS vendor's own server — the same server the site already uses for its license and updates — into a new folder only root can read, runs that copy for this site, and deletes it again.

Why it is downloaded, not run from the site's folder. Everything in a site's folder can be rewritten by the site's own user — so by anyone who breaks into the site, and by design by any Admin who can run PHP code in it. If root ran an installer from there, or put a router from there into root's cron, whoever controls the site would control what root runs. So root only ever runs the downloaded copy, and the router it installs is carried inside that same downloaded file — never taken from a site. The installer also checks this itself: it refuses to run (and changes nothing) unless its own file, and every folder above it, can be changed by root alone, and says to use the download command instead — so the copy every site carries, run the old way (sudo bash /path/to/site/…/bounce-setup.sh), stops. That copy has no router attached either, so it couldn't install one. But a copy someone has already tampered with would simply leave those checks out, which is why the rule is to never run a site's copy at all. Before pasting the command, check that it downloads from the vendor's server: the dashboard names that server under the command.

What it changes on the server, in full:

  1. A mailbox user. It adds a system user called wpcmsbounce (no login shell, home /var/lib/wpcmsbounce) whose mailbox collects the server's failure notices. Skipped if the user already exists.
  2. One mail-server setting, so a copy of every failure notice the server produces goes to that mailbox. Senders still get their own failure notices as before.
    • sendmail: adds define(`confCOPY_ERRORS_TO', `wpcmsbounce')dnl to /etc/mail/sendmail.mc, rebuilds sendmail.cf, and restarts sendmail. Before touching anything it builds a trial sendmail.cf and refuses unless the trial differs from the running one by exactly one line (O PostmasterCopy=wpcmsbounce) — if your running config wasn't built from today's sendmail.mc, rebuilding it would change more than this, so it stops instead.
    • Postfix: adds bounce to notify_classes and sets bounce_notice_recipient (the old values are saved for --uninstall), runs postfix check, then postfix reload. The copies go to wpcmsbounce — or to wpcmsbounce@localhost when Postfix would complete the bare name with a domain this server doesn't deliver locally and localhost is one of its local domains (common on a web server whose mail name is the public site name). If neither stays on the server, it refuses. Two side effects of Postfix's bounce class to know about: Postfix also copies the transcripts of SMTP sessions it rejects to that mailbox (the job discards them), and it implies 2bounce, so mail that can't even be returned to its sender goes to 2bounce_notice_recipient (normally postmaster).
  3. A 5-minute job. /usr/local/sbin/bounce-router, its settings in /etc/bounce-router/ (config, plus installs, the list of sites it serves), and /etc/cron.d/bounce-router, which runs it every 5 minutes as root, as python3 -I (so it imports nothing from the folder it runs in or from the environment). Its second line names the release it came from.

What the job does with each notice. It works out which system user sent the original message, reading only the returned copy of that message's headers inside the notice, and in them only the lines the mail server writes above the message's own headers (never the notice's explanation or any message body). It finds that user's site and hands the notice to it with php artisan marketing:record-bounce, run as that user — never as root. A user on the server who connects to the mail server directly can write lookalike lines into their own message, so these lines alone prove little; that is why the site acts on a server notice only when it carries the private send reference of a message it sent to that same recipient (see "A notice has to prove itself" above). When one system user runs several sites, each email the site sends through the server names the site it came from, and that decides between that user's own sites only.

  • Dropped (and logged): a notice that belongs to no site (system mail, other programs), one that names no submitting user or names root, and one larger than 512 KB.
  • The site's answer decides the rest. Exit status 0 — done, removed. 75 ("try again later") — kept and offered again on every run for up to 24 hours, then dropped. Any other status, or a command still running after 60 seconds — logged and dropped.
  • No site can hold up another. A run starts nothing new after 3 minutes, and gives no single user more than half of that; whatever is left waits for the next run.
  • Faults — runuser or php missing, or a mailbox it isn't safe to touch — go to syslog under bounce-router and to standard error, so cron mails them to root. To see what it would do without doing it: sudo python3 -I /usr/local/sbin/bounce-router --dry-run --verbose.

It never opens a network port, never touches DNS, and never changes how mail is sent or signed. Every mail-server file it edits is backed up first (as <file>.bak-bounce-<date-time>), each step is checked, and if a step fails everything it changed is put back. If it is interrupted (Ctrl-C, a closed session), it also puts back everything it changed, and says so. It refuses — and changes nothing — when the setup isn't one it understands: an alias already pointing wpcmsbounce somewhere, failure notices already copied elsewhere, Postfix handing local mail to another program (mailbox_command / mailbox_transport), the copies not being deliverable on this server, a Maildir already there (or its new/, cur/ or tmp/) that is a symbolic link or isn't owned by wpcmsbounce, or a mail server that isn't running (it never starts services). Each site is told when the setup is done, which is what the Deliverability page reads.

Supported mail servers: sendmail and Postfix. Exim (including cPanel servers) and others are not supported yet — the installer says what it found and exits without changing anything. The Postfix path has been tested with simulated commands and sample notices, not yet on a live Postfix server. The sendmail steps follow a sequence first proven by hand on a live sendmail server; the installer that automates them has itself been run only against simulated commands so far.

Naming the sites. --site <path> names a site — the folder holding its artisan file, owned by the user the site runs as (not root). It must be an absolute path; the installer checks each one and refuses, changing nothing, if it isn't a site. Several sites on one server:

  • give --site more than once, or run each site's own command (each run adds that site to the list and changes nothing else that's already set up); or
  • tell the job where the sites are with --discover (repeatable): --discover '/home/*/webapps/*' serves every folder matching the pattern that holds a site, owned by the user who sent the mail.

On a server that isn't set up yet, a run with neither --site nor --discover is an error. Once it is set up, a run with neither updates the router and settings for the sites it already serves — do that after a CMS update when --status says the router differs.

Options. Every option runs the same way — the download command with that option where the dashboard's command has --site <path>:

sudo sh -c 'umask 077; d=$(mktemp -d) && curl --proto =https --proto-redir =https -fsSL https://<vendor server>/installer/bounce-setup.sh -o "$d/bounce-setup.sh" && bash "$d/bounce-setup.sh" --status; s=$?; rm -rf "$d"; exit $s'
  • --status — shows the installer's release, the mail server, whether the copy setting is on, the mailbox and how many notices are waiting, whether the job and router are installed (and the router's release, and whether it differs from the downloaded one), and which sites and patterns it serves. Changes nothing.
  • --dry-run — prints every change it would make, and changes nothing. Works with --uninstall too.
  • --uninstall — removes the job, the router and /etc/bounce-router/, puts the mail-server setting back (sendmail: removes the line, rebuilds, restarts; Postfix: restores the saved values, checks, reloads) and tells each site. It covers the whole server, so it takes no --site. The wpcmsbounce user and its mailbox stay; remove them with userdel wpcmsbounce if you want.

If you would rather keep a copy, download it to a folder only root can change and run that: sudo sh -c 'umask 077; curl --proto =https --proto-redir =https -fsSL https://<vendor server>/installer/bounce-setup.sh -o /root/bounce-setup.sh', then sudo bash /root/bounce-setup.sh --status. The installer refuses to run from anywhere a non-root user could change it. Download a fresh copy after a CMS update, since the router it carries is the one that gets installed.

Exit codes: 0 done, 1 failed (and rolled back), 2 not supported or refused (nothing changed). The job logs what it did with each notice to syslog under bounce-router (journalctl -t bounce-router).

What it can't see. Only notices the server itself generates arrive this way. A "late" bounce — a remote server first accepts the message and refuses it later — goes to the From address's mailbox instead and never reaches the server; add those on the Subscribers page by hand. And it can only place notices for mail the site handed to the server's own sendmail command (the site's mail driver set to sendmail): mail handed over by SMTP to localhost carries no record of which user sent it, so its notices are dropped. The job runs every site's command with one PHP binary — the php found when setup ran, saved as PHP= in /etc/bounce-router/config — so a server whose default php is too old for the CMS needs that line pointed at the right one.

Other mail servers: the manual contract

On a mail server the script doesn't support, arrange for each delivery-failure notice to be piped, raw, into the site that sent the original message:

php artisan marketing:record-bounce          # notice on standard input
php artisan marketing:record-bounce --file=notice.eml
  • Input: one standard delivery-status notice (RFC 3464, as sendmail and Postfix generate them). Only the first 512 KB is read; the delivery details come first, so a huge returned original doesn't matter.
  • Output: one line per failed recipient — address category status outcome, where category is hard, soft, policy or unknown, status is the code (or -), and outcome is suppressed, held (the safety brake held it back for review), logged (recorded without removing anyone — a policy refusal, a soft bounce below its threshold, or a notice that doesn't carry the site's private reference for that address) or duplicate. Anything that isn't a failure notice (a delay warning, an ordinary email) prints a single "Ignored" line.
  • Exit code: 0 the notice was read — recorded, logged, a duplicate, or ignored; 1 empty or unreadable input (there is nothing to retry); 75 try again later — recording failed on the site's side (the database was unreachable, say), and the command prints one line saying so. On 75 keep the notice and pipe it again later; any recipients it had already recorded come back as duplicate. With the Marketing feature switched off the command prints a notice, records nothing, and exits 0.

Run it as the site's own user, from the site's directory. Only feed it notices your own mail server produced. A notice only removes someone when it carries the site's private reference for that address (see "A notice has to prove itself" above), so a forged one can't — but it is still logged. A hand-wired setup doesn't tell the site it exists, so the Deliverability row keeps showing "not set up" until the first bounce arrives; php artisan marketing:bounce-hook installed records it by hand (removed clears it).

Open & click tracking

Each can be toggled under Marketing → Settings → Open & Click Tracking. Opens are on by default; both click switches are off by default (link rewriting changes the visible link to a redirect, which security/spam filters scrutinize, so it's opt-in for deliverability).

  • Opens — an invisible 1×1 image per recipient. First load marks the recipient as opened and bumps the campaign's unique-opens count. Email only — a text and a notification have nowhere to put a pixel, so neither reports opens, ever.
  • Clicks — links are rewritten through a short redirect on your own domain. The destination is stored server-side (nothing user-controlled in the URL, so the redirect can't be abused), and each recipient's first click counts once toward unique clicks; per-link totals accumulate on every hit.
  • Clicks in text messages — a separate switch, because SMS pays for tracking by the character. See below.

Turning on Track clicks in text messages rewrites every http(s) link in an SMS campaign to a short per-recipient redirect on your own domain (/r/{code}/{link}) — never a third-party shortener, which carriers filter far harder than a link to the site the message is about. Texts then report unique clicks, per-link totals, and top links exactly like emails, and SMS clicks feed Analytics attribution with utm_source=sms.

The cost is length. A tracked link runs about 30 characters, and carriers bill per 160-character segment, so a rewrite can push a message into another segment. The composer's character/segment counter already accounts for this — it measures the message as it will actually be sent, not as typed — so watch that number rather than guessing.

Details worth knowing:

  • Trailing punctuation stays out of the link. "Book now: https://example.com/book." tracks …/book, not …/book..
  • {{review_link}} is never rewritten. The review funnel does its own per-recipient tracking; wrapping it would break that.
  • No opens. An SMS click is recorded as a click only — unlike email, it does not imply an open, because a campaign that can't measure opens shouldn't report an open rate.
  • Existing campaigns aren't retroactively changed. Tracking applies at send time, so a text already sent keeps whatever links it went out with.

Sent campaigns show a Performance card in the designer — delivered count, open rate, click rate, send time, and a top-links table — and the campaigns index shows opens/clicks per row. A text campaign shows its click numbers only when it was sent with link tracking on; without it the card stays a delivered/failed count, as before.

What's deliberately not tracked: the unsubscribe link (always a direct URL, so one-click unsubscribe never routes through tracking), and test sends/previews. Tracking hits are also invisible to the site's own Analytics feature.

Bot filtering. Security gateways like Outlook SafeLinks fetch every link in an email at delivery time to scan it, and phone messaging apps fetch a link preview the moment a text arrives — on a raw counter both would register as a click (and, for email, an implied open) the recipient never made, so a corporate-heavy list would show implausibly high click rates within minutes of sending. Tracking hits are therefore filtered: a hit is ignored when it lands within about ten seconds of the send (the delivery-scan window — a real click needs the message opened and read first), when it's a bare HEAD probe, or when its user agent identifies a known scanner, crawler, HTTP library, or link-preview bot. The recipient is never affected — the redirect (or pixel) is served either way; only the counting is skipped, and a skipped open is still recorded by any later one. The filter is heuristic by nature: a gateway that scans minutes after delivery with a stock browser user agent still slips through, so corporate-heavy lists can still read slightly high — true of every email platform.

Caveat to set expectations: image-blocking mail apps undercount opens, and Apple Mail privacy protection prefetches pixels regardless of whether the message is read, which overcounts them. This is true of every email platform.

SMS campaigns

The New Campaign button offers Email campaign or Text message (SMS). SMS campaigns share the whole pipeline — audiences, scheduling, the queued send job with its per-recipient delivery log, test sends — but compose differently:

  • Plain-text composer with a live phone-style preview, a character/segment counter (carriers bill per 160-character GSM segment, 70 for unicode), and {{name}} personalization.
  • Attach an image to send as MMS — pick from the media library and the campaign goes out as a picture message (Twilio fetches the image from your site). The composer flags the trade-offs: MMS costs roughly 2–4× per message and is best supported on US/Canadian numbers.
  • "Reply STOP to opt out." is appended automatically unless the message already mentions STOP — carrier rules require opt-out language in marketing texts.
  • The Subject field doubles as an internal campaign name (it never appears in the text).

Provider setup

SMS needs a texting gateway — unlike email there is no server fallback. Configure it under Marketing → Settings → Text Messages (SMS):

  1. Pick Twilio and paste the Account SID, auth token, and a From value (an E.164 phone number you rent from Twilio, or a Messaging Service SID starting with MG).
  2. US senders: register an A2P 10DLC brand + campaign in the Twilio console first — carriers won't reliably deliver unregistered marketing traffic. This is form-filling on Twilio's side, not configuration here.
  3. Point the Twilio number's "A message comes in" webhook at POST /marketing/sms/webhook so STOP/START replies update the opt-out list. Requests are verified with the X-Twilio-Signature header.
  4. Optional: turn on Track clicks in text messages under Open & Click Tracking to measure link clicks in texts (see Link tracking in text messages).

Texting requires its own consent (TCPA in the US) — having someone's email permission does not allow texting them:

  • The All subscribers audience only includes people with a phone number and a recorded SMS opt-in (the checkbox when adding a subscriber, or the per-row "Record SMS opt-in" action).
  • CRM-targeted audiences need recorded SMS consent too. A CRM group, status or view (or "Subscribers + all CRM contacts") narrows which people with a recorded SMS opt-in get the text — it never stands in for consent. A contact whose phone has no SMS opt-in on file is left out, and the audience count shows only the people who will actually be texted. (Email is different: CRM targeting is allowed for email under CAN-SPAM's opt-out model, with the unsubscribe link on every message.)
  • Opt-outs always stick. A STOP reply (or dashboard opt-out) suppresses that phone number from every future SMS audience, no matter how it was targeted. STOP suppression is phone-keyed (marketing_sms_optouts), so it covers CRM contacts who never had a subscriber row. START (or UNSTOP / YES) undoes a STOP for someone who had opted in before — it is never a first opt-in, so a "yes" in an ordinary text conversation signs nobody up.
  • Twilio also blocks STOPped numbers at the carrier level; the webhook keeps your dashboard in agreement with that.

Web Push notifications

Marketing → Settings → Web Push Notifications turns on the third channel. Enabling it mints the install's own VAPID key pair (the cryptographic sender identity browsers verify) — there is no push provider account, no API key, and no per-message cost; notifications go straight from your server to each browser's push service, end-to-end encrypted per subscriber (RFC 8291).

  • How visitors subscribe: a small, dismissable prompt appears on the public site (title, message, and delay are configurable). Accepting triggers the browser's native permission dialog; granted browsers are stored as anonymous subscriptions. Dismissals re-offer after 30 days; the prompt never renders for browsers that already decided. Push requires HTTPS.
  • Composing: New Campaign → Push notification gives a title, a short message, and the page to open on click — with a live notification mock as the preview. The site logo is the icon automatically.
  • Sending & stats: push campaigns send to every subscribed browser (subscriptions are anonymous, so audience targeting doesn't apply). Delivered/failed counts work like the other channels; with click tracking on, the notification's link routes through the standard campaign click redirect, so clicks land in the same stats and pick up auto-UTM attribution. There is no open tracking for notifications. Expired subscriptions (uninstalled browsers, revoked permission) are pruned automatically on send.
  • Testing: the designer's Send test to this browser subscribes your own browser and delivers the draft to it for real.

Deliverability wizard & spam score

Dashboard → Marketing → Deliverability verifies the DNS records mailbox providers use to trust your mail, checked live against the domain of your from-address:

  • SPF — record present, single (multiple v=spf1 records are invalid), and containing your bulk provider's include (Postmark/Mailgun/SendGrid/Resend hints built in). Missing records get a copy-ready suggestion.
  • DKIM — probes your provider's usual selectors plus common ones; a miss is a warning (selectors are account-specific), with a pointer to verify in the provider dashboard.
  • DMARC — presence + policy readout; missing gets a starter p=none record to copy.
  • MX and a freemail check (sending bulk mail "from" gmail.com et al. fails DMARC at the big receivers).
  • Bounce reports — not a DNS record: whether the server's delivery-failure notices are reaching the site (see "Bounces").

Content-side, every email campaign has a Check spam score button in the designer: a local, deterministic scan for the classic filter signals — ALL-CAPS/exclamation subjects, trigger phrases ("act now", "100% free", …), link shorteners, raw-IP links, image-only bodies, "click here" anchors, a missing postal address, freemail senders. Issues come back as a rated list (Excellent → Poor) of concrete fixes. Nothing leaves the server; no third-party scoring service is involved.

Automatic blog digest

Marketing → Settings → Automatic Blog Digest assembles a recurring "what we published" email with zero composing:

  • Pick the content type (blog by default — any content type works), cadence (weekly / every two weeks / monthly), day + hour, audience, and how many posts per edition (max 10).
  • Each edition includes the posts published since the previous edition — featured image, title, excerpt, and a "Read more" button per post, with your logo and a configurable heading/intro on top. The subject line is a template with {site}, {count}, {first_title}, and {date} tokens.
  • Delivery mode: Send automatically, or Save a draft for review — drafts appear in the campaigns list (badged Digest) ready to tweak and send by hand.
  • A cycle with nothing new is skipped — no empty email — and those posts roll into the next edition. Enabling the feature never fires immediately; the first edition waits for the next scheduled slot. Auto-UTM is on, so digest visits attribute in Analytics.

Sequences (automated drips)

Dashboard → Marketing → Sequences. A sequence is an ordered series of delayed emails and texts — "day 0 welcome, day 3 tips, day 7 offer" — that sends automatically to everyone who enrolls, one person at a time on their own clock. Broadcasts answer "send this to everyone now"; sequences answer "send these, spaced out, to each new person from the moment they arrive."

Building one

Create a sequence (name + who enters it) — or pick Start from: Template: Review follow-up in the create modal to get the classic post-job flow prebuilt as a draft: day-1 "does everything look good?" check-in, day-4 thank-you with a Leave a review button ({{review_link}}), day-8 nudge that only sends to people who haven't left a review yet. Then add or tweak steps. Each step is a wait (minutes / hours / days — the first step's wait counts from enrollment, later ones from the previous step) plus a message. Edit content opens the step in the same designer campaigns use — same blocks, live preview, personalization tokens, and Send a Test. Steps reorder with up/down arrows and can be mixed email/SMS. A sequence is draft until you Activate it, and can be paused any time — paused sequences stop enrolling and stop sending; enrollments resume where they left off when reactivated.

Under the hood each step's content is a hidden campaign record, which is why the designer, per-recipient delivery log, and open/click tracking all work identically. Step campaigns never appear in the campaigns list and can't be blast-sent.

Who enters it, and when

  • When someone joins the mailing list — new confirmed subscribers from the public signup form or a dashboard add are enrolled the moment they join (after email confirmation when double opt-in is on). CSV imports and rows auto-created by campaign sends never trigger a drip.
  • CRM automations — the CRM's rule engine (CRM Settings → Automations) has an Enroll in a marketing sequence action that composes with every CRM trigger: contact captured (per source), status changed, deal stage changed, deal won, inactive N days, course enrolled/completed, and the five commerce moments — store order placed, donation received, invoice paid, appointment booked, event tickets bought. "Lead captured from the real-estate form → start the buyer nurture drip" is one rule; "bought tickets for the gala → run the pre-event logistics drip" is another. See CRM → Automations for the trigger list and how each one is scoped.
  • By hand — the sequence editor has an enroll-by-email box, and every CRM contact page has an Enroll in Sequence button.
  • Project Tracking — completed projects enroll their client in a configurable completion sequence (Projects → Settings default, per-project override). See project-tracking.md.

Enrollment is once per person per sequence, forever — completed or exited enrollments block re-entry, so a re-captured lead is never welcomed twice. Only active sequences enroll, and activation is not retroactive (people already on the list don't get back-filled into a new sequence).

Branching on engagement

Every step after the first can carry a send condition: always send (default), or only if the person opened / didn't open / clicked / didn't click the previous email. Conditions are evaluated when the step comes due — so the step's own wait time is the decision window ("wait 2 days, then: if they opened…"). A step whose condition isn't met is skipped and the sequence continues, which makes two-path branching a matter of pairing steps: step 3 "only if opened" + step 4 "only if didn't open" sends different content down each path, and both paths rejoin at step 5.

"The previous message" means the most recent unconditional ("always send") step before the conditional one — conditional steps never anchor each other, so a branch pair both measure the same main-line message. Which steps count depends on what you're measuring: open conditions look only at email steps (texts and notifications have no opens), while click conditions also accept a text step, provided that text was sent with link tracking on. An untracked text in between is ignored rather than read as "didn't click", so turning SMS link tracking on later never silently rewires a sequence that's already running. If no eligible earlier step exists — or it was never sent to that person — the "didn't open/click" conditions pass and the positive ones skip.

With the Reviews feature on, two more conditions appear: only if they left a review or gave feedback and only if they haven't left a review yet. These don't look at email clicks at all — they check the person's review requests (the {{review_link}} funnel) since enrollment: rating privately, submitting a first-party review, or clicking through to Google/Yelp/Facebook all count as "responded". That's what makes the Review follow-up template's nudge step precise.

  • An email step for someone who has unsubscribed exits the enrollment immediately — no send, drip over.
  • An email step for an address suppressed for bouncing is skipped and the drip continues — the mailbox is dead, but later text steps can still reach the person (see "Bounces").
  • An SMS step without SMS consent is skipped and the drip continues with the next step (SMS consent is a separate grant; its absence shouldn't cancel someone's emails). Everyone needs a recorded SMS opt-in on the phone being texted — a CRM contact enrolled by an automation included; STOP suppression always sticks for everyone.
  • Every sequence email carries the same automatic compliance footer, unsubscribe link, and one-click unsubscribe headers as a broadcast; every text carries the STOP notice.

Delivery mechanics

Due steps send on the same no-cron background schedule as scheduled campaigns (LazyCron, 60-second cadence, lock-guarded, capped per pass so big backlogs spread across ticks). A text step only goes out between 8 AM and 9 PM in the site's timezone (Settings → Business); one that comes due outside those hours waits for 8 AM rather than texting someone at night, while email steps send whenever they come due. Sends are resume-safe — an interrupted pass never double-sends a step. A failed send is recorded on the step and the enrollment still advances, so one bouncing address can't wedge a drip. The sequence editor shows active/completed/exited counts and recent enrollments; each step's designer shows its own delivered/opens/clicks Performance card.

Privacy & data

  • The list lives in your database; nothing is shared with third parties unless you choose a bulk sending provider (which then processes the emails it sends, like any SMTP relay would).
  • Unsubscribe is honored three ways: the footer link (with a resubscribe option if they change their mind), RFC 8058 one-click unsubscribe from the mail client's native button, and admin action in the dashboard. The same link and headers are on the abandoned-cart, loyalty and CRM automation emails, and the unsubscribe routes stay up with the feature switched off.
  • Consent proof (marketing_subscribers.consent) holds the opt-in's IP address and wording for as long as the subscriber row exists — it is the evidence the site needs if a regulator, carrier or complaint asks.
  • Tracking can be disabled entirely for a no-tracking newsletter program.

Technical notes (for developers)

  • Module: app/Features/Marketing/ (key marketing, default ON, gated by the standard feature middleware). Campaigns carry a channel of email, sms, or push, and an origin of null (dashboard) or digest (auto-assembled).
  • A/B subject tests: state machine in Jobs/SendCampaignJob.php (ab_* columns on campaigns, ab_variant on campaign_sends); the campaign stays sending between phases and the marketing:send-scheduled LazyCron tick advances it. Winner = unique opens, clicks tiebreak, dead heat → A; audiences < AB_MIN_RECIPIENTS (10) skip the test.
  • Send-time recommendation: Support/SendTimeOptimizer.php — PHP-side histogram of campaign_sends.opened_at (last 90 days, ≥30 opens) so it runs identically on MySQL and sqlite.
  • Web Push: Support/WebPushCrypto.php (pure-PHP RFC 8291 aes128gcm + RFC 8292 VAPID on ext-openssl, unit-tested against the RFC's Appendix A vector), Support/WebPushSender.php (delivery + 404/410 pruning), Models/PushSubscription.php (marketing_push_subscriptions). VAPID keys live in marketing.vapid_public/private Settings; enabling mints them once and never rotates silently. Public pieces: partials/push-prompt.blade.php + resources/js/push.js (conditionally included in layouts/public.blade.php, cached-page-safe — all per-visitor state is client-side), the static service worker public/push-sw.js, and CSRF-exempt POST /push/subscribe|unsubscribe. New-browser subscriptions record the push_subscriber Analytics goal.
  • Sequence branching: send_condition on marketing_sequence_steps (opened_previous, not_opened_previous, clicked_previous, not_clicked_previous), evaluated in Support/SequenceRunner.php against the previous email step's campaign_sends row at due time; unmet ⇒ advance without sending.
  • Deliverability: Support/DeliverabilityChecker.php (DNS via overridable txtRecords()/mxRecords() seams for tests) behind dashboard/marketing/deliverability; content scan Support/SpamScoreAnalyzer.php (local heuristics, no external service).
  • Blog digest: Support/BlogDigestBuilder.php + marketing:send-digest (LazyCron, 900s). Cycle clock in marketing.digest_last_run_at; the posts window is anchored to the previous digest campaign's created_at (origin digest), so skipped cycles never drop posts. Assembles standard EmailBlocks and sends through the ordinary SendCampaignJob.
  • Blocks registry: Support/EmailBlocks.php; HTML compiler: Support/EmailRenderer.php (600px inline-styled tables; brand color from branding.colors.primary).
  • Style: Support/EmailStyle.php — defaults() reproduces the pre-style output value for value; normalize() is the only path into inline styles (hex-only colours, clamped ints, enum fallbacks); forStorage() keeps only keys that differ from the defaults, so campaigns.style / campaign_templates.style are null for the default look. describe() is the schema the AI designer is shown — add a key there when adding one to defaults(). Button blocks' color / text_color of '' follow the style (the 2026-10-03 migration unpinned the old default #ffffff labels on editable campaigns and templates).
  • HTML mode: campaigns.content_mode (blocks | html) + campaigns.html; Campaign::isHtmlMode() is false when the document is empty, so an email can never send blank. EmailRenderer::editableHtml() is the design as a document with the {{footer}} token and no preheader; render() in HTML mode runs Support/EmailHtmlSanitizer.php (string pass, keeps Outlook conditional comments), puts the compliance footer at the token (or before </body>, or appended), and injects the preheader after <body>. The HTML is kept when switching back to Design, for the restore offer.
  • AI designer: Support/EmailAiDesigner.php (prompts, answer parsing and clean-up, apply with a one-step undo) run by the email-ai-design task (Support/Tasks/EmailAiDesignTask.php, one live turn per campaign via idempotencyKey(), not pumpable, 600 s stale window, re-run-safe by finding its own task id in the conversation). Lane: AiTextService::TASK_CODE, vision through generateVision() when a screenshot is attached. Conversation + undo point live in campaigns.ai_session (messages, previous); duplicating a campaign leaves it behind. Screenshots wait on the private local disk under marketing-ai/ and are deleted when the turn ends. The designer stops saving design fields while aiTaskId is set and re-reads the campaign in hydrate() once the turn ends, so its stale copy can never overwrite the result.
  • Audience resolution + suppression: Support/MarketingAudience.php. Audience strings: subscribers, all, crm_group:{id}, crm_status:{id}, crm_view:{userId}:{id}. SMS campaigns resolve phone-keyed recipients (smsActive subscribers / CRM contacts whose phone matches an smsActive subscriber) with phone-keyed suppression.
  • Email providers: Support/CampaignMailer.php — Setting-first credentials (mail.postmark_token, mail.resend_token, mail.mailgun_token + mail.mailgun_domain, mail.sendgrid_token) with config/services.php fallback. send(..., sendReference:) stamps an X-WPCMS-Send header (the campaign_sends.token) on Server email only; SendCampaignJob and SequenceRunner pass it. Never add Precedence: bulk — sendmail withholds postmaster copies of bounces for negative-precedence mail.
  • Bounces: marketing:record-bounce (Console/RecordBounceCommand.php, stdin or --file, 512 KB cap) → Support/Bounces/DsnParser.php (pure RFC 3464 reader: message/delivery-status first, sendmail's "permanent fatal errors" text as fallback; one ParsedBounce per Action: failed recipient; Original-Recipient is xtext-decoded (+2B → +), and a recipient longer than 254 bytes, not valid UTF-8 or not an email address yields no bounce; generic X.0.0 statuses refined from the diagnostic; readableReason() is the one formatter of a reason for people — status · diagnostic minus SMTP reply-code prefixes, sendmail's <address>... echo and a leading SMTP; — used for bounce_reason, campaign_sends.error and MarketingBounce::readableReason(), while marketing_bounces.diagnostic stays raw) → Support/Bounces/BounceClassifier.php (hard / soft / policy / unknown; a leading smtp; is dropped before the reply class is read; policy evidence — any x.7.x, 5.1.7/5.1.8/5.3.4, sender-blaming wording, or a refusal of the sender address (SENDER_WORDING: sender verify / "Verification failed for", "Sender address rejected", sender … does not exist / unknown / rejected / denied, "Domain of sender address", "in reply to MAIL FROM", "your IP") — beats every other signal, and soft beats hard; 5.1.0 is not in HARD_CODES — hard only with dead-mailbox wording; domain-level failures — DOMAIN_CODES 5.1.2 / 5.4.4 and host-unknown / domain-not-found wording — are soft, never hard) → Support/Bounces/BounceRecorder.php (one transaction: marketing_bounces row keyed by a unique fingerprint() — original Message-ID, else the send reference, + recipient + status when the notice returned the original headers, else the notice's own Message-Id / digest — so two copies of one failure are one row. Proof rule: provenSend() = the email send (phone null) whose token is the reference AND whose email is the recipient; only then send sent → bounced + bounced_at and subscriber bounced_at / bounce_reason (or held) on hard or the 3rd soft/unknown; with no proven send the row is log-only — suppressed/held false, campaign_send_id null — except source manual, which is exempt. No address fallback. suppresses() counts soft/unknown rows in SOFT_WINDOW_DAYS that are proven (campaign_send_id set) or manual, with an id above the address's latest suppressed row (any source) AND, when set, a created_at strictly after the subscriber's marketing_subscribers.bounce_cleared_at (stamped by keepOnList(), clearBounce(), resubscribe(), confirm()), so a vouch restarts it — also for an address that was only ever held; SOFT_BOUNCE_THRESHOLD, SOFT_WINDOW_DAYS constants. A manual record whose fingerprint already has a row that isLogOnly() — automatic source, campaign_send_id null, not suppressed, not held, not a complaint — re-reads that row under lockForUpdate() and turns it into the manual entry in place (rules applied as for a first paste, source = manual, created_at = now, returned with wasRecentlyCreated = true) instead of returning it as a duplicate; any other existing row is returned untouched, so a repeated paste and the two copies of a proven notice stay one event). RecordBounceCommand exits 0 read / 1 empty or unreadable / 75 (EXIT_TRY_AGAIN) on any Throwable while recording, after report() and one line. Mass-bounce breaker in the same class: breakerTripped() compares recentSuppressionCount() (rows with suppressed = true and source != manual in the last MASS_BOUNCE_WINDOW_HOURS = 24) against massBounceLimit() = min(MASS_BOUNCE_CEILING = 250, max(MASS_BOUNCE_FLOOR = 25, ceil(MASS_BOUNCE_PERCENT = 10% × (active subscribers + that count)))) — integer ceiling, no float; store() asks suppressionPaused() = breakerTripped() || awaitingReview() (the review set is not empty), so once anything is held the pause outlives the 24-hour window until the set empties; while paused a would-suppress bounce is logged with held = true instead. MarketingSubscriber::needsBounceReview() = not bounced, not unsubscribed, with a held bounce in the last REVIEW_WINDOW_DAYS = 30; keepOnList() (stamps bounce_cleared_at, nothing else) / clearBounce() / resubscribe() / confirm() release held rows. Subscribers page: keepOnList($id) (row menu on a held row) and keepReviewSetOnList() (callout action, showKeepReviewModal confirmation, chunkById over the review set) each abort_unless Manager+ in the method itself. Hand entry: Support/Bounces/ManualBounceEntry.php (apply() = DSN through BounceRecorder with SOURCE_MANUAL, else a strict all-or-nothing address list; a notice's kept recipients split into policy and kept; not_on_list_emails names up to NOT_ON_LIST_LIMIT; markAddresses() = suppress + one manual hard log row per address, fingerprint = address + UTC day, breaker bypassed, and a subscriber with a held bounce in the review window gets "Confirmed by :name · " + that bounce's readable reason instead of "Marked as bounced by :name") — used by the Subscribers page's paste box, row action and review bulk action. Performance card: bouncedCount / deliveredCount / bouncedRecipients computed properties on ⚡edit.blade.php (eager-loads CampaignSend::bounces()); abStats() counts sent and bounced alike. SendCampaignJob sets sent_count from the campaign's sends in CampaignSend::DONE_STATUSES (sent + bounced) after every pass and A/B phase, and recipients_count = the audience as it resolves now + done sends outside it, so a recipient suppressed between passes is neither dropped from sent nor subtracted twice from Delivered. Deliverability: DeliverabilityChecker::bounceReportCheck() (state = receiving / installed / not_set_up in Server email mode; provider_off (warn) / provider_waiting (info) / provider_receiving (pass) under a bulk provider, from ProviderBounceTracking::status()); the component's bounceSetupCommand() returns the root command only for Admin+ and only in not_set_up, and canManageBounceTracking() gates the Marketing Settings button in provider_off. Server pipeline: resources/server/bounce-setup.sh (bash 3.2+, root) and resources/server/bounce-router.py (Python 3.6+, stdlib only, run by cron as python3 -I; /etc/bounce-router/config KEY=VALUE — MAILBOX, FORMAT mbox|maildir, MAILBOX_USER, MTA, repeatable DISCOVER, PHP, RUNUSER — and /etc/bounce-router/installs, user<TAB>path<TAB>host). Both live in the module because scripts/ never ships (PackageCmsReleaseCommand::NEVER_SHIP_TREES) and the license server serves them from its own copy. Root never runs either from a site's tree: GET /installer/bounce-setup.sh (marketing.bounce-installer, Http/Controllers/BounceInstallerController.php, registered in the module's routes/web.php outside the web group and the feature:marketing gate, so no session or cookie; 404 unless Membership::isLicenseServer(); text/x-shellscript; charset=utf-8, nosniff, no-store) returns Support/Bounces/BounceInstaller::build() — the setup script with INSTALLER_VERSION (the VERSION file, via UpdateChecker::currentVersion()) and DOWNLOAD_URL stamped into their ="" lines and a header comment, then after its final exit 0 a payload section: #### WEBPROCMS BOUNCE ROUTER PAYLOAD sha256=<hex> ####, the router verbatim, #### END WEBPROCMS BOUNCE ROUTER PAYLOAD #### as the last line. The script's load_router() extracts it from its own file (exactly one begin marker, end marker last, SHA-256 match, must compile(); refused as missing / damaged, exit 2) and installs it with a version comment as line 2. Before anything else, outside BOUNCE_ROOT it requires root and runs trust on its own file: owned by uid 0, not group/world-writable, every ancestor owned by root and not group/world-writable unless sticky (the payload is then read from a descriptor whose owner and mode are checked again). Sites are named with --site <abs path> (repeatable; validated as a non-root-owned folder + artisan of one owner) and/or --discover; with neither, it refuses on a server with no registry and no saved DISCOVER, else updates. Messages that tell the operator to run it again print run_command — the same download command BounceInstaller::rootCommand() builds (sh_quote / single_quote mirror shellWord() / singleQuoted()). The dashboard's command is BouncePipeline::setupCommand() = rootCommand(['--site', base_path()]), origin from BounceInstaller::origin() (cms.membership_api_url's scheme/host/port; null — no command — when empty or not plain http(s)). Test hooks, each refused (exit 2) unless BOUNCE_ROOT is set: BOUNCE_ROOT (prefixes every system path, skips the root and owner checks), BOUNCE_TEST_ROUTER (the router to install instead of the payload), BOUNCE_TEST_OWNER_UID (runs the owner check with that uid standing in for root). The setup reports back through marketing:bounce-hook installed|removed [--mta=sendmail|postfix] (Console/BounceHookCommand.php) into the marketing.bounce_pipeline Setting, read through Support/Bounces/BouncePipeline.php. Server email stamps X-WPCMS-Site: <APP_URL host> (CampaignMailer::siteHost()), which the router uses only to choose among one submitting user's own installs. API: MarketingSubscriberResource reports bounced (precedence unsubscribed > bounced > pending > active) with bounced_at, and ?status=bounced filters on it. Model Models/MarketingBounce.php (categoryLabels(), SOURCE_SERVER / SOURCE_MANUAL, RETENTION_MONTHS = 12). Retention: marketing:prune-bounces (Console/PruneBouncesCommand.php, LazyCron daily, source: feature:marketing, no-op while the feature is off — the same shape as errors:prune / ai-chat:prune) deletes rows with created_at older than RETENTION_MONTHS, except the newest row (max id) of each non-manual source, which DeliverabilityChecker::bounceReportCheck() and ProviderBounceTracking::lastReportAt() read as evidence. Suppression lives on MarketingSubscriber: scopeActive() excludes bounced, scopeEmailSuppressed() (unsubscribed OR bounced) is the list MarketingAudience and CartRecovery::isSuppressed() subtract, resubscribe() / confirm() clear it, as does clearBounce() from the Subscribers page's Clear bounce and Add Subscriber; the public form (MarketingPublicController::subscribe()) and the AI chat's subscribe_to_newsletter tool (ChatToolbox) never clear it — a bounced address gets SubscriberConfirmation::send() (composed under MailLocale::visitor()) and the check-email outcome / confirmation_required reply whatever marketing.double_opt_in says; findOrCreateByEmail() (imports) never does. Every resume/retry skip uses CampaignSend::alreadySent() (sent or bounced), never status === 'sent'. Fixtures: tests/Fixtures/bounces/ (all LF; the CRLF case is built at test time by converting an LF fixture, so no git line-ending setting is involved).
  • SMS provider: Support/SmsSender.php (Twilio via plain HTTP, credentials in marketing.twilio_* Settings, webhook signature validation). STOP suppression model: Models/SmsOptout.php.
  • Tracking: Support/CampaignTracking.php; endpoints /newsletter/o/{token} and /newsletter/c/{token}/{link}; per-URL totals in campaign_links. Scanner/bot hits are kept out of every counter by Support/TrackingBotFilter.php (HEAD probes, known scanner/crawler/preview user agents, and any hit inside the 10-second delivery-scan window after campaign_sends.sent_at) — the redirect/pixel is still served, only the accounting is skipped, on all three tracking endpoints (email open, email click, SMS click).
  • SMS link tracking: Support/SmsLinkTracking.php (marketing.track_sms_clicks) — same two-phase instrument() / forSend() shape as CampaignTracking but over plain text, sharing campaign_links so stats and the top-links table need no separate path. Its redirect /r/{code}/{link} keys on campaign_sends.short_code, a 7-char code minted in the model's creating hook for phone-keyed sends only (email keeps the 40-char token, where URL length is free); both redirect routes run through one MarketingPublicController::recordClick(), with the implied-open step passed in rather than assumed. Click branching on an SMS step is gated on that campaign having campaign_links rows, so enabling the setting can't retroactively rewire a running sequence — see SequenceRunner::conditionMet().
  • CampaignUtm::channelSource() derives utm_source from the channel (email / sms / push). Push clicks previously tagged as email.
  • Public endpoints (newsletter/subscribe, newsletter/unsubscribe/*, marketing/sms/webhook) are CSRF-exempt because they're hit by plain form posts from cached pages, mail clients, and Twilio. The public URLs deliberately kept the newsletter/ prefix — they're baked into design-library signup rows and they still serve the email list. The two newsletter/unsubscribe/{token} routes (GET page + RFC 8058 POST) sit in their own web group OUTSIDE feature:marketing.
  • Non-campaign unsubscribe links: Support/UnsubscribeLinks.php. forEmail() returns the row's own token URL, else an address token — AES-256-GCM over the address with a key derived as HMAC-SHA256(marketing-unsubscribe-address-token, app key), base64url(iv‖tag‖ciphertext) (~60–90 chars); addressFor() also tries every APP_PREVIOUS_KEYS key, so a rotation keeps old links working. unsubscribe() creates the row on first use (source unsubscribe, confirmed_at null, unsubscribed_at now; a concurrent click's unique-index race re-finds and unsubscribes). footerHtml() = postal address + link. MarketingSubscriber::isEmailSuppressed() is the shared pre-send check (OrderEmails via CartRecovery::isSuppressed(), LoyaltyEmails, CrmAutomations::actionSendEmail()); CrmContactMail's optional unsubscribeUrl adds the footer + headers(). The Webhooks module fires subscriber.unsubscribed for a row CREATED unsubscribed. Tests: MarketingUnsubscribeLinksTest.
  • Consent proof: Support/ConsentProof.php — fromVisitor() / fromStaff() / reported() build a record, merged() / record() store it under consent.email or consent.sms (JSON column, cast array); holdUntilConfirmed() parks a returning signup's proof under consent.email_held and confirm() settles it via settleHeld(); findOrCreateByEmail() / createByEmail() take consent as a create-only attribute; smsOptIn(?array $proof). The browser half is resources/js/signup-consent.js (imported by visibility.js): on submit of any form posting to /newsletter/subscribe it adds consent_text (visible text of the largest ancestor ≤ 2000 chars, stopping at section/aside/dialog/[role=dialog|region]/[data-wpcms-popup]), sms_consent_text (the ticked sms_opt_in box's label), consent_page and signup_via; the controller reads them leniently (never validated, so a bad field can't block a signup). SMS audiences and drips share MarketingAudience::smsConsentedPhones() (SequenceRunner preloads it per pass). Tests: MarketingConsentProofTest, MarketingSmsConsentTest, SignupConsentScriptTest (Node harness tests/scripts/run-signup-consent.mjs), and tests/Unit/ConsentProofCoverageTest.php, which fails on any file under app/ that adds someone to the list without referencing ConsentProof (or on an smsOptIn() without proof) unless it is on its reasoned no-opt-in list.
  • Sequences: models Models/{Sequence,SequenceStep,SequenceEnrollment}.php; enrollment gate Support/SequenceEnroller.php (subscriber hooks + the CRM enroll_sequence automation action); sender Support/SequenceRunner.php driven by marketing:run-sequences (LazyCron, 60s, lock-guarded, 200 enrollments/pass). Step content lives on hidden campaigns rows with status sequence — excluded from the index list, refused by SendCampaignJob, deleted with their step.
  • Provider bounce tracking (built from each provider's documentation; not yet verified against live accounts): Support/Bounces/ProviderBounceTracking.php (status() / enable($provider) / disable() / endpoint(); state in two Settings — marketing.bounce_tracking plain {provider, state: pending|active, since}, and marketing.bounce_tracking_secret, encrypted whole by SettingSecrets because the key contains "secret", holding the URL secret and the RegisteredWebhook — id, URL, signing credentials, context; neither is ever loaded into a Livewire property; a pending record older than PENDING_TTL_MINUTES reads as off; switching registers the new webhook before removing the old one and restores the old state if registration fails — only while the stored URL secret is still that attempt's; enable() / disable() run under Cache::lock(LOCK_KEY) (BounceTrackingFailed::busy() after 5 s); status()['state'] is off / active / unreadable — STATE_UNREADABLE when the record says active but the secret has no URL secret, no non-empty credential or no webhook handle (an APP_KEY rotation makes decryptIfNeeded() return ciphertext, which reads as []); then active is false, the settings box says "Not working" and offers Turn on again, and disable/switch return a delete-it-by-hand warning instead of calling the provider). Adapters in Support/Bounces/Providers/ behind BounceProvider (prepare / register / unregister / authenticate / events / malformedStatus / testsEndpointOnCreate; every API call is ->withoutRedirecting() and anything but a 2xx is a failure; unregister() throws when it has no handle; BounceTrackingFailed::withoutSecrets() scrubs a provider's error before cutting it to length): Postmark (POST /webhooks on CampaignMailer::POSTMARK_STREAM with generated HttpAuth, DELETE /webhooks/{id}; basic auth; malformed → 400), Resend (POST /webhooks events email.bounced + email.complained, DELETE /webhooks/{id}; Svix HMAC over id.timestamp.body, 5-minute window; event id = svix-id), Mailgun (GET /v5/accounts/http_signing_key, POST /v4/domains/{domain}/webhooks with repeated event_types=permanent_fail|complained, DELETE …/webhooks?url=; HMAC of timestamp+token, 24-hour window, token bound in cache to a SHA-256 of the raw body it first came with; malformed → 406), SendGrid (create disabled → PATCH …/signed/{id} → enable; ECDSA P-256 over timestamp+body via openssl_verify; events bounce + spam_report; only bounce_classification "Invalid Address" hints hard). Endpoint POST marketing/bounces/{provider}/{secret} (marketing.bounces.webhook, Http/Controllers/ProviderBounceWebhookController.php, throttle:600,1,marketing-bounces — its own bucket, never shared with other throttled public routes — CSRF-exempt in bootstrap/app.php): wrong secret or provider not enabled → 404, pending → 503 + Retry-After for a provider whose testsEndpointOnCreate() is false, failed provider auth → 401, pending Postmark → 200 recording nothing, irrelevant events → 200. App\Support\ErrorLogger stores the URL of marketing/bounces/{provider}/{secret}, support/inbound/{secret} and crm/inbound/{secret} with the secret segment replaced by [redacted]. BounceRecorder::recordProviderBounce() (classifier first, the adapter's typeHint only when it says unknown; fingerprint providerFingerprint() = provider + event id + recipient + kind) and recordComplaint() (unsubscribe only when provenSend() finds the reference's send to that address, else log-only; CATEGORY_COMPLAINT row never suppressed/held, send status untouched). complaint is excluded deliberately from recentSuppressionCount(), scopeNeedsBounceReview(), CampaignSend::bounces() and MarketingBounce::CATEGORIES; the soft threshold counts soft/unknown only. CampaignMailer::send() passes sendReference to providers under SEND_REFERENCE_KEY (wpcms_send: Postmark Metadata, Resend tags, Mailgun v:, SendGrid personalization custom_args); CampaignMailer::apiKey() is the adapters' key. Tests: tests/Feature/MarketingBounceProviderTest.php (adapters against faked APIs, the endpoint against tests/Fixtures/bounce-providers/, attribution, recorder) and MarketingBounceProviderSettingsTest.php (settings box, Deliverability states).
  • Tables: campaigns, marketing_subscribers, campaign_sends, campaign_templates, campaign_links, marketing_sms_optouts, marketing_sequences, marketing_sequence_steps, marketing_sequence_enrollments, marketing_push_subscriptions, marketing_bounces.
  • Tests: tests/Feature/Marketing*.php, tests/Feature/Campaign*.php (designer, audiences, send job, tracking, double opt-in, settings, SMS, SMS link tracking, sequences, A/B + send-time, push, branching, deliverability, digest, bounces + the marketing:record-bounce command), plus tests/Unit/MarketingBounce*Test.php (DSN parser against every fixture, classifier precedence), tests/Feature/MarketingBounceServerRouterTest.php + MarketingBounceServerSetupTest.php (the server scripts, run against temp dirs and stub commands; shared world in tests/Support/BounceServerHarness.php) + MarketingBounceInstallerDownloadTest.php (the route, the served file run end to end under BOUNCE_ROOT, damaged payloads, the dashboard command's quoting executed through sh with a stub curl) and MarketingBounceServerHookTest.php (header, hook command, Deliverability states) and tests/Feature/MarketingBounceManualEntryTest.php (hand entry); the breaker and review set are in MarketingBouncesTest.php.