Discourse Bounce Guard plugin

:discourse2: Summary Discourse Bounce Guard detects hard email delivery failures, stops mail to dead addresses, and can deactivate the affected user so they must verify a working address
:hammer_and_wrench: Repository Link https://github.com/overgrow/discourse-bounce-guard
:open_book: Install Guide How to install plugins in Discourse

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.1 and 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:

  1. 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.
  2. After a configurable count of failures spread over a configurable span (default: 2 failures at least 48 hours apart, so a short outage cannot take anyone out), the plugin acts. The default action is log_only: a staff action log entry recording that the user would have been deactivated. Switch to deactivate when you trust it.
  3. 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.