sincerely 1.0.0 → 1.1.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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -0
  3. data/app/assets/config/sincerely.js +2 -0
  4. data/app/assets/images/sincerely/arrow_left.svg +1 -0
  5. data/app/assets/images/sincerely/check.svg +3 -0
  6. data/app/assets/images/sincerely/chevron_left.svg +1 -0
  7. data/app/assets/images/sincerely/clock.svg +1 -0
  8. data/app/assets/images/sincerely/code.svg +1 -0
  9. data/app/assets/images/sincerely/eye.svg +1 -0
  10. data/app/assets/images/sincerely/logout.svg +1 -0
  11. data/app/assets/images/sincerely/menu.svg +1 -0
  12. data/app/assets/images/sincerely/moon.svg +1 -0
  13. data/app/assets/images/sincerely/pencil.svg +4 -0
  14. data/app/assets/images/sincerely/plus.svg +3 -0
  15. data/app/assets/images/sincerely/progress_spinner.svg +3 -0
  16. data/app/assets/images/sincerely/spinner.svg +3 -0
  17. data/app/assets/images/sincerely/sun.svg +1 -0
  18. data/app/assets/images/sincerely/warning_circle.svg +4 -0
  19. data/app/assets/javascripts/sincerely/charts.js +115 -0
  20. data/app/assets/javascripts/sincerely/clickable_rows.js +29 -0
  21. data/app/assets/javascripts/sincerely/layout.js +104 -0
  22. data/app/assets/javascripts/sincerely/send_form.js +243 -0
  23. data/app/assets/javascripts/sincerely/template_form.js +44 -0
  24. data/app/assets/javascripts/sincerely/template_preview.js +12 -0
  25. data/app/assets/javascripts/sincerely/theme_init.js +9 -0
  26. data/app/assets/stylesheets/sincerely/charts.css +250 -0
  27. data/app/assets/stylesheets/sincerely/components.css +632 -0
  28. data/app/assets/stylesheets/sincerely/layout.css +233 -0
  29. data/app/assets/stylesheets/sincerely/navigation.css +198 -0
  30. data/app/assets/stylesheets/sincerely/send_form.css +287 -0
  31. data/app/assets/stylesheets/sincerely/template_form.css +74 -0
  32. data/app/assets/stylesheets/sincerely/template_preview.css +75 -0
  33. data/app/assets/stylesheets/sincerely/variables.css +88 -0
  34. data/app/controllers/sincerely/application_controller.rb +3 -72
  35. data/app/controllers/sincerely/concerns/authorization.rb +21 -0
  36. data/app/controllers/sincerely/concerns/delivery_metrics.rb +51 -0
  37. data/app/controllers/sincerely/concerns/event_filtering.rb +58 -0
  38. data/app/controllers/sincerely/concerns/event_timeline.rb +41 -0
  39. data/app/controllers/sincerely/concerns/notification_timeline.rb +73 -0
  40. data/app/controllers/sincerely/concerns/notifications.rb +47 -0
  41. data/app/controllers/sincerely/concerns/pagination.rb +39 -0
  42. data/app/controllers/sincerely/concerns/periods.rb +40 -0
  43. data/app/controllers/sincerely/dashboard_controller.rb +30 -88
  44. data/app/controllers/sincerely/delivery_events_controller.rb +13 -21
  45. data/app/controllers/sincerely/engagement_events_controller.rb +13 -22
  46. data/app/controllers/sincerely/notifications_controller.rb +68 -49
  47. data/app/controllers/sincerely/send_controller.rb +82 -67
  48. data/app/controllers/sincerely/templates_controller.rb +37 -8
  49. data/app/helpers/sincerely/application_helper.rb +22 -0
  50. data/app/helpers/sincerely/chart_helper.rb +79 -0
  51. data/app/views/layouts/sincerely/application.html.erb +24 -528
  52. data/app/views/sincerely/dashboard/index.html.erb +25 -256
  53. data/app/views/sincerely/delivery_events/index.html.erb +6 -38
  54. data/app/views/sincerely/engagement_events/index.html.erb +6 -38
  55. data/app/views/sincerely/notifications/index.html.erb +7 -23
  56. data/app/views/sincerely/notifications/show.html.erb +4 -4
  57. data/app/views/sincerely/send/new.html.erb +8 -522
  58. data/app/views/sincerely/shared/_event.html.erb +21 -0
  59. data/app/views/sincerely/shared/_line_chart.html.erb +38 -0
  60. data/app/views/sincerely/shared/_notification_type_filter.html.erb +9 -0
  61. data/app/views/sincerely/shared/_status_option.html.erb +1 -0
  62. data/app/views/sincerely/shared/_template_option.html.erb +3 -0
  63. data/app/views/sincerely/templates/_form.html.erb +8 -107
  64. data/app/views/sincerely/templates/edit.html.erb +2 -2
  65. data/app/views/sincerely/templates/index.html.erb +7 -15
  66. data/app/views/sincerely/templates/new.html.erb +2 -2
  67. data/app/views/sincerely/templates/preview.html.erb +7 -26
  68. data/app/views/sincerely/templates/show.html.erb +3 -3
  69. data/lib/config/sincerely_config.rb +2 -2
  70. data/lib/sincerely/delivery_systems/email_aws_ses.rb +2 -2
  71. data/lib/sincerely/engine.rb +4 -0
  72. data/lib/sincerely/mixins/notification_model.rb +5 -1
  73. data/lib/sincerely/services/process_delivery_event.rb +9 -7
  74. data/lib/sincerely/templates/notification_template.rb +1 -1
  75. data/lib/sincerely/version.rb +1 -1
  76. metadata +52 -4
  77. data/sincerely.gemspec +0 -37
@@ -2,111 +2,53 @@
2
2
 
3
3
  module Sincerely
4
4
  class DashboardController < ApplicationController
5
- def index
6
- filtered_notifications = apply_time_filter(notification_model)
7
- filtered_notifications = apply_notification_filter(filtered_notifications)
5
+ include Concerns::Periods
6
+ include Concerns::DeliveryMetrics
7
+ include Concerns::NotificationTimeline
8
8
 
9
+ def index
9
10
  @total_notifications = filtered_notifications.count
10
11
  @notifications_by_state = filtered_notifications.group(:delivery_state).count
11
12
  @recent_notifications = filtered_notifications.order(created_at: :desc).limit(10)
12
-
13
- # Delivery rates
14
- total_sent = filtered_notifications.where.not(delivery_state: %w[draft rejected]).count
15
- delivered = filtered_notifications.where(delivery_state: %w[delivered opened clicked]).count
16
- @delivery_rate = total_sent.positive? ? (delivered.to_f / total_sent * 100).round(1) : 0
17
-
18
- # Engagement rates
19
- delivered_count = filtered_notifications.where(delivery_state: %w[delivered opened clicked]).count
20
- engaged = filtered_notifications.where(delivery_state: %w[opened clicked]).count
21
- @open_rate = delivered_count.positive? ? (engaged.to_f / delivered_count * 100).round(1) : 0
22
-
23
- # Recent events (filtered by user's notifications)
24
- user_message_ids = filtered_notifications.where.not(message_id: nil).pluck(:message_id)
25
- @recent_delivery_events = apply_time_filter(Sincerely::DeliveryEvent)
26
- .where(message_id: user_message_ids)
27
- .order(created_at: :desc).limit(5)
28
- @recent_engagement_events = apply_time_filter(Sincerely::EngagementEvent)
29
- .where(message_id: user_message_ids)
30
- .order(created_at: :desc).limit(5)
31
-
32
- # Bounce rate
33
- bounced = filtered_notifications.where(delivery_state: 'bounced').count
34
- @bounce_rate = total_sent.positive? ? (bounced.to_f / total_sent * 100).round(1) : 0
35
-
36
- # Current period for display
37
- @current_period = params[:period] || '24h'
38
-
13
+ @delivery_rate = calculate_delivery_rate
14
+ @open_rate = calculate_open_rate
15
+ @bounce_rate = calculate_bounce_rate
16
+ @recent_delivery_events = recent_delivery_events
17
+ @recent_engagement_events = recent_engagement_events
18
+ @notifications_by_message_id = preload_notifications_for_events
19
+ @current_period = current_period
39
20
  @notifications_timeline = build_timeline_data(filtered_notifications)
40
21
  end
41
22
 
42
23
  private
43
24
 
44
- def build_timeline_data(notifications)
45
- bucket_config = timeline_bucket_config
46
- buckets = generate_time_buckets(bucket_config)
47
- interval = bucket_config[:interval]
48
- series = {}
49
-
50
- notifications.find_each do |notification|
51
- bucket_index = find_bucket_index(notification.created_at, buckets, interval)
52
- next if bucket_index.nil?
53
-
54
- state = notification.delivery_state
55
- series[state] ||= Array.new(buckets.length, 0)
56
- series[state][bucket_index] += 1
57
- end
58
-
59
- labels = buckets.map { |b| format_bucket_label(b, bucket_config) }
60
-
61
- { labels:, series:, buckets: }
25
+ def filtered_notifications
26
+ @filtered_notifications ||= apply_notification_filter(apply_time_filter(notification_model))
62
27
  end
63
28
 
64
- def timeline_bucket_config
65
- period = params[:period]
66
- period = '24h' unless %w[1h 24h 7d 30d 3m all].include?(period)
67
-
68
- case period
69
- when '1h'
70
- { interval: 5.minutes, format: '%H:%M', start: 1.hour.ago }
71
- when '24h'
72
- { interval: 1.hour, format: '%H:%M', start: 24.hours.ago }
73
- when '7d'
74
- { interval: 6.hours, format: '%a %H:%M', start: 7.days.ago }
75
- when '30d'
76
- { interval: 1.day, format: '%b %d', start: 30.days.ago }
77
- when '3m'
78
- { interval: 1.week, format: '%b %d', start: 3.months.ago }
79
- when 'all'
80
- { interval: 1.month, format: '%b %Y', start: 1.year.ago }
81
- end
29
+ def user_message_ids_subquery
30
+ filtered_notifications.where.not(message_id: nil).select(:message_id)
82
31
  end
83
32
 
84
- def generate_time_buckets(config)
85
- buckets = []
86
- current = config[:start].beginning_of_hour
87
- now = Time.current
88
-
89
- while current <= now
90
- buckets << current
91
- current += config[:interval]
92
- end
93
-
94
- buckets
33
+ def recent_delivery_events
34
+ apply_time_filter(Sincerely::DeliveryEvent, column: :timestamp)
35
+ .where(message_id: user_message_ids_subquery)
36
+ .order(timestamp: :desc)
37
+ .limit(5)
95
38
  end
96
39
 
97
- def find_bucket_index(time, buckets, interval)
98
- return nil if buckets.empty?
99
-
100
- buckets.each_with_index do |bucket_start, index|
101
- bucket_end = bucket_start + interval
102
- return index if time >= bucket_start && time < bucket_end
103
- end
104
-
105
- buckets.length - 1 if time >= buckets.last && time < buckets.last + interval
40
+ def recent_engagement_events
41
+ apply_time_filter(Sincerely::EngagementEvent, column: :timestamp)
42
+ .where(message_id: user_message_ids_subquery)
43
+ .order(timestamp: :desc)
44
+ .limit(5)
106
45
  end
107
46
 
108
- def format_bucket_label(bucket, config)
109
- bucket.strftime(config[:format])
47
+ def preload_notifications_for_events
48
+ message_ids = (@recent_delivery_events + @recent_engagement_events).map(&:message_id).compact.uniq
49
+ return {} if message_ids.empty?
50
+
51
+ notification_model.where(message_id: message_ids).index_by(&:message_id)
110
52
  end
111
53
  end
112
54
  end
@@ -2,30 +2,22 @@
2
2
 
3
3
  module Sincerely
4
4
  class DeliveryEventsController < ApplicationController
5
- def index
6
- @events = apply_time_filter(Sincerely::DeliveryEvent).order(created_at: :desc)
7
- @events = apply_event_filter(@events)
8
-
9
- @events = @events.where(event_type: params[:event_type]) if params[:event_type].present?
10
- @events = @events.where('recipient LIKE ?', "%#{params[:recipient]}%") if params[:recipient].present?
11
-
12
- if params[:template_id].present? || params[:notification_type].present?
13
- notification_scope = notification_model.all
14
- if params[:template_id].present?
15
- notification_scope = notification_scope.where(template_id: params[:template_id])
16
- end
17
- if params[:notification_type].present?
18
- notification_scope = notification_scope.where(notification_type: params[:notification_type])
19
- end
20
- message_ids = notification_scope.pluck(:message_id).compact
21
- @events = @events.where(message_id: message_ids)
22
- end
5
+ include Concerns::Pagination
6
+ include Concerns::Periods
7
+ include Concerns::EventFiltering
23
8
 
24
- @pagination = paginate(@events)
9
+ def index
10
+ @pagination = paginate(filtered_events)
25
11
  @events = @pagination[:records]
12
+ @notifications_by_message_id = preload_notifications(@events)
13
+ @event_types = available_event_types
14
+ @templates = available_templates
15
+ end
16
+
17
+ private
26
18
 
27
- @event_types = Sincerely::DeliveryEvent.distinct.pluck(:event_type).compact
28
- @templates = Sincerely::Templates::NotificationTemplate.all
19
+ def event_model
20
+ Sincerely::DeliveryEvent
29
21
  end
30
22
  end
31
23
  end
@@ -2,31 +2,22 @@
2
2
 
3
3
  module Sincerely
4
4
  class EngagementEventsController < ApplicationController
5
- def index
6
- @events = apply_time_filter(Sincerely::EngagementEvent).order(created_at: :desc)
7
- @events = apply_event_filter(@events)
8
-
9
- @events = @events.where(event_type: params[:event_type]) if params[:event_type].present?
10
- @events = @events.where('recipient LIKE ?', "%#{params[:recipient]}%") if params[:recipient].present?
11
-
12
- # Filter by notification properties via message_id
13
- if params[:template_id].present? || params[:notification_type].present?
14
- notification_scope = notification_model.all
15
- if params[:template_id].present?
16
- notification_scope = notification_scope.where(template_id: params[:template_id])
17
- end
18
- if params[:notification_type].present?
19
- notification_scope = notification_scope.where(notification_type: params[:notification_type])
20
- end
21
- message_ids = notification_scope.pluck(:message_id).compact
22
- @events = @events.where(message_id: message_ids)
23
- end
5
+ include Concerns::Pagination
6
+ include Concerns::Periods
7
+ include Concerns::EventFiltering
24
8
 
25
- @pagination = paginate(@events)
9
+ def index
10
+ @pagination = paginate(filtered_events)
26
11
  @events = @pagination[:records]
12
+ @notifications_by_message_id = preload_notifications(@events)
13
+ @event_types = available_event_types
14
+ @templates = available_templates
15
+ end
16
+
17
+ private
27
18
 
28
- @event_types = Sincerely::EngagementEvent.distinct.pluck(:event_type).compact
29
- @templates = Sincerely::Templates::NotificationTemplate.all
19
+ def event_model
20
+ Sincerely::EngagementEvent
30
21
  end
31
22
  end
32
23
  end
@@ -2,68 +2,87 @@
2
2
 
3
3
  module Sincerely
4
4
  class NotificationsController < ApplicationController
5
+ include Concerns::Pagination
6
+ include Concerns::Periods
7
+ include Concerns::EventTimeline
8
+
5
9
  def index
6
- @notifications = apply_time_filter(notification_model).order(created_at: :desc)
7
-
8
- # Apply host app filter (e.g., filter by logged-in user's email)
9
- @notifications = apply_notification_filter(@notifications)
10
-
11
- # Apply filters
12
- @notifications = @notifications.where(delivery_state: params[:status]) if params[:status].present?
13
- @notifications = @notifications.where(template_id: params[:template_id]) if params[:template_id].present?
14
- if params[:recipient].present?
15
- @notifications = @notifications.where('recipient LIKE ?',
16
- "%#{params[:recipient]}%")
17
- end
18
- if params[:notification_type].present?
19
- @notifications = @notifications.where(notification_type: params[:notification_type])
20
- end
21
-
22
- @notifications = @notifications.where(created_at: Date.parse(params[:date_from])..) if params[:date_from].present?
23
- if params[:date_to].present?
24
- @notifications = @notifications.where(created_at: ..Date.parse(params[:date_to]).end_of_day)
25
- end
26
-
27
- @pagination = paginate(@notifications)
10
+ @pagination = paginate(filtered_notifications)
28
11
  @notifications = @pagination[:records]
29
- @templates = Sincerely::Templates::NotificationTemplate.all
30
- @states = notification_model.aasm.states.map(&:name)
12
+ @templates = available_templates
13
+ @states = available_states
31
14
  end
32
15
 
33
16
  def show
34
- @notification = apply_notification_filter(notification_model).find(params[:id])
17
+ @notification = find_notification
35
18
  @template = @notification.template
36
- @timeline = build_event_timeline(@notification)
19
+ @timeline = build_event_timeline
37
20
  end
38
21
 
39
22
  private
40
23
 
41
- def build_event_timeline(notification)
42
- events = []
43
-
44
- Sincerely::DeliveryEvent.where(message_id: notification.message_id).find_each do |event|
45
- events << {
46
- type: event.event_type,
47
- timestamp: event.timestamp || event.created_at,
48
- details: format_event_details(event.options)
49
- }
50
- end
51
-
52
- Sincerely::EngagementEvent.where(message_id: notification.message_id).find_each do |event|
53
- events << {
54
- type: event.event_type,
55
- timestamp: event.timestamp || event.created_at,
56
- details: format_event_details(event.options)
57
- }
58
- end
59
-
60
- events.sort_by { |e| e[:timestamp] }.reverse
24
+ def filtered_notifications
25
+ scope = base_notifications_scope
26
+ scope = filter_by_status(scope)
27
+ scope = filter_by_template(scope)
28
+ scope = filter_by_recipient(scope)
29
+ scope = filter_by_notification_type(scope)
30
+ filter_by_date_range(scope)
31
+ end
32
+
33
+ def base_notifications_scope
34
+ apply_notification_filter(apply_time_filter(notification_model)).includes(:template).order(created_at: :desc)
35
+ end
36
+
37
+ def filter_by_status(scope)
38
+ return scope if params[:status].blank?
39
+
40
+ scope.where(delivery_state: params[:status])
41
+ end
42
+
43
+ def filter_by_template(scope)
44
+ return scope if params[:template_id].blank?
45
+
46
+ scope.where(template_id: params[:template_id])
47
+ end
48
+
49
+ def filter_by_recipient(scope)
50
+ return scope if params[:recipient].blank?
51
+
52
+ scope.where('recipient LIKE ?', "%#{params[:recipient]}%")
61
53
  end
62
54
 
63
- def format_event_details(data)
64
- return nil if data.blank?
55
+ def filter_by_notification_type(scope)
56
+ return scope if params[:notification_type].blank?
57
+
58
+ scope.where(notification_type: params[:notification_type])
59
+ end
60
+
61
+ def filter_by_date_range(scope)
62
+ date_from = safe_parse_date(params[:date_from])
63
+ date_to = safe_parse_date(params[:date_to])
64
+
65
+ scope = scope.where(created_at: date_from..) if date_from
66
+ scope = scope.where(created_at: ..date_to.end_of_day) if date_to
67
+ scope
68
+ end
69
+
70
+ def safe_parse_date(value)
71
+ Date.iso8601(value) if value.present?
72
+ rescue ArgumentError
73
+ nil
74
+ end
75
+
76
+ def find_notification
77
+ apply_notification_filter(notification_model).find(params[:id])
78
+ end
79
+
80
+ def available_templates
81
+ Sincerely::Templates::NotificationTemplate.all
82
+ end
65
83
 
66
- data.is_a?(Hash) ? data.map { |k, v| "#{k}: #{v}" }.join(', ') : data.to_s
84
+ def available_states
85
+ notification_model.aasm.states.map(&:name)
67
86
  end
68
87
  end
69
88
  end
@@ -3,103 +3,118 @@
3
3
  module Sincerely
4
4
  class SendController < ApplicationController
5
5
  MAX_RECIPIENTS = 50
6
+ EMAIL_PATTERN = /\A[^@\s]+@[^@\s]+\z/
7
+ RECIPIENT_DELIMITER = /[\s,;\n]+/
8
+
9
+ private_constant :MAX_RECIPIENTS, :EMAIL_PATTERN, :RECIPIENT_DELIMITER
6
10
 
7
11
  def new
8
- @templates = Sincerely::Templates::NotificationTemplate.order(:name)
12
+ @templates = available_templates
9
13
  end
10
14
 
11
15
  def create
12
- recipients = parse_recipients(params[:recipients])
16
+ return render_error('No valid recipients provided') if recipients.empty?
17
+ return render_error("Maximum #{MAX_RECIPIENTS} recipients allowed") if recipients.size > MAX_RECIPIENTS
18
+ return render_error('Template not found') unless template
13
19
 
14
- if recipients.empty?
15
- render json: { success: false, error: 'No valid recipients provided' }, status: :unprocessable_entity
16
- return
17
- end
20
+ render json: send_results
21
+ end
18
22
 
19
- if recipients.size > MAX_RECIPIENTS
20
- render json: { success: false, error: "Maximum #{MAX_RECIPIENTS} recipients allowed" },
21
- status: :unprocessable_entity
22
- return
23
- end
23
+ def template_variables
24
+ return render json: { variables: [] } unless template
24
25
 
25
- template = Sincerely::Templates::NotificationTemplate.find_by(id: params[:template_id])
26
- unless template
27
- render json: { success: false, error: 'Template not found' }, status: :unprocessable_entity
28
- return
29
- end
26
+ render json: { variables: extract_liquid_variables.uniq.sort }
27
+ end
30
28
 
31
- template_data = params[:template_data]&.to_unsafe_h || {}
29
+ private
32
30
 
33
- results = { sent: 0, failed: 0, errors: [] }
31
+ def available_templates
32
+ Sincerely::Templates::NotificationTemplate.order(:name)
33
+ end
34
+
35
+ def template
36
+ @template ||= Sincerely::Templates::NotificationTemplate.find_by(id: params[:template_id] || params[:id])
37
+ end
38
+
39
+ def recipients
40
+ @recipients ||= parse_recipients(params[:recipients])
41
+ end
34
42
 
35
- recipients.each do |recipient|
36
- notification = notification_model.new(
37
- recipient:,
38
- notification_type: 'email',
39
- template_id: template.id,
40
- delivery_options: { template_data: }
41
- )
42
-
43
- if notification.save
44
- begin
45
- notification.deliver
46
- results[:sent] += 1
47
- rescue StandardError => e
48
- results[:failed] += 1
49
- results[:errors] << { recipient:, error: e.message }
50
- end
51
- else
52
- results[:failed] += 1
53
- results[:errors] << { recipient:, error: notification.errors.full_messages.join(', ') }
54
- end
43
+ def template_data
44
+ @template_data ||= begin
45
+ raw = params[:template_data]
46
+ data = case raw
47
+ when ActionController::Parameters then raw.to_unsafe_h
48
+ when Hash then raw
49
+ else {}
50
+ end
51
+ allowed_keys = extract_liquid_variables.uniq
52
+ data.slice(*allowed_keys)
55
53
  end
54
+ end
55
+
56
+ def send_results
57
+ results = { sent: 0, failed: 0, errors: [] }
58
+
59
+ recipients.each { |recipient| process_recipient(recipient, results) }
56
60
 
57
- render json: {
58
- success: true,
59
- sent: results[:sent],
60
- failed: results[:failed],
61
- errors: results[:errors].first(5) # Limit error details
62
- }
61
+ { success: true, sent: results[:sent], failed: results[:failed], errors: results[:errors].first(5) }
63
62
  end
64
63
 
65
- def template_variables
66
- template = Sincerely::Templates::NotificationTemplate.find_by(id: params[:id])
64
+ def process_recipient(recipient, results)
65
+ notification = build_notification(recipient)
67
66
 
68
- unless template
69
- render json: { variables: [] }
70
- return
67
+ if notification.save
68
+ deliver_notification(notification, recipient, results)
69
+ else
70
+ record_failure(results, recipient, notification.errors.full_messages.join(', '))
71
71
  end
72
+ end
72
73
 
73
- variables = extract_liquid_variables(template)
74
- render json: { variables: variables.uniq.sort }
74
+ def build_notification(recipient)
75
+ notification_model.new(
76
+ recipient:,
77
+ notification_type: 'email',
78
+ template_id: template.id,
79
+ delivery_options: { template_data: }
80
+ )
75
81
  end
76
82
 
77
- private
83
+ def deliver_notification(notification, recipient, results)
84
+ notification.deliver
85
+ results[:sent] += 1
86
+ rescue StandardError => e
87
+ Rails.logger.error("Sincerely delivery failed for #{recipient}: #{e.class}: #{e.message}")
88
+ record_failure(results, recipient, 'Delivery failed')
89
+ end
90
+
91
+ def record_failure(results, recipient, error)
92
+ results[:failed] += 1
93
+ results[:errors] << { recipient:, error: }
94
+ end
95
+
96
+ def render_error(message)
97
+ render json: { success: false, error: message }, status: :unprocessable_entity
98
+ end
78
99
 
79
100
  def parse_recipients(input)
80
101
  return [] if input.blank?
81
102
 
82
- # Split by comma, semicolon, space, or newline
83
- input.split(/[\s,;\n]+/)
103
+ input.split(RECIPIENT_DELIMITER)
84
104
  .map(&:strip)
85
105
  .compact_blank
86
- .grep(/\A[^@\s]+@[^@\s]+\z/)
106
+ .grep(EMAIL_PATTERN)
87
107
  .uniq
88
- .first(MAX_RECIPIENTS)
89
108
  end
90
109
 
91
- def extract_liquid_variables(template)
92
- content = [
93
- template.subject,
94
- template.html_content,
95
- template.text_content
96
- ].compact.join(' ')
110
+ def extract_liquid_variables
111
+ template_content.scan(/\{\{\s*([a-zA-Z_][a-zA-Z0-9_.]*?)[\s|}]/)
112
+ .flatten
113
+ .map { |v| v.split('.').first }
114
+ end
97
115
 
98
- # Extract {{ variable }} and {{ variable.property }} patterns
99
- content.scan(/\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*)\s*\}\}/)
100
- .flatten
101
- .map { |v| v.split('.').first } # Get root variable name
102
- .uniq
116
+ def template_content
117
+ [template.subject, template.html_content, template.text_content].compact.join(' ')
103
118
  end
104
119
  end
105
120
  end
@@ -2,15 +2,17 @@
2
2
 
3
3
  module Sincerely
4
4
  class TemplatesController < ApplicationController
5
+ include Concerns::Pagination
6
+
5
7
  before_action :set_template, only: %i[show edit update preview]
6
8
 
7
9
  def index
8
- @pagination = paginate(Sincerely::Templates::NotificationTemplate.order(created_at: :desc))
10
+ @pagination = paginate(all_templates)
9
11
  @templates = @pagination[:records]
10
12
  end
11
13
 
12
14
  def show
13
- @notification_count = notification_model.where(template_id: @template.id).count
15
+ @notification_count = template_notification_count
14
16
  end
15
17
 
16
18
  def new
@@ -38,14 +40,13 @@ module Sincerely
38
40
  end
39
41
 
40
42
  def preview
41
- sample_data = params[:sample_data].present? ? JSON.parse(params[:sample_data]) : {}
42
- @rendered_subject = @template.renderer.render(@template.subject || '', { 'template_data' => sample_data })
43
- @rendered_html = @template.render(:html, { 'template_data' => sample_data })
44
- @rendered_text = @template.render(:text, { 'template_data' => sample_data })
45
-
46
- render layout: false
43
+ render_preview
47
44
  rescue JSON::ParserError
48
45
  render plain: 'Invalid JSON in sample data', status: :bad_request
46
+ rescue ArgumentError => e
47
+ render plain: e.message, status: :bad_request
48
+ rescue Liquid::Error => e
49
+ render plain: "Template rendering error: #{e.message}", status: :unprocessable_entity
49
50
  end
50
51
 
51
52
  private
@@ -57,5 +58,33 @@ module Sincerely
57
58
  def template_params
58
59
  params.require(:template).permit(:name, :subject, :sender, :html_content, :text_content)
59
60
  end
61
+
62
+ def all_templates
63
+ Sincerely::Templates::NotificationTemplate.order(created_at: :desc)
64
+ end
65
+
66
+ def template_notification_count
67
+ notification_model.where(template_id: @template.id).count
68
+ end
69
+
70
+ def render_preview
71
+ @rendered_subject = @template.renderer.render(@template.subject || '', preview_context)
72
+ @rendered_html = @template.render(:html, preview_context)
73
+ @rendered_text = @template.render(:text, preview_context)
74
+ render layout: false
75
+ end
76
+
77
+ def preview_context
78
+ { 'template_data' => parsed_sample_data }
79
+ end
80
+
81
+ def parsed_sample_data
82
+ return {} if params[:sample_data].blank?
83
+
84
+ parsed = JSON.parse(params[:sample_data])
85
+ raise ArgumentError, 'Sample data must be a JSON object' unless parsed.is_a?(Hash)
86
+
87
+ parsed
88
+ end
60
89
  end
61
90
  end
@@ -17,12 +17,34 @@ module Sincerely
17
17
  end
18
18
  end
19
19
 
20
+ def inline_svg(name)
21
+ return '' unless name.match?(%r{\A[a-z0-9_\-/]+\z}i) && name.exclude?('..')
22
+
23
+ base = Sincerely::Engine.root.join('app', 'assets', 'images')
24
+ path = base.join("#{name}.svg")
25
+ return '' unless path.to_s.start_with?(base.to_s) && path.exist?
26
+
27
+ raw path.read # rubocop:disable Rails/OutputSafety -- SVGs are read from the engine's own assets, not user input
28
+ end
29
+
20
30
  def format_timestamp(time)
21
31
  return '-' unless time
22
32
 
23
33
  time.strftime('%b %d, %Y %H:%M:%S')
24
34
  end
25
35
 
36
+ def event_timestamp(event)
37
+ event.timestamp || event.created_at
38
+ end
39
+
40
+ def format_event_options(options)
41
+ return nil if options.blank?
42
+
43
+ JSON.pretty_generate(options)
44
+ rescue JSON::GeneratorError
45
+ options.to_s
46
+ end
47
+
26
48
  def event_type_badge_class(event_type)
27
49
  case event_type.to_s.downcase
28
50
  when 'delivery', 'send' then 'badge-delivered'