diff --git a/.changeset/core-request-json.md b/.changeset/core-request-json.md new file mode 100644 index 0000000000..10451aa865 --- /dev/null +++ b/.changeset/core-request-json.md @@ -0,0 +1,5 @@ +--- +'@posthog/core': patch +--- + +Let SDKs built on core send JSON requests to the PostHog API through the shared retrying transport. diff --git a/.changeset/node-emails-code-fields.md b/.changeset/node-emails-code-fields.md new file mode 100644 index 0000000000..dae18bd99b --- /dev/null +++ b/.changeset/node-emails-code-fields.md @@ -0,0 +1,5 @@ +--- +'posthog-node': minor +--- + +The code form of `posthog.emails.send()` takes `cc`, `bcc`, `headers`, `tags` and `attachments`. An attachment's `content` is a Buffer, a Uint8Array or a Base64 string. A Resend attachment with a `path` returns an `invalid_attachment` error, because PostHog can't fetch files. A PostHog server that doesn't take one of these fields yet refuses the send with an `invalid_request` error on that field, instead of sending without it. 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/.changeset/node-emails-get.md b/.changeset/node-emails-get.md new file mode 100644 index 0000000000..191f58348b --- /dev/null +++ b/.changeset/node-emails-get.md @@ -0,0 +1,5 @@ +--- +'posthog-node': minor +--- + +Add `posthog.emails.get(id)` to read the latest status of an email that `posthog.emails.send()` queued. diff --git a/.changeset/node-emails-send.md b/.changeset/node-emails-send.md new file mode 100644 index 0000000000..340186739c --- /dev/null +++ b/.changeset/node-emails-send.md @@ -0,0 +1,5 @@ +--- +'posthog-node': minor +--- + +Add `posthog.emails.send()` to send transactional email from a template made in PostHog, by its id. Every send carries an idempotency key and retries network errors, throttling and an unavailable pipeline with the same key. Results are `{ data, error }`, with Resend's error names. New client options: `appHost`, `maxRetries` and `migratedFrom`. diff --git a/packages/core/src/posthog-core-stateless.ts b/packages/core/src/posthog-core-stateless.ts index 4a2d09968e..f96da667f5 100644 --- a/packages/core/src/posthog-core-stateless.ts +++ b/packages/core/src/posthog-core-stateless.ts @@ -17,6 +17,7 @@ import { PostHogFeatureFlagDetails, FeatureFlagDetail, SurveyResponse, + PostHogApiResponse, PostHogFetchResponse, PostHogFetchBodyBytes, PostHogFetchOptions, @@ -40,6 +41,7 @@ import { createLogger, getEventUuid, safeJsonStringify, + trySafe, } from './utils' import { parseRetryAfterMs } from './utils/retry-after' import { uuidv7 } from './vendor/uuidv7' @@ -156,6 +158,11 @@ type SuccessfulWriteResponseHandling = { type: 'successful-write' } +async function readApiResponse(response: PostHogFetchResponse): Promise { + const text = await response.text() + return { status: response.status, body: trySafe(() => JSON.parse(text)) } +} + export const maybeAdd = (key: string, value: JsonType | undefined): Record => value !== undefined ? { [key]: value } : {} @@ -1874,6 +1881,25 @@ export abstract class PostHogCoreStateless { return this._sendOtlpBatch({ path: 'traces', auth: 'bearer', payload }) } + protected async requestJson( + url: string, + options: PostHogFetchOptions, + retryOptions?: Partial + ): Promise { + try { + return await this.fetchWithRetry(url, options, { type: 'required', consume: readApiResponse }, retryOptions) + } catch (error) { + if (error instanceof PostHogFetchHttpError) { + return { + status: error.status, + body: await error.json.catch(() => undefined), + retryAfterMs: error.retryAfterMs, + } + } + throw error + } + } + private fetchWithRetry( url: string, options: PostHogFetchOptions, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 9c1475e472..8500e7ce33 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -336,6 +336,13 @@ export type PostHogFetchResponse = { body?: ReadableStream | null } +export type PostHogApiResponse = { + status: number + body: unknown + /** The answer's `Retry-After` in milliseconds, when it sent a usable one. */ + retryAfterMs?: number +} + export type PostHogQueueItem = { message?: PostHogEventProperties callback?: (err: unknown) => void diff --git a/packages/node/references/posthog-node-references-latest.json b/packages/node/references/posthog-node-references-latest.json index dbe0b9d64e..d8c8aa78b1 100644 --- a/packages/node/references/posthog-node-references-latest.json +++ b/packages/node/references/posthog-node-references-latest.json @@ -2035,6 +2035,67 @@ "path": "../core/src/error-tracking/types.ts", "example": "Record" }, + { + "id": "CodeEmailFields", + "name": "CodeEmailFields", + "properties": [ + { + "type": "EmailAttachment[]", + "name": "attachments" + }, + { + "description": "Bare addresses that get a copy the other recipients never see.", + "type": "string | string[]", + "name": "bcc" + }, + { + "description": "Bare addresses, such as `'finance@example.com'`, that get a copy.", + "type": "string | string[]", + "name": "cc" + }, + { + "description": "A verified sender, such as `'Acme '`. Without it, the project's default sender.", + "type": "string", + "name": "from" + }, + { + "description": "Custom email headers, such as `{ 'X-Entity-Ref-ID': 'order-123' }`. PostHog sets the standard ones itself.", + "type": "Record", + "name": "headers" + }, + { + "type": "string | string[]", + "name": "replyTo" + }, + { + "type": "string", + "name": "subject" + }, + { + "description": "Added to the email's engagement events as `$email_tags`.", + "type": "EmailTag[]", + "name": "tags" + }, + { + "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", @@ -2108,6 +2169,169 @@ "properties": [], "path": "../core/src/cookie.ts" }, + { + "id": "EmailAttachment", + "name": "EmailAttachment", + "properties": [ + { + "description": "The file's bytes, or its content already Base64 encoded.", + "type": "Buffer | Uint8Array | string", + "name": "content" + }, + { + "description": "Shows the file inline where the HTML has `` with this id.", + "type": "string", + "name": "contentId" + }, + { + "description": "A MIME type, such as `'application/pdf'`.", + "type": "string", + "name": "contentType" + }, + { + "description": "The name the recipient sees, such as `'invoice.pdf'`.", + "type": "string", + "name": "filename" + }, + { + "description": "PostHog can't fetch a file from a path or URL: read the file and pass its `content`.", + "type": "never", + "name": "path" + } + ], + "path": "src/emails.ts" + }, + { + "id": "EmailError", + "name": "EmailError", + "properties": [ + { + "description": "PostHog's error code, such as `invalid_recipient`, or one of the SDK's own: `missing_secret_key`, `missing_app_host`, `client_disabled`, `client_closed`, `network_error`, `api_error` for an answer without a code, `unexpected_error`, `invalid_form`, `invalid_template_id`, `invalid_template_key`, `invalid_attachment` and `invalid_idempotency_key` for a send the SDK refused before any request, and `invalid_message_id` for a read.", + "type": "string", + "name": "code" + }, + { + "description": "A link to the docs section that explains the fix.", + "type": "string", + "name": "docsUrl" + }, + { + "description": "The request field the error is about, such as `cc` or `attachments__0__filename`.", + "type": "string", + "name": "field" + }, + { + "description": "What to change so the email sends.", + "type": "string", + "name": "fix" + }, + { + "type": "string", + "name": "message" + }, + { + "description": "`validation_error`, `missing_api_key`, `invalid_permission`, `not_found`, `concurrent_idempotent_requests`, `invalid_idempotent_request`, `rate_limit_exceeded`, `monthly_quota_exceeded`, `service_unavailable` or `application_error`.", + "type": "EmailErrorName", + "name": "name" + }, + { + "description": "The HTTP status, when PostHog answered.", + "type": "number", + "name": "statusCode" + }, + { + "description": "Why PostHog refused the email.", + "type": "string", + "name": "why" + } + ], + "path": "src/emails.ts" + }, + { + "id": "EmailErrorName", + "name": "EmailErrorName", + "properties": [], + "path": "src/emails.ts", + "example": "\"validation_error\" | \"missing_api_key\" | \"invalid_permission\" | \"not_found\" | \"concurrent_idempotent_requests\" | \"invalid_idempotent_request\" | \"rate_limit_exceeded\" | \"monthly_quota_exceeded\" | \"service_unavailable\" | \"application_error\"" + }, + { + "id": "Emails", + "name": "Emails", + "properties": [], + "path": "src/emails.ts" + }, + { + "id": "EmailStatus", + "name": "EmailStatus", + "properties": [], + "path": "src/emails.ts", + "example": "\"queued\" | \"waiting\" | \"unknown\" | \"skipped\" | \"failed\" | \"sent\" | \"delivered\" | \"bounced\" | \"complained\"" + }, + { + "id": "EmailStatusRecord", + "name": "EmailStatusRecord", + "properties": [ + { + "description": "When PostHog queued the email, as an ISO 8601 timestamp.", + "type": "string", + "name": "createdAt" + }, + { + "description": "The `id` that `posthog.emails.send()` returned.", + "type": "string", + "name": "id" + }, + { + "description": "Why the email was skipped, failed or bounced, when PostHog knows.", + "type": "string", + "name": "reason" + }, + { + "type": "EmailStatus", + "name": "status" + }, + { + "description": "When the email reached its status, as an ISO 8601 timestamp.", + "type": "string", + "name": "statusAt" + } + ], + "path": "src/emails.ts" + }, + { + "id": "EmailTag", + "name": "EmailTag", + "properties": [ + { + "type": "string", + "name": "name" + }, + { + "type": "string", + "name": "value" + } + ], + "path": "src/emails.ts" + }, + { + "id": "EmailWarning", + "name": "EmailWarning", + "properties": [ + { + "type": "string", + "name": "code" + }, + { + "type": "string", + "name": "fix" + }, + { + "type": "string", + "name": "message" + } + ], + "path": "src/emails.ts" + }, { "id": "EndBranching", "name": "EndBranching", @@ -2603,6 +2827,13 @@ "path": "src/types.ts", "example": "string | number | (string | number)[] | boolean" }, + { + "id": "GetEmailResult", + "name": "GetEmailResult", + "properties": [], + "path": "src/emails.ts", + "example": "{\n data: EmailStatusRecord;\n error: null;\n} | {\n data: null;\n error: EmailError;\n}" + }, { "id": "GetFlagsResult", "name": "GetFlagsResult", @@ -2667,6 +2898,11 @@ "id": "IPostHog", "name": "IPostHog", "properties": [ + { + "description": "The `posthog.emails` API: send transactional email from your server. Needs the `secretKey` client option.", + "type": "Emails", + "name": "emails" + }, { "description": "The `posthog.metrics` API: a statsd-style pre-aggregating metrics client (count, gauge, histogram) — alpha. Samples are folded into per-series aggregates in memory and flushed periodically. Configure via the `metrics` client option.", "type": "Metrics", @@ -2983,6 +3219,26 @@ ], "path": "../core/src/error-tracking/types.ts" }, + { + "id": "PostHogApiResponse", + "name": "PostHogApiResponse", + "properties": [ + { + "type": "number", + "name": "status" + }, + { + "type": "unknown", + "name": "body" + }, + { + "description": "The answer's `Retry-After` in milliseconds, when it sent a usable one.", + "type": "number", + "name": "retryAfterMs" + } + ], + "path": "../core/src/types.ts" + }, { "id": "PostHogCaptureOptions", "name": "PostHogCaptureOptions", @@ -3716,7 +3972,7 @@ "name": "traces" }, { - "description": "Credential for fetching local feature flag definitions and decrypting remote config.\n\nAccepts either a Personal API Key (`phx_...`) or a Project Secret API Key (`phs_...`).\nLocal evaluation can also use definitions from `flagDefinitionCacheProvider` without\nthis credential. Remote config payload decryption via `getRemoteConfigPayload`\nstill requires it. Prefer this over the deprecated\n`personalApiKey` option; when both are set, `secretKey` takes precedence.", + "description": "Credential for fetching local feature flag definitions and decrypting remote config.\n\nAccepts either a Personal API Key (`phx_...`) or a Project Secret API Key (`phs_...`).\nLocal evaluation can also use definitions from `flagDefinitionCacheProvider` without\nthis credential. Remote config payload decryption via `getRemoteConfigPayload`\nstill requires it. Prefer this over the deprecated\n`personalApiKey` option; when both are set, `secretKey` takes precedence.\n\n`posthog.emails` sends with it too, which needs a project secret API key with the `hog_flow:write` scope. When\nthat key can't read feature flags, also set `enableLocalEvaluation: false`.", "type": "string", "name": "secretKey" }, @@ -3726,6 +3982,21 @@ "name": "personalApiKey", "releaseTag": "deprecated" }, + { + "description": "The PostHog app host that `posthog.emails` sends to with the `secretKey`, such as `https://posthog.example.com`\nfor a self-hosted instance. Derived when `host` is PostHog Cloud. Any other `host` may be a reverse proxy, so\n`emails` calls fail with `missing_app_host` until you set it, and the secret key never goes to the proxy.", + "type": "string", + "name": "appHost" + }, + { + "description": "How often `posthog.emails` retries a send that PostHog did not queue, a whole number from 0. Defaults to 2. `0`\nturns retries off.", + "type": "number", + "name": "maxRetries" + }, + { + "description": "The provider your email moved from, such as `'resend'`. Sent with every `posthog.emails` request.", + "type": "string", + "name": "migratedFrom" + }, { "description": "Redact prompts, outputs and error details from every event that the PostHog AI\nintegrations (`@posthog/ai`) capture with this client. Per-call privacy settings\ncan only add redaction. Defaults to false.", "type": "boolean", @@ -4185,6 +4456,59 @@ ], "path": "../core/src/utils/index.ts" }, + { + "id": "SendCodeEmailOptions", + "name": "SendCodeEmailOptions", + "properties": [], + "path": "src/emails.ts", + "example": "SendEmailBase & CodeEmailForm & Without>" + }, + { + "id": "SendEmailBase", + "name": "SendEmailBase", + "properties": [ + { + "description": "Whether PostHog captures the email's engagement events (sent, delivered, opened, clicked, bounced) as analytics events of the recipient. They count as analytics events. 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, 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" + }, + { + "description": "How often to retry this send, a whole number from 0. Overrides the `maxRetries` client option.", + "type": "number", + "name": "maxRetries" + }, + { + "description": "One recipient email address.", + "type": "string", + "name": "to" + } + ], + "path": "src/emails.ts" + }, + { + "id": "SendEmailOptions", + "name": "SendEmailOptions", + "properties": [], + "path": "src/emails.ts", + "example": "SendTemplateEmailOptions | SendCodeEmailOptions" + }, + { + "id": "SendEmailResult", + "name": "SendEmailResult", + "properties": [], + "path": "src/emails.ts", + "example": "{\n data: {\n id: string;\n warnings: EmailWarning[];\n };\n error: null;\n} | {\n data: null;\n error: EmailError;\n}" + }, { "id": "SendFeatureFlagsOptions", "name": "SendFeatureFlagsOptions", @@ -4222,6 +4546,98 @@ "path": "../core/src/metrics/types.ts", "example": "{\n kind: 'ok';\n} | {\n kind: 'retry-later';\n error: unknown;\n retryAfterMs?: number;\n} | {\n kind: 'too-large';\n measuredLocally?: boolean;\n} | {\n kind: 'fatal';\n error: unknown;\n}" }, + { + "id": "SendTemplateEmailOptions", + "name": "SendTemplateEmailOptions", + "properties": [ + { + "description": "One recipient email address.", + "type": "string", + "name": "to" + }, + { + "description": "The recipient's distinct ID in your app. The email's events, such as sent, opened and clicked, land on this\nperson and count as analytics events. Always pass it: without it, they land on a person keyed by the address.", + "type": "string", + "name": "distinctId" + }, + { + "description": "Sends with a key PostHog already accepted queue nothing and return the first send's `id`, even after the email's\nworkflow was paused or the default sender changed. A deleted template, 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" + }, + { + "description": "Bare addresses, such as `'finance@example.com'`, that get a copy.", + "type": "never", + "name": "cc" + }, + { + "description": "Bare addresses that get a copy the other recipients never see.", + "type": "never", + "name": "bcc" + }, + { + "description": "Custom email headers, such as `{ 'X-Entity-Ref-ID': 'order-123' }`. PostHog sets the standard ones itself.", + "type": "never", + "name": "headers" + }, + { + "description": "Added to the email's engagement events as `$email_tags`.", + "type": "never", + "name": "tags" + }, + { + "type": "never", + "name": "attachments" + }, + { + "type": "never", + "name": "html" + }, + { + "type": "never", + "name": "text" + } + ], + "path": "src/emails.ts" + }, { "id": "SendTracesBatchOutcome", "name": "SendTracesBatchOutcome", @@ -4867,6 +5283,23 @@ "path": "../core/src/types.ts", "example": "(typeof SurveyWidgetType)[keyof typeof SurveyWidgetType]" }, + { + "id": "TemplateEmailForm", + "name": "TemplateEmailForm", + "properties": [ + { + "description": "Values for the template's `{{ variables.* }}`. The template can't read person properties.", + "type": "Record", + "name": "templateData" + }, + { + "description": "The UUID of the template.", + "type": "string", + "name": "templateId" + } + ], + "path": "src/emails.ts" + }, { "id": "UnresolvedFlagReason", "name": "UnresolvedFlagReason", @@ -4888,6 +5321,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 new file mode 100644 index 0000000000..221a71e6cf --- /dev/null +++ b/packages/node/src/__tests__/email-content-hash.spec.ts @@ -0,0 +1,30 @@ +import { emailContentHash } from '@/email-content-hash' + +describe('emailContentHash', () => { + it.each([ + { + content: { subject: 'Welcome, {{ variables.first_name }}', html: '

Hi {{ variables.first_name }}

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

ä

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

Hi {{ variables.first_name }}

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

\na\nb\n

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

\r\na\rb\r\n

' })).toBe(lf) + }) +}) diff --git a/packages/node/src/__tests__/emails.spec.ts b/packages/node/src/__tests__/emails.spec.ts new file mode 100644 index 0000000000..b0d1b2c197 --- /dev/null +++ b/packages/node/src/__tests__/emails.spec.ts @@ -0,0 +1,826 @@ +import { PostHog, PostHogOptions } from '@/entrypoints/index.node' +import type { SendEmailOptions } from '@/emails' +import { type Fault, FakeEmailsApi, refusalBody } from './utils/fake-emails-api' + +vi.mock('../version', () => ({ version: '1.2.3' })) + +const JANE = 'jane@example.com' +const SECRET_KEY = 'phs_secret' +const APP_HOST = 'https://posthog.example.com' +const TEMPLATE_ID = '0190f6d2-7c1e-4b3a-9f5e-1c2d3e4f5a6b' +const 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', () => { + let api: FakeEmailsApi + const clients: PostHog[] = [] + + const createClient = (options: PostHogOptions = {}): PostHog => { + const client = new PostHog('phc_project_token', { + host: 'https://ingest.example.com', + appHost: APP_HOST, + secretKey: SECRET_KEY, + enableLocalEvaluation: false, + fetch: api.fetch, + ...options, + }) + clients.push(client) + return client + } + + const sendWelcome = (posthog: PostHog = createClient(), options: { maxRetries?: number } = {}) => + posthog.emails.send({ to: JANE, distinctId: 'user_42', templateId: TEMPLATE_ID, ...options }) + + const requestLog = (): string[] => api.received.map(({ method, path }) => `${method} ${path}`) + const idempotencyKeys = (): string[] => api.received.map(({ headers }) => headers['idempotency-key']) + + beforeEach(() => { + api = new FakeEmailsApi({ secretKey: SECRET_KEY, appHost: APP_HOST }) + api.addTemplate(TEMPLATE_ID) + }) + + afterEach(async () => { + vi.useRealTimers() + vi.restoreAllMocks() + await Promise.all(clients.splice(0).map((client) => client.shutdown())) + }) + + describe('the template form', () => { + it('sends the template with its data to the recipient, as the distinct id, with the secret key', async () => { + const result = await createClient().emails.send({ + to: JANE, + distinctId: 'user_42', + templateId: TEMPLATE_ID, + templateData: { firstName: 'Jane' }, + }) + + expect(result).toEqual({ data: { id: 'message-1', warnings: [] }, error: null }) + expect(api.delivered).toEqual([ + { + messageId: 'message-1', + to: JANE, + template: TEMPLATE_ID, + variables: { firstName: 'Jane' }, + distinctId: 'user_42', + }, + ]) + expect(api.received).toEqual([ + expect.objectContaining({ + origin: APP_HOST, + headers: expect.objectContaining({ + authorization: `Bearer ${SECRET_KEY}`, + 'content-type': 'application/json', + 'user-agent': 'posthog-node/1.2.3', + 'idempotency-key': expect.stringMatching(UUID_V4), + }), + }), + ]) + expect(requestLog()).toEqual([SEND]) + }) + + it.each([ + [ + '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) + + expect(api.received.map((request) => request.body)).toEqual([body]) + }) + + it('sends the given idempotency key, and the migration marker of the client', async () => { + await createClient({ migratedFrom: 'resend' }).emails.send({ + to: JANE, + distinctId: 'user_42', + templateId: TEMPLATE_ID, + idempotencyKey: 'welcome:user_42', + }) + + expect(api.received[0].headers).toEqual( + expect.objectContaining({ 'idempotency-key': 'welcome:user_42', 'x-posthog-migrated-from': 'resend' }) + ) + }) + + it('answers a replayed idempotency key with the id of the first send, and sends one email', async () => { + const posthog = createClient() + const send = () => + posthog.emails.send({ to: JANE, distinctId: 'user_42', templateId: TEMPLATE_ID, idempotencyKey: 'k-1' }) + + const first = await send() + const replay = await send() + + expect(replay).toEqual(first) + expect(api.delivered).toHaveLength(1) + }) + + it('returns the warnings of a send, and logs each one once', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const posthog = createClient() + posthog.debug(true) + api.failNext({ + status: 202, + body: { + message_id: 'message-9', + warnings: [ + { code: 'sender_unverified_soon', detail: 'The sender expires soon.', fix: 'Verify it again.' }, + 'unreadable', + ], + }, + }) + + const result = await sendWelcome(posthog) + + expect(result).toEqual({ + data: { + id: 'message-9', + warnings: [{ code: 'sender_unverified_soon', message: 'The sender expires soon.', fix: 'Verify it again.' }], + }, + error: null, + }) + expect(warn.mock.calls.filter((call) => call.join(' ').includes('sender_unverified_soon'))).toHaveLength(1) + }) + }) + + describe('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' } }) + const receipt = { templateKey: 'receipt', subject: 'Your receipt', html: '

Thanks

' } + const report = Buffer.alloc(40_000, 'report') + const extras = { + cc: 'finance@example.com', + bcc: ['audit@example.com', 'archive@example.com'], + headers: { 'X-Entity-Ref-ID': 'order-123' }, + tags: [{ name: 'category', value: 'receipt' }], + attachments: [ + { filename: 'receipt.txt', content: Buffer.from('hello') }, + { filename: 'logo.png', content: new Uint8Array([0, 255, 1]), contentType: 'image/png', contentId: 'logo' }, + { filename: 'invite.ics', content: 'QkVHSU46VkNBTEVOREFS', contentType: 'text/calendar' }, + { filename: 'report.txt', content: report }, + ], + } + const extrasOnTheWire = { + cc: ['finance@example.com'], + bcc: ['audit@example.com', 'archive@example.com'], + headers: { 'X-Entity-Ref-ID': 'order-123' }, + tags: [{ name: 'category', value: 'receipt' }], + attachments: [ + { filename: 'receipt.txt', content: 'aGVsbG8=' }, + { filename: 'logo.png', content: 'AP8B', content_type: 'image/png', content_id: 'logo' }, + { filename: 'invite.ics', content: 'QkVHSU46VkNBTEVOREFS', content_type: 'text/calendar' }, + { filename: 'report.txt', content: report.toString('base64') }, + ], + } + + 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'], + }, + ], + [ + 'copies, headers, tags and attachments', + { ...receipt, ...extras }, + { template_key: 'receipt', subject: 'Your receipt', html: '

Thanks

', ...extrasOnTheWire }, + ], + ])('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 copies, headers, tags and attachments with both sends of a local template, and never uploads them', async () => { + const result = await createClient().emails.send({ to: JANE, ...receipt, ...extras, templateData: { total: 42 } }) + + expect(result.error).toBeNull() + expect(requestLog()).toEqual([SEND, 'PUT /api/projects/@current/messaging_templates/by_key/receipt/', SEND]) + const [firstSend, upload, secondSend] = api.received + expect(firstSend.body).toEqual(expect.objectContaining(extrasOnTheWire)) + expect(secondSend.body).toEqual(firstSend.body) + expect(Object.keys(upload.body).sort()).toEqual([ + 'html', + 'if_code_managed', + 'if_not_superseded', + 'name', + 'subject', + ]) + }) + + 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('get', () => { + const MESSAGE_ID = '0192a7c4-5e2f-7d1a-8b3c-4d5e6f708192' + const STATUS = `GET /api/projects/@current/messaging/emails/${MESSAGE_ID}/` + + it('reads the status of an email from the app host with the secret key', async () => { + api.recordStatus({ + id: MESSAGE_ID, + status: 'bounced', + created_at: '2026-10-22T08:00:00Z', + status_at: '2026-10-22T08:00:05Z', + reason: 'mailbox_full', + }) + + const result = await createClient({ migratedFrom: 'resend' }).emails.get(MESSAGE_ID) + + expect(result).toEqual({ + data: { + id: MESSAGE_ID, + status: 'bounced', + createdAt: '2026-10-22T08:00:00Z', + statusAt: '2026-10-22T08:00:05Z', + reason: 'mailbox_full', + }, + error: null, + }) + expect(requestLog()).toEqual([STATUS]) + expect(api.received[0]).toEqual( + expect.objectContaining({ + origin: APP_HOST, + headers: expect.objectContaining({ + authorization: `Bearer ${SECRET_KEY}`, + 'user-agent': 'posthog-node/1.2.3', + 'x-posthog-migrated-from': 'resend', + }), + }) + ) + }) + + it('leaves out a reason PostHog did not give', async () => { + api.recordStatus({ id: MESSAGE_ID, status: 'sent', created_at: 'a', status_at: 'b' }) + + const result = await createClient().emails.get(MESSAGE_ID) + + expect(result.data).toEqual({ id: MESSAGE_ID, status: 'sent', createdAt: 'a', statusAt: 'b' }) + }) + + it.each([ + ['an unknown or new email', undefined, 'not_found'], + [ + 'an unavailable pipeline', + { status: 503, body: refusalBody('delivery_unavailable', null) }, + 'delivery_unavailable', + ], + ['a throttled read', { status: 429, body: refusalBody('throttled', null) }, 'throttled'], + ['a network failure', { networkError: new Error('ECONNRESET') }, 'network_error'], + ['an answer without a status', { status: 200, body: { id: MESSAGE_ID } }, 'api_error'], + ] satisfies [string, Fault | undefined, string][])( + 'returns %s as the error, without a retry', + async (_, fault, code) => { + if (fault) { + api.failNext(fault, Infinity) + } + + const result = await createClient().emails.get(MESSAGE_ID) + + expect(result).toEqual({ data: null, error: expect.objectContaining({ code }) }) + expect(api.received).toHaveLength(1) + } + ) + + it.each([ + ['missing_secret_key', 'missing_api_key', { secretKey: undefined }, MESSAGE_ID, 'hog_flow:read'], + ['missing_app_host', 'application_error', { appHost: undefined }, MESSAGE_ID, 'appHost'], + ['client_disabled', 'application_error', { disabled: true }, MESSAGE_ID, 'disabled'], + ['invalid_message_id', 'validation_error', {}, '', 'id'], + ['invalid_message_id', 'validation_error', {}, 'message-\uD800', 'id'], + ])('returns %s as %s without a request', async (code, name, clientOptions: PostHogOptions, id, says) => { + const result = await createClient(clientOptions).emails.get(id) + + expect(result).toEqual({ + data: null, + error: expect.objectContaining({ name, code, message: expect.stringContaining(says) }), + }) + expect(api.received).toEqual([]) + }) + + it('returns client_closed as application_error after shutdown without a request', async () => { + const client = createClient() + await client.shutdown() + + const result = await client.emails.get(MESSAGE_ID) + + expect(result).toEqual({ + data: null, + error: expect.objectContaining({ name: 'application_error', code: 'client_closed' }), + }) + expect(api.received).toEqual([]) + }) + + it('reads an id with reserved characters as one path segment', async () => { + await createClient().emails.get('a/b?c') + + expect(requestLog()).toEqual(['GET /api/projects/@current/messaging/emails/a%2Fb%3Fc/']) + }) + }) + + describe('errors', () => { + it.each([ + [400, 'invalid_recipient', 'validation_error'], + [400, 'transactional_email_paused', 'validation_error'], + [400, 'invalid_request', 'validation_error'], + [400, 'invalid_idempotency_key', 'validation_error'], + [401, 'authentication_failed', 'missing_api_key'], + [403, 'permission_denied', 'invalid_permission'], + [404, 'not_found', 'not_found'], + [409, 'idempotency_key_in_flight', 'concurrent_idempotent_requests'], + [409, 'template_outdated', 'validation_error'], + [409, 'template_edited_in_posthog', 'validation_error'], + [422, 'idempotency_key_reused', 'invalid_idempotent_request'], + [429, 'throttled', 'rate_limit_exceeded'], + [429, 'quota_limited', 'monthly_quota_exceeded'], + [503, 'delivery_unavailable', 'service_unavailable'], + [500, 'server_error', 'application_error'], + ])('names a %s %s answer %s, with the fields PostHog sent', async (status, code, name) => { + api.failNext({ status, body: refusalBody(code, 'to') }, Infinity) + + const result = await sendWelcome(createClient(), { maxRetries: 0 }) + + expect(result).toEqual({ + data: null, + error: { + name, + code, + message: `Refused with ${code}.`, + why: `Because the fake refuses ${code}.`, + fix: `Fix ${code}.`, + statusCode: status, + docsUrl: `https://posthog.com/docs/workflows/transactional-email-api#${code}`, + field: 'to', + }, + }) + }) + + it.each([ + [ + 'an answer without a code', + { status: 500, body: 'oops' }, + 'api_error', + 'application_error', + 'PostHog answered HTTP 500.', + 500, + ], + [ + 'a 202 without a message id', + { status: 202, body: { ok: true } }, + 'api_error', + 'application_error', + 'PostHog answered HTTP 202 with an unexpected body.', + 202, + ], + [ + 'a network failure', + { networkError: new Error('getaddrinfo ENOTFOUND posthog.example.com') }, + 'network_error', + 'service_unavailable', + 'Request failed: getaddrinfo ENOTFOUND posthog.example.com', + undefined, + ], + [ + 'a refused connection to a host with two addresses', + { + networkError: new TypeError('fetch failed', { + cause: new AggregateError([ + new Error('connect ECONNREFUSED ::1:8000'), + new Error('connect ECONNREFUSED 127.0.0.1:8000'), + ]), + }), + }, + 'network_error', + 'service_unavailable', + 'Request failed: fetch failed: connect ECONNREFUSED ::1:8000, connect ECONNREFUSED 127.0.0.1:8000', + undefined, + ], + ] satisfies [string, Fault, string, string, string, number | undefined][])( + 'returns %s as %s', + async (_, fault, code, name, message, statusCode) => { + api.failNext(fault, Infinity) + + const result = await sendWelcome(createClient(), { maxRetries: 0 }) + + expect(result).toEqual({ + data: null, + error: { name, code, message, ...(statusCode !== undefined && { statusCode }) }, + }) + } + ) + + it.each([ + ['missing_secret_key', 'missing_api_key', { secretKey: undefined }, {}], + ['missing_app_host', 'application_error', { appHost: undefined }, {}], + ['client_disabled', 'application_error', { disabled: true }, {}], + ['invalid_template_id', 'validation_error', {}, { templateId: '../../feature_flags' }], + ['invalid_idempotency_key', 'validation_error', {}, { idempotencyKey: 'has spaces' }], + ['invalid_idempotency_key', 'validation_error', {}, { idempotencyKey: 42 as unknown as string }], + ['invalid_form', 'validation_error', {}, { templateKey: 'welcome', subject: 'Hi', html: '

Hi

' }], + [ + 'invalid_template_key', + 'validation_error', + {}, + { templateId: undefined, templateKey: '../welcome', subject: 'Hi', html: '

Hi

' }, + ], + [ + 'invalid_attachment', + 'validation_error', + {}, + { + templateId: undefined, + templateKey: 'invoice', + subject: 'Hi', + html: '

Hi

', + attachments: [{ filename: 'invoice.pdf', content: 42 }], + }, + ], + [ + 'invalid_attachment', + 'validation_error', + {}, + { + templateId: undefined, + templateKey: 'invoice', + subject: 'Hi', + html: '

Hi

', + attachments: { filename: 'invoice.pdf', content: 'aW52b2ljZQ==' }, + }, + ], + ])('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([]) + }) + + it.each([ + ['after shutdown', async (client: PostHog) => await client.shutdown()], + ['while shutdown drains', (client: PostHog) => void client.shutdown()], + ])('returns client_closed as application_error %s without a request', async (_, shutDown) => { + const client = createClient() + await shutDown(client) + + const result = await sendWelcome(client) + + expect(result).toEqual({ + data: null, + error: expect.objectContaining({ name: 'application_error', code: 'client_closed' }), + }) + expect(api.received).toEqual([]) + }) + + it.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'], + ['cc on the template form', { templateId: TEMPLATE_ID, cc: 'finance@example.com' }, 'cc'], + ['bcc on the template form', { templateId: TEMPLATE_ID, bcc: ['audit@example.com'] }, 'bcc'], + ['headers on the template form', { templateId: TEMPLATE_ID, headers: { 'X-Entity-Ref-ID': '42' } }, 'headers'], + ['tags on the template form', { templateId: TEMPLATE_ID, tags: [{ name: 'kind', value: 'invoice' }] }, 'tags'], + [ + 'attachments on the template form', + { templateId: TEMPLATE_ID, attachments: [{ filename: 'a.txt', content: 'YQ==' }] }, + 'attachments', + ], + ['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 })) + expect(api.received).toEqual([]) + }) + + it.each([undefined, null, JANE])('returns invalid_form for %s options without a request', async (options) => { + const result = await createClient().emails.send(options as never) + + expect(result).toEqual({ + data: null, + error: expect.objectContaining({ name: 'validation_error', code: 'invalid_form' }), + }) + expect(api.received).toEqual([]) + }) + + it("refuses a Resend attachment path, which PostHog can't fetch, without a request", async () => { + const resendAttachment = { filename: 'invoice.pdf', path: 'https://example.com/invoice.pdf' } + + const result = await createClient().emails.send({ + to: JANE, + templateKey: 'invoice', + subject: 'Your invoice', + html: '

Attached

', + attachments: [{ filename: 'terms.txt', content: 'dGVybXM=' }, resendAttachment], + } as SendEmailOptions) + + expect(result.error).toEqual( + expect.objectContaining({ + name: 'validation_error', + code: 'invalid_attachment', + field: 'attachments__1__path', + message: expect.stringContaining('content'), + }) + ) + expect(api.received).toEqual([]) + }) + + it('returns unexpected_error when the template data cannot be encoded', async () => { + const result = await createClient().emails.send({ + to: JANE, + templateId: TEMPLATE_ID, + templateData: { count: BigInt(1) }, + }) + + expect(result).toEqual({ + data: null, + error: expect.objectContaining({ name: 'application_error', code: 'unexpected_error' }), + }) + }) + }) + + describe('retries', () => { + const settle = async (work: Promise): Promise => { + await vi.runAllTimersAsync() + return work + } + + beforeEach(() => { + vi.useFakeTimers() + vi.spyOn(Math, 'random').mockReturnValue(0.5) + }) + + it.each([ + ['a network failure', { networkError: new Error('ECONNRESET') }], + ['an in-flight idempotency key', { status: 409, body: refusalBody('idempotency_key_in_flight', null) }], + ['a throttled send', { status: 429, body: refusalBody('throttled', null) }], + ['an unavailable pipeline', { status: 503, body: refusalBody('delivery_unavailable', null) }], + ] satisfies [string, Fault][])('retry %s after 1 s and 2 s with the same key', async (_, fault) => { + api.failNext(fault, 2) + + const result = sendWelcome() + await vi.advanceTimersByTimeAsync(999) + expect(api.received).toHaveLength(1) + await vi.advanceTimersByTimeAsync(1) + expect(api.received).toHaveLength(2) + await vi.advanceTimersByTimeAsync(1999) + expect(api.received).toHaveLength(2) + + expect(await settle(result)).toEqual({ data: { id: 'message-1', warnings: [] }, error: null }) + expect(api.received).toHaveLength(3) + expect(new Set(idempotencyKeys()).size).toBe(1) + }) + + it('gives up after maxRetries and returns the last answer', async () => { + api.failNext({ status: 503, body: refusalBody('delivery_unavailable', null) }, Infinity) + + const result = await settle(sendWelcome()) + + expect(result.error).toEqual(expect.objectContaining({ code: 'delivery_unavailable' })) + expect(api.received).toHaveLength(3) + }) + + it.each([ + ['the client', { maxRetries: 0 }, {}], + ['the send', {}, { maxRetries: 0 }], + ])('never retries when %s sets maxRetries to 0', async (_, clientOptions: PostHogOptions, sendOptions) => { + api.failNext({ status: 503, body: refusalBody('delivery_unavailable', null) }, Infinity) + + await settle(sendWelcome(createClient(clientOptions), sendOptions)) + + expect(api.received).toHaveLength(1) + }) + + it.each([ + ['the client sets NaN', { maxRetries: NaN }, {}, 3], + ['the send sets Infinity', {}, { maxRetries: Infinity }, 3], + ['the send sets -1', { maxRetries: 1 }, { maxRetries: -1 }, 2], + ['the send sets 1.5', { maxRetries: 1 }, { maxRetries: 1.5 }, 2], + ])('falls back to the default when %s', async (_, clientOptions: PostHogOptions, sendOptions, attempts) => { + api.failNext({ status: 503, body: refusalBody('delivery_unavailable', null) }, Infinity) + + await settle(sendWelcome(createClient(clientOptions), sendOptions)) + + expect(api.received).toHaveLength(attempts) + }) + + it('retries a 202 whose body breaks off, and returns the id of the queued email', async () => { + api.failNext({ bodyBreaksOff: new Error('socket hang up') }) + + const result = await settle(sendWelcome()) + + expect(result).toEqual({ data: { id: 'message-1', warnings: [] }, error: null }) + expect(api.delivered).toHaveLength(1) + expect(new Set(idempotencyKeys()).size).toBe(1) + }) + + it('lets a send retry more often than the client', async () => { + api.failNext({ status: 503, body: refusalBody('delivery_unavailable', null) }, Infinity) + + await settle(sendWelcome(createClient({ maxRetries: 0 }), { maxRetries: 3 })) + + expect(api.received).toHaveLength(4) + }) + + it.each([ + ['a longer Retry-After', '5', 5_000], + ['a Retry-After over 30 s, capped', '120', 30_000], + ])('waits for %s', async (_, retryAfter, wait) => { + api.failNext({ status: 429, body: refusalBody('throttled', null), headers: { 'Retry-After': retryAfter } }) + + const result = sendWelcome() + await vi.advanceTimersByTimeAsync(wait - 1) + expect(api.received).toHaveLength(1) + await vi.advanceTimersByTimeAsync(1) + expect(api.received).toHaveLength(2) + await settle(result) + }) + + it.each([ + [429, 'quota_limited'], + [409, 'template_edited_in_posthog'], + [400, 'invalid_recipient'], + [500, 'server_error'], + ])('never retries a %s %s answer', async (status, code) => { + api.failNext({ status, body: refusalBody(code, null) }, Infinity) + + await settle(sendWelcome()) + + expect(api.received).toHaveLength(1) + }) + }) + + describe('the app host', () => { + it.each([ + ['https://us.i.posthog.com', 'https://us.posthog.com'], + ['https://us.posthog.com', 'https://us.posthog.com'], + ['https://app.posthog.com', 'https://us.posthog.com'], + ['https://eu.i.posthog.com/', 'https://eu.posthog.com'], + ['https://eu.posthog.com', 'https://eu.posthog.com'], + [undefined, 'https://us.posthog.com'], + ])('derives the app host of PostHog Cloud host %s', async (host, appHost) => { + api = new FakeEmailsApi({ secretKey: SECRET_KEY, appHost }) + api.addTemplate(TEMPLATE_ID) + + const result = await sendWelcome(createClient({ host, appHost: undefined })) + + expect(result.error).toBeNull() + expect(api.received.map(({ origin }) => origin)).toEqual([appHost]) + }) + + it('never sends the secret key to a host that is not PostHog Cloud', async () => { + const result = await sendWelcome(createClient({ host: 'https://proxy.example.com', appHost: undefined })) + + expect(result.error).toEqual(expect.objectContaining({ code: 'missing_app_host' })) + expect(api.received).toEqual([]) + }) + + it('sends to the app host, not to the ingestion host', async () => { + await sendWelcome(createClient({ host: 'https://proxy.example.com', appHost: `${APP_HOST}/` })) + + expect(api.received.map(({ origin }) => origin)).toEqual([APP_HOST]) + }) + }) +}) diff --git a/packages/node/src/__tests__/types/emails-forms.ts b/packages/node/src/__tests__/types/emails-forms.ts new file mode 100644 index 0000000000..6b83c10ad3 --- /dev/null +++ b/packages/node/src/__tests__/types/emails-forms.ts @@ -0,0 +1,48 @@ +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' } }, + { + to, + templateKey: 'receipt', + subject: 'Receipt', + html: '

Thanks

', + cc: 'finance@example.com', + bcc: ['audit@example.com'], + headers: { 'X-Entity-Ref-ID': 'order-123' }, + tags: [{ name: 'category', value: 'receipt' }], + attachments: [ + { filename: 'receipt.pdf', content: Buffer.from('pdf') }, + { filename: 'logo.png', content: new Uint8Array([1]), contentType: 'image/png', contentId: 'logo' }, + { filename: 'invite.ics', content: 'QkVHSU4=' }, + ], + }, +] + +export const wrongCombinations: SendEmailOptions[] = [ + // @ts-expect-error a send takes exactly one form + { to, templateId: '0190f6d2-7c1e-4b3a-9f5e-1c2d3e4f5a6b', templateKey: 'reset', subject: 'Reset', html: '

R

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

Reset

' }, + // @ts-expect-error a template made in PostHog keeps its own copies + { to, templateId: '0190f6d2-7c1e-4b3a-9f5e-1c2d3e4f5a6b', cc: 'finance@example.com' }, + // @ts-expect-error a template made in PostHog keeps its own attachments + { to, templateId: '0190f6d2-7c1e-4b3a-9f5e-1c2d3e4f5a6b', attachments: [{ filename: 'a.txt', content: 'YQ==' }] }, + { + to, + templateKey: 'invoice', + subject: 'Invoice', + html: '

I

', + // @ts-expect-error PostHog can't fetch an attachment from a path + attachments: [{ filename: 'a.pdf', path: '/a.pdf' }], + }, +] diff --git a/packages/node/src/__tests__/utils/fake-emails-api.ts b/packages/node/src/__tests__/utils/fake-emails-api.ts new file mode 100644 index 0000000000..e7d4142e84 --- /dev/null +++ b/packages/node/src/__tests__/utils/fake-emails-api.ts @@ -0,0 +1,238 @@ +import type { PostHogFetchOptions, PostHogFetchResponse } from '@posthog/core' +import { emailContentHash } from '@/email-content-hash' + +const SEND_PATH = '/api/projects/@current/messaging/send/email/' +const UPLOAD_PATH = /^\/api\/projects\/@current\/messaging_templates\/by_key\/([^/]+)\/$/ +const STATUS_PATH = /^\/api\/projects\/@current\/messaging\/emails\/([^/]+)\/$/ +const DOCS_URL = 'https://posthog.com/docs/workflows/transactional-email-api' + +export interface ReceivedRequest { + method: string + origin: string + path: string + headers: Record + body: Record +} + +export interface DeliveredEmail { + messageId: string + to: string + /** 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 +} + +export interface StatusRecord { + id: string + status: string + created_at: string + status_at: string + reason?: string +} + +interface LocalTemplate { + subject: string + html: string + text?: string + contentHash: string + editedInPostHog: boolean +} + +export type Fault = + | { status: number; body?: unknown; headers?: Record } + | { networkError: Error } + | { bodyBreaksOff: Error } + +interface ScheduledFault { + matches: (request: ReceivedRequest) => boolean + fault: Fault + remaining: number +} + +/** + * An in-memory PostHog app host that speaks the send 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. It answers the status of an email it was told about. 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 statuses = new Map() + private readonly accepted = new Map() + private readonly faults: ScheduledFault[] = [] + private nextId = 1 + + constructor(private readonly options: { secretKey: string; appHost: string }) {} + + readonly fetch = async (url: string, init: PostHogFetchOptions): Promise => { + const request = await describe(new Request(url, init as RequestInit)) + this.received.push(request) + const fault = this.takeFault(request) + if (fault && 'networkError' in fault) { + throw fault.networkError + } + if (fault && 'bodyBreaksOff' in fault) { + return breakOff(await this.handle(request), fault.bodyBreaksOff) + } + if (fault) { + return respond(fault.status, fault.body, fault.headers) + } + return this.handle(request) + } + + addTemplate(id: string): void { + this.templateIds.add(id) + } + + editInPostHog(key: string, content: { subject: string; html: string }): void { + this.localTemplates.set(key, { ...content, contentHash: 'v1:edited', editedInPostHog: true }) + } + + recordStatus(record: StatusRecord): void { + this.statuses.set(record.id, record) + } + + localTemplate(key: string): LocalTemplate | undefined { + return this.localTemplates.get(key) + } + + failWhen(matches: (request: ReceivedRequest) => boolean, fault: Fault, times = Infinity): void { + this.faults.push({ matches, fault, remaining: times }) + } + + failNext(fault: Fault, times = 1): void { + this.failWhen(() => true, fault, times) + } + + private takeFault(request: ReceivedRequest): Fault | undefined { + const scheduled = this.faults.find((candidate) => candidate.remaining > 0 && candidate.matches(request)) + if (scheduled) { + scheduled.remaining-- + } + return scheduled?.fault + } + + private async handle(request: ReceivedRequest): Promise { + if (request.origin !== this.options.appHost) { + return respond(404, 'Not the app host') + } + if (request.headers.authorization !== `Bearer ${this.options.secretKey}`) { + return refusal(401, 'authentication_failed', null) + } + const uploadKey = UPLOAD_PATH.exec(request.path)?.[1] + if (request.method === 'PUT' && uploadKey) { + return this.handleUpload(decodeURIComponent(uploadKey), request.body) + } + if (request.method === 'POST' && request.path === SEND_PATH) { + return this.handleSend(request) + } + const messageId = STATUS_PATH.exec(request.path)?.[1] + if (request.method === 'GET' && messageId) { + const record = this.statuses.get(decodeURIComponent(messageId)) + return record ? respond(200, record) : refusal(404, 'not_found', null) + } + 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(body) + const earlier = key === undefined ? undefined : this.accepted.get(key) + if (earlier) { + return earlier.fingerprint === fingerprint + ? 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, 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 } + } +} + +async function describe(request: Request): Promise { + const text = await request.text() + const url = new URL(request.url) + return { + method: request.method, + origin: url.origin, + path: url.pathname, + headers: Object.fromEntries(request.headers.entries()), + body: text ? JSON.parse(text) : {}, + } +} + +export function refusal(status: number, code: string, attr: string | null): PostHogFetchResponse { + return respond(status, refusalBody(code, attr)) +} + +export function refusalBody(code: string, attr: string | null): Record { + return { + type: 'invalid_request', + code, + detail: `Refused with ${code}.`, + attr, + why: `Because the fake refuses ${code}.`, + fix: `Fix ${code}.`, + docs_url: `${DOCS_URL}#${code}`, + } +} + +function respond(status: number, body: unknown, headers: Record = {}): PostHogFetchResponse { + const text = typeof body === 'string' ? body : JSON.stringify(body ?? '') + return new Response(text, { status, headers: { 'Content-Type': 'application/json', ...headers } }) +} + +/** The answer PostHog sent, with a body that fails while the SDK reads it. */ +function breakOff(answer: PostHogFetchResponse, error: Error): PostHogFetchResponse { + const body = new ReadableStream({ start: (controller) => controller.error(error) }) + return new Response(body, { status: answer.status }) +} diff --git a/packages/node/src/client.ts b/packages/node/src/client.ts index 8e1fa3daba..8d01dce3ff 100644 --- a/packages/node/src/client.ts +++ b/packages/node/src/client.ts @@ -7,10 +7,13 @@ import { isBlockedUA, isPlainObject, isPostHogFetchContentTooLargeError, + isPostHogFetchNetworkError, JsonType, minimizeFlagCalledEventProperties, PostHogCaptureOptions, + PostHogApiResponse, PostHogCoreStateless, + removeTrailingSlash, PostHogEventProperties, PostHogFetchOptions, PostHogFetchResponse, @@ -77,6 +80,7 @@ import { AI_ROUTE, ANALYTICS_ROUTE, isLegacyOnlyEvent } from './capture-v1/routi import { V1CaptureSender } from './capture-v1/sender' import { eventByteSize, partitionAiBatch } from './ai-capture/batching' import { AI_CAPTURE_ENDPOINT_PATH, AI_CAPTURE_ROUTE, AI_MAX_EVENT_BYTES } from './ai-capture/routing' +import { type Emails, PostHogEmails } from './emails' // Standard local evaluation rate limit is 600 per minute (10 per second), // so the fastest a poller should ever be set is 100ms. @@ -121,6 +125,47 @@ function normalizeHost(value?: unknown): string { return normalizedValue || DEFAULT_NODE_HOST } +const CLOUD_APP_HOSTS: Record = { + 'us.i.posthog.com': 'https://us.posthog.com', + 'us.posthog.com': 'https://us.posthog.com', + 'app.posthog.com': 'https://us.posthog.com', + 'eu.i.posthog.com': 'https://eu.posthog.com', + 'eu.posthog.com': 'https://eu.posthog.com', +} + +function cloudAppHost(host: string): string | undefined { + try { + const url = new URL(host) + return url.protocol === 'https:' && url.port === '' ? CLOUD_APP_HOSTS[url.hostname] : undefined + } catch { + return undefined + } +} + +function isTimeout(error: unknown): boolean { + return isPostHogFetchNetworkError(error) && error.error instanceof Error && error.error.name === 'AbortError' +} + +function requestFailure(error: unknown): string { + const reasons: string[] = [] + let reason = isPostHogFetchNetworkError(error) ? error.error : error + for (let depth = 0; reason instanceof Error && depth < 3; depth++) { + reasons.push(describeError(reason)) + reason = reason.cause + } + return ['Request failed', ...reasons.filter(Boolean)].join(': ') +} + +function describeError(error: Error): string { + if (error.message || !(error instanceof AggregateError)) { + return error.message + } + return error.errors + .filter((inner): inner is Error => inner instanceof Error) + .map((inner) => inner.message) + .join(', ') +} + function normalizeUnsetPersonProperties(value: string | string[]): string[] { const propertyNames = Array.isArray(value) ? value : [value] return propertyNames.filter( @@ -159,6 +204,8 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen public readonly options: PostHogOptions protected readonly context?: IPostHogContext private _metrics?: PostHogMetrics + private _emails?: Emails + private _emailsClosed = false private _traces?: PostHogTraces private _spanContext?: SpanContextManager @@ -671,6 +718,39 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen return this._metrics } + /** + * The `posthog.emails` API: send transactional email from your server. Needs the `secretKey` client option. + * + * @example + * ```ts + * const { data, error } = await client.emails.send({ + * to: 'jane@example.com', + * distinctId: user.id, + * templateId: '0190f6d2-7c1e-4b3a-9f5e-1c2d3e4f5a6b', + * }) + * ``` + * + * {@label Emails} + */ + public get emails(): Emails { + this._emails ??= new PostHogEmails({ + isDisabled: () => this.disabled, + isClosed: () => this._emailsClosed, + secretKey: () => this.options.personalApiKey, + appHost: () => (this.options.appHost ? removeTrailingSlash(this.options.appHost) : cloudAppHost(this.host)), + maxRetries: () => this.options.maxRetries, + headers: () => ({ + ...this.getCustomHeaders(), + 'User-Agent': this.getCustomUserAgent(), + ...(this.options.migratedFrom && { 'X-PostHog-Migrated-From': this.options.migratedFrom }), + }), + warn: (message) => this._logger.warn(message), + track: (work) => this.addPendingPromise(work), + request: (method, url, headers, body) => this._requestEmailsApi(method, url, headers, body), + }) + return this._emails + } + /** * Active-span tracking. Overridden by the Node entrypoint with an * `AsyncLocalStorage`-backed manager; the edge build keeps this synchronous @@ -2909,6 +2989,7 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen // super._shutdown spend from the same budget, so shutdown(500) can't take // ~1000ms by giving each phase a fresh full timeout. const shutdownDeadlineMs = Date.now() + (shutdownTimeoutMs ?? 30000) + this._emailsClosed = true // Cancel any pending debounced flush — shutdown will flush directly. const resolve = this._consumeWaitUntilCycle() @@ -2983,6 +3064,21 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen } } + /** One attempt: `posthog.emails` retries on its own, with the same idempotency key. */ + private async _requestEmailsApi( + method: 'GET' | 'POST' | 'PUT', + url: string, + headers: Record, + body: string | undefined + ): Promise { + try { + 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)) + } + } + private extractPropertiesFromEvent( eventProperties?: Record, groups?: Record diff --git a/packages/node/src/email-content-hash.ts b/packages/node/src/email-content-hash.ts new file mode 100644 index 0000000000..6bc70605a5 --- /dev/null +++ b/packages/node/src/email-content-hash.ts @@ -0,0 +1,54 @@ +/// + +/** The published parts of an email that a recipient gets. An absent part hashes as an empty string. */ +export interface EmailContent { + subject?: string + preheader?: string + html?: string + text?: string +} + +const HASH_PREFIX = 'v1:' +const ENCODING_HEADER = 'posthog-email-template-v1\n' +const HASHED_PARTS = ['subject', 'preheader', 'html', 'text'] as const + +/** + * The hash PostHog stores for a template's published content. The server computes the same bytes, so a send can say + * which content the caller holds: the UTF-8 bytes of each part, line endings normalized, length-prefixed. + */ +export async function emailContentHash(content: EmailContent): Promise { + const subtle = globalThis.crypto?.subtle + if (!subtle) { + throw new Error('SubtleCrypto API not available') + } + const digest = await subtle.digest('SHA-256', encode(content)) + return HASH_PREFIX + toHex(new Uint8Array(digest)) +} + +function encode(content: EmailContent): Uint8Array { + const encoder = new TextEncoder() + const chunks: Uint8Array[] = [encoder.encode(ENCODING_HEADER)] + for (const part of HASHED_PARTS) { + const bytes = encoder.encode(normalizeLineEndings(content[part] ?? '')) + chunks.push(encoder.encode(`${bytes.length}:`), bytes, encoder.encode('\n')) + } + return concat(chunks) +} + +function normalizeLineEndings(value: string): string { + return value.replace(/\r\n?/g, '\n') +} + +function concat(chunks: Uint8Array[]): Uint8Array { + const joined = new Uint8Array(chunks.reduce((total, chunk) => total + chunk.length, 0)) + let offset = 0 + for (const chunk of chunks) { + joined.set(chunk, offset) + offset += chunk.length + } + return joined +} + +function toHex(bytes: Uint8Array): string { + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('') +} diff --git a/packages/node/src/emails.ts b/packages/node/src/emails.ts new file mode 100644 index 0000000000..c04f117add --- /dev/null +++ b/packages/node/src/emails.ts @@ -0,0 +1,821 @@ +import { isValidUUID, type JsonType, type PostHogApiResponse } from '@posthog/core' +import { emailContentHash } from './email-content-hash' + +const SEND_PATH = '/api/projects/@current/messaging/send/email/' +const UPLOAD_PATH = (key: string): string => `/api/projects/@current/messaging_templates/by_key/${key}/` +const STATUS_PATH = (messageId: string): string => + `/api/projects/@current/messaging/emails/${encodeURIComponent(messageId)}/` + +const TEMPLATE_KEY_PATTERN = /^[a-zA-Z0-9_-]{1,400}$/ +const CODE_ONLY_FIELDS = [ + 'from', + 'replyTo', + 'subject', + 'html', + 'text', + 'cc', + 'bcc', + 'headers', + 'tags', + 'attachments', +] as const +const IDEMPOTENCY_KEY_PATTERN = /^[\x21-\x7e]{1,255}$/ +const SEND_OPTIONS = new Set([ + 'to', + 'distinctId', + 'idempotencyKey', + 'captureEngagement', + 'maxRetries', + 'templateId', + 'templateKey', + 'templateData', + 'from', + 'replyTo', + 'subject', + 'html', + 'text', + 'cc', + 'bcc', + 'headers', + 'tags', + 'attachments', +]) +const DEFAULT_MAX_RETRIES = 2 +const FIRST_RETRY_DELAY_MS = 1_000 +const MAX_RETRY_DELAY_MS = 30_000 +const BASE64_CHUNK_BYTES = 0x8000 + +/** What every send takes, whatever its form. */ +export interface SendEmailBase { + /** One recipient email address. */ + to: string + /** + * The recipient's distinct ID in your app. The email's events, such as sent, opened and clicked, land on this + * person and count as analytics events. Always pass it: without it, they land on a person keyed by the address. + */ + distinctId?: string + /** + * Sends with a key PostHog already accepted queue nothing and return the first send's `id`, even after the email's + * workflow was paused or the default sender changed. A deleted template, 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 TemplateEmailForm { + /** The UUID of the template. */ + templateId: string + /** Values for the template's `{{ variables.* }}`. The template can't read person properties. */ + templateData?: Record +} + +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[] + /** Bare addresses, such as `'finance@example.com'`, that get a copy. */ + cc?: string | string[] + /** Bare addresses that get a copy the other recipients never see. */ + bcc?: string | string[] + /** Custom email headers, such as `{ 'X-Entity-Ref-ID': 'order-123' }`. PostHog sets the standard ones itself. */ + headers?: Record + /** Added to the email's engagement events as `$email_tags`. */ + tags?: EmailTag[] + attachments?: EmailAttachment[] + /** + * 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 +} + +/** A name and value that PostHog adds to the email's engagement events. ASCII letters, numbers, `_` and `-`. */ +export type EmailTag = { + name: string + value: string +} + +/** A file sent with the email. */ +export interface EmailAttachment { + /** The name the recipient sees, such as `'invoice.pdf'`. */ + filename: string + /** The file's bytes, or its content already Base64 encoded. */ + content: Buffer | Uint8Array | string + /** A MIME type, such as `'application/pdf'`. */ + contentType?: string + /** Shows the file inline where the HTML has `` with this id. */ + contentId?: string + /** PostHog can't fetch a file from a path or URL: read the file and pass its `content`. */ + path?: never +} + +/** + * 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 { + code: string + message: string + fix?: string +} + +/** Why an email call failed. Branch on `code`, PostHog's stable code, or on `name`, which uses Resend's names. */ +export interface EmailError { + /** + * `validation_error`, `missing_api_key`, `invalid_permission`, `not_found`, `concurrent_idempotent_requests`, + * `invalid_idempotent_request`, `rate_limit_exceeded`, `monthly_quota_exceeded`, `service_unavailable` or + * `application_error`. + */ + name: EmailErrorName + /** + * PostHog's error code, such as `invalid_recipient`, or one of the SDK's own: `missing_secret_key`, + * `missing_app_host`, `client_disabled`, `client_closed`, `network_error`, `api_error` for an answer without a + * code, `unexpected_error`, `invalid_form`, `invalid_template_id`, `invalid_template_key`, `invalid_attachment` and + * `invalid_idempotency_key` for a send the SDK refused before any request, and `invalid_message_id` for a read. + */ + code: string + message: string + /** Why PostHog refused the email. */ + why?: string + /** What to change so the email sends. */ + fix?: string + /** The HTTP status, when PostHog answered. */ + statusCode?: number + /** A link to the docs section that explains the fix. */ + docsUrl?: string + /** The request field the error is about, such as `cc` or `attachments__0__filename`. */ + field?: string +} + +export type EmailErrorName = + | 'validation_error' + | 'missing_api_key' + | 'invalid_permission' + | 'not_found' + | 'concurrent_idempotent_requests' + | 'invalid_idempotent_request' + | 'rate_limit_exceeded' + | 'monthly_quota_exceeded' + | 'service_unavailable' + | 'application_error' + +/** The result of `posthog.emails.send()`: the queued email's `id`, or the `error`. */ +export type SendEmailResult = + | { data: { id: string; warnings: EmailWarning[] }; error: null } + | { data: null; error: EmailError } + +/** Where an email is. `sent` and later come from the email provider. */ +export type EmailStatus = + | 'queued' + | 'waiting' + | 'unknown' + | 'skipped' + | 'failed' + | 'sent' + | 'delivered' + | 'bounced' + | 'complained' + +/** The latest status PostHog knows of one email. */ +export interface EmailStatusRecord { + /** The `id` that `posthog.emails.send()` returned. */ + id: string + status: EmailStatus + /** When PostHog queued the email, as an ISO 8601 timestamp. */ + createdAt: string + /** When the email reached its status, as an ISO 8601 timestamp. */ + statusAt: string + /** Why the email was skipped, failed or bounced, when PostHog knows. */ + reason?: string +} + +/** The result of `posthog.emails.get()`: the email's status, or the `error`. */ +export type GetEmailResult = { data: EmailStatusRecord; error: null } | { data: null; error: EmailError } + +/** The `posthog.emails` API: send transactional email from your server. */ +export interface Emails { + /** + * Queue one email. A result with `data` means PostHog stored the send in its queue, not that it was delivered. + * + * Never throws: it returns `{ data, error }`. Every send carries an idempotency key, so the SDK retries network + * errors, an in-flight key, throttling and an unavailable pipeline, up to `maxRetries` times, without sending twice. + * It never retries an exhausted monthly quota. The SDK captures no event: the send and its engagement events come + * from PostHog and count as analytics events. + * + * Needs the `secretKey` client option, a project secret API key (`phs_`) with the `hog_flow:write` scope. When the + * key can't read feature flags, also set `enableLocalEvaluation: false`, or the client polls flag definitions with it. + * + * @example A template made in PostHog, by its id + * ```ts + * const { data, error } = await posthog.emails.send({ + * to: 'jane@example.com', + * distinctId: user.id, + * templateId: '0190f6d2-7c1e-4b3a-9f5e-1c2d3e4f5a6b', + * templateData: { firstName: 'Jane' }, + * idempotencyKey: `welcome:${user.id}`, + * }) + * ``` + * + * @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' }, + * }) + * ``` + * + * @example A copy, a tag and an attachment + * ```ts + * await posthog.emails.send({ + * to: 'jane@example.com', + * distinctId: user.id, + * templateKey: 'invoice', + * subject: 'Your invoice', + * html: invoiceHtml, + * cc: 'finance@example.com', + * tags: [{ name: 'category', value: 'billing' }], + * attachments: [{ filename: 'invoice.pdf', content: await readFile('invoice.pdf') }], + * }) + * ``` + */ + send(options: SendEmailOptions): Promise + + /** + * Read the latest status of an email that `send` queued. + * + * `status` is not Resend's `last_event`: it is where the email is now, from `queued` to `delivered`, `bounced` + * or `complained`. A new email can take a few seconds to show up, and until then the read answers `not_found`. + * Never throws, and never retries: call it again later to see a newer status. + * + * Needs the `secretKey` client option, a project secret API key with the `hog_flow:read` scope. + * + * @example + * ```ts + * const { data, error } = await posthog.emails.get(sent.id) + * if (data?.status === 'bounced') { + * console.log(`Bounced: ${data.reason}`) + * } + * ``` + */ + get(id: string): Promise +} + +/** @internal */ +export interface EmailsApi { + isDisabled(): boolean + isClosed(): boolean + secretKey(): string | undefined + /** `undefined` when the client's host may be a proxy and no `appHost` was set. */ + appHost(): string | undefined + maxRetries(): number | undefined + headers(): Record + warn(message: string): void + track(work: Promise): Promise + /** Rejects only when PostHog never answered. */ + request( + method: 'GET' | 'POST' | 'PUT', + url: string, + headers: Record, + body: string | undefined + ): Promise +} + +type Outcome = { data: T; error: null } | Failure +type Failure = { data: null; error: EmailError } + +interface Delivery { + idempotencyKey?: string + maxRetries: number +} + +const READ_ONCE: Delivery = { maxRetries: 0 } + +interface EmailCall { + action: string + scope: string +} + +const SENDING: EmailCall = { action: 'Sending email', scope: 'hog_flow:write' } +const READING: EmailCall = { action: 'Reading an email', scope: 'hog_flow:read' } + +interface ApiRequest { + method: 'GET' | 'POST' | 'PUT' + path: string + body?: JsonType +} + +interface Attempt { + outcome: Outcome + retryAfterMs?: number +} + +/** @internal */ +export class PostHogEmails implements Emails { + constructor(private readonly api: EmailsApi) {} + + send(options: SendEmailOptions): Promise { + if (!isRecord(options)) { + return Promise.resolve( + failure('invalid_form', 'send needs an options object with to, and templateId or templateKey.') + ) + } + const refusal = this.refusalBeforeRequest(options) + if (refusal) { + return Promise.resolve(refusal) + } + 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) + ) + } + + get(id: string): Promise { + const refusal = this.clientRefusal(READING) + if (refusal) { + return Promise.resolve(refusal) + } + const path = statusPath(id) + if (path === undefined) { + return Promise.resolve(failure('invalid_message_id', 'id must be the id that send returned.', { field: 'id' })) + } + return this.api.track( + this.exchange({ method: 'GET', path }, READ_ONCE) + .then((outcome) => (outcome.error ? outcome : statusRecord(outcome.data))) + .catch(unexpectedFailure) + ) + } + + private refusalBeforeRequest(options: SendEmailOptions): Failure | undefined { + return ( + this.clientRefusal(SENDING) ?? + formRefusal(options) ?? + unknownOptionRefusal(options) ?? + idempotencyKeyRefusal(options.idempotencyKey) + ) + } + + private clientRefusal({ action, scope }: EmailCall): Failure | undefined { + if (this.api.isDisabled()) { + return failure('client_disabled', 'The PostHog client is disabled, so the SDK made no request.') + } + if (this.api.isClosed()) { + return failure('client_closed', 'The PostHog client was shut down, so the SDK made no request.') + } + if (!this.api.secretKey()) { + return failure( + 'missing_secret_key', + `${action} needs the secretKey client option, a project secret API key with the ${scope} scope.` + ) + } + if (!this.api.appHost()) { + return failure( + 'missing_app_host', + `${action} needs the appHost client option when host is not PostHog Cloud, so the secret key never goes ` + + 'to a proxy.' + ) + } + return undefined + } + + private async sendForm(options: SendEmailOptions, delivery: Delivery): Promise { + if (isTemplateForm(options)) { + return this.post(templateBody(options), delivery) + } + if (options.templateData === undefined) { + return this.post(renderedContentBody(options), delivery) + } + return this.sendLocalTemplate(options, delivery) + } + + /** Sends the content's hash, and uploads the content only when PostHog does not hold it. */ + private async sendLocalTemplate(options: SendCodeEmailOptions, delivery: Delivery): Promise { + const { templateKey } = options + const content = { subject: options.subject, html: options.html, text: options.text } + const fields = jsonCopy(localTemplateFields(options)) + const body = { template: { key: templateKey, hash: await emailContentHash(content) }, ...fields } + const sent = await this.post(body, delivery) + if (!isOutdated(sent)) { + return sent + } + const uploaded = await this.exchange( + { method: 'PUT', path: UPLOAD_PATH(templateKey), body: uploadBody(templateKey, content) }, + delivery + ) + return uploaded.error ? uploaded : this.post(body, delivery) + } + + private async post(body: JsonType, delivery: Delivery): Promise { + const outcome = await this.exchange({ method: 'POST', path: SEND_PATH, body }, delivery) + return outcome.error ? outcome : queuedEmail(outcome.data) + } + + private async exchange(request: ApiRequest, delivery: Delivery): Promise> { + const body = request.body === undefined ? undefined : JSON.stringify(request.body) + for (let retry = 0; ; retry++) { + 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(request: ApiRequest, body: string | undefined, delivery: Delivery): Promise { + const headers = { + ...this.api.headers(), + ...(body !== undefined && { 'Content-Type': 'application/json' }), + Authorization: `Bearer ${this.api.secretKey()}`, + ...(delivery.idempotencyKey !== undefined && { 'Idempotency-Key': delivery.idempotencyKey }), + } + let response: PostHogApiResponse + try { + response = await this.api.request(request.method, `${this.api.appHost()}${request.path}`, headers, body) + } catch (error) { + return { outcome: failure('network_error', error instanceof Error ? error.message : 'Request failed') } + } + const outcome = isSuccess(response.status) + ? { data: response, error: null } + : { data: null, error: apiError(response) } + return { outcome, retryAfterMs: response.retryAfterMs } + } + + private logWarnings(result: SendEmailResult): SendEmailResult { + for (const warning of result.data?.warnings ?? []) { + this.api.warn(`Email ${result.data?.id} was queued with a warning, ${warning.code}: ${warning.message}`) + } + return result + } +} + +function 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) ?? attachmentRefusal(options.attachments) +} + +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 attachmentRefusal(attachments: EmailAttachment[] | undefined): Failure | undefined { + if (attachments !== undefined && !Array.isArray(attachments)) { + return failure('invalid_attachment', 'attachments must be a list of files.', { field: 'attachments' }) + } + for (const [index, attachment] of (attachments ?? []).entries()) { + if (attachment?.path !== undefined) { + return failure( + 'invalid_attachment', + "PostHog can't fetch an attachment from a path or URL. Read the file and pass its content instead.", + { field: `attachments__${index}__path` } + ) + } + if (typeof attachment?.content !== 'string' && !(attachment?.content instanceof Uint8Array)) { + return failure('invalid_attachment', 'An attachment needs content: a Buffer, a Uint8Array or a Base64 string.', { + field: `attachments__${index}__content`, + }) + } + } + 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), + ...codeFormFields(options), + } +} + +function localTemplateFields(options: SendCodeEmailOptions): Record { + return { + ...recipientFields(options), + ...templateDataField(options.templateData), + ...codeFormFields(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 codeFormFields(options: SendCodeEmailOptions): Record { + const { from, replyTo, cc, bcc, headers, tags, attachments } = options + return { + ...(from !== undefined && { from }), + ...(replyTo !== undefined && { reply_to: addressList(replyTo) }), + ...(cc !== undefined && { cc: addressList(cc) }), + ...(bcc !== undefined && { bcc: addressList(bcc) }), + ...(headers !== undefined && { headers }), + ...(tags !== undefined && { tags }), + ...(attachments !== undefined && { attachments: attachments.map(attachmentFields) }), + } +} + +function addressList(addresses: string | string[]): string[] { + return typeof addresses === 'string' ? [addresses] : addresses +} + +function attachmentFields({ filename, content, contentType, contentId }: EmailAttachment): Record { + return { + filename, + content: typeof content === 'string' ? content : base64Of(content), + ...definedFields({ content_type: contentType, content_id: contentId }), + } +} + +function base64Of(bytes: Uint8Array): string { + const chunks: string[] = [] + for (let start = 0; start < bytes.length; start += BASE64_CHUNK_BYTES) { + chunks.push(String.fromCharCode(...bytes.subarray(start, start + BASE64_CHUNK_BYTES))) + } + return btoa(chunks.join('')) +} + +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 }: Outcome): boolean { + switch (error?.name) { + case 'concurrent_idempotent_requests': + case 'rate_limit_exceeded': + return true + case 'service_unavailable': + return error.code === 'network_error' || error.statusCode === 503 + default: + return false + } +} + +function unknownOptionRefusal(options: SendEmailOptions): Failure | undefined { + const field = Object.entries(options).find(([name, value]) => value !== undefined && !SEND_OPTIONS.has(name))?.[0] + if (field === undefined) { + return undefined + } + return failure('invalid_form', `send doesn't take ${field}, so it sent nothing. Remove ${field} and send again.`, { + field, + }) +} + +function isIdempotencyKey(value: unknown): boolean { + return typeof value === 'string' && IDEMPOTENCY_KEY_PATTERN.test(value) +} + +function retryCount(value: number | undefined): number | undefined { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : undefined +} + +function retryDelayMs(retry: number, retryAfterMs = 0): number { + const backoff = FIRST_RETRY_DELAY_MS * 2 ** retry * (0.8 + Math.random() * 0.4) + return Math.min(MAX_RETRY_DELAY_MS, Math.max(backoff, retryAfterMs)) +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +/** Resend's error name for an answer, so migrated code that branches on `error.name` keeps working. */ +function errorName(code: string, statusCode?: number): EmailErrorName { + switch (code) { + case 'missing_secret_key': + return 'missing_api_key' + case 'network_error': + return 'service_unavailable' + case 'invalid_form': + case 'invalid_template_id': + case 'invalid_template_key': + case 'invalid_attachment': + case 'invalid_message_id': + case 'invalid_idempotency_key': + return 'validation_error' + } + switch (statusCode) { + case 400: + return 'validation_error' + case 401: + return 'missing_api_key' + case 403: + return 'invalid_permission' + case 404: + return 'not_found' + case 409: + return code === 'idempotency_key_in_flight' ? 'concurrent_idempotent_requests' : 'validation_error' + case 422: + return 'invalid_idempotent_request' + case 429: + return code === 'quota_limited' ? 'monthly_quota_exceeded' : 'rate_limit_exceeded' + case 503: + return 'service_unavailable' + default: + return 'application_error' + } +} + +function failure(code: string, message: string, details: Partial = {}): Failure { + return { data: null, error: { name: errorName(code, details.statusCode), code, message, ...details } } +} + +function unexpectedFailure(error: unknown): Failure { + return failure('unexpected_error', error instanceof Error ? error.message : 'The email call failed unexpectedly.') +} + +function apiError({ status, body }: PostHogApiResponse): EmailError { + const fields = isRecord(body) ? body : {} + const code = textOf(fields.code) ?? 'api_error' + return { + name: errorName(code, status), + code, + message: textOf(fields.detail) ?? `PostHog answered HTTP ${status}.`, + ...optional('why', textOf(fields.why)), + ...optional('fix', textOf(fields.fix)), + statusCode: status, + ...optional('docsUrl', textOf(fields.docs_url)), + ...optional('field', textOf(fields.attr)), + } +} + +function statusPath(id: unknown): string | undefined { + if (typeof id !== 'string' || !id) { + return undefined + } + try { + return STATUS_PATH(id) + } catch { + return undefined + } +} + +function statusRecord({ status, body }: PostHogApiResponse): GetEmailResult { + const fields = isRecord(body) ? body : {} + const [id, emailStatus, createdAt, statusAt] = [fields.id, fields.status, fields.created_at, fields.status_at].map( + textOf + ) + if (!id || !emailStatus || !createdAt || !statusAt) { + return failure('api_error', `PostHog answered HTTP ${status} with an unexpected body.`, { statusCode: status }) + } + return { + data: { id, status: emailStatus as EmailStatus, createdAt, statusAt, ...optional('reason', textOf(fields.reason)) }, + error: null, + } +} + +function warningsOf(warnings: unknown): EmailWarning[] { + return (Array.isArray(warnings) ? warnings : []).flatMap((warning) => { + if (!isRecord(warning) || !textOf(warning.code) || !textOf(warning.detail)) { + return [] + } + return [{ code: textOf(warning.code)!, message: textOf(warning.detail)!, ...optional('fix', textOf(warning.fix)) }] + }) +} + +function optional(key: K, value: string | undefined): { [P in K]?: string } { + return (value === undefined ? {} : { [key]: value }) as { [P in K]?: string } +} + +function textOf(value: unknown): string | undefined { + return typeof value === 'string' && value ? value : undefined +} + +function isSuccess(status: number): boolean { + return status >= 200 && status < 300 +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/packages/node/src/exports.ts b/packages/node/src/exports.ts index b30dd1de21..ab03cd3f15 100644 --- a/packages/node/src/exports.ts +++ b/packages/node/src/exports.ts @@ -3,6 +3,24 @@ export * from './extensions/express' export * from './types' export { FeatureFlagEvaluations } from './feature-flag-evaluations' +export type { + CodeEmailForm, + EmailAttachment, + EmailError, + EmailErrorName, + EmailStatus, + EmailStatusRecord, + Emails, + EmailTag, + EmailWarning, + GetEmailResult, + SendCodeEmailOptions, + SendEmailBase, + SendEmailOptions, + SendEmailResult, + SendTemplateEmailOptions, + TemplateEmailForm, +} from './emails' export type { FlagDefinitionCacheData, FlagDefinitionCacheInput, diff --git a/packages/node/src/types.ts b/packages/node/src/types.ts index 27fe90ba27..afe4859a6c 100644 --- a/packages/node/src/types.ts +++ b/packages/node/src/types.ts @@ -16,6 +16,7 @@ import type { import { ContextData, ContextOptions } from './extensions/context/types' import type { FeatureFlagEvaluations } from './feature-flag-evaluations' +import type { Emails } from './emails' import type { FlagDefinitionCacheInput, FlagDefinitionCacheProvider } from './extensions/feature-flags/cache' export type IdentifyMessage = { @@ -219,6 +220,9 @@ export type PostHogOptions = Omit