Skip to content
Draft
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-emails-template-route.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'posthog-node': patch
---

`posthog.emails.send()` sends a template made in PostHog by its id on the same send route as code content.
4 changes: 2 additions & 2 deletions packages/node/references/posthog-node-references-latest.json
Original file line number Diff line number Diff line change
Expand Up @@ -4347,7 +4347,7 @@
"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. Defaults to `true` on every form; pass `false` to turn capture off for this send. A project whose admin turned engagement capture off captures nothing, whatever the send asks. A template sent by `templateId` still follows the project's setting, until PostHog sends it on the same route as code content.",
"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. Defaults to `true` on every form; pass `false` to turn capture off for this send. A project whose admin turned engagement capture off captures nothing, whatever the send asks.",
"type": "boolean",
"name": "captureEngagement"
},
Expand Down Expand Up @@ -4445,7 +4445,7 @@
"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. Defaults to `true` on every form; pass `false` to turn\ncapture off for this send. A project whose admin turned engagement capture off captures nothing, whatever the send\nasks. A template sent by `templateId` still follows the project's setting, until PostHog sends it on the same route\nas code content.",
"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. Defaults to `true` on every form; pass `false` to turn\ncapture off for this send. A project whose admin turned engagement capture off captures nothing, whatever the send\nasks.",
"type": "boolean",
"name": "captureEngagement"
},
Expand Down
15 changes: 10 additions & 5 deletions packages/node/src/__tests__/emails.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ const JANE = 'jane@example.com'
const SECRET_KEY = 'phs_secret'
const APP_HOST = 'https://posthog.example.com'
const TEMPLATE_ID = '0190f6d2-7c1e-4b3a-9f5e-1c2d3e4f5a6b'
const TEMPLATE_SEND = `POST /api/projects/@current/messaging_templates/${TEMPLATE_ID}/send/`
const SEND = 'POST /api/projects/@current/messaging/send/email/'
const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/

Expand Down Expand Up @@ -76,19 +75,25 @@ describe('emails', () => {
}),
}),
])
expect(requestLog()).toEqual([TEMPLATE_SEND])
expect(requestLog()).toEqual([SEND])
})

it.each([
[
'no template data or distinct id',
{ to: JANE, templateId: TEMPLATE_ID },
{ to: JANE, capture_engagement: true },
{ template: { id: TEMPLATE_ID }, to: JANE, capture_engagement: true },
],
[
'template data, a distinct id and engagement capture turned off',
{ to: JANE, templateId: TEMPLATE_ID, templateData: { n: 1 }, distinctId: 'u', captureEngagement: false },
{ to: JANE, message_data: { n: 1 }, identifiers: { distinct_id: 'u' }, capture_engagement: 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)
Expand Down Expand Up @@ -427,7 +432,7 @@ describe('emails', () => {
const result = await send({ to: JANE, templateId: TEMPLATE_ID }, undefined)

expect(result).toEqual({ data: { id: 'message-1', warnings: [] }, error: null })
expect(requestLog()).toEqual([TEMPLATE_SEND])
expect(requestLog()).toEqual([SEND])
})

it.each([
Expand Down
19 changes: 9 additions & 10 deletions packages/node/src/__tests__/utils/fake-emails-api.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type { PostHogFetchOptions, PostHogFetchResponse } from '@posthog/core'

const TEMPLATE_SEND_PATH = /^\/api\/projects\/@current\/messaging_templates\/([^/]+)\/send\/$/
const SEND_PATH = '/api/projects/@current/messaging/send/email/'
const DOCS_URL = 'https://posthog.com/docs/workflows/transactional-email-api'

Expand Down Expand Up @@ -35,9 +34,9 @@ interface ScheduledFault {
}

/**
* An in-memory PostHog app host that speaks the send routes of the transactional email API: a template by id on its
* own route, and rendered content by `template_key` on the send route. An idempotency key PostHog already accepted for
* the same request answers the first `202` again.
* An in-memory PostHog app host that speaks the send route of the transactional email API: a template by id, and
* rendered content by `template_key`. An idempotency key PostHog already accepted for the same request answers the
* first `202` again.
*/
export class FakeEmailsApi {
readonly received: ReceivedRequest[] = []
Expand Down Expand Up @@ -88,17 +87,17 @@ export class FakeEmailsApi {
if (request.headers.authorization !== `Bearer ${this.options.secretKey}`) {
return refusal(401, 'authentication_failed', null)
}
const templateId = TEMPLATE_SEND_PATH.exec(request.path)?.[1]
if (request.method === 'POST' && templateId) {
return this.handleTemplateSend(decodeURIComponent(templateId), request)
}
if (request.method === 'POST' && request.path === SEND_PATH) {
return this.accept(request, { template: request.body.template_key, ...renderedContent(request.body) })
return this.handleSend(request)
}
return refusal(404, 'not_found', null)
}

private handleTemplateSend(templateId: string, request: ReceivedRequest): PostHogFetchResponse {
private handleSend(request: ReceivedRequest): PostHogFetchResponse {
const templateId = request.body.template?.id
if (templateId === undefined) {
return this.accept(request, { template: request.body.template_key, ...renderedContent(request.body) })
}
if (!this.templateIds.has(templateId)) {
return refusal(404, 'not_found', null)
}
Expand Down
39 changes: 15 additions & 24 deletions packages/node/src/emails.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import { isValidUUID, type JsonType, type PostHogApiResponse } from '@posthog/core'

const TEMPLATE_SEND_PATH = (templateId: string): string =>
`/api/projects/@current/messaging_templates/${templateId}/send/`
const SEND_PATH = '/api/projects/@current/messaging/send/email/'

interface SendForm {
Expand Down Expand Up @@ -69,8 +67,7 @@ export interface SendEmailBase {
* Whether PostHog captures the email's engagement events (sent, delivered, opened, clicked, bounced) as analytics
* events of the recipient. They count as analytics events. Defaults to `true` on every form; pass `false` to turn
* capture off for this send. A project whose admin turned engagement capture off captures nothing, whatever the send
* asks. A template sent by `templateId` still follows the project's setting, until PostHog sends it on the same route
* as code content.
* asks.
*/
captureEngagement?: boolean
/** How often to retry this send, a whole number from 0. Overrides the `maxRetries` client option. */
Expand Down Expand Up @@ -220,7 +217,6 @@ export interface EmailsApi {
type Failure = { data: null; error: EmailError }

interface PreparedSend {
path: string
body: JsonType
idempotencyKey: string
maxRetries: number
Expand Down Expand Up @@ -281,7 +277,7 @@ export class PostHogEmails implements Emails {

private prepare(options: SendEmailOptions): PreparedSend {
return {
...(isTemplateForm(options) ? templateSend(options) : renderedContentSend(options)),
body: isTemplateForm(options) ? templateBody(options) : renderedContentBody(options),
idempotencyKey: options.idempotencyKey ?? crypto.randomUUID(),
maxRetries: retryCount(options.maxRetries) ?? retryCount(this.api.maxRetries()) ?? DEFAULT_MAX_RETRIES,
}
Expand All @@ -307,7 +303,7 @@ export class PostHogEmails implements Emails {
}
let response: PostHogApiResponse
try {
response = await this.api.request(`${this.api.appHost()}${send.path}`, headers, body)
response = await this.api.request(`${this.api.appHost()}${SEND_PATH}`, headers, body)
} catch (error) {
return { result: failure('network_error', error instanceof Error ? error.message : 'Request failed') }
}
Expand Down Expand Up @@ -390,29 +386,24 @@ function idempotencyKeyRefusal(idempotencyKey: string | undefined): Failure | un
)
}

function templateSend(options: SendTemplateEmailOptions): Pick<PreparedSend, 'path' | 'body'> {
function templateBody(options: SendTemplateEmailOptions): JsonType {
return {
path: TEMPLATE_SEND_PATH(options.templateId),
body: {
...recipientFields(options),
...(options.templateData !== undefined && { message_data: options.templateData as JsonType }),
},
template: { id: options.templateId },
...recipientFields(options),
...(options.templateData !== undefined && { message_data: options.templateData as JsonType }),
}
}

function renderedContentSend(options: SendCodeEmailOptions): Pick<PreparedSend, 'path' | 'body'> {
function renderedContentBody(options: SendCodeEmailOptions): JsonType {
const { templateKey, subject, html, text, from, replyTo } = options
return {
path: SEND_PATH,
body: {
template_key: templateKey,
subject,
...(html !== undefined && { html }),
...(text !== undefined && { text }),
...(from !== undefined && { from }),
...(replyTo !== undefined && { reply_to: typeof replyTo === 'string' ? [replyTo] : replyTo }),
...recipientFields(options),
},
template_key: templateKey,
subject,
...(html !== undefined && { html }),
...(text !== undefined && { text }),
...(from !== undefined && { from }),
...(replyTo !== undefined && { reply_to: typeof replyTo === 'string' ? [replyTo] : replyTo }),
...recipientFields(options),
}
}

Expand Down
Loading