sms_opener 0.1.1

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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 68eca8fb2a67758906029a23d8f55f5fd2addf948b17a2e64e95840c886813be
4
+ data.tar.gz: 16bcb4329919ba8492105e93fd74da3e136e597ca647f1c31ac912fcfa242667
5
+ SHA512:
6
+ metadata.gz: 1c6d613f42706f7eed39dc383e818cc7b34cac70f71184e038716e5c70eceae49cc46faf460ea7089c1d6e5d8c0676388a334c7bb72e9cfbfff40ad4b6335ec8
7
+ data.tar.gz: ba01a21a56f80dc0f5ca985c37f2fd3928e984dafc11735c5e5f226c441e5c09f333cc5cea8078f9c60b2992b3f5fdf6e419b1b3abe8218d95d82b7c3e001890
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Scott Stubblebine
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,154 @@
1
+ # SmsOpener
2
+
3
+ Preview SMS and MMS messages in the browser instead of sending them in Ruby and Rails development environments.
4
+
5
+ Inspired by [`letter_opener`](https://github.com/ryanb/letter_opener) and [`letter_opener_web`](https://github.com/fgrehm/letter_opener_web), **`sms_opener`** provides:
6
+
7
+ 1. **Automatic Browser Previews (via [`launchy`](https://github.com/copiousfreetime/launchy))**: Opens a mobile phone preview of every outgoing text message in your default browser as soon as it is sent.
8
+ 2. **Mountable Rails Engine Web Inbox (`SmsOpener::Engine`)**: Browse, search, inspect, send test texts, and clear captured SMS/MMS messages at `/sms_opener`.
9
+ 3. **Zero-Config `twilio-ruby` Interception**: Automatically intercepts `Twilio::REST::Client#messages.create` in development without making network requests or requiring real Twilio credentials.
10
+ 4. **Direct Ruby API & Delivery Adapter**: Call `SmsOpener.deliver(to:, from:, body:)` or `SmsOpener::DeliveryMethod.new.deliver!(...)` from any custom SMS service object or adapter.
11
+ 5. **Developer Conveniences**:
12
+ - **1-Click OTP Detection**: Automatically extracts 4–8 digit verification/2FA codes with a one-click copy button.
13
+ - **Clickable Magic Links**: Automatically extracts and links URLs inside SMS bodies.
14
+ - **Segment & Encoding Inspector**: Calculates `GSM-7` vs `UCS-2` encoding, character count, and multi-segment boundaries (`160`/`153` vs `70`/`67`).
15
+
16
+ ---
17
+
18
+ ## Installation
19
+
20
+ Add `sms_opener` to your `Gemfile` under the `:development` group:
21
+
22
+ ```ruby
23
+ group :development do
24
+ gem "sms_opener"
25
+ end
26
+ ```
27
+
28
+ Then run:
29
+
30
+ ```bash
31
+ bundle install
32
+ ```
33
+
34
+ ---
35
+
36
+ ## Usage
37
+
38
+ ### 1. Automatic `twilio-ruby` Interception
39
+
40
+ If your application uses `twilio-ruby`, `sms_opener` automatically hooks into `Twilio::REST::Api::V2010::AccountContext::MessageList#create`.
41
+
42
+ ```ruby
43
+ client = Twilio::REST::Client.new(ENV["TWILIO_ACCOUNT_SID"], ENV["TWILIO_AUTH_TOKEN"])
44
+
45
+ message = client.messages.create(
46
+ to: "+15558675309",
47
+ from: "+15550001234",
48
+ body: "Your verification code is 849201"
49
+ )
50
+
51
+ message.sid # => "SM..."
52
+ message.status # => "queued"
53
+ ```
54
+
55
+ Instead of hitting Twilio's API, `sms_opener` saves the message to `tmp/sms_opener/`, opens the mobile preview in your browser, and returns a `Twilio::REST::Api::V2010::AccountContext::MessageInstance`.
56
+
57
+ ### 2. Direct Ruby API (Custom SMS Services / Adapters)
58
+
59
+ If you use another SMS provider or a custom service object in your app, call `SmsOpener.deliver`:
60
+
61
+ ```ruby
62
+ SmsOpener.deliver(
63
+ to: "+15558675309",
64
+ from: "+15550001234",
65
+ body: "Sign in to your account: https://localhost:3000/magic?token=abc123",
66
+ media_urls: ["https://example.com/receipt.png"], # optional MMS attachments
67
+ provider: "custom_sms" # optional provider label
68
+ )
69
+ ```
70
+
71
+ Or use `SmsOpener::DeliveryMethod` as a pluggable adapter:
72
+
73
+ ```ruby
74
+ delivery = SmsOpener::DeliveryMethod.new(launch_browser: true)
75
+ delivery.deliver!(to: "+15558675309", from: "+15550001234", body: "Hello world!")
76
+ ```
77
+
78
+ ### 3. Mountable Rails Web Inbox (`SmsOpener::Engine`)
79
+
80
+ To browse all captured texts in a web UI (and optionally disable automatic browser tab popups), mount `SmsOpener::Engine` in `config/routes.rb`:
81
+
82
+ ```ruby
83
+ Rails.application.routes.draw do
84
+ if Rails.env.development?
85
+ mount SmsOpener::Engine, at: "/sms_opener"
86
+ end
87
+ end
88
+ ```
89
+
90
+ Visit `http://localhost:3000/sms_opener` to:
91
+ - View all captured SMS/MMS messages in a split-pane inbox
92
+ - Copy detected 2FA/OTP codes with one click
93
+ - Search messages by phone number, body, or OTP code
94
+ - Send test SMS messages right from the browser
95
+ - Query `/sms_opener/messages.json` in development or system tests
96
+
97
+ ---
98
+
99
+ ## Configuration
100
+
101
+ Configure `sms_opener` in `config/environments/development.rb` or an initializer (`config/initializers/sms_opener.rb`):
102
+
103
+ ```ruby
104
+ SmsOpener.configure do |config|
105
+ # Directory where captured SMS files are stored (default: Rails.root.join("tmp", "sms_opener"))
106
+ config.location = Rails.root.join("tmp", "sms_opener")
107
+
108
+ # Open each SMS in the browser via Launchy when sent (default: true)
109
+ # Set to false if you prefer checking the /sms_opener web inbox manually
110
+ config.launch_browser = true
111
+
112
+ # Automatically intercept twilio-ruby client.messages.create (default: true)
113
+ config.intercept_twilio = true
114
+
115
+ # Fallback sender number when `from:` is omitted (default: "+15550000000")
116
+ config.default_from = "+15550000000"
117
+
118
+ # Optional URI scheme override for Launchy (e.g., "file")
119
+ config.file_uri_scheme = nil
120
+ end
121
+ ```
122
+
123
+ Or via Rails environment configuration in `config/environments/development.rb`:
124
+
125
+ ```ruby
126
+ Rails.application.configure do
127
+ config.sms_opener.launch_browser = true
128
+ config.sms_opener.intercept_twilio = true
129
+ end
130
+ ```
131
+
132
+ ---
133
+
134
+ ## Inspecting Captured Messages in Console or Tests
135
+
136
+ ```ruby
137
+ SmsOpener.messages # => Array of SmsOpener::Message (newest first)
138
+ last_sms = SmsOpener.messages.first
139
+ last_sms.to # => ["+15558675309"]
140
+ last_sms.from # => "+15550001234"
141
+ last_sms.body # => "Your verification code is 849201"
142
+ last_sms.otp_code # => "849201"
143
+ last_sms.urls # => []
144
+ last_sms.encoding # => "GSM-7"
145
+ last_sms.segments # => 1
146
+
147
+ SmsOpener.clear! # Deletes all stored messages
148
+ ```
149
+
150
+ ---
151
+
152
+ ## License
153
+
154
+ The gem is available as open source under the terms of the [MIT License](LICENSE.txt).
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_controller"
4
+ require "erb"
5
+ require "json"
6
+
7
+ module SmsOpener
8
+ class MessagesController < ActionController::Base
9
+ protect_from_forgery with: :null_session if respond_to?(:protect_from_forgery)
10
+
11
+ def index
12
+ @messages = SmsOpener.messages
13
+ @selected_message = if params[:id]
14
+ SmsOpener.find(params[:id]) || @messages.first
15
+ else
16
+ @messages.first
17
+ end
18
+
19
+ if json_request?
20
+ render json: @messages.map(&:to_h)
21
+ else
22
+ render html: render_inbox_template.html_safe, layout: false
23
+ end
24
+ end
25
+
26
+ def show
27
+ @message = SmsOpener.find(params[:id])
28
+ if @message.nil?
29
+ if json_request?
30
+ render json: { error: "Message not found" }, status: :not_found
31
+ else
32
+ render plain: "Message not found", status: :not_found
33
+ end
34
+ return
35
+ end
36
+
37
+ if json_request?
38
+ render json: @message.to_h
39
+ else
40
+ html = File.exist?(@message.filepath) ? File.read(@message.filepath) : @message.rendered_html
41
+ render html: html.html_safe, layout: false
42
+ end
43
+ end
44
+
45
+ def create
46
+ to = params[:to].to_s.strip
47
+ from = params[:from].to_s.strip
48
+ body = params[:body].to_s
49
+
50
+ if to.empty? || body.empty?
51
+ if json_request?
52
+ render json: { error: "Both 'to' and 'body' are required" }, status: :unprocessable_entity
53
+ else
54
+ redirect_to root_path
55
+ end
56
+ return
57
+ end
58
+
59
+ message = SmsOpener::DeliveryMethod.new(launch_browser: false).deliver!(
60
+ to: to,
61
+ from: from.empty? ? nil : from,
62
+ body: body,
63
+ provider: params[:provider].presence || "web_test"
64
+ )
65
+
66
+ if json_request?
67
+ render json: message.to_h, status: :created
68
+ else
69
+ redirect_to root_path(id: message.id)
70
+ end
71
+ end
72
+
73
+ def destroy
74
+ message = SmsOpener.find(params[:id])
75
+ message&.delete!
76
+
77
+ if json_request?
78
+ head :no_content
79
+ else
80
+ redirect_to root_path
81
+ end
82
+ end
83
+
84
+ def clear
85
+ SmsOpener.clear!
86
+
87
+ if json_request?
88
+ head :no_content
89
+ else
90
+ redirect_to root_path
91
+ end
92
+ end
93
+
94
+ private
95
+
96
+ def json_request?
97
+ request.format.json? || request.path.end_with?(".json") || request.headers["Accept"].to_s.include?("application/json")
98
+ end
99
+
100
+ def render_inbox_template
101
+ template_file = File.expand_path("../../views/sms_opener/messages/index.html.erb", __dir__)
102
+ ERB.new(File.read(template_file), trim_mode: "-").result(binding)
103
+ end
104
+ end
105
+ end