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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,16 @@ const response = await email
foo: 'bar',
})
.tag('campaign-123')
.tags([
{ name: 'campaign', value: 'welcome' },
{ name: 'customer', value: 'new' },
])
.send();
```

`tag()` remains available for the legacy single tag. `tags()` accepts up to 20
case-sensitive name/value tags, or 19 when `tag()` is also set.

The legacy constructor still works for sending-only usage:

```typescript
Expand Down
22 changes: 22 additions & 0 deletions src/endpoints/email.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,28 @@ describe('EmailEndpoint', () => {
});
});

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
27 changes: 27 additions & 0 deletions src/endpoints/email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,9 @@ 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;
}
Expand All @@ -248,6 +251,30 @@ export class EmailEndpoint extends Endpoint {
* @returns The current instance for chaining
*/
public tags(tags: NonNullable<EmailPayload['tags']>): 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<string>();
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;
return this;
}
Expand Down
31 changes: 11 additions & 20 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -16,10 +22,7 @@ export interface SendMailRequest {
"headers"?: Record<string, string>;
"metadata"?: Record<string, string>;
"tag"?: string | null;
"tags"?: {
"name": string;
"value": string;
}[];
"tags"?: MessageTag[];
"settings"?: {
"track_opens"?: boolean;
"track_clicks"?: boolean;
Expand Down Expand Up @@ -47,10 +50,7 @@ export type SendBatchMailRequest = {
"headers"?: Record<string, string>;
"metadata"?: Record<string, string>;
"tag"?: string | null;
"tags"?: {
"name": string;
"value": string;
}[];
"tags"?: MessageTag[];
"settings"?: {
"track_opens"?: boolean;
"track_clicks"?: boolean;
Expand Down Expand Up @@ -145,10 +145,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;
Expand All @@ -168,10 +165,7 @@ export interface MessageEventData {
"message_id": string;
"event": MessageEventType;
"tag": string | null;
"tags": {
"name": string;
"value": string;
}[];
"tags": MessageTag[];
"metadata": Record<string, unknown> | null;
"timestamp": string;
}
Expand All @@ -192,10 +186,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;
}
Expand Down
Loading