diff --git a/src/api-surface.spec.ts b/src/api-surface.spec.ts index 19f367d..439e292 100644 --- a/src/api-surface.spec.ts +++ b/src/api-surface.spec.ts @@ -133,11 +133,14 @@ 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) }) + expect.objectContaining({ + method: 'PUT', + body: JSON.stringify(assignment), + }) ); }); - it('reschedules and cancels scheduled messages', async () => { + 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'); @@ -221,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; @@ -237,18 +242,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 +266,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 +279,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 +325,29 @@ 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 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 sourceMessage: Types.SuppressionSourceMessageData = { - id: 'msg_123', - available: true, - subject: 'Blocked', - created_at: '2026-08-12T00:00:00Z', + const cancelResponse: Types.CancelScheduledMessageResponse = { + message_id: 'message_123', + status: 'canceled', + scheduled_at: null, }; - const spamScore: Types.MessageListData['spam_score'] = 2.5; - const scheduledAt: Types.SendMailRequest['scheduled_at'] = '2026-08-27T09:00:00Z'; expect({ messageEvent, @@ -319,13 +358,19 @@ describe('generated api types', () => { projectCreate, project, projectUpdate, + sendRequest, + sendResponse, + messageMode, + webhookCreate, + webhookDelivery, blockedFileTypes, teamRole, assignment, - domain, - sourceMessage, - spamScore, - scheduledAt, + messageTag, + cursorPaginator, + rescheduleRequest, + rescheduleResponse, + cancelResponse, }).toBeDefined(); }); }); diff --git a/src/endpoints/api.ts b/src/endpoints/api.ts index c04656b..f86113a 100644 --- a/src/endpoints/api.ts +++ b/src/endpoints/api.ts @@ -57,7 +57,7 @@ export class MessagesEndpoint extends Endpoint { return this.httpClient.patch(`/messages/${this.pathSegment(messageId)}`, payload); } - public cancel(messageId: string): Promise { + public cancel(messageId: string): Promise { return this.httpClient.post(`/messages/${this.pathSegment(messageId)}/cancel`); } diff --git a/src/endpoints/email.spec.ts b/src/endpoints/email.spec.ts index 9e49916..23585f2 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); @@ -235,24 +232,45 @@ describe('EmailEndpoint', () => { }); }); - it('should set per-email settings', () => { + 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({ settings }), + 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'); @@ -303,43 +321,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 diff --git a/src/endpoints/email.ts b/src/endpoints/email.ts index 02e0d6b..8105e72 100644 --- a/src/endpoints/email.ts +++ b/src/endpoints/email.ts @@ -1,5 +1,6 @@ import type { EmailPayload, + SandboxResult, SendBatchEmailResponse, SendBatchMailRequest, SendEmailResponse, @@ -244,33 +245,26 @@ export class EmailEndpoint extends Endpoint { return this; } - /** - * Set reusable name-value tags for the email. - * - * @param tags The tags to attach to the email - * @returns The current instance for chaining - */ + /** Set reusable name-value tags for the email. */ public tags(tags: NonNullable): this { - const maximum = this.payload.tag == null ? 20 : 19; + const maximum = this.payload.tag ? 19 : 20; if (tags.length > maximum) { - throw new TypeError( - `No more than ${maximum} message tags are permitted with the current legacy tag` - ); + throw new TypeError(`No more than ${maximum} message tags are permitted`); } 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)) { + 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 (tag.value.length < 1 || tag.value.length > 64 || !/^[A-Za-z0-9_-]+$/.test(tag.value)) { + 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(`Duplicate message tag name: ${tag.name}`); + throw new TypeError('Message tag names must be unique and case-sensitive'); } names.add(tag.name); } @@ -279,6 +273,17 @@ export class EmailEndpoint extends Endpoint { 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 * diff --git a/src/types.ts b/src/types.ts index 41a7b0a..42903da 100644 --- a/src/types.ts +++ b/src/types.ts @@ -36,6 +36,7 @@ export interface SendMailRequest { "content_type"?: string | null; "content_id"?: string | null; }[]; + "sandbox_result"?: SandboxResult; } export type SendBatchMailRequest = { @@ -64,10 +65,13 @@ 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"; @@ -159,6 +163,8 @@ export interface MessageData { "spam_symbols"?: SpamSymbol[]; "route_id": string; "created_at": string; + "delivery_mode": DeliveryMode; + "sandbox_result": SandboxResult | null; } export interface MessageEventData { @@ -189,11 +195,14 @@ export interface MessageListData { "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 { @@ -225,6 +234,7 @@ export interface ProjectData { "last_28_days"?: MessageStatsData | null; "created_at": string; "updated_at": string; + "delivery_mode": DeliveryMode; } export interface ProjectListData { @@ -244,6 +254,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 +266,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 +399,7 @@ export interface StoreProjectData { "smtp_enabled"?: boolean; "initial_routes"?: InitialRoutes; "short_token"?: boolean; + "delivery_mode"?: DeliveryMode; } export interface StoreRouteData { @@ -399,15 +415,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 +437,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 +531,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 +575,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 +616,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 +636,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 +735,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 +758,6 @@ export type MessageIndexResponse = { "prev_cursor_url": string | null; }; }; -export type MessageShowResponse = MessageData; export type MessageEventsResponse = { "data": MessageEventData[]; "links": string[]; @@ -694,7 +771,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 +892,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 +909,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 = {