escalated 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +302 -0
- data/app/controllers/escalated/admin/bulk_actions_controller.rb +42 -0
- data/app/controllers/escalated/admin/canned_responses_controller.rb +73 -0
- data/app/controllers/escalated/admin/departments_controller.rb +135 -0
- data/app/controllers/escalated/admin/escalation_rules_controller.rb +121 -0
- data/app/controllers/escalated/admin/macros_controller.rb +73 -0
- data/app/controllers/escalated/admin/reports_controller.rb +152 -0
- data/app/controllers/escalated/admin/settings_controller.rb +111 -0
- data/app/controllers/escalated/admin/sla_policies_controller.rb +109 -0
- data/app/controllers/escalated/admin/tags_controller.rb +67 -0
- data/app/controllers/escalated/admin/tickets_controller.rb +299 -0
- data/app/controllers/escalated/agent/bulk_actions_controller.rb +42 -0
- data/app/controllers/escalated/agent/dashboard_controller.rb +94 -0
- data/app/controllers/escalated/agent/tickets_controller.rb +330 -0
- data/app/controllers/escalated/application_controller.rb +110 -0
- data/app/controllers/escalated/customer/satisfaction_ratings_controller.rb +44 -0
- data/app/controllers/escalated/customer/tickets_controller.rb +169 -0
- data/app/controllers/escalated/guest/tickets_controller.rb +231 -0
- data/app/controllers/escalated/inbound_controller.rb +79 -0
- data/app/jobs/escalated/check_sla_job.rb +36 -0
- data/app/jobs/escalated/close_resolved_job.rb +51 -0
- data/app/jobs/escalated/evaluate_escalations_job.rb +24 -0
- data/app/jobs/escalated/poll_imap_job.rb +74 -0
- data/app/jobs/escalated/purge_activities_job.rb +24 -0
- data/app/mailers/escalated/application_mailer.rb +6 -0
- data/app/mailers/escalated/ticket_mailer.rb +93 -0
- data/app/models/escalated/application_record.rb +5 -0
- data/app/models/escalated/attachment.rb +46 -0
- data/app/models/escalated/canned_response.rb +45 -0
- data/app/models/escalated/department.rb +43 -0
- data/app/models/escalated/escalated_setting.rb +43 -0
- data/app/models/escalated/escalation_rule.rb +96 -0
- data/app/models/escalated/inbound_email.rb +60 -0
- data/app/models/escalated/macro.rb +18 -0
- data/app/models/escalated/reply.rb +42 -0
- data/app/models/escalated/satisfaction_rating.rb +21 -0
- data/app/models/escalated/sla_policy.rb +54 -0
- data/app/models/escalated/tag.rb +28 -0
- data/app/models/escalated/ticket.rb +166 -0
- data/app/models/escalated/ticket_activity.rb +60 -0
- data/app/policies/escalated/canned_response_policy.rb +40 -0
- data/app/policies/escalated/department_policy.rb +36 -0
- data/app/policies/escalated/escalation_rule_policy.rb +36 -0
- data/app/policies/escalated/sla_policy_policy.rb +36 -0
- data/app/policies/escalated/tag_policy.rb +36 -0
- data/app/policies/escalated/ticket_policy.rb +111 -0
- data/config/routes.rb +81 -0
- data/db/migrate/001_create_escalated_departments.rb +18 -0
- data/db/migrate/002_create_escalated_sla_policies.rb +23 -0
- data/db/migrate/003_create_escalated_tags.rb +15 -0
- data/db/migrate/004_create_escalated_tickets.rb +48 -0
- data/db/migrate/005_create_escalated_replies.rb +21 -0
- data/db/migrate/006_create_escalated_attachments.rb +17 -0
- data/db/migrate/007_create_escalated_ticket_tags.rb +13 -0
- data/db/migrate/008_create_escalated_support_tables.rb +49 -0
- data/db/migrate/009_create_escalated_ticket_activities.rb +20 -0
- data/db/migrate/010_create_escalated_settings.rb +29 -0
- data/db/migrate/011_add_guest_fields_to_escalated_tickets.rb +28 -0
- data/db/migrate/012_create_escalated_inbound_emails.rb +30 -0
- data/db/migrate/013_create_escalated_macros.rb +18 -0
- data/db/migrate/014_create_escalated_ticket_followers.rb +18 -0
- data/db/migrate/015_create_escalated_satisfaction_ratings.rb +21 -0
- data/db/migrate/016_add_is_pinned_to_escalated_replies.rb +6 -0
- data/lib/escalated/configuration.rb +111 -0
- data/lib/escalated/drivers/cloud_driver.rb +134 -0
- data/lib/escalated/drivers/hosted_api_client.rb +166 -0
- data/lib/escalated/drivers/local_driver.rb +341 -0
- data/lib/escalated/drivers/synced_driver.rb +124 -0
- data/lib/escalated/engine.rb +45 -0
- data/lib/escalated/mail/adapters/base_adapter.rb +60 -0
- data/lib/escalated/mail/adapters/imap_adapter.rb +209 -0
- data/lib/escalated/mail/adapters/mailgun_adapter.rb +93 -0
- data/lib/escalated/mail/adapters/postmark_adapter.rb +94 -0
- data/lib/escalated/mail/adapters/ses_adapter.rb +179 -0
- data/lib/escalated/mail/inbound_message.rb +78 -0
- data/lib/escalated/manager.rb +33 -0
- data/lib/escalated/services/assignment_service.rb +85 -0
- data/lib/escalated/services/attachment_service.rb +110 -0
- data/lib/escalated/services/escalation_service.rb +159 -0
- data/lib/escalated/services/inbound_email_service.rb +255 -0
- data/lib/escalated/services/macro_service.rb +49 -0
- data/lib/escalated/services/notification_service.rb +157 -0
- data/lib/escalated/services/sla_service.rb +203 -0
- data/lib/escalated/services/ticket_service.rb +113 -0
- data/lib/escalated.rb +25 -0
- data/lib/generators/escalated/install_generator.rb +75 -0
- data/lib/generators/escalated/templates/initializer.rb +89 -0
- metadata +227 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
require "net/imap"
|
|
2
|
+
|
|
3
|
+
module Escalated
|
|
4
|
+
module Mail
|
|
5
|
+
module Adapters
|
|
6
|
+
class ImapAdapter < BaseAdapter
|
|
7
|
+
# The IMAP adapter does not parse HTTP requests.
|
|
8
|
+
# Instead, it provides methods to poll an IMAP mailbox.
|
|
9
|
+
#
|
|
10
|
+
# @param request [ActionDispatch::Request] unused for IMAP
|
|
11
|
+
# @return [nil]
|
|
12
|
+
def parse_request(request)
|
|
13
|
+
raise NotImplementedError,
|
|
14
|
+
"ImapAdapter does not support webhook parsing. Use #fetch_messages instead."
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# IMAP does not use webhook verification.
|
|
18
|
+
def verify_request(request)
|
|
19
|
+
false
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Connect to the configured IMAP server and fetch unread messages.
|
|
23
|
+
#
|
|
24
|
+
# @return [Array<Escalated::Mail::InboundMessage>]
|
|
25
|
+
def fetch_messages
|
|
26
|
+
messages = []
|
|
27
|
+
config = imap_config
|
|
28
|
+
|
|
29
|
+
imap = connect(config)
|
|
30
|
+
return messages unless imap
|
|
31
|
+
|
|
32
|
+
begin
|
|
33
|
+
imap.login(config[:username], config[:password])
|
|
34
|
+
imap.select(config[:mailbox])
|
|
35
|
+
|
|
36
|
+
# Search for unseen (unread) messages
|
|
37
|
+
uids = imap.uid_search(["UNSEEN"])
|
|
38
|
+
|
|
39
|
+
uids.each do |uid|
|
|
40
|
+
message = fetch_message(imap, uid)
|
|
41
|
+
messages << message if message
|
|
42
|
+
end
|
|
43
|
+
rescue Net::IMAP::Error => e
|
|
44
|
+
Rails.logger.error("[Escalated::ImapAdapter] IMAP error: #{e.message}")
|
|
45
|
+
ensure
|
|
46
|
+
begin
|
|
47
|
+
imap.logout
|
|
48
|
+
imap.disconnect
|
|
49
|
+
rescue StandardError
|
|
50
|
+
# Ignore disconnect errors
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
messages
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Mark a message as seen/read on the IMAP server.
|
|
58
|
+
#
|
|
59
|
+
# @param uid [Integer] the UID of the message to mark
|
|
60
|
+
def mark_as_read(uid)
|
|
61
|
+
config = imap_config
|
|
62
|
+
imap = connect(config)
|
|
63
|
+
return unless imap
|
|
64
|
+
|
|
65
|
+
begin
|
|
66
|
+
imap.login(config[:username], config[:password])
|
|
67
|
+
imap.select(config[:mailbox])
|
|
68
|
+
imap.uid_store(uid, "+FLAGS", [:Seen])
|
|
69
|
+
rescue Net::IMAP::Error => e
|
|
70
|
+
Rails.logger.error("[Escalated::ImapAdapter] Failed to mark message #{uid} as read: #{e.message}")
|
|
71
|
+
ensure
|
|
72
|
+
begin
|
|
73
|
+
imap.logout
|
|
74
|
+
imap.disconnect
|
|
75
|
+
rescue StandardError
|
|
76
|
+
# Ignore disconnect errors
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def imap_config
|
|
84
|
+
config = Escalated.configuration
|
|
85
|
+
{
|
|
86
|
+
host: config.imap_host,
|
|
87
|
+
port: config.imap_port || 993,
|
|
88
|
+
encryption: config.imap_encryption || :ssl,
|
|
89
|
+
username: config.imap_username,
|
|
90
|
+
password: config.imap_password,
|
|
91
|
+
mailbox: config.imap_mailbox || "INBOX"
|
|
92
|
+
}
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def connect(config)
|
|
96
|
+
return nil if config[:host].blank? || config[:username].blank? || config[:password].blank?
|
|
97
|
+
|
|
98
|
+
ssl = config[:encryption] == :ssl || config[:encryption] == :tls
|
|
99
|
+
Net::IMAP.new(config[:host], port: config[:port], ssl: ssl)
|
|
100
|
+
rescue SocketError, Errno::ECONNREFUSED, Net::IMAP::Error => e
|
|
101
|
+
Rails.logger.error("[Escalated::ImapAdapter] Connection failed: #{e.message}")
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def fetch_message(imap, uid)
|
|
106
|
+
fetch_data = imap.uid_fetch(uid, ["ENVELOPE", "BODY[TEXT]", "BODY[HEADER]", "RFC822"])
|
|
107
|
+
return nil unless fetch_data&.first
|
|
108
|
+
|
|
109
|
+
data = fetch_data.first
|
|
110
|
+
envelope = data.attr["ENVELOPE"]
|
|
111
|
+
raw_body = data.attr["BODY[TEXT]"] || ""
|
|
112
|
+
raw_headers = data.attr["BODY[HEADER]"] || ""
|
|
113
|
+
rfc822 = data.attr["RFC822"] || ""
|
|
114
|
+
|
|
115
|
+
from = envelope.from&.first
|
|
116
|
+
to = envelope.to&.first
|
|
117
|
+
|
|
118
|
+
from_email = from ? "#{from.mailbox}@#{from.host}" : nil
|
|
119
|
+
from_name = from&.name
|
|
120
|
+
to_email = to ? "#{to.mailbox}@#{to.host}" : nil
|
|
121
|
+
|
|
122
|
+
# Parse headers for In-Reply-To and References
|
|
123
|
+
headers = parse_raw_headers(raw_headers)
|
|
124
|
+
|
|
125
|
+
# Extract plain text body
|
|
126
|
+
body_text, body_html = extract_body_parts(rfc822)
|
|
127
|
+
|
|
128
|
+
message = InboundMessage.new(
|
|
129
|
+
from_email: from_email,
|
|
130
|
+
from_name: from_name,
|
|
131
|
+
to_email: to_email,
|
|
132
|
+
subject: envelope.subject || "(no subject)",
|
|
133
|
+
body_text: body_text.presence || raw_body,
|
|
134
|
+
body_html: body_html,
|
|
135
|
+
message_id: envelope.message_id,
|
|
136
|
+
in_reply_to: envelope.in_reply_to,
|
|
137
|
+
references: parse_references(headers["References"]),
|
|
138
|
+
headers: headers,
|
|
139
|
+
attachments: []
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
# Mark the message as seen after successful fetch
|
|
143
|
+
imap.uid_store(uid, "+FLAGS", [:Seen])
|
|
144
|
+
|
|
145
|
+
message
|
|
146
|
+
rescue StandardError => e
|
|
147
|
+
Rails.logger.error("[Escalated::ImapAdapter] Failed to fetch message #{uid}: #{e.message}")
|
|
148
|
+
nil
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def parse_raw_headers(raw_headers)
|
|
152
|
+
return {} if raw_headers.blank?
|
|
153
|
+
|
|
154
|
+
headers = {}
|
|
155
|
+
current_key = nil
|
|
156
|
+
current_value = nil
|
|
157
|
+
|
|
158
|
+
raw_headers.each_line do |line|
|
|
159
|
+
if line =~ /\A(\S+):\s*(.*)/
|
|
160
|
+
headers[current_key] = current_value.strip if current_key
|
|
161
|
+
current_key = $1
|
|
162
|
+
current_value = $2
|
|
163
|
+
elsif line =~ /\A\s+(.*)/
|
|
164
|
+
# Continuation of previous header
|
|
165
|
+
current_value = "#{current_value} #{$1}" if current_key
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
headers[current_key] = current_value.strip if current_key
|
|
170
|
+
headers
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def extract_body_parts(rfc822)
|
|
174
|
+
return ["", nil] if rfc822.blank?
|
|
175
|
+
|
|
176
|
+
# Simple MIME extraction — for production, use the `mail` gem
|
|
177
|
+
# This handles the common case of plain text emails
|
|
178
|
+
body_text = ""
|
|
179
|
+
body_html = nil
|
|
180
|
+
|
|
181
|
+
# Check for multipart boundary
|
|
182
|
+
if rfc822 =~ /Content-Type:.*?boundary="?([^";\s]+)"?/mi
|
|
183
|
+
boundary = $1
|
|
184
|
+
parts = rfc822.split("--#{boundary}")
|
|
185
|
+
|
|
186
|
+
parts.each do |part|
|
|
187
|
+
if part =~ /Content-Type:\s*text\/plain/i
|
|
188
|
+
body_text = extract_part_body(part)
|
|
189
|
+
elsif part =~ /Content-Type:\s*text\/html/i
|
|
190
|
+
body_html = extract_part_body(part)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
else
|
|
194
|
+
# No multipart — treat the whole body as text
|
|
195
|
+
body_text = rfc822.sub(/\A.*?\r?\n\r?\n/m, "")
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
[body_text, body_html]
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def extract_part_body(part)
|
|
202
|
+
# Skip headers, extract body after blank line
|
|
203
|
+
body = part.sub(/\A.*?\r?\n\r?\n/m, "")
|
|
204
|
+
body&.strip || ""
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
require "openssl"
|
|
2
|
+
|
|
3
|
+
module Escalated
|
|
4
|
+
module Mail
|
|
5
|
+
module Adapters
|
|
6
|
+
class MailgunAdapter < BaseAdapter
|
|
7
|
+
# Parse a Mailgun inbound webhook into an InboundMessage.
|
|
8
|
+
#
|
|
9
|
+
# Mailgun POSTs multipart form data with fields:
|
|
10
|
+
# sender, from, recipient, subject, body-plain, body-html,
|
|
11
|
+
# Message-Id, In-Reply-To, References, message-headers, etc.
|
|
12
|
+
#
|
|
13
|
+
# @param request [ActionDispatch::Request]
|
|
14
|
+
# @return [Escalated::Mail::InboundMessage]
|
|
15
|
+
def parse_request(request)
|
|
16
|
+
params = request.params
|
|
17
|
+
|
|
18
|
+
from_name, from_email = parse_from(params)
|
|
19
|
+
|
|
20
|
+
InboundMessage.new(
|
|
21
|
+
from_email: from_email,
|
|
22
|
+
from_name: from_name,
|
|
23
|
+
to_email: safe_param(params, "recipient"),
|
|
24
|
+
subject: safe_param(params, "subject", "(no subject)"),
|
|
25
|
+
body_text: safe_param(params, "body-plain"),
|
|
26
|
+
body_html: safe_param(params, "body-html"),
|
|
27
|
+
message_id: safe_param(params, "Message-Id"),
|
|
28
|
+
in_reply_to: safe_param(params, "In-Reply-To"),
|
|
29
|
+
references: parse_references(safe_param(params, "References")),
|
|
30
|
+
headers: parse_headers(safe_param(params, "message-headers")),
|
|
31
|
+
attachments: []
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Verify the Mailgun webhook signature.
|
|
36
|
+
#
|
|
37
|
+
# Mailgun sends: timestamp, token, signature
|
|
38
|
+
# Signature = HMAC-SHA256(timestamp + token, signing_key)
|
|
39
|
+
#
|
|
40
|
+
# @param request [ActionDispatch::Request]
|
|
41
|
+
# @return [Boolean]
|
|
42
|
+
def verify_request(request)
|
|
43
|
+
signing_key = Escalated.configuration.mailgun_signing_key
|
|
44
|
+
return true if signing_key.blank? # Skip verification if no key configured
|
|
45
|
+
|
|
46
|
+
params = request.params
|
|
47
|
+
timestamp = params["timestamp"].to_s
|
|
48
|
+
token = params["token"].to_s
|
|
49
|
+
signature = params["signature"].to_s
|
|
50
|
+
|
|
51
|
+
return false if timestamp.blank? || token.blank? || signature.blank?
|
|
52
|
+
|
|
53
|
+
# Reject timestamps older than 5 minutes
|
|
54
|
+
if (Time.current.to_i - timestamp.to_i).abs > 300
|
|
55
|
+
Rails.logger.warn("[Escalated::MailgunAdapter] Webhook timestamp too old: #{timestamp}")
|
|
56
|
+
return false
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
expected = OpenSSL::HMAC.hexdigest("SHA256", signing_key, "#{timestamp}#{token}")
|
|
60
|
+
ActiveSupport::SecurityUtils.secure_compare(expected, signature)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def parse_from(params)
|
|
66
|
+
from_field = safe_param(params, "from")
|
|
67
|
+
if from_field
|
|
68
|
+
parse_email_address(from_field)
|
|
69
|
+
else
|
|
70
|
+
[nil, safe_param(params, "sender")]
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def parse_headers(headers_json)
|
|
75
|
+
return {} if headers_json.blank?
|
|
76
|
+
|
|
77
|
+
parsed = begin
|
|
78
|
+
JSON.parse(headers_json)
|
|
79
|
+
rescue JSON::ParserError
|
|
80
|
+
[]
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Mailgun sends headers as [[key, value], [key, value], ...]
|
|
84
|
+
if parsed.is_a?(Array)
|
|
85
|
+
parsed.each_with_object({}) { |pair, hash| hash[pair[0]] = pair[1] if pair.is_a?(Array) && pair.size >= 2 }
|
|
86
|
+
else
|
|
87
|
+
{}
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
module Escalated
|
|
2
|
+
module Mail
|
|
3
|
+
module Adapters
|
|
4
|
+
class PostmarkAdapter < BaseAdapter
|
|
5
|
+
# Parse a Postmark inbound webhook into an InboundMessage.
|
|
6
|
+
#
|
|
7
|
+
# Postmark POSTs JSON with fields:
|
|
8
|
+
# From, FromName, FromFull, To, ToFull, Subject, TextBody, HtmlBody,
|
|
9
|
+
# MessageID, Headers, Attachments, etc.
|
|
10
|
+
#
|
|
11
|
+
# @param request [ActionDispatch::Request]
|
|
12
|
+
# @return [Escalated::Mail::InboundMessage]
|
|
13
|
+
def parse_request(request)
|
|
14
|
+
params = request.params
|
|
15
|
+
|
|
16
|
+
from_email = extract_from_email(params)
|
|
17
|
+
from_name = extract_from_name(params)
|
|
18
|
+
to_email = extract_to_email(params)
|
|
19
|
+
headers = extract_headers(params)
|
|
20
|
+
|
|
21
|
+
InboundMessage.new(
|
|
22
|
+
from_email: from_email,
|
|
23
|
+
from_name: from_name,
|
|
24
|
+
to_email: to_email,
|
|
25
|
+
subject: safe_param(params, "Subject", "(no subject)"),
|
|
26
|
+
body_text: safe_param(params, "TextBody"),
|
|
27
|
+
body_html: safe_param(params, "HtmlBody"),
|
|
28
|
+
message_id: safe_param(params, "MessageID"),
|
|
29
|
+
in_reply_to: headers["In-Reply-To"],
|
|
30
|
+
references: parse_references(headers["References"]),
|
|
31
|
+
headers: headers,
|
|
32
|
+
attachments: []
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Verify the Postmark inbound webhook.
|
|
37
|
+
#
|
|
38
|
+
# Postmark doesn't send a signature by default, but you can verify
|
|
39
|
+
# the inbound token matches the configured one.
|
|
40
|
+
#
|
|
41
|
+
# @param request [ActionDispatch::Request]
|
|
42
|
+
# @return [Boolean]
|
|
43
|
+
def verify_request(request)
|
|
44
|
+
token = Escalated.configuration.postmark_inbound_token.presence || Escalated::EscalatedSetting.get('postmark_inbound_token')
|
|
45
|
+
if token.blank?
|
|
46
|
+
Rails.logger.warn('Escalated: Postmark inbound token not configured — rejecting request.')
|
|
47
|
+
return false
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
request_token = request.headers['X-Postmark-Token'].to_s
|
|
51
|
+
ActiveSupport::SecurityUtils.secure_compare(request_token, token)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def extract_from_email(params)
|
|
57
|
+
from_full = params["FromFull"]
|
|
58
|
+
if from_full.is_a?(Hash)
|
|
59
|
+
from_full["Email"]
|
|
60
|
+
else
|
|
61
|
+
safe_param(params, "From")&.then { |f| parse_email_address(f).last }
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def extract_from_name(params)
|
|
66
|
+
from_full = params["FromFull"]
|
|
67
|
+
if from_full.is_a?(Hash)
|
|
68
|
+
from_full["Name"].presence
|
|
69
|
+
else
|
|
70
|
+
safe_param(params, "FromName")
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def extract_to_email(params)
|
|
75
|
+
to_full = params["ToFull"]
|
|
76
|
+
if to_full.is_a?(Array) && to_full.first.is_a?(Hash)
|
|
77
|
+
to_full.first["Email"]
|
|
78
|
+
else
|
|
79
|
+
safe_param(params, "To")&.then { |t| parse_email_address(t).last }
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def extract_headers(params)
|
|
84
|
+
raw_headers = params["Headers"]
|
|
85
|
+
return {} unless raw_headers.is_a?(Array)
|
|
86
|
+
|
|
87
|
+
raw_headers.each_with_object({}) do |header, hash|
|
|
88
|
+
hash[header["Name"]] = header["Value"] if header.is_a?(Hash)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "base64"
|
|
3
|
+
|
|
4
|
+
module Escalated
|
|
5
|
+
module Mail
|
|
6
|
+
module Adapters
|
|
7
|
+
class SesAdapter < BaseAdapter
|
|
8
|
+
# Parse an AWS SES/SNS inbound notification into an InboundMessage.
|
|
9
|
+
#
|
|
10
|
+
# SES sends notifications via SNS. The SNS message contains either:
|
|
11
|
+
# 1. A SubscriptionConfirmation (must be confirmed)
|
|
12
|
+
# 2. A Notification with the email content in the "Message" field
|
|
13
|
+
#
|
|
14
|
+
# The SES notification message contains:
|
|
15
|
+
# mail.source, mail.destination, mail.headers, mail.subject,
|
|
16
|
+
# content (base64 or S3 reference)
|
|
17
|
+
#
|
|
18
|
+
# For simplicity, this adapter handles the SNS notification format
|
|
19
|
+
# where SES includes parsed email data.
|
|
20
|
+
#
|
|
21
|
+
# @param request [ActionDispatch::Request]
|
|
22
|
+
# @return [Escalated::Mail::InboundMessage, nil]
|
|
23
|
+
def parse_request(request)
|
|
24
|
+
body = parse_sns_body(request)
|
|
25
|
+
return nil unless body
|
|
26
|
+
|
|
27
|
+
sns_type = body["Type"]
|
|
28
|
+
|
|
29
|
+
# Handle SNS subscription confirmation
|
|
30
|
+
if sns_type == "SubscriptionConfirmation"
|
|
31
|
+
confirm_subscription(body)
|
|
32
|
+
return nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Handle notification
|
|
36
|
+
return nil unless sns_type == "Notification"
|
|
37
|
+
|
|
38
|
+
ses_message = parse_ses_message(body["Message"])
|
|
39
|
+
return nil unless ses_message
|
|
40
|
+
|
|
41
|
+
build_inbound_message(ses_message)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Verify the SNS message signature.
|
|
45
|
+
#
|
|
46
|
+
# @param request [ActionDispatch::Request]
|
|
47
|
+
# @return [Boolean]
|
|
48
|
+
def verify_request(request)
|
|
49
|
+
topic_arn = Escalated.configuration.ses_topic_arn
|
|
50
|
+
if topic_arn.blank?
|
|
51
|
+
Rails.logger.warn('Escalated: SES Topic ARN not configured — rejecting request.')
|
|
52
|
+
return false
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
body = parse_sns_body(request)
|
|
56
|
+
return false unless body
|
|
57
|
+
|
|
58
|
+
# Verify the TopicArn matches
|
|
59
|
+
message_topic_arn = body["TopicArn"]
|
|
60
|
+
return false unless message_topic_arn == topic_arn
|
|
61
|
+
|
|
62
|
+
# SNS signature verification can be implemented here.
|
|
63
|
+
# For production, you should verify the SigningCertURL, download
|
|
64
|
+
# the certificate, and verify the Signature field.
|
|
65
|
+
# For now, we verify the TopicArn match.
|
|
66
|
+
true
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def parse_sns_body(request)
|
|
72
|
+
raw_body = request.raw_post
|
|
73
|
+
JSON.parse(raw_body)
|
|
74
|
+
rescue JSON::ParserError => e
|
|
75
|
+
Rails.logger.error("[Escalated::SesAdapter] Failed to parse SNS body: #{e.message}")
|
|
76
|
+
nil
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def confirm_subscription(body)
|
|
80
|
+
subscribe_url = body["SubscribeURL"]
|
|
81
|
+
|
|
82
|
+
unless subscribe_url.present? && valid_sns_url?(subscribe_url)
|
|
83
|
+
Rails.logger.warn("Escalated: Rejected SNS SubscribeURL — not a valid Amazon SNS URL. url=#{subscribe_url}")
|
|
84
|
+
return
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
Rails.logger.info("[Escalated::SesAdapter] Confirming SNS subscription: #{subscribe_url}")
|
|
88
|
+
Thread.new do
|
|
89
|
+
begin
|
|
90
|
+
uri = URI.parse(subscribe_url)
|
|
91
|
+
Net::HTTP.get(uri)
|
|
92
|
+
rescue StandardError => e
|
|
93
|
+
Rails.logger.error("[Escalated::SesAdapter] Failed to confirm subscription: #{e.message}")
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def valid_sns_url?(url)
|
|
99
|
+
return false if url.blank?
|
|
100
|
+
|
|
101
|
+
uri = URI.parse(url)
|
|
102
|
+
uri.scheme == 'https' && uri.host.match?(/\Asns\.[a-z0-9-]+\.amazonaws\.com\z/)
|
|
103
|
+
rescue URI::InvalidURIError
|
|
104
|
+
false
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def parse_ses_message(message_string)
|
|
108
|
+
return nil if message_string.blank?
|
|
109
|
+
|
|
110
|
+
JSON.parse(message_string)
|
|
111
|
+
rescue JSON::ParserError => e
|
|
112
|
+
Rails.logger.error("[Escalated::SesAdapter] Failed to parse SES message: #{e.message}")
|
|
113
|
+
nil
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def build_inbound_message(ses_message)
|
|
117
|
+
mail_data = ses_message["mail"] || {}
|
|
118
|
+
receipt_data = ses_message["receipt"] || {}
|
|
119
|
+
content = ses_message["content"]
|
|
120
|
+
|
|
121
|
+
headers = extract_headers(mail_data)
|
|
122
|
+
from_email = mail_data.dig("source") || headers["From"]
|
|
123
|
+
from_name = nil
|
|
124
|
+
|
|
125
|
+
# Parse the From header for a display name
|
|
126
|
+
if headers["From"].present?
|
|
127
|
+
from_name, parsed_email = parse_email_address(headers["From"])
|
|
128
|
+
from_email = parsed_email if parsed_email.present?
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
to_email = Array(mail_data["destination"]).first || headers["To"]
|
|
132
|
+
if to_email.present?
|
|
133
|
+
_, to_email = parse_email_address(to_email)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Extract body from content (if raw email is provided)
|
|
137
|
+
body_text, body_html = extract_body(content)
|
|
138
|
+
|
|
139
|
+
InboundMessage.new(
|
|
140
|
+
from_email: from_email,
|
|
141
|
+
from_name: from_name,
|
|
142
|
+
to_email: to_email,
|
|
143
|
+
subject: headers["Subject"] || mail_data.dig("commonHeaders", "subject") || "(no subject)",
|
|
144
|
+
body_text: body_text,
|
|
145
|
+
body_html: body_html,
|
|
146
|
+
message_id: mail_data["messageId"] || headers["Message-ID"],
|
|
147
|
+
in_reply_to: headers["In-Reply-To"],
|
|
148
|
+
references: parse_references(headers["References"]),
|
|
149
|
+
headers: headers,
|
|
150
|
+
attachments: []
|
|
151
|
+
)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def extract_headers(mail_data)
|
|
155
|
+
raw_headers = mail_data["headers"]
|
|
156
|
+
return {} unless raw_headers.is_a?(Array)
|
|
157
|
+
|
|
158
|
+
raw_headers.each_with_object({}) do |header, hash|
|
|
159
|
+
hash[header["name"]] = header["value"] if header.is_a?(Hash)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def extract_body(content)
|
|
164
|
+
return ["", nil] if content.blank?
|
|
165
|
+
|
|
166
|
+
# If content is a raw MIME message (base64 encoded), do basic extraction
|
|
167
|
+
# For production, consider using the `mail` gem for robust MIME parsing
|
|
168
|
+
if content.is_a?(String)
|
|
169
|
+
# Try to find text/plain and text/html parts from a raw email
|
|
170
|
+
# This is a simplified parser; production should use the `mail` gem
|
|
171
|
+
[content, nil]
|
|
172
|
+
else
|
|
173
|
+
["", nil]
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
module Escalated
|
|
2
|
+
module Mail
|
|
3
|
+
class InboundMessage
|
|
4
|
+
attr_accessor :from_email, :from_name, :to_email, :subject,
|
|
5
|
+
:body_text, :body_html, :message_id, :in_reply_to,
|
|
6
|
+
:references, :headers, :attachments
|
|
7
|
+
|
|
8
|
+
def initialize(**attrs)
|
|
9
|
+
@from_email = attrs[:from_email]
|
|
10
|
+
@from_name = attrs[:from_name]
|
|
11
|
+
@to_email = attrs[:to_email]
|
|
12
|
+
@subject = attrs[:subject]
|
|
13
|
+
@body_text = attrs[:body_text]
|
|
14
|
+
@body_html = attrs[:body_html]
|
|
15
|
+
@message_id = attrs[:message_id]
|
|
16
|
+
@in_reply_to = attrs[:in_reply_to]
|
|
17
|
+
@references = attrs[:references] || []
|
|
18
|
+
@headers = attrs[:headers] || {}
|
|
19
|
+
@attachments = attrs[:attachments] || []
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Extract ticket reference from subject line (e.g., "Re: [ESC-2602-ABC123] Original subject")
|
|
23
|
+
def ticket_reference
|
|
24
|
+
match = subject&.match(/\[([A-Z0-9]+-\d{4}-[A-Z0-9]+)\]/)
|
|
25
|
+
match ? match[1] : nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Strip the ticket reference tag from the subject for display
|
|
29
|
+
def clean_subject
|
|
30
|
+
return subject unless subject
|
|
31
|
+
|
|
32
|
+
subject.gsub(/\s*\[[A-Z0-9]+-\d{4}-[A-Z0-9]+\]\s*/, "")
|
|
33
|
+
.gsub(/\A\s*(Re|Fwd|Fw):\s*/i, "")
|
|
34
|
+
.strip
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Determine the best body content to use as reply/description text
|
|
38
|
+
def body
|
|
39
|
+
if body_text.present?
|
|
40
|
+
body_text.strip
|
|
41
|
+
elsif body_html.present?
|
|
42
|
+
strip_html(body_html).strip
|
|
43
|
+
else
|
|
44
|
+
""
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def valid?
|
|
49
|
+
from_email.present? && to_email.present? && subject.present?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def reply?
|
|
53
|
+
in_reply_to.present? || ticket_reference.present?
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def raw_headers_string
|
|
57
|
+
return "" if headers.blank?
|
|
58
|
+
|
|
59
|
+
headers.map { |k, v| "#{k}: #{v}" }.join("\n")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def strip_html(html)
|
|
65
|
+
# Basic HTML tag stripping — production systems may want a proper sanitizer
|
|
66
|
+
text = html.gsub(/<br\s*\/?>|<\/p>|<\/div>|<\/li>/i, "\n")
|
|
67
|
+
text = text.gsub(/<[^>]+>/, "")
|
|
68
|
+
text = text.gsub(/ /i, " ")
|
|
69
|
+
text = text.gsub(/&/i, "&")
|
|
70
|
+
text = text.gsub(/</i, "<")
|
|
71
|
+
text = text.gsub(/>/i, ">")
|
|
72
|
+
text = text.gsub(/"/i, '"')
|
|
73
|
+
text = text.gsub(/'/i, "'")
|
|
74
|
+
text.gsub(/\n{3,}/, "\n\n")
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
require "escalated/drivers/local_driver"
|
|
2
|
+
require "escalated/drivers/synced_driver"
|
|
3
|
+
require "escalated/drivers/cloud_driver"
|
|
4
|
+
|
|
5
|
+
module Escalated
|
|
6
|
+
class Manager
|
|
7
|
+
class << self
|
|
8
|
+
def driver
|
|
9
|
+
@driver ||= resolve_driver
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def reset_driver!
|
|
13
|
+
@driver = nil
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def resolve_driver
|
|
19
|
+
case Escalated.configuration.mode
|
|
20
|
+
when :self_hosted
|
|
21
|
+
Drivers::LocalDriver.new
|
|
22
|
+
when :synced
|
|
23
|
+
Drivers::SyncedDriver.new
|
|
24
|
+
when :cloud
|
|
25
|
+
Drivers::CloudDriver.new
|
|
26
|
+
else
|
|
27
|
+
raise ArgumentError, "Unknown Escalated mode: #{Escalated.configuration.mode}. " \
|
|
28
|
+
"Valid modes are :self_hosted, :synced, :cloud"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|