Skip to content

feat(messaging): send cc, bcc, headers and tags with a code send - #558

Draft
Silthus wants to merge 17 commits into
feat/email-send-route-idempotencyfrom
feat/email-send-fields
Draft

Silthus wants to merge 17 commits into
feat/email-send-route-idempotencyfrom
feat/email-send-fields

Conversation

@Silthus

@Silthus Silthus commented Oct 23, 2026 •

Copy link
Copy Markdown
Owner

Moved here from #121851, closed on 2026-10-25 so this fork stack is the only copy. It opens upstream again after review.

Problem

  • A team moving from Resend keeps every call site that sets cc, bcc, custom headers or tags on Resend, because PostHog's code-form send cannot carry them. The migration never finishes (Silthus/posthog#543).
  • Map: Silthus/posthog#461, slice 1 of strand Silthus/posthog#543. Contract: #120921. An amendment for this slice and the next waits for approval before it is posted there.

Impact

Pays into Grow Workflows through billed workflows_emails actions from orgs that send by code (not measured yet: needs send attribution, #120628, merged and an insight of code sends per org). In scope: Messaging, email API sends. Expected move: small, up. It removes a reason a Resend call site stays on Resend; the larger move comes from the send route and the SDK code form. Run cost: n/a, the same queue and worker. Each cc and bcc address uses one unit of the sending cap, as to does.

Merge Danger

Warning

🟡 Merge risk: medium · two-way door (merge-risk rubric)

  • Caller headers and copies now reach real sends through SES and maildev. A header PostHog sets itself, or a line break in a value, could change who gets the email or how it is tracked.
  • Django refuses those with a 400, and the worker checks them again in its zod schema, so an unsafe header fails closed from any path. The send route sits behind workflows-transactional-email-api, and no SDK sends these fields yet.
  • Rollout precondition: the flag stays off until tracking code v3 minting is on and every stack PR has deployed on Django, cdp-api, the hogflow worker and the email worker (#121272 has the reason). An older cdp-api or worker drops the new fields and still answers 202, as a rollback after the flag turns on would.

Changes

Kind Files Lines
Production 15 +364 −42
Tests 7 +343 −15
Generated types and snapshots 9 +238 −5
Total (this layer) 31 +945 −62

What a caller can now do

  • The code form of POST /api/projects/@current/messaging/send/email/ takes cc, bcc, headers and tags. All four work with rendered content and with a local template by key.
  • template.id refuses all four with a 400 that says why: a template made in PostHog sends with its own settings.
  • tags reach the email's engagement events as $email_tags, an object of name to value. $workflows_email_sent and $workflows_email_failed carry them too.
  • A suppressed cc or bcc address answers 400 recipient_suppressed before the 202, as a suppressed to does, with attr naming cc or bcc. Nothing is queued.
  • The same Idempotency-Key with another cc, bcc, header or tag answers 422 idempotency_key_reused. The same headers in another order replay.
  • A tag with a key other than name and value answers 400 invalid_request on tags.
  • A refused cc, bcc, header or tag carries a why that explains that field.

Limits

Field Limit Refused, with a fix in detail
cc, bcc 10 each Anything but a bare address. A display name, quotes or a comma could otherwise smuggle in a second recipient
headers 10 A header PostHog sets (From, To, Cc, Bcc, Reply-To, Subject, Date, Message-ID, MIME-Version, Sender, Return-Path, Received, DKIM-Signature, Auto-Submitted, any Content-*, X-PostHog-*, X-SES-*). A name outside SES's rules (visible ASCII, no colon, 126 characters). A value with line breaks or non-ASCII, or over 870 characters. A name set twice in another case
tags 10 Characters other than [A-Za-z0-9_-], over 256. A name that starts with ph_, which PostHog uses. A name used twice

List-Unsubscribe, In-Reply-To and References stay allowed, so a caller can thread replies and offer its own unsubscribe link.

How it works

  • Django adds the four fields to the send it hands cdp-api, which stores it on the run as state.send.
  • The worker lays them over the email step's inputs, next to the content and sender from #121272.
  • SES gets copies in Destination, headers first in Simple.Headers (PostHog's unsubscribe, tracking and Auto-Submitted headers follow), and tags after ph_id in EmailTags.
  • The SES webhook copies the send's own tags onto engagement events, without PostHog's and SES's tags.
  • cdp-api counts each cc and bcc address as a recipient of the checks before the 202. The worker still skips the whole send on a suppressed address, and each address uses one unit of the sending cap.
  • The copies, headers and tags live inside send, so they are part of the idempotency fingerprint with no fingerprint change.
  • Django sorts the headers and the tags by name before it builds send, so the fingerprint does not depend on the caller's order.

Mechanical: the regenerated OpenAPI types and MCP tool-schema snapshots.

Not in this PR: attachments (the next slice), the SDK parameters (lanes Silthus/posthog#536 and Silthus/posthog#537).

How did you test this code?

Test seams:

  • The send serializer, without a database, for every limit.
  • The send route at the HTTP boundary, with cdp-api faked at its HTTP client.
  • The hog flow executor with a function_email step, for the overlay.
  • EmailService against a faked SES client and real maildev, and the SES webhook.
  • The email queue's zod schema, for the worker's own header check.

Test rationale: what each new test catches:

  • Twenty refusals at the serializer, one per limit above, and each field refused with template.id.
  • The route hands cdp-api the copies, headers and tags it accepted, for rendered content and for a template by key.
  • The executor puts the caller's copies, headers and tags over the step.
  • SES gets the caller's headers before PostHog's, and the caller's tags after ph_id. Maildev gets the headers, including ones named key and value, which nodemailer would otherwise read as one header.
  • The webhook adds the send's tags as $email_tags and drops ph_* and ses:* tags. The sent event carries $email_tags.
  • The queue schema refuses a header PostHog sets and a value with a line break.
  • cdp-api queues a copy Django accepts, such as archive=orders@example.com, and refuses a copy that names two recipients.
  • cdp-api refuses a send with a suppressed cc before it queues.
  • cdp-api answers 422 idempotency_key_reused for a key reused with another cc, bcc, header or tag.
  • Each refused cc, bcc, header and tag has a why that names its field, not the unknown-field why.
  • The route posts the same send for the same headers, and for the same tags, in two orders.
  • The route refuses a tag with an unknown key, and a reply_to quoted with a comma.

Each new test failed without its production change. Local gate at the stack top 987b927630e, which holds this head unchanged, all green: pytest 1764, CDP jest 3690 (1 skipped), cdp-api serial 288, MCP 5035, repo-wide mypy, nodejs tsc, and the rest of the gate. The pre-push preflight ran in strict mode with 0 failures.

Live run at 987b927630e (Django, cdp-api and the email worker on a migrated clone of the local test database, delivering to maildev):

Case Send Answer Delivered
01 Code form with cc copy@example.com (and an attachment from #121852) 202 To jane@example.com, cc copy@example.com
04 A reused Idempotency-Key with another cc 422 idempotency_key_reused Nothing more
05 A cc the project suppresses 400 recipient_suppressed, attr: "cc" Nothing; no job queued
Earlier live run on 0470814fdd6, before the review fixes: headers, bcc and refusals

The stored template password-reset and its workflow existed before the sends, the way the base code writes them.

Send Answer Delivered
Rendered content with cc, bcc, X-Entity-Ref-ID, List-Unsubscribe and a tag 202 Cc header manager@example.com. Envelope jane@, manager@, audit@, and no Bcc header. Both custom headers present
Stored local template by key, with a cc and a header 202 Cc manager@example.com, header X-Entity-Ref-ID: reset-9
Template made in PostHog, by id 202 As before: no cc, no custom header
A cc with template.id 400 attr: cc, "Not with template.id. A template made in PostHog sends with its own settings."
Header Bcc 400 attr: headers, "PostHog sets Bcc itself. Use the send's own fields…"
Header value with \r\nBcc: 400 attr: headers, "…printable ASCII characters, with no line breaks."

Cross-version (base ba5d72e8d88, head 6a82b47b1b2, real Django body, cdp-api route, workers, webhook and fingerprint in each tree):

Crossing Result
Base Django or base cdp-api into this head Pass. A send without the new fields takes the unchanged path, and the webhook adds no $email_tags to a base-sent email.
Template sends, both ways Pass. Same fingerprint in every tree.
This head's 400 with attr, base Django Pass. Base Django ignores attr and still returns the rejection.
This head's code send, base cdp-api or base worker Fails behind the flag. Base strips cc, bcc, headers and tags and answers 202; only to gets the email.
A retry with the same key on the other version Fails behind the flag. The fingerprint differs, so the retry answers 422 instead of the replay.

Not checked: real SES. A sandbox SES account was not available to this lane, so SES's handling of the copies, headers and tags, and tags on real engagement events, are unproven. The base-built cross-version values are kept as fixtures in proof storage, not committed as tests. Prod shape: gap, accepted until read-only production access arrives on 2026-11-02.

Release status

  • This change is behind a feature flag and is not available to users

Docs update

None. No doc under docs/ covers the send route.

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Agent: Claude Code, Claude Opus 5.5 (claude-opus-5-5)

  • Built as one lane of a map conductor run for Michael, test-first.
  • Skills: /tdd, /improving-drf-endpoints, /writing-tests, /writing-pr-descriptions, impact, merge-risk. .agents/security.md was read before the header handling.
  • Review: one GPT-6.1 Sol round found two medium issues, both fixed. Headers named key and value dropped the other headers, and the worker checked copies more strictly than Django.
  • Decisions: limits follow SES's own header and tag rules. $email_tags is one object, not flat properties, so a tag name can never collide with an event property. The worker checks headers again, so an unsafe header fails closed whatever path queued it.
  • The stack's QA swarm then reviewed this layer. Round 1 fixed six items, among them sorted headers in the fingerprint. Round 2 fixed tag order in the fingerprint, the attr of a suppressed cc, the "one recipient" copy and the template.id hint for an invalid to. Final read 1 and the rebase check in final read 3 found nothing.
  • Open: the rollout decision on #121272: keep the deploy order plus the flag, or give code sends their own cdp-api path (➡️ the current plan is the deploy order plus the flag).
  • Involvement: one GPT-6.1 Sol round in the lane. Stack QA swarm: design pass (GPT-6.1 Sol high); round 1 and round 2 (review and team lanes GPT-6.1 Sol high, fresh eyes Claude Opus 5.5 high); final read 1 and final read 3 over the rebase (GPT-6.1 Sol high, clean); fixers Claude Opus 5.5 high; cross-version proof (Claude Opus 5.5); live run at the stack top.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

🤖 CI report

✅ Complexity (TypeScript) — clean

Cyclomatic complexity above the limit in changed typescript files (10 for production files, 15 for test files). Warn only: worth simplifying when you next touch these functions.

✅ Bundle size — no change

Uncompressed size of every built .js bundle, compared against the base branch.

Total: 76.03 MiB · no change

No file changed by more than 1000 B.

Posted automatically by build-bundle-size-report · uncompressed bytes from dist-report

⚠️ Eager graph — 2 over budget

How much code each root ships on the eager path — downloaded and parsed before the surface is interactive. Measured from the esbuild output chunks (post-tree-shake, static imports only); lazy import() / React.lazy chunks are not counted.

Root Eager (shipped) Δ vs base Budget
entry (logged-out pages, app bootstrap)
src/index.tsx
1.01 MiB · 24 files no change █████████░ 88.6% of 1.14 MiB
logged-out boot: index + App + bootApp (preloaded by every page, including /login)
src/index.tsx + src/scenes/App.tsx + src/scenes/bootApp.ts
2.94 MiB · 620 files no change ██████████ 96.9% of 3.03 MiB
🟡 authenticated shell (every logged-in page)
src/scenes/AuthenticatedShell.tsx
7.65 MiB · 2,633 files no change ██████████ 102.1% of 7.50 MiB
dashboard scene
src/scenes/dashboard/Dashboard.tsx
9.68 MiB · 3,703 files no change ██████████ 96.7% of 10.01 MiB
🟡 today home path
src/scenes/AuthenticatedShell.tsx + src/scenes/project-homepage/ProjectHomepage.tsx + src/scenes/project-homepage/today/TodayHome.tsx
8.06 MiB · 2,977 files no change ██████████ 104.1% of 7.74 MiB
AI-first home path
src/scenes/AuthenticatedShell.tsx + src/scenes/project-homepage/ProjectHomepage.tsx + src/scenes/project-homepage/ai-first/AiFirstHomepage.tsx
7.90 MiB · 2,706 files no change █████████░ 88.1% of 8.96 MiB
events scene
src/scenes/activity/explore/EventsScene.tsx
9.15 MiB · 3,495 files no change ██████████ 95.4% of 9.59 MiB
web analytics scene
src/scenes/web-analytics/WebAnalyticsScene.tsx
10.01 MiB · 3,713 files no change █████████░ 92.4% of 10.83 MiB
replay detail scene
src/scenes/session-recordings/detail/SessionRecordingDetail.tsx
8.22 MiB · 3,075 files no change █████████░ 86.8% of 9.47 MiB

🟢 node_modules/monaco-editor/ stays out of src/index.tsx
🟢 src/lib/components/ActivityLog/describers stays out of src/index.tsx
🟢 node_modules/@posthog/brand/dist/generated/hoggies/svg/ stays out of src/index.tsx
🟢 node_modules/@posthog/brand/dist/generated/hoggies/components/ stays out of src/index.tsx
🟢 node_modules/monaco-editor/ stays out of src/index.tsx + src/scenes/App.tsx + src/scenes/bootApp.ts
🟢 src/layout/navigation-3000/navigationLogic.tsx stays out of src/index.tsx + src/scenes/App.tsx + src/scenes/bootApp.ts
🟢 src/scenes/dashboard/dashboardLogic.tsx stays out of src/index.tsx + src/scenes/App.tsx + src/scenes/bootApp.ts
🟢 src/lib/lemon-ui/LemonMarkdown/ stays out of src/index.tsx + src/scenes/App.tsx + src/scenes/bootApp.ts
🟢 src/lib/components/RichContentEditor/ stays out of src/index.tsx + src/scenes/App.tsx + src/scenes/bootApp.ts
🟢 src/lib/components/CodeSnippet/ stays out of src/index.tsx + src/scenes/App.tsx + src/scenes/bootApp.ts
🟢 src/taxonomy/core-filter-definitions-by-group.json stays out of src/index.tsx + src/scenes/App.tsx + src/scenes/bootApp.ts
🟢 node_modules/zod/ stays out of src/index.tsx + src/scenes/App.tsx + src/scenes/bootApp.ts
🟢 src/scenes/billing/billingLogic.tsx stays out of src/index.tsx + src/scenes/App.tsx + src/scenes/bootApp.ts
🟢 src/lib/components/ProductEmptyState/productSetupPreloadLogic.ts stays out of src/index.tsx + src/scenes/App.tsx + src/scenes/bootApp.ts
🟢 node_modules/monaco-editor/ stays out of src/scenes/AuthenticatedShell.tsx
🟢 src/lib/components/ActivityLog/describers stays out of src/scenes/AuthenticatedShell.tsx
🟢 products/dashboards/frontend/widgets/previews/ stays out of src/scenes/AuthenticatedShell.tsx
🟢 src/scenes/session-recordings/player/sessionRecordingPlayerLogic.ts stays out of src/scenes/AuthenticatedShell.tsx
🟢 products/wizard/frontend/runs/WizardRunDetailsDrawer.tsx stays out of src/scenes/AuthenticatedShell.tsx
🟢 zod/v4/locales/de.js stays out of src/scenes/AuthenticatedShell.tsx
🟢 node_modules/@posthog/brand/dist/generated/hoggies/svg/ stays out of src/scenes/AuthenticatedShell.tsx
🟢 node_modules/@posthog/brand/dist/generated/hoggies/components/ stays out of src/scenes/AuthenticatedShell.tsx
🟢 src/scenes/session-recordings/playlist/SessionRecordingsPlaylist.tsx stays out of src/scenes/dashboard/Dashboard.tsx
🟢 src/scenes/web-analytics/tiles/WebAnalyticsTile.tsx stays out of src/scenes/dashboard/Dashboard.tsx
🟢 src/queries/validators.js stays out of src/scenes/dashboard/Dashboard.tsx
🟢 src/scenes/project-homepage/ai-first/AiFirstHomepage.tsx stays out of src/scenes/AuthenticatedShell.tsx + src/scenes/project-homepage/ProjectHomepage.tsx + src/scenes/project-homepage/today/TodayHome.tsx
🟢 src/scenes/project-homepage/today/TodayReportPage.tsx stays out of src/scenes/AuthenticatedShell.tsx + src/scenes/project-homepage/ProjectHomepage.tsx + src/scenes/project-homepage/today/TodayHome.tsx
🟢 src/queries/Query/Query.tsx stays out of src/scenes/AuthenticatedShell.tsx + src/scenes/project-homepage/ProjectHomepage.tsx + src/scenes/project-homepage/today/TodayHome.tsx
🟢 products/posthog_ai/frontend/components/tool/widgets/VisualizationWidget.tsx stays out of src/scenes/AuthenticatedShell.tsx + src/scenes/project-homepage/ProjectHomepage.tsx + src/scenes/project-homepage/ai-first/AiFirstHomepage.tsx
🟢 products/posthog_ai/frontend/components/tool/widgets/RecordingsWidget.tsx stays out of src/scenes/AuthenticatedShell.tsx + src/scenes/project-homepage/ProjectHomepage.tsx + src/scenes/project-homepage/ai-first/AiFirstHomepage.tsx
🟢 src/queries/Query/Query.tsx stays out of src/scenes/AuthenticatedShell.tsx + src/scenes/project-homepage/ProjectHomepage.tsx + src/scenes/project-homepage/ai-first/AiFirstHomepage.tsx
🟢 products/posthog_ai/frontend/api/primitives.ts stays out of src/scenes/AuthenticatedShell.tsx + src/scenes/project-homepage/ProjectHomepage.tsx + src/scenes/project-homepage/ai-first/AiFirstHomepage.tsx
🟢 src/scenes/notebooks/Notebook/notebookLogic.ts stays out of src/scenes/AuthenticatedShell.tsx + src/scenes/project-homepage/ProjectHomepage.tsx + src/scenes/project-homepage/ai-first/AiFirstHomepage.tsx
🟢 src/scenes/session-recordings/playlist/SessionRecordingsPlaylist.tsx stays out of src/scenes/activity/explore/EventsScene.tsx
🟢 src/scenes/web-analytics/tiles/WebAnalyticsTile.tsx stays out of src/scenes/activity/explore/EventsScene.tsx
🟢 src/queries/validators.js stays out of src/scenes/activity/explore/EventsScene.tsx
🟢 src/scenes/web-analytics/LiveMetricsDashboard/LiveWebAnalyticsMetrics.tsx stays out of src/scenes/web-analytics/WebAnalyticsScene.tsx
🟢 products/product_analytics/frontend/insights/trends/WorldMap/countryVectors.tsx stays out of src/scenes/web-analytics/WebAnalyticsScene.tsx
🟢 src/scenes/settings/SettingsMap.tsx stays out of src/scenes/session-recordings/detail/SessionRecordingDetail.tsx
🟢 @jspm/core/nodelibs/browser/fs stays out of src/scenes/session-recordings/detail/SessionRecordingDetail.tsx

Largest files eagerly shipped from src/index.tsx
Size File
126.8 KiB ../node_modules/.pnpm/react-dom@18.3.1_react@18.3.1/node_modules/react-dom/cjs/react-dom.production.min.js
24.6 KiB ../node_modules/.pnpm/buffer@6.0.3/node_modules/buffer/index.js
6.3 KiB ../node_modules/.pnpm/react@18.3.1/node_modules/react/cjs/react.production.min.js
4.5 KiB ../node_modules/.pnpm/@jspm+core@2.1.0/node_modules/@jspm/core/nodelibs/browser/process.js
3.9 KiB ../node_modules/.pnpm/scheduler@0.23.2/node_modules/scheduler/cjs/scheduler.production.min.js
1.4 KiB ../node_modules/.pnpm/base64-js@1.5.1/node_modules/base64-js/index.js
1.4 KiB src/index.tsx
1.3 KiB src/RootErrorBoundary.tsx
1.3 KiB src/lib/utils/bootTiming.ts
912 B ../node_modules/.pnpm/ieee754@1.2.1/node_modules/ieee754/index.js
Largest files eagerly shipped from src/index.tsx + src/scenes/App.tsx + src/scenes/bootApp.ts
Size File
321.9 KiB ../node_modules/.pnpm/posthog-js@1.438.18_@types+react@18.3.27_react@18.3.1/node_modules/posthog-js/dist/module.mjs
220.9 KiB ../node_modules/.pnpm/@posthog+icons@0.38.0_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/@posthog/icons/dist/posthog-icons.es.js
126.8 KiB ../node_modules/.pnpm/react-dom@18.3.1_react@18.3.1/node_modules/react-dom/cjs/react-dom.production.min.js
106.3 KiB src/products.tsx
98.9 KiB src/lib/api.ts
69.4 KiB src/lib/lemon-ui/icons/icons.tsx
38.7 KiB ../node_modules/.pnpm/@dnd-kit+core@6.0.8_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/@dnd-kit/core/dist/core.esm.js
33.9 KiB ../node_modules/.pnpm/kea@4.0.0-pre.6_patch_hash=139b8d1f1304f9d9da452a9a1244c94ea679dbcb85687d8999563146879fb6f5_react@18.3.1/node_modules/kea/lib/index.cjs.js
29.4 KiB src/scenes/scenes.ts
26.9 KiB src/lib/constants.tsx
Largest files eagerly shipped from src/scenes/AuthenticatedShell.tsx
Size File
321.9 KiB ../node_modules/.pnpm/posthog-js@1.438.18_@types+react@18.3.27_react@18.3.1/node_modules/posthog-js/dist/module.mjs
286.8 KiB src/taxonomy/core-filter-definitions-by-group.json
238.5 KiB ../packages/quill/packages/quill/dist/index.js
220.9 KiB ../node_modules/.pnpm/@posthog+icons@0.38.0_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/@posthog/icons/dist/posthog-icons.es.js
153.7 KiB ../node_modules/.pnpm/re2js@0.4.1/node_modules/re2js/build/index.esm.js
143.2 KiB ../packages/quill/packages/primitives/dist/index.js
126.8 KiB ../node_modules/.pnpm/react-dom@18.3.1_react@18.3.1/node_modules/react-dom/cjs/react-dom.production.min.js
106.3 KiB src/products.tsx
98.9 KiB src/lib/api.ts
93.3 KiB ../node_modules/.pnpm/prosemirror-view@1.40.1/node_modules/prosemirror-view/dist/index.js
Largest files eagerly shipped from src/scenes/dashboard/Dashboard.tsx
Size File
321.9 KiB ../node_modules/.pnpm/posthog-js@1.438.18_@types+react@18.3.27_react@18.3.1/node_modules/posthog-js/dist/module.mjs
286.8 KiB src/taxonomy/core-filter-definitions-by-group.json
238.5 KiB ../packages/quill/packages/quill/dist/index.js
220.9 KiB ../node_modules/.pnpm/@posthog+icons@0.38.0_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/@posthog/icons/dist/posthog-icons.es.js
153.7 KiB ../node_modules/.pnpm/re2js@0.4.1/node_modules/re2js/build/index.esm.js
143.2 KiB ../packages/quill/packages/primitives/dist/index.js
126.8 KiB ../node_modules/.pnpm/react-dom@18.3.1_react@18.3.1/node_modules/react-dom/cjs/react-dom.production.min.js
106.3 KiB src/products.tsx
98.9 KiB src/lib/api.ts
93.3 KiB ../node_modules/.pnpm/prosemirror-view@1.40.1/node_modules/prosemirror-view/dist/index.js
Largest files eagerly shipped from src/scenes/AuthenticatedShell.tsx + src/scenes/project-homepage/ProjectHomepage.tsx + src/scenes/project-homepage/today/TodayHome.tsx
Size File
321.9 KiB ../node_modules/.pnpm/posthog-js@1.438.18_@types+react@18.3.27_react@18.3.1/node_modules/posthog-js/dist/module.mjs
286.8 KiB src/taxonomy/core-filter-definitions-by-group.json
238.5 KiB ../packages/quill/packages/quill/dist/index.js
220.9 KiB ../node_modules/.pnpm/@posthog+icons@0.38.0_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/@posthog/icons/dist/posthog-icons.es.js
153.7 KiB ../node_modules/.pnpm/re2js@0.4.1/node_modules/re2js/build/index.esm.js
143.2 KiB ../packages/quill/packages/primitives/dist/index.js
126.8 KiB ../node_modules/.pnpm/react-dom@18.3.1_react@18.3.1/node_modules/react-dom/cjs/react-dom.production.min.js
106.3 KiB src/products.tsx
98.9 KiB src/lib/api.ts
93.3 KiB ../node_modules/.pnpm/prosemirror-view@1.40.1/node_modules/prosemirror-view/dist/index.js
Largest files eagerly shipped from src/scenes/AuthenticatedShell.tsx + src/scenes/project-homepage/ProjectHomepage.tsx + src/scenes/project-homepage/ai-first/AiFirstHomepage.tsx
Size File
321.9 KiB ../node_modules/.pnpm/posthog-js@1.438.18_@types+react@18.3.27_react@18.3.1/node_modules/posthog-js/dist/module.mjs
286.8 KiB src/taxonomy/core-filter-definitions-by-group.json
238.5 KiB ../packages/quill/packages/quill/dist/index.js
220.9 KiB ../node_modules/.pnpm/@posthog+icons@0.38.0_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/@posthog/icons/dist/posthog-icons.es.js
153.7 KiB ../node_modules/.pnpm/re2js@0.4.1/node_modules/re2js/build/index.esm.js
143.2 KiB ../packages/quill/packages/primitives/dist/index.js
126.8 KiB ../node_modules/.pnpm/react-dom@18.3.1_react@18.3.1/node_modules/react-dom/cjs/react-dom.production.min.js
106.3 KiB src/products.tsx
98.9 KiB src/lib/api.ts
93.3 KiB ../node_modules/.pnpm/prosemirror-view@1.40.1/node_modules/prosemirror-view/dist/index.js
Largest files eagerly shipped from src/scenes/activity/explore/EventsScene.tsx
Size File
321.9 KiB ../node_modules/.pnpm/posthog-js@1.438.18_@types+react@18.3.27_react@18.3.1/node_modules/posthog-js/dist/module.mjs
286.8 KiB src/taxonomy/core-filter-definitions-by-group.json
238.5 KiB ../packages/quill/packages/quill/dist/index.js
220.9 KiB ../node_modules/.pnpm/@posthog+icons@0.38.0_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/@posthog/icons/dist/posthog-icons.es.js
153.7 KiB ../node_modules/.pnpm/re2js@0.4.1/node_modules/re2js/build/index.esm.js
143.2 KiB ../packages/quill/packages/primitives/dist/index.js
126.8 KiB ../node_modules/.pnpm/react-dom@18.3.1_react@18.3.1/node_modules/react-dom/cjs/react-dom.production.min.js
106.3 KiB src/products.tsx
98.9 KiB src/lib/api.ts
93.3 KiB ../node_modules/.pnpm/prosemirror-view@1.40.1/node_modules/prosemirror-view/dist/index.js
Largest files eagerly shipped from src/scenes/web-analytics/WebAnalyticsScene.tsx
Size File
321.9 KiB ../node_modules/.pnpm/posthog-js@1.438.18_@types+react@18.3.27_react@18.3.1/node_modules/posthog-js/dist/module.mjs
286.8 KiB src/taxonomy/core-filter-definitions-by-group.json
238.5 KiB ../packages/quill/packages/quill/dist/index.js
220.9 KiB ../node_modules/.pnpm/@posthog+icons@0.38.0_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/@posthog/icons/dist/posthog-icons.es.js
183.2 KiB src/queries/validators.js
153.7 KiB ../node_modules/.pnpm/re2js@0.4.1/node_modules/re2js/build/index.esm.js
143.2 KiB ../packages/quill/packages/primitives/dist/index.js
126.8 KiB ../node_modules/.pnpm/react-dom@18.3.1_react@18.3.1/node_modules/react-dom/cjs/react-dom.production.min.js
106.3 KiB src/products.tsx
98.9 KiB src/lib/api.ts
Largest files eagerly shipped from src/scenes/session-recordings/detail/SessionRecordingDetail.tsx
Size File
321.9 KiB ../node_modules/.pnpm/posthog-js@1.438.18_@types+react@18.3.27_react@18.3.1/node_modules/posthog-js/dist/module.mjs
316.1 KiB ../node_modules/.pnpm/posthog-js@1.438.18_@types+react@18.3.27_react@18.3.1/node_modules/posthog-js/dist/rrweb.js
286.8 KiB src/taxonomy/core-filter-definitions-by-group.json
238.5 KiB ../packages/quill/packages/quill/dist/index.js
220.9 KiB ../node_modules/.pnpm/@posthog+icons@0.38.0_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/@posthog/icons/dist/posthog-icons.es.js
153.7 KiB ../node_modules/.pnpm/re2js@0.4.1/node_modules/re2js/build/index.esm.js
126.8 KiB ../node_modules/.pnpm/react-dom@18.3.1_react@18.3.1/node_modules/react-dom/cjs/react-dom.production.min.js
106.3 KiB src/products.tsx
98.9 KiB src/lib/api.ts
93.3 KiB ../node_modules/.pnpm/prosemirror-view@1.40.1/node_modules/prosemirror-view/dist/index.js

Posted automatically by check-eager-graph · sizes are eager output bytes (shipped, post-tree-shake) from the esbuild metafile · part of PostHog#32479

✅ Toolbar bundle — eager 2.29 MiB within budget

What the toolbar ships to customer pages, measured from the esbuild output (minified, post-tree-shake). The eager set is the entry plus everything statically imported from it — fetched before any feature runs; deferred chunks load lazily. The eager guardrail is 5.72 MiB. Each output file must also stay below 10 MB, where CloudFront stops compressing it. The module boundary is enforced separately by check-toolbar-graph.

Metric Size Δ vs base Budget
Eager (shipped)
entry + static imports
2.29 MiB · 18 files no change ████░░░░░░ 39.9% of 5.72 MiB
Deferred (lazy) 2.11 MiB · 40 files no change n/a — loads on demand
Loader dist/toolbar.js 1.2 KiB no change █░░░░░░░░░ 6.0% of 19.5 KiB
Largest eagerly-shipped chunks
Size File
899.4 KiB dist/toolbar/toolbar-app-REMKF2SV.css
691.4 KiB dist/toolbar/chunk-chunk-DDSP4M6B.js
260.7 KiB dist/toolbar/chunk-chunk-HRRIOF34.js
131.8 KiB dist/toolbar/chunk-chunk-FDH2IBXT.js
130.4 KiB dist/toolbar/chunk-chunk-IT6XUU73.js
74.8 KiB dist/toolbar/toolbar-app-YPZZ2PIS.js
69.0 KiB dist/toolbar/chunk-chunk-TSAL54PB.js
35.6 KiB dist/toolbar/chunk-chunk-CGDYYVIR.js
21.7 KiB dist/toolbar/chunk-chunk-PM4VBMO5.js
6.8 KiB dist/toolbar/chunk-chunk-DV7IWQNF.js

Posted automatically by check-toolbar-size · sizes are toolbar output bytes (shipped, post-tree-shake) from the esbuild metafile

✅ Dist folder size — 🔺 +453 B (+0.0%)

Total size of the built frontend/dist folder (all assets), compared against the base branch.

Total: 1020.76 MiB · 🔺 +453 B (+0.0%)

ℹ️ MCP UI apps size — 34 app(s), 27964.5 KB JS

Built size of each MCP UI app (main.js + styles.css).

App JS CSS
debug 844.2 KB 209.0 KB
action 767.6 KB 209.0 KB
action-list 835.3 KB 209.0 KB
cohort 766.6 KB 209.0 KB
cohort-list 834.2 KB 209.0 KB
email-template 766.5 KB 209.0 KB
error-details 771.5 KB 209.0 KB
error-issue 768.0 KB 209.0 KB
error-issue-list 835.8 KB 209.0 KB
experiment 832.4 KB 209.0 KB
experiment-list 836.0 KB 209.0 KB
experiment-results 834.9 KB 209.0 KB
feature-flag 834.6 KB 209.0 KB
feature-flag-list 838.4 KB 209.0 KB
feature-flag-testing 769.9 KB 209.0 KB
inline-scan 767.1 KB 209.0 KB
insight-actors 833.7 KB 209.0 KB
llm-costs 831.0 KB 209.0 KB
session-recording 768.9 KB 209.0 KB
survey 768.2 KB 209.0 KB
survey-global-stats 831.1 KB 209.0 KB
survey-list 835.9 KB 209.0 KB
survey-stats 831.0 KB 209.0 KB
trace-span 767.0 KB 209.0 KB
trace-span-list 835.1 KB 209.0 KB
vision-observation-list 834.3 KB 209.0 KB
workflow 767.0 KB 209.0 KB
workflow-list 834.6 KB 209.0 KB
workflow-setup-status 766.7 KB 209.0 KB
query-results 1086.5 KB 209.0 KB
render-ui 1161.5 KB 209.0 KB
visual-review-snapshots 771.5 KB 209.0 KB
workflow-data-suggestions 769.3 KB 209.0 KB
workflow-sending-rules 768.0 KB 209.0 KB
ℹ️ MCP agent API — agent-facing tool changes

What this PR changes for agents, from the tool schema snapshots and tool definitions.

Tools changed (1):

Tool Params Scopes Annotations Schema chars
workflows-send-email +bcc +cc +headers +tags description changed 3,291 -> 4,912

@Silthus
Silthus force-pushed the feat/email-send-fields branch from ed45d34 to 2585fe8 Compare October 23, 2026 21:24
@Silthus
Silthus force-pushed the feat/email-send-route-idempotency branch from 9a2b0e1 to 0398172 Compare October 23, 2026 21:24
Silthus and others added 17 commits October 23, 2026 23:02
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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Nodemailer reads a header object with `key` and `value` names as one
header, so a send with headers by those names lost the rest, including
Auto-Submitted. Pass the headers as a list.

The worker checked copies with a stricter address rule than Django, so a
copy such as archive=orders@example.com passed Django and then failed to
queue. The worker now uses Django's bare-address grammar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d checks

A reused key with another cc, bcc, header or tag answers idempotency_key_reused,
and a suppressed cc is refused before the 202.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The send checks before the 202 saw only the recipient, so a suppressed cc or
bcc passed admission and the worker skipped the send after a 202. Every copy is
now a recipient of the admission checks, as it is at delivery.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A refused cc, bcc, header or tag answered with the why of a field PostHog
doesn't know. Each now has its own why. The rendered send's expected
payload also carries the default sender flag the code form added.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A keyed retry that listed the same headers in another order changed the
idempotency fingerprint, so it got a 422 instead of a replay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The unknown-field check moves beside the other send field rules, so the
tag serializer refuses an unknown key by name as the root body does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Silthus
Silthus force-pushed the feat/email-send-route-idempotency branch from 0398172 to ba5d72e Compare October 24, 2026 17:28
@Silthus
Silthus force-pushed the feat/email-send-fields branch from 2585fe8 to 6a82b47 Compare October 24, 2026 17:28
@Silthus Silthus closed this Oct 28, 2026
@Silthus Silthus reopened this Oct 28, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant