Repository navigation
Email templates: one send route that names the template in the body, with a content hash #488
Description
Map: Map: send email from code with one PostHog call
Spec: Spec: code-managed email templates, sections "Send", "Hash on send, and the rolling-deploy rule", "MCP tools", "Error catalog"
Cast: Opus 5.5, high
Repository: PostHog/posthog
Question
Can code send a template through one send route that names it in the body, by key or id, and does a content hash on the send keep the caller and PostHog in step without ever putting old content back?
Delivers end to end. POST /api/projects/{project_id}/messaging/send/email/ on a new MessagingSendViewSet registered at messaging with a send/email collection action, behind workflows-transactional-email-api. Project secret API keys with hog_flow:write may call send_email only; normal auth works too, with editor access to the linked workflow, as on the UUID send. Body: the UUID route's body plus template: {key, hash?} | {id}. It resolves the template, applies the hash rule (absent or current: send; superseded: send with a template_superseded warning; unknown: 409 template_outdated, nothing queued), then calls the existing send service unchanged. 202 {message_id, warnings}. An unknown key or id, or a deleted template, answers 404 template_not_found with why, fix, docs_url. The send usage event gains addressed_by and hash_state. The same per-key and per-team throttles. A generated MCP tool for the send. The UUID send route stays as it is.
A superseded hash sends the current content with a warning (Card 2 → i, decided). Building id support is cheap; it can move to a follow-up PR if the diff grows.
Test seams. The send API (APIBaseTest), CDP mocked at the HTTP boundary only:
- send by key with a
phs_key queues withvariablesfrommessage_dataand the distinct id; - send by id queues the same way;
- current hash sends; unknown hash answers
409 template_outdatedand queues nothing; superseded hash sends the current content with the warning; - unknown key, unknown id and deleted template answer
template_not_found; - both or neither of
keyandidanswersinvalid_requestwithattr: template; - a
phs_key cannot call other actions of the new viewset; flag off answers the plain 404; - throttles apply (extend the existing throttle test, do not add a parallel one).
Write scope. A new view module in products/messaging/backend/presentation/views/, products/messaging/backend/routes.py, the messaging services and facade (resolve by key or id, hash check), products/messaging/backend/tests/api/, products/workflows/mcp/email_templates.yaml, generated files from hogli build:openapi.
Proof.
- Red: the send-by-key test fails with 404.
- Green: the cases above pass.
- Local: upload
welcomewith cURL, then send by key with itscontent_hash; the email lands in the dev mail catcher with themessage_datavalues filled in. Upload a new subject, send with the old hash: the response carriestemplate_supersededand the email has the new subject. Send with a made-up hash:409 template_outdated. Call the MCP tool through the local MCP server. Verbatim responses in the PR.
Acceptance criteria
- The UUID send route is unchanged.
- The new route reuses the send service; no second send path.
- A superseded hash never changes the template.
-
template_not_foundandtemplate_outdatedcarrywhy,fixanddocs_url.
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
[-]Email templates: send a template by key[/-][+]Email templates: one send route that names the template in the body, with a content hash[/+]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-488-1Cast: Opus 5.5, high. Cap 180 minutes. Lands on
spec/code-managed-templates.Silthus commentedon Oct 16, 2026
Resolution
Landed:
e5234aa0bf9onspec/code-managed-templates(fast-forwardbfe29ccc10d..e5234aa0bf9:0221cb6ed98the feature,e5234aa0bf9the merge with #491'sbfe29ccc10d).Branch:
spec/code-managed-templates/488. No PR: the ship ticket #498 opens it.What landed
POST /api/projects/{project_id}/messaging/send/email/, also through@current, on a newMessagingSendViewSet(products/messaging/backend/presentation/views/messaging_send.py) registered atmessagingwith asend/emailcollection action. Behindworkflows-transactional-email-api(plain 404 when off, before the body is read).template: {key, hash?} | {id}. Both, neither, orhashwithidanswer400 invalid_requestwithattr: template. A key with other characters answers400 invalid_template_keywithattr: template__key.template_supersededwarning; unknown answers409 template_outdatedand queues nothing. The template is never written by a send.404 template_not_found.template_not_foundandtemplate_outdatedcarrywhy,fixanddocs_url.202 {message_id, warnings}, where a warning is{code, detail, fix}.phs_keys withhog_flow:write(psak_allowed_actions = ["send_email"]), or normal auth with editor access to the linked workflow, as on the UUID send.TransactionalEmailViewSetMixin._send_transactional_email(flag, workflow access check, send service call, error mapping, usage event). Both send routes call it. The UUID route's request, response and error bodies are unchanged.find_template_to_send(team_id, TemplateReference)resolves by key or id and returns the template with aContentHashState.sent_hash_stateinservices/content_hash.pyis the pure comparison.transactional email api sendgainsaddressed_by(key,id,url_id) andhash_state(none,current,superseded,outdated). Refusals withtemplate_not_foundandtemplate_outdatedare reported too.TemplateRefusalandTemplateErrorSerializer, as the upload and the lock do.invalid_request(errors, why=, retry=)replacesinvalid_upload, so the upload and the send each give their ownwhy. A nested object's own error (non_field_errors) names the object inattr.workflows-send-emailinproducts/workflows/mcp/email_templates.yaml, gated byworkflows-transactional-email-api,idempotent: false. Generated withhogli build:openapi; snapshot added.Proof
Red (the new send-by-key case, before the route existed):
Green on the landed tree:
products/messaging/backend360 passed(348 on this ticket's commit, 331 before it; #491 adds the rest). The new and extended cases, nothing skipped:27 cases
Mutations. Parts of the code were written before their tests, so each test was proven against a mutation of the code it guards:
..._only_ever_sends_the_published_content_1_superseded..._only_ever_sends_the_published_content_2_outdatedtest_both_send_routes_share_one_rate_limit_per_keysend_emailcounted as a read actiontest_project_secret_api_key_can_only_send_3_send_email_with_read_scopetest_a_member_denied_the_workflow_cannot_send_or_change_it(send, send_email)test_is_hidden_while_the_flag_is_off_2_send_email..._cannot_send_with_what_to_fix_2_deletedaddressed_byon the UUID route's eventtest_runs_the_templates_workflow_for_the_recipient(url_id cases)hashallowed withid..._cannot_send_with_what_to_fix_5_hash_with_id(added after the first mutation run showed the gap)Local gate on the landed tree:
hogli ci:preflight --strict --against origin/spec/code-managed-templates: pass, 0 failures. The one semgrep finding on the changed files (choices-need-a-classontemplating) predates this change. The pre-push hook (hogli ci:preflight --strict) also passed, 0 failures.uv run mypy --cache-fine-grained .:Success: no issues found in 24087 source files.tsgo --noEmit --checkers 1infrontend/(thetypescript:checkscript with one checker so it fits the memory cap): exit 0.pnpm --filter=@posthog/mcp run typecheck: exit 0.lint-tool-names: pass. Tool schema snapshot test: 3 passed.hogli lint:tach,hogli product:lint messaging: pass.hogli build:openapi: exit 0 on the merged tree, regenerated output committed.Local proof, end to end into the dev mail catcher (on the ticket commit, before the merge with #491). Django from this worktree on a scratch database (cloned from the migrated test database, flag forced on by a harness), the Node CDP service from this worktree (
NODEJS_CAPABILITY_GROUPS=cdp_workflows) with private personhog containers, aphs_key withhog_flow:write, and a verified sender withprovider: maildev. Verbatim:cURL transcript
The dev mail catcher afterwards (two emails: the made-up hash sent nothing):
The second email carries the new subject although the send held the old hash, and the template's hash stayed
v1:2465…da58.MCP tool through the local MCP server (
services/mcpdev server against the same Django, personal API key, so the normal-auth path):Calls made
APIBaseTest), CDP mocked atinternal_requests.post. The send-by-key and send-by-id cases are new rows of the existingtest_runs_the_templates_workflow_for_the_recipient; scope, flag and workflow-access cases are new rows of the existing tests. The hash rule test sits with the upload tests, because it needs two real uploads.upstream/master. I added one test that proves both send routes draw on one per-key budget.phs_key cannot call other actions of the new viewset": the viewset has no other action, so the case is the read scope refused.psak_allowed_actions = ["send_email"]covers any action added later.hashwithidis refused. The spec shape is{key, hash?} | {id}. Accepting it later is additive; refusing it later would break callers.409or a404answers before the workflow editor check, as the UUID route answers404before it. Templates are listable by project members anyway.sync_hog_function_templateson the scratch database, and the MX verdict cache forexample.comset to deliverable for 15 minutes, then restored to its earlier value. Node skips sends to domains without mail servers, andexample.comhas none; the email only went to the local catcher.whyno longer says the key is part of the URL, because on the send it is in the body.TemplateRefusal,TemplateErrorSerializer) so one concept has one name, kept theinvalid_requesthelper, and regenerated every generated file instead of merging them by hand. The lock does not touch sends: the new viewset has no template object, and the UUID send passescheck_template_write.Follow-ups
content_hash, so any hash sent for it answerstemplate_outdateduntil its next save. The SDK then uploads, which also claims it as managed by code. Only adopted templates hit this.desktopAPI client was not regenerated locally (generator not installed); backend CI regenerates it.