Skip to content

Email templates: read-only view of code-managed templates #492

Closed

Description

@Silthus

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)

  • /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 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-492-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: 33fbbe2bc1d on spec/code-managed-templates (fast-forward 7a117081e1f..33fbbe2bc1d, one commit).
    Branch: spec/code-managed-templates/492. No PR: the ship ticket #498 opens it.

    What landed

    • Read-only preview (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.
    • Shared HTML frame (EmailHtmlFrame.tsx): the library card's sandboxed iframe (sandbox="allow-same-origin", no scripts), now used by the card and by the preview.
    • No writes from the page: Save, Discard changes and Save as new template are hidden, the title and description are not editable, and Delete has a disabled reason. Send test, Metrics, Sends, Versions and API stay.
    • "Stop managing in code" (data-attr="message-template-release", IconUnlock) in the page menu. It opens a confirmation (releaseTemplateDialog.ts, confirm data-attr="message-template-release-confirm") that names the file the next upload comes from. On confirm the logic sends PATCH {"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's detail.
    • Duplicate: the page menu, the notice and the library card share one payload (templateCopy.ts): name + " (copy)", description and content. No key, managed_by, source_* or hashes.
    • Library card: Delete on a code-managed template is disabled: "This template is managed by code. To delete it, open it and stop managing it in code first." This closes Email templates: show "Managed by code" and the source file in the library #489's follow-up about the server's 403 immutable on delete.
    • Notice copy: "This template is managed by code, so it can't be changed here. To change it, edit the file and upload it again. To edit it here, duplicate it or stop managing it in code."
    • Refactor: the page's overflow menu moved to MessageTemplateMenu.tsx, so MessageTemplate stays 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:

    1. A setTemplateValue or setTemplateValues on a code-managed template resets the form to the loaded server copy. The form never differs from the saved state, so templateChanged stays false.
    2. The form's submit does nothing when the template is code-managed, so a Save or Publish never sends.
    3. The view mounts no editor or inputs for a code-managed template, so nothing dispatches field changes in the first place.

    PostHog#118664's autosave is setTemplateValue/setTemplateValues → autoSaveDraft, which saves only when draftOutOfDate is 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 a setTemplateValue listener to the same listeners object, so whichever lands second gets a merge conflict there. The fix is to keep both bodies in one listener. The test keeps its fields and never saves when they change should cover the draft path too: it waits for all running listeners, which would include the 1.5 s breakpoint, and asserts that no PATCH left. 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 a PATCH with the edited subject and name, and managed_by: "code".
    • becomes editable once released: logic.actions.releaseTemplate is not a function.
    • duplicates as a copy without its key, ownership, source or hash passed already, because the existing duplicate sent only three fields. It now guards the shared payload.

    Green: messageTemplateLogic.test.ts 32 passed. Every test under products/workflows/frontend/TemplateLibrary/: 5 suites, 56 passed, nothing skipped.

    Seam Case
    messageTemplateLogic (kea, API mocked at HTTP) field edits + submit on a code-managed template → no PATCH, form unchanged
    messageTemplateLogic duplicate → POST body is exactly {name, description, content}
    messageTemplateLogic release → PATCH {"managed_by": "gui"}, then isCodeManaged false and edits register
    Storybook Products/Workflows/Template library/Read-only template preview

    Local gate on the landed tree:

    • tsgo --noEmit --checkers 1 in frontend/ through fleet heavy: exit 0, 0 errors.
    • hogli ci:preflight --strict --against origin/spec/code-managed-templates: 0 failures. Warnings only: complexity on MessageTemplate (34) and MessageTemplateCard (12, unchanged).
    • The pre-push hook (hogli ci:preflight --strict) passed on both pushes: 0 failures.
    • No backend change, so no build:openapi or mypy run.

    Screenshots: Storybook from this worktree, rendering the real App scene against mocked data (MockHog project, example.com repository, 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.

    Narrow Wide
    Before: code-managed template (editor, Save) before before
    After: read-only preview after after
    Page menu (Stop managing in code, Delete disabled) menu menu
    Release confirmation dialog dialog
    After release: editor, Save, key kept released released
    Duplicate: editable copy, no key or tag copy copy
    Library card: Delete disabled with reason card card

    Calls made

    • Guard in the logic, not a per-endpoint check. The form of a code-managed template can't change, and its submit sends nothing. This covers today's Save and feat(workflows): draft and publish edits to transactional email templates PostHog/posthog#118664's autosave and Publish without naming them. The view also mounts no editor.
    • Release through api.messaging.updateTemplate, not the generated client. The schema marks managed_by read-only, so NonReadonly<PatchedMessageTemplateUpdateApi> can't carry the release body. The page uses the same lib/api call as its Save.
    • Release uses the PATCH response to reset the page, with no second GET. The response is the full template.
    • Menu placement: "Stop managing in code" sits in the page's overflow menu, as in the read-only prototype (310c71d393, scene-panel placement), and the notice names it. The notice keeps one action, Duplicate.
    • Hidden, not disabled: Save, Discard and Save as new template are hidden on a code-managed template, because a read-only page that shows a disabled Save suggests an edit path. Delete stays visible with a disabled reason, because it is the action people look for and the reason says how to get there.
    • Save as new template is hidden too: it needs the editor's state, and Duplicate covers the same need.
    • Tab label: the Design tab reads "Preview" on a code-managed template. Its key stays design.
    • Notice height: at 560px the taller preview made the flex column shrink the notice over the tabs. The notice now has shrink-0.
    • Screenshots from Storybook, as Email templates: show "Managed by code" and the source file in the library #489 did. The scene story is scratch and stays out of the commit. A local upload on the dev stack was not run: the running stack serves the main checkout, not this worktree.
    • No review ran in this ticket. The ship ticket Ship code-managed email templates as one reviewed draft PR #498 owns review.

    Follow-ups

    • feat(workflows): draft and publish edits to transactional email templates PostHog/posthog#118664 merge: whichever of it and this branch lands second merges the two setTemplateValue/setTemplateValues listeners into one, as described above.
    • Generated client: the release can't go through messagingTemplatesPartialUpdate while managed_by is read-only in the schema. A declared write-only release field would let the frontend use the generated client.
    • Library card Duplicate of a code-managed template still works from the card. That matches the spec: duplicate is allowed.
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