lettermint 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +21 -0
- data/README.md +141 -28
- data/docs/api.md +210 -0
- data/docs/sdk.md +510 -0
- data/lib/lettermint/errors.rb +66 -9
- data/lib/lettermint/http_client.rb +34 -21
- data/lib/lettermint/resources/messages.rb +1 -2
- data/lib/lettermint/resources/routes.rb +1 -2
- data/lib/lettermint/resources/suppressions.rb +1 -2
- data/lib/lettermint/resources/webhooks.rb +2 -4
- data/lib/lettermint/version.rb +1 -1
- metadata +3 -3
- data/.gemini/settings.json +0 -7
- data/lettermint.gemspec +0 -35
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8aad4ee2bba82edddc66ea5843cb3218394a4efca38bc82b9c2c7b784cd98f2f
|
|
4
|
+
data.tar.gz: 9bcdbaffbb553fe38d8e83afee6a1b8b2e6b231e7953fb9342e9c64d3b979455
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e9b83c4a5fb73350768a2a49774e712ecd65d5b0cd5001475c3df4748ce5e8bca67e596a6105a809cf6d1512b13d394be8b40fe202f593f8579aa1a4efb71de2
|
|
7
|
+
data.tar.gz: 26a144391b3bf492267b82ef115f89c9e1527fd19dff8c604379cb35c871f52dfe340df2f20ee1950e55516aa25baf2b686d9433c0c3e1051afb476382f2af90
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,27 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.2.1] - 2026-10-16
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `NotFoundError` for HTTP 404 responses (subclass of `HttpRequestError`)
|
|
13
|
+
- `code` and `details` readers on `HttpRequestError`, inherited by every subclass
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- Error bodies are parsed from the three shapes used by the official Lettermint SDKs: `{"error":{"code","message","details"}}`, `{"error":"<string>"}`, and `{"message":"<string>"}`
|
|
18
|
+
- Error message resolves from `error.message`, then top-level `message`, then `"HTTP <status>"`; the per-status fallback literals (`Unknown client error`, `Validation error`, `Rate limit exceeded`) are removed
|
|
19
|
+
- `response_body` keeps non-JSON bodies as a String (for example an HTML 404 page from a proxy); empty bodies are nil
|
|
20
|
+
- A String `error` value is now exposed as `code` and no longer used as the message; a 400 with `{"error":"..."}` and no `message` reports `HTTP 400`
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- 400 responses with an `{"error":{...}}` body produced a stringified Hash as the message
|
|
25
|
+
- 422 responses with an `{"error":{...}}` body set `error_type` to a Hash; it is now the `code` string
|
|
26
|
+
|
|
27
|
+
[0.2.1]: https://github.com/onetimesecret/lettermint-ruby/compare/v0.2.0...v0.2.1
|
|
28
|
+
|
|
8
29
|
## [0.2.0] - 2026-04-01
|
|
9
30
|
|
|
10
31
|
### Added
|
data/README.md
CHANGED
|
@@ -4,7 +4,12 @@
|
|
|
4
4
|
[](https://www.ruby-lang.org)
|
|
5
5
|
[](https://github.com/onetimesecret/lettermint-ruby/blob/main/LICENSE)
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
An unofficial but orthodox Ruby SDK for the [Lettermint](https://lettermint.co) transactional email API. Based on the official [Python SDK](https://github.com/lettermint/lettermint-python).
|
|
8
|
+
|
|
9
|
+
The SDK provides two API clients:
|
|
10
|
+
|
|
11
|
+
- **SendingAPI** (aliased as `Client`) — Project-level email sending with `x-lettermint-token` authentication
|
|
12
|
+
- **TeamAPI** — Team-level management (domains, projects, webhooks, etc.) with `lm_team_*` token authentication
|
|
8
13
|
|
|
9
14
|
## Installation
|
|
10
15
|
|
|
@@ -27,7 +32,8 @@ gem install lettermint
|
|
|
27
32
|
```ruby
|
|
28
33
|
require 'lettermint'
|
|
29
34
|
|
|
30
|
-
|
|
35
|
+
# SendingAPI for email sending (aliased as Client for backward compatibility)
|
|
36
|
+
client = Lettermint::SendingAPI.new(api_token: 'your-project-token')
|
|
31
37
|
|
|
32
38
|
response = client.email
|
|
33
39
|
.from('sender@example.com')
|
|
@@ -40,6 +46,21 @@ response = client.email
|
|
|
40
46
|
puts response.message_id
|
|
41
47
|
```
|
|
42
48
|
|
|
49
|
+
### Team Management
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
# TeamAPI for team-level operations (requires lm_team_* token)
|
|
53
|
+
team_api = Lettermint::TeamAPI.new(team_token: 'lm_team_your-token')
|
|
54
|
+
|
|
55
|
+
# List domains
|
|
56
|
+
domains = team_api.domains.list
|
|
57
|
+
puts domains['data'].map { |d| d['domain'] }
|
|
58
|
+
|
|
59
|
+
# Get team info
|
|
60
|
+
team = team_api.team.get
|
|
61
|
+
puts team['name']
|
|
62
|
+
```
|
|
63
|
+
|
|
43
64
|
## Email Options
|
|
44
65
|
|
|
45
66
|
### Multiple Recipients
|
|
@@ -223,12 +244,117 @@ webhooks using the `x-lettermint-delivery` header value as an idempotency key --
|
|
|
223
244
|
example, by recording processed delivery IDs in a database or cache and rejecting
|
|
224
245
|
duplicates.
|
|
225
246
|
|
|
247
|
+
## TeamAPI
|
|
248
|
+
|
|
249
|
+
The TeamAPI provides access to team-level management operations. It requires a team token (prefixed with `lm_team_`).
|
|
250
|
+
|
|
251
|
+
```ruby
|
|
252
|
+
team_api = Lettermint::TeamAPI.new(team_token: 'lm_team_your-token')
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
### Available Resources
|
|
256
|
+
|
|
257
|
+
| Resource | Methods |
|
|
258
|
+
|----------|---------|
|
|
259
|
+
| `team` | `get`, `update`, `usage`, `members` |
|
|
260
|
+
| `domains` | `list`, `create`, `find`, `delete`, `verify_dns`, `verify_dns_record`, `update_projects` |
|
|
261
|
+
| `projects` | `list`, `create`, `find`, `update`, `delete`, `rotate_token`, `add_member`, `remove_member`, `update_members`, `routes` |
|
|
262
|
+
| `webhooks` | `list`, `create`, `find`, `update`, `delete`, `test`, `deliveries`, `delivery`, `regenerate_secret` |
|
|
263
|
+
| `messages` | `list`, `find`, `html`, `text`, `source`, `events` |
|
|
264
|
+
| `suppressions` | `list`, `create`, `delete` |
|
|
265
|
+
| `routes` | `list`, `create`, `find`, `update`, `delete`, `verify_inbound_domain` |
|
|
266
|
+
| `stats` | `get` |
|
|
267
|
+
|
|
268
|
+
### Examples
|
|
269
|
+
|
|
270
|
+
```ruby
|
|
271
|
+
# Domains
|
|
272
|
+
team_api.domains.create(domain: 'mail.example.com')
|
|
273
|
+
team_api.domains.verify_dns('domain-id')
|
|
274
|
+
|
|
275
|
+
# Projects
|
|
276
|
+
projects = team_api.projects.list(sort: '-created_at')
|
|
277
|
+
project = team_api.projects.create(name: 'My Project')
|
|
278
|
+
team_api.projects.rotate_token(project['id'])
|
|
279
|
+
|
|
280
|
+
# Webhooks
|
|
281
|
+
team_api.webhooks.create(
|
|
282
|
+
name: 'My Webhook',
|
|
283
|
+
url: 'https://example.com/webhook',
|
|
284
|
+
events: ['message.sent', 'message.delivered']
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
# Messages (search and retrieve)
|
|
288
|
+
messages = team_api.messages.list(status: 'delivered', tag: 'welcome')
|
|
289
|
+
html_body = team_api.messages.html('message-id')
|
|
290
|
+
|
|
291
|
+
# Suppressions
|
|
292
|
+
team_api.suppressions.create(
|
|
293
|
+
emails: ['bounce@example.com'],
|
|
294
|
+
reason: 'hard_bounce',
|
|
295
|
+
scope: 'project',
|
|
296
|
+
project_id: 'project-id'
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
# Stats
|
|
300
|
+
stats = team_api.stats.get(from: '2026-01-01', to: '2026-01-31')
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
### Pagination
|
|
304
|
+
|
|
305
|
+
All list methods support cursor-based pagination:
|
|
306
|
+
|
|
307
|
+
```ruby
|
|
308
|
+
# First page
|
|
309
|
+
result = team_api.domains.list(page_size: 10)
|
|
310
|
+
|
|
311
|
+
# Next page
|
|
312
|
+
if result['meta']['next_cursor']
|
|
313
|
+
next_page = team_api.domains.list(
|
|
314
|
+
page_size: 10,
|
|
315
|
+
page_cursor: result['meta']['next_cursor']
|
|
316
|
+
)
|
|
317
|
+
end
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
### Sorting and Filtering
|
|
321
|
+
|
|
322
|
+
```ruby
|
|
323
|
+
# Sort by field (prefix with - for descending)
|
|
324
|
+
team_api.projects.list(sort: '-created_at')
|
|
325
|
+
team_api.domains.list(sort: 'domain')
|
|
326
|
+
|
|
327
|
+
# Filter by field
|
|
328
|
+
team_api.messages.list(status: 'delivered', from_email: 'noreply@example.com')
|
|
329
|
+
team_api.domains.list(status: 'verified')
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
## Raw HTTP Methods
|
|
333
|
+
|
|
334
|
+
The SendingAPI provides raw HTTP methods for accessing API endpoints not yet wrapped in typed methods:
|
|
335
|
+
|
|
336
|
+
```ruby
|
|
337
|
+
client = Lettermint::SendingAPI.new(api_token: 'your-api-token')
|
|
338
|
+
|
|
339
|
+
# GET with query params
|
|
340
|
+
response = client.get('/some-endpoint', params: { limit: 10 })
|
|
341
|
+
|
|
342
|
+
# POST with JSON body
|
|
343
|
+
response = client.post('/some-endpoint', data: { key: 'value' })
|
|
344
|
+
|
|
345
|
+
# PUT
|
|
346
|
+
response = client.put('/some-endpoint/123', data: { key: 'new-value' })
|
|
347
|
+
|
|
348
|
+
# DELETE
|
|
349
|
+
client.delete('/some-endpoint/123')
|
|
350
|
+
```
|
|
351
|
+
|
|
226
352
|
## Error Handling
|
|
227
353
|
|
|
228
354
|
```ruby
|
|
229
355
|
require 'lettermint'
|
|
230
356
|
|
|
231
|
-
client = Lettermint::
|
|
357
|
+
client = Lettermint::SendingAPI.new(api_token: 'your-api-token')
|
|
232
358
|
|
|
233
359
|
begin
|
|
234
360
|
response = client.email
|
|
@@ -249,12 +375,20 @@ rescue Lettermint::ValidationError => e
|
|
|
249
375
|
rescue Lettermint::ClientError => e
|
|
250
376
|
# 400 errors
|
|
251
377
|
puts "Client error: #{e.message}"
|
|
378
|
+
rescue Lettermint::NotFoundError => e
|
|
379
|
+
# 404 errors
|
|
380
|
+
puts "Not found: #{e.code}" # e.g. "RESOURCE_NOT_FOUND" (may be nil)
|
|
381
|
+
puts "Details: #{e.details}" # Hash, Array or nil
|
|
252
382
|
rescue Lettermint::TimeoutError => e
|
|
253
383
|
# Request timeout
|
|
254
384
|
puts "Timeout: #{e.message}"
|
|
385
|
+
rescue Lettermint::ConnectionError => e
|
|
386
|
+
# Network-level failures (DNS, connection refused, etc.)
|
|
387
|
+
puts "Connection error: #{e.message}"
|
|
388
|
+
puts "Original: #{e.original_exception}" if e.original_exception
|
|
255
389
|
rescue Lettermint::HttpRequestError => e
|
|
256
|
-
# Other HTTP errors
|
|
257
|
-
puts "HTTP error #{e.status_code}: #{e.message}"
|
|
390
|
+
# Other HTTP errors. `code` and `details` are available on every HTTP error.
|
|
391
|
+
puts "HTTP error #{e.status_code}: #{e.message} (#{e.code})"
|
|
258
392
|
end
|
|
259
393
|
```
|
|
260
394
|
|
|
@@ -282,37 +416,16 @@ Set defaults once at application boot (e.g., in a Rails initializer):
|
|
|
282
416
|
|
|
283
417
|
```ruby
|
|
284
418
|
Lettermint.configure do |config|
|
|
285
|
-
config.base_url = 'https://custom.api.com/v1'
|
|
286
419
|
config.timeout = 60
|
|
287
420
|
end
|
|
288
421
|
```
|
|
289
422
|
|
|
290
|
-
All clients created afterward inherit these defaults
|
|
291
|
-
|
|
292
|
-
```ruby
|
|
293
|
-
client = Lettermint::Client.new(api_token: 'your-api-token')
|
|
294
|
-
# Uses the global base_url and timeout
|
|
295
|
-
```
|
|
423
|
+
All clients created afterward inherit these defaults.
|
|
296
424
|
|
|
297
425
|
### Per-Client Overrides
|
|
298
426
|
|
|
299
|
-
Explicit keyword arguments take precedence over global configuration:
|
|
300
|
-
|
|
301
427
|
```ruby
|
|
302
|
-
client = Lettermint::
|
|
303
|
-
api_token: 'your-api-token',
|
|
304
|
-
base_url: 'https://other.api.com/v1',
|
|
305
|
-
timeout: 10
|
|
306
|
-
)
|
|
307
|
-
```
|
|
308
|
-
|
|
309
|
-
### Block Configuration
|
|
310
|
-
|
|
311
|
-
```ruby
|
|
312
|
-
client = Lettermint::Client.new(api_token: 'your-api-token') do |config|
|
|
313
|
-
config.base_url = 'https://custom.api.com/v1'
|
|
314
|
-
config.timeout = 60
|
|
315
|
-
end
|
|
428
|
+
client = Lettermint::SendingAPI.new(api_token: 'your-api-token', timeout: 10)
|
|
316
429
|
```
|
|
317
430
|
|
|
318
431
|
## Requirements
|
data/docs/api.md
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# Lettermint Team API Reference
|
|
2
|
+
|
|
3
|
+
`https://api.lettermint.co/v1` · Auth: `Authorization: Bearer {team_token}`
|
|
4
|
+
|
|
5
|
+
Pagination: cursor-based via `page[size]` (default 30), `page[cursor]`. Sorting: comma-separated fields, `-` prefix for desc.
|
|
6
|
+
|
|
7
|
+
**Conventions:** `?` = nullable/optional, `*` = required, defaults in parens. List variants omit nested includes. `?include=` params noted per endpoint.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Enums
|
|
12
|
+
|
|
13
|
+
| Name | Values |
|
|
14
|
+
|------|--------|
|
|
15
|
+
| AttachmentDelivery | `inline` `url` |
|
|
16
|
+
| DnsRecordStatus | `active` `failed` `pending` |
|
|
17
|
+
| DomainStatus | `verified` `partially_verified` `pending_verification` `failed_verification` |
|
|
18
|
+
| InitialRoutes | `both` `transactional` `broadcast` |
|
|
19
|
+
| MessageEventType | `queued` `processed` `suppressed` `delivered` `soft_bounced` `hard_bounced` `spam_complaint` `failed` |
|
|
20
|
+
| MessageStatus | `pending` `queued` `suppressed` `processed` `delivered` `opened` `clicked` `soft_bounced` |
|
|
21
|
+
| MessageType | `inbound` `outbound` |
|
|
22
|
+
| Plan | `free` `starter` `growth` `pro` |
|
|
23
|
+
| RecordType | `TXT` `CNAME` `MX` |
|
|
24
|
+
| RouteType | `transactional` `broadcast` `inbound` |
|
|
25
|
+
| SuppressionReason | `spam_complaint` `hard_bounce` `unsubscribe` `manual` |
|
|
26
|
+
| SuppressionScope | `global` `team` `project` `route` |
|
|
27
|
+
| SuppressionType | `email` `domain` `extension` |
|
|
28
|
+
| VolumeTier | `300` `10k` `50k` `125k` `500k` `750k` `1M` `1.5M` |
|
|
29
|
+
| WebhookDeliveryStatus | `pending` `success` `failed` `client_error` `server_error` `timeout` |
|
|
30
|
+
| WebhookEvent | `message.{created,sent,delivered,hard_bounced,soft_bounced,spam_complaint,failed,suppressed}` |
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Generic
|
|
35
|
+
|
|
36
|
+
| | |
|
|
37
|
+
|---|---|
|
|
38
|
+
| `GET /ping` | Health check. Also accepts `X-Lettermint-Token: {project_token}` |
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## Team
|
|
43
|
+
|
|
44
|
+
| | |
|
|
45
|
+
|---|---|
|
|
46
|
+
| `GET /team` | `?include=features,featuresCount,featuresExists` |
|
|
47
|
+
| `PUT /team` | Body: `{name}` |
|
|
48
|
+
| `GET /team/usage` | Current period + up to 12 historical |
|
|
49
|
+
| `GET /team/members` | Paginated |
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
TeamData { id, name, plan: Plan, tier: VolumeTier, verified_at?, created_at,
|
|
53
|
+
features: string[], addons: [{type?, expires_at?}],
|
|
54
|
+
domains_count?, projects_count?, members_count? }
|
|
55
|
+
TeamMemberData { id, role?, joined_at?, user?: {id, name, email, avatar?} }
|
|
56
|
+
UsageDetail { current_period, historical_usage[]: {usage: int, last_incremented_at?, period_start, period_end} }
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## Domains
|
|
62
|
+
|
|
63
|
+
| | |
|
|
64
|
+
|---|---|
|
|
65
|
+
| `GET /domains` | `filter[status]`, `filter[domain]` · `sort`: domain, created_at, status_changed_at |
|
|
66
|
+
| `POST /domains` | Body: `{domain*}` (max 255, regex validated) |
|
|
67
|
+
| `GET /domains/{id}` | `?include=dnsRecords,dnsRecordsCount,dnsRecordsExists` |
|
|
68
|
+
| `DELETE /domains/{id}` | |
|
|
69
|
+
| `POST /domains/{id}/dns-records/verify` | Verify all records |
|
|
70
|
+
| `POST /domains/{id}/dns-records/{recordId}/verify` | Verify single |
|
|
71
|
+
| `PUT /domains/{id}/projects` | Body: `{project_ids[]}` |
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
DomainData { id, domain, status_changed_at?, created_at, dns_records[], projects[] }
|
|
75
|
+
DomainListData { id, domain, status: DomainStatus, status_changed_at?, created_at }
|
|
76
|
+
DnsRecord { id, type: RecordType, hostname, fqdn, content,
|
|
77
|
+
status: DnsRecordStatus, verified_at?, last_checked_at? }
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## Projects
|
|
83
|
+
|
|
84
|
+
| | |
|
|
85
|
+
|---|---|
|
|
86
|
+
| `GET /projects` | `filter[search]` · `sort`: name, created_at |
|
|
87
|
+
| `POST /projects` | Body: `{name*, smtp_enabled? (false), initial_routes? (both)}`. Returns `api_token` |
|
|
88
|
+
| `GET /projects/{id}` | `?include=routes,domains,teamMembers,messageStats` (+ Count/Exists) |
|
|
89
|
+
| `PUT /projects/{id}` | Body: `{name?, smtp_enabled?, default_route_id?}` |
|
|
90
|
+
| `DELETE /projects/{id}` | |
|
|
91
|
+
| `POST /projects/{id}/rotate-token` | Returns `new_token` |
|
|
92
|
+
| `PUT /projects/{id}/members` | Body: `{team_member_ids[]}` |
|
|
93
|
+
| `POST /projects/{id}/members/{memberId}` | Add member |
|
|
94
|
+
| `DELETE /projects/{id}/members/{memberId}` | Remove member |
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
ProjectData { id, name, smtp_enabled: bool, default_route_id?,
|
|
98
|
+
token_generated_at?, token_last_used_at?, token_last_used_ip?,
|
|
99
|
+
created_at, updated_at,
|
|
100
|
+
routes[], domains[], team_members[],
|
|
101
|
+
last_28_days: {messages_transactional, messages_broadcast, messages_inbound: int, deliverability: float} }
|
|
102
|
+
ProjectListData { id, name, smtp_enabled, routes_count, domains_count,
|
|
103
|
+
team_members_count, last_28_days, created_at, updated_at }
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## Routes
|
|
109
|
+
|
|
110
|
+
| | |
|
|
111
|
+
|---|---|
|
|
112
|
+
| `GET /projects/{id}/routes` | `filter[route_type]`, `filter[is_default]`, `filter[search]` · `sort`: name, slug, created_at |
|
|
113
|
+
| `POST /projects/{id}/routes` | Body: `{name*, route_type*, slug?}` |
|
|
114
|
+
| `GET /routes/{id}` | `?include=project,statistics` |
|
|
115
|
+
| `PUT /routes/{id}` | Body: `{name?, settings{track_opens, track_clicks, disable_hosted_unsubscribe}?, inbound_settings{inbound_domain, inbound_spam_threshold, attachment_delivery}?}` |
|
|
116
|
+
| `DELETE /routes/{id}` | |
|
|
117
|
+
| `POST /routes/{id}/verify-inbound-domain` | |
|
|
118
|
+
|
|
119
|
+
```
|
|
120
|
+
RouteData { id, project_id, slug, name, route_type: RouteType, is_default: bool,
|
|
121
|
+
created_at, updated_at,
|
|
122
|
+
inbound_address?, inbound_domain?, inbound_domain_verified_at?,
|
|
123
|
+
inbound_spam_threshold?, attachment_delivery?,
|
|
124
|
+
project?, webhooks_count?, suppressed_recipients_count?, statistics? }
|
|
125
|
+
RouteListData { id, slug, name, route_type, is_default,
|
|
126
|
+
webhooks_count, suppressed_recipients_count, created_at, updated_at }
|
|
127
|
+
RouteStatistic { date, sent_count, delivered_count, opened_count, clicked_count,
|
|
128
|
+
hard_bounce_count, spam_complaint_count, inbound_received_count }
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## Messages
|
|
134
|
+
|
|
135
|
+
| | |
|
|
136
|
+
|---|---|
|
|
137
|
+
| `GET /messages` | `filter[type]`, `filter[status]`, `filter[route_id]`, `filter[domain_id]`, `filter[tag]`, `filter[from_email]`, `filter[subject]`, `filter[from_date]`, `filter[to_date]` · `sort`: type, status, from_email, subject, created_at, status_changed_at |
|
|
138
|
+
| `GET /messages/{id}` | Full detail |
|
|
139
|
+
| `GET /messages/{id}/events` | `sort`: timestamp, event |
|
|
140
|
+
| `GET /messages/{id}/source` | Returns `message/rfc822` |
|
|
141
|
+
| `GET /messages/{id}/html` | Returns `text/html` |
|
|
142
|
+
| `GET /messages/{id}/text` | Returns `text/plain` |
|
|
143
|
+
|
|
144
|
+
```
|
|
145
|
+
MessageData { id, type: MessageType, status: MessageStatus, status_changed_at?,
|
|
146
|
+
tag?, from_email, from_name?, reply_to?, subject?,
|
|
147
|
+
to?, cc?, bcc?: [{email, name?}],
|
|
148
|
+
attachments?: [{size (0), filename ("unknown"), content_id?, content_type ("application/octet-stream")}],
|
|
149
|
+
metadata?, route_id, created_at, spam_score?, spam_symbols?: [{name, score, options[], description?}] }
|
|
150
|
+
MessageListData { id, type, status, from_email, from_name?, subject?,
|
|
151
|
+
to?, cc?, bcc?, reply_to?, tag?, created_at }
|
|
152
|
+
MessageEvent { message_id, event: MessageEventType, metadata?, timestamp }
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
## Webhooks
|
|
158
|
+
|
|
159
|
+
| | |
|
|
160
|
+
|---|---|
|
|
161
|
+
| `GET /webhooks` | `filter[enabled]`, `filter[event]`, `filter[route_id]`, `filter[search]` · `sort`: name, url, created_at |
|
|
162
|
+
| `POST /webhooks` | Body: `{route_id*, name*, url*, events[]*, enabled? (true)}`. Returns `secret` (shown once) |
|
|
163
|
+
| `GET /webhooks/{id}` | |
|
|
164
|
+
| `PUT /webhooks/{id}` | Body: `{name?, url?, enabled?, events[]?}` |
|
|
165
|
+
| `DELETE /webhooks/{id}` | |
|
|
166
|
+
| `POST /webhooks/{id}/test` | Returns `delivery_id` |
|
|
167
|
+
| `POST /webhooks/{id}/regenerate-secret` | Returns new `secret` |
|
|
168
|
+
| `GET /webhooks/{id}/deliveries` | `filter[status]`, `filter[event_type]`, `filter[from_date]`, `filter[to_date]` |
|
|
169
|
+
| `GET /webhooks/{id}/deliveries/{deliveryId}` | Full payload/response |
|
|
170
|
+
|
|
171
|
+
```
|
|
172
|
+
WebhookData { id, route_id, name, url, events: WebhookEvent[], enabled: bool,
|
|
173
|
+
last_called_at?, created_at, updated_at, secret? }
|
|
174
|
+
WebhookDelivery { id, webhook_id, event_type: WebhookEvent, status: WebhookDeliveryStatus,
|
|
175
|
+
attempt_number, http_status_code?, duration_ms?,
|
|
176
|
+
payload[], response_body?, response_headers?, error_message?,
|
|
177
|
+
delivered_at?, timestamp }
|
|
178
|
+
WebhookDeliveryList { ...sans payload/response_body/response_headers/error_message, + created_at }
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
## Suppressions
|
|
184
|
+
|
|
185
|
+
| | |
|
|
186
|
+
|---|---|
|
|
187
|
+
| `GET /suppressions` | `filter[scope]`, `filter[route_id]`, `filter[project_id]`, `filter[value]`, `filter[reason]` · `sort`: value, created_at, reason |
|
|
188
|
+
| `POST /suppressions` | Body: `{reason*, scope*, email?, emails[]? (max 1000), route_id?, project_id?}` |
|
|
189
|
+
| `DELETE /suppressions/{id}` | |
|
|
190
|
+
|
|
191
|
+
```
|
|
192
|
+
SuppressedRecipient { id, type: SuppressionType, value, reason: SuppressionReason,
|
|
193
|
+
scope: SuppressionScope, project_id?, route_id?, created_at, updated_at }
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
---
|
|
197
|
+
|
|
198
|
+
## Stats
|
|
199
|
+
|
|
200
|
+
| | |
|
|
201
|
+
|---|---|
|
|
202
|
+
| `GET /stats` | `from*` (Y-m-d), `to*` (Y-m-d, max 90 days span), `project_id?` |
|
|
203
|
+
|
|
204
|
+
```
|
|
205
|
+
StatsData { from, to, totals: StatsTotals, daily: StatsDaily[] }
|
|
206
|
+
StatsTotals / StatsDaily { sent, delivered, hard_bounced, spam_complaints: int,
|
|
207
|
+
opened?, clicked?: int (null if tracking disabled),
|
|
208
|
+
inbound: {received: int},
|
|
209
|
+
transactional, broadcast: {sent, hard_bounced, spam_complaints: int} }
|
|
210
|
+
```
|
data/docs/sdk.md
ADDED
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
# Lettermint Ruby SDK Reference
|
|
2
|
+
|
|
3
|
+
`gem 'lettermint'` v0.2.1 · Ruby >= 3.2
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Quick Start
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
require 'lettermint'
|
|
11
|
+
|
|
12
|
+
# Sending API (project-level email sending)
|
|
13
|
+
client = Lettermint::SendingAPI.new(api_token: 'lm_project_xxx')
|
|
14
|
+
|
|
15
|
+
# Team API (team-level management)
|
|
16
|
+
team = Lettermint::TeamAPI.new(team_token: 'lm_team_xxx')
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Authentication
|
|
22
|
+
|
|
23
|
+
| API | Token Format | Header |
|
|
24
|
+
|-----|--------------|--------|
|
|
25
|
+
| SendingAPI | `lm_project_*` | `x-lettermint-token` |
|
|
26
|
+
| TeamAPI | `lm_team_*` | `Authorization: Bearer` |
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
# Global configuration (optional)
|
|
30
|
+
Lettermint.configure do |config|
|
|
31
|
+
config.base_url = 'https://api.lettermint.co/v1'
|
|
32
|
+
config.timeout = 30
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Instance-level configuration
|
|
36
|
+
client = Lettermint::SendingAPI.new(api_token: 'xxx', base_url: 'https://...', timeout: 60)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## Sending Emails
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
client = Lettermint::SendingAPI.new(api_token: 'lm_project_xxx')
|
|
45
|
+
|
|
46
|
+
# Fluent builder API
|
|
47
|
+
response = client.email
|
|
48
|
+
.from('sender@example.com')
|
|
49
|
+
.to('recipient@example.com')
|
|
50
|
+
.subject('Hello')
|
|
51
|
+
.html('<h1>Hello World</h1>')
|
|
52
|
+
.text('Hello World')
|
|
53
|
+
.deliver
|
|
54
|
+
|
|
55
|
+
response.message_id # => "msg_xxx"
|
|
56
|
+
response.status # => "queued"
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### EmailMessage Methods
|
|
60
|
+
|
|
61
|
+
| Method | Description |
|
|
62
|
+
|--------|-------------|
|
|
63
|
+
| `from(email)` | Sender address (required) |
|
|
64
|
+
| `to(*emails)` | Recipient(s) (required, accepts array) |
|
|
65
|
+
| `subject(str)` | Subject line (required) |
|
|
66
|
+
| `html(str)` | HTML body |
|
|
67
|
+
| `text(str)` | Plain text body |
|
|
68
|
+
| `cc(*emails)` | CC recipients |
|
|
69
|
+
| `bcc(*emails)` | BCC recipients |
|
|
70
|
+
| `reply_to(*emails)` | Reply-to addresses |
|
|
71
|
+
| `route(slug)` | Route slug |
|
|
72
|
+
| `tag(str)` | Message tag |
|
|
73
|
+
| `headers(hash)` | Custom headers |
|
|
74
|
+
| `metadata(hash)` | Custom metadata |
|
|
75
|
+
| `attach(filename, content, content_id:)` | Add attachment |
|
|
76
|
+
| `idempotency_key(key)` | Idempotency key header |
|
|
77
|
+
| `deliver` | Send the email, returns `SendEmailResponse` |
|
|
78
|
+
|
|
79
|
+
### Attachments
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
# Simple attachment
|
|
83
|
+
client.email
|
|
84
|
+
.from('sender@example.com')
|
|
85
|
+
.to('recipient@example.com')
|
|
86
|
+
.subject('Report')
|
|
87
|
+
.html('<p>See attached</p>')
|
|
88
|
+
.attach('report.pdf', Base64.strict_encode64(pdf_data))
|
|
89
|
+
.deliver
|
|
90
|
+
|
|
91
|
+
# With content ID (for inline images)
|
|
92
|
+
client.email
|
|
93
|
+
.attach('logo.png', base64_data, content_id: 'logo')
|
|
94
|
+
.html('<img src="cid:logo">')
|
|
95
|
+
...
|
|
96
|
+
|
|
97
|
+
# Using EmailAttachment type
|
|
98
|
+
attachment = Lettermint::EmailAttachment.new(
|
|
99
|
+
filename: 'doc.pdf',
|
|
100
|
+
content: base64_data,
|
|
101
|
+
content_id: nil
|
|
102
|
+
)
|
|
103
|
+
client.email.attach(attachment)...
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## Team API Resources
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
team = Lettermint::TeamAPI.new(team_token: 'lm_team_xxx')
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Generic
|
|
115
|
+
|
|
116
|
+
```ruby
|
|
117
|
+
team.ping # => { 'ok' => true }
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Team
|
|
121
|
+
|
|
122
|
+
```ruby
|
|
123
|
+
# Get team details
|
|
124
|
+
team.team.get # => Hash
|
|
125
|
+
team.team.get(include: 'features,featuresCount') # with includes
|
|
126
|
+
|
|
127
|
+
# Update team
|
|
128
|
+
team.team.update(name: 'New Name')
|
|
129
|
+
|
|
130
|
+
# Usage statistics
|
|
131
|
+
team.team.usage # => { current_period: ..., historical_usage: [...] }
|
|
132
|
+
|
|
133
|
+
# List members (paginated)
|
|
134
|
+
team.team.members(page_size: 50, page_cursor: 'cursor_xxx')
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### Domains
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
# List domains
|
|
141
|
+
team.domains.list
|
|
142
|
+
team.domains.list(status: 'verified', sort: '-created_at', page_size: 10)
|
|
143
|
+
|
|
144
|
+
# Create domain
|
|
145
|
+
team.domains.create(domain: 'mail.example.com')
|
|
146
|
+
|
|
147
|
+
# Get domain
|
|
148
|
+
team.domains.find('dom_xxx')
|
|
149
|
+
team.domains.find('dom_xxx', include: 'dnsRecords')
|
|
150
|
+
|
|
151
|
+
# Delete domain
|
|
152
|
+
team.domains.delete('dom_xxx')
|
|
153
|
+
|
|
154
|
+
# Verify DNS records
|
|
155
|
+
team.domains.verify_dns('dom_xxx') # all records
|
|
156
|
+
team.domains.verify_dns_record('dom_xxx', 'rec_xxx') # single record
|
|
157
|
+
|
|
158
|
+
# Update associated projects
|
|
159
|
+
team.domains.update_projects('dom_xxx', project_ids: ['proj_1', 'proj_2'])
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
### Projects
|
|
163
|
+
|
|
164
|
+
```ruby
|
|
165
|
+
# List projects
|
|
166
|
+
team.projects.list
|
|
167
|
+
team.projects.list(search: 'api', sort: 'name')
|
|
168
|
+
|
|
169
|
+
# Create project
|
|
170
|
+
team.projects.create(
|
|
171
|
+
name: 'My Project',
|
|
172
|
+
smtp_enabled: true,
|
|
173
|
+
initial_routes: 'transactional' # both, transactional, broadcast
|
|
174
|
+
)
|
|
175
|
+
# => { ..., 'api_token' => 'lm_project_xxx' }
|
|
176
|
+
|
|
177
|
+
# Get project
|
|
178
|
+
team.projects.find('proj_xxx')
|
|
179
|
+
team.projects.find('proj_xxx', include: 'routes,domains,messageStats')
|
|
180
|
+
|
|
181
|
+
# Update project
|
|
182
|
+
team.projects.update('proj_xxx',
|
|
183
|
+
name: 'Renamed',
|
|
184
|
+
smtp_enabled: false,
|
|
185
|
+
default_route_id: 'route_xxx'
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# Delete project
|
|
189
|
+
team.projects.delete('proj_xxx')
|
|
190
|
+
|
|
191
|
+
# Rotate API token
|
|
192
|
+
team.projects.rotate_token('proj_xxx') # => { 'new_token' => '...' }
|
|
193
|
+
|
|
194
|
+
# Manage members
|
|
195
|
+
team.projects.update_members('proj_xxx', team_member_ids: ['mem_1', 'mem_2'])
|
|
196
|
+
team.projects.add_member('proj_xxx', 'mem_xxx')
|
|
197
|
+
team.projects.remove_member('proj_xxx', 'mem_xxx')
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### Routes
|
|
201
|
+
|
|
202
|
+
```ruby
|
|
203
|
+
# Project-scoped routes
|
|
204
|
+
routes = team.projects.routes('proj_xxx')
|
|
205
|
+
|
|
206
|
+
routes.list
|
|
207
|
+
routes.list(route_type: 'transactional', is_default: true, sort: '-created_at')
|
|
208
|
+
|
|
209
|
+
routes.create(
|
|
210
|
+
name: 'Notifications',
|
|
211
|
+
route_type: 'transactional', # transactional, broadcast, inbound
|
|
212
|
+
slug: 'notifications'
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
# Direct route access (any route by ID)
|
|
216
|
+
team.routes.find('route_xxx')
|
|
217
|
+
team.routes.find('route_xxx', include: 'project,statistics')
|
|
218
|
+
|
|
219
|
+
team.routes.update('route_xxx',
|
|
220
|
+
name: 'Renamed',
|
|
221
|
+
settings: { track_opens: true, track_clicks: true },
|
|
222
|
+
inbound_settings: { inbound_domain: 'in.example.com', inbound_spam_threshold: 5 }
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
team.routes.delete('route_xxx')
|
|
226
|
+
team.routes.verify_inbound_domain('route_xxx')
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
### Messages
|
|
230
|
+
|
|
231
|
+
```ruby
|
|
232
|
+
# List messages
|
|
233
|
+
team.messages.list
|
|
234
|
+
team.messages.list(
|
|
235
|
+
type: 'outbound',
|
|
236
|
+
status: 'delivered',
|
|
237
|
+
route_id: 'route_xxx',
|
|
238
|
+
from_date: '2024-01-01',
|
|
239
|
+
to_date: '2024-01-31',
|
|
240
|
+
sort: '-created_at'
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
# Get message
|
|
244
|
+
team.messages.find('msg_xxx')
|
|
245
|
+
|
|
246
|
+
# Message events (delivery history)
|
|
247
|
+
team.messages.events('msg_xxx')
|
|
248
|
+
team.messages.events('msg_xxx', sort: '-timestamp')
|
|
249
|
+
|
|
250
|
+
# Message content
|
|
251
|
+
team.messages.source('msg_xxx') # RFC822 format
|
|
252
|
+
team.messages.html('msg_xxx') # HTML body
|
|
253
|
+
team.messages.text('msg_xxx') # Plain text body
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
### Webhooks
|
|
257
|
+
|
|
258
|
+
```ruby
|
|
259
|
+
# List webhooks
|
|
260
|
+
team.webhooks.list
|
|
261
|
+
team.webhooks.list(enabled: true, event: 'message.delivered', route_id: 'route_xxx')
|
|
262
|
+
|
|
263
|
+
# Create webhook (returns secret once)
|
|
264
|
+
result = team.webhooks.create(
|
|
265
|
+
route_id: 'route_xxx',
|
|
266
|
+
name: 'Delivery notifications',
|
|
267
|
+
url: 'https://example.com/webhooks',
|
|
268
|
+
events: ['message.delivered', 'message.hard_bounced'],
|
|
269
|
+
enabled: true
|
|
270
|
+
)
|
|
271
|
+
secret = result['secret'] # Store this securely
|
|
272
|
+
|
|
273
|
+
# Get webhook
|
|
274
|
+
team.webhooks.find('wh_xxx')
|
|
275
|
+
|
|
276
|
+
# Update webhook
|
|
277
|
+
team.webhooks.update('wh_xxx', name: 'Renamed', enabled: false)
|
|
278
|
+
|
|
279
|
+
# Delete webhook
|
|
280
|
+
team.webhooks.delete('wh_xxx')
|
|
281
|
+
|
|
282
|
+
# Test webhook
|
|
283
|
+
team.webhooks.test('wh_xxx') # => { 'delivery_id' => '...' }
|
|
284
|
+
|
|
285
|
+
# Regenerate secret
|
|
286
|
+
team.webhooks.regenerate_secret('wh_xxx') # => { 'secret' => '...' }
|
|
287
|
+
|
|
288
|
+
# Webhook deliveries
|
|
289
|
+
team.webhooks.deliveries('wh_xxx')
|
|
290
|
+
team.webhooks.deliveries('wh_xxx', status: 'failed', from_date: '2024-01-01')
|
|
291
|
+
team.webhooks.delivery('wh_xxx', 'del_xxx') # Full delivery details
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
### Suppressions
|
|
295
|
+
|
|
296
|
+
```ruby
|
|
297
|
+
# List suppressions
|
|
298
|
+
team.suppressions.list
|
|
299
|
+
team.suppressions.list(scope: 'team', reason: 'hard_bounce')
|
|
300
|
+
|
|
301
|
+
# Create suppression
|
|
302
|
+
team.suppressions.create(
|
|
303
|
+
reason: 'manual', # spam_complaint, hard_bounce, unsubscribe, manual
|
|
304
|
+
scope: 'team', # global, team, project, route
|
|
305
|
+
email: 'bad@example.com'
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
# Bulk suppress (up to 1000)
|
|
309
|
+
team.suppressions.create(
|
|
310
|
+
reason: 'manual',
|
|
311
|
+
scope: 'project',
|
|
312
|
+
project_id: 'proj_xxx',
|
|
313
|
+
emails: ['a@example.com', 'b@example.com']
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
# Delete suppression
|
|
317
|
+
team.suppressions.delete('sup_xxx')
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
### Stats
|
|
321
|
+
|
|
322
|
+
```ruby
|
|
323
|
+
# Get statistics (max 90 day range)
|
|
324
|
+
team.stats.get(from: '2024-01-01', to: '2024-03-31')
|
|
325
|
+
team.stats.get(from: '2024-01-01', to: '2024-01-31', project_id: 'proj_xxx')
|
|
326
|
+
team.stats.get(from: '2024-01-01', to: '2024-01-31', route_ids: ['r1', 'r2'])
|
|
327
|
+
|
|
328
|
+
# Returns:
|
|
329
|
+
# {
|
|
330
|
+
# 'from' => '2024-01-01',
|
|
331
|
+
# 'to' => '2024-01-31',
|
|
332
|
+
# 'totals' => { 'sent' => 1000, 'delivered' => 980, ... },
|
|
333
|
+
# 'daily' => [{ 'date' => '2024-01-01', 'sent' => 50, ... }, ...]
|
|
334
|
+
# }
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
---
|
|
338
|
+
|
|
339
|
+
## Webhook Verification
|
|
340
|
+
|
|
341
|
+
Standalone module, no client dependency.
|
|
342
|
+
|
|
343
|
+
```ruby
|
|
344
|
+
# Instance-based
|
|
345
|
+
webhook = Lettermint::Webhook.new(secret: 'whsec_xxx', tolerance: 300)
|
|
346
|
+
|
|
347
|
+
# Verify from headers (recommended)
|
|
348
|
+
payload = webhook.verify_headers(request.headers, request.raw_body)
|
|
349
|
+
# => parsed JSON hash
|
|
350
|
+
|
|
351
|
+
# Verify from signature string
|
|
352
|
+
payload = webhook.verify(raw_body, signature_header)
|
|
353
|
+
|
|
354
|
+
# Class method (one-shot)
|
|
355
|
+
payload = Lettermint::Webhook.verify_signature(
|
|
356
|
+
raw_body,
|
|
357
|
+
signature_header,
|
|
358
|
+
secret: 'whsec_xxx',
|
|
359
|
+
tolerance: 300
|
|
360
|
+
)
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
### Signature Format
|
|
364
|
+
|
|
365
|
+
Header: `x-lettermint-signature: t=<unix_ts>,v1=<hex_hmac>`
|
|
366
|
+
Timestamp: `x-lettermint-delivery: <unix_ts>`
|
|
367
|
+
|
|
368
|
+
---
|
|
369
|
+
|
|
370
|
+
## Error Handling
|
|
371
|
+
|
|
372
|
+
```ruby
|
|
373
|
+
begin
|
|
374
|
+
client.email.from('x').to('y').subject('z').html('...').deliver
|
|
375
|
+
rescue Lettermint::ValidationError => e
|
|
376
|
+
e.message # Error message
|
|
377
|
+
e.status_code # 422
|
|
378
|
+
e.error_type # Validation error type (same as e.code for ApiError bodies)
|
|
379
|
+
e.response_body # Raw response
|
|
380
|
+
rescue Lettermint::ClientError => e
|
|
381
|
+
e.status_code # 400
|
|
382
|
+
rescue Lettermint::AuthenticationError => e
|
|
383
|
+
e.status_code # 401 or 403
|
|
384
|
+
rescue Lettermint::NotFoundError => e
|
|
385
|
+
e.status_code # 404
|
|
386
|
+
e.code # e.g. "RESOURCE_NOT_FOUND" (may be nil)
|
|
387
|
+
e.details # Hash, Array or nil; shape depends on code
|
|
388
|
+
rescue Lettermint::RateLimitError => e
|
|
389
|
+
e.retry_after # Seconds to wait (may be nil)
|
|
390
|
+
rescue Lettermint::TimeoutError
|
|
391
|
+
# Request timed out
|
|
392
|
+
rescue Lettermint::ConnectionError => e
|
|
393
|
+
e.original_exception # Underlying Faraday error
|
|
394
|
+
rescue Lettermint::ResponseParsingError => e
|
|
395
|
+
e.original_exception # Faraday::ParsingError
|
|
396
|
+
rescue Lettermint::HttpRequestError => e
|
|
397
|
+
# Catch-all for HTTP errors
|
|
398
|
+
e.status_code # Integer
|
|
399
|
+
e.code # API error code string (may be nil)
|
|
400
|
+
e.details # Hash, Array or nil
|
|
401
|
+
e.response_body # Hash (JSON), String (non-JSON) or nil (empty)
|
|
402
|
+
rescue Lettermint::Error => e
|
|
403
|
+
# Catch-all for SDK errors
|
|
404
|
+
end
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
### Webhook Errors
|
|
408
|
+
|
|
409
|
+
```ruby
|
|
410
|
+
begin
|
|
411
|
+
webhook.verify_headers(headers, body)
|
|
412
|
+
rescue Lettermint::InvalidSignatureError
|
|
413
|
+
# HMAC mismatch
|
|
414
|
+
rescue Lettermint::TimestampToleranceError
|
|
415
|
+
# Timestamp too old or future
|
|
416
|
+
rescue Lettermint::WebhookJsonDecodeError => e
|
|
417
|
+
e.original_exception # JSON::ParserError
|
|
418
|
+
rescue Lettermint::WebhookVerificationError
|
|
419
|
+
# Catch-all for webhook errors
|
|
420
|
+
end
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
---
|
|
424
|
+
|
|
425
|
+
## Error Hierarchy
|
|
426
|
+
|
|
427
|
+
```
|
|
428
|
+
Lettermint::Error
|
|
429
|
+
├── HttpRequestError (status_code, response_body, code, details)
|
|
430
|
+
│ ├── ValidationError (422, error_type)
|
|
431
|
+
│ ├── ClientError (400)
|
|
432
|
+
│ ├── AuthenticationError (401/403)
|
|
433
|
+
│ ├── NotFoundError (404)
|
|
434
|
+
│ └── RateLimitError (429, retry_after)
|
|
435
|
+
├── TimeoutError
|
|
436
|
+
├── ConnectionError (original_exception)
|
|
437
|
+
├── ResponseParsingError (original_exception)
|
|
438
|
+
└── WebhookVerificationError
|
|
439
|
+
├── InvalidSignatureError
|
|
440
|
+
├── TimestampToleranceError
|
|
441
|
+
└── WebhookJsonDecodeError (original_exception)
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
### Error Bodies
|
|
445
|
+
|
|
446
|
+
`HttpRequestError` and its subclasses read three JSON body shapes, matching the official Lettermint SDKs:
|
|
447
|
+
|
|
448
|
+
| Body | Sets |
|
|
449
|
+
|------|------|
|
|
450
|
+
| `{"error":{"code":"...","message":"...","details":...}}` | `code`, `message`, `details` |
|
|
451
|
+
| `{"error":"<string>"}` | `code` |
|
|
452
|
+
| `{"message":"<string>"}` | `message` |
|
|
453
|
+
|
|
454
|
+
`message` resolves in order: `error.message`, top-level `message`, then `"HTTP <status>"`.
|
|
455
|
+
|
|
456
|
+
`response_body` is the parsed Hash for JSON responses, the stripped String for non-JSON responses (for example an HTML 404 page from a proxy), and nil for an empty body.
|
|
457
|
+
|
|
458
|
+
Limitation: a non-JSON body served with a JSON Content-Type raises `ResponseParsingError` before status mapping, so such a proxy 404 is not a `NotFoundError`.
|
|
459
|
+
|
|
460
|
+
---
|
|
461
|
+
|
|
462
|
+
## Types
|
|
463
|
+
|
|
464
|
+
```ruby
|
|
465
|
+
# SendEmailResponse (Data.define, frozen)
|
|
466
|
+
response = client.email...deliver
|
|
467
|
+
response.message_id # String
|
|
468
|
+
response.status # String
|
|
469
|
+
|
|
470
|
+
# EmailAttachment (Data.define, frozen)
|
|
471
|
+
attachment = Lettermint::EmailAttachment.new(
|
|
472
|
+
filename: 'report.pdf',
|
|
473
|
+
content: Base64.strict_encode64(data),
|
|
474
|
+
content_id: nil # optional
|
|
475
|
+
)
|
|
476
|
+
attachment.to_h # => { filename: ..., content: ... }
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
---
|
|
480
|
+
|
|
481
|
+
## Pagination
|
|
482
|
+
|
|
483
|
+
All list methods support cursor-based pagination:
|
|
484
|
+
|
|
485
|
+
```ruby
|
|
486
|
+
# First page
|
|
487
|
+
result = team.domains.list(page_size: 10)
|
|
488
|
+
items = result['data']
|
|
489
|
+
cursor = result.dig('meta', 'cursor')
|
|
490
|
+
|
|
491
|
+
# Next page
|
|
492
|
+
if cursor
|
|
493
|
+
result = team.domains.list(page_size: 10, page_cursor: cursor)
|
|
494
|
+
end
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
---
|
|
498
|
+
|
|
499
|
+
## Raw HTTP Access
|
|
500
|
+
|
|
501
|
+
For endpoints not yet wrapped in typed methods:
|
|
502
|
+
|
|
503
|
+
```ruby
|
|
504
|
+
client = Lettermint::SendingAPI.new(api_token: 'xxx')
|
|
505
|
+
|
|
506
|
+
client.get('/some/endpoint', params: { key: 'value' })
|
|
507
|
+
client.post('/some/endpoint', data: { key: 'value' })
|
|
508
|
+
client.put('/some/endpoint', data: { key: 'value' })
|
|
509
|
+
client.delete('/some/endpoint')
|
|
510
|
+
```
|
data/lib/lettermint/errors.rb
CHANGED
|
@@ -4,11 +4,15 @@ module Lettermint
|
|
|
4
4
|
class Error < StandardError; end
|
|
5
5
|
|
|
6
6
|
class HttpRequestError < Error
|
|
7
|
-
|
|
7
|
+
# code: the API error code string, when the response carried one.
|
|
8
|
+
# details: Hash, Array or nil; the shape depends on code.
|
|
9
|
+
attr_reader :status_code, :response_body, :code, :details
|
|
8
10
|
|
|
9
|
-
def initialize(message:, status_code:, response_body: nil)
|
|
11
|
+
def initialize(message:, status_code:, response_body: nil, code: nil, details: nil)
|
|
10
12
|
@status_code = status_code
|
|
11
13
|
@response_body = response_body
|
|
14
|
+
@code = code
|
|
15
|
+
@details = details
|
|
12
16
|
super(message)
|
|
13
17
|
end
|
|
14
18
|
end
|
|
@@ -16,20 +20,26 @@ module Lettermint
|
|
|
16
20
|
class ValidationError < HttpRequestError
|
|
17
21
|
attr_reader :error_type
|
|
18
22
|
|
|
19
|
-
def initialize(message:, error_type:, response_body: nil)
|
|
23
|
+
def initialize(message:, error_type:, response_body: nil, code: nil, details: nil)
|
|
20
24
|
@error_type = error_type
|
|
21
|
-
super(message: message, status_code: 422, response_body: response_body)
|
|
25
|
+
super(message: message, status_code: 422, response_body: response_body, code: code, details: details)
|
|
22
26
|
end
|
|
23
27
|
end
|
|
24
28
|
|
|
25
29
|
class ClientError < HttpRequestError
|
|
26
|
-
def initialize(message:, response_body: nil)
|
|
27
|
-
super(message: message, status_code: 400, response_body: response_body)
|
|
30
|
+
def initialize(message:, response_body: nil, code: nil, details: nil)
|
|
31
|
+
super(message: message, status_code: 400, response_body: response_body, code: code, details: details)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
class NotFoundError < HttpRequestError
|
|
36
|
+
def initialize(message:, response_body: nil, code: nil, details: nil)
|
|
37
|
+
super(message: message, status_code: 404, response_body: response_body, code: code, details: details)
|
|
28
38
|
end
|
|
29
39
|
end
|
|
30
40
|
|
|
31
41
|
class AuthenticationError < HttpRequestError
|
|
32
|
-
def initialize(message:, status_code:, response_body: nil)
|
|
42
|
+
def initialize(message:, status_code:, response_body: nil, code: nil, details: nil)
|
|
33
43
|
super
|
|
34
44
|
end
|
|
35
45
|
end
|
|
@@ -37,9 +47,47 @@ module Lettermint
|
|
|
37
47
|
class RateLimitError < HttpRequestError
|
|
38
48
|
attr_reader :retry_after
|
|
39
49
|
|
|
40
|
-
def initialize(message:, retry_after: nil, response_body: nil)
|
|
50
|
+
def initialize(message:, retry_after: nil, response_body: nil, code: nil, details: nil)
|
|
41
51
|
@retry_after = retry_after
|
|
42
|
-
super(message: message, status_code: 429, response_body: response_body)
|
|
52
|
+
super(message: message, status_code: 429, response_body: response_body, code: code, details: details)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Extracts [message, code, details] from an error response body, mirroring the
|
|
57
|
+
# official Node and Python SDKs. Only a Hash body is inspected; any other body
|
|
58
|
+
# yields "HTTP <status>" with no code or details.
|
|
59
|
+
# @api private
|
|
60
|
+
module ErrorBody
|
|
61
|
+
module_function
|
|
62
|
+
|
|
63
|
+
def parse(status, body)
|
|
64
|
+
message, code, details = body.is_a?(Hash) ? fields_from(body) : []
|
|
65
|
+
message = "HTTP #{status}" if blank?(message)
|
|
66
|
+
[message, code, details]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def fields_from(body)
|
|
70
|
+
error = body['error']
|
|
71
|
+
message, code, details = fields_from_error(error)
|
|
72
|
+
message = body['message'] if blank?(message) && body['message'].is_a?(String)
|
|
73
|
+
[message, code, details]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# A String `error` is a code, never a message.
|
|
77
|
+
def fields_from_error(error)
|
|
78
|
+
case error
|
|
79
|
+
when Hash then [string_or_nil(error['message']), string_or_nil(error['code']), error['details']]
|
|
80
|
+
when String then [nil, error, nil]
|
|
81
|
+
else []
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def string_or_nil(value)
|
|
86
|
+
value.is_a?(String) ? value : nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def blank?(value)
|
|
90
|
+
value.nil? || value.strip.empty?
|
|
43
91
|
end
|
|
44
92
|
end
|
|
45
93
|
|
|
@@ -54,6 +102,15 @@ module Lettermint
|
|
|
54
102
|
end
|
|
55
103
|
end
|
|
56
104
|
|
|
105
|
+
class ResponseParsingError < Error
|
|
106
|
+
attr_reader :original_exception
|
|
107
|
+
|
|
108
|
+
def initialize(message:, original_exception: nil)
|
|
109
|
+
@original_exception = original_exception
|
|
110
|
+
super(message)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
57
114
|
class WebhookVerificationError < Error; end
|
|
58
115
|
|
|
59
116
|
class InvalidSignatureError < WebhookVerificationError; end
|
|
@@ -70,41 +70,54 @@ module Lettermint
|
|
|
70
70
|
handle_response(response)
|
|
71
71
|
rescue Faraday::TimeoutError, Timeout::Error
|
|
72
72
|
raise Lettermint::TimeoutError, "Request timeout after #{@connection.options.timeout}s"
|
|
73
|
+
rescue Faraday::SSLError => e
|
|
74
|
+
raise Lettermint::ConnectionError.new(message: "SSL error: #{e.message}", original_exception: e)
|
|
73
75
|
rescue Faraday::ConnectionFailed => e
|
|
74
76
|
raise Lettermint::ConnectionError.new(message: e.message, original_exception: e)
|
|
77
|
+
rescue Faraday::ParsingError => e
|
|
78
|
+
raise Lettermint::ResponseParsingError.new(message: "API response parsing failed: #{e.message}",
|
|
79
|
+
original_exception: e)
|
|
75
80
|
end
|
|
76
81
|
|
|
77
82
|
def handle_response(response)
|
|
78
83
|
return response.body if response.success?
|
|
79
84
|
|
|
80
|
-
|
|
81
|
-
raise_api_error(response.status, body, response.headers)
|
|
85
|
+
raise build_error(response.status, normalize_body(response.body), response.headers)
|
|
82
86
|
end
|
|
83
87
|
|
|
84
|
-
|
|
85
|
-
|
|
88
|
+
# Hash stays Hash; String is stripped (nil when empty); anything else passes through as parsed.
|
|
89
|
+
def normalize_body(body)
|
|
90
|
+
return body unless body.is_a?(String)
|
|
91
|
+
|
|
92
|
+
stripped = body.strip
|
|
93
|
+
stripped.empty? ? nil : stripped
|
|
86
94
|
end
|
|
87
95
|
|
|
88
|
-
def build_error(status, body, headers)
|
|
89
|
-
|
|
96
|
+
def build_error(status, body, headers)
|
|
97
|
+
message, code, details = ErrorBody.parse(status, body)
|
|
98
|
+
common = { message: message, response_body: body, code: code, details: details }
|
|
90
99
|
|
|
91
100
|
case status
|
|
92
|
-
when 401, 403
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
when
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
ValidationError.new(message: msg['message', 'Validation error'],
|
|
99
|
-
error_type: msg['error', 'ValidationError'], response_body: body)
|
|
100
|
-
when 429
|
|
101
|
-
retry_after = headers && headers['Retry-After'] && Integer(headers['Retry-After'], exception: false)
|
|
102
|
-
RateLimitError.new(message: msg['message', 'Rate limit exceeded'],
|
|
103
|
-
retry_after: retry_after, response_body: body)
|
|
104
|
-
else
|
|
105
|
-
HttpRequestError.new(message: msg['message', "HTTP #{status}"],
|
|
106
|
-
status_code: status, response_body: body)
|
|
101
|
+
when 401, 403 then AuthenticationError.new(status_code: status, **common)
|
|
102
|
+
when 400 then ClientError.new(**common)
|
|
103
|
+
when 404 then NotFoundError.new(**common)
|
|
104
|
+
when 422 then ValidationError.new(error_type: validation_error_type(body, code), **common)
|
|
105
|
+
when 429 then RateLimitError.new(retry_after: retry_after_from(headers), **common)
|
|
106
|
+
else HttpRequestError.new(status_code: status, **common)
|
|
107
107
|
end
|
|
108
108
|
end
|
|
109
|
+
|
|
110
|
+
# A String `error` is kept verbatim; a Hash `error` contributes its code.
|
|
111
|
+
def validation_error_type(body, code)
|
|
112
|
+
error = body.is_a?(Hash) ? body['error'] : nil
|
|
113
|
+
return error if error.is_a?(String)
|
|
114
|
+
|
|
115
|
+
code || 'ValidationError'
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def retry_after_from(headers)
|
|
119
|
+
value = headers && headers['Retry-After']
|
|
120
|
+
value && Integer(value, exception: false)
|
|
121
|
+
end
|
|
109
122
|
end
|
|
110
123
|
end
|
|
@@ -18,7 +18,7 @@ module Lettermint
|
|
|
18
18
|
# @param from_date [String, nil] Filter from date (Y-m-d)
|
|
19
19
|
# @param to_date [String, nil] Filter to date (Y-m-d)
|
|
20
20
|
# @return [Hash] Paginated list of messages
|
|
21
|
-
# rubocop:disable Metrics/ParameterLists
|
|
21
|
+
# rubocop:disable-next Metrics/ParameterLists
|
|
22
22
|
def list(page_size: nil, page_cursor: nil, sort: nil, type: nil, status: nil,
|
|
23
23
|
route_id: nil, domain_id: nil, tag: nil, from_email: nil, subject: nil,
|
|
24
24
|
from_date: nil, to_date: nil)
|
|
@@ -38,7 +38,6 @@ module Lettermint
|
|
|
38
38
|
)
|
|
39
39
|
@http_client.get(path: '/messages', params: params)
|
|
40
40
|
end
|
|
41
|
-
# rubocop:enable Metrics/ParameterLists
|
|
42
41
|
|
|
43
42
|
# Get message details.
|
|
44
43
|
# @param id [String] Message ID
|
|
@@ -21,7 +21,7 @@ module Lettermint
|
|
|
21
21
|
# @param is_default [Boolean, nil] Filter by default route status
|
|
22
22
|
# @param search [String, nil] Search filter
|
|
23
23
|
# @return [Hash] Paginated list of routes
|
|
24
|
-
# rubocop:disable Metrics/ParameterLists
|
|
24
|
+
# rubocop:disable-next Metrics/ParameterLists
|
|
25
25
|
def list(page_size: nil, page_cursor: nil, sort: nil, route_type: nil,
|
|
26
26
|
is_default: nil, search: nil)
|
|
27
27
|
raise ArgumentError, 'project_id required for listing routes' unless @project_id
|
|
@@ -36,7 +36,6 @@ module Lettermint
|
|
|
36
36
|
)
|
|
37
37
|
@http_client.get(path: "/projects/#{@project_id}/routes", params: params)
|
|
38
38
|
end
|
|
39
|
-
# rubocop:enable Metrics/ParameterLists
|
|
40
39
|
|
|
41
40
|
# Create a new route in a project.
|
|
42
41
|
# Requires project_id to be set.
|
|
@@ -14,7 +14,7 @@ module Lettermint
|
|
|
14
14
|
# @param value [String, nil] Filter by suppression value (email/domain/extension)
|
|
15
15
|
# @param reason [String, nil] Filter: spam_complaint, hard_bounce, unsubscribe, manual
|
|
16
16
|
# @return [Hash] Paginated list of suppressions
|
|
17
|
-
# rubocop:disable Metrics/ParameterLists
|
|
17
|
+
# rubocop:disable-next Metrics/ParameterLists
|
|
18
18
|
def list(page_size: nil, page_cursor: nil, sort: nil, scope: nil,
|
|
19
19
|
route_id: nil, project_id: nil, value: nil, reason: nil)
|
|
20
20
|
params = build_params(
|
|
@@ -29,7 +29,6 @@ module Lettermint
|
|
|
29
29
|
)
|
|
30
30
|
@http_client.get(path: '/suppressions', params: params)
|
|
31
31
|
end
|
|
32
|
-
# rubocop:enable Metrics/ParameterLists
|
|
33
32
|
|
|
34
33
|
# Create a suppression entry.
|
|
35
34
|
# @param reason [String] Reason: spam_complaint, hard_bounce, unsubscribe, manual
|
|
@@ -13,7 +13,7 @@ module Lettermint
|
|
|
13
13
|
# @param route_id [String, nil] Filter by route ID
|
|
14
14
|
# @param search [String, nil] Search filter
|
|
15
15
|
# @return [Hash] Paginated list of webhooks
|
|
16
|
-
# rubocop:disable Metrics/ParameterLists
|
|
16
|
+
# rubocop:disable-next Metrics/ParameterLists
|
|
17
17
|
def list(page_size: nil, page_cursor: nil, sort: nil, enabled: nil,
|
|
18
18
|
event: nil, route_id: nil, search: nil)
|
|
19
19
|
params = build_params(
|
|
@@ -27,7 +27,6 @@ module Lettermint
|
|
|
27
27
|
)
|
|
28
28
|
@http_client.get(path: '/webhooks', params: params)
|
|
29
29
|
end
|
|
30
|
-
# rubocop:enable Metrics/ParameterLists
|
|
31
30
|
|
|
32
31
|
# Create a new webhook.
|
|
33
32
|
# @param route_id [String] Route ID to attach webhook to
|
|
@@ -96,7 +95,7 @@ module Lettermint
|
|
|
96
95
|
# @param from_date [String, nil] Filter from date (Y-m-d)
|
|
97
96
|
# @param to_date [String, nil] Filter to date (Y-m-d)
|
|
98
97
|
# @return [Hash] Paginated list of deliveries
|
|
99
|
-
# rubocop:disable Metrics/ParameterLists
|
|
98
|
+
# rubocop:disable-next Metrics/ParameterLists
|
|
100
99
|
def deliveries(webhook_id, page_size: nil, page_cursor: nil, sort: nil,
|
|
101
100
|
status: nil, event_type: nil, from_date: nil, to_date: nil)
|
|
102
101
|
params = build_params(
|
|
@@ -110,7 +109,6 @@ module Lettermint
|
|
|
110
109
|
)
|
|
111
110
|
@http_client.get(path: "/webhooks/#{webhook_id}/deliveries", params: params)
|
|
112
111
|
end
|
|
113
|
-
# rubocop:enable Metrics/ParameterLists
|
|
114
112
|
|
|
115
113
|
# Get a specific delivery.
|
|
116
114
|
# @param webhook_id [String] Webhook ID
|
data/lib/lettermint/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: lettermint
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Delano
|
|
@@ -31,11 +31,11 @@ executables: []
|
|
|
31
31
|
extensions: []
|
|
32
32
|
extra_rdoc_files: []
|
|
33
33
|
files:
|
|
34
|
-
- ".gemini/settings.json"
|
|
35
34
|
- CHANGELOG.md
|
|
36
35
|
- LICENSE
|
|
37
36
|
- README.md
|
|
38
|
-
-
|
|
37
|
+
- docs/api.md
|
|
38
|
+
- docs/sdk.md
|
|
39
39
|
- lib/lettermint.rb
|
|
40
40
|
- lib/lettermint/client.rb
|
|
41
41
|
- lib/lettermint/configuration.rb
|
data/.gemini/settings.json
DELETED
data/lettermint.gemspec
DELETED
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative 'lib/lettermint/version'
|
|
4
|
-
|
|
5
|
-
Gem::Specification.new do |spec|
|
|
6
|
-
spec.name = 'lettermint'
|
|
7
|
-
spec.version = Lettermint::VERSION
|
|
8
|
-
spec.authors = ['Delano']
|
|
9
|
-
spec.email = ['gems@onetimesecret.com']
|
|
10
|
-
|
|
11
|
-
spec.summary = 'Ruby SDK for the Lettermint transactional email API'
|
|
12
|
-
spec.description = 'Send transactional emails and verify webhooks with the Lettermint API. ' \
|
|
13
|
-
'Provides a fluent builder interface, typed responses, and HMAC-SHA256 webhook verification.'
|
|
14
|
-
spec.homepage = 'https://github.com/onetimesecret/lettermint-ruby'
|
|
15
|
-
spec.license = 'MIT'
|
|
16
|
-
spec.required_ruby_version = '>= 3.2.0'
|
|
17
|
-
|
|
18
|
-
spec.metadata['homepage_uri'] = spec.homepage
|
|
19
|
-
spec.metadata['source_code_uri'] = spec.homepage
|
|
20
|
-
spec.metadata['changelog_uri'] = "#{spec.homepage}/blob/main/CHANGELOG.md"
|
|
21
|
-
spec.metadata['rubygems_mfa_required'] = 'true'
|
|
22
|
-
spec.metadata['allowed_push_host'] = 'https://rubygems.org'
|
|
23
|
-
|
|
24
|
-
spec.files = Dir.chdir(__dir__) do
|
|
25
|
-
`git ls-files -z`.split("\x0").reject do |f|
|
|
26
|
-
(File.expand_path(f) == __FILE__) ||
|
|
27
|
-
f.start_with?('spec/', 'examples/', '.git', '.rubocop', 'Rakefile', 'Gemfile',
|
|
28
|
-
'CLAUDE', '.pre-commit', '.rspec')
|
|
29
|
-
end
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
spec.require_paths = ['lib']
|
|
33
|
-
|
|
34
|
-
spec.add_dependency 'faraday', '~> 2.0'
|
|
35
|
-
end
|