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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +24 -0
- data/LICENSE +29 -0
- data/README.md +322 -0
- data/app/controllers/spree/admin/notification_channel_providers_controller.rb +152 -0
- data/app/controllers/spree/admin/notification_deliveries_controller.rb +69 -0
- data/app/controllers/spree/admin/notification_providers_controller.rb +31 -0
- data/app/controllers/spree/webhooks/gupshup_controller.rb +61 -0
- data/app/helpers/spree/admin/notification_masking_helper.rb +65 -0
- data/app/jobs/spree/notification_deliveries/purge_job.rb +26 -0
- data/app/models/concerns/spree/notification_provider_settings.rb +81 -0
- data/app/models/spree/notification_channel_provider.rb +165 -0
- data/app/models/spree/notification_delivery.rb +39 -0
- data/app/models/spree/notification_provider_config.rb +32 -0
- data/app/models/spree/notification_providers_store_decorator.rb +16 -0
- data/app/models/spree/permission_sets/notification_provider_management.rb +20 -0
- data/app/views/spree/admin/notification_channel_providers/_input_field.html.erb +51 -0
- data/app/views/spree/admin/notification_channel_providers/edit.html.erb +118 -0
- data/app/views/spree/admin/notification_deliveries/index.html.erb +127 -0
- data/app/views/spree/admin/notification_deliveries/show.html.erb +52 -0
- data/app/views/spree/admin/notification_providers/index.html.erb +114 -0
- data/config/locales/en.yml +92 -0
- data/config/routes.rb +17 -0
- data/db/migrate/20261019000001_create_spree_notification_provider_configs.rb +19 -0
- data/db/migrate/20261019000002_create_spree_notification_channel_providers.rb +23 -0
- data/db/migrate/20261019000003_create_spree_notification_deliveries.rb +29 -0
- data/lib/generators/spree_notification_providers/import_env/import_env_generator.rb +23 -0
- data/lib/generators/spree_notification_providers/install/install_generator.rb +41 -0
- data/lib/generators/spree_notification_providers/install/templates/initializer.rb +14 -0
- data/lib/spree_notification_providers/adapters/base.rb +76 -0
- data/lib/spree_notification_providers/adapters/gupshup.rb +87 -0
- data/lib/spree_notification_providers/adapters/http_outcome.rb +61 -0
- data/lib/spree_notification_providers/adapters/log.rb +26 -0
- data/lib/spree_notification_providers/adapters/msg91.rb +117 -0
- data/lib/spree_notification_providers/adapters/smtp.rb +77 -0
- data/lib/spree_notification_providers/bulk_message.rb +33 -0
- data/lib/spree_notification_providers/configuration.rb +32 -0
- data/lib/spree_notification_providers/engine.rb +59 -0
- data/lib/spree_notification_providers/http.rb +42 -0
- data/lib/spree_notification_providers/importers/env_importer.rb +43 -0
- data/lib/spree_notification_providers/importers/writer.rb +132 -0
- data/lib/spree_notification_providers/input_definition.rb +75 -0
- data/lib/spree_notification_providers/mail_delivery_method.rb +78 -0
- data/lib/spree_notification_providers/message.rb +55 -0
- data/lib/spree_notification_providers/message_type.rb +26 -0
- data/lib/spree_notification_providers/message_types.rb +8 -0
- data/lib/spree_notification_providers/provider_definition.rb +103 -0
- data/lib/spree_notification_providers/providers/gupshup.rb +26 -0
- data/lib/spree_notification_providers/providers/log.rb +16 -0
- data/lib/spree_notification_providers/providers/msg91.rb +27 -0
- data/lib/spree_notification_providers/providers/smtp.rb +18 -0
- data/lib/spree_notification_providers/registry.rb +79 -0
- data/lib/spree_notification_providers/result.rb +73 -0
- data/lib/spree_notification_providers/router.rb +205 -0
- data/lib/spree_notification_providers/version.rb +5 -0
- data/lib/spree_notification_providers.rb +96 -0
- data/spec/factories/notification_providers.rb +91 -0
- data/spec/support/message_types.rb +15 -0
- data/spec/support/provider_http_helpers.rb +46 -0
- data/spree_notification_providers.gemspec +44 -0
- 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
|