From c33450a84e6d69754bc1b0a32481fbee8dfe6210 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:18:49 +0000 Subject: [PATCH 01/13] feat(node): send a PostHog template by id with idempotent retries Moves the reviewed send surface of PostHog/posthog-js#5312 (feat/node-emails-send at 3b3e05fab) onto the synced main, without the content hash module that only the local template slice uses. Co-Authored-By: Claude Opus 5.5 --- .changeset/core-request-json.md | 5 + .changeset/node-emails-send.md | 5 + packages/core/src/posthog-core-stateless.ts | 26 + packages/core/src/types.ts | 7 + packages/node/src/__tests__/emails.spec.ts | 451 ++++++++++++++++++ .../src/__tests__/utils/fake-emails-api.ts | 161 +++++++ packages/node/src/client.ts | 95 ++++ packages/node/src/emails.ts | 390 +++++++++++++++ packages/node/src/exports.ts | 10 + packages/node/src/types.ts | 23 + 10 files changed, 1173 insertions(+) create mode 100644 .changeset/core-request-json.md create mode 100644 .changeset/node-emails-send.md create mode 100644 packages/node/src/__tests__/emails.spec.ts create mode 100644 packages/node/src/__tests__/utils/fake-emails-api.ts create mode 100644 packages/node/src/emails.ts diff --git a/.changeset/core-request-json.md b/.changeset/core-request-json.md new file mode 100644 index 0000000000..10451aa865 --- /dev/null +++ b/.changeset/core-request-json.md @@ -0,0 +1,5 @@ +--- +'@posthog/core': patch +--- + +Let SDKs built on core send JSON requests to the PostHog API through the shared retrying transport. diff --git a/.changeset/node-emails-send.md b/.changeset/node-emails-send.md new file mode 100644 index 0000000000..340186739c --- /dev/null +++ b/.changeset/node-emails-send.md @@ -0,0 +1,5 @@ +--- +'posthog-node': minor +--- + +Add `posthog.emails.send()` to send transactional email from a template made in PostHog, by its id. Every send carries an idempotency key and retries network errors, throttling and an unavailable pipeline with the same key. Results are `{ data, error }`, with Resend's error names. New client options: `appHost`, `maxRetries` and `migratedFrom`. diff --git a/packages/core/src/posthog-core-stateless.ts b/packages/core/src/posthog-core-stateless.ts index 4a2d09968e..f96da667f5 100644 --- a/packages/core/src/posthog-core-stateless.ts +++ b/packages/core/src/posthog-core-stateless.ts @@ -17,6 +17,7 @@ import { PostHogFeatureFlagDetails, FeatureFlagDetail, SurveyResponse, + PostHogApiResponse, PostHogFetchResponse, PostHogFetchBodyBytes, PostHogFetchOptions, @@ -40,6 +41,7 @@ import { createLogger, getEventUuid, safeJsonStringify, + trySafe, } from './utils' import { parseRetryAfterMs } from './utils/retry-after' import { uuidv7 } from './vendor/uuidv7' @@ -156,6 +158,11 @@ type SuccessfulWriteResponseHandling = { type: 'successful-write' } +async function readApiResponse(response: PostHogFetchResponse): Promise { + const text = await response.text() + return { status: response.status, body: trySafe(() => JSON.parse(text)) } +} + export const maybeAdd = (key: string, value: JsonType | undefined): Record => value !== undefined ? { [key]: value } : {} @@ -1874,6 +1881,25 @@ export abstract class PostHogCoreStateless { return this._sendOtlpBatch({ path: 'traces', auth: 'bearer', payload }) } + protected async requestJson( + url: string, + options: PostHogFetchOptions, + retryOptions?: Partial + ): Promise { + try { + return await this.fetchWithRetry(url, options, { type: 'required', consume: readApiResponse }, retryOptions) + } catch (error) { + if (error instanceof PostHogFetchHttpError) { + return { + status: error.status, + body: await error.json.catch(() => undefined), + retryAfterMs: error.retryAfterMs, + } + } + throw error + } + } + private fetchWithRetry( url: string, options: PostHogFetchOptions, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 9c1475e472..8500e7ce33 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -336,6 +336,13 @@ export type PostHogFetchResponse = { body?: ReadableStream | null } +export type PostHogApiResponse = { + status: number + body: unknown + /** The answer's `Retry-After` in milliseconds, when it sent a usable one. */ + retryAfterMs?: number +} + export type PostHogQueueItem = { message?: PostHogEventProperties callback?: (err: unknown) => void diff --git a/packages/node/src/__tests__/emails.spec.ts b/packages/node/src/__tests__/emails.spec.ts new file mode 100644 index 0000000000..d6f1035674 --- /dev/null +++ b/packages/node/src/__tests__/emails.spec.ts @@ -0,0 +1,451 @@ +import { PostHog, PostHogOptions } from '@/entrypoints/index.node' +import type { SendEmailOptions } from '@/emails' +import { type Fault, FakeEmailsApi, refusalBody } from './utils/fake-emails-api' + +vi.mock('../version', () => ({ version: '1.2.3' })) + +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 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', () => { + let api: FakeEmailsApi + const clients: PostHog[] = [] + + const createClient = (options: PostHogOptions = {}): PostHog => { + const client = new PostHog('phc_project_token', { + host: 'https://ingest.example.com', + appHost: APP_HOST, + secretKey: SECRET_KEY, + enableLocalEvaluation: false, + fetch: api.fetch, + ...options, + }) + clients.push(client) + return client + } + + const sendWelcome = (posthog: PostHog = createClient(), options: { maxRetries?: number } = {}) => + posthog.emails.send({ to: JANE, distinctId: 'user_42', templateId: TEMPLATE_ID, ...options }) + + const requestLog = (): string[] => api.received.map(({ method, path }) => `${method} ${path}`) + const idempotencyKeys = (): string[] => api.received.map(({ headers }) => headers['idempotency-key']) + + beforeEach(() => { + api = new FakeEmailsApi({ secretKey: SECRET_KEY, appHost: APP_HOST }) + api.addTemplate(TEMPLATE_ID) + }) + + afterEach(async () => { + vi.useRealTimers() + vi.restoreAllMocks() + await Promise.all(clients.splice(0).map((client) => client.shutdown())) + }) + + describe('the template form', () => { + it('sends the template with its data to the recipient, as the distinct id, with the secret key', async () => { + const result = await createClient().emails.send({ + to: JANE, + distinctId: 'user_42', + templateId: TEMPLATE_ID, + templateData: { firstName: 'Jane' }, + }) + + expect(result).toEqual({ data: { id: 'message-1', warnings: [] }, error: null }) + expect(api.delivered).toEqual([ + { + messageId: 'message-1', + to: JANE, + templateId: TEMPLATE_ID, + variables: { firstName: 'Jane' }, + distinctId: 'user_42', + }, + ]) + expect(api.received).toEqual([ + expect.objectContaining({ + origin: APP_HOST, + headers: expect.objectContaining({ + authorization: `Bearer ${SECRET_KEY}`, + 'content-type': 'application/json', + 'user-agent': 'posthog-node/1.2.3', + 'idempotency-key': expect.stringMatching(UUID_V4), + }), + }), + ]) + expect(requestLog()).toEqual([TEMPLATE_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' } }, + ], + ])('maps %s to the request body', async (_, options, body) => { + await createClient().emails.send(options) + + expect(api.received.map((request) => request.body)).toEqual([body]) + }) + + it('sends the given idempotency key, and the migration marker of the client', async () => { + await createClient({ migratedFrom: 'resend' }).emails.send({ + to: JANE, + distinctId: 'user_42', + templateId: TEMPLATE_ID, + idempotencyKey: 'welcome:user_42', + }) + + expect(api.received[0].headers).toEqual( + expect.objectContaining({ 'idempotency-key': 'welcome:user_42', 'x-posthog-migrated-from': 'resend' }) + ) + }) + + it('answers a replayed idempotency key with the id of the first send, and sends one email', async () => { + const posthog = createClient() + const send = () => + posthog.emails.send({ to: JANE, distinctId: 'user_42', templateId: TEMPLATE_ID, idempotencyKey: 'k-1' }) + + const first = await send() + const replay = await send() + + expect(replay).toEqual(first) + expect(api.delivered).toHaveLength(1) + }) + + it('returns the warnings of a send, and logs each one once', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const posthog = createClient() + posthog.debug(true) + api.failNext({ + status: 202, + body: { + message_id: 'message-9', + warnings: [ + { code: 'sender_unverified_soon', detail: 'The sender expires soon.', fix: 'Verify it again.' }, + 'unreadable', + ], + }, + }) + + const result = await sendWelcome(posthog) + + expect(result).toEqual({ + data: { + id: 'message-9', + warnings: [{ code: 'sender_unverified_soon', message: 'The sender expires soon.', fix: 'Verify it again.' }], + }, + error: null, + }) + expect(warn.mock.calls.filter((call) => call.join(' ').includes('sender_unverified_soon'))).toHaveLength(1) + }) + }) + + describe('errors', () => { + it.each([ + [400, 'invalid_recipient', 'validation_error'], + [400, 'transactional_email_paused', 'validation_error'], + [400, 'invalid_idempotency_key', 'validation_error'], + [401, 'authentication_failed', 'missing_api_key'], + [403, 'permission_denied', 'invalid_permission'], + [404, 'not_found', 'not_found'], + [409, 'idempotency_key_in_flight', 'concurrent_idempotent_requests'], + [409, 'template_outdated', 'validation_error'], + [409, 'template_edited_in_posthog', 'validation_error'], + [422, 'idempotency_key_reused', 'invalid_idempotent_request'], + [429, 'throttled', 'rate_limit_exceeded'], + [429, 'quota_limited', 'monthly_quota_exceeded'], + [503, 'delivery_unavailable', 'service_unavailable'], + [500, 'server_error', 'application_error'], + ])('names a %s %s answer %s, with the fields PostHog sent', async (status, code, name) => { + api.failNext({ status, body: refusalBody(code, 'to') }, Infinity) + + const result = await sendWelcome(createClient(), { maxRetries: 0 }) + + expect(result).toEqual({ + data: null, + error: { + name, + code, + message: `Refused with ${code}.`, + why: `Because the fake refuses ${code}.`, + fix: `Fix ${code}.`, + statusCode: status, + docsUrl: `https://posthog.com/docs/workflows/transactional-email-api#${code}`, + field: 'to', + }, + }) + }) + + it.each([ + [ + 'an answer without a code', + { status: 500, body: 'oops' }, + 'api_error', + 'application_error', + 'PostHog answered HTTP 500.', + 500, + ], + [ + 'a 202 without a message id', + { status: 202, body: { ok: true } }, + 'api_error', + 'application_error', + 'PostHog answered HTTP 202 with an unexpected body.', + 202, + ], + [ + 'a network failure', + { networkError: new Error('getaddrinfo ENOTFOUND posthog.example.com') }, + 'network_error', + 'service_unavailable', + 'Request failed: getaddrinfo ENOTFOUND posthog.example.com', + undefined, + ], + [ + 'a refused connection to a host with two addresses', + { + networkError: new TypeError('fetch failed', { + cause: new AggregateError([ + new Error('connect ECONNREFUSED ::1:8000'), + new Error('connect ECONNREFUSED 127.0.0.1:8000'), + ]), + }), + }, + 'network_error', + 'service_unavailable', + 'Request failed: fetch failed: connect ECONNREFUSED ::1:8000, connect ECONNREFUSED 127.0.0.1:8000', + undefined, + ], + ] satisfies [string, Fault, string, string, string, number | undefined][])( + 'returns %s as %s', + async (_, fault, code, name, message, statusCode) => { + api.failNext(fault, Infinity) + + const result = await sendWelcome(createClient(), { maxRetries: 0 }) + + expect(result).toEqual({ + data: null, + error: { name, code, message, ...(statusCode !== undefined && { statusCode }) }, + }) + } + ) + + it.each([ + ['missing_secret_key', 'missing_api_key', { secretKey: undefined }, {}], + ['missing_app_host', 'application_error', { appHost: undefined }, {}], + ['client_disabled', 'application_error', { disabled: true }, {}], + ['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 }], + ])('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, + }) + + expect(result).toEqual({ data: null, error: expect.objectContaining({ name, code }) }) + expect(api.received).toEqual([]) + }) + + it.each([ + ['after shutdown', async (client: PostHog) => await client.shutdown()], + ['while shutdown drains', (client: PostHog) => void client.shutdown()], + ])('returns client_closed as application_error %s without a request', async (_, shutDown) => { + const client = createClient() + await shutDown(client) + + const result = await sendWelcome(client) + + expect(result).toEqual({ + data: null, + error: expect.objectContaining({ name: 'application_error', code: 'client_closed' }), + }) + 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) + + expect(result.error).toEqual( + expect.objectContaining({ name: 'validation_error', code: 'invalid_form', field: 'scheduledAt' }) + ) + expect(api.received).toEqual([]) + }) + + it.each([undefined, null, JANE])('returns invalid_form for %s options without a request', async (options) => { + const result = await createClient().emails.send(options as never) + + expect(result).toEqual({ + data: null, + error: expect.objectContaining({ name: 'validation_error', code: 'invalid_form' }), + }) + expect(api.received).toEqual([]) + }) + + it('returns unexpected_error when the template data cannot be encoded', async () => { + const result = await createClient().emails.send({ + to: JANE, + templateId: TEMPLATE_ID, + templateData: { count: BigInt(1) }, + }) + + expect(result).toEqual({ + data: null, + error: expect.objectContaining({ name: 'application_error', code: 'unexpected_error' }), + }) + }) + }) + + describe('retries', () => { + const settle = async (work: Promise): Promise => { + await vi.runAllTimersAsync() + return work + } + + beforeEach(() => { + vi.useFakeTimers() + vi.spyOn(Math, 'random').mockReturnValue(0.5) + }) + + it.each([ + ['a network failure', { networkError: new Error('ECONNRESET') }], + ['an in-flight idempotency key', { status: 409, body: refusalBody('idempotency_key_in_flight', null) }], + ['a throttled send', { status: 429, body: refusalBody('throttled', null) }], + ['an unavailable pipeline', { status: 503, body: refusalBody('delivery_unavailable', null) }], + ] satisfies [string, Fault][])('retry %s after 1 s and 2 s with the same key', async (_, fault) => { + api.failNext(fault, 2) + + const result = sendWelcome() + await vi.advanceTimersByTimeAsync(999) + expect(api.received).toHaveLength(1) + await vi.advanceTimersByTimeAsync(1) + expect(api.received).toHaveLength(2) + await vi.advanceTimersByTimeAsync(1999) + expect(api.received).toHaveLength(2) + + expect(await settle(result)).toEqual({ data: { id: 'message-1', warnings: [] }, error: null }) + expect(api.received).toHaveLength(3) + expect(new Set(idempotencyKeys()).size).toBe(1) + }) + + it('gives up after maxRetries and returns the last answer', async () => { + api.failNext({ status: 503, body: refusalBody('delivery_unavailable', null) }, Infinity) + + const result = await settle(sendWelcome()) + + expect(result.error).toEqual(expect.objectContaining({ code: 'delivery_unavailable' })) + expect(api.received).toHaveLength(3) + }) + + it.each([ + ['the client', { maxRetries: 0 }, {}], + ['the send', {}, { maxRetries: 0 }], + ])('never retries when %s sets maxRetries to 0', async (_, clientOptions: PostHogOptions, sendOptions) => { + api.failNext({ status: 503, body: refusalBody('delivery_unavailable', null) }, Infinity) + + await settle(sendWelcome(createClient(clientOptions), sendOptions)) + + expect(api.received).toHaveLength(1) + }) + + it.each([ + ['the client sets NaN', { maxRetries: NaN }, {}, 3], + ['the send sets Infinity', {}, { maxRetries: Infinity }, 3], + ['the send sets -1', { maxRetries: 1 }, { maxRetries: -1 }, 2], + ['the send sets 1.5', { maxRetries: 1 }, { maxRetries: 1.5 }, 2], + ])('falls back to the default when %s', async (_, clientOptions: PostHogOptions, sendOptions, attempts) => { + api.failNext({ status: 503, body: refusalBody('delivery_unavailable', null) }, Infinity) + + await settle(sendWelcome(createClient(clientOptions), sendOptions)) + + expect(api.received).toHaveLength(attempts) + }) + + it('retries a 202 whose body breaks off, and returns the id of the queued email', async () => { + api.failNext({ bodyBreaksOff: new Error('socket hang up') }) + + const result = await settle(sendWelcome()) + + expect(result).toEqual({ data: { id: 'message-1', warnings: [] }, error: null }) + expect(api.delivered).toHaveLength(1) + expect(new Set(idempotencyKeys()).size).toBe(1) + }) + + it('lets a send retry more often than the client', async () => { + api.failNext({ status: 503, body: refusalBody('delivery_unavailable', null) }, Infinity) + + await settle(sendWelcome(createClient({ maxRetries: 0 }), { maxRetries: 3 })) + + expect(api.received).toHaveLength(4) + }) + + it.each([ + ['a longer Retry-After', '5', 5_000], + ['a Retry-After over 30 s, capped', '120', 30_000], + ])('waits for %s', async (_, retryAfter, wait) => { + api.failNext({ status: 429, body: refusalBody('throttled', null), headers: { 'Retry-After': retryAfter } }) + + const result = sendWelcome() + await vi.advanceTimersByTimeAsync(wait - 1) + expect(api.received).toHaveLength(1) + await vi.advanceTimersByTimeAsync(1) + expect(api.received).toHaveLength(2) + await settle(result) + }) + + it.each([ + [429, 'quota_limited'], + [409, 'template_edited_in_posthog'], + [400, 'invalid_recipient'], + [500, 'server_error'], + ])('never retries a %s %s answer', async (status, code) => { + api.failNext({ status, body: refusalBody(code, null) }, Infinity) + + await settle(sendWelcome()) + + expect(api.received).toHaveLength(1) + }) + }) + + describe('the app host', () => { + it.each([ + ['https://us.i.posthog.com', 'https://us.posthog.com'], + ['https://us.posthog.com', 'https://us.posthog.com'], + ['https://app.posthog.com', 'https://us.posthog.com'], + ['https://eu.i.posthog.com/', 'https://eu.posthog.com'], + ['https://eu.posthog.com', 'https://eu.posthog.com'], + [undefined, 'https://us.posthog.com'], + ])('derives the app host of PostHog Cloud host %s', async (host, appHost) => { + api = new FakeEmailsApi({ secretKey: SECRET_KEY, appHost }) + api.addTemplate(TEMPLATE_ID) + + const result = await sendWelcome(createClient({ host, appHost: undefined })) + + expect(result.error).toBeNull() + expect(api.received.map(({ origin }) => origin)).toEqual([appHost]) + }) + + it('never sends the secret key to a host that is not PostHog Cloud', async () => { + const result = await sendWelcome(createClient({ host: 'https://proxy.example.com', appHost: undefined })) + + expect(result.error).toEqual(expect.objectContaining({ code: 'missing_app_host' })) + expect(api.received).toEqual([]) + }) + + it('sends to the app host, not to the ingestion host', async () => { + await sendWelcome(createClient({ host: 'https://proxy.example.com', appHost: `${APP_HOST}/` })) + + expect(api.received.map(({ origin }) => origin)).toEqual([APP_HOST]) + }) + }) +}) diff --git a/packages/node/src/__tests__/utils/fake-emails-api.ts b/packages/node/src/__tests__/utils/fake-emails-api.ts new file mode 100644 index 0000000000..9f718a251f --- /dev/null +++ b/packages/node/src/__tests__/utils/fake-emails-api.ts @@ -0,0 +1,161 @@ +import type { PostHogFetchOptions, PostHogFetchResponse } from '@posthog/core' + +const TEMPLATE_SEND_PATH = /^\/api\/projects\/@current\/messaging_templates\/([^/]+)\/send\/$/ +const DOCS_URL = 'https://posthog.com/docs/workflows/transactional-email-api' + +export interface ReceivedRequest { + method: string + origin: string + path: string + headers: Record + body: Record +} + +export interface DeliveredEmail { + messageId: string + to: string + templateId: string + variables: Record + distinctId?: string +} + +export type Fault = + | { status: number; body?: unknown; headers?: Record } + | { networkError: Error } + | { bodyBreaksOff: Error } + +interface ScheduledFault { + matches: (request: ReceivedRequest) => boolean + fault: Fault + remaining: number +} + +/** + * 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. + */ +export class FakeEmailsApi { + readonly received: ReceivedRequest[] = [] + readonly delivered: DeliveredEmail[] = [] + private readonly templateIds = new Set() + private readonly accepted = new Map() + private readonly faults: ScheduledFault[] = [] + private nextId = 1 + + constructor(private readonly options: { secretKey: string; appHost: string }) {} + + readonly fetch = async (url: string, init: PostHogFetchOptions): Promise => { + const request = await describe(new Request(url, init as RequestInit)) + this.received.push(request) + const fault = this.takeFault(request) + if (fault && 'networkError' in fault) { + throw fault.networkError + } + if (fault && 'bodyBreaksOff' in fault) { + return breakOff(this.handle(request), fault.bodyBreaksOff) + } + if (fault) { + return respond(fault.status, fault.body, fault.headers) + } + return this.handle(request) + } + + addTemplate(id: string): void { + this.templateIds.add(id) + } + + failWhen(matches: (request: ReceivedRequest) => boolean, fault: Fault, times = Infinity): void { + this.faults.push({ matches, fault, remaining: times }) + } + + failNext(fault: Fault, times = 1): void { + this.failWhen(() => true, fault, times) + } + + private takeFault(request: ReceivedRequest): Fault | undefined { + const scheduled = this.faults.find((candidate) => candidate.remaining > 0 && candidate.matches(request)) + if (scheduled) { + scheduled.remaining-- + } + return scheduled?.fault + } + + private handle(request: ReceivedRequest): PostHogFetchResponse { + 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) + } + 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) + } + const key = headers['idempotency-key'] + const fingerprint = JSON.stringify([templateId, body]) + const earlier = key === undefined ? undefined : this.accepted.get(key) + if (earlier) { + return earlier.fingerprint === fingerprint + ? respond(202, { message_id: earlier.messageId, replayed: true }) + : 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 }) + } +} + +async function describe(request: Request): Promise { + const text = await request.text() + const url = new URL(request.url) + return { + method: request.method, + origin: url.origin, + path: url.pathname, + headers: Object.fromEntries(request.headers.entries()), + body: text ? JSON.parse(text) : {}, + } +} + +export function refusal(status: number, code: string, attr: string | null): PostHogFetchResponse { + return respond(status, refusalBody(code, attr)) +} + +export function refusalBody(code: string, attr: string | null): Record { + return { + type: 'invalid_request', + code, + detail: `Refused with ${code}.`, + attr, + why: `Because the fake refuses ${code}.`, + fix: `Fix ${code}.`, + docs_url: `${DOCS_URL}#${code}`, + } +} + +function respond(status: number, body: unknown, headers: Record = {}): PostHogFetchResponse { + const text = typeof body === 'string' ? body : JSON.stringify(body ?? '') + return new Response(text, { status, headers: { 'Content-Type': 'application/json', ...headers } }) +} + +/** The answer PostHog sent, with a body that fails while the SDK reads it. */ +function breakOff(answer: PostHogFetchResponse, error: Error): PostHogFetchResponse { + const body = new ReadableStream({ start: (controller) => controller.error(error) }) + return new Response(body, { status: answer.status }) +} diff --git a/packages/node/src/client.ts b/packages/node/src/client.ts index 8e1fa3daba..e13a15a1e1 100644 --- a/packages/node/src/client.ts +++ b/packages/node/src/client.ts @@ -7,10 +7,13 @@ import { isBlockedUA, isPlainObject, isPostHogFetchContentTooLargeError, + isPostHogFetchNetworkError, JsonType, minimizeFlagCalledEventProperties, PostHogCaptureOptions, + PostHogApiResponse, PostHogCoreStateless, + removeTrailingSlash, PostHogEventProperties, PostHogFetchOptions, PostHogFetchResponse, @@ -77,6 +80,7 @@ import { AI_ROUTE, ANALYTICS_ROUTE, isLegacyOnlyEvent } from './capture-v1/routi import { V1CaptureSender } from './capture-v1/sender' import { eventByteSize, partitionAiBatch } from './ai-capture/batching' import { AI_CAPTURE_ENDPOINT_PATH, AI_CAPTURE_ROUTE, AI_MAX_EVENT_BYTES } from './ai-capture/routing' +import { type Emails, PostHogEmails } from './emails' // Standard local evaluation rate limit is 600 per minute (10 per second), // so the fastest a poller should ever be set is 100ms. @@ -121,6 +125,47 @@ function normalizeHost(value?: unknown): string { return normalizedValue || DEFAULT_NODE_HOST } +const CLOUD_APP_HOSTS: Record = { + 'us.i.posthog.com': 'https://us.posthog.com', + 'us.posthog.com': 'https://us.posthog.com', + 'app.posthog.com': 'https://us.posthog.com', + 'eu.i.posthog.com': 'https://eu.posthog.com', + 'eu.posthog.com': 'https://eu.posthog.com', +} + +function cloudAppHost(host: string): string | undefined { + try { + const url = new URL(host) + return url.protocol === 'https:' && url.port === '' ? CLOUD_APP_HOSTS[url.hostname] : undefined + } catch { + return undefined + } +} + +function isTimeout(error: unknown): boolean { + return isPostHogFetchNetworkError(error) && error.error instanceof Error && error.error.name === 'AbortError' +} + +function requestFailure(error: unknown): string { + const reasons: string[] = [] + let reason = isPostHogFetchNetworkError(error) ? error.error : error + for (let depth = 0; reason instanceof Error && depth < 3; depth++) { + reasons.push(describeError(reason)) + reason = reason.cause + } + return ['Request failed', ...reasons.filter(Boolean)].join(': ') +} + +function describeError(error: Error): string { + if (error.message || !(error instanceof AggregateError)) { + return error.message + } + return error.errors + .filter((inner): inner is Error => inner instanceof Error) + .map((inner) => inner.message) + .join(', ') +} + function normalizeUnsetPersonProperties(value: string | string[]): string[] { const propertyNames = Array.isArray(value) ? value : [value] return propertyNames.filter( @@ -159,6 +204,8 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen public readonly options: PostHogOptions protected readonly context?: IPostHogContext private _metrics?: PostHogMetrics + private _emails?: Emails + private _emailsClosed = false private _traces?: PostHogTraces private _spanContext?: SpanContextManager @@ -671,6 +718,39 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen return this._metrics } + /** + * The `posthog.emails` API: send transactional email from your server. Needs the `secretKey` client option. + * + * @example + * ```ts + * const { data, error } = await client.emails.send({ + * to: 'jane@example.com', + * distinctId: user.id, + * templateId: '0190f6d2-7c1e-4b3a-9f5e-1c2d3e4f5a6b', + * }) + * ``` + * + * {@label Emails} + */ + public get emails(): Emails { + this._emails ??= new PostHogEmails({ + isDisabled: () => this.disabled, + isClosed: () => this._emailsClosed, + secretKey: () => this.options.personalApiKey, + appHost: () => (this.options.appHost ? removeTrailingSlash(this.options.appHost) : cloudAppHost(this.host)), + maxRetries: () => this.options.maxRetries, + headers: () => ({ + ...this.getCustomHeaders(), + 'User-Agent': this.getCustomUserAgent(), + ...(this.options.migratedFrom && { 'X-PostHog-Migrated-From': this.options.migratedFrom }), + }), + warn: (message) => this._logger.warn(message), + track: (work) => this.addPendingPromise(work), + request: (url, headers, body) => this._requestEmailsApi(url, headers, body), + }) + return this._emails + } + /** * Active-span tracking. Overridden by the Node entrypoint with an * `AsyncLocalStorage`-backed manager; the edge build keeps this synchronous @@ -2909,6 +2989,7 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen // super._shutdown spend from the same budget, so shutdown(500) can't take // ~1000ms by giving each phase a fresh full timeout. const shutdownDeadlineMs = Date.now() + (shutdownTimeoutMs ?? 30000) + this._emailsClosed = true // Cancel any pending debounced flush — shutdown will flush directly. const resolve = this._consumeWaitUntilCycle() @@ -2983,6 +3064,20 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen } } + /** One attempt: `posthog.emails` retries on its own, with the same idempotency key. */ + private async _requestEmailsApi( + url: string, + headers: Record, + body: string + ): Promise { + try { + return await this.requestJson(url, { method: 'POST', 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)) + } + } + private extractPropertiesFromEvent( eventProperties?: Record, groups?: Record diff --git a/packages/node/src/emails.ts b/packages/node/src/emails.ts new file mode 100644 index 0000000000..b48620cd19 --- /dev/null +++ b/packages/node/src/emails.ts @@ -0,0 +1,390 @@ +import { isValidUUID, type JsonType, type PostHogApiResponse } from '@posthog/core' + +const TEMPLATE_SEND_PATH = (templateId: string): string => + `/api/projects/@current/messaging_templates/${templateId}/send/` + +const IDEMPOTENCY_KEY_PATTERN = /^[\x21-\x7e]{1,255}$/ +const SEND_OPTIONS = new Set(['to', 'distinctId', 'idempotencyKey', 'maxRetries', 'templateId', 'templateData']) +const DEFAULT_MAX_RETRIES = 2 +const FIRST_RETRY_DELAY_MS = 1_000 +const MAX_RETRY_DELAY_MS = 30_000 + +/** What every send takes, whatever its form. */ +export interface SendEmailBase { + /** One recipient email address. */ + to: string + /** + * 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. + */ + 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`. + */ + idempotencyKey?: string + /** 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 { + /** 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 + +/** Something to know about a queued email. */ +export interface EmailWarning { + code: string + message: string + fix?: string +} + +/** Why an email call failed. Branch on `code`, PostHog's stable code, or on `name`, which uses Resend's names. */ +export interface EmailError { + /** + * `validation_error`, `missing_api_key`, `invalid_permission`, `not_found`, `concurrent_idempotent_requests`, + * `invalid_idempotent_request`, `rate_limit_exceeded`, `monthly_quota_exceeded`, `service_unavailable` or + * `application_error`. + */ + 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. + */ + code: string + message: string + /** Why PostHog refused the email. */ + why?: string + /** What to change so the email sends. */ + fix?: string + /** The HTTP status, when PostHog answered. */ + statusCode?: number + /** A link to the docs section that explains the fix. */ + docsUrl?: string + /** The request field the error is about. */ + field?: string +} + +export type EmailErrorName = + | 'validation_error' + | 'missing_api_key' + | 'invalid_permission' + | 'not_found' + | 'concurrent_idempotent_requests' + | 'invalid_idempotent_request' + | 'rate_limit_exceeded' + | 'monthly_quota_exceeded' + | 'service_unavailable' + | 'application_error' + +/** The result of `posthog.emails.send()`: the queued email's `id`, or the `error`. */ +export type SendEmailResult = + | { data: { id: string; warnings: EmailWarning[] }; error: null } + | { data: null; error: EmailError } + +/** The `posthog.emails` API: send transactional email from your server. */ +export interface Emails { + /** + * Queue one email. A result with `data` means PostHog stored the send in its queue, not that it was delivered. + * + * Never throws: it returns `{ data, error }`. Every send carries an idempotency key, so the SDK retries network + * errors, an in-flight key, throttling and an unavailable pipeline, up to `maxRetries` times, without sending twice. + * It never retries an exhausted monthly quota. The SDK captures no event: the send and its engagement events come + * from PostHog and count as analytics events. + * + * 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 + * ```ts + * const { data, error } = await posthog.emails.send({ + * to: 'jane@example.com', + * distinctId: user.id, + * templateId: '0190f6d2-7c1e-4b3a-9f5e-1c2d3e4f5a6b', + * templateData: { firstName: 'Jane' }, + * idempotencyKey: `welcome:${user.id}`, + * }) + * ``` + */ + send(options: SendEmailOptions): Promise +} + +/** @internal */ +export interface EmailsApi { + isDisabled(): boolean + isClosed(): boolean + secretKey(): string | undefined + /** `undefined` when the client's host may be a proxy and no `appHost` was set. */ + appHost(): string | undefined + maxRetries(): number | undefined + headers(): Record + warn(message: string): void + track(work: Promise): Promise + /** Rejects only when PostHog never answered. */ + request(url: string, headers: Record, body: string): Promise +} + +type Failure = { data: null; error: EmailError } + +interface PreparedSend { + path: string + body: JsonType + idempotencyKey: string + maxRetries: number +} + +interface Attempt { + result: SendEmailResult + retryAfterMs?: number +} + +/** @internal */ +export class PostHogEmails implements Emails { + constructor(private readonly api: EmailsApi) {} + + send(options: SendEmailOptions): Promise { + if (!isRecord(options)) { + return Promise.resolve(failure('invalid_form', 'send needs an options object with to and templateId.')) + } + const refusal = this.refusalBeforeRequest(options) + if (refusal) { + return Promise.resolve(refusal) + } + return this.api.track(this.deliver(this.prepare(options)).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.') + } + if (this.api.isClosed()) { + return failure('client_closed', 'The PostHog client was shut down, so nothing was sent.') + } + if (!this.api.secretKey()) { + return failure( + 'missing_secret_key', + 'Sending email needs the secretKey client option, a project secret API key with the hog_flow:write scope.' + ) + } + if (!this.api.appHost()) { + return failure( + 'missing_app_host', + 'Sending email needs the appHost client option when host is not PostHog Cloud, so the secret key never ' + + '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 + } + if (idempotencyKey !== undefined && !isIdempotencyKey(idempotencyKey)) { + return failure( + 'invalid_idempotency_key', + 'idempotencyKey must be 1 to 255 visible ASCII characters, with no spaces.', + { field: 'idempotencyKey' } + ) + } + return undefined + } + + 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, + } + } + + private async deliver(send: PreparedSend): Promise { + const body = JSON.stringify(send.body) + for (let retry = 0; ; retry++) { + const { result, retryAfterMs } = await this.attempt(send, body) + if (retry >= send.maxRetries || !isRetryable(result)) { + return this.logWarnings(result) + } + await sleep(retryDelayMs(retry, retryAfterMs)) + } + } + + private async attempt(send: PreparedSend, body: string): Promise { + const headers = { + ...this.api.headers(), + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.api.secretKey()}`, + 'Idempotency-Key': send.idempotencyKey, + } + let response: PostHogApiResponse + try { + response = await this.api.request(`${this.api.appHost()}${send.path}`, headers, body) + } catch (error) { + return { result: failure('network_error', error instanceof Error ? error.message : 'Request failed') } + } + return { result: sendResult(response), retryAfterMs: response.retryAfterMs } + } + + private logWarnings(result: SendEmailResult): SendEmailResult { + for (const warning of result.data?.warnings ?? []) { + this.api.warn(`Email ${result.data?.id} was queued with a warning, ${warning.code}: ${warning.message}`) + } + return result + } +} + +function sendResult(response: PostHogApiResponse): SendEmailResult { + const { status, body } = response + if (!isSuccess(status)) { + return { data: null, error: apiError(response) } + } + 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 { + switch (error?.name) { + case 'concurrent_idempotent_requests': + case 'rate_limit_exceeded': + return true + case 'service_unavailable': + return error.code === 'network_error' || error.statusCode === 503 + default: + return false + } +} + +function unknownOptionRefusal(options: SendEmailOptions): Failure | undefined { + const field = Object.entries(options).find(([name, value]) => value !== undefined && !SEND_OPTIONS.has(name))?.[0] + if (field === undefined) { + return undefined + } + return failure('invalid_form', `send doesn't take ${field}, so it sent nothing. Remove ${field} and send again.`, { + field, + }) +} + +function isIdempotencyKey(value: unknown): boolean { + return typeof value === 'string' && IDEMPOTENCY_KEY_PATTERN.test(value) +} + +function retryCount(value: number | undefined): number | undefined { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : undefined +} + +function retryDelayMs(retry: number, retryAfterMs = 0): number { + const backoff = FIRST_RETRY_DELAY_MS * 2 ** retry * (0.8 + Math.random() * 0.4) + return Math.min(MAX_RETRY_DELAY_MS, Math.max(backoff, retryAfterMs)) +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +/** Resend's error name for an answer, so migrated code that branches on `error.name` keeps working. */ +function errorName(code: string, statusCode?: number): EmailErrorName { + switch (code) { + case 'missing_secret_key': + return 'missing_api_key' + case 'network_error': + return 'service_unavailable' + case 'invalid_form': + case 'invalid_template_id': + case 'invalid_idempotency_key': + return 'validation_error' + } + switch (statusCode) { + case 400: + return 'validation_error' + case 401: + return 'missing_api_key' + case 403: + return 'invalid_permission' + case 404: + return 'not_found' + case 409: + return code === 'idempotency_key_in_flight' ? 'concurrent_idempotent_requests' : 'validation_error' + case 422: + return 'invalid_idempotent_request' + case 429: + return code === 'quota_limited' ? 'monthly_quota_exceeded' : 'rate_limit_exceeded' + case 503: + return 'service_unavailable' + default: + return 'application_error' + } +} + +function failure(code: string, message: string, details: Partial = {}): Failure { + return { data: null, error: { name: errorName(code, details.statusCode), code, message, ...details } } +} + +function unexpectedFailure(error: unknown): Failure { + return failure('unexpected_error', error instanceof Error ? error.message : 'The email call failed unexpectedly.') +} + +function apiError({ status, body }: PostHogApiResponse): EmailError { + const fields = isRecord(body) ? body : {} + const code = textOf(fields.code) ?? 'api_error' + return { + name: errorName(code, status), + code, + message: textOf(fields.detail) ?? `PostHog answered HTTP ${status}.`, + ...optional('why', textOf(fields.why)), + ...optional('fix', textOf(fields.fix)), + statusCode: status, + ...optional('docsUrl', textOf(fields.docs_url)), + ...optional('field', textOf(fields.attr)), + } +} + +function warningsOf(warnings: unknown): EmailWarning[] { + return (Array.isArray(warnings) ? warnings : []).flatMap((warning) => { + if (!isRecord(warning) || !textOf(warning.code) || !textOf(warning.detail)) { + return [] + } + return [{ code: textOf(warning.code)!, message: textOf(warning.detail)!, ...optional('fix', textOf(warning.fix)) }] + }) +} + +function optional(key: K, value: string | undefined): { [P in K]?: string } { + return (value === undefined ? {} : { [key]: value }) as { [P in K]?: string } +} + +function textOf(value: unknown): string | undefined { + return typeof value === 'string' && value ? value : undefined +} + +function isSuccess(status: number): boolean { + return status >= 200 && status < 300 +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/packages/node/src/exports.ts b/packages/node/src/exports.ts index b30dd1de21..1ef4b53f4e 100644 --- a/packages/node/src/exports.ts +++ b/packages/node/src/exports.ts @@ -3,6 +3,16 @@ export * from './extensions/express' export * from './types' export { FeatureFlagEvaluations } from './feature-flag-evaluations' +export type { + EmailError, + EmailErrorName, + Emails, + EmailWarning, + SendEmailBase, + SendEmailOptions, + SendEmailResult, + SendTemplateEmailOptions, +} from './emails' export type { FlagDefinitionCacheData, FlagDefinitionCacheInput, diff --git a/packages/node/src/types.ts b/packages/node/src/types.ts index 27fe90ba27..afe4859a6c 100644 --- a/packages/node/src/types.ts +++ b/packages/node/src/types.ts @@ -16,6 +16,7 @@ import type { import { ContextData, ContextOptions } from './extensions/context/types' import type { FeatureFlagEvaluations } from './feature-flag-evaluations' +import type { Emails } from './emails' import type { FlagDefinitionCacheInput, FlagDefinitionCacheProvider } from './extensions/feature-flags/cache' export type IdentifyMessage = { @@ -219,6 +220,9 @@ export type PostHogOptions = Omit Date: Sun, 25 Oct 2026 02:22:22 +0000 Subject: [PATCH 02/13] feat(node): send inline HTML with emails.send, and capture engagement by default A send with templateKey, subject and html or text posts the content to /messaging/send/email/, where PostHog sends it as is. The template form keeps its route until the server moves it. Every send carries capture_engagement: true unless it passes captureEngagement: false. Co-Authored-By: Claude Opus 5.5 --- .changeset/node-emails-send.md | 2 +- packages/node/src/__tests__/emails.spec.ts | 148 ++++++++++-- .../node/src/__tests__/types/emails-forms.ts | 22 ++ .../src/__tests__/utils/fake-emails-api.ts | 48 ++-- packages/node/src/client.ts | 4 +- packages/node/src/emails.ts | 217 ++++++++++++++---- packages/node/src/exports.ts | 3 + 7 files changed, 374 insertions(+), 70 deletions(-) create mode 100644 packages/node/src/__tests__/types/emails-forms.ts diff --git a/.changeset/node-emails-send.md b/.changeset/node-emails-send.md index 340186739c..43c5665f59 100644 --- a/.changeset/node-emails-send.md +++ b/.changeset/node-emails-send.md @@ -2,4 +2,4 @@ 'posthog-node': minor --- -Add `posthog.emails.send()` to send transactional email from a template made in PostHog, by its id. Every send carries an idempotency key and retries network errors, throttling and an unavailable pipeline with the same key. Results are `{ data, error }`, with Resend's error names. New client options: `appHost`, `maxRetries` and `migratedFrom`. +Add `posthog.emails.send()` to send transactional email: HTML your code rendered, named by a `templateKey`, or a template made in PostHog, by its id. Every send carries an idempotency key and retries network errors, throttling and an unavailable pipeline with the same key. Results are `{ data, error }`, with Resend's error names. Engagement capture is on unless a send passes `captureEngagement: false`. New client options: `appHost`, `maxRetries` and `migratedFrom`. diff --git a/packages/node/src/__tests__/emails.spec.ts b/packages/node/src/__tests__/emails.spec.ts index d6f1035674..2a98270151 100644 --- a/packages/node/src/__tests__/emails.spec.ts +++ b/packages/node/src/__tests__/emails.spec.ts @@ -9,6 +9,7 @@ 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 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 +60,7 @@ describe('emails', () => { { messageId: 'message-1', to: JANE, - templateId: TEMPLATE_ID, + template: TEMPLATE_ID, variables: { firstName: 'Jane' }, distinctId: 'user_42', }, @@ -79,11 +80,15 @@ describe('emails', () => { }) 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 }, + { to: JANE, capture_engagement: true }, + ], + [ + 'template data, a distinct id and engagement capture turned off', + { to: JANE, templateId: TEMPLATE_ID, templateData: { n: 1 }, distinctId: 'u', captureEngagement: false }, + { 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 +149,94 @@ describe('emails', () => { }) }) + describe('the code form', () => { + it('sends inline HTML to the recipient, as the distinct id, with the secret key', async () => { + const result = await createClient().emails.send({ + to: JANE, + distinctId: 'user_42', + templateKey: 'password-reset', + from: 'Security ', + subject: 'Reset your password', + html: '

Reset

', + }) + + expect(result).toEqual({ data: { id: 'message-1', warnings: [] }, error: null }) + expect(api.delivered).toEqual([ + { + messageId: 'message-1', + to: JANE, + template: 'password-reset', + subject: 'Reset your password', + html: '

Reset

', + distinctId: 'user_42', + }, + ]) + expect(api.received[0].headers).toEqual( + expect.objectContaining({ + authorization: `Bearer ${SECRET_KEY}`, + 'idempotency-key': expect.stringMatching(UUID_V4), + }) + ) + expect(requestLog()).toEqual([SEND]) + }) + + 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, + }, + ], + ])('maps %s to the request body', async (_, content, body) => { + await createClient().emails.send({ to: JANE, distinctId: 'user_42', ...content }) + + expect(api.received.map((request) => request.body)).toEqual([ + { ...body, to: JANE, identifiers: { distinct_id: 'user_42' } }, + ]) + }) + }) + describe('errors', () => { it.each([ [400, 'invalid_recipient', 'validation_error'], @@ -241,13 +334,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([]) @@ -269,16 +369,34 @@ 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'], + [ + 'a code send with template data, which only the template form takes', + { templateKey: 'welcome', subject: 'Hi', html: '

Hi

', templateData: { name: 'Jane' } }, + 'templateData', + ], + [ + '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/__tests__/types/emails-forms.ts b/packages/node/src/__tests__/types/emails-forms.ts new file mode 100644 index 0000000000..67a34fdd7d --- /dev/null +++ b/packages/node/src/__tests__/types/emails-forms.ts @@ -0,0 +1,22 @@ +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', captureEngagement: false }, +] + +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

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

Reset

', templateData: { name: 'J' } }, +] diff --git a/packages/node/src/__tests__/utils/fake-emails-api.ts b/packages/node/src/__tests__/utils/fake-emails-api.ts index 9f718a251f..85cb35fc93 100644 --- a/packages/node/src/__tests__/utils/fake-emails-api.ts +++ b/packages/node/src/__tests__/utils/fake-emails-api.ts @@ -1,6 +1,7 @@ import type { PostHogFetchOptions, PostHogFetchResponse } from '@posthog/core' const TEMPLATE_SEND_PATH = /^\/api\/projects\/@current\/messaging_templates\/([^/]+)\/send\/$/ +const SEND_PATH = '/api/projects/@current/messaging/send/email/' const DOCS_URL = 'https://posthog.com/docs/workflows/transactional-email-api' export interface ReceivedRequest { @@ -14,8 +15,12 @@ export interface ReceivedRequest { export interface DeliveredEmail { messageId: string to: string - templateId: string - variables: Record + /** The template id, or the rendered content's `template_key`. */ + template: string + subject?: string + html?: string + text?: string + variables?: Record distinctId?: string } @@ -31,8 +36,9 @@ 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 routes of the transactional email API: a template by id on its + * own route, and rendered content by `template_key` on the send route. An idempotency key PostHog already accepted for + * the same request answers the first `202` again. */ export class FakeEmailsApi { readonly received: ReceivedRequest[] = [] @@ -91,15 +97,25 @@ export class FakeEmailsApi { if (request.method === 'POST' && templateId) { return this.handleTemplateSend(decodeURIComponent(templateId), request) } + if (request.method === 'POST' && request.path === SEND_PATH) { + return this.accept(request, { template: request.body.template_key, ...renderedContent(request.body) }) + } return refusal(404, 'not_found', null) } - private handleTemplateSend(templateId: string, { headers, body }: ReceivedRequest): PostHogFetchResponse { + private handleTemplateSend(templateId: string, request: ReceivedRequest): PostHogFetchResponse { if (!this.templateIds.has(templateId)) { return refusal(404, 'not_found', null) } + return this.accept(request, { template: templateId, variables: request.body.message_data ?? {} }) + } + + private accept( + { path, headers, body }: ReceivedRequest, + content: Omit + ): PostHogFetchResponse { const key = headers['idempotency-key'] - const fingerprint = JSON.stringify([templateId, body]) + const fingerprint = JSON.stringify([path, body]) const earlier = key === undefined ? undefined : this.accepted.get(key) if (earlier) { return earlier.fingerprint === fingerprint @@ -110,17 +126,23 @@ export class FakeEmailsApi { 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, - }) + this.delivered.push({ messageId, to: body.to, ...content, distinctId: body.identifiers?.distinct_id }) return respond(202, { message_id: messageId, replayed: false }) } } +function renderedContent({ + subject, + html, + text, +}: Record): Pick { + return { + subject, + ...(html !== undefined && { html }), + ...(text !== undefined && { text }), + } +} + async function describe(request: Request): Promise { const text = await request.text() const url = new URL(request.url) diff --git a/packages/node/src/client.ts b/packages/node/src/client.ts index e13a15a1e1..6d1a00e3e8 100644 --- a/packages/node/src/client.ts +++ b/packages/node/src/client.ts @@ -726,7 +726,9 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen * const { data, error } = await client.emails.send({ * to: 'jane@example.com', * distinctId: user.id, - * templateId: '0190f6d2-7c1e-4b3a-9f5e-1c2d3e4f5a6b', + * templateKey: 'password-reset', + * subject: 'Reset your password', + * html: '

Reset your password

', * }) * ``` * diff --git a/packages/node/src/emails.ts b/packages/node/src/emails.ts index b48620cd19..d7827fc1fc 100644 --- a/packages/node/src/emails.ts +++ b/packages/node/src/emails.ts @@ -2,9 +2,26 @@ import { isValidUUID, type JsonType, type PostHogApiResponse } from '@posthog/co const TEMPLATE_SEND_PATH = (templateId: string): string => `/api/projects/@current/messaging_templates/${templateId}/send/` +const SEND_PATH = '/api/projects/@current/messaging/send/email/' +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', 'distinctId', 'idempotencyKey', 'maxRetries', 'templateId', 'templateData']) +const SEND_OPTIONS = new Set([ + 'to', + 'distinctId', + 'idempotencyKey', + 'captureEngagement', + 'maxRetries', + 'templateId', + 'templateData', + 'templateKey', + '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 +43,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. Defaults to `true` on every form; pass `false` to turn + * capture off for this send. A project whose admin turned engagement capture off captures nothing, whatever the send + * asks. + */ + 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[] +} + +/** + * 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`, 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 } + +export type SendTemplateEmailOptions = SendEmailBase & + TemplateEmailForm & + Without + +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 { @@ -59,8 +107,8 @@ export interface EmailError { /** * 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. + * `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 +154,18 @@ 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 Rendered content, such as React Email rendered to HTML in your app + * ```ts + * const { data, error } = await posthog.emails.send({ + * to: 'jane@example.com', + * distinctId: user.id, + * templateKey: 'password-reset', + * subject: 'Reset your password', + * html: await render(), + * }) + * ``` + * + * @example A template made in PostHog, by its id * ```ts * const { data, error } = await posthog.emails.send({ * to: 'jane@example.com', @@ -155,7 +214,9 @@ 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) { @@ -165,7 +226,6 @@ export class PostHogEmails implements Emails { } 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,40 +245,14 @@ 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 - } - if (idempotencyKey !== undefined && !isIdempotencyKey(idempotencyKey)) { - return failure( - 'invalid_idempotency_key', - 'idempotencyKey must be 1 to 255 visible ASCII characters, with no spaces.', - { field: 'idempotencyKey' } - ) - } - return undefined + return formRefusal(options) ?? unknownOptionRefusal(options) ?? idempotencyKeyRefusal(options.idempotencyKey) } - private prepare({ - to, - distinctId, - idempotencyKey, - maxRetries, - templateId, - templateData, - }: SendEmailOptions): PreparedSend { + private prepare(options: 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, + ...(isTemplateForm(options) ? templateSend(options) : renderedContentSend(options)), + idempotencyKey: options.idempotencyKey ?? crypto.randomUUID(), + maxRetries: retryCount(options.maxRetries) ?? retryCount(this.api.maxRetries()) ?? DEFAULT_MAX_RETRIES, } } @@ -257,6 +291,108 @@ export class PostHogEmails implements Emails { } } +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) + ? 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)) { + return failure( + 'invalid_template_key', + 'templateKey can only have letters, digits, hyphens (-) and underscores (_), at most 400.', + { 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, templateData }: 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' }) + } + if (templateData !== undefined) { + return failure( + 'invalid_form', + 'A send with templateKey sends its content as is, so it takes no templateData. Fill in the values before you ' + + 'send, or send a template made in PostHog with templateId.', + { field: 'templateData' } + ) + } + 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 templateSend(options: SendTemplateEmailOptions): Pick { + return { + path: TEMPLATE_SEND_PATH(options.templateId), + body: { + ...recipientFields(options), + ...(options.templateData !== undefined && { message_data: options.templateData as JsonType }), + }, + } +} + +function renderedContentSend(options: SendCodeEmailOptions): Pick { + const { templateKey, subject, html, text, from, replyTo } = options + return { + path: SEND_PATH, + body: { + template_key: templateKey, + subject, + ...(html !== undefined && { html }), + ...(text !== undefined && { text }), + ...(from !== undefined && { from }), + ...(replyTo !== undefined && { reply_to: typeof replyTo === 'string' ? [replyTo] : replyTo }), + ...recipientFields(options), + }, + } +} + +function recipientFields({ to, distinctId, captureEngagement }: SendEmailBase): Record { + return { + to, + ...(distinctId !== undefined && { identifiers: { distinct_id: distinctId } }), + capture_engagement: captureEngagement ?? true, + } +} + function sendResult(response: PostHogApiResponse): SendEmailResult { const { status, body } = response if (!isSuccess(status)) { @@ -316,6 +452,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' } 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 bd246cd13fee5e0c3f57879576c267302366b6f4 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:27:30 +0000 Subject: [PATCH 03/13] docs(node): say that a template sent by id follows the project's engagement setting for now Co-Authored-By: Claude Opus 5.5 --- packages/node/src/emails.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/node/src/emails.ts b/packages/node/src/emails.ts index d7827fc1fc..82dcb2d5ac 100644 --- a/packages/node/src/emails.ts +++ b/packages/node/src/emails.ts @@ -47,7 +47,8 @@ export interface SendEmailBase { * Whether PostHog captures the email's engagement events (sent, delivered, opened, clicked, bounced) as analytics * events of the recipient. They count as analytics events. Defaults to `true` on every form; pass `false` to turn * capture off for this send. A project whose admin turned engagement capture off captures nothing, whatever the send - * asks. + * asks. A template sent by `templateId` still follows the project's setting, until PostHog sends it on the same route + * as code content. */ captureEngagement?: boolean /** How often to retry this send, a whole number from 0. Overrides the `maxRetries` client option. */ From 96c30e4be1d78feb0f018cb1634f53334c6b3921 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:43:01 +0000 Subject: [PATCH 04/13] chore(node): regenerate the API references Co-Authored-By: Claude Opus 5.5 --- .../posthog-node-references-latest.json | 297 +++++++++++++++++- 1 file changed, 296 insertions(+), 1 deletion(-) diff --git a/packages/node/references/posthog-node-references-latest.json b/packages/node/references/posthog-node-references-latest.json index dbe0b9d64e..b56f25fc7f 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.", + "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", @@ -2108,6 +2140,84 @@ "properties": [], "path": "../core/src/cookie.ts" }, + { + "id": "EmailError", + "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`, `invalid_template_key` and `invalid_idempotency_key` for a send the SDK refused before any request.", + "type": "string", + "name": "code" + }, + { + "description": "A link to the docs section that explains the fix.", + "type": "string", + "name": "docsUrl" + }, + { + "description": "The request field the error is about.", + "type": "string", + "name": "field" + }, + { + "description": "What to change so the email sends.", + "type": "string", + "name": "fix" + }, + { + "type": "string", + "name": "message" + }, + { + "description": "`validation_error`, `missing_api_key`, `invalid_permission`, `not_found`, `concurrent_idempotent_requests`, `invalid_idempotent_request`, `rate_limit_exceeded`, `monthly_quota_exceeded`, `service_unavailable` or `application_error`.", + "type": "EmailErrorName", + "name": "name" + }, + { + "description": "The HTTP status, when PostHog answered.", + "type": "number", + "name": "statusCode" + }, + { + "description": "Why PostHog refused the email.", + "type": "string", + "name": "why" + } + ], + "path": "src/emails.ts" + }, + { + "id": "EmailErrorName", + "name": "EmailErrorName", + "properties": [], + "path": "src/emails.ts", + "example": "\"validation_error\" | \"missing_api_key\" | \"invalid_permission\" | \"not_found\" | \"concurrent_idempotent_requests\" | \"invalid_idempotent_request\" | \"rate_limit_exceeded\" | \"monthly_quota_exceeded\" | \"service_unavailable\" | \"application_error\"" + }, + { + "id": "Emails", + "name": "Emails", + "properties": [], + "path": "src/emails.ts" + }, + { + "id": "EmailWarning", + "name": "EmailWarning", + "properties": [ + { + "type": "string", + "name": "code" + }, + { + "type": "string", + "name": "fix" + }, + { + "type": "string", + "name": "message" + } + ], + "path": "src/emails.ts" + }, { "id": "EndBranching", "name": "EndBranching", @@ -2667,6 +2777,11 @@ "id": "IPostHog", "name": "IPostHog", "properties": [ + { + "description": "The `posthog.emails` API: send transactional email from your server. Needs the `secretKey` client option.", + "type": "Emails", + "name": "emails" + }, { "description": "The `posthog.metrics` API: a statsd-style pre-aggregating metrics client (count, gauge, histogram) — alpha. Samples are folded into per-series aggregates in memory and flushed periodically. Configure via the `metrics` client option.", "type": "Metrics", @@ -2983,6 +3098,26 @@ ], "path": "../core/src/error-tracking/types.ts" }, + { + "id": "PostHogApiResponse", + "name": "PostHogApiResponse", + "properties": [ + { + "type": "number", + "name": "status" + }, + { + "type": "unknown", + "name": "body" + }, + { + "description": "The answer's `Retry-After` in milliseconds, when it sent a usable one.", + "type": "number", + "name": "retryAfterMs" + } + ], + "path": "../core/src/types.ts" + }, { "id": "PostHogCaptureOptions", "name": "PostHogCaptureOptions", @@ -3716,7 +3851,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" }, @@ -3726,6 +3861,21 @@ "name": "personalApiKey", "releaseTag": "deprecated" }, + { + "description": "The PostHog app host that `posthog.emails` sends to with the `secretKey`, such as `https://posthog.example.com`\nfor a self-hosted instance. Derived when `host` is PostHog Cloud. Any other `host` may be a reverse proxy, so\n`emails` calls fail with `missing_app_host` until you set it, and the secret key never goes to the proxy.", + "type": "string", + "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.", + "type": "number", + "name": "maxRetries" + }, + { + "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", @@ -4185,6 +4335,59 @@ ], "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. Defaults to `true` on every form; pass `false` to turn capture off for this send. A project whose admin turned engagement capture off captures nothing, whatever the send asks. A template sent by `templateId` still follows the project's setting, until PostHog sends it on the same route as code content.", + "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`.", + "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" + }, + { + "description": "One recipient email address.", + "type": "string", + "name": "to" + } + ], + "path": "src/emails.ts" + }, + { + "id": "SendEmailOptions", + "name": "SendEmailOptions", + "properties": [], + "path": "src/emails.ts", + "example": "SendTemplateEmailOptions | SendCodeEmailOptions" + }, + { + "id": "SendEmailResult", + "name": "SendEmailResult", + "properties": [], + "path": "src/emails.ts", + "example": "{\n data: {\n id: string;\n warnings: EmailWarning[];\n };\n error: null;\n} | {\n data: null;\n error: EmailError;\n}" + }, { "id": "SendFeatureFlagsOptions", "name": "SendFeatureFlagsOptions", @@ -4222,6 +4425,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 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": "Whether PostHog captures the email's engagement events (sent, delivered, opened, clicked, bounced) as analytics\nevents of the recipient. They count as analytics events. Defaults to `true` on every form; pass `false` to turn\ncapture off for this send. A project whose admin turned engagement capture off captures nothing, whatever the send\nasks. A template sent by `templateId` still follows the project's setting, until PostHog sends it on the same route\nas code content.", + "type": "boolean", + "name": "captureEngagement" + }, + { + "description": "How often to retry this send, a whole number from 0. 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" + }, { "id": "SendTracesBatchOutcome", "name": "SendTracesBatchOutcome", @@ -4867,6 +5138,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", @@ -4888,6 +5176,13 @@ } ], "path": "src/types.ts" + }, + { + "id": "Without", + "name": "Without", + "properties": [], + "path": "src/emails.ts", + "example": "{\n [K in keyof T]?: never;\n}" } ], "categories": [ From 41267e3b3ba55b7785d37f4d2fc3c298daf85ab2 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:51:24 +0000 Subject: [PATCH 05/13] test(node): schedule fake email API faults in order only Co-Authored-By: Claude Opus 5.5 --- .../node/src/__tests__/utils/fake-emails-api.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/packages/node/src/__tests__/utils/fake-emails-api.ts b/packages/node/src/__tests__/utils/fake-emails-api.ts index 85cb35fc93..12ff169021 100644 --- a/packages/node/src/__tests__/utils/fake-emails-api.ts +++ b/packages/node/src/__tests__/utils/fake-emails-api.ts @@ -30,7 +30,6 @@ export type Fault = | { bodyBreaksOff: Error } interface ScheduledFault { - matches: (request: ReceivedRequest) => boolean fault: Fault remaining: number } @@ -53,7 +52,7 @@ export class FakeEmailsApi { readonly fetch = async (url: string, init: PostHogFetchOptions): Promise => { const request = await describe(new Request(url, init as RequestInit)) this.received.push(request) - const fault = this.takeFault(request) + const fault = this.takeFault() if (fault && 'networkError' in fault) { throw fault.networkError } @@ -70,16 +69,12 @@ export class FakeEmailsApi { this.templateIds.add(id) } - failWhen(matches: (request: ReceivedRequest) => boolean, fault: Fault, times = Infinity): void { - this.faults.push({ matches, fault, remaining: times }) - } - failNext(fault: Fault, times = 1): void { - this.failWhen(() => true, fault, times) + this.faults.push({ fault, remaining: times }) } - private takeFault(request: ReceivedRequest): Fault | undefined { - const scheduled = this.faults.find((candidate) => candidate.remaining > 0 && candidate.matches(request)) + private takeFault(): Fault | undefined { + const scheduled = this.faults.find((candidate) => candidate.remaining > 0) if (scheduled) { scheduled.remaining-- } From 8e786d7db383064de454e8816deb41f6e0e8b334 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:16:51 +0000 Subject: [PATCH 06/13] fix(node): check each send form's fields in one place and refuse a second send argument A send checks its options against the field set of its form, so a field the other form takes names that form. A second argument, such as Resend's { idempotencyKey }, is refused instead of dropped. requestJson sends one attempt, and the email headers take User-Agent from the client's headers. Co-Authored-By: Claude Opus 5.5 --- .changeset/core-request-json.md | 2 +- packages/core/src/posthog-core-stateless.ts | 8 +-- packages/node/src/__tests__/emails.spec.ts | 12 +++- packages/node/src/client.ts | 3 +- packages/node/src/emails.ts | 75 +++++++++------------ 5 files changed, 48 insertions(+), 52 deletions(-) diff --git a/.changeset/core-request-json.md b/.changeset/core-request-json.md index 10451aa865..a44f3be966 100644 --- a/.changeset/core-request-json.md +++ b/.changeset/core-request-json.md @@ -2,4 +2,4 @@ '@posthog/core': patch --- -Let SDKs built on core send JSON requests to the PostHog API through the shared retrying transport. +Let SDKs built on core send one JSON request to the PostHog API through the shared transport, and read the answer's `Retry-After`. diff --git a/packages/core/src/posthog-core-stateless.ts b/packages/core/src/posthog-core-stateless.ts index f96da667f5..1dbf92304f 100644 --- a/packages/core/src/posthog-core-stateless.ts +++ b/packages/core/src/posthog-core-stateless.ts @@ -1881,13 +1881,9 @@ export abstract class PostHogCoreStateless { return this._sendOtlpBatch({ path: 'traces', auth: 'bearer', payload }) } - protected async requestJson( - url: string, - options: PostHogFetchOptions, - retryOptions?: Partial - ): Promise { + protected async requestJson(url: string, options: PostHogFetchOptions): Promise { try { - return await this.fetchWithRetry(url, options, { type: 'required', consume: readApiResponse }, retryOptions) + return await this.fetchWithRetry(url, options, { type: 'required', consume: readApiResponse }, { retryCount: 0 }) } catch (error) { if (error instanceof PostHogFetchHttpError) { return { diff --git a/packages/node/src/__tests__/emails.spec.ts b/packages/node/src/__tests__/emails.spec.ts index 2a98270151..95938be289 100644 --- a/packages/node/src/__tests__/emails.spec.ts +++ b/packages/node/src/__tests__/emails.spec.ts @@ -1,5 +1,5 @@ import { PostHog, PostHogOptions } from '@/entrypoints/index.node' -import type { SendEmailOptions } from '@/emails' +import type { SendEmailOptions, SendEmailResult } from '@/emails' import { type Fault, FakeEmailsApi, refusalBody } from './utils/fake-emails-api' vi.mock('../version', () => ({ version: '1.2.3' })) @@ -410,6 +410,16 @@ describe('emails', () => { expect(api.received).toEqual([]) }) + it("returns invalid_form for Resend's second options argument without a request", async () => { + const { emails } = createClient() + const send = emails.send.bind(emails) as (...args: unknown[]) => Promise + + const result = await send({ to: JANE, templateId: TEMPLATE_ID }, { idempotencyKey: 'welcome:user_42' }) + + expect(result.error).toEqual(expect.objectContaining({ name: 'validation_error', code: 'invalid_form' })) + expect(api.received).toEqual([]) + }) + it('returns unexpected_error when the template data cannot be encoded', async () => { const result = await createClient().emails.send({ to: JANE, diff --git a/packages/node/src/client.ts b/packages/node/src/client.ts index 6d1a00e3e8..bcf32c4ec3 100644 --- a/packages/node/src/client.ts +++ b/packages/node/src/client.ts @@ -743,7 +743,6 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen maxRetries: () => this.options.maxRetries, headers: () => ({ ...this.getCustomHeaders(), - 'User-Agent': this.getCustomUserAgent(), ...(this.options.migratedFrom && { 'X-PostHog-Migrated-From': this.options.migratedFrom }), }), warn: (message) => this._logger.warn(message), @@ -3073,7 +3072,7 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen body: string ): Promise { try { - return await this.requestJson(url, { method: 'POST', headers, body }, { retryCount: 0 }) + return await this.requestJson(url, { method: 'POST', headers, body }) } 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 82dcb2d5ac..72725bf14c 100644 --- a/packages/node/src/emails.ts +++ b/packages/node/src/emails.ts @@ -4,24 +4,22 @@ const TEMPLATE_SEND_PATH = (templateId: string): string => `/api/projects/@current/messaging_templates/${templateId}/send/` const SEND_PATH = '/api/projects/@current/messaging/send/email/' +interface SendForm { + name: 'templateId' | 'templateKey' + fields: ReadonlySet +} + 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', - 'distinctId', - 'idempotencyKey', - 'captureEngagement', - 'maxRetries', - 'templateId', - 'templateData', - 'templateKey', - 'from', - 'replyTo', - 'subject', - 'html', - 'text', -]) +const SHARED_FIELDS = ['to', 'distinctId', 'idempotencyKey', 'captureEngagement', 'maxRetries'] +const TEMPLATE_FORM: SendForm = { + name: 'templateId', + fields: new Set([...SHARED_FIELDS, 'templateId', 'templateData']), +} +const CODE_FORM: SendForm = { + name: 'templateKey', + fields: new Set([...SHARED_FIELDS, 'templateKey', 'from', 'replyTo', 'subject', 'html', 'text']), +} const DEFAULT_MAX_RETRIES = 2 const FIRST_RETRY_DELAY_MS = 1_000 const MAX_RETRY_DELAY_MS = 30_000 @@ -213,12 +211,20 @@ interface Attempt { export class PostHogEmails implements Emails { constructor(private readonly api: EmailsApi) {} - send(options: SendEmailOptions): Promise { + send(options: SendEmailOptions, ...unexpected: unknown[]): Promise { if (!isRecord(options)) { return Promise.resolve( failure('invalid_form', 'send needs an options object with to, and templateId or templateKey.') ) } + if (unexpected.length > 0) { + return Promise.resolve( + failure( + 'invalid_form', + 'send takes one options object, so it sent nothing. Put idempotencyKey and every other option in it.' + ) + ) + } const refusal = this.refusalBeforeRequest(options) if (refusal) { return Promise.resolve(refusal) @@ -246,7 +252,7 @@ export class PostHogEmails implements Emails { 'goes to a proxy.' ) } - return formRefusal(options) ?? unknownOptionRefusal(options) ?? idempotencyKeyRefusal(options.idempotencyKey) + return formRefusal(options) ?? idempotencyKeyRefusal(options.idempotencyKey) } private prepare(options: SendEmailOptions): PreparedSend { @@ -302,7 +308,7 @@ function formRefusal(options: SendEmailOptions): Failure | undefined { } if (isTemplateForm(options)) { return isValidUUID(options.templateId) - ? codeOnlyFieldRefusal(options) + ? fieldRefusal(options, TEMPLATE_FORM, CODE_FORM) : 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)) { @@ -312,18 +318,21 @@ function formRefusal(options: SendEmailOptions): Failure | undefined { { field: 'templateKey' } ) } - return contentRefusal(options) + return fieldRefusal(options, CODE_FORM, TEMPLATE_FORM) ?? contentRefusal(options) } -function codeOnlyFieldRefusal(options: SendTemplateEmailOptions): Failure | undefined { - const field = CODE_ONLY_FIELDS.find((name) => options[name] !== undefined) +function fieldRefusal(options: SendEmailOptions, form: SendForm, otherForm: SendForm): Failure | undefined { + const field = Object.entries(options).find(([name, value]) => value !== undefined && !form.fields.has(name))?.[0] if (field === undefined) { return undefined } - return failure('invalid_form', `Send ${field} with templateKey. A template made in PostHog sets its own.`, { field }) + const fix = otherForm.fields.has(field) ? `Send ${field} with ${otherForm.name}.` : `Remove ${field} and send again.` + return failure('invalid_form', `A send with ${form.name} doesn't take ${field}, so it sent nothing. ${fix}`, { + field, + }) } -function contentRefusal({ subject, html, text, templateData }: SendCodeEmailOptions): Failure | undefined { +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' }) } @@ -336,14 +345,6 @@ function contentRefusal({ subject, html, text, templateData }: SendCodeEmailOpti if (html === undefined && text === undefined) { return failure('invalid_form', 'A send with templateKey needs html, text or both.', { field: 'html' }) } - if (templateData !== undefined) { - return failure( - 'invalid_form', - 'A send with templateKey sends its content as is, so it takes no templateData. Fill in the values before you ' + - 'send, or send a template made in PostHog with templateId.', - { field: 'templateData' } - ) - } return undefined } @@ -417,16 +418,6 @@ function isRetryable({ error }: SendEmailResult): boolean { } } -function unknownOptionRefusal(options: SendEmailOptions): Failure | undefined { - const field = Object.entries(options).find(([name, value]) => value !== undefined && !SEND_OPTIONS.has(name))?.[0] - if (field === undefined) { - return undefined - } - return failure('invalid_form', `send doesn't take ${field}, so it sent nothing. Remove ${field} and send again.`, { - field, - }) -} - function isIdempotencyKey(value: unknown): boolean { return typeof value === 'string' && IDEMPOTENCY_KEY_PATTERN.test(value) } From 5c10c83c38ae70fa415ee70a91644b3d3db2c153 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:34:43 +0000 Subject: [PATCH 07/13] fix(node): say why a send form refuses a field and accept an undefined second argument A field that only the other form takes now carries `why` and `fix` on the error, instead of a bare pointer to the other form. Each form's field set is derived from its option types, so a typed field missing from it fails to compile. A wrapper that passes `undefined` as a second argument sends as before. Co-Authored-By: Claude Opus 5.5 --- packages/node/src/__tests__/emails.spec.ts | 29 ++++++++++++++ packages/node/src/emails.ts | 44 ++++++++++++++++++---- 2 files changed, 65 insertions(+), 8 deletions(-) diff --git a/packages/node/src/__tests__/emails.spec.ts b/packages/node/src/__tests__/emails.spec.ts index 95938be289..f759fb87e6 100644 --- a/packages/node/src/__tests__/emails.spec.ts +++ b/packages/node/src/__tests__/emails.spec.ts @@ -420,6 +420,35 @@ describe('emails', () => { expect(api.received).toEqual([]) }) + it('sends when a wrapper passes undefined as a second argument', async () => { + const { emails } = createClient() + const send = emails.send.bind(emails) as (...args: unknown[]) => Promise + + const result = await send({ to: JANE, templateId: TEMPLATE_ID }, undefined) + + expect(result).toEqual({ data: { id: 'message-1', warnings: [] }, error: null }) + expect(requestLog()).toEqual([TEMPLATE_SEND]) + }) + + 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', + { templateKey: 'welcome', subject: 'Hi', html: '

Hi

', templateData: { name: 'Jane' } }, + 'A send with templateKey takes its content from your code.', + 'Remove templateData, or send a template made in PostHog with templateId.', + ], + ])('explains why it refuses %s and how to send it', async (_, form, why, fix) => { + const result = await createClient().emails.send({ to: JANE, ...form } as unknown as SendEmailOptions) + + expect(result.error).toEqual(expect.objectContaining({ code: 'invalid_form', why, fix })) + }) + it('returns unexpected_error when the template data cannot be encoded', async () => { const result = await createClient().emails.send({ to: JANE, diff --git a/packages/node/src/emails.ts b/packages/node/src/emails.ts index 72725bf14c..eaffe143a7 100644 --- a/packages/node/src/emails.ts +++ b/packages/node/src/emails.ts @@ -7,18 +7,41 @@ const SEND_PATH = '/api/projects/@current/messaging/send/email/' interface SendForm { name: 'templateId' | 'templateKey' fields: ReadonlySet + why: string + howToSend: string } +type EveryField
= Record + const TEMPLATE_KEY_PATTERN = /^[a-zA-Z0-9_-]{1,400}$/ const IDEMPOTENCY_KEY_PATTERN = /^[\x21-\x7e]{1,255}$/ -const SHARED_FIELDS = ['to', 'distinctId', 'idempotencyKey', 'captureEngagement', 'maxRetries'] +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: new Set([...SHARED_FIELDS, 'templateId', 'templateData']), + 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: new Set([...SHARED_FIELDS, 'templateKey', 'from', 'replyTo', 'subject', 'html', 'text']), + 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', } const DEFAULT_MAX_RETRIES = 2 const FIRST_RETRY_DELAY_MS = 1_000 @@ -217,7 +240,7 @@ export class PostHogEmails implements Emails { failure('invalid_form', 'send needs an options object with to, and templateId or templateKey.') ) } - if (unexpected.length > 0) { + if (unexpected.some((argument) => argument !== undefined)) { return Promise.resolve( failure( 'invalid_form', @@ -326,10 +349,15 @@ function fieldRefusal(options: SendEmailOptions, form: SendForm, otherForm: Send if (field === undefined) { return undefined } - const fix = otherForm.fields.has(field) ? `Send ${field} with ${otherForm.name}.` : `Remove ${field} and send again.` - return failure('invalid_form', `A send with ${form.name} doesn't take ${field}, so it sent nothing. ${fix}`, { - field, - }) + const message = `A send with ${form.name} doesn't take ${field}, so it sent nothing.` + if (!otherForm.fields.has(field)) { + return failure('invalid_form', message, { field, fix: `Remove ${field} and send again.` }) + } + return failure('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 contentRefusal({ subject, html, text }: SendCodeEmailOptions): Failure | undefined { From af5fe5e036f290f4894b0959ba31256345d22854 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sun, 25 Oct 2026 10:43:18 +0000 Subject: [PATCH 08/13] chore(react-native): regenerate public API references for PostHogApiResponse Co-Authored-By: Claude Opus 5.5 --- ...osthog-react-native-references-latest.json | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/react-native/references/posthog-react-native-references-latest.json b/packages/react-native/references/posthog-react-native-references-latest.json index 62ac6aaa72..0b139a674b 100644 --- a/packages/react-native/references/posthog-react-native-references-latest.json +++ b/packages/react-native/references/posthog-react-native-references-latest.json @@ -3009,6 +3009,26 @@ ], "path": "../core/src/error-tracking/types.ts" }, + { + "id": "PostHogApiResponse", + "name": "PostHogApiResponse", + "properties": [ + { + "type": "number", + "name": "status" + }, + { + "type": "unknown", + "name": "body" + }, + { + "description": "The answer's `Retry-After` in milliseconds, when it sent a usable one.", + "type": "number", + "name": "retryAfterMs" + } + ], + "path": "../core/src/types.ts" + }, { "id": "PostHogAutocaptureElement", "name": "PostHogAutocaptureElement", From 815f1924df913b92a22b40cfbc208ea6f2026bef Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:09:11 +0000 Subject: [PATCH 09/13] docs(node): say that a reused idempotency key with other options sends nothing Co-Authored-By: Claude Opus 5.5 --- .../node/references/posthog-node-references-latest.json | 4 ++-- packages/node/src/emails.ts | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/node/references/posthog-node-references-latest.json b/packages/node/references/posthog-node-references-latest.json index b56f25fc7f..0b5bb08101 100644 --- a/packages/node/references/posthog-node-references-latest.json +++ b/packages/node/references/posthog-node-references-latest.json @@ -4357,7 +4357,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 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 template, or a project that no longer has a default sender, still refuses the send. The same key with other options, such as another `captureEngagement` or `replyTo`, sends nothing and returns `invalid_idempotent_request`. 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" }, @@ -4440,7 +4440,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 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`.", + "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. The same key with other options, such as another `captureEngagement` or `replyTo`,\nsends nothing and returns `invalid_idempotent_request`. Without it, the SDK makes a UUID v4 that covers its own\nretries. When a job runner retries the send, pass a key derived from what the email is for, such as\n`welcome:user_42`, and set `maxRetries: 0`.", "type": "string", "name": "idempotencyKey" }, diff --git a/packages/node/src/emails.ts b/packages/node/src/emails.ts index eaffe143a7..6f87fb02bb 100644 --- a/packages/node/src/emails.ts +++ b/packages/node/src/emails.ts @@ -59,9 +59,10 @@ export interface SendEmailBase { /** * 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`. + * sender, still refuses the send. The same key with other options, such as another `captureEngagement` or `replyTo`, + * sends nothing and returns `invalid_idempotent_request`. 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 /** From 10421e4a94e0efdef0d606d4aa4f20a6b6867338 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:27:15 +0000 Subject: [PATCH 10/13] feat(node): send a template by id on the shared email send route The template form posts template: { id } to /messaging/send/email/ instead of the template's own send route, once the server takes it there. Callers change nothing. Co-Authored-By: Claude Opus 5.5 --- .changeset/node-emails-template-route.md | 5 +++ packages/node/src/__tests__/emails.spec.ts | 13 +++++-- .../src/__tests__/utils/fake-emails-api.ts | 21 +++++----- packages/node/src/emails.ts | 39 +++++++------------ 4 files changed, 39 insertions(+), 39 deletions(-) create mode 100644 .changeset/node-emails-template-route.md diff --git a/.changeset/node-emails-template-route.md b/.changeset/node-emails-template-route.md new file mode 100644 index 0000000000..4d7c944c81 --- /dev/null +++ b/.changeset/node-emails-template-route.md @@ -0,0 +1,5 @@ +--- +'posthog-node': patch +--- + +`posthog.emails.send()` sends a template made in PostHog on the same send route as code content, so `captureEngagement` applies to it too. No change for callers. diff --git a/packages/node/src/__tests__/emails.spec.ts b/packages/node/src/__tests__/emails.spec.ts index f759fb87e6..de6ad14f03 100644 --- a/packages/node/src/__tests__/emails.spec.ts +++ b/packages/node/src/__tests__/emails.spec.ts @@ -8,7 +8,6 @@ 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 UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ @@ -76,19 +75,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, capture_engagement: true }, + { template: { id: TEMPLATE_ID }, to: JANE, capture_engagement: true }, ], [ 'template data, a distinct id and engagement capture turned off', { to: JANE, templateId: TEMPLATE_ID, templateData: { n: 1 }, distinctId: 'u', captureEngagement: false }, - { to: JANE, message_data: { n: 1 }, identifiers: { distinct_id: 'u' }, capture_engagement: 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) diff --git a/packages/node/src/__tests__/utils/fake-emails-api.ts b/packages/node/src/__tests__/utils/fake-emails-api.ts index 12ff169021..c321b495bf 100644 --- a/packages/node/src/__tests__/utils/fake-emails-api.ts +++ b/packages/node/src/__tests__/utils/fake-emails-api.ts @@ -1,6 +1,5 @@ import type { PostHogFetchOptions, PostHogFetchResponse } from '@posthog/core' -const TEMPLATE_SEND_PATH = /^\/api\/projects\/@current\/messaging_templates\/([^/]+)\/send\/$/ const SEND_PATH = '/api/projects/@current/messaging/send/email/' const DOCS_URL = 'https://posthog.com/docs/workflows/transactional-email-api' @@ -35,9 +34,9 @@ interface ScheduledFault { } /** - * An in-memory PostHog app host that speaks the send routes of the transactional email API: a template by id on its - * own route, and rendered content by `template_key` on the send route. An idempotency key PostHog already accepted for - * the same request answers the first `202` again. + * An in-memory PostHog app host that speaks the send route of the transactional email API: a template by id, and + * rendered content by `template_key`. An idempotency key PostHog already accepted for the same request answers the + * first `202` again. */ export class FakeEmailsApi { readonly received: ReceivedRequest[] = [] @@ -88,19 +87,19 @@ export class FakeEmailsApi { 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) - } if (request.method === 'POST' && request.path === SEND_PATH) { - return this.accept(request, { template: request.body.template_key, ...renderedContent(request.body) }) + return this.handleSend(request) } return refusal(404, 'not_found', null) } - private handleTemplateSend(templateId: string, request: ReceivedRequest): PostHogFetchResponse { + private handleSend(request: ReceivedRequest): PostHogFetchResponse { + const templateId = request.body.template?.id + if (templateId === undefined) { + return this.accept(request, { template: request.body.template_key, ...renderedContent(request.body) }) + } if (!this.templateIds.has(templateId)) { - return refusal(404, 'not_found', null) + return refusal(404, 'template_not_found', 'template__id') } return this.accept(request, { template: templateId, variables: request.body.message_data ?? {} }) } diff --git a/packages/node/src/emails.ts b/packages/node/src/emails.ts index 6f87fb02bb..943996d43a 100644 --- a/packages/node/src/emails.ts +++ b/packages/node/src/emails.ts @@ -1,7 +1,5 @@ import { isValidUUID, type JsonType, type PostHogApiResponse } from '@posthog/core' -const TEMPLATE_SEND_PATH = (templateId: string): string => - `/api/projects/@current/messaging_templates/${templateId}/send/` const SEND_PATH = '/api/projects/@current/messaging/send/email/' interface SendForm { @@ -69,8 +67,7 @@ export interface SendEmailBase { * Whether PostHog captures the email's engagement events (sent, delivered, opened, clicked, bounced) as analytics * events of the recipient. They count as analytics events. Defaults to `true` on every form; pass `false` to turn * capture off for this send. A project whose admin turned engagement capture off captures nothing, whatever the send - * asks. A template sent by `templateId` still follows the project's setting, until PostHog sends it on the same route - * as code content. + * asks. */ captureEngagement?: boolean /** How often to retry this send, a whole number from 0. Overrides the `maxRetries` client option. */ @@ -220,7 +217,6 @@ export interface EmailsApi { type Failure = { data: null; error: EmailError } interface PreparedSend { - path: string body: JsonType idempotencyKey: string maxRetries: number @@ -281,7 +277,7 @@ export class PostHogEmails implements Emails { private prepare(options: SendEmailOptions): PreparedSend { return { - ...(isTemplateForm(options) ? templateSend(options) : renderedContentSend(options)), + body: isTemplateForm(options) ? templateBody(options) : renderedContentBody(options), idempotencyKey: options.idempotencyKey ?? crypto.randomUUID(), maxRetries: retryCount(options.maxRetries) ?? retryCount(this.api.maxRetries()) ?? DEFAULT_MAX_RETRIES, } @@ -307,7 +303,7 @@ export class PostHogEmails implements Emails { } let response: PostHogApiResponse try { - response = await this.api.request(`${this.api.appHost()}${send.path}`, headers, body) + response = await this.api.request(`${this.api.appHost()}${SEND_PATH}`, headers, body) } catch (error) { return { result: failure('network_error', error instanceof Error ? error.message : 'Request failed') } } @@ -390,29 +386,24 @@ function idempotencyKeyRefusal(idempotencyKey: string | undefined): Failure | un ) } -function templateSend(options: SendTemplateEmailOptions): Pick { +function templateBody(options: SendTemplateEmailOptions): JsonType { return { - path: TEMPLATE_SEND_PATH(options.templateId), - body: { - ...recipientFields(options), - ...(options.templateData !== undefined && { message_data: options.templateData as JsonType }), - }, + template: { id: options.templateId }, + ...recipientFields(options), + ...(options.templateData !== undefined && { message_data: options.templateData as JsonType }), } } -function renderedContentSend(options: SendCodeEmailOptions): Pick { +function renderedContentBody(options: SendCodeEmailOptions): JsonType { const { templateKey, subject, html, text, from, replyTo } = options return { - path: SEND_PATH, - body: { - template_key: templateKey, - subject, - ...(html !== undefined && { html }), - ...(text !== undefined && { text }), - ...(from !== undefined && { from }), - ...(replyTo !== undefined && { reply_to: typeof replyTo === 'string' ? [replyTo] : replyTo }), - ...recipientFields(options), - }, + template_key: templateKey, + subject, + ...(html !== undefined && { html }), + ...(text !== undefined && { text }), + ...(from !== undefined && { from }), + ...(replyTo !== undefined && { reply_to: typeof replyTo === 'string' ? [replyTo] : replyTo }), + ...recipientFields(options), } } From ca36e2b12f4d538ce2f6037e84b3463a9e2bca62 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:43:07 +0000 Subject: [PATCH 11/13] chore(node): regenerate the API references Co-Authored-By: Claude Opus 5.5 --- packages/node/references/posthog-node-references-latest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/node/references/posthog-node-references-latest.json b/packages/node/references/posthog-node-references-latest.json index 0b5bb08101..9ba78a3413 100644 --- a/packages/node/references/posthog-node-references-latest.json +++ b/packages/node/references/posthog-node-references-latest.json @@ -4347,7 +4347,7 @@ "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. Defaults to `true` on every form; pass `false` to turn capture off for this send. A project whose admin turned engagement capture off captures nothing, whatever the send asks. A template sent by `templateId` still follows the project's setting, until PostHog sends it on the same route as code content.", + "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. Defaults to `true` on every form; pass `false` to turn capture off for this send. A project whose admin turned engagement capture off captures nothing, whatever the send asks.", "type": "boolean", "name": "captureEngagement" }, @@ -4445,7 +4445,7 @@ "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. Defaults to `true` on every form; pass `false` to turn\ncapture off for this send. A project whose admin turned engagement capture off captures nothing, whatever the send\nasks. A template sent by `templateId` still follows the project's setting, until PostHog sends it on the same route\nas code content.", + "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. Defaults to `true` on every form; pass `false` to turn\ncapture off for this send. A project whose admin turned engagement capture off captures nothing, whatever the send\nasks.", "type": "boolean", "name": "captureEngagement" }, From 2197b7bb730b3d0f9ec43c496cc85bd7323ad599 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:20:41 +0000 Subject: [PATCH 12/13] test(node): answer an unknown template id with the API's plain not_found The fake email API no longer invents a template_not_found code, and the changeset states the route change only. Co-Authored-By: Claude Opus 5.5 --- .changeset/node-emails-template-route.md | 2 +- packages/node/src/__tests__/utils/fake-emails-api.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/node-emails-template-route.md b/.changeset/node-emails-template-route.md index 4d7c944c81..0140d6770e 100644 --- a/.changeset/node-emails-template-route.md +++ b/.changeset/node-emails-template-route.md @@ -2,4 +2,4 @@ 'posthog-node': patch --- -`posthog.emails.send()` sends a template made in PostHog on the same send route as code content, so `captureEngagement` applies to it too. No change for callers. +`posthog.emails.send()` sends a template made in PostHog by its id on the same send route as code content. diff --git a/packages/node/src/__tests__/utils/fake-emails-api.ts b/packages/node/src/__tests__/utils/fake-emails-api.ts index c321b495bf..f15915eb05 100644 --- a/packages/node/src/__tests__/utils/fake-emails-api.ts +++ b/packages/node/src/__tests__/utils/fake-emails-api.ts @@ -99,7 +99,7 @@ export class FakeEmailsApi { return this.accept(request, { template: request.body.template_key, ...renderedContent(request.body) }) } if (!this.templateIds.has(templateId)) { - return refusal(404, 'template_not_found', 'template__id') + return refusal(404, 'not_found', null) } return this.accept(request, { template: templateId, variables: request.body.message_data ?? {} }) } From 8384b8ceb97b37b9296a26fd692c95c1d0102751 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:35:08 +0000 Subject: [PATCH 13/13] test(node): expect the send route for a template sent with an undefined second argument Co-Authored-By: Claude Opus 5.5 --- packages/node/src/__tests__/emails.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/node/src/__tests__/emails.spec.ts b/packages/node/src/__tests__/emails.spec.ts index de6ad14f03..4a667ad1e4 100644 --- a/packages/node/src/__tests__/emails.spec.ts +++ b/packages/node/src/__tests__/emails.spec.ts @@ -432,7 +432,7 @@ describe('emails', () => { const result = await send({ to: JANE, templateId: TEMPLATE_ID }, undefined) expect(result).toEqual({ data: { id: 'message-1', warnings: [] }, error: null }) - expect(requestLog()).toEqual([TEMPLATE_SEND]) + expect(requestLog()).toEqual([SEND]) }) it.each([