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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +67 -3
- data/README.md +222 -9
- data/RELEASING.md +49 -0
- data/app/controllers/smswire/development_controller.rb +17 -0
- data/app/controllers/smswire/inbound_messages_controller.rb +9 -0
- data/app/controllers/smswire/inbox_controller.rb +31 -0
- data/app/controllers/smswire/previews_controller.rb +22 -0
- data/app/controllers/smswire/status_callbacks_controller.rb +8 -0
- data/app/controllers/smswire/webhooks_controller.rb +49 -0
- data/app/models/smswire/application_record.rb +5 -0
- data/app/models/smswire/consent.rb +71 -0
- data/app/models/smswire/delivery.rb +157 -0
- data/app/models/smswire/inbound_message.rb +25 -0
- data/app/views/layouts/smswire/application.html.erb +60 -0
- data/app/views/smswire/inbox/index.html.erb +56 -0
- data/app/views/smswire/previews/index.html.erb +21 -0
- data/app/views/smswire/previews/show.html.erb +34 -0
- data/config/routes.rb +10 -0
- data/db/migrate/20261008000000_create_smswire_deliveries.rb +47 -0
- data/db/migrate/20261008000001_create_smswire_consents_and_inbound_messages.rb +34 -0
- data/docs/SPEC.md +164 -43
- data/docs/guides/compliance.md +124 -0
- data/docs/guides/getting-started.md +154 -0
- data/docs/guides/migrating-from-noticed-twilio.md +105 -0
- data/docs/guides/migrating-from-twilio-ruby.md +146 -0
- data/docs/guides/writing-a-provider.md +126 -0
- data/lib/generators/smswire/install/install_generator.rb +44 -0
- data/lib/generators/smswire/install/templates/application_messenger.rb.tt +3 -0
- data/lib/generators/smswire/install/templates/initializer.rb.tt +35 -0
- data/lib/generators/smswire/messenger/messenger_generator.rb +51 -0
- data/lib/generators/smswire/messenger/templates/messenger.rb.tt +14 -0
- data/lib/generators/smswire/messenger/templates/preview.rb.tt +13 -0
- data/lib/generators/smswire/messenger/templates/test.rb.tt +18 -0
- data/lib/generators/smswire/messenger/templates/view.text.erb.tt +1 -0
- data/lib/generators/smswire/provider/provider_generator.rb +36 -0
- data/lib/generators/smswire/provider/templates/provider.rb.tt +52 -0
- data/lib/generators/smswire/provider/templates/test.rb.tt +29 -0
- data/lib/smswire/base.rb +123 -0
- data/lib/smswire/body_formatter.rb +14 -0
- data/lib/smswire/callbacks.rb +24 -0
- data/lib/smswire/configuration.rb +224 -0
- data/lib/smswire/delivery_job.rb +29 -0
- data/lib/smswire/engine.rb +52 -0
- data/lib/smswire/errors.rb +59 -0
- data/lib/smswire/http.rb +65 -0
- data/lib/smswire/inbound.rb +11 -0
- data/lib/smswire/inbound_handler.rb +29 -0
- data/lib/smswire/interceptors/allowlist.rb +36 -0
- data/lib/smswire/keyword_messenger.rb +12 -0
- data/lib/smswire/keywords.rb +51 -0
- data/lib/smswire/log_subscriber.rb +57 -0
- data/lib/smswire/message.rb +72 -0
- data/lib/smswire/message_delivery.rb +69 -0
- data/lib/smswire/noticed_delivery_method.rb +54 -0
- data/lib/smswire/parameterized_messenger.rb +24 -0
- data/lib/smswire/phone_number.rb +65 -0
- data/lib/smswire/pipeline.rb +114 -0
- data/lib/smswire/preview.rb +54 -0
- data/lib/smswire/provider_contract.rb +158 -0
- data/lib/smswire/providers/base.rb +66 -0
- data/lib/smswire/providers/log.rb +23 -0
- data/lib/smswire/providers/null.rb +12 -0
- data/lib/smswire/providers/telnyx.rb +178 -0
- data/lib/smswire/providers/test.rb +59 -0
- data/lib/smswire/providers/twilio.rb +167 -0
- data/lib/smswire/providers/vonage.rb +193 -0
- data/lib/smswire/providers.rb +38 -0
- data/lib/smswire/quiet_hours.rb +45 -0
- data/lib/smswire/rate_limit.rb +27 -0
- data/lib/smswire/receipt.rb +11 -0
- data/lib/smswire/recipient.rb +38 -0
- data/lib/smswire/result.rb +40 -0
- data/lib/smswire/rspec.rb +133 -0
- data/lib/smswire/segments.rb +81 -0
- data/lib/smswire/sender.rb +27 -0
- data/lib/smswire/status_handler.rb +25 -0
- data/lib/smswire/status_update.rb +8 -0
- data/lib/smswire/test_helper.rb +81 -0
- data/lib/smswire/testing.rb +72 -0
- data/lib/smswire/version.rb +1 -1
- data/lib/smswire.rb +70 -6
- data/smswire.gemspec +9 -5
- metadata +195 -10
- data/Gemfile +0 -7
- data/Rakefile +0 -8
data/docs/SPEC.md
CHANGED
|
@@ -196,11 +196,15 @@ end
|
|
|
196
196
|
`deliver_later` is called, exactly like `mail`.
|
|
197
197
|
- Templates live at `app/views/<messenger_name>/<action>.text.erb`. I18n via
|
|
198
198
|
`t(".key")` scoped the same way Action Mailer scopes it.
|
|
199
|
-
- Body whitespace is
|
|
200
|
-
|
|
199
|
+
- Body whitespace is cleaned with `body_whitespace = :strip` by default:
|
|
200
|
+
trailing spaces on each line are removed, three or more newlines collapse to
|
|
201
|
+
two, and the ends are stripped. `:squish` and `:preserve` are the
|
|
202
|
+
alternatives. Layouts are supported, which suits a shared opt-out footer.
|
|
203
|
+
Rendering computes `segments` and `encoding` (GSM-7 vs. UCS-2) on the
|
|
201
204
|
message so previews, logs, and callbacks can report them.
|
|
202
|
-
- `default` supports `from`, `category`, `
|
|
203
|
-
`
|
|
205
|
+
- `default` supports `from`, `category`, `provider`, `messaging_service`,
|
|
206
|
+
`metadata`, `validity_period`. A named sender is given as `from: :name`.
|
|
207
|
+
Quiet hours and rate limits are set per category, not per messenger.
|
|
204
208
|
|
|
205
209
|
### 6.2 Message object
|
|
206
210
|
|
|
@@ -208,9 +212,13 @@ end
|
|
|
208
212
|
ActiveJob (GlobalID for the recipient object, primitives for the rest). It
|
|
209
213
|
exposes:
|
|
210
214
|
|
|
211
|
-
- `deliver_now` runs the pipeline inline and returns a `Smswire::
|
|
215
|
+
- `deliver_now` runs the pipeline inline and returns a `Smswire::Result`
|
|
216
|
+
(status, message, receipt, error). From Phase 2 the result also carries the
|
|
217
|
+
persisted `Smswire::Delivery`.
|
|
212
218
|
- `deliver_later(wait:, wait_until:, queue:, priority:)` enqueues
|
|
213
|
-
`Smswire::DeliveryJob
|
|
219
|
+
`Smswire::DeliveryJob` with the messenger name, action, params, and
|
|
220
|
+
arguments, like Action Mailer. Arguments must be ActiveJob-serializable.
|
|
221
|
+
The job re-runs the action and the pipeline, so consent and quiet
|
|
214
222
|
hours are evaluated at send time, not enqueue time.
|
|
215
223
|
- `segments`, `encoding`, `length` computed at render.
|
|
216
224
|
- `idempotency_key` defaults to a digest of messenger, action, recipient, and
|
|
@@ -228,6 +236,8 @@ halt with a typed outcome so callers and observers can distinguish:
|
|
|
228
236
|
- `:deferred_quiet_hours` (re-enqueued for the next allowed window)
|
|
229
237
|
- `:deferred_rate_limited`
|
|
230
238
|
- `:suppressed_by_interceptor` (dev sandbox, allowlist)
|
|
239
|
+
- `:rejected_empty_body` (no body and no media)
|
|
240
|
+
- `:skipped` (the action returned without calling `text`)
|
|
231
241
|
- `:duplicate`
|
|
232
242
|
- `:accepted` / `:failed`
|
|
233
243
|
|
|
@@ -272,6 +282,33 @@ Later: `sinch`, `bandwidth`, `aws_sns`, `plivo`, `messagebird`.
|
|
|
272
282
|
All adapters use Net::HTTP (or a tiny shared client as in Noticed). No
|
|
273
283
|
provider SDKs are runtime dependencies. Bodies are never logged.
|
|
274
284
|
|
|
285
|
+
Adapter notes:
|
|
286
|
+
|
|
287
|
+
- **Twilio** signs webhooks with HMAC-SHA1 over the URL and sorted POST
|
|
288
|
+
parameters. Messaging services map to `MessagingServiceSid`.
|
|
289
|
+
- **Telnyx** signs webhooks with Ed25519 over `<timestamp>|<raw body>`,
|
|
290
|
+
verified against the account public key, with a five-minute timestamp
|
|
291
|
+
tolerance. Messaging services map to `messaging_profile_id`. A messaging
|
|
292
|
+
profile can post every event to one URL, so both webhook endpoints accept
|
|
293
|
+
both event kinds. Telnyx takes no validity period here, so it is ignored.
|
|
294
|
+
- **Vonage** (SMS API) signs webhooks with sorted `&key=value` pairs using the
|
|
295
|
+
configured `signature_secret` and `signature_method` (`md5hash`, Vonage's
|
|
296
|
+
default, or an HMAC). Its send error 7, "number barred," also covers blocks
|
|
297
|
+
other than opt-outs, so it maps to `:unroutable` and never records an
|
|
298
|
+
opt-out. Long messages are billed as several parts with separate ids; the
|
|
299
|
+
receipt keeps the first, so receipts for later parts are acknowledged but
|
|
300
|
+
not matched. The API does not name a price currency.
|
|
301
|
+
|
|
302
|
+
**Contract suite.** `Smswire::ProviderContract` is a Minitest module shipped
|
|
303
|
+
in the gem. An adapter's test includes it and defines hooks that stub the
|
|
304
|
+
provider for each outcome (`success`, `invalid_number`, `opted_out`,
|
|
305
|
+
`authentication`, `throttled`, `server_error`, `timeout`) and build signed
|
|
306
|
+
status and inbound requests. The suite checks receipts, the normalized status
|
|
307
|
+
vocabulary, the error taxonomy and reasons, a full pipeline send, and, for
|
|
308
|
+
declared capabilities, callback parsing and signature rejection. An adapter
|
|
309
|
+
whose provider cannot report opt-outs at send time says so with
|
|
310
|
+
`contract_reports_opt_outs?` rather than guessing.
|
|
311
|
+
|
|
275
312
|
### 6.5 Persistence
|
|
276
313
|
|
|
277
314
|
Three tables, installed by `rails g smswire:install`.
|
|
@@ -280,22 +317,25 @@ Three tables, installed by `rails g smswire:install`.
|
|
|
280
317
|
|
|
281
318
|
| column | type | notes |
|
|
282
319
|
|---|---|---|
|
|
283
|
-
| id |
|
|
320
|
+
| id | app default | follows the app's generator `primary_key_type`, like Active Storage |
|
|
284
321
|
| messenger, action | string | e.g. `OrderMessenger`, `shipped` |
|
|
285
|
-
|
|
|
286
|
-
|
|
|
322
|
+
| to_number, from_number | string | E.164; named to avoid the reserved words `to` and `from` |
|
|
323
|
+
| messaging_service | string | |
|
|
324
|
+
| recipient_type, recipient_id | string, app key type | polymorphic, optional; set only for persisted Active Record recipients |
|
|
287
325
|
| category | string | `transactional`, `otp`, `marketing`, custom |
|
|
288
326
|
| body | text | nullable; `store_bodies` config defaults to true, with a per-category override so OTP bodies can be excluded |
|
|
289
327
|
| segments, encoding | integer, string | |
|
|
290
328
|
| provider | string | |
|
|
291
329
|
| provider_id | string | indexed, unique with provider |
|
|
292
|
-
| status | string | normalized vocabulary |
|
|
330
|
+
| status | string | `pending`, `suppressed`, then the normalized vocabulary; only moves forward |
|
|
293
331
|
| error_code, error_message | string, text | provider raw code + normalized message |
|
|
294
|
-
| idempotency_key | string | unique index |
|
|
332
|
+
| idempotency_key | string | unique index; cleared on the old row when the dedupe window expires |
|
|
295
333
|
| price_amount, price_currency | decimal, string | if the provider reports it |
|
|
296
334
|
| metadata | json | app-supplied |
|
|
297
335
|
| scheduled_at, sent_at, delivered_at, failed_at | datetime | |
|
|
298
336
|
| attempts | integer | |
|
|
337
|
+
| claimed_at | datetime | send claim; released on transient errors, expires after two minutes |
|
|
338
|
+
| status_updated_at | datetime | |
|
|
299
339
|
| created_at, updated_at | datetime | |
|
|
300
340
|
|
|
301
341
|
**smswire_consents**
|
|
@@ -331,18 +371,30 @@ legal compliance. Defaults follow CTIA messaging principles so an app that does
|
|
|
331
371
|
nothing is at least not wrong:
|
|
332
372
|
|
|
333
373
|
- **Keyword handling** on inbound: opt-out set (`STOP`, `STOPALL`,
|
|
334
|
-
`UNSUBSCRIBE`, `CANCEL`, `END`, `QUIT
|
|
335
|
-
set (`START`, `YES`, `UNSTOP`).
|
|
374
|
+
`UNSUBSCRIBE`, `CANCEL`, `END`, `QUIT`, `OPTOUT`, `REVOKE`), help set
|
|
375
|
+
(`HELP`, `INFO`), opt-in set (`START`, `YES`, `UNSTOP`). A keyword matches
|
|
376
|
+
only as the whole message, ignoring case, whitespace, and surrounding
|
|
377
|
+
punctuation. Configurable. `STOPALL` opts out of every consent scope.
|
|
378
|
+
- **Consent scopes**: consent is stored per phone number and scope. A
|
|
379
|
+
sender's `consent_scope` (default `"default"`) decides the scope for both
|
|
380
|
+
outbound checks and inbound keywords to that sender's number.
|
|
336
381
|
- **Auto-replies** for each keyword are templated and configurable; the
|
|
337
|
-
opt-out confirmation is sent even if consent is already `opted_out
|
|
382
|
+
opt-out confirmation is sent even if consent is already `opted_out`,
|
|
383
|
+
through the built-in `compliance` category, which skips consent checks.
|
|
384
|
+
When the provider already replied, as Twilio Advanced Opt-Out signals with
|
|
385
|
+
`OptOutType`, consent is recorded and no second reply is sent.
|
|
338
386
|
- **Consent gating** by category: `otp` and `transactional` default to
|
|
339
387
|
`allow_unless_opted_out`; `marketing` defaults to `require_opted_in`.
|
|
340
388
|
Apps can define categories and rules.
|
|
341
389
|
- **Quiet hours** per category, evaluated in the recipient's time zone when
|
|
342
390
|
resolvable (recipient object responds to `time_zone`), otherwise in a
|
|
343
|
-
configured default. Deferred messages re-enqueue for the next window
|
|
344
|
-
|
|
345
|
-
|
|
391
|
+
configured default. Deferred messages re-enqueue for the next window,
|
|
392
|
+
from both `deliver_now` and `deliver_later`. The marketing default is the
|
|
393
|
+
US federal TCPA window, 9pm to 8am; some states are stricter.
|
|
394
|
+
- **Rate limits** per recipient per category over a sliding window, counted
|
|
395
|
+
from `smswire_deliveries` so they survive restarts and are shared across
|
|
396
|
+
processes. Failed and suppressed deliveries do not count. Concurrent sends
|
|
397
|
+
can exceed the limit briefly.
|
|
346
398
|
- **Carrier opt-out sync**: a permanent error with an unsubscribed/blocked
|
|
347
399
|
code updates the consent row to `opted_out` with `source: carrier`.
|
|
348
400
|
|
|
@@ -368,7 +420,23 @@ assert_no_sms_delivered
|
|
|
368
420
|
Previews mirror Action Mailer previews, at `test/messengers/previews/` or
|
|
369
421
|
`spec/messengers/previews/`, served at `/smswire/previews` in development.
|
|
370
422
|
Each preview renders the body in a phone frame, shows segment count, encoding,
|
|
371
|
-
character budget remaining, and the resolved sender
|
|
423
|
+
character budget remaining, and the resolved sender, and names any characters
|
|
424
|
+
that force UCS-2. Previews build the message without running interceptors or
|
|
425
|
+
sending.
|
|
426
|
+
|
|
427
|
+
The development inbox at `/smswire/inbox` lists recent deliveries and inbound
|
|
428
|
+
messages, and has a form that simulates a reply such as STOP through the same
|
|
429
|
+
handling as the inbound webhook.
|
|
430
|
+
|
|
431
|
+
Both tools are enabled with `config.smswire.show_previews` and
|
|
432
|
+
`config.smswire.show_inbox` in `config/application.rb` or an environment
|
|
433
|
+
file, and default to true in development only. Preview paths are registered
|
|
434
|
+
for autoloading before initializers run, as Action Mailer does, so they cannot
|
|
435
|
+
be set from `config/initializers`.
|
|
436
|
+
|
|
437
|
+
The allowlist interceptor either suppresses messages to unlisted numbers or,
|
|
438
|
+
with `redirect_to:`, reroutes them to one number with the original recipient
|
|
439
|
+
prefixed to the body.
|
|
372
440
|
|
|
373
441
|
### 6.8 Observability
|
|
374
442
|
|
|
@@ -394,15 +462,20 @@ class OrderShippedNotifier < Noticed::Event
|
|
|
394
462
|
config.messenger = "OrderMessenger"
|
|
395
463
|
config.action = :shipped
|
|
396
464
|
config.params = -> { {order: record} }
|
|
397
|
-
# Optional: config.
|
|
465
|
+
# Optional: config.args, config.kwargs, config.params, config.sms_queue;
|
|
466
|
+
# Noticed options such as config.wait and config.if apply to its own job
|
|
398
467
|
end
|
|
399
468
|
end
|
|
400
469
|
```
|
|
401
470
|
|
|
402
471
|
The adapter builds the message through the messenger, so templates, consent,
|
|
403
|
-
quiet hours, persistence, and status tracking all apply.
|
|
404
|
-
`Smswire
|
|
405
|
-
|
|
472
|
+
quiet hours, persistence, and status tracking all apply. It always uses
|
|
473
|
+
`deliver_later`, so Smswire's retry policy applies even though Noticed's own
|
|
474
|
+
delivery jobs do not retry. The messenger receives the event params plus
|
|
475
|
+
`notification`, `record`, and `recipient`, and the default action argument is
|
|
476
|
+
the recipient. Each `Smswire::Delivery` records `noticed_notification_id` and
|
|
477
|
+
`noticed_event_id` in its metadata, which joins the two records without a
|
|
478
|
+
schema dependency between the gems.
|
|
406
479
|
|
|
407
480
|
### 6.10 Generators
|
|
408
481
|
|
|
@@ -410,7 +483,10 @@ quiet hours, persistence, and status tracking all apply. The resulting
|
|
|
410
483
|
route mount, credentials scaffold hints.
|
|
411
484
|
- `rails g smswire:messenger Order shipped delivered` — class, templates,
|
|
412
485
|
preview, test.
|
|
413
|
-
- `rails g smswire:provider Acme` — adapter skeleton
|
|
486
|
+
- `rails g smswire:provider Acme` — adapter skeleton in `app/sms_providers`,
|
|
487
|
+
a test that runs `Smswire::ProviderContract` against it with WebMock stubs,
|
|
488
|
+
and a registration line in the initializer. The generated pair passes the
|
|
489
|
+
contract as generated.
|
|
414
490
|
|
|
415
491
|
## 7. Configuration surface
|
|
416
492
|
|
|
@@ -431,26 +507,36 @@ Smswire.configure do |c|
|
|
|
431
507
|
|
|
432
508
|
c.recipient_resolver = ->(obj) { obj.respond_to?(:mobile_phone) ? obj.mobile_phone : obj.phone_number }
|
|
433
509
|
c.default_region = "US" # for parsing national-format numbers
|
|
434
|
-
c.phone_validator = :
|
|
510
|
+
c.phone_validator = :auto # :phonelib, or :e164 (built-in, no extra dependency)
|
|
435
511
|
|
|
512
|
+
# Merged per key over the shipped defaults:
|
|
513
|
+
# otp: { consent: :allow_unless_opted_out, store_body: false }
|
|
514
|
+
# transactional: { consent: :allow_unless_opted_out }
|
|
515
|
+
# marketing: { consent: :require_opted_in, quiet_hours: "21:00".."08:00" }
|
|
516
|
+
# compliance: { consent: :none } # keyword auto-replies
|
|
436
517
|
c.categories = {
|
|
437
|
-
|
|
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} }
|
|
518
|
+
marketing: { rate_limit: {max: 3, per: 1.day} } # exceed: :defer (default) or :reject
|
|
440
519
|
}
|
|
520
|
+
c.default_time_zone = "America/New_York" # quiet hours when the recipient has no time_zone
|
|
521
|
+
c.program_name = "Acme Alerts" # used in keyword auto-replies
|
|
441
522
|
|
|
442
523
|
c.dedupe_window = 10.minutes
|
|
443
|
-
c.
|
|
524
|
+
c.retry_attempts = 5 # total attempts for transient errors; polynomial backoff
|
|
444
525
|
c.store_bodies = true
|
|
445
|
-
c.
|
|
446
|
-
c.
|
|
526
|
+
c.body_whitespace = :strip # or :squish, :preserve
|
|
527
|
+
c.deliver_later_queue = :default
|
|
528
|
+
# Smswire.register_interceptor / register_observer, per environment
|
|
529
|
+
c.callbacks_url = "https://app.example.com/smswire" # engine mount URL; builds StatusCallback URLs
|
|
530
|
+
c.persist_deliveries = true
|
|
531
|
+
c.verify_callback_signatures = true
|
|
447
532
|
end
|
|
448
533
|
```
|
|
449
534
|
|
|
450
535
|
### 7.1 Dependency policy
|
|
451
536
|
|
|
452
|
-
Runtime: `
|
|
453
|
-
`railties` (all `>= 7.1
|
|
537
|
+
Runtime: `actionpack` (for AbstractController and Action View rendering),
|
|
538
|
+
`activejob`, `activesupport`, `railties` (all `>= 7.1, < 9`), and `zeitwerk`.
|
|
539
|
+
`activerecord` joins in Phase 2 with the persistence tables. Optional: `phonelib` (soft dependency, detected at
|
|
454
540
|
load). No provider SDKs. No HTTP client gem.
|
|
455
541
|
|
|
456
542
|
### 7.2 Recipient resolution
|
|
@@ -496,6 +582,8 @@ next. No phase includes a calendar estimate.
|
|
|
496
582
|
- `TestHelper`, RSpec matchers, interceptors and observers.
|
|
497
583
|
- Accept: a dummy-app messenger renders a template, sends through Twilio
|
|
498
584
|
against WebMock, and the full test-helper surface passes.
|
|
585
|
+
- Status: implemented. Built-in phone validation accepts E.164 input and
|
|
586
|
+
North American national formats; other national formats need `phonelib`.
|
|
499
587
|
|
|
500
588
|
### Phase 2: Persistence and status tracking
|
|
501
589
|
|
|
@@ -506,6 +594,11 @@ next. No phase includes a calendar estimate.
|
|
|
506
594
|
- Accept: a delivery row moves `pending → accepted → delivered` from a
|
|
507
595
|
replayed Twilio callback fixture; duplicate sends inside the window are
|
|
508
596
|
refused.
|
|
597
|
+
- Status: implemented. Rows are claimed atomically before sending, so a
|
|
598
|
+
transient-error retry reuses its row and two workers never send the same
|
|
599
|
+
claimed message. Callback URLs carry the delivery id to survive the race
|
|
600
|
+
between the provider's response and its first callback. Rejected messages
|
|
601
|
+
are not persisted; they are reported through `reject.smswire`.
|
|
509
602
|
|
|
510
603
|
### Phase 3: Consent and inbound keywords
|
|
511
604
|
|
|
@@ -516,12 +609,16 @@ next. No phase includes a calendar estimate.
|
|
|
516
609
|
confirmation; a `marketing` send to a non-opted-in number is rejected with
|
|
517
610
|
`:rejected_no_consent`; a quiet-hours send is re-enqueued for the next
|
|
518
611
|
window.
|
|
612
|
+
- Status: implemented. Inbound webhooks are recorded once per provider
|
|
613
|
+
message id, so retries never double-process a keyword. Observers can
|
|
614
|
+
implement `sms_received(inbound_message)` to build two-way messaging.
|
|
519
615
|
|
|
520
616
|
### Phase 4: Developer experience
|
|
521
617
|
|
|
522
618
|
- Previews route and UI, local inbox UI, generators, allowlist interceptor.
|
|
523
619
|
- Accept: `rails g smswire:messenger` output renders in previews with correct
|
|
524
620
|
segment and encoding display; development deliveries appear in the inbox.
|
|
621
|
+
- Status: implemented.
|
|
525
622
|
|
|
526
623
|
### Phase 5: Breadth
|
|
527
624
|
|
|
@@ -530,12 +627,31 @@ next. No phase includes a calendar estimate.
|
|
|
530
627
|
- `Noticed::DeliveryMethods::Smswire`.
|
|
531
628
|
- Accept: the same dummy-app messenger passes the contract suite against all
|
|
532
629
|
three real adapters (WebMock) and delivers through a Noticed notifier.
|
|
630
|
+
- Status: implemented. Webhook endpoints share one controller, so a provider
|
|
631
|
+
that sends every event to one URL is routed by event kind.
|
|
533
632
|
|
|
534
633
|
### Phase 6: Release
|
|
535
634
|
|
|
536
635
|
- Guides: getting started, migrating from direct twilio-ruby, migrating from
|
|
537
636
|
Noticed `twilio_messaging`, compliance primer, provider authoring.
|
|
538
637
|
- `1.0.0` once the adapter contract and schema are considered stable.
|
|
638
|
+
- Status: guides written in `docs/guides`, with a test that every setting,
|
|
639
|
+
constant, and generator they name exists. Compliance research changed the
|
|
640
|
+
defaults: HELP replies include `support_contact`, the opt-out confirmation
|
|
641
|
+
follows CTIA wording, keywords ignore spaces ("opt out"), and
|
|
642
|
+
`opt_out_all_scopes` prepares for the FCC revoke-all provision.
|
|
643
|
+
`Smswire.verify_credentials!` from section 6.8 is implemented. Release
|
|
644
|
+
steps are in `RELEASING.md`; publishing 1.0 is left to the maintainer.
|
|
645
|
+
|
|
646
|
+
### Known limitations at 1.0
|
|
647
|
+
|
|
648
|
+
- Free-text opt-outs ("please stop texting me") are stored but not acted on;
|
|
649
|
+
the compliance guide shows an observer for review.
|
|
650
|
+
- Rate limits can be exceeded briefly by concurrent sends.
|
|
651
|
+
- Vonage receipts for the second and later parts of a long message are
|
|
652
|
+
acknowledged but not matched to the delivery.
|
|
653
|
+
- Vonage uses the SMS API, not the newer Messages API, and has no MMS.
|
|
654
|
+
- Telnyx ignores `validity_period`.
|
|
539
655
|
|
|
540
656
|
## 10. Risks and mitigations
|
|
541
657
|
|
|
@@ -549,17 +665,22 @@ next. No phase includes a calendar estimate.
|
|
|
549
665
|
|
|
550
666
|
## 11. Open decisions
|
|
551
667
|
|
|
552
|
-
|
|
668
|
+
Decisions 1 to 3 are settled. Decisions 4 to 6 are proposed as settled in
|
|
669
|
+
Phase 6, matching the implementation; confirm them before tagging 1.0.
|
|
553
670
|
|
|
554
671
|
1. **Name.** `smswire` is recommended; `textable` and `smsable` are the
|
|
555
672
|
fallbacks. Decide before the pre-release push.
|
|
556
|
-
2. **Body storage default.**
|
|
557
|
-
|
|
558
|
-
3. **UUID vs. bigint** primary keys
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
673
|
+
2. **Body storage default.** Settled in Phase 2: `store_bodies = true`, with
|
|
674
|
+
`categories: { otp: { store_body: false } }` as the shipped default.
|
|
675
|
+
3. **UUID vs. bigint** primary keys. Settled in Phase 2: the migration follows
|
|
676
|
+
the app's generator `primary_key_type`, so both work without a choice here.
|
|
677
|
+
4. **Phone validation.** Proposed: both. The built-in validator handles E.164
|
|
678
|
+
input and North American national formats with no dependency; `phonelib`
|
|
679
|
+
is used automatically when it is in the bundle, for other regions.
|
|
680
|
+
5. **`deliver_now` in production.** Proposed: allowed without a warning. It
|
|
681
|
+
returns a `Result`, which is useful for interactive flows such as
|
|
682
|
+
verification codes. Guides recommend `deliver_later`, and the Noticed
|
|
683
|
+
adapter always uses it.
|
|
684
|
+
6. **MMS in 1.0.** Proposed: a capability, not a requirement. Twilio and
|
|
685
|
+
Telnyx send `media_urls`; Vonage's SMS API does not and does not declare
|
|
686
|
+
`:mms`. The contract does not require it.
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# Compliance primer
|
|
2
|
+
|
|
3
|
+
This primer explains what Smswire does for SMS compliance, what it leaves to
|
|
4
|
+
you, and which settings matter. It summarizes US rules and industry
|
|
5
|
+
guidance as of 2026 and is not legal advice. Have counsel review your
|
|
6
|
+
messaging program.
|
|
7
|
+
|
|
8
|
+
## The rules in brief
|
|
9
|
+
|
|
10
|
+
- **TCPA and FCC rules** govern automated texts in the US. Marketing texts
|
|
11
|
+
need prior express written consent. Consumers may revoke consent by any
|
|
12
|
+
reasonable means, and since April 11, 2025 a revocation must be honored
|
|
13
|
+
within 10 days. One confirmation text is allowed after an opt-out; it must
|
|
14
|
+
not contain marketing and may ask the person to clarify what they want to
|
|
15
|
+
stop.
|
|
16
|
+
- **The FCC "revoke all" provision**, which makes an opt-out from one kind of
|
|
17
|
+
message apply to all of a sender's unrelated messages, is delayed to
|
|
18
|
+
January 31, 2027.
|
|
19
|
+
- **Quiet hours.** Federal rules restrict telemarketing outside 8am to 9pm in
|
|
20
|
+
the recipient's local time. Several states are stricter.
|
|
21
|
+
- **CTIA Messaging Principles and Best Practices** are what carriers enforce.
|
|
22
|
+
They expect opt-out keywords to work regardless of case or punctuation; one
|
|
23
|
+
confirmation after an opt-out and then silence; HELP replies that name the
|
|
24
|
+
brand and give a support contact; and consent specific to the program it
|
|
25
|
+
was collected for.
|
|
26
|
+
- **Carrier registration.** US long codes need 10DLC brand and campaign
|
|
27
|
+
registration. Toll-free numbers need verification. Unregistered traffic is
|
|
28
|
+
filtered or blocked.
|
|
29
|
+
|
|
30
|
+
## What Smswire does
|
|
31
|
+
|
|
32
|
+
| Requirement | Smswire |
|
|
33
|
+
|---|---|
|
|
34
|
+
| Honor STOP-type replies | STOP, STOPALL, UNSUBSCRIBE, CANCEL, END, QUIT, OPTOUT (or "opt out"), and REVOKE opt the number out immediately |
|
|
35
|
+
| One confirmation, no marketing | one opt-out reply through the `compliance` category; deduplication blocks repeats inside 10 minutes |
|
|
36
|
+
| HELP reply with brand and contact | HELP and INFO reply with `program_name` and `support_contact` |
|
|
37
|
+
| Re-subscribe | START, YES, and UNSTOP opt back in |
|
|
38
|
+
| Refuse sends to opted-out numbers | every category except `compliance` checks consent first |
|
|
39
|
+
| Marketing needs opt-in | `marketing` requires `opted_in` consent |
|
|
40
|
+
| Quiet hours | `marketing` waits outside 9pm to 8am, recipient time |
|
|
41
|
+
| Carrier opt-outs | a provider "recipient opted out" error records an opt-out |
|
|
42
|
+
| Evidence | consent rows keep source, timestamps, last keyword, and your metadata |
|
|
43
|
+
|
|
44
|
+
## What you must do
|
|
45
|
+
|
|
46
|
+
1. **Collect and record opt-in** before marketing. Store the evidence:
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
Smswire::Consent.opt_in!(user.phone, source: :web_form,
|
|
50
|
+
metadata: {ip: request.remote_ip, form: "checkout", copy: SMS_TERMS, at: Time.current.iso8601})
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
2. **Set your program name and support contact.**
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
config.program_name = "Acme Alerts"
|
|
57
|
+
config.support_contact = "help@acme.example"
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Without a contact, HELP replies omit it and Smswire logs a warning.
|
|
61
|
+
|
|
62
|
+
3. **Route incoming messages to Smswire** at `/smswire/inbound/<provider>`.
|
|
63
|
+
Without this, STOP replies never reach your app. If your provider handles
|
|
64
|
+
opt-outs itself, such as Twilio Advanced Opt-Out, Smswire still records
|
|
65
|
+
them and skips its own reply.
|
|
66
|
+
|
|
67
|
+
4. **Watch for free-text opt-outs.** Smswire matches keywords only when they
|
|
68
|
+
are the whole message. "Please stop texting me" is stored but not acted
|
|
69
|
+
on, yet it is a reasonable revocation. Review unmatched messages:
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
class OptOutReview
|
|
73
|
+
def self.sms_received(message)
|
|
74
|
+
return if message.keyword
|
|
75
|
+
SupportTicket.create!(phone: message.from_number, body: message.body) if message.body.match?(/\b(stop|unsubscribe|remove me)\b/i)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
Smswire.register_observer(OptOutReview)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
5. **Honor opt-outs from other channels.** When a customer opts out by email
|
|
82
|
+
or phone, call `Smswire::Consent.opt_out!(phone, source: :support)`.
|
|
83
|
+
|
|
84
|
+
6. **Choose consent scopes deliberately.** By default STOP applies to the
|
|
85
|
+
`consent_scope` of the number that received it, so two programs on two
|
|
86
|
+
numbers keep separate consent. To make every STOP apply everywhere, as the
|
|
87
|
+
FCC revoke-all provision will require across a sender's messages, set:
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
config.opt_out_all_scopes = true
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
7. **Check your categories.** Anything promotional must use
|
|
94
|
+
`category: :marketing`. Transactional messages skip the opt-in
|
|
95
|
+
requirement, so mislabeling a promotion as transactional defeats the
|
|
96
|
+
checks.
|
|
97
|
+
|
|
98
|
+
8. **Set time zones.** Quiet hours use `recipient.time_zone` when the
|
|
99
|
+
recipient object has one, and `config.default_time_zone` otherwise.
|
|
100
|
+
|
|
101
|
+
9. **Register with the carriers** and keep your messages consistent with the
|
|
102
|
+
registered campaign: brand name in messages, opt-out instructions at least
|
|
103
|
+
periodically, and content matching the sample messages.
|
|
104
|
+
|
|
105
|
+
## Settings reference
|
|
106
|
+
|
|
107
|
+
| Setting | Default | Notes |
|
|
108
|
+
|---|---|---|
|
|
109
|
+
| `enforce_consent` | `true` | turn off only if another system owns consent |
|
|
110
|
+
| `categories` | see README | merged per key over the shipped rules |
|
|
111
|
+
| `keywords` | the lists above | add synonyms, such as `ARRET` |
|
|
112
|
+
| `keyword_replies` | CTIA-style replies | `%{program}` and `%{contact}` are replaced; `nil` sends no reply |
|
|
113
|
+
| `program_name` | the app name | |
|
|
114
|
+
| `support_contact` | none | phone, email, or URL |
|
|
115
|
+
| `opt_out_all_scopes` | `false` | STOP applies to every scope |
|
|
116
|
+
| `default_time_zone` | `Time.zone` | for quiet hours |
|
|
117
|
+
|
|
118
|
+
## Sources
|
|
119
|
+
|
|
120
|
+
- [FCC consent revocation order, FCC 24-24](https://docs.fcc.gov/public/attachments/FCC-24-24A1.pdf)
|
|
121
|
+
- [FCC revocation rule effective April 11, 2025 (JD Supra)](https://www.jdsupra.com/legalnews/fcc-s-tcpa-consent-revocation-rule-8022288/)
|
|
122
|
+
- [Revoke-all provision delayed to January 31, 2027 (Wiley)](https://www.wiley.law/alert-FCC-Extends-Limited-Waiver-for-Part-of-the-TCPA-Consent-Revocation-Rule)
|
|
123
|
+
- [CTIA Messaging Principles summary (Bird)](https://bird.com/explained/compliance/what-are-the-ctia-messaging-principles)
|
|
124
|
+
- [Vonage 10DLC traffic compliance](https://api.support.vonage.com/hc/en-us/articles/8443130026140-10DLC-Traffic-compliance)
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# Getting started
|
|
2
|
+
|
|
3
|
+
This guide takes a Rails app from no SMS to a tested, previewable message
|
|
4
|
+
with delivery tracking. It assumes Rails 7.1 or newer and Ruby 3.2 or newer.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
```ruby
|
|
9
|
+
# Gemfile
|
|
10
|
+
gem "smswire"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
bundle install
|
|
15
|
+
bin/rails generate smswire:install
|
|
16
|
+
bin/rails db:migrate
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The install generator:
|
|
20
|
+
|
|
21
|
+
- copies three tables: `smswire_deliveries`, `smswire_consents`, and
|
|
22
|
+
`smswire_inbound_messages`;
|
|
23
|
+
- writes `config/initializers/smswire.rb` and `app/messengers/application_messenger.rb`;
|
|
24
|
+
- mounts the engine at `/smswire` in `config/routes.rb`.
|
|
25
|
+
|
|
26
|
+
Out of the box, development uses the `:log` provider, which writes each
|
|
27
|
+
message to the log, and tests use the `:test` provider, which records
|
|
28
|
+
messages in memory. Nothing is sent until you configure a real provider.
|
|
29
|
+
|
|
30
|
+
## Configure a sender and a provider
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
# config/initializers/smswire.rb
|
|
34
|
+
Smswire.configure do |config|
|
|
35
|
+
config.default_provider = :twilio if Rails.env.production?
|
|
36
|
+
config.providers = {
|
|
37
|
+
twilio: {
|
|
38
|
+
account_sid: Rails.application.credentials.dig(:twilio, :account_sid),
|
|
39
|
+
auth_token: Rails.application.credentials.dig(:twilio, :auth_token)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
config.senders = {
|
|
43
|
+
transactional: {number: "+15555550100"}
|
|
44
|
+
}
|
|
45
|
+
config.callbacks_url = "https://app.example.com/smswire"
|
|
46
|
+
config.program_name = "Acme"
|
|
47
|
+
config.support_contact = "help@acme.example"
|
|
48
|
+
end
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Twilio credentials default to `Rails.application.credentials.twilio`, so the
|
|
52
|
+
`providers` entry can be omitted when the credentials file has `account_sid`
|
|
53
|
+
and `auth_token` under `twilio:`. Telnyx and Vonage work the same way under
|
|
54
|
+
`telnyx:` and `vonage:`.
|
|
55
|
+
|
|
56
|
+
`callbacks_url` is the public URL of the engine mount. With it set, every
|
|
57
|
+
message asks the provider to post delivery status back, and each
|
|
58
|
+
`Smswire::Delivery` row moves from `pending` to `delivered` or `failed`.
|
|
59
|
+
|
|
60
|
+
## Write a messenger
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
bin/rails generate smswire:messenger Order shipped
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
# app/messengers/order_messenger.rb
|
|
68
|
+
class OrderMessenger < ApplicationMessenger
|
|
69
|
+
def shipped(user)
|
|
70
|
+
@order = params[:order]
|
|
71
|
+
text to: user
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
```erb
|
|
77
|
+
<%# app/views/order_messenger/shipped.text.erb %>
|
|
78
|
+
Order <%= @order.number %> has shipped. Track it: <%= order_url(@order) %>
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`text to:` accepts an E.164 string or any object with `phone_number`. Use
|
|
82
|
+
`config.recipient_resolver` if your column is named differently. URL helpers
|
|
83
|
+
use `Rails.application.routes.default_url_options`, so set a host there.
|
|
84
|
+
|
|
85
|
+
Send it:
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
OrderMessenger.with(order:).shipped(user).deliver_later
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
`deliver_later` is the right default. `deliver_now` runs the pipeline inline
|
|
92
|
+
and returns a `Smswire::Result`:
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
result = OrderMessenger.with(order:).shipped(user).deliver_now
|
|
96
|
+
result.status # :accepted, :failed, :duplicate, :rejected_opted_out, ...
|
|
97
|
+
result.delivery # the Smswire::Delivery row
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Preview it
|
|
101
|
+
|
|
102
|
+
Open `http://localhost:3000/smswire/previews`. The generator wrote a preview
|
|
103
|
+
in `test/messengers/previews/order_messenger_preview.rb`; point it at real
|
|
104
|
+
records:
|
|
105
|
+
|
|
106
|
+
```ruby
|
|
107
|
+
class OrderMessengerPreview < Smswire::Preview
|
|
108
|
+
def shipped
|
|
109
|
+
OrderMessenger.with(order: Order.last).shipped(User.first)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Each preview shows the message in a phone frame with its encoding, segment
|
|
115
|
+
count, and remaining characters, and names any character that forces UCS-2,
|
|
116
|
+
which cuts a segment from 160 characters to 70. The inbox at
|
|
117
|
+
`/smswire/inbox` shows what development sent and lets you simulate a reply
|
|
118
|
+
such as STOP.
|
|
119
|
+
|
|
120
|
+
## Test it
|
|
121
|
+
|
|
122
|
+
```ruby
|
|
123
|
+
class OrderTest < ActiveSupport::TestCase
|
|
124
|
+
include Smswire::TestHelper
|
|
125
|
+
|
|
126
|
+
test "shipping texts the customer" do
|
|
127
|
+
assert_sms_delivered(to: users(:ada).phone_number, messenger: OrderMessenger, body: /has shipped/) do
|
|
128
|
+
orders(:one).ship!
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Inside the block, `deliver_later` jobs are performed. RSpec users can
|
|
135
|
+
`require "smswire/rspec"` for `deliver_sms` and `have_enqueued_sms`.
|
|
136
|
+
|
|
137
|
+
## Point the provider at the webhooks
|
|
138
|
+
|
|
139
|
+
| Webhook | URL |
|
|
140
|
+
|---|---|
|
|
141
|
+
| Delivery status | sent automatically per message when `callbacks_url` is set |
|
|
142
|
+
| Incoming messages | `https://app.example.com/smswire/inbound/<provider>` |
|
|
143
|
+
|
|
144
|
+
Incoming messages are needed for STOP, START, and HELP handling. Read the
|
|
145
|
+
[compliance primer](compliance.md) before sending marketing messages.
|
|
146
|
+
|
|
147
|
+
## Before production
|
|
148
|
+
|
|
149
|
+
- Set `config.default_provider` and real credentials.
|
|
150
|
+
- Run `Smswire.verify_credentials!` from a boot check or health endpoint.
|
|
151
|
+
- In staging, register `Smswire::Interceptors::Allowlist` so only your team's
|
|
152
|
+
numbers receive messages.
|
|
153
|
+
- Register your sender with the carriers: 10DLC brand and campaign
|
|
154
|
+
registration for US long codes, or toll-free verification.
|