smswire 0.0.1.pre
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 +10 -0
- data/Gemfile +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +21 -0
- data/Rakefile +8 -0
- data/docs/SPEC.md +565 -0
- data/lib/smswire/version.rb +3 -0
- data/lib/smswire.rb +11 -0
- data/smswire.gemspec +38 -0
- metadata +63 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 513ee2707d29f757c1f3dd7f43eefda758ff634decbd09bfae3bcde38b29c775
|
|
4
|
+
data.tar.gz: 8edba21706e42e4825524d28f5de622294aeacec784089ddb83b88cecc19276c
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 908a9ff9f247261b23fbecaad6e91e78e12310b0afa0c282a7da55cb581cef0f2779851903ddd6184bb1fa34de917e2dd887646ad5085de8246cc7514daadcbc
|
|
7
|
+
data.tar.gz: 1d5ee0fa8d452ea828004fe551dfb69a1468a5e0dd6e8770793d8a2c94e825948cb684f6b2c5ab6228f5fb0e46a9225fa939ff5a37ac2d46632010a6577d56b3
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
5
|
+
|
|
6
|
+
## [Unreleased]
|
|
7
|
+
|
|
8
|
+
## [0.0.1.pre]
|
|
9
|
+
|
|
10
|
+
- Reserve the gem name. No functionality yet; see `docs/SPEC.md`.
|
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tim Walsh
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Smswire
|
|
2
|
+
|
|
3
|
+
> The production SMS layer for Rails that Noticed and twilio-ruby both leave to you.
|
|
4
|
+
|
|
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.
|
|
10
|
+
|
|
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.
|
|
14
|
+
|
|
15
|
+
**Status:** specification stage. Nothing is published yet.
|
|
16
|
+
|
|
17
|
+
- [Scoped specification](docs/SPEC.md)
|
|
18
|
+
|
|
19
|
+
## License
|
|
20
|
+
|
|
21
|
+
MIT.
|
data/Rakefile
ADDED
data/docs/SPEC.md
ADDED
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
# Smswire: Scoped Specification
|
|
2
|
+
|
|
3
|
+
> The production SMS layer that Noticed and twilio-ruby both leave to you.
|
|
4
|
+
|
|
5
|
+
Status: draft for review. Sequencing is expressed as ordered phases with
|
|
6
|
+
acceptance criteria. No calendar estimates are included by design.
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## 1. Purpose and positioning
|
|
11
|
+
|
|
12
|
+
Rails has no first-party SMS framework. In practice, Rails apps in 2026 send
|
|
13
|
+
SMS one of two ways:
|
|
14
|
+
|
|
15
|
+
1. **Directly through a provider SDK** (twilio-ruby at ~136M downloads), with
|
|
16
|
+
phone normalization, consent, retries, status tracking, and dev safety
|
|
17
|
+
hand-rolled per app.
|
|
18
|
+
2. **Through Noticed** (~7M downloads), where SMS is one of a dozen delivery
|
|
19
|
+
channels and is implemented as a ~45-line HTTP post with a message string.
|
|
20
|
+
|
|
21
|
+
Everything that makes SMS hard in production sits between those two layers and
|
|
22
|
+
is owned by nobody:
|
|
23
|
+
|
|
24
|
+
| Concern | twilio-ruby | Noticed | Smswire |
|
|
25
|
+
|---|---|---|---|
|
|
26
|
+
| Mailer-style classes and templates | no | no (inline lambda / params) | yes |
|
|
27
|
+
| Preview UI with segment and encoding display | no | no | yes |
|
|
28
|
+
| E.164 normalization and validation | no | no | yes |
|
|
29
|
+
| Provider-agnostic message object | no | no (per-provider payloads) | yes |
|
|
30
|
+
| Persisted delivery record with provider ID and status | no | requested since 2021, not built | yes |
|
|
31
|
+
| Status-callback endpoint and signature verification | SDK helpers only | no | yes |
|
|
32
|
+
| Consent store, STOP/HELP/START keyword handling | no | no | yes |
|
|
33
|
+
| Quiet hours and per-recipient rate limits | no | no | yes |
|
|
34
|
+
| Retries with transient vs. permanent error taxonomy | no | unanswered request | yes |
|
|
35
|
+
| Dev/staging interceptor (sandbox, allowlist, local inbox) | no | no | yes |
|
|
36
|
+
| Test helpers and assertions | no | requested, not built | yes |
|
|
37
|
+
| Notification inbox, fan-out, multi-channel | no | **yes** | no (delegates to Noticed) |
|
|
38
|
+
|
|
39
|
+
Smswire is a **complement** to Noticed, not a competitor. It ships a Noticed
|
|
40
|
+
delivery method so Noticed users get all of the above by changing one line.
|
|
41
|
+
It is also useful standalone for apps that do not need an inbox.
|
|
42
|
+
|
|
43
|
+
## 2. Goals
|
|
44
|
+
|
|
45
|
+
- Make sending a compliant, observable SMS from Rails as ergonomic as sending
|
|
46
|
+
an email with Action Mailer.
|
|
47
|
+
- Own the full outbound lifecycle: compose, normalize, authorize (consent),
|
|
48
|
+
deliver, track, retry, report.
|
|
49
|
+
- Own the minimum inbound surface needed for compliance: delivery status
|
|
50
|
+
callbacks and opt-out / help / opt-in keywords.
|
|
51
|
+
- Be provider-agnostic with a small adapter contract and zero provider SDK
|
|
52
|
+
dependencies in the core gem.
|
|
53
|
+
- Be safe by default in non-production environments.
|
|
54
|
+
- Be testable without network access.
|
|
55
|
+
|
|
56
|
+
## 3. Non-goals
|
|
57
|
+
|
|
58
|
+
- A notification inbox, read/unread tracking, or multi-channel fan-out. That
|
|
59
|
+
is Noticed.
|
|
60
|
+
- Email, push, chat, or voice delivery.
|
|
61
|
+
- Two-way conversational messaging, chat UIs, or inbound message routing beyond
|
|
62
|
+
compliance keywords. (The inbound adapter contract will allow an app to build
|
|
63
|
+
this on top, but Smswire does not ship it.)
|
|
64
|
+
- Marketing campaign management, audience segmentation, or scheduling UIs.
|
|
65
|
+
- Carrier registration workflows (10DLC brand/campaign registration, toll-free
|
|
66
|
+
verification). Smswire exposes the fields these require but does not
|
|
67
|
+
automate the registrations.
|
|
68
|
+
- Replacing provider SDKs for non-messaging APIs (voice, verify, lookup).
|
|
69
|
+
|
|
70
|
+
## 4. Prior art
|
|
71
|
+
|
|
72
|
+
| Project | State | What to borrow | What to avoid |
|
|
73
|
+
|---|---|---|---|
|
|
74
|
+
| Action Mailer (Rails) | core | Class-per-concern, `with(params)`, `deliver_now`/`deliver_later`, interceptors and observers, previews, `deliveries` test array | Nothing; this is the ergonomic target |
|
|
75
|
+
| Noticed 3.x | active, 0 open issues | Zero-dependency HTTP client, `error_handler` lambda, Rails credentials defaults, WebMock-based tests | Per-provider payload shapes, always-async-only delivery |
|
|
76
|
+
| Textris | dead since 2019 | Phony-based E.164 handling, ActiveJob delivery, email proxy for inspection | Provider SDK dependencies, unmaintained |
|
|
77
|
+
| action_smser | alive, tiny | Delivery reports table, gateway status-callback endpoints, resend from report | 2012-era config style, DelayedJob coupling |
|
|
78
|
+
| short_message (2026) | alive, single provider | Delivery status callbacks, metadata persistence as a Rails engine | Hard-wired to one provider |
|
|
79
|
+
| sms_safe | dead | The interceptor idea: redirect or block in non-prod | Nothing else to borrow |
|
|
80
|
+
| Twilio Messaging API | n/a | `StatusCallback`, `MessagingServiceSid`, error code taxonomy, signature verification scheme | Nothing |
|
|
81
|
+
|
|
82
|
+
## 5. Naming and distribution
|
|
83
|
+
|
|
84
|
+
### Why not keep `actionmessenger`
|
|
85
|
+
|
|
86
|
+
- `action_messenger` (underscore) is already a published gem by another author
|
|
87
|
+
(Slack-style delivery, 2019, ~16k downloads). The constant `ActionMessenger`
|
|
88
|
+
and `require "action_messenger"` would collide in any app that has both, and
|
|
89
|
+
rubygems search would surface both side by side.
|
|
90
|
+
- Four unrelated GitHub repos already use the name, including one for XMPP.
|
|
91
|
+
- The `Action*` prefix implies Rails core. Third-party use is tolerated but
|
|
92
|
+
invites confusion with Action Mailbox and Action Text.
|
|
93
|
+
- The existing repo has no users, no stars of note, and a dead lockfile. There
|
|
94
|
+
is nothing to preserve beyond the MIT license.
|
|
95
|
+
|
|
96
|
+
### Shortlist (checked on this date against rubygems.org, GitHub, and DNS)
|
|
97
|
+
|
|
98
|
+
| Name | Gem | GitHub org/handle | Repos with exact name | `.dev` | `.io` | `.com` |
|
|
99
|
+
|---|---|---|---|---|---|---|
|
|
100
|
+
| **smswire** | available | available | none | unresolved | unresolved | parked for sale |
|
|
101
|
+
| textable | available | available | none | unresolved | not checked | in use |
|
|
102
|
+
| smsable | available | available | none | unresolved | not checked | not checked |
|
|
103
|
+
| actionmessenger | available (but `action_messenger` taken) | available | 4 | unresolved | not checked | in use |
|
|
104
|
+
| textkit / smskit | available | **taken** | several, active | n/a | n/a | n/a |
|
|
105
|
+
|
|
106
|
+
**Recommendation: `smswire`.** It says exactly what it is, has zero collisions
|
|
107
|
+
on gem, org, or repo, and the `.dev` and `.io` domains do not resolve. Module
|
|
108
|
+
name `Smswire`, Noticed adapter `Noticed::DeliveryMethods::Smswire`.
|
|
109
|
+
|
|
110
|
+
"Unresolved" in DNS is a signal, not proof of availability. Confirm with a
|
|
111
|
+
registrar before buying.
|
|
112
|
+
|
|
113
|
+
### Registration checklist
|
|
114
|
+
|
|
115
|
+
Perform in this order, since the first two are free and instant:
|
|
116
|
+
|
|
117
|
+
1. Create the repo `timimsms/smswire` (done; an org can be created or the repo moved later).
|
|
118
|
+
2. Push a `0.0.1.pre` gem to rubygems.org to reserve the name, with MFA
|
|
119
|
+
enabled on the account and the `allowed_push_host` metadata set.
|
|
120
|
+
3. Register `smswire.dev` (and `smswire.io` if desired).
|
|
121
|
+
4. Archive `timimsms/actionmessenger` with a README pointer to the new project.
|
|
122
|
+
|
|
123
|
+
## 6. Architecture
|
|
124
|
+
|
|
125
|
+
```
|
|
126
|
+
┌──────────────────────────────────────────────────────────────────────┐
|
|
127
|
+
│ App code │
|
|
128
|
+
│ OrderMessenger.with(order:).shipped(user).deliver_later │
|
|
129
|
+
│ Noticed: deliver_by :smswire, messenger: "OrderMessenger", ... │
|
|
130
|
+
└───────────────┬──────────────────────────────────────────────────────┘
|
|
131
|
+
│
|
|
132
|
+
┌───────────────▼──────────────┐ ┌────────────────────────────────────┐
|
|
133
|
+
│ Smswire::Base (Messenger) │ │ Smswire::Preview (dev route) │
|
|
134
|
+
│ - default from:, sender: │ │ - renders body, segments, encoding│
|
|
135
|
+
│ - text(to:, body:|template) │ │ - phone-frame + raw view │
|
|
136
|
+
│ - renders app/views/...erb │ └────────────────────────────────────┘
|
|
137
|
+
└───────────────┬──────────────┘
|
|
138
|
+
│ returns
|
|
139
|
+
┌───────────────▼──────────────────────────────────────────────────────┐
|
|
140
|
+
│ Smswire::Message (value object, serializable) │
|
|
141
|
+
│ to, from, body, media_urls, messenger, action, params, metadata, │
|
|
142
|
+
│ idempotency_key, scheduled_at, segments, encoding │
|
|
143
|
+
│ #deliver_now #deliver_later(wait:, queue:, priority:) │
|
|
144
|
+
└───────────────┬──────────────────────────────────────────────────────┘
|
|
145
|
+
│
|
|
146
|
+
┌───────────────▼──────────────────────────────────────────────────────┐
|
|
147
|
+
│ Delivery pipeline (Smswire::Pipeline) │
|
|
148
|
+
│ 1. normalize recipient -> E.164 (or reject) │
|
|
149
|
+
│ 2. authorize consent check, quiet hours, rate limit, allowlist │
|
|
150
|
+
│ 3. persist Smswire::Delivery row (status: pending) │
|
|
151
|
+
│ 4. intercept interceptors may mutate/cancel (dev sandbox lives │
|
|
152
|
+
│ here) │
|
|
153
|
+
│ 5. send Provider#deliver(message) -> Receipt │
|
|
154
|
+
│ 6. record provider_id, status: accepted|failed, error_code │
|
|
155
|
+
│ 7. observe observers + ActiveSupport::Notifications │
|
|
156
|
+
└───────────────┬──────────────────────────────────────────────────────┘
|
|
157
|
+
│
|
|
158
|
+
┌───────────────▼──────────────┐ ┌────────────────────────────────────┐
|
|
159
|
+
│ Providers (adapters) │ │ Smswire::Engine (mounted routes) │
|
|
160
|
+
│ twilio telnyx vonage │◄──┤ POST /smswire/status/:provider │
|
|
161
|
+
│ test log null │ │ POST /smswire/inbound/:provider │
|
|
162
|
+
│ (later: sinch bandwidth sns)│ │ GET /smswire/previews (dev only) │
|
|
163
|
+
└──────────────────────────────┘ │ GET /smswire/inbox (dev only) │
|
|
164
|
+
└────────────────────────────────────┘
|
|
165
|
+
┌──────────────────────────────────────────────────────────────────────┐
|
|
166
|
+
│ Persistence │
|
|
167
|
+
│ smswire_deliveries smswire_consents smswire_inbound_messages │
|
|
168
|
+
└──────────────────────────────────────────────────────────────────────┘
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
### 6.1 Messenger classes
|
|
172
|
+
|
|
173
|
+
Mirror Action Mailer closely so the mental model transfers:
|
|
174
|
+
|
|
175
|
+
```ruby
|
|
176
|
+
# app/messengers/order_messenger.rb
|
|
177
|
+
class OrderMessenger < Smswire::Base
|
|
178
|
+
default from: :transactional # named sender, see 7.3
|
|
179
|
+
default category: :transactional # drives consent rules
|
|
180
|
+
|
|
181
|
+
def shipped(user)
|
|
182
|
+
@order = params[:order]
|
|
183
|
+
text to: user, body: nil # renders app/views/order_messenger/shipped.text.erb
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def verification_code(phone, code)
|
|
187
|
+
@code = code
|
|
188
|
+
text to: phone, category: :otp, idempotency_key: "otp:#{phone}:#{code}"
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
- `to:` accepts an E.164 string, any object responding to `phone_number`, or
|
|
194
|
+
a `Smswire::Recipient`. Resolution is configurable (see 7.2).
|
|
195
|
+
- `text` returns a `Smswire::Message`. Nothing is sent until `deliver_now` or
|
|
196
|
+
`deliver_later` is called, exactly like `mail`.
|
|
197
|
+
- Templates live at `app/views/<messenger_name>/<action>.text.erb`. I18n via
|
|
198
|
+
`t(".key")` scoped the same way Action Mailer scopes it.
|
|
199
|
+
- Body whitespace is squished by default (configurable) since SMS has no
|
|
200
|
+
layout. Rendering computes `segments` and `encoding` (GSM-7 vs. UCS-2) on the
|
|
201
|
+
message so previews, logs, and callbacks can report them.
|
|
202
|
+
- `default` supports `from`, `category`, `sender`, `provider`,
|
|
203
|
+
`messaging_service`, `validity_period`, `quiet_hours`.
|
|
204
|
+
|
|
205
|
+
### 6.2 Message object
|
|
206
|
+
|
|
207
|
+
`Smswire::Message` is a plain Ruby value object, serializable through
|
|
208
|
+
ActiveJob (GlobalID for the recipient object, primitives for the rest). It
|
|
209
|
+
exposes:
|
|
210
|
+
|
|
211
|
+
- `deliver_now` runs the pipeline inline and returns a `Smswire::Delivery`.
|
|
212
|
+
- `deliver_later(wait:, wait_until:, queue:, priority:)` enqueues
|
|
213
|
+
`Smswire::DeliveryJob`. The job re-runs the pipeline, so consent and quiet
|
|
214
|
+
hours are evaluated at send time, not enqueue time.
|
|
215
|
+
- `segments`, `encoding`, `length` computed at render.
|
|
216
|
+
- `idempotency_key` defaults to a digest of messenger, action, recipient, and
|
|
217
|
+
body. The pipeline refuses a duplicate key inside the configured dedupe
|
|
218
|
+
window.
|
|
219
|
+
|
|
220
|
+
### 6.3 Delivery pipeline
|
|
221
|
+
|
|
222
|
+
Each step is a small object with a single `call(message, context)`. Steps can
|
|
223
|
+
halt with a typed outcome so callers and observers can distinguish:
|
|
224
|
+
|
|
225
|
+
- `:rejected_invalid_number`
|
|
226
|
+
- `:rejected_no_consent`
|
|
227
|
+
- `:rejected_opted_out`
|
|
228
|
+
- `:deferred_quiet_hours` (re-enqueued for the next allowed window)
|
|
229
|
+
- `:deferred_rate_limited`
|
|
230
|
+
- `:suppressed_by_interceptor` (dev sandbox, allowlist)
|
|
231
|
+
- `:duplicate`
|
|
232
|
+
- `:accepted` / `:failed`
|
|
233
|
+
|
|
234
|
+
Interceptors and observers use the Action Mailer contract:
|
|
235
|
+
|
|
236
|
+
```ruby
|
|
237
|
+
Smswire.register_interceptor(MyInterceptor) # #delivering_sms(message) may mutate or cancel
|
|
238
|
+
Smswire.register_observer(MyObserver) # #delivered_sms(delivery)
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
### 6.4 Provider adapter contract
|
|
242
|
+
|
|
243
|
+
```ruby
|
|
244
|
+
module Smswire::Providers
|
|
245
|
+
class Base
|
|
246
|
+
def initialize(config) # from Smswire.config.providers[:name]
|
|
247
|
+
def deliver(message) -> Receipt # Receipt(provider_id:, status:, raw:, segments:, price:)
|
|
248
|
+
def parse_status_callback(request) -> StatusUpdate # StatusUpdate(provider_id:, status:, error_code:, raw:)
|
|
249
|
+
def parse_inbound(request) -> InboundMessage # InboundMessage(from:, to:, body:, provider_id:, raw:)
|
|
250
|
+
def verify_signature!(request) # raises Smswire::SignatureError
|
|
251
|
+
def capabilities -> Set[:mms, :status_callbacks, :inbound, :scheduling, :messaging_services]
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Normalized status vocabulary across providers:
|
|
257
|
+
`queued`, `accepted`, `sent`, `delivered`, `undelivered`, `failed`, `read`
|
|
258
|
+
(where supported). Each adapter maps provider-specific codes to this vocabulary
|
|
259
|
+
and preserves the raw code.
|
|
260
|
+
|
|
261
|
+
Error taxonomy raised by `deliver`:
|
|
262
|
+
|
|
263
|
+
- `Smswire::TransientError` (5xx, timeouts, provider rate limits) → retried
|
|
264
|
+
- `Smswire::PermanentError` (invalid number, blocked, unsubscribed at carrier,
|
|
265
|
+
bad credentials) → not retried, delivery marked failed, optional consent
|
|
266
|
+
update (e.g. carrier opt-out → mark opted out)
|
|
267
|
+
- `Smswire::ThrottledError` (subclass of Transient with `retry_after`)
|
|
268
|
+
|
|
269
|
+
Initial adapters: `twilio`, `telnyx`, `vonage`, plus `test`, `log`, `null`.
|
|
270
|
+
Later: `sinch`, `bandwidth`, `aws_sns`, `plivo`, `messagebird`.
|
|
271
|
+
|
|
272
|
+
All adapters use Net::HTTP (or a tiny shared client as in Noticed). No
|
|
273
|
+
provider SDKs are runtime dependencies. Bodies are never logged.
|
|
274
|
+
|
|
275
|
+
### 6.5 Persistence
|
|
276
|
+
|
|
277
|
+
Three tables, installed by `rails g smswire:install`.
|
|
278
|
+
|
|
279
|
+
**smswire_deliveries**
|
|
280
|
+
|
|
281
|
+
| column | type | notes |
|
|
282
|
+
|---|---|---|
|
|
283
|
+
| id | uuid/bigint | |
|
|
284
|
+
| messenger, action | string | e.g. `OrderMessenger`, `shipped` |
|
|
285
|
+
| to, from | string | E.164 |
|
|
286
|
+
| recipient_type, recipient_id | string, string | polymorphic, optional |
|
|
287
|
+
| category | string | `transactional`, `otp`, `marketing`, custom |
|
|
288
|
+
| body | text | nullable; `store_bodies` config defaults to true, with a per-category override so OTP bodies can be excluded |
|
|
289
|
+
| segments, encoding | integer, string | |
|
|
290
|
+
| provider | string | |
|
|
291
|
+
| provider_id | string | indexed, unique with provider |
|
|
292
|
+
| status | string | normalized vocabulary |
|
|
293
|
+
| error_code, error_message | string, text | provider raw code + normalized message |
|
|
294
|
+
| idempotency_key | string | unique index |
|
|
295
|
+
| price_amount, price_currency | decimal, string | if the provider reports it |
|
|
296
|
+
| metadata | json | app-supplied |
|
|
297
|
+
| scheduled_at, sent_at, delivered_at, failed_at | datetime | |
|
|
298
|
+
| attempts | integer | |
|
|
299
|
+
| created_at, updated_at | datetime | |
|
|
300
|
+
|
|
301
|
+
**smswire_consents**
|
|
302
|
+
|
|
303
|
+
| column | type | notes |
|
|
304
|
+
|---|---|---|
|
|
305
|
+
| phone | string | E.164, unique with `scope` |
|
|
306
|
+
| scope | string | default `"default"`; allows per-category or per-sender consent |
|
|
307
|
+
| status | string | `opted_in`, `opted_out`, `pending`, `unknown` |
|
|
308
|
+
| source | string | `keyword`, `web_form`, `import`, `api`, `carrier` |
|
|
309
|
+
| opted_in_at, opted_out_at | datetime | |
|
|
310
|
+
| last_keyword, last_keyword_at | string, datetime | |
|
|
311
|
+
| metadata | json | consent evidence (IP, form id, copy shown) for audit |
|
|
312
|
+
|
|
313
|
+
**smswire_inbound_messages**
|
|
314
|
+
|
|
315
|
+
| column | type | notes |
|
|
316
|
+
|---|---|---|
|
|
317
|
+
| provider, provider_id | string | unique together |
|
|
318
|
+
| from, to | string | E.164 |
|
|
319
|
+
| body | text | |
|
|
320
|
+
| keyword | string | normalized: `stop`, `help`, `start`, or null |
|
|
321
|
+
| handled_at | datetime | |
|
|
322
|
+
| raw | json | |
|
|
323
|
+
|
|
324
|
+
Models are plain ActiveRecord, namespaced, and extendable via
|
|
325
|
+
`Smswire.config.delivery_class` etc. for apps that need custom columns.
|
|
326
|
+
|
|
327
|
+
### 6.6 Consent and compliance primitives
|
|
328
|
+
|
|
329
|
+
Smswire provides the mechanisms; the app remains responsible for policy and
|
|
330
|
+
legal compliance. Defaults follow CTIA messaging principles so an app that does
|
|
331
|
+
nothing is at least not wrong:
|
|
332
|
+
|
|
333
|
+
- **Keyword handling** on inbound: opt-out set (`STOP`, `STOPALL`,
|
|
334
|
+
`UNSUBSCRIBE`, `CANCEL`, `END`, `QUIT`), help set (`HELP`, `INFO`), opt-in
|
|
335
|
+
set (`START`, `YES`, `UNSTOP`). Case-insensitive, trimmed, configurable.
|
|
336
|
+
- **Auto-replies** for each keyword are templated and configurable; the
|
|
337
|
+
opt-out confirmation is sent even if consent is already `opted_out`.
|
|
338
|
+
- **Consent gating** by category: `otp` and `transactional` default to
|
|
339
|
+
`allow_unless_opted_out`; `marketing` defaults to `require_opted_in`.
|
|
340
|
+
Apps can define categories and rules.
|
|
341
|
+
- **Quiet hours** per category, evaluated in the recipient's time zone when
|
|
342
|
+
resolvable (recipient object responds to `time_zone`), otherwise in a
|
|
343
|
+
configured default. Deferred messages re-enqueue for the next window.
|
|
344
|
+
- **Rate limits** per recipient per category over a sliding window, backed by
|
|
345
|
+
`Rails.cache`.
|
|
346
|
+
- **Carrier opt-out sync**: a permanent error with an unsubscribed/blocked
|
|
347
|
+
code updates the consent row to `opted_out` with `source: carrier`.
|
|
348
|
+
|
|
349
|
+
### 6.7 Development and test safety
|
|
350
|
+
|
|
351
|
+
Environment defaults:
|
|
352
|
+
|
|
353
|
+
| env | provider | interceptor |
|
|
354
|
+
|---|---|---|
|
|
355
|
+
| development | `log` | local inbox UI at `/smswire/inbox` |
|
|
356
|
+
| test | `test` | none; `Smswire::Testing.deliveries` array |
|
|
357
|
+
| staging | real | `Smswire::Interceptors::Allowlist` (configured numbers only) |
|
|
358
|
+
| production | real | none |
|
|
359
|
+
|
|
360
|
+
`Smswire::TestHelper` for Minitest and matchers for RSpec:
|
|
361
|
+
|
|
362
|
+
```ruby
|
|
363
|
+
assert_sms_delivered to: "+15551234567", messenger: OrderMessenger, action: :shipped
|
|
364
|
+
assert_enqueued_sms(OrderMessenger, :shipped) { Order.ship!(order) }
|
|
365
|
+
assert_no_sms_delivered
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
Previews mirror Action Mailer previews, at `test/messengers/previews/` or
|
|
369
|
+
`spec/messengers/previews/`, served at `/smswire/previews` in development.
|
|
370
|
+
Each preview renders the body in a phone frame, shows segment count, encoding,
|
|
371
|
+
character budget remaining, and the resolved sender.
|
|
372
|
+
|
|
373
|
+
### 6.8 Observability
|
|
374
|
+
|
|
375
|
+
`ActiveSupport::Notifications` events, all with the delivery id and never the
|
|
376
|
+
body:
|
|
377
|
+
|
|
378
|
+
- `deliver.smswire` (duration, provider, status, segments)
|
|
379
|
+
- `reject.smswire` (reason)
|
|
380
|
+
- `status_update.smswire`
|
|
381
|
+
- `inbound.smswire` (keyword)
|
|
382
|
+
- `retry.smswire`
|
|
383
|
+
|
|
384
|
+
A `Smswire::LogSubscriber` is attached by default. A Rails health check for
|
|
385
|
+
provider credentials is exposed as `Smswire.verify_credentials!`.
|
|
386
|
+
|
|
387
|
+
### 6.9 Noticed integration
|
|
388
|
+
|
|
389
|
+
Shipped inside the gem, loaded only when `Noticed` is defined:
|
|
390
|
+
|
|
391
|
+
```ruby
|
|
392
|
+
class OrderShippedNotifier < Noticed::Event
|
|
393
|
+
deliver_by :smswire do |config|
|
|
394
|
+
config.messenger = "OrderMessenger"
|
|
395
|
+
config.action = :shipped
|
|
396
|
+
config.params = -> { {order: record} }
|
|
397
|
+
# Optional: config.category, config.wait, config.if
|
|
398
|
+
end
|
|
399
|
+
end
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
The adapter builds the message through the messenger, so templates, consent,
|
|
403
|
+
quiet hours, persistence, and status tracking all apply. The resulting
|
|
404
|
+
`Smswire::Delivery` id is written back to the Noticed notification via
|
|
405
|
+
`error_handler`-style callbacks so the two records can be joined.
|
|
406
|
+
|
|
407
|
+
### 6.10 Generators
|
|
408
|
+
|
|
409
|
+
- `rails g smswire:install` — migrations, initializer with commented config,
|
|
410
|
+
route mount, credentials scaffold hints.
|
|
411
|
+
- `rails g smswire:messenger Order shipped delivered` — class, templates,
|
|
412
|
+
preview, test.
|
|
413
|
+
- `rails g smswire:provider Acme` — adapter skeleton with contract tests.
|
|
414
|
+
|
|
415
|
+
## 7. Configuration surface
|
|
416
|
+
|
|
417
|
+
```ruby
|
|
418
|
+
# config/initializers/smswire.rb
|
|
419
|
+
Smswire.configure do |c|
|
|
420
|
+
c.default_provider = :twilio
|
|
421
|
+
c.providers = {
|
|
422
|
+
twilio: { account_sid: Rails.application.credentials.dig(:twilio, :account_sid),
|
|
423
|
+
auth_token: Rails.application.credentials.dig(:twilio, :auth_token) },
|
|
424
|
+
telnyx: { api_key: ... }
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
c.senders = { # 7.3 named senders
|
|
428
|
+
transactional: { number: "+15550001111", provider: :twilio, messaging_service: "MG..." },
|
|
429
|
+
marketing: { number: "+15550002222", provider: :telnyx }
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
c.recipient_resolver = ->(obj) { obj.respond_to?(:mobile_phone) ? obj.mobile_phone : obj.phone_number }
|
|
433
|
+
c.default_region = "US" # for parsing national-format numbers
|
|
434
|
+
c.phone_validator = :phonelib # or :e164_regex (no extra dependency)
|
|
435
|
+
|
|
436
|
+
c.categories = {
|
|
437
|
+
otp: { consent: :allow_unless_opted_out, quiet_hours: nil, store_body: false },
|
|
438
|
+
transactional: { consent: :allow_unless_opted_out, quiet_hours: nil },
|
|
439
|
+
marketing: { consent: :require_opted_in, quiet_hours: "21:00".."09:00", rate_limit: {max: 3, per: 1.day} }
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
c.dedupe_window = 10.minutes
|
|
443
|
+
c.retry = { attempts: 5, wait: :polynomially_longer }
|
|
444
|
+
c.store_bodies = true
|
|
445
|
+
c.interceptors = [] # per-env additions in config/environments/*.rb
|
|
446
|
+
c.callbacks_host = "https://app.example.com" # used to build StatusCallback URLs
|
|
447
|
+
end
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
### 7.1 Dependency policy
|
|
451
|
+
|
|
452
|
+
Runtime: `activesupport`, `activejob`, `activerecord`, `actionview`,
|
|
453
|
+
`railties` (all `>= 7.1`). Optional: `phonelib` (soft dependency, detected at
|
|
454
|
+
load). No provider SDKs. No HTTP client gem.
|
|
455
|
+
|
|
456
|
+
### 7.2 Recipient resolution
|
|
457
|
+
|
|
458
|
+
Order: `Smswire::Recipient` instance → string → object responding to the
|
|
459
|
+
configured resolver → object responding to `phone_number`. Resolution failures
|
|
460
|
+
raise `Smswire::UnresolvableRecipient` at `text` time, not at delivery time,
|
|
461
|
+
so they surface in tests.
|
|
462
|
+
|
|
463
|
+
### 7.3 Named senders
|
|
464
|
+
|
|
465
|
+
A sender bundles a from-number or messaging service, a provider, and defaults.
|
|
466
|
+
Messengers reference senders by name so number rotation and provider migration
|
|
467
|
+
are a config change, not a code change.
|
|
468
|
+
|
|
469
|
+
## 8. Compatibility targets
|
|
470
|
+
|
|
471
|
+
- Ruby `>= 3.2`
|
|
472
|
+
- Rails `>= 7.1` (matches Noticed 3.x so the integration is always valid)
|
|
473
|
+
- Databases: PostgreSQL, MySQL, SQLite (CI matrix)
|
|
474
|
+
- ActiveJob adapters: any; test against `async`, `solid_queue`, `sidekiq`
|
|
475
|
+
- CI: GitHub Actions, matrix over Ruby × Rails × DB
|
|
476
|
+
|
|
477
|
+
## 9. Sequencing
|
|
478
|
+
|
|
479
|
+
Phases are ordered by dependency. Each has acceptance criteria that gate the
|
|
480
|
+
next. No phase includes a calendar estimate.
|
|
481
|
+
|
|
482
|
+
### Phase 0: Reservation and skeleton
|
|
483
|
+
|
|
484
|
+
- Register org, repo, gem name (pre-release push), domain.
|
|
485
|
+
- Gem skeleton with Zeitwerk, engine, dummy app, CI matrix, RuboCop,
|
|
486
|
+
Standard or similar, `CHANGELOG.md`, MIT license.
|
|
487
|
+
- Accept: `bundle exec rake` green on all matrix cells; `0.0.1.pre` on
|
|
488
|
+
rubygems.org.
|
|
489
|
+
|
|
490
|
+
### Phase 1: Core messenger and delivery
|
|
491
|
+
|
|
492
|
+
- `Smswire::Base`, `Message`, template rendering, segment/encoding calc.
|
|
493
|
+
- Pipeline with normalize, intercept, send, observe (no persistence yet).
|
|
494
|
+
- Providers: `test`, `log`, `null`, `twilio`.
|
|
495
|
+
- `deliver_now`, `deliver_later`, `DeliveryJob` with retry taxonomy.
|
|
496
|
+
- `TestHelper`, RSpec matchers, interceptors and observers.
|
|
497
|
+
- Accept: a dummy-app messenger renders a template, sends through Twilio
|
|
498
|
+
against WebMock, and the full test-helper surface passes.
|
|
499
|
+
|
|
500
|
+
### Phase 2: Persistence and status tracking
|
|
501
|
+
|
|
502
|
+
- Migrations and models for deliveries.
|
|
503
|
+
- Engine route for status callbacks with signature verification (Twilio).
|
|
504
|
+
- Idempotency key and dedupe window.
|
|
505
|
+
- `ActiveSupport::Notifications` + log subscriber.
|
|
506
|
+
- Accept: a delivery row moves `pending → accepted → delivered` from a
|
|
507
|
+
replayed Twilio callback fixture; duplicate sends inside the window are
|
|
508
|
+
refused.
|
|
509
|
+
|
|
510
|
+
### Phase 3: Consent and inbound keywords
|
|
511
|
+
|
|
512
|
+
- Consents and inbound tables, inbound route, keyword parser, auto-replies.
|
|
513
|
+
- Category rules, quiet hours with deferral, per-recipient rate limits.
|
|
514
|
+
- Carrier opt-out sync from permanent errors.
|
|
515
|
+
- Accept: `STOP` from a fixture request flips consent and triggers a
|
|
516
|
+
confirmation; a `marketing` send to a non-opted-in number is rejected with
|
|
517
|
+
`:rejected_no_consent`; a quiet-hours send is re-enqueued for the next
|
|
518
|
+
window.
|
|
519
|
+
|
|
520
|
+
### Phase 4: Developer experience
|
|
521
|
+
|
|
522
|
+
- Previews route and UI, local inbox UI, generators, allowlist interceptor.
|
|
523
|
+
- Accept: `rails g smswire:messenger` output renders in previews with correct
|
|
524
|
+
segment and encoding display; development deliveries appear in the inbox.
|
|
525
|
+
|
|
526
|
+
### Phase 5: Breadth
|
|
527
|
+
|
|
528
|
+
- Providers: `telnyx`, `vonage`; contract test suite that every adapter must
|
|
529
|
+
pass.
|
|
530
|
+
- `Noticed::DeliveryMethods::Smswire`.
|
|
531
|
+
- Accept: the same dummy-app messenger passes the contract suite against all
|
|
532
|
+
three real adapters (WebMock) and delivers through a Noticed notifier.
|
|
533
|
+
|
|
534
|
+
### Phase 6: Release
|
|
535
|
+
|
|
536
|
+
- Guides: getting started, migrating from direct twilio-ruby, migrating from
|
|
537
|
+
Noticed `twilio_messaging`, compliance primer, provider authoring.
|
|
538
|
+
- `1.0.0` once the adapter contract and schema are considered stable.
|
|
539
|
+
|
|
540
|
+
## 10. Risks and mitigations
|
|
541
|
+
|
|
542
|
+
| Risk | Mitigation |
|
|
543
|
+
|---|---|
|
|
544
|
+
| SMS volume for app notifications keeps shifting to push and WhatsApp | Adapter contract is channel-agnostic enough to add WhatsApp via Twilio/Meta later; Noticed integration keeps Smswire relevant as one channel among many |
|
|
545
|
+
| Compliance defaults implying legal guarantees | Documentation states primitives-not-policy clearly; defaults are conservative; consent evidence is stored for audit |
|
|
546
|
+
| Single maintainer, like Noticed | Small core, adapter contract with conformance tests so community adapters do not need core changes |
|
|
547
|
+
| Schema churn before 1.0 | Pre-1.0 migrations are versioned and documented; `store_bodies` and custom-column hooks reduce pressure to fork models |
|
|
548
|
+
| `phonelib` data size | Soft dependency with a regex fallback; documented trade-off |
|
|
549
|
+
|
|
550
|
+
## 11. Open decisions
|
|
551
|
+
|
|
552
|
+
These do not block Phase 0 or 1 but should be settled before Phase 2:
|
|
553
|
+
|
|
554
|
+
1. **Name.** `smswire` is recommended; `textable` and `smsable` are the
|
|
555
|
+
fallbacks. Decide before the pre-release push.
|
|
556
|
+
2. **Body storage default.** Spec says `true` with OTP excluded. Privacy-first
|
|
557
|
+
alternative is `false` by default.
|
|
558
|
+
3. **UUID vs. bigint** primary keys for the engine tables. Spec leans UUID.
|
|
559
|
+
4. **Phone validation**: `phonelib` soft dependency vs. a vendored minimal
|
|
560
|
+
E.164 validator.
|
|
561
|
+
5. **Whether `deliver_now` should be allowed in production** or warn, given
|
|
562
|
+
Noticed's stance that inline delivery slows requests. Spec allows it; the
|
|
563
|
+
Noticed adapter always uses `deliver_later`.
|
|
564
|
+
6. **MMS in 1.0.** `media_urls` is on the message object; whether adapters
|
|
565
|
+
must support it for 1.0 is open.
|
data/lib/smswire.rb
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
require_relative "smswire/version"
|
|
2
|
+
|
|
3
|
+
# Smswire is the production SMS layer for Rails: messenger classes and
|
|
4
|
+
# templates, a provider-agnostic message object, persisted deliveries with
|
|
5
|
+
# status callbacks, consent and keyword handling, retries, development
|
|
6
|
+
# sandboxing, previews, and test helpers.
|
|
7
|
+
#
|
|
8
|
+
# This pre-release reserves the gem name. See docs/SPEC.md for the design.
|
|
9
|
+
module Smswire
|
|
10
|
+
class Error < StandardError; end
|
|
11
|
+
end
|
data/smswire.gemspec
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
require_relative "lib/smswire/version"
|
|
2
|
+
|
|
3
|
+
Gem::Specification.new do |spec|
|
|
4
|
+
spec.name = "smswire"
|
|
5
|
+
spec.version = Smswire::VERSION
|
|
6
|
+
spec.authors = ["Tim Walsh"]
|
|
7
|
+
spec.email = ["tim@mims.ms"]
|
|
8
|
+
|
|
9
|
+
spec.summary = "The production SMS layer for Rails."
|
|
10
|
+
spec.description = <<~DESC.strip
|
|
11
|
+
Smswire gives Rails applications Action Mailer-style messenger classes and
|
|
12
|
+
templates for SMS, a provider-agnostic message object, persisted deliveries
|
|
13
|
+
with provider status callbacks, consent and keyword handling, quiet hours,
|
|
14
|
+
retries, development sandboxing, previews, and test helpers. It complements
|
|
15
|
+
Noticed with a delivery adapter and works standalone.
|
|
16
|
+
DESC
|
|
17
|
+
spec.homepage = "https://smswire.dev"
|
|
18
|
+
spec.license = "MIT"
|
|
19
|
+
spec.required_ruby_version = ">= 3.2"
|
|
20
|
+
|
|
21
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
|
22
|
+
spec.metadata["source_code_uri"] = "https://github.com/timimsms/smswire"
|
|
23
|
+
spec.metadata["changelog_uri"] = "https://github.com/timimsms/smswire/blob/main/CHANGELOG.md"
|
|
24
|
+
spec.metadata["bug_tracker_uri"] = "https://github.com/timimsms/smswire/issues"
|
|
25
|
+
spec.metadata["documentation_uri"] = "https://github.com/timimsms/smswire/blob/main/docs/SPEC.md"
|
|
26
|
+
spec.metadata["rubygems_mfa_required"] = "true"
|
|
27
|
+
|
|
28
|
+
spec.files = Dir.chdir(__dir__) do
|
|
29
|
+
`git ls-files -z`.split("\x0").reject do |f|
|
|
30
|
+
f.start_with?("test/", "bin/", ".github/", ".gitignore")
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
spec.require_paths = ["lib"]
|
|
34
|
+
|
|
35
|
+
# Runtime dependencies arrive with the first functional release (Phase 1
|
|
36
|
+
# of docs/SPEC.md): activesupport, activejob, activerecord, actionview,
|
|
37
|
+
# railties, all >= 7.1. This pre-release only reserves the name.
|
|
38
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: smswire
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.0.1.pre
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Tim Walsh
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-10-08 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description: |-
|
|
14
|
+
Smswire gives Rails applications Action Mailer-style messenger classes and
|
|
15
|
+
templates for SMS, a provider-agnostic message object, persisted deliveries
|
|
16
|
+
with provider status callbacks, consent and keyword handling, quiet hours,
|
|
17
|
+
retries, development sandboxing, previews, and test helpers. It complements
|
|
18
|
+
Noticed with a delivery adapter and works standalone.
|
|
19
|
+
email:
|
|
20
|
+
- tim@mims.ms
|
|
21
|
+
executables: []
|
|
22
|
+
extensions: []
|
|
23
|
+
extra_rdoc_files: []
|
|
24
|
+
files:
|
|
25
|
+
- CHANGELOG.md
|
|
26
|
+
- Gemfile
|
|
27
|
+
- LICENSE.txt
|
|
28
|
+
- README.md
|
|
29
|
+
- Rakefile
|
|
30
|
+
- docs/SPEC.md
|
|
31
|
+
- lib/smswire.rb
|
|
32
|
+
- lib/smswire/version.rb
|
|
33
|
+
- smswire.gemspec
|
|
34
|
+
homepage: https://smswire.dev
|
|
35
|
+
licenses:
|
|
36
|
+
- MIT
|
|
37
|
+
metadata:
|
|
38
|
+
homepage_uri: https://smswire.dev
|
|
39
|
+
source_code_uri: https://github.com/timimsms/smswire
|
|
40
|
+
changelog_uri: https://github.com/timimsms/smswire/blob/main/CHANGELOG.md
|
|
41
|
+
bug_tracker_uri: https://github.com/timimsms/smswire/issues
|
|
42
|
+
documentation_uri: https://github.com/timimsms/smswire/blob/main/docs/SPEC.md
|
|
43
|
+
rubygems_mfa_required: 'true'
|
|
44
|
+
post_install_message:
|
|
45
|
+
rdoc_options: []
|
|
46
|
+
require_paths:
|
|
47
|
+
- lib
|
|
48
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
49
|
+
requirements:
|
|
50
|
+
- - ">="
|
|
51
|
+
- !ruby/object:Gem::Version
|
|
52
|
+
version: '3.2'
|
|
53
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
54
|
+
requirements:
|
|
55
|
+
- - ">="
|
|
56
|
+
- !ruby/object:Gem::Version
|
|
57
|
+
version: '0'
|
|
58
|
+
requirements: []
|
|
59
|
+
rubygems_version: 3.5.22
|
|
60
|
+
signing_key:
|
|
61
|
+
specification_version: 4
|
|
62
|
+
summary: The production SMS layer for Rails.
|
|
63
|
+
test_files: []
|