diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b6304a..3d560a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,27 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.0] - 2026-04-01 + +### Added + +- `TeamAPI` for managing team resources (members, invitations) +- `SendingAPI` for domain and sender identity management +- Resource class infrastructure (`BaseResource`, `Resource`) +- Raw HTTP methods (`Client#get`, `#post`, `#put`, `#delete`) for arbitrary endpoint access +- `ConnectionError` for network-level failures + +### Changed + +- HTTP response body validation before JSON parsing + +### Fixed + +- TypeError leak in webhook header parsing +- Blank recipient validation + +[0.2.0]: https://github.com/onetimesecret/lettermint-ruby/compare/v0.1.0...v0.2.0 + ## [0.1.0] - 2026-02-18 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 8783d39..ba04189 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,8 +46,9 @@ Error │ ├── ClientError (400) │ ├── AuthenticationError (401/403) │ └── RateLimitError (429, has retry_after) -├── ConnectionError (has original_exception) ├── TimeoutError +├── ConnectionError (has original_exception) +├── ResponseParsingError (has original_exception) └── WebhookVerificationError ├── InvalidSignatureError ├── TimestampToleranceError diff --git a/README.md b/README.md index 0a0d232..061a156 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,11 @@ Unofficial Ruby SDK for the [Lettermint](https://lettermint.co) transactional email API. Based on the official [Python SDK](https://github.com/lettermint/lettermint-python). +The SDK provides two API clients: + +- **SendingAPI** (aliased as `Client`) — Project-level email sending with `x-lettermint-token` authentication +- **TeamAPI** — Team-level management (domains, projects, webhooks, etc.) with `lm_team_*` token authentication + ## Installation Add to your Gemfile: @@ -27,7 +32,8 @@ gem install lettermint ```ruby require 'lettermint' -client = Lettermint::Client.new(api_token: 'your-api-token') +# SendingAPI for email sending (aliased as Client for backward compatibility) +client = Lettermint::SendingAPI.new(api_token: 'your-project-token') response = client.email .from('sender@example.com') @@ -40,6 +46,21 @@ response = client.email puts response.message_id ``` +### Team Management + +```ruby +# TeamAPI for team-level operations (requires lm_team_* token) +team_api = Lettermint::TeamAPI.new(team_token: 'lm_team_your-token') + +# List domains +domains = team_api.domains.list +puts domains['data'].map { |d| d['domain'] } + +# Get team info +team = team_api.team.get +puts team['name'] +``` + ## Email Options ### Multiple Recipients @@ -223,12 +244,117 @@ webhooks using the `x-lettermint-delivery` header value as an idempotency key -- example, by recording processed delivery IDs in a database or cache and rejecting duplicates. +## TeamAPI + +The TeamAPI provides access to team-level management operations. It requires a team token (prefixed with `lm_team_`). + +```ruby +team_api = Lettermint::TeamAPI.new(team_token: 'lm_team_your-token') +``` + +### Available Resources + +| Resource | Methods | +|----------|---------| +| `team` | `get`, `update`, `usage`, `members` | +| `domains` | `list`, `create`, `find`, `delete`, `verify_dns`, `verify_dns_record`, `update_projects` | +| `projects` | `list`, `create`, `find`, `update`, `delete`, `rotate_token`, `add_member`, `remove_member`, `update_members`, `routes` | +| `webhooks` | `list`, `create`, `find`, `update`, `delete`, `test`, `deliveries`, `delivery`, `regenerate_secret` | +| `messages` | `list`, `find`, `html`, `text`, `source`, `events` | +| `suppressions` | `list`, `create`, `delete` | +| `routes` | `list`, `create`, `find`, `update`, `delete`, `verify_inbound_domain` | +| `stats` | `get` | + +### Examples + +```ruby +# Domains +team_api.domains.create(domain: 'mail.example.com') +team_api.domains.verify_dns('domain-id') + +# Projects +projects = team_api.projects.list(sort: '-created_at') +project = team_api.projects.create(name: 'My Project') +team_api.projects.rotate_token(project['id']) + +# Webhooks +team_api.webhooks.create( + name: 'My Webhook', + url: 'https://example.com/webhook', + events: ['message.sent', 'message.delivered'] +) + +# Messages (search and retrieve) +messages = team_api.messages.list(status: 'delivered', tag: 'welcome') +html_body = team_api.messages.html('message-id') + +# Suppressions +team_api.suppressions.create( + emails: ['bounce@example.com'], + reason: 'hard_bounce', + scope: 'project', + project_id: 'project-id' +) + +# Stats +stats = team_api.stats.get(from: '2026-01-01', to: '2026-01-31') +``` + +### Pagination + +All list methods support cursor-based pagination: + +```ruby +# First page +result = team_api.domains.list(page_size: 10) + +# Next page +if result['meta']['next_cursor'] + next_page = team_api.domains.list( + page_size: 10, + page_cursor: result['meta']['next_cursor'] + ) +end +``` + +### Sorting and Filtering + +```ruby +# Sort by field (prefix with - for descending) +team_api.projects.list(sort: '-created_at') +team_api.domains.list(sort: 'domain') + +# Filter by field +team_api.messages.list(status: 'delivered', from_email: 'noreply@example.com') +team_api.domains.list(status: 'verified') +``` + +## Raw HTTP Methods + +The SendingAPI provides raw HTTP methods for accessing API endpoints not yet wrapped in typed methods: + +```ruby +client = Lettermint::SendingAPI.new(api_token: 'your-api-token') + +# GET with query params +response = client.get('/some-endpoint', params: { limit: 10 }) + +# POST with JSON body +response = client.post('/some-endpoint', data: { key: 'value' }) + +# PUT +response = client.put('/some-endpoint/123', data: { key: 'new-value' }) + +# DELETE +client.delete('/some-endpoint/123') +``` + ## Error Handling ```ruby require 'lettermint' -client = Lettermint::Client.new(api_token: 'your-api-token') +client = Lettermint::SendingAPI.new(api_token: 'your-api-token') begin response = client.email @@ -252,6 +378,10 @@ rescue Lettermint::ClientError => e rescue Lettermint::TimeoutError => e # Request timeout puts "Timeout: #{e.message}" +rescue Lettermint::ConnectionError => e + # Network-level failures (DNS, connection refused, etc.) + puts "Connection error: #{e.message}" + puts "Original: #{e.original_exception}" if e.original_exception rescue Lettermint::HttpRequestError => e # Other HTTP errors puts "HTTP error #{e.status_code}: #{e.message}" @@ -282,37 +412,16 @@ Set defaults once at application boot (e.g., in a Rails initializer): ```ruby Lettermint.configure do |config| - config.base_url = 'https://custom.api.com/v1' config.timeout = 60 end ``` -All clients created afterward inherit these defaults: - -```ruby -client = Lettermint::Client.new(api_token: 'your-api-token') -# Uses the global base_url and timeout -``` +All clients created afterward inherit these defaults. ### Per-Client Overrides -Explicit keyword arguments take precedence over global configuration: - ```ruby -client = Lettermint::Client.new( - api_token: 'your-api-token', - base_url: 'https://other.api.com/v1', - timeout: 10 -) -``` - -### Block Configuration - -```ruby -client = Lettermint::Client.new(api_token: 'your-api-token') do |config| - config.base_url = 'https://custom.api.com/v1' - config.timeout = 60 -end +client = Lettermint::SendingAPI.new(api_token: 'your-api-token', timeout: 10) ``` ## Requirements diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..12660c1 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,210 @@ +# Lettermint Team API Reference + +`https://api.lettermint.co/v1` · Auth: `Authorization: Bearer {team_token}` + +Pagination: cursor-based via `page[size]` (default 30), `page[cursor]`. Sorting: comma-separated fields, `-` prefix for desc. + +**Conventions:** `?` = nullable/optional, `*` = required, defaults in parens. List variants omit nested includes. `?include=` params noted per endpoint. + +--- + +## Enums + +| Name | Values | +|------|--------| +| AttachmentDelivery | `inline` `url` | +| DnsRecordStatus | `active` `failed` `pending` | +| DomainStatus | `verified` `partially_verified` `pending_verification` `failed_verification` | +| InitialRoutes | `both` `transactional` `broadcast` | +| MessageEventType | `queued` `processed` `suppressed` `delivered` `soft_bounced` `hard_bounced` `spam_complaint` `failed` | +| MessageStatus | `pending` `queued` `suppressed` `processed` `delivered` `opened` `clicked` `soft_bounced` | +| MessageType | `inbound` `outbound` | +| Plan | `free` `starter` `growth` `pro` | +| RecordType | `TXT` `CNAME` `MX` | +| RouteType | `transactional` `broadcast` `inbound` | +| SuppressionReason | `spam_complaint` `hard_bounce` `unsubscribe` `manual` | +| SuppressionScope | `global` `team` `project` `route` | +| SuppressionType | `email` `domain` `extension` | +| VolumeTier | `300` `10k` `50k` `125k` `500k` `750k` `1M` `1.5M` | +| WebhookDeliveryStatus | `pending` `success` `failed` `client_error` `server_error` `timeout` | +| WebhookEvent | `message.{created,sent,delivered,hard_bounced,soft_bounced,spam_complaint,failed,suppressed}` | + +--- + +## Generic + +| | | +|---|---| +| `GET /ping` | Health check. Also accepts `X-Lettermint-Token: {project_token}` | + +--- + +## Team + +| | | +|---|---| +| `GET /team` | `?include=features,featuresCount,featuresExists` | +| `PUT /team` | Body: `{name}` | +| `GET /team/usage` | Current period + up to 12 historical | +| `GET /team/members` | Paginated | + +``` +TeamData { id, name, plan: Plan, tier: VolumeTier, verified_at?, created_at, + features: string[], addons: [{type?, expires_at?}], + domains_count?, projects_count?, members_count? } +TeamMemberData { id, role?, joined_at?, user?: {id, name, email, avatar?} } +UsageDetail { current_period, historical_usage[]: {usage: int, last_incremented_at?, period_start, period_end} } +``` + +--- + +## Domains + +| | | +|---|---| +| `GET /domains` | `filter[status]`, `filter[domain]` · `sort`: domain, created_at, status_changed_at | +| `POST /domains` | Body: `{domain*}` (max 255, regex validated) | +| `GET /domains/{id}` | `?include=dnsRecords,dnsRecordsCount,dnsRecordsExists` | +| `DELETE /domains/{id}` | | +| `POST /domains/{id}/dns-records/verify` | Verify all records | +| `POST /domains/{id}/dns-records/{recordId}/verify` | Verify single | +| `PUT /domains/{id}/projects` | Body: `{project_ids[]}` | + +``` +DomainData { id, domain, status_changed_at?, created_at, dns_records[], projects[] } +DomainListData { id, domain, status: DomainStatus, status_changed_at?, created_at } +DnsRecord { id, type: RecordType, hostname, fqdn, content, + status: DnsRecordStatus, verified_at?, last_checked_at? } +``` + +--- + +## Projects + +| | | +|---|---| +| `GET /projects` | `filter[search]` · `sort`: name, created_at | +| `POST /projects` | Body: `{name*, smtp_enabled? (false), initial_routes? (both)}`. Returns `api_token` | +| `GET /projects/{id}` | `?include=routes,domains,teamMembers,messageStats` (+ Count/Exists) | +| `PUT /projects/{id}` | Body: `{name?, smtp_enabled?, default_route_id?}` | +| `DELETE /projects/{id}` | | +| `POST /projects/{id}/rotate-token` | Returns `new_token` | +| `PUT /projects/{id}/members` | Body: `{team_member_ids[]}` | +| `POST /projects/{id}/members/{memberId}` | Add member | +| `DELETE /projects/{id}/members/{memberId}` | Remove member | + +``` +ProjectData { id, name, smtp_enabled: bool, default_route_id?, + token_generated_at?, token_last_used_at?, token_last_used_ip?, + created_at, updated_at, + routes[], domains[], team_members[], + last_28_days: {messages_transactional, messages_broadcast, messages_inbound: int, deliverability: float} } +ProjectListData { id, name, smtp_enabled, routes_count, domains_count, + team_members_count, last_28_days, created_at, updated_at } +``` + +--- + +## Routes + +| | | +|---|---| +| `GET /projects/{id}/routes` | `filter[route_type]`, `filter[is_default]`, `filter[search]` · `sort`: name, slug, created_at | +| `POST /projects/{id}/routes` | Body: `{name*, route_type*, slug?}` | +| `GET /routes/{id}` | `?include=project,statistics` | +| `PUT /routes/{id}` | Body: `{name?, settings{track_opens, track_clicks, disable_hosted_unsubscribe}?, inbound_settings{inbound_domain, inbound_spam_threshold, attachment_delivery}?}` | +| `DELETE /routes/{id}` | | +| `POST /routes/{id}/verify-inbound-domain` | | + +``` +RouteData { id, project_id, slug, name, route_type: RouteType, is_default: bool, + created_at, updated_at, + inbound_address?, inbound_domain?, inbound_domain_verified_at?, + inbound_spam_threshold?, attachment_delivery?, + project?, webhooks_count?, suppressed_recipients_count?, statistics? } +RouteListData { id, slug, name, route_type, is_default, + webhooks_count, suppressed_recipients_count, created_at, updated_at } +RouteStatistic { date, sent_count, delivered_count, opened_count, clicked_count, + hard_bounce_count, spam_complaint_count, inbound_received_count } +``` + +--- + +## Messages + +| | | +|---|---| +| `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 | +| `GET /messages/{id}` | Full detail | +| `GET /messages/{id}/events` | `sort`: timestamp, event | +| `GET /messages/{id}/source` | Returns `message/rfc822` | +| `GET /messages/{id}/html` | Returns `text/html` | +| `GET /messages/{id}/text` | Returns `text/plain` | + +``` +MessageData { id, type: MessageType, status: MessageStatus, status_changed_at?, + tag?, from_email, from_name?, reply_to?, subject?, + to?, cc?, bcc?: [{email, name?}], + attachments?: [{size (0), filename ("unknown"), content_id?, content_type ("application/octet-stream")}], + metadata?, route_id, created_at, spam_score?, spam_symbols?: [{name, score, options[], description?}] } +MessageListData { id, type, status, from_email, from_name?, subject?, + to?, cc?, bcc?, reply_to?, tag?, created_at } +MessageEvent { message_id, event: MessageEventType, metadata?, timestamp } +``` + +--- + +## Webhooks + +| | | +|---|---| +| `GET /webhooks` | `filter[enabled]`, `filter[event]`, `filter[route_id]`, `filter[search]` · `sort`: name, url, created_at | +| `POST /webhooks` | Body: `{route_id*, name*, url*, events[]*, enabled? (true)}`. Returns `secret` (shown once) | +| `GET /webhooks/{id}` | | +| `PUT /webhooks/{id}` | Body: `{name?, url?, enabled?, events[]?}` | +| `DELETE /webhooks/{id}` | | +| `POST /webhooks/{id}/test` | Returns `delivery_id` | +| `POST /webhooks/{id}/regenerate-secret` | Returns new `secret` | +| `GET /webhooks/{id}/deliveries` | `filter[status]`, `filter[event_type]`, `filter[from_date]`, `filter[to_date]` | +| `GET /webhooks/{id}/deliveries/{deliveryId}` | Full payload/response | + +``` +WebhookData { id, route_id, name, url, events: WebhookEvent[], enabled: bool, + last_called_at?, created_at, updated_at, secret? } +WebhookDelivery { id, webhook_id, event_type: WebhookEvent, status: WebhookDeliveryStatus, + attempt_number, http_status_code?, duration_ms?, + payload[], response_body?, response_headers?, error_message?, + delivered_at?, timestamp } +WebhookDeliveryList { ...sans payload/response_body/response_headers/error_message, + created_at } +``` + +--- + +## Suppressions + +| | | +|---|---| +| `GET /suppressions` | `filter[scope]`, `filter[route_id]`, `filter[project_id]`, `filter[value]`, `filter[reason]` · `sort`: value, created_at, reason | +| `POST /suppressions` | Body: `{reason*, scope*, email?, emails[]? (max 1000), route_id?, project_id?}` | +| `DELETE /suppressions/{id}` | | + +``` +SuppressedRecipient { id, type: SuppressionType, value, reason: SuppressionReason, + scope: SuppressionScope, project_id?, route_id?, created_at, updated_at } +``` + +--- + +## Stats + +| | | +|---|---| +| `GET /stats` | `from*` (Y-m-d), `to*` (Y-m-d, max 90 days span), `project_id?` | + +``` +StatsData { from, to, totals: StatsTotals, daily: StatsDaily[] } +StatsTotals / StatsDaily { sent, delivered, hard_bounced, spam_complaints: int, + opened?, clicked?: int (null if tracking disabled), + inbound: {received: int}, + transactional, broadcast: {sent, hard_bounced, spam_complaints: int} } +``` diff --git a/docs/sdk.md b/docs/sdk.md new file mode 100644 index 0000000..a891ea0 --- /dev/null +++ b/docs/sdk.md @@ -0,0 +1,485 @@ +# Lettermint Ruby SDK Reference + +`gem 'lettermint'` v0.2.0 · Ruby >= 3.2 + +--- + +## Quick Start + +```ruby +require 'lettermint' + +# Sending API (project-level email sending) +client = Lettermint::SendingAPI.new(api_token: 'lm_project_xxx') + +# Team API (team-level management) +team = Lettermint::TeamAPI.new(team_token: 'lm_team_xxx') +``` + +--- + +## Authentication + +| API | Token Format | Header | +|-----|--------------|--------| +| SendingAPI | `lm_project_*` | `x-lettermint-token` | +| TeamAPI | `lm_team_*` | `Authorization: Bearer` | + +```ruby +# Global configuration (optional) +Lettermint.configure do |config| + config.base_url = 'https://api.lettermint.co/v1' + config.timeout = 30 +end + +# Instance-level configuration +client = Lettermint::SendingAPI.new(api_token: 'xxx', base_url: 'https://...', timeout: 60) +``` + +--- + +## Sending Emails + +```ruby +client = Lettermint::SendingAPI.new(api_token: 'lm_project_xxx') + +# Fluent builder API +response = client.email + .from('sender@example.com') + .to('recipient@example.com') + .subject('Hello') + .html('

Hello World

') + .text('Hello World') + .deliver + +response.message_id # => "msg_xxx" +response.status # => "queued" +``` + +### EmailMessage Methods + +| Method | Description | +|--------|-------------| +| `from(email)` | Sender address (required) | +| `to(*emails)` | Recipient(s) (required, accepts array) | +| `subject(str)` | Subject line (required) | +| `html(str)` | HTML body | +| `text(str)` | Plain text body | +| `cc(*emails)` | CC recipients | +| `bcc(*emails)` | BCC recipients | +| `reply_to(*emails)` | Reply-to addresses | +| `route(slug)` | Route slug | +| `tag(str)` | Message tag | +| `headers(hash)` | Custom headers | +| `metadata(hash)` | Custom metadata | +| `attach(filename, content, content_id:)` | Add attachment | +| `idempotency_key(key)` | Idempotency key header | +| `deliver` | Send the email, returns `SendEmailResponse` | + +### Attachments + +```ruby +# Simple attachment +client.email + .from('sender@example.com') + .to('recipient@example.com') + .subject('Report') + .html('

See attached

') + .attach('report.pdf', Base64.strict_encode64(pdf_data)) + .deliver + +# With content ID (for inline images) +client.email + .attach('logo.png', base64_data, content_id: 'logo') + .html('') + ... + +# Using EmailAttachment type +attachment = Lettermint::EmailAttachment.new( + filename: 'doc.pdf', + content: base64_data, + content_id: nil +) +client.email.attach(attachment)... +``` + +--- + +## Team API Resources + +```ruby +team = Lettermint::TeamAPI.new(team_token: 'lm_team_xxx') +``` + +### Generic + +```ruby +team.ping # => { 'ok' => true } +``` + +### Team + +```ruby +# Get team details +team.team.get # => Hash +team.team.get(include: 'features,featuresCount') # with includes + +# Update team +team.team.update(name: 'New Name') + +# Usage statistics +team.team.usage # => { current_period: ..., historical_usage: [...] } + +# List members (paginated) +team.team.members(page_size: 50, page_cursor: 'cursor_xxx') +``` + +### Domains + +```ruby +# List domains +team.domains.list +team.domains.list(status: 'verified', sort: '-created_at', page_size: 10) + +# Create domain +team.domains.create(domain: 'mail.example.com') + +# Get domain +team.domains.find('dom_xxx') +team.domains.find('dom_xxx', include: 'dnsRecords') + +# Delete domain +team.domains.delete('dom_xxx') + +# Verify DNS records +team.domains.verify_dns('dom_xxx') # all records +team.domains.verify_dns_record('dom_xxx', 'rec_xxx') # single record + +# Update associated projects +team.domains.update_projects('dom_xxx', project_ids: ['proj_1', 'proj_2']) +``` + +### Projects + +```ruby +# List projects +team.projects.list +team.projects.list(search: 'api', sort: 'name') + +# Create project +team.projects.create( + name: 'My Project', + smtp_enabled: true, + initial_routes: 'transactional' # both, transactional, broadcast +) +# => { ..., 'api_token' => 'lm_project_xxx' } + +# Get project +team.projects.find('proj_xxx') +team.projects.find('proj_xxx', include: 'routes,domains,messageStats') + +# Update project +team.projects.update('proj_xxx', + name: 'Renamed', + smtp_enabled: false, + default_route_id: 'route_xxx' +) + +# Delete project +team.projects.delete('proj_xxx') + +# Rotate API token +team.projects.rotate_token('proj_xxx') # => { 'new_token' => '...' } + +# Manage members +team.projects.update_members('proj_xxx', team_member_ids: ['mem_1', 'mem_2']) +team.projects.add_member('proj_xxx', 'mem_xxx') +team.projects.remove_member('proj_xxx', 'mem_xxx') +``` + +### Routes + +```ruby +# Project-scoped routes +routes = team.projects.routes('proj_xxx') + +routes.list +routes.list(route_type: 'transactional', is_default: true, sort: '-created_at') + +routes.create( + name: 'Notifications', + route_type: 'transactional', # transactional, broadcast, inbound + slug: 'notifications' +) + +# Direct route access (any route by ID) +team.routes.find('route_xxx') +team.routes.find('route_xxx', include: 'project,statistics') + +team.routes.update('route_xxx', + name: 'Renamed', + settings: { track_opens: true, track_clicks: true }, + inbound_settings: { inbound_domain: 'in.example.com', inbound_spam_threshold: 5 } +) + +team.routes.delete('route_xxx') +team.routes.verify_inbound_domain('route_xxx') +``` + +### Messages + +```ruby +# List messages +team.messages.list +team.messages.list( + type: 'outbound', + status: 'delivered', + route_id: 'route_xxx', + from_date: '2024-01-01', + to_date: '2024-01-31', + sort: '-created_at' +) + +# Get message +team.messages.find('msg_xxx') + +# Message events (delivery history) +team.messages.events('msg_xxx') +team.messages.events('msg_xxx', sort: '-timestamp') + +# Message content +team.messages.source('msg_xxx') # RFC822 format +team.messages.html('msg_xxx') # HTML body +team.messages.text('msg_xxx') # Plain text body +``` + +### Webhooks + +```ruby +# List webhooks +team.webhooks.list +team.webhooks.list(enabled: true, event: 'message.delivered', route_id: 'route_xxx') + +# Create webhook (returns secret once) +result = team.webhooks.create( + route_id: 'route_xxx', + name: 'Delivery notifications', + url: 'https://example.com/webhooks', + events: ['message.delivered', 'message.hard_bounced'], + enabled: true +) +secret = result['secret'] # Store this securely + +# Get webhook +team.webhooks.find('wh_xxx') + +# Update webhook +team.webhooks.update('wh_xxx', name: 'Renamed', enabled: false) + +# Delete webhook +team.webhooks.delete('wh_xxx') + +# Test webhook +team.webhooks.test('wh_xxx') # => { 'delivery_id' => '...' } + +# Regenerate secret +team.webhooks.regenerate_secret('wh_xxx') # => { 'secret' => '...' } + +# Webhook deliveries +team.webhooks.deliveries('wh_xxx') +team.webhooks.deliveries('wh_xxx', status: 'failed', from_date: '2024-01-01') +team.webhooks.delivery('wh_xxx', 'del_xxx') # Full delivery details +``` + +### Suppressions + +```ruby +# List suppressions +team.suppressions.list +team.suppressions.list(scope: 'team', reason: 'hard_bounce') + +# Create suppression +team.suppressions.create( + reason: 'manual', # spam_complaint, hard_bounce, unsubscribe, manual + scope: 'team', # global, team, project, route + email: 'bad@example.com' +) + +# Bulk suppress (up to 1000) +team.suppressions.create( + reason: 'manual', + scope: 'project', + project_id: 'proj_xxx', + emails: ['a@example.com', 'b@example.com'] +) + +# Delete suppression +team.suppressions.delete('sup_xxx') +``` + +### Stats + +```ruby +# Get statistics (max 90 day range) +team.stats.get(from: '2024-01-01', to: '2024-03-31') +team.stats.get(from: '2024-01-01', to: '2024-01-31', project_id: 'proj_xxx') +team.stats.get(from: '2024-01-01', to: '2024-01-31', route_ids: ['r1', 'r2']) + +# Returns: +# { +# 'from' => '2024-01-01', +# 'to' => '2024-01-31', +# 'totals' => { 'sent' => 1000, 'delivered' => 980, ... }, +# 'daily' => [{ 'date' => '2024-01-01', 'sent' => 50, ... }, ...] +# } +``` + +--- + +## Webhook Verification + +Standalone module, no client dependency. + +```ruby +# Instance-based +webhook = Lettermint::Webhook.new(secret: 'whsec_xxx', tolerance: 300) + +# Verify from headers (recommended) +payload = webhook.verify_headers(request.headers, request.raw_body) +# => parsed JSON hash + +# Verify from signature string +payload = webhook.verify(raw_body, signature_header) + +# Class method (one-shot) +payload = Lettermint::Webhook.verify_signature( + raw_body, + signature_header, + secret: 'whsec_xxx', + tolerance: 300 +) +``` + +### Signature Format + +Header: `x-lettermint-signature: t=,v1=` +Timestamp: `x-lettermint-delivery: ` + +--- + +## Error Handling + +```ruby +begin + client.email.from('x').to('y').subject('z').html('...').deliver +rescue Lettermint::ValidationError => e + e.message # Error message + e.status_code # 422 + e.error_type # Validation error type + e.response_body # Raw response +rescue Lettermint::ClientError => e + e.status_code # 400 +rescue Lettermint::AuthenticationError => e + e.status_code # 401 or 403 +rescue Lettermint::RateLimitError => e + e.retry_after # Seconds to wait (may be nil) +rescue Lettermint::TimeoutError + # Request timed out +rescue Lettermint::ConnectionError => e + e.original_exception # Underlying Faraday error +rescue Lettermint::ResponseParsingError => e + e.original_exception # Faraday::ParsingError +rescue Lettermint::HttpRequestError => e + # Catch-all for HTTP errors +rescue Lettermint::Error => e + # Catch-all for SDK errors +end +``` + +### Webhook Errors + +```ruby +begin + webhook.verify_headers(headers, body) +rescue Lettermint::InvalidSignatureError + # HMAC mismatch +rescue Lettermint::TimestampToleranceError + # Timestamp too old or future +rescue Lettermint::WebhookJsonDecodeError => e + e.original_exception # JSON::ParserError +rescue Lettermint::WebhookVerificationError + # Catch-all for webhook errors +end +``` + +--- + +## Error Hierarchy + +``` +Lettermint::Error +├── HttpRequestError (status_code, response_body) +│ ├── ValidationError (422, error_type) +│ ├── ClientError (400) +│ ├── AuthenticationError (401/403) +│ └── RateLimitError (429, retry_after) +├── TimeoutError +├── ConnectionError (original_exception) +├── ResponseParsingError (original_exception) +└── WebhookVerificationError + ├── InvalidSignatureError + ├── TimestampToleranceError + └── WebhookJsonDecodeError (original_exception) +``` + +--- + +## Types + +```ruby +# SendEmailResponse (Data.define, frozen) +response = client.email...deliver +response.message_id # String +response.status # String + +# EmailAttachment (Data.define, frozen) +attachment = Lettermint::EmailAttachment.new( + filename: 'report.pdf', + content: Base64.strict_encode64(data), + content_id: nil # optional +) +attachment.to_h # => { filename: ..., content: ... } +``` + +--- + +## Pagination + +All list methods support cursor-based pagination: + +```ruby +# First page +result = team.domains.list(page_size: 10) +items = result['data'] +cursor = result.dig('meta', 'cursor') + +# Next page +if cursor + result = team.domains.list(page_size: 10, page_cursor: cursor) +end +``` + +--- + +## Raw HTTP Access + +For endpoints not yet wrapped in typed methods: + +```ruby +client = Lettermint::SendingAPI.new(api_token: 'xxx') + +client.get('/some/endpoint', params: { key: 'value' }) +client.post('/some/endpoint', data: { key: 'value' }) +client.put('/some/endpoint', data: { key: 'value' }) +client.delete('/some/endpoint') +``` diff --git a/lib/lettermint/errors.rb b/lib/lettermint/errors.rb index 269f54c..6713a24 100644 --- a/lib/lettermint/errors.rb +++ b/lib/lettermint/errors.rb @@ -54,6 +54,15 @@ def initialize(message:, original_exception: nil) end end + class ResponseParsingError < Error + attr_reader :original_exception + + def initialize(message:, original_exception: nil) + @original_exception = original_exception + super(message) + end + end + class WebhookVerificationError < Error; end class InvalidSignatureError < WebhookVerificationError; end diff --git a/lib/lettermint/http_client.rb b/lib/lettermint/http_client.rb index 5a11a0e..1fcdadf 100644 --- a/lib/lettermint/http_client.rb +++ b/lib/lettermint/http_client.rb @@ -75,7 +75,8 @@ def with_error_handling rescue Faraday::ConnectionFailed => e raise Lettermint::ConnectionError.new(message: e.message, original_exception: e) rescue Faraday::ParsingError => e - raise Lettermint::Error, "Failed to parse API response: #{e.message}" + raise Lettermint::ResponseParsingError.new(message: "API response parsing failed: #{e.message}", + original_exception: e) end def handle_response(response) diff --git a/lib/lettermint/version.rb b/lib/lettermint/version.rb index a8379ef..6d49224 100644 --- a/lib/lettermint/version.rb +++ b/lib/lettermint/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Lettermint - VERSION = '0.1.0' + VERSION = '0.2.0' end diff --git a/spec/lettermint/http_client_spec.rb b/spec/lettermint/http_client_spec.rb index 2b2f398..b1e34be 100644 --- a/spec/lettermint/http_client_spec.rb +++ b/spec/lettermint/http_client_spec.rb @@ -221,11 +221,13 @@ } end - it 'raises Error on JSON parsing failure' do + it 'raises ResponseParsingError on invalid JSON response' do stub_request(:post, "#{base_url}/send").to_raise(Faraday::ParsingError.new('unexpected token')) expect { client.post(path: '/send', data: {}) } - .to raise_error(Lettermint::Error, /unexpected token|parsing/i) + .to raise_error(Lettermint::ResponseParsingError, /API response parsing failed/) { |e| + expect(e.original_exception).to be_a(Faraday::ParsingError) + } end end