diff --git a/README.md b/README.md index 8a61fa3..bd372f3 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/endpoints/email.spec.ts b/src/endpoints/email.spec.ts index 906b55e..9e49916 100644 --- a/src/endpoints/email.spec.ts +++ b/src/endpoints/email.spec.ts @@ -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 diff --git a/src/endpoints/email.ts b/src/endpoints/email.ts index fbb489b..02e0d6b 100644 --- a/src/endpoints/email.ts +++ b/src/endpoints/email.ts @@ -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; } @@ -248,6 +251,30 @@ export class EmailEndpoint extends Endpoint { * @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; return this; } diff --git a/src/types.ts b/src/types.ts index 4049259..41a7b0a 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; @@ -47,10 +50,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; @@ -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; @@ -168,10 +165,7 @@ export interface MessageEventData { "message_id": string; "event": MessageEventType; "tag": string | null; - "tags": { - "name": string; - "value": string; -}[]; + "tags": MessageTag[]; "metadata": Record | null; "timestamp": string; } @@ -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; }