Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
081f633
feat(messaging): send cc, bcc, headers and tags with a code send
Silthus Oct 22, 2026
795a02d
chore(messaging): regenerate the API types for cc, bcc, headers and tags
Silthus Oct 22, 2026
aaa9721
fix(messaging): keep every code send header and accept django's copies
Silthus Oct 22, 2026
fe9b293
chore(cdp): cover a code send's copies in its idempotency key and sen…
Silthus Oct 23, 2026
3d02d2e
feat(cdp): check a code send's cc and bcc before the 202
Silthus Oct 23, 2026
54c4b95
fix(messaging): explain a refused cc, bcc, header or tag as that field
Silthus Oct 23, 2026
bbd9845
chore(messaging): regenerate the API types for the send field checks
Silthus Oct 23, 2026
3fbb23f
test(messaging): refuse a reply-to address quoted with a comma
Silthus Oct 23, 2026
c2ee67d
fix(messaging): post a send's headers in one order whatever the caller's
Silthus Oct 23, 2026
2b6e9d4
fix(messaging): refuse a tag key the send route does not take
Silthus Oct 23, 2026
a40cdb1
fix(messaging): explain a refused to without denying cc and bcc
Silthus Oct 23, 2026
1d5af41
test(cdp): check a code send's headers and tags by membership
Silthus Oct 23, 2026
4be44dc
refactor(cdp): name the limit on a code send's copies
Silthus Oct 23, 2026
94b57cb
fix(messaging): fingerprint a send's tags in name order
Silthus Oct 23, 2026
8361308
fix(messaging): name the field that holds a refused recipient
Silthus Oct 23, 2026
3cbf394
fix(messaging): say which sends take cc and bcc copies
Silthus Oct 23, 2026
6a82b47
chore(messaging): regenerate the API types for the send description
Silthus Oct 23, 2026
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
56 changes: 47 additions & 9 deletions nodejs/src/cdp/cdp-api.serial.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2253,6 +2253,26 @@ describe('CDP API', () => {
{ send: codeSend },
{ send: { ...codeSend, from: { ...codeSend.from, integrationId: 13 } } },
],
[
'another cc',
{ send: { ...codeSend, cc: ['first@example.com'] } },
{ send: { ...codeSend, cc: ['second@example.com'] } },
],
[
'another bcc',
{ send: { ...codeSend, bcc: ['first@example.com'] } },
{ send: { ...codeSend, bcc: ['second@example.com'] } },
],
[
'other headers',
{ send: { ...codeSend, headers: [{ name: 'X-Entity-Ref-ID', value: 'order-1' }] } },
{ send: { ...codeSend, headers: [{ name: 'X-Entity-Ref-ID', value: 'order-2' }] } },
],
[
'other tags',
{ send: { ...codeSend, tags: [{ name: 'plan', value: 'free' }] } },
{ send: { ...codeSend, tags: [{ name: 'plan', value: 'pro' }] } },
],
])('rejects the key for %s', async (_desc, first, retry) => {
await sendKeyed({ ...keyed, ...first })

Expand Down Expand Up @@ -2346,6 +2366,10 @@ describe('CDP API', () => {
{ ...codeSend, content: { subject: 'Hi', html: '<img src="https://example.com/a.png" alt="Hi">' } },
],
['a code send for a local template', { tracking: { captureEngagement: false } }],
[
'a code send with copies to addresses with punctuation',
{ ...codeSend, cc: ['archive=orders@example.com'], bcc: ["o'brien+audit@example.com"] },
],
])('stamps %s on the queued run', async (_desc, acceptedSend) => {
const res = await send(
hogFlow.id,
Expand All @@ -2363,6 +2387,7 @@ describe('CDP API', () => {
['content with an empty subject', { ...codeSend, content: { subject: '', text: 'Hi' } }],
['a sender without an integration', { ...codeSend, from: { email: 'security@example.com' } }],
['no tracking choice', { content: codeSend.content }],
['a copy that names two recipients', { ...codeSend, cc: ['jane@example.com, joe@example.com'] }],
])('refuses a code send with %s', async (_desc, acceptedSend) => {
const invalidBefore = await outcomeCount('invalid_request')

Expand Down Expand Up @@ -2394,7 +2419,7 @@ describe('CDP API', () => {
})

describe('send checks', () => {
type Target = { teamId: number; flowId: string; to: string }
type Target = { teamId: number; flowId: string; to: string; send?: Record<string, unknown> }

const suppress = async (address: string): Promise<void> => {
await hub.postgres.query(
Expand Down Expand Up @@ -2463,23 +2488,35 @@ describe('CDP API', () => {
return { teamId: team.id, flowId: flow.id, to: body.to }
}

const suppressedCopy = async (): Promise<Target> => {
const { teamId, flowId } = await suppressedRecipient()
return { teamId, flowId, to: body.to, send: { ...codeSend, cc: ['bounced@example.com'] } }
}

const suppressedRecipientAndCopy = async (): Promise<Target> => {
const target = await suppressedRecipient()
return { ...target, send: { ...codeSend, cc: ['bounced@example.com'] } }
}

const domainWithoutMailServers = (): Promise<Target> =>
Promise.resolve({ teamId: team.id, flowId: hogFlow.id, to: 'someone@no-mail.invalid' })

it.each([
['invalid_recipient', domainWithoutMailServers, 'has no mail servers'],
['recipient_suppressed', suppressedRecipient, 'bounced or was marked as spam'],
['template_paused', pausedWorkflow, 'Email sending is paused for this email'],
['sending_suspended', suspendedProject, 'Email sending is suspended for this project'],
['sender_not_verified', unverifiedSender, "The sender's domain is not verified"],
])('refuses a send that fails %s before it queues', async (code, setUp, why) => {
const { teamId, flowId, to } = await setUp()
['invalid_recipient', domainWithoutMailServers, 'has no mail servers', 'to'],
['recipient_suppressed', suppressedRecipient, 'bounced or was marked as spam', 'to'],
['recipient_suppressed', suppressedCopy, 'bounced or was marked as spam', 'cc'],
['recipient_suppressed', suppressedRecipientAndCopy, 'bounced or was marked as spam', null],
['template_paused', pausedWorkflow, 'Email sending is paused for this email', undefined],
['sending_suspended', suspendedProject, 'Email sending is suspended for this project', undefined],
['sender_not_verified', unverifiedSender, "The sender's domain is not verified", undefined],
])('refuses a send that fails %s before it queues, set up by %p', async (code, setUp, why, attr) => {
const { teamId, flowId, to, send } = await setUp()
const rejectedBefore = await outcomeCount(code)

const res = await supertest(app)
.post(`/api/projects/${teamId}/hog_flows/${flowId}/transactional_email`)
.set({ Authorization: `Bearer ${mintToken(teamId, flowId)}` })
.send({ ...body, to })
.send({ ...body, to, ...(send && { send }) })

expect(res.status).toEqual(400)
expect(res.body).toEqual({
Expand All @@ -2488,6 +2525,7 @@ describe('CDP API', () => {
detail: expect.not.stringMatching(/^Skipping send/),
why: expect.stringContaining(why),
fix: expect.any(String),
attr,
})
expect(await outcomeCount(code)).toEqual(rejectedBefore + 1)
expect(mockQueueInvocations).not.toHaveBeenCalled()
Expand Down
7 changes: 6 additions & 1 deletion nodejs/src/cdp/cdp-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import {
NOT_SENDABLE,
TransactionalEmailOutcome,
TransactionalEmailRequestSchema,
refusedRecipientField,
transactionalEmailRequestsTotal,
transactionalSendCandidate,
transactionalSendFingerprint,
Expand Down Expand Up @@ -1251,7 +1252,11 @@ export class CdpApi {
await idempotencyKeys.release(team.id, idempotencyKey, invocation.id)
}
const { code, detail, why, fix } = decision
return reply(code, 400, { error: code, code, detail, why, fix })
const attr =
'refusedAddresses' in decision
? { attr: refusedRecipientField(decision.refusedAddresses, to, send) }
: {}
return reply(code, 400, { error: code, code, detail, why, fix, ...attr })
}
const { isLimited } = await checkHogFlowQuotaLimits(hogFlow, team.id, this.deps.quotaLimiting)
if (isLimited) {
Expand Down
18 changes: 18 additions & 0 deletions nodejs/src/cdp/schema/cyclotron.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,5 +50,23 @@ describe('cyclotron schema', () => {

expect(CyclotronInvocationQueueParametersEmailSchema.shape.from.safeParse(from).success).toBe(false)
})

it.each([
{
name: 'a header the caller may set',
headers: [{ name: 'X-Entity-Ref-ID', value: 'order-123' }],
ok: true,
},
{ name: 'a header PostHog sets', headers: [{ name: 'bcc', value: 'a@example.com' }], ok: false },
{ name: 'a header in a family PostHog sets', headers: [{ name: 'Content-Type', value: 'x' }], ok: false },
{
name: 'a line break in a value',
headers: [{ name: 'X-Ref', value: '1\r\nBcc: a@example.com' }],
ok: false,
},
{ name: 'a colon in a name', headers: [{ name: 'X-Ref: 1\r\nBcc', value: 'a' }], ok: false },
])('sends $name only when it is safe: $ok', ({ headers, ok }) => {
expect(CyclotronInvocationQueueParametersEmailSchema.shape.headers.safeParse(headers).success).toBe(ok)
})
})
})
4 changes: 4 additions & 0 deletions nodejs/src/cdp/schema/cyclotron.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { z } from 'zod'

import { EmailHeaderSchema, EmailTagSchema, MAX_EMAIL_HEADERS, MAX_EMAIL_TAGS } from './emailFields'
import { PushNotificationPayloadSchema } from './pushNotification'

export const CyclotronInputSchema = z.object({
Expand Down Expand Up @@ -165,6 +166,9 @@ export const CyclotronInvocationQueueParametersEmailSchema = z.object({
}),
cc: z.string().optional(),
bcc: z.string().optional(),
// Set only by a code send of the transactional email API.
headers: z.array(EmailHeaderSchema).max(MAX_EMAIL_HEADERS).optional(),
tags: z.array(EmailTagSchema).max(MAX_EMAIL_TAGS).optional(),
subject: z.string(),
preheader: z.string().optional(),
// Emails authored through the API or MCP often carry html only.
Expand Down
53 changes: 53 additions & 0 deletions nodejs/src/cdp/schema/emailFields.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,60 @@
import { z } from 'zod'

// Django refuses these with a reason before the send is queued. The worker checks again, because a
// header PostHog sets itself, or a line break in a value, must never reach a provider from any path.
const HEADERS_POSTHOG_SETS = new Set([
'auto-submitted',
'bcc',
'cc',
'date',
'dkim-signature',
'from',
'message-id',
'mime-version',
'received',
'reply-to',
'return-path',
'sender',
'subject',
'to',
])
const HEADER_PREFIXES_POSTHOG_SETS = ['content-', 'x-posthog-', 'x-ses-']

export const MAX_EMAIL_COPIES = 10
export const MAX_EMAIL_HEADERS = 10
export const MAX_EMAIL_TAGS = 10
export const RESERVED_EMAIL_TAG_PREFIX = 'ph_'

const isHeaderPostHogSets = (name: string): boolean => {
const lowered = name.toLowerCase()
return (
HEADERS_POSTHOG_SETS.has(lowered) || HEADER_PREFIXES_POSTHOG_SETS.some((prefix) => lowered.startsWith(prefix))
)
}

export const EmailHeaderSchema = z.object({
name: z
.string()
.regex(/^[!-9;-~]{1,126}$/)
.refine((name) => !isHeaderPostHogSets(name), 'PostHog sets this header itself'),
value: z.string().regex(/^[ -~]{0,870}$/),
})

// One bare address with a dotted domain. Spaces, quotes, brackets and list separators are refused, so a copy never
// names a second recipient.
export const BareEmailAddressSchema = z
.string()
.regex(/^[^\s@"<>,;:()[\]\\]+@[^\s@"<>,;:()[\]\\]+\.[^\s@"<>,;:()[\]\\]+$/)

const TagPart = z.string().regex(/^[A-Za-z0-9_-]{1,256}$/)

export const EmailTagSchema = z.object({
name: TagPart.refine(
(name) => !name.toLowerCase().startsWith(RESERVED_EMAIL_TAG_PREFIX),
'PostHog reserves tag names that start with ph_'
),
value: TagPart,
})

export type EmailHeader = z.infer<typeof EmailHeaderSchema>
export type EmailTag = z.infer<typeof EmailTagSchema>
25 changes: 25 additions & 0 deletions nodejs/src/cdp/services/hogflows/hogflow-executor.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3311,6 +3311,31 @@ describe('Hogflow Executor', () => {
html: '<p>Hi {{ variables.name }}</p>',
}),
},
{
name: "a code send with the caller's copies, headers and tags over the step",
step: codeKeyStep,
send: (): AcceptedSend => ({
...codeSend(),
cc: ['jane@example.com', 'joe@example.com'],
bcc: ['audit@example.com'],
headers: [{ name: 'X-Entity-Ref-ID', value: 'order-123' }],
tags: [{ name: 'category', value: 'password_reset' }],
}),
expected: () => ({
type: 'email',
to: { email: 'recipient@example.com', name: 'Recipient' },
from: { integrationId, email: 'security@posthog.com', name: 'Security' },
cc: 'jane@example.com, joe@example.com',
bcc: 'audit@example.com',
headers: [{ name: 'X-Entity-Ref-ID', value: 'order-123' }],
tags: [{ name: 'category', value: 'password_reset' }],
replyTo: 'help@example.com, support@example.com',
subject: 'Reset your password',
preheader: '',
text: 'Hi',
html: '<p>Hi {{ variables.name }}</p>',
}),
},
])('hands the email queue $name', async ({ step, send, expected }) => {
const invocation = flowInvocation(emailFlow(step()))
invocation.state.variables = { name: 'Ada' }
Expand Down
4 changes: 4 additions & 0 deletions nodejs/src/cdp/services/hogflows/hogflow-functions.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ const layAcceptedSendOver = (
}),
...(send.from && { from: { ...send.from } }),
...(send.replyTo && { replyTo: send.replyTo.join(', ') }),
...(send.cc && { cc: send.cc.join(', ') }),
...(send.bcc && { bcc: send.bcc.join(', ') }),
...(send.headers && { headers: send.headers.map((header) => ({ ...header })) }),
...(send.tags && { tags: send.tags.map((tag) => ({ ...tag })) }),
},
},
})
Expand Down
33 changes: 30 additions & 3 deletions nodejs/src/cdp/services/hogflows/transactional-email.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import { Counter } from 'prom-client'
import { z } from 'zod'

import { BareEmailAddressSchema } from '~/cdp/schema/emailFields'
import {
BareEmailAddressSchema,
EmailHeaderSchema,
EmailTagSchema,
MAX_EMAIL_COPIES,
MAX_EMAIL_HEADERS,
MAX_EMAIL_TAGS,
} from '~/cdp/schema/emailFields'
import { HogFlow } from '~/cdp/schema/hogflow'

import { IntegrationManagerService } from '../managers/integration-manager.service'
Expand Down Expand Up @@ -34,6 +41,10 @@ export const AcceptedSendSchema = z
.readonly()
.optional(),
replyTo: z.array(z.string()).readonly().optional(),
cc: z.array(BareEmailAddressSchema).max(MAX_EMAIL_COPIES).readonly().optional(),
bcc: z.array(BareEmailAddressSchema).max(MAX_EMAIL_COPIES).readonly().optional(),
headers: z.array(EmailHeaderSchema.readonly()).max(MAX_EMAIL_HEADERS).readonly().optional(),
tags: z.array(EmailTagSchema.readonly()).max(MAX_EMAIL_TAGS).readonly().optional(),
tracking: z.object({ captureEngagement: z.boolean() }).readonly(),
})
.readonly()
Expand Down Expand Up @@ -102,7 +113,7 @@ export const transactionalEmailRequestsTotal = new Counter({

/**
* What the delivery's send checks will see, known when the send is accepted: the sender the email step
* resolves to for this run, and the recipient. The caller's `send.from` replaces the step's sender, as it
* resolves to for this run, and the recipients. The caller's `send.from` replaces the step's sender, as it
* does at delivery.
*/
export async function transactionalSendCandidate(
Expand All @@ -117,10 +128,26 @@ export async function transactionalSendCandidate(
teamId: hogFlow.team_id,
workflowMetadata: emailPauseMetadata(hogFlow),
sender: from ? await integrationManager.get(selectEmailSenderIntegrationId(invocationId, from)) : null,
recipients: [to],
// Every copy is a recipient, so suppression covers the whole send.
recipients: [to, ...(send?.cc ?? []), ...(send?.bcc ?? [])],
}
}

type RecipientField = 'to' | 'cc' | 'bcc'

/** The request field that holds every refused address, or null when they sit in more than one field. */
export function refusedRecipientField(
refusedAddresses: string[],
to: string,
send: AcceptedSend | undefined
): RecipientField | null {
const held: Record<RecipientField, readonly string[]> = { to: [to], cc: send?.cc ?? [], bcc: send?.bcc ?? [] }
const holding = (Object.keys(held) as RecipientField[]).filter((field) =>
refusedAddresses.some((address) => held[field].includes(address))
)
return holding.length === 1 ? holding[0] : null
}

function emailStepSender(hogFlow: HogFlow): EmailSenderConfiguration | null {
const step = hogFlow.actions.find((action) => action.type === 'function_email')
const from = step?.type === 'function_email' ? step.config.inputs?.email?.value?.from : undefined
Expand Down
5 changes: 4 additions & 1 deletion nodejs/src/cdp/services/messaging/email-send-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ type Rejection = {
}

export type PolicyRejection =
| (Rejection & { code: Exclude<PolicyCode, 'sending_suspended'> })
| (Rejection & { code: 'template_paused' | 'sender_not_verified' })
| (Rejection & { code: 'invalid_recipient' | 'recipient_suppressed'; refusedAddresses: string[] })
| (Rejection & { code: 'sending_suspended'; cause: EmailSendingSuspensionCause })

export type PolicyDecision = { ok: true; sender: IntegrationType } | PolicyRejection
Expand Down Expand Up @@ -93,6 +94,7 @@ export class EmailSendPolicy {
return {
ok: false,
code: 'recipient_suppressed',
refusedAddresses: suppressed,
detail: `Recipient(s) on the suppression list: ${suppressed.join(', ')}`,
why: `An earlier email to ${suppressed.join(', ')} bounced or was marked as spam, so PostHog no longer sends to it.`,
fix: 'Send to another address, or remove the address from the suppression list once it can receive email again.',
Expand Down Expand Up @@ -207,6 +209,7 @@ function invalidRecipient(undeliverable: Undeliverable): PolicyRejection {
return {
ok: false,
code: 'invalid_recipient',
refusedAddresses: [undeliverable.address],
detail: undeliverable.message,
why,
fix: 'Check the address for a typo. If it is right, the recipient cannot receive email, so skip them.',
Expand Down
Loading
Loading