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,85 @@
1
+ module Escalated
2
+ module Services
3
+ class AssignmentService
4
+ class << self
5
+ def assign(ticket, agent, actor:)
6
+ TicketService.assign(ticket, agent, actor: actor)
7
+ end
8
+
9
+ def unassign(ticket, actor:)
10
+ TicketService.unassign(ticket, actor: actor)
11
+ end
12
+
13
+ def auto_assign(ticket)
14
+ return nil unless ticket.department.present?
15
+
16
+ agent = next_available_agent(ticket.department)
17
+ return nil unless agent
18
+
19
+ TicketService.assign(ticket, agent, actor: nil)
20
+ agent
21
+ end
22
+
23
+ def round_robin(department)
24
+ agents = department.agents.to_a
25
+ return nil if agents.empty?
26
+
27
+ # Find the agent with the fewest open tickets in this department
28
+ agent_loads = agents.map do |agent|
29
+ open_count = Escalated::Ticket
30
+ .by_open
31
+ .assigned_to(agent.id)
32
+ .where(department_id: department.id)
33
+ .count
34
+
35
+ { agent: agent, count: open_count }
36
+ end
37
+
38
+ # Sort by ticket count, then by last assignment time for tie-breaking
39
+ agent_loads.sort_by { |a| a[:count] }.first[:agent]
40
+ end
41
+
42
+ def reassign(ticket, new_agent, actor:)
43
+ old_agent_id = ticket.assigned_to
44
+ result = TicketService.assign(ticket, new_agent, actor: actor)
45
+
46
+ ActiveSupport::Notifications.instrument("escalated.ticket.reassigned", {
47
+ ticket: result,
48
+ from_agent_id: old_agent_id,
49
+ to_agent_id: new_agent.id
50
+ })
51
+
52
+ result
53
+ end
54
+
55
+ def bulk_assign(ticket_ids, agent, actor:)
56
+ tickets = Escalated::Ticket.where(id: ticket_ids)
57
+ results = []
58
+
59
+ tickets.each do |ticket|
60
+ results << TicketService.assign(ticket, agent, actor: actor)
61
+ end
62
+
63
+ results
64
+ end
65
+
66
+ def bulk_unassign(ticket_ids, actor:)
67
+ tickets = Escalated::Ticket.where(id: ticket_ids)
68
+ results = []
69
+
70
+ tickets.each do |ticket|
71
+ results << TicketService.unassign(ticket, actor: actor)
72
+ end
73
+
74
+ results
75
+ end
76
+
77
+ private
78
+
79
+ def next_available_agent(department)
80
+ round_robin(department)
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,110 @@
1
+ module Escalated
2
+ module Services
3
+ class AttachmentService
4
+ class TooManyAttachmentsError < StandardError; end
5
+ class FileTooLargeError < StandardError; end
6
+ class InvalidFileTypeError < StandardError; end
7
+
8
+ ALLOWED_CONTENT_TYPES = %w[
9
+ image/jpeg
10
+ image/png
11
+ image/gif
12
+ image/webp
13
+ image/svg+xml
14
+ application/pdf
15
+ application/msword
16
+ application/vnd.openxmlformats-officedocument.wordprocessingml.document
17
+ application/vnd.ms-excel
18
+ application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
19
+ text/plain
20
+ text/csv
21
+ application/zip
22
+ application/x-zip-compressed
23
+ ].freeze
24
+
25
+ class << self
26
+ def attach(attachable, files)
27
+ files = Array(files)
28
+
29
+ validate_count(attachable, files.size)
30
+
31
+ attachments = []
32
+
33
+ files.each do |file|
34
+ validate_size(file)
35
+ validate_type(file)
36
+
37
+ attachment = attachable.attachments.create!(
38
+ filename: file.original_filename,
39
+ content_type: file.content_type,
40
+ byte_size: file.size
41
+ )
42
+
43
+ attachment.file.attach(file)
44
+ attachments << attachment
45
+ end
46
+
47
+ attachments
48
+ end
49
+
50
+ def detach(attachment)
51
+ attachment.file.purge if attachment.file.attached?
52
+ attachment.destroy!
53
+ end
54
+
55
+ def purge_orphaned
56
+ # Remove attachments whose attachable has been deleted
57
+ Escalated::Attachment.where(attachable_type: nil).or(
58
+ Escalated::Attachment.where(attachable_id: nil)
59
+ ).find_each do |attachment|
60
+ detach(attachment)
61
+ end
62
+ end
63
+
64
+ def url_for(attachment, expires_in: 5.minutes)
65
+ return nil unless attachment.file.attached?
66
+
67
+ if Escalated.configuration.storage_service == :local
68
+ Rails.application.routes.url_helpers.rails_blob_path(
69
+ attachment.file,
70
+ only_path: true
71
+ )
72
+ else
73
+ attachment.file.url(expires_in: expires_in)
74
+ end
75
+ end
76
+
77
+ private
78
+
79
+ def validate_count(attachable, new_count)
80
+ existing_count = attachable.attachments.count
81
+ max = Escalated.configuration.max_attachments
82
+
83
+ if existing_count + new_count > max
84
+ raise TooManyAttachmentsError,
85
+ "Maximum #{max} attachments allowed. Currently has #{existing_count}, " \
86
+ "trying to add #{new_count}."
87
+ end
88
+ end
89
+
90
+ def validate_size(file)
91
+ max_bytes = Escalated.configuration.max_attachment_size_kb * 1024
92
+
93
+ if file.size > max_bytes
94
+ raise FileTooLargeError,
95
+ "File '#{file.original_filename}' is #{(file.size / 1024.0).round(1)} KB. " \
96
+ "Maximum allowed is #{Escalated.configuration.max_attachment_size_kb} KB."
97
+ end
98
+ end
99
+
100
+ def validate_type(file)
101
+ unless ALLOWED_CONTENT_TYPES.include?(file.content_type)
102
+ raise InvalidFileTypeError,
103
+ "File type '#{file.content_type}' is not allowed. " \
104
+ "Allowed types: #{ALLOWED_CONTENT_TYPES.join(', ')}"
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,159 @@
1
+ module Escalated
2
+ module Services
3
+ class EscalationService
4
+ class << self
5
+ def evaluate_all
6
+ rules = Escalated::EscalationRule.active.ordered
7
+ tickets = Escalated::Ticket.by_open
8
+ escalated_tickets = []
9
+
10
+ tickets.find_each do |ticket|
11
+ rules.each do |rule|
12
+ if rule.matches?(ticket)
13
+ execute_actions(ticket, rule)
14
+ escalated_tickets << { ticket: ticket, rule: rule }
15
+ break # Only apply the first matching rule per ticket
16
+ end
17
+ end
18
+ end
19
+
20
+ escalated_tickets
21
+ end
22
+
23
+ def evaluate_ticket(ticket)
24
+ rules = Escalated::EscalationRule.active.ordered
25
+
26
+ rules.each do |rule|
27
+ if rule.matches?(ticket)
28
+ execute_actions(ticket, rule)
29
+ return rule
30
+ end
31
+ end
32
+
33
+ nil
34
+ end
35
+
36
+ def execute_actions(ticket, rule)
37
+ actions = rule.actions
38
+ return unless actions.is_a?(Hash)
39
+
40
+ ActiveRecord::Base.transaction do
41
+ change_priority_action(ticket, actions["change_priority"]) if actions["change_priority"]
42
+ change_status_action(ticket, actions["change_status"]) if actions["change_status"]
43
+ assign_agent_action(ticket, actions["assign_to_agent_id"]) if actions["assign_to_agent_id"]
44
+ assign_department_action(ticket, actions["assign_to_department_id"]) if actions["assign_to_department_id"]
45
+ add_tags_action(ticket, actions["add_tags"]) if actions["add_tags"]
46
+ add_note_action(ticket, actions["add_internal_note"]) if actions["add_internal_note"]
47
+
48
+ log_escalation(ticket, rule)
49
+ end
50
+
51
+ if actions["send_notification"]
52
+ send_escalation_notification(ticket, rule, actions["notification_recipients"])
53
+ end
54
+
55
+ ActiveSupport::Notifications.instrument("escalated.ticket.escalated", {
56
+ ticket: ticket,
57
+ rule: rule
58
+ })
59
+ end
60
+
61
+ private
62
+
63
+ def change_priority_action(ticket, new_priority)
64
+ old_priority = ticket.priority
65
+ ticket.update!(priority: new_priority)
66
+
67
+ ticket.activities.create!(
68
+ action: "priority_changed",
69
+ causer: nil,
70
+ details: { from: old_priority, to: new_priority, reason: "escalation_rule" }
71
+ )
72
+ end
73
+
74
+ def change_status_action(ticket, new_status)
75
+ old_status = ticket.status
76
+ ticket.update!(status: new_status)
77
+
78
+ ticket.activities.create!(
79
+ action: "status_changed",
80
+ causer: nil,
81
+ details: { from: old_status, to: new_status, reason: "escalation_rule" }
82
+ )
83
+ end
84
+
85
+ def assign_agent_action(ticket, agent_id)
86
+ agent = Escalated.configuration.user_model.find_by(id: agent_id)
87
+ return unless agent
88
+
89
+ old_assignee = ticket.assigned_to
90
+ ticket.update!(assigned_to: agent.id)
91
+
92
+ ticket.activities.create!(
93
+ action: "ticket_assigned",
94
+ causer: nil,
95
+ details: { from_agent_id: old_assignee, to_agent_id: agent.id, reason: "escalation_rule" }
96
+ )
97
+ end
98
+
99
+ def assign_department_action(ticket, department_id)
100
+ department = Escalated::Department.find_by(id: department_id)
101
+ return unless department
102
+
103
+ old_department = ticket.department_id
104
+ ticket.update!(department_id: department.id)
105
+
106
+ ticket.activities.create!(
107
+ action: "department_changed",
108
+ causer: nil,
109
+ details: { from_department_id: old_department, to_department_id: department.id, reason: "escalation_rule" }
110
+ )
111
+ end
112
+
113
+ def add_tags_action(ticket, tag_names)
114
+ return unless tag_names.is_a?(Array)
115
+
116
+ tag_names.each do |name|
117
+ tag = Escalated::Tag.find_or_create_by!(name: name) do |t|
118
+ t.slug = name.parameterize
119
+ end
120
+ ticket.tags << tag unless ticket.tags.include?(tag)
121
+ end
122
+ end
123
+
124
+ def add_note_action(ticket, note_body)
125
+ ticket.replies.create!(
126
+ body: note_body,
127
+ author: nil,
128
+ is_internal: true,
129
+ is_system: true
130
+ )
131
+ end
132
+
133
+ def log_escalation(ticket, rule)
134
+ ticket.activities.create!(
135
+ action: "ticket_escalated",
136
+ causer: nil,
137
+ details: {
138
+ rule_id: rule.id,
139
+ rule_name: rule.name,
140
+ actions_applied: rule.actions.keys
141
+ }
142
+ )
143
+ end
144
+
145
+ def send_escalation_notification(ticket, rule, recipients)
146
+ if Escalated.configuration.notification_channels.include?(:email)
147
+ Escalated::TicketMailer.ticket_escalated(ticket, rule).deliver_later
148
+ end
149
+
150
+ NotificationService.dispatch(:ticket_escalated, {
151
+ ticket: ticket,
152
+ rule: rule,
153
+ recipients: recipients
154
+ })
155
+ end
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,255 @@
1
+ module Escalated
2
+ module Services
3
+ class InboundEmailService
4
+ ALLOWED_TAGS = %w[p br b strong i em u a ul ol li h1 h2 h3 h4 h5 h6 blockquote pre code table thead tbody tr th td img hr div span sub sup].freeze
5
+
6
+ BLOCKED_EXTENSIONS = %w[
7
+ exe bat cmd com msi scr pif vbs vbe
8
+ js jse wsf wsh ps1 psm1 psd1 reg
9
+ cpl hta inf lnk sct shb sys drv
10
+ php phtml php3 php4 php5 phar
11
+ sh bash csh ksh pl py rb
12
+ dll so dylib
13
+ ].freeze
14
+
15
+ class << self
16
+ # Process an inbound email message and create/reply to a ticket.
17
+ #
18
+ # @param message [Escalated::Mail::InboundMessage] the parsed email message
19
+ # @param adapter_name [String] the name of the adapter that parsed this message
20
+ # @return [Escalated::InboundEmail] the inbound email record
21
+ def process(message, adapter_name: "unknown")
22
+ unless Escalated.configuration.inbound_email_enabled
23
+ Rails.logger.info("[Escalated::InboundEmailService] Inbound email is disabled, skipping")
24
+ return nil
25
+ end
26
+
27
+ unless message.valid?
28
+ Rails.logger.warn("[Escalated::InboundEmailService] Invalid message: missing required fields")
29
+ return nil
30
+ end
31
+
32
+ # Create the inbound email record for tracking
33
+ inbound_email = create_inbound_record(message, adapter_name)
34
+
35
+ # Check for duplicate by message_id
36
+ if inbound_email.duplicate?
37
+ Rails.logger.info("[Escalated::InboundEmailService] Duplicate message_id: #{message.message_id}")
38
+ inbound_email.mark_failed!("Duplicate message_id: #{message.message_id}")
39
+ return inbound_email
40
+ end
41
+
42
+ begin
43
+ ActiveRecord::Base.transaction do
44
+ ticket, reply = resolve_and_process(message)
45
+ inbound_email.mark_processed!(ticket: ticket, reply: reply)
46
+ end
47
+ rescue StandardError => e
48
+ Rails.logger.error(
49
+ "[Escalated::InboundEmailService] Failed to process message: #{e.message}\n#{e.backtrace&.first(5)&.join("\n")}"
50
+ )
51
+ inbound_email.mark_failed!(e.message)
52
+ end
53
+
54
+ inbound_email
55
+ end
56
+
57
+ private
58
+
59
+ # Create the inbound email tracking record.
60
+ def create_inbound_record(message, adapter_name)
61
+ Escalated::InboundEmail.create!(
62
+ message_id: message.message_id,
63
+ from_email: message.from_email,
64
+ from_name: message.from_name,
65
+ to_email: message.to_email,
66
+ subject: message.subject,
67
+ body_text: message.body_text,
68
+ body_html: sanitize_html(message.body_html),
69
+ raw_headers: message.raw_headers_string,
70
+ adapter: adapter_name,
71
+ status: :pending
72
+ )
73
+ end
74
+
75
+ # Determine whether this is a reply to an existing ticket or a new ticket,
76
+ # then process accordingly.
77
+ #
78
+ # @return [Array(Ticket, Reply|nil)] the ticket and optional reply
79
+ def resolve_and_process(message)
80
+ # Try to find an existing ticket by subject reference
81
+ ticket = find_existing_ticket(message)
82
+
83
+ if ticket
84
+ reply = add_reply_to_ticket(ticket, message)
85
+ [ticket, reply]
86
+ else
87
+ ticket = create_new_ticket(message)
88
+ [ticket, nil]
89
+ end
90
+ end
91
+
92
+ # Search for an existing ticket using:
93
+ # 1. Subject line reference tag (e.g., [ESC-2602-ABC123])
94
+ # 2. In-Reply-To / References headers matching previous message IDs
95
+ #
96
+ # @return [Escalated::Ticket, nil]
97
+ def find_existing_ticket(message)
98
+ # Strategy 1: Look for ticket reference in subject
99
+ reference = message.ticket_reference
100
+ if reference.present?
101
+ ticket = Escalated::Ticket.find_by(reference: reference)
102
+ return ticket if ticket
103
+ end
104
+
105
+ # Strategy 2: Look up by In-Reply-To matching a previous inbound email
106
+ if message.in_reply_to.present?
107
+ previous = Escalated::InboundEmail.find_by(message_id: message.in_reply_to)
108
+ return previous.ticket if previous&.ticket
109
+ end
110
+
111
+ # Strategy 3: Look up by References header
112
+ if message.references.present?
113
+ message.references.reverse_each do |ref|
114
+ previous = Escalated::InboundEmail.find_by(message_id: ref)
115
+ return previous.ticket if previous&.ticket
116
+ end
117
+ end
118
+
119
+ nil
120
+ end
121
+
122
+ # Add a reply to an existing ticket.
123
+ # Look up the user by email; if not found, treat as guest reply.
124
+ #
125
+ # @return [Escalated::Reply]
126
+ def add_reply_to_ticket(ticket, message)
127
+ author = find_user_by_email(message.from_email)
128
+ body = get_sanitized_body(message)
129
+
130
+ if body.blank?
131
+ body = "(empty reply from #{message.from_email})"
132
+ end
133
+
134
+ reply = Services::TicketService.reply(ticket, {
135
+ body: body,
136
+ author: author,
137
+ is_internal: false,
138
+ is_system: false
139
+ })
140
+
141
+ Rails.logger.info(
142
+ "[Escalated::InboundEmailService] Added reply to ticket #{ticket.reference} from #{message.from_email}"
143
+ )
144
+
145
+ reply
146
+ end
147
+
148
+ # Create a new ticket from the inbound email.
149
+ # Look up the user by email; if not found, create as guest ticket.
150
+ #
151
+ # @return [Escalated::Ticket]
152
+ def create_new_ticket(message)
153
+ user = find_user_by_email(message.from_email)
154
+ subject = message.clean_subject.presence || message.subject
155
+ description = get_sanitized_body(message)
156
+
157
+ if description.blank?
158
+ description = "(no content)"
159
+ end
160
+
161
+ if user
162
+ # Authenticated user ticket
163
+ ticket = Services::TicketService.create(
164
+ subject: subject,
165
+ description: description,
166
+ priority: Escalated.configuration.default_priority,
167
+ requester: user,
168
+ metadata: { channel: "email", original_message_id: message.message_id }
169
+ )
170
+ else
171
+ # Guest ticket (follows guest/tickets_controller.rb pattern)
172
+ guest_token = SecureRandom.hex(32)
173
+
174
+ ticket = Escalated::Ticket.create!(
175
+ requester: nil,
176
+ guest_name: message.from_name || message.from_email,
177
+ guest_email: message.from_email,
178
+ guest_token: guest_token,
179
+ subject: subject,
180
+ description: description,
181
+ priority: Escalated.configuration.default_priority,
182
+ metadata: { channel: "email", original_message_id: message.message_id }
183
+ )
184
+
185
+ # Dispatch notifications manually since we bypassed TicketService.create
186
+ Services::NotificationService.dispatch(:ticket_created, ticket: ticket)
187
+ end
188
+
189
+ Rails.logger.info(
190
+ "[Escalated::InboundEmailService] Created ticket #{ticket.reference} from #{message.from_email}" \
191
+ "#{user ? '' : ' (guest)'}"
192
+ )
193
+
194
+ ticket
195
+ end
196
+
197
+ def sanitize_html(html)
198
+ return html if html.blank?
199
+
200
+ # Use Rails' built-in sanitizer if available
201
+ if defined?(ActionView::Base)
202
+ ActionView::Base.safe_list_sanitizer.new.sanitize(
203
+ html,
204
+ tags: ALLOWED_TAGS,
205
+ attributes: %w[href src alt title class style id]
206
+ )
207
+ else
208
+ # Fallback: strip all tags except allowed
209
+ clean = html.dup
210
+ # Remove script tags and their content
211
+ clean.gsub!(/<script\b[^>]*>.*?<\/script>/mi, '')
212
+ # Remove event handlers
213
+ clean.gsub!(/\s+on\w+\s*=\s*["'][^"']*["']/i, '')
214
+ clean.gsub!(/\s+on\w+\s*=\s*\S+/i, '')
215
+ # Remove javascript: protocol
216
+ clean.gsub!(/\b(href|src|action)\s*=\s*["']?\s*javascript\s*:/i, '\1="')
217
+ # Remove dangerous data: URLs
218
+ clean.gsub!(/\b(href|src|action)\s*=\s*["']?\s*data\s*:(?!image\/)/i, '\1="')
219
+ clean
220
+ end
221
+ end
222
+
223
+ def get_sanitized_body(message)
224
+ if message.body_text.present?
225
+ message.body_text
226
+ elsif message.body_html.present?
227
+ sanitize_html(message.body_html) || ''
228
+ else
229
+ ''
230
+ end
231
+ end
232
+
233
+ # Look up a user in the host application by email.
234
+ #
235
+ # @param email [String]
236
+ # @return [User, nil]
237
+ def find_user_by_email(email)
238
+ return nil if email.blank?
239
+
240
+ user_class = Escalated.configuration.user_model
241
+ if user_class.respond_to?(:find_by)
242
+ user_class.find_by(email: email.downcase.strip)
243
+ else
244
+ nil
245
+ end
246
+ rescue StandardError => e
247
+ Rails.logger.warn(
248
+ "[Escalated::InboundEmailService] Failed to look up user by email: #{e.message}"
249
+ )
250
+ nil
251
+ end
252
+ end
253
+ end
254
+ end
255
+ end
@@ -0,0 +1,49 @@
1
+ module Escalated
2
+ module Services
3
+ class MacroService
4
+ class << self
5
+ def apply(macro, ticket, actor:)
6
+ actions = macro.actions || []
7
+
8
+ actions.each do |action|
9
+ action = action.with_indifferent_access if action.respond_to?(:with_indifferent_access)
10
+ type = action["type"] || action[:type]
11
+ value = action["value"] || action[:value]
12
+
13
+ case type.to_s
14
+ when "status"
15
+ TicketService.transition_status(ticket, value, actor: actor)
16
+ when "priority"
17
+ TicketService.change_priority(ticket, value, actor: actor)
18
+ when "assign"
19
+ agent = Escalated.configuration.user_model.find(value)
20
+ AssignmentService.assign(ticket, agent, actor: actor)
21
+ when "tags"
22
+ tag_ids = Array(value)
23
+ TicketService.add_tags(ticket, tag_ids, actor: actor)
24
+ when "department"
25
+ department = Escalated::Department.find(value)
26
+ TicketService.change_department(ticket, department, actor: actor)
27
+ when "reply"
28
+ TicketService.reply(ticket, {
29
+ body: value.to_s,
30
+ author: actor,
31
+ is_internal: false
32
+ })
33
+ when "note"
34
+ TicketService.reply(ticket, {
35
+ body: value.to_s,
36
+ author: actor,
37
+ is_internal: true
38
+ })
39
+ end
40
+
41
+ ticket.reload
42
+ end
43
+
44
+ ticket
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end