Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 47 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ const html = await lettermint.messages.html('message-id');
| `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` |
| (root) | `ping`, `analytics`, `analyticsPages`, `blockedFileTypes` |

### Query parameters and pagination

Expand Down Expand Up @@ -288,6 +288,51 @@ for await (const delivery of lettermint.webhooks.deliveries.iterate(webhookId))

Stop early with `break`. The SDK requests the next page only when you get to it.

### Analytics

`lettermint.analytics(query)` runs one analytics query. `metrics` is the only required field; by default the API returns a summary of the last 30 days:

```ts
const result = await lettermint.analytics({
metrics: ['delivered', 'bounced', 'delivery_rate'],
from: '2026-10-01',
to: '2026-10-31',
timezone: 'Europe/Amsterdam',
});

console.log(result.data.summary?.metrics.delivery_rate); // 0.9836, or null when there is no data
console.log(result.meta.partial, result.meta.effective_to);
```

Add `include` to ask for a `time_series` or a `breakdown`. A breakdown needs `group_by`, and the API returns its rows in pages of `limit` (at most 200). `analyticsPages()` follows `pagination.next_cursor` for you. It is an async generator that yields one whole response per request, so each page keeps its `meta` and `pagination`:

```ts
import type { AnalyticsBreakdownRow, AnalyticsQuery } from 'lettermint';

const query: AnalyticsQuery = {
metrics: ['delivered', 'bounced'],
include: ['breakdown'],
group_by: ['recipient_domain'],
sort: { metric: 'bounced', direction: 'desc' },
limit: 200,
};

const rows: AnalyticsBreakdownRow[] = [];
for await (const page of lettermint.analyticsPages(query)) {
rows.push(...(page.data.breakdown ?? []));
if (page.pagination.truncated) console.warn('More groups exist than the API ranks.');
}
```

A cursor expires 60 seconds after its response, so read the next page promptly. An expired cursor throws a `ValidationError` with `errors.cursor`; run the query again to start over.

A few things to know when you read a response:

- A metric is `null` when the API cannot measure it for that row or bucket, and a rate is `null` when its denominator is zero. `0` means a measured zero.
- `data.summary`, `data.time_series` and `data.breakdown` are present only when `include` asks for them. `previous`, `change` and `meta.comparison` are present only with `compare`.
- `smtp_response_group` can be used in `group_by` but not as a filter dimension.
- Analytics can answer `503` or `504` when a query takes too long or the service is busy. Both throw a `ServerError`; see [Errors](#errors).

### Cancellation and timeouts

Every method takes an `options` argument last, with `signal` (an `AbortSignal`) and `timeout` (milliseconds, overrides the client's timeout):
Expand All @@ -311,7 +356,7 @@ Every error the SDK throws extends `LettermintError`:
| `ConflictError` | 409 | |
| `ValidationError` | 422 | `errors` (field errors) |
| `RateLimitError` | 429 | `retryAfter` (seconds) |
| `ServerError` | 5xx | |
| `ServerError` | 5xx | `retryAfter` (seconds, when the API sent `Retry-After`) |
| `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` |
Expand Down
6 changes: 5 additions & 1 deletion src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,9 +131,13 @@ export class RateLimitError extends ApiError {

/** HTTP 5xx with a JSON or empty body. */
export class ServerError extends ApiError {
constructor(init: ApiErrorInit) {
/** 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 = 'ServerError';
this.retryAfter = init.retryAfter;
}
}

Expand Down
9 changes: 6 additions & 3 deletions src/generated/operations.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// 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
// Spec: lettermint/lettermint@bff20c7d86dd05b926713f89c0ad716ef630ae21 (main)
// sending-openapi.json SHA-256: 958303afdf2971b372cab97e72845db2568bc0b6cb6f5c41b2a0dad0386df15d
// team-openapi.json SHA-256: 04718e1e006be90fe586f30754a73a4fd69233119dc36862ded9fb98d69aa138
// Naming profile: next

import type * as Types from './types.js';
Expand Down Expand Up @@ -737,6 +737,8 @@ export const operations = {
415: ['ErrorMessage'],
422: ['ErrorMessage'],
429: ['ErrorMessage', 'ApiError'],
503: ['ErrorMessage', 'ApiError'],
504: ['ErrorMessage'],
},
pagination: null,
deprecated: false,
Expand Down Expand Up @@ -821,6 +823,7 @@ export const operations = {
response: { status: [200], contentType: 'application/json', type: 'ScheduledMessage' },
errors: {
401: ['ApiError', 'ErrorMessage'],
403: ['ApiError'],
404: ['ErrorMessage', 'ApiError'],
409: ['ApiError', 'ErrorMessage'],
422: ['ErrorMessage'],
Expand Down
21 changes: 12 additions & 9 deletions src/generated/types.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// 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
// Spec: lettermint/lettermint@bff20c7d86dd05b926713f89c0ad716ef630ae21 (main)
// sending-openapi.json SHA-256: 958303afdf2971b372cab97e72845db2568bc0b6cb6f5c41b2a0dad0386df15d
// team-openapi.json SHA-256: 04718e1e006be90fe586f30754a73a4fd69233119dc36862ded9fb98d69aa138
// Naming profile: next

/** Metric values for one group of dimension values. */
Expand Down Expand Up @@ -32,7 +32,6 @@ export type AnalyticsCatalogueDimension =
| 'enhanced_status_category'
| 'bounce_classification'
| 'failure_reason'
| 'smtp_response_group'
| 'engagement_state'
| 'request_actor'
| 'proxy_type'
Expand Down Expand Up @@ -83,7 +82,7 @@ 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. */
/** String values to match. Supply one for eq, one or more for in/not_in, and none for null checks. project_id and route_id take UUIDs; smtp_code takes a three-digit reply code. */
values?: string[];
}

Expand All @@ -96,7 +95,7 @@ export type AnalyticsFilterOperator =
| 'is_not_null'
| (string & {});

/** A catalogue dimension or tag:<case-sensitive name> to group breakdown rows by. Engagement class dimensions (is_human, is_privacy, is_bot and is_scanner) can only filter. */
/** A catalogue dimension or tag:<case-sensitive name> to group breakdown rows by. Engagement class dimensions (is_human, is_privacy, is_bot and is_scanner) can only filter. smtp_response_group can only group: each row reads the SMTP reply of the group as its reply code, enhanced status code and response text, with addresses and other identifiers replaced by placeholders. */
export type AnalyticsGroupDimension = AnalyticsCatalogueGroupDimension | string;

/** A time-series bucket size. */
Expand Down Expand Up @@ -716,7 +715,7 @@ export type ListMessageEventsResponse = CursorPage<MessageEventData>;
/** Query parameters of `GET /messages` (listMessages). */
export interface ListMessagesQuery {
sort?: ListMessagesQuerySortItem[];
/** Search in sender, recipients, and subject */
/** Search in sender, recipients, and subject. A complete email address matches the sender, To, CC or BCC address exactly and does not search the subject. */
'filter[search]'?: string;
/** Exact tag name. Add more numbered pairs to match all pairs. */
'filter[tags][0][name]'?: string;
Expand Down Expand Up @@ -744,9 +743,9 @@ export interface ListMessagesQuery {
'filter[domain_id]'?: string;
/** Filter by tag name */
'filter[tag]'?: string;
/** Filter messages created after this date */
/** Filter messages created on or after this date */
'filter[from_date]'?: string;
/** Filter messages created before this date */
/** Filter messages created on or before this date */
'filter[to_date]'?: string;
}

Expand Down Expand Up @@ -974,6 +973,7 @@ export interface MessageEventData {
event: MessageEventType;
tag: string | null;
tags: MessageTag[];
/** Event-specific metadata. For `opened` and `clicked` events, `reason_codes` and `engagement.reason_codes` list broad categories behind the bot classification: `privacy_proxy`, `image_proxy`, `link_protection`, `known_bot`, `security_network`, `timing`, `link_pattern`, `request_shape`, `unconfirmed`, `confirmed`, `sandbox` or `other`. New categories can be added. Use `classification` and `counts_for_metrics` for decisions */
metadata: Record<string, unknown> | null;
timestamp: string;
}
Expand Down Expand Up @@ -1391,6 +1391,9 @@ export interface SendMailRequest {
* 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.
* Send values as plain text: Lettermint encodes non-ASCII characters and repairs small formatting
* errors, such as line breaks, control characters and missing angle brackets. Header names must
* be printable ASCII without a colon.
*/
headers?: Record<string, string>;
/** Metadata to track with the email (not added as email headers). */
Expand Down
26 changes: 26 additions & 0 deletions src/lettermint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,32 @@ export class Lettermint {
return this.#transport.call('POST /analytics', { label: 'analytics', body: query, options });
}

/**
* Queries email analytics and follows `pagination.next_cursor`, yielding one
* whole response per request. Each response carries the next page of
* `data.breakdown` with its own `meta` and `pagination`. Needs `teamToken`.
*
* A cursor expires 60 seconds after its response, so request the next page
* promptly; an expired cursor rejects with a `ValidationError`. The query
* passed in is not changed.
*/
async *analyticsPages(
query: AnalyticsQuery,
options?: RequestOptions
): AsyncGenerator<AnalyticsResponse, void, undefined> {
const seen = new Set<string>();
if (typeof query.cursor === 'string') seen.add(query.cursor);
let body = query;
while (true) {
const page = await this.analytics(body, options);
yield page;
const next = page.pagination?.next_cursor;
if (typeof next !== 'string' || next === '' || seen.has(next)) return;
seen.add(next);
body = { ...query, cursor: next };
}
}

/** The file extensions and MIME types that cannot be attached. Needs `teamToken`. */
blockedFileTypes(options?: RequestOptions): Promise<BlockedFileTypes> {
return this.#transport.call('GET /blocked-file-types', { label: 'blockedFileTypes', options });
Expand Down
7 changes: 6 additions & 1 deletion src/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,12 @@ function createApiError(response: Response, body: unknown): ApiError {
retryAfter: parseRetryAfter(response.headers.get('retry-after')),
});
default:
return status >= 500 ? new ServerError(init) : new ApiError(init);
return status >= 500
? new ServerError({
...init,
retryAfter: parseRetryAfter(response.headers.get('retry-after')),
})
: new ApiError(init);
}
}

Expand Down
Loading
Loading