Skip to content

Email templates: one send route that names the template in the body, with a content hash #488

Closed

Description

@Silthus

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 with variables from message_data and the distinct id;
  • send by id queues the same way;
  • current hash sends; unknown hash answers 409 template_outdated and 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 key and id answers invalid_request with attr: 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 welcome with cURL, then send by key with its content_hash; the email lands in the dev mail catcher with the message_data values filled in. Upload a new subject, send with the old hash: the response carries template_superseded and 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_found and template_outdated carry why, fix and docs_url.

How to build (map #461)

  • /tdd red-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's master is 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

  1. changed the title [-]Email templates: send a template by key[/-] [+]Email templates: one send route that names the template in the body, with a content hash[/+] on Oct 16, 2026
  2. self-assigned this
    on Oct 16, 2026
  3. Silthus commented on Oct 16, 2026

    @Silthus
    OwnerAuthor

    Runtime: T3 child task
    Task: node:delegated-task:command%3Amcp%3A955e36b3-3986-469c-b816-09cf656780af%3Adelegate-task%3Awf-email-impl-488-1
    Cast: Opus 5.5, high. Cap 180 minutes. Lands on spec/code-managed-templates.

  4. Silthus commented on Oct 16, 2026

    @Silthus
    OwnerAuthor

    Resolution

    Landed: e5234aa0bf9 on spec/code-managed-templates (fast-forward bfe29ccc10d..e5234aa0bf9: 0221cb6ed98 the feature, e5234aa0bf9 the merge with #491's bfe29ccc10d).
    Branch: spec/code-managed-templates/488. No PR: the ship ticket #498 opens it.

    What landed

    • Send route: POST /api/projects/{project_id}/messaging/send/email/, also through @current, on a new MessagingSendViewSet (products/messaging/backend/presentation/views/messaging_send.py) registered at messaging with a send/email collection action. Behind workflows-transactional-email-api (plain 404 when off, before the body is read).
      • Body: the UUID route's body plus template: {key, hash?} | {id}. Both, neither, or hash with id answer 400 invalid_request with attr: template. A key with other characters answers 400 invalid_template_key with attr: template__key.
      • Hash rule: absent or current sends; superseded sends the current content with a template_superseded warning; unknown answers 409 template_outdated and queues nothing. The template is never written by a send.
      • An unknown key, unknown id, or deleted template answers 404 template_not_found. template_not_found and template_outdated carry why, fix and docs_url.
      • 202 {message_id, warnings}, where a warning is {code, detail, fix}.
      • Auth: phs_ keys with hog_flow:write (psak_allowed_actions = ["send_email"]), or normal auth with editor access to the linked workflow, as on the UUID send.
      • Throttles: the same four classes as the UUID send, so both send routes share one budget per key and per team.
    • No second send path. The UUID send's body moved into 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.
    • Facade: find_template_to_send(team_id, TemplateReference) resolves by key or id and returns the template with a ContentHashState. sent_hash_state in services/content_hash.py is the pure comparison.
    • Usage event: transactional email api send gains addressed_by (key, id, url_id) and hash_state (none, current, superseded, outdated). Refusals with template_not_found and template_outdated are reported too.
    • Shared error envelope: the send uses Email templates: refuse edits to code-managed templates on the server #491's TemplateRefusal and TemplateErrorSerializer, as the upload and the lock do. invalid_request(errors, why=, retry=) replaces invalid_upload, so the upload and the send each give their own why. A nested object's own error (non_field_errors) names the object in attr.
    • MCP: workflows-send-email in products/workflows/mcp/email_templates.yaml, gated by workflows-transactional-email-api, idempotent: false. Generated with hogli build:openapi; snapshot added.

    Proof

    Red (the new send-by-key case, before the route existed):

    E       AssertionError: {'type': 'invalid_request', 'code': 'not_found', 'detail': 'Endpoint not found.', 'attr': None}
    E       assert 404 == 202
    1 failed, 33 deselected
    

    Green on the landed tree: products/messaging/backend 360 passed (348 on this ticket's commit, 331 before it; #491 adds the rest). The new and extended cases, nothing skipped:

    27 cases
    PASSED test_transactional_email.py::TestTransactionalEmail::test_both_send_routes_share_one_rate_limit_per_key
    PASSED test_transactional_email.py::TestTransactionalEmail::test_is_hidden_while_the_flag_is_off_0_send
    PASSED test_transactional_email.py::TestTransactionalEmail::test_is_hidden_while_the_flag_is_off_1_make_transactional
    PASSED test_transactional_email.py::TestTransactionalEmail::test_is_hidden_while_the_flag_is_off_2_send_email
    PASSED test_transactional_email.py::TestTransactionalEmail::test_project_secret_api_key_can_only_send_0_send_with_write_scope
    PASSED test_transactional_email.py::TestTransactionalEmail::test_project_secret_api_key_can_only_send_1_send_with_read_scope
    PASSED test_transactional_email.py::TestTransactionalEmail::test_project_secret_api_key_can_only_send_2_send_email_with_write_scope
    PASSED test_transactional_email.py::TestTransactionalEmail::test_project_secret_api_key_can_only_send_3_send_email_with_read_scope
    PASSED test_transactional_email.py::TestTransactionalEmail::test_project_secret_api_key_can_only_send_4_retrieve_is_not_a_psak_action
    PASSED test_transactional_email.py::TestTransactionalEmail::test_project_secret_api_key_can_only_send_5_make_transactional_is_not_a_psak_action
    PASSED test_transactional_email.py::TestTransactionalEmail::test_refuses_a_template_reference_it_cannot_send_with_what_to_fix_0_unknown_key
    PASSED test_transactional_email.py::TestTransactionalEmail::test_refuses_a_template_reference_it_cannot_send_with_what_to_fix_1_unknown_id
    PASSED test_transactional_email.py::TestTransactionalEmail::test_refuses_a_template_reference_it_cannot_send_with_what_to_fix_2_deleted
    PASSED test_transactional_email.py::TestTransactionalEmail::test_refuses_a_template_reference_it_cannot_send_with_what_to_fix_3_key_and_id
    PASSED test_transactional_email.py::TestTransactionalEmail::test_refuses_a_template_reference_it_cannot_send_with_what_to_fix_4_neither_key_nor_id
    PASSED test_transactional_email.py::TestTransactionalEmail::test_refuses_a_template_reference_it_cannot_send_with_what_to_fix_5_hash_with_id
    PASSED test_transactional_email.py::TestTransactionalEmail::test_refuses_a_template_reference_it_cannot_send_with_what_to_fix_6_invalid_key
    PASSED test_transactional_email.py::TestTransactionalEmail::test_runs_the_templates_workflow_for_the_recipient_0_with_distinct_id
    PASSED test_transactional_email.py::TestTransactionalEmail::test_runs_the_templates_workflow_for_the_recipient_1_email_only
    PASSED test_transactional_email.py::TestTransactionalEmail::test_runs_the_templates_workflow_for_the_recipient_2_by_key
    PASSED test_transactional_email.py::TestTransactionalEmail::test_runs_the_templates_workflow_for_the_recipient_3_by_id
    PASSED test_transactional_email.py::TestTransactionalEmailWorkflowAccess::test_a_member_denied_the_workflow_cannot_send_or_change_it_0_send
    PASSED test_transactional_email.py::TestTransactionalEmailWorkflowAccess::test_a_member_denied_the_workflow_cannot_send_or_change_it_1_send_email
    PASSED test_transactional_email.py::TestTransactionalEmailWorkflowAccess::test_a_member_denied_the_workflow_cannot_send_or_change_it_2_update
    PASSED test_template_upload.py::TestTemplateUpload::test_a_send_by_key_with_a_hash_only_ever_sends_the_published_content_0_current
    PASSED test_template_upload.py::TestTemplateUpload::test_a_send_by_key_with_a_hash_only_ever_sends_the_published_content_1_superseded
    PASSED test_template_upload.py::TestTemplateUpload::test_a_send_by_key_with_a_hash_only_ever_sends_the_published_content_2_outdated
    27 passed, 35 deselected in 15.28s
    

    Mutations. Parts of the code were written before their tests, so each test was proven against a mutation of the code it guards:

    Mutation Caught by
    a superseded hash counts as outdated ..._only_ever_sends_the_published_content_1_superseded
    an outdated hash still sends ..._only_ever_sends_the_published_content_2_outdated
    no throttles on the send route test_both_send_routes_share_one_rate_limit_per_key
    send_email counted as a read action test_project_secret_api_key_can_only_send_3_send_email_with_read_scope
    no workflow access check in the shared send test_a_member_denied_the_workflow_cannot_send_or_change_it (send, send_email)
    no flag check on the send route test_is_hidden_while_the_flag_is_off_2_send_email
    a deleted template keeps its key ..._cannot_send_with_what_to_fix_2_deleted
    no addressed_by on the UUID route's event test_runs_the_templates_workflow_for_the_recipient (url_id cases)
    hash allowed with id ..._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-class on templating) 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 1 in frontend/ (the typescript:check script 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.
    • The gate ran on the ticket commit, then again on the merged tree that landed.

    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, a phs_ key with hog_flow:write, and a verified sender with provider: maildev. Verbatim:

    cURL transcript
    ### 1. upload welcome
    $ curl -X PUT http://127.0.0.1:8488/api/projects/@current/messaging_templates/by_key/welcome/ -H 'Authorization: Bearer phs_…' -d '{"name":"Welcome","subject":"Welcome, {{ variables.first_name }}","html":"<p>Hi {{ variables.first_name }}</p>","source":{"repository":"github.com/example/app","path":"emails/welcome.html","ref":"3f2c1ab"}}'
    {"id":"01a146ff-9c8b-0000-21de-0600af49d6f0","key":"welcome","result":"created","content_hash":"v1:f74cd913f3aa2e5667a588f2865634e56693df77793ca594d18e9b5f89e3d2e6","managed_by":"code","source":{"repository":"github.com/example/app","path":"emails/welcome.html","ref":"3f2c1ab"},"transactional_hog_flow_id":"01a146ff-9c93-0000-2575-f9944eaf5b71"}
    HTTP 201
    
    ### 2. send by key with its content_hash
    $ curl -X POST http://127.0.0.1:8488/api/projects/@current/messaging/send/email/ -H 'Authorization: Bearer phs_…' -d '{"template":{"key":"welcome","hash":"v1:f74cd913f3aa2e5667a588f2865634e56693df77793ca594d18e9b5f89e3d2e6"},"to":"jane@example.com","identifiers":{"distinct_id":"user_42"},"message_data":{"first_name":"Jane"}}'
    {"message_id":"01a146ff-a14e-0000-81c0-f2ccc2444f5f","warnings":[]}
    HTTP 202
    
    ### 3. upload a new subject
    $ curl -X PUT http://127.0.0.1:8488/api/projects/@current/messaging_templates/by_key/welcome/ -H 'Authorization: Bearer phs_…' -d '{"name":"Welcome","subject":"Welcome aboard, {{ variables.first_name }}","html":"<p>Hi {{ variables.first_name }}</p>","source":{"ref":"9e8d7c6"}}'
    {"id":"01a146ff-9c8b-0000-21de-0600af49d6f0","key":"welcome","result":"updated","content_hash":"v1:246510b8be58d294af5fe467bbad517a464f72dcdf9a5850f3ef95d56126da58","managed_by":"code","source":{"repository":"github.com/example/app","path":"emails/welcome.html","ref":"9e8d7c6"},"transactional_hog_flow_id":"01a146ff-9c93-0000-2575-f9944eaf5b71"}
    HTTP 200
    
    ### 4. send with the old hash
    $ curl -X POST http://127.0.0.1:8488/api/projects/@current/messaging/send/email/ -H 'Authorization: Bearer phs_…' -d '{"template":{"key":"welcome","hash":"v1:f74cd913f3aa2e5667a588f2865634e56693df77793ca594d18e9b5f89e3d2e6"},"to":"jane@example.com","identifiers":{"distinct_id":"user_42"},"message_data":{"first_name":"Jane"}}'
    {"message_id":"01a146ff-acd6-0001-cb61-b4d68ddaaed2","warnings":[{"code":"template_superseded","detail":"The email was sent with the template's current content, because newer content replaced the content you hold.","fix":"Deploy the newer file, or upload your file to publish it again."}]}
    HTTP 202
    
    ### 5. send with a made-up hash
    $ curl -X POST http://127.0.0.1:8488/api/projects/@current/messaging/send/email/ -H 'Authorization: Bearer phs_…' -d '{"template":{"key":"welcome","hash":"v1:0000000000000000000000000000000000000000000000000000000000000000"},"to":"jane@example.com","message_data":{"first_name":"Jane"}}'
    {"type":"invalid_request","code":"template_outdated","detail":"PostHog does not hold the content this hash describes, so nothing was sent.","attr":"template__hash","why":"The template never had this content, so sending its current content could send the wrong email.","fix":"Upload the template, then send again. posthog-node does this on its own.","docs_url":"https://posthog.com/docs/workflows/transactional-email-api#template_outdated"}
    HTTP 409
    
    ### 6. send an unknown key
    $ curl -X POST http://127.0.0.1:8488/api/projects/@current/messaging/send/email/ -H 'Authorization: Bearer phs_…' -d '{"template":{"key":"welcom"},"to":"jane@example.com"}'
    {"type":"invalid_request","code":"template_not_found","detail":"No template in this project has the key `welcom`.","attr":"template__key","why":"A send names a template of this project that is not deleted.","fix":"Upload the template first with `PUT /api/projects/@current/messaging_templates/by_key/welcom/`, or check the key and the project.","docs_url":"https://posthog.com/docs/workflows/transactional-email-api#template_not_found"}
    HTTP 404
    
    ### 7. name both key and id
    $ curl -X POST http://127.0.0.1:8488/api/projects/@current/messaging/send/email/ -H 'Authorization: Bearer phs_…' -d '{"template":{"key":"welcome","id":"01a14631-40b7-0000-4dfd-60cd5a2e12ad"},"to":"jane@example.com"}'
    {"type":"invalid_request","code":"invalid_request","detail":"template: Name the template with either `key` (and an optional `hash`) or `id`.","attr":"template","why":"A send needs a template, a recipient address, and data as an object.","fix":"Fix `template` and send again.","docs_url":"https://posthog.com/docs/workflows/transactional-email-api#invalid_request"}
    HTTP 400
    

    The dev mail catcher afterwards (two emails: the made-up hash sent nothing):

    {"from": ["hello@example.com"], "to": ["jane@example.com"], "subject": "Welcome, Jane", "html": "<p>Hi Jane</p>\n"}
    {"from": ["hello@example.com"], "to": ["jane@example.com"], "subject": "Welcome aboard, Jane", "html": "<p>Hi Jane</p>\n"}
    

    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/mcp dev server against the same Django, personal API key, so the normal-auth path):

    $ exec: call --json workflows-send-email {"template":{"key":"welcome","hash":"v1:f74cd913f3aa2e5667a588f2865634e56693df77793ca594d18e9b5f89e3d2e6"},"to":"jane@example.com","identifiers":{"distinct_id":"user_42"},"message_data":{"first_name":"Jane"}}
    {"message_id":"01a14701-8db1-0000-c02e-3c38dacebe75","warnings":[{"code":"template_superseded","detail":"The email was sent with the template's current content, because newer content replaced the content you hold.","fix":"Deploy the newer file, or upload your file to publish it again."}]}
    
    $ exec: call --json workflows-send-email {"template":{"key":"welcome","hash":"v1:0000000000000000000000000000000000000000000000000000000000000000"},"to":"jane@example.com"}
    Error: [workflows-send-email]: Request failed:
    Path: POST /api/projects/23/messaging/send/email/
    Status Code: 409 (Conflict)
    Error Message: {"type":"invalid_request","code":"template_outdated","detail":"PostHog does not hold the content this hash describes, so nothing was sent.","attr":"template__hash","why":"The template never had this content, so sending its current content could send the wrong email.","fix":"Upload the template, then send again. posthog-node does this on its own.","docs_url":"https://posthog.com/docs/workflows/transactional-email-api#template_outdated"}
    

    Calls made

    • Test seams, as the ticket named them: the send API (APIBaseTest), CDP mocked at internal_requests.post. The send-by-key and send-by-id cases are new rows of the existing test_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.
    • Throttle test: the ticket said to extend the existing throttle test, but none exists for the transactional email routes on this branch or on upstream/master. I added one test that proves both send routes draw on one per-key budget.
    • "A 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.
    • hash with id is refused. The spec shape is {key, hash?} | {id}. Accepting it later is additive; refusing it later would break callers.
    • Workflow access before the hash check: a 409 or a 404 answers before the workflow editor check, as the UUID route answers 404 before it. Templates are listable by project members anyway.
    • Local proof needed three local-only workarounds, none in the diff: personhog containers on private ports (CDP requires it), sync_hog_function_templates on the scratch database, and the MX verdict cache for example.com set to deliverable for 15 minutes, then restored to its earlier value. Node skips sends to domains without mail servers, and example.com has none; the email only went to the local catcher.
    • The key error's why no longer says the key is part of the URL, because on the send it is in the body.
    • Merge with Email templates: refuse edits to code-managed templates on the server #491: Email templates: refuse edits to code-managed templates on the server #491 landed while this ticket's pre-push hook ran. Both renamed the shared error classes; I took Email templates: refuse edits to code-managed templates on the server #491's names (TemplateRefusal, TemplateErrorSerializer) so one concept has one name, kept the invalid_request helper, 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 passes check_template_write.
    • No review ran in this ticket. The ship ticket Ship code-managed email templates as one reviewed draft PR #498 owns review.

    Follow-ups

    • A template saved before Email templates: upload a template by key from code #487 has no content_hash, so any hash sent for it answers template_outdated until its next save. The SDK then uploads, which also claims it as managed by code. Only adopted templates hit this.
    • The desktop API client was not regenerated locally (generator not installed); backend CI regenerates it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions