From d17acf93db9a894b7a1c1df55af28b3079427456 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 29 Oct 2026 04:34:29 +0000 Subject: [PATCH 1/7] feat(node): send content from code with emails.send A send now takes one of two forms on the same send endpoint: a template made in PostHog by templateId, or content your code rendered with templateKey, subject, html or text, and an optional from and replyTo. The client refuses a field outside the chosen form and a second send argument before any request. Every send carries capture_engagement, true unless the send turns it off, and the migratedFrom client option adds the X-PostHog-Migrated-From header to every email request. Co-Authored-By: Claude Opus 5.5 --- .changeset/node-email-send-code-content.md | 5 + packages/node/src/__tests__/emails.spec.ts | 155 +++++++++++++++- .../node/src/__tests__/types/emails-forms.ts | 23 +++ packages/node/src/client.ts | 1 + packages/node/src/emails/emails.ts | 171 +++++++++++++++--- packages/node/src/emails/types.ts | 67 +++++-- packages/node/src/exports.ts | 5 + packages/node/src/types.ts | 2 + 8 files changed, 393 insertions(+), 36 deletions(-) create mode 100644 .changeset/node-email-send-code-content.md create mode 100644 packages/node/src/__tests__/types/emails-forms.ts diff --git a/.changeset/node-email-send-code-content.md b/.changeset/node-email-send-code-content.md new file mode 100644 index 0000000000..b1a6d466b1 --- /dev/null +++ b/.changeset/node-email-send-code-content.md @@ -0,0 +1,5 @@ +--- +'posthog-node': minor +--- + +Send email content your code rendered with `posthog.emails.send()`: pass `templateKey`, `subject`, and `html`, `text` or both, with an optional `from` and `replyTo`. Every send captures engagement events unless it passes `captureEngagement: false`. The new `migratedFrom` client option names the email provider you moved from. diff --git a/packages/node/src/__tests__/emails.spec.ts b/packages/node/src/__tests__/emails.spec.ts index 50dcdc5c37..be8a72e6bd 100644 --- a/packages/node/src/__tests__/emails.spec.ts +++ b/packages/node/src/__tests__/emails.spec.ts @@ -1,4 +1,4 @@ -import { EmailError, PostHog, PostHogOptions } from '@/entrypoints/index.node' +import { EmailError, PostHog, PostHogOptions, SendEmailOptions } from '@/entrypoints/index.node' vi.mock('../version', () => ({ version: '1.2.3' })) @@ -106,6 +106,7 @@ describe('emails', () => { template: { id: TEMPLATE_ID }, to: 'user@example.com', message_data: { name: 'Ada', expiresAt: '2026-11-20T00:00:00.000Z' }, + capture_engagement: true, ...extraBody, }) }) @@ -119,9 +120,87 @@ describe('emails', () => { template: { id: TEMPLATE_ID }, to: 'user@example.com', message_data: {}, + capture_engagement: true, }) }) + it.each([ + [ + 'html', + { templateKey: 'password-reset', subject: 'Reset your password', html: '

Reset

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

Reset

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

Reset

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

Reset

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

Hi

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

Hi

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

Hi

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

Hi

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

Reset

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

R

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

Reset

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

Reset

', templateData: { name: 'J' } }, +] diff --git a/packages/node/src/client.ts b/packages/node/src/client.ts index 16c2cbf9f8..21be469de3 100644 --- a/packages/node/src/client.ts +++ b/packages/node/src/client.ts @@ -726,6 +726,7 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen appHost: this.options.appHost, requestTimeoutMs: positiveNumberOr(this.options.emailRequestTimeoutMs, DEFAULT_EMAIL_REQUEST_TIMEOUT_MS), maxRetries: nonNegativeIntegerOr(this.options.emailRequestMaxRetries, DEFAULT_EMAIL_REQUEST_MAX_RETRIES), + migratedFrom: this.options.migratedFrom, }, { fetch: (url, fetchOptions) => this.fetch(url, fetchOptions), diff --git a/packages/node/src/emails/emails.ts b/packages/node/src/emails/emails.ts index 5acf65fb06..21055c8829 100644 --- a/packages/node/src/emails/emails.ts +++ b/packages/node/src/emails/emails.ts @@ -9,13 +9,17 @@ import { import { EmailError } from './errors' import type { + CodeEmailForm, EmailStatus, EmailWarning, Emails, GetEmailResponse, + SendEmailBase, SendEmailOptions, SendEmailResponse, SendEmailResult, + SendTemplateEmailOptions, + TemplateEmailForm, } from './types' const SEND_PATH = '/api/projects/@current/messaging/send/email/' @@ -32,6 +36,46 @@ const CLOUD_APP_HOSTS: Record = { const MAX_RETRY_DELAY_MS = 30_000 const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i const IDEMPOTENCY_KEY_PATTERN = /^[\x21-\x7e]{1,255}$/ +const TEMPLATE_KEY_PATTERN = /^[a-zA-Z0-9_-]{1,400}$/ + +type EveryField
= Record +type UncheckedSend = { [Field in keyof SendTemplateEmailOptions]?: unknown } + +interface SendForm { + name: 'templateId' | 'templateKey' + fields: ReadonlySet + why: string + howToSend: string +} + +const SHARED_FIELDS: EveryField = { + to: true, + distinctId: true, + idempotencyKey: true, + captureEngagement: true, + maxRetries: true, +} +const TEMPLATE_FIELDS: EveryField = { templateId: true, templateData: true } +const CODE_FIELDS: EveryField = { + templateKey: true, + subject: true, + from: true, + replyTo: true, + html: true, + text: true, +} +const TEMPLATE_FORM: SendForm = { + name: 'templateId', + fields: fieldSet(SHARED_FIELDS, TEMPLATE_FIELDS), + why: 'A template made in PostHog sets its own sender, subject and content.', + howToSend: 'send a template made in PostHog with templateId', +} +const CODE_FORM: SendForm = { + name: 'templateKey', + fields: fieldSet(SHARED_FIELDS, CODE_FIELDS), + why: 'A send with templateKey takes its content from your code.', + howToSend: 'send content from your code with templateKey', +} export const DEFAULT_EMAIL_REQUEST_TIMEOUT_MS = 15_000 export const DEFAULT_EMAIL_REQUEST_MAX_RETRIES = 2 @@ -43,6 +87,7 @@ export interface EmailSenderConfig { appHost?: string requestTimeoutMs: number maxRetries: number + migratedFrom?: string } export interface EmailSenderHooks { @@ -81,8 +126,8 @@ export class EmailSender implements Emails { this.hooks = hooks } - send(options: SendEmailOptions): Promise { - return this.track(this.sendUntracked(options)) + send(options: SendEmailOptions, ...unexpected: unknown[]): Promise { + return this.track(this.sendUntracked(options, unexpected)) } get(id: string): Promise { @@ -105,8 +150,9 @@ export class EmailSender implements Emails { } } - private async sendUntracked(options: SendEmailOptions): Promise { + private async sendUntracked(options: SendEmailOptions, unexpected: unknown[]): Promise { try { + refuseSecondArgument(unexpected) const prepared = prepareSend(options, this.config.maxRetries) const { appHost, secretKey } = this.connect( 'no email was sent', @@ -186,6 +232,9 @@ export class EmailSender implements Emails { if (this.hooks.userAgent) { headers['User-Agent'] = this.hooks.userAgent } + if (this.config.migratedFrom) { + headers['X-PostHog-Migrated-From'] = this.config.migratedFrom + } return headers } @@ -274,21 +323,22 @@ export class EmailSender implements Emails { } } +function refuseSecondArgument(unexpected: unknown[]): void { + if (unexpected.some((argument) => argument !== undefined)) { + throw new EmailError({ + code: 'invalid_form', + message: 'send takes one options object, so no email was sent. Put idempotencyKey and every other option in it.', + }) + } +} + function prepareSend(options: SendEmailOptions, defaultMaxRetries: number): PreparedSend { - const fallback: Partial = {} - const { to, templateId, templateData, distinctId, idempotencyKey, maxRetries } = options ?? fallback + const send: UncheckedSend = options ?? {} + const { to, distinctId, idempotencyKey, captureEngagement, maxRetries } = send if (typeof to !== 'string' || !to.trim()) { throw invalid('invalid_recipient', 'to must be one email address as a nonempty string.', 'to') } - if (typeof templateId !== 'string' || !UUID_PATTERN.test(templateId)) { - throw invalid('invalid_template_id', "templateId must be the template's UUID.", 'templateId') - } - if ( - templateData !== undefined && - (typeof templateData !== 'object' || templateData === null || Array.isArray(templateData)) - ) { - throw invalid('invalid_template_data', 'templateData must be an object.', 'templateData') - } + const content = contentOf(send) if (distinctId !== undefined && (typeof distinctId !== 'string' || !distinctId)) { throw invalid('invalid_distinct_id', 'distinctId must be a nonempty string.', 'distinctId') } @@ -302,15 +352,11 @@ function prepareSend(options: SendEmailOptions, defaultMaxRetries: number): Prep 'idempotencyKey' ) } - if (maxRetries !== undefined && !(Number.isInteger(maxRetries) && maxRetries >= 0)) { + if (maxRetries !== undefined && !isRetryCount(maxRetries)) { throw invalid('invalid_max_retries', 'maxRetries must be a nonnegative integer.', 'maxRetries') } - const payload: Record = { - template: { id: templateId.toLowerCase() }, - to, - message_data: templateData ?? {}, - } + const payload: Record = { ...content, to, capture_engagement: captureEngagement ?? true } if (distinctId !== undefined) { payload.identifiers = { distinct_id: distinctId } } @@ -318,10 +364,93 @@ function prepareSend(options: SendEmailOptions, defaultMaxRetries: number): Prep path: SEND_PATH, body: safeJsonStringify(payload), idempotencyKey: idempotencyKey ?? globalThis.crypto.randomUUID(), - maxRetries: maxRetries ?? defaultMaxRetries, + maxRetries: isRetryCount(maxRetries) ? maxRetries : defaultMaxRetries, + } +} + +function isRetryCount(value: unknown): value is number { + return typeof value === 'number' && Number.isInteger(value) && value >= 0 +} + +function contentOf(send: UncheckedSend): Record { + if ((send.templateId === undefined) === (send.templateKey === undefined)) { + throw new EmailError({ + code: 'invalid_form', + message: 'Send either templateId, or templateKey with subject and html or text.', + }) + } + return send.templateKey === undefined ? templateContent(send) : codeContent(send) +} + +function templateContent(send: UncheckedSend): Record { + const { templateId, templateData } = send + if (typeof templateId !== 'string' || !UUID_PATTERN.test(templateId)) { + throw invalid('invalid_template_id', "templateId must be the template's UUID.", 'templateId') + } + if ( + templateData !== undefined && + (typeof templateData !== 'object' || templateData === null || Array.isArray(templateData)) + ) { + throw invalid('invalid_template_data', 'templateData must be an object.', 'templateData') + } + refuseFieldsOutside(send, TEMPLATE_FORM, CODE_FORM) + return { template: { id: templateId.toLowerCase() }, message_data: templateData ?? {} } +} + +function codeContent(send: UncheckedSend): Record { + const { templateKey, subject, html, text, from, replyTo } = send + if (typeof templateKey !== 'string' || !TEMPLATE_KEY_PATTERN.test(templateKey)) { + throw invalid( + 'invalid_template_key', + 'templateKey can only have letters, digits, hyphens (-) and underscores (_), at most 400.', + 'templateKey' + ) + } + refuseFieldsOutside(send, CODE_FORM, TEMPLATE_FORM) + if (typeof subject !== 'string') { + throw invalid('invalid_form', 'A send with templateKey needs a subject.', 'subject') + } + if (html !== undefined && typeof html !== 'string') { + throw invalid('invalid_form', 'html must be a string.', 'html') + } + if (text !== undefined && typeof text !== 'string') { + throw invalid('invalid_form', 'text must be a string.', 'text') + } + if (html === undefined && text === undefined) { + throw invalid('invalid_form', 'A send with templateKey needs html, text or both.', 'html') + } + return { + template_key: templateKey, + subject, + ...(html !== undefined && { html }), + ...(text !== undefined && { text }), + ...(from !== undefined && { from }), + ...(replyTo !== undefined && { reply_to: typeof replyTo === 'string' ? [replyTo] : replyTo }), } } +function refuseFieldsOutside(send: UncheckedSend, form: SendForm, otherForm: SendForm): void { + const field = Object.entries(send).find(([name, value]) => value !== undefined && !form.fields.has(name))?.[0] + if (field === undefined) { + return + } + const message = `A send with ${form.name} doesn't take ${field}, so no email was sent.` + if (!otherForm.fields.has(field)) { + throw new EmailError({ code: 'invalid_form', message, field, fix: `Remove ${field} and send again.` }) + } + throw new EmailError({ + code: 'invalid_form', + message, + field, + why: form.why, + fix: `Remove ${field}, or ${otherForm.howToSend}.`, + }) +} + +function fieldSet(...groups: object[]): ReadonlySet { + return new Set(groups.flatMap((group) => Object.keys(group))) +} + function invalid(code: string, message: string, field: string): EmailError { return new EmailError({ code, message, field }) } diff --git a/packages/node/src/emails/types.ts b/packages/node/src/emails/types.ts index 6b532ce411..9a95ec03c4 100644 --- a/packages/node/src/emails/types.ts +++ b/packages/node/src/emails/types.ts @@ -1,15 +1,9 @@ import type { EmailError } from './errors' -/** - * Options for `posthog.emails.send()`. - */ -export interface SendEmailOptions { +/** What every send takes, whatever its form. */ +export interface SendEmailBase { /** One recipient email address. */ to: string - /** The UUID of an email template made transactional in PostHog. */ - templateId: string - /** Values for the template's `{{ variables.* }}` placeholders. Sent as `message_data`. */ - templateData?: Record /** * The recipient's distinct ID in your product. The email's events go to this person. * Without it, they go to a person whose distinct ID is the email address. @@ -17,15 +11,60 @@ export interface SendEmailOptions { distinctId?: string /** * 1 to 255 visible ASCII characters. A send with a key that PostHog has already accepted - * queues nothing and returns the first send's result. Without it, the client makes a - * UUID that covers its own retries. Pass a key derived from what the email is for, such - * as `welcome:user_123`, to cover retries outside this call too. + * queues nothing and returns the first send's result. The same key with other options sends + * nothing and returns `invalid_idempotent_request`. Without it, the client makes a UUID that + * covers its own retries. Pass a key derived from what the email is for, such as + * `welcome:user_123`, to cover retries outside this call too. */ idempotencyKey?: string + /** + * Whether PostHog captures the email's engagement events (sent, delivered, opened, clicked, + * bounced) as events of the recipient. Defaults to `true`. A project whose admin turned + * engagement capture off captures nothing, whatever the send asks. + */ + captureEngagement?: boolean /** Overrides the client's `emailRequestMaxRetries` for this send. `0` turns retries off. */ maxRetries?: number } +/** A template made transactional in PostHog, sent by its ID. */ +export interface TemplateEmailForm { + /** The UUID of an email template made transactional in PostHog. */ + templateId: string + /** Values for the template's `{{ variables.* }}` placeholders. Sent as `message_data`. */ + templateData?: Record +} + +interface CodeEmailFields { + /** + * Names the email, such as `'password-reset'`, so its metrics, logs and status group under it + * in PostHog. Letters, digits, hyphens and underscores, at most 400. + */ + templateKey: string + subject: string + /** A verified sender, such as `'Acme '`. Without it, the project's default sender. */ + from?: string + replyTo?: string | string[] +} + +/** + * Email content your code rendered, such as React Email output, named by a key. PostHog sends it + * as is and never stores it. Needs `html`, `text` or both. PostHog derives a missing `text` from + * the `html`. + */ +export type CodeEmailForm = CodeEmailFields & ({ html: string; text?: string } | { html?: string; text: string }) + +type Without = { [K in keyof T]?: never } + +export type SendTemplateEmailOptions = SendEmailBase & + TemplateEmailForm & + Without + +export type SendCodeEmailOptions = SendEmailBase & CodeEmailForm & Without + +/** Options for `posthog.emails.send()`: a template made in PostHog, or content from your code. */ +export type SendEmailOptions = SendTemplateEmailOptions | SendCodeEmailOptions + /** A warning PostHog returned with a queued email. */ export interface EmailWarning { code: string @@ -84,12 +123,12 @@ export interface EmailStatus { export type GetEmailResponse = { data: EmailStatus; error: null } | { data: null; error: EmailError } /** - * The `posthog.emails` API: sends transactional email from PostHog email templates, and reads - * the status of each email it sent. + * The `posthog.emails` API: sends transactional email from PostHog email templates or from + * content your code rendered, and reads the status of each email it sent. */ export interface Emails { /** - * Queue one email from a transactional email template. + * Queue one email, from a transactional email template or from content your code rendered. * * Resolves to `{ data, error }` and does not reject. `data` means PostHog queued the email, * not that it was delivered. Network errors, rate limits, an in-flight idempotency key and diff --git a/packages/node/src/exports.ts b/packages/node/src/exports.ts index 9e4d13fe68..1e7b596373 100644 --- a/packages/node/src/exports.ts +++ b/packages/node/src/exports.ts @@ -7,14 +7,19 @@ export { FeatureFlagEvaluations } from './feature-flag-evaluations' export { EmailError } from './emails/errors' export type { EmailErrorName } from './emails/errors' export type { + CodeEmailForm, EmailDeliveryStatus, Emails, EmailStatus, EmailWarning, GetEmailResponse, + SendCodeEmailOptions, + SendEmailBase, SendEmailOptions, SendEmailResponse, SendEmailResult, + SendTemplateEmailOptions, + TemplateEmailForm, } from './emails/types' export type { FlagDefinitionCacheData, diff --git a/packages/node/src/types.ts b/packages/node/src/types.ts index 491535d2ba..865e276ebe 100644 --- a/packages/node/src/types.ts +++ b/packages/node/src/types.ts @@ -273,6 +273,8 @@ export type PostHogOptions = Omit Date: Thu, 29 Oct 2026 04:42:12 +0000 Subject: [PATCH 2/7] chore(node): regenerate the API references for the code send form Co-Authored-By: Claude Opus 5.5 --- .../posthog-node-references-latest.json | 164 ++++++++++++++++-- 1 file changed, 151 insertions(+), 13 deletions(-) diff --git a/packages/node/references/posthog-node-references-latest.json b/packages/node/references/posthog-node-references-latest.json index f148cab716..6a70dd3940 100644 --- a/packages/node/references/posthog-node-references-latest.json +++ b/packages/node/references/posthog-node-references-latest.json @@ -2035,6 +2035,38 @@ "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": "Names the email, such as `'password-reset'`, so its metrics, logs and status group under it in PostHog. Letters, digits, hyphens and underscores, at most 400.", + "type": "string", + "name": "templateKey" + } + ], + "path": "src/emails/types.ts" + }, + { + "id": "CodeEmailForm", + "name": "CodeEmailForm", + "properties": [], + "path": "src/emails/types.ts", + "example": "CodeEmailFields & ({\n html: string;\n text?: string;\n} | {\n html?: string;\n text: string;\n})" + }, { "id": "Coercer", "name": "Coercer", @@ -3876,6 +3908,11 @@ "type": "number", "name": "emailRequestMaxRetries" }, + { + "description": "The provider your email moved from, such as `'resend'`. Sent with every `posthog.emails` request.", + "type": "string", + "name": "migratedFrom" + }, { "description": "Redact prompts, outputs and error details from every event that the PostHog AI\nintegrations (`@posthog/ai`) capture with this client. Per-call privacy settings\ncan only add redaction. Defaults to false.", "type": "boolean", @@ -4336,16 +4373,28 @@ "path": "../core/src/utils/index.ts" }, { - "id": "SendEmailOptions", - "name": "SendEmailOptions", + "id": "SendCodeEmailOptions", + "name": "SendCodeEmailOptions", + "properties": [], + "path": "src/emails/types.ts", + "example": "SendEmailBase & CodeEmailForm & Without" + }, + { + "id": "SendEmailBase", + "name": "SendEmailBase", "properties": [ + { + "description": "Whether PostHog captures the email's engagement events (sent, delivered, opened, clicked, bounced) as events of the recipient. Defaults to `true`. A project whose admin turned engagement capture off captures nothing, whatever the send asks.", + "type": "boolean", + "name": "captureEngagement" + }, { "description": "The recipient's distinct ID in your product. The email's events go to this person. Without it, they go to a person whose distinct ID is the email address.", "type": "string", "name": "distinctId" }, { - "description": "1 to 255 visible ASCII characters. A send with a key that PostHog has already accepted queues nothing and returns the first send's result. Without it, the client makes a UUID that covers its own retries. Pass a key derived from what the email is for, such as `welcome:user_123`, to cover retries outside this call too.", + "description": "1 to 255 visible ASCII characters. A send with a key that PostHog has already accepted queues nothing and returns the first send's result. The same key with other options sends nothing and returns `invalid_idempotent_request`. Without it, the client makes a UUID that covers its own retries. Pass a key derived from what the email is for, such as `welcome:user_123`, to cover retries outside this call too.", "type": "string", "name": "idempotencyKey" }, @@ -4354,16 +4403,6 @@ "type": "number", "name": "maxRetries" }, - { - "description": "Values for the template's `{{ variables.* }}` placeholders. Sent as `message_data`.", - "type": "Record", - "name": "templateData" - }, - { - "description": "The UUID of an email template made transactional in PostHog.", - "type": "string", - "name": "templateId" - }, { "description": "One recipient email address.", "type": "string", @@ -4372,6 +4411,13 @@ ], "path": "src/emails/types.ts" }, + { + "id": "SendEmailOptions", + "name": "SendEmailOptions", + "properties": [], + "path": "src/emails/types.ts", + "example": "SendTemplateEmailOptions | SendCodeEmailOptions" + }, { "id": "SendEmailResponse", "name": "SendEmailResponse", @@ -4437,6 +4483,74 @@ "path": "../core/src/metrics/types.ts", "example": "{\n kind: 'ok';\n} | {\n kind: 'retry-later';\n error: unknown;\n retryAfterMs?: number;\n} | {\n kind: 'too-large';\n measuredLocally?: boolean;\n} | {\n kind: 'fatal';\n error: unknown;\n}" }, + { + "id": "SendTemplateEmailOptions", + "name": "SendTemplateEmailOptions", + "properties": [ + { + "description": "One recipient email address.", + "type": "string", + "name": "to" + }, + { + "description": "The recipient's distinct ID in your product. The email's events go to this person.\nWithout it, they go to a person whose distinct ID is the email address.", + "type": "string", + "name": "distinctId" + }, + { + "description": "1 to 255 visible ASCII characters. A send with a key that PostHog has already accepted\nqueues nothing and returns the first send's result. The same key with other options sends\nnothing and returns `invalid_idempotent_request`. Without it, the client makes a UUID that\ncovers its own retries. Pass a key derived from what the email is for, such as\n`welcome:user_123`, to cover retries outside this call too.", + "type": "string", + "name": "idempotencyKey" + }, + { + "description": "Whether PostHog captures the email's engagement events (sent, delivered, opened, clicked,\nbounced) as events of the recipient. Defaults to `true`. A project whose admin turned\nengagement capture off captures nothing, whatever the send asks.", + "type": "boolean", + "name": "captureEngagement" + }, + { + "description": "Overrides the client's `emailRequestMaxRetries` for this send. `0` turns retries off.", + "type": "number", + "name": "maxRetries" + }, + { + "description": "The UUID of an email template made transactional in PostHog.", + "type": "string", + "name": "templateId" + }, + { + "description": "Values for the template's `{{ variables.* }}` placeholders. Sent as `message_data`.", + "type": "Record", + "name": "templateData" + }, + { + "description": "Names the email, such as `'password-reset'`, so its metrics, logs and status group under it\nin PostHog. Letters, digits, hyphens and underscores, at most 400.", + "type": "never", + "name": "templateKey" + }, + { + "type": "never", + "name": "subject" + }, + { + "description": "A verified sender, such as `'Acme '`. Without it, the project's default sender.", + "type": "never", + "name": "from" + }, + { + "type": "never", + "name": "replyTo" + }, + { + "type": "never", + "name": "html" + }, + { + "type": "never", + "name": "text" + } + ], + "path": "src/emails/types.ts" + }, { "id": "SendTracesBatchOutcome", "name": "SendTracesBatchOutcome", @@ -5082,6 +5196,23 @@ "path": "../core/src/types.ts", "example": "(typeof SurveyWidgetType)[keyof typeof SurveyWidgetType]" }, + { + "id": "TemplateEmailForm", + "name": "TemplateEmailForm", + "properties": [ + { + "description": "Values for the template's `{{ variables.* }}` placeholders. Sent as `message_data`.", + "type": "Record", + "name": "templateData" + }, + { + "description": "The UUID of an email template made transactional in PostHog.", + "type": "string", + "name": "templateId" + } + ], + "path": "src/emails/types.ts" + }, { "id": "UnresolvedFlagReason", "name": "UnresolvedFlagReason", @@ -5103,6 +5234,13 @@ } ], "path": "src/types.ts" + }, + { + "id": "Without", + "name": "Without", + "properties": [], + "path": "src/emails/types.ts", + "example": "{\n [K in keyof T]?: never;\n}" } ], "categories": [ From 0377f10beb9bf5f2da9b7c4ade6ab83aa6f226c2 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 29 Oct 2026 06:24:04 +0000 Subject: [PATCH 3/7] fix(node): send capture_engagement only with content from code The send endpoint takes capture_engagement only on a send with template_key and refuses it on a template send. A template made in PostHog follows the project's engagement setting, so the client now refuses captureEngagement on the template form before any request and leaves it out of the template body. Co-Authored-By: Claude Opus 5.5 --- .changeset/node-email-send-code-content.md | 2 +- .../posthog-node-references-latest.json | 20 +++++++++---------- packages/node/src/__tests__/emails.spec.ts | 15 +++++++++++--- .../node/src/__tests__/types/emails-forms.ts | 2 ++ packages/node/src/emails/emails.ts | 11 +++++----- packages/node/src/emails/types.ts | 13 ++++++------ 6 files changed, 38 insertions(+), 25 deletions(-) diff --git a/.changeset/node-email-send-code-content.md b/.changeset/node-email-send-code-content.md index b1a6d466b1..4cb00f481d 100644 --- a/.changeset/node-email-send-code-content.md +++ b/.changeset/node-email-send-code-content.md @@ -2,4 +2,4 @@ 'posthog-node': minor --- -Send email content your code rendered with `posthog.emails.send()`: pass `templateKey`, `subject`, and `html`, `text` or both, with an optional `from` and `replyTo`. Every send captures engagement events unless it passes `captureEngagement: false`. The new `migratedFrom` client option names the email provider you moved from. +Send email content your code rendered with `posthog.emails.send()`: pass `templateKey`, `subject`, and `html`, `text` or both, with an optional `from` and `replyTo`. A send with `templateKey` captures engagement events unless it passes `captureEngagement: false`; a template made in PostHog follows the project setting. The new `migratedFrom` client option names the email provider you moved from. diff --git a/packages/node/references/posthog-node-references-latest.json b/packages/node/references/posthog-node-references-latest.json index 6a70dd3940..81e33821e4 100644 --- a/packages/node/references/posthog-node-references-latest.json +++ b/packages/node/references/posthog-node-references-latest.json @@ -2039,6 +2039,11 @@ "id": "CodeEmailFields", "name": "CodeEmailFields", "properties": [ + { + "description": "Whether PostHog captures the email's engagement events (sent, delivered, opened, clicked, bounced) as events of the recipient. Defaults to `true`. A project whose admin turned engagement capture off captures nothing, whatever the send asks. A template made in PostHog follows the project's setting.", + "type": "boolean", + "name": "captureEngagement" + }, { "description": "A verified sender, such as `'Acme '`. Without it, the project's default sender.", "type": "string", @@ -4383,11 +4388,6 @@ "id": "SendEmailBase", "name": "SendEmailBase", "properties": [ - { - "description": "Whether PostHog captures the email's engagement events (sent, delivered, opened, clicked, bounced) as events of the recipient. Defaults to `true`. A project whose admin turned engagement capture off captures nothing, whatever the send asks.", - "type": "boolean", - "name": "captureEngagement" - }, { "description": "The recipient's distinct ID in your product. The email's events go to this person. Without it, they go to a person whose distinct ID is the email address.", "type": "string", @@ -4502,11 +4502,6 @@ "type": "string", "name": "idempotencyKey" }, - { - "description": "Whether PostHog captures the email's engagement events (sent, delivered, opened, clicked,\nbounced) as events of the recipient. Defaults to `true`. A project whose admin turned\nengagement capture off captures nothing, whatever the send asks.", - "type": "boolean", - "name": "captureEngagement" - }, { "description": "Overrides the client's `emailRequestMaxRetries` for this send. `0` turns retries off.", "type": "number", @@ -4540,6 +4535,11 @@ "type": "never", "name": "replyTo" }, + { + "description": "Whether PostHog captures the email's engagement events (sent, delivered, opened, clicked,\nbounced) as events of the recipient. Defaults to `true`. A project whose admin turned\nengagement capture off captures nothing, whatever the send asks. A template made in PostHog\nfollows the project's setting.", + "type": "never", + "name": "captureEngagement" + }, { "type": "never", "name": "html" diff --git a/packages/node/src/__tests__/emails.spec.ts b/packages/node/src/__tests__/emails.spec.ts index be8a72e6bd..abc56ba81f 100644 --- a/packages/node/src/__tests__/emails.spec.ts +++ b/packages/node/src/__tests__/emails.spec.ts @@ -106,7 +106,6 @@ describe('emails', () => { template: { id: TEMPLATE_ID }, to: 'user@example.com', message_data: { name: 'Ada', expiresAt: '2026-11-20T00:00:00.000Z' }, - capture_engagement: true, ...extraBody, }) }) @@ -120,7 +119,6 @@ describe('emails', () => { template: { id: TEMPLATE_ID }, to: 'user@example.com', message_data: {}, - capture_engagement: true, }) }) @@ -504,6 +502,11 @@ describe('emails', () => { ['subject on the template form', { templateId: TEMPLATE_ID, subject: 'Hi' }, 'subject'], ['html on the template form', { templateId: TEMPLATE_ID, html: '

Hi

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

Hi

' }, 'subject'], ['a code send without html or text', { templateKey: 'welcome', subject: 'Hi' }, 'html'], ['a code send with html that is not a string', { ...CODE_SEND, html: 1 }, 'html'], @@ -526,9 +529,15 @@ describe('emails', () => { [ 'a sender on the template form', { templateId: TEMPLATE_ID, from: 'Acme ' }, - 'A template made in PostHog sets its own sender, subject and content.', + "A template made in PostHog sets its own sender, subject and content, and follows the project's engagement setting.", 'Remove from, or send content from your code with templateKey.', ], + [ + 'engagement capture on the template form', + { templateId: TEMPLATE_ID, captureEngagement: true }, + "A template made in PostHog sets its own sender, subject and content, and follows the project's engagement setting.", + 'Remove captureEngagement, or send content from your code with templateKey.', + ], [ 'template data on a code send', { ...CODE_SEND, templateData: { name: 'Jane' } }, diff --git a/packages/node/src/__tests__/types/emails-forms.ts b/packages/node/src/__tests__/types/emails-forms.ts index 140eb3589c..64b43469c4 100644 --- a/packages/node/src/__tests__/types/emails-forms.ts +++ b/packages/node/src/__tests__/types/emails-forms.ts @@ -12,6 +12,8 @@ export const forms: SendEmailOptions[] = [ export const wrongCombinations: SendEmailOptions[] = [ // @ts-expect-error a send takes exactly one form { to, templateId, templateKey: 'reset', subject: 'Reset', html: '

R

' }, + // @ts-expect-error a template made in PostHog follows the project's engagement capture setting + { to, templateId, captureEngagement: false }, // @ts-expect-error a template made in PostHog keeps its own sender { to, templateId, from: 'security@example.com' }, // @ts-expect-error the code form needs html, text or both diff --git a/packages/node/src/emails/emails.ts b/packages/node/src/emails/emails.ts index 21055c8829..6bd8e0373f 100644 --- a/packages/node/src/emails/emails.ts +++ b/packages/node/src/emails/emails.ts @@ -52,7 +52,6 @@ const SHARED_FIELDS: EveryField = { to: true, distinctId: true, idempotencyKey: true, - captureEngagement: true, maxRetries: true, } const TEMPLATE_FIELDS: EveryField = { templateId: true, templateData: true } @@ -61,13 +60,14 @@ const CODE_FIELDS: EveryField = { subject: true, from: true, replyTo: true, + captureEngagement: true, html: true, text: true, } const TEMPLATE_FORM: SendForm = { name: 'templateId', fields: fieldSet(SHARED_FIELDS, TEMPLATE_FIELDS), - why: 'A template made in PostHog sets its own sender, subject and content.', + why: "A template made in PostHog sets its own sender, subject and content, and follows the project's engagement setting.", howToSend: 'send a template made in PostHog with templateId', } const CODE_FORM: SendForm = { @@ -334,7 +334,7 @@ function refuseSecondArgument(unexpected: unknown[]): void { function prepareSend(options: SendEmailOptions, defaultMaxRetries: number): PreparedSend { const send: UncheckedSend = options ?? {} - const { to, distinctId, idempotencyKey, captureEngagement, maxRetries } = send + const { to, distinctId, idempotencyKey, maxRetries } = send if (typeof to !== 'string' || !to.trim()) { throw invalid('invalid_recipient', 'to must be one email address as a nonempty string.', 'to') } @@ -356,7 +356,7 @@ function prepareSend(options: SendEmailOptions, defaultMaxRetries: number): Prep throw invalid('invalid_max_retries', 'maxRetries must be a nonnegative integer.', 'maxRetries') } - const payload: Record = { ...content, to, capture_engagement: captureEngagement ?? true } + const payload: Record = { ...content, to } if (distinctId !== undefined) { payload.identifiers = { distinct_id: distinctId } } @@ -398,7 +398,7 @@ function templateContent(send: UncheckedSend): Record { } function codeContent(send: UncheckedSend): Record { - const { templateKey, subject, html, text, from, replyTo } = send + const { templateKey, subject, html, text, from, replyTo, captureEngagement } = send if (typeof templateKey !== 'string' || !TEMPLATE_KEY_PATTERN.test(templateKey)) { throw invalid( 'invalid_template_key', @@ -426,6 +426,7 @@ function codeContent(send: UncheckedSend): Record { ...(text !== undefined && { text }), ...(from !== undefined && { from }), ...(replyTo !== undefined && { reply_to: typeof replyTo === 'string' ? [replyTo] : replyTo }), + capture_engagement: captureEngagement ?? true, } } diff --git a/packages/node/src/emails/types.ts b/packages/node/src/emails/types.ts index 9a95ec03c4..7de44ddc0e 100644 --- a/packages/node/src/emails/types.ts +++ b/packages/node/src/emails/types.ts @@ -17,12 +17,6 @@ export interface SendEmailBase { * `welcome:user_123`, to cover retries outside this call too. */ idempotencyKey?: string - /** - * Whether PostHog captures the email's engagement events (sent, delivered, opened, clicked, - * bounced) as events of the recipient. Defaults to `true`. A project whose admin turned - * engagement capture off captures nothing, whatever the send asks. - */ - captureEngagement?: boolean /** Overrides the client's `emailRequestMaxRetries` for this send. `0` turns retries off. */ maxRetries?: number } @@ -45,6 +39,13 @@ interface CodeEmailFields { /** A verified sender, such as `'Acme '`. Without it, the project's default sender. */ from?: string replyTo?: string | string[] + /** + * Whether PostHog captures the email's engagement events (sent, delivered, opened, clicked, + * bounced) as events of the recipient. Defaults to `true`. A project whose admin turned + * engagement capture off captures nothing, whatever the send asks. A template made in PostHog + * follows the project's setting. + */ + captureEngagement?: boolean } /** From a57fb728bbf2ab4b9be923f09389c542029a63b1 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sat, 31 Oct 2026 08:11:33 +0000 Subject: [PATCH 4/7] fix(node): send a code send's distinct ID as a top-level field Co-Authored-By: Claude Opus 5.5 --- packages/node/src/__tests__/emails.spec.ts | 2 +- packages/node/src/emails/emails.ts | 23 +++++++++++----------- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/packages/node/src/__tests__/emails.spec.ts b/packages/node/src/__tests__/emails.spec.ts index abc56ba81f..f32496caba 100644 --- a/packages/node/src/__tests__/emails.spec.ts +++ b/packages/node/src/__tests__/emails.spec.ts @@ -182,7 +182,7 @@ describe('emails', () => { expect(JSON.parse(options.body)).toEqual({ ...body, to: 'user@example.com', - identifiers: { distinct_id: 'user_123' }, + distinct_id: 'user_123', }) }) diff --git a/packages/node/src/emails/emails.ts b/packages/node/src/emails/emails.ts index 6bd8e0373f..d17ed19994 100644 --- a/packages/node/src/emails/emails.ts +++ b/packages/node/src/emails/emails.ts @@ -338,10 +338,10 @@ function prepareSend(options: SendEmailOptions, defaultMaxRetries: number): Prep if (typeof to !== 'string' || !to.trim()) { throw invalid('invalid_recipient', 'to must be one email address as a nonempty string.', 'to') } - const content = contentOf(send) if (distinctId !== undefined && (typeof distinctId !== 'string' || !distinctId)) { throw invalid('invalid_distinct_id', 'distinctId must be a nonempty string.', 'distinctId') } + const content = contentOf(send, distinctId) if ( idempotencyKey !== undefined && (typeof idempotencyKey !== 'string' || !IDEMPOTENCY_KEY_PATTERN.test(idempotencyKey)) @@ -356,13 +356,9 @@ function prepareSend(options: SendEmailOptions, defaultMaxRetries: number): Prep throw invalid('invalid_max_retries', 'maxRetries must be a nonnegative integer.', 'maxRetries') } - const payload: Record = { ...content, to } - if (distinctId !== undefined) { - payload.identifiers = { distinct_id: distinctId } - } return { path: SEND_PATH, - body: safeJsonStringify(payload), + body: safeJsonStringify({ ...content, to }), idempotencyKey: idempotencyKey ?? globalThis.crypto.randomUUID(), maxRetries: isRetryCount(maxRetries) ? maxRetries : defaultMaxRetries, } @@ -372,17 +368,17 @@ function isRetryCount(value: unknown): value is number { return typeof value === 'number' && Number.isInteger(value) && value >= 0 } -function contentOf(send: UncheckedSend): Record { +function contentOf(send: UncheckedSend, distinctId: string | undefined): Record { if ((send.templateId === undefined) === (send.templateKey === undefined)) { throw new EmailError({ code: 'invalid_form', message: 'Send either templateId, or templateKey with subject and html or text.', }) } - return send.templateKey === undefined ? templateContent(send) : codeContent(send) + return send.templateKey === undefined ? templateContent(send, distinctId) : codeContent(send, distinctId) } -function templateContent(send: UncheckedSend): Record { +function templateContent(send: UncheckedSend, distinctId: string | undefined): Record { const { templateId, templateData } = send if (typeof templateId !== 'string' || !UUID_PATTERN.test(templateId)) { throw invalid('invalid_template_id', "templateId must be the template's UUID.", 'templateId') @@ -394,10 +390,14 @@ function templateContent(send: UncheckedSend): Record { 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 ?? {} } + return { + template: { id: templateId.toLowerCase() }, + message_data: templateData ?? {}, + ...(distinctId !== undefined && { identifiers: { distinct_id: distinctId } }), + } } -function codeContent(send: UncheckedSend): Record { +function codeContent(send: UncheckedSend, distinctId: string | undefined): Record { const { templateKey, subject, html, text, from, replyTo, captureEngagement } = send if (typeof templateKey !== 'string' || !TEMPLATE_KEY_PATTERN.test(templateKey)) { throw invalid( @@ -427,6 +427,7 @@ function codeContent(send: UncheckedSend): Record { ...(from !== undefined && { from }), ...(replyTo !== undefined && { reply_to: typeof replyTo === 'string' ? [replyTo] : replyTo }), capture_engagement: captureEngagement ?? true, + ...(distinctId !== undefined && { distinct_id: distinctId }), } } From 8da3b71c2be1a8be8eebaaa95ea12e6d381c1020 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:46:07 +0000 Subject: [PATCH 5/7] fix(node): leave reply_to out when the list is empty Co-Authored-By: Claude Opus 5.5 --- .../posthog-node-references-latest.json | 4 ++-- packages/node/src/__tests__/emails.spec.ts | 15 +++++++++++++++ packages/node/src/emails/emails.ts | 3 ++- packages/node/src/emails/types.ts | 2 +- 4 files changed, 20 insertions(+), 4 deletions(-) diff --git a/packages/node/references/posthog-node-references-latest.json b/packages/node/references/posthog-node-references-latest.json index 81e33821e4..360c578e07 100644 --- a/packages/node/references/posthog-node-references-latest.json +++ b/packages/node/references/posthog-node-references-latest.json @@ -4394,7 +4394,7 @@ "name": "distinctId" }, { - "description": "1 to 255 visible ASCII characters. A send with a key that PostHog has already accepted queues nothing and returns the first send's result. The same key with other options sends nothing and returns `invalid_idempotent_request`. Without it, the client makes a UUID that covers its own retries. Pass a key derived from what the email is for, such as `welcome:user_123`, to cover retries outside this call too.", + "description": "1 to 255 visible ASCII characters. A send with a key that PostHog has already accepted queues nothing and returns the first send's result. The same key with other options sends nothing and returns an error with code `idempotency_key_reused`. Without it, the client makes a UUID that covers its own retries. Pass a key derived from what the email is for, such as `welcome:user_123`, to cover retries outside this call too.", "type": "string", "name": "idempotencyKey" }, @@ -4498,7 +4498,7 @@ "name": "distinctId" }, { - "description": "1 to 255 visible ASCII characters. A send with a key that PostHog has already accepted\nqueues nothing and returns the first send's result. The same key with other options sends\nnothing and returns `invalid_idempotent_request`. Without it, the client makes a UUID that\ncovers its own retries. Pass a key derived from what the email is for, such as\n`welcome:user_123`, to cover retries outside this call too.", + "description": "1 to 255 visible ASCII characters. A send with a key that PostHog has already accepted\nqueues nothing and returns the first send's result. The same key with other options sends\nnothing and returns an error with code `idempotency_key_reused`. Without it, the client makes a UUID that\ncovers its own retries. Pass a key derived from what the email is for, such as\n`welcome:user_123`, to cover retries outside this call too.", "type": "string", "name": "idempotencyKey" }, diff --git a/packages/node/src/__tests__/emails.spec.ts b/packages/node/src/__tests__/emails.spec.ts index df52532dbb..3ae07d2864 100644 --- a/packages/node/src/__tests__/emails.spec.ts +++ b/packages/node/src/__tests__/emails.spec.ts @@ -170,6 +170,21 @@ describe('emails', () => { capture_engagement: true, }, ], + [ + 'an empty reply-to list, which it leaves out', + { + templateKey: 'password-reset', + subject: 'Reset', + html: '

Reset

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

Reset

', + capture_engagement: true, + }, + ], ])('posts content from code with %s to the same send URL', async (_name, content, body) => { replies = [accepted()] diff --git a/packages/node/src/emails/emails.ts b/packages/node/src/emails/emails.ts index 9720d083d6..0f55e601b3 100644 --- a/packages/node/src/emails/emails.ts +++ b/packages/node/src/emails/emails.ts @@ -419,13 +419,14 @@ 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') } + const sendsReplyTo = replyTo !== undefined && !(Array.isArray(replyTo) && replyTo.length === 0) return { template_key: templateKey, subject, ...(html !== undefined && { html }), ...(text !== undefined && { text }), ...(from !== undefined && { from }), - ...(replyTo !== undefined && { reply_to: typeof replyTo === 'string' ? [replyTo] : replyTo }), + ...(sendsReplyTo && { reply_to: typeof replyTo === 'string' ? [replyTo] : replyTo }), capture_engagement: captureEngagement ?? true, ...(distinctId !== undefined && { distinct_id: distinctId }), } diff --git a/packages/node/src/emails/types.ts b/packages/node/src/emails/types.ts index 7de44ddc0e..ad65911f48 100644 --- a/packages/node/src/emails/types.ts +++ b/packages/node/src/emails/types.ts @@ -12,7 +12,7 @@ export interface SendEmailBase { /** * 1 to 255 visible ASCII characters. A send with a key that PostHog has already accepted * queues nothing and returns the first send's result. The same key with other options sends - * nothing and returns `invalid_idempotent_request`. Without it, the client makes a UUID that + * nothing and returns an error with code `idempotency_key_reused`. Without it, the client makes a UUID that * covers its own retries. Pass a key derived from what the email is for, such as * `welcome:user_123`, to cover retries outside this call too. */ From 992748284beb792490ff945db414d6e43e127f9c Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 5 Nov 2026 07:48:14 +0000 Subject: [PATCH 6/7] fix(emails): name the transactional_email scopes for sends and status reads Co-Authored-By: Claude Opus 5.5 --- .../node/references/posthog-node-references-latest.json | 4 ++-- packages/node/src/client.ts | 8 +++++--- packages/node/src/emails/emails.ts | 4 ++-- packages/node/src/types.ts | 4 ++-- 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/node/references/posthog-node-references-latest.json b/packages/node/references/posthog-node-references-latest.json index 360c578e07..2c19c77a08 100644 --- a/packages/node/references/posthog-node-references-latest.json +++ b/packages/node/references/posthog-node-references-latest.json @@ -2830,7 +2830,7 @@ "name": "IPostHog", "properties": [ { - "description": "The `posthog.emails` API: sends transactional email from PostHog email templates. Needs `secretKey`, a project secret API key with the `hog_flow:write` scope.", + "description": "The `posthog.emails` API: sends transactional email from PostHog email templates. Needs `secretKey`, a project secret API key with the `transactional_email:write` scope.", "type": "Emails", "name": "emails" }, @@ -3888,7 +3888,7 @@ "name": "traces" }, { - "description": "Credential for fetching local feature flag definitions, decrypting remote config and\nsending email.\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. `emails.send` needs a Project Secret API Key with the\n`hog_flow:write` scope; set `enableLocalEvaluation: false` when the key is only for email.\nPrefer this over the deprecated\n`personalApiKey` option; when both are set, `secretKey` takes precedence.", + "description": "Credential for fetching local feature flag definitions, decrypting remote config and\nsending email.\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. `emails.send` needs a Project Secret API Key with the\n`transactional_email:write` scope; set `enableLocalEvaluation: false` when the key is only for email.\nPrefer this over the deprecated\n`personalApiKey` option; when both are set, `secretKey` takes precedence.", "type": "string", "name": "secretKey" }, diff --git a/packages/node/src/client.ts b/packages/node/src/client.ts index 21be469de3..b1a74fd4ec 100644 --- a/packages/node/src/client.ts +++ b/packages/node/src/client.ts @@ -691,9 +691,11 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen * The `posthog.emails` API: sends transactional email from PostHog email templates, and reads * the status of each email it sent. * - * Needs `secretKey`, a project secret API key with the `hog_flow:write` scope. A key with only - * `hog_flow:read` can read statuses but not send. Requests go to `appHost`, or to the PostHog - * Cloud app host of `host`. `send` and `get` resolve to `{ data, error }` and do not reject. + * Needs `secretKey`, a project secret API key with the `transactional_email:write` scope (the + * "Send transactional email" preset). A key with only `transactional_email:read` can read + * statuses but not send. Keys made with `hog_flow:write` still work. Requests go to `appHost`, + * or to the PostHog Cloud app host of `host`. `send` and `get` resolve to `{ data, error }` and + * do not reject. * `data.id` is the queued email's message ID, which `get` takes. A disabled client sends nothing * and returns the error `client_disabled`. After `shutdown()`, the client sends nothing and * returns the error `client_closed`. diff --git a/packages/node/src/emails/emails.ts b/packages/node/src/emails/emails.ts index 0f55e601b3..8fbbac3798 100644 --- a/packages/node/src/emails/emails.ts +++ b/packages/node/src/emails/emails.ts @@ -156,7 +156,7 @@ export class EmailSender implements Emails { const prepared = 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.' + 'Sending email needs secretKey, a project secret API key with the transactional_email:write scope.' ) const headers = this.headers(secretKey, { 'Content-Type': 'application/json', @@ -181,7 +181,7 @@ export class EmailSender implements Emails { } const { appHost, secretKey } = this.connect( 'no email status was read', - 'Reading an email status needs secretKey, a project secret API key with the hog_flow:read or hog_flow:write scope.' + 'Reading an email status needs secretKey, a project secret API key with the transactional_email:read or transactional_email:write scope.' ) const url = appHost + STATUS_PATH.replace('{messageId}', id.toLowerCase()) const headers = this.headers(secretKey, {}) diff --git a/packages/node/src/types.ts b/packages/node/src/types.ts index 865e276ebe..415c1c1cfc 100644 --- a/packages/node/src/types.ts +++ b/packages/node/src/types.ts @@ -237,7 +237,7 @@ export type PostHogOptions = Omit Date: Thu, 5 Nov 2026 11:00:00 +0000 Subject: [PATCH 7/7] docs(emails): rewrite the release note for transactional email Co-Authored-By: Claude Opus 5.5 --- .changeset/node-email-send-code-content.md | 26 +++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/.changeset/node-email-send-code-content.md b/.changeset/node-email-send-code-content.md index 4cb00f481d..bbd2880ea4 100644 --- a/.changeset/node-email-send-code-content.md +++ b/.changeset/node-email-send-code-content.md @@ -2,4 +2,28 @@ 'posthog-node': minor --- -Send email content your code rendered with `posthog.emails.send()`: pass `templateKey`, `subject`, and `html`, `text` or both, with an optional `from` and `replyTo`. A send with `templateKey` captures engagement events unless it passes `captureEngagement: false`; a template made in PostHog follows the project setting. The new `migratedFrom` client option names the email provider you moved from. +`posthog.emails.send()` can now send email content your code rendered, such as HTML from React Email. Give the email a `templateKey` and a `subject`, and pass `html`, `text` or both. + +```ts +const posthog = new PostHog('phc_...', { secretKey: 'phs_...' }) + +const { data, error } = await posthog.emails.send({ + to: 'user@example.com', + distinctId: 'user_123', + templateKey: 'password-reset', + subject: 'Reset your password', + html: renderedHtml, +}) +if (error) throw error + +// 'delivered', 'bounced', ... +const { data: status } = await posthog.emails.get(data.id) +``` + +You need a project secret API key, the kind that starts with `phs_`. Create one under **Project secret API keys** in your project settings. Set its **Transactional email** scope to Write to send emails, or to Read for a key that only reads statuses. Pass it to the client as `secretKey`. Set `appHost` to your PostHog app URL when you self-host PostHog or send through a reverse proxy. + +PostHog records each email's delivery, opens and clicks on the person with that `distinctId`. It groups every email with the same `templateKey`, so you see their metrics and logs in one place. The SDK retries a request that hits a network error, a rate limit or an outage twice by default. Every send carries an idempotency key, so a retry never sends the email twice. Pass your own `idempotencyKey`, such as `welcome:user_123`, and your job runner's retries are safe too. + +A send with your own content also takes `from`, `replyTo` and `captureEngagement: false`. Set `migratedFrom` on the client to tell PostHog which email provider you moved from. A failed call returns an `EmailError` with PostHog's `code` and the HTTP `statusCode`. + +Docs: [Send transactional emails with Node.js](https://posthog.com/docs/workflows/transactional-emails/node) and the [transactional email overview](https://posthog.com/docs/workflows/transactional-emails).