Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/core-request-json.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@posthog/core': patch
---

Let SDKs built on core send JSON requests to the PostHog API through the shared retrying transport.
5 changes: 5 additions & 0 deletions .changeset/node-emails-send.md
Original file line number Diff line number Diff line change
@@ -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`.
26 changes: 26 additions & 0 deletions packages/core/src/posthog-core-stateless.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
PostHogFeatureFlagDetails,
FeatureFlagDetail,
SurveyResponse,
PostHogApiResponse,
PostHogFetchResponse,
PostHogFetchBodyBytes,
PostHogFetchOptions,
Expand All @@ -40,6 +41,7 @@ import {
createLogger,
getEventUuid,
safeJsonStringify,
trySafe,
} from './utils'
import { parseRetryAfterMs } from './utils/retry-after'
import { uuidv7 } from './vendor/uuidv7'
Expand Down Expand Up @@ -156,6 +158,11 @@ type SuccessfulWriteResponseHandling = {
type: 'successful-write'
}

async function readApiResponse(response: PostHogFetchResponse): Promise<PostHogApiResponse> {
const text = await response.text()
return { status: response.status, body: trySafe(() => JSON.parse(text)) }
}

export const maybeAdd = (key: string, value: JsonType | undefined): Record<string, JsonType> =>
value !== undefined ? { [key]: value } : {}

Expand Down Expand Up @@ -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<RetriableOptions>
): Promise<PostHogApiResponse> {
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<T>(
url: string,
options: PostHogFetchOptions,
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,13 @@ export type PostHogFetchResponse = {
body?: ReadableStream<Uint8Array> | 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
Expand Down
208 changes: 207 additions & 1 deletion packages/node/references/posthog-node-references-latest.json
Original file line number Diff line number Diff line change
Expand Up @@ -2108,6 +2108,84 @@
"properties": [],
"path": "../core/src/cookie.ts"
},
{
"id": "EmailError",
"name": "EmailError",
"properties": [
{
"description": "PostHog's error code, such as `invalid_recipient`, or one of the SDK's own: `missing_secret_key`, `missing_app_host`, `client_disabled`, `client_closed`, `network_error`, `api_error` for an answer without a code, `unexpected_error`, and `invalid_form`, `invalid_template_id` and `invalid_idempotency_key` for a send the SDK refused before any request.",
"type": "string",
"name": "code"
},
{
"description": "A link to the docs section that explains the fix.",
"type": "string",
"name": "docsUrl"
},
{
"description": "The request field the error is about.",
"type": "string",
"name": "field"
},
{
"description": "What to change so the email sends.",
"type": "string",
"name": "fix"
},
{
"type": "string",
"name": "message"
},
{
"description": "`validation_error`, `missing_api_key`, `invalid_permission`, `not_found`, `concurrent_idempotent_requests`, `invalid_idempotent_request`, `rate_limit_exceeded`, `monthly_quota_exceeded`, `service_unavailable` or `application_error`.",
"type": "EmailErrorName",
"name": "name"
},
{
"description": "The HTTP status, when PostHog answered.",
"type": "number",
"name": "statusCode"
},
{
"description": "Why PostHog refused the email.",
"type": "string",
"name": "why"
}
],
"path": "src/emails.ts"
},
{
"id": "EmailErrorName",
"name": "EmailErrorName",
"properties": [],
"path": "src/emails.ts",
"example": "\"validation_error\" | \"missing_api_key\" | \"invalid_permission\" | \"not_found\" | \"concurrent_idempotent_requests\" | \"invalid_idempotent_request\" | \"rate_limit_exceeded\" | \"monthly_quota_exceeded\" | \"service_unavailable\" | \"application_error\""
},
{
"id": "Emails",
"name": "Emails",
"properties": [],
"path": "src/emails.ts"
},
{
"id": "EmailWarning",
"name": "EmailWarning",
"properties": [
{
"type": "string",
"name": "code"
},
{
"type": "string",
"name": "fix"
},
{
"type": "string",
"name": "message"
}
],
"path": "src/emails.ts"
},
{
"id": "EndBranching",
"name": "EndBranching",
Expand Down Expand Up @@ -2667,6 +2745,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",
Expand Down Expand Up @@ -2983,6 +3066,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",
Expand Down Expand Up @@ -3716,7 +3819,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"
},
Expand All @@ -3726,6 +3829,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",
Expand Down Expand Up @@ -4185,6 +4303,77 @@
],
"path": "../core/src/utils/index.ts"
},
{
"id": "SendEmailBase",
"name": "SendEmailBase",
"properties": [
{
"description": "The recipient's distinct ID in your app. The email's events, such as sent, opened and clicked, land on this person and count as analytics events. Always pass it: without it, they land on a person keyed by the address.",
"type": "string",
"name": "distinctId"
},
{
"description": "Sends with a key PostHog already accepted queue nothing and return the first send's `id`, even after the email's workflow was paused or the default sender changed. A deleted template, or a project that no longer has a default sender, still refuses the send. Without it, the SDK makes a UUID v4 that covers its own retries. When a job runner retries the send, pass a key derived from what the email is for, such as `welcome:user_42`, and set `maxRetries: 0`.",
"type": "string",
"name": "idempotencyKey"
},
{
"description": "How often to retry this send, a whole number from 0. Overrides the `maxRetries` client option.",
"type": "number",
"name": "maxRetries"
},
{
"description": "One recipient email address.",
"type": "string",
"name": "to"
}
],
"path": "src/emails.ts"
},
{
"id": "SendEmailOptions",
"name": "SendEmailOptions",
"properties": [
{
"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<string, unknown>",
"name": "templateData"
},
{
"description": "One recipient email address.",
"type": "string",
"name": "to"
},
{
"description": "The recipient's distinct ID in your app. The email's events, such as sent, opened and clicked, land on this\nperson and count as analytics events. Always pass it: without it, they land on a person keyed by the address.",
"type": "string",
"name": "distinctId"
},
{
"description": "Sends with a key PostHog already accepted queue nothing and return the first send's `id`, even after the email's\nworkflow was paused or the default sender changed. A deleted template, or a project that no longer has a default\nsender, still refuses the send. Without it, the SDK makes a UUID v4 that covers its own retries. When a job runner\nretries the send, pass a key derived from what the email is for, such as `welcome:user_42`, and set\n`maxRetries: 0`.",
"type": "string",
"name": "idempotencyKey"
},
{
"description": "How often to retry this send, a whole number from 0. Overrides the `maxRetries` client option.",
"type": "number",
"name": "maxRetries"
}
],
"path": "src/emails.ts"
},
{
"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",
Expand Down Expand Up @@ -4222,6 +4411,23 @@
"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": "Values for the template's `{{ variables.* }}`. The template can't read person properties.",
"type": "Record<string, unknown>",
"name": "templateData"
},
{
"description": "The UUID of the template.",
"type": "string",
"name": "templateId"
}
],
"path": "src/emails.ts"
},
{
"id": "SendTracesBatchOutcome",
"name": "SendTracesBatchOutcome",
Expand Down
22 changes: 22 additions & 0 deletions packages/node/src/__tests__/email-content-hash.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { emailContentHash } from '@/email-content-hash'

describe('emailContentHash', () => {
it.each([
{
content: { subject: 'Welcome, {{ variables.first_name }}', html: '<p>Hi {{ variables.first_name }}</p>' },
hash: 'v1:f74cd913f3aa2e5667a588f2865634e56693df77793ca594d18e9b5f89e3d2e6',
},
{
content: { subject: 'Grüße', preheader: 'Vorschau', html: '<p>ä</p>', text: 'ä' },
hash: 'v1:75cf6fc3189ca26f60da672d29a1d4c89c7db3dd458df3a550a34a4d2a92c149',
},
])('matches the spec test vector $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: '<p>\na\nb\n</p>' })

expect(await emailContentHash({ subject: 'Hi', html: '<p>\r\na\rb\r\n</p>' })).toBe(lf)
})
})
Loading
Loading