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,341 @@
1
+ module Escalated
2
+ module Drivers
3
+ class LocalDriver
4
+ ALLOWED_SORT_COLUMNS = %w[created_at updated_at status priority subject reference assigned_to department_id resolved_at closed_at].freeze
5
+
6
+ def create_ticket(params)
7
+ ticket = Escalated::Ticket.new(
8
+ subject: params[:subject],
9
+ description: params[:description],
10
+ priority: params[:priority] || Escalated.configuration.default_priority,
11
+ status: :open,
12
+ requester: params[:requester],
13
+ assigned_to: params[:assigned_to],
14
+ department_id: params[:department_id],
15
+ reference: Escalated::Ticket.generate_reference,
16
+ metadata: params[:metadata] || {}
17
+ )
18
+
19
+ ActiveRecord::Base.transaction do
20
+ ticket.save!
21
+
22
+ if params[:tag_ids].present?
23
+ ticket.tags = Escalated::Tag.where(id: params[:tag_ids])
24
+ end
25
+
26
+ attach_sla_policy(ticket)
27
+ log_activity(ticket, params[:requester], "ticket_created", { subject: ticket.subject })
28
+ end
29
+
30
+ instrument("escalated.ticket.created", ticket: ticket)
31
+ ticket
32
+ end
33
+
34
+ def update_ticket(ticket, params, actor:)
35
+ ActiveRecord::Base.transaction do
36
+ changes = {}
37
+
38
+ if params[:subject].present? && params[:subject] != ticket.subject
39
+ changes[:subject] = [ticket.subject, params[:subject]]
40
+ ticket.subject = params[:subject]
41
+ end
42
+
43
+ if params[:description].present? && params[:description] != ticket.description
44
+ changes[:description] = [ticket.description, params[:description]]
45
+ ticket.description = params[:description]
46
+ end
47
+
48
+ if params[:metadata].present?
49
+ ticket.metadata = ticket.metadata.merge(params[:metadata])
50
+ end
51
+
52
+ ticket.save!
53
+
54
+ if changes.any?
55
+ log_activity(ticket, actor, "ticket_updated", changes)
56
+ end
57
+ end
58
+
59
+ instrument("escalated.ticket.updated", ticket: ticket)
60
+ ticket
61
+ end
62
+
63
+ def transition_status(ticket, new_status, actor:, note: nil)
64
+ old_status = ticket.status
65
+
66
+ ActiveRecord::Base.transaction do
67
+ ticket.update!(status: new_status)
68
+
69
+ if new_status.to_s == "resolved"
70
+ ticket.update!(resolved_at: Time.current)
71
+ end
72
+
73
+ if new_status.to_s == "closed"
74
+ ticket.update!(closed_at: Time.current)
75
+ end
76
+
77
+ if new_status.to_s == "reopened"
78
+ ticket.update!(resolved_at: nil, closed_at: nil)
79
+ end
80
+
81
+ log_activity(ticket, actor, "status_changed", {
82
+ from: old_status,
83
+ to: new_status,
84
+ note: note
85
+ })
86
+ end
87
+
88
+ instrument("escalated.ticket.status_changed", ticket: ticket, from: old_status, to: new_status)
89
+ ticket
90
+ end
91
+
92
+ def assign_ticket(ticket, agent, actor:)
93
+ old_assignee_id = ticket.assigned_to
94
+
95
+ ActiveRecord::Base.transaction do
96
+ ticket.update!(assigned_to: agent.id, status: :in_progress)
97
+
98
+ log_activity(ticket, actor, "ticket_assigned", {
99
+ from_agent_id: old_assignee_id,
100
+ to_agent_id: agent.id
101
+ })
102
+ end
103
+
104
+ instrument("escalated.ticket.assigned", ticket: ticket, agent: agent)
105
+ ticket
106
+ end
107
+
108
+ def unassign_ticket(ticket, actor:)
109
+ old_assignee_id = ticket.assigned_to
110
+
111
+ ActiveRecord::Base.transaction do
112
+ ticket.update!(assigned_to: nil, status: :open)
113
+
114
+ log_activity(ticket, actor, "ticket_unassigned", {
115
+ from_agent_id: old_assignee_id
116
+ })
117
+ end
118
+
119
+ instrument("escalated.ticket.unassigned", ticket: ticket)
120
+ ticket
121
+ end
122
+
123
+ def add_reply(ticket, params)
124
+ reply = nil
125
+
126
+ ActiveRecord::Base.transaction do
127
+ reply = ticket.replies.create!(
128
+ body: params[:body],
129
+ author: params[:author],
130
+ is_internal: params[:is_internal] || false,
131
+ is_system: params[:is_system] || false
132
+ )
133
+
134
+ # Update first response time if this is the first agent reply
135
+ if !params[:is_internal] && ticket.first_response_at.nil? && is_agent?(params[:author])
136
+ ticket.update!(first_response_at: Time.current)
137
+ end
138
+
139
+ # Update status based on who replied
140
+ if is_agent?(params[:author]) && !params[:is_internal]
141
+ ticket.update!(status: :waiting_on_customer) if ticket.open? || ticket.in_progress?
142
+ elsif !is_agent?(params[:author])
143
+ ticket.update!(status: :waiting_on_agent) if ticket.waiting_on_customer?
144
+ end
145
+
146
+ log_activity(ticket, params[:author], params[:is_internal] ? "internal_note_added" : "reply_added", {
147
+ reply_id: reply.id
148
+ })
149
+ end
150
+
151
+ instrument("escalated.ticket.reply_added", ticket: ticket, reply: reply)
152
+ reply
153
+ end
154
+
155
+ def get_ticket(id)
156
+ Escalated::Ticket.find(id)
157
+ end
158
+
159
+ def list_tickets(filters = {})
160
+ scope = Escalated::Ticket.all
161
+
162
+ scope = scope.where(status: filters[:status]) if filters[:status].present?
163
+ scope = scope.where(priority: filters[:priority]) if filters[:priority].present?
164
+ scope = scope.where(assigned_to: filters[:assigned_to]) if filters[:assigned_to].present?
165
+ scope = scope.where(department_id: filters[:department_id]) if filters[:department_id].present?
166
+ scope = scope.where(requester: filters[:requester]) if filters[:requester].present?
167
+ scope = scope.search(filters[:search]) if filters[:search].present?
168
+
169
+ if filters[:sla_breached]
170
+ scope = scope.breached_sla
171
+ end
172
+
173
+ order_col = filters[:order_by].to_s
174
+ order_col = 'created_at' unless ALLOWED_SORT_COLUMNS.include?(order_col)
175
+ order_dir = filters[:order_dir].to_s.downcase == 'asc' ? :asc : :desc
176
+ scope = scope.order(order_col => order_dir)
177
+
178
+ if filters[:page].present?
179
+ scope = scope.page(filters[:page]).per(filters[:per_page] || 25)
180
+ end
181
+
182
+ scope
183
+ end
184
+
185
+ def add_tags(ticket, tag_ids, actor:)
186
+ tags = Escalated::Tag.where(id: tag_ids)
187
+ new_tags = tags - ticket.tags
188
+
189
+ ActiveRecord::Base.transaction do
190
+ ticket.tags << new_tags
191
+
192
+ if new_tags.any?
193
+ log_activity(ticket, actor, "tags_added", {
194
+ tag_names: new_tags.map(&:name)
195
+ })
196
+ end
197
+ end
198
+
199
+ instrument("escalated.ticket.tags_added", ticket: ticket, tags: new_tags)
200
+ ticket
201
+ end
202
+
203
+ def remove_tags(ticket, tag_ids, actor:)
204
+ tags_to_remove = ticket.tags.where(id: tag_ids)
205
+
206
+ ActiveRecord::Base.transaction do
207
+ ticket.tags.delete(tags_to_remove)
208
+
209
+ if tags_to_remove.any?
210
+ log_activity(ticket, actor, "tags_removed", {
211
+ tag_names: tags_to_remove.map(&:name)
212
+ })
213
+ end
214
+ end
215
+
216
+ instrument("escalated.ticket.tags_removed", ticket: ticket, tags: tags_to_remove)
217
+ ticket
218
+ end
219
+
220
+ def change_department(ticket, department, actor:)
221
+ old_department_id = ticket.department_id
222
+
223
+ ActiveRecord::Base.transaction do
224
+ ticket.update!(department_id: department.id)
225
+
226
+ log_activity(ticket, actor, "department_changed", {
227
+ from_department_id: old_department_id,
228
+ to_department_id: department.id
229
+ })
230
+ end
231
+
232
+ instrument("escalated.ticket.department_changed", ticket: ticket, department: department)
233
+ ticket
234
+ end
235
+
236
+ def change_priority(ticket, new_priority, actor:)
237
+ old_priority = ticket.priority
238
+
239
+ ActiveRecord::Base.transaction do
240
+ ticket.update!(priority: new_priority)
241
+
242
+ log_activity(ticket, actor, "priority_changed", {
243
+ from: old_priority,
244
+ to: new_priority
245
+ })
246
+ end
247
+
248
+ instrument("escalated.ticket.priority_changed", ticket: ticket, from: old_priority, to: new_priority)
249
+ ticket
250
+ end
251
+
252
+ private
253
+
254
+ def log_activity(ticket, actor, action, details = {})
255
+ ticket.activities.create!(
256
+ action: action,
257
+ causer: actor,
258
+ details: details
259
+ )
260
+ end
261
+
262
+ def instrument(event, payload = {})
263
+ ActiveSupport::Notifications.instrument(event, payload)
264
+ end
265
+
266
+ def attach_sla_policy(ticket)
267
+ return unless Escalated.configuration.sla_enabled?
268
+
269
+ policy = if ticket.department&.default_sla_policy_id.present?
270
+ Escalated::SlaPolicy.find_by(id: ticket.department.default_sla_policy_id)
271
+ else
272
+ Escalated::SlaPolicy.default_policy.first
273
+ end
274
+
275
+ if policy
276
+ ticket.update!(
277
+ sla_policy_id: policy.id,
278
+ sla_first_response_due_at: calculate_due_date(policy.first_response_hours_for(ticket.priority)),
279
+ sla_resolution_due_at: calculate_due_date(policy.resolution_hours_for(ticket.priority))
280
+ )
281
+ end
282
+ end
283
+
284
+ def calculate_due_date(hours)
285
+ return nil unless hours
286
+
287
+ if Escalated.configuration.business_hours_only?
288
+ calculate_business_hours_due_date(hours)
289
+ else
290
+ Time.current + hours.hours
291
+ end
292
+ end
293
+
294
+ def calculate_business_hours_due_date(hours)
295
+ bh = Escalated.configuration.business_hours
296
+ start_hour = bh[:start] || 9
297
+ end_hour = bh[:end] || 17
298
+ working_days = bh[:working_days] || [1, 2, 3, 4, 5]
299
+ tz = bh[:timezone] || "UTC"
300
+
301
+ current_time = Time.current.in_time_zone(tz)
302
+ remaining_hours = hours.to_f
303
+ hours_per_day = end_hour - start_hour
304
+
305
+ while remaining_hours > 0
306
+ if working_days.include?(current_time.wday)
307
+ day_start = current_time.change(hour: start_hour, min: 0, sec: 0)
308
+ day_end = current_time.change(hour: end_hour, min: 0, sec: 0)
309
+
310
+ if current_time < day_start
311
+ current_time = day_start
312
+ end
313
+
314
+ if current_time < day_end
315
+ available_hours = (day_end - current_time) / 3600.0
316
+
317
+ if remaining_hours <= available_hours
318
+ return current_time + remaining_hours.hours
319
+ else
320
+ remaining_hours -= available_hours
321
+ end
322
+ end
323
+ end
324
+
325
+ current_time = (current_time + 1.day).change(hour: start_hour, min: 0, sec: 0)
326
+ end
327
+
328
+ current_time
329
+ end
330
+
331
+ def is_agent?(user)
332
+ return false unless user.present?
333
+
334
+ # Check if user responds to agent-like methods
335
+ user.respond_to?(:escalated_agent?) && user.escalated_agent?
336
+ rescue StandardError
337
+ false
338
+ end
339
+ end
340
+ end
341
+ end
@@ -0,0 +1,124 @@
1
+ require "escalated/drivers/local_driver"
2
+ require "escalated/drivers/hosted_api_client"
3
+
4
+ module Escalated
5
+ module Drivers
6
+ class SyncedDriver < LocalDriver
7
+ def create_ticket(params)
8
+ ticket = super
9
+ sync_to_cloud(:create_ticket, ticket_payload(ticket))
10
+ ticket
11
+ end
12
+
13
+ def update_ticket(ticket, params, actor:)
14
+ result = super
15
+ sync_to_cloud(:update_ticket, ticket_payload(result))
16
+ result
17
+ end
18
+
19
+ def transition_status(ticket, new_status, actor:, note: nil)
20
+ result = super
21
+ sync_to_cloud(:transition_status, {
22
+ ticket_reference: result.reference,
23
+ status: new_status,
24
+ note: note
25
+ })
26
+ result
27
+ end
28
+
29
+ def assign_ticket(ticket, agent, actor:)
30
+ result = super
31
+ sync_to_cloud(:assign_ticket, {
32
+ ticket_reference: result.reference,
33
+ agent_email: agent.email
34
+ })
35
+ result
36
+ end
37
+
38
+ def unassign_ticket(ticket, actor:)
39
+ result = super
40
+ sync_to_cloud(:unassign_ticket, {
41
+ ticket_reference: result.reference
42
+ })
43
+ result
44
+ end
45
+
46
+ def add_reply(ticket, params)
47
+ reply = super
48
+ sync_to_cloud(:add_reply, {
49
+ ticket_reference: ticket.reference,
50
+ body: reply.body,
51
+ author_email: reply.author&.email,
52
+ is_internal: reply.is_internal
53
+ })
54
+ reply
55
+ end
56
+
57
+ def add_tags(ticket, tag_ids, actor:)
58
+ result = super
59
+ sync_to_cloud(:add_tags, {
60
+ ticket_reference: result.reference,
61
+ tag_names: result.tags.pluck(:name)
62
+ })
63
+ result
64
+ end
65
+
66
+ def remove_tags(ticket, tag_ids, actor:)
67
+ result = super
68
+ sync_to_cloud(:remove_tags, {
69
+ ticket_reference: result.reference,
70
+ tag_names: result.tags.pluck(:name)
71
+ })
72
+ result
73
+ end
74
+
75
+ def change_department(ticket, department, actor:)
76
+ result = super
77
+ sync_to_cloud(:change_department, {
78
+ ticket_reference: result.reference,
79
+ department_name: department.name
80
+ })
81
+ result
82
+ end
83
+
84
+ def change_priority(ticket, new_priority, actor:)
85
+ result = super
86
+ sync_to_cloud(:change_priority, {
87
+ ticket_reference: result.reference,
88
+ priority: new_priority
89
+ })
90
+ result
91
+ end
92
+
93
+ private
94
+
95
+ def sync_to_cloud(action, payload)
96
+ HostedApiClient.emit(action, payload)
97
+ rescue StandardError => e
98
+ Rails.logger.error("[Escalated::SyncedDriver] Cloud sync failed for #{action}: #{e.message}")
99
+ ActiveSupport::Notifications.instrument("escalated.sync.failed", {
100
+ action: action,
101
+ error: e.message
102
+ })
103
+ # Local operation already succeeded - don't re-raise
104
+ end
105
+
106
+ def ticket_payload(ticket)
107
+ {
108
+ reference: ticket.reference,
109
+ subject: ticket.subject,
110
+ description: ticket.description,
111
+ status: ticket.status,
112
+ priority: ticket.priority,
113
+ requester_email: ticket.requester&.email,
114
+ assignee_email: ticket.assignee&.email,
115
+ department_name: ticket.department&.name,
116
+ tag_names: ticket.tags.pluck(:name),
117
+ metadata: ticket.metadata,
118
+ created_at: ticket.created_at&.iso8601,
119
+ updated_at: ticket.updated_at&.iso8601
120
+ }
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,45 @@
1
+ module Escalated
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace Escalated
4
+
5
+ initializer "escalated.configuration" do |app|
6
+ # Allow host app to configure Escalated before boot
7
+ end
8
+
9
+ initializer "escalated.assets" do |app|
10
+ # Make engine assets available to host app
11
+ app.config.assets.precompile += %w[escalated_manifest.js] if app.config.respond_to?(:assets)
12
+ end
13
+
14
+ initializer "escalated.migrations" do |app|
15
+ unless app.root.to_s.match?(root.to_s)
16
+ config.paths["db/migrate"].expanded.each do |expanded_path|
17
+ app.config.paths["db/migrate"] << expanded_path
18
+ end
19
+ end
20
+ end
21
+
22
+ initializer "escalated.pundit" do
23
+ ActiveSupport.on_load(:action_controller) do
24
+ # Pundit policies are auto-discovered via namespace
25
+ end
26
+ end
27
+
28
+ initializer "escalated.append_routes" do |app|
29
+ app.routes.append do
30
+ mount Escalated::Engine, at: "/#{Escalated.configuration.route_prefix}"
31
+ end
32
+ end
33
+
34
+ initializer "escalated.inertia" do
35
+ ActiveSupport.on_load(:action_controller) do
36
+ # Configure Inertia shared data at engine level
37
+ end
38
+ end
39
+
40
+ config.generators do |g|
41
+ g.test_framework :rspec
42
+ g.fixture_replacement :factory_bot, dir: "spec/factories"
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,60 @@
1
+ module Escalated
2
+ module Mail
3
+ module Adapters
4
+ class BaseAdapter
5
+ # Parse the incoming webhook/request into an InboundMessage.
6
+ # Subclasses must implement this method.
7
+ #
8
+ # @param request [ActionDispatch::Request] the raw HTTP request
9
+ # @return [Escalated::Mail::InboundMessage]
10
+ def parse_request(request)
11
+ raise NotImplementedError, "#{self.class.name}#parse_request must be implemented"
12
+ end
13
+
14
+ # Verify the authenticity of the incoming request (signature check, etc.).
15
+ # Returns true if the request is valid, false otherwise.
16
+ # Default implementation returns true (no verification).
17
+ #
18
+ # @param request [ActionDispatch::Request] the raw HTTP request
19
+ # @return [Boolean]
20
+ def verify_request(request)
21
+ true
22
+ end
23
+
24
+ # Human-readable adapter name for logging and storage
25
+ #
26
+ # @return [String]
27
+ def adapter_name
28
+ self.class.name.demodulize.underscore.sub(/_adapter\z/, "")
29
+ end
30
+
31
+ private
32
+
33
+ # Safely extract a value from params, returning nil if missing
34
+ def safe_param(params, key, default = nil)
35
+ value = params[key]
36
+ value.present? ? value : default
37
+ end
38
+
39
+ # Parse an email address string like "John Doe <john@example.com>"
40
+ # into [name, email] tuple
41
+ def parse_email_address(address_string)
42
+ return [nil, nil] if address_string.blank?
43
+
44
+ if match = address_string.match(/\A\s*(.+?)\s*<([^>]+)>\s*\z/)
45
+ [match[1].strip.gsub(/\A["']|["']\z/, ""), match[2].strip.downcase]
46
+ else
47
+ [nil, address_string.strip.downcase]
48
+ end
49
+ end
50
+
51
+ # Parse a comma-separated list of email addresses
52
+ def parse_references(references_string)
53
+ return [] if references_string.blank?
54
+
55
+ references_string.scan(/<([^>]+)>/).flatten
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end