moderate never sends an email, never posts to Telegram, never writes a push. It does exactly one thing with notifications: it emits events. You wire those events to wherever they should go β once β and everything downstream (a report receipt, a decision email, an admin "π© new flag" ping, an in-app feed item) flows from that single hook.
That hook is config.notify:
Moderate.configure do |config|
config.notify = ->(event) { ... } # called for every notifiable event; no-op by default
endOne lambda. Every notifiable thing that happens in your Trust & Safety layer passes through it. Inside it you case on event.name and fan out to:
goodmailβ beautiful transactional emails to the user (report receipt, decision / statement-of-reasons, appeal outcome).telegramaβ a one-line Telegram admin alert (a new report landed, content got auto-flagged, a DSA notice arrived).noticedβ multi-channel in-app feed + push to the user.
The whole point: notify users by email and in-app, AND optionally ping yourself on Telegram, all wired in one place. No notification logic scattered across your models, services, and controllers β moderate collects every moment worth telling someone about and hands them to you with a stable envelope.
Note
config.notify is for people (users get emails/push, admins get a Telegram nudge). It's separate from config.audit, which is for machines (append-only record of every action for your audit log). Same envelope, different intent β see Audit at the bottom. Most apps wire both, in the same initializer, in about fifteen lines.
# config/initializers/moderate.rb
Moderate.configure do |config|
config.notify = ->(event) do
# 1) Email the user (goodmail) β receipts, decisions, statements of reasons.
case event.name
when :report_received, :report_decision, :affected_user_decision,
:appeal_received, :appeal_decision, :notice_received
Array(event.recipients).each do |user|
next unless user.respond_to?(:email) # anonymous DSA notifiers carry an email, not a User
ModerationMailer.with(event: event, recipient: user)
.public_send(event.name)
.deliver_later
end
end
# 2) Ping admins on Telegram (telegrama) β ONE message, only for things YOU care about.
case event.name
when :report_received, :notice_received, :content_flagged
Telegrama.send_message(event.payload[:summary], formatting: { obfuscate_emails: true })
end
# 3) In-app feed + push (noticed) β the user's bell icon and device.
case event.name
when :report_decision, :affected_user_decision, :appeal_decision,
:user_banned, :content_removed
Moderate::DecisionNotifier.with(event: event).deliver(event.recipients)
end
end
endThat's the entire integration. Three cases, one per channel, in one lambda. Add or drop an event.name from any list to turn a channel on/off for that moment. The rest of this doc explains the envelope, the full vocabulary, and each channel in detail.
Important
Keep config.notify fast. It runs inside the same call as the moderation action (often inside a transaction). Always hand off to a background job β deliver_later (goodmail / Action Mailer), perform_later (your own jobs), or Telegrama's async mode (below). Never do blocking HTTP in the hook itself.
Every event your notify hook receives is the same small, stable object. You never construct it β moderate does β you only read it:
event.name # Symbol β which moment this is, e.g. :report_decision (the full list below)
event.subject # the record this is about β a Moderate::Report / Flag / Block / Appeal / Notice
event.actor # who triggered it β a moderator, a user, or nil for system/automated events
event.recipients # Array of who should be told β usually the user(s) to email/notify
event.payload # Hash of event-specific context, ALWAYS including :summary (a ready-to-send line)
event.to_h # the whole envelope as a Hash (handy for logging, tests, and noticed params)Two fields do most of the work:
event.recipientsβ already resolved to the right people for this event. Forreport_receivedit's the reporter (their receipt). Forreport_decisionit's the reporter (the outcome). Foraffected_user_decisionit's the person whose content/account was acted on (their statement of reasons). You don't compute audiences βmoderatedoes, and hands you the array. Iterate it.event.payload[:summary]β a short, human, already-redaction-safe one-liner describing what happened (e.g."New harassment report on Comment #4213 by johβ¦e@example.com"). It exists so your admin Telegram ping is literally one line β no string-building, no leaking. Everything richer (the model, the category, the moderator's note) is also inpayloadif you want to compose your own copy.
A recipient is usually one of your User records (it responds to email, id, etc.), except for DSA-notice events (notice_received, and the affected_user_decision for a public notice) where the notifier may be an anonymous person β there moderate gives you a lightweight recipient that responds to email/name but is not a User. The next unless user.respond_to?(:email) / user.is_a?(User) guards in the recipes above handle that cleanly.
These are every event moderate can emit. Wire the ones you care about; ignore the rest (the hook is a plain case β unmatched names just fall through).
| Event | When it fires | actor |
recipients |
Typical channels |
|---|---|---|---|---|
report_received |
A user files an in-app report | the reporter | the reporter (receipt) | π§ user receipt Β· π¬ admin ping |
notice_received |
A public DSA Art. 16 notice is submitted | the notifier (may be anonymous) | the notifier (Art. 16(4) confirmation of receipt) | π§ confirmation Β· π¬ admin ping |
report_decision |
A moderator resolves/dismisses a report | the moderator | the reporter (outcome) | π§ email Β· π in-app |
affected_user_decision |
A decision affects the reported party | the moderator | the reported user / notice subject (statement of reasons, Art. 17) | π§ email Β· π in-app |
appeal_received |
A user appeals a decision (Art. 20) | the appellant | the appellant (receipt) | π§ receipt Β· π¬ admin ping |
appeal_decision |
A moderator upholds/rejects an appeal | the moderator | the appellant | π§ email Β· π in-app |
user_blocked |
One user blocks another | the blocker | β (usually silent; no recipients) | π audit only, typically |
user_unblocked |
A block is lifted | the blocker | β | π audit only, typically |
user_banned |
A user is banned (your ban_handler ran) |
the moderator | the banned user | π§ email Β· π in-app |
content_flagged |
The filter auto-creates a Moderate::Flag (:flag mode) |
nil (system) | β (no user; it's a queue item) | π¬ admin ping |
content_removed |
Content is removed (via remove_reported_field!) |
the moderator | the content's owner | π§ email Β· π in-app |
A few notes that save you grief:
content_flaggedhas no user recipient. It's the system telling admins "something needs a look" β there's nothing to email the author. Route it to Telegram (and/or your moderation dashboard), not to a user mailer. Itsrecipientsis empty by design.report_decisionvsaffected_user_decisionare two events on purpose. The same resolution tells the reporter "we handled your report" (one tone) and the reported party "here's what we did and why, and how to appeal" (the DSA Art. 17 statement of reasons β a different tone, different legal weight). Two events let you send two different emails from one moderator click.- Blocks are usually silent. You rarely email "you've been blocked" (it invites retaliation).
user_blocked/user_unblockedexist mostly forconfig.audit, but they're here innotifytoo if your product wants an in-app signal.
Channel 1 β Email the user with goodmail
Receipts, decisions, and statements of reasons are exactly what goodmail is for: clean, single-template transactional email with zero HTML hell. Build one mailer keyed by event name and let config.notify call it.
# config/initializers/moderate.rb
config.notify = ->(event) do
case event.name
when :report_received, :report_decision, :affected_user_decision,
:appeal_received, :appeal_decision, :notice_received, :user_banned, :content_removed
Array(event.recipients).each do |recipient|
next unless recipient.respond_to?(:email) && recipient.email.present?
ModerationMailer.with(event: event, recipient: recipient)
.public_send(event.name)
.deliver_later
end
end
end# app/mailers/moderation_mailer.rb
class ModerationMailer < ApplicationMailer
# `event` and `recipient` arrive via `.with(...)` and are available as `params[:event]` / `params[:recipient]`.
def report_received
event = params[:event]
goodmail_mail(to: params[:recipient].email, from: "trust@myapp.com",
subject: "We received your report") do
h1 "Thanks β we're on it"
text "We received your report and our team is reviewing it. You don't need to do anything else."
info_row "Reference", event.subject.reference
info_row "Filed", event.subject.created_at.to_date.to_s
sign
end
end
def affected_user_decision
event = params[:event]
# DSA Art. 17 statement of reasons: action taken, the ground, automated-means flag, redress path.
goodmail_mail(to: params[:recipient].email, from: "trust@myapp.com",
subject: "An update about your content") do
h1 "We took action on your content"
text event.payload[:reason] # the human-readable ground
info_row "Action", event.payload[:action] # e.g. "Content removed"
info_row "Automated means", event.payload[:automated] ? "Yes" : "No"
space
text "If you believe this was a mistake, you can appeal β it's free and reviewed by a person."
button "Appeal this decision", event.payload[:appeal_url] # moderate mints a signed link for you
sign
end
end
def notice_received
event = params[:event]
goodmail_mail(to: params[:recipient].email, from: "legal@myapp.com",
subject: "Confirmation of your notice") do
h1 "We received your notice"
text "This confirms receipt of your notice under Article 16 of the Digital Services Act."
info_row "Reference", event.subject.reference
sign
end
end
endWhy this shape:
.with(event:, recipient:)is the standard Action Mailer params path, so the same arguments survivedeliver_laterserialization (the event's records are GlobalID-serializable AR objects).public_send(event.name)lets the event name select the mailer action βreport_receivedβ#report_receivedβ so adding a new email is "add a method," not "add a branch."goodmail_mailis goodmail's mailer helper: it renders the DSL block, wiresList-Unsubscribe, and hands a normal multipart message to Action Mailer. Use its DSL (h1,text,info_row,button,sign, β¦) instead of ERB templates.info_row/price_roware perfect for the reference/date/action rows a decision email needs.- The appeal link in a decision email is the signed link
moderatemints for you (config.signed_gid_purposesincludes:appeal); read it fromevent.payload[:appeal_url]rather than building your own route.
Tip
Per-product or per-tenant branding? goodmail_mail accepts config: { company_name:, brand_color:, logo_url: } for a scoped, thread-local override β handy if your app is white-labeled. See goodmail's "Per-message branding."
Channel 2 β Ping admins on Telegram with telegrama
This is the "tell me something happened" channel. event.payload[:summary] is built for it β a single, safe line β so the admin ping is genuinely one call:
config.notify = ->(event) do
case event.name
when :report_received, :notice_received, :content_flagged, :appeal_received
Telegrama.send_message(event.payload[:summary], formatting: { obfuscate_emails: true })
end
endThat's it. event.payload[:summary] for content_flagged reads like:
π© Auto-flagged Message #8821 β categories: hate, threats
and for report_received:
π© New harassment report on Comment #4213 by johβ¦e@example.com
obfuscate_emails: true means even if a summary or your own copy contains a user's address, telegrama redacts it (john.doe@x.com β johβ¦e@x.com) before it hits a shared admin chat. Turn it on for anything touching user PII.
The summary is the fast path. When you'd rather build the message (the way you'd alert a sale), reach into event.payload and use telegrama's MarkdownV2 formatting:
when :report_received
report = event.subject
msg = <<~MSG
π© *New report*
*Category:* #{report.category}
*On:* #{report.reportable_label}
*By:* #{event.actor&.email}
[π Open in moderation queue](#{Rails.application.routes.url_helpers.admin_report_url(report)})
MSG
Telegrama.send_message(msg, formatting: { obfuscate_emails: true })Got separate Telegram chats (or forum topics) for "trust & safety" vs "everything else"? telegrama takes chat_id: and message_thread_id: per message:
TRUST_CHAT = Rails.application.credentials.dig(:telegram, :trust_chat_id)
TRUST_TOPIC = Rails.application.credentials.dig(:telegram, :trust_topic_id)
when :content_flagged, :notice_received
Telegrama.send_message(event.payload[:summary],
chat_id: TRUST_CHAT,
message_thread_id: TRUST_TOPIC,
formatting: { obfuscate_emails: true })Tip
Enable telegrama's async delivery (config.deliver_message_async = true in config/initializers/telegrama.rb) so admin pings never block a moderation action β this is the cleanest way to keep your config.notify hook fast for the Telegram leg. telegrama also degrades gracefully (MarkdownV2 β HTML β plain text), so a weird character in a user's content can't break the alert.
Channel 3 β In-app feed + push with noticed
For decisions and outcomes that the user should see in your app's notification bell (and on their phone), noticed is the multi-channel fan-out. The event envelope drops straight into Notifier.with(...).deliver(recipients):
# config/initializers/moderate.rb
config.notify = ->(event) do
case event.name
when :report_decision, :affected_user_decision, :appeal_decision,
:user_banned, :content_removed
Moderate::DecisionNotifier.with(event: event.to_h).deliver(event.recipients)
end
end# app/notifiers/moderate/decision_notifier.rb
class Moderate::DecisionNotifier < Noticed::Event
deliver_by :database # the in-app feed
deliver_by :fcm do |config| # push to devices
config.credentials = Rails.application.credentials.fcm
config.json { |notification| { title: "Moderation update", body: params.dig(:event, :payload, :summary) } }
end
notification_methods do
def message = params.dig(:event, :payload, :summary)
def url = params.dig(:event, :payload, :url)
end
endNotes:
- Pass
event.to_h(the Hash) rather than the raw event object tonoticedβnoticedserializes params to the database, and a plain Hash of GlobalID-able records and scalars stores cleanly.event.payload[:summary]doubles as the push body. event.recipientsis already the correct audience, sodeliver(event.recipients)needs no massaging.- Same
:summaryyou used for Telegram works as the push title/body β write the human line once, reuse it across channels.
This is the RailsFast Native path:
noticed+:fcm/APNs gives you the in-app feed and native push from the same decision event, so a moderation outcome reaches the user on web and mobile without a second integration.
Here's the complete, production-shaped config.notify β email via goodmail, admin pings via telegrama, in-app + push via noticed β wired exactly once:
# config/initializers/moderate.rb
Moderate.configure do |config|
config.notify = ->(event) do
# --- 1. Email the user (goodmail) ---------------------------------------
case event.name
when :report_received, :report_decision, :affected_user_decision,
:appeal_received, :appeal_decision, :notice_received,
:user_banned, :content_removed
Array(event.recipients).each do |recipient|
next unless recipient.respond_to?(:email) && recipient.email.present?
ModerationMailer.with(event: event, recipient: recipient)
.public_send(event.name)
.deliver_later
end
end
# --- 2. Ping admins on Telegram (telegrama) -----------------------------
case event.name
when :report_received, :notice_received, :content_flagged, :appeal_received
Telegrama.send_message(event.payload[:summary], formatting: { obfuscate_emails: true })
end
# --- 3. In-app feed + push (noticed) ------------------------------------
case event.name
when :report_decision, :affected_user_decision, :appeal_decision,
:user_banned, :content_removed
Moderate::DecisionNotifier.with(event: event.to_h).deliver(event.recipients)
end
end
endEvery branch is independent: the same event can email a user and ping you on Telegram and drop into their in-app feed, or just one of those, depending on which lists you put its name in. One user-facing decision, one moderator click, three channels β no duplicate audience logic, no notification code in your models.
config.notify is for telling people. config.audit is for telling your records: an append-only log of every important action, with the same envelope, so compliance and forensics don't depend on whether an email went out.
Moderate.configure do |config|
config.audit = ->(event) do
AuditLog.record!(
event_type: event.name,
actor: event.actor,
subject: event.subject,
data: event.payload
)
end
endaudit fires for every event (including the silent ones like user_blocked that you usually don't notify on), it's append-only by intent, and it's where DSA Art. 24 transparency counters ultimately draw from (notices received, actions taken, appeal outcomes). Wire it once next to notify and you have both halves: humans get told, history gets kept.
Note
Same envelope (name / subject / actor / recipients / payload / to_h), two destinations. Keep both hooks fast and side-effect-light; push real work (emails, HTTP, heavy writes) to background jobs.
- Notifications & audit β one hook each β the README overview
- The DSA notice form β where
notice_received(the Art. 16 confirmation of receipt) comes from goodmailΒ·telegramaΒ·noticedβ the three destinations