Discourse reply by email — pick a mode and actually get it working (2026)

Outgoing email is easy. Incoming replies are where Discourse setups go to die. There are three completely different inbound modes — forwarded, polled POP3, and IMAP — and most online tutorials only cover one. This guide gives you a decision matrix, the DNS prerequisites every mode shares, working Mailgun and IMAP configs, and a log-line debug map that tells you which line in app/services/email/processor.rb rejected your reply and why.

forum.example.com/admin/site_settings/category/email
SimpleReview extension
Email Settings
Admin · Settings · Email · Incoming
Discourse Admin
SendingReceivingTemplates
reply_by_email_address
reply+%{reply_key}@reply.example.com
enable_imap (group inbox)
imap.example.com:993 · poll: BROKEN
pop3_polling_enabled
false (using IMAP instead)
manual_polling_enabled
false (Mailgun forwards)
✓ IMAP poll restored · 12 replies imported
Latest replies via email
support@example.com → topic #4421 — "yes, that fixed it"
jane@acme.io → topic #4419 — "thanks for the workaround"
mike@startup.dev → topic #4418 — "shipping the patch today"
support@example.com → topic #4415 — "closing this — resolved"
Comment×
Replies aren't getting through — fix the IMAP poll config?|
Fix it ✓ Done
waiting for selection…
Detected
ModeIMAP group
Errorpoll timeout
Last sync42 min ago
Fix plan
Update imap_password (App Password expired) · re-enable poll job · validate folder mapping
Result
IMAP IDLE reconnected. 12 queued replies imported into group inbox.
✓ Fix ready
fix(email): rotate IMAP App Password
app.yml · 1 line
Click SimpleReview → select the broken IMAP toggle → Fix it → poll restored, replies flowing
Replies silently disappearing? → SimpleReview reads your app.yml and Discourse site settings, replays the last failed inbound email through Email::Receiver, and prepares a fix with the missing DNS record, wrong reply pattern, or expired App Password — without you ever opening Sidekiq.

TL;DR — What you're about to read

  • Three modes, pick one: Mailgun-forwarded webhook (cheapest, fragile to spam), POP3 polled mailbox (built-in, slow, 1-min poll cap), IMAP for group inboxes (only mode for shared support@).
  • Prerequisites every mode shares: MX record on the reply subdomain, SPF + DKIM + DMARC on the sending domain, and a reply_by_email_address pattern that contains the literal token %{reply_key}.
  • Validation in one command: ./launcher mailtest app for outgoing SMTP. For incoming, send a real reply and watch /admin/email/received plus the Sidekiq Email::Receiver job.
  • Debug entry point: app/services/email/processor.rb — every rejection raises a named exception (BadDestinationAddress, NoBodyDetectedError, UserNotFoundError) which lands in /logs with a one-line cause.

The three inbound modes — pick one

Discourse's outbound side is straightforward: configure SMTP credentials in app.yml and point DISCOURSE_SMTP_ADDRESS at Mailgun, SES, Postmark, or your own postfix. Done. The inbound side is where forums wedge themselves for weeks. There is no single "incoming email" knob — there are three independent pipelines, and choosing the wrong one turns a 30-minute setup into a multi-day debugging session.

ModeHow it worksBest forPain point
Forwarded (webhook)Mail provider receives MX, POSTs raw RFC 5322 to /admin/email/handle_mailSolo blogs, mid-size communities on Mailgun/SES/PostmarkSpam slips through; signing secret leaks let anyone forge replies
Polled POP3Discourse Sidekiq job logs into a real mailbox every 1 minute and downloads new mailCheap shared hosts, no MX control, can't run webhooks1-minute polling cap, no IDLE; mail provider locks accounts that "look like spam"
IMAP (group inbox)Continuous IMAP sync between a mailbox and a Discourse group; bidirectional read/unreadShared support inboxes (support@, sales@) where staff and forum users see same threadsOAuth pain (Gmail/O365), folder mapping, only available for groups, not global replies

Quick decision tree

Prerequisites you must get right (all three modes)

Whatever mode you pick, the same DNS plumbing has to be in place. Skip any of these and replies will arrive at your mailbox but Discourse will refuse to process them — or worse, they'll be silently dropped by spam filters before you ever see them.

1. MX record on the reply subdomain

Pick a subdomain dedicated to replies — reply.example.com is the canonical choice. Don't use the apex domain, because that conflicts with sending mail and breaks DMARC alignment. Add an MX record:

# Mailgun forwarded mode
reply.example.com.    IN  MX  10  mxa.mailgun.org.
reply.example.com.    IN  MX  10  mxb.mailgun.org.

# Polled POP3 / IMAP — point at your mail server
reply.example.com.    IN  MX  10  mail.example.com.

2. SPF, DKIM, DMARC on the sending domain

Discourse sends notifications from noreply@example.com (or whatever you set as notification_email) and users reply to that. If your sending domain doesn't have SPF/DKIM/DMARC, the reply you generate from Gmail or Outlook will be flagged as spoofed by the recipient's spam filter — even though "the recipient" is your own server.

# SPF (TXT on example.com)
example.com.  IN  TXT  "v=spf1 include:mailgun.org ~all"

# DKIM (TXT — selector from Mailgun/SES dashboard)
mg._domainkey.example.com.  IN  TXT  "v=DKIM1; k=rsa; p=MIGfMA0GCSqG..."

# DMARC (TXT — start with p=none, tighten later)
_dmarc.example.com.  IN  TXT  "v=DMARC1; p=none; rua=mailto:dmarc@example.com"

3. The reply pattern with literal %{reply_key}

This is the #1 silent-failure cause. In /admin/site_settings the field reply_by_email_address must contain the literal token %{reply_key} — Discourse substitutes a unique per-post key into outgoing notifications and uses it to match incoming replies back to the right topic. Forget the token and every reply will be rejected with BadDestinationAddress.

# ✅ correct
reply+%{reply_key}@reply.example.com

# ❌ silently broken — no token, all replies bounce
[email protected]

# ❌ also broken — Discourse doesn't expand outside the localpart
reply@%{reply_key}.example.com
Plus-addressing requires mail-provider support. Mailgun, SES, and most modern providers handle [email protected] correctly. Some legacy mail servers strip everything after the + — if you're using on-prem postfix, verify recipient_delimiter = + is set in main.cf.

4. notification_email and reply_by_email_address must be different

Don't use the same address for both. notification_email is the From: header users see (noreply@example.com). reply_by_email_address is the Reply-To: header (reply+%{reply_key}@reply.example.com). Putting them on the same domain is fine; using the exact same mailbox is not — Discourse will fight itself trying to read its own outgoing notifications.

Setup A — Mailgun forwarded mode (the canonical setup)

This is the path described in the official Discourse Mailgun guide and the path 80% of self-hosted forums end up on. Mailgun handles SMTP outbound and inbound MX in one product; you only need one DNS zone, one set of credentials, and one inbound route.

1. Add the domain to Mailgun

In Mailgun → Domains → Add New Domain → enter mg.example.com (or use example.com directly). Mailgun will give you SPF, DKIM, and MX records to add. Add all of them — skipping the MX records is the bug that ships every week.

2. Configure outbound SMTP in app.yml

env:
  DISCOURSE_SMTP_ADDRESS:        smtp.mailgun.org
  DISCOURSE_SMTP_PORT:           587
  DISCOURSE_SMTP_USER_NAME:      [email protected]
  DISCOURSE_SMTP_PASSWORD:       "your-mailgun-smtp-password"
  DISCOURSE_SMTP_DOMAIN:         mg.example.com
  DISCOURSE_NOTIFICATION_EMAIL:  [email protected]

Then rebuild: ./launcher rebuild app. Validate with ./launcher mailtest app — it sends a test mail through the configured SMTP and reports the actual error if it fails (auth, TLS, rate limit, etc).

3. Configure the Mailgun inbound route

In Mailgun → Receiving → Create Route. Use the store and forward action with these settings:

Expression Type:  Match Recipient
Recipient:        ^reply\+(.*)@reply\.example\.com$

Actions:
  store(notify="https://forum.example.com/admin/email/handle_mail")
  stop()

Priority: 10

Mailgun will store the raw email and POST a notification to your handle_mail endpoint. Discourse fetches the body, runs it through Email::Receiver, and creates the post. The stop() prevents Mailgun's default routes from firing.

4. Set the Mailgun signing secret

Mailgun signs every webhook POST. In /admin/site_settings search for mailgun api key and paste the key from Mailgun → Settings → API Keys → HTTP webhook signing key. Without it, anyone who guesses your handle_mail URL can inject forged replies.

5. Wire up the reply pattern

In /admin/site_settings/category/email:

Setup B — Polled POP3 mailbox

Use this when you can't run an inbound webhook (no public IP, behind NAT, shared host) but you do have a real mailbox somewhere. Discourse's Sidekiq runs the Jobs::PollMailbox job every minute, logs in over POP3, downloads new mail, processes it through Email::Receiver, and deletes it from the server.

Configuration

In /admin/site_settings → Email:

pop3_polling_enabled:     true
pop3_polling_ssl:         true
pop3_polling_host:        mail.example.com
pop3_polling_port:        995
pop3_polling_username:    [email protected]
pop3_polling_password:    "mailbox-password"
pop3_polling_period_mins: 1   # minimum is 1 — Discourse caps it

When POP3 is the right choice

Gotchas

Setup C — IMAP for group inboxes

IMAP support lives in a different code path than the global reply system. Each Discourse group can be linked to one IMAP folder; mail sent to the group's email lands in the forum, replies posted in the forum land back in the IMAP folder, and read/unread state syncs both ways. This is the only mode that gives you a real shared support inbox where the email team and the forum team see the same conversation.

Configuration

Step 1 — enable global IMAP support:

enable_imap:  true

Step 2 — open /g/<group>/manage/email for the group (e.g. support) and fill in:

IMAP server:    imap.gmail.com   # or imap.fastmail.com, mail.example.com
IMAP port:      993
IMAP SSL:       true
IMAP username:  [email protected]
IMAP password:  <App Password — NOT account password>

SMTP server:    smtp.gmail.com
SMTP port:      465
SMTP SSL:       true
SMTP username:  [email protected]
SMTP password:  <same App Password>

Step 3 — assign mailboxes. Pick the IMAP folder that holds incoming support mail (usually INBOX) and a "trash" folder where Discourse will move processed mail.

Gmail and Microsoft 365 quirks

Validation flow — does it actually work?

Before debugging, validate from both ends. Most "replies aren't working" tickets are actually outbound problems — users never get the notification, so no Reply-To: address ever exists.

Step 1: outbound SMTP

cd /var/discourse
./launcher mailtest app
# prompts for an address, sends a real mail
# any failure prints the full SMTP exchange

Or from the admin UI: /admin/email → "Send test email". Look for the green confirmation and check Mailgun/SES dashboard for the delivered event.

Step 2: send a real reply

  1. Subscribe a real user (not your admin account — admins get notifications differently) to a topic with "Watching" mute level.
  2. Have someone else post in that topic so the user receives a notification.
  3. Reply to the email from the user's real mail client. Don't hit "Reply to All", don't edit the To: address — Discourse needs the Reply-To: with the reply_key intact.
  4. Wait 1-3 minutes (or up to the POP3 poll interval).
  5. Check /admin/email/received — successful inbound mail appears here with the matched topic.
  6. Check /admin/email/rejected — failures land here with the exception name and a copy of the raw body.
  7. Check /logs for any Email::Receiver exceptions if the reply doesn't appear in either list.

Step 3: read the Sidekiq queue

Inbound processing is a Sidekiq job. If Sidekiq is wedged, replies queue up forever:

# inside the container
./launcher enter app
sv status sidekiq         # should be "run"
# Sidekiq web UI:
# /sidekiq → Scheduled / Retries / Dead
# Look for Jobs::PollMailbox or Jobs::ProcessEmail entries

Debug map — failure mode → log line → fix

Every rejection inside app/services/email/processor.rb and app/services/email/receiver.rb raises a named exception. Discourse catches it and writes one line to /logs with the exception class. This table maps every common failure to its log signature and the fix.

SymptomLog line / exceptionFix
Reply lands in /admin/email/rejected with no bodyEmail::Receiver::NoBodyDetectedErrorUser replied with HTML-only mail and Discourse couldn't extract a text part. Check strip_incoming_email_lines setting and any custom signatures.
"Bad destination address"Email::Receiver::BadDestinationAddressErrorTo: doesn't match reply_by_email_address. Verify the literal %{reply_key} is in the pattern. Check the user clicked Reply (not Forward).
Sender unknownEmail::Receiver::UserNotFoundFromEmailErrorThe From: address isn't on any Discourse user. Either the user replied from a different mailbox, or staged users are disabled.
Reply too shortEmail::Receiver::TooShortPostmin_post_length too high relative to mobile-quoted replies. Lower it or set min_first_post_length separately.
Group not foundEmail::Receiver::BadDestinationAddressError on group addressIMAP group email mismatch — the To: address isn't mapped to any group's incoming_email.
Reply silently dropped before Discoursenothing in /logsSPF/DKIM failure — check the recipient mail provider's spam folder. Fix DMARC alignment.
550 bounce from spam filterMailgun event log: 5.7.1 spam contentReply quoted too much of the original notification. Tune strip_incoming_email_lines or whitelist the sending domain in your spam filter.
"Email already processed"Email::Receiver::EmailLogNotFound or duplicate Message-IDMailgun retried delivery after webhook timeout. Discourse dedupes by Message-ID; this is benign unless every reply is a duplicate.
IMAP poll never runsSidekiq → Scheduled → emptyenable_imap not set, or group's IMAP credentials missing. Check /g/<group>/manage/email.
IMAP poll authentication failsNet::IMAP::NoResponseError: AUTHENTICATIONFAILEDGmail App Password expired/revoked. Regenerate. Or 2FA disabled — App Passwords disappear when 2FA is off.

Where these are raised in the source

If you want to read the actual logic: app/services/email/receiver.rb in the Discourse repo defines every Email::Receiver::*Error subclass at the top of the file. app/services/email/processor.rb is the orchestrator that catches them and writes the rejection record. Reading those two files is the fastest way to understand exactly which guard rejected your mail.

Common gotchas (read this list before opening a ticket)

When to hand it off to AI

Email setup is famously the hardest part of running a Discourse forum. There are five DNS records, three site settings, one signing secret, one Sidekiq job, and seven different exceptions that all surface as "replies aren't working". SimpleReview automates the audit:

  1. Install the Chrome extension and open your Discourse admin email page.
  2. Click any of the broken settings — the IMAP toggle, the reply pattern, the Mailgun secret field.
  3. Type what you observe ("replies aren't getting through") and click Fix it.
  4. SimpleReview reads app.yml over SFTP, queries Discourse's /admin/email/rejected for the last failures, replays the most recent rejected message through Email::Receiver in a sandbox, and prepares a fix with the precise fix — usually a missing DNS record, a wrong reply pattern, or an expired App Password.

Faster than reading processor.rb at 2 AM trying to figure out which exception you're hitting.

Fix Discourse Email Without Reading the Source

SimpleReview audits your DNS, app.yml, and site settings, replays failed inbound mail, and prepares a fix with the one-line fix.

Install SimpleReview Chrome Extension →

Self-hosting Discourse? SimpleReview reads your container config over SFTP →

Frequently Asked Questions

Why is reply-by-email so hard to set up in Discourse?
Because reply-by-email is two completely separate flows. Sending email out is a normal SMTP relay (Mailgun, SES, Postmark, SendGrid). Receiving replies requires a public MX record pointing somewhere that can deliver inbound mail to Discourse — either an inbound webhook (forwarded mode), a mailbox Discourse polls over POP3, or an IMAP inbox Discourse syncs continuously. Each mode has its own DNS, auth, and failure surface, and most online tutorials only cover one of the three.
Do I need MX records for Discourse reply by email?
Yes — replies have to reach a mailbox or webhook before Discourse can pull them in. The MX record on your reply domain (often a subdomain like reply.example.com) must point to your inbound provider: Mailgun's MX hosts in forwarded mode, or your mail server's hostname if Discourse polls a real mailbox. SPF, DKIM, and DMARC are also required — without them inbound spam filters will silently drop replies before Discourse ever sees them.
What is the difference between polled mailbox and IMAP in Discourse?
Polled mailbox uses POP3 to fetch new replies on a schedule (Sidekiq job runs every minute). It's read-only, simple, and built for the reply_by_email_address pattern. IMAP support is different: it's designed for group inboxes — a shared support@example.com mailbox where staff and forum users see the same threads. IMAP keeps state synced both ways (read/unread, archive, delete), polls more aggressively, and is mapped to a Discourse group, not the global reply system.
Can Discourse use Gmail for reply by email?
Technically yes via IMAP, but it's painful. Gmail blocks plain password login, so you need either an App Password (only works if 2FA is on and the account is not in Advanced Protection) or full OAuth, which Discourse doesn't natively support for IMAP. Most production setups use Mailgun or a dedicated mailbox on your own mail server. If you must use Gmail, generate an App Password and configure it in Settings → Email → IMAP.
Why are replies bouncing with "Bad destination address"?
BadDestinationAddress is raised by Email::Receiver when the To: address doesn't match the reply_by_email_address pattern, or when the %{reply_key} token is missing or invalid. Common causes: the user replied to the From: address instead of the Reply-To:, the reply_by_email_address site setting was changed and old keys no longer decode, or a forwarder rewrote the recipient address. Check /logs and the Sidekiq Email::Receiver job for the exact failed address.
How do I test Discourse email without sending a real reply?
For outgoing SMTP: from the Discourse host run ./launcher mailtest app, or use the admin Email page's "Send Test Email" button. For incoming, enable mailing-list mode on a test account, send a notification, then reply from a real mail client. Watch /admin/email/received and /logs for Email::Receiver entries. The launcher mailtest command is the fastest way to validate outbound credentials.
Does Discourse support inbound webhooks from SES, Postmark, or SendGrid?
Yes, but with caveats. Discourse exposes /admin/email/handle_mail as a generic inbound endpoint that accepts a raw RFC 5322 email body. Mailgun's "store and forward" route is the canonical example. Postmark inbound and SES inbound webhooks need a small adapter to convert their JSON payloads into a raw email POST — community plugins exist but aren't shipped by default. For SendGrid, the Inbound Parse webhook can be pointed directly at Discourse if you set the multipart format correctly.

Related Discourse guides

Sources