From 1ab30f4c4c0ec8af7d6d67f2e46200a0eb236ee7 Mon Sep 17 00:00:00 2001 From: Bjarn Bronsveld Date: Sat, 10 Oct 2026 20:32:47 +0200 Subject: [PATCH 1/2] chore: regenerate types from the lettermint#2694 API spec Spec: lettermint/lettermint@bff20c7d86. - POST /analytics documents 503 and 504. - POST /messages/{messageId}/cancel documents 403. - The analytics filter dimension loses smtp_response_group. The API rejects it as a filter; it stays a group dimension. - Description updates. --- src/generated/operations.ts | 9 ++++++--- src/generated/types.ts | 21 ++++++++++++--------- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/src/generated/operations.ts b/src/generated/operations.ts index 4f0888d..50bde5d 100644 --- a/src/generated/operations.ts +++ b/src/generated/operations.ts @@ -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'; @@ -737,6 +737,8 @@ export const operations = { 415: ['ErrorMessage'], 422: ['ErrorMessage'], 429: ['ErrorMessage', 'ApiError'], + 503: ['ErrorMessage', 'ApiError'], + 504: ['ErrorMessage'], }, pagination: null, deprecated: false, @@ -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'], diff --git a/src/generated/types.ts b/src/generated/types.ts index 876f093..e939092 100644 --- a/src/generated/types.ts +++ b/src/generated/types.ts @@ -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. */ @@ -32,7 +32,6 @@ export type AnalyticsCatalogueDimension = | 'enhanced_status_category' | 'bounce_classification' | 'failure_reason' - | 'smtp_response_group' | 'engagement_state' | 'request_actor' | 'proxy_type' @@ -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[]; } @@ -96,7 +95,7 @@ export type AnalyticsFilterOperator = | '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. */ +/** 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. 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. */ @@ -716,7 +715,7 @@ export type ListMessageEventsResponse = CursorPage; /** 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; @@ -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; } @@ -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 | null; timestamp: string; } @@ -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; /** Metadata to track with the email (not added as email headers). */ From e400849b7b639810a2e0ad1d224cf4894e7574e8 Mon Sep 17 00:00:00 2001 From: Bjarn Bronsveld Date: Sat, 10 Oct 2026 20:32:47 +0200 Subject: [PATCH 2/2] feat: add analyticsPages and retryAfter on server errors - analyticsPages(query) follows pagination.next_cursor and yields one whole analytics response per request. - ServerError carries retryAfter when a 5xx response has a Retry-After header, as an analytics 503 can. - Document analytics in the README and test it against realistic responses. --- README.md | 49 ++++++- src/errors.ts | 6 +- src/lettermint.ts | 26 ++++ src/transport.ts | 7 +- test/analytics.test.ts | 198 +++++++++++++++++++++++++++ test/fixtures/analytics-page-1.json | 140 +++++++++++++++++++ test/fixtures/analytics-page-2.json | 118 ++++++++++++++++ test/fixtures/analytics-summary.json | 33 +++++ test/transport.test.ts | 10 ++ 9 files changed, 583 insertions(+), 4 deletions(-) create mode 100644 test/analytics.test.ts create mode 100644 test/fixtures/analytics-page-1.json create mode 100644 test/fixtures/analytics-page-2.json create mode 100644 test/fixtures/analytics-summary.json diff --git a/README.md b/README.md index 4d94011..2aac279 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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): @@ -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` | diff --git a/src/errors.ts b/src/errors.ts index 02804fb..0c58de0 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -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; } } diff --git a/src/lettermint.ts b/src/lettermint.ts index 256903d..e0b2fc1 100644 --- a/src/lettermint.ts +++ b/src/lettermint.ts @@ -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 { + const seen = new Set(); + 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 { return this.#transport.call('GET /blocked-file-types', { label: 'blockedFileTypes', options }); diff --git a/src/transport.ts b/src/transport.ts index 1ef2015..9f193fb 100644 --- a/src/transport.ts +++ b/src/transport.ts @@ -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); } } diff --git a/test/analytics.test.ts b/test/analytics.test.ts new file mode 100644 index 0000000..871a2e0 --- /dev/null +++ b/test/analytics.test.ts @@ -0,0 +1,198 @@ +import { type AnalyticsQuery, type AnalyticsResponse, ServerError, ValidationError } from '../src'; +import pageOne from './fixtures/analytics-page-1.json'; +import pageTwo from './fixtures/analytics-page-2.json'; +import summaryOnly from './fixtures/analytics-summary.json'; +import { client, json, rejection } from './helpers'; + +const query: AnalyticsQuery = { + metrics: ['delivered', 'bounced', 'delivery_rate', 'delivery_latency_p50_ms'], + include: ['summary', 'time_series', 'breakdown'], + group_by: ['recipient_domain'], + interval: 'hour', + timezone: 'Asia/Kolkata', + compare: 'previous_period', + limit: 2, +}; + +describe('analytics responses', () => { + it('keeps null values, empty rate bases and both timestamp formats', async () => { + const { lettermint, requests } = client({}, () => json(200, pageOne)); + const result = await lettermint.analytics(query); + + expect(requests[0].body).toEqual(query); + const summary = result.data.summary; + expect(summary?.metrics).toEqual({ + delivered: 1200, + bounced: 0, + delivery_rate: 0.9836, + delivery_latency_p50_ms: null, + }); + expect(summary?.rate_bases.delivery_rate).toEqual({ numerator: 1200, denominator: 1220 }); + expect(summary?.previous?.rate_bases.delivery_rate).toEqual({ + numerator: null, + denominator: null, + }); + expect(summary?.change?.delivery_rate).toEqual({ + absolute: null, + relative: null, + percentage_points: null, + }); + expect(summary?.change?.delivered).not.toHaveProperty('percentage_points'); + + const [complete, unavailable] = result.data.time_series ?? []; + expect(complete.from).toBe('2026-09-15T08:30:00+05:30'); + expect(new Date(complete.from).toISOString()).toBe('2026-09-15T03:00:00.000Z'); + expect(unavailable).toMatchObject({ available: false, partial: true, rate_bases: {} }); + expect(unavailable.metrics.delivered).toBeNull(); + + expect(result.meta.generated_at).toBe('2026-09-15T04:12:30.482915Z'); + expect(new Date(result.meta.from).toISOString()).toBe('2026-09-15T03:00:00.000Z'); + expect(result.meta.last_ingested_at).toBeNull(); + expect(result.meta.comparison).toEqual({ + from: '2026-09-15T01:00:00.000000Z', + to: '2026-09-15T03:00:00.000000Z', + partial: false, + }); + expect(result.pagination).toEqual({ + total_groups: 3, + returned_groups: 2, + next_cursor: 'cursor-page-2', + truncated: false, + }); + }); + + it('leaves out the sections and comparison a query did not ask for', async () => { + const { lettermint } = client({}, () => json(200, summaryOnly)); + const result = await lettermint.analytics({ metrics: ['delivered'] }); + expect(result.data.summary?.rate_bases).toEqual({}); + expect(result.data).not.toHaveProperty('time_series'); + expect(result.data).not.toHaveProperty('breakdown'); + expect(result.data.summary).not.toHaveProperty('previous'); + expect(result.meta).not.toHaveProperty('comparison'); + expect(result.pagination.next_cursor).toBeNull(); + }); + + it('keeps a null dimension value in a breakdown row', async () => { + const { lettermint } = client({}, () => json(200, pageTwo)); + const result = await lettermint.analytics({ ...query, cursor: 'cursor-page-2' }); + expect(result.data.breakdown).toHaveLength(1); + expect(result.data.breakdown?.[0].dimensions).toEqual({ recipient_domain: null }); + expect(result.data.breakdown?.[0].metrics.bounced).toBeNull(); + }); +}); + +describe('analyticsPages', () => { + it('follows next_cursor and yields every response', async () => { + const sent = { ...query }; + const { lettermint, requests } = client({}, (_, index) => + json(200, index === 0 ? pageOne : pageTwo) + ); + const pages: AnalyticsResponse[] = []; + for await (const page of lettermint.analyticsPages(sent)) pages.push(page); + + expect(pages).toHaveLength(2); + expect(requests).toHaveLength(2); + expect(requests.every((request) => request.method === 'POST')).toBe(true); + expect(requests.every((request) => request.path === '/analytics')).toBe(true); + expect(requests[0].body).toEqual(query); + expect(requests[1].body).toEqual({ ...query, cursor: 'cursor-page-2' }); + expect(sent).toEqual(query); + + const rows = pages.flatMap((page) => page.data.breakdown ?? []); + expect(rows.map((row) => row.dimensions.recipient_domain)).toEqual([ + 'gmail.com', + 'outlook.com', + null, + ]); + expect(pages[1].pagination).toMatchObject({ returned_groups: 1, next_cursor: null }); + }); + + it('makes one request for a query without more pages', async () => { + const { lettermint, requests } = client({}, () => json(200, summaryOnly)); + const pages: AnalyticsResponse[] = []; + for await (const page of lettermint.analyticsPages({ metrics: ['delivered'] })) + pages.push(page); + expect(pages).toHaveLength(1); + expect(requests).toHaveLength(1); + }); + + it('requests the next page only when it is asked for', async () => { + const { lettermint, requests } = client({}, () => json(200, pageOne)); + for await (const page of lettermint.analyticsPages(query)) { + expect(page.pagination.next_cursor).toBe('cursor-page-2'); + break; + } + expect(requests).toHaveLength(1); + }); + + it('stops when the API repeats a cursor', async () => { + const { lettermint, requests } = client({}, () => json(200, pageOne)); + let count = 0; + for await (const _ of lettermint.analyticsPages(query)) count += 1; + expect(count).toBe(2); + expect(requests).toHaveLength(2); + }); + + it('stops when the API returns the cursor the query started from', async () => { + const { lettermint, requests } = client({}, () => json(200, pageOne)); + let count = 0; + for await (const _ of lettermint.analyticsPages({ ...query, cursor: 'cursor-page-2' })) + count += 1; + expect(count).toBe(1); + expect(requests[0].body).toEqual({ ...query, cursor: 'cursor-page-2' }); + }); + + it('passes request options to every page and surfaces an expired cursor', async () => { + const body = { + message: 'The analytics cursor is invalid or expired. Submit a new query.', + errors: { cursor: ['The analytics cursor is invalid or expired. Submit a new query.'] }, + }; + const { lettermint, requests } = client({}, (_, index) => + index === 0 ? json(200, pageOne) : json(422, body) + ); + const controller = new AbortController(); + const pages = lettermint.analyticsPages(query, { signal: controller.signal }); + expect((await pages.next()).done).toBe(false); + const error = await rejection(pages.next()); + expect(error).toBeInstanceOf(ValidationError); + expect((error as ValidationError).errors).toEqual(body.errors); + expect(requests).toHaveLength(2); + expect(requests.every((request) => request.init.signal instanceof AbortSignal)).toBe(true); + }); +}); + +describe('analytics errors', () => { + it('reads field errors from a 422', async () => { + const body = { + message: 'smtp_response_group can only be used in group_by.', + errors: { filters: ['smtp_response_group can only be used in group_by.'] }, + }; + const { lettermint } = client({}, () => json(422, body)); + const error = await rejection(lettermint.analytics(query)); + expect(error).toBeInstanceOf(ValidationError); + expect(error).toMatchObject({ status: 422, message: body.message, errors: body.errors }); + }); + + it('reads Retry-After from a 503', async () => { + const body = { error: { code: 'SERVICE_UNAVAILABLE', message: 'Try again shortly.' } }; + const { lettermint } = client({}, () => json(503, body, { 'Retry-After': '2' })); + const error = await rejection(lettermint.analytics(query)); + expect(error).toBeInstanceOf(ServerError); + expect(error).toMatchObject({ status: 503, code: 'SERVICE_UNAVAILABLE', retryAfter: 2 }); + }); + + it('has no retryAfter for a 503 or 504 without the header', async () => { + const unavailable = client({}, () => json(503, { message: 'Analytics is unavailable.' })); + const first = await rejection(unavailable.lettermint.analytics(query)); + expect(first).toBeInstanceOf(ServerError); + expect((first as ServerError).retryAfter).toBeUndefined(); + + const message = + 'Analytics exceeded the query time limit. Retry with a shorter period or fewer dimensions.'; + const timedOut = client({}, () => json(504, { message })); + const second = await rejection(timedOut.lettermint.analytics(query)); + expect(second).toBeInstanceOf(ServerError); + expect(second).toMatchObject({ status: 504, message }); + expect((second as ServerError).retryAfter).toBeUndefined(); + }); +}); diff --git a/test/fixtures/analytics-page-1.json b/test/fixtures/analytics-page-1.json new file mode 100644 index 0000000..eba87a8 --- /dev/null +++ b/test/fixtures/analytics-page-1.json @@ -0,0 +1,140 @@ +{ + "data": { + "summary": { + "metrics": { + "delivered": 1200, + "bounced": 0, + "delivery_rate": 0.9836, + "delivery_latency_p50_ms": null + }, + "rate_bases": { + "delivery_rate": { + "numerator": 1200, + "denominator": 1220 + } + }, + "previous": { + "metrics": { + "delivered": 1100, + "bounced": 4, + "delivery_rate": null, + "delivery_latency_p50_ms": 812.5 + }, + "rate_bases": { + "delivery_rate": { + "numerator": null, + "denominator": null + } + } + }, + "change": { + "delivered": { + "absolute": 100, + "relative": 0.0909 + }, + "delivery_rate": { + "absolute": null, + "relative": null, + "percentage_points": null + } + } + }, + "time_series": [ + { + "from": "2026-09-15T08:30:00+05:30", + "to": "2026-09-15T09:30:00+05:30", + "available": true, + "partial": false, + "metrics": { + "delivered": 1200, + "bounced": 0, + "delivery_rate": 0.9836, + "delivery_latency_p50_ms": null + }, + "rate_bases": { + "delivery_rate": { + "numerator": 1200, + "denominator": 1220 + } + } + }, + { + "from": "2026-09-15T09:30:00+05:30", + "to": "2026-09-15T10:30:00+05:30", + "available": false, + "partial": true, + "metrics": { + "delivered": null, + "bounced": null, + "delivery_rate": null, + "delivery_latency_p50_ms": null + }, + "rate_bases": {} + } + ], + "breakdown": [ + { + "dimensions": { + "recipient_domain": "gmail.com" + }, + "metrics": { + "delivered": 900, + "bounced": 0, + "delivery_rate": 0.989, + "delivery_latency_p50_ms": null + }, + "rate_bases": { + "delivery_rate": { + "numerator": 900, + "denominator": 910 + } + } + }, + { + "dimensions": { + "recipient_domain": "outlook.com" + }, + "metrics": { + "delivered": 250, + "bounced": 0, + "delivery_rate": 0.9615, + "delivery_latency_p50_ms": null + }, + "rate_bases": { + "delivery_rate": { + "numerator": 250, + "denominator": 260 + } + } + } + ] + }, + "meta": { + "time_basis": "event", + "timezone": "Asia/Kolkata", + "interval": "hour", + "from": "2026-09-15T03:00:00.000000Z", + "to": "2026-09-15T05:00:00.000000Z", + "effective_to": "2026-09-15T05:00:00.000000Z", + "alignment": "hour", + "generated_at": "2026-09-15T04:12:30.482915Z", + "available_since": "2026-06-17T00:00:00.000000Z", + "partial": true, + "ongoing": true, + "collection_completeness": "best_effort", + "last_ingested_at": null, + "metric_definition_version": "analytics-v2", + "ranked_group_limit": 1000, + "comparison": { + "from": "2026-09-15T01:00:00.000000Z", + "to": "2026-09-15T03:00:00.000000Z", + "partial": false + } + }, + "pagination": { + "total_groups": 3, + "returned_groups": 2, + "next_cursor": "cursor-page-2", + "truncated": false + } +} diff --git a/test/fixtures/analytics-page-2.json b/test/fixtures/analytics-page-2.json new file mode 100644 index 0000000..e133309 --- /dev/null +++ b/test/fixtures/analytics-page-2.json @@ -0,0 +1,118 @@ +{ + "data": { + "summary": { + "metrics": { + "delivered": 1200, + "bounced": 0, + "delivery_rate": 0.9836, + "delivery_latency_p50_ms": null + }, + "rate_bases": { + "delivery_rate": { + "numerator": 1200, + "denominator": 1220 + } + }, + "previous": { + "metrics": { + "delivered": 1100, + "bounced": 4, + "delivery_rate": null, + "delivery_latency_p50_ms": 812.5 + }, + "rate_bases": { + "delivery_rate": { + "numerator": null, + "denominator": null + } + } + }, + "change": { + "delivered": { + "absolute": 100, + "relative": 0.0909 + }, + "delivery_rate": { + "absolute": null, + "relative": null, + "percentage_points": null + } + } + }, + "time_series": [ + { + "from": "2026-09-15T08:30:00+05:30", + "to": "2026-09-15T09:30:00+05:30", + "available": true, + "partial": false, + "metrics": { + "delivered": 1200, + "bounced": 0, + "delivery_rate": 0.9836, + "delivery_latency_p50_ms": null + }, + "rate_bases": { + "delivery_rate": { + "numerator": 1200, + "denominator": 1220 + } + } + }, + { + "from": "2026-09-15T09:30:00+05:30", + "to": "2026-09-15T10:30:00+05:30", + "available": false, + "partial": true, + "metrics": { + "delivered": null, + "bounced": null, + "delivery_rate": null, + "delivery_latency_p50_ms": null + }, + "rate_bases": {} + } + ], + "breakdown": [ + { + "dimensions": { + "recipient_domain": null + }, + "metrics": { + "delivered": 50, + "bounced": null, + "delivery_rate": null, + "delivery_latency_p50_ms": null + }, + "rate_bases": {} + } + ] + }, + "meta": { + "time_basis": "event", + "timezone": "Asia/Kolkata", + "interval": "hour", + "from": "2026-09-15T03:00:00.000000Z", + "to": "2026-09-15T05:00:00.000000Z", + "effective_to": "2026-09-15T05:00:00.000000Z", + "alignment": "hour", + "generated_at": "2026-09-15T04:12:30.482915Z", + "available_since": "2026-06-17T00:00:00.000000Z", + "partial": true, + "ongoing": true, + "collection_completeness": "best_effort", + "last_ingested_at": null, + "metric_definition_version": "analytics-v2", + "ranked_group_limit": 1000, + "comparison": { + "from": "2026-09-15T01:00:00.000000Z", + "to": "2026-09-15T03:00:00.000000Z", + "partial": false + } + }, + "pagination": { + "total_groups": 3, + "returned_groups": 1, + "next_cursor": null, + "truncated": false + } +} diff --git a/test/fixtures/analytics-summary.json b/test/fixtures/analytics-summary.json new file mode 100644 index 0000000..f8fb3db --- /dev/null +++ b/test/fixtures/analytics-summary.json @@ -0,0 +1,33 @@ +{ + "data": { + "summary": { + "metrics": { + "delivered": 1200 + }, + "rate_bases": {} + } + }, + "meta": { + "time_basis": "event", + "timezone": "UTC", + "interval": "day", + "from": "2026-09-15T03:00:00.000000Z", + "to": "2026-09-15T05:00:00.000000Z", + "effective_to": "2026-09-15T05:00:00.000000Z", + "alignment": "day", + "generated_at": "2026-09-15T04:12:30.482915Z", + "available_since": "2026-06-17T00:00:00.000000Z", + "partial": true, + "ongoing": true, + "collection_completeness": "best_effort", + "last_ingested_at": null, + "metric_definition_version": "analytics-v2", + "ranked_group_limit": 1000 + }, + "pagination": { + "total_groups": 0, + "returned_groups": 0, + "next_cursor": null, + "truncated": false + } +} diff --git a/test/transport.test.ts b/test/transport.test.ts index 7851bbf..793418b 100644 --- a/test/transport.test.ts +++ b/test/transport.test.ts @@ -133,6 +133,16 @@ describe('HTTP error mapping', () => { expect((error as RateLimitError).retryAfter).toBeLessThanOrEqual(61); }); + it('reads Retry-After from a 5xx response', async () => { + const { error } = await sendError(() => + json(503, { message: 'Service Unavailable' }, { 'Retry-After': '2' }) + ); + expect(error).toBeInstanceOf(ServerError); + expect((error as ServerError).retryAfter).toBe(2); + const { error: plain } = await sendError(() => json(500, { message: 'Server Error' })); + expect((plain as ServerError).retryAfter).toBeUndefined(); + }); + it('does not retry automatically', async () => { const { error, fetch } = await sendError(() => json(503, { message: 'Down' })); expect(error).toBeInstanceOf(ServerError);