Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,29 @@ on:

jobs:
test:
name: Ruby ${{ matrix.ruby }} / Rails ${{ matrix.rails }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
ruby: ["3.2", "3.3", "3.4"]
rails: ["7.1", "7.2", "8.0", "8.1"]
env:
RAILS_VERSION: ${{ matrix.rails }}
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
ruby-version: ${{ matrix.ruby }}
bundler-cache: true
- run: bundle exec rake
- run: bundle exec rake test

lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
ruby-version: "3.4"
bundler-cache: true
- run: bundle exec standardrb
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,20 @@ All notable changes to this project are documented here. The format follows

## [Unreleased]

### Added

- `Smswire::Base` messenger classes with `default`, `with(params)`, `.text.erb`
templates, layouts, I18n lazy lookup, and URL helpers.
- `Smswire::Message` with GSM-7 / UCS-2 encoding detection and segment counting.
- Delivery pipeline: E.164 normalization, empty-body rejection, interceptors,
provider delivery, observers, and `ActiveSupport::Notifications` events.
- Providers: `twilio`, `log`, `test`, `null`, plus a registry for custom adapters.
- `deliver_now` returning `Smswire::Result`, and `deliver_later` through
`Smswire::DeliveryJob` with transient retries and Retry-After handling.
- Named senders, recipient resolution, and optional `phonelib` support.
- `Smswire::TestHelper` for Minitest and RSpec matchers in `smswire/rspec`.
- CI matrix over Ruby 3.2 to 3.4 and Rails 7.1 to 8.1.

## [0.0.1.pre]

- Reserve the gem name. No functionality yet; see `docs/SPEC.md`.
14 changes: 14 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,20 @@ source "https://rubygems.org"

gemspec

# CI sets RAILS_VERSION (e.g. "7.1") to test each supported Rails line.
if (rails_version = ENV["RAILS_VERSION"])
requirement = "~> #{rails_version}.0"
gem "actionpack", requirement
gem "activejob", requirement
gem "activesupport", requirement
gem "railties", requirement
# ActiveSupport 7.1 and 8.0 pass to_json options that json 3.0 removed.
gem "json", "< 3" if %w[7.1 8.0].include?(rails_version)
end

gem "rake", "~> 13.0"
gem "minitest", "~> 5.25"
gem "webmock", "~> 3.23"
gem "phonelib", "~> 0.10", require: false
gem "rspec-expectations", "~> 3.13", require: false
gem "standard", "~> 1.0"
98 changes: 89 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,98 @@
> The production SMS layer for Rails that Noticed and twilio-ruby both leave to you.

Smswire gives Rails applications Action Mailer-style messenger classes and
templates for SMS, a provider-agnostic message object, persisted deliveries
with provider status callbacks, consent and keyword handling (STOP / HELP /
START), quiet hours, retries with a transient-vs-permanent error taxonomy,
development sandboxing, previews, and test helpers.
templates for SMS, a provider-agnostic message object, a delivery pipeline
with typed outcomes, retries with a transient-versus-permanent error
taxonomy, and test helpers. Persisted deliveries with status callbacks,
consent and STOP / HELP / START handling, quiet hours, previews, and a
[Noticed](https://github.com/excid3/noticed) delivery adapter follow in later
phases of the [specification](docs/SPEC.md).

It complements [Noticed](https://github.com/excid3/noticed) by shipping a
`Noticed::DeliveryMethods::Smswire` adapter, and it works standalone for apps
that do not need a notification inbox.
**Status:** pre-release. Phase 1 of the spec is implemented on `main`; the
published `0.0.1.pre` gem only reserves the name.

**Status:** specification stage. Nothing is published yet.
## Requirements

- [Scoped specification](docs/SPEC.md)
Ruby 3.2+ and Rails 7.1 through 8.1.

## Usage

```ruby
# config/initializers/smswire.rb
Smswire.configure do |config|
config.default_provider = :twilio # :log in development and :test in test by default
config.providers = {twilio: {account_sid: "AC...", auth_token: "..."}} # or credentials.twilio
config.senders = {transactional: {number: "+15005550006"}}
end
```

```ruby
# app/messengers/order_messenger.rb
class OrderMessenger < Smswire::Base
default from: :transactional

def shipped(user)
@order = params[:order]
text to: user # any object with #phone_number, or an E.164 string
end
end
```

```erb
<%# app/views/order_messenger/shipped.text.erb %>
<%= t(".greeting", name: @order.customer_name) %> Order <%= @order.number %> has shipped.
Track it: <%= order_url(@order) %>
```

```ruby
OrderMessenger.with(order:).shipped(user).deliver_later
result = OrderMessenger.with(order:).shipped(user).deliver_now
result.status # => :accepted, :failed, :rejected_invalid_number, ...
result.provider_id # => "SM..."
```

Every message is normalized to E.164, checked for an empty body, passed
through interceptors, sent, and reported to observers and
`ActiveSupport::Notifications` (`deliver.smswire`, `reject.smswire`). Bodies
are never instrumented or logged by provider adapters.

### Providers

| Name | Use |
|---|---|
| `:twilio` | Twilio Programmable Messaging over REST, no SDK |
| `:log` | Writes messages to the logger; development default |
| `:test` | Records messages in memory; test default |
| `:null` | Accepts and discards |

Register your own with `Smswire::Providers.register(:acme, AcmeProvider)`,
subclassing `Smswire::Providers::Base`.

### Testing

```ruby
class OrderTest < ActiveSupport::TestCase
include Smswire::TestHelper

test "ships" do
assert_sms_delivered(to: "+15551234567", messenger: OrderMessenger, body: /shipped/) do
order.ship! # deliver_later jobs inside the block are performed
end
assert_enqueued_sms(OrderMessenger, :shipped) { order.ship! }
end
end
```

RSpec: `require "smswire/rspec"` for `deliver_sms(...)` and
`have_enqueued_sms(OrderMessenger, :shipped)`.

## Development

```
bin/setup
bundle exec rake # tests and Standard
RAILS_VERSION=7.1 bundle exec rake test
```

## License

Expand Down
37 changes: 26 additions & 11 deletions docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,21 +196,29 @@ end
`deliver_later` is called, exactly like `mail`.
- Templates live at `app/views/<messenger_name>/<action>.text.erb`. I18n via
`t(".key")` scoped the same way Action Mailer scopes it.
- Body whitespace is squished by default (configurable) since SMS has no
layout. Rendering computes `segments` and `encoding` (GSM-7 vs. UCS-2) on the
- Body whitespace is cleaned with `body_whitespace = :strip` by default:
trailing spaces on each line are removed, three or more newlines collapse to
two, and the ends are stripped. `:squish` and `:preserve` are the
alternatives. Layouts are supported, which suits a shared opt-out footer.
Rendering computes `segments` and `encoding` (GSM-7 vs. UCS-2) on the
message so previews, logs, and callbacks can report them.
- `default` supports `from`, `category`, `sender`, `provider`,
`messaging_service`, `validity_period`, `quiet_hours`.
- `default` supports `from`, `category`, `provider`, `messaging_service`,
`metadata`, `validity_period`. A named sender is given as `from: :name`.
`quiet_hours` joins in Phase 3.

### 6.2 Message object

`Smswire::Message` is a plain Ruby value object, serializable through
ActiveJob (GlobalID for the recipient object, primitives for the rest). It
exposes:

- `deliver_now` runs the pipeline inline and returns a `Smswire::Delivery`.
- `deliver_now` runs the pipeline inline and returns a `Smswire::Result`
(status, message, receipt, error). From Phase 2 the result also carries the
persisted `Smswire::Delivery`.
- `deliver_later(wait:, wait_until:, queue:, priority:)` enqueues
`Smswire::DeliveryJob`. The job re-runs the pipeline, so consent and quiet
`Smswire::DeliveryJob` with the messenger name, action, params, and
arguments, like Action Mailer. Arguments must be ActiveJob-serializable.
The job re-runs the action and the pipeline, so consent and quiet
hours are evaluated at send time, not enqueue time.
- `segments`, `encoding`, `length` computed at render.
- `idempotency_key` defaults to a digest of messenger, action, recipient, and
Expand All @@ -228,6 +236,8 @@ halt with a typed outcome so callers and observers can distinguish:
- `:deferred_quiet_hours` (re-enqueued for the next allowed window)
- `:deferred_rate_limited`
- `:suppressed_by_interceptor` (dev sandbox, allowlist)
- `:rejected_empty_body` (no body and no media)
- `:skipped` (the action returned without calling `text`)
- `:duplicate`
- `:accepted` / `:failed`

Expand Down Expand Up @@ -431,7 +441,7 @@ Smswire.configure do |c|

c.recipient_resolver = ->(obj) { obj.respond_to?(:mobile_phone) ? obj.mobile_phone : obj.phone_number }
c.default_region = "US" # for parsing national-format numbers
c.phone_validator = :phonelib # or :e164_regex (no extra dependency)
c.phone_validator = :auto # :phonelib, or :e164 (built-in, no extra dependency)

c.categories = {
otp: { consent: :allow_unless_opted_out, quiet_hours: nil, store_body: false },
Expand All @@ -440,17 +450,20 @@ Smswire.configure do |c|
}

c.dedupe_window = 10.minutes
c.retry = { attempts: 5, wait: :polynomially_longer }
c.retry_attempts = 5 # total attempts for transient errors; polynomial backoff
c.store_bodies = true
c.interceptors = [] # per-env additions in config/environments/*.rb
c.body_whitespace = :strip # or :squish, :preserve
c.deliver_later_queue = :default
# Smswire.register_interceptor / register_observer, per environment
c.callbacks_host = "https://app.example.com" # used to build StatusCallback URLs
end
```

### 7.1 Dependency policy

Runtime: `activesupport`, `activejob`, `activerecord`, `actionview`,
`railties` (all `>= 7.1`). Optional: `phonelib` (soft dependency, detected at
Runtime: `actionpack` (for AbstractController and Action View rendering),
`activejob`, `activesupport`, `railties` (all `>= 7.1, < 9`), and `zeitwerk`.
`activerecord` joins in Phase 2 with the persistence tables. Optional: `phonelib` (soft dependency, detected at
load). No provider SDKs. No HTTP client gem.

### 7.2 Recipient resolution
Expand Down Expand Up @@ -496,6 +509,8 @@ next. No phase includes a calendar estimate.
- `TestHelper`, RSpec matchers, interceptors and observers.
- Accept: a dummy-app messenger renders a template, sends through Twilio
against WebMock, and the full test-helper surface passes.
- Status: implemented. Built-in phone validation accepts E.164 input and
North American national formats; other national formats need `phonelib`.

### Phase 2: Persistence and status tracking

Expand Down
67 changes: 61 additions & 6 deletions lib/smswire.rb
Original file line number Diff line number Diff line change
@@ -1,11 +1,66 @@
require_relative "smswire/version"
require "zeitwerk"
require "active_support"
require "active_support/core_ext/object/blank"
require "active_support/core_ext/string/filters"
require "active_support/core_ext/hash/keys"
require "active_support/core_ext/class/attribute"
require "active_job"

loader = Zeitwerk::Loader.for_gem
loader.inflector.inflect("http" => "HTTP", "rspec" => "RSpec")
loader.ignore(
"#{__dir__}/smswire/errors.rb",
"#{__dir__}/smswire/railtie.rb",
"#{__dir__}/smswire/rspec.rb"
)
loader.setup

require_relative "smswire/errors"

# Smswire is the production SMS layer for Rails: messenger classes and
# templates, a provider-agnostic message object, persisted deliveries with
# status callbacks, consent and keyword handling, retries, development
# sandboxing, previews, and test helpers.
# templates, a provider-agnostic message object, a delivery pipeline with
# typed outcomes, provider adapters, retries, and test helpers.
#
# This pre-release reserves the gem name. See docs/SPEC.md for the design.
# See docs/SPEC.md for the full design.
module Smswire
class Error < StandardError; end
class << self
def config
@config ||= Configuration.new
end

def configure
yield config
end

def reset_config!
@config = Configuration.new
end

def logger
config.logger || ActiveSupport::Logger.new($stdout)
end

# Interceptors respond to +delivering_sms(message)+. They run after the
# recipient is normalized and before the provider is called, and may
# mutate the message or cancel it with +message.cancel!+.
def register_interceptor(interceptor)
config.interceptors << interceptor unless config.interceptors.include?(interceptor)
end

def unregister_interceptor(interceptor)
config.interceptors.delete(interceptor)
end

# Observers respond to +delivered_sms(result)+. They are called with
# every pipeline outcome except transient errors, which raise.
def register_observer(observer)
config.observers << observer unless config.observers.include?(observer)
end

def unregister_observer(observer)
config.observers.delete(observer)
end
end
end

require_relative "smswire/railtie" if defined?(Rails::Railtie)
Loading
Loading