Repository navigation
Email templates: adopt an existing template by giving it a key #490
Description
Map: Map: send email from code with one PostHog call
Spec: Spec: code-managed email templates, section "Adoption of an existing template"
Cast: Opus 5.5, high
Repository: PostHog/posthog
Question
Can a person give an existing template, made in the UI and addressed by UUID, a key, so that code takes it over without changing its UUID, workflow or history?
Delivers end to end. The ordinary template PATCH accepts a body whose only field is key, once, on a keyless template, for session, personal API key and OAuth callers with editor access. 400 key_already_set on a keyed template, 400 key_in_use on a taken key, 400 invalid_template_key on a bad one, each with why, fix, docs_url. The key is not writable through create, and not with other fields. The next upload under that key claims the template (managed_by = code) and keeps its UUID, linked workflow and sender. Never matched by display name. Michael chose this shape (Card 3 → a). The MCP update tool exposes the field.
Test seams. The template API: adopt then upload keeps the UUID and the workflow id and answers updated; adopt twice answers key_already_set; a taken key answers key_in_use; a phs_ key cannot adopt; key next to other fields is refused. Prior art: validate_key and test_hog_flow_key.py on the fork's feat/workflows-key (5d1c453e76).
Write scope. products/messaging/backend/presentation/views/message_templates.py (serializer), the messaging services and facade, products/messaging/backend/tests/api/, products/workflows/mcp/email_templates.yaml, generated files.
Proof.
- Red: the adopt-then-upload test fails.
- Green: the cases above pass.
- Local: make a template in the UI and make it transactional,
PATCHa key, upload by that key with aphs_key, send by key through the new send route; the email lands in the dev mail catcher with the uploaded subject, and the template page shows the same UUID.
Acceptance criteria
- Adoption is explicit and set-once.
- The UUID, linked workflow and sender survive the first upload.
- Project secret API keys cannot adopt.
How to build (map #461)
/tddred-green-refactor, outside-in, at the seams named above. Real Postgres, Valkey and ClickHouse; mock only what the seam section allows.- Repo skills apply where the diff touches their area:
/writing-tests,/improving-drf-endpoints,/django-migrations,/adding-api-scopes,/implementing-mcp-tools,/writing-ui-components,/writing-kea-logics,/writing-user-facing-copy,/writing-code-comments. - Read code from
upstream/master(PostHog/posthog). The fork'smasteris stale. - Public repo: no usage numbers, customer names, internal metrics, Slack content or security reasoning in code, commits or the PR. Fixture addresses use
example.com. The private note the map points at is background only; never quote it. - Shipped means a draft PR handed off for review (see the map's Notes for remotes and branches). Never mark ready, enqueue or merge.
Activity
Silthus commentedon Oct 16, 2026
Runtime: T3 child task
Task:
node:delegated-task:command%3Amcp%3A955e36b3-3986-469c-b816-09cf656780af%3Adelegate-task%3Awf-email-impl-490-1Cast: Opus 5.5, high. Cap 180 minutes. Lands on
spec/code-managed-templates.Silthus commentedon Oct 17, 2026
Resolution
Landed:
7a117081e1fonspec/code-managed-templates(fast-forward0062c7672a8..7a117081e1f:50c19098ea6the feature,4b119fc69b8a mypy fix, and the merges with #488'se5234aa0bf9and #492's0062c7672a8).Branch:
spec/code-managed-templates/490. No PR: the ship ticket #498 opens it.What landed
PATCH /api/projects/{project_id}/messaging_templates/{id}/whose only field iskeygives a keyless template its key. It writes onlykey(update_fields), under the template's row lock. The content,managed_by, the linked workflow and the sender stay as they are. No workflow version is written.partial_update(psak_allowed_actionsis unchanged).400 key_already_seton a keyed template,400 key_in_useon a taken key (theunique_message_template_key_per_teamconflict, so two concurrent adoptions of one key can't both win),400 invalid_template_keyon a bad or null key. All three carrywhy,fix,docs_urlandattr: key, in the sharedTemplateErrorSerializerenvelope.403 immutablebefore the key is looked at. That's consistent, because a code-managed template already has a key.keyanswers400before the body is validated:key_already_seton a keyed template,invalid_request("sendkeyon its own") on a keyless one. A body that sends the stored key back (the editor's form spreads the whole template into itsPATCH) still saves.keyis still not writable through create.managed_by = codegetsupdated,managed_by = code, sender and workflow status kept). No upload code changed. Matching is by key only, never by display name.services/template_keys.py(is_adoption,check_key_unchanged,adopt_template),TemplateKeyRefusedinfacade/contracts.py, andadopt_template,is_adoptionandcheck_template_key_writeinfacade/templates.py. The view branches on adoption next to Email templates: refuse edits to code-managed templates on the server #491's release, and mapsTemplateKeyRefusedinhandle_exception. The threereport_user_actionblocks became one_report_user_actionhelper.partial_updatedeclaresMessageTemplateUpdateSerializer, which isMessageTemplateSerializerplus a writablekeywithhelp_text. That keeps the request a superset of the runtime fields. It also declares the400response. The response'skeystays read-only, and itshelp_textnow says how a key is set. The generatedPatchedMessageTemplateApibecomesPatchedMessageTemplateUpdateApi, and nothing outside generated files used it.workflows-update-email-templateexposeskey, and its description says how to move a template made in PostHog into code. Regenerated withhogli build:openapi; snapshot refreshed.message template adopted(template_id,team_id) throughreport_user_action, so the source and MCP client properties come along.Proof
Red (the new tests on
bfe29ccc10d, the integration tip when this ticket started, in a separate worktree):10 failed, 1 passed. The adopt-then-upload test fails because thePATCHignoreskey:The one that passes is
test_a_project_secret_api_key_cannot_adopt, which the base already enforces. It stays as the guard that keepsphs_keys off adoption.Green on the landed tree: the 11 cases pass.
products/messaging/backend:370 passed, nothing skipped.11 cases
phs_uploadupdated, same id, same workflow id,managed_by: code, statusarchivedand sender kept, new subject in the workflowx-posthog-client: mcpmanaged_bynull, content unchangedphs_keyattr: keyanddocs_url; key and name unchangedPATCHthat sends the stored key back with a renameMutations, each caught:
..._4_key_with_other_fieldsand..._5_changed_key_with_other_fieldsfail (2 failed, 8 passed).key_in_use:..._1_taken_keyfails (1 failed, 9 passed).Local gate on the landed tree:
hogli ci:preflight --strict --against origin/spec/code-managed-templates: 0 failures, on the tree with Email templates: read-only view of code-managed templates #492 merged. The pre-push hook ran it again on the push: 0 failures.uv run mypy --cache-fine-grained .:Success: no issues found in 24089 source files. Its first run found one unusedtype: ignore, which4b119fc69b8removes.tsgo --noEmit --checkers 1infrontend/: exit 0, on the tree with Email templates: read-only view of code-managed templates #492 merged.pnpm --filter=@posthog/mcp run typecheck: exit 0. Tool schema snapshot test: 3 passed (refreshed forworkflows-update-email-template).hogli lint:tach,hogli product:lint messaging: pass.hogli build:openapi: exit 0, also after the merge with Email templates: one send route that names the template in the body, with a content hash #488. The generated files that conflicted were regenerated, not merged by hand.products/messaging/backend:370 passedafter the last backend change. The Email templates: read-only view of code-managed templates #492 merge only brought frontend files.Local proof, end to end into the dev mail catcher. Django and the Node CDP service (
NODEJS_CAPABILITY_GROUPS=cdp_workflows) ran from this worktree on a scratch database cloned from a migrated test database. A harness forced onlyworkflows-transactional-email-apion. The setup had a personal API key (hog_flow:write) for the editor's calls, aphs_key withhog_flow:write, and a verified default sender withprovider: maildev. Verbatim, with the long template bodies of steps 1, 2 and 6 cut to the fields that matter:cURL transcript
The dev mail catcher afterwards:
The template made in PostHog kept its id
01a14732-45d2-…and its workflow01a14732-47cc-…through adoption, upload and send. The email carries the uploaded subject.Calls made
APIBaseTest), one MCP-style personal key call, and the existing upload test. That test set the key through the ORM. It now adopts through thePATCH, then uploads with thephs_key, so one test covers adopt-then-upload and the kept id, workflow and sender. The refusal cases are one parameterized test.invalid_requestwithwhy,fixanddocs_url(the spec's generic code, withattr: key), not DRF's read-only silence. A caller that thinks it set a key learns it didn't. On a keyed template a different key answerskey_already_set, whatever else is in the body.keyincluded, into itsPATCH, so a body whosekeyequals the stored key (null included) is not a key change.test_a_form_save_that_sends_the_stored_key_back_keeps_workingpins it.{"key": null}alone is an adoption with an invalid key, so it answersinvalid_template_key.{"key": "welcome"}onwelcome) answerskey_already_set, as the spec says for a keyed template. It's not an idempotent 200.key_in_use. Not tested with real concurrency (that needsTransactionTestCase); the taken-key case runs the sameIntegrityErrorpath.Gaps
GETon the template, not a screenshot.Follow-ups
sync_hog_function_templateson the scratch database, and Node's SES Valkey pointed at a private container with the MX verdict forexample.compreset, so the shared Valkey stayed untouched. Everything was removed afterwards.desktopAPI client was not regenerated locally (generator not installed); backend CI regenerates it.