FEATURE: New automation for emailing specific users when a post is flagged (#36580)

This commit adds a new "Email on flagged post" automation which can be configured to send emails to users when a post is flagged. The automation can be configured to trigger only when posts are flagged in certain categories or only when certain tags are present on the post's topic.

The body of the email sent can also be configured with the following placeholders available: 

* topic_url
* topic_title
* post_url
* post_number
* flagger_username
* flagged_username
* flag_type
* category
* tags
* post_excerpt
This commit is contained in:
Sam
2025-12-11 10:31:13 +08:00
committed by GitHub
parent add6bdca32
commit e67d9b5f66
15 changed files with 480 additions and 2 deletions
@@ -0,0 +1,65 @@
# frozen_string_literal: true
module Jobs
module DiscourseAutomation
class SendFlagEmail < ::Jobs::Base
def execute(args)
post_action = PostAction.find_by(id: args[:post_action_id])
return if post_action.blank?
post = post_action&.post
return if post.blank?
email = args[:email]
raise Discourse::InvalidParameters.new(:email) if email.blank?
email_template =
::DiscourseAutomation::Field.where(id: args[:email_template_automation_field_id]).pick(
:metadata,
)[
"value"
]
if email_template.blank?
raise Discourse::InvalidParameters.new(:email_template_automation_field_id)
end
placeholders = build_placeholders(post_action, post)
subject =
I18n.t(
"discourse_automation.scriptables.email_on_flagged_post.subject",
topic_title: placeholders[:topic_title],
flagger_username: placeholders[:flagger_username],
)
body =
::DiscourseAutomation::Scriptable::Utils.apply_placeholders(email_template, placeholders)
::DiscourseAutomation::FlagMailer.send_flag_email(email, subject:, body:).deliver_now
end
private
def build_placeholders(post_action, post)
topic = post.topic
flagger = post_action.user
target_user = post.user
{
topic_url: topic.url,
post_url: post.full_url,
topic_title: topic&.title,
post_number: post.post_number,
flagger_username: flagger.username,
flagged_username: target_user.username,
flag_type: PostActionTypeView.new.names[post_action.post_action_type_id],
category: topic.category&.name,
tags: topic.tags&.map(&:name)&.join(", "),
site_title: SiteSetting.title,
post_excerpt: post.excerpt_for_topic,
}.compact
end
end
end
end
@@ -0,0 +1,17 @@
# frozen_string_literal: true
module DiscourseAutomation
class FlagMailer < ActionMailer::Base
include Email::BuildEmailHelper
def send_flag_email(to_address, subject:, body:)
build_email(
to_address,
subject: subject,
body: body,
add_unsubscribe_link: false,
allow_reply_by_email: false,
)
end
end
end
@@ -100,6 +100,7 @@ module DiscourseAutomation
def upsert_field!(name, component, metadata, target: "script")
field = fields.find_or_initialize_by(name: name, component: component, target: target)
field.update!(metadata: metadata)
field
end
def self.deserialize_context(context)
@@ -61,6 +61,14 @@ en:
text:
label: Text
triggerables:
post_flag_created:
fields:
categories:
label: Categories
description: Only trigger when the flagged topic is in these categories
tags:
label: Tags
description: Only trigger when the flagged topic has any of these tags
not_found: Couldnt find trigger `%{trigger}` for automation `%{automation}`, ensure the associated plugin is installed
user_badge_granted:
fields:
@@ -271,6 +279,14 @@ en:
label: Tags
description: Optional, will trigger only if the post has any of these tags
scriptables:
email_on_flagged_post:
fields:
email_template:
label: Email template
description: Supports automation placeholders
recipients:
label: Recipients
description: Users or email addresses to notify
not_found: Couldnt find script `%{script}` for automation `%{automation}`, ensure the associated plugin is installed
zapier_webhook:
fields:
@@ -16,6 +16,9 @@ en:
invalid_field: Fields component `%{component}` is not usable on `%{target}:%{target_name}`.
invalid_metadata: Data for `%{field}` is invalid or component `%{component}` is unknown.
triggerables:
post_flag_created:
title: Post flag created
description: Triggers an automation when a post is flagged
errors:
custom_fields_or_user_profile_required: "At least one of 'custom_fields' or 'user_profile' must be provided."
user_badge_granted:
@@ -74,6 +77,24 @@ en:
title: After user update
description: When a user updates any information the automation will be triggered
scriptables:
email_on_flagged_post:
title: Email on flagged post
description: Send a custom email whenever a flag is created on a post
subject: "Flagged post: %{topic_title} (%{flagger_username})"
default_template: |-
A post has been flagged.
Topic: {{topic_title}} ({{topic_url}})
Post: {{post_url}} (#{{post_number}})
Flag type: {{flag_type}}
Flagged user: {{flagged_username}}
Flagged by: {{flagger_username}}
Category: {{category}}
Tags: {{tags}}
--------
Post excerpt:
{{post_excerpt}}
post:
title: Create a post
description: Create a post on a specified topic
@@ -430,5 +430,31 @@ module DiscourseAutomation
.destroy_all
end
end
def self.handle_post_flag_created(post_action)
name = DiscourseAutomation::Triggers::POST_FLAG_CREATED
post = post_action.post
topic = post.topic
DiscourseAutomation::Automation
.where(trigger: name, enabled: true)
.find_each do |automation|
flag_type_field = automation.trigger_field("flag_type")
flag_type_id = flag_type_field["value"]
next if flag_type_id.present? && flag_type_id != post_action.post_action_type_id
categories = automation.trigger_field("categories")["value"]
next if categories.present? && !categories.include?(topic.category_id)
if SiteSetting.tagging_enabled?
tags = automation.trigger_field("tags")["value"]
next if tags.present? && (topic.tags.map(&:name) & tags).empty?
end
automation.trigger!("kind" => name, "post_action_id" => post_action.id)
end
end
end
end
@@ -9,6 +9,7 @@ module DiscourseAutomation
AUTO_TAG_TOPIC = "auto_tag_topic"
BANNER_TOPIC = "banner_topic"
CLOSE_TOPIC = "close_topic"
EMAIL_ON_FLAGGED_POST = "email_on_flagged_post"
FLAG_POST_ON_WORDS = "flag_post_on_words"
GIFT_EXCHANGE = "gift_exchange"
GROUP_CATEGORY_NOTIFICATION_DEFAULT = "group_category_notification_default"
@@ -0,0 +1,44 @@
# frozen_string_literal: true
DiscourseAutomation::Scriptable.add(DiscourseAutomation::Scripts::EMAIL_ON_FLAGGED_POST) do
version 1
run_in_background if !Rails.env.test?
triggerables [DiscourseAutomation::Triggers::POST_FLAG_CREATED]
field :email_template,
component: :message,
required: true,
accepts_placeholders: true,
default_value:
I18n.t("discourse_automation.scriptables.email_on_flagged_post.default_template")
field :recipients, component: :users, required: true
script do |context, fields, automation|
recipients = fields.dig("recipients", "value").uniq
next if recipients.blank?
to_emails = []
to_emails = to_emails.concat(recipients.select { |r| Email.is_valid?(r) })
to_users = recipients - to_emails
if to_users.present?
primary_emails = User.includes(:primary_email).filter_by_username(to_users).map(&:email)
to_emails.concat(primary_emails)
end
to_emails.uniq!
automation_email_template_field_id = automation.fields.where(name: "email_template").pick(:id)
to_emails.each do |email|
Jobs.enqueue(
Jobs::DiscourseAutomation::SendFlagEmail,
email:,
email_template_automation_field_id: automation_email_template_field_id,
post_action_id: context["post_action_id"],
)
end
end
end
@@ -5,6 +5,7 @@ module DiscourseAutomation
AFTER_POST_COOK = "after_post_cook"
API_CALL = "api_call"
CATEGORY_CREATED_EDITED = "category_created_edited"
POST_FLAG_CREATED = "post_flag_created"
PM_CREATED = "pm_created"
TOPIC_TAGS_CHANGED = "topic_tags_changed"
POINT_IN_TIME = "point_in_time"
@@ -0,0 +1,17 @@
# frozen_string_literal: true
DiscourseAutomation::Triggerable.add(DiscourseAutomation::Triggers::POST_FLAG_CREATED) do
field :categories, component: :categories
field :tags, component: :tags
placeholder :topic_url
placeholder :topic_title
placeholder :post_url
placeholder :post_number
placeholder :flagger_username
placeholder :flagged_username
placeholder :flag_type
placeholder :category
placeholder :tags
placeholder :post_excerpt
end
+6
View File
@@ -52,6 +52,7 @@ after_initialize do
lib/discourse_automation/scripts/banner_topic
lib/discourse_automation/scripts/close_topic
lib/discourse_automation/scripts/flag_post_on_words
lib/discourse_automation/scripts/email_on_flagged_post
lib/discourse_automation/scripts/gift_exchange
lib/discourse_automation/scripts/group_category_notification_default
lib/discourse_automation/scripts/pin_topic
@@ -71,6 +72,7 @@ after_initialize do
lib/discourse_automation/triggers/pm_created
lib/discourse_automation/triggers/point_in_time
lib/discourse_automation/triggers/post_created_edited
lib/discourse_automation/triggers/post_flag_created
lib/discourse_automation/triggers/recurring
lib/discourse_automation/triggers/stalled_topic
lib/discourse_automation/triggers/stalled_wiki
@@ -206,6 +208,10 @@ after_initialize do
DiscourseAutomation::EventHandlers.handle_post_created_edited(post, :edit)
end
on(:flag_created) do |post_action|
DiscourseAutomation::EventHandlers.handle_post_flag_created(post_action) if post_action.post
end
on(:category_created) do |category|
DiscourseAutomation::EventHandlers.handle_category_created_edited(category, :create)
end
@@ -0,0 +1,92 @@
# frozen_string_literal: true
describe Jobs::DiscourseAutomation::SendFlagEmail do
fab!(:topic)
fab!(:post) { Fabricate(:post, topic:) }
fab!(:email_template_automation_field) do
field = nil
Fabricate(
:automation,
script: DiscourseAutomation::Scripts::EMAIL_ON_FLAGGED_POST,
trigger: DiscourseAutomation::Triggers::POST_FLAG_CREATED,
).tap do |automation|
field =
automation.upsert_field!(
"email_template",
"message",
{
value:
I18n.t("discourse_automation.scriptables.email_on_flagged_post.default_template"),
},
)
end
field
end
fab!(:post_action) do
Fabricate(:post_action, post:, post_action_type_id: PostActionType.types[:spam])
end
describe "#execute" do
it "delivers email to the right recipient with the right subject and body" do
expect do
described_class.new.execute(
email: "recipient@example.com",
email_template_automation_field_id: email_template_automation_field.id,
post_action_id: post_action.id,
)
end.to change { ActionMailer::Base.deliveries.size }.by(1)
mail = ActionMailer::Base.deliveries.last
expect(mail.subject).to eq(
I18n.t(
"discourse_automation.scriptables.email_on_flagged_post.subject",
topic_title: topic.title,
flagger_username: post_action.user.username,
),
)
expect(mail.body.to_s).to eq(<<~BODY.chomp)
A post has been flagged.
Topic: #{topic.title} (#{topic.url})
Post: #{post.full_url} (##{post.post_number})
Flag type: #{PostActionTypeView.new.names[post_action.post_action_type_id]}
Flagged user: #{post.user.username}
Flagged by: #{post_action.user.username}
Category: #{post.topic.category.name}
Tags: #{post.topic.tags.pluck(:name).join(", ")}
--------
Post excerpt:
#{post.excerpt_for_topic}
BODY
end
it "does not deliver email if `post_action_id` is invalid" do
expect do
described_class.new.execute(
email: "recipient@example.com",
email_template_automation_field_id: email_template_automation_field.id,
post_action_id: -999_999,
)
end.not_to change { ActionMailer::Base.deliveries.size }
end
it "does not deliver email if post associated with the post action has been destroyed" do
post_action.post.destroy!
expect do
described_class.new.execute(
email: "recipient@example.com",
email_template_automation_field_id: email_template_automation_field.id,
post_action_id: post_action.id,
)
end.not_to change { ActionMailer::Base.deliveries.size }
end
end
end
@@ -0,0 +1,88 @@
# frozen_string_literal: true
describe "EmailOnFlaggedPost" do
fab!(:recipient) { Fabricate(:user, email: "recipient@example.com") }
fab!(:user_2, :user)
fab!(:flagger) { Fabricate(:user, trust_level: TrustLevel[2]) }
fab!(:second_flagger) { Fabricate(:user, trust_level: TrustLevel[2]) }
fab!(:topic)
fab!(:post) { Fabricate(:post, topic: topic) }
fab!(:automation) do
Fabricate(
:automation,
script: DiscourseAutomation::Scripts::EMAIL_ON_FLAGGED_POST,
trigger: DiscourseAutomation::Triggers::POST_FLAG_CREATED,
)
end
before do
SiteSetting.discourse_automation_enabled = true
SiteSetting.disable_emails = "no"
end
it "rejects email addresses that are invalid" do
automation.upsert_field!("recipients", "users", { value: ["@invalid-email"] })
expect do
result = PostActionCreator.spam(flagger, post)
expect(result.success).to eq(true)
end.not_to change { Jobs::DiscourseAutomation::SendFlagEmail.jobs.length }
end
it "rejects usernames that do not exist" do
automation.upsert_field!("recipients", "users", { value: ["nonexistent_user"] })
expect do
result = PostActionCreator.spam(flagger, post)
expect(result.success).to eq(true)
end.not_to change { Jobs::DiscourseAutomation::SendFlagEmail.jobs.length }
end
it "deduplicates email addresses" do
automation.upsert_field!("recipients", "users", { value: [recipient.email, recipient.email] })
expect do
result = PostActionCreator.spam(flagger, post)
expect(result.success).to eq(true)
end.to change { Jobs::DiscourseAutomation::SendFlagEmail.jobs.length }.by(1)
end
it "enqueues a job per recipient with placeholders applied" do
automation.upsert_field!("recipients", "users", { value: [recipient.email, user_2.username] })
field =
automation.upsert_field!(
"email_template",
"message",
{
value: I18n.t("discourse_automation.scriptables.email_on_flagged_post.default_template"),
},
)
result = nil
expect do
result = PostActionCreator.spam(flagger, post)
expect(result.success).to eq(true)
end.to change { Jobs::DiscourseAutomation::SendFlagEmail.jobs.length }.by(2)
expect_job_enqueued(
job: Jobs::DiscourseAutomation::SendFlagEmail,
args: {
email: recipient.email,
email_template_automation_field_id: field.id,
post_action_id: result.post_action.id,
},
)
expect_job_enqueued(
job: Jobs::DiscourseAutomation::SendFlagEmail,
args: {
email: user_2.primary_email.email,
email_template_automation_field_id: field.id,
post_action_id: result.post_action.id,
},
)
end
end
@@ -0,0 +1,82 @@
# frozen_string_literal: true
describe DiscourseAutomation::Triggers::POST_FLAG_CREATED do
fab!(:flagger) { Fabricate(:user, trust_level: TrustLevel[2]) }
fab!(:second_flagger) { Fabricate(:user, trust_level: TrustLevel[2]) }
fab!(:category)
fab!(:tag)
fab!(:topic) { Fabricate(:topic, category: category, tags: [tag]) }
fab!(:post) { Fabricate(:post, topic: topic) }
fab!(:automation) do
Fabricate(:automation, trigger: DiscourseAutomation::Triggers::POST_FLAG_CREATED)
end
before { SiteSetting.discourse_automation_enabled = true }
it "ignores the tags field if tagging is disabled" do
SiteSetting.tagging_enabled = false
automation.upsert_field!("tags", "tags", { value: [tag.name] }, target: "trigger")
expect do
result = PostActionCreator.spam(flagger, Fabricate(:post, topic: Fabricate(:topic)))
expect(result.success).to eq(true)
end.to change { automation.reload.stats.count }.by(1)
end
it "only triggers the automation if flagged post is in a topic with the specified tags defined by the tags field" do
automation.upsert_field!("tags", "tags", { value: [tag.name] }, target: "trigger")
post_action_id = nil
triggered_automations =
capture_contexts do
expect do
result = PostActionCreator.spam(flagger, post)
expect(result.success).to eq(true)
post_action_id = result.post_action.id
result = PostActionCreator.spam(flagger, Fabricate(:post, topic: Fabricate(:topic)))
expect(result.success).to eq(true)
end.to change { automation.reload.stats.count }.by(1)
end
expect(triggered_automations.length).to eq(1)
triggered_automation = triggered_automations.first
expect(triggered_automation["kind"]).to eq(DiscourseAutomation::Triggers::POST_FLAG_CREATED)
expect(triggered_automation["post_action_id"]).to eq(post_action_id)
end
it "only triggers the automation if flagged post is in a topic with the specified categories defined by the categories field" do
automation.upsert_field!(
"categories",
"categories",
{ value: [category.id] },
target: "trigger",
)
post_action_id = nil
triggered_automations =
capture_contexts do
expect do
result = PostActionCreator.spam(flagger, post)
expect(result.success).to eq(true)
post_action_id = result.post_action.id
result = PostActionCreator.spam(flagger, Fabricate(:post, topic: Fabricate(:topic)))
expect(result.success).to eq(true)
end.to change { automation.reload.stats.count }.by(1)
end
expect(triggered_automations.length).to eq(1)
triggered_automation = triggered_automations.first
expect(triggered_automation["kind"]).to eq(DiscourseAutomation::Triggers::POST_FLAG_CREATED)
expect(triggered_automation["post_action_id"]).to eq(post_action_id)
end
end
+3 -2
View File
@@ -56,8 +56,9 @@ class LocaleFileValidator
PLURALIZATION_KEYS = %w[zero one two few many other]
ENGLISH_KEYS = %w[one other]
EXEMPTED_DOUBLE_CURLY_BRACKET_KEYS = [
"js.discourse_automation.scriptables.auto_responder.fields.word_answer_list.description",
EXEMPTED_DOUBLE_CURLY_BRACKET_KEYS = %w[
js.discourse_automation.scriptables.auto_responder.fields.word_answer_list.description
discourse_automation.scriptables.email_on_flagged_post.default_template
]
def initialize(filename)