diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 48c5c79..11973c1 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node-version: [18, 20, 22, 23, 24] + node-version: [20, 22, 24] steps: - name: Checkout code @@ -37,6 +37,10 @@ jobs: - name: Run type checking run: npm run type-check + # The generator is private, so CI only checks the generated-file headers. + - name: Check generated files + run: npm run generate:check + - name: Run tests run: npm run test:coverage @@ -76,6 +80,10 @@ jobs: exit 1 fi + # Packs the package, installs the tarball and checks ESM, CJS and types. + - name: Smoke-test the package + run: npm run test:smoke + - name: Upload build artifacts uses: actions/upload-artifact@v4 with: diff --git a/README.md b/README.md index bd372f3..4d94011 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,9 @@ ![NPM Downloads](https://img.shields.io/npm/dm/lettermint) [![Join our Discord server](https://img.shields.io/discord/1305510095588819035?logo=discord&logoColor=eee&label=Discord&labelColor=464ce5&color=0D0E28&cacheSeconds=43200)](https://lettermint.co/r/discord) +The official JavaScript and TypeScript SDK for [Lettermint](https://lettermint.co). It runs on Node.js 20+, Bun, Deno and edge runtimes, and has no dependencies. -The official Node.js SDK for [Lettermint](https://lettermint.co). +Upgrading from 2.x? Read [UPGRADE.md](UPGRADE.md). ## Installation @@ -13,189 +14,425 @@ The official Node.js SDK for [Lettermint](https://lettermint.co). npm install lettermint ``` -## Usage +## Quick start -### Sending Emails +Create a client with a project sending token and send an email: -Use a project sending token with `Lettermint.email(...)`. Sending tokens authenticate with the `x-lettermint-token` header. +```ts +import { Lettermint } from 'lettermint'; -```typescript -import { Lettermint } from "lettermint"; +const lettermint = new Lettermint({ sendingToken: process.env.LETTERMINT_PROJECT_TOKEN }); -const email = Lettermint.email(process.env.LETTERMINT_SENDING_TOKEN!); +const result = await lettermint.emails.send({ + from: 'Acme ', + to: ['jane@example.com'], + subject: 'Welcome to Acme', + html: '

Thanks for signing up.

', + text: 'Thanks for signing up.', +}); + +console.log(result.message_id, result.status); // "…", "pending" ``` -The SDK provides a fluent interface for sending emails: +`import Lettermint from 'lettermint'` and `const { Lettermint } = require('lettermint')` work as well. -```typescript -const response = await email - .from('sender@acme.com') - .to('recipient@acme.com') - .subject('Hello from Lettermint') - .text('This is a test email sent using the Lettermint Node.js SDK.') - .send(); +## Tokens + +Lettermint has two kinds of API tokens: + +| Option | Token | Used by | Sent as | +| --- | --- | --- | --- | +| `sendingToken` | Project sending token (`lm_…`) | `lettermint.emails.*` | `x-lettermint-token` header | +| `teamToken` | Team API token (`lm_team_…`) | Every other part (domains, messages, projects, …) | `Authorization: Bearer` header | + +Pass one or both: + +```ts +const lettermint = new Lettermint({ + sendingToken: process.env.LETTERMINT_PROJECT_TOKEN, + teamToken: process.env.LETTERMINT_TEAM_TOKEN, +}); +``` + +Each part uses its own token and never falls back to the other one. If the token a method needs is missing, it throws a `LettermintConfigError` that names the option (`domains.list needs `teamToken``), before any request. `lettermint.ping()` uses the team token when it is set, otherwise the sending token. `messages.reschedule()` and `messages.cancel()` accept either token in the same way. + +You can also pass a single token as a string. The SDK chooses its type by the format: `lm_team_` followed by letters and digits is a team token, and `lm_` followed by letters and digits is a sending token. Any other value, such as an SSO verification token (`lm_sso_…`), throws a `LettermintConfigError`; pass `{ sendingToken }` or `{ teamToken }` explicitly in that case. + +```ts +const lettermint = new Lettermint(process.env.LETTERMINT_TOKEN!); +// Other options go in the second argument: +const withTimeout = new Lettermint(process.env.LETTERMINT_TOKEN!, { timeout: 10_000 }); +``` + +Other formats throw `LettermintConfigError`. Use `{ sendingToken }` or `{ teamToken }` for them. Error messages never contain the token. + +### Options + +| Option | Default | Description | +| --- | --- | --- | +| `sendingToken` | | Project sending token. | +| `teamToken` | | Team API token. | +| `baseUrl` | `https://api.lettermint.co/v1` | API base URL. | +| `timeout` | `30000` | Request timeout in milliseconds. It covers the response headers and the body. | +| `fetch` | global `fetch` | A `fetch` implementation, for example for tests or a proxy. | + +The client holds no per-request state, so create it once and share it. + +## Sending email + +### The email builder + +`emails.compose()` returns an immutable builder. Every setter returns a new builder and leaves the original unchanged, so you can keep a base builder and reuse it, also across concurrent requests: + +```ts +const welcome = lettermint.emails + .compose() + .from('Acme ') + .subject('Welcome to Acme') + .tags([{ name: 'campaign', value: 'welcome' }]); + +await welcome.to('jane@example.com').html('

Hi Jane

').send(); +await welcome.to('john@example.com').html('

Hi John

').send(); +``` + +When you build an email over several statements, keep the returned builder: -console.log(`Email sent with ID: ${response.message_id}`); -console.log(`Status: ${response.status}`); -``` - -#### Advanced Email Options - -```typescript -const response = await email - .from('John Doe ') - .to('recipient1@acme.com', 'recipient2@acme.com') - .cc('cc@acme.com') - .bcc('bcc@acme.com') - .replyTo('reply@acme.com') - .subject('Hello from Lettermint') - .html('

Hello

This is an HTML email.

') - .text('This is a plain text version of the email.') - .headers({ - 'X-Custom-Header': 'Custom Value', - }) - .attach('attachment.txt', Buffer.from('Hello World').toString('base64')) - .attach('logo.png', Buffer.from('...').toString('base64'), 'logo') // Inline attachment - .idempotencyKey('unique-id-123') - .metadata({ - foo: 'bar', - }) - .tag('campaign-123') - .tags([ - { name: 'campaign', value: 'welcome' }, - { name: 'customer', value: 'new' }, - ]) +```ts +let email = lettermint.emails.compose().from('hello@acme.com').to(user.email).subject('Your invoice'); +if (user.accountant) email = email.cc(user.accountant); +await email.html(invoiceHtml).send(); +``` + +Builder methods: + +| Method | Description | +| --- | --- | +| `from(address)` | Sender, for example `Acme `. | +| `to(...addresses)`, `cc(...)`, `bcc(...)`, `replyTo(...)` | Replace the recipient list. | +| `subject(text)` | Subject line. | +| `html(html \| null)`, `text(text \| null)` | Bodies. `null` removes one. | +| `headers(record)` | Custom email headers. | +| `metadata(record)` | Data stored with the message, not added as headers. | +| `tags([{ name, value }])`, `tag(name \| null)` | Name/value tags, and the legacy single tag. | +| `route(slug)` | The route to send through. | +| `scheduledAt(when \| null)` | Delivery time: a `Date`, ISO 8601, or English such as `tomorrow 9am`. | +| `settings({ track_opens, track_clicks, tls })` | Per-email settings that override the route. | +| `sandboxResult(result)` | The result a Sandbox project simulates. | +| `attach({ filename, content, contentType?, contentId? })` | Adds an attachment. | +| `send(options?)` | Sends a snapshot of the email. The builder can be sent again. | +| `build()` | Returns the message in API format. | + +`emails.compose(message)` starts a builder from an existing message. + +### Plain objects + +`emails.send()` takes the message in the API's format (`reply_to`, `scheduled_at`, `sandbox_result`, …). It is typed as `EmailMessage`: + +```ts +await lettermint.emails.send({ + from: 'Acme ', + to: ['jane@example.com'], + reply_to: ['support@acme.com'], + subject: 'Your order has shipped', + html, + metadata: { order_id: '1234' }, +}); +``` + +### Batch sending + +Send up to 500 emails in one request. The array may mix messages and builders: + +```ts +const results = await lettermint.emails.sendBatch([ + { from: 'hello@acme.com', to: ['jane@example.com'], subject: 'Hi Jane', text: 'Hello' }, + welcome.to('john@example.com').html('

Hi John

'), +]); +``` + +### Idempotency + +Pass an idempotency key to make retries safe. The API processes a key once, so a retry with the same key does not send the email again. The key applies only to the call it is passed to. + +```ts +await lettermint.emails.send(message, { idempotencyKey: `order-${order.id}-confirmation` }); +await builder.send({ idempotencyKey: 'welcome-jane' }); +await lettermint.emails.sendBatch(messages, { idempotencyKey: 'newsletter-2026-10' }); +``` + +The SDK never retries on its own. + +### Scheduling + +```ts +const result = await lettermint.emails + .compose() + .from('hello@acme.com') + .to('jane@example.com') + .subject('Your trial ends tomorrow') + .text('…') + .scheduledAt(new Date(Date.now() + 24 * 60 * 60 * 1000)) .send(); + +if (result.status === 'scheduled') console.log(result.scheduled_at); + +await lettermint.messages.reschedule(result.message_id, { scheduled_at: '2026-10-20T09:00:00Z' }); +await lettermint.messages.cancel(result.message_id); ``` -`tag()` remains available for the legacy single tag. `tags()` accepts up to 20 -case-sensitive name/value tags, or 19 when `tag()` is also set. +### Sandbox -The legacy constructor still works for sending-only usage: +In a Sandbox project, nothing is delivered. Choose the simulated result per email: -```typescript -const lettermint = new Lettermint({ apiToken: 'your-sending-token' }); -await lettermint.email.from('sender@acme.com').to('recipient@acme.com').subject('Hello').send(); +```ts +const result = await lettermint.emails.compose() + .from('hello@acme.com') + .to('jane@example.com') + .subject('Test') + .text('Test') + .sandboxResult('hard_bounced') + .send(); + +console.log(result.sandbox, result.sandbox_result); // true, "hard_bounced" ``` -### Batch Sending +### Tags + +`tags()` accepts up to 20 case-sensitive name/value tags (19 when the legacy `tag()` is also set). Names match `^[A-Za-z0-9_-]{1,32}$`, may not start with `__lettermint` and must be unique. Values match `^[A-Za-z0-9_-]{1,64}$`. The SDK checks this before the request and throws `LettermintValidationError`. Because builders are immutable, a rejected tag leaves the builder unchanged. -```typescript -const response = await email.sendBatch([ - { - from: 'sender@acme.com', - to: ['recipient@acme.com'], - subject: 'Hello from Lettermint', - text: 'This is a batch email.', - }, +```ts +const email = base.tags([ + { name: 'campaign', value: 'welcome' }, + { name: 'plan', value: 'pro' }, ]); ``` -### Team API +### Attachments -Use a team API token with `Lettermint.api(...)`. API tokens authenticate with `Authorization: Bearer ...` and are separate from project sending tokens. +`content` is a base64 string, a `Uint8Array` (including a Node.js `Buffer`) or an `ArrayBuffer`. The SDK encodes bytes to base64. -```typescript -const api = Lettermint.api(process.env.LETTERMINT_API_TOKEN!); +```ts +import { readFile } from 'node:fs/promises'; -const domains = await api.domains.list({ 'page[size]': '10' }); -const project = await api.projects.create({ - name: 'Production', - smtp_enabled: true, -}); -const stats = await api.stats.retrieve({ - start: '2026-01-01', - end: '2026-01-31', +await lettermint.emails + .compose() + .from('billing@acme.com') + .to('jane@example.com') + .subject('Your invoice') + .html(' Your invoice is attached.') + .attach({ filename: 'invoice.pdf', content: await readFile('invoice.pdf'), contentType: 'application/pdf' }) + .attach({ filename: 'logo.png', content: logoBase64, contentId: 'logo' }) + .send(); +``` + +`lettermint.blockedFileTypes()` lists the extensions and MIME types the API rejects. + +## Team API + +With a team token, the client manages domains, messages, projects, routes, statistics, suppressions, the team and webhooks: + +```ts +const lettermint = new Lettermint({ teamToken: process.env.LETTERMINT_TEAM_TOKEN }); + +const domain = await lettermint.domains.create({ domain: 'acme.com' }); +await lettermint.domains.verifyDnsRecords(domain.id); + +const project = await lettermint.projects.create({ name: 'Production' }); +console.log(project.api_token); // the new project's sending token, shown once + +const stats = await lettermint.stats.retrieve({ from: '2026-10-01', to: '2026-10-31' }); +const html = await lettermint.messages.html('message-id'); +``` + +| Property | Methods | +| --- | --- | +| `domains` | `list`, `iterate`, `create`, `retrieve`, `delete`, `verifyDnsRecords`, `verifyDnsRecord`, `updateProjects` | +| `messages` | `list`, `iterate`, `retrieve`, `events`, `iterateEvents`, `source`, `html`, `text`, `reschedule`, `cancel`, `process` | +| `projects` | `list`, `iterate`, `create`, `retrieve`, `update`, `delete`, `rotateToken` | +| `projects.reportForwarding` | `retrieve`, `update`, `delete`, `verify`, `resendCode` | +| `routes` | `list(projectId)`, `iterate(projectId)`, `create(projectId, …)`, `retrieve`, `update`, `delete`, `verifyInboundDomain` | +| `stats` | `retrieve` | +| `suppressions` | `list`, `iterate`, `create`, `delete` | +| `team` | `retrieve`, `update`, `usage`, `roles` | +| `team.members` | `list`, `iterate`, `retrieve`, `updateAssignment` | +| `webhooks` | `list`, `iterate`, `create`, `retrieve`, `update`, `delete`, `test`, `regenerateSecret` | +| `webhooks.deliveries` | `list(webhookId)`, `iterate(webhookId)`, `retrieve(webhookId, deliveryId)` | +| (root) | `ping`, `analytics`, `blockedFileTypes` | + +### Query parameters and pagination + +Query parameters are typed objects. The SDK sends them in the API's bracket syntax (`page[size]=30&filter[status]=verified&sort=-created_at`): + +```ts +const page = await lettermint.domains.list({ + page: { size: 30 }, + filter: { status: 'verified' }, + sort: ['-created_at'], }); -const messageHtml = await api.messages.html('message-id'); + +console.log(page.data.length, page.next_cursor); +const next = await lettermint.domains.list({ page: { size: 30, cursor: page.next_cursor! } }); ``` -Both API surfaces support `ping()`: +Every list has an `iterate()` method, an async generator that follows `next_cursor` until the last page: + +```ts +for await (const message of lettermint.messages.iterate({ filter: { status: 'hard_bounced' } })) { + console.log(message.id, message.subject); +} -```typescript -await email.ping(); -await api.ping(); +for await (const delivery of lettermint.webhooks.deliveries.iterate(webhookId)) { + // … +} ``` -### Webhook Verification +Stop early with `break`. The SDK requests the next page only when you get to it. -Use `Webhook` to verify incoming webhooks. Use the webhook signing secret, not an API token. -Pass the raw request body as a string or `Buffer`. Do not parse or change the body before verification. +### Cancellation and timeouts -```typescript -import { Webhook, WebhookVerificationError } from 'lettermint'; +Every method takes an `options` argument last, with `signal` (an `AbortSignal`) and `timeout` (milliseconds, overrides the client's timeout): -const webhook = new Webhook(process.env.LETTERMINT_WEBHOOK_SECRET!); +```ts +const controller = new AbortController(); +const page = lettermint.messages.list(undefined, { signal: controller.signal, timeout: 5_000 }); +controller.abort(); // `page` rejects with the signal's reason +``` + +## Errors + +Every error the SDK throws extends `LettermintError`: + +| Class | When | Properties | +| --- | --- | --- | +| `ApiError` | Any 4xx or 5xx JSON (or empty) response | `status`, `code`, `message`, `details`, `body` | +| `AuthenticationError` | 401 | | +| `PermissionError` | 403 | | +| `NotFoundError` | 404 | | +| `ConflictError` | 409 | | +| `ValidationError` | 422 | `errors` (field errors) | +| `RateLimitError` | 429 | `retryAfter` (seconds) | +| `ServerError` | 5xx | | +| `TimeoutError` | No complete response within the timeout | `timeout` | +| `ConnectionError` | The request failed (DNS, TLS, refused, reset) | `cause` | +| `UnexpectedResponseError` | An empty or non-JSON body where JSON was expected, or an error page such as a proxy's HTML 502 | `status`, `bodyExcerpt` | +| `RedirectError` | A 3xx response. Redirects are never followed, so tokens never go elsewhere. | `status` | +| `LettermintConfigError` | A missing or unrecognised token, an invalid option or ID | | +| `LettermintValidationError` | The SDK rejected the request before sending it, such as invalid tags | `field` | +| `WebhookVerificationError` | A webhook delivery is not genuine | `reason` | + +The `ApiError` subclasses extend `ApiError`. `code` and `message` come from the API's error body (`{ error: { code, message, details } }` or `{ message, errors }`). + +```ts +import { ApiError, RateLimitError, TimeoutError, ValidationError } from 'lettermint'; try { - const payload = webhook.verifyHeaders(request.headers, rawBody); - // Process the verified payload here. + await lettermint.emails.send(message, { idempotencyKey }); } catch (error) { - if (error instanceof WebhookVerificationError) { - // Reject the request. Do not process its payload. + if (error instanceof ValidationError) { + console.error(error.message, error.errors); + } else if (error instanceof RateLimitError) { + await sleep((error.retryAfter ?? 1) * 1000); // then retry with the same idempotencyKey + } else if (error instanceof TimeoutError) { + // The outcome is unknown. Retry with the same idempotencyKey. + } else if (error instanceof ApiError) { + console.error(error.status, error.code, error.message); } else { throw error; } } ``` -`verifyHeaders(headers, rawBody)` accepts Node.js request headers. It requires -`X-Lettermint-Signature` and `X-Lettermint-Delivery`. Header names are case-insensitive. -The delivery timestamp must match the timestamp in the signature. +Errors never contain request headers or tokens, and `console.log(lettermint)` shows tokens as `[redacted]`. -You can also call `webhook.verify(rawBody, signatureHeader, deliveryTimestamp?)` directly. -Both methods check HMAC-SHA256 signatures with a constant-time comparison and return -the decoded JSON as `unknown`. Check the payload structure before use. +## Webhooks -The default timestamp tolerance is 300 seconds in either direction. To change it, -use `new Webhook(secret, { tolerance: 60 })`. The tolerance must be a non-negative -integer in seconds. A value of `0` only accepts the current second; it does not -disable the timestamp check. A valid signature does not prevent repeat delivery -within this period. Track processed events if you must prevent duplicate work. +Verify each webhook delivery before you trust it. Use the webhook's signing secret (`whsec_…`), not an API token, and pass the **raw** request body: the signature covers the exact bytes, so parsing and re-serializing the JSON breaks it. -## API Reference +```ts +import { Webhook, WebhookVerificationError } from 'lettermint'; + +const webhook = new Webhook(process.env.LETTERMINT_WEBHOOK_SECRET!); -### Lettermint Class +const event = await webhook.verify(rawBody, headers); +console.log(event.event, event.data); +``` -The main entry points for the SDK. +`verify(rawBody, headers)` takes the body as a `string`, `Uint8Array` (including `Buffer`) or `ArrayBuffer`, and the headers as a Fetch `Headers` object or a Node.js header record. It requires `X-Lettermint-Signature` and `X-Lettermint-Delivery` (header names are case-insensitive), checks the HMAC-SHA256 signature with Web Crypto and a constant-time comparison, checks that the delivery timestamp equals the signed one and is within the tolerance, and returns the parsed payload. Otherwise it throws `WebhookVerificationError` with a `reason`. -```typescript -const email = Lettermint.email('your-sending-token', { - baseUrl: 'https://api.lettermint.co/v1', - timeout: 30000, -}); +### Express -const api = Lettermint.api('your-api-token', { - baseUrl: 'https://api.lettermint.co/v1', - timeout: 30000, +```ts +import express from 'express'; +import { Webhook, WebhookVerificationError } from 'lettermint'; + +const app = express(); +const webhook = new Webhook(process.env.LETTERMINT_WEBHOOK_SECRET!); + +// express.raw keeps the body as a Buffer. Do not use express.json() on this route. +app.post('/webhooks/lettermint', express.raw({ type: 'application/json' }), async (req, res) => { + try { + const event = await webhook.verify(req.body, req.headers); + // Handle event.event and event.data here. + res.sendStatus(204); + } catch (error) { + if (error instanceof WebhookVerificationError) return res.sendStatus(400); + throw error; + } }); ``` -### Email Endpoint +### Next.js, Cloudflare Workers and other Fetch runtimes + +```ts +// app/api/webhooks/lettermint/route.ts +import { Webhook, WebhookVerificationError } from 'lettermint'; -Methods for sending emails: +const webhook = new Webhook(process.env.LETTERMINT_WEBHOOK_SECRET!); -- `from(email: string)`: Set the sender email address -- `to(...emails: string[])`: Set one or more recipient email addresses -- `subject(subject: string)`: Set the email subject -- `html(html: string | null)`: Set the HTML body of the email -- `text(text: string | null)`: Set the plain text body of the email -- `cc(...emails: string[])`: Set one or more CC email addresses -- `bcc(...emails: string[])`: Set one or more BCC email addresses -- `replyTo(...emails: string[])`: Set one or more Reply-To email addresses -- `headers(headers: Record)`: Set custom headers for the email -- `attach(filename: string, base64Content: string, content_id?: string)`: Attach a file to the email. Optional `content_id` for inline attachments. -- `route(route: string)`: Set the routing key for the email -- `idempotencyKey(key: string)`: Set an idempotency key to prevent duplicate email sends -- `metadata(metadata: Record)`: Set metadata for the email -- `tag(tag: string)`: Set a tag for the email -- `send()`: Send the email and return a promise with the response -- `sendBatch(payload)`: Send multiple email payloads in one request -- `ping()`: Ping the Sending API and return the raw response body +export async function POST(request: Request) { + const rawBody = await request.text(); + try { + const event = await webhook.verify(rawBody, request.headers); + // Handle event.event and event.data here. + return new Response(null, { status: 204 }); + } catch (error) { + if (error instanceof WebhookVerificationError) return new Response('Invalid signature', { status: 400 }); + throw error; + } +} +``` -### API Endpoint Groups +### Options and lower-level verification + +The default tolerance is 300 seconds in either direction. Change it with `new Webhook(secret, { tolerance: 60 })`. `0` accepts only the current second; it does not disable the check. A valid signature does not prevent a repeated delivery within the tolerance, so track `event.id` if you must not process an event twice. + +If the headers are not at hand, call `webhook.verifySignature(rawBody, signatureHeader, deliveryHeader?)`. + +The payload is typed as `WebhookPayload`, with `event` as a `WebhookEvent` (`'message.delivered'`, `'message.hard_bounced'`, …). Unknown event names pass through as strings. Pass a type argument for `data`: `webhook.verify<{ message_id: string }>(rawBody, headers)`. + +## TypeScript + +Request and response types are generated from the Lettermint API specification and exported by name, for example `SendMailRequest`, `SendMailResponse`, `DomainData` and `ListDomainsResponse` (a `CursorPage`). Enums are open (`'pending' | 'delivered' | … | (string & {})`), so values that the API adds later still type-check; give `switch` statements a `default` branch. The generated `ApiError` and `ValidationError` body types are exported as `ApiErrorBody` and `ValidationErrorBody`. + +## Runtime support + +- Node.js 20, 22 and 24 (tested in CI), Bun, Deno, Cloudflare Workers, Vercel Edge and other runtimes with `fetch` and Web Crypto. +- No dependencies and no Node.js-only APIs (`Buffer`, `node:*` modules, `process`). Pass `fetch` if your runtime has no global one. +- ESM and CommonJS builds with type declarations, not minified. +- The `User-Agent` header (`lettermint-node/`) is set everywhere except in browsers. Do not use API tokens in browser code. + +## Development + +```bash +npm ci +npm run lint +npm run type-check +npm test +npm run build +npm run test:smoke # packs the package and checks ESM, CJS and types from the tarball +``` -The full API client exposes `domains`, `messages`, `projects`, `routes`, `stats`, `suppressions`, `team`, and `webhooks`. Request and response types are exported from the package. +`src/generated/` is generated by the private [SDK generator](https://github.com/lettermint/sdk-generator). Do not edit it by hand. With a checkout of the generator, `npm run generate` regenerates the files and `npm run generate:check` verifies them; set `LETTERMINT_SDK_GENERATOR` to the checkout (default `../sdk-generator`). Without the generator, as in CI, `generate:check` only verifies the generated headers. ## License diff --git a/UPGRADE.md b/UPGRADE.md index 2d123dc..bac7f6c 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,4 +1,457 @@ -# Upgrade To v2 +# Upgrade guide + +- [Upgrade from 2.x to 3.0](#upgrade-from-2x-to-30) +- [Upgrade from 1.x to 2.0](#upgrade-from-1x-to-20) + +# Upgrade from 2.x to 3.0 + +2.x no longer receives updates, including fixes. Upgrade to 3.0 to keep getting them. + +3.0 is a new major version. The main reason is safety: in 2.x, `Lettermint.email(token)` returned one mutable builder per client. Two emails composed at the same time on one client could mix recipients, content and `Idempotency-Key`, and an email abandoned halfway (for example because `tags()` threw) leaked into the next send. 3.0 stores nothing about a message on the client. + +## Highlights + +- One client: `new Lettermint({ sendingToken, teamToken })`, or `new Lettermint(token)`. It replaces `Lettermint.email()` and `Lettermint.api()`. +- Sending is stateless: `emails.send(message, options)`, `emails.sendBatch(messages, options)` and an immutable `emails.compose()` builder. The `Idempotency-Key` is a per-call option. +- Each part uses its own token: `emails.*` uses the sending token, the Team API uses the team token. The SDK never falls back to the other token. +- Typed errors for every outcome, including empty or HTML responses, redirects, timeouts and network failures. +- Redirects are never followed, so tokens are never sent to another host. +- Tokens never appear in `console.log`, `util.inspect` or `JSON.stringify` output. +- Webhook verification is `async` (Web Crypto) and requires both signature headers. +- Typed query objects (`{ page: { size: 30 } }`) and `iterate()` helpers that follow `next_cursor`. +- Types are generated from the current API specification and use its names (see [Type names](#type-names)). +- Runs on Node.js 20+, Bun, Deno and edge runtimes. Node.js 18 is no longer supported. + +## Requirements + +- Node.js 20 or later (or Bun, Deno, Cloudflare Workers, Vercel Edge). +- TypeScript users: the type declarations need `lib: ["dom"]` or `@types/node` 20+ for `fetch`, `Headers` and `AbortSignal`. + +## Upgrade with a coding agent + +You can let a coding agent (Claude Code, Codex, Cursor, Copilot, …) do the upgrade. Copy this instruction into the agent from your project's root, then review its changes: + +````text +Upgrade this project from the `lettermint` Node.js SDK 2.x to 3.0. + +1. Install `lettermint@^3.0.0` with the project's package manager. 3.0 needs Node.js 20 or newer: check `engines`, CI workflows and Dockerfiles, and report anything older. +2. Read the upgrade guide before changing code: `node_modules/lettermint/UPGRADE.md`, or https://github.com/lettermint/lettermint-node/blob/main/UPGRADE.md. Treat it as the source of truth and don't guess APIs; when unsure, read the type declarations in `node_modules/lettermint/dist/index.d.ts`. +3. Find every use of the SDK: imports or requires of `lettermint`, `Lettermint.email(`, `Lettermint.api(`, `new Lettermint(`, `.sendBatch(`, `.idempotencyKey(`, `.attach(`, `Webhook`, `verifyHeaders(`, the 2.x error classes, and the 2.x type names from the guide's type-name table. +4. Rewrite each use following the guide's before/after examples: + - Create one client with `new Lettermint({ sendingToken })`, adding `teamToken` only where the Team API is used. Keep the project's existing environment variable names. + - Replace fluent builder chains with `lettermint.emails.send({ ... })`, or with `lettermint.emails.compose()` per email. Never keep a builder in module scope or share it between emails. + - Move idempotency keys into the `send()` / `sendBatch()` options. Attachments become objects: `{ filename, content, contentType?, contentId? }`. + - Team API: use the same client, typed query objects instead of `'page[size]'`-style strings, and the renamed methods from the guide. + - Errors: switch to the 3.0 classes (`ApiError`, `ValidationError`, `RateLimitError`, …) and check them with `instanceof`. + - Webhooks: `await new Webhook(secret).verify(rawBody, headers)`. Keep passing the raw request body, keep the secret's `whsec_` prefix, and make sure the `X-Lettermint-Signature` and `X-Lettermint-Delivery` headers reach the handler. + - Rename types using the guide's type-name table. +5. Run the type checker, linter and tests, and fix every error. Don't send real email or call the live API while testing. +6. Finish with a summary: the files you changed, anything you could not migrate with certainty, and behaviour changes I should review. + +Never print, log or commit API tokens or webhook secrets. +```` + +## Create the client + +`Lettermint.email()`, `Lettermint.api()`, `ApiClient`, `LettermintClient` and the `apiToken` option are removed. + +```ts +// 2.x +import { Lettermint } from 'lettermint'; +const email = Lettermint.email(process.env.LETTERMINT_PROJECT_TOKEN!, { timeout: 10_000 }); +const api = Lettermint.api(process.env.LETTERMINT_API_TOKEN!); +const legacy = new Lettermint({ apiToken: process.env.LETTERMINT_PROJECT_TOKEN! }); + +// 3.0 +import { Lettermint } from 'lettermint'; // or: import Lettermint from 'lettermint' +const lettermint = new Lettermint({ + sendingToken: process.env.LETTERMINT_PROJECT_TOKEN, // for lettermint.emails.* + teamToken: process.env.LETTERMINT_TEAM_TOKEN, // for the Team API + timeout: 10_000, +}); +``` + +Pass one token or both. With only one token, calling a part that needs the other throws `LettermintConfigError` (for example `domains.list needs `teamToken``) before any request. + +You can also pass a token string. The SDK chooses the token type by its prefix: + +```ts +const lettermint = new Lettermint('lm_team_...'); // team token +const lettermint = new Lettermint('lm_...'); // project sending token +const lettermint = new Lettermint(token, { timeout: 10_000 }); // with options +``` + +Any other format (SSO tokens, OAuth tokens, an empty string) throws `LettermintConfigError`. Use `{ sendingToken }` or `{ teamToken }` for those. + +The `baseUrl` and `timeout` options work as before. A `fetch` option is new. + +## Send an email + +The builder lived on the client and was reset after each send. In 3.0, `emails.compose()` returns an immutable builder: each setter returns a new builder and leaves the old one unchanged. Chaining works as before. If you built an email over several statements, assign the result of each setter. + +```ts +// 2.x +const email = Lettermint.email(token); +await email + .from('Acme ') + .to('jane@example.com') + .subject('Welcome') + .html('

Hi Jane

') + .idempotencyKey('welcome-jane') + .send(); + +// 3.0: builder +await lettermint.emails + .compose() + .from('Acme ') + .to('jane@example.com') + .subject('Welcome') + .html('

Hi Jane

') + .send({ idempotencyKey: 'welcome-jane' }); + +// 3.0: plain object (API field names) +await lettermint.emails.send( + { from: 'Acme ', to: ['jane@example.com'], subject: 'Welcome', html: '

Hi Jane

' }, + { idempotencyKey: 'welcome-jane' } +); +``` + +```ts +// 2.x: statements mutated the shared builder +email.from('hello@acme.com'); +email.to('jane@example.com'); +if (copy) email.cc('team@acme.com'); +await email.subject('Hi').send(); + +// 3.0: keep the returned builder +let draft = lettermint.emails.compose().from('hello@acme.com').to('jane@example.com'); +if (copy) draft = draft.cc('team@acme.com'); +await draft.subject('Hi').send(); +``` + +A base builder can now be shared safely: + +```ts +const welcome = lettermint.emails.compose().from('Acme ').subject('Welcome'); +await welcome.to('jane@example.com').html(janeHtml).send(); +await welcome.to('john@example.com').html(johnHtml).send({ idempotencyKey: 'welcome-john' }); +``` + +### Changed builder methods + +| 2.x | 3.0 | +| --- | --- | +| `email.from(x)` and the other setters change the client's builder and return it | Return a new builder: `compose().from(x)` | +| `.idempotencyKey(key).send()` | `.send({ idempotencyKey: key })` | +| `.attach(filename, base64, contentId?, contentType?)` | `.attach({ filename, content, contentType?, contentId? })`; `content` may also be a `Uint8Array` or `ArrayBuffer` | +| `.html(null)`, `.text(null)` were ignored | `null` removes the field; so does `tag(null)` and `scheduledAt(null)` | +| `.scheduledAt(string)` | `.scheduledAt(string \| Date)` | +| `.tags()` / `.tag()` threw `TypeError` | Throw `LettermintValidationError` (with `field`) | +| `email.send()` | `builder.send(options?)`; the builder can be sent again | +| — | `builder.build()` returns the message in API format | + +Unchanged setters: `to`, `cc`, `bcc`, `replyTo` (each replaces its list), `subject`, `headers`, `metadata`, `route`, `settings`, `tag`, `tags`, `sandboxResult`. + +Attachment content is base64-encoded by the SDK when you pass bytes, so `Buffer.from(...).toString('base64')` is no longer needed: + +```ts +// 2.x +email.attach('invoice.pdf', pdfBuffer.toString('base64'), undefined, 'application/pdf'); +email.attach('logo.png', logoBase64, 'logo'); + +// 3.0 +builder + .attach({ filename: 'invoice.pdf', content: pdfBytes, contentType: 'application/pdf' }) + .attach({ filename: 'logo.png', content: logoBase64, contentId: 'logo' }); +``` + +## Batch sending and ping + +```ts +// 2.x +await Lettermint.email(token).idempotencyKey('batch-1').sendBatch([message1, message2]); +await Lettermint.email(token).ping(); +await Lettermint.api(token).ping(); + +// 3.0 +await lettermint.emails.sendBatch([message1, message2], { idempotencyKey: 'batch-1' }); +await lettermint.emails.sendBatch([builder1, builder2]); // builders work too +await lettermint.emails.ping(); // sending token +await lettermint.ping(); // team token if configured, otherwise the sending token +``` + +## Team API + +The sub-clients move from `Lettermint.api(token).x` to `lettermint.x`. Query parameters are typed nested objects instead of `Record` with bracket keys. Every list also has an `iterate()` method that follows `next_cursor`. + +```ts +// 2.x +const api = Lettermint.api(token); +const page = await api.domains.list({ 'page[size]': '10', 'filter[status]': 'verified' }); + +// 3.0 +const page = await lettermint.domains.list({ page: { size: 10 }, filter: { status: 'verified' } }); +for await (const domain of lettermint.domains.iterate({ filter: { status: 'verified' } })) { + console.log(domain.domain); +} +``` + +| 2.x (`api = Lettermint.api(token)`) | 3.0 (`lettermint = new Lettermint({ teamToken })`) | +| --- | --- | +| `api.ping()` | `lettermint.ping()` | +| `api.blockedFileTypes()` | `lettermint.blockedFileTypes()` | +| `api.analytics(payload)` | `lettermint.analytics(query)` | +| `api.domains.list(params)` | `lettermint.domains.list(query)`, `lettermint.domains.iterate(query)` | +| `api.domains.create(payload)` | `lettermint.domains.create(payload)` | +| `api.domains.retrieve(id)` | `lettermint.domains.retrieve(id, query?)` (`{ include: ['dnsRecords'] }`) | +| `api.domains.delete(id)` | `lettermint.domains.delete(id)` | +| `api.domains.verifyDnsRecords(id)` | `lettermint.domains.verifyDnsRecords(id)` | +| `api.domains.verifyDnsRecord(id, recordId)` | `lettermint.domains.verifyDnsRecord(id, recordId)` | +| `api.domains.updateProjects(id, payload)` | `lettermint.domains.updateProjects(id, payload)` | +| `api.messages.list(params)` | `lettermint.messages.list(query)`, `lettermint.messages.iterate(query)` | +| `api.messages.retrieve(id)` | `lettermint.messages.retrieve(id)` | +| `api.messages.events(id)` | `lettermint.messages.events(id, query?)`, `lettermint.messages.iterateEvents(id, query?)` | +| `api.messages.source(id)` / `.html(id)` / `.text(id)` | unchanged, on `lettermint.messages` | +| `api.messages.reschedule(id, payload)` | `lettermint.messages.reschedule(id, payload)` | +| `api.messages.cancel(id)` | `lettermint.messages.cancel(id)` | +| `api.messages.process(id)` | `lettermint.messages.process(id, { idempotencyKey? })` | +| `api.projects.list(params)` | `lettermint.projects.list(query)`, `lettermint.projects.iterate(query)` | +| `api.projects.create(payload)` | `lettermint.projects.create(payload)` | +| `api.projects.retrieve(id)` | `lettermint.projects.retrieve(id, query?)` | +| `api.projects.update(id, payload)` | `lettermint.projects.update(id, payload)` | +| `api.projects.delete(id)` | `lettermint.projects.delete(id)` | +| `api.projects.rotateToken(id)` | `lettermint.projects.rotateToken(id)` (deprecated by the API) | +| `api.projects.routes(projectId, params)` | `lettermint.routes.list(projectId, query)`, `lettermint.routes.iterate(projectId, query)` | +| `api.projects.createRoute(projectId, payload)` | `lettermint.routes.create(projectId, payload)` | +| `api.projects.retrieveReportForwarding(id)` | `lettermint.projects.reportForwarding.retrieve(id)` | +| `api.projects.updateReportForwarding(id, payload)` | `lettermint.projects.reportForwarding.update(id, payload)` | +| `api.projects.deleteReportForwarding(id)` | `lettermint.projects.reportForwarding.delete(id)` | +| `api.projects.verifyReportForwarding(id, payload)` | `lettermint.projects.reportForwarding.verify(id, payload)` | +| `api.projects.resendReportForwardingCode(id)` | `lettermint.projects.reportForwarding.resendCode(id)` | +| `api.routes.retrieve(id)` | `lettermint.routes.retrieve(id, query?)` | +| `api.routes.update(id, payload)` | `lettermint.routes.update(id, payload)` | +| `api.routes.delete(id)` | `lettermint.routes.delete(id)` | +| `api.routes.verifyInboundDomain(id)` | `lettermint.routes.verifyInboundDomain(id)` | +| `api.stats.retrieve(params)` | `lettermint.stats.retrieve({ from, to, project_id?, include_machine? })` | +| `api.suppressions.list(params)` | `lettermint.suppressions.list(query)`, `lettermint.suppressions.iterate(query)` | +| `api.suppressions.create(payload)` | `lettermint.suppressions.create(payload)` | +| `api.suppressions.delete(id)` | `lettermint.suppressions.delete(id)` | +| `api.team.retrieve()` | `lettermint.team.retrieve(query?)` (`{ include: ['features'] }`) | +| `api.team.update(payload)` | `lettermint.team.update(payload)` | +| `api.team.usage(params)` | `lettermint.team.usage()` (the endpoint takes no parameters) | +| `api.team.roles()` | `lettermint.team.roles()` | +| `api.team.members(params)` | `lettermint.team.members.list(query)`, `lettermint.team.members.iterate(query)` | +| `api.team.member(userId)` | `lettermint.team.members.retrieve(userId)` | +| `api.team.updateMemberAssignment(userId, payload)` | `lettermint.team.members.updateAssignment(userId, payload)` | +| `api.webhooks.list(params)` | `lettermint.webhooks.list(query)`, `lettermint.webhooks.iterate(query)` | +| `api.webhooks.create(payload)` | `lettermint.webhooks.create(payload)` | +| `api.webhooks.retrieve(id)` | `lettermint.webhooks.retrieve(id)` | +| `api.webhooks.update(id, payload)` | `lettermint.webhooks.update(id, payload)` | +| `api.webhooks.delete(id)` | `lettermint.webhooks.delete(id)` | +| `api.webhooks.test(id)` | `lettermint.webhooks.test(id)` | +| `api.webhooks.regenerateSecret(id)` | `lettermint.webhooks.regenerateSecret(id)` | +| `api.webhooks.deliveries(id, params)` | `lettermint.webhooks.deliveries.list(id, query)`, `lettermint.webhooks.deliveries.iterate(id, query)` | +| `api.webhooks.delivery(id, deliveryId)` | `lettermint.webhooks.deliveries.retrieve(id, deliveryId)` | + +Every method also takes a last `options` argument: `{ signal?: AbortSignal, timeout?: number }`. + +`messages.reschedule()` and `messages.cancel()` accept either token: the team token when configured, otherwise the sending token. This lets a sending-only client cancel the scheduled email it sent. + +### Query parameters + +Write bracketed names as nested objects. Arrays of values are joined with commas, arrays of objects are indexed, and booleans are sent as `1`/`0`. + +| 2.x | 3.0 | +| --- | --- | +| `{ 'page[size]': '30', 'page[cursor]': c }` | `{ page: { size: 30, cursor: c } }` | +| `{ 'filter[status]': 'verified' }` | `{ filter: { status: 'verified' } }` | +| `{ sort: '-created_at,domain' }` | `{ sort: ['-created_at', 'domain'] }` | +| `{ 'filter[tags][0][name]': 'a', 'filter[tags][0][value]': 'b' }` | `{ filter: { tags: [{ name: 'a', value: 'b' }] } }` | +| `{ 'filter[enabled]': 'true' }` | `{ filter: { enabled: true } }` | +| webhooks: `{ cursor: c }` | unchanged: `{ cursor: c }` (these lists use `cursor`, not `page[cursor]`) | + +### Path parameters + +IDs are still URL-encoded. An empty ID, `.` or `..` now throws `LettermintConfigError` before the request. + +### Message lists + +The 2.x types described message and event lists with a nested `meta` object. The API returns a flat cursor page, which 3.0 types as `CursorPage`: read `page.next_cursor`, not `page.meta.next_cursor`. Or use `iterate()`. + +## Errors + +`HttpRequestError` and `ClientError` are removed. Every SDK error extends `LettermintError` and has an explicit `name`, so `error.name` is readable in logs (2.x was minified, so names could show as `a`). + +| Situation | 2.x | 3.0 | +| --- | --- | --- | +| HTTP 400 | `ClientError` (`statusCode`, `responseBody`) | `ApiError` (`status`, `code`, `message`, `details`, `body`) | +| HTTP 401 | `HttpRequestError` | `AuthenticationError` | +| HTTP 403 | `HttpRequestError` | `PermissionError` | +| HTTP 404 | `HttpRequestError` | `NotFoundError` | +| HTTP 409 | `HttpRequestError` | `ConflictError` | +| HTTP 422 | `ValidationError` (`statusCode`, `errorType`, `responseBody`) | `ValidationError` (`status`, `code`, `errors`, `body`) | +| HTTP 429 | `HttpRequestError` | `RateLimitError` (`retryAfter` in seconds) | +| HTTP 5xx | `HttpRequestError` | `ServerError` | +| Other 4xx | `HttpRequestError` | `ApiError` | +| Empty or invalid JSON body, HTML error page | raw `SyntaxError` | `UnexpectedResponseError` (`status`, `bodyExcerpt`) | +| Redirect (3xx) | followed, with the token | `RedirectError` (`status`); never followed | +| Timeout | `TimeoutError` (headers only) | `TimeoutError` (`timeout`); covers headers and body | +| Network failure | raw `TypeError` | `ConnectionError` (`cause`) | +| Invalid tags | `TypeError` | `LettermintValidationError` (`field`) | +| Missing or wrong token, bad option | — | `LettermintConfigError` | + +Property renames: `statusCode` → `status`, `responseBody` → `body`, `errorType` → `code`. `code` comes from `{ error: { code } }`, or from a string `error` field. `message` is the API's message, or the HTTP status text. + +```ts +// 2.x +try { + await email.send(); +} catch (error) { + if (error instanceof ValidationError) console.log(error.statusCode, error.errorType, error.responseBody); + else if (error instanceof HttpRequestError && error.statusCode === 429) retryLater(); +} + +// 3.0 +import { RateLimitError, ValidationError } from 'lettermint'; +try { + await builder.send(); +} catch (error) { + if (error instanceof ValidationError) console.log(error.status, error.code, error.errors); + else if (error instanceof RateLimitError) retryLater(error.retryAfter); +} +``` + +The SDK does not retry requests. Pass an `idempotencyKey` when you retry a send. + +User cancellation through `signal` rejects with the signal's `reason` (an `AbortError` by default), not an SDK error. + +## Webhooks + +`verify()` is now `async`, takes the body first and the headers second, and replaces `verifyHeaders()`. The old `verify(payload, signature, timestamp?)` is now `verifySignature()`. Both return the typed payload (`WebhookPayload`) instead of `unknown`. + +```ts +// 2.x +const webhook = new Webhook(secret); +const payload = webhook.verifyHeaders(req.headers, rawBody); +const payload2 = webhook.verify(rawBody, signatureHeader, Number(deliveryHeader)); + +// 3.0 +const webhook = new Webhook(secret); +const event = await webhook.verify(rawBody, req.headers); // Node headers or a Fetch Headers object +const event2 = await webhook.verifySignature(rawBody, signatureHeader, deliveryHeader); +``` + +- The body may be a `string`, `Uint8Array` (including `Buffer`) or `ArrayBuffer`. +- Both `X-Lettermint-Signature` and `X-Lettermint-Delivery` are required, as in 2.8. +- `WebhookVerificationError` has a `reason`: `signature_header_missing`, `signature_header_malformed`, `delivery_header_missing`, `delivery_timestamp_mismatch`, `timestamp_out_of_tolerance`, `signature_mismatch`, `body_invalid` or `payload_invalid`. +- An empty secret or an invalid `tolerance` throws `LettermintConfigError` (2.x: `WebhookVerificationError`). + +## Type names + +The types are generated from the API specification of lettermint#2582 and use its names. Types that are not listed below keep their name. Some shapes also changed: + +- Enums are open: `MessageStatus` is `'pending' | ... | (string & {})`, so values the API adds later still type-check. Give `switch` statements a `default` branch. +- `SendMailResponse` is `PendingSendMailResponse | ScheduledSendMailResponse`; narrow on `status`. +- `MessageTag` describes tags in responses; `MessageTagInput` describes tags you send. +- Message and event lists are `CursorPage` (see [Message lists](#message-lists)). +- The generated `ApiError` and `ValidationError` body types are exported as `ApiErrorBody` and `ValidationErrorBody`, because `ApiError` and `ValidationError` are error classes. + +| 2.x | 3.0 | +| --- | --- | +| `AnalyticsRequest` | `AnalyticsQuery` | +| `BlockedFileTypesResponse` | `BlockedFileTypes` | +| `CancelScheduledMessageResponse` | `ScheduledMessage` | +| `CursorPaginator` | `CursorPage` | +| `DomainDestroyResponse` | `MessageResponse` | +| `DomainIndexResponse` | `ListDomainsResponse` | +| `DomainShowResponse` | `DomainData` | +| `DomainStoreRequest` | `StoreDomainData` | +| `DomainStoreResponse` | `DomainData` | +| `DomainUpdateProjectsRequest` | `UpdateDomainProjectsData` | +| `DomainUpdateProjectsResponse` | `DomainMutationResponse` | +| `DomainVerifyDnsRecordsResponse` | `DnsVerificationSuccessResponse` | +| `DomainVerifySpecificDnsRecordResponse` | `MessageResponse` | +| `EmailPayload` | `SendMailRequest` (or `EmailMessage`, which also accepts binary attachments) | +| `MessageEventsResponse` | `ListMessageEventsResponse` | +| `MessageIndexResponse` | `ListMessagesResponse` | +| `MessageShowResponse` | `MessageData` | +| `PingResponse` | `string` | +| `ProjectDestroyResponse` | `MessageResponse` | +| `ProjectIndexResponse` | `ListProjectsResponse` | +| `ProjectRotateTokenResponse` | `RotateProjectTokenResponse` | +| `ProjectShowResponse` | `ProjectData` | +| `ProjectStoreRequest` | `StoreProjectData` | +| `ProjectStoreResponse` | `ProjectCreatedData` | +| `ProjectUpdateRequest` | `UpdateProjectData` | +| `ProjectUpdateResponse` | `ProjectMutationResponse` | +| `RescheduleMessageResponse` | `ScheduledMessage` | +| `RouteDestroyResponse` | `MessageResponse` | +| `RouteIndexResponse` | `ListRoutesResponse` | +| `RouteShowResponse` | `RouteData` | +| `RouteStoreRequest` | `StoreRouteData` | +| `RouteStoreResponse` | `RouteMutationResponse` | +| `RouteUpdateRequest` | `UpdateRouteData` | +| `RouteUpdateResponse` | `RouteMutationResponse` | +| `RouteVerifyInboundDomainResponse` | `InboundDomainVerificationResponse` | +| `SendBatchEmailResponse` | `SendBatchMailResponse` | +| `SendEmailResponse` | `SendMailResponse` | +| `StatsIndexResponse` | `StatsData` | +| `SuppressionDestroyResponse` | `DeleteSuppressionResponse` | +| `SuppressionIndexResponse` | `ListSuppressionsResponse` | +| `SuppressionStoreRequest` | `StoreSuppressionData` | +| `TeamMembersAssignmentUpdateRequest` | `UpdateTeamMemberAssignmentData` | +| `TeamMembersAssignmentUpdateResponse` | `TeamMemberData` | +| `TeamMembersResponse` | `ListTeamMembersResponse` | +| `TeamMembersShowResponse` | `TeamMemberData` | +| `TeamRolesResponse` | `TeamRoleListResponse` | +| `TeamShowResponse` | `TeamData` | +| `TeamUpdateRequest` | `UpdateTeamData` | +| `TeamUpdateResponse` | `TeamMutationResponse` | +| `TeamUsageResponse` | `TeamUsageDetailData` | +| `UpdateReportForwardingRequest` | `ReportForwardingRequest` | +| `WebhookDeliveriesResponse` | `ListWebhookDeliveriesResponse` | +| `WebhookDestroyResponse` | `MessageResponse` | +| `WebhookIndexResponse` | `ListWebhooksResponse` | +| `WebhookRegenerateSecretResponse` | `WebhookSecretResponse` | +| `WebhookShowDeliveryResponse` | `WebhookDeliveryData` | +| `WebhookShowResponse` | `WebhookData` | +| `WebhookStoreRequest` | `StoreWebhookData` | +| `WebhookStoreResponse` | `WebhookSecretResponse` | +| `WebhookTestResponse` | `TestWebhookResponse` | +| `WebhookUpdateRequest` | `UpdateWebhookData` | +| `WebhookUpdateResponse` | `WebhookMutationResponse` | + +### Removed types + +lettermint#2582 removed these schemas from the API specification: + +| 2.x | 3.0 | +| --- | --- | +| `AnalyticsResponseData` | Removed. Use `AnalyticsResponse` (`data: AnalyticsResults`). | +| `StatsRequestData` | Removed. Use `GetStatsQuery`, the parameters of `stats.retrieve()`. | +| Message list `meta` (`MessageIndexResponseMeta`) | Removed; not exported by 2.x. Lists are flat `CursorPage`. | +| Message events `meta` (`MessageEventsResponseMeta`) | Removed; not exported by 2.x. | +| `SuppressionStoreResponseMessage1` | Removed; not exported by 2.x. `SuppressionStoreResponse.message` is a `string`. | + +### Removed classes and helpers + +| 2.x | 3.0 | +| --- | --- | +| `Lettermint.email(token, config)` | `new Lettermint({ sendingToken, ...config }).emails` | +| `Lettermint.api(token, config)` | `new Lettermint({ teamToken, ...config })` | +| `new Lettermint({ apiToken })`, `lettermint.email` | `new Lettermint({ sendingToken })`, `lettermint.emails` | +| `ApiClient` | `Lettermint` | +| `EmailEndpoint` | `Emails` (type) and `EmailBuilder` (type) from `lettermint.emails.compose()` | +| `Endpoint`, `DomainsEndpoint`, `MessagesEndpoint`, `ProjectsEndpoint`, `RoutesEndpoint`, `StatsEndpoint`, `SuppressionsEndpoint`, `TeamEndpoint`, `WebhooksEndpoint` | `Domains`, `Messages`, `Projects`, `Routes`, `Stats`, `Suppressions`, `Team`, `Webhooks` (types only; use the properties of `Lettermint`) | +| `LettermintClient` (`get`, `post`, `put`, `patch`, `delete`, `getRaw`) | Removed. Every documented endpoint has a method. | +| `LettermintClientConfig` | `LettermintOptions` | +| `RequestConfig` | `RequestOptions` (`signal`, `timeout`) and `SendOptions` (`idempotencyKey`) | +| `QueryParams` | Typed query objects: `NestedQuery` and so on | +| `HttpRequestError`, `ClientError` | `ApiError` and its subclasses | +| `Webhook.verifyHeaders(headers, body)` | `await Webhook.verify(body, headers)` | +| `Webhook.verify(body, signature, timestamp)` | `await Webhook.verifySignature(body, signature, timestamp)` | + +# Upgrade from 1.x to 2.0 This guide covers upgrading from the latest released v1 Node SDK to v2. @@ -15,7 +468,7 @@ This guide covers upgrading from the latest released v1 Node SDK to v2. ```ts import { Lettermint } from 'lettermint'; -const email = Lettermint.email(process.env.LETTERMINT_SENDING_TOKEN!); +const email = Lettermint.email(process.env.LETTERMINT_PROJECT_TOKEN!); const api = Lettermint.api(process.env.LETTERMINT_API_TOKEN!); ``` @@ -35,4 +488,3 @@ await Lettermint.email(token).sendBatch([ const domains = await Lettermint.api(token).domains.list(); const messageHtml = await Lettermint.api(token).messages.html('message-id'); ``` - diff --git a/biome.json b/biome.json index ba982aa..02a213b 100644 --- a/biome.json +++ b/biome.json @@ -1,10 +1,10 @@ { - "$schema": "https://biomejs.dev/schemas/1.4.1/schema.json", + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", "organizeImports": { "enabled": true }, "files": { - "ignore": ["package.json", "coverage/**", "dist/**", "src/types.ts"] + "ignore": ["package.json", "coverage/**", "dist/**", "src/generated/**", "test/fixtures/**"] }, "linter": { "enabled": true, diff --git a/jest.config.js b/jest.config.js index eec72bc..aeb8a93 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,10 +1,13 @@ +/** @type {import('jest').Config} */ module.exports = { preset: 'ts-jest', testEnvironment: 'node', - testMatch: ['**/*.spec.ts'], - collectCoverage: true, - collectCoverageFrom: ['src/**/*.ts', '!src/**/*.d.ts'], + roots: ['/test'], + testMatch: ['**/*.test.ts'], + transform: { + '^.+\\.ts$': ['ts-jest', { tsconfig: 'tsconfig.test.json' }], + }, + collectCoverageFrom: ['src/**/*.ts', '!src/generated/**'], coverageDirectory: 'coverage', coverageReporters: ['text', 'lcov'], - verbose: true, }; diff --git a/package-lock.json b/package-lock.json index ae6e0e5..784004a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lettermint", - "version": "2.6.0", + "version": "3.0.0-dev", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lettermint", - "version": "2.6.0", + "version": "3.0.0-dev", "license": "MIT", "devDependencies": { "@biomejs/biome": "1.9.4", @@ -16,6 +16,9 @@ "ts-jest": "^29.3.4", "tsup": "^8.5.0", "typescript": "^5.8.3" + }, + "engines": { + "node": ">=20" } }, "node_modules/@ampproject/remapping": { diff --git a/package.json b/package.json index 7e26c66..2da3732 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,13 @@ { "name": "lettermint", - "version": "2.6.0", - "description": "Official Lettermint Node.js SDK", + "version": "3.0.0-dev", + "description": "Official Lettermint SDK for Node.js, Bun, Deno and edge runtimes", "type": "commonjs", "main": "dist/index.js", "module": "dist/index.mjs", "types": "dist/index.d.ts", "exports": { ".": { - "types": "./dist/index.d.ts", "import": { "types": "./dist/index.d.mts", "default": "./dist/index.mjs" @@ -17,7 +16,8 @@ "types": "./dist/index.d.ts", "default": "./dist/index.js" } - } + }, + "./package.json": "./package.json" }, "files": [ "dist", @@ -25,8 +25,14 @@ "CHANGELOG.md", "UPGRADE.md" ], + "sideEffects": false, + "engines": { + "node": ">=20" + }, "scripts": { - "build": "tsup src/index.ts --format esm,cjs --dts --clean --minify --sourcemap", + "build": "tsup", + "generate": "node scripts/generate.mjs", + "generate:check": "node scripts/generate.mjs --check", "lint": "biome check .", "lint:fix": "biome check --write .", "format": "biome format .", @@ -34,16 +40,19 @@ "test": "jest", "test:coverage": "jest --coverage", "test:watch": "jest --watch", - "type-check": "tsc --noEmit" + "test:smoke": "node scripts/smoke.mjs", + "type-check": "tsc -p tsconfig.json && tsc -p tsconfig.test.json" }, "repository": { "type": "git", "url": "git+https://github.com/lettermint/lettermint-node.git" }, "keywords": [ - "Lettermint", - "Node", - "lettermint-node" + "lettermint", + "email", + "transactional email", + "sdk", + "webhooks" ], "author": "Bjarn Bronsveld", "license": "MIT", diff --git a/scripts/generate.mjs b/scripts/generate.mjs new file mode 100644 index 0000000..6fe7bc8 --- /dev/null +++ b/scripts/generate.mjs @@ -0,0 +1,69 @@ +// Regenerates (or, with --check, verifies) src/generated/ with the private +// lettermint/sdk-generator. Set LETTERMINT_SDK_GENERATOR to its checkout +// (default: ../sdk-generator next to this repository). +// +// Without the generator (CI, contributors without access), --check only +// verifies that both files carry the generator header with the spec commit +// and hashes, so that hand edits that drop the header are caught. +import { spawnSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const out = join(root, 'src', 'generated'); +const check = process.argv.includes('--check'); +const generator = resolve(root, process.env.LETTERMINT_SDK_GENERATOR ?? '../sdk-generator'); +const available = + existsSync(join(generator, 'lettermint_sdkgen')) && + existsSync(join(generator, 'spec.next.lock.json')); + +if (available) { + const args = [ + '-m', + 'lettermint_sdkgen', + 'emit', + 'typescript', + '--profile', + 'next', + '--lock', + 'spec.next.lock.json', + '--out', + out, + ]; + if (check) args.push('--check'); + const result = spawnSync('python3', args, { cwd: generator, stdio: 'inherit' }); + process.exit(result.status ?? 1); +} + +if (!check) { + console.error( + `The SDK generator was not found at ${generator}. Set LETTERMINT_SDK_GENERATOR to its checkout.` + ); + process.exit(1); +} + +const headers = ['types.ts', 'operations.ts'].map((name) => { + const lines = readFileSync(join(out, name), 'utf8').split('\n').slice(0, 5); + const valid = + lines[0] === '// Generated by lettermint/sdk-generator — do not edit.' && + /^\/\/ Spec: lettermint\/lettermint@[0-9a-f]{40}( \(.+\))?$/.test(lines[1]) && + /^\/\/ sending-openapi\.json SHA-256: [0-9a-f]{64}$/.test(lines[2]) && + /^\/\/ team-openapi\.json SHA-256: [0-9a-f]{64}$/.test(lines[3]) && + lines[4] === '// Naming profile: next'; + if (!valid) { + console.error( + `src/generated/${name} is missing the generator header. Regenerate it; do not edit it by hand.` + ); + process.exit(1); + } + return lines.join('\n'); +}); +if (headers[0] !== headers[1]) { + console.error( + 'src/generated/types.ts and operations.ts come from different specs. Regenerate both.' + ); + process.exit(1); +} +console.log(`SDK generator not found at ${generator}; checked the generated headers only.`); +console.log(headers[0].split('\n')[1]); diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs new file mode 100644 index 0000000..a8e0f4e --- /dev/null +++ b/scripts/smoke.mjs @@ -0,0 +1,97 @@ +// Packs the built package, installs the tarball into a temporary project and +// checks that ESM import, CJS require and the type declarations work there. +// Run `npm run build` first. +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const work = mkdtempSync(join(tmpdir(), 'lettermint-smoke-')); +const run = (command, args, cwd = work) => + execFileSync(command, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'] }); + +try { + const [pack] = JSON.parse(run('npm', ['pack', '--json', '--pack-destination', work], root)); + const files = pack.files.map((file) => file.path).sort(); + const allowed = + /^(dist\/index\.(js|mjs|d\.ts|d\.mts)|README\.md|UPGRADE\.md|CHANGELOG\.md|LICENSE(\.md)?|package\.json)$/; + const unexpected = files.filter((file) => !allowed.test(file)); + if (unexpected.length) + throw new Error(`Unexpected files in the package: ${unexpected.join(', ')}`); + console.log(`packed ${pack.filename}: ${files.join(', ')}`); + + writeFileSync(join(work, 'package.json'), '{"name":"smoke","private":true}\n'); + run('npm', ['install', '--no-audit', '--no-fund', '--ignore-scripts', join(work, pack.filename)]); + + const checks = ` + const token = 'lm_smokeSecret0123456789abcdefABCD'; + if (Default !== Lettermint) throw new Error('default export differs from the named export'); + const client = new Lettermint({ sendingToken: token }); + const rendered = [inspect(client), inspect(client, { depth: Infinity }), JSON.stringify(client), + inspect(client.emails.compose().from('a@example.test'))].join('\\n'); + if (rendered.includes(token)) throw new Error('the token is visible in debug output'); + if (new Lettermint(token).toJSON().sendingToken !== '[redacted]') throw new Error('shorthand failed'); + if (new TimeoutError(1).name !== 'TimeoutError') throw new Error('error names are mangled'); + try { new Lettermint('lm_sso_abc'); throw new Error('no error'); } + catch (error) { if (!(error instanceof LettermintConfigError)) throw error; } + const hook = new Webhook('whsec_smoke'); + const reason = await hook.verify('{}', {}).catch((error) => error.reason); + if (reason !== 'signature_header_missing') throw new Error('webhook verification failed: ' + reason); + `; + writeFileSync( + join(work, 'esm.mjs'), + `import Default, { Lettermint, LettermintConfigError, TimeoutError, Webhook } from 'lettermint'; + import { inspect } from 'node:util'; + ${checks} + console.log('ESM import ok');` + ); + writeFileSync( + join(work, 'cjs.cjs'), + `const { Lettermint, LettermintConfigError, TimeoutError, Webhook } = require('lettermint'); + const Default = require('lettermint').default; + const { inspect } = require('node:util'); + (async () => { ${checks} console.log('CJS require ok'); })().catch((error) => { console.error(error); process.exit(1); });` + ); + process.stdout.write(run('node', ['esm.mjs'])); + process.stdout.write(run('node', ['cjs.cjs'])); + + const typed = ` + const lettermint = new Lettermint({ sendingToken: 'lm_x', teamToken: 'lm_team_x' }); + async function main(): Promise { + const sent: SendMailResponse = await lettermint.emails.send({ from: 'a@b.c', to: ['d@e.f'], subject: 's' }); + const page: ListDomainsResponse = await lettermint.domains.list({ page: { size: 30 } }); + for await (const domain of lettermint.domains.iterate()) domain.id.toUpperCase(); + const error: ApiError = new NotFoundError({ status: 404, message: 'x' }); + void sent; void page; void error; void Default; + } + void main;`; + writeFileSync( + join(work, 'types.mts'), + `import Default, { Lettermint, NotFoundError, type ApiError, type ListDomainsResponse, type SendMailResponse } from 'lettermint';\n${typed}` + ); + writeFileSync( + join(work, 'types.cts'), + `import Default = require('lettermint');\nimport { Lettermint, NotFoundError, type ApiError, type ListDomainsResponse, type SendMailResponse } from 'lettermint';\n${typed}` + ); + writeFileSync( + join(work, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { + noEmit: true, + strict: true, + module: 'nodenext', + moduleResolution: 'nodenext', + target: 'es2022', + lib: ['es2022', 'dom'], + types: [], + }, + files: ['types.mts', 'types.cts'], + }) + ); + run(join(root, 'node_modules', '.bin', 'tsc'), ['-p', 'tsconfig.json']); + console.log('Type declarations ok (ESM and CJS, moduleResolution nodenext)'); +} finally { + rmSync(work, { recursive: true, force: true }); +} diff --git a/src/analytics-forwarding.spec.ts b/src/analytics-forwarding.spec.ts deleted file mode 100644 index 86faaeb..0000000 --- a/src/analytics-forwarding.spec.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { Lettermint } from './lettermint'; -import type * as Types from './types'; - -it('uses the new analytics and report forwarding contracts', async () => { - const payload: Types.AnalyticsRequest = { - metrics: ['accepted', 'delivered'], - include: ['summary', 'time_series'], - interval: 'day', - filters: [{ dimension: 'project', operator: 'eq', values: ['project/id'] }], - sort: { metric: 'accepted', direction: 'desc' }, - limit: 10, - }; - const resource = { destination: null, verified: false, verified_at: null }; - const mockFetch = jest - .fn() - .mockResolvedValue({ ok: true, status: 200, json: async () => ({ data: resource }) }); - global.fetch = mockFetch; - const api = Lettermint.api('team-token'); - const analyticsResponse: Types.AnalyticsResponse = { - data: { summary: { metrics: { accepted: 12, delivery_rate: null } } }, - meta: { timezone: 'UTC' }, - pagination: { total_groups: 0, returned_groups: 0, next_cursor: null, truncated: false }, - }; - mockFetch.mockResolvedValueOnce({ ok: true, status: 200, json: async () => analyticsResponse }); - const analytics = await api.analytics(payload); - expect(analytics.meta.timezone).toBe('UTC'); - expect(analytics.data.summary?.metrics?.delivery_rate).toBeNull(); - expect((await api.projects.retrieveReportForwarding('project/id')).data).toEqual(resource); - await api.projects.updateReportForwarding('project/id', { destination: 'reports@example.com' }); - await api.projects.verifyReportForwarding('project/id', { code: '123456' }); - await api.projects.resendReportForwardingCode('project/id'); - const decode = jest.fn(() => { - throw new Error('204 must not be decoded'); - }); - mockFetch.mockResolvedValueOnce({ ok: true, status: 204, json: decode }); - await expect(api.projects.deleteReportForwarding('project/id')).resolves.toBeUndefined(); - expect(decode).not.toHaveBeenCalled(); - const calls = mockFetch.mock.calls; - expect( - calls.map(([url, options]) => [url.replace('https://api.lettermint.co/v1', ''), options.method]) - ).toEqual([ - ['/analytics', 'POST'], - ['/projects/project%2Fid/report-forwarding', 'GET'], - ['/projects/project%2Fid/report-forwarding', 'PUT'], - ['/projects/project%2Fid/report-forwarding/verify', 'POST'], - ['/projects/project%2Fid/report-forwarding/resend-code', 'POST'], - ['/projects/project%2Fid/report-forwarding', 'DELETE'], - ]); - expect(JSON.parse(calls[0][1].body)).toEqual(payload); - expect(JSON.parse(calls[2][1].body)).toEqual({ destination: 'reports@example.com' }); - expect(JSON.parse(calls[3][1].body)).toEqual({ code: '123456' }); - for (const [, options] of calls) { - expect(options.headers.Authorization).toBe('Bearer team-token'); - expect(options.headers).not.toHaveProperty('x-lettermint-token'); - } -}); - -it('keeps both suppression deletion results and project creation tokens', () => { - const deleted: Types.SuppressionDestroyResponse = { - success: true, - message: 'Deleted', - status: 'removed', - confidence: 0.9, - }; - const review: Types.SuppressionDestroyResponse = { - success: true, - message: 'Review', - status: 'review_ticket_created', - ticket_identifier: 'ticket-1', - }; - const created: Types.ProjectStoreResponse = { - data: {} as Types.ProjectData, - message: 'Created', - api_token: 'project-token', - }; - expect(deleted.message).toBe('Deleted'); - expect(review.ticket_identifier).toBe('ticket-1'); - expect(created.api_token).toBe('project-token'); -}); diff --git a/src/api-surface.spec.ts b/src/api-surface.spec.ts deleted file mode 100644 index 3f1a616..0000000 --- a/src/api-surface.spec.ts +++ /dev/null @@ -1,382 +0,0 @@ -import { LettermintClient } from './client'; -import { Lettermint } from './lettermint'; -import type * as Types from './types'; - -const mockFetch = jest.fn(); - -beforeEach(() => { - jest.clearAllMocks(); - global.fetch = mockFetch; - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({}), - text: async () => 'pong', - } as Response); -}); - -describe('public SDK surface', () => { - it('creates an email client with sending token auth and raw ping', async () => { - const email = Lettermint.email('sending-token'); - - await email.ping(); - - expect(mockFetch).toHaveBeenCalledWith( - 'https://api.lettermint.co/v1/ping', - expect.objectContaining({ - method: 'GET', - headers: expect.objectContaining({ - 'x-lettermint-token': 'sending-token', - }), - }) - ); - expect(mockFetch.mock.calls[0][1].headers).not.toHaveProperty('Authorization'); - }); - - it('creates an api client with bearer token auth and raw ping', async () => { - const api = Lettermint.api('api-token'); - - await api.ping(); - - expect(mockFetch).toHaveBeenCalledWith( - 'https://api.lettermint.co/v1/ping', - expect.objectContaining({ - method: 'GET', - headers: expect.objectContaining({ - Authorization: 'Bearer api-token', - }), - }) - ); - expect(mockFetch.mock.calls[0][1].headers).not.toHaveProperty('x-lettermint-token'); - }); - - it('lists blocked file types with bearer token auth', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - extensions: ['exe'], - mime_types: ['application/x-msdownload'], - }), - text: async () => '', - } as Response); - const api = Lettermint.api('api-token'); - - await expect(api.blockedFileTypes()).resolves.toEqual({ - extensions: ['exe'], - mime_types: ['application/x-msdownload'], - }); - - expect(mockFetch).toHaveBeenCalledWith( - 'https://api.lettermint.co/v1/blocked-file-types', - expect.objectContaining({ - method: 'GET', - headers: expect.objectContaining({ - Authorization: 'Bearer api-token', - }), - }) - ); - }); - - it('does not let custom headers override SDK auth headers', async () => { - const client = new LettermintClient({ apiToken: 'api-token', authMode: 'api' }); - - await client.get('/team', { - headers: { - Authorization: 'Bearer attacker', - 'x-lettermint-token': 'attacker', - }, - }); - - expect(mockFetch.mock.calls[0][1].headers).toEqual( - expect.objectContaining({ - Authorization: 'Bearer api-token', - }) - ); - expect(mockFetch.mock.calls[0][1].headers).not.toHaveProperty('x-lettermint-token'); - }); - - it('posts batch email payloads to the sending API', async () => { - const email = Lettermint.email('sending-token'); - const payload = [{ from: 'from@example.com', to: ['to@example.com'], subject: 'Hello' }]; - - await email.sendBatch(payload); - - expect(mockFetch).toHaveBeenCalledWith( - 'https://api.lettermint.co/v1/send/batch', - expect.objectContaining({ - method: 'POST', - body: JSON.stringify(payload), - }) - ); - }); - - it('maps reusable team role and member assignment endpoints', async () => { - const api = Lettermint.api('api-token'); - const assignment: Types.TeamMembersAssignmentUpdateRequest = { - role_id: 'role_123', - project_access: { scope: 'all' }, - }; - - await api.team.roles(); - await api.team.member('user/id'); - await api.team.updateMemberAssignment('user/id', assignment); - - expect(mockFetch).toHaveBeenNthCalledWith( - 1, - 'https://api.lettermint.co/v1/team/roles', - expect.objectContaining({ method: 'GET' }) - ); - expect(mockFetch).toHaveBeenNthCalledWith( - 2, - 'https://api.lettermint.co/v1/team/members/user%2Fid', - expect.objectContaining({ method: 'GET' }) - ); - expect(mockFetch).toHaveBeenNthCalledWith( - 3, - 'https://api.lettermint.co/v1/team/members/user%2Fid/assignment', - expect.objectContaining({ - method: 'PUT', - body: JSON.stringify(assignment), - }) - ); - }); - - it('reschedules, cancels, and processes messages', async () => { - const api = Lettermint.api('api-token'); - await api.messages.reschedule('message/id', { scheduled_at: '2026-08-27T09:00:00Z' }); - await api.messages.cancel('message/id'); - await api.messages.process('message/id'); - - expect(mockFetch).toHaveBeenNthCalledWith( - 1, - 'https://api.lettermint.co/v1/messages/message%2Fid', - expect.objectContaining({ - method: 'PATCH', - body: JSON.stringify({ scheduled_at: '2026-08-27T09:00:00Z' }), - }) - ); - expect(mockFetch).toHaveBeenNthCalledWith( - 2, - 'https://api.lettermint.co/v1/messages/message%2Fid/cancel', - expect.objectContaining({ method: 'POST' }) - ); - expect(mockFetch).toHaveBeenNthCalledWith( - 3, - 'https://api.lettermint.co/v1/messages/message%2Fid/process', - expect.objectContaining({ method: 'POST' }) - ); - }); -}); - -describe('api endpoint coverage', () => { - const documentedMethods = { - 'v1.analytics': 'analytics', - getReportForwarding: 'projects.retrieveReportForwarding', - updateReportForwarding: 'projects.updateReportForwarding', - deleteReportForwarding: 'projects.deleteReportForwarding', - verifyReportForwarding: 'projects.verifyReportForwarding', - resendReportForwardingCode: 'projects.resendReportForwardingCode', - 'domain.index': 'domains.list', - 'domain.store': 'domains.create', - 'domain.show': 'domains.retrieve', - 'domain.destroy': 'domains.delete', - 'domain.verifyDnsRecords': 'domains.verifyDnsRecords', - 'domain.verifySpecificDnsRecord': 'domains.verifyDnsRecord', - 'domain.updateProjects': 'domains.updateProjects', - 'v1.ping': 'ping', - 'v1.blockedFileTypes': 'blockedFileTypes', - 'message.index': 'messages.list', - 'message.show': 'messages.retrieve', - rescheduleMessage: 'messages.reschedule', - cancelScheduledMessage: 'messages.cancel', - processInboundMessage: 'messages.process', - 'message.events': 'messages.events', - 'message.source': 'messages.source', - 'message.html': 'messages.html', - 'message.text': 'messages.text', - 'project.index': 'projects.list', - 'project.store': 'projects.create', - 'project.show': 'projects.retrieve', - 'project.update': 'projects.update', - 'project.destroy': 'projects.delete', - 'project.rotateToken': 'projects.rotateToken', - 'route.index': 'projects.routes', - 'route.store': 'projects.createRoute', - 'route.show': 'routes.retrieve', - 'route.update': 'routes.update', - 'route.destroy': 'routes.delete', - 'route.verifyInboundDomain': 'routes.verifyInboundDomain', - 'stats.index': 'stats.retrieve', - 'suppression.index': 'suppressions.list', - 'suppression.store': 'suppressions.create', - 'suppression.destroy': 'suppressions.delete', - 'team.show': 'team.retrieve', - 'team.update': 'team.update', - 'team.usage': 'team.usage', - 'team.roles': 'team.roles', - 'team.members': 'team.members', - 'team.members.show': 'team.member', - 'team.members.assignment.update': 'team.updateMemberAssignment', - 'webhook.index': 'webhooks.list', - 'webhook.store': 'webhooks.create', - 'webhook.show': 'webhooks.retrieve', - 'webhook.update': 'webhooks.update', - 'webhook.destroy': 'webhooks.delete', - 'webhook.test': 'webhooks.test', - 'webhook.regenerateSecret': 'webhooks.regenerateSecret', - 'webhook.deliveries': 'webhooks.deliveries', - 'webhook.showDelivery': 'webhooks.delivery', - } as const; - - it('exposes documented API operations', () => { - const api = Lettermint.api('api-token') as unknown as Record; - - expect(Object.keys(documentedMethods)).toHaveLength(56); - - for (const exposedPath of Object.values(documentedMethods)) { - const segments = exposedPath.split('.'); - let cursor: unknown = api; - - for (const segment of segments) { - cursor = (cursor as Record)[segment]; - } - - expect(cursor).toBeDefined(); - expect(typeof cursor).toBe('function'); - } - }); -}); - -describe('generated api types', () => { - it('matches current Team API schema additions', () => { - const messageEvent: Types.MessageEventType = 'auto_replied'; - const webhookEvent: Types.WebhookEvent = 'message.auto_replied'; - const builtInRole: Types.BuiltInTeamRole = 'admin'; - const suppression: Types.StoreSuppressionData = { - reason: 'manual', - scope: 'team', - emails: ['blocked@example.com'], - }; - const routeSettings: Types.UpdateRouteSettingsData = { - redact_email_content: true, - generate_plaintext_fallback: false, - }; - const routeInboundSettings: Types.UpdateRouteInboundSettingsData = { - inbound_domain: 'inbound.example.com', - inbound_spam_threshold: 3, - attachment_delivery: 'url', - }; - const routeUpdate: Types.UpdateRouteData = { - settings: routeSettings, - inbound_settings: routeInboundSettings, - }; - const projectCreate: Types.StoreProjectData = { - name: 'Production', - short_token: true, - delivery_mode: 'sandbox', - }; - const project: Types.ProjectData = { - id: 'project_123', - name: 'Production', - smtp_enabled: true, - redact_email_content: true, - default_route_id: null, - token_generated_at: null, - token_last_used_at: null, - token_last_used_ip: null, - created_at: '2026-06-28T00:00:00Z', - updated_at: '2026-06-28T00:00:00Z', - delivery_mode: 'sandbox', - }; - const projectUpdate: Types.UpdateProjectData = { - redact_email_content: false, - delivery_mode: 'live', - }; - const sendRequest: Types.SendMailRequest = { - from: 'sender@example.com', - to: ['recipient@example.com'], - subject: 'Sandbox test', - sandbox_result: 'clicked', - }; - const sendResponse: Types.SendEmailResponse = { - message_id: 'message_123', - status: 'pending', - sandbox: true, - sandbox_result: 'clicked', - }; - const messageMode: Pick = { - delivery_mode: 'sandbox', - sandbox_result: 'clicked', - }; - const webhookCreate: Types.StoreWebhookData = { - name: 'Sandbox webhook', - url: 'https://example.com/webhooks', - events: ['message.delivered'], - delivery_mode_filter: 'both', - }; - const webhookDelivery: Pick = { - sandbox: true, - }; - const blockedFileTypes: Types.BlockedFileTypesResponse = { - extensions: ['exe'], - mime_types: ['application/x-msdownload'], - }; - const teamRole: Types.TeamRoleData = { - id: 'role_123', - name: 'Admin', - system_key: builtInRole, - permissions: ['members:manage'], - assignable: true, - }; - const assignment: Types.UpdateTeamMemberAssignmentData = { - role_id: 'role_123', - project_access: { scope: 'selected', project_ids: ['project_123'] }, - }; - const messageTag: Types.MessageTag = { name: 'campaign', value: 'welcome' }; - const cursorPaginator: Types.CursorPaginator = { - data: [], - path: null, - per_page: 25, - next_cursor: null, - next_page_url: null, - prev_cursor: null, - prev_page_url: null, - }; - const rescheduleRequest: Types.RescheduleMessageRequest = { - scheduled_at: '2026-10-01T09:00:00Z', - }; - const rescheduleResponse: Types.RescheduleMessageResponse = { - message_id: 'message_123', - status: 'scheduled', - scheduled_at: '2026-10-01T09:00:00Z', - }; - const cancelResponse: Types.CancelScheduledMessageResponse = { - message_id: 'message_123', - status: 'canceled', - scheduled_at: null, - }; - - expect({ - messageEvent, - webhookEvent, - builtInRole, - suppression, - routeUpdate, - projectCreate, - project, - projectUpdate, - sendRequest, - sendResponse, - messageMode, - webhookCreate, - webhookDelivery, - blockedFileTypes, - teamRole, - assignment, - messageTag, - cursorPaginator, - rescheduleRequest, - rescheduleResponse, - cancelResponse, - }).toBeDefined(); - }); -}); diff --git a/src/client.spec.ts b/src/client.spec.ts deleted file mode 100644 index 257d091..0000000 --- a/src/client.spec.ts +++ /dev/null @@ -1,273 +0,0 @@ -import { LettermintClient } from './client'; - -const mockFetch = jest.fn(); - -describe('LettermintClient', () => { - const apiToken = 'test-api-key'; - let client: LettermintClient; - - beforeEach(() => { - jest.clearAllMocks(); - - global.fetch = mockFetch; - - client = new LettermintClient({ apiToken }); - - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({}), - } as Response); - }); - - it('should initialize with default options', () => { - // No need to test initialization directly since we're not creating an instance - // of anything like Axios. Instead, we'll test the behavior in the HTTP method tests. - expect(client).toBeInstanceOf(LettermintClient); - }); - - it('should initialize with custom options', () => { - const customClient = new LettermintClient({ - apiToken, - baseUrl: 'https://custom-api.example.com', - timeout: 5000, - }); - - expect(customClient).toBeInstanceOf(LettermintClient); - }); - - it('should make a GET request', async () => { - const mockData = { test: 'data' }; - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => mockData, - } as Response); - - const result = await client.get('/test'); - - expect(mockFetch).toHaveBeenCalledWith( - 'https://api.lettermint.co/v1/test', - expect.objectContaining({ - method: 'GET', - headers: expect.objectContaining({ - 'Content-Type': 'application/json', - Accept: 'application/json', - 'x-lettermint-token': apiToken, - }), - }) - ); - expect(result).toEqual(mockData); - }); - - it('should make a GET request with query params', async () => { - const mockData = { test: 'data' }; - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => mockData, - } as Response); - - const result = await client.get('/test', { - params: { - foo: 'bar', - }, - }); - - expect(mockFetch).toHaveBeenCalledWith( - 'https://api.lettermint.co/v1/test?foo=bar', - expect.objectContaining({ - method: 'GET', - headers: expect.objectContaining({ - 'Content-Type': 'application/json', - Accept: 'application/json', - 'x-lettermint-token': apiToken, - }), - }) - ); - expect(result).toEqual(mockData); - }); - - it('should make a POST request', async () => { - const mockData = { id: '123' }; - const requestData = { name: 'Test' }; - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => mockData, - } as Response); - - const result = await client.post('/test', requestData); - - expect(mockFetch).toHaveBeenCalledWith( - 'https://api.lettermint.co/v1/test', - expect.objectContaining({ - method: 'POST', - headers: expect.objectContaining({ - 'Content-Type': 'application/json', - Accept: 'application/json', - 'x-lettermint-token': apiToken, - }), - body: JSON.stringify(requestData), - }) - ); - expect(result).toEqual(mockData); - }); - - it('should make a PUT request', async () => { - const mockData = { updated: true }; - const requestData = { name: 'Updated Test' }; - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => mockData, - } as Response); - - const result = await client.put('/test/123', requestData); - - expect(mockFetch).toHaveBeenCalledWith( - 'https://api.lettermint.co/v1/test/123', - expect.objectContaining({ - method: 'PUT', - headers: expect.objectContaining({ - 'Content-Type': 'application/json', - Accept: 'application/json', - 'x-lettermint-token': apiToken, - }), - body: JSON.stringify(requestData), - }) - ); - expect(result).toEqual(mockData); - }); - - it('should make a DELETE request', async () => { - const mockData = { deleted: true }; - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => mockData, - } as Response); - - const result = await client.delete('/test/123'); - - expect(mockFetch).toHaveBeenCalledWith( - 'https://api.lettermint.co/v1/test/123', - expect.objectContaining({ - method: 'DELETE', - headers: expect.objectContaining({ - 'Content-Type': 'application/json', - Accept: 'application/json', - 'x-lettermint-token': apiToken, - }), - }) - ); - expect(result).toEqual(mockData); - }); - - it('should use custom baseUrl', async () => { - const customClient = new LettermintClient({ - apiToken, - baseUrl: 'https://custom-api.example.com', - }); - - const mockData = { test: 'custom' }; - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => mockData, - } as Response); - - await customClient.get('/test'); - - expect(mockFetch).toHaveBeenCalledWith( - 'https://custom-api.example.com/test', - expect.any(Object) - ); - }); - - it('should reject absolute request paths', async () => { - await expect(client.get('https://attacker.example.test/collect')).rejects.toThrow( - 'Request path must be relative' - ); - - expect(mockFetch).not.toHaveBeenCalled(); - }); - - it('should reject protocol-relative request paths', async () => { - await expect(client.post('//attacker.example.test/collect', {})).rejects.toThrow( - 'Request path must be relative' - ); - - expect(mockFetch).not.toHaveBeenCalled(); - }); - - it('should handle HTTP errors', async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 404, - statusText: 'Not Found', - json: async () => ({ message: 'Resource not found' }), - } as Response); - - await expect(client.get('/not-found')).rejects.toThrow('HTTP error 404 Not Found'); - }); - - it('should handle 422 validation errors', async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 422, - statusText: 'Unprocessable Entity', - json: async () => ({ error: 'DailyLimitExceeded', message: 'Daily limit exceeded' }), - } as Response); - - try { - await client.post('/send', { to: 'test@example.com' }); - fail('Expected ValidationError to be thrown'); - } catch (error) { - expect(error).toHaveProperty('name', 'ValidationError'); - expect(error).toHaveProperty('statusCode', 422); - expect(error).toHaveProperty('errorType', 'DailyLimitExceeded'); - expect(error).toHaveProperty( - 'responseBody', - expect.objectContaining({ - error: 'DailyLimitExceeded', - message: 'Daily limit exceeded', - }) - ); - } - }); - - it('should handle 400 client errors', async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 400, - statusText: 'Bad Request', - json: async () => ({ error: 'InvalidRequest', message: 'Invalid request parameters' }), - } as Response); - - try { - await client.post('/send', { invalid: 'data' }); - fail('Expected ClientError to be thrown'); - } catch (error) { - expect(error).toHaveProperty('name', 'ClientError'); - expect(error).toHaveProperty('statusCode', 400); - expect(error).toHaveProperty( - 'responseBody', - expect.objectContaining({ - error: 'InvalidRequest', - message: 'Invalid request parameters', - }) - ); - } - }); - - it('should handle timeout errors', async () => { - const abortControllerMock = { - abort: jest.fn(), - signal: {}, - }; - - // @ts-expect-error - Mock implementation - global.AbortController = jest.fn(() => abortControllerMock); - - // Simulate timeout by rejecting with AbortError - const abortError = new Error('The operation was aborted'); - abortError.name = 'AbortError'; - mockFetch.mockRejectedValueOnce(abortError); - - await expect(client.get('/timeout')).rejects.toThrow('Request timeout after 30000ms'); - }); -}); diff --git a/src/client.ts b/src/client.ts deleted file mode 100644 index 6dc2977..0000000 --- a/src/client.ts +++ /dev/null @@ -1,282 +0,0 @@ -import { isNativeError } from 'node:util/types'; -import { version } from '../package.json'; -import { ClientError, HttpRequestError, TimeoutError, ValidationError } from './utils/errors'; - -/** - * Request configuration options - */ -export interface RequestConfig { - /** - * Request headers - */ - headers?: Record; - - /** - * Request timeout in milliseconds - */ - timeout?: number; - - /** - * Query parameters - */ - params?: Record; -} - -/** - * Configuration options for the Lettermint client - */ -export interface LettermintClientConfig { - /** - * API token for authentication - */ - apiToken: string; - - /** - * Base URL for the API (optional, defaults to https://api.lettermint.co/v1) - */ - baseUrl?: string; - - /** - * Timeout in milliseconds (optional, defaults to 30000) - */ - timeout?: number; - - /** - * Authentication surface for this client. - */ - authMode?: 'sending' | 'api'; -} - -/** - * Client for making HTTP requests to the Lettermint API - */ -export class LettermintClient { - private readonly baseUrl: string; - private readonly apiToken: string; - private readonly timeout: number; - private readonly defaultHeaders: Record; - private readonly authMode: 'sending' | 'api'; - - /** - * Create a new Lettermint client - * - * @param config Configuration options - */ - constructor(config: LettermintClientConfig) { - this.apiToken = config.apiToken; - this.baseUrl = config.baseUrl || 'https://api.lettermint.co/v1'; - this.timeout = config.timeout || 30000; - this.authMode = config.authMode || 'sending'; - - const nodeVersion = process.version.replace(/^v/, ''); - - this.defaultHeaders = { - 'Content-Type': 'application/json', - Accept: 'application/json', - 'User-Agent': `Lettermint/${version} (Node.js; Node ${nodeVersion})`, - }; - } - - /** - * Make a request to the API with timeout support - * - * @param url The full URL to request - * @param options Fetch options - * @returns Promise resolving to the response - */ - private async fetchWithTimeout(url: string, options: RequestInit): Promise { - const controller = new AbortController(); - const { signal } = controller; - - const timeoutId = setTimeout(() => controller.abort(), this.timeout); - - try { - const response = await fetch(url, { ...options, signal }); - clearTimeout(timeoutId); - - if (!response.ok) { - const responseBody = await response.json(); - - if (response.status === 422) { - const errorType = responseBody?.error || 'ValidationError'; - throw new ValidationError(`Validation error: ${errorType}`, errorType, responseBody); - } - - // Handle 400 client errors - if (response.status === 400) { - throw new ClientError( - `Client error: ${responseBody?.error || 'Unknown client error'}`, - responseBody - ); - } - - throw new HttpRequestError( - `HTTP error ${response.status} ${response.statusText}`, - response.status, - responseBody - ); - } - - return response; - } catch (error) { - clearTimeout(timeoutId); - - if (isNativeError(error) && error.name === 'AbortError') { - throw new TimeoutError(`Request timeout after ${this.timeout}ms`); - } - - throw error; - } - } - - /** - * Build the full URL including query parameters - * - * @param path API endpoint path - * @param params Query parameters - * @returns The full URL - */ - private buildUrl(path: string, params?: Record): string { - if (/^[a-z][a-z\d+\-.]*:/i.test(path) || path.startsWith('//')) { - throw new Error('Request path must be relative'); - } - - const pathSegment = path.startsWith('/') ? path.substring(1) : path; - const effectiveBaseUrl = this.baseUrl.endsWith('/') ? this.baseUrl : `${this.baseUrl}/`; - const url = new URL(pathSegment, effectiveBaseUrl); - - if (params) { - for (const [key, value] of Object.entries(params)) { - url.searchParams.append(key, value); - } - } - - return url.toString(); - } - - private buildHeaders(headers?: Record): Record { - const sanitizedHeaders = Object.fromEntries( - Object.entries(headers || {}).filter( - ([key]) => !['authorization', 'x-lettermint-token'].includes(key.toLowerCase()) - ) - ); - - return { - ...this.defaultHeaders, - ...sanitizedHeaders, - ...(this.authMode === 'api' - ? { Authorization: `Bearer ${this.apiToken}` } - : { 'x-lettermint-token': this.apiToken }), - }; - } - - /** - * Make a GET request to the API - * - * @param path API endpoint path - * @param config Optional request configuration - * @returns Promise resolving to the response data - */ - public async get(path: string, config?: RequestConfig): Promise { - const url = this.buildUrl(path, config?.params); - - const headers = this.buildHeaders(config?.headers); - - const response = await this.fetchWithTimeout(url, { - method: 'GET', - headers, - }); - - return response.json(); - } - - public async getRaw(path: string, config?: RequestConfig): Promise { - const url = this.buildUrl(path, config?.params); - const headers = this.buildHeaders(config?.headers); - - const response = await this.fetchWithTimeout(url, { - method: 'GET', - headers, - }); - - return response.text(); - } - - /** - * Make a POST request to the API - * - * @param path API endpoint path - * @param data Request payload - * @param config Optional request configuration - * @returns Promise resolving to the response data - */ - public async post(path: string, data?: unknown, config?: RequestConfig): Promise { - const url = this.buildUrl(path, config?.params); - - const headers = this.buildHeaders(config?.headers); - - const response = await this.fetchWithTimeout(url, { - method: 'POST', - headers, - body: data ? JSON.stringify(data) : undefined, - }); - - return response.json(); - } - - /** - * Make a PUT request to the API - * - * @param path API endpoint path - * @param data Request payload - * @param config Optional request configuration - * @returns Promise resolving to the response data - */ - public async put(path: string, data?: unknown, config?: RequestConfig): Promise { - const url = this.buildUrl(path, config?.params); - - const headers = this.buildHeaders(config?.headers); - - const response = await this.fetchWithTimeout(url, { - method: 'PUT', - headers, - body: data ? JSON.stringify(data) : undefined, - }); - - return response.json(); - } - - /** Make a PATCH request to the API. */ - public async patch(path: string, data?: unknown, config?: RequestConfig): Promise { - const url = this.buildUrl(path, config?.params); - const headers = this.buildHeaders(config?.headers); - const response = await this.fetchWithTimeout(url, { - method: 'PATCH', - headers, - body: data ? JSON.stringify(data) : undefined, - }); - - return response.json(); - } - - /** - * Make a DELETE request to the API - * - * @param path API endpoint path - * @param config Optional request configuration - * @returns Promise resolving to the response data - */ - public async delete(path: string, config?: RequestConfig): Promise { - const url = this.buildUrl(path, config?.params); - - const headers = this.buildHeaders(config?.headers); - - const response = await this.fetchWithTimeout(url, { - method: 'DELETE', - headers, - }); - - if (response.status === 204) return undefined as T; - return response.json(); - } -} diff --git a/src/emails.ts b/src/emails.ts new file mode 100644 index 0000000..3b704a1 --- /dev/null +++ b/src/emails.ts @@ -0,0 +1,368 @@ +import { type BinaryInput, bytesToBase64, isBinary, toBytes } from './encoding'; +import { LettermintValidationError } from './errors'; +import type { + MessageAttachmentInput, + MessageTagInput, + SandboxResult, + SendBatchMailResponse, + SendMailRequest, + SendMailRequestSettings, + SendMailResponse, +} from './generated/types'; +import { inspectCustom, renderInspect } from './redact'; +import type { IdempotentRequestOptions, RequestOptions, Transport } from './transport'; + +/** An attachment in an {@link EmailMessage}. `content` may be base64 text or raw bytes. */ +export interface EmailMessageAttachment extends Omit { + content: string | BinaryInput; +} + +/** + * An email, in the API's wire format (`reply_to`, `scheduled_at`, ...). + * Attachment content may also be raw bytes; the SDK base64-encodes it. + */ +export interface EmailMessage extends Omit { + attachments?: EmailMessageAttachment[]; +} + +/** An attachment for {@link EmailBuilder.attach}. */ +export interface EmailAttachment { + filename: string; + /** Base64-encoded text, or raw bytes (`Uint8Array`, `ArrayBuffer`). */ + content: string | BinaryInput; + /** MIME type, for example `application/pdf`. Detected by the API when omitted. */ + contentType?: string; + /** Content-ID for inline images referenced as `cid:` in the HTML. */ + contentId?: string; +} + +/** Options of `send()` and `sendBatch()`. */ +export type SendOptions = IdempotentRequestOptions; + +const TAG_NAME = /^[A-Za-z0-9_-]{1,32}$/; +const TAG_VALUE = /^[A-Za-z0-9_-]{1,64}$/; +const MAX_TAGS = 20; + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function fail(field: string, message: string): never { + throw new LettermintValidationError(message, field); +} + +function validateTags(tags: unknown, hasLegacyTag: boolean, field: string): void { + if (tags === undefined) return; + if (!Array.isArray(tags)) + fail(field, 'Message tags must be an array of { name, value } objects.'); + const maximum = hasLegacyTag ? MAX_TAGS - 1 : MAX_TAGS; + if (tags.length > maximum) { + fail( + field, + hasLegacyTag + ? `A legacy tag and no more than ${maximum} message tags are permitted.` + : `No more than ${maximum} message tags are permitted.` + ); + } + const names = new Set(); + for (const tag of tags as unknown[]) { + if (!isPlainObject(tag) || typeof tag.name !== 'string' || typeof tag.value !== 'string') { + fail(field, 'Message tags must be { name, value } objects with string values.'); + } + const { name, value } = tag as unknown as MessageTagInput; + if (!TAG_NAME.test(name)) fail(field, 'Message tag names must match ^[A-Za-z0-9_-]{1,32}$.'); + if (name.toLowerCase().startsWith('__lettermint')) { + fail(field, 'Message tag names must not start with __lettermint.'); + } + if (!TAG_VALUE.test(value)) fail(field, 'Message tag values must match ^[A-Za-z0-9_-]{1,64}$.'); + if (names.has(name)) fail(field, 'Message tag names must be unique (case-sensitive).'); + names.add(name); + } +} + +/** + * Checks what the SDK can check before a request: the message shape, tags and + * attachments. Shared by `emails.send()`, `emails.sendBatch()` and the builder. + */ +export function validateEmailMessage( + message: unknown, + prefix = '' +): asserts message is EmailMessage { + const at = (field: string) => (prefix ? `${prefix}.${field}` : field); + if (!isPlainObject(message)) fail(prefix || 'message', 'An email message must be an object.'); + const legacyTag = message.tag; + if (legacyTag !== undefined && legacyTag !== null && typeof legacyTag !== 'string') { + fail(at('tag'), 'The legacy tag must be a string.'); + } + validateTags(message.tags, typeof legacyTag === 'string' && legacyTag !== '', at('tags')); + const attachments = message.attachments; + if (attachments !== undefined) { + if (!Array.isArray(attachments)) fail(at('attachments'), 'Attachments must be an array.'); + attachments.forEach((attachment: unknown, index: number) => { + const field = `${at('attachments')}[${index}]`; + if ( + !isPlainObject(attachment) || + typeof attachment.filename !== 'string' || + !attachment.filename + ) { + fail(field, 'An attachment needs a filename.'); + } + if (typeof attachment.content !== 'string' && !isBinary(attachment.content)) { + fail(field, 'Attachment content must be a base64 string, a Uint8Array or an ArrayBuffer.'); + } + }); + } +} + +/** Returns a deep copy of the message in wire format, with binary attachment content base64-encoded. */ +function toWire(message: EmailMessage): SendMailRequest { + const copy = structuredCopy(message) as SendMailRequest; + if (message.attachments) { + copy.attachments = message.attachments.map((attachment) => ({ + ...attachment, + content: + typeof attachment.content === 'string' + ? attachment.content + : bytesToBase64(attachment.content), + })); + } + return copy; +} + +/** Copies plain data (objects, arrays, scalars and bytes). */ +function structuredCopy(value: unknown): unknown { + if (Array.isArray(value)) return value.map(structuredCopy); + if (isBinary(value)) return toBytes(value).slice(); + if (isPlainObject(value)) { + const result: Record = {}; + for (const [key, item] of Object.entries(value)) { + if (item !== undefined) result[key] = structuredCopy(item); + } + return result; + } + return value; +} + +type Sender = (message: EmailMessage, options?: SendOptions) => Promise; + +/** + * An immutable email builder, created by `lettermint.emails.compose()`. + * + * Every setter returns a new builder and leaves the current one unchanged, so a + * base builder can be shared and reused safely, also across concurrent sends. + * `to`, `cc`, `bcc` and `replyTo` replace their list; `attach` appends. + */ +export class EmailBuilder { + readonly #message: EmailMessage; + readonly #send: Sender; + + /** @internal Use `lettermint.emails.compose()`. */ + constructor(send: Sender, message: EmailMessage = { from: '', to: [], subject: '' }) { + this.#send = send; + this.#message = message; + } + + #with(changes: Partial): EmailBuilder { + const message = { ...this.#message, ...changes } as EmailMessage; + for (const key of Object.keys(changes) as (keyof EmailMessage)[]) { + if (changes[key] === undefined) delete message[key]; + } + validateEmailMessage(message); + return new EmailBuilder(this.#send, message); + } + + /** Sender, for example `Acme `. */ + from(address: string): EmailBuilder { + return this.#with({ from: address }); + } + + /** Replaces the recipients. */ + to(...addresses: string[]): EmailBuilder { + return this.#with({ to: [...addresses] }); + } + + /** Replaces the CC recipients. */ + cc(...addresses: string[]): EmailBuilder { + return this.#with({ cc: [...addresses] }); + } + + /** Replaces the BCC recipients. */ + bcc(...addresses: string[]): EmailBuilder { + return this.#with({ bcc: [...addresses] }); + } + + /** Replaces the Reply-To addresses. */ + replyTo(...addresses: string[]): EmailBuilder { + return this.#with({ reply_to: [...addresses] }); + } + + subject(subject: string): EmailBuilder { + return this.#with({ subject }); + } + + /** HTML body. `null` removes it. */ + html(html: string | null): EmailBuilder { + return this.#with({ html: html ?? undefined }); + } + + /** Plain-text body. `null` removes it. */ + text(text: string | null): EmailBuilder { + return this.#with({ text: text ?? undefined }); + } + + /** Replaces the custom email headers. */ + headers(headers: Record): EmailBuilder { + return this.#with({ headers: { ...headers } }); + } + + /** Replaces the metadata (tracked with the email, not added as headers). */ + metadata(metadata: Record): EmailBuilder { + return this.#with({ metadata: { ...metadata } }); + } + + /** The legacy single tag. `null` removes it. */ + tag(tag: string | null): EmailBuilder { + return this.#with({ tag: tag ?? undefined }); + } + + /** Replaces the name/value tags (up to 20, or 19 with a legacy `tag`). */ + tags(tags: MessageTagInput[]): EmailBuilder { + return this.#with({ tags: Array.isArray(tags) ? tags.map((tag) => ({ ...tag })) : tags }); + } + + /** The route slug to send through. */ + route(route: string): EmailBuilder { + return this.#with({ route }); + } + + /** Schedules delivery. Strings are passed through (ISO 8601 or English); dates are sent as ISO 8601. */ + scheduledAt(when: string | Date | null): EmailBuilder { + return this.#with({ + scheduled_at: when instanceof Date ? when.toISOString() : (when ?? undefined), + }); + } + + /** Per-email settings that override the route settings. */ + settings(settings: SendMailRequestSettings): EmailBuilder { + return this.#with({ settings: { ...settings } }); + } + + /** The result a Sandbox project simulates for every recipient. */ + sandboxResult(result: SandboxResult): EmailBuilder { + return this.#with({ sandbox_result: result }); + } + + /** Adds an attachment. */ + attach(attachment: EmailAttachment): EmailBuilder { + if (!isPlainObject(attachment)) { + fail( + 'attachments', + 'attach() takes an object: { filename, content, contentType?, contentId? }.' + ); + } + const { filename, content, contentType, contentId } = attachment; + const next: EmailMessageAttachment = { + filename, + content: isBinary(content) ? toBytes(content).slice() : content, + }; + if (contentType !== undefined) next.content_type = contentType; + if (contentId !== undefined) next.content_id = contentId; + return this.#with({ attachments: [...(this.#message.attachments ?? []), next] }); + } + + /** Returns a copy of the message in the API's wire format, with attachments base64-encoded. */ + build(): SendMailRequest { + return toWire(this.#message); + } + + /** Sends a snapshot of this email. The builder stays unchanged and can be sent again. */ + send(options?: SendOptions): Promise { + return this.#send(this.#message, options); + } + + /** The message in wire format. Contains no credentials. */ + toJSON(): SendMailRequest { + return this.build(); + } + + [inspectCustom](_depth: number, options: unknown, inspect: unknown): string { + return renderInspect('EmailBuilder', this.build(), options, inspect); + } +} + +/** + * Sends email with the project sending token (`x-lettermint-token`). + * Holds no message state: every call sends exactly what it is given. + */ +export class Emails { + readonly #transport: Transport; + + /** @internal */ + constructor(transport: Transport) { + this.#transport = transport; + } + + /** Sends one email. */ + async send(message: EmailMessage, options?: SendOptions): Promise { + this.#transport.assertAuth('emails.send', 'sending'); + validateEmailMessage(message); + return this.#transport.call('POST /send', { + label: 'emails.send', + body: toWire(message), + idempotencyKey: options?.idempotencyKey, + options, + }); + } + + /** Sends up to 500 emails in one request. Accepts messages and builders. */ + async sendBatch( + messages: (EmailMessage | EmailBuilder)[], + options?: SendOptions + ): Promise { + this.#transport.assertAuth('emails.sendBatch', 'sending'); + if (!Array.isArray(messages)) fail('messages', 'sendBatch() takes an array of messages.'); + const body = messages.map((item, index) => { + if (item instanceof EmailBuilder) return item.build(); + validateEmailMessage(item, `messages[${index}]`); + return toWire(item); + }); + return this.#transport.call('POST /send/batch', { + label: 'emails.sendBatch', + body, + idempotencyKey: options?.idempotencyKey, + options, + }); + } + + /** + * Starts an immutable email builder. Every setter returns a new builder. + * Pass a message to start from it. + */ + compose(message?: EmailMessage): EmailBuilder { + this.#transport.assertAuth('emails.compose', 'sending'); + let initial: EmailMessage | undefined; + if (message !== undefined) { + validateEmailMessage(message); + initial = structuredCopy(message) as EmailMessage; + } + return new EmailBuilder((snapshot, options) => this.send(snapshot, options), initial); + } + + /** Checks the sending token: `GET /ping` returns `pong`. */ + async ping(options?: RequestOptions): Promise { + const text = await this.#transport.call('GET /ping', { + label: 'emails.ping', + auth: 'sending', + options, + }); + return text.trim(); + } + + toJSON(): Record { + return {}; + } + + [inspectCustom](_depth: number, options: unknown, inspect: unknown): string { + return renderInspect('Emails', {}, options, inspect); + } +} diff --git a/src/encoding.ts b/src/encoding.ts new file mode 100644 index 0000000..c47198f --- /dev/null +++ b/src/encoding.ts @@ -0,0 +1,34 @@ +/** Binary input accepted for attachments and webhook bodies. */ +export type BinaryInput = Uint8Array | ArrayBuffer | ArrayBufferView; + +// Tag checks instead of `instanceof`, so values from another realm (iframes, vm contexts) work too. +function isArrayBuffer(value: unknown): value is ArrayBuffer { + return Object.prototype.toString.call(value) === '[object ArrayBuffer]'; +} + +export function isBinary(value: unknown): value is BinaryInput { + return isArrayBuffer(value) || ArrayBuffer.isView(value); +} + +export function toBytes(value: BinaryInput): Uint8Array { + if (isArrayBuffer(value)) return new Uint8Array(value); + if (value instanceof Uint8Array) return value; + return new Uint8Array(value.buffer, value.byteOffset, value.byteLength); +} + +/** Base64-encodes bytes with web-standard APIs only, so it runs in every runtime. */ +export function bytesToBase64(input: BinaryInput): string { + const bytes = toBytes(input); + const native = (bytes as Uint8Array & { toBase64?: () => string }).toBase64; + if (typeof native === 'function') return native.call(bytes); + let binary = ''; + const chunk = 0x8000; + for (let offset = 0; offset < bytes.length; offset += chunk) { + binary += String.fromCharCode(...bytes.subarray(offset, offset + chunk)); + } + return btoa(binary); +} + +export function utf8(text: string): Uint8Array { + return new TextEncoder().encode(text); +} diff --git a/src/endpoints/api.ts b/src/endpoints/api.ts deleted file mode 100644 index f614033..0000000 --- a/src/endpoints/api.ts +++ /dev/null @@ -1,283 +0,0 @@ -import type * as Types from '../types'; -import { Endpoint } from './endpoint'; - -export type QueryParams = Record; - -export class DomainsEndpoint extends Endpoint { - public list(params?: QueryParams): Promise { - return this.httpClient.get('/domains', { params }); - } - - public create(payload: Types.DomainStoreRequest): Promise { - return this.httpClient.post('/domains', payload); - } - - public retrieve(domainId: string): Promise { - return this.httpClient.get(`/domains/${this.pathSegment(domainId)}`); - } - - public delete(domainId: string): Promise { - return this.httpClient.delete(`/domains/${this.pathSegment(domainId)}`); - } - - public verifyDnsRecords(domainId: string): Promise { - return this.httpClient.post(`/domains/${this.pathSegment(domainId)}/dns-records/verify`); - } - - public verifyDnsRecord( - domainId: string, - recordId: string - ): Promise { - return this.httpClient.post( - `/domains/${this.pathSegment(domainId)}/dns-records/${this.pathSegment(recordId)}/verify` - ); - } - - public updateProjects( - domainId: string, - payload: Types.DomainUpdateProjectsRequest - ): Promise { - return this.httpClient.put(`/domains/${this.pathSegment(domainId)}/projects`, payload); - } -} - -export class MessagesEndpoint extends Endpoint { - public list(params?: QueryParams): Promise { - return this.httpClient.get('/messages', { params }); - } - - public retrieve(messageId: string): Promise { - return this.httpClient.get(`/messages/${this.pathSegment(messageId)}`); - } - - public reschedule( - messageId: string, - payload: Types.RescheduleMessageRequest - ): Promise { - return this.httpClient.patch(`/messages/${this.pathSegment(messageId)}`, payload); - } - - public cancel(messageId: string): Promise { - return this.httpClient.post(`/messages/${this.pathSegment(messageId)}/cancel`); - } - - public process(messageId: string): Promise { - return this.httpClient.post(`/messages/${this.pathSegment(messageId)}/process`); - } - - public events(messageId: string): Promise { - return this.httpClient.get(`/messages/${this.pathSegment(messageId)}/events`); - } - - public source(messageId: string): Promise { - return this.httpClient.getRaw(`/messages/${this.pathSegment(messageId)}/source`); - } - - public html(messageId: string): Promise { - return this.httpClient.getRaw(`/messages/${this.pathSegment(messageId)}/html`); - } - - public text(messageId: string): Promise { - return this.httpClient.getRaw(`/messages/${this.pathSegment(messageId)}/text`); - } -} - -export class ProjectsEndpoint extends Endpoint { - public async retrieveReportForwarding( - projectId: string - ): Promise { - return this.httpClient.get(`/projects/${this.pathSegment(projectId)}/report-forwarding`); - } - - public async updateReportForwarding( - projectId: string, - payload: Types.UpdateReportForwardingRequest - ): Promise { - return this.httpClient.put( - `/projects/${this.pathSegment(projectId)}/report-forwarding`, - payload - ); - } - - public async deleteReportForwarding(projectId: string): Promise { - await this.httpClient.delete(`/projects/${this.pathSegment(projectId)}/report-forwarding`); - } - - public async verifyReportForwarding( - projectId: string, - payload: Types.VerifyReportForwardingRequest - ): Promise { - return this.httpClient.post( - `/projects/${this.pathSegment(projectId)}/report-forwarding/verify`, - payload - ); - } - - public async resendReportForwardingCode( - projectId: string - ): Promise { - return this.httpClient.post( - `/projects/${this.pathSegment(projectId)}/report-forwarding/resend-code` - ); - } - public list(params?: QueryParams): Promise { - return this.httpClient.get('/projects', { params }); - } - - public create(payload: Types.ProjectStoreRequest): Promise { - return this.httpClient.post('/projects', payload); - } - - public retrieve(projectId: string): Promise { - return this.httpClient.get(`/projects/${this.pathSegment(projectId)}`); - } - - public update( - projectId: string, - payload: Types.ProjectUpdateRequest - ): Promise { - return this.httpClient.put(`/projects/${this.pathSegment(projectId)}`, payload); - } - - public delete(projectId: string): Promise { - return this.httpClient.delete(`/projects/${this.pathSegment(projectId)}`); - } - - public rotateToken(projectId: string): Promise { - return this.httpClient.post(`/projects/${this.pathSegment(projectId)}/rotate-token`); - } - - public routes(projectId: string, params?: QueryParams): Promise { - return this.httpClient.get(`/projects/${this.pathSegment(projectId)}/routes`, { params }); - } - - public createRoute( - projectId: string, - payload: Types.RouteStoreRequest - ): Promise { - return this.httpClient.post(`/projects/${this.pathSegment(projectId)}/routes`, payload); - } -} - -export class RoutesEndpoint extends Endpoint { - public retrieve(routeId: string): Promise { - return this.httpClient.get(`/routes/${this.pathSegment(routeId)}`); - } - - public update( - routeId: string, - payload: Types.RouteUpdateRequest - ): Promise { - return this.httpClient.put(`/routes/${this.pathSegment(routeId)}`, payload); - } - - public delete(routeId: string): Promise { - return this.httpClient.delete(`/routes/${this.pathSegment(routeId)}`); - } - - public verifyInboundDomain(routeId: string): Promise { - return this.httpClient.post(`/routes/${this.pathSegment(routeId)}/verify-inbound-domain`); - } -} - -export class StatsEndpoint extends Endpoint { - public retrieve(params?: QueryParams): Promise { - return this.httpClient.get('/stats', { params }); - } -} - -export class SuppressionsEndpoint extends Endpoint { - public list(params?: QueryParams): Promise { - return this.httpClient.get('/suppressions', { params }); - } - - public create(payload: Types.SuppressionStoreRequest): Promise { - return this.httpClient.post('/suppressions', payload); - } - - public delete(suppressionId: string): Promise { - return this.httpClient.delete(`/suppressions/${this.pathSegment(suppressionId)}`); - } -} - -export class TeamEndpoint extends Endpoint { - public retrieve(): Promise { - return this.httpClient.get('/team'); - } - - public update(payload: Types.TeamUpdateRequest): Promise { - return this.httpClient.put('/team', payload); - } - - public usage(params?: QueryParams): Promise { - return this.httpClient.get('/team/usage', { params }); - } - - public roles(): Promise { - return this.httpClient.get('/team/roles'); - } - - public members(params?: QueryParams): Promise { - return this.httpClient.get('/team/members', { params }); - } - - public member(userId: string): Promise { - return this.httpClient.get(`/team/members/${this.pathSegment(userId)}`); - } - - public updateMemberAssignment( - userId: string, - payload: Types.TeamMembersAssignmentUpdateRequest - ): Promise { - return this.httpClient.put(`/team/members/${this.pathSegment(userId)}/assignment`, payload); - } -} - -export class WebhooksEndpoint extends Endpoint { - public list(params?: QueryParams): Promise { - return this.httpClient.get('/webhooks', { params }); - } - - public create(payload: Types.WebhookStoreRequest): Promise { - return this.httpClient.post('/webhooks', payload); - } - - public retrieve(webhookId: string): Promise { - return this.httpClient.get(`/webhooks/${this.pathSegment(webhookId)}`); - } - - public update( - webhookId: string, - payload: Types.WebhookUpdateRequest - ): Promise { - return this.httpClient.put(`/webhooks/${this.pathSegment(webhookId)}`, payload); - } - - public delete(webhookId: string): Promise { - return this.httpClient.delete(`/webhooks/${this.pathSegment(webhookId)}`); - } - - public test(webhookId: string): Promise { - return this.httpClient.post(`/webhooks/${this.pathSegment(webhookId)}/test`); - } - - public regenerateSecret(webhookId: string): Promise { - return this.httpClient.post(`/webhooks/${this.pathSegment(webhookId)}/regenerate-secret`); - } - - public deliveries( - webhookId: string, - params?: QueryParams - ): Promise { - return this.httpClient.get(`/webhooks/${this.pathSegment(webhookId)}/deliveries`, { params }); - } - - public delivery( - webhookId: string, - deliveryId: string - ): Promise { - return this.httpClient.get( - `/webhooks/${this.pathSegment(webhookId)}/deliveries/${this.pathSegment(deliveryId)}` - ); - } -} diff --git a/src/endpoints/email.spec.ts b/src/endpoints/email.spec.ts deleted file mode 100644 index 23585f2..0000000 --- a/src/endpoints/email.spec.ts +++ /dev/null @@ -1,499 +0,0 @@ -import { LettermintClient } from '../client'; -import { EmailEndpoint } from './email'; - -jest.mock('../client'); - -describe('EmailEndpoint', () => { - let client: jest.Mocked; - let emailEndpoint: EmailEndpoint; - - beforeEach(() => { - client = new LettermintClient({ apiToken: 'test-api-key' }) as jest.Mocked; - - emailEndpoint = new EmailEndpoint(client); - - client.post = jest.fn().mockResolvedValue({ - message_id: 'test-message-id', - status: 'queued', - }); - }); - - it('should set the from address', () => { - const result = emailEndpoint.from('test@example.com'); - - // Verify fluent interface returns this - expect(result).toBe(emailEndpoint); - - // Send the email to trigger the API call - return emailEndpoint.send().then(() => { - // Verify the payload contains the from address - expect(client.post).toHaveBeenCalledWith( - '/send', - expect.objectContaining({ - from: 'test@example.com', - }), - undefined - ); - }); - }); - - it('should set the to addresses', () => { - const result = emailEndpoint.to('recipient1@example.com', 'recipient2@example.com'); - - expect(result).toBe(emailEndpoint); - - return emailEndpoint.send().then(() => { - expect(client.post).toHaveBeenCalledWith( - '/send', - expect.objectContaining({ - to: ['recipient1@example.com', 'recipient2@example.com'], - }), - undefined - ); - }); - }); - - it('should set the subject', () => { - const result = emailEndpoint.subject('Test Subject'); - - expect(result).toBe(emailEndpoint); - - return emailEndpoint.send().then(() => { - expect(client.post).toHaveBeenCalledWith( - '/send', - expect.objectContaining({ - subject: 'Test Subject', - }), - undefined - ); - }); - }); - - it('should set the HTML content', () => { - const result = emailEndpoint.html('

Test HTML

'); - - expect(result).toBe(emailEndpoint); - - return emailEndpoint.send().then(() => { - expect(client.post).toHaveBeenCalledWith( - '/send', - expect.objectContaining({ - html: '

Test HTML

', - }), - undefined - ); - }); - }); - - it('should set the text content', () => { - const result = emailEndpoint.text('Test text'); - - expect(result).toBe(emailEndpoint); - - return emailEndpoint.send().then(() => { - expect(client.post).toHaveBeenCalledWith( - '/send', - expect.objectContaining({ - text: 'Test text', - }), - undefined - ); - }); - }); - - it('should set the CC addresses', () => { - const result = emailEndpoint.cc('cc1@example.com', 'cc2@example.com'); - - expect(result).toBe(emailEndpoint); - - return emailEndpoint.send().then(() => { - expect(client.post).toHaveBeenCalledWith( - '/send', - expect.objectContaining({ - cc: ['cc1@example.com', 'cc2@example.com'], - }), - undefined - ); - }); - }); - - it('should set the BCC addresses', () => { - const result = emailEndpoint.bcc('bcc1@example.com', 'bcc2@example.com'); - - expect(result).toBe(emailEndpoint); - - return emailEndpoint.send().then(() => { - expect(client.post).toHaveBeenCalledWith( - '/send', - expect.objectContaining({ - bcc: ['bcc1@example.com', 'bcc2@example.com'], - }), - undefined - ); - }); - }); - - it('should set the reply-to addresses', () => { - const result = emailEndpoint.replyTo('reply1@example.com', 'reply2@example.com'); - - expect(result).toBe(emailEndpoint); - - return emailEndpoint.send().then(() => { - expect(client.post).toHaveBeenCalledWith( - '/send', - expect.objectContaining({ - reply_to: ['reply1@example.com', 'reply2@example.com'], - }), - undefined - ); - }); - }); - - it('should set custom headers', () => { - const headers = { 'X-Custom': 'Value', 'X-Another': 'Another Value' }; - const result = emailEndpoint.headers(headers); - - expect(result).toBe(emailEndpoint); - - return emailEndpoint.send().then(() => { - expect(client.post).toHaveBeenCalledWith( - '/send', - expect.objectContaining({ - headers, - }), - undefined - ); - }); - }); - - it('should add attachments', () => { - const result = emailEndpoint.attach('test.txt', 'base64content'); - - expect(result).toBe(emailEndpoint); - - return emailEndpoint.send().then(() => { - expect(client.post).toHaveBeenCalledWith( - '/send', - expect.objectContaining({ - attachments: [ - { - filename: 'test.txt', - content: 'base64content', - }, - ], - }), - undefined - ); - }); - }); - - it('should add attachments with content_id', () => { - const result = emailEndpoint.attach('image.png', 'base64imagedata', 'logo'); - - expect(result).toBe(emailEndpoint); - - return emailEndpoint.send().then(() => { - expect(client.post).toHaveBeenCalledWith( - '/send', - expect.objectContaining({ - attachments: [ - { - filename: 'image.png', - content: 'base64imagedata', - content_id: 'logo', - }, - ], - }), - undefined - ); - }); - }); - - it('should add attachments with content_id and content_type', () => { - const result = emailEndpoint.attach('invite.ics', 'base64calendar', 'invite', 'text/calendar'); - - expect(result).toBe(emailEndpoint); - - return emailEndpoint.send().then(() => { - expect(client.post).toHaveBeenCalledWith( - '/send', - expect.objectContaining({ - attachments: [ - { - filename: 'invite.ics', - content: 'base64calendar', - content_id: 'invite', - content_type: 'text/calendar', - }, - ], - }), - undefined - ); - }); - }); - - it('should keep scheduled delivery, settings, and reusable tags', () => { - const settings = { - track_opens: false, - track_clicks: true, - tls: 'enforced' as const, - }; - const tags = [{ name: 'campaign', value: 'welcome-v2' }]; - - expect(emailEndpoint.scheduledAt('2026-10-01T09:00:00Z')).toBe(emailEndpoint); - expect(emailEndpoint.settings(settings)).toBe(emailEndpoint); - expect(emailEndpoint.tags(tags)).toBe(emailEndpoint); - - return emailEndpoint.send().then(() => { - expect(client.post).toHaveBeenCalledWith( - '/send', - expect.objectContaining({ - scheduled_at: '2026-10-01T09:00:00Z', - settings, - tags, - }), - undefined - ); - }); - }); - - it.each([ - [ - [ - { name: 'duplicate', value: 'one' }, - { name: 'duplicate', value: 'two' }, - ], - ], - [[{ name: '__LETTERMint_internal', value: 'one' }]], - [[{ name: 'invalid name', value: 'one' }]], - [[{ name: 'valid', value: 'invalid value' }]], - ])('should reject invalid reusable tags', (tags) => { - expect(() => emailEndpoint.tags(tags)).toThrow(TypeError); - }); - - it('should set the route', () => { - const result = emailEndpoint.route('test-route'); - - expect(result).toBe(emailEndpoint); - - return emailEndpoint.send().then(() => { - expect(client.post).toHaveBeenCalledWith( - '/send', - expect.objectContaining({ - route: 'test-route', - }), - undefined - ); - }); - }); - - it('should set metadata', () => { - const metadata = { foo: 'bar', fizz: 'buzz' }; - const result = emailEndpoint.metadata(metadata); - - expect(result).toBe(emailEndpoint); - - return emailEndpoint.send().then(() => { - expect(client.post).toHaveBeenCalledWith( - '/send', - expect.objectContaining({ - metadata, - }), - undefined - ); - }); - }); - - it('should set tag', () => { - const tag = 'campaign-123'; - const result = emailEndpoint.tag(tag); - - expect(result).toBe(emailEndpoint); - - return emailEndpoint.send().then(() => { - expect(client.post).toHaveBeenCalledWith( - '/send', - expect.objectContaining({ - tag, - }), - undefined - ); - }); - }); - - it('should set the Sandbox result', () => { - const result = emailEndpoint.sandboxResult('hard_bounced'); - - expect(result).toBe(emailEndpoint); - - return emailEndpoint.send().then(() => { - expect(client.post).toHaveBeenCalledWith( - '/send', - expect.objectContaining({ - sandbox_result: 'hard_bounced', - }), - undefined - ); - }); - }); - - it('should send the email with all options', async () => { - // Set up a complete email - emailEndpoint - .from('sender@example.com') - .to('recipient@example.com') - .subject('Test Subject') - .html('

HTML Content

') - .text('Text content') - .cc('cc@example.com') - .bcc('bcc@example.com') - .replyTo('reply@example.com') - .headers({ 'X-Custom': 'Value' }) - .attach('test.txt', 'base64content') - .route('test-route') - .metadata({ - foo: 'bar', - }) - .tag('campaign-123'); - - // Send the email - const response = await emailEndpoint.send(); - - // Verify the response - expect(response).toEqual({ - message_id: 'test-message-id', - status: 'queued', - }); - - // Verify the complete payload - expect(client.post).toHaveBeenCalledWith( - '/send', - { - from: 'sender@example.com', - to: ['recipient@example.com'], - subject: 'Test Subject', - html: '

HTML Content

', - text: 'Text content', - cc: ['cc@example.com'], - bcc: ['bcc@example.com'], - reply_to: ['reply@example.com'], - headers: { 'X-Custom': 'Value' }, - metadata: { - foo: 'bar', - }, - attachments: [ - { - filename: 'test.txt', - content: 'base64content', - }, - ], - route: 'test-route', - tag: 'campaign-123', - }, - undefined - ); - }); - - it('should reset all payload properties after send', async () => { - // First email with all properties set - await emailEndpoint - .from('sender@example.com') - .to('recipient@example.com') - .subject('First Subject') - .html('

First

') - .text('First') - .cc('cc@example.com') - .bcc('bcc@example.com') - .replyTo('reply@example.com') - .headers({ 'X-Custom': 'Value' }) - .attach('file.txt', 'base64content') - .route('first-route') - .metadata({ key: 'value' }) - .tag('first-tag') - .idempotencyKey('key-1') - .send(); - - // Second email with only required properties - await emailEndpoint - .from('other@example.com') - .to('other-recipient@example.com') - .subject('Second Subject') - .text('Second') - .send(); - - // Verify second email does NOT contain any properties from the first - expect(client.post).toHaveBeenLastCalledWith( - '/send', - { - from: 'other@example.com', - to: ['other-recipient@example.com'], - subject: 'Second Subject', - text: 'Second', - }, - undefined - ); - }); - - it('should not accumulate attachments across sends', async () => { - await emailEndpoint - .from('sender@example.com') - .to('recipient@example.com') - .subject('First') - .text('First') - .attach('file1.txt', 'content1') - .send(); - - await emailEndpoint - .from('sender@example.com') - .to('recipient@example.com') - .subject('Second') - .text('Second') - .attach('file2.txt', 'content2') - .send(); - - expect(client.post).toHaveBeenLastCalledWith( - '/send', - expect.objectContaining({ - attachments: [{ filename: 'file2.txt', content: 'content2' }], - }), - undefined - ); - }); - - it('should set the idempotency key in the request headers', async () => { - // Set up a basic email with an idempotency key - emailEndpoint - .from('sender@example.com') - .to('recipient@example.com') - .subject('Test Subject') - .idempotencyKey('unique-id-123'); - - // Send the email - await emailEndpoint.send(); - - // Verify the idempotency key is included in the request config - expect(client.post).toHaveBeenCalledWith('/send', expect.any(Object), { - headers: { - 'Idempotency-Key': 'unique-id-123', - }, - }); - }); - - it('should set and reset the batch idempotency key', async () => { - const payload = [ - { - from: 'sender@example.com', - to: ['recipient@example.com'], - subject: 'Test Subject', - }, - ]; - - await emailEndpoint.idempotencyKey('batch-key').sendBatch(payload); - await emailEndpoint.sendBatch(payload); - - expect(client.post).toHaveBeenNthCalledWith(1, '/send/batch', payload, { - headers: { 'Idempotency-Key': 'batch-key' }, - }); - expect(client.post).toHaveBeenNthCalledWith(2, '/send/batch', payload, undefined); - }); -}); diff --git a/src/endpoints/email.ts b/src/endpoints/email.ts deleted file mode 100644 index 8105e72..0000000 --- a/src/endpoints/email.ts +++ /dev/null @@ -1,332 +0,0 @@ -import type { - EmailPayload, - SandboxResult, - SendBatchEmailResponse, - SendBatchMailRequest, - SendEmailResponse, -} from '../types'; -import { Endpoint } from './endpoint'; - -/** - * Endpoint for sending emails - */ -export class EmailEndpoint extends Endpoint { - /** - * The email payload to be sent - */ - private payload: EmailPayload = { - from: '', - to: [], - subject: '', - }; - - /** - * The idempotency key for the request - */ - private idempotencyKeyValue?: string; - - /** - * Set custom headers for the email - * - * @example headers({ 'X-Custom': 'Value' }) - * - * @param headers The custom headers - * @returns The current instance for chaining - */ - public headers(headers: Record): this { - this.payload.headers = headers; - return this; - } - - /** - * Set the idempotency key for the request - * - * This helps prevent duplicate email sends when retrying failed requests. - * If you provide the same idempotency key for multiple requests, only the first one will be processed. - * - * @example idempotencyKey('unique-id-123') - * - * @param key A unique string to identify this request - * @returns The current instance for chaining - */ - public idempotencyKey(key: string): this { - this.idempotencyKeyValue = key; - return this; - } - - /** - * Set the sender email address - * - * Supports RFC 5322 addresses, e.g. , <>. - * - * @example from('John Doe ') - * @example from('john@example.com') - * - * @param email The sender's email address - * @returns The current instance for chaining - */ - public from(email: string): this { - this.payload.from = email; - return this; - } - - /** - * Set one or more recipient email addresses - * - * @example to('user1@example.com', 'user2@example.com') - * - * @param emails One or more recipient email addresses - * @returns The current instance for chaining - */ - public to(...emails: string[]): this { - this.payload.to = emails; - return this; - } - - /** - * Set the subject of the email - * - * @param subject The subject line - * @returns The current instance for chaining - */ - public subject(subject: string): this { - this.payload.subject = subject; - return this; - } - - /** Set the requested delivery time for the email. */ - public scheduledAt(scheduledAt: string): this { - this.payload.scheduled_at = scheduledAt; - return this; - } - - /** - * Set the HTML body of the email - * - * @param html The HTML content for the email body - * @returns The current instance for chaining - */ - public html(html: string | null): this { - if (html !== null) { - this.payload.html = html; - } - return this; - } - - /** - * Set the plain text body of the email - * - * @param text The plain text content for the email body - * @returns The current instance for chaining - */ - public text(text: string | null): this { - if (text !== null) { - this.payload.text = text; - } - return this; - } - - /** - * Set one or more CC email addresses - * - * @example cc('cc1@example.com', 'cc2@example.com') - * - * @param emails Email addresses to be CC'd - * @returns The current instance for chaining - */ - public cc(...emails: string[]): this { - this.payload.cc = emails; - return this; - } - - /** - * Set one or more BCC email addresses - * - * @example bcc('bcc1@example.com', 'bcc2@example.com') - * - * @param emails Email addresses to be BCC'd - * @returns The current instance for chaining - */ - public bcc(...emails: string[]): this { - this.payload.bcc = emails; - return this; - } - - /** - * Set one or more Reply-To email addresses - * - * @example replyTo('reply1@example.com', 'reply2@example.com') - * - * @param emails Reply-To email addresses - * @returns The current instance for chaining - */ - public replyTo(...emails: string[]): this { - this.payload.reply_to = emails; - return this; - } - - /** - * Set the routing key for the email - * - * @param route The routing key - * @returns The current instance for chaining - */ - public route(route: string): this { - this.payload.route = route; - return this; - } - - /** - * Attach a file to the email - * - * @param filename The attachment filename - * @param content The base64-encoded file content - * @param content_id The Content-ID for inline attachments (optional) - * @param content_type The MIME type for the attachment (optional) - * @returns The current instance for chaining - */ - public attach( - filename: string, - content: string, - content_id?: string, - content_type?: string - ): this { - if (!this.payload.attachments) { - this.payload.attachments = []; - } - - this.payload.attachments.push({ - filename, - content, - ...(content_id && { content_id }), - ...(content_type && { content_type }), - }); - - return this; - } - - /** - * Set per-email delivery and tracking settings - * - * @param settings Settings that override the selected route for this email - * @returns The current instance for chaining - */ - public settings(settings: NonNullable): this { - this.payload.settings = settings; - return this; - } - - /** - * Set metadata for the email - * - * @example metadata({ 'custom': 'value' }) - * - * @param metadata The metadata object - * @returns The current instance for chaining - */ - public metadata(metadata: Record): this { - this.payload.metadata = metadata; - return this; - } - - /** - * Set the tag for the email - * - * @example tag('campaign-123') - * - * @param key A string to categorize the email - * @returns The current instance for chaining - */ - public tag(tag: string): this { - if ((this.payload.tags?.length ?? 0) >= 20) { - throw new TypeError('A legacy tag and no more than 19 message tags are permitted'); - } - this.payload.tag = tag; - return this; - } - - /** Set reusable name-value tags for the email. */ - public tags(tags: NonNullable): this { - const maximum = this.payload.tag ? 19 : 20; - if (tags.length > maximum) { - throw new TypeError(`No more than ${maximum} message tags are permitted`); - } - - const names = new Set(); - for (const tag of tags) { - if (!/^[A-Za-z0-9_-]{1,32}$/.test(tag.name)) { - throw new TypeError('Message tag names must match ^[A-Za-z0-9_-]{1,32}$'); - } - if (tag.name.toLowerCase().startsWith('__lettermint')) { - throw new TypeError('Message tag names must not start with __lettermint'); - } - if (!/^[A-Za-z0-9_-]{1,64}$/.test(tag.value)) { - throw new TypeError('Message tag values must match ^[A-Za-z0-9_-]{1,64}$'); - } - if (names.has(tag.name)) { - throw new TypeError('Message tag names must be unique and case-sensitive'); - } - names.add(tag.name); - } - - this.payload.tags = tags; - return this; - } - - /** - * Select the simulated result for a Sandbox project. - * - * @param result The result to simulate for every recipient - * @returns The current instance for chaining - */ - public sandboxResult(result: SandboxResult): this { - this.payload.sandbox_result = result; - return this; - } - - /** - * Send the composed email using the current payload - * - * @returns Promise resolving to the API response - * @throws Error on HTTP or API failure - */ - public async send(): Promise { - const config = this.idempotencyKeyValue - ? { headers: { 'Idempotency-Key': this.idempotencyKeyValue } } - : undefined; - - try { - return await this.httpClient.post('/send', this.payload, config); - } finally { - this.reset(); - } - } - - public async sendBatch(payload: SendBatchMailRequest): Promise { - const config = this.idempotencyKeyValue - ? { headers: { 'Idempotency-Key': this.idempotencyKeyValue } } - : undefined; - - try { - return await this.httpClient.post('/send/batch', payload, config); - } finally { - this.reset(); - } - } - - public async ping(): Promise { - return (await this.httpClient.getRaw('/ping')).trim(); - } - - /** - * Reset the payload and idempotency key to their initial state - */ - private reset(): void { - this.payload = { - from: '', - to: [], - subject: '', - }; - this.idempotencyKeyValue = undefined; - } -} diff --git a/src/endpoints/endpoint.ts b/src/endpoints/endpoint.ts deleted file mode 100644 index 8529848..0000000 --- a/src/endpoints/endpoint.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { LettermintClient } from '../client'; - -/** - * Base class for all API endpoints - */ -export abstract class Endpoint { - /** - * The HTTP client used to make API requests - */ - protected readonly httpClient: LettermintClient; - - /** - * Create a new endpoint instance - * - * @param client The Lettermint client - */ - constructor(client: LettermintClient) { - this.httpClient = client; - } - - protected pathSegment(value: string): string { - return encodeURIComponent(value); - } -} diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..02804fb --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,220 @@ +/** + * Error classes of the Lettermint SDK. + * + * Every class sets an explicit `name`, so `error.name` stays readable in logs + * and bundles. No error carries request headers or API tokens. + */ + +/** Base class of every error the SDK throws. */ +export class LettermintError extends Error { + constructor(message: string, options?: { cause?: unknown }) { + super(message, options); + this.name = 'LettermintError'; + } +} + +/** + * The client was configured or called incorrectly: a missing or unrecognised + * token, a token that the called method cannot use, an invalid option or an + * invalid path parameter. Thrown before any request is made. + */ +export class LettermintConfigError extends LettermintError { + constructor(message: string) { + super(message); + this.name = 'LettermintConfigError'; + } +} + +/** + * The SDK rejected a request before sending it, for example invalid message + * tags. Unlike {@link ValidationError}, the API never saw this request. + */ +export class LettermintValidationError extends LettermintError { + /** The offending field, for example `tags` or `messages[2].tags`. */ + public readonly field: string | undefined; + + constructor(message: string, field?: string) { + super(message); + this.name = 'LettermintValidationError'; + this.field = field; + } +} + +/** Fields of an {@link ApiError}. */ +export interface ApiErrorInit { + status: number; + message: string; + code?: string; + details?: unknown; + body?: unknown; +} + +/** + * The API answered with an error status (4xx or 5xx) and a JSON or empty body. + * Subclasses cover the common statuses. + */ +export class ApiError extends LettermintError { + /** The HTTP status code. */ + public readonly status: number; + /** Machine-readable error code from `{ error: { code } }`, if the API sent one. */ + public readonly code: string | undefined; + /** Additional context from `{ error: { details } }`, if the API sent any. */ + public readonly details: unknown; + /** The decoded JSON error body, or `undefined` for an empty body. */ + public readonly body: unknown; + + constructor(init: ApiErrorInit) { + super(init.message); + this.name = 'ApiError'; + this.status = init.status; + this.code = init.code; + this.details = init.details; + this.body = init.body; + } +} + +/** HTTP 401: the token is missing, invalid or revoked. */ +export class AuthenticationError extends ApiError { + constructor(init: ApiErrorInit) { + super(init); + this.name = 'AuthenticationError'; + } +} + +/** HTTP 403: the token may not perform this action, or the plan lacks the feature. */ +export class PermissionError extends ApiError { + constructor(init: ApiErrorInit) { + super(init); + this.name = 'PermissionError'; + } +} + +/** HTTP 404: the resource does not exist or is not visible to the token. */ +export class NotFoundError extends ApiError { + constructor(init: ApiErrorInit) { + super(init); + this.name = 'NotFoundError'; + } +} + +/** HTTP 409: the request conflicts with the current state, for example an Idempotency-Key reused with a different body. */ +export class ConflictError extends ApiError { + constructor(init: ApiErrorInit) { + super(init); + this.name = 'ConflictError'; + } +} + +/** HTTP 422: the API rejected the request data. */ +export class ValidationError extends ApiError { + /** Field errors from the `{ message, errors }` body, when the API sent them. */ + public readonly errors: Record | undefined; + + constructor(init: ApiErrorInit & { errors?: Record }) { + super(init); + this.name = 'ValidationError'; + this.errors = init.errors; + } +} + +/** HTTP 429: too many requests. */ +export class RateLimitError extends ApiError { + /** Seconds to wait, from the `Retry-After` header, when the API sent one. */ + public readonly retryAfter: number | undefined; + + constructor(init: ApiErrorInit & { retryAfter?: number }) { + super(init); + this.name = 'RateLimitError'; + this.retryAfter = init.retryAfter; + } +} + +/** HTTP 5xx with a JSON or empty body. */ +export class ServerError extends ApiError { + constructor(init: ApiErrorInit) { + super(init); + this.name = 'ServerError'; + } +} + +/** + * The request did not complete within the timeout. The timeout covers the + * response headers and the body. The API may still have processed the request. + */ +export class TimeoutError extends LettermintError { + /** The timeout in milliseconds. */ + public readonly timeout: number; + + constructor(timeout: number) { + super(`The request to the Lettermint API timed out after ${timeout} ms.`); + this.name = 'TimeoutError'; + this.timeout = timeout; + } +} + +/** The request could not be sent or the connection failed (DNS, TLS, refused, reset). */ +export class ConnectionError extends LettermintError { + constructor(cause: unknown) { + const reason = cause instanceof Error && cause.message ? `: ${cause.message}` : ''; + super(`Could not reach the Lettermint API${reason}`, { cause }); + this.name = 'ConnectionError'; + } +} + +/** + * The response could not be decoded: an empty or non-JSON body where JSON was + * expected, or an error status with a non-JSON body such as a proxy's HTML page. + */ +export class UnexpectedResponseError extends LettermintError { + /** The HTTP status code. */ + public readonly status: number; + /** The first 200 characters of the response body. */ + public readonly bodyExcerpt: string; + + constructor(message: string, status: number, body: string) { + super(message); + this.name = 'UnexpectedResponseError'; + this.status = status; + this.bodyExcerpt = body.length > 200 ? `${body.slice(0, 200)}…` : body; + } +} + +/** + * The API answered with a redirect (3xx). The SDK never follows redirects, so + * that tokens are not sent to another location. + */ +export class RedirectError extends LettermintError { + /** The HTTP status code, or 0 when the runtime hides the redirect (browsers). */ + public readonly status: number; + + constructor(status: number) { + super( + `The Lettermint API answered with a redirect${status ? ` (HTTP ${status})` : ''}. Redirects are not followed; check the baseUrl option.` + ); + this.name = 'RedirectError'; + this.status = status; + } +} + +/** Why a webhook delivery failed verification. */ +export type WebhookVerificationReason = + | 'signature_header_missing' + | 'signature_header_malformed' + | 'delivery_header_missing' + | 'delivery_timestamp_mismatch' + | 'timestamp_out_of_tolerance' + | 'signature_mismatch' + | 'body_invalid' + | 'payload_invalid'; + +/** A webhook delivery could not be verified. Reject the request; do not process its payload. */ +export class WebhookVerificationError extends LettermintError { + /** A machine-readable reason. */ + public readonly reason: WebhookVerificationReason; + + constructor(reason: WebhookVerificationReason, message: string) { + super(message); + this.name = 'WebhookVerificationError'; + this.reason = reason; + } +} diff --git a/src/generated/operations.ts b/src/generated/operations.ts new file mode 100644 index 0000000..4f0888d --- /dev/null +++ b/src/generated/operations.ts @@ -0,0 +1,1415 @@ +// Generated by lettermint/sdk-generator — do not edit. +// Spec: lettermint/lettermint@0b4ecbdd237d155305b9aab19efe96b2a4830446 (main) +// sending-openapi.json SHA-256: 53e80a6c21cd8995a02c75dfbef79773242cd0d60a5e6262eb79dc127f779c7f +// team-openapi.json SHA-256: 8b379814a0f501d2c564858bc382a29133535e704cc279545a934db611d587dd +// Naming profile: next + +import type * as Types from './types.js'; + +/** Which API token an operation accepts: a sending token, a team token, or either. */ +export type AuthSurface = 'sending' | 'team' | 'either'; + +/** The shape of one entry of `operations`. Type names refer to `OperationTypes`. */ +export interface OperationDefinition { + readonly operationId: string; + readonly method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + readonly path: string; + readonly auth: AuthSurface; + readonly surfaces: readonly ('sending' | 'team')[]; + readonly pathParams: readonly string[]; + readonly query: keyof OperationTypes | null; + readonly headers: readonly string[]; + readonly request: { readonly type: keyof OperationTypes; readonly contentType: string; readonly required: boolean } | null; + /** `type` is a type name, `'text'` for a text body or `'empty'` for no body. */ + readonly response: { readonly status: readonly number[]; readonly contentType: string | null; readonly type: keyof OperationTypes | 'text' | 'empty' }; + /** Error statuses with the type names their body may have (`'text'` for a text body; empty for no body). */ + readonly errors: { readonly [status: number]: readonly (keyof OperationTypes | 'text')[] }; + readonly pagination: { readonly style: 'cursor'; readonly items: keyof OperationTypes; readonly cursorParam: string | null; readonly sizeParam: string | null } | null; + readonly deprecated: boolean; +} + +/** Every operation of the Sending and Team APIs, keyed by ` `. */ +export const operations = { + /** Delete a domain */ + 'DELETE /domains/{domainId}': { + operationId: 'deleteDomain', + method: 'DELETE', + path: '/domains/{domainId}', + auth: 'team', + surfaces: ['team'], + pathParams: ['domainId'], + query: null, + headers: [], + request: null, + response: { status: [200], contentType: 'application/json', type: 'MessageResponse' }, + errors: { + 401: ['ApiError'], + 403: ['ApiError'], + 404: ['ErrorMessage', 'ApiError'], + 429: ['TooManyRequests'], + }, + pagination: null, + deprecated: false, + }, + /** Delete a project */ + 'DELETE /projects/{projectId}': { + operationId: 'deleteProject', + method: 'DELETE', + path: '/projects/{projectId}', + auth: 'team', + surfaces: ['team'], + pathParams: ['projectId'], + query: null, + headers: [], + request: null, + response: { status: [200], contentType: 'application/json', type: 'MessageResponse' }, + errors: { + 401: ['ApiError'], + 403: ['ApiError'], + 404: ['ErrorMessage', 'ApiError'], + 429: ['TooManyRequests'], + }, + pagination: null, + deprecated: false, + }, + /** Disable report forwarding */ + 'DELETE /projects/{projectId}/report-forwarding': { + operationId: 'deleteReportForwarding', + method: 'DELETE', + path: '/projects/{projectId}/report-forwarding', + auth: 'team', + surfaces: ['team'], + pathParams: ['projectId'], + query: null, + headers: [], + request: null, + response: { status: [204], contentType: null, type: 'empty' }, + errors: { + 401: ['ErrorMessage', 'ApiError'], + 403: ['ApiError', 'ErrorMessage'], + 404: ['ModelNotFoundException'], + 429: ['ErrorMessage', 'ApiError'], + }, + pagination: null, + deprecated: false, + }, + /** Delete a route */ + 'DELETE /routes/{routeId}': { + operationId: 'deleteRoute', + method: 'DELETE', + path: '/routes/{routeId}', + auth: 'team', + surfaces: ['team'], + pathParams: ['routeId'], + query: null, + headers: [], + request: null, + response: { status: [200], contentType: 'application/json', type: 'MessageResponse' }, + errors: { + 401: ['ApiError'], + 403: ['ApiError'], + 404: ['ErrorMessage', 'ApiError'], + 422: ['ApiError'], + 429: ['TooManyRequests'], + }, + pagination: null, + deprecated: false, + }, + /** Remove email from suppression list */ + 'DELETE /suppressions/{suppressionId}': { + operationId: 'deleteSuppression', + method: 'DELETE', + path: '/suppressions/{suppressionId}', + auth: 'team', + surfaces: ['team'], + pathParams: ['suppressionId'], + query: null, + headers: [], + request: null, + response: { status: [200, 202], contentType: 'application/json', type: 'DeleteSuppressionResponse' }, + errors: { + 401: ['ApiError'], + 403: ['ApiError'], + 404: ['ModelNotFoundException'], + 429: ['TooManyRequests'], + }, + pagination: null, + deprecated: false, + }, + /** Delete a webhook */ + 'DELETE /webhooks/{webhookId}': { + operationId: 'deleteWebhook', + method: 'DELETE', + path: '/webhooks/{webhookId}', + auth: 'team', + surfaces: ['team'], + pathParams: ['webhookId'], + query: null, + headers: [], + request: null, + response: { status: [200], contentType: 'application/json', type: 'MessageResponse' }, + errors: { + 401: ['ErrorMessage', 'ApiError'], + 403: ['ApiError', 'ErrorMessage'], + 404: ['ErrorMessage', 'ApiError'], + 429: ['ErrorMessage', 'ApiError'], + }, + pagination: null, + deprecated: false, + }, + /** List blocked file types */ + 'GET /blocked-file-types': { + operationId: 'listBlockedFileTypes', + method: 'GET', + path: '/blocked-file-types', + auth: 'team', + surfaces: ['team'], + pathParams: [], + query: null, + headers: [], + request: null, + response: { status: [200], contentType: 'application/json', type: 'BlockedFileTypes' }, + errors: { + 401: ['ApiError'], + 429: ['TooManyRequests'], + }, + pagination: null, + deprecated: false, + }, + /** List all domains for the team */ + 'GET /domains': { + operationId: 'listDomains', + method: 'GET', + path: '/domains', + auth: 'team', + surfaces: ['team'], + pathParams: [], + query: 'ListDomainsQuery', + headers: [], + request: null, + response: { status: [200], contentType: 'application/json', type: 'ListDomainsResponse' }, + errors: { + 401: ['ErrorMessage', 'ApiError'], + 403: ['ApiError', 'ErrorMessage'], + 429: ['ErrorMessage', 'ApiError'], + }, + pagination: { style: 'cursor', items: 'DomainListData', cursorParam: 'page[cursor]', sizeParam: 'page[size]' }, + deprecated: false, + }, + /** Get domain details */ + 'GET /domains/{domainId}': { + operationId: 'getDomain', + method: 'GET', + path: '/domains/{domainId}', + auth: 'team', + surfaces: ['team'], + pathParams: ['domainId'], + query: 'GetDomainQuery', + headers: [], + request: null, + response: { status: [200], contentType: 'application/json', type: 'DomainData' }, + errors: { + 401: ['ErrorMessage', 'ApiError'], + 403: ['ApiError', 'ErrorMessage'], + 404: ['ErrorMessage', 'ApiError'], + 429: ['ErrorMessage', 'ApiError'], + }, + pagination: null, + deprecated: false, + }, + /** List messages for the team */ + 'GET /messages': { + operationId: 'listMessages', + method: 'GET', + path: '/messages', + auth: 'team', + surfaces: ['team'], + pathParams: [], + query: 'ListMessagesQuery', + headers: [], + request: null, + response: { status: [200], contentType: 'application/json', type: 'ListMessagesResponse' }, + errors: { + 401: ['ErrorMessage', 'ApiError'], + 403: ['ApiError', 'ErrorMessage'], + 422: ['ValidationException'], + 429: ['ErrorMessage', 'ApiError'], + }, + pagination: { style: 'cursor', items: 'MessageListData', cursorParam: 'page[cursor]', sizeParam: 'page[size]' }, + deprecated: false, + }, + /** Get message details */ + 'GET /messages/{messageId}': { + operationId: 'getMessage', + method: 'GET', + path: '/messages/{messageId}', + auth: 'team', + surfaces: ['team'], + pathParams: ['messageId'], + query: null, + headers: [], + request: null, + response: { status: [200], contentType: 'application/json', type: 'MessageData' }, + errors: { + 401: ['ErrorMessage', 'ApiError'], + 403: ['ApiError', 'ErrorMessage'], + 404: ['ErrorMessage', 'ApiError'], + 429: ['ErrorMessage', 'ApiError'], + }, + pagination: null, + deprecated: false, + }, + /** Get message events */ + 'GET /messages/{messageId}/events': { + operationId: 'listMessageEvents', + method: 'GET', + path: '/messages/{messageId}/events', + auth: 'team', + surfaces: ['team'], + pathParams: ['messageId'], + query: 'ListMessageEventsQuery', + headers: [], + request: null, + response: { status: [200], contentType: 'application/json', type: 'ListMessageEventsResponse' }, + errors: { + 401: ['ErrorMessage', 'ApiError'], + 403: ['ApiError', 'ErrorMessage'], + 404: ['ErrorMessage', 'ApiError'], + 429: ['ErrorMessage', 'ApiError'], + }, + pagination: { style: 'cursor', items: 'MessageEventData', cursorParam: 'page[cursor]', sizeParam: 'page[size]' }, + deprecated: false, + }, + /** Get message HTML content */ + 'GET /messages/{messageId}/html': { + operationId: 'getMessageHtml', + method: 'GET', + path: '/messages/{messageId}/html', + auth: 'team', + surfaces: ['team'], + pathParams: ['messageId'], + query: null, + headers: [], + request: null, + response: { status: [200], contentType: 'text/html; charset=UTF-8', type: 'text' }, + errors: { + 401: ['ErrorMessage', 'ApiError'], + 403: ['ApiError', 'ErrorMessage'], + 404: ['ErrorMessage', 'ApiError'], + 429: ['ErrorMessage', 'ApiError'], + }, + pagination: null, + deprecated: false, + }, + /** Get message source */ + 'GET /messages/{messageId}/source': { + operationId: 'getMessageSource', + method: 'GET', + path: '/messages/{messageId}/source', + auth: 'team', + surfaces: ['team'], + pathParams: ['messageId'], + query: null, + headers: [], + request: null, + response: { status: [200], contentType: 'message/rfc822', type: 'text' }, + errors: { + 401: ['ErrorMessage', 'ApiError'], + 403: ['ErrorMessage', 'ApiError'], + 404: ['ErrorMessage', 'ApiError'], + 410: ['ErrorMessage'], + 429: ['ErrorMessage', 'ApiError'], + }, + pagination: null, + deprecated: false, + }, + /** Get message plain text content */ + 'GET /messages/{messageId}/text': { + operationId: 'getMessageText', + method: 'GET', + path: '/messages/{messageId}/text', + auth: 'team', + surfaces: ['team'], + pathParams: ['messageId'], + query: null, + headers: [], + request: null, + response: { status: [200], contentType: 'text/plain; charset=UTF-8', type: 'text' }, + errors: { + 401: ['ErrorMessage', 'ApiError'], + 403: ['ApiError', 'ErrorMessage'], + 404: ['ErrorMessage', 'ApiError'], + 429: ['ErrorMessage', 'ApiError'], + }, + pagination: null, + deprecated: false, + }, + /** Ping the API */ + 'GET /ping': { + operationId: 'ping', + method: 'GET', + path: '/ping', + auth: 'either', + surfaces: ['sending', 'team'], + pathParams: [], + query: null, + headers: [], + request: null, + response: { status: [200], contentType: 'text/html; charset=UTF-8', type: 'text' }, + errors: { + 401: ['ApiError', 'ErrorMessage'], + 429: ['TooManyRequests'], + }, + pagination: null, + deprecated: false, + }, + /** List all projects for the team */ + 'GET /projects': { + operationId: 'listProjects', + method: 'GET', + path: '/projects', + auth: 'team', + surfaces: ['team'], + pathParams: [], + query: 'ListProjectsQuery', + headers: [], + request: null, + response: { status: [200], contentType: 'application/json', type: 'ListProjectsResponse' }, + errors: { + 401: ['ErrorMessage', 'ApiError'], + 403: ['ApiError', 'ErrorMessage'], + 429: ['ErrorMessage', 'ApiError'], + }, + pagination: { style: 'cursor', items: 'ProjectListData', cursorParam: 'page[cursor]', sizeParam: 'page[size]' }, + deprecated: false, + }, + /** Get project details */ + 'GET /projects/{projectId}': { + operationId: 'getProject', + method: 'GET', + path: '/projects/{projectId}', + auth: 'team', + surfaces: ['team'], + pathParams: ['projectId'], + query: 'GetProjectQuery', + headers: [], + request: null, + response: { status: [200], contentType: 'application/json', type: 'ProjectData' }, + errors: { + 401: ['ErrorMessage', 'ApiError'], + 403: ['ApiError', 'ErrorMessage'], + 404: ['ErrorMessage', 'ApiError'], + 429: ['ErrorMessage', 'ApiError'], + }, + pagination: null, + deprecated: false, + }, + /** Get report forwarding settings */ + 'GET /projects/{projectId}/report-forwarding': { + operationId: 'getReportForwarding', + method: 'GET', + path: '/projects/{projectId}/report-forwarding', + auth: 'team', + surfaces: ['team'], + pathParams: ['projectId'], + query: null, + headers: [], + request: null, + response: { status: [200], contentType: 'application/json', type: 'GetReportForwardingResponse' }, + errors: { + 401: ['ErrorMessage', 'ApiError'], + 403: ['ApiError', 'ErrorMessage'], + 404: ['ModelNotFoundException'], + 429: ['ErrorMessage', 'ApiError'], + }, + pagination: null, + deprecated: false, + }, + /** List routes for a project */ + 'GET /projects/{projectId}/routes': { + operationId: 'listRoutes', + method: 'GET', + path: '/projects/{projectId}/routes', + auth: 'team', + surfaces: ['team'], + pathParams: ['projectId'], + query: 'ListRoutesQuery', + headers: [], + request: null, + response: { status: [200], contentType: 'application/json', type: 'ListRoutesResponse' }, + errors: { + 401: ['ErrorMessage', 'ApiError'], + 403: ['ApiError', 'ErrorMessage'], + 404: ['ErrorMessage', 'ApiError'], + 429: ['ErrorMessage', 'ApiError'], + }, + pagination: { style: 'cursor', items: 'RouteListData', cursorParam: 'page[cursor]', sizeParam: 'page[size]' }, + deprecated: false, + }, + /** Get route details */ + 'GET /routes/{routeId}': { + operationId: 'getRoute', + method: 'GET', + path: '/routes/{routeId}', + auth: 'team', + surfaces: ['team'], + pathParams: ['routeId'], + query: 'GetRouteQuery', + headers: [], + request: null, + response: { status: [200], contentType: 'application/json', type: 'RouteData' }, + errors: { + 401: ['ErrorMessage', 'ApiError'], + 403: ['ApiError', 'ErrorMessage'], + 404: ['ErrorMessage', 'ApiError'], + 429: ['ErrorMessage', 'ApiError'], + }, + pagination: null, + deprecated: false, + }, + /** Get message statistics */ + 'GET /stats': { + operationId: 'getStats', + method: 'GET', + path: '/stats', + auth: 'team', + surfaces: ['team'], + pathParams: [], + query: 'GetStatsQuery', + headers: [], + request: null, + response: { status: [200], contentType: 'application/json', type: 'StatsData' }, + errors: { + 401: ['ApiError'], + 403: ['ApiError'], + 404: ['ModelNotFoundException'], + 422: ['ValidationException'], + 429: ['TooManyRequests'], + }, + pagination: null, + deprecated: false, + }, + /** List all suppressions for the team */ + 'GET /suppressions': { + operationId: 'listSuppressions', + method: 'GET', + path: '/suppressions', + auth: 'team', + surfaces: ['team'], + pathParams: [], + query: 'ListSuppressionsQuery', + headers: [], + request: null, + response: { status: [200], contentType: 'application/json', type: 'ListSuppressionsResponse' }, + errors: { + 401: ['ApiError'], + 403: ['ApiError'], + 422: ['ValidationException'], + 429: ['TooManyRequests'], + }, + pagination: { style: 'cursor', items: 'SuppressedRecipientData', cursorParam: 'page[cursor]', sizeParam: 'page[size]' }, + deprecated: false, + }, + /** Get team details */ + 'GET /team': { + operationId: 'getTeam', + method: 'GET', + path: '/team', + auth: 'team', + surfaces: ['team'], + pathParams: [], + query: 'GetTeamQuery', + headers: [], + request: null, + response: { status: [200], contentType: 'application/json', type: 'TeamData' }, + errors: { + 401: ['ApiError'], + 403: ['ApiError'], + 429: ['TooManyRequests'], + }, + pagination: null, + deprecated: false, + }, + /** Get team members */ + 'GET /team/members': { + operationId: 'listTeamMembers', + method: 'GET', + path: '/team/members', + auth: 'team', + surfaces: ['team'], + pathParams: [], + query: 'ListTeamMembersQuery', + headers: [], + request: null, + response: { status: [200], contentType: 'application/json', type: 'ListTeamMembersResponse' }, + errors: { + 401: ['ApiError'], + 403: ['ApiError'], + 409: ['RbacConflictException'], + 429: ['TooManyRequests'], + }, + pagination: { style: 'cursor', items: 'TeamMemberData', cursorParam: 'page[cursor]', sizeParam: 'page[size]' }, + deprecated: false, + }, + /** Get a team member */ + 'GET /team/members/{userId}': { + operationId: 'getTeamMember', + method: 'GET', + path: '/team/members/{userId}', + auth: 'team', + surfaces: ['team'], + pathParams: ['userId'], + query: null, + headers: [], + request: null, + response: { status: [200], contentType: 'application/json', type: 'TeamMemberData' }, + errors: { + 401: ['ApiError'], + 403: ['ApiError'], + 404: ['ErrorMessage', 'ApiError'], + 409: ['RbacConflictException'], + 429: ['TooManyRequests'], + }, + pagination: null, + deprecated: false, + }, + /** Get reusable team roles */ + 'GET /team/roles': { + operationId: 'listTeamRoles', + method: 'GET', + path: '/team/roles', + auth: 'team', + surfaces: ['team'], + pathParams: [], + query: null, + headers: [], + request: null, + response: { status: [200], contentType: 'application/json', type: 'TeamRoleListResponse' }, + errors: { + 401: ['ApiError'], + 403: ['ApiError'], + 429: ['TooManyRequests'], + }, + pagination: null, + deprecated: false, + }, + /** Get team usage statistics */ + 'GET /team/usage': { + operationId: 'getTeamUsage', + method: 'GET', + path: '/team/usage', + auth: 'team', + surfaces: ['team'], + pathParams: [], + query: null, + headers: [], + request: null, + response: { status: [200], contentType: 'application/json', type: 'TeamUsageDetailData' }, + errors: { + 401: ['ApiError'], + 403: ['ApiError'], + 429: ['TooManyRequests'], + }, + pagination: null, + deprecated: false, + }, + /** List all webhooks for the team */ + 'GET /webhooks': { + operationId: 'listWebhooks', + method: 'GET', + path: '/webhooks', + auth: 'team', + surfaces: ['team'], + pathParams: [], + query: 'ListWebhooksQuery', + headers: [], + request: null, + response: { status: [200], contentType: 'application/json', type: 'ListWebhooksResponse' }, + errors: { + 401: ['ErrorMessage', 'ApiError'], + 403: ['ApiError', 'ErrorMessage'], + 429: ['ErrorMessage', 'ApiError'], + }, + pagination: { style: 'cursor', items: 'WebhookListData', cursorParam: 'cursor', sizeParam: 'page[size]' }, + deprecated: false, + }, + /** Get webhook details */ + 'GET /webhooks/{webhookId}': { + operationId: 'getWebhook', + method: 'GET', + path: '/webhooks/{webhookId}', + auth: 'team', + surfaces: ['team'], + pathParams: ['webhookId'], + query: null, + headers: [], + request: null, + response: { status: [200], contentType: 'application/json', type: 'WebhookData' }, + errors: { + 401: ['ErrorMessage', 'ApiError'], + 403: ['ApiError', 'ErrorMessage'], + 404: ['ApiError'], + 429: ['ErrorMessage', 'ApiError'], + }, + pagination: null, + deprecated: false, + }, + /** Get webhook deliveries */ + 'GET /webhooks/{webhookId}/deliveries': { + operationId: 'listWebhookDeliveries', + method: 'GET', + path: '/webhooks/{webhookId}/deliveries', + auth: 'team', + surfaces: ['team'], + pathParams: ['webhookId'], + query: 'ListWebhookDeliveriesQuery', + headers: [], + request: null, + response: { status: [200], contentType: 'application/json', type: 'ListWebhookDeliveriesResponse' }, + errors: { + 401: ['ApiError'], + 403: ['ApiError'], + 404: ['ErrorMessage', 'ApiError'], + 429: ['TooManyRequests'], + }, + pagination: { style: 'cursor', items: 'WebhookDeliveryListData', cursorParam: 'cursor', sizeParam: null }, + deprecated: false, + }, + /** Get a specific webhook delivery */ + 'GET /webhooks/{webhookId}/deliveries/{deliveryId}': { + operationId: 'getWebhookDelivery', + method: 'GET', + path: '/webhooks/{webhookId}/deliveries/{deliveryId}', + auth: 'team', + surfaces: ['team'], + pathParams: ['webhookId', 'deliveryId'], + query: null, + headers: [], + request: null, + response: { status: [200], contentType: 'application/json', type: 'WebhookDeliveryData' }, + errors: { + 401: ['ApiError'], + 403: ['ApiError'], + 404: ['ApiError', 'ErrorMessage'], + 429: ['TooManyRequests'], + }, + pagination: null, + deprecated: false, + }, + /** Reschedule a message */ + 'PATCH /messages/{messageId}': { + operationId: 'rescheduleMessage', + method: 'PATCH', + path: '/messages/{messageId}', + auth: 'either', + surfaces: ['team'], + pathParams: ['messageId'], + query: null, + headers: [], + request: { type: 'RescheduleMessageRequest', contentType: 'application/json', required: true }, + response: { status: [200], contentType: 'application/json', type: 'ScheduledMessage' }, + errors: { + 401: ['ApiError', 'ErrorMessage'], + 403: ['ApiError'], + 404: ['ErrorMessage', 'ApiError'], + 409: ['ApiError', 'ErrorMessage'], + 422: ['ApiError', 'ValidationError', 'ErrorMessage'], + }, + pagination: null, + deprecated: false, + }, + /** Query email analytics */ + 'POST /analytics': { + operationId: 'queryAnalytics', + method: 'POST', + path: '/analytics', + auth: 'team', + surfaces: ['team'], + pathParams: [], + query: null, + headers: [], + request: { type: 'AnalyticsQuery', contentType: 'application/json', required: true }, + response: { status: [200], contentType: 'application/json', type: 'AnalyticsResponse' }, + errors: { + 400: ['ErrorMessage'], + 401: ['ErrorMessage', 'ApiError'], + 403: ['ApiError', 'ErrorMessage'], + 415: ['ErrorMessage'], + 422: ['ErrorMessage'], + 429: ['ErrorMessage', 'ApiError'], + }, + pagination: null, + deprecated: false, + }, + /** Create a new domain */ + 'POST /domains': { + operationId: 'createDomain', + method: 'POST', + path: '/domains', + auth: 'team', + surfaces: ['team'], + pathParams: [], + query: null, + headers: [], + request: { type: 'StoreDomainData', contentType: 'application/json', required: true }, + response: { status: [201], contentType: 'application/json', type: 'DomainData' }, + errors: { + 401: ['ErrorMessage', 'ApiError'], + 403: ['ApiError', 'ErrorMessage'], + 422: ['ValidationException'], + 429: ['ErrorMessage', 'ApiError'], + 500: ['ApiError'], + }, + pagination: null, + deprecated: false, + }, + /** Verify all DNS records for a domain */ + 'POST /domains/{domainId}/dns-records/verify': { + operationId: 'verifyDomainDnsRecords', + method: 'POST', + path: '/domains/{domainId}/dns-records/verify', + auth: 'team', + surfaces: ['team'], + pathParams: ['domainId'], + query: null, + headers: [], + request: null, + response: { status: [200], contentType: 'application/json', type: 'DnsVerificationSuccessResponse' }, + errors: { + 401: ['ErrorMessage', 'ApiError'], + 403: ['ApiError', 'ErrorMessage'], + 404: ['ErrorMessage', 'ApiError'], + 422: ['DnsVerificationFailureResponse'], + 429: ['ErrorMessage', 'ApiError'], + }, + pagination: null, + deprecated: false, + }, + /** Verify a specific DNS record */ + 'POST /domains/{domainId}/dns-records/{recordId}/verify': { + operationId: 'verifyDomainDnsRecord', + method: 'POST', + path: '/domains/{domainId}/dns-records/{recordId}/verify', + auth: 'team', + surfaces: ['team'], + pathParams: ['domainId', 'recordId'], + query: null, + headers: [], + request: null, + response: { status: [200], contentType: 'application/json', type: 'MessageResponse' }, + errors: { + 401: ['ApiError'], + 403: ['ApiError'], + 404: ['ApiError', 'ErrorMessage'], + 422: ['DnsRecordVerificationFailureResponse'], + 429: ['TooManyRequests'], + }, + pagination: null, + deprecated: false, + }, + /** Cancel a scheduled message */ + 'POST /messages/{messageId}/cancel': { + operationId: 'cancelScheduledMessage', + method: 'POST', + path: '/messages/{messageId}/cancel', + auth: 'either', + surfaces: ['team'], + pathParams: ['messageId'], + query: null, + headers: [], + request: null, + response: { status: [200], contentType: 'application/json', type: 'ScheduledMessage' }, + errors: { + 401: ['ApiError', 'ErrorMessage'], + 404: ['ErrorMessage', 'ApiError'], + 409: ['ApiError', 'ErrorMessage'], + 422: ['ErrorMessage'], + }, + pagination: null, + deprecated: false, + }, + /** Process one quarantined inbound message */ + 'POST /messages/{messageId}/process': { + operationId: 'processInboundMessage', + method: 'POST', + path: '/messages/{messageId}/process', + auth: 'team', + surfaces: ['team'], + pathParams: ['messageId'], + query: null, + headers: ['Idempotency-Key'], + request: null, + response: { status: [202], contentType: 'application/json', type: 'ProcessInboundMessageResponse' }, + errors: { + 401: ['ApiError'], + 403: ['ApiError'], + 404: ['ErrorMessage', 'ApiError'], + 409: ['ProcessInboundMessageConflict', 'ErrorMessage'], + 422: ['ErrorMessage'], + 429: ['TooManyRequests'], + }, + pagination: null, + deprecated: false, + }, + /** Create a new project */ + 'POST /projects': { + operationId: 'createProject', + method: 'POST', + path: '/projects', + auth: 'team', + surfaces: ['team'], + pathParams: [], + query: null, + headers: [], + request: { type: 'StoreProjectData', contentType: 'application/json', required: true }, + response: { status: [201], contentType: 'application/json', type: 'ProjectCreatedData' }, + errors: { + 401: ['ErrorMessage', 'ApiError'], + 403: ['ApiError', 'ErrorMessage'], + 422: ['ValidationException'], + 429: ['ErrorMessage', 'ApiError'], + }, + pagination: null, + deprecated: false, + }, + /** Resend report forwarding verification code */ + 'POST /projects/{projectId}/report-forwarding/resend-code': { + operationId: 'resendReportForwardingCode', + method: 'POST', + path: '/projects/{projectId}/report-forwarding/resend-code', + auth: 'team', + surfaces: ['team'], + pathParams: ['projectId'], + query: null, + headers: [], + request: null, + response: { status: [200], contentType: 'application/json', type: 'ResendReportForwardingCodeResponse' }, + errors: { + 401: ['ErrorMessage', 'ApiError'], + 403: ['ApiError', 'ErrorMessage'], + 404: ['ModelNotFoundException'], + 429: ['ErrorMessage', 'ApiError'], + }, + pagination: null, + deprecated: false, + }, + /** Verify report forwarding destination */ + 'POST /projects/{projectId}/report-forwarding/verify': { + operationId: 'verifyReportForwarding', + method: 'POST', + path: '/projects/{projectId}/report-forwarding/verify', + auth: 'team', + surfaces: ['team'], + pathParams: ['projectId'], + query: null, + headers: [], + request: { type: 'VerifyReportForwardingRequest', contentType: 'application/json', required: true }, + response: { status: [200], contentType: 'application/json', type: 'VerifyReportForwardingResponse' }, + errors: { + 401: ['ErrorMessage', 'ApiError'], + 403: ['ApiError', 'ErrorMessage'], + 404: ['ModelNotFoundException'], + 422: ['ValidationException'], + 429: ['ErrorMessage', 'ApiError'], + }, + pagination: null, + deprecated: false, + }, + /** + * Rotate Project API token (legacy) + * @deprecated + */ + 'POST /projects/{projectId}/rotate-token': { + operationId: 'rotateProjectToken', + method: 'POST', + path: '/projects/{projectId}/rotate-token', + auth: 'team', + surfaces: ['team'], + pathParams: ['projectId'], + query: null, + headers: [], + request: null, + response: { status: [200], contentType: 'application/json', type: 'RotateProjectTokenResponse' }, + errors: { + 401: ['ApiError'], + 403: ['ApiError'], + 404: ['ErrorMessage', 'ApiError'], + 429: ['TooManyRequests'], + }, + pagination: null, + deprecated: true, + }, + /** Create a new route */ + 'POST /projects/{projectId}/routes': { + operationId: 'createRoute', + method: 'POST', + path: '/projects/{projectId}/routes', + auth: 'team', + surfaces: ['team'], + pathParams: ['projectId'], + query: null, + headers: [], + request: { type: 'StoreRouteData', contentType: 'application/json', required: true }, + response: { status: [201], contentType: 'application/json', type: 'RouteMutationResponse' }, + errors: { + 401: ['ErrorMessage', 'ApiError'], + 403: ['ApiError', 'ErrorMessage'], + 404: ['ErrorMessage', 'ApiError'], + 422: ['ApiError', 'ValidationError'], + 429: ['ErrorMessage', 'ApiError'], + }, + pagination: null, + deprecated: false, + }, + /** Verify inbound domain for a route */ + 'POST /routes/{routeId}/verify-inbound-domain': { + operationId: 'verifyRouteInboundDomain', + method: 'POST', + path: '/routes/{routeId}/verify-inbound-domain', + auth: 'team', + surfaces: ['team'], + pathParams: ['routeId'], + query: null, + headers: [], + request: null, + response: { status: [200], contentType: 'application/json', type: 'InboundDomainVerificationResponse' }, + errors: { + 401: ['ErrorMessage', 'ApiError'], + 403: ['ApiError', 'ErrorMessage'], + 404: ['ErrorMessage', 'ApiError'], + 422: ['InboundDomainVerificationResponse', 'ApiError'], + 429: ['ErrorMessage', 'ApiError'], + }, + pagination: null, + deprecated: false, + }, + /** Send an email */ + 'POST /send': { + operationId: 'sendMail', + method: 'POST', + path: '/send', + auth: 'sending', + surfaces: ['sending'], + pathParams: [], + query: null, + headers: ['Idempotency-Key'], + request: { type: 'SendMailRequest', contentType: 'application/json', required: true }, + response: { status: [202], contentType: 'application/json', type: 'SendMailResponse' }, + errors: { + 401: ['ErrorMessage', 'ApiError'], + 403: ['ApiError', 'ErrorMessage'], + 409: ['ErrorMessage'], + 422: ['ErrorMessage', 'ApiError', 'ValidationError'], + 429: ['ApiError'], + 500: ['ApiError'], + }, + pagination: null, + deprecated: false, + }, + /** Send multiple emails in a batch */ + 'POST /send/batch': { + operationId: 'sendBatchMail', + method: 'POST', + path: '/send/batch', + auth: 'sending', + surfaces: ['sending'], + pathParams: [], + query: null, + headers: ['Idempotency-Key'], + request: { type: 'SendBatchMailRequest', contentType: 'application/json', required: true }, + response: { status: [202], contentType: 'application/json', type: 'SendBatchMailResponse' }, + errors: { + 401: ['ErrorMessage'], + 403: ['ApiError'], + 409: ['ErrorMessage'], + 422: ['ApiError', 'ValidationError', 'ErrorMessage'], + 429: ['ApiError'], + 500: ['ApiError'], + }, + pagination: null, + deprecated: false, + }, + /** Add email(s) to suppression list */ + 'POST /suppressions': { + operationId: 'createSuppressions', + method: 'POST', + path: '/suppressions', + auth: 'team', + surfaces: ['team'], + pathParams: [], + query: null, + headers: [], + request: { type: 'StoreSuppressionData', contentType: 'application/json', required: true }, + response: { status: [201], contentType: 'application/json', type: 'SuppressionStoreResponse' }, + errors: { + 401: ['ApiError'], + 403: ['ApiError'], + 404: ['ModelNotFoundException'], + 422: ['ValidationException'], + 429: ['TooManyRequests'], + }, + pagination: null, + deprecated: false, + }, + /** Create a new webhook */ + 'POST /webhooks': { + operationId: 'createWebhook', + method: 'POST', + path: '/webhooks', + auth: 'team', + surfaces: ['team'], + pathParams: [], + query: null, + headers: [], + request: { type: 'StoreWebhookData', contentType: 'application/json', required: true }, + response: { status: [201], contentType: 'application/json', type: 'WebhookSecretResponse' }, + errors: { + 401: ['ErrorMessage', 'ApiError'], + 403: ['ApiError', 'ErrorMessage'], + 422: ['ValidationException'], + 429: ['ErrorMessage', 'ApiError'], + }, + pagination: null, + deprecated: false, + }, + /** Regenerate webhook secret */ + 'POST /webhooks/{webhookId}/regenerate-secret': { + operationId: 'regenerateWebhookSecret', + method: 'POST', + path: '/webhooks/{webhookId}/regenerate-secret', + auth: 'team', + surfaces: ['team'], + pathParams: ['webhookId'], + query: null, + headers: [], + request: null, + response: { status: [200], contentType: 'application/json', type: 'WebhookSecretResponse' }, + errors: { + 401: ['ApiError'], + 403: ['ApiError'], + 404: ['ErrorMessage', 'ApiError'], + 429: ['TooManyRequests'], + }, + pagination: null, + deprecated: false, + }, + /** Test a webhook by sending a sample payload */ + 'POST /webhooks/{webhookId}/test': { + operationId: 'testWebhook', + method: 'POST', + path: '/webhooks/{webhookId}/test', + auth: 'team', + surfaces: ['team'], + pathParams: ['webhookId'], + query: null, + headers: [], + request: null, + response: { status: [200], contentType: 'application/json', type: 'TestWebhookResponse' }, + errors: { + 401: ['ErrorMessage', 'ApiError'], + 403: ['ApiError', 'ErrorMessage'], + 404: ['ErrorMessage', 'ApiError'], + 429: ['ErrorMessage', 'ApiError'], + }, + pagination: null, + deprecated: false, + }, + /** Update projects associated with a domain */ + 'PUT /domains/{domainId}/projects': { + operationId: 'updateDomainProjects', + method: 'PUT', + path: '/domains/{domainId}/projects', + auth: 'team', + surfaces: ['team'], + pathParams: ['domainId'], + query: null, + headers: [], + request: { type: 'UpdateDomainProjectsData', contentType: 'application/json', required: true }, + response: { status: [200], contentType: 'application/json', type: 'DomainMutationResponse' }, + errors: { + 401: ['ErrorMessage', 'ApiError'], + 403: ['ApiError', 'ErrorMessage'], + 404: ['ErrorMessage', 'ApiError'], + 422: ['ApiError', 'ValidationError'], + 429: ['ErrorMessage', 'ApiError'], + }, + pagination: null, + deprecated: false, + }, + /** Update project settings */ + 'PUT /projects/{projectId}': { + operationId: 'updateProject', + method: 'PUT', + path: '/projects/{projectId}', + auth: 'team', + surfaces: ['team'], + pathParams: ['projectId'], + query: null, + headers: [], + request: { type: 'UpdateProjectData', contentType: 'application/json', required: false }, + response: { status: [200], contentType: 'application/json', type: 'ProjectMutationResponse' }, + errors: { + 401: ['ApiError'], + 403: ['ApiError'], + 404: ['ErrorMessage', 'ApiError'], + 422: ['ValidationException'], + 429: ['TooManyRequests'], + }, + pagination: null, + deprecated: false, + }, + /** Update report forwarding destination */ + 'PUT /projects/{projectId}/report-forwarding': { + operationId: 'updateReportForwarding', + method: 'PUT', + path: '/projects/{projectId}/report-forwarding', + auth: 'team', + surfaces: ['team'], + pathParams: ['projectId'], + query: null, + headers: [], + request: { type: 'ReportForwardingRequest', contentType: 'application/json', required: true }, + response: { status: [200], contentType: 'application/json', type: 'UpdateReportForwardingResponse' }, + errors: { + 401: ['ErrorMessage', 'ApiError'], + 403: ['ApiError', 'ErrorMessage'], + 404: ['ModelNotFoundException'], + 422: ['ValidationException'], + 429: ['ErrorMessage', 'ApiError'], + }, + pagination: null, + deprecated: false, + }, + /** Update route settings */ + 'PUT /routes/{routeId}': { + operationId: 'updateRoute', + method: 'PUT', + path: '/routes/{routeId}', + auth: 'team', + surfaces: ['team'], + pathParams: ['routeId'], + query: null, + headers: [], + request: { type: 'UpdateRouteData', contentType: 'application/json', required: false }, + response: { status: [200], contentType: 'application/json', type: 'RouteMutationResponse' }, + errors: { + 401: ['ErrorMessage', 'ApiError'], + 403: ['ApiError', 'ErrorMessage'], + 404: ['ErrorMessage', 'ApiError'], + 422: ['ValidationException'], + 429: ['ErrorMessage', 'ApiError'], + }, + pagination: null, + deprecated: false, + }, + /** Update team settings */ + 'PUT /team': { + operationId: 'updateTeam', + method: 'PUT', + path: '/team', + auth: 'team', + surfaces: ['team'], + pathParams: [], + query: null, + headers: [], + request: { type: 'UpdateTeamData', contentType: 'application/json', required: false }, + response: { status: [200], contentType: 'application/json', type: 'TeamMutationResponse' }, + errors: { + 401: ['ApiError'], + 403: ['ApiError'], + 422: ['ValidationException'], + 429: ['TooManyRequests'], + }, + pagination: null, + deprecated: false, + }, + /** Replace a team member's assignment */ + 'PUT /team/members/{userId}/assignment': { + operationId: 'updateTeamMemberAssignment', + method: 'PUT', + path: '/team/members/{userId}/assignment', + auth: 'team', + surfaces: ['team'], + pathParams: ['userId'], + query: null, + headers: [], + request: { type: 'UpdateTeamMemberAssignmentData', contentType: 'application/json', required: true }, + response: { status: [200], contentType: 'application/json', type: 'TeamMemberData' }, + errors: { + 401: ['ApiError'], + 403: ['ApiError'], + 404: ['ErrorMessage', 'ApiError'], + 409: ['RbacConflictException'], + 422: ['ValidationException'], + 429: ['TooManyRequests'], + }, + pagination: null, + deprecated: false, + }, + /** Update webhook settings */ + 'PUT /webhooks/{webhookId}': { + operationId: 'updateWebhook', + method: 'PUT', + path: '/webhooks/{webhookId}', + auth: 'team', + surfaces: ['team'], + pathParams: ['webhookId'], + query: null, + headers: [], + request: { type: 'UpdateWebhookData', contentType: 'application/json', required: false }, + response: { status: [200], contentType: 'application/json', type: 'WebhookMutationResponse' }, + errors: { + 401: ['ErrorMessage', 'ApiError'], + 403: ['ApiError', 'ErrorMessage'], + 404: ['ErrorMessage', 'ApiError'], + 422: ['ValidationException'], + 429: ['ErrorMessage', 'ApiError'], + }, + pagination: null, + deprecated: false, + }, +} as const satisfies Record; + +/** Every type name used in `operations`, mapped to its type. */ +export interface OperationTypes { + AnalyticsQuery: Types.AnalyticsQuery; + AnalyticsResponse: Types.AnalyticsResponse; + ApiError: Types.ApiError; + BlockedFileTypes: Types.BlockedFileTypes; + DeleteSuppressionResponse: Types.DeleteSuppressionResponse; + DeliveryMode: Types.DeliveryMode; + DnsRecordVerificationFailureResponse: Types.DnsRecordVerificationFailureResponse; + DnsVerificationFailureResponse: Types.DnsVerificationFailureResponse; + DnsVerificationSuccessResponse: Types.DnsVerificationSuccessResponse; + DomainData: Types.DomainData; + DomainListData: Types.DomainListData; + DomainMutationResponse: Types.DomainMutationResponse; + DomainStatus: Types.DomainStatus; + ErrorMessage: Types.ErrorMessage; + GetDomainQuery: Types.GetDomainQuery; + GetDomainQueryIncludeItem: Types.GetDomainQueryIncludeItem; + GetProjectQuery: Types.GetProjectQuery; + GetProjectQueryIncludeItem: Types.GetProjectQueryIncludeItem; + GetReportForwardingResponse: Types.GetReportForwardingResponse; + GetRouteQuery: Types.GetRouteQuery; + GetRouteQueryIncludeItem: Types.GetRouteQueryIncludeItem; + GetStatsQuery: Types.GetStatsQuery; + GetTeamQuery: Types.GetTeamQuery; + GetTeamQueryIncludeItem: Types.GetTeamQueryIncludeItem; + InboundDomainVerificationResponse: Types.InboundDomainVerificationResponse; + ListDomainsQuery: Types.ListDomainsQuery; + ListDomainsQuerySortItem: Types.ListDomainsQuerySortItem; + ListDomainsResponse: Types.ListDomainsResponse; + ListMessageEventsQuery: Types.ListMessageEventsQuery; + ListMessageEventsQuerySortItem: Types.ListMessageEventsQuerySortItem; + ListMessageEventsResponse: Types.ListMessageEventsResponse; + ListMessagesQuery: Types.ListMessagesQuery; + ListMessagesQueryFilter: Types.ListMessagesQueryFilter; + ListMessagesQuerySortItem: Types.ListMessagesQuerySortItem; + ListMessagesResponse: Types.ListMessagesResponse; + ListProjectsQuery: Types.ListProjectsQuery; + ListProjectsQuerySortItem: Types.ListProjectsQuerySortItem; + ListProjectsResponse: Types.ListProjectsResponse; + ListRoutesQuery: Types.ListRoutesQuery; + ListRoutesQuerySortItem: Types.ListRoutesQuerySortItem; + ListRoutesResponse: Types.ListRoutesResponse; + ListSuppressionsQuery: Types.ListSuppressionsQuery; + ListSuppressionsQuerySortItem: Types.ListSuppressionsQuerySortItem; + ListSuppressionsResponse: Types.ListSuppressionsResponse; + ListTeamMembersQuery: Types.ListTeamMembersQuery; + ListTeamMembersResponse: Types.ListTeamMembersResponse; + ListWebhookDeliveriesQuery: Types.ListWebhookDeliveriesQuery; + ListWebhookDeliveriesQuerySortItem: Types.ListWebhookDeliveriesQuerySortItem; + ListWebhookDeliveriesResponse: Types.ListWebhookDeliveriesResponse; + ListWebhooksQuery: Types.ListWebhooksQuery; + ListWebhooksQuerySortItem: Types.ListWebhooksQuerySortItem; + ListWebhooksResponse: Types.ListWebhooksResponse; + MessageData: Types.MessageData; + MessageEventData: Types.MessageEventData; + MessageListData: Types.MessageListData; + MessageResponse: Types.MessageResponse; + MessageStatus: Types.MessageStatus; + MessageType: Types.MessageType; + ModelNotFoundException: Types.ModelNotFoundException; + ProcessInboundMessageConflict: Types.ProcessInboundMessageConflict; + ProcessInboundMessageResponse: Types.ProcessInboundMessageResponse; + ProjectCreatedData: Types.ProjectCreatedData; + ProjectData: Types.ProjectData; + ProjectListData: Types.ProjectListData; + ProjectMutationResponse: Types.ProjectMutationResponse; + RbacConflictException: Types.RbacConflictException; + ReportForwardingRequest: Types.ReportForwardingRequest; + RescheduleMessageRequest: Types.RescheduleMessageRequest; + ResendReportForwardingCodeResponse: Types.ResendReportForwardingCodeResponse; + RotateProjectTokenResponse: Types.RotateProjectTokenResponse; + RouteData: Types.RouteData; + RouteListData: Types.RouteListData; + RouteMutationResponse: Types.RouteMutationResponse; + RouteType: Types.RouteType; + ScheduledMessage: Types.ScheduledMessage; + SendBatchMailRequest: Types.SendBatchMailRequest; + SendBatchMailResponse: Types.SendBatchMailResponse; + SendMailRequest: Types.SendMailRequest; + SendMailResponse: Types.SendMailResponse; + StatsData: Types.StatsData; + StoreDomainData: Types.StoreDomainData; + StoreProjectData: Types.StoreProjectData; + StoreRouteData: Types.StoreRouteData; + StoreSuppressionData: Types.StoreSuppressionData; + StoreWebhookData: Types.StoreWebhookData; + SuppressedRecipientData: Types.SuppressedRecipientData; + SuppressionReason: Types.SuppressionReason; + SuppressionStoreResponse: Types.SuppressionStoreResponse; + TeamData: Types.TeamData; + TeamMemberData: Types.TeamMemberData; + TeamMutationResponse: Types.TeamMutationResponse; + TeamRoleListResponse: Types.TeamRoleListResponse; + TeamUsageDetailData: Types.TeamUsageDetailData; + TestWebhookResponse: Types.TestWebhookResponse; + TooManyRequests: Types.TooManyRequests; + UpdateDomainProjectsData: Types.UpdateDomainProjectsData; + UpdateProjectData: Types.UpdateProjectData; + UpdateReportForwardingResponse: Types.UpdateReportForwardingResponse; + UpdateRouteData: Types.UpdateRouteData; + UpdateTeamData: Types.UpdateTeamData; + UpdateTeamMemberAssignmentData: Types.UpdateTeamMemberAssignmentData; + UpdateWebhookData: Types.UpdateWebhookData; + ValidationError: Types.ValidationError; + ValidationException: Types.ValidationException; + VerifyReportForwardingRequest: Types.VerifyReportForwardingRequest; + VerifyReportForwardingResponse: Types.VerifyReportForwardingResponse; + WebhookData: Types.WebhookData; + WebhookDeliveryData: Types.WebhookDeliveryData; + WebhookDeliveryListData: Types.WebhookDeliveryListData; + WebhookDeliveryStatus: Types.WebhookDeliveryStatus; + WebhookEvent: Types.WebhookEvent; + WebhookListData: Types.WebhookListData; + WebhookMutationResponse: Types.WebhookMutationResponse; + WebhookScope: Types.WebhookScope; + WebhookSecretResponse: Types.WebhookSecretResponse; +} + +export type Operations = typeof operations; +export type OperationKey = keyof Operations; + +type TypeOf = N extends keyof OperationTypes ? OperationTypes[N] : N extends 'text' ? string : N extends 'empty' ? undefined : never; + +/** The success body of an operation. */ +export type OperationResponse = TypeOf; + +/** The request body of an operation (`undefined` when it has none). */ +export type OperationRequest = Operations[K]['request'] extends { readonly type: infer N } ? TypeOf : undefined; + +/** The query parameters of an operation (`undefined` when it has none). */ +export type OperationQuery = Operations[K]['query'] extends null ? undefined : TypeOf; + +/** The path parameters of an operation. */ +export type OperationPathParams = { [P in Operations[K]['pathParams'][number]]: string }; + +/** The error statuses of an operation. */ +export type OperationErrorStatus = keyof Operations[K]['errors']; + +/** The error body of an operation, for one status or for any. */ +export type OperationError = OperationErrorStatus> = Operations[K]['errors'] extends infer E + ? { [P in keyof E & S]: E[P] extends readonly (infer N)[] ? TypeOf : never }[keyof E & S] + : never; diff --git a/src/generated/types.ts b/src/generated/types.ts new file mode 100644 index 0000000..876f093 --- /dev/null +++ b/src/generated/types.ts @@ -0,0 +1,1988 @@ +// Generated by lettermint/sdk-generator — do not edit. +// Spec: lettermint/lettermint@0b4ecbdd237d155305b9aab19efe96b2a4830446 (main) +// sending-openapi.json SHA-256: 53e80a6c21cd8995a02c75dfbef79773242cd0d60a5e6262eb79dc127f779c7f +// team-openapi.json SHA-256: 8b379814a0f501d2c564858bc382a29133535e704cc279545a934db611d587dd +// Naming profile: next + +/** Metric values for one group of dimension values. */ +export interface AnalyticsBreakdownRow { + metrics: AnalyticsMetricValues; + rate_bases: AnalyticsRateBases; + previous?: AnalyticsComparisonValues; + change?: Record; + dimensions: Record; + trend?: AnalyticsTimeSeriesPoint[]; +} + +export type AnalyticsCatalogueDimension = + | 'project_id' + | 'route_id' + | 'message_type' + | 'sending_domain' + | 'recipient_domain' + | 'provider_family' + | 'provider_subtype' + | 'from_address' + | 'subject' + | 'dedicated_ip' + | 'dedicated_pool_id' + | 'infrastructure_state' + | 'smtp_code' + | 'enhanced_status_code' + | 'enhanced_status_category' + | 'bounce_classification' + | 'failure_reason' + | 'smtp_response_group' + | 'engagement_state' + | 'request_actor' + | 'proxy_type' + | 'is_human' + | 'is_privacy' + | 'is_bot' + | 'is_scanner' + | (string & {}); + +export type AnalyticsCatalogueGroupDimension = + | 'project_id' + | 'route_id' + | 'message_type' + | 'sending_domain' + | 'recipient_domain' + | 'provider_family' + | 'provider_subtype' + | 'from_address' + | 'subject' + | 'dedicated_ip' + | 'dedicated_pool_id' + | 'infrastructure_state' + | 'smtp_code' + | 'enhanced_status_code' + | 'enhanced_status_category' + | 'bounce_classification' + | 'failure_reason' + | 'smtp_response_group' + | 'engagement_state' + | 'request_actor' + | 'proxy_type' + | (string & {}); + +/** A comparison period. */ +export type AnalyticsComparison = 'previous_period' | (string & {}); + +/** Metric values for the comparison period. */ +export interface AnalyticsComparisonValues { + metrics: AnalyticsMetricValues; + rate_bases: AnalyticsRateBases; +} + +/** A catalogue dimension or tag: to filter by. is_human, is_privacy, is_bot and is_scanner select an engagement class: filter them with the value 1 to restrict engagement metrics to that class. */ +export type AnalyticsDimension = AnalyticsCatalogueDimension | string; + +/** A dimension filter. */ +export interface AnalyticsFilter { + dimension: AnalyticsDimension; + /** eq requires one value; in and not_in require one or more values; is_null and is_not_null require no values. */ + operator: AnalyticsFilterOperator; + /** String values to match. Supply one for eq, one or more for in/not_in, and none for null checks. */ + values?: string[]; +} + +/** A filter operator. */ +export type AnalyticsFilterOperator = + | 'eq' + | 'in' + | 'not_in' + | 'is_null' + | 'is_not_null' + | (string & {}); + +/** A catalogue dimension or tag: to group breakdown rows by. Engagement class dimensions (is_human, is_privacy, is_bot and is_scanner) can only filter. */ +export type AnalyticsGroupDimension = AnalyticsCatalogueGroupDimension | string; + +/** A time-series bucket size. */ +export type AnalyticsInterval = 'hour' | 'day' | (string & {}); + +/** The time window and data availability of the results. */ +export interface AnalyticsMeta { + time_basis: AnalyticsMetaTimeBasis; + timezone: string; + interval: AnalyticsInterval; + /** Start of the window read, aligned down to the alignment grain. */ + from: string; + /** Exclusive end of the window, aligned up to the alignment grain. */ + to: string; + /** Exclusive end of the counted data: to, or generated_at while the window is ongoing. */ + effective_to: string; + /** Grain the requested boundaries were widened to: whole UTC hours, or whole UTC days for personal-data dimensions. Counts cover exactly from to effective_to. */ + alignment: AnalyticsMetaAlignment; + generated_at: string; + available_since: string; + partial: boolean; + ongoing: boolean; + collection_completeness: AnalyticsMetaCollectionCompleteness; + /** Latest ingestion among matching rollup rows. This is not a completeness watermark. */ + last_ingested_at: string | null; + metric_definition_version: string; + ranked_group_limit: number; + comparison?: AnalyticsMetaComparison; +} + +/** Grain the requested boundaries were widened to: whole UTC hours, or whole UTC days for personal-data dimensions. Counts cover exactly from to effective_to. */ +export type AnalyticsMetaAlignment = 'hour' | 'day' | (string & {}); + +export type AnalyticsMetaCollectionCompleteness = 'best_effort' | (string & {}); + +export interface AnalyticsMetaComparison { + from: string; + to: string; + partial: boolean; +} + +export type AnalyticsMetaTimeBasis = 'event' | (string & {}); + +/** A metric from the analytics catalogue. */ +export type AnalyticsMetric = + | 'accepted' + | 'processed' + | 'suppressed' + | 'policy_rejected' + | 'application_failed' + | 'mta_accepted' + | 'canceled' + | 'messages' + | 'delivered' + | 'bounced' + | 'soft_bounced' + | 'administratively_bounced' + | 'deferred_recipients' + | 'deferred_events' + | 'delivery_attempts' + | 'attempted_recipients' + | 'transport_outcome_recipients' + | 'effective_delivered' + | 'open_tracked_delivered' + | 'click_tracked_delivered' + | 'out_of_band_bounced_recipients' + | 'out_of_band_bounce_events' + | 'complained' + | 'unsubscribed' + | 'human_opens' + | 'human_opens_events' + | 'human_clicks' + | 'human_clicks_events' + | 'machine_opens' + | 'machine_opens_events' + | 'machine_clicks' + | 'machine_clicks_events' + | 'privacy_opens' + | 'privacy_opens_events' + | 'privacy_clicks' + | 'privacy_clicks_events' + | 'bot_opens' + | 'bot_opens_events' + | 'bot_clicks' + | 'bot_clicks_events' + | 'scanner_opens' + | 'scanner_opens_events' + | 'scanner_clicks' + | 'scanner_clicks_events' + | 'observed_opens' + | 'observed_opens_events' + | 'observed_clicks' + | 'observed_clicks_events' + | 'delivery_rate' + | 'effective_delivery_rate' + | 'bounce_rate' + | 'deferral_rate' + | 'complaint_rate' + | 'human_open_rate' + | 'human_click_rate' + | 'processing_latency_p50_ms' + | 'processing_latency_p95_ms' + | 'processing_latency_p99_ms' + | 'processing_latency_samples' + | 'delivery_latency_p50_ms' + | 'delivery_latency_p95_ms' + | 'delivery_latency_p99_ms' + | 'delivery_latency_samples' + | 'total_latency_p50_ms' + | 'total_latency_p95_ms' + | 'total_latency_p99_ms' + | 'total_latency_samples' + | (string & {}); + +/** Change of a metric against the comparison period. */ +export interface AnalyticsMetricChange { + absolute?: number | null; + relative?: number | null; + percentage_points?: number | null; +} + +/** Values of the selected metrics. Rates are fractions; null when not available. */ +export interface AnalyticsMetricValues { + accepted?: number | null; + processed?: number | null; + suppressed?: number | null; + policy_rejected?: number | null; + application_failed?: number | null; + mta_accepted?: number | null; + canceled?: number | null; + messages?: number | null; + delivered?: number | null; + bounced?: number | null; + soft_bounced?: number | null; + administratively_bounced?: number | null; + deferred_recipients?: number | null; + deferred_events?: number | null; + delivery_attempts?: number | null; + attempted_recipients?: number | null; + transport_outcome_recipients?: number | null; + effective_delivered?: number | null; + open_tracked_delivered?: number | null; + click_tracked_delivered?: number | null; + out_of_band_bounced_recipients?: number | null; + out_of_band_bounce_events?: number | null; + complained?: number | null; + unsubscribed?: number | null; + human_opens?: number | null; + human_opens_events?: number | null; + human_clicks?: number | null; + human_clicks_events?: number | null; + machine_opens?: number | null; + machine_opens_events?: number | null; + machine_clicks?: number | null; + machine_clicks_events?: number | null; + privacy_opens?: number | null; + privacy_opens_events?: number | null; + privacy_clicks?: number | null; + privacy_clicks_events?: number | null; + bot_opens?: number | null; + bot_opens_events?: number | null; + bot_clicks?: number | null; + bot_clicks_events?: number | null; + scanner_opens?: number | null; + scanner_opens_events?: number | null; + scanner_clicks?: number | null; + scanner_clicks_events?: number | null; + observed_opens?: number | null; + observed_opens_events?: number | null; + observed_clicks?: number | null; + observed_clicks_events?: number | null; + delivery_rate?: number | null; + effective_delivery_rate?: number | null; + bounce_rate?: number | null; + deferral_rate?: number | null; + complaint_rate?: number | null; + human_open_rate?: number | null; + human_click_rate?: number | null; + processing_latency_p50_ms?: number | null; + processing_latency_p95_ms?: number | null; + processing_latency_p99_ms?: number | null; + processing_latency_samples?: number | null; + delivery_latency_p50_ms?: number | null; + delivery_latency_p95_ms?: number | null; + delivery_latency_p99_ms?: number | null; + delivery_latency_samples?: number | null; + total_latency_p50_ms?: number | null; + total_latency_p95_ms?: number | null; + total_latency_p99_ms?: number | null; + total_latency_samples?: number | null; +} + +/** Breakdown pagination. */ +export interface AnalyticsPagination { + total_groups: number; + returned_groups: number; + next_cursor: string | null; + truncated: boolean; +} + +/** An analytics query. */ +export interface AnalyticsQuery { + /** Every count is attributed to the time its event occurred, so a message accepted in one bucket can be delivered in the next. Delivery outcomes happen once per recipient, so their event counts equal recipient counts. messages counts accepted messages; mta_accepted and attempted_recipients count recipients the MTA received. Under provider, infrastructure and diagnosis dimensions, sent/attempted count delivery attempts: mta_accepted and attempted_recipients equal delivery_attempts when a query groups or filters by provider_family, provider_subtype, dedicated_ip, dedicated_pool_id, infrastructure_state or an SMTP diagnosis dimension, because the MTA assigns those values at the delivery attempt; the other lifecycle metrics return null there, and a breakdown by those dimensions omits the unknown group. deferred_recipients counts recipients delayed on the first attempt; deferred_events counts every deferral. delivery_attempts counts delivered, deferred and in-band bounced attempts. transport_outcome_recipients = delivered + bounced + soft_bounced. effective_delivered = delivered minus out-of-band bounces in the same period and can be negative for a single bucket or group. Engagement counts per class are unique per recipient, counted when the first qualifying open or click occurred; _events variants count every observation. Rates are fractions; zero denominators return null, and event-time rates can exceed 1. Latency percentiles are milliseconds read from log-scale histograms (error below 15 percent); _samples returns the sample count. Personal message dimensions support daily buckets over the message retention period. delivery_rate = delivered / transport_outcome_recipients. effective_delivery_rate = effective_delivered / transport_outcome_recipients. bounce_rate = bounced / transport_outcome_recipients. deferral_rate = deferred_events / delivery_attempts. complaint_rate = complained / delivered. human_open_rate = human_opens / open_tracked_delivered. human_click_rate = human_clicks / click_tracked_delivered. */ + metrics: AnalyticsMetric[]; + /** Inclusive calendar date or RFC 3339 instant. Default: 30 calendar days including today. Instants are aligned outward to whole UTC hours, or whole UTC days when from_address or subject is grouped or filtered; meta.from, meta.to, meta.effective_to and meta.alignment report the exact window used. */ + from?: string; + /** Inclusive calendar date or exclusive RFC 3339 instant. Maximum window: 90 days. Instants are aligned outward to whole UTC hours, or whole UTC days when from_address or subject is grouped or filtered; meta.from, meta.to, meta.effective_to and meta.alignment report the exact window used. */ + to?: string; + /** IANA time zone. Default: UTC. */ + timezone?: string; + /** Result sections. Default: summary only. breakdown requires group_by, and group_by requires breakdown. */ + include?: AnalyticsSection[]; + /** Up to three grouping dimensions for breakdown rows. Requires breakdown in include. Default: no grouping. */ + group_by?: AnalyticsGroupDimension[]; + /** Dimension filters combined with AND. Default: no filters. Each metric must support every filter dimension. */ + filters?: AnalyticsFilter[]; + /** Time-series bucket size. Default: day. */ + interval?: AnalyticsInterval; + /** Compares with the preceding period of the same duration. Omit for no comparison. */ + compare?: AnalyticsComparison; + /** Includes trends for breakdown rows. Requires breakdown and group_by. Default: false. */ + include_trend?: boolean; + /** Optional breakdown sort. Omit to use the default row order. */ + sort?: AnalyticsSort; + /** Maximum breakdown rows per page. Default: 50; maximum: 200. */ + limit?: number; + /** Repeat the same query with this cursor. It expires within 60 seconds. */ + cursor?: string; +} + +/** Numerator and denominator of a rate metric. */ +export interface AnalyticsRateBase { + numerator: number | null; + denominator: number | null; +} + +/** Numerators and denominators of the selected rate metrics. */ +export interface AnalyticsRateBases { + delivery_rate?: AnalyticsRateBase; + effective_delivery_rate?: AnalyticsRateBase; + bounce_rate?: AnalyticsRateBase; + deferral_rate?: AnalyticsRateBase; + complaint_rate?: AnalyticsRateBase; + human_open_rate?: AnalyticsRateBase; + human_click_rate?: AnalyticsRateBase; +} + +/** Analytics results with their window metadata and pagination. */ +export interface AnalyticsResponse { + data: AnalyticsResults; + meta: AnalyticsMeta; + pagination: AnalyticsPagination; +} + +/** The selected result sections. */ +export interface AnalyticsResults { + summary?: AnalyticsSummary; + time_series?: AnalyticsTimeSeriesPoint[]; + breakdown?: AnalyticsBreakdownRow[]; +} + +/** A result section. */ +export type AnalyticsSection = 'summary' | 'time_series' | 'breakdown' | (string & {}); + +/** A breakdown sort order. */ +export interface AnalyticsSort { + /** Metric used to sort breakdown rows. Must also be selected in metrics. */ + metric: AnalyticsMetric; + /** Ascending or descending order for the selected sort metric. */ + direction: AnalyticsSortDirection; +} + +/** Ascending or descending order for the selected sort metric. */ +export type AnalyticsSortDirection = 'asc' | 'desc' | (string & {}); + +/** Metric values for the whole window. */ +export interface AnalyticsSummary { + metrics: AnalyticsMetricValues; + rate_bases: AnalyticsRateBases; + previous?: AnalyticsComparisonValues; + change?: Record; +} + +/** Metric values for one time-series bucket. */ +export interface AnalyticsTimeSeriesPoint { + metrics: AnalyticsMetricValues; + rate_bases: AnalyticsRateBases; + previous?: AnalyticsComparisonValues; + change?: Record; + from: string; + to: string; + available: boolean; + partial: boolean; +} + +/** Structured API error. */ +export interface ApiError { + error: ApiErrorDetail; +} + +export interface ApiErrorDetail { + /** Machine-readable error code, for example `RESOURCE_NOT_FOUND`. */ + code: string; + /** Human-readable error message. */ + message: string; + /** Additional context. Its shape depends on the error code. */ + details?: Record | unknown[] | null; +} + +export type AttachmentDelivery = 'inline' | 'url' | (string & {}); + +/** File types that cannot be sent as email attachments. */ +export interface BlockedFileTypes { + /** Blocked file extensions, without the leading dot */ + extensions: string[]; + /** Blocked MIME types */ + mime_types: string[]; +} + +export type BuiltInTeamRole = 'owner' | 'admin' | 'member' | (string & {}); + +/** A page of a cursor-paginated list. Pass `next_cursor` back as the cursor query parameter to read the next page; it is `null` on the last page. */ +export interface CursorPage { + data: T[]; + /** Base path for paginator generated URLs. */ + path: string | null; + /** Number of items shown per page. */ + per_page: number; + /** The "cursor" that points to the next set of items. */ + next_cursor: string | null; + next_page_url: string | null; + /** The "cursor" that points to the previous set of items. */ + prev_cursor: string | null; + prev_page_url: string | null; +} + +export type DeleteSuppressionResponse = SuppressionDeletedResponse | SuppressionReviewResponse; + +export type DeliveryMode = 'live' | 'sandbox' | (string & {}); + +export type DkimMode = 'legacy_txt' | 'managed_cname' | (string & {}); + +export type DmarcPolicy = 'none' | 'quarantine' | 'reject' | (string & {}); + +export type DnsRecordPurpose = + | 'return_path' + | 'dmarc' + | 'dkim_legacy' + | 'dkim_primary' + | 'dkim_secondary' + | (string & {}); + +/** + * | | + * |---| + * | `active`
The record is active and verified. | + * | `failed`
The record could not be verified. | + * | `pending`
The record is pending verification. | + */ +export type DnsRecordStatus = 'active' | 'failed' | 'pending' | (string & {}); + +/** A DNS record that did not pass verification, with the verification details. */ +export interface DnsRecordVerificationFailureResponse { + /** Human-readable verification error */ + message: string; + result: DnsVerificationResultData; +} + +/** Why a DNS record did not pass verification. */ +export interface DnsVerificationError { + /** The DNS record ID */ + record_id: string; + error: string; +} + +/** A DNS record that did not pass verification. */ +export interface DnsVerificationFailedRecord { + /** The DNS record ID */ + id: string; + type: RecordType; + /** The record hostname */ + name: string; +} + +/** One or more required DNS records of the domain did not pass verification. */ +export interface DnsVerificationFailureResponse { + /** Human-readable verification result */ + message: string; + /** Required records that did not pass verification */ + failed_records: DnsVerificationFailedRecord[]; + /** Verification errors of every failed record, including recommended records */ + errors: DnsVerificationError[]; + /** The number of required records that failed */ + failed_count: number; + /** The number of required records */ + total_count: number; +} + +export interface DnsVerificationResultData { + dmarc_spf_alignment_issue: DnsVerificationResultDataDmarcSpfAlignmentIssue | null; + verified: boolean; + error: string | null; + expected_record: string | null; + found_record: string | null; + dmarc_policy_domain: string | null; + dmarc_effective_policy: DmarcPolicy | null; +} + +export interface DnsVerificationResultDataDmarcSpfAlignmentIssue { + code: string; + blocks_verification: boolean; + return_path_domain: string; +} + +export type DnsVerificationScope = + | 'required' + | 'recommended' + | 'migration' + | 'deprecated' + | (string & {}); + +/** All required DNS records of the domain passed verification. */ +export interface DnsVerificationSuccessResponse { + /** Human-readable verification result */ + message: string; + /** Recommended records that did not pass verification. They do not block verification */ + recommended_failed_records: DnsVerificationFailedRecord[]; +} + +export interface DomainData { + id: string; + domain: string; + dkim_mode: DkimMode; + rotation_ready: boolean; + status_changed_at: string | null; + dns_records?: DomainDnsRecordData[]; + projects?: DomainDataProjectsItem[]; + created_at: string; +} + +export interface DomainDataProjectsItem { + id: string; + name: string; +} + +export interface DomainDnsRecordData { + id: string; + type: RecordType; + hostname: string; + fqdn: string; + content: string; + status: DnsRecordStatus; + purpose: DnsRecordPurpose; + verification_scope: DnsVerificationScope; + required_for_verification: boolean; + verified_at: string | null; + last_checked_at: string | null; +} + +export interface DomainListData { + id: string; + domain: string; + status: DomainStatus; + dkim_mode: DkimMode; + status_changed_at: string | null; + created_at: string; +} + +/** The domain after a change, with a confirmation message. */ +export interface DomainMutationResponse { + data: DomainData; + /** Human-readable confirmation */ + message: string; +} + +/** + * | | + * |---| + * | `verified`
The domain is active and verified. | + * | `partially_verified`
Some DNS records of the domain could not be verified. | + * | `pending_verification`
The domain is new and verification is pending. | + * | `failed_verification`
The domain verification failed. | + */ +export type DomainStatus = + | 'verified' + | 'partially_verified' + | 'pending_verification' + | 'failed_verification' + | (string & {}); + +/** Error with only a message. */ +export interface ErrorMessage { + /** Error overview. */ + message: string; +} + +/** Query parameters of `GET /domains/{domainId}` (getDomain). */ +export interface GetDomainQuery { + include?: GetDomainQueryIncludeItem[]; +} + +export type GetDomainQueryIncludeItem = 'dnsRecords' | 'projects' | (string & {}); + +/** Query parameters of `GET /projects/{projectId}` (getProject). */ +export interface GetProjectQuery { + include?: GetProjectQueryIncludeItem[]; +} + +export type GetProjectQueryIncludeItem = + | 'routes' + | 'routesCount' + | 'routesExists' + | 'domains' + | 'domainsCount' + | 'domainsExists' + | 'messageStats' + | 'messageStatsCount' + | 'messageStatsExists' + | (string & {}); + +export interface GetReportForwardingResponse { + data: ReportForwardingResource; +} + +/** Query parameters of `GET /routes/{routeId}` (getRoute). */ +export interface GetRouteQuery { + /** Include legacy machine observations in open and click totals. Privacy opens are reported in the observed and privacy fields without this option. */ + include_machine?: boolean; + include?: GetRouteQueryIncludeItem[]; +} + +export type GetRouteQueryIncludeItem = + | 'project' + | 'projectCount' + | 'projectExists' + | 'statistics' + | (string & {}); + +/** Query parameters of `GET /stats` (getStats). */ +export interface GetStatsQuery { + from: string; + to: string; + project_id?: string | null; + include_machine?: boolean; +} + +/** Query parameters of `GET /team` (getTeam). */ +export interface GetTeamQuery { + include?: GetTeamQueryIncludeItem[]; +} + +export type GetTeamQueryIncludeItem = 'features' | 'addons' | (string & {}); + +/** Whether the inbound domain MX records point to Lettermint. The MX record lists are only returned when verification fails. */ +export interface InboundDomainVerification { + verified: boolean; + /** Human-readable verification result */ + message: string; + /** The MX records the inbound domain must publish */ + expected_mx_records?: string[]; + /** The MX records found for the inbound domain */ + found_mx_records?: string[]; +} + +/** The result of an inbound domain MX verification. */ +export interface InboundDomainVerificationResponse { + data: InboundDomainVerification; +} + +export type InitialRoutes = 'both' | 'transactional' | 'broadcast' | (string & {}); + +/** Query parameters of `GET /domains` (listDomains). */ +export interface ListDomainsQuery { + /** The number of results that will be returned per page. */ + 'page[size]'?: number; + /** The cursor to start the pagination from. */ + 'page[cursor]'?: string; + sort?: ListDomainsQuerySortItem[]; + /** Filter by project ID */ + 'filter[project]'?: string; + 'filter[status]'?: DomainStatus; + /** Search by domain name (partial match, case insensitive) */ + 'filter[domain]'?: string; +} + +export type ListDomainsQuerySortItem = + | 'domain' + | '-domain' + | 'created_at' + | '-created_at' + | 'status_changed_at' + | '-status_changed_at' + | (string & {}); + +export type ListDomainsResponse = CursorPage; + +/** Query parameters of `GET /messages/{messageId}/events` (listMessageEvents). */ +export interface ListMessageEventsQuery { + /** The number of results that will be returned per page. */ + 'page[size]'?: number; + /** The cursor to start the pagination from. */ + 'page[cursor]'?: string; + /** Include security scanner, preview, and other machine-only tracking events. Supported privacy opens are included without this option. */ + include_machine_events?: boolean; + sort?: ListMessageEventsQuerySortItem[]; +} + +export type ListMessageEventsQuerySortItem = + | 'timestamp' + | '-timestamp' + | 'event' + | '-event' + | (string & {}); + +export type ListMessageEventsResponse = CursorPage; + +/** Query parameters of `GET /messages` (listMessages). */ +export interface ListMessagesQuery { + sort?: ListMessagesQuerySortItem[]; + /** Search in sender, recipients, and subject */ + 'filter[search]'?: string; + /** Exact tag name. Add more numbered pairs to match all pairs. */ + 'filter[tags][0][name]'?: string; + /** Exact tag value. */ + 'filter[tags][0][value]'?: string; + /** Search by sender email (partial match) */ + 'filter[from_email]'?: string; + /** Search by subject (partial match) */ + 'filter[subject]'?: string; + /** Number of results per page */ + 'page[size]'?: number; + /** Cursor for the next or previous page */ + 'page[cursor]'?: string; + filter?: ListMessagesQueryFilter; + 'filter[tags]'?: string; + 'filter[overdue]'?: string; + /** Filter by project ID */ + 'filter[project]'?: string; + 'filter[type]'?: MessageType; + 'filter[status]'?: MessageStatus; + 'filter[delivery_mode]'?: DeliveryMode; + /** Filter by route ID */ + 'filter[route_id]'?: string; + /** Filter by domain ID */ + 'filter[domain_id]'?: string; + /** Filter by tag name */ + 'filter[tag]'?: string; + /** Filter messages created after this date */ + 'filter[from_date]'?: string; + /** Filter messages created before this date */ + 'filter[to_date]'?: string; +} + +export interface ListMessagesQueryFilter { + tags?: ListMessagesQueryFilterTagsItem[]; +} + +export interface ListMessagesQueryFilterTagsItem { + name?: string; + value?: string; +} + +export type ListMessagesQuerySortItem = + | 'type' + | '-type' + | 'status' + | '-status' + | 'from_email' + | '-from_email' + | 'subject' + | '-subject' + | 'created_at' + | '-created_at' + | 'status_changed_at' + | '-status_changed_at' + | 'scheduled_at' + | '-scheduled_at' + | (string & {}); + +export type ListMessagesResponse = CursorPage; + +/** Query parameters of `GET /projects` (listProjects). */ +export interface ListProjectsQuery { + /** The number of results that will be returned per page. */ + 'page[size]'?: number; + /** The cursor to start the pagination from. */ + 'page[cursor]'?: string; + 'filter[search]'?: string; + sort?: ListProjectsQuerySortItem[]; +} + +export type ListProjectsQuerySortItem = + | 'name' + | '-name' + | 'created_at' + | '-created_at' + | (string & {}); + +export type ListProjectsResponse = CursorPage; + +/** Query parameters of `GET /projects/{projectId}/routes` (listRoutes). */ +export interface ListRoutesQuery { + /** The number of results that will be returned per page. */ + 'page[size]'?: number; + /** The cursor to start the pagination from. */ + 'page[cursor]'?: string; + sort?: ListRoutesQuerySortItem[]; + 'filter[route_type]'?: RouteType; + 'filter[is_default]'?: boolean; + /** Search by name or slug */ + 'filter[search]'?: string; +} + +export type ListRoutesQuerySortItem = + | 'name' + | '-name' + | 'slug' + | '-slug' + | 'created_at' + | '-created_at' + | (string & {}); + +export type ListRoutesResponse = CursorPage; + +/** Query parameters of `GET /suppressions` (listSuppressions). */ +export interface ListSuppressionsQuery { + /** The number of results that will be returned per page. */ + 'page[size]'?: number; + /** The cursor to start the pagination from. */ + 'page[cursor]'?: string; + sort?: ListSuppressionsQuerySortItem[]; + /** Filter by scope (team, project, route) */ + 'filter[scope]'?: string; + /** Filter by route ID */ + 'filter[route_id]'?: string; + /** Filter by project ID */ + 'filter[project_id]'?: string; + /** Search by email/domain value (partial match) */ + 'filter[value]'?: string; + 'filter[reason]'?: SuppressionReason; + /** Filter suppressions created or updated on or after this ISO 8601 date or datetime */ + 'filter[startDate]'?: string; + /** Filter suppressions created or updated on or before this ISO 8601 date or datetime */ + 'filter[endDate]'?: string; +} + +export type ListSuppressionsQuerySortItem = + | 'value' + | '-value' + | 'created_at' + | '-created_at' + | 'reason' + | '-reason' + | (string & {}); + +export type ListSuppressionsResponse = CursorPage; + +/** Query parameters of `GET /team/members` (listTeamMembers). */ +export interface ListTeamMembersQuery { + /** The number of results that will be returned per page. */ + 'page[size]'?: number; + /** The cursor to start the pagination from. */ + 'page[cursor]'?: string; +} + +export type ListTeamMembersResponse = CursorPage; + +/** Query parameters of `GET /webhooks/{webhookId}/deliveries` (listWebhookDeliveries). */ +export interface ListWebhookDeliveriesQuery { + /** Cursor for the next or previous page. */ + cursor?: string; + sort?: ListWebhookDeliveriesQuerySortItem[]; + /** Filter by delivery status */ + 'filter[status]'?: WebhookDeliveryStatus; + /** Filter by event type */ + 'filter[event_type]'?: string; + /** Filter deliveries from this date (Y-m-d format) */ + 'filter[from_date]'?: string; + /** Filter deliveries to this date (Y-m-d format) */ + 'filter[to_date]'?: string; +} + +export type ListWebhookDeliveriesQuerySortItem = + | 'created_at' + | '-created_at' + | 'attempt_number' + | '-attempt_number' + | (string & {}); + +export type ListWebhookDeliveriesResponse = CursorPage; + +/** Query parameters of `GET /webhooks` (listWebhooks). */ +export interface ListWebhooksQuery { + 'page[size]'?: number; + /** Cursor for the next or previous page. */ + cursor?: string; + sort?: ListWebhooksQuerySortItem[]; + /** Filter by project ID */ + 'filter[project]'?: string; + /** Filter by enabled status */ + 'filter[enabled]'?: boolean; + /** Filter by specific event type */ + 'filter[event]'?: WebhookEvent; + /** Filter by route ID */ + 'filter[route_id]'?: string; + /** Filter by webhook scope */ + 'filter[scope]'?: WebhookScope; + /** Search by webhook name or URL */ + 'filter[search]'?: string; +} + +export type ListWebhooksQuerySortItem = + | 'name' + | '-name' + | 'url' + | '-url' + | 'created_at' + | '-created_at' + | (string & {}); + +export type ListWebhooksResponse = CursorPage; + +export interface MessageAttachmentData { + size: number; + filename: string; + content_id: string | null; + content_type: string; +} + +/** A file attached to the message. */ +export interface MessageAttachmentInput { + filename: string; + content: string; + /** + * The MIME type of the attachment. Supports parameters (e.g. `text/calendar; method=REQUEST`). + * If omitted, detected automatically from content or filename. + */ + content_type?: string | null; + /** + * Content ID for inline attachments, referenced via `cid:` in HTML body. + * If no `@` is present, `@lm` is appended automatically. + */ + content_id?: string | null; +} + +export interface MessageData { + id: string; + type: MessageType; + status: MessageStatus; + delivery_mode: DeliveryMode; + sandbox_result: SandboxResult | null; + status_changed_at: string | null; + scheduled_at: string | null; + tag: string | null; + tags: MessageTag[]; + from_email: string; + from_name: string | null; + reply_to: string[] | null; + subject: string | null; + to: MessageRecipientData[] | null; + cc: MessageRecipientData[] | null; + bcc: MessageRecipientData[] | null; + attachments: MessageAttachmentData[] | null; + metadata: Record | null; + /** Overall spam assessment score. Scores below 5 are clean, 5 through 10 indicate potential spam, scores above 10 and below 15 are likely spam, and 15 or higher is definite spam. Null means no assessment is available. Available on plans with Spam Insights */ + spam_score?: number | null; + /** Detailed rules that contributed to the spam assessment. Available on plans with Spam Insights in individual-message responses */ + spam_symbols?: SpamSymbol[]; + route_id: string; + created_at: string; +} + +export interface MessageEventData { + message_id: string; + event: MessageEventType; + tag: string | null; + tags: MessageTag[]; + metadata: Record | null; + timestamp: string; +} + +export type MessageEventType = + | 'scheduled' + | 'rescheduled' + | 'canceled' + | 'released' + | 'queued' + | 'processed' + | 'suppressed' + | 'delivered' + | 'auto_replied' + | 'soft_bounced' + | 'hard_bounced' + | 'spam_complaint' + | 'failed' + | 'blocked' + | 'policy_rejected' + | 'unsubscribed' + | 'opened' + | 'clicked' + | 'inbound_received' + | 'inbound_queued' + | 'inbound_spam_blocked' + | 'inbound_released' + | 'inbound_processed' + | 'inbound_retry' + | (string & {}); + +export interface MessageListData { + id: string; + type: MessageType; + status: MessageStatus; + delivery_mode: DeliveryMode; + sandbox_result: SandboxResult | null; + scheduled_at: string | null; + /** Overall spam assessment score. Scores below 5 are clean, 5 through 10 indicate potential spam, scores above 10 and below 15 are likely spam, and 15 or higher is definite spam. Null means no assessment is available. Available on plans with Spam Insights */ + spam_score?: number | null; + from_email: string; + from_name: string | null; + subject: string | null; + to: MessageRecipientData[] | null; + cc: MessageRecipientData[] | null; + bcc: MessageRecipientData[] | null; + reply_to: string[] | null; + tag: string | null; + tags: MessageTag[]; + status_changed_at: string | null; + created_at: string; +} + +export interface MessageRecipientData { + email: string; + name: string | null; + /** The simulated result for this recipient in a Sandbox project. Lettermint test addresses override the message result. Null for Live messages */ + sandbox_result: SandboxResult | null; +} + +/** Confirmation that a request succeeded. */ +export interface MessageResponse { + /** Human-readable confirmation */ + message: string; +} + +export interface MessageStatsData { + messages_transactional: number; + messages_broadcast: number; + messages_inbound: number; + deliverability: number; +} + +export type MessageStatus = + | 'scheduled' + | 'pending' + | 'queued' + | 'quarantined' + | 'suppressed' + | 'processed' + | 'delivered' + | 'opened' + | 'clicked' + | 'soft_bounced' + | 'hard_bounced' + | 'spam_complaint' + | 'failed' + | 'blocked' + | 'policy_rejected' + | 'unsubscribed' + | 'canceled' + | (string & {}); + +export interface MessageTag { + name: string; + value: string; +} + +/** A reusable exact-match tag with a name and a value. */ +export interface MessageTagInput { + name: string; + value: string; +} + +export type MessageType = 'inbound' | 'outbound' | (string & {}); + +/** Not found */ +export type ModelNotFoundException = ErrorMessage; + +/** An accepted message that is queued for delivery. */ +export interface PendingSendMailResponse { + message_id: string; + status: 'pending'; + /** Present when the project uses Sandbox delivery */ + sandbox?: true; + /** The simulated delivery result. Present when the project uses Sandbox delivery */ + sandbox_result?: SandboxResult; +} + +export type Plan = 'free' | 'starter' | 'growth' | 'pro' | (string & {}); + +export interface ProcessInboundMessageConflict { + error: ProcessInboundMessageConflictError; +} + +export interface ProcessInboundMessageConflictError { + code: ProcessInboundMessageConflictErrorCode; + message: string; +} + +export type ProcessInboundMessageConflictErrorCode = + | 'INBOUND_MESSAGE_NOT_QUARANTINED' + | 'INBOUND_WEBHOOK_NOT_CONFIGURED' + | 'MESSAGE_SOURCE_UNAVAILABLE' + | (string & {}); + +/** The queued release of a quarantined inbound message. */ +export interface ProcessInboundMessageResponse { + data: ProcessInboundMessageResult; +} + +/** A quarantined inbound message that was queued for webhook delivery. */ +export interface ProcessInboundMessageResult { + message_id: string; + status: 'queued'; + /** The number of webhooks the message is delivered to */ + webhook_target_count: number; +} + +export type ProjectAccessScope = 'all' | 'selected' | (string & {}); + +export interface ProjectCreatedData { + data: ProjectData; + message: string; + api_token?: string; +} + +export interface ProjectData { + id: string; + name: string; + delivery_mode: DeliveryMode; + smtp_enabled: boolean; + redact_email_content: boolean; + default_route_id: string | null; + token_generated_at: string | null; + token_last_used_at: string | null; + token_last_used_ip: string | null; + routes?: RouteData[]; + routes_count?: number; + domains?: DomainData[]; + domains_count?: number; + last_28_days?: MessageStatsData | null; + created_at: string; + updated_at: string; +} + +export interface ProjectListData { + id: string; + name: string; + delivery_mode: DeliveryMode; + smtp_enabled: boolean; + routes_count: number; + domains_count: number; + last_28_days: MessageStatsData; + created_at: string; + updated_at: string; +} + +/** The project after a change, with a confirmation message. */ +export interface ProjectMutationResponse { + data: ProjectData; + /** Human-readable confirmation */ + message: string; +} + +export type RbacConflictCode = + | 'stale_resource' + | 'owner_protected' + | 'last_owner' + | 'built_in_role_immutable' + | 'custom_role_requires_pro' + | (string & {}); + +/** Role or project-access state conflict */ +export interface RbacConflictException { + error: RbacConflictExceptionError; +} + +export interface RbacConflictExceptionError { + code: RbacConflictCode; + message: string; +} + +export type RbacPermission = + | 'team:manage' + | 'billing:manage' + | 'security:manage' + | 'audit:read' + | 'support:manage' + | 'members:read' + | 'members:manage' + | 'roles:manage' + | 'team_tokens:read' + | 'team_tokens:manage' + | 'team_tokens:rotate' + | 'team_tokens:revoke' + | 'projects:create' + | 'team_suppressions:read' + | 'team_suppressions:add' + | 'team_suppressions:remove' + | 'projects:read' + | 'projects:manage' + | 'projects:delete' + | 'routes:read' + | 'routes:manage' + | 'routes:delete' + | 'domains:read' + | 'domains:manage' + | 'domains:delete' + | 'project_tokens:read' + | 'project_tokens:manage' + | 'project_tokens:rotate' + | 'project_tokens:revoke' + | 'webhooks:read' + | 'webhooks:manage' + | 'webhooks:delete' + | 'webhooks:rotate_secret' + | 'stats:read' + | 'analytics:read' + | 'messages:read' + | 'messages:read_content' + | 'messages:send' + | 'suppressions:read' + | 'suppressions:add' + | 'suppressions:remove' + | (string & {}); + +export type RecordType = 'TXT' | 'CNAME' | 'MX' | (string & {}); + +export interface ReportForwardingRequest { + destination: string; +} + +export interface ReportForwardingResource { + destination: string | null; + verified: boolean; + verified_at: string | null; +} + +export interface RescheduleMessageRequest { + scheduled_at: string; +} + +export interface ResendReportForwardingCodeResponse { + data: ReportForwardingResource; +} + +/** The project and its new legacy Project API token. */ +export interface RotateProjectTokenResponse { + data: ProjectData; + /** The new Project API token. It is only returned once */ + new_token: string; + /** Human-readable confirmation */ + message: string; +} + +export interface RouteData { + id: string; + project_id: string; + slug: string; + name: string; + route_type: RouteType; + is_default: boolean; + /** Permanent compatibility address for this inbound route (`@inbound.lettermint.co`). It keeps working alongside the catch-all route domain */ + inbound_address?: string | null; + /** Catch-all receiving domain for this inbound route. Mail to any address at this domain reaches the route. Null for non-inbound routes */ + inbound_route_domain?: string | null; + inbound_mx_hostname?: string; + inbound_domain?: string | null; + inbound_domain_verified_at?: string | null; + inbound_spam_threshold?: number | null; + attachment_delivery?: AttachmentDelivery; + /** Route settings available for this route type and team feature access */ + settings?: RouteDataSettings | null; + project?: ProjectData; + webhooks_count?: number; + suppressed_recipients_count?: number; + statistics?: RouteStatisticData[]; + created_at: string; + updated_at: string; +} + +/** Route settings available for this route type and team feature access */ +export interface RouteDataSettings { + disable_hosted_unsubscribe?: boolean; + track_opens?: boolean; + track_clicks?: boolean; + generate_plaintext_fallback?: boolean; + suppress_auto_responders?: boolean; + suppress_disposable_recipients?: boolean; + tls?: TlsPolicy; + redact_email_content?: boolean; + attachment_delivery?: AttachmentDelivery; +} + +export interface RouteListData { + id: string; + slug: string; + name: string; + route_type: RouteType; + is_default: boolean; + webhooks_count: number; + suppressed_recipients_count: number; + created_at: string; + updated_at: string; +} + +/** The route after it was created or changed, with a confirmation message. */ +export interface RouteMutationResponse { + data: RouteData; + /** Human-readable confirmation */ + message: string; +} + +export interface RouteStatisticData { + date: string; + sent_count: number; + delivered_count: number; + opened_count: number; + clicked_count: number; + hard_bounce_count: number; + spam_complaint_count: number; + inbound_received_count: number; + observed_opened_count?: number | null; + human_opened_count?: number | null; + privacy_opened_count?: number | null; + effective_opened_count: number | null; + machine_opened_count: number | null; + machine_clicked_count: number | null; +} + +export type RouteType = 'transactional' | 'broadcast' | 'inbound' | (string & {}); + +export type SandboxResult = + | 'delivered' + | 'hard_bounced' + | 'soft_bounced' + | 'deferred' + | 'failed' + | 'suppressed' + | 'spam_complaint' + | 'auto_replied' + | 'opened' + | 'clicked' + | 'unsubscribed' + | (string & {}); + +/** The schedule state of a message after it was rescheduled or canceled. */ +export interface ScheduledMessage { + message_id: string; + status: MessageStatus | null; + /** The scheduled delivery time in ISO 8601 format */ + scheduled_at: string | null; +} + +/** An accepted message that is scheduled for later delivery. */ +export interface ScheduledSendMailResponse { + message_id: string; + status: 'scheduled'; + /** The scheduled delivery time in ISO 8601 format */ + scheduled_at: string; + /** Present when the project uses Sandbox delivery */ + sandbox?: true; + /** The simulated delivery result. Present when the project uses Sandbox delivery */ + sandbox_result?: SandboxResult; +} + +export type SendBatchMailRequest = SendMailRequest[]; + +export type SendBatchMailResponse = SendMailResponse[]; + +export interface SendMailRequest { + route?: string; + from: string; + to: string[]; + cc?: string[]; + bcc?: string[]; + reply_to?: string[]; + subject: string; + /** ISO 8601 or English delivery time. A time without a timezone uses UTC. */ + scheduled_at?: string; + /** The result that a Sandbox project simulates for every recipient. */ + sandbox_result?: SandboxResult; + /** + * Custom headers to include in the email. To preserve a submitted RFC Message-ID, + * include both Message-ID and X-LM-Preserve-Message-ID with a string value of true or 1. + * Header names and control values are case-insensitive; false or 0 retains replacement. + */ + headers?: Record; + /** Metadata to track with the email (not added as email headers). */ + metadata?: Record; + /** Tag to categorize and filter emails (alphanumeric, underscores, hyphens, spaces allowed). */ + tag?: string | null; + /** Reusable exact-match tags. Names must be unique and are case-sensitive. */ + tags?: MessageTagInput[]; + /** Per-email settings that override the selected route settings for this email only. */ + settings?: SendMailRequestSettings; + html?: string | null; + text?: string | null; + attachments?: MessageAttachmentInput[]; +} + +/** Per-email settings that override the selected route settings for this email only. */ +export interface SendMailRequestSettings { + track_opens?: boolean; + track_clicks?: boolean; + tls?: TlsPolicy; +} + +export type SendMailResponse = PendingSendMailResponse | ScheduledSendMailResponse; + +export interface SpamSymbol { + name: string; + score: number; + options: string[]; + description: string | null; +} + +export interface StatsDailyData { + date: string; + sent: number; + delivered: number; + hard_bounced: number; + spam_complaints: number; + /** Null when tracking is not enabled for this context */ + opened: number | null; + /** Null when tracking is not enabled for this context */ + clicked: number | null; + inbound: StatsInboundData; + /** Null for team-scoped stats (only available when project_id is specified) */ + transactional: StatsTypeData | null; + /** Null for team-scoped stats (only available when project_id is specified) */ + broadcast: StatsTypeData | null; + /** Human opens plus unresolved privacy-proxy opens */ + observed_opened?: number | null; + human_opened?: number | null; + privacy_opened?: number | null; + effective_opened: number | null; + machine_opened: number | null; + machine_clicked: number | null; +} + +export interface StatsData { + from: string; + to: string; + totals: StatsTotalsData; + daily: StatsDailyData[]; +} + +export interface StatsInboundData { + received: number; +} + +export interface StatsTotalsData { + sent: number; + delivered: number; + hard_bounced: number; + spam_complaints: number; + /** Null when tracking is not enabled for this context */ + opened: number | null; + /** Null when tracking is not enabled for this context */ + clicked: number | null; + inbound: StatsInboundData; + /** Null for team-scoped stats (only available when project_id is specified) */ + transactional: StatsTypeData | null; + /** Null for team-scoped stats (only available when project_id is specified) */ + broadcast: StatsTypeData | null; + /** Human opens plus unresolved privacy-proxy opens */ + observed_opened?: number | null; + human_opened?: number | null; + privacy_opened?: number | null; + effective_opened: number | null; + machine_opened: number | null; + machine_clicked: number | null; +} + +export interface StatsTypeData { + sent: number; + hard_bounced: number; + spam_complaints: number; +} + +export interface StoreDomainData { + domain: string; +} + +export interface StoreProjectData { + name: string; + smtp_enabled?: boolean; + delivery_mode?: DeliveryMode; + initial_routes?: InitialRoutes; + short_token?: boolean; + redact_email_content?: boolean; +} + +export interface StoreRouteData { + name: string; + route_type: RouteType; + slug?: string | null; + settings?: UpdateRouteSettingsData | null; + inbound_settings?: UpdateRouteInboundSettingsData | null; + inbound_domain?: string | null; + inbound_spam_threshold?: number | null; + attachment_delivery?: AttachmentDelivery | null; +} + +export interface StoreSuppressionData { + email?: string | null; + emails?: string[] | null; + reason: SuppressionCreateReason; + scope: SuppressionCreateScope; + route_id?: string | null; + project_id?: string | null; + /** Mail category to block. Manual suppressions default to `all`. Use `broadcast` only at team scope, project scope, or on a broadcast route */ + applies_to?: SuppressionAppliesTo | null; +} + +export interface StoreWebhookData { + name: string; + url: string; + events: WebhookEvent[]; + enabled?: boolean | null; + include_machine_events?: boolean | null; + delivery_mode_filter?: WebhookDeliveryModeFilter | null; + scope?: WebhookScope | null; + project_ids?: string[]; + route_ids?: string[]; + route_id?: string | null; + /** Write-only credentials. Omit to keep credentials. Null removes Basic Auth */ + basic_auth?: WebhookBasicAuthData | null; +} + +export interface SuppressedRecipientData { + id: string; + type: SuppressionType; + value: string; + reason: SuppressionReason; + scope: SuppressionScope; + applies_to: SuppressionAppliesTo; + project_id: string | null; + route_id: string | null; + source_message?: SuppressionSourceMessageData | null; + created_at: string; +} + +/** Mail category blocked by the suppression. `all` blocks transactional and broadcast mail. `broadcast` blocks broadcast mail only. */ +export type SuppressionAppliesTo = 'all' | 'broadcast' | (string & {}); + +/** Reason that the recipient is suppressed. The `disposable_email` reason is reserved for route policies. */ +export type SuppressionCreateReason = + | 'spam_complaint' + | 'hard_bounce' + | 'unsubscribe' + | 'manual' + | (string & {}); + +/** Where the suppression applies. The `global` and `subscription_group` scopes cannot be created through the API. */ +export type SuppressionCreateScope = 'team' | 'project' | 'route' | (string & {}); + +/** Confirmation that a suppression was removed. */ +export interface SuppressionDeletedResponse { + success: true; + status: 'removed'; + /** Human-readable confirmation */ + message: string; + /** Confidence of the automated removal assessment, when one ran */ + confidence?: number; +} + +/** Reason that the recipient is suppressed. */ +export type SuppressionReason = + | 'spam_complaint' + | 'hard_bounce' + | 'unsubscribe' + | 'manual' + | 'disposable_email' + | (string & {}); + +/** A suppression that stays active while a support ticket reviews its removal. */ +export interface SuppressionReviewResponse { + success: true; + status: SuppressionReviewResponseStatus; + /** Human-readable confirmation */ + message: string; + /** The identifier of the support ticket that reviews the removal */ + ticket_identifier: string; + /** Confidence of the automated removal assessment, when one ran */ + confidence?: number; +} + +export type SuppressionReviewResponseStatus = + | 'review_ticket_created' + | 'review_ticket_exists' + | (string & {}); + +/** Where the suppression applies. */ +export type SuppressionScope = + | 'global' + | 'team' + | 'project' + | 'route' + | 'subscription_group' + | (string & {}); + +export interface SuppressionSourceMessageData { + id: string; + available: boolean; + subject: string | null; + created_at: string | null; +} + +/** The outcome of adding addresses to the suppression list. */ +export interface SuppressionStoreResponse { + /** Human-readable summary */ + message: string; + data: SuppressionStoreResult; +} + +/** The addresses that were added and the addresses that were already suppressed. */ +export interface SuppressionStoreResult { + created: string[]; + skipped: string[]; +} + +export type SuppressionType = 'email' | 'domain' | 'extension' | (string & {}); + +export interface TeamAddonData { + type: string | null; + expires_at: string | null; +} + +export interface TeamData { + id: string; + name: string; + type: TeamType; + plan: Plan; + /** Number of emails included in the team's monthly plan */ + included_volume: number; + /** + * Deprecated alias for `included_volume` + * @deprecated + */ + tier: number; + verified_at: string | null; + features?: string[]; + addons?: TeamAddonData[]; + created_at: string; + domains_count?: number; + projects_count?: number; + members_count?: number; +} + +export interface TeamMemberData { + id: string; + name: string; + email: string; + role: TeamMemberDataRole; + project_access: TeamMemberProjectAccessData; + joined_at: string | null; +} + +export interface TeamMemberDataRole { + id: string; + name: string; +} + +export interface TeamMemberProjectAccessData { + scope: ProjectAccessScope; + projects: TeamMemberProjectAccessDataProjectsItem[]; +} + +export interface TeamMemberProjectAccessDataProjectsItem { + id: string; + name: string; +} + +/** The team after a settings change, with a confirmation message. */ +export interface TeamMutationResponse { + data: TeamData; + /** Human-readable confirmation */ + message: string; +} + +export interface TeamRoleData { + id: string; + name: string; + system_key: BuiltInTeamRole | null; + permissions: RbacPermission[]; + assignable: boolean; +} + +/** The reusable roles of the team. */ +export interface TeamRoleListResponse { + data: TeamRoleData[]; +} + +export type TeamType = 'personal' | 'business' | (string & {}); + +export interface TeamUsageDetailData { + current_period: TeamUsagePeriodData; + historical_usage: TeamUsagePeriodData[]; +} + +export interface TeamUsagePeriodData { + usage: number; + last_incremented_at: string | null; + period_start: string; + period_end: string; +} + +/** Confirmation that a test delivery was queued. */ +export interface TestWebhookResponse { + /** Human-readable confirmation */ + message: string; + /** The ID of the queued test delivery */ + delivery_id: string; +} + +export type TlsPolicy = 'opportunistic' | 'enforced' | (string & {}); + +/** Too many requests. */ +export type TooManyRequests = ErrorMessage; + +export interface UpdateDomainProjectsData { + project_ids: string[]; +} + +export interface UpdateProjectData { + name?: string | null; + smtp_enabled?: boolean | null; + redact_email_content?: boolean | null; + delivery_mode?: DeliveryMode | null; + default_route_id?: string | null; +} + +export interface UpdateReportForwardingResponse { + data: ReportForwardingResource; +} + +export interface UpdateRouteData { + name?: string | null; + /** Route settings. `track_opens` and `track_clicks` apply to transactional and broadcast routes when email tracking is available. `generate_plaintext_fallback` opts in to generated plaintext fallbacks for HTML-only outbound messages; customer-provided plaintext is always preserved. `suppress_auto_responders` adds outbound headers that suppress auto-responders. `suppress_disposable_recipients` skips matching recipients for each message without adding them to suppression lists. `tls` controls whether TLS is opportunistic or enforced. `disable_hosted_unsubscribe` applies to broadcast routes when enabled for the team. `redact_email_content` requires email redaction access */ + settings?: UpdateRouteSettingsData | null; + /** Inbound route settings. Only accepted for inbound routes */ + inbound_settings?: UpdateRouteInboundSettingsData | null; + inbound_domain?: string | null; + inbound_spam_threshold?: number | null; + attachment_delivery?: AttachmentDelivery | null; +} + +export interface UpdateRouteInboundSettingsData { + /** Custom receiving domain for inbound routes, such as support.example.com or *.m.example.com. Address patterns such as *@*.m.example.com are normalized */ + inbound_domain?: string | null; + /** Inbound spam threshold for inbound routes. Higher values are more permissive */ + inbound_spam_threshold?: number | null; + /** Inbound attachment delivery mode */ + attachment_delivery?: AttachmentDelivery | null; +} + +export interface UpdateRouteSettingsData { + /** Enable open tracking for transactional and broadcast routes. Requires email tracking access */ + track_opens?: boolean | null; + /** Enable click tracking for transactional and broadcast routes. Requires email tracking access */ + track_clicks?: boolean | null; + /** Opt in to generated plaintext fallbacks for HTML-only outbound messages on transactional and broadcast routes. Customer-provided plaintext is always preserved */ + generate_plaintext_fallback?: boolean | null; + /** Add outbound headers that suppress auto-responders for transactional and broadcast routes. Customer-provided header values are preserved */ + suppress_auto_responders?: boolean | null; + /** Skip disposable email recipients for transactional and broadcast routes. This check applies to each message and does not add recipients to suppression lists */ + suppress_disposable_recipients?: boolean | null; + /** TLS delivery policy for transactional and broadcast routes */ + tls?: TlsPolicy | null; + /** Disable Lettermint-hosted unsubscribe link injection for broadcast routes. Available only when enabled for the team */ + disable_hosted_unsubscribe?: boolean | null; + /** Redact email content in the dashboard for this route. Requires email redaction access */ + redact_email_content?: boolean | null; +} + +export interface UpdateTeamData { + name?: string; +} + +export interface UpdateTeamMemberAssignmentData { + role_id: string; + project_access: UpdateTeamMemberAssignmentDataProjectAccess; +} + +export interface UpdateTeamMemberAssignmentDataProjectAccess { + scope: ProjectAccessScope; + project_ids?: string[]; +} + +export interface UpdateWebhookData { + name?: string; + url?: string; + events?: WebhookEvent[]; + enabled?: boolean; + include_machine_events?: boolean; + delivery_mode_filter?: WebhookDeliveryModeFilter; + scope?: WebhookScope; + project_ids?: string[]; + route_ids?: string[]; + route_id?: string | null; + /** Write-only credentials. Omit to keep credentials. Null removes Basic Auth */ + basic_auth?: WebhookBasicAuthData | null; +} + +export interface ValidationError { + /** Errors overview. */ + message: string; + /** A detailed description of each field that failed validation. */ + errors: Record; +} + +/** Validation error */ +export type ValidationException = ValidationError; + +export interface VerifyReportForwardingRequest { + code: string; +} + +export interface VerifyReportForwardingResponse { + data: ReportForwardingResource; +} + +export interface WebhookBasicAuthData { + username: string; + password: string; +} + +export interface WebhookData { + id: string; + scope: WebhookScope; + project_ids: string[]; + route_ids: string[]; + /** + * Use route_ids. + * @deprecated + */ + route_id: string | null; + name: string; + url: string; + has_basic_auth: boolean; + events: string[]; + enabled: boolean; + include_machine_events: boolean; + delivery_mode_filter: WebhookDeliveryModeFilter; + last_called_at: string | null; + created_at: string; + updated_at: string; +} + +export interface WebhookDeliveryData { + id: string; + webhook_id: string; + event_type: WebhookEvent; + source_scope: string | null; + source_project_id: string | null; + source_route_id: string | null; + status: WebhookDeliveryStatus; + sandbox: boolean; + attempt_number: number; + http_status_code: number | null; + duration_ms: number | null; + payload: string[]; + response_body: string | null; + response_headers: string[] | null; + error_message: string | null; + delivered_at: string | null; + timestamp: string; +} + +export interface WebhookDeliveryListData { + id: string; + webhook_id: string; + event_type: WebhookEvent; + source_scope: string | null; + source_project_id: string | null; + source_route_id: string | null; + status: WebhookDeliveryStatus; + sandbox: boolean; + attempt_number: number; + http_status_code: number | null; + duration_ms: number | null; + delivered_at: string | null; + created_at: string; +} + +export type WebhookDeliveryModeFilter = 'live' | 'sandbox' | 'both' | (string & {}); + +export type WebhookDeliveryStatus = + | 'pending' + | 'success' + | 'failed' + | 'client_error' + | 'server_error' + | 'timeout' + | (string & {}); + +export type WebhookEvent = + | 'message.created' + | 'message.sent' + | 'message.delivered' + | 'message.auto_replied' + | 'message.hard_bounced' + | 'message.soft_bounced' + | 'message.spam_complaint' + | 'message.failed' + | 'message.suppressed' + | 'message.unsubscribed' + | 'message.opened' + | 'message.clicked' + | 'message.inbound' + | 'message.policy_rejected' + | 'message.scheduled' + | 'message.rescheduled' + | 'message.canceled' + | 'message.released' + | 'suppression.added' + | 'suppression.removed' + | 'webhook.test' + | (string & {}); + +export interface WebhookListData { + id: string; + scope: WebhookScope; + project_ids: string[]; + route_ids: string[]; + /** + * Use route_ids. + * @deprecated + */ + route_id: string | null; + name: string; + url: string; + has_basic_auth: boolean; + events: string[]; + enabled: boolean; + delivery_mode_filter: WebhookDeliveryModeFilter; + last_called_at: string | null; + created_at: string; + updated_at: string; +} + +/** The webhook after a change, with a confirmation message. */ +export interface WebhookMutationResponse { + data: WebhookData; + /** Human-readable confirmation */ + message: string; +} + +export type WebhookScope = 'team' | 'project' | 'route' | (string & {}); + +export interface WebhookSecretData { + id: string; + scope: WebhookScope; + project_ids: string[]; + route_ids: string[]; + /** + * Use route_ids. + * @deprecated + */ + route_id: string | null; + name: string; + url: string; + has_basic_auth: boolean; + events: string[]; + enabled: boolean; + include_machine_events: boolean; + delivery_mode_filter: WebhookDeliveryModeFilter; + secret: string; + last_called_at: string | null; + created_at: string; + updated_at: string; +} + +/** The webhook with its signing secret, which is only returned when the secret is created or regenerated. */ +export interface WebhookSecretResponse { + data: WebhookSecretData; + /** Human-readable confirmation */ + message: string; +} diff --git a/src/index.spec.ts b/src/index.spec.ts deleted file mode 100644 index 0bd2531..0000000 --- a/src/index.spec.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { EmailEndpoint, Lettermint, LettermintClient } from '.'; - -jest.mock('./client'); -jest.mock('./endpoints/email'); - -describe('Lettermint', () => { - const apiToken = 'test-api-key'; - let lettermint: Lettermint; - - beforeEach(() => { - jest.clearAllMocks(); - - lettermint = new Lettermint({ apiToken }); - }); - - it('should initialize with the provided API token', () => { - // Verify that LettermintClient was initialized with the correct config - expect(LettermintClient).toHaveBeenCalledWith({ apiToken }); - }); - - it('should initialize with custom options', () => { - const config = { - apiToken, - baseUrl: 'https://custom-api.example.com', - timeout: 5000, - }; - - const customLettermint = new Lettermint(config); - - // Verify that LettermintClient was initialized with the correct config - expect(LettermintClient).toHaveBeenCalledWith(config); - }); - - it('should initialize the email endpoint', () => { - // Verify that EmailEndpoint was initialized with the client - expect(EmailEndpoint).toHaveBeenCalledWith(expect.any(LettermintClient)); - - // Verify that the email property is an instance of EmailEndpoint - expect(lettermint.email).toBeInstanceOf(EmailEndpoint); - }); -}); diff --git a/src/index.ts b/src/index.ts index 543c35f..337d500 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,65 @@ -export * from './client'; -export * from './endpoints/endpoint'; -export * from './endpoints/email'; -export * from './endpoints/api'; -export * from './types'; -export * from './utils/errors'; -export * from './lettermint'; -export * from './webhook'; +import { Lettermint } from './lettermint'; + +export { Lettermint }; +export type { LettermintOptions, LettermintShorthandOptions } from './lettermint'; +export type { + EmailAttachment, + EmailBuilder, + EmailMessage, + EmailMessageAttachment, + Emails, + SendOptions, +} from './emails'; +export type { Domains } from './resources/domains'; +export type { Messages } from './resources/messages'; +export type { Projects, ReportForwarding } from './resources/projects'; +export type { Routes } from './resources/routes'; +export type { Stats, Suppressions, Team, TeamMembers } from './resources/team'; +export type { WebhookDeliveries, Webhooks } from './resources/webhooks'; +export type { FetchFunction, IdempotentRequestOptions, RequestOptions } from './transport'; +export type { NestedQuery } from './query'; +export type { BinaryInput } from './encoding'; +export { Webhook } from './webhook'; +export type { WebhookBody, WebhookHeaders, WebhookOptions, WebhookPayload } from './webhook'; +export { + ApiError, + AuthenticationError, + ConflictError, + ConnectionError, + LettermintConfigError, + LettermintError, + LettermintValidationError, + NotFoundError, + PermissionError, + RateLimitError, + RedirectError, + ServerError, + TimeoutError, + UnexpectedResponseError, + ValidationError, + WebhookVerificationError, +} from './errors'; +export type { ApiErrorInit, WebhookVerificationReason } from './errors'; + +// Generated API types. `ApiError` and `ValidationError` are error classes in the +// SDK, so their body types are exported as `ApiErrorBody` and `ValidationErrorBody`. +export type * from './generated/types'; +export type { + ApiError as ApiErrorBody, + ValidationError as ValidationErrorBody, +} from './generated/types'; +export type { + AuthSurface, + OperationDefinition, + OperationError, + OperationErrorStatus, + OperationKey, + OperationPathParams, + OperationQuery, + OperationRequest, + OperationResponse, + OperationTypes, + Operations, +} from './generated/operations'; + +export default Lettermint; diff --git a/src/lettermint.ts b/src/lettermint.ts index 005bf88..256903d 100644 --- a/src/lettermint.ts +++ b/src/lettermint.ts @@ -1,94 +1,176 @@ -import { LettermintClient, type LettermintClientConfig } from './client'; +import { Emails } from './emails'; +import { LettermintConfigError } from './errors'; +import type { AnalyticsQuery, AnalyticsResponse, BlockedFileTypes } from './generated/types'; +import { REDACTED, inspectCustom, renderInspect } from './redact'; +import { Domains } from './resources/domains'; +import { Messages } from './resources/messages'; +import { Projects } from './resources/projects'; +import { Routes } from './resources/routes'; +import { Stats, Suppressions, Team } from './resources/team'; +import { Webhooks } from './resources/webhooks'; +import { checkToken, detectTokenKind } from './tokens'; import { - DomainsEndpoint, - MessagesEndpoint, - ProjectsEndpoint, - RoutesEndpoint, - StatsEndpoint, - SuppressionsEndpoint, - TeamEndpoint, - WebhooksEndpoint, -} from './endpoints/api'; -import { EmailEndpoint } from './endpoints/email'; -import type * as Types from './types'; + DEFAULT_BASE_URL, + DEFAULT_TIMEOUT, + type FetchFunction, + type RequestOptions, + Transport, + checkBaseUrl, + checkTimeout, +} from './transport'; + +/** Client options. Pass at least one token. */ +export interface LettermintOptions { + /** A project sending token (`lm_...`), sent as `x-lettermint-token`. Used by `emails.*`. */ + sendingToken?: string; + /** A team API token (`lm_team_...`), sent as `Authorization: Bearer`. Used by the Team API. */ + teamToken?: string; + /** API base URL. Default `https://api.lettermint.co/v1`. */ + baseUrl?: string; + /** Request timeout in milliseconds, covering headers and body. Default 30000. */ + timeout?: number; + /** A `fetch` implementation. Default: the global `fetch`. */ + fetch?: FetchFunction; +} + +/** Options for the token shorthand `new Lettermint(token, options)`. */ +export type LettermintShorthandOptions = Omit; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} /** - * Lettermint SDK + * The Lettermint client. + * + * ```ts + * const lettermint = new Lettermint({ sendingToken, teamToken }); + * const lettermint = new Lettermint('lm_...'); // team or sending token, detected by prefix + * ``` + * + * `emails.*` uses the sending token; every other part uses the team token. + * The client holds no message state and is safe to share across requests. */ export class Lettermint { - /** - * The Lettermint client - */ - private readonly client: LettermintClient; + readonly #transport: Transport; - /** - * Email endpoint for sending emails - */ - public readonly email: EmailEndpoint; + /** Send email. Needs `sendingToken`. */ + public readonly emails: Emails; + /** Sending domains. Needs `teamToken`. */ + public readonly domains: Domains; + /** Sent and received messages. Needs `teamToken`. */ + public readonly messages: Messages; + /** Projects and their report forwarding. Needs `teamToken`. */ + public readonly projects: Projects; + /** Routes of a project. Needs `teamToken`. */ + public readonly routes: Routes; + /** Sending statistics. Needs `teamToken`. */ + public readonly stats: Stats; + /** The suppression list. Needs `teamToken`. */ + public readonly suppressions: Suppressions; + /** The team and its members. Needs `teamToken`. */ + public readonly team: Team; + /** Webhook endpoints and their deliveries. Needs `teamToken`. */ + public readonly webhooks: Webhooks; - /** - * Create a new Lettermint SDK instance - * - * @param config Configuration options - */ - constructor(config: LettermintClientConfig) { - this.client = new LettermintClient(config); - this.email = new EmailEndpoint(this.client); - } + constructor(options: LettermintOptions); + constructor(token: string, options?: LettermintShorthandOptions); + constructor( + tokenOrOptions: string | LettermintOptions, + shorthandOptions?: LettermintShorthandOptions + ) { + let options: LettermintOptions; + if (typeof tokenOrOptions === 'string') { + if (shorthandOptions !== undefined && !isRecord(shorthandOptions)) { + throw new LettermintConfigError('The options argument must be an object.'); + } + const kind = detectTokenKind(tokenOrOptions); + // Token options are ignored with the shorthand: the string decides. + const { + sendingToken: _sending, + teamToken: _team, + ...rest + } = (shorthandOptions ?? {}) as LettermintOptions; + options = { ...rest, [kind === 'team' ? 'teamToken' : 'sendingToken']: tokenOrOptions }; + } else if (isRecord(tokenOrOptions)) { + options = tokenOrOptions; + } else { + throw new LettermintConfigError( + 'Pass an options object ({ sendingToken, teamToken }) or a token string.' + ); + } - public static email( - apiToken: string, - config?: Omit - ): EmailEndpoint { - return new EmailEndpoint(new LettermintClient({ ...config, apiToken })); - } + if ('apiToken' in options) { + throw new LettermintConfigError( + '`apiToken` was removed in 3.0; pass `sendingToken` (project token) or `teamToken` (team API token).' + ); + } + const sendingToken = checkToken('sendingToken', options.sendingToken); + const teamToken = checkToken('teamToken', options.teamToken); + if (sendingToken === undefined && teamToken === undefined) { + throw new LettermintConfigError('Pass `sendingToken`, `teamToken` or both.'); + } + const fetchFunction = + options.fetch ?? + (typeof globalThis.fetch === 'function' ? globalThis.fetch.bind(globalThis) : undefined); + if (typeof fetchFunction !== 'function') { + throw new LettermintConfigError( + 'No global fetch is available; pass a fetch implementation as the `fetch` option.' + ); + } - public static api( - apiToken: string, - config?: Omit - ): ApiClient { - return new ApiClient(apiToken, config); + this.#transport = new Transport({ + sendingToken, + teamToken, + baseUrl: checkBaseUrl(options.baseUrl ?? DEFAULT_BASE_URL), + timeout: options.timeout === undefined ? DEFAULT_TIMEOUT : checkTimeout(options.timeout), + fetch: fetchFunction, + }); + this.emails = new Emails(this.#transport); + this.domains = new Domains(this.#transport); + this.messages = new Messages(this.#transport); + this.projects = new Projects(this.#transport); + this.routes = new Routes(this.#transport); + this.stats = new Stats(this.#transport); + this.suppressions = new Suppressions(this.#transport); + this.team = new Team(this.#transport); + this.webhooks = new Webhooks(this.#transport); } -} -export class ApiClient { - private readonly client: LettermintClient; + /** + * Checks the configured token: `GET /ping` returns `pong`. Uses the team token + * when configured, otherwise the sending token. + */ + async ping(options?: RequestOptions): Promise { + const text = await this.#transport.call('GET /ping', { label: 'ping', options }); + return text.trim(); + } - public readonly domains: DomainsEndpoint; - public readonly messages: MessagesEndpoint; - public readonly projects: ProjectsEndpoint; - public readonly routes: RoutesEndpoint; - public readonly stats: StatsEndpoint; - public readonly suppressions: SuppressionsEndpoint; - public readonly team: TeamEndpoint; - public readonly webhooks: WebhooksEndpoint; + /** Queries email analytics. Needs `teamToken`. */ + analytics(query: AnalyticsQuery, options?: RequestOptions): Promise { + return this.#transport.call('POST /analytics', { label: 'analytics', body: query, options }); + } - public constructor( - apiToken: string, - config?: Omit - ) { - this.client = new LettermintClient({ ...config, apiToken, authMode: 'api' }); - this.domains = new DomainsEndpoint(this.client); - this.messages = new MessagesEndpoint(this.client); - this.projects = new ProjectsEndpoint(this.client); - this.routes = new RoutesEndpoint(this.client); - this.stats = new StatsEndpoint(this.client); - this.suppressions = new SuppressionsEndpoint(this.client); - this.team = new TeamEndpoint(this.client); - this.webhooks = new WebhooksEndpoint(this.client); + /** The file extensions and MIME types that cannot be attached. Needs `teamToken`. */ + blockedFileTypes(options?: RequestOptions): Promise { + return this.#transport.call('GET /blocked-file-types', { label: 'blockedFileTypes', options }); } - public async ping(): Promise { - return (await this.client.getRaw('/ping')).trim(); + #view(): Record { + return { + baseUrl: this.#transport.baseUrl, + timeout: this.#transport.timeout, + sendingToken: this.#transport.hasSendingToken ? REDACTED : undefined, + teamToken: this.#transport.hasTeamToken ? REDACTED : undefined, + }; } - public async blockedFileTypes(): Promise { - return this.client.get('/blocked-file-types'); + /** The configuration without credentials: tokens are shown as `[redacted]`. */ + toJSON(): Record { + return this.#view(); } - public async analytics(payload: Types.AnalyticsRequest): Promise { - return this.client.post('/analytics', payload); + [inspectCustom](_depth: number, options: unknown, inspect: unknown): string { + return renderInspect('Lettermint', this.#view(), options, inspect); } } - -export default Lettermint; diff --git a/src/query.ts b/src/query.ts new file mode 100644 index 0000000..8633b7d --- /dev/null +++ b/src/query.ts @@ -0,0 +1,89 @@ +type Simplify = { [K in keyof T]: T[K] } & {}; + +type BracketKey = `${string}[${string}`; +type Prefix = K extends `${infer P}[${string}]` ? P : never; +type Group = { + [K in keyof Q as K extends `${P}[${infer S}]` + ? S extends `${string}[${string}` | `${string}]${string}` + ? never + : S + : never]?: Q[K]; +}; +type Merge = Omit & { + [K in keyof B]?: B[K] | (K extends keyof A ? A[K] : never); +}; +type GroupOf = P extends keyof Q + ? Simplify, NonNullable>> + : Simplify>; + +/** + * Turns a generated query type, keyed by wire names such as `'page[size]'` and + * `'filter[status]'`, into nested objects: `{ page: { size }, filter: { status } }`. + * Keys without brackets (`sort`, `include`, `cursor`) stay as they are. + */ +export type NestedQuery = Simplify< + { + [K in keyof Q as K extends BracketKey ? never : K extends Prefix ? never : K]: Q[K]; + } & { + [P in Prefix & string]?: GroupOf; + } +>; + +function scalar(value: unknown): string { + if (typeof value === 'boolean') return value ? '1' : '0'; + if (value instanceof Date) return value.toISOString(); + return String(value); +} + +function isScalar(value: unknown): boolean { + return value === null || value instanceof Date || typeof value !== 'object'; +} + +function append(params: URLSearchParams, key: string, value: unknown): void { + if (value === undefined || value === null) return; + if (Array.isArray(value)) { + if (value.every(isScalar)) { + const items = value.filter((item) => item !== undefined && item !== null).map(scalar); + if (items.length > 0) params.append(key, items.join(',')); + return; + } + value.forEach((item, index) => append(params, `${key}[${index}]`, item)); + return; + } + if (isScalar(value)) { + params.append(key, scalar(value)); + return; + } + for (const [name, item] of Object.entries(value as Record)) { + append(params, `${key}[${name}]`, item); + } +} + +/** + * Serializes a nested query object to the API's bracket syntax: + * `{ page: { size: 10 }, filter: { status: 'verified' }, sort: ['-created_at'] }` + * becomes `page[size]=10&filter[status]=verified&sort=-created_at`. + * Arrays of scalars are comma-separated, arrays of objects are indexed, + * booleans are `1`/`0`, and `undefined`/`null` values are left out. + */ +export function serializeQuery(query: object | undefined): string { + if (!query) return ''; + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(query)) append(params, key, value); + return params.toString(); +} + +/** Returns a copy of `query` with the parameter at wire name `name` (`cursor` or `page[cursor]`) set. */ +export function withQueryParam(query: object | undefined, name: string, value: string): object { + const match = /^([^[\]]+)\[([^[\]]+)\]$/.exec(name); + const base = { ...(query ?? {}) } as Record; + if (!match) { + base[name] = value; + return base; + } + const [, group, key] = match; + delete base[name]; + const current = base[group]; + base[group] = { ...(current && typeof current === 'object' ? current : {}), [key]: value }; + return base; +} diff --git a/src/redact.ts b/src/redact.ts new file mode 100644 index 0000000..7423eb8 --- /dev/null +++ b/src/redact.ts @@ -0,0 +1,22 @@ +/** Node's `util.inspect` hook, registered by name so that no `node:util` import is needed. */ +export const inspectCustom: unique symbol = Symbol.for('nodejs.util.inspect.custom'); + +export const REDACTED = '[redacted]'; + +type Inspect = (value: unknown, options?: unknown) => string; + +/** + * Renders `name { ...view }` for `util.inspect`. `view` must only hold values + * that are safe to log. + */ +export function renderInspect( + name: string, + view: object, + options: unknown, + inspect: unknown +): string { + if (typeof inspect === 'function') { + return `${name} ${(inspect as Inspect)(view, options)}`; + } + return `${name} ${JSON.stringify(view)}`; +} diff --git a/src/resources/base.ts b/src/resources/base.ts new file mode 100644 index 0000000..19083b4 --- /dev/null +++ b/src/resources/base.ts @@ -0,0 +1,43 @@ +import type { OperationKey, OperationResponse } from '../generated/operations'; +import { inspectCustom, renderInspect } from '../redact'; +import type { CallArgs, Transport } from '../transport'; + +/** + * Base of the Team API sub-clients. The transport (and with it the tokens) + * lives in a private field; inspecting or serializing a sub-client shows no + * credentials. + */ +export abstract class ApiResource { + readonly #transport: Transport; + readonly #name: string; + + /** @internal */ + constructor(transport: Transport, name: string) { + this.#transport = transport; + this.#name = name; + } + + /** @internal */ + protected request( + key: K, + args: CallArgs + ): Promise> { + return this.#transport.call(key, args); + } + + /** @internal */ + protected paginate( + key: K, + args: CallArgs + ): AsyncGenerator { + return this.#transport.paginate(key, args); + } + + toJSON(): Record { + return {}; + } + + [inspectCustom](_depth: number, options: unknown, inspect: unknown): string { + return renderInspect(this.#name, {}, options, inspect); + } +} diff --git a/src/resources/domains.ts b/src/resources/domains.ts new file mode 100644 index 0000000..6ed790d --- /dev/null +++ b/src/resources/domains.ts @@ -0,0 +1,103 @@ +import type { + DnsVerificationSuccessResponse, + DomainData, + DomainListData, + DomainMutationResponse, + GetDomainQuery, + ListDomainsQuery, + ListDomainsResponse, + MessageResponse, + StoreDomainData, + UpdateDomainProjectsData, +} from '../generated/types'; +import type { NestedQuery } from '../query'; +import type { RequestOptions, Transport } from '../transport'; +import { ApiResource } from './base'; + +/** Sending domains. Team token. */ +export class Domains extends ApiResource { + /** @internal */ + constructor(transport: Transport) { + super(transport, 'Domains'); + } + + /** Lists domains, one page at a time. */ + list( + query?: NestedQuery, + options?: RequestOptions + ): Promise { + return this.request('GET /domains', { label: 'domains.list', query, options }); + } + + /** Iterates over every domain, following `next_cursor`. */ + iterate( + query?: NestedQuery, + options?: RequestOptions + ): AsyncGenerator { + return this.paginate('GET /domains', { label: 'domains.iterate', query, options }); + } + + create(body: StoreDomainData, options?: RequestOptions): Promise { + return this.request('POST /domains', { label: 'domains.create', body, options }); + } + + retrieve( + domainId: string, + query?: NestedQuery, + options?: RequestOptions + ): Promise { + return this.request('GET /domains/{domainId}', { + label: 'domains.retrieve', + path: { domainId }, + query, + options, + }); + } + + delete(domainId: string, options?: RequestOptions): Promise { + return this.request('DELETE /domains/{domainId}', { + label: 'domains.delete', + path: { domainId }, + options, + }); + } + + /** Checks every DNS record of the domain. */ + verifyDnsRecords( + domainId: string, + options?: RequestOptions + ): Promise { + return this.request('POST /domains/{domainId}/dns-records/verify', { + label: 'domains.verifyDnsRecords', + path: { domainId }, + options, + }); + } + + /** Checks one DNS record of the domain. */ + verifyDnsRecord( + domainId: string, + recordId: string, + options?: RequestOptions + ): Promise { + return this.request('POST /domains/{domainId}/dns-records/{recordId}/verify', { + label: 'domains.verifyDnsRecord', + path: { domainId, recordId }, + options, + }); + } + + /** Replaces the projects that may send from the domain. */ + updateProjects( + domainId: string, + body: UpdateDomainProjectsData, + options?: RequestOptions + ): Promise { + return this.request('PUT /domains/{domainId}/projects', { + label: 'domains.updateProjects', + path: { domainId }, + body, + options, + }); + } +} diff --git a/src/resources/messages.ts b/src/resources/messages.ts new file mode 100644 index 0000000..b3fc206 --- /dev/null +++ b/src/resources/messages.ts @@ -0,0 +1,141 @@ +import type { + ListMessageEventsQuery, + ListMessageEventsResponse, + ListMessagesQuery, + ListMessagesResponse, + MessageData, + MessageEventData, + MessageListData, + ProcessInboundMessageResponse, + RescheduleMessageRequest, + ScheduledMessage, +} from '../generated/types'; +import type { NestedQuery } from '../query'; +import type { IdempotentRequestOptions, RequestOptions, Transport } from '../transport'; +import { ApiResource } from './base'; + +/** + * Sent and received messages. Team token; `reschedule` and `cancel` also + * accept the sending token when no team token is configured. + */ +export class Messages extends ApiResource { + /** @internal */ + constructor(transport: Transport) { + super(transport, 'Messages'); + } + + /** Lists messages, one page at a time. */ + list( + query?: NestedQuery, + options?: RequestOptions + ): Promise { + return this.request('GET /messages', { label: 'messages.list', query, options }); + } + + /** Iterates over every message, following `next_cursor`. */ + iterate( + query?: NestedQuery, + options?: RequestOptions + ): AsyncGenerator { + return this.paginate('GET /messages', { label: 'messages.iterate', query, options }); + } + + retrieve(messageId: string, options?: RequestOptions): Promise { + return this.request('GET /messages/{messageId}', { + label: 'messages.retrieve', + path: { messageId }, + options, + }); + } + + /** Lists the events of a message, one page at a time. */ + events( + messageId: string, + query?: NestedQuery, + options?: RequestOptions + ): Promise { + return this.request('GET /messages/{messageId}/events', { + label: 'messages.events', + path: { messageId }, + query, + options, + }); + } + + /** Iterates over every event of a message, following `next_cursor`. */ + iterateEvents( + messageId: string, + query?: NestedQuery, + options?: RequestOptions + ): AsyncGenerator { + return this.paginate('GET /messages/{messageId}/events', { + label: 'messages.iterateEvents', + path: { messageId }, + query, + options, + }); + } + + /** The raw RFC 822 source. */ + source(messageId: string, options?: RequestOptions): Promise { + return this.request('GET /messages/{messageId}/source', { + label: 'messages.source', + path: { messageId }, + options, + }); + } + + /** The HTML body. */ + html(messageId: string, options?: RequestOptions): Promise { + return this.request('GET /messages/{messageId}/html', { + label: 'messages.html', + path: { messageId }, + options, + }); + } + + /** The plain-text body. */ + text(messageId: string, options?: RequestOptions): Promise { + return this.request('GET /messages/{messageId}/text', { + label: 'messages.text', + path: { messageId }, + options, + }); + } + + /** Moves a scheduled message to another delivery time. */ + reschedule( + messageId: string, + body: RescheduleMessageRequest, + options?: RequestOptions + ): Promise { + return this.request('PATCH /messages/{messageId}', { + label: 'messages.reschedule', + path: { messageId }, + body, + options, + }); + } + + /** Cancels a scheduled message. */ + cancel(messageId: string, options?: RequestOptions): Promise { + return this.request('POST /messages/{messageId}/cancel', { + label: 'messages.cancel', + path: { messageId }, + options, + }); + } + + /** Releases one quarantined inbound message for webhook delivery. */ + process( + messageId: string, + options?: IdempotentRequestOptions + ): Promise { + return this.request('POST /messages/{messageId}/process', { + label: 'messages.process', + path: { messageId }, + idempotencyKey: options?.idempotencyKey, + options, + }); + } +} diff --git a/src/resources/projects.ts b/src/resources/projects.ts new file mode 100644 index 0000000..8163679 --- /dev/null +++ b/src/resources/projects.ts @@ -0,0 +1,163 @@ +import type { + GetProjectQuery, + GetReportForwardingResponse, + ListProjectsQuery, + ListProjectsResponse, + MessageResponse, + ProjectCreatedData, + ProjectData, + ProjectListData, + ProjectMutationResponse, + ReportForwardingRequest, + ResendReportForwardingCodeResponse, + RotateProjectTokenResponse, + StoreProjectData, + UpdateProjectData, + UpdateReportForwardingResponse, + VerifyReportForwardingRequest, + VerifyReportForwardingResponse, +} from '../generated/types'; +import type { NestedQuery } from '../query'; +import type { RequestOptions, Transport } from '../transport'; +import { ApiResource } from './base'; + +/** DMARC and complaint report forwarding of a project. Team token. */ +export class ReportForwarding extends ApiResource { + /** @internal */ + constructor(transport: Transport) { + super(transport, 'ReportForwarding'); + } + + retrieve(projectId: string, options?: RequestOptions): Promise { + return this.request('GET /projects/{projectId}/report-forwarding', { + label: 'projects.reportForwarding.retrieve', + path: { projectId }, + options, + }); + } + + update( + projectId: string, + body: ReportForwardingRequest, + options?: RequestOptions + ): Promise { + return this.request('PUT /projects/{projectId}/report-forwarding', { + label: 'projects.reportForwarding.update', + path: { projectId }, + body, + options, + }); + } + + /** Disables report forwarding. Resolves with `undefined` (HTTP 204). */ + delete(projectId: string, options?: RequestOptions): Promise { + return this.request('DELETE /projects/{projectId}/report-forwarding', { + label: 'projects.reportForwarding.delete', + path: { projectId }, + options, + }); + } + + verify( + projectId: string, + body: VerifyReportForwardingRequest, + options?: RequestOptions + ): Promise { + return this.request('POST /projects/{projectId}/report-forwarding/verify', { + label: 'projects.reportForwarding.verify', + path: { projectId }, + body, + options, + }); + } + + resendCode( + projectId: string, + options?: RequestOptions + ): Promise { + return this.request('POST /projects/{projectId}/report-forwarding/resend-code', { + label: 'projects.reportForwarding.resendCode', + path: { projectId }, + options, + }); + } +} + +/** Projects. Team token. */ +export class Projects extends ApiResource { + /** Report forwarding of a project. */ + public readonly reportForwarding: ReportForwarding; + + /** @internal */ + constructor(transport: Transport) { + super(transport, 'Projects'); + this.reportForwarding = new ReportForwarding(transport); + } + + /** Lists projects, one page at a time. */ + list( + query?: NestedQuery, + options?: RequestOptions + ): Promise { + return this.request('GET /projects', { label: 'projects.list', query, options }); + } + + /** Iterates over every project, following `next_cursor`. */ + iterate( + query?: NestedQuery, + options?: RequestOptions + ): AsyncGenerator { + return this.paginate('GET /projects', { label: 'projects.iterate', query, options }); + } + + /** Creates a project. The response holds its sending token once (`api_token`). */ + create(body: StoreProjectData, options?: RequestOptions): Promise { + return this.request('POST /projects', { label: 'projects.create', body, options }); + } + + retrieve( + projectId: string, + query?: NestedQuery, + options?: RequestOptions + ): Promise { + return this.request('GET /projects/{projectId}', { + label: 'projects.retrieve', + path: { projectId }, + query, + options, + }); + } + + update( + projectId: string, + body: UpdateProjectData, + options?: RequestOptions + ): Promise { + return this.request('PUT /projects/{projectId}', { + label: 'projects.update', + path: { projectId }, + body, + options, + }); + } + + delete(projectId: string, options?: RequestOptions): Promise { + return this.request('DELETE /projects/{projectId}', { + label: 'projects.delete', + path: { projectId }, + options, + }); + } + + /** + * Rotates the project's legacy sending token. + * @deprecated The API marks this endpoint as legacy. + */ + rotateToken(projectId: string, options?: RequestOptions): Promise { + return this.request('POST /projects/{projectId}/rotate-token', { + label: 'projects.rotateToken', + path: { projectId }, + options, + }); + } +} diff --git a/src/resources/routes.ts b/src/resources/routes.ts new file mode 100644 index 0000000..b549162 --- /dev/null +++ b/src/resources/routes.ts @@ -0,0 +1,109 @@ +import type { + GetRouteQuery, + InboundDomainVerificationResponse, + ListRoutesQuery, + ListRoutesResponse, + MessageResponse, + RouteData, + RouteListData, + RouteMutationResponse, + StoreRouteData, + UpdateRouteData, +} from '../generated/types'; +import type { NestedQuery } from '../query'; +import type { RequestOptions, Transport } from '../transport'; +import { ApiResource } from './base'; + +/** Routes of a project. Team token. */ +export class Routes extends ApiResource { + /** @internal */ + constructor(transport: Transport) { + super(transport, 'Routes'); + } + + /** Lists the routes of a project, one page at a time. */ + list( + projectId: string, + query?: NestedQuery, + options?: RequestOptions + ): Promise { + return this.request('GET /projects/{projectId}/routes', { + label: 'routes.list', + path: { projectId }, + query, + options, + }); + } + + /** Iterates over every route of a project, following `next_cursor`. */ + iterate( + projectId: string, + query?: NestedQuery, + options?: RequestOptions + ): AsyncGenerator { + return this.paginate('GET /projects/{projectId}/routes', { + label: 'routes.iterate', + path: { projectId }, + query, + options, + }); + } + + create( + projectId: string, + body: StoreRouteData, + options?: RequestOptions + ): Promise { + return this.request('POST /projects/{projectId}/routes', { + label: 'routes.create', + path: { projectId }, + body, + options, + }); + } + + retrieve( + routeId: string, + query?: NestedQuery, + options?: RequestOptions + ): Promise { + return this.request('GET /routes/{routeId}', { + label: 'routes.retrieve', + path: { routeId }, + query, + options, + }); + } + + update( + routeId: string, + body: UpdateRouteData, + options?: RequestOptions + ): Promise { + return this.request('PUT /routes/{routeId}', { + label: 'routes.update', + path: { routeId }, + body, + options, + }); + } + + delete(routeId: string, options?: RequestOptions): Promise { + return this.request('DELETE /routes/{routeId}', { + label: 'routes.delete', + path: { routeId }, + options, + }); + } + + verifyInboundDomain( + routeId: string, + options?: RequestOptions + ): Promise { + return this.request('POST /routes/{routeId}/verify-inbound-domain', { + label: 'routes.verifyInboundDomain', + path: { routeId }, + options, + }); + } +} diff --git a/src/resources/team.ts b/src/resources/team.ts new file mode 100644 index 0000000..ab3402a --- /dev/null +++ b/src/resources/team.ts @@ -0,0 +1,148 @@ +import type { + DeleteSuppressionResponse, + GetStatsQuery, + GetTeamQuery, + ListSuppressionsQuery, + ListSuppressionsResponse, + ListTeamMembersQuery, + ListTeamMembersResponse, + StatsData, + StoreSuppressionData, + SuppressedRecipientData, + SuppressionStoreResponse, + TeamData, + TeamMemberData, + TeamMutationResponse, + TeamRoleListResponse, + TeamUsageDetailData, + UpdateTeamData, + UpdateTeamMemberAssignmentData, +} from '../generated/types'; +import type { NestedQuery } from '../query'; +import type { RequestOptions, Transport } from '../transport'; +import { ApiResource } from './base'; + +/** Sending statistics. Team token. */ +export class Stats extends ApiResource { + /** @internal */ + constructor(transport: Transport) { + super(transport, 'Stats'); + } + + /** Daily statistics between `from` and `to` (Y-m-d, at most 90 days). */ + retrieve(query: NestedQuery, options?: RequestOptions): Promise { + return this.request('GET /stats', { label: 'stats.retrieve', query, options }); + } +} + +/** The suppression list. Team token. */ +export class Suppressions extends ApiResource { + /** @internal */ + constructor(transport: Transport) { + super(transport, 'Suppressions'); + } + + /** Lists suppressions, one page at a time. */ + list( + query?: NestedQuery, + options?: RequestOptions + ): Promise { + return this.request('GET /suppressions', { label: 'suppressions.list', query, options }); + } + + /** Iterates over every suppression, following `next_cursor`. */ + iterate( + query?: NestedQuery, + options?: RequestOptions + ): AsyncGenerator { + return this.paginate('GET /suppressions', { label: 'suppressions.iterate', query, options }); + } + + create(body: StoreSuppressionData, options?: RequestOptions): Promise { + return this.request('POST /suppressions', { label: 'suppressions.create', body, options }); + } + + delete(suppressionId: string, options?: RequestOptions): Promise { + return this.request('DELETE /suppressions/{suppressionId}', { + label: 'suppressions.delete', + path: { suppressionId }, + options, + }); + } +} + +/** Team members. Team token. */ +export class TeamMembers extends ApiResource { + /** @internal */ + constructor(transport: Transport) { + super(transport, 'TeamMembers'); + } + + /** Lists team members, one page at a time. */ + list( + query?: NestedQuery, + options?: RequestOptions + ): Promise { + return this.request('GET /team/members', { label: 'team.members.list', query, options }); + } + + /** Iterates over every team member, following `next_cursor`. */ + iterate( + query?: NestedQuery, + options?: RequestOptions + ): AsyncGenerator { + return this.paginate('GET /team/members', { label: 'team.members.iterate', query, options }); + } + + retrieve(userId: string, options?: RequestOptions): Promise { + return this.request('GET /team/members/{userId}', { + label: 'team.members.retrieve', + path: { userId }, + options, + }); + } + + /** Changes a member's role and project access. */ + updateAssignment( + userId: string, + body: UpdateTeamMemberAssignmentData, + options?: RequestOptions + ): Promise { + return this.request('PUT /team/members/{userId}/assignment', { + label: 'team.members.updateAssignment', + path: { userId }, + body, + options, + }); + } +} + +/** The team of the token. Team token. */ +export class Team extends ApiResource { + /** Team members. */ + public readonly members: TeamMembers; + + /** @internal */ + constructor(transport: Transport) { + super(transport, 'Team'); + this.members = new TeamMembers(transport); + } + + retrieve(query?: NestedQuery, options?: RequestOptions): Promise { + return this.request('GET /team', { label: 'team.retrieve', query, options }); + } + + update(body: UpdateTeamData, options?: RequestOptions): Promise { + return this.request('PUT /team', { label: 'team.update', body, options }); + } + + /** Usage of the current and previous billing periods. */ + usage(options?: RequestOptions): Promise { + return this.request('GET /team/usage', { label: 'team.usage', options }); + } + + /** The roles that can be assigned to members. */ + roles(options?: RequestOptions): Promise { + return this.request('GET /team/roles', { label: 'team.roles', options }); + } +} diff --git a/src/resources/webhooks.ts b/src/resources/webhooks.ts new file mode 100644 index 0000000..875d7a9 --- /dev/null +++ b/src/resources/webhooks.ts @@ -0,0 +1,147 @@ +import type { + ListWebhookDeliveriesQuery, + ListWebhookDeliveriesResponse, + ListWebhooksQuery, + ListWebhooksResponse, + MessageResponse, + StoreWebhookData, + TestWebhookResponse, + UpdateWebhookData, + WebhookData, + WebhookDeliveryData, + WebhookDeliveryListData, + WebhookListData, + WebhookMutationResponse, + WebhookSecretResponse, +} from '../generated/types'; +import type { NestedQuery } from '../query'; +import type { RequestOptions, Transport } from '../transport'; +import { ApiResource } from './base'; + +/** Delivery attempts of a webhook. Team token. */ +export class WebhookDeliveries extends ApiResource { + /** @internal */ + constructor(transport: Transport) { + super(transport, 'WebhookDeliveries'); + } + + /** Lists the deliveries of a webhook, one page at a time. */ + list( + webhookId: string, + query?: NestedQuery, + options?: RequestOptions + ): Promise { + return this.request('GET /webhooks/{webhookId}/deliveries', { + label: 'webhooks.deliveries.list', + path: { webhookId }, + query, + options, + }); + } + + /** Iterates over every delivery of a webhook, following `next_cursor`. */ + iterate( + webhookId: string, + query?: NestedQuery, + options?: RequestOptions + ): AsyncGenerator { + return this.paginate('GET /webhooks/{webhookId}/deliveries', { + label: 'webhooks.deliveries.iterate', + path: { webhookId }, + query, + options, + }); + } + + retrieve( + webhookId: string, + deliveryId: string, + options?: RequestOptions + ): Promise { + return this.request('GET /webhooks/{webhookId}/deliveries/{deliveryId}', { + label: 'webhooks.deliveries.retrieve', + path: { webhookId, deliveryId }, + options, + }); + } +} + +/** Webhook endpoints. Team token. To verify incoming deliveries, use `Webhook`. */ +export class Webhooks extends ApiResource { + /** Delivery attempts of a webhook. */ + public readonly deliveries: WebhookDeliveries; + + /** @internal */ + constructor(transport: Transport) { + super(transport, 'Webhooks'); + this.deliveries = new WebhookDeliveries(transport); + } + + /** Lists webhooks, one page at a time. */ + list( + query?: NestedQuery, + options?: RequestOptions + ): Promise { + return this.request('GET /webhooks', { label: 'webhooks.list', query, options }); + } + + /** Iterates over every webhook, following `next_cursor`. */ + iterate( + query?: NestedQuery, + options?: RequestOptions + ): AsyncGenerator { + return this.paginate('GET /webhooks', { label: 'webhooks.iterate', query, options }); + } + + /** Creates a webhook. The response holds its signing secret once. */ + create(body: StoreWebhookData, options?: RequestOptions): Promise { + return this.request('POST /webhooks', { label: 'webhooks.create', body, options }); + } + + retrieve(webhookId: string, options?: RequestOptions): Promise { + return this.request('GET /webhooks/{webhookId}', { + label: 'webhooks.retrieve', + path: { webhookId }, + options, + }); + } + + update( + webhookId: string, + body: UpdateWebhookData, + options?: RequestOptions + ): Promise { + return this.request('PUT /webhooks/{webhookId}', { + label: 'webhooks.update', + path: { webhookId }, + body, + options, + }); + } + + delete(webhookId: string, options?: RequestOptions): Promise { + return this.request('DELETE /webhooks/{webhookId}', { + label: 'webhooks.delete', + path: { webhookId }, + options, + }); + } + + /** Sends a `webhook.test` delivery. */ + test(webhookId: string, options?: RequestOptions): Promise { + return this.request('POST /webhooks/{webhookId}/test', { + label: 'webhooks.test', + path: { webhookId }, + options, + }); + } + + /** Replaces the signing secret. The response holds the new secret once. */ + regenerateSecret(webhookId: string, options?: RequestOptions): Promise { + return this.request('POST /webhooks/{webhookId}/regenerate-secret', { + label: 'webhooks.regenerateSecret', + path: { webhookId }, + options, + }); + } +} diff --git a/src/route-contract.spec.ts b/src/route-contract.spec.ts deleted file mode 100644 index 1825dac..0000000 --- a/src/route-contract.spec.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Lettermint } from './lettermint'; -import type { RouteData } from './types'; - -const base: RouteData = { - id: 'route_1', - project_id: 'project_1', - slug: 'incoming', - name: 'Incoming', - route_type: 'inbound', - is_default: false, - created_at: '2026-10-01T12:00:00Z', - updated_at: '2026-10-01T12:00:00Z', -}; - -describe('inbound route domain contract', () => { - it.each(['incoming.example.com', null, undefined])( - 'keeps a %s domain on the route client', - async (domain) => { - const payload: RouteData = { - ...base, - ...(domain === undefined ? {} : { inbound_route_domain: domain }), - }; - global.fetch = jest - .fn() - .mockResolvedValue({ ok: true, json: async () => payload } as Response); - const result = await Lettermint.api('team-token').routes.retrieve('route_1'); - const typedDomain: string | null | undefined = result.inbound_route_domain; - expect(typedDomain).toBe(domain); - expect(JSON.parse(JSON.stringify(result))).toEqual(payload); - } - ); -}); diff --git a/src/tokens.ts b/src/tokens.ts new file mode 100644 index 0000000..b92c566 --- /dev/null +++ b/src/tokens.ts @@ -0,0 +1,42 @@ +import { LettermintConfigError } from './errors'; + +/** Team API tokens: `ApiToken::TEAM_PREFIX` in the Lettermint backend. */ +const TEAM_TOKEN = /^lm_team_[0-9A-Za-z]+$/; +/** Project sending tokens (32 or 22 random characters): `ApiToken::PROJECT_PREFIX`. */ +const SENDING_TOKEN = /^lm_[0-9A-Za-z]+$/; +/** Characters allowed in a token, so that it is a valid HTTP header value. */ +const HEADER_SAFE = /^[\x21-\x7e]+$/; + +export type TokenKind = 'sending' | 'team'; + +/** + * Classifies a token passed as `new Lettermint(token)`. The team pattern is + * checked first, because every team token also starts with `lm_`. Error + * messages never contain the token. + */ +export function detectTokenKind(token: unknown): TokenKind { + if (typeof token === 'string') { + if (TEAM_TOKEN.test(token)) return 'team'; + if (SENDING_TOKEN.test(token)) return 'sending'; + } + throw new LettermintConfigError( + 'Unrecognised token format; pass { sendingToken } or { teamToken } instead.' + ); +} + +/** Validates an explicitly configured token. Returns `undefined` when it is not set. */ +export function checkToken( + option: 'sendingToken' | 'teamToken', + token: unknown +): string | undefined { + if (token === undefined) return undefined; + if (typeof token !== 'string' || token.length === 0) { + throw new LettermintConfigError(`\`${option}\` must be a non-empty string.`); + } + if (!HEADER_SAFE.test(token)) { + throw new LettermintConfigError( + `\`${option}\` contains whitespace or characters that are not allowed in an HTTP header.` + ); + } + return token; +} diff --git a/src/transport.ts b/src/transport.ts new file mode 100644 index 0000000..1ef2015 --- /dev/null +++ b/src/transport.ts @@ -0,0 +1,447 @@ +import { version } from '../package.json'; +import { + ApiError, + type ApiErrorInit, + AuthenticationError, + ConflictError, + ConnectionError, + LettermintConfigError, + LettermintValidationError, + NotFoundError, + PermissionError, + RateLimitError, + RedirectError, + ServerError, + TimeoutError, + UnexpectedResponseError, + ValidationError, +} from './errors'; +import { + type OperationKey, + type OperationQuery, + type OperationRequest, + type OperationResponse, + operations, +} from './generated/operations'; +import { type NestedQuery, serializeQuery, withQueryParam } from './query'; + +/** Per-call options accepted by every SDK method that makes a request. */ +export interface RequestOptions { + /** Cancels the request. The SDK rethrows the signal's `reason`. */ + signal?: AbortSignal; + /** Overrides the client's timeout for this call, in milliseconds. */ + timeout?: number; +} + +/** Options of calls that accept an `Idempotency-Key`. */ +export interface IdempotentRequestOptions extends RequestOptions { + /** + * Sent as the `Idempotency-Key` header. Retrying with the same key does not + * send the email again. Never stored on the client. + */ + idempotencyKey?: string; +} + +/** A `fetch`-compatible function. */ +export type FetchFunction = (input: string, init: RequestInit) => Promise; + +export const DEFAULT_BASE_URL = 'https://api.lettermint.co/v1'; +export const DEFAULT_TIMEOUT = 30_000; +export const USER_AGENT = `lettermint-node/${version}`; + +export type AuthChoice = 'sending' | 'team' | 'either'; + +export interface CallArgs { + /** The public method name, used in error messages (`domains.list`). */ + label: string; + path?: Record; + query?: OperationQuery extends undefined ? undefined : NestedQuery>; + body?: OperationRequest; + idempotencyKey?: string; + /** Overrides the auth surface from the operation table. */ + auth?: AuthChoice; + options?: RequestOptions; +} + +export interface TransportConfig { + sendingToken: string | undefined; + teamToken: string | undefined; + baseUrl: string; + timeout: number; + fetch: FetchFunction; +} + +/** Page shape shared by every cursor-paginated list. */ +interface CursorPageLike { + data: T[]; + next_cursor: string | null; +} + +// Browsers ignore or reject a custom User-Agent, so the header is only set elsewhere. +const IS_BROWSER = + typeof (globalThis as { window?: unknown }).window !== 'undefined' && + typeof (globalThis as { document?: unknown }).document !== 'undefined'; + +const HEADER_VALUE = /^[^\r\n\0]+$/; + +export function checkTimeout(value: unknown, option = 'timeout'): number { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { + throw new LettermintConfigError(`\`${option}\` must be a positive number of milliseconds.`); + } + return value; +} + +export function checkBaseUrl(value: unknown): string { + if (typeof value !== 'string') { + throw new LettermintConfigError('`baseUrl` must be a string.'); + } + let url: URL; + try { + url = new URL(value); + } catch { + throw new LettermintConfigError('`baseUrl` must be an absolute http(s) URL.'); + } + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + throw new LettermintConfigError('`baseUrl` must be an absolute http(s) URL.'); + } + if (url.username || url.password || url.search || url.hash) { + throw new LettermintConfigError( + '`baseUrl` must not contain credentials, a query string or a fragment.' + ); + } + return url.href.replace(/\/+$/, ''); +} + +function encodePathParam(label: string, name: string, value: unknown): string { + if (typeof value !== 'string' || value === '' || value === '.' || value === '..') { + throw new LettermintConfigError( + `${label}: \`${name}\` must be a non-empty string other than "." and "..".` + ); + } + return encodeURIComponent(value); +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isAbortSignal(value: unknown): value is AbortSignal { + return ( + typeof value === 'object' && + value !== null && + typeof (value as AbortSignal).aborted === 'boolean' && + typeof (value as AbortSignal).addEventListener === 'function' + ); +} + +function parseRetryAfter(value: string | null): number | undefined { + if (!value) return undefined; + const trimmed = value.trim(); + if (/^\d+$/.test(trimmed)) return Number(trimmed); + const date = Date.parse(trimmed); + if (Number.isNaN(date)) return undefined; + return Math.max(0, Math.ceil((date - Date.now()) / 1000)); +} + +function createApiError(response: Response, body: unknown): ApiError { + const { status } = response; + const init: ApiErrorInit = { status, message: '', body }; + let errors: Record | undefined; + if (isObject(body)) { + const error = body.error; + if (isObject(error)) { + if (typeof error.code === 'string') init.code = error.code; + if (typeof error.message === 'string') init.message = error.message; + init.details = error.details; + } else if (typeof error === 'string') { + init.code = error; + } + if (!init.message && typeof body.message === 'string') init.message = body.message; + if (isObject(body.errors)) errors = body.errors as Record; + } + if (!init.message) init.message = response.statusText || `HTTP ${status}`; + + switch (status) { + case 401: + return new AuthenticationError(init); + case 403: + return new PermissionError(init); + case 404: + return new NotFoundError(init); + case 409: + return new ConflictError(init); + case 422: + return new ValidationError({ ...init, errors }); + case 429: + return new RateLimitError({ + ...init, + retryAfter: parseRetryAfter(response.headers.get('retry-after')), + }); + default: + return status >= 500 ? new ServerError(init) : new ApiError(init); + } +} + +/** Rejects with `signal.reason` when the signal aborts, even if `promise` ignores the signal. */ +function abortable(promise: Promise, signal: AbortSignal): Promise { + if (signal.aborted) { + promise.catch(() => {}); + return Promise.reject(signal.reason); + } + return new Promise((resolve, reject) => { + const onAbort = () => reject(signal.reason); + signal.addEventListener('abort', onAbort, { once: true }); + promise.then( + (value) => { + signal.removeEventListener('abort', onAbort); + resolve(value); + }, + (error) => { + signal.removeEventListener('abort', onAbort); + reject(error); + } + ); + }); +} + +/** Reads the body as text; on abort, cancels the stream and rejects with the signal's reason. */ +async function readText(response: Response, signal: AbortSignal): Promise { + const body = response.body; + if (!body || typeof body.getReader !== 'function') return abortable(response.text(), signal); + const reader = body.getReader(); + const decoder = new TextDecoder(); + let text = ''; + try { + while (true) { + const { done, value } = await abortable(reader.read(), signal); + if (done) break; + text += decoder.decode(value, { stream: true }); + } + return text + decoder.decode(); + } catch (error) { + reader.cancel().catch(() => {}); + throw error; + } +} + +function discardBody(response: Response): void { + try { + response.body?.cancel().catch(() => {}); + } catch { + // The body is locked or already consumed. + } +} + +/** + * Sends requests for one client. Holds the tokens in private fields; nothing + * else in the SDK can read them. + */ +export class Transport { + readonly #sendingToken: string | undefined; + readonly #teamToken: string | undefined; + readonly #fetch: FetchFunction; + readonly baseUrl: string; + readonly timeout: number; + + constructor(config: TransportConfig) { + this.#sendingToken = config.sendingToken; + this.#teamToken = config.teamToken; + this.#fetch = config.fetch; + this.baseUrl = config.baseUrl; + this.timeout = config.timeout; + } + + get hasSendingToken(): boolean { + return this.#sendingToken !== undefined; + } + + get hasTeamToken(): boolean { + return this.#teamToken !== undefined; + } + + /** Throws if the token for `auth` is not configured. */ + assertAuth(label: string, auth: AuthChoice): void { + this.#authHeader(label, auth); + } + + #authHeader(label: string, auth: AuthChoice): [string, string] { + const useTeam = auth === 'team' || (auth === 'either' && this.#teamToken !== undefined); + if (useTeam) { + if (this.#teamToken === undefined) { + throw new LettermintConfigError( + `${label} needs \`teamToken\`; pass it as new Lettermint({ teamToken }).` + ); + } + return ['Authorization', `Bearer ${this.#teamToken}`]; + } + if (this.#sendingToken === undefined) { + throw new LettermintConfigError( + `${label} needs \`sendingToken\`; pass it as new Lettermint({ sendingToken }).` + ); + } + return ['x-lettermint-token', this.#sendingToken]; + } + + async call(key: K, args: CallArgs): Promise> { + const operation = operations[key]; + const { label, options } = args; + + const [authName, authValue] = this.#authHeader(label, args.auth ?? operation.auth); + const path = operation.path.replace(/\{(\w+)\}/g, (_, name: string) => + encodePathParam(label, name, args.path?.[name]) + ); + if (options !== undefined && !isObject(options)) { + throw new LettermintConfigError(`${label}: the options argument must be an object.`); + } + const timeout = options?.timeout === undefined ? this.timeout : checkTimeout(options.timeout); + const signal = options?.signal; + if (signal !== undefined && !isAbortSignal(signal)) { + throw new LettermintConfigError(`${label}: \`signal\` must be an AbortSignal.`); + } + + const headers: Record = { Accept: 'application/json' }; + if (!IS_BROWSER) headers['User-Agent'] = USER_AGENT; + if (args.idempotencyKey !== undefined) { + if (typeof args.idempotencyKey !== 'string' || !HEADER_VALUE.test(args.idempotencyKey)) { + throw new LettermintValidationError( + '`idempotencyKey` must be a non-empty string without line breaks.', + 'idempotencyKey' + ); + } + headers['Idempotency-Key'] = args.idempotencyKey; + } + let body: string | undefined; + if (args.body !== undefined) { + headers['Content-Type'] = 'application/json'; + body = JSON.stringify(args.body); + } + headers[authName] = authValue; + + const query = serializeQuery(args.query); + const url = `${this.baseUrl}${path}${query ? `?${query}` : ''}`; + + if (signal?.aborted) throw signal.reason; + const controller = new AbortController(); + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + controller.abort(); + }, timeout); + const onAbort = () => controller.abort(signal?.reason); + signal?.addEventListener('abort', onAbort, { once: true }); + const rethrow = (error: unknown): never => { + if (timedOut) throw new TimeoutError(timeout); + if (signal?.aborted) throw signal.reason; + throw new ConnectionError(error); + }; + + try { + const fetchFunction = this.#fetch; + let response: Response; + try { + response = await abortable( + fetchFunction(url, { + method: operation.method, + headers, + body, + redirect: 'manual', + signal: controller.signal, + }), + controller.signal + ); + } catch (error) { + return rethrow(error); + } + + if (response.type === 'opaqueredirect' || (response.status >= 300 && response.status < 400)) { + discardBody(response); + throw new RedirectError(response.type === 'opaqueredirect' ? 0 : response.status); + } + + let text: string; + try { + text = await readText(response, controller.signal); + } catch (error) { + return rethrow(error); + } + return decode(operation.response.type, response, text) as OperationResponse; + } finally { + clearTimeout(timer); + signal?.removeEventListener('abort', onAbort); + } + } + + /** Follows `next_cursor` through every page of a cursor-paginated list. */ + async *paginate( + key: K, + args: CallArgs + ): AsyncGenerator { + const cursorParam = operations[key].pagination?.cursorParam; + if (!cursorParam) throw new Error(`${key} is not cursor-paginated`); + let query = args.query; + const seen = new Set(); + while (true) { + const page = (await this.call(key, { + ...args, + query, + } as CallArgs)) as unknown as CursorPageLike; + if (!isObject(page) || !Array.isArray(page.data)) { + throw new UnexpectedResponseError( + `${args.label}: the API returned a page without a data array.`, + 200, + JSON.stringify(page) ?? '' + ); + } + yield* page.data; + const next = page.next_cursor; + if (typeof next !== 'string' || next === '' || seen.has(next)) return; + seen.add(next); + query = withQueryParam(args.query, cursorParam, next) as CallArgs['query']; + } + } +} + +function decode(type: string, response: Response, text: string): unknown { + const { status } = response; + if (status >= 200 && status < 300) { + if (type === 'empty' || status === 204 || status === 205) return undefined; + if (type === 'text') return text; + if (text.trim() === '') { + throw new UnexpectedResponseError( + `The Lettermint API answered with HTTP ${status} and an empty body where JSON was expected.`, + status, + text + ); + } + try { + return JSON.parse(text); + } catch { + throw new UnexpectedResponseError( + `The Lettermint API answered with HTTP ${status} and a body that is not valid JSON.`, + status, + text + ); + } + } + if (status < 400) { + throw new UnexpectedResponseError( + `The Lettermint API answered with an unexpected HTTP status ${status}.`, + status, + text + ); + } + let body: unknown; + if (text.trim() !== '') { + try { + body = JSON.parse(text); + } catch { + const contentType = response.headers.get('content-type'); + throw new UnexpectedResponseError( + `The Lettermint API answered with HTTP ${status} and a body that is not JSON${contentType ? ` (${contentType.split(';')[0]})` : ''}.`, + status, + text + ); + } + } + throw createApiError(response, body); +} diff --git a/src/types.ts b/src/types.ts deleted file mode 100644 index 07cdb17..0000000 --- a/src/types.ts +++ /dev/null @@ -1,1885 +0,0 @@ -/* eslint-disable */ -// biome-ignore-all lint: generated OpenAPI types use schema-shaped names. -// This file is generated by sdk-generator/node/generate-types.mjs. - -export type MessageStatus = "scheduled" | "pending" | "queued" | "quarantined" | "suppressed" | "processed" | "delivered" | "opened" | "clicked" | "soft_bounced" | "hard_bounced" | "spam_complaint" | "failed" | "blocked" | "policy_rejected" | "unsubscribed" | "canceled"; - -/** A reusable exact-match message tag. */ -export interface MessageTag { - "name": string; - "value": string; -} - -export interface SendMailRequest { - "route"?: string; - "from": string; - "to": string[]; - "cc"?: string[]; - "bcc"?: string[]; - "reply_to"?: string[]; - "subject": string; - "scheduled_at"?: string; - "headers"?: Record; - "metadata"?: Record; - "tag"?: string | null; - "tags"?: MessageTag[]; - "settings"?: { - "track_opens"?: boolean; - "track_clicks"?: boolean; - "tls"?: TlsPolicy; -}; - "html"?: string | null; - "text"?: string | null; - "attachments"?: { - "filename": string; - "content": string; - "content_type"?: string | null; - "content_id"?: string | null; -}[]; - "sandbox_result"?: SandboxResult; -} - -export type SendBatchMailRequest = { - "route"?: string; - "from": string; - "to": string[]; - "cc"?: string[]; - "bcc"?: string[]; - "reply_to"?: string[]; - "subject": string; - "scheduled_at"?: string; - "headers"?: Record; - "metadata"?: Record; - "tag"?: string | null; - "tags"?: MessageTag[]; - "settings"?: { - "track_opens"?: boolean; - "track_clicks"?: boolean; - "tls"?: TlsPolicy; -}; - "html"?: string | null; - "text"?: string | null; - "attachments"?: { - "filename": string; - "content": string; - "content_type"?: string | null; - "content_id"?: string | null; -}[]; - "sandbox_result"?: SandboxResult; -}[]; - -export type TlsPolicy = "opportunistic" | "enforced"; - -export type SandboxResult = "delivered" | "hard_bounced" | "soft_bounced" | "deferred" | "failed" | "suppressed" | "spam_complaint" | "auto_replied" | "opened" | "clicked" | "unsubscribed"; - -export type AttachmentDelivery = "inline" | "url"; - -export type BuiltInTeamRole = "owner" | "admin" | "member"; - -export interface CursorPaginator { - "data": string[]; - "path": string | null; - "per_page": number; - "next_cursor": string | null; - "next_page_url": string | null; - "prev_cursor": string | null; - "prev_page_url": string | null; -} - -export type DkimMode = "legacy_txt" | "managed_cname"; - -export type DnsRecordPurpose = "return_path" | "dmarc" | "dkim_legacy" | "dkim_primary" | "dkim_secondary"; - -export type DnsRecordStatus = "active" | "failed" | "pending"; - -export type DnsVerificationScope = "required" | "recommended" | "migration" | "deprecated"; - -export interface DomainData { - "id": string; - "domain": string; - "dkim_mode": DkimMode; - "rotation_ready": boolean; - "status_changed_at": string | null; - "dns_records"?: DomainDnsRecordData[]; - "projects"?: { - "id": string; - "name": string; -}[]; - "created_at": string; -} - -export interface DomainDnsRecordData { - "id": string; - "type": RecordType; - "hostname": string; - "fqdn": string; - "content": string; - "status": DnsRecordStatus; - "purpose": DnsRecordPurpose; - "verification_scope": DnsVerificationScope; - "required_for_verification": boolean; - "verified_at": string | null; - "last_checked_at": string | null; -} - -export interface DomainListData { - "id": string; - "domain": string; - "status": DomainStatus; - "dkim_mode": DkimMode; - "status_changed_at": string | null; - "created_at": string; -} - -export type DomainStatus = "verified" | "partially_verified" | "pending_verification" | "failed_verification"; - -export type InitialRoutes = "both" | "transactional" | "broadcast"; - -export interface MessageAttachmentData { - "size": number; - "filename": string; - "content_id": string | null; - "content_type": string; -} - -export interface MessageData { - "id": string; - "type": MessageType; - "status": MessageStatus; - "status_changed_at": string | null; - "scheduled_at": string | null; - "tag": string | null; - "tags": MessageTag[]; - "from_email": string; - "from_name": string | null; - "reply_to": string[] | null; - "subject": string | null; - "to": MessageRecipientData[] | null; - "cc": MessageRecipientData[] | null; - "bcc": MessageRecipientData[] | null; - "attachments": MessageAttachmentData[] | null; - "metadata": Record | null; - "spam_score"?: number | null; - "spam_symbols"?: SpamSymbol[]; - "route_id": string; - "created_at": string; - "delivery_mode": DeliveryMode; - "sandbox_result": SandboxResult | null; -} - -export interface MessageEventData { - "message_id": string; - "event": MessageEventType; - "tag": string | null; - "tags": MessageTag[]; - "metadata": Record | null; - "timestamp": string; -} - -export type MessageEventType = "scheduled" | "rescheduled" | "canceled" | "released" | "queued" | "processed" | "suppressed" | "delivered" | "auto_replied" | "soft_bounced" | "hard_bounced" | "spam_complaint" | "failed" | "blocked" | "policy_rejected" | "unsubscribed" | "opened" | "clicked" | "inbound_received" | "inbound_queued" | "inbound_spam_blocked" | "inbound_released" | "inbound_processed" | "inbound_retry"; - -export interface MessageListData { - "id": string; - "type": MessageType; - "status": MessageStatus; - "scheduled_at": string | null; - "spam_score"?: number | null; - "from_email": string; - "from_name": string | null; - "subject": string | null; - "to": MessageRecipientData[] | null; - "cc": MessageRecipientData[] | null; - "bcc": MessageRecipientData[] | null; - "reply_to": string[] | null; - "tag": string | null; - "tags": MessageTag[]; - "status_changed_at": string | null; - "created_at": string; - "delivery_mode": DeliveryMode; - "sandbox_result": SandboxResult | null; -} - -export interface MessageRecipientData { - "email": string; - "name": string | null; - "sandbox_result": SandboxResult | null; -} - -export interface MessageStatsData { - "messages_transactional": number; - "messages_broadcast": number; - "messages_inbound": number; - "deliverability": number; -} - -export type MessageType = "inbound" | "outbound"; - -export type Plan = "free" | "starter" | "growth" | "pro"; - -export type ProjectAccessScope = "all" | "selected"; - -export interface ProjectData { - "id": string; - "name": string; - "smtp_enabled": boolean; - "redact_email_content": boolean; - "default_route_id": string | null; - "token_generated_at": string | null; - "token_last_used_at": string | null; - "token_last_used_ip": string | null; - "routes"?: RouteData[]; - "routes_count"?: number; - "domains"?: DomainData[]; - "domains_count"?: number; - "last_28_days"?: MessageStatsData | null; - "created_at": string; - "updated_at": string; - "delivery_mode": DeliveryMode; -} - -export interface ProjectListData { - "id": string; - "name": string; - "delivery_mode": DeliveryMode; - "smtp_enabled": boolean; - "routes_count": number; - "domains_count": number; - "last_28_days": MessageStatsData; - "created_at": string; - "updated_at": string; -} - -export type RbacConflictCode = "stale_resource" | "owner_protected" | "last_owner" | "built_in_role_immutable" | "custom_role_requires_pro"; - -export type RbacPermission = "team:manage" | "billing:manage" | "security:manage" | "audit:read" | "support:manage" | "members:read" | "members:manage" | "roles:manage" | "team_tokens:read" | "team_tokens:manage" | "team_tokens:rotate" | "team_tokens:revoke" | "projects:create" | "team_suppressions:read" | "team_suppressions:add" | "team_suppressions:remove" | "projects:read" | "projects:manage" | "projects:delete" | "routes:read" | "routes:manage" | "routes:delete" | "domains:read" | "domains:manage" | "domains:delete" | "project_tokens:read" | "project_tokens:manage" | "project_tokens:rotate" | "project_tokens:revoke" | "webhooks:read" | "webhooks:manage" | "webhooks:delete" | "webhooks:rotate_secret" | "stats:read" | "analytics:read" | "messages:read" | "messages:read_content" | "messages:send" | "suppressions:read" | "suppressions:add" | "suppressions:remove"; - -export type RecordType = "TXT" | "CNAME" | "MX"; - -export interface RescheduleMessageRequest { - "scheduled_at": string; -} - -export interface RouteData { - "id": string; - "project_id": string; - "slug": string; - "name": string; - "route_type": RouteType; - "is_default": boolean; - "inbound_address"?: string | null; - "inbound_mx_hostname"?: string; - "inbound_route_domain"?: string | null; - "inbound_domain"?: string | null; - "inbound_domain_verified_at"?: string | null; - "inbound_spam_threshold"?: number | null; - "attachment_delivery"?: AttachmentDelivery; - "settings"?: { - "disable_hosted_unsubscribe"?: boolean; - "track_opens"?: boolean; - "track_clicks"?: boolean; - "generate_plaintext_fallback"?: boolean; - "suppress_auto_responders"?: boolean; - "suppress_disposable_recipients"?: boolean; - "tls"?: TlsPolicy; - "redact_email_content"?: boolean; - "attachment_delivery"?: { -}; -} | null; - "project"?: ProjectData; - "webhooks_count"?: number; - "suppressed_recipients_count"?: number; - "statistics"?: RouteStatisticData[]; - "created_at": string; - "updated_at": string; -} - -export interface RouteListData { - "id": string; - "slug": string; - "name": string; - "route_type": RouteType; - "is_default": boolean; - "webhooks_count": number; - "suppressed_recipients_count": number; - "created_at": string; - "updated_at": string; -} - -export interface RouteStatisticData { - "date": string; - "sent_count": number; - "delivered_count": number; - "opened_count": number; - "clicked_count": number; - "hard_bounce_count": number; - "spam_complaint_count": number; - "inbound_received_count": number; - "observed_opened_count"?: number | null; - "human_opened_count"?: number | null; - "privacy_opened_count"?: number | null; - "effective_opened_count": number | null; - "machine_opened_count": number | null; - "machine_clicked_count": number | null; -} - -export type RouteType = "transactional" | "broadcast" | "inbound"; - -export interface SpamSymbol { - "name": string; - "score": number; - "options": string[]; - "description": string | null; -} - -export interface StatsDailyData { - "date": string; - "sent": number; - "delivered": number; - "hard_bounced": number; - "spam_complaints": number; - "opened": number | null; - "clicked": number | null; - "inbound": StatsInboundData; - "transactional": StatsTypeData | null; - "broadcast": StatsTypeData | null; - "observed_opened"?: number | null; - "human_opened"?: number | null; - "privacy_opened"?: number | null; - "effective_opened": number | null; - "machine_opened": number | null; - "machine_clicked": number | null; -} - -export interface StatsData { - "from": string; - "to": string; - "totals": StatsTotalsData; - "daily": StatsDailyData[]; -} - -export interface StatsInboundData { - "received": number; -} - -export interface StatsRequestData { - "from": string; - "to": string; - "project_id"?: string | null; - "include_machine"?: boolean; -} - -export interface StatsTotalsData { - "sent": number; - "delivered": number; - "hard_bounced": number; - "spam_complaints": number; - "opened": number | null; - "clicked": number | null; - "inbound": StatsInboundData; - "transactional": StatsTypeData | null; - "broadcast": StatsTypeData | null; - "observed_opened"?: number | null; - "human_opened"?: number | null; - "privacy_opened"?: number | null; - "effective_opened": number | null; - "machine_opened": number | null; - "machine_clicked": number | null; -} - -export interface StatsTypeData { - "sent": number; - "hard_bounced": number; - "spam_complaints": number; -} - -export interface StoreDomainData { - "domain": string; -} - -export interface StoreProjectData { - "name": string; - "smtp_enabled"?: boolean; - "delivery_mode"?: DeliveryMode; - "initial_routes"?: InitialRoutes; - "short_token"?: boolean; - "redact_email_content"?: boolean; -} - -export interface StoreRouteData { - "name": string; - "route_type": RouteType; - "slug"?: string | null; - "settings"?: UpdateRouteSettingsData | null; - "inbound_settings"?: UpdateRouteInboundSettingsData | null; - "inbound_domain"?: string | null; - "inbound_spam_threshold"?: number | null; - "attachment_delivery"?: AttachmentDelivery | null; -} - -export interface StoreSuppressionData { - "email"?: string | null; - "emails"?: string[] | null; - "reason": SuppressionReason; - "scope": SuppressionScope; - "route_id"?: string | null; - "project_id"?: string | null; - "applies_to"?: SuppressionAppliesTo | null; -} - -export interface WebhookBasicAuthData { - "username": string; - "password": string; -} - -export interface StoreWebhookData { - "name": string; - "url": string; - "events": WebhookEvent[]; - "enabled"?: boolean | null; - "include_machine_events"?: boolean | null; - "scope"?: WebhookScope | null; - "project_ids"?: string[]; - "route_ids"?: string[]; - "route_id"?: string | null; - "delivery_mode_filter"?: WebhookDeliveryModeFilter | null; - basic_auth?: WebhookBasicAuthData | null; -} - -export interface SuppressedRecipientData { - "id": string; - "type": SuppressionType; - "value": string; - "reason": SuppressionReason; - "scope": SuppressionScope; - "applies_to": SuppressionAppliesTo; - "project_id": string | null; - "route_id": string | null; - "source_message"?: SuppressionSourceMessageData | null; - "created_at": string; -} - -export type SuppressionAppliesTo = "all" | "broadcast"; - -export type SuppressionReason = "spam_complaint" | "hard_bounce" | "unsubscribe" | "manual"; - -export type SuppressionScope = "team" | "project" | "route"; - -export interface SuppressionSourceMessageData { - "id": string; - "available": boolean; - "subject": string | null; - "created_at": string | null; -} - -export type SuppressionType = "email" | "domain" | "extension"; - -export interface TeamAddonData { - "type": string | null; - "expires_at": string | null; -} - -export interface TeamData { - "id": string; - "name": string; - "type": TeamType; - "plan": Plan; - "included_volume": number; - "tier": number; - "verified_at": string | null; - "features"?: string[]; - "addons"?: TeamAddonData[]; - "created_at": string; - "domains_count"?: number; - "projects_count"?: number; - "members_count"?: number; -} - -export interface TeamMemberData { - "id": string; - "name": string; - "email": string; - "role": { - "id": string; - "name": string; -}; - "project_access": TeamMemberProjectAccessData; - "joined_at": string | null; -} - -export interface TeamMemberProjectAccessData { - "scope": ProjectAccessScope; - "projects": { - "id": string; - "name": string; -}[]; -} - -export interface TeamRoleData { - "id": string; - "name": string; - "system_key": BuiltInTeamRole | null; - "permissions": RbacPermission[]; - "assignable": boolean; -} - -export type TeamType = "personal" | "business"; - -export interface TeamUsageDetailData { - "current_period": TeamUsagePeriodData; - "historical_usage": TeamUsagePeriodData[]; -} - -export interface TeamUsagePeriodData { - "usage": number; - "last_incremented_at": string | null; - "period_start": string; - "period_end": string; -} - -export interface UpdateDomainProjectsData { - "project_ids": string[]; -} - -export interface UpdateProjectData { - "name"?: string | null; - "smtp_enabled"?: boolean | null; - "redact_email_content"?: boolean | null; - "default_route_id"?: string | null; - "delivery_mode"?: DeliveryMode | null; -} - -export interface UpdateRouteData { - "name"?: string | null; - "settings"?: UpdateRouteSettingsData | null; - "inbound_settings"?: UpdateRouteInboundSettingsData | null; - "inbound_domain"?: string | null; - "inbound_spam_threshold"?: number | null; - "attachment_delivery"?: AttachmentDelivery | null; -} - -export interface UpdateRouteInboundSettingsData { - "inbound_domain"?: string | null; - "inbound_spam_threshold"?: number | null; - "attachment_delivery"?: AttachmentDelivery | null; -} - -export interface UpdateRouteSettingsData { - "track_opens"?: boolean | null; - "track_clicks"?: boolean | null; - "generate_plaintext_fallback"?: boolean | null; - "suppress_auto_responders"?: boolean | null; - "suppress_disposable_recipients"?: boolean | null; - "tls"?: TlsPolicy | null; - "disable_hosted_unsubscribe"?: boolean | null; - "redact_email_content"?: boolean | null; -} - -export interface UpdateTeamData { - "name"?: string; -} - -export interface UpdateTeamMemberAssignmentData { - "role_id": string; - "project_access": { - "scope": ProjectAccessScope; - "project_ids"?: string[]; -}; -} - -export interface UpdateWebhookData { - "name"?: string; - "url"?: string; - "events"?: WebhookEvent[]; - "enabled"?: boolean; - "include_machine_events"?: boolean; - "scope"?: WebhookScope; - "project_ids"?: string[]; - "route_ids"?: string[]; - "route_id"?: string | null; - "delivery_mode_filter"?: WebhookDeliveryModeFilter; - basic_auth?: WebhookBasicAuthData | null; -} - -export interface WebhookData { - "id": string; - "scope": WebhookScope; - "project_ids": string[]; - "route_ids": string[]; - "route_id": string | null; - "name": string; - "url": string; - "events": string[]; - "enabled": boolean; - "include_machine_events": boolean; - "last_called_at": string | null; - "created_at": string; - "updated_at": string; - "delivery_mode_filter": WebhookDeliveryModeFilter; - has_basic_auth: boolean; -} - -export interface WebhookDeliveryData { - "id": string; - "webhook_id": string; - "event_type": WebhookEvent; - "source_scope": string | null; - "source_project_id": string | null; - "source_route_id": string | null; - "status": WebhookDeliveryStatus; - "attempt_number": number; - "http_status_code": number | null; - "duration_ms": number | null; - "payload": string[]; - "response_body": string | null; - "response_headers": string[] | null; - "error_message": string | null; - "delivered_at": string | null; - "timestamp": string; - "sandbox": boolean; -} - -export interface WebhookDeliveryListData { - "id": string; - "webhook_id": string; - "event_type": WebhookEvent; - "source_scope": string | null; - "source_project_id": string | null; - "source_route_id": string | null; - "status": WebhookDeliveryStatus; - "sandbox": boolean; - "attempt_number": number; - "http_status_code": number | null; - "duration_ms": number | null; - "delivered_at": string | null; - "created_at": string; -} - -export type WebhookDeliveryStatus = "pending" | "success" | "failed" | "client_error" | "server_error" | "timeout"; - -export type WebhookEvent = "message.created" | "message.sent" | "message.delivered" | "message.auto_replied" | "message.hard_bounced" | "message.soft_bounced" | "message.spam_complaint" | "message.failed" | "message.suppressed" | "message.unsubscribed" | "message.opened" | "message.clicked" | "message.inbound" | "message.policy_rejected" | "message.scheduled" | "message.rescheduled" | "message.canceled" | "message.released" | "suppression.added" | "suppression.removed" | "webhook.test"; - -export interface WebhookListData { - "id": string; - "scope": WebhookScope; - "project_ids": (string)[]; - "route_ids": (string)[]; - "route_id": string | null; - "name": string; - "url": string; - "events": (string)[]; - "enabled": boolean; - "delivery_mode_filter": WebhookDeliveryModeFilter; - "last_called_at": string | null; - "created_at": string; - "updated_at": string; - has_basic_auth: boolean; -} - -export type WebhookScope = "team" | "project" | "route"; - -export interface WebhookSecretData { - "id": string; - "scope": WebhookScope; - "project_ids": string[]; - "route_ids": string[]; - "route_id": string | null; - "name": string; - "url": string; - "events": string[]; - "enabled": boolean; - "include_machine_events": boolean; - "secret": string; - "last_called_at": string | null; - "created_at": string; - "updated_at": string; - "delivery_mode_filter": WebhookDeliveryModeFilter; - has_basic_auth: boolean; -} - -export type DeliveryMode = "live" | "sandbox"; - -export type WebhookDeliveryModeFilter = "live" | "sandbox" | "both"; - -export type EmailPayload = SendMailRequest; -export type SendEmailResponse = SendMailResponse; -export type SendBatchEmailResponse = SendBatchMailResponse; -export interface SendMailResponse { - "message_id": string; - "status": "pending" | "scheduled"; - "sandbox"?: true; - "sandbox_result"?: SandboxResult; - "scheduled_at"?: string; -} - -export type SendBatchMailResponse = { - "message_id": string; - "status": MessageStatus; - "scheduled_at"?: string; - "sandbox"?: true; - "sandbox_result"?: SandboxResult; -}[]; -export type PingResponse = string; -export type DomainIndexResponse = { - "data": DomainListData[]; - "path": string | null; - "per_page": number; - "next_cursor": string | null; - "next_page_url": string | null; - "prev_cursor": string | null; - "prev_page_url": string | null; -}; -export type DomainStoreRequest = StoreDomainData; -export type DomainStoreResponse = DomainData; -export type DomainShowResponse = DomainData; -export type DomainDestroyResponse = { - "message": "Domain deleted successfully."; -}; -export type DomainVerifyDnsRecordsResponse = { - "message": string; - "recommended_failed_records": { - "id": string; - "type": string; - "name": string; -}[]; -}; -export type DomainVerifySpecificDnsRecordResponse = { - "message": "DNS record verified successfully."; -}; -export type DomainUpdateProjectsRequest = UpdateDomainProjectsData; -export type DomainUpdateProjectsResponse = { - "data": DomainData; - "message": "Domain projects updated successfully."; -}; -export type BlockedFileTypesResponse = { - "extensions": string[]; - "mime_types": string[]; -}; -export type RescheduleMessageResponse = { - "message_id": string; - "status": MessageStatus | null; - "scheduled_at": string | null; -}; -export type MessageShowResponse = MessageData; -export type CancelScheduledMessageResponse = { - "message_id": string; - "status": MessageStatus | null; - "scheduled_at": string | null; -}; -export type MessageIndexResponse = { - "data": MessageListData[]; - "links": string[]; - "meta": { - "path": string | null; - "per_page": number; - "next_cursor": string | null; - "next_cursor_url": string | null; - "prev_cursor": string | null; - "prev_cursor_url": string | null; -}; -}; -export type MessageEventsResponse = { - "data": MessageEventData[]; - "links": string[]; - "meta": { - "path": string | null; - "per_page": number; - "next_cursor": string | null; - "next_cursor_url": string | null; - "prev_cursor": string | null; - "prev_cursor_url": string | null; -}; -}; -export type ProcessInboundMessageResponse = { - "data": { - "message_id": string; - "status": "queued"; - "webhook_target_count": number; -}; -}; -export type ProjectIndexResponse = { - "data": ProjectListData[]; - "path": string | null; - "per_page": number; - "next_cursor": string | null; - "next_page_url": string | null; - "prev_cursor": string | null; - "prev_page_url": string | null; -}; -export type ProjectStoreRequest = StoreProjectData; -export interface ProjectStoreResponse { - "data": ProjectData; - "message": string; - "api_token"?: string; -} - -export type ProjectShowResponse = ProjectData; -export type ProjectUpdateRequest = UpdateProjectData; -export type ProjectUpdateResponse = { - "data": ProjectData; - "message": "Project updated successfully."; -}; -export type ProjectDestroyResponse = { - "message": "Project deleted successfully."; -}; -export type ProjectRotateTokenResponse = { - "data": ProjectData; - "new_token": string; - "message": "API token rotated successfully. Please update your integrations."; -}; -export type RouteIndexResponse = { - "data": RouteListData[]; - "path": string | null; - "per_page": number; - "next_cursor": string | null; - "next_page_url": string | null; - "prev_cursor": string | null; - "prev_page_url": string | null; -}; -export type RouteStoreRequest = StoreRouteData; -export type RouteStoreResponse = { - "data": RouteData; - "message": "Route created successfully."; -}; -export type RouteShowResponse = RouteData; -export type RouteUpdateRequest = UpdateRouteData; -export type RouteUpdateResponse = { - "data": RouteData; - "message": "Route updated successfully."; -}; -export type RouteDestroyResponse = { - "message": "Route deleted successfully."; -}; -export interface RouteVerifyInboundDomainResponse { - "data": { - "verified": true; - "message": "Inbound domain verified successfully."; -}; -} - -export type StatsIndexResponse = StatsData; -export interface SuppressionIndexResponse { - "data": (SuppressedRecipientData)[]; - "path": string; - "per_page": number; - "next_cursor": string | null; - "next_page_url": string | null; - "prev_cursor": string | null; - "prev_page_url": string | null; -} - -export type SuppressionStoreRequest = StoreSuppressionData; -export type SuppressionStoreResponse = { - "message": string | "No emails were added."; - "data": { - "created": string[]; - "skipped": string[]; -}; -}; -export interface SuppressionDestroyResponse { - "success": true; - "status": "removed" | "review_ticket_created" | "review_ticket_exists"; - "message": string; - "confidence"?: number; - "ticket_identifier"?: string; -} - -export type TeamShowResponse = TeamData; -export type TeamUpdateRequest = UpdateTeamData; -export type TeamUpdateResponse = { - "data": TeamData; - "message": "Team settings updated successfully."; -}; -export type TeamUsageResponse = TeamUsageDetailData; -export type TeamRolesResponse = { - "data": TeamRoleData[]; -}; -export type TeamMembersResponse = { - "data": TeamMemberData[]; - "path": string | null; - "per_page": number; - "next_cursor": string | null; - "next_page_url": string | null; - "prev_cursor": string | null; - "prev_page_url": string | null; -}; -export type TeamMembersShowResponse = TeamMemberData; -export type TeamMembersAssignmentUpdateRequest = UpdateTeamMemberAssignmentData; -export type TeamMembersAssignmentUpdateResponse = TeamMemberData; -export type WebhookIndexResponse = { - "data": WebhookListData[]; - "path": string | null; - "per_page": number; - "next_cursor": string | null; - "next_page_url": string | null; - "prev_cursor": string | null; - "prev_page_url": string | null; -}; -export type WebhookStoreRequest = StoreWebhookData; -export type WebhookStoreResponse = { - "data": WebhookSecretData; - "message": "Webhook created successfully. Please save the secret as it will not be shown again."; -}; -export type WebhookShowResponse = WebhookData; -export type WebhookUpdateRequest = UpdateWebhookData; -export type WebhookUpdateResponse = { - "data": WebhookData; - "message": "Webhook updated successfully."; -}; -export type WebhookDestroyResponse = { - "message": "Webhook deleted successfully."; -}; -export type WebhookTestResponse = { - "message": "Test webhook dispatched successfully. Check the deliveries endpoint for results."; - "delivery_id": string; -}; -export type WebhookRegenerateSecretResponse = { - "data": WebhookSecretData; - "message": "Webhook secret regenerated successfully. Please update your integration."; -}; -export type WebhookDeliveriesResponse = { - "data": WebhookDeliveryListData[]; - "path": string | null; - "per_page": number; - "next_cursor": string | null; - "next_page_url": string | null; - "prev_cursor": string | null; - "prev_page_url": string | null; -}; -export type WebhookShowDeliveryResponse = WebhookDeliveryData; - -export interface AnalyticsResponseData { - "data": Record; - "meta": Record; - "pagination": (string)[]; -} - -export interface ProjectCreatedData { - "data": ProjectData; - "message": string; - "api_token"?: string; -} - -export interface ReportForwardingRequest { - "destination": string; -} - -export interface ReportForwardingResource { - "destination": string | null; - "verified": boolean; - "verified_at": string | null; -} - -export interface VerifyReportForwardingRequest { - "code": string; -} - -export interface AnalyticsRequest { - "metrics": ("accepted" | "processed" | "suppressed" | "policy_rejected" | "application_failed" | "mta_accepted" | "canceled" | "messages" | "delivered" | "bounced" | "soft_bounced" | "administratively_bounced" | "deferred_recipients" | "deferred_events" | "delivery_attempts" | "attempted_recipients" | "transport_outcome_recipients" | "effective_delivered" | "open_tracked_delivered" | "click_tracked_delivered" | "out_of_band_bounced_recipients" | "out_of_band_bounce_events" | "complained" | "unsubscribed" | "human_opens" | "human_opens_events" | "human_clicks" | "human_clicks_events" | "machine_opens" | "machine_opens_events" | "machine_clicks" | "machine_clicks_events" | "privacy_opens" | "privacy_opens_events" | "privacy_clicks" | "privacy_clicks_events" | "bot_opens" | "bot_opens_events" | "bot_clicks" | "bot_clicks_events" | "scanner_opens" | "scanner_opens_events" | "scanner_clicks" | "scanner_clicks_events" | "observed_opens" | "observed_opens_events" | "observed_clicks" | "observed_clicks_events" | "delivery_rate" | "effective_delivery_rate" | "bounce_rate" | "deferral_rate" | "complaint_rate" | "human_open_rate" | "human_click_rate" | "processing_latency_p50_ms" | "processing_latency_p95_ms" | "processing_latency_p99_ms" | "processing_latency_samples" | "delivery_latency_p50_ms" | "delivery_latency_p95_ms" | "delivery_latency_p99_ms" | "delivery_latency_samples" | "total_latency_p50_ms" | "total_latency_p95_ms" | "total_latency_p99_ms" | "total_latency_samples")[]; - "from"?: string; - "to"?: string; - "timezone"?: string; - "include"?: ("summary" | "time_series" | "breakdown")[]; - "group_by"?: (string)[]; - "filters"?: ({ - "dimension": string; - "operator": "eq" | "in" | "not_in" | "is_null" | "is_not_null"; - "values"?: (string)[]; -})[]; - "interval"?: "hour" | "day"; - "compare"?: "previous_period"; - "include_trend"?: boolean; - "sort"?: { - "metric": "accepted" | "processed" | "suppressed" | "policy_rejected" | "application_failed" | "mta_accepted" | "canceled" | "messages" | "delivered" | "bounced" | "soft_bounced" | "administratively_bounced" | "deferred_recipients" | "deferred_events" | "delivery_attempts" | "attempted_recipients" | "transport_outcome_recipients" | "effective_delivered" | "open_tracked_delivered" | "click_tracked_delivered" | "out_of_band_bounced_recipients" | "out_of_band_bounce_events" | "complained" | "unsubscribed" | "human_opens" | "human_opens_events" | "human_clicks" | "human_clicks_events" | "machine_opens" | "machine_opens_events" | "machine_clicks" | "machine_clicks_events" | "privacy_opens" | "privacy_opens_events" | "privacy_clicks" | "privacy_clicks_events" | "bot_opens" | "bot_opens_events" | "bot_clicks" | "bot_clicks_events" | "scanner_opens" | "scanner_opens_events" | "scanner_clicks" | "scanner_clicks_events" | "observed_opens" | "observed_opens_events" | "observed_clicks" | "observed_clicks_events" | "delivery_rate" | "effective_delivery_rate" | "bounce_rate" | "deferral_rate" | "complaint_rate" | "human_open_rate" | "human_click_rate" | "processing_latency_p50_ms" | "processing_latency_p95_ms" | "processing_latency_p99_ms" | "processing_latency_samples" | "delivery_latency_p50_ms" | "delivery_latency_p95_ms" | "delivery_latency_p99_ms" | "delivery_latency_samples" | "total_latency_p50_ms" | "total_latency_p95_ms" | "total_latency_p99_ms" | "total_latency_samples"; - "direction": "asc" | "desc"; -}; - "limit"?: number; - "cursor"?: string; -} - -export type UpdateReportForwardingRequest = ReportForwardingRequest; - -export interface AnalyticsResponse { - "data": { - "summary"?: { - "metrics"?: { - "accepted"?: number | null; - "processed"?: number | null; - "suppressed"?: number | null; - "policy_rejected"?: number | null; - "application_failed"?: number | null; - "mta_accepted"?: number | null; - "canceled"?: number | null; - "messages"?: number | null; - "delivered"?: number | null; - "bounced"?: number | null; - "soft_bounced"?: number | null; - "administratively_bounced"?: number | null; - "deferred_recipients"?: number | null; - "deferred_events"?: number | null; - "delivery_attempts"?: number | null; - "attempted_recipients"?: number | null; - "transport_outcome_recipients"?: number | null; - "effective_delivered"?: number | null; - "open_tracked_delivered"?: number | null; - "click_tracked_delivered"?: number | null; - "out_of_band_bounced_recipients"?: number | null; - "out_of_band_bounce_events"?: number | null; - "complained"?: number | null; - "unsubscribed"?: number | null; - "human_opens"?: number | null; - "human_opens_events"?: number | null; - "human_clicks"?: number | null; - "human_clicks_events"?: number | null; - "machine_opens"?: number | null; - "machine_opens_events"?: number | null; - "machine_clicks"?: number | null; - "machine_clicks_events"?: number | null; - "privacy_opens"?: number | null; - "privacy_opens_events"?: number | null; - "privacy_clicks"?: number | null; - "privacy_clicks_events"?: number | null; - "bot_opens"?: number | null; - "bot_opens_events"?: number | null; - "bot_clicks"?: number | null; - "bot_clicks_events"?: number | null; - "scanner_opens"?: number | null; - "scanner_opens_events"?: number | null; - "scanner_clicks"?: number | null; - "scanner_clicks_events"?: number | null; - "observed_opens"?: number | null; - "observed_opens_events"?: number | null; - "observed_clicks"?: number | null; - "observed_clicks_events"?: number | null; - "delivery_rate"?: number | null; - "effective_delivery_rate"?: number | null; - "bounce_rate"?: number | null; - "deferral_rate"?: number | null; - "complaint_rate"?: number | null; - "human_open_rate"?: number | null; - "human_click_rate"?: number | null; - "processing_latency_p50_ms"?: number | null; - "processing_latency_p95_ms"?: number | null; - "processing_latency_p99_ms"?: number | null; - "processing_latency_samples"?: number | null; - "delivery_latency_p50_ms"?: number | null; - "delivery_latency_p95_ms"?: number | null; - "delivery_latency_p99_ms"?: number | null; - "delivery_latency_samples"?: number | null; - "total_latency_p50_ms"?: number | null; - "total_latency_p95_ms"?: number | null; - "total_latency_p99_ms"?: number | null; - "total_latency_samples"?: number | null; -}; - "rate_bases"?: { - "delivery_rate"?: { - "numerator": number | null; - "denominator": number | null; -}; - "effective_delivery_rate"?: { - "numerator": number | null; - "denominator": number | null; -}; - "bounce_rate"?: { - "numerator": number | null; - "denominator": number | null; -}; - "deferral_rate"?: { - "numerator": number | null; - "denominator": number | null; -}; - "complaint_rate"?: { - "numerator": number | null; - "denominator": number | null; -}; - "human_open_rate"?: { - "numerator": number | null; - "denominator": number | null; -}; - "human_click_rate"?: { - "numerator": number | null; - "denominator": number | null; -}; -}; - "previous"?: { - "metrics": { - "accepted"?: number | null; - "processed"?: number | null; - "suppressed"?: number | null; - "policy_rejected"?: number | null; - "application_failed"?: number | null; - "mta_accepted"?: number | null; - "canceled"?: number | null; - "messages"?: number | null; - "delivered"?: number | null; - "bounced"?: number | null; - "soft_bounced"?: number | null; - "administratively_bounced"?: number | null; - "deferred_recipients"?: number | null; - "deferred_events"?: number | null; - "delivery_attempts"?: number | null; - "attempted_recipients"?: number | null; - "transport_outcome_recipients"?: number | null; - "effective_delivered"?: number | null; - "open_tracked_delivered"?: number | null; - "click_tracked_delivered"?: number | null; - "out_of_band_bounced_recipients"?: number | null; - "out_of_band_bounce_events"?: number | null; - "complained"?: number | null; - "unsubscribed"?: number | null; - "human_opens"?: number | null; - "human_opens_events"?: number | null; - "human_clicks"?: number | null; - "human_clicks_events"?: number | null; - "machine_opens"?: number | null; - "machine_opens_events"?: number | null; - "machine_clicks"?: number | null; - "machine_clicks_events"?: number | null; - "privacy_opens"?: number | null; - "privacy_opens_events"?: number | null; - "privacy_clicks"?: number | null; - "privacy_clicks_events"?: number | null; - "bot_opens"?: number | null; - "bot_opens_events"?: number | null; - "bot_clicks"?: number | null; - "bot_clicks_events"?: number | null; - "scanner_opens"?: number | null; - "scanner_opens_events"?: number | null; - "scanner_clicks"?: number | null; - "scanner_clicks_events"?: number | null; - "observed_opens"?: number | null; - "observed_opens_events"?: number | null; - "observed_clicks"?: number | null; - "observed_clicks_events"?: number | null; - "delivery_rate"?: number | null; - "effective_delivery_rate"?: number | null; - "bounce_rate"?: number | null; - "deferral_rate"?: number | null; - "complaint_rate"?: number | null; - "human_open_rate"?: number | null; - "human_click_rate"?: number | null; - "processing_latency_p50_ms"?: number | null; - "processing_latency_p95_ms"?: number | null; - "processing_latency_p99_ms"?: number | null; - "processing_latency_samples"?: number | null; - "delivery_latency_p50_ms"?: number | null; - "delivery_latency_p95_ms"?: number | null; - "delivery_latency_p99_ms"?: number | null; - "delivery_latency_samples"?: number | null; - "total_latency_p50_ms"?: number | null; - "total_latency_p95_ms"?: number | null; - "total_latency_p99_ms"?: number | null; - "total_latency_samples"?: number | null; -}; - "rate_bases": { - "delivery_rate"?: { - "numerator": number | null; - "denominator": number | null; -}; - "effective_delivery_rate"?: { - "numerator": number | null; - "denominator": number | null; -}; - "bounce_rate"?: { - "numerator": number | null; - "denominator": number | null; -}; - "deferral_rate"?: { - "numerator": number | null; - "denominator": number | null; -}; - "complaint_rate"?: { - "numerator": number | null; - "denominator": number | null; -}; - "human_open_rate"?: { - "numerator": number | null; - "denominator": number | null; -}; - "human_click_rate"?: { - "numerator": number | null; - "denominator": number | null; -}; -}; -}; - "change"?: Record; -}; - "time_series"?: ({ - "metrics": { - "accepted"?: number | null; - "processed"?: number | null; - "suppressed"?: number | null; - "policy_rejected"?: number | null; - "application_failed"?: number | null; - "mta_accepted"?: number | null; - "canceled"?: number | null; - "messages"?: number | null; - "delivered"?: number | null; - "bounced"?: number | null; - "soft_bounced"?: number | null; - "administratively_bounced"?: number | null; - "deferred_recipients"?: number | null; - "deferred_events"?: number | null; - "delivery_attempts"?: number | null; - "attempted_recipients"?: number | null; - "transport_outcome_recipients"?: number | null; - "effective_delivered"?: number | null; - "open_tracked_delivered"?: number | null; - "click_tracked_delivered"?: number | null; - "out_of_band_bounced_recipients"?: number | null; - "out_of_band_bounce_events"?: number | null; - "complained"?: number | null; - "unsubscribed"?: number | null; - "human_opens"?: number | null; - "human_opens_events"?: number | null; - "human_clicks"?: number | null; - "human_clicks_events"?: number | null; - "machine_opens"?: number | null; - "machine_opens_events"?: number | null; - "machine_clicks"?: number | null; - "machine_clicks_events"?: number | null; - "privacy_opens"?: number | null; - "privacy_opens_events"?: number | null; - "privacy_clicks"?: number | null; - "privacy_clicks_events"?: number | null; - "bot_opens"?: number | null; - "bot_opens_events"?: number | null; - "bot_clicks"?: number | null; - "bot_clicks_events"?: number | null; - "scanner_opens"?: number | null; - "scanner_opens_events"?: number | null; - "scanner_clicks"?: number | null; - "scanner_clicks_events"?: number | null; - "observed_opens"?: number | null; - "observed_opens_events"?: number | null; - "observed_clicks"?: number | null; - "observed_clicks_events"?: number | null; - "delivery_rate"?: number | null; - "effective_delivery_rate"?: number | null; - "bounce_rate"?: number | null; - "deferral_rate"?: number | null; - "complaint_rate"?: number | null; - "human_open_rate"?: number | null; - "human_click_rate"?: number | null; - "processing_latency_p50_ms"?: number | null; - "processing_latency_p95_ms"?: number | null; - "processing_latency_p99_ms"?: number | null; - "processing_latency_samples"?: number | null; - "delivery_latency_p50_ms"?: number | null; - "delivery_latency_p95_ms"?: number | null; - "delivery_latency_p99_ms"?: number | null; - "delivery_latency_samples"?: number | null; - "total_latency_p50_ms"?: number | null; - "total_latency_p95_ms"?: number | null; - "total_latency_p99_ms"?: number | null; - "total_latency_samples"?: number | null; -}; - "rate_bases": { - "delivery_rate"?: { - "numerator": number | null; - "denominator": number | null; -}; - "effective_delivery_rate"?: { - "numerator": number | null; - "denominator": number | null; -}; - "bounce_rate"?: { - "numerator": number | null; - "denominator": number | null; -}; - "deferral_rate"?: { - "numerator": number | null; - "denominator": number | null; -}; - "complaint_rate"?: { - "numerator": number | null; - "denominator": number | null; -}; - "human_open_rate"?: { - "numerator": number | null; - "denominator": number | null; -}; - "human_click_rate"?: { - "numerator": number | null; - "denominator": number | null; -}; -}; - "previous"?: { - "metrics": { - "accepted"?: number | null; - "processed"?: number | null; - "suppressed"?: number | null; - "policy_rejected"?: number | null; - "application_failed"?: number | null; - "mta_accepted"?: number | null; - "canceled"?: number | null; - "messages"?: number | null; - "delivered"?: number | null; - "bounced"?: number | null; - "soft_bounced"?: number | null; - "administratively_bounced"?: number | null; - "deferred_recipients"?: number | null; - "deferred_events"?: number | null; - "delivery_attempts"?: number | null; - "attempted_recipients"?: number | null; - "transport_outcome_recipients"?: number | null; - "effective_delivered"?: number | null; - "open_tracked_delivered"?: number | null; - "click_tracked_delivered"?: number | null; - "out_of_band_bounced_recipients"?: number | null; - "out_of_band_bounce_events"?: number | null; - "complained"?: number | null; - "unsubscribed"?: number | null; - "human_opens"?: number | null; - "human_opens_events"?: number | null; - "human_clicks"?: number | null; - "human_clicks_events"?: number | null; - "machine_opens"?: number | null; - "machine_opens_events"?: number | null; - "machine_clicks"?: number | null; - "machine_clicks_events"?: number | null; - "privacy_opens"?: number | null; - "privacy_opens_events"?: number | null; - "privacy_clicks"?: number | null; - "privacy_clicks_events"?: number | null; - "bot_opens"?: number | null; - "bot_opens_events"?: number | null; - "bot_clicks"?: number | null; - "bot_clicks_events"?: number | null; - "scanner_opens"?: number | null; - "scanner_opens_events"?: number | null; - "scanner_clicks"?: number | null; - "scanner_clicks_events"?: number | null; - "observed_opens"?: number | null; - "observed_opens_events"?: number | null; - "observed_clicks"?: number | null; - "observed_clicks_events"?: number | null; - "delivery_rate"?: number | null; - "effective_delivery_rate"?: number | null; - "bounce_rate"?: number | null; - "deferral_rate"?: number | null; - "complaint_rate"?: number | null; - "human_open_rate"?: number | null; - "human_click_rate"?: number | null; - "processing_latency_p50_ms"?: number | null; - "processing_latency_p95_ms"?: number | null; - "processing_latency_p99_ms"?: number | null; - "processing_latency_samples"?: number | null; - "delivery_latency_p50_ms"?: number | null; - "delivery_latency_p95_ms"?: number | null; - "delivery_latency_p99_ms"?: number | null; - "delivery_latency_samples"?: number | null; - "total_latency_p50_ms"?: number | null; - "total_latency_p95_ms"?: number | null; - "total_latency_p99_ms"?: number | null; - "total_latency_samples"?: number | null; -}; - "rate_bases": { - "delivery_rate"?: { - "numerator": number | null; - "denominator": number | null; -}; - "effective_delivery_rate"?: { - "numerator": number | null; - "denominator": number | null; -}; - "bounce_rate"?: { - "numerator": number | null; - "denominator": number | null; -}; - "deferral_rate"?: { - "numerator": number | null; - "denominator": number | null; -}; - "complaint_rate"?: { - "numerator": number | null; - "denominator": number | null; -}; - "human_open_rate"?: { - "numerator": number | null; - "denominator": number | null; -}; - "human_click_rate"?: { - "numerator": number | null; - "denominator": number | null; -}; -}; -}; - "change"?: Record; - "from": string; - "to": string; - "available": boolean; - "partial": boolean; -})[]; - "breakdown"?: ({ - "metrics": { - "accepted"?: number | null; - "processed"?: number | null; - "suppressed"?: number | null; - "policy_rejected"?: number | null; - "application_failed"?: number | null; - "mta_accepted"?: number | null; - "canceled"?: number | null; - "messages"?: number | null; - "delivered"?: number | null; - "bounced"?: number | null; - "soft_bounced"?: number | null; - "administratively_bounced"?: number | null; - "deferred_recipients"?: number | null; - "deferred_events"?: number | null; - "delivery_attempts"?: number | null; - "attempted_recipients"?: number | null; - "transport_outcome_recipients"?: number | null; - "effective_delivered"?: number | null; - "open_tracked_delivered"?: number | null; - "click_tracked_delivered"?: number | null; - "out_of_band_bounced_recipients"?: number | null; - "out_of_band_bounce_events"?: number | null; - "complained"?: number | null; - "unsubscribed"?: number | null; - "human_opens"?: number | null; - "human_opens_events"?: number | null; - "human_clicks"?: number | null; - "human_clicks_events"?: number | null; - "machine_opens"?: number | null; - "machine_opens_events"?: number | null; - "machine_clicks"?: number | null; - "machine_clicks_events"?: number | null; - "privacy_opens"?: number | null; - "privacy_opens_events"?: number | null; - "privacy_clicks"?: number | null; - "privacy_clicks_events"?: number | null; - "bot_opens"?: number | null; - "bot_opens_events"?: number | null; - "bot_clicks"?: number | null; - "bot_clicks_events"?: number | null; - "scanner_opens"?: number | null; - "scanner_opens_events"?: number | null; - "scanner_clicks"?: number | null; - "scanner_clicks_events"?: number | null; - "observed_opens"?: number | null; - "observed_opens_events"?: number | null; - "observed_clicks"?: number | null; - "observed_clicks_events"?: number | null; - "delivery_rate"?: number | null; - "effective_delivery_rate"?: number | null; - "bounce_rate"?: number | null; - "deferral_rate"?: number | null; - "complaint_rate"?: number | null; - "human_open_rate"?: number | null; - "human_click_rate"?: number | null; - "processing_latency_p50_ms"?: number | null; - "processing_latency_p95_ms"?: number | null; - "processing_latency_p99_ms"?: number | null; - "processing_latency_samples"?: number | null; - "delivery_latency_p50_ms"?: number | null; - "delivery_latency_p95_ms"?: number | null; - "delivery_latency_p99_ms"?: number | null; - "delivery_latency_samples"?: number | null; - "total_latency_p50_ms"?: number | null; - "total_latency_p95_ms"?: number | null; - "total_latency_p99_ms"?: number | null; - "total_latency_samples"?: number | null; -}; - "rate_bases": { - "delivery_rate"?: { - "numerator": number | null; - "denominator": number | null; -}; - "effective_delivery_rate"?: { - "numerator": number | null; - "denominator": number | null; -}; - "bounce_rate"?: { - "numerator": number | null; - "denominator": number | null; -}; - "deferral_rate"?: { - "numerator": number | null; - "denominator": number | null; -}; - "complaint_rate"?: { - "numerator": number | null; - "denominator": number | null; -}; - "human_open_rate"?: { - "numerator": number | null; - "denominator": number | null; -}; - "human_click_rate"?: { - "numerator": number | null; - "denominator": number | null; -}; -}; - "previous"?: { - "metrics": { - "accepted"?: number | null; - "processed"?: number | null; - "suppressed"?: number | null; - "policy_rejected"?: number | null; - "application_failed"?: number | null; - "mta_accepted"?: number | null; - "canceled"?: number | null; - "messages"?: number | null; - "delivered"?: number | null; - "bounced"?: number | null; - "soft_bounced"?: number | null; - "administratively_bounced"?: number | null; - "deferred_recipients"?: number | null; - "deferred_events"?: number | null; - "delivery_attempts"?: number | null; - "attempted_recipients"?: number | null; - "transport_outcome_recipients"?: number | null; - "effective_delivered"?: number | null; - "open_tracked_delivered"?: number | null; - "click_tracked_delivered"?: number | null; - "out_of_band_bounced_recipients"?: number | null; - "out_of_band_bounce_events"?: number | null; - "complained"?: number | null; - "unsubscribed"?: number | null; - "human_opens"?: number | null; - "human_opens_events"?: number | null; - "human_clicks"?: number | null; - "human_clicks_events"?: number | null; - "machine_opens"?: number | null; - "machine_opens_events"?: number | null; - "machine_clicks"?: number | null; - "machine_clicks_events"?: number | null; - "privacy_opens"?: number | null; - "privacy_opens_events"?: number | null; - "privacy_clicks"?: number | null; - "privacy_clicks_events"?: number | null; - "bot_opens"?: number | null; - "bot_opens_events"?: number | null; - "bot_clicks"?: number | null; - "bot_clicks_events"?: number | null; - "scanner_opens"?: number | null; - "scanner_opens_events"?: number | null; - "scanner_clicks"?: number | null; - "scanner_clicks_events"?: number | null; - "observed_opens"?: number | null; - "observed_opens_events"?: number | null; - "observed_clicks"?: number | null; - "observed_clicks_events"?: number | null; - "delivery_rate"?: number | null; - "effective_delivery_rate"?: number | null; - "bounce_rate"?: number | null; - "deferral_rate"?: number | null; - "complaint_rate"?: number | null; - "human_open_rate"?: number | null; - "human_click_rate"?: number | null; - "processing_latency_p50_ms"?: number | null; - "processing_latency_p95_ms"?: number | null; - "processing_latency_p99_ms"?: number | null; - "processing_latency_samples"?: number | null; - "delivery_latency_p50_ms"?: number | null; - "delivery_latency_p95_ms"?: number | null; - "delivery_latency_p99_ms"?: number | null; - "delivery_latency_samples"?: number | null; - "total_latency_p50_ms"?: number | null; - "total_latency_p95_ms"?: number | null; - "total_latency_p99_ms"?: number | null; - "total_latency_samples"?: number | null; -}; - "rate_bases": { - "delivery_rate"?: { - "numerator": number | null; - "denominator": number | null; -}; - "effective_delivery_rate"?: { - "numerator": number | null; - "denominator": number | null; -}; - "bounce_rate"?: { - "numerator": number | null; - "denominator": number | null; -}; - "deferral_rate"?: { - "numerator": number | null; - "denominator": number | null; -}; - "complaint_rate"?: { - "numerator": number | null; - "denominator": number | null; -}; - "human_open_rate"?: { - "numerator": number | null; - "denominator": number | null; -}; - "human_click_rate"?: { - "numerator": number | null; - "denominator": number | null; -}; -}; -}; - "change"?: Record; - "dimensions": Record; - "trend"?: ({ - "metrics": { - "accepted"?: number | null; - "processed"?: number | null; - "suppressed"?: number | null; - "policy_rejected"?: number | null; - "application_failed"?: number | null; - "mta_accepted"?: number | null; - "canceled"?: number | null; - "messages"?: number | null; - "delivered"?: number | null; - "bounced"?: number | null; - "soft_bounced"?: number | null; - "administratively_bounced"?: number | null; - "deferred_recipients"?: number | null; - "deferred_events"?: number | null; - "delivery_attempts"?: number | null; - "attempted_recipients"?: number | null; - "transport_outcome_recipients"?: number | null; - "effective_delivered"?: number | null; - "open_tracked_delivered"?: number | null; - "click_tracked_delivered"?: number | null; - "out_of_band_bounced_recipients"?: number | null; - "out_of_band_bounce_events"?: number | null; - "complained"?: number | null; - "unsubscribed"?: number | null; - "human_opens"?: number | null; - "human_opens_events"?: number | null; - "human_clicks"?: number | null; - "human_clicks_events"?: number | null; - "machine_opens"?: number | null; - "machine_opens_events"?: number | null; - "machine_clicks"?: number | null; - "machine_clicks_events"?: number | null; - "privacy_opens"?: number | null; - "privacy_opens_events"?: number | null; - "privacy_clicks"?: number | null; - "privacy_clicks_events"?: number | null; - "bot_opens"?: number | null; - "bot_opens_events"?: number | null; - "bot_clicks"?: number | null; - "bot_clicks_events"?: number | null; - "scanner_opens"?: number | null; - "scanner_opens_events"?: number | null; - "scanner_clicks"?: number | null; - "scanner_clicks_events"?: number | null; - "observed_opens"?: number | null; - "observed_opens_events"?: number | null; - "observed_clicks"?: number | null; - "observed_clicks_events"?: number | null; - "delivery_rate"?: number | null; - "effective_delivery_rate"?: number | null; - "bounce_rate"?: number | null; - "deferral_rate"?: number | null; - "complaint_rate"?: number | null; - "human_open_rate"?: number | null; - "human_click_rate"?: number | null; - "processing_latency_p50_ms"?: number | null; - "processing_latency_p95_ms"?: number | null; - "processing_latency_p99_ms"?: number | null; - "processing_latency_samples"?: number | null; - "delivery_latency_p50_ms"?: number | null; - "delivery_latency_p95_ms"?: number | null; - "delivery_latency_p99_ms"?: number | null; - "delivery_latency_samples"?: number | null; - "total_latency_p50_ms"?: number | null; - "total_latency_p95_ms"?: number | null; - "total_latency_p99_ms"?: number | null; - "total_latency_samples"?: number | null; -}; - "rate_bases": { - "delivery_rate"?: { - "numerator": number | null; - "denominator": number | null; -}; - "effective_delivery_rate"?: { - "numerator": number | null; - "denominator": number | null; -}; - "bounce_rate"?: { - "numerator": number | null; - "denominator": number | null; -}; - "deferral_rate"?: { - "numerator": number | null; - "denominator": number | null; -}; - "complaint_rate"?: { - "numerator": number | null; - "denominator": number | null; -}; - "human_open_rate"?: { - "numerator": number | null; - "denominator": number | null; -}; - "human_click_rate"?: { - "numerator": number | null; - "denominator": number | null; -}; -}; - "previous"?: { - "metrics": { - "accepted"?: number | null; - "processed"?: number | null; - "suppressed"?: number | null; - "policy_rejected"?: number | null; - "application_failed"?: number | null; - "mta_accepted"?: number | null; - "canceled"?: number | null; - "messages"?: number | null; - "delivered"?: number | null; - "bounced"?: number | null; - "soft_bounced"?: number | null; - "administratively_bounced"?: number | null; - "deferred_recipients"?: number | null; - "deferred_events"?: number | null; - "delivery_attempts"?: number | null; - "attempted_recipients"?: number | null; - "transport_outcome_recipients"?: number | null; - "effective_delivered"?: number | null; - "open_tracked_delivered"?: number | null; - "click_tracked_delivered"?: number | null; - "out_of_band_bounced_recipients"?: number | null; - "out_of_band_bounce_events"?: number | null; - "complained"?: number | null; - "unsubscribed"?: number | null; - "human_opens"?: number | null; - "human_opens_events"?: number | null; - "human_clicks"?: number | null; - "human_clicks_events"?: number | null; - "machine_opens"?: number | null; - "machine_opens_events"?: number | null; - "machine_clicks"?: number | null; - "machine_clicks_events"?: number | null; - "privacy_opens"?: number | null; - "privacy_opens_events"?: number | null; - "privacy_clicks"?: number | null; - "privacy_clicks_events"?: number | null; - "bot_opens"?: number | null; - "bot_opens_events"?: number | null; - "bot_clicks"?: number | null; - "bot_clicks_events"?: number | null; - "scanner_opens"?: number | null; - "scanner_opens_events"?: number | null; - "scanner_clicks"?: number | null; - "scanner_clicks_events"?: number | null; - "observed_opens"?: number | null; - "observed_opens_events"?: number | null; - "observed_clicks"?: number | null; - "observed_clicks_events"?: number | null; - "delivery_rate"?: number | null; - "effective_delivery_rate"?: number | null; - "bounce_rate"?: number | null; - "deferral_rate"?: number | null; - "complaint_rate"?: number | null; - "human_open_rate"?: number | null; - "human_click_rate"?: number | null; - "processing_latency_p50_ms"?: number | null; - "processing_latency_p95_ms"?: number | null; - "processing_latency_p99_ms"?: number | null; - "processing_latency_samples"?: number | null; - "delivery_latency_p50_ms"?: number | null; - "delivery_latency_p95_ms"?: number | null; - "delivery_latency_p99_ms"?: number | null; - "delivery_latency_samples"?: number | null; - "total_latency_p50_ms"?: number | null; - "total_latency_p95_ms"?: number | null; - "total_latency_p99_ms"?: number | null; - "total_latency_samples"?: number | null; -}; - "rate_bases": { - "delivery_rate"?: { - "numerator": number | null; - "denominator": number | null; -}; - "effective_delivery_rate"?: { - "numerator": number | null; - "denominator": number | null; -}; - "bounce_rate"?: { - "numerator": number | null; - "denominator": number | null; -}; - "deferral_rate"?: { - "numerator": number | null; - "denominator": number | null; -}; - "complaint_rate"?: { - "numerator": number | null; - "denominator": number | null; -}; - "human_open_rate"?: { - "numerator": number | null; - "denominator": number | null; -}; - "human_click_rate"?: { - "numerator": number | null; - "denominator": number | null; -}; -}; -}; - "change"?: Record; - "from": string; - "to": string; - "available": boolean; - "partial": boolean; -})[]; -})[]; -}; - "meta": { - "time_basis"?: "event"; - "timezone"?: string; - "interval"?: "hour" | "day"; - "from"?: string; - "to"?: string; - "effective_to"?: string; - "alignment"?: "hour" | "day"; - "generated_at"?: string; - "available_since"?: string; - "partial"?: boolean; - "ongoing"?: boolean; - "collection_completeness"?: "best_effort"; - "last_ingested_at"?: string | null; - "metric_definition_version"?: string; - "ranked_group_limit"?: number; - "comparison"?: { - "from": string; - "to": string; - "partial": boolean; -}; -}; - "pagination": { - "total_groups": number; - "returned_groups": number; - "next_cursor": string | null; - "truncated": boolean; -}; -} - -export interface GetReportForwardingResponse { - "data": ReportForwardingResource; -} - -export interface UpdateReportForwardingResponse { - "data": ReportForwardingResource; -} - -export interface VerifyReportForwardingResponse { - "data": ReportForwardingResource; -} - -export interface ResendReportForwardingCodeResponse { - "data": ReportForwardingResource; -} diff --git a/src/utils/errors.ts b/src/utils/errors.ts deleted file mode 100644 index 33297c2..0000000 --- a/src/utils/errors.ts +++ /dev/null @@ -1,36 +0,0 @@ -export class LettermintError extends Error { - constructor(message: string) { - super(message); - this.name = this.constructor.name; - } -} - -export class HttpRequestError extends LettermintError { - public readonly statusCode: number; - public readonly responseBody?: unknown; - - constructor(message: string, statusCode: number, responseBody?: unknown) { - super(message); - this.statusCode = statusCode; - this.responseBody = responseBody; - } -} - -export class TimeoutError extends LettermintError {} - -export class ValidationError extends HttpRequestError { - public readonly errorType: string; - - constructor(message: string, errorType: string, responseBody?: unknown) { - super(message, 422, responseBody); - this.errorType = errorType; - } -} - -export class ClientError extends HttpRequestError { - constructor(message: string, responseBody?: unknown) { - super(message, 400, responseBody); - } -} - -export class WebhookVerificationError extends LettermintError {} diff --git a/src/webhook-basic-auth.spec.ts b/src/webhook-basic-auth.spec.ts deleted file mode 100644 index 1ab2654..0000000 --- a/src/webhook-basic-auth.spec.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { resolve } from 'node:path'; -import ts from 'typescript'; -import { Lettermint } from './lettermint'; -import type { WebhookBasicAuthData, WebhookStoreRequest, WebhookUpdateRequest } from './types'; - -const fetchMock = jest.fn(); -beforeEach(() => { - global.fetch = fetchMock; - fetchMock.mockReset(); - fetchMock.mockResolvedValue({ ok: true, json: async () => ({ data: { has_basic_auth: true } }) }); -}); - -it.each([ - ['omit', {}], - ['set', { basic_auth: { username: ' fixture user ', password: '' } }], - ['remove', { basic_auth: null }], -])('keeps the %s credential state and API Bearer authentication', async (_, state) => { - const api = Lettermint.api('fixture-token'); - const create: WebhookStoreRequest = { - name: 'Fixture', - url: 'https://example.test/hook', - events: ['message.sent'], - ...state, - }; - const update: WebhookUpdateRequest = state; - expect((await api.webhooks.create(create)).data.has_basic_auth).toBe(true); - expect((await api.webhooks.update('webhook-id', update)).data.has_basic_auth).toBe(true); - for (const [index, payload] of [create, update].entries()) { - const [url, request] = fetchMock.mock.calls[index]; - expect(url).toBe(`https://api.lettermint.co/v1/webhooks${index ? '/webhook-id' : ''}`); - expect(request.method).toBe(index ? 'PUT' : 'POST'); - expect(JSON.parse(request.body)).toEqual(payload); - expect(request.headers.Authorization).toBe('Bearer fixture-token'); - expect(request.headers).not.toHaveProperty('x-lettermint-token'); - } - const credentials: WebhookBasicAuthData = { username: 'fixture', password: '' }; - expect(credentials.password).toBe(''); -}); - -it('keeps the Free-plan Sandbox 403 response', async () => { - const body = { - error: { - code: 'FEATURE_NOT_AVAILABLE', - message: 'Sandbox mode is available only on paid plans.', - }, - }; - fetchMock.mockResolvedValueOnce({ ok: false, status: 403, json: async () => body }); - await expect( - Lettermint.email('fixture-token') - .from('from@example.test') - .to('to@example.test') - .subject('Fixture') - .send() - ).rejects.toMatchObject({ statusCode: 403, responseBody: body }); -}); - -it('proves that old typed webhook fixtures need the required safe flag', () => { - const filename = resolve(__dirname, '__old_webhook_caller__.ts'); - const source = `import type { WebhookData, WebhookListData, WebhookSecretData } from './types'; -const legacy: Omit = { - id: 'fixture', scope: 'route', project_ids: [], route_ids: [], route_id: null, - name: 'Fixture', url: 'https://example.test/hook', events: [], enabled: true, - include_machine_events: false, last_called_at: null, created_at: '', updated_at: '', - delivery_mode_filter: 'both' -}; -const detail: WebhookData = legacy; -const list: WebhookListData = legacy; -const secret: WebhookSecretData = { ...legacy, secret: 'synthetic-signing-secret' }; -`; - const compile = (text: string) => { - const options: ts.CompilerOptions = { - strict: true, - noEmit: true, - skipLibCheck: true, - types: [], - target: ts.ScriptTarget.ES2022, - module: ts.ModuleKind.CommonJS, - }; - const host = ts.createCompilerHost(options); - const read = host.getSourceFile.bind(host); - host.getSourceFile = (path, languageVersion, ...args) => - path === filename - ? ts.createSourceFile(path, text, languageVersion, true) - : read(path, languageVersion, ...args); - return ts.getPreEmitDiagnostics(ts.createProgram([filename], options, host)); - }; - const errors = compile(source); - expect(errors).toHaveLength(3); - for (const error of errors) { - expect(error.code).toBe(2741); - expect(ts.flattenDiagnosticMessageText(error.messageText, '\n')).toContain('has_basic_auth'); - } - expect( - compile( - source - .replace( - "delivery_mode_filter: 'both'", - "delivery_mode_filter: 'both', has_basic_auth: false" - ) - .replace("Omit", 'WebhookData') - ) - ).toHaveLength(0); -}); diff --git a/src/webhook.spec.ts b/src/webhook.spec.ts deleted file mode 100644 index 31c9ccf..0000000 --- a/src/webhook.spec.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { createHmac } from 'node:crypto'; -import { LettermintError, Webhook, WebhookVerificationError } from './index'; - -const now = 1700000000; -const secret = 'test-webhook-secret'; -const body = '{"event":"message.delivered","data":{"subject":"Hello 🌍"}}'; -const sign = (payload: string | Buffer = body, timestamp = now, key = secret) => - `t=${timestamp},v1=${createHmac('sha256', key).update(`${timestamp}.`).update(payload).digest('hex')}`; - -describe('Webhook', () => { - beforeEach(() => { - jest.spyOn(Date, 'now').mockReturnValue(now * 1000); - }); - afterEach(() => jest.restoreAllMocks()); - - it('exports the verifier and SDK error type', () => { - expect(new WebhookVerificationError('test')).toBeInstanceOf(LettermintError); - expect(new Webhook(secret).verify(body, sign())).toEqual(JSON.parse(body)); - }); - - it('verifies exact UTF-8 bytes from a Buffer', () => { - const raw = Buffer.from(` ${body}\n`); - expect(new Webhook(secret).verify(raw, sign(raw))).toEqual(JSON.parse(body)); - expect(() => new Webhook(secret).verify(body, sign(raw))).toThrow(WebhookVerificationError); - }); - - it.each([-300, 0, 300])('accepts timestamps within tolerance: %i', (offset) => { - expect(new Webhook(secret).verify(body, sign(body, now + offset))).toEqual(JSON.parse(body)); - }); - - it.each([-301, 301])('rejects timestamps outside tolerance: %i', (offset) => { - expect(() => new Webhook(secret).verify(body, sign(body, now + offset))).toThrow( - 'outside the allowed range' - ); - }); - - it('uses a custom tolerance and keeps the check enabled at zero', () => { - expect(() => new Webhook(secret, { tolerance: 60 }).verify(body, sign(body, now - 61))).toThrow( - WebhookVerificationError - ); - const webhook = new Webhook(secret, { tolerance: 0 }); - expect(webhook.verify(body, sign())).toEqual(JSON.parse(body)); - expect(() => webhook.verify(body, sign(body, now - 1))).toThrow(WebhookVerificationError); - }); - - it.each([-1, 0.5, Number.NaN, Number.POSITIVE_INFINITY, Number.MAX_SAFE_INTEGER + 1])( - 'rejects invalid tolerance: %s', - (tolerance) => { - expect(() => new Webhook(secret, { tolerance })).toThrow(WebhookVerificationError); - } - ); - - it('rejects an empty secret', () => { - expect(() => new Webhook('')).toThrow(WebhookVerificationError); - }); - - it('rejects a changed payload or wrong secret', () => { - expect(() => new Webhook(secret).verify('{}', sign())).toThrow('does not match'); - expect(() => new Webhook('wrong').verify(body, sign())).toThrow('does not match'); - }); - - it.each([ - '', - 'v1=abc', - `t=${now}`, - `t=${now},v1=abc`, - `t=${now},v1=${'g'.repeat(64)}`, - `t=${now},v1=${'a'.repeat(63)}`, - `t=${now},v1=${'a'.repeat(65)}`, - `t=${now},${sign()}`, - sign().replace(`t=${now}`, 't=NaN'), - sign().replace(`t=${now}`, 't=1e9'), - sign().replace(`t=${now}`, 't=-1'), - sign().replace(`t=${now}`, 't=9007199254740992'), - sign().replace(`t=${now}`, `t=${now}=extra`), - `${sign()}=extra`, - ])('rejects invalid signature headers: %s', (signature) => { - expect(() => new Webhook(secret).verify(body, signature)).toThrow(WebhookVerificationError); - }); - - it('accepts any matching v1 signature and ignores unsupported versions', () => { - const signature = `v2=ignored, v1=${'0'.repeat(64)}, ${sign()}, v1=malformed`; - expect(new Webhook(secret).verify(body, signature)).toEqual(JSON.parse(body)); - }); - - it('rejects empty bodies and invalid signed JSON', () => { - expect(() => new Webhook(secret).verify('', sign(''))).toThrow(WebhookVerificationError); - expect(() => new Webhook(secret).verify('invalid', sign('invalid'))).toThrow('not valid JSON'); - expect(() => new Webhook(secret).verify('invalid', sign())).toThrow('does not match'); - }); - - it('checks the optional delivery timestamp', () => { - const webhook = new Webhook(secret); - expect(webhook.verify(body, sign(), now)).toEqual(JSON.parse(body)); - for (const timestamp of [now + 1, Number.NaN, Number.POSITIVE_INFINITY]) { - expect(() => webhook.verify(body, sign(), timestamp)).toThrow('do not match'); - } - }); - - it('accepts Node.js headers with case-insensitive names', () => { - expect( - new Webhook(secret).verifyHeaders( - { - 'X-Lettermint-Signature': sign(), - 'x-lettermint-delivery': String(now), - host: 'localhost', - }, - Buffer.from(body) - ) - ).toEqual(JSON.parse(body)); - }); - - it.each([ - {}, - { 'x-lettermint-signature': sign() }, - { 'x-lettermint-delivery': String(now) }, - { 'x-lettermint-signature': [sign()], 'x-lettermint-delivery': String(now) }, - { 'x-lettermint-signature': sign(), 'x-lettermint-delivery': '' }, - { 'x-lettermint-signature': sign(), 'x-lettermint-delivery': `${now}junk` }, - { 'x-lettermint-signature': sign(), 'x-lettermint-delivery': String(now + 1) }, - { 'x-lettermint-signature': sign(), 'x-lettermint-delivery': '9007199254740992' }, - { - 'x-lettermint-signature': sign(), - 'X-Lettermint-Signature': sign(), - 'x-lettermint-delivery': String(now), - }, - ])('rejects missing, ambiguous, or invalid headers: %j', (headers) => { - expect(() => new Webhook(secret).verifyHeaders(headers, body)).toThrow( - WebhookVerificationError - ); - }); -}); diff --git a/src/webhook.ts b/src/webhook.ts index d053a39..c3f8efb 100644 --- a/src/webhook.ts +++ b/src/webhook.ts @@ -1,94 +1,284 @@ -import { createHmac, timingSafeEqual } from 'node:crypto'; -import { WebhookVerificationError } from './utils/errors'; +import { type BinaryInput, isBinary, toBytes, utf8 } from './encoding'; +import { LettermintConfigError, WebhookVerificationError } from './errors'; +import type { WebhookEvent } from './generated/types'; +import { inspectCustom, renderInspect } from './redact'; +/** Options of {@link Webhook}. */ export interface WebhookOptions { - /** Maximum timestamp difference in seconds. The default is 300. */ + /** + * Maximum difference between the signed timestamp and the current time, in + * seconds, in either direction. Default 300. `0` accepts only the current second. + */ tolerance?: number; } -export type WebhookHeaders = Record; +/** Request headers: a Fetch `Headers` object or a Node.js-style header record. */ +export type WebhookHeaders = Headers | Record; +/** The raw request body, exactly as received. */ +export type WebhookBody = string | BinaryInput; + +/** A verified webhook delivery. Unknown fields are kept. */ +export interface WebhookPayload> { + /** The delivery id. */ + id?: string; + /** The event name, for example `message.delivered`. Unknown events pass through as strings. */ + event: WebhookEvent; + /** When the event occurred, ISO 8601. */ + timestamp: string; + data: TData; + [key: string]: unknown; +} + +const SIGNATURE_HEADER = 'x-lettermint-signature'; +const DELIVERY_HEADER = 'x-lettermint-delivery'; +const PRINTABLE_ASCII = /^[\x20-\x7e]*$/; +const DIGITS = /^[0-9]+$/; +const HEX_SHA256 = /^[0-9a-fA-F]{64}$/; + +type Lookup = { value: string } | { missing: true } | { ambiguous: true }; + +function readHeader(headers: WebhookHeaders, name: string): Lookup { + if (headers && typeof (headers as Headers).get === 'function') { + const value = (headers as Headers).get(name); + return value === null ? { missing: true } : { value }; + } + if (typeof headers !== 'object' || headers === null) return { missing: true }; + const values: string[] = []; + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() !== name || value === undefined) continue; + if (Array.isArray(value)) values.push(...value); + else values.push(value); + } + if (values.length === 0) return { missing: true }; + if (values.length > 1 || typeof values[0] !== 'string') return { ambiguous: true }; + return { value: values[0] }; +} + +function hexToBytes(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < bytes.length; i++) + bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16); + return bytes; +} + +function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + let difference = 0; + for (let i = 0; i < a.length; i++) difference |= a[i] ^ b[i]; + return difference === 0; +} + +interface ParsedSignature { + timestamp: string; + signatures: Uint8Array[]; +} + +function parseSignatureHeader(header: string): ParsedSignature { + const malformed = (detail: string) => + new WebhookVerificationError( + 'signature_header_malformed', + `The signature header is malformed: ${detail}.` + ); + if (!PRINTABLE_ASCII.test(header)) throw malformed('it contains non-ASCII or control characters'); + let timestamp: string | undefined; + const signatures: Uint8Array[] = []; + for (const part of header.split(',')) { + const entry = part.trim(); + const separator = entry.indexOf('='); + if (separator === -1) continue; + const key = entry.slice(0, separator); + const value = entry.slice(separator + 1); + if (key === 't') { + if (timestamp !== undefined) throw malformed('it has more than one timestamp'); + if (!DIGITS.test(value) || !Number.isSafeInteger(Number(value))) { + throw malformed('the timestamp is not a number of seconds'); + } + timestamp = value; + } else if (key === 'v1' && HEX_SHA256.test(value)) { + signatures.push(hexToBytes(value)); + } + } + if (timestamp === undefined) throw malformed('the timestamp (t=) is missing'); + if (signatures.length === 0) throw malformed('no v1 signature is present'); + return { timestamp, signatures }; +} + +function bodyBytes(body: unknown): Uint8Array { + let bytes: Uint8Array | undefined; + if (typeof body === 'string') bytes = utf8(body); + else if (isBinary(body)) bytes = toBytes(body); + if (!bytes) { + throw new WebhookVerificationError( + 'body_invalid', + 'Pass the raw request body (string, Uint8Array or ArrayBuffer), not parsed JSON.' + ); + } + if (bytes.length === 0) { + throw new WebhookVerificationError('body_invalid', 'The raw request body is empty.'); + } + return bytes; +} + +function getSubtle(): SubtleCrypto { + const subtle = (globalThis as { crypto?: Crypto }).crypto?.subtle; + if (!subtle) { + throw new LettermintConfigError( + 'Webhook verification needs the Web Crypto API (crypto.subtle).' + ); + } + return subtle; +} + +/** + * Verifies Lettermint webhook deliveries: an HMAC-SHA256 signature over + * `"." + raw body` with the endpoint's signing secret (`whsec_...`), checked + * with Web Crypto and a constant-time comparison. + */ export class Webhook { - private readonly tolerance: number; + readonly #secret: string; + readonly #tolerance: number; + #key: Promise | undefined; - constructor( - private readonly secret: string, - options: WebhookOptions = {} - ) { + constructor(secret: string, options: WebhookOptions = {}) { if (typeof secret !== 'string' || secret.length === 0) { - throw new WebhookVerificationError('The webhook secret is required.'); + throw new LettermintConfigError('The webhook signing secret must be a non-empty string.'); } - this.tolerance = options.tolerance ?? 300; - if (!Number.isSafeInteger(this.tolerance) || this.tolerance < 0) { - throw new WebhookVerificationError('The tolerance must be a non-negative integer.'); + const tolerance = options?.tolerance ?? 300; + if (!Number.isSafeInteger(tolerance) || tolerance < 0) { + throw new LettermintConfigError( + '`tolerance` must be a non-negative whole number of seconds.' + ); } + this.#secret = secret; + this.#tolerance = tolerance; + } + + /** The timestamp tolerance in seconds. */ + get tolerance(): number { + return this.#tolerance; } - /** Verify the raw request body before JSON decoding. */ - public verify(payload: string | Buffer, signature: string, timestamp?: number): unknown { - if ((typeof payload !== 'string' && !Buffer.isBuffer(payload)) || payload.length === 0) { - throw new WebhookVerificationError('The raw request body is required.'); + /** + * Verifies a delivery from its raw body and request headers, and returns the + * decoded payload. Requires `X-Lettermint-Signature` and `X-Lettermint-Delivery` + * (which must equal the signed timestamp). Header names are case-insensitive. + * + * @throws {WebhookVerificationError} when the delivery is not genuine. + */ + async verify>( + rawBody: WebhookBody, + headers: WebhookHeaders + ): Promise> { + const signature = readHeader(headers, SIGNATURE_HEADER); + if ('missing' in signature) { + throw new WebhookVerificationError( + 'signature_header_missing', + 'The X-Lettermint-Signature header is missing.' + ); } - if (typeof signature !== 'string' || signature.length === 0) { - throw new WebhookVerificationError('The signature header is required.'); + if ('ambiguous' in signature) { + throw new WebhookVerificationError( + 'signature_header_malformed', + 'The request has more than one X-Lettermint-Signature header.' + ); } - - let signedTimestamp: string | undefined; - const hashes: Buffer[] = []; - for (const part of signature.split(',')) { - const entry = part.trim(); - const separator = entry.indexOf('='); - if (separator === -1) continue; - const key = entry.slice(0, separator); - const value = entry.slice(separator + 1); - if (key === 't') { - if (signedTimestamp !== undefined || !/^\d+$/.test(value ?? '')) { - throw new WebhookVerificationError('The signature timestamp is invalid.'); - } - signedTimestamp = value; - } else if (key === 'v1' && /^[a-fA-F0-9]{64}$/.test(value ?? '')) { - hashes.push(Buffer.from(value, 'hex')); - } + const delivery = readHeader(headers, DELIVERY_HEADER); + if ('missing' in delivery) { + throw new WebhookVerificationError( + 'delivery_header_missing', + 'The X-Lettermint-Delivery header is missing.' + ); } - const seconds = Number(signedTimestamp); - if (signedTimestamp === undefined || !Number.isSafeInteger(seconds) || hashes.length === 0) { - throw new WebhookVerificationError('The signature header is invalid.'); + if ('ambiguous' in delivery) { + throw new WebhookVerificationError( + 'delivery_timestamp_mismatch', + 'The request has more than one X-Lettermint-Delivery header.' + ); } - if (timestamp !== undefined && (!Number.isSafeInteger(timestamp) || timestamp !== seconds)) { - throw new WebhookVerificationError('The signature and delivery timestamps do not match.'); + return this.verifySignature(rawBody, signature.value, delivery.value); + } + + /** + * Verifies the raw body against an `X-Lettermint-Signature` value, for setups + * where the headers are not at hand. When `timestamp` (the `X-Lettermint-Delivery` + * value) is given, it must equal the signed timestamp. + * + * @throws {WebhookVerificationError} when the delivery is not genuine. + */ + async verifySignature>( + rawBody: WebhookBody, + signatureHeader: string, + timestamp?: string | number + ): Promise> { + if (typeof signatureHeader !== 'string' || signatureHeader.trim() === '') { + throw new WebhookVerificationError( + 'signature_header_missing', + 'The X-Lettermint-Signature header is missing.' + ); + } + const parsed = parseSignatureHeader(signatureHeader); + if (timestamp !== undefined && String(timestamp).trim() !== parsed.timestamp) { + throw new WebhookVerificationError( + 'delivery_timestamp_mismatch', + 'The X-Lettermint-Delivery header does not match the signed timestamp.' + ); } - if (Math.abs(Math.floor(Date.now() / 1000) - seconds) > this.tolerance) { - throw new WebhookVerificationError('The signature timestamp is outside the allowed range.'); + const bytes = bodyBytes(rawBody); + const now = Math.floor(Date.now() / 1000); + if (Math.abs(now - Number(parsed.timestamp)) > this.#tolerance) { + throw new WebhookVerificationError( + 'timestamp_out_of_tolerance', + 'The signed timestamp is outside the allowed tolerance.' + ); } - const expected = createHmac('sha256', this.secret) - .update(`${signedTimestamp}.`) - .update(payload) - .digest(); - if (!hashes.some((hash) => timingSafeEqual(hash, expected))) { - throw new WebhookVerificationError('The webhook signature does not match.'); + const subtle = getSubtle(); + this.#key ??= Promise.resolve( + subtle.importKey('raw', utf8(this.#secret), { name: 'HMAC', hash: 'SHA-256' }, false, [ + 'sign', + ]) + ); + const prefix = utf8(`${parsed.timestamp}.`); + const signed = new Uint8Array(prefix.length + bytes.length); + signed.set(prefix); + signed.set(bytes, prefix.length); + const expected = new Uint8Array(await subtle.sign('HMAC', await this.#key, signed)); + let matched = false; + for (const candidate of parsed.signatures) { + if (constantTimeEqual(candidate, expected)) matched = true; } + if (!matched) { + throw new WebhookVerificationError( + 'signature_mismatch', + 'The webhook signature does not match.' + ); + } + + let payload: unknown; try { - return JSON.parse(typeof payload === 'string' ? payload : payload.toString('utf8')); + payload = JSON.parse(new TextDecoder().decode(bytes)); } catch { - throw new WebhookVerificationError('The webhook payload is not valid JSON.'); + throw new WebhookVerificationError( + 'payload_invalid', + 'The webhook payload is not valid JSON.' + ); } + if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) { + throw new WebhookVerificationError( + 'payload_invalid', + 'The webhook payload is not a JSON object.' + ); + } + return payload as WebhookPayload; } - /** Verify Node.js request headers and the raw request body. Header names are case-insensitive. */ - public verifyHeaders(headers: WebhookHeaders, payload: string | Buffer): unknown { - const readHeader = (name: string): string => { - const matches = Object.entries(headers).filter(([key]) => key.toLowerCase() === name); - if (matches.length !== 1 || typeof matches[0][1] !== 'string') { - throw new WebhookVerificationError(`A single ${name} header is required.`); - } - return matches[0][1]; - }; - const signature = readHeader('x-lettermint-signature'); - const delivery = readHeader('x-lettermint-delivery'); - if (!/^\d+$/.test(delivery)) { - throw new WebhookVerificationError('The delivery timestamp is invalid.'); - } - return this.verify(payload, signature, Number(delivery)); + /** Contains no secret. */ + toJSON(): { tolerance: number } { + return { tolerance: this.#tolerance }; + } + + [inspectCustom](_depth: number, options: unknown, inspect: unknown): string { + return renderInspect('Webhook', { tolerance: this.#tolerance }, options, inspect); } } diff --git a/test/client.test.ts b/test/client.test.ts new file mode 100644 index 0000000..defd881 --- /dev/null +++ b/test/client.test.ts @@ -0,0 +1,272 @@ +import { inspect } from 'node:util'; +import Default, { Lettermint, LettermintConfigError, LettermintError } from '../src'; +import { + BASE, + SENDING_TOKEN, + TEAM_TOKEN, + client, + fakeFetch, + json, + rejection, + text, + thrown, +} from './helpers'; + +const TEAM = `lm_team_${'A1b2C3d4E5'.repeat(4)}`; +const PROJECT_32 = `lm_${'Z9y8X7w6V5u4T3s2'.repeat(2)}`; +const PROJECT_22 = `lm_${'Q1w2E3r4T5y6U7i8O9p0A1'}`; + +describe('construction', () => { + it('exports the client as default and named export', () => { + expect(Default).toBe(Lettermint); + }); + + it('requires at least one token', () => { + const error = thrown(() => new Lettermint({})); + expect(error).toBeInstanceOf(LettermintConfigError); + expect(error).toBeInstanceOf(LettermintError); + expect(error.name).toBe('LettermintConfigError'); + expect(error.message).toBe('Pass `sendingToken`, `teamToken` or both.'); + }); + + it.each([ + [{ sendingToken: '' }, '`sendingToken` must be a non-empty string.'], + [{ teamToken: 42 }, '`teamToken` must be a non-empty string.'], + [{ sendingToken: `${SENDING_TOKEN}\n` }, '`sendingToken` contains whitespace'], + [{ teamToken: `Bearer ${TEAM_TOKEN}` }, '`teamToken` contains whitespace'], + ])('rejects invalid tokens %#', (options, message) => { + const error = thrown(() => new Lettermint(options as never)); + expect(error).toBeInstanceOf(LettermintConfigError); + expect(error.message).toContain(message); + expect(error.message).not.toContain(SENDING_TOKEN); + expect(error.message).not.toContain(TEAM_TOKEN); + }); + + it('explains the removed v2 apiToken option', () => { + const error = thrown(() => new Lettermint({ apiToken: SENDING_TOKEN } as never)); + expect(error).toBeInstanceOf(LettermintConfigError); + expect(error.message).toContain('`apiToken` was removed in 3.0'); + expect(error.message).not.toContain(SENDING_TOKEN); + }); + + it.each([ + ['not a url', 'absolute http(s) URL'], + ['ftp://api.example.test', 'absolute http(s) URL'], + ['https://user:pass@api.example.test/v1', 'must not contain credentials'], + ['https://api.example.test/v1?x=1', 'must not contain credentials'], + ])('rejects the base URL %s', (baseUrl, message) => { + expect(() => new Lettermint({ sendingToken: SENDING_TOKEN, baseUrl })).toThrow(message); + }); + + it.each([0, -1, Number.NaN, Number.POSITIVE_INFINITY, '100'])( + 'rejects the timeout %p', + (timeout) => { + expect( + () => new Lettermint({ sendingToken: SENDING_TOKEN, timeout: timeout as number }) + ).toThrow(LettermintConfigError); + } + ); + + it('uses a custom base URL and strips trailing slashes', async () => { + const { lettermint, requests } = client( + { baseUrl: 'https://custom.example.test/api/v1///' }, + () => text(200, 'pong') + ); + await lettermint.ping(); + expect(requests[0].url).toBe('https://custom.example.test/api/v1/ping'); + }); + + it('uses the global fetch when none is passed', async () => { + const original = globalThis.fetch; + const { fetch } = fakeFetch(() => text(200, 'pong')); + globalThis.fetch = fetch as unknown as typeof globalThis.fetch; + try { + await expect(new Lettermint({ teamToken: TEAM_TOKEN }).ping()).resolves.toBe('pong'); + expect(fetch).toHaveBeenCalledTimes(1); + } finally { + globalThis.fetch = original; + } + }); + + it('fails clearly without any fetch', () => { + const original = globalThis.fetch; + // @ts-expect-error simulate a runtime without fetch + globalThis.fetch = undefined; + try { + expect(() => new Lettermint({ teamToken: TEAM_TOKEN })).toThrow( + 'No global fetch is available' + ); + } finally { + globalThis.fetch = original; + } + }); +}); + +describe('token shorthand', () => { + async function authHeaders(token: string) { + const { fetch, requests } = fakeFetch(() => text(200, 'pong')); + const lettermint = new Lettermint(token, { fetch }); + await lettermint.ping(); + return requests[0].headers; + } + + it('detects a team token first', async () => { + const headers = await authHeaders(TEAM); + expect(headers.Authorization).toBe(`Bearer ${TEAM}`); + expect(headers).not.toHaveProperty('x-lettermint-token'); + }); + + it.each([ + ['32-character', PROJECT_32], + ['22-character', PROJECT_22], + ])('detects a %s project sending token', async (_, token) => { + const headers = await authHeaders(token); + expect(headers['x-lettermint-token']).toBe(token); + expect(headers).not.toHaveProperty('Authorization'); + }); + + it.each([ + ['an SSO token', `lm_sso_${'a1B2c3D4'.repeat(4)}`], + ['an empty string', ''], + ['a JWT', 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.c2lnbmF0dXJl'], + ['an unknown prefix', 'sk_live_0123456789abcdef'], + ['a bare team prefix', 'lm_team_'], + ['a bare project prefix', 'lm_'], + ['a token with a trailing newline', `${PROJECT_32}\n`], + ['a token with a dash', 'lm_abc-def'], + ])('rejects %s without echoing it', (_, token) => { + const error = thrown(() => new Lettermint(token)); + expect(error).toBeInstanceOf(LettermintConfigError); + expect(error.message).toBe( + 'Unrecognised token format; pass { sendingToken } or { teamToken } instead.' + ); + if (token) { + expect(error.message).not.toContain(token); + expect(String(error.stack)).not.toContain(token); + } + }); + + it('ignores token options passed with the shorthand', async () => { + const { fetch, requests } = fakeFetch(() => text(200, 'pong')); + const lettermint = new Lettermint(PROJECT_32, { fetch, teamToken: TEAM } as never); + await lettermint.ping(); + expect(requests[0].headers['x-lettermint-token']).toBe(PROJECT_32); + expect(requests[0].headers).not.toHaveProperty('Authorization'); + }); +}); + +describe('single-token clients', () => { + it('rejects Team API calls without a team token, naming the option', async () => { + const { fetch } = fakeFetch(); + const lettermint = new Lettermint({ sendingToken: SENDING_TOKEN, fetch }); + for (const call of [ + () => lettermint.domains.list(), + () => lettermint.projects.reportForwarding.retrieve('project_1'), + () => lettermint.team.members.list(), + () => lettermint.webhooks.deliveries.list('webhook_1'), + () => lettermint.analytics({ metrics: ['accepted'] }), + () => lettermint.blockedFileTypes(), + () => lettermint.messages.process('message_1'), + ]) { + const error = await rejection(call()); + expect(error).toBeInstanceOf(LettermintConfigError); + expect(error.message).toMatch(/^[\w.]+ needs `teamToken`/); + expect(error.message).not.toContain(SENDING_TOKEN); + } + const iterator = lettermint.domains.iterate(); + await expect(iterator.next()).rejects.toThrow('domains.iterate needs `teamToken`'); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('rejects sending without a sending token and never falls back to the team token', async () => { + const { fetch } = fakeFetch(); + const lettermint = new Lettermint({ teamToken: TEAM_TOKEN, fetch }); + const message = { from: 'a@example.test', to: ['b@example.test'], subject: 'Hi' }; + await expect(lettermint.emails.send(message)).rejects.toThrow( + 'emails.send needs `sendingToken`' + ); + await expect(lettermint.emails.sendBatch([message])).rejects.toThrow( + 'emails.sendBatch needs `sendingToken`' + ); + await expect(lettermint.emails.ping()).rejects.toThrow('emails.ping needs `sendingToken`'); + expect(() => lettermint.emails.compose()).toThrow('emails.compose needs `sendingToken`'); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('pings with the team token when present, otherwise the sending token', async () => { + const both = client({}, () => text(200, 'pong\n')); + await expect(both.lettermint.ping()).resolves.toBe('pong'); + expect(both.requests[0].headers.Authorization).toBe(`Bearer ${TEAM_TOKEN}`); + expect(both.requests[0].headers).not.toHaveProperty('x-lettermint-token'); + + const sendingOnly = client({ teamToken: undefined }, () => text(200, 'pong')); + await sendingOnly.lettermint.ping(); + expect(sendingOnly.requests[0].headers['x-lettermint-token']).toBe(SENDING_TOKEN); + expect(sendingOnly.requests[0].headers).not.toHaveProperty('Authorization'); + + await both.lettermint.emails.ping(); + expect(both.requests[1].headers['x-lettermint-token']).toBe(SENDING_TOKEN); + expect(both.requests[1].headers).not.toHaveProperty('Authorization'); + }); + + it('lets reschedule and cancel use the sending token when no team token is set', async () => { + const scheduled = { message_id: 'm', status: 'scheduled', scheduled_at: null }; + const sendingOnly = client({ teamToken: undefined }, () => json(200, scheduled)); + await sendingOnly.lettermint.messages.cancel('m'); + await sendingOnly.lettermint.messages.reschedule('m', { scheduled_at: '2026-10-05T09:00:00Z' }); + for (const request of sendingOnly.requests) { + expect(request.headers['x-lettermint-token']).toBe(SENDING_TOKEN); + expect(request.headers).not.toHaveProperty('Authorization'); + } + const both = client({}, () => json(200, scheduled)); + await both.lettermint.messages.cancel('m'); + expect(both.requests[0].headers.Authorization).toBe(`Bearer ${TEAM_TOKEN}`); + }); +}); + +describe('redaction', () => { + const render = (value: unknown) => [ + inspect(value), + inspect(value, { depth: Number.POSITIVE_INFINITY, showHidden: true }), + JSON.stringify(value), + String(value), + ]; + + it('never shows tokens on the client or any sub-client', () => { + const { lettermint } = client(); + const subjects: unknown[] = [ + lettermint, + lettermint.emails, + lettermint.domains, + lettermint.messages, + lettermint.projects, + lettermint.projects.reportForwarding, + lettermint.routes, + lettermint.stats, + lettermint.suppressions, + lettermint.team, + lettermint.team.members, + lettermint.webhooks, + lettermint.webhooks.deliveries, + lettermint.emails.compose().from('a@example.test').to('b@example.test').subject('Hi'), + ]; + for (const subject of subjects) { + for (const output of render(subject)) { + expect(output).not.toContain(SENDING_TOKEN); + expect(output).not.toContain(TEAM_TOKEN); + } + } + }); + + it('shows which tokens are configured', () => { + const { lettermint } = client({ teamToken: undefined }); + expect(inspect(lettermint)).toContain("sendingToken: '[redacted]'"); + expect(lettermint.toJSON()).toEqual({ + baseUrl: BASE, + timeout: 30000, + sendingToken: '[redacted]', + teamToken: undefined, + }); + expect(Object.keys(lettermint)).not.toContain('sendingToken'); + }); +}); diff --git a/test/emails.test.ts b/test/emails.test.ts new file mode 100644 index 0000000..947de81 --- /dev/null +++ b/test/emails.test.ts @@ -0,0 +1,498 @@ +import { inspect } from 'node:util'; +import { type EmailMessage, LettermintValidationError } from '../src'; +import { SENDING_TOKEN, TEAM_TOKEN, client, delay, json, rejection, thrown } from './helpers'; + +const accepted = (id = 'message_1') => json(202, { message_id: id, status: 'pending' }); + +function sendingClient() { + return client({}, (request) => accepted(`msg_${(request.body as EmailMessage).subject}`)); +} + +describe('emails.send', () => { + it('posts exactly the message with the sending token', async () => { + const { lettermint, requests } = sendingClient(); + const message: EmailMessage = { + from: 'Acme ', + to: ['jane@example.test'], + subject: 'Welcome', + html: '

Hi

', + }; + await expect(lettermint.emails.send(message)).resolves.toEqual({ + message_id: 'msg_Welcome', + status: 'pending', + }); + const [request] = requests; + expect(request.url).toBe('https://api.lettermint.co/v1/send'); + expect(request.method).toBe('POST'); + expect(request.body).toEqual(message); + expect(request.headers['x-lettermint-token']).toBe(SENDING_TOKEN); + expect(request.headers['Content-Type']).toBe('application/json'); + expect(request.headers.Accept).toBe('application/json'); + expect(request.headers['User-Agent']).toMatch(/^lettermint-node\/\d+\.\d+\.\d+/); + expect(request.headers).not.toHaveProperty('Authorization'); + expect(request.headers).not.toHaveProperty('Idempotency-Key'); + expect(request.init.redirect).toBe('manual'); + }); + + it('sends the idempotency key only for the call that sets it', async () => { + const { lettermint, requests } = sendingClient(); + const message = { from: 'a@example.test', to: ['b@example.test'], subject: 'One' }; + await lettermint.emails.send(message, { idempotencyKey: 'order-123' }); + await lettermint.emails.send({ ...message, subject: 'Two' }); + expect(requests[0].headers['Idempotency-Key']).toBe('order-123'); + expect(requests[1].headers).not.toHaveProperty('Idempotency-Key'); + }); + + it.each(['', 'line\nbreak', 42])('rejects the idempotency key %p before sending', async (key) => { + const { lettermint, fetch } = sendingClient(); + const error = await rejection( + lettermint.emails.send( + { from: 'a@example.test', to: ['b@example.test'], subject: 'x' }, + { idempotencyKey: key as string } + ) + ); + expect(error).toBeInstanceOf(LettermintValidationError); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('snapshots the message when called, so later mutations do not leak in', async () => { + let release: () => void = () => {}; + const gate = new Promise((resolve) => { + release = resolve; + }); + const { lettermint, requests } = client({}, async () => { + await gate; + return accepted(); + }); + const message: EmailMessage = { from: 'a@example.test', to: ['b@example.test'], subject: 'x' }; + const pending = lettermint.emails.send(message); + message.to.push('mallory@example.test'); + message.subject = 'changed'; + release(); + await pending; + expect(requests[0].body).toEqual({ + from: 'a@example.test', + to: ['b@example.test'], + subject: 'x', + }); + }); + + it('base64-encodes binary attachment content', async () => { + const { lettermint, requests } = sendingClient(); + await lettermint.emails.send({ + from: 'a@example.test', + to: ['b@example.test'], + subject: 'Files', + attachments: [ + { filename: 'a.txt', content: new TextEncoder().encode('Hello World') }, + { + filename: 'b.bin', + content: new Uint8Array([0, 255, 128]).buffer, + content_type: 'application/octet-stream', + }, + { filename: 'c.txt', content: 'SGk=' }, + ], + }); + expect((requests[0].body as EmailMessage).attachments).toEqual([ + { filename: 'a.txt', content: 'SGVsbG8gV29ybGQ=' }, + { filename: 'b.bin', content: 'AP+A', content_type: 'application/octet-stream' }, + { filename: 'c.txt', content: 'SGk=' }, + ]); + }); + + it('validates tags before any request', async () => { + const { lettermint, fetch } = sendingClient(); + const error = await rejection( + lettermint.emails.send({ + from: 'a@example.test', + to: ['b@example.test'], + subject: 'x', + tags: [{ name: 'not valid!', value: 'x' }], + }) + ); + expect(error).toBeInstanceOf(LettermintValidationError); + expect(error.name).toBe('LettermintValidationError'); + expect((error as LettermintValidationError).field).toBe('tags'); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('rejects a message that is not an object', async () => { + const { lettermint } = sendingClient(); + await expect(lettermint.emails.send(null as never)).rejects.toThrow(LettermintValidationError); + }); +}); + +describe('emails.sendBatch', () => { + it('posts the messages and builders as one batch with its own key', async () => { + const { lettermint, requests } = client({}, () => + json(202, [ + { message_id: 'm1', status: 'pending' }, + { message_id: 'm2', status: 'scheduled', scheduled_at: '2026-10-05T09:00:00Z' }, + ]) + ); + const first = { from: 'a@example.test', to: ['b@example.test'], subject: 'One' }; + const second = lettermint.emails + .compose() + .from('a@example.test') + .to('c@example.test') + .subject('Two') + .scheduledAt(new Date('2026-10-05T09:00:00Z')); + const result = await lettermint.emails.sendBatch([first, second], { + idempotencyKey: 'batch-1', + }); + expect(result[1].status).toBe('scheduled'); + expect(requests[0].url).toBe('https://api.lettermint.co/v1/send/batch'); + expect(requests[0].body).toEqual([ + first, + { + from: 'a@example.test', + to: ['c@example.test'], + subject: 'Two', + scheduled_at: '2026-10-05T09:00:00.000Z', + }, + ]); + expect(requests[0].headers['Idempotency-Key']).toBe('batch-1'); + + await lettermint.emails.sendBatch([first]); + expect(requests[1].headers).not.toHaveProperty('Idempotency-Key'); + }); + + it('names the invalid message', async () => { + const { lettermint, fetch } = sendingClient(); + const error = (await rejection( + lettermint.emails.sendBatch([ + { from: 'a@example.test', to: ['b@example.test'], subject: 'ok' }, + { from: 'a@example.test', to: ['b@example.test'], subject: 'bad', tags: 'x' as never }, + ]) + )) as LettermintValidationError; + expect(error.field).toBe('messages[1].tags'); + expect(fetch).not.toHaveBeenCalled(); + }); +}); + +describe('emails.compose', () => { + it('sets every field', async () => { + const { lettermint, requests } = sendingClient(); + await lettermint.emails + .compose() + .from('John Doe ') + .to('to1@example.test', 'to2@example.test') + .cc('cc@example.test') + .bcc('bcc@example.test') + .replyTo('reply@example.test') + .subject('Everything') + .html('

Hello

') + .text('Hello') + .headers({ 'X-Custom': 'Value' }) + .metadata({ foo: 'bar' }) + .tag('campaign-123') + .tags([{ name: 'campaign', value: 'welcome' }]) + .route('transactional') + .scheduledAt('tomorrow 9am') + .settings({ track_opens: false, track_clicks: true, tls: 'enforced' }) + .sandboxResult('clicked') + .attach({ filename: 'a.txt', content: 'SGk=' }) + .attach({ + filename: 'logo.png', + content: 'iVBO', + contentType: 'image/png', + contentId: 'logo', + }) + .send({ idempotencyKey: 'unique-id-123' }); + expect(requests[0].body).toEqual({ + from: 'John Doe ', + to: ['to1@example.test', 'to2@example.test'], + cc: ['cc@example.test'], + bcc: ['bcc@example.test'], + reply_to: ['reply@example.test'], + subject: 'Everything', + html: '

Hello

', + text: 'Hello', + headers: { 'X-Custom': 'Value' }, + metadata: { foo: 'bar' }, + tag: 'campaign-123', + tags: [{ name: 'campaign', value: 'welcome' }], + route: 'transactional', + scheduled_at: 'tomorrow 9am', + settings: { track_opens: false, track_clicks: true, tls: 'enforced' }, + sandbox_result: 'clicked', + attachments: [ + { filename: 'a.txt', content: 'SGk=' }, + { filename: 'logo.png', content: 'iVBO', content_type: 'image/png', content_id: 'logo' }, + ], + }); + expect(requests[0].headers['Idempotency-Key']).toBe('unique-id-123'); + }); + + it('returns a new builder from every setter', () => { + const { lettermint } = sendingClient(); + const base = lettermint.emails.compose(); + const next = base.from('a@example.test'); + expect(next).not.toBe(base); + expect(base.build().from).toBe(''); + expect(next.build().from).toBe('a@example.test'); + }); + + it('reuses a base builder for two recipients without mixing them', async () => { + const { lettermint, requests } = sendingClient(); + const base = lettermint.emails.compose().from('Acme ').subject('Welcome'); + await base.to('jane@example.test').html('

Jane

').send(); + await base.to('john@example.test').html('

John

').send({ idempotencyKey: 'welcome-john' }); + expect(requests[0].body).toEqual({ + from: 'Acme ', + to: ['jane@example.test'], + subject: 'Welcome', + html: '

Jane

', + }); + expect(requests[0].headers).not.toHaveProperty('Idempotency-Key'); + expect(requests[1].body).toEqual({ + from: 'Acme ', + to: ['john@example.test'], + subject: 'Welcome', + html: '

John

', + }); + expect(requests[1].headers['Idempotency-Key']).toBe('welcome-john'); + expect(base.build()).toEqual({ from: 'Acme ', to: [], subject: 'Welcome' }); + }); + + it('does not accumulate attachments across derived builders', () => { + const { lettermint } = sendingClient(); + const base = lettermint.emails.compose().attach({ filename: 'a.txt', content: 'QQ==' }); + const one = base.attach({ filename: 'b.txt', content: 'Qg==' }); + const two = base.attach({ filename: 'c.txt', content: 'Qw==' }); + expect(base.build().attachments?.map((a) => a.filename)).toEqual(['a.txt']); + expect(one.build().attachments?.map((a) => a.filename)).toEqual(['a.txt', 'b.txt']); + expect(two.build().attachments?.map((a) => a.filename)).toEqual(['a.txt', 'c.txt']); + }); + + it('copies arrays and maps passed to setters', () => { + const { lettermint } = sendingClient(); + const recipients = ['a@example.test']; + const headers: Record = { 'X-A': '1' }; + const tags = [{ name: 'n', value: 'v' }]; + const builder = lettermint.emails + .compose() + .to(...recipients) + .headers(headers) + .tags(tags); + recipients.push('b@example.test'); + headers['X-B'] = '2'; + tags[0].value = 'changed'; + expect(builder.build()).toMatchObject({ + to: ['a@example.test'], + headers: { 'X-A': '1' }, + tags: [{ name: 'n', value: 'v' }], + }); + const built = builder.build(); + built.to.push('c@example.test'); + expect(builder.build().to).toEqual(['a@example.test']); + }); + + it('keeps the base builder unchanged when a setter throws', async () => { + const { lettermint, requests } = sendingClient(); + const base = lettermint.emails + .compose() + .from('a@example.test') + .to('b@example.test') + .subject('Base') + .tags([{ name: 'ok', value: 'yes' }]); + const before = base.build(); + const error = thrown(() => base.tags([{ name: 'not a valid tag name!', value: 'x' }])); + expect(error).toBeInstanceOf(LettermintValidationError); + expect(base.build()).toEqual(before); + await base.send(); + expect(requests[0].body).toEqual(before); + }); + + it('removes html, text, tag and scheduled time with null', () => { + const { lettermint } = sendingClient(); + const builder = lettermint.emails + .compose() + .html('

x

') + .text('x') + .tag('t') + .scheduledAt('tomorrow') + .html(null) + .text(null) + .tag(null) + .scheduledAt(null); + expect(builder.build()).toEqual({ from: '', to: [], subject: '' }); + }); + + it('starts from a message', () => { + const { lettermint } = sendingClient(); + const message = { from: 'a@example.test', to: ['b@example.test'], subject: 'Hi' }; + const builder = lettermint.emails.compose(message); + message.to.push('c@example.test'); + expect(builder.to('d@example.test').build()).toEqual({ + from: 'a@example.test', + to: ['d@example.test'], + subject: 'Hi', + }); + expect(builder.build().to).toEqual(['b@example.test']); + }); + + it('encodes binary attachments without Buffer and copies the bytes', () => { + const { lettermint } = sendingClient(); + const bytes = new TextEncoder().encode('Hello World'); + const builder = lettermint.emails.compose().attach({ filename: 'a.txt', content: bytes }); + bytes[0] = 0; + expect(builder.build().attachments).toEqual([ + { filename: 'a.txt', content: 'SGVsbG8gV29ybGQ=' }, + ]); + const large = new Uint8Array(100_000).map((_, i) => i % 256); + const encoded = lettermint.emails.compose().attach({ filename: 'big', content: large }).build(); + expect(encoded.attachments?.[0].content).toBe(Buffer.from(large).toString('base64')); + }); + + it('rejects the positional v2 attach() signature', () => { + const { lettermint } = sendingClient(); + const error = thrown(() => + (lettermint.emails.compose().attach as (...args: unknown[]) => unknown)('a.txt', 'SGk=') + ); + expect(error).toBeInstanceOf(LettermintValidationError); + expect(error.message).toContain('attach() takes an object'); + }); + + it('serializes and inspects the message without credentials', () => { + const { lettermint } = sendingClient(); + const builder = lettermint.emails.compose().from('a@example.test').subject('Hi'); + expect(JSON.parse(JSON.stringify(builder))).toEqual({ + from: 'a@example.test', + to: [], + subject: 'Hi', + }); + const rendered = inspect(builder, { depth: Number.POSITIVE_INFINITY }); + expect(rendered).toContain('EmailBuilder'); + expect(rendered).toContain('a@example.test'); + expect(rendered).not.toContain(SENDING_TOKEN); + expect(rendered).not.toContain(TEAM_TOKEN); + }); +}); + +describe('message tags', () => { + const maxTags = Array.from({ length: 20 }, (_, i) => ({ name: `tag${i}`, value: 'v' })); + + it.each([ + [ + [ + { name: 'duplicate', value: 'one' }, + { name: 'duplicate', value: 'two' }, + ], + 'unique', + ], + [[{ name: '__LETTERMint_internal', value: 'one' }], '__lettermint'], + [[{ name: 'invalid name', value: 'one' }], 'names must match'], + [[{ name: 'x'.repeat(33), value: 'one' }], 'names must match'], + [[{ name: 'valid', value: 'invalid value' }], 'values must match'], + [[{ name: 'valid', value: 'x'.repeat(65) }], 'values must match'], + [[{ name: 'valid' }], '{ name, value }'], + [[...maxTags, { name: 'one-more', value: 'v' }], 'No more than 20'], + ])('rejects invalid tags %#', (tags, message) => { + const { lettermint } = sendingClient(); + const error = thrown(() => lettermint.emails.compose().tags(tags as never)); + expect(error).toBeInstanceOf(LettermintValidationError); + expect(error.message).toContain(message); + }); + + it('allows 20 tags, or 19 with a legacy tag', () => { + const { lettermint } = sendingClient(); + const builder = lettermint.emails.compose().tags(maxTags); + expect(() => builder.tag('legacy')).toThrow('A legacy tag and no more than 19'); + expect(() => lettermint.emails.compose().tag('legacy').tags(maxTags)).toThrow( + 'A legacy tag and no more than 19' + ); + expect( + lettermint.emails.compose().tag('legacy').tags(maxTags.slice(1)).build().tags + ).toHaveLength(19); + }); + + it('accepts case-sensitive duplicates', () => { + const { lettermint } = sendingClient(); + const tags = [ + { name: 'Campaign', value: 'a' }, + { name: 'campaign', value: 'b' }, + ]; + expect(lettermint.emails.compose().tags(tags).build().tags).toEqual(tags); + }); +}); + +describe('concurrency', () => { + it('keeps two emails composed and sent concurrently on one client separate', async () => { + // The fake API answers the first request last, so both requests are in flight together. + const { lettermint, requests } = client({}, async (request) => { + const subject = (request.body as EmailMessage).subject; + await delay(subject === 'A' ? 30 : 5); + return accepted(`msg_${subject}`); + }); + const compose = async (label: 'A' | 'B', recipients: string[]) => { + let builder = lettermint.emails.compose(); + builder = builder.from(`${label} <${label.toLowerCase()}@example.test>`); + await delay(1); + builder = builder.to(...recipients); + await delay(1); + builder = builder.subject(label).html(`

${label}

`); + await delay(1); + builder = builder.attach({ filename: `${label}.txt`, content: 'SGk=' }); + return builder.send({ idempotencyKey: `key-${label}` }); + }; + const direct = (label: 'C') => + lettermint.emails.send( + { from: 'c@example.test', to: ['c@example.test'], subject: label }, + { idempotencyKey: 'key-C' } + ); + const results = await Promise.all([ + compose('A', ['a1@example.test', 'a2@example.test']), + compose('B', ['b1@example.test']), + direct('C'), + ]); + expect(results.map((result) => result.message_id)).toEqual(['msg_A', 'msg_B', 'msg_C']); + const bySubject = Object.fromEntries( + requests.map((request) => [(request.body as EmailMessage).subject, request]) + ); + expect(bySubject.A.body).toEqual({ + from: 'A ', + to: ['a1@example.test', 'a2@example.test'], + subject: 'A', + html: '

A

', + attachments: [{ filename: 'A.txt', content: 'SGk=' }], + }); + expect(bySubject.B.body).toEqual({ + from: 'B ', + to: ['b1@example.test'], + subject: 'B', + html: '

B

', + attachments: [{ filename: 'B.txt', content: 'SGk=' }], + }); + expect(bySubject.A.headers['Idempotency-Key']).toBe('key-A'); + expect(bySubject.B.headers['Idempotency-Key']).toBe('key-B'); + expect(bySubject.C.headers['Idempotency-Key']).toBe('key-C'); + }); + + it('leaves nothing behind after a failed request', async () => { + const { lettermint, requests } = client({}, (_, index) => + index === 0 ? json(500, { message: 'Server Error' }) : accepted() + ); + await expect( + lettermint.emails + .compose() + .from('a@example.test') + .to('b@example.test') + .cc('cc@example.test') + .subject('A') + .send({ idempotencyKey: 'key-A' }) + ).rejects.toMatchObject({ name: 'ServerError', status: 500 }); + await lettermint.emails + .compose() + .from('c@example.test') + .to('d@example.test') + .subject('C') + .send(); + expect(requests[1].body).toEqual({ + from: 'c@example.test', + to: ['d@example.test'], + subject: 'C', + }); + expect(requests[1].headers).not.toHaveProperty('Idempotency-Key'); + }); +}); diff --git a/test/fixtures/webhooks.json b/test/fixtures/webhooks.json new file mode 100644 index 0000000..caa3fd9 --- /dev/null +++ b/test/fixtures/webhooks.json @@ -0,0 +1,444 @@ +{ + "description": "Lettermint webhook verification vectors. Generated by conformance/tools/make_webhook_vectors.py; do not edit.", + "scheme": { + "signature_header": "X-Lettermint-Signature: t=,v1=.' + raw body)>", + "delivery_header": "X-Lettermint-Delivery: , equal to t. Required.", + "secret": "Used as-is, including the whsec_ prefix.", + "tolerance": "Accept when |now - t| <= tolerance, in both directions.", + "multiple_v1": "Accept when any v1 matches." + }, + "reasons": [ + "valid", + "signature_header_missing", + "signature_header_malformed", + "timestamp_out_of_tolerance", + "delivery_header_missing", + "delivery_timestamp_mismatch", + "signature_mismatch" + ], + "vectors": [ + { + "id": "valid-basic", + "description": "A delivery exactly as the backend sends it.", + "expect": "valid", + "reason": "valid", + "secret": "whsec_conformance0123456789abcdefABCDEF01", + "now": 1767225600, + "tolerance": 300, + "headers": { + "X-Lettermint-Signature": "t=1767225570,v1=14b942853f50d3d313f71135ee85933b89fbd0302b93faefff4be84c61a2ec62", + "X-Lettermint-Delivery": "1767225570", + "X-Lettermint-Event": "message.delivered", + "X-Lettermint-Attempt": "1" + }, + "body_base64": "eyJpZCI6IjAxOTk5YjBlLTZjMWQtN2E4Yi05YzBkLTFlMmYzYTRiNWM2ZCIsImV2ZW50IjoibWVzc2FnZS5kZWxpdmVyZWQiLCJjcmVhdGVkX2F0IjoiMjAyNS0xMi0zMVQyMzo1OTozMC4wMDAwMDBaIiwiZGF0YSI6eyJtZXNzYWdlX2lkIjoiMDE5OWIwZTYtMDAwMC03MDAwLTgwMDAtMDAwMDAwMDAwMDAxIiwic3ViamVjdCI6IldlbGNvbWUgdG8gTGV0dGVybWludCIsInJlY2lwaWVudCI6InJlY2lwaWVudEBleGFtcGxlLmNvbSIsInVybCI6Imh0dHBzOi8vZXhhbXBsZS5jb20vYS9iP2M9ZCJ9LCJjb250ZXh0Ijp7InByb2plY3RfaWQiOiIwMTk5YjBlNi0wMDAwLTcwMDAtODAwMC0wMDAwMDAwMDAwYWEifX0=", + "body_utf8": "{\"id\":\"01999b0e-6c1d-7a8b-9c0d-1e2f3a4b5c6d\",\"event\":\"message.delivered\",\"created_at\":\"2025-12-31T23:59:30.000000Z\",\"data\":{\"message_id\":\"0199b0e6-0000-7000-8000-000000000001\",\"subject\":\"Welcome to Lettermint\",\"recipient\":\"recipient@example.com\",\"url\":\"https://example.com/a/b?c=d\"},\"context\":{\"project_id\":\"0199b0e6-0000-7000-8000-0000000000aa\"}}" + }, + { + "id": "valid-lowercase-header-names", + "description": "Header names arrive lower-cased, as Node.js and most frameworks expose them.", + "expect": "valid", + "reason": "valid", + "secret": "whsec_conformance0123456789abcdefABCDEF01", + "now": 1767225600, + "tolerance": 300, + "headers": { + "x-lettermint-signature": "t=1767225570,v1=14b942853f50d3d313f71135ee85933b89fbd0302b93faefff4be84c61a2ec62", + "x-lettermint-delivery": "1767225570", + "x-lettermint-event": "message.delivered", + "x-lettermint-attempt": "1" + }, + "body_base64": "eyJpZCI6IjAxOTk5YjBlLTZjMWQtN2E4Yi05YzBkLTFlMmYzYTRiNWM2ZCIsImV2ZW50IjoibWVzc2FnZS5kZWxpdmVyZWQiLCJjcmVhdGVkX2F0IjoiMjAyNS0xMi0zMVQyMzo1OTozMC4wMDAwMDBaIiwiZGF0YSI6eyJtZXNzYWdlX2lkIjoiMDE5OWIwZTYtMDAwMC03MDAwLTgwMDAtMDAwMDAwMDAwMDAxIiwic3ViamVjdCI6IldlbGNvbWUgdG8gTGV0dGVybWludCIsInJlY2lwaWVudCI6InJlY2lwaWVudEBleGFtcGxlLmNvbSIsInVybCI6Imh0dHBzOi8vZXhhbXBsZS5jb20vYS9iP2M9ZCJ9LCJjb250ZXh0Ijp7InByb2plY3RfaWQiOiIwMTk5YjBlNi0wMDAwLTcwMDAtODAwMC0wMDAwMDAwMDAwYWEifX0=", + "body_utf8": "{\"id\":\"01999b0e-6c1d-7a8b-9c0d-1e2f3a4b5c6d\",\"event\":\"message.delivered\",\"created_at\":\"2025-12-31T23:59:30.000000Z\",\"data\":{\"message_id\":\"0199b0e6-0000-7000-8000-000000000001\",\"subject\":\"Welcome to Lettermint\",\"recipient\":\"recipient@example.com\",\"url\":\"https://example.com/a/b?c=d\"},\"context\":{\"project_id\":\"0199b0e6-0000-7000-8000-0000000000aa\"}}" + }, + { + "id": "valid-raw-bytes-non-ascii-utf8", + "description": "The body contains multi-byte UTF-8. Verify the raw bytes, not a re-encoded string.", + "expect": "valid", + "reason": "valid", + "secret": "whsec_conformance0123456789abcdefABCDEF01", + "now": 1767225600, + "tolerance": 300, + "headers": { + "X-Lettermint-Signature": "t=1767225570,v1=d86abc23d77d6cc64037a2c7bf81bfc97f4c54f3332e5dcb6deeefc4a770631c", + "X-Lettermint-Delivery": "1767225570", + "X-Lettermint-Event": "message.delivered", + "X-Lettermint-Attempt": "1" + }, + "body_base64": "eyJpZCI6IjAxOTk5YjBlLTZjMWQtN2E4Yi05YzBkLTFlMmYzYTRiNWM2ZCIsImV2ZW50IjoibWVzc2FnZS5kZWxpdmVyZWQiLCJjcmVhdGVkX2F0IjoiMjAyNS0xMi0zMVQyMzo1OTozMC4wMDAwMDBaIiwiZGF0YSI6eyJtZXNzYWdlX2lkIjoiMDE5OWIwZTYtMDAwMC03MDAwLTgwMDAtMDAwMDAwMDAwMDAxIiwic3ViamVjdCI6Ikdyw7zDn2UgYXVzIFrDvHJpY2gg4oCTIOadseS6rCDwn5qAIiwicmVjaXBpZW50IjoicmVjaXBpZW50QGV4YW1wbGUuY29tIiwidXJsIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS9hL2I/Yz1kIn0sImNvbnRleHQiOnsicHJvamVjdF9pZCI6IjAxOTliMGU2LTAwMDAtNzAwMC04MDAwLTAwMDAwMDAwMDBhYSJ9fQ==", + "body_utf8": "{\"id\":\"01999b0e-6c1d-7a8b-9c0d-1e2f3a4b5c6d\",\"event\":\"message.delivered\",\"created_at\":\"2025-12-31T23:59:30.000000Z\",\"data\":{\"message_id\":\"0199b0e6-0000-7000-8000-000000000001\",\"subject\":\"Grüße aus Zürich – 東京 🚀\",\"recipient\":\"recipient@example.com\",\"url\":\"https://example.com/a/b?c=d\"},\"context\":{\"project_id\":\"0199b0e6-0000-7000-8000-0000000000aa\"}}" + }, + { + "id": "valid-key-rotation-second-v1-matches", + "description": "Two v1 values; only the second matches. Any matching v1 is accepted.", + "expect": "valid", + "reason": "valid", + "secret": "whsec_conformance0123456789abcdefABCDEF01", + "now": 1767225600, + "tolerance": 300, + "headers": { + "X-Lettermint-Signature": "t=1767225570,v1=78d40acbde7a1d62e558ccb262f80452400d3121afbd391dbc4a9d3d977781f0,v1=14b942853f50d3d313f71135ee85933b89fbd0302b93faefff4be84c61a2ec62", + "X-Lettermint-Delivery": "1767225570", + "X-Lettermint-Event": "message.delivered", + "X-Lettermint-Attempt": "1" + }, + "body_base64": "eyJpZCI6IjAxOTk5YjBlLTZjMWQtN2E4Yi05YzBkLTFlMmYzYTRiNWM2ZCIsImV2ZW50IjoibWVzc2FnZS5kZWxpdmVyZWQiLCJjcmVhdGVkX2F0IjoiMjAyNS0xMi0zMVQyMzo1OTozMC4wMDAwMDBaIiwiZGF0YSI6eyJtZXNzYWdlX2lkIjoiMDE5OWIwZTYtMDAwMC03MDAwLTgwMDAtMDAwMDAwMDAwMDAxIiwic3ViamVjdCI6IldlbGNvbWUgdG8gTGV0dGVybWludCIsInJlY2lwaWVudCI6InJlY2lwaWVudEBleGFtcGxlLmNvbSIsInVybCI6Imh0dHBzOi8vZXhhbXBsZS5jb20vYS9iP2M9ZCJ9LCJjb250ZXh0Ijp7InByb2plY3RfaWQiOiIwMTk5YjBlNi0wMDAwLTcwMDAtODAwMC0wMDAwMDAwMDAwYWEifX0=", + "body_utf8": "{\"id\":\"01999b0e-6c1d-7a8b-9c0d-1e2f3a4b5c6d\",\"event\":\"message.delivered\",\"created_at\":\"2025-12-31T23:59:30.000000Z\",\"data\":{\"message_id\":\"0199b0e6-0000-7000-8000-000000000001\",\"subject\":\"Welcome to Lettermint\",\"recipient\":\"recipient@example.com\",\"url\":\"https://example.com/a/b?c=d\"},\"context\":{\"project_id\":\"0199b0e6-0000-7000-8000-0000000000aa\"}}" + }, + { + "id": "valid-key-rotation-first-v1-matches", + "description": "Two v1 values; only the first matches. A verifier that checks only the last v1 fails this.", + "expect": "valid", + "reason": "valid", + "secret": "whsec_conformance0123456789abcdefABCDEF01", + "now": 1767225600, + "tolerance": 300, + "headers": { + "X-Lettermint-Signature": "t=1767225570,v1=14b942853f50d3d313f71135ee85933b89fbd0302b93faefff4be84c61a2ec62,v1=78d40acbde7a1d62e558ccb262f80452400d3121afbd391dbc4a9d3d977781f0", + "X-Lettermint-Delivery": "1767225570", + "X-Lettermint-Event": "message.delivered", + "X-Lettermint-Attempt": "1" + }, + "body_base64": "eyJpZCI6IjAxOTk5YjBlLTZjMWQtN2E4Yi05YzBkLTFlMmYzYTRiNWM2ZCIsImV2ZW50IjoibWVzc2FnZS5kZWxpdmVyZWQiLCJjcmVhdGVkX2F0IjoiMjAyNS0xMi0zMVQyMzo1OTozMC4wMDAwMDBaIiwiZGF0YSI6eyJtZXNzYWdlX2lkIjoiMDE5OWIwZTYtMDAwMC03MDAwLTgwMDAtMDAwMDAwMDAwMDAxIiwic3ViamVjdCI6IldlbGNvbWUgdG8gTGV0dGVybWludCIsInJlY2lwaWVudCI6InJlY2lwaWVudEBleGFtcGxlLmNvbSIsInVybCI6Imh0dHBzOi8vZXhhbXBsZS5jb20vYS9iP2M9ZCJ9LCJjb250ZXh0Ijp7InByb2plY3RfaWQiOiIwMTk5YjBlNi0wMDAwLTcwMDAtODAwMC0wMDAwMDAwMDAwYWEifX0=", + "body_utf8": "{\"id\":\"01999b0e-6c1d-7a8b-9c0d-1e2f3a4b5c6d\",\"event\":\"message.delivered\",\"created_at\":\"2025-12-31T23:59:30.000000Z\",\"data\":{\"message_id\":\"0199b0e6-0000-7000-8000-000000000001\",\"subject\":\"Welcome to Lettermint\",\"recipient\":\"recipient@example.com\",\"url\":\"https://example.com/a/b?c=d\"},\"context\":{\"project_id\":\"0199b0e6-0000-7000-8000-0000000000aa\"}}" + }, + { + "id": "valid-unknown-scheme-ignored", + "description": "An unknown scheme next to a matching v1 is ignored.", + "expect": "valid", + "reason": "valid", + "secret": "whsec_conformance0123456789abcdefABCDEF01", + "now": 1767225600, + "tolerance": 300, + "headers": { + "X-Lettermint-Signature": "t=1767225570,v1=14b942853f50d3d313f71135ee85933b89fbd0302b93faefff4be84c61a2ec62,v2=abababababababababababababababababababababababababababababababab", + "X-Lettermint-Delivery": "1767225570", + "X-Lettermint-Event": "message.delivered", + "X-Lettermint-Attempt": "1" + }, + "body_base64": "eyJpZCI6IjAxOTk5YjBlLTZjMWQtN2E4Yi05YzBkLTFlMmYzYTRiNWM2ZCIsImV2ZW50IjoibWVzc2FnZS5kZWxpdmVyZWQiLCJjcmVhdGVkX2F0IjoiMjAyNS0xMi0zMVQyMzo1OTozMC4wMDAwMDBaIiwiZGF0YSI6eyJtZXNzYWdlX2lkIjoiMDE5OWIwZTYtMDAwMC03MDAwLTgwMDAtMDAwMDAwMDAwMDAxIiwic3ViamVjdCI6IldlbGNvbWUgdG8gTGV0dGVybWludCIsInJlY2lwaWVudCI6InJlY2lwaWVudEBleGFtcGxlLmNvbSIsInVybCI6Imh0dHBzOi8vZXhhbXBsZS5jb20vYS9iP2M9ZCJ9LCJjb250ZXh0Ijp7InByb2plY3RfaWQiOiIwMTk5YjBlNi0wMDAwLTcwMDAtODAwMC0wMDAwMDAwMDAwYWEifX0=", + "body_utf8": "{\"id\":\"01999b0e-6c1d-7a8b-9c0d-1e2f3a4b5c6d\",\"event\":\"message.delivered\",\"created_at\":\"2025-12-31T23:59:30.000000Z\",\"data\":{\"message_id\":\"0199b0e6-0000-7000-8000-000000000001\",\"subject\":\"Welcome to Lettermint\",\"recipient\":\"recipient@example.com\",\"url\":\"https://example.com/a/b?c=d\"},\"context\":{\"project_id\":\"0199b0e6-0000-7000-8000-0000000000aa\"}}" + }, + { + "id": "valid-timestamp-past-within-tolerance", + "description": "Signed 120 seconds before now.", + "expect": "valid", + "reason": "valid", + "secret": "whsec_conformance0123456789abcdefABCDEF01", + "now": 1767225600, + "tolerance": 300, + "headers": { + "X-Lettermint-Signature": "t=1767225480,v1=d9d5742f8e6ed427651981a955b4898953b06251d4d54d95822f82c31feef2f5", + "X-Lettermint-Delivery": "1767225480", + "X-Lettermint-Event": "message.delivered", + "X-Lettermint-Attempt": "1" + }, + "body_base64": "eyJpZCI6IjAxOTk5YjBlLTZjMWQtN2E4Yi05YzBkLTFlMmYzYTRiNWM2ZCIsImV2ZW50IjoibWVzc2FnZS5kZWxpdmVyZWQiLCJjcmVhdGVkX2F0IjoiMjAyNS0xMi0zMVQyMzo1OTozMC4wMDAwMDBaIiwiZGF0YSI6eyJtZXNzYWdlX2lkIjoiMDE5OWIwZTYtMDAwMC03MDAwLTgwMDAtMDAwMDAwMDAwMDAxIiwic3ViamVjdCI6IldlbGNvbWUgdG8gTGV0dGVybWludCIsInJlY2lwaWVudCI6InJlY2lwaWVudEBleGFtcGxlLmNvbSIsInVybCI6Imh0dHBzOi8vZXhhbXBsZS5jb20vYS9iP2M9ZCJ9LCJjb250ZXh0Ijp7InByb2plY3RfaWQiOiIwMTk5YjBlNi0wMDAwLTcwMDAtODAwMC0wMDAwMDAwMDAwYWEifX0=", + "body_utf8": "{\"id\":\"01999b0e-6c1d-7a8b-9c0d-1e2f3a4b5c6d\",\"event\":\"message.delivered\",\"created_at\":\"2025-12-31T23:59:30.000000Z\",\"data\":{\"message_id\":\"0199b0e6-0000-7000-8000-000000000001\",\"subject\":\"Welcome to Lettermint\",\"recipient\":\"recipient@example.com\",\"url\":\"https://example.com/a/b?c=d\"},\"context\":{\"project_id\":\"0199b0e6-0000-7000-8000-0000000000aa\"}}" + }, + { + "id": "valid-timestamp-future-within-tolerance", + "description": "Signed 120 seconds after now (receiver clock behind).", + "expect": "valid", + "reason": "valid", + "secret": "whsec_conformance0123456789abcdefABCDEF01", + "now": 1767225600, + "tolerance": 300, + "headers": { + "X-Lettermint-Signature": "t=1767225720,v1=d2031a10b1b71d08b0557f4cfda07dc06489df741689661b7cb291a8cf7bc4bd", + "X-Lettermint-Delivery": "1767225720", + "X-Lettermint-Event": "message.delivered", + "X-Lettermint-Attempt": "1" + }, + "body_base64": "eyJpZCI6IjAxOTk5YjBlLTZjMWQtN2E4Yi05YzBkLTFlMmYzYTRiNWM2ZCIsImV2ZW50IjoibWVzc2FnZS5kZWxpdmVyZWQiLCJjcmVhdGVkX2F0IjoiMjAyNS0xMi0zMVQyMzo1OTozMC4wMDAwMDBaIiwiZGF0YSI6eyJtZXNzYWdlX2lkIjoiMDE5OWIwZTYtMDAwMC03MDAwLTgwMDAtMDAwMDAwMDAwMDAxIiwic3ViamVjdCI6IldlbGNvbWUgdG8gTGV0dGVybWludCIsInJlY2lwaWVudCI6InJlY2lwaWVudEBleGFtcGxlLmNvbSIsInVybCI6Imh0dHBzOi8vZXhhbXBsZS5jb20vYS9iP2M9ZCJ9LCJjb250ZXh0Ijp7InByb2plY3RfaWQiOiIwMTk5YjBlNi0wMDAwLTcwMDAtODAwMC0wMDAwMDAwMDAwYWEifX0=", + "body_utf8": "{\"id\":\"01999b0e-6c1d-7a8b-9c0d-1e2f3a4b5c6d\",\"event\":\"message.delivered\",\"created_at\":\"2025-12-31T23:59:30.000000Z\",\"data\":{\"message_id\":\"0199b0e6-0000-7000-8000-000000000001\",\"subject\":\"Welcome to Lettermint\",\"recipient\":\"recipient@example.com\",\"url\":\"https://example.com/a/b?c=d\"},\"context\":{\"project_id\":\"0199b0e6-0000-7000-8000-0000000000aa\"}}" + }, + { + "id": "valid-timestamp-past-at-boundary", + "description": "Signed exactly `tolerance` seconds before now; the boundary is accepted.", + "expect": "valid", + "reason": "valid", + "secret": "whsec_conformance0123456789abcdefABCDEF01", + "now": 1767225600, + "tolerance": 300, + "headers": { + "X-Lettermint-Signature": "t=1767225300,v1=29436298f2468c1fcc397ff6f23553eac2dce5a71d5c1195d2fe5adca7d5a261", + "X-Lettermint-Delivery": "1767225300", + "X-Lettermint-Event": "message.delivered", + "X-Lettermint-Attempt": "1" + }, + "body_base64": "eyJpZCI6IjAxOTk5YjBlLTZjMWQtN2E4Yi05YzBkLTFlMmYzYTRiNWM2ZCIsImV2ZW50IjoibWVzc2FnZS5kZWxpdmVyZWQiLCJjcmVhdGVkX2F0IjoiMjAyNS0xMi0zMVQyMzo1OTozMC4wMDAwMDBaIiwiZGF0YSI6eyJtZXNzYWdlX2lkIjoiMDE5OWIwZTYtMDAwMC03MDAwLTgwMDAtMDAwMDAwMDAwMDAxIiwic3ViamVjdCI6IldlbGNvbWUgdG8gTGV0dGVybWludCIsInJlY2lwaWVudCI6InJlY2lwaWVudEBleGFtcGxlLmNvbSIsInVybCI6Imh0dHBzOi8vZXhhbXBsZS5jb20vYS9iP2M9ZCJ9LCJjb250ZXh0Ijp7InByb2plY3RfaWQiOiIwMTk5YjBlNi0wMDAwLTcwMDAtODAwMC0wMDAwMDAwMDAwYWEifX0=", + "body_utf8": "{\"id\":\"01999b0e-6c1d-7a8b-9c0d-1e2f3a4b5c6d\",\"event\":\"message.delivered\",\"created_at\":\"2025-12-31T23:59:30.000000Z\",\"data\":{\"message_id\":\"0199b0e6-0000-7000-8000-000000000001\",\"subject\":\"Welcome to Lettermint\",\"recipient\":\"recipient@example.com\",\"url\":\"https://example.com/a/b?c=d\"},\"context\":{\"project_id\":\"0199b0e6-0000-7000-8000-0000000000aa\"}}" + }, + { + "id": "valid-timestamp-future-at-boundary", + "description": "Signed exactly `tolerance` seconds after now; the boundary is accepted.", + "expect": "valid", + "reason": "valid", + "secret": "whsec_conformance0123456789abcdefABCDEF01", + "now": 1767225600, + "tolerance": 300, + "headers": { + "X-Lettermint-Signature": "t=1767225900,v1=d6f11c156c62816ecd4cc5d39e793b35389253ecf103b9a76b698cd2f922b115", + "X-Lettermint-Delivery": "1767225900", + "X-Lettermint-Event": "message.delivered", + "X-Lettermint-Attempt": "1" + }, + "body_base64": "eyJpZCI6IjAxOTk5YjBlLTZjMWQtN2E4Yi05YzBkLTFlMmYzYTRiNWM2ZCIsImV2ZW50IjoibWVzc2FnZS5kZWxpdmVyZWQiLCJjcmVhdGVkX2F0IjoiMjAyNS0xMi0zMVQyMzo1OTozMC4wMDAwMDBaIiwiZGF0YSI6eyJtZXNzYWdlX2lkIjoiMDE5OWIwZTYtMDAwMC03MDAwLTgwMDAtMDAwMDAwMDAwMDAxIiwic3ViamVjdCI6IldlbGNvbWUgdG8gTGV0dGVybWludCIsInJlY2lwaWVudCI6InJlY2lwaWVudEBleGFtcGxlLmNvbSIsInVybCI6Imh0dHBzOi8vZXhhbXBsZS5jb20vYS9iP2M9ZCJ9LCJjb250ZXh0Ijp7InByb2plY3RfaWQiOiIwMTk5YjBlNi0wMDAwLTcwMDAtODAwMC0wMDAwMDAwMDAwYWEifX0=", + "body_utf8": "{\"id\":\"01999b0e-6c1d-7a8b-9c0d-1e2f3a4b5c6d\",\"event\":\"message.delivered\",\"created_at\":\"2025-12-31T23:59:30.000000Z\",\"data\":{\"message_id\":\"0199b0e6-0000-7000-8000-000000000001\",\"subject\":\"Welcome to Lettermint\",\"recipient\":\"recipient@example.com\",\"url\":\"https://example.com/a/b?c=d\"},\"context\":{\"project_id\":\"0199b0e6-0000-7000-8000-0000000000aa\"}}" + }, + { + "id": "invalid-timestamp-past-beyond-tolerance", + "description": "Signed tolerance + 1 seconds before now (replay).", + "expect": "invalid", + "reason": "timestamp_out_of_tolerance", + "secret": "whsec_conformance0123456789abcdefABCDEF01", + "now": 1767225600, + "tolerance": 300, + "headers": { + "X-Lettermint-Signature": "t=1767225299,v1=a8a0c4956ac712924ca805ceed443718b0c38e98a2bbe1c85ed9e31e1d26b26d", + "X-Lettermint-Delivery": "1767225299", + "X-Lettermint-Event": "message.delivered", + "X-Lettermint-Attempt": "1" + }, + "body_base64": "eyJpZCI6IjAxOTk5YjBlLTZjMWQtN2E4Yi05YzBkLTFlMmYzYTRiNWM2ZCIsImV2ZW50IjoibWVzc2FnZS5kZWxpdmVyZWQiLCJjcmVhdGVkX2F0IjoiMjAyNS0xMi0zMVQyMzo1OTozMC4wMDAwMDBaIiwiZGF0YSI6eyJtZXNzYWdlX2lkIjoiMDE5OWIwZTYtMDAwMC03MDAwLTgwMDAtMDAwMDAwMDAwMDAxIiwic3ViamVjdCI6IldlbGNvbWUgdG8gTGV0dGVybWludCIsInJlY2lwaWVudCI6InJlY2lwaWVudEBleGFtcGxlLmNvbSIsInVybCI6Imh0dHBzOi8vZXhhbXBsZS5jb20vYS9iP2M9ZCJ9LCJjb250ZXh0Ijp7InByb2plY3RfaWQiOiIwMTk5YjBlNi0wMDAwLTcwMDAtODAwMC0wMDAwMDAwMDAwYWEifX0=", + "body_utf8": "{\"id\":\"01999b0e-6c1d-7a8b-9c0d-1e2f3a4b5c6d\",\"event\":\"message.delivered\",\"created_at\":\"2025-12-31T23:59:30.000000Z\",\"data\":{\"message_id\":\"0199b0e6-0000-7000-8000-000000000001\",\"subject\":\"Welcome to Lettermint\",\"recipient\":\"recipient@example.com\",\"url\":\"https://example.com/a/b?c=d\"},\"context\":{\"project_id\":\"0199b0e6-0000-7000-8000-0000000000aa\"}}" + }, + { + "id": "invalid-timestamp-future-beyond-tolerance", + "description": "Signed tolerance + 1 seconds after now.", + "expect": "invalid", + "reason": "timestamp_out_of_tolerance", + "secret": "whsec_conformance0123456789abcdefABCDEF01", + "now": 1767225600, + "tolerance": 300, + "headers": { + "X-Lettermint-Signature": "t=1767225901,v1=23ab3a289112633d24dbd423b2eb5d523608057130cebb6e07abbeb98767a2b2", + "X-Lettermint-Delivery": "1767225901", + "X-Lettermint-Event": "message.delivered", + "X-Lettermint-Attempt": "1" + }, + "body_base64": "eyJpZCI6IjAxOTk5YjBlLTZjMWQtN2E4Yi05YzBkLTFlMmYzYTRiNWM2ZCIsImV2ZW50IjoibWVzc2FnZS5kZWxpdmVyZWQiLCJjcmVhdGVkX2F0IjoiMjAyNS0xMi0zMVQyMzo1OTozMC4wMDAwMDBaIiwiZGF0YSI6eyJtZXNzYWdlX2lkIjoiMDE5OWIwZTYtMDAwMC03MDAwLTgwMDAtMDAwMDAwMDAwMDAxIiwic3ViamVjdCI6IldlbGNvbWUgdG8gTGV0dGVybWludCIsInJlY2lwaWVudCI6InJlY2lwaWVudEBleGFtcGxlLmNvbSIsInVybCI6Imh0dHBzOi8vZXhhbXBsZS5jb20vYS9iP2M9ZCJ9LCJjb250ZXh0Ijp7InByb2plY3RfaWQiOiIwMTk5YjBlNi0wMDAwLTcwMDAtODAwMC0wMDAwMDAwMDAwYWEifX0=", + "body_utf8": "{\"id\":\"01999b0e-6c1d-7a8b-9c0d-1e2f3a4b5c6d\",\"event\":\"message.delivered\",\"created_at\":\"2025-12-31T23:59:30.000000Z\",\"data\":{\"message_id\":\"0199b0e6-0000-7000-8000-000000000001\",\"subject\":\"Welcome to Lettermint\",\"recipient\":\"recipient@example.com\",\"url\":\"https://example.com/a/b?c=d\"},\"context\":{\"project_id\":\"0199b0e6-0000-7000-8000-0000000000aa\"}}" + }, + { + "id": "invalid-body-tampered", + "description": "One character of the body changed after signing.", + "expect": "invalid", + "reason": "signature_mismatch", + "secret": "whsec_conformance0123456789abcdefABCDEF01", + "now": 1767225600, + "tolerance": 300, + "headers": { + "X-Lettermint-Signature": "t=1767225570,v1=14b942853f50d3d313f71135ee85933b89fbd0302b93faefff4be84c61a2ec62", + "X-Lettermint-Delivery": "1767225570", + "X-Lettermint-Event": "message.delivered", + "X-Lettermint-Attempt": "1" + }, + "body_base64": "eyJpZCI6IjAxOTk5YjBlLTZjMWQtN2E4Yi05YzBkLTFlMmYzYTRiNWM2ZCIsImV2ZW50IjoibWVzc2FnZS5kZWxpdmVyZWQiLCJjcmVhdGVkX2F0IjoiMjAyNS0xMi0zMVQyMzo1OTozMC4wMDAwMDBaIiwiZGF0YSI6eyJtZXNzYWdlX2lkIjoiMDE5OWIwZTYtMDAwMC03MDAwLTgwMDAtMDAwMDAwMDAwMDAxIiwic3ViamVjdCI6IldlbGNvbWUgdG8gTGV0dGVybXludCIsInJlY2lwaWVudCI6InJlY2lwaWVudEBleGFtcGxlLmNvbSIsInVybCI6Imh0dHBzOi8vZXhhbXBsZS5jb20vYS9iP2M9ZCJ9LCJjb250ZXh0Ijp7InByb2plY3RfaWQiOiIwMTk5YjBlNi0wMDAwLTcwMDAtODAwMC0wMDAwMDAwMDAwYWEifX0=", + "body_utf8": "{\"id\":\"01999b0e-6c1d-7a8b-9c0d-1e2f3a4b5c6d\",\"event\":\"message.delivered\",\"created_at\":\"2025-12-31T23:59:30.000000Z\",\"data\":{\"message_id\":\"0199b0e6-0000-7000-8000-000000000001\",\"subject\":\"Welcome to Lettermynt\",\"recipient\":\"recipient@example.com\",\"url\":\"https://example.com/a/b?c=d\"},\"context\":{\"project_id\":\"0199b0e6-0000-7000-8000-0000000000aa\"}}" + }, + { + "id": "invalid-body-reserialized", + "description": "The body was re-serialised with escaped slashes; the signature covers the original bytes.", + "expect": "invalid", + "reason": "signature_mismatch", + "secret": "whsec_conformance0123456789abcdefABCDEF01", + "now": 1767225600, + "tolerance": 300, + "headers": { + "X-Lettermint-Signature": "t=1767225570,v1=14b942853f50d3d313f71135ee85933b89fbd0302b93faefff4be84c61a2ec62", + "X-Lettermint-Delivery": "1767225570", + "X-Lettermint-Event": "message.delivered", + "X-Lettermint-Attempt": "1" + }, + "body_base64": "eyJpZCI6IjAxOTk5YjBlLTZjMWQtN2E4Yi05YzBkLTFlMmYzYTRiNWM2ZCIsImV2ZW50IjoibWVzc2FnZS5kZWxpdmVyZWQiLCJjcmVhdGVkX2F0IjoiMjAyNS0xMi0zMVQyMzo1OTozMC4wMDAwMDBaIiwiZGF0YSI6eyJtZXNzYWdlX2lkIjoiMDE5OWIwZTYtMDAwMC03MDAwLTgwMDAtMDAwMDAwMDAwMDAxIiwic3ViamVjdCI6IldlbGNvbWUgdG8gTGV0dGVybWludCIsInJlY2lwaWVudCI6InJlY2lwaWVudEBleGFtcGxlLmNvbSIsInVybCI6Imh0dHBzOlwvXC9leGFtcGxlLmNvbVwvYVwvYj9jPWQifSwiY29udGV4dCI6eyJwcm9qZWN0X2lkIjoiMDE5OWIwZTYtMDAwMC03MDAwLTgwMDAtMDAwMDAwMDAwMGFhIn19", + "body_utf8": "{\"id\":\"01999b0e-6c1d-7a8b-9c0d-1e2f3a4b5c6d\",\"event\":\"message.delivered\",\"created_at\":\"2025-12-31T23:59:30.000000Z\",\"data\":{\"message_id\":\"0199b0e6-0000-7000-8000-000000000001\",\"subject\":\"Welcome to Lettermint\",\"recipient\":\"recipient@example.com\",\"url\":\"https:\\/\\/example.com\\/a\\/b?c=d\"},\"context\":{\"project_id\":\"0199b0e6-0000-7000-8000-0000000000aa\"}}" + }, + { + "id": "invalid-wrong-secret", + "description": "Signed with a different secret.", + "expect": "invalid", + "reason": "signature_mismatch", + "secret": "whsec_conformance0123456789abcdefABCDEF01", + "now": 1767225600, + "tolerance": 300, + "headers": { + "X-Lettermint-Signature": "t=1767225570,v1=ed59387c3cd9af2e957f07606ed6416da05fd1cf35696dda47586292fe090546", + "X-Lettermint-Delivery": "1767225570", + "X-Lettermint-Event": "message.delivered", + "X-Lettermint-Attempt": "1" + }, + "body_base64": "eyJpZCI6IjAxOTk5YjBlLTZjMWQtN2E4Yi05YzBkLTFlMmYzYTRiNWM2ZCIsImV2ZW50IjoibWVzc2FnZS5kZWxpdmVyZWQiLCJjcmVhdGVkX2F0IjoiMjAyNS0xMi0zMVQyMzo1OTozMC4wMDAwMDBaIiwiZGF0YSI6eyJtZXNzYWdlX2lkIjoiMDE5OWIwZTYtMDAwMC03MDAwLTgwMDAtMDAwMDAwMDAwMDAxIiwic3ViamVjdCI6IldlbGNvbWUgdG8gTGV0dGVybWludCIsInJlY2lwaWVudCI6InJlY2lwaWVudEBleGFtcGxlLmNvbSIsInVybCI6Imh0dHBzOi8vZXhhbXBsZS5jb20vYS9iP2M9ZCJ9LCJjb250ZXh0Ijp7InByb2plY3RfaWQiOiIwMTk5YjBlNi0wMDAwLTcwMDAtODAwMC0wMDAwMDAwMDAwYWEifX0=", + "body_utf8": "{\"id\":\"01999b0e-6c1d-7a8b-9c0d-1e2f3a4b5c6d\",\"event\":\"message.delivered\",\"created_at\":\"2025-12-31T23:59:30.000000Z\",\"data\":{\"message_id\":\"0199b0e6-0000-7000-8000-000000000001\",\"subject\":\"Welcome to Lettermint\",\"recipient\":\"recipient@example.com\",\"url\":\"https://example.com/a/b?c=d\"},\"context\":{\"project_id\":\"0199b0e6-0000-7000-8000-0000000000aa\"}}" + }, + { + "id": "invalid-secret-prefix-stripped", + "description": "Signed with the secret minus its whsec_ prefix. The secret is used as-is.", + "expect": "invalid", + "reason": "signature_mismatch", + "secret": "whsec_conformance0123456789abcdefABCDEF01", + "now": 1767225600, + "tolerance": 300, + "headers": { + "X-Lettermint-Signature": "t=1767225570,v1=a29a6b254dcc0ba4662871e6c088380185375e1b552a94d36339f678baa59ed7", + "X-Lettermint-Delivery": "1767225570", + "X-Lettermint-Event": "message.delivered", + "X-Lettermint-Attempt": "1" + }, + "body_base64": "eyJpZCI6IjAxOTk5YjBlLTZjMWQtN2E4Yi05YzBkLTFlMmYzYTRiNWM2ZCIsImV2ZW50IjoibWVzc2FnZS5kZWxpdmVyZWQiLCJjcmVhdGVkX2F0IjoiMjAyNS0xMi0zMVQyMzo1OTozMC4wMDAwMDBaIiwiZGF0YSI6eyJtZXNzYWdlX2lkIjoiMDE5OWIwZTYtMDAwMC03MDAwLTgwMDAtMDAwMDAwMDAwMDAxIiwic3ViamVjdCI6IldlbGNvbWUgdG8gTGV0dGVybWludCIsInJlY2lwaWVudCI6InJlY2lwaWVudEBleGFtcGxlLmNvbSIsInVybCI6Imh0dHBzOi8vZXhhbXBsZS5jb20vYS9iP2M9ZCJ9LCJjb250ZXh0Ijp7InByb2plY3RfaWQiOiIwMTk5YjBlNi0wMDAwLTcwMDAtODAwMC0wMDAwMDAwMDAwYWEifX0=", + "body_utf8": "{\"id\":\"01999b0e-6c1d-7a8b-9c0d-1e2f3a4b5c6d\",\"event\":\"message.delivered\",\"created_at\":\"2025-12-31T23:59:30.000000Z\",\"data\":{\"message_id\":\"0199b0e6-0000-7000-8000-000000000001\",\"subject\":\"Welcome to Lettermint\",\"recipient\":\"recipient@example.com\",\"url\":\"https://example.com/a/b?c=d\"},\"context\":{\"project_id\":\"0199b0e6-0000-7000-8000-0000000000aa\"}}" + }, + { + "id": "invalid-key-rotation-no-v1-matches", + "description": "Two v1 values and neither matches.", + "expect": "invalid", + "reason": "signature_mismatch", + "secret": "whsec_conformance0123456789abcdefABCDEF01", + "now": 1767225600, + "tolerance": 300, + "headers": { + "X-Lettermint-Signature": "t=1767225570,v1=78d40acbde7a1d62e558ccb262f80452400d3121afbd391dbc4a9d3d977781f0,v1=0000000000000000000000000000000000000000000000000000000000000000", + "X-Lettermint-Delivery": "1767225570", + "X-Lettermint-Event": "message.delivered", + "X-Lettermint-Attempt": "1" + }, + "body_base64": "eyJpZCI6IjAxOTk5YjBlLTZjMWQtN2E4Yi05YzBkLTFlMmYzYTRiNWM2ZCIsImV2ZW50IjoibWVzc2FnZS5kZWxpdmVyZWQiLCJjcmVhdGVkX2F0IjoiMjAyNS0xMi0zMVQyMzo1OTozMC4wMDAwMDBaIiwiZGF0YSI6eyJtZXNzYWdlX2lkIjoiMDE5OWIwZTYtMDAwMC03MDAwLTgwMDAtMDAwMDAwMDAwMDAxIiwic3ViamVjdCI6IldlbGNvbWUgdG8gTGV0dGVybWludCIsInJlY2lwaWVudCI6InJlY2lwaWVudEBleGFtcGxlLmNvbSIsInVybCI6Imh0dHBzOi8vZXhhbXBsZS5jb20vYS9iP2M9ZCJ9LCJjb250ZXh0Ijp7InByb2plY3RfaWQiOiIwMTk5YjBlNi0wMDAwLTcwMDAtODAwMC0wMDAwMDAwMDAwYWEifX0=", + "body_utf8": "{\"id\":\"01999b0e-6c1d-7a8b-9c0d-1e2f3a4b5c6d\",\"event\":\"message.delivered\",\"created_at\":\"2025-12-31T23:59:30.000000Z\",\"data\":{\"message_id\":\"0199b0e6-0000-7000-8000-000000000001\",\"subject\":\"Welcome to Lettermint\",\"recipient\":\"recipient@example.com\",\"url\":\"https://example.com/a/b?c=d\"},\"context\":{\"project_id\":\"0199b0e6-0000-7000-8000-0000000000aa\"}}" + }, + { + "id": "invalid-signature-header-missing", + "description": "X-Lettermint-Signature is absent.", + "expect": "invalid", + "reason": "signature_header_missing", + "secret": "whsec_conformance0123456789abcdefABCDEF01", + "now": 1767225600, + "tolerance": 300, + "headers": { + "X-Lettermint-Delivery": "1767225570", + "X-Lettermint-Event": "message.delivered", + "X-Lettermint-Attempt": "1" + }, + "body_base64": "eyJpZCI6IjAxOTk5YjBlLTZjMWQtN2E4Yi05YzBkLTFlMmYzYTRiNWM2ZCIsImV2ZW50IjoibWVzc2FnZS5kZWxpdmVyZWQiLCJjcmVhdGVkX2F0IjoiMjAyNS0xMi0zMVQyMzo1OTozMC4wMDAwMDBaIiwiZGF0YSI6eyJtZXNzYWdlX2lkIjoiMDE5OWIwZTYtMDAwMC03MDAwLTgwMDAtMDAwMDAwMDAwMDAxIiwic3ViamVjdCI6IldlbGNvbWUgdG8gTGV0dGVybWludCIsInJlY2lwaWVudCI6InJlY2lwaWVudEBleGFtcGxlLmNvbSIsInVybCI6Imh0dHBzOi8vZXhhbXBsZS5jb20vYS9iP2M9ZCJ9LCJjb250ZXh0Ijp7InByb2plY3RfaWQiOiIwMTk5YjBlNi0wMDAwLTcwMDAtODAwMC0wMDAwMDAwMDAwYWEifX0=", + "body_utf8": "{\"id\":\"01999b0e-6c1d-7a8b-9c0d-1e2f3a4b5c6d\",\"event\":\"message.delivered\",\"created_at\":\"2025-12-31T23:59:30.000000Z\",\"data\":{\"message_id\":\"0199b0e6-0000-7000-8000-000000000001\",\"subject\":\"Welcome to Lettermint\",\"recipient\":\"recipient@example.com\",\"url\":\"https://example.com/a/b?c=d\"},\"context\":{\"project_id\":\"0199b0e6-0000-7000-8000-0000000000aa\"}}" + }, + { + "id": "invalid-signature-missing-timestamp", + "description": "The signature header has v1 but no t.", + "expect": "invalid", + "reason": "signature_header_malformed", + "secret": "whsec_conformance0123456789abcdefABCDEF01", + "now": 1767225600, + "tolerance": 300, + "headers": { + "X-Lettermint-Signature": "v1=14b942853f50d3d313f71135ee85933b89fbd0302b93faefff4be84c61a2ec62", + "X-Lettermint-Delivery": "1767225570", + "X-Lettermint-Event": "message.delivered", + "X-Lettermint-Attempt": "1" + }, + "body_base64": "eyJpZCI6IjAxOTk5YjBlLTZjMWQtN2E4Yi05YzBkLTFlMmYzYTRiNWM2ZCIsImV2ZW50IjoibWVzc2FnZS5kZWxpdmVyZWQiLCJjcmVhdGVkX2F0IjoiMjAyNS0xMi0zMVQyMzo1OTozMC4wMDAwMDBaIiwiZGF0YSI6eyJtZXNzYWdlX2lkIjoiMDE5OWIwZTYtMDAwMC03MDAwLTgwMDAtMDAwMDAwMDAwMDAxIiwic3ViamVjdCI6IldlbGNvbWUgdG8gTGV0dGVybWludCIsInJlY2lwaWVudCI6InJlY2lwaWVudEBleGFtcGxlLmNvbSIsInVybCI6Imh0dHBzOi8vZXhhbXBsZS5jb20vYS9iP2M9ZCJ9LCJjb250ZXh0Ijp7InByb2plY3RfaWQiOiIwMTk5YjBlNi0wMDAwLTcwMDAtODAwMC0wMDAwMDAwMDAwYWEifX0=", + "body_utf8": "{\"id\":\"01999b0e-6c1d-7a8b-9c0d-1e2f3a4b5c6d\",\"event\":\"message.delivered\",\"created_at\":\"2025-12-31T23:59:30.000000Z\",\"data\":{\"message_id\":\"0199b0e6-0000-7000-8000-000000000001\",\"subject\":\"Welcome to Lettermint\",\"recipient\":\"recipient@example.com\",\"url\":\"https://example.com/a/b?c=d\"},\"context\":{\"project_id\":\"0199b0e6-0000-7000-8000-0000000000aa\"}}" + }, + { + "id": "invalid-signature-missing-v1", + "description": "The signature header has t but no v1.", + "expect": "invalid", + "reason": "signature_header_malformed", + "secret": "whsec_conformance0123456789abcdefABCDEF01", + "now": 1767225600, + "tolerance": 300, + "headers": { + "X-Lettermint-Signature": "t=1767225570", + "X-Lettermint-Delivery": "1767225570", + "X-Lettermint-Event": "message.delivered", + "X-Lettermint-Attempt": "1" + }, + "body_base64": "eyJpZCI6IjAxOTk5YjBlLTZjMWQtN2E4Yi05YzBkLTFlMmYzYTRiNWM2ZCIsImV2ZW50IjoibWVzc2FnZS5kZWxpdmVyZWQiLCJjcmVhdGVkX2F0IjoiMjAyNS0xMi0zMVQyMzo1OTozMC4wMDAwMDBaIiwiZGF0YSI6eyJtZXNzYWdlX2lkIjoiMDE5OWIwZTYtMDAwMC03MDAwLTgwMDAtMDAwMDAwMDAwMDAxIiwic3ViamVjdCI6IldlbGNvbWUgdG8gTGV0dGVybWludCIsInJlY2lwaWVudCI6InJlY2lwaWVudEBleGFtcGxlLmNvbSIsInVybCI6Imh0dHBzOi8vZXhhbXBsZS5jb20vYS9iP2M9ZCJ9LCJjb250ZXh0Ijp7InByb2plY3RfaWQiOiIwMTk5YjBlNi0wMDAwLTcwMDAtODAwMC0wMDAwMDAwMDAwYWEifX0=", + "body_utf8": "{\"id\":\"01999b0e-6c1d-7a8b-9c0d-1e2f3a4b5c6d\",\"event\":\"message.delivered\",\"created_at\":\"2025-12-31T23:59:30.000000Z\",\"data\":{\"message_id\":\"0199b0e6-0000-7000-8000-000000000001\",\"subject\":\"Welcome to Lettermint\",\"recipient\":\"recipient@example.com\",\"url\":\"https://example.com/a/b?c=d\"},\"context\":{\"project_id\":\"0199b0e6-0000-7000-8000-0000000000aa\"}}" + }, + { + "id": "invalid-signature-timestamp-not-numeric", + "description": "t is not a number.", + "expect": "invalid", + "reason": "signature_header_malformed", + "secret": "whsec_conformance0123456789abcdefABCDEF01", + "now": 1767225600, + "tolerance": 300, + "headers": { + "X-Lettermint-Signature": "t=abc,v1=14b942853f50d3d313f71135ee85933b89fbd0302b93faefff4be84c61a2ec62", + "X-Lettermint-Delivery": "1767225570", + "X-Lettermint-Event": "message.delivered", + "X-Lettermint-Attempt": "1" + }, + "body_base64": "eyJpZCI6IjAxOTk5YjBlLTZjMWQtN2E4Yi05YzBkLTFlMmYzYTRiNWM2ZCIsImV2ZW50IjoibWVzc2FnZS5kZWxpdmVyZWQiLCJjcmVhdGVkX2F0IjoiMjAyNS0xMi0zMVQyMzo1OTozMC4wMDAwMDBaIiwiZGF0YSI6eyJtZXNzYWdlX2lkIjoiMDE5OWIwZTYtMDAwMC03MDAwLTgwMDAtMDAwMDAwMDAwMDAxIiwic3ViamVjdCI6IldlbGNvbWUgdG8gTGV0dGVybWludCIsInJlY2lwaWVudCI6InJlY2lwaWVudEBleGFtcGxlLmNvbSIsInVybCI6Imh0dHBzOi8vZXhhbXBsZS5jb20vYS9iP2M9ZCJ9LCJjb250ZXh0Ijp7InByb2plY3RfaWQiOiIwMTk5YjBlNi0wMDAwLTcwMDAtODAwMC0wMDAwMDAwMDAwYWEifX0=", + "body_utf8": "{\"id\":\"01999b0e-6c1d-7a8b-9c0d-1e2f3a4b5c6d\",\"event\":\"message.delivered\",\"created_at\":\"2025-12-31T23:59:30.000000Z\",\"data\":{\"message_id\":\"0199b0e6-0000-7000-8000-000000000001\",\"subject\":\"Welcome to Lettermint\",\"recipient\":\"recipient@example.com\",\"url\":\"https://example.com/a/b?c=d\"},\"context\":{\"project_id\":\"0199b0e6-0000-7000-8000-0000000000aa\"}}" + }, + { + "id": "invalid-signature-non-ascii-in-v1", + "description": "The v1 value ends in a non-ASCII character. Reject it; do not crash.", + "expect": "invalid", + "reason": "signature_header_malformed", + "secret": "whsec_conformance0123456789abcdefABCDEF01", + "now": 1767225600, + "tolerance": 300, + "headers": { + "X-Lettermint-Signature": "t=1767225570,v1=14b942853f50d3d313f71135ee85933b89fbd0302b93faefff4be84c61a2ec6é", + "X-Lettermint-Delivery": "1767225570", + "X-Lettermint-Event": "message.delivered", + "X-Lettermint-Attempt": "1" + }, + "body_base64": "eyJpZCI6IjAxOTk5YjBlLTZjMWQtN2E4Yi05YzBkLTFlMmYzYTRiNWM2ZCIsImV2ZW50IjoibWVzc2FnZS5kZWxpdmVyZWQiLCJjcmVhdGVkX2F0IjoiMjAyNS0xMi0zMVQyMzo1OTozMC4wMDAwMDBaIiwiZGF0YSI6eyJtZXNzYWdlX2lkIjoiMDE5OWIwZTYtMDAwMC03MDAwLTgwMDAtMDAwMDAwMDAwMDAxIiwic3ViamVjdCI6IldlbGNvbWUgdG8gTGV0dGVybWludCIsInJlY2lwaWVudCI6InJlY2lwaWVudEBleGFtcGxlLmNvbSIsInVybCI6Imh0dHBzOi8vZXhhbXBsZS5jb20vYS9iP2M9ZCJ9LCJjb250ZXh0Ijp7InByb2plY3RfaWQiOiIwMTk5YjBlNi0wMDAwLTcwMDAtODAwMC0wMDAwMDAwMDAwYWEifX0=", + "body_utf8": "{\"id\":\"01999b0e-6c1d-7a8b-9c0d-1e2f3a4b5c6d\",\"event\":\"message.delivered\",\"created_at\":\"2025-12-31T23:59:30.000000Z\",\"data\":{\"message_id\":\"0199b0e6-0000-7000-8000-000000000001\",\"subject\":\"Welcome to Lettermint\",\"recipient\":\"recipient@example.com\",\"url\":\"https://example.com/a/b?c=d\"},\"context\":{\"project_id\":\"0199b0e6-0000-7000-8000-0000000000aa\"}}" + }, + { + "id": "invalid-signature-non-ascii-timestamp-digits", + "description": "t uses full-width Unicode digits; v1 is valid for the ASCII timestamp. Parsers that accept Unicode digits wrongly accept it.", + "expect": "invalid", + "reason": "signature_header_malformed", + "secret": "whsec_conformance0123456789abcdefABCDEF01", + "now": 1767225600, + "tolerance": 300, + "headers": { + "X-Lettermint-Signature": "t=1767225570,v1=14b942853f50d3d313f71135ee85933b89fbd0302b93faefff4be84c61a2ec62", + "X-Lettermint-Delivery": "1767225570", + "X-Lettermint-Event": "message.delivered", + "X-Lettermint-Attempt": "1" + }, + "body_base64": "eyJpZCI6IjAxOTk5YjBlLTZjMWQtN2E4Yi05YzBkLTFlMmYzYTRiNWM2ZCIsImV2ZW50IjoibWVzc2FnZS5kZWxpdmVyZWQiLCJjcmVhdGVkX2F0IjoiMjAyNS0xMi0zMVQyMzo1OTozMC4wMDAwMDBaIiwiZGF0YSI6eyJtZXNzYWdlX2lkIjoiMDE5OWIwZTYtMDAwMC03MDAwLTgwMDAtMDAwMDAwMDAwMDAxIiwic3ViamVjdCI6IldlbGNvbWUgdG8gTGV0dGVybWludCIsInJlY2lwaWVudCI6InJlY2lwaWVudEBleGFtcGxlLmNvbSIsInVybCI6Imh0dHBzOi8vZXhhbXBsZS5jb20vYS9iP2M9ZCJ9LCJjb250ZXh0Ijp7InByb2plY3RfaWQiOiIwMTk5YjBlNi0wMDAwLTcwMDAtODAwMC0wMDAwMDAwMDAwYWEifX0=", + "body_utf8": "{\"id\":\"01999b0e-6c1d-7a8b-9c0d-1e2f3a4b5c6d\",\"event\":\"message.delivered\",\"created_at\":\"2025-12-31T23:59:30.000000Z\",\"data\":{\"message_id\":\"0199b0e6-0000-7000-8000-000000000001\",\"subject\":\"Welcome to Lettermint\",\"recipient\":\"recipient@example.com\",\"url\":\"https://example.com/a/b?c=d\"},\"context\":{\"project_id\":\"0199b0e6-0000-7000-8000-0000000000aa\"}}" + }, + { + "id": "invalid-delivery-header-missing", + "description": "X-Lettermint-Delivery is absent. The backend always sends it (WebhookService.php:102), so it is required.", + "expect": "invalid", + "reason": "delivery_header_missing", + "secret": "whsec_conformance0123456789abcdefABCDEF01", + "now": 1767225600, + "tolerance": 300, + "headers": { + "X-Lettermint-Signature": "t=1767225570,v1=14b942853f50d3d313f71135ee85933b89fbd0302b93faefff4be84c61a2ec62", + "X-Lettermint-Event": "message.delivered", + "X-Lettermint-Attempt": "1" + }, + "body_base64": "eyJpZCI6IjAxOTk5YjBlLTZjMWQtN2E4Yi05YzBkLTFlMmYzYTRiNWM2ZCIsImV2ZW50IjoibWVzc2FnZS5kZWxpdmVyZWQiLCJjcmVhdGVkX2F0IjoiMjAyNS0xMi0zMVQyMzo1OTozMC4wMDAwMDBaIiwiZGF0YSI6eyJtZXNzYWdlX2lkIjoiMDE5OWIwZTYtMDAwMC03MDAwLTgwMDAtMDAwMDAwMDAwMDAxIiwic3ViamVjdCI6IldlbGNvbWUgdG8gTGV0dGVybWludCIsInJlY2lwaWVudCI6InJlY2lwaWVudEBleGFtcGxlLmNvbSIsInVybCI6Imh0dHBzOi8vZXhhbXBsZS5jb20vYS9iP2M9ZCJ9LCJjb250ZXh0Ijp7InByb2plY3RfaWQiOiIwMTk5YjBlNi0wMDAwLTcwMDAtODAwMC0wMDAwMDAwMDAwYWEifX0=", + "body_utf8": "{\"id\":\"01999b0e-6c1d-7a8b-9c0d-1e2f3a4b5c6d\",\"event\":\"message.delivered\",\"created_at\":\"2025-12-31T23:59:30.000000Z\",\"data\":{\"message_id\":\"0199b0e6-0000-7000-8000-000000000001\",\"subject\":\"Welcome to Lettermint\",\"recipient\":\"recipient@example.com\",\"url\":\"https://example.com/a/b?c=d\"},\"context\":{\"project_id\":\"0199b0e6-0000-7000-8000-0000000000aa\"}}" + }, + { + "id": "invalid-delivery-header-mismatch", + "description": "X-Lettermint-Delivery differs from t. The backend always sends them equal.", + "expect": "invalid", + "reason": "delivery_timestamp_mismatch", + "secret": "whsec_conformance0123456789abcdefABCDEF01", + "now": 1767225600, + "tolerance": 300, + "headers": { + "X-Lettermint-Signature": "t=1767225570,v1=14b942853f50d3d313f71135ee85933b89fbd0302b93faefff4be84c61a2ec62", + "X-Lettermint-Delivery": "1767225571", + "X-Lettermint-Event": "message.delivered", + "X-Lettermint-Attempt": "1" + }, + "body_base64": "eyJpZCI6IjAxOTk5YjBlLTZjMWQtN2E4Yi05YzBkLTFlMmYzYTRiNWM2ZCIsImV2ZW50IjoibWVzc2FnZS5kZWxpdmVyZWQiLCJjcmVhdGVkX2F0IjoiMjAyNS0xMi0zMVQyMzo1OTozMC4wMDAwMDBaIiwiZGF0YSI6eyJtZXNzYWdlX2lkIjoiMDE5OWIwZTYtMDAwMC03MDAwLTgwMDAtMDAwMDAwMDAwMDAxIiwic3ViamVjdCI6IldlbGNvbWUgdG8gTGV0dGVybWludCIsInJlY2lwaWVudCI6InJlY2lwaWVudEBleGFtcGxlLmNvbSIsInVybCI6Imh0dHBzOi8vZXhhbXBsZS5jb20vYS9iP2M9ZCJ9LCJjb250ZXh0Ijp7InByb2plY3RfaWQiOiIwMTk5YjBlNi0wMDAwLTcwMDAtODAwMC0wMDAwMDAwMDAwYWEifX0=", + "body_utf8": "{\"id\":\"01999b0e-6c1d-7a8b-9c0d-1e2f3a4b5c6d\",\"event\":\"message.delivered\",\"created_at\":\"2025-12-31T23:59:30.000000Z\",\"data\":{\"message_id\":\"0199b0e6-0000-7000-8000-000000000001\",\"subject\":\"Welcome to Lettermint\",\"recipient\":\"recipient@example.com\",\"url\":\"https://example.com/a/b?c=d\"},\"context\":{\"project_id\":\"0199b0e6-0000-7000-8000-0000000000aa\"}}" + } + ] +} diff --git a/test/helpers.ts b/test/helpers.ts new file mode 100644 index 0000000..f22f39d --- /dev/null +++ b/test/helpers.ts @@ -0,0 +1,88 @@ +import { Lettermint, type LettermintOptions } from '../src'; + +export const SENDING_TOKEN = 'lm_sendingFixture0000000000000000'; +export const TEAM_TOKEN = 'lm_team_teamFixture000000000000000000000000000'; +export const BASE = 'https://api.lettermint.co/v1'; + +export interface RecordedRequest { + url: string; + path: string; + query: URLSearchParams; + method: string; + headers: Record; + body: unknown; + rawBody: string | undefined; + init: RequestInit; +} + +export type Handler = (request: RecordedRequest, index: number) => Response | Promise; + +export function json( + status: number, + body: unknown, + headers: Record = {} +): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json', ...headers }, + }); +} + +export function text(status: number, body: string, headers: Record = {}): Response { + return new Response(status === 204 ? null : body, { status, headers }); +} + +/** A fake fetch that records every request and answers with `handler` (default: `{}` 200). */ +export function fakeFetch(handler: Handler = () => json(200, {})) { + const requests: RecordedRequest[] = []; + const fetch = jest.fn(async (url: string, init: RequestInit) => { + const parsed = new URL(url); + const rawBody = typeof init.body === 'string' ? init.body : undefined; + const request: RecordedRequest = { + url, + path: parsed.pathname.replace(/^\/v1/, ''), + query: parsed.searchParams, + method: String(init.method), + headers: { ...(init.headers as Record) }, + body: rawBody === undefined ? undefined : JSON.parse(rawBody), + rawBody, + init, + }; + requests.push(request); + return handler(request, requests.length - 1); + }); + return { fetch, requests }; +} + +export function client(options: Partial = {}, handler?: Handler) { + const { fetch, requests } = fakeFetch(handler); + const lettermint = new Lettermint({ + sendingToken: SENDING_TOKEN, + teamToken: TEAM_TOKEN, + fetch, + ...options, + }); + return { lettermint, fetch, requests }; +} + +/** Resolves with the rejection of `promise`, or fails the test when it resolves. */ +export async function rejection(promise: Promise): Promise { + try { + await promise; + } catch (error) { + return error as Error; + } + throw new Error('Expected the promise to reject'); +} + +/** Returns the error thrown by `fn`, or fails the test when it does not throw. */ +export function thrown(fn: () => unknown): Error { + try { + fn(); + } catch (error) { + return error as Error; + } + throw new Error('Expected the function to throw'); +} + +export const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/test/internals.test.ts b/test/internals.test.ts new file mode 100644 index 0000000..569993e --- /dev/null +++ b/test/internals.test.ts @@ -0,0 +1,79 @@ +import { runInNewContext } from 'node:vm'; +import { bytesToBase64, isBinary, toBytes } from '../src/encoding'; +import { serializeQuery, withQueryParam } from '../src/query'; + +describe('bytesToBase64', () => { + const sample = Uint8Array.from({ length: 70_000 }, (_, i) => (i * 7) % 256); + + it('matches Node for every input type', () => { + const expected = Buffer.from(sample).toString('base64'); + expect(bytesToBase64(sample)).toBe(expected); + expect(bytesToBase64(sample.buffer)).toBe(expected); + expect(bytesToBase64(new DataView(sample.buffer))).toBe(expected); + expect(bytesToBase64(sample.subarray(1, 4))).toBe( + Buffer.from(sample.subarray(1, 4)).toString('base64') + ); + expect(bytesToBase64(Buffer.from('Hello'))).toBe('SGVsbG8='); + expect(bytesToBase64(new Uint8Array())).toBe(''); + }); + + it('works without the native Uint8Array.prototype.toBase64', () => { + const proto = Uint8Array.prototype as unknown as Record; + const native = proto.toBase64; + // biome-ignore lint/performance/noDelete: the property must be absent, not undefined + delete proto.toBase64; + try { + expect(bytesToBase64(sample)).toBe(Buffer.from(sample).toString('base64')); + } finally { + if (native) proto.toBase64 = native; + } + }); + + it('accepts binary values from another realm', () => { + const foreign = runInNewContext('new Uint8Array([104, 105]).buffer'); + expect(isBinary(foreign)).toBe(true); + expect(toBytes(foreign)).toEqual(new Uint8Array([104, 105])); + expect(isBinary('text')).toBe(false); + expect(isBinary({ length: 1 })).toBe(false); + }); +}); + +describe('serializeQuery', () => { + it('handles scalars, dates, nesting and empty values', () => { + expect(serializeQuery(undefined)).toBe(''); + expect(serializeQuery({})).toBe(''); + expect( + decodeURIComponent( + serializeQuery({ + a: 1, + b: true, + c: false, + d: null, + e: undefined, + f: [], + g: ['x', 'y'], + h: { i: 'j', k: { l: 'm' } }, + n: new Date('2026-01-02T03:04:05Z'), + 'page[size]': 5, + }) + ) + ).toBe('a=1&b=1&c=0&g=x,y&h[i]=j&h[k][l]=m&n=2026-01-02T03:04:05.000Z&page[size]=5'); + }); + + it('encodes reserved characters', () => { + expect(serializeQuery({ filter: { search: 'a&b=c #d' } })).toBe( + 'filter%5Bsearch%5D=a%26b%3Dc+%23d' + ); + }); +}); + +describe('withQueryParam', () => { + it('sets top-level and bracketed parameters without changing the input', () => { + const query = { page: { size: 2 }, 'page[cursor]': 'old' }; + expect(withQueryParam(query, 'page[cursor]', 'next')).toEqual({ + page: { size: 2, cursor: 'next' }, + }); + expect(withQueryParam(undefined, 'cursor', 'c')).toEqual({ cursor: 'c' }); + expect(query).toEqual({ page: { size: 2 }, 'page[cursor]': 'old' }); + }); +}); diff --git a/test/team-api.test.ts b/test/team-api.test.ts new file mode 100644 index 0000000..54eae7c --- /dev/null +++ b/test/team-api.test.ts @@ -0,0 +1,401 @@ +import { type Lettermint, LettermintConfigError, type RouteData } from '../src'; +import { type OperationKey, operations } from '../src/generated/operations'; +import { SENDING_TOKEN, TEAM_TOKEN, client, json, rejection, text } from './helpers'; + +const ids = { + domainId: 'domain/1', + recordId: 'record 1', + messageId: 'message?1', + projectId: 'project#1', + routeId: 'route_1', + suppressionId: 'suppression_1', + userId: 'user/id', + webhookId: 'webhook_1', + deliveryId: 'delivery_1', +}; + +type Call = (lettermint: Lettermint) => Promise | AsyncGenerator; + +/** Every operation of the API, called through its public SDK method. */ +const calls: Record = { + 'DELETE /domains/{domainId}': (l) => l.domains.delete(ids.domainId), + 'DELETE /projects/{projectId}': (l) => l.projects.delete(ids.projectId), + 'DELETE /projects/{projectId}/report-forwarding': (l) => + l.projects.reportForwarding.delete(ids.projectId), + 'DELETE /routes/{routeId}': (l) => l.routes.delete(ids.routeId), + 'DELETE /suppressions/{suppressionId}': (l) => l.suppressions.delete(ids.suppressionId), + 'DELETE /webhooks/{webhookId}': (l) => l.webhooks.delete(ids.webhookId), + 'GET /blocked-file-types': (l) => l.blockedFileTypes(), + 'GET /domains': (l) => l.domains.list(), + 'GET /domains/{domainId}': (l) => l.domains.retrieve(ids.domainId), + 'GET /messages': (l) => l.messages.list(), + 'GET /messages/{messageId}': (l) => l.messages.retrieve(ids.messageId), + 'GET /messages/{messageId}/events': (l) => l.messages.events(ids.messageId), + 'GET /messages/{messageId}/html': (l) => l.messages.html(ids.messageId), + 'GET /messages/{messageId}/source': (l) => l.messages.source(ids.messageId), + 'GET /messages/{messageId}/text': (l) => l.messages.text(ids.messageId), + 'GET /ping': (l) => l.ping(), + 'GET /projects': (l) => l.projects.list(), + 'GET /projects/{projectId}': (l) => l.projects.retrieve(ids.projectId), + 'GET /projects/{projectId}/report-forwarding': (l) => + l.projects.reportForwarding.retrieve(ids.projectId), + 'GET /projects/{projectId}/routes': (l) => l.routes.list(ids.projectId), + 'GET /routes/{routeId}': (l) => l.routes.retrieve(ids.routeId), + 'GET /stats': (l) => l.stats.retrieve({ from: '2026-01-01', to: '2026-01-31' }), + 'GET /suppressions': (l) => l.suppressions.list(), + 'GET /team': (l) => l.team.retrieve(), + 'GET /team/members': (l) => l.team.members.list(), + 'GET /team/members/{userId}': (l) => l.team.members.retrieve(ids.userId), + 'GET /team/roles': (l) => l.team.roles(), + 'GET /team/usage': (l) => l.team.usage(), + 'GET /webhooks': (l) => l.webhooks.list(), + 'GET /webhooks/{webhookId}': (l) => l.webhooks.retrieve(ids.webhookId), + 'GET /webhooks/{webhookId}/deliveries': (l) => l.webhooks.deliveries.list(ids.webhookId), + 'GET /webhooks/{webhookId}/deliveries/{deliveryId}': (l) => + l.webhooks.deliveries.retrieve(ids.webhookId, ids.deliveryId), + 'PATCH /messages/{messageId}': (l) => + l.messages.reschedule(ids.messageId, { scheduled_at: '2026-10-01T09:00:00Z' }), + 'POST /analytics': (l) => l.analytics({ metrics: ['accepted'] }), + 'POST /domains': (l) => l.domains.create({ domain: 'example.test' }), + 'POST /domains/{domainId}/dns-records/verify': (l) => l.domains.verifyDnsRecords(ids.domainId), + 'POST /domains/{domainId}/dns-records/{recordId}/verify': (l) => + l.domains.verifyDnsRecord(ids.domainId, ids.recordId), + 'POST /messages/{messageId}/cancel': (l) => l.messages.cancel(ids.messageId), + 'POST /messages/{messageId}/process': (l) => l.messages.process(ids.messageId), + 'POST /projects': (l) => l.projects.create({ name: 'Production' }), + 'POST /projects/{projectId}/report-forwarding/resend-code': (l) => + l.projects.reportForwarding.resendCode(ids.projectId), + 'POST /projects/{projectId}/report-forwarding/verify': (l) => + l.projects.reportForwarding.verify(ids.projectId, { code: '123456' }), + 'POST /projects/{projectId}/rotate-token': (l) => l.projects.rotateToken(ids.projectId), + 'POST /projects/{projectId}/routes': (l) => + l.routes.create(ids.projectId, { name: 'Inbound', route_type: 'inbound' } as never), + 'POST /routes/{routeId}/verify-inbound-domain': (l) => l.routes.verifyInboundDomain(ids.routeId), + 'POST /send': (l) => + l.emails.send({ from: 'a@example.test', to: ['b@example.test'], subject: 'x' }), + 'POST /send/batch': (l) => + l.emails.sendBatch([{ from: 'a@example.test', to: ['b@example.test'], subject: 'x' }]), + 'POST /suppressions': (l) => + l.suppressions.create({ + reason: 'manual', + scope: 'team', + emails: ['blocked@example.test'], + } as never), + 'POST /webhooks': (l) => + l.webhooks.create({ + name: 'Hook', + url: 'https://example.test/hook', + events: ['message.sent'], + } as never), + 'POST /webhooks/{webhookId}/regenerate-secret': (l) => l.webhooks.regenerateSecret(ids.webhookId), + 'POST /webhooks/{webhookId}/test': (l) => l.webhooks.test(ids.webhookId), + 'PUT /domains/{domainId}/projects': (l) => + l.domains.updateProjects(ids.domainId, { project_ids: ['p'] }), + 'PUT /projects/{projectId}': (l) => + l.projects.update(ids.projectId, { name: 'Renamed' } as never), + 'PUT /projects/{projectId}/report-forwarding': (l) => + l.projects.reportForwarding.update(ids.projectId, { + destination: 'reports@example.test', + } as never), + 'PUT /routes/{routeId}': (l) => l.routes.update(ids.routeId, { name: 'Renamed' } as never), + 'PUT /team': (l) => l.team.update({ name: 'Acme' } as never), + 'PUT /team/members/{userId}/assignment': (l) => + l.team.members.updateAssignment(ids.userId, { + role_id: 'role_1', + project_access: { scope: 'all' }, + }), + 'PUT /webhooks/{webhookId}': (l) => l.webhooks.update(ids.webhookId, { basic_auth: null }), +}; + +function responseFor(key: OperationKey): Response { + const { response } = operations[key]; + if (response.type === 'empty') return new Response(null, { status: 204 }); + if (response.type === 'text') return text(200, 'pong'); + return json(response.status[0], { data: [], next_cursor: null }); +} + +describe('operation coverage', () => { + it('has an SDK method for every operation in the generated table', () => { + expect(Object.keys(calls).sort()).toEqual(Object.keys(operations).sort()); + expect(Object.keys(operations)).toHaveLength(58); + }); + + it.each(Object.keys(calls) as OperationKey[])('%s', async (key) => { + const operation = operations[key]; + const { lettermint, requests } = client({}, () => responseFor(key)); + await calls[key](lettermint); + expect(requests).toHaveLength(1); + const [request] = requests; + const path = operation.path.replace(/\{(\w+)\}/g, (_, name: keyof typeof ids) => + encodeURIComponent(ids[name]) + ); + expect(request.method).toBe(operation.method); + expect(request.url.replace(/\?.*$/, '')).toBe(`https://api.lettermint.co/v1${path}`); + if (operation.auth === 'sending') { + expect(request.headers['x-lettermint-token']).toBe(SENDING_TOKEN); + expect(request.headers).not.toHaveProperty('Authorization'); + } else { + expect(request.headers.Authorization).toBe(`Bearer ${TEAM_TOKEN}`); + expect(request.headers).not.toHaveProperty('x-lettermint-token'); + } + expect(request.headers.Accept).toBe('application/json'); + if (operation.request) { + expect(request.headers['Content-Type']).toBe('application/json'); + } else { + expect(request.rawBody).toBeUndefined(); + expect(request.headers).not.toHaveProperty('Content-Type'); + } + }); +}); + +describe('request bodies and options', () => { + it('sends request bodies as JSON', async () => { + const { lettermint, requests } = client(); + const assignment = { role_id: 'role_123', project_access: { scope: 'all' as const } }; + await lettermint.team.members.updateAssignment('user/id', assignment); + expect(requests[0].body).toEqual(assignment); + await lettermint.messages.reschedule('message/id', { scheduled_at: '2026-08-27T09:00:00Z' }); + expect(requests[1].body).toEqual({ scheduled_at: '2026-08-27T09:00:00Z' }); + }); + + it('sends an Idempotency-Key for processing an inbound message', async () => { + const { lettermint, requests } = client({}, () => json(202, { data: { message_id: 'm' } })); + await lettermint.messages.process('m', { idempotencyKey: 'process-1' }); + await lettermint.messages.process('m'); + expect(requests[0].headers['Idempotency-Key']).toBe('process-1'); + expect(requests[1].headers).not.toHaveProperty('Idempotency-Key'); + }); + + it.each([ + ['omit', {}], + ['set', { basic_auth: { username: ' fixture user ', password: '' } }], + ['remove', { basic_auth: null }], + ])('keeps the %s webhook basic-auth state', async (_, state) => { + const { lettermint, requests } = client({}, () => + json(200, { data: { has_basic_auth: true } }) + ); + const create = { + name: 'Fixture', + url: 'https://example.test/hook', + events: ['message.sent'], + ...state, + }; + expect((await lettermint.webhooks.create(create)).data.has_basic_auth).toBe(true); + expect((await lettermint.webhooks.update('webhook-id', state)).data.has_basic_auth).toBe(true); + expect(requests[0].body).toEqual(create); + expect(requests[1].body).toEqual(state); + }); + + it('sends analytics queries and report forwarding payloads', async () => { + const analytics = { + metrics: ['accepted', 'delivered'], + include: ['summary', 'time_series'], + interval: 'day', + filters: [{ dimension: 'project', operator: 'eq', values: ['project/id'] }], + sort: { metric: 'accepted', direction: 'desc' }, + limit: 10, + } as const; + const { lettermint, requests } = client({}, () => + json(200, { + data: { summary: { metrics: { accepted: 12, delivery_rate: null } } }, + meta: { timezone: 'UTC' }, + }) + ); + const result = await lettermint.analytics(analytics as never); + expect(result.meta.timezone).toBe('UTC'); + expect(requests[0].body).toEqual(analytics); + await lettermint.projects.reportForwarding.update('project/id', { + destination: 'reports@example.test', + } as never); + expect(requests[1].body).toEqual({ destination: 'reports@example.test' }); + }); + + it.each(['incoming.example.com', null, undefined])( + 'keeps a %s inbound route domain', + async (domain) => { + const route = { + id: 'route_1', + project_id: 'project_1', + slug: 'incoming', + name: 'Incoming', + route_type: 'inbound', + is_default: false, + created_at: '2026-10-01T12:00:00Z', + updated_at: '2026-10-01T12:00:00Z', + ...(domain === undefined ? {} : { inbound_route_domain: domain }), + } as RouteData; + const { lettermint } = client({}, () => json(200, route)); + const result = await lettermint.routes.retrieve('route_1'); + const typed: string | null | undefined = result.inbound_route_domain; + expect(typed).toBe(domain); + expect(result).toEqual(route); + } + ); +}); + +describe('query parameters', () => { + it('serializes nested objects to the bracket syntax', async () => { + const { lettermint, requests } = client({}, () => json(200, { data: [], next_cursor: null })); + await lettermint.domains.list({ + page: { size: 10, cursor: 'abc' }, + filter: { status: 'verified', domain: 'acme' }, + sort: ['-created_at', 'domain'], + }); + expect(requests[0].query.get('page[size]')).toBe('10'); + expect(requests[0].query.get('page[cursor]')).toBe('abc'); + expect(requests[0].query.get('filter[status]')).toBe('verified'); + expect(requests[0].query.get('filter[domain]')).toBe('acme'); + expect(requests[0].query.get('sort')).toBe('-created_at,domain'); + expect([...requests[0].query.keys()]).toHaveLength(5); + }); + + it('serializes booleans as 1/0, tag pairs with indexes and skips undefined', async () => { + const { lettermint, requests } = client({}, () => json(200, { data: [], next_cursor: null })); + await lettermint.messages.list({ + filter: { + tags: [ + { name: 'campaign', value: 'welcome' }, + { name: 'tier', value: 'gold' }, + ], + status: undefined, + search: 'hello world', + }, + }); + expect(requests[0].url).toContain('filter%5Bsearch%5D=hello+world'); + expect(Object.fromEntries(requests[0].query)).toEqual({ + 'filter[tags][0][name]': 'campaign', + 'filter[tags][0][value]': 'welcome', + 'filter[tags][1][name]': 'tier', + 'filter[tags][1][value]': 'gold', + 'filter[search]': 'hello world', + }); + await lettermint.webhooks.list({ filter: { enabled: false }, cursor: 'c1', page: { size: 5 } }); + expect(Object.fromEntries(requests[1].query)).toEqual({ + 'filter[enabled]': '0', + cursor: 'c1', + 'page[size]': '5', + }); + await lettermint.messages.events('m', { include_machine_events: true, page: { size: 2 } }); + expect(Object.fromEntries(requests[2].query)).toEqual({ + include_machine_events: '1', + 'page[size]': '2', + }); + await lettermint.domains.retrieve('d', { include: ['dnsRecords', 'projects'] }); + expect(Object.fromEntries(requests[3].query)).toEqual({ include: 'dnsRecords,projects' }); + await lettermint.stats.retrieve({ from: '2026-01-01', to: '2026-01-31', project_id: null }); + expect(Object.fromEntries(requests[4].query)).toEqual({ from: '2026-01-01', to: '2026-01-31' }); + }); +}); + +describe('pagination', () => { + it('follows next_cursor in page[cursor] until it is null', async () => { + const pages: Record = { + first: { data: [{ id: 'd1' }, { id: 'd2' }], next_cursor: 'c2' }, + c2: { data: [{ id: 'd3' }], next_cursor: 'c3' }, + c3: { data: [], next_cursor: null }, + }; + const { lettermint, requests } = client({}, (request) => + json(200, pages[request.query.get('page[cursor]') ?? 'first']) + ); + const ids: string[] = []; + for await (const domain of lettermint.domains.iterate({ + page: { size: 2 }, + filter: { status: 'verified' }, + })) { + ids.push(domain.id); + } + expect(ids).toEqual(['d1', 'd2', 'd3']); + expect(requests.map((r) => Object.fromEntries(r.query))).toEqual([ + { 'page[size]': '2', 'filter[status]': 'verified' }, + { 'page[size]': '2', 'filter[status]': 'verified', 'page[cursor]': 'c2' }, + { 'page[size]': '2', 'filter[status]': 'verified', 'page[cursor]': 'c3' }, + ]); + }); + + it('uses the cursor parameter where the table says so', async () => { + const { lettermint, requests } = client({}, (request) => + json( + 200, + request.query.get('cursor') + ? { data: [{ id: 'w2' }], next_cursor: null } + : { data: [{ id: 'w1' }], next_cursor: 'next' } + ) + ); + const ids: string[] = []; + for await (const delivery of lettermint.webhooks.deliveries.iterate('hook/1')) + ids.push(delivery.id); + expect(ids).toEqual(['w1', 'w2']); + expect(requests[1].url).toBe( + 'https://api.lettermint.co/v1/webhooks/hook%2F1/deliveries?cursor=next' + ); + expect(operations['GET /webhooks/{webhookId}/deliveries'].pagination.cursorParam).toBe( + 'cursor' + ); + }); + + it.each([ + ['domains', (l: Lettermint) => l.domains.iterate()], + ['messages', (l: Lettermint) => l.messages.iterate()], + ['message events', (l: Lettermint) => l.messages.iterateEvents('m')], + ['projects', (l: Lettermint) => l.projects.iterate()], + ['routes', (l: Lettermint) => l.routes.iterate('p')], + ['suppressions', (l: Lettermint) => l.suppressions.iterate()], + ['team members', (l: Lettermint) => l.team.members.iterate()], + ['webhooks', (l: Lettermint) => l.webhooks.iterate()], + ['webhook deliveries', (l: Lettermint) => l.webhooks.deliveries.iterate('w')], + ])('iterates %s', async (_, iterate) => { + const { lettermint, requests } = client({}, (_request, index) => + json( + 200, + index === 0 + ? { data: [{ id: 1 }], next_cursor: 'n' } + : { data: [{ id: 2 }], next_cursor: null } + ) + ); + const items: unknown[] = []; + for await (const item of iterate(lettermint)) items.push(item); + expect(items).toEqual([{ id: 1 }, { id: 2 }]); + expect(requests).toHaveLength(2); + }); + + it('stops when the API repeats a cursor', async () => { + const { lettermint, requests } = client({}, () => + json(200, { data: [{ id: 'x' }], next_cursor: 'same' }) + ); + const items: unknown[] = []; + for await (const item of lettermint.projects.iterate()) items.push(item); + expect(requests).toHaveLength(2); + expect(items).toHaveLength(2); + }); + + it('stops early when the caller breaks', async () => { + const { lettermint, requests } = client({}, () => + json(200, { data: [{ id: 'x' }, { id: 'y' }], next_cursor: 'n' }) + ); + for await (const _ of lettermint.suppressions.iterate()) break; + expect(requests).toHaveLength(1); + }); +}); + +describe('path parameters', () => { + it('encodes path parameters', async () => { + const { lettermint, requests } = client(); + await lettermint.domains.verifyDnsRecord('domain/../x', 'record?a=b#c'); + expect(requests[0].url).toBe( + 'https://api.lettermint.co/v1/domains/domain%2F..%2Fx/dns-records/record%3Fa%3Db%23c/verify' + ); + }); + + it.each(['', '.', '..', undefined, 42])('rejects %p before any request', async (id) => { + const { lettermint, fetch } = client(); + const error = await rejection(lettermint.domains.retrieve(id as string)); + expect(error).toBeInstanceOf(LettermintConfigError); + expect(error.message).toBe( + 'domains.retrieve: `domainId` must be a non-empty string other than "." and "..".' + ); + await expect(lettermint.webhooks.deliveries.retrieve('w', id as string)).rejects.toThrow( + 'webhooks.deliveries.retrieve: `deliveryId`' + ); + expect(fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/test/transport.test.ts b/test/transport.test.ts new file mode 100644 index 0000000..7851bbf --- /dev/null +++ b/test/transport.test.ts @@ -0,0 +1,341 @@ +import { inspect } from 'node:util'; +import { + ApiError, + AuthenticationError, + ConflictError, + ConnectionError, + LettermintConfigError, + LettermintError, + NotFoundError, + PermissionError, + RateLimitError, + RedirectError, + ServerError, + TimeoutError, + UnexpectedResponseError, + ValidationError, +} from '../src'; +import { SENDING_TOKEN, TEAM_TOKEN, client, json, rejection, text } from './helpers'; + +const message = { from: 'a@example.test', to: ['b@example.test'], subject: 'x' }; + +async function sendError(response: () => Response | Promise, options = {}) { + const { lettermint, fetch } = client(options, response); + const error = await rejection(lettermint.emails.send(message)); + return { error, fetch }; +} + +function expectNoSecrets(error: Error) { + for (const output of [ + inspect(error, { depth: Number.POSITIVE_INFINITY }), + JSON.stringify(error), + String(error.stack), + ]) { + expect(output).not.toContain(SENDING_TOKEN); + expect(output).not.toContain(TEAM_TOKEN); + } +} + +describe('HTTP error mapping', () => { + it.each([ + [400, ApiError, 'ApiError'], + [401, AuthenticationError, 'AuthenticationError'], + [403, PermissionError, 'PermissionError'], + [404, NotFoundError, 'NotFoundError'], + [409, ConflictError, 'ConflictError'], + [410, ApiError, 'ApiError'], + [422, ValidationError, 'ValidationError'], + [429, RateLimitError, 'RateLimitError'], + [500, ServerError, 'ServerError'], + [503, ServerError, 'ServerError'], + ])('maps HTTP %i to %p', async (status, type, name) => { + const body = { + error: { code: 'SOME_CODE', message: 'Something went wrong.', details: { a: 1 } }, + }; + const { error, fetch } = await sendError(() => json(status, body)); + expect(error).toBeInstanceOf(type); + expect(error).toBeInstanceOf(ApiError); + expect(error).toBeInstanceOf(LettermintError); + expect(error.name).toBe(name); + expect(error).toMatchObject({ + status, + code: 'SOME_CODE', + message: 'Something went wrong.', + details: { a: 1 }, + body, + }); + expect(fetch).toHaveBeenCalledTimes(1); + expectNoSecrets(error); + }); + + it('reads Laravel validation errors', async () => { + const body = { + message: 'The to field is required.', + errors: { to: ['The to field is required.'] }, + }; + const { error } = await sendError(() => json(422, body)); + expect(error).toBeInstanceOf(ValidationError); + expect(error).toMatchObject({ + status: 422, + code: undefined, + message: 'The to field is required.', + errors: { to: ['The to field is required.'] }, + body, + }); + }); + + it('keeps the legacy string error code', async () => { + const body = { error: 'DailyLimitExceeded', message: 'Daily limit exceeded' }; + const { error } = await sendError(() => json(422, body)); + expect(error).toMatchObject({ code: 'DailyLimitExceeded', message: 'Daily limit exceeded' }); + }); + + it('keeps the Free-plan Sandbox 403 response', async () => { + const body = { + error: { + code: 'FEATURE_NOT_AVAILABLE', + message: 'Sandbox mode is available only on paid plans.', + }, + }; + const { error } = await sendError(() => json(403, body)); + expect(error).toBeInstanceOf(PermissionError); + expect(error).toMatchObject({ status: 403, code: 'FEATURE_NOT_AVAILABLE', body }); + }); + + it('falls back to the status text for an empty error body', async () => { + const { error } = await sendError( + () => new Response(null, { status: 404, statusText: 'Not Found' }) + ); + expect(error).toBeInstanceOf(NotFoundError); + expect(error).toMatchObject({ status: 404, message: 'Not Found', body: undefined }); + const { error: noText } = await sendError(() => new Response('', { status: 500 })); + expect(noText).toMatchObject({ name: 'ServerError', message: 'HTTP 500' }); + }); + + it.each([ + ['120', 120], + ['0', 0], + ['soon', undefined], + ])('reads Retry-After %p', async (header, expected) => { + const { error } = await sendError(() => + json(429, { message: 'Too Many Attempts.' }, { 'Retry-After': header }) + ); + expect(error).toBeInstanceOf(RateLimitError); + expect((error as RateLimitError).retryAfter).toBe(expected); + }); + + it('reads an HTTP-date Retry-After', async () => { + const date = new Date(Date.now() + 61_000).toUTCString(); + const { error } = await sendError(() => + json(429, { message: 'Slow down' }, { 'Retry-After': date }) + ); + expect((error as RateLimitError).retryAfter).toBeGreaterThanOrEqual(59); + expect((error as RateLimitError).retryAfter).toBeLessThanOrEqual(61); + }); + + it('does not retry automatically', async () => { + const { error, fetch } = await sendError(() => json(503, { message: 'Down' })); + expect(error).toBeInstanceOf(ServerError); + expect(fetch).toHaveBeenCalledTimes(1); + }); +}); + +describe('unexpected responses', () => { + it('raises a typed error with the status for an empty 2xx body', async () => { + const { error } = await sendError( + () => new Response('', { status: 202, headers: { 'Content-Type': 'application/json' } }) + ); + expect(error).toBeInstanceOf(UnexpectedResponseError); + expect(error.name).toBe('UnexpectedResponseError'); + expect(error).toMatchObject({ status: 202, bodyExcerpt: '' }); + }); + + it('raises a typed error for an invalid JSON 2xx body', async () => { + const { error } = await sendError(() => text(200, '{"message_id":')); + expect(error).toBeInstanceOf(UnexpectedResponseError); + expect(error).toMatchObject({ status: 200, bodyExcerpt: '{"message_id":' }); + }); + + it('raises a typed error with the status and an excerpt for an HTML 502', async () => { + const page = `502 Bad Gateway${'x'.repeat(500)}`; + const { error } = await sendError(() => + text(502, page, { 'Content-Type': 'text/html; charset=UTF-8' }) + ); + expect(error).toBeInstanceOf(UnexpectedResponseError); + expect(error).toMatchObject({ status: 502 }); + expect(error.message).toContain('HTTP 502'); + expect(error.message).toContain('text/html'); + const excerpt = (error as UnexpectedResponseError).bodyExcerpt; + expect(excerpt.startsWith('502 Bad Gateway')).toBe(true); + expect(excerpt.length).toBeLessThanOrEqual(201); + }); + + it('accepts unknown enum values and fields', async () => { + const { lettermint } = client({}, () => + json(202, { + message_id: 'm', + status: 'some_future_status', + some_future_field: { nested: [1] }, + }) + ); + await expect(lettermint.emails.send(message)).resolves.toEqual({ + message_id: 'm', + status: 'some_future_status', + some_future_field: { nested: [1] }, + }); + }); + + it('resolves 204 responses with undefined without decoding', async () => { + const { lettermint } = client({}, () => new Response(null, { status: 204 })); + await expect(lettermint.projects.reportForwarding.delete('p')).resolves.toBeUndefined(); + }); + + it('returns text endpoints as strings', async () => { + const source = 'From: a@example.test\r\nSubject: x\r\n\r\nBody\r\n'; + const { lettermint } = client({}, () => + text(200, source, { 'Content-Type': 'message/rfc822' }) + ); + await expect(lettermint.messages.source('m')).resolves.toBe(source); + await expect(lettermint.messages.html('m')).resolves.toBe(source); + await expect(lettermint.messages.text('m')).resolves.toBe(source); + }); +}); + +describe('redirects', () => { + it.each([301, 302, 303, 307, 308])( + 'raises RedirectError for HTTP %i and never follows it', + async (status) => { + const { error, fetch } = await sendError( + () => + new Response('{"message":"Moved"}', { + status, + headers: { Location: 'https://user:secret@attacker.example.test/v1/send?token=x' }, + }) + ); + expect(error).toBeInstanceOf(RedirectError); + expect(error.name).toBe('RedirectError'); + expect((error as RedirectError).status).toBe(status); + expect(error.message).not.toContain('attacker'); + expect(JSON.stringify(error)).not.toContain('attacker'); + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch.mock.calls[0][1].redirect).toBe('manual'); + } + ); + + it('raises RedirectError for opaque redirect responses (browsers)', async () => { + const opaque = { + type: 'opaqueredirect', + status: 0, + body: null, + headers: new Headers(), + } as unknown as Response; + const { error } = await sendError(() => opaque); + expect(error).toBeInstanceOf(RedirectError); + expect((error as RedirectError).status).toBe(0); + }); + + it('applies to the Team API', async () => { + const { lettermint } = client( + {}, + () => new Response('', { status: 307, headers: { Location: '/elsewhere' } }) + ); + await expect(lettermint.ping()).rejects.toBeInstanceOf(RedirectError); + }); +}); + +describe('timeouts, cancellation and network failures', () => { + it('times out while waiting for the response headers', async () => { + const { lettermint } = client( + { timeout: 20 }, + (request) => + new Promise<Response>((_, reject) => { + request.init.signal?.addEventListener('abort', () => + reject(new DOMException('aborted', 'AbortError')) + ); + }) + ); + const error = await rejection(lettermint.emails.send(message)); + expect(error).toBeInstanceOf(TimeoutError); + expect(error.name).toBe('TimeoutError'); + expect((error as TimeoutError).timeout).toBe(20); + }); + + it('times out when fetch ignores the abort signal', async () => { + const { lettermint } = client({ timeout: 20 }, () => new Promise<Response>(() => {})); + await expect(lettermint.emails.send(message)).rejects.toBeInstanceOf(TimeoutError); + }); + + it('times out while reading the body', async () => { + let cancelled = false; + const { lettermint } = client({ timeout: 30 }, () => { + const stream = new ReadableStream<Uint8Array>({ + start(controller) { + controller.enqueue(new TextEncoder().encode('{"message_id":')); + }, + cancel() { + cancelled = true; + }, + }); + return new Response(stream, { status: 202, headers: { 'Content-Type': 'application/json' } }); + }); + const error = await rejection(lettermint.emails.send(message)); + expect(error).toBeInstanceOf(TimeoutError); + await new Promise((resolve) => setTimeout(resolve, 5)); + expect(cancelled).toBe(true); + }); + + it('uses a per-call timeout', async () => { + const { lettermint } = client({ timeout: 60_000 }, () => new Promise<Response>(() => {})); + await expect(lettermint.emails.send(message, { timeout: 10 })).rejects.toMatchObject({ + name: 'TimeoutError', + timeout: 10, + }); + }); + + it('rethrows the reason of a user abort', async () => { + const controller = new AbortController(); + const { lettermint } = client({}, () => new Promise<Response>(() => {})); + const pending = lettermint.emails.send(message, { signal: controller.signal }); + const reason = new Error('user cancelled'); + controller.abort(reason); + await expect(pending).rejects.toBe(reason); + + const aborted = AbortSignal.abort(); + const { lettermint: other, fetch } = client(); + const error = await rejection(other.domains.list(undefined, { signal: aborted })); + expect(error.name).toBe('AbortError'); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('rejects an invalid signal', async () => { + const { lettermint } = client(); + await expect( + lettermint.domains.list(undefined, { signal: {} as AbortSignal }) + ).rejects.toBeInstanceOf(LettermintConfigError); + }); + + it('wraps network failures in ConnectionError', async () => { + const cause = new TypeError('fetch failed'); + const { error } = await sendError(() => Promise.reject(cause)); + expect(error).toBeInstanceOf(ConnectionError); + expect(error.name).toBe('ConnectionError'); + expect(error.message).toBe('Could not reach the Lettermint API: fetch failed'); + expect(error.cause).toBe(cause); + expectNoSecrets(error); + }); + + it('wraps a failing body stream in ConnectionError', async () => { + const { error } = await sendError( + () => + new Response( + new ReadableStream({ + start(controller) { + controller.error(new TypeError('terminated')); + }, + }), + { status: 202 } + ) + ); + expect(error).toBeInstanceOf(ConnectionError); + }); +}); diff --git a/test/types.test.ts b/test/types.test.ts new file mode 100644 index 0000000..9038ac2 --- /dev/null +++ b/test/types.test.ts @@ -0,0 +1,183 @@ +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import ts from 'typescript'; +import type * as Types from '../src'; +import type { NestedQuery, OperationResponse } from '../src'; + +type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 + ? true + : false; +const assertType = <T extends true>(_value?: T) => {}; + +describe('generated types', () => { + it('uses the next-profile names and open enums', () => { + const status: Types.MessageStatus = 'some_future_status'; + const event: Types.WebhookEvent = 'message.some_future_event'; + const known: Types.WebhookEvent = 'message.auto_replied'; + const tls: Types.TlsPolicy = 'enforced'; + const response: Types.SendMailResponse = { + message_id: 'message_123', + status: 'pending', + sandbox: true, + sandbox_result: 'clicked', + }; + const scheduled: Types.SendMailResponse = { + message_id: 'message_123', + status: 'scheduled', + scheduled_at: '2026-10-01T09:00:00Z', + }; + const page: Types.ListDomainsResponse = { + data: [], + path: null, + per_page: 30, + next_cursor: null, + next_page_url: null, + prev_cursor: null, + prev_page_url: null, + }; + const created: Types.ProjectCreatedData = { + data: {} as Types.ProjectData, + message: 'Created', + api_token: 'project-token', + }; + const deleted: Types.DeleteSuppressionResponse = { + success: true, + message: 'Deleted', + status: 'removed', + confidence: 0.9, + } as Types.DeleteSuppressionResponse; + const credentials: Types.WebhookBasicAuthData = { username: 'fixture', password: '' }; + const errorBody: Types.ApiErrorBody = { error: { code: 'X', message: 'Y' } }; + const validationBody: Types.ValidationErrorBody = { + message: 'x', + errors: { to: ['required'] }, + }; + expect({ + status, + event, + known, + tls, + response, + scheduled, + page, + created, + deleted, + credentials, + errorBody, + validationBody, + }).toBeDefined(); + + assertType<Equal<OperationResponse<'GET /domains'>, Types.ListDomainsResponse>>(); + assertType<Equal<Types.ListDomainsResponse, Types.CursorPage<Types.DomainListData>>>(); + assertType< + Equal<OperationResponse<'PUT /projects/{projectId}'>, Types.ProjectMutationResponse> + >(); + assertType< + Equal<Types.SendMailResponse, Types.PendingSendMailResponse | Types.ScheduledSendMailResponse> + >(); + }); + + it('nests wire-name query parameters', () => { + type Domains = NestedQuery<Types.ListDomainsQuery>; + assertType<Equal<NonNullable<Domains['page']>, { size?: number; cursor?: string }>>(); + assertType<Equal<NonNullable<Domains['filter']>['status'], Types.DomainStatus | undefined>>(); + assertType<Equal<Domains['sort'], Types.ListDomainsQuerySortItem[] | undefined>>(); + type Webhooks = NestedQuery<Types.ListWebhooksQuery>; + assertType<Equal<Webhooks['cursor'], string | undefined>>(); + assertType<Equal<NonNullable<Webhooks['filter']>['enabled'], boolean | undefined>>(); + type Messages = NonNullable<NestedQuery<Types.ListMessagesQuery>['filter']>; + assertType< + Equal<Messages['tags'], string | Types.ListMessagesQueryFilterTagsItem[] | undefined> + >(); + type Stats = NestedQuery<Types.GetStatsQuery>; + assertType<Equal<Stats['from'], string>>(); + const query: NestedQuery<Types.ListMessagesQuery> = { + page: { size: 10 }, + filter: { status: 'delivered', tags: [{ name: 'campaign', value: 'welcome' }] }, + // @ts-expect-error wire-name keys are not part of the nested type + 'page[size]': 10, + }; + expect(query).toBeDefined(); + }); + + it('requires the has_basic_auth flag on webhook fixtures', () => { + const filename = resolve(__dirname, '__old_webhook_caller__.ts'); + const source = `import type { WebhookData, WebhookListData, WebhookSecretData } from '../src'; +const legacy: Omit<WebhookData, 'has_basic_auth'> = { + id: 'fixture', scope: 'route', project_ids: [], route_ids: [], route_id: null, + name: 'Fixture', url: 'https://example.test/hook', events: [], enabled: true, + include_machine_events: false, last_called_at: null, created_at: '', updated_at: '', + delivery_mode_filter: 'both' +}; +const detail: WebhookData = legacy; +const list: WebhookListData = legacy; +const secret: WebhookSecretData = { ...legacy, secret: 'synthetic-signing-secret' }; +`; + const compile = (text: string) => { + const options: ts.CompilerOptions = { + strict: true, + noEmit: true, + skipLibCheck: true, + types: [], + lib: ['lib.es2022.d.ts', 'lib.dom.d.ts'], + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.CommonJS, + resolveJsonModule: true, + esModuleInterop: true, + }; + const host = ts.createCompilerHost(options); + const read = host.getSourceFile.bind(host); + host.getSourceFile = (path, languageVersion, ...args) => + path === filename + ? ts.createSourceFile(path, text, languageVersion, true) + : read(path, languageVersion, ...args); + return ts.getPreEmitDiagnostics(ts.createProgram([filename], options, host)); + }; + const errors = compile(source); + expect(errors.map((error) => error.code)).toEqual([2741, 2741, 2741]); + for (const error of errors) { + expect(ts.flattenDiagnosticMessageText(error.messageText, '\n')).toContain('has_basic_auth'); + } + expect( + compile( + source + .replace( + "delivery_mode_filter: 'both'", + "delivery_mode_filter: 'both', has_basic_auth: false" + ) + .replace("Omit<WebhookData, 'has_basic_auth'>", 'WebhookData') + ) + ).toHaveLength(0); + }); +}); + +describe('runtime-neutral source', () => { + const files = (dir: string): string[] => + readdirSync(dir).flatMap((name) => { + const path = join(dir, name); + return statSync(path).isDirectory() ? files(path) : [path]; + }); + + it.each(files(resolve(__dirname, '../src')).map((path) => [path.split('/src/')[1], path]))( + '%s uses no Node-only APIs', + (_, path) => { + const source = readFileSync(path, 'utf8'); + expect(source).not.toMatch(/from ['"]node:/); + expect(source).not.toMatch(/require\(/); + expect(source).not.toMatch(/\bBuffer\b/); + expect(source).not.toMatch(/\bprocess\./); + } + ); + + it('keeps the generated files untouched', () => { + for (const name of ['types.ts', 'operations.ts']) { + const source = readFileSync(resolve(__dirname, '../src/generated', name), 'utf8'); + expect(source.startsWith('// Generated by lettermint/sdk-generator — do not edit.\n')).toBe( + true + ); + expect(source).toMatch(/^\/\/ sending-openapi\.json SHA-256: [0-9a-f]{64}$/m); + expect(source).toMatch(/^\/\/ team-openapi\.json SHA-256: [0-9a-f]{64}$/m); + expect(source).toMatch(/^\/\/ Naming profile: next$/m); + } + }); +}); diff --git a/test/webhook.test.ts b/test/webhook.test.ts new file mode 100644 index 0000000..6bc54f8 --- /dev/null +++ b/test/webhook.test.ts @@ -0,0 +1,259 @@ +import { createHmac } from 'node:crypto'; +import { inspect } from 'node:util'; +import { + LettermintConfigError, + LettermintError, + Webhook, + type WebhookPayload, + WebhookVerificationError, +} from '../src'; +import vectors from './fixtures/webhooks.json'; + +const now = 1700000000; +const secret = 'whsec_test-webhook-secret'; +const body = + '{"id":"d1","event":"message.delivered","timestamp":"2023-11-14T22:13:20Z","data":{"subject":"Hello 🌍"}}'; +const sign = (payload: string | Uint8Array = body, timestamp = now, key = secret) => + `t=${timestamp},v1=${createHmac('sha256', key).update(`${timestamp}.`).update(payload).digest('hex')}`; +const headers = (signature = sign(), delivery: string | number = now) => ({ + 'X-Lettermint-Signature': signature, + 'X-Lettermint-Delivery': String(delivery), +}); + +async function failure(promise: Promise<unknown>): Promise<WebhookVerificationError> { + try { + await promise; + } catch (error) { + expect(error).toBeInstanceOf(WebhookVerificationError); + return error as WebhookVerificationError; + } + throw new Error('Expected verification to fail'); +} + +describe('Webhook', () => { + beforeEach(() => { + jest.spyOn(Date, 'now').mockReturnValue(now * 1000); + }); + afterEach(() => jest.restoreAllMocks()); + + it('verifies headers and returns the typed payload', async () => { + const payload: WebhookPayload<{ subject: string }> = await new Webhook(secret).verify( + body, + headers() + ); + expect(payload).toEqual(JSON.parse(body)); + expect(payload.event).toBe('message.delivered'); + expect(payload.data.subject).toBe('Hello 🌍'); + expect(new WebhookVerificationError('signature_mismatch', 'x')).toBeInstanceOf(LettermintError); + }); + + it('accepts a Fetch Headers object (edge runtimes)', async () => { + const request = new Request('https://example.test/hook', { + method: 'POST', + body, + headers: headers(), + }); + await expect( + new Webhook(secret).verify(await request.text(), request.headers) + ).resolves.toEqual(JSON.parse(body)); + }); + + it('verifies the exact bytes from a Uint8Array or ArrayBuffer', async () => { + const raw = new TextEncoder().encode(` ${body}\n`); + const webhook = new Webhook(secret); + await expect(webhook.verify(raw, headers(sign(raw)))).resolves.toEqual(JSON.parse(body)); + await expect(webhook.verify(raw.buffer, headers(sign(raw)))).resolves.toEqual(JSON.parse(body)); + expect((await failure(webhook.verify(body, headers(sign(raw))))).reason).toBe( + 'signature_mismatch' + ); + }); + + it.each([-300, 0, 300])('accepts timestamps within tolerance: %i', async (offset) => { + const t = now + offset; + await expect( + new Webhook(secret).verify(body, headers(sign(body, t), t)) + ).resolves.toBeDefined(); + }); + + it.each([-301, 301])('rejects timestamps outside tolerance: %i', async (offset) => { + const t = now + offset; + const error = await failure(new Webhook(secret).verify(body, headers(sign(body, t), t))); + expect(error.reason).toBe('timestamp_out_of_tolerance'); + }); + + it('uses a custom tolerance and keeps the check enabled at zero', async () => { + await failure( + new Webhook(secret, { tolerance: 60 }).verify(body, headers(sign(body, now - 61), now - 61)) + ); + const webhook = new Webhook(secret, { tolerance: 0 }); + expect(webhook.tolerance).toBe(0); + await expect(webhook.verify(body, headers())).resolves.toBeDefined(); + await failure(webhook.verify(body, headers(sign(body, now - 1), now - 1))); + }); + + it.each([-1, 0.5, Number.NaN, Number.POSITIVE_INFINITY, Number.MAX_SAFE_INTEGER + 1])( + 'rejects invalid tolerance: %s', + (tolerance) => { + expect(() => new Webhook(secret, { tolerance })).toThrow(LettermintConfigError); + } + ); + + it('rejects an empty secret', () => { + expect(() => new Webhook('')).toThrow(LettermintConfigError); + expect(() => new Webhook(undefined as never)).toThrow('signing secret'); + }); + + it('rejects a changed payload or wrong secret', async () => { + expect((await failure(new Webhook(secret).verify('{}', headers()))).reason).toBe( + 'signature_mismatch' + ); + expect((await failure(new Webhook('wrong').verify(body, headers()))).reason).toBe( + 'signature_mismatch' + ); + }); + + it.each([ + ['', 'signature_header_missing'], + ['v1=abc', 'signature_header_malformed'], + [`t=${now}`, 'signature_header_malformed'], + [`t=${now},v1=abc`, 'signature_header_malformed'], + [`t=${now},v1=${'g'.repeat(64)}`, 'signature_header_malformed'], + [`t=${now},v1=${'a'.repeat(63)}`, 'signature_header_malformed'], + [`t=${now},v1=${'a'.repeat(65)}`, 'signature_header_malformed'], + [`t=${now},${sign()}`, 'signature_header_malformed'], + [sign().replace(`t=${now}`, 't=NaN'), 'signature_header_malformed'], + [sign().replace(`t=${now}`, 't=1e9'), 'signature_header_malformed'], + [sign().replace(`t=${now}`, 't=-1'), 'signature_header_malformed'], + [sign().replace(`t=${now}`, 't=9007199254740992'), 'signature_header_malformed'], + [sign().replace(`t=${now}`, `t=${now}=extra`), 'signature_header_malformed'], + [`${sign()}=extra`, 'signature_header_malformed'], + [`${sign()}é`, 'signature_header_malformed'], + ])('rejects the signature header %j', async (signature, reason) => { + const error = await failure(new Webhook(secret).verifySignature(body, signature)); + expect(error.reason).toBe(reason); + }); + + it('accepts any matching v1 signature and ignores unsupported versions', async () => { + const signature = `v2=ignored, v1=${'0'.repeat(64)}, ${sign()}, v1=malformed`; + await expect(new Webhook(secret).verifySignature(body, signature)).resolves.toEqual( + JSON.parse(body) + ); + }); + + it('rejects empty, parsed and non-JSON bodies', async () => { + const webhook = new Webhook(secret); + expect((await failure(webhook.verifySignature('', sign('')))).reason).toBe('body_invalid'); + expect((await failure(webhook.verifySignature(JSON.parse(body), sign()))).reason).toBe( + 'body_invalid' + ); + expect((await failure(webhook.verifySignature('invalid', sign('invalid')))).reason).toBe( + 'payload_invalid' + ); + expect((await failure(webhook.verifySignature('[1]', sign('[1]')))).reason).toBe( + 'payload_invalid' + ); + expect((await failure(webhook.verifySignature('invalid', sign()))).reason).toBe( + 'signature_mismatch' + ); + }); + + it('checks the optional delivery timestamp of verifySignature', async () => { + const webhook = new Webhook(secret); + await expect(webhook.verifySignature(body, sign(), now)).resolves.toBeDefined(); + await expect(webhook.verifySignature(body, sign(), String(now))).resolves.toBeDefined(); + for (const timestamp of [now + 1, Number.NaN, '']) { + expect((await failure(webhook.verifySignature(body, sign(), timestamp))).reason).toBe( + 'delivery_timestamp_mismatch' + ); + } + }); + + it('reads Node.js headers case-insensitively', async () => { + await expect( + new Webhook(secret).verify(new TextEncoder().encode(body), { + 'x-lettermint-signature': sign(), + 'X-LETTERMINT-DELIVERY': [String(now)], + host: 'localhost', + }) + ).resolves.toEqual(JSON.parse(body)); + }); + + it.each([ + [{}, 'signature_header_missing'], + [{ 'x-lettermint-signature': sign() }, 'delivery_header_missing'], + [{ 'x-lettermint-delivery': String(now) }, 'signature_header_missing'], + [ + { 'x-lettermint-signature': sign(), 'x-lettermint-delivery': undefined }, + 'delivery_header_missing', + ], + [ + { 'x-lettermint-signature': [sign(), sign()], 'x-lettermint-delivery': String(now) }, + 'signature_header_malformed', + ], + [ + { 'x-lettermint-signature': sign(), 'x-lettermint-delivery': '' }, + 'delivery_timestamp_mismatch', + ], + [ + { 'x-lettermint-signature': sign(), 'x-lettermint-delivery': `${now}junk` }, + 'delivery_timestamp_mismatch', + ], + [ + { 'x-lettermint-signature': sign(), 'x-lettermint-delivery': String(now + 1) }, + 'delivery_timestamp_mismatch', + ], + [ + { + 'x-lettermint-signature': sign(), + 'X-Lettermint-Signature': sign(), + 'x-lettermint-delivery': String(now), + }, + 'signature_header_malformed', + ], + ])('rejects missing, ambiguous or invalid headers %#', async (input, reason) => { + const error = await failure(new Webhook(secret).verify(body, input as Record<string, string>)); + expect(error.reason).toBe(reason); + expect(error.name).toBe('WebhookVerificationError'); + }); + + it('never shows the secret', async () => { + const webhook = new Webhook(secret); + const error = await failure(webhook.verify('{}', headers(`t=1,v1=${'0'.repeat(64)}`, 1))); + for (const subject of [webhook, error]) { + for (const output of [ + inspect(subject), + inspect(subject, { depth: Number.POSITIVE_INFINITY, showHidden: true }), + JSON.stringify(subject), + String(subject), + String((subject as Error).stack ?? ''), + ]) { + expect(output).not.toContain(secret); + } + } + expect(JSON.stringify(webhook)).toBe('{"tolerance":300}'); + }); +}); + +describe('conformance webhook vectors', () => { + afterEach(() => jest.restoreAllMocks()); + + it('uses the full vector set', () => { + expect(vectors.vectors.length).toBeGreaterThanOrEqual(25); + }); + + it.each(vectors.vectors.map((vector) => [vector.id, vector] as const))( + '%s', + async (_, vector) => { + jest.spyOn(Date, 'now').mockReturnValue(vector.now * 1000); + const bytes = Uint8Array.from(atob(vector.body_base64), (c) => c.charCodeAt(0)); + const webhook = new Webhook(vector.secret, { tolerance: vector.tolerance }); + const result = webhook.verify(bytes, vector.headers as unknown as Record<string, string>); + if (vector.expect === 'valid') { + await expect(result).resolves.toEqual(JSON.parse(new TextDecoder().decode(bytes))); + } else { + const error = await failure(result); + expect(error.reason).toBe(vector.reason); + } + } + ); +}); diff --git a/tsconfig.json b/tsconfig.json index 7639d3c..2bdd306 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,15 +1,19 @@ { "compilerOptions": { - "target": "es2018", + "target": "ES2022", + "lib": ["ES2022", "DOM"], "module": "commonjs", - "declaration": true, - "outDir": "./dist", + "moduleResolution": "node10", + "types": [], "strict": true, + "noEmit": true, + "declaration": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true + "resolveJsonModule": true, + "isolatedModules": true, + "noImplicitOverride": true }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "**/*.test.ts"] + "include": ["src/**/*"] } diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..57c2b44 --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["jest", "node"] + }, + "include": ["src/**/*", "test/**/*"] +} diff --git a/tsup.config.ts b/tsup.config.ts new file mode 100644 index 0000000..133b324 --- /dev/null +++ b/tsup.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + dts: true, + clean: true, + sourcemap: false, + // Readable output: error class names and stack traces stay intact. + minify: false, + keepNames: true, + target: 'es2022', + platform: 'neutral', + // Named and default exports work for both `import` and `require`. + cjsInterop: false, + splitting: false, + treeshake: true, +});