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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4755559f2d03919f11cb88181b581062497106993656e9d1b0a87738769718e5
4
- data.tar.gz: c5b8df6ea0033b41290f4db33aff06854a1ff02404af2391643d6fc736c38167
3
+ metadata.gz: 8aad4ee2bba82edddc66ea5843cb3218394a4efca38bc82b9c2c7b784cd98f2f
4
+ data.tar.gz: 9bcdbaffbb553fe38d8e83afee6a1b8b2e6b231e7953fb9342e9c64d3b979455
5
5
  SHA512:
6
- metadata.gz: a7f78e00a6044e15e2e57efaa365d798a922e1d96fb58d418ba4279cb03785c48c05537e5c0921b177d75d4648c14b8ee0dcf81390dabe9402f21f9485184f5b
7
- data.tar.gz: b254a766abe3503cbfb5fad105d9acf112e4d9ed2cb1c642caf42289a90f037ecdd3d2768b2148ee2f60590b5e3acff232e7fabdc409d249afb73e3f41120b08
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
  [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.2-red?style=flat-square)](https://www.ruby-lang.org)
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://github.com/onetimesecret/lettermint-ruby/blob/main/LICENSE)
6
6
 
7
- Unofficial Ruby SDK for the [Lettermint](https://lettermint.co) transactional email API. Based on the official [Python SDK](https://github.com/lettermint/lettermint-python).
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
- client = Lettermint::Client.new(api_token: 'your-api-token')
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::Client.new(api_token: 'your-api-token')
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::Client.new(
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
+ ```
@@ -4,11 +4,15 @@ module Lettermint
4
4
  class Error < StandardError; end
5
5
 
6
6
  class HttpRequestError < Error
7
- attr_reader :status_code, :response_body
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
- body = response.body.is_a?(Hash) ? response.body : nil
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
- def raise_api_error(status, body, headers)
85
- raise build_error(status, body, headers)
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) # rubocop:disable Metrics
89
- msg = ->(key, fallback) { body&.dig(key) || fallback }
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
- AuthenticationError.new(message: msg['message', "HTTP #{status}"],
94
- status_code: status, response_body: body)
95
- when 400
96
- ClientError.new(message: msg['error', 'Unknown client error'], response_body: body)
97
- when 422
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Lettermint
4
- VERSION = '0.2.0'
4
+ VERSION = '0.2.1'
5
5
  end
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.0
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
- - lettermint.gemspec
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
@@ -1,7 +0,0 @@
1
- {
2
- "codeReview": {
3
- "enabled": true,
4
- "autoReview": true,
5
- "reviewStyle": "concise"
6
- }
7
- }
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