From e67d9b5f66bc23d00a40b64b3aa70a458c53ed80 Mon Sep 17 00:00:00 2001 From: Sam Date: Thu, 11 Dec 2025 13:31:13 +1100 Subject: [PATCH] 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 --- .../discourse_automation/send_flag_email.rb | 65 +++++++++++++ .../discourse_automation/flag_mailer.rb | 17 ++++ .../models/discourse_automation/automation.rb | 1 + .../automation/config/locales/client.en.yml | 16 ++++ .../automation/config/locales/server.en.yml | 21 +++++ .../discourse_automation/event_handlers.rb | 26 ++++++ .../lib/discourse_automation/scripts.rb | 1 + .../scripts/email_on_flagged_post.rb | 44 +++++++++ .../lib/discourse_automation/triggers.rb | 1 + .../triggers/post_flag_created.rb | 17 ++++ plugins/automation/plugin.rb | 6 ++ .../send_flag_email_spec.rb | 92 +++++++++++++++++++ .../scripts/email_on_flagged_post_spec.rb | 88 ++++++++++++++++++ .../spec/triggers/post_flag_created_spec.rb | 82 +++++++++++++++++ script/i18n_lint.rb | 5 +- 15 files changed, 480 insertions(+), 2 deletions(-) create mode 100644 plugins/automation/app/jobs/regular/discourse_automation/send_flag_email.rb create mode 100644 plugins/automation/app/mailers/discourse_automation/flag_mailer.rb create mode 100644 plugins/automation/lib/discourse_automation/scripts/email_on_flagged_post.rb create mode 100644 plugins/automation/lib/discourse_automation/triggers/post_flag_created.rb create mode 100644 plugins/automation/spec/jobs/regular/discourse_automation/send_flag_email_spec.rb create mode 100644 plugins/automation/spec/scripts/email_on_flagged_post_spec.rb create mode 100644 plugins/automation/spec/triggers/post_flag_created_spec.rb diff --git a/plugins/automation/app/jobs/regular/discourse_automation/send_flag_email.rb b/plugins/automation/app/jobs/regular/discourse_automation/send_flag_email.rb new file mode 100644 index 00000000000..fcf8ce3fdf5 --- /dev/null +++ b/plugins/automation/app/jobs/regular/discourse_automation/send_flag_email.rb @@ -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 diff --git a/plugins/automation/app/mailers/discourse_automation/flag_mailer.rb b/plugins/automation/app/mailers/discourse_automation/flag_mailer.rb new file mode 100644 index 00000000000..5eb569b955c --- /dev/null +++ b/plugins/automation/app/mailers/discourse_automation/flag_mailer.rb @@ -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 diff --git a/plugins/automation/app/models/discourse_automation/automation.rb b/plugins/automation/app/models/discourse_automation/automation.rb index a70a389c01a..d84889dc107 100644 --- a/plugins/automation/app/models/discourse_automation/automation.rb +++ b/plugins/automation/app/models/discourse_automation/automation.rb @@ -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) diff --git a/plugins/automation/config/locales/client.en.yml b/plugins/automation/config/locales/client.en.yml index ec9320b058a..f0ba17e52bb 100644 --- a/plugins/automation/config/locales/client.en.yml +++ b/plugins/automation/config/locales/client.en.yml @@ -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: Couldn’t 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: Couldn’t find script `%{script}` for automation `%{automation}`, ensure the associated plugin is installed zapier_webhook: fields: diff --git a/plugins/automation/config/locales/server.en.yml b/plugins/automation/config/locales/server.en.yml index 9bbac53a215..6520eeed6a9 100644 --- a/plugins/automation/config/locales/server.en.yml +++ b/plugins/automation/config/locales/server.en.yml @@ -16,6 +16,9 @@ en: invalid_field: Field’s 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 diff --git a/plugins/automation/lib/discourse_automation/event_handlers.rb b/plugins/automation/lib/discourse_automation/event_handlers.rb index ba9630ec666..893f2526df0 100644 --- a/plugins/automation/lib/discourse_automation/event_handlers.rb +++ b/plugins/automation/lib/discourse_automation/event_handlers.rb @@ -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 diff --git a/plugins/automation/lib/discourse_automation/scripts.rb b/plugins/automation/lib/discourse_automation/scripts.rb index 20704f513bc..a3c0cd705be 100644 --- a/plugins/automation/lib/discourse_automation/scripts.rb +++ b/plugins/automation/lib/discourse_automation/scripts.rb @@ -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" diff --git a/plugins/automation/lib/discourse_automation/scripts/email_on_flagged_post.rb b/plugins/automation/lib/discourse_automation/scripts/email_on_flagged_post.rb new file mode 100644 index 00000000000..44aaef2dc86 --- /dev/null +++ b/plugins/automation/lib/discourse_automation/scripts/email_on_flagged_post.rb @@ -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 diff --git a/plugins/automation/lib/discourse_automation/triggers.rb b/plugins/automation/lib/discourse_automation/triggers.rb index 08a3a7fbb67..4c3925de847 100644 --- a/plugins/automation/lib/discourse_automation/triggers.rb +++ b/plugins/automation/lib/discourse_automation/triggers.rb @@ -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" diff --git a/plugins/automation/lib/discourse_automation/triggers/post_flag_created.rb b/plugins/automation/lib/discourse_automation/triggers/post_flag_created.rb new file mode 100644 index 00000000000..4d945294a62 --- /dev/null +++ b/plugins/automation/lib/discourse_automation/triggers/post_flag_created.rb @@ -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 diff --git a/plugins/automation/plugin.rb b/plugins/automation/plugin.rb index 2046d3ba934..9cb09a1498e 100644 --- a/plugins/automation/plugin.rb +++ b/plugins/automation/plugin.rb @@ -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 diff --git a/plugins/automation/spec/jobs/regular/discourse_automation/send_flag_email_spec.rb b/plugins/automation/spec/jobs/regular/discourse_automation/send_flag_email_spec.rb new file mode 100644 index 00000000000..a8a2a7c45ae --- /dev/null +++ b/plugins/automation/spec/jobs/regular/discourse_automation/send_flag_email_spec.rb @@ -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 diff --git a/plugins/automation/spec/scripts/email_on_flagged_post_spec.rb b/plugins/automation/spec/scripts/email_on_flagged_post_spec.rb new file mode 100644 index 00000000000..434b92610ad --- /dev/null +++ b/plugins/automation/spec/scripts/email_on_flagged_post_spec.rb @@ -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 diff --git a/plugins/automation/spec/triggers/post_flag_created_spec.rb b/plugins/automation/spec/triggers/post_flag_created_spec.rb new file mode 100644 index 00000000000..a457ee6a5b5 --- /dev/null +++ b/plugins/automation/spec/triggers/post_flag_created_spec.rb @@ -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 diff --git a/script/i18n_lint.rb b/script/i18n_lint.rb index 2e089622f33..98212682f26 100755 --- a/script/i18n_lint.rb +++ b/script/i18n_lint.rb @@ -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)