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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: eaf2c7d68c2a67e9bdcfff25733031fd17e23645ae6d5c2cc73100177ecd8865
4
- data.tar.gz: 7ecfec4398f01936474380abb69c595e9220d1f90e3e990b9261e426e72c1772
3
+ metadata.gz: 8aad4ee2bba82edddc66ea5843cb3218394a4efca38bc82b9c2c7b784cd98f2f
4
+ data.tar.gz: 9bcdbaffbb553fe38d8e83afee6a1b8b2e6b231e7953fb9342e9c64d3b979455
5
5
  SHA512:
6
- metadata.gz: fa95bb5a225b54f848de6b62e0e334072207373ab84bbd0a56ba5babc23b47f6b60d4bbdf30097884c6df28c64209ae7956e4c9b551e7b38a735740555a43e09
7
- data.tar.gz: a4e41473d58b93ced91c3219d6074d0eaf6382b61174758cc62a091c8ca59762fc75e1a1e9acd4de5372e5c86083cd3a34767c88d7561ab31a7d0ff7b5c4ad4f
6
+ metadata.gz: e9b83c4a5fb73350768a2a49774e712ecd65d5b0cd5001475c3df4748ce5e8bca67e596a6105a809cf6d1512b13d394be8b40fe202f593f8579aa1a4efb71de2
7
+ data.tar.gz: 26a144391b3bf492267b82ef115f89c9e1527fd19dff8c604379cb35c871f52dfe340df2f20ee1950e55516aa25baf2b686d9433c0c3e1051afb476382f2af90
data/CHANGELOG.md CHANGED
@@ -5,6 +5,48 @@ 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
+
29
+ ## [0.2.0] - 2026-04-01
30
+
31
+ ### Added
32
+
33
+ - `TeamAPI` for managing team resources (members, invitations)
34
+ - `SendingAPI` for domain and sender identity management
35
+ - Resource class infrastructure (`BaseResource`, `Resource`)
36
+ - Raw HTTP methods (`Client#get`, `#post`, `#put`, `#delete`) for arbitrary endpoint access
37
+ - `ConnectionError` for network-level failures
38
+
39
+ ### Changed
40
+
41
+ - HTTP response body validation before JSON parsing
42
+
43
+ ### Fixed
44
+
45
+ - TypeError leak in webhook header parsing
46
+ - Blank recipient validation
47
+
48
+ [0.2.0]: https://github.com/onetimesecret/lettermint-ruby/compare/v0.1.0...v0.2.0
49
+
8
50
  ## [0.1.0] - 2026-02-18
9
51
 
10
52
  ### 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
+ ```