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.
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_addresspattern that contains the literal token%{reply_key}. - Validation in one command:
./launcher mailtest appfor outgoing SMTP. For incoming, send a real reply and watch/admin/email/receivedplus the SidekiqEmail::Receiverjob. - Debug entry point:
app/services/email/processor.rb— every rejection raises a named exception (BadDestinationAddress,NoBodyDetectedError,UserNotFoundError) which lands in/logswith 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.
| Mode | How it works | Best for | Pain point |
|---|---|---|---|
| Forwarded (webhook) | Mail provider receives MX, POSTs raw RFC 5322 to /admin/email/handle_mail | Solo blogs, mid-size communities on Mailgun/SES/Postmark | Spam slips through; signing secret leaks let anyone forge replies |
| Polled POP3 | Discourse Sidekiq job logs into a real mailbox every 1 minute and downloads new mail | Cheap shared hosts, no MX control, can't run webhooks | 1-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/unread | Shared support inboxes (support@, sales@) where staff and forum users see same threads | OAuth pain (Gmail/O365), folder mapping, only available for groups, not global replies |
Quick decision tree
- Solo blog or small community → Forwarded. Mailgun free tier handles inbound, you only need one DNS MX record and one route.
- Shared support inbox where staff already use email → IMAP. Map
support@example.comto a Discourse group; replies show up in both Gmail and the forum. - Cheap shared host, no inbound webhook capability → POP3 polled mailbox. Slow but works anywhere mail does.
- Multiple of the above → run them in parallel. Mailgun for global replies, IMAP for the support group inbox. They don't conflict.
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
[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:
reply_by_email_address:reply+%{reply_key}@reply.example.comreply_by_email_enabled:trueemail_in: leave empty (that's a different feature — creating new topics by 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
- Your forum runs on a shared host or PaaS with no inbound HTTP for arbitrary paths.
- You don't want to deal with Mailgun signing secrets or DNS routes.
- The mail volume is low (under a few hundred replies/day) — POP3's 1-minute floor adds up otherwise.
Gotchas
- POP3 deletes mail. Discourse removes processed messages from the server. Use a dedicated mailbox; don't point this at your real inbox.
- Provider locks accounts. Gmail will lock a POP3 account that polls every 60 seconds within hours, calling it "suspicious activity". Don't use Gmail for POP3 — use a real mail server, FastMail, or Migadu.
- Reply latency feels broken at 1 min. Users used to Slack-speed get confused by 60-second delays. Mention it in your community guidelines or use IMAP/Mailgun instead.
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
- Gmail App Passwords: only available if 2-Step Verification is on. Generate at
myaccount.google.com/apppasswords. They can't be used in Workspace accounts under Advanced Protection. - Microsoft 365: requires modern auth (OAuth 2.0). Discourse doesn't natively support OAuth IMAP — community plugins exist but most production setups bridge through a postfix proxy or use a non-Microsoft provider for the group inbox.
- FastMail / Migadu: work natively with App Passwords. Recommended.
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
- Subscribe a real user (not your admin account — admins get notifications differently) to a topic with "Watching" mute level.
- Have someone else post in that topic so the user receives a notification.
- 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_keyintact. - Wait 1-3 minutes (or up to the POP3 poll interval).
- Check
/admin/email/received— successful inbound mail appears here with the matched topic. - Check
/admin/email/rejected— failures land here with the exception name and a copy of the raw body. - Check
/logsfor anyEmail::Receiverexceptions 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.
| Symptom | Log line / exception | Fix |
|---|---|---|
| Reply lands in /admin/email/rejected with no body | Email::Receiver::NoBodyDetectedError | User 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::BadDestinationAddressError | To: doesn't match reply_by_email_address. Verify the literal %{reply_key} is in the pattern. Check the user clicked Reply (not Forward). |
| Sender unknown | Email::Receiver::UserNotFoundFromEmailError | The From: address isn't on any Discourse user. Either the user replied from a different mailbox, or staged users are disabled. |
| Reply too short | Email::Receiver::TooShortPost | min_post_length too high relative to mobile-quoted replies. Lower it or set min_first_post_length separately. |
| Group not found | Email::Receiver::BadDestinationAddressError on group address | IMAP group email mismatch — the To: address isn't mapped to any group's incoming_email. |
| Reply silently dropped before Discourse | nothing in /logs | SPF/DKIM failure — check the recipient mail provider's spam folder. Fix DMARC alignment. |
| 550 bounce from spam filter | Mailgun event log: 5.7.1 spam content | Reply 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-ID | Mailgun retried delivery after webhook timeout. Discourse dedupes by Message-ID; this is benign unless every reply is a duplicate. |
| IMAP poll never runs | Sidekiq → Scheduled → empty | enable_imap not set, or group's IMAP credentials missing. Check /g/<group>/manage/email. |
| IMAP poll authentication fails | Net::IMAP::NoResponseError: AUTHENTICATIONFAILED | Gmail 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)
- Gmail OAuth vs App Password: SMTP can use OAuth (it does in many configurations); IMAP in Discourse cannot. Use App Passwords for IMAP.
- Postmark inbound vs Mailgun: Postmark's inbound webhook posts JSON, not raw RFC 5322. You need a small adapter (Cloudflare Worker, AWS Lambda) to repackage as a raw email POST. SES is the same — SES → SNS → Lambda → handle_mail.
- SendGrid Inbound Parse: works directly if you set "POST raw" mode on the parse webhook. Without that flag, SendGrid sends parsed JSON which Discourse can't read.
- The
%{reply_key}token must be literal. Don't URL-encode it, don't paste it from a docs page where curly braces got smart-quoted. - Topic must be open. Closed/archived topics reject email replies with a polite bounce notice — but only if outbound is working. If outbound is broken, the reply is silently dropped.
- Staged users can break. Discourse auto-creates "staged" users from email-only senders. If
enable_staged_usersis off, replies from non-registered emails getUserNotFoundFromEmailError. - HTML-only mail clients (Outlook). Discourse extracts the text part. Outlook Web sometimes sends only HTML — the
strip_incoming_email_linesheuristic falls over and the post body becomes garbled. Tell users to send plain text replies, or accept the noise. - The reply-key TTL. Reply keys don't expire in current Discourse, but if you change the site's secret key (rare, but happens during disaster recovery) all old reply keys become invalid and old notifications can no longer be replied to.
- Mailgun route priorities. If multiple routes match, only the highest-priority one runs unless they all use
store(). Use a single dedicated route for Discourse and put it at priority 10 with astop(). - DKIM alignment is the silent killer. If your sending domain is
example.combut DKIM signs withmg.example.comand DMARC requires strict alignment, replies bounce upstream and never reach you. Use relaxed alignment in DMARC:aspf=r; adkim=r;
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:
- Install the Chrome extension and open your Discourse admin email page.
- Click any of the broken settings — the IMAP toggle, the reply pattern, the Mailgun secret field.
- Type what you observe ("replies aren't getting through") and click Fix it.
- SimpleReview reads
app.ymlover SFTP, queries Discourse's/admin/email/rejectedfor the last failures, replays the most recent rejected message throughEmail::Receiverin 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
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.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.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../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./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
- meta.discourse.org — Set up reply via email support
- meta.discourse.org — Configure Discourse with Mailgun
- meta.discourse.org — IMAP support for group inboxes
- meta.discourse.org — Troubleshoot email on a new Discourse install
- github.com — Email::Receiver source (exception classes)
- github.com — Email::Processor source (orchestration)
- documentation.mailgun.com — Receiving, forwarding, storing email
- datatracker.ietf.org — RFC 7489 DMARC