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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ All notable changes to this project are documented here. The format follows

### Added

- `Smswire::Consent` with per-sender consent scopes, evidence metadata, and
`opt_in!`, `opt_out!`, `opt_out_everywhere!`, and `status_for`.
- Inbound webhook endpoint storing `Smswire::InboundMessage` once per
provider message id, with STOP / START / HELP keyword handling,
configurable auto-replies, and an `sms_received` observer hook.
- Category rules for consent, quiet hours, and rate limits, merged over
shipped defaults. New outcomes `:rejected_opted_out`,
`:rejected_no_consent`, `:rejected_rate_limited`, `:deferred_quiet_hours`,
and `:deferred_rate_limited`; deferred messages re-enqueue themselves.
- Carrier opt-out errors record an opt-out with source `carrier`.

- `Smswire::Engine` with `smswire:install:migrations` and the
`smswire_deliveries` table, following the app's primary key type.
- `Smswire::Delivery` records for every send, with atomic send claims,
Expand Down
31 changes: 30 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ 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).

**Status:** pre-release. Phases 1 and 2 of the spec are implemented; the
**Status:** pre-release. Phases 1 to 3 of the spec are implemented; the
published `0.0.1.pre` gem only reserves the name.

## Requirements
Expand Down Expand Up @@ -90,6 +90,35 @@ result.delivery.status # => "accepted"
Smswire::Delivery.for_number("+15551234567").recent
```

### Consent, keywords, quiet hours, and rate limits

Point your Twilio number's incoming message webhook at
`https://app.example.com/smswire/inbound/twilio`. Replies of STOP, START,
and HELP (and their synonyms) update `Smswire::Consent` and send the
configured confirmation. Record opt-ins from your own forms with evidence:

```ruby
Smswire::Consent.opt_in!(user.phone, source: :web_form,
metadata: {ip: request.remote_ip, form: "checkout", copy: terms_text})
```

Each message has a category. The shipped rules are:

| Category | Consent | Quiet hours |
|---|---|---|
| `transactional` (default) | send unless opted out | none |
| `otp` | send unless opted out; body not stored | none |
| `marketing` | requires opt-in | 9pm to 8am recipient time |
| `compliance` | not checked; keyword replies | none |

Override any key, and add rate limits, in `config.categories`. Messages in
quiet hours or over a rate limit are re-enqueued for when they may send and
return `:deferred_quiet_hours` or `:deferred_rate_limited`. A carrier
opt-out error from the provider records an opt-out automatically.

Smswire provides these mechanisms. Your app is still responsible for its
messaging program's legal compliance.

### Providers

| Name | Use |
Expand Down
45 changes: 45 additions & 0 deletions app/controllers/smswire/inbound_messages_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
module Smswire
# Receives inbound messages at POST <mount>/inbound/:provider, records
# them once per provider message id, and handles compliance keywords.
# Apps can build two-way messaging with an observer that implements
# +sms_received(inbound_message)+.
class InboundMessagesController < ActionController::Base
skip_forgery_protection

def create
provider = Providers.resolve(params[:provider])
return head(:not_found) unless provider.capabilities.include?(:inbound)

if Smswire.config.verify_callback_signatures
provider.verify_signature!(request, url: Callbacks.request_url(request))
end

inbound = provider.parse_inbound(request)
payload = {provider: provider.name, provider_id: inbound.provider_id, from: inbound.from}
ActiveSupport::Notifications.instrument("inbound.smswire", payload) do
record = InboundMessage.receive(inbound, provider: provider.name)
payload[:duplicate] = record.nil?
if record
payload[:keyword] = Keywords.handle(record, inbound, provider: provider.name) if Smswire.config.enforce_consent
notify_observers(record)
end
end

body, content_type = provider.inbound_response
body ? render(plain: body, content_type:) : head(:no_content)
rescue ConfigurationError
head :not_found
rescue SignatureError
head :forbidden
end

private

def notify_observers(record)
Smswire.config.observers.each do |observer|
observer = observer.is_a?(String) ? observer.constantize : observer
observer.sms_received(record) if observer.respond_to?(:sms_received)
end
end
end
end
71 changes: 71 additions & 0 deletions app/models/smswire/consent.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
module Smswire
# A phone number's messaging consent within a scope. The scope is
# "default" unless a sender sets +consent_scope+, which lets separate
# programs (for example two brands on two numbers) keep separate consent.
#
# Smswire::Consent.opt_in!("+15551234567", source: :web_form,
# metadata: {ip: request.remote_ip, form: "checkout", copy: terms_text})
# Smswire::Consent.status_for("+15551234567") # => "opted_in"
class Consent < ApplicationRecord
DEFAULT_SCOPE = "default".freeze
STATUSES = %w[opted_in opted_out pending unknown].freeze

enum :status, STATUSES.index_with(&:itself)

class << self
def status_for(phone, scope: DEFAULT_SCOPE)
find_by(phone: normalize!(phone), scope: scope.to_s)&.status || "unknown"
end

def opt_in!(phone, source:, scope: DEFAULT_SCOPE, keyword: nil, metadata: {})
record!(phone, scope, {status: "opted_in", opted_in_at: Time.current}, source:, keyword:, metadata:)
end

def opt_out!(phone, source:, scope: DEFAULT_SCOPE, keyword: nil, metadata: {})
record!(phone, scope, {status: "opted_out", opted_out_at: Time.current}, source:, keyword:, metadata:)
end

# STOPALL: opt out of every scope this number has, plus every scope
# the configured senders use.
def opt_out_everywhere!(phone, source:, keyword: nil)
number = normalize!(phone)
scopes = (where(phone: number).pluck(:scope) + Smswire.config.consent_scopes).uniq
scopes.map { |scope| opt_out!(number, scope:, source:, keyword:) }
end

# The consent scope of the configured sender that owns +number+ or
# +messaging_service+, used for inbound keywords.
def scope_for_sender(number: nil, messaging_service: nil)
Smswire.config.senders.each_value do |options|
options = options.to_h.symbolize_keys
matched = (number.present? && options[:number] == number) ||
(messaging_service.present? && options[:messaging_service] == messaging_service)
return (options[:consent_scope] || DEFAULT_SCOPE).to_s if matched
end
DEFAULT_SCOPE
end

private

def normalize!(phone)
PhoneNumber.normalize(phone) || raise(ArgumentError, "Invalid phone number: #{phone.inspect}")
end

def record!(phone, scope, attributes, source:, keyword:, metadata:)
retried = false
begin
consent = find_or_initialize_by(phone: normalize!(phone), scope: scope.to_s)
consent.assign_attributes(attributes.merge(source: source.to_s))
consent.assign_attributes(last_keyword: keyword, last_keyword_at: Time.current) if keyword
consent.metadata = (consent.metadata || {}).merge(metadata.deep_stringify_keys) if metadata.present?
consent.save!
consent
rescue ActiveRecord::RecordNotUnique
raise if retried
retried = true
retry
end
end
end
end
end
25 changes: 25 additions & 0 deletions app/models/smswire/inbound_message.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
module Smswire
# A message received from a provider webhook. Webhook retries for the
# same provider message id are recorded once.
class InboundMessage < ApplicationRecord
KEYWORDS = %w[stop start help].freeze

scope :recent, -> { order(created_at: :desc) }

# Returns the new record, or nil when this provider message id was
# already received.
def self.receive(inbound, provider:)
create!(
provider: provider.to_s,
provider_id: inbound.provider_id,
from_number: PhoneNumber.normalize(inbound.from) || inbound.from,
to_number: inbound.to,
messaging_service: inbound.messaging_service,
body: inbound.body,
raw: inbound.raw
)
rescue ActiveRecord::RecordNotUnique
nil
end
end
end
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
Smswire::Engine.routes.draw do
post "status/:provider", to: "status_callbacks#create", as: :status_callback
post "inbound/:provider", to: "inbound_messages#create", as: :inbound_message
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
class CreateSmswireConsentsAndInboundMessages < ActiveRecord::Migration[7.1]
def change
primary_key_type = Rails.configuration.generators.options[Rails.configuration.generators.orm][:primary_key_type]

create_table :smswire_consents, id: primary_key_type || :primary_key do |t|
t.string :phone, null: false
t.string :scope, null: false, default: "default"
t.string :status, null: false, default: "unknown"
t.string :source
t.datetime :opted_in_at
t.datetime :opted_out_at
t.string :last_keyword
t.datetime :last_keyword_at
t.json :metadata
t.timestamps
end
add_index :smswire_consents, [:phone, :scope], unique: true

create_table :smswire_inbound_messages, id: primary_key_type || :primary_key do |t|
t.string :provider, null: false
t.string :provider_id, null: false
t.string :from_number, null: false
t.string :to_number
t.string :messaging_service
t.text :body
t.string :keyword
t.datetime :handled_at
t.json :raw
t.timestamps
end
add_index :smswire_inbound_messages, [:provider, :provider_id], unique: true
add_index :smswire_inbound_messages, [:from_number, :created_at]
end
end
40 changes: 30 additions & 10 deletions docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ end
message so previews, logs, and callbacks can report them.
- `default` supports `from`, `category`, `provider`, `messaging_service`,
`metadata`, `validity_period`. A named sender is given as `from: :name`.
`quiet_hours` joins in Phase 3.
Quiet hours and rate limits are set per category, not per messenger.

### 6.2 Message object

Expand Down Expand Up @@ -344,18 +344,30 @@ legal compliance. Defaults follow CTIA messaging principles so an app that does
nothing is at least not wrong:

- **Keyword handling** on inbound: opt-out set (`STOP`, `STOPALL`,
`UNSUBSCRIBE`, `CANCEL`, `END`, `QUIT`), help set (`HELP`, `INFO`), opt-in
set (`START`, `YES`, `UNSTOP`). Case-insensitive, trimmed, configurable.
`UNSUBSCRIBE`, `CANCEL`, `END`, `QUIT`, `OPTOUT`, `REVOKE`), help set
(`HELP`, `INFO`), opt-in set (`START`, `YES`, `UNSTOP`). A keyword matches
only as the whole message, ignoring case, whitespace, and surrounding
punctuation. Configurable. `STOPALL` opts out of every consent scope.
- **Consent scopes**: consent is stored per phone number and scope. A
sender's `consent_scope` (default `"default"`) decides the scope for both
outbound checks and inbound keywords to that sender's number.
- **Auto-replies** for each keyword are templated and configurable; the
opt-out confirmation is sent even if consent is already `opted_out`.
opt-out confirmation is sent even if consent is already `opted_out`,
through the built-in `compliance` category, which skips consent checks.
When the provider already replied, as Twilio Advanced Opt-Out signals with
`OptOutType`, consent is recorded and no second reply is sent.
- **Consent gating** by category: `otp` and `transactional` default to
`allow_unless_opted_out`; `marketing` defaults to `require_opted_in`.
Apps can define categories and rules.
- **Quiet hours** per category, evaluated in the recipient's time zone when
resolvable (recipient object responds to `time_zone`), otherwise in a
configured default. Deferred messages re-enqueue for the next window.
- **Rate limits** per recipient per category over a sliding window, backed by
`Rails.cache`.
configured default. Deferred messages re-enqueue for the next window,
from both `deliver_now` and `deliver_later`. The marketing default is the
US federal TCPA window, 9pm to 8am; some states are stricter.
- **Rate limits** per recipient per category over a sliding window, counted
from `smswire_deliveries` so they survive restarts and are shared across
processes. Failed and suppressed deliveries do not count. Concurrent sends
can exceed the limit briefly.
- **Carrier opt-out sync**: a permanent error with an unsubscribed/blocked
code updates the consent row to `opted_out` with `source: carrier`.

Expand Down Expand Up @@ -446,11 +458,16 @@ Smswire.configure do |c|
c.default_region = "US" # for parsing national-format numbers
c.phone_validator = :auto # :phonelib, or :e164 (built-in, no extra dependency)

# Merged per key over the shipped defaults:
# otp: { consent: :allow_unless_opted_out, store_body: false }
# transactional: { consent: :allow_unless_opted_out }
# marketing: { consent: :require_opted_in, quiet_hours: "21:00".."08:00" }
# compliance: { consent: :none } # keyword auto-replies
c.categories = {
otp: { consent: :allow_unless_opted_out, quiet_hours: nil, store_body: false },
transactional: { consent: :allow_unless_opted_out, quiet_hours: nil },
marketing: { consent: :require_opted_in, quiet_hours: "21:00".."09:00", rate_limit: {max: 3, per: 1.day} }
marketing: { rate_limit: {max: 3, per: 1.day} } # exceed: :defer (default) or :reject
}
c.default_time_zone = "America/New_York" # quiet hours when the recipient has no time_zone
c.program_name = "Acme Alerts" # used in keyword auto-replies

c.dedupe_window = 10.minutes
c.retry_attempts = 5 # total attempts for transient errors; polynomial backoff
Expand Down Expand Up @@ -541,6 +558,9 @@ next. No phase includes a calendar estimate.
confirmation; a `marketing` send to a non-opted-in number is rejected with
`:rejected_no_consent`; a quiet-hours send is re-enqueued for the next
window.
- Status: implemented. Inbound webhooks are recorded once per provider
message id, so retries never double-process a keyword. Observers can
implement `sms_received(inbound_message)` to build two-way messaging.

### Phase 4: Developer experience

Expand Down
3 changes: 2 additions & 1 deletion lib/smswire/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ class Base < AbstractController::Base

abstract!

TEXT_OPTIONS = %i[category provider messaging_service media_urls metadata idempotency_key validity_period].freeze
TEXT_OPTIONS = %i[category provider messaging_service media_urls metadata idempotency_key validity_period consent_scope].freeze
DEFAULT_KEYS = (%i[from] + TEXT_OPTIONS - %i[media_urls idempotency_key]).freeze

class_attribute :default_params, default: {}.freeze
Expand Down Expand Up @@ -112,6 +112,7 @@ def text(to:, body: nil, from: nil, **options)
metadata: settings[:metadata],
validity_period: settings[:validity_period],
idempotency_key: settings[:idempotency_key],
consent_scope: settings[:consent_scope] || sender.consent_scope,
messenger: self.class.name,
action: action_name
)
Expand Down
Loading
Loading