diff --git a/.planning/phases/35-mail-templates-and-layouts-editor/35-CONTEXT.md b/.planning/phases/35-mail-templates-and-layouts-editor/35-CONTEXT.md index 1ed35b08..18ac16b7 100644 --- a/.planning/phases/35-mail-templates-and-layouts-editor/35-CONTEXT.md +++ b/.planning/phases/35-mail-templates-and-layouts-editor/35-CONTEXT.md @@ -91,6 +91,12 @@ Not in this phase: mail driver/SMTP settings screens, MailBlocker, newsletters/b The postcard and cabana READMEs and the affected `docs/` pages are updated in the same commits as API/config/permission changes. The `admin/` changes regenerate `admin/openapi/admin.json` + `schema.d.ts` (`scripts/check-admin-openapi.sh`) and rebuild the committed `modules/boardwalk/dist` (`scripts/check-admin-dist.sh`). +### Planning-time amendments (2026-11-06, confirmed by the user after research) +- **D-22:** postcard, not cabana, owns the mail tables, their migrations (a framework set in `lagoon.migrationSets`, like `attach.Migrations`) and the per-send override lookup, because `queue:work` sends mail in a process where cabana never activates. cabana still owns the screens, permissions and controllers (D-01 otherwise unchanged). +- **D-23:** Every file template, layout and partial gets an index row (Winter `syncAll` style), synced from the file catalog at serve start. Reset (D-06) clears the override columns on that row instead of deleting it; custom layouts are still deleted. +- **D-24:** The Branding logo's absolute URL is built at send time from config (`storage.uploads.public_url` / `app.url`) through a config-based attach helper, not stored at save. +- **D-25:** postcard ships no built-in template; the templates list keeps the UI-SPEC empty-state text. + ### Claude's Discretion - Table names and schema (one overrides table per kind, or a shared one), as long as it is Postgres/GORM with gormigrate migrations owned by the cabana pseudo-plugin, or by postcard if the planner justifies it. - Exact partial call syntax and the built-in partial set beyond button/panel/table. diff --git a/.planning/phases/35-mail-templates-and-layouts-editor/35-PATTERNS.md b/.planning/phases/35-mail-templates-and-layouts-editor/35-PATTERNS.md new file mode 100644 index 00000000..6c213bbf --- /dev/null +++ b/.planning/phases/35-mail-templates-and-layouts-editor/35-PATTERNS.md @@ -0,0 +1,282 @@ +# Phase 35: Mail templates and layouts editor - Pattern Map + +**Mapped:** 2026-11-06 +**Files analyzed:** 27 (new + modified, from 35-RESEARCH.md "Recommended Project Structure" and Wave 0 gaps) +**Analogs found:** 24 / 27 + +Line numbers are from master at `9232dc1`. Phase 33 is editing cabana at the same time, so re-check `form_schema.go`, `http.go` and `crud.go` line numbers before executing. + +## File Classification + +| New/Modified File | Role | Data Flow | Closest Analog | Match Quality | +|---|---|---|---|---| +| `modules/postcard/store.go` (new: Migrations, tables, OverrideSource, index sync) | migration + service | CRUD / batch sync | `modules/lagoon/attach/migrations.go` + `modules/lagoon/ondatabase.go` | exact (migrations), role-match (sync) | +| `modules/postcard/mailer.go` (mod: Activate wires DB source, Send lookup + fallback) | service | request-response | itself, lines 92-109 | exact | +| `modules/postcard/templates.go` (mod: partials, samples, hashes, alias fallback) | service | transform | itself | exact | +| `modules/postcard/partials.go` (new) | utility | transform | `modules/postcard/templates.go` (execHTML + goldmark, lines ~343-372) | role-match | +| `modules/postcard/samples.go` (new) | utility | transform | `modules/postcard/templates.go` parseINI / parseTemplateFile | role-match | +| `modules/postcard/brand.go` (new) | utility | transform | `modules/postcard/templates.go` layout data (lines 358-372) | partial | +| `modules/postcard/editor.go` (new: exported read + Validate/Preview/SendDraft) | service | request-response | `modules/postcard/mailer.go` Send | role-match | +| `modules/postcard/assets/{system,minimal}.htm, partials/*.htm, brand.css.tmpl, sample.htm` | config/asset | file-I/O | `modules/postcard/assets/default.htm` | exact | +| `modules/pact/capabilities.go` (mod: HasMailPartials, RowStateCustomized/Outdated) | config (interface) | n/a | itself, lines 1055-1062 and ~1271 | exact | +| `modules/lagoon/migrations.go` (mod: add postcard set) | migration registry | batch | itself, lines 225-230 | exact | +| `modules/cabana/backend_screens.go` (mod: mail controllers, permissions, settings items) | provider (pseudo-plugin) | n/a | itself, lines 76-117 | exact | +| `modules/cabana/mail_admin.go` (new: models, controllers, hooks, record actions) | controller | CRUD | `modules/cabana/backend_users_admin.go` | exact | +| `modules/cabana/mail_routes.go` (new: preview, test send, image library) | controller (route) | request-response | `modules/cabana/field_markdown.go` (markdownPreview) + `http.go:342` | exact | +| `modules/cabana/field_codeeditor.go` etc. (codeeditor, colorpicker, mailimage, mailpreview) | config (field registry) | n/a | `modules/cabana/form_schema.go:24-51` | exact | +| Branding settings model (in mail_admin.go or settings registration) | model | CRUD singleton | `modules/cabana/settings.go` (lines 96-102, 186, 236-277) | exact | +| Image library host (AdminSingleton) | controller | CRUD singleton | `modules/cabana/singleton.go:41-80` | exact | +| `modules/cabana/backendscreens/controllers/mail{templates,layouts,partials,images}/config_{form,list,filter}.yaml` + `_*.htm` | config | n/a | `modules/cabana/backendscreens/controllers/users/*` | exact | +| `modules/cabana/backendscreens/models/mail*/{fields,columns}.yaml` | config | n/a | `modules/cabana/backendscreens/models/user/*` | exact | +| `admin/src/components/form/fields/CodeEditorField.vue` | component | event-driven | `admin/src/components/form/fields/RichEditorField.vue` (lazy editor) + `MarkdownField.vue` | role-match | +| `admin/src/components/form/fields/MailPreviewField.vue` | component | request-response | `admin/src/components/form/fields/MarkdownField.vue` | exact | +| `admin/src/components/form/fields/ColorPickerField.vue` | component | event-driven | `admin/src/components/form/fields/TextField.vue` | role-match | +| `admin/src/components/form/fields/MailImageField.vue`, `admin/src/components/mail/MailImagePicker.vue` | component | request-response | `admin/src/components/form/fields/FileuploadField.vue` | role-match | +| `admin/src/components/form/registry.ts` (mod) | config | n/a | itself, lines 36-90 | exact | +| `admin/src/components/form/codeeditor/editor.ts` | utility | event-driven | none (CodeMirror is new) | no analog | +| `admin/src/components/mail/insertImage.ts` | utility | transform | none | no analog | +| Go tests (`modules/postcard/*_test.go`, `modules/cabana/mail_*_test.go`) | test | n/a | `modules/cabana/administrators_harness_test.go:154`, `lagoon/postgres_test.go` | exact | +| `admin/tests/form/{codeeditor,mailpreview,insertImage}*.test.ts`, `admin/tests/browser/codeeditor.test.ts` | test | n/a | existing `admin/tests/form/*` markdown tests | role-match | + +## Pattern Assignments + +### `modules/postcard/store.go` (migration + DB-backed source) + +**Analog:** `modules/lagoon/attach/migrations.go` (lines 1-40). Raw SQL statements inside a gormigrate migration, ID prefixed with a date stamp: +```go +var Migrations = []*gormigrate.Migration{ + { + ID: "202609180001_create_system_files", + Migrate: func(tx *gorm.DB) error { + stmts := []string{ + `CREATE TABLE system_files ( + id SERIAL PRIMARY KEY, + ... + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +)`, + `CREATE INDEX system_files_field_index ON system_files (field)`, + } + for _, stmt := range stmts { + if err := tx.Exec(stmt).Error; err != nil { + return err +``` +Use the schema in 35-RESEARCH.md "Recommended schema" (Winter-style table names). Wire the index sync through `lagoon.OnDatabase` (`modules/lagoon/ondatabase.go:19-25`): +```go +func OnDatabase(app *backpack.App, fn func(sqlDB *sql.DB, gdb *gorm.DB) error) error +``` +It runs immediately if the DB is already published, otherwise when Publish runs. Because lagoon imports postcard (for the migration set), postcard must not import lagoon. Look the `*gorm.DB` up through `backpack` (`app.Lookup`) at send time, or take the source through an interface that postcard defines (D-01). + +### `modules/lagoon/migrations.go` (modified) + +**Analog:** itself, `migrationSets` (lines 220-230): +```go +sets := []migrationSet{ + {"summercms.attach", "system_files", attach.Migrations}, + {"summercms.cabana", "backend admin", BackendAdminMigrations}, + {DeferredHistoryID, "deferred bindings", DeferredBindingMigrations}, + {QueueHistoryID, "queue", QueueMigrations(sqlDB)}, +} +``` +Add `{"summercms.postcard", "mail", postcard.Migrations},` and update the set list in `modules/lagoon/pending_test.go`. + +### `modules/postcard/mailer.go` (modified) + +**Analog:** itself, `Activate` lines 92-109: +```go +func Activate[P interface{ ID() string }](app *backpack.App, _ []P) error { + ... + m := &mailer{catalog: NewCatalog(), driver: driver, opts: opts} + if err := app.Publish[Mailer](m); err != nil { + return fmt.Errorf("postcard: %w", err) + } + return nil +} +``` +Do not change the `Mailer` interface: `../sm-paymentgateway-plugin/mail_smoke_test.go:56` implements it. Add the override lookup inside `(*mailer).Send`. Treat any lookup error as "no overrides" and log a rate-limited warning (Pitfall 7). + +### `modules/pact/capabilities.go` (modified) + +**Analog:** itself. Row states at lines 1055-1062: +```go +const ( + RowStateDeleted RowState = "deleted" + RowStateNegative RowState = "negative" + RowStateDisabled RowState = "disabled" +) +``` +Append `RowStateCustomized` and `RowStateOutdated` in the same style, and update `TestRowStateValues` and cabana's `rowStateOrder`. `HasMailTemplates` (lines ~1270-1275) stays as it is. Put `HasMailPartials` next to it, in the same three-method `fs.FS` + names shape. + +### `modules/cabana/backend_screens.go` (modified) + +**Analog:** itself, lines 76-117. Add exported permission and controller ID constants next to `PermissionManageUsers`/`AdministratorsControllerID` (lines 14-28). Extend: +```go +func (s *backendScreens) AdminControllers() []pact.AdminController { + return []pact.AdminController{usersAdmin{screens: s}, rolesAdmin{screens: s}} +} +func (*backendScreens) Permissions() []pact.Permission { + return []pact.Permission{ + {Code: PermissionManageUsers, Tab: "backend::lang.permissions.tab_system", Label: "backend::lang.permissions.manage_users"}, +``` +Settings items follow the `pact.SettingsItem{Code, Label, Description, Category: "backend::lang.settings.category_system", Icon, Order, Permissions, Controller}` shape (lines 96-115). No role grants in code (D-03 / 19.6.1 D-04). The YAML is embedded through `//go:embed all:backendscreens` (line 38), so the new `controllers/mail*/` and `models/mail*/` directories are picked up with no extra wiring. + +### `modules/cabana/mail_admin.go` (controller, CRUD) + +**Analog:** `modules/cabana/backend_users_admin.go`. + +**Struct + interface assertions** (lines 24-53): +```go +type usersAdmin struct { + screens *backendScreens +} +var ( + _ pact.AdminController = usersAdmin{} + _ pact.AdminPermissioned = usersAdmin{} + _ pact.ListRowStates = usersAdmin{} + _ pact.FormBeforeUpdate = usersAdmin{} + _ pact.FormBeforeDelete = usersAdmin{} + _ pact.HasAdminRecordActions = usersAdmin{} + _ pact.AdminPartialData = usersAdmin{} + ... +) +``` +**Identity methods** (lines 55-63): `ID()`, `ModelName()`, `ConfigDir() "controllers/users"`, `RequiredPermissions()`, `NewRecord()`. +**Helpers to copy:** `db()` (109), `requestDB(ctx)` (122), `translator()` (130), `asBackendUser` type assertion (140). +**Row states** (178-193): Customized when `is_custom`, Outdated when `is_custom && base_hash != file_hash`. +```go +func (usersAdmin) ListRowStates(_ context.Context, _ *gorm.DB, records []any) ([][]pact.RowState, error) { + out := make([][]pact.RowState, len(records)) + for i, record := range records { + user, err := asBackendUser(record) + if err != nil { return nil, err } + if user.DeletedAt.Valid { out[i] = append(out[i], pact.RowStateDeleted) } + ... +``` +**Hooks:** FormBeforeDelete (199) is the model for refusing deletion of an in-use layout (use `ForbiddenError`, Pitfall 9). FormRules (475) handles save validation. Record actions (325) cover Reset to default and Test send. PartialData (422) feeds the server-rendered `_info.htm` / drift partials. Parameterized messages are built with `backendMessageParams(ctx, tr, key, fallback, params)` (`account_tokens.go:206,227`), not with `localizeDetails`. + +### `modules/cabana/mail_routes.go` (route, request-response) + +**Analog:** `modules/cabana/field_markdown.go` lines 55-80 plus mounting at `modules/cabana/http.go:342`: +```go +g.Post("/markdown/preview", requireAjax(s.markdownPreview)) +``` +```go +type AdminMarkdownPreviewRequest struct { Markdown string `json:"markdown"` } +type AdminMarkdownPreviewResult struct { HTML string `json:"html"` } + +func (s *service) markdownPreview(w http.ResponseWriter, r *http.Request) { + principal, ok := bouncer.User(r.Context()) + if !ok || principal == nil || !principal.Backend { + WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated) + return + } + if !tokenRouteGate(principal, w) { return } + var body AdminMarkdownPreviewRequest +``` +Add an exported `AdminMail*Request/Result` pair per route, swag annotations, and a per-`mode` permission check. The 422 responses use fixed message constants (`msgMarkdownPreviewRefused` style) and never echo the renderer's error. Rate-limit test send with `cooler.Attempter` (`modules/cooler/cooler.go:68-73`). Afterwards run `scripts/check-admin-openapi.sh`. + +### New field types (`form_schema.go` + `field_codeeditor.go`) + +**Analog:** `modules/cabana/form_schema.go` lines 24-51: +```go +formFieldTypes = map[string]struct{}{ + "text": {}, ... "richeditor": {}, "repeater": {}, "money": {}, "taglist": {}, +} +formFieldKeys = map[string]struct{}{ ... "mode": {}, ... "separator": {}, "customTags": {}, } +``` +Add `codeeditor`, `colorpicker`, `mailimage`, `mailpreview` to the types, and `language` and `mailImages` to the keys. If the Branding settings form uses colorpicker, add it to the settings refusal check at `settings.go:102` only for types that need to be refused. + +### Branding settings model + +**Analog:** `modules/cabana/settings.go`. Fields are decoded with `decodeFields(raw, types.refusing())` (line 96). The refused built-ins are `widget/partial/fileupload/permissioneditor` (line 102), so a Branding logo upload cannot use `fileupload` there and needs a `mailimage` URL field instead. The singleton row sits at PK 1 (line 186), and the first Put creates it (lines 236-277). Example registration: `modules/cabana/example_settings_test.go`. + +### Image library host + +**Analog:** `modules/cabana/singleton.go` lines 41-80. Implement `pact.AdminSingleton.SingletonRecord(ctx, db) (any, error)` with first-or-create. cabana already retries on a unique-index race (`uniqueViolation(err)` → `singletonRace{}`). Absolute URLs come from `attach.PublicURL` (`lagoon/attach/thumb.go:80-96`) and are prefixed with `app.url` when no `public_url` is set. + +### YAML controllers and models + +**Analog:** `modules/cabana/backendscreens/controllers/users/config_form.yaml`: +```yaml +name: backend::lang.user.name +form: ~/plugins/summercms/cabana/models/user/fields.yaml +modelClass: Backend\Models\User +defaultRedirect: summercms/cabana/users +create: + redirect: summercms/cabana/users/preview/:id + redirectClose: summercms/cabana/users +update: + redirect: summercms/cabana/users/update/:id + redirectClose: summercms/cabana/users/preview/:id +preview: + headerPartial: hint +recordActions: [activate, deactivate, restore] +messages: { create: ..., update: ..., saved: ..., deleteConfirm: ..., deleted: ..., edit: ..., preview: ... } +``` +The siblings `config_list.yaml`, `config_filter.yaml` and `_hint.htm` cover lists, the Customized/Outdated filters and server partials. The partial allowlist is in `partial_render.go:105-118`. + +### `admin/src/components/form/registry.ts` (modified) + +**Analog:** itself. Add an import block (lines 38-62) and an entry in the `renderers` map (lines 79-90+): +```ts +const renderers = new Map([ + ['text', TextField], + ... + ['fileupload', FileuploadField], + ['datepicker', DatepickerField], +``` +Add `mailpreview` to `valueless` (`new Set([RELATION_MANAGER, 'widget', 'partial', 'fileupload'])`) and the group-labelled types to `groupLabelledTypes`. Extend the phase-history header comment (lines 1-35) with a Phase 35 paragraph. + +### `MailPreviewField.vue` / `CodeEditorField.vue` + +**Analog:** `admin/src/components/form/fields/MarkdownField.vue` lines 1-70: +```ts +import { computed, onBeforeUnmount, ref, watch } from 'vue' +import { api } from '../../../api/client' +import { controlAttributes, controlClass, type FieldControlProps } from '../control' +const props = defineProps() +const emit = defineEmits<{ 'update:modelValue': [value: string] }>() +const PREVIEW_DEBOUNCE_MS = 300 +let sequence = 0 // drops stale answers: if (current !== sequence) return +... +const { data, error } = await api.POST('/markdown/preview', { body: { markdown: text.value } }) +if (data) { sanitizedHtml.value = data.data.html } else { previewError.value = refusal(error) } +``` +For the preview, copy the debounce, the sequence guard and the `refusal()` details extraction. Render into a `sandbox` iframe `srcdoc`, which the CSP comment allows. Never `v-html` the mail. For CodeEditorField, copy the lazy-import pattern of `RichEditorField.vue` (TipTap loaded on demand) to load `codeeditor/editor.ts`. Field components import only from `../control`, never from `registry.ts` (registry.ts lines 64-67, to avoid an import cycle). Keep Mod+Enter reaching the form (Pitfall 5). + +### Go tests + +**Analog:** `modules/cabana/administrators_harness_test.go:154` `newScreensEnv(t, extra ...party.Plugin)`. It builds compass config with the admin secret, `lagoon.Publish(app, adminSQL, gdb)`, phrasebook and `surf.Assemble`. Reuse it for `TestMailPermissions`, routes and lifecycle. For postcard Postgres tests, copy the TestMain from `modules/lagoon/postgres_test.go`, which uses `flag.Parse()` and `testing.Short()` per CLAUDE.md. Smoke-test naming follows `backend_screens_smoke_test.go`. + +## Shared Patterns + +### Backend auth gate on custom routes +**Source:** `modules/cabana/field_markdown.go:72-79` (`bouncer.User` + `principal.Backend` + `tokenRouteGate`). Mount with `requireAjax` for POSTs (`http.go:342`). +**Apply to:** all mail_routes.go handlers, plus a per-permission check against `PermissionManageMail*`. + +### Error responses +**Source:** `WriteError(w, status, code, msgConst)` with fixed `msg*` constants (`field_markdown.go:53,74`). Use `ForbiddenError` for refusals and translated params via `backendMessageParams`. +**Apply to:** routes and hooks. + +### HTML safety gate +**Source:** `modules/postcard/templates.go:25-26` and `modules/cabana/field_markdown.go:16-18,37-48` (identical regexes). Reuse them unchanged for draft validation, but report their false positives to the user (Pitfall 8). + +### Embedded pseudo-plugin assets +**Source:** `backend_screens.go:35-74` (`//go:embed all:backendscreens`, `fs.Sub`). + +### Docs/README and generated artifacts +Update READMEs for postcard and cabana in the same commit. Then run `scripts/check-admin-openapi.sh`, `scripts/check-admin-dist.sh` and `summer docs:build --check`. + +## No Analog Found + +| File | Role | Data Flow | Reason | +|---|---|---|---| +| `admin/src/components/form/codeeditor/editor.ts` | utility | event-driven | CodeMirror 6 is new. Use 35-RESEARCH.md "CodeMirror setup" (verified export names). Needs a STACK.md row and a decision note before install. | +| `admin/src/components/mail/insertImage.ts` | utility | transform | No Markdown-insertion helper exists. Use the RESEARCH Pitfall 4 rules (absolute URL, percent-encoding). | +| `modules/postcard/samples.go` placeholder walker | utility | transform | No `text/template/parse` walker exists in the repo. Use the RESEARCH "Placeholder walker" prototype. | + +## Metadata + +**Analog search scope:** modules/postcard, modules/cabana (+ backendscreens), modules/lagoon (+ attach), modules/pact, admin/src/components/form +**Files scanned:** ~20 +**Pattern extraction date:** 2026-11-06