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('
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('