sincerely 1.0.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 (63) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +38 -0
  4. data/CHANGELOG.md +12 -0
  5. data/README.md +202 -0
  6. data/Rakefile +12 -0
  7. data/app/controllers/sincerely/application_controller.rb +81 -0
  8. data/app/controllers/sincerely/dashboard_controller.rb +112 -0
  9. data/app/controllers/sincerely/delivery_events_controller.rb +31 -0
  10. data/app/controllers/sincerely/engagement_events_controller.rb +32 -0
  11. data/app/controllers/sincerely/notifications_controller.rb +69 -0
  12. data/app/controllers/sincerely/send_controller.rb +105 -0
  13. data/app/controllers/sincerely/templates_controller.rb +61 -0
  14. data/app/helpers/sincerely/application_helper.rb +39 -0
  15. data/app/views/layouts/sincerely/application.html.erb +593 -0
  16. data/app/views/sincerely/dashboard/index.html.erb +382 -0
  17. data/app/views/sincerely/delivery_events/index.html.erb +97 -0
  18. data/app/views/sincerely/engagement_events/index.html.erb +97 -0
  19. data/app/views/sincerely/notifications/index.html.erb +91 -0
  20. data/app/views/sincerely/notifications/show.html.erb +98 -0
  21. data/app/views/sincerely/send/new.html.erb +592 -0
  22. data/app/views/sincerely/shared/_pagination.html.erb +19 -0
  23. data/app/views/sincerely/templates/_form.html.erb +226 -0
  24. data/app/views/sincerely/templates/edit.html.erb +11 -0
  25. data/app/views/sincerely/templates/index.html.erb +59 -0
  26. data/app/views/sincerely/templates/new.html.erb +11 -0
  27. data/app/views/sincerely/templates/preview.html.erb +48 -0
  28. data/app/views/sincerely/templates/show.html.erb +69 -0
  29. data/config/routes.rb +21 -0
  30. data/lib/config/application_config.rb +18 -0
  31. data/lib/config/sincerely_config.rb +31 -0
  32. data/lib/generators/sincerely/aws_ses_webhook_controller_generator.rb +31 -0
  33. data/lib/generators/sincerely/events_generator.rb +45 -0
  34. data/lib/generators/sincerely/install_generator.rb +18 -0
  35. data/lib/generators/sincerely/migration_generator.rb +65 -0
  36. data/lib/generators/templates/aws_ses_webhook_controller.rb.erb +3 -0
  37. data/lib/generators/templates/events/delivery_event_model.rb.erb +5 -0
  38. data/lib/generators/templates/events/delivery_events_create.rb.erb +20 -0
  39. data/lib/generators/templates/events/engagement_event_model.rb.erb +5 -0
  40. data/lib/generators/templates/events/engagement_events_create.rb.erb +21 -0
  41. data/lib/generators/templates/notification_model.rb.erb +3 -0
  42. data/lib/generators/templates/notifications_create.rb.erb +21 -0
  43. data/lib/generators/templates/notifications_update.rb.erb +16 -0
  44. data/lib/generators/templates/sincerely.yml +21 -0
  45. data/lib/generators/templates/templates_create.rb.erb +15 -0
  46. data/lib/sincerely/delivery_systems/email_aws_ses.rb +69 -0
  47. data/lib/sincerely/engine.rb +7 -0
  48. data/lib/sincerely/mixins/notification_model.rb +94 -0
  49. data/lib/sincerely/mixins/webhooks/aws_ses_events.rb +74 -0
  50. data/lib/sincerely/renderers/liquid.rb +14 -0
  51. data/lib/sincerely/services/events/aws_ses_bounce_event.rb +26 -0
  52. data/lib/sincerely/services/events/aws_ses_click_event.rb +25 -0
  53. data/lib/sincerely/services/events/aws_ses_complaint_event.rb +25 -0
  54. data/lib/sincerely/services/events/aws_ses_delivery_event.rb +17 -0
  55. data/lib/sincerely/services/events/aws_ses_event.rb +56 -0
  56. data/lib/sincerely/services/events/aws_ses_open_event.rb +25 -0
  57. data/lib/sincerely/services/process_delivery_event.rb +72 -0
  58. data/lib/sincerely/templates/email_liquid_template.rb +13 -0
  59. data/lib/sincerely/templates/notification_template.rb +22 -0
  60. data/lib/sincerely/version.rb +5 -0
  61. data/lib/sincerely.rb +20 -0
  62. data/sincerely.gemspec +37 -0
  63. metadata +187 -0
@@ -0,0 +1,5 @@
1
+ class Sincerely::DeliveryEvent < ApplicationRecord
2
+ self.table_name_prefix = :sincerely_
3
+
4
+ serialize :options, coder: JSON
5
+ end
@@ -0,0 +1,20 @@
1
+ class CreateSincerelyDeliveryEvents < ActiveRecord::Migration<%= migration_version %>
2
+ def change
3
+ create_table :sincerely_delivery_events do |t|
4
+ t.string :message_id
5
+ t.string :delivery_system
6
+ t.string :event_type
7
+ t.string :recipient
8
+ t.string :delivery_event_type
9
+ t.string :delivery_event_subtype
10
+ t.text :options
11
+ t.datetime :timestamp
12
+
13
+ t.timestamps
14
+ end
15
+
16
+ add_index :sincerely_delivery_events, :message_id
17
+ add_index :sincerely_delivery_events, :delivery_system
18
+ add_index :sincerely_delivery_events, :created_at
19
+ end
20
+ end
@@ -0,0 +1,5 @@
1
+ class Sincerely::EngagementEvent < ApplicationRecord
2
+ self.table_name_prefix = :sincerely_
3
+
4
+ serialize :options, coder: JSON
5
+ end
@@ -0,0 +1,21 @@
1
+ class CreateSincerelyEngagementEvents < ActiveRecord::Migration<%= migration_version %>
2
+ def change
3
+ create_table :sincerely_engagement_events do |t|
4
+ t.string :message_id
5
+ t.string :delivery_system
6
+ t.string :event_type
7
+ t.string :recipient
8
+ t.string :ip_address
9
+ t.string :user_agent
10
+ t.string :link
11
+ t.string :feedback_type
12
+ t.text :options
13
+ t.datetime :timestamp
14
+
15
+ t.timestamps
16
+ end
17
+
18
+ add_index :sincerely_engagement_events, :message_id
19
+ add_index :sincerely_engagement_events, :created_at
20
+ end
21
+ end
@@ -0,0 +1,3 @@
1
+ class <%= class_name %> < ApplicationRecord
2
+ include Sincerely::Mixins::NotificationModel
3
+ end
@@ -0,0 +1,21 @@
1
+ class Create<%= class_name.pluralize %> < ActiveRecord::Migration<%= migration_version %>
2
+ def change
3
+ create_table :<%= table_name %> do |t|
4
+ t.string :recipient
5
+ t.string :notification_type
6
+ t.text :delivery_options
7
+ t.string :delivery_system
8
+ t.string :delivery_state
9
+ t.string :template_id
10
+ t.datetime :sent_at
11
+ t.datetime :scheduled_at
12
+ t.integer :delivery_attempts
13
+ t.string :message_id
14
+ t.string :error_message
15
+
16
+ t.timestamps
17
+ end
18
+
19
+ add_index :<%= table_name %>, :recipient
20
+ end
21
+ end
@@ -0,0 +1,16 @@
1
+ class Update<%= class_name.pluralize %> < ActiveRecord::Migration<%= migration_version %>
2
+ def change
3
+ add_column :<%= table_name %>, :recipient, :string
4
+ add_column :<%= table_name %>, :notification_type, :string
5
+ add_column :<%= table_name %>, :delivery_options, :text
6
+ add_column :<%= table_name %>, :delivery_system, :string
7
+ add_column :<%= table_name %>, :delivery_state, :string
8
+ add_column :<%= table_name %>, :sent_at, :datetime
9
+ add_column :<%= table_name %>, :scheduled_at, :datetime
10
+ add_column :<%= table_name %>, :delivery_attempts, :integer
11
+ add_column :<%= table_name %>, :message_id, :string
12
+ add_column :<%= table_name %>, :error_message, :string
13
+
14
+ add_index :<%= table_name %>, :recipient
15
+ end
16
+ end
@@ -0,0 +1,21 @@
1
+ defaults: &defaults
2
+ notification_model_name: Notification
3
+ delivery_methods:
4
+ email:
5
+ delivery_system: Sincerely::DeliverySystems::EmailAwsSes
6
+ options:
7
+ region: <%%= ENV['AWS_REGION'] %>
8
+ access_key_id: <%%= ENV['AWS_ACCESS_KEY_ID'] %>
9
+ secret_access_key: <%%= ENV['AWS_SECRET_ACCESS_KEY'] %>
10
+ configuration_set_name: config_set
11
+ test:
12
+ <<: *defaults
13
+
14
+ development:
15
+ <<: *defaults
16
+
17
+ staging:
18
+ <<: *defaults
19
+
20
+ production:
21
+ <<: *defaults
@@ -0,0 +1,15 @@
1
+ class Create<%= class_name %>Templates < ActiveRecord::Migration<%= migration_version %>
2
+ def change
3
+ create_table :notification_templates do |t|
4
+ t.integer :template_id
5
+ t.string :name
6
+ t.string :subject
7
+ t.string :sender
8
+ t.string :html_content
9
+ t.string :text_content
10
+ t.string :type
11
+
12
+ t.timestamps
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'aws-sdk-sesv2'
4
+
5
+ module Sincerely
6
+ module DeliverySystems
7
+ class EmailAwsSes
8
+ DELIVERY_SYSTEM = :aws_ses2
9
+
10
+ class << self
11
+ def call(notification:, options: {})
12
+ new(notification:, options:).deliver
13
+ end
14
+ end
15
+
16
+ def initialize(notification:, options:)
17
+ @notification = notification
18
+ @template = notification.template
19
+ @options = options.symbolize_keys
20
+ end
21
+
22
+ def deliver
23
+ response = client.send_email(email_options)
24
+ update_notification(response)
25
+ end
26
+
27
+ private
28
+
29
+ attr_reader :notification, :template, :options
30
+
31
+ def client
32
+ client_options = options.slice(:region, :access_key_id, :secret_access_key)
33
+ @client ||= Aws::SESV2::Client.new(**client_options)
34
+ end
35
+
36
+ def email_options
37
+ opts = {
38
+ from_email_address: template.sender,
39
+ destination: { to_addresses: [notification.recipient] },
40
+ content: {
41
+ simple: {
42
+ subject: { data: subject },
43
+ body: {
44
+ html: {
45
+ data: notification.render_content(:html)
46
+ },
47
+ text: {
48
+ data: notification.render_content(:text)
49
+ }
50
+ }
51
+ }
52
+ }
53
+ }
54
+ config_set = options[:configuration_set_name]
55
+ opts = opts.merge(configuration_set_name: config_set) if config_set.present?
56
+ opts
57
+ end
58
+
59
+ def subject
60
+ raw_subject = notification.delivery_options&.fetch('subject', nil) || template.subject
61
+ template.renderer.render(raw_subject, notification.delivery_options&.stringify_keys)
62
+ end
63
+
64
+ def update_notification(response)
65
+ notification.update(message_id: response.message_id, delivery_system: DELIVERY_SYSTEM, sent_at: Time.current)
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sincerely
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace Sincerely
6
+ end
7
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'aasm'
4
+ require 'active_support/concern'
5
+
6
+ module Sincerely
7
+ module Mixins
8
+ module NotificationModel
9
+ extend ActiveSupport::Concern
10
+
11
+ included do # rubocop:disable Metrics/BlockLength
12
+ include AASM
13
+
14
+ serialize :delivery_options, coder: JSON
15
+
16
+ belongs_to :template, class_name: 'Sincerely::Templates::NotificationTemplate'
17
+
18
+ validates :recipient, presence: true
19
+ validates :notification_type, inclusion: { in: %w[email sms push] }
20
+
21
+ aasm column: :delivery_state do # rubocop:disable Metrics/BlockLength
22
+ state :draft, initial: true
23
+ state :accepted
24
+ state :rejected
25
+ state :delivered
26
+ state :bounced
27
+ state :complained
28
+ state :delayed
29
+ state :opened
30
+ state :clicked
31
+
32
+ # Terminal states: bounced, complained, rejected - no transitions allowed from these
33
+ # Flow: draft -> accepted -> delivered -> opened -> clicked
34
+ # \-> bounced (terminal)
35
+ # \-> complained (terminal from delivered/opened/clicked)
36
+
37
+ event :set_accepted do
38
+ transitions to: :accepted, from: [:draft]
39
+ end
40
+
41
+ event :set_rejected do
42
+ transitions to: :rejected, from: %i[draft accepted]
43
+ end
44
+
45
+ event :set_delivered do
46
+ transitions to: :delivered, from: %i[draft accepted delayed]
47
+ end
48
+
49
+ event :set_bounced do
50
+ transitions to: :bounced, from: %i[draft accepted delivered delayed]
51
+ end
52
+
53
+ event :set_complained do
54
+ transitions to: :complained, from: %i[delivered opened clicked]
55
+ end
56
+
57
+ event :set_delayed do
58
+ transitions to: :delayed, from: %i[draft accepted]
59
+ end
60
+
61
+ event :set_opened do
62
+ transitions to: :opened, from: [:delivered]
63
+ end
64
+
65
+ event :set_clicked do
66
+ transitions to: :clicked, from: %i[delivered opened]
67
+ end
68
+ end
69
+
70
+ def render_content(content_type)
71
+ template.render(content_type, delivery_options)
72
+ end
73
+
74
+ def deliver
75
+ raise StandardError, "Delivery method not configured for #{notification_type}." if delivery_method.blank?
76
+
77
+ call_delivery_method
78
+ end
79
+
80
+ private
81
+
82
+ def delivery_method
83
+ @delivery_method ||= Sincerely.config.delivery_methods&.fetch(notification_type, {})
84
+ end
85
+
86
+ def call_delivery_method
87
+ class_name, options = delivery_method.values_at('delivery_system', 'options')
88
+ klass = class_name.constantize
89
+ klass.call(notification: self, options:)
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/concern'
4
+ require 'aws-sdk-sns'
5
+
6
+ require 'sincerely/services/process_delivery_event'
7
+ require 'sincerely/services/events/aws_ses_event'
8
+
9
+ module Sincerely
10
+ module Mixins
11
+ module Webhooks
12
+ module AwsSesEvents
13
+ extend ActiveSupport::Concern
14
+
15
+ included do # rubocop:disable Metrics/BlockLength
16
+ skip_before_action :verify_authenticity_token, only: [:create]
17
+
18
+ def create
19
+ verifier.authenticate!(request.raw_post)
20
+
21
+ case sns_message_type
22
+ when 'SubscriptionConfirmation'
23
+ confirm_subscription
24
+ when 'Notification'
25
+ logger&.info event_payload # temporary log
26
+ event = Sincerely::Services::Events::AwsSesEvent.event_for(event_payload)
27
+ Sincerely::Services::ProcessDeliveryEvent.call(event:)
28
+ end
29
+
30
+ render json: { message: 'ok' }, status: :ok
31
+ end
32
+
33
+ private
34
+
35
+ def verifier
36
+ @verifier ||= Aws::SNS::MessageVerifier.new
37
+ end
38
+
39
+ def posted_message_body
40
+ @posted_message_body ||= JSON.parse(request.raw_post)
41
+ end
42
+
43
+ def sns_message_type
44
+ posted_message_body['Type']
45
+ end
46
+
47
+ def topic_arn
48
+ posted_message_body['TopicArn']
49
+ end
50
+
51
+ def token
52
+ posted_message_body['Token']
53
+ end
54
+
55
+ def event_payload
56
+ message = posted_message_body['Message']
57
+ JSON.parse(message) if message.present?
58
+ end
59
+
60
+ def delivery_options
61
+ Sincerely.config.delivery_methods.dig('email', 'options').symbolize_keys
62
+ end
63
+
64
+ def confirm_subscription
65
+ client_options = delivery_options.slice(:region, :access_key_id, :secret_access_key)
66
+ client = Aws::SNS::Client.new(**client_options)
67
+
68
+ client.confirm_subscription(topic_arn:, token:)
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'liquid'
4
+
5
+ module Sincerely
6
+ module Renderers
7
+ class Liquid
8
+ def self.render(content, options = {})
9
+ template = ::Liquid::Template.parse(content)
10
+ template.render(options&.dig('template_data')&.stringify_keys)
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sincerely
4
+ module Services
5
+ module Events
6
+ class AwsSesBounceEvent < AwsSesEvent
7
+ def delivery_event_type
8
+ event.dig('bounce', 'bounceType')
9
+ end
10
+
11
+ def delivery_event_subtype
12
+ event.dig('bounce', 'bounceSubType')
13
+ end
14
+
15
+ def options
16
+ bounced_recipient = event.dig('bounce', 'bouncedRecipients')[0]
17
+ {
18
+ action: bounced_recipient&.dig('action'),
19
+ status: bounced_recipient&.dig('status'),
20
+ diagnostic_code: bounced_recipient&.dig('diagnosticCode')
21
+ }
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sincerely
4
+ module Services
5
+ module Events
6
+ class AwsSesClickEvent < AwsSesEvent
7
+ def ip_address
8
+ event.dig('click', 'ipAddress')
9
+ end
10
+
11
+ def user_agent
12
+ event.dig('click', 'userAgent')
13
+ end
14
+
15
+ def link
16
+ event.dig('click', 'link')
17
+ end
18
+
19
+ def feedback_type
20
+ nil
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sincerely
4
+ module Services
5
+ module Events
6
+ class AwsSesComplaintEvent < AwsSesEvent
7
+ def ip_address
8
+ nil
9
+ end
10
+
11
+ def user_agent
12
+ event.dig('complaint', 'userAgent')
13
+ end
14
+
15
+ def link
16
+ nil
17
+ end
18
+
19
+ def feedback_type
20
+ event.dig('complaint', 'complaintFeedbackType')
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sincerely
4
+ module Services
5
+ module Events
6
+ class AwsSesDeliveryEvent < AwsSesEvent
7
+ def delivery_event_type
8
+ nil
9
+ end
10
+
11
+ def delivery_event_subtype
12
+ nil
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sincerely
4
+ module Services
5
+ module Events
6
+ class AwsSesEvent
7
+ attr_reader :event
8
+
9
+ class << self
10
+ def event_for(event_payload)
11
+ case event_type(event_payload)
12
+ when 'bounce' then AwsSesBounceEvent.new(event_payload)
13
+ when 'delivery' then AwsSesDeliveryEvent.new(event_payload)
14
+ when 'click' then AwsSesClickEvent.new(event_payload)
15
+ when 'open' then AwsSesOpenEvent.new(event_payload)
16
+ when 'complaint' then AwsSesComplaintEvent.new(event_payload)
17
+ else new(event_payload)
18
+ end
19
+ end
20
+
21
+ def event_type(event_payload)
22
+ event_payload&.dig('eventType')&.downcase
23
+ end
24
+ end
25
+
26
+ def initialize(event_payload)
27
+ @event = event_payload
28
+ end
29
+
30
+ def event_type
31
+ self.class.event_type(event)
32
+ end
33
+
34
+ def message_id
35
+ event.dig('mail', 'messageId')
36
+ end
37
+
38
+ def recipient
39
+ event.dig('mail', 'destination')[0]
40
+ end
41
+
42
+ def timestamp
43
+ Time.parse(event.dig(event_type, 'timestamp') || event.dig('mail', 'timestamp')) # rubocop:disable Rails/TimeZone
44
+ end
45
+
46
+ def options
47
+ nil
48
+ end
49
+
50
+ def delivery_system
51
+ Sincerely::DeliverySystems::EmailAwsSes::DELIVERY_SYSTEM
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sincerely
4
+ module Services
5
+ module Events
6
+ class AwsSesOpenEvent < AwsSesEvent
7
+ def ip_address
8
+ event.dig('open', 'ipAddress')
9
+ end
10
+
11
+ def user_agent
12
+ event.dig('open', 'userAgent')
13
+ end
14
+
15
+ def link
16
+ nil
17
+ end
18
+
19
+ def feedback_type
20
+ nil
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sincerely
4
+ module Services
5
+ class ProcessDeliveryEvent
6
+ delegate :event_type, :message_id, :recipient, :timestamp, :options, :delivery_system,
7
+ to: :event
8
+
9
+ class << self
10
+ def call(event:)
11
+ new(event:).call
12
+ end
13
+ end
14
+
15
+ def initialize(event:)
16
+ @event = event
17
+ end
18
+
19
+ def call
20
+ return if notification.blank?
21
+
22
+ case event_type.to_sym
23
+ when :bounce
24
+ notification.set_bounced!
25
+ create_event
26
+ when :complaint
27
+ notification.set_complained!
28
+ create_event
29
+ when :delivery
30
+ notification.set_delivered!
31
+ create_event
32
+ when :send
33
+ notification.set_accepted! if notification.may_set_accepted?
34
+ when :reject
35
+ notification.set_rejected!
36
+ notification.update(error_message: event.rejection_reason)
37
+ when :open
38
+ notification.set_opened!
39
+ create_event
40
+ when :click
41
+ notification.set_clicked!
42
+ create_event
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ attr_reader :event
49
+
50
+ def create_event
51
+ case event_type.to_sym
52
+ when :bounce, :delivery
53
+ Sincerely::DeliveryEvent.create(
54
+ message_id:, delivery_system:, event_type:, recipient:, timestamp:, options:,
55
+ delivery_event_type: event.delivery_event_type, delivery_event_subtype: event.delivery_event_subtype
56
+ )
57
+ else
58
+ Sincerely::EngagementEvent.create(
59
+ message_id:, delivery_system:, event_type:, recipient:, timestamp:, options:,
60
+ ip_address: event.ip_address, user_agent: event.user_agent, link: event.link,
61
+ feedback_type: event.feedback_type
62
+ )
63
+ end
64
+ end
65
+
66
+ def notification
67
+ model = Sincerely.config.notification_model_name.constantize
68
+ @notification ||= model.find_by(message_id:)
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sincerely/templates/notification_template'
4
+
5
+ module Sincerely
6
+ module Templates
7
+ class EmailLiquidTemplate < NotificationTemplate
8
+ def renderer
9
+ Sincerely::Renderers::Liquid
10
+ end
11
+ end
12
+ end
13
+ end