diff --git a/nodejs/src/cdp/cdp-api.serial.test.ts b/nodejs/src/cdp/cdp-api.serial.test.ts
index 76d175cbacde..661d619a6588 100644
--- a/nodejs/src/cdp/cdp-api.serial.test.ts
+++ b/nodejs/src/cdp/cdp-api.serial.test.ts
@@ -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 })
@@ -2346,6 +2366,10 @@ describe('CDP API', () => {
{ ...codeSend, content: { subject: 'Hi', html: '
' } },
],
['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,
@@ -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')
@@ -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 }
const suppress = async (address: string): Promise => {
await hub.postgres.query(
@@ -2463,23 +2488,35 @@ describe('CDP API', () => {
return { teamId: team.id, flowId: flow.id, to: body.to }
}
+ const suppressedCopy = async (): Promise => {
+ const { teamId, flowId } = await suppressedRecipient()
+ return { teamId, flowId, to: body.to, send: { ...codeSend, cc: ['bounced@example.com'] } }
+ }
+
+ const suppressedRecipientAndCopy = async (): Promise => {
+ const target = await suppressedRecipient()
+ return { ...target, send: { ...codeSend, cc: ['bounced@example.com'] } }
+ }
+
const domainWithoutMailServers = (): Promise =>
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({
@@ -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()
diff --git a/nodejs/src/cdp/cdp-api.ts b/nodejs/src/cdp/cdp-api.ts
index 4a34391b8975..12271a9822b0 100644
--- a/nodejs/src/cdp/cdp-api.ts
+++ b/nodejs/src/cdp/cdp-api.ts
@@ -57,6 +57,7 @@ import {
NOT_SENDABLE,
TransactionalEmailOutcome,
TransactionalEmailRequestSchema,
+ refusedRecipientField,
transactionalEmailRequestsTotal,
transactionalSendCandidate,
transactionalSendFingerprint,
@@ -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) {
diff --git a/nodejs/src/cdp/schema/cyclotron.test.ts b/nodejs/src/cdp/schema/cyclotron.test.ts
index 561325c5a923..8c2efff1eff9 100644
--- a/nodejs/src/cdp/schema/cyclotron.test.ts
+++ b/nodejs/src/cdp/schema/cyclotron.test.ts
@@ -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)
+ })
})
})
diff --git a/nodejs/src/cdp/schema/cyclotron.ts b/nodejs/src/cdp/schema/cyclotron.ts
index 560b39f4ba6e..b7cbdfe44a5e 100644
--- a/nodejs/src/cdp/schema/cyclotron.ts
+++ b/nodejs/src/cdp/schema/cyclotron.ts
@@ -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({
@@ -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.
diff --git a/nodejs/src/cdp/schema/emailFields.ts b/nodejs/src/cdp/schema/emailFields.ts
index 710bfbb97c6f..25b17dcfe00b 100644
--- a/nodejs/src/cdp/schema/emailFields.ts
+++ b/nodejs/src/cdp/schema/emailFields.ts
@@ -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
+export type EmailTag = z.infer
diff --git a/nodejs/src/cdp/services/hogflows/hogflow-executor.service.test.ts b/nodejs/src/cdp/services/hogflows/hogflow-executor.service.test.ts
index d2723c075829..caa9d3efe235 100644
--- a/nodejs/src/cdp/services/hogflows/hogflow-executor.service.test.ts
+++ b/nodejs/src/cdp/services/hogflows/hogflow-executor.service.test.ts
@@ -3311,6 +3311,31 @@ describe('Hogflow Executor', () => {
html: 'Hi {{ variables.name }}
',
}),
},
+ {
+ 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: 'Hi {{ variables.name }}
',
+ }),
+ },
])('hands the email queue $name', async ({ step, send, expected }) => {
const invocation = flowInvocation(emailFlow(step()))
invocation.state.variables = { name: 'Ada' }
diff --git a/nodejs/src/cdp/services/hogflows/hogflow-functions.service.ts b/nodejs/src/cdp/services/hogflows/hogflow-functions.service.ts
index c79d74ba0edf..663b072ac51a 100644
--- a/nodejs/src/cdp/services/hogflows/hogflow-functions.service.ts
+++ b/nodejs/src/cdp/services/hogflows/hogflow-functions.service.ts
@@ -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 })) }),
},
},
})
diff --git a/nodejs/src/cdp/services/hogflows/transactional-email.ts b/nodejs/src/cdp/services/hogflows/transactional-email.ts
index 048e97702e89..c3bfb948c147 100644
--- a/nodejs/src/cdp/services/hogflows/transactional-email.ts
+++ b/nodejs/src/cdp/services/hogflows/transactional-email.ts
@@ -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'
@@ -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()
@@ -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(
@@ -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 = { 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
diff --git a/nodejs/src/cdp/services/messaging/email-send-policy.ts b/nodejs/src/cdp/services/messaging/email-send-policy.ts
index 1e9777b43540..cba0b7e4a617 100644
--- a/nodejs/src/cdp/services/messaging/email-send-policy.ts
+++ b/nodejs/src/cdp/services/messaging/email-send-policy.ts
@@ -20,7 +20,8 @@ type Rejection = {
}
export type PolicyRejection =
- | (Rejection & { code: Exclude })
+ | (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
@@ -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.',
@@ -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.',
diff --git a/nodejs/src/cdp/services/messaging/email.service.test.ts b/nodejs/src/cdp/services/messaging/email.service.test.ts
index bec798495ba0..151509fcc2a3 100644
--- a/nodejs/src/cdp/services/messaging/email.service.test.ts
+++ b/nodejs/src/cdp/services/messaging/email.service.test.ts
@@ -989,6 +989,26 @@ describe('EmailService', () => {
to: [{ address: 'test@example.com', name: 'Test User' }],
})
})
+ it("should send a code send's headers", async () => {
+ invocation.queueParameters = createEmailParams({
+ from: { integrationId: 1 },
+ headers: [
+ { name: 'X-Entity-Ref-ID', value: 'order-123' },
+ { name: 'key', value: 'invoice' },
+ { name: 'value', value: '123' },
+ ],
+ })
+ const result = await service.executeSendEmail(invocation)
+ expect(result.error).toBeUndefined()
+ await waitForExpect(async () => expect(mailDevAPI.getEmails()).resolves.toHaveLength(1))
+ const emails = await mailDevAPI.getEmails()
+ expect(emails[0].headers).toMatchObject({
+ 'x-entity-ref-id': 'order-123',
+ key: 'invoice',
+ value: '123',
+ 'auto-submitted': 'auto-generated',
+ })
+ })
it('should include tracking code in the email with distinct_id', async () => {
invocation.queueParameters = createEmailParams({
html: 'Hi! Click me',
@@ -1538,6 +1558,37 @@ describe('EmailService', () => {
}
)
+ it("sends a code send's headers beside PostHog's own and its tags beside PostHog's tags", async () => {
+ sendEmailSpy.mockResolvedValue({ MessageId: 'test-message-id' })
+ invocation.hogFunction.metadata = { message_category_type: 'transactional' }
+ invocation.queueParameters = createEmailParams({
+ headers: [{ name: 'X-Entity-Ref-ID', value: 'order-123' }],
+ tags: [{ name: 'category', value: 'password_reset' }],
+ })
+
+ const result = await service.executeSendEmail(invocation)
+
+ expect(result.error).toBeUndefined()
+ const sentCommand = sendEmailSpy.mock.calls[0][0] as { input: any }
+ const headers = sentCommand.input.Content.Simple.Headers
+ expect(headers).toHaveLength(3)
+ expect(headers).toEqual(
+ expect.arrayContaining([
+ { Name: 'X-Entity-Ref-ID', Value: 'order-123' },
+ expect.objectContaining({ Name: 'X-PostHog-Tracking-Code' }),
+ { Name: 'Auto-Submitted', Value: 'auto-generated' },
+ ])
+ )
+ const tags = sentCommand.input.EmailTags
+ expect(tags).toHaveLength(2)
+ expect(tags).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ Name: 'ph_id' }),
+ { Name: 'category', Value: 'password_reset' },
+ ])
+ )
+ })
+
it('attaches the X-PostHog-Tracking-Code header carrying the full signed code', async () => {
// The header is the authoritative tracking-code carrier (the EmailTag is the
// bounded backwards-compat fallback). It rides on every outbound message,
@@ -1796,17 +1847,22 @@ describe('EmailService', () => {
})
it.each([
- ['an ordinary template', {}, 'Test Subject'],
- ['a sensitive template', { sensitive: true }, undefined],
+ ['an ordinary template', {}, 'Test Subject', {}],
+ ['a sensitive template', { sensitive: true }, undefined, {}],
+ ['a code send with tags', {}, 'Test Subject', { tags: [{ name: 'category', value: 'password_reset' }] }],
])(
'should capture a $workflows_email_sent PostHog event on success, for %s',
- async (_name, extraMetadata, subject) => {
+ async (_name, extraMetadata, subject, extraParams) => {
// Engagement capture is team-opt-in; enable it for this team so the captured event is emitted.
jest.spyOn(
(service as any).teamWorkflowsConfigService,
'shouldCaptureEngagementEvents'
).mockResolvedValue(true)
invocation.hogFunction.metadata = { ...invocation.hogFunction.metadata, ...extraMetadata }
+ invocation.queueParameters = {
+ ...invocation.queueParameters!,
+ ...extraParams,
+ } as typeof invocation.queueParameters
sendEmailSpy.mockResolvedValue({ MessageId: 'test-message-id' })
const result = await service.executeSendEmail(invocation)
expect(result.error).toBeUndefined()
@@ -1823,6 +1879,9 @@ describe('EmailService', () => {
},
})
expect(result.capturedPostHogEvents[0].properties.$email_subject).toEqual(subject)
+ expect(result.capturedPostHogEvents[0].properties.$email_tags).toEqual(
+ 'tags' in extraParams ? { category: 'password_reset' } : undefined
+ )
}
)
diff --git a/nodejs/src/cdp/services/messaging/email.service.ts b/nodejs/src/cdp/services/messaging/email.service.ts
index 133cf60b4774..23ffa15179a5 100644
--- a/nodejs/src/cdp/services/messaging/email.service.ts
+++ b/nodejs/src/cdp/services/messaging/email.service.ts
@@ -4,6 +4,7 @@ import { SendMailOptions } from 'nodemailer'
import { Counter, Histogram } from 'prom-client'
import { CyclotronInvocationQueueParametersEmailType } from '~/cdp/schema/cyclotron'
+import { EmailTag } from '~/cdp/schema/emailFields'
import { HogFlowEmailSendingRateLimit, HogFlowEmailSendingRateLimitSchema } from '~/cdp/schema/hogflow'
import {
CyclotronJobInvocationHogFunction,
@@ -316,6 +317,10 @@ export function formatMailbox(name: string | undefined, email: string): string {
return cleaned ? `"${cleaned.replace(/[\\"]/g, '\\$&')}" <${email}>` : email
}
+export function emailTagProperties(tags: readonly EmailTag[]): Record {
+ return Object.fromEntries(tags.map((tag) => [tag.name, tag.value]))
+}
+
export function parseAddressList(value?: string): string[] | undefined {
if (!value || !value.trim()) {
return undefined
@@ -665,6 +670,7 @@ export class EmailService {
// dimension on the send there is no way to build an open rate in an insight that
// isn't deflated by however much of the audience declined tracking.
$email_tracking_enabled: trackingEnabled,
+ ...(params.tags?.length ? { $email_tags: emailTagProperties(params.tags) } : {}),
},
})
}
@@ -971,7 +977,11 @@ export class EmailService {
to: formatMailbox(params.to.name, params.to.email),
subject: sanitizeEmailSubject(params.subject),
...(params.text ? { text: params.text } : {}),
- headers: { [AUTO_SUBMITTED_HEADER.Name!]: AUTO_SUBMITTED_HEADER.Value! },
+ // A list, because nodemailer reads an object with `key` and `value` names as one header.
+ headers: [
+ ...(params.headers ?? []).map(({ name, value }) => ({ key: name, value })),
+ { key: AUTO_SUBMITTED_HEADER.Name!, value: AUTO_SUBMITTED_HEADER.Value! },
+ ],
...(params.html
? {
html: trackingEnabled
@@ -1075,6 +1085,7 @@ export class EmailService {
...(result.invocation.hogFunction?.metadata?.sensitive === true
? [{ Name: SES_SENSITIVE_TAG, Value: 'true' }]
: []),
+ ...(params.tags ?? []).map((tag) => ({ Name: tag.name, Value: tag.value })),
],
FeedbackForwardingEmailAddress: from.email,
}
@@ -1105,7 +1116,13 @@ export class EmailService {
identifier: params.to.email,
})
: []
- sendEmailParams.Content.Simple.Headers = [...unsubscribeHeaders, trackingHeader, AUTO_SUBMITTED_HEADER]
+ const codeSendHeaders = (params.headers ?? []).map((header) => ({ Name: header.name, Value: header.value }))
+ sendEmailParams.Content.Simple.Headers = [
+ ...codeSendHeaders,
+ ...unsubscribeHeaders,
+ trackingHeader,
+ AUTO_SUBMITTED_HEADER,
+ ]
}
const replyToAddresses = parseAddressList(params.replyTo)
diff --git a/nodejs/src/cdp/services/messaging/helpers/ses.test.ts b/nodejs/src/cdp/services/messaging/helpers/ses.test.ts
index ace2fd9dcbb6..5909398ade44 100644
--- a/nodejs/src/cdp/services/messaging/helpers/ses.test.ts
+++ b/nodejs/src/cdp/services/messaging/helpers/ses.test.ts
@@ -85,6 +85,31 @@ describe('SesWebhookHandler', () => {
})
})
+ it("adds the send's own tags as $email_tags, without PostHog's and SES's", async () => {
+ const mailWithTags = {
+ ...baseMail,
+ tags: {
+ ...baseMail.tags,
+ ph_sensitive: ['false'],
+ 'ses:configuration-set': ['tracked'],
+ category: ['password_reset'],
+ 'plan-tier': ['pro'],
+ },
+ }
+ const body = [
+ {
+ eventType: 'Delivery',
+ mail: mailWithTags,
+ delivery: { timestamp: '2025-10-03T12:01:00Z', recipients: ['to@example.com'] },
+ },
+ ]
+ const result = await handler.handleWebhook({ body, headers: {} })
+ expect(result.metrics?.[0].properties).toEqual({
+ $email_to: 'to@example.com',
+ $email_tags: { category: 'password_reset', 'plan-tier': 'pro' },
+ })
+ })
+
it('parses a raw Click event with link URL', async () => {
const body = [
{
diff --git a/nodejs/src/cdp/services/messaging/helpers/ses.ts b/nodejs/src/cdp/services/messaging/helpers/ses.ts
index c978b41d86ee..75bf4b47b442 100644
--- a/nodejs/src/cdp/services/messaging/helpers/ses.ts
+++ b/nodejs/src/cdp/services/messaging/helpers/ses.ts
@@ -2,6 +2,7 @@ import crypto from 'node:crypto'
import { Counter } from 'prom-client'
import { z } from 'zod'
+import { RESERVED_EMAIL_TAG_PREFIX } from '~/cdp/schema/emailFields'
import { MinimalAppMetric } from '~/cdp/types'
import { parseJSON } from '~/common/utils/json-parse'
import { logger } from '~/common/utils/logger'
@@ -217,6 +218,15 @@ export const SES_LINK_INDEX_TAG = 'phl'
// tells the links apart.
export const SES_SENSITIVE_TAG = 'ph_sensitive'
+// The tags a code send set on its email. PostHog's own tags start with `ph_`, and SES adds its own as `ses:…`.
+export function sendTagsOf(tags: Record | undefined): Record | undefined {
+ const sendTags = Object.entries(tags ?? {}).filter(
+ ([name, values]) =>
+ !name.toLowerCase().startsWith(RESERVED_EMAIL_TAG_PREFIX) && !name.includes(':') && values[0] !== undefined
+ )
+ return sendTags.length ? Object.fromEntries(sendTags.map(([name, values]) => [name, values[0]])) : undefined
+}
+
const sensitiveLinkOrigin = (link: string): string => {
try {
const url = new URL(link)
@@ -765,9 +775,11 @@ export class SesWebhookHandler {
// email activity in its Metrics tab. Bounce-driven opt-outs below still apply.
if (metricName && !isTest) {
const isSensitive = rec.mail.tags?.[SES_SENSITIVE_TAG]?.[0] === 'true'
+ const sendTags = sendTagsOf(rec.mail.tags)
const properties: Record = {
$email_to: rec.mail.destination?.[0],
...(isSensitive ? {} : { $email_subject: rec.mail.commonHeaders?.subject }),
+ ...(sendTags ? { $email_tags: sendTags } : {}),
}
// Each SES event detail carries its own timestamp (open.timestamp, click.timestamp, etc.)
diff --git a/products/messaging/backend/facade/contracts.py b/products/messaging/backend/facade/contracts.py
index 1a07ce1ce901..2d01d247340f 100644
--- a/products/messaging/backend/facade/contracts.py
+++ b/products/messaging/backend/facade/contracts.py
@@ -92,6 +92,14 @@ class SendFrom:
is_default: bool = False
+@frozen
+class EmailField:
+ """A header or tag a code send sets on its email, by name."""
+
+ name: str
+ value: str
+
+
@frozen
class AcceptedSend:
"""What a code send sets on its own, over what its workflow's email step holds."""
@@ -100,6 +108,10 @@ class AcceptedSend:
sender: SendFrom | None
reply_to: tuple[str, ...]
capture_engagement: bool
+ cc: tuple[str, ...] = ()
+ bcc: tuple[str, ...] = ()
+ headers: tuple[EmailField, ...] = ()
+ tags: tuple[EmailField, ...] = ()
class SendRoute(LabeledStrEnum):
@@ -157,6 +169,8 @@ class Rejected:
# Set for the send checks, which say why PostHog refused the send and what to change.
why: str | None = None
fix: str | None = None
+ # The request field that holds a refused recipient, or None when that is not known.
+ recipient_field: str | None = None
@frozen
diff --git a/products/messaging/backend/facade/transactional_email.py b/products/messaging/backend/facade/transactional_email.py
index 037fe50d12a5..d64f2cfdcd53 100644
--- a/products/messaging/backend/facade/transactional_email.py
+++ b/products/messaging/backend/facade/transactional_email.py
@@ -2,6 +2,7 @@
from products.messaging.backend.facade.contracts import (
AcceptedSend,
+ EmailField,
EmailSendCommand,
EmailSendOutcome,
Queued,
@@ -31,6 +32,7 @@
"SENDER_ADDRESS_MAX_LENGTH",
"SENDER_ADDRESS_PATTERN",
"AcceptedSend",
+ "EmailField",
"EmailSendCommand",
"EmailSendOutcome",
"Queued",
diff --git a/products/messaging/backend/presentation/views/messaging_send.py b/products/messaging/backend/presentation/views/messaging_send.py
index 495c54cb297f..332a38d53266 100644
--- a/products/messaging/backend/presentation/views/messaging_send.py
+++ b/products/messaging/backend/presentation/views/messaging_send.py
@@ -22,6 +22,7 @@
SENDER_ADDRESS_MAX_LENGTH,
SENDER_ADDRESS_PATTERN,
AcceptedSend,
+ EmailField,
EmailSendCommand,
RenderedContent,
RenderedEmail,
@@ -32,7 +33,19 @@
rendered_content,
require_code_email_access,
)
-from products.messaging.backend.presentation.views.send_email_fields import PlainAddressField
+from products.messaging.backend.presentation.views.send_email_fields import (
+ MAX_COPIES,
+ MAX_HEADER_VALUE_LENGTH,
+ MAX_HEADERS,
+ MAX_TAGS,
+ UNKNOWN_FIELD,
+ EmailTagSerializer,
+ PlainAddressField,
+ RefusesUnknownFieldsSerializer,
+ copies_field,
+ validate_headers,
+ validate_tags,
+)
from products.messaging.backend.presentation.views.transactional_email_api import (
IDEMPOTENCY_KEY_PARAMETER,
TRANSACTIONAL_EMAIL_THROTTLES,
@@ -47,17 +60,6 @@
invalid_template_key,
)
-UNKNOWN_FIELD = "PostHog doesn't take this field on this route."
-
-
-class RefusesUnknownFieldsSerializer(serializers.Serializer):
- def to_internal_value(self, data: Any) -> dict[str, Any]:
- if isinstance(data, Mapping):
- unknown = [field for field in data if field not in self.fields]
- if unknown:
- raise serializers.ValidationError({unknown[0]: UNKNOWN_FIELD})
- return super().to_internal_value(data)
-
class TemplateReferenceSerializer(RefusesUnknownFieldsSerializer):
key = serializers.CharField(
@@ -83,6 +85,7 @@ def validate(self, data: dict[str, Any]) -> dict[str, Any]:
CODE_FORM_FIELDS = ("subject", "html", "text")
+CODE_SEND_FIELDS = ("from", "reply_to", "cc", "bcc", "headers", "tags")
class SendIdentifiersSerializer(RefusesUnknownFieldsSerializer, TransactionalEmailIdentifiersSerializer):
@@ -135,6 +138,30 @@ class SendEmailSerializer(RefusesUnknownFieldsSerializer, SendTransactionalEmail
max_length=10,
help_text="Addresses replies go to. Not with `template.id`.",
)
+ cc = copies_field(
+ help_text=f"Addresses that get a visible copy, at most {MAX_COPIES}. Each one is checked against the "
+ "suppression list, and one suppressed address stops the send. Not with `template.id`."
+ )
+ bcc = copies_field(
+ help_text=f"Addresses that get a hidden copy, at most {MAX_COPIES}. Each one is checked against the "
+ "suppression list, and one suppressed address stops the send. Not with `template.id`."
+ )
+ headers = serializers.DictField(
+ child=serializers.CharField(trim_whitespace=False, allow_blank=True),
+ required=False,
+ validators=[validate_headers],
+ help_text=f"Extra email headers by name, such as `X-Entity-Ref-ID` or `List-Unsubscribe`, at most "
+ f"{MAX_HEADERS}. Values are printable ASCII up to {MAX_HEADER_VALUE_LENGTH} characters. Headers PostHog "
+ "sets itself, such as `From`, `Subject` and `Content-Type`, are refused. Not with `template.id`.",
+ )
+ tags = serializers.ListField(
+ child=EmailTagSerializer(),
+ required=False,
+ max_length=MAX_TAGS,
+ validators=[validate_tags],
+ help_text=f"Name and value pairs, at most {MAX_TAGS}, that PostHog adds to the email's engagement events "
+ "as `$email_tags`. Not with `template.id`.",
+ )
capture_engagement = serializers.BooleanField(
required=False,
help_text="Whether the email's engagement events (sent, delivered, opened, clicked, bounced) are captured "
@@ -192,9 +219,11 @@ def _validate_template_send(data: dict[str, Any]) -> None:
if field in data:
raise serializers.ValidationError({field: "Only with `template_key`."})
if "id" in data["template"]:
- for field in ("from", "reply_to"):
+ for field in CODE_SEND_FIELDS:
if field in data:
- raise serializers.ValidationError({field: "Not with `template.id`. The template's sender applies."})
+ raise serializers.ValidationError(
+ {field: "Not with `template.id`. A template made in PostHog sends with its own settings."}
+ )
class SendEmailWarningSerializer(serializers.Serializer):
@@ -233,7 +262,8 @@ def template_reference(template: dict[str, Any]) -> TemplateReference:
"send takes all three from the template."
)
SEND_FIELD_WHY = {
- "to": "PostHog sends each email to one recipient, so `to` must be one email address.",
+ "to": "`to` is one email address. A send by `template.key` or `template_key` adds more with `cc` and `bcc`, and "
+ "a template made in PostHog keeps its own recipients.",
"identifiers": "PostHog records the email's engagement events against `identifiers.distinct_id`.",
"message_data": "`message_data` fills a template's `{{ variables.* }}` placeholders, so it is an object, and "
"content your code rendered takes none.",
@@ -245,6 +275,14 @@ def template_reference(template: dict[str, Any]) -> TemplateReference:
"from": "PostHog sends from an address on a verified domain, given in `from`. A template made in PostHog keeps "
"its own sender.",
"reply_to": "Replies go to the addresses in `reply_to`. A template made in PostHog keeps its own reply-to.",
+ "cc": f"`cc` holds up to {MAX_COPIES} plain addresses that get a visible copy. A template made in PostHog keeps "
+ "its own recipients.",
+ "bcc": f"`bcc` holds up to {MAX_COPIES} plain addresses that get a hidden copy. A template made in PostHog keeps "
+ "its own recipients.",
+ "headers": f"`headers` adds up to {MAX_HEADERS} headers of your own. PostHog sets `From`, `Subject` and the "
+ "other standard headers itself, so you can't set them here.",
+ "tags": f"`tags` holds up to {MAX_TAGS} name and value pairs of letters, numbers, `_` and `-`, which PostHog "
+ "adds to the email's engagement events.",
"capture_engagement": "`capture_engagement` turns the email's engagement events on or off, so it is true or false.",
}
@@ -360,13 +398,14 @@ class MessagingSendViewSet(TransactionalEmailViewSetMixin, viewsets.ViewSet):
},
summary="Send a transactional email",
description=(
- "Send one transactional email to one recipient, in one of two forms. A template named by its key or id: "
- "`message_data` fills its `{{ variables.* }}` placeholders. With `template.hash`, PostHog checks that it "
- "holds the content you hold: a key or content it doesn't hold sends nothing and answers "
- "`template_outdated`, and content that newer content replaced sends the current content with a "
- "`template_superseded` warning. Or content your code rendered, named by `template_key`: PostHog sends "
- "it as is and never stores it. Transactional emails skip opt-outs and frequency caps. Send an "
- "`Idempotency-Key` header to retry safely without sending the email twice."
+ "Send one transactional email to the address in `to`, in one of two forms. A template named by its key or id:"
+ " `message_data` fills its `{{ variables.* }}` placeholders. With `template.hash`, PostHog checks that it "
+ "holds the content you hold: a key or content it doesn't hold sends nothing and answers `template_outdated`, "
+ "and content that newer content replaced sends the current content with a `template_superseded` warning. Or "
+ "content your code rendered, named by `template_key`: PostHog sends it as is and never stores it. A send by "
+ "`template.key` or `template_key` can copy more addresses with `cc` and `bcc`, and a template made in PostHog"
+ " keeps its own recipients. Transactional emails skip opt-outs and frequency caps. Send an `Idempotency-Key` "
+ "header to retry safely without sending the email twice."
),
parameters=[IDEMPOTENCY_KEY_PARAMETER],
)
@@ -451,6 +490,15 @@ def _accepted_send(self, data: dict[str, Any], *, content: RenderedContent | Non
sender=code_send_from(self.team_id, data.get("from")) if needs_sender else None,
reply_to=tuple(data.get("reply_to", ())),
capture_engagement=True if capture is None else capture,
+ cc=tuple(data.get("cc", ())),
+ bcc=tuple(data.get("bcc", ())),
+ headers=tuple(
+ EmailField(name=name, value=value) for name, value in sorted(data.get("headers", {}).items())
+ ),
+ tags=tuple(
+ EmailField(name=tag["name"], value=tag["value"])
+ for tag in sorted(data.get("tags", ()), key=lambda tag: tag["name"])
+ ),
)
@staticmethod
diff --git a/products/messaging/backend/presentation/views/send_email_fields.py b/products/messaging/backend/presentation/views/send_email_fields.py
index 15d113a84181..18b096655beb 100644
--- a/products/messaging/backend/presentation/views/send_email_fields.py
+++ b/products/messaging/backend/presentation/views/send_email_fields.py
@@ -1,14 +1,46 @@
-"""The addresses a code send takes, held to what the email pipeline accepts."""
+"""The copies, headers and tags a code send sets on its own email, with the limits the email provider holds them to."""
import re
+from collections.abc import Mapping
from typing import Any
from rest_framework import serializers
from products.messaging.backend.facade.transactional_email import SENDER_ADDRESS_MAX_LENGTH
+MAX_COPIES = 10
+MAX_HEADERS = 10
+MAX_TAGS = 10
+MAX_HEADER_NAME_LENGTH = 126
+MAX_HEADER_VALUE_LENGTH = 870
+MAX_TAG_LENGTH = 256
+
# A bare address with none of the characters that would let one entry of a comma-separated list name a second one.
PLAIN_ADDRESS = re.compile(r"[^\s@\"<>,;:()\[\]\\]+@[^\s@\"<>,;:()\[\]\\]+\.[^\s@\"<>,;:()\[\]\\]+")
+HEADER_NAME = re.compile(r"[!-9;-~]+")
+HEADER_VALUE = re.compile(r"[ -~]*")
+TAG_PART = re.compile(r"[A-Za-z0-9_-]+")
+RESERVED_TAG_PREFIX = "ph_"
+
+HEADERS_POSTHOG_SETS = frozenset(
+ {
+ "auto-submitted",
+ "bcc",
+ "cc",
+ "date",
+ "dkim-signature",
+ "from",
+ "message-id",
+ "mime-version",
+ "received",
+ "reply-to",
+ "return-path",
+ "sender",
+ "subject",
+ "to",
+ }
+)
+HEADER_PREFIXES_POSTHOG_SETS = ("content-", "x-posthog-", "x-ses-")
def plain_address(value: str) -> None:
@@ -20,3 +52,80 @@ class PlainAddressField(serializers.EmailField):
def __init__(self, **kwargs: Any) -> None:
super().__init__(**kwargs)
self.validators.append(plain_address)
+
+
+UNKNOWN_FIELD = "PostHog doesn't take this field on this route."
+
+
+class RefusesUnknownFieldsSerializer(serializers.Serializer):
+ def to_internal_value(self, data: Any) -> dict[str, Any]:
+ if isinstance(data, Mapping):
+ unknown = [field for field in data if field not in self.fields]
+ if unknown:
+ raise serializers.ValidationError({unknown[0]: UNKNOWN_FIELD})
+ return super().to_internal_value(data)
+
+
+def copies_field(*, help_text: str) -> serializers.ListField:
+ return serializers.ListField(child=PlainAddressField(), required=False, max_length=MAX_COPIES, help_text=help_text)
+
+
+class EmailTagSerializer(RefusesUnknownFieldsSerializer):
+ name = serializers.CharField(
+ max_length=MAX_TAG_LENGTH,
+ help_text="Tag name: ASCII letters, numbers, `_` and `-`. Names starting with `ph_` are PostHog's.",
+ )
+ value = serializers.CharField(
+ max_length=MAX_TAG_LENGTH, help_text="Tag value: ASCII letters, numbers, `_` and `-`."
+ )
+
+ def validate_name(self, value: str) -> str:
+ if not TAG_PART.fullmatch(value):
+ raise serializers.ValidationError("Use only ASCII letters, numbers, `_` and `-`.")
+ if value.lower().startswith(RESERVED_TAG_PREFIX):
+ raise serializers.ValidationError(f"Names starting with `{RESERVED_TAG_PREFIX}` are PostHog's. Rename it.")
+ return value
+
+ def validate_value(self, value: str) -> str:
+ if not TAG_PART.fullmatch(value):
+ raise serializers.ValidationError("Use only ASCII letters, numbers, `_` and `-`.")
+ return value
+
+
+def validate_headers(headers: dict[str, str]) -> None:
+ if len(headers) > MAX_HEADERS:
+ raise serializers.ValidationError(f"Send at most {MAX_HEADERS} headers.")
+ seen: set[str] = set()
+ for name, value in headers.items():
+ _validate_header_name(name)
+ if name.lower() in seen:
+ raise serializers.ValidationError(f"`{name}` is set twice. Header names ignore case, so keep one.")
+ seen.add(name.lower())
+ _validate_header_value(name, value)
+
+
+def _validate_header_name(name: str) -> None:
+ if len(name) > MAX_HEADER_NAME_LENGTH or not HEADER_NAME.fullmatch(name):
+ raise serializers.ValidationError(
+ f"`{name}` is not a header name. Use up to {MAX_HEADER_NAME_LENGTH} visible ASCII characters, "
+ "with no spaces or colons."
+ )
+ lowered = name.lower()
+ if lowered in HEADERS_POSTHOG_SETS or lowered.startswith(HEADER_PREFIXES_POSTHOG_SETS):
+ raise serializers.ValidationError(
+ f"PostHog sets `{name}` itself. Use the send's own fields, such as `from`, `cc` or `subject`, instead."
+ )
+
+
+def _validate_header_value(name: str, value: str) -> None:
+ if len(value) > MAX_HEADER_VALUE_LENGTH or not HEADER_VALUE.fullmatch(value):
+ raise serializers.ValidationError(
+ f"The value of `{name}` must be up to {MAX_HEADER_VALUE_LENGTH} printable ASCII characters, "
+ "with no line breaks."
+ )
+
+
+def validate_tags(tags: list[dict[str, str]]) -> None:
+ names = [tag["name"] for tag in tags]
+ if len(set(names)) != len(names):
+ raise serializers.ValidationError("Each tag name can appear once.")
diff --git a/products/messaging/backend/presentation/views/transactional_email_api.py b/products/messaging/backend/presentation/views/transactional_email_api.py
index c048406c0c3c..bde0fc0c73c9 100644
--- a/products/messaging/backend/presentation/views/transactional_email_api.py
+++ b/products/messaging/backend/presentation/views/transactional_email_api.py
@@ -167,11 +167,7 @@ def first_field_error(errors: Any, path: tuple[str, ...] = ()) -> FieldError:
class TransactionalEmailRejected(exceptions.APIException):
STATUS_BY_CODE = {"quota_limited": 429, "idempotency_key_in_flight": 409, "idempotency_key_reused": 422}
- ATTR_BY_CODE = {
- "invalid_recipient": "to",
- "recipient_suppressed": "to",
- "sender_not_verified": "from",
- }
+ ATTR_BY_CODE = {"sender_not_verified": "from"}
# The caller has to change the request. Without this the error handler reports a server error.
default_type = "invalid_request"
@@ -186,7 +182,7 @@ def body(self) -> dict[str, Any]:
"type": self.default_type,
"code": rejected.code,
"detail": rejected.detail,
- "attr": self.ATTR_BY_CODE.get(rejected.code),
+ "attr": rejected.recipient_field or self.ATTR_BY_CODE.get(rejected.code),
"docs_url": f"{TRANSACTIONAL_EMAIL_DOCS_URL}#{rejected.code}",
}
if rejected.why is not None and rejected.fix is not None:
diff --git a/products/messaging/backend/services/transactional_email.py b/products/messaging/backend/services/transactional_email.py
index 0c2e36236315..c6689d040457 100644
--- a/products/messaging/backend/services/transactional_email.py
+++ b/products/messaging/backend/services/transactional_email.py
@@ -326,7 +326,9 @@ def send_email(command: EmailSendCommand) -> EmailSendOutcome:
)
# Defense in depth: the workflow reads the recipient from an event property, which Liquid outputs as typed.
if "{" in command.to or "}" in command.to:
- return Rejected(code="invalid_recipient", detail="The recipient address can't contain { or }.")
+ return Rejected(
+ code="invalid_recipient", detail="The recipient address can't contain { or }.", recipient_field="to"
+ )
try:
hog_flow_id = _sendable_workflow_id(command.team_id, command.source)
except TransactionalEmailError as e:
@@ -352,7 +354,7 @@ def send_email(command: EmailSendCommand) -> EmailSendOutcome:
return Unavailable()
if response.status_code != 202:
- if response.status_code == 400 and (refused := _failed_send_check(response)):
+ if response.status_code == 400 and (refused := _failed_send_check(response, command.source.send)):
return refused
if response.status_code == 429:
return Rejected(code="quota_limited", detail="The project is over its email quota.")
@@ -391,14 +393,31 @@ def send_email(command: EmailSendCommand) -> EmailSendOutcome:
)
-def _failed_send_check(response: Any) -> Rejected | None:
+RECIPIENT_CHECK_CODES = frozenset({"invalid_recipient", "recipient_suppressed"})
+RECIPIENT_FIELDS = frozenset({"to", "cc", "bcc"})
+
+
+def _failed_send_check(response: Any, send: AcceptedSend | None) -> Rejected | None:
try:
body = response.json()
except ValueError:
return None
if not isinstance(body, dict) or body.get("code") not in SEND_CHECK_CODES:
return None
- return Rejected(code=body["code"], detail=str(body["detail"]), why=str(body["why"]), fix=str(body["fix"]))
+ return Rejected(
+ code=body["code"],
+ detail=str(body["detail"]),
+ why=str(body["why"]),
+ fix=str(body["fix"]),
+ recipient_field=_refused_recipient_field(body, send) if body["code"] in RECIPIENT_CHECK_CODES else None,
+ )
+
+
+def _refused_recipient_field(body: dict[str, Any], send: AcceptedSend | None) -> str | None:
+ if send is None or not (send.cc or send.bcc):
+ return "to"
+ attr = body.get("attr")
+ return attr if attr in RECIPIENT_FIELDS else None
def _sendable_workflow_id(team_id: int, source: StoredTemplate | RenderedEmail) -> UUID:
@@ -426,6 +445,14 @@ def _send_payload(send: AcceptedSend) -> dict[str, Any]:
)
if send.reply_to:
payload["replyTo"] = list(send.reply_to)
+ if send.cc:
+ payload["cc"] = list(send.cc)
+ if send.bcc:
+ payload["bcc"] = list(send.bcc)
+ if send.headers:
+ payload["headers"] = [{"name": header.name, "value": header.value} for header in send.headers]
+ if send.tags:
+ payload["tags"] = [{"name": tag.name, "value": tag.value} for tag in send.tags]
return payload
diff --git a/products/messaging/backend/tests/api/test_send_email_fields.py b/products/messaging/backend/tests/api/test_send_email_fields.py
new file mode 100644
index 000000000000..055f5ae387e7
--- /dev/null
+++ b/products/messaging/backend/tests/api/test_send_email_fields.py
@@ -0,0 +1,92 @@
+from typing import Any
+
+from django.test import SimpleTestCase
+
+from parameterized import parameterized
+
+from products.messaging.backend.presentation.views.messaging_send import SendEmailSerializer
+
+
+def rendered(**fields: Any) -> dict[str, Any]:
+ return {
+ "to": "user@example.com",
+ "template_key": "welcome",
+ "subject": "Welcome",
+ "html": "Hi
",
+ **fields,
+ }
+
+
+class TestSendEmailFields(SimpleTestCase):
+ def test_accepts_copies_headers_and_tags_on_a_code_send(self) -> None:
+ body = SendEmailSerializer(
+ data=rendered(
+ cc=["jane@example.com"],
+ bcc=["audit@example.com", "Archive@Example.com"],
+ headers={"X-Entity-Ref-ID": "order-123", "List-Unsubscribe": ""},
+ tags=[{"name": "category", "value": "password_reset"}, {"name": "plan-tier", "value": "pro"}],
+ )
+ )
+
+ assert body.is_valid(), body.errors
+ assert body.validated_data["cc"] == ["jane@example.com"]
+ assert body.validated_data["bcc"] == ["audit@example.com", "Archive@Example.com"]
+ assert body.validated_data["headers"] == {
+ "X-Entity-Ref-ID": "order-123",
+ "List-Unsubscribe": "",
+ }
+ assert body.validated_data["tags"] == [
+ {"name": "category", "value": "password_reset"},
+ {"name": "plan-tier", "value": "pro"},
+ ]
+
+ @parameterized.expand(
+ [
+ ("cc_not_an_address", {"cc": ["Jane"]}, "cc"),
+ ("cc_with_a_display_name", {"cc": ["Jane "]}, "cc"),
+ ("cc_quoted_with_a_comma", {"cc": ['"jane,admin"@example.com']}, "cc"),
+ ("bcc_over_the_limit", {"bcc": [f"user{i}@example.com" for i in range(11)]}, "bcc"),
+ ("header_posthog_sets", {"headers": {"Subject": "Hi"}}, "headers"),
+ ("header_posthog_sets_any_case", {"headers": {"content-type": "text/plain"}}, "headers"),
+ ("header_for_tracking", {"headers": {"X-PostHog-Tracking-Code": "x"}}, "headers"),
+ ("header_for_the_mail_system", {"headers": {"Return-Path": ""}}, "headers"),
+ ("header_name_with_a_colon", {"headers": {"X-Ref:": "1"}}, "headers"),
+ ("header_name_with_a_space", {"headers": {"X Ref": "1"}}, "headers"),
+ ("header_value_with_a_line_break", {"headers": {"X-Ref": "1\r\nBcc: a@example.com"}}, "headers"),
+ ("header_value_not_ascii", {"headers": {"X-Ref": "café"}}, "headers"),
+ ("header_value_too_long", {"headers": {"X-Ref": "a" * 871}}, "headers"),
+ ("header_twice_in_another_case", {"headers": {"X-Ref": "1", "x-ref": "2"}}, "headers"),
+ ("headers_over_the_limit", {"headers": {f"X-Ref-{i}": "1" for i in range(11)}}, "headers"),
+ ("tag_name_with_a_space", {"tags": [{"name": "my tag", "value": "a"}]}, "tags"),
+ ("tag_value_with_a_dot", {"tags": [{"name": "plan", "value": "pro.v2"}]}, "tags"),
+ ("tag_name_posthog_uses", {"tags": [{"name": "ph_id", "value": "a"}]}, "tags"),
+ ("tag_twice", {"tags": [{"name": "plan", "value": "a"}, {"name": "plan", "value": "b"}]}, "tags"),
+ ("tags_over_the_limit", {"tags": [{"name": f"tag{i}", "value": "a"} for i in range(11)]}, "tags"),
+ ("tag_with_an_unknown_key", {"tags": [{"name": "plan", "value": "pro", "colour": "red"}]}, "tags"),
+ ("reply_to_quoted_with_a_comma", {"reply_to": ['"a,b"@example.com']}, "reply_to"),
+ ]
+ )
+ def test_refuses_a_field_it_cannot_send(self, _name: str, fields: dict[str, Any], attr: str) -> None:
+ body = SendEmailSerializer(data=rendered(**fields))
+
+ assert not body.is_valid()
+ assert attr in body.errors, body.errors
+
+ @parameterized.expand([("cc",), ("bcc",), ("headers",), ("tags",)])
+ def test_refuses_code_send_fields_on_a_template_made_in_posthog(self, field: str) -> None:
+ values: dict[str, Any] = {
+ "cc": ["jane@example.com"],
+ "bcc": ["jane@example.com"],
+ "headers": {"X-Ref": "1"},
+ "tags": [{"name": "plan", "value": "pro"}],
+ }
+ body = SendEmailSerializer(
+ data={
+ "to": "user@example.com",
+ "template": {"id": "4c1f0c3c-8d1b-4a51-9f3a-0d8f1c0f6d2e"},
+ field: values[field],
+ }
+ )
+
+ assert not body.is_valid()
+ assert field in body.errors, body.errors
diff --git a/products/messaging/backend/tests/api/test_transactional_email.py b/products/messaging/backend/tests/api/test_transactional_email.py
index 58b8a44aa7e0..85b3b16a2279 100644
--- a/products/messaging/backend/tests/api/test_transactional_email.py
+++ b/products/messaging/backend/tests/api/test_transactional_email.py
@@ -524,6 +524,33 @@ def test_reports_a_send_the_pipeline_refuses(
reported = capture.call_args.kwargs["properties"]
assert (reported["outcome"], reported["error_code"]) == ("rejected", code)
+ @parameterized.expand(
+ [
+ ("copy_the_pipeline_names", ["copy@example.com"], {"attr": "cc"}, "cc"),
+ ("addresses_in_two_fields", ["copy@example.com"], {"attr": None}, None),
+ ("copy_and_a_pipeline_that_names_none", ["copy@example.com"], {}, None),
+ ("no_copies", [], {}, "to"),
+ ]
+ )
+ def test_a_refused_recipient_names_the_field_that_holds_it(
+ self, mock_post, _flag, _capture, _name, cc, cdp_attr, expected_attr
+ ):
+ cdp_body = {
+ "error": "recipient_suppressed",
+ "code": "recipient_suppressed",
+ "detail": "Refused by a send check.",
+ "why": "Why.",
+ "fix": "Fix it.",
+ **cdp_attr,
+ }
+ mock_post.return_value = MagicMock(status_code=400, json=lambda: cdp_body)
+ body = self._send_body("key", **({"cc": cc} if cc else {}))
+
+ response = self.client.post(self.send_email_url, body, format="json")
+
+ assert response.status_code == status.HTTP_400_BAD_REQUEST, response.json()
+ assert (response.json()["code"], response.json()["attr"]) == ("recipient_suppressed", expected_attr)
+
def _send_body(self, addressed_by: str, **fields: Any) -> dict[str, Any]:
if addressed_by == "template_key":
return self._rendered(**fields)
@@ -633,6 +660,32 @@ def test_sends_rendered_content_as_is_under_the_keys_workflow(self, mock_post, _
"tracking": {"captureEngagement": True},
},
),
+ (
+ "rendered_content_with_copies_headers_and_tags",
+ "template_key",
+ {
+ "text": "Hi",
+ "cc": ["jane@example.com"],
+ "bcc": ["audit@example.com"],
+ "headers": {"X-Entity-Ref-ID": "order-123"},
+ "tags": [{"name": "category", "value": "password_reset"}],
+ },
+ {
+ "content": {"subject": "Welcome, Jane", "html": "Hi {{ variables.name }}
", "text": "Hi"},
+ "from": {"integrationId": "default", "isDefault": True},
+ "cc": ["jane@example.com"],
+ "bcc": ["audit@example.com"],
+ "headers": [{"name": "X-Entity-Ref-ID", "value": "order-123"}],
+ "tags": [{"name": "category", "value": "password_reset"}],
+ "tracking": {"captureEngagement": True},
+ },
+ ),
+ (
+ "template_by_key_with_a_copy",
+ "key",
+ {"bcc": ["audit@example.com"]},
+ {"bcc": ["audit@example.com"], "tracking": {"captureEngagement": True}},
+ ),
(
"template_by_id_with_capture",
"id",
@@ -660,6 +713,22 @@ def test_a_send_carries_what_the_caller_set_over_its_workflow(
sent = {**sent, "from": {**sent["from"], "integrationId": sender_ids[sent["from"]["integrationId"]]}}
assert mock_post.call_args.kwargs["json"].get("send") == sent
+ def test_a_send_posts_the_same_headers_and_tags_whatever_their_order(self, mock_post, _flag, _capture):
+ self._cdp_accepts(mock_post)
+ client = self._psak_client(["hog_flow:write"])
+ plan, region = {"name": "plan", "value": "pro"}, {"name": "region", "value": "eu"}
+
+ for headers, tags in (
+ ({"X-Entity-Ref-ID": "order-123", "X-Ref": "1"}, [plan, region]),
+ ({"X-Ref": "1", "X-Entity-Ref-ID": "order-123"}, [region, plan]),
+ ):
+ body = self._send_body("template_key", headers=headers, tags=tags)
+ response = client.post(self.send_email_url, body, format="json")
+ assert response.status_code == status.HTTP_202_ACCEPTED, response.json()
+
+ first, second = (call.kwargs["json"]["send"] for call in mock_post.call_args_list)
+ assert first == second
+
@parameterized.expand(
[
("template_and_key", {"template": {"key": "password-reset"}}, "invalid_request", "template_key"),
@@ -688,6 +757,8 @@ def test_a_send_carries_what_the_caller_set_over_its_workflow(
"invalid_request",
"identifiers__email",
),
+ ("header_posthog_sets", {"headers": {"From": "a@example.com"}}, "invalid_request", "headers"),
+ ("bad_tag", {"tags": [{"name": "plan", "value": "pro.v2"}]}, "invalid_request", "tags__0__value"),
]
)
def test_refuses_a_rendered_send_it_cannot_make_with_what_to_fix(
@@ -703,10 +774,10 @@ def test_refuses_a_rendered_send_it_cannot_make_with_what_to_fix(
assert error["why"] and error["fix"]
if expected_code == "invalid_request":
# The why explains the field that failed, not what a send needs in general.
- named = {"reply_to__0": "reply_to", "identifiers__email": "identifiers.email"}.get(
- expected_attr, expected_attr
- )
+ named = {"identifiers__email": "identifiers.email"}.get(expected_attr, expected_attr.split("__")[0])
assert f"`{named}`" in error["why"], error["why"]
+ unknown = expected_attr in ("scheduled_at", "identifiers__email")
+ assert ("doesn't know" in error["why"]) == unknown, error["why"]
if expected_attr == "to":
assert error["detail"] == f"to: `{fields['to']}` is not a bare address. Send it as `jane@example.com`."
mock_post.assert_not_called()
diff --git a/products/messaging/frontend/generated/api.schemas.ts b/products/messaging/frontend/generated/api.schemas.ts
index 621fb202a90a..8f9ccc65159d 100644
--- a/products/messaging/frontend/generated/api.schemas.ts
+++ b/products/messaging/frontend/generated/api.schemas.ts
@@ -12,6 +12,11 @@
*/
export type SendEmailApiMessageData = { [key: string]: unknown }
+/**
+ * Extra email headers by name, such as `X-Entity-Ref-ID` or `List-Unsubscribe`, at most 10. Values are printable ASCII up to 870 characters. Headers PostHog sets itself, such as `From`, `Subject` and `Content-Type`, are refused. Not with `template.id`.
+ */
+export type SendEmailApiHeaders = { [key: string]: string }
+
export interface SendIdentifiersApi {
/**
* Distinct ID of the recipient's person. The email's engagement events (sent, delivered, opened, clicked, bounced) and the send itself are recorded against that person. Defaults to the recipient's email address. The template can't read the person's properties, so pass everything it needs in `message_data`.
@@ -30,6 +35,19 @@ export interface TemplateReferenceApi {
id?: string
}
+export interface EmailTagApi {
+ /**
+ * Tag name: ASCII letters, numbers, `_` and `-`. Names starting with `ph_` are PostHog's.
+ * @maxLength 256
+ */
+ name: string
+ /**
+ * Tag value: ASCII letters, numbers, `_` and `-`.
+ * @maxLength 256
+ */
+ value: string
+}
+
export interface SendEmailApi {
/** Email address of the recipient, as `jane@example.com`. */
to: string
@@ -52,6 +70,23 @@ export interface SendEmailApi {
* @maxItems 10
*/
reply_to?: string[]
+ /**
+ * Addresses that get a visible copy, at most 10. Each one is checked against the suppression list, and one suppressed address stops the send. Not with `template.id`.
+ * @maxItems 10
+ */
+ cc?: string[]
+ /**
+ * Addresses that get a hidden copy, at most 10. Each one is checked against the suppression list, and one suppressed address stops the send. Not with `template.id`.
+ * @maxItems 10
+ */
+ bcc?: string[]
+ /** Extra email headers by name, such as `X-Entity-Ref-ID` or `List-Unsubscribe`, at most 10. Values are printable ASCII up to 870 characters. Headers PostHog sets itself, such as `From`, `Subject` and `Content-Type`, are refused. Not with `template.id`. */
+ headers?: SendEmailApiHeaders
+ /**
+ * Name and value pairs, at most 10, that PostHog adds to the email's engagement events as `$email_tags`. Not with `template.id`.
+ * @maxItems 10
+ */
+ tags?: EmailTagApi[]
/** Whether the email's engagement events (sent, delivered, opened, clicked, bounced) are captured as analytics events of the recipient. They count as analytics events. Defaults to on for `template_key` and `template.key` sends, and to the project's setting for `template.id` sends. */
capture_engagement?: boolean
/** Sender, as `security@example.com` or `Example `, on a verified domain of the project. Without it, rendered content goes out from the project's default sender, and a template from its own sender. Not with `template.id`. */
diff --git a/products/messaging/frontend/generated/api.ts b/products/messaging/frontend/generated/api.ts
index 6d6bd4364d71..5e8fdac27669 100644
--- a/products/messaging/frontend/generated/api.ts
+++ b/products/messaging/frontend/generated/api.ts
@@ -64,7 +64,7 @@ export const getMessagingSendEmailCreateUrl = (projectId: string) => {
}
/**
- * Send one transactional email to one recipient, in one of two forms. A template named by its key or id: `message_data` fills its `{{ variables.* }}` placeholders. With `template.hash`, PostHog checks that it holds the content you hold: a key or content it doesn't hold sends nothing and answers `template_outdated`, and content that newer content replaced sends the current content with a `template_superseded` warning. Or content your code rendered, named by `template_key`: PostHog sends it as is and never stores it. Transactional emails skip opt-outs and frequency caps. Send an `Idempotency-Key` header to retry safely without sending the email twice.
+ * Send one transactional email to the address in `to`, in one of two forms. A template named by its key or id: `message_data` fills its `{{ variables.* }}` placeholders. With `template.hash`, PostHog checks that it holds the content you hold: a key or content it doesn't hold sends nothing and answers `template_outdated`, and content that newer content replaced sends the current content with a `template_superseded` warning. Or content your code rendered, named by `template_key`: PostHog sends it as is and never stores it. A send by `template.key` or `template_key` can copy more addresses with `cc` and `bcc`, and a template made in PostHog keeps its own recipients. Transactional emails skip opt-outs and frequency caps. Send an `Idempotency-Key` header to retry safely without sending the email twice.
* @summary Send a transactional email
*/
export const messagingSendEmailCreate = async (
diff --git a/products/messaging/frontend/generated/api.zod.ts b/products/messaging/frontend/generated/api.zod.ts
index 56612184c0e9..14803b117560 100644
--- a/products/messaging/frontend/generated/api.zod.ts
+++ b/products/messaging/frontend/generated/api.zod.ts
@@ -10,13 +10,23 @@
import * as zod from 'zod'
/**
- * Send one transactional email to one recipient, in one of two forms. A template named by its key or id: `message_data` fills its `{{ variables.* }}` placeholders. With `template.hash`, PostHog checks that it holds the content you hold: a key or content it doesn't hold sends nothing and answers `template_outdated`, and content that newer content replaced sends the current content with a `template_superseded` warning. Or content your code rendered, named by `template_key`: PostHog sends it as is and never stores it. Transactional emails skip opt-outs and frequency caps. Send an `Idempotency-Key` header to retry safely without sending the email twice.
+ * Send one transactional email to the address in `to`, in one of two forms. A template named by its key or id: `message_data` fills its `{{ variables.* }}` placeholders. With `template.hash`, PostHog checks that it holds the content you hold: a key or content it doesn't hold sends nothing and answers `template_outdated`, and content that newer content replaced sends the current content with a `template_superseded` warning. Or content your code rendered, named by `template_key`: PostHog sends it as is and never stores it. A send by `template.key` or `template_key` can copy more addresses with `cc` and `bcc`, and a template made in PostHog keeps its own recipients. Transactional emails skip opt-outs and frequency caps. Send an `Idempotency-Key` header to retry safely without sending the email twice.
* @summary Send a transactional email
*/
export const messagingSendEmailCreateBodyIdentifiersOneDistinctIdMax = 400
export const messagingSendEmailCreateBodyReplyToMax = 10
+export const messagingSendEmailCreateBodyCcMax = 10
+
+export const messagingSendEmailCreateBodyBccMax = 10
+
+export const messagingSendEmailCreateBodyTagsItemNameMax = 256
+
+export const messagingSendEmailCreateBodyTagsItemValueMax = 256
+
+export const messagingSendEmailCreateBodyTagsMax = 10
+
export const MessagingSendEmailCreateBody = /* @__PURE__ */ zod.object({
to: zod.email().describe('Email address of the recipient, as `jane@example.com`.'),
identifiers: zod
@@ -79,6 +89,46 @@ export const MessagingSendEmailCreateBody = /* @__PURE__ */ zod.object({
.max(messagingSendEmailCreateBodyReplyToMax)
.optional()
.describe('Addresses replies go to. Not with `template.id`.'),
+ cc: zod
+ .array(zod.email())
+ .max(messagingSendEmailCreateBodyCcMax)
+ .optional()
+ .describe(
+ 'Addresses that get a visible copy, at most 10. Each one is checked against the suppression list, and one suppressed address stops the send. Not with `template.id`.'
+ ),
+ bcc: zod
+ .array(zod.email())
+ .max(messagingSendEmailCreateBodyBccMax)
+ .optional()
+ .describe(
+ 'Addresses that get a hidden copy, at most 10. Each one is checked against the suppression list, and one suppressed address stops the send. Not with `template.id`.'
+ ),
+ headers: zod
+ .record(zod.string(), zod.string())
+ .optional()
+ .describe(
+ 'Extra email headers by name, such as `X-Entity-Ref-ID` or `List-Unsubscribe`, at most 10. Values are printable ASCII up to 870 characters. Headers PostHog sets itself, such as `From`, `Subject` and `Content-Type`, are refused. Not with `template.id`.'
+ ),
+ tags: zod
+ .array(
+ zod.object({
+ name: zod
+ .string()
+ .max(messagingSendEmailCreateBodyTagsItemNameMax)
+ .describe(
+ "Tag name: ASCII letters, numbers, `_` and `-`. Names starting with `ph_` are PostHog's."
+ ),
+ value: zod
+ .string()
+ .max(messagingSendEmailCreateBodyTagsItemValueMax)
+ .describe('Tag value: ASCII letters, numbers, `_` and `-`.'),
+ })
+ )
+ .max(messagingSendEmailCreateBodyTagsMax)
+ .optional()
+ .describe(
+ "Name and value pairs, at most 10, that PostHog adds to the email's engagement events as `$email_tags`. Not with `template.id`."
+ ),
capture_engagement: zod
.boolean()
.optional()
diff --git a/products/workflows/mcp/email_templates.yaml b/products/workflows/mcp/email_templates.yaml
index 84725f1666d7..63f1aff08758 100644
--- a/products/workflows/mcp/email_templates.yaml
+++ b/products/workflows/mcp/email_templates.yaml
@@ -189,8 +189,9 @@ tools:
idempotent: false
title: Send transactional email
description: >
- Send one transactional email to one recipient from a template, named by template.key (as uploaded with
- workflows-upload-email-template) or template.id (a template made in PostHog). Put every value the
+ Send one transactional email to the address in to from a template, named by template.key (as uploaded with
+ workflows-upload-email-template) or template.id (a template made in PostHog). A template.key send can copy
+ more addresses with cc and bcc; a template.id send keeps the template's own recipients. Put every value the
template needs into message_data, read as {{ variables.* }}; the template can't read person properties.
Set identifiers.distinct_id to record the email on that person. Pass template.hash, the content_hash of the
last upload, to check that PostHog holds that content: template_outdated means nothing was sent, so upload
diff --git a/services/mcp/schema/generated-tool-definitions.json b/services/mcp/schema/generated-tool-definitions.json
index b654995555e6..25ffcf1a9bdd 100644
--- a/services/mcp/schema/generated-tool-definitions.json
+++ b/services/mcp/schema/generated-tool-definitions.json
@@ -16542,7 +16542,7 @@
}
},
"workflows-send-email": {
- "description": "Send one transactional email to one recipient from a template, named by template.key (as uploaded with workflows-upload-email-template) or template.id (a template made in PostHog). Put every value the template needs into message_data, read as {{ variables.* }}; the template can't read person properties. Set identifiers.distinct_id to record the email on that person. Pass template.hash, the content_hash of the last upload, to check that PostHog holds that content: template_outdated means nothing was sent, so upload the template and send again; a template_superseded warning means newer content was sent. Each call sends an email, so never repeat a call that succeeded. A plain 404 without a code means the transactional email API is not enabled for this project: stop and tell the user.",
+ "description": "Send one transactional email to the address in to from a template, named by template.key (as uploaded with workflows-upload-email-template) or template.id (a template made in PostHog). A template.key send can copy more addresses with cc and bcc; a template.id send keeps the template's own recipients. Put every value the template needs into message_data, read as {{ variables.* }}; the template can't read person properties. Set identifiers.distinct_id to record the email on that person. Pass template.hash, the content_hash of the last upload, to check that PostHog holds that content: template_outdated means nothing was sent, so upload the template and send again; a template_superseded warning means newer content was sent. Each call sends an email, so never repeat a call that succeeded. A plain 404 without a code means the transactional email API is not enabled for this project: stop and tell the user.",
"category": "Workflows",
"feature": "workflows",
"summary": "Send transactional email",
diff --git a/services/mcp/schema/tool-definitions-all.json b/services/mcp/schema/tool-definitions-all.json
index 4c55425bf742..901fbd2256f9 100644
--- a/services/mcp/schema/tool-definitions-all.json
+++ b/services/mcp/schema/tool-definitions-all.json
@@ -17359,7 +17359,7 @@
}
},
"workflows-send-email": {
- "description": "Send one transactional email to one recipient from a template, named by template.key (as uploaded with workflows-upload-email-template) or template.id (a template made in PostHog). Put every value the template needs into message_data, read as {{ variables.* }}; the template can't read person properties. Set identifiers.distinct_id to record the email on that person. Pass template.hash, the content_hash of the last upload, to check that PostHog holds that content: template_outdated means nothing was sent, so upload the template and send again; a template_superseded warning means newer content was sent. Each call sends an email, so never repeat a call that succeeded. A plain 404 without a code means the transactional email API is not enabled for this project: stop and tell the user.",
+ "description": "Send one transactional email to the address in to from a template, named by template.key (as uploaded with workflows-upload-email-template) or template.id (a template made in PostHog). A template.key send can copy more addresses with cc and bcc; a template.id send keeps the template's own recipients. Put every value the template needs into message_data, read as {{ variables.* }}; the template can't read person properties. Set identifiers.distinct_id to record the email on that person. Pass template.hash, the content_hash of the last upload, to check that PostHog holds that content: template_outdated means nothing was sent, so upload the template and send again; a template_superseded warning means newer content was sent. Each call sends an email, so never repeat a call that succeeded. A plain 404 without a code means the transactional email API is not enabled for this project: stop and tell the user.",
"category": "Workflows",
"feature": "workflows",
"summary": "Send transactional email",
diff --git a/services/mcp/src/api/generated.ts b/services/mcp/src/api/generated.ts
index 4e15135474d4..014e03963367 100644
--- a/services/mcp/src/api/generated.ts
+++ b/services/mcp/src/api/generated.ts
@@ -40015,6 +40015,19 @@ export namespace Schemas {
readonly email_sending_suspension_reason: string;
}
+ export interface EmailTag {
+ /**
+ * Tag name: ASCII letters, numbers, `_` and `-`. Names starting with `ph_` are PostHog's.
+ * @maxLength 256
+ */
+ name: string;
+ /**
+ * Tag value: ASCII letters, numbers, `_` and `-`.
+ * @maxLength 256
+ */
+ value: string;
+ }
+
/**
* Highest htmlID suffix per element type, e.g. {"u_row": 1, "u_content_text": 2}.
*/
@@ -105817,6 +105830,11 @@ export namespace Schemas {
*/
export type SendEmailMessageData = {[key: string]: unknown};
+ /**
+ * Extra email headers by name, such as `X-Entity-Ref-ID` or `List-Unsubscribe`, at most 10. Values are printable ASCII up to 870 characters. Headers PostHog sets itself, such as `From`, `Subject` and `Content-Type`, are refused. Not with `template.id`.
+ */
+ export type SendEmailHeaders = {[key: string]: string};
+
export interface SendIdentifiers {
/**
* Distinct ID of the recipient's person. The email's engagement events (sent, delivered, opened, clicked, bounced) and the send itself are recorded against that person. Defaults to the recipient's email address. The template can't read the person's properties, so pass everything it needs in `message_data`.
@@ -105857,6 +105875,23 @@ export namespace Schemas {
* @maxItems 10
*/
reply_to?: string[];
+ /**
+ * Addresses that get a visible copy, at most 10. Each one is checked against the suppression list, and one suppressed address stops the send. Not with `template.id`.
+ * @maxItems 10
+ */
+ cc?: string[];
+ /**
+ * Addresses that get a hidden copy, at most 10. Each one is checked against the suppression list, and one suppressed address stops the send. Not with `template.id`.
+ * @maxItems 10
+ */
+ bcc?: string[];
+ /** Extra email headers by name, such as `X-Entity-Ref-ID` or `List-Unsubscribe`, at most 10. Values are printable ASCII up to 870 characters. Headers PostHog sets itself, such as `From`, `Subject` and `Content-Type`, are refused. Not with `template.id`. */
+ headers?: SendEmailHeaders;
+ /**
+ * Name and value pairs, at most 10, that PostHog adds to the email's engagement events as `$email_tags`. Not with `template.id`.
+ * @maxItems 10
+ */
+ tags?: EmailTag[];
/** Whether the email's engagement events (sent, delivered, opened, clicked, bounced) are captured as analytics events of the recipient. They count as analytics events. Defaults to on for `template_key` and `template.key` sends, and to the project's setting for `template.id` sends. */
capture_engagement?: boolean;
/** Sender, as `security@example.com` or `Example `, on a verified domain of the project. Without it, rendered content goes out from the project's default sender, and a template from its own sender. Not with `template.id`. */
diff --git a/services/mcp/src/generated/email_templates/api.ts b/services/mcp/src/generated/email_templates/api.ts
index 0bfee6c9f417..c08aa7755153 100644
--- a/services/mcp/src/generated/email_templates/api.ts
+++ b/services/mcp/src/generated/email_templates/api.ts
@@ -9,7 +9,7 @@
import * as zod from 'zod'
/**
- * Send one transactional email to one recipient, in one of two forms. A template named by its key or id: `message_data` fills its `{{ variables.* }}` placeholders. With `template.hash`, PostHog checks that it holds the content you hold: a key or content it doesn't hold sends nothing and answers `template_outdated`, and content that newer content replaced sends the current content with a `template_superseded` warning. Or content your code rendered, named by `template_key`: PostHog sends it as is and never stores it. Transactional emails skip opt-outs and frequency caps. Send an `Idempotency-Key` header to retry safely without sending the email twice.
+ * Send one transactional email to the address in `to`, in one of two forms. A template named by its key or id: `message_data` fills its `{{ variables.* }}` placeholders. With `template.hash`, PostHog checks that it holds the content you hold: a key or content it doesn't hold sends nothing and answers `template_outdated`, and content that newer content replaced sends the current content with a `template_superseded` warning. Or content your code rendered, named by `template_key`: PostHog sends it as is and never stores it. A send by `template.key` or `template_key` can copy more addresses with `cc` and `bcc`, and a template made in PostHog keeps its own recipients. Transactional emails skip opt-outs and frequency caps. Send an `Idempotency-Key` header to retry safely without sending the email twice.
* @summary Send a transactional email
*/
export const MessagingSendEmailCreateParams = () => zod.object({
@@ -33,6 +33,16 @@ export const messagingSendEmailCreateBodyIdentifiersOneDistinctIdMax = 400
export const messagingSendEmailCreateBodyReplyToMax = 10
+export const messagingSendEmailCreateBodyCcMax = 10
+
+export const messagingSendEmailCreateBodyBccMax = 10
+
+export const messagingSendEmailCreateBodyTagsItemNameMax = 256
+
+export const messagingSendEmailCreateBodyTagsItemValueMax = 256
+
+export const messagingSendEmailCreateBodyTagsMax = 10
+
export const MessagingSendEmailCreateBody = () => zod.object({
to: zod.email().describe('Email address of the recipient, as `jane@example.com`.'),
identifiers: zod
@@ -95,6 +105,46 @@ export const MessagingSendEmailCreateBody = () => zod.object({
.max(messagingSendEmailCreateBodyReplyToMax)
.optional()
.describe('Addresses replies go to. Not with `template.id`.'),
+ cc: zod
+ .array(zod.email())
+ .max(messagingSendEmailCreateBodyCcMax)
+ .optional()
+ .describe(
+ 'Addresses that get a visible copy, at most 10. Each one is checked against the suppression list, and one suppressed address stops the send. Not with `template.id`.'
+ ),
+ bcc: zod
+ .array(zod.email())
+ .max(messagingSendEmailCreateBodyBccMax)
+ .optional()
+ .describe(
+ 'Addresses that get a hidden copy, at most 10. Each one is checked against the suppression list, and one suppressed address stops the send. Not with `template.id`.'
+ ),
+ headers: zod
+ .record(zod.string(), zod.string())
+ .optional()
+ .describe(
+ 'Extra email headers by name, such as `X-Entity-Ref-ID` or `List-Unsubscribe`, at most 10. Values are printable ASCII up to 870 characters. Headers PostHog sets itself, such as `From`, `Subject` and `Content-Type`, are refused. Not with `template.id`.'
+ ),
+ tags: zod
+ .array(
+ zod.object({
+ name: zod
+ .string()
+ .max(messagingSendEmailCreateBodyTagsItemNameMax)
+ .describe(
+ "Tag name: ASCII letters, numbers, `_` and `-`. Names starting with `ph_` are PostHog's."
+ ),
+ value: zod
+ .string()
+ .max(messagingSendEmailCreateBodyTagsItemValueMax)
+ .describe('Tag value: ASCII letters, numbers, `_` and `-`.'),
+ })
+ )
+ .max(messagingSendEmailCreateBodyTagsMax)
+ .optional()
+ .describe(
+ "Name and value pairs, at most 10, that PostHog adds to the email's engagement events as `$email_tags`. Not with `template.id`."
+ ),
capture_engagement: zod
.boolean()
.optional()
diff --git a/services/mcp/src/tools/generated/email_templates.ts b/services/mcp/src/tools/generated/email_templates.ts
index d9357252ca45..6d9d4a48b0d3 100644
--- a/services/mcp/src/tools/generated/email_templates.ts
+++ b/services/mcp/src/tools/generated/email_templates.ts
@@ -349,6 +349,18 @@ const workflowsSendEmail = (): ToolBase`, on a verified domain of the project. Without it, rendered content goes out from the project's default sender, and a template from its own sender. Not with `template.id`.",
"type": "string"
},
+ "headers": {
+ "additionalProperties": {
+ "type": "string"
+ },
+ "description": "Extra email headers by name, such as `X-Entity-Ref-ID` or `List-Unsubscribe`, at most 10. Values are printable ASCII up to 870 characters. Headers PostHog sets itself, such as `From`, `Subject` and `Content-Type`, are refused. Not with `template.id`.",
+ "propertyNames": {
+ "type": "string"
+ },
+ "type": "object"
+ },
"html": {
"description": "HTML body of the rendered email. Only with `template_key`.",
"type": "string"
@@ -53,6 +83,27 @@
"description": "Subject of the rendered email. Only with `template_key`.",
"type": "string"
},
+ "tags": {
+ "description": "Name and value pairs, at most 10, that PostHog adds to the email's engagement events as `$email_tags`. Not with `template.id`.",
+ "items": {
+ "properties": {
+ "name": {
+ "description": "Tag name: ASCII letters, numbers, `_` and `-`. Names starting with `ph_` are PostHog's.",
+ "maxLength": 256,
+ "type": "string"
+ },
+ "value": {
+ "description": "Tag value: ASCII letters, numbers, `_` and `-`.",
+ "maxLength": 256,
+ "type": "string"
+ }
+ },
+ "required": ["name", "value"],
+ "type": "object"
+ },
+ "maxItems": 10,
+ "type": "array"
+ },
"template": {
"description": "The template to send, by key or by id. Leave it out to send content your code rendered, with `template_key`.",
"properties": {