diff --git a/.changeset/node-emails-local-template.md b/.changeset/node-emails-local-template.md new file mode 100644 index 0000000000..603585b7fb --- /dev/null +++ b/.changeset/node-emails-local-template.md @@ -0,0 +1,5 @@ +--- +'posthog-node': minor +--- + +`posthog.emails.send()` also sends a local template: a send with `templateKey` and `templateData` names content from your code that PostHog fills in. The SDK uploads the template whenever PostHog doesn't hold its content yet, such as on its first send or after the content changed. A local template needs `html`. diff --git a/packages/node/references/posthog-node-references-latest.json b/packages/node/references/posthog-node-references-latest.json index 81e33821e4..f43d0f90f7 100644 --- a/packages/node/references/posthog-node-references-latest.json +++ b/packages/node/references/posthog-node-references-latest.json @@ -2057,6 +2057,11 @@ "type": "string", "name": "subject" }, + { + "description": "Leave it out to send rendered content: the email goes out as is, and PostHog never stores it. Pass it to send a local template: PostHog fills its `{{ variables.* }}`, and the SDK uploads the template whenever PostHog doesn't hold its content yet. A local template needs `html`. Content that PostHog already replaced with newer content isn't uploaded again: the email goes out with the newer content, and the send returns a `template_superseded` warning.", + "type": "Record", + "name": "templateData" + }, { "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", @@ -2070,7 +2075,7 @@ "name": "CodeEmailForm", "properties": [], "path": "src/emails/types.ts", - "example": "CodeEmailFields & ({\n html: string;\n text?: string;\n} | {\n html?: string;\n text: string;\n})" + "example": "CodeEmailFields & ({\n html: string;\n text?: string;\n} | {\n html?: string;\n text: string;\n templateData?: never;\n})" }, { "id": "Coercer", @@ -4382,7 +4387,7 @@ "name": "SendCodeEmailOptions", "properties": [], "path": "src/emails/types.ts", - "example": "SendEmailBase & CodeEmailForm & Without" + "example": "SendEmailBase & CodeEmailForm & Without>" }, { "id": "SendEmailBase", diff --git a/packages/node/src/__tests__/email-content-hash.spec.ts b/packages/node/src/__tests__/email-content-hash.spec.ts new file mode 100644 index 0000000000..50da93f80a --- /dev/null +++ b/packages/node/src/__tests__/email-content-hash.spec.ts @@ -0,0 +1,30 @@ +import { emailContentHash } from '@/emails/content-hash' + +describe('emailContentHash', () => { + it.each([ + { + content: { subject: 'Welcome, {{ variables.first_name }}', html: '

Hi {{ variables.first_name }}

' }, + hash: 'v1:f74cd913f3aa2e5667a588f2865634e56693df77793ca594d18e9b5f89e3d2e6', + }, + { + content: { subject: 'Grüße', preheader: 'Vorschau', html: '

ä

', text: 'ä' }, + hash: 'v1:75cf6fc3189ca26f60da672d29a1d4c89c7db3dd458df3a550a34a4d2a92c149', + }, + { + content: { + subject: 'Welcome, {{ variables.first_name }}', + html: '

Hi {{ variables.first_name }}

', + text: '\r\n\r', + }, + hash: 'v1:8112876ee1b2dd7327f571b04b546ed057bfff5a904d9f0ae507b6a449ab6453', + }, + ])('matches the content_hash v1 test vector of the server: $hash', async ({ content, hash }) => { + expect(await emailContentHash(content)).toBe(hash) + }) + + it('hashes CRLF and lone CR line endings like LF', async () => { + const lf = await emailContentHash({ subject: 'Hi', html: '

\na\nb\n

' }) + + expect(await emailContentHash({ subject: 'Hi', html: '

\r\na\rb\r\n

' })).toBe(lf) + }) +}) diff --git a/packages/node/src/__tests__/emails.spec.ts b/packages/node/src/__tests__/emails.spec.ts index f32496caba..8dbbbf1858 100644 --- a/packages/node/src/__tests__/emails.spec.ts +++ b/packages/node/src/__tests__/emails.spec.ts @@ -460,6 +460,21 @@ describe('emails', () => { expect(batchCallsDuringBackoff).toBe(1) }) + it.each([ + ['a template send', {}], + [ + 'a local template send', + { templateId: undefined, templateKey: 'welcome', subject: 'Hi', html: '

Hi

', templateData: {} }, + ], + ])('sends %s that started before shutdown', async (_name, overrides) => { + replies = [accepted()] + + const pending = send(overrides) + await posthog.shutdown() + + expect((await pending).data?.id).toBe('msg_1') + }) + it('sends nothing after shutdown and returns client_closed', async () => { await posthog.shutdown() @@ -510,7 +525,6 @@ describe('emails', () => { ['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' }, @@ -538,12 +552,6 @@ describe('emails', () => { "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' }, @@ -572,6 +580,146 @@ describe('emails', () => { }) }) + describe('a local template', () => { + const UPLOAD_URL = 'https://us.posthog.com/api/projects/@current/messaging_templates/by_key/welcome/' + const WELCOME_HASH = 'v1:e78af62206b6fdc0bd87d56d2a70555a9bb8d4a37617dcbb8a746f86f7db7a63' + const WELCOME = { templateKey: 'welcome', subject: 'Welcome, {{ variables.name }}', html: '

Hi

' } + const uploaded = (): Reply => ({ status: 201, body: { id: 'tpl_1', key: 'welcome', result: 'created' } }) + + const sendWelcome = async (overrides: Record = {}): ReturnType => { + const pending = posthog.emails.send({ + to: 'user@example.com', + distinctId: 'user_123', + ...WELCOME, + templateData: { name: 'Jane' }, + ...overrides, + } as SendEmailOptions) + await vi.runAllTimersAsync() + return pending + } + + const requests = (): string[] => fetch.mock.calls.map(([url, options]) => `${options.method} ${url}`) + const bodies = (): unknown[] => fetch.mock.calls.map(([, options]) => JSON.parse(options.body)) + + it('sends a template PostHog holds by its key and content hash in one request', async () => { + replies = [accepted()] + + const { data } = await sendWelcome({ from: 'Acme ', replyTo: 'help@example.com' }) + + expect(data?.id).toBe('msg_1') + expect(requests()).toEqual([`POST ${SEND_URL}`]) + expect(bodies()).toEqual([ + { + template: { key: 'welcome', hash: WELCOME_HASH }, + to: 'user@example.com', + message_data: { name: 'Jane' }, + identifiers: { distinct_id: 'user_123' }, + from: 'Acme ', + reply_to: ['help@example.com'], + capture_engagement: true, + }, + ]) + }) + + it('uploads a template PostHog does not hold, then sends it again with the same idempotency key', async () => { + makeClient({ migratedFrom: 'resend' }) + replies = [rejected(409, 'template_outdated'), uploaded(), accepted()] + + const { data } = await sendWelcome() + + expect(data?.id).toBe('msg_1') + expect(requests()).toEqual([`POST ${SEND_URL}`, `PUT ${UPLOAD_URL}`, `POST ${SEND_URL}`]) + const [firstSend, upload, secondSend] = bodies() + expect(upload).toEqual({ + name: 'welcome', + subject: WELCOME.subject, + html: WELCOME.html, + if_code_managed: true, + if_not_superseded: true, + }) + expect(secondSend).toEqual(firstSend) + const [firstHeaders, uploadHeaders, secondHeaders] = fetch.mock.calls.map(([, options]) => options.headers) + expect(secondHeaders['Idempotency-Key']).toBe(firstHeaders['Idempotency-Key']) + expect(uploadHeaders).toMatchObject({ + Authorization: `Bearer ${SECRET_KEY}`, + 'Content-Type': 'application/json', + 'X-PostHog-Migrated-From': 'resend', + }) + }) + + it.each([ + ['a refused upload', [rejected(409, 'template_outdated'), rejected(409, 'template_edited_in_posthog')], 2], + [ + 'a second template_outdated', + [rejected(409, 'template_outdated'), uploaded(), rejected(409, 'template_outdated')], + 3, + ], + ])('returns %s and uploads at most once per send', async (_name, queued, requestsMade) => { + replies = queued + + const { data, error } = await sendWelcome() + + expect(data).toBeNull() + expect(error).toMatchObject({ statusCode: 409, name: 'validation_error' }) + expect(fetch).toHaveBeenCalledTimes(requestsMade) + }) + + it('never uploads content rendered for one recipient', async () => { + replies = [rejected(409, 'template_outdated')] + + const { error } = await sendWelcome({ templateData: undefined }) + + expect(error?.code).toBe('template_outdated') + expect(requests()).toEqual([`POST ${SEND_URL}`]) + }) + + it('sends the recipient and data it was called with, even when the caller changes them afterwards', async () => { + replies = [accepted(), accepted()] + const options = { + to: 'jane@example.com', + ...WELCOME, + templateData: { name: 'Jane' }, + replyTo: ['jane-help@example.com'], + } + + const toJane = posthog.emails.send(options) + options.to = 'bob@example.com' + options.templateData.name = 'Bob' + options.replyTo[0] = 'bob-help@example.com' + const toBob = posthog.emails.send(options) + await vi.runAllTimersAsync() + await Promise.all([toJane, toBob]) + + expect(bodies()).toHaveLength(2) + expect(bodies()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + to: 'jane@example.com', + message_data: { name: 'Jane' }, + reply_to: ['jane-help@example.com'], + }), + expect.objectContaining({ + to: 'bob@example.com', + message_data: { name: 'Bob' }, + reply_to: ['bob-help@example.com'], + }), + ]) + ) + }) + + it.each([ + ['template data that is not an object', { templateData: ['Jane'] }, 'invalid_template_data', 'templateData'], + ['a local template without html', { html: undefined, text: 'Hi {{ variables.name }}' }, 'invalid_form', 'html'], + ['a local template with empty html', { html: '' }, 'invalid_form', 'html'], + ['a local template with an empty subject', { subject: '' }, 'invalid_form', 'subject'], + ])('refuses %s before sending', async (_name, overrides, code, field) => { + const { error } = await sendWelcome(overrides) + + expect(error).toMatchObject({ code, name: 'validation_error', field }) + expect(fetch).not.toHaveBeenCalled() + }) + }) + 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 index 64b43469c4..46301a7316 100644 --- a/packages/node/src/__tests__/types/emails-forms.ts +++ b/packages/node/src/__tests__/types/emails-forms.ts @@ -7,6 +7,7 @@ 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 }, + { to, templateKey: 'welcome', subject: 'Hi {{ variables.name }}', html: '

Hi

', templateData: { name: 'J' } }, ] export const wrongCombinations: SendEmailOptions[] = [ @@ -20,6 +21,6 @@ export const wrongCombinations: SendEmailOptions[] = [ { 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' } }, + // @ts-expect-error a local template needs html + { to, templateKey: 'welcome', subject: 'Hi {{ variables.name }}', text: 'Hi', templateData: { name: 'J' } }, ] diff --git a/packages/node/src/emails/content-hash.ts b/packages/node/src/emails/content-hash.ts new file mode 100644 index 0000000000..d2170427ba --- /dev/null +++ b/packages/node/src/emails/content-hash.ts @@ -0,0 +1,39 @@ +/// + +export interface EmailContent { + subject?: string + preheader?: string + html?: string + text?: string +} + +const HASH_PREFIX = 'v1:' +const ENCODING_HEADER = 'posthog-email-template-v1\n' +const HASHED_PARTS = ['subject', 'preheader', 'html', 'text'] as const + +export async function emailContentHash(content: EmailContent): Promise { + const subtle = globalThis.crypto?.subtle + if (!subtle) { + throw new Error('SubtleCrypto API not available') + } + const digest = await subtle.digest('SHA-256', encode(content)) + return HASH_PREFIX + toHex(new Uint8Array(digest)) +} + +function encode(content: EmailContent): Uint8Array { + const encoder = new TextEncoder() + let encoded = ENCODING_HEADER + for (const part of HASHED_PARTS) { + const value = normalizeLineEndings(content[part] ?? '') + encoded += `${encoder.encode(value).length}:${value}\n` + } + return encoder.encode(encoded) +} + +function normalizeLineEndings(value: string): string { + return value.replace(/\r\n?/g, '\n') +} + +function toHex(bytes: Uint8Array): string { + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('') +} diff --git a/packages/node/src/emails/emails.ts b/packages/node/src/emails/emails.ts index d17ed19994..1aea4be3bb 100644 --- a/packages/node/src/emails/emails.ts +++ b/packages/node/src/emails/emails.ts @@ -7,6 +7,7 @@ import { safeJsonStringify, } from '@posthog/core' +import { type EmailContent, emailContentHash } from './content-hash' import { EmailError } from './errors' import type { CodeEmailForm, @@ -24,6 +25,7 @@ import type { const SEND_PATH = '/api/projects/@current/messaging/send/email/' const STATUS_PATH = '/api/projects/@current/messaging/emails/{messageId}/' +const TEMPLATE_UPLOAD_PATH = '/api/projects/@current/messaging_templates/by_key/{templateKey}/' const US_APP_HOST = 'https://us.posthog.com' const EU_APP_HOST = 'https://eu.posthog.com' const CLOUD_APP_HOSTS: Record = { @@ -61,6 +63,7 @@ const CODE_FIELDS: EveryField = { from: true, replyTo: true, captureEngagement: true, + templateData: true, html: true, text: true, } @@ -98,13 +101,30 @@ export interface EmailSenderHooks { userAgent?: string } +interface PendingSend { + fields: Record + localTemplate?: LocalTemplate + idempotencyKey: string + maxRetries: number +} + interface PreparedSend { - path: string body: string + localTemplate?: LocalTemplate idempotencyKey: string maxRetries: number } +interface SendContent { + fields: Record + localTemplate?: LocalTemplate +} + +interface LocalTemplate { + key: string + content: EmailContent +} + interface Reply { status: number payload: unknown @@ -153,18 +173,12 @@ export class EmailSender implements Emails { private async sendUntracked(options: SendEmailOptions, unexpected: unknown[]): Promise { try { refuseSecondArgument(unexpected) - const prepared = prepareSend(options, this.config.maxRetries) + const pending = prepareSend(options, this.config.maxRetries) const { appHost, secretKey } = this.connect( 'no email was sent', 'Sending email needs secretKey, a project secret API key with the hog_flow:write scope.' ) - const headers = this.headers(secretKey, { - 'Content-Type': 'application/json', - 'Idempotency-Key': prepared.idempotencyKey, - }) - const result = await this.withRetries(prepared.maxRetries, async () => - parseSendReply(await this.request(appHost + prepared.path, 'POST', headers, prepared.body)) - ) + const result = await this.deliver(await completeSend(pending), appHost, secretKey) for (const warning of result.warnings) { this.hooks.logger.warn(`PostHog email warning ${warning.code}: ${warning.message}`) } @@ -174,6 +188,38 @@ export class EmailSender implements Emails { } } + private async deliver(prepared: PreparedSend, appHost: string, secretKey: string): Promise { + const headers = this.headers(secretKey, { + 'Content-Type': 'application/json', + 'Idempotency-Key': prepared.idempotencyKey, + }) + const post = (): Promise => + this.withRetries(prepared.maxRetries, async () => + parseSendReply(await this.request(appHost + SEND_PATH, 'POST', headers, prepared.body)) + ) + try { + return await post() + } catch (error) { + if (!prepared.localTemplate || !isOutdatedTemplate(error)) { + throw error + } + await this.upload(prepared.localTemplate, appHost, secretKey, prepared.maxRetries) + return await post() + } + } + + private async upload( + { key, content }: LocalTemplate, + appHost: string, + secretKey: string, + maxRetries: number + ): Promise { + const url = appHost + TEMPLATE_UPLOAD_PATH.replace('{templateKey}', key) + const headers = this.headers(secretKey, { 'Content-Type': 'application/json' }) + const body = safeJsonStringify({ name: key, ...content, if_code_managed: true, if_not_superseded: true }) + await this.withRetries(maxRetries, async () => parseUploadReply(await this.request(url, 'PUT', headers, body))) + } + private async getUntracked(id: string): Promise { try { if (typeof id !== 'string' || !UUID_PATTERN.test(id)) { @@ -264,7 +310,7 @@ export class EmailSender implements Emails { private async request( url: string, - method: 'GET' | 'POST', + method: 'GET' | 'POST' | 'PUT', headers: Record, body?: string ): Promise { @@ -332,7 +378,7 @@ function refuseSecondArgument(unexpected: unknown[]): void { } } -function prepareSend(options: SendEmailOptions, defaultMaxRetries: number): PreparedSend { +function prepareSend(options: SendEmailOptions, defaultMaxRetries: number): PendingSend { const send: UncheckedSend = options ?? {} const { to, distinctId, idempotencyKey, maxRetries } = send if (typeof to !== 'string' || !to.trim()) { @@ -341,7 +387,7 @@ function prepareSend(options: SendEmailOptions, defaultMaxRetries: number): Prep if (distinctId !== undefined && (typeof distinctId !== 'string' || !distinctId)) { throw invalid('invalid_distinct_id', 'distinctId must be a nonempty string.', 'distinctId') } - const content = contentOf(send, distinctId) + const { fields, localTemplate } = contentOf(send, distinctId) if ( idempotencyKey !== undefined && (typeof idempotencyKey !== 'string' || !IDEMPOTENCY_KEY_PATTERN.test(idempotencyKey)) @@ -357,18 +403,26 @@ function prepareSend(options: SendEmailOptions, defaultMaxRetries: number): Prep } return { - path: SEND_PATH, - body: safeJsonStringify({ ...content, to }), + fields: JSON.parse(safeJsonStringify({ ...fields, to })), + localTemplate, idempotencyKey: idempotencyKey ?? globalThis.crypto.randomUUID(), maxRetries: isRetryCount(maxRetries) ? maxRetries : defaultMaxRetries, } } +async function completeSend({ fields, localTemplate, ...delivery }: PendingSend): Promise { + if (!localTemplate) { + return { ...delivery, body: safeJsonStringify(fields) } + } + const template = { key: localTemplate.key, hash: await emailContentHash(localTemplate.content) } + return { ...delivery, body: safeJsonStringify({ template, ...fields }), localTemplate } +} + function isRetryCount(value: unknown): value is number { return typeof value === 'number' && Number.isInteger(value) && value >= 0 } -function contentOf(send: UncheckedSend, distinctId: string | undefined): Record { +function contentOf(send: UncheckedSend, distinctId: string | undefined): SendContent { if ((send.templateId === undefined) === (send.templateKey === undefined)) { throw new EmailError({ code: 'invalid_form', @@ -378,27 +432,37 @@ function contentOf(send: UncheckedSend, distinctId: string | undefined): Record< return send.templateKey === undefined ? templateContent(send, distinctId) : codeContent(send, distinctId) } -function templateContent(send: UncheckedSend, distinctId: string | undefined): Record { +function templateContent(send: UncheckedSend, distinctId: string | undefined): SendContent { 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') } + refuseNonObjectTemplateData(templateData) + refuseFieldsOutside(send, TEMPLATE_FORM, CODE_FORM) + return { + fields: { + template: { id: templateId.toLowerCase() }, + message_data: templateData ?? {}, + ...identifiersOf(distinctId), + }, + } +} + +function refuseNonObjectTemplateData(templateData: unknown): void { 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() }, - message_data: templateData ?? {}, - ...(distinctId !== undefined && { identifiers: { distinct_id: distinctId } }), - } } -function codeContent(send: UncheckedSend, distinctId: string | undefined): Record { - const { templateKey, subject, html, text, from, replyTo, captureEngagement } = send +function identifiersOf(distinctId: string | undefined): Record { + return distinctId === undefined ? {} : { identifiers: { distinct_id: distinctId } } +} + +function codeContent(send: UncheckedSend, distinctId: string | undefined): SendContent { + const { templateKey, subject, html, text, from, replyTo, captureEngagement, templateData } = send if (typeof templateKey !== 'string' || !TEMPLATE_KEY_PATTERN.test(templateKey)) { throw invalid( 'invalid_template_key', @@ -419,15 +483,32 @@ function codeContent(send: UncheckedSend, distinctId: string | undefined): Recor if (html === undefined && text === undefined) { throw invalid('invalid_form', 'A send with templateKey needs html, text or both.', 'html') } - return { - template_key: templateKey, - subject, - ...(html !== undefined && { html }), - ...(text !== undefined && { text }), + refuseNonObjectTemplateData(templateData) + const content = { subject, ...(html !== undefined && { html }), ...(text !== undefined && { text }) } + const sender = { ...(from !== undefined && { from }), ...(replyTo !== undefined && { reply_to: typeof replyTo === 'string' ? [replyTo] : replyTo }), capture_engagement: captureEngagement ?? true, - ...(distinctId !== undefined && { distinct_id: distinctId }), + } + if (templateData === undefined) { + return { + fields: { + template_key: templateKey, + ...content, + ...sender, + ...(distinctId !== undefined && { distinct_id: distinctId }), + }, + } + } + if (!subject) { + throw invalid('invalid_form', 'A local template needs a subject that is not empty.', 'subject') + } + if (!html) { + throw invalid('invalid_form', 'A local template needs html. Pass the HTML body with templateData.', 'html') + } + return { + fields: { message_data: templateData, ...identifiersOf(distinctId), ...sender }, + localTemplate: { key: templateKey, content }, } } @@ -526,6 +607,16 @@ function parseSendReply({ status, payload, retryAfterMs }: Reply): SendEmailResu throw replyError(fields, status, retryAfterMs) } +function parseUploadReply({ status, payload, retryAfterMs }: Reply): void { + if (status < 200 || status >= 300) { + throw replyError(replyFields(payload), status, retryAfterMs) + } +} + +function isOutdatedTemplate(error: unknown): boolean { + return error instanceof EmailError && error.statusCode === 409 && error.code === 'template_outdated' +} + function parseStatusReply({ status, payload, retryAfterMs }: Reply): EmailStatus { const fields = replyFields(payload) if (status < 200 || status >= 300) { diff --git a/packages/node/src/emails/errors.ts b/packages/node/src/emails/errors.ts index a982935bda..1dfeae07e9 100644 --- a/packages/node/src/emails/errors.ts +++ b/packages/node/src/emails/errors.ts @@ -58,6 +58,8 @@ export class EmailError extends Error { } } +const TEMPLATE_CONFLICTS = new Set(['template_outdated', 'template_edited_in_posthog']) + function emailErrorName(status: number | undefined, code: string): EmailErrorName { if (code === 'missing_secret_key' || status === 401) { return 'missing_api_key' @@ -76,7 +78,7 @@ function emailErrorName(status: number | undefined, code: string): EmailErrorNam case 404: return 'not_found' case 409: - return code === 'template_outdated' ? 'validation_error' : 'concurrent_idempotent_requests' + return TEMPLATE_CONFLICTS.has(code) ? 'validation_error' : 'concurrent_idempotent_requests' case 422: // The template send route also answers 422 for send checks such as recipient_suppressed. return code === 'idempotency_key_reused' ? 'invalid_idempotent_request' : 'validation_error' diff --git a/packages/node/src/emails/types.ts b/packages/node/src/emails/types.ts index 7de44ddc0e..381dde67c0 100644 --- a/packages/node/src/emails/types.ts +++ b/packages/node/src/emails/types.ts @@ -46,22 +46,31 @@ interface CodeEmailFields { * follows the project's setting. */ captureEngagement?: boolean + /** + * Leave it out to send rendered content: the email goes out as is, and PostHog never stores it. + * Pass it to send a local template: PostHog fills its `{{ variables.* }}`, and the SDK uploads + * the template whenever PostHog doesn't hold its content yet. A local template needs `html`. + * Content that PostHog already replaced with newer content isn't uploaded again: the email goes + * out with the newer content, and the send returns a `template_superseded` warning. + */ + templateData?: Record } /** - * 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`. + * Email content from your code, named by a key: content your code rendered, such as React Email + * output, or a local template that PostHog fills in with `templateData`. Needs `html`, `text` or + * both, and a local template needs `html`. PostHog derives a missing `text` from the `html`. */ -export type CodeEmailForm = CodeEmailFields & ({ html: string; text?: string } | { html?: string; text: string }) +export type CodeEmailForm = CodeEmailFields & + ({ html: string; text?: string } | { html?: string; text: string; templateData?: never }) type Without = { [K in keyof T]?: never } export type SendTemplateEmailOptions = SendEmailBase & TemplateEmailForm & - Without + Without & { html: string; text: string }> -export type SendCodeEmailOptions = SendEmailBase & CodeEmailForm & 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 @@ -129,11 +138,13 @@ export type GetEmailResponse = { data: EmailStatus; error: null } | { data: null */ export interface Emails { /** - * Queue one email, from a transactional email template or from content your code rendered. + * Queue one email, from a transactional email template, from content your code rendered, or + * from a local template. * * 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 - * an unavailable service are retried with the same idempotency key. + * an unavailable service are retried with the same idempotency key. A local template that + * PostHog doesn't hold yet is uploaded once, then sent again with the same idempotency key. */ send(options: SendEmailOptions): Promise