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
91 changes: 68 additions & 23 deletions src/api-surface.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -221,6 +224,8 @@ describe('api endpoint coverage', () => {
it('exposes documented API operations', () => {
const api = Lettermint.api('api-token') as unknown as Record<string, unknown>;

expect(Object.keys(documentedMethods)).toHaveLength(50);

for (const exposedPath of Object.values(documentedMethods)) {
const segments = exposedPath.split('.');
let cursor: unknown = api;
Expand All @@ -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',
Expand All @@ -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',
Expand All @@ -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<Types.MessageData, 'delivery_mode' | 'sandbox_result'> = {
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<Types.WebhookDeliveryData, 'sandbox'> = {
sandbox: true,
};
const blockedFileTypes: Types.BlockedFileTypesResponse = {
extensions: ['exe'],
Expand All @@ -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,
Expand All @@ -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();
});
});
2 changes: 1 addition & 1 deletion src/endpoints/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export class MessagesEndpoint extends Endpoint {
return this.httpClient.patch(`/messages/${this.pathSegment(messageId)}`, payload);
}

public cancel(messageId: string): Promise<Types.RescheduleMessageResponse> {
public cancel(messageId: string): Promise<Types.CancelScheduledMessageResponse> {
return this.httpClient.post(`/messages/${this.pathSegment(messageId)}/cancel`);
}

Expand Down
61 changes: 29 additions & 32 deletions src/endpoints/email.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,10 +150,7 @@ describe('EmailEndpoint', () => {
});

it('should set custom headers', () => {
const headers = {
'Message-ID': '<ticket-123@example.com>',
'X-LM-Preserve-Message-ID': 'true',
};
const headers = { 'X-Custom': 'Value', 'X-Another': 'Another Value' };
const result = emailEndpoint.headers(headers);

expect(result).toBe(emailEndpoint);
Expand Down Expand Up @@ -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');

Expand Down Expand Up @@ -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
Expand Down
31 changes: 18 additions & 13 deletions src/endpoints/email.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type {
EmailPayload,
SandboxResult,
SendBatchEmailResponse,
SendBatchMailRequest,
SendEmailResponse,
Expand Down Expand Up @@ -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<EmailPayload['tags']>): 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<string>();
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);
}
Expand All @@ -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
*
Expand Down
Loading
Loading