Skip to content

Email templates: an automatic upload never republishes a superseded hash #499

Closed

Description

@Silthus

Map: Map: send code-managed email templates with one PostHog call
Spec: Spec: code-managed email templates, sections "Upload" and "Hash on send, and the rolling-deploy rule"
Origin: follow-up from #474 (PostHog/posthog-js#5312)

Cast: Opus 5.5, high
Repository: PostHog/posthog, plus a small change on PostHog/posthog-js#5312

Question

Can an automatic upload from the SDK never republish content that a newer upload already replaced?

The gap. The send refuses a hash it has never seen with 409 template_outdated, and the SDK then uploads and sends again. Between that 409 and the upload, another instance can publish the same hash and a newer deploy can replace it. The automatic upload then publishes the old content again, which breaks the rule that an older file never puts old content back (Card 2 → i).

Delivers end to end.

  • The upload route accepts an option (name it in the spec's style, for example if_not_superseded: true). With it set, an upload whose hash is in the template's superseded_content_hashes writes nothing and answers 200 with a stable result such as superseded. The response names the current hash. Without the option, uploads behave as today, so an explicit uploadTemplate stays the rollback path.
  • posthog-node sets the option on its automatic upload only. On superseded it sends again once, gets the template_superseded warning, and does not upload. This lands as a commit on feat(node): send transactional email with the flat SDK contract PostHog/posthog-js#5312, by the same rules as that PR.
  • The MCP upload tool exposes the option in its description, so an agent can choose it.

Test seams. The upload API (option set and hash superseded writes nothing; option set and hash new uploads; option unset keeps today's behavior). The SDK fake API (an automatic upload answered superseded sends once more and returns the warning). The hash rules stay in services/content_hash.py.

Write scope. products/messaging/backend/services/template_saves.py and the upload serializer and view, the upload tests, products/workflows/mcp/email_templates.yaml, generated files. In posthog-js: packages/node/src/emails.ts and its spec.

Proof. Red and green for both seams. A local replay of the race: publish hash A, then hash B, then run an automatic upload of A. The template keeps B.

Acceptance criteria

  • An automatic upload never republishes a superseded hash.
  • An explicit upload still can (the rollback path).
  • No new idempotency or retry beyond the one resend after superseded.

How to build (map #461)

  • /tdd red-green-refactor, outside-in, at the seams named above. Real Postgres; mock only what the seam section allows.
  • Read code from the integration branch spec/code-managed-templates on the fork.
  • Public repo: no usage numbers, customer names, internal metrics, Slack content or security reasoning in code, commits or PRs. Fixture addresses use example.com.
  • Shipped means the backend part landed on spec/code-managed-templates and the SDK commit pushed to feat(node): send transactional email with the flat SDK contract PostHog/posthog-js#5312. Never mark ready, enqueue or merge.

Activity

  1. self-assigned this
    on Oct 17, 2026
  2. Silthus commented on Oct 17, 2026

    @Silthus
    OwnerAuthor

    Runtime: T3 child task
    Task: node:delegated-task:command%3Amcp%3A955e36b3-3986-469c-b816-09cf656780af%3Adelegate-task%3Awf-email-impl-499-1
    Cast: Opus 5.5, high. Cap 180 minutes.

  3. Silthus commented on Oct 17, 2026

    @Silthus
    OwnerAuthor

    Resolution

    Landed: 7845707ba33 on spec/code-managed-templates (fast-forward 33fbbe2bc1d..7845707ba33: daf70f92f6a the feature, then the merge with #492's 33fbbe2bc1d).
    Branch: spec/code-managed-templates/499. No PR: the ship ticket #498 opens it.

    SDK commit: 5a896e92 on Silthus:feat/node-emails-send, PostHog/posthog-js#5312 (description updated). fleet proof gate passes on it: live-run and cross-version evidence at HEAD, HEAD reviewed.

    What landed

    • Upload option: the upload body takes if_not_superseded (boolean, default false). With it set, an upload whose content hash is in the template's superseded_content_hashes writes nothing: no content, no source, no workflow version, no activity row. It answers 200 with result: "superseded" and the current content_hash.
    • Without the option an upload behaves as before, so an explicit upload stays the rollback path.
    • Hash rule: the check reuses sent_hash_state in services/content_hash.py, so "superseded" means the same on upload and send. The decision sits in services/template_uploads.py next to the unchanged and updated paths.
    • Contract and schema: TemplateUploadResult.SUPERSEDED, TemplateUpload.if_not_superseded, help_text on the new field and on result, content_hash and managed_by (that one is no longer "always code" after a superseded answer). The usage event reports result: superseded.
    • MCP: workflows-upload-email-template says when to set if_not_superseded, and that leaving it out rolls back. Regenerated with hogli build:openapi, tool snapshot refreshed.
    • posthog-node: the automatic upload after template_outdated or template_not_found sends if_not_superseded: true. uploadTemplate sends false. On superseded, the existing single resend returns the template_superseded warning. No new retry.

    Proof

    Red, backend (the new test on 7a117081e1f, before the change):

    >       assert (uploaded["result"], uploaded["id"]) == (expected_result, newer["id"])
    E       AssertionError: assert equals failed
    E          -  'updated',                      +  'superseded',
    FAILED ...test_an_upload_if_not_superseded_never_puts_replaced_content_back_0_superseded_content
    ================= 1 failed, 1 passed, 16 deselected in 14.58s ==================
    

    The new_content case passes on both sides. It guards that the option never blocks content that is new.

    Green, backend: test_template_upload.py 18 passed. products/messaging/backend 372 passed, nothing skipped.

    Red, SDK (the new race case in emails.spec.ts, with the fake implementing the option, before the SDK change):

    FAIL  src/__tests__/emails.spec.ts > emails > send from a template file > never publishes content that newer content replaced while its first send was answered
    AssertionError: expected 'Welcome' to be 'Welcome aboard' // Object.is equality
          Tests  1 failed | 29 skipped (30)
    

    Green, SDK: emails.spec.ts and email-content-hash.spec.ts 33 passed.

    Mutations, each caught:

    Mutation Caught by
    The server refuses a superseded hash without the option test_a_publish_moves_the_replaced_hash_to_the_superseded_list (the explicit rollback)
    The server ignores the option ..._never_puts_replaced_content_back_0_superseded_content (the red above)
    The SDK sets the option on uploadTemplate too uploadTemplate publishes replaced content on purpose, …
    The SDK never sets the option the new race case (the red above)

    Local gate, backend, on the landed tree:

    • hogli ci:preflight --strict --against origin/spec/code-managed-templates: 0 failures, before and after the merge with Email templates: read-only view of code-managed templates #492. The pre-push hook ran it again on the push: 0 failures.
    • uv run mypy --cache-fine-grained .: Success: no issues found in 24089 source files.
    • tsgo --noEmit --checkers 1 in frontend/: exit 0, on the tree with Email templates: read-only view of code-managed templates #492 merged.
    • pnpm --filter=@posthog/mcp run typecheck: exit 0. Tool schema snapshot test: 3 passed.
    • hogli lint:tach, hogli product:lint messaging: pass.
    • hogli build:openapi: exit 0, regenerated output committed.

    Local gate, SDK on 5a896e92, through fleet heavy: pnpm format, pnpm lint:oxlint, node lint and check-types pass; node test:unit 1199 passed; test:edge-compat 1199 passed; build and generate-references pass (no reference change).

    Local race replay through the API. Django from this worktree on a scratch database cloned from a migrated test database, a phs_ key with hog_flow:write, a verified default sender, only workflows-transactional-email-api forced on. Verbatim:

    curl transcript: publish A, publish B, automatic upload of A, explicit upload of A
    ### 1. publish hash A
    $ curl -X PUT http://127.0.0.1:8499/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":"01a14792-d0cd-0000-8e29-ba26935018fa","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":"01a14792-d0d8-0000-d798-85ea409c1304"}
    HTTP 201
    
    ### 2. publish hash B
    $ curl -X PUT http://127.0.0.1:8499/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":"01a14792-d0cd-0000-8e29-ba26935018fa","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":"01a14792-d0d8-0000-d798-85ea409c1304"}
    HTTP 200
    
    ### 3. automatic upload of A (if_not_superseded)
    $ curl -X PUT http://127.0.0.1:8499/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":{"ref":"3f2c1ab"},"if_not_superseded":true}'
    {"id":"01a14792-d0cd-0000-8e29-ba26935018fa","key":"welcome","result":"superseded","content_hash":"v1:246510b8be58d294af5fe467bbad517a464f72dcdf9a5850f3ef95d56126da58","managed_by":"code","source":{"repository":"github.com/example/app","path":"emails/welcome.html","ref":"9e8d7c6"},"transactional_hog_flow_id":"01a14792-d0d8-0000-d798-85ea409c1304"}
    HTTP 200
    
    ### 4. the template after the automatic upload of A
                      subject                   | source_ref |                            content_hash                             |                       superseded_content_hashes                       | workflow_version
    --------------------------------------------+------------+---------------------------------------------------------------------+-----------------------------------------------------------------------+------------------
     Welcome aboard, {{ variables.first_name }} | 9e8d7c6    | v1:246510b8be58d294af5fe467bbad517a464f72dcdf9a5850f3ef95d56126da58 | {v1:f74cd913f3aa2e5667a588f2865634e56693df77793ca594d18e9b5f89e3d2e6} |                2
    
    ### 5. explicit upload of A (the rollback path)
    $ curl -X PUT http://127.0.0.1:8499/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":{"ref":"3f2c1ab"}}'
    {"id":"01a14792-d0cd-0000-8e29-ba26935018fa","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":"01a14792-d0d8-0000-d798-85ea409c1304"}
    HTTP 200
    
    ### 6. the template after the explicit upload of A
                   subject               | source_ref |                            content_hash                             |                       superseded_content_hashes                       | workflow_version
    -------------------------------------+------------+---------------------------------------------------------------------+-----------------------------------------------------------------------+------------------
     Welcome, {{ variables.first_name }} | 3f2c1ab    | v1:f74cd913f3aa2e5667a588f2865634e56693df77793ca594d18e9b5f89e3d2e6 | {v1:246510b8be58d294af5fe467bbad517a464f72dcdf9a5850f3ef95d56126da58} |                3
    

    The template keeps B after the automatic upload of A: same subject, same source ref, workflow still at version 2. The explicit upload of A still rolls back.

    The same race through the real SDK (5a896e92) against that Django. A stub answered the CDP delivery route with 202, the same boundary the API tests mock. The SDK's fetch wrapper held the old deploy's upload until two other clients had published A and then B. Every answer is the server's:

    Request log
    ### 0. a template under the key already exists, with content C
    [seed] PUT …/by_key/welcome-race/ {"subject":"Hello, {{ variables.first_name }}","if_not_superseded":false} -> 201 created
    ### 1. the old deploy sends file A; other instances publish A, then the new deploy publishes B, before its upload
    [old deploy] POST …/messaging/send/email/ {"key":"welcome-race","hash":"v1:b43ba0d9…2174"} -> 409
    [other old instance] PUT …/by_key/welcome-race/ {"subject":"Welcome, {{ variables.first_name }}","if_not_superseded":false} -> 200 updated
    [new deploy] POST …/messaging/send/email/ {"key":"welcome-race","hash":"v1:a9468b35…f3b0"} -> 409
    [new deploy] PUT …/by_key/welcome-race/ {"subject":"Welcome aboard, {{ variables.first_name }}","if_not_superseded":true} -> 200 updated
    [new deploy] POST …/messaging/send/email/ {"key":"welcome-race","hash":"v1:a9468b35…f3b0"} -> 202
    [old deploy] PUT …/by_key/welcome-race/ {"subject":"Welcome, {{ variables.first_name }}","if_not_superseded":true} -> 200 superseded
    [old deploy] POST …/messaging/send/email/ {"key":"welcome-race","hash":"v1:b43ba0d9…2174"} -> 202
    old deploy result {"data":{"id":"…","warnings":[{"code":"template_superseded","message":"The email was sent with the template's current content, because newer content replaced the content you hold."}]},"error":null}
    
    ### the template afterwards
     subject                                    | workflow_version
     Welcome aboard, {{ variables.first_name }} | 3
    

    Review

    GPT-6.1 Sol at high effort reviewed the SDK commit through T3 delegate_task. It was recorded as final read 5 on PostHog/posthog-js#5312 (base 363b0e33) with fleet proof review-start and review-finish. Result: no findings, PASS. The backend part has no review in this ticket. The ship ticket #498 owns it.

    Calls made

    • Name and shape: if_not_superseded in the upload body, as the ticket suggested. superseded is a fourth result with status 200, because nothing failed and the response names the current hash.
    • "Writes nothing" includes source: an unchanged upload still writes source parts that differ, but a superseded one does not. The old file's ref must not replace the newer deploy's ref. The test asserts source_ref stays at the newer upload's.
    • Order of checks: superseded is decided before unchanged. They can't both hold, because publishing a hash removes it from the superseded list.
    • A released template (managed_by = gui) whose hash list holds the uploaded hash also answers superseded and stays released. An old file should not claim it back. That is why the response's managed_by help text changed.
    • Test seams: the template upload API (APIBaseTest, a phs_ key, CDP mocked at internal_requests.post), one parameterized test with a superseded case and a new-content case. "Option unset keeps today's behavior" is already pinned by test_a_publish_moves_the_replaced_hash_to_the_superseded_list. The mutation table shows it catches the regression, so no duplicate case was added. On the SDK side, FakeEmailsApi implements the option, and one new case schedules a stale 409 after a newer deploy published.
    • SDK body: uploadTemplate sends if_not_superseded: false explicitly rather than omitting it, so each call site states which upload it is.
    • Public SDK type unchanged: UploadEmailTemplateResult.result stays created | updated | unchanged. superseded only answers the automatic upload, which callers never see.
    • Cross-version: a server without the option drops the unknown field and publishes as before. An old SDK never sends it, and the server defaults it to false. Saved as cross-version proof on Think through the Elements tab PostHog/posthog#5312.
    • Local test databases: the shared test_posthog had 13 migrations applied and ClickHouse's posthog_test was stale. Tests ran on test_t499 (cloned from test_posthog_cmdk) with PYTEST_XDIST_WORKER=gw499 for a private ClickHouse database. Both were dropped afterwards.

    Gaps

    Follow-ups

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