spree_notification_providers 0.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 (61) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +24 -0
  3. data/LICENSE +29 -0
  4. data/README.md +322 -0
  5. data/app/controllers/spree/admin/notification_channel_providers_controller.rb +152 -0
  6. data/app/controllers/spree/admin/notification_deliveries_controller.rb +69 -0
  7. data/app/controllers/spree/admin/notification_providers_controller.rb +31 -0
  8. data/app/controllers/spree/webhooks/gupshup_controller.rb +61 -0
  9. data/app/helpers/spree/admin/notification_masking_helper.rb +65 -0
  10. data/app/jobs/spree/notification_deliveries/purge_job.rb +26 -0
  11. data/app/models/concerns/spree/notification_provider_settings.rb +81 -0
  12. data/app/models/spree/notification_channel_provider.rb +165 -0
  13. data/app/models/spree/notification_delivery.rb +39 -0
  14. data/app/models/spree/notification_provider_config.rb +32 -0
  15. data/app/models/spree/notification_providers_store_decorator.rb +16 -0
  16. data/app/models/spree/permission_sets/notification_provider_management.rb +20 -0
  17. data/app/views/spree/admin/notification_channel_providers/_input_field.html.erb +51 -0
  18. data/app/views/spree/admin/notification_channel_providers/edit.html.erb +118 -0
  19. data/app/views/spree/admin/notification_deliveries/index.html.erb +127 -0
  20. data/app/views/spree/admin/notification_deliveries/show.html.erb +52 -0
  21. data/app/views/spree/admin/notification_providers/index.html.erb +114 -0
  22. data/config/locales/en.yml +92 -0
  23. data/config/routes.rb +17 -0
  24. data/db/migrate/20261019000001_create_spree_notification_provider_configs.rb +19 -0
  25. data/db/migrate/20261019000002_create_spree_notification_channel_providers.rb +23 -0
  26. data/db/migrate/20261019000003_create_spree_notification_deliveries.rb +29 -0
  27. data/lib/generators/spree_notification_providers/import_env/import_env_generator.rb +23 -0
  28. data/lib/generators/spree_notification_providers/install/install_generator.rb +41 -0
  29. data/lib/generators/spree_notification_providers/install/templates/initializer.rb +14 -0
  30. data/lib/spree_notification_providers/adapters/base.rb +76 -0
  31. data/lib/spree_notification_providers/adapters/gupshup.rb +87 -0
  32. data/lib/spree_notification_providers/adapters/http_outcome.rb +61 -0
  33. data/lib/spree_notification_providers/adapters/log.rb +26 -0
  34. data/lib/spree_notification_providers/adapters/msg91.rb +117 -0
  35. data/lib/spree_notification_providers/adapters/smtp.rb +77 -0
  36. data/lib/spree_notification_providers/bulk_message.rb +33 -0
  37. data/lib/spree_notification_providers/configuration.rb +32 -0
  38. data/lib/spree_notification_providers/engine.rb +59 -0
  39. data/lib/spree_notification_providers/http.rb +42 -0
  40. data/lib/spree_notification_providers/importers/env_importer.rb +43 -0
  41. data/lib/spree_notification_providers/importers/writer.rb +132 -0
  42. data/lib/spree_notification_providers/input_definition.rb +75 -0
  43. data/lib/spree_notification_providers/mail_delivery_method.rb +78 -0
  44. data/lib/spree_notification_providers/message.rb +55 -0
  45. data/lib/spree_notification_providers/message_type.rb +26 -0
  46. data/lib/spree_notification_providers/message_types.rb +8 -0
  47. data/lib/spree_notification_providers/provider_definition.rb +103 -0
  48. data/lib/spree_notification_providers/providers/gupshup.rb +26 -0
  49. data/lib/spree_notification_providers/providers/log.rb +16 -0
  50. data/lib/spree_notification_providers/providers/msg91.rb +27 -0
  51. data/lib/spree_notification_providers/providers/smtp.rb +18 -0
  52. data/lib/spree_notification_providers/registry.rb +79 -0
  53. data/lib/spree_notification_providers/result.rb +73 -0
  54. data/lib/spree_notification_providers/router.rb +205 -0
  55. data/lib/spree_notification_providers/version.rb +5 -0
  56. data/lib/spree_notification_providers.rb +96 -0
  57. data/spec/factories/notification_providers.rb +91 -0
  58. data/spec/support/message_types.rb +15 -0
  59. data/spec/support/provider_http_helpers.rb +46 -0
  60. data/spree_notification_providers.gemspec +44 -0
  61. metadata +189 -0
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpreeNotificationProviders
4
+ module Importers
5
+ # Fills provider inputs for one store without ever overwriting a value
6
+ # that is already set, and keeps a printable report of what it did.
7
+ class Writer
8
+ Line = Struct.new(:key, :status, :detail, keyword_init: true) do
9
+ def to_s
10
+ " #{key.ljust(28, '.')} #{[status, detail].compact.join(' ')}"
11
+ end
12
+ end
13
+
14
+ attr_reader :store, :lines
15
+
16
+ def initialize(store, dry_run: false)
17
+ @store = store
18
+ @dry_run = dry_run
19
+ @lines = []
20
+ @configs = {}
21
+ @entries = {}
22
+ @sources = Hash.new { |hash, key| hash[key] = Set.new }
23
+ end
24
+
25
+ def dry_run? = @dry_run
26
+
27
+ # scope is :shared or a channel.
28
+ def set_input(provider_key, scope, name, value, source:, flag: nil)
29
+ value = value.to_s.strip
30
+ return if value.empty?
31
+
32
+ definition = SpreeNotificationProviders.provider(provider_key) or return
33
+ inputs = scope == :shared ? definition.shared_inputs : Array(definition.capability(scope)&.inputs)
34
+ input = inputs.find { |candidate| candidate.name == name.to_sym } or return
35
+
36
+ record = scope == :shared ? config(provider_key) : entry(provider_key, scope)
37
+ key = "#{provider_key}.#{scope == :shared ? '' : "#{scope}."}#{name}"
38
+ if record.raw_value(input).present?
39
+ lines << Line.new(key: key, status: 'unchanged')
40
+ return
41
+ end
42
+
43
+ record.write_raw(input, value)
44
+ @sources[provider_key.to_sym] << source_kind(source)
45
+ detail = input.secret? ? "(secret, #{mask(value)})" : nil
46
+ lines << Line.new(key: key, status: "imported from #{source}", detail: [detail, flag].compact.join(' ').presence)
47
+ end
48
+
49
+ def set_template(provider_key, channel, message_type, value, source:)
50
+ value = value.to_s.strip
51
+ return if value.empty?
52
+
53
+ record = entry(provider_key, channel)
54
+ key = "#{provider_key}.#{channel}.template.#{message_type}"
55
+ if record.templates.to_h[message_type.to_s].present?
56
+ lines << Line.new(key: key, status: 'unchanged')
57
+ return
58
+ end
59
+
60
+ record.templates = record.templates.to_h.merge(message_type.to_s => value)
61
+ @sources[provider_key.to_sym] << source_kind(source)
62
+ lines << Line.new(key: key, status: "imported from #{source}")
63
+ end
64
+
65
+ # Creates the channel entry if missing. Existing entries keep their
66
+ # enabled flag and position.
67
+ def ensure_entry(provider_key, channel, enabled:)
68
+ record = entry(provider_key, channel)
69
+ record.enabled = enabled if record.new_record?
70
+ record
71
+ end
72
+
73
+ def note(key, text)
74
+ lines << Line.new(key: key, status: text)
75
+ end
76
+
77
+ def touched?(provider_key)
78
+ @sources.key?(provider_key.to_sym)
79
+ end
80
+
81
+ def save!
82
+ return if dry_run?
83
+
84
+ ActiveRecord::Base.transaction do
85
+ @configs.each do |provider_key, record|
86
+ next unless record.new_record? || record.changed?
87
+
88
+ if @sources.key?(provider_key)
89
+ kinds = @sources[provider_key]
90
+ record.import_source ||= kinds.size > 1 ? 'mixed' : kinds.first
91
+ record.imported_at ||= Time.current
92
+ end
93
+ record.save!(validate: false)
94
+ end
95
+ @entries.each_value do |record|
96
+ next unless record.new_record? || record.changed?
97
+
98
+ record.provider_config # make sure the shared config exists
99
+ record.save!(validate: false)
100
+ end
101
+ end
102
+ end
103
+
104
+ def report
105
+ (["store #{store.code}"] + lines.map(&:to_s)).join("\n")
106
+ end
107
+
108
+ private
109
+
110
+ def config(provider_key)
111
+ @configs[provider_key.to_sym] ||=
112
+ Spree::NotificationProviderConfig.find_or_initialize_by(store_id: store.id, provider_key: provider_key.to_s)
113
+ end
114
+
115
+ def entry(provider_key, channel)
116
+ @entries[[provider_key.to_sym, channel.to_sym]] ||= begin
117
+ config(provider_key)
118
+ Spree::NotificationChannelProvider.find_or_initialize_by(store_id: store.id, channel: channel.to_s,
119
+ provider_key: provider_key.to_s)
120
+ end
121
+ end
122
+
123
+ def source_kind(source)
124
+ source.to_s.start_with?('ENV') ? 'env' : 'legacy_columns'
125
+ end
126
+
127
+ def mask(value)
128
+ value.length >= 12 ? "••••#{value[-4..]}" : 'set'
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpreeNotificationProviders
4
+ # One input a provider needs, e.g. MSG91's auth key or SMTP's port.
5
+ #
6
+ # `env` names the environment variable an older setup kept this value in. It
7
+ # is read by the importers only; adapters never read ENV.
8
+ class InputDefinition
9
+ TYPES = %i[string integer email phone url select boolean].freeze
10
+ EMAIL_FORMAT = URI::MailTo::EMAIL_REGEXP
11
+ PHONE_FORMAT = /\A\+?[1-9]\d{9,14}\z/
12
+
13
+ attr_reader :name, :provider_key, :scope, :type, :format, :default, :env, :options
14
+
15
+ def initialize(name, provider_key:, scope:, type: :string, required: false, secret: false, format: nil,
16
+ default: nil, env: nil, options: nil, label: nil, help: nil)
17
+ raise ArgumentError, "unknown input type #{type.inspect}" unless TYPES.include?(type)
18
+
19
+ @name = name.to_sym
20
+ @provider_key = provider_key.to_sym
21
+ @scope = scope
22
+ @type = type
23
+ @required = required
24
+ @secret = secret
25
+ @format = format
26
+ @default = default
27
+ @env = env
28
+ @options = options
29
+ @label = label
30
+ @help = help
31
+ end
32
+
33
+ def required? = @required
34
+ def secret? = @secret
35
+ def key = name.to_s
36
+
37
+ def label
38
+ I18n.t("spree_notification_providers.providers.#{provider_key}.inputs.#{name}.label",
39
+ default: @label || name.to_s.humanize)
40
+ end
41
+
42
+ def help
43
+ I18n.t("spree_notification_providers.providers.#{provider_key}.inputs.#{name}.help", default: @help.to_s)
44
+ end
45
+
46
+ # The value an adapter sees: the stored value, else the default, cast.
47
+ def resolve(raw)
48
+ value = raw.to_s.strip.presence || default
49
+ return nil if value.nil?
50
+
51
+ case type
52
+ when :integer then value.to_i
53
+ when :boolean then ActiveModel::Type::Boolean.new.cast(value)
54
+ else value.to_s
55
+ end
56
+ end
57
+
58
+ # nil when valid (blank counts as valid; readiness reports missing inputs).
59
+ def validate(raw)
60
+ value = raw.to_s.strip
61
+ return nil if value.empty?
62
+
63
+ message = case type
64
+ when :integer then 'must be a whole number' unless value.match?(/\A\d+\z/)
65
+ when :email then 'must be an email address' unless value.match?(EMAIL_FORMAT)
66
+ when :phone then 'must be a phone number with country code' unless value.delete(' -').match?(PHONE_FORMAT)
67
+ when :url then 'must be a URL' unless value.match?(%r{\Ahttps?://\S+\z})
68
+ when :boolean then 'must be true or false' unless %w[true false 1 0].include?(value)
69
+ when :select then 'is not an allowed option' unless Array(options).map(&:to_s).include?(value)
70
+ end
71
+ message ||= 'is not in the expected format' if format && !value.match?(format)
72
+ message
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpreeNotificationProviders
4
+ # Action Mailer delivery method that sends every mail through the store's
5
+ # Email providers in priority order.
6
+ #
7
+ # config.action_mailer.delivery_method = :spree_notification_providers
8
+ #
9
+ # Headers read, then removed before sending:
10
+ # X-Spree-Store-Id - store id (else Spree::Current.store, else default store)
11
+ # X-Spree-Message-Type - registered message type (default: transactional_email)
12
+ # X-Spree-Provider-Template-<key> - template override for one provider, e.g. ...-Msg91
13
+ #
14
+ # Raises DeliveryFailed only when no provider can have taken the mail, so
15
+ # deliver_later retries. An :unknown outcome is not retried: the mail may
16
+ # already be on its way.
17
+ class MailDeliveryMethod
18
+ STORE_HEADER = 'X-Spree-Store-Id'
19
+ TYPE_HEADER = 'X-Spree-Message-Type'
20
+ TEMPLATE_HEADER_PREFIX = 'X-Spree-Provider-Template-'
21
+
22
+ attr_accessor :settings
23
+
24
+ def initialize(settings = {})
25
+ @settings = settings
26
+ end
27
+
28
+ def deliver!(mail)
29
+ store = resolve_store(header(mail, STORE_HEADER))
30
+ type = header(mail, TYPE_HEADER).presence || 'transactional_email'
31
+ templates = template_headers(mail)
32
+ strip_headers(mail)
33
+
34
+ message = Message.new(channel: :email, type: type, kind: :html, to: Array(mail.to).join(', '),
35
+ subject: mail.subject, mail: mail, templates: templates)
36
+ result = SpreeNotificationProviders.deliver(store: store, message: message)
37
+
38
+ if result.retryable?
39
+ raise DeliveryFailed, "no email provider delivered #{type} (#{result.reasons.join('; ').presence || 'no provider'})"
40
+ end
41
+
42
+ if result.outcome == :unknown
43
+ Rails.logger.warn("[NotificationProviders] mail outcome unknown message_ref=#{result.message_ref}")
44
+ end
45
+ result
46
+ end
47
+
48
+ private
49
+
50
+ # Mail appends custom headers rather than replacing them, so a mailer that
51
+ # sets one after a parent class did has several; the last one wins.
52
+ def header(mail, name)
53
+ field = mail.header[name]
54
+ field = field.last if field.is_a?(Array)
55
+ field&.value
56
+ end
57
+
58
+ def template_headers(mail)
59
+ mail.header.fields.each_with_object({}) do |field, templates|
60
+ next unless field.name.to_s.downcase.start_with?(TEMPLATE_HEADER_PREFIX.downcase)
61
+
62
+ templates[field.name.to_s[TEMPLATE_HEADER_PREFIX.length..].downcase] = field.value
63
+ end
64
+ end
65
+
66
+ def strip_headers(mail)
67
+ names = mail.header.fields.map(&:name).select do |name|
68
+ name.to_s.casecmp(STORE_HEADER).zero? || name.to_s.casecmp(TYPE_HEADER).zero? ||
69
+ name.to_s.downcase.start_with?(TEMPLATE_HEADER_PREFIX.downcase)
70
+ end
71
+ names.each { |name| mail.header[name] = nil }
72
+ end
73
+
74
+ def resolve_store(id)
75
+ (Spree::Store.find_by(id: id) if id.present?) || SpreeNotificationProviders.configuration.default_store
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpreeNotificationProviders
4
+ # One message to one recipient.
5
+ #
6
+ # kind:
7
+ # :otp - a one-time code (`code`, `expiry_minutes`)
8
+ # :template - a provider-side template filled with `variables`
9
+ # :html - a rendered email (`subject` + `html`, or a ready `mail`)
10
+ class Message
11
+ KINDS = %i[otp template html].freeze
12
+
13
+ attr_reader :channel, :type, :kind, :to, :to_name, :code, :expiry_minutes, :variables, :subject, :html,
14
+ :mail, :from_email, :from_name, :reply_to, :templates, :source
15
+
16
+ def initialize(channel:, type:, to:, kind: nil, to_name: nil, code: nil, expiry_minutes: nil, variables: {},
17
+ subject: nil, html: nil, mail: nil, from_email: nil, from_name: nil, reply_to: nil,
18
+ templates: {}, source: nil)
19
+ @channel = SpreeNotificationProviders.channel!(channel)
20
+ @type = type.to_sym
21
+ @to = to.to_s.strip
22
+ @to_name = to_name
23
+ @code = code&.to_s
24
+ @expiry_minutes = expiry_minutes
25
+ @variables = variables || {}
26
+ @subject = subject
27
+ @html = html
28
+ @mail = mail
29
+ @from_email = from_email.presence
30
+ @from_name = from_name.presence
31
+ @reply_to = reply_to.presence
32
+ @templates = (templates || {}).transform_keys(&:to_s).compact_blank
33
+ @source = source
34
+ @kind = (kind || infer_kind).to_sym
35
+ raise ArgumentError, "unknown kind #{@kind.inspect}" unless KINDS.include?(@kind)
36
+ end
37
+
38
+ # A template id given for this message only, beating the channel's
39
+ # configured template for the type.
40
+ def template_override(provider_key)
41
+ templates[provider_key.to_s]
42
+ end
43
+
44
+ def bulk? = false
45
+
46
+ private
47
+
48
+ def infer_kind
49
+ return :html if html.present? || mail.present?
50
+ return :otp if code.present?
51
+
52
+ :template
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpreeNotificationProviders
4
+ # A kind of message an app sends, e.g. :otp_code or :back_in_stock. Each one
5
+ # gets its own template field on template-based providers.
6
+ class MessageType
7
+ attr_reader :key, :channels
8
+
9
+ def initialize(key, label:, channels:, bulk: false)
10
+ @key = key.to_sym
11
+ @label = label
12
+ @channels = Array(channels).map { |c| SpreeNotificationProviders.channel!(c) }
13
+ @bulk = bulk
14
+ end
15
+
16
+ def bulk? = @bulk
17
+
18
+ def label
19
+ I18n.t("spree_notification_providers.message_types.#{key}", default: @label)
20
+ end
21
+
22
+ def sent_on?(channel)
23
+ channels.include?(channel.to_s.to_sym)
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Message types the gem itself sends. Apps register their own in an
4
+ # initializer (see README).
5
+ SpreeNotificationProviders.register_message_type :provider_test,
6
+ label: 'Test message', channels: %i[email sms whatsapp]
7
+ SpreeNotificationProviders.register_message_type :transactional_email,
8
+ label: 'Transactional email', channels: %i[email]
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpreeNotificationProviders
4
+ # What one provider can do on one channel.
5
+ class ChannelCapability
6
+ KINDS = %i[otp template html bulk].freeze
7
+
8
+ attr_reader :provider, :channel, :kinds, :inputs, :template_format
9
+
10
+ def initialize(provider, channel, kinds:, template_required: false, template_format: nil)
11
+ unknown = Array(kinds) - KINDS
12
+ raise ArgumentError, "unknown kinds #{unknown.inspect}" if unknown.any?
13
+
14
+ @provider = provider
15
+ @channel = channel
16
+ @kinds = Array(kinds)
17
+ @template_required = template_required
18
+ @template_format = template_format
19
+ @inputs = []
20
+ @missing_checks = []
21
+ end
22
+
23
+ def template_required? = @template_required
24
+
25
+ def supports_kind?(kind)
26
+ kinds.include?(kind.to_sym)
27
+ end
28
+
29
+ # DSL
30
+ def input(name, **options)
31
+ @inputs << InputDefinition.new(name, provider_key: provider.key, scope: channel, **options)
32
+ end
33
+
34
+ # Extra readiness rules that depend on several values at once. The block
35
+ # receives (values, templates) and returns labels of what is missing.
36
+ def missing_check(&block)
37
+ @missing_checks << block
38
+ end
39
+
40
+ def extra_missing(values, templates)
41
+ @missing_checks.flat_map { |check| Array(check.call(values, templates)) }
42
+ end
43
+
44
+ def template_valid?(value)
45
+ template_format.nil? || value.to_s.match?(template_format)
46
+ end
47
+ end
48
+
49
+ # A registered provider. Built by `SpreeNotificationProviders.register_provider`.
50
+ class ProviderDefinition
51
+ attr_reader :key, :channels, :shared_inputs
52
+
53
+ def initialize(key)
54
+ @key = key.to_sym
55
+ @channels = {}
56
+ @shared_inputs = []
57
+ end
58
+
59
+ # DSL ------------------------------------------------------------------
60
+
61
+ def label(value = nil)
62
+ return @label = value if value
63
+
64
+ I18n.t("spree_notification_providers.providers.#{key}.label", default: @label || key.to_s.titleize)
65
+ end
66
+
67
+ def adapter(value = nil)
68
+ return @adapter = value if value
69
+
70
+ @adapter
71
+ end
72
+
73
+ def shared_input(name, **options)
74
+ @shared_inputs << InputDefinition.new(name, provider_key: key, scope: :shared, **options)
75
+ end
76
+
77
+ def channel(name, kinds:, template_required: false, template_format: nil, &block)
78
+ channel = SpreeNotificationProviders.channel!(name)
79
+ capability = ChannelCapability.new(self, channel, kinds: kinds, template_required: template_required,
80
+ template_format: template_format)
81
+ capability.instance_eval(&block) if block
82
+ @channels[channel] = capability
83
+ end
84
+
85
+ # Queries --------------------------------------------------------------
86
+
87
+ def adapter_class
88
+ @adapter.is_a?(String) ? @adapter.constantize : @adapter
89
+ end
90
+
91
+ def supports?(channel)
92
+ channels.key?(channel.to_s.to_sym)
93
+ end
94
+
95
+ def capability(channel)
96
+ channels[channel.to_s.to_sym]
97
+ end
98
+
99
+ def channel_names
100
+ channels.keys
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ SpreeNotificationProviders.register_provider :gupshup do
4
+ label 'Gupshup'
5
+ adapter 'SpreeNotificationProviders::Adapters::Gupshup'
6
+
7
+ shared_input :api_key, label: 'API key', required: true, secret: true, env: 'GUPSHUP_API_KEY'
8
+ shared_input :webhook_secret, label: 'Webhook secret', secret: true, env: 'GUPSHUP_WEBHOOK_SECRET',
9
+ help: 'Random string in the callback URL: /webhooks/gupshup?secret=<this value>.'
10
+
11
+ channel :whatsapp, kinds: %i[otp template], template_required: true do
12
+ input :source_number, label: 'Source number', type: :phone, required: true, format: /\A[1-9]\d{9,14}\z/,
13
+ help: 'Your WhatsApp business number with country code, digits only.'
14
+ input :app_name, label: 'App name', required: true
15
+ input :app_id, label: 'App ID', help: 'Needed when a template is entered by name instead of its UUID.'
16
+
17
+ # A template entered by name is resolved through the app's template list,
18
+ # which needs the app ID.
19
+ missing_check do |values, templates|
20
+ named = templates.values.any? do |template|
21
+ template.present? && !template.to_s.match?(SpreeNotificationProviders::Adapters::Gupshup::UUID_FORMAT)
22
+ end
23
+ named && values[:app_id].blank? ? ['App ID'] : []
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpreeNotificationProviders
4
+ # The Log provider is registered after the app's initializers have run, so
5
+ # `config.enable_log_provider` set there is respected.
6
+ def self.register_log_provider!
7
+ register_provider :log, replace: true do
8
+ label 'Log only (development)'
9
+ adapter 'SpreeNotificationProviders::Adapters::Log'
10
+
11
+ %i[email sms whatsapp].each do |name|
12
+ channel name, kinds: %i[otp template html bulk]
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ SpreeNotificationProviders.register_provider :msg91 do
4
+ label 'MSG91'
5
+ adapter 'SpreeNotificationProviders::Adapters::Msg91'
6
+
7
+ shared_input :auth_key, label: 'Auth key', required: true, secret: true, env: 'MSG91_AUTH_KEY',
8
+ help: 'MSG91 dashboard → Authkey. Used by every MSG91 channel.'
9
+
10
+ channel :email, kinds: %i[template bulk], template_required: true do
11
+ input :domain, label: 'Sending domain', required: true, env: 'MSG91_EMAIL_DOMAIN',
12
+ help: 'The domain verified in MSG91 Email, e.g. mail.example.com.'
13
+ input :default_from_email, label: 'Default from address', type: :email, env: 'MSG91_EMAIL_FROM',
14
+ help: 'Used when the message has no sender of its own.'
15
+ end
16
+
17
+ channel :sms, kinds: %i[otp template bulk], template_required: true do
18
+ input :sender_id, label: 'Sender ID', env: 'MSG91_SENDER_ID', help: 'DLT-registered 6-character sender ID.'
19
+ input :default_country_code, label: 'Default country code', type: :integer, default: '91',
20
+ help: 'Added to 10-digit numbers.'
21
+ end
22
+
23
+ channel :whatsapp, kinds: %i[otp], template_required: true do
24
+ input :default_country_code, label: 'Default country code', type: :integer, default: '91',
25
+ help: 'Added to 10-digit numbers.'
26
+ end
27
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ SpreeNotificationProviders.register_provider :smtp do
4
+ label 'SMTP'
5
+ adapter 'SpreeNotificationProviders::Adapters::Smtp'
6
+
7
+ channel :email, kinds: %i[html] do
8
+ input :host, label: 'Host', required: true, env: 'SMTP_HOST'
9
+ input :port, label: 'Port', type: :integer, required: true, default: '587', env: 'SMTP_PORT'
10
+ input :username, label: 'Username', required: true, env: 'SMTP_USERNAME'
11
+ input :password, label: 'Password', required: true, secret: true, env: 'SMTP_PASSWORD'
12
+ input :from_address, label: 'From address', type: :email, env: 'SMTP_FROM_ADDRESS',
13
+ help: 'Used when the message has no sender of its own.'
14
+ input :authentication, label: 'Authentication', type: :select, options: %w[plain login cram_md5],
15
+ default: 'plain'
16
+ input :starttls, label: 'Use STARTTLS', type: :boolean, default: 'true'
17
+ end
18
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpreeNotificationProviders
4
+ # Providers, message types and webhook message resolvers known to the app.
5
+ # Lives for the whole process; registrations happen at boot.
6
+ class Registry
7
+ def initialize
8
+ @providers = {}
9
+ @message_types = {}
10
+ @resolvers = Hash.new { |hash, key| hash[key] = [] }
11
+ end
12
+
13
+ def register_provider(key, replace: false, &block)
14
+ key = key.to_sym
15
+ raise DuplicateProvider, "provider #{key.inspect} is already registered" if @providers.key?(key) && !replace
16
+
17
+ definition = ProviderDefinition.new(key)
18
+ definition.instance_eval(&block) if block
19
+ raise ArgumentError, "provider #{key.inspect} needs an adapter" if definition.adapter.blank?
20
+
21
+ @providers[key] = definition
22
+ end
23
+
24
+ def unregister_provider(key)
25
+ @providers.delete(key.to_sym)
26
+ end
27
+
28
+ def provider(key)
29
+ return nil if key.blank?
30
+
31
+ @providers[key.to_sym]
32
+ end
33
+
34
+ def providers
35
+ @providers.values
36
+ end
37
+
38
+ def providers_for(channel)
39
+ providers.select { |provider| provider.supports?(channel) }
40
+ end
41
+
42
+ # Registering the same key again replaces it, so re-running an initializer
43
+ # (e.g. on a code reload) is harmless.
44
+ def register_message_type(key, label:, channels:, bulk: false)
45
+ @message_types[key.to_sym] = MessageType.new(key, label: label, channels: channels, bulk: bulk)
46
+ end
47
+
48
+ def message_type(key)
49
+ return nil if key.blank?
50
+
51
+ @message_types[key.to_sym]
52
+ end
53
+
54
+ def message_types
55
+ @message_types.values
56
+ end
57
+
58
+ def message_types_for(channel)
59
+ message_types.select { |type| type.sent_on?(channel) }
60
+ end
61
+
62
+ # Webhooks call these when a provider message id has no delivery row (e.g.
63
+ # it was sent before this gem was installed). Each block takes the id and
64
+ # returns the owning Spree::Store, or nil.
65
+ def register_message_resolver(provider_key, &block)
66
+ raise ArgumentError, 'a block is required' unless block
67
+
68
+ @resolvers[provider_key.to_sym] << block
69
+ end
70
+
71
+ def resolve_message_store(provider_key, provider_message_id)
72
+ @resolvers[provider_key.to_sym].each do |resolver|
73
+ store = resolver.call(provider_message_id)
74
+ return store if store
75
+ end
76
+ nil
77
+ end
78
+ end
79
+ end