diff --git a/.changeset/node-email-send-code-content.md b/.changeset/node-email-send-code-content.md new file mode 100644 index 0000000000..4cb00f481d --- /dev/null +++ b/.changeset/node-email-send-code-content.md @@ -0,0 +1,5 @@ +--- +'posthog-node': minor +--- + +Send email content your code rendered with `posthog.emails.send()`: pass `templateKey`, `subject`, and `html`, `text` or both, with an optional `from` and `replyTo`. A send with `templateKey` captures engagement events unless it passes `captureEngagement: false`; a template made in PostHog follows the project setting. The new `migratedFrom` client option names the email provider you moved from. diff --git a/packages/node/references/posthog-node-references-latest.json b/packages/node/references/posthog-node-references-latest.json index f148cab716..360c578e07 100644 --- a/packages/node/references/posthog-node-references-latest.json +++ b/packages/node/references/posthog-node-references-latest.json @@ -2035,6 +2035,43 @@ "path": "../core/src/error-tracking/types.ts", "example": "Record" }, + { + "id": "CodeEmailFields", + "name": "CodeEmailFields", + "properties": [ + { + "description": "Whether PostHog captures the email's engagement events (sent, delivered, opened, clicked, bounced) as events of the recipient. Defaults to `true`. A project whose admin turned engagement capture off captures nothing, whatever the send asks. A template made in PostHog follows the project's setting.", + "type": "boolean", + "name": "captureEngagement" + }, + { + "description": "A verified sender, such as `'Acme '`. Without it, the project's default sender.", + "type": "string", + "name": "from" + }, + { + "type": "string | string[]", + "name": "replyTo" + }, + { + "type": "string", + "name": "subject" + }, + { + "description": "Names the email, such as `'password-reset'`, so its metrics, logs and status group under it in PostHog. Letters, digits, hyphens and underscores, at most 400.", + "type": "string", + "name": "templateKey" + } + ], + "path": "src/emails/types.ts" + }, + { + "id": "CodeEmailForm", + "name": "CodeEmailForm", + "properties": [], + "path": "src/emails/types.ts", + "example": "CodeEmailFields & ({\n html: string;\n text?: string;\n} | {\n html?: string;\n text: string;\n})" + }, { "id": "Coercer", "name": "Coercer", @@ -3876,6 +3913,11 @@ "type": "number", "name": "emailRequestMaxRetries" }, + { + "description": "The provider your email moved from, such as `'resend'`. Sent with every `posthog.emails` request.", + "type": "string", + "name": "migratedFrom" + }, { "description": "Redact prompts, outputs and error details from every event that the PostHog AI\nintegrations (`@posthog/ai`) capture with this client. Per-call privacy settings\ncan only add redaction. Defaults to false.", "type": "boolean", @@ -4336,8 +4378,15 @@ "path": "../core/src/utils/index.ts" }, { - "id": "SendEmailOptions", - "name": "SendEmailOptions", + "id": "SendCodeEmailOptions", + "name": "SendCodeEmailOptions", + "properties": [], + "path": "src/emails/types.ts", + "example": "SendEmailBase & CodeEmailForm & Without" + }, + { + "id": "SendEmailBase", + "name": "SendEmailBase", "properties": [ { "description": "The recipient's distinct ID in your product. The email's events go to this person. Without it, they go to a person whose distinct ID is the email address.", @@ -4345,7 +4394,7 @@ "name": "distinctId" }, { - "description": "1 to 255 visible ASCII characters. A send with a key that PostHog has already accepted queues nothing and returns the first send's result. Without it, the client makes a UUID that covers its own retries. Pass a key derived from what the email is for, such as `welcome:user_123`, to cover retries outside this call too.", + "description": "1 to 255 visible ASCII characters. A send with a key that PostHog has already accepted queues nothing and returns the first send's result. The same key with other options sends nothing and returns an error with code `idempotency_key_reused`. Without it, the client makes a UUID that covers its own retries. Pass a key derived from what the email is for, such as `welcome:user_123`, to cover retries outside this call too.", "type": "string", "name": "idempotencyKey" }, @@ -4354,16 +4403,6 @@ "type": "number", "name": "maxRetries" }, - { - "description": "Values for the template's `{{ variables.* }}` placeholders. Sent as `message_data`.", - "type": "Record", - "name": "templateData" - }, - { - "description": "The UUID of an email template made transactional in PostHog.", - "type": "string", - "name": "templateId" - }, { "description": "One recipient email address.", "type": "string", @@ -4372,6 +4411,13 @@ ], "path": "src/emails/types.ts" }, + { + "id": "SendEmailOptions", + "name": "SendEmailOptions", + "properties": [], + "path": "src/emails/types.ts", + "example": "SendTemplateEmailOptions | SendCodeEmailOptions" + }, { "id": "SendEmailResponse", "name": "SendEmailResponse", @@ -4437,6 +4483,74 @@ "path": "../core/src/metrics/types.ts", "example": "{\n kind: 'ok';\n} | {\n kind: 'retry-later';\n error: unknown;\n retryAfterMs?: number;\n} | {\n kind: 'too-large';\n measuredLocally?: boolean;\n} | {\n kind: 'fatal';\n error: unknown;\n}" }, + { + "id": "SendTemplateEmailOptions", + "name": "SendTemplateEmailOptions", + "properties": [ + { + "description": "One recipient email address.", + "type": "string", + "name": "to" + }, + { + "description": "The recipient's distinct ID in your product. The email's events go to this person.\nWithout it, they go to a person whose distinct ID is the email address.", + "type": "string", + "name": "distinctId" + }, + { + "description": "1 to 255 visible ASCII characters. A send with a key that PostHog has already accepted\nqueues nothing and returns the first send's result. The same key with other options sends\nnothing and returns an error with code `idempotency_key_reused`. Without it, the client makes a UUID that\ncovers its own retries. Pass a key derived from what the email is for, such as\n`welcome:user_123`, to cover retries outside this call too.", + "type": "string", + "name": "idempotencyKey" + }, + { + "description": "Overrides the client's `emailRequestMaxRetries` for this send. `0` turns retries off.", + "type": "number", + "name": "maxRetries" + }, + { + "description": "The UUID of an email template made transactional in PostHog.", + "type": "string", + "name": "templateId" + }, + { + "description": "Values for the template's `{{ variables.* }}` placeholders. Sent as `message_data`.", + "type": "Record", + "name": "templateData" + }, + { + "description": "Names the email, such as `'password-reset'`, so its metrics, logs and status group under it\nin PostHog. Letters, digits, hyphens and underscores, at most 400.", + "type": "never", + "name": "templateKey" + }, + { + "type": "never", + "name": "subject" + }, + { + "description": "A verified sender, such as `'Acme '`. Without it, the project's default sender.", + "type": "never", + "name": "from" + }, + { + "type": "never", + "name": "replyTo" + }, + { + "description": "Whether PostHog captures the email's engagement events (sent, delivered, opened, clicked,\nbounced) as events of the recipient. Defaults to `true`. A project whose admin turned\nengagement capture off captures nothing, whatever the send asks. A template made in PostHog\nfollows the project's setting.", + "type": "never", + "name": "captureEngagement" + }, + { + "type": "never", + "name": "html" + }, + { + "type": "never", + "name": "text" + } + ], + "path": "src/emails/types.ts" + }, { "id": "SendTracesBatchOutcome", "name": "SendTracesBatchOutcome", @@ -5082,6 +5196,23 @@ "path": "../core/src/types.ts", "example": "(typeof SurveyWidgetType)[keyof typeof SurveyWidgetType]" }, + { + "id": "TemplateEmailForm", + "name": "TemplateEmailForm", + "properties": [ + { + "description": "Values for the template's `{{ variables.* }}` placeholders. Sent as `message_data`.", + "type": "Record", + "name": "templateData" + }, + { + "description": "The UUID of an email template made transactional in PostHog.", + "type": "string", + "name": "templateId" + } + ], + "path": "src/emails/types.ts" + }, { "id": "UnresolvedFlagReason", "name": "UnresolvedFlagReason", @@ -5103,6 +5234,13 @@ } ], "path": "src/types.ts" + }, + { + "id": "Without", + "name": "Without", + "properties": [], + "path": "src/emails/types.ts", + "example": "{\n [K in keyof T]?: never;\n}" } ], "categories": [ diff --git a/packages/node/src/__tests__/emails.spec.ts b/packages/node/src/__tests__/emails.spec.ts index 09e078f8e6..3ae07d2864 100644 --- a/packages/node/src/__tests__/emails.spec.ts +++ b/packages/node/src/__tests__/emails.spec.ts @@ -1,4 +1,4 @@ -import { EmailError, PostHog, PostHogOptions } from '@/entrypoints/index.node' +import { EmailError, PostHog, PostHogOptions, SendEmailOptions } from '@/entrypoints/index.node' vi.mock('../version', () => ({ version: '1.2.3' })) @@ -122,6 +122,98 @@ describe('emails', () => { }) }) + it.each([ + [ + 'html', + { templateKey: 'password-reset', subject: 'Reset your password', html: '

Reset

' }, + { + template_key: 'password-reset', + subject: 'Reset your password', + html: '

Reset

', + capture_engagement: true, + }, + ], + [ + 'text, a sender, one reply-to address and engagement capture turned off', + { + templateKey: 'password-reset', + subject: 'Reset', + text: 'Reset', + from: 'Security ', + replyTo: 'help@example.com', + captureEngagement: false, + }, + { + template_key: 'password-reset', + subject: 'Reset', + text: 'Reset', + from: 'Security ', + reply_to: ['help@example.com'], + capture_engagement: false, + }, + ], + [ + 'html, text and reply-to addresses', + { + templateKey: 'password-reset', + subject: 'Reset', + html: '

Reset

', + text: 'Reset', + replyTo: ['a@example.com', 'b@example.com'], + }, + { + template_key: 'password-reset', + subject: 'Reset', + html: '

Reset

', + text: 'Reset', + reply_to: ['a@example.com', 'b@example.com'], + capture_engagement: true, + }, + ], + [ + 'an empty reply-to list, which it leaves out', + { + templateKey: 'password-reset', + subject: 'Reset', + html: '

Reset

', + replyTo: [], + }, + { + template_key: 'password-reset', + subject: 'Reset', + html: '

Reset

', + capture_engagement: true, + }, + ], + ])('posts content from code with %s to the same send URL', async (_name, content, body) => { + replies = [accepted()] + + const { data, error } = await posthog.emails.send({ to: 'user@example.com', distinctId: 'user_123', ...content }) + + expect(error).toBeNull() + expect(data?.id).toBe('msg_1') + const [url, options] = fetch.mock.calls[0] + expect(url).toBe(SEND_URL) + expect(JSON.parse(options.body)).toEqual({ + ...body, + to: 'user@example.com', + distinct_id: 'user_123', + }) + }) + + it('names the provider the client moved from on every request', async () => { + makeClient({ migratedFrom: 'resend' }) + replies = [accepted(), { status: 200, body: { id: MESSAGE_ID, status: 'sent', created_at: 'a', status_at: 'b' } }] + + await send() + await posthog.emails.get(MESSAGE_ID) + + expect(fetch.mock.calls.map(([, options]) => options.headers['X-PostHog-Migrated-From'])).toEqual([ + 'resend', + 'resend', + ]) + }) + it.each([ [undefined, undefined, 'https://us.posthog.com'], ['https://us.i.posthog.com', undefined, 'https://us.posthog.com'], @@ -402,6 +494,9 @@ describe('emails', () => { ['a key with a space', { idempotencyKey: 'welcome user' }, 'invalid_idempotency_key'], ['a key that is too long', { idempotencyKey: 'k'.repeat(256) }, 'invalid_idempotency_key'], ['negative retries', { maxRetries: -1 }, 'invalid_max_retries'], + ['neither templateId nor templateKey', { templateId: undefined }, 'invalid_form'], + ['both templateId and templateKey', { templateKey: 'welcome', subject: 'Hi', html: '

Hi

' }, 'invalid_form'], + ['a template key with a space', { templateId: undefined, templateKey: 'pass reset' }, 'invalid_template_key'], ])('rejects %s before sending', async (_name, overrides, code) => { const { data, error } = await send(overrides) @@ -410,6 +505,88 @@ describe('emails', () => { expect(fetch).not.toHaveBeenCalled() }) + describe('the send form', () => { + const CODE_SEND = { templateKey: 'welcome', subject: 'Hi', html: '

Hi

' } + + const sendForm = (form: Record): ReturnType => + posthog.emails.send({ to: 'user@example.com', ...form } as unknown as SendEmailOptions) + + it.each([ + ['from on the template form', { templateId: TEMPLATE_ID, from: 'Acme ' }, 'from'], + ['replyTo on the template form', { templateId: TEMPLATE_ID, replyTo: 'help@example.com' }, 'replyTo'], + ['subject on the template form', { templateId: TEMPLATE_ID, subject: 'Hi' }, 'subject'], + ['html on the template form', { templateId: TEMPLATE_ID, html: '

Hi

' }, 'html'], + ['text on the template form', { templateId: TEMPLATE_ID, text: 'Hi' }, 'text'], + [ + 'captureEngagement on the template form', + { templateId: TEMPLATE_ID, captureEngagement: false }, + 'captureEngagement', + ], + ['a code send without a subject', { templateKey: 'welcome', html: '

Hi

' }, 'subject'], + ['a code send without html or text', { templateKey: 'welcome', subject: 'Hi' }, 'html'], + ['a code send with html that is not a string', { ...CODE_SEND, html: 1 }, 'html'], + ['template data on a code send', { ...CODE_SEND, templateData: { name: 'Jane' } }, 'templateData'], + [ + "Resend's scheduledAt on the template form", + { templateId: TEMPLATE_ID, scheduledAt: 'in 1 hour' }, + 'scheduledAt', + ], + ["Resend's scheduledAt on a code send", { ...CODE_SEND, scheduledAt: 'in 1 hour' }, 'scheduledAt'], + ])('refuses %s before sending', async (_name, form, field) => { + const { data, error } = await sendForm(form) + + expect(data).toBeNull() + expect(error).toMatchObject({ code: 'invalid_form', name: 'validation_error', field }) + expect(fetch).not.toHaveBeenCalled() + }) + + it.each([ + [ + 'a sender on the template form', + { templateId: TEMPLATE_ID, from: 'Acme ' }, + "A template made in PostHog sets its own sender, subject and content, and follows the project's engagement setting.", + 'Remove from, or send content from your code with templateKey.', + ], + [ + 'engagement capture on the template form', + { templateId: TEMPLATE_ID, captureEngagement: true }, + "A template made in PostHog sets its own sender, subject and content, and follows the project's engagement setting.", + 'Remove captureEngagement, or send content from your code with templateKey.', + ], + [ + 'template data on a code send', + { ...CODE_SEND, templateData: { name: 'Jane' } }, + 'A send with templateKey takes its content from your code.', + 'Remove templateData, or send a template made in PostHog with templateId.', + ], + [ + 'a field no form takes', + { ...CODE_SEND, scheduledAt: 'in 1 hour' }, + undefined, + 'Remove scheduledAt and send again.', + ], + ])('explains why it refuses %s and how to send it', async (_name, form, why, fix) => { + const { error } = await sendForm(form) + + expect({ why: error?.why, fix: error?.fix }).toEqual({ why, fix }) + }) + + it.each([ + ["Resend's second options argument", { idempotencyKey: 'welcome:user_123' }, 0], + ['an undefined second argument from a wrapper', undefined, 1], + ])('sends with %s: %s requests', async (_name, secondArgument, requests) => { + replies = [accepted()] + const sendWithTwoArguments = posthog.emails.send.bind(posthog.emails) as ( + ...args: unknown[] + ) => ReturnType + + const { error } = await sendWithTwoArguments({ to: 'user@example.com', templateId: TEMPLATE_ID }, secondArgument) + + expect(fetch).toHaveBeenCalledTimes(requests) + expect(error?.code ?? null).toBe(requests ? null : 'invalid_form') + }) + }) + describe('get', () => { const statusReply = (fields: Record = {}): Reply => ({ status: 200, diff --git a/packages/node/src/__tests__/types/emails-forms.ts b/packages/node/src/__tests__/types/emails-forms.ts new file mode 100644 index 0000000000..64b43469c4 --- /dev/null +++ b/packages/node/src/__tests__/types/emails-forms.ts @@ -0,0 +1,25 @@ +import type { SendEmailOptions } from '../../exports' + +const to = 'jane@example.com' +const templateId = '0190f6d2-7c1e-4b3a-9f5e-1c2d3e4f5a6b' + +export const forms: SendEmailOptions[] = [ + { to, templateId, templateData: { name: 'Jane' } }, + { to, templateKey: 'reset', subject: 'Reset', html: '

Reset

', replyTo: ['help@example.com'] }, + { to, templateKey: 'reset', subject: 'Reset', text: 'Reset', from: 'security@example.com', captureEngagement: false }, +] + +export const wrongCombinations: SendEmailOptions[] = [ + // @ts-expect-error a send takes exactly one form + { to, templateId, templateKey: 'reset', subject: 'Reset', html: '

R

' }, + // @ts-expect-error a template made in PostHog follows the project's engagement capture setting + { to, templateId, captureEngagement: false }, + // @ts-expect-error a template made in PostHog keeps its own sender + { to, templateId, from: 'security@example.com' }, + // @ts-expect-error the code form needs html, text or both + { to, templateKey: 'reset', subject: 'Reset' }, + // @ts-expect-error the code form needs a subject + { to, templateKey: 'reset', html: '

Reset

' }, + // @ts-expect-error content from code takes no template data + { to, templateKey: 'reset', subject: 'Reset', html: '

Reset

', templateData: { name: 'J' } }, +] diff --git a/packages/node/src/client.ts b/packages/node/src/client.ts index 16c2cbf9f8..21be469de3 100644 --- a/packages/node/src/client.ts +++ b/packages/node/src/client.ts @@ -726,6 +726,7 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen appHost: this.options.appHost, requestTimeoutMs: positiveNumberOr(this.options.emailRequestTimeoutMs, DEFAULT_EMAIL_REQUEST_TIMEOUT_MS), maxRetries: nonNegativeIntegerOr(this.options.emailRequestMaxRetries, DEFAULT_EMAIL_REQUEST_MAX_RETRIES), + migratedFrom: this.options.migratedFrom, }, { fetch: (url, fetchOptions) => this.fetch(url, fetchOptions), diff --git a/packages/node/src/emails/emails.ts b/packages/node/src/emails/emails.ts index ae50668d15..0f55e601b3 100644 --- a/packages/node/src/emails/emails.ts +++ b/packages/node/src/emails/emails.ts @@ -9,13 +9,17 @@ import { import { EmailError } from './errors' import type { + CodeEmailForm, EmailStatus, EmailWarning, Emails, GetEmailResponse, + SendEmailBase, SendEmailOptions, SendEmailResponse, SendEmailResult, + SendTemplateEmailOptions, + TemplateEmailForm, } from './types' const SEND_PATH = '/api/projects/@current/messaging/send/email/' @@ -32,6 +36,46 @@ const CLOUD_APP_HOSTS: Record = { const MAX_RETRY_DELAY_MS = 30_000 const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i const IDEMPOTENCY_KEY_PATTERN = /^[\x21-\x7e]{1,255}$/ +const TEMPLATE_KEY_PATTERN = /^[a-zA-Z0-9_-]{1,400}$/ + +type EveryField
= Record +type UncheckedSend = { [Field in keyof SendTemplateEmailOptions]?: unknown } + +interface SendForm { + name: 'templateId' | 'templateKey' + fields: ReadonlySet + why: string + howToSend: string +} + +const SHARED_FIELDS: EveryField = { + to: true, + distinctId: true, + idempotencyKey: true, + maxRetries: true, +} +const TEMPLATE_FIELDS: EveryField = { templateId: true, templateData: true } +const CODE_FIELDS: EveryField = { + templateKey: true, + subject: true, + from: true, + replyTo: true, + captureEngagement: true, + html: true, + text: true, +} +const TEMPLATE_FORM: SendForm = { + name: 'templateId', + fields: fieldSet(SHARED_FIELDS, TEMPLATE_FIELDS), + why: "A template made in PostHog sets its own sender, subject and content, and follows the project's engagement setting.", + howToSend: 'send a template made in PostHog with templateId', +} +const CODE_FORM: SendForm = { + name: 'templateKey', + fields: fieldSet(SHARED_FIELDS, CODE_FIELDS), + why: 'A send with templateKey takes its content from your code.', + howToSend: 'send content from your code with templateKey', +} export const DEFAULT_EMAIL_REQUEST_TIMEOUT_MS = 15_000 export const DEFAULT_EMAIL_REQUEST_MAX_RETRIES = 2 @@ -43,6 +87,7 @@ export interface EmailSenderConfig { appHost?: string requestTimeoutMs: number maxRetries: number + migratedFrom?: string } export interface EmailSenderHooks { @@ -81,8 +126,8 @@ export class EmailSender implements Emails { this.hooks = hooks } - send(options: SendEmailOptions): Promise { - return this.track(this.sendUntracked(options)) + send(options: SendEmailOptions, ...unexpected: unknown[]): Promise { + return this.track(this.sendUntracked(options, unexpected)) } get(id: string): Promise { @@ -105,8 +150,9 @@ export class EmailSender implements Emails { } } - private async sendUntracked(options: SendEmailOptions): Promise { + private async sendUntracked(options: SendEmailOptions, unexpected: unknown[]): Promise { try { + refuseSecondArgument(unexpected) const prepared = prepareSend(options, this.config.maxRetries) const { appHost, secretKey } = this.connect( 'no email was sent', @@ -186,6 +232,9 @@ export class EmailSender implements Emails { if (this.hooks.userAgent) { headers['User-Agent'] = this.hooks.userAgent } + if (this.config.migratedFrom) { + headers['X-PostHog-Migrated-From'] = this.config.migratedFrom + } return headers } @@ -274,24 +323,25 @@ export class EmailSender implements Emails { } } +function refuseSecondArgument(unexpected: unknown[]): void { + if (unexpected.some((argument) => argument !== undefined)) { + throw new EmailError({ + code: 'invalid_form', + message: 'send takes one options object, so no email was sent. Put idempotencyKey and every other option in it.', + }) + } +} + function prepareSend(options: SendEmailOptions, defaultMaxRetries: number): PreparedSend { - const fallback: Partial = {} - const { to, templateId, templateData, distinctId, idempotencyKey, maxRetries } = options ?? fallback + const send: UncheckedSend = options ?? {} + const { to, distinctId, idempotencyKey, maxRetries } = send if (typeof to !== 'string' || !to.trim()) { throw invalid('invalid_recipient', 'to must be one email address as a nonempty string.', 'to') } - if (typeof templateId !== 'string' || !UUID_PATTERN.test(templateId)) { - throw invalid('invalid_template_id', "templateId must be the template's UUID.", 'templateId') - } - if ( - templateData !== undefined && - (typeof templateData !== 'object' || templateData === null || Array.isArray(templateData)) - ) { - throw invalid('invalid_template_data', 'templateData must be an object.', 'templateData') - } if (distinctId !== undefined && (typeof distinctId !== 'string' || !distinctId)) { throw invalid('invalid_distinct_id', 'distinctId must be a nonempty string.', 'distinctId') } + const content = contentOf(send, distinctId) if ( idempotencyKey !== undefined && (typeof idempotencyKey !== 'string' || !IDEMPOTENCY_KEY_PATTERN.test(idempotencyKey)) @@ -302,26 +352,108 @@ function prepareSend(options: SendEmailOptions, defaultMaxRetries: number): Prep 'idempotencyKey' ) } - if (maxRetries !== undefined && !(Number.isInteger(maxRetries) && maxRetries >= 0)) { + if (maxRetries !== undefined && !isRetryCount(maxRetries)) { throw invalid('invalid_max_retries', 'maxRetries must be a nonnegative integer.', 'maxRetries') } - const payload: Record = { + return { + path: SEND_PATH, + body: safeJsonStringify({ ...content, to }), + idempotencyKey: idempotencyKey ?? globalThis.crypto.randomUUID(), + maxRetries: isRetryCount(maxRetries) ? maxRetries : defaultMaxRetries, + } +} + +function isRetryCount(value: unknown): value is number { + return typeof value === 'number' && Number.isInteger(value) && value >= 0 +} + +function contentOf(send: UncheckedSend, distinctId: string | undefined): Record { + if ((send.templateId === undefined) === (send.templateKey === undefined)) { + throw new EmailError({ + code: 'invalid_form', + message: 'Send either templateId, or templateKey with subject and html or text.', + }) + } + return send.templateKey === undefined ? templateContent(send, distinctId) : codeContent(send, distinctId) +} + +function templateContent(send: UncheckedSend, distinctId: string | undefined): Record { + const { templateId, templateData } = send + if (typeof templateId !== 'string' || !UUID_PATTERN.test(templateId)) { + throw invalid('invalid_template_id', "templateId must be the template's UUID.", 'templateId') + } + if ( + templateData !== undefined && + (typeof templateData !== 'object' || templateData === null || Array.isArray(templateData)) + ) { + throw invalid('invalid_template_data', 'templateData must be an object.', 'templateData') + } + refuseFieldsOutside(send, TEMPLATE_FORM, CODE_FORM) + return { template_id: templateId.toLowerCase(), - to, message_data: templateData ?? {}, + ...(distinctId !== undefined && { distinct_id: distinctId }), } - if (distinctId !== undefined) { - payload.distinct_id = distinctId +} + +function codeContent(send: UncheckedSend, distinctId: string | undefined): Record { + const { templateKey, subject, html, text, from, replyTo, captureEngagement } = send + if (typeof templateKey !== 'string' || !TEMPLATE_KEY_PATTERN.test(templateKey)) { + throw invalid( + 'invalid_template_key', + 'templateKey can only have letters, digits, hyphens (-) and underscores (_), at most 400.', + 'templateKey' + ) + } + refuseFieldsOutside(send, CODE_FORM, TEMPLATE_FORM) + if (typeof subject !== 'string') { + throw invalid('invalid_form', 'A send with templateKey needs a subject.', 'subject') + } + if (html !== undefined && typeof html !== 'string') { + throw invalid('invalid_form', 'html must be a string.', 'html') } + if (text !== undefined && typeof text !== 'string') { + throw invalid('invalid_form', 'text must be a string.', 'text') + } + if (html === undefined && text === undefined) { + throw invalid('invalid_form', 'A send with templateKey needs html, text or both.', 'html') + } + const sendsReplyTo = replyTo !== undefined && !(Array.isArray(replyTo) && replyTo.length === 0) return { - path: SEND_PATH, - body: safeJsonStringify(payload), - idempotencyKey: idempotencyKey ?? globalThis.crypto.randomUUID(), - maxRetries: maxRetries ?? defaultMaxRetries, + template_key: templateKey, + subject, + ...(html !== undefined && { html }), + ...(text !== undefined && { text }), + ...(from !== undefined && { from }), + ...(sendsReplyTo && { reply_to: typeof replyTo === 'string' ? [replyTo] : replyTo }), + capture_engagement: captureEngagement ?? true, + ...(distinctId !== undefined && { distinct_id: distinctId }), } } +function refuseFieldsOutside(send: UncheckedSend, form: SendForm, otherForm: SendForm): void { + const field = Object.entries(send).find(([name, value]) => value !== undefined && !form.fields.has(name))?.[0] + if (field === undefined) { + return + } + const message = `A send with ${form.name} doesn't take ${field}, so no email was sent.` + if (!otherForm.fields.has(field)) { + throw new EmailError({ code: 'invalid_form', message, field, fix: `Remove ${field} and send again.` }) + } + throw new EmailError({ + code: 'invalid_form', + message, + field, + why: form.why, + fix: `Remove ${field}, or ${otherForm.howToSend}.`, + }) +} + +function fieldSet(...groups: object[]): ReadonlySet { + return new Set(groups.flatMap((group) => Object.keys(group))) +} + function invalid(code: string, message: string, field: string): EmailError { return new EmailError({ code, message, field }) } diff --git a/packages/node/src/emails/types.ts b/packages/node/src/emails/types.ts index 6b532ce411..ad65911f48 100644 --- a/packages/node/src/emails/types.ts +++ b/packages/node/src/emails/types.ts @@ -1,15 +1,9 @@ import type { EmailError } from './errors' -/** - * Options for `posthog.emails.send()`. - */ -export interface SendEmailOptions { +/** What every send takes, whatever its form. */ +export interface SendEmailBase { /** One recipient email address. */ to: string - /** The UUID of an email template made transactional in PostHog. */ - templateId: string - /** Values for the template's `{{ variables.* }}` placeholders. Sent as `message_data`. */ - templateData?: Record /** * The recipient's distinct ID in your product. The email's events go to this person. * Without it, they go to a person whose distinct ID is the email address. @@ -17,15 +11,61 @@ export interface SendEmailOptions { distinctId?: string /** * 1 to 255 visible ASCII characters. A send with a key that PostHog has already accepted - * queues nothing and returns the first send's result. Without it, the client makes a - * UUID that covers its own retries. Pass a key derived from what the email is for, such - * as `welcome:user_123`, to cover retries outside this call too. + * queues nothing and returns the first send's result. The same key with other options sends + * nothing and returns an error with code `idempotency_key_reused`. Without it, the client makes a UUID that + * covers its own retries. Pass a key derived from what the email is for, such as + * `welcome:user_123`, to cover retries outside this call too. */ idempotencyKey?: string /** Overrides the client's `emailRequestMaxRetries` for this send. `0` turns retries off. */ maxRetries?: number } +/** A template made transactional in PostHog, sent by its ID. */ +export interface TemplateEmailForm { + /** The UUID of an email template made transactional in PostHog. */ + templateId: string + /** Values for the template's `{{ variables.* }}` placeholders. Sent as `message_data`. */ + templateData?: Record +} + +interface CodeEmailFields { + /** + * Names the email, such as `'password-reset'`, so its metrics, logs and status group under it + * in PostHog. Letters, digits, hyphens and underscores, at most 400. + */ + templateKey: string + subject: string + /** A verified sender, such as `'Acme '`. Without it, the project's default sender. */ + from?: string + replyTo?: string | string[] + /** + * Whether PostHog captures the email's engagement events (sent, delivered, opened, clicked, + * bounced) as events of the recipient. Defaults to `true`. A project whose admin turned + * engagement capture off captures nothing, whatever the send asks. A template made in PostHog + * follows the project's setting. + */ + captureEngagement?: boolean +} + +/** + * Email content your code rendered, such as React Email output, named by a key. PostHog sends it + * as is and never stores it. Needs `html`, `text` or both. PostHog derives a missing `text` from + * the `html`. + */ +export type CodeEmailForm = CodeEmailFields & ({ html: string; text?: string } | { html?: string; text: string }) + +type Without = { [K in keyof T]?: never } + +export type SendTemplateEmailOptions = SendEmailBase & + TemplateEmailForm & + Without + +export type SendCodeEmailOptions = SendEmailBase & CodeEmailForm & Without + +/** Options for `posthog.emails.send()`: a template made in PostHog, or content from your code. */ +export type SendEmailOptions = SendTemplateEmailOptions | SendCodeEmailOptions + /** A warning PostHog returned with a queued email. */ export interface EmailWarning { code: string @@ -84,12 +124,12 @@ export interface EmailStatus { export type GetEmailResponse = { data: EmailStatus; error: null } | { data: null; error: EmailError } /** - * The `posthog.emails` API: sends transactional email from PostHog email templates, and reads - * the status of each email it sent. + * The `posthog.emails` API: sends transactional email from PostHog email templates or from + * content your code rendered, and reads the status of each email it sent. */ export interface Emails { /** - * Queue one email from a transactional email template. + * Queue one email, from a transactional email template or from content your code rendered. * * Resolves to `{ data, error }` and does not reject. `data` means PostHog queued the email, * not that it was delivered. Network errors, rate limits, an in-flight idempotency key and diff --git a/packages/node/src/exports.ts b/packages/node/src/exports.ts index 9e4d13fe68..1e7b596373 100644 --- a/packages/node/src/exports.ts +++ b/packages/node/src/exports.ts @@ -7,14 +7,19 @@ export { FeatureFlagEvaluations } from './feature-flag-evaluations' export { EmailError } from './emails/errors' export type { EmailErrorName } from './emails/errors' export type { + CodeEmailForm, EmailDeliveryStatus, Emails, EmailStatus, EmailWarning, GetEmailResponse, + SendCodeEmailOptions, + SendEmailBase, SendEmailOptions, SendEmailResponse, SendEmailResult, + SendTemplateEmailOptions, + TemplateEmailForm, } from './emails/types' export type { FlagDefinitionCacheData, diff --git a/packages/node/src/types.ts b/packages/node/src/types.ts index 491535d2ba..865e276ebe 100644 --- a/packages/node/src/types.ts +++ b/packages/node/src/types.ts @@ -273,6 +273,8 @@ export type PostHogOptions = Omit