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,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SpreeNotificationProviders
|
|
4
|
+
module Adapters
|
|
5
|
+
# WhatsApp template messages through Gupshup (https://gupshup.io).
|
|
6
|
+
#
|
|
7
|
+
# The send endpoint takes the template UUID. A template configured by name
|
|
8
|
+
# is resolved through the app's template list (needs the app ID) and cached.
|
|
9
|
+
# A "submitted" reply only means Gupshup accepted the message; delivery
|
|
10
|
+
# failures arrive later on the webhook (Spree::Webhooks::GupshupController).
|
|
11
|
+
class Gupshup < Base
|
|
12
|
+
HOST = 'https://api.gupshup.io'
|
|
13
|
+
SEND_PATH = '/wa/api/v1/template/msg'
|
|
14
|
+
TEMPLATE_CACHE_TTL = 12.hours
|
|
15
|
+
UUID_FORMAT = /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/
|
|
16
|
+
|
|
17
|
+
def self.template_cache_key(store)
|
|
18
|
+
"notification_providers:gupshup_template:#{store.id}"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.clear_template_cache(store)
|
|
22
|
+
Rails.cache.delete(template_cache_key(store))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def deliver(message, context)
|
|
26
|
+
template_id = begin
|
|
27
|
+
resolve_template_id(context)
|
|
28
|
+
rescue StandardError => e
|
|
29
|
+
# Nothing has been sent yet, so any lookup error is a definite failure.
|
|
30
|
+
return Result.failed("template_lookup_failed: #{HttpOutcome.from_error(e).reason}")
|
|
31
|
+
end
|
|
32
|
+
return Result.failed('template_not_found') if template_id.blank?
|
|
33
|
+
|
|
34
|
+
params = message.kind == :otp ? [message.code.to_s] : Array(message.variables.to_h.values).map(&:to_s)
|
|
35
|
+
response = Http.request(
|
|
36
|
+
:post, "#{HOST}#{SEND_PATH}",
|
|
37
|
+
headers: { 'apikey' => context[:api_key].to_s },
|
|
38
|
+
form: {
|
|
39
|
+
'channel' => 'whatsapp',
|
|
40
|
+
'source' => context[:source_number],
|
|
41
|
+
'destination' => message.to.gsub(/\D/, ''),
|
|
42
|
+
'src.name' => context[:app_name],
|
|
43
|
+
'template' => { id: template_id, params: params }.to_json
|
|
44
|
+
}
|
|
45
|
+
)
|
|
46
|
+
body = Http.parse_json(response.body)
|
|
47
|
+
accepted = body['status'] == 'submitted'
|
|
48
|
+
provider_message = accepted ? nil : scrub("status #{body['status'].presence || 'unknown'}", context)
|
|
49
|
+
HttpOutcome.from_response(response, body, accepted: accepted, message_id: body['messageId'],
|
|
50
|
+
provider_message: provider_message)
|
|
51
|
+
rescue StandardError => e
|
|
52
|
+
HttpOutcome.from_error(e)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def resolve_template_id(context)
|
|
58
|
+
template = context.template.to_s.strip
|
|
59
|
+
return template if template.match?(UUID_FORMAT)
|
|
60
|
+
return nil if template.blank? || context[:app_id].blank?
|
|
61
|
+
|
|
62
|
+
cache_key = self.class.template_cache_key(context.store)
|
|
63
|
+
cached = Rails.cache.read(cache_key)
|
|
64
|
+
return cached[:id] if cached.is_a?(Hash) && cached[:name] == template && cached[:app_id] == context[:app_id]
|
|
65
|
+
|
|
66
|
+
id = lookup_template_id(template, context)
|
|
67
|
+
if id
|
|
68
|
+
Rails.cache.write(cache_key, { name: template, app_id: context[:app_id], id: id },
|
|
69
|
+
expires_in: TEMPLATE_CACHE_TTL)
|
|
70
|
+
end
|
|
71
|
+
id
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def lookup_template_id(name, context)
|
|
75
|
+
response = Http.request(
|
|
76
|
+
:get, "#{HOST}/wa/app/#{ERB::Util.url_encode(context[:app_id])}/template",
|
|
77
|
+
headers: { 'apikey' => context[:api_key].to_s }, query: { templateStatus: 'APPROVED' }
|
|
78
|
+
)
|
|
79
|
+
return nil unless response.code.to_i.between?(200, 299)
|
|
80
|
+
|
|
81
|
+
templates = Array(Http.parse_json(response.body)['templates'])
|
|
82
|
+
match = templates.find { |t| t['elementName'] == name && t['status'].to_s.casecmp('APPROVED').zero? }
|
|
83
|
+
match&.dig('id')
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'openssl'
|
|
4
|
+
require 'socket'
|
|
5
|
+
|
|
6
|
+
module SpreeNotificationProviders
|
|
7
|
+
module Adapters
|
|
8
|
+
# Turns an HTTP response or transport error into a Result.
|
|
9
|
+
#
|
|
10
|
+
# The router fails over only on :failed, so anything that might have
|
|
11
|
+
# reached the provider is :unknown. See research R5.
|
|
12
|
+
module HttpOutcome
|
|
13
|
+
# The request never reached the provider.
|
|
14
|
+
DEFINITE_ERRORS = {
|
|
15
|
+
Net::OpenTimeout => 'connect_timeout',
|
|
16
|
+
Errno::ECONNREFUSED => 'connection_refused',
|
|
17
|
+
Errno::EHOSTUNREACH => 'host_unreachable',
|
|
18
|
+
Errno::ENETUNREACH => 'network_unreachable',
|
|
19
|
+
SocketError => 'dns_error',
|
|
20
|
+
OpenSSL::SSL::SSLError => 'tls_error'
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
# The request may have reached the provider.
|
|
24
|
+
AMBIGUOUS_ERRORS = {
|
|
25
|
+
Net::ReadTimeout => 'read_timeout',
|
|
26
|
+
Net::WriteTimeout => 'write_timeout',
|
|
27
|
+
Errno::ECONNRESET => 'connection_reset',
|
|
28
|
+
Errno::EPIPE => 'broken_pipe',
|
|
29
|
+
EOFError => 'connection_closed'
|
|
30
|
+
}.freeze
|
|
31
|
+
|
|
32
|
+
module_function
|
|
33
|
+
|
|
34
|
+
def from_error(error)
|
|
35
|
+
DEFINITE_ERRORS.each { |klass, code| return Result.failed(code) if error.is_a?(klass) }
|
|
36
|
+
AMBIGUOUS_ERRORS.each { |klass, code| return Result.unknown(code) if error.is_a?(klass) }
|
|
37
|
+
|
|
38
|
+
Result.failed("exception:#{error.class.name}")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @param accepted [Boolean] the adapter's reading of a 2xx body
|
|
42
|
+
# @param provider_message [String, nil] short provider error text (already scrubbed)
|
|
43
|
+
def from_response(response, body, accepted:, message_id: nil, provider_message: nil)
|
|
44
|
+
code = response.code.to_i
|
|
45
|
+
return Result.delivered(message_id) if code.between?(200, 299) && accepted
|
|
46
|
+
|
|
47
|
+
detail = provider_message.presence
|
|
48
|
+
if code.between?(200, 299)
|
|
49
|
+
Result.failed(['rejected', detail].compact.join(': '))
|
|
50
|
+
elsif code.between?(400, 499)
|
|
51
|
+
Result.failed(["http_#{code}", detail].compact.join(': '))
|
|
52
|
+
elsif body.present?
|
|
53
|
+
# A 5xx that explains itself was a rejection.
|
|
54
|
+
Result.failed(["http_#{code}", detail].compact.join(': '))
|
|
55
|
+
else
|
|
56
|
+
Result.unknown("http_#{code}")
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SpreeNotificationProviders
|
|
4
|
+
module Adapters
|
|
5
|
+
# Writes the message to the Rails log instead of sending it. Registered
|
|
6
|
+
# only when `config.enable_log_provider` is true (non-production default).
|
|
7
|
+
class Log < Base
|
|
8
|
+
def deliver(message, context)
|
|
9
|
+
Rails.logger.info(
|
|
10
|
+
"[NotificationProviders::Log] store=#{context.store&.id} channel=#{message.channel} " \
|
|
11
|
+
"type=#{message.type} kind=#{message.kind} to=#{message.to}" \
|
|
12
|
+
"#{" code=#{message.code}" if message.code}#{" subject=#{message.subject.inspect}" if message.subject}"
|
|
13
|
+
)
|
|
14
|
+
Result.delivered("sim-#{SecureRandom.hex(8)}")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def deliver_bulk(bulk, context)
|
|
18
|
+
Rails.logger.info(
|
|
19
|
+
"[NotificationProviders::Log] store=#{context.store&.id} channel=#{bulk.channel} type=#{bulk.type} " \
|
|
20
|
+
"bulk recipients=#{bulk.recipients.size}"
|
|
21
|
+
)
|
|
22
|
+
Result.delivered("sim-#{SecureRandom.hex(8)}")
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SpreeNotificationProviders
|
|
4
|
+
module Adapters
|
|
5
|
+
# MSG91 (https://msg91.com):
|
|
6
|
+
# email - Email API, provider-side template + variables
|
|
7
|
+
# sms - OTP API for codes, Flow API for templates (DLT-approved)
|
|
8
|
+
# whatsapp - OTP API with whatsapp=1
|
|
9
|
+
class Msg91 < Base
|
|
10
|
+
OTP_URL = 'https://control.msg91.com/api/v5/otp'
|
|
11
|
+
FLOW_URL = 'https://control.msg91.com/api/v5/flow'
|
|
12
|
+
EMAIL_URL = 'https://control.msg91.com/api/v5/email/send'
|
|
13
|
+
|
|
14
|
+
def deliver(message, context)
|
|
15
|
+
case message.channel
|
|
16
|
+
when :email then send_email(message.to, message.to_name, message, context)
|
|
17
|
+
when :sms then message.kind == :otp ? send_otp(message, context) : send_flow([message.to], message, context)
|
|
18
|
+
when :whatsapp then send_otp(message, context, whatsapp: true)
|
|
19
|
+
end
|
|
20
|
+
rescue StandardError => e
|
|
21
|
+
HttpOutcome.from_error(e)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def deliver_bulk(bulk, context)
|
|
25
|
+
case bulk.channel
|
|
26
|
+
when :email then send_email_batch(bulk.recipients, bulk, context)
|
|
27
|
+
when :sms then send_flow(bulk.recipients, bulk, context)
|
|
28
|
+
else Result.failed('unsupported')
|
|
29
|
+
end
|
|
30
|
+
rescue StandardError => e
|
|
31
|
+
HttpOutcome.from_error(e)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def send_otp(message, context, whatsapp: false)
|
|
37
|
+
query = {
|
|
38
|
+
template_id: context.template,
|
|
39
|
+
mobile: international(message.to, context),
|
|
40
|
+
otp: message.code,
|
|
41
|
+
otp_expiry: message.expiry_minutes || 10
|
|
42
|
+
}
|
|
43
|
+
query[:sender] = context[:sender_id] if !whatsapp && context[:sender_id].present?
|
|
44
|
+
query[:whatsapp] = 1 if whatsapp
|
|
45
|
+
|
|
46
|
+
response = Http.request(:post, OTP_URL, headers: auth(context), query: query)
|
|
47
|
+
body = Http.parse_json(response.body)
|
|
48
|
+
accepted = body['type'] == 'success' || body['message_id'].present?
|
|
49
|
+
outcome(response, body, accepted, body['message_id'] || body['request_id'], context)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def send_flow(phones, message, context)
|
|
53
|
+
variables = message.variables.to_h.stringify_keys
|
|
54
|
+
payload = {
|
|
55
|
+
template_id: context.template,
|
|
56
|
+
short_url: '1',
|
|
57
|
+
recipients: phones.map { |phone| { 'mobiles' => international(phone, context) }.merge(variables) }
|
|
58
|
+
}
|
|
59
|
+
response = Http.request(:post, FLOW_URL, headers: auth(context), json: payload)
|
|
60
|
+
body = Http.parse_json(response.body)
|
|
61
|
+
# A successful Flow reply carries the request id in "message".
|
|
62
|
+
message_id = body['type'] == 'success' ? body['message'] : body['request_id']
|
|
63
|
+
outcome(response, body, body['type'] != 'error', message_id, context)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def send_email(to, to_name, message, context)
|
|
67
|
+
send_email_payload([{ to: [{ email: to, name: to_name.presence || to }.compact], variables: message.variables }],
|
|
68
|
+
message, context)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def send_email_batch(emails, bulk, context)
|
|
72
|
+
send_email_payload(emails.map { |email| { to: [{ email: email }], variables: bulk.variables } }, bulk, context)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def send_email_payload(recipients, message, context)
|
|
76
|
+
from_email = message.from_email || context[:default_from_email]
|
|
77
|
+
payload = {
|
|
78
|
+
recipients: recipients,
|
|
79
|
+
from: { name: message.from_name || context.store&.name, email: from_email }.compact,
|
|
80
|
+
domain: sending_domain(message, context),
|
|
81
|
+
template_id: context.template
|
|
82
|
+
}
|
|
83
|
+
reply_to = message.respond_to?(:reply_to) ? message.reply_to : nil
|
|
84
|
+
payload[:reply_to] = [{ email: reply_to }] if reply_to.present?
|
|
85
|
+
|
|
86
|
+
response = Http.request(:post, EMAIL_URL, headers: auth(context), json: payload)
|
|
87
|
+
body = Http.parse_json(response.body)
|
|
88
|
+
accepted = body['hasError'] != true && body['status'] != 'fail'
|
|
89
|
+
outcome(response, body, accepted, body.dig('data', 'unique_id') || body['request_id'], context)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# The domain of the message's own sender (it must be verified in MSG91),
|
|
93
|
+
# else the configured sending domain.
|
|
94
|
+
def sending_domain(message, context)
|
|
95
|
+
own = message.from_email.to_s.split('@', 2).last if message.from_email.to_s.include?('@')
|
|
96
|
+
own.presence || context[:domain].presence || context[:default_from_email].to_s.split('@', 2).last
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def outcome(response, body, accepted, message_id, context)
|
|
100
|
+
provider_message = scrub(body['message'].presence || body['errors'].to_s.presence, context)
|
|
101
|
+
HttpOutcome.from_response(response, body, accepted: accepted, message_id: message_id,
|
|
102
|
+
provider_message: provider_message)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def auth(context)
|
|
106
|
+
{ 'authkey' => context[:auth_key].to_s, 'accept' => 'application/json' }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# MSG91 wants country-code-prefixed numbers; a national 10-digit number
|
|
110
|
+
# gets the channel's default country code.
|
|
111
|
+
def international(phone, context)
|
|
112
|
+
digits = phone.to_s.gsub(/\D/, '')
|
|
113
|
+
digits.length == 10 ? "#{context[:default_country_code].presence || '91'}#{digits}" : digits
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'mail'
|
|
4
|
+
require 'net/smtp'
|
|
5
|
+
|
|
6
|
+
module SpreeNotificationProviders
|
|
7
|
+
module Adapters
|
|
8
|
+
# Sends rendered email over the store's own SMTP account. Never touches
|
|
9
|
+
# the global Action Mailer configuration.
|
|
10
|
+
class Smtp < Base
|
|
11
|
+
DEFINITE_SMTP_ERRORS = {
|
|
12
|
+
'Net::SMTPAuthenticationError' => 'smtp_auth',
|
|
13
|
+
'Net::SMTPFatalError' => 'smtp_rejected',
|
|
14
|
+
'Net::SMTPServerBusy' => 'smtp_busy',
|
|
15
|
+
'Net::SMTPSyntaxError' => 'smtp_syntax',
|
|
16
|
+
'Net::SMTPUnknownError' => 'smtp_unknown_reply'
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
def deliver(message, context)
|
|
20
|
+
mail = build_mail(message, context)
|
|
21
|
+
mail.delivery_method(:smtp, smtp_settings(context))
|
|
22
|
+
mail.deliver!
|
|
23
|
+
Result.delivered(mail.message_id)
|
|
24
|
+
rescue StandardError => e
|
|
25
|
+
classify(e, context)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def build_mail(message, context)
|
|
31
|
+
mail = message.mail ? message.mail.dup : Mail.new
|
|
32
|
+
unless message.mail
|
|
33
|
+
mail.to = message.to
|
|
34
|
+
mail.subject = message.subject
|
|
35
|
+
mail.html_part = Mail::Part.new do
|
|
36
|
+
content_type 'text/html; charset=UTF-8'
|
|
37
|
+
body message.html.to_s
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
if message.from_email
|
|
42
|
+
mail.from = message.from_name ? "#{message.from_name} <#{message.from_email}>" : message.from_email
|
|
43
|
+
elsif mail.from.blank? && (address = default_from(context))
|
|
44
|
+
mail.from = context.store&.name.present? ? "#{context.store.name} <#{address}>" : address
|
|
45
|
+
end
|
|
46
|
+
mail.reply_to = message.reply_to if message.reply_to
|
|
47
|
+
mail
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The from address input, else the SMTP username when it is an address.
|
|
51
|
+
def default_from(context)
|
|
52
|
+
context[:from_address].presence ||
|
|
53
|
+
(context[:username] if context[:username].to_s.match?(InputDefinition::EMAIL_FORMAT))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def smtp_settings(context)
|
|
57
|
+
{
|
|
58
|
+
address: context[:host],
|
|
59
|
+
port: context[:port],
|
|
60
|
+
user_name: context[:username],
|
|
61
|
+
password: context[:password],
|
|
62
|
+
authentication: context[:authentication].presence&.to_sym || :plain,
|
|
63
|
+
enable_starttls_auto: context[:starttls] != false,
|
|
64
|
+
open_timeout: SpreeNotificationProviders.configuration.http_timeout_seconds,
|
|
65
|
+
read_timeout: SpreeNotificationProviders.configuration.http_timeout_seconds
|
|
66
|
+
}
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def classify(error, context)
|
|
70
|
+
code = DEFINITE_SMTP_ERRORS[error.class.name]
|
|
71
|
+
return Result.failed([code, scrub(error.message, context)].compact.join(': ')) if code
|
|
72
|
+
|
|
73
|
+
HttpOutcome.from_error(error)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SpreeNotificationProviders
|
|
4
|
+
# One template message to many recipients (e.g. a customer group blast).
|
|
5
|
+
# The same `variables` apply to every recipient.
|
|
6
|
+
class BulkMessage
|
|
7
|
+
attr_reader :channel, :type, :recipients, :variables, :templates, :source, :from_email, :from_name
|
|
8
|
+
|
|
9
|
+
def initialize(channel:, type:, recipients:, variables: {}, templates: {}, source: nil, from_email: nil,
|
|
10
|
+
from_name: nil)
|
|
11
|
+
@channel = SpreeNotificationProviders.channel!(channel)
|
|
12
|
+
@type = type.to_sym
|
|
13
|
+
@recipients = Array(recipients).map { |r| r.to_s.strip }.compact_blank.uniq
|
|
14
|
+
@variables = variables || {}
|
|
15
|
+
@templates = (templates || {}).transform_keys(&:to_s).compact_blank
|
|
16
|
+
@source = source
|
|
17
|
+
@from_email = from_email.presence
|
|
18
|
+
@from_name = from_name.presence
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def kind = :bulk
|
|
22
|
+
def bulk? = true
|
|
23
|
+
|
|
24
|
+
def template_override(provider_key)
|
|
25
|
+
templates[provider_key.to_s]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def with_recipients(batch)
|
|
29
|
+
self.class.new(channel: channel, type: type, recipients: batch, variables: variables, templates: templates,
|
|
30
|
+
source: source, from_email: from_email, from_name: from_name)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SpreeNotificationProviders
|
|
4
|
+
# Set in config/initializers/spree_notification_providers.rb:
|
|
5
|
+
#
|
|
6
|
+
# SpreeNotificationProviders.configure do |config|
|
|
7
|
+
# config.delivery_retention_days = 90
|
|
8
|
+
# end
|
|
9
|
+
class Configuration
|
|
10
|
+
attr_reader :delivery_retention_days
|
|
11
|
+
attr_accessor :enable_log_provider, :http_timeout_seconds, :default_store_resolver, :bulk_batch_size
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
@delivery_retention_days = 90
|
|
15
|
+
@enable_log_provider = !(defined?(Rails) && Rails.env.production?)
|
|
16
|
+
@http_timeout_seconds = 5
|
|
17
|
+
@bulk_batch_size = 100
|
|
18
|
+
@default_store_resolver = -> { Spree::Current.store || Spree::Store.default }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def delivery_retention_days=(value)
|
|
22
|
+
days = Integer(value)
|
|
23
|
+
raise ArgumentError, 'delivery_retention_days must be >= 1' if days < 1
|
|
24
|
+
|
|
25
|
+
@delivery_retention_days = days
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def default_store
|
|
29
|
+
default_store_resolver.call
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SpreeNotificationProviders
|
|
4
|
+
class Engine < ::Rails::Engine
|
|
5
|
+
engine_name 'spree_notification_providers'
|
|
6
|
+
|
|
7
|
+
config.generators do |g|
|
|
8
|
+
g.test_framework :rspec
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Secrets arrive in these params; keep them out of the log lines even in
|
|
12
|
+
# apps that trimmed Rails' default filter list.
|
|
13
|
+
initializer 'spree_notification_providers.filter_parameters' do |app|
|
|
14
|
+
app.config.filter_parameters += %i[secret_settings shared_secret_settings secret]
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
initializer 'spree_notification_providers.mail_delivery_method' do
|
|
18
|
+
ActiveSupport.on_load(:action_mailer) do
|
|
19
|
+
add_delivery_method :spree_notification_providers, SpreeNotificationProviders::MailDeliveryMethod
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# After the app's initializers, so `enable_log_provider` set there counts.
|
|
24
|
+
config.after_initialize do
|
|
25
|
+
SpreeNotificationProviders.register_log_provider! if SpreeNotificationProviders.configuration.enable_log_provider
|
|
26
|
+
SpreeNotificationProviders.register_admin_navigation!
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.activate
|
|
30
|
+
Dir.glob(File.join(File.dirname(__FILE__), '../../app/**/*_decorator*.rb')) do |c|
|
|
31
|
+
Rails.configuration.cache_classes ? require(c) : load(c)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
config.to_prepare(&method(:activate).to_proc)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.register_admin_navigation!
|
|
39
|
+
return unless Spree.respond_to?(:admin) && Spree.admin.respond_to?(:navigation)
|
|
40
|
+
|
|
41
|
+
nav = Spree.admin.navigation
|
|
42
|
+
return if nav.nil?
|
|
43
|
+
|
|
44
|
+
nav.settings.add :notification_providers,
|
|
45
|
+
label: :notification_providers,
|
|
46
|
+
icon: 'send',
|
|
47
|
+
url: :admin_notification_providers_path,
|
|
48
|
+
position: 76,
|
|
49
|
+
active: -> { controller_path.in?(%w[spree/admin/notification_providers spree/admin/notification_channel_providers]) },
|
|
50
|
+
if: -> { can?(:manage, Spree::NotificationChannelProvider) }
|
|
51
|
+
nav.settings.add :notification_deliveries,
|
|
52
|
+
label: :notification_deliveries,
|
|
53
|
+
icon: 'list-check',
|
|
54
|
+
url: :admin_notification_deliveries_path,
|
|
55
|
+
position: 77,
|
|
56
|
+
active: -> { controller_path == 'spree/admin/notification_deliveries' },
|
|
57
|
+
if: -> { can?(:manage, Spree::NotificationDelivery) }
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'uri'
|
|
6
|
+
|
|
7
|
+
module SpreeNotificationProviders
|
|
8
|
+
# The one place bundled adapters make HTTP calls, so timeouts are uniform
|
|
9
|
+
# and specs can stub a single method.
|
|
10
|
+
module Http
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# @param form [Hash] sent as application/x-www-form-urlencoded
|
|
14
|
+
# @param json [Hash] sent as application/json
|
|
15
|
+
def request(method, url, headers: {}, query: nil, form: nil, json: nil)
|
|
16
|
+
uri = URI(url)
|
|
17
|
+
uri.query = URI.encode_www_form(query) if query.present?
|
|
18
|
+
klass = method.to_sym == :get ? Net::HTTP::Get : Net::HTTP::Post
|
|
19
|
+
req = klass.new(uri.request_uri)
|
|
20
|
+
headers.each { |name, value| req[name] = value }
|
|
21
|
+
if form
|
|
22
|
+
req.set_form_data(form)
|
|
23
|
+
elsif json
|
|
24
|
+
req['Content-Type'] = 'application/json'
|
|
25
|
+
req.body = json.to_json
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
timeout = SpreeNotificationProviders.configuration.http_timeout_seconds
|
|
29
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https', open_timeout: timeout,
|
|
30
|
+
read_timeout: timeout, write_timeout: timeout) do |http|
|
|
31
|
+
http.request(req)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def parse_json(raw)
|
|
36
|
+
parsed = JSON.parse(raw.to_s)
|
|
37
|
+
parsed.is_a?(Hash) ? parsed : {}
|
|
38
|
+
rescue JSON::ParserError
|
|
39
|
+
{}
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spree_notification_providers/importers/writer'
|
|
4
|
+
|
|
5
|
+
module SpreeNotificationProviders
|
|
6
|
+
module Importers
|
|
7
|
+
# Copies the environment variables named by each provider input's `env:`
|
|
8
|
+
# into one store's configuration. Fills blanks only; safe to rerun.
|
|
9
|
+
# A provider with any value found gets an enabled entry on each of its
|
|
10
|
+
# channels (an entry missing inputs or templates simply shows as not ready).
|
|
11
|
+
#
|
|
12
|
+
# bin/rails g spree_notification_providers:import_env --store=<code>
|
|
13
|
+
class EnvImporter
|
|
14
|
+
def initialize(store, env: ENV, dry_run: false)
|
|
15
|
+
@env = env
|
|
16
|
+
@writer = Writer.new(store, dry_run: dry_run)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call
|
|
20
|
+
SpreeNotificationProviders.providers.each do |definition|
|
|
21
|
+
shared_found = import_inputs(definition, :shared, definition.shared_inputs)
|
|
22
|
+
definition.channels.each do |channel, capability|
|
|
23
|
+
channel_found = import_inputs(definition, channel, capability.inputs)
|
|
24
|
+
@writer.ensure_entry(definition.key, channel, enabled: true) if shared_found || channel_found
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
@writer.save!
|
|
28
|
+
@writer
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
# @return [Boolean] whether any input had a value in the environment
|
|
34
|
+
def import_inputs(definition, scope, inputs)
|
|
35
|
+
inputs.map do |input|
|
|
36
|
+
value = input.env.present? ? @env[input.env].to_s.strip.presence : nil
|
|
37
|
+
@writer.set_input(definition.key, scope, input.name, value, source: "ENV #{input.env}") if value
|
|
38
|
+
value.present?
|
|
39
|
+
end.any?
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|