Skip to content

fix(workflows): keep the library template link when inserting a template - #106553

Open
Silthus wants to merge 9 commits into
PostHog:masterfrom
Silthus:workflows/keep-email-template-link
Open

Silthus wants to merge 9 commits into
PostHog:masterfrom
Silthus:workflows/keep-email-template-link

Conversation

@Silthus

@Silthus Silthus commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Problem

  • Someone who inserts a Library template into a workflow email step loses the record of which template the email came from.
  • The web editor copies the subject and body, but drops the template id.
  • The API and MCP paths already store that id in the step's config.template_uuid. Only steps built in the web editor lose it.
  • Without the link, the Library cannot tell which workflows use a template.

Changes

  • Inserting a Library template into a workflow email step now saves the template id in config.template_uuid. Nothing looks different in the editor.
  • The link means "based on this template". Later edits keep it, and inserting another template replaces it. There is no detach control.
  • Only function_email steps link. A generic destination step with an email input (for example Mailgun) keeps its template_uuid untouched, because there the field names the destination template version.
  • Backend: the server fills an email step in from its linked template only for programmatic requests.
    • Before, _apply_email_template_content copied the template body into any linked step whose body was empty, whoever saved it.
    • With the editor now writing the link, a user who cleared an email got the template content back on the next auto-save. A publish could also fail with an MCP-worded error once the template was deleted.
    • Enabling a workflow from a canvas and the refresh_hog_flows command had the same problem. They save with no request source, and the canvas enable could put the template content live.
    • Now only requests with a programmatic source (API key, MCP, CLI and other agents) get a body filled in. Web saves and internal re-saves keep the stored body, and the link is provenance only there.
    • MCP and API saves still materialize a body-less reference, and still reject an unresolvable one. Those saves always validate strictly, so the lenient branch in the helper is removed.
  • emailTemplaterLogic gains an optional onTemplateApplied(templateId) prop. The applyTemplate listener calls it after it sets the content.
  • CyclotronJobInputs and HogFlowFunctionConfiguration pass an optional onEmailTemplateApplied down, typed from that one prop. StepFunction wires it to partialSetWorkflowActionConfig. This prop threading is mechanical.
  • Hosts that pass no prop behave as before: the Library template editor, hog function email destinations, and the Broadcasts wizard.
  • No migration.

How did you test this code?

  • StepFunction.test.tsx (new) renders the step against a real workflowLogic with MSW mocks and picks Library templates through the picker:
    • The link reaches the step and the PATCH body, through a later subject edit, for a draft workflow and for an active workflow that stages into its draft. Catches the link getting dropped between the picker and the save.
    • A second insert points the link at the second template. Catches a step that keeps the first link.
    • A generic destination step with an email input gets no link. Catches the function_email guard going missing.
  • emailTemplaterLogic.test.ts: applying a template reports the id after the content, and still applies the content when the host passes no callback. Catches a lost or reordered callback, and a crash in hosts without it.
  • test_hog_flow_action_email.py, backend contract tests for saves of a linked step:
    • A web save of a body plus template_uuid goes through the staged draft and publish, and both reach live.
    • A cleared body saved from the web stays cleared in the draft, and publish reports the missing body even after the template is deleted. Catches the server refilling the email.
    • Enabling that cleared draft through the workflows facade (the canvas path) fails validation and keeps the body empty, and refresh_hog_flows leaves it empty. Catches internal re-saves refilling it.
    • These test the server contract only. The editor wiring is covered by the frontend tests above.
  • The existing MCP and API template-reference tests still pass unchanged, which covers the strict path.
  • A manual run on a local stack compared master (ed038b0fb49) with this PR (36b00ff1792), using invented Library templates and draft workflows. Each result comes from the workflow API after the action:
Action Before (master) After (this PR)
Insert "Spring newsletter" into an email step in the web editor Subject and body saved, no template_uuid Subject and body saved, template_uuid is the Spring template
Edit the subject after the insert Not checked template_uuid kept
Clear the step, then insert "Summer newsletter" Not checked template_uuid is the Summer template
Web save of an empty body, then reload the page Not checked Body stays empty, template_uuid kept
API key save of a step with only template_uuid Not checked Body filled in from the template
API key save with an unknown template_uuid Not checked 400 on actions__1__template_uuid
Insert a Library template into a Mailgun step Not checked Content inserted, no template_uuid
  • The editor looks the same in both runs. Only the saved step differs.
  • The editor keeps its layout data after the text is cleared, so the empty-body row used a web-session save that also cleared the layout data. Later auto-saves from the editor kept the body empty.
  • Master saves no link, so the later rows have no before state to compare.
  • Not run: repo-wide mypy. The backend change is a four-line guard.
  • test-new-events-schema: not needed. The diff touches no event ingestion, event reads, or SQL over events.

👉 Stay up-to-date with PostHog coding conventions for a smoother review.

Release status

  • No feature flag controls this change
  • This change is behind a feature flag and is not available to users
  • This change makes a previously flagged feature available to everyone

Automatic notifications

  • Publish to changelog?

Docs update

None. No doc under docs/ covers the email step's template link.

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Agent: Claude Code, claude-opus-5-5[1m] (an Opus 5.5 sub-agent, run by an orchestration conductor on the same model)

  • Built test-first from a written spec. Each behavior fix started from a failing test, and each new test was checked against a mutation of the code it guards.
  • Skills invoked: /tdd, /writing-tests, /writing-kea-logics, /writing-ui-components, /writing-code-comments, /running-ci-preflight, /writing-pr-descriptions.
  • CodeRabbit CLI skipped: signed out, Michael chose to skip the local pass; the CodeRabbit bot reviews the draft.
  • Five fresh review lanes (three adversarial reviewers on different models, a code-quality lane and a logic and safety lane) reviewed the first commit. Dispositions:
    • Cleared body refilled from the template (major and minor, three lanes): fixed with the web-save guard. Michael chose the backend guard over a frontend detach rule, so this PR now carries one small backend change.
    • Link written on generic destination steps: fixed, only function_email links.
    • "Replace" semantics tested in the wrong unit: moved to StepFunction.test.tsx, duplicate logic test removed.
    • Backend test read as proof of the editor wiring: split into two contract tests, redundant assert dropped, publish steps moved into a helper.
    • Test state at describe scope and a log after teardown: fixed.
    • Callback type repeated per layer: every copy now reads the templater's prop type.
    • Callback order not asserted: asserted.
    • PR body cleanups: applied.
    • A later fix-verification review found that canvas enable and refresh_hog_flows still refilled a cleared body, because they carry no request source. Fixed by filling in bodies only for programmatic sources. The same review's nits (two stale comments, a renamed lenient-save test, a return type, and a React-Modal warning in the step test) are fixed too.
    • Accepted without change: org-scoped workflow templates can carry one team's template_uuid into another team. Every lookup is team-scoped, so nothing leaks. The Broadcasts wizard does not link, which the spec leaves out of scope.
  • Duplicate search: no open PR stores the template link.
  • The before-and-after run: a Claude Fable 5.1 (claude-fable-5-1) sub-agent drove the local stack with Playwright after the rebase onto current master.
  • Public artifact: all test and demo data is invented.

🤖 Generated with Claude Code

@trunk-io

trunk-io Bot commented Sep 25, 2026

Copy link
Copy Markdown

Merging to master in this repository is managed by Trunk.

  • To merge this pull request, check the box to the left or comment /trunk merge below.

After your PR is submitted to the merge queue, this comment will be automatically updated with its status. If the PR fails, failure details will also be posted here

@coderabbitai

coderabbitai Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

Important

Review skipped

We couldn't safely recover the incremental review. No full review was started, and the last reviewed checkpoint was preserved. Retry later, or explicitly request a full review by commenting @coderabbitai full review.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

EmailTemplater now reports the selected template ID through email input components to workflow configuration. For function_email steps, the workflow stores that ID as template_uuid. Backend processing leaves web-sourced email inputs unchanged by template materialization. For other request sources, unresolved template references raise a validation error. Tests cover callback behavior, workflow saves, and publishing or re-saving linked email steps.

Priority: ➖ Normal

Merge Risk: 🔵 Low · up to d2e67

The workflow changes appear mergeable with a bounded test-coverage follow-up: confirm that refresh actually re-saves the flow, rather than only leaving its fields unchanged.

Security Architecture Review

Security architecture risk: 🔵 Low · up to d2e67

The editor now preserves a template link without letting that link replace the email a user wrote. No new path to another project's template content or to sending an unvalidated email was established. The link itself is not verified on web saves, so it should not be treated as proof of template ownership.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — The examined cross-project exposure is limited to an unverified stored provenance ID: programmatic template-content lookup is team-scoped, and the web path does not dereference that ID for email content.

Trust Boundaries and Controls

  • observed — Request classification, rather than a workflow field supplied in the save payload, selects web behavior. The classifier can also identify WEB from a request session key, so provenance should not be treated as an authorization assertion merely because the save followed that path.

Resilience and Maintainability Implications

  • observed — Invalid bodyless programmatic references fail validation, while web and internal re-saves avoid reintroducing template content after an email body is cleared.

Hardening Proposals

  • proposed — If template usage reporting later becomes an authorization, deletion, or audit control, verify template ownership at that control boundary rather than trusting the stored provenance ID.
🚥 Pre-merge checks | ✅ 1
✅ Passed checks (1 passed)
Check name Status Explanation
Description check ✅ Passed The description is complete and follows the required structure. It explains the problem, user-visible and backend changes, testing coverage and limits, release status, documentation status, and agent …
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

Quiet mode is enabled, so only the most important comments were posted inline. Other review comments are grouped below.

🟡 Other comments (1)
products/workflows/frontend/Workflows/hogflows/steps/StepFunction.test.tsx-184-185 (1)

184-185: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Own the single preflight mount in this test.

initKeaTests() mounts preflightLogic by default. Its afterMount handler can dispatch loadPreflightSuccess synchronously when window.POSTHOG_APP_CONTEXT.preflight exists. The later preflightLogic.mount() does not create a new lifecycle for the already-mounted logic, so the action can occur before expectLogic starts observing it. Disable common mounts and keep the explicit mount under test.

Suggested fix
-    initKeaTests()
+    initKeaTests(false)
     preflightLogic.mount()

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository: PostHog/posthog/.coderabbit.yaml

Review profile: QUIET

Plan: Enterprise

Run ID: 51b97c4b-c0d6-447e-ab9b-ca7a922b56a4

📥 Commits

Reviewing files that changed from the base of the PR and between dadd805 and 92e5ed6.

📒 Files selected for processing (7)
  • frontend/src/lib/components/CyclotronJob/CyclotronJobInputs.tsx
  • frontend/src/scenes/hog-functions/email-templater/emailTemplaterLogic.test.ts
  • products/workflows/backend/api/hog_flow.py
  • products/workflows/backend/api/test/test_hog_flow_action_email.py
  • products/workflows/frontend/Workflows/hogflows/steps/StepFunction.test.tsx
  • products/workflows/frontend/Workflows/hogflows/steps/StepFunction.tsx
  • products/workflows/frontend/Workflows/hogflows/steps/components/HogFlowFunctionConfiguration.tsx

Included review availability: Your plan provides up to 12 included reviews per hour; 11 remain after this review.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
products/workflows/backend/api/test/test_hog_flow_action_email.py (2)

563-563: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Specify the body_edit value type.

body_edit: dict leaves its keys and values implicitly typed as Any. Use dict[str, str | None] for the body fields passed by these tests.

As per coding guidelines, “Write as if mypy --strict were on. Annotate every signature, avoid Any.”

Proposed type annotation
-    def _stage_linked_web_edit(self, body_edit: dict) -> tuple[str, MessageTemplate]:
+    def _stage_linked_web_edit(self, body_edit: dict[str, str | None]) -> tuple[str, MessageTemplate]:

Source: Coding guidelines


623-624: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Assert that the refresh command re-saved the flow.

The command catches validation failures and reports them without raising. The test discards this output, so unchanged fields do not prove that the re-save succeeded. Capture the output and assert one update and zero errors.

Suggested assertion
+            output = StringIO()
             with patch("products.workflows.backend.models.hog_flow.hog_flow.reload_hog_flows_on_workers"):
-                call_command("refresh_hog_flows", hog_flow_id=flow_id, stdout=StringIO())
+                call_command("refresh_hog_flows", hog_flow_id=flow_id, stdout=output)
+            assert "Updated: 1" in output.getvalue()
+            assert "Errors: 0" in output.getvalue()

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository: PostHog/posthog/.coderabbit.yaml

Review profile: QUIET

Plan: Enterprise

Run ID: 4b0f8002-0089-4660-a162-347b8d2e4610

📥 Commits

Reviewing files that changed from the base of the PR and between 46f683c and d2e6714.

📒 Files selected for processing (3)
  • products/workflows/backend/api/hog_flow.py
  • products/workflows/backend/api/test/test_hog_flow_action_email.py
  • products/workflows/frontend/Workflows/hogflows/steps/StepFunction.test.tsx

Included review availability: Your plan provides up to 12 included reviews per hour; 10 remain after this review.

Silthus and others added 8 commits September 28, 2026 05:52
Inserting a Library template into a workflow email step copied the
content but dropped which template it came from. The templater now
reports the applied template id through an optional onTemplateApplied
prop, and the workflow email step stores it in config.template_uuid.
Later edits keep the link; inserting another template replaces it.
Hosts that do not pass the prop behave as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Now that the editor stores template_uuid, a web save whose email body
the user cleared was refilled from the Library template, and a publish
could fail on a template deleted since the insert. Web saves already
carry the whole email, so they skip server-side materialization and the
link stays provenance only. MCP and API saves are unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A generic function step whose destination has an email input (Mailgun)
also rendered the templater, so inserting a Library template wrote a
message template id into its template_uuid, a field that means the
destination template version there. Only function_email steps link now.

The step test now also covers a second insert replacing the link, keeps
its saved bodies per case, and settles the onboarding team update so
nothing logs after teardown.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Type each copy of the template-applied callback from the templater's
own prop type, check that the host gets the content before the link,
and drop the logic test that duplicated the step test's replace check.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
initKeaTests already mounts preflightLogic, so the test only waits for
its load before rendering the email editor.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Enabling a workflow from a canvas and the refresh_hog_flows command
build a serializer context with no request source, so the web-save guard
did not fire and a cleared linked email step was refilled from its
Library template, and could go live that way. Template bodies are now
filled in only for programmatic (API key, MCP, CLI) requests. Those
saves always validate strictly, so the lenient branch is gone.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Reopening the picker during its close transition made react-modal warn
about registering the same instance twice.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Upstream replaced HogFlow's conversion.window_minutes with a duration
string window field; adapt this PR's test fixture to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@Silthus
Silthus force-pushed the workflows/keep-email-template-link branch from d2e6714 to 36b00ff Compare September 28, 2026 06:28
@Silthus
Silthus marked this pull request as ready for review September 28, 2026 06:32
@Silthus
Silthus requested a review from a team as a code owner September 28, 2026 06:32
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
template_cache[cache_key] = template
email_content = (template.content or {}).get("email") if template else None
if not isinstance(email_content, dict) or not any(email_content.get(key) for key in _TEMPLATE_EMAIL_BODY_KEYS):
if strict:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

🤖 Claude Opus 5.5 responding on behalf of Michael

Removed in c373eac because strict is always true here now. Only API, MCP and CLI saves get past the new early return, and _should_validate_strictly makes those strict, so an unknown template is always a 400.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant