From 91fd0cb6f2d927622a81926b61b5e5e6beb2a8cb Mon Sep 17 00:00:00 2001 From: Tim Walsh Date: Thu, 8 Oct 2026 15:47:20 -0700 Subject: [PATCH] Implement Phase 3: consent, inbound keywords, quiet hours, rate limits Add smswire_consents and smswire_inbound_messages. Consent is kept per phone number and scope, where a sender's consent_scope picks the scope for both outbound checks and inbound keywords. Inbound webhooks are verified, stored once per provider message id, and STOP / START / HELP keywords update consent and send a configurable reply through the built-in compliance category, unless the provider already replied. Category rules merge over shipped defaults: transactional and otp send unless opted out, marketing requires opt-in and observes 9pm-8am quiet hours in the recipient's time zone. Rate limits are counted from the deliveries table. Quiet-hour and rate-limit deferrals re-enqueue the action for when it may send. Carrier opt-out errors record an opt-out with source carrier. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01L5SuBiSMN4r9qr5CXbKkHV --- CHANGELOG.md | 11 ++ README.md | 31 ++++- .../smswire/inbound_messages_controller.rb | 45 ++++++ app/models/smswire/consent.rb | 71 ++++++++++ app/models/smswire/inbound_message.rb | 25 ++++ config/routes.rb | 1 + ...e_smswire_consents_and_inbound_messages.rb | 34 +++++ docs/SPEC.md | 40 ++++-- lib/smswire/base.rb | 3 +- lib/smswire/configuration.rb | 86 +++++++++++- lib/smswire/inbound.rb | 11 ++ lib/smswire/keyword_messenger.rb | 12 ++ lib/smswire/keywords.rb | 50 +++++++ lib/smswire/log_subscriber.rb | 11 +- lib/smswire/message.rb | 5 +- lib/smswire/message_delivery.rb | 15 +- lib/smswire/pipeline.rb | 28 ++++ lib/smswire/providers/base.rb | 10 ++ lib/smswire/providers/twilio.rb | 18 +++ lib/smswire/quiet_hours.rb | 45 ++++++ lib/smswire/rate_limit.rb | 27 ++++ lib/smswire/result.rb | 11 +- lib/smswire/sender.rb | 8 +- test/consent_test.rb | 92 +++++++++++++ test/delivery_test.rb | 4 +- test/dummy/app/messengers/order_messenger.rb | 4 + test/inbound_test.rb | 129 ++++++++++++++++++ test/quiet_hours_test.rb | 38 ++++++ test/scheduling_test.rb | 93 +++++++++++++ test/status_callbacks_test.rb | 2 +- test/test_helper.rb | 2 +- test/twilio_test.rb | 1 + 32 files changed, 932 insertions(+), 31 deletions(-) create mode 100644 app/controllers/smswire/inbound_messages_controller.rb create mode 100644 app/models/smswire/consent.rb create mode 100644 app/models/smswire/inbound_message.rb create mode 100644 db/migrate/20261008000001_create_smswire_consents_and_inbound_messages.rb create mode 100644 lib/smswire/inbound.rb create mode 100644 lib/smswire/keyword_messenger.rb create mode 100644 lib/smswire/keywords.rb create mode 100644 lib/smswire/quiet_hours.rb create mode 100644 lib/smswire/rate_limit.rb create mode 100644 test/consent_test.rb create mode 100644 test/inbound_test.rb create mode 100644 test/quiet_hours_test.rb create mode 100644 test/scheduling_test.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 835d139..deb67df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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, diff --git a/README.md b/README.md index ca0fdcd..8308736 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 | diff --git a/app/controllers/smswire/inbound_messages_controller.rb b/app/controllers/smswire/inbound_messages_controller.rb new file mode 100644 index 0000000..a7d85b3 --- /dev/null +++ b/app/controllers/smswire/inbound_messages_controller.rb @@ -0,0 +1,45 @@ +module Smswire + # Receives inbound messages at POST /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 diff --git a/app/models/smswire/consent.rb b/app/models/smswire/consent.rb new file mode 100644 index 0000000..a4503a0 --- /dev/null +++ b/app/models/smswire/consent.rb @@ -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 diff --git a/app/models/smswire/inbound_message.rb b/app/models/smswire/inbound_message.rb new file mode 100644 index 0000000..d623a1f --- /dev/null +++ b/app/models/smswire/inbound_message.rb @@ -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 diff --git a/config/routes.rb b/config/routes.rb index 98ed050..b604bc0 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/db/migrate/20261008000001_create_smswire_consents_and_inbound_messages.rb b/db/migrate/20261008000001_create_smswire_consents_and_inbound_messages.rb new file mode 100644 index 0000000..be8e4ab --- /dev/null +++ b/db/migrate/20261008000001_create_smswire_consents_and_inbound_messages.rb @@ -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 diff --git a/docs/SPEC.md b/docs/SPEC.md index 2856ed3..bcc2901 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -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 @@ -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`. @@ -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 @@ -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 diff --git a/lib/smswire/base.rb b/lib/smswire/base.rb index 801337c..6da55e4 100644 --- a/lib/smswire/base.rb +++ b/lib/smswire/base.rb @@ -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 @@ -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 ) diff --git a/lib/smswire/configuration.rb b/lib/smswire/configuration.rb index 0c5d25a..442b825 100644 --- a/lib/smswire/configuration.rb +++ b/lib/smswire/configuration.rb @@ -1,6 +1,30 @@ module Smswire class Configuration WHITESPACE_MODES = %i[strip squish preserve].freeze + CONSENT_RULES = %i[allow_unless_opted_out require_opted_in none].freeze + + # Shipped category rules. Settings in +categories+ are merged over these + # per key, so overriding one category leaves the others intact. + # Marketing quiet hours follow the US federal TCPA window (8am to 9pm + # recipient time); some states are stricter. + DEFAULT_CATEGORIES = { + 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} + }.freeze + + DEFAULT_KEYWORDS = { + opt_out: %w[STOP STOPALL UNSUBSCRIBE CANCEL END QUIT OPTOUT REVOKE], + opt_in: %w[START YES UNSTOP], + help: %w[HELP INFO] + }.freeze + + DEFAULT_KEYWORD_REPLIES = { + opt_out: "You have been unsubscribed from %{program} messages and will not receive any more. Reply START to resubscribe.", + opt_in: "You are subscribed to %{program} messages again. Reply STOP to unsubscribe, HELP for help.", + help: "%{program}: Reply STOP to unsubscribe. Msg & data rates may apply." + }.freeze PHONE_VALIDATORS = %i[auto phonelib e164].freeze # Provider name used when a message or sender does not name one. @@ -59,9 +83,30 @@ class Configuration # categories: { otp: { store_body: false } }. attr_accessor :store_bodies - # Per-category settings. Phase 2 honors :store_body. + # Per-category settings merged over DEFAULT_CATEGORIES: + # consent: :allow_unless_opted_out, :require_opted_in, or :none + # quiet_hours: "21:00".."08:00" in the recipient's time zone, or nil + # rate_limit: { max: 3, per: 1.day, exceed: :defer } (or :reject) + # store_body: overrides store_bodies attr_accessor :categories + # Check consent before sending and record carrier opt-outs. Turn off + # only when another system, such as Twilio Advanced Opt-Out, owns consent. + attr_accessor :enforce_consent + + # Time zone for quiet hours when the recipient has none. Defaults to + # Time.zone. + attr_accessor :default_time_zone + + # Inbound keyword sets: { opt_out: [...], opt_in: [...], help: [...] }. + attr_accessor :keywords + + # Auto-reply bodies per keyword kind; %{program} is replaced by + # program_name. Set a kind to nil to send no reply. + attr_accessor :keyword_replies + + attr_accessor :program_name + def initialize @default_provider = nil @providers = {} @@ -81,7 +126,12 @@ def initialize @callbacks_url = nil @verify_callback_signatures = true @store_bodies = true - @categories = {otp: {store_body: false}} + @categories = {} + @enforce_consent = true + @default_time_zone = nil + @keywords = DEFAULT_KEYWORDS.transform_values(&:dup) + @keyword_replies = DEFAULT_KEYWORD_REPLIES.dup + @program_name = nil end def phone_validator=(value) @@ -101,7 +151,29 @@ def body_whitespace=(value) end def category_options(category) - (categories[category.to_s.to_sym] || categories[category.to_s] || {}).to_h.symbolize_keys + key = category.to_s.to_sym + custom = (categories[key] || categories[category.to_s] || {}).to_h.symbolize_keys + options = DEFAULT_CATEGORIES.fetch(key, {}).merge(custom) + consent = options.fetch(:consent, :allow_unless_opted_out).to_sym + unless CONSENT_RULES.include?(consent) + raise ConfigurationError, "Unknown consent rule #{consent.inspect} for category #{key}" + end + options.merge(consent:) + end + + def time_zone + zone = default_time_zone || Time.zone || "UTC" + ActiveSupport::TimeZone[zone] || raise(ConfigurationError, "Unknown time zone #{zone.inspect}") + end + + def consent_scopes + scopes = senders.values.filter_map { |options| options.to_h.symbolize_keys[:consent_scope]&.to_s } + (["default"] + scopes).uniq + end + + def keyword_reply(kind) + template = keyword_replies[kind.to_sym] or return nil + format(template, program: program_name || default_program_name) end def store_body?(category) @@ -109,6 +181,14 @@ def store_body?(category) setting.nil? ? store_bodies : setting end + def default_program_name + if defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application + ::Rails.application.class.module_parent_name.titleize + else + "our" + end + end + def provider_options(name) (providers[name.to_sym] || providers[name.to_s] || {}).to_h.symbolize_keys end diff --git a/lib/smswire/inbound.rb b/lib/smswire/inbound.rb new file mode 100644 index 0000000..9fc6c3a --- /dev/null +++ b/lib/smswire/inbound.rb @@ -0,0 +1,11 @@ +module Smswire + # A received message as parsed by a provider adapter. + # + # +provider_keyword+ is true when the provider already handled an opt-out + # keyword and replied itself, as Twilio Advanced Opt-Out does. + Inbound = Data.define(:provider_id, :from, :to, :body, :messaging_service, :provider_keyword, :raw) do + def initialize(provider_id:, from:, to:, body:, messaging_service: nil, provider_keyword: false, raw: {}) + super + end + end +end diff --git a/lib/smswire/keyword_messenger.rb b/lib/smswire/keyword_messenger.rb new file mode 100644 index 0000000..9b2dcc0 --- /dev/null +++ b/lib/smswire/keyword_messenger.rb @@ -0,0 +1,12 @@ +module Smswire + # Sends keyword auto-replies. Uses the :compliance category, which skips + # consent checks so the opt-out confirmation reaches an opted-out number. + class KeywordMessenger < Base + def reply(phone, kind, from: nil, messaging_service: nil, provider: nil) + body = Smswire.config.keyword_reply(kind) or return + + text to: phone, body:, category: :compliance, from: (from unless messaging_service), + messaging_service:, provider: provider&.to_sym + end + end +end diff --git a/lib/smswire/keywords.rb b/lib/smswire/keywords.rb new file mode 100644 index 0000000..d2e040d --- /dev/null +++ b/lib/smswire/keywords.rb @@ -0,0 +1,50 @@ +module Smswire + # Detects STOP / START / HELP style keywords in inbound messages, updates + # consent, and sends the configured auto-reply. + # + # A keyword matches only when it is the whole message, ignoring case, + # surrounding whitespace, and surrounding punctuation, which is how + # carriers and Twilio match them. + module Keywords + STORED = {opt_out: "stop", opt_in: "start", help: "help"}.freeze + + module_function + + # Returns [kind, word] or nil. + def classify(body) + word = body.to_s.strip.gsub(/\A[^[:alnum:]]+|[^[:alnum:]]+\z/, "").upcase + return nil if word.empty? + + Smswire.config.keywords.each do |kind, words| + return [kind.to_sym, word] if words.map(&:upcase).include?(word) + end + nil + end + + def handle(record, inbound, provider:) + kind, word = classify(record.body) + return nil unless kind + + phone = record.from_number + scope = Consent.scope_for_sender(number: record.to_number, messaging_service: record.messaging_service) + case kind + when :opt_out + if word == "STOPALL" + Consent.opt_out_everywhere!(phone, source: :keyword, keyword: word) + else + Consent.opt_out!(phone, scope:, source: :keyword, keyword: word) + end + when :opt_in + Consent.opt_in!(phone, scope:, source: :keyword, keyword: word) + end + + unless inbound.provider_keyword + KeywordMessenger.reply(phone, kind.to_s, from: record.to_number, + messaging_service: record.messaging_service, provider: provider.to_s).deliver_later + end + + record.update!(keyword: STORED.fetch(kind), handled_at: Time.current) + kind + end + end +end diff --git a/lib/smswire/log_subscriber.rb b/lib/smswire/log_subscriber.rb index a7ebda0..34ee417 100644 --- a/lib/smswire/log_subscriber.rb +++ b/lib/smswire/log_subscriber.rb @@ -17,7 +17,8 @@ def deliver(event) def reject(event) payload = event.payload - info { "SMS #{payload[:messenger]}##{payload[:action]} to #{mask(payload[:to])} not sent: #{payload[:reason]}" } + until_time = payload[:resume_at] ? " until #{payload[:resume_at].utc.iso8601}" : "" + info { "SMS #{payload[:messenger]}##{payload[:action]} to #{mask(payload[:to])} not sent: #{payload[:reason]}#{until_time}" } end def status_update(event) @@ -32,6 +33,14 @@ def status_update(event) end end + def inbound(event) + payload = event.payload + info do + keyword = payload[:keyword] ? " keyword #{payload[:keyword]}" : "" + "SMS received via #{payload[:provider]} from #{mask(payload[:from])}#{keyword}#{" (duplicate webhook)" if payload[:duplicate]}" + end + end + def logger Smswire.logger end diff --git a/lib/smswire/message.rb b/lib/smswire/message.rb index 13fdd46..2523c54 100644 --- a/lib/smswire/message.rb +++ b/lib/smswire/message.rb @@ -5,7 +5,7 @@ module Smswire # the pipeline. Interceptors may change any attribute before delivery. class Message attr_accessor :to, :from, :body, :media_urls, :messaging_service, :provider, - :category, :metadata, :validity_period, :messenger, :action, :recipient + :category, :metadata, :validity_period, :messenger, :action, :recipient, :consent_scope attr_reader :raw_to # Set by the pipeline when Smswire.config.callbacks_url is configured. @@ -14,7 +14,7 @@ class Message def initialize(to:, body:, from: nil, media_urls: [], messaging_service: nil, provider: nil, category: nil, metadata: {}, validity_period: nil, idempotency_key: nil, - messenger: nil, action: nil, recipient: nil) + messenger: nil, action: nil, recipient: nil, consent_scope: nil) @to = to @raw_to = to @from = from @@ -29,6 +29,7 @@ def initialize(to:, body:, from: nil, media_urls: [], messaging_service: nil, pr @messenger = messenger @action = action&.to_s @recipient = recipient + @consent_scope = (consent_scope || "default").to_s @perform_deliveries = true end diff --git a/lib/smswire/message_delivery.rb b/lib/smswire/message_delivery.rb index 6655787..9cb6321 100644 --- a/lib/smswire/message_delivery.rb +++ b/lib/smswire/message_delivery.rb @@ -30,12 +30,16 @@ def processed? !@processed_messenger.nil? end - # Run the pipeline now and return a Smswire::Result. + # Run the pipeline now and return a Smswire::Result. A deferred result + # (quiet hours or rate limit) enqueues the action again for +resume_at+, + # so arguments must then be serializable by ActiveJob. def deliver_now message = processed_messenger.message return Result.new(status: :skipped) if message.nil? - Pipeline.call(message) + result = Pipeline.call(message) + enqueue(wait_until: result.resume_at) if result.deferred? && result.resume_at + result end # Enqueue Smswire::DeliveryJob. The action runs again inside the job, @@ -46,12 +50,15 @@ def deliver_later(wait: nil, wait_until: nil, queue: nil, priority: nil) "Changes made to the message would be lost; call deliver_later without reading it first." end - options = {wait:, wait_until:, queue:, priority:}.compact - DeliveryJob.set(options).perform_later(@messenger_class.name, @action.to_s, @params, @args, @kwargs) + enqueue(wait:, wait_until:, queue:, priority:) end private + def enqueue(**options) + DeliveryJob.set(options.compact).perform_later(@messenger_class.name, @action.to_s, @params, @args, @kwargs) + end + def processed_messenger @processed_messenger ||= @messenger_class.new.tap do |messenger| messenger.params = @params diff --git a/lib/smswire/pipeline.rb b/lib/smswire/pipeline.rb index 4a20215..8f0e890 100644 --- a/lib/smswire/pipeline.rb +++ b/lib/smswire/pipeline.rb @@ -25,6 +25,18 @@ def call return reject(:rejected_empty_body) if message.body.blank? && message.media_urls.empty? + rule = Smswire.config.category_options(message.category) + if (status = consent_rejection(rule)) + return reject(status) + end + if (resume_at = QuietHours.resume_at(rule[:quiet_hours], time_zone: QuietHours.time_zone_for(message.recipient))) + return defer(:deferred_quiet_hours, resume_at) + end + if rule[:rate_limit] && (resume_at = RateLimit.resume_at(message, rule[:rate_limit])) + return reject(:rejected_rate_limited) if rule[:rate_limit][:exceed]&.to_sym == :reject + return defer(:deferred_rate_limited, resume_at) + end + provider = Providers.resolve(message.provider) message.lock_idempotency_key! @@ -59,12 +71,28 @@ def deliver(provider, delivery) finish(Result.new(status:, message:, receipt:, delivery:)) rescue PermanentError => error delivery&.record_failure!(error, message) + if error.opted_out? && Smswire.config.enforce_consent + Consent.opt_out!(message.to, scope: message.consent_scope, source: :carrier) + end finish(Result.new(status: :failed, message:, error:, delivery:)) rescue TransientError => error delivery&.release!(error) raise end + def consent_rejection(rule) + return nil if rule[:consent] == :none || !Smswire.config.enforce_consent + + status = Consent.status_for(message.to, scope: message.consent_scope) + return :rejected_opted_out if status == "opted_out" + :rejected_no_consent if rule[:consent] == :require_opted_in && status != "opted_in" + end + + def defer(status, resume_at) + ActiveSupport::Notifications.instrument("reject.smswire", message.to_log_h.merge(reason: status, resume_at:)) + finish(Result.new(status:, message:, resume_at:)) + end + def reject(status) ActiveSupport::Notifications.instrument("reject.smswire", message.to_log_h.merge(reason: status)) finish(Result.new(status:, message:)) diff --git a/lib/smswire/providers/base.rb b/lib/smswire/providers/base.rb index 0b600fa..b44783b 100644 --- a/lib/smswire/providers/base.rb +++ b/lib/smswire/providers/base.rb @@ -25,6 +25,16 @@ def verify_signature!(request, url:) raise NotImplementedError, "#{self.class.name} does not support callbacks" end + # Return a Smswire::Inbound for an inbound message webhook. + def parse_inbound(request) + raise NotImplementedError, "#{self.class.name} does not support inbound messages" + end + + # [body, content_type] to answer an inbound webhook with, or nil for 204. + def inbound_response + nil + end + # Return a Smswire::StatusUpdate for a status callback request. def parse_status_callback(request) raise NotImplementedError, "#{self.class.name} does not support status callbacks" diff --git a/lib/smswire/providers/twilio.rb b/lib/smswire/providers/twilio.rb index d64c08b..dc5d2a0 100644 --- a/lib/smswire/providers/twilio.rb +++ b/lib/smswire/providers/twilio.rb @@ -81,6 +81,24 @@ def parse_status_callback(request) ) end + def parse_inbound(request) + params = request.request_parameters + Inbound.new( + provider_id: params["MessageSid"] || params["SmsSid"], + from: params["From"], + to: params["To"], + body: params["Body"].to_s, + messaging_service: params["MessagingServiceSid"].presence, + provider_keyword: params["OptOutType"].present?, + raw: params.to_h + ) + end + + # Empty TwiML, so Twilio sends no reply of its own. + def inbound_response + ['', "text/xml"] + end + private def handle(response) diff --git a/lib/smswire/quiet_hours.rb b/lib/smswire/quiet_hours.rb new file mode 100644 index 0000000..0b95dee --- /dev/null +++ b/lib/smswire/quiet_hours.rb @@ -0,0 +1,45 @@ +module Smswire + # Quiet-hour windows given as "HH:MM".."HH:MM" in the recipient's local + # time. A window whose start is after its end wraps past midnight. The + # start is inclusive and the end exclusive. + module QuietHours + FORMAT = /\A([01]?\d|2[0-3]):([0-5]\d)\z/ + + module_function + + # Returns when sending may resume, or nil when +now+ is outside the window. + def resume_at(window, time_zone:, now: Time.current) + return nil if window.blank? + + start_minute, end_minute = parse(window) + local = now.in_time_zone(time_zone) + minute = (local.hour * 60) + local.min + quiet = + if start_minute < end_minute + minute >= start_minute && minute < end_minute + else + minute >= start_minute || minute < end_minute + end + return nil unless quiet + + resume = local.change(hour: end_minute / 60, min: end_minute % 60) + (resume > local) ? resume : resume.advance(days: 1) + end + + def parse(window) + unless window.is_a?(Range) + raise ConfigurationError, "quiet_hours must be a Range like \"21:00\"..\"08:00\", got #{window.inspect}" + end + [window.begin, window.end].map do |value| + match = FORMAT.match(value.to_s) or raise ConfigurationError, "Invalid quiet_hours time #{value.inspect}" + (match[1].to_i * 60) + match[2].to_i + end + end + + def time_zone_for(recipient) + zone = recipient&.time_zone + zone = ActiveSupport::TimeZone[zone] if zone.is_a?(String) + zone || Smswire.config.time_zone + end + end +end diff --git a/lib/smswire/rate_limit.rb b/lib/smswire/rate_limit.rb new file mode 100644 index 0000000..e8c2b9c --- /dev/null +++ b/lib/smswire/rate_limit.rb @@ -0,0 +1,27 @@ +module Smswire + # Per-recipient, per-category sliding-window limits counted from + # smswire_deliveries. Failed and suppressed deliveries do not count. + # Concurrent sends can briefly exceed the limit by the number of workers. + module RateLimit + COUNTED = %w[pending queued accepted sent delivered undelivered read].freeze + + module_function + + # Returns when the next send fits the window, or nil when it fits now. + def resume_at(message, rule, now: Time.current) + max = rule.fetch(:max) + per = rule.fetch(:per) + unless Smswire.config.persist_deliveries + raise ConfigurationError, "Rate limits require persist_deliveries" + end + + recent = Delivery.where(to_number: message.to, category: message.category.to_s, status: COUNTED) + .where(Delivery.arel_table[:created_at].gt(now - per)) + count = recent.count + return nil if count < max + + oldest = recent.order(:created_at).offset(count - max).pick(:created_at) + oldest + per + end + end +end diff --git a/lib/smswire/result.rb b/lib/smswire/result.rb index be104a0..8c08c57 100644 --- a/lib/smswire/result.rb +++ b/lib/smswire/result.rb @@ -9,10 +9,15 @@ module Smswire # :suppressed_by_interceptor an interceptor cancelled delivery # :skipped the messenger action did not call +text+ # :duplicate an identical message was sent inside the dedupe window + # :rejected_opted_out the recipient opted out in this consent scope + # :rejected_no_consent the category requires opt-in and there is none + # :rejected_rate_limited over the category rate limit, with exceed: :reject + # :deferred_quiet_hours inside quiet hours; re-enqueued for +resume_at+ + # :deferred_rate_limited over the category rate limit; re-enqueued for +resume_at+ # # +delivery+ is the Smswire::Delivery row when persistence is enabled. - Result = Data.define(:status, :message, :receipt, :error, :delivery) do - def initialize(status:, message: nil, receipt: nil, error: nil, delivery: nil) + Result = Data.define(:status, :message, :receipt, :error, :delivery, :resume_at) do + def initialize(status:, message: nil, receipt: nil, error: nil, delivery: nil, resume_at: nil) super end @@ -28,6 +33,8 @@ def skipped? = status == :skipped def duplicate? = status == :duplicate + def deferred? = status.to_s.start_with?("deferred_") + def provider_id = receipt&.provider_id end end diff --git a/lib/smswire/sender.rb b/lib/smswire/sender.rb index 2a708ee..43d20ea 100644 --- a/lib/smswire/sender.rb +++ b/lib/smswire/sender.rb @@ -1,7 +1,8 @@ module Smswire # A from-number or messaging service, with the provider that owns it. - Sender = Data.define(:number, :messaging_service, :provider) do - def initialize(number: nil, messaging_service: nil, provider: nil) + # +consent_scope+ names the consent program the sender belongs to. + Sender = Data.define(:number, :messaging_service, :provider, :consent_scope) do + def initialize(number: nil, messaging_service: nil, provider: nil, consent_scope: nil) super end @@ -16,7 +17,8 @@ def self.resolve(value) raise ConfigurationError, "Unknown sender :#{value}. Define it in Smswire.config.senders." end options = options.to_h.symbolize_keys - new(number: options[:number], messaging_service: options[:messaging_service], provider: options[:provider]&.to_sym) + new(number: options[:number], messaging_service: options[:messaging_service], + provider: options[:provider]&.to_sym, consent_scope: options[:consent_scope]&.to_s) else raise ConfigurationError, "from: must be a phone number String or a sender Symbol, got #{value.class.name}" end diff --git a/test/consent_test.rb b/test/consent_test.rb new file mode 100644 index 0000000..94a3e9d --- /dev/null +++ b/test/consent_test.rb @@ -0,0 +1,92 @@ +require "test_helper" + +class ConsentTest < Smswire::TestCase + PHONE = "+14155552671" + + def brand_messenger + Class.new(ApplicationMessenger) do + def self.name = "BrandMessenger" + + def note(phone) = text(to: phone, from: :brand_b, body: "Brand B note") + end + end + + def brand_senders + Smswire.config.senders.merge(brand_b: {number: "+15005550001", consent_scope: "brand_b"}) + end + + test "opt in and out with evidence" do + Smswire::Consent.opt_in!("(415) 555-2671", source: :web_form, metadata: {ip: "203.0.113.9", form: "checkout"}) + consent = Smswire::Consent.sole + assert_equal [PHONE, "default", "opted_in", "web_form"], [consent.phone, consent.scope, consent.status, consent.source] + assert consent.opted_in_at + assert_equal "opted_in", Smswire::Consent.status_for(PHONE) + + Smswire::Consent.opt_out!(PHONE, source: :api, metadata: {reason: "support ticket"}) + assert_equal({"ip" => "203.0.113.9", "form" => "checkout", "reason" => "support ticket"}, consent.reload.metadata) + assert_equal "opted_out", consent.status + assert_equal "unknown", Smswire::Consent.status_for("+12125550123") + assert_raises(ArgumentError) { Smswire::Consent.opt_in!("nope", source: :api) } + end + + test "opted-out numbers are refused for every category" do + Smswire::Consent.opt_out!(PHONE, source: :api) + assert_equal :rejected_opted_out, OrderMessenger.literal(PHONE, "hi").deliver_now.status + assert_equal :rejected_opted_out, OrderMessenger.deal(PHONE, "sale").deliver_now.status + assert_empty sms_deliveries + assert_equal 0, Smswire::Delivery.count + end + + test "marketing requires opt-in" do + travel_to Time.utc(2026, 1, 15, 17) do + assert_equal :rejected_no_consent, OrderMessenger.deal(PHONE, "sale").deliver_now.status + Smswire::Consent.opt_in!(PHONE, source: :web_form) + assert OrderMessenger.deal(PHONE, "sale").deliver_now.accepted? + end + end + + test "consent is kept per sender scope" do + with_config(senders: brand_senders) do + Smswire::Consent.opt_out!(PHONE, source: :api) + assert brand_messenger.note(PHONE).deliver_now.accepted? + + Smswire::Consent.opt_out!(PHONE, scope: "brand_b", source: :api) + assert_equal :rejected_opted_out, brand_messenger.note(PHONE).deliver_now.status + end + end + + test "opt_out_everywhere covers every configured scope" do + with_config(senders: brand_senders) do + Smswire::Consent.opt_out_everywhere!(PHONE, source: :keyword, keyword: "STOPALL") + assert_equal %w[brand_b default], Smswire::Consent.where(status: "opted_out").pluck(:scope).sort + end + end + + test "a carrier opt-out error records the opt-out" do + Smswire::Providers::Test.fail_next(Smswire::PermanentError.new("unsubscribed", reason: :opted_out, provider_code: "21610")) + assert OrderMessenger.literal(PHONE, "hi").deliver_now.failed? + + consent = Smswire::Consent.sole + assert_equal ["opted_out", "carrier"], [consent.status, consent.source] + assert_equal :rejected_opted_out, OrderMessenger.literal(PHONE, "again").deliver_now.status + end + + test "enforcement can be turned off" do + Smswire::Consent.opt_out!(PHONE, source: :api) + with_config(enforce_consent: false) do + assert OrderMessenger.literal(PHONE, "hi").deliver_now.accepted? + end + end + + test "category overrides merge over the defaults" do + with_config(categories: {marketing: {quiet_hours: nil}}) do + assert_nil Smswire.config.category_options(:marketing)[:quiet_hours] + assert_equal :require_opted_in, Smswire.config.category_options(:marketing)[:consent] + assert_equal false, Smswire.config.category_options(:otp)[:store_body] + assert_equal :allow_unless_opted_out, Smswire.config.category_options(:custom)[:consent] + end + with_config(categories: {marketing: {consent: :maybe}}) do + assert_raises(Smswire::ConfigurationError) { Smswire.config.category_options(:marketing) } + end + end +end diff --git a/test/delivery_test.rb b/test/delivery_test.rb index d9c5efa..5f952c8 100644 --- a/test/delivery_test.rb +++ b/test/delivery_test.rb @@ -92,10 +92,10 @@ def code(phone, code) = text(to: phone, body: "Code #{code}", idempotency_key: " end test "a permanent failure is recorded and may be retried" do - Smswire::Providers::Test.fail_next(Smswire::PermanentError.new("blocked", reason: :opted_out, provider_code: "21610")) + Smswire::Providers::Test.fail_next(Smswire::PermanentError.new("filtered", reason: :rejected, provider_code: "30007")) failed = send_literal assert failed.failed? - assert_equal ["failed", "21610", "blocked"], [failed.delivery.status, failed.delivery.error_code, failed.delivery.error_message] + assert_equal ["failed", "30007", "filtered"], [failed.delivery.status, failed.delivery.error_code, failed.delivery.error_message] assert failed.delivery.failed_at retried = send_literal diff --git a/test/dummy/app/messengers/order_messenger.rb b/test/dummy/app/messengers/order_messenger.rb index 87a16bc..d175d07 100644 --- a/test/dummy/app/messengers/order_messenger.rb +++ b/test/dummy/app/messengers/order_messenger.rb @@ -20,4 +20,8 @@ def maybe(phone, send:) def literal(phone, body) text to: phone, body: end + + def deal(phone, body) + text to: phone, body:, category: :marketing + end end diff --git a/test/inbound_test.rb b/test/inbound_test.rb new file mode 100644 index 0000000..fbc14c8 --- /dev/null +++ b/test/inbound_test.rb @@ -0,0 +1,129 @@ +require "test_helper" +require "base64" + +class InboundTest < ActionDispatch::IntegrationTest + include Smswire::TestHelper + include ConfigHelpers + + PHONE = "+14155552671" + OUR_NUMBER = "+15005550006" + + setup do + [Smswire::Delivery, Smswire::Consent, Smswire::InboundMessage].each(&:delete_all) + stub_request(:post, twilio_url).to_return(twilio_success) + end + + def receive(body, sid: "SMin#{SecureRandom.hex(4)}", signature: :valid, **extra) + params = {"MessageSid" => sid, "From" => PHONE, "To" => OUR_NUMBER, "Body" => body}.merge(extra.transform_keys(&:to_s)) + url = "http://www.example.com/smswire/inbound/twilio" + if signature == :valid + data = url + params.sort.map { |key, value| "#{key}#{value}" }.join + signature = Base64.strict_encode64(OpenSSL::HMAC.digest("SHA1", "secret", data)) + end + post "/smswire/inbound/twilio", params:, headers: {"X-Twilio-Signature" => signature.to_s} + end + + test "acceptance: STOP flips consent and sends a confirmation" do + receive(" stop ") + + assert_response :ok + assert_equal "text/xml", response.media_type + assert_includes response.body, "" + + consent = Smswire::Consent.sole + assert_equal ["opted_out", "keyword", "STOP"], [consent.status, consent.source, consent.last_keyword] + inbound = Smswire::InboundMessage.sole + assert_equal ["stop", PHONE, OUR_NUMBER], [inbound.keyword, inbound.from_number, inbound.to_number] + assert inbound.handled_at + + assert_enqueued_sms(Smswire::KeywordMessenger, :reply, args: [PHONE, "opt_out"]) + perform_enqueued_jobs + reply = Smswire::Delivery.sole + assert_equal ["compliance", OUR_NUMBER, "twilio", "queued"], [reply.category, reply.from_number, reply.provider, reply.status] + assert_equal "You have been unsubscribed from Dummy messages and will not receive any more. Reply START to resubscribe.", + reply.body + + assert_equal :rejected_opted_out, OrderMessenger.literal(PHONE, "Your order shipped").deliver_now.status + end + + test "START opts back in and HELP replies without changing consent" do + receive("STOP") + receive("Start!") + assert_equal "opted_in", Smswire::Consent.status_for(PHONE) + + receive("help") + assert_equal %w[stop start help], Smswire::InboundMessage.order(:created_at).pluck(:keyword) + assert_equal %w[opt_out opt_in help], Smswire::Testing.enqueued(enqueued_jobs).map { |job| job[:args].last } + end + + test "STOPALL opts out of every scope" do + senders = Smswire.config.senders.merge(brand_b: {number: "+15005550001", consent_scope: "brand_b"}) + with_config(senders:) { receive("STOPALL") } + assert_equal %w[brand_b default], Smswire::Consent.where(status: "opted_out").pluck(:scope).sort + end + + test "STOP to a scoped sender only opts out of that scope" do + senders = Smswire.config.senders.merge(transactional: {number: OUR_NUMBER, consent_scope: "orders"}) + with_config(senders:) { receive("STOP") } + assert_equal [["orders", "opted_out"]], Smswire::Consent.pluck(:scope, :status) + end + + test "keywords must be the whole message" do + receive("please stop texting me") + assert_nil Smswire::InboundMessage.sole.keyword + assert_equal 0, Smswire::Consent.count + assert_no_enqueued_sms + end + + test "webhook retries are processed once" do + 2.times { receive("STOP", sid: "SMsame") } + assert_response :ok + assert_equal 1, Smswire::InboundMessage.count + assert_enqueued_sms(count: 1) + end + + test "a provider-handled opt-out is recorded without a second reply" do + receive("STOP", OptOutType: "STOP") + assert_equal "opted_out", Smswire::Consent.status_for(PHONE) + assert_no_enqueued_sms + end + + test "replies can be turned off per keyword" do + with_config(keyword_replies: {opt_out: nil, opt_in: nil, help: nil}) do + receive("STOP") + perform_enqueued_jobs + end + assert_equal "opted_out", Smswire::Consent.status_for(PHONE) + assert_equal 0, Smswire::Delivery.count + end + + test "custom program name and keywords" do + keywords = Smswire.config.keywords.merge(opt_out: %w[STOP ARRET]) + with_config(program_name: "Acme Alerts", keywords:) do + receive("arrêt".tr("ê", "e")) + perform_enqueued_jobs + end + assert_includes Smswire::Delivery.sole.body, "Acme Alerts" + end + + test "observers receive inbound messages" do + received = [] + observer = Object.new + observer.define_singleton_method(:delivered_sms) { |_result| } + observer.define_singleton_method(:sms_received) { |message| received << message.body } + Smswire.register_observer(observer) + receive("Is my order ready?") + assert_equal ["Is my order ready?"], received + ensure + Smswire.unregister_observer(observer) + end + + test "invalid signatures and unsupported providers are refused" do + receive("STOP", signature: "forged") + assert_response :forbidden + assert_equal 0, Smswire::InboundMessage.count + + post "/smswire/inbound/test", params: {"Body" => "STOP"} + assert_response :not_found + end +end diff --git a/test/quiet_hours_test.rb b/test/quiet_hours_test.rb new file mode 100644 index 0000000..485d779 --- /dev/null +++ b/test/quiet_hours_test.rb @@ -0,0 +1,38 @@ +require "test_helper" + +class QuietHoursTest < Smswire::TestCase + NEW_YORK = ActiveSupport::TimeZone["America/New_York"] + + def resume(window, at, zone = NEW_YORK) = Smswire::QuietHours.resume_at(window, time_zone: zone, now: at) + + test "a window that wraps midnight" do + window = "21:00".."08:00" + assert_equal NEW_YORK.parse("2026-01-16 08:00"), resume(window, NEW_YORK.parse("2026-01-15 22:30")) + assert_equal NEW_YORK.parse("2026-01-15 08:00"), resume(window, NEW_YORK.parse("2026-01-15 06:59")) + assert_equal NEW_YORK.parse("2026-01-16 08:00"), resume(window, NEW_YORK.parse("2026-01-15 21:00")) + assert_nil resume(window, NEW_YORK.parse("2026-01-15 08:00")) + assert_nil resume(window, NEW_YORK.parse("2026-01-15 20:59")) + end + + test "a same-day window" do + window = "12:00".."13:30" + assert_equal NEW_YORK.parse("2026-01-15 13:30"), resume(window, NEW_YORK.parse("2026-01-15 12:15")) + assert_nil resume(window, NEW_YORK.parse("2026-01-15 13:30")) + end + + test "the window is evaluated in the given zone" do + utc_now = Time.utc(2026, 1, 15, 3) # 22:00 in New York, 19:00 in Los Angeles + assert resume("21:00".."08:00", utc_now, NEW_YORK) + assert_nil resume("21:00".."08:00", utc_now, ActiveSupport::TimeZone["America/Los_Angeles"]) + end + + test "resume time survives a daylight saving change" do + assert_equal NEW_YORK.parse("2026-03-08 08:00"), resume("21:00".."08:00", NEW_YORK.parse("2026-03-07 23:00")) + end + + test "invalid windows raise" do + assert_raises(Smswire::ConfigurationError) { resume("21:00-08:00", Time.current) } + assert_raises(Smswire::ConfigurationError) { resume("25:00".."08:00", Time.current) } + assert_nil resume(nil, Time.current) + end +end diff --git a/test/scheduling_test.rb b/test/scheduling_test.rb new file mode 100644 index 0000000..9af50b3 --- /dev/null +++ b/test/scheduling_test.rb @@ -0,0 +1,93 @@ +require "test_helper" + +class SchedulingTest < Smswire::TestCase + PHONE = "+14155552671" + + setup { Smswire::Consent.opt_in!(PHONE, source: :web_form) } + + test "acceptance: a marketing send in quiet hours is re-enqueued for the next window" do + with_config(default_time_zone: "America/New_York") do + travel_to Time.utc(2026, 1, 15, 3) do # 22:00 in New York + result = OrderMessenger.deal(PHONE, "Sale today").deliver_now + assert_equal :deferred_quiet_hours, result.status + assert_equal Time.utc(2026, 1, 15, 13), result.resume_at # 08:00 in New York + assert_empty sms_deliveries + assert_enqueued_sms(OrderMessenger, :deal, args: [PHONE, "Sale today"]) + assert_equal Time.utc(2026, 1, 15, 13).to_f, enqueued_jobs.last[:at] + end + + travel_to Time.utc(2026, 1, 15, 13) do + perform_enqueued_jobs + assert_equal ["Sale today"], sms_deliveries.map(&:body) + end + end + end + + test "a deferred background job re-enqueues itself" do + with_config(default_time_zone: "America/New_York") do + travel_to Time.utc(2026, 1, 15, 3) do + OrderMessenger.deal(PHONE, "Later").deliver_later + perform_enqueued_jobs(only: Smswire::DeliveryJob) + assert_empty sms_deliveries + assert_equal Time.utc(2026, 1, 15, 13).to_f, enqueued_jobs.last[:at] + end + end + end + + test "transactional messages ignore marketing quiet hours" do + with_config(default_time_zone: "America/New_York") do + travel_to(Time.utc(2026, 1, 15, 3)) { assert OrderMessenger.literal(PHONE, "Shipped").deliver_now.accepted? } + end + end + + test "quiet hours use the recipient's time zone" do + recipient = Struct.new(:phone_number, :time_zone).new(PHONE, "America/Los_Angeles") + with_config(default_time_zone: "America/New_York") do + travel_to Time.utc(2026, 1, 15, 3) do # 19:00 in Los Angeles + message = OrderMessenger.deal(recipient, "Sale").message + assert Smswire::Pipeline.call(message).accepted? + end + travel_to Time.utc(2026, 1, 15, 6) do # 22:00 in Los Angeles + message = OrderMessenger.deal(recipient, "Sale 2").message + result = Smswire::Pipeline.call(message) + assert_equal Time.utc(2026, 1, 15, 16), result.resume_at # 08:00 in Los Angeles + end + end + end + + test "rate limits defer once the window is full" do + with_config(categories: {transactional: {rate_limit: {max: 2, per: 1.day}}}) do + travel_to(Time.utc(2026, 1, 15, 9)) { OrderMessenger.literal(PHONE, "one").deliver_now } + travel_to(Time.utc(2026, 1, 15, 10)) { OrderMessenger.literal(PHONE, "two").deliver_now } + travel_to Time.utc(2026, 1, 15, 11) do + result = OrderMessenger.literal(PHONE, "three").deliver_now + assert_equal :deferred_rate_limited, result.status + assert_equal Time.utc(2026, 1, 16, 9), result.resume_at + assert OrderMessenger.literal("+12125550123", "other person").deliver_now.accepted? + end + travel_to(Time.utc(2026, 1, 16, 9)) { assert OrderMessenger.literal(PHONE, "three").deliver_now.accepted? } + end + end + + test "failed sends do not count toward the rate limit" do + with_config(categories: {transactional: {rate_limit: {max: 1, per: 1.hour}}}) do + Smswire::Providers::Test.fail_next(Smswire::PermanentError.new("filtered")) + assert OrderMessenger.literal(PHONE, "one").deliver_now.failed? + assert OrderMessenger.literal(PHONE, "two").deliver_now.accepted? + end + end + + test "rate limits can reject instead of deferring" do + with_config(categories: {transactional: {rate_limit: {max: 1, per: 1.hour, exceed: :reject}}}) do + OrderMessenger.literal(PHONE, "one").deliver_now + assert_equal :rejected_rate_limited, OrderMessenger.literal(PHONE, "two").deliver_now.status + assert_no_enqueued_sms + end + end + + test "rate limits require persistence" do + with_config(persist_deliveries: false, categories: {transactional: {rate_limit: {max: 1, per: 1.hour}}}) do + assert_raises(Smswire::ConfigurationError) { OrderMessenger.literal(PHONE, "one").deliver_now } + end + end +end diff --git a/test/status_callbacks_test.rb b/test/status_callbacks_test.rb index a2efa1e..34ecdab 100644 --- a/test/status_callbacks_test.rb +++ b/test/status_callbacks_test.rb @@ -5,7 +5,7 @@ class StatusCallbacksTest < ActionDispatch::IntegrationTest include Smswire::TestHelper include ConfigHelpers - setup { Smswire::Delivery.delete_all } + setup { [Smswire::Delivery, Smswire::Consent, Smswire::InboundMessage].each(&:delete_all) } def sign(url, params, token: "secret") data = url + params.sort.map { |key, value| "#{key}#{value}" }.join diff --git a/test/test_helper.rb b/test/test_helper.rb index 036d77f..b2572d2 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -36,5 +36,5 @@ class Smswire::TestCase < ActiveSupport::TestCase include Smswire::TestHelper include ConfigHelpers - setup { Smswire::Delivery.delete_all } + setup { [Smswire::Delivery, Smswire::Consent, Smswire::InboundMessage].each(&:delete_all) } end diff --git a/test/twilio_test.rb b/test/twilio_test.rb index 144b7c9..4917a52 100644 --- a/test/twilio_test.rb +++ b/test/twilio_test.rb @@ -77,6 +77,7 @@ def photo(phone) assert_equal reason, result.error.reason assert_equal code.to_s, result.error.provider_code assert_equal 400, result.error.http_status + Smswire::Consent.delete_all end end