Skip to content

Email templates: keep unsaved edits when an upload claims a template back #508

Closed

Description

@Silthus

Map: Map: send code-managed email templates with one PostHog call
Spec: Spec: code-managed email templates, sections "Lock" and "Release"
Origin: a follow-up from the ship review of #498 (PostHog#118924). Michael decided on 2026-10-17.

Cast: Opus 5.5, high
Repository: PostHog/posthog

Question

When a released template is claimed back by an upload while someone edits it, does that person keep their edits without breaking the lock?

The gap. A member releases a template ("Stop managing in code") and starts editing. Meanwhile an upload claims the template back. Their save then gets 403 immutable, the page shows a toast, and the page stays editable until they reload.

Delivers end to end. When a save is refused with 403 immutable:

  • the page reloads the template and shows it read-only, with the managed-by-code notice;
  • it offers "Duplicate with my changes", which creates an editable copy of the template with the person's unsaved form values (no key, no ownership, no source, no hash), the same copy payload Email templates: read-only view of code-managed templates #492 uses, plus the edits;
  • if the person dismisses the offer, the edits are dropped.

Test seams. messageTemplateLogic with the API mocked at HTTP:

  • a save answered 403 immutable reloads, turns read-only, and offers the duplicate;
  • the duplicate request carries the unsaved values and no ownership fields.

Write scope. products/workflows/frontend/TemplateLibrary/ (logic, page, the shared copy payload in templateCopy.ts), plus its tests.

Proof. Red and green for both seams. Screenshot of the refusal state in Storybook or the running app.

Acceptance criteria

  • No save path can write to a template that an upload has claimed back.
  • The person's unsaved edits can be kept as a duplicate.

How to build (map #461)

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%3A20856fd9-5abc-465e-a589-ee884946446a%3Adelegate-task%3Awf-email-impl-508-1
    Cast: Opus 5.5, high. Cap 150 minutes. Lands on spec/code-managed-templates (head of PostHog#118924).

  3. Silthus commented on Oct 17, 2026

    @Silthus
    OwnerAuthor

    Status: built and reviewed, not landed. One question for Michael

    Question: The frontend typecheck can't finish on this devbox before the 150-minute cap. Should I land anyway?

    • A (recommended): Free the heavy slot. The slot is taken by an orphaned tsgo --noEmit --checkers 4 from another worktree (upstream-command-k-view-all-filters, pid 2946890, about 22 GB, running for over 2.5 hours). Then rerun the typecheck and land. Killing it is your call, not mine.
    • B: Land now and record the typecheck as a gap. CI typechecks the PR.
    • C: Hold until the slot frees up by itself.

    Why it's blocked: fleet heavy admitted my tsgo --noEmit --checkers 1, but the heavy slice sits at its memory ceiling. In 74 minutes it got 2 minutes of CPU.

    Ready to land

    • Work branch: wip/code-managed-templates-508 at d746c355e26. It is a fast-forward of spec/code-managed-templates (07d7e0ee544), with two commits: a863ed0e911 and the review fix d746c355e26. The local branch is spec/code-managed-templates/508. Git can't push that name, because the remote branch spec/code-managed-templates takes the prefix.

    • What it does: a save answered 403 immutable locks the page at once and reloads it read-only with the managed-by-code notice. A warning banner offers "Duplicate with my changes", which posts templateCopy(<unsaved values>): no key, managed_by, source_* or hash. Closing the banner discards the edits. The page stays read-only even when the reload fails.

    • Proof: red, red for the review fix, green: TemplateLibrary 6 suites, 63 passed. hogli ci:preflight --strict --against origin/spec/code-managed-templates: 0 failures.

    • Screenshot (Storybook, mocked data):

      refused edits

    • Review: GPT-6.1 Sol high, recorded in fleet proof. Final read 2 on a863ed0e911 found 2 medium items: a failed reload left the page editable, and a duplicate navigated after the person left the page. Both are fixed in d746c355e26. Final read 3 on d746c355e26 found none.

    • Prepared, not applied: the feat(messaging): upload, send and lock code-managed email templates PostHog/posthog#118924 body update (the Email templates: keep unsaved edits when an upload claims a template back #508 row, the screenshot, the follow-up marked resolved) and the full resolution comment. They go out once this lands.

    • Follow-up found: the global loader toast in frontend/src/initKea.ts repeats every failed template save next to the page's own message. It predates this ticket and is outside its write scope.

    The worktree stays in place, and #508 stays open.

  4. Silthus commented on Oct 17, 2026

    @Silthus
    OwnerAuthor
    No description provided.
  5. Silthus commented on Oct 17, 2026

    @Silthus
    OwnerAuthor

    Resolution

    Landed: d746c355e26 on spec/code-managed-templates, fast-forward 07d7e0ee544..d746c355e26, two commits (a863ed0e911, then the review fix d746c355e26). This updates PostHog#118924.
    Branch: spec/code-managed-templates/508 (local). The interim fork branch wip/code-managed-templates-508 is removed.

    What landed

    • The refusal locks the page at once. When a save answers 403 immutable, messageTemplateLogic keeps the form values as the refused edits. It marks the loaded copy as managed by code, resets the form to it, and reloads the template. The page shows the read-only preview, the managed-by-code notice, and no Save, even when the reload fails.
    • "Duplicate with my changes". A warning banner (RefusedEditsBanner.tsx, action data-attr="message-template-refused-edits-duplicate") sits above the managed-by-code notice. Its action posts templateCopy(refusedEdits): the shared copy payload from Email templates: read-only view of code-managed templates #492, built from the unsaved name, description and content. It carries no key, managed_by, source_* or hash. On success the page opens the copy.
    • Dismissal drops the edits. Closing the banner clears the refused edits. The form already holds the server copy, so nothing else remains.
    • Copy: "Your changes weren't saved. While you were editing, an upload from code took this template back. To keep your changes, duplicate the template with them. If you close this message, your changes are discarded."
    • Duplicate cleanup: both duplicate actions share createCopyOf. They check the listener breakpoint after the request, so a copy that finishes after the person left the page no longer navigates.
    • The refusal no longer shows the logic's own toast with the server's detail; the banner explains it.

    Proof

    Red (red.log), the two seams before the change: 2 failed, 31 skipped. The setup times out waiting for a reload after saveTemplateFailure, because nothing reloaded the template after the 403.

    Red for the review finding (red-review-fix.log): the failed-reload case failed with isCodeManaged false before d746c355e26.

    Green (green.log): messageTemplateLogic.test.ts 34 passed. Every suite under products/workflows/frontend/TemplateLibrary/: 6 suites, 63 passed.

    Seam Case
    messageTemplateLogic (kea, API mocked at HTTP) a save answered 403 immutable, with the reload answering 200 and 500 → isCodeManaged true, templateChanged false, the form shows the server copy, the refused edits are offered
    messageTemplateLogic "Duplicate with my changes" → the POST body is exactly {name: "<edited name> (copy)", description, content} with the edited subject, and the offer clears

    Local gate on d746c355e26:

    • hogli ci:preflight --strict --against origin/spec/code-managed-templates: 0 failures. Warning only: complexity of MessageTemplate stays 34.
    • tsgo --noEmit --checkers 1 in frontend/ through fleet heavy: exit 0, no errors. The first runs got no memory in the heavy slice and missed the first cap (blocker comment). The slot freed up by itself, and the rerun passed before the landing.
    • fleet proof gate on d746c355e26: exit 0. Review: HEAD reviewed in final read 3. Live run and shadow diff are reused from 07d7e0ee544, because Email templates: keep unsaved edits when an upload claims a template back #508 changes only the template page. Prod shape stays an accepted gap.

    Screenshot: Storybook from this worktree, the real App scene against mocked data (example.com repository, invented copy). A scratch story released the template, edited the name, let the upload claim it back, and saved. The story stays out of the commit. The toasts in the corner are Storybook's mock gaps plus the global loader toast (see Follow-ups).

    Wide (1440px) Narrow (775px)
    wide narrow

    Rendered on a863ed0e911. d746c355e26 changes the failed-reload path and the duplicate navigation, not this render.

    Review

    GPT-6.1 Sol, high, through T3 delegate_task, recorded with fleet proof review-start --scope final and review-finish:

    Read Commit Findings Outcome
    Final read 2 a863ed0e911 2 medium both fixed in d746c355e26
    Final read 3 d746c355e26 none –
    1. Medium, fixed: a failed reload after the refusal left originalTemplate as gui, so the page stayed editable and closing the banner kept the edits in the form. Now the logic locks the page before the reload. A test covers the failed reload.
    2. Medium, fixed: a duplicate navigated to the copy after the person left the page. Both duplicate listeners now check their breakpoint before they navigate. No test: it needs a held request and an unmount, and the breakpoint is kea's own guard.

    Calls made

    • Lock before the reload (🤖), not only after it. The server already said the template is code-managed, so the page trusts it at once. A failed reload keeps the last known content read-only, and kea-loaders' global toast reports the failed load.
    • Two banners: the warning about the refused edits sits above the standing managed-by-code notice, as the ticket asks. Their actions read differently ("Duplicate with my changes" and "Duplicate").
    • No toast for the refusal: the banner says what happened and what to do, so the logic's detail toast went.
    • The edits live in the logic (refusedEdits), not in the form, so the form guard from Email templates: read-only view of code-managed templates #492 keeps the read-only page unchanged.
    • No committed story for RefusedEditsBanner: it reads the logic, and the state needs a scripted save. The screenshot story was scratch.
    • Navigating away with refused edits gives no leave warning. The banner is on screen, and the ticket names dismissal as the way to drop them.

    PR

    PostHog#118924 (still a draft) now has the #508 row in its ticket map, the screenshot, the test row, the two final reads, and Closes Silthus/posthog#508. The release-then-reupload race moved from Follow-ups and Open to Resolved.

    Follow-ups

    • Global loader toast: frontend/src/initKea.ts toasts "Save template failed: …" for every saveTemplate failure, next to the page's own handling (404, 500, and now the refusal). It predates this ticket and is outside its write scope. The fix is one ERROR_FILTER_ALLOW_LIST_BY_LOGIC_PATH entry: saveTemplate: 'products.workflows.frontend.messageTemplateLogic'.
    • Release after a refusal: if the person then stops managing the template in code, the banner stays and still duplicates the refused edits. It does not put them back into the form.
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