From 5b6dfca942c758acb0cd5a258609469937491d31 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:29:05 +0000 Subject: [PATCH 1/2] feat(node): send a local template, uploading it by content hash A code send with templateData sends template: { key, hash }. When PostHog answers template_outdated, the SDK uploads the content by key and sends again once with the same idempotency key. Moves the local template arm of PostHog/posthog-js#5371, with its content hash module. Co-Authored-By: Claude Opus 5.5 --- .changeset/node-emails-local-template.md | 5 + .../src/__tests__/email-content-hash.spec.ts | 30 +++ packages/node/src/__tests__/emails.spec.ts | 118 +++++++++-- .../node/src/__tests__/types/emails-forms.ts | 3 +- .../src/__tests__/utils/fake-emails-api.ts | 114 +++++++---- packages/node/src/client.ts | 5 +- packages/node/src/email-content-hash.ts | 54 +++++ packages/node/src/emails.ts | 187 +++++++++++++----- 8 files changed, 410 insertions(+), 106 deletions(-) create mode 100644 .changeset/node-emails-local-template.md create mode 100644 packages/node/src/__tests__/email-content-hash.spec.ts create mode 100644 packages/node/src/email-content-hash.ts diff --git a/.changeset/node-emails-local-template.md b/.changeset/node-emails-local-template.md new file mode 100644 index 0000000000..120eea5b4a --- /dev/null +++ b/.changeset/node-emails-local-template.md @@ -0,0 +1,5 @@ +--- +'posthog-node': minor +--- + +`posthog.emails.send()` also sends a local template: code content named by a `templateKey`, with `templateData` that PostHog fills in. The SDK uploads the template on its first send and whenever its content changes. diff --git a/packages/node/src/__tests__/email-content-hash.spec.ts b/packages/node/src/__tests__/email-content-hash.spec.ts new file mode 100644 index 0000000000..221a71e6cf --- /dev/null +++ b/packages/node/src/__tests__/email-content-hash.spec.ts @@ -0,0 +1,30 @@ +import { emailContentHash } from '@/email-content-hash' + +describe('emailContentHash', () => { + it.each([ + { + content: { subject: 'Welcome, {{ variables.first_name }}', html: '

Hi {{ variables.first_name }}

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

ä

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

Hi {{ variables.first_name }}

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

\na\nb\n

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

\r\na\rb\r\n

' })).toBe(lf) + }) +}) diff --git a/packages/node/src/__tests__/emails.spec.ts b/packages/node/src/__tests__/emails.spec.ts index 4a667ad1e4..06cafb8677 100644 --- a/packages/node/src/__tests__/emails.spec.ts +++ b/packages/node/src/__tests__/emails.spec.ts @@ -9,6 +9,8 @@ const SECRET_KEY = 'phs_secret' const APP_HOST = 'https://posthog.example.com' const TEMPLATE_ID = '0190f6d2-7c1e-4b3a-9f5e-1c2d3e4f5a6b' const SEND = 'POST /api/projects/@current/messaging/send/email/' +const UPLOAD_WELCOME = 'PUT /api/projects/@current/messaging_templates/by_key/welcome/' +const WELCOME_HASH = 'v1:e78af62206b6fdc0bd87d56d2a70555a9bb8d4a37617dcbb8a746f86f7db7a63' const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ describe('emails', () => { @@ -155,6 +157,10 @@ describe('emails', () => { }) describe('the code form', () => { + const welcome = { templateKey: 'welcome', subject: 'Welcome, {{ variables.name }}', html: '

Hi

' } + const sendLocalWelcome = (posthog: PostHog, content: Partial = {}) => + posthog.emails.send({ to: JANE, distinctId: 'user_42', ...welcome, ...content, templateData: { name: 'Jane' } }) + it('sends inline HTML to the recipient, as the distinct id, with the secret key', async () => { const result = await createClient().emails.send({ to: JANE, @@ -240,6 +246,84 @@ describe('emails', () => { { ...body, to: JANE, identifiers: { distinct_id: 'user_42' } }, ]) }) + + it('uploads a local template PostHog does not hold, then sends it again with the same idempotency key', async () => { + const result = await sendLocalWelcome(createClient({ migratedFrom: 'resend' })) + + expect(result).toEqual({ data: { id: 'message-1', warnings: [] }, error: null }) + expect(requestLog()).toEqual([SEND, UPLOAD_WELCOME, SEND]) + const [firstSend, upload, secondSend] = api.received + expect(firstSend.body).toEqual({ + template: { key: 'welcome', hash: WELCOME_HASH }, + to: JANE, + message_data: { name: 'Jane' }, + identifiers: { distinct_id: 'user_42' }, + capture_engagement: true, + }) + expect(upload.body).toEqual({ + name: 'welcome', + subject: welcome.subject, + html: welcome.html, + if_code_managed: true, + if_not_superseded: true, + }) + expect(upload.headers).toEqual(expect.objectContaining({ 'x-posthog-migrated-from': 'resend' })) + expect(secondSend.body).toEqual(firstSend.body) + expect(secondSend.headers['idempotency-key']).toBe(firstSend.headers['idempotency-key']) + expect(api.delivered).toEqual([ + expect.objectContaining({ template: 'welcome', subject: welcome.subject, variables: { name: 'Jane' } }), + ]) + }) + + it('sends a local template PostHog holds in one request, and uploads it again once it changes', async () => { + const posthog = createClient() + await sendLocalWelcome(posthog) + api.received.length = 0 + + await sendLocalWelcome(posthog) + expect(requestLog()).toEqual([SEND]) + + await sendLocalWelcome(posthog, { html: '

Hi again

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

Hi again

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

Edited

' }) + + const result = await sendLocalWelcome(createClient()) + + expect(result.error).toEqual( + expect.objectContaining({ name: 'validation_error', code: 'template_edited_in_posthog', statusCode: 409 }) + ) + expect(requestLog()).toEqual([SEND, UPLOAD_WELCOME]) + expect(api.delivered).toEqual([]) + }) + + it('returns a second template_outdated instead of uploading again', async () => { + api.failWhen(({ method }) => method === 'POST', { status: 409, body: refusalBody('template_outdated', null) }) + + const result = await sendLocalWelcome(createClient()) + + expect(result.error).toEqual(expect.objectContaining({ code: 'template_outdated' })) + expect(requestLog()).toEqual([SEND, UPLOAD_WELCOME, SEND]) + }) }) describe('errors', () => { @@ -383,11 +467,6 @@ describe('emails', () => { ['a code send without a subject', { templateKey: 'welcome', html: '

Hi

' }, 'subject'], ['a code send without html or text', { templateKey: 'welcome', subject: 'Hi' }, 'html'], ['a code send with html that is not a string', { 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' }, @@ -435,23 +514,20 @@ describe('emails', () => { expect(requestLog()).toEqual([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) + it('explains why it refuses a sender on the template form and how to send it', async () => { + const result = await createClient().emails.send({ + to: JANE, + templateId: TEMPLATE_ID, + from: 'Acme ', + } as unknown as SendEmailOptions) - expect(result.error).toEqual(expect.objectContaining({ code: 'invalid_form', why, fix })) + expect(result.error).toEqual( + expect.objectContaining({ + code: 'invalid_form', + why: 'A template made in PostHog sets its own sender, subject and content.', + fix: 'Remove from, or send content from your code with templateKey.', + }) + ) }) it('returns unexpected_error when the template data cannot be encoded', async () => { diff --git a/packages/node/src/__tests__/types/emails-forms.ts b/packages/node/src/__tests__/types/emails-forms.ts index 67a34fdd7d..3ef4383ef8 100644 --- a/packages/node/src/__tests__/types/emails-forms.ts +++ b/packages/node/src/__tests__/types/emails-forms.ts @@ -6,6 +6,7 @@ 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 }, + { to, templateKey: 'welcome', subject: 'Hi {{ variables.name }}', html: '

Hi

', templateData: { name: 'J' } }, ] export const wrongCombinations: SendEmailOptions[] = [ @@ -17,6 +18,4 @@ export const wrongCombinations: SendEmailOptions[] = [ { to, templateKey: 'reset', subject: 'Reset' }, // @ts-expect-error the code form needs a subject { to, templateKey: 'reset', html: '

Reset

' }, - // @ts-expect-error 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 f15915eb05..14283c34f4 100644 --- a/packages/node/src/__tests__/utils/fake-emails-api.ts +++ b/packages/node/src/__tests__/utils/fake-emails-api.ts @@ -1,6 +1,8 @@ import type { PostHogFetchOptions, PostHogFetchResponse } from '@posthog/core' +import { emailContentHash } from '@/email-content-hash' const SEND_PATH = '/api/projects/@current/messaging/send/email/' +const UPLOAD_PATH = /^\/api\/projects\/@current\/messaging_templates\/by_key\/([^/]+)\/$/ const DOCS_URL = 'https://posthog.com/docs/workflows/transactional-email-api' export interface ReceivedRequest { @@ -14,7 +16,7 @@ export interface ReceivedRequest { export interface DeliveredEmail { messageId: string to: string - /** The template id, or the rendered content's `template_key`. */ + /** The template id, the local template's key, or the rendered content's `template_key`. */ template: string subject?: string html?: string @@ -23,25 +25,36 @@ export interface DeliveredEmail { distinctId?: string } +interface LocalTemplate { + subject: string + html: string + text?: string + contentHash: string + editedInPostHog: boolean +} + export type Fault = | { status: number; body?: unknown; headers?: Record } | { networkError: Error } | { bodyBreaksOff: Error } interface ScheduledFault { + matches: (request: ReceivedRequest) => boolean fault: Fault remaining: number } /** - * 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. + * An in-memory PostHog app host that speaks the send and upload routes of the transactional email API: a template by + * id, rendered content by `template_key`, and a local template by key and content hash, which answers + * `template_outdated` until an upload publishes that content. An idempotency key PostHog already accepted for the + * same request answers the first `202` again. */ export class FakeEmailsApi { readonly received: ReceivedRequest[] = [] readonly delivered: DeliveredEmail[] = [] private readonly templateIds = new Set() + private readonly localTemplates = new Map() private readonly accepted = new Map() private readonly faults: ScheduledFault[] = [] private nextId = 1 @@ -51,12 +64,12 @@ 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() + 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) + return breakOff(await this.handle(request), fault.bodyBreaksOff) } if (fault) { return respond(fault.status, fault.body, fault.headers) @@ -68,72 +81,101 @@ export class FakeEmailsApi { this.templateIds.add(id) } + editInPostHog(key: string, content: { subject: string; html: string }): void { + this.localTemplates.set(key, { ...content, contentHash: 'v1:edited', editedInPostHog: true }) + } + + localTemplate(key: string): LocalTemplate | undefined { + return this.localTemplates.get(key) + } + + failWhen(matches: (request: ReceivedRequest) => boolean, fault: Fault, times = Infinity): void { + this.faults.push({ matches, fault, remaining: times }) + } + failNext(fault: Fault, times = 1): void { - this.faults.push({ fault, remaining: times }) + this.failWhen(() => true, fault, times) } - private takeFault(): Fault | undefined { - const scheduled = this.faults.find((candidate) => candidate.remaining > 0) + 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 { + private async handle(request: ReceivedRequest): Promise { if (request.origin !== this.options.appHost) { return respond(404, 'Not the app host') } if (request.headers.authorization !== `Bearer ${this.options.secretKey}`) { return refusal(401, 'authentication_failed', null) } + const uploadKey = UPLOAD_PATH.exec(request.path)?.[1] + if (request.method === 'PUT' && uploadKey) { + return this.handleUpload(decodeURIComponent(uploadKey), request.body) + } if (request.method === 'POST' && request.path === SEND_PATH) { return this.handleSend(request) } return refusal(404, 'not_found', null) } - private 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) + private async handleUpload(key: string, body: Record): Promise { + const existing = this.localTemplates.get(key) + if (existing?.editedInPostHog && body.if_code_managed === true) { + return refusal(409, 'template_edited_in_posthog', null) } - return this.accept(request, { template: templateId, variables: request.body.message_data ?? {} }) + const { subject, html, text } = body + const contentHash = await emailContentHash({ subject, html, text }) + this.localTemplates.set(key, { subject, html, text, contentHash, editedInPostHog: false }) + return respond(existing ? 200 : 201, { key, result: existing ? 'updated' : 'created', content_hash: contentHash }) } - private accept( - { path, headers, body }: ReceivedRequest, - content: Omit - ): PostHogFetchResponse { + private handleSend({ headers, body }: ReceivedRequest): PostHogFetchResponse { + const refused = this.refusalOf(body) + if (refused) { + return refused + } const key = headers['idempotency-key'] - const fingerprint = JSON.stringify([path, body]) + const fingerprint = JSON.stringify(body) const earlier = key === undefined ? undefined : this.accepted.get(key) if (earlier) { return earlier.fingerprint === fingerprint - ? respond(202, { message_id: earlier.messageId, replayed: true }) + ? respond(202, { message_id: earlier.messageId, replayed: true, warnings: [] }) : refusal(422, 'idempotency_key_reused', null) } const messageId = `message-${this.nextId++}` if (key !== undefined) { this.accepted.set(key, { fingerprint, messageId }) } - this.delivered.push({ messageId, to: body.to, ...content, distinctId: body.identifiers?.distinct_id }) - return respond(202, { message_id: messageId, replayed: false }) + this.delivered.push({ messageId, to: body.to, distinctId: body.identifiers?.distinct_id, ...this.contentOf(body) }) + return respond(202, { message_id: messageId, replayed: false, warnings: [] }) } -} -function renderedContent({ - subject, - html, - text, -}: Record): Pick { - return { - subject, - ...(html !== undefined && { html }), - ...(text !== undefined && { text }), + private refusalOf(body: Record): PostHogFetchResponse | undefined { + const { id, key, hash } = body.template ?? {} + if (id !== undefined && !this.templateIds.has(id)) { + return refusal(404, 'not_found', null) + } + const local = key === undefined ? undefined : this.localTemplates.get(key) + if (key !== undefined && (!local || (hash !== undefined && hash !== local.contentHash))) { + return refusal(409, 'template_outdated', local ? 'template__hash' : 'template__key') + } + return undefined + } + + private contentOf(body: Record): Omit { + const { id, key } = body.template ?? {} + if (id !== undefined) { + return { template: id, variables: body.message_data ?? {} } + } + if (key !== undefined) { + const { subject, html, text } = this.localTemplates.get(key)! + return { template: key, subject, html, text, variables: body.message_data ?? {} } + } + return { template: body.template_key, subject: body.subject, html: body.html, text: body.text } } } diff --git a/packages/node/src/client.ts b/packages/node/src/client.ts index bcf32c4ec3..f2427235e2 100644 --- a/packages/node/src/client.ts +++ b/packages/node/src/client.ts @@ -747,7 +747,7 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen }), warn: (message) => this._logger.warn(message), track: (work) => this.addPendingPromise(work), - request: (url, headers, body) => this._requestEmailsApi(url, headers, body), + request: (method, url, headers, body) => this._requestEmailsApi(method, url, headers, body), }) return this._emails } @@ -3067,12 +3067,13 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen /** One attempt: `posthog.emails` retries on its own, with the same idempotency key. */ private async _requestEmailsApi( + method: 'POST' | 'PUT', url: string, headers: Record, body: string ): Promise { try { - return await this.requestJson(url, { method: 'POST', headers, body }) + return await this.requestJson(url, { method, 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/email-content-hash.ts b/packages/node/src/email-content-hash.ts new file mode 100644 index 0000000000..6bc70605a5 --- /dev/null +++ b/packages/node/src/email-content-hash.ts @@ -0,0 +1,54 @@ +/// + +/** The published parts of an email that a recipient gets. An absent part hashes as an empty string. */ +export interface EmailContent { + subject?: string + preheader?: string + html?: string + text?: string +} + +const HASH_PREFIX = 'v1:' +const ENCODING_HEADER = 'posthog-email-template-v1\n' +const HASHED_PARTS = ['subject', 'preheader', 'html', 'text'] as const + +/** + * The hash PostHog stores for a template's published content. The server computes the same bytes, so a send can say + * which content the caller holds: the UTF-8 bytes of each part, line endings normalized, length-prefixed. + */ +export async function emailContentHash(content: EmailContent): Promise { + const subtle = globalThis.crypto?.subtle + if (!subtle) { + throw new Error('SubtleCrypto API not available') + } + const digest = await subtle.digest('SHA-256', encode(content)) + return HASH_PREFIX + toHex(new Uint8Array(digest)) +} + +function encode(content: EmailContent): Uint8Array { + const encoder = new TextEncoder() + const chunks: Uint8Array[] = [encoder.encode(ENCODING_HEADER)] + for (const part of HASHED_PARTS) { + const bytes = encoder.encode(normalizeLineEndings(content[part] ?? '')) + chunks.push(encoder.encode(`${bytes.length}:`), bytes, encoder.encode('\n')) + } + return concat(chunks) +} + +function normalizeLineEndings(value: string): string { + return value.replace(/\r\n?/g, '\n') +} + +function concat(chunks: Uint8Array[]): Uint8Array { + const joined = new Uint8Array(chunks.reduce((total, chunk) => total + chunk.length, 0)) + let offset = 0 + for (const chunk of chunks) { + joined.set(chunk, offset) + offset += chunk.length + } + return joined +} + +function toHex(bytes: Uint8Array): string { + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('') +} diff --git a/packages/node/src/emails.ts b/packages/node/src/emails.ts index 943996d43a..f5b21aca87 100644 --- a/packages/node/src/emails.ts +++ b/packages/node/src/emails.ts @@ -1,6 +1,8 @@ import { isValidUUID, type JsonType, type PostHogApiResponse } from '@posthog/core' +import { emailContentHash } from './email-content-hash' const SEND_PATH = '/api/projects/@current/messaging/send/email/' +const UPLOAD_PATH = (key: string): string => `/api/projects/@current/messaging_templates/by_key/${key}/` interface SendForm { name: 'templateId' | 'templateKey' @@ -26,6 +28,7 @@ const CODE_FIELDS: EveryField subject: true, from: true, replyTo: true, + templateData: true, html: true, text: true, } @@ -56,11 +59,12 @@ export interface SendEmailBase { distinctId?: string /** * Sends with a key PostHog already accepted queue nothing and return the first send's `id`, even after the email's - * workflow was paused or the default sender changed. A deleted template, or a project that no longer has a default - * sender, still refuses the send. 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`. + * workflow was paused or the default sender changed. A deleted template, a local template whose content changed, or + * a project that no longer has a default sender while you name no `from`, still refuses the send. 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 /** @@ -89,12 +93,18 @@ interface CodeEmailFields { /** A verified sender, such as `'Acme '`. Without it, the project's default sender. */ from?: string replyTo?: string | string[] + /** + * Leave it out to send rendered content: the email goes out as is, and PostHog never stores it. Pass it to send a + * local template: PostHog fills its `{{ variables.* }}`, and the SDK uploads the template when PostHog does not hold + * its content yet. + */ + templateData?: Record } /** - * Email content 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. + * Email content from your code, named by a key: content your code rendered, such as React Email output, or a local + * template that PostHog fills in. 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 }) @@ -102,9 +112,9 @@ type Without = { [K in keyof T]?: never } export type SendTemplateEmailOptions = SendEmailBase & TemplateEmailForm & - Without + Without & { html: string; text: string }> -export type SendCodeEmailOptions = SendEmailBase & CodeEmailForm & Without +export type SendCodeEmailOptions = SendEmailBase & CodeEmailForm & Without> /** One email to send with `posthog.emails.send()`, in exactly one form. */ export type SendEmailOptions = SendTemplateEmailOptions | SendCodeEmailOptions @@ -126,8 +136,8 @@ export interface EmailError { name: EmailErrorName /** * PostHog's error code, such as `invalid_recipient`, or one of the SDK's own: `missing_secret_key`, - * `missing_app_host`, `client_disabled`, `client_closed`, `network_error`, `api_error` for an answer without a code, - * `unexpected_error`, and `invalid_form`, `invalid_template_id`, `invalid_template_key` and + * `missing_app_host`, `client_disabled`, `client_closed`, `network_error`, `api_error` for an answer without a + * code, `unexpected_error`, and `invalid_form`, `invalid_template_id`, `invalid_template_key` and * `invalid_idempotency_key` for a send the SDK refused before any request. */ code: string @@ -185,6 +195,18 @@ export interface Emails { * }) * ``` * + * @example A local template: your code holds it, PostHog fills in the variables + * ```ts + * await posthog.emails.send({ + * to: 'jane@example.com', + * distinctId: user.id, + * templateKey: 'welcome', + * subject: 'Welcome, {{ variables.name }}', + * html: welcomeHtml, + * templateData: { name: 'Jane' }, + * }) + * ``` + * * @example A template made in PostHog, by its id * ```ts * const { data, error } = await posthog.emails.send({ @@ -211,19 +233,30 @@ export interface EmailsApi { warn(message: string): void track(work: Promise): Promise /** Rejects only when PostHog never answered. */ - request(url: string, headers: Record, body: string): Promise + request( + method: 'POST' | 'PUT', + url: string, + headers: Record, + body: string + ): Promise } +type Outcome = { data: T; error: null } | Failure type Failure = { data: null; error: EmailError } -interface PreparedSend { - body: JsonType +interface Delivery { idempotencyKey: string maxRetries: number } +interface ApiRequest { + method: 'POST' | 'PUT' + path: string + body: JsonType +} + interface Attempt { - result: SendEmailResult + outcome: Outcome retryAfterMs?: number } @@ -249,7 +282,15 @@ export class PostHogEmails implements Emails { if (refusal) { return Promise.resolve(refusal) } - return this.api.track(this.deliver(this.prepare(options)).catch(unexpectedFailure)) + const delivery = { + idempotencyKey: options.idempotencyKey ?? crypto.randomUUID(), + maxRetries: retryCount(options.maxRetries) ?? retryCount(this.api.maxRetries()) ?? DEFAULT_MAX_RETRIES, + } + return this.api.track( + this.sendForm(options, delivery) + .then((result) => this.logWarnings(result)) + .catch(unexpectedFailure) + ) } private refusalBeforeRequest(options: SendEmailOptions): Failure | undefined { @@ -275,39 +316,66 @@ export class PostHogEmails implements Emails { return formRefusal(options) ?? idempotencyKeyRefusal(options.idempotencyKey) } - private prepare(options: SendEmailOptions): PreparedSend { - return { - body: isTemplateForm(options) ? templateBody(options) : renderedContentBody(options), - idempotencyKey: options.idempotencyKey ?? crypto.randomUUID(), - maxRetries: retryCount(options.maxRetries) ?? retryCount(this.api.maxRetries()) ?? DEFAULT_MAX_RETRIES, + private sendForm(options: SendEmailOptions, delivery: Delivery): Promise { + if (isTemplateForm(options)) { + return this.post(templateBody(options), delivery) + } + if (options.templateData === undefined) { + return this.post(renderedContentBody(options), delivery) + } + return this.sendLocalTemplate(options, delivery) + } + + /** Sends the content's hash, and uploads the content only when PostHog does not hold it. */ + private async sendLocalTemplate(options: SendCodeEmailOptions, delivery: Delivery): Promise { + const { templateKey } = options + const content = { subject: options.subject, html: options.html, text: options.text } + const fields = jsonCopy(localTemplateFields(options)) + const body = { template: { key: templateKey, hash: await emailContentHash(content) }, ...fields } + const sent = await this.post(body, delivery) + if (!isOutdated(sent)) { + return sent } + const uploaded = await this.exchange( + { method: 'PUT', path: UPLOAD_PATH(templateKey), body: uploadBody(templateKey, content) }, + delivery + ) + return uploaded.error ? uploaded : this.post(body, delivery) } - private async deliver(send: PreparedSend): Promise { - const body = JSON.stringify(send.body) + private async post(body: JsonType, delivery: Delivery): Promise { + const outcome = await this.exchange({ method: 'POST', path: SEND_PATH, body }, delivery) + return outcome.error ? outcome : queuedEmail(outcome.data) + } + + private async exchange(request: ApiRequest, delivery: Delivery): Promise> { + const body = JSON.stringify(request.body) for (let retry = 0; ; retry++) { - const { result, retryAfterMs } = await this.attempt(send, body) - if (retry >= send.maxRetries || !isRetryable(result)) { - return this.logWarnings(result) + const { outcome, retryAfterMs } = await this.attempt(request, body, delivery) + if (retry >= delivery.maxRetries || !isRetryable(outcome)) { + return outcome } await sleep(retryDelayMs(retry, retryAfterMs)) } } - private async attempt(send: PreparedSend, body: string): Promise { + private async attempt(request: ApiRequest, body: string, delivery: Delivery): Promise { const headers = { ...this.api.headers(), 'Content-Type': 'application/json', Authorization: `Bearer ${this.api.secretKey()}`, - 'Idempotency-Key': send.idempotencyKey, + 'Idempotency-Key': delivery.idempotencyKey, } let response: PostHogApiResponse try { - response = await this.api.request(`${this.api.appHost()}${SEND_PATH}`, headers, body) + response = await this.api.request(request.method, `${this.api.appHost()}${request.path}`, headers, body) } catch (error) { - return { result: failure('network_error', error instanceof Error ? error.message : 'Request failed') } + return { outcome: failure('network_error', error instanceof Error ? error.message : 'Request failed') } } - return { result: sendResult(response), retryAfterMs: response.retryAfterMs } + const outcome = isSuccess(response.status) + ? { data: response, error: null } + : { data: null, error: apiError(response) } + return { outcome, retryAfterMs: response.retryAfterMs } } private logWarnings(result: SendEmailResult): SendEmailResult { @@ -390,23 +458,32 @@ function templateBody(options: SendTemplateEmailOptions): JsonType { return { template: { id: options.templateId }, ...recipientFields(options), - ...(options.templateData !== undefined && { message_data: options.templateData as JsonType }), + ...templateDataField(options.templateData), } } function renderedContentBody(options: SendCodeEmailOptions): JsonType { - const { templateKey, subject, html, text, from, replyTo } = options return { - template_key: templateKey, - subject, - ...(html !== undefined && { html }), - ...(text !== undefined && { text }), - ...(from !== undefined && { from }), - ...(replyTo !== undefined && { reply_to: typeof replyTo === 'string' ? [replyTo] : replyTo }), + template_key: options.templateKey, + subject: options.subject, + ...definedFields({ html: options.html, text: options.text }), + ...recipientFields(options), + ...senderFields(options), + } +} + +function localTemplateFields(options: SendCodeEmailOptions): Record { + return { ...recipientFields(options), + ...templateDataField(options.templateData), + ...senderFields(options), } } +function uploadBody(key: string, content: { subject: string; html?: string; text?: string }): JsonType { + return { name: key, ...definedFields(content), if_code_managed: true, if_not_superseded: true } +} + function recipientFields({ to, distinctId, captureEngagement }: SendEmailBase): Record { return { to, @@ -415,18 +492,38 @@ function recipientFields({ to, distinctId, captureEngagement }: SendEmailBase): } } -function sendResult(response: PostHogApiResponse): SendEmailResult { - const { status, body } = response - if (!isSuccess(status)) { - return { data: null, error: apiError(response) } +function senderFields({ from, replyTo }: SendCodeEmailOptions): Record { + return { + ...(from !== undefined && { from }), + ...(replyTo !== undefined && { reply_to: typeof replyTo === 'string' ? [replyTo] : replyTo }), } +} + +function templateDataField(templateData: Record | undefined): Record { + return templateData === undefined ? {} : { message_data: templateData as JsonType } +} + +function jsonCopy(value: T): T { + return JSON.parse(JSON.stringify(value)) as T +} + +function definedFields(fields: Record): Record { + return Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined)) as Record +} + +/** PostHog does not hold this local template's content, so nothing was sent and an upload fixes it. */ +function isOutdated({ error }: SendEmailResult): boolean { + return error?.statusCode === 409 && error.code === 'template_outdated' +} + +function queuedEmail({ status, body }: PostHogApiResponse): SendEmailResult { if (!isRecord(body) || typeof body.message_id !== 'string') { return failure('api_error', `PostHog answered HTTP ${status} with an unexpected body.`, { statusCode: status }) } return { data: { id: body.message_id, warnings: warningsOf(body.warnings) }, error: null } } -function isRetryable({ error }: SendEmailResult): boolean { +function isRetryable({ error }: Outcome): boolean { switch (error?.name) { case 'concurrent_idempotent_requests': case 'rate_limit_exceeded': From da59e276973c523bcc7449b2e4169db0ff82a4e3 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:43:12 +0000 Subject: [PATCH 2/2] chore(node): regenerate the API references Co-Authored-By: Claude Opus 5.5 --- .../references/posthog-node-references-latest.json | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/node/references/posthog-node-references-latest.json b/packages/node/references/posthog-node-references-latest.json index 9ba78a3413..65dc691f68 100644 --- a/packages/node/references/posthog-node-references-latest.json +++ b/packages/node/references/posthog-node-references-latest.json @@ -2052,6 +2052,11 @@ "type": "string", "name": "subject" }, + { + "description": "Leave it out to send rendered content: the email goes out as is, and PostHog never stores it. Pass it to send a local template: PostHog fills its `{{ variables.* }}`, and the SDK uploads the template when PostHog does not hold its content yet.", + "type": "Record", + "name": "templateData" + }, { "description": "Names the email, such as `'password-reset'`, so its metrics, logs and status group under it in PostHog.", "type": "string", @@ -4340,7 +4345,7 @@ "name": "SendCodeEmailOptions", "properties": [], "path": "src/emails.ts", - "example": "SendEmailBase & CodeEmailForm & Without" + "example": "SendEmailBase & CodeEmailForm & Without>" }, { "id": "SendEmailBase", @@ -4357,7 +4362,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. 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`.", + "description": "Sends with a key PostHog already accepted queue nothing and return the first send's `id`, even after the email's workflow was paused or the default sender changed. A deleted template, a local template whose content changed, or a project that no longer has a default sender while you name no `from`, still refuses the send. 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 +4445,7 @@ "name": "distinctId" }, { - "description": "Sends with a key PostHog already accepted queue nothing and return the first send's `id`, even after the email's\nworkflow was paused or the default sender changed. A deleted 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`.", + "description": "Sends with a key PostHog already accepted queue nothing and return the first send's `id`, even after the email's\nworkflow was paused or the default sender changed. A deleted template, a local template whose content changed, or\na project that no longer has a default sender while you name no `from`, still refuses the send. The same key with\nother options, such as another `captureEngagement` or `replyTo`, sends nothing and returns\n`invalid_idempotent_request`. 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" },