From 081f633daf60caef084f26f91bd9f69d7e44e5e3 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 22 Oct 2026 04:21:34 +0000 Subject: [PATCH 01/17] feat(messaging): send cc, bcc, headers and tags with a code send A code send on messaging/send/email/ takes cc, bcc, headers and tags. Django checks them and passes them to cdp-api on the accepted send. The worker lays them over the email step, sends headers and tags to SES and headers to maildev, and adds the tags to the sent event. The SES webhook adds the send's own tags to engagement events as $email_tags. Co-Authored-By: Claude Opus 5.5 --- nodejs/src/cdp/schema/cyclotron.test.ts | 18 ++++ nodejs/src/cdp/schema/cyclotron.ts | 4 + nodejs/src/cdp/schema/emailFields.ts | 52 ++++++++++ .../hogflows/hogflow-executor.service.test.ts | 25 +++++ .../hogflows/hogflow-functions.service.ts | 4 + .../services/hogflows/transactional-email.ts | 12 ++- .../services/messaging/email.service.test.ts | 47 ++++++++- .../cdp/services/messaging/email.service.ts | 20 +++- .../services/messaging/helpers/ses.test.ts | 25 +++++ .../src/cdp/services/messaging/helpers/ses.ts | 12 +++ .../messaging/backend/facade/contracts.py | 12 +++ .../backend/facade/transactional_email.py | 2 + .../presentation/views/messaging_send.py | 48 ++++++++- .../presentation/views/send_email_fields.py | 98 ++++++++++++++++++- .../backend/services/transactional_email.py | 8 ++ .../tests/api/test_send_email_fields.py | 90 +++++++++++++++++ .../tests/api/test_transactional_email.py | 28 ++++++ 17 files changed, 495 insertions(+), 10 deletions(-) create mode 100644 products/messaging/backend/tests/api/test_send_email_fields.py 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..48c468292f5d 100644 --- a/nodejs/src/cdp/schema/emailFields.ts +++ b/nodejs/src/cdp/schema/emailFields.ts @@ -1,7 +1,59 @@ 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_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..44df48a48380 100644 --- a/nodejs/src/cdp/services/hogflows/transactional-email.ts +++ b/nodejs/src/cdp/services/hogflows/transactional-email.ts @@ -1,7 +1,13 @@ import { Counter } from 'prom-client' import { z } from 'zod' -import { BareEmailAddressSchema } from '~/cdp/schema/emailFields' +import { + BareEmailAddressSchema, + EmailHeaderSchema, + EmailTagSchema, + 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 +40,10 @@ export const AcceptedSendSchema = z .readonly() .optional(), replyTo: z.array(z.string()).readonly().optional(), + cc: z.array(z.string().email()).max(10).readonly().optional(), + bcc: z.array(z.string().email()).max(10).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() diff --git a/nodejs/src/cdp/services/messaging/email.service.test.ts b/nodejs/src/cdp/services/messaging/email.service.test.ts index bec798495ba0..2ce73f6df683 100644 --- a/nodejs/src/cdp/services/messaging/email.service.test.ts +++ b/nodejs/src/cdp/services/messaging/email.service.test.ts @@ -989,6 +989,17 @@ 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' }], + }) + 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' }) + }) it('should include tracking code in the email with distinct_id', async () => { invocation.queueParameters = createEmailParams({ html: 'Hi! Click me', @@ -1538,6 +1549,28 @@ 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 } + expect(sentCommand.input.Content.Simple.Headers.map((h: { Name: string }) => h.Name)).toEqual([ + 'X-Entity-Ref-ID', + 'X-PostHog-Tracking-Code', + 'Auto-Submitted', + ]) + expect(sentCommand.input.Content.Simple.Headers[0]).toEqual({ Name: 'X-Entity-Ref-ID', Value: 'order-123' }) + expect(sentCommand.input.EmailTags.map((t: { Name: string }) => t.Name)).toEqual(['ph_id', 'category']) + expect(sentCommand.input.EmailTags[1]).toEqual({ 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 +1829,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 +1861,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..e7aa1c3a94b6 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,10 @@ 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! }, + headers: { + ...Object.fromEntries((params.headers ?? []).map((header) => [header.name, header.value])), + [AUTO_SUBMITTED_HEADER.Name!]: AUTO_SUBMITTED_HEADER.Value!, + }, ...(params.html ? { html: trackingEnabled @@ -1075,6 +1084,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 +1115,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..13212a5a37e7 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): 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..dd9128fff974 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,17 @@ 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, + EmailTagSerializer, + PlainAddressField, + copies_field, + validate_headers, + validate_tags, +) from products.messaging.backend.presentation.views.transactional_email_api import ( IDEMPOTENCY_KEY_PARAMETER, TRANSACTIONAL_EMAIL_THROTTLES, @@ -83,6 +94,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 +147,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 +228,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): @@ -451,6 +489,10 @@ 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 data.get("headers", {}).items()), + tags=tuple(EmailField(name=tag["name"], value=tag["value"]) for tag in data.get("tags", ())), ) @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..84266a41bd07 100644 --- a/products/messaging/backend/presentation/views/send_email_fields.py +++ b/products/messaging/backend/presentation/views/send_email_fields.py @@ -1,4 +1,4 @@ -"""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 typing import Any @@ -7,8 +7,39 @@ 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 +51,68 @@ class PlainAddressField(serializers.EmailField): def __init__(self, **kwargs: Any) -> None: super().__init__(**kwargs) self.validators.append(plain_address) + + +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(serializers.Serializer): + 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/services/transactional_email.py b/products/messaging/backend/services/transactional_email.py index 0c2e36236315..1cc089357a43 100644 --- a/products/messaging/backend/services/transactional_email.py +++ b/products/messaging/backend/services/transactional_email.py @@ -426,6 +426,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..1cb6eea74c9a --- /dev/null +++ b/products/messaging/backend/tests/api/test_send_email_fields.py @@ -0,0 +1,90 @@ +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"), + ] + ) + 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..2a487b80e007 100644 --- a/products/messaging/backend/tests/api/test_transactional_email.py +++ b/products/messaging/backend/tests/api/test_transactional_email.py @@ -633,6 +633,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"}, + "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", @@ -688,6 +714,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( From 795a02d964eb82b68dec6f272bb6a308c9e58d38 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 22 Oct 2026 04:53:05 +0000 Subject: [PATCH 02/17] chore(messaging): regenerate the API types for cc, bcc, headers and tags Co-Authored-By: Claude Opus 5.5 --- .../messaging/frontend/generated/api.zod.ts | 50 ++++++++++++++++++ .../mcp/src/generated/email_templates/api.ts | 50 ++++++++++++++++++ .../src/tools/generated/email_templates.ts | 12 +++++ .../tool-schemas/workflows-send-email.json | 51 +++++++++++++++++++ 4 files changed, 163 insertions(+) diff --git a/products/messaging/frontend/generated/api.zod.ts b/products/messaging/frontend/generated/api.zod.ts index 56612184c0e9..614777296a2d 100644 --- a/products/messaging/frontend/generated/api.zod.ts +++ b/products/messaging/frontend/generated/api.zod.ts @@ -17,6 +17,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 = /* @__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/services/mcp/src/generated/email_templates/api.ts b/services/mcp/src/generated/email_templates/api.ts index 0bfee6c9f417..a88ec1b75a73 100644 --- a/services/mcp/src/generated/email_templates/api.ts +++ b/services/mcp/src/generated/email_templates/api.ts @@ -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