Skip to content

Email templates: upload a template by key from code #487

Closed

Description

@Silthus

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 unchanged and 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_unavailable and writes nothing;
  • an invalid key answers 400 invalid_template_key;
  • a phs_ key without hog_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_hash equals 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 from 1b513421c3 on the fork's feat/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 PUT uploads welcome (201), again (200 unchanged), with a new subject (200 updated, new content_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)

  • /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-487-1
    Cast: Opus 5.5, high. Cap 180 minutes. Lands on spec/code-managed-templates.

  3. Silthus commented on Oct 16, 2026

    @Silthus
    OwnerAuthor

    Resolution

    Landed: 65d441ac6e9 on spec/code-managed-templates (fast-forward a5dbb10460b..65d441ac6e9: a17b303d456 the feature, 65d441ac6e9 the semgrep and mypy fixes).
    Branch: spec/code-managed-templates/487. No PR: the ship ticket #498 opens it.

    What landed

    • Upload route: PUT /api/projects/{project_id}/messaging_templates/by_key/{key}/, also through @current, behind workflows-transactional-email-api (plain 404 when off). Open to phs_ keys with hog_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 answers 400 sender_unavailable and 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, no updated_at bump).
      • Racing first uploads: the loser catches the unique_message_template_key_per_team conflict and retries as an update, so one template exists and both calls succeed.
      • Response {id, key, result, content_hash, managed_by, source, transactional_hog_flow_id}.
      • New error codes invalid_template_key and invalid_request carry why, fix and docs_url; attr names the field (source__path for nested fields, as exceptions-hog does). Existing codes (sender_unavailable, template_not_sendable, marketing_template) keep their bodies.
      • Usage event transactional email template uploaded with result, auth_method, team_id (and template_id, or error_code on a refusal).
    • Prefactor: the locked save-and-sync block moved out of the view into services/template_saves.py. update, design and the upload all call save_locked_template. lock_transactional_workflow_id and check_transactional_update are gone; check_still_sendable(template) checks the written row.
    • Content hash: services/content_hash.py implements 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 of superseded_content_hashes (at most 100), and publishing a hash removes it from the list.
    • Serializers: preheader is declared on the email content serializer. The upload serializer turns off whitespace trimming on subject, preheader, HTML and text. MessageTemplateSerializer exposes key, managed_by, source_* and content_hash read-only with help_text. MessageTemplateRow carries them.
    • MCP: workflows-upload-email-template in products/workflows/mcp/email_templates.yaml, gated by workflows-transactional-email-api. Generated with hogli build:openapi; snapshots refreshed.

    Proof

    Red (base a5dbb10460b plus only the new tests, on a separate tree):

    • test_template_upload.py: 12 failed. The first-upload test fails with 404 {'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/backend 331 passed (the 13 upload cases and 3 hash cases included, nothing skipped).

    Mutations, each caught by the new tests:

    • Trimming the HTML field: test_a_changed_upload_is_what_the_next_send_delivers fails (the fixture's HTML ends with a newline, and the expected hash comes from a standalone script of the spec algorithm).
    • _is_unchanged always false: test_an_identical_upload_is_unchanged_and_writes_no_workflow_version fails.
    • No hash on save: the changed-upload and superseded-list tests fail.
    • No retry after the key conflict: test_two_racing_first_uploads_end_with_one_template fails.

    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-class on templating) predates this change and is not blocked.
    • uv run mypy --cache-fine-grained .: Success: no issues found in 24084 source files.
    • tsgo --noEmit --checkers 1 in frontend/, the typescript:check script 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.
    • The pre-push hook (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 only workflows-transactional-email-api on. Verbatim:

    curl transcript
    ### 1. first upload
    $ curl -X PUT http://127.0.0.1:8487/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":"01a14631-40b7-0000-4dfd-60cd5a2e12ad","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":"01a14631-40c1-0000-d930-cf5400ef453a"}
    HTTP 201
    
    ### 2. same upload again
    {"id":"01a14631-40b7-0000-4dfd-60cd5a2e12ad","key":"welcome","result":"unchanged","content_hash":"v1:f74cd913f3aa2e5667a588f2865634e56693df77793ca594d18e9b5f89e3d2e6","managed_by":"code","source":{"repository":"github.com/example/app","path":"emails/welcome.html","ref":"3f2c1ab"},"transactional_hog_flow_id":"01a14631-40c1-0000-d930-cf5400ef453a"}
    HTTP 200
    
    ### 3. changed subject
    -d '{"name":"Welcome","subject":"Welcome aboard, {{ variables.first_name }}","html":"<p>Hi {{ variables.first_name }}</p>","source":{"ref":"9e8d7c6"}}'
    {"id":"01a14631-40b7-0000-4dfd-60cd5a2e12ad","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":"01a14631-40c1-0000-d930-cf5400ef453a"}
    HTTP 200
    
    ### 4. the template's workflow after the three uploads
     version | status |                  subject                   | sender 
    ---------+--------+--------------------------------------------+--------
           2 | active | Welcome aboard, {{ variables.first_name }} | 259
    (1 row)
    
                                content_hash                             |                       superseded_content_hashes                       
    ---------------------------------------------------------------------+-----------------------------------------------------------------------
     v1:246510b8be58d294af5fe467bbad517a464f72dcdf9a5850f3ef95d56126da58 | {v1:f74cd913f3aa2e5667a588f2865634e56693df77793ca594d18e9b5f89e3d2e6}
    (1 row)
    
    
    ### 5. an invalid key
    {"type":"invalid_request","code":"invalid_template_key","detail":"The key can have at most 400 letters, digits, hyphens (-) and underscores (_).","attr":"key","why":"The key is part of the URL and names the template in every project, so it 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. a missing subject
    {"type":"invalid_request","code":"invalid_request","detail":"subject: This field is required.","attr":"subject","why":"An upload needs a name, a subject and HTML, each as a string.","fix":"Fix `subject` and upload again.","docs_url":"https://posthog.com/docs/workflows/transactional-email-api#invalid_request"}
    HTTP 400
    
    ### 7. template CRUD stays closed to the phs_ key
    {"type":"authentication_error","code":"permission_denied","detail":"This action does not support project secret API key access","attr":null}
    HTTP 403
    

    The workflow went from version 1 (create) to version 2 (the changed subject). The unchanged upload wrote no version. The new hash v1:2465…da58 matches an independent run of the spec algorithm.

    Calls made

    • Test seams, as the ticket named them: the template API (APIBaseTest, CDP mocked at internal_requests.post), plus one unit test of the hash against the spec vectors.
    • "The send route queues the new subject": the CDP payload carries only to, distinct_id and variables. 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.
    • The race test forces the conflict by making the key lookup miss the winner once (patch.object on locked_template_with_key). A real two-transaction race needs TransactionTestCase, which /writing-tests advises against.
    • The superseded list is internal, so its two assertions read the model row. Nothing public exposes it until the send-with-hash ticket.
    • Non-key callers: editor access to the linked workflow on update (same check as PATCH), and resource-level workflow editor access before a workflow is created (same check as make_transactional).
    • Field errors use invalid_request with why, fix and docs_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_by now takes its choices from the contract enum TemplateManagedBy (no migration change). Two classes with identical pairs left the OpenAPI enum unnamed and broke the schema build.
    • products/tasks/frontend/generated/api.schemas.ts renames ResultEnumApi to TaskRunPeerMessageResponseResultEnumApi. The new result field collides with tasks' inline result choices. Nothing outside generated files imports the old name, and the frontend typecheck passes.
    • No review ran in this ticket. The ship ticket Ship code-managed email templates as one reviewed draft PR #498 owns review.

    Gaps

    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.
    • 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