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)