Repository navigation
Email templates: read-only view of code-managed templates #492
Description
Map: Map: send email from code with one PostHog call
Spec: Spec: code-managed email templates, section "Lock (later slices)", UI part
Cast: Opus 5.5, high
Repository: PostHog/posthog
Question
Does a code-managed template look read-only in the template library, while a person can still preview it, copy it, or take it back?
Delivers end to end. On a code-managed template page: a read-only preview (subject, preheader, HTML in a sandboxed frame, text) in place of the email editor; no Save, Publish or Discard draft; no draft autosave (whichever autosave exists when this is built). Metrics, sends and versions tabs stay. Duplicate creates an editable copy without key, managed_by, source_* and the hashes. A "Stop managing in code" action, behind a confirmation, sends the release and reloads the editor. Reuse the card's existing HTML preview.
Test seams. Kea logic tests: a code-managed template never calls the save or draft endpoints when fields change; duplicate drops the four fields; release reloads as editable. Storybook story for the read-only page. Prior art: messageTemplateLogic.test.ts, and the read-only prototype on the fork's prototype/workflows-ui-read-only (310c71d393) for layout only.
Write scope. products/workflows/frontend/TemplateLibrary/ (MessageTemplate.tsx, messageTemplateLogic.ts, messageTemplatesLogic.ts, new read-only preview component and story).
Proof.
- Red: the no-autosave logic test fails.
- Green: the logic tests pass.
- Local: upload a template, open it, screenshot the read-only page, duplicate it and screenshot the editable copy, release it and screenshot the editor. At 1280px with the side panel open and at full width. Before and after screenshots in the PR.
Acceptance criteria
- No write request leaves the page for a code-managed template except release and duplicate.
- The duplicate is a normal UI template, with no key and no hashes.
- Screenshots in the PR.
How to build (map #461)
/tddred-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'smasteris 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
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-492-1Cast: Opus 5.5, high. Cap 180 minutes. Lands on
spec/code-managed-templates.Silthus commentedon Oct 17, 2026
Resolution
Landed:
33fbbe2bc1donspec/code-managed-templates(fast-forward7a117081e1f..33fbbe2bc1d, one commit).Branch:
spec/code-managed-templates/492. No PR: the ship ticket #498 opens it.What landed
ReadOnlyTemplatePreview.tsx,data-attr="message-template-read-only-preview"): on a code-managed template, the Design tab becomes Preview and shows the subject, the preheader (when set), the HTML in a sandboxed frame and the plain text. The email editor does not mount.EmailHtmlFrame.tsx): the library card's sandboxediframe(sandbox="allow-same-origin", no scripts), now used by the card and by the preview.data-attr="message-template-release",IconUnlock) in the page menu. It opens a confirmation (releaseTemplateDialog.ts, confirmdata-attr="message-template-release-confirm") that names the file the next upload comes from. On confirm the logic sendsPATCH {"managed_by": "gui"}(Email templates: refuse edits to code-managed templates on the server #491's release), resets the form to the response, and the page shows the editor again. A refused release toasts the server'sdetail.templateCopy.ts): name + " (copy)", description and content. No key,managed_by,source_*or hashes.403 immutableon delete.MessageTemplateMenu.tsx, soMessageTemplatestays at its old complexity (34, was 33).How the guard holds with or without PostHog#118664
The guard lives in
messageTemplateLogic, not only in the view:setTemplateValueorsetTemplateValueson a code-managed template resets the form to the loaded server copy. The form never differs from the saved state, sotemplateChangedstays false.submitdoes nothing when the template is code-managed, so a Save or Publish never sends.PostHog#118664's autosave is
setTemplateValue/setTemplateValues→autoSaveDraft, which saves only whendraftOutOfDateis true after its 1.5 s breakpoint. Point 1 keeps that false, so the draft endpoint is never called. The guard needs no code in PostHog#118664's listener. Both PRs add asetTemplateValuelistener to the samelistenersobject, so whichever lands second gets a merge conflict there. The fix is to keep both bodies in one listener. The testkeeps its fields and never saves when they changeshould cover the draft path too: it waits for all running listeners, which would include the 1.5 s breakpoint, and asserts that noPATCHleft. Not checked: this branch was not run with PostHog#118664 merged in.Proof
Red (red.log), the new tests run before the change:
2 failed, 30 passed.keeps its fields and never saves when they change: the submit sent aPATCHwith the edited subject and name, andmanaged_by: "code".becomes editable once released:logic.actions.releaseTemplate is not a function.duplicates as a copy without its key, ownership, source or hashpassed already, because the existing duplicate sent only three fields. It now guards the shared payload.Green:
messageTemplateLogic.test.ts32 passed. Every test underproducts/workflows/frontend/TemplateLibrary/:5 suites, 56 passed, nothing skipped.messageTemplateLogic(kea, API mocked at HTTP)PATCH, form unchangedmessageTemplateLogicPOSTbody is exactly{name, description, content}messageTemplateLogicPATCH {"managed_by": "gui"}, thenisCodeManagedfalse and edits registerProducts/Workflows/Template library/Read-only template previewLocal gate on the landed tree:
tsgo --noEmit --checkers 1infrontend/throughfleet heavy: exit 0, 0 errors.hogli ci:preflight --strict --against origin/spec/code-managed-templates: 0 failures. Warnings only: complexity onMessageTemplate(34) andMessageTemplateCard(12, unchanged).hogli ci:preflight --strict) passed on both pushes: 0 failures.build:openapior mypy run.Screenshots: Storybook from this worktree, rendering the real
Appscene against mocked data (MockHog project,example.comrepository, invented copy, no customer data). Narrow is 560px, about the scene width of a 1280px window with the side panel open. Wide is 1440px. "Before" is the same page as #489 landed it.Calls made
api.messaging.updateTemplate, not the generated client. The schema marksmanaged_byread-only, soNonReadonly<PatchedMessageTemplateUpdateApi>can't carry the release body. The page uses the samelib/apicall as its Save.310c71d393, scene-panel placement), and the notice names it. The notice keeps one action, Duplicate.design.shrink-0.Follow-ups
setTemplateValue/setTemplateValueslisteners into one, as described above.messagingTemplatesPartialUpdatewhilemanaged_byis read-only in the schema. A declared write-only release field would let the frontend use the generated client.