From 4e4bcb2b993f45d1c468301034a219a0c3f0918a Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sat, 31 Oct 2026 08:31:17 +0000 Subject: [PATCH 1/6] feat(node): send a local template by its content hash A send with templateKey and templateData names content from code that PostHog fills in. The SDK sends the template's key and content hash, and uploads the template once when PostHog answers template_outdated, then sends again with the same idempotency key. Co-Authored-By: Claude Opus 5.5 --- .changeset/node-emails-local-template.md | 5 + .../posthog-node-references-latest.json | 7 +- .../src/__tests__/email-content-hash.spec.ts | 30 ++++ packages/node/src/__tests__/emails.spec.ts | 129 +++++++++++++++- .../node/src/__tests__/types/emails-forms.ts | 3 +- packages/node/src/emails/content-hash.ts | 49 ++++++ packages/node/src/emails/emails.ts | 140 ++++++++++++++---- packages/node/src/emails/types.ts | 22 ++- 8 files changed, 339 insertions(+), 46 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/emails/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..36cbe51e2e --- /dev/null +++ b/.changeset/node-emails-local-template.md @@ -0,0 +1,5 @@ +--- +'posthog-node': minor +--- + +`posthog.emails.send()` also sends a local template: a send with `templateKey` and `templateData` names content from your code that PostHog fills in. The SDK uploads the template on its first send and whenever its content changes. diff --git a/packages/node/references/posthog-node-references-latest.json b/packages/node/references/posthog-node-references-latest.json index 81e33821e4..083cd4564d 100644 --- a/packages/node/references/posthog-node-references-latest.json +++ b/packages/node/references/posthog-node-references-latest.json @@ -2057,6 +2057,11 @@ "type": "string", "name": "subject" }, + { + "description": "Leave it out to send rendered content: the email goes out as is, and PostHog never stores it. Pass it to send a local template: PostHog fills its `{{ variables.* }}`, and the SDK uploads the template whenever PostHog doesn't hold its content yet.", + "type": "Record", + "name": "templateData" + }, { "description": "Names the email, such as `'password-reset'`, so its metrics, logs and status group under it in PostHog. Letters, digits, hyphens and underscores, at most 400.", "type": "string", @@ -4382,7 +4387,7 @@ "name": "SendCodeEmailOptions", "properties": [], "path": "src/emails/types.ts", - "example": "SendEmailBase & CodeEmailForm & Without" + "example": "SendEmailBase & CodeEmailForm & Without>" }, { "id": "SendEmailBase", diff --git a/packages/node/src/__tests__/email-content-hash.spec.ts b/packages/node/src/__tests__/email-content-hash.spec.ts new file mode 100644 index 0000000000..50da93f80a --- /dev/null +++ b/packages/node/src/__tests__/email-content-hash.spec.ts @@ -0,0 +1,30 @@ +import { emailContentHash } from '@/emails/content-hash' + +describe('emailContentHash', () => { + it.each([ + { + content: { subject: 'Welcome, {{ variables.first_name }}', html: '

Hi {{ variables.first_name }}

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

ä

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

Hi {{ variables.first_name }}

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

\na\nb\n

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

\r\na\rb\r\n

' })).toBe(lf) + }) +}) diff --git a/packages/node/src/__tests__/emails.spec.ts b/packages/node/src/__tests__/emails.spec.ts index f32496caba..dd8e9fd0c4 100644 --- a/packages/node/src/__tests__/emails.spec.ts +++ b/packages/node/src/__tests__/emails.spec.ts @@ -510,7 +510,6 @@ describe('emails', () => { ['a code send without a subject', { templateKey: 'welcome', html: '

Hi

' }, 'subject'], ['a code send without html or text', { templateKey: 'welcome', subject: 'Hi' }, 'html'], ['a code send with html that is not a string', { ...CODE_SEND, html: 1 }, 'html'], - ['template data on a code send', { ...CODE_SEND, templateData: { name: 'Jane' } }, 'templateData'], [ "Resend's scheduledAt on the template form", { templateId: TEMPLATE_ID, scheduledAt: 'in 1 hour' }, @@ -538,12 +537,6 @@ describe('emails', () => { "A template made in PostHog sets its own sender, subject and content, and follows the project's engagement setting.", 'Remove captureEngagement, or send content from your code with templateKey.', ], - [ - 'template data on a code send', - { ...CODE_SEND, templateData: { name: 'Jane' } }, - 'A send with templateKey takes its content from your code.', - 'Remove templateData, or send a template made in PostHog with templateId.', - ], [ 'a field no form takes', { ...CODE_SEND, scheduledAt: 'in 1 hour' }, @@ -572,6 +565,128 @@ describe('emails', () => { }) }) + describe('a local template', () => { + const UPLOAD_URL = 'https://us.posthog.com/api/projects/@current/messaging_templates/by_key/welcome/' + const WELCOME_HASH = 'v1:e78af62206b6fdc0bd87d56d2a70555a9bb8d4a37617dcbb8a746f86f7db7a63' + const WELCOME = { templateKey: 'welcome', subject: 'Welcome, {{ variables.name }}', html: '

Hi

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

Reset

', replyTo: ['help@example.com'] }, { to, templateKey: 'reset', subject: 'Reset', text: 'Reset', from: 'security@example.com', captureEngagement: false }, + { to, templateKey: 'welcome', subject: 'Hi {{ variables.name }}', html: '

Hi

', templateData: { name: 'J' } }, ] export const wrongCombinations: SendEmailOptions[] = [ @@ -20,6 +21,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 content from code takes no template data - { to, templateKey: 'reset', subject: 'Reset', html: '

Reset

', templateData: { name: 'J' } }, ] diff --git a/packages/node/src/emails/content-hash.ts b/packages/node/src/emails/content-hash.ts new file mode 100644 index 0000000000..f38782c7df --- /dev/null +++ b/packages/node/src/emails/content-hash.ts @@ -0,0 +1,49 @@ +/// + +export interface EmailContent { + subject?: string + preheader?: string + html?: string + text?: string +} + +const HASH_PREFIX = 'v1:' +const ENCODING_HEADER = 'posthog-email-template-v1\n' +const HASHED_PARTS = ['subject', 'preheader', 'html', 'text'] as const + +export async function emailContentHash(content: EmailContent): Promise { + const subtle = globalThis.crypto?.subtle + if (!subtle) { + throw new Error('SubtleCrypto API not available') + } + const digest = await subtle.digest('SHA-256', encode(content)) + return HASH_PREFIX + toHex(new Uint8Array(digest)) +} + +function encode(content: EmailContent): Uint8Array { + const encoder = new TextEncoder() + 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/emails.ts b/packages/node/src/emails/emails.ts index d17ed19994..31de38f49a 100644 --- a/packages/node/src/emails/emails.ts +++ b/packages/node/src/emails/emails.ts @@ -7,6 +7,7 @@ import { safeJsonStringify, } from '@posthog/core' +import { type EmailContent, emailContentHash } from './content-hash' import { EmailError } from './errors' import type { CodeEmailForm, @@ -24,6 +25,7 @@ import type { const SEND_PATH = '/api/projects/@current/messaging/send/email/' const STATUS_PATH = '/api/projects/@current/messaging/emails/{messageId}/' +const TEMPLATE_UPLOAD_PATH = '/api/projects/@current/messaging_templates/by_key/{templateKey}/' const US_APP_HOST = 'https://us.posthog.com' const EU_APP_HOST = 'https://eu.posthog.com' const CLOUD_APP_HOSTS: Record = { @@ -61,6 +63,7 @@ const CODE_FIELDS: EveryField = { from: true, replyTo: true, captureEngagement: true, + templateData: true, html: true, text: true, } @@ -103,6 +106,22 @@ interface PreparedSend { body: string idempotencyKey: string maxRetries: number + upload?: TemplateUpload +} + +interface TemplateUpload { + path: string + body: string +} + +interface SendContent { + fields: Record + localTemplate?: LocalTemplate +} + +interface LocalTemplate { + key: string + content: EmailContent } interface Reply { @@ -153,18 +172,12 @@ export class EmailSender implements Emails { private async sendUntracked(options: SendEmailOptions, unexpected: unknown[]): Promise { try { refuseSecondArgument(unexpected) - const prepared = prepareSend(options, this.config.maxRetries) + const prepared = await prepareSend(options, this.config.maxRetries) const { appHost, secretKey } = this.connect( 'no email was sent', 'Sending email needs secretKey, a project secret API key with the hog_flow:write scope.' ) - const headers = this.headers(secretKey, { - 'Content-Type': 'application/json', - 'Idempotency-Key': prepared.idempotencyKey, - }) - const result = await this.withRetries(prepared.maxRetries, async () => - parseSendReply(await this.request(appHost + prepared.path, 'POST', headers, prepared.body)) - ) + const result = await this.deliver(prepared, appHost, secretKey) for (const warning of result.warnings) { this.hooks.logger.warn(`PostHog email warning ${warning.code}: ${warning.message}`) } @@ -174,6 +187,33 @@ export class EmailSender implements Emails { } } + private async deliver(prepared: PreparedSend, appHost: string, secretKey: string): Promise { + const headers = this.headers(secretKey, { + 'Content-Type': 'application/json', + 'Idempotency-Key': prepared.idempotencyKey, + }) + const post = (): Promise => + this.withRetries(prepared.maxRetries, async () => + parseSendReply(await this.request(appHost + prepared.path, 'POST', headers, prepared.body)) + ) + try { + return await post() + } catch (error) { + if (!prepared.upload || !isOutdatedTemplate(error)) { + throw error + } + await this.upload(prepared.upload, appHost, secretKey, prepared.maxRetries) + return await post() + } + } + + private async upload(upload: TemplateUpload, appHost: string, secretKey: string, maxRetries: number): Promise { + const headers = this.headers(secretKey, { 'Content-Type': 'application/json' }) + await this.withRetries(maxRetries, async () => + parseUploadReply(await this.request(appHost + upload.path, 'PUT', headers, upload.body)) + ) + } + private async getUntracked(id: string): Promise { try { if (typeof id !== 'string' || !UUID_PATTERN.test(id)) { @@ -264,7 +304,7 @@ export class EmailSender implements Emails { private async request( url: string, - method: 'GET' | 'POST', + method: 'GET' | 'POST' | 'PUT', headers: Record, body?: string ): Promise { @@ -332,7 +372,7 @@ function refuseSecondArgument(unexpected: unknown[]): void { } } -function prepareSend(options: SendEmailOptions, defaultMaxRetries: number): PreparedSend { +async function prepareSend(options: SendEmailOptions, defaultMaxRetries: number): Promise { const send: UncheckedSend = options ?? {} const { to, distinctId, idempotencyKey, maxRetries } = send if (typeof to !== 'string' || !to.trim()) { @@ -341,7 +381,7 @@ function prepareSend(options: SendEmailOptions, defaultMaxRetries: number): Prep if (distinctId !== undefined && (typeof distinctId !== 'string' || !distinctId)) { throw invalid('invalid_distinct_id', 'distinctId must be a nonempty string.', 'distinctId') } - const content = contentOf(send, distinctId) + const { fields, localTemplate } = contentOf(send, distinctId) if ( idempotencyKey !== undefined && (typeof idempotencyKey !== 'string' || !IDEMPOTENCY_KEY_PATTERN.test(idempotencyKey)) @@ -356,19 +396,30 @@ function prepareSend(options: SendEmailOptions, defaultMaxRetries: number): Prep throw invalid('invalid_max_retries', 'maxRetries must be a nonnegative integer.', 'maxRetries') } - return { + const prepared = { path: SEND_PATH, - body: safeJsonStringify({ ...content, to }), idempotencyKey: idempotencyKey ?? globalThis.crypto.randomUUID(), maxRetries: isRetryCount(maxRetries) ? maxRetries : defaultMaxRetries, } + if (!localTemplate) { + return { ...prepared, body: safeJsonStringify({ ...fields, to }) } + } + const template = { key: localTemplate.key, hash: await emailContentHash(localTemplate.content) } + return { ...prepared, body: safeJsonStringify({ template, ...fields, to }), upload: templateUpload(localTemplate) } +} + +function templateUpload({ key, content }: LocalTemplate): TemplateUpload { + return { + path: TEMPLATE_UPLOAD_PATH.replace('{templateKey}', key), + body: safeJsonStringify({ name: key, ...content, if_code_managed: true, if_not_superseded: true }), + } } function isRetryCount(value: unknown): value is number { return typeof value === 'number' && Number.isInteger(value) && value >= 0 } -function contentOf(send: UncheckedSend, distinctId: string | undefined): Record { +function contentOf(send: UncheckedSend, distinctId: string | undefined): SendContent { if ((send.templateId === undefined) === (send.templateKey === undefined)) { throw new EmailError({ code: 'invalid_form', @@ -378,27 +429,37 @@ function contentOf(send: UncheckedSend, distinctId: string | undefined): Record< return send.templateKey === undefined ? templateContent(send, distinctId) : codeContent(send, distinctId) } -function templateContent(send: UncheckedSend, distinctId: string | undefined): Record { +function templateContent(send: UncheckedSend, distinctId: string | undefined): SendContent { const { templateId, templateData } = send if (typeof templateId !== 'string' || !UUID_PATTERN.test(templateId)) { throw invalid('invalid_template_id', "templateId must be the template's UUID.", 'templateId') } + refuseNonObjectTemplateData(templateData) + refuseFieldsOutside(send, TEMPLATE_FORM, CODE_FORM) + return { + fields: { + template: { id: templateId.toLowerCase() }, + message_data: templateData ?? {}, + ...identifiersOf(distinctId), + }, + } +} + +function refuseNonObjectTemplateData(templateData: unknown): void { if ( templateData !== undefined && (typeof templateData !== 'object' || templateData === null || Array.isArray(templateData)) ) { throw invalid('invalid_template_data', 'templateData must be an object.', 'templateData') } - refuseFieldsOutside(send, TEMPLATE_FORM, CODE_FORM) - return { - template: { id: templateId.toLowerCase() }, - message_data: templateData ?? {}, - ...(distinctId !== undefined && { identifiers: { distinct_id: distinctId } }), - } } -function codeContent(send: UncheckedSend, distinctId: string | undefined): Record { - const { templateKey, subject, html, text, from, replyTo, captureEngagement } = send +function identifiersOf(distinctId: string | undefined): Record { + return distinctId === undefined ? {} : { identifiers: { distinct_id: distinctId } } +} + +function codeContent(send: UncheckedSend, distinctId: string | undefined): SendContent { + const { templateKey, subject, html, text, from, replyTo, captureEngagement, templateData } = send if (typeof templateKey !== 'string' || !TEMPLATE_KEY_PATTERN.test(templateKey)) { throw invalid( 'invalid_template_key', @@ -419,15 +480,26 @@ function codeContent(send: UncheckedSend, distinctId: string | undefined): Recor if (html === undefined && text === undefined) { throw invalid('invalid_form', 'A send with templateKey needs html, text or both.', 'html') } - return { - template_key: templateKey, - subject, - ...(html !== undefined && { html }), - ...(text !== undefined && { text }), + refuseNonObjectTemplateData(templateData) + const content = { subject, ...(html !== undefined && { html }), ...(text !== undefined && { text }) } + const sender = { ...(from !== undefined && { from }), ...(replyTo !== undefined && { reply_to: typeof replyTo === 'string' ? [replyTo] : replyTo }), capture_engagement: captureEngagement ?? true, - ...(distinctId !== undefined && { distinct_id: distinctId }), + } + if (templateData === undefined) { + return { + fields: { + template_key: templateKey, + ...content, + ...sender, + ...(distinctId !== undefined && { distinct_id: distinctId }), + }, + } + } + return { + fields: { message_data: JSON.parse(safeJsonStringify(templateData)), ...identifiersOf(distinctId), ...sender }, + localTemplate: { key: templateKey, content }, } } @@ -526,6 +598,16 @@ function parseSendReply({ status, payload, retryAfterMs }: Reply): SendEmailResu throw replyError(fields, status, retryAfterMs) } +function parseUploadReply({ status, payload, retryAfterMs }: Reply): void { + if (status < 200 || status >= 300) { + throw replyError(replyFields(payload), status, retryAfterMs) + } +} + +function isOutdatedTemplate(error: unknown): boolean { + return error instanceof EmailError && error.statusCode === 409 && error.code === 'template_outdated' +} + function parseStatusReply({ status, payload, retryAfterMs }: Reply): EmailStatus { const fields = replyFields(payload) if (status < 200 || status >= 300) { diff --git a/packages/node/src/emails/types.ts b/packages/node/src/emails/types.ts index 7de44ddc0e..5125969081 100644 --- a/packages/node/src/emails/types.ts +++ b/packages/node/src/emails/types.ts @@ -46,12 +46,18 @@ interface CodeEmailFields { * follows the project's setting. */ captureEngagement?: boolean + /** + * Leave it out to send rendered content: the email goes out as is, and PostHog never stores it. + * Pass it to send a local template: PostHog fills its `{{ variables.* }}`, and the SDK uploads + * the template whenever PostHog doesn't hold its content yet. + */ + templateData?: Record } /** - * Email content your code rendered, such as React Email output, named by a key. PostHog sends it - * as is and never stores it. Needs `html`, `text` or both. PostHog derives a missing `text` from - * the `html`. + * Email content from your code, named by a key: content your code rendered, such as React Email + * output, or a local template that PostHog fills in with `templateData`. Needs `html`, `text` or + * both. PostHog derives a missing `text` from the `html`. */ export type CodeEmailForm = CodeEmailFields & ({ html: string; text?: string } | { html?: string; text: string }) @@ -59,9 +65,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> /** Options for `posthog.emails.send()`: a template made in PostHog, or content from your code. */ export type SendEmailOptions = SendTemplateEmailOptions | SendCodeEmailOptions @@ -129,11 +135,13 @@ export type GetEmailResponse = { data: EmailStatus; error: null } | { data: null */ export interface Emails { /** - * Queue one email, from a transactional email template or from content your code rendered. + * Queue one email, from a transactional email template, from content your code rendered, or + * from a local template. * * Resolves to `{ data, error }` and does not reject. `data` means PostHog queued the email, * not that it was delivered. Network errors, rate limits, an in-flight idempotency key and - * an unavailable service are retried with the same idempotency key. + * an unavailable service are retried with the same idempotency key. A local template that + * PostHog doesn't hold yet is uploaded once, then sent again with the same idempotency key. */ send(options: SendEmailOptions): Promise From 660f6a0f1159c32b182a233f3b9751b7fcac7c2d Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sat, 31 Oct 2026 08:37:48 +0000 Subject: [PATCH 2/6] fix(node): name a refused template upload validation_error A 409 template_edited_in_posthog from a local-template upload is a permanent refusal, not a concurrent request, so it carries the name validation_error like template_outdated. Co-Authored-By: Claude Opus 5.5 --- packages/node/src/__tests__/emails.spec.ts | 2 +- packages/node/src/emails/errors.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/node/src/__tests__/emails.spec.ts b/packages/node/src/__tests__/emails.spec.ts index dd8e9fd0c4..7c4efd2ea1 100644 --- a/packages/node/src/__tests__/emails.spec.ts +++ b/packages/node/src/__tests__/emails.spec.ts @@ -645,7 +645,7 @@ describe('emails', () => { const { data, error } = await sendWelcome() expect(data).toBeNull() - expect(error).toMatchObject({ statusCode: 409 }) + expect(error).toMatchObject({ statusCode: 409, name: 'validation_error' }) expect(fetch).toHaveBeenCalledTimes(requestsMade) }) diff --git a/packages/node/src/emails/errors.ts b/packages/node/src/emails/errors.ts index a982935bda..1dfeae07e9 100644 --- a/packages/node/src/emails/errors.ts +++ b/packages/node/src/emails/errors.ts @@ -58,6 +58,8 @@ export class EmailError extends Error { } } +const TEMPLATE_CONFLICTS = new Set(['template_outdated', 'template_edited_in_posthog']) + function emailErrorName(status: number | undefined, code: string): EmailErrorName { if (code === 'missing_secret_key' || status === 401) { return 'missing_api_key' @@ -76,7 +78,7 @@ function emailErrorName(status: number | undefined, code: string): EmailErrorNam case 404: return 'not_found' case 409: - return code === 'template_outdated' ? 'validation_error' : 'concurrent_idempotent_requests' + return TEMPLATE_CONFLICTS.has(code) ? 'validation_error' : 'concurrent_idempotent_requests' case 422: // The template send route also answers 422 for send checks such as recipient_suppressed. return code === 'idempotency_key_reused' ? 'invalid_idempotent_request' : 'validation_error' From bf8cfa2b32558a3d97c894cf874388358e09f044 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sat, 31 Oct 2026 08:42:50 +0000 Subject: [PATCH 3/6] fix(node): check the client and copy the options before hashing a send A send now validates, copies its options and checks the client before its first await, so shutdown drains a send that started before it and a caller who changes replyTo afterwards doesn't change the email. Co-Authored-By: Claude Opus 5.5 --- packages/node/src/__tests__/emails.spec.ts | 30 ++++++++++++++++---- packages/node/src/emails/emails.ts | 32 ++++++++++++++-------- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/packages/node/src/__tests__/emails.spec.ts b/packages/node/src/__tests__/emails.spec.ts index 7c4efd2ea1..36a1120b1e 100644 --- a/packages/node/src/__tests__/emails.spec.ts +++ b/packages/node/src/__tests__/emails.spec.ts @@ -460,6 +460,21 @@ describe('emails', () => { expect(batchCallsDuringBackoff).toBe(1) }) + it.each([ + ['a template send', {}], + [ + 'a local template send', + { templateId: undefined, templateKey: 'welcome', subject: 'Hi', html: '

Hi

', templateData: {} }, + ], + ])('sends %s that started before shutdown', async (_name, overrides) => { + replies = [accepted()] + + const pending = send(overrides) + await posthog.shutdown() + + expect((await pending).data?.id).toBe('msg_1') + }) + it('sends nothing after shutdown and returns client_closed', async () => { await posthog.shutdown() @@ -660,20 +675,25 @@ describe('emails', () => { it('sends the recipient and data it was called with, even when the caller changes them afterwards', async () => { replies = [accepted(), accepted()] - const options = { to: 'jane@example.com', ...WELCOME, templateData: { name: 'Jane' } } + const options = { + to: 'jane@example.com', + ...WELCOME, + templateData: { name: 'Jane' }, + replyTo: ['jane-help@example.com'], + } const toJane = posthog.emails.send(options) options.to = 'bob@example.com' options.templateData.name = 'Bob' + options.replyTo[0] = 'bob-help@example.com' const toBob = posthog.emails.send(options) await vi.runAllTimersAsync() await Promise.all([toJane, toBob]) - expect(bodies().map((body) => (body as { to: string; message_data: unknown }).message_data)).toEqual([ - { name: 'Jane' }, - { name: 'Bob' }, + expect(bodies()).toMatchObject([ + { to: 'jane@example.com', message_data: { name: 'Jane' }, reply_to: ['jane-help@example.com'] }, + { to: 'bob@example.com', message_data: { name: 'Bob' }, reply_to: ['bob-help@example.com'] }, ]) - expect(bodies().map((body) => (body as { to: string }).to)).toEqual(['jane@example.com', 'bob@example.com']) }) it.each([['template data that is not an object', { templateData: ['Jane'] }, 'invalid_template_data']])( diff --git a/packages/node/src/emails/emails.ts b/packages/node/src/emails/emails.ts index 31de38f49a..e7dba554a4 100644 --- a/packages/node/src/emails/emails.ts +++ b/packages/node/src/emails/emails.ts @@ -101,12 +101,18 @@ export interface EmailSenderHooks { userAgent?: string } +interface PendingSend { + fields: Record + localTemplate?: LocalTemplate + idempotencyKey: string + maxRetries: number +} + interface PreparedSend { - path: string body: string + upload?: TemplateUpload idempotencyKey: string maxRetries: number - upload?: TemplateUpload } interface TemplateUpload { @@ -172,12 +178,12 @@ export class EmailSender implements Emails { private async sendUntracked(options: SendEmailOptions, unexpected: unknown[]): Promise { try { refuseSecondArgument(unexpected) - const prepared = await prepareSend(options, this.config.maxRetries) + const pending = prepareSend(options, this.config.maxRetries) const { appHost, secretKey } = this.connect( 'no email was sent', 'Sending email needs secretKey, a project secret API key with the hog_flow:write scope.' ) - const result = await this.deliver(prepared, appHost, secretKey) + const result = await this.deliver(await completeSend(pending), appHost, secretKey) for (const warning of result.warnings) { this.hooks.logger.warn(`PostHog email warning ${warning.code}: ${warning.message}`) } @@ -194,7 +200,7 @@ export class EmailSender implements Emails { }) const post = (): Promise => this.withRetries(prepared.maxRetries, async () => - parseSendReply(await this.request(appHost + prepared.path, 'POST', headers, prepared.body)) + parseSendReply(await this.request(appHost + SEND_PATH, 'POST', headers, prepared.body)) ) try { return await post() @@ -372,7 +378,7 @@ function refuseSecondArgument(unexpected: unknown[]): void { } } -async function prepareSend(options: SendEmailOptions, defaultMaxRetries: number): Promise { +function prepareSend(options: SendEmailOptions, defaultMaxRetries: number): PendingSend { const send: UncheckedSend = options ?? {} const { to, distinctId, idempotencyKey, maxRetries } = send if (typeof to !== 'string' || !to.trim()) { @@ -396,16 +402,20 @@ async function prepareSend(options: SendEmailOptions, defaultMaxRetries: number) throw invalid('invalid_max_retries', 'maxRetries must be a nonnegative integer.', 'maxRetries') } - const prepared = { - path: SEND_PATH, + return { + fields: JSON.parse(safeJsonStringify({ ...fields, to })), + localTemplate, idempotencyKey: idempotencyKey ?? globalThis.crypto.randomUUID(), maxRetries: isRetryCount(maxRetries) ? maxRetries : defaultMaxRetries, } +} + +async function completeSend({ fields, localTemplate, ...delivery }: PendingSend): Promise { if (!localTemplate) { - return { ...prepared, body: safeJsonStringify({ ...fields, to }) } + return { ...delivery, body: safeJsonStringify(fields) } } const template = { key: localTemplate.key, hash: await emailContentHash(localTemplate.content) } - return { ...prepared, body: safeJsonStringify({ template, ...fields, to }), upload: templateUpload(localTemplate) } + return { ...delivery, body: safeJsonStringify({ template, ...fields }), upload: templateUpload(localTemplate) } } function templateUpload({ key, content }: LocalTemplate): TemplateUpload { @@ -498,7 +508,7 @@ function codeContent(send: UncheckedSend, distinctId: string | undefined): SendC } } return { - fields: { message_data: JSON.parse(safeJsonStringify(templateData)), ...identifiersOf(distinctId), ...sender }, + fields: { message_data: templateData, ...identifiersOf(distinctId), ...sender }, localTemplate: { key: templateKey, content }, } } From 2fd1f1af2c75e85b9410eb2c434389b9f421cb75 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sun, 1 Nov 2026 16:42:25 +0000 Subject: [PATCH 4/6] fix(node): refuse a local template without html or a subject PostHog stores a local template on upload, and its upload route needs a subject and an HTML body. A text-only local template got 409 template_outdated, then a 400 on the upload, on every send. The SDK now refuses it before any request, and the types need html with templateData. The local-template mutation test no longer depends on which of two content hashes finishes first. Co-Authored-By: Claude Opus 5.5 --- .changeset/node-emails-local-template.md | 2 +- .../posthog-node-references-latest.json | 4 +- packages/node/src/__tests__/emails.spec.ts | 40 +++++++++++++------ .../node/src/__tests__/types/emails-forms.ts | 2 + packages/node/src/emails/emails.ts | 7 ++++ packages/node/src/emails/types.ts | 9 +++-- 6 files changed, 45 insertions(+), 19 deletions(-) diff --git a/.changeset/node-emails-local-template.md b/.changeset/node-emails-local-template.md index 36cbe51e2e..603585b7fb 100644 --- a/.changeset/node-emails-local-template.md +++ b/.changeset/node-emails-local-template.md @@ -2,4 +2,4 @@ 'posthog-node': minor --- -`posthog.emails.send()` also sends a local template: a send with `templateKey` and `templateData` names content from your code that PostHog fills in. The SDK uploads the template on its first send and whenever its content changes. +`posthog.emails.send()` also sends a local template: a send with `templateKey` and `templateData` names content from your code that PostHog fills in. The SDK uploads the template whenever PostHog doesn't hold its content yet, such as on its first send or after the content changed. A local template needs `html`. diff --git a/packages/node/references/posthog-node-references-latest.json b/packages/node/references/posthog-node-references-latest.json index 083cd4564d..f43d0f90f7 100644 --- a/packages/node/references/posthog-node-references-latest.json +++ b/packages/node/references/posthog-node-references-latest.json @@ -2058,7 +2058,7 @@ "name": "subject" }, { - "description": "Leave it out to send rendered content: the email goes out as is, and PostHog never stores it. Pass it to send a local template: PostHog fills its `{{ variables.* }}`, and the SDK uploads the template whenever PostHog doesn't hold its content yet.", + "description": "Leave it out to send rendered content: the email goes out as is, and PostHog never stores it. Pass it to send a local template: PostHog fills its `{{ variables.* }}`, and the SDK uploads the template whenever PostHog doesn't hold its content yet. A local template needs `html`. Content that PostHog already replaced with newer content isn't uploaded again: the email goes out with the newer content, and the send returns a `template_superseded` warning.", "type": "Record", "name": "templateData" }, @@ -2075,7 +2075,7 @@ "name": "CodeEmailForm", "properties": [], "path": "src/emails/types.ts", - "example": "CodeEmailFields & ({\n html: string;\n text?: string;\n} | {\n html?: string;\n text: string;\n})" + "example": "CodeEmailFields & ({\n html: string;\n text?: string;\n} | {\n html?: string;\n text: string;\n templateData?: never;\n})" }, { "id": "Coercer", diff --git a/packages/node/src/__tests__/emails.spec.ts b/packages/node/src/__tests__/emails.spec.ts index 36a1120b1e..6a6f9d6427 100644 --- a/packages/node/src/__tests__/emails.spec.ts +++ b/packages/node/src/__tests__/emails.spec.ts @@ -690,21 +690,35 @@ describe('emails', () => { await vi.runAllTimersAsync() await Promise.all([toJane, toBob]) - expect(bodies()).toMatchObject([ - { to: 'jane@example.com', message_data: { name: 'Jane' }, reply_to: ['jane-help@example.com'] }, - { to: 'bob@example.com', message_data: { name: 'Bob' }, reply_to: ['bob-help@example.com'] }, - ]) + // The two hashes may finish in either order, so the requests may too. + expect(bodies()).toHaveLength(2) + expect(bodies()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + to: 'jane@example.com', + message_data: { name: 'Jane' }, + reply_to: ['jane-help@example.com'], + }), + expect.objectContaining({ + to: 'bob@example.com', + message_data: { name: 'Bob' }, + reply_to: ['bob-help@example.com'], + }), + ]) + ) }) - it.each([['template data that is not an object', { templateData: ['Jane'] }, 'invalid_template_data']])( - 'refuses %s before sending', - async (_name, overrides, code) => { - const { error } = await sendWelcome(overrides) - - expect(error).toMatchObject({ code, name: 'validation_error' }) - expect(fetch).not.toHaveBeenCalled() - } - ) + it.each([ + ['template data that is not an object', { templateData: ['Jane'] }, 'invalid_template_data', 'templateData'], + ['a local template without html', { html: undefined, text: 'Hi {{ variables.name }}' }, 'invalid_form', 'html'], + ['a local template with empty html', { html: '' }, 'invalid_form', 'html'], + ['a local template with an empty subject', { subject: '' }, 'invalid_form', 'subject'], + ])('refuses %s before sending', async (_name, overrides, code, field) => { + const { error } = await sendWelcome(overrides) + + expect(error).toMatchObject({ code, name: 'validation_error', field }) + expect(fetch).not.toHaveBeenCalled() + }) }) describe('get', () => { diff --git a/packages/node/src/__tests__/types/emails-forms.ts b/packages/node/src/__tests__/types/emails-forms.ts index 6343835cd1..46301a7316 100644 --- a/packages/node/src/__tests__/types/emails-forms.ts +++ b/packages/node/src/__tests__/types/emails-forms.ts @@ -21,4 +21,6 @@ export const wrongCombinations: SendEmailOptions[] = [ { to, templateKey: 'reset', subject: 'Reset' }, // @ts-expect-error the code form needs a subject { to, templateKey: 'reset', html: '

Reset

' }, + // @ts-expect-error a local template needs html + { to, templateKey: 'welcome', subject: 'Hi {{ variables.name }}', text: 'Hi', templateData: { name: 'J' } }, ] diff --git a/packages/node/src/emails/emails.ts b/packages/node/src/emails/emails.ts index e7dba554a4..c6b8438ef7 100644 --- a/packages/node/src/emails/emails.ts +++ b/packages/node/src/emails/emails.ts @@ -507,6 +507,13 @@ function codeContent(send: UncheckedSend, distinctId: string | undefined): SendC }, } } + // PostHog stores a local template, and a stored template needs a subject and HTML. + if (!subject) { + throw invalid('invalid_form', 'A local template needs a subject that is not empty.', 'subject') + } + if (!html) { + throw invalid('invalid_form', 'A local template needs html. Pass the HTML body with templateData.', 'html') + } return { fields: { message_data: templateData, ...identifiersOf(distinctId), ...sender }, localTemplate: { key: templateKey, content }, diff --git a/packages/node/src/emails/types.ts b/packages/node/src/emails/types.ts index 5125969081..381dde67c0 100644 --- a/packages/node/src/emails/types.ts +++ b/packages/node/src/emails/types.ts @@ -49,7 +49,9 @@ interface CodeEmailFields { /** * Leave it out to send rendered content: the email goes out as is, and PostHog never stores it. * Pass it to send a local template: PostHog fills its `{{ variables.* }}`, and the SDK uploads - * the template whenever PostHog doesn't hold its content yet. + * the template whenever PostHog doesn't hold its content yet. A local template needs `html`. + * Content that PostHog already replaced with newer content isn't uploaded again: the email goes + * out with the newer content, and the send returns a `template_superseded` warning. */ templateData?: Record } @@ -57,9 +59,10 @@ interface CodeEmailFields { /** * Email content from your code, named by a key: content your code rendered, such as React Email * output, or a local template that PostHog fills in with `templateData`. Needs `html`, `text` or - * both. PostHog derives a missing `text` from the `html`. + * both, and a local template needs `html`. PostHog derives a missing `text` from the `html`. */ -export type CodeEmailForm = CodeEmailFields & ({ html: string; text?: string } | { html?: string; text: string }) +export type CodeEmailForm = CodeEmailFields & + ({ html: string; text?: string } | { html?: string; text: string; templateData?: never }) type Without = { [K in keyof T]?: never } From 3cc39e0367f8fb160a5bc80b58b76280b794b84b Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sun, 1 Nov 2026 16:42:32 +0000 Subject: [PATCH 5/6] chore(node): build the template upload where it is sent The upload body is built in upload() from the local template the send already holds, so the TemplateUpload shape is gone. The content hash builds its input as one string and encodes it once, which drops the byte concatenation helper. The hash test vectors still match. Co-Authored-By: Claude Opus 5.5 --- packages/node/src/emails/content-hash.ts | 18 +++---------- packages/node/src/emails/emails.ts | 33 ++++++++++-------------- 2 files changed, 17 insertions(+), 34 deletions(-) diff --git a/packages/node/src/emails/content-hash.ts b/packages/node/src/emails/content-hash.ts index f38782c7df..d2170427ba 100644 --- a/packages/node/src/emails/content-hash.ts +++ b/packages/node/src/emails/content-hash.ts @@ -22,28 +22,18 @@ export async function emailContentHash(content: EmailContent): Promise { function encode(content: EmailContent): Uint8Array { const encoder = new TextEncoder() - const chunks: Uint8Array[] = [encoder.encode(ENCODING_HEADER)] + let encoded = 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')) + const value = normalizeLineEndings(content[part] ?? '') + encoded += `${encoder.encode(value).length}:${value}\n` } - return concat(chunks) + return encoder.encode(encoded) } 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/emails.ts b/packages/node/src/emails/emails.ts index c6b8438ef7..e306fe0bac 100644 --- a/packages/node/src/emails/emails.ts +++ b/packages/node/src/emails/emails.ts @@ -110,16 +110,11 @@ interface PendingSend { interface PreparedSend { body: string - upload?: TemplateUpload + localTemplate?: LocalTemplate idempotencyKey: string maxRetries: number } -interface TemplateUpload { - path: string - body: string -} - interface SendContent { fields: Record localTemplate?: LocalTemplate @@ -205,19 +200,24 @@ export class EmailSender implements Emails { try { return await post() } catch (error) { - if (!prepared.upload || !isOutdatedTemplate(error)) { + if (!prepared.localTemplate || !isOutdatedTemplate(error)) { throw error } - await this.upload(prepared.upload, appHost, secretKey, prepared.maxRetries) + await this.upload(prepared.localTemplate, appHost, secretKey, prepared.maxRetries) return await post() } } - private async upload(upload: TemplateUpload, appHost: string, secretKey: string, maxRetries: number): Promise { + private async upload( + { key, content }: LocalTemplate, + appHost: string, + secretKey: string, + maxRetries: number + ): Promise { + const url = appHost + TEMPLATE_UPLOAD_PATH.replace('{templateKey}', key) const headers = this.headers(secretKey, { 'Content-Type': 'application/json' }) - await this.withRetries(maxRetries, async () => - parseUploadReply(await this.request(appHost + upload.path, 'PUT', headers, upload.body)) - ) + const body = safeJsonStringify({ name: key, ...content, if_code_managed: true, if_not_superseded: true }) + await this.withRetries(maxRetries, async () => parseUploadReply(await this.request(url, 'PUT', headers, body))) } private async getUntracked(id: string): Promise { @@ -415,14 +415,7 @@ async function completeSend({ fields, localTemplate, ...delivery }: PendingSend) return { ...delivery, body: safeJsonStringify(fields) } } const template = { key: localTemplate.key, hash: await emailContentHash(localTemplate.content) } - return { ...delivery, body: safeJsonStringify({ template, ...fields }), upload: templateUpload(localTemplate) } -} - -function templateUpload({ key, content }: LocalTemplate): TemplateUpload { - return { - path: TEMPLATE_UPLOAD_PATH.replace('{templateKey}', key), - body: safeJsonStringify({ name: key, ...content, if_code_managed: true, if_not_superseded: true }), - } + return { ...delivery, body: safeJsonStringify({ template, ...fields }), localTemplate } } function isRetryCount(value: unknown): value is number { From 1c241bb6c61fbdbe57f10f724a60569b0aafbd1a Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sun, 1 Nov 2026 17:09:04 +0000 Subject: [PATCH 6/6] chore(node): drop two comments the code already says Co-Authored-By: Claude Opus 5.5 --- packages/node/src/__tests__/emails.spec.ts | 1 - packages/node/src/emails/emails.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/packages/node/src/__tests__/emails.spec.ts b/packages/node/src/__tests__/emails.spec.ts index 6a6f9d6427..8dbbbf1858 100644 --- a/packages/node/src/__tests__/emails.spec.ts +++ b/packages/node/src/__tests__/emails.spec.ts @@ -690,7 +690,6 @@ describe('emails', () => { await vi.runAllTimersAsync() await Promise.all([toJane, toBob]) - // The two hashes may finish in either order, so the requests may too. expect(bodies()).toHaveLength(2) expect(bodies()).toEqual( expect.arrayContaining([ diff --git a/packages/node/src/emails/emails.ts b/packages/node/src/emails/emails.ts index e306fe0bac..1aea4be3bb 100644 --- a/packages/node/src/emails/emails.ts +++ b/packages/node/src/emails/emails.ts @@ -500,7 +500,6 @@ function codeContent(send: UncheckedSend, distinctId: string | undefined): SendC }, } } - // PostHog stores a local template, and a stored template needs a subject and HTML. if (!subject) { throw invalid('invalid_form', 'A local template needs a subject that is not empty.', 'subject') }