diff --git a/.changeset/node-emails-code-form.md b/.changeset/node-emails-code-form.md new file mode 100644 index 0000000000..f1a6bafcc8 --- /dev/null +++ b/.changeset/node-emails-code-form.md @@ -0,0 +1,5 @@ +--- +'posthog-node': minor +--- + +`posthog.emails.send()` also sends email content from your code, named by a `templateKey`: rendered content as is, or a local template that PostHog fills with `templateData`. The SDK uploads a local template on its first send and whenever its content changes. Template sends by id move to the same send route, with no change for callers. diff --git a/packages/node/references/posthog-node-references-latest.json b/packages/node/references/posthog-node-references-latest.json index a6cd6fa5bd..3e5a24ea4b 100644 --- a/packages/node/references/posthog-node-references-latest.json +++ b/packages/node/references/posthog-node-references-latest.json @@ -2035,6 +2035,43 @@ "path": "../core/src/error-tracking/types.ts", "example": "Record" }, + { + "id": "CodeEmailFields", + "name": "CodeEmailFields", + "properties": [ + { + "description": "A verified sender, such as `'Acme '`. Without it, the project's default sender.", + "type": "string", + "name": "from" + }, + { + "type": "string | string[]", + "name": "replyTo" + }, + { + "type": "string", + "name": "subject" + }, + { + "description": "Leave it out to send rendered content: the email goes out as is, and PostHog never stores it. Pass it to send a local template: PostHog fills its `{{ variables.* }}`, and the SDK uploads the template when PostHog does not hold its content yet.", + "type": "Record", + "name": "templateData" + }, + { + "description": "Names the email, such as `'password-reset'`, so its metrics, logs and status group under it in PostHog.", + "type": "string", + "name": "templateKey" + } + ], + "path": "src/emails.ts" + }, + { + "id": "CodeEmailForm", + "name": "CodeEmailForm", + "properties": [], + "path": "src/emails.ts", + "example": "CodeEmailFields & ({\n html: string;\n text?: string;\n} | {\n html?: string;\n text: string;\n})" + }, { "id": "Coercer", "name": "Coercer", @@ -2113,7 +2150,7 @@ "name": "EmailError", "properties": [ { - "description": "PostHog's error code, such as `invalid_recipient`, or one of the SDK's own: `missing_secret_key`, `missing_app_host`, `client_disabled`, `client_closed`, `network_error`, `api_error` for an answer without a code, `unexpected_error`, and `invalid_form`, `invalid_template_id` and `invalid_idempotency_key` for a send the SDK refused before any request.", + "description": "PostHog's error code, such as `invalid_recipient`, or one of the SDK's own: `missing_secret_key`, `missing_app_host`, `client_disabled`, `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" }, @@ -4303,17 +4340,29 @@ ], "path": "../core/src/utils/index.ts" }, + { + "id": "SendCodeEmailOptions", + "name": "SendCodeEmailOptions", + "properties": [], + "path": "src/emails.ts", + "example": "SendEmailBase & CodeEmailForm & Without>" + }, { "id": "SendEmailBase", "name": "SendEmailBase", "properties": [ + { + "description": "Whether PostHog captures the email's engagement events (sent, delivered, opened, clicked, bounced) as analytics events of the recipient. They count as analytics events. Without it, PostHog decides: on for the code form.", + "type": "boolean", + "name": "captureEngagement" + }, { "description": "The recipient's distinct ID in your app. The email's events, such as sent, opened and clicked, land on this person and count as analytics events. Always pass it: without it, they land on a person keyed by the address.", "type": "string", "name": "distinctId" }, { - "description": "Sends with a key PostHog already accepted queue nothing and return the first send's `id`, even after the email's workflow was paused or the default sender changed. A deleted template, or a project that no longer has a default sender, still refuses the send. Without it, the SDK makes a UUID v4 that covers its own retries. When a job runner retries the send, pass a key derived from what the email is for, such as `welcome:user_42`, and set `maxRetries: 0`.", + "description": "Sends with a key PostHog already accepted queue nothing and return the first send's `id`, even after the email's workflow was paused or the default sender changed. A deleted template, a local template whose content changed, or a project that no longer has a default sender while you name no `from`, still refuses the send. Without it, the SDK makes a UUID v4 that covers its own retries. When a job runner retries the send, pass a key derived from what the email is for, such as `welcome:user_42`, and set `maxRetries: 0`.", "type": "string", "name": "idempotencyKey" }, @@ -4333,39 +4382,9 @@ { "id": "SendEmailOptions", "name": "SendEmailOptions", - "properties": [ - { - "description": "The UUID of the template.", - "type": "string", - "name": "templateId" - }, - { - "description": "Values for the template's `{{ variables.* }}`. The template can't read person properties.", - "type": "Record", - "name": "templateData" - }, - { - "description": "One recipient email address.", - "type": "string", - "name": "to" - }, - { - "description": "The recipient's distinct ID in your app. The email's events, such as sent, opened and clicked, land on this\nperson and count as analytics events. Always pass it: without it, they land on a person keyed by the address.", - "type": "string", - "name": "distinctId" - }, - { - "description": "Sends with a key PostHog already accepted queue nothing and return the first send's `id`, even after the email's\nworkflow was paused or the default sender changed. A deleted template, or a project that no longer has a default\nsender, still refuses the send. Without it, the SDK makes a UUID v4 that covers its own retries. When a job runner\nretries the send, pass a key derived from what the email is for, such as `welcome:user_42`, and set\n`maxRetries: 0`.", - "type": "string", - "name": "idempotencyKey" - }, - { - "description": "How often to retry this send, a whole number from 0. Overrides the `maxRetries` client option.", - "type": "number", - "name": "maxRetries" - } - ], - "path": "src/emails.ts" + "properties": [], + "path": "src/emails.ts", + "example": "SendTemplateEmailOptions | SendCodeEmailOptions" }, { "id": "SendEmailResult", @@ -4416,14 +4435,65 @@ "name": "SendTemplateEmailOptions", "properties": [ { - "description": "Values for the template's `{{ variables.* }}`. The template can't read person properties.", - "type": "Record", - "name": "templateData" + "description": "One recipient email address.", + "type": "string", + "name": "to" + }, + { + "description": "The recipient's distinct ID in your app. The email's events, such as sent, opened and clicked, land on this\nperson and count as analytics events. Always pass it: without it, they land on a person keyed by the address.", + "type": "string", + "name": "distinctId" + }, + { + "description": "Sends with a key PostHog already accepted queue nothing and return the first send's `id`, even after the email's\nworkflow was paused or the default sender changed. A deleted template, a local template whose content changed, or\na project that no longer has a default sender while you name no `from`, still refuses the send. Without it, the SDK\nmakes a UUID v4 that covers its own retries. When a job runner retries the send, pass a key derived from what the\nemail is for, such as `welcome:user_42`, and set `maxRetries: 0`.", + "type": "string", + "name": "idempotencyKey" + }, + { + "description": "Whether PostHog captures the email's engagement events (sent, delivered, opened, clicked, bounced) as analytics\nevents of the recipient. They count as analytics events. Without it, PostHog decides: on for the code form.", + "type": "boolean", + "name": "captureEngagement" + }, + { + "description": "How often to retry this send, 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" @@ -5073,6 +5143,23 @@ "path": "../core/src/types.ts", "example": "(typeof SurveyWidgetType)[keyof typeof SurveyWidgetType]" }, + { + "id": "TemplateEmailForm", + "name": "TemplateEmailForm", + "properties": [ + { + "description": "Values for the template's `{{ variables.* }}`. The template can't read person properties.", + "type": "Record", + "name": "templateData" + }, + { + "description": "The UUID of the template.", + "type": "string", + "name": "templateId" + } + ], + "path": "src/emails.ts" + }, { "id": "UnresolvedFlagReason", "name": "UnresolvedFlagReason", @@ -5094,6 +5181,13 @@ } ], "path": "src/types.ts" + }, + { + "id": "Without", + "name": "Without", + "properties": [], + "path": "src/emails.ts", + "example": "{\n [K in keyof T]?: never;\n}" } ], "categories": [ diff --git a/packages/node/src/__tests__/email-content-hash.spec.ts b/packages/node/src/__tests__/email-content-hash.spec.ts index 538962be82..221a71e6cf 100644 --- a/packages/node/src/__tests__/email-content-hash.spec.ts +++ b/packages/node/src/__tests__/email-content-hash.spec.ts @@ -10,7 +10,15 @@ describe('emailContentHash', () => { content: { subject: 'Grüße', preheader: 'Vorschau', html: '

ä

', text: 'ä' }, hash: 'v1:75cf6fc3189ca26f60da672d29a1d4c89c7db3dd458df3a550a34a4d2a92c149', }, - ])('matches the spec test vector $hash', async ({ content, hash }) => { + { + content: { + subject: 'Welcome, {{ variables.first_name }}', + html: '

Hi {{ variables.first_name }}

', + text: '\r\n\r', + }, + hash: 'v1:8112876ee1b2dd7327f571b04b546ed057bfff5a904d9f0ae507b6a449ab6453', + }, + ])('matches the content_hash v1 test vector of the server: $hash', async ({ content, hash }) => { expect(await emailContentHash(content)).toBe(hash) }) diff --git a/packages/node/src/__tests__/emails.spec.ts b/packages/node/src/__tests__/emails.spec.ts index d6f1035674..db49808986 100644 --- a/packages/node/src/__tests__/emails.spec.ts +++ b/packages/node/src/__tests__/emails.spec.ts @@ -8,7 +8,9 @@ const JANE = 'jane@example.com' const SECRET_KEY = 'phs_secret' const APP_HOST = 'https://posthog.example.com' const TEMPLATE_ID = '0190f6d2-7c1e-4b3a-9f5e-1c2d3e4f5a6b' -const TEMPLATE_SEND = `POST /api/projects/@current/messaging_templates/${TEMPLATE_ID}/send/` +const SEND = 'POST /api/projects/@current/messaging/send/email/' +const UPLOAD_WELCOME = 'PUT /api/projects/@current/messaging_templates/by_key/welcome/' +const 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', () => { @@ -59,7 +61,7 @@ describe('emails', () => { { messageId: 'message-1', to: JANE, - templateId: TEMPLATE_ID, + template: TEMPLATE_ID, variables: { firstName: 'Jane' }, distinctId: 'user_42', }, @@ -75,15 +77,25 @@ describe('emails', () => { }), }), ]) - expect(requestLog()).toEqual([TEMPLATE_SEND]) + expect(requestLog()).toEqual([SEND]) }) it.each([ - ['no template data or distinct id', { to: JANE, templateId: TEMPLATE_ID }, { to: JANE }], [ - 'template data and a distinct id', - { to: JANE, templateId: TEMPLATE_ID, templateData: { n: 1 }, distinctId: 'u' }, - { to: JANE, message_data: { n: 1 }, identifiers: { distinct_id: 'u' } }, + 'no template data or distinct id', + { to: JANE, templateId: TEMPLATE_ID }, + { template: { id: TEMPLATE_ID }, to: JANE }, + ], + [ + 'template data, a distinct id and an engagement choice', + { to: JANE, templateId: TEMPLATE_ID, templateData: { n: 1 }, distinctId: 'u', captureEngagement: false }, + { + template: { id: TEMPLATE_ID }, + to: JANE, + message_data: { n: 1 }, + identifiers: { distinct_id: 'u' }, + capture_engagement: false, + }, ], ])('maps %s to the request body', async (_, options, body) => { await createClient().emails.send(options) @@ -144,6 +156,139 @@ describe('emails', () => { }) }) + describe('the code form', () => { + const welcome = { templateKey: 'welcome', subject: 'Welcome, {{ variables.name }}', html: '

Hi

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

Reset

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

Reset

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

Reset

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

Reset

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

Hi again

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

Hi again

') + }) + + it('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', () => { it.each([ [400, 'invalid_recipient', 'validation_error'], @@ -241,13 +386,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 +421,29 @@ describe('emails', () => { expect(api.received).toEqual([]) }) - it('returns invalid_form for an option it does not take, such as Resend scheduledAt, without a request', async () => { - const result = await createClient().emails.send({ - to: JANE, - templateId: TEMPLATE_ID, - scheduledAt: 'in 1 hour', - } as SendEmailOptions) + it.each([ + ['from on the template form', { templateId: TEMPLATE_ID, from: 'Acme ' }, 'from'], + ['replyTo on the template form', { templateId: TEMPLATE_ID, replyTo: 'help@example.com' }, 'replyTo'], + ['subject on the template form', { templateId: TEMPLATE_ID, subject: 'Hi' }, 'subject'], + ['html on the template form', { templateId: TEMPLATE_ID, html: '

Hi

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

Hi

' }, 'subject'], + ['a code send without html or text', { templateKey: 'welcome', subject: 'Hi' }, 'html'], + ['a code send with html that is not a string', { templateKey: 'welcome', subject: 'Hi', html: 1 }, 'html'], + [ + 'Resend scheduledAt, which it does not take', + { templateId: TEMPLATE_ID, scheduledAt: 'in 1 hour' }, + 'scheduledAt', + ], + [ + 'Resend scheduledAt on a code send', + { templateKey: 'welcome', subject: 'Hi', html: '

Hi

', scheduledAt: 'in 1 hour' }, + 'scheduledAt', + ], + ])('returns invalid_form for %s without a request', async (_, form, field) => { + const result = await createClient().emails.send({ to: JANE, ...form } as unknown as SendEmailOptions) - expect(result.error).toEqual( - expect.objectContaining({ name: 'validation_error', code: 'invalid_form', field: 'scheduledAt' }) - ) + expect(result.error).toEqual(expect.objectContaining({ name: 'validation_error', code: 'invalid_form', field })) expect(api.received).toEqual([]) }) diff --git a/packages/node/src/__tests__/types/emails-forms.ts b/packages/node/src/__tests__/types/emails-forms.ts new file mode 100644 index 0000000000..64ab79fbbe --- /dev/null +++ b/packages/node/src/__tests__/types/emails-forms.ts @@ -0,0 +1,21 @@ +import type { SendEmailOptions } from '../../exports' + +const to = 'jane@example.com' + +export const forms: SendEmailOptions[] = [ + { to, templateId: '0190f6d2-7c1e-4b3a-9f5e-1c2d3e4f5a6b', templateData: { name: 'Jane' } }, + { to, templateKey: 'reset', subject: 'Reset', html: '

Reset

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

Hi

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

R

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

Reset

' }, +] diff --git a/packages/node/src/__tests__/utils/fake-emails-api.ts b/packages/node/src/__tests__/utils/fake-emails-api.ts index 9f718a251f..7308c98a65 100644 --- a/packages/node/src/__tests__/utils/fake-emails-api.ts +++ b/packages/node/src/__tests__/utils/fake-emails-api.ts @@ -1,6 +1,8 @@ import type { PostHogFetchOptions, PostHogFetchResponse } from '@posthog/core' +import { emailContentHash } from '@/email-content-hash' -const TEMPLATE_SEND_PATH = /^\/api\/projects\/@current\/messaging_templates\/([^/]+)\/send\/$/ +const SEND_PATH = '/api/projects/@current/messaging/send/email/' +const UPLOAD_PATH = /^\/api\/projects\/@current\/messaging_templates\/by_key\/([^/]+)\/$/ const DOCS_URL = 'https://posthog.com/docs/workflows/transactional-email-api' export interface ReceivedRequest { @@ -14,11 +16,23 @@ export interface ReceivedRequest { export interface DeliveredEmail { messageId: string to: string - templateId: string - variables: Record + /** The template id, the local template's key, or the rendered content's `template_key`. */ + template: string + subject?: string + html?: string + text?: string + variables?: Record distinctId?: string } +interface LocalTemplate { + subject: string + html: string + text?: string + contentHash: string + editedInPostHog: boolean +} + export type Fault = | { status: number; body?: unknown; headers?: Record } | { networkError: Error } @@ -31,13 +45,16 @@ interface ScheduledFault { } /** - * An in-memory PostHog app host that speaks the template send route of the transactional email API, with its - * idempotency rule: a key PostHog already accepted for the same request answers the first `202` again. + * An in-memory PostHog app host that speaks the send and upload routes of the transactional email API: a template by + * id, rendered content by `template_key`, and a local template by key and content hash, which answers + * `template_outdated` until an upload publishes that content. An idempotency key PostHog already accepted for the + * same request answers the first `202` again. */ export class FakeEmailsApi { readonly received: ReceivedRequest[] = [] readonly delivered: DeliveredEmail[] = [] private readonly templateIds = new Set() + private readonly localTemplates = new Map() private readonly accepted = new Map() private readonly faults: ScheduledFault[] = [] private nextId = 1 @@ -52,7 +69,7 @@ export class FakeEmailsApi { throw fault.networkError } if (fault && 'bodyBreaksOff' in fault) { - return breakOff(this.handle(request), fault.bodyBreaksOff) + return breakOff(await this.handle(request), fault.bodyBreaksOff) } if (fault) { return respond(fault.status, fault.body, fault.headers) @@ -64,6 +81,14 @@ export class FakeEmailsApi { this.templateIds.add(id) } + editInPostHog(key: string, content: { subject: string; html: string }): void { + this.localTemplates.set(key, { ...content, contentHash: 'v1:edited', editedInPostHog: true }) + } + + localTemplate(key: string): LocalTemplate | undefined { + return this.localTemplates.get(key) + } + failWhen(matches: (request: ReceivedRequest) => boolean, fault: Fault, times = Infinity): void { this.faults.push({ matches, fault, remaining: times }) } @@ -80,44 +105,77 @@ export class FakeEmailsApi { return scheduled?.fault } - private handle(request: ReceivedRequest): PostHogFetchResponse { + private async handle(request: ReceivedRequest): Promise { if (request.origin !== this.options.appHost) { return respond(404, 'Not the app host') } if (request.headers.authorization !== `Bearer ${this.options.secretKey}`) { return refusal(401, 'authentication_failed', null) } - const templateId = TEMPLATE_SEND_PATH.exec(request.path)?.[1] - if (request.method === 'POST' && templateId) { - return this.handleTemplateSend(decodeURIComponent(templateId), request) + const uploadKey = UPLOAD_PATH.exec(request.path)?.[1] + if (request.method === 'PUT' && uploadKey) { + return this.handleUpload(decodeURIComponent(uploadKey), request.body) + } + if (request.method === 'POST' && request.path === SEND_PATH) { + return this.handleSend(request) } return refusal(404, 'not_found', null) } - private handleTemplateSend(templateId: string, { headers, body }: ReceivedRequest): PostHogFetchResponse { - if (!this.templateIds.has(templateId)) { - return refusal(404, 'not_found', null) + private async handleUpload(key: string, body: Record): Promise { + const existing = this.localTemplates.get(key) + if (existing?.editedInPostHog && body.if_code_managed === true) { + return refusal(409, 'template_edited_in_posthog', null) + } + const { subject, html, text } = body + const contentHash = await emailContentHash({ subject, html, text }) + this.localTemplates.set(key, { subject, html, text, contentHash, editedInPostHog: false }) + return respond(existing ? 200 : 201, { key, result: existing ? 'updated' : 'created', content_hash: contentHash }) + } + + private handleSend({ headers, body }: ReceivedRequest): PostHogFetchResponse { + const refused = this.refusalOf(body) + if (refused) { + return refused } const key = headers['idempotency-key'] - const fingerprint = JSON.stringify([templateId, body]) + const fingerprint = JSON.stringify(body) const earlier = key === undefined ? undefined : this.accepted.get(key) if (earlier) { return earlier.fingerprint === fingerprint - ? respond(202, { message_id: earlier.messageId, replayed: true }) + ? respond(202, { message_id: earlier.messageId, replayed: true, warnings: [] }) : refusal(422, 'idempotency_key_reused', null) } const messageId = `message-${this.nextId++}` if (key !== undefined) { this.accepted.set(key, { fingerprint, messageId }) } - this.delivered.push({ - messageId, - to: body.to, - templateId, - variables: body.message_data ?? {}, - distinctId: body.identifiers?.distinct_id, - }) - return respond(202, { message_id: messageId, replayed: false }) + this.delivered.push({ messageId, to: body.to, distinctId: body.identifiers?.distinct_id, ...this.contentOf(body) }) + return respond(202, { message_id: messageId, replayed: false, warnings: [] }) + } + + private refusalOf(body: Record): PostHogFetchResponse | undefined { + const { id, key, hash } = body.template ?? {} + if (id !== undefined && !this.templateIds.has(id)) { + return refusal(404, 'template_not_found', 'template__id') + } + const local = key === undefined ? undefined : this.localTemplates.get(key) + if (key !== undefined && (!local || (hash !== undefined && hash !== local.contentHash))) { + return refusal(409, 'template_outdated', local ? 'template__hash' : 'template__key') + } + return undefined + } + + private contentOf(body: Record): Omit { + const { id, key } = body.template ?? {} + if (id !== undefined) { + return { template: id, variables: body.message_data ?? {} } + } + if (key !== undefined) { + const { subject, html, text } = this.localTemplates.get(key)! + return { template: key, subject, html, text, variables: body.message_data ?? {} } + } + return { template: body.template_key, subject: body.subject, html: body.html, text: body.text } } } diff --git a/packages/node/src/client.ts b/packages/node/src/client.ts index e13a15a1e1..70c50c68f6 100644 --- a/packages/node/src/client.ts +++ b/packages/node/src/client.ts @@ -746,7 +746,7 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen }), warn: (message) => this._logger.warn(message), track: (work) => this.addPendingPromise(work), - request: (url, headers, body) => this._requestEmailsApi(url, headers, body), + request: (method, url, headers, body) => this._requestEmailsApi(method, url, headers, body), }) return this._emails } @@ -3066,12 +3066,13 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen /** One attempt: `posthog.emails` retries on its own, with the same idempotency key. */ private async _requestEmailsApi( + method: 'POST' | 'PUT', url: string, headers: Record, body: string ): Promise { try { - return await this.requestJson(url, { method: 'POST', headers, body }, { retryCount: 0 }) + return await this.requestJson(url, { method, headers, body }, { retryCount: 0 }) } catch (error) { this._events.emit('error', error) throw new Error(isTimeout(error) ? `Request timed out after ${this.requestTimeout}ms` : requestFailure(error)) diff --git a/packages/node/src/emails.ts b/packages/node/src/emails.ts index b48620cd19..3980e969f5 100644 --- a/packages/node/src/emails.ts +++ b/packages/node/src/emails.ts @@ -1,10 +1,27 @@ import { isValidUUID, type JsonType, type PostHogApiResponse } from '@posthog/core' +import { emailContentHash } from './email-content-hash' -const TEMPLATE_SEND_PATH = (templateId: string): string => - `/api/projects/@current/messaging_templates/${templateId}/send/` +const SEND_PATH = '/api/projects/@current/messaging/send/email/' +const UPLOAD_PATH = (key: string): string => `/api/projects/@current/messaging_templates/by_key/${key}/` +const TEMPLATE_KEY_PATTERN = /^[a-zA-Z0-9_-]{1,400}$/ +const 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', + 'templateKey', + 'templateData', + 'from', + 'replyTo', + 'subject', + 'html', + 'text', +]) const DEFAULT_MAX_RETRIES = 2 const FIRST_RETRY_DELAY_MS = 1_000 const MAX_RETRY_DELAY_MS = 30_000 @@ -20,26 +37,60 @@ export interface SendEmailBase { distinctId?: string /** * Sends with a key PostHog already accepted queue nothing and return the first send's `id`, even after the email's - * workflow was paused or the default sender changed. A deleted template, or a project that no longer has a default - * sender, still refuses the send. Without it, the SDK makes a UUID v4 that covers its own retries. When a job runner - * retries the send, pass a key derived from what the email is for, such as `welcome:user_42`, and set - * `maxRetries: 0`. + * workflow was paused or the default sender changed. A deleted template, a local template whose content changed, or + * a project that no longer has a default sender while you name no `from`, still refuses the send. Without it, the SDK + * makes a UUID v4 that covers its own retries. When a job runner retries the send, pass a key derived from what the + * email is for, such as `welcome:user_42`, and set `maxRetries: 0`. */ idempotencyKey?: string + /** + * Whether PostHog captures the email's engagement events (sent, delivered, opened, clicked, bounced) as analytics + * events of the recipient. They count as analytics events. Without it, PostHog decides: on for the code form. + */ + captureEngagement?: boolean /** How often to retry this send, a whole number from 0. Overrides the `maxRetries` client option. */ maxRetries?: number } /** A template made in PostHog, sent by its id. */ -export interface SendTemplateEmailOptions extends SendEmailBase { +export interface TemplateEmailForm { /** The UUID of the template. */ templateId: string /** Values for the template's `{{ variables.* }}`. The template can't read person properties. */ templateData?: Record } -/** One email to send with `posthog.emails.send()`. */ -export type SendEmailOptions = SendTemplateEmailOptions +interface CodeEmailFields { + /** Names the email, such as `'password-reset'`, so its metrics, logs and status group under it in PostHog. */ + templateKey: string + subject: string + /** A verified sender, such as `'Acme '`. Without it, the project's default sender. */ + from?: string + replyTo?: string | string[] + /** + * Leave it out to send rendered content: the email goes out as is, and PostHog never stores it. Pass it to send a + * local template: PostHog fills its `{{ variables.* }}`, and the SDK uploads the template when PostHog does not hold + * its content yet. + */ + templateData?: Record +} + +/** + * Email content from your code, named by a key. Needs `html`, `text` or both. PostHog derives a missing `text` 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 & { html: string; text: string }> + +export type SendCodeEmailOptions = SendEmailBase & CodeEmailForm & Without> + +/** One email to send with `posthog.emails.send()`, in exactly one form. */ +export type SendEmailOptions = SendTemplateEmailOptions | SendCodeEmailOptions /** Something to know about a queued email. */ export interface EmailWarning { @@ -58,9 +109,9 @@ export interface EmailError { name: EmailErrorName /** * PostHog's error code, such as `invalid_recipient`, or one of the SDK's own: `missing_secret_key`, - * `missing_app_host`, `client_disabled`, `client_closed`, `network_error`, `api_error` for an answer without a code, - * `unexpected_error`, and `invalid_form`, `invalid_template_id` and `invalid_idempotency_key` for a send the SDK - * refused before any request. + * `missing_app_host`, `client_disabled`, `client_closed`, `network_error`, `api_error` for an answer without a + * code, `unexpected_error`, and `invalid_form`, `invalid_template_id`, `invalid_template_key` and + * `invalid_idempotency_key` for a send the SDK refused before any request. */ code: string message: string @@ -106,7 +157,7 @@ export interface Emails { * Needs the `secretKey` client option, a project secret API key (`phs_`) with the `hog_flow:write` scope. When the * key can't read feature flags, also set `enableLocalEvaluation: false`, or the client polls flag definitions with it. * - * @example + * @example A template made in PostHog, by its id * ```ts * const { data, error } = await posthog.emails.send({ * to: 'jane@example.com', @@ -116,6 +167,29 @@ export interface Emails { * idempotencyKey: `welcome:${user.id}`, * }) * ``` + * + * @example Rendered content, such as React Email rendered to HTML in your app + * ```ts + * await posthog.emails.send({ + * to: 'jane@example.com', + * distinctId: user.id, + * templateKey: 'password-reset', + * subject: 'Reset your password', + * html: await render(), + * }) + * ``` + * + * @example A local template: your code holds it, PostHog fills in the variables + * ```ts + * await posthog.emails.send({ + * to: 'jane@example.com', + * distinctId: user.id, + * templateKey: 'welcome', + * subject: 'Welcome, {{ variables.name }}', + * html: welcomeHtml, + * templateData: { name: 'Jane' }, + * }) + * ``` */ send(options: SendEmailOptions): Promise } @@ -132,20 +206,30 @@ export interface EmailsApi { warn(message: string): void track(work: Promise): Promise /** Rejects only when PostHog never answered. */ - request(url: string, headers: Record, body: string): Promise + request( + method: 'POST' | 'PUT', + url: string, + headers: Record, + body: string + ): Promise } +type Outcome = { data: T; error: null } | Failure type Failure = { data: null; error: EmailError } -interface PreparedSend { - path: string - body: JsonType +interface Delivery { idempotencyKey: string maxRetries: number } +interface ApiRequest { + method: 'POST' | 'PUT' + path: string + body: JsonType +} + interface Attempt { - result: SendEmailResult + outcome: Outcome retryAfterMs?: number } @@ -155,17 +239,26 @@ export class PostHogEmails implements Emails { send(options: SendEmailOptions): Promise { if (!isRecord(options)) { - return Promise.resolve(failure('invalid_form', 'send needs an options object with to and templateId.')) + return Promise.resolve( + failure('invalid_form', 'send needs an options object with to, and templateId or templateKey.') + ) } const refusal = this.refusalBeforeRequest(options) if (refusal) { return Promise.resolve(refusal) } - return this.api.track(this.deliver(this.prepare(options)).catch(unexpectedFailure)) + const delivery = { + idempotencyKey: options.idempotencyKey ?? crypto.randomUUID(), + maxRetries: retryCount(options.maxRetries) ?? retryCount(this.api.maxRetries()) ?? DEFAULT_MAX_RETRIES, + } + return this.api.track( + this.sendForm(options, delivery) + .then((result) => this.logWarnings(result)) + .catch(unexpectedFailure) + ) } private refusalBeforeRequest(options: SendEmailOptions): Failure | undefined { - const { templateId, idempotencyKey } = options if (this.api.isDisabled()) { return failure('client_disabled', 'The PostHog client is disabled, so nothing was sent.') } @@ -185,68 +278,69 @@ export class PostHogEmails implements Emails { 'goes to a proxy.' ) } - if (!isValidUUID(templateId)) { - return failure('invalid_template_id', 'templateId must be the UUID of a template.', { field: 'templateId' }) - } - const unknownOption = unknownOptionRefusal(options) - if (unknownOption) { - return unknownOption + return formRefusal(options) ?? unknownOptionRefusal(options) ?? idempotencyKeyRefusal(options.idempotencyKey) + } + + private sendForm(options: SendEmailOptions, delivery: Delivery): Promise { + if (isTemplateForm(options)) { + return this.post(templateBody(options), delivery) } - if (idempotencyKey !== undefined && !isIdempotencyKey(idempotencyKey)) { - return failure( - 'invalid_idempotency_key', - 'idempotencyKey must be 1 to 255 visible ASCII characters, with no spaces.', - { field: 'idempotencyKey' } - ) + if (options.templateData === undefined) { + return this.post(renderedContentBody(options), delivery) } - return undefined + return this.sendLocalTemplate(options, delivery) } - private prepare({ - to, - distinctId, - idempotencyKey, - maxRetries, - templateId, - templateData, - }: SendEmailOptions): PreparedSend { - return { - path: TEMPLATE_SEND_PATH(templateId), - body: { - to, - ...(templateData !== undefined && { message_data: templateData as JsonType }), - ...(distinctId !== undefined && { identifiers: { distinct_id: distinctId } }), - }, - idempotencyKey: idempotencyKey ?? crypto.randomUUID(), - maxRetries: retryCount(maxRetries) ?? retryCount(this.api.maxRetries()) ?? DEFAULT_MAX_RETRIES, + /** Sends the content's hash, and uploads the content only when PostHog does not hold it. */ + private async sendLocalTemplate(options: SendCodeEmailOptions, delivery: Delivery): Promise { + const { 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 post(body: JsonType, delivery: Delivery): Promise { + const outcome = await this.exchange({ method: 'POST', path: SEND_PATH, body }, delivery) + return outcome.error ? outcome : queuedEmail(outcome.data) } - private async deliver(send: PreparedSend): Promise { - const body = JSON.stringify(send.body) + private async exchange(request: ApiRequest, delivery: Delivery): Promise> { + const body = JSON.stringify(request.body) for (let retry = 0; ; retry++) { - const { result, retryAfterMs } = await this.attempt(send, body) - if (retry >= send.maxRetries || !isRetryable(result)) { - return this.logWarnings(result) + const { outcome, retryAfterMs } = await this.attempt(request, body, delivery) + if (retry >= delivery.maxRetries || !isRetryable(outcome)) { + return outcome } await sleep(retryDelayMs(retry, retryAfterMs)) } } - private async attempt(send: PreparedSend, body: string): Promise { + private async attempt(request: ApiRequest, body: string, delivery: Delivery): Promise { const headers = { ...this.api.headers(), 'Content-Type': 'application/json', Authorization: `Bearer ${this.api.secretKey()}`, - 'Idempotency-Key': send.idempotencyKey, + 'Idempotency-Key': delivery.idempotencyKey, } let response: PostHogApiResponse try { - response = await this.api.request(`${this.api.appHost()}${send.path}`, headers, body) + response = await this.api.request(request.method, `${this.api.appHost()}${request.path}`, headers, body) } catch (error) { - return { result: failure('network_error', error instanceof Error ? error.message : 'Request failed') } + return { outcome: failure('network_error', error instanceof Error ? error.message : 'Request failed') } } - return { result: sendResult(response), retryAfterMs: response.retryAfterMs } + const outcome = isSuccess(response.status) + ? { data: response, error: null } + : { data: null, error: apiError(response) } + return { outcome, retryAfterMs: response.retryAfterMs } } private logWarnings(result: SendEmailResult): SendEmailResult { @@ -257,18 +351,136 @@ export class PostHogEmails implements Emails { } } -function sendResult(response: PostHogApiResponse): SendEmailResult { - const { status, body } = response - if (!isSuccess(status)) { - return { data: null, error: apiError(response) } +function isTemplateForm(options: SendEmailOptions): options is SendTemplateEmailOptions { + return options.templateId !== undefined +} + +function formRefusal(options: SendEmailOptions): Failure | undefined { + if ((options.templateId === undefined) === (options.templateKey === undefined)) { + return failure('invalid_form', 'Send either templateId, or templateKey with subject and html or text.') } + if (isTemplateForm(options)) { + return isValidUUID(options.templateId) + ? 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 }: SendCodeEmailOptions): Failure | undefined { + if (typeof subject !== 'string') { + return failure('invalid_form', 'A send with templateKey needs a subject.', { field: 'subject' }) + } + if (html !== undefined && typeof html !== 'string') { + return failure('invalid_form', 'html must be a string.', { field: 'html' }) + } + if (text !== undefined && typeof text !== 'string') { + return failure('invalid_form', 'text must be a string.', { field: 'text' }) + } + if (html === undefined && text === undefined) { + return failure('invalid_form', 'A send with templateKey needs html, text or both.', { field: 'html' }) + } + return undefined +} + +function idempotencyKeyRefusal(idempotencyKey: string | undefined): Failure | undefined { + if (idempotencyKey === undefined || isIdempotencyKey(idempotencyKey)) { + return undefined + } + return failure( + 'invalid_idempotency_key', + 'idempotencyKey must be 1 to 255 visible ASCII characters, with no spaces.', + { + field: 'idempotencyKey', + } + ) +} + +function templateBody(options: SendTemplateEmailOptions): JsonType { + return { + template: { id: options.templateId }, + ...recipientFields(options), + ...templateDataField(options.templateData), + } +} + +function renderedContentBody(options: SendCodeEmailOptions): JsonType { + return { + template_key: options.templateKey, + subject: options.subject, + ...definedFields({ html: options.html, text: options.text }), + ...recipientFields(options), + ...senderFields(options), + } +} + +function 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, + ...(distinctId !== undefined && { identifiers: { distinct_id: distinctId } }), + ...(captureEngagement !== undefined && { capture_engagement: captureEngagement }), + } +} + +function senderFields({ from, replyTo }: SendCodeEmailOptions): Record { + return { + ...(from !== undefined && { from }), + ...(replyTo !== undefined && { reply_to: typeof replyTo === 'string' ? [replyTo] : replyTo }), + } +} + +function templateDataField(templateData: Record | undefined): Record { + return templateData === undefined ? {} : { message_data: templateData as JsonType } +} + +function 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': @@ -316,6 +528,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,