letmesendemail 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +14 -0
- data/LICENSE.md +21 -0
- data/README.md +220 -0
- data/docs/docs.md +513 -0
- data/examples/attachments.rb +21 -0
- data/examples/errors.rb +18 -0
- data/examples/idempotency.rb +17 -0
- data/examples/pagination.rb +20 -0
- data/examples/send.rb +16 -0
- data/examples/template.rb +18 -0
- data/examples/webhooks.rb +12 -0
- data/lib/letmesendemail/client.rb +109 -0
- data/lib/letmesendemail/config.rb +80 -0
- data/lib/letmesendemail/errors.rb +60 -0
- data/lib/letmesendemail/http_transport.rb +129 -0
- data/lib/letmesendemail/models.rb +99 -0
- data/lib/letmesendemail/resources/base.rb +31 -0
- data/lib/letmesendemail/resources/contact_categories.rb +45 -0
- data/lib/letmesendemail/resources/contacts.rb +60 -0
- data/lib/letmesendemail/resources/domains.rb +28 -0
- data/lib/letmesendemail/resources/email_topics.rb +53 -0
- data/lib/letmesendemail/resources/emails.rb +76 -0
- data/lib/letmesendemail/version.rb +6 -0
- data/lib/letmesendemail/webhooks.rb +109 -0
- data/lib/letmesendemail.rb +15 -0
- metadata +82 -0
data/docs/docs.md
ADDED
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
# letmesend.email SDK for Ruby
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
The `letmesendemail` gem is the official Ruby client for the letmesend.email API. It provides resource-oriented email, domain, contact, contact-category, and email-topic operations; structured errors; cursor pagination; conservative retries; and webhook verification.
|
|
6
|
+
|
|
7
|
+
API responses are returned as `LetMeSendEmail::Models::Model` values with hash-style access and recursive serialization.
|
|
8
|
+
|
|
9
|
+
## Requirements
|
|
10
|
+
|
|
11
|
+
- Ruby 3.1 or newer
|
|
12
|
+
- Bundler or RubyGems
|
|
13
|
+
- A letmesend.email API key
|
|
14
|
+
|
|
15
|
+
The SDK uses Ruby's `net/http` and `json` libraries. Its only declared runtime gem is `base64`.
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
With Bundler:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
bundle add letmesendemail
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Or install the gem directly:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
gem install letmesendemail
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Load the package with:
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
require "letmesendemail"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Authentication
|
|
38
|
+
|
|
39
|
+
Create an API key in your letmesend.email account and expose it to the application as `LETMESENDEMAIL_API_KEY`. Do not commit API keys or place them in source code.
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
api_key = ENV.fetch("LETMESENDEMAIL_API_KEY")
|
|
43
|
+
raise "LETMESENDEMAIL_API_KEY must not be empty" if api_key.empty?
|
|
44
|
+
|
|
45
|
+
client = LetMeSendEmail::Client.new(api_key: api_key)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The SDK sends the key as a Bearer token. It rejects empty keys, surrounding whitespace, and control characters.
|
|
49
|
+
|
|
50
|
+
## Quick Start
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
require "letmesendemail"
|
|
54
|
+
|
|
55
|
+
api_key = ENV.fetch("LETMESENDEMAIL_API_KEY")
|
|
56
|
+
raise "LETMESENDEMAIL_API_KEY must not be empty" if api_key.empty?
|
|
57
|
+
|
|
58
|
+
client = LetMeSendEmail::Client.new(api_key: api_key)
|
|
59
|
+
|
|
60
|
+
begin
|
|
61
|
+
result = client.emails.send(
|
|
62
|
+
from: "Acme <hello@example.com>",
|
|
63
|
+
to: ["customer@example.net"],
|
|
64
|
+
subject: "Welcome",
|
|
65
|
+
html: "<p>Thanks for joining.</p>",
|
|
66
|
+
text: "Thanks for joining."
|
|
67
|
+
)
|
|
68
|
+
puts "Accepted email #{result['id']} with status #{result['status']}"
|
|
69
|
+
rescue LetMeSendEmail::Error => e
|
|
70
|
+
warn "Email request failed: #{e.message}"
|
|
71
|
+
end
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Client Configuration
|
|
75
|
+
|
|
76
|
+
For custom settings, create a configuration object before creating the client:
|
|
77
|
+
|
|
78
|
+
```ruby
|
|
79
|
+
config = LetMeSendEmail::Config.new(api_key)
|
|
80
|
+
config.base_url = "https://letmesend.email/api/v1"
|
|
81
|
+
config.timeout_ms = 30_000
|
|
82
|
+
config.retries = 2
|
|
83
|
+
|
|
84
|
+
client = LetMeSendEmail::Client.new(config: config)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
| Option | Default | Behavior |
|
|
88
|
+
|---|---:|---|
|
|
89
|
+
| `api_key` | Required | Bearer token; must be a non-empty safe string. |
|
|
90
|
+
| `base_url` | `https://letmesend.email/api/v1` | Absolute HTTP or HTTPS URL without credentials, query, or fragment. Trailing slashes are removed. |
|
|
91
|
+
| `timeout_ms` | `30000` | Positive timeout in milliseconds applied to connection, read, and write phases. |
|
|
92
|
+
| `retries` | `0` | Additional attempts, from 0 through 20. |
|
|
93
|
+
|
|
94
|
+
Requests use `letmesendemail-ruby/<version>` as their User-Agent. Supplying both `api_key:` and `config:` is rejected.
|
|
95
|
+
The client takes an immutable snapshot of the validated configuration when it is created; later changes to the original configuration object do not affect that client.
|
|
96
|
+
|
|
97
|
+
### Retry behavior
|
|
98
|
+
|
|
99
|
+
When retries are enabled, GET and DELETE requests may retry. POST and PUT requests require a non-empty `Idempotency-Key`; email and domain verification calls do not retry. The SDK retries network failures, timeouts, HTTP 408, 500, 502, 503, and 504. HTTP 429 retries only when `Retry-After` is a positive delta-seconds value or future HTTP date no more than 300 seconds away.
|
|
100
|
+
|
|
101
|
+
Ordinary retry delay starts at 100 milliseconds, doubles per retry, adds random jitter from zero through 25% of that delay, and caps at 300 seconds. A valid `Retry-After` delay is used exactly without jitter.
|
|
102
|
+
|
|
103
|
+
## Emails
|
|
104
|
+
|
|
105
|
+
Email operations are available through `client.emails`. Every method returns a `LetMeSendEmail::Models::Model`.
|
|
106
|
+
|
|
107
|
+
### Send an Email
|
|
108
|
+
|
|
109
|
+
```ruby
|
|
110
|
+
result = client.emails.send(
|
|
111
|
+
from: "Acme <hello@example.com>",
|
|
112
|
+
to: ["customer@example.net"],
|
|
113
|
+
subject: "Your receipt",
|
|
114
|
+
html: "<p>Your receipt is ready.</p>",
|
|
115
|
+
text: "Your receipt is ready.",
|
|
116
|
+
type: "transactional",
|
|
117
|
+
event_name: "receipt.created",
|
|
118
|
+
email_topic_id: "topic_123",
|
|
119
|
+
reply_to: ["support@example.com"],
|
|
120
|
+
cc: ["accounts@example.com"],
|
|
121
|
+
bcc: ["archive@example.com"],
|
|
122
|
+
headers: { "X-Order-ID" => "order_123" },
|
|
123
|
+
idempotency_key: "receipt-order-123"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
puts result["id"]
|
|
127
|
+
puts result["status"]
|
|
128
|
+
puts result["emails"].inspect
|
|
129
|
+
puts result["restricted_emails"].inspect
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
`from`, `to`, and `subject` are required. Provide at least one of `html` or `text` as required by your email workflow. `type` supports `broadcast` and `transactional`. Address arrays accept plain addresses or display-name forms such as `Taylor <customer@example.net>`.
|
|
133
|
+
|
|
134
|
+
### Send with a Template
|
|
135
|
+
|
|
136
|
+
```ruby
|
|
137
|
+
result = client.emails.send_with_template(
|
|
138
|
+
from: "Acme <hello@example.com>",
|
|
139
|
+
to: ["customer@example.net"],
|
|
140
|
+
template_id: "template_123",
|
|
141
|
+
subject: "Order update",
|
|
142
|
+
template_variables: [
|
|
143
|
+
{ key: "CUSTOMER_NAME", type: "string", value: "Taylor" },
|
|
144
|
+
{ key: "ORDER_NUMBER", type: "number", value: 12_345 }
|
|
145
|
+
],
|
|
146
|
+
idempotency_key: "template-order-123"
|
|
147
|
+
)
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
`from`, `to`, and `template_id` are required. Template variables are arrays of hashes containing `key`, `type`, and `value`. Number values must be Ruby numeric values rather than numeric strings.
|
|
151
|
+
|
|
152
|
+
Template sending also accepts `type`, `event_name`, `email_topic_id`, `reply_to`, `cc`, `bcc`, `headers`, `attachments`, and `idempotency_key`.
|
|
153
|
+
|
|
154
|
+
### Attachments
|
|
155
|
+
|
|
156
|
+
An attachment uses `name` and exactly one of `path` or Base64-encoded `content`. Optional fields are `mime`, `content_id`, and `content_disposition`. `content_disposition` supports `attachment` and `inline`. Use `content_id` to reference an inline attachment from HTML.
|
|
157
|
+
|
|
158
|
+
```ruby
|
|
159
|
+
require "base64"
|
|
160
|
+
|
|
161
|
+
encoded_content = Base64.strict_encode64("Example attachment\n".b)
|
|
162
|
+
|
|
163
|
+
result = client.emails.send(
|
|
164
|
+
from: "Acme <hello@example.com>",
|
|
165
|
+
to: ["customer@example.net"],
|
|
166
|
+
subject: "Your file",
|
|
167
|
+
text: "The requested file is attached.",
|
|
168
|
+
attachments: [{
|
|
169
|
+
name: "message.txt",
|
|
170
|
+
content: encoded_content,
|
|
171
|
+
mime: "text/plain",
|
|
172
|
+
content_disposition: "attachment"
|
|
173
|
+
}]
|
|
174
|
+
)
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
A remote attachment can use `path` instead:
|
|
178
|
+
|
|
179
|
+
```ruby
|
|
180
|
+
attachments = [{
|
|
181
|
+
name: "invoice.pdf",
|
|
182
|
+
path: "https://files.example.com/invoice.pdf",
|
|
183
|
+
mime: "application/pdf",
|
|
184
|
+
content_disposition: "attachment"
|
|
185
|
+
}]
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
Do not send `path` and `content` together. `download_url`, attachment `id`, and `size` are response fields and must not be used in requests.
|
|
189
|
+
|
|
190
|
+
### Idempotency
|
|
191
|
+
|
|
192
|
+
Pass a stable, unique idempotency key when a send might be retried:
|
|
193
|
+
|
|
194
|
+
```ruby
|
|
195
|
+
result = client.emails.send(
|
|
196
|
+
from: "Acme <hello@example.com>",
|
|
197
|
+
to: ["customer@example.net"],
|
|
198
|
+
subject: "Receipt",
|
|
199
|
+
text: "Your receipt is ready.",
|
|
200
|
+
idempotency_key: "receipt-order-123"
|
|
201
|
+
)
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
The key is sent in the `Idempotency-Key` header and is never placed in the JSON response model.
|
|
205
|
+
|
|
206
|
+
### Verify an Email Address
|
|
207
|
+
|
|
208
|
+
```ruby
|
|
209
|
+
verification = client.emails.verify("customer@example.net")
|
|
210
|
+
puts verification["status"]
|
|
211
|
+
puts verification["score"]
|
|
212
|
+
puts verification["domain_exists"]
|
|
213
|
+
puts verification["valid_syntax"]
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Verification results can also contain `disposable`, `role_based`, `has_mailbox`, `receive_email`, `mx_records`, and `belongs_to`. Verification is deliberately never retried.
|
|
217
|
+
|
|
218
|
+
### List Emails
|
|
219
|
+
|
|
220
|
+
```ruby
|
|
221
|
+
first = client.emails.list(per_page: 20)
|
|
222
|
+
items = first["data"]
|
|
223
|
+
pagination = first["pagination"]
|
|
224
|
+
|
|
225
|
+
if pagination["has_more"] && !items.empty?
|
|
226
|
+
next_page = client.emails.list(per_page: 20, after: items.last["id"])
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
unless items.empty?
|
|
230
|
+
previous_page = client.emails.list(per_page: 20, before: items.first["id"])
|
|
231
|
+
end
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
Email list items include `id`, `status`, `subject`, `event_name`, `type`, `created_at`, `sent_at`, `recipients_count`, and `attachments_count`.
|
|
235
|
+
|
|
236
|
+
### Get an Email
|
|
237
|
+
|
|
238
|
+
```ruby
|
|
239
|
+
email = client.emails.get("email_123")
|
|
240
|
+
|
|
241
|
+
email["recipients"].each do |recipient|
|
|
242
|
+
puts "#{recipient['email_address']}: #{recipient['status']}"
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
email["attachments"].each do |attachment|
|
|
246
|
+
puts "#{attachment['name']}: #{attachment['download_url']}"
|
|
247
|
+
end
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
Detailed recipients expose delivery, open, click, bounce, complaint, failure, suppression, and timestamp fields returned by the API. Detailed attachments include `id`, `name`, `mime`, `content_id`, `content_disposition`, `size`, and `download_url`.
|
|
251
|
+
|
|
252
|
+
## Domains
|
|
253
|
+
|
|
254
|
+
```ruby
|
|
255
|
+
first = client.domains.list(per_page: 20)
|
|
256
|
+
items = first["data"]
|
|
257
|
+
|
|
258
|
+
if first["pagination"]["has_more"] && !items.empty?
|
|
259
|
+
next_page = client.domains.list(per_page: 20, after: items.last["id"])
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
unless items.empty?
|
|
263
|
+
previous_page = client.domains.list(per_page: 20, before: items.first["id"])
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
domain = client.domains.get("domain_123")
|
|
267
|
+
verification = client.domains.verify("example.com")
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
`list` returns domain models and pagination. `get(id)` returns `id`, `domain_name`, `status`, and `created_at`. `verify(domain)` returns the verification status and is never retried.
|
|
271
|
+
|
|
272
|
+
## Contacts
|
|
273
|
+
|
|
274
|
+
```ruby
|
|
275
|
+
contact = client.contacts.create(
|
|
276
|
+
email: "customer@example.net",
|
|
277
|
+
first_name: "Taylor",
|
|
278
|
+
last_name: "Morgan",
|
|
279
|
+
phone: "+14165550123",
|
|
280
|
+
is_globally_unsubscribed: false,
|
|
281
|
+
categories: ["category_123"],
|
|
282
|
+
email_topics: ["topic_123"]
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
client.contacts.update(
|
|
286
|
+
contact["id"],
|
|
287
|
+
first_name: "Alex",
|
|
288
|
+
categories: ["category_456"],
|
|
289
|
+
sync_categories: true
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
stored = client.contacts.get(contact["id"])
|
|
293
|
+
client.contacts.delete(contact["id"])
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
`update` also supports `last_name`, `phone`, `is_globally_unsubscribed`, `email_topics`, and `sync_email_topics`.
|
|
297
|
+
|
|
298
|
+
Contact pagination:
|
|
299
|
+
|
|
300
|
+
```ruby
|
|
301
|
+
first = client.contacts.list(per_page: 20)
|
|
302
|
+
items = first["data"]
|
|
303
|
+
next_page = client.contacts.list(per_page: 20, after: items.last["id"]) if first["pagination"]["has_more"] && !items.empty?
|
|
304
|
+
previous_page = client.contacts.list(per_page: 20, before: items.first["id"]) unless items.empty?
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
## Contact Categories
|
|
308
|
+
|
|
309
|
+
```ruby
|
|
310
|
+
category = client.contact_categories.create(name: "Customers", slug: "customers")
|
|
311
|
+
category = client.contact_categories.get(category["id"])
|
|
312
|
+
client.contact_categories.update(category["id"], name: "Active customers", slug: "active-customers")
|
|
313
|
+
client.contact_categories.delete(category["id"])
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
Contact-category pagination:
|
|
317
|
+
|
|
318
|
+
```ruby
|
|
319
|
+
first = client.contact_categories.list(per_page: 20)
|
|
320
|
+
items = first["data"]
|
|
321
|
+
next_page = client.contact_categories.list(per_page: 20, after: items.last["id"]) if first["pagination"]["has_more"] && !items.empty?
|
|
322
|
+
previous_page = client.contact_categories.list(per_page: 20, before: items.first["id"]) unless items.empty?
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
## Email Topics
|
|
326
|
+
|
|
327
|
+
```ruby
|
|
328
|
+
topic = client.email_topics.create(
|
|
329
|
+
name: "Product updates",
|
|
330
|
+
slug: "product-updates",
|
|
331
|
+
auto_subscribe: false,
|
|
332
|
+
public: true,
|
|
333
|
+
description: "News about product improvements",
|
|
334
|
+
domain_id: "domain_123"
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
topic = client.email_topics.get(topic["id"])
|
|
338
|
+
client.email_topics.update(
|
|
339
|
+
topic["id"],
|
|
340
|
+
name: "Product news",
|
|
341
|
+
description: "Important product news",
|
|
342
|
+
public: true,
|
|
343
|
+
auto_subscribe: false
|
|
344
|
+
)
|
|
345
|
+
client.email_topics.delete(topic["id"])
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
When `domain_id` is supplied, the request sends it as `domain: { id: domain_id }`. Responses may include a nested domain with `id` and `name`.
|
|
349
|
+
|
|
350
|
+
Email-topic pagination:
|
|
351
|
+
|
|
352
|
+
```ruby
|
|
353
|
+
first = client.email_topics.list(per_page: 20)
|
|
354
|
+
items = first["data"]
|
|
355
|
+
next_page = client.email_topics.list(per_page: 20, after: items.last["id"]) if first["pagination"]["has_more"] && !items.empty?
|
|
356
|
+
previous_page = client.email_topics.list(per_page: 20, before: items.first["id"]) unless items.empty?
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
## Model Serialization and Database Storage
|
|
360
|
+
|
|
361
|
+
Every API response, list envelope, pagination object, list item, and nested response hash is a `LetMeSendEmail::Models::Model`. Arrays contain recursively wrapped models. Access fields with string or symbol keys:
|
|
362
|
+
|
|
363
|
+
```ruby
|
|
364
|
+
contact = client.contacts.get("contact_123")
|
|
365
|
+
puts contact["email"]
|
|
366
|
+
puts contact[:email]
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
Use `to_h` for a recursive defensive copy suitable for persistence:
|
|
370
|
+
|
|
371
|
+
```ruby
|
|
372
|
+
contact = client.contacts.get("contact_123")
|
|
373
|
+
database_record = contact.to_h
|
|
374
|
+
|
|
375
|
+
# Pass database_record to your application's persistence layer.
|
|
376
|
+
database_record["synced_at"] = Time.now.utc.iso8601
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
Use the standard JSON library directly:
|
|
380
|
+
|
|
381
|
+
```ruby
|
|
382
|
+
require "json"
|
|
383
|
+
|
|
384
|
+
json_document = JSON.generate(contact)
|
|
385
|
+
restored = JSON.parse(json_document)
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
`as_json` returns the same plain representation for Rails integrations. Field names remain API snake-case strings. Nested models and arrays convert recursively; `nil`, booleans, numbers, identifiers, enum strings, and ISO 8601 timestamp strings are preserved. `to_h` returns new hashes, arrays, and strings so mutating the result does not mutate the SDK model. Client configuration, API keys, authorization headers, webhook secrets, retries, and transport state are never part of response models.
|
|
389
|
+
|
|
390
|
+
Request inputs are ordinary Ruby hashes and arrays, so they are already compatible with `JSON.generate`. The SDK omits optional keyword fields that are not supplied.
|
|
391
|
+
|
|
392
|
+
## Pagination
|
|
393
|
+
|
|
394
|
+
`emails`, `domains`, `contacts`, `contact_categories`, and `email_topics` list methods accept `per_page:`, `after:`, and `before:`. A list response contains:
|
|
395
|
+
|
|
396
|
+
- `data`: the current array of models
|
|
397
|
+
- `pagination.has_more`: whether a next page is available
|
|
398
|
+
- `pagination.per_page`: requested page size
|
|
399
|
+
- `pagination.fetched`: number fetched
|
|
400
|
+
- `pagination.total`: total matching records
|
|
401
|
+
|
|
402
|
+
Use the final item ID as `after` and the first item ID as `before`. Always guard an empty collection, check `has_more` before moving forward, and never pass `after` with `before`. The client rejects that combination before network I/O.
|
|
403
|
+
|
|
404
|
+
The API does not expose arbitrary page-number jumps or `has_previous`. Retain cursor history in the application when implementing Back/Previous navigation. The SDK returns one page per call and does not auto-paginate.
|
|
405
|
+
|
|
406
|
+
## Errors and Exceptions
|
|
407
|
+
|
|
408
|
+
All SDK errors inherit from `LetMeSendEmail::Error`:
|
|
409
|
+
|
|
410
|
+
| Error | Meaning |
|
|
411
|
+
|---|---|
|
|
412
|
+
| `ValidationError` | HTTP 400, 413, or 422 |
|
|
413
|
+
| `AuthenticationError` | HTTP 401 |
|
|
414
|
+
| `AuthorizationError` | HTTP 403 |
|
|
415
|
+
| `NotFoundError` | HTTP 404 |
|
|
416
|
+
| `ConflictError` | HTTP 409 |
|
|
417
|
+
| `RateLimitError` | HTTP 429 |
|
|
418
|
+
| `ApiError` | Other API errors or malformed success responses |
|
|
419
|
+
| `NetworkError` | Connection, TLS, socket, or response transport failure |
|
|
420
|
+
| `TimeoutError` | Connection, read, or write timeout |
|
|
421
|
+
| `WebhookVerificationError` | Invalid webhook headers, timestamp, signature, or JSON |
|
|
422
|
+
| `WebhookSigningError` | Missing or invalid webhook secret |
|
|
423
|
+
|
|
424
|
+
```ruby
|
|
425
|
+
begin
|
|
426
|
+
client.emails.get("email_123")
|
|
427
|
+
rescue LetMeSendEmail::ValidationError => e
|
|
428
|
+
warn e.validation_errors.inspect
|
|
429
|
+
rescue LetMeSendEmail::AuthenticationError, LetMeSendEmail::AuthorizationError => e
|
|
430
|
+
warn e.message
|
|
431
|
+
rescue LetMeSendEmail::NotFoundError, LetMeSendEmail::ConflictError => e
|
|
432
|
+
warn "#{e.message} (HTTP #{e.status_code})"
|
|
433
|
+
rescue LetMeSendEmail::RateLimitError => e
|
|
434
|
+
warn "Retry after: #{e.retry_after.inspect}"
|
|
435
|
+
rescue LetMeSendEmail::NetworkError, LetMeSendEmail::TimeoutError => e
|
|
436
|
+
warn "Transport failure: #{e.message}"
|
|
437
|
+
rescue LetMeSendEmail::ApiError => e
|
|
438
|
+
warn "API failure: #{e.message}"
|
|
439
|
+
end
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
`Error` exposes `status_code`, `api_code`, `validation_errors`, `request_id`, `response_headers`, and `raw_body`. `RateLimitError` additionally exposes `retry_after`, `limit`, `remaining`, and `reset_at`. Treat `raw_body` and headers as diagnostic data; avoid logging message content or other sensitive application data.
|
|
443
|
+
|
|
444
|
+
## Timeouts, Cancellation, and Retries
|
|
445
|
+
|
|
446
|
+
The configured positive `timeout_ms` value applies separately to the `net/http` connect, response-read, and request-write phases. Ruby `net/http` does not expose a separate SDK cancellation token. Applications can stop waiting by cancelling or terminating their own task/thread according to their framework's conventions.
|
|
447
|
+
|
|
448
|
+
Retries are disabled by default. Configure them explicitly and use idempotency keys for sends. A timeout or network failure on a non-idempotent write without a key is returned immediately because the server may have accepted the request.
|
|
449
|
+
|
|
450
|
+
For HTTP 429, inspect `RateLimitError#retry_after`. Missing, malformed, zero, negative, past, or greater-than-300-second values are exposed as `nil` and are not automatically retried.
|
|
451
|
+
|
|
452
|
+
## Webhooks
|
|
453
|
+
|
|
454
|
+
Verification requires the exact raw request body and these headers:
|
|
455
|
+
|
|
456
|
+
- `webhook-id`
|
|
457
|
+
- `webhook-log-id`
|
|
458
|
+
- `webhook-timestamp`
|
|
459
|
+
- `webhook-signature`
|
|
460
|
+
|
|
461
|
+
The secret is Base64 encoded and may use the `whsec_` prefix. Only `v1` signatures are accepted; multiple signatures are supported and unknown versions are ignored. The default timestamp tolerance is 300 seconds in either direction.
|
|
462
|
+
|
|
463
|
+
```ruby
|
|
464
|
+
require "json"
|
|
465
|
+
require "letmesendemail"
|
|
466
|
+
|
|
467
|
+
secret = ENV.fetch("LETMESENDEMAIL_WEBHOOK_SECRET")
|
|
468
|
+
raise "LETMESENDEMAIL_WEBHOOK_SECRET must not be empty" if secret.empty?
|
|
469
|
+
|
|
470
|
+
raw_body = $stdin.read
|
|
471
|
+
headers = JSON.parse(ENV.fetch("LETMESENDEMAIL_WEBHOOK_HEADERS_JSON"))
|
|
472
|
+
|
|
473
|
+
begin
|
|
474
|
+
verified_payload = LetMeSendEmail::Webhooks.verify(raw_body, headers, secret)
|
|
475
|
+
puts verified_payload.inspect
|
|
476
|
+
rescue LetMeSendEmail::WebhookVerificationError,
|
|
477
|
+
LetMeSendEmail::WebhookSigningError => e
|
|
478
|
+
warn "Webhook rejected: #{e.message}"
|
|
479
|
+
end
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
Rack/CGI header keys such as `HTTP_WEBHOOK_ID` are normalized automatically. The verifier checks the HMAC in constant time, then parses the payload and requires a JSON object. It does not assume any event names or payload schema.
|
|
483
|
+
|
|
484
|
+
## Framework Integration
|
|
485
|
+
|
|
486
|
+
The client is a plain Ruby object and can be registered in the dependency container used by Rails, Hanami, Sinatra, or another framework. Create it once per application configuration, or per tenant when API keys differ. Do not expose the client or its API key through model serialization or logs.
|
|
487
|
+
|
|
488
|
+
## Testing
|
|
489
|
+
|
|
490
|
+
This gem does not provide a fake client. Place the client behind an application service boundary and inject a test double for that service in application tests. To contribute to the gem:
|
|
491
|
+
|
|
492
|
+
```bash
|
|
493
|
+
bundle install
|
|
494
|
+
bundle exec rubocop --parallel
|
|
495
|
+
bundle exec rspec
|
|
496
|
+
find examples -name "*.rb" -print0 | xargs -0 -n1 ruby -c
|
|
497
|
+
gem build letmesendemail.gemspec
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
## Runtime Support
|
|
501
|
+
|
|
502
|
+
Ruby 3.1, 3.2, 3.3, 3.4, and 4.0 are tested in CI. The gemspec requires Ruby 3.1 or newer.
|
|
503
|
+
|
|
504
|
+
## Upgrading
|
|
505
|
+
|
|
506
|
+
Review the [changelog](../CHANGELOG.md) before upgrading. Install an explicit
|
|
507
|
+
version when your application requires reproducible dependency resolution.
|
|
508
|
+
|
|
509
|
+
## Getting Help
|
|
510
|
+
|
|
511
|
+
- [letmesend.email documentation](https://letmesend.email/docs)
|
|
512
|
+
- [Issue tracker](https://github.com/letmesendemail/letmesendemail-ruby/issues)
|
|
513
|
+
- [Changelog](../CHANGELOG.md)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'base64'
|
|
4
|
+
require 'letmesendemail'
|
|
5
|
+
|
|
6
|
+
api_key = ENV.fetch('LETMESENDEMAIL_API_KEY')
|
|
7
|
+
raise 'LETMESENDEMAIL_API_KEY must not be empty' if api_key.empty?
|
|
8
|
+
|
|
9
|
+
content = Base64.strict_encode64("Example attachment\n".b)
|
|
10
|
+
client = LetMeSendEmail::Client.new(api_key: api_key)
|
|
11
|
+
result = client.emails.send(
|
|
12
|
+
from: 'Acme <hello@example.com>',
|
|
13
|
+
to: ['customer@example.net'],
|
|
14
|
+
subject: 'Your file',
|
|
15
|
+
text: 'The requested file is attached.',
|
|
16
|
+
attachments: [{
|
|
17
|
+
name: 'message.txt', content: content, mime: 'text/plain',
|
|
18
|
+
content_disposition: 'attachment'
|
|
19
|
+
}]
|
|
20
|
+
)
|
|
21
|
+
puts result['id']
|
data/examples/errors.rb
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'letmesendemail'
|
|
4
|
+
|
|
5
|
+
api_key = ENV.fetch('LETMESENDEMAIL_API_KEY')
|
|
6
|
+
raise 'LETMESENDEMAIL_API_KEY must not be empty' if api_key.empty?
|
|
7
|
+
|
|
8
|
+
client = LetMeSendEmail::Client.new(api_key: api_key)
|
|
9
|
+
|
|
10
|
+
begin
|
|
11
|
+
client.emails.get('email_123')
|
|
12
|
+
rescue LetMeSendEmail::ValidationError => e
|
|
13
|
+
warn "Validation failed: #{e.validation_errors.inspect}"
|
|
14
|
+
rescue LetMeSendEmail::RateLimitError => e
|
|
15
|
+
warn "Rate limited; retry after: #{e.retry_after.inspect}"
|
|
16
|
+
rescue LetMeSendEmail::Error => e
|
|
17
|
+
warn "Request failed: #{e.message} (request #{e.request_id || 'unknown'})"
|
|
18
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
require 'letmesendemail'
|
|
5
|
+
|
|
6
|
+
api_key = ENV.fetch('LETMESENDEMAIL_API_KEY')
|
|
7
|
+
raise 'LETMESENDEMAIL_API_KEY must not be empty' if api_key.empty?
|
|
8
|
+
|
|
9
|
+
client = LetMeSendEmail::Client.new(api_key: api_key)
|
|
10
|
+
result = client.emails.send(
|
|
11
|
+
from: 'Acme <hello@example.com>',
|
|
12
|
+
to: ['customer@example.net'],
|
|
13
|
+
subject: 'Receipt',
|
|
14
|
+
text: 'Your receipt is ready.',
|
|
15
|
+
idempotency_key: SecureRandom.uuid
|
|
16
|
+
)
|
|
17
|
+
puts result['id']
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'letmesendemail'
|
|
4
|
+
|
|
5
|
+
api_key = ENV.fetch('LETMESENDEMAIL_API_KEY')
|
|
6
|
+
raise 'LETMESENDEMAIL_API_KEY must not be empty' if api_key.empty?
|
|
7
|
+
|
|
8
|
+
client = LetMeSendEmail::Client.new(api_key: api_key)
|
|
9
|
+
first_page = client.emails.list(per_page: 20)
|
|
10
|
+
items = first_page['data']
|
|
11
|
+
|
|
12
|
+
if first_page['pagination']['has_more'] && !items.empty?
|
|
13
|
+
next_page = client.emails.list(per_page: 20, after: items.last['id'])
|
|
14
|
+
puts "Next page contains #{next_page['data'].length} emails"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
unless items.empty?
|
|
18
|
+
previous_page = client.emails.list(per_page: 20, before: items.first['id'])
|
|
19
|
+
puts "Previous page contains #{previous_page['data'].length} emails"
|
|
20
|
+
end
|
data/examples/send.rb
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'letmesendemail'
|
|
4
|
+
|
|
5
|
+
api_key = ENV.fetch('LETMESENDEMAIL_API_KEY')
|
|
6
|
+
raise 'LETMESENDEMAIL_API_KEY must not be empty' if api_key.empty?
|
|
7
|
+
|
|
8
|
+
client = LetMeSendEmail::Client.new(api_key: api_key)
|
|
9
|
+
result = client.emails.send(
|
|
10
|
+
from: 'Acme <hello@example.com>',
|
|
11
|
+
to: ['customer@example.net'],
|
|
12
|
+
subject: 'Welcome',
|
|
13
|
+
html: '<p>Thanks for joining.</p>',
|
|
14
|
+
text: 'Thanks for joining.'
|
|
15
|
+
)
|
|
16
|
+
puts result['id']
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'letmesendemail'
|
|
4
|
+
|
|
5
|
+
api_key = ENV.fetch('LETMESENDEMAIL_API_KEY')
|
|
6
|
+
raise 'LETMESENDEMAIL_API_KEY must not be empty' if api_key.empty?
|
|
7
|
+
|
|
8
|
+
client = LetMeSendEmail::Client.new(api_key: api_key)
|
|
9
|
+
result = client.emails.send_with_template(
|
|
10
|
+
from: 'Acme <hello@example.com>',
|
|
11
|
+
to: ['customer@example.net'],
|
|
12
|
+
template_id: 'template_123',
|
|
13
|
+
template_variables: [
|
|
14
|
+
{ key: 'CUSTOMER_NAME', type: 'string', value: 'Taylor' },
|
|
15
|
+
{ key: 'ORDER_NUMBER', type: 'number', value: 12_345 }
|
|
16
|
+
]
|
|
17
|
+
)
|
|
18
|
+
puts result['status']
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'letmesendemail'
|
|
5
|
+
|
|
6
|
+
secret = ENV.fetch('LETMESENDEMAIL_WEBHOOK_SECRET')
|
|
7
|
+
raise 'LETMESENDEMAIL_WEBHOOK_SECRET must not be empty' if secret.empty?
|
|
8
|
+
|
|
9
|
+
headers = JSON.parse(ENV.fetch('LETMESENDEMAIL_WEBHOOK_HEADERS_JSON'))
|
|
10
|
+
raw_payload = $stdin.read
|
|
11
|
+
verified_payload = LetMeSendEmail::Webhooks.verify(raw_payload, headers, secret)
|
|
12
|
+
puts JSON.generate(verified_payload)
|