Skip to content

Copy as code: copy a code-managed template as its HTML file #509

Closed

Description

@Silthus

Map: Map: send code-managed email templates with one PostHog call
Spec: Spec: copy-as-code and open-a-PR for code-managed templates (revision 2), section "Copy HTML file (stack A)"

Cast: Opus 5.5, high
Repository: PostHog/posthog (frontend only)
Stack: A, second slice

Question

Can a person copy a code-managed template as the exact .html file posthog.emails.templateFile() reads?

Delivers end to end.

  • Menu item. The side menu of "Edit with your agent" offers "Copy HTML file".
  • Encoder. A pure encoder turns the published template into the file:
    • the subject goes into the first <title> and the preheader into the first <meta name="preheader">, each added when missing;
    • a tag whose parsed value already matches stays as it is;
    • a blank preheader removes its tag;
    • Liquid stays literal;
    • the document is always the published HTML.
  • Toast. The spec's toast after copying, plus the text-part note when the saved template has a nonempty text part. Copy is never blocked.
  • Event: message template html file copied {template_id, dropped_text_part}.

Test seams.

  • The encoder against a vector table. Its expected parse results and hashes are recorded once from the SDK's parseTemplateFile and emailContentHash at feat(node): send transactional email with the flat SDK contract PostHog/posthog-js#5312's head, including the research report's synthetic vector (v1:b967355e…). Cover:
    • the parse round trip;
    • idempotence;
    • an SDK-uploaded file encoding to identical bytes;
    • a missing head, title or meta;
    • entities in the title;
    • a blank preheader;
    • Liquid.
  • Kea logic test: the clipboard holds the encoded file and no request leaves the page.
  • Prior art: messageTemplateLogic.test.ts.

Write scope. products/workflows/frontend/TemplateLibrary/: a new encoder module and its test, MessageTemplateMenu.tsx, messageTemplateLogic.ts, templateCopy.ts.

Proof.

  • Red: the "SDK file encodes to identical bytes" vector fails.
  • Green: the encoder and logic tests pass.
  • Local:
    • upload a template from a file through posthog-node, copy it, and diff the clipboard against the file (no difference);
    • upload one through REST with a separate subject, preheader and text part, copy it, check the title and meta, and screenshot the text-part note.
  • Screenshots at 1280px with the side panel open and at full width.

Acceptance criteria

  • Copying an SDK-uploaded template gives back the original file byte for byte.
  • The SDK's parser reads the copied file's subject and preheader as the template's.
  • Screenshots in the PR.

Blockers

Blocked by #503 (the button and its side menu).

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, /writing-ui-components, /writing-kea-logics, /writing-user-facing-copy, /writing-code-comments, /improving-drf-endpoints, /routing-outbound-api-calls.
  • Read code from origin/spec/code-managed-templates (the fork's integration branch for feat(messaging): upload, send and lock code-managed email templates PostHog/posthog#118924), not from master.
  • 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. The conductor names the stack's integration branch.

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%3A1778d880-5a25-404c-80d0-25fc5ccca3b2%3Adelegate-task%3Awf-email-impl-509-1
    Cast: Opus 5.5, high. Cap 180 minutes.

  3. Silthus commented on Oct 17, 2026

    @Silthus
    OwnerAuthor

    Note

    🤖 Opus 5.5 responding on behalf of Michael

    Resolution

    Landed 8a50f6ef11c on spec/template-agent-edit as a fast-forward from 62c695af6cb. The ship ticket #516 owns the PR and the review.

    • Menu item: "Edit with your agent" is now a split button. Its side menu offers "Copy HTML file". The side button replaces the dropdown chevron, as Edit with your agent: copy a prompt for a code-managed template's file #503's follow-up asked.
    • Encoder (templateHtmlFile.ts, pure) turns the published subject, preheader and HTML into the file posthog.emails.templateFile() reads:
      • the subject goes into the first <title>. A missing title is added at the start of <head>, and a missing <head> is added too;
      • the preheader goes into the first <meta name="preheader">. A missing tag is added after the title;
      • a tag whose parsed value already matches stays byte for byte, so an author's &#233; survives;
      • a blank preheader removes its tag;
      • Liquid stays literal, and nothing else is reformatted or reordered.
    • Toast: "Copied. Save it as the template's file. PostHog keeps sending the current version until your app sends or uploads the file." When the saved template has a nonempty text part, the toast adds "This file has no plain-text part. The next upload from this file removes the plain-text version." Copy is never blocked.
    • Event: message template html file copied {template_id, dropped_text_part}.
    • Story: Products/Workflows/Template library/Edit with your agent gains Copy menu open.

    Proof

    Red (red.log): the final test table against an encoder that always rewrites the title and meta. 1 failed, 10 passed. The failing case is gives back the file the SDK uploaded byte for byte: an author's own entities, whitespace and attribute order: the output loses Caf&#233; and the author's single-quoted meta. The logic red (red-logic.log): TypeError: logic.actions.copyHtmlFile is not a function.

    Green (green.log): every suite under TemplateLibrary/ plus lib/components/Git on the landed tree. 8 suites, 101 passed, nothing skipped.

    Seam Asserts
    templateHtmlFile: 3 files uploaded by the SDK the encoder returns each file byte for byte. Rows: the research report's synthetic vector v1:b967355e…; an author's entities, whitespace and attribute order; a commented-out title before the real one, with no preheader
    templateHtmlFile: 8 encoded templates exact output bytes; the SDK's recorded parse of those bytes equals the template's subject (whitespace collapsed) and preheader; encoding the output again changes nothing. Rows: missing head, missing title and meta, a bare fragment, escaped subject, escaped preheader, blank preheader, Liquid with quotes and >, a subject that matches after whitespace collapses
    messageTemplateLogic, API mocked at HTTP, with and without a text part the clipboard holds the encoded file; no PATCH or POST; the exact toast; the event with dropped_text_part

    I recorded each row's expected parse and hash once with the SDK's own parseTemplateFile and emailContentHash, at PostHog/posthog-js#5312's head 87e9e95ca65 (script). The server's Python hash agrees on the SDK rows.

    Property check, not committed (script, result): 10,560 combinations of document shapes, subjects and preheaders. The cases include comments, an SVG <title>, entities, quotes, Liquid and blank values. For each, the real SDK parser read the encoded file back as the template's subject and preheader, and a second encode changed nothing. failed 0.

    Local gate on the landed tree:

    • tsgo --noEmit --checkers 1 in frontend/ through fleet heavy: exit 0 (log).
    • pnpm --filter=@posthog/frontend fix through fleet heavy: exit 0, no further changes.
    • kea typegen on messageTemplateLogic.ts matches the committed inline types.
    • The pre-push hook (hogli ci:preflight --strict): "failures": 0 (log).
    • No backend change, so no build:openapi or mypy run.

    Browser round trip (Storybook from this worktree, real App scene, API mocked at HTTP, headless Chromium with clipboard access, log):

    • SDK upload: the template served exactly the fields posthog-node uploads for this file: parseTemplateFile's subject and preheader, with the whole file as HTML. After "Copy HTML file", diff between the clipboard (560px, 1440px) and the file finds no difference.
    • REST upload with a separate subject, preheader and text part: the clipboard (file) gains <head><title>Your weekly summary</title><meta name="preheader" content="Three things changed this week"></head>. The SDK parses it back to that subject and preheader (parse). The toast carries the text-part note.
    • Zero non-GET /api/ requests after any copy click.

    Screenshots (MockHog project, example.com repository, invented copy, no customer data). Narrow is a 560px window, about the scene of a 1280px window with the side panel open, as #503 used. Wide is 1440px.

    Narrow (560px) Wide (1440px)
    Page: split button page page
    Side menu open menu menu
    Toast, SDK-uploaded template toast toast
    Toast with the text-part note, REST template toast toast

    Committed story: Copy menu open.

    Gaps

    Calls made

    • Placement: the side action sits on EditWithAgentButton. It follows the precedent of workflow-publish in WorkflowSceneHeader.tsx (sideAction with a LemonMenuOverlay). MessageTemplateMenu.tsx and templateCopy.ts are unchanged: the overflow menu is not the button's side menu, and templateCopy is the Duplicate payload. The encoder got its own module.
    • The scanner mirrors the SDK's scanHead: comments and titles hide tags, the first title and preheader win, and a malformed tag stops the scan. The frontend can't import posthog-node, so about 40 lines repeat its rules. The vector table pins them to Think through the Elements tab PostHog/posthog#5312's head.
    • Liquid stays literal inside escaped values. Escaping applies only outside {{ … }} and {% … %}. A preheader whose Liquid holds " gets a single-quoted content='…'. Otherwise default: "there" would turn into &quot; and break the Liquid when the HTML renders. When the Liquid holds both quote kinds, the encoder escapes everything, because the attribute can't hold both raw.
    • A changed subject is written whitespace-collapsed, which is what the SDK reads anyway.
    • A changed preheader rewrites its whole tag as <meta name="preheader" content="…">. Other attributes on that one tag go away.
    • "Blank" means empty or whitespace only. A tag that already holds exactly that value stays, so an SDK file with content="" copies back unchanged.
    • The SDK refuses a file without a <title> (template_missing_subject), so the missing-title cases are REST-shaped templates, not SDK files.
    • dropped_text_part is true when the saved text part is nonempty after trimming.
    • No pending state: the copy is a local clipboard write. A failure uses copyToClipboard's toasts, and the event fires only on success.
    • The toast is not a committed story. A clipboard write in the visual test runner isn't deterministic. The screenshots show the toast instead.
    • Boy scout: typegen replaced three any parameter types in Edit with your agent: copy a prompt for a code-managed template's file #503's selector types with their real types.
    • pnpm install --frozen-lockfile was needed in this worktree for Edit with your agent: copy a prompt for a code-managed template's file #503's new devDependencies. The lockfile is unchanged.
    • No review ran in this ticket. Ship stack A: edit a code-managed template with your agent #516 owns review.

    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