Skip to content

Latest commit

 

History

5 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 

Repository files navigation

discourse-pm-reply-only

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 in LOUD_RECIPIENTS and Saturday 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.

Before you push

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.

Deploying a change

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.

Upgrading Discourse

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.

About

Restrict Discourse reply-by-email to private messages only (reject public-topic email replies)

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages