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 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
@@ -0,0 +1,7 @@
1
+ source "https://rubygems.org"
2
+
3
+ gemspec
4
+
5
+ gem "rake", "~> 13.0"
6
+ gem "minitest", "~> 5.25"
7
+ gem "standard", "~> 1.0"
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
@@ -0,0 +1,8 @@
1
+ require "bundler/gem_tasks"
2
+ require "minitest/test_task"
3
+
4
+ Minitest::TestTask.create
5
+
6
+ require "standard/rake"
7
+
8
+ task default: %i[test standard]
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.
@@ -0,0 +1,3 @@
1
+ module Smswire
2
+ VERSION = "0.0.1.pre"
3
+ end
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: []