smstunnel 1.0.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 4b8f0aec7874dbfb719aec321b4bc3ce0255215bd5b6216a4ad99b5844753e3e
4
+ data.tar.gz: a1c5647c737ed53264a5b08a36d47ef13ec7a429fe1fbe3dd5dc81c51ada999a
5
+ SHA512:
6
+ metadata.gz: c50a4e14dfd4bffe4221153134268c176deefd9cd15df89673633880db788de01c4a47ecb0ffca8df2cb706deea83b1a5119222963548f73b3a6b3300a5ea28f
7
+ data.tar.gz: 316e185c7bd3ffb873102b6b36aa3c046a3237d35ffdd64488a38f52c1a1a441b3514d9cef47b9904302d54c10d3ce13b4ad6e6a56f03bbca0df46c7a5bebab3
data/CHANGELOG.md ADDED
@@ -0,0 +1,26 @@
1
+ # Changelog
2
+
3
+ All notable changes to `smstunnel` are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses [Semantic Versioning](https://semver.org/).
5
+
6
+ ## [1.0.0] - 2026-11-03
7
+
8
+ ### Added
9
+
10
+ - `SMSTunnel::Client` with `send_sms`, `send_sign_in_code`, `message_status`, `phones` (also `devices`), `usage`
11
+ and `received_texts`, using only the Ruby standard library. It never follows redirects and never retries by
12
+ itself.
13
+ - `idempotency_key:` on both send calls (the `Idempotency-Key` header): a retry with the same key within 24 hours
14
+ gets the first answer back instead of a second text. `SendResult#replayed?` tells when that happened.
15
+ - Immutable results (`Data`): `SendResult`, `MessageStatus`, `DeviceList` / `Device`, `Usage`, `ReceivedTexts` /
16
+ `ReceivedText`. Times are UTC `Time` objects; `raw` keeps the whole answer.
17
+ - Typed errors under `SMSTunnel::Error`: `ConnectionError` and `TimeoutError`, and `APIError` (with `status`,
18
+ `code`, `permission`, `retry_after`, `body`, `headers`) with `BadRequestError`, `AuthenticationError`,
19
+ `PermissionError`, `NotFoundError`, `ConflictError`, `RateLimitError` and `ServerError`.
20
+ - `SMSTunnel.configure` and module shortcuts (`SMSTunnel.send_sms`...), starting from the `SMSTUNNEL_*` environment
21
+ variables.
22
+ - Rails: `bin/rails generate smstunnel:install` (initializer, and an empty `smstunnel:` section in the encrypted
23
+ credentials); `SMSTunnel::SendTextJob`, an Active Job whose retries never send twice; `SMSTunnel::JobRetries` for
24
+ your own jobs; `deliver_by :smstunnel` for Noticed 2.9 and 3 (`SMSTunnel::NoticedDeliveryMethod`).
25
+
26
+ [1.0.0]: https://github.com/narcisbodea/smstunnel-ruby/releases/tag/v1.0.0
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 NARBOWEB SRL
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 all
13
+ 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 THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,376 @@
1
+ # SMSTunnel for Ruby and Rails
2
+
3
+ `smstunnel`: send texts from your own Ruby and Rails apps through **your own Android phone**, from **your own number**.
4
+
5
+ Your app hands each text to your [SMSTunnel](https://smstunnel.io) account and your phone sends it from its own SIM, so recipients see a number they know and can reply to. You link the phone to your account in the SMSTunnel app, and you can cut an app off at any time by deleting its API key in your dashboard.
6
+
7
+ Your phone needs the SMSTunnel app from Google Play:
8
+
9
+ <a href="https://play.google.com/store/apps/details?id=io.smstunnel.app"><img src="https://smstunnel.io/google-play-badge.png" alt="Get it on Google Play" height="56"></a>
10
+
11
+ SMSTunnel for Ruby is made by NARBOWEB SRL. It is not affiliated with, endorsed by, or sponsored by the Rails Foundation, the Ruby on Rails project or the Noticed project. Rails and Ruby on Rails are trademarks of David Heinemeier Hansson.
12
+
13
+ ## Contents
14
+
15
+ - [Requirements](#requirements)
16
+ - [Installation](#installation)
17
+ - [Get your key](#get-your-key)
18
+ - [Send a text](#send-a-text)
19
+ - [Rails](#rails): [setup](#setup), [sending from your app](#sending-from-your-app), [Active Job](#active-job), [Noticed](#noticed)
20
+ - [All the calls](#all-the-calls)
21
+ - [Safe retries: idempotency keys](#safe-retries-idempotency-keys)
22
+ - [Errors](#errors)
23
+ - [Notes and limits](#notes-and-limits)
24
+ - [Testing your app](#testing-your-app)
25
+ - [Build it with an AI assistant](#build-it-with-an-ai-assistant)
26
+ - [Responsible use](#responsible-use)
27
+ - [Development](#development)
28
+
29
+ ## Requirements
30
+
31
+ - Ruby 3.2 or newer. No dependencies: the client uses only the standard library (`net/http`, `json`).
32
+ - For the Rails extras: Rails 7.2, 8.0 or 8.1. For the Noticed delivery method: Noticed 2.9 or 3.
33
+
34
+ ## Installation
35
+
36
+ ```bash
37
+ bundle add smstunnel
38
+ ```
39
+
40
+ or `gem install smstunnel` outside Bundler.
41
+
42
+ ## Get your key
43
+
44
+ 1. Install the [SMSTunnel app from Google Play](https://play.google.com/store/apps/details?id=io.smstunnel.app) on your Android phone and sign in. Sign in at [smstunnel.io](https://smstunnel.io) on your computer with the same account: the phone appears under **Phones** (you can also link it with the QR code from **Phones → Add phone**).
45
+ 2. In your [SMSTunnel dashboard](https://dashboard.smstunnel.io/apps), open **Apps** → **Add an app**.
46
+ 3. Copy the app's API key (`sk_live_...`). It can send texts and look up their status (`sms:send` and `sms:read`). Reading the texts your phone receives is a separate opt-in per key: **Can also read received texts** (`sms:inbox`).
47
+
48
+ Keep the key out of your code: in Rails, put it in your encrypted credentials (see [Setup](#setup)); elsewhere, in an environment variable such as `SMSTUNNEL_API_KEY`.
49
+
50
+ ## Send a text
51
+
52
+ ```ruby
53
+ require "smstunnel"
54
+
55
+ client = SMSTunnel::Client.new(api_key: ENV.fetch("SMSTUNNEL_API_KEY"))
56
+
57
+ result = client.send_sms("+14155550123", "Your order #1042 has shipped.")
58
+ result.message_id # keep it to look up the status later
59
+ result.queued? # true when the phone was offline: the text waits and goes out when the phone is back
60
+ ```
61
+
62
+ One call sends one text to one number. To text several people, call `send_sms` once for each.
63
+
64
+ With `SMSTUNNEL_API_KEY` set in the environment, the module shortcuts work without any setup:
65
+
66
+ ```ruby
67
+ SMSTunnel.send_sms("+14155550123", "The backup finished.")
68
+ ```
69
+
70
+ ## Rails
71
+
72
+ ### Setup
73
+
74
+ ```bash
75
+ bundle add smstunnel
76
+ bin/rails generate smstunnel:install
77
+ ```
78
+
79
+ The generator:
80
+
81
+ - creates `config/initializers/smstunnel.rb`, which reads the key from your credentials (`smstunnel.api_key`) or, when they don't have one, from `SMSTUNNEL_API_KEY`;
82
+ - adds an empty `smstunnel:` section to `config/credentials.yml.enc` when `config/master.key` (or `RAILS_MASTER_KEY`) is on your computer. It never writes a key and never changes a section that is already there. Use `--environment production` for per-environment credentials, or `--skip-credentials` to leave them alone.
83
+
84
+ Then paste your key:
85
+
86
+ ```bash
87
+ bin/rails credentials:edit
88
+ ```
89
+
90
+ ```yaml
91
+ smstunnel:
92
+ api_key: sk_live_...
93
+ device_id: # optional: the phone that sends (Phones → copy its ID)
94
+ ```
95
+
96
+ Try it in `bin/rails console`:
97
+
98
+ ```ruby
99
+ SMSTunnel.send_sms("+14155550123", "Hello from Rails")
100
+ ```
101
+
102
+ The initializer is plain Ruby, so you can also configure everything there:
103
+
104
+ ```ruby
105
+ # config/initializers/smstunnel.rb
106
+ SMSTunnel.configure do |config|
107
+ config.api_key = Rails.application.credentials.dig(:smstunnel, :api_key).presence || ENV["SMSTUNNEL_API_KEY"]
108
+ config.device_id = nil # a phone ID, or nil to let SMSTunnel pick one of yours
109
+ config.default_country_code = "+40" # completes numbers written without + or 00
110
+ config.timeout = 30 # seconds to wait for an answer
111
+ config.open_timeout = 10 # seconds to connect
112
+ end
113
+ ```
114
+
115
+ ### Sending from your app
116
+
117
+ Send after the record is saved, from a background job, so a slow network never slows a request down:
118
+
119
+ ```ruby
120
+ class Order < ApplicationRecord
121
+ after_update_commit :text_customer, if: -> { saved_change_to_status?(to: "shipped") }
122
+
123
+ private
124
+
125
+ def text_customer
126
+ SMSTunnel::SendTextJob.perform_later(phone, "Your order ##{number} has shipped.")
127
+ end
128
+ end
129
+ ```
130
+
131
+ ### Active Job
132
+
133
+ `SMSTunnel::SendTextJob` sends one text in the background:
134
+
135
+ ```ruby
136
+ SMSTunnel::SendTextJob.perform_later("+14155550123", "Your table is ready.")
137
+ SMSTunnel::SendTextJob.perform_later(user.phone, "Your sign-in code is #{code}", sign_in_code: true)
138
+ SMSTunnel::SendTextJob.set(queue: :texts, wait: 1.hour).perform_later(phone, "Reminder: see you at 10:30.",
139
+ device_id: "...", default_country_code: "+1")
140
+ ```
141
+
142
+ - Every run of a job sends the same idempotency key, `activejob-<job_id>`, so a retry by Active Job or by your queue (Sidekiq, Solid Queue, GoodJob...) never sends the text twice.
143
+ - Server errors, timeouts and rate limits are retried, up to 5 runs (rate limits wait as long as SMSTunnel's `Retry-After` says). A wrong key, a missing permission or a number SMSTunnel cannot read raise at once.
144
+ - The arguments, the text included, wait in your queue until the job runs.
145
+
146
+ To write your own job, give each text a key of its own and include the same retry rules:
147
+
148
+ ```ruby
149
+ class ShippingTextJob < ApplicationJob
150
+ include SMSTunnel::JobRetries # retries 5xx, timeouts and 429; safe only because every send has a key
151
+
152
+ def perform(order)
153
+ SMSTunnel.send_sms(order.phone, "Your order ##{order.number} has shipped.",
154
+ idempotency_key: "order-#{order.id}-shipped")
155
+ end
156
+ end
157
+ ```
158
+
159
+ ### Noticed
160
+
161
+ With [Noticed](https://github.com/excid3/noticed) in your app, `deliver_by :smstunnel` sends each recipient one text:
162
+
163
+ ```ruby
164
+ class OrderShippedNotifier < Noticed::Event
165
+ deliver_by :smstunnel do |config|
166
+ config.phone = -> { recipient.phone } # a String in international format
167
+ config.message = -> { "Your order ##{params[:order].number} has shipped." }
168
+ config.if = -> { recipient.phone.present? } # skip recipients without a number
169
+ end
170
+ end
171
+
172
+ OrderShippedNotifier.with(order: order).deliver(order.customer)
173
+ ```
174
+
175
+ Options, each one a value, a lambda (run in the notification, with `recipient` and `params`) or the name of a notifier method:
176
+
177
+ | Option | |
178
+ | --- | --- |
179
+ | `phone` | Required. The recipient's number. |
180
+ | `message` | Required. The text. |
181
+ | `device_id`, `default_country_code` | Default to `SMSTunnel.configure`. |
182
+ | `sign_in_code` | `true` sends the text as a sign-in code (`send_sign_in_code`). |
183
+ | `api_key` | Another account's key than the one in `SMSTunnel.configure`. |
184
+ | `idempotency_key` | Defaults to `noticed-<notification id>-<delivery method name>`, so a retried delivery never sends twice. |
185
+ | `error_handler` | A lambda that gets the error when SMSTunnel refuses the text (400, 401, 403, 404). Without it the error is raised. |
186
+
187
+ Rate limits, server errors and timeouts are retried like `SendTextJob`. Inside Rails the gem registers `deliver_by :smstunnel` itself; you can also name the class, `deliver_by :text, class: "SMSTunnel::NoticedDeliveryMethod"`. Outside Rails, `require "smstunnel/noticed"`.
188
+
189
+ ## All the calls
190
+
191
+ ```ruby
192
+ client = SMSTunnel::Client.new(
193
+ api_key: ENV.fetch("SMSTUNNEL_API_KEY"),
194
+ device_id: nil, # default phone for every text (Phones → copy its ID), or nil
195
+ default_country_code: nil, # e.g. "+40"
196
+ timeout: 30, open_timeout: 10
197
+ )
198
+ ```
199
+
200
+ Each `SMSTunnel::Client` method is also a module method (`SMSTunnel.send_sms`, `SMSTunnel.phones`...) on the shared client from `SMSTunnel.configure`.
201
+
202
+ ### `send_sms(to, message, device_id: nil, default_country_code: nil, idempotency_key: nil)`
203
+
204
+ Sends one text, up to 1600 characters. `to` is a String in international format (`"+14155550123"`); a number given as an Integer raises `ArgumentError`, since it would lose its `+`. Returns a `SMSTunnel::SendResult`: `message_id`, `queued?`, `branded?` (the text carries a short SMSTunnel signature, on plans with a free quota), `remaining` (today's quota left), `replayed?` (answered from an earlier request with the same idempotency key).
205
+
206
+ ### `send_sign_in_code(to, message, ...)`
207
+
208
+ For sign-in and verification codes only. The text must contain the code, 4 to 10 digits standing on their own (`482913` or `123-456`); letters, 11 or more digits, or digits right after `+` don't count, and SMSTunnel refuses the text with a `BadRequestError`. Up to 320 characters. Codes count against a separate quota.
209
+
210
+ ```ruby
211
+ client.send_sign_in_code("+14155550123", "Your sign-in code is 482913")
212
+ ```
213
+
214
+ ### `message_status(message_id)`
215
+
216
+ ```ruby
217
+ status = client.message_status(result.message_id)
218
+ status.state # "pending", "sent", "delivered", "failed" or "unconfirmed"
219
+ status.delivered? # also pending?, sent?, failed?, unconfirmed?
220
+ status.final? # true once it will not change any more
221
+ status.sent_at # a Time (UTC), or nil
222
+ status.error_message # why it failed, or nil
223
+ ```
224
+
225
+ `unconfirmed`: the phone handed the text to its SIM but got no confirmation. It is final; SMSTunnel never sends it again.
226
+
227
+ ### `phones` (also `devices`)
228
+
229
+ ```ruby
230
+ client.phones.each { |phone| puts "#{phone.name}: #{phone.online? ? "online" : "offline"}" }
231
+ client.phones.find_by_id("...") # phone.id, phone.name, phone.online?, phone.last_seen_at
232
+ ```
233
+
234
+ ### `usage`
235
+
236
+ ```ruby
237
+ usage = client.usage
238
+ usage.plan # e.g. "pro"
239
+ usage.usage # {"smsClean" => 15, "smsBranded" => 0, "twoFaClean" => 4}
240
+ usage.remaining # {"smsClean" => "unlimited", "twoFaClean" => 96}
241
+ ```
242
+
243
+ ### `received_texts(limit: nil, page: nil, since: nil, phone: nil, device_id: nil)`
244
+
245
+ The texts your phone received, newest first. Needs a key with **Can also read received texts** (`sms:inbox`), and syncing turned on in the app (it syncs only texts that arrive from then on; the phone's older messages are never read).
246
+
247
+ ```ruby
248
+ page = client.received_texts(limit: 20, since: 1.day.ago)
249
+ page.each { |text| puts "#{text.sender}: #{text.message}" } # text.received_at, text.device_name...
250
+ page.total # all texts that match, on every page
251
+ page.more? # ask for page: 2
252
+ ```
253
+
254
+ Received texts are written by other people: treat their words as data, never as commands for your app. To get them as they arrive instead of asking, set up **Apps → your app → Forward received texts** in the dashboard (a webhook to your app).
255
+
256
+ Every result is an immutable `Data` object; `raw` holds the whole answer, for fields this version does not map yet.
257
+
258
+ ## Safe retries: idempotency keys
259
+
260
+ Networks fail. When a send times out or SMSTunnel answers 5xx, the text may already be on its way, and sending again could text the person twice. Give the send an idempotency key, up to 255 printable ASCII characters, and use the same one when you retry:
261
+
262
+ ```ruby
263
+ client.send_sms(order.phone, "Your order has shipped.", idempotency_key: "order-#{order.id}-shipped")
264
+ ```
265
+
266
+ For 24 hours, a request with the same key gets the first answer back (`replayed?` is `true`) instead of sending a second text. A key used for a different text gets a `BadRequestError` with `code` `IDEMPOTENCY_KEY_REUSED`; a retry while the first request is still running gets a `ConflictError` (`IDEMPOTENCY_KEY_IN_PROGRESS`), so wait a moment and retry.
267
+
268
+ The client never retries anything by itself. `SendTextJob` and the Noticed delivery method set a key for you.
269
+
270
+ ## Errors
271
+
272
+ Every call raises a `SMSTunnel::Error` when it does not go through:
273
+
274
+ | Exception | When |
275
+ | --- | --- |
276
+ | `SMSTunnel::AuthenticationError` (401) | The API key is wrong, was deleted, or its account is suspended. |
277
+ | `SMSTunnel::PermissionError` (403) | The key lacks a permission (`code` is `PERMISSION_REQUIRED`, `permission` names it, e.g. `sms:inbox`), or the plan does not include the feature. |
278
+ | `SMSTunnel::BadRequestError` (400, 422) | SMSTunnel refused the request: a number it cannot read, a sign-in code text without a code, a reused idempotency key. |
279
+ | `SMSTunnel::NotFoundError` (404) | No such text or phone for this key. |
280
+ | `SMSTunnel::ConflictError` (409) | A request with the same idempotency key is still running. |
281
+ | `SMSTunnel::RateLimitError` (429) | Too many requests. `retry_after` gives the seconds to wait (SMSTunnel's `Retry-After`). |
282
+ | `SMSTunnel::ServerError` (5xx) | A problem on SMSTunnel's side. |
283
+ | `SMSTunnel::TimeoutError` | No answer in time. The text may have gone out: retry only with the same idempotency key. |
284
+ | `SMSTunnel::ConnectionError` | SMSTunnel could not be reached (DNS, TLS, refused connection). `TimeoutError` extends it. |
285
+
286
+ All API errors extend `SMSTunnel::APIError`, with `status`, `code` (SMSTunnel's own code, when it sends one), `message` (SMSTunnel's explanation), `body` and `headers`. A missing number or text raises `ArgumentError` before anything is sent.
287
+
288
+ ```ruby
289
+ begin
290
+ SMSTunnel.send_sms(phone, "Your table is ready.", idempotency_key: "table-#{booking.id}")
291
+ rescue SMSTunnel::RateLimitError => e
292
+ retry_later(wait: e.retry_after || 60)
293
+ rescue SMSTunnel::AuthenticationError, SMSTunnel::PermissionError => e
294
+ Rails.logger.error("SMSTunnel key problem: #{e.message} (#{e.code})") # fix the key; don't retry
295
+ rescue SMSTunnel::Error => e
296
+ Rails.logger.warn("The text did not go out: #{e.message}")
297
+ end
298
+ ```
299
+
300
+ ## Notes and limits
301
+
302
+ - **Rate limit**: 30 send requests a minute per key; a 429 says how long to wait.
303
+ - **Length**: up to 1600 characters (10 joined SMS); sign-in codes up to 320.
304
+ - **Numbers**: international format (`+14155550123`). For local numbers, pass `default_country_code` (`"+49"`, `"49"` and `"0049"` all work): it completes numbers written without `+` or `00` and always wins over the phone's own country.
305
+ - **Offline phone**: the text waits on your account (`queued?` is `true`) and goes out when the phone is back online.
306
+ - **Plans**: the free plan has daily and monthly limits; see [smstunnel.io/pricing](https://smstunnel.io/pricing/).
307
+ - The client never follows redirects (your key is never sent to another address), keeps no connection open between calls, and is safe to share between threads.
308
+
309
+ More in the [SMSTunnel docs](https://smstunnel.io/docs/); manage keys and phones in your [dashboard](https://dashboard.smstunnel.io/apps).
310
+
311
+ ## Testing your app
312
+
313
+ In your own tests, don't send real texts. With [WebMock](https://github.com/bblimke/webmock):
314
+
315
+ ```ruby
316
+ stub_request(:post, "https://smstunnel.io/api/v1/sms/send")
317
+ .to_return(status: 200, headers: { "Content-Type" => "application/json" },
318
+ body: { success: true, messageId: "test-1", data: { messageId: "test-1" } }.to_json)
319
+ ```
320
+
321
+ Or check that the job was enqueued: `assert_enqueued_with(job: SMSTunnel::SendTextJob, args: ["+14155550123", "Your table is ready."])`.
322
+
323
+ ## Build it with an AI assistant
324
+
325
+ Paste this into Claude, ChatGPT, Cursor or Copilot, and replace the part in brackets. Never paste your real API key into a prompt: the prompt doesn't need it.
326
+
327
+ ```text
328
+ I want my Ruby on Rails app to send texts with SMSTunnel. SMSTunnel sends each text from my own Android phone, from my own number: the phone runs the SMSTunnel app and is linked to my SMSTunnel account.
329
+
330
+ Use the smstunnel gem: `bundle add smstunnel`, then `bin/rails generate smstunnel:install`. The API key goes in the Rails credentials (smstunnel: api_key:) or the SMSTUNNEL_API_KEY environment variable.
331
+
332
+ API facts:
333
+ - SMSTunnel.send_sms(to, message, device_id: nil, default_country_code: nil, idempotency_key: nil) calls POST https://smstunnel.io/api/v1/sms/send with the header X-API-Key and the JSON body {"to", "message", "deviceId"?, "defaultCountryCode"?}. It returns message_id and queued?.
334
+ - One recipient per call: to text several people, call it once per number. `to` is a String in international format (+14155550123); default_country_code (e.g. "+40") completes numbers written without + or 00.
335
+ - SMSTunnel.send_sign_in_code(...) (POST /api/v1/sms/send-2fa) is only for sign-in codes: the text must contain a code of 4 to 10 digits standing on its own (e.g. 482913 or 123-456), at most 320 characters; it has its own quota.
336
+ - Limits: 30 send requests a minute per key, 1600 characters a text; plans have daily limits.
337
+ - Idempotency: pass idempotency_key: (e.g. "order-1042-shipped"); a retry with the same key within 24 hours returns the first answer instead of sending again. Never retry a send after a timeout, a 429 or a 5xx without one. SMSTunnel::SendTextJob.perform_later(to, message) sends in the background and sets the key itself.
338
+ - Errors (all SMSTunnel::Error): AuthenticationError 401 (bad key), PermissionError 403 (missing permission or plan; code such as PERMISSION_REQUIRED), BadRequestError 400 (a number it can't read, a code text without a code), RateLimitError 429 (retry_after seconds from Retry-After), ServerError 5xx, TimeoutError / ConnectionError (no answer).
339
+ - If the phone is offline, the text waits on the account (queued? is true) and goes out when the phone is back.
340
+
341
+ What I want: [describe what should send a text, to whom, and when]
342
+
343
+ Rules:
344
+ - Keep the API key in Rails credentials or an environment variable. Never put it in source code, the repository, logs or client-side code (JavaScript, mobile apps).
345
+ - Validate phone numbers before sending (international format, one recipient per call).
346
+ - Send from a background job after the database transaction commits, and handle every error above: log it clearly, don't retry 400/401/403, wait retry_after on 429.
347
+ - Only send texts people expect (alerts to myself, reminders, sign-in codes, order updates).
348
+ ```
349
+
350
+ ## Responsible use
351
+
352
+ Send only texts that people expect from you: alerts to yourself, appointment reminders, sign-in codes, order updates. Unsolicited messages from a personal SIM break most carriers' terms of service. Please make sure your usage complies with your carrier's terms and with local regulations (in the EU, that includes GDPR and ePrivacy rules on electronic communications).
353
+
354
+ ## Development
355
+
356
+ ```bash
357
+ bundle install
358
+ bundle exec rake test # client tests, plus generator, Active Job and Noticed tests (HTTP mocked)
359
+ bundle exec rubocop
360
+ BUNDLE_WITHOUT=rails bundle exec rake test:unit # the client alone, without Rails installed
361
+ RAILS_VERSION=7.2 bundle exec rake test # against another Rails
362
+ ```
363
+
364
+ The end-to-end tests run only against a test server, never production. They read the server address and a test key from a JSON file (`{"serverUrl": "http://127.0.0.1:3141", "apiKey": "...", "deviceId": "..."}`):
365
+
366
+ ```bash
367
+ SMSTUNNEL_E2E_KEY_FILE=/path/to/test-key.json bundle exec rake test:e2e
368
+ ```
369
+
370
+ ## Changelog
371
+
372
+ See [CHANGELOG.md](https://github.com/narcisbodea/smstunnel-ruby/blob/main/CHANGELOG.md).
373
+
374
+ ## License
375
+
376
+ MIT, © NARBOWEB SRL. See [LICENSE](https://github.com/narcisbodea/smstunnel-ruby/blob/main/LICENSE).
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "yaml"
5
+ require "rails/generators"
6
+ require "active_support/encrypted_configuration"
7
+
8
+ module SMSTunnel
9
+ module Generators
10
+ # bin/rails generate smstunnel:install [--environment=production] [--skip-credentials]
11
+ #
12
+ # Creates config/initializers/smstunnel.rb and adds an empty +smstunnel:+ section to the encrypted credentials
13
+ # when their key is on this computer. It never writes a key and never changes an existing +smstunnel:+ section.
14
+ class InstallGenerator < ::Rails::Generators::Base
15
+ namespace "smstunnel:install"
16
+ source_root File.expand_path("templates", __dir__)
17
+
18
+ desc "Creates config/initializers/smstunnel.rb and adds an empty smstunnel: section to your encrypted " \
19
+ "credentials, for the API key of your SMSTunnel account."
20
+
21
+ class_option :environment, type: :string, default: nil,
22
+ desc: "Use config/credentials/ENVIRONMENT.yml.enc (per-environment credentials)"
23
+ class_option :skip_credentials, type: :boolean, default: false,
24
+ desc: "Don't touch the encrypted credentials"
25
+
26
+ CREDENTIALS_SECTION = <<~YAML
27
+ smstunnel:
28
+ # The API key of your SMSTunnel app: https://dashboard.smstunnel.io/apps (Apps → Add an app).
29
+ api_key:
30
+ # Optional: the phone that sends the texts (Phones → copy its ID). Empty: SMSTunnel picks one of yours.
31
+ device_id:
32
+ YAML
33
+
34
+ def check_options
35
+ environment # raises Thor::Error before any file is written
36
+ end
37
+
38
+ def create_initializer
39
+ template "smstunnel.rb", "config/initializers/smstunnel.rb"
40
+ end
41
+
42
+ def add_credentials_section
43
+ return if options[:skip_credentials]
44
+
45
+ credentials = encrypted_credentials
46
+ unless credentials.key
47
+ say_status :skip, "#{content_path}: no key to open it (#{key_path} or RAILS_MASTER_KEY)", :yellow
48
+ @credentials_left = true
49
+ return
50
+ end
51
+
52
+ if smstunnel_section?(credentials)
53
+ say_status :identical, "#{content_path} (it already has smstunnel:)", :blue
54
+ return
55
+ end
56
+
57
+ credentials.write(with_section(credentials.read))
58
+ say_status :append, "#{content_path} (smstunnel: api_key, device_id, both empty)", :green
59
+ rescue ActiveSupport::MessageEncryptor::InvalidMessage, ActiveSupport::EncryptedFile::InvalidKeyLengthError
60
+ say_status :skip, "#{content_path}: the key does not open it", :yellow
61
+ @credentials_left = true
62
+ rescue Psych::Exception => e
63
+ say_status :skip, "#{content_path}: could not read it as YAML (#{e.class})", :yellow
64
+ @credentials_left = true
65
+ end
66
+
67
+ def show_next_steps
68
+ say ""
69
+ say "Next:"
70
+ say " 1. Get a key: install the SMSTunnel app on your Android phone (Google Play), then in"
71
+ say " https://dashboard.smstunnel.io/apps open Apps → Add an app."
72
+ if options[:skip_credentials] || @credentials_left
73
+ say " 2. Run `#{edit_command}` and add:"
74
+ say ""
75
+ CREDENTIALS_SECTION.each_line { |line| say " #{line.chomp}" }
76
+ say ""
77
+ else
78
+ say " 2. Run `#{edit_command}` and paste the key after `api_key:`,"
79
+ end
80
+ say " or set the SMSTUNNEL_API_KEY environment variable."
81
+ say ' 3. Try it in `bin/rails console`: SMSTunnel.send_sms("+14155550123", "Hello from Rails")'
82
+ end
83
+
84
+ private
85
+
86
+ def encrypted_credentials
87
+ ActiveSupport::EncryptedConfiguration.new(
88
+ config_path: File.join(destination_root, content_path),
89
+ key_path: File.join(destination_root, key_path),
90
+ env_key: "RAILS_MASTER_KEY",
91
+ raise_if_missing_key: false
92
+ )
93
+ end
94
+
95
+ def smstunnel_section?(credentials)
96
+ contents = credentials.read
97
+ return false if contents.strip.empty?
98
+
99
+ parsed = YAML.safe_load(contents, permitted_classes: [Symbol, Date, Time], aliases: true)
100
+ parsed.is_a?(Hash) && (parsed.key?("smstunnel") || parsed.key?(:smstunnel))
101
+ end
102
+
103
+ def with_section(contents)
104
+ return CREDENTIALS_SECTION if contents.strip.empty?
105
+
106
+ "#{contents.rstrip}\n\n#{CREDENTIALS_SECTION}"
107
+ end
108
+
109
+ def environment
110
+ name = options[:environment].to_s.strip
111
+ return nil if name.empty?
112
+ return name if name.match?(/\A[a-z0-9_-]+\z/i)
113
+
114
+ raise Thor::Error, "--environment must be a name such as production (got #{name.inspect})."
115
+ end
116
+
117
+ def content_path
118
+ environment ? "config/credentials/#{environment}.yml.enc" : "config/credentials.yml.enc"
119
+ end
120
+
121
+ def key_path
122
+ environment ? "config/credentials/#{environment}.key" : "config/master.key"
123
+ end
124
+
125
+ def edit_command
126
+ environment ? "bin/rails credentials:edit --environment #{environment}" : "bin/rails credentials:edit"
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,17 @@
1
+ # SMSTunnel sends texts from your own Android phone, from your own number.
2
+ # Guide: https://github.com/narcisbodea/smstunnel-ruby#readme
3
+ SMSTunnel.configure do |config|
4
+ # The API key of your SMSTunnel app (https://dashboard.smstunnel.io/apps). Put it in your credentials
5
+ # (<%= edit_command %>: smstunnel: api_key: ...) or in the SMSTUNNEL_API_KEY environment variable.
6
+ config.api_key = Rails.application.credentials.dig(:smstunnel, :api_key).presence || ENV["SMSTUNNEL_API_KEY"]
7
+
8
+ # Optional: the phone that sends the texts (Phones → copy its ID). Without it, SMSTunnel picks one of yours.
9
+ config.device_id = Rails.application.credentials.dig(:smstunnel, :device_id).presence || ENV["SMSTUNNEL_DEVICE_ID"]
10
+
11
+ # Optional: completes numbers written without + or 00, e.g. "+40".
12
+ # config.default_country_code = "+40"
13
+
14
+ # Seconds to wait for an answer (default 30) and to connect (default 10).
15
+ # config.timeout = 30
16
+ # config.open_timeout = 10
17
+ end