Skip to content

feat(messaging): send a stored template by key on send/email - #127176

Draft
Silthus wants to merge 75 commits into
email-v2/04c-template-lock-adoptfrom
email-v2/05-template-send
Draft

Silthus wants to merge 75 commits into
email-v2/04c-template-lock-adoptfrom
email-v2/05-template-send

Conversation

@Silthus

@Silthus Silthus commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Moved from Silthus#574.

Part of the code-first rebuild of the email send stack; replaces parts of #118924 (the template send on send/email/).

Problem

  • A developer who uploaded a template from code can't send it by its key. send/email/ only takes rendered content, and master's template route needs the PostHog id.
  • The SDKs send one shape for both. A template send also needs capture_engagement on every form, and reply_to and from on a template uploaded from code.

Impact

Pays into Grow Workflows through billed workflows_emails actions per paying org, for template sends from code. In scope: Messaging, email API sends.

Merge Danger

Warning

🟡 Merge risk: medium · two-way door: a revert removes the template forms from send/email/

  • Deploy order: cdp-api and both CDP workers from the node stack go out before this Django change. With cdp-api on master, a code send, a send by id with capture_engagement and a send by key with from or reply_to answer 503 and send nothing. Template sends without those options deliver (cross-version proof below).
  • Dark while workflows-email-from-code is off. A send by id keeps master's route. One that sets capture_engagement answers 400 on that field and sends nothing, because the opt-out needs the beta. An existing caller that sends the field gets the 400 once this deploys.
  • The route sends real email and stays behind workflows-email-from-code.

Changes

Kind Files Lines
Production 8 +314 −43
Tests 3 +289 −12
Generated types and snapshots 10 +223 −54
  • POST messaging/send/email/ sends a stored template by template: {key} or template: {id}, next to the rendered-content form, in one serializer that refuses unknown fields.
  • message_data fills the template's {{ variables.* }}.
  • template.hash names the content the caller holds, as v1: and 64 lowercase hex characters. Another format answers 400 invalid_input on template.hash.
  • The first send of a new local template uploads it. A template: {key, hash} send to a key PostHog doesn't hold yet answers 409 template_outdated, as contract Transactional email SDK contract for posthog-python and posthog-node #120921 says, so the SDK uploads and sends again. It answered 404 before. A key sent without a hash still answers 404.
  • Content PostHog does not hold, as the current content or one of the last 100 different contents it replaced, answers 409 template_outdated. Content a newer upload replaced still sends, with a template_superseded warning.
  • capture_engagement (default true) works on every form, a send by id included, as map Map: send code-managed email templates with one PostHog call Silthus/posthog#461 decided.
  • A send by id takes capture_engagement only while workflows-email-from-code is on. While it is off, a send that sets the field answers 400 with attr: "capture_engagement": "A send by template.id takes this field only in the beta for sending email from code. Remove it to send the email." A send without it keeps master's route, with no send block. The field's help text says so too.
  • A send by template: {key} that cdp-api does not confirm answers the same 503 as a code send: the email may still go out, retry with the same Idempotency-Key.
  • Usage events name the form: addressed_by is template_key, template.key or template.id. The help text says a project with capture off captures nothing.
  • A send by template: {key} takes reply_to and from, as contract Transactional email SDK contract for posthog-python and posthog-node #120921 promises for a template uploaded from code. A named from must be a verified sender, and the email goes out from it instead of the template's. Without from, the template's own sender holds.
  • A send by template: {id} refuses subject, html, text, from and reply_to with 400 invalid_input on that field, with why and fix. A from or reply_to refusal points at template: {key}: a template made in PostHog sends with its own settings. A send by key still refuses the content fields.
  • Known limit, until the flag-on gate: opens and clicks of a capture_engagement: false send are still captured. cdp-api mints tracking codes that carry the choice only when EVERY_TRACKING_DEPLOYMENT_PARSES_CAPTURE_CHOICE is on (feat(cdp): deliver a code send's content on its own transactional email route #127122). The flag-on gate for workflows-email-from-code flips that switch after feat(cdp): deliver a code send's content on its own transactional email route #127122 rolls out to every deployment. The help text keeps the full rule.
  • template_key help says that a template uploaded under a key that code sends already used takes over those sends.
  • An uploaded template with no text sends a text part derived from its html, as an inline-HTML send does. HTML with nothing to derive, such as a single image, sends with no text part. Templates made in PostHog keep the text their editor saves.
  • A template uploaded before this change keeps an empty text until its next changed upload or save.
  • warnings returns with its first producer, template_superseded.
  • Known limit: the hash check reads the published template in Postgres. The email goes out from the workflow copy cdp-api holds, which follows once the workers reload. A send right after an upload can still carry the previous content without a warning.
  • Mechanical: the send names its template as TemplateByKey or TemplateById, never both.

How did you test this code?

Test seams: the send/email/ route through the DRF client with cdp-api mocked (tests/api/test_transactional_email.py).

Test rationale:

  • test_a_send_by_template_key_runs_the_templates_workflow: capture_engagement, reply_to or a named from not reaching cdp-api on a send by key. The SDK depends on this seam.
  • test_a_send_by_template_id_carries_its_engagement_capture: a send by id that drops capture_engagement, or that leaves master's route while workflows-email-from-code is off.
  • test_refuses_engagement_capture_on_a_send_by_template_id_while_email_from_code_is_off: a send outside the beta that drops the opt-out instead of refusing it.
  • test_a_form_encoded_send_by_template_id_without_engagement_capture_keeps_masters_route: a form body that omits the field, which DRF fills with false, refused or routed as if the caller set it.
  • test_refuses_on_a_send_by_template_id_what_its_template_sets, one row per field: a send by id that takes a field the template sets. A red run showed reply_to passing.
  • test_a_send_from_code_the_pipeline_does_not_confirm_warns_it_may_still_go_out, over code sends and sends by key: a timeout that tells the caller to retry without the key.
  • The key-send sender row: an unverified named from that answers anything but sender_not_verified on from.
  • The hash rows: an outdated copy that sends, a superseded copy that sends without its warning, and a malformed hash that loops on template_outdated.
  • The unknown-key rows: a key PostHog doesn't hold, or a deleted one, sent with a hash, that answers 404 and so stops the SDK's upload.
  • test_an_upload_without_text_sends_the_text_its_html_holds: an upload with no text, or a blank text, that sends an empty text part. A single-image upload sends none.

Proofs on this head 33e700b (saved with fleet proof):

  • Live run: Django at this head, cdp-api and both CDP workers at the node stack f9abed0, maildev as the sink. 21 requests through the public API: sends by key with the current and a superseded hash, an unknown key, a send by key with from, reply_to and capture_engagement: false, sends by id, code sends and uploads.
  • Shadow diff: the same 21 requests on master 43333f8. Each difference is a send by key, the 404 for an unknown key, capture_engagement on a send by id, the id refusal pointing at template.key, the derived text part, or the new usage event properties.
  • Cross-version: base producer, head consumer: Django at feat(messaging): lock code-managed templates and adopt templates into code #127172 against the node stack sends as the shadow diff shows. Head producer, base consumer: Django at this head against cdp-api master. Sends by id or key without options and uploads behave as with the node stack. Code sends, the send by id with capture_engagement and the send by key with options answer 503 delivery_unavailable; nothing reaches maildev and nothing goes out twice. With workflows-email-from-code off, a send by id without capture_engagement answers 202 and delivers the stored template; with capture_engagement false or true it answers 400 and maildev gets nothing (run on 0f27c16).
  • Prod shape: a gap, accepted by the standing rule until read-only production access exists on 2026-11-02.
  • Lane gate on this head: ruff, the IDOR check, the migration checks, repo-wide mypy, and the messaging and workflows tests pass, and so do the MCP server's typecheck, lint and unit tests.
Earlier proofs, at c4c5ff0, 7d9f7aa and 9836720
  • Live run of the two fixes, Django at feat(messaging): mark a code send that leaves the sender to the project #127182 c4c5ff0, which carries this head, cdp-api and workers at feat(cdp): key a code send on the sender the caller chose #127129 1a43bdd, curl into maildev (saved with fleet proof):

    Case Answer Delivered
    Send by key and hash, a key PostHog doesn't hold yet 409 template_outdated nothing
    Upload that key with if_code_managed: true, send again with the same idempotency key 202 one email, with the text Hi Ada, start here (https://example.com/start). derived from the html
    Upload a template whose html is one image, then send it 202 one email, no text part
    Inline-HTML send (control) 202 one email with a text part
  • Live run at 7d9f7aa, before the two fixes, cdp-api and workers from feat(cdp): deliver a code send's content on its own transactional email route #127122 at 24644bc, curl into maildev (saved with fleet proof):

    Case Answer Delivered
    Upload a new key, then send by key and hash 202 "Welcome, Ada" from message_data
    Send with a hash a later upload replaced 202 with template_superseded the newer content
    Send with a hash PostHog never held 409 template_outdated nothing
    Send by id with reply_to 400 invalid_request, attr: "reply_to", with why and fix nothing
    Send by id with capture_engagement: false 202 the stored template
    Send by key with reply_to 202 the template, with Reply-To: support@example.com
    Send by key with from: "Security <security@example.com>" 202 from Security <security@example.com>, not the template's hello@example.com
    Template-by-id route (control) 202 the stored template
  • Capture check at 9836720, the project's engagement capture on, captured events recorded per distinct id:

    Template send by id Sent event Link click event
    capture_engagement: false 0 1
    capture_engagement: true 1 1
    No capture_engagement (default) 1, under the recipient address 1

    The click under false is the known limit above. Later commits leave the capture path as it was at 9836720.

  • Cross-version at 7d9f7aa, Django there and cdp-api at master: sends by key and by id answer 503 with the "did not confirm the send" detail, and maildev gets nothing. The template-by-id route still delivers.

  • The two fixes change only Django, so the cdp-api window above still holds.

  • Prod shape: a gap until read-only production access exists on 2026-11-02.

Release status

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

Docs update

None in this PR. The send API docs follow when the flag opens.

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

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

Question Answer Who Where
What does capture_engagement default to on a template send? true on every form, template: {id} included 👤 Human map Silthus#461, lane brief of 2026-10-25
Does an upload under a key take over that key's code sends? Yes, and the template_key help says so 👤 Human map Silthus#461, lane brief of 2026-10-25
Which flag gates the template forms? workflows-email-from-code, per organization 👤 Human map Silthus#461, 2026-10-25
Which fields does a send by template.id refuse? subject, html, text, from and reply_to, as contract #120921 says; reply_to stays with template.key 🤖 Agent map Silthus#461 conductor, 2026-10-25
Does a send by template.key take from? Yes, the named verified sender overrides the template's, as contract #120921 promises 🤖 Agent map Silthus#461 conductor, 2026-10-25
When do clicks of a capture_engagement: false send stop being captured? When the flag-on gate flips EVERY_TRACKING_DEPLOYMENT_PARSES_CAPTURE_CHOICE, after #127122 rolls out everywhere; the help text stays as it is 🤖 Agent map Silthus#461 conductor, 2026-10-25
What does a key PostHog doesn't know answer? 409 template_outdated with a hash, as contract #120921 says; 404 without one, where the contract is silent 🤖 Agent pending approval
What does an unconfirmed send by template.key answer? The code send's 503: the email may still go out, retry with the same Idempotency-Key 🤖 Agent QA swarm 2026-11-01
Which templates derive their text from html? Templates uploaded from code; templates made in PostHog keep their editor's text, and no backfill runs 🤖 Agent pending approval
Does a send by id take capture_engagement while workflows-email-from-code is off? No; it answers 400 on the field, so the opt-out is never dropped silently. Replaces ignoring the field 👤 Human map Silthus#461 conductor as review owner, 2026-11-02

Involvement: one brief from Michael with decisions; one review round (GPT-6.1 Sol: review, design and Michael's lens; Opus fresh eyes) with its fixes; the SDK lane's live test found the reply_to gap, fixed here; final reads by GPT-6.1 Sol; QA swarm on 2026-11-01 with two panel rounds and three final reads, the last clean: 6 findings fixed, none rejected; live, shadow-diff and cross-version proof captured. Fix reviews (GPT-6.1 Sol, correctness and systems): 6d6c5a2 clean; 30b7e40 found the form-body refusal, fixed in 0f27c16, and a flag-off caller now getting 400, rejected as the review owner's decision; the final read of 0f27c16 found nothing.

🤖 Generated with Claude Code

Silthus and others added 12 commits October 31, 2026 15:54
`POST /api/projects/:id/messaging/send/email/` now takes
`template: {key, hash?}` for a template uploaded from code. A send by key
may set `from`, `reply_to` and `capture_engagement`; a send by
`template.id` still refuses them. With `template.hash`, a hash PostHog
doesn't hold answers `409 template_outdated` and sends nothing, and a
superseded hash sends the current content with a `template_superseded`
warning. An unknown key answers `404 template_not_found`, or
`409 template_outdated` when the send carries a hash. A code-managed
template without text sends the text its html holds.

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

# Conflicts:
#	products/messaging/backend/tests/api/test_template_upload.py
…ate sends

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…answer an unconfirmed key send

A send by template.key now answers an unconfirmed send like a code send and
reports a distinct addressed_by. A send by template.id takes capture_engagement
and points from and reply_to to template.key.

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

# Conflicts:
#	products/messaging/backend/services/template_uploads.py
#	products/messaging/backend/tests/api/test_template_upload.py
…late-send

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ail from code is off

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tside the email from code beta

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

# Conflicts:
#	products/messaging/backend/presentation/views/message_templates.py
#	products/messaging/backend/tests/api/test_template_upload.py
@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

🤖 CI report

⚠️ Trunk lane — backend Python lane

This PR is assigned to the backend Python lane. It runs backend Python tests and may merge in parallel with PRs in other lanes.

✅ 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.

✅ Duplication (Python) — clean

New Python code duplication introduced by this branch. Fails at 70+ tokens in app code, or 150+ tokens when both copies live in test files. Advisory while the gate proves itself: extract a shared helper instead of copying.

✅ Duplication (TypeScript) — clean

New TypeScript code duplication introduced by this branch. Fails at 70+ tokens in app code, or 150+ tokens when both copies live in test files. Advisory while the gate proves itself: extract a shared helper instead of copying.

✅ Bundle size — no change

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

Total: 75.13 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 — within 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.02 MiB · 25 files no change █████████░ 88.8% 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.99 MiB · 622 files no change ██████████ 98.5% of 3.03 MiB
authenticated shell (every logged-in page)
src/scenes/AuthenticatedShell.tsx
6.46 MiB · 2,235 files no change ██████████ 95.4% of 6.77 MiB
dashboard scene
src/scenes/dashboard/Dashboard.tsx
9.97 MiB · 3,728 files no change ██████████ 99.5% of 10.01 MiB
today home path
src/scenes/AuthenticatedShell.tsx + src/scenes/project-homepage/ProjectHomepage.tsx + src/scenes/project-homepage/today/TodayHome.tsx
6.71 MiB · 2,337 files no change ██████████ 96.4% of 6.96 MiB
AI-first home path
src/scenes/AuthenticatedShell.tsx + src/scenes/project-homepage/ProjectHomepage.tsx + src/scenes/project-homepage/ai-first/AiFirstHomepage.tsx
7.28 MiB · 2,426 files no change ██████████ 96.7% of 7.53 MiB
events scene
src/scenes/activity/explore/EventsScene.tsx
9.40 MiB · 3,500 files no change ██████████ 98.0% of 9.59 MiB
web analytics scene
src/scenes/web-analytics/WebAnalyticsScene.tsx
10.36 MiB · 3,767 files no change ██████████ 95.6% of 10.83 MiB
replay detail scene
src/scenes/session-recordings/detail/SessionRecordingDetail.tsx
7.99 MiB · 2,968 files no change ████████░░ 84.4% 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
🟢 src/layout/today/TodayShell.tsx stays out of src/scenes/AuthenticatedShell.tsx
🟢 src/layout/today/TodayContextPanelFloating.tsx stays out of src/scenes/AuthenticatedShell.tsx
🟢 src/layout/today/TodaySidePanel.tsx stays out of src/scenes/AuthenticatedShell.tsx
🟢 src/layout/today/TodayTopBar.tsx stays out of src/scenes/AuthenticatedShell.tsx
🟢 src/layout/today/todayRailItems.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
🟢 packages/quill/packages/charts/ 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.7 KiB ../node_modules/.pnpm/@jspm+core@2.1.0/node_modules/@jspm/core/nodelibs/browser/process.js
4.0 KiB ../node_modules/.pnpm/scheduler@0.23.2/node_modules/scheduler/cjs/scheduler.production.min.js
1.7 KiB src/index.tsx
1.7 KiB ../node_modules/.pnpm/base64-js@1.5.1/node_modules/base64-js/index.js
1.7 KiB src/lib/utils/bootTiming.ts
1.2 KiB src/RootErrorBoundary.tsx
949 B src/scenes/ChunkLoadErrorBoundary.tsx
Largest files eagerly shipped from src/index.tsx + src/scenes/App.tsx + src/scenes/bootApp.ts
Size File
325.0 KiB ../node_modules/.pnpm/posthog-js@1.443.0_@types+react@18.3.27_react@18.3.1/node_modules/posthog-js/dist/module.mjs
217.5 KiB ../node_modules/.pnpm/@posthog+icons@0.39.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
102.9 KiB src/products.tsx
86.6 KiB src/lib/api.ts
67.9 KiB src/lib/lemon-ui/icons/icons.tsx
41.9 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
36.4 KiB ../node_modules/.pnpm/kea@4.0.0-pre.6_patch_hash=139b8d1f1304f9d9da452a9a1244c94ea679dbcb85687d8999563146879fb6f5_react@18.3.1/node_modules/kea/lib/index.cjs.js
27.0 KiB ../node_modules/.pnpm/tailwind-merge@2.2.2/node_modules/tailwind-merge/dist/bundle-mjs.mjs
24.6 KiB ../node_modules/.pnpm/buffer@6.0.3/node_modules/buffer/index.js
Largest files eagerly shipped from src/scenes/AuthenticatedShell.tsx
Size File
325.0 KiB ../node_modules/.pnpm/posthog-js@1.443.0_@types+react@18.3.27_react@18.3.1/node_modules/posthog-js/dist/module.mjs
286.4 KiB src/taxonomy/core-filter-definitions-by-group.json
239.9 KiB ../packages/quill/packages/quill/dist/index.js
217.5 KiB ../node_modules/.pnpm/@posthog+icons@0.39.0_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/@posthog/icons/dist/posthog-icons.es.js
153.8 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
102.9 KiB src/products.tsx
94.5 KiB ../node_modules/.pnpm/@tiptap+core@3.20.6_@tiptap+pm@3.20.6/node_modules/@tiptap/core/dist/index.js
92.5 KiB ../node_modules/.pnpm/prosemirror-view@1.40.1/node_modules/prosemirror-view/dist/index.js
86.6 KiB src/lib/api.ts
Largest files eagerly shipped from src/scenes/dashboard/Dashboard.tsx
Size File
325.0 KiB ../node_modules/.pnpm/posthog-js@1.443.0_@types+react@18.3.27_react@18.3.1/node_modules/posthog-js/dist/module.mjs
286.4 KiB src/taxonomy/core-filter-definitions-by-group.json
239.9 KiB ../packages/quill/packages/quill/dist/index.js
217.5 KiB ../node_modules/.pnpm/@posthog+icons@0.39.0_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/@posthog/icons/dist/posthog-icons.es.js
153.8 KiB ../node_modules/.pnpm/re2js@0.4.1/node_modules/re2js/build/index.esm.js
144.1 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
102.9 KiB src/products.tsx
94.5 KiB ../node_modules/.pnpm/@tiptap+core@3.20.6_@tiptap+pm@3.20.6/node_modules/@tiptap/core/dist/index.js
92.5 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
325.0 KiB ../node_modules/.pnpm/posthog-js@1.443.0_@types+react@18.3.27_react@18.3.1/node_modules/posthog-js/dist/module.mjs
286.4 KiB src/taxonomy/core-filter-definitions-by-group.json
239.9 KiB ../packages/quill/packages/quill/dist/index.js
217.5 KiB ../node_modules/.pnpm/@posthog+icons@0.39.0_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/@posthog/icons/dist/posthog-icons.es.js
153.8 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
102.9 KiB src/products.tsx
94.5 KiB ../node_modules/.pnpm/@tiptap+core@3.20.6_@tiptap+pm@3.20.6/node_modules/@tiptap/core/dist/index.js
92.5 KiB ../node_modules/.pnpm/prosemirror-view@1.40.1/node_modules/prosemirror-view/dist/index.js
86.6 KiB src/lib/api.ts
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
325.0 KiB ../node_modules/.pnpm/posthog-js@1.443.0_@types+react@18.3.27_react@18.3.1/node_modules/posthog-js/dist/module.mjs
286.4 KiB src/taxonomy/core-filter-definitions-by-group.json
239.9 KiB ../packages/quill/packages/quill/dist/index.js
217.5 KiB ../node_modules/.pnpm/@posthog+icons@0.39.0_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/@posthog/icons/dist/posthog-icons.es.js
153.8 KiB ../node_modules/.pnpm/re2js@0.4.1/node_modules/re2js/build/index.esm.js
144.1 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
102.9 KiB src/products.tsx
94.5 KiB ../node_modules/.pnpm/@tiptap+core@3.20.6_@tiptap+pm@3.20.6/node_modules/@tiptap/core/dist/index.js
92.5 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
325.0 KiB ../node_modules/.pnpm/posthog-js@1.443.0_@types+react@18.3.27_react@18.3.1/node_modules/posthog-js/dist/module.mjs
286.4 KiB src/taxonomy/core-filter-definitions-by-group.json
239.9 KiB ../packages/quill/packages/quill/dist/index.js
217.5 KiB ../node_modules/.pnpm/@posthog+icons@0.39.0_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/@posthog/icons/dist/posthog-icons.es.js
153.8 KiB ../node_modules/.pnpm/re2js@0.4.1/node_modules/re2js/build/index.esm.js
144.1 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
102.9 KiB src/products.tsx
94.5 KiB ../node_modules/.pnpm/@tiptap+core@3.20.6_@tiptap+pm@3.20.6/node_modules/@tiptap/core/dist/index.js
92.5 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
325.0 KiB ../node_modules/.pnpm/posthog-js@1.443.0_@types+react@18.3.27_react@18.3.1/node_modules/posthog-js/dist/module.mjs
286.4 KiB src/taxonomy/core-filter-definitions-by-group.json
239.9 KiB ../packages/quill/packages/quill/dist/index.js
217.5 KiB ../node_modules/.pnpm/@posthog+icons@0.39.0_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/@posthog/icons/dist/posthog-icons.es.js
187.9 KiB src/queries/validators.js
153.8 KiB ../node_modules/.pnpm/re2js@0.4.1/node_modules/re2js/build/index.esm.js
144.1 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
102.9 KiB src/products.tsx
94.5 KiB ../node_modules/.pnpm/@tiptap+core@3.20.6_@tiptap+pm@3.20.6/node_modules/@tiptap/core/dist/index.js
Largest files eagerly shipped from src/scenes/session-recordings/detail/SessionRecordingDetail.tsx
Size File
325.0 KiB ../node_modules/.pnpm/posthog-js@1.443.0_@types+react@18.3.27_react@18.3.1/node_modules/posthog-js/dist/module.mjs
316.5 KiB ../node_modules/.pnpm/posthog-js@1.443.0_@types+react@18.3.27_react@18.3.1/node_modules/posthog-js/dist/rrweb.js
286.4 KiB src/taxonomy/core-filter-definitions-by-group.json
239.9 KiB ../packages/quill/packages/quill/dist/index.js
217.5 KiB ../node_modules/.pnpm/@posthog+icons@0.39.0_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/@posthog/icons/dist/posthog-icons.es.js
153.8 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
102.9 KiB src/products.tsx
94.5 KiB ../node_modules/.pnpm/@tiptap+core@3.20.6_@tiptap+pm@3.20.6/node_modules/@tiptap/core/dist/index.js
92.5 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 #32479

✅ Toolbar bundle — eager 2.33 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.33 MiB · 19 files no change ████░░░░░░ 40.8% of 5.72 MiB
Deferred (lazy) 2.11 MiB · 42 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
941.7 KiB dist/toolbar/toolbar-app-UY2NCWYC.css
695.8 KiB dist/toolbar/chunk-chunk-OU5JIGRB.js
261.2 KiB dist/toolbar/chunk-chunk-T5DKNJQU.js
131.8 KiB dist/toolbar/chunk-chunk-FDH2IBXT.js
130.4 KiB dist/toolbar/chunk-chunk-H2B6C5E4.js
75.7 KiB dist/toolbar/toolbar-app-SI4MRMOT.js
69.0 KiB dist/toolbar/chunk-chunk-TSAL54PB.js
33.8 KiB dist/toolbar/chunk-chunk-FR2SFTP5.js
21.7 KiB dist/toolbar/chunk-chunk-SFGY4JKG.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 — 🔺 +138 B (+0.0%)

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

Total: 966.34 MiB · 🔺 +138 B (+0.0%)

ℹ️ MCP UI apps size — 35 app(s), 29120.8 KB JS

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

App JS CSS
debug 844.3 KB 211.6 KB
explain-chart 1117.7 KB 211.6 KB
action 767.7 KB 211.6 KB
action-list 835.3 KB 211.6 KB
cohort 766.7 KB 211.6 KB
cohort-list 834.3 KB 211.6 KB
email-template 766.6 KB 211.6 KB
error-details 771.6 KB 211.6 KB
error-issue 768.9 KB 211.6 KB
error-issue-list 836.8 KB 211.6 KB
experiment 832.4 KB 211.6 KB
experiment-list 836.1 KB 211.6 KB
experiment-results 834.3 KB 211.6 KB
feature-flag 834.7 KB 211.6 KB
feature-flag-list 838.4 KB 211.6 KB
feature-flag-testing 770.0 KB 211.6 KB
inline-scan 767.2 KB 211.6 KB
insight-actors 833.8 KB 211.6 KB
llm-costs 831.1 KB 211.6 KB
session-recording 769.0 KB 211.6 KB
survey 768.3 KB 211.6 KB
survey-global-stats 831.1 KB 211.6 KB
survey-list 836.0 KB 211.6 KB
survey-stats 831.1 KB 211.6 KB
trace-span 767.1 KB 211.6 KB
trace-span-list 835.2 KB 211.6 KB
vision-observation-list 834.4 KB 211.6 KB
workflow 767.1 KB 211.6 KB
workflow-list 834.7 KB 211.6 KB
workflow-setup-status 766.7 KB 211.6 KB
query-results 1089.6 KB 211.6 KB
render-ui 1193.5 KB 211.6 KB
visual-review-snapshots 771.6 KB 211.6 KB
workflow-data-suggestions 769.4 KB 211.6 KB
workflow-sending-rules 768.1 KB 211.6 KB
ℹ️ MCP agent API — agent-facing tool changes

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

Tools changed (2):

Tool Params Scopes Annotations Schema chars
workflows-send-email 2,190 -> 2,368
workflows-upload-email-template description changed 2,420 -> 2,476
✅ Django migration risk — migration analysis complete

We've analyzed your migrations for potential risks.

Summary: 4 Safe | 0 Needs Review | 0 Blocked

✅ Safe

Brief or no lock, backwards compatible

messaging.0012_messagetemplate_key
  └─ #1 ✅ AddField
     Adding nullable field requires brief lock
     model: messagetemplate, field: key
messaging.0013_messagetemplate_unique_key_per_team
  └─ #1 ✅ SeparateDatabaseAndState
     Wrapper operation - see nested operations for risk: CreateIndexConcurrently
     database_operations: CreateIndexConcurrently
     └─ #2 ✅ CreateIndexConcurrently
        PostHog concurrent-index helper: idempotent (timeout disabling + invalid-leftover recovery)
        model: None, index: unique_message_template_key_per_team
messaging.0014_transactional_email_key
  └─ #1 ✅ CreateModel
     Creating new table is safe
     model: TransactionalEmailKey
  │
  └──> ℹ️  INFO:
       ℹ️  Skipped operations on newly created tables (empty tables
       don't cause lock contention).
messaging.0015_messagetemplate_code_managed_fields
  └─ #1 ✅ AddField
     Adding nullable field requires brief lock
     model: messagetemplate, field: content_hash
  └─ #2 ✅ AddField
     Adding nullable field requires brief lock
     model: messagetemplate, field: managed_by
  └─ #3 ✅ AddField
     Adding nullable field requires brief lock
     model: messagetemplate, field: source_path
  └─ #4 ✅ AddField
     Adding nullable field requires brief lock
     model: messagetemplate, field: source_ref
  └─ #5 ✅ AddField
     Adding nullable field requires brief lock
     model: messagetemplate, field: source_repository
  └─ #6 ✅ AddField
     Adding nullable field requires brief lock
     model: messagetemplate, field: superseded_content_hashes

📚 How to Deploy These Changes Safely

AddField:

This operation acquires a brief lock but doesn't rewrite the table.

Deployment uses lock timeouts with automatic retries, so lock contention will cause retries rather than connection pile-up.

Last updated: 2026-11-02 10:24 UTC (b206f1b)

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostHog Review · Standard · reviewed 9974101: 2 Should fix.

This is the only automatic review while this pull request is a draft. Comment @posthog review to review new changes sooner. Otherwise, a review of the new changes runs when you mark it ready for review.

See this review and its findings in PostHog Review. Comment @posthog review --deep for a Deep review. A Deep review takes longer and looks deeper than a Standard review.

Comment on lines +624 to +625
if "key" in data["template"]:
refuse_unknown_fields(sent, self.fields)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 · Recognize nested form fields before rejecting unknown fields

For form-encoded requests with template.key=welcome, DRF correctly parses the nested template, but sent still contains the raw template.key name. This check rejects it as unknown because self.fields contains only template, so every form-encoded send by key returns 400. Normalize nested form field names before checking for unknown fields.

Comment on lines +1649 to +1653
if found.hash_state == ContentHashState.OUTDATED:
raise TemplateOutdated(TEMPLATE_OUTDATED_DETAIL)
return send_transactional_email(
self.team,
template.id,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 · Validate the published version in the delivery path

After an upload, delayed or lost workflow cache invalidation leaves CDP serving older content. found.hash_state validates the new Postgres content, but send_transactional_email forwards no expected version, so the request succeeds and delivers the previous template without a warning. Pass the published hash through and validate the content used for delivery.

jurajmajerik and others added 29 commits November 6, 2026 11:05
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: posthog[bot] <206114724+posthog[bot]@users.noreply.github.com>
)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
…tage (#130720)

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…e-send

# Conflicts:
#	posthog/plugins/plugin_server_api.py
#	products/messaging/backend/facade/contracts.py
#	products/messaging/backend/facade/templates.py
#	products/messaging/backend/facade/transactional_email.py
#	products/messaging/backend/migrations/max_migration.txt
#	products/messaging/backend/models/message_template.py
#	products/messaging/backend/models/transactional_email_key.py
#	products/messaging/backend/presentation/views/message_templates.py
#	products/messaging/backend/services/html_text.py
#	products/messaging/backend/services/transactional_email.py
#	products/messaging/backend/tests/api/test_transactional_email.py
#	products/messaging/backend/tests/test_html_text.py
#	products/messaging/frontend/generated/api.schemas.ts
#	products/messaging/frontend/generated/api.ts
#	products/messaging/frontend/generated/api.zod.ts
#	products/workflows/backend/facade/transactional_emails.py
#	products/workflows/backend/services/transactional_email_workflows.py
#	products/workflows/frontend/generated/api.zod.ts
#	products/workflows/mcp/email_templates.yaml
#	services/mcp/schema/generated-tool-definitions.json
#	services/mcp/schema/tool-definitions-all.json
#	services/mcp/src/api/generated.ts
#	services/mcp/src/generated/email_templates/api.ts
#	services/mcp/src/generated/workflows/api.ts
#	services/mcp/src/tools/generated/email_templates.ts
#	services/mcp/tests/unit/__snapshots__/tool-schemas/broadcasts-create.json
#	services/mcp/tests/unit/__snapshots__/tool-schemas/workflows-create.json
# Conflicts:
#	products/messaging/backend/facade/contracts.py
#	products/messaging/backend/facade/templates.py
#	products/messaging/backend/presentation/views/message_templates.py
#	products/messaging/backend/tests/api/test_transactional_email.py
#	products/messaging/frontend/generated/api.schemas.ts
#	products/messaging/frontend/generated/api.ts
#	services/mcp/tests/unit/__snapshots__/tool-schemas/workflows-send-email.json
…130770)

Co-authored-by: posthog[bot] <206114724+posthog[bot]@users.noreply.github.com>
Co-authored-by: posthog[bot] <206114724+posthog[bot]@users.noreply.github.com>
Co-authored-by: Paul D'Ambra <paul@posthog.com>
Co-authored-by: tests-posthog[bot] <250237707+tests-posthog[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: veryayskiy <13624984+veryayskiy@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
…130143)

Co-authored-by: posthog[bot] <206114724+posthog[bot]@users.noreply.github.com>
Co-authored-by: Gilbert09 <1459269+Gilbert09@users.noreply.github.com>
Co-authored-by: tests-posthog[bot] <250237707+tests-posthog[bot]@users.noreply.github.com>
…d template_hash

Take the key and the hash check as flat send fields beside master's template_id,
regenerate the OpenAPI types and MCP tool schemas, and keep the MCP send tool on
rendered content only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions github-actions Bot added the feature/agent-infra Feature Tag: Agent infra label Nov 6, 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

feature/agent-infra Feature Tag: Agent infra

Projects

None yet

Development

Successfully merging this pull request may close these issues.