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
@@ -0,0 +1,29 @@
1
+ require "test_helper"
2
+ require "webmock/minitest"
3
+
4
+ class <%= provider_class %>Test < ActiveSupport::TestCase
5
+ include Smswire::TestHelper
6
+ include Smswire::ProviderContract
7
+
8
+ def contract_provider_name = :<%= file_name %>
9
+
10
+ def contract_provider_options = {api_key: "test-key"}
11
+
12
+ # Stub the provider API for each contract outcome. Update the error
13
+ # payloads to the provider's real responses.
14
+ def stub_contract_send(outcome, message)
15
+ stub = stub_request(:post, <%= provider_class %>::API_URL)
16
+ error = ->(status, code) { stub.to_return(status:, body: {error: {code:}}.to_json) }
17
+ case outcome
18
+ when :success
19
+ stub.with(body: hash_including("to" => message.to, "body" => message.body))
20
+ .to_return(status: 200, body: {id: "msg_1"}.to_json)
21
+ when :invalid_number then error.call(400, "invalid_number")
22
+ when :opted_out then error.call(400, "opted_out")
23
+ when :authentication then stub.to_return(status: 401)
24
+ when :throttled then stub.to_return(status: 429)
25
+ when :server_error then stub.to_return(status: 503)
26
+ when :timeout then stub.to_timeout
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,123 @@
1
+ require "abstract_controller"
2
+ require "action_view"
3
+
4
+ module Smswire
5
+ # Base class for messengers, modeled on ActionMailer::Base.
6
+ #
7
+ # class OrderMessenger < Smswire::Base
8
+ # default from: :transactional
9
+ #
10
+ # def shipped(user)
11
+ # @order = params[:order]
12
+ # text to: user # renders app/views/order_messenger/shipped.text.erb
13
+ # end
14
+ # end
15
+ #
16
+ # OrderMessenger.with(order:).shipped(user).deliver_later
17
+ class Base < AbstractController::Base
18
+ include AbstractController::Rendering
19
+ include AbstractController::Logger
20
+ include AbstractController::Helpers
21
+ include AbstractController::Translation
22
+ include AbstractController::Callbacks
23
+ include ActionView::Layouts
24
+
25
+ abstract!
26
+
27
+ TEXT_OPTIONS = %i[category provider messaging_service media_urls metadata idempotency_key validity_period consent_scope].freeze
28
+ DEFAULT_KEYS = (%i[from] + TEXT_OPTIONS - %i[media_urls idempotency_key]).freeze
29
+
30
+ class_attribute :default_params, default: {}.freeze
31
+
32
+ class << self
33
+ # Class-level defaults for every message this messenger builds.
34
+ # Supported keys: from, category, provider, messaging_service,
35
+ # metadata, validity_period.
36
+ def default(value = nil)
37
+ if value
38
+ unknown = value.keys.map(&:to_sym) - DEFAULT_KEYS
39
+ raise ArgumentError, "Unknown default(s): #{unknown.join(", ")}" if unknown.any?
40
+ self.default_params = default_params.merge(value.symbolize_keys).freeze
41
+ end
42
+ default_params
43
+ end
44
+ alias_method :default_options=, :default
45
+
46
+ def with(params)
47
+ ParameterizedMessenger.new(self, params)
48
+ end
49
+
50
+ def messenger_name
51
+ controller_path
52
+ end
53
+
54
+ def respond_to_missing?(name, include_all = false)
55
+ action_methods.include?(name.to_s) || super
56
+ end
57
+
58
+ private
59
+
60
+ def method_missing(name, *args, **kwargs, &block)
61
+ if action_methods.include?(name.to_s)
62
+ MessageDelivery.new(self, name, args:, kwargs:)
63
+ else
64
+ super
65
+ end
66
+ end
67
+ end
68
+
69
+ attr_writer :params
70
+
71
+ def params
72
+ @params ||= {}
73
+ end
74
+
75
+ # The message built by the last processed action, or nil when the
76
+ # action returned without calling +text+.
77
+ def message
78
+ @_message if @_message_was_called
79
+ end
80
+
81
+ def process(method_name, *args, **kwargs)
82
+ @_message_was_called = false
83
+ @_message = nil
84
+ super
85
+ end
86
+
87
+ def messenger_name
88
+ self.class.messenger_name
89
+ end
90
+
91
+ # Build the message for this action. When +body+ is omitted the body is
92
+ # rendered from app/views/<messenger_name>/<action>.text.erb.
93
+ def text(to:, body: nil, from: nil, **options)
94
+ unknown = options.keys - TEXT_OPTIONS
95
+ raise ArgumentError, "Unknown option(s) for text: #{unknown.join(", ")}" if unknown.any?
96
+
97
+ settings = self.class.default_params.merge(options)
98
+ sender = Sender.resolve(from || settings[:from])
99
+ recipient = Recipient.resolve(to)
100
+ content = body.nil? ? render_to_string(action: action_name, formats: [:text]) : body
101
+
102
+ @_message_was_called = true
103
+ @_message = Message.new(
104
+ to: recipient.phone,
105
+ recipient:,
106
+ from: sender.number,
107
+ messaging_service: settings[:messaging_service] || sender.messaging_service,
108
+ provider: settings[:provider] || sender.provider,
109
+ body: BodyFormatter.call(content),
110
+ media_urls: settings[:media_urls],
111
+ category: settings[:category],
112
+ metadata: (settings[:metadata] || {}).merge(params.fetch(:smswire_metadata, {})),
113
+ validity_period: settings[:validity_period],
114
+ idempotency_key: settings[:idempotency_key],
115
+ consent_scope: settings[:consent_scope] || sender.consent_scope,
116
+ messenger: self.class.name,
117
+ action: action_name
118
+ )
119
+ end
120
+
121
+ ActiveSupport.run_load_hooks(:smswire, self)
122
+ end
123
+ end
@@ -0,0 +1,14 @@
1
+ module Smswire
2
+ module BodyFormatter
3
+ module_function
4
+
5
+ def call(text, mode = Smswire.config.body_whitespace)
6
+ text = text.to_s
7
+ case mode
8
+ when :squish then text.squish
9
+ when :preserve then text
10
+ else text.gsub(/[ \t]+$/, "").gsub(/\n{3,}/, "\n\n").strip
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,24 @@
1
+ module Smswire
2
+ # Builds callback URLs from Smswire.config.callbacks_url and reconstructs
3
+ # the public URL of an incoming callback for signature checks.
4
+ module Callbacks
5
+ module_function
6
+
7
+ def status_url(provider, delivery = nil)
8
+ base = Smswire.config.callbacks_url.presence or return nil
9
+ url = "#{base.chomp("/")}/status/#{provider}"
10
+ delivery ? "#{url}?delivery_id=#{delivery.id}" : url
11
+ end
12
+
13
+ # The URL the provider signed. Behind a TLS-terminating proxy the
14
+ # request may look like plain HTTP, so when callbacks_url is configured
15
+ # its scheme, host, and port are used with the request path.
16
+ def request_url(request)
17
+ base = Smswire.config.callbacks_url.presence or return request.original_url
18
+ uri = URI(base)
19
+ origin = "#{uri.scheme}://#{uri.host}"
20
+ origin += ":#{uri.port}" unless uri.port == uri.default_port
21
+ origin + request.original_fullpath
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,224 @@
1
+ module Smswire
2
+ class Configuration
3
+ WHITESPACE_MODES = %i[strip squish preserve].freeze
4
+ CONSENT_RULES = %i[allow_unless_opted_out require_opted_in none].freeze
5
+
6
+ # Shipped category rules. Settings in +categories+ are merged over these
7
+ # per key, so overriding one category leaves the others intact.
8
+ # Marketing quiet hours follow the US federal TCPA window (8am to 9pm
9
+ # recipient time); some states are stricter.
10
+ DEFAULT_CATEGORIES = {
11
+ otp: {consent: :allow_unless_opted_out, store_body: false},
12
+ transactional: {consent: :allow_unless_opted_out},
13
+ marketing: {consent: :require_opted_in, quiet_hours: "21:00".."08:00"},
14
+ compliance: {consent: :none}
15
+ }.freeze
16
+
17
+ DEFAULT_KEYWORDS = {
18
+ opt_out: %w[STOP STOPALL UNSUBSCRIBE CANCEL END QUIT OPTOUT REVOKE],
19
+ opt_in: %w[START YES UNSTOP],
20
+ help: %w[HELP INFO]
21
+ }.freeze
22
+
23
+ # CTIA guidance: the opt-out confirmation names the brand, confirms the
24
+ # removal, and says no further messages will be sent; the HELP reply
25
+ # names the brand and a support contact.
26
+ DEFAULT_KEYWORD_REPLIES = {
27
+ opt_out: "%{program}: You are unsubscribed and will no longer receive any further messages. Reply START to resubscribe.",
28
+ opt_in: "%{program}: You are subscribed again. Reply HELP for help, STOP to unsubscribe. Msg & data rates may apply.",
29
+ help: "%{program}: For help, contact %{contact}. Reply STOP to unsubscribe. Msg & data rates may apply."
30
+ }.freeze
31
+ HELP_WITHOUT_CONTACT = "%{program}: Reply STOP to unsubscribe. Msg & data rates may apply.".freeze
32
+ PHONE_VALIDATORS = %i[auto phonelib e164].freeze
33
+
34
+ # Provider name used when a message or sender does not name one.
35
+ # The Railtie defaults this to :log in development and :test in test.
36
+ attr_accessor :default_provider
37
+
38
+ # Per-provider options, e.g. { twilio: { account_sid: "...", auth_token: "..." } }.
39
+ attr_accessor :providers
40
+
41
+ # Named senders, e.g. { transactional: { number: "+1...", provider: :twilio } }.
42
+ attr_accessor :senders
43
+
44
+ # Callable that turns a recipient object into a phone number string.
45
+ # When nil, objects responding to +phone_number+ are used.
46
+ attr_accessor :recipient_resolver
47
+
48
+ # ISO 3166 region used to parse numbers written in national format.
49
+ attr_accessor :default_region
50
+
51
+ # :auto uses phonelib when it is loaded, :phonelib requires it, and
52
+ # :e164 uses the built-in validator (E.164 plus NANP national formats).
53
+ attr_reader :phone_validator
54
+
55
+ attr_accessor :default_category
56
+
57
+ # Total attempts for transient errors in Smswire::DeliveryJob,
58
+ # counting the first.
59
+ attr_accessor :retry_attempts
60
+
61
+ attr_accessor :deliver_later_queue
62
+
63
+ # How rendered and literal bodies are cleaned before sending.
64
+ # :strip removes trailing spaces on each line, collapses three or more
65
+ # newlines to two, and strips the ends. :squish collapses all
66
+ # whitespace to single spaces. :preserve leaves the body untouched.
67
+ attr_reader :body_whitespace
68
+
69
+ attr_accessor :interceptors, :observers, :logger
70
+
71
+ # Record every send in smswire_deliveries. Requires the engine migrations.
72
+ attr_accessor :persist_deliveries
73
+
74
+ # Identical messages (same messenger, action, recipient, and body, or the
75
+ # same explicit idempotency_key) inside this window are refused as
76
+ # :duplicate. nil disables deduplication.
77
+ attr_accessor :dedupe_window
78
+
79
+ # Public base URL where Smswire::Engine is mounted, e.g.
80
+ # "https://app.example.com/smswire". When set, providers that support
81
+ # status callbacks are told to post to "<callbacks_url>/status/<provider>".
82
+ attr_accessor :callbacks_url
83
+
84
+ attr_accessor :verify_callback_signatures
85
+
86
+ # Store message bodies on deliveries. Overridden per category with
87
+ # categories: { otp: { store_body: false } }.
88
+ attr_accessor :store_bodies
89
+
90
+ # Per-category settings merged over DEFAULT_CATEGORIES:
91
+ # consent: :allow_unless_opted_out, :require_opted_in, or :none
92
+ # quiet_hours: "21:00".."08:00" in the recipient's time zone, or nil
93
+ # rate_limit: { max: 3, per: 1.day, exceed: :defer } (or :reject)
94
+ # store_body: overrides store_bodies
95
+ attr_accessor :categories
96
+
97
+ # Check consent before sending and record carrier opt-outs. Turn off
98
+ # only when another system, such as Twilio Advanced Opt-Out, owns consent.
99
+ attr_accessor :enforce_consent
100
+
101
+ # Time zone for quiet hours when the recipient has none. Defaults to
102
+ # Time.zone.
103
+ attr_accessor :default_time_zone
104
+
105
+ # Inbound keyword sets: { opt_out: [...], opt_in: [...], help: [...] }.
106
+ attr_accessor :keywords
107
+
108
+ # Auto-reply bodies per keyword kind; %{program} is replaced by
109
+ # program_name. Set a kind to nil to send no reply.
110
+ attr_accessor :keyword_replies
111
+
112
+ attr_accessor :program_name
113
+
114
+ # Phone number, email, or URL for customer care, used in HELP replies
115
+ # as %{contact}. CTIA guidance expects one.
116
+ attr_accessor :support_contact
117
+
118
+ # When true, STOP to any sender opts the number out of every consent
119
+ # scope, as STOPALL does. The FCC rule applying a revocation to all of a
120
+ # sender's unrelated messages is delayed to January 31, 2027.
121
+ attr_accessor :opt_out_all_scopes
122
+
123
+ # Development tools served by the engine. Both default to true in
124
+ # development only. Set them in config/application.rb or an environment
125
+ # file as config.smswire.show_previews, since preview paths are
126
+ # registered for autoloading before initializers run.
127
+ attr_accessor :show_previews, :show_inbox, :preview_paths
128
+
129
+ def initialize
130
+ @default_provider = nil
131
+ @providers = {}
132
+ @senders = {}
133
+ @recipient_resolver = nil
134
+ @default_region = "US"
135
+ @phone_validator = :auto
136
+ @default_category = :transactional
137
+ @retry_attempts = 5
138
+ @deliver_later_queue = :default
139
+ @body_whitespace = :strip
140
+ @interceptors = []
141
+ @observers = []
142
+ @logger = nil
143
+ @persist_deliveries = true
144
+ @dedupe_window = 10.minutes
145
+ @callbacks_url = nil
146
+ @verify_callback_signatures = true
147
+ @store_bodies = true
148
+ @categories = {}
149
+ @enforce_consent = true
150
+ @default_time_zone = nil
151
+ @keywords = DEFAULT_KEYWORDS.transform_values(&:dup)
152
+ @keyword_replies = DEFAULT_KEYWORD_REPLIES.dup
153
+ @program_name = nil
154
+ @support_contact = nil
155
+ @opt_out_all_scopes = false
156
+ @show_previews = false
157
+ @show_inbox = false
158
+ @preview_paths = []
159
+ end
160
+
161
+ def phone_validator=(value)
162
+ value = value.to_sym
163
+ unless PHONE_VALIDATORS.include?(value)
164
+ raise ConfigurationError, "phone_validator must be one of #{PHONE_VALIDATORS.join(", ")}"
165
+ end
166
+ @phone_validator = value
167
+ end
168
+
169
+ def body_whitespace=(value)
170
+ value = value.to_sym
171
+ unless WHITESPACE_MODES.include?(value)
172
+ raise ConfigurationError, "body_whitespace must be one of #{WHITESPACE_MODES.join(", ")}"
173
+ end
174
+ @body_whitespace = value
175
+ end
176
+
177
+ def category_options(category)
178
+ key = category.to_s.to_sym
179
+ custom = (categories[key] || categories[category.to_s] || {}).to_h.symbolize_keys
180
+ options = DEFAULT_CATEGORIES.fetch(key, {}).merge(custom)
181
+ consent = options.fetch(:consent, :allow_unless_opted_out).to_sym
182
+ unless CONSENT_RULES.include?(consent)
183
+ raise ConfigurationError, "Unknown consent rule #{consent.inspect} for category #{key}"
184
+ end
185
+ options.merge(consent:)
186
+ end
187
+
188
+ def time_zone
189
+ zone = default_time_zone || Time.zone || "UTC"
190
+ ActiveSupport::TimeZone[zone] || raise(ConfigurationError, "Unknown time zone #{zone.inspect}")
191
+ end
192
+
193
+ def consent_scopes
194
+ scopes = senders.values.filter_map { |options| options.to_h.symbolize_keys[:consent_scope]&.to_s }
195
+ (["default"] + scopes).uniq
196
+ end
197
+
198
+ def keyword_reply(kind)
199
+ template = keyword_replies[kind.to_sym] or return nil
200
+ if template.include?("%{contact}") && support_contact.blank?
201
+ Smswire.logger.warn("[Smswire] Set config.support_contact; HELP replies should include a support contact.")
202
+ template = HELP_WITHOUT_CONTACT if template == DEFAULT_KEYWORD_REPLIES[:help]
203
+ end
204
+ format(template, program: program_name || default_program_name, contact: support_contact.to_s)
205
+ end
206
+
207
+ def store_body?(category)
208
+ setting = category_options(category)[:store_body]
209
+ setting.nil? ? store_bodies : setting
210
+ end
211
+
212
+ def default_program_name
213
+ if defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
214
+ ::Rails.application.class.module_parent_name.titleize
215
+ else
216
+ "our"
217
+ end
218
+ end
219
+
220
+ def provider_options(name)
221
+ (providers[name.to_sym] || providers[name.to_s] || {}).to_h.symbolize_keys
222
+ end
223
+ end
224
+ end
@@ -0,0 +1,29 @@
1
+ module Smswire
2
+ # Delivers a messenger action in the background. Transient errors are
3
+ # retried with polynomial backoff up to Smswire.config.retry_attempts
4
+ # total attempts. Throttled errors wait for the provider's Retry-After.
5
+ # Permanent errors are recorded as :failed results and not retried.
6
+ class DeliveryJob < ActiveJob::Base
7
+ queue_as { Smswire.config.deliver_later_queue }
8
+
9
+ rescue_from TransientError do |error|
10
+ raise error if executions >= Smswire.config.retry_attempts
11
+
12
+ retry_job(wait: retry_wait(error))
13
+ end
14
+
15
+ def perform(messenger_name, action, params, args, kwargs)
16
+ MessageDelivery.new(messenger_name.constantize, action, args:, kwargs:, params:).deliver_now
17
+ end
18
+
19
+ private
20
+
21
+ def retry_wait(error)
22
+ if error.is_a?(ThrottledError) && error.retry_after
23
+ error.retry_after.seconds
24
+ else
25
+ ((executions**4) + 2).seconds
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,52 @@
1
+ require "rails/engine"
2
+ require "abstract_controller/railties/routes_helpers"
3
+
4
+ module Smswire
5
+ # Mount for status callbacks:
6
+ #
7
+ # mount Smswire::Engine => "/smswire"
8
+ #
9
+ # Install the migrations with +bin/rails smswire:install:migrations+.
10
+ class Engine < ::Rails::Engine
11
+ isolate_namespace Smswire
12
+
13
+ ENVIRONMENT_PROVIDERS = {development: :log, test: :test}.freeze
14
+
15
+ config.smswire = ActiveSupport::OrderedOptions.new
16
+
17
+ # Like Action Mailer: register preview directories for autoloading
18
+ # before Rails builds its autoload paths. Declared first because Rails
19
+ # chains an engine's initializers in declaration order.
20
+ initializer "smswire.preview_paths", before: :set_autoload_paths do |app|
21
+ options = app.config.smswire
22
+ development = ::Rails.env.development?
23
+ options.show_previews = development if options.show_previews.nil?
24
+ options.show_inbox = development if options.show_inbox.nil?
25
+ options.preview_paths ||= %w[test/messengers/previews spec/messengers/previews].map { |path| "#{app.root}/#{path}" }
26
+ app.config.paths.add "smswire/previews", with: options.preview_paths, autoload: true
27
+ end
28
+
29
+ # Runs after config/initializers so app settings win over defaults.
30
+ initializer "smswire.configure", after: :load_config_initializers do |app|
31
+ app.config.smswire.each { |key, value| Smswire.config.public_send(:"#{key}=", value) }
32
+ Smswire.config.default_provider ||= ENVIRONMENT_PROVIDERS[::Rails.env.to_sym]
33
+ Smswire.config.logger ||= ::Rails.logger
34
+ end
35
+
36
+ config.after_initialize do
37
+ require_relative "noticed_delivery_method" if defined?(::Noticed::DeliveryMethod)
38
+ end
39
+
40
+ initializer "smswire.log_subscriber" do
41
+ Smswire::LogSubscriber.attach_to :smswire
42
+ end
43
+
44
+ initializer "smswire.messenger" do |app|
45
+ ActiveSupport.on_load(:smswire) do
46
+ self.view_paths = ["#{::Rails.root}/app/views"]
47
+ extend ::AbstractController::Railties::RoutesHelpers.with(app.routes, false)
48
+ include app.routes.mounted_helpers
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,59 @@
1
+ module Smswire
2
+ class Error < StandardError; end
3
+
4
+ # Raised for missing or invalid configuration: no provider, unknown sender,
5
+ # missing credentials. Never retried.
6
+ class ConfigurationError < Error; end
7
+
8
+ # Raised when +text to:+ is given something that cannot produce a phone
9
+ # number. Raised while building the message so it surfaces in tests.
10
+ class UnresolvableRecipient < Error; end
11
+
12
+ # Raised when a provider callback fails signature verification.
13
+ class SignatureError < Error; end
14
+
15
+ # Base class for errors raised by provider adapters.
16
+ class DeliveryError < Error
17
+ attr_reader :provider, :provider_code, :http_status, :raw
18
+
19
+ def initialize(message = nil, provider: nil, provider_code: nil, http_status: nil, raw: nil)
20
+ super(message)
21
+ @provider = provider
22
+ @provider_code = provider_code
23
+ @http_status = http_status
24
+ @raw = raw
25
+ end
26
+ end
27
+
28
+ # Network failures, timeouts, and provider 5xx responses. Retried by
29
+ # Smswire::DeliveryJob and re-raised from +deliver_now+.
30
+ class TransientError < DeliveryError; end
31
+
32
+ # Provider rate limiting. Retried after +retry_after+ seconds when known.
33
+ class ThrottledError < TransientError
34
+ attr_reader :retry_after
35
+
36
+ def initialize(message = nil, retry_after: nil, **options)
37
+ super(message, **options)
38
+ @retry_after = retry_after
39
+ end
40
+ end
41
+
42
+ # The provider refused the message and retrying will not help. The
43
+ # pipeline turns this into a +:failed+ result instead of raising.
44
+ #
45
+ # +reason+ is one of :invalid_number, :opted_out, :unroutable,
46
+ # :authentication, or :rejected.
47
+ class PermanentError < DeliveryError
48
+ attr_reader :reason
49
+
50
+ def initialize(message = nil, reason: :rejected, **options)
51
+ super(message, **options)
52
+ @reason = reason
53
+ end
54
+
55
+ def opted_out?
56
+ reason == :opted_out
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,65 @@
1
+ require "net/http"
2
+ require "openssl"
3
+ require "uri"
4
+ require "json"
5
+
6
+ module Smswire
7
+ # Minimal HTTP client shared by provider adapters. Network failures are
8
+ # raised as Smswire::TransientError. Bodies are never logged.
9
+ module HTTP
10
+ NETWORK_ERRORS = [
11
+ Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout,
12
+ Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH, Errno::ETIMEDOUT,
13
+ EOFError, IOError, SocketError, OpenSSL::SSL::SSLError
14
+ ].freeze
15
+
16
+ module_function
17
+
18
+ def post_form(url, pairs, basic_auth: nil, headers: {}, open_timeout: 5, read_timeout: 15, provider: nil)
19
+ uri = URI(url)
20
+ request = Net::HTTP::Post.new(uri, {"User-Agent" => "smswire/#{Smswire::VERSION}"}.merge(headers))
21
+ request.basic_auth(*basic_auth) if basic_auth
22
+ request.content_type = "application/x-www-form-urlencoded"
23
+ request.body = URI.encode_www_form(pairs)
24
+
25
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout:, read_timeout:) do |http|
26
+ http.request(request)
27
+ end
28
+ rescue *NETWORK_ERRORS => error
29
+ raise TransientError.new("#{error.class}: #{error.message}", provider:)
30
+ end
31
+
32
+ def post_json(url, payload, headers: {}, open_timeout: 5, read_timeout: 15, provider: nil)
33
+ uri = URI(url)
34
+ request = Net::HTTP::Post.new(uri, {"User-Agent" => "smswire/#{Smswire::VERSION}",
35
+ "Accept" => "application/json"}.merge(headers))
36
+ request.content_type = "application/json"
37
+ request.body = JSON.generate(payload)
38
+
39
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout:, read_timeout:) do |http|
40
+ http.request(request)
41
+ end
42
+ rescue *NETWORK_ERRORS => error
43
+ raise TransientError.new("#{error.class}: #{error.message}", provider:)
44
+ end
45
+
46
+ def get(url, basic_auth: nil, headers: {}, open_timeout: 5, read_timeout: 15, provider: nil)
47
+ uri = URI(url)
48
+ request = Net::HTTP::Get.new(uri, {"User-Agent" => "smswire/#{Smswire::VERSION}",
49
+ "Accept" => "application/json"}.merge(headers))
50
+ request.basic_auth(*basic_auth) if basic_auth
51
+
52
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout:, read_timeout:) do |http|
53
+ http.request(request)
54
+ end
55
+ rescue *NETWORK_ERRORS => error
56
+ raise TransientError.new("#{error.class}: #{error.message}", provider:)
57
+ end
58
+
59
+ def parse_json(body)
60
+ JSON.parse(body.to_s)
61
+ rescue JSON::ParserError
62
+ {}
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,11 @@
1
+ module Smswire
2
+ # A received message as parsed by a provider adapter.
3
+ #
4
+ # +provider_keyword+ is true when the provider already handled an opt-out
5
+ # keyword and replied itself, as Twilio Advanced Opt-Out does.
6
+ Inbound = Data.define(:provider_id, :from, :to, :body, :messaging_service, :provider_keyword, :raw) do
7
+ def initialize(provider_id:, from:, to:, body:, messaging_service: nil, provider_keyword: false, raw: {})
8
+ super
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,29 @@
1
+ module Smswire
2
+ # Records an inbound message once, handles compliance keywords, and
3
+ # notifies observers. Shared by the webhook endpoint and the development
4
+ # inbox.
5
+ module InboundHandler
6
+ module_function
7
+
8
+ # Returns the new Smswire::InboundMessage, or nil for a repeated webhook.
9
+ def call(inbound, provider:)
10
+ payload = {provider:, provider_id: inbound.provider_id, from: inbound.from}
11
+ ActiveSupport::Notifications.instrument("inbound.smswire", payload) do
12
+ record = InboundMessage.receive(inbound, provider:)
13
+ payload[:duplicate] = record.nil?
14
+ if record
15
+ payload[:keyword] = Keywords.handle(record, inbound, provider:) if Smswire.config.enforce_consent
16
+ notify_observers(record)
17
+ end
18
+ record
19
+ end
20
+ end
21
+
22
+ def notify_observers(record)
23
+ Smswire.config.observers.each do |observer|
24
+ observer = observer.is_a?(String) ? observer.constantize : observer
25
+ observer.sms_received(record) if observer.respond_to?(:sms_received)
26
+ end
27
+ end
28
+ end
29
+ end