Repository navigation
Email templates: upload a template by key from code #487
Description
Map: Map: send email from code with one PostHog call
Spec: Spec: code-managed email templates, sections "Upload", "Template metadata", "MCP tools", "Error catalog"
Cast: Opus 5.5, high
Repository: PostHog/posthog
Question
Can code upload an HTML template by key with one idempotent call, using a project secret API key, and have the next send use it?
Delivers end to end. PUT /api/projects/{project_id}/messaging_templates/by_key/{key}/ behind workflows-transactional-email-api, open to phs_ keys with hog_flow:write and to normal auth. Body {name, subject, html, text?, preheader?, source?}. 201 created on first upload (template created with the key, managed_by = code, source, then made transactional with the default verified sender, all in one transaction); 200 updated (published through the save-and-sync orchestration, draft cleared when PostHog#118664 is in, sender and workflow status kept, made transactional if it was not); 200 unchanged (exact comparison per the spec, nothing written but changed source parts). Concurrent first uploads converge on one template. preheader declared in the email content serializer. The upload serializer turns off whitespace trimming on subject, preheader, html and text. Every save that changes published content (upload, update, design) computes content_hash per the spec's "Content hash" section and moves the previous hash to the front of superseded_content_hashes (at most 100); publishing a hash removes it from that list. The response carries content_hash. The serializer exposes key, managed_by, source_* and content_hash read-only with help_text. Usage event transactional email template uploaded. A generated MCP tool for the upload in the workflows email templates config.
Prefactor first, in this PR. Move the locked save-and-sync block out of the view's update into a messaging service function, and make update, design and the upload call it. No second sync path.
Test seams. The template API (APIBaseTest), CDP mocked at the HTTP boundary only:
- first upload with a
phs_key creates a keyed, code-managed, transactional template (201); - a second identical upload answers
unchangedand writes no workflow version; - a changed upload answers
updated, and the send route then queues the new subject (assert the payload sent to CDP); - no verified default sender answers
400 sender_unavailableand writes nothing; - an invalid key answers
400 invalid_template_key; - a
phs_key withouthog_flow:write, or for another project, is refused; flag off answers 404; - two racing first uploads end with one template (a test that forces the unique-index conflict path);
- the stored
content_hashequals the spec's test vectors, an update moves the old hash into the superseded list, a UI update changes the hash, and an upload of a superseded content publishes it and removes it from the list.
Plus one unit test of the hash function against the spec's test vectors.
Prior art:products/messaging/backend/tests/api/test_transactional_email.py,test_message_templates.py; upsert semantics from1b513421c3on the fork'sfeat/workflows-code-check.
Write scope. products/messaging/backend/presentation/views/message_templates.py, products/messaging/backend/services/ (save-and-sync service, upload service), products/messaging/backend/facade/ (row DTO gains the new fields; upload entry point), products/messaging/backend/tests/api/, products/workflows/mcp/email_templates.yaml, generated OpenAPI, frontend and MCP files from hogli build:openapi.
Proof.
- Red: the first-upload test fails with 404/405.
- Green: every case above passes.
- Local: on the local dev stack with the flag on and a verified dev sender,
curl -X PUTuploadswelcome(201), again (200unchanged), with a new subject (200updated, newcontent_hash); then a UUID send lands in the dev mail catcher with the new subject. Verbatim responses in the PR.
Acceptance criteria
- One save-and-sync path, shared by update, design and upload.
-
phs_keys can call the upload and nothing else new; template CRUD stays closed to them. - Unchanged uploads write no workflow version and no activity row.
- An upload never reactivates a paused workflow and never changes the sender.
- Generated types and the MCP tool are regenerated, not hand-edited.
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-487-1Cast: Opus 5.5, high. Cap 180 minutes. Lands on
spec/code-managed-templates.Silthus commentedon Oct 16, 2026
Resolution
Landed:
65d441ac6e9onspec/code-managed-templates(fast-forwarda5dbb10460b..65d441ac6e9:a17b303d456the feature,65d441ac6e9the semgrep and mypy fixes).Branch:
spec/code-managed-templates/487. No PR: the ship ticket #498 opens it.What landed
PUT /api/projects/{project_id}/messaging_templates/by_key/{key}/, also through@current, behindworkflows-transactional-email-api(plain 404 when off). Open tophs_keys withhog_flow:write(psak_allowed_actions = ["send", "upload"]) and to normal auth. Same four throttles as the UUID send.201 created: the template gets the key,managed_by = code, the source and the hash. It is then made transactional with the default verified sender, all in one transaction. No verified default sender answers400 sender_unavailableand writes nothing.200 updated: published through the shared save-and-sync service. The workflow keeps its sender and its status. A keyed template without a workflow (adopted, or its workflow was deleted) is made transactional in the same transaction.200 unchanged: name, subject, preheader, HTML and text equal, managed by code, workflow exists. Nothing is written except source parts that differ (save(update_fields=...), no workflow version, noupdated_atbump).unique_message_template_key_per_teamconflict and retries as an update, so one template exists and both calls succeed.{id, key, result, content_hash, managed_by, source, transactional_hog_flow_id}.invalid_template_keyandinvalid_requestcarrywhy,fixanddocs_url;attrnames the field (source__pathfor nested fields, as exceptions-hog does). Existing codes (sender_unavailable,template_not_sendable,marketing_template) keep their bodies.transactional email template uploadedwithresult,auth_method,team_id(andtemplate_id, orerror_codeon a refusal).services/template_saves.py.update,designand the upload all callsave_locked_template.lock_transactional_workflow_idandcheck_transactional_updateare gone;check_still_sendable(template)checks the written row.services/content_hash.pyimplements the spec's v1 encoding. Every save through the shared service, the ordinary create and the upload records it. The replaced hash moves to the front ofsuperseded_content_hashes(at most 100), and publishing a hash removes it from the list.preheaderis declared on the email content serializer. The upload serializer turns off whitespace trimming on subject, preheader, HTML and text.MessageTemplateSerializerexposeskey,managed_by,source_*andcontent_hashread-only withhelp_text.MessageTemplateRowcarries them.workflows-upload-email-templateinproducts/workflows/mcp/email_templates.yaml, gated byworkflows-transactional-email-api. Generated withhogli build:openapi; snapshots refreshed.Proof
Red (base
a5dbb10460bplus only the new tests, on a separate tree):test_template_upload.py:12 failed. The first-upload test fails with404 {'type': 'invalid_request', 'code': 'not_found', 'detail': 'Endpoint not found.'}. The race test was left out of the red copy because it imports the new upload module.test_content_hash.py:ModuleNotFoundError: No module named 'products.messaging.backend.services.content_hash'.Green on the landed tree:
products/messaging/backend331 passed(the 13 upload cases and 3 hash cases included, nothing skipped).Mutations, each caught by the new tests:
test_a_changed_upload_is_what_the_next_send_deliversfails (the fixture's HTML ends with a newline, and the expected hash comes from a standalone script of the spec algorithm)._is_unchangedalways false:test_an_identical_upload_is_unchanged_and_writes_no_workflow_versionfails.test_two_racing_first_uploads_end_with_one_templatefails.Local gate on the landed tree:
hogli ci:preflight --strict --against origin/spec/code-managed-templates: pass, 0 failures. The one remaining semgrep-devex finding (choices-need-a-classontemplating) predates this change and is not blocked.uv run mypy --cache-fine-grained .:Success: no issues found in 24084 source files.tsgo --noEmit --checkers 1infrontend/, thetypescript:checkscript with one checker so it fits the memory cap:No errors reported by tsgo.pnpm --filter=@posthog/mcp run typecheck: pass.lint-tool-names: pass. Tool schema snapshot test: 3 passed.hogli build:openapi: exit 0, regenerated output committed.hogli ci:preflight --strict) passed on the push.Local proof through the API. Django from this worktree on a scratch database cloned from a fully migrated test database, with a
phs_key (hog_flow:write) and a verified default sender. Analytics is off in local Django, so a harness forced onlyworkflows-transactional-email-apion. Verbatim:curl transcript
The workflow went from version 1 (create) to version 2 (the changed subject). The
unchangedupload wrote no version. The new hashv1:2465…da58matches an independent run of the spec algorithm.Calls made
APIBaseTest, CDP mocked atinternal_requests.post), plus one unit test of the hash against the spec vectors.to,distinct_idandvariables. The test asserts the send queues the template's workflow, and that the workflow's email step holds the new subject, preheader, HTML and text at version 2.patch.objectonlocked_template_with_key). A real two-transaction race needsTransactionTestCase, which/writing-testsadvises against.PATCH), and resource-level workflow editor access before a workflow is created (same check asmake_transactional).invalid_requestwithwhy,fixanddocs_url, per the spec's catalog. Body validation runs after the flag check, so a flag that is off answers 404 whatever the body.MessageTemplate.managed_bynow takes its choices from the contract enumTemplateManagedBy(no migration change). Two classes with identical pairs left the OpenAPI enum unnamed and broke the schema build.products/tasks/frontend/generated/api.schemas.tsrenamesResultEnumApitoTaskRunPeerMessageResponseResultEnumApi. The newresultfield collides with tasks' inlineresultchoices. Nothing outside generated files imports the old name, and the frontend typecheck passes.Gaps
#118664is not on this base, so an upload has no draft to clear and the unchanged check has no draft condition. Both arrive with feat(workflows): draft and publish edits to transactional email templates PostHog/posthog#118664 through the shared save path.Follow-ups
rewrite_template_content(the facade's bulk content rewrite) writes content without the hash or the workflow sync. The spec says every content save hashes, so that path should move onto the shared service.desktopAPI client was not regenerated locally (generator not installed); backend CI regenerates it.