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,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
|