Repository navigation
Conversation
…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 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
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 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
…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 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The code send takes distinct_id as a top-level field, as the SDKs send it, so the tool drops the nested identifiers. Co-Authored-By: Claude Opus 5.5 <[email protected]>
…nt field Co-Authored-By: Claude Opus 5.5 <[email protected]>
A template uploaded from a repository needs to record who owns its content, which file it came from, and a hash of what it publishes. This adds those columns (all nullable) and the content hash function. No route reads or writes them yet. Co-Authored-By: Claude Opus 5.5 <[email protected]>
`PUT /api/projects/:id/messaging_templates/by_key/:key/` creates or updates the template with that key from HTML kept in a repository. The first upload makes it transactional with the project's default verified sender; later uploads publish changed content at once, answer `unchanged` for the same content and, with `if_not_superseded`, `superseded` for content a newer upload replaced. Code sends under a key move to the template's workflow once an upload claims the key. The upload sits behind `workflows-email-from-code`, keeps the PSAK bypass and workflow access checks of the send route, gets its own rate limits, and reports a usage event. The MCP tool `workflows-upload-email-template` calls it. Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ode sends Co-Authored-By: Claude Opus 5.5 <[email protected]>
… code A template managed by code now refuses edits in PostHog with `403 immutable` until someone releases it with `managed_by: gui`, which captures a `message template released` usage event. A template made in PostHog moves into code when someone gives it a `key`: the next upload under that key keeps its id, workflow and sender, and takes over the code sends under the key from then on. Co-Authored-By: Claude Opus 5.5 <[email protected]>
… template Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A template uploaded from code opens as a read-only preview with its key and source. Edits refused by an upload wait in a banner that offers a duplicate, and the menu can stop managing the template in code. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
`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 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
…te source Co-Authored-By: Claude Opus 5.5 <[email protected]>
…izer Co-Authored-By: Claude Opus 5.5 <[email protected]>
…e edit A publish refused because an upload took the template back over already shows the warning banner with "Duplicate with my changes". The global loader toast also showed the server message, so the user saw two errors. messageTemplateLogic now owns the `immutable` refusal of saveTemplate. Every other saveTemplate failure keeps the toast. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A code send, or a send by `template.key`, that names a `from` on no verified domain now answers `sender_not_verified` with `attr: "from"`, so an SDK can show which field to fix. A send without `from` whose project has no verified default sender still names no field. Co-Authored-By: Claude Opus 5.5 <[email protected]>
The send tool's Zod stripped unknown keys, so template.content_hash instead of template.hash sent the email without the hash check. Orval now generates strict bodies for the email_templates module only. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Upload, update and make-transactional join path params and a body, so they took the params' loose top level and stripped a misspelled key such as if_not_superseded. The joined input is now strict when its module has strict bodies. The design document stays open: its skeleton sets additionalProperties, and the Orval post-step turns those objects into looseObject, because Orval's strict mode ignores the flag. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Every open object the OpenAPI marks must now match a generated object, or the generator fails and names the module, the operation and the path. Unions with more than one non-null branch, colliding paths and dotted property names fail the same way instead of loosening a closed sibling. The update tool now tells the agent to keep the template id. Co-Authored-By: Claude Opus 5.5 <[email protected]>
|
Merging to
After your PR is submitted to the merge queue, this comment will be automatically updated with its status. If the PR fails, failure details will also be posted here |
Master now holds the merged code send layers this branch was stacked on, so the merge keeps master's code and re-applies only this layer's change: code-managed fields on message templates and the content hash. Master's messaging migrations end at 0016_first_code_send and already hold this branch's 0012 to 0014 under other numbers, so those copies go, and the code-managed fields migration becomes 0017 on top of 0016_first_code_send. Co-Authored-By: Claude Opus 5.5 <[email protected]>
The template code fields layer now sits on master, so this merge keeps master's code and re-applies only this layer's change: uploading email templates from code by key. Master moved on in a few places this layer touches, and the merge keeps master's shape there: - the code send sender helper is code_send_sender_id - a key's workflow lookup takes the template, not its id - send and make_transactional declare their scopes on the action, so only upload joins the write actions list - make_transactional still drops content for a send-scoped key OpenAPI types, MCP tools and tool snapshots are regenerated. Co-Authored-By: Claude Opus 5.5 <[email protected]>
The template uploads layer now sits on master, so this merge keeps that layer's code and re-applies only this layer's change: code-managed templates refuse edits in PostHog, and a template made in PostHog moves into code by key. The update tool's description keeps master's note on the read-only fields of a fetched template and adds this layer's lock and adoption text. OpenAPI types, MCP tools and tool snapshots are regenerated. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Patch the workflow reload where master's hog flow model calls it, and expect a key workflow's email step to carry its key when no upload claimed the template that shares it. Co-Authored-By: Claude Opus 5.5 <[email protected]>
…e-lock-adopt Co-Authored-By: Claude Opus 5.5 <[email protected]>
Master reads a template through read_object now, so the race test claims the template there, and the workflow reload is patched where the hog flow model calls it. Co-Authored-By: Claude Opus 5.5 <[email protected]>
…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
…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 <[email protected]>
…e-editor # Conflicts: # frontend/src/initKea.ts # 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/TemplateLibrary/TransactionalEmailApiExample.tsx # 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: # frontend/src/initKea.ts # posthog/plugins/plugin_server_api.py # products/messaging/backend/facade/contracts.py # products/messaging/backend/facade/templates.py # products/messaging/backend/migrations/0016_first_code_send.py # products/messaging/backend/models/__init__.py # 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/api.py # products/workflows/backend/facade/transactional_emails.py # products/workflows/backend/services/transactional_email_workflows.py # products/workflows/frontend/TemplateLibrary/TransactionalEmailApiExample.tsx # 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/scripts/generate-tools.ts # services/mcp/scripts/lib/loosen-open-objects.mjs # 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 # services/mcp/tests/unit/email-templates-generated.test.ts
Co-Authored-By: Claude Opus 5.5 <[email protected]>
# Conflicts: # products/messaging/backend/services/transactional_email.py # products/messaging/backend/tests/api/test_transactional_email.py
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
…/12-send-graded-template-version
Co-Authored-By: Claude Opus 5.5 <[email protected]>
…/06-template-editor
…/12-send-graded-template-version
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Contributor
|
👋 Visual changes detected for this PR. Review and approve in PostHog Visual Review If these changes are unexpected, they may be caused by a flaky test or a broken snapshot on master. Don't approve — rerun the job or wait for a fix. Install the Visual Review Chrome extension to see visual review results at the top of your pull requests. |
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Moved from Silthus#585.
Part of the code-first rebuild of the email send stack; new in the rebuild, with no closed PR to replace: a template send delivers the content Django graded (decision D3 on map Silthus#461).
Problem
send/email/right after an upload can deliver the previous content, while Django answered that the caller'stemplate.hashis current.Impact
Pays into Grow Workflows through billed
workflows_emailsactions per paying org: a developer who uploads a template and sends it at once gets the new content. In scope: thesend/email/route.Merge Danger
Note
🟢 Merge risk: low · two-way door: a revert stops sending the version, and delivery works as today
send/email/only, behindworkflows-email-from-code. Rendered-content sends and the legacy template send route don't change.template.keynow goes to cdp-api's/transactional_email/send, which feat(cdp): deliver a code send's content on its own transactional email route #127122 adds. Against cdp-api master, those sends answer 503delivery_unavailableand send nothing (cross-version proof below). They stay behindworkflows-email-from-code.template.keythat crosses a deploy of feat(messaging): capture email first code send once per project #127187 without feat(messaging): send the workflow version a template send by key was checked against #127188 answers 422, because the two pick different routes for one fingerprint. Key sends exist only inside this stack, so no stored fingerprint has that shape today.Changes
send.hogFlowVersion. Django reads it after grading the hash, from the writer database, so it is never older than the graded content.Unavailableto the caller), without a stack trace in the log.503 delivery_unavailableshape with its owndetail:{ "type": "server_error", "code": "delivery_unavailable", "detail": "This template's newest version hasn't reached the email pipeline yet, so nothing was sent. Retry in a few seconds, with the same Idempotency-Key if you sent one.", "attr": null }workflow_version_behind, never reaches the caller.template.keyposts the version, with or withoutfrom,reply_toorcapture_engagement, so it goes to the accepted-send route. Its defaultcaptureEngagement: truechanges nothing: the project's setting still decides.template.idkeeps master's route, which grades no hash and posts no version. One that setscapture_engagement(feat(messaging): send a stored template by key on send/email #127176), which takesworkflows-email-from-code, goes to the accepted-send route and posts the version, like a send bytemplate.key. Only a field the caller sent counts, so a form body that omits it keeps master's route.How did you test this code?
Test seams: the DRF client on
send/email/, asserting the body posted to cdp-api (test_transactional_email.py).Test rationale, each run red first:
template.keythat posts no version, with or without the send options, or a version from before a save. A PATCH that saves new content is followed by a send that must post the new version.senddict, which pins that the field is absent there.workflow_version_behindanywhere in the body.Proofs (saved with
fleet proofonef44c9b):f9abed0841c), mail caught in maildev. Sends by id and bytemplate.key, uploads, the superseded and outdated hashes, and the send options all answer and deliver as on feat(messaging): capture email first code send once per project #127187.5304e8d(cdp-api at feat(cdp): send a template from the workflow version Django graded #127130's5d66b71): posthog-node (feat(node): upload local email templates by hash on send Silthus/posthog-js#7 (now feat(node): upload local email templates by hash on send posthog-js#5513)) and posthog-python (feat: send email content from code with posthog.emails.send Silthus/posthog-python#2 (now feat: send email content from code with posthog.emails.send posthog-python#1171)) send a local template under a new key:409 template_outdated, upload201, send202, delivered. Onto a template edited in PostHog, the upload answers409 template_edited_in_posthog(feat(messaging): lock code-managed templates and adopt templates into code #127172), the SDK reports it, and nothing is sent.template.keyanswer 503delivery_unavailableand send nothing. Sends by id deliver.Earlier live run, 2026-10-31
Django at
3a30f51c529, cdp-api and both CDP workers at #127130 (f9abed0841c), mail caught in maildev.template.keytemplate_supersededwarning, the new content deliveredtemplate_outdated, nothing sentfrom,reply_toandcapture_engagement[email protected]Release status
Docs update
None.
🤖 Agent context
Autonomy: Human-driven (agent-assisted)
Agent: Claude Code, Claude Opus 5.5 (claude-opus-5-5)
srv2of map Map: send code-managed email templates with one PostHog call Silthus/posthog#461, for decision D3; restacked as lanedj3onto feat(messaging): capture email first code send once per project #127187 and master's refusal code, so the 503 drops itswhyandfix.modernize-pr,tdd,writing-tests,writing-user-facing-copy,qa-swarm,pr-description,writing-pr-descriptions.delivery_unavailablewith the version lag indetail; replaces a new publicworkflow_version_behindcodewhyandfix?detailtemplate.idwithcapture_engagementpost the version?Open: a send by
template.idposts no version, so one right after an upload can still use the cached copy. Recommendation: leave it; that send grades no hash.Involvement: one decision from Michael and one from the map's conductor; review round 1 (GPT-6.1 Sol, Opus fresh eyes) fixed, then final reads by GPT-6.1 Sol; dj3 round 1 (GPT-6.1 Sol) fixed, its final read raised the cdp-api route order, answered in the merge risk; live run captured; cross-version proof in #127130. dj3 QA swarm (GPT-6.1 Sol review, Opus fresh eyes): one merge fix, two rejections; live run, shadow diff and cross-version on this head.
🤖 Generated with Claude Code