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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 2c2f8625e5b892ebfe95c3ce69935440f18fa7afb28dcf65fc255ac76f50dfc7
|
|
4
|
+
data.tar.gz: 12517886161486ee64ba223c9b69c723a70ecd494b18cad8a0a85a6d6c4eb04e
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 52a68681db0269d172a4b36a71d1833ec3a37861588cf2d7cea470e10ff9c92c139b77ed6bfe51f32381ab83a01569aac62f7a696b2036867cb29a46c7f534a8
|
|
7
|
+
data.tar.gz: e026d7aa779bdf9ad8d0aad7a887d606ac26f92cab648bfc7c15c4356343493b2fffae849bd4502ac83b61f7cbf7d6af28ba3f5c8feae998e3bf4252a0aae6c1
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this gem are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the gem uses
|
|
5
|
+
[Semantic Versioning](https://semver.org/). While the version is 0.x, a minor
|
|
6
|
+
bump may change the public API described in the README; such changes are listed
|
|
7
|
+
under **Breaking**.
|
|
8
|
+
|
|
9
|
+
## 0.1.0 — 2026-10-19
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Provider registry with a DSL for shared and per-channel inputs, capabilities
|
|
14
|
+
and per-message-type templates.
|
|
15
|
+
- Bundled providers: MSG91 (email, SMS, WhatsApp), SMTP (email), Gupshup
|
|
16
|
+
(WhatsApp), and a Log provider for non-production environments.
|
|
17
|
+
- Per-store provider configuration with encrypted secrets, readiness and
|
|
18
|
+
priority ordering per channel.
|
|
19
|
+
- Router with failover on definite failures only, bulk sends without failover,
|
|
20
|
+
and one Delivery log row per attempt.
|
|
21
|
+
- Admin screens: Notification providers, per-provider test send, Delivery log.
|
|
22
|
+
- Action Mailer delivery method `:spree_notification_providers`.
|
|
23
|
+
- Gupshup delivery webhook with per-store secrets.
|
|
24
|
+
- Env importer generator for apps moving from environment variables.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, ONTODI
|
|
4
|
+
All rights reserved.
|
|
5
|
+
|
|
6
|
+
Redistribution and use in source and binary forms, with or without
|
|
7
|
+
modification, are permitted provided that the following conditions are met:
|
|
8
|
+
|
|
9
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
10
|
+
list of conditions and the following disclaimer.
|
|
11
|
+
|
|
12
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
13
|
+
this list of conditions and the following disclaimer in the documentation
|
|
14
|
+
and/or other materials provided with the distribution.
|
|
15
|
+
|
|
16
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
17
|
+
contributors may be used to endorse or promote products derived from
|
|
18
|
+
this software without specific prior written permission.
|
|
19
|
+
|
|
20
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
21
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
22
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
23
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
24
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
25
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
26
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
27
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
28
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
29
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
data/README.md
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
# spree_notification_providers
|
|
2
|
+
|
|
3
|
+
Per-channel notification providers with priority and failover for
|
|
4
|
+
[Spree Commerce](https://spreecommerce.org) 5.5+.
|
|
5
|
+
|
|
6
|
+
Each store picks the providers it uses for **Email**, **SMS** and **WhatsApp**,
|
|
7
|
+
enters only the inputs each provider needs, and puts them in order. Messages go
|
|
8
|
+
to the first ready provider; if it definitely did not take the message, the next
|
|
9
|
+
one is tried. Every attempt is recorded in an admin **Delivery log**.
|
|
10
|
+
|
|
11
|
+
Bundled providers:
|
|
12
|
+
|
|
13
|
+
| Provider | Email | SMS | WhatsApp |
|
|
14
|
+
|----------|-------|-----|----------|
|
|
15
|
+
| MSG91 | templates, bulk | OTP, Flow templates, bulk | OTP |
|
|
16
|
+
| SMTP | rendered HTML (any mailer) | — | — |
|
|
17
|
+
| Gupshup | — | — | OTP, templates |
|
|
18
|
+
| Log | all (writes to the Rails log; non-production only) | all | all |
|
|
19
|
+
|
|
20
|
+
You can add your own provider without changing this gem (see
|
|
21
|
+
[Adding a provider](#adding-a-provider)).
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
# Gemfile
|
|
29
|
+
gem 'spree_notification_providers', '~> 0.1'
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
bundle install
|
|
34
|
+
bin/rails g spree_notification_providers:install
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The generator copies three migrations (`spree_notification_provider_configs`,
|
|
38
|
+
`spree_notification_channel_providers`, `spree_notification_deliveries`), runs
|
|
39
|
+
them, and writes `config/initializers/spree_notification_providers.rb`.
|
|
40
|
+
|
|
41
|
+
Then:
|
|
42
|
+
|
|
43
|
+
1. **Encryption keys.** Provider secrets are stored with Active Record
|
|
44
|
+
Encryption. Configure `active_record_encryption` credentials or set
|
|
45
|
+
`config.active_record.encryption.primary_key`, `deterministic_key` and
|
|
46
|
+
`key_derivation_salt` (`bin/rails db:encryption:init` prints a set).
|
|
47
|
+
2. **Permissions.** Super users can open the screens already. For other admin
|
|
48
|
+
roles add the permission set:
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
Spree.permissions.assign(:admin, [Spree::PermissionSets::NotificationProviderManagement])
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
3. **Mailers (optional but recommended).** Route Action Mailer through the
|
|
55
|
+
store's Email providers:
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
# config/environments/production.rb
|
|
59
|
+
config.action_mailer.delivery_method = :spree_notification_providers
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
4. **Retention.** Schedule `Spree::NotificationDeliveries::PurgeJob` daily, e.g.
|
|
63
|
+
with sidekiq-cron:
|
|
64
|
+
|
|
65
|
+
```yaml
|
|
66
|
+
notification_deliveries_purge:
|
|
67
|
+
cron: "0 4 * * *"
|
|
68
|
+
class: "Spree::NotificationDeliveries::PurgeJob"
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
5. Open **Admin → Settings → Notification providers**.
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## Configuring providers
|
|
76
|
+
|
|
77
|
+
**Admin → Settings → Notification providers** has a tab per channel. Each tab
|
|
78
|
+
lists the store's providers in priority order (drag to reorder) with a status:
|
|
79
|
+
|
|
80
|
+
- **Ready** — enabled and every required input is set.
|
|
81
|
+
- **Not ready** — names the missing inputs.
|
|
82
|
+
- **Disabled** — skipped; its inputs are kept.
|
|
83
|
+
- **Unavailable** — the provider is no longer registered; skipped.
|
|
84
|
+
|
|
85
|
+
**Add** a provider from *Available providers*, then **Configure** it. The form
|
|
86
|
+
shows only that provider's inputs:
|
|
87
|
+
|
|
88
|
+
- **Shared settings** are entered once per store and used by every channel the
|
|
89
|
+
provider serves (e.g. the MSG91 auth key for Email and SMS).
|
|
90
|
+
- **Channel settings** apply to one channel (e.g. the MSG91 email sending domain).
|
|
91
|
+
- **Templates** — one field per registered message type (see below). A
|
|
92
|
+
template-based provider is skipped for a message type with no template.
|
|
93
|
+
|
|
94
|
+
Secret inputs are write-only: they show as "Set ••••1234" (last four characters
|
|
95
|
+
only for values of 12+ characters), a blank submit keeps the stored value, and
|
|
96
|
+
**Clear** removes it. They are stored encrypted and never logged.
|
|
97
|
+
|
|
98
|
+
**Send test** sends one message through that provider only, ignoring priority,
|
|
99
|
+
and records it in the Delivery log as `provider_test`.
|
|
100
|
+
|
|
101
|
+
### MSG91
|
|
102
|
+
|
|
103
|
+
| Scope | Input | Notes |
|
|
104
|
+
|-------|-------|-------|
|
|
105
|
+
| shared | Auth key * (secret) | MSG91 dashboard → Authkey |
|
|
106
|
+
| email | Sending domain * | Verified domain; the sender's own domain is used when the message has a sender |
|
|
107
|
+
| email | Default from address | Used when the message has no sender |
|
|
108
|
+
| sms | Sender ID | DLT-registered header |
|
|
109
|
+
| sms, whatsapp | Default country code | Added to 10-digit numbers (default `91`) |
|
|
110
|
+
|
|
111
|
+
Templates: email template id, SMS OTP template / Flow id, WhatsApp OTP template.
|
|
112
|
+
|
|
113
|
+
### SMTP
|
|
114
|
+
|
|
115
|
+
| Input | Notes |
|
|
116
|
+
|-------|-------|
|
|
117
|
+
| Host *, Port * (587), Username *, Password * (secret) | |
|
|
118
|
+
| From address | Used when a mail has no sender (else the username, if it is an address) |
|
|
119
|
+
| Authentication | `plain` (default), `login`, `cram_md5` |
|
|
120
|
+
| Use STARTTLS | default yes |
|
|
121
|
+
|
|
122
|
+
SMTP sends rendered HTML only (Action Mailer mails and `kind: :html` messages).
|
|
123
|
+
|
|
124
|
+
### Gupshup
|
|
125
|
+
|
|
126
|
+
| Scope | Input | Notes |
|
|
127
|
+
|-------|-------|-------|
|
|
128
|
+
| shared | API key * (secret) | |
|
|
129
|
+
| shared | Webhook secret (secret) | Random string for the callback URL |
|
|
130
|
+
| whatsapp | Source number * | Business number with country code, digits only |
|
|
131
|
+
| whatsapp | App name * | |
|
|
132
|
+
| whatsapp | App ID | Needed when a template is entered by name instead of UUID |
|
|
133
|
+
|
|
134
|
+
Point the Gupshup app's callback at
|
|
135
|
+
`https://<host>/webhooks/gupshup?secret=<webhook secret>`. A `failed` event
|
|
136
|
+
marks the delivery failed and publishes `notification.delivery_failed`.
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
## How priority and failover work
|
|
141
|
+
|
|
142
|
+
For one-to-one messages the router walks the channel's enabled providers in
|
|
143
|
+
order:
|
|
144
|
+
|
|
145
|
+
1. A provider that is not ready, has no template for the message type, or can't
|
|
146
|
+
send this kind of message is recorded as **skipped**.
|
|
147
|
+
2. **Delivered** — stop.
|
|
148
|
+
3. **Failed** — a definite failure (connection refused, connect timeout, DNS/TLS
|
|
149
|
+
error, HTTP 4xx, a 5xx that explains itself, SMTP auth/rejection): try the
|
|
150
|
+
next provider.
|
|
151
|
+
4. **Unknown** — the message may have reached the provider (read timeout,
|
|
152
|
+
connection reset after sending, a 5xx with no body): **stop**, so the
|
|
153
|
+
customer never gets a duplicate.
|
|
154
|
+
|
|
155
|
+
Bulk sends (`deliver_bulk`) use the first ready provider that supports bulk and
|
|
156
|
+
**never fail over**; each rejected recipient is recorded as failed.
|
|
157
|
+
|
|
158
|
+
A provider that accepts a message and later reports it failed (e.g. a Gupshup
|
|
159
|
+
callback) does not trigger failover; subscribe to `notification.delivery_failed`
|
|
160
|
+
to react (spree_otp_auth uses it for its email backup).
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
## Delivery log
|
|
165
|
+
|
|
166
|
+
**Admin → Settings → Delivery log** lists every attempt for the store, newest
|
|
167
|
+
first, with attempts of one message grouped. Filter by channel, provider,
|
|
168
|
+
outcome, message type, date and bulk. Recipients are masked
|
|
169
|
+
(`ka•••@gmail.com`, `+91•••••3210`) and not searchable. Rows older than
|
|
170
|
+
`delivery_retention_days` (default 90) are removed by the purge job.
|
|
171
|
+
|
|
172
|
+
---
|
|
173
|
+
|
|
174
|
+
## Sending from code
|
|
175
|
+
|
|
176
|
+
```ruby
|
|
177
|
+
message = SpreeNotificationProviders::Message.new(
|
|
178
|
+
channel: :sms, type: :otp_code, kind: :otp, to: '+919876543210', code: '482913', expiry_minutes: 10
|
|
179
|
+
)
|
|
180
|
+
result = SpreeNotificationProviders.deliver(store: store, message: message)
|
|
181
|
+
result.success? # true when a provider delivered it
|
|
182
|
+
result.retryable? # true only when no provider can have taken it
|
|
183
|
+
result.attempts # [{ provider_key:, outcome:, reason: }]
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
```ruby
|
|
187
|
+
bulk = SpreeNotificationProviders::BulkMessage.new(
|
|
188
|
+
channel: :email, type: :customer_blast, recipients: emails, variables: { 'OFFER' => '10%' },
|
|
189
|
+
templates: { msg91: 'blast-template' }
|
|
190
|
+
)
|
|
191
|
+
SpreeNotificationProviders.deliver_bulk(store: store, message: bulk).failed_recipients
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
`SpreeNotificationProviders.ready_for?(store, :whatsapp, :otp_code)` and
|
|
195
|
+
`channel_status(store, :sms)` answer readiness questions.
|
|
196
|
+
|
|
197
|
+
### Message types
|
|
198
|
+
|
|
199
|
+
Register the kinds of message your app sends; each gets a template field on
|
|
200
|
+
template-based providers:
|
|
201
|
+
|
|
202
|
+
```ruby
|
|
203
|
+
SpreeNotificationProviders.register_message_type :order_confirmation,
|
|
204
|
+
label: 'Order confirmation', channels: %i[email sms]
|
|
205
|
+
SpreeNotificationProviders.register_message_type :newsletter,
|
|
206
|
+
label: 'Newsletter', channels: %i[email], bulk: true
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
### Action Mailer
|
|
210
|
+
|
|
211
|
+
With `delivery_method = :spree_notification_providers`, each mail is sent
|
|
212
|
+
through the store's Email providers. Optional headers (removed before sending):
|
|
213
|
+
|
|
214
|
+
| Header | Meaning |
|
|
215
|
+
|--------|---------|
|
|
216
|
+
| `X-Spree-Store-Id` | Store id (default `Spree::Current.store`, then the default store) |
|
|
217
|
+
| `X-Spree-Message-Type` | Message type (default `transactional_email`) |
|
|
218
|
+
| `X-Spree-Provider-Template-<Key>` | Template for one provider, e.g. `X-Spree-Provider-Template-Msg91` |
|
|
219
|
+
|
|
220
|
+
The method raises `SpreeNotificationProviders::DeliveryFailed` only when no
|
|
221
|
+
provider can have taken the mail, so `deliver_later` retries are safe.
|
|
222
|
+
|
|
223
|
+
### Events
|
|
224
|
+
|
|
225
|
+
| Event | When |
|
|
226
|
+
|-------|------|
|
|
227
|
+
| `notification.delivered` | a provider delivered a message |
|
|
228
|
+
| `notification.failed` | every provider failed or was skipped |
|
|
229
|
+
| `notification.delivery_failed` | a provider reported an accepted message as failed |
|
|
230
|
+
|
|
231
|
+
---
|
|
232
|
+
|
|
233
|
+
## Adding a provider
|
|
234
|
+
|
|
235
|
+
```ruby
|
|
236
|
+
# config/initializers/notification_providers.rb
|
|
237
|
+
SpreeNotificationProviders.register_provider :twilio do
|
|
238
|
+
label 'Twilio'
|
|
239
|
+
adapter 'MyApp::TwilioAdapter'
|
|
240
|
+
|
|
241
|
+
shared_input :account_sid, label: 'Account SID', required: true, env: 'TWILIO_ACCOUNT_SID'
|
|
242
|
+
shared_input :auth_token, label: 'Auth token', required: true, secret: true, env: 'TWILIO_AUTH_TOKEN'
|
|
243
|
+
|
|
244
|
+
channel :sms, kinds: %i[otp template] do
|
|
245
|
+
input :from_number, label: 'From number', type: :phone, required: true
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
```ruby
|
|
251
|
+
class MyApp::TwilioAdapter < SpreeNotificationProviders::Adapters::Base
|
|
252
|
+
def deliver(message, context)
|
|
253
|
+
response = SpreeNotificationProviders::Http.request(
|
|
254
|
+
:post, "https://api.twilio.com/2010-04-01/Accounts/#{context[:account_sid]}/Messages.json",
|
|
255
|
+
headers: { 'Authorization' => "Basic #{Base64.strict_encode64("#{context[:account_sid]}:#{context[:auth_token]}")}" },
|
|
256
|
+
form: { 'To' => message.to, 'From' => context[:from_number], 'Body' => "Your code is #{message.code}" }
|
|
257
|
+
)
|
|
258
|
+
body = SpreeNotificationProviders::Http.parse_json(response.body)
|
|
259
|
+
SpreeNotificationProviders::Adapters::HttpOutcome.from_response(
|
|
260
|
+
response, body, accepted: body['sid'].present?, message_id: body['sid'], provider_message: body['message']
|
|
261
|
+
)
|
|
262
|
+
rescue StandardError => e
|
|
263
|
+
SpreeNotificationProviders::Adapters::HttpOutcome.from_error(e)
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
- Input types: `:string`, `:integer`, `:email`, `:phone`, `:url`, `:select`
|
|
269
|
+
(with `options:`), `:boolean`. Options: `required`, `secret`, `format`
|
|
270
|
+
(Regexp), `default`, `label`, `help`, `env`.
|
|
271
|
+
- Channel kinds: `:otp`, `:template`, `:html`, `:bulk`. `template_required: true`
|
|
272
|
+
skips the provider for message types without a template; `missing_check`
|
|
273
|
+
adds readiness rules that depend on several values.
|
|
274
|
+
- `context[:name]` gives merged shared + channel values (secrets decrypted,
|
|
275
|
+
defaults applied); `context.template` is the template for the message type.
|
|
276
|
+
- Return `Result.delivered(id)`, `Result.failed(reason)` or
|
|
277
|
+
`Result.unknown(reason)`. Never put credentials or full response bodies in a
|
|
278
|
+
reason.
|
|
279
|
+
- `env:` is read only by the importer below; adapters never read `ENV`.
|
|
280
|
+
|
|
281
|
+
---
|
|
282
|
+
|
|
283
|
+
## Upgrading from environment variables
|
|
284
|
+
|
|
285
|
+
If your app kept provider settings in ENV (`MSG91_AUTH_KEY`, `SMTP_HOST`,
|
|
286
|
+
`GUPSHUP_API_KEY`, …), copy them into a store's configuration:
|
|
287
|
+
|
|
288
|
+
```bash
|
|
289
|
+
bin/rails g spree_notification_providers:import_env --store=<code> --dry-run
|
|
290
|
+
bin/rails g spree_notification_providers:import_env --store=<code>
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
Only blank inputs are filled, so it is safe to rerun.
|
|
294
|
+
|
|
295
|
+
---
|
|
296
|
+
|
|
297
|
+
## Configuration
|
|
298
|
+
|
|
299
|
+
```ruby
|
|
300
|
+
SpreeNotificationProviders.configure do |config|
|
|
301
|
+
config.delivery_retention_days = 90 # >= 1
|
|
302
|
+
config.enable_log_provider = !Rails.env.production? # register the Log provider
|
|
303
|
+
config.http_timeout_seconds = 5 # bundled HTTP adapters
|
|
304
|
+
config.bulk_batch_size = 100
|
|
305
|
+
end
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
---
|
|
309
|
+
|
|
310
|
+
## Development
|
|
311
|
+
|
|
312
|
+
```bash
|
|
313
|
+
bundle exec rake test_app # generates spec/dummy (spree_dev_tools)
|
|
314
|
+
bundle exec rspec
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
Specs stub `SpreeNotificationProviders::Http.request`; a real provider call
|
|
318
|
+
from a spec raises.
|
|
319
|
+
|
|
320
|
+
## License
|
|
321
|
+
|
|
322
|
+
BSD 3-Clause. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spree
|
|
4
|
+
module Admin
|
|
5
|
+
# Adds, configures, reorders, tests and removes one provider on one channel.
|
|
6
|
+
class NotificationChannelProvidersController < Spree::Admin::BaseController
|
|
7
|
+
helper 'spree/admin/notification_masking'
|
|
8
|
+
|
|
9
|
+
before_action :authorize_providers
|
|
10
|
+
before_action :load_entry, except: :create
|
|
11
|
+
|
|
12
|
+
rate_limit to: 5, within: 1.minute, only: :test, by: -> { try_spree_current_user&.id || request.remote_ip },
|
|
13
|
+
with: -> { redirect_back_or_to(edit_path, alert: 'Too many test messages. Try again in a minute.') }
|
|
14
|
+
|
|
15
|
+
def create
|
|
16
|
+
channel = params[:channel].to_s
|
|
17
|
+
definition = SpreeNotificationProviders.provider(params[:provider_key])
|
|
18
|
+
unless Spree::NotificationChannelProvider::CHANNELS.include?(channel) && definition&.supports?(channel)
|
|
19
|
+
flash[:error] = 'That provider is not available for this channel.'
|
|
20
|
+
return redirect_to spree.admin_notification_providers_path(channel: channel.presence), status: :see_other
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
entry = Spree::NotificationChannelProvider.find_or_initialize_by(store_id: current_store.id, channel: channel,
|
|
24
|
+
provider_key: definition.key.to_s)
|
|
25
|
+
entry.enabled = false if entry.new_record?
|
|
26
|
+
entry.save!(validate: false)
|
|
27
|
+
entry.provider_config
|
|
28
|
+
flash[:success] = t('spree_notification_providers.admin.providers.added', provider: definition.label,
|
|
29
|
+
channel: channel_label(channel))
|
|
30
|
+
redirect_to spree.edit_admin_notification_channel_provider_path(entry), status: :see_other
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def edit; end
|
|
34
|
+
|
|
35
|
+
def update
|
|
36
|
+
config = @entry.provider_config
|
|
37
|
+
attrs = entry_params
|
|
38
|
+
|
|
39
|
+
config.assign_inputs(@entry.shared_inputs, values: attrs[:shared_settings], secrets: attrs[:shared_secret_settings],
|
|
40
|
+
clear: attrs[:clear_shared_secrets])
|
|
41
|
+
@entry.assign_inputs(@entry.channel_inputs, values: attrs[:settings], secrets: attrs[:secret_settings],
|
|
42
|
+
clear: attrs[:clear_secrets])
|
|
43
|
+
@entry.templates = cleaned_templates(attrs[:templates]) if attrs.key?(:templates)
|
|
44
|
+
@entry.enabled = ActiveModel::Type::Boolean.new.cast(attrs[:enabled]) if attrs.key?(:enabled)
|
|
45
|
+
|
|
46
|
+
config_valid = config.valid?
|
|
47
|
+
if config_valid && @entry.valid?
|
|
48
|
+
ActiveRecord::Base.transaction do
|
|
49
|
+
config.save!
|
|
50
|
+
@entry.save!
|
|
51
|
+
end
|
|
52
|
+
SpreeNotificationProviders::Adapters::Gupshup.clear_template_cache(current_store) if @entry.provider_key == 'gupshup'
|
|
53
|
+
flash[:success] = t('spree_notification_providers.admin.providers.updated', provider: provider_label)
|
|
54
|
+
redirect_to(params[:return_to] == 'index' ? index_path : edit_path, status: :see_other)
|
|
55
|
+
else
|
|
56
|
+
@config_errors = config.input_errors
|
|
57
|
+
flash.now[:error] = (config.errors.full_messages + @entry.errors.full_messages).uniq.join(', ')
|
|
58
|
+
render :edit, status: :unprocessable_content
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def reposition
|
|
63
|
+
position = (params.dig(:notification_channel_provider, :position) || params[:position]).to_i
|
|
64
|
+
count = Spree::NotificationChannelProvider.where(store_id: current_store.id, channel: @entry.channel).count
|
|
65
|
+
@entry.insert_at(position.clamp(1, count)) if position.positive?
|
|
66
|
+
head :no_content
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def destroy
|
|
70
|
+
channel = @entry.channel
|
|
71
|
+
@entry.destroy!
|
|
72
|
+
unless Spree::NotificationChannelProvider.exists?(store_id: current_store.id, provider_key: @entry.provider_key)
|
|
73
|
+
Spree::NotificationProviderConfig.where(store_id: current_store.id, provider_key: @entry.provider_key).destroy_all
|
|
74
|
+
end
|
|
75
|
+
flash[:success] = t('spree_notification_providers.admin.providers.removed', provider: provider_label,
|
|
76
|
+
channel: channel_label(channel))
|
|
77
|
+
redirect_to spree.admin_notification_providers_path(channel: channel), status: :see_other
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Sends one message through this provider only, ignoring priority.
|
|
81
|
+
def test
|
|
82
|
+
to = params[:to].to_s.strip
|
|
83
|
+
unless valid_test_recipient?(to)
|
|
84
|
+
kind = @entry.channel == 'email' ? 'email address' : 'phone number'
|
|
85
|
+
flash[:error] = t('spree_notification_providers.admin.configure.test_invalid_to', kind: kind)
|
|
86
|
+
return redirect_to edit_path, status: :see_other
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
message = SpreeNotificationProviders::Message.new(channel: @entry.channel, type: :provider_test, to: to,
|
|
90
|
+
code: SpreeNotificationProviders::Adapters::Base::TEST_CODE)
|
|
91
|
+
result = SpreeNotificationProviders::Router.new(current_store).deliver_via(@entry, message)
|
|
92
|
+
if result.success?
|
|
93
|
+
flash[:success] = t('spree_notification_providers.admin.configure.test_delivered', provider: provider_label)
|
|
94
|
+
else
|
|
95
|
+
flash[:error] = t('spree_notification_providers.admin.configure.test_failed',
|
|
96
|
+
provider: provider_label, reason: result.attempts.first[:reason] || result.outcome)
|
|
97
|
+
end
|
|
98
|
+
redirect_to edit_path, status: :see_other
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def model_class
|
|
104
|
+
Spree::NotificationChannelProvider
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def authorize_providers
|
|
108
|
+
authorize! :manage, Spree::NotificationChannelProvider
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def load_entry
|
|
112
|
+
@entry = Spree::NotificationChannelProvider.where(store_id: current_store.id).find_by_param!(params[:id])
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def entry_params
|
|
116
|
+
params.fetch(:entry, {}).permit(:enabled, settings: {}, secret_settings: {}, shared_settings: {},
|
|
117
|
+
shared_secret_settings: {}, templates: {}, clear_secrets: [],
|
|
118
|
+
clear_shared_secrets: [])
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Only registered message types for this channel; blanks dropped.
|
|
122
|
+
def cleaned_templates(raw)
|
|
123
|
+
allowed = SpreeNotificationProviders.message_types_for(@entry.channel).map { |type| type.key.to_s }
|
|
124
|
+
raw.to_h.slice(*allowed).transform_values { |value| value.to_s.strip }.compact_blank
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def valid_test_recipient?(to)
|
|
128
|
+
if @entry.channel == 'email'
|
|
129
|
+
to.match?(SpreeNotificationProviders::InputDefinition::EMAIL_FORMAT)
|
|
130
|
+
else
|
|
131
|
+
to.delete(' -').match?(SpreeNotificationProviders::InputDefinition::PHONE_FORMAT)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def provider_label
|
|
136
|
+
@entry.definition&.label || @entry.provider_key.titleize
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def channel_label(channel)
|
|
140
|
+
t("spree_notification_providers.channels.#{channel}")
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def edit_path
|
|
144
|
+
spree.edit_admin_notification_channel_provider_path(@entry)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def index_path
|
|
148
|
+
spree.admin_notification_providers_path(channel: @entry.channel)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spree
|
|
4
|
+
module Admin
|
|
5
|
+
# Settings → Delivery log: every send attempt for the current store,
|
|
6
|
+
# grouped by message. Read-only; recipients are masked.
|
|
7
|
+
class NotificationDeliveriesController < Spree::Admin::BaseController
|
|
8
|
+
include Pagy::Method
|
|
9
|
+
|
|
10
|
+
helper 'spree/admin/notification_masking'
|
|
11
|
+
|
|
12
|
+
PER_PAGE = 50
|
|
13
|
+
|
|
14
|
+
def index
|
|
15
|
+
authorize! :manage, Spree::NotificationDelivery
|
|
16
|
+
|
|
17
|
+
@search = scope.ransack(params[:q])
|
|
18
|
+
filtered = @search.result
|
|
19
|
+
refs = filtered.group(:message_ref).order(Arel.sql('MAX(created_at) DESC')).select(:message_ref)
|
|
20
|
+
# Grouped rows have no simple count, so page without one.
|
|
21
|
+
@pagy, page_refs = pagy(:countless, refs, limit: PER_PAGE)
|
|
22
|
+
@groups = build_groups(page_refs.map(&:message_ref))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def show
|
|
26
|
+
authorize! :manage, Spree::NotificationDelivery
|
|
27
|
+
|
|
28
|
+
all = scope.where(message_ref: params[:message_ref]).order(:attempt_number, :id)
|
|
29
|
+
raise ActiveRecord::RecordNotFound if all.none?
|
|
30
|
+
|
|
31
|
+
@message_ref = params[:message_ref]
|
|
32
|
+
@first = all.first
|
|
33
|
+
@pagy, @deliveries = pagy(:offset, all, limit: PER_PAGE)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def model_class
|
|
39
|
+
Spree::NotificationDelivery
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def scope
|
|
43
|
+
Spree::NotificationDelivery.where(store_id: current_store.id)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
Group = Struct.new(:message_ref, :attempts, :bulk_first, :bulk_counts, keyword_init: true) do
|
|
47
|
+
def bulk? = bulk_first.present?
|
|
48
|
+
def first = bulk_first || attempts.first
|
|
49
|
+
def final = bulk? ? nil : (attempts.reverse.find { |a| a.outcome.in?(%w[delivered unknown]) } || attempts.last)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# One-to-one messages load their few attempts; a bulk send loads one
|
|
53
|
+
# sample row and counts per outcome, never every recipient.
|
|
54
|
+
def build_groups(refs)
|
|
55
|
+
attempts = scope.where(message_ref: refs, bulk: false).order(:attempt_number, :id).group_by(&:message_ref)
|
|
56
|
+
bulk_first = scope.where(message_ref: refs, bulk: true).select('DISTINCT ON (message_ref) *')
|
|
57
|
+
.order(:message_ref, :id).index_by(&:message_ref)
|
|
58
|
+
counts = scope.where(message_ref: bulk_first.keys).group(:message_ref, :outcome).count
|
|
59
|
+
|
|
60
|
+
refs.filter_map do |ref|
|
|
61
|
+
next unless attempts[ref] || bulk_first[ref]
|
|
62
|
+
|
|
63
|
+
bulk_counts = counts.select { |(r, _), _| r == ref }.transform_keys(&:last)
|
|
64
|
+
Group.new(message_ref: ref, attempts: attempts[ref] || [], bulk_first: bulk_first[ref], bulk_counts: bulk_counts)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spree
|
|
4
|
+
module Admin
|
|
5
|
+
# Settings → Notification providers: one tab per channel, providers in
|
|
6
|
+
# priority order, and the providers that can still be added.
|
|
7
|
+
class NotificationProvidersController < Spree::Admin::BaseController
|
|
8
|
+
helper 'spree/admin/notification_masking'
|
|
9
|
+
|
|
10
|
+
def index
|
|
11
|
+
authorize! :manage, Spree::NotificationChannelProvider
|
|
12
|
+
|
|
13
|
+
@channel = channel_param
|
|
14
|
+
@entries = Spree::NotificationChannelProvider.where(store_id: current_store.id, channel: @channel)
|
|
15
|
+
.order(:position).to_a
|
|
16
|
+
added = @entries.map(&:provider_key)
|
|
17
|
+
@available = SpreeNotificationProviders.providers_for(@channel).reject { |p| added.include?(p.key.to_s) }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def model_class
|
|
23
|
+
Spree::NotificationChannelProvider
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def channel_param
|
|
27
|
+
Spree::NotificationChannelProvider::CHANNELS.include?(params[:channel]) ? params[:channel] : 'email'
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|