Repo: GitHub - overgrow/discourse-bounce-guard · GitHub
License: MIT
&tldr; What you get: cleaner error log (the hourly retries to dead addresses stop). Discourse stops sending mail that cannot arrive. And happier users: the can get back into their account, because a dead address gets swapped for a working one in time.
If your outgoing mail goes through your own relay (Postfix, Exim, most self-hosted
setups), you have probably seen /logs fill up with pairs like this:
SMTP Error Net::SMTPServerBusy with message: 450 4.1.2 <someone@gone-domain.com>: Recipient address rejected: Domain not found
Job exception: Net::SMTPServerBusy
The domain is gone and is not coming back. But the relay answers with a temporary error
code (450), because in theory a DNS lookup can fail for a moment. Discourse takes any
temporary error to mean “try again in an hour”, and Sidekiq keeps trying for weeks. Core’s
bounce detection never fires. It only reacts when a bounce message comes back, either as an email (VERP) or as a webhook from your mail provider, and here the message is refused on the spot, so no bounce message ever exists. The bounce score stays at zero, the retries
keep going, and the user keeps an address that cannot receive anything, including a
password reset.
This has come up here a few times without a built-in answer, for example Handling emails to non-existent domain, Deactivate user with hard bounce and How to deactivate accounts of users who are not receiving emails. We had the same pain on our forum, so we built a plugin.
What it does
Bounce Guard hooks the sending path itself and classifies every SMTP rejection:
- 5xx replies with a bad-recipient enhanced status (
5.1.1,5.1.2,5.2.1and friends)
count as hard failures. - Any reply matching a configurable phrase list (“domain not found”, “user unknown”, …)
counts as hard whatever the code. That catches relays that soft-fail permanent
conditions with a 450. - Greylisting, full mailboxes and rate limits are left alone. Core’s retry behaviour is
untouched.
Recorded hard failures then climb a ladder:
- Each one feeds core’s bounce score as a hard bounce (on by default). After two, core’s
own threshold trips and Discourse stops emailing the user. The log noise ends here,
even if you never enable anything further. - After a configurable count (default 3) spread over a configurable span (default 48
hours, so a short outage cannot take anyone out), the plugin acts. The default action
islog_only: a staff action log entry recording that the user would have been
deactivated. Switch todeactivatewhen you trust it. - Deactivation routes the user through the standard activation flow on their next login,
where changing the address is built in. They verify a working email and continue with
their account intact. The point is recoverability: an active account whose only
recovery channel is a dead mailbox is a lockout waiting to happen.
If your site does receive bounces (VERP or webhooks), you can also let the plugin act when
core’s bounce score passes a level you choose, higher than the score at which core stops
sending. A few safety rules apply everywhere. Staff and bots are never touched. One stuck
email that retries every hour counts as a single failure, thanks to a cooldown window. A
failure only counts against a user when the address the server rejected is that user’s
current address. And every recorded failure is kept for 90 days so you can check what
happened (Data Explorer query in the README).
Install
Standard plugin install:
hooks:
after_code:
- exec:
cd: $home/plugins
cmd:
- git clone https://github.com/discourse/docker_manager.git
- git clone https://github.com/overgrow/discourse-bounce-guard.git
Enable bounce_guard_enabled, leave bounce_guard_action on log_only for a week or
two, review what it flags, then decide about deactivation. Settings reference is in the
README.
Server side only, no theme or JS components. Built and tested against current core
(2026.8), 31 specs, CI runs the standard discourse-plugin workflow. Feedback welcome,
especially rejection phrasings from other relays that the default phrase list misses.