smswire 0.0.1.pre → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +67 -3
  3. data/README.md +222 -9
  4. data/RELEASING.md +49 -0
  5. data/app/controllers/smswire/development_controller.rb +17 -0
  6. data/app/controllers/smswire/inbound_messages_controller.rb +9 -0
  7. data/app/controllers/smswire/inbox_controller.rb +31 -0
  8. data/app/controllers/smswire/previews_controller.rb +22 -0
  9. data/app/controllers/smswire/status_callbacks_controller.rb +8 -0
  10. data/app/controllers/smswire/webhooks_controller.rb +49 -0
  11. data/app/models/smswire/application_record.rb +5 -0
  12. data/app/models/smswire/consent.rb +71 -0
  13. data/app/models/smswire/delivery.rb +157 -0
  14. data/app/models/smswire/inbound_message.rb +25 -0
  15. data/app/views/layouts/smswire/application.html.erb +60 -0
  16. data/app/views/smswire/inbox/index.html.erb +56 -0
  17. data/app/views/smswire/previews/index.html.erb +21 -0
  18. data/app/views/smswire/previews/show.html.erb +34 -0
  19. data/config/routes.rb +10 -0
  20. data/db/migrate/20261008000000_create_smswire_deliveries.rb +47 -0
  21. data/db/migrate/20261008000001_create_smswire_consents_and_inbound_messages.rb +34 -0
  22. data/docs/SPEC.md +164 -43
  23. data/docs/guides/compliance.md +124 -0
  24. data/docs/guides/getting-started.md +154 -0
  25. data/docs/guides/migrating-from-noticed-twilio.md +105 -0
  26. data/docs/guides/migrating-from-twilio-ruby.md +146 -0
  27. data/docs/guides/writing-a-provider.md +126 -0
  28. data/lib/generators/smswire/install/install_generator.rb +44 -0
  29. data/lib/generators/smswire/install/templates/application_messenger.rb.tt +3 -0
  30. data/lib/generators/smswire/install/templates/initializer.rb.tt +35 -0
  31. data/lib/generators/smswire/messenger/messenger_generator.rb +51 -0
  32. data/lib/generators/smswire/messenger/templates/messenger.rb.tt +14 -0
  33. data/lib/generators/smswire/messenger/templates/preview.rb.tt +13 -0
  34. data/lib/generators/smswire/messenger/templates/test.rb.tt +18 -0
  35. data/lib/generators/smswire/messenger/templates/view.text.erb.tt +1 -0
  36. data/lib/generators/smswire/provider/provider_generator.rb +36 -0
  37. data/lib/generators/smswire/provider/templates/provider.rb.tt +52 -0
  38. data/lib/generators/smswire/provider/templates/test.rb.tt +29 -0
  39. data/lib/smswire/base.rb +123 -0
  40. data/lib/smswire/body_formatter.rb +14 -0
  41. data/lib/smswire/callbacks.rb +24 -0
  42. data/lib/smswire/configuration.rb +224 -0
  43. data/lib/smswire/delivery_job.rb +29 -0
  44. data/lib/smswire/engine.rb +52 -0
  45. data/lib/smswire/errors.rb +59 -0
  46. data/lib/smswire/http.rb +65 -0
  47. data/lib/smswire/inbound.rb +11 -0
  48. data/lib/smswire/inbound_handler.rb +29 -0
  49. data/lib/smswire/interceptors/allowlist.rb +36 -0
  50. data/lib/smswire/keyword_messenger.rb +12 -0
  51. data/lib/smswire/keywords.rb +51 -0
  52. data/lib/smswire/log_subscriber.rb +57 -0
  53. data/lib/smswire/message.rb +72 -0
  54. data/lib/smswire/message_delivery.rb +69 -0
  55. data/lib/smswire/noticed_delivery_method.rb +54 -0
  56. data/lib/smswire/parameterized_messenger.rb +24 -0
  57. data/lib/smswire/phone_number.rb +65 -0
  58. data/lib/smswire/pipeline.rb +114 -0
  59. data/lib/smswire/preview.rb +54 -0
  60. data/lib/smswire/provider_contract.rb +158 -0
  61. data/lib/smswire/providers/base.rb +66 -0
  62. data/lib/smswire/providers/log.rb +23 -0
  63. data/lib/smswire/providers/null.rb +12 -0
  64. data/lib/smswire/providers/telnyx.rb +178 -0
  65. data/lib/smswire/providers/test.rb +59 -0
  66. data/lib/smswire/providers/twilio.rb +167 -0
  67. data/lib/smswire/providers/vonage.rb +193 -0
  68. data/lib/smswire/providers.rb +38 -0
  69. data/lib/smswire/quiet_hours.rb +45 -0
  70. data/lib/smswire/rate_limit.rb +27 -0
  71. data/lib/smswire/receipt.rb +11 -0
  72. data/lib/smswire/recipient.rb +38 -0
  73. data/lib/smswire/result.rb +40 -0
  74. data/lib/smswire/rspec.rb +133 -0
  75. data/lib/smswire/segments.rb +81 -0
  76. data/lib/smswire/sender.rb +27 -0
  77. data/lib/smswire/status_handler.rb +25 -0
  78. data/lib/smswire/status_update.rb +8 -0
  79. data/lib/smswire/test_helper.rb +81 -0
  80. data/lib/smswire/testing.rb +72 -0
  81. data/lib/smswire/version.rb +1 -1
  82. data/lib/smswire.rb +70 -6
  83. data/smswire.gemspec +9 -5
  84. metadata +195 -10
  85. data/Gemfile +0 -7
  86. data/Rakefile +0 -8
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 513ee2707d29f757c1f3dd7f43eefda758ff634decbd09bfae3bcde38b29c775
4
- data.tar.gz: 8edba21706e42e4825524d28f5de622294aeacec784089ddb83b88cecc19276c
3
+ metadata.gz: 2fa9a8b61ee54a3bb0b7e0addffcf887fac34022b9f34765390fff4e6d230b3b
4
+ data.tar.gz: '0649a4c714cab2a26a4abf71014a4cd88d6713eef7c88aef0ce4f17c20ccb853'
5
5
  SHA512:
6
- metadata.gz: 908a9ff9f247261b23fbecaad6e91e78e12310b0afa0c282a7da55cb581cef0f2779851903ddd6184bb1fa34de917e2dd887646ad5085de8246cc7514daadcbc
7
- data.tar.gz: 1d5ee0fa8d452ea828004fe551dfb69a1468a5e0dd6e8770793d8a2c94e825948cb684f6b2c5ab6228f5fb0e46a9225fa939ff5a37ac2d46632010a6577d56b3
6
+ metadata.gz: 06e54b416dcb1e58be5cdd8a563ce1a1fde6022ab82fe084012be2b15bc702dd49fa90c6f5251927d92d2b3c3c4ddf8acb7b6389ffc1a4069208dd3fba25c45f
7
+ data.tar.gz: '095e78ad1679ec376c2f3787f1699b940190ac619226b3c16b47f7af4b3a92bfef80faeaea0d0ea830d19a6b2e09dc39a828b5aad212c418bccc0939b289ccc5'
data/CHANGELOG.md CHANGED
@@ -1,10 +1,74 @@
1
1
  # Changelog
2
2
 
3
3
  All notable changes to this project are documented here. The format follows
4
- [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow
5
+ [Semantic Versioning](https://semver.org/). Before 1.0, minor versions may
6
+ include breaking changes, which are called out here.
5
7
 
6
8
  ## [Unreleased]
7
9
 
8
- ## [0.0.1.pre]
10
+ ## [0.1.0] - 2026-10-15
9
11
 
10
- - Reserve the gem name. No functionality yet; see `docs/SPEC.md`.
12
+ First functional release. Requires Ruby 3.2+ and Rails 7.1 to 8.1.
13
+
14
+ ### Messengers and delivery
15
+
16
+ - `Smswire::Base` messenger classes with `default`, `with(params)`, `.text.erb`
17
+ templates, layouts, I18n lazy lookup, URL helpers, and named senders.
18
+ - `Smswire::Message` with GSM-7 / UCS-2 detection and segment counting.
19
+ - A delivery pipeline with E.164 normalization, consent checks, quiet hours,
20
+ rate limits, deduplication, interceptors, and observers, returning a
21
+ `Smswire::Result` with a typed status.
22
+ - `deliver_later` through `Smswire::DeliveryJob`, retrying transient errors
23
+ with backoff and honoring `Retry-After`.
24
+
25
+ ### Providers
26
+
27
+ - Twilio, Telnyx, and Vonage adapters over REST with no provider SDK, plus
28
+ `log`, `test`, and `null` providers and a registry for custom adapters.
29
+ - Webhook signature verification for each provider: Twilio HMAC-SHA1, Telnyx
30
+ Ed25519, and Vonage `md5hash` or HMAC.
31
+ - `Smswire::ProviderContract`, a Minitest suite every adapter passes.
32
+ - `Smswire.verify_credentials!` for boot checks and health endpoints.
33
+
34
+ ### Persistence and tracking
35
+
36
+ - `Smswire::Engine` with `smswire:install:migrations` and tables for
37
+ deliveries, consents, and inbound messages, following the app's primary
38
+ key type.
39
+ - A `Smswire::Delivery` row for every send, with atomic send claims, retries
40
+ that reuse their row, and forward-only status from provider callbacks.
41
+
42
+ ### Consent and compliance
43
+
44
+ - `Smswire::Consent` with per-sender scopes and evidence metadata.
45
+ - STOP / START / HELP keyword handling with replies following CTIA guidance,
46
+ including a `support_contact` in HELP replies.
47
+ - Category rules: `marketing` requires opt-in and observes 9pm to 8am quiet
48
+ hours in the recipient's time zone. Carrier opt-out errors record opt-outs.
49
+
50
+ ### Developer experience
51
+
52
+ - Previews at `<mount>/previews` and a development inbox at `<mount>/inbox`.
53
+ - Generators: `smswire:install`, `smswire:messenger`, and `smswire:provider`.
54
+ - `Smswire::TestHelper` for Minitest and RSpec matchers in `smswire/rspec`.
55
+ - `Smswire::Interceptors::Allowlist` for staging.
56
+ - `Noticed::DeliveryMethods::Smswire` for `deliver_by :smswire`.
57
+ - Guides in `docs/guides`.
58
+
59
+ ### Known limitations
60
+
61
+ - Free-text opt-outs such as "please stop texting me" are stored but not
62
+ acted on; the compliance guide shows how to review them.
63
+ - Concurrent sends can briefly exceed a rate limit.
64
+ - Vonage receipts for the second and later parts of a long message are not
65
+ matched to the delivery, and Vonage has no MMS.
66
+ - Telnyx ignores `validity_period`.
67
+
68
+ ## [0.0.1.pre] - 2026-10-08
69
+
70
+ - Reserve the gem name. No functionality.
71
+
72
+ [Unreleased]: https://github.com/timimsms/smswire/compare/v0.1.0...HEAD
73
+ [0.1.0]: https://github.com/timimsms/smswire/compare/v0.0.1.pre...v0.1.0
74
+ [0.0.1.pre]: https://github.com/timimsms/smswire/releases/tag/v0.0.1.pre
data/README.md CHANGED
@@ -3,18 +3,231 @@
3
3
  > The production SMS layer for Rails that Noticed and twilio-ruby both leave to you.
4
4
 
5
5
  Smswire gives Rails applications Action Mailer-style messenger classes and
6
- templates for SMS, a provider-agnostic message object, persisted deliveries
7
- with provider status callbacks, consent and keyword handling (STOP / HELP /
8
- START), quiet hours, retries with a transient-vs-permanent error taxonomy,
9
- development sandboxing, previews, and test helpers.
6
+ templates for SMS, a provider-agnostic message object, a delivery pipeline
7
+ with typed outcomes, retries with a transient-versus-permanent error
8
+ taxonomy, and test helpers. Persisted deliveries with status callbacks,
9
+ consent and STOP / HELP / START handling, quiet hours, previews, and a
10
+ [Noticed](https://github.com/excid3/noticed) delivery method are included.
11
+ See the [specification](docs/SPEC.md) for the full design.
10
12
 
11
- It complements [Noticed](https://github.com/excid3/noticed) by shipping a
12
- `Noticed::DeliveryMethods::Smswire` adapter, and it works standalone for apps
13
- that do not need a notification inbox.
13
+ **Status:** `0.1.0`, the first functional release. The API may still change
14
+ before 1.0; breaking changes are called out in the [changelog](CHANGELOG.md).
14
15
 
15
- **Status:** specification stage. Nothing is published yet.
16
+ ## Guides
16
17
 
17
- - [Scoped specification](docs/SPEC.md)
18
+ - [Getting started](docs/guides/getting-started.md)
19
+ - [Migrating from direct twilio-ruby calls](docs/guides/migrating-from-twilio-ruby.md)
20
+ - [Migrating from Noticed's Twilio delivery method](docs/guides/migrating-from-noticed-twilio.md)
21
+ - [Compliance primer](docs/guides/compliance.md)
22
+ - [Writing a provider adapter](docs/guides/writing-a-provider.md)
23
+
24
+ ## Requirements
25
+
26
+ Ruby 3.2+ and Rails 7.1 through 8.1.
27
+
28
+ ## Installation
29
+
30
+ ```ruby
31
+ # Gemfile
32
+ gem "smswire"
33
+ ```
34
+
35
+ ```
36
+ bin/rails generate smswire:install
37
+ bin/rails db:migrate
38
+ bin/rails generate smswire:messenger Order shipped
39
+ ```
40
+
41
+ The install generator copies the migrations, writes
42
+ `config/initializers/smswire.rb` and `ApplicationMessenger`, and mounts the
43
+ engine at `/smswire`. The messenger generator writes the class, a template per
44
+ action, a preview, and a test.
45
+
46
+ ## Usage
47
+
48
+ ```ruby
49
+ # config/initializers/smswire.rb
50
+ Smswire.configure do |config|
51
+ config.default_provider = :twilio # :log in development and :test in test by default
52
+ config.providers = {twilio: {account_sid: "AC...", auth_token: "..."}} # or credentials.twilio
53
+ config.senders = {transactional: {number: "+15005550006"}}
54
+ end
55
+ ```
56
+
57
+ ```ruby
58
+ # app/messengers/order_messenger.rb
59
+ class OrderMessenger < Smswire::Base
60
+ default from: :transactional
61
+
62
+ def shipped(user)
63
+ @order = params[:order]
64
+ text to: user # any object with #phone_number, or an E.164 string
65
+ end
66
+ end
67
+ ```
68
+
69
+ ```erb
70
+ <%# app/views/order_messenger/shipped.text.erb %>
71
+ <%= t(".greeting", name: @order.customer_name) %> Order <%= @order.number %> has shipped.
72
+ Track it: <%= order_url(@order) %>
73
+ ```
74
+
75
+ ```ruby
76
+ OrderMessenger.with(order:).shipped(user).deliver_later
77
+ result = OrderMessenger.with(order:).shipped(user).deliver_now
78
+ result.status # => :accepted, :failed, :rejected_invalid_number, ...
79
+ result.provider_id # => "SM..."
80
+ ```
81
+
82
+ Every message is normalized to E.164, checked for an empty body, passed
83
+ through interceptors, sent, and reported to observers and
84
+ `ActiveSupport::Notifications` (`deliver.smswire`, `reject.smswire`). Bodies
85
+ are never instrumented or logged by provider adapters.
86
+
87
+ ### Deliveries and status callbacks
88
+
89
+ ```
90
+ bin/rails smswire:install:migrations
91
+ bin/rails db:migrate
92
+ ```
93
+
94
+ ```ruby
95
+ # config/routes.rb
96
+ mount Smswire::Engine => "/smswire"
97
+
98
+ # config/initializers/smswire.rb
99
+ config.callbacks_url = "https://app.example.com/smswire"
100
+ ```
101
+
102
+ Every send is recorded in `Smswire::Delivery` with the provider message id,
103
+ segments, price, and status. With `callbacks_url` set, Twilio posts status
104
+ updates to the engine, which verifies the signature and moves the row
105
+ forward: `pending`, `queued`, `sent`, `delivered` (or `undelivered` /
106
+ `failed`). Out-of-order callbacks never move a status backwards.
107
+
108
+ Identical messages to the same number inside `dedupe_window` (10 minutes by
109
+ default) are refused with status `:duplicate`. Pass `idempotency_key:` to
110
+ `text` to choose what counts as identical. Bodies are stored unless the
111
+ category opts out; `otp` does by default.
112
+
113
+ ```ruby
114
+ result = OrderMessenger.with(order:).shipped(user).deliver_now
115
+ result.delivery.status # => "accepted"
116
+ Smswire::Delivery.for_number("+15551234567").recent
117
+ ```
118
+
119
+ ### Consent, keywords, quiet hours, and rate limits
120
+
121
+ Point your Twilio number's incoming message webhook at
122
+ `https://app.example.com/smswire/inbound/twilio`. Replies of STOP, START,
123
+ and HELP (and their synonyms) update `Smswire::Consent` and send the
124
+ configured confirmation. Record opt-ins from your own forms with evidence:
125
+
126
+ ```ruby
127
+ Smswire::Consent.opt_in!(user.phone, source: :web_form,
128
+ metadata: {ip: request.remote_ip, form: "checkout", copy: terms_text})
129
+ ```
130
+
131
+ Each message has a category. The shipped rules are:
132
+
133
+ | Category | Consent | Quiet hours |
134
+ |---|---|---|
135
+ | `transactional` (default) | send unless opted out | none |
136
+ | `otp` | send unless opted out; body not stored | none |
137
+ | `marketing` | requires opt-in | 9pm to 8am recipient time |
138
+ | `compliance` | not checked; keyword replies | none |
139
+
140
+ Override any key, and add rate limits, in `config.categories`. Messages in
141
+ quiet hours or over a rate limit are re-enqueued for when they may send and
142
+ return `:deferred_quiet_hours` or `:deferred_rate_limited`. A carrier
143
+ opt-out error from the provider records an opt-out automatically.
144
+
145
+ Smswire provides these mechanisms. Your app is still responsible for its
146
+ messaging program's legal compliance.
147
+
148
+ ### Development tools
149
+
150
+ In development, open `/smswire/previews` to see every preview rendered in a
151
+ phone frame with its encoding, segment count, and remaining characters, and
152
+ which characters force UCS-2. Open `/smswire/inbox` to see what was sent and
153
+ received, and to simulate a reply such as STOP.
154
+
155
+ ```ruby
156
+ # test/messengers/previews/order_messenger_preview.rb
157
+ class OrderMessengerPreview < Smswire::Preview
158
+ def shipped
159
+ OrderMessenger.with(order: Order.first).shipped(User.first)
160
+ end
161
+ end
162
+ ```
163
+
164
+ For staging, only let messages through to known numbers:
165
+
166
+ ```ruby
167
+ Smswire.register_interceptor(
168
+ Smswire::Interceptors::Allowlist.new(numbers: ENV.fetch("SMS_ALLOWLIST", "").split(","))
169
+ # or redirect_to: "+15555550100" to reroute everything else to one phone
170
+ )
171
+ ```
172
+
173
+ ### Providers
174
+
175
+ | Name | Use |
176
+ |---|---|
177
+ | `:twilio` | Twilio Programmable Messaging over REST, no SDK |
178
+ | `:telnyx` | Telnyx Messaging API v2; `api_key`, `public_key` for webhooks |
179
+ | `:vonage` | Vonage SMS API; `api_key`, `api_secret`, `signature_secret` |
180
+ | `:log` | Writes messages to the logger; development default |
181
+ | `:test` | Records messages in memory; test default |
182
+ | `:null` | Accepts and discards |
183
+
184
+ Each adapter passes `Smswire::ProviderContract`, a test suite shipped in the
185
+ gem. Generate your own with `bin/rails generate smswire:provider Acme`, which
186
+ writes an adapter and a test that runs the contract against it, or
187
+ subclass `Smswire::Providers::Base` and call
188
+ `Smswire::Providers.register(:acme, "AcmeProvider")`.
189
+
190
+ ### Noticed
191
+
192
+ ```ruby
193
+ class OrderShippedNotifier < Noticed::Event
194
+ deliver_by :smswire do |config|
195
+ config.messenger = "OrderMessenger"
196
+ config.action = :shipped # called with the recipient by default
197
+ end
198
+ end
199
+ ```
200
+
201
+ The messenger gets the event params plus `notification`, `record`, and
202
+ `recipient`, so consent, quiet hours, deduplication, retries, and status
203
+ tracking all apply. Each delivery stores the Noticed notification and event
204
+ ids in its metadata.
205
+
206
+ ### Testing
207
+
208
+ ```ruby
209
+ class OrderTest < ActiveSupport::TestCase
210
+ include Smswire::TestHelper
211
+
212
+ test "ships" do
213
+ assert_sms_delivered(to: "+15551234567", messenger: OrderMessenger, body: /shipped/) do
214
+ order.ship! # deliver_later jobs inside the block are performed
215
+ end
216
+ assert_enqueued_sms(OrderMessenger, :shipped) { order.ship! }
217
+ end
218
+ end
219
+ ```
220
+
221
+ RSpec: `require "smswire/rspec"` for `deliver_sms(...)` and
222
+ `have_enqueued_sms(OrderMessenger, :shipped)`.
223
+
224
+ ## Development
225
+
226
+ ```
227
+ bin/setup
228
+ bundle exec rake # tests and Standard
229
+ RAILS_VERSION=7.1 bundle exec rake test
230
+ ```
18
231
 
19
232
  ## License
20
233
 
data/RELEASING.md ADDED
@@ -0,0 +1,49 @@
1
+ # Releasing
2
+
3
+ Releases publish to RubyGems from GitHub Actions with
4
+ [trusted publishing](https://guides.rubygems.org/trusted-publishing/), so no
5
+ API key or one-time code is stored anywhere. The gem requires MFA for
6
+ manual pushes.
7
+
8
+ ## One-time setup
9
+
10
+ 1. On rubygems.org, open the `smswire` gem, then **Trusted publishers**, and
11
+ add a GitHub Actions publisher:
12
+ - Repository: `timimsms/smswire`
13
+ - Workflow filename: `release.yml`
14
+ - Environment: `rubygems`
15
+ 2. In the GitHub repository settings, create an environment named
16
+ `rubygems`. Add required reviewers if releases should need approval.
17
+
18
+ ## Before 1.0
19
+
20
+ - Confirm open decisions 4 to 6 in `docs/SPEC.md`.
21
+ - Review the known limitations in `CHANGELOG.md` and `docs/SPEC.md`.
22
+ - Try the getting-started guide in a new Rails app against a real provider
23
+ account, including a STOP reply and a delivery status callback.
24
+
25
+ ## Each release
26
+
27
+ 1. Open a pull request that:
28
+ - updates `lib/smswire/version.rb`;
29
+ - in `CHANGELOG.md`, moves the `[Unreleased]` entries under a new
30
+ `[X.Y.Z] - YYYY-MM-DD` heading, leaves `[Unreleased]` empty, and updates
31
+ the compare links at the bottom.
32
+ 2. Merge it once CI is green.
33
+ 3. In GitHub, open **Actions > Release > Run workflow** on `main`.
34
+ 4. The workflow runs the tests, then `rake release` builds the gem, creates
35
+ and pushes the `vX.Y.Z` tag, and publishes with trusted publishing. It
36
+ waits until the version is available on RubyGems.
37
+ 5. Create a GitHub release from the new tag with the changelog section:
38
+
39
+ ```
40
+ gh release create vX.Y.Z --notes-file <(sed -n '/^## \[X.Y.Z\]/,/^## \[/p' CHANGELOG.md | sed '$d')
41
+ ```
42
+
43
+ If the workflow fails before publishing, fix the problem and run it again;
44
+ `rake release` skips tagging when the tag already exists.
45
+
46
+ ## If a release goes wrong
47
+
48
+ `gem yank smswire -v X.Y.Z` removes a version from installs, but the version
49
+ number cannot be reused. Fix forward with a patch release.
@@ -0,0 +1,17 @@
1
+ module Smswire
2
+ # Base for the development tools. Each subclass names the config flag
3
+ # that enables it; when the flag is off the tool answers 404.
4
+ class DevelopmentController < ActionController::Base
5
+ layout "smswire/application"
6
+
7
+ class_attribute :enabled_by
8
+
9
+ before_action :require_enabled
10
+
11
+ private
12
+
13
+ def require_enabled
14
+ head :not_found unless Smswire.config.public_send(enabled_by)
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,9 @@
1
+ module Smswire
2
+ # POST <mount>/inbound/:provider. Apps can build two-way messaging with
3
+ # an observer that implements +sms_received(inbound_message)+.
4
+ class InboundMessagesController < WebhooksController
5
+ def create
6
+ handle_inbound || handle_status || head(:no_content)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,31 @@
1
+ require "securerandom"
2
+
3
+ module Smswire
4
+ # Development inbox: recent deliveries and inbound messages, plus a form
5
+ # to simulate a reply such as STOP without a real provider.
6
+ class InboxController < DevelopmentController
7
+ self.enabled_by = :show_inbox
8
+
9
+ LIMIT = 50
10
+
11
+ def index
12
+ @deliveries = Delivery.recent.limit(LIMIT)
13
+ @inbound_messages = InboundMessage.recent.limit(LIMIT)
14
+ @default_to = Smswire.config.senders.values.filter_map { |sender| sender.to_h.symbolize_keys[:number] }.first
15
+ end
16
+
17
+ def receive
18
+ from = PhoneNumber.normalize(params[:from])
19
+ if from.nil? || params[:body].blank?
20
+ flash[:alert] = "Enter a valid From number and a message body."
21
+ return redirect_to(inbox_path)
22
+ end
23
+
24
+ inbound = Inbound.new(provider_id: "inbox-#{SecureRandom.hex(8)}", from:, to: params[:to].presence, body: params[:body])
25
+ record = InboundHandler.call(inbound, provider: Smswire.config.default_provider || :log)
26
+ keyword = record&.reload&.keyword
27
+ flash[:notice] = keyword ? "Received from #{from}; handled keyword #{keyword.upcase}." : "Received from #{from}."
28
+ redirect_to inbox_path
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,22 @@
1
+ module Smswire
2
+ class PreviewsController < DevelopmentController
3
+ self.enabled_by = :show_previews
4
+
5
+ def index
6
+ @previews = Preview.all
7
+ end
8
+
9
+ def show
10
+ preview_name, _, message_name = params[:path].rpartition("/")
11
+ @preview = Preview.find(preview_name)
12
+ return head(:not_found) unless @preview&.message_exists?(message_name)
13
+
14
+ @message_name = message_name
15
+ @message = @preview.call(message_name)
16
+ return render(plain: @message&.body.to_s) if params[:raw].present?
17
+
18
+ @analysis = @message && Segments.analyze(@message.body)
19
+ @non_gsm = @message ? Segments.non_gsm_characters(@message.body) : []
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,8 @@
1
+ module Smswire
2
+ # POST <mount>/status/:provider
3
+ class StatusCallbacksController < WebhooksController
4
+ def create
5
+ handle_status || handle_inbound || head(:no_content)
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,49 @@
1
+ module Smswire
2
+ # Shared provider webhook handling. Each endpoint tries its own kind of
3
+ # event first and falls back to the other, so a provider that posts every
4
+ # event to one URL (Telnyx messaging profiles do) is handled either way.
5
+ # Signed, well-formed requests always get a 2xx so providers do not retry.
6
+ class WebhooksController < ActionController::Base
7
+ skip_forgery_protection
8
+
9
+ before_action :load_provider
10
+ before_action :verify_signature
11
+
12
+ private
13
+
14
+ def load_provider
15
+ @provider = Providers.resolve(params[:provider])
16
+ head(:not_found) unless (@provider.capabilities & %i[status_callbacks inbound]).any?
17
+ rescue ConfigurationError
18
+ head :not_found
19
+ end
20
+
21
+ def verify_signature
22
+ return unless Smswire.config.verify_callback_signatures
23
+
24
+ @provider.verify_signature!(request, url: Callbacks.request_url(request))
25
+ rescue SignatureError => error
26
+ Smswire.logger.warn("[Smswire] Rejected #{@provider.name} webhook: #{error.message}")
27
+ head :forbidden
28
+ end
29
+
30
+ def handle_status
31
+ return false unless @provider.capabilities.include?(:status_callbacks)
32
+
33
+ update = @provider.parse_status_callback(request) or return false
34
+ StatusHandler.call(update, provider: @provider.name, delivery_id: params[:delivery_id])
35
+ head :no_content
36
+ true
37
+ end
38
+
39
+ def handle_inbound
40
+ return false unless @provider.capabilities.include?(:inbound)
41
+
42
+ inbound = @provider.parse_inbound(request) or return false
43
+ InboundHandler.call(inbound, provider: @provider.name)
44
+ body, content_type = @provider.inbound_response
45
+ body ? render(plain: body, content_type:) : head(:no_content)
46
+ true
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,5 @@
1
+ module Smswire
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,71 @@
1
+ module Smswire
2
+ # A phone number's messaging consent within a scope. The scope is
3
+ # "default" unless a sender sets +consent_scope+, which lets separate
4
+ # programs (for example two brands on two numbers) keep separate consent.
5
+ #
6
+ # Smswire::Consent.opt_in!("+15551234567", source: :web_form,
7
+ # metadata: {ip: request.remote_ip, form: "checkout", copy: terms_text})
8
+ # Smswire::Consent.status_for("+15551234567") # => "opted_in"
9
+ class Consent < ApplicationRecord
10
+ DEFAULT_SCOPE = "default".freeze
11
+ STATUSES = %w[opted_in opted_out pending unknown].freeze
12
+
13
+ enum :status, STATUSES.index_with(&:itself)
14
+
15
+ class << self
16
+ def status_for(phone, scope: DEFAULT_SCOPE)
17
+ find_by(phone: normalize!(phone), scope: scope.to_s)&.status || "unknown"
18
+ end
19
+
20
+ def opt_in!(phone, source:, scope: DEFAULT_SCOPE, keyword: nil, metadata: {})
21
+ record!(phone, scope, {status: "opted_in", opted_in_at: Time.current}, source:, keyword:, metadata:)
22
+ end
23
+
24
+ def opt_out!(phone, source:, scope: DEFAULT_SCOPE, keyword: nil, metadata: {})
25
+ record!(phone, scope, {status: "opted_out", opted_out_at: Time.current}, source:, keyword:, metadata:)
26
+ end
27
+
28
+ # STOPALL: opt out of every scope this number has, plus every scope
29
+ # the configured senders use.
30
+ def opt_out_everywhere!(phone, source:, keyword: nil)
31
+ number = normalize!(phone)
32
+ scopes = (where(phone: number).pluck(:scope) + Smswire.config.consent_scopes).uniq
33
+ scopes.map { |scope| opt_out!(number, scope:, source:, keyword:) }
34
+ end
35
+
36
+ # The consent scope of the configured sender that owns +number+ or
37
+ # +messaging_service+, used for inbound keywords.
38
+ def scope_for_sender(number: nil, messaging_service: nil)
39
+ Smswire.config.senders.each_value do |options|
40
+ options = options.to_h.symbolize_keys
41
+ matched = (number.present? && options[:number] == number) ||
42
+ (messaging_service.present? && options[:messaging_service] == messaging_service)
43
+ return (options[:consent_scope] || DEFAULT_SCOPE).to_s if matched
44
+ end
45
+ DEFAULT_SCOPE
46
+ end
47
+
48
+ private
49
+
50
+ def normalize!(phone)
51
+ PhoneNumber.normalize(phone) || raise(ArgumentError, "Invalid phone number: #{phone.inspect}")
52
+ end
53
+
54
+ def record!(phone, scope, attributes, source:, keyword:, metadata:)
55
+ retried = false
56
+ begin
57
+ consent = find_or_initialize_by(phone: normalize!(phone), scope: scope.to_s)
58
+ consent.assign_attributes(attributes.merge(source: source.to_s))
59
+ consent.assign_attributes(last_keyword: keyword, last_keyword_at: Time.current) if keyword
60
+ consent.metadata = (consent.metadata || {}).merge(metadata.deep_stringify_keys) if metadata.present?
61
+ consent.save!
62
+ consent
63
+ rescue ActiveRecord::RecordNotUnique
64
+ raise if retried
65
+ retried = true
66
+ retry
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end