lettermint 0.1.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 +42 -0
- data/README.md +141 -28
- data/docs/api.md +210 -0
- data/docs/sdk.md +510 -0
- data/lib/lettermint/client.rb +3 -29
- data/lib/lettermint/email_message.rb +11 -2
- data/lib/lettermint/errors.rb +75 -9
- data/lib/lettermint/http_client.rb +45 -24
- data/lib/lettermint/resources/base.rb +33 -0
- data/lib/lettermint/resources/domains.rb +66 -0
- data/lib/lettermint/resources/messages.rb +80 -0
- data/lib/lettermint/resources/projects.rb +103 -0
- data/lib/lettermint/resources/routes.rb +92 -0
- data/lib/lettermint/resources/stats.rb +23 -0
- data/lib/lettermint/resources/suppressions.rb +58 -0
- data/lib/lettermint/resources/team.rb +38 -0
- data/lib/lettermint/resources/webhooks.rb +122 -0
- data/lib/lettermint/sending_api.rb +75 -0
- data/lib/lettermint/team_api.rb +90 -0
- data/lib/lettermint/version.rb +1 -1
- data/lib/lettermint.rb +15 -0
- metadata +14 -2
- data/lettermint.gemspec +0 -35
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/client.rb
CHANGED
|
@@ -1,33 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Lettermint
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
def initialize(api_token:, base_url: nil, timeout: nil)
|
|
8
|
-
validate_api_token!(api_token)
|
|
9
|
-
|
|
10
|
-
@configuration = Configuration.new
|
|
11
|
-
@configuration.base_url = base_url || Lettermint.configuration.base_url
|
|
12
|
-
@configuration.timeout = timeout || Lettermint.configuration.timeout
|
|
13
|
-
|
|
14
|
-
yield @configuration if block_given?
|
|
15
|
-
|
|
16
|
-
@http_client = HttpClient.new(
|
|
17
|
-
api_token: api_token,
|
|
18
|
-
base_url: @configuration.base_url,
|
|
19
|
-
timeout: @configuration.timeout
|
|
20
|
-
)
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
def email
|
|
24
|
-
EmailMessage.new(http_client: @http_client)
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
private
|
|
28
|
-
|
|
29
|
-
def validate_api_token!(token)
|
|
30
|
-
raise ArgumentError, 'API token cannot be empty' if token.nil? || token.to_s.strip.empty?
|
|
31
|
-
end
|
|
32
|
-
end
|
|
4
|
+
# Backward compatibility: Client is an alias for SendingAPI.
|
|
5
|
+
# Use Lettermint::SendingAPI explicitly for clarity.
|
|
6
|
+
Client = SendingAPI
|
|
33
7
|
end
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Lettermint
|
|
4
|
-
class EmailMessage
|
|
4
|
+
class EmailMessage # rubocop:disable Metrics/ClassLength
|
|
5
5
|
def initialize(http_client:)
|
|
6
6
|
@http_client = http_client
|
|
7
7
|
reset
|
|
@@ -108,7 +108,8 @@ module Lettermint
|
|
|
108
108
|
|
|
109
109
|
def validate_required_fields
|
|
110
110
|
missing = %i[from subject].select { |f| blank?(f) }
|
|
111
|
-
missing << :to
|
|
111
|
+
missing << :to unless valid_recipients?
|
|
112
|
+
missing << :body unless body?
|
|
112
113
|
return if missing.empty?
|
|
113
114
|
|
|
114
115
|
raise ArgumentError, "Missing required field(s): #{missing.join(', ')}"
|
|
@@ -118,6 +119,14 @@ module Lettermint
|
|
|
118
119
|
@payload[field].nil? || @payload[field].to_s.strip.empty?
|
|
119
120
|
end
|
|
120
121
|
|
|
122
|
+
def valid_recipients?
|
|
123
|
+
@payload[:to]&.any? { |e| e.is_a?(String) && !e.strip.empty? }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def body?
|
|
127
|
+
!blank?(:html) || !blank?(:text)
|
|
128
|
+
end
|
|
129
|
+
|
|
121
130
|
def reset
|
|
122
131
|
@payload = {}
|
|
123
132
|
@idempotency_key = nil
|
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,14 +47,70 @@ 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
|
|
|
46
94
|
class TimeoutError < Error; end
|
|
47
95
|
|
|
96
|
+
class ConnectionError < Error
|
|
97
|
+
attr_reader :original_exception
|
|
98
|
+
|
|
99
|
+
def initialize(message:, original_exception: nil)
|
|
100
|
+
@original_exception = original_exception
|
|
101
|
+
super(message)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
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
|
+
|
|
48
114
|
class WebhookVerificationError < Error; end
|
|
49
115
|
|
|
50
116
|
class InvalidSignatureError < WebhookVerificationError; end
|