smswire 0.0.1.pre → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +67 -3
  3. data/README.md +222 -9
  4. data/RELEASING.md +49 -0
  5. data/app/controllers/smswire/development_controller.rb +17 -0
  6. data/app/controllers/smswire/inbound_messages_controller.rb +9 -0
  7. data/app/controllers/smswire/inbox_controller.rb +31 -0
  8. data/app/controllers/smswire/previews_controller.rb +22 -0
  9. data/app/controllers/smswire/status_callbacks_controller.rb +8 -0
  10. data/app/controllers/smswire/webhooks_controller.rb +49 -0
  11. data/app/models/smswire/application_record.rb +5 -0
  12. data/app/models/smswire/consent.rb +71 -0
  13. data/app/models/smswire/delivery.rb +157 -0
  14. data/app/models/smswire/inbound_message.rb +25 -0
  15. data/app/views/layouts/smswire/application.html.erb +60 -0
  16. data/app/views/smswire/inbox/index.html.erb +56 -0
  17. data/app/views/smswire/previews/index.html.erb +21 -0
  18. data/app/views/smswire/previews/show.html.erb +34 -0
  19. data/config/routes.rb +10 -0
  20. data/db/migrate/20261008000000_create_smswire_deliveries.rb +47 -0
  21. data/db/migrate/20261008000001_create_smswire_consents_and_inbound_messages.rb +34 -0
  22. data/docs/SPEC.md +164 -43
  23. data/docs/guides/compliance.md +124 -0
  24. data/docs/guides/getting-started.md +154 -0
  25. data/docs/guides/migrating-from-noticed-twilio.md +105 -0
  26. data/docs/guides/migrating-from-twilio-ruby.md +146 -0
  27. data/docs/guides/writing-a-provider.md +126 -0
  28. data/lib/generators/smswire/install/install_generator.rb +44 -0
  29. data/lib/generators/smswire/install/templates/application_messenger.rb.tt +3 -0
  30. data/lib/generators/smswire/install/templates/initializer.rb.tt +35 -0
  31. data/lib/generators/smswire/messenger/messenger_generator.rb +51 -0
  32. data/lib/generators/smswire/messenger/templates/messenger.rb.tt +14 -0
  33. data/lib/generators/smswire/messenger/templates/preview.rb.tt +13 -0
  34. data/lib/generators/smswire/messenger/templates/test.rb.tt +18 -0
  35. data/lib/generators/smswire/messenger/templates/view.text.erb.tt +1 -0
  36. data/lib/generators/smswire/provider/provider_generator.rb +36 -0
  37. data/lib/generators/smswire/provider/templates/provider.rb.tt +52 -0
  38. data/lib/generators/smswire/provider/templates/test.rb.tt +29 -0
  39. data/lib/smswire/base.rb +123 -0
  40. data/lib/smswire/body_formatter.rb +14 -0
  41. data/lib/smswire/callbacks.rb +24 -0
  42. data/lib/smswire/configuration.rb +224 -0
  43. data/lib/smswire/delivery_job.rb +29 -0
  44. data/lib/smswire/engine.rb +52 -0
  45. data/lib/smswire/errors.rb +59 -0
  46. data/lib/smswire/http.rb +65 -0
  47. data/lib/smswire/inbound.rb +11 -0
  48. data/lib/smswire/inbound_handler.rb +29 -0
  49. data/lib/smswire/interceptors/allowlist.rb +36 -0
  50. data/lib/smswire/keyword_messenger.rb +12 -0
  51. data/lib/smswire/keywords.rb +51 -0
  52. data/lib/smswire/log_subscriber.rb +57 -0
  53. data/lib/smswire/message.rb +72 -0
  54. data/lib/smswire/message_delivery.rb +69 -0
  55. data/lib/smswire/noticed_delivery_method.rb +54 -0
  56. data/lib/smswire/parameterized_messenger.rb +24 -0
  57. data/lib/smswire/phone_number.rb +65 -0
  58. data/lib/smswire/pipeline.rb +114 -0
  59. data/lib/smswire/preview.rb +54 -0
  60. data/lib/smswire/provider_contract.rb +158 -0
  61. data/lib/smswire/providers/base.rb +66 -0
  62. data/lib/smswire/providers/log.rb +23 -0
  63. data/lib/smswire/providers/null.rb +12 -0
  64. data/lib/smswire/providers/telnyx.rb +178 -0
  65. data/lib/smswire/providers/test.rb +59 -0
  66. data/lib/smswire/providers/twilio.rb +167 -0
  67. data/lib/smswire/providers/vonage.rb +193 -0
  68. data/lib/smswire/providers.rb +38 -0
  69. data/lib/smswire/quiet_hours.rb +45 -0
  70. data/lib/smswire/rate_limit.rb +27 -0
  71. data/lib/smswire/receipt.rb +11 -0
  72. data/lib/smswire/recipient.rb +38 -0
  73. data/lib/smswire/result.rb +40 -0
  74. data/lib/smswire/rspec.rb +133 -0
  75. data/lib/smswire/segments.rb +81 -0
  76. data/lib/smswire/sender.rb +27 -0
  77. data/lib/smswire/status_handler.rb +25 -0
  78. data/lib/smswire/status_update.rb +8 -0
  79. data/lib/smswire/test_helper.rb +81 -0
  80. data/lib/smswire/testing.rb +72 -0
  81. data/lib/smswire/version.rb +1 -1
  82. data/lib/smswire.rb +70 -6
  83. data/smswire.gemspec +9 -5
  84. metadata +195 -10
  85. data/Gemfile +0 -7
  86. data/Rakefile +0 -8
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 squished by default (configurable) since SMS has no
200
- layout. Rendering computes `segments` and `encoding` (GSM-7 vs. UCS-2) on the
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`, `sender`, `provider`,
203
- `messaging_service`, `validity_period`, `quiet_hours`.
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::Delivery`.
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`. The job re-runs the pipeline, so consent and quiet
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 | uuid/bigint | |
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
- | to, from | string | E.164 |
286
- | recipient_type, recipient_id | string, string | polymorphic, optional |
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`), help set (`HELP`, `INFO`), opt-in
335
- set (`START`, `YES`, `UNSTOP`). Case-insensitive, trimmed, configurable.
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
- - **Rate limits** per recipient per category over a sliding window, backed by
345
- `Rails.cache`.
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.category, config.wait, config.if
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. 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.
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 with contract tests.
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 = :phonelib # or :e164_regex (no extra dependency)
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
- 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} }
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.retry = { attempts: 5, wait: :polynomially_longer }
524
+ c.retry_attempts = 5 # total attempts for transient errors; polynomial backoff
444
525
  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
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: `activesupport`, `activejob`, `activerecord`, `actionview`,
453
- `railties` (all `>= 7.1`). Optional: `phonelib` (soft dependency, detected at
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
- These do not block Phase 0 or 1 but should be settled before Phase 2:
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.** 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.
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.