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.
Files changed (90) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +302 -0
  4. data/app/controllers/escalated/admin/bulk_actions_controller.rb +42 -0
  5. data/app/controllers/escalated/admin/canned_responses_controller.rb +73 -0
  6. data/app/controllers/escalated/admin/departments_controller.rb +135 -0
  7. data/app/controllers/escalated/admin/escalation_rules_controller.rb +121 -0
  8. data/app/controllers/escalated/admin/macros_controller.rb +73 -0
  9. data/app/controllers/escalated/admin/reports_controller.rb +152 -0
  10. data/app/controllers/escalated/admin/settings_controller.rb +111 -0
  11. data/app/controllers/escalated/admin/sla_policies_controller.rb +109 -0
  12. data/app/controllers/escalated/admin/tags_controller.rb +67 -0
  13. data/app/controllers/escalated/admin/tickets_controller.rb +299 -0
  14. data/app/controllers/escalated/agent/bulk_actions_controller.rb +42 -0
  15. data/app/controllers/escalated/agent/dashboard_controller.rb +94 -0
  16. data/app/controllers/escalated/agent/tickets_controller.rb +330 -0
  17. data/app/controllers/escalated/application_controller.rb +110 -0
  18. data/app/controllers/escalated/customer/satisfaction_ratings_controller.rb +44 -0
  19. data/app/controllers/escalated/customer/tickets_controller.rb +169 -0
  20. data/app/controllers/escalated/guest/tickets_controller.rb +231 -0
  21. data/app/controllers/escalated/inbound_controller.rb +79 -0
  22. data/app/jobs/escalated/check_sla_job.rb +36 -0
  23. data/app/jobs/escalated/close_resolved_job.rb +51 -0
  24. data/app/jobs/escalated/evaluate_escalations_job.rb +24 -0
  25. data/app/jobs/escalated/poll_imap_job.rb +74 -0
  26. data/app/jobs/escalated/purge_activities_job.rb +24 -0
  27. data/app/mailers/escalated/application_mailer.rb +6 -0
  28. data/app/mailers/escalated/ticket_mailer.rb +93 -0
  29. data/app/models/escalated/application_record.rb +5 -0
  30. data/app/models/escalated/attachment.rb +46 -0
  31. data/app/models/escalated/canned_response.rb +45 -0
  32. data/app/models/escalated/department.rb +43 -0
  33. data/app/models/escalated/escalated_setting.rb +43 -0
  34. data/app/models/escalated/escalation_rule.rb +96 -0
  35. data/app/models/escalated/inbound_email.rb +60 -0
  36. data/app/models/escalated/macro.rb +18 -0
  37. data/app/models/escalated/reply.rb +42 -0
  38. data/app/models/escalated/satisfaction_rating.rb +21 -0
  39. data/app/models/escalated/sla_policy.rb +54 -0
  40. data/app/models/escalated/tag.rb +28 -0
  41. data/app/models/escalated/ticket.rb +166 -0
  42. data/app/models/escalated/ticket_activity.rb +60 -0
  43. data/app/policies/escalated/canned_response_policy.rb +40 -0
  44. data/app/policies/escalated/department_policy.rb +36 -0
  45. data/app/policies/escalated/escalation_rule_policy.rb +36 -0
  46. data/app/policies/escalated/sla_policy_policy.rb +36 -0
  47. data/app/policies/escalated/tag_policy.rb +36 -0
  48. data/app/policies/escalated/ticket_policy.rb +111 -0
  49. data/config/routes.rb +81 -0
  50. data/db/migrate/001_create_escalated_departments.rb +18 -0
  51. data/db/migrate/002_create_escalated_sla_policies.rb +23 -0
  52. data/db/migrate/003_create_escalated_tags.rb +15 -0
  53. data/db/migrate/004_create_escalated_tickets.rb +48 -0
  54. data/db/migrate/005_create_escalated_replies.rb +21 -0
  55. data/db/migrate/006_create_escalated_attachments.rb +17 -0
  56. data/db/migrate/007_create_escalated_ticket_tags.rb +13 -0
  57. data/db/migrate/008_create_escalated_support_tables.rb +49 -0
  58. data/db/migrate/009_create_escalated_ticket_activities.rb +20 -0
  59. data/db/migrate/010_create_escalated_settings.rb +29 -0
  60. data/db/migrate/011_add_guest_fields_to_escalated_tickets.rb +28 -0
  61. data/db/migrate/012_create_escalated_inbound_emails.rb +30 -0
  62. data/db/migrate/013_create_escalated_macros.rb +18 -0
  63. data/db/migrate/014_create_escalated_ticket_followers.rb +18 -0
  64. data/db/migrate/015_create_escalated_satisfaction_ratings.rb +21 -0
  65. data/db/migrate/016_add_is_pinned_to_escalated_replies.rb +6 -0
  66. data/lib/escalated/configuration.rb +111 -0
  67. data/lib/escalated/drivers/cloud_driver.rb +134 -0
  68. data/lib/escalated/drivers/hosted_api_client.rb +166 -0
  69. data/lib/escalated/drivers/local_driver.rb +341 -0
  70. data/lib/escalated/drivers/synced_driver.rb +124 -0
  71. data/lib/escalated/engine.rb +45 -0
  72. data/lib/escalated/mail/adapters/base_adapter.rb +60 -0
  73. data/lib/escalated/mail/adapters/imap_adapter.rb +209 -0
  74. data/lib/escalated/mail/adapters/mailgun_adapter.rb +93 -0
  75. data/lib/escalated/mail/adapters/postmark_adapter.rb +94 -0
  76. data/lib/escalated/mail/adapters/ses_adapter.rb +179 -0
  77. data/lib/escalated/mail/inbound_message.rb +78 -0
  78. data/lib/escalated/manager.rb +33 -0
  79. data/lib/escalated/services/assignment_service.rb +85 -0
  80. data/lib/escalated/services/attachment_service.rb +110 -0
  81. data/lib/escalated/services/escalation_service.rb +159 -0
  82. data/lib/escalated/services/inbound_email_service.rb +255 -0
  83. data/lib/escalated/services/macro_service.rb +49 -0
  84. data/lib/escalated/services/notification_service.rb +157 -0
  85. data/lib/escalated/services/sla_service.rb +203 -0
  86. data/lib/escalated/services/ticket_service.rb +113 -0
  87. data/lib/escalated.rb +25 -0
  88. data/lib/generators/escalated/install_generator.rb +75 -0
  89. data/lib/generators/escalated/templates/initializer.rb +89 -0
  90. 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(/&nbsp;/i, " ")
69
+ text = text.gsub(/&amp;/i, "&")
70
+ text = text.gsub(/&lt;/i, "<")
71
+ text = text.gsub(/&gt;/i, ">")
72
+ text = text.gsub(/&quot;/i, '"')
73
+ text = text.gsub(/&#39;/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