Skip to content

feat(messaging): send an email with inline html from code - #127123

Merged
trunk-io[bot] merged 47 commits into
masterfrom
email-v2/02-django-code-send
Nov 4, 2026
Merged

trunk-io[bot] merged 47 commits into
masterfrom
email-v2/02-django-code-send

Conversation

@Silthus

@Silthus Silthus commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Warning

Merge risk: medium. Migrations 0012 to 0014 add a nullable key column with a unique index per team, built concurrently, and the key table. They stay after a revert.

  • The code form stays behind the per-organization flag workflows-email-from-code.
  • Turn the flag on only after cdp-api and both CDP workers run feat(cdp): deliver a code send's content on its own transactional email route #127122. With only cdp-api on it, a code send answers 202 and delivers nothing.
  • Before the flag goes beyond the PostHog org, move inline-content sends behind the reworked key scopes.
  • A send with both template_id and template_key, or neither, answers 400 on template_id. A send by template_id that also carries subject, html, text, from, reply_to or capture_engagement answers 400 on that field. Master ignored those undocumented fields.

Changes

  • POST /api/projects/:id/messaging/send/email/ now also sends content the caller rendered. No template has to exist first:

    curl "$HOST/api/projects/$PROJECT/messaging/send/email/" -H "Authorization: Bearer $PROJECT_SECRET" -H 'Content-Type: application/json' \
      -d '{"template_key":"welcome","from":"sender@example.com","to":"recipient@example.com","subject":"Hello","html":"<p>Hello</p>"}'
  • The code form is flat: template_key, to, subject, html and/or text, plus optional from, reply_to, distinct_id and capture_engagement.

  • The template form is master's flat body from feat(messaging): take template_id and distinct_id at the top of the send body #126287: template_id, message_data and a flat distinct_id. A send names either template_id or template_key.

  • The nested template.id and identifiers.distinct_id fallback is master's, kept for released SDKs. A code send refuses template and identifiers.

  • A from off the project's verified domains answers 400 sender_not_verified. A from nested too deep to parse answers 400, not 500.

  • A code send without from uses the project's default sender. With no default it uses the only verified sender, and with several it answers 400 sender_required.

  • A code send with a field it doesn't take, an empty key included, answers 400 naming the field. No email goes out without it.

  • An address cdp-api can't send to answers 400 up front. Both forms check to, and the code form each reply_to, with PIPELINE_ADDRESS_PATTERN, the pattern feat(cdp): deliver a code send's content on its own transactional email route #127122 checks. IP literals, non-ASCII domains, @localhost and dots that lead, trail or repeat answer 400 on the field, and cdp-api gets no request.

  • A 400 from cdp-api reaches the caller as 400 invalid_request. Master answered 503 delivery_unavailable, so a caller retried a send that could never succeed. Other unknown answers, such as 401 or 5xx, stay 503.

  • The send endpoints take master's transactional_email:write scope from feat(messaging): give transactional email its own scope #128166. A key with only that scope can send code under a new key, which creates the key's workflow without hog_flow:write.

  • html and text are each capped at 5 MiB. A 413 from cdp-api answers 400 content_too_large, not a retryable 503.

  • The same Idempotency-Key answers 202 with replayed: true and sends nothing new.

  • An unconfirmed code send answers 503 and says how to retry safely.

  • Each key gets one unlisted workflow on its first send. A project holds at most 1000 keys: a new key past that answers 400 too_many_keys, and existing keys keep sending. First sends of new keys take a per-project lock, so racing sends can't pass the limit together.

  • A key's own workflow is sensitive. A code send under a key without a template keeps its content out of the 30-day saved run history, and a rerun skips it with a log line saying why (feat(cdp): deliver a code send's content on its own transactional email route #127122). A key that names a template follows that template's setting. The send API has no sensitive field.

    • As for any sensitive email, the person's messages show a "body not stored" placeholder for these sends.
  • Each environment of a project keeps its own workflow for a key. The key row stores the environment's own team_id, like the workflow it points to.

  • A key's own workflow names its key in the email step as template_key, as template workflows name theirs in template_uuid. Delivery events can then report the key, and no key workflow needs a backfill.

  • Text outside ASCII reaches cdp-api as UTF-8, so the 5 MiB cap counts the bytes cdp-api reads.

  • Plain text made from html starts a new line after a block, so <div>Order 123</div>shipped reads as two lines.

  • The text comes from one streaming pass over html, not a full DOM, so a 5 MiB or deeply nested body converts in bounded memory.

  • Code-send errors name the email's workflow, not a template, and the 413 text no longer points to message_data.

  • A code send's status reads under either send flag, so a team with only workflows-email-from-code can poll the message_id it got back. The code form extends the existing send view, so its auth, access checks and metrics cover both forms.

  • The flag gates every way into the queue. A rerun tells CDP replay_code_sends from workflows-email-from-code, and CDP (#127122) skips a stored code send while it is false. This covers a code send through an existing template's workflow, which a check on the workflow's origin missed.

  • Django mints the code-send token with kind: code_send. feat(cdp): deliver a code send's content on its own transactional email route #127122's code send route requires it, and its template route refuses any token with a kind, so neither token works on the other route.

  • The workflows list filter no longer offers transactional_email_keys, which always came back empty. The create schema's help text says create never takes it.

  • Mechanical: the regenerated OpenAPI, frontend, desktop and MCP types, and the key model in the IDOR rule list. Their only runtime frontend code is the regenerated messaging and workflows api.zod.ts and api.ts, about 65 changed lines. Since the master merge, the CI report's only bundle row is render-query.js at +1.7 KiB, from the regenerated API clients above.

How did you test this code?

Test rationale: the route through the DRF client with cdp-api mocked (tests/api/test_transactional_email.py), the route's OpenAPI schema, the key constraint, and the workflow per key. Each test names its regression: code-send fields dropped before cdp-api, a schema default a client copies into a template send, a timeout that says just retry, and two racing first sends making two workflows.

test_a_new_key_counts_a_key_another_sender_committed_while_it_waited_for_the_lock (test_transactional_email_key_lock.py, outside a test transaction): two first sends of different keys both counting 999 keys and passing the limit, or a lock taken outside a transaction, which Postgres releases at once.

test_a_code_send_goes_out_as_is_under_the_keys_workflow now checks the email step's sensitive: a key's own workflow storing a code send's content in run history, or a template key losing its template's setting.

test_a_code_send_takes_the_addresses_the_pipeline_takes runs 13 addresses through the endpoint for to and reply_to, the table #127122 tests: Django accepting an address cdp-api refuses, or refusing one it sends. test_reports_a_send_the_pipeline_refuses gains invalid_request and an unknown 400: a cdp-api 400 turning into a retryable 503.

These cases fail without their fix: a status read under workflows-email-from-code alone, a from nested 1000 parentheses deep, a rerun with the flag off on a key and on a template workflow, one key sent from a project and its child environment, and a code send with text outside ASCII.

Status read, live at the head 213fad6 with #127122's eb26545 and only workflows-email-from-code on: a code send delivers once, and GET /api/projects/@current/messaging/emails/<message_id>/ answers 200 with sent. An unknown ID answers 404.

Sensitive key workflows, live at 503f4ba with #127122's 20fb623, and before at e2722a6 with ccdd9ff: a code send under a new key delivers once, its email step is sensitive, and its stored run has no send, subject or login code. A rerun answers skipped=1, sends nothing, and logs "Not re-run: this run's message is sensitive…" on the run, for a succeeded and a failed run. A send under a template key keeps sensitive: false, stores send, and a rerun of its failed run sends once.

Addresses, live at the head 7571429 with cdp-api and both workers at #127122's 3639e5d: code sends to jane=tag@example.com (Reply-To o'brien@example.com) and to an address with every symbol email allows, a template send to jane~tag@example.com, and a code send with a key holding only transactional_email:write each deliver once. An IP-literal to and a leading-dot reply_to answer 400 on the field, with no request to cdp-api.

The other proofs ran at 5ec28fe. Since then the branch deleted code comments, changed one help text, added the address check and the 400 mapping, and merged master with #128166. They ran with cdp-api and both workers at #127122's head 2eac306. The Django runs used 31dc832 and 427bea9, which differ from it only in the IDOR rule list and the desktop app's generated client:

  • Live run: an idempotent replay, a template send, capture_engagement on and off on a maildev and an SES sender, all three from fallbacks, and two environments sending one key each deliver as described above.
  • Flag off: a code send answers 404 and creates no key, workflow or job. A template send still delivers.
  • Rerun gate: a code send through a stored template's workflow fails at its email step. A rerun with the flag off skips it and sends nothing. With the flag on, the rerun replays it once, with the caller's content.
  • Shadow diff: 66 public API requests on the merge base and on 5ec28fe. 39 answer differently, and each is a change listed above.
  • Cross-version: with master's cdp-api, a code send answers 503 delivery_unavailable and queues nothing. Template sends deliver in both rollout orders.
  • Prod shape: production has projects in each branch of the from fallback.

CI report warnings that do not apply: the bundle size growth is in render-query, StamphogScene and BroadcastScene, which this PR does not touch; its only frontend change is regenerated API types. The two flaky Playwright tests cover experiments and insight sharing. The MCP snapshot update and the three migrations are this PR's own changes.

Release status

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

Docs update

  • None. 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)

Skills: /tdd, /django-migrations, /improving-drf-endpoints, /writing-tests, /stacking-prs, /recreating-fork-prs, /writing-pr-descriptions

More context for AI agents
  • Session: map Map: send code-managed email templates with one PostHog call Silthus/posthog#461, lane dj3 built it on the fork; the exact reviewed SHA moved here unchanged.
  • Flat body: 51409d9b86b merges master with feat(messaging): take template_id and distinct_id at the top of the send body #126287. The code form keeps HEAD's shape, and the template form, its nested fallback and the either/or check take master's, with the check on template_id vs template_key.
  • After the move: c29fba4 imports the workflows models in the send tests through the workflows facade, because master's hogli lint:tach refuses the internal path. A GPT-6.1 Sol final read found nothing.
  • Decisions: one route and view for both forms, on the existing send view; flag workflows-email-from-code, set by Michael.
  • Decisions, 2026-11-03, Michael: the only verified sender as the from fallback, 400 with several; the flag must cover every entry point, so every rerun passes it and CDP skips stored code sends while it is off.
  • Decisions, 2026-11-03, Michael: a code send under a key without a template is sensitive by default; there is no sensitive field on the send API, and contract Transactional email SDK contract for posthog-python and posthog-node #120921 says so.
  • Decisions, 2026-11-03, map conductor: loosen cdp-api's to check, share one address pattern with Django, and answer a cdp-api 400 with 400; feat(messaging): send cc, bcc and attachments on a code send #127988 imports the pattern for cc and bcc.
  • Decisions: both forms take a flat distinct_id; a send by id refuses from with a 400, and a send with both or neither of template_id and template_key answers 400 on template_id.
  • Reviews: GPT-6.1 Sol rounds 1 and 2, a two-family panel (Sol and Opus 5.5) rounds 3 and 4, then Sol final reads and a fifth panel; final reads 6 to 8 at 31dc832, 5ec28fe and 23f8c06 found nothing. Final read 9 found the address table bypassed the endpoint, fixed in 0d9fb11; final read 10 at 7571429, master merge included, found nothing. Final read 11 at e2722a6 found nothing. The proof judge at e2722a6 found racing first sends of different keys could pass the 1000-key limit, fixed in 164c0db. Final read 12 found the race test did not pin the lock before the count, fixed in 503f4ba; final read 13 found nothing. feat(workflows): send an email with inline html from an MCP tool #127167's final read 9 found the test ran inside the test transaction, fixed in 213fad6; final read 14 found nothing. PostHog Review at c4b7fb2 found the status flag and the deep from, both fixed in 43988f7.
  • Since the move: a 1000-key cap per project, a 5 MiB body cap, one-pass link text, an empty-key refusal, and a send under a key a racing first send just added at the limit.
  • Follow-ups: click tracking for capture_engagement: false sends; once the sandbox sender lands, feat(cdp): send sandbox email through its ses identity to org members #128250 wires it so that a code send without from and with no verified sender sends from it.
  • StampHog at 213fad6: refused for unbounded HTML-to-text memory, fixed in 3a7a7aa with a streaming parser, and final read 15's quadratic scan on unmatched closing tags fixed in ea00975; final read 16 found nothing; and for route-crossing tokens, fixed in feat(cdp): deliver a code send's content on its own transactional email route #127122. The scope widening for inline content is accepted for the internal test phase (Michael, 2026-11-04) and gets reworked before the flag goes past the PostHog org.
  • Read closely: the migrations, and the id-send refusal in message_templates.py.

🤖 Generated with Claude Code

Silthus and others added 9 commits October 31, 2026 08:32
…endpoint

The single send endpoint now takes either a stored template (`template.id`)
or content the caller's code rendered (`template_key`, `subject`, `html` or
`text`, `from`, `reply_to`, `capture_engagement`). The code form is gated by
`workflows-email-from-code` and goes out through cdp-api's accepted-send
route, under one hidden workflow per key. A key that names a template uses
that template's transactional workflow.

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>
Both SDKs send distinct_id as a top-level field with template_key, so the
code form refused their sends as an unknown field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s off template sends

- A code send the pipeline does not confirm answers 503 with a detail that says
  the email may still go out, so a caller checks before it sends again.
- capture_engagement has no schema default, so a generated client no longer adds
  it to a template send, which the server refuses.
- A template send honors a top-level distinct_id, like a code send does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The 503 for an unconfirmed code send now says to send again with the same
Idempotency-Key. The template_key help no longer uses a password reset as
its example, because code sends track their links. A test covers a key
whose workflow was deleted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The unconfirmed-send detail promised that a retry with the same
Idempotency-Key never sends twice, but the key is kept for 24 hours.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Silthus Silthus added hogbox-preview Spin up a per-PR PostHog preview on a hogland hogbox posthog-review labels Nov 2, 2026
@Silthus Silthus self-assigned this Nov 2, 2026
@trunk-io

trunk-io Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

😎 Stack merged successfully - details.

@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 — 🔺 +28.3 KiB (+0.0%)

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

Total: 77.16 MiB · 🔺 +28.3 KiB (+0.0%)

File Size Δ vs base
render-query/src/render-query/render-query.js 21.21 MiB 🔺 +18.7 KiB (+0.1%)
posthog-app/_parent/products/workflows/frontend/Broadcasts/BroadcastScene.js 151.3 KiB 🔺 +6.0 KiB (+4.1%)
posthog-app/_parent/products/stamphog/frontend/scenes/StamphogScene/StamphogScene.js 25.9 KiB 🔺 +2.9 KiB (+12.8%)

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 🟢 -98 B (-0.0%) █████████░ 89.0% 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 · 623 files 🟢 -98 B (-0.0%) ██████████ 98.7% of 3.03 MiB
authenticated shell (every logged-in page)
src/scenes/AuthenticatedShell.tsx
3.86 MiB · 1,233 files 🟢 -98 B (-0.0%) ██████████ 96.2% of 4.01 MiB
dashboard scene
src/scenes/dashboard/Dashboard.tsx
8.25 MiB · 3,033 files 🟢 -98 B (-0.0%) ████████░░ 83.9% of 9.84 MiB
today home path
src/scenes/AuthenticatedShell.tsx + src/scenes/project-homepage/ProjectHomepage.tsx + src/scenes/project-homepage/today/TodayHome.tsx
4.71 MiB · 1,757 files 🟢 -98 B (-0.0%) ██████████ 95.5% of 4.93 MiB
AI-first home path
src/scenes/AuthenticatedShell.tsx + src/scenes/project-homepage/ProjectHomepage.tsx + src/scenes/project-homepage/ai-first/AiFirstHomepage.tsx
5.96 MiB · 2,255 files 🟢 -98 B (-0.0%) ██████████ 95.1% of 6.27 MiB
events scene
src/scenes/activity/explore/EventsScene.tsx
7.29 MiB · 2,614 files 🟢 -98 B (-0.0%) ████████░░ 77.5% of 9.41 MiB
web analytics scene
src/scenes/web-analytics/WebAnalyticsScene.tsx
8.20 MiB · 2,941 files 🟢 -98 B (-0.0%) ████████░░ 78.2% of 10.49 MiB
replay detail scene
src/scenes/session-recordings/detail/SessionRecordingDetail.tsx
7.47 MiB · 2,681 files 🔺 +17.2 KiB (+0.2%) █████████░ 91.9% of 8.13 MiB

🟢 node_modules/monaco-editor/ stays out of src/index.tsx
🟢 products/product_analytics/frontend/insights/activityDescriptions 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/lib/logic/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/kea-test-utils/ 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
🟢 products/product_analytics/frontend/insights/activityDescriptions stays out of src/scenes/AuthenticatedShell.tsx
🟢 node_modules/kea-test-utils/ 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
🟢 src/layout/panel-layout/ProjectTree/menus/SessionReplayMenuItems.tsx stays out of src/scenes/AuthenticatedShell.tsx
🟢 src/taxonomy/core-filter-definitions-by-group.json stays out of src/scenes/AuthenticatedShell.tsx
🟢 src/layout/navigation/projectNoticeLogic.tsx stays out of src/scenes/AuthenticatedShell.tsx
🟢 src/layout/navigation/ImpersonationNotice/ImpersonationNotice.tsx stays out of src/scenes/AuthenticatedShell.tsx
🟢 src/scenes/cohorts/cohortSavedToast.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
🟢 products/subscriptions/frontend/components/Subscriptions/exportNudge/exportNudgeLogic.ts stays out of src/scenes/AuthenticatedShell.tsx
🟢 src/scenes/billing/paymentEntryLogic.ts stays out of src/scenes/AuthenticatedShell.tsx
🟢 products/conversations/frontend/components/SidePanel/sidepanelTicketsLogic.ts stays out of src/scenes/AuthenticatedShell.tsx
🟢 node_modules/zod/ stays out of src/scenes/AuthenticatedShell.tsx
🟢 src/scenes/authentication/auth/loginLogic.ts stays out of src/scenes/AuthenticatedShell.tsx
🟢 src/scenes/authentication/shared/passkeyLogic.ts stays out of src/scenes/AuthenticatedShell.tsx
🟢 src/scenes/surveys/constants.tsx stays out of src/scenes/AuthenticatedShell.tsx
🟢 node_modules/@tiptap/ stays out of src/scenes/AuthenticatedShell.tsx
🟢 node_modules/prosemirror- stays out of src/scenes/AuthenticatedShell.tsx
🟢 node_modules/re2js/ stays out of src/scenes/AuthenticatedShell.tsx
🟢 node_modules/react-markdown/ stays out of src/scenes/AuthenticatedShell.tsx
🟢 src/lib/components/Command/Command.tsx stays out of src/scenes/AuthenticatedShell.tsx
🟢 packages/quill/packages/ 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
🟢 packages/quill/packages/quill/dist/ stays out of src/scenes/dashboard/Dashboard.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/queries/web-analytics-validators.js stays out of src/scenes/dashboard/Dashboard.tsx
🟢 node_modules/re2js/ stays out of src/scenes/dashboard/Dashboard.tsx
🟢 node_modules/kea-test-utils/ stays out of src/scenes/dashboard/Dashboard.tsx
🟢 src/scenes/debug/HogDebug.tsx stays out of src/scenes/dashboard/Dashboard.tsx
🟢 src/queries/QueryEditor/QueryEditor.tsx stays out of src/scenes/dashboard/Dashboard.tsx
🟢 packages/quill/packages/quill/dist/ stays out of src/scenes/AuthenticatedShell.tsx + src/scenes/project-homepage/ProjectHomepage.tsx + src/scenes/project-homepage/today/TodayHome.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
🟢 packages/quill/packages/quill/dist/ 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/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
🟢 packages/quill/packages/quill/dist/ stays out of src/scenes/activity/explore/EventsScene.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/queries/web-analytics-validators.js stays out of src/scenes/activity/explore/EventsScene.tsx
🟢 node_modules/re2js/ stays out of src/scenes/activity/explore/EventsScene.tsx
🟢 node_modules/kea-test-utils/ stays out of src/scenes/activity/explore/EventsScene.tsx
🟢 src/queries/nodes/InsightViz/InsightViz.tsx stays out of src/scenes/activity/explore/EventsScene.tsx
🟢 src/queries/nodes/DataVisualization/DataVisualization.tsx stays out of src/scenes/activity/explore/EventsScene.tsx
🟢 src/queries/nodes/WebOverview/WebOverview.tsx stays out of src/scenes/activity/explore/EventsScene.tsx
🟢 src/scenes/debug/HogDebug.tsx stays out of src/scenes/activity/explore/EventsScene.tsx
🟢 src/queries/QueryEditor/QueryEditor.tsx stays out of src/scenes/activity/explore/EventsScene.tsx
🟢 packages/quill/packages/quill/dist/ stays out of src/scenes/web-analytics/WebAnalyticsScene.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/queries/validators.js stays out of src/scenes/web-analytics/WebAnalyticsScene.tsx
🟢 node_modules/re2js/ stays out of src/scenes/web-analytics/WebAnalyticsScene.tsx
🟢 node_modules/kea-test-utils/ stays out of src/scenes/web-analytics/WebAnalyticsScene.tsx
🟢 src/scenes/debug/HogDebug.tsx stays out of src/scenes/web-analytics/WebAnalyticsScene.tsx
🟢 src/queries/QueryEditor/QueryEditor.tsx stays out of src/scenes/web-analytics/WebAnalyticsScene.tsx
🟢 packages/quill/packages/quill/dist/ stays out of src/scenes/session-recordings/detail/SessionRecordingDetail.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
🟢 node_modules/re2js/ stays out of src/scenes/session-recordings/detail/SessionRecordingDetail.tsx
🟢 node_modules/kea-test-utils/ stays out of src/scenes/session-recordings/detail/SessionRecordingDetail.tsx
🟢 node_modules/d3/src/ 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.9 KiB src/lib/utils/bootTiming.ts
1.7 KiB src/index.tsx
1.7 KiB ../node_modules/.pnpm/base64-js@1.5.1/node_modules/base64-js/index.js
1.2 KiB src/RootErrorBoundary.tsx
1.1 KiB src/lib/utils/deferredAppContext.ts
Largest files eagerly shipped from src/index.tsx + src/scenes/App.tsx + src/scenes/bootApp.ts
Size File
356.2 KiB ../node_modules/.pnpm/posthog-js@1.445.2_@types+react@18.3.27_react@18.3.1/node_modules/posthog-js/dist/module.mjs
224.1 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
104.6 KiB src/products.tsx
89.6 KiB src/lib/api.ts
68.7 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
28.4 KiB src/lib/constants.tsx
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
356.2 KiB ../node_modules/.pnpm/posthog-js@1.445.2_@types+react@18.3.27_react@18.3.1/node_modules/posthog-js/dist/module.mjs
224.1 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
104.6 KiB src/products.tsx
89.6 KiB src/lib/api.ts
68.7 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
32.3 KiB src/lib/utils/eventUsageLogic.ts
28.4 KiB src/lib/constants.tsx
Largest files eagerly shipped from src/scenes/dashboard/Dashboard.tsx
Size File
356.2 KiB ../node_modules/.pnpm/posthog-js@1.445.2_@types+react@18.3.27_react@18.3.1/node_modules/posthog-js/dist/module.mjs
224.1 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
114.5 KiB ../node_modules/.pnpm/posthog-js@1.445.2_@types+react@18.3.27_react@18.3.1/node_modules/posthog-js/dist/surveys-preview.js
110.8 KiB ../packages/quill/packages/primitives/dist/index.js
104.6 KiB src/products.tsx
96.1 KiB ../packages/quill/packages/components/dist/index.js
94.6 KiB ../node_modules/.pnpm/@tiptap+core@3.20.6_@tiptap+pm@3.20.6/node_modules/@tiptap/core/dist/index.js
92.6 KiB ../node_modules/.pnpm/prosemirror-view@1.40.1/node_modules/prosemirror-view/dist/index.js
89.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/today/TodayHome.tsx
Size File
356.2 KiB ../node_modules/.pnpm/posthog-js@1.445.2_@types+react@18.3.27_react@18.3.1/node_modules/posthog-js/dist/module.mjs
224.1 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
110.8 KiB ../packages/quill/packages/primitives/dist/index.js
104.6 KiB src/products.tsx
89.6 KiB src/lib/api.ts
68.7 KiB src/lib/lemon-ui/icons/icons.tsx
68.0 KiB ../products/posthog_ai/frontend/logics/runStreamLogic.ts
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
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
356.2 KiB ../node_modules/.pnpm/posthog-js@1.445.2_@types+react@18.3.27_react@18.3.1/node_modules/posthog-js/dist/module.mjs
224.1 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
110.8 KiB ../packages/quill/packages/primitives/dist/index.js
104.6 KiB src/products.tsx
89.6 KiB src/lib/api.ts
68.7 KiB src/lib/lemon-ui/icons/icons.tsx
68.0 KiB ../products/posthog_ai/frontend/logics/runStreamLogic.ts
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
41.7 KiB src/scenes/hog-functions/sub-templates/sub-templates.ts
Largest files eagerly shipped from src/scenes/activity/explore/EventsScene.tsx
Size File
356.2 KiB ../node_modules/.pnpm/posthog-js@1.445.2_@types+react@18.3.27_react@18.3.1/node_modules/posthog-js/dist/module.mjs
224.1 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
110.8 KiB ../packages/quill/packages/primitives/dist/index.js
104.6 KiB src/products.tsx
96.1 KiB ../packages/quill/packages/components/dist/index.js
94.6 KiB ../node_modules/.pnpm/@tiptap+core@3.20.6_@tiptap+pm@3.20.6/node_modules/@tiptap/core/dist/index.js
92.6 KiB ../node_modules/.pnpm/prosemirror-view@1.40.1/node_modules/prosemirror-view/dist/index.js
89.6 KiB src/lib/api.ts
75.5 KiB src/scenes/dashboard/dashboardLogic.tsx
Largest files eagerly shipped from src/scenes/web-analytics/WebAnalyticsScene.tsx
Size File
356.2 KiB ../node_modules/.pnpm/posthog-js@1.445.2_@types+react@18.3.27_react@18.3.1/node_modules/posthog-js/dist/module.mjs
224.1 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
114.5 KiB ../node_modules/.pnpm/posthog-js@1.445.2_@types+react@18.3.27_react@18.3.1/node_modules/posthog-js/dist/surveys-preview.js
110.8 KiB ../packages/quill/packages/primitives/dist/index.js
104.6 KiB src/products.tsx
96.1 KiB ../packages/quill/packages/components/dist/index.js
94.6 KiB ../node_modules/.pnpm/@tiptap+core@3.20.6_@tiptap+pm@3.20.6/node_modules/@tiptap/core/dist/index.js
92.6 KiB ../node_modules/.pnpm/prosemirror-view@1.40.1/node_modules/prosemirror-view/dist/index.js
89.6 KiB src/lib/api.ts
Largest files eagerly shipped from src/scenes/session-recordings/detail/SessionRecordingDetail.tsx
Size File
356.2 KiB ../node_modules/.pnpm/posthog-js@1.445.2_@types+react@18.3.27_react@18.3.1/node_modules/posthog-js/dist/module.mjs
321.5 KiB ../node_modules/.pnpm/posthog-js@1.445.2_@types+react@18.3.27_react@18.3.1/node_modules/posthog-js/dist/rrweb.js
224.1 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
110.8 KiB ../packages/quill/packages/primitives/dist/index.js
104.6 KiB src/products.tsx
96.1 KiB ../packages/quill/packages/components/dist/index.js
94.6 KiB ../node_modules/.pnpm/@tiptap+core@3.20.6_@tiptap+pm@3.20.6/node_modules/@tiptap/core/dist/index.js
92.6 KiB ../node_modules/.pnpm/prosemirror-view@1.40.1/node_modules/prosemirror-view/dist/index.js
89.6 KiB src/lib/api.ts

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

✅ Frontend layers — no new upward imports, 0 removed

Static imports from a lower frontend layer into a higher one (layers, lowest first: kernel, shell, query, features). Compared with the base branch, so an import that a PR also adds to layer-baseline.txt still shows here. Sizes are the source bytes on the eager path of every logged-in page.

Layer Eager source bytes Δ vs base
kernel 2.99 MiB no change
shell 2.38 MiB no change
query 56.4 KiB no change
features 356.2 KiB no change

Download the interactive layer graph for this PR.

Posted automatically by check-layers

✅ Toolbar bundle — eager 2.38 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.38 MiB · 19 files 🟢 -98 B (-0.0%) ████░░░░░░ 41.6% 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
954.1 KiB dist/toolbar/toolbar-app-R2Z72QDW.css
727.8 KiB dist/toolbar/chunk-chunk-P74TPHVK.js
261.3 KiB dist/toolbar/chunk-chunk-LPZVU6KG.js
131.8 KiB dist/toolbar/chunk-chunk-FDH2IBXT.js
130.3 KiB dist/toolbar/chunk-chunk-ZIENG33C.js
75.7 KiB dist/toolbar/toolbar-app-76MY5FO6.js
69.0 KiB dist/toolbar/chunk-chunk-TSAL54PB.js
33.8 KiB dist/toolbar/chunk-chunk-RNAY4ENA.js
21.7 KiB dist/toolbar/chunk-chunk-3FRNZBT5.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 — 🔺 +448.7 KiB (+0.0%)

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

Total: 972.17 MiB · 🔺 +448.7 KiB (+0.0%)

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

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

App JS CSS
debug 848.8 KB 214.1 KB
explain-chart 1123.9 KB 214.1 KB
action 771.5 KB 214.1 KB
action-list 839.8 KB 214.1 KB
cohort 770.5 KB 214.1 KB
cohort-list 838.8 KB 214.1 KB
email-template 770.4 KB 214.1 KB
error-details 775.4 KB 214.1 KB
error-issue 772.7 KB 214.1 KB
error-issue-list 841.2 KB 214.1 KB
experiment 836.9 KB 214.1 KB
experiment-list 840.5 KB 214.1 KB
experiment-results 838.7 KB 214.1 KB
feature-flag 839.2 KB 214.1 KB
feature-flag-list 842.9 KB 214.1 KB
feature-flag-testing 773.8 KB 214.1 KB
inline-scan 771.0 KB 214.1 KB
insight-actors 838.3 KB 214.1 KB
llm-costs 835.6 KB 214.1 KB
session-recording 772.8 KB 214.1 KB
survey 772.1 KB 214.1 KB
survey-global-stats 835.6 KB 214.1 KB
survey-list 840.5 KB 214.1 KB
survey-stats 835.6 KB 214.1 KB
trace-span 770.9 KB 214.1 KB
trace-span-list 839.7 KB 214.1 KB
vision-observation-list 838.9 KB 214.1 KB
workflow 770.9 KB 214.1 KB
workflow-list 839.1 KB 214.1 KB
workflow-setup-status 770.6 KB 214.1 KB
query-results 1095.4 KB 214.1 KB
render-ui 1199.9 KB 214.1 KB
visual-review-snapshots 775.4 KB 214.1 KB
workflow-data-suggestions 773.2 KB 214.1 KB
workflow-sending-rules 771.9 KB 214.1 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
broadcasts-create 19,872 -> 20,088
workflows-create 19,872 -> 20,088
⚠️ Playwright — 2 flaky

🎭 Playwright report · View test results →

⚠️ 2 flaky tests:

  • create experiment via wizard, add metrics, and launch (chromium)
  • password-protected insight sharing (chromium)

These issues are not necessarily caused by your changes.
Annoyed by this section? Help fix flakies and failures and it will go green!

⚠️ MCP snapshots — 1 updated (1 modified, 0 added, 0 deleted)

Snapshots: MCP unit test snapshots updated

Changes: 1 snapshots (1 modified, 0 added, 0 deleted)

What this means:

  • Snapshots have been automatically updated to match current output

Next steps:

  • Review the changes to ensure they're intentional
  • If unexpected, investigate what caused the output to change

Review snapshot changes →

⚠️ Django migration SQL — 3 new migrations to review

We've detected new migrations on this PR. Review the SQL output for each migration:

products/messaging/backend/migrations/0013_messagetemplate_key.py

/home/runner/work/tmp/tool_cache/Python/3.14.7/x64/lib/python3.14/site-packages/anyio/from_thread.py:119: SyntaxWarning: 'return' in a 'finally' block
  return result
2026-11-04T14:03:37.847667Z [error    ] Path must be a valid database or directory containing databases. [posthog.exceptions_capture] pid=6314 tid=140359376313216
Traceback (most recent call last):
  File "/home/runner/work/posthog/posthog/posthog/geoip.py", line 15, in <module>
    geoip: Optional[GeoIP2] = GeoIP2(cache=8)
                              ~~~~~~^^^^^^^^^
  File "/home/runner/work/tmp/tool_cache/Python/3.14.7/x64/lib/python3.14/site-packages/django/contrib/gis/geoip2.py", line 114, in __init__
    raise GeoIP2Exception(
        "Path must be a valid database or directory containing databases."
    )
django.contrib.gis.geoip2.GeoIP2Exception: Path must be a valid database or directory containing databases.
/home/runner/work/tmp/tool_cache/Python/3.14.7/x64/lib/python3.14/site-packages/sshtunnel.py:1040: SyntaxWarning: 'return' in a 'finally' block
  return (ssh_host,
System check identified some issues:

WARNINGS:
?: (axes.W001) You are using the django-axes cache handler for login attempt tracking. Your cache configuration is however invalid and will not work correctly with django-axes. This can leave security holes in your login systems as attempts are not tracked correctly. Reconfigure settings.AXES_CACHE and settings.CACHES per django-axes configuration documentation.
?: (staticfiles.W004) The directory '/home/runner/work/posthog/posthog/frontend/dist' in the STATICFILES_DIRS setting does not exist.
BEGIN;
--
-- Add field key to messagetemplate
--
ALTER TABLE "posthog_messagetemplate" ADD COLUMN "key" varchar(400) NULL;
COMMIT;

products/messaging/backend/migrations/0014_messagetemplate_unique_key_per_team.py

2026-11-04T14:04:24.016550Z [error    ] Path must be a valid database or directory containing databases. [posthog.exceptions_capture] pid=7480 tid=140470137600896
Traceback (most recent call last):
  File "/home/runner/work/posthog/posthog/posthog/geoip.py", line 15, in <module>
    geoip: Optional[GeoIP2] = GeoIP2(cache=8)
                              ~~~~~~^^^^^^^^^
  File "/home/runner/work/tmp/tool_cache/Python/3.14.7/x64/lib/python3.14/site-packages/django/contrib/gis/geoip2.py", line 114, in __init__
    raise GeoIP2Exception(
        "Path must be a valid database or directory containing databases."
    )
django.contrib.gis.geoip2.GeoIP2Exception: Path must be a valid database or directory containing databases.
System check identified some issues:

WARNINGS:
?: (axes.W001) You are using the django-axes cache handler for login attempt tracking. Your cache configuration is however invalid and will not work correctly with django-axes. This can leave security holes in your login systems as attempts are not tracked correctly. Reconfigure settings.AXES_CACHE and settings.CACHES per django-axes configuration documentation.
?: (staticfiles.W004) The directory '/home/runner/work/posthog/posthog/frontend/dist' in the STATICFILES_DIRS setting does not exist.
--
-- Custom state/database change combination
--
SET lock_timeout = 0;
SET statement_timeout = 0;
CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "unique_message_template_key_per_team" ON "posthog_messagetemplate" ("team_id", "key") WHERE (NOT "deleted" AND "key" IS NOT NULL);

products/messaging/backend/migrations/0015_transactional_email_key.py

2026-11-04T14:04:43.543900Z [error    ] Path must be a valid database or directory containing databases. [posthog.exceptions_capture] pid=7905 tid=140247952915328
Traceback (most recent call last):
  File "/home/runner/work/posthog/posthog/posthog/geoip.py", line 15, in <module>
    geoip: Optional[GeoIP2] = GeoIP2(cache=8)
                              ~~~~~~^^^^^^^^^
  File "/home/runner/work/tmp/tool_cache/Python/3.14.7/x64/lib/python3.14/site-packages/django/contrib/gis/geoip2.py", line 114, in __init__
    raise GeoIP2Exception(
        "Path must be a valid database or directory containing databases."
    )
django.contrib.gis.geoip2.GeoIP2Exception: Path must be a valid database or directory containing databases.
System check identified some issues:

WARNINGS:
?: (axes.W001) You are using the django-axes cache handler for login attempt tracking. Your cache configuration is however invalid and will not work correctly with django-axes. This can leave security holes in your login systems as attempts are not tracked correctly. Reconfigure settings.AXES_CACHE and settings.CACHES per django-axes configuration documentation.
?: (staticfiles.W004) The directory '/home/runner/work/posthog/posthog/frontend/dist' in the STATICFILES_DIRS setting does not exist.
BEGIN;
--
-- Create model TransactionalEmailKey
--
CREATE TABLE "messaging_transactionalemailkey" ("id" uuid NOT NULL PRIMARY KEY, "key" varchar(400) NOT NULL, "hog_flow_id" uuid NOT NULL, "created_at" timestamp with time zone NOT NULL, "team_id" integer NOT NULL, CONSTRAINT "unique_transactional_email_key_per_team" UNIQUE ("team_id", "key"));
CREATE INDEX "messaging_transactionalemailkey_team_id_41b9e2b2" ON "messaging_transactionalemailkey" ("team_id");
COMMIT;

Last updated: 2026-11-04 14:05 UTC (c81b9e0)

✅ Django migration risk — migration analysis complete

We've analyzed your migrations for potential risks.

Summary: 3 Safe | 0 Needs Review | 0 Blocked

✅ Safe

Brief or no lock, backwards compatible

messaging.0013_messagetemplate_key
  └─ #1 ✅ AddField
     Adding nullable field requires brief lock
     model: messagetemplate, field: key
messaging.0014_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.0015_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).

📚 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-04 14:05 UTC (c81b9e0)

ℹ️ Hogbox preview — building

Spinning up PostHog for this PR on a hogland hogbox. This section updates in place when it's ready (usually a few minutes).

commit c81b9e0 · build log

@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

React Doctor found no issues in the changed files. 🎉

Reviewed by React Doctor for commit c81b9e0.

@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 0d164b2: 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.

@hosthog

hosthog Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

HostHog preview — posthog-desktop-web

The previews for this PR have been torn down and no longer serve.

…d tests

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

Takes master's flat send body from #126287: template_id or template_key,
and a flat distinct_id, with master's nested fallback kept for released
SDKs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Silthus and others added 2 commits November 4, 2026 12:17
Master added messaging migration 0012_email_hard_bounce, so the layer's
messaging migrations move to 0013-0015 on top of it.

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

@stamphog stamphog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Not approved yet — waiting on the conditions below.

The Migration risk check has not finished for this commit, so stamphog cannot tell a safe migration from a risky one yet. The review runs again on the next push, or you can re-request it once the check reports.

  • 1 of the 39 changed files are governed by products/desktop/AGENT_APPROVALS.md.
Gate mechanics and policy version
Gate Result
prerequisites ✓ all clear
deny-list ✗ matches: migrations
size ✓ 1078L, 21F substantive, 1984L/39F incl. docs/generated/snapshots — within ceiling
tier ✗ classified as T2-never: T2-never (1984L, 39F, cross-cutting, feat)
stamphog 2.7.0 .stamphog/policy.yml @ unknown · reviewed head 99bc511

Master's #125785 passes the loaded template into send_transactional_email.
The layer's template route and its key lookup now pass the template too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Silthus added a commit that referenced this pull request Nov 4, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ty refuses a MessageTemplate in the UUID | TransactionalTemplate parameter,
so the UUID branch holds the loaded row in its own variable, as master does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Silthus added a commit that referenced this pull request Nov 4, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
parseaddr() is quadratic on group addresses, and validate_from() ran it on
an unbounded value before the flag and sender checks. The field now takes
at most 1,024 characters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Silthus added a commit that referenced this pull request Nov 4, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	products/messaging/frontend/generated/api.zod.ts
@Silthus

Silthus commented Nov 4, 2026

Copy link
Copy Markdown
Contributor Author

/trunk merge

Silthus added a commit that referenced this pull request Nov 4, 2026
…irst-code-send-event

Brings in #127123's master merge, which adds messaging migration 0012_email_hard_bounce and renumbers the code-send key migration to 0015_transactional_email_key. The first code send migration becomes 0016_first_code_send on top of it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@trunk-io
trunk-io Bot merged commit 01d46bc into master Nov 4, 2026
311 of 312 checks passed
@trunk-io
trunk-io Bot deleted the email-v2/02-django-code-send branch November 4, 2026 17:16
@deployment-status-posthog

deployment-status-posthog Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

Deploy status

Environment Status Deployed At Workflow
dev ✅ Deployed 2026-11-04 17:45 UTC Run
prod-us ✅ Deployed 2026-11-04 18:20 UTC Run
prod-eu ✅ Deployed 2026-11-04 18:22 UTC Run

Silthus added a commit to PostHog/posthog-rs that referenced this pull request Nov 13, 2026
* feat: send transactional email with client.emails()

Add `client.emails()` with two send forms, `TemplateEmail` for a template
made transactional in PostHog and `CodeEmail` for content the app rendered,
and `get` for an email's delivery status. Both clients share the argument
checks, flat request body, error mapping and retry policy; shutdown refuses
new email requests and waits for those in flight within its timeout.

New client options: app_host, email_request_timeout_seconds,
email_request_max_retries and migrated_from.

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

* refactor(emails): one retry loop per HTTP client, httpmock test server

- Each Emails handle retries inside request(); send and get only parse the
  2xx reply, so a malformed success is never retried.
- The email test server is httpmock's stateful responder instead of a raw
  TCP server.
- New tests pin email_request_timeout_seconds against a slow server, and
  that it replaces a custom HTTP client's longer timeout.
- wait_idle drops an explicit Notified::enable that notify_waiters does not
  need.

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

* test(emails): bound the async shutdown tests' wait for the first request

The wait now fails when the send ends without reaching the server, or
after 10 seconds, instead of hanging.

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

* fix(emails): keep Error unwind safe

Error::Email holds an EmailError, whose source was a boxed dyn Error. That
made posthog_rs::Error lose UnwindSafe and RefUnwindSafe, which it has on
main, so code that moves an SDK error across catch_unwind would stop
compiling. The source now sits in a newtype that keeps both, and a unit test
pins Send, Sync, UnwindSafe and RefUnwindSafe on Error and EmailError.

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

* fix(emails): read at most 1 MiB of a reply

Both clients read an email reply in full, so a hostile or misconfigured app
host could make the application buffer an oversized or gzip-expanded body.
They now read at most 1 MiB after decompression. A larger body is unreadable,
as a failed read was already: an acceptance becomes a retryable network
error, and a refusal still counts by its status and Retry-After.

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

* test(emails): prove reading stops at the reply cap

A raw-socket server claims a 10 MB body, sends 1.1 MiB and holds the
connection open. Both clients must report the oversized reply within
5 seconds, well before their 10 second request timeout. A client that read
the whole body first fails this test.

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

* fix(emails): wait for email requests when shutdown runs off the runtime

`futures::executor::block_on(client.shutdown())`, as the global
`posthog_rs::shutdown()` runs it, returned before a send in its retry
backoff finished. Off a Tokio runtime, shutdown now blocks the calling
thread until the requests end or `shutdown_timeout_ms` passes. From a
transport callback it still waits for nothing.

Also from the qa-swarm round 2 panel:
- `SendEmail` is `#[non_exhaustive]`, so a later send form isn't a
  breaking change.
- `Error::Email` returns the email error's cause from `source()`, so a
  chain report no longer prints the message twice.
- The network-error test proves its retry by the backoff it waits.
- One shared rule turns a read body into a reply for both clients, and
  inline comments became names and docs.

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

* feat(emails): send cc, bcc and attachments with a code send

A `CodeEmail` takes `cc` and `bcc`, one address per call, and
`attachment(EmailAttachment)`. `EmailAttachment::new` encodes the file's
bytes as Base64 once; `from_base64` sends encoded content as is, with an
optional `content_type` and `content_id`. Empty lists stay off the wire,
so an older PostHog still sends a plain email, and a send that does carry
one of these fields to a PostHog without it is refused with an error
naming the field. `TemplateEmail` has none of these: the template sets
its own recipients and content. An attachment's `Debug` output never
shows its content.

Matches posthog-python (PostHog/posthog-python#1172) and posthog-node
(PostHog/posthog-js#5514), against the server contract in
PostHog/posthog#127988.

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

* test(emails): format the attachment Debug assertions for edition 2018

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

* test(client): prove a transport callback's shutdown waits for no email request

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

* test(emails): script the older server's refusal as PostHog sends it

The cross-version live run against PostHog/posthog#127123 answered a send
with attachments with 400 validation_error, code invalid_input and attr
attachments. The test now scripts that reply instead of a guessed one.

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

* docs(emails): say the attachment limit counts file bytes before Base64

PostHog caps attachments at 512 KB of decoded file content. The method doc
and the guide now say so, since Base64 makes the encoded content about a
third larger.

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

* feat(emails): upload attachments by hash, then send their references

A code send's files no longer ride inline as Base64. The client hashes each
file, asks PostHog which files it lacks, PUTs those to the presigned URLs
PostHog returns with only the signed headers, then sends the email naming
each file by its SHA-256. It checks 10 files and the 40 MB message estimate
before any request, and a refused upload stops the send with the file
store's answer.

Refs PostHog/michaelr-context#87

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

* refactor(emails): format the SHA-256 with the digest's own hex

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

* fix(emails): keep the signed upload URL out of errors

A failed upload's error drops the presigned URL, whose query signs the
upload, and a request that can't be built is an unexpected_error. Tests
cover a file attached twice uploading once and a 503 from the file store
being retried. The guide names the upload timeout, and the custom client
docs say default headers reach the file store too.

Refs PostHog/michaelr-context#87

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

* fix(emails): retry the file store's transient server errors

An upload now retries the file store's 500, 502 and 504 as well as 503,
since a PUT of the same bytes is safe to repeat. PostHog's own 500 is
still final. These answers have kind service_unavailable.

Refs PostHog/michaelr-context#87

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

* refactor(emails): tidy the upload error's doc and test

One doc comment for Upload::request_error, and the signed-URL test
reuses closed_url().

Refs PostHog/michaelr-context#87

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

* test(emails): pin the body estimate to the server's

A body counts like a file: its UTF-8 bytes in Base64 with line breaks,
so 57 ASCII bytes count 78 and 57 two-byte characters count 156. The
upload refusal's doc names every retried status, and the retry rule
names its reason in a helper.

Refs PostHog/michaelr-context#87

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

* fix(emails): report an upload-route 404 as not_found on attachments

A 404 from the upload route means PostHog has no upload route or sending
email from code is off for the project. The client can't tell which, so
it reports PostHog's own answer, not_found when the reply named no code,
on field attachments, and sends nothing.

Refs PostHog/michaelr-context#87

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

* docs(emails): name every cause of an upload-route 404

The NotFound kind covers a send with files to a PostHog without the
upload route, and the guide and mapper name a wrong app_host as a third
cause.

Refs PostHog/michaelr-context#87

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

* fix(emails): keep a supplied client's default headers off the file store

When the app supplies http_client or blocking_http_client, uploads go
through a client the SDK builds, so the app's default headers, such as a
credential, never reach the file store or make it refuse the signed PUT.
The SDK's own client still uploads when the app supplies none.

Refs PostHog/michaelr-context#87

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

* fix(emails): keep PostHog's code on an upload-route 404

The sdk-specs emails spec carries a response's code unchanged, or
api_error when it has none, so an upload-route 404 keeps PostHog's
answer and only names attachments as the field.

Refs PostHog/michaelr-context#87

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

* docs(emails): say what the upload client takes from a supplied one

A supplied client's root certificates, TLS options and in-code proxy don't
reach uploads; the SDK-built client trusts the system roots and takes its
proxy from the environment.

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

* fix(emails): count the subject in the message estimate

The server counts the subject as folded encoded words, 75 bytes for each
42 UTF-8 bytes. Without it, a long subject beside a near-limit file passed
the local check, uploaded, and was refused at send. PostHog/michaelr-context#87

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

* test(emails): prove uploads against a fake that behaves like PostHog

The upload tests now run against an in-memory PostHog and file store that
behave like the real ones: the store keeps a file only when its bytes match
the signed checksum and no other credentials ride along, and PostHog accepts
a send only when each file it names is stored. The size, retry and error-kind
cases move from private helpers to public sends.

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

* fix(emails): build the upload client once per PostHog client

With a supplied HTTP client, each send with files built and dropped its own
upload client: a runtime thread per send for the blocking flavour, and no
connection reuse for either. The PostHog client now keeps the one it built.
Tests' closed_url moves to port 1, so a parallel test's server can't take it.

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

* test(emails): let the fake refuse what PostHog and S3 refuse

The fake now takes only POST and JSON on PostHog's routes, refuses a blank
subject or a send with no body, answers field_not_accepted when files are off,
and issues expired grants or grants for other bytes that its store refuses on
its own. Tests read the store's versions and the accepted emails instead of
request counts, and the size boundary sends real emails with HTML, text or
both.

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

* fix(emails): count derived text and upload only over https

Without text, PostHog sends text it derives from the HTML and counts it in
the size estimate, so the client now counts the HTML twice. An HTML-only
send near the limit was passing the local check, uploading its files, and
then being refused.

A file store URL that isn't https now fails the send on that attachment
before any upload, with a fix to make the store's URL https and no URL in
the error. Only an app_host that is plain http itself, the existing way to
reach a local PostHog, allows plain-http uploads. No new option.

Tests reach the fake PostHog over https through a TLS front with a test
certificate. Also adds a test that a refused upload isn't retried, and
simplifies the fake's subject check.

Refs PostHog/michaelr-context#87

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

* fix(emails): never follow a redirect on an upload

An https upload URL could still leak the file: reqwest follows a 307 or 308
on the PUT and re-sends the body, even from https to http. Every upload now
goes through the client the SDK builds, which follows no redirect, whether or
not the app supplied its own. A 3xx fails that attachment with
attachment_upload_failed, names the attachment but not the URL, and sends
nothing. A signed upload never succeeds through a redirect.

The no-retry test now uses the fake's expired grants and shows the refusal
comes back before the first retry could have run, and the https refusal test
pins its api_error code, which the guide now names.

Refs PostHog/michaelr-context#87

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

* test(emails): redirect uploads to a second behaving store

The redirect tests sent the 307 to a scripted server and counted its
requests. They now send it to a second fake file store that would keep a
correctly signed PUT, and assert that it kept nothing, along with the error,
the empty first store and no send.

Refs PostHog/michaelr-context#87

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

* fix(emails): fail a send whose upload grant overflows the header map

HeaderMap::insert panics past 32,768 entries, which a malformed or
compromised upload-route reply can reach below the reply size limit.
Insert with try_insert so such a reply reads as malformed instead.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Silthus added a commit to PostHog/posthog-go that referenced this pull request Nov 13, 2026
* feat(emails): send transactional email and read its status

Add Client.Emails(), a port of posthog.emails from posthog-node and
posthog-python. Send takes a TemplateEmail (a template made in PostHog,
by ID) or a CodeEmail (content the app rendered, named by a key), posts
the flat body to /api/projects/@current/messaging/send/email/ on the app
host with the project secret key, and retries with one idempotency key.
Get reads /api/projects/@current/messaging/emails/{id}/. Close waits for
email requests in flight.

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

* refactor(emails): retry one request path, settle the Close test

One request method sends, retries and classifies a reply that isn't a 2xx,
so Send and Get parse a successful reply once. The Close test checks the
retry reached the server before Close returned, instead of a race on the
result channel.

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

* fix(emails): refuse nil emails, keep the context cause, reject trailing bytes

- Send refuses a nil *TemplateEmail or *CodeEmail with invalid_form instead
  of panicking.
- When the context ends during a retry wait, the error wraps ctx.Err(), so
  errors.Is finds context.DeadlineExceeded.
- A reply that isn't JSON as a whole carries no fields, so a 2xx with
  trailing bytes is an api_error.

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

* test(emails): assert the shutdown cause on an interrupted send

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

* fix(emails): encode request bodies with encoding/json

goccy/go-json's MarshalNoEscape hides its argument from escape analysis, so
a body built on the stack can move while it encodes. The Go 1.21 race tests
caught it reading the template body as garbage and panicking. The email
bodies now use the standard library encoder with HTML escaping off, and a
test pins the HTML as written on the wire.

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

* test(emails): close the client in the request timeout test

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

* fix(emails): retry an answer cut off mid-body, whatever its status

A dropped connection after a 409's headers lost idempotency_key_in_flight
and became a non-retryable api_error. A failed body read now is a network
error for every status, so the client retries it with the same
idempotency key.

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

* fix(emails): keep Retry-After on an answer cut off mid-body

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

* feat(emails): send cc, bcc and attachments on a code send

A CodeEmail takes Cc, Bcc and Attachments, matching posthog-node
(PostHog/posthog-js#5514) and posthog-python (PostHog/posthog-python#1172)
and the server contract in PostHog/posthog#127988. An EmailAttachment
carries the file's bytes, which encoding/json sends as standard Base64.
An attachment without a Filename or Content fails with invalid_attachment
before any request, and empty lists stay out of the body, so a server that
doesn't take a field yet only sees it when the send uses it.

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

* fix(emails): address the QA swarm panel on the emails API

- Cap EmailRequestMaxRetries and a send's MaxRetries at 9, as MaxRetries
  and FeatureFlagRequestMaxRetries are, so one send can't retry for hours
  and hold Close.
- Match a PostHog Cloud endpoint written with upper case or the default
  :443 port to its app host.
- Keep Retry-After on a 2xx answer that lacks its message ID or status.
- Check UUIDs with google/uuid instead of a second pattern.
- Merge the caller's context and the shutdown context once per request,
  which removes the separate shutdown branch of the retry wait.
- Move the reasons in function bodies into named helpers, and correct the
  AppHost doc: SecretKey also feeds local flag evaluation on Endpoint.

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

* fix(emails): one attachment type, and the reply an older PostHog sends

EmailAttachment carries its own JSON names, so the code send encodes it
directly. A blank Filename is refused like a blank address. The test for
a field PostHog doesn't take yet replays the reply PostHog/posthog#127123
gives for it.

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

* feat(emails): upload attachments to file storage, then send references

A CodeEmail's attachments no longer ride inline as Base64. Send checks the
10-file and 40 MB message limits locally, hashes each file with SHA-256, asks
PostHog which files it lacks, PUTs those to the presigned URLs without the
secret key, and sends the email naming each file by its hash. A failed upload
stops the send and surfaces the storage's answer.

Refs PostHog/michaelr-context#80

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

* refactor(emails): one attachment type, and refuse an upload slot without a method

Folds the reference type into the attachment the send encodes, refuses an
upload answer that names no method instead of guessing PUT, and tests that the
message limit counts every file together.

Refs PostHog/michaelr-context#80

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

* fix(emails): word file storage's refusal and a broken-off upload answer cleanly

A live run against SeaweedFS showed the storage's message ending in two
periods. A broken-off answer to an upload now names file storage, not
PostHog, and is retried like any network error.

Refs PostHog/michaelr-context#80

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

* fix(emails): ask again for an expired upload URL, upload a file once, retry storage 5xx

Review round 1 on the upload flow:

- An upload URL that expired while earlier files uploaded is asked for again
  before its PUT starts.
- A file attached twice uploads once.
- A storage 500, 502, 503, 504 or 429 is retried within MaxRetries; the daily
  upload quota is not.
- The EmailRequestTimeout doc and the changeset say how long an upload may take
  and what a PostHog without the upload route answers.

Refs PostHog/michaelr-context#80

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

* fix(emails): expire upload URLs by the local clock, name the daily upload quota

An upload URL now expires 15 minutes after PostHog's answer by the client's
own clock, or earlier when PostHog says so, so a clock behind PostHog's
can't keep an expired URL. The daily upload quota gets its own name,
EmailErrorDailyQuotaExceeded. Field names an attachment only when its upload
failed, not when asking PostHog again for its URL failed, and a file PostHog
has by the time it asks again isn't uploaded.

Refs PostHog/michaelr-context#80

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

* test(emails): an upload URL expires 15 minutes after the answer by the local clock

Refs PostHog/michaelr-context#80

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

* fix(emails): honor file storage's Retry-After and retry a stalled upload

A storage refusal keeps its Retry-After, so a throttled upload waits as long
as storage asks, or goes back to the caller when that is longer than the
client retries. S3's RequestTimeout, a 400 for a stalled upload, is retried
like a 5xx. The expiry test now pins the 15-minute lifetime with its own
durations instead of the constant it checks.

Refs PostHog/michaelr-context#80

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

* fix(emails): report the upload route's 404 on Attachments as PostHog answered

A 404 from the upload route comes from a PostHog without the route or a
project that can't send email from code. The client can't tell which, so it
keeps PostHog's not_found and its reason, names Attachments, and sends
nothing.

Refs PostHog/michaelr-context#80

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

* test(emails): keep storage's Retry-After when its answer breaks off

Both broken-off upload tests now flush before aborting, so the client reads
a status and headers and then loses the body, the path they name.

Refs PostHog/michaelr-context#80

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

* fix(emails): keep a storage redirect's signed Location out of upload errors

Go reads a redirect's Location before any redirect policy runs, and quotes a
Location it can't parse in its error. A storage redirect keeps the request's
signature, so an upload error whose cause names a URL now drops that text,
keeping only whether it was a cancellation or a timeout.

Refs PostHog/michaelr-context#80

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

* fix(emails): name storage throttles and upload refusals as the caller knows them

A storage 429 is now a rate limit and S3's RequestTimeout is service
unavailable, so a caller that waits on Name sees the same retry the client
made. An upload route refusal names Attachments or Attachments[i] in Field,
not the route's own files field, which the caller never sent. The duplicate
503 retry test folds into the table test.

Refs PostHog/michaelr-context#80

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

* fix(emails): name the right attachment when asking again for its URL is refused

Asking again for an expired URL lists one file, so the route's files__0__
names whichever attachment that was. The refusal now names it by its place
in Attachments.

Refs PostHog/michaelr-context#80

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

* fix(emails): count the subject in the message size as PostHog does

PostHog now counts a subject as encoded words, 75 bytes per 42 bytes of
UTF-8, so a long subject could push a near-limit email past what PostHog
takes after the SDK let it through. The docs for the storage-backed error
names now say file storage can be the one that answered.

Refs PostHog/michaelr-context#80

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

* test(emails): time an upload URL's life through Send with the client's clock

The local-clock expiry test called private helpers. The email sender now
reads the client's clock, so the test drives Send with a clock that moves
14 minutes during the first upload and sees the second file's URL asked
for again.

Refs PostHog/michaelr-context#80

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

* test(emails): prove attachments by what storage holds and PostHog delivers

The attachment tests asserted signed paths and header strings against a
bucket that took anything. They now run against a fake bucket that, like
S3, stores a PUT only when it matches what PostHog signed, arrives before
the URL expires and carries the signed checksum, and a fake PostHog that
answers uploads from what storage holds, refuses fields and files it
doesn't take, and records the email it would deliver. Each test asserts
the delivered email or the error, and was mutation-checked.

A storage refusal is retried by its name instead of a separate flag, an
upload's network error says no email was sent, the CodeEmail doc no longer
promises PostHog stores nothing, and a file attached twice is uploaded per
attachment, since only bandwidth told the two apart.

Refs PostHog/michaelr-context#80

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

* docs(emails): say each attachment PostHog lacks is uploaded

The changeset still said each missing file uploads once, which held only
while a file attached twice was uploaded once.

Refs PostHog/michaelr-context#80

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

* fix(emails): count the text PostHog derives and upload only over https

Without Text, PostHog derives the plain text from the HTML and counts it
in the message size, so an HTML-only email near the limit uploaded its
files and was then refused. The client now counts the HTML twice, since
the derived text is never longer.

An http upload URL from misconfigured storage would carry the file and
its signed URL unencrypted. The client now stops before any upload
unless AllowInsecureAppHost allows plain HTTP for local development.

Refs PostHog/michaelr-context#80

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

* docs(emails): say a storage wait past 30 seconds goes back to the caller

The changeset said retries wait as long as storage's Retry-After asks;
the client waits up to 30 seconds and hands a longer wait back in
EmailError.RetryAfter.

Refs PostHog/michaelr-context#80

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

* fix(emails): upload a file attached twice once again

A second PUT of bytes storage already holds only added traffic, and a
refusal of it, such as a throttle, failed a send whose file had landed.
The client uploads each hash once again; the test has storage throttle
the redundant upload and the email still goes out.

Refs PostHog/michaelr-context#80

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

* test(emails): prove an upload never follows a storage redirect

A 307 from file storage to another host would re-send the file there,
even over http, and report success. The email client already answers a
redirect with the redirect itself; this test pins that from the outside:
the other host receives nothing, the attachment fails without its URL,
and no email is sent. The changeset says uploads never go through a
redirect.

Refs PostHog/michaelr-context#80

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

* docs(emails): say retries bound uploads and not_found covers attachments

EmailRequestMaxRetries also bounds each attachment upload's retries, so
Ptr(0) turns those off too. EmailErrorNotFound on Attachments means this
PostHog can't take attachments yet.

Refs PostHog/michaelr-context#80

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

* docs(emails): say not_found on Attachments has two causes

A 404 from the upload route means this PostHog can't take attachments
yet or the project can't send email from code, and the client can't tell
which.

Refs PostHog/michaelr-context#80

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

This branch is being deployed

1 in progress deployment
hogbox-preview — c81b9e0b Deployed Nov 4, 2026 by github-actions[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

hogbox-preview Spin up a per-PR PostHog preview on a hogland hogbox

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants