thousandmails 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 +7 -0
- data/LICENSE +70 -0
- data/README.md +390 -0
- data/lib/thousandmails/client.rb +159 -0
- data/lib/thousandmails/resources/emails.rb +172 -0
- data/lib/thousandmails/resources/logs.rb +204 -0
- data/lib/thousandmails/resources/realtime.rb +112 -0
- data/lib/thousandmails/resources/stats.rb +122 -0
- data/lib/thousandmails/utils/attachments.rb +291 -0
- data/lib/thousandmails/utils/constants.rb +100 -0
- data/lib/thousandmails/utils/errors.rb +197 -0
- data/lib/thousandmails/utils/http.rb +287 -0
- data/lib/thousandmails/utils/options.rb +29 -0
- data/lib/thousandmails/utils/stream.rb +252 -0
- data/lib/thousandmails/utils/transport.rb +177 -0
- data/lib/thousandmails/utils/validate.rb +263 -0
- data/lib/thousandmails/version.rb +6 -0
- data/lib/thousandmails.rb +36 -0
- metadata +58 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 5b4a4b596ac5abce715b93136c69714c6020c3cb5c0fe5e3ef85309784171c7a
|
|
4
|
+
data.tar.gz: 1ac22010648ed66129b927163f5cc1b0c2e20584a461527c548ef9069e3609ff
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: b3b86c412bdf3ea27f520f7c285d0bb3f2f317a0bfce50f9ce93528e14fea27946c86bc5cb62e4e2128e85dfef2db54e7855d7d8f7bb967716b63f7de82d840f
|
|
7
|
+
data.tar.gz: d1be573a21663fbea2de6d6f7b78ec8b7785e1034f92aeb3da8a69de51ce49df9f87385975dfe6fc1a24a8e5d7af6ba3fb098e94d050b8f97c3cd7b41852417e
|
data/LICENSE
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
ThousandMails Ruby SDK — Licence
|
|
2
|
+
|
|
3
|
+
Copyright © 2026 Techinorm Solutions Pvt Ltd. All rights reserved.
|
|
4
|
+
|
|
5
|
+
This software is licensed, not sold. By installing, copying or using it you
|
|
6
|
+
accept the terms below. If you do not accept them, do not install or use it.
|
|
7
|
+
|
|
8
|
+
1. Grant of licence
|
|
9
|
+
|
|
10
|
+
Techinorm Solutions Pvt Ltd grants you a non-exclusive, non-transferable,
|
|
11
|
+
revocable, worldwide licence to:
|
|
12
|
+
|
|
13
|
+
a. install and use this software, in unmodified form, within your own
|
|
14
|
+
applications, for the sole purpose of accessing the ThousandMails service;
|
|
15
|
+
and
|
|
16
|
+
|
|
17
|
+
b. redistribute it, in unmodified form, only as an installed dependency of
|
|
18
|
+
those applications.
|
|
19
|
+
|
|
20
|
+
2. Restrictions
|
|
21
|
+
|
|
22
|
+
You may not:
|
|
23
|
+
|
|
24
|
+
a. modify, adapt, translate or otherwise alter the source code, or create
|
|
25
|
+
derivative works based on it, in whole or in part;
|
|
26
|
+
|
|
27
|
+
b. reverse engineer, decompile or disassemble the software, except to the
|
|
28
|
+
extent applicable law expressly permits despite this restriction;
|
|
29
|
+
|
|
30
|
+
c. remove, obscure or alter any copyright, trademark or other proprietary
|
|
31
|
+
notice contained in the software;
|
|
32
|
+
|
|
33
|
+
d. distribute, publish, sublicense, sell, rent, lease or lend the software
|
|
34
|
+
other than as permitted by section 1(b), or publish it — in whole or in
|
|
35
|
+
part, under this or any other name — as a separate package or as part of
|
|
36
|
+
a competing software development kit; or
|
|
37
|
+
|
|
38
|
+
e. use the software for any purpose other than accessing the ThousandMails
|
|
39
|
+
service.
|
|
40
|
+
|
|
41
|
+
3. Automated build steps
|
|
42
|
+
|
|
43
|
+
Bundling, minification, transpilation, compression, tree-shaking and similar
|
|
44
|
+
automated build steps that do not change the behaviour of the software are
|
|
45
|
+
permitted, and are not "modification" for the purposes of section 2(a).
|
|
46
|
+
|
|
47
|
+
4. Ownership
|
|
48
|
+
|
|
49
|
+
All right, title and interest in the software, including all intellectual
|
|
50
|
+
property rights in it, remain with Techinorm Solutions Pvt Ltd. No rights are
|
|
51
|
+
granted to you except those expressly set out in this licence.
|
|
52
|
+
|
|
53
|
+
5. No warranty
|
|
54
|
+
|
|
55
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
56
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
57
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
|
58
|
+
|
|
59
|
+
6. Limitation of liability
|
|
60
|
+
|
|
61
|
+
IN NO EVENT SHALL TECHINORM SOLUTIONS PVT LTD BE LIABLE FOR ANY CLAIM,
|
|
62
|
+
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
|
63
|
+
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
|
64
|
+
USE OF OR OTHER DEALINGS IN THE SOFTWARE.
|
|
65
|
+
|
|
66
|
+
7. Termination
|
|
67
|
+
|
|
68
|
+
This licence terminates automatically, without notice, if you breach any of
|
|
69
|
+
its terms. On termination you must stop using the software and destroy all
|
|
70
|
+
copies in your possession.
|
data/README.md
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
# thousandmails
|
|
2
|
+
|
|
3
|
+
Ruby SDK for ThousandMails — transactional email, attachments, statistics,
|
|
4
|
+
delivery logs and a live event stream, behind one client object.
|
|
5
|
+
|
|
6
|
+
- One thing to configure: your API key. No host, no setup step, no init call
|
|
7
|
+
- Zero dependencies — Ruby 3.0+, standard library only
|
|
8
|
+
- Local validation, so a typo fails in microseconds instead of a round trip
|
|
9
|
+
- Automatic idempotency keys on single sends, so a retry can't double-send
|
|
10
|
+
- Typed errors, retry with backoff, and a reconnecting event stream
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
gem install thousandmails
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Quick start
|
|
17
|
+
|
|
18
|
+
Your API key is the only thing to configure. Generate one on the in-app **API
|
|
19
|
+
keys** page, and you're sending:
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
require "thousandmails"
|
|
23
|
+
|
|
24
|
+
thousandmails = ThousandMails.new(api_key: "tm_live_...")
|
|
25
|
+
|
|
26
|
+
result = thousandmails.send_mail(
|
|
27
|
+
senderemail: "noreply@yourdomain.com",
|
|
28
|
+
to: "customer@example.com",
|
|
29
|
+
subject: "Your receipt",
|
|
30
|
+
html: "<p>Thanks for your order.</p>"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
puts result["status"], result["messageId"]
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Set `THOUSANDMAILS_API_KEY` in your environment and even that argument goes away:
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
thousandmails = ThousandMails.new
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`ThousandMails.new` is `ThousandMails::Client.new`; use whichever reads better.
|
|
43
|
+
|
|
44
|
+
Everything else — the host, timeouts, retries, idempotency keys, validation — is
|
|
45
|
+
configured for you with working defaults. There is no setup step, no
|
|
46
|
+
initialisation call, and nothing to install alongside it.
|
|
47
|
+
|
|
48
|
+
Messages and query filters are plain hashes whose keys are exactly what the API
|
|
49
|
+
documents, so anything you read in the API reference works here unchanged.
|
|
50
|
+
Symbol and string keys are both accepted. Responses come back as hashes with
|
|
51
|
+
**string** keys, matching the JSON on the wire.
|
|
52
|
+
|
|
53
|
+
Because every method also takes options (`timeout:`, `idempotency_key:`), you
|
|
54
|
+
can pass a message either way:
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
thousandmails.send_mail(senderemail: "...", to: "...", subject: "...", text: "...")
|
|
58
|
+
thousandmails.send_mail({ senderemail: "...", to: "..." }, timeout: 5_000)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
> **Timeouts are in milliseconds**, not seconds — `timeout: 30_000` is thirty
|
|
62
|
+
> seconds. The Node, PHP, Python and Ruby SDKs take the same number so a service
|
|
63
|
+
> running more than one of them configures them the same way.
|
|
64
|
+
|
|
65
|
+
## Methods
|
|
66
|
+
|
|
67
|
+
Every method is reachable two ways — through its resource and as a flat alias on
|
|
68
|
+
the client. `thousandmails.emails.send` and `thousandmails.send_mail` run the same
|
|
69
|
+
code; use whichever reads better.
|
|
70
|
+
|
|
71
|
+
### Sending — `thousandmails.emails`
|
|
72
|
+
|
|
73
|
+
| Method | Flat alias | Does |
|
|
74
|
+
| ----------------------------------------- | ------------------------------- | ----------------------------------------------------------------------------- |
|
|
75
|
+
| `send(message, **options)` | `send_mail` | Sends one email. Routes to the multipart path when the message carries files. |
|
|
76
|
+
| `send_batch(messages, **options)` | `send_batch` | Up to 100 emails in one request, each with its own outcome. |
|
|
77
|
+
| `send_with_attachments(message, ...)` | `send_mail_with_attachments` | One email with 1–5 files. |
|
|
78
|
+
| `send_batch_with_attachments(messages)` | `send_batch_with_attachments` | Up to 20 emails, each carrying its own files. |
|
|
79
|
+
| `get(message_id)` | `get_message` | Looks up a send by its record id or its SMTP message id. |
|
|
80
|
+
|
|
81
|
+
`send` shadows Ruby's `Object#send` on that object. `deliver` is an alias if you
|
|
82
|
+
would rather not.
|
|
83
|
+
|
|
84
|
+
### Statistics — `thousandmails.stats`
|
|
85
|
+
|
|
86
|
+
| Method | Flat alias | Does |
|
|
87
|
+
| -------------------- | ------------------------ | ------------------------------------------------------------- |
|
|
88
|
+
| `summary(params)` | `get_stats_summary` | Totals for a window, the preceding window, and derived rates. |
|
|
89
|
+
| `timeseries(params)` | `get_stats_timeseries` | The same metrics bucketed by day, week or month. |
|
|
90
|
+
| `by_sender(params)` | `get_stats_by_sender` | Totals grouped by sender address. |
|
|
91
|
+
| `by_tag(params)` | `get_stats_by_tag` | Send outcomes grouped by tag. |
|
|
92
|
+
|
|
93
|
+
### Realtime — `thousandmails.realtime`
|
|
94
|
+
|
|
95
|
+
| Method | Flat alias | Does |
|
|
96
|
+
| -------------------- | --------------------------- | -------------------------------------------------------- |
|
|
97
|
+
| `stats(params)` | `get_realtime_stats` | Every metric summed over the last N minutes (1–120). |
|
|
98
|
+
| `per_minute(params)` | `get_realtime_per_minute` | The last N per-minute buckets, oldest first. |
|
|
99
|
+
| `per_second(params)` | `get_realtime_per_second` | Per-second buckets over the last N seconds, zero-filled. |
|
|
100
|
+
| `activity(params)` | `get_realtime_activity` | The most recent events, newest first (1–100). |
|
|
101
|
+
| `stream(**options)` | `stream_events` | A live event stream. Returns an `EventStream`. |
|
|
102
|
+
|
|
103
|
+
### Logs — `thousandmails.logs`
|
|
104
|
+
|
|
105
|
+
| Method | Flat alias | Does |
|
|
106
|
+
| ------------------------------- | ----------------------- | --------------------------------------------------- |
|
|
107
|
+
| `list(query)` | `get_logs` | One page of events, newest first. |
|
|
108
|
+
| `iterate(query)` | `iterate_logs` | Enumerator over every matching event, paged for you.|
|
|
109
|
+
| `get(event_id)` | `get_log` | One event by id. |
|
|
110
|
+
| `export(query)` | `export_logs` | The filtered log as CSV text. |
|
|
111
|
+
| `export_to_file(path, query)` | `export_logs_to_file` | Writes that CSV to disk and returns the row count. |
|
|
112
|
+
|
|
113
|
+
Every method takes a final `timeout:` keyword (milliseconds, overriding the
|
|
114
|
+
client's; `0` to disable). Single sends also accept `idempotency_key:`.
|
|
115
|
+
|
|
116
|
+
## Sending
|
|
117
|
+
|
|
118
|
+
### Raw and templated
|
|
119
|
+
|
|
120
|
+
```ruby
|
|
121
|
+
# Raw
|
|
122
|
+
thousandmails.send_mail(
|
|
123
|
+
senderemail: "noreply@yourdomain.com",
|
|
124
|
+
to: ["ada@example.com", { name: "Bob", address: "bob@example.com" }],
|
|
125
|
+
cc: "manager@example.com",
|
|
126
|
+
bcc: "archive@example.com",
|
|
127
|
+
subject: "Welcome, {{name}}",
|
|
128
|
+
html: "<p>Hello {{name}}</p>",
|
|
129
|
+
templaterequiredfields: { name: "Ada" }
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# From a saved template
|
|
133
|
+
thousandmails.send_mail(
|
|
134
|
+
senderemail: "noreply@yourdomain.com",
|
|
135
|
+
to: "ada@example.com",
|
|
136
|
+
templateid: "tpl_abc123",
|
|
137
|
+
templaterequiredfields: { name: "Ada", order: "4417" }
|
|
138
|
+
)
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Send _either_ `templateid` _or_ `subject`/`text`/`html` — never both.
|
|
142
|
+
`{{placeholders}}` work in raw bodies as well as templates, and every one of them
|
|
143
|
+
needs a value.
|
|
144
|
+
|
|
145
|
+
Recipients accept a string, a comma-separated string, an array, or
|
|
146
|
+
`{ name:, address: }` hashes. Everyone on `to`/`cc` sees each other — use
|
|
147
|
+
`send_batch` for individually addressed mail. `bcc` is stripped from the message
|
|
148
|
+
and travels in the envelope only, hidden from every other recipient.
|
|
149
|
+
|
|
150
|
+
`senderemail` also answers to `from`, `templateid` to `templateId`, and
|
|
151
|
+
`templaterequiredfields` to `templateRequiredFields`.
|
|
152
|
+
|
|
153
|
+
### Attachments
|
|
154
|
+
|
|
155
|
+
`send_mail` routes to the multipart path automatically when the message has
|
|
156
|
+
`attachments`:
|
|
157
|
+
|
|
158
|
+
```ruby
|
|
159
|
+
thousandmails.send_mail(
|
|
160
|
+
senderemail: "billing@yourdomain.com",
|
|
161
|
+
to: "customer@example.com",
|
|
162
|
+
subject: "Invoice 4417",
|
|
163
|
+
text: "Attached.",
|
|
164
|
+
attachments: [
|
|
165
|
+
"./invoices/4417.pdf", # a path
|
|
166
|
+
{ path: "/tmp/tmp-2f8a.pdf", filename: "terms.pdf" }, # a path, renamed
|
|
167
|
+
{ filename: "lines.csv", content: "sku,qty\nA1,2" }, # text
|
|
168
|
+
{ filename: "logo.png", content: png_bytes } # bytes
|
|
169
|
+
]
|
|
170
|
+
)
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Limits, checked locally before anything uploads: 1–5 files per message, 5 MB per
|
|
174
|
+
file, 20 MB per request, unique filenames, and only
|
|
175
|
+
`csv, xls, xlsx, doc, docx, pdf, jpg, jpeg, png`. Files are also inspected on
|
|
176
|
+
arrival — a renamed `.exe` is rejected however it is labelled.
|
|
177
|
+
|
|
178
|
+
### Batches
|
|
179
|
+
|
|
180
|
+
```ruby
|
|
181
|
+
batch = thousandmails.send_batch([
|
|
182
|
+
{ senderemail: "noreply@yourdomain.com", to: "a@example.com",
|
|
183
|
+
subject: "Hi", text: "One" },
|
|
184
|
+
{ senderemail: "noreply@yourdomain.com", to: "b@example.com",
|
|
185
|
+
subject: "Hi", text: "Two" }
|
|
186
|
+
])
|
|
187
|
+
|
|
188
|
+
batch["results"].each do |entry|
|
|
189
|
+
warn "#{entry['index']} #{entry['error']['message']}" if entry["outcome"] == "error"
|
|
190
|
+
end
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
A batch **always returns** — each entry succeeds or fails on its own, so check
|
|
194
|
+
`outcome` per entry rather than relying on the call not raising. Up to 100
|
|
195
|
+
messages, or 20 for the attachment variant, where every message must carry at
|
|
196
|
+
least one file.
|
|
197
|
+
|
|
198
|
+
## Idempotency
|
|
199
|
+
|
|
200
|
+
Single sends carry an idempotency key by default, generated per call. If the SDK
|
|
201
|
+
retries after a timeout or a server error, the key is recognised and the original
|
|
202
|
+
result comes back instead of a second send.
|
|
203
|
+
|
|
204
|
+
```ruby
|
|
205
|
+
# Your own key, stable across process restarts — de-duplicates at your level too
|
|
206
|
+
thousandmails.send_mail(message, idempotency_key: "receipt-#{order_id}")
|
|
207
|
+
|
|
208
|
+
# Opt out
|
|
209
|
+
thousandmails.send_mail(message, idempotency_key: false)
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Keys do not apply to batches, so batch sends are never retried automatically.
|
|
213
|
+
|
|
214
|
+
## Retries
|
|
215
|
+
|
|
216
|
+
`max_retries` (default 2) applies with exponential backoff and full jitter:
|
|
217
|
+
|
|
218
|
+
| Situation | Retried? |
|
|
219
|
+
| ----------------------------------------- | ------------------------------------------- |
|
|
220
|
+
| Rate limited | yes — the request was refused untouched |
|
|
221
|
+
| Upload capacity reached | yes — same reason, honours `Retry-After` |
|
|
222
|
+
| Server error, timeout, dropped connection | only with an idempotency key |
|
|
223
|
+
| Batch sends | never |
|
|
224
|
+
| Any other rejection | never |
|
|
225
|
+
|
|
226
|
+
A `Retry-After` longer than the SDK is willing to wait (8s) is not slept
|
|
227
|
+
through. The window would still be shut when the retry landed, so the call fails
|
|
228
|
+
immediately instead and `error.retry_after` carries the server's real figure for
|
|
229
|
+
you to back off against.
|
|
230
|
+
|
|
231
|
+
`timeout` applies **per attempt**, not per call: a retried request gets a fresh
|
|
232
|
+
window, so a call can take up to `(max_retries + 1) × timeout` in the worst case.
|
|
233
|
+
|
|
234
|
+
`thousandmails.rate_limit` holds `{ "limit", "remaining", "reset" }` from the last
|
|
235
|
+
response so you can pace yourself before being throttled. The budget is per API
|
|
236
|
+
key — 1000 requests per 15 minutes.
|
|
237
|
+
|
|
238
|
+
## Reading
|
|
239
|
+
|
|
240
|
+
```ruby
|
|
241
|
+
summary = thousandmails.get_stats_summary(from: "2026-07-01", to: "2026-07-31")
|
|
242
|
+
puts summary["totals"]["delivered"], summary["rates"]["openRate"]
|
|
243
|
+
|
|
244
|
+
thousandmails.get_stats_timeseries(from: "2026-07-01", interval: "week")
|
|
245
|
+
thousandmails.get_stats_by_sender
|
|
246
|
+
thousandmails.get_realtime_stats(minutes: 15)
|
|
247
|
+
|
|
248
|
+
# One page, or every event
|
|
249
|
+
page = thousandmails.get_logs(event: "bounced", pageSize: 100)
|
|
250
|
+
thousandmails.iterate_logs(event: "bounced").each do |event|
|
|
251
|
+
puts "#{event['recipient']} #{event['dsn']}"
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
thousandmails.export_logs_to_file("./bounces.csv", event: "bounced")
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
Dates are `YYYY-MM-DD` (UTC), a `Date`, or a `Time`. An unparsable date is
|
|
258
|
+
silently ignored upstream and would quietly widen your window, so the SDK rejects
|
|
259
|
+
it instead.
|
|
260
|
+
|
|
261
|
+
### Live event stream
|
|
262
|
+
|
|
263
|
+
```ruby
|
|
264
|
+
stream = thousandmails.stream_events
|
|
265
|
+
|
|
266
|
+
stream.on("email") { |event| puts "#{event['type']} #{event['recipient']}" }
|
|
267
|
+
stream.on("error") { |error| warn "stream: #{error.message}" }
|
|
268
|
+
stream.listen # blocks, dispatching to the handlers above
|
|
269
|
+
|
|
270
|
+
# …or iterate
|
|
271
|
+
thousandmails.stream_events.events.each do |event|
|
|
272
|
+
handle_bounce(event) if event["type"] == "bounced"
|
|
273
|
+
break # breaking out closes the connection
|
|
274
|
+
end
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
`EventStream` dispatches `ready`, `email`, `heartbeat`, `frame`, `open`, `close`,
|
|
278
|
+
`error` and `end`. It reconnects with backoff after a dropped connection and
|
|
279
|
+
gives up on an authentication failure, which will not resolve itself. Pass
|
|
280
|
+
`reconnect: false` to disable.
|
|
281
|
+
|
|
282
|
+
Nothing is queued: an event goes to exactly one consumer and is then dropped, so
|
|
283
|
+
a stream held open for days does not grow. Always `close` it (or `break` out of
|
|
284
|
+
the loop) — the connection is held open by design and blocks the process
|
|
285
|
+
otherwise.
|
|
286
|
+
|
|
287
|
+
Closing from a handler, or breaking out of `events`, takes effect at once.
|
|
288
|
+
Closing from *another* thread sets `closed?` immediately, but the reader stays
|
|
289
|
+
parked on a blocking socket read until the next frame arrives — at worst one
|
|
290
|
+
heartbeat, which the server sends every 25 seconds.
|
|
291
|
+
|
|
292
|
+
> **A reconnect does not replay what it missed.** The SDK sends `Last-Event-ID`
|
|
293
|
+
> when the server labels frames with an `id:`, but the stream endpoint does not
|
|
294
|
+
> label them today, so a reconnect resumes from the live edge and events that
|
|
295
|
+
> occurred during the gap are not delivered. Where you cannot afford to miss
|
|
296
|
+
> one, treat the stream as a low-latency notification and reconcile against
|
|
297
|
+
> `client.logs` — the log is the record of what happened, the stream is not.
|
|
298
|
+
|
|
299
|
+
## Errors
|
|
300
|
+
|
|
301
|
+
```ruby
|
|
302
|
+
begin
|
|
303
|
+
thousandmails.send_mail(message)
|
|
304
|
+
rescue ThousandMails::InvalidInputError
|
|
305
|
+
# rejected locally — nothing was sent
|
|
306
|
+
rescue ThousandMails::ValidationError => e
|
|
307
|
+
warn e.missing_fields.inspect
|
|
308
|
+
rescue ThousandMails::PermissionError => e
|
|
309
|
+
warn "suppressed: #{e.suppressed.inspect}"
|
|
310
|
+
rescue ThousandMails::RateLimitError => e
|
|
311
|
+
warn "retry after #{e.retry_after} seconds"
|
|
312
|
+
end
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
Every error is a `ThousandMails::Error`. Anything that reached the service and came
|
|
316
|
+
back a failure also carries `status`, `body`, `headers`, `http_method` and `url`.
|
|
317
|
+
|
|
318
|
+
| Class | `status` | Meaning |
|
|
319
|
+
| ------------------------------------------------ | -------- | ---------------------------------------------- |
|
|
320
|
+
| `InvalidInputError` | — | Rejected locally, before anything was sent |
|
|
321
|
+
| `BadRequestError` | 400 | Unknown template, sender, message or event id |
|
|
322
|
+
| `AuthenticationError` | 401 | Missing, malformed or unknown key |
|
|
323
|
+
| `APIError` | 402 | Your plan does not cover it — see `body` |
|
|
324
|
+
| `PermissionError` | 403 | Inactive key, IP-pinned sender, all suppressed |
|
|
325
|
+
| `PayloadTooLargeError` | 413 | An attachment or field went over the limit |
|
|
326
|
+
| `UnsupportedMediaTypeError` | 415 | An upload was not encoded as multipart |
|
|
327
|
+
| `ValidationError` | 422 | Understood and rejected |
|
|
328
|
+
| `RateLimitError` | 429 | Throttled — see `retry_after` |
|
|
329
|
+
| `ServiceUnavailableError` | 503 | Upload capacity reached — safe to retry |
|
|
330
|
+
| `ServerError` | 5xx | Service or transport failure |
|
|
331
|
+
| `TimeoutError` / `ConnectionError` | — | The request never completed |
|
|
332
|
+
| `ConfigError` | — | Bad or missing client configuration |
|
|
333
|
+
|
|
334
|
+
The base class is `ThousandMails::Error` rather than `ThousandMailsError` — the
|
|
335
|
+
namespace already says which library it came from. Everything else is named the
|
|
336
|
+
same as in the Node, PHP and Python SDKs.
|
|
337
|
+
|
|
338
|
+
### Plan limits
|
|
339
|
+
|
|
340
|
+
Your key inherits its account's plan, so the same subscription that governs the
|
|
341
|
+
dashboard governs this SDK. Two refusals come from there. Both are `402`, both
|
|
342
|
+
arrive as `APIError` with the response body intact, and neither is retried —
|
|
343
|
+
the answer will not change until the plan does or the billing period rolls over.
|
|
344
|
+
|
|
345
|
+
- **`Not included in your plan`** — the call needs an entitlement the plan does
|
|
346
|
+
not carry. The stats, realtime and log methods all need `apiAccess` at
|
|
347
|
+
`full`; a `limited` plan may send and read back its own send records, and
|
|
348
|
+
gets this on everything else. The body names the `feature`, what you hold
|
|
349
|
+
(`current`) and what is `required`.
|
|
350
|
+
- **`Monthly send limit reached`** — the period's send allowance is spent. The
|
|
351
|
+
body carries `limit`, `used`, `requested`, `remaining` and `resetsAt`. A batch
|
|
352
|
+
is weighed whole, so an over-cap batch sends nothing rather than part of
|
|
353
|
+
itself.
|
|
354
|
+
|
|
355
|
+
A `503` carrying `Entitlement check unavailable` is a different thing: the
|
|
356
|
+
service could not read your plan, not a statement about it. That one is
|
|
357
|
+
transient and the retry policy handles it for you.
|
|
358
|
+
|
|
359
|
+
Full detail in the
|
|
360
|
+
[client API reference](../../../backend/client/docs/api-reference.md#plan-entitlements).
|
|
361
|
+
|
|
362
|
+
## Bring your own HTTP client
|
|
363
|
+
|
|
364
|
+
The SDK talks to the network through a small `Transport` interface and ships a
|
|
365
|
+
`Net::HTTP` implementation, which is what keeps the dependency list empty.
|
|
366
|
+
Supply your own to route through a pooled client or a test double:
|
|
367
|
+
|
|
368
|
+
```ruby
|
|
369
|
+
class MyTransport < ThousandMails::Transports::Transport
|
|
370
|
+
def send_request(request) = ...
|
|
371
|
+
def stream(request, &on_chunk) = ...
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
thousandmails = ThousandMails.new(api_key: ..., transport: MyTransport.new)
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
## License
|
|
378
|
+
|
|
379
|
+
Proprietary. Copyright © 2026 Techinorm Solutions Pvt Ltd. All rights reserved.
|
|
380
|
+
|
|
381
|
+
You may install and use this SDK, **unmodified**, inside your own applications
|
|
382
|
+
to access the ThousandMails service, and redistribute it unmodified as a dependency
|
|
383
|
+
of those applications.
|
|
384
|
+
|
|
385
|
+
You may not modify or adapt the source, create derivative works from it, reverse
|
|
386
|
+
engineer it, strip its notices, or republish it as a separate gem. Automated
|
|
387
|
+
build steps that don't change behaviour — bundling, packaging — are fine.
|
|
388
|
+
|
|
389
|
+
The software is provided "as is", without warranty of any kind. See
|
|
390
|
+
[LICENSE](LICENSE) for the full terms.
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ThousandMails
|
|
4
|
+
# The ThousandMails client.
|
|
5
|
+
#
|
|
6
|
+
# require "thousandmails"
|
|
7
|
+
#
|
|
8
|
+
# thousandmails = ThousandMails::Client.new(api_key: ENV["THOUSANDMAILS_API_KEY"])
|
|
9
|
+
# thousandmails.send_mail(senderemail: ..., to: ..., subject: ..., html: ...)
|
|
10
|
+
#
|
|
11
|
+
# Every endpoint is reachable two ways: through its resource
|
|
12
|
+
# (`client.emails.send`) or through a flat alias on the client itself
|
|
13
|
+
# (`client.send_mail`). They run the same code — pick whichever reads better.
|
|
14
|
+
class Client
|
|
15
|
+
attr_reader :http, :emails, :stats, :realtime, :logs
|
|
16
|
+
# Run the local pre-flight checks before sending.
|
|
17
|
+
attr_reader :validate_input
|
|
18
|
+
# Mint an Idempotency-Key per single send so a retry can't double-send.
|
|
19
|
+
attr_reader :auto_idempotency
|
|
20
|
+
attr_reader :validate_options
|
|
21
|
+
|
|
22
|
+
# @param api_key [String] defaults to the THOUSANDMAILS_API_KEY environment variable
|
|
23
|
+
# @param base_url [String] defaults to THOUSANDMAILS_BASE_URL, then the shipped host
|
|
24
|
+
# @param timeout [Integer] per-attempt timeout in **milliseconds** (default
|
|
25
|
+
# 30000); 0 disables it. Milliseconds, not seconds, so the Node, PHP,
|
|
26
|
+
# Python and Ruby SDKs configure identically. A retry gets a fresh window,
|
|
27
|
+
# so a call can take up to (max_retries + 1) x timeout in the worst case.
|
|
28
|
+
# @param max_retries [Integer] retries for safe requests (default 2)
|
|
29
|
+
# @param validate_input [Boolean] run the local pre-flight checks (default true)
|
|
30
|
+
# @param validate_recipients [Boolean] check recipient syntax locally (default true)
|
|
31
|
+
# @param auto_idempotency [Boolean] mint a key per single send (default true)
|
|
32
|
+
# @param headers [Hash] extra headers on every request
|
|
33
|
+
# @param transport [ThousandMails::Transports::Transport] inject an HTTP implementation
|
|
34
|
+
def initialize(api_key: nil, base_url: nil, timeout: nil, max_retries: nil,
|
|
35
|
+
validate_input: true, validate_recipients: true,
|
|
36
|
+
auto_idempotency: true, headers: nil, user_agent: nil,
|
|
37
|
+
transport: nil)
|
|
38
|
+
@http = Http.new(
|
|
39
|
+
api_key: api_key,
|
|
40
|
+
base_url: base_url,
|
|
41
|
+
timeout: timeout,
|
|
42
|
+
max_retries: max_retries,
|
|
43
|
+
headers: headers,
|
|
44
|
+
user_agent: user_agent,
|
|
45
|
+
transport: transport
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
@validate_input = validate_input != false
|
|
49
|
+
@auto_idempotency = auto_idempotency != false
|
|
50
|
+
@validate_options = { validate_recipients: validate_recipients != false }
|
|
51
|
+
|
|
52
|
+
@emails = Resources::Emails.new(self)
|
|
53
|
+
@stats = Resources::Stats.new(self)
|
|
54
|
+
@realtime = Resources::Realtime.new(self)
|
|
55
|
+
@logs = Resources::Logs.new(self)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Where this client points.
|
|
59
|
+
def base_url
|
|
60
|
+
http.base_url
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Rate-limit headers from the most recent response, or nil before the first
|
|
64
|
+
# one: `{ "limit" => ..., "remaining" => ..., "reset" => ... }`.
|
|
65
|
+
#
|
|
66
|
+
# The budget is per API key (1000 requests per 15 minutes), which is the
|
|
67
|
+
# tighter of the two limiters in front of the API and therefore the one worth
|
|
68
|
+
# pacing against.
|
|
69
|
+
def rate_limit
|
|
70
|
+
http.last_rate_limit
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Escape hatch for anything this SDK doesn't wrap yet. `path` is relative to
|
|
74
|
+
# /mailerapi/client, and the response body is returned as-is.
|
|
75
|
+
#
|
|
76
|
+
# client.request("GET", "/stats/summary")
|
|
77
|
+
def request(http_method, path, **options)
|
|
78
|
+
http.request(http_method, path, **options)[:data]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# --- flat aliases --------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
def send_mail(message = nil, **options)
|
|
84
|
+
emails.send(message, **options)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def send_batch(messages = nil, **options)
|
|
88
|
+
emails.send_batch(messages, **options)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def send_mail_with_attachments(message = nil, **options)
|
|
92
|
+
emails.send_with_attachments(message, **options)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def send_batch_with_attachments(messages = nil, **options)
|
|
96
|
+
emails.send_batch_with_attachments(messages, **options)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def get_message(message_id, **options)
|
|
100
|
+
emails.get(message_id, **options)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def get_stats_summary(params = nil, **options)
|
|
104
|
+
stats.summary(params, **options)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def get_stats_timeseries(params = nil, **options)
|
|
108
|
+
stats.timeseries(params, **options)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def get_stats_by_sender(params = nil, **options)
|
|
112
|
+
stats.by_sender(params, **options)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def get_stats_by_tag(params = nil, **options)
|
|
116
|
+
stats.by_tag(params, **options)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def get_realtime_stats(params = nil, **options)
|
|
120
|
+
realtime.stats(params, **options)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def get_realtime_per_minute(params = nil, **options)
|
|
124
|
+
realtime.per_minute(params, **options)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def get_realtime_per_second(params = nil, **options)
|
|
128
|
+
realtime.per_second(params, **options)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def get_realtime_activity(params = nil, **options)
|
|
132
|
+
realtime.activity(params, **options)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def stream_events(**options)
|
|
136
|
+
realtime.stream(**options)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def get_logs(query = nil, **options)
|
|
140
|
+
logs.list(query, **options)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def iterate_logs(query = nil, **options, &block)
|
|
144
|
+
logs.iterate(query, **options, &block)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def get_log(event_id, **options)
|
|
148
|
+
logs.get(event_id, **options)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def export_logs(query = nil, **options)
|
|
152
|
+
logs.export(query, **options)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def export_logs_to_file(file_path, query = nil, **options)
|
|
156
|
+
logs.export_to_file(file_path, query, **options)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|