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/node-email-send-code-content.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'posthog-node': minor
---

Send email content your code rendered with `posthog.emails.send()`: pass `templateKey`, `subject`, and `html`, `text` or both, with an optional `from` and `replyTo`. A send with `templateKey` captures engagement events unless it passes `captureEngagement: false`; a template made in PostHog follows the project setting. The new `migratedFrom` client option names the email provider you moved from.
5 changes: 5 additions & 0 deletions .changeset/node-emails-local-template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'posthog-node': minor
---

`posthog.emails.send()` also sends a local template: a send with `templateKey` and `templateData` names content from your code that PostHog fills in. The SDK uploads the template whenever PostHog doesn't hold its content yet, such as on its first send or after the content changed. A local template needs `html`.
169 changes: 156 additions & 13 deletions packages/node/references/posthog-node-references-latest.json
Original file line number Diff line number Diff line change
Expand Up @@ -2035,6 +2035,48 @@
"path": "../core/src/error-tracking/types.ts",
"example": "Record<string, string>"
},
{
"id": "CodeEmailFields",
"name": "CodeEmailFields",
"properties": [
{
"description": "Whether PostHog captures the email's engagement events (sent, delivered, opened, clicked, bounced) as events of the recipient. Defaults to `true`. A project whose admin turned engagement capture off captures nothing, whatever the send asks. A template made in PostHog follows the project's setting.",
"type": "boolean",
"name": "captureEngagement"
},
{
"description": "A verified sender, such as `'Acme <security@example.com>'`. Without it, the project's default sender.",
"type": "string",
"name": "from"
},
{
"type": "string | string[]",
"name": "replyTo"
},
{
"type": "string",
"name": "subject"
},
{
"description": "Leave it out to send rendered content: the email goes out as is, and PostHog never stores it. Pass it to send a local template: PostHog fills its `{{ variables.* }}`, and the SDK uploads the template whenever PostHog doesn't hold its content yet. A local template needs `html`. Content that PostHog already replaced with newer content isn't uploaded again: the email goes out with the newer content, and the send returns a `template_superseded` warning.",
"type": "Record<string, unknown>",
"name": "templateData"
},
{
"description": "Names the email, such as `'password-reset'`, so its metrics, logs and status group under it in PostHog. Letters, digits, hyphens and underscores, at most 400.",
"type": "string",
"name": "templateKey"
}
],
"path": "src/emails/types.ts"
},
{
"id": "CodeEmailForm",
"name": "CodeEmailForm",
"properties": [],
"path": "src/emails/types.ts",
"example": "CodeEmailFields & ({\n html: string;\n text?: string;\n} | {\n html?: string;\n text: string;\n templateData?: never;\n})"
},
{
"id": "Coercer",
"name": "Coercer",
Expand Down Expand Up @@ -3876,6 +3918,11 @@
"type": "number",
"name": "emailRequestMaxRetries"
},
{
"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 @@ -4336,16 +4383,23 @@
"path": "../core/src/utils/index.ts"
},
{
"id": "SendEmailOptions",
"name": "SendEmailOptions",
"id": "SendCodeEmailOptions",
"name": "SendCodeEmailOptions",
"properties": [],
"path": "src/emails/types.ts",
"example": "SendEmailBase & CodeEmailForm & Without<Omit<TemplateEmailForm, 'templateData'>>"
},
{
"id": "SendEmailBase",
"name": "SendEmailBase",
"properties": [
{
"description": "The recipient's distinct ID in your product. The email's events go to this person. Without it, they go to a person whose distinct ID is the email address.",
"type": "string",
"name": "distinctId"
},
{
"description": "1 to 255 visible ASCII characters. A send with a key that PostHog has already accepted queues nothing and returns the first send's result. Without it, the client makes a UUID that covers its own retries. Pass a key derived from what the email is for, such as `welcome:user_123`, to cover retries outside this call too.",
"description": "1 to 255 visible ASCII characters. A send with a key that PostHog has already accepted queues nothing and returns the first send's result. The same key with other options sends nothing and returns `invalid_idempotent_request`. Without it, the client makes a UUID that covers its own retries. Pass a key derived from what the email is for, such as `welcome:user_123`, to cover retries outside this call too.",
"type": "string",
"name": "idempotencyKey"
},
Expand All @@ -4354,16 +4408,6 @@
"type": "number",
"name": "maxRetries"
},
{
"description": "Values for the template's `{{ variables.* }}` placeholders. Sent as `message_data`.",
"type": "Record<string, unknown>",
"name": "templateData"
},
{
"description": "The UUID of an email template made transactional in PostHog.",
"type": "string",
"name": "templateId"
},
{
"description": "One recipient email address.",
"type": "string",
Expand All @@ -4372,6 +4416,13 @@
],
"path": "src/emails/types.ts"
},
{
"id": "SendEmailOptions",
"name": "SendEmailOptions",
"properties": [],
"path": "src/emails/types.ts",
"example": "SendTemplateEmailOptions | SendCodeEmailOptions"
},
{
"id": "SendEmailResponse",
"name": "SendEmailResponse",
Expand Down Expand Up @@ -4437,6 +4488,74 @@
"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 product. The email's events go to this person.\nWithout it, they go to a person whose distinct ID is the email address.",
"type": "string",
"name": "distinctId"
},
{
"description": "1 to 255 visible ASCII characters. A send with a key that PostHog has already accepted\nqueues nothing and returns the first send's result. The same key with other options sends\nnothing and returns `invalid_idempotent_request`. Without it, the client makes a UUID that\ncovers its own retries. Pass a key derived from what the email is for, such as\n`welcome:user_123`, to cover retries outside this call too.",
"type": "string",
"name": "idempotencyKey"
},
{
"description": "Overrides the client's `emailRequestMaxRetries` for this send. `0` turns retries off.",
"type": "number",
"name": "maxRetries"
},
{
"description": "The UUID of an email template made transactional in PostHog.",
"type": "string",
"name": "templateId"
},
{
"description": "Values for the template's `{{ variables.* }}` placeholders. Sent as `message_data`.",
"type": "Record<string, unknown>",
"name": "templateData"
},
{
"description": "Names the email, such as `'password-reset'`, so its metrics, logs and status group under it\nin PostHog. Letters, digits, hyphens and underscores, at most 400.",
"type": "never",
"name": "templateKey"
},
{
"type": "never",
"name": "subject"
},
{
"description": "A verified sender, such as `'Acme <security@example.com>'`. Without it, the project's default sender.",
"type": "never",
"name": "from"
},
{
"type": "never",
"name": "replyTo"
},
{
"description": "Whether PostHog captures the email's engagement events (sent, delivered, opened, clicked,\nbounced) as events of the recipient. Defaults to `true`. A project whose admin turned\nengagement capture off captures nothing, whatever the send asks. A template made in PostHog\nfollows the project's setting.",
"type": "never",
"name": "captureEngagement"
},
{
"type": "never",
"name": "html"
},
{
"type": "never",
"name": "text"
}
],
"path": "src/emails/types.ts"
},
{
"id": "SendTracesBatchOutcome",
"name": "SendTracesBatchOutcome",
Expand Down Expand Up @@ -5082,6 +5201,23 @@
"path": "../core/src/types.ts",
"example": "(typeof SurveyWidgetType)[keyof typeof SurveyWidgetType]"
},
{
"id": "TemplateEmailForm",
"name": "TemplateEmailForm",
"properties": [
{
"description": "Values for the template's `{{ variables.* }}` placeholders. Sent as `message_data`.",
"type": "Record<string, unknown>",
"name": "templateData"
},
{
"description": "The UUID of an email template made transactional in PostHog.",
"type": "string",
"name": "templateId"
}
],
"path": "src/emails/types.ts"
},
{
"id": "UnresolvedFlagReason",
"name": "UnresolvedFlagReason",
Expand All @@ -5103,6 +5239,13 @@
}
],
"path": "src/types.ts"
},
{
"id": "Without",
"name": "Without",
"properties": [],
"path": "src/emails/types.ts",
"example": "{\n [K in keyof T]?: never;\n}"
}
],
"categories": [
Expand Down
30 changes: 30 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,30 @@
import { emailContentHash } from '@/emails/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',
},
{
content: {
subject: 'Welcome, {{ variables.first_name }}',
html: '<p>Hi {{ variables.first_name }}</p>',
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: '<p>\na\nb\n</p>' })

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