From 7d59ad2b2b09d70223bb4ee57d7583f8f3eef7d1 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 22 Oct 2026 03:44:38 +0000 Subject: [PATCH 1/6] feat(node): send email content from code, uploading local templates by hash posthog.emails.send takes the code form of the shared SDK contract: templateKey, subject, html and/or text, with optional from, replyTo and captureEngagement. The types refuse a send that mixes the two forms. Without templateData the content is rendered and goes to the send route as is, never uploaded. With templateData it is a local template: the SDK sends its content hash, and on 409 template_outdated uploads it by key with if_code_managed and sends once more with the same idempotency key. An upload refused because the key was edited in PostHog returns its error and is never retried. Template sends by id move to the send route as template.id. Co-Authored-By: Claude Opus 5.5 --- .changeset/node-emails-code-form.md | 5 + .../src/__tests__/email-content-hash.spec.ts | 10 +- packages/node/src/__tests__/emails.spec.ts | 152 ++++++++- .../node/src/__tests__/types/emails-forms.ts | 21 ++ .../src/__tests__/utils/fake-emails-api.ts | 104 ++++-- packages/node/src/client.ts | 5 +- packages/node/src/emails.ts | 306 ++++++++++++++---- 7 files changed, 506 insertions(+), 97 deletions(-) create mode 100644 .changeset/node-emails-code-form.md create mode 100644 packages/node/src/__tests__/types/emails-forms.ts diff --git a/.changeset/node-emails-code-form.md b/.changeset/node-emails-code-form.md new file mode 100644 index 0000000000..f1a6bafcc8 --- /dev/null +++ b/.changeset/node-emails-code-form.md @@ -0,0 +1,5 @@ +--- +'posthog-node': minor +--- + +`posthog.emails.send()` also sends email content from your code, named by a `templateKey`: rendered content as is, or a local template that PostHog fills with `templateData`. The SDK uploads a local template on its first send and whenever its content changes. Template sends by id move to the same send route, with no change for callers. diff --git a/packages/node/src/__tests__/email-content-hash.spec.ts b/packages/node/src/__tests__/email-content-hash.spec.ts index 538962be82..221a71e6cf 100644 --- a/packages/node/src/__tests__/email-content-hash.spec.ts +++ b/packages/node/src/__tests__/email-content-hash.spec.ts @@ -10,7 +10,15 @@ describe('emailContentHash', () => { content: { subject: 'Grüße', preheader: 'Vorschau', html: '

ä

', text: 'ä' }, hash: 'v1:75cf6fc3189ca26f60da672d29a1d4c89c7db3dd458df3a550a34a4d2a92c149', }, - ])('matches the spec test vector $hash', async ({ content, hash }) => { + { + 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) }) diff --git a/packages/node/src/__tests__/emails.spec.ts b/packages/node/src/__tests__/emails.spec.ts index d6f1035674..de457835fb 100644 --- a/packages/node/src/__tests__/emails.spec.ts +++ b/packages/node/src/__tests__/emails.spec.ts @@ -1,5 +1,6 @@ import { PostHog, PostHogOptions } from '@/entrypoints/index.node' import type { SendEmailOptions } from '@/emails' +import { emailContentHash } from '@/email-content-hash' import { type Fault, FakeEmailsApi, refusalBody } from './utils/fake-emails-api' vi.mock('../version', () => ({ version: '1.2.3' })) @@ -8,7 +9,8 @@ const JANE = 'jane@example.com' const SECRET_KEY = 'phs_secret' const APP_HOST = 'https://posthog.example.com' const TEMPLATE_ID = '0190f6d2-7c1e-4b3a-9f5e-1c2d3e4f5a6b' -const TEMPLATE_SEND = `POST /api/projects/@current/messaging_templates/${TEMPLATE_ID}/send/` +const SEND = 'POST /api/projects/@current/messaging/send/email/' +const UPLOAD_WELCOME = 'PUT /api/projects/@current/messaging_templates/by_key/welcome/' const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ describe('emails', () => { @@ -59,7 +61,7 @@ describe('emails', () => { { messageId: 'message-1', to: JANE, - templateId: TEMPLATE_ID, + template: TEMPLATE_ID, variables: { firstName: 'Jane' }, distinctId: 'user_42', }, @@ -75,15 +77,25 @@ describe('emails', () => { }), }), ]) - expect(requestLog()).toEqual([TEMPLATE_SEND]) + expect(requestLog()).toEqual([SEND]) }) it.each([ - ['no template data or distinct id', { to: JANE, templateId: TEMPLATE_ID }, { to: JANE }], [ - 'template data and a distinct id', - { to: JANE, templateId: TEMPLATE_ID, templateData: { n: 1 }, distinctId: 'u' }, - { to: JANE, message_data: { n: 1 }, identifiers: { distinct_id: 'u' } }, + 'no template data or distinct id', + { to: JANE, templateId: TEMPLATE_ID }, + { template: { id: TEMPLATE_ID }, to: JANE }, + ], + [ + 'template data, a distinct id and an engagement choice', + { to: JANE, templateId: TEMPLATE_ID, templateData: { n: 1 }, distinctId: 'u', captureEngagement: false }, + { + template: { id: TEMPLATE_ID }, + to: JANE, + message_data: { n: 1 }, + identifiers: { distinct_id: 'u' }, + capture_engagement: false, + }, ], ])('maps %s to the request body', async (_, options, body) => { await createClient().emails.send(options) @@ -144,6 +156,123 @@ describe('emails', () => { }) }) + describe('the code form', () => { + const welcome = { templateKey: 'welcome', subject: 'Welcome, {{ variables.name }}', html: '

Hi

' } + const sendLocalWelcome = (posthog: PostHog, content: Partial = {}) => + posthog.emails.send({ to: JANE, distinctId: 'user_42', ...welcome, ...content, templateData: { name: 'Jane' } }) + + it.each([ + [ + 'html', + { templateKey: 'password-reset', subject: 'Reset your password', html: '

Reset

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

Reset

' }, + ], + [ + 'text, a sender, one reply-to address and an engagement choice', + { + 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'], + }, + ], + ])('sends rendered content with %s as is, without an upload', async (_, content, body) => { + const result = await createClient().emails.send({ to: JANE, distinctId: 'user_42', ...content }) + + expect(result.error).toBeNull() + expect(requestLog()).toEqual([SEND]) + expect(api.received[0].body).toEqual({ ...body, to: JANE, identifiers: { distinct_id: 'user_42' } }) + }) + + it('uploads a local template PostHog does not hold, then sends it again with the same idempotency key', async () => { + const result = await sendLocalWelcome(createClient({ migratedFrom: 'resend' })) + + expect(result).toEqual({ data: { id: 'message-1', warnings: [] }, error: null }) + expect(requestLog()).toEqual([SEND, UPLOAD_WELCOME, SEND]) + const [firstSend, upload, secondSend] = api.received + expect(firstSend.body).toEqual({ + template: { key: 'welcome', hash: await emailContentHash(welcome) }, + to: JANE, + message_data: { name: 'Jane' }, + identifiers: { distinct_id: 'user_42' }, + }) + expect(upload.body).toEqual({ + name: 'welcome', + subject: welcome.subject, + html: welcome.html, + if_code_managed: true, + if_not_superseded: true, + }) + expect(upload.headers).toEqual(expect.objectContaining({ 'x-posthog-migrated-from': 'resend' })) + expect(secondSend.body).toEqual(firstSend.body) + expect(secondSend.headers['idempotency-key']).toBe(firstSend.headers['idempotency-key']) + expect(api.delivered).toEqual([ + expect.objectContaining({ template: 'welcome', subject: welcome.subject, variables: { name: 'Jane' } }), + ]) + }) + + it('sends a local template PostHog holds in one request, and uploads it again once it changes', async () => { + const posthog = createClient() + await sendLocalWelcome(posthog) + api.received.length = 0 + + await sendLocalWelcome(posthog) + expect(requestLog()).toEqual([SEND]) + + await sendLocalWelcome(posthog, { html: '

Hi again

' }) + expect(requestLog()).toEqual([SEND, SEND, UPLOAD_WELCOME, SEND]) + expect(api.localTemplate('welcome')?.html).toBe('

Hi again

') + }) + + it('returns the refusal of a key edited in PostHog, and sends nothing', async () => { + api.editInPostHog('welcome', { subject: 'Edited', html: '

Edited

' }) + + const result = await sendLocalWelcome(createClient()) + + expect(result.error).toEqual( + expect.objectContaining({ name: 'validation_error', code: 'template_edited_in_posthog', statusCode: 409 }) + ) + expect(requestLog()).toEqual([SEND, UPLOAD_WELCOME]) + expect(api.delivered).toEqual([]) + }) + + it('returns a second template_outdated instead of uploading again', async () => { + api.failWhen(({ method }) => method === 'POST', { status: 409, body: refusalBody('template_outdated', null) }) + + const result = await sendLocalWelcome(createClient()) + + expect(result.error).toEqual(expect.objectContaining({ code: 'template_outdated' })) + expect(requestLog()).toEqual([SEND, UPLOAD_WELCOME, SEND]) + }) + }) + describe('errors', () => { it.each([ [400, 'invalid_recipient', 'validation_error'], @@ -241,13 +370,20 @@ describe('emails', () => { ['invalid_template_id', 'validation_error', {}, { templateId: '../../feature_flags' }], ['invalid_idempotency_key', 'validation_error', {}, { idempotencyKey: 'has spaces' }], ['invalid_idempotency_key', 'validation_error', {}, { idempotencyKey: 42 as unknown as string }], + ['invalid_form', 'validation_error', {}, { templateKey: 'welcome', subject: 'Hi', html: '

Hi

' }], + [ + 'invalid_template_key', + 'validation_error', + {}, + { templateId: undefined, templateKey: '../welcome', subject: 'Hi', html: '

Hi

' }, + ], ])('returns %s as %s without a request', async (code, name, clientOptions: PostHogOptions, sendOptions) => { const result = await createClient(clientOptions).emails.send({ to: JANE, distinctId: 'user_42', templateId: TEMPLATE_ID, ...sendOptions, - }) + } as SendEmailOptions) expect(result).toEqual({ data: null, error: expect.objectContaining({ name, code }) }) expect(api.received).toEqual([]) 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..64ab79fbbe --- /dev/null +++ b/packages/node/src/__tests__/types/emails-forms.ts @@ -0,0 +1,21 @@ +import type { SendEmailOptions } from '../../exports' + +const to = 'jane@example.com' + +export const forms: SendEmailOptions[] = [ + { to, templateId: '0190f6d2-7c1e-4b3a-9f5e-1c2d3e4f5a6b', 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' }, + { to, templateKey: 'welcome', subject: 'Hi {{ variables.name }}', html: '

Hi

', templateData: { name: 'J' } }, +] + +export const wrongCombinations: SendEmailOptions[] = [ + // @ts-expect-error a send takes exactly one form + { to, templateId: '0190f6d2-7c1e-4b3a-9f5e-1c2d3e4f5a6b', templateKey: 'reset', subject: 'Reset', html: '

R

' }, + // @ts-expect-error a template made in PostHog keeps its own sender + { to, templateId: '0190f6d2-7c1e-4b3a-9f5e-1c2d3e4f5a6b', 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

' }, +] diff --git a/packages/node/src/__tests__/utils/fake-emails-api.ts b/packages/node/src/__tests__/utils/fake-emails-api.ts index 9f718a251f..7308c98a65 100644 --- a/packages/node/src/__tests__/utils/fake-emails-api.ts +++ b/packages/node/src/__tests__/utils/fake-emails-api.ts @@ -1,6 +1,8 @@ import type { PostHogFetchOptions, PostHogFetchResponse } from '@posthog/core' +import { emailContentHash } from '@/email-content-hash' -const TEMPLATE_SEND_PATH = /^\/api\/projects\/@current\/messaging_templates\/([^/]+)\/send\/$/ +const SEND_PATH = '/api/projects/@current/messaging/send/email/' +const UPLOAD_PATH = /^\/api\/projects\/@current\/messaging_templates\/by_key\/([^/]+)\/$/ const DOCS_URL = 'https://posthog.com/docs/workflows/transactional-email-api' export interface ReceivedRequest { @@ -14,11 +16,23 @@ export interface ReceivedRequest { export interface DeliveredEmail { messageId: string to: string - templateId: string - variables: Record + /** The template id, the local template's key, or the rendered content's `template_key`. */ + template: string + subject?: string + html?: string + text?: string + variables?: Record distinctId?: string } +interface LocalTemplate { + subject: string + html: string + text?: string + contentHash: string + editedInPostHog: boolean +} + export type Fault = | { status: number; body?: unknown; headers?: Record } | { networkError: Error } @@ -31,13 +45,16 @@ interface ScheduledFault { } /** - * An in-memory PostHog app host that speaks the template send route of the transactional email API, with its - * idempotency rule: a key PostHog already accepted for the same request answers the first `202` again. + * An in-memory PostHog app host that speaks the send and upload routes of the transactional email API: a template by + * id, rendered content by `template_key`, and a local template by key and content hash, which answers + * `template_outdated` until an upload publishes that content. An idempotency key PostHog already accepted for the + * same request answers the first `202` again. */ export class FakeEmailsApi { readonly received: ReceivedRequest[] = [] readonly delivered: DeliveredEmail[] = [] private readonly templateIds = new Set() + private readonly localTemplates = new Map() private readonly accepted = new Map() private readonly faults: ScheduledFault[] = [] private nextId = 1 @@ -52,7 +69,7 @@ export class FakeEmailsApi { throw fault.networkError } if (fault && 'bodyBreaksOff' in fault) { - return breakOff(this.handle(request), fault.bodyBreaksOff) + return breakOff(await this.handle(request), fault.bodyBreaksOff) } if (fault) { return respond(fault.status, fault.body, fault.headers) @@ -64,6 +81,14 @@ export class FakeEmailsApi { this.templateIds.add(id) } + editInPostHog(key: string, content: { subject: string; html: string }): void { + this.localTemplates.set(key, { ...content, contentHash: 'v1:edited', editedInPostHog: true }) + } + + localTemplate(key: string): LocalTemplate | undefined { + return this.localTemplates.get(key) + } + failWhen(matches: (request: ReceivedRequest) => boolean, fault: Fault, times = Infinity): void { this.faults.push({ matches, fault, remaining: times }) } @@ -80,44 +105,77 @@ export class FakeEmailsApi { return scheduled?.fault } - private handle(request: ReceivedRequest): PostHogFetchResponse { + private async handle(request: ReceivedRequest): Promise { if (request.origin !== this.options.appHost) { return respond(404, 'Not the app host') } if (request.headers.authorization !== `Bearer ${this.options.secretKey}`) { return refusal(401, 'authentication_failed', null) } - const templateId = TEMPLATE_SEND_PATH.exec(request.path)?.[1] - if (request.method === 'POST' && templateId) { - return this.handleTemplateSend(decodeURIComponent(templateId), request) + const uploadKey = UPLOAD_PATH.exec(request.path)?.[1] + if (request.method === 'PUT' && uploadKey) { + return this.handleUpload(decodeURIComponent(uploadKey), request.body) + } + if (request.method === 'POST' && request.path === SEND_PATH) { + return this.handleSend(request) } return refusal(404, 'not_found', null) } - private handleTemplateSend(templateId: string, { headers, body }: ReceivedRequest): PostHogFetchResponse { - if (!this.templateIds.has(templateId)) { - return refusal(404, 'not_found', null) + private async handleUpload(key: string, body: Record): Promise { + const existing = this.localTemplates.get(key) + if (existing?.editedInPostHog && body.if_code_managed === true) { + return refusal(409, 'template_edited_in_posthog', null) + } + const { subject, html, text } = body + const contentHash = await emailContentHash({ subject, html, text }) + this.localTemplates.set(key, { subject, html, text, contentHash, editedInPostHog: false }) + return respond(existing ? 200 : 201, { key, result: existing ? 'updated' : 'created', content_hash: contentHash }) + } + + private handleSend({ headers, body }: ReceivedRequest): PostHogFetchResponse { + const refused = this.refusalOf(body) + if (refused) { + return refused } const key = headers['idempotency-key'] - const fingerprint = JSON.stringify([templateId, body]) + const fingerprint = JSON.stringify(body) const earlier = key === undefined ? undefined : this.accepted.get(key) if (earlier) { return earlier.fingerprint === fingerprint - ? respond(202, { message_id: earlier.messageId, replayed: true }) + ? respond(202, { message_id: earlier.messageId, replayed: true, warnings: [] }) : refusal(422, 'idempotency_key_reused', null) } const messageId = `message-${this.nextId++}` if (key !== undefined) { this.accepted.set(key, { fingerprint, messageId }) } - this.delivered.push({ - messageId, - to: body.to, - templateId, - variables: body.message_data ?? {}, - distinctId: body.identifiers?.distinct_id, - }) - return respond(202, { message_id: messageId, replayed: false }) + this.delivered.push({ messageId, to: body.to, distinctId: body.identifiers?.distinct_id, ...this.contentOf(body) }) + return respond(202, { message_id: messageId, replayed: false, warnings: [] }) + } + + private refusalOf(body: Record): PostHogFetchResponse | undefined { + const { id, key, hash } = body.template ?? {} + if (id !== undefined && !this.templateIds.has(id)) { + return refusal(404, 'template_not_found', 'template__id') + } + const local = key === undefined ? undefined : this.localTemplates.get(key) + if (key !== undefined && (!local || (hash !== undefined && hash !== local.contentHash))) { + return refusal(409, 'template_outdated', local ? 'template__hash' : 'template__key') + } + return undefined + } + + private contentOf(body: Record): Omit { + const { id, key } = body.template ?? {} + if (id !== undefined) { + return { template: id, variables: body.message_data ?? {} } + } + if (key !== undefined) { + const { subject, html, text } = this.localTemplates.get(key)! + return { template: key, subject, html, text, variables: body.message_data ?? {} } + } + return { template: body.template_key, subject: body.subject, html: body.html, text: body.text } } } diff --git a/packages/node/src/client.ts b/packages/node/src/client.ts index e13a15a1e1..70c50c68f6 100644 --- a/packages/node/src/client.ts +++ b/packages/node/src/client.ts @@ -746,7 +746,7 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen }), warn: (message) => this._logger.warn(message), track: (work) => this.addPendingPromise(work), - request: (url, headers, body) => this._requestEmailsApi(url, headers, body), + request: (method, url, headers, body) => this._requestEmailsApi(method, url, headers, body), }) return this._emails } @@ -3066,12 +3066,13 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen /** One attempt: `posthog.emails` retries on its own, with the same idempotency key. */ private async _requestEmailsApi( + method: 'POST' | 'PUT', url: string, headers: Record, body: string ): Promise { try { - return await this.requestJson(url, { method: 'POST', headers, body }, { retryCount: 0 }) + return await this.requestJson(url, { method, headers, body }, { retryCount: 0 }) } catch (error) { this._events.emit('error', error) throw new Error(isTimeout(error) ? `Request timed out after ${this.requestTimeout}ms` : requestFailure(error)) diff --git a/packages/node/src/emails.ts b/packages/node/src/emails.ts index b48620cd19..fbef2124e3 100644 --- a/packages/node/src/emails.ts +++ b/packages/node/src/emails.ts @@ -1,10 +1,26 @@ import { isValidUUID, type JsonType, type PostHogApiResponse } from '@posthog/core' +import { emailContentHash } from './email-content-hash' -const TEMPLATE_SEND_PATH = (templateId: string): string => - `/api/projects/@current/messaging_templates/${templateId}/send/` +const SEND_PATH = '/api/projects/@current/messaging/send/email/' +const UPLOAD_PATH = (key: string): string => `/api/projects/@current/messaging_templates/by_key/${key}/` +const TEMPLATE_KEY_PATTERN = /^[a-zA-Z0-9_-]{1,400}$/ const IDEMPOTENCY_KEY_PATTERN = /^[\x21-\x7e]{1,255}$/ -const SEND_OPTIONS = new Set(['to', 'distinctId', 'idempotencyKey', 'maxRetries', 'templateId', 'templateData']) +const SEND_OPTIONS = new Set([ + 'to', + 'distinctId', + 'idempotencyKey', + 'captureEngagement', + 'maxRetries', + 'templateId', + 'templateKey', + 'templateData', + 'from', + 'replyTo', + 'subject', + 'html', + 'text', +]) const DEFAULT_MAX_RETRIES = 2 const FIRST_RETRY_DELAY_MS = 1_000 const MAX_RETRY_DELAY_MS = 30_000 @@ -26,20 +42,51 @@ export interface SendEmailBase { * `maxRetries: 0`. */ idempotencyKey?: string + /** + * Whether PostHog captures the email's engagement events (sent, delivered, opened, clicked, bounced) as analytics + * events of the recipient. They count as analytics events. Without it, PostHog decides: on for the code form. + */ + captureEngagement?: boolean /** How often to retry this send, a whole number from 0. Overrides the `maxRetries` client option. */ maxRetries?: number } /** A template made in PostHog, sent by its id. */ -export interface SendTemplateEmailOptions extends SendEmailBase { +export interface TemplateEmailForm { /** The UUID of the template. */ templateId: string /** Values for the template's `{{ variables.* }}`. The template can't read person properties. */ templateData?: Record } -/** One email to send with `posthog.emails.send()`. */ -export type SendEmailOptions = SendTemplateEmailOptions +interface CodeEmailFields { + /** Names the email, such as `'password-reset'`, so its metrics, logs and status group under it in PostHog. */ + templateKey: string + subject: string + /** A verified sender, such as `'Acme '`. Without it, the project's default sender. */ + from?: string + replyTo?: string | string[] + /** + * 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 when PostHog does not hold + * its content yet. + */ + templateData?: Record +} + +/** Email content from your code, named by a key. Needs `html`, `text` or both; PostHog derives a missing `text`. */ +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 & { html: string; text: string }> + +export type SendCodeEmailOptions = SendEmailBase & CodeEmailForm & Without> + +/** One email to send with `posthog.emails.send()`, in exactly one form. */ +export type SendEmailOptions = SendTemplateEmailOptions | SendCodeEmailOptions /** Something to know about a queued email. */ export interface EmailWarning { @@ -58,9 +105,9 @@ export interface EmailError { name: EmailErrorName /** * PostHog's error code, such as `invalid_recipient`, or one of the SDK's own: `missing_secret_key`, - * `missing_app_host`, `client_disabled`, `client_closed`, `network_error`, `api_error` for an answer without a code, - * `unexpected_error`, and `invalid_form`, `invalid_template_id` and `invalid_idempotency_key` for a send the SDK - * refused before any request. + * `missing_app_host`, `client_disabled`, `client_closed`, `network_error`, `api_error` for an answer without a + * code, `unexpected_error`, and `invalid_form`, `invalid_template_id`, `invalid_template_key` and + * `invalid_idempotency_key` for a send the SDK refused before any request. */ code: string message: string @@ -106,7 +153,7 @@ export interface Emails { * Needs the `secretKey` client option, a project secret API key (`phs_`) with the `hog_flow:write` scope. When the * key can't read feature flags, also set `enableLocalEvaluation: false`, or the client polls flag definitions with it. * - * @example + * @example A template made in PostHog, by its id * ```ts * const { data, error } = await posthog.emails.send({ * to: 'jane@example.com', @@ -116,6 +163,29 @@ export interface Emails { * idempotencyKey: `welcome:${user.id}`, * }) * ``` + * + * @example Rendered content, such as React Email rendered to HTML in your app + * ```ts + * await posthog.emails.send({ + * to: 'jane@example.com', + * distinctId: user.id, + * templateKey: 'password-reset', + * subject: 'Reset your password', + * html: await render(), + * }) + * ``` + * + * @example A local template: your code holds it, PostHog fills in the variables + * ```ts + * await posthog.emails.send({ + * to: 'jane@example.com', + * distinctId: user.id, + * templateKey: 'welcome', + * subject: 'Welcome, {{ variables.name }}', + * html: welcomeHtml, + * templateData: { name: 'Jane' }, + * }) + * ``` */ send(options: SendEmailOptions): Promise } @@ -132,20 +202,30 @@ export interface EmailsApi { warn(message: string): void track(work: Promise): Promise /** Rejects only when PostHog never answered. */ - request(url: string, headers: Record, body: string): Promise + request( + method: 'POST' | 'PUT', + url: string, + headers: Record, + body: string + ): Promise } +type Outcome = { data: T; error: null } | Failure type Failure = { data: null; error: EmailError } -interface PreparedSend { - path: string - body: JsonType +interface Delivery { idempotencyKey: string maxRetries: number } +interface ApiRequest { + method: 'POST' | 'PUT' + path: string + body: JsonType +} + interface Attempt { - result: SendEmailResult + outcome: Outcome retryAfterMs?: number } @@ -155,17 +235,26 @@ export class PostHogEmails implements Emails { send(options: SendEmailOptions): Promise { if (!isRecord(options)) { - return Promise.resolve(failure('invalid_form', 'send needs an options object with to and templateId.')) + return Promise.resolve( + failure('invalid_form', 'send needs an options object with to, and templateId or templateKey.') + ) } const refusal = this.refusalBeforeRequest(options) if (refusal) { return Promise.resolve(refusal) } - return this.api.track(this.deliver(this.prepare(options)).catch(unexpectedFailure)) + const delivery = { + idempotencyKey: options.idempotencyKey ?? crypto.randomUUID(), + maxRetries: retryCount(options.maxRetries) ?? retryCount(this.api.maxRetries()) ?? DEFAULT_MAX_RETRIES, + } + return this.api.track( + this.sendForm(options, delivery) + .then((result) => this.logWarnings(result)) + .catch(unexpectedFailure) + ) } private refusalBeforeRequest(options: SendEmailOptions): Failure | undefined { - const { templateId, idempotencyKey } = options if (this.api.isDisabled()) { return failure('client_disabled', 'The PostHog client is disabled, so nothing was sent.') } @@ -185,68 +274,67 @@ export class PostHogEmails implements Emails { 'goes to a proxy.' ) } - if (!isValidUUID(templateId)) { - return failure('invalid_template_id', 'templateId must be the UUID of a template.', { field: 'templateId' }) - } - const unknownOption = unknownOptionRefusal(options) - if (unknownOption) { - return unknownOption + return formRefusal(options) ?? unknownOptionRefusal(options) ?? idempotencyKeyRefusal(options.idempotencyKey) + } + + private sendForm(options: SendEmailOptions, delivery: Delivery): Promise { + if (isTemplateForm(options)) { + return this.post(templateBody(options), delivery) } - if (idempotencyKey !== undefined && !isIdempotencyKey(idempotencyKey)) { - return failure( - 'invalid_idempotency_key', - 'idempotencyKey must be 1 to 255 visible ASCII characters, with no spaces.', - { field: 'idempotencyKey' } - ) + if (options.templateData === undefined) { + return this.post(renderedContentBody(options), delivery) } - return undefined + return this.sendLocalTemplate(options, delivery) } - private prepare({ - to, - distinctId, - idempotencyKey, - maxRetries, - templateId, - templateData, - }: SendEmailOptions): PreparedSend { - return { - path: TEMPLATE_SEND_PATH(templateId), - body: { - to, - ...(templateData !== undefined && { message_data: templateData as JsonType }), - ...(distinctId !== undefined && { identifiers: { distinct_id: distinctId } }), - }, - idempotencyKey: idempotencyKey ?? crypto.randomUUID(), - maxRetries: retryCount(maxRetries) ?? retryCount(this.api.maxRetries()) ?? DEFAULT_MAX_RETRIES, + /** Sends the content's hash, and uploads the content only when PostHog does not hold it. */ + private async sendLocalTemplate(options: SendCodeEmailOptions, delivery: Delivery): Promise { + const content = { subject: options.subject, html: options.html, text: options.text } + const body = localTemplateBody(options, await emailContentHash(content)) + const sent = await this.post(body, delivery) + if (!isOutdated(sent)) { + return sent } + const uploaded = await this.exchange( + { method: 'PUT', path: UPLOAD_PATH(options.templateKey), body: uploadBody(options.templateKey, content) }, + delivery + ) + return uploaded.error ? uploaded : this.post(body, delivery) + } + + private async post(body: JsonType, delivery: Delivery): Promise { + const outcome = await this.exchange({ method: 'POST', path: SEND_PATH, body }, delivery) + return outcome.error ? outcome : queuedEmail(outcome.data) } - private async deliver(send: PreparedSend): Promise { - const body = JSON.stringify(send.body) + private async exchange(request: ApiRequest, delivery: Delivery): Promise> { + const body = JSON.stringify(request.body) for (let retry = 0; ; retry++) { - const { result, retryAfterMs } = await this.attempt(send, body) - if (retry >= send.maxRetries || !isRetryable(result)) { - return this.logWarnings(result) + const { outcome, retryAfterMs } = await this.attempt(request, body, delivery) + if (retry >= delivery.maxRetries || !isRetryable(outcome)) { + return outcome } await sleep(retryDelayMs(retry, retryAfterMs)) } } - private async attempt(send: PreparedSend, body: string): Promise { + private async attempt(request: ApiRequest, body: string, delivery: Delivery): Promise { const headers = { ...this.api.headers(), 'Content-Type': 'application/json', Authorization: `Bearer ${this.api.secretKey()}`, - 'Idempotency-Key': send.idempotencyKey, + 'Idempotency-Key': delivery.idempotencyKey, } let response: PostHogApiResponse try { - response = await this.api.request(`${this.api.appHost()}${send.path}`, headers, body) + response = await this.api.request(request.method, `${this.api.appHost()}${request.path}`, headers, body) } catch (error) { - return { result: failure('network_error', error instanceof Error ? error.message : 'Request failed') } + return { outcome: failure('network_error', error instanceof Error ? error.message : 'Request failed') } } - return { result: sendResult(response), retryAfterMs: response.retryAfterMs } + const outcome = isSuccess(response.status) + ? { data: response, error: null } + : { data: null, error: apiError(response) } + return { outcome, retryAfterMs: response.retryAfterMs } } private logWarnings(result: SendEmailResult): SendEmailResult { @@ -257,18 +345,109 @@ export class PostHogEmails implements Emails { } } -function sendResult(response: PostHogApiResponse): SendEmailResult { - const { status, body } = response - if (!isSuccess(status)) { - return { data: null, error: apiError(response) } +function isTemplateForm(options: SendEmailOptions): options is SendTemplateEmailOptions { + return options.templateId !== undefined +} + +function formRefusal(options: SendEmailOptions): Failure | undefined { + if ((options.templateId === undefined) === (options.templateKey === undefined)) { + return failure('invalid_form', 'Send either templateId, or templateKey with subject and html or text.') + } + if (isTemplateForm(options)) { + return isValidUUID(options.templateId) + ? undefined + : failure('invalid_template_id', 'templateId must be the UUID of a template.', { field: 'templateId' }) + } + if (typeof options.templateKey !== 'string' || !TEMPLATE_KEY_PATTERN.test(options.templateKey)) { + return failure( + 'invalid_template_key', + 'templateKey can only have letters, digits, hyphens (-) and underscores (_), at most 400.', + { field: 'templateKey' } + ) + } + return undefined +} + +function idempotencyKeyRefusal(idempotencyKey: string | undefined): Failure | undefined { + if (idempotencyKey === undefined || isIdempotencyKey(idempotencyKey)) { + return undefined + } + return failure( + 'invalid_idempotency_key', + 'idempotencyKey must be 1 to 255 visible ASCII characters, with no spaces.', + { + field: 'idempotencyKey', + } + ) +} + +function templateBody(options: SendTemplateEmailOptions): JsonType { + return { + template: { id: options.templateId }, + ...recipientFields(options), + ...templateDataField(options.templateData), + } +} + +function renderedContentBody(options: SendCodeEmailOptions): JsonType { + return { + template_key: options.templateKey, + subject: options.subject, + ...definedFields({ html: options.html, text: options.text }), + ...recipientFields(options), + ...senderFields(options), + } +} + +function localTemplateBody(options: SendCodeEmailOptions, hash: string): JsonType { + return { + template: { key: options.templateKey, hash }, + ...recipientFields(options), + ...templateDataField(options.templateData), + ...senderFields(options), + } +} + +function uploadBody(key: string, content: { subject: string; html?: string; text?: string }): JsonType { + return { name: key, ...definedFields(content), if_code_managed: true, if_not_superseded: true } +} + +function recipientFields({ to, distinctId, captureEngagement }: SendEmailBase): Record { + return { + to, + ...(distinctId !== undefined && { identifiers: { distinct_id: distinctId } }), + ...(captureEngagement !== undefined && { capture_engagement: captureEngagement }), + } +} + +function senderFields({ from, replyTo }: SendCodeEmailOptions): Record { + return { + ...(from !== undefined && { from }), + ...(replyTo !== undefined && { reply_to: typeof replyTo === 'string' ? [replyTo] : replyTo }), } +} + +function templateDataField(templateData: Record | undefined): Record { + return templateData === undefined ? {} : { message_data: templateData as JsonType } +} + +function definedFields(fields: Record): Record { + return Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined)) as Record +} + +/** PostHog does not hold this local template's content, so nothing was sent and an upload fixes it. */ +function isOutdated({ error }: SendEmailResult): boolean { + return error?.statusCode === 409 && error.code === 'template_outdated' +} + +function queuedEmail({ status, body }: PostHogApiResponse): SendEmailResult { if (!isRecord(body) || typeof body.message_id !== 'string') { return failure('api_error', `PostHog answered HTTP ${status} with an unexpected body.`, { statusCode: status }) } return { data: { id: body.message_id, warnings: warningsOf(body.warnings) }, error: null } } -function isRetryable({ error }: SendEmailResult): boolean { +function isRetryable({ error }: Outcome): boolean { switch (error?.name) { case 'concurrent_idempotent_requests': case 'rate_limit_exceeded': @@ -316,6 +495,7 @@ function errorName(code: string, statusCode?: number): EmailErrorName { return 'service_unavailable' case 'invalid_form': case 'invalid_template_id': + case 'invalid_template_key': case 'invalid_idempotency_key': return 'validation_error' } From 47d7e79f9a10591883378d2e92c70bf8580febf5 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 22 Oct 2026 03:46:26 +0000 Subject: [PATCH 2/6] feat(node): export the code form types of emails.send Co-Authored-By: Claude Opus 5.5 --- packages/node/src/exports.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/node/src/exports.ts b/packages/node/src/exports.ts index 1ef4b53f4e..c53a235b4a 100644 --- a/packages/node/src/exports.ts +++ b/packages/node/src/exports.ts @@ -4,14 +4,17 @@ export * from './types' export { FeatureFlagEvaluations } from './feature-flag-evaluations' export type { + CodeEmailForm, EmailError, EmailErrorName, Emails, EmailWarning, + SendCodeEmailOptions, SendEmailBase, SendEmailOptions, SendEmailResult, SendTemplateEmailOptions, + TemplateEmailForm, } from './emails' export type { FlagDefinitionCacheData, From b7a28358c8a00cc15518ff775e3e33d70126495d Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 22 Oct 2026 04:03:24 +0000 Subject: [PATCH 3/6] fix(node): send a local template to the recipient it was called with A local template send read its options after hashing the content, so a caller that changed the same options object before the send finished sent to the new recipient. Co-Authored-By: Claude Opus 5.5 --- packages/node/src/__tests__/emails.spec.ts | 12 ++++++++++++ packages/node/src/emails.ts | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/node/src/__tests__/emails.spec.ts b/packages/node/src/__tests__/emails.spec.ts index de457835fb..c3ad0880b0 100644 --- a/packages/node/src/__tests__/emails.spec.ts +++ b/packages/node/src/__tests__/emails.spec.ts @@ -251,6 +251,18 @@ describe('emails', () => { expect(api.localTemplate('welcome')?.html).toBe('

Hi again

') }) + it('sends a local template to the recipient it was called with, even when the caller reuses the options', async () => { + const posthog = createClient() + const options = { to: JANE, ...welcome, templateData: { name: 'Jane' } } + + const toJane = posthog.emails.send(options) + options.to = 'bob@example.com' + const toBob = posthog.emails.send(options) + await Promise.all([toJane, toBob]) + + expect(api.delivered.map(({ to }) => to).sort()).toEqual(['bob@example.com', JANE]) + }) + it('returns the refusal of a key edited in PostHog, and sends nothing', async () => { api.editInPostHog('welcome', { subject: 'Edited', html: '

Edited

' }) diff --git a/packages/node/src/emails.ts b/packages/node/src/emails.ts index fbef2124e3..fd6cc2714b 100644 --- a/packages/node/src/emails.ts +++ b/packages/node/src/emails.ts @@ -248,7 +248,7 @@ export class PostHogEmails implements Emails { maxRetries: retryCount(options.maxRetries) ?? retryCount(this.api.maxRetries()) ?? DEFAULT_MAX_RETRIES, } return this.api.track( - this.sendForm(options, delivery) + this.sendForm({ ...options }, delivery) .then((result) => this.logWarnings(result)) .catch(unexpectedFailure) ) From ea1e2e70f5a17b99bbd48f3f794d66bd446a11d4 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:53:54 +0000 Subject: [PATCH 4/6] docs(node): say that HTML with no text sends without a text part Co-Authored-By: Claude Opus 5.5 --- packages/node/src/emails.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/node/src/emails.ts b/packages/node/src/emails.ts index fd6cc2714b..c64f7944d3 100644 --- a/packages/node/src/emails.ts +++ b/packages/node/src/emails.ts @@ -74,7 +74,10 @@ interface CodeEmailFields { templateData?: Record } -/** Email content from your code, named by a key. Needs `html`, `text` or both; PostHog derives a missing `text`. */ +/** + * Email content from your code, named by a key. Needs `html`, `text` or both. PostHog derives a missing `text` from + * the `html`, and sends HTML with no text in it, such as a single image, without a text part. + */ export type CodeEmailForm = CodeEmailFields & ({ html: string; text?: string } | { html?: string; text: string }) type Without = { [K in keyof T]?: never } From 61d2670e2a019591fa2784abd6ae57dc37ab6129 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:54:00 +0000 Subject: [PATCH 5/6] chore(node): regenerate the API references Co-Authored-By: Claude Opus 5.5 --- .../posthog-node-references-latest.json | 176 ++++++++++++++---- 1 file changed, 135 insertions(+), 41 deletions(-) diff --git a/packages/node/references/posthog-node-references-latest.json b/packages/node/references/posthog-node-references-latest.json index a6cd6fa5bd..6dba353e8d 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": "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": "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 when PostHog does not hold its content yet.", + "type": "Record", + "name": "templateData" + }, + { + "description": "Names the email, such as `'password-reset'`, so its metrics, logs and status group under it in PostHog.", + "type": "string", + "name": "templateKey" + } + ], + "path": "src/emails.ts" + }, + { + "id": "CodeEmailForm", + "name": "CodeEmailForm", + "properties": [], + "path": "src/emails.ts", + "example": "CodeEmailFields & ({\n html: string;\n text?: string;\n} | {\n html?: string;\n text: string;\n})" + }, { "id": "Coercer", "name": "Coercer", @@ -2113,7 +2150,7 @@ "name": "EmailError", "properties": [ { - "description": "PostHog's error code, such as `invalid_recipient`, or one of the SDK's own: `missing_secret_key`, `missing_app_host`, `client_disabled`, `client_closed`, `network_error`, `api_error` for an answer without a code, `unexpected_error`, and `invalid_form`, `invalid_template_id` and `invalid_idempotency_key` for a send the SDK refused before any request.", + "description": "PostHog's error code, such as `invalid_recipient`, or one of the SDK's own: `missing_secret_key`, `missing_app_host`, `client_disabled`, `network_error`, `api_error` for an answer without a code, `unexpected_error`, and `invalid_form`, `invalid_template_id`, `invalid_template_key` and `invalid_idempotency_key` for a send the SDK refused before any request.", "type": "string", "name": "code" }, @@ -3819,7 +3856,7 @@ "name": "traces" }, { - "description": "Credential for fetching local feature flag definitions and decrypting remote config.\n\nAccepts either a Personal API Key (`phx_...`) or a Project Secret API Key (`phs_...`).\nLocal evaluation can also use definitions from `flagDefinitionCacheProvider` without\nthis credential. Remote config payload decryption via `getRemoteConfigPayload`\nstill requires it. Prefer this over the deprecated\n`personalApiKey` option; when both are set, `secretKey` takes precedence.\n\n`posthog.emails` sends with it too, which needs a project secret API key with the `hog_flow:write` scope. When\nthat key can't read feature flags, also set `enableLocalEvaluation: false`.", + "description": "Credential for fetching local feature flag definitions and decrypting remote config.\n\nAccepts either a Personal API Key (`phx_...`) or a Project Secret API Key (`phs_...`).\nLocal evaluation can also use definitions from `flagDefinitionCacheProvider` without\nthis credential. Remote config payload decryption via `getRemoteConfigPayload`\nstill requires it. Prefer this over the deprecated\n`personalApiKey` option; when both are set, `secretKey` takes precedence.", "type": "string", "name": "secretKey" }, @@ -3835,7 +3872,7 @@ "name": "appHost" }, { - "description": "How often `posthog.emails` retries a send that PostHog did not queue, a whole number from 0. Defaults to 2. `0`\nturns retries off.", + "description": "How often `posthog.emails` retries a send that PostHog did not queue. Defaults to 2. `0` turns retries off.", "type": "number", "name": "maxRetries" }, @@ -4303,22 +4340,34 @@ ], "path": "../core/src/utils/index.ts" }, + { + "id": "SendCodeEmailOptions", + "name": "SendCodeEmailOptions", + "properties": [], + "path": "src/emails.ts", + "example": "SendEmailBase & CodeEmailForm & Without>" + }, { "id": "SendEmailBase", "name": "SendEmailBase", "properties": [ + { + "description": "Whether PostHog captures the email's engagement events (sent, delivered, opened, clicked, bounced) as analytics events of the recipient. They count as analytics events. Without it, PostHog decides: on for the code form.", + "type": "boolean", + "name": "captureEngagement" + }, { "description": "The recipient's distinct ID in your app. The email's events, such as sent, opened and clicked, land on this person and count as analytics events. Always pass it: without it, they land on a person keyed by the address.", "type": "string", "name": "distinctId" }, { - "description": "Sends with a key PostHog already accepted queue nothing and return the first send's `id`, even after the email's workflow was paused or the default sender changed. A deleted template, or a project that no longer has a default sender, still refuses the send. Without it, the SDK makes a UUID v4 that covers its own retries. When a job runner retries the send, pass a key derived from what the email is for, such as `welcome:user_42`, and set `maxRetries: 0`.", + "description": "Sends with a key PostHog already accepted queue nothing and return the first send's `id`, even after the email's workflow was paused or the default sender changed. A deleted or changed template, or a project with no default sender, still refuses the send. Without it, the SDK makes a UUID v4 that covers its own retries. When a job runner retries the send, pass a key derived from what the email is for, such as `welcome:user_42`, and set `maxRetries: 0`.", "type": "string", "name": "idempotencyKey" }, { - "description": "How often to retry this send, a whole number from 0. Overrides the `maxRetries` client option.", + "description": "How often to retry this send. Overrides the `maxRetries` client option.", "type": "number", "name": "maxRetries" }, @@ -4333,39 +4382,9 @@ { "id": "SendEmailOptions", "name": "SendEmailOptions", - "properties": [ - { - "description": "The UUID of the template.", - "type": "string", - "name": "templateId" - }, - { - "description": "Values for the template's `{{ variables.* }}`. The template can't read person properties.", - "type": "Record", - "name": "templateData" - }, - { - "description": "One recipient email address.", - "type": "string", - "name": "to" - }, - { - "description": "The recipient's distinct ID in your app. The email's events, such as sent, opened and clicked, land on this\nperson and count as analytics events. Always pass it: without it, they land on a person keyed by the address.", - "type": "string", - "name": "distinctId" - }, - { - "description": "Sends with a key PostHog already accepted queue nothing and return the first send's `id`, even after the email's\nworkflow was paused or the default sender changed. A deleted template, or a project that no longer has a default\nsender, still refuses the send. Without it, the SDK makes a UUID v4 that covers its own retries. When a job runner\nretries the send, pass a key derived from what the email is for, such as `welcome:user_42`, and set\n`maxRetries: 0`.", - "type": "string", - "name": "idempotencyKey" - }, - { - "description": "How often to retry this send, a whole number from 0. Overrides the `maxRetries` client option.", - "type": "number", - "name": "maxRetries" - } - ], - "path": "src/emails.ts" + "properties": [], + "path": "src/emails.ts", + "example": "SendTemplateEmailOptions | SendCodeEmailOptions" }, { "id": "SendEmailResult", @@ -4416,14 +4435,65 @@ "name": "SendTemplateEmailOptions", "properties": [ { - "description": "Values for the template's `{{ variables.* }}`. The template can't read person properties.", - "type": "Record", - "name": "templateData" + "description": "One recipient email address.", + "type": "string", + "name": "to" + }, + { + "description": "The recipient's distinct ID in your app. The email's events, such as sent, opened and clicked, land on this\nperson and count as analytics events. Always pass it: without it, they land on a person keyed by the address.", + "type": "string", + "name": "distinctId" + }, + { + "description": "Sends with a key PostHog already accepted queue nothing and return the first send's `id`, even after the email's\nworkflow was paused or the default sender changed. A deleted or changed template, or a project with no default\nsender, still refuses the send. Without it, the SDK makes a UUID v4 that covers its own retries. When a job runner retries the send, pass a key derived from what\nthe email is for, such as `welcome:user_42`, and set `maxRetries: 0`.", + "type": "string", + "name": "idempotencyKey" + }, + { + "description": "Whether PostHog captures the email's engagement events (sent, delivered, opened, clicked, bounced) as analytics\nevents of the recipient. They count as analytics events. Without it, PostHog decides: on for the code form.", + "type": "boolean", + "name": "captureEngagement" + }, + { + "description": "How often to retry this send. Overrides the `maxRetries` client option.", + "type": "number", + "name": "maxRetries" }, { "description": "The UUID of the template.", "type": "string", "name": "templateId" + }, + { + "description": "Values for the template's `{{ variables.* }}`. The template can't read person properties.", + "type": "Record", + "name": "templateData" + }, + { + "description": "Names the email, such as `'password-reset'`, so its metrics, logs and status group under it in PostHog.", + "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" + }, + { + "type": "never", + "name": "html" + }, + { + "type": "never", + "name": "text" } ], "path": "src/emails.ts" @@ -5073,6 +5143,23 @@ "path": "../core/src/types.ts", "example": "(typeof SurveyWidgetType)[keyof typeof SurveyWidgetType]" }, + { + "id": "TemplateEmailForm", + "name": "TemplateEmailForm", + "properties": [ + { + "description": "Values for the template's `{{ variables.* }}`. The template can't read person properties.", + "type": "Record", + "name": "templateData" + }, + { + "description": "The UUID of the template.", + "type": "string", + "name": "templateId" + } + ], + "path": "src/emails.ts" + }, { "id": "UnresolvedFlagReason", "name": "UnresolvedFlagReason", @@ -5094,6 +5181,13 @@ } ], "path": "src/types.ts" + }, + { + "id": "Without", + "name": "Without", + "properties": [], + "path": "src/emails.ts", + "example": "{\n [K in keyof T]?: never;\n}" } ], "categories": [ From 65fcbb97d2170240cc754a98cca5e69b35ccf15d Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:27:03 +0000 Subject: [PATCH 6/6] fix(node): refuse code fields on a template send and copy a local send's data - A template send with from, replyTo, subject, html or text returns invalid_form on that field, instead of sending without it. - A code send without a subject, or without html or text, returns invalid_form before any request. - A local template send copies its request body before it hashes the content, so a caller that changes templateData during the send changes nothing. - The round-trip tests expect a fixed content hash. Co-Authored-By: Claude Opus 5.5 --- .../posthog-node-references-latest.json | 14 +++--- packages/node/src/__tests__/emails.spec.ts | 43 +++++++++++----- packages/node/src/emails.ts | 50 +++++++++++++++---- 3 files changed, 77 insertions(+), 30 deletions(-) diff --git a/packages/node/references/posthog-node-references-latest.json b/packages/node/references/posthog-node-references-latest.json index 6dba353e8d..3e5a24ea4b 100644 --- a/packages/node/references/posthog-node-references-latest.json +++ b/packages/node/references/posthog-node-references-latest.json @@ -2150,7 +2150,7 @@ "name": "EmailError", "properties": [ { - "description": "PostHog's error code, such as `invalid_recipient`, or one of the SDK's own: `missing_secret_key`, `missing_app_host`, `client_disabled`, `network_error`, `api_error` for an answer without a code, `unexpected_error`, and `invalid_form`, `invalid_template_id`, `invalid_template_key` and `invalid_idempotency_key` for a send the SDK refused before any request.", + "description": "PostHog's error code, such as `invalid_recipient`, or one of the SDK's own: `missing_secret_key`, `missing_app_host`, `client_disabled`, `client_closed`, `network_error`, `api_error` for an answer without a code, `unexpected_error`, and `invalid_form`, `invalid_template_id`, `invalid_template_key` and `invalid_idempotency_key` for a send the SDK refused before any request.", "type": "string", "name": "code" }, @@ -3856,7 +3856,7 @@ "name": "traces" }, { - "description": "Credential for fetching local feature flag definitions and decrypting remote config.\n\nAccepts either a Personal API Key (`phx_...`) or a Project Secret API Key (`phs_...`).\nLocal evaluation can also use definitions from `flagDefinitionCacheProvider` without\nthis credential. Remote config payload decryption via `getRemoteConfigPayload`\nstill requires it. Prefer this over the deprecated\n`personalApiKey` option; when both are set, `secretKey` takes precedence.", + "description": "Credential for fetching local feature flag definitions and decrypting remote config.\n\nAccepts either a Personal API Key (`phx_...`) or a Project Secret API Key (`phs_...`).\nLocal evaluation can also use definitions from `flagDefinitionCacheProvider` without\nthis credential. Remote config payload decryption via `getRemoteConfigPayload`\nstill requires it. Prefer this over the deprecated\n`personalApiKey` option; when both are set, `secretKey` takes precedence.\n\n`posthog.emails` sends with it too, which needs a project secret API key with the `hog_flow:write` scope. When\nthat key can't read feature flags, also set `enableLocalEvaluation: false`.", "type": "string", "name": "secretKey" }, @@ -3872,7 +3872,7 @@ "name": "appHost" }, { - "description": "How often `posthog.emails` retries a send that PostHog did not queue. Defaults to 2. `0` turns retries off.", + "description": "How often `posthog.emails` retries a send that PostHog did not queue, a whole number from 0. Defaults to 2. `0`\nturns retries off.", "type": "number", "name": "maxRetries" }, @@ -4362,12 +4362,12 @@ "name": "distinctId" }, { - "description": "Sends with a key PostHog already accepted queue nothing and return the first send's `id`, even after the email's workflow was paused or the default sender changed. A deleted or changed template, or a project with no default sender, still refuses the send. Without it, the SDK makes a UUID v4 that covers its own retries. When a job runner retries the send, pass a key derived from what the email is for, such as `welcome:user_42`, and set `maxRetries: 0`.", + "description": "Sends with a key PostHog already accepted queue nothing and return the first send's `id`, even after the email's workflow was paused or the default sender changed. A deleted template, a local template whose content changed, or a project that no longer has a default sender while you name no `from`, still refuses the send. Without it, the SDK makes a UUID v4 that covers its own retries. When a job runner retries the send, pass a key derived from what the email is for, such as `welcome:user_42`, and set `maxRetries: 0`.", "type": "string", "name": "idempotencyKey" }, { - "description": "How often to retry this send. Overrides the `maxRetries` client option.", + "description": "How often to retry this send, a whole number from 0. Overrides the `maxRetries` client option.", "type": "number", "name": "maxRetries" }, @@ -4445,7 +4445,7 @@ "name": "distinctId" }, { - "description": "Sends with a key PostHog already accepted queue nothing and return the first send's `id`, even after the email's\nworkflow was paused or the default sender changed. A deleted or changed template, or a project with no default\nsender, still refuses the send. Without it, the SDK makes a UUID v4 that covers its own retries. When a job runner retries the send, pass a key derived from what\nthe email is for, such as `welcome:user_42`, and set `maxRetries: 0`.", + "description": "Sends with a key PostHog already accepted queue nothing and return the first send's `id`, even after the email's\nworkflow was paused or the default sender changed. A deleted template, a local template whose content changed, or\na project that no longer has a default sender while you name no `from`, still refuses the send. Without it, the SDK\nmakes a UUID v4 that covers its own retries. When a job runner retries the send, pass a key derived from what the\nemail is for, such as `welcome:user_42`, and set `maxRetries: 0`.", "type": "string", "name": "idempotencyKey" }, @@ -4455,7 +4455,7 @@ "name": "captureEngagement" }, { - "description": "How often to retry this send. Overrides the `maxRetries` client option.", + "description": "How often to retry this send, a whole number from 0. Overrides the `maxRetries` client option.", "type": "number", "name": "maxRetries" }, diff --git a/packages/node/src/__tests__/emails.spec.ts b/packages/node/src/__tests__/emails.spec.ts index c3ad0880b0..db49808986 100644 --- a/packages/node/src/__tests__/emails.spec.ts +++ b/packages/node/src/__tests__/emails.spec.ts @@ -1,6 +1,5 @@ import { PostHog, PostHogOptions } from '@/entrypoints/index.node' import type { SendEmailOptions } from '@/emails' -import { emailContentHash } from '@/email-content-hash' import { type Fault, FakeEmailsApi, refusalBody } from './utils/fake-emails-api' vi.mock('../version', () => ({ version: '1.2.3' })) @@ -11,6 +10,7 @@ const APP_HOST = 'https://posthog.example.com' const TEMPLATE_ID = '0190f6d2-7c1e-4b3a-9f5e-1c2d3e4f5a6b' const SEND = 'POST /api/projects/@current/messaging/send/email/' const UPLOAD_WELCOME = 'PUT /api/projects/@current/messaging_templates/by_key/welcome/' +const WELCOME_HASH = 'v1:e78af62206b6fdc0bd87d56d2a70555a9bb8d4a37617dcbb8a746f86f7db7a63' const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ describe('emails', () => { @@ -218,7 +218,7 @@ describe('emails', () => { expect(requestLog()).toEqual([SEND, UPLOAD_WELCOME, SEND]) const [firstSend, upload, secondSend] = api.received expect(firstSend.body).toEqual({ - template: { key: 'welcome', hash: await emailContentHash(welcome) }, + template: { key: 'welcome', hash: WELCOME_HASH }, to: JANE, message_data: { name: 'Jane' }, identifiers: { distinct_id: 'user_42' }, @@ -251,16 +251,20 @@ describe('emails', () => { expect(api.localTemplate('welcome')?.html).toBe('

Hi again

') }) - it('sends a local template to the recipient it was called with, even when the caller reuses the options', async () => { + it('sends a local template with the recipient and data it was called with, even when the caller reuses them', async () => { const posthog = createClient() const options = { to: JANE, ...welcome, templateData: { name: 'Jane' } } const toJane = posthog.emails.send(options) options.to = 'bob@example.com' + options.templateData.name = 'Bob' const toBob = posthog.emails.send(options) await Promise.all([toJane, toBob]) - expect(api.delivered.map(({ to }) => to).sort()).toEqual(['bob@example.com', JANE]) + expect(api.delivered.map(({ to, variables }) => [to, variables]).sort()).toEqual([ + ['bob@example.com', { name: 'Bob' }], + [JANE, { name: 'Jane' }], + ]) }) it('returns the refusal of a key edited in PostHog, and sends nothing', async () => { @@ -417,16 +421,29 @@ describe('emails', () => { expect(api.received).toEqual([]) }) - it('returns invalid_form for an option it does not take, such as Resend scheduledAt, without a request', async () => { - const result = await createClient().emails.send({ - to: JANE, - templateId: TEMPLATE_ID, - scheduledAt: 'in 1 hour', - } 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'], + ['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', { templateKey: 'welcome', subject: 'Hi', html: 1 }, 'html'], + [ + 'Resend scheduledAt, which it does not take', + { templateId: TEMPLATE_ID, scheduledAt: 'in 1 hour' }, + 'scheduledAt', + ], + [ + 'Resend scheduledAt on a code send', + { templateKey: 'welcome', subject: 'Hi', html: '

Hi

', scheduledAt: 'in 1 hour' }, + 'scheduledAt', + ], + ])('returns invalid_form for %s without a request', async (_, form, field) => { + const result = await createClient().emails.send({ to: JANE, ...form } as unknown as SendEmailOptions) - expect(result.error).toEqual( - expect.objectContaining({ name: 'validation_error', code: 'invalid_form', field: 'scheduledAt' }) - ) + expect(result.error).toEqual(expect.objectContaining({ name: 'validation_error', code: 'invalid_form', field })) expect(api.received).toEqual([]) }) diff --git a/packages/node/src/emails.ts b/packages/node/src/emails.ts index c64f7944d3..3980e969f5 100644 --- a/packages/node/src/emails.ts +++ b/packages/node/src/emails.ts @@ -5,6 +5,7 @@ const SEND_PATH = '/api/projects/@current/messaging/send/email/' const UPLOAD_PATH = (key: string): string => `/api/projects/@current/messaging_templates/by_key/${key}/` const TEMPLATE_KEY_PATTERN = /^[a-zA-Z0-9_-]{1,400}$/ +const CODE_ONLY_FIELDS = ['from', 'replyTo', 'subject', 'html', 'text'] as const const IDEMPOTENCY_KEY_PATTERN = /^[\x21-\x7e]{1,255}$/ const SEND_OPTIONS = new Set([ 'to', @@ -36,10 +37,10 @@ export interface SendEmailBase { distinctId?: string /** * Sends with a key PostHog already accepted queue nothing and return the first send's `id`, even after the email's - * workflow was paused or the default sender changed. A deleted template, or a project that no longer has a default - * sender, still refuses the send. Without it, the SDK makes a UUID v4 that covers its own retries. When a job runner - * retries the send, pass a key derived from what the email is for, such as `welcome:user_42`, and set - * `maxRetries: 0`. + * workflow was paused or the default sender changed. A deleted template, a local template whose content changed, or + * a project that no longer has a default sender while you name no `from`, still refuses the send. Without it, the SDK + * makes a UUID v4 that covers its own retries. When a job runner retries the send, pass a key derived from what the + * email is for, such as `welcome:user_42`, and set `maxRetries: 0`. */ idempotencyKey?: string /** @@ -251,7 +252,7 @@ export class PostHogEmails implements Emails { maxRetries: retryCount(options.maxRetries) ?? retryCount(this.api.maxRetries()) ?? DEFAULT_MAX_RETRIES, } return this.api.track( - this.sendForm({ ...options }, delivery) + this.sendForm(options, delivery) .then((result) => this.logWarnings(result)) .catch(unexpectedFailure) ) @@ -292,14 +293,16 @@ export class PostHogEmails implements Emails { /** Sends the content's hash, and uploads the content only when PostHog does not hold it. */ private async sendLocalTemplate(options: SendCodeEmailOptions, delivery: Delivery): Promise { + const { templateKey } = options const content = { subject: options.subject, html: options.html, text: options.text } - const body = localTemplateBody(options, await emailContentHash(content)) + const fields = jsonCopy(localTemplateFields(options)) + const body = { template: { key: templateKey, hash: await emailContentHash(content) }, ...fields } const sent = await this.post(body, delivery) if (!isOutdated(sent)) { return sent } const uploaded = await this.exchange( - { method: 'PUT', path: UPLOAD_PATH(options.templateKey), body: uploadBody(options.templateKey, content) }, + { method: 'PUT', path: UPLOAD_PATH(templateKey), body: uploadBody(templateKey, content) }, delivery ) return uploaded.error ? uploaded : this.post(body, delivery) @@ -358,7 +361,7 @@ function formRefusal(options: SendEmailOptions): Failure | undefined { } if (isTemplateForm(options)) { return isValidUUID(options.templateId) - ? undefined + ? codeOnlyFieldRefusal(options) : failure('invalid_template_id', 'templateId must be the UUID of a template.', { field: 'templateId' }) } if (typeof options.templateKey !== 'string' || !TEMPLATE_KEY_PATTERN.test(options.templateKey)) { @@ -368,6 +371,30 @@ function formRefusal(options: SendEmailOptions): Failure | undefined { { field: 'templateKey' } ) } + return contentRefusal(options) +} + +function codeOnlyFieldRefusal(options: SendTemplateEmailOptions): Failure | undefined { + const field = CODE_ONLY_FIELDS.find((name) => options[name] !== undefined) + if (field === undefined) { + return undefined + } + return failure('invalid_form', `Send ${field} with templateKey. A template made in PostHog sets its own.`, { field }) +} + +function contentRefusal({ subject, html, text }: SendCodeEmailOptions): Failure | undefined { + if (typeof subject !== 'string') { + return failure('invalid_form', 'A send with templateKey needs a subject.', { field: 'subject' }) + } + if (html !== undefined && typeof html !== 'string') { + return failure('invalid_form', 'html must be a string.', { field: 'html' }) + } + if (text !== undefined && typeof text !== 'string') { + return failure('invalid_form', 'text must be a string.', { field: 'text' }) + } + if (html === undefined && text === undefined) { + return failure('invalid_form', 'A send with templateKey needs html, text or both.', { field: 'html' }) + } return undefined } @@ -402,9 +429,8 @@ function renderedContentBody(options: SendCodeEmailOptions): JsonType { } } -function localTemplateBody(options: SendCodeEmailOptions, hash: string): JsonType { +function localTemplateFields(options: SendCodeEmailOptions): Record { return { - template: { key: options.templateKey, hash }, ...recipientFields(options), ...templateDataField(options.templateData), ...senderFields(options), @@ -434,6 +460,10 @@ function templateDataField(templateData: Record | undefined): R return templateData === undefined ? {} : { message_data: templateData as JsonType } } +function jsonCopy(value: T): T { + return JSON.parse(JSON.stringify(value)) as T +} + function definedFields(fields: Record): Record { return Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined)) as Record }