docs(35): create phase plan (4 plans, 4 waves)

This commit is contained in:
Jakub Zych
2026-11-06 19:43:03 +01:00
parent a0242808a0
commit 6f9b77033f
6 changed files with 1780 additions and 2 deletions

View File

@@ -1870,7 +1870,17 @@ Plans:
**Requirements**: TBD
**Depends on:** Phase 34
**Context:** postcard already loads plugin templates and layouts from `views/mail/` (`pact.HasMailTemplates`, `postcard.BootPlugin`); this phase adds the editable database layer, admin screens and image handling on top. To confirm in discussion: partials (WinterCMS mail partials), preview and test send, per-locale variants, and whether the screens live in cabana or in a plugin.
**Plans:** 0 plans
**Plans:** 4 plans
Plans:
- [ ] TBD (run /msd-plan-phase 35 to break down)
**Wave 1**
- [ ] 35-01-PLAN.md — pact `HasMailPartials`, postcard-owned mail tables and migrations, per-send override lookup with file fallback, partials, sample vars, Branding CSS, index sync, Editor API, docs
**Wave 2** *(blocked on Wave 1 completion)*
- [ ] 35-02-PLAN.md — cabana Settings → Mail screens, two permission codes, reset/delete rules, drift notice and diff, server field types, preview and own-address test send, OpenAPI, en/pl copy
**Wave 3** *(blocked on Wave 2 completion)*
- [ ] 35-03-PLAN.md — admin SPA: CodeMirror decision note and checkpoint, codeeditor/colour/image fields, image picker, mail preview and test send, rebuilt dist
**Wave 4** *(blocked on Wave 3 completion)*
- [ ] 35-04-PLAN.md — unit tests for the D-21 list and `scripts/check-phase35.sh` (incl. user and paymentgateway mail tests)

View File

@@ -0,0 +1,516 @@
---
phase: 35-mail-templates-and-layouts-editor
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- modules/pact/capabilities.go
- modules/pact/README.md
- modules/postcard/store.go
- modules/postcard/mailer.go
- modules/postcard/templates.go
- modules/postcard/partials.go
- modules/postcard/samples.go
- modules/postcard/editor.go
- modules/postcard/brand.go
- modules/postcard/assets/system.htm
- modules/postcard/assets/minimal.htm
- modules/postcard/assets/sample.htm
- modules/postcard/assets/brand.css.tmpl
- modules/postcard/assets/partials/button.htm
- modules/postcard/assets/partials/panel.htm
- modules/postcard/assets/partials/table.htm
- modules/postcard/assets/partials/subcopy.htm
- modules/postcard/override_smoke_test.go
- modules/postcard/partials_smoke_test.go
- modules/postcard/editor_smoke_test.go
- modules/postcard/brand_smoke_test.go
- modules/postcard/README.md
- modules/lagoon/migrations.go
- modules/lagoon/pending_test.go
- modules/lagoon/README.md
- modules/lagoon/attach/absolute.go
- docs/services/mail.md
- docs/services/storage.md
- docs/plugins/registration.md
- docs/database/migrations.md
autonomous: false
requirements: [D-01, D-04, D-05, D-06, D-07, D-08, D-10, D-11, D-12, D-13, D-14, D-15, D-16, D-17, D-19, D-22, D-23, D-24, D-25]
estimate:
tokens: 170000
raw_tokens: 170000
tasks: 5
confidence: low
must_haves:
truths:
- "A customized row in system_mail_templates changes the subject, body and layout of the mail postcard sends in any process that published a *gorm.DB (serve, queue:work, console), with no cabana activation; postcard reads it once per send through NewDBSource, never from a boot-time snapshot (D-01 as amended by D-22, Claude's Discretion: per-send lookup)"
- "Without a customized row the sent mail is byte-identical to the file version, and an app with no published database or with the mail tables missing still sends the file version and logs a rate-limited warning (D-14, research Pitfall 7)"
- "An override that fails to render or fails the safety checks at send time is logged with component postcard and the file version is sent instead; an override whose layout code resolves to nothing renders with postcard's default layout and a warning (D-14)"
- "A caller's non-empty Message.Subject still wins over an override subject, and Mailer, Message, Catalog.Register, BootPlugin and pact.HasMailTemplates keep their exact signatures (D-04, D-12)"
- "Plugins declare partials through the additive pact.HasMailPartials capability; names must be <plugin>::mail.partials.<name> and map to views/mail/partials/<name>.htm; a foreign namespace, a missing file or a duplicate fails BootPlugin with the dotted name; postcard ships the built-in partials button, panel, table and subcopy and the file layouts default (unchanged), system and minimal (D-08, D-12)"
- "`{{ partial \"button\" \"url\" .Link \"body\" \"Go\" }}` in a Markdown body survives goldmark through per-render nonce tokens swapped after conversion, renders the partial's HTML section in the HTML part and its text section in the text part, and works the same in layouts (D-12)"
- "Template headers may declare `sample.<path> = value` keys (case kept after the prefix); files without them parse exactly as before, and previews and save validation fill every other variable with a visible `[path]` placeholder, including nested fields, if/with blocks and range bodies (D-15, research Pitfall 3)"
- "postcard's exported Editor validates a template, layout or partial draft (parse, test render with samples, the existing script/iframe/object/embed, event-handler and javascript:/vbscript:/data: checks, subject CR/LF, unknown layout or partial, a layout without {{ .Content }}) and reports the failing field and rule; Preview and SendDraft render a draft through the same path as Send (D-13, D-16, D-17)"
- "SyncIndex upserts one index row per file template, layout and partial (code unique), mirrors file content into rows that are not customized, always refreshes file_hash, source and description, never deletes rows and never touches a customized row's override columns; ResetOverride clears the override columns of one row and re-mirrors the file (D-06 as amended by D-23, D-05 locale column)"
- "Every file entry carries a sha256 hash of its CRLF-normalized bytes, so a saved override's base_hash can be compared with the current file_hash (D-07)"
- "With no row in system_mail_brand_settings the layout's brandCss slot holds config mail.brandCss; once the row exists, CompileBrandCSS of its colours replaces it, mail.css is still applied, and layouts get `{{ .logoUrl }}` as an absolute URL built at send time from storage.uploads.public_url or app.url (D-10, D-11, D-19, D-24)"
- "postcard registers no built-in template (D-25)"
prohibitions:
- statement: "pact.HasMailTemplates, postcard.Mailer, postcard.Message, postcard.Catalog.Register and postcard.BootPlugin keep their exact signatures; changes are additive only."
status: unresolved
verification: judgment
- statement: "Existing plugin mail renders byte-identically when no override exists (the default layout file and its output are unchanged)."
status: unresolved
verification: judgment
- statement: "goldmark is not switched to unsafe HTML rendering for mail bodies."
status: unresolved
verification: judgment
- statement: "postcard does not import cabana or lagoon, and no override is cached across sends."
status: unresolved
verification: judgment
- statement: "Framework code, READMEs and docs name no consuming application (neutral names such as acme or blog)."
status: unresolved
verification: judgment
artifacts:
- path: "modules/postcard/store.go"
provides: "Migrations, table constants, OverrideSource, NewDBSource, SyncIndex, ResetOverride"
contains: "func NewDBSource("
- path: "modules/postcard/partials.go"
provides: "partial template func, token swap"
contains: "MAILPARTIAL"
- path: "modules/postcard/samples.go"
provides: "sample.* keys and the placeholder walker"
contains: "text/template/parse"
- path: "modules/postcard/editor.go"
provides: "Editor, EditorFrom, Draft, DraftError, Preview, SendDraft"
contains: "func EditorFrom("
- path: "modules/postcard/brand.go"
provides: "Brand, DefaultBrand, CompileBrandCSS"
contains: "func CompileBrandCSS("
- path: "modules/lagoon/attach/absolute.go"
provides: "config-based absolute public URL"
contains: "func AbsolutePublicURL("
- path: "modules/pact/capabilities.go"
provides: "HasMailPartials, RowStateCustomized, RowStateOutdated"
contains: "type HasMailPartials interface"
- path: "modules/postcard/override_smoke_test.go"
provides: "TestMailOverrideSmoke (tracer)"
contains: "func TestMailOverrideSmoke("
key_links:
- from: "modules/postcard/mailer.go"
to: "modules/postcard/store.go"
via: "Activate installs NewDBSource(app); Send calls source.MailOverrides once per message"
pattern: "NewDBSource\\(app\\)"
- from: "modules/lagoon/migrations.go"
to: "modules/postcard/store.go"
via: "framework migration set summercms.postcard"
pattern: "postcard\\.Migrations"
- from: "modules/postcard/brand.go"
to: "modules/lagoon/attach/absolute.go"
via: "logo URL resolved at send time from config"
pattern: "attach\\.AbsolutePublicURL"
- from: "modules/postcard/mailer.go"
to: "modules/pact/capabilities.go"
via: "BootPlugin type-asserts pact.HasMailPartials next to pact.HasMailTemplates"
pattern: "pact\\.HasMailPartials"
---
## Phase Goal
ROADMAP Goal (not in user-story format; no story is invented): administrators edit every mail the application sends from the admin panel, as in WinterCMS's Mail templates screen. postcard renders the override when one exists, otherwise the plugin's file, so mail that already works keeps working. This plan builds the mail-domain half that every other plan consumes: postcard's database layer, partials, sample values, the draft editor API and Branding, with no admin code (D-01, D-22).
Spec-less probe fallback skipped: Phase 35 has no requirement IDs and no SPEC.md, so this plan set derives must_haves from the ROADMAP goal and CONTEXT decisions D-01..D-25 (a visible choice, not an omission). Prohibitions recalled from CONTEXT and CLAUDE.md are authored descriptor-less above.
<objective>
Give postcard an editable database layer that works in every process that sends mail:
- postcard owns the mail tables and their migrations (a framework set in lagoon.migrationSets) and a per-send override lookup with fallback to the file version (D-22, D-14);
- an additive pact.HasMailPartials capability, built-in partials and layouts, and partial calls that survive Markdown (D-12, D-08);
- `sample.<path>` header keys plus a placeholder walker, file hashes for drift, index-row sync and reset (D-15, D-07, D-23);
- an exported Editor that validates, previews and test-sends drafts through the real render path (D-13, D-16, D-17);
- Branding: compiled brand CSS replacing mail.brandCss once saved, and a logo URL built at send time from config (D-10, D-11, D-24).
Purpose: 35-02 (cabana screens) and 35-03 (admin SPA) are thin over this API; putting the read path in postcard keeps overrides effective in queue:work, where cabana never activates (research Pattern 1).
Output: commits in this repository only, code and its docs together per commit group below. Nothing is pushed or tagged.
**Planner decisions recorded here (confirmed or adjusted at Task 1):**
- **P35-01 schema** (Claude's Discretion; D-07 costly). One gormigrate migration `202611060001_create_system_mail_tables` in `postcard.Migrations`, raw SQL like attach.Migrations, history ID `summercms.postcard`, placed after `summercms.attach` in migrationSets:
- `system_mail_layouts`: id SERIAL PK; code TEXT NOT NULL UNIQUE (file layout: its full name containing `::`; database-only layout: `^[a-z0-9-]+$`); source TEXT NULL (owning plugin ID; NULL marks a database-only layout); name TEXT NOT NULL; content_html, content_text, content_css TEXT NOT NULL DEFAULT ''; is_custom BOOLEAN NOT NULL DEFAULT FALSE (file row: override active; database-only row: always TRUE); file_hash TEXT NULL; base_hash TEXT NULL; customized_at TIMESTAMPTZ NULL; created_at, updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW().
- `system_mail_templates`: id; code UNIQUE; source TEXT NOT NULL; locale TEXT NULL; description TEXT NOT NULL DEFAULT ''; subject, body TEXT NOT NULL DEFAULT ''; layout_id INTEGER NULL REFERENCES system_mail_layouts(id) ON DELETE RESTRICT (index); is_custom, file_hash, base_hash, customized_at, timestamps as above.
- `system_mail_partials`: id; code UNIQUE; source TEXT NOT NULL; name TEXT NOT NULL DEFAULT ''; content_html, content_text; is_custom, file_hash, base_hash, customized_at, timestamps.
- `system_mail_brand_settings`: id INTEGER PRIMARY KEY (the settings singleton, row 1); logo_file_id INTEGER NULL; the 13 colour columns of the UI-SPEC Branding table (body_bg, content_bg, header_color, footer_color, heading_color, text_color, link_color, button_text_color, button_primary_bg, button_positive_bg, button_negative_bg, panel_bg, table_border_color) TEXT NOT NULL DEFAULT ''; timestamps.
- `system_mail_image_library`: id SERIAL PK; timestamps (the attachMany host of the image library, never the Branding row, research anti-pattern).
- "Outdated" means `is_custom AND source IS NOT NULL AND base_hash IS DISTINCT FROM file_hash`. Hash: sha256 hex of the raw file bytes with CRLF normalized to LF.
- **P35-02 partial call syntax** (D-12 costly; the syntax becomes text in plugin files and stored overrides): `{{ partial "button" "url" .Link "body" "Activate" "type" "positive" }}`, a template func `partial(name string, kv ...any)` taking key/value pairs. A bare name resolves to `postcard::mail.partials.<name>`; plugin partials use the full name. Unknown name or an odd kv count is a render error (rule `partial`). Inside a partial, `markdown` renders a string through goldmark with the table extension; `partial` is not available inside a partial. Built-in partials: button (url, body, type primary|positive|negative), panel (body), table (body, a Markdown table), subcopy (body). Built-in layouts: default (byte-identical), system (Winter's "System layout" with the automatic-message footer), minimal; plugin templates reach `system` and `minimal` through a fallback after the plugin's own aliases.
- **P35-03 sample keys** (D-15): dotted INI keys in the existing header, `sample.Name = "Ada"`, `sample.order.code = "ZAM-1"`; the part after `sample.` keeps its case; values are strings. A `[sample]` section is not used because released postcard versions reject section lines.
- **P35-04 layout data**: layout CSS (content_css of an override) is prepended to config mail.css in the existing `css` slot, so a layout that renders `{{ .css }}` picks it up and file layouts (no CSS section) are unchanged; layouts get a new `logoUrl` string key next to Content, Subject, css and brandCss.
- **P35-05 names used by 35-02 and 35-03**: permission codes `system.manage_mail_templates` and `system.manage_mail_layouts` (D-03); controller IDs `summercms.cabana.mailtemplates`, `summercms.cabana.maillayouts`, `summercms.cabana.mailpartials`, `summercms.cabana.mailimages`; row states `customized` and `outdated`; admin routes `POST /mail/preview`, `POST /mail/test-send`, `GET /mail/images`, `GET /mail/images/{id}`, `POST /mail/images`; test send limited to 5 per admin per 10 minutes (research A7); the image picker lists for holders of either mail permission and uploads only for `system.manage_mail_layouts` (D-03 planner's call, UI-SPEC C4).
- **Reset semantics** (D-23 amends D-06): reset clears the override columns of the index row and re-mirrors the file; custom layouts are deleted, never reset.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/phases/35-mail-templates-and-layouts-editor/35-CONTEXT.md
@.planning/phases/35-mail-templates-and-layouts-editor/35-RESEARCH.md
@.planning/phases/35-mail-templates-and-layouts-editor/35-PATTERNS.md
@.planning/phases/35-mail-templates-and-layouts-editor/35-UI-SPEC.md
@CLAUDE.md
<interfaces>
modules/postcard/mailer.go: Message{Template, To, Cc, Bcc, ReplyTo, Vars, Subject}; Mailer interface{ Send(ctx, Message) error } (implemented by outside code: keep it); Options{From, CSS, BrandCSS}; unexported mailer{catalog, driver, opts}; NewMailer(cat, driver, opts) Mailer; Activate[P](app, []P) publishes Mailer; driverFromApp reads mail.driver, mail.from, mail.css, mail.brandCss; BootPlugin[P](app, p) type-asserts pact.HasMailTemplates and the published *mailer, then Catalog.Register; moduleLog = diary.For("postcard").
modules/postcard/templates.go: NewCatalog registers postcard::mail.layouts.default from assets/default.htm; Register(pluginID, fsys, templates, layouts) with assertOwner, assetPath (name -> views/mail/<dots as slashes>.htm), duplicate/missing errors; resolveLayoutLocked (plugin aliases only; "default" reserved); parseTemplateFile (subject, description, layout), parseLayout (header name, text, html); splitEQ normalizes CRLF; parseINI lower-cases keys; render(msg, renderOptions{css, brandCSS}) = execHTML subject (CR/LF refused) -> execHTML body -> goldmark -> validateHTML -> layout text (Content = pre-Markdown body) and HTML (Content, Subject, css, brandCss) -> validateHTML; execHTML/execText use missingkey=zero.
modules/postcard/drivers.go: MemoryDriver{Messages()}, validateRendered(msg, requireFrom).
modules/pact/capabilities.go: HasMailTemplates (~1271, unchanged); RowState consts (~1055); ListRowStates doc says values outside the three constants are dropped.
modules/lagoon/migrations.go: migrationSets (~224) lists {"summercms.attach", "system_files", attach.Migrations} first; modules/lagoon/pending_test.go:349 TestMigrateStatusJSON wantSets.
modules/lagoon/attach: PublicURL(key), PublicURLBase(), PublicPathPrefix() (process globals set by OpenBucket, absent in queue:work), BlobKey(diskName), parsePublicURL(raw), defaultPublicPathPrefix "/storage/uploads", File{ID, DiskName, IsPublic, AttachmentType, Field}.
modules/backpack: app.Publish[T](v), app.Lookup[T]() (T, bool); publishing the same T twice is an error.
Import graph: postcard imports backpack, compass, diary, pact; lagoon will import postcard (party already does); postcard must not import lagoon. Internal postcard tests therefore open Postgres with gorm.io/driver/postgres directly (pgx stdlib, as modules/cabana/auth_test.go adminGorm does) and run postcard.Migrations with gormigrate; they never import lagoon.
</interfaces>
</context>
## Execution units and resume points
- Each task ends with the commit(s) listed below, staged by explicit path (never `-A` or `.`). Other sessions may hold uncommitted `.planning/STATE.md`, `.planning/state.json`, `.planning/G15OFFICE.md`, `examples/hello/plugins.gen.go` or cabana edits; leave them alone. Before editing `modules/postcard`, `modules/pact` or `modules/lagoon`, run `git status --short modules/postcard modules/pact modules/lagoon docs` and stop if another session has uncommitted edits there.
- Resume check: `git log --format=%s -F --grep='<subject>'`; present subjects mean the task is done: re-run its `<verify>` and continue with the next one.
- Docker-backed smoke subtests skip under `-short`; run them without `-short` (Docker is available on this host; if the socket is denied, run through `sudo -u golem` per CLAUDE.md).
| Task | Commit subject | Resume point |
|------|----------------|--------------|
| 1 | none (answer recorded in the SUMMARY) | answer recorded |
| 2 | `feat(postcard): database overrides for mail templates with file fallback` | TestMailOverrideSmoke passes |
| 3 | `feat(postcard): mail partials, built-in layouts and layout and partial overrides` | TestMailPartialsSmoke passes |
| 4 | `feat(postcard): sample values, file hashes, index sync and the draft editor` | TestMailEditorSmoke passes |
| 5 | `feat(attach): absolute public URL from config`, then `feat(postcard): mail branding and the logo URL` | TestMailBrandSmoke passes |
<tasks>
<task type="checkpoint:decision" gate="blocking">
<name>Task 1: Confirm the mail schema, the partial call syntax, the sample keys and the names before any code depends on them</name>
<reversibility rating="one-way">After a release, installs hold rows in system_mail_* and admins and plugins write `{{ partial ... }}` calls and `sample.*` keys into stored overrides and plugin files; renaming a table, column or the call syntax then needs a data migration that rewrites stored bodies.</reversibility>
<decision>Adopt P35-01 (schema), P35-02 (partial syntax and built-ins), P35-03 (sample keys), P35-04 (layout CSS slot and logoUrl) and P35-05 (names, limits, picker upload rule) as written in the objective, or adjust them.</decision>
<context>
CONTEXT leaves table names and schema, the partial call syntax, the built-in set, the D-15 mechanism, the extra predefined layouts and the picker upload permission to the planner. D-22..D-25 (2026-11-06) fixed the ownership (postcard), the index-row model, the logo URL timing and the empty template list, but not these names. 35-02 and 35-03 encode every name fixed here (controller IDs, permission codes, routes, row states, column names in fields.yaml), so this is the only point before all of them. D-03, D-07, D-09 and D-12 are already rated costly by the user and are carried through, not re-asked.
</context>
<options>
<option id="as-planned">
<name>Adopt P35-01..P35-05 as written</name>
<pros>Matches the research recommendation and Winter's table names; the partial syntax is a plain Go template call; sample keys stay readable by released postcard versions</pros>
<cons>Freezes the column and call names now</cons>
</option>
<option id="adjust">
<name>Adopt with the changes Jakub states (table or column names, call syntax, built-in set, limits)</name>
<pros>Names fixed before they become stored data</pros>
<cons>The executor records the adjusted names in the SUMMARY; plans 35-02..35-04 use the SUMMARY's names wherever they differ from their text</cons>
</option>
<option id="sample-capability">
<name>Adopt, but declare sample values through an additive pact capability instead of `sample.*` header keys</name>
<pros>Typed sample values (numbers, lists) instead of strings</pros>
<cons>A second new pact capability to support forever; samples live in Go instead of next to the template text</cons>
</option>
</options>
<resume-signal>Reply "as-planned", "adjust: <changes>" or "sample-capability"</resume-signal>
</task>
<task type="tracer">
<name>Task 2: End-to-end "a customized template row changes the mail a process without cabana sends" — templates only, with the file fallback</name>
<reversibility rating="costly">D-07: the hash columns are part of the override table schema; a later change is a new migration.</reversibility>
<files>modules/postcard/store.go, modules/postcard/mailer.go, modules/postcard/templates.go, modules/postcard/override_smoke_test.go, modules/lagoon/migrations.go, modules/lagoon/pending_test.go, modules/lagoon/README.md, modules/postcard/README.md, docs/services/mail.md, docs/database/migrations.md</files>
<read_first>
- modules/postcard/mailer.go and modules/postcard/templates.go (whole files: Send, Activate, BootPlugin, render)
- modules/postcard/mailer_test.go and modules/postcard/templates_test.go (what byte-identity the existing tests pin)
- modules/lagoon/attach/migrations.go (raw-SQL gormigrate migration to copy)
- modules/lagoon/migrations.go (~200-245 migrationSets) and modules/lagoon/pending_test.go (~330-355)
- modules/cabana/auth_test.go (~321-370 adminGorm: testcontainers postgres:16-alpine, pgx, skip under testing.Short)
- modules/postcard/README.md and docs/services/mail.md (sections to extend)
- .planning/phases/35-mail-templates-and-layouts-editor/35-RESEARCH.md (Pattern 1, Pattern 3, Pitfall 1, Pitfall 7)
</read_first>
<action>
Per D-22, D-14 and D-01 (postcard defines the interface and ships the database-backed implementation), using the Task 1 answer for every name:
- `store.go`: exported constants `TemplatesTable`, `LayoutsTable`, `PartialsTable`, `BrandTable`, `ImageLibraryTable` with the P35-01 names; `Migrations []*gormigrate.Migration` with the single P35-01 migration (create all five tables now, so later tasks add no migration; Rollback drops them in reverse dependency order); `OverrideSource` interface with `MailOverrides(ctx context.Context, template string) (Overrides, error)` where an empty template asks for everything except a template override (the editor uses that in Task 4); `Overrides{Template *TemplateOverride; Layouts map[string]LayoutOverride; Partials map[string]PartialOverride}` (Task 5 adds the `Brand *Brand` field; Task 3 fills Layouts and Partials); `TemplateOverride{Subject, Body, Layout string}` where Layout is a layout code; `LayoutOverride{Name, HTML, Text, CSS string}`; `PartialOverride{HTML, Text string}`; `NewDBSource(app *backpack.App) OverrideSource` that looks up `*gorm.DB` through `app.Lookup` on every call (none published: zero Overrides, nil error) and in this task reads only the template row `WHERE code = ? AND is_custom` plus the code of its layout_id row, inside one read-only query or transaction.
- `mailer.go`: add `Overrides OverrideSource` to Options (additive field) and a `source` field to mailer; NewMailer keeps its signature and takes the source from opts; Activate installs `NewDBSource(app)`. In Send, after the existing checks, ask the source once; a lookup error is logged through moduleLog at warning level at most once per minute per distinct error text and the file version is rendered. Render with the override applied; when that render or validation fails and an override was applied, log at error level with keys template and error and the message "postcard: override failed, sending the file version", then render the file version (D-14). A template override whose layout code resolves to no catalog layout renders with postcard's default layout and logs a warning.
- `templates.go`: refactor render so the file version and an override go through one path (subject source, body, effective layout parts) and the file-only output stays byte-identical; the caller's non-empty Message.Subject still wins over both.
- `modules/lagoon/migrations.go`: add `{"summercms.postcard", "mail", postcard.Migrations}` directly after the attach set; update the set list in `modules/lagoon/pending_test.go` TestMigrateStatusJSON to match.
- `override_smoke_test.go` (package postcard, so it may type-assert the published *mailer and read its MemoryDriver): `TestMailOverrideSmoke`, skipped under testing.Short, starts postgres:16-alpine with testcontainers (pattern of cabana adminGorm), opens GORM with gorm.io/driver/postgres over the pgx stdlib connection, runs Migrations with gormigrate, publishes the *gorm.DB on a backpack.App, calls Activate and BootPlugin for a fixture plugin (fstest.MapFS with `acme.blog::mail.welcome` and a layout alias), and has subtests `override` (an is_custom row's subject and body are sent), `file` (no row: output equals a NewMailer without a source), `fallback` (an override body that fails at runtime, such as a call on a missing nested key, sends the file version and logs the error message), `caller_subject`, `missing_table` (table dropped: file version, warning logged) and `no_db` (app without a published DB: file version).
- Docs in the same commit: postcard README (Overview sentence, Features bullet "database overrides", Usage "Overrides from the admin" with the precedence caller Subject > override > file and the fallback rule, API reference rows for every new identifier, Dependencies: gorm and gormigrate), docs/services/mail.md new section "Overrides from the admin", lagoon README framework-set sentence naming `postcard.Migrations` under the `summercms.postcard` history, and docs/database/migrations.md where it lists the framework sets. Use neutral names (acme) only.
Commit as listed in Execution units.
</action>
<verify>
<automated>go test ./modules/postcard -run 'TestMailOverrideSmoke' -count=1 -v 2>&1 | grep -E '^\s*(--- |FAIL|ok|PASS)' | tail -20</automated>
<fails_when>a "FAIL" line, a "--- SKIP" line, "no tests to run", or no "--- PASS: TestMailOverrideSmoke/fallback" line</fails_when>
<automated>go test ./modules/lagoon -run 'TestMigrateStatusJSON' -count=1 2>&1 | tail -3</automated>
<fails_when>non-zero exit or a "FAIL" line</fails_when>
<automated>go vet ./... && go test -short ./...</automated>
<fails_when>non-zero exit or a "FAIL" line</fails_when>
<automated>go test ./cmd/summer -run 'TestCommandsDocumented|TestDocsTree' -count=1 2>&1 | tail -3 && go run ./cmd/summer docs:build --check 2>&1 | tail -5</automated>
<fails_when>non-zero exit, or a "FAIL" or "unknown identifier" line in the output</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "func NewDBSource(app \*backpack.App) OverrideSource" modules/postcard/store.go` prints one line
- `grep -n "MailOverrides(ctx context.Context, template string) (Overrides, error)" modules/postcard/store.go` prints one line
- `grep -n '"summercms.postcard", "mail", postcard.Migrations' modules/lagoon/migrations.go` prints one line
- `go doc ./modules/postcard Options` lists From, CSS, BrandCSS and Overrides
- `go doc ./modules/postcard Mailer` still shows exactly `Send(ctx context.Context, msg Message) error`
- `go list -deps ./modules/postcard | grep -E 'summercms/modules/(lagoon|cabana)$'` prints nothing
- `grep -n "Overrides from the admin" docs/services/mail.md modules/postcard/README.md` prints at least two lines
</acceptance_criteria>
<done>A customized template row reaches real sends in every process with a database, and anything wrong with it degrades to the file version.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Partials and built-in layouts: plugins declare partials, bodies and layouts call them, layout and partial overrides apply at send</name>
<reversibility rating="costly">D-12: HasMailPartials, the <plugin>::mail.partials.<name> naming and the partial call syntax are a published contract plugins implement.</reversibility>
<files>modules/pact/capabilities.go, modules/pact/README.md, modules/postcard/partials.go, modules/postcard/templates.go, modules/postcard/mailer.go, modules/postcard/store.go, modules/postcard/assets/system.htm, modules/postcard/assets/minimal.htm, modules/postcard/assets/partials/button.htm, modules/postcard/assets/partials/panel.htm, modules/postcard/assets/partials/table.htm, modules/postcard/assets/partials/subcopy.htm, modules/postcard/partials_smoke_test.go, modules/postcard/README.md, docs/services/mail.md, docs/plugins/registration.md</files>
<read_first>
- modules/postcard/templates.go and modules/postcard/store.go (Task 2 output)
- modules/pact/capabilities.go (~1260-1280 HasMailTemplates; keep it byte-identical)
- modules/postcard/assets/default.htm (layout file format and the `<style>` slot)
- ../examples/golem15-wintercms-starter/modules/system/views/mail/layout-system.php and partial-button.php, partial-panel.php, partial-table.php, partial-subcopy.php (content to port to Go templates) — read only
- docs/plugins/registration.md (capability table)
- .planning/phases/35-mail-templates-and-layouts-editor/35-RESEARCH.md (Pattern 4, Pitfall 2)
</read_first>
<behavior>
- A plugin implementing pact.HasMailPartials with `acme.blog::mail.partials.note` boots; `acme.other::mail.partials.note` from acme.blog fails with "not owned"; a name without the `mail.partials.` segment, a missing file and a duplicate fail with the dotted name
- A plugin that implements only HasMailPartials (no templates) boots and its partial is callable from another plugin's template
- Body `Hi\n\n{{ partial "button" "url" .Link "body" "Go" }}\n\nBye` renders the button table with href = .Link in the HTML part (no "raw HTML omitted" comment) and `Go <link>` in the text part
- A variable value that contains a forged token (MAILPARTIAL followed by digits) is rendered as text, not replaced
- An odd kv count or an unknown partial is a render error naming the partial
- A template with `layout = system` from a plugin without a `system` alias renders with the built-in system layout; a plugin alias named system still wins
- A fake OverrideSource returning a customized file layout, a database-only layout (template override Layout = its code) and a partial override changes the output accordingly; layout CSS appears before mail.css in the style block
- Existing templates_test.go and mailer_test.go still pass unchanged
</behavior>
<action>
Per D-12 and D-08, with the Task 1 syntax:
- `modules/pact/capabilities.go`: add `HasMailPartials{ MailPartialsFS() fs.FS; MailPartials() []string }` next to HasMailTemplates, with a doc comment naming the file path and namespace rule; HasMailTemplates stays byte-identical. Add a row to modules/pact/README.md.
- `templates.go`: `parsedPartial{name, pluginID, headerName, textWrap, htmlWrap}` (layout file format: header, `==`, text, `==`, HTML), a partials map on Catalog, `(*Catalog) RegisterPartials(pluginID string, fsys fs.FS, partials []string) error` reusing assertOwner, assetPath and the missing/duplicate errors and requiring the `mail.partials.` segment; NewCatalog also registers the built-in layouts `postcard::mail.layouts.system` and `postcard::mail.layouts.minimal` and the partials button, panel, table and subcopy from embedded assets; resolveLayoutLocked tries the plugin's aliases, then the built-in aliases default, system and minimal.
- `mailer.go` BootPlugin: check pact.HasMailTemplates and pact.HasMailPartials independently (each optional) and register partials before templates.
- `partials.go`: the `partial` func per P35-02. In the body pass it renders the partial's text and HTML sections with the kv map as data and returns an alphanumeric token `MAILPARTIAL<nonce><n>` (nonce from crypto/rand, fresh per render); after goldmark the HTML part replaces `<p>TOKEN</p>` and then bare TOKEN with the partial HTML, and the text part (the pre-Markdown body) replaces TOKEN with the partial text. In layouts it returns template.HTML (HTML wrapper) or the text (text wrapper) directly. Partial templates get `markdown` (goldmark plus extension.Table, output validated with validateHTML) and no `partial`. Partial outputs pass validateHTML. Never enable goldmark's unsafe HTML option.
- Assets: port Winter's system layout and the four partials to Go templates with the class names the brand CSS will style (`button button-primary|positive|negative`, `panel`, `table`, `subcopy`); `minimal.htm` is a bare HTML wrapper with the style slot and `{{ .Content }}`. default.htm is not touched.
- Overrides at send: NewDBSource now also reads customized file layouts and every database-only layout (`WHERE is_custom`) into Overrides.Layouts by code, and customized partials into Overrides.Partials by code. A template's effective layout is its override's Layout code or its file layout; a customized file layout or a database-only layout replaces the file wrappers; a partial override replaces that partial's sections. P35-04: a non-empty layout CSS is placed before mail.css in the css slot.
- Write the behavior subtests in `partials_smoke_test.go` (`TestMailPartialsSmoke`, runs under -short: fstest.MapFS fixtures, NewMailer with a fake OverrideSource and a MemoryDriver) first, then implement.
- Docs in the same commit: postcard README (partials, built-in layouts and partials, the call syntax with an acme example, the token rule, API rows for RegisterPartials, LayoutOverride/PartialOverride use), docs/services/mail.md sections "Partials" and "Built-in layouts", docs/plugins/registration.md capability row for pact.HasMailPartials.
Commit as listed in Execution units.
</action>
<verify>
<automated>go test ./modules/postcard -run 'TestMailPartialsSmoke|TestMailOverrideSmoke' -count=1 -v 2>&1 | grep -E '^\s*(--- |FAIL|ok|PASS)' | tail -30</automated>
<fails_when>a "FAIL" line, "no tests to run", or no "--- PASS: TestMailPartialsSmoke" line</fails_when>
<automated>go doc ./modules/pact HasMailTemplates | grep -cE '^\s+(MailTemplatesFS\(\) fs\.FS|MailTemplates\(\) \[\]string|MailLayouts\(\) map\[string\]string)$'</automated>
<fails_when>non-zero exit or a count other than 3 (HasMailTemplates changed)</fails_when>
<automated>go vet ./... && go test -short ./...</automated>
<fails_when>non-zero exit or a "FAIL" line</fails_when>
<automated>go test ./cmd/summer -run 'TestCommandsDocumented|TestDocsTree' -count=1 2>&1 | tail -3 && go run ./cmd/summer docs:build --check 2>&1 | tail -5</automated>
<fails_when>non-zero exit, or a "FAIL" or "unknown identifier" line in the output</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "type HasMailPartials interface" modules/pact/capabilities.go` prints one line
- `grep -n "pact.HasMailPartials" modules/postcard/mailer.go` prints at least one line
- `grep -n "func (c \*Catalog) RegisterPartials(pluginID string, fsys fs.FS, partials \[\]string) error" modules/postcard/templates.go` prints one line
- `ls modules/postcard/assets/partials/` lists button.htm, panel.htm, subcopy.htm and table.htm
- `git diff a024280 --stat -- modules/postcard/assets/default.htm` prints nothing
- `grep -n "pact.HasMailPartials" docs/plugins/registration.md` prints at least one line
</acceptance_criteria>
<done>Plugins and admins can use reusable mail blocks in bodies and layouts, and customized layouts and partials reach real sends.</done>
</task>
<task type="auto" tdd="true">
<name>Task 4: Sample values, file hashes, index rows and the draft editor: previews and save checks render like real sends</name>
<files>modules/postcard/samples.go, modules/postcard/editor.go, modules/postcard/templates.go, modules/postcard/store.go, modules/postcard/assets/sample.htm, modules/pact/capabilities.go, modules/pact/README.md, modules/postcard/editor_smoke_test.go, modules/postcard/README.md, docs/services/mail.md</files>
<read_first>
- modules/postcard/templates.go, modules/postcard/partials.go, modules/postcard/store.go (Tasks 2-3 output)
- modules/pact/capabilities.go (~1050-1075 RowState and ListRowStates doc)
- ../sm-paymentgateway-plugin/views/mail/order/paid.htm and ../sm-user-plugin/views/mail/activate.htm (real nested, range and if shapes the walker must survive) — read only
- .planning/phases/35-mail-templates-and-layouts-editor/35-RESEARCH.md (Pattern 2, Pattern 5, "Placeholder walker" code example, Pitfalls 3, 8, 10)
- .planning/phases/35-mail-templates-and-layouts-editor/35-UI-SPEC.md ("Save validation (D-13)" table: the rules and fields)
</read_first>
<behavior>
- `sample.Name = "Ada"` and `sample.order.code = "ZAM-1"` parse to Samples{"Name": "Ada", "order.code": "ZAM-1"}; a file without sample keys parses exactly as before (subject, description, layout unchanged)
- Placeholder vars for `{{ .Name }} {{ .order.code }} {{ if .employee.first_name }}x{{ end }} {{ range .order.items }}{{ .description }}{{ end }} {{ range .lines }}{{ . }}{{ end }} {{ with .user }}{{ .email }}{{ end }}` render without error as `Ada ZAM-1 x [order.items.description] [lines] [user.email]`
- Validate of a template draft: a parse error gives Rule parse on Field body; `<script>` gives Rule unsafe with the matched snippet in Message; a subject with a newline gives Rule subject_line on subject; an unknown layout code gives Rule layout on layout; an unknown partial gives Rule partial with Name set
- Validate of a layout draft without `{{ .Content }}` in the HTML (or text) wrapper gives Rule no_content on content_html (or content_text); CSS containing a closing style tag gives Rule unsafe on content_css
- Preview of a template draft returns Subject, HTML and Text rendered through the same render path as Send, with samples and placeholders; Preview of a layout draft wraps assets/sample.htm; Preview of a partial draft renders a sample message calling it with placeholder arguments
- SendDraft delivers only to the given address through the mailer's driver with mail.from
- Every TemplateFile, LayoutFile and PartialFile has a 64-hex Hash that is equal for LF and CRLF copies of the same file
- TemplateLocale("acme.blog::mail.welcome-en", ["en","pl"]) is "en"; "acme.blog::mail.cancel-notice" with the same locales is ""
- SyncIndex run twice leaves the same rows and IDs; a customized row keeps its subject, body, layout_id and base_hash while file_hash is refreshed; a database-only layout is untouched; two concurrent SyncIndex calls both succeed; ResetOverride on a customized template restores the file subject, body and layout_id and clears is_custom, base_hash and customized_at
</behavior>
<action>
Per D-15, D-13, D-16, D-17, D-07, D-05 and D-23 (amending D-06):
- `samples.go`: read `sample.<path>` header keys keeping the case after the prefix (parseINI keeps lower-casing other keys); a parse-tree walker over the html/template Tree (text/template/parse) that builds nested maps with `[a.b]` leaves or the sample value, one-element slices for range targets (a map of the fields used in the body, or a string when the body prints the dot), handles if/with/else, with re-rooting, chains and variables, and parses with the partial and markdown funcs present.
- Field names in DraftError.Field are the admin column names 35-02 uses: subject, layout, body for templates; content_html, content_text, content_css for layouts and partials.
- Hashes and the catalog read API in templates.go: sha256 hex of CRLF-normalized bytes on every parsed entry; exported read structs `TemplateFile{Name, PluginID, Subject, Description, Layout, Body, Samples, Source, Hash}`, `LayoutFile{Name, PluginID, Title, Text, HTML, Source, Hash}`, `PartialFile{Name, PluginID, Title, Text, HTML, Source, Hash}` (Source is the normalized raw file text; Layout is the resolved full layout name); methods `Templates()`, `Layouts()`, `Partials()` (sorted by name) and `Template(name)`, `Layout(name)`, `Partial(name)` returning (value, bool); serializers `FormatTemplate(subject, layout, body string) string`, `FormatLayout(name, text, html, css string) string`, `FormatPartial(name, text, html string) string` producing the header plus `==` sections, used by 35-02's diff on both sides; `TemplateLocale(name string, locales []string) string` (suffix after the last dash of the last dotted segment, only when it is one of locales).
- `editor.go`: type `Kind` with `KindTemplate`, `KindLayout`, `KindPartial`, `KindBrand`; `Draft{Kind; Name; Subject, Body, Layout; HTML, Text, CSS string; Brand *Brand}` (Name is the template full name, layout code or partial full name); `DraftError{Field, Rule, Message, Name string}` implementing error, Rule one of parse, render, unsafe, subject_line, layout, partial, no_content (35-02 maps them to `backend::lang.mail.invalid_<rule>`) and colour (Task 5; 35-02 maps it to `backend::lang.colorpicker.invalid`); Message carries only parser, renderer or safety-check text (never a database error or a file path beyond the name), and for the unsafe rule the matched snippet (research Pitfall 8); `Preview{Subject, HTML, Text string}`; `Editor` with `EditorFrom(app *backpack.App) (*Editor, bool)` (from the published *mailer, as BootPlugin finds it), `Catalog() *Catalog`, `Validate(ctx, Draft) *DraftError`, `Preview(ctx, Draft) (Preview, error)` (a *DraftError through errors.As for draft problems), `SendDraft(ctx, Draft, to string) error`. Preview and Validate read the other pieces (layouts, partials, Brand) through the mailer's source with an empty template name, so a preview matches a real send (D-16). Leave the KindBrand preview to Task 5.
- `assets/sample.htm`: the sample Markdown message used to preview layouts (a heading, a paragraph, a button partial call, a panel).
- `store.go`: `SyncIndex(ctx context.Context, db *gorm.DB, cat *Catalog, locales []string) error`: layouts first, then templates (layout_id from the layout row of the resolved layout), then partials; `INSERT ... ON CONFLICT (code) DO UPDATE` that always sets source, description, locale, name (header name for layouts and partials) and file_hash and sets the mirror columns only `WHERE NOT is_custom`; never deletes. `ResetOverride(ctx context.Context, db *gorm.DB, cat *Catalog, kind Kind, code string) error` clears is_custom, base_hash and customized_at on one file-backed row and re-mirrors the file content; it refuses a database-only layout.
- pact: add `RowStateCustomized RowState = "customized"` and `RowStateOutdated RowState = "outdated"` and update the ListRowStates doc comment (five constants); pact README row.
- Write the behavior subtests first in `editor_smoke_test.go` (`TestMailEditorSmoke`: subtests `samples`, `walker`, `validate`, `preview`, `send_draft`, `hash`, `locale` run under -short; `sync` and `reset` use the Postgres helper from override_smoke_test.go and skip under -short), then implement.
- Docs in the same commit: postcard README (Editor API, sample keys, SyncIndex, ResetOverride, Kind, the read structs, TemplateLocale), docs/services/mail.md "Sample values for previews" with an acme example and the placeholder rule; pact README row-state sentence.
Commit as listed in Execution units.
</action>
<verify>
<automated>go test ./modules/postcard -run 'TestMailEditorSmoke' -count=1 -v 2>&1 | grep -E '^\s*(--- |FAIL|ok|PASS)' | tail -30</automated>
<fails_when>a "FAIL" line, a "--- SKIP" line, "no tests to run", or no "--- PASS: TestMailEditorSmoke/sync" or "--- PASS: TestMailEditorSmoke/walker" line</fails_when>
<automated>go vet ./... && go test -short ./...</automated>
<fails_when>non-zero exit or a "FAIL" line</fails_when>
<automated>go test ./cmd/summer -run 'TestCommandsDocumented|TestDocsTree' -count=1 2>&1 | tail -3 && go run ./cmd/summer docs:build --check 2>&1 | tail -5</automated>
<fails_when>non-zero exit, or a "FAIL" or "unknown identifier" line in the output</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "func EditorFrom(app \*backpack.App) (\*Editor, bool)" modules/postcard/editor.go` prints one line
- `grep -n "func SyncIndex(ctx context.Context, db \*gorm.DB, cat \*Catalog, locales \[\]string) error" modules/postcard/store.go` prints one line
- `grep -n "func ResetOverride(" modules/postcard/store.go` prints one line
- `grep -n 'RowStateCustomized RowState = "customized"' modules/pact/capabilities.go` and `grep -n 'RowStateOutdated RowState = "outdated"' modules/pact/capabilities.go` each print one line
- `go doc ./modules/postcard DraftError` lists Field, Rule, Message and Name
- `grep -n "sample\." docs/services/mail.md` prints at least one line
</acceptance_criteria>
<done>The admin layer can list file mail as rows, reset an override, and validate, preview and test-send a draft exactly as postcard would send it.</done>
</task>
<task type="auto" tdd="true">
<name>Task 5: Branding: saved colours replace mail.brandCss and layouts show the logo by an absolute URL built at send time</name>
<files>modules/lagoon/attach/absolute.go, modules/lagoon/README.md, docs/services/storage.md, modules/postcard/brand.go, modules/postcard/assets/brand.css.tmpl, modules/postcard/store.go, modules/postcard/mailer.go, modules/postcard/templates.go, modules/postcard/editor.go, modules/postcard/brand_smoke_test.go, modules/postcard/README.md, docs/services/mail.md</files>
<read_first>
- modules/lagoon/attach/bucket.go (~35-80 PublicURLBase, PublicPathPrefix, parsePublicURL; ~125-160 OpenBucket) and modules/lagoon/attach/thumb.go (~60-100 BlobKey, PublicURL)
- modules/postcard/store.go, modules/postcard/editor.go (Tasks 2-4 output)
- ../examples/golem15-wintercms-starter/modules/system/models/mailbrandsetting/custom.less and ../examples/golem15-wintercms-starter/modules/system/models/MailBrandSetting.php (colour set and defaults) — read only
- .planning/phases/35-mail-templates-and-layouts-editor/35-UI-SPEC.md ("Branding page (C12, D-10, D-11)" colour table with defaults)
- .planning/phases/35-mail-templates-and-layouts-editor/35-RESEARCH.md (Pattern 6, Pattern 8, Pitfall 4)
</read_first>
<behavior>
- AbsolutePublicURL with storage.uploads.public_url `https://cdn.example.com/u` returns `https://cdn.example.com/u/<key>`; without it and with app.url `https://acme.test` and the default prefix it returns `https://acme.test/storage/uploads/<key>`; with neither it returns ErrNoAbsoluteURL; it never reads the process globals set by OpenBucket
- CompileBrandCSS(DefaultBrand()) succeeds and contains every default colour; a colour `#ABC`, `red` or one containing a closing style tag returns an error naming the field; an empty colour uses that field's default; upper-case hex is accepted and emitted lower-case
- No brand row: the sent HTML's style block contains config mail.brandCss; a brand row exists: it contains the compiled CSS, not mail.brandCss, and mail.css still follows it
- A brand row with logo_file_id pointing at a public system_files row: a layout using `{{ .logoUrl }}` gets the absolute URL; a missing or non-public file gives an empty logoUrl and a warning, and the mail is still sent
- Editor Preview of a KindBrand draft renders the sample message in the default layout with the draft colours and logo
</behavior>
<action>
Per D-10, D-11, D-19 and D-24:
- `modules/lagoon/attach/absolute.go` (own commit first): `ErrNoAbsoluteURL` and `AbsolutePublicURL(cfg *compass.Config, key string) (string, error)`: storage.uploads.public_url (validated with parsePublicURL) joined with the key by one slash; else app.url (absolute http or https) joined with storage.uploads.public_path_prefix (default `/storage/uploads`) and the key; else ErrNoAbsoluteURL. It reads only cfg, so it gives the same URL in serve and in queue:work (research Pitfall 4). Document it in modules/lagoon/README.md (attach bullet and API row) and docs/services/storage.md in the same commit.
- `brand.go`: `Brand{BodyBg, ContentBg, HeaderColor, FooterColor, HeadingColor, TextColor, LinkColor, ButtonTextColor, ButtonPrimaryBg, ButtonPositiveBg, ButtonNegativeBg, PanelBg, TableBorderColor, LogoURL string}`, `DefaultBrand() Brand` with the UI-SPEC Winter defaults, `CompileBrandCSS(b Brand) (string, error)` validating each colour against `^#[0-9a-f]{6}$` after lower-casing (empty means default) and executing `assets/brand.css.tmpl` (a text/template port of Winter's custom.less styling body, a, h1-h3, p, table and the built-in partial classes button-primary/positive/negative, panel, table and subcopy, so Branding also shows in plugin layouts that only carry wrapper classes).
- Add `Brand *Brand` to Overrides; NewDBSource reads row 1 of the brand table into Overrides.Brand (no row: nil) and, when logo_file_id is set, the system_files row by id with is_public true, then `attach.AbsolutePublicURL(app.Config, attach.BlobKey(disk_name))` into Brand.LogoURL; a missing or non-public file or ErrNoAbsoluteURL gives an empty LogoURL and a rate-limited warning.
- Send and render: a non-nil Brand replaces the brandCss slot with CompileBrandCSS (D-11; a compile error logs and keeps config mail.brandCss); mail.css is unchanged; layouts get `logoUrl` (empty when no brand or no logo). Editor gains the KindBrand preview (sample message, default layout, draft Brand) and Validate for KindBrand reports an invalid colour with Rule colour and the column name (for example button_primary_bg) in Field.
- Write the behavior subtests first in `brand_smoke_test.go` (`TestMailBrandSmoke`: `compile`, `defaults`, `preview` under -short; `precedence` and `logo` with the Postgres helper, skipped under -short) plus an attach case for AbsolutePublicURL inside the same test (no new attach test file), then implement.
- Docs in the same postcard commit: postcard README (Branding, the D-11 precedence in Configuration next to mail.brandCss and mail.css, logoUrl, API rows), docs/services/mail.md section "Branding".
Commit as listed in Execution units.
</action>
<verify>
<automated>go test ./modules/postcard -run 'TestMailBrandSmoke' -count=1 -v 2>&1 | grep -E '^\s*(--- |FAIL|ok|PASS)' | tail -20</automated>
<fails_when>a "FAIL" line, a "--- SKIP" line, "no tests to run", or no "--- PASS: TestMailBrandSmoke/precedence" or "--- PASS: TestMailBrandSmoke/logo" line</fails_when>
<automated>go test ./modules/postcard/... ./modules/lagoon/... ./modules/pact/... -count=1 2>&1 | tail -8</automated>
<fails_when>non-zero exit or a "FAIL" line</fails_when>
<automated>go vet ./... && go test -short ./...</automated>
<fails_when>non-zero exit or a "FAIL" line</fails_when>
<automated>go test ./cmd/summer -run 'TestCommandsDocumented|TestDocsTree' -count=1 2>&1 | tail -3 && go run ./cmd/summer docs:build --check 2>&1 | tail -5</automated>
<fails_when>non-zero exit, or a "FAIL" or "unknown identifier" line in the output</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "func AbsolutePublicURL(cfg \*compass.Config, key string) (string, error)" modules/lagoon/attach/absolute.go` prints one line
- `grep -n "attach.AbsolutePublicURL" modules/postcard/store.go` prints at least one line
- `grep -n "func CompileBrandCSS(b Brand) (string, error)" modules/postcard/brand.go` prints one line
- `grep -n '"logoUrl"' modules/postcard/templates.go` prints at least one line
- `grep -n "AbsolutePublicURL" modules/lagoon/README.md docs/services/storage.md` prints at least two lines
- `git log --format=%s -F --grep='feat(attach): absolute public URL from config' --grep='feat(postcard): mail branding and the logo URL'` prints two lines, the postcard one first (newer)
</acceptance_criteria>
<done>Saving Branding restyles every mail and shows the logo, while apps that never open the page keep their configured brand CSS.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| admin-edited database rows → postcard render | subject, body, layout and partial text written by administrators, rendered into mail sent to members |
| template variables (member data) → rendered mail | plugin-supplied values that may contain markup or a forged partial token |
| database → send path | a missing table, a stale row or an outage during a send |
| config → logo URL | storage.uploads.public_url and app.url become links in sent mail |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-35-01 | Tampering | override rendering in Send | high | mitigate | validateHTML (script/iframe/object/embed, event handlers, javascript:/vbscript:/data:) runs on the body, every partial output and the final HTML at send, exactly as for files; TestMailOverrideSmoke/fallback proves a failing override never ships |
| T-35-02 | Tampering | partial tokens in bodies | high | mitigate | per-render crypto/rand nonce in an alphanumeric token, replaced only after goldmark; a forged token in a variable stays text (Task 3 behavior) |
| T-35-03 | Tampering | subject override | medium | mitigate | existing CR/LF refusal at send and Rule subject_line in Validate |
| T-35-04 | Tampering | brand colours into the style block | high | mitigate | strict `^#[0-9a-f]{6}$` before CompileBrandCSS; layout CSS with a closing style tag refused at Validate; validateHTML backstop |
| T-35-05 | Denial of service | per-send lookup | medium | mitigate | any lookup error or missing table renders the file version with a rate-limited warning (TestMailOverrideSmoke/missing_table, no_db) |
| T-35-06 | Information disclosure | DraftError.Message | medium | mitigate | Message carries parser, renderer and safety text only, never database errors or filesystem paths |
| T-35-07 | Spoofing | logo URL host | low | mitigate | URL built from operator config only (public_url validated by parsePublicURL, app.url absolute http/https); never from request headers |
| T-35-08 | Tampering | stale overrides across instances | medium | mitigate | no cache: every send reads the rows (Claude's Discretion: per-send lookup) |
| T-35-SC | Tampering | npm/pip/cargo installs | high | accept | this plan installs no package; every Go dependency used (gorm, gormigrate, goldmark, testcontainers) is already in go.mod |
</threat_model>
<verification>
- `go vet ./... && go test -short ./...` green after every commit; `go test ./modules/postcard/... ./modules/lagoon/... ./modules/pact/...` (Docker) green after Task 5.
- `go test ./cmd/summer -run 'TestCommandsDocumented|TestDocsTree'` and `go run ./cmd/summer docs:build --check` pass.
- `(cd ../sm-user-plugin && go test -run 'TestMail|TestPolishMailSubjects' ./...)` and `(cd ../sm-paymentgateway-plugin && go test -run 'TestMailCatalog|TestMailTracer|TestMailersMatrixSmoke' ./...)` pass against this checkout (their go.mod replaces the framework with ../summercms.go); nothing in those repositories is changed.
</verification>
<success_criteria>
- postcard owns the mail tables, reads overrides per send and falls back to the file version on any failure.
- Partials, built-in layouts, sample values, hashes, index sync, reset, the Editor and Branding exist with smoke tests and docs.
- pact gained HasMailPartials and two row states additively; HasMailTemplates is unchanged.
- No push, no tag; no file in a sibling repository changed.
</success_criteria>
<output>
Create `.planning/phases/35-mail-templates-and-layouts-editor/35-01-SUMMARY.md` when done; record the Task 1 answer verbatim and every name that differs from this plan's text.
</output>
## Artifacts this phase produces
- Tables (migration `202611060001_create_system_mail_tables`, history `summercms.postcard`): `system_mail_layouts`, `system_mail_templates`, `system_mail_partials`, `system_mail_brand_settings`, `system_mail_image_library`.
- pact: `pact.HasMailPartials`, `pact.RowStateCustomized`, `pact.RowStateOutdated`.
- postcard types: `postcard.OverrideSource`, `postcard.Overrides`, `postcard.TemplateOverride`, `postcard.LayoutOverride`, `postcard.PartialOverride`, `postcard.Brand`, `postcard.Kind`, `postcard.Draft`, `postcard.DraftError`, `postcard.Preview`, `postcard.Editor`, `postcard.TemplateFile`, `postcard.LayoutFile`, `postcard.PartialFile`; field `postcard.Options.Overrides`.
- postcard functions and methods: `postcard.NewDBSource`, `postcard.SyncIndex`, `postcard.ResetOverride`, `postcard.EditorFrom`, `postcard.DefaultBrand`, `postcard.CompileBrandCSS`, `postcard.FormatTemplate`, `postcard.FormatLayout`, `postcard.FormatPartial`, `postcard.TemplateLocale`, `(*Catalog).RegisterPartials`, `(*Catalog).Templates|Layouts|Partials|Template|Layout|Partial`, `(*Editor).Catalog|Validate|Preview|SendDraft`.
- postcard constants and variables: `postcard.Migrations`, `postcard.TemplatesTable`, `postcard.LayoutsTable`, `postcard.PartialsTable`, `postcard.BrandTable`, `postcard.ImageLibraryTable`, `postcard.KindTemplate`, `postcard.KindLayout`, `postcard.KindPartial`, `postcard.KindBrand`.
- attach: `attach.AbsolutePublicURL`, `attach.ErrNoAbsoluteURL`.
- Built-in mail names: `postcard::mail.layouts.system`, `postcard::mail.layouts.minimal`, `postcard::mail.partials.button|panel|table|subcopy`; layout aliases `system`, `minimal` (fallback after plugin aliases).
- Template language: `{{ partial "<name>" key value ... }}`, `markdown` inside partials, layout key `logoUrl`, header keys `sample.<path>`.
- Files: assets `system.htm`, `minimal.htm`, `sample.htm`, `brand.css.tmpl`, `partials/{button,panel,table,subcopy}.htm`; smoke tests `TestMailOverrideSmoke`, `TestMailPartialsSmoke`, `TestMailEditorSmoke`, `TestMailBrandSmoke`.
- Config keys: none new (reads existing `mail.brandCss`, `mail.css`, `storage.uploads.public_url`, `storage.uploads.public_path_prefix`, `app.url`).
## Phase-wide source coverage audit
| Source | ID | Item | Plan | Status |
|--------|----|------|------|--------|
| GOAL | — | Edit every mail from the admin; list, override, reset; editable layouts with predefined ones; images in layouts and templates; override else file | 01-04 | COVERED |
| REQ | — | none mapped (Requirements: TBD); I18N-03 kept satisfied by the consumer-repo checks | 01, 04 | n/a |
| CONTEXT | D-01 | screens in cabana pseudo-plugin; postcard free of admin code | 01 (interface), 02 (screens) | COVERED |
| CONTEXT | D-02 | Settings → Mail entries | 02 | COVERED |
| CONTEXT | D-03 | two permission codes, no default grants, picker for template editors | 02, 03 | COVERED |
| CONTEXT | D-04 | override = subject + body + layout, description read-only | 01, 02 | COVERED |
| CONTEXT | D-05 | locale column and filter | 01 (TemplateLocale, sync), 02 | COVERED |
| CONTEXT | D-06 | reset to file version (as amended by D-23) | 01 (ResetOverride), 02 | COVERED |
| CONTEXT | D-07 | drift hash, notice, file version view/diff | 01 (hashes), 02 | COVERED |
| CONTEXT | D-08 | file + custom layouts, in-use delete refusal, predefined layouts | 01 (system, minimal), 02 | COVERED |
| CONTEXT | D-09 | codeeditor field type | 02 (server), 03 (SPA) | COVERED |
| CONTEXT | D-10 | Branding page with colours and logo | 01 (compile), 02 (page), 03 (fields) | COVERED |
| CONTEXT | D-11 | Branding replaces mail.brandCss once saved | 01 | COVERED |
| CONTEXT | D-12 | partials capability, built-ins, overrides | 01, 02 | COVERED |
| CONTEXT | D-13 | save validation | 01 (Validate), 02 (hooks) | COVERED |
| CONTEXT | D-14 | fallback at send | 01 | COVERED |
| CONTEXT | D-15 | sample variables, [name] placeholders | 01 | COVERED |
| CONTEXT | D-16 | live preview of the draft, sandboxed iframe | 01 (Preview), 02 (route), 03 (field) | COVERED |
| CONTEXT | D-17 | test send to own address, rate-limited | 01 (SendDraft), 02 (route), 03 (button) | COVERED |
| CONTEXT | D-18 | shared image library via attach | 02, 03 | COVERED |
| CONTEXT | D-19 | absolute public URLs | 01 (helper), 02 (library URLs) | COVERED |
| CONTEXT | D-20 | insert button in editors | 03 | COVERED |
| CONTEXT | D-21 | unit tests last, READMEs/docs, OpenAPI and dist gates | 01-03 (docs), 04 (tests, gate) | COVERED |
| CONTEXT | D-22 | postcard owns tables, migrations, per-send lookup | 01 | COVERED |
| CONTEXT | D-23 | index rows synced at serve start; reset clears columns | 01 (SyncIndex), 02 (serve-start call) | COVERED |
| CONTEXT | D-24 | logo URL built at send time from config | 01 | COVERED |
| CONTEXT | D-25 | no built-in template; empty-state text | 01, 02 | COVERED |
| RESEARCH | Pattern 1-3 | store split, index rows, per-send lookup | 01 | COVERED |
| RESEARCH | Pattern 4-6 | partials, samples, Branding | 01 | COVERED |
| RESEARCH | Pattern 7-9 | routes, permissions, rate limit, images, admin wiring | 02, 03 | COVERED |
| RESEARCH | Pitfall 5 | CodeMirror Mod-Enter | 03 | COVERED |
| RESEARCH | Pitfall 6, 9 | validate in FormAfterUpdate; parameterized messages | 02 | COVERED |
| RESEARCH | Package audit | @codemirror/view 6.43.15 [SUS too-new] → human checkpoint | 03 | COVERED |
| CONTEXT | Deferred | hand-written text parts, WYSIWYG body, CID images, arbitrary test recipient, SMTP screen, MailBlocker | — | excluded (deferred) |

View File

@@ -0,0 +1,472 @@
---
phase: 35-mail-templates-and-layouts-editor
plan: 02
type: execute
wave: 2
depends_on: ["35-01"]
files_modified:
- modules/cabana/backend_screens.go
- modules/cabana/mail_admin.go
- modules/cabana/mail_diff.go
- modules/cabana/mail_routes.go
- modules/cabana/field_mail.go
- modules/cabana/http.go
- modules/cabana/form_schema.go
- modules/cabana/crud.go
- modules/cabana/settings.go
- modules/cabana/query.go
- modules/cabana/messages.go
- modules/cabana/relation_field.go
- modules/cabana/admin_openapi.go
- modules/cabana/mail_screens_smoke_test.go
- modules/cabana/phase10_coverage_test.go
- modules/cabana/openapi_conformance_test.go
- modules/cabana/security_coverage_test.go
- modules/cabana/token_routes_test.go
- modules/cabana/backendscreens/controllers/mailtemplates/config_form.yaml
- modules/cabana/backendscreens/controllers/mailtemplates/config_list.yaml
- modules/cabana/backendscreens/controllers/mailtemplates/config_filter.yaml
- modules/cabana/backendscreens/controllers/mailtemplates/_info.htm
- modules/cabana/backendscreens/controllers/mailtemplates/_file_version.htm
- modules/cabana/backendscreens/controllers/maillayouts/config_form.yaml
- modules/cabana/backendscreens/controllers/maillayouts/config_list.yaml
- modules/cabana/backendscreens/controllers/maillayouts/config_filter.yaml
- modules/cabana/backendscreens/controllers/maillayouts/_info.htm
- modules/cabana/backendscreens/controllers/maillayouts/_file_version.htm
- modules/cabana/backendscreens/controllers/mailpartials/config_form.yaml
- modules/cabana/backendscreens/controllers/mailpartials/config_list.yaml
- modules/cabana/backendscreens/controllers/mailpartials/config_filter.yaml
- modules/cabana/backendscreens/controllers/mailpartials/_info.htm
- modules/cabana/backendscreens/controllers/mailpartials/_file_version.htm
- modules/cabana/backendscreens/controllers/mailimages/config_form.yaml
- modules/cabana/backendscreens/models/mailtemplate/fields.yaml
- modules/cabana/backendscreens/models/mailtemplate/columns.yaml
- modules/cabana/backendscreens/models/maillayout/fields.yaml
- modules/cabana/backendscreens/models/maillayout/columns.yaml
- modules/cabana/backendscreens/models/mailpartial/fields.yaml
- modules/cabana/backendscreens/models/mailpartial/columns.yaml
- modules/cabana/backendscreens/models/mailbrandsetting/fields.yaml
- modules/cabana/backendscreens/models/mailimagelibrary/fields.yaml
- modules/phrasebook/backend/lang/en/lang.yaml
- modules/phrasebook/backend/lang/pl/lang.yaml
- modules/surf/serve.go
- modules/surf/README.md
- admin/openapi/admin.json
- admin/src/api/schema.d.ts
- modules/cabana/README.md
- docs/backend/forms.md
- docs/backend/settings.md
- docs/backend/users-and-permissions.md
- docs/backend/lists-and-filters.md
- docs/services/mail.md
- docs/services/rate-limiting.md
autonomous: true
requirements: [D-01, D-02, D-03, D-04, D-05, D-06, D-07, D-08, D-09, D-10, D-13, D-16, D-17, D-18, D-19, D-23, D-25]
estimate:
tokens: 210000
raw_tokens: 210000
tasks: 4
confidence: low
must_haves:
truths:
- "Every app whose postcard Mailer is published gets five Settings → Mail entries (Mail templates, Mail layouts, Mail partials, Mail branding, Mail images) from cabana's internal pseudo-plugin, with no top-level navigation item and no app or plugin setup (D-01, D-02)"
- "cabana registers `system.manage_mail_templates` (templates list/form, template preview and test send) and `system.manage_mail_layouts` (layouts, partials, Branding, image library) with no role grants; a holder of only the templates code sees one tile and gets 403 on layouts, partials, Branding, image upload and layout or partial preview; the image list and picker answer either code (D-03)"
- "Every file template, layout and partial appears as a row after serve start (postcard.SyncIndex through lagoon.OnDatabase, retried on the first list request when the tables were missing); rows absent from this process's catalog are hidden, database-only layouts are always listed (D-23, D-08)"
- "The templates list shows code, effective subject, description, locale (suffix or Default) and layout, with a locale filter and a Customized / Not customized / File changed status filter; the empty view shows the UI-SPEC empty-state text (D-05, D-25)"
- "Saving a template stores subject, Markdown body and layout (a dropdown over file and custom layouts) as the override, stamps base_hash from the current file hash, and runs postcard's draft validation inside the save transaction; a failure is a 422 field line with the translated rule text and :message, and nothing is saved (D-04, D-13)"
- "Reset to file version, a record action on customized template, file layout and partial rows, clears the override columns and re-mirrors the file; custom layouts are created with a lowercase-dash code and deleted, a delete of one still used by customized templates is a 403 naming the layout and the count, and file rows can never be deleted (D-06 as amended by D-23, D-08)"
- "A customized row whose file changed carries the `customized` and `outdated` row states, the File changed filter finds it, the form shows the drift callout and a File version tab with a line diff of the serialized override against the file, and saving again clears the drift (D-07)"
- "Layouts and partials are edited in the new built-in `codeeditor` field type (`language: html | css | text`, optional size and mailImages), registered in cabana's field registry and the form schema; the layout form has HTML, Plain text, CSS and File version tabs (D-09)"
- "The Branding page is a cabana settings form on system_mail_brand_settings with the 13 colours as `colorpicker` fields and the logo as a `mailimage` field; an invalid colour is a 422 field line, and saving it is what switches mail from mail.brandCss to the compiled colours (D-10, D-11)"
- "Mail images is a singleton screen whose fileupload stores public images through lagoon/attach; GET/POST /mail/images return absolute public URLs from config and refuse with a clear message when neither storage.uploads.public_url nor app.url is set (D-18, D-19, D-24)"
- "POST /mail/preview renders the unsaved draft (template, layout, partial or branding mode) through postcard's Editor with sample values and returns subject, HTML and text; POST /mail/test-send sends the draft only to the signed-in administrator's own email, refuses any recipient in the body, needs the record kind's permission, is interactive-session only and answers 429 with Retry-After after 5 sends per 10 minutes per administrator (D-16, D-17)"
- "admin/openapi/admin.json and admin/src/api/schema.d.ts are regenerated from the annotations, the route inventories list the new routes, and the cabana README and docs pages describe every new screen, permission, route and field type in the same commits (D-21)"
prohibitions:
- statement: "No role is granted system.manage_mail_templates or system.manage_mail_layouts in code or migrations."
status: unresolved
verification: judgment
- statement: "The test-send route accepts no recipient: the address is always the signed-in administrator's own email."
status: unresolved
verification: judgment
- statement: "cabana's mail models carry no postcard rendering logic of their own: validation, preview and sends go through postcard.Editor so previews match real sends."
status: unresolved
verification: judgment
- statement: "Existing controllers see no behaviour change: meta.deletable is omitted unless a mail controller sets it, and the three existing row states keep their order and rendering."
status: unresolved
verification: judgment
- statement: "Framework code, READMEs and docs name no consuming application."
status: unresolved
verification: judgment
artifacts:
- path: "modules/cabana/mail_admin.go"
provides: "mail models, controllers, hooks, row states, reset action, delete refusal"
contains: "PermissionManageMailTemplates"
- path: "modules/cabana/mail_routes.go"
provides: "preview, test-send and image library routes"
contains: "func (s *service) mailTestSend("
- path: "modules/cabana/mail_diff.go"
provides: "bounded LCS line diff for the File version tab"
contains: "func lineDiff("
- path: "modules/cabana/field_mail.go"
provides: "codeeditor, colorpicker, mailimage, mailpreview schema checks"
contains: "codeeditor"
- path: "modules/cabana/mail_screens_smoke_test.go"
provides: "TestMailScreensSmoke (tracer and expansion subtests)"
contains: "func TestMailScreensSmoke("
- path: "modules/cabana/backendscreens/controllers/mailtemplates/config_list.yaml"
provides: "templates list"
contains: "mail.template.list_title"
key_links:
- from: "modules/cabana/mail_admin.go"
to: "modules/postcard/editor.go"
via: "FormAfterUpdate validates through postcard.Editor.Validate"
pattern: "\\.Validate\\(ctx"
- from: "modules/cabana/http.go"
to: "modules/postcard/store.go"
via: "serve-start index sync"
pattern: "postcard\\.SyncIndex"
- from: "modules/cabana/mail_routes.go"
to: "modules/cooler/cooler.go"
via: "per-admin test-send limit through cooler.Attempter, published by surf"
pattern: "cooler\\.Attempter"
- from: "modules/cabana/backend_screens.go"
to: "modules/cabana/mail_admin.go"
via: "pseudo-plugin AdminControllers, Permissions and Settings include the mail screens when postcard.EditorFrom succeeds"
pattern: "postcard\\.EditorFrom"
---
## Phase Goal
ROADMAP Goal (not in user-story format; no story is invented): administrators edit every mail the application sends from the admin panel, as in WinterCMS's Mail templates screen. This plan delivers the server side of the screens on top of 35-01's postcard API: the Settings → Mail entries, two permissions, the templates, layouts and partials screens with reset and drift, Branding, the image library, and the preview and test-send routes the SPA (35-03) calls.
Spec-less probe fallback skipped: no SPEC.md and no requirement IDs; must_haves derive from the ROADMAP goal and CONTEXT D-01..D-25. Names come from 35-01's Task 1 answer (read 35-01-SUMMARY.md; where it differs from this text, the SUMMARY wins).
<objective>
Add the mail screens to cabana's internal pseudo-plugin (D-01, the 19.6.1 pattern):
- templates, layouts, partials and image library controllers over postcard's tables, Branding as a settings model (D-02, D-08, D-10, D-18);
- the two permission codes with no default grants and the picker split (D-03);
- override save with validation, reset, custom layouts with in-use delete refusal, drift states, filters and the File version diff (D-04..D-08, D-13, D-23);
- the built-in field types codeeditor, colorpicker, mailimage and mailpreview on the server (D-09, UI-SPEC C1, C2, C10, C11);
- preview, test-send and image library routes with OpenAPI annotations (D-16, D-17, D-19);
- all UI-SPEC copy in en and pl, the cabana README and docs.
Purpose: 35-03 renders these screens with the generic SPA views plus four field controls; every rule that protects mail (permissions, validation, recipient, rate limit) lives here on the server.
Output: commits in this repository only. Nothing is pushed or tagged.
**Planner decisions recorded here:**
- The mail screens, permissions and settings entries are added only when `postcard.EditorFrom(app)` succeeds (always true in an application, since party.Activate runs postcard.Activate; false in cabana tests that never activate postcard, which therefore see no change).
- Image picker: list and pick for holders of either mail code; upload only for `system.manage_mail_layouts` (D-03 planner's call, UI-SPEC C4 recommendation).
- Test-send limit: 5 per administrator per 10 minutes, key `mail_test:<adminID>`, through `cooler.Attempter` when surf publishes one (Redis store), otherwise an in-process fixed window in cabana (research Pattern 7, A6, A7).
- The custom-layout create form is prefilled from postcard's default layout, injected into the compiled form schema at Activate, so the YAML carries no copy of it (UI-SPEC C5).
- New copy key beyond the UI-SPEC: `mail.images.no_public_url` (EN "Mail images need an absolute address. Set storage.uploads.public_url or app.url in the configuration." PL "Obrazy w wiadomościach wymagają pełnego adresu. Ustaw storage.uploads.public_url lub app.url w konfiguracji.").
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/phases/35-mail-templates-and-layouts-editor/35-CONTEXT.md
@.planning/phases/35-mail-templates-and-layouts-editor/35-RESEARCH.md
@.planning/phases/35-mail-templates-and-layouts-editor/35-PATTERNS.md
@.planning/phases/35-mail-templates-and-layouts-editor/35-UI-SPEC.md
@.planning/phases/35-mail-templates-and-layouts-editor/35-01-SUMMARY.md
@.planning/phases/19.6.1-backend-administrators-screen/19.6.1-CONTEXT.md
@CLAUDE.md
<interfaces>
From 35-01 (postcard): Migrations and table constants TemplatesTable, LayoutsTable, PartialsTable, BrandTable, ImageLibraryTable; SyncIndex(ctx, db, cat, locales); ResetOverride(ctx, db, cat, kind, code); EditorFrom(app) (*Editor, bool); Editor.Catalog() / Validate(ctx, Draft) *DraftError / Preview(ctx, Draft) (Preview, error) / SendDraft(ctx, Draft, to); Draft{Kind, Name, Subject, Body, Layout, HTML, Text, CSS, Brand}; Kind consts KindTemplate|KindLayout|KindPartial|KindBrand; DraftError{Field, Rule, Message, Name}; Catalog.Template|Layout|Partial(name) and Templates|Layouts|Partials(); TemplateFile/LayoutFile/PartialFile{..., Source, Hash}; FormatTemplate/FormatLayout/FormatPartial; TemplateLocale(name, locales); Brand, DefaultBrand(), CompileBrandCSS(Brand); attach.AbsolutePublicURL(cfg, key), attach.ErrNoAbsoluteURL; pact.RowStateCustomized, pact.RowStateOutdated.
modules/cabana/backend_screens.go: backendScreens pseudo-plugin (ID summercms.cabana; AdminFS from //go:embed all:backendscreens; AdminControllers, Permissions, Settings); permission/controller constants next to PermissionManageUsers; withBackendScreens.
modules/cabana/backend_users_admin.go: the controller to mirror (interface assertions block, ID/ModelName/ConfigDir/RequiredPermissions/NewRecord, db()/requestDB(ctx)/translator(), AdminFieldRelations for a belongsTo, ListRowStates, FormBeforeDelete, AdminRecordActions, PartialData, FormRules); recordDataExtender (crud.go ~699) and schemaFieldHider (http.go ~894) are unexported seams.
modules/cabana/http.go: Activate (~81) appends the pseudo-plugin; route group (~305-445): `g.Post("/markdown/preview", requireAjax(s.markdownPreview))`, interactiveOnly wrapper for account routes.
modules/cabana/field_markdown.go (~50-80): AdminMarkdownPreviewRequest/Result, the bouncer.User + principal.Backend + tokenRouteGate gate, WriteError with fixed msg constants.
modules/cabana/contracts.go: Allows(principal, required) any-of with superuser bypass (~327); BackendUser{ID, Email, ...}.
modules/cabana/form_schema.go (~24-51): formFieldTypes, formFieldKeys; crud.go (~1518) scalarFormField; settings.go (~96-102) settings refusal list and BeforeValidate hook use (~261).
modules/cabana/query.go (~182) rowStateOrder; messages.go (~42, 61, 163) row-state message keys; relation_field.go (~104) RecordMeta.
modules/cabana/administrators_harness_test.go (~154) newScreensEnv: compass config, lagoon.Publish, phrasebook.Activate, surf.Assemble (does not activate postcard).
modules/surf/serve.go (~230) installCooler publishes Store only for the Redis rate-limit store; modules/cooler/cooler.go (~68) Attempter has exactly surf.Store's method set.
Route inventories that list every admin route: modules/cabana/phase10_coverage_test.go, openapi_conformance_test.go, security_coverage_test.go, token_routes_test.go (grep `/markdown/preview` in each).
</interfaces>
</context>
## Execution units and resume points
- Phase 33 edits cabana concurrently. Before each task run `git status --short modules/cabana modules/phrasebook admin/openapi admin/src/api modules/surf` and stop if another session holds uncommitted edits there; re-check the line numbers named in interfaces before editing.
- Each task ends with the commit listed below, staged by explicit path. Resume check: `git log --format=%s -F --grep='<subject>'`.
- Docker-backed subtests run without `-short` (through `sudo -u golem` if the Docker socket is denied).
| Task | Commit subject | Resume point |
|------|----------------|--------------|
| 1 | `feat(cabana): mail templates screen over postcard overrides` | TestMailScreensSmoke/templates passes |
| 2 | `feat(cabana): mail layouts and partials screens with reset, drift and custom layouts` | TestMailScreensSmoke/layouts passes |
| 3 | `feat(cabana): code editor, colour and mail image fields, mail branding and the image library` | TestMailScreensSmoke/branding passes |
| 4 | `feat(cabana): mail preview and test send to yourself` | TestMailScreensSmoke/test_send passes |
<tasks>
<task type="tracer">
<name>Task 1: End-to-end "an administrator with system.manage_mail_templates opens Settings → Mail templates, saves a subject override and the next mail uses it" — templates only</name>
<reversibility rating="costly">D-03: the permission codes end up in backend_user_roles.permissions and backend_users.permissions JSON on every install that grants them.</reversibility>
<precondition>35-01 is complete: `git log --format=%s -F --grep='feat(postcard): mail branding and the logo URL'` prints one line and `grep -n "func EditorFrom" modules/postcard/editor.go` prints one line</precondition>
<files>modules/cabana/backend_screens.go, modules/cabana/mail_admin.go, modules/cabana/http.go, modules/cabana/backendscreens/controllers/mailtemplates/config_form.yaml, modules/cabana/backendscreens/controllers/mailtemplates/config_list.yaml, modules/cabana/backendscreens/models/mailtemplate/fields.yaml, modules/cabana/backendscreens/models/mailtemplate/columns.yaml, modules/cabana/mail_screens_smoke_test.go, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, modules/cabana/README.md, docs/backend/users-and-permissions.md, docs/services/mail.md</files>
<read_first>
- modules/cabana/backend_screens.go (whole file)
- modules/cabana/backend_users_admin.go (~1-200: assertions, identity, helpers, relations, row states, FormBeforeDelete) and backend_roles_admin.go (a smaller controller)
- modules/cabana/backendscreens/controllers/users/config_form.yaml, config_list.yaml and modules/cabana/backendscreens/models/user/fields.yaml, columns.yaml
- modules/cabana/http.go (~80-150 Activate)
- modules/lagoon/ondatabase.go (OnDatabase: queued errors are returned by Publish, so the hook must not return the sync error)
- modules/cabana/administrators_harness_test.go (~142-360 newScreensEnv, seedAdmin, seedRole, login, request)
- modules/postcard/editor.go and modules/postcard/store.go (35-01 output) and .planning/phases/35-mail-templates-and-layouts-editor/35-01-SUMMARY.md
- .planning/phases/35-mail-templates-and-layouts-editor/35-UI-SPEC.md (C3b, C14, Copywriting "Settings entries and permission catalog", "Lists", "Template form")
</read_first>
<action>
Per D-01, D-02, D-03, D-04, D-05, D-13, D-23 and D-25:
- `backend_screens.go`: exported constants `PermissionManageMailTemplates = "system.manage_mail_templates"`, `PermissionManageMailLayouts = "system.manage_mail_layouts"`, `MailTemplatesControllerID = "summercms.cabana.mailtemplates"`, `MailLayoutsControllerID`, `MailPartialsControllerID`, `MailImagesControllerID` (P35-05 names). The pseudo-plugin holds the *postcard.Editor found by `postcard.EditorFrom(app)` when it is built; only then do AdminControllers, Permissions and Settings include the mail parts. In this task: both permission codes (Tab `backend::lang.permissions.tab_system`, Labels `backend::lang.permissions.manage_mail_templates` / `manage_mail_layouts`, no Roles), the templates controller and the settings link `mail_templates` (category `backend::lang.settings.category_mail`, icon `mail`, order 200, Permissions templates code, Controller MailTemplatesControllerID).
- `mail_admin.go`: model `MailTemplate` (TableName postcard.TemplatesTable; columns per P35-01; belongsTo `layout` through layout_id with a minimal `MailLayout` model, TableName postcard.LayoutsTable, enough for the relation and its name column; Fillable subject, body and the layout foreign key) and controller `mailTemplatesAdmin` (ID, ModelName, ConfigDir `controllers/mailtemplates`, RequiredPermissions templates code, NewRecord) with: AdminFieldRelations for `layout` (belongsTo, LabelColumn name) plus pact.RelationExtendOptionsQuery limiting options to layouts present in this process's catalog or database-only; ListExtendQuery and FormExtendQuery hiding template rows whose code is not in the catalog; FormRecordTitle = code; FormBeforeCreate and FormBeforeDelete refusing with ForbiddenError (templates come from files); FormBeforeUpdate stamping is_custom true, base_hash = the catalog file Hash and customized_at = now; FormAfterUpdate building a postcard Draft (KindTemplate, Name code, Subject, Body, Layout = the code of the assigned layout row) and calling Editor.Validate; a *DraftError becomes a ValidationError on its Field with the text of `backend::lang.mail.invalid_<rule>` translated with :message and :name (build it with the translator as account_tokens.go backendMessageParams does, research Pitfall 9).
- Index sync per D-23: in Activate (when the editor exists) register `lagoon.OnDatabase` with a func that calls postcard.SyncIndex with the app's phrasebook translator locales (`Locales("")`, empty when none is published), logs any error through cabana's logger and returns nil; until a sync has succeeded, the first mail list request in the process retries it.
- YAML: controllers/mailtemplates/config_form.yaml (name `backend::lang.mail.template.name`, form `models/mailtemplate/fields.yaml` in the users-controller path style, no create, update redirect to the update form, Save and close to the list, messages per UI-SPEC C3b); config_list.yaml (title `backend::lang.mail.template.list_title`, recordUrl to update, `toolbar.buttons: []`, no checkboxes, recordsPerPage 20, noRecordsMessage `backend::lang.mail.template.list_empty`, default sort code ascending); models/mailtemplate/columns.yaml (code searchable+sortable, subject searchable, description searchable, locale sortable, layout through the relation's name, customized_at invisible sortable); models/mailtemplate/fields.yaml (subject text left required with comment, layout relation right required without an empty option, body markdown size giant, full) — the `_info`, `_preview` and `_file_version` fields and `mailImages` arrive in Tasks 2-4.
- Copy en and pl (UI-SPEC tables, Polish as the design language): `settings.category_mail`, `permissions.manage_mail_templates[_comment]`, `permissions.manage_mail_layouts[_comment]`, the `mail.template.*` keys used here, `mail.code`, `mail.template.locale_default`.
- `mail_screens_smoke_test.go` (package cabana_test): helper `newMailScreensEnv(t)` that follows newScreensEnv but also calls postcard.Activate and postcard.BootPlugin for a fixture plugin `acme.blog` (fstest.MapFS: `mail.welcome`, `mail.welcome-en`, one layout alias) before surf.Assemble, and runs the index sync; `TestMailScreensSmoke/templates`: a superuser lists templates and sees both rows with locale `en` on the suffixed one; GET schema of the form; PUT a new subject on `acme.blog::mail.welcome` gives 200 and is_custom true; the published postcard Mailer then sends that subject; a PUT with a `<script>` body gives 422 with Details.body and leaves the row unchanged; a role holding only system.manage_mail_layouts gets 403 on the templates list; a role holding only the templates code gets 200; no role in the database gained either code; GET /settings shows exactly one Mail tile for the templates-only role.
- Docs in the same commit: cabana README (Mail screens overview, the two permission codes, the controller ID constants, the serve-start sync), docs/backend/users-and-permissions.md (the two codes, no default grant, what each opens), docs/services/mail.md section "Editing mail in the admin" (which screens exist, what is stored, that the override wins until reset).
Commit as listed in Execution units.
</action>
<verify>
<automated>go test ./modules/cabana -run 'TestMailScreensSmoke' -count=1 -v 2>&1 | grep -E '^\s*(--- |FAIL|ok|PASS)' | tail -20</automated>
<fails_when>a "FAIL" line, a "--- SKIP" line, "no tests to run", or no "--- PASS: TestMailScreensSmoke/templates" line</fails_when>
<automated>go vet ./... && go test -short ./...</automated>
<fails_when>non-zero exit or a "FAIL" line</fails_when>
<automated>go test ./modules/cabana -count=1 2>&1 | tail -5</automated>
<fails_when>non-zero exit or a "FAIL" line (existing cabana Postgres tests must stay green)</fails_when>
<automated>go test ./cmd/summer -run 'TestCommandsDocumented|TestDocsTree' -count=1 2>&1 | tail -3 && go run ./cmd/summer docs:build --check 2>&1 | tail -5</automated>
<fails_when>non-zero exit, or a "FAIL" or "unknown identifier" line in the output</fails_when>
</verify>
<acceptance_criteria>
- `grep -n 'PermissionManageMailTemplates = "system.manage_mail_templates"' modules/cabana/backend_screens.go` and `grep -n 'PermissionManageMailLayouts = "system.manage_mail_layouts"' modules/cabana/backend_screens.go` each print one line
- `grep -n "postcard.EditorFrom" modules/cabana/backend_screens.go modules/cabana/http.go` prints at least one line
- `grep -n "postcard.SyncIndex" modules/cabana/*.go | grep -v _test` prints at least one line
- `grep -n "category_mail" modules/phrasebook/backend/lang/en/lang.yaml modules/phrasebook/backend/lang/pl/lang.yaml` prints two lines
- `grep -rn "manage_mail" modules/cabana/*.go | grep -v _test | grep -n "Roles:"` prints nothing
- `grep -n "system.manage_mail_templates" docs/backend/users-and-permissions.md modules/cabana/README.md` prints at least two lines
</acceptance_criteria>
<done>An administrator can find every file template in Settings → Mail templates, save an override that real sends use, and is refused unsafe content and the wrong permission.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Layouts and partials screens, reset to the file version, custom layouts with the in-use delete refusal, drift states and the File version diff</name>
<reversibility rating="costly">D-07: the drift states read base_hash and file_hash columns; D-08: the custom-layout code is what template overrides store.</reversibility>
<files>modules/cabana/mail_admin.go, modules/cabana/mail_diff.go, modules/cabana/backend_screens.go, modules/cabana/query.go, modules/cabana/messages.go, modules/cabana/relation_field.go, modules/cabana/crud.go, modules/cabana/backendscreens/controllers/mailtemplates/config_form.yaml, modules/cabana/backendscreens/controllers/mailtemplates/config_list.yaml, modules/cabana/backendscreens/controllers/mailtemplates/config_filter.yaml, modules/cabana/backendscreens/controllers/mailtemplates/_info.htm, modules/cabana/backendscreens/controllers/mailtemplates/_file_version.htm, modules/cabana/backendscreens/controllers/maillayouts/config_form.yaml, modules/cabana/backendscreens/controllers/maillayouts/config_list.yaml, modules/cabana/backendscreens/controllers/maillayouts/config_filter.yaml, modules/cabana/backendscreens/controllers/maillayouts/_info.htm, modules/cabana/backendscreens/controllers/maillayouts/_file_version.htm, modules/cabana/backendscreens/controllers/mailpartials/config_form.yaml, modules/cabana/backendscreens/controllers/mailpartials/config_list.yaml, modules/cabana/backendscreens/controllers/mailpartials/config_filter.yaml, modules/cabana/backendscreens/controllers/mailpartials/_info.htm, modules/cabana/backendscreens/controllers/mailpartials/_file_version.htm, modules/cabana/backendscreens/models/mailtemplate/fields.yaml, modules/cabana/backendscreens/models/maillayout/fields.yaml, modules/cabana/backendscreens/models/maillayout/columns.yaml, modules/cabana/backendscreens/models/mailpartial/fields.yaml, modules/cabana/backendscreens/models/mailpartial/columns.yaml, modules/cabana/mail_screens_smoke_test.go, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, modules/cabana/README.md, docs/backend/lists-and-filters.md, docs/services/mail.md</files>
<read_first>
- modules/cabana/mail_admin.go and mail_screens_smoke_test.go (Task 1 output)
- modules/cabana/backend_users_admin.go (~178-200 ListRowStates, ~199 FormBeforeDelete, ~325 AdminRecordActions, ~422 PartialData) and modules/cabana/backendscreens/controllers/users/config_filter.yaml, _hint.htm
- modules/cabana/partial_render.go (~105-118 allowlist) and modules/cabana/account_tokens.go (~200-230 backendMessageParams)
- modules/cabana/query.go (~160-200), modules/cabana/messages.go (~35-70, ~150-170), modules/cabana/relation_field.go (~95-120), modules/cabana/crud.go (show response that fills RecordMeta.Actions)
- .planning/phases/35-mail-templates-and-layouts-editor/35-UI-SPEC.md (C5, C6, C7, C8 items 1-2, C9, C15, Copywriting "Layout form", "Partial form", "Info partial and drift notice", "Record actions and destructive confirmations")
</read_first>
<behavior>
- Layouts list shows postcard's default first, then by name, including system, minimal, the fixture plugin layout and database-only layouts (source shown as Custom); partials list shows the four built-ins and the fixture plugin partial
- Creating a layout with code `promo-2026` and the default layout content succeeds (is_custom true, source NULL); code `Promo 2026` gives 422 mail.layout.code_format on code; a taken code gives mail.layout.code_taken
- Saving a file layout's HTML without {{ .Content }} gives 422 on content_html with the no_content text; saving a valid change marks it customized and the next send uses it
- A template override pointing at the custom layout, then DELETE of that layout: 403 whose message names the layout and the count 1 (CLDR one form); after resetting the template, the delete succeeds; DELETE of a file layout, a template or a partial is 403
- Record action reset on a customized template restores the file subject, body and layout, clears is_custom, and the record's meta.actions no longer offers reset; reset is not offered on a not-customized row nor on a custom layout
- meta.deletable is false for templates, partials and file layouts, true for custom layouts, and absent for every existing controller
- After changing the fixture file's bytes and re-syncing, the customized row carries row states [customized, outdated], the status filter `outdated` returns it, `_info` contains the drift callout and `_file_version` contains summer-diff__add and summer-diff__del lines; saving the row again clears outdated
- lineDiff with 3 lines of context returns the expected add/del/ctx sequence for a small pair and returns no diff (content block only) above the line cap
</behavior>
<action>
Per D-06 (as amended by D-23), D-07, D-08 and D-12 (admins override and reset partials):
- Models `MailLayout` (full columns) and `MailPartial`; controllers `mailLayoutsAdmin` (ConfigDir controllers/maillayouts, RequiredPermissions layouts code) and `mailPartialsAdmin` (controllers/mailpartials, layouts code), with the same catalog-membership query scoping (database-only layouts always included), stamping in FormBeforeUpdate and postcard validation in FormAfterUpdate (KindLayout with Name = code for file layouts and the custom code otherwise; KindPartial) as Task 1. Layouts: create allowed only for custom layouts (FormBeforeCreate checks the code against `^[a-z0-9-]+$` and uniqueness with mail.layout.code_format / code_taken, sets source NULL and is_custom true); code is create-only (hidden and not writable on update through schemaFieldHider and the update rules); default sort default layout first, then name. Partials: no create, no delete.
- Record action `reset` on all three controllers (Label `backend::lang.mail.reset`, Confirm the kind's `mail.<kind>.reset_confirm`, Danger true, Permissions the controller's code, Applies when the row is file-backed and is_custom) whose Run calls postcard.ResetOverride on the action transaction and returns Message `backend::lang.mail.reset_done`. The templates, layouts and partials config_form.yaml declare `recordActions: [reset]`.
- Delete: FormBeforeDelete refuses file-backed rows with ForbiddenError, and a custom layout still referenced by template rows with a ForbiddenError whose Message is `backend::lang.mail.layout.delete_in_use` translated with :name and the CLDR plural for :count (UI-SPEC C15 note: never a ValidationError). The FK ON DELETE RESTRICT stays as the backstop.
- `meta.deletable`: add `Deletable *bool` with json `deletable,omitempty` to RecordMeta and fill it in the show response only for controllers implementing a new unexported cabana interface (mail controllers): false for templates, partials and file layouts, true for custom layouts.
- Row states: rowStateOrder becomes deleted, negative, disabled, customized, outdated; messages.go gains RowStateCustomized / RowStateOutdated (yaml `rowStateCustomized`, `rowStateOutdated`, defaults `backend::lang.messages.list.row_state_customized` / `row_state_outdated`, json keys in the list messages schema); the mail controllers' ListRowStates give customized for is_custom file-backed rows and also outdated when base_hash differs from file_hash; the three config_list.yaml files set `rowStateCustomized: backend::lang.mail.state_customized` and `rowStateOutdated: backend::lang.mail.state_outdated`.
- Filters (config_filter.yaml, scope type with options as the users `trashed` filter): templates `locale` (default plus one option per locale value found, through pact.FilterOptions) and `status` (customized, not_customized, outdated); layouts and partials `status`. Outdated SQL per P35-01.
- Partials `_info.htm` and `_file_version.htm` for each controller (allowlisted tags only; UI-SPEC C7 content and order; the partial usage line shows the exact P35-02 call, a bare name for postcard built-ins and the full name otherwise; variables line lists the file's sample names), fed by PartialData with curated view models (never a GORM model). `mail_diff.go`: `lineDiff(a, b []string, context int) []diffLine` (stdlib LCS, cap 2000 lines per side, above which only the file content block is shown); both sides serialized with postcard.FormatTemplate / FormatLayout / FormatPartial; the content block shows the catalog Source.
- Template fields.yaml gains `_info` (partial, first, full) and `_file_version` (partial, tab `backend::lang.mail.tab_file_version`, context update); layouts and partials fields.yaml per UI-SPEC C5/C6, using `type: textarea` for content_html, content_text and content_css in this task (Task 3 switches them to codeeditor).
- Copy en and pl for every key used: `mail.layout.*`, `mail.partial.*`, `mail.name_field`, `mail.source`, `mail.source_custom`, `mail.customized_at`, the filter and state keys, `mail.info_*`, `mail.drift_*`, `mail.file_*`, `mail.reset`, `mail.reset_done`, the reset confirms, `mail.layout.delete_in_use` (CLDR maps), `mail.invalid_*`, `mail.layout.code_*`, `messages.list.row_state_customized` / `row_state_outdated`.
- Write the behavior subtests first in mail_screens_smoke_test.go (`layouts`, `partials`, `reset`, `delete`, `drift`, `deletable`, `diff`), then implement.
- Docs in the same commit: cabana README (layouts and partials screens, reset, custom layouts, RecordMeta.Deletable, the two new row states), docs/backend/lists-and-filters.md "Row state" (customized and outdated), docs/services/mail.md (layouts, partials, reset and the drift notice in the admin).
Commit as listed in Execution units.
</action>
<verify>
<automated>go test ./modules/cabana -run 'TestMailScreensSmoke' -count=1 -v 2>&1 | grep -E '^\s*(--- |FAIL|ok|PASS)' | tail -30</automated>
<fails_when>a "FAIL" line, a "--- SKIP" line, "no tests to run", or no "--- PASS: TestMailScreensSmoke/drift" or "--- PASS: TestMailScreensSmoke/delete" line</fails_when>
<automated>go vet ./... && go test -short ./... && go test ./modules/cabana -count=1 2>&1 | tail -5</automated>
<fails_when>non-zero exit or a "FAIL" line</fails_when>
<automated>go test ./cmd/summer -run 'TestCommandsDocumented|TestDocsTree' -count=1 2>&1 | tail -3 && go run ./cmd/summer docs:build --check 2>&1 | tail -5</automated>
<fails_when>non-zero exit, or a "FAIL" or "unknown identifier" line in the output</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "pact.RowStateCustomized, pact.RowStateOutdated" modules/cabana/query.go` prints one line
- `grep -n 'json:"deletable,omitempty"' modules/cabana/relation_field.go` prints one line
- `grep -n "postcard.ResetOverride" modules/cabana/mail_admin.go` prints at least one line
- `grep -n "func lineDiff(" modules/cabana/mail_diff.go` prints one line
- `grep -c "recordActions: \[reset\]" modules/cabana/backendscreens/controllers/mail*/config_form.yaml | grep -c ':1'` prints 3
- `grep -n "delete_in_use" modules/phrasebook/backend/lang/pl/lang.yaml -A 5 | grep -E "few|many"` prints at least two lines
</acceptance_criteria>
<done>Admins can customize, reset and review drift on templates, layouts and partials, create and safely delete their own layouts, and every list shows which records differ from the files.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: New field types on the server, the Branding page and the mail image library with absolute URLs</name>
<reversibility rating="costly">D-09: `codeeditor` (and colorpicker, mailimage, mailpreview) become published field type names in fields.yaml that plugins may start using.</reversibility>
<files>modules/cabana/field_mail.go, modules/cabana/form_schema.go, modules/cabana/crud.go, modules/cabana/settings.go, modules/cabana/mail_admin.go, modules/cabana/mail_routes.go, modules/cabana/http.go, modules/cabana/backend_screens.go, modules/cabana/admin_openapi.go, modules/cabana/backendscreens/models/mailtemplate/fields.yaml, modules/cabana/backendscreens/models/maillayout/fields.yaml, modules/cabana/backendscreens/models/mailpartial/fields.yaml, modules/cabana/backendscreens/models/mailbrandsetting/fields.yaml, modules/cabana/backendscreens/models/mailimagelibrary/fields.yaml, modules/cabana/backendscreens/controllers/mailimages/config_form.yaml, modules/cabana/mail_screens_smoke_test.go, modules/cabana/phase10_coverage_test.go, modules/cabana/openapi_conformance_test.go, modules/cabana/security_coverage_test.go, modules/cabana/token_routes_test.go, admin/openapi/admin.json, admin/src/api/schema.d.ts, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, modules/cabana/README.md, docs/backend/forms.md, docs/backend/settings.md</files>
<read_first>
- modules/cabana/form_schema.go (~20-60 and the per-type key checks), modules/cabana/crud.go (~1510-1540 scalarFormField, valueFormField), modules/cabana/settings.go (~70-140, ~200-280)
- modules/cabana/field_file.go and modules/cabana/field_type_upload.go (how fileupload attaches to a record: attachment_type, field, is_public, limits), modules/cabana/singleton.go (~41-80)
- modules/cabana/example_settings_test.go (settings registration example) and modules/cabana/admin_openapi.go (swag block style, ~460-480)
- modules/lagoon/attach/store.go (~60-125 Upload, Limits, Store) and modules/lagoon/attach/absolute.go (35-01)
- scripts/check-admin-openapi.sh
- .planning/phases/35-mail-templates-and-layouts-editor/35-UI-SPEC.md (C1, C2, C4 permission, C11, C12, C13, C14, C15, Copywriting "Image picker and image fields", "Code editor and colour picker", "Branding page")
</read_first>
<behavior>
- A fields.yaml with `type: codeeditor` and `language: html` compiles; `language: php` or a missing language is a boot error naming controller, field and value; `mailImages: true` is accepted on markdown and codeeditor and is a boot error elsewhere; colorpicker with `availableColors` is a boot error (unknown key)
- The form schema JSON carries `language` and `mailImages` for those fields; codeeditor, colorpicker and mailimage values bind to their columns and round-trip through PUT
- GET /settings/mail_brand on a fresh database returns exists false with the UI-SPEC Winter defaults; PUT with `button_primary_bg: "#D66829"` stores `#d66829` and creates row 1; PUT with `red` gives 422 colorpicker.invalid on that field; PUT with a logo_file_id that is not a library image gives 422 mailimage.missing; before the first PUT a send still uses mail.brandCss and after it the compiled CSS
- GET /mail/images as a templates-only holder lists library images with absolute URLs and meta.canUpload false; as a layouts holder canUpload is true; POST /mail/images as a templates-only holder is 403; as a layouts holder with a PNG it is 201 with an absolute URL and the image is attached to the library row; a non-image upload is 422; with neither storage.uploads.public_url nor app.url configured both routes answer with the mail.images.no_public_url message; GET /mail/images/{id} of an image outside the library is 404
- The Mail images singleton form opens for a layouts holder and shows the images fileupload; it is 403 for a templates-only holder
</behavior>
<action>
Per D-09, D-10, D-11, D-18, D-19, D-20 (server half) and D-24:
- `form_schema.go`: add `codeeditor`, `colorpicker`, `mailimage`, `mailpreview` to formFieldTypes and `language`, `mailImages` to formFieldKeys (mode and size exist); `field_mail.go` holds the checks: codeeditor language in html|css|text (required), mailImages boolean only on markdown and codeeditor, mailpreview mode in template|layout|partial|branding (required), and serializes `language` and `mailImages` into the field schema JSON. `crud.go` scalarFormField also returns true for codeeditor, colorpicker and mailimage; mailpreview is valueless (never bound, never accepted in a save body). `settings.go`: colorpicker, mailimage and mailpreview are allowed on settings forms (not in the refusal list); mailpreview is skipped from Writable.
- Switch the layout and partial content fields to codeeditor per UI-SPEC C5/C6 (html giant with mailImages, text huge, css huge with mailImages; partial html huge with mailImages, text large) and the template body to `mailImages: true`. Inject the create defaults of the layouts form (content_html, content_text, content_css) from the editor's catalog default layout into the compiled form schema at Activate.
- Branding: model `MailBrandSetting` (TableName postcard.BrandTable, the 13 colour columns and logo_file_id, Fillable all of them, Rules all nullable) with a BeforeValidate that lower-cases colours, refuses anything but `^#[0-9a-f]{6}$` or empty with a ValidationError on that field (`backend::lang.colorpicker.invalid`) and checks logo_file_id is null or an image in the library (`backend::lang.mailimage.missing`); settings item `mail_brand` (Label `backend::lang.mail.brand.menu_label`, Description `..._description`, category mail, icon palette, order 230, Permissions layouts code, Form `models/mailbrandsetting/fields.yaml`, NewModel); fields.yaml per UI-SPEC C12 (logo mailimage full, colours colorpicker in the row order with the Winter defaults as `default:`; the `_preview` mailpreview arrives in Task 4).
- Image library: model `MailImageLibrary` (TableName postcard.ImageLibraryTable) with an attachMany `images` relation, public; controller `mailImagesAdmin` implementing pact.AdminSingleton (first-or-create, as singleton.go), ConfigDir controllers/mailimages, RequiredPermissions layouts code, form fields.yaml with `images` fileupload mode image, useCaption true, fileTypes jpg,jpeg,png,gif,webp, maxFilesize 5 MB (planner's value; within http.body_limits.upload_bytes); settings link `mail_images` (icon image, order 240, layouts code, Controller MailImagesControllerID). Also add the settings links `mail_layouts` (icon layout-grid, order 210) and `mail_partials` (icon puzzle, order 220), both layouts code.
- `mail_routes.go` image routes (backend group, auth gate as markdownPreview): `GET /mail/images` (query q, page; 40 per page; filename or title match; newest first; allowed for either code via Allows; item `AdminMailImage{ID, FileName, Title, URL, ThumbURL}` with URL from attach.AbsolutePublicURL on the blob key; meta `canUpload` = Allows(layouts), total, page, perPage), `GET /mail/images/{id}` (404 outside the library), `POST /mail/images` (requireAjax, layouts code only, multipart `file`, attach.Store with Public true and image limits, then attached to the library row as the fileupload field does; 201 with the item). When attach.ErrNoAbsoluteURL, answer 409 with the localized `mail.images.no_public_url` message. swag annotations for all three in admin_openapi.go; run `scripts/check-admin-openapi.sh` to regenerate admin/openapi/admin.json and admin/src/api/schema.d.ts; add the routes and handlers to the four route inventories.
- Copy en and pl: `mail.brand.*`, `mail.images.*` (including no_public_url), `codeeditor.*`, `colorpicker.*`, `mailimage.*`, `mail.picker.*`.
- Write the behavior subtests first (`fields`, `branding`, `images`, `images_permissions`) plus field-schema boot cases, then implement.
- Docs in the same commit: cabana README (field types table rows, Branding, image library routes), docs/backend/forms.md "Field types" and "Field options" rows for codeeditor (language, size, mailImages), colorpicker, mailimage and mailpreview (mode), docs/backend/settings.md (the Mail section entries; colorpicker, mailimage and mailpreview on settings forms; Branding precedence over mail.brandCss).
Commit as listed in Execution units.
</action>
<verify>
<automated>go test ./modules/cabana -run 'TestMailScreensSmoke' -count=1 -v 2>&1 | grep -E '^\s*(--- |FAIL|ok|PASS)' | tail -30</automated>
<fails_when>a "FAIL" line, a "--- SKIP" line, "no tests to run", or no "--- PASS: TestMailScreensSmoke/branding" or "--- PASS: TestMailScreensSmoke/images_permissions" line</fails_when>
<automated>scripts/check-admin-openapi.sh --check</automated>
<fails_when>non-zero exit (admin.json or schema.d.ts differs from a fresh generation)</fails_when>
<automated>go vet ./... && go test -short ./... && go test ./modules/cabana -count=1 2>&1 | tail -5</automated>
<fails_when>non-zero exit or a "FAIL" line</fails_when>
<automated>go test ./cmd/summer -run 'TestCommandsDocumented|TestDocsTree' -count=1 2>&1 | tail -3 && go run ./cmd/summer docs:build --check 2>&1 | tail -5</automated>
<fails_when>non-zero exit, or a "FAIL" or "unknown identifier" line in the output</fails_when>
</verify>
<acceptance_criteria>
- `grep -n '"codeeditor": {}' modules/cabana/form_schema.go` and `grep -n '"mailImages": {}' modules/cabana/form_schema.go` each print one line
- `grep -n '/mail/images' admin/openapi/admin.json` prints at least two lines
- `grep -n 'mail/images' modules/cabana/security_coverage_test.go modules/cabana/openapi_conformance_test.go` prints at least two lines
- `grep -n "type: codeeditor" modules/cabana/backendscreens/models/maillayout/fields.yaml` prints three lines
- `grep -n "type: colorpicker" modules/cabana/backendscreens/models/mailbrandsetting/fields.yaml | wc -l` prints 13
- `grep -n "codeeditor" docs/backend/forms.md` prints at least one line
</acceptance_criteria>
<done>Layouts and partials have a real code-editor field on the server, Branding is a settings page that switches mail to its colours once saved, and the image library hands out absolute URLs under the agreed permission split.</done>
</task>
<task type="auto" tdd="true">
<name>Task 4: Preview and test send: the draft renders through postcard and goes only to the signed-in administrator, with a per-admin limit</name>
<files>modules/cabana/mail_routes.go, modules/cabana/http.go, modules/cabana/admin_openapi.go, modules/cabana/backendscreens/models/mailtemplate/fields.yaml, modules/cabana/backendscreens/models/maillayout/fields.yaml, modules/cabana/backendscreens/models/mailpartial/fields.yaml, modules/cabana/backendscreens/models/mailbrandsetting/fields.yaml, modules/cabana/mail_screens_smoke_test.go, modules/cabana/phase10_coverage_test.go, modules/cabana/openapi_conformance_test.go, modules/cabana/security_coverage_test.go, modules/cabana/token_routes_test.go, modules/surf/serve.go, modules/surf/README.md, admin/openapi/admin.json, admin/src/api/schema.d.ts, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, modules/cabana/README.md, docs/services/mail.md, docs/services/rate-limiting.md</files>
<read_first>
- modules/cabana/field_markdown.go (~50-80 the route to mirror) and modules/cabana/http.go (route group, interactiveOnly)
- modules/cabana/mail_routes.go (Task 3 output)
- modules/surf/serve.go (~224-245 installCooler) and modules/cooler/cooler.go (~60-75 Attempter)
- modules/surf/limiter.go (how surf answers 429 with Retry-After)
- modules/postcard/editor.go (Preview, SendDraft, DraftError)
- .planning/phases/35-mail-templates-and-layouts-editor/35-UI-SPEC.md (C10, C15, Copywriting "Preview and test send")
</read_first>
<behavior>
- POST /mail/preview mode template with id and values {subject, body, layout} returns subject, html and text with sample placeholders; the html contains no script and is what Editor.Preview returned; a draft with an unknown partial returns 422 with details field, rule partial and message
- Mode layout with id 0 renders a new custom layout draft; mode partial renders the sample call; mode branding renders the sample message with the draft colours
- A templates-only holder gets 403 on modes layout, partial and branding and 200 on template; a layouts-only holder gets 403 on template
- POST /mail/test-send as an administrator with email ada@example.test sends one message whose only recipient is ada@example.test (Cc and Bcc empty) and returns that email; a body with a `to`, `email` or `recipient` key is refused before sending; a personal API token request is refused (interactive only)
- The sixth test send within 10 minutes for the same administrator returns 429 with a Retry-After header; another administrator is not limited; the limit uses a published cooler.Attempter when present
- A driver failure returns 5xx with the fixed message and no driver error text
</behavior>
<action>
Per D-16 and D-17:
- `mail_routes.go`: `AdminMailPreviewRequest{Mode string; ID uint64; Values map[string]any}` and `AdminMailPreviewResult{Subject, HTML, Text string}`; handler `mailPreview` (`POST /mail/preview`, requireAjax) with the markdownPreview auth gate, the mode's permission through Allows (template: templates code; layout, partial, branding: layouts code), the record loaded by ID through the controller's FormExtendQuery scope when ID > 0 (its code is the Draft Name; a custom layout create uses ID 0), the values mapped to a postcard Draft (template: subject, body and the `layout` relation value, the selected layout row id, resolved to that row's code; layout: content_html, content_text, content_css, name; partial: content_html, content_text; branding: the colour columns and logo_file_id → absolute URL), then Editor.Preview; a *DraftError answers 422 in the existing error envelope with details `field`, `rule` and `message` and the translated `mail.invalid_<rule>` text as the message; any other error is 500 with a fixed message. `AdminMailTestSendResult{Email string}`; handler `mailTestSend` (`POST /mail/test-send`, interactiveOnly and requireAjax) taking the same request decoded with unknown fields refused, modes template, layout and partial only, the same permission as the mode, the recipient read from the BackendUser row of principal.ID (never from the body), the limit checked before rendering (5 per 10 minutes, key `mail_test:<adminID>`; 429 with Retry-After in seconds as surf does), Editor.SendDraft to that address; a driver error answers 502 with the fixed message `backend::lang.mail.preview.test_failed`.
- Rate-limit store: cabana looks up `cooler.Attempter` from the app per request and falls back to a small in-process fixed-window limiter it owns. `modules/surf/serve.go` installCooler also publishes the Redis-backed store as `cooler.Attempter` (one Publish next to the Store publish), documented in modules/surf/README.md and docs/services/rate-limiting.md.
- YAML: add the mailpreview fields per UI-SPEC: template `_preview` (mode template, label `backend::lang.mail.preview.label`) after body; layouts `_preview_html`, `_preview_text`, `_preview_css` (mode layout) at the end of each tab; partials `_preview_html`, `_preview_text` (mode partial); Branding `_preview` (mode branding, full, comment `backend::lang.mail.brand.preview_comment`).
- swag annotations for both routes in admin_openapi.go (200, 401, 403, 422, 429 for test-send, 502); run `scripts/check-admin-openapi.sh`; add both routes to the four route inventories.
- Copy en and pl: the remaining `mail.preview.*` keys.
- Write the behavior subtests first (`preview`, `preview_permissions`, `test_send`, `test_send_limit`, `test_send_recipient`), then implement.
- Docs in the same commit: cabana README (both routes, the recipient rule, the limit), docs/services/mail.md (preview and test send from the admin; sample values; the limit).
Commit as listed in Execution units.
</action>
<verify>
<automated>go test ./modules/cabana -run 'TestMailScreensSmoke' -count=1 -v 2>&1 | grep -E '^\s*(--- |FAIL|ok|PASS)' | tail -30</automated>
<fails_when>a "FAIL" line, a "--- SKIP" line, "no tests to run", or no "--- PASS: TestMailScreensSmoke/test_send_limit" or "--- PASS: TestMailScreensSmoke/test_send_recipient" line</fails_when>
<automated>scripts/check-admin-openapi.sh --check</automated>
<fails_when>non-zero exit</fails_when>
<automated>go vet ./... && go test -short ./... && go test ./modules/cabana ./modules/surf -count=1 2>&1 | tail -6</automated>
<fails_when>non-zero exit or a "FAIL" line</fails_when>
<automated>go test ./cmd/summer -run 'TestCommandsDocumented|TestDocsTree' -count=1 2>&1 | tail -3 && go run ./cmd/summer docs:build --check 2>&1 | tail -5</automated>
<fails_when>non-zero exit, or a "FAIL" or "unknown identifier" line in the output</fails_when>
</verify>
<acceptance_criteria>
- `grep -n 'g.Post("/mail/test-send", interactiveOnly(requireAjax(' modules/cabana/http.go` prints one line
- `grep -n "type AdminMailPreviewRequest struct" modules/cabana/mail_routes.go` prints one line and that struct has no field named To, Email or Recipient (`go doc ./modules/cabana AdminMailPreviewRequest` lists only Mode, ID and Values)
- `grep -n "cooler.Attempter" modules/surf/serve.go modules/cabana/mail_routes.go` prints at least two lines
- `grep -n '/mail/preview\|/mail/test-send' admin/openapi/admin.json` prints at least two lines
- `grep -c "type: mailpreview" modules/cabana/backendscreens/models/maillayout/fields.yaml` prints 3
</acceptance_criteria>
<done>Every mail form can preview its unsaved draft through postcard and send a test only to the editing administrator, within a per-admin limit.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| admin browser → cabana mail routes | authenticated administrators with varying permission grants post drafts, uploads and test sends |
| personal API tokens → admin API | token principals reach the generic admin routes |
| cabana → postcard Editor → mail driver | drafts become real outbound mail on test send |
| uploads → public disk | administrator files become public URLs linked from mail |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-35-09 | Elevation of privilege | mail controllers and routes | high | mitigate | RequiredPermissions per controller; Allows per preview mode, test send and image upload; codes declared with no Roles (TestMailScreensSmoke/templates, preview_permissions, images_permissions) |
| T-35-10 | Elevation of privilege | picker upload | medium | mitigate | upload needs system.manage_mail_layouts; list allows either code; canUpload computed on the server |
| T-35-11 | Abuse (spam) | POST /mail/test-send | high | mitigate | recipient read from the administrator's own BackendUser row, unknown body keys refused, interactive sessions only, 5 per 10 minutes per admin through cooler.Attempter (TestMailScreensSmoke/test_send_recipient, test_send_limit) |
| T-35-12 | Tampering | override saves | high | mitigate | FormAfterUpdate runs postcard Editor.Validate inside the save transaction; a ValidationError rolls back (research Pitfall 6) |
| T-35-13 | Tampering | brand colours | high | mitigate | BeforeValidate refuses anything but #rrggbb before the row is written; postcard re-validates at compile |
| T-35-14 | Information disclosure | 422 and 5xx answers | medium | mitigate | 422 carries postcard's DraftError text only; driver and database errors map to fixed messages |
| T-35-15 | Tampering | uploads to the public disk | medium | mitigate | attach.Store with Image true, the jpg/jpeg/png/gif/webp allow-list and a 5 MB limit; public flag set explicitly |
| T-35-16 | Repudiation | override changes | low | accept | customized_at and updated_at record when; per-change author history is out of scope (no audit log exists for other admin records either) |
| T-35-17 | Denial of service | data loss through delete | medium | mitigate | file rows never deletable; in-use custom layouts refused with a count; FK ON DELETE RESTRICT backstop |
| T-35-18 | Spoofing | CSRF on new POST routes | high | mitigate | requireAjax on every POST and the existing admin CSRF/cookie guard; token principals refused on test send |
| T-35-SC | Tampering | npm/pip/cargo installs | high | accept | this plan installs no package |
</threat_model>
<verification>
- `go vet ./... && go test -short ./...` and `go test ./modules/cabana ./modules/surf ./modules/postcard/...` green after every commit.
- `scripts/check-admin-openapi.sh --check` passes after Tasks 3 and 4.
- `go test ./cmd/summer -run 'TestCommandsDocumented|TestDocsTree'` and `go run ./cmd/summer docs:build --check` pass.
</verification>
<success_criteria>
- Five Settings → Mail screens exist behind two permissions with no default grants.
- Override, reset, custom layouts, drift and the File version diff work over postcard's tables.
- Branding, the image library and the four field types exist on the server; preview and test send are permission-checked, recipient-fixed and rate-limited.
- OpenAPI and schema.d.ts regenerated; README and docs updated in the same commits; nothing pushed or tagged.
</success_criteria>
<output>
Create `.planning/phases/35-mail-templates-and-layouts-editor/35-02-SUMMARY.md` when done; list every route, request/response type and field schema key exactly as shipped (35-03 builds against them).
</output>
## Artifacts this phase produces
- Constants: `cabana.PermissionManageMailTemplates`, `cabana.PermissionManageMailLayouts`, `cabana.MailTemplatesControllerID`, `cabana.MailLayoutsControllerID`, `cabana.MailPartialsControllerID`, `cabana.MailImagesControllerID`.
- Types: `cabana.MailTemplate`, `cabana.MailLayout`, `cabana.MailPartial`, `cabana.MailBrandSetting`, `cabana.MailImageLibrary`, `cabana.AdminMailPreviewRequest`, `cabana.AdminMailPreviewResult`, `cabana.AdminMailTestSendResult`, `cabana.AdminMailImage`; field `cabana.RecordMeta.Deletable`.
- Routes: `POST /mail/preview`, `POST /mail/test-send`, `GET /mail/images`, `GET /mail/images/{id}`, `POST /mail/images` (admin API prefix).
- Controllers: `summercms.cabana.mailtemplates`, `summercms.cabana.maillayouts`, `summercms.cabana.mailpartials`, `summercms.cabana.mailimages`; record action `reset`; settings entries `mail_templates`, `mail_layouts`, `mail_partials`, `mail_brand`, `mail_images` in category `backend::lang.settings.category_mail`.
- Field types: `codeeditor`, `colorpicker`, `mailimage`, `mailpreview`; field keys `language`, `mailImages` (and `mode` on mailpreview).
- List messages: `rowStateCustomized`, `rowStateOutdated`; row states `customized`, `outdated` now honoured by cabana.
- Services: surf publishes `cooler.Attempter` (Redis rate-limit store).
- Copy: `backend::lang.mail.*`, `codeeditor.*`, `colorpicker.*`, `mailimage.*`, `settings.category_mail`, `permissions.manage_mail_*`, `messages.list.row_state_customized|outdated` (en, pl).
- Generated: `admin/openapi/admin.json`, `admin/src/api/schema.d.ts`; test `TestMailScreensSmoke`.
- Config keys: none new.

View File

@@ -0,0 +1,440 @@
---
phase: 35-mail-templates-and-layouts-editor
plan: 03
type: execute
wave: 3
depends_on: ["35-02"]
files_modified:
- .planning/notes/2026-11-06-admin-code-editor-codemirror.md
- .planning/research/STACK.md
- admin/package.json
- admin/package-lock.json
- admin/src/components/form/registry.ts
- admin/src/components/form/fields/MailPreviewField.vue
- admin/src/components/form/fields/CodeEditorField.vue
- admin/src/components/form/codeeditor/editor.ts
- admin/src/components/form/fields/ColorPickerField.vue
- admin/src/components/form/fields/MailImageField.vue
- admin/src/components/mail/MailImagePicker.vue
- admin/src/components/mail/insertImage.ts
- admin/src/components/form/fields/MarkdownField.vue
- admin/src/components/form/fields/PartialField.vue
- admin/src/views/FormView.vue
- admin/src/views/SettingsFormView.vue
- admin/src/views/ListView.vue
- admin/src/components/list/RowStateBadges.vue
- admin/src/components/list/DataTable.vue
- admin/src/styles/main.css
- admin/src/api/types.ts
- admin/tests/form/mail-smoke.test.ts
- admin/tests/browser/codeeditor-smoke.test.ts
- modules/boardwalk/dist
- docs/backend/admin-spa.md
- docs/backend/forms.md
autonomous: false
requirements: [D-03, D-07, D-08, D-09, D-10, D-11, D-16, D-17, D-18, D-19, D-20, D-21]
estimate:
tokens: 190000
raw_tokens: 190000
tasks: 6
confidence: low
must_haves:
truths:
- "On a template, layout, partial or Branding form, the `mailpreview` field renders the unsaved draft from FORM_VALUES through POST /mail/preview 500 ms after the last change, in an iframe with an empty sandbox attribute and srcdoc bound only to the server's html, with a Plain text tab, the subject row, a sequence guard and the UI-SPEC error states (D-16)"
- "The Send test to me button posts the draft to POST /mail/test-send with no recipient, shows the signed-in administrator's email in its hint and the success toast, and on 429 shows the limit line and stays disabled for Retry-After seconds (D-17)"
- "Layouts and partials are edited in `codeeditor`, a CodeMirror 6 field loaded lazily in its own chunk with html, css or plain-text highlighting, Go template action highlighting, line numbers, bracket matching and history; Tab moves focus out, Mod+S and Mod+Enter still save the form, Esc does not cancel; a load failure falls back to a monospace textarea that still saves (D-09)"
- "The CodeMirror packages are added only after a decision note and a STACK.md row are committed and Jakub has confirmed the versions at a blocking checkpoint, with exact pins and the committed lockfile (CLAUDE.md dependency rule, research Package Legitimacy Audit)"
- "The Insert image button on the template body (markdown with mailImages) and on codeeditor fields with mailImages opens MailImagePicker, which lists the library from the server, uploads only when the server says canUpload, and inserts `![alt](url)`, `<img src=\"url\" alt=\"alt\">`, `url(\"url\")` or the bare URL with the server's absolute URL, escaped per UI-SPEC C4; MarkdownField hides its own preview toggle when mailImages is set (D-18, D-19, D-20, D-03)"
- "The Branding page renders 13 `colorpicker` fields (native swatch plus #rrggbb text) and the `mailimage` logo field (thumbnail, choose, change, remove, missing-image line), and SettingsFormView provides FORM_VALUES and FORM_LOCALE so its preview reads the draft (D-10, D-11)"
- "FormView shows the record's meta.actions (Reset to file version) in the update footer with the danger confirm, hides Delete when meta.deletable is false, and reloads partial fields after every save and record action, so the drift callout and File version diff are never stale; controllers without these keys behave as before (D-06, D-07, D-08)"
- "Lists show the `customized` (neutral outline) and `outdated` (sel tint) badges after the existing three, in that order, with labels from messages.rowStateCustomized and rowStateOutdated; main.css has the `.summer-diff` kit (D-07)"
- "modules/boardwalk/dist is rebuilt from the committed lockfile and passes scripts/check-admin-dist.sh, and the CodeMirror code is in a separate chunk, not in the entry bundle (D-21)"
prohibitions:
- statement: "The SPA never builds or modifies mail HTML: the preview iframe's srcdoc is only the server's rendered html, the iframe has no src and its sandbox attribute is empty."
status: unresolved
verification: judgment
- statement: "The SPA never builds image URLs; it inserts the absolute URL the server returned."
status: unresolved
verification: judgment
- statement: "The `codemirror` meta package (basicSetup) and indentWithTab are not used; no colour token, font, radius or shadow is added to the design system."
status: unresolved
verification: judgment
- statement: "No npm package is installed before the decision note, the STACK.md row and the human checkpoint."
status: unresolved
verification: judgment
- statement: "There is no free-text recipient input anywhere in the mail screens."
status: unresolved
verification: judgment
artifacts:
- path: "admin/src/components/form/fields/MailPreviewField.vue"
provides: "mailpreview control (tracer)"
contains: "sandbox=\"\""
- path: "admin/src/components/form/codeeditor/editor.ts"
provides: "CodeMirror setup, lazy import target"
contains: "Mod-Enter"
- path: "admin/src/components/form/fields/CodeEditorField.vue"
provides: "codeeditor control"
contains: "codeeditor.load_failed"
- path: "admin/src/components/mail/insertImage.ts"
provides: "pure snippet builder"
contains: "export function insertImage"
- path: "admin/src/components/mail/MailImagePicker.vue"
provides: "library picker dialog"
contains: "radiogroup"
- path: ".planning/notes/2026-11-06-admin-code-editor-codemirror.md"
provides: "dependency decision note"
contains: "@codemirror/view"
key_links:
- from: "admin/src/components/form/fields/MailPreviewField.vue"
to: "modules/cabana/mail_routes.go"
via: "api.POST('/mail/preview') and api.POST('/mail/test-send') typed by schema.d.ts"
pattern: "/mail/preview"
- from: "admin/src/components/form/fields/CodeEditorField.vue"
to: "admin/src/components/form/codeeditor/editor.ts"
via: "dynamic import, so CodeMirror stays out of the entry chunk"
pattern: "import\\(.*codeeditor/editor"
- from: "admin/src/components/mail/MailImagePicker.vue"
to: "modules/cabana/mail_routes.go"
via: "GET/POST /mail/images, canUpload from meta"
pattern: "/mail/images"
- from: "admin/src/components/form/registry.ts"
to: "admin/src/components/form/fields/MailPreviewField.vue"
via: "mailpreview registered, valueless and group-labelled"
pattern: "'mailpreview'"
---
## Phase Goal
ROADMAP Goal (not in user-story format; no story is invented): administrators edit every mail the application sends from the admin panel. This plan gives the Vue admin SPA what the generic views cannot render on their own: the live preview and test send, the code editor, the colour and image fields, the image picker, and the small FormView, SettingsFormView and list additions of UI-SPEC C8 and C9, then rebuilds the embedded admin.
Spec-less probe fallback skipped: no SPEC.md and no requirement IDs; must_haves derive from the ROADMAP goal, CONTEXT D-01..D-25 and the approved 35-UI-SPEC.md. Route, type and key names come from 35-02-SUMMARY.md where they differ from this text.
<objective>
Implement UI-SPEC C1-C4, C8-C11 in admin/:
- `mailpreview` with sandboxed srcdoc preview and test send (D-16, D-17);
- `codeeditor` on CodeMirror 6, lazily loaded, after the decision note, the STACK.md row and a blocking version check (D-09; research Package Legitimacy Audit flags @codemirror/view 6.43.15 as too-new);
- `colorpicker`, `mailimage`, MailImagePicker and insertImage, the Insert image button on markdown and codeeditor (D-10, D-18..D-20);
- FormView footer record actions, meta.deletable, partial reload; SettingsFormView FORM_VALUES/FORM_LOCALE; row-state badges; the `.summer-diff` CSS kit (D-06..D-08);
- the rebuilt modules/boardwalk/dist (D-21).
Purpose: with 35-02's server screens, this completes the editor administrators use; nothing here decides permissions, recipients or URLs, which stay on the server.
Output: commits in this repository only; planning docs (decision note, STACK.md) in their own commit, the dist rebuild in its own commit. Nothing is pushed or tagged.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/phases/35-mail-templates-and-layouts-editor/35-UI-SPEC.md
@.planning/phases/35-mail-templates-and-layouts-editor/35-RESEARCH.md
@.planning/phases/35-mail-templates-and-layouts-editor/35-PATTERNS.md
@.planning/phases/35-mail-templates-and-layouts-editor/35-02-SUMMARY.md
@.planning/notes/2026-10-12-admin-display-sanitizer-dompurify.md
@.planning/notes/2026-10-07-embedded-admin-release-gate.md
@CLAUDE.md
<interfaces>
admin/src/components/form/registry.ts: `renderers` Map (type → component), `valueless` Set (RELATION_MANAGER, widget, partial, fileupload), `groupLabelledTypes` Set, `selfLabelled`, `recordBound`; field components import only from `../control` (never from registry.ts) to avoid an import cycle; header comment carries a per-phase history paragraph.
admin/src/components/form/fields/MarkdownField.vue: PREVIEW_DEBOUNCE_MS, the `sequence` guard, `api.POST('/markdown/preview', ...)`, `refusal(error)` details extraction, controlAttributes/controlClass from ../control.
admin/src/components/form/fields/RichEditorField.vue: lazy import of the TipTap editor with a `bg-skel` loading box (`data-*-loading`, aria-busy) and a failure fallback; toolbar class strings.
admin/src/components/form/formContext.ts: FORM_VALUES, FORM_PATCH, FORM_SESSION, FORM_LOCALE injection keys.
admin/src/views/FormView.vue: provides at ~354-417; `singleton`, `backToPreview` (schema.preview) at ~205-214; footer at ~680-727 with Delete (`mode === 'update' && !singleton`) and onDelete (~546).
admin/src/views/SettingsFormView.vue: provides SETTINGS_SECRETS only (~54).
admin/src/components/form/RecordActions.vue (used by PreviewView; `busy` emit, danger confirm); admin/src/components/relation/RelationPickerModal.vue (dialog shell, search input, chosen tile classes); admin/src/components/form/fields/FileuploadField.vue (tile grid, captions).
admin/src/components/list/RowStateBadges.vue:19 `const ORDER = ['deleted', 'negative', 'disabled']`; DataTable.vue:160 `const ROW_STATES = [...]`; ListView.vue:~108 row-state labels from messages.
From 35-02: POST /mail/preview {mode, id, values} → {subject, html, text}, 422 details {field, rule, message}; POST /mail/test-send same body → {email}, 429 + Retry-After, 502; GET /mail/images?q=&page= → {data: [{id, fileName, title, url, thumbUrl}], meta: {canUpload, total, page, perPage}}, 409 with message when no absolute URL is configured; GET /mail/images/{id}; POST /mail/images multipart `file`; form field schema keys `language`, `mailImages`, `mode`; RecordMeta.deletable; list messages rowStateCustomized / rowStateOutdated.
Admin CSP (modules/boardwalk/boardwalk.go ~61-66) has frame-src 'none'; its comment states a sandboxed srcdoc preview still renders, so no CSP change.
</interfaces>
</context>
## Execution units and resume points
- Before each task run `git status --short admin modules/boardwalk/dist` and stop if another session holds uncommitted edits there.
- Each task ends with the commit(s) listed below, staged by explicit path. Resume check: `git log --format=%s -F --grep='<subject>'`.
- Per task: `npm --prefix admin run test` and `npm --prefix admin run typecheck`; the browser suite needs the Playwright Chromium build already in ~/.cache/ms-playwright.
| Task | Commit subject | Resume point |
|------|----------------|--------------|
| 1 | `feat(admin): mail preview field with sandboxed HTML and test send` | mail-smoke preview cases pass |
| 2 | `docs(35): CodeMirror decision note and STACK.md row for the admin code editor` | note and row committed |
| 3 | none (answer recorded in the SUMMARY) | answer recorded |
| 4 | `feat(admin): codeeditor field on CodeMirror 6, loaded lazily` | codeeditor-smoke passes |
| 5 | `feat(admin): colour picker, mail image field and the image picker with insert` | mail-smoke picker cases pass |
| 6 | `feat(admin): footer record actions, deletable flag, mail row states and diff styles`, then `build(admin): rebuild the embedded admin for the mail editor` | check-admin-dist.sh passes |
<tasks>
<task type="tracer">
<name>Task 1: End-to-end "an administrator edits a template body and sees the rendered mail, then sends a test to their own address" — the mailpreview field over the 35-02 routes</name>
<precondition>35-02 is complete: `grep -n '/mail/preview' admin/src/api/schema.d.ts` prints at least one line</precondition>
<files>admin/src/components/form/fields/MailPreviewField.vue, admin/src/components/form/registry.ts, admin/src/views/SettingsFormView.vue, admin/src/api/types.ts, admin/tests/form/mail-smoke.test.ts</files>
<read_first>
- admin/src/components/form/fields/MarkdownField.vue (whole file: debounce, sequence guard, refusal extraction)
- admin/src/components/form/registry.ts (whole file) and admin/src/components/form/formContext.ts
- admin/src/views/SettingsFormView.vue (whole file) and admin/src/views/FormView.vue (~340-420 provides)
- admin/src/components/form/FormTabs.vue (tab pill class strings) and admin/src/state/useToasts.ts
- admin/tests/form/MarkdownField.test.ts and admin/tests/helpers.ts (how fields are mounted with provides and api mocks)
- .planning/phases/35-mail-templates-and-layouts-editor/35-02-SUMMARY.md (exact request and response shapes)
- .planning/phases/35-mail-templates-and-layouts-editor/35-UI-SPEC.md (C10, C15, Copywriting "Preview and test send")
</read_first>
<action>
Per D-16 and D-17 and UI-SPEC C10:
- `MailPreviewField.vue`: reads the whole draft from FORM_VALUES and the record id (0 on create) and posts `{mode: field.mode, id, values}` to `/mail/preview` on mount and 500 ms after the last change, with the MarkdownField sequence guard; while its tab is inactive it sends nothing and renders when the tab becomes active (use the same visibility signal other lazy fields use, or an IntersectionObserver when none exists). Markup and classes exactly per C10: toolbar with the HTML / Plain text switch (FormTabs class strings, role tablist) and the role status line (`mail.preview.rendering`); subject row hidden in branding mode; `<iframe sandbox="" referrerpolicy="no-referrer">` with srcdoc bound only to the html of the latest 2xx answer and the `mail.preview.frame_title` title, white canvas, 640px; the plain-text `pre`; notes (`mail.preview.sample_note` in template mode, `sample_content_note` otherwise, plus the field comment); the error line (`failed` keeping the last render, `failed_first`, `unavailable` on network or 5xx) with :message from the 422 details; the empty-draft state when the mode's content field is blank (no request).
- Test send (not in branding mode): outline small Button with the Send icon and `mail.preview.test_send` / `test_sending`, the hint `mail.preview.test_hint` with :email from the signed-in administrator's session; POST the same payload to `/mail/test-send` (never a recipient); 2xx → toast `mail.preview.test_sent` with the returned email; 429 → inline line `test_limited` with :seconds from Retry-After and the button disabled until then; 422 → inline `test_invalid` with :message; 5xx → danger toast `test_failed`; 403 → danger toast with the server message. No confirm dialog.
- `registry.ts`: register `mailpreview`, add it to `valueless` and `groupLabelledTypes`; add a Phase 35 paragraph to the header history comment. `types.ts`: export any helper types the field needs from schema.d.ts (no hand-written API shapes).
- `SettingsFormView.vue`: `provide(FORM_VALUES, computed(() => values.value))` and `provide(FORM_LOCALE, ...)` as FormView does; no FORM_PATCH or session (UI-SPEC C8 item 4).
- `admin/tests/form/mail-smoke.test.ts` (smoke, happy-dom): mounts MailPreviewField with provided values and a mocked api; asserts the iframe's sandbox attribute is present and empty, there is no src attribute, srcdoc equals the mocked html, a stale answer is dropped by the sequence guard, a 422 after a good render keeps the previous srcdoc and shows the failed line, and a 429 test send disables the button.
Commit as listed in Execution units.
</action>
<verify>
<automated>npm --prefix admin run test -- mail-smoke 2>&1 | tail -8</automated>
<fails_when>non-zero exit, a "FAIL" line, or "No test files found"</fails_when>
<automated>npm --prefix admin run typecheck 2>&1 | tail -3</automated>
<fails_when>non-zero exit or an "error TS" line</fails_when>
<automated>npm --prefix admin run test 2>&1 | tail -6</automated>
<fails_when>non-zero exit or a "FAIL" line</fails_when>
</verify>
<acceptance_criteria>
- `grep -n 'sandbox=""' admin/src/components/form/fields/MailPreviewField.vue` prints one line
- `grep -n "'/mail/test-send'" admin/src/components/form/fields/MailPreviewField.vue` prints one line
- `grep -n "'mailpreview'" admin/src/components/form/registry.ts` prints at least two lines (renderer and valueless or group set)
- `grep -n "provide(FORM_VALUES" admin/src/views/SettingsFormView.vue` prints one line
</acceptance_criteria>
<done>Every mail form shows its unsaved draft exactly as postcard renders it and can send a test to the editing administrator.</done>
</task>
<task type="auto">
<name>Task 2: Write the CodeMirror decision note and the STACK.md row before anything is installed</name>
<files>.planning/notes/2026-11-06-admin-code-editor-codemirror.md, .planning/research/STACK.md</files>
<read_first>
- .planning/notes/2026-10-12-admin-display-sanitizer-dompurify.md (structure to mirror: Decision table, Why, Scope, Legitimacy with integrity values)
- .planning/research/STACK.md (~74-84 "Admin SPA (npm) additions")
- .planning/phases/35-mail-templates-and-layouts-editor/35-RESEARCH.md ("Admin SPA (new npm runtime dependencies)", "Package Legitimacy Audit", "CodeMirror setup", Pitfall 5)
- .planning/phases/35-mail-templates-and-layouts-editor/35-UI-SPEC.md (Design System "Why CodeMirror 6", Registry Safety)
</read_first>
<action>
Per D-09 and the CLAUDE.md dependency rule ("A new dependency needs a research doc or a phase/decision note naming it"):
- Create `.planning/notes/2026-11-06-admin-code-editor-codemirror.md`: Decision table with the seven direct runtime packages and exact pins (@codemirror/state 6.7.6, @codemirror/view 6.43.15, @codemirror/language 6.13.1, @codemirror/commands 6.11.1, @codemirror/lang-html 6.4.12, @codemirror/lang-css 6.3.1, @lezer/highlight 1.2.5), what each is used for, that they load lazily from `admin/src/components/form/codeeditor/editor.ts` in their own chunk, that the meta package with basicSetup is not used, and the keymap rule (defaultKeymap without Mod-Enter, no indentWithTab, research Pitfall 5); Why (MIT, modular, no workers so the CSP is unchanged, accessible contenteditable; Monaco, Ace, textarea overlay and TipTap rejected per UI-SPEC); the transitive packages (the @lezer grammars, @codemirror/autocomplete and @codemirror/lang-javascript via lang-html, style-mod, w3c-keyname, crelt, @marijn/find-cluster-break); Legitimacy: the research audit table (maintainer marijn, no postinstall scripts, weekly downloads, source repositories), the [SUS] too-new flag on @codemirror/view 6.43.15 (published 2026-10-19, past the 7-day cooldown) and the `dist.integrity` value of each pinned version read with `npm view <pkg>@<version> dist.integrity` (read-only registry queries; no install).
- `.planning/research/STACK.md` "Admin SPA (npm) additions": one row per direct package (or one grouped CodeMirror row listing all seven pins) with purpose "codeeditor field (Phase 35)" and a link to the note.
Commit both as one planning-docs commit, separate from code (CLAUDE.md), as listed in Execution units.
</action>
<verify>
<automated>grep -c "@codemirror/" .planning/notes/2026-11-06-admin-code-editor-codemirror.md && grep -n "codemirror" .planning/research/STACK.md | head -5</automated>
<fails_when>non-zero exit, a count below 6, or no STACK.md line</fails_when>
<automated>git diff --quiet HEAD -- admin/package.json admin/package-lock.json && echo unchanged</automated>
<fails_when>no "unchanged" line (package files must not change in this task)</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "sha512-" .planning/notes/2026-11-06-admin-code-editor-codemirror.md | wc -l` prints at least 7
- `grep -n "6.43.15" .planning/notes/2026-11-06-admin-code-editor-codemirror.md` prints at least one line mentioning the too-new flag
- `git log --format=%H%x09%s -F --grep='docs(35): CodeMirror decision note and STACK.md row for the admin code editor'` prints one line, and `git show --stat --format= <that sha>` lists only the two planning files
</acceptance_criteria>
<done>The dependency is documented and justified before it exists in the repository.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking-human">
<name>Task 3: Jakub confirms the CodeMirror package versions before npm install (legitimacy gate; never auto-approved)</name>
<what-built>The decision note `.planning/notes/2026-11-06-admin-code-editor-codemirror.md` lists the seven pins and their dist.integrity values. Research flagged `@codemirror/view` 6.43.15 [SUS] as too-new (18 days old on 2026-11-06, past the project's 7-day cooldown, same maintainer as every other package).</what-built>
<how-to-verify>
1. Open https://www.npmjs.com/package/@codemirror/view and check that 6.43.15 is published by marijn, has no install scripts and matches the integrity in the note (or decide to pin 6.43.14 or older).
2. Spot-check https://www.npmjs.com/package/@codemirror/state, /@codemirror/language, /@codemirror/commands, /@codemirror/lang-html, /@codemirror/lang-css and /@lezer/highlight against the note's versions.
3. Confirm that installing exactly these pins into admin/ (runtime dependencies, exact versions, committed lockfile) is approved.
</how-to-verify>
<resume-signal>Reply "approved", or "pin @codemirror/view <version>" (the executor updates the note and STACK.md in a follow-up planning commit before installing), or "stop"</resume-signal>
</task>
<task type="auto" tdd="true">
<name>Task 4: The codeeditor field: CodeMirror 6 loaded lazily, Go template actions highlighted, keyboard rules that keep the form's hotkeys and Tab free</name>
<reversibility rating="costly">D-09: the `codeeditor` type, its `language` and `size` keys and its value shape are a published field type plugins may start using.</reversibility>
<precondition>Task 3 answered "approved" or named a pin, recorded in the plan SUMMARY draft</precondition>
<files>admin/package.json, admin/package-lock.json, admin/src/components/form/codeeditor/editor.ts, admin/src/components/form/fields/CodeEditorField.vue, admin/src/components/form/registry.ts, admin/tests/browser/codeeditor-smoke.test.ts, admin/tests/form/mail-smoke.test.ts</files>
<read_first>
- admin/src/components/form/fields/RichEditorField.vue (lazy import, loading skeleton, failure fallback, toolbar classes)
- admin/src/components/form/control.ts (controlAttributes, controlClass, FieldControlProps)
- admin/vitest.browser.config.ts and admin/tests/browser/richeditor-display.test.ts (browser-mode test shape)
- admin/src/views/FormView.vue (the form hotkey handler and how it ignores plain keys inside contenteditable)
- .planning/notes/2026-11-06-admin-code-editor-codemirror.md (Task 2, as confirmed at Task 3)
- .planning/phases/35-mail-templates-and-layouts-editor/35-UI-SPEC.md (C1, Color "Code editor syntax colours", Spacing "Code editor by size")
- .planning/phases/35-mail-templates-and-layouts-editor/35-RESEARCH.md ("CodeMirror setup", Pitfall 5)
</read_first>
<behavior>
- The control shows the `bg-skel` loading box (aria-busy true) at the size's height until the chunk loads; size tiny/small/large/huge/giant gives 128/192/320/480/640 px, default large
- A failed import shows the `codeeditor.load_failed` note and a monospace textarea bound to the value; editing it emits update:modelValue
- Typing in the editor emits update:modelValue with the full document; the content element has the control id, aria-labelledby, aria-describedby, aria-invalid and aria-multiline true
- Browser: Tab from inside the editor moves focus to the next control; Mod+S and Mod+Enter inside the editor trigger the form save; Esc inside the editor does not trigger Cancel
- language html and css use their grammars, text uses none and wraps lines; `{{ ... }}` spans carry the cm-summer-action class
- Read-only rendering is a `pre` with the text (or list.empty_value) and loads no CodeMirror
</behavior>
<action>
Per D-09 and UI-SPEC C1:
- Install exactly the pins confirmed at Task 3 with `npm --prefix admin install --save-exact` (runtime dependencies), then `npm --prefix admin ci` to prove the lockfile.
- `codeeditor/editor.ts` (the only module that imports CodeMirror): a `createCodeEditor(parent, options)` factory taking the initial doc, language (html | css | text), height, ids/aria attributes and an onChange callback, returning an object with `view`, `insert(text)` (replaces the main selection, or appends at the end when the editor never had focus, then focuses with the caret after the text) and `destroy()`. Extensions: lineNumbers, bracketMatching, history, the html() or css() grammar or none, a MatchDecorator plus ViewPlugin marking Go template actions with class `cm-summer-action`, `EditorView.lineWrapping` only for text, contentAttributes for the aria wiring, a HighlightStyle and EditorView.theme using only the existing `var(--c-*)` variables per the UI-SPEC syntax table, and `keymap.of([...historyKeymap, ...defaultKeymap without the Mod-Enter binding])`; indentWithTab is not added.
- `CodeEditorField.vue`: wrapper and toolbar markup per C1 (the toolbar renders only when mailImages is set; its Insert image button is wired in Task 5 through the editor's insert), dynamic `import('../codeeditor/editor')` so CodeMirror lands in its own chunk, the loading box and failure fallback copied from RichEditorField, read-only `pre` rendering, destroy on unmount, external value changes (record reload after reset) replace the document without emitting.
- `registry.ts`: register `codeeditor`.
- Write first: `admin/tests/browser/codeeditor-smoke.test.ts` (browser mode: Tab leaves, Mod+S and Mod+Enter reach a form submit handler, Esc does not reach Cancel, typing emits the value) and happy-dom cases in mail-smoke.test.ts for the loading box, the failure fallback (mock the dynamic import to reject) and read-only rendering; then implement.
Commit as listed in Execution units.
</action>
<verify>
<automated>npm --prefix admin run test:browser -- codeeditor-smoke 2>&1 | tail -8</automated>
<fails_when>non-zero exit, a "FAIL" line, or "No test files found"</fails_when>
<automated>npm --prefix admin run test -- mail-smoke 2>&1 | tail -6 && npm --prefix admin run typecheck 2>&1 | tail -3</automated>
<fails_when>non-zero exit, a "FAIL" line or an "error TS" line</fails_when>
<automated>npm --prefix admin ci 2>&1 | tail -3</automated>
<fails_when>non-zero exit or an "npm error" line</fails_when>
</verify>
<acceptance_criteria>
- `node -e "const p=require('./admin/package.json');for(const n of ['@codemirror/state','@codemirror/view','@codemirror/language','@codemirror/commands','@codemirror/lang-html','@codemirror/lang-css','@lezer/highlight'])if(!/^\d+\.\d+\.\d+$/.test(p.dependencies[n]||''))process.exit(1)"` exits 0
- `grep -n "import('../codeeditor/editor')" admin/src/components/form/fields/CodeEditorField.vue` prints one line
- `grep -rln "@codemirror/" admin/src | grep -v codeeditor/editor.ts` prints nothing (type-only imports included: editor.ts is the only module that names a CodeMirror package)
<!-- planner-discipline-allow: @codemirror/ -->
- `grep -n "'codeeditor'" admin/src/components/form/registry.ts` prints at least one line
</acceptance_criteria>
<done>Layout and partial HTML, text and CSS are edited in a real code editor that respects the form's keyboard model.</done>
</task>
<task type="auto" tdd="true">
<name>Task 5: Colour picker, mail image field and the image picker that inserts library images into bodies and code editors</name>
<files>admin/src/components/form/fields/ColorPickerField.vue, admin/src/components/form/fields/MailImageField.vue, admin/src/components/mail/MailImagePicker.vue, admin/src/components/mail/insertImage.ts, admin/src/components/form/fields/MarkdownField.vue, admin/src/components/form/fields/CodeEditorField.vue, admin/src/components/form/registry.ts, admin/tests/form/mail-smoke.test.ts</files>
<read_first>
- admin/src/components/relation/RelationPickerModal.vue (dialog shell, search, chosen tile classes) and admin/src/components/form/fields/FileuploadField.vue (thumbnails, captions, upload error display)
- admin/src/components/form/fields/TextField.vue and MarkdownField.vue (textarea selection handling, preview toggle)
- admin/src/components/form/fields/CodeEditorField.vue (Task 4) and admin/src/components/ui/Button.vue
- .planning/phases/35-mail-templates-and-layouts-editor/35-UI-SPEC.md (C2, C3, C4, C11, C12, Copywriting "Image picker and image fields", "Code editor and colour picker")
- .planning/phases/35-mail-templates-and-layouts-editor/35-RESEARCH.md (Pitfall 4: percent-encode URLs with spaces for Markdown)
</read_first>
<behavior>
- insertImage('markdown', 'https://cdn.example.com/a b.png', 'Logo [x]') returns `![Logo \[x\]](https://cdn.example.com/a%20b.png)`; ('html', url, 'a "b" <c>') entity-escapes &, ", < and > in alt and url; ('css', url, alt) returns `url("<url>")`; ('text', url, alt) returns the url
- The picker lists images from GET /mail/images with a 300 ms debounced server search, loads the next page of 40 on scroll, is a radiogroup with arrow-key movement, keeps the primary disabled until a tile is chosen, prefills alt from the caption title in insert mode, and shows the upload row only when meta.canUpload is true; an upload adds the new image first and selected; a 422 upload shows the server message under the row; empty, no-result and load-failed states use the UI-SPEC copy, and a 409 shows the server's message
- The markdown field with mailImages shows Insert image and no Preview toggle; without mailImages it is unchanged; choosing an image inserts the markdown snippet at the textarea selection and the caret lands after it
- A codeeditor with mailImages inserts the html or css snippet through the editor's insert
- ColorPicker: typing `#D66829` updates the swatch and emits the value; a partial value leaves the swatch at the last valid colour; an empty value shows the not-set placeholder and a white swatch
- MailImage: shows the thumbnail and filename for a stored id (GET /mail/images/{id}), Choose opens the picker in choose mode, Remove emits null, a 404 for the stored id shows the mailimage.missing line and the empty icon
</behavior>
<action>
Per D-18, D-19, D-20, D-10 and D-03 (UI-SPEC C2, C3, C4, C11):
- `insertImage.ts`: pure `insertImage(language: 'markdown' | 'html' | 'css' | 'text', url: string, alt: string): string` per the C4 table, percent-encoding spaces and parentheses in Markdown URLs (research Pitfall 4).
- `MailImagePicker.vue`: Reka Dialog with the RelationPickerModal shell, insert and choose modes, search, upload row from `meta.canUpload` only (POST /mail/images, hidden file input with the four image types), the tile grid radiogroup, alt input in insert mode, footer Cancel + primary; never builds a URL, always uses the server's `url`.
- `MarkdownField.vue`: with `mailImages` the Insert image text button replaces the Preview toggle; insertion through insertImage('markdown', ...) at the textarea selection, focus back with the caret after the text, update:modelValue emitted.
- `CodeEditorField.vue`: the toolbar button opens the picker and inserts insertImage(language, ...) through the editor's insert (the fallback textarea uses the selection path).
- `ColorPickerField.vue` per C2 (native swatch with aria-label `colorpicker.pick`, mono hex input with maxlength 7 and the not-set placeholder; no client-side validation beyond the native control). `MailImageField.vue` per C11 (value is the library image id or null, group-labelled).
- `registry.ts`: register `colorpicker` and `mailimage`; add `mailimage` to groupLabelledTypes.
- Write the behavior cases first in mail-smoke.test.ts (insertImage table, picker canUpload both ways, markdown insert and hidden preview toggle, colour sync, mailimage missing), then implement.
Commit as listed in Execution units.
</action>
<verify>
<automated>npm --prefix admin run test -- mail-smoke 2>&1 | tail -8</automated>
<fails_when>non-zero exit, a "FAIL" line, or "No test files found"</fails_when>
<automated>npm --prefix admin run typecheck 2>&1 | tail -3 && npm --prefix admin run test 2>&1 | tail -6</automated>
<fails_when>non-zero exit, an "error TS" line or a "FAIL" line</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "export function insertImage" admin/src/components/mail/insertImage.ts` prints one line
- `grep -n 'role="radiogroup"' admin/src/components/mail/MailImagePicker.vue` prints one line
- `grep -n "canUpload" admin/src/components/mail/MailImagePicker.vue` prints at least one line
- `grep -n "'colorpicker'\|'mailimage'" admin/src/components/form/registry.ts` prints at least two lines
- `grep -n "mailImages" admin/src/components/form/fields/MarkdownField.vue admin/src/components/form/fields/CodeEditorField.vue` prints at least two lines
</acceptance_criteria>
<done>Administrators pick brand colours and the logo, and place library images into template bodies, layouts and partials without typing a URL.</done>
</task>
<task type="auto" tdd="true">
<name>Task 6: FormView footer record actions, the deletable flag, partial reloads, mail row-state badges and the diff kit, then rebuild the embedded admin</name>
<files>admin/src/views/FormView.vue, admin/src/components/form/fields/PartialField.vue, admin/src/views/ListView.vue, admin/src/components/list/RowStateBadges.vue, admin/src/components/list/DataTable.vue, admin/src/styles/main.css, admin/tests/form/mail-smoke.test.ts, modules/boardwalk/dist, docs/backend/admin-spa.md, docs/backend/forms.md</files>
<read_first>
- admin/src/views/FormView.vue (whole file: footer, onDelete, save path, reload, snapshot reset)
- admin/src/views/PreviewView.vue (RecordActions use and the hintKey reload) and admin/src/components/form/RecordActions.vue
- admin/src/components/form/fields/PartialField.vue and the PartialHost it renders
- admin/src/components/list/RowStateBadges.vue, DataTable.vue (~155-195), ListView.vue (~100-115)
- admin/src/styles/main.css (`@layer components`, `.summer-callout` kit)
- scripts/check-admin-dist.sh and .planning/notes/2026-10-07-embedded-admin-release-gate.md
- .planning/phases/35-mail-templates-and-layouts-editor/35-UI-SPEC.md (C7 CSS kit, C8 items 1-3, C9)
</read_first>
<behavior>
- An update form whose record has meta.actions [reset] and no preview screen shows an outline Reset to file version button after Delete in the footer; its confirm is the danger variant; while it runs every footer button is disabled; after a 2xx the mail.reset_done toast shows, values are replaced, the form is clean and the partial field's reloadKey increments; 409 shows the stale toast and reloads; 403 shows a danger toast
- meta.deletable false hides Delete; true or absent shows it (absent = unchanged behaviour for every existing controller)
- A successful save also increments the partial reloadKey
- RowStateBadges renders deleted, negative, disabled, customized, outdated in that order with the customized outline and outdated sel styles; DataTable keeps text-text (no strike-through) for rows with only the new states; ListView passes the rowStateCustomized and rowStateOutdated labels
</behavior>
<action>
Per D-06 (as amended by D-23), D-07 and D-08 (UI-SPEC C7, C8, C9):
- `FormView.vue`: footer record actions from the show response's meta.actions through the existing RecordActions component in the left group after Delete, only on update and only when the controller has no preview screen (the generic rule in C8 item 1); busy disables every footer button; on success toast, reload the record in place and reset the dirty snapshot; Delete hidden when meta.deletable is false; a `reloadKey` provided to partial fields and bumped after every successful save and record action.
- `PartialField.vue`: re-fetch its partial when the provided reloadKey changes (the PreviewView hintKey mechanism).
- Lists: RowStateBadges ORDER and styles, DataTable ROW_STATES (new states do not change the title style), ListView labels.
- `main.css`: the `.summer-diff`, `.summer-diff__add`, `__del`, `__ctx` rules from UI-SPEC C7 in `@layer components`, existing variables only.
- Write the behavior cases first in mail-smoke.test.ts, then implement. Run `npm --prefix admin run test`, `npm --prefix admin run test:browser`, `npm --prefix admin run typecheck` and `scripts/check-admin-openapi.sh --check`, and commit the source change.
- Docs in the same source commit: docs/backend/admin-spa.md (the code editor's keyboard behaviour next to Keyboard shortcuts; the preview and test-send behaviour) and docs/backend/forms.md (how codeeditor, colorpicker, mailimage and mailpreview look and behave in the admin, footer record actions on forms without a preview screen, meta.deletable).
- Then `npm --prefix admin run build`, stage `modules/boardwalk/dist` by path (it is a directory of generated files; `git add modules/boardwalk/dist` is the explicit path), run `scripts/check-admin-dist.sh`, and commit the rebuild separately as listed in Execution units.
</action>
<verify>
<automated>npm --prefix admin run test 2>&1 | tail -6 && npm --prefix admin run test:browser 2>&1 | tail -6 && npm --prefix admin run typecheck 2>&1 | tail -3</automated>
<fails_when>non-zero exit, a "FAIL" line or an "error TS" line</fails_when>
<automated>scripts/check-admin-openapi.sh --check && scripts/check-admin-dist.sh</automated>
<fails_when>non-zero exit (generated types or the committed dist differ from a fresh build)</fails_when>
<automated>go vet ./... && go test -short ./... && go test ./cmd/summer -run 'TestCommandsDocumented|TestDocsTree' -count=1 2>&1 | tail -3 && go run ./cmd/summer docs:build --check 2>&1 | tail -5</automated>
<fails_when>non-zero exit, or a "FAIL" or "unknown identifier" line</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "'customized', 'outdated'" admin/src/components/list/RowStateBadges.vue` prints one line
- `grep -n "deletable" admin/src/views/FormView.vue` prints at least one line
- `grep -n "\.summer-diff__add" admin/src/styles/main.css` prints one line
- `grep -l "cm-summer-action" modules/boardwalk/dist/assets/*.js` prints exactly one path and it is not an `index-*.js` entry chunk (CodeMirror lives in its own lazy chunk next to the richeditor's)
- `git log --format=%s -F --grep='build(admin): rebuild the embedded admin for the mail editor'` prints one line, and `git show --stat --format= <that commit>` lists only paths under modules/boardwalk/dist
</acceptance_criteria>
<done>The admin shows reset, delete and drift exactly where the UI contract puts them, and the binary embeds the finished mail editor.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| server-rendered mail HTML → admin browser | a draft may contain hostile markup that rendered on the server |
| image library entries → editors | file names and captions typed by administrators |
| npm registry → admin build | new third-party code in the admin bundle |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-35-19 | Elevation of privilege (stored XSS) | MailPreviewField | high | mitigate | iframe with an empty sandbox (no scripts, no same-origin, no forms, no top navigation), srcdoc only from a 2xx answer, no src, never v-html; server already ran validateHTML; CSP frame-src unchanged (mail-smoke sandbox case) |
| T-35-20 | Tampering | insertImage snippets | medium | mitigate | alt and url escaped per target (markdown brackets, HTML entities), URLs only from the server (mail-smoke insertImage table) |
| T-35-21 | Tampering (supply chain) | CodeMirror packages | high | mitigate | decision note and STACK row first, blocking human checkpoint (never auto-approved) with integrity values, exact pins, committed lockfile, npm ci; lazy chunk limits the surface to the code editor |
| T-35-22 | Abuse | test-send button | medium | mitigate | no recipient input; 429 disables the button for Retry-After; the server enforces the rule regardless |
| T-35-23 | Information disclosure | picker and preview errors | low | mitigate | only server-provided messages and fixed copy are shown; no stack traces |
| T-35-24 | Denial of service (keyboard trap) | codeeditor | low | mitigate | Tab unbound in the editor (WCAG 2.1.2), browser smoke test |
| T-35-SC | Tampering | npm/pip/cargo installs | high | mitigate | package-legitimacy gate (research audit, [SUS] @codemirror/view) + blocking human checkpoint Task 3 before the install in Task 4 |
</threat_model>
<verification>
- `npm --prefix admin run test`, `npm --prefix admin run test:browser` and `npm --prefix admin run typecheck` green after every task that touches admin/.
- `scripts/check-admin-openapi.sh --check` and `scripts/check-admin-dist.sh` pass after Task 6.
- `go vet ./... && go test -short ./...` and the docs checker pass.
</verification>
<success_criteria>
- The four field controls, the picker, the footer actions, the badges and the diff kit match UI-SPEC C1-C4 and C7-C11.
- CodeMirror is documented, human-approved, exactly pinned, lazily loaded.
- modules/boardwalk/dist is rebuilt and passes the dist gate; nothing pushed or tagged.
</success_criteria>
<output>
Create `.planning/phases/35-mail-templates-and-layouts-editor/35-03-SUMMARY.md` when done; record the Task 3 answer verbatim and the installed versions.
</output>
## Artifacts this phase produces
- SPA components: `MailPreviewField.vue`, `CodeEditorField.vue`, `ColorPickerField.vue`, `MailImageField.vue`, `MailImagePicker.vue`; modules `codeeditor/editor.ts` (`createCodeEditor`), `mail/insertImage.ts` (`insertImage`).
- Registry entries: `mailpreview` (valueless, group-labelled), `codeeditor`, `colorpicker`, `mailimage` (group-labelled).
- FormView: footer record actions on forms without a preview screen, `meta.deletable`, partial `reloadKey`; SettingsFormView: `FORM_VALUES` and `FORM_LOCALE` provides.
- Lists: `customized` and `outdated` badges; CSS kit `.summer-diff`, `.summer-diff__add`, `.summer-diff__del`, `.summer-diff__ctx`; editor class `cm-summer-action`.
- npm runtime dependencies (exact pins): `@codemirror/state`, `@codemirror/view`, `@codemirror/language`, `@codemirror/commands`, `@codemirror/lang-html`, `@codemirror/lang-css`, `@lezer/highlight`.
- Planning docs: `.planning/notes/2026-11-06-admin-code-editor-codemirror.md`, STACK.md row.
- Generated: rebuilt `modules/boardwalk/dist`; smoke tests `admin/tests/form/mail-smoke.test.ts`, `admin/tests/browser/codeeditor-smoke.test.ts`.
- Config keys: none.

View File

@@ -0,0 +1,339 @@
---
phase: 35-mail-templates-and-layouts-editor
plan: 04
type: execute
wave: 4
depends_on: ["35-01", "35-02", "35-03"]
files_modified:
- scripts/check-phase35.sh
- modules/postcard/override_test.go
- modules/postcard/partials_test.go
- modules/postcard/samples_test.go
- modules/postcard/editor_test.go
- modules/postcard/brand_test.go
- modules/postcard/store_postgres_test.go
- modules/pact/capabilities_test.go
- modules/lagoon/attach/absolute_test.go
- modules/cabana/mail_permissions_postgres_test.go
- modules/cabana/mail_lifecycle_postgres_test.go
- modules/cabana/mail_test_send_postgres_test.go
- modules/cabana/mail_unit_test.go
- modules/cabana/field_mail_test.go
- modules/surf/store_lookup_test.go
- admin/tests/form/CodeEditorField.test.ts
- admin/tests/form/MailPreviewField.test.ts
- admin/tests/form/insertImage.test.ts
- admin/tests/form/MailImagePicker.test.ts
- admin/tests/form/ColorPickerField.test.ts
- admin/tests/form/MailImageField.test.ts
- admin/tests/form/FormViewMail.test.ts
- admin/tests/list/RowStateBadges.test.ts
- admin/tests/views/SettingsFormView.test.ts
- admin/tests/browser/codeeditor.test.ts
- .planning/phases/35-mail-templates-and-layouts-editor/35-VALIDATION.md
autonomous: true
requirements: [D-21, D-03, D-06, D-07, D-11, D-12, D-13, D-14, D-15, D-17, D-23]
estimate:
tokens: 170000
raw_tokens: 170000
tasks: 4
confidence: low
must_haves:
truths:
- "scripts/check-phase35.sh runs every Phase 35 check by stage (self-test, go, docker, consumers, admin, docs, marker, additive), fails closed on a missing repository, a skipped or missing required test, a zero-match run or a planted failure, and its consumers stage runs the sm-user-plugin and sm-paymentgateway-plugin mail tests against this checkout (D-21, Wave 0 of 35-VALIDATION.md)"
- "Unit tests cover override precedence and reset: an override beats the file, the caller's subject beats the override, no row means the file, and ResetOverride restores the file (D-21, D-14, D-23)"
- "Unit tests cover drift detection: a changed file hash marks a customized row outdated in postcard's sync and in cabana's row states and filter, and saving again clears it (D-21, D-07)"
- "Unit tests cover fallback at send: a render failure, a safety-check failure, a lookup error, a missing table and no database each send the file version, and an unresolved layout code uses the default layout (D-21, D-14)"
- "Unit tests cover save validation: every DraftError rule (parse, render, unsafe, subject_line, layout, partial, no_content, colour) in postcard and its 422 field line with translated text in cabana, including the 55 paymentgateway-shaped and 8 user-shaped real template shapes passing validation with no samples (D-21, D-13, D-15)"
- "Unit tests cover Branding precedence over config: no row uses mail.brandCss, a saved row's compiled CSS replaces it, mail.css still applies, and an invalid colour is refused (D-21, D-11)"
- "Unit tests cover the partial capability: boot errors for a foreign namespace, a missing file, a duplicate and a name outside mail.partials; token forgery; layouts and text parts; pact.HasMailTemplates' method set is pinned unchanged (D-21, D-12)"
- "Unit tests cover the permission split: each controller, preview mode, test-send mode, picker list and upload against templates-only, layouts-only, both and superuser principals, and no role holds either code after migrations (D-21, D-03)"
- "Unit tests cover the test-send recipient restriction and rate limit: only the admin's own email receives mail, a recipient key in the body is refused, token principals are refused, and the sixth send in 10 minutes is a 429 with Retry-After through a fake cooler.Attempter (D-21, D-17)"
- "SPA unit and browser tests cover the codeeditor keyboard rules, insertImage escaping, the picker's canUpload states, the preview sandbox and sequence guard, the footer record actions, meta.deletable and the new row-state badges (D-21)"
- "35-VALIDATION.md's per-decision map is filled with the final commands and statuses, and every Wave 0 item is checked"
prohibitions:
- statement: "No production code changes in this plan except fixes for defects the new tests expose, each recorded as a deviation in the SUMMARY with its own fix commit."
status: unresolved
verification: judgment
- statement: "Tests never write into a sibling repository and never reach the network beyond localhost, httptest servers and testcontainers."
status: unresolved
verification: judgment
- statement: "Nothing is pushed or tagged by this plan (tag only at phase completion, on the default branch, after Jakub's go-ahead)."
status: unresolved
verification: judgment
artifacts:
- path: "scripts/check-phase35.sh"
provides: "phase gate (tracer)"
contains: "--self-test"
- path: "modules/postcard/override_test.go"
provides: "precedence and fallback unit tests"
contains: "func TestOverride"
- path: "modules/cabana/mail_permissions_postgres_test.go"
provides: "permission split tests"
contains: "func TestMailPermissions"
- path: "modules/cabana/mail_test_send_postgres_test.go"
provides: "recipient and rate-limit tests"
contains: "func TestMailTestSend"
- path: "admin/tests/browser/codeeditor.test.ts"
provides: "keyboard tests in a real engine"
contains: "Mod"
key_links:
- from: "scripts/check-phase35.sh"
to: "../sm-paymentgateway-plugin"
via: "consumers stage runs TestMailCatalog, TestMailTracer and TestMailersMatrixSmoke against this checkout"
pattern: "TestMailersMatrixSmoke"
- from: "scripts/check-phase35.sh"
to: "scripts/apisurface/main.go"
via: "additive stage compares exported API with PHASE35_BASE_TAG"
pattern: "apisurface"
---
## Phase Goal
ROADMAP Goal (not in user-story format; no story is invented): administrators edit every mail the application sends from the admin panel, and mail that already works keeps working. Project rule 2 puts unit tests in the last plan of the phase; this plan adds them for the D-21 list and the phase gate that also proves the two core consumer plugins still send their mail unchanged.
Spec-less probe fallback skipped: no SPEC.md and no requirement IDs; must_haves derive from CONTEXT D-21 and the other decisions it lists.
<objective>
- `scripts/check-phase35.sh`: one fail-closed gate over the framework, the admin SPA, the docs and the sibling sm-user-plugin and sm-paymentgateway-plugin mail tests.
- Unit tests for postcard, pact and attach (precedence, reset, drift hashes, fallback, validation, samples, partials, Branding).
- Unit tests for cabana and surf (permission split, save validation, drift states and filter, reset, delete refusal, test-send recipient and limit, locale, diff, field schema checks, the published Attempter).
- SPA unit and browser tests; the filled 35-VALIDATION.md.
Purpose: D-21 and the Nyquist map in 35-VALIDATION.md; the gate is what `/msd-verify-work` runs.
Output: test commits in this repository; one planning-docs commit for 35-VALIDATION.md. No sibling repository is changed; nothing is pushed or tagged.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/phases/35-mail-templates-and-layouts-editor/35-VALIDATION.md
@.planning/phases/35-mail-templates-and-layouts-editor/35-RESEARCH.md
@.planning/phases/35-mail-templates-and-layouts-editor/35-01-SUMMARY.md
@.planning/phases/35-mail-templates-and-layouts-editor/35-02-SUMMARY.md
@.planning/phases/35-mail-templates-and-layouts-editor/35-03-SUMMARY.md
@CLAUDE.md
<interfaces>
scripts/check-phase34.sh and scripts/check-phase33.sh: the stage engine to copy (usage, detect_go on go test -json, go_named / go_pkg_named for required names, require_dir "missing <path>", marker_hits, npm_vitest, run_self_test planting, additive_case over scripts/apisurface, docker NOT RUN rule, PHASE*_ALLOW_NO_DOCKER).
Smoke tests left by 35-01..35-03: TestMailOverrideSmoke, TestMailPartialsSmoke, TestMailEditorSmoke, TestMailBrandSmoke (modules/postcard, the Postgres helper lives in override_smoke_test.go), TestMailScreensSmoke and newMailScreensEnv (modules/cabana/mail_screens_smoke_test.go), admin/tests/form/mail-smoke.test.ts, admin/tests/browser/codeeditor-smoke.test.ts.
Consumer tests (run against this checkout through their go.mod `replace git.golem15.com/golem15/summercms => ../summercms.go`): ../sm-user-plugin TestMail (mail_test.go), TestPolishMailSubjects (mail_subjects_test.go); ../sm-paymentgateway-plugin TestMailCatalog, TestMailTracer, TestMailersMatrixSmoke (mail_smoke_test.go; that repo also has a go.work).
Base tag for marker and additive: v0.6.3 (newest tag before the phase), overridable with PHASE35_BASE_TAG.
</interfaces>
</context>
## Execution units and resume points
- Each task ends with the commit listed below, staged by explicit path. Resume check: `git log --format=%s -F --grep='<subject>'`.
- A test that exposes a production defect: fix it in its own `fix(<module>): ...` commit, record it as a deviation in the SUMMARY, then continue.
- Docker-backed tests run without `-short` (through `sudo -u golem` if the Docker socket is denied).
| Task | Commit subject | Resume point |
|------|----------------|--------------|
| 1 | `test(35): phase gate script check-phase35.sh` | `--self-test` passes |
| 2 | `test(postcard): unit tests for overrides, fallback, partials, samples, validation, sync and branding` | postcard tests pass |
| 3 | `test(cabana): unit tests for mail permissions, save validation, drift, reset, layout delete and test send` | cabana mail tests pass |
| 4 | `test(admin): unit and browser tests for the mail editor fields`, then `docs(35): fill the validation map` | `scripts/check-phase35.sh --all` passes |
<tasks>
<task type="tracer">
<name>Task 1: End-to-end "one command runs every Phase 35 check, including the core plugins' mail" — the gate script over the smoke tests plans 01-03 left behind</name>
<files>scripts/check-phase35.sh</files>
<read_first>
- scripts/check-phase34.sh (whole file: stage engine, self-test planting, marker word list, additive comparison)
- scripts/apisurface/main.go
- .planning/phases/35-mail-templates-and-layouts-editor/35-VALIDATION.md and the three plan SUMMARYs (test names and commands that worked)
</read_first>
<action>
Create `scripts/check-phase35.sh` from check-phase34.sh's engine, labelled Phase 35 (mail templates and layouts editor), with stages (each a flag; `--all` runs them in this order; no argument prints usage listing every flag and exits 2):
- `--self-test`: plants a failing go test name, a missing required name, a skipped required test, a marker hit in a scratch repository, an additive removal and an interface method added in planted packages, and a missing repository, and requires each to be detected.
- `--go`: `go vet ./...`, `go test -short ./...`, the Docker-free tests by required name in GO_TESTS (TestMailPartialsSmoke, the -short subtests of TestMailEditorSmoke and TestMailBrandSmoke; Tasks 2 and 3 extend the list), and `cd examples/hello && go test ./...`.
- `--docker`: Docker host only (NOT RUN otherwise, which fails --all unless PHASE35_ALLOW_NO_DOCKER=1): `go test ./...` plus DOCKER_TESTS by required name (TestMailOverrideSmoke, TestMailEditorSmoke, TestMailBrandSmoke, TestMailScreensSmoke, TestMigrateStatusJSON; later tasks extend it).
- `--consumers`: for ../sm-user-plugin and ../sm-paymentgateway-plugin: check that the framework module resolves to this checkout (`go list -m -f '{{.Dir}}' git.golem15.com/golem15/summercms` equals this checkout), `go vet ./...`, and the named mail tests (TestMail and TestPolishMailSubjects; TestMailCatalog, TestMailTracer and TestMailersMatrixSmoke) without -short when Docker answers, each required to run and pass; read-only (no file in those repositories is written).
- `--admin`: `npm --prefix admin run test` with required files (ADMIN_FILES: mail-smoke.test.ts now; Task 4 extends it), `npm --prefix admin run test:browser` with codeeditor-smoke required, `npm --prefix admin run typecheck`, `scripts/check-admin-openapi.sh --check`, `scripts/check-admin-dist.sh`.
- `--docs`: TestDocsTree, TestCommandsDocumented and `go run ./cmd/summer docs:build --check`.
- `--marker`: the lines added since PHASE35_BASE_TAG (default v0.6.3) to non-test Go files, READMEs and docs under modules/, cmd/, internal/ and docs/ name no consuming application (the check-phase34 word list).
- `--additive`: scripts/apisurface of every package under modules/ changed since the base tag keeps every exported line and adds no method to an interface that exists at the tag (pact, postcard, cabana, lagoon/attach and surf only gain elements; this proves pact.HasMailTemplates, postcard.Mailer and Catalog.Register are unchanged).
A stage whose repository or directory is missing fails with `missing <path>`. Commit as listed in Execution units.
</action>
<verify>
<automated>bash scripts/check-phase35.sh --self-test</automated>
<fails_when>non-zero exit, or a planted case reported as undetected</fails_when>
<automated>bash scripts/check-phase35.sh --go --docs --marker --additive</automated>
<fails_when>non-zero exit, or a "FAIL", "missing " or marker hit line</fails_when>
<automated>bash scripts/check-phase35.sh --consumers --admin</automated>
<fails_when>non-zero exit or a "FAIL" line</fails_when>
</verify>
<acceptance_criteria>
- `bash scripts/check-phase35.sh; echo $?` prints a usage naming --self-test, --go, --docker, --consumers, --admin, --docs, --marker, --additive, --all and exits 2
- `grep -n "TestMailersMatrixSmoke" scripts/check-phase35.sh` and `grep -n "TestPolishMailSubjects" scripts/check-phase35.sh` each print at least one line
- `grep -c "missing " scripts/check-phase35.sh` is at least 1
- `git -C ../sm-user-plugin status --short` and `git -C ../sm-paymentgateway-plugin status --short` print the same output before and after a `--consumers` run
</acceptance_criteria>
<done>The phase has one gate that proves the mail editor and the unchanged mail of the core plugins together.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Unit tests for postcard, pact and attach: precedence, reset, drift hashes, fallback, validation, samples, partials and Branding</name>
<files>modules/postcard/override_test.go, modules/postcard/partials_test.go, modules/postcard/samples_test.go, modules/postcard/editor_test.go, modules/postcard/brand_test.go, modules/postcard/store_postgres_test.go, modules/pact/capabilities_test.go, modules/lagoon/attach/absolute_test.go, scripts/check-phase35.sh</files>
<read_first>
- modules/postcard/*.go and the four *_smoke_test.go files (35-01 output; reuse the Postgres helper and fixtures, do not duplicate the smoke assertions)
- modules/pact/capabilities_test.go (~250-265 TestRowStateValues, ~410-420 the set check)
- ../sm-paymentgateway-plugin/views/mail and ../sm-user-plugin/views/mail (read only: copy the template shapes into testdata of the test, never import those repositories)
- .planning/phases/35-mail-templates-and-layouts-editor/35-VALIDATION.md (test names per decision)
</read_first>
<behavior>
- TestOverride: table over (no row, customized template, customized layout, database-only layout, partial override, caller Subject) with a fake OverrideSource; each asserts the exact subject, HTML fragment and text part
- TestFallback: render failure, unsafe override, lookup error, unresolved layout code (default layout plus warning), DB source with no published DB; each sends exactly one message whose body is the file version; log records carry component postcard
- TestPartial: HasMailPartials boot errors (foreign namespace, missing file, duplicate, name without mail.partials), bare built-in name, full plugin name, odd kv count, unknown name, forged token in a variable, layout use, text part, nonce differs between two renders
- TestSample: case kept after the prefix; old headers unchanged; walker over nested, if/else, with re-rooting, range of maps, range of strings, chains and variables; every real consumer template shape copied into testdata validates with no samples
- TestValidateDraft: every Rule with Field and a Message that contains no filesystem path or SQL text
- TestBrand: compile defaults, refuse bad colours with the field name, empty colour uses default, lower-casing, precedence of a brand row over mail.brandCss with mail.css kept, logoUrl absolute or empty
- TestStore and TestSync (Postgres): NewDBSource reads only is_custom rows; SyncIndex idempotent with stable IDs, concurrent calls, customized row keeps its override columns while file_hash refreshes (drift: base_hash differs), database-only layouts untouched, ResetOverride restores the file and refuses a database-only layout
- TestRowStateValues pins five values; a compile-time and reflect check pins pact.HasMailTemplates to exactly MailTemplatesFS, MailTemplates and MailLayouts and HasMailPartials to MailPartialsFS and MailPartials
- TestAbsolutePublicURL: public_url, app.url with default and custom prefix, neither (ErrNoAbsoluteURL), trailing slashes, a public_url with a query refused
</behavior>
<action>
Per D-21 (and D-11, D-12, D-13, D-14, D-15, D-07, D-23 it covers): write the tests above with the names VALIDATION.md lists (TestOverride*, TestFallback*, TestPartial*, TestSample*, TestValidateDraft*, TestBrand*, TestStore*, TestSync*), table-driven, `-short` for everything that needs no database and testing.Short skips for the Postgres ones (reuse the override_smoke_test.go helper; no TestMain scanning os.Args). Consumer template shapes go into the test as string fixtures with neutral plugin IDs (acme.*), never naming a consuming application. Add the new names to GO_TESTS and DOCKER_TESTS in scripts/check-phase35.sh. Fix production defects only as described in Execution units. Commit as listed.
</action>
<verify>
<automated>go test -short ./modules/postcard/... ./modules/pact/... ./modules/lagoon/attach/... -run 'TestOverride|TestFallback|TestPartial|TestSample|TestValidateDraft|TestBrand|TestRowStateValues|TestAbsolutePublicURL' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -30</automated>
<fails_when>a "FAIL" line, "no tests to run", or no "--- PASS: TestFallback" line</fails_when>
<automated>go test ./modules/postcard -run 'TestStore|TestSync' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -10</automated>
<fails_when>a "FAIL" line, a "--- SKIP" line, or no "--- PASS: TestSync" line</fails_when>
<automated>bash scripts/check-phase35.sh --go --docker</automated>
<fails_when>non-zero exit or a "FAIL" or "missing " line</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "func TestFallback" modules/postcard/override_test.go` and `grep -n "func TestSync" modules/postcard/store_postgres_test.go` each print at least one line
- `grep -n '"customized"' modules/pact/capabilities_test.go` and `grep -n '"outdated"' modules/pact/capabilities_test.go` each print at least one line
- `git show --stat --format= <the Task 2 test commit sha>` lists only `_test.go` files and scripts/check-phase35.sh; any production change is in a separate `fix(...)` commit recorded as a deviation in the SUMMARY
</acceptance_criteria>
<done>postcard's override, fallback, partial, sample, validation, sync and Branding rules are pinned by unit tests.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Unit tests for cabana and surf: permission split, save validation, drift, reset, layout delete, test send, locale, diff and field schema</name>
<files>modules/cabana/mail_permissions_postgres_test.go, modules/cabana/mail_lifecycle_postgres_test.go, modules/cabana/mail_test_send_postgres_test.go, modules/cabana/mail_unit_test.go, modules/cabana/field_mail_test.go, modules/surf/store_lookup_test.go, scripts/check-phase35.sh</files>
<read_first>
- modules/cabana/mail_screens_smoke_test.go (newMailScreensEnv) and modules/cabana/administrators_harness_test.go (seedAdmin, seedRole, login, request)
- modules/cabana/mail_admin.go, mail_routes.go, mail_diff.go, field_mail.go (35-02 output)
- modules/surf/store_lookup_test.go and modules/surf/serve.go (installCooler)
- .planning/phases/35-mail-templates-and-layouts-editor/35-VALIDATION.md
</read_first>
<behavior>
- TestMailPermissions: matrix of principals (templates-only, layouts-only, both, superuser, none) × (templates list, form PUT, layouts list, partials list, Branding GET and PUT, image list, image upload, Mail images singleton, preview per mode, test send per mode, settings tiles); expected status per UI-SPEC C14 and D-03; after migrations no role or user permission JSON contains either code
- TestMailSave: each postcard rule becomes a 422 on its field with the translated text and :message filled (en and pl), the row is unchanged afterwards; Branding colour 422 colorpicker.invalid; logo id outside the library 422 mailimage.missing; custom layout code format and taken
- TestMailDrift: base_hash stamped on save; file change plus sync gives [customized, outdated]; the status filter outdated; re-save clears; _info shows the callout only when outdated
- TestMailReset and TestMailLayoutDelete: reset per kind restores the file and removes the action from meta.actions; reset not offered on custom layouts; delete of an in-use custom layout is 403 with name and count in en (one and other) and pl (one, few, many); file rows never deletable; meta.deletable values
- TestMailTestSend: recipient is the admin's email only (Cc and Bcc empty), body with to/email/recipient refused, token principal refused, a fake cooler.Attempter published on the app counts per admin and the sixth call returns 429 with Retry-After, a second admin is independent, the in-process fallback limits without a published Attempter, a failing driver gives 502 with the fixed message
- TestMailLocale (-short): TemplateLocale with enabled and unknown suffixes as cabana computes locales; TestMailDiff (-short): lineDiff context, cap, identical inputs; TestMailFieldSchema (-short): codeeditor language, mailImages placement, mailpreview mode, settings-form acceptance of colorpicker, mailimage and mailpreview
- surf TestInstallCoolerPublishesAttempter: installCooler with the Redis rate-limit store publishes a cooler.Attempter that is the same store as the published Store (miniredis); with the memory store it publishes neither
</behavior>
<action>
Per D-21 (and D-03, D-06, D-07, D-08, D-13, D-17, D-05 it covers): write the tests above with the names VALIDATION.md lists (TestMailPermissions, TestMailSave, TestMailDrift, TestMailReset, TestMailLayoutDelete, TestMailBrand, TestMailTestSend, TestMailLocale) plus TestMailDiff and TestMailFieldSchema, reusing newMailScreensEnv and the 19.6.1 harness helpers; Postgres tests skip under testing.Short. Add the names to GO_TESTS / DOCKER_TESTS in scripts/check-phase35.sh. Fix production defects only as described in Execution units. Commit as listed.
</action>
<verify>
<automated>go test ./modules/cabana -run 'TestMailPermissions|TestMailSave|TestMailDrift|TestMailReset|TestMailLayoutDelete|TestMailBrand|TestMailTestSend' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -30</automated>
<fails_when>a "FAIL" line, a "--- SKIP" line, "no tests to run", or no "--- PASS: TestMailTestSend" or "--- PASS: TestMailPermissions" line</fails_when>
<automated>go test -short ./modules/cabana ./modules/surf -run 'TestMailLocale|TestMailDiff|TestMailFieldSchema|TestInstallCoolerPublishesAttempter' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -10</automated>
<fails_when>a "FAIL" line, "no tests to run", or no "--- PASS: TestInstallCoolerPublishesAttempter" or "--- PASS: TestMailDiff" line</fails_when>
<automated>bash scripts/check-phase35.sh --go --docker</automated>
<fails_when>non-zero exit or a "FAIL" or "missing " line</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "func TestMailPermissions" modules/cabana/mail_permissions_postgres_test.go` and `grep -n "func TestMailTestSend" modules/cabana/mail_test_send_postgres_test.go` each print one line
- `grep -n "Retry-After" modules/cabana/mail_test_send_postgres_test.go` prints at least one line
- `grep -n "cooler.Attempter" modules/surf/store_lookup_test.go` prints at least one line
- `grep -n "TestMailLayoutDelete" scripts/check-phase35.sh` prints at least one line
</acceptance_criteria>
<done>The permission split, the recipient rule, the limit and every admin-side refusal are pinned by unit tests.</done>
</task>
<task type="auto" tdd="true">
<name>Task 4: SPA unit and browser tests for the mail editor, the filled validation map and the full gate</name>
<files>admin/tests/form/CodeEditorField.test.ts, admin/tests/form/MailPreviewField.test.ts, admin/tests/form/insertImage.test.ts, admin/tests/form/MailImagePicker.test.ts, admin/tests/form/ColorPickerField.test.ts, admin/tests/form/MailImageField.test.ts, admin/tests/form/FormViewMail.test.ts, admin/tests/list/RowStateBadges.test.ts, admin/tests/views/SettingsFormView.test.ts, admin/tests/browser/codeeditor.test.ts, scripts/check-phase35.sh, .planning/phases/35-mail-templates-and-layouts-editor/35-VALIDATION.md</files>
<read_first>
- admin/tests/form/mail-smoke.test.ts and admin/tests/browser/codeeditor-smoke.test.ts (35-03 output; extend coverage, do not duplicate)
- admin/tests/form/RichEditorField.test.ts, admin/tests/form/FormView.test.ts, admin/tests/list/RowStateBadges.test.ts, admin/tests/views/SettingsFormView.test.ts (mount patterns, api mocks)
- .planning/phases/35-mail-templates-and-layouts-editor/35-UI-SPEC.md (UI Considerations rows marked backstop: footer reset, meta.deletable, codeeditor keyboard, insertImage, mailpreview failure and sandbox)
- .planning/phases/35-mail-templates-and-layouts-editor/35-VALIDATION.md
</read_first>
<behavior>
- codeeditor (browser): Tab and Shift+Tab leave the editor; Mod+S and Mod+Enter save; Esc does not cancel; undo and redo through history; the Go action decoration on `{{ .Name }}`; text wraps, html does not
- CodeEditorField (happy-dom): heights per size, loading box, failure fallback textarea saves, read-only pre, external value replacement without an emit
- insertImage: the full C4 table plus spaces and parentheses in URLs and empty alt
- MailPreviewField: sandbox empty and no src, srcdoc only from 2xx, sequence guard, failed and failed_first and unavailable, empty draft sends nothing, inactive tab sends nothing, test send 2xx toast with email, 429 seconds and disabled button, 422 inline, 5xx toast, branding mode without subject row and test send
- MailImagePicker: canUpload true and false, empty-library copy per mode, no results, load failed, 409 server message, upload success selects the new tile, 422 upload line, primary disabled until chosen, radiogroup keys, alt prefill in insert mode only
- ColorPickerField and MailImageField: per UI-SPEC C2 and C11
- FormViewMail: footer reset with danger confirm, busy disables the footer, success toast plus reload plus clean form plus reloadKey bump, 409 and 403 paths, meta.deletable false/true/absent, no footer actions on a controller with a preview screen
- RowStateBadges: five states in order with the two new styles; SettingsFormView: FORM_VALUES and FORM_LOCALE provided
</behavior>
<action>
Per D-21: write the SPA tests above (happy-dom unit tests under admin/tests/form, list and views; the browser test under admin/tests/browser), add every new file to ADMIN_FILES and codeeditor to the required browser files in scripts/check-phase35.sh, and commit as listed. Then fill 35-VALIDATION.md: every per-decision row gets the final command and status, the Wave 0 items are checked (or name the plan that delivered them), `wave_0_complete: true` and `nyquist_compliant: true` in its front matter when every row is green, and commit it as a separate planning-docs commit. Finally run `bash scripts/check-phase35.sh --all` (with PHASE35_ALLOW_NO_DOCKER=1 only if Docker is unavailable, recorded in the SUMMARY).
</action>
<verify>
<automated>npm --prefix admin run test 2>&1 | tail -6 && npm --prefix admin run test:browser 2>&1 | tail -6</automated>
<fails_when>non-zero exit or a "FAIL" line</fails_when>
<automated>bash scripts/check-phase35.sh --all</automated>
<fails_when>non-zero exit, a "FAIL", "missing ", "NOT RUN" (without the allow variable) or marker hit line</fails_when>
</verify>
<acceptance_criteria>
- `ls admin/tests/form/insertImage.test.ts admin/tests/form/MailPreviewField.test.ts admin/tests/browser/codeeditor.test.ts` lists all three
- `grep -n "nyquist_compliant: true" .planning/phases/35-mail-templates-and-layouts-editor/35-VALIDATION.md` prints one line
- `grep -c "⬜ pending" .planning/phases/35-mail-templates-and-layouts-editor/35-VALIDATION.md` prints 0
- `git log --format=%s -F --grep='docs(35): fill the validation map'` prints one line, and `git show --stat --format= <that commit>` lists only 35-VALIDATION.md
</acceptance_criteria>
<done>The mail editor's UI rules are pinned by tests, the validation map is complete and the phase gate is green.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| gate script → sibling repositories | the consumers stage runs go commands inside ../sm-user-plugin and ../sm-paymentgateway-plugin |
| tests → Docker | testcontainers start Postgres containers on the host |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-35-25 | Tampering | consumers stage | medium | mitigate | read-only commands (go list, go vet, go test); acceptance compares `git status --short` of both repositories before and after a run |
| T-35-26 | Repudiation | a gate that passes on zero tests | high | mitigate | go test -json detection fails on skips, zero matches, "no tests to run" and missing required names; self-test plants each case |
| T-35-27 | Elevation of privilege | security regressions after the phase | high | mitigate | TestMailPermissions, TestMailTestSend and the preview sandbox tests pin T-35-09, T-35-11 and T-35-19 so a regression fails the gate |
| T-35-28 | Information disclosure | test fixtures | low | mitigate | fixtures use neutral plugin IDs and example.test addresses; the marker stage refuses consuming-application names |
| T-35-SC | Tampering | npm/pip/cargo installs | high | accept | this plan installs no package |
</threat_model>
<verification>
- `bash scripts/check-phase35.sh --all` green (Docker host), including the consumers stage.
- `go vet ./... && go test ./...`, `npm --prefix admin run test`, `npm --prefix admin run test:browser`, `npm --prefix admin run typecheck`, `scripts/check-admin-openapi.sh --check`, `scripts/check-admin-dist.sh`, `summer docs:build --check` green.
</verification>
<success_criteria>
- Every D-21 item has named unit tests; SPA backstop rows of the UI-SPEC have tests.
- check-phase35.sh exists, fails closed, and proves the two core plugins' mail unchanged.
- 35-VALIDATION.md is complete; nothing pushed or tagged; no sibling repository changed.
</success_criteria>
<output>
Create `.planning/phases/35-mail-templates-and-layouts-editor/35-04-SUMMARY.md` when done; list every deviation fix commit and the final `--all` result.
</output>
## Artifacts this phase produces
- Script: `scripts/check-phase35.sh` with `--self-test`, `--go`, `--docker`, `--consumers`, `--admin`, `--docs`, `--marker`, `--additive`, `--all`; environment `PHASE35_BASE_TAG`, `PHASE35_ALLOW_NO_DOCKER`.
- Go tests: `TestOverride*`, `TestFallback*`, `TestPartial*`, `TestSample*`, `TestValidateDraft*`, `TestBrand*`, `TestStore*`, `TestSync*`, `TestAbsolutePublicURL`, updated `TestRowStateValues`, `TestMailPermissions`, `TestMailSave`, `TestMailDrift`, `TestMailReset`, `TestMailLayoutDelete`, `TestMailBrand`, `TestMailTestSend`, `TestMailLocale`, `TestMailDiff`, `TestMailFieldSchema`, `TestInstallCoolerPublishesAttempter`.
- SPA tests: `CodeEditorField.test.ts`, `MailPreviewField.test.ts`, `insertImage.test.ts`, `MailImagePicker.test.ts`, `ColorPickerField.test.ts`, `MailImageField.test.ts`, `FormViewMail.test.ts`, updated `RowStateBadges.test.ts` and `SettingsFormView.test.ts`, browser `codeeditor.test.ts`.
- Planning: filled `35-VALIDATION.md`.
- No new exported identifiers, config keys or dependencies.

View File

@@ -0,0 +1 @@
No external API integration: Phase 35 adds a database override layer, an editor API inside postcard and admin API routes inside cabana for the framework's own admin SPA; mail still leaves through the existing, unchanged postcard drivers (memory, log, SMTP), and the only new third-party code is the CodeMirror editor library, which calls no service.