Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
226 changes: 207 additions & 19 deletions app/models/pager_tree/integrations/email/v3.rb
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -13,11 +20,18 @@ 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 ||
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
Expand Down Expand Up @@ -45,6 +59,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

Expand All @@ -66,23 +84,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
Expand All @@ -92,27 +207,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
Expand All @@ -134,7 +298,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)
Comment on lines +301 to +303
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When custom_definition is enabled, the _body method extracts unsanitized HTML (document.at_css("body")&.inner_html || document.to_html) directly without applying the sanitization that's used when custom_definition is disabled. This unsanitized HTML body is then sent to the custom webhook service and potentially used in the description field of alerts. If the custom webhook service doesn't properly sanitize this HTML, it could lead to security issues such as XSS vulnerabilities when the alert description is displayed. Consider whether sanitization should still be applied even when using custom definitions, or ensure the downstream service properly sanitizes this content.

Suggested change
@_body = custom_definition? ?
(document.at_css("body")&.inner_html || document.to_html) :
::Sanitize.fragment(document, _sanitize_config)
html_body = document.at_css("body")&.inner_html || document.to_html
@_body = if custom_definition?
::Sanitize.fragment(html_body, _sanitize_config)
else
::Sanitize.fragment(document, _sanitize_config)
end

Copilot uses AI. Check for mistakes.
elsif _mail.multipart? && _mail.text_part
@_body = _mail_body_part_to_utf8(_mail.text_part)
else
Expand Down Expand Up @@ -198,7 +364,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)
Expand Down
24 changes: 24 additions & 0 deletions app/models/pager_tree/integrations/email/v3_examples/example.yml
Original file line number Diff line number Diff line change
@@ -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:
# event_url=https://...
# event_url=<a href="https://...">
thirdparty_id: >-
{{{itemAt (regexMatch log.body 'event_url=(?:<a\s+href=")?(https?://[^"\r\n>]+)') 1}}}

- match:
log.body:
"$regex": "event_type=Manual"
"$options": "i"
actions:
- type: "resolve"
thirdparty_id: >-
{{{itemAt (regexMatch log.body 'event_url=(?:<a\s+href=")?(https?://[^"\r\n>]+)') 1}}}
54 changes: 39 additions & 15 deletions app/views/pager_tree/integrations/email/v3/_form_options.html.erb
Original file line number Diff line number Diff line change
@@ -1,17 +1,41 @@
<div class="form-group group">
<%= form.check_box :option_allow_spam, class: "form-checkbox" %>
<%= form.label :option_allow_spam %>
<p class="form-hint md:inline-block"><%== t(".option_allow_spam_hint_html") %></p>
</div>
<%
x_data = {
option_custom_definition_enabled: form.object.option_custom_definition_enabled,
}
%>
<div x-data=<%= x_data.to_json.html_safe %>>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="form-group group">
<%= form.check_box :option_allow_spam, class: "form-checkbox" %>
<%= form.label :option_allow_spam, class: "inline-block" %>
<p class="form-hint md:inline-block"><%== t(".option_allow_spam_hint_html") %></p>
</div>

<div class="form-group group">
<%= form.check_box :option_dedup_threads, class: "form-checkbox" %>
<%= form.label :option_dedup_threads %>
<p class="form-hint"><%== t(".option_dedup_threads_hint_html") %></p>
</div>
<div class="form-group group">
<%= form.check_box :option_dedup_threads, class: "form-checkbox" %>
<%= form.label :option_dedup_threads, class: "inline-block" %>
<p class="form-hint md:inline-block"><%== t(".option_dedup_threads_hint_html") %></p>
</div>

<div class="form-group group">
<%= form.label :option_sanitize_level %>
<%= form.select :option_sanitize_level, PagerTree::Integrations::Email::V3::SANITIZE_LEVELS.map{|x| [x.humanize, x]}, {}, class:'form-control' %>
<p class="form-hint"><%== t(".option_sanitize_level_hint_html") %></p>
</div>
<div class="form-group group">
<%= form.label :option_sanitize_level %>
<%= form.select :option_sanitize_level, PagerTree::Integrations::Email::V3::SANITIZE_LEVELS.map{|x| [x.humanize, x]}, {}, class:'form-control' %>
<p class="form-hint"><%== t(".option_sanitize_level_hint_html") %></p>
</div>
</div>

<div class="form-group group">
<%= 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" %>
<p class="form-hint md:inline-block"><%== t(".option_custom_definition_enabled_hint_html") %></p>
</div>

<div class="form-group group" x-show="option_custom_definition_enabled" x-cloak x-transition>
<%= 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 %>
<p class="form-hint"><%== t(".option_custom_definition_hint_html") %></p>
<% end %>
</div>
</div>
Loading