Repository navigation
Description
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'ssuperseded_content_hasheswrites nothing and answers200with a stable result such assuperseded. The response names the current hash. Without the option, uploads behave as today, so an explicituploadTemplatestays the rollback path. - posthog-node sets the option on its automatic upload only. On
supersededit sends again once, gets thetemplate_supersededwarning, 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)
/tddred-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-templateson 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-templatesand 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
Silthus commentedon Oct 17, 2026
Runtime: T3 child task
Task:
node:delegated-task:command%3Amcp%3A955e36b3-3986-469c-b816-09cf656780af%3Adelegate-task%3Awf-email-impl-499-1Cast: Opus 5.5, high. Cap 180 minutes.
Silthus commentedon Oct 17, 2026
Resolution
Landed:
7845707ba33onspec/code-managed-templates(fast-forward33fbbe2bc1d..7845707ba33:daf70f92f6athe feature, then the merge with #492's33fbbe2bc1d).Branch:
spec/code-managed-templates/499. No PR: the ship ticket #498 opens it.SDK commit:
5a896e92onSilthus:feat/node-emails-send, PostHog/posthog-js#5312 (description updated).fleet proof gatepasses on it: live-run and cross-version evidence at HEAD, HEAD reviewed.What landed
if_not_superseded(boolean, defaultfalse). With it set, an upload whose content hash is in the template'ssuperseded_content_hasheswrites nothing: no content, no source, no workflow version, no activity row. It answers200withresult: "superseded"and the currentcontent_hash.sent_hash_stateinservices/content_hash.py, so "superseded" means the same on upload and send. The decision sits inservices/template_uploads.pynext to the unchanged and updated paths.TemplateUploadResult.SUPERSEDED,TemplateUpload.if_not_superseded,help_texton the new field and onresult,content_hashandmanaged_by(that one is no longer "alwayscode" after asupersededanswer). The usage event reportsresult: superseded.workflows-upload-email-templatesays when to setif_not_superseded, and that leaving it out rolls back. Regenerated withhogli build:openapi, tool snapshot refreshed.template_outdatedortemplate_not_foundsendsif_not_superseded: true.uploadTemplatesendsfalse. Onsuperseded, the existing single resend returns thetemplate_supersededwarning. No new retry.Proof
Red, backend (the new test on
7a117081e1f, before the change):The
new_contentcase passes on both sides. It guards that the option never blocks content that is new.Green, backend:
test_template_upload.py18 passed.products/messaging/backend372 passed, nothing skipped.Red, SDK (the new race case in
emails.spec.ts, with the fake implementing the option, before the SDK change):Green, SDK:
emails.spec.tsandemail-content-hash.spec.ts33 passed.Mutations, each caught:
test_a_publish_moves_the_replaced_hash_to_the_superseded_list(the explicit rollback)..._never_puts_replaced_content_back_0_superseded_content(the red above)uploadTemplatetoouploadTemplate publishes replaced content on purpose, …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 1infrontend/: 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, throughfleet heavy:pnpm format,pnpm lint:oxlint, nodelintandcheck-typespass; nodetest:unit1199 passed;test:edge-compat1199 passed; build andgenerate-referencespass (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 withhog_flow:write, a verified default sender, onlyworkflows-transactional-email-apiforced on. Verbatim:curl transcript: publish A, publish B, automatic upload of A, explicit upload of A
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 with202, the same boundary the API tests mock. The SDK'sfetchwrapper held the old deploy's upload until two other clients had published A and then B. Every answer is the server's:Request log
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 (base363b0e33) withfleet proof review-startandreview-finish. Result: no findings, PASS. The backend part has no review in this ticket. The ship ticket #498 owns it.Calls made
if_not_supersededin the upload body, as the ticket suggested.supersededis a fourthresultwith status200, because nothing failed and the response names the current hash.source: anunchangedupload still writes source parts that differ, but asupersededone does not. The old file's ref must not replace the newer deploy's ref. The test assertssource_refstays at the newer upload's.supersededis decided beforeunchanged. They can't both hold, because publishing a hash removes it from the superseded list.managed_by = gui) whose hash list holds the uploaded hash also answerssupersededand stays released. An old file should not claim it back. That is why the response'smanaged_byhelp text changed.APIBaseTest, aphs_key, CDP mocked atinternal_requests.post), one parameterized test with a superseded case and a new-content case. "Option unset keeps today's behavior" is already pinned bytest_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,FakeEmailsApiimplements the option, and one new case schedules a stale409after a newer deploy published.uploadTemplatesendsif_not_superseded: falseexplicitly rather than omitting it, so each call site states which upload it is.UploadEmailTemplateResult.resultstayscreated | updated | unchanged.supersededonly answers the automatic upload, which callers never see.false. Saved as cross-version proof on Think through the Elements tab PostHog/posthog#5312.test_posthoghad 13 migrations applied and ClickHouse'sposthog_testwas stale. Tests ran ontest_t499(cloned fromtest_posthog_cmdk) withPYTEST_XDIST_WORKER=gw499for a private ClickHouse database. Both were dropped afterwards.Gaps
Follow-ups
desktopAPI client was not regenerated locally (the generator is not installed). Backend CI regenerates it.unchangedstill waits for that PR. Asupersededupload discards no draft, which matches "writes nothing".