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.
@@ -0,0 +1,431 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>SmsOpener — Development SMS Inbox (<%= @messages.size %>)</title>
7
+ <style>
8
+ :root {
9
+ --bg: #0b1120;
10
+ --sidebar-bg: #0f172a;
11
+ --card-bg: #1e293b;
12
+ --border: #1e293b;
13
+ --border-light: #334155;
14
+ --text: #f8fafc;
15
+ --muted: #94a3b8;
16
+ --accent: #38bdf8;
17
+ --primary: #2563eb;
18
+ --danger: #ef4444;
19
+ --success: #10b981;
20
+ }
21
+ * { box-sizing: border-box; }
22
+ body {
23
+ margin: 0;
24
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
25
+ background: var(--bg);
26
+ color: var(--text);
27
+ height: 100vh;
28
+ display: flex;
29
+ flex-direction: column;
30
+ overflow: hidden;
31
+ }
32
+ /* Top Navbar */
33
+ .navbar {
34
+ height: 58px;
35
+ background: var(--sidebar-bg);
36
+ border-bottom: 1px solid var(--border-light);
37
+ padding: 0 20px;
38
+ display: flex;
39
+ align-items: center;
40
+ justify-content: space-between;
41
+ flex-shrink: 0;
42
+ }
43
+ .brand {
44
+ display: flex;
45
+ align-items: center;
46
+ gap: 10px;
47
+ font-weight: 700;
48
+ font-size: 16px;
49
+ color: var(--text);
50
+ text-decoration: none;
51
+ }
52
+ .brand-icon {
53
+ width: 30px;
54
+ height: 30px;
55
+ border-radius: 8px;
56
+ background: linear-gradient(135deg, #2563eb, #38bdf8);
57
+ display: inline-flex;
58
+ align-items: center;
59
+ justify-content: center;
60
+ font-size: 14px;
61
+ font-weight: 800;
62
+ color: #fff;
63
+ }
64
+ .count-pill {
65
+ background: rgba(56, 189, 248, 0.16);
66
+ color: var(--accent);
67
+ font-size: 12px;
68
+ padding: 2px 9px;
69
+ border-radius: 999px;
70
+ font-weight: 600;
71
+ }
72
+ .nav-actions {
73
+ display: flex;
74
+ align-items: center;
75
+ gap: 10px;
76
+ }
77
+ .btn {
78
+ display: inline-flex;
79
+ align-items: center;
80
+ gap: 6px;
81
+ padding: 7px 13px;
82
+ border-radius: 8px;
83
+ font-size: 13px;
84
+ font-weight: 600;
85
+ border: 1px solid var(--border-light);
86
+ background: var(--card-bg);
87
+ color: var(--text);
88
+ cursor: pointer;
89
+ text-decoration: none;
90
+ transition: all 0.15s ease;
91
+ }
92
+ .btn:hover { border-color: var(--accent); }
93
+ .btn-primary {
94
+ background: var(--primary);
95
+ border-color: var(--primary);
96
+ color: #fff;
97
+ }
98
+ .btn-primary:hover { opacity: 0.92; border-color: var(--accent); }
99
+ .btn-danger {
100
+ background: rgba(239, 68, 68, 0.12);
101
+ border-color: rgba(239, 68, 68, 0.35);
102
+ color: #fca5a5;
103
+ }
104
+ .btn-danger:hover {
105
+ background: rgba(239, 68, 68, 0.22);
106
+ border-color: var(--danger);
107
+ }
108
+ /* Main Workspace */
109
+ .workspace {
110
+ flex: 1;
111
+ display: grid;
112
+ grid-template-columns: 360px 1fr;
113
+ min-height: 0;
114
+ }
115
+ .sidebar {
116
+ background: var(--sidebar-bg);
117
+ border-right: 1px solid var(--border-light);
118
+ display: flex;
119
+ flex-direction: column;
120
+ min-height: 0;
121
+ }
122
+ .search-box {
123
+ padding: 12px 14px;
124
+ border-bottom: 1px solid var(--border-light);
125
+ }
126
+ .search-input {
127
+ width: 100%;
128
+ padding: 8px 12px;
129
+ border-radius: 8px;
130
+ border: 1px solid var(--border-light);
131
+ background: #0b1120;
132
+ color: var(--text);
133
+ font-size: 13px;
134
+ outline: none;
135
+ }
136
+ .search-input:focus { border-color: var(--accent); }
137
+ .message-list {
138
+ flex: 1;
139
+ overflow-y: auto;
140
+ list-style: none;
141
+ margin: 0;
142
+ padding: 0;
143
+ }
144
+ .message-item {
145
+ display: block;
146
+ padding: 14px 16px;
147
+ border-bottom: 1px solid rgba(51, 65, 85, 0.5);
148
+ text-decoration: none;
149
+ color: inherit;
150
+ transition: background 0.12s;
151
+ }
152
+ .message-item:hover {
153
+ background: rgba(30, 41, 59, 0.6);
154
+ }
155
+ .message-item.active {
156
+ background: rgba(37, 99, 235, 0.18);
157
+ border-left: 3px solid var(--accent);
158
+ }
159
+ .item-top {
160
+ display: flex;
161
+ justify-content: space-between;
162
+ align-items: center;
163
+ margin-bottom: 4px;
164
+ }
165
+ .item-to {
166
+ font-weight: 600;
167
+ font-size: 13.5px;
168
+ color: var(--text);
169
+ }
170
+ .item-time {
171
+ font-size: 11px;
172
+ color: var(--muted);
173
+ }
174
+ .item-body {
175
+ font-size: 13px;
176
+ color: #cbd5e1;
177
+ white-space: nowrap;
178
+ overflow: hidden;
179
+ text-overflow: ellipsis;
180
+ margin-bottom: 8px;
181
+ }
182
+ .item-tags {
183
+ display: flex;
184
+ align-items: center;
185
+ gap: 6px;
186
+ flex-wrap: wrap;
187
+ }
188
+ .tag {
189
+ font-size: 10.5px;
190
+ font-weight: 600;
191
+ padding: 2px 7px;
192
+ border-radius: 6px;
193
+ background: rgba(148, 163, 184, 0.14);
194
+ color: var(--muted);
195
+ text-transform: uppercase;
196
+ }
197
+ .tag-otp {
198
+ background: rgba(16, 185, 129, 0.18);
199
+ color: #34d399;
200
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
201
+ }
202
+ /* Detail Pane */
203
+ .detail-pane {
204
+ display: flex;
205
+ flex-direction: column;
206
+ min-height: 0;
207
+ background: var(--bg);
208
+ }
209
+ .detail-toolbar {
210
+ padding: 12px 20px;
211
+ background: rgba(15, 23, 42, 0.75);
212
+ border-bottom: 1px solid var(--border-light);
213
+ display: flex;
214
+ align-items: center;
215
+ justify-content: space-between;
216
+ }
217
+ .detail-frame {
218
+ flex: 1;
219
+ width: 100%;
220
+ border: none;
221
+ background: var(--bg);
222
+ }
223
+ .empty-state {
224
+ flex: 1;
225
+ display: flex;
226
+ flex-direction: column;
227
+ align-items: center;
228
+ justify-content: center;
229
+ padding: 40px;
230
+ text-align: center;
231
+ color: var(--muted);
232
+ }
233
+ .empty-state h2 {
234
+ color: var(--text);
235
+ margin: 12px 0 6px;
236
+ font-size: 18px;
237
+ }
238
+ .empty-state p {
239
+ max-width: 420px;
240
+ font-size: 14px;
241
+ line-height: 1.5;
242
+ margin: 0 0 20px;
243
+ }
244
+ /* Modal */
245
+ .modal-backdrop {
246
+ display: none;
247
+ position: fixed;
248
+ inset: 0;
249
+ background: rgba(0, 0, 0, 0.65);
250
+ align-items: center;
251
+ justify-content: center;
252
+ z-index: 50;
253
+ }
254
+ .modal-backdrop.open { display: flex; }
255
+ .modal {
256
+ background: var(--sidebar-bg);
257
+ border: 1px solid var(--border-light);
258
+ border-radius: 14px;
259
+ width: 100%;
260
+ max-width: 460px;
261
+ padding: 22px;
262
+ box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5);
263
+ }
264
+ .modal h3 { margin: 0 0 14px; font-size: 16px; }
265
+ .form-group { margin-bottom: 14px; }
266
+ .form-group label {
267
+ display: block;
268
+ font-size: 12px;
269
+ font-weight: 600;
270
+ color: var(--muted);
271
+ margin-bottom: 5px;
272
+ }
273
+ .form-control {
274
+ width: 100%;
275
+ padding: 9px 12px;
276
+ border-radius: 8px;
277
+ border: 1px solid var(--border-light);
278
+ background: #0b1120;
279
+ color: var(--text);
280
+ font-size: 13.5px;
281
+ font-family: inherit;
282
+ }
283
+ .preset-bar {
284
+ display: flex;
285
+ gap: 6px;
286
+ margin-bottom: 14px;
287
+ flex-wrap: wrap;
288
+ }
289
+ .preset-btn {
290
+ font-size: 11px;
291
+ padding: 4px 8px;
292
+ border-radius: 6px;
293
+ border: 1px solid var(--border-light);
294
+ background: var(--card-bg);
295
+ color: var(--accent);
296
+ cursor: pointer;
297
+ }
298
+ </style>
299
+ </head>
300
+ <body>
301
+ <header class="navbar">
302
+ <a href="<%= root_path %>" class="brand">
303
+ <span class="brand-icon">SMS</span>
304
+ <span>SmsOpener</span>
305
+ <span class="count-pill"><%= @messages.size %></span>
306
+ </a>
307
+ <div class="nav-actions">
308
+ <button type="button" class="btn btn-primary" onclick="document.getElementById('test-sms-modal').classList.add('open')">
309
+ + Send Test SMS
310
+ </button>
311
+ <a href="<%= root_path %>" class="btn">Refresh</a>
312
+ <% unless @messages.empty? -%>
313
+ <form action="<%= clear_messages_path %>" method="post" style="margin:0;" onsubmit="return confirm('Clear all captured SMS messages?');">
314
+ <button type="submit" class="btn btn-danger">Clear All</button>
315
+ </form>
316
+ <% end -%>
317
+ </div>
318
+ </header>
319
+
320
+ <% if @messages.empty? -%>
321
+ <div class="empty-state">
322
+ <div class="brand-icon" style="width:48px;height:48px;font-size:18px;border-radius:14px;">SMS</div>
323
+ <h2>No SMS messages captured yet</h2>
324
+ <p>
325
+ Any SMS sent via <code>Twilio::REST::Client</code> or <code>SmsOpener.deliver</code> in development will appear here automatically.
326
+ </p>
327
+ <button type="button" class="btn btn-primary" onclick="document.getElementById('test-sms-modal').classList.add('open')">
328
+ Send a Sample 2FA Text
329
+ </button>
330
+ </div>
331
+ <% else -%>
332
+ <div class="workspace">
333
+ <aside class="sidebar">
334
+ <div class="search-box">
335
+ <input type="text" id="sms-search" class="search-input" placeholder="Filter by phone, body, or OTP..." oninput="filterMessages(this.value)">
336
+ </div>
337
+ <div class="message-list" id="message-list">
338
+ <% @messages.each do |msg| -%>
339
+ <% is_active = @selected_message && @selected_message.id == msg.id -%>
340
+ <a href="<%= root_path(id: msg.id) %>"
341
+ class="message-item <%= 'active' if is_active %>"
342
+ data-search="<%= CGI.escapeHTML("#{msg.to_display} #{msg.from} #{msg.body} #{msg.otp_code}".downcase) %>">
343
+ <div class="item-top">
344
+ <span class="item-to"><%= CGI.escapeHTML(msg.to_display) %></span>
345
+ <span class="item-time"><%= msg.created_at.strftime("%H:%M:%S") %></span>
346
+ </div>
347
+ <div class="item-body"><%= CGI.escapeHTML(msg.body) %></div>
348
+ <div class="item-tags">
349
+ <span class="tag"><%= CGI.escapeHTML(msg.provider) %></span>
350
+ <span class="tag"><%= msg.segments %> seg</span>
351
+ <% if msg.otp_code -%>
352
+ <span class="tag tag-otp">OTP: <%= CGI.escapeHTML(msg.otp_code) %></span>
353
+ <% end -%>
354
+ </div>
355
+ </a>
356
+ <% end -%>
357
+ </div>
358
+ </aside>
359
+
360
+ <main class="detail-pane">
361
+ <% if @selected_message -%>
362
+ <div class="detail-toolbar">
363
+ <div>
364
+ <strong>To:</strong> <%= CGI.escapeHTML(@selected_message.to_display) %>
365
+ <span style="color:var(--muted); margin: 0 8px;">|</span>
366
+ <strong>From:</strong> <%= CGI.escapeHTML(@selected_message.from) %>
367
+ </div>
368
+ <div style="display:flex; gap:8px; align-items:center;">
369
+ <% if @selected_message.otp_code -%>
370
+ <button type="button" class="btn" style="border-color:#10b981;color:#34d399;"
371
+ onclick="navigator.clipboard.writeText('<%= CGI.escapeHTML(@selected_message.otp_code) %>'); this.textContent='Copied <%= CGI.escapeHTML(@selected_message.otp_code) %>!';">
372
+ Copy OTP (<%= CGI.escapeHTML(@selected_message.otp_code) %>)
373
+ </button>
374
+ <% end -%>
375
+ <a href="<%= message_path(@selected_message.id) %>" target="_blank" class="btn">Open Standalone</a>
376
+ <form action="<%= delete_message_path(@selected_message.id) %>" method="post" style="margin:0;">
377
+ <button type="submit" class="btn btn-danger">Delete</button>
378
+ </form>
379
+ </div>
380
+ </div>
381
+ <iframe class="detail-frame" src="<%= message_path(@selected_message.id) %>" title="SMS Preview"></iframe>
382
+ <% end -%>
383
+ </main>
384
+ </div>
385
+ <% end -%>
386
+
387
+ <!-- Send Test SMS Modal -->
388
+ <div class="modal-backdrop" id="test-sms-modal" onclick="if(event.target===this)this.classList.remove('open')">
389
+ <div class="modal">
390
+ <h3>Send Test SMS</h3>
391
+ <div class="preset-bar">
392
+ <button type="button" class="preset-btn" onclick="fillPreset('Your verification code is 849201. Valid for 10 minutes.')">2FA OTP Preset</button>
393
+ <button type="button" class="preset-btn" onclick="fillPreset('Sign in to your account: https://localhost:3000/session/magic?token=xyz987')">Magic Link Preset</button>
394
+ <button type="button" class="preset-btn" onclick="fillPreset('Your order #4821 has shipped! 📦 Track at https://localhost:3000/orders/4821')">Unicode MMS/Emoji</button>
395
+ </div>
396
+ <form action="<%= messages_path %>" method="post">
397
+ <div class="form-group">
398
+ <label for="sms-to">To Phone Number</label>
399
+ <input type="text" id="sms-to" name="to" class="form-control" value="+15558675309" required>
400
+ </div>
401
+ <div class="form-group">
402
+ <label for="sms-from">From Phone Number / Sender ID</label>
403
+ <input type="text" id="sms-from" name="from" class="form-control" value="<%= CGI.escapeHTML(SmsOpener.configuration.default_from) %>">
404
+ </div>
405
+ <div class="form-group">
406
+ <label for="sms-body-input">Message Body</label>
407
+ <textarea id="sms-body-input" name="body" rows="4" class="form-control" required>Your verification code is 849201. Valid for 10 minutes.</textarea>
408
+ </div>
409
+ <div style="display:flex; justify-content:flex-end; gap:8px;">
410
+ <button type="button" class="btn" onclick="document.getElementById('test-sms-modal').classList.remove('open')">Cancel</button>
411
+ <button type="submit" class="btn btn-primary">Deliver SMS</button>
412
+ </div>
413
+ </form>
414
+ </div>
415
+ </div>
416
+
417
+ <script>
418
+ function filterMessages(query) {
419
+ var normalized = (query || "").toLowerCase().trim();
420
+ var items = document.querySelectorAll("#message-list .message-item");
421
+ items.forEach(function(item) {
422
+ var text = item.getAttribute("data-search") || "";
423
+ item.style.display = (!normalized || text.indexOf(normalized) !== -1) ? "block" : "none";
424
+ });
425
+ }
426
+ function fillPreset(bodyText) {
427
+ document.getElementById("sms-body-input").value = bodyText;
428
+ }
429
+ </script>
430
+ </body>
431
+ </html>
data/config/routes.rb ADDED
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ SmsOpener::Engine.routes.draw do
4
+ root to: "messages#index"
5
+
6
+ resources :messages, only: %i[index show create destroy] do
7
+ collection do
8
+ match :clear, via: %i[post delete]
9
+ end
10
+ member do
11
+ post :delete, action: :destroy
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmsOpener
4
+ class Configuration
5
+ attr_accessor :location, :launch_browser, :intercept_twilio, :default_from, :file_uri_scheme
6
+
7
+ def initialize
8
+ @location = default_location
9
+ @launch_browser = ENV.fetch("SMS_OPENER_LAUNCH_BROWSER", "true") != "false"
10
+ @intercept_twilio = true
11
+ @default_from = "+15550000000"
12
+ @file_uri_scheme = nil
13
+ end
14
+
15
+ def resolved_location
16
+ @location || default_location
17
+ end
18
+
19
+ private
20
+
21
+ def default_location
22
+ if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
23
+ Rails.root.join("tmp", "sms_opener").to_s
24
+ else
25
+ File.join(Dir.pwd, "tmp", "sms_opener")
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "launchy"
4
+
5
+ module SmsOpener
6
+ class DeliveryMethod
7
+ class InvalidOption < StandardError; end
8
+
9
+ attr_reader :settings
10
+
11
+ def initialize(options = {})
12
+ @settings = {
13
+ location: SmsOpener.configuration.resolved_location,
14
+ launch_browser: SmsOpener.configuration.launch_browser
15
+ }.merge(options)
16
+ end
17
+
18
+ def deliver!(message_or_attributes = nil, **kwargs)
19
+ message = build_message(message_or_attributes, kwargs)
20
+
21
+ instrument_delivery(message) do
22
+ message.render!
23
+ log_delivery(message)
24
+ open_in_browser(message) if should_launch_browser?
25
+ end
26
+
27
+ message
28
+ end
29
+
30
+ private
31
+
32
+ def build_message(message_or_attributes, kwargs)
33
+ return message_or_attributes if message_or_attributes.is_a?(SmsOpener::Message)
34
+
35
+ attrs = if message_or_attributes.is_a?(Hash)
36
+ symbolize_keys(message_or_attributes).merge(kwargs)
37
+ elsif message_or_attributes.respond_to?(:to) && message_or_attributes.respond_to?(:body)
38
+ {
39
+ to: message_or_attributes.to,
40
+ from: message_or_attributes.respond_to?(:from) ? message_or_attributes.from : nil,
41
+ body: message_or_attributes.body,
42
+ media_urls: message_or_attributes.respond_to?(:media_urls) ? message_or_attributes.media_urls : []
43
+ }.merge(kwargs)
44
+ else
45
+ kwargs
46
+ end
47
+
48
+ to = attrs.delete(:to)
49
+ body = attrs.delete(:body)
50
+ raise InvalidOption, "Both :to and :body are required to deliver an SMS" if to.nil? || body.nil?
51
+
52
+ from = attrs.delete(:from)
53
+ media_urls = attrs.delete(:media_urls) || attrs.delete(:media_url) || []
54
+ provider = attrs.delete(:provider) || "direct"
55
+ id = attrs.delete(:id)
56
+ created_at = attrs.delete(:created_at)
57
+ explicit_metadata = attrs.delete(:metadata) || {}
58
+ metadata = explicit_metadata.merge(attrs)
59
+
60
+ SmsOpener::Message.new(
61
+ to: to,
62
+ from: from,
63
+ body: body,
64
+ media_urls: media_urls,
65
+ provider: provider,
66
+ metadata: metadata,
67
+ id: id,
68
+ created_at: created_at,
69
+ base_location: @settings[:location]
70
+ )
71
+ end
72
+
73
+ def should_launch_browser?
74
+ Boolean(@settings[:launch_browser])
75
+ end
76
+
77
+ def open_in_browser(message)
78
+ Launchy.open(message.file_uri)
79
+ rescue StandardError => e
80
+ warn "[SmsOpener] Failed to open browser via Launchy: #{e.message} (file saved at #{message.filepath})"
81
+ end
82
+
83
+ def log_delivery(message)
84
+ return unless defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
85
+
86
+ otp_info = message.otp_code ? " [OTP: #{message.otp_code}]" : ""
87
+ Rails.logger.info(
88
+ "[SmsOpener] Captured SMS (#{message.provider}) From: #{message.from} -> To: #{message.to_display}#{otp_info} | " \
89
+ "Body: #{message.body.inspect} | Saved to: #{message.filepath}"
90
+ )
91
+ end
92
+
93
+ def instrument_delivery(message, &block)
94
+ if defined?(ActiveSupport::Notifications)
95
+ ActiveSupport::Notifications.instrument(
96
+ "deliver.sms_opener",
97
+ message: message,
98
+ to: message.to,
99
+ from: message.from,
100
+ body: message.body,
101
+ provider: message.provider,
102
+ &block
103
+ )
104
+ else
105
+ yield
106
+ end
107
+ end
108
+
109
+ def symbolize_keys(hash)
110
+ hash.each_with_object({}) do |(k, v), acc|
111
+ acc[k.to_sym] = v
112
+ end
113
+ end
114
+
115
+ def Boolean(val) # rubocop:disable Naming/MethodName
116
+ return false if val.nil? || val == false || val.to_s.strip.casecmp("false").zero?
117
+
118
+ true
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/engine"
4
+
5
+ module SmsOpener
6
+ class Engine < ::Rails::Engine
7
+ isolate_namespace SmsOpener
8
+ end
9
+ end