diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index d28a51ae..b239424b 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -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) diff --git a/.planning/phases/35-mail-templates-and-layouts-editor/35-01-PLAN.md b/.planning/phases/35-mail-templates-and-layouts-editor/35-01-PLAN.md new file mode 100644 index 00000000..10ee01d0 --- /dev/null +++ b/.planning/phases/35-mail-templates-and-layouts-editor/35-01-PLAN.md @@ -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 ::mail.partials. and map to views/mail/partials/.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. = 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. + + +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.` 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.`; 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. + + + +@~/.claude/msd-core/workflows/execute-plan.md +@~/.claude/msd-core/templates/summary.md + + + +@.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 + + +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/.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. + + + +## 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=''`; present subjects mean the task is done: re-run its `` 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 | + + + + + Task 1: Confirm the mail schema, the partial call syntax, the sample keys and the names before any code depends on them + 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. + 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. + + 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. + + + + + + + Reply "as-planned", "adjust: " or "sample-capability" + + + + Task 2: End-to-end "a customized template row changes the mail a process without cabana sends" — templates only, with the file fallback + D-07: the hash columns are part of the override table schema; a later change is a new migration. + 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 + + - 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) + + + 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. + + + go test ./modules/postcard -run 'TestMailOverrideSmoke' -count=1 -v 2>&1 | grep -E '^\s*(--- |FAIL|ok|PASS)' | tail -20 + a "FAIL" line, a "--- SKIP" line, "no tests to run", or no "--- PASS: TestMailOverrideSmoke/fallback" line + go test ./modules/lagoon -run 'TestMigrateStatusJSON' -count=1 2>&1 | tail -3 + non-zero exit or a "FAIL" line + go vet ./... && go test -short ./... + non-zero exit or a "FAIL" line + go test ./cmd/summer -run 'TestCommandsDocumented|TestDocsTree' -count=1 2>&1 | tail -3 && go run ./cmd/summer docs:build --check 2>&1 | tail -5 + non-zero exit, or a "FAIL" or "unknown identifier" line in the output + + + - `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 + + A customized template row reaches real sends in every process with a database, and anything wrong with it degrades to the file version. + + + + Task 3: Partials and built-in layouts: plugins declare partials, bodies and layouts call them, layout and partial overrides apply at send + D-12: HasMailPartials, the ::mail.partials. naming and the partial call syntax are a published contract plugins implement. + 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 + + - 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 `