ClientsFlow · Email-System Overhaul · W6 · EBO
FINAL — owner comments applied · 2026-07-12. The target state after W6 ships: the canonical sign-off is locked by a machine test that has teeth, every client-facing copy and every AI prompt is editable from the Templates panel (nothing code-only), the payment-details block stays locked inside the two money emails, and no raw {{variable}}, empty greeting, or double closing can ever reach a client. Scenarios S1 … S4.
SIGNATURE_HU block (name / role line / company + address / email): no phone line, no bank line, ever; bank + Stripe details appear only inside the payment-instruction bodies where a client actually has to pay; no client-facing text ever contains a raw {{token}}, an empty greeting ("Kedves ,") or a double closing; W6 sends nothing to a real lead — every live step runs on a ZZ-sentinel staging deal, purged after; all operator UI is English, Hungarian appears only inside client-facing copy; W6's regression test may never be disabled, skipped or weakened to make a run green — a red test is the feature working.DECISIONS_FINAL.md). Nothing in this EBO is open; the run never guesses, never defaults, never flags amber.| # | You do | You should see | Element that changes copy · look · where |
What changes underneath | Must NOT happen | 🕓 Touchpoint history |
|---|---|---|---|---|---|---|
| 1 | Run the email-copy test (python3 -m pytest tests/test_signoff_footer_regression.py -v), then open the dashboard's Health tab |
The test passes green and names every outbound email it checked (signed-contract delivery, payment reminders, booking confirmation, pre-call reminders, proposal, no-show ladder, first reply, negative follow-ups) — and the Health tab shows one plain line: "Sign-off check: 15/15 emails canonical — 2026-07-12", so the lock is visible without a terminal | Copy: Health-tab line "Sign-off check: N/N emails canonical — <date>" · Look: a normal Health-tab status line, green · Where: Health tab (dash) + new file tests/test_signoff_footer_regression.py |
Every client-facing outbound-email-composing function is rendered with sample lead data and asserted to END with SIGNATURE_HU (a leading "Üdv,\n" immediately before it is allowed); the run writes its dated one-line result where the Health tab reads it; rep-only internal notification bodies are explicitly excluded by name, never silently skipped |
Must NOT skip any client-facing send site (a body not in the walk is a hole — exactly how the incident escaped); must NOT pass if even one body ends with anything other than the canonical block; must NOT need network/Notion to run; must NOT show a stale Health-tab stamp from an older run | — (a test run touches no lead) |
| 2 | Perform the documented "teeth check": temporarily swap the sign-off constant to the OLD leaked footer (the one with the phone line + "Bank: 1260…"), re-run the test, revert, and record the check | The suite goes RED and names each affected email function and quotes the offending leaked line — the exact 2026-07-11 incident is now impossible to ship silently. After reverting, the test file's docstring carries a dated stamp: "Teeth check last proven: 2026-07-12 — RED as expected", so "when was the lock last proven?" is answerable at a glance | Copy: failure text = function name + the leaked string found; docstring line "Teeth check last proven: <date> — RED as expected" · Look: terminal, red · Where: tests/test_signoff_footer_regression.py (swap recipe + dated stamp in its docstring, never a permanent fixture) |
Nothing persists in the app — the swap is reverted immediately; the only lasting artifact is the dated teeth-check stamp in the docstring | Must NOT leave the leaked footer in the working tree after the check; must NOT encode the teeth check as an always-on expected-failure that could mask a real regression; must NOT claim the check without updating the dated stamp | — |
| 3 | Later, any workstream edits an email body and (accidentally) drops or mangles its sign-off, then runs the suite | The suite fails loudly, naming exactly which email lost its sign-off and quoting its actual last lines — the fix is obvious from the message alone | Copy: "email_signed_document body does not end with SIGNATURE_HU — got: '…'" · Look: red test, precise message · Where: CI / local suite | Nothing merges through the Ship pipeline with this test red; the failing function is fixed, never the test loosened. If a body one day legitimately needs a line after the signature (a P.S.), that is a single explicit, commented allowlist entry in the test — a visible decision, never a silent loosening of the "ends with" rule | Must NOT be "fixed" by disabling, skipping, weakening or xfail-ing the test (the run report must state "the sign-off regression test was NOT disabled or weakened"); must NOT pass because a body merely CONTAINS the sign-off somewhere — it must END with it | — |
| # | You do | You should see | Element that changes copy · look · where |
What changes underneath | Must NOT happen | 🕓 Touchpoint history |
|---|---|---|---|---|---|---|
| 1 | Mátyás opens the Templates panel on the dashboard | The signed-contract delivery email and each step of the payment-reminder ladder now appear in the template list as editable entries, alongside the existing templates — previously they were simply absent, with no hint they were code-only | Copy: new list entries "Signed contract — delivery email" · "Payment reminder — day 1…4" · Look: same list style as the existing template entries · Where: Templates panel | The two bodies move from hardcoded Python literals into the owner-editable copy layer; their send timing and triggers stay exactly as they are (W1's automatic flow — auto-send on the client's signature, then one reminder a day for 3–4 days until payment or a logged phone call) | Must NOT change what these emails currently say (migration is verbatim — the current live text becomes the initial template text); must NOT alter when/whether they send (W1's, untouched); must NOT list them as editable while edits still silently do nothing (the whole point) | — |
| 2 | Mátyás edits the signed-contract email's opening sentence, saves, then a ZZ-sentinel staging contract-signing fires the email for ZZ-S2-SIGN Kft | The staging email arrives with his edited opening — proof the edit is LIVE, not decorative — and still carries everything the flow promises: the confirmation, the signed-contract download link, the Stripe link and the transfer details, ending with the canonical sign-off | Copy: his edited Hungarian opening, then the unchanged 4 parts · Look: a real received email (staging, ZZ lead) · Where: the ZZ test inbox + the ZZ-S2-SIGN Kft card's history | The send site now reads its body through the owner-editable copy layer (live override → baked default), interpolating the deal's Stripe link/amount into the edited text; S1's regression test still passes against the edited copy | Must NOT send the old hardcoded text after a save succeeded (the silent-no-op bug this exists to kill); must NOT let an edit strip the sign-off or the payment block; must NOT run against a real lead — ZZ sentinel only, purged after | The ZZ-S2-SIGN Kft card's history logs the sent email with the edited text — word-for-word identical to what was received (W2's parity bar, verified here on W6's migrated copy). |
| 3 | In the template editor, Mátyás tries to change, delete or move the payment-details part (including a select-all-delete + save, and an attempt to drag it above his closing paragraph) | The bank-transfer + Stripe details render as a visibly locked block — readable in place so he sees the whole email, marked "Locked — payment details", not editable, not deletable and not movable: it is fixed in place (D1). His edits flow around it; the saved template still renders the block, in the same position | Copy: locked-block label "Locked — payment details" (English UI); the block's content stays the Hungarian bank/Stripe lines · Look: muted background + lock icon, visually set off from the editable text; no drag handle, no move-up/move-down control · Where: template editor for the two money emails | The payment-details block stays sourced from the single code-side constant, at a fixed position in the body; owner edits can only change the text around it, never the block itself and never its place in the email | Must NOT allow the locked block to be edited, emptied or removed through ANY editor path (select-all-delete included — the saved template must still render it); must NOT allow the block to be repositioned/reordered as a unit (D1: fixed in place — no drag handle, no move control, and a save that relocated it is not applied); must NOT hide the block from the editor view (invisible-but-sent text is a parity violation) | — |
| 4 | Mátyás edits the signed-contract (or a payment-reminder) template so that a required placeholder is gone — he deletes the amount placeholder, or the Stripe-link placeholder — and clicks Save | The save is REFUSED — a hard stop, not a warning he can click past. A plain-English message names exactly what is missing: "The payment amount is missing from this template — the save was not applied." The editor keeps his text on screen so he can fix it, and the stored template is still the last valid version. A payment email with no way to pay never becomes a saved template | Copy: refusal message naming the missing placeholder ("The payment amount is missing from this template — the save was not applied." / "The Stripe payment link is missing…") · Look: the Save is blocked, the message sits by the Save button; no "saved" confirmation appears · Where: template editor for the money emails | Nothing is written — the copy layer still holds the previous valid body; the send site therefore still renders a payable email. The refusal is server-side too, not only a UI check | Must NOT allow the save (D2 — this is a hard stop; the old "warn on save but allow it" behavior is DEAD and must not be built); must NOT save the placeholder-less body and merely display a warning; must NOT let a later, unrelated save silently persist the broken body; must NOT silently re-insert the placeholder for him (the refusal must be visible, not auto-corrected); must NOT block a save that only touched text around the required placeholders | — |
| # | You do | You should see | Element that changes copy · look · where |
What changes underneath | Must NOT happen | 🕓 Touchpoint history |
|---|---|---|---|---|---|---|
| 1 | Mátyás opens the Templates panel and scrolls the full list of client-facing copies | EVERY client-facing email and SMS the app can send is there as an editable entry — booking confirmation, pre-call reminders, first reply, sequence steps, no-show ladder, negative follow-ups, proposal, signed contract, payment reminders, welcome kit, review request — with none missing and nothing marked code-only | Copy: one editable entry per send site, named in plain English · Look: the existing Templates list, longer · Where: Templates panel | Every remaining hardcoded body is wired through the owner-editable copy layer (verbatim migration — the current live text becomes the initial template text); the copy layer, not the Python literal, is now what the send site reads | Must NOT change any body's wording during migration; must NOT leave a single client-facing body unlisted; must NOT show an entry whose edits are silently ignored at send time | — |
| 2 | Mátyás opens the Prompts section of the same panel, edits an AI prompt (e.g. the reply-drafting prompt), saves, then triggers that AI feature on the staging card ZZ-S3-COPY Kft | Every AI prompt the app uses is listed and editable in the same panel — and the next generation visibly follows his edited prompt, so a prompt change is a thing he can make himself, not a code change he has to ask for | Copy: prompt entries (reply drafting, sequence generation, enrichment/personalization) with an editor + Save · Look: same panel, Prompts section · Where: Templates panel → Prompts | Prompt text is read at generation time from the editable layer (live override → baked default); no prompt string is reachable only from code | Must NOT keep any prompt code-only; must NOT apply an edited prompt only after a redeploy (the edit must take effect on the next generation); must NOT let a prompt edit bypass the sign-off/greeting rules (S4 still governs the output) | — (a draft generation is not a send) |
| 3 | Run the drift guard: python3 scripts/detect_copy_drift.py — then, as its self-test, have it scan a copy of the tree with one email body deliberately hardcoded |
On the real tree it exits clean with 0 code-only bodies; on the seeded tree it fails and names the offending file:line — proof the guard actually bites, so a future workstream that adds a raw email literal is caught immediately instead of months later when an owner edit does nothing | Copy: "0 code-only bodies — all N copies + M prompts editable" / on the seeded tree: "HARDCODED: flows_x.py:123" · Look: terminal, one summary line · Where: new file scripts/detect_copy_drift.py |
The script derives the fact from the live code on every run (never a saved snapshot): it walks every email/SMS body and prompt string and cross-checks each against the editable copy layer | Must NOT miss inline body literals that never got a named constant (the strongest form of hardcoded); must NOT auto-rewrite code (report + exit code only); must NOT be satisfied by an entry that merely EXISTS in Templates while the send site still reads the literal | — |
{{variable}}, no empty greeting, no double closing ever reaches a client (happy path + guard / cross-surface proof)| # | You do | You should see | Element that changes copy · look · where |
What changes underneath | Must NOT happen | 🕓 Touchpoint history |
|---|---|---|---|---|---|---|
| 1 | Mátyás clicks Send Emails on the staging card ZZ-S4-VAR Kft and reads the generated sequence in the modal | Every email in the modal opens with the lead's real resolved greeting — "Kedves Anna," — correct name, correct Hungarian form; nowhere in any preview does a raw {{greeting}}, {{name}} or any other {{…}} token show (existing flow, verify unchanged) |
Copy: resolved Hungarian greeting per email · Look: the Send-Emails modal previews · Where: lead card → Send Emails modal (W1/W2's surface) | Copy is generated at Send-click from the templates + touchpoint history; variable resolution happens at generation, so what he reads IS what will be sent and frozen | Must NOT show a resolved preview but send an unresolved body (preview↔send parity); must NOT fall back to an empty greeting ("Kedves ,") when a name is missing — a missing input surfaces as a visible problem in the modal, never as broken Hungarian in a sent mail | On Send, the frozen scheduled rows carry the same resolved text the modal showed (W2's parity bar — W6 checks the copy half of it). |
| 2 | On a card with an unreplied lead email (ZZ-S4-VAR Kft), Mátyás clicks the compose-AI-reply button and reads the AI draft (W2's feature) | The draft opens with the proper resolved Hungarian greeting, its body draws on this lead's actual history, and it ends with the exact canonical sign-off — and with ONE closing only: no home-grown "Üdvözlettel, Mátyás" sitting on top of the canonical block, no double goodbye | Copy: "Kedves Anna," + contextual Hungarian body + the single 4-line canonical sign-off · Look: the editable AI-reply field on the card · Where: lead card (W2's surface) | The AI path runs the same greeting-resolution, sign-off and closing rules as template emails — one copy-rule set, not a separate AI-path approximation; a duplicate closing is stripped before the draft is shown | Must NOT show a raw token in an AI draft; must NOT let the model improvise its own sign-off variant (extra titles, different address, added phone) — the canonical block is appended by rule, not by the model's memory; must NOT render two closings | — (a draft is not a send; the send that may follow is W1/W2's, and its history row must match what left) |
| 3 | Run the copy guard across every rendered body: python3 -m pytest tests/test_copy_guard.py -v |
Green — zero {{ occurrences in any RENDERED body (template path and AI path), zero empty-greeting shapes ("Kedves ,", doubled spaces, lowercase name), zero duplicate closings |
Copy: test output, one line per checked body · Look: terminal, green · Where: new file tests/test_copy_guard.py |
The suite renders every body with sample data and inspects the OUTPUT, never the template source (tokens legitimately live in templates pre-resolution; the crime is a token in rendered output) | Must NOT grep template source and call it a pass; must NOT check only the "{{" syntax and miss the Hungarian shape failures (empty greeting, double closing); must NOT send anything as part of the check | — |
Each build work item, and the scenario steps that prove it works when run/clicked through. The three W6 document deliverables (QA-trust gate · Codex 3-step review plan · live-truth prod audit) have no clickable surface — they are specified in the handoff (handoffs/W6_COPY_QA_TRUST.md) with their own definitions of done, and are not walked as EBO steps.
| Work item | What it delivers | Proven by (scenario · step) |
|---|---|---|
| WI-1 · Sign-off regression test + Health stamp | Hermetic test walking every client-facing send site: each rendered body ends with the canonical SIGNATURE_HU block; a dated teeth-check stamp in its docstring proves the test catches the 2026-07-11 incident footer; a one-line dated result on the Health tab makes the lock owner-visible. | S1.1 · S1.2 · S1.3 |
| WI-2 · Money-email Templates migration + locked payment block | Signed-contract delivery + payment-reminder ladder bodies become owner-editable in the Templates panel (verbatim migration, send behavior untouched — W1's); the payment-details block renders as a locked, non-editable, fixed-in-place unit inside the editor (D1); and a save that removed a required placeholder (amount / Stripe link) is REFUSED outright (D2). | S2.1 · S2.2 · S2.3 · S2.4 |
| WI-3 · All copies + prompts editable (nothing code-only) | Every remaining client-facing email/SMS body AND every AI prompt migrates into the Templates panel; scripts/detect_copy_drift.py is the standing guard (0 code-only bodies, self-tested to actually bite). | S3.1 · S3.2 · S3.3 |
| WI-4 · Variable / greeting / closing guard | tests/test_copy_guard.py: no rendered body (template or AI path) contains a raw {{token}}, an empty greeting, or a duplicate closing; verified on the Send-Emails previews and the on-card AI reply. | S4.1 · S4.2 · S4.3 |
DECISIONS_FINAL.md) — nothing in this EBO is open, and no step may be flagged AMBER. The W6 run's own final report must include the explicit statement "the sign-off regression test was NOT disabled or weakened to make this run green." No W6 code is built or deployed before this signature.