Two halves of one policy: reply-by-email belongs to private messages.
Inbound. Email replies to public topics are rejected; the sender gets Discourse's
standard notice. PM replies post normally. reply_by_email_enabled is a global
Discourse setting with no PM-vs-topic split; this plugin adds that split by guarding
Email::Receiver#process_destination.
Outbound. Notifications about public topics say so before the reader writes back:
- an amber banner at the top of the email body, HTML and plain text
- the subject reads
Saturday FORUM: ...for the addresses inLOUD_RECIPIENTSandSaturday Forum: ...for everyone else - the closing line says "Visit Topic to respond" instead of inviting an email reply the inbound guard would refuse, including for staged users, who core sends down a separate "Reply to this email to respond" branch
The banner and subject are applied on before_email_send, after Discourse has
finished styling and immediately before delivery. That is deliberate: email templates
are global with no PM-vs-topic split, and Discourse's markdown pipeline strips
styling, so neither the color nor the per-recipient subject can live in a
translation override.
Pairs with the forum-reply-ingest Cloudflare Email Worker (inbound pipe) in the
sweb repo. Saturday Inc internal infra.
ruby script/check_metadata.rb
Discourse parses the header comment block before it boots and crashes on a comment
line that holds nothing but #. That takes down the whole forum, not just this
plugin. It cost a five minute outage on 2026-08-13.
The plugin is cloned into the container by hooks: after_code in the VM's
containers/app.yml, so pushing to main is what makes a change durable. To pick it
up on the running forum without the ~5-10 minute ./launcher rebuild app downtime:
gcloud compute ssh discourse-forum --zone us-central1-a --project fuel-app-prod \
--tunnel-through-iap --command \
"sudo docker exec app bash -c 'cd /var/www/discourse/plugins/discourse-pm-reply-only && git pull' \
&& sudo docker exec app sv restart unicorn"
Boot the app in a throwaway process before restarting unicorn, so a plugin that cannot load fails there instead of on the live site:
sudo docker exec app bash -c \
'cd /var/www/discourse && RAILS_ENV=production bundle exec rails runner \
"puts Discourse.plugins.map(&:name).sort.join(%q(,))"'
Ruby plugin code loads at boot, so the unicorn restart is what actually applies it.
Confirm with /admin/plugins.json, which reports the loaded commit hash.
A rebuild re-clones this repo's main, so the plugin comes back on its own and needs
no special handling. What does need handling is core moving under it. Every hook below
fails silently if core renames or drops it, and the loudest failure mode is the
first one: email replies to public topics start posting again.
| Touchpoint | Where | If it breaks |
|---|---|---|
Email::Receiver#process_destination |
prepended | inbound guard stops; public email replies post |
Email::MessageBuilder#initialize, only_reply_by_email opt |
prepended | staged users see "Reply to this email to respond" again |
message_builder_reply_by_email modifier |
register_modifier |
closing line invites an email reply that bounces |
before_email_send event |
DiscourseEvent.on |
no banner, no FORUM subject |
X-Discourse-Topic-Id header |
read from the message | banner and subject silently skip every email |
<div class="header-instructions"> |
banner anchor in the HTML | banner moves to just after <body>, still shows |
After any upgrade, send yourself a real notification and check all four at once:
post = Post.last
user = User.find_by(username: "Alex")
m = UserNotifications.user_posted(user, post: post,
notification_type: Notification.types[:posted],
notification_data_hash: { original_username: post.user.username,
topic_title: post.topic.title })
Email::Sender.new(m, :user_posted, user).send
puts m.subject # expect "Saturday FORUM: ..."
puts m.html_part.body.to_s.include?("PUBLIC FORUM POST") # expect true
puts m.text_part.body.to_s.include?("PUBLIC FORUM POST") # expect true
puts m.html_part.body.to_s.include?("or reply to this email") # expect false
Then reply to that email from Alex's inbox: it must bounce with Discourse's rejection notice, and the topic must stay untouched. That single round trip covers every row above.