From b890553d2429b89bc085ee9a96043df303f064ea Mon Sep 17 00:00:00 2001 From: Bjarn Bronsveld Date: Tue, 29 Sep 2026 21:09:44 +0200 Subject: [PATCH 1/2] feat(api): align sandbox responses with OpenAPI --- src/api-surface.spec.ts | 88 ++++++++----------- src/endpoints/api.ts | 15 ---- src/endpoints/email.spec.ts | 95 ++------------------- src/endpoints/email.ts | 72 ++-------------- src/types.ts | 166 ++++++++++++++++++++++++++---------- 5 files changed, 173 insertions(+), 263 deletions(-) diff --git a/src/api-surface.spec.ts b/src/api-surface.spec.ts index 19f367d..a14b2f0 100644 --- a/src/api-surface.spec.ts +++ b/src/api-surface.spec.ts @@ -133,34 +133,11 @@ describe('public SDK surface', () => { expect(mockFetch).toHaveBeenNthCalledWith( 3, 'https://api.lettermint.co/v1/team/members/user%2Fid/assignment', - expect.objectContaining({ method: 'PUT', body: JSON.stringify(assignment) }) - ); - }); - - it('reschedules and cancels scheduled 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' }), + method: 'PUT', + body: JSON.stringify(assignment), }) ); - 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' }) - ); }); }); @@ -177,9 +154,6 @@ describe('api endpoint coverage', () => { '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', @@ -237,18 +211,17 @@ describe('api endpoint coverage', () => { describe('generated api types', () => { it('matches current Team API schema additions', () => { - const messageEvent: Types.MessageEventType = 'scheduled'; + 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: 'global', + scope: 'team', emails: ['blocked@example.com'], }; const routeSettings: Types.UpdateRouteSettingsData = { redact_email_content: true, generate_plaintext_fallback: false, - tls: 'enforced', }; const routeInboundSettings: Types.UpdateRouteInboundSettingsData = { inbound_domain: 'inbound.example.com', @@ -262,6 +235,7 @@ describe('generated api types', () => { const projectCreate: Types.StoreProjectData = { name: 'Production', short_token: true, + delivery_mode: 'sandbox', }; const project: Types.ProjectData = { id: 'project_123', @@ -274,9 +248,36 @@ describe('generated api types', () => { 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: 'delivered', + 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'], @@ -293,22 +294,6 @@ describe('generated api types', () => { role_id: 'role_123', project_access: { scope: 'selected', project_ids: ['project_123'] }, }; - const domain: Types.DomainData = { - id: 'domain_123', - domain: 'example.com', - dkim_mode: 'managed_cname', - rotation_ready: true, - status_changed_at: null, - created_at: '2026-08-12T00:00:00Z', - }; - const sourceMessage: Types.SuppressionSourceMessageData = { - id: 'msg_123', - available: true, - subject: 'Blocked', - created_at: '2026-08-12T00:00:00Z', - }; - const spamScore: Types.MessageListData['spam_score'] = 2.5; - const scheduledAt: Types.SendMailRequest['scheduled_at'] = '2026-08-27T09:00:00Z'; expect({ messageEvent, @@ -319,13 +304,14 @@ describe('generated api types', () => { projectCreate, project, projectUpdate, + sendRequest, + sendResponse, + messageMode, + webhookCreate, + webhookDelivery, blockedFileTypes, teamRole, assignment, - domain, - sourceMessage, - spamScore, - scheduledAt, }).toBeDefined(); }); }); diff --git a/src/endpoints/api.ts b/src/endpoints/api.ts index c04656b..adfa0e5 100644 --- a/src/endpoints/api.ts +++ b/src/endpoints/api.ts @@ -50,21 +50,6 @@ export class MessagesEndpoint extends Endpoint { 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`); } diff --git a/src/endpoints/email.spec.ts b/src/endpoints/email.spec.ts index 9e49916..e38ecb2 100644 --- a/src/endpoints/email.spec.ts +++ b/src/endpoints/email.spec.ts @@ -150,10 +150,7 @@ describe('EmailEndpoint', () => { }); it('should set custom headers', () => { - const headers = { - 'Message-ID': '', - 'X-LM-Preserve-Message-ID': 'true', - }; + const headers = { 'X-Custom': 'Value', 'X-Another': 'Another Value' }; const result = emailEndpoint.headers(headers); expect(result).toBe(emailEndpoint); @@ -212,47 +209,6 @@ describe('EmailEndpoint', () => { }); }); - 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 set per-email settings', () => { - const settings = { - track_opens: false, - track_clicks: true, - tls: 'enforced' as const, - }; - - expect(emailEndpoint.settings(settings)).toBe(emailEndpoint); - - return emailEndpoint.send().then(() => { - expect(client.post).toHaveBeenCalledWith( - '/send', - expect.objectContaining({ settings }), - undefined - ); - }); - }); - it('should set the route', () => { const result = emailEndpoint.route('test-route'); @@ -303,43 +259,22 @@ describe('EmailEndpoint', () => { }); }); - it('should set reusable tags', () => { - const tags = [{ name: 'campaign', value: 'welcome-v2' }]; - const result = emailEndpoint.tags(tags); + 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({ tags }), + expect.objectContaining({ + sandbox_result: 'hard_bounced', + }), 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 count the legacy tag in the message tag limit', () => { - const tags = Array.from({ length: 20 }, (_, index) => ({ - name: `tag_${index}`, - value: 'value', - })); - expect(() => emailEndpoint.tag('legacy').tags(tags)).toThrow(TypeError); - }); - it('should send the email with all options', async () => { // Set up a complete email emailEndpoint @@ -481,22 +416,4 @@ describe('EmailEndpoint', () => { }, }); }); - - 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 index 02e0d6b..517d97a 100644 --- a/src/endpoints/email.ts +++ b/src/endpoints/email.ts @@ -1,5 +1,6 @@ import type { EmailPayload, + SandboxResult, SendBatchEmailResponse, SendBatchMailRequest, SendEmailResponse, @@ -93,12 +94,6 @@ export class EmailEndpoint extends Endpoint { 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 * @@ -181,15 +176,9 @@ export class EmailEndpoint extends Endpoint { * @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 { + public attach(filename: string, content: string, content_id?: string): this { if (!this.payload.attachments) { this.payload.attachments = []; } @@ -198,23 +187,11 @@ export class EmailEndpoint extends Endpoint { 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 * @@ -237,45 +214,18 @@ export class EmailEndpoint extends Endpoint { * @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. + * Select the simulated result for a Sandbox project. * - * @param tags The tags to attach to the email + * @param result The result to simulate for every recipient * @returns The current instance for chaining */ - public tags(tags: NonNullable): this { - const maximum = this.payload.tag == null ? 20 : 19; - if (tags.length > maximum) { - throw new TypeError( - `No more than ${maximum} message tags are permitted with the current legacy tag` - ); - } - - const names = new Set(); - for (const tag of tags) { - if (tag.name.length < 1 || tag.name.length > 32 || !/^[A-Za-z0-9_-]+$/.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 (tag.value.length < 1 || tag.value.length > 64 || !/^[A-Za-z0-9_-]+$/.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(`Duplicate message tag name: ${tag.name}`); - } - names.add(tag.name); - } - - this.payload.tags = tags; + public sandboxResult(result: SandboxResult): this { + this.payload.sandbox_result = result; return this; } @@ -298,15 +248,7 @@ export class EmailEndpoint extends Endpoint { } 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(); - } + return this.httpClient.post('/send/batch', payload); } public async ping(): Promise { diff --git a/src/types.ts b/src/types.ts index 41a7b0a..2eb74b5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,12 +4,6 @@ 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; @@ -22,7 +16,10 @@ export interface SendMailRequest { "headers"?: Record; "metadata"?: Record; "tag"?: string | null; - "tags"?: MessageTag[]; + "tags"?: { + "name": string; + "value": string; +}[]; "settings"?: { "track_opens"?: boolean; "track_clicks"?: boolean; @@ -36,6 +33,7 @@ export interface SendMailRequest { "content_type"?: string | null; "content_id"?: string | null; }[]; + "sandbox_result"?: SandboxResult; } export type SendBatchMailRequest = { @@ -50,7 +48,10 @@ export type SendBatchMailRequest = { "headers"?: Record; "metadata"?: Record; "tag"?: string | null; - "tags"?: MessageTag[]; + "tags"?: { + "name": string; + "value": string; +}[]; "settings"?: { "track_opens"?: boolean; "track_clicks"?: boolean; @@ -64,24 +65,17 @@ export type SendBatchMailRequest = { "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"; @@ -145,7 +139,10 @@ export interface MessageData { "status_changed_at": string | null; "scheduled_at": string | null; "tag": string | null; - "tags": MessageTag[]; + "tags": { + "name": string; + "value": string; +}[]; "from_email": string; "from_name": string | null; "reply_to": string[] | null; @@ -159,13 +156,18 @@ export interface MessageData { "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[]; + "tags": { + "name": string; + "value": string; +}[]; "metadata": Record | null; "timestamp": string; } @@ -186,14 +188,20 @@ export interface MessageListData { "bcc": MessageRecipientData[] | null; "reply_to": string[] | null; "tag": string | null; - "tags": MessageTag[]; + "tags": { + "name": string; + "value": string; +}[]; "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 { @@ -225,6 +233,7 @@ export interface ProjectData { "last_28_days"?: MessageStatsData | null; "created_at": string; "updated_at": string; + "delivery_mode": DeliveryMode; } export interface ProjectListData { @@ -244,6 +253,10 @@ export type RbacPermission = "team:manage" | "billing:manage" | "security:manage export type RecordType = "TXT" | "CNAME" | "MX"; +export interface RescheduleMessageRequest { + "scheduled_at": string; +} + export interface RouteData { "id": string; "project_id": string; @@ -252,6 +265,7 @@ export interface RouteData { "route_type": RouteType; "is_default": boolean; "inbound_address"?: string | null; + "inbound_mx_hostname"?: string; "inbound_domain"?: string | null; "inbound_domain_verified_at"?: string | null; "inbound_spam_threshold"?: number | null; @@ -384,6 +398,7 @@ export interface StoreProjectData { "smtp_enabled"?: boolean; "initial_routes"?: InitialRoutes; "short_token"?: boolean; + "delivery_mode"?: DeliveryMode; } export interface StoreRouteData { @@ -399,15 +414,20 @@ export interface StoreSuppressionData { "scope": SuppressionScope; "route_id"?: string | null; "project_id"?: string | null; + "applies_to"?: SuppressionAppliesTo | null; } export interface StoreWebhookData { - "route_id": string; "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; } export interface SuppressedRecipientData { @@ -416,16 +436,18 @@ export interface SuppressedRecipientData { "value": string; "reason": SuppressionReason; "scope": SuppressionScope; + "applies_to": SuppressionAppliesTo; "project_id": string | null; "route_id": string | null; "source_message"?: SuppressionSourceMessageData | null; "created_at": string; - "updated_at": string; } +export type SuppressionAppliesTo = "all" | "broadcast"; + export type SuppressionReason = "spam_complaint" | "hard_bounce" | "unsubscribe" | "manual"; -export type SuppressionScope = "global" | "team" | "project" | "route"; +export type SuppressionScope = "team" | "project" | "route"; export interface SuppressionSourceMessageData { "id": string; @@ -508,6 +530,7 @@ export interface UpdateProjectData { "smtp_enabled"?: boolean | null; "redact_email_content"?: boolean | null; "default_route_id"?: string | null; + "delivery_mode"?: DeliveryMode | null; } export interface UpdateRouteData { @@ -551,26 +574,37 @@ export interface UpdateWebhookData { "events"?: WebhookEvent[]; "enabled"?: boolean; "include_machine_events"?: boolean; + "scope"?: WebhookScope; + "project_ids"?: string[]; + "route_ids"?: string[]; + "route_id"?: string | null; + "delivery_mode_filter"?: WebhookDeliveryModeFilter; } export interface WebhookData { "id": string; - "route_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; } 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; @@ -581,12 +615,16 @@ export interface WebhookDeliveryData { "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; "attempt_number": number; "http_status_code": number | null; @@ -597,41 +635,69 @@ export interface WebhookDeliveryListData { 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" | "suppression.added" | "suppression.removed" | "webhook.test"; +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; - "route_id": string; + "scope": WebhookScope; + "project_ids": string[]; + "route_ids": string[]; + "route_id": string | null; "name": string; "url": string; - "events": WebhookEvent[]; + "events": string[]; "enabled": boolean; "last_called_at": string | null; "created_at": string; "updated_at": string; } +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; +} + +export type DeliveryMode = "live" | "sandbox"; + +export type WebhookDeliveryModeFilter = "live" | "sandbox" | "both"; + export type EmailPayload = SendMailRequest; export type SendEmailResponse = SendMailResponse; export type SendBatchEmailResponse = SendBatchMailResponse; export type SendMailResponse = { - "message_id": string; + "message_id": string | null; "status": MessageStatus; - "scheduled_at"?: string; + "scheduled_at": string; + "sandbox"?: true; + "sandbox_result"?: SandboxResult; +} | { + "message_id": string | null; + "status": MessageStatus; + "sandbox"?: true; + "sandbox_result"?: SandboxResult; }; export type SendBatchMailResponse = { "message_id": string; "status": MessageStatus; "scheduled_at"?: string; + "sandbox"?: true; + "sandbox_result"?: SandboxResult; }[]; -export interface RescheduleMessageRequest { - "scheduled_at": string; -} -export type RescheduleMessageResponse = { - "message_id": string; - "status": MessageStatus | null; - "scheduled_at": string | null; -}; export type PingResponse = 200; export type DomainIndexResponse = { "data": DomainListData[]; @@ -668,6 +734,17 @@ 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[]; @@ -680,7 +757,6 @@ export type MessageIndexResponse = { "prev_cursor_url": string | null; }; }; -export type MessageShowResponse = MessageData; export type MessageEventsResponse = { "data": MessageEventData[]; "links": string[]; @@ -694,7 +770,11 @@ export type MessageEventsResponse = { }; }; export type ProcessInboundMessageResponse = { - "data": { "message_id": string; "status": "queued"; "webhook_target_count": number; }; + "data": { + "message_id": string; + "status": "queued"; + "webhook_target_count": number; +}; }; export type ProjectIndexResponse = { "data": ProjectListData[]; @@ -811,7 +891,7 @@ export type WebhookIndexResponse = { }; export type WebhookStoreRequest = StoreWebhookData; export type WebhookStoreResponse = { - "data": WebhookData; + "data": WebhookSecretData; "message": "Webhook created successfully. Please save the secret as it will not be shown again."; }; export type WebhookShowResponse = WebhookData; @@ -828,7 +908,7 @@ export type WebhookTestResponse = { "delivery_id": string; }; export type WebhookRegenerateSecretResponse = { - "data": WebhookData; + "data": WebhookSecretData; "message": "Webhook secret regenerated successfully. Please update your integration."; }; export type WebhookDeliveriesResponse = { From a89e3f0be3861cb85c65ab6c7773f370d827f870 Mon Sep 17 00:00:00 2001 From: Bjarn Bronsveld Date: Tue, 29 Sep 2026 22:27:34 +0200 Subject: [PATCH 2/2] fix(api): retain scheduled message API surface --- src/api-surface.spec.ts | 59 +++++++++++++++++++++++++++ src/endpoints/api.ts | 15 +++++++ src/endpoints/email.spec.ts | 80 +++++++++++++++++++++++++++++++++++++ src/endpoints/email.ts | 67 ++++++++++++++++++++++++++++++- src/types.ts | 41 +++++++++---------- 5 files changed, 240 insertions(+), 22 deletions(-) diff --git a/src/api-surface.spec.ts b/src/api-surface.spec.ts index a14b2f0..439e292 100644 --- a/src/api-surface.spec.ts +++ b/src/api-surface.spec.ts @@ -139,6 +139,32 @@ describe('public SDK surface', () => { }) ); }); + + 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', () => { @@ -154,6 +180,9 @@ describe('api endpoint coverage', () => { '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', @@ -195,6 +224,8 @@ describe('api endpoint coverage', () => { it('exposes documented API operations', () => { const api = Lettermint.api('api-token') as unknown as Record; + expect(Object.keys(documentedMethods)).toHaveLength(50); + for (const exposedPath of Object.values(documentedMethods)) { const segments = exposedPath.split('.'); let cursor: unknown = api; @@ -294,6 +325,29 @@ describe('generated api types', () => { 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, @@ -312,6 +366,11 @@ describe('generated api types', () => { blockedFileTypes, teamRole, assignment, + messageTag, + cursorPaginator, + rescheduleRequest, + rescheduleResponse, + cancelResponse, }).toBeDefined(); }); }); diff --git a/src/endpoints/api.ts b/src/endpoints/api.ts index adfa0e5..f86113a 100644 --- a/src/endpoints/api.ts +++ b/src/endpoints/api.ts @@ -50,6 +50,21 @@ export class MessagesEndpoint extends Endpoint { 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`); } diff --git a/src/endpoints/email.spec.ts b/src/endpoints/email.spec.ts index e38ecb2..23585f2 100644 --- a/src/endpoints/email.spec.ts +++ b/src/endpoints/email.spec.ts @@ -209,6 +209,68 @@ describe('EmailEndpoint', () => { }); }); + 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'); @@ -416,4 +478,22 @@ describe('EmailEndpoint', () => { }, }); }); + + 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 index 517d97a..8105e72 100644 --- a/src/endpoints/email.ts +++ b/src/endpoints/email.ts @@ -94,6 +94,12 @@ export class EmailEndpoint extends Endpoint { 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 * @@ -176,9 +182,15 @@ export class EmailEndpoint extends Endpoint { * @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): this { + public attach( + filename: string, + content: string, + content_id?: string, + content_type?: string + ): this { if (!this.payload.attachments) { this.payload.attachments = []; } @@ -187,11 +199,23 @@ export class EmailEndpoint extends Endpoint { 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 * @@ -214,10 +238,41 @@ export class EmailEndpoint extends Endpoint { * @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. * @@ -248,7 +303,15 @@ export class EmailEndpoint extends Endpoint { } public async sendBatch(payload: SendBatchMailRequest): Promise { - return this.httpClient.post('/send/batch', payload); + 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 { diff --git a/src/types.ts b/src/types.ts index 2eb74b5..42903da 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,6 +4,12 @@ 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; @@ -16,10 +22,7 @@ export interface SendMailRequest { "headers"?: Record; "metadata"?: Record; "tag"?: string | null; - "tags"?: { - "name": string; - "value": string; -}[]; + "tags"?: MessageTag[]; "settings"?: { "track_opens"?: boolean; "track_clicks"?: boolean; @@ -48,10 +51,7 @@ export type SendBatchMailRequest = { "headers"?: Record; "metadata"?: Record; "tag"?: string | null; - "tags"?: { - "name": string; - "value": string; -}[]; + "tags"?: MessageTag[]; "settings"?: { "track_opens"?: boolean; "track_clicks"?: boolean; @@ -76,6 +76,16 @@ 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"; @@ -139,10 +149,7 @@ export interface MessageData { "status_changed_at": string | null; "scheduled_at": string | null; "tag": string | null; - "tags": { - "name": string; - "value": string; -}[]; + "tags": MessageTag[]; "from_email": string; "from_name": string | null; "reply_to": string[] | null; @@ -164,10 +171,7 @@ export interface MessageEventData { "message_id": string; "event": MessageEventType; "tag": string | null; - "tags": { - "name": string; - "value": string; -}[]; + "tags": MessageTag[]; "metadata": Record | null; "timestamp": string; } @@ -188,10 +192,7 @@ export interface MessageListData { "bcc": MessageRecipientData[] | null; "reply_to": string[] | null; "tag": string | null; - "tags": { - "name": string; - "value": string; -}[]; + "tags": MessageTag[]; "status_changed_at": string | null; "created_at": string; "delivery_mode": DeliveryMode;