From 463265a6488b08bf095c44ce40dea10ecde7ebab Mon Sep 17 00:00:00 2001 From: Austin Miller Date: Wed, 18 Feb 2026 11:08:15 -0700 Subject: [PATCH 1/6] Adding the custom defition for email integration. Also adds tests for the email integration --- .../pager_tree/integrations/email/v3.rb | 225 +++++++++++++++-- .../email/v3/_form_options.html.erb | 7 + .../email/v3/_show_options.html.erb | 9 + config/locales/en.yml | 4 + .../pager_tree/integrations/integrations.yml | 4 + .../pager_tree/integrations/email/v3_test.rb | 234 ++++++++++++++++++ ...l_v3_custom_adapter_action_acknowledge.yml | 50 ++++ .../email_v3_custom_adapter_action_create.yml | 52 ++++ .../email_v3_custom_adapter_action_other.yml | 48 ++++ ...email_v3_custom_adapter_action_resolve.yml | 50 ++++ ...email_v3_custom_adapter_process_create.yml | 52 ++++ .../email_v3_custom_adapter_thirdparty_id.yml | 52 ++++ 12 files changed, 768 insertions(+), 19 deletions(-) create mode 100644 test/models/pager_tree/integrations/email/v3_test.rb create mode 100644 test/vcr_cassettes/email_v3_custom_adapter_action_acknowledge.yml create mode 100644 test/vcr_cassettes/email_v3_custom_adapter_action_create.yml create mode 100644 test/vcr_cassettes/email_v3_custom_adapter_action_other.yml create mode 100644 test/vcr_cassettes/email_v3_custom_adapter_action_resolve.yml create mode 100644 test/vcr_cassettes/email_v3_custom_adapter_process_create.yml create mode 100644 test/vcr_cassettes/email_v3_custom_adapter_thirdparty_id.yml diff --git a/app/models/pager_tree/integrations/email/v3.rb b/app/models/pager_tree/integrations/email/v3.rb index f11a8bc..96c6be7 100644 --- a/app/models/pager_tree/integrations/email/v3.rb +++ b/app/models/pager_tree/integrations/email/v3.rb @@ -1,9 +1,16 @@ module PagerTree::Integrations class Email::V3 < Integration + extend ::PagerTree::Integrations::Env + + # the source log (if created) - Its what shows on the integration page (different from deferred request) + attribute :adapter_source_log + OPTIONS = [ {key: :allow_spam, type: :boolean, default: false}, {key: :dedup_threads, type: :boolean, default: true}, - {key: :sanitize_level, type: :string, default: "relaxed"} + {key: :sanitize_level, type: :string, default: "relaxed"}, + {key: :custom_definition, type: :string, default: nil}, + {key: :custom_definition_enabled, type: :boolean, default: false} ] store_accessor :options, *OPTIONS.map { |x| x[:key] }.map(&:to_s), prefix: "option" @@ -14,10 +21,16 @@ class Email::V3 < Integration validates :option_dedup_threads, inclusion: {in: [true, false]} validates :option_sanitize_level, inclusion: {in: SANITIZE_LEVELS} + def self.custom_webhook_v3_service_url + ::PagerTree::Integrations.integration_custom_webhook_v3_service_url.presence || + find_value_by_name("integration_custom_webhook_v3", "service_url") + end + after_initialize do self.option_allow_spam = false if option_allow_spam.nil? self.option_dedup_threads = true if option_dedup_threads.nil? self.option_sanitize_level = "relaxed" if option_sanitize_level.nil? + self.option_custom_definition_enabled = false if option_custom_definition_enabled.nil? end # SPECIAL: override integration endpoint @@ -45,6 +58,10 @@ def endpoint end end + def custom_definition? + option_custom_definition_enabled && option_custom_definition.present? + end + def adapter_should_block? return false if option_allow_spam == true @@ -66,23 +83,120 @@ def adapter_thirdparty_id end def adapter_action - :create + if custom_definition? + case custom_response_result.dig("type")&.downcase + when "create" + :create + when "acknowledge" + :acknowledge + when "resolve" + :resolve + else + :other + end + else + :create + end end def adapter_process_create - Alert.new( - title: _title, - description: _description, - urgency: urgency, - thirdparty_id: _thirdparty_id, - dedup_keys: _dedup_keys, - additional_data: _additional_datums, - attachments: _attachments - ) + if custom_definition? + Alert.new( + title: _title, + description: _description, + urgency: _urgency, + thirdparty_id: _thirdparty_id, + dedup_keys: _dedup_keys, + incident: _incident, + incident_severity: _incident_severity, + incident_message: _incident_message, + tags: _tags, + meta: _meta, + additional_data: _additional_datums, + attachments: _attachments + ) + else + Alert.new( + title: _title, + description: _description, + urgency: urgency, + thirdparty_id: _thirdparty_id, + dedup_keys: _dedup_keys, + additional_data: _additional_datums, + attachments: _attachments + ) + end end private + def _custom_response + return @_custom_response ||= {} unless custom_definition? + + @_custom_response ||= begin + log_hash = { + subject: _mail.subject, + body: _body, + from: _mail.from, + to: _mail.to + } + + body_hash = { + log: log_hash, + config: JSON.parse(PagerTree::Integrations::FormatConverters::YamlJsonConverter.convert_to_json(option_custom_definition)) + } + + response = HTTParty.post( + self.class.custom_webhook_v3_service_url, + body: body_hash.to_json, + headers: {"Content-Type" => "application/json"}, + timeout: 2 + ) + + unless response.success? + if response.parsed_response.dig("error").present? + adapter_source_log&.sublog({ + message: "Custom Webhook Service Error:", + parsed_response: response.parsed_response + }) + adapter_source_log&.save + end + raise "Custom Webhook Service HTTP error: #{response.code} - #{response.message} - #{response.body}" + end + + adapter_source_log&.sublog({ + message: "Custom Webhook Service Response:", + parsed_response: response.parsed_response + }) + adapter_source_log&.save + + response.parsed_response + rescue JSON::ParserError => e + Rails.logger.error("Custom Webhook YAML to JSON conversion error: #{e.message}") + adapter_source_log&.sublog("Custom Webhook YAML to JSON conversion error: #{e.message}") + adapter_source_log&.save + raise "Invalid YAML configuration: #{e.message}" + rescue HTTParty::Error, SocketError, Net::OpenTimeout, Net::ReadTimeout => e + Rails.logger.error("Custom Webhook Service error: #{e.message}") + adapter_source_log&.sublog("Custom Webhook Service error: #{e.message}") + adapter_source_log&.save + raise "Custom Webhook Service error: #{e.message}" + rescue => e + Rails.logger.error("Unexpected error in Custom Webhook: #{e.message}") + adapter_source_log&.sublog("Unexpected error in Custom Webhook: #{e.message}") + adapter_source_log&.save + raise e + end + end + + def _custom_response_status + @_custom_response_status ||= _custom_response.dig("status") + end + + def custom_response_result + @_custom_response_result ||= _custom_response.dig("results")&.first || {} + end + def _mail @_mail ||= adapter_incoming_request_params.dig("mail") end @@ -92,27 +206,76 @@ def _inbound_email end def _thirdparty_id + if custom_definition? + @_thirdparty_id ||= custom_response_result.dig("thirdparty_id").to_s.presence + end + @_thirdparty_id ||= _mail.message_id || SecureRandom.uuid end def _dedup_keys - keys = [] + return @_dedup_keys if @_dedup_keys + + @_dedup_keys ||= [] if option_dedup_threads - keys.concat(Array(_thirdparty_id)) - keys.concat(Array(_mail.references)) + @_dedup_keys.concat(Array(_thirdparty_id)) + @_dedup_keys.concat(Array(_mail.references)) + end + + if custom_definition? + @_dedup_keys.concat(Array(custom_response_result.dig("dedup_keys"))) end # only dedup the references per integration. Customer like sending one email to multiple integration inboxes - keys.compact_blank.uniq.map { |x| "#{prefix_id}_#{x}" } + @_dedup_keys = @_dedup_keys.compact_blank.uniq.map { |x| "#{prefix_id}_#{x}" } end def _title - _mail.subject + if custom_definition? + @_title ||= custom_response_result.dig("title")&.to_s&.presence + end + + @_title ||= _mail.subject.to_s.presence || "Incoming Email - Untitled Alert" end def _description - _body + if custom_definition? + @_description ||= custom_response_result.dig("description")&.to_s&.presence + end + + @_description ||= _body.to_s + end + + def _urgency + if custom_definition? + @_urgency ||= custom_response_result.dig("urgency")&.to_s&.presence + end + + @_urgency ||= urgency + end + + def _incident + @_incident ||= ActiveModel::Type::Boolean.new.cast(custom_response_result.dig("incident")) + end + + def _incident_severity + custom_response_result.dig("incident_severity")&.to_s&.presence + end + + def _incident_message + custom_response_result.dig("incident_message")&.to_s&.presence + end + + def _tags + tags = custom_response_result.dig("tags") + tags = tags.split(",") if tags.is_a?(String) + Array(tags).compact_blank.map(&:to_s).uniq + end + + def _meta + meta = custom_response_result.dig("meta") + meta.is_a?(Hash) ? meta : {} end def _body @@ -134,7 +297,9 @@ def _body end end - @_body = ::Sanitize.fragment(document, _sanitize_config) + @_body = custom_definition? ? + (document.at_css("body")&.inner_html || document.to_html) : + ::Sanitize.fragment(document, _sanitize_config) elsif _mail.multipart? && _mail.text_part @_body = _mail_body_part_to_utf8(_mail.text_part) else @@ -198,7 +363,29 @@ def _attachments # TODO: Implement any additional data that should be shown in the alert with high priority (be picky as to 'very important' information) def _additional_datums - [ + return @_additional_datums if @_additional_datums + + if custom_definition? + @_additional_datums ||= begin + items = custom_response_result.dig("additional_data") || [] + items = [items] unless items.is_a?(Array) + + items.each_with_object([]) do |ad, result| + next unless ad.is_a?(Hash) + + format = ad["format"].to_s + next unless PagerTree::Integrations::AdditionalDatum::FORMATS.include?(format) + + result << AdditionalDatum.new( + format: format, + label: ad["label"].to_s.presence || "Untitled", + value: ad["value"] + ) + end + end + end + + @_additional_datums ||= [ AdditionalDatum.new(format: "email", label: "From", value: _mail.from), AdditionalDatum.new(format: "email", label: "To", value: _mail.to), AdditionalDatum.new(format: "email", label: "CCs", value: _mail.cc) diff --git a/app/views/pager_tree/integrations/email/v3/_form_options.html.erb b/app/views/pager_tree/integrations/email/v3/_form_options.html.erb index f80af33..b837aae 100644 --- a/app/views/pager_tree/integrations/email/v3/_form_options.html.erb +++ b/app/views/pager_tree/integrations/email/v3/_form_options.html.erb @@ -15,3 +15,10 @@ <%= form.select :option_sanitize_level, PagerTree::Integrations::Email::V3::SANITIZE_LEVELS.map{|x| [x.humanize, x]}, {}, class:'form-control' %>

<%== t(".option_sanitize_level_hint_html") %>

+ +<%= tag.div class: "form-group group", data: {controller: "code-editor", code_editor_language_value: "yaml", code_editor_read_only_value: false } do %> + <%= form.label :option_custom_definition %> + <%= form.hidden_field :option_custom_definition, class: "form-control", data: {code_editor_target: "form"} %> + <%= tag.div class: "h-96", data: {code_editor_target: "editor"} do %><%= form.object.option_custom_definition %><% end %> +

<%== t(".option_custom_definition_hint_html") %>

+<% end %> diff --git a/app/views/pager_tree/integrations/email/v3/_show_options.html.erb b/app/views/pager_tree/integrations/email/v3/_show_options.html.erb index 70d6300..592a383 100644 --- a/app/views/pager_tree/integrations/email/v3/_show_options.html.erb +++ b/app/views/pager_tree/integrations/email/v3/_show_options.html.erb @@ -23,4 +23,13 @@
<%= integration.option_sanitize_level.humanize %>
+ + +
+
+ <%= t("activerecord.attributes.pager_tree/integrations/email/v3.option_custom_definition") %> +
+
+ <%= render partial: "shared/components/badge_enabled", locals: { enabled: integration.option_custom_definition_enabled == true } %> +
\ No newline at end of file diff --git a/config/locales/en.yml b/config/locales/en.yml index 0d9ec43..6ddce1a 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -58,6 +58,8 @@ en: option_allow_spam_hint_html: "Allow emails marked as SPAM to create alerts" option_dedup_threads_hint_html: "Ignore emails from same thread (ex: Prevents new alerts for replys on emails (aka: RE:RE:RE...))" option_sanitize_level_hint_html: "Email HTML Sanitization level. relaxed_2 can remove style tags from Microsoft Outlook. See santize gem documentation for details." + option_custom_definition_enabled_hint_html: "Enable custom email parsing rules defined in the YAML definition" + option_custom_definition_hint_html: "YAML definition for custom email parsing rules. See docs 🔗." form: v3: form_options: @@ -217,6 +219,8 @@ en: option_allow_spam: "Allow Spam" option_dedup_threads: "Dedup Threads" option_sanitize_level: "Sanitize Level" + option_custom_definition_enabled: "Enable Custom Definition" + option_custom_definition: "YAML Custom Definition" "pager_tree/integrations/form/v3": option_form_title: "Form Title" option_form_header: "Form Header" diff --git a/test/fixtures/pager_tree/integrations/integrations.yml b/test/fixtures/pager_tree/integrations/integrations.yml index 99f2c6f..78b47c3 100644 --- a/test/fixtures/pager_tree/integrations/integrations.yml +++ b/test/fixtures/pager_tree/integrations/integrations.yml @@ -54,6 +54,10 @@ elast_alert_v3: type: "PagerTree::Integrations::ElastAlert::V3" # options: no_options +email_v3: + type: "PagerTree::Integrations::Email::V3" + # options: no_options + form_v3: type: "PagerTree::Integrations::Form::V3" options: diff --git a/test/models/pager_tree/integrations/email/v3_test.rb b/test/models/pager_tree/integrations/email/v3_test.rb new file mode 100644 index 0000000..4ba11a4 --- /dev/null +++ b/test/models/pager_tree/integrations/email/v3_test.rb @@ -0,0 +1,234 @@ +require "test_helper" + +module PagerTree::Integrations + class Email::V3Test < ActiveSupport::TestCase + include Integrateable + + setup do + @integration = pager_tree_integrations_integrations(:email_v3) + + @integration.singleton_class.class_eval do + attr_accessor :v, :urgency, :prefix_id + end + + @integration.v = 3 + @integration.urgency = "medium" + @integration.prefix_id = "int_xxxyyy" + + # Default state - no custom definition + @integration.option_custom_definition_enabled = false + @integration.option_custom_definition = nil + @integration.option_dedup_threads = true + @integration.option_allow_spam = false + @integration.option_sanitize_level = "relaxed" + + # Basic mail used for non-custom tests + @basic_mail = Mail.new do + from "sender@example.com" + to "inbox@pagertree.com" + subject "Test Subject: Incoming Alert" + body "This is the plain-text body" + message_id "basic-12345@example.com" + end + + @integration.adapter_incoming_request_params = {"mail" => @basic_mail} + + # Mails for custom-definition tests (different subjects trigger different rules) + @down_mail = Mail.new do + from "alerts@server.com" + to "inbox@pagertree.com" + subject "Server is DOWN right now" + body "Full downtime details here..." + message_id "down-98765@server.com" + end + + @ack_mail = @down_mail.dup.tap { |m| m.subject = "Server is PENDING maintenance" } + @resolve_mail = @down_mail.dup.tap { |m| m.subject = "Server is UP again" } + @other_mail = @down_mail.dup.tap { |m| m.subject = "Paused" } + end + + # =================================================================== + # Sanity / basic adapter methods + # =================================================================== + test "sanity" do + assert @integration.adapter_supports_incoming? + assert_not @integration.adapter_supports_outgoing? + assert @integration.adapter_show_alerts? + assert @integration.adapter_show_logs? + assert_not @integration.adapter_show_outgoing_webhook_delivery? + end + + test "endpoint in test environment" do + assert_match %r{_tst\+}, @integration.endpoint + assert_includes @integration.endpoint, "@" + end + + # =================================================================== + # adapter_should_block? + # =================================================================== + test "adapter_should_block? is false by default (no spam header)" do + assert_not @integration.adapter_should_block? + end + + test "adapter_should_block? returns true when SES marks as spam" do + spam_mail = @basic_mail.dup + spam_mail.header["X-SES-Spam-Verdict"] = "FAIL" + + @integration.adapter_incoming_request_params = {"mail" => spam_mail} + assert @integration.adapter_should_block? + end + + test "adapter_should_block? respects option_allow_spam = true" do + @integration.option_allow_spam = true + + spam_mail = @basic_mail.dup + spam_mail.header["X-SES-Spam-Verdict"] = "FAIL" + + @integration.adapter_incoming_request_params = {"mail" => spam_mail} + assert_not @integration.adapter_should_block? + end + + # =================================================================== + # adapter_action (non-custom) + # =================================================================== + test "adapter_action is always :create when custom_definition is disabled" do + assert_equal :create, @integration.adapter_action + end + + # =================================================================== + # Custom definition tests (uses the shared custom webhook service) + # =================================================================== + test "adapter_action_create with custom definition" do + setup_custom_definition + + VCR.use_cassette("email_v3_custom_adapter_action_create") do + @integration.adapter_incoming_request_params = {"mail" => @down_mail} + assert_equal :create, @integration.adapter_action + end + end + + test "adapter_action_acknowledge with custom definition" do + setup_custom_definition + + VCR.use_cassette("email_v3_custom_adapter_action_acknowledge") do + @integration.adapter_incoming_request_params = {"mail" => @ack_mail} + assert_equal :acknowledge, @integration.adapter_action + end + end + + test "adapter_action_resolve with custom definition" do + setup_custom_definition + + VCR.use_cassette("email_v3_custom_adapter_action_resolve") do + @integration.adapter_incoming_request_params = {"mail" => @resolve_mail} + assert_equal :resolve, @integration.adapter_action + end + end + + test "adapter_action_other with custom definition" do + setup_custom_definition + + VCR.use_cassette("email_v3_custom_adapter_action_other") do + @integration.adapter_incoming_request_params = {"mail" => @other_mail} + assert_equal :other, @integration.adapter_action + end + end + + test "adapter_thirdparty_id with custom definition" do + setup_custom_definition + + VCR.use_cassette("email_v3_custom_adapter_thirdparty_id") do + @integration.adapter_incoming_request_params = {"mail" => @down_mail} + assert_equal "email-test-123", @integration.adapter_thirdparty_id + end + end + + test "adapter_process_create default (no custom definition)" do + alert = @integration.adapter_process_create + + assert_equal "Test Subject: Incoming Alert", alert.title + assert_equal "This is the plain-text body", alert.description + assert_equal "medium", alert.urgency + assert_equal "basic-12345@example.com", alert.thirdparty_id + assert_equal 3, alert.additional_data.size + assert_equal "email", alert.additional_data.first.format + end + + test "adapter_process_create with custom definition" do + setup_custom_definition + @integration.option_dedup_threads = false # makes expected dedup_keys deterministic + + VCR.use_cassette("email_v3_custom_adapter_process_create") do + @integration.adapter_incoming_request_params = {"mail" => @down_mail} + + expected_alert = Alert.new( + title: "Server is DOWN right now", + description: "Full downtime details here...", + urgency: "high", + thirdparty_id: "email-test-123", + dedup_keys: [], # because we disabled dedup_threads + no dedup_keys in rule + incident: true, + incident_severity: "SEV-1", + incident_message: "Check the email thread", + tags: ["email", "alert"], + meta: {"source" => "email"}, + additional_data: [ + AdditionalDatum.new(format: "email", label: "From", value: "alerts@server.com") + ], + attachments: [] + ) + + assert_equal expected_alert.to_json, @integration.adapter_process_create.to_json + end + end + + private + + def setup_custom_definition + @yml_definition ||= <<~YAML + --- + rules: + - match: + log.subject: { $regex: "down", $options: "i" } + actions: + - type: create + title: "{{log.subject}}" + description: "{{log.body}}" + urgency: "high" + thirdparty_id: "email-test-123" + incident: "true" + incident_severity: "SEV-1" + incident_message: "Check the email thread" + tags: + - email + - alert + meta: + source: "email" + additional_data: + - format: email + label: From + value: "{{log.from}}" + + - match: + log.subject: { $regex: "pending", $options: "i" } + actions: + - type: acknowledge + thirdparty_id: "email-test-123" + + - match: + log.subject: { $regex: "up", $options: "i" } + actions: + - type: resolve + thirdparty_id: "email-test-123" + + - match: + log.subject: "Paused" + actions: + - type: ignore + YAML + + @integration.option_custom_definition_enabled = true + @integration.option_custom_definition = @yml_definition + end + end +end diff --git a/test/vcr_cassettes/email_v3_custom_adapter_action_acknowledge.yml b/test/vcr_cassettes/email_v3_custom_adapter_action_acknowledge.yml new file mode 100644 index 0000000..53fff1c --- /dev/null +++ b/test/vcr_cassettes/email_v3_custom_adapter_action_acknowledge.yml @@ -0,0 +1,50 @@ +--- +http_interactions: +- request: + method: post + uri: "" + body: + encoding: UTF-8 + string: '{"log":{"subject":"Server is PENDING maintenance","body":"Full downtime + details here...","from":["alerts@server.com"],"to":["inbox@pagertree.com"]},"config":{"rules":[{"match":{"log.subject":{"$regex":"down","$options":"i"}},"actions":[{"type":"create","title":"{{log.subject}}","description":"{{log.body}}","urgency":"high","thirdparty_id":"email-test-123","incident":"true","incident_severity":"SEV-1","incident_message":"Check + the email thread","tags":["email","alert"],"meta":{"source":"email"},"additional_data":[{"format":"email","label":"From","value":"{{log.from}}"}]}]},{"match":{"log.subject":{"$regex":"pending","$options":"i"}},"actions":[{"type":"acknowledge","thirdparty_id":"email-test-123"}]},{"match":{"log.subject":{"$regex":"up","$options":"i"}},"actions":[{"type":"resolve","thirdparty_id":"email-test-123"}]},{"match":{"log.subject":"Paused"},"actions":[{"type":"ignore"}]}]}}' + headers: + Content-Type: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + response: + status: + code: 200 + message: OK + headers: + X-Powered-By: + - Express + Content-Type: + - application/json; charset=utf-8 + Etag: + - W/"76-VWcm/HoTZcUVZZEqVl60+4YSOhw" + Date: + - Wed, 18 Feb 2026 18:04:07 GMT + Connection: + - keep-alive + Keep-Alive: + - timeout=5 + Server: + - Fly/84caf4a9 (2026-02-18) + Via: + - 1.1 fly.io, 1.1 fly.io + Fly-Request-Id: + - 01KHRYRWJX2M13X2AW60GWRZ8X-lax + Transfer-Encoding: + - chunked + body: + encoding: ASCII-8BIT + string: '{"results":[{"type":"acknowledge","thirdparty_id":"email-test-123"}],"status":"query + matched","processedLogData":null}' + recorded_at: Wed, 18 Feb 2026 18:04:05 GMT +recorded_with: VCR 6.0.0 diff --git a/test/vcr_cassettes/email_v3_custom_adapter_action_create.yml b/test/vcr_cassettes/email_v3_custom_adapter_action_create.yml new file mode 100644 index 0000000..a5edb1c --- /dev/null +++ b/test/vcr_cassettes/email_v3_custom_adapter_action_create.yml @@ -0,0 +1,52 @@ +--- +http_interactions: +- request: + method: post + uri: "" + body: + encoding: UTF-8 + string: '{"log":{"subject":"Server is DOWN right now","body":"Full downtime + details here...","from":["alerts@server.com"],"to":["inbox@pagertree.com"]},"config":{"rules":[{"match":{"log.subject":{"$regex":"down","$options":"i"}},"actions":[{"type":"create","title":"{{log.subject}}","description":"{{log.body}}","urgency":"high","thirdparty_id":"email-test-123","incident":"true","incident_severity":"SEV-1","incident_message":"Check + the email thread","tags":["email","alert"],"meta":{"source":"email"},"additional_data":[{"format":"email","label":"From","value":"{{log.from}}"}]}]},{"match":{"log.subject":{"$regex":"pending","$options":"i"}},"actions":[{"type":"acknowledge","thirdparty_id":"email-test-123"}]},{"match":{"log.subject":{"$regex":"up","$options":"i"}},"actions":[{"type":"resolve","thirdparty_id":"email-test-123"}]},{"match":{"log.subject":"Paused"},"actions":[{"type":"ignore"}]}]}}' + headers: + Content-Type: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + response: + status: + code: 200 + message: OK + headers: + X-Powered-By: + - Express + Content-Type: + - application/json; charset=utf-8 + Etag: + - W/"1b2-DWiqlspc76kS9yLS/fUvzB7y0SQ" + Date: + - Wed, 18 Feb 2026 18:04:07 GMT + Connection: + - keep-alive + Keep-Alive: + - timeout=5 + Server: + - Fly/84caf4a9 (2026-02-18) + Via: + - 1.1 fly.io, 1.1 fly.io + Fly-Request-Id: + - 01KHRYRX6BKXM9XSSKGS3CH1PZ-lax + Transfer-Encoding: + - chunked + body: + encoding: ASCII-8BIT + string: '{"results":[{"type":"create","title":"Server is DOWN right now","description":"Full + downtime details here...","urgency":"high","thirdparty_id":"email-test-123","incident":"true","incident_severity":"SEV-1","incident_message":"Check + the email thread","tags":["email","alert"],"meta":{"source":"email"},"additional_data":[{"format":"email","label":"From","value":"alerts@server.com"}]}],"status":"query + matched","processedLogData":null}' + recorded_at: Wed, 18 Feb 2026 18:04:06 GMT +recorded_with: VCR 6.0.0 diff --git a/test/vcr_cassettes/email_v3_custom_adapter_action_other.yml b/test/vcr_cassettes/email_v3_custom_adapter_action_other.yml new file mode 100644 index 0000000..3dff109 --- /dev/null +++ b/test/vcr_cassettes/email_v3_custom_adapter_action_other.yml @@ -0,0 +1,48 @@ +--- +http_interactions: +- request: + method: post + uri: "" + body: + encoding: UTF-8 + string: '{"log":{"subject":"Paused","body":"Full downtime details here...","from":["alerts@server.com"],"to":["inbox@pagertree.com"]},"config":{"rules":[{"match":{"log.subject":{"$regex":"down","$options":"i"}},"actions":[{"type":"create","title":"{{log.subject}}","description":"{{log.body}}","urgency":"high","thirdparty_id":"email-test-123","incident":"true","incident_severity":"SEV-1","incident_message":"Check + the email thread","tags":["email","alert"],"meta":{"source":"email"},"additional_data":[{"format":"email","label":"From","value":"{{log.from}}"}]}]},{"match":{"log.subject":{"$regex":"pending","$options":"i"}},"actions":[{"type":"acknowledge","thirdparty_id":"email-test-123"}]},{"match":{"log.subject":{"$regex":"up","$options":"i"}},"actions":[{"type":"resolve","thirdparty_id":"email-test-123"}]},{"match":{"log.subject":"Paused"},"actions":[{"type":"ignore"}]}]}}' + headers: + Content-Type: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + response: + status: + code: 200 + message: OK + headers: + X-Powered-By: + - Express + Content-Type: + - application/json; charset=utf-8 + Etag: + - W/"50-h9IvIcpHRxLjpGQ4nZkFTWqMBCw" + Date: + - Wed, 18 Feb 2026 18:04:07 GMT + Connection: + - keep-alive + Keep-Alive: + - timeout=5 + Transfer-Encoding: + - chunked + Server: + - Fly/84caf4a9 (2026-02-18) + Via: + - 1.1 fly.io, 1.1 fly.io + Fly-Request-Id: + - 01KHRYRWWF9B7D74E032N5QGZ0-lax + body: + encoding: ASCII-8BIT + string: '{"results":[{"type":"ignore"}],"status":"query matched","processedLogData":null}' + recorded_at: Wed, 18 Feb 2026 18:04:06 GMT +recorded_with: VCR 6.0.0 diff --git a/test/vcr_cassettes/email_v3_custom_adapter_action_resolve.yml b/test/vcr_cassettes/email_v3_custom_adapter_action_resolve.yml new file mode 100644 index 0000000..e62d96d --- /dev/null +++ b/test/vcr_cassettes/email_v3_custom_adapter_action_resolve.yml @@ -0,0 +1,50 @@ +--- +http_interactions: +- request: + method: post + uri: "" + body: + encoding: UTF-8 + string: '{"log":{"subject":"Server is UP again","body":"Full downtime details + here...","from":["alerts@server.com"],"to":["inbox@pagertree.com"]},"config":{"rules":[{"match":{"log.subject":{"$regex":"down","$options":"i"}},"actions":[{"type":"create","title":"{{log.subject}}","description":"{{log.body}}","urgency":"high","thirdparty_id":"email-test-123","incident":"true","incident_severity":"SEV-1","incident_message":"Check + the email thread","tags":["email","alert"],"meta":{"source":"email"},"additional_data":[{"format":"email","label":"From","value":"{{log.from}}"}]}]},{"match":{"log.subject":{"$regex":"pending","$options":"i"}},"actions":[{"type":"acknowledge","thirdparty_id":"email-test-123"}]},{"match":{"log.subject":{"$regex":"up","$options":"i"}},"actions":[{"type":"resolve","thirdparty_id":"email-test-123"}]},{"match":{"log.subject":"Paused"},"actions":[{"type":"ignore"}]}]}}' + headers: + Content-Type: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + response: + status: + code: 200 + message: OK + headers: + X-Powered-By: + - Express + Content-Type: + - application/json; charset=utf-8 + Etag: + - W/"72-il+womoQ7440/oNnEkFpPkVhswU" + Date: + - Wed, 18 Feb 2026 18:04:07 GMT + Connection: + - keep-alive + Keep-Alive: + - timeout=5 + Server: + - Fly/84caf4a9 (2026-02-18) + Via: + - 1.1 fly.io, 1.1 fly.io + Fly-Request-Id: + - 01KHRYRX391ETQJB03GNX43W8X-lax + Transfer-Encoding: + - chunked + body: + encoding: ASCII-8BIT + string: '{"results":[{"type":"resolve","thirdparty_id":"email-test-123"}],"status":"query + matched","processedLogData":null}' + recorded_at: Wed, 18 Feb 2026 18:04:06 GMT +recorded_with: VCR 6.0.0 diff --git a/test/vcr_cassettes/email_v3_custom_adapter_process_create.yml b/test/vcr_cassettes/email_v3_custom_adapter_process_create.yml new file mode 100644 index 0000000..d90c9ac --- /dev/null +++ b/test/vcr_cassettes/email_v3_custom_adapter_process_create.yml @@ -0,0 +1,52 @@ +--- +http_interactions: +- request: + method: post + uri: "" + body: + encoding: UTF-8 + string: '{"log":{"subject":"Server is DOWN right now","body":"Full downtime + details here...","from":["alerts@server.com"],"to":["inbox@pagertree.com"]},"config":{"rules":[{"match":{"log.subject":{"$regex":"down","$options":"i"}},"actions":[{"type":"create","title":"{{log.subject}}","description":"{{log.body}}","urgency":"high","thirdparty_id":"email-test-123","incident":"true","incident_severity":"SEV-1","incident_message":"Check + the email thread","tags":["email","alert"],"meta":{"source":"email"},"additional_data":[{"format":"email","label":"From","value":"{{log.from}}"}]}]},{"match":{"log.subject":{"$regex":"pending","$options":"i"}},"actions":[{"type":"acknowledge","thirdparty_id":"email-test-123"}]},{"match":{"log.subject":{"$regex":"up","$options":"i"}},"actions":[{"type":"resolve","thirdparty_id":"email-test-123"}]},{"match":{"log.subject":"Paused"},"actions":[{"type":"ignore"}]}]}}' + headers: + Content-Type: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + response: + status: + code: 200 + message: OK + headers: + X-Powered-By: + - Express + Content-Type: + - application/json; charset=utf-8 + Etag: + - W/"1b2-DWiqlspc76kS9yLS/fUvzB7y0SQ" + Date: + - Wed, 18 Feb 2026 18:04:07 GMT + Connection: + - keep-alive + Keep-Alive: + - timeout=5 + Transfer-Encoding: + - chunked + Server: + - Fly/84caf4a9 (2026-02-18) + Via: + - 1.1 fly.io, 1.1 fly.io + Fly-Request-Id: + - 01KHRYRX09AQH4M9AYD1M32GM0-lax + body: + encoding: ASCII-8BIT + string: '{"results":[{"type":"create","title":"Server is DOWN right now","description":"Full + downtime details here...","urgency":"high","thirdparty_id":"email-test-123","incident":"true","incident_severity":"SEV-1","incident_message":"Check + the email thread","tags":["email","alert"],"meta":{"source":"email"},"additional_data":[{"format":"email","label":"From","value":"alerts@server.com"}]}],"status":"query + matched","processedLogData":null}' + recorded_at: Wed, 18 Feb 2026 18:04:06 GMT +recorded_with: VCR 6.0.0 diff --git a/test/vcr_cassettes/email_v3_custom_adapter_thirdparty_id.yml b/test/vcr_cassettes/email_v3_custom_adapter_thirdparty_id.yml new file mode 100644 index 0000000..cc1f407 --- /dev/null +++ b/test/vcr_cassettes/email_v3_custom_adapter_thirdparty_id.yml @@ -0,0 +1,52 @@ +--- +http_interactions: +- request: + method: post + uri: "" + body: + encoding: UTF-8 + string: '{"log":{"subject":"Server is DOWN right now","body":"Full downtime + details here...","from":["alerts@server.com"],"to":["inbox@pagertree.com"]},"config":{"rules":[{"match":{"log.subject":{"$regex":"down","$options":"i"}},"actions":[{"type":"create","title":"{{log.subject}}","description":"{{log.body}}","urgency":"high","thirdparty_id":"email-test-123","incident":"true","incident_severity":"SEV-1","incident_message":"Check + the email thread","tags":["email","alert"],"meta":{"source":"email"},"additional_data":[{"format":"email","label":"From","value":"{{log.from}}"}]}]},{"match":{"log.subject":{"$regex":"pending","$options":"i"}},"actions":[{"type":"acknowledge","thirdparty_id":"email-test-123"}]},{"match":{"log.subject":{"$regex":"up","$options":"i"}},"actions":[{"type":"resolve","thirdparty_id":"email-test-123"}]},{"match":{"log.subject":"Paused"},"actions":[{"type":"ignore"}]}]}}' + headers: + Content-Type: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + response: + status: + code: 200 + message: OK + headers: + X-Powered-By: + - Express + Content-Type: + - application/json; charset=utf-8 + Etag: + - W/"1b2-DWiqlspc76kS9yLS/fUvzB7y0SQ" + Date: + - Wed, 18 Feb 2026 18:04:07 GMT + Connection: + - keep-alive + Keep-Alive: + - timeout=5 + Transfer-Encoding: + - chunked + Server: + - Fly/84caf4a9 (2026-02-18) + Via: + - 1.1 fly.io, 1.1 fly.io + Fly-Request-Id: + - 01KHRYRWRB13TN4YD80VHPMC81-lax + body: + encoding: ASCII-8BIT + string: '{"results":[{"type":"create","title":"Server is DOWN right now","description":"Full + downtime details here...","urgency":"high","thirdparty_id":"email-test-123","incident":"true","incident_severity":"SEV-1","incident_message":"Check + the email thread","tags":["email","alert"],"meta":{"source":"email"},"additional_data":[{"format":"email","label":"From","value":"alerts@server.com"}]}],"status":"query + matched","processedLogData":null}' + recorded_at: Wed, 18 Feb 2026 18:04:06 GMT +recorded_with: VCR 6.0.0 From e0007296ed2178e09c1050d084a525a182b14703 Mon Sep 17 00:00:00 2001 From: Austin Miller Date: Wed, 18 Feb 2026 11:30:29 -0700 Subject: [PATCH 2/6] Add the option to enable disable definition --- .../email/v3/_form_options.html.erb | 59 ++++++++++++------- 1 file changed, 38 insertions(+), 21 deletions(-) diff --git a/app/views/pager_tree/integrations/email/v3/_form_options.html.erb b/app/views/pager_tree/integrations/email/v3/_form_options.html.erb index b837aae..7452ad0 100644 --- a/app/views/pager_tree/integrations/email/v3/_form_options.html.erb +++ b/app/views/pager_tree/integrations/email/v3/_form_options.html.erb @@ -1,24 +1,41 @@ -
- <%= form.check_box :option_allow_spam, class: "form-checkbox" %> - <%= form.label :option_allow_spam %> -

<%== t(".option_allow_spam_hint_html") %>

-
+<% + x_data = { + option_custom_definition_enabled: form.object.option_custom_definition_enabled, + } +%> +
> +
+
+ <%= form.check_box :option_allow_spam, class: "form-checkbox" %> + <%= form.label :option_allow_spam, class: "inline-block" %> +

<%== t(".option_allow_spam_hint_html") %>

+
-
- <%= form.check_box :option_dedup_threads, class: "form-checkbox" %> - <%= form.label :option_dedup_threads %> -

<%== t(".option_dedup_threads_hint_html") %>

-
+
+ <%= form.check_box :option_dedup_threads, class: "form-checkbox" %> + <%= form.label :option_dedup_threads, class: "inline-block" %> +

<%== t(".option_dedup_threads_hint_html") %>

+
-
- <%= form.label :option_sanitize_level %> - <%= form.select :option_sanitize_level, PagerTree::Integrations::Email::V3::SANITIZE_LEVELS.map{|x| [x.humanize, x]}, {}, class:'form-control' %> -

<%== t(".option_sanitize_level_hint_html") %>

-
+
+ <%= form.label :option_sanitize_level %> + <%= form.select :option_sanitize_level, PagerTree::Integrations::Email::V3::SANITIZE_LEVELS.map{|x| [x.humanize, x]}, {}, class:'form-control' %> +

<%== t(".option_sanitize_level_hint_html") %>

+
+
-<%= tag.div class: "form-group group", data: {controller: "code-editor", code_editor_language_value: "yaml", code_editor_read_only_value: false } do %> - <%= form.label :option_custom_definition %> - <%= form.hidden_field :option_custom_definition, class: "form-control", data: {code_editor_target: "form"} %> - <%= tag.div class: "h-96", data: {code_editor_target: "editor"} do %><%= form.object.option_custom_definition %><% end %> -

<%== t(".option_custom_definition_hint_html") %>

-<% end %> +
+ <%= form.check_box :option_custom_definition_enabled, class: "form-checkbox", "x-model": "option_custom_definition_enabled" %> + <%= form.label :option_custom_definition_enabled, class: "inline-block" %> +

<%== t(".option_custom_definition_enabled_hint_html") %>

+
+ +
+ <%= tag.div data: {controller: "code-editor", code_editor_language_value: "yaml", code_editor_read_only_value: false } do %> + <%= form.label :option_custom_definition %> + <%= form.hidden_field :option_custom_definition, class: "form-control", data: {code_editor_target: "form"} %> + <%= tag.div class: "h-96", data: {code_editor_target: "editor"} do %><%= form.object.option_custom_definition %><% end %> +

<%== t(".option_custom_definition_hint_html") %>

+ <% end %> +
+
\ No newline at end of file From 9b40b0e0be11318b0d7c5f8ee1b561518f772bbd Mon Sep 17 00:00:00 2001 From: Austin Miller Date: Wed, 18 Feb 2026 14:56:05 -0700 Subject: [PATCH 3/6] Add an example for the custom definition --- .../email/v3_examples/example.yml | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 app/models/pager_tree/integrations/email/v3_examples/example.yml diff --git a/app/models/pager_tree/integrations/email/v3_examples/example.yml b/app/models/pager_tree/integrations/email/v3_examples/example.yml new file mode 100644 index 0000000..59f1a09 --- /dev/null +++ b/app/models/pager_tree/integrations/email/v3_examples/example.yml @@ -0,0 +1,24 @@ +--- +rules: +- match: + log.body: + "$regex": "event_type=Automatic" + "$options": "i" + actions: + - type: "create" + title: "{{log.subject}}" + description: "{{log.body}}" + # Works for: + # nmx_event_url=https://... + # nmx_event_url= + thirdparty_id: >- + {{{itemAt (regexMatch log.body 'event_url=(?:]+)') 1}}} + +- match: + log.body: + "$regex": "event_type=Manual" + "$options": "i" + actions: + - type: "resolve" + thirdparty_id: >- + {{{itemAt (regexMatch log.body 'event_url=(?:]+)') 1}}} From c612b19e9664461262e1eb6837fa2d9cf9a707cc Mon Sep 17 00:00:00 2001 From: Austin Miller Date: Thu, 19 Feb 2026 08:16:01 -0700 Subject: [PATCH 4/6] Updating the example --- .../pager_tree/integrations/email/v3_examples/example.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/models/pager_tree/integrations/email/v3_examples/example.yml b/app/models/pager_tree/integrations/email/v3_examples/example.yml index 59f1a09..f7a42ee 100644 --- a/app/models/pager_tree/integrations/email/v3_examples/example.yml +++ b/app/models/pager_tree/integrations/email/v3_examples/example.yml @@ -9,8 +9,8 @@ rules: title: "{{log.subject}}" description: "{{log.body}}" # Works for: - # nmx_event_url=https://... - # nmx_event_url= + # event_url=https://... + # event_url= thirdparty_id: >- {{{itemAt (regexMatch log.body 'event_url=(?:]+)') 1}}} From 4ffd847a5fb1f98dfd497166c5903887bc0a7093 Mon Sep 17 00:00:00 2001 From: Austin Miller Date: Thu, 19 Feb 2026 08:40:18 -0700 Subject: [PATCH 5/6] Update the example --- .../pager_tree/integrations/email/v3_examples/example.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/pager_tree/integrations/email/v3_examples/example.yml b/app/models/pager_tree/integrations/email/v3_examples/example.yml index f7a42ee..00af524 100644 --- a/app/models/pager_tree/integrations/email/v3_examples/example.yml +++ b/app/models/pager_tree/integrations/email/v3_examples/example.yml @@ -7,7 +7,7 @@ rules: actions: - type: "create" title: "{{log.subject}}" - description: "{{log.body}}" + description: "{{{log.body}}}" # Works for: # event_url=https://... # event_url= From 46d63a0636b589722b29e62b7875c7eb122f0471 Mon Sep 17 00:00:00 2001 From: Austin Miller Date: Thu, 19 Feb 2026 09:05:12 -0700 Subject: [PATCH 6/6] Implementing Copilot Review suggestions --- app/models/pager_tree/integrations/email/v3.rb | 1 + .../pager_tree/integrations/email/v3/_form_options.html.erb | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/models/pager_tree/integrations/email/v3.rb b/app/models/pager_tree/integrations/email/v3.rb index 96c6be7..72fb724 100644 --- a/app/models/pager_tree/integrations/email/v3.rb +++ b/app/models/pager_tree/integrations/email/v3.rb @@ -20,6 +20,7 @@ class Email::V3 < Integration validates :option_allow_spam, inclusion: {in: [true, false]} validates :option_dedup_threads, inclusion: {in: [true, false]} validates :option_sanitize_level, inclusion: {in: SANITIZE_LEVELS} + validates :option_custom_definition, presence: true, if: ->(record) { record.option_custom_definition_enabled == true } def self.custom_webhook_v3_service_url ::PagerTree::Integrations.integration_custom_webhook_v3_service_url.presence || diff --git a/app/views/pager_tree/integrations/email/v3/_form_options.html.erb b/app/views/pager_tree/integrations/email/v3/_form_options.html.erb index 7452ad0..2601232 100644 --- a/app/views/pager_tree/integrations/email/v3/_form_options.html.erb +++ b/app/views/pager_tree/integrations/email/v3/_form_options.html.erb @@ -14,7 +14,7 @@
<%= form.check_box :option_dedup_threads, class: "form-checkbox" %> <%= form.label :option_dedup_threads, class: "inline-block" %> -

<%== t(".option_dedup_threads_hint_html") %>

+

<%== t(".option_dedup_threads_hint_html") %>