Skip to content

Email templates: adopt an existing template by giving it a key #490

Closed

Description

@Silthus

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, PATCH a key, upload by that key with a phs_ 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)

  • /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. self-assigned this
    on Oct 16, 2026
  2. 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-490-1
    Cast: Opus 5.5, high. Cap 180 minutes. Lands on spec/code-managed-templates.

  3. Silthus commented on Oct 17, 2026

    @Silthus
    OwnerAuthor

    Resolution

    Landed: 7a117081e1f on spec/code-managed-templates (fast-forward 0062c7672a8..7a117081e1f: 50c19098ea6 the feature, 4b119fc69b8 a mypy fix, and the merges with #488's e5234aa0bf9 and #492's 0062c7672a8).
    Branch: spec/code-managed-templates/490. No PR: the ship ticket #498 opens it.

    What landed

    • Adoption: a PATCH /api/projects/{project_id}/messaging_templates/{id}/ whose only field is key gives a keyless template its key. It writes only key (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.
      • Allowed for session, personal API key and OAuth callers. They need editor access to the linked workflow, as on any update. Project secret API keys still can't reach partial_update (psak_allowed_actions is unchanged).
      • 400 key_already_set on a keyed template, 400 key_in_use on a taken key (the unique_message_template_key_per_team conflict, so two concurrent adoptions of one key can't both win), 400 invalid_template_key on a bad or null key. All three carry why, fix, docs_url and attr: key, in the shared TemplateErrorSerializer envelope.
      • A code-managed template answers Email templates: refuse edits to code-managed templates on the server #491's 403 immutable before the key is looked at. That's consistent, because a code-managed template already has a key.
    • Set once, never with other fields: any other save whose body sets or changes key answers 400 before the body is validated: key_already_set on a keyed template, invalid_request ("send key on its own") on a keyless one. A body that sends the stored key back (the editor's form spreads the whole template into its PATCH) still saves. key is still not writable through create.
    • The next upload claims the template: this is Email templates: upload a template by key from code #487's existing path (a keyed template without managed_by = code gets updated, managed_by = code, sender and workflow status kept). No upload code changed. Matching is by key only, never by display name.
    • Code: services/template_keys.py (is_adoption, check_key_unchanged, adopt_template), TemplateKeyRefused in facade/contracts.py, and adopt_template, is_adoption and check_template_key_write in facade/templates.py. The view branches on adoption next to Email templates: refuse edits to code-managed templates on the server #491's release, and maps TemplateKeyRefused in handle_exception. The three report_user_action blocks became one _report_user_action helper.
    • Schema: partial_update declares MessageTemplateUpdateSerializer, which is MessageTemplateSerializer plus a writable key with help_text. That keeps the request a superset of the runtime fields. It also declares the 400 response. The response's key stays read-only, and its help_text now says how a key is set. The generated PatchedMessageTemplateApi becomes PatchedMessageTemplateUpdateApi, and nothing outside generated files used it.
    • MCP: workflows-update-email-template exposes key, and its description says how to move a template made in PostHog into code. Regenerated with hogli build:openapi; snapshot refreshed.
    • Usage event: message template adopted (template_id, team_id) through report_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 the PATCH ignores key:

    >       assert (adopted.json()["key"], adopted.json()["managed_by"]) == ("welcome", None)
    E       AssertionError: assert equals failed
    E          -  None,        +  'welcome',
    

    The one that passes is test_a_project_secret_api_key_cannot_adopt, which the base already enforces. It stays as the guard that keeps phs_ keys off adoption.

    Green on the landed tree: the 11 cases pass. products/messaging/backend: 370 passed, nothing skipped.

    11 cases
    test_template_adoption.py::TestTemplateAdoption::test_a_form_save_that_sends_the_stored_key_back_keeps_working PASSED
    test_template_adoption.py::TestTemplateAdoption::test_a_project_secret_api_key_cannot_adopt PASSED
    test_template_adoption.py::TestTemplateAdoption::test_gives_a_template_without_a_key_its_key_and_keeps_its_content_0_session PASSED
    test_template_adoption.py::TestTemplateAdoption::test_gives_a_template_without_a_key_its_key_and_keeps_its_content_1_personal_api_key PASSED
    test_template_adoption.py::TestTemplateAdoption::test_refuses_a_key_it_cannot_set_and_says_how_to_fix_it_0_twice PASSED
    test_template_adoption.py::TestTemplateAdoption::test_refuses_a_key_it_cannot_set_and_says_how_to_fix_it_1_taken_key PASSED
    test_template_adoption.py::TestTemplateAdoption::test_refuses_a_key_it_cannot_set_and_says_how_to_fix_it_2_invalid_key PASSED
    test_template_adoption.py::TestTemplateAdoption::test_refuses_a_key_it_cannot_set_and_says_how_to_fix_it_3_null_key PASSED
    test_template_adoption.py::TestTemplateAdoption::test_refuses_a_key_it_cannot_set_and_says_how_to_fix_it_4_key_with_other_fields PASSED
    test_template_adoption.py::TestTemplateAdoption::test_refuses_a_key_it_cannot_set_and_says_how_to_fix_it_5_changed_key_with_other_fields PASSED
    test_template_upload.py::TestTemplateUpload::test_an_upload_claims_an_adopted_template_and_keeps_its_id_sender_and_paused_workflow PASSED
    ======================== 11 passed in 111.92s (0:01:51) ========================
    
    Seam Case Result
    Template API, session, then phs_ upload adopt a transactional template with another sender and a paused workflow, then upload updated, same id, same workflow id, managed_by: code, status archived and sender kept, new subject in the workflow
    Template API, session and personal key with x-posthog-client: mcp adopt 200, key set, managed_by null, content unchanged
    Template API, phs_ key adopt 403, key stays null
    Template API adopt twice, taken key, invalid key, null key, key with other fields, changed key with other fields 400 with the code, attr: key and docs_url; key and name unchanged
    Template API form PATCH that sends the stored key back with a rename 200

    Mutations, each caught:

    • No key check on ordinary saves: ..._4_key_with_other_fields and ..._5_changed_key_with_other_fields fail (2 failed, 8 passed).
    • The key conflict re-raised instead of key_in_use: ..._1_taken_key fails (1 failed, 9 passed).

    Local gate on the landed tree:

    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 only workflows-transactional-email-api on. The setup had a personal API key (hog_flow:write) for the editor's calls, a phs_ key with hog_flow:write, and a verified default sender with provider: maildev. Verbatim, with the long template bodies of steps 1, 2 and 6 cut to the fields that matter:

    cURL transcript
    ### 1. make a template in PostHog (the editor's create call)
    $ curl -X POST http://127.0.0.1:8490/api/projects/319/messaging_templates/ -H 'Authorization: Bearer phx_…' -d '{"name":"Welcome","type":"email","content":{"templating":"liquid","email":{"subject":"Hello from the editor","html":"<p>Hello from the editor</p>"}}}'
    {"id":"01a14732-45d2-0000-6b93-bfa3c4ef0b42","name":"Welcome","transactional_hog_flow_id":null,"key":null,"managed_by":null,"content_hash":"v1:059de73ddc4b90b83731786887087decb7c6da4fe34783a945f90a3abd0f630d","content.email.subject":"Hello from the editor"}  (cut)
    HTTP 201
    
    ### 2. make it transactional
    $ curl -X POST http://127.0.0.1:8490/api/projects/319/messaging_templates/01a14732-45d2-0000-6b93-bfa3c4ef0b42/make_transactional/ -H 'Authorization: Bearer phx_…' -d '{}'
    {"id":"01a14732-45d2-0000-6b93-bfa3c4ef0b42","name":"Welcome","transactional_hog_flow_id":"01a14732-47cc-0000-a3a5-1e35dae7cf12","key":null,"managed_by":null,"content_hash":"v1:059de73ddc4b90b83731786887087decb7c6da4fe34783a945f90a3abd0f630d","content.email.subject":"Hello from the editor"}  (cut)
    HTTP 200
    
    ### 3. a project secret API key cannot adopt
    $ curl -X PATCH http://127.0.0.1:8490/api/projects/319/messaging_templates/01a14732-45d2-0000-6b93-bfa3c4ef0b42/ -H 'Authorization: Bearer phs_…' -d '{"key":"welcome"}'
    {"type":"authentication_error","code":"permission_denied","detail":"This action does not support project secret API key access","attr":null}
    HTTP 403
    
    ### 4. key together with another field
    $ curl -X PATCH http://127.0.0.1:8490/api/projects/319/messaging_templates/01a14732-45d2-0000-6b93-bfa3c4ef0b42/ -H 'Authorization: Bearer phx_…' -d '{"key":"welcome","name":"Renamed"}'
    {"type":"invalid_request","code":"invalid_request","detail":"`key` can't be set together with other fields.","attr":"key","why":"A key prepares the template for uploads from code, so it is set in a request of its own.","fix":"Send a PATCH whose only field is `key`, then send the other changes.","docs_url":"https://posthog.com/docs/workflows/transactional-email-api#invalid_request"}
    HTTP 400
    
    ### 5. an invalid key
    $ curl -X PATCH http://127.0.0.1:8490/api/projects/319/messaging_templates/01a14732-45d2-0000-6b93-bfa3c4ef0b42/ -H 'Authorization: Bearer phx_…' -d '{"key":"wel come"}'
    {"type":"invalid_request","code":"invalid_template_key","detail":"The key can have at most 400 letters, digits, hyphens (-) and underscores (_).","attr":"key","why":"Code names the template by its key in every project, so the key must stay the same.","fix":"Use a key like `password-reset`.","docs_url":"https://posthog.com/docs/workflows/transactional-email-api#invalid_template_key"}
    HTTP 400
    
    ### 6. adopt: PATCH the key
    $ curl -X PATCH http://127.0.0.1:8490/api/projects/319/messaging_templates/01a14732-45d2-0000-6b93-bfa3c4ef0b42/ -H 'Authorization: Bearer phx_…' -d '{"key":"welcome"}'
    {"id":"01a14732-45d2-0000-6b93-bfa3c4ef0b42","name":"Welcome","transactional_hog_flow_id":"01a14732-47cc-0000-a3a5-1e35dae7cf12","key":"welcome","managed_by":null,"content_hash":"v1:059de73ddc4b90b83731786887087decb7c6da4fe34783a945f90a3abd0f630d","content.email.subject":"Hello from the editor"}  (cut)
    HTTP 200
    
    ### 7. adopt again
    $ curl -X PATCH http://127.0.0.1:8490/api/projects/319/messaging_templates/01a14732-45d2-0000-6b93-bfa3c4ef0b42/ -H 'Authorization: Bearer phx_…' -d '{"key":"welcome-v2"}'
    {"type":"invalid_request","code":"key_already_set","detail":"This template already has the key `welcome`.","attr":"key","why":"Code names the template by its key, so a key never changes.","fix":"Use `welcome` in your code, or duplicate the template and give the copy a key.","docs_url":"https://posthog.com/docs/workflows/transactional-email-api#key_already_set"}
    HTTP 400
    
    ### 8. upload by that key with a phs_ key
    $ curl -X PUT http://127.0.0.1:8490/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":"01a14732-45d2-0000-6b93-bfa3c4ef0b42","key":"welcome","result":"updated","content_hash":"v1:f74cd913f3aa2e5667a588f2865634e56693df77793ca594d18e9b5f89e3d2e6","managed_by":"code","source":{"repository":"github.com/example/app","path":"emails/welcome.html","ref":"3f2c1ab"},"transactional_hog_flow_id":"01a14732-47cc-0000-a3a5-1e35dae7cf12"}
    HTTP 200
    
    ### 9. send by key through the send route
    $ curl -X POST http://127.0.0.1:8490/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":"01a14732-5325-0000-cf32-7f7fe83dc260","warnings":[]}
    HTTP 202
    
    ### 10. the template afterwards
    {"id": "01a14732-45d2-0000-6b93-bfa3c4ef0b42", "key": "welcome", "managed_by": "code", "transactional_hog_flow_id": "01a14732-47cc-0000-a3a5-1e35dae7cf12", "content_hash": "v1:f74cd913f3aa2e5667a588f2865634e56693df77793ca594d18e9b5f89e3d2e6"}
    template id before adoption: 01a14732-45d2-0000-6b93-bfa3c4ef0b42, workflow id before adoption: 01a14732-47cc-0000-a3a5-1e35dae7cf12
    

    The dev mail catcher afterwards:

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

    The template made in PostHog kept its id 01a14732-45d2-… and its workflow 01a14732-47cc-… through adoption, upload and send. The email carries the uploaded subject.

    Calls made

    • Test seams, as the ticket named them: the template API (APIBaseTest), one MCP-style personal key call, and the existing upload test. That test set the key through the ORM. It now adopts through the PATCH, then uploads with the phs_ key, so one test covers adopt-then-upload and the kept id, workflow and sender. The refusal cases are one parameterized test.
    • Key with other fields: on a keyless template it answers invalid_request with why, fix and docs_url (the spec's generic code, with attr: 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 answers key_already_set, whatever else is in the body.
    • The stored key passes: the editor's save spreads the whole template, key included, into its PATCH, so a body whose key equals the stored key (null included) is not a key change. test_a_form_save_that_sends_the_stored_key_back_keeps_working pins it.
    • {"key": null} alone is an adoption with an invalid key, so it answers invalid_template_key.
    • Adopting a key the template already has ({"key": "welcome"} on welcome) answers key_already_set, as the spec says for a keyed template. It's not an idempotent 200.
    • Race: two adoptions of one key on two templates meet the partial unique index. The loser answers key_in_use. Not tested with real concurrency (that needs TransactionTestCase); the taken-key case runs the same IntegrityError path.
    • The template page: Email templates: read-only view of code-managed templates #492, which landed just before this ticket, shows the key on the template page. This ticket changes no UI surface, so it has no screenshot.
    • No review ran in this ticket. The ship ticket Ship code-managed email templates as one reviewed draft PR #498 owns review.

    Gaps

    • UI step of the local proof: the template was made and made transactional through the same API calls the editor makes, with a personal API key, not by clicking through the UI. "The template page shows the same UUID" is shown through GET on the template, not a screenshot.

    Follow-ups

    • The local proof needed the same local-only workarounds as Email templates: one send route that names the template in the body, with a content hash #488, none in the diff: personhog containers on private ports, sync_hog_function_templates on the scratch database, and Node's SES Valkey pointed at a private container with the MX verdict for example.com preset, so the shared Valkey stayed untouched. Everything was removed afterwards.
    • 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