Skip to content

[2.1] feat: Email bounce & complaint handling - #4827

Draft
imorland wants to merge 11 commits into
2.xfrom
im/mail-bounce-handling
Draft

imorland wants to merge 11 commits into
2.xfrom
im/mail-bounce-handling

Conversation

@imorland

@imorland imorland commented Jul 23, 2026 •

Copy link
Copy Markdown
Member

The problem

When Flarum sends mail to an address that hard-bounces (mailbox doesn't exist, permanently rejected) or is marked as spam, Flarum currently has no idea. It keeps sending to that address indefinitely. Repeated delivery to dead addresses and to people who filed spam complaints is the single fastest way to damage a forum's sender reputation and get its domain throttled or blocklisted by the mail provider.

Two things that look like they'd help, but don't:

  • EmailSendFailed only fires on submission failure (bad credentials, API rejects the request). A bounce happens asynchronously, minutes-to-hours after the provider already accepted the message — the transport never sees it.
  • is_email_confirmed does not gate outbound mail at all (notifications gate solely on shouldEmail()), so it can't be repurposed for this.

Bounces are only knowable via the provider's inbound webhook — Mailgun, Postmark, etc. POST delivery events back to us.

How this solves it

An abstract, provider-agnostic pipeline in core:

  • New opt-in driver capability interfaces — HandlesWebhooks (receive + verify + parse inbound events) and ProvisionsWebhooks (register the webhook with the provider automatically). DriverInterface is untouched, so third-party mail drivers are unaffected.
  • A single inbound route POST /mail/webhook[/{driver}] → WebhookController resolves the active driver, hands the request to its parser, and dispatches provider-neutral EmailBounced / EmailComplained events. Verification is owned entirely by each provider's parser (HMAC signature, IP allowlist, …) — the controller never assumes how a provider authenticates.
  • Leverages Symfony Mailer's webhook bridges (symfony/webhook + symfony/remote-event, newly required) so signature/IP verification and payload normalisation come from Symfony.
  • Only permanent failures count, and each driver decides which those are (isPermanentFailure()), since many of Symfony's converters report soft bounces as bounces. Mailgun's converter already separates them; Postmark only counts bounces that made Postmark deactivate the address.
  • A driver can switch webhooks off for its current settings (webhooksAvailable(), e.g. a sandbox mode), which hides bounce handling entirely rather than warning that it isn't set up.
  • Installs behind HTTP basic auth: an optional webhook username and password on the mail page are added to the webhook URL, both where it's shown and when it's registered with the provider.
  • Every refused webhook is logged with the reason and the sender's IP, so an admin can see why no bounces arrive.

On top of the pipeline:

  • Bounce state on the user (email_bounced_at + email_bounce_reason), plus a permanent email_bounce_events log so statistics survive a user fixing their address.
  • Suppression of notification email to bounced/complained users, behind a mail_suppress_bounced setting that defaults off — no behaviour change unless an admin opts in. Transactional mail (e.g. email confirmation) is deliberately not suppressed, so users can still recover.
  • In-app notice prompting affected users to update their email; it clears automatically once they do.
  • Admin visibility — the webhook URL and register/remove buttons on the mail page, an "Email delivery" column and is:bounced filter on the user list (shown only when the active driver can report bounces), and a dashboard widget with bounce volume over time plus currently-affected / recovered counts, warning when the return path isn't configured.
  • Audit log — the bundled audit extension logs user.email_bounced and user.email_complained.
  • GDPR-aware — the bundled GDPR extension scrubs the email from bounce-event rows on anonymisation (deletes them on erasure), and anonymised users are filtered out of all notifications so their placeholder addresses never generate mail.

Drivers

Implemented for the two core drivers that support it: Mailgun (HMAC-signed webhooks + automatic provisioning, which keeps any webhook URLs other applications registered on the domain) and Postmark (IP-verified webhooks; the URL is shown on the mail page for the admin to add in Postmark). SMTP and Sendmail have no feedback channel and are unaffected.

The interface is deliberately shaped to fit providers that verify by shared secret, IP allowlist, or SNS certificate, so additional drivers can implement it without core changes. Longer term, the Mailgun and Postmark drivers may be split out of core into their own extensions, and dedicated provider extensions (fof/brevo-mail, fof/mailtrap-mail, fof/ses-mail) will build on these same interfaces.

Notes

Reviewing

  • The capability interfaces are the public contract — Flarum\Mail\HandlesWebhooks / Flarum\Mail\ProvisionsWebhooks. Flarum\Mail\MailWebhooks is the one place that decides whether the active driver reports bounces and builds the webhook URL.
  • WebhookController is the security-sensitive entry point: verification is delegated to each driver's parser, and the route is CSRF-exempt by design (the provider signature / source IP is the authentication).

imorland and others added 11 commits October 30, 2026 09:20
Add an abstract, provider-agnostic pipeline for receiving bounce and
spam-complaint webhooks from mail providers, flagging affected users,
optionally suppressing further mail (opt-in, default off), prompting
users to fix their address, and surfacing bounce data to admins.

Implemented for the Mailgun (HMAC-signed webhooks + auto-provisioning)
and Postmark (IP-verified webhooks) core drivers via new opt-in
HandlesWebhooks / ProvisionsWebhooks driver interfaces, so third-party
drivers can add support without core changes.

GDPR-aware: bounce-event PII is scrubbed on anonymisation, and
anonymised users are excluded from notifications.
@imorland
imorland force-pushed the im/mail-bounce-handling branch from 45d509e to 1c65636 Compare October 30, 2026 08:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file type/feature type/reliability

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants