From f5605fc8d1b4cb4594664f409eb5b8d9f642ace8 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Fri, 18 Sep 2026 12:43:41 +0200 Subject: [PATCH] docs(04): research phase implementation --- .../04-PATTERNS.md | 198 ++++++++++++++++++ .../04-RESEARCH.md | 84 ++++++++ 2 files changed, 282 insertions(+) create mode 100644 .planning/phases/04-cli-scaffolding-i18n-and-mail/04-PATTERNS.md create mode 100644 .planning/phases/04-cli-scaffolding-i18n-and-mail/04-RESEARCH.md diff --git a/.planning/phases/04-cli-scaffolding-i18n-and-mail/04-PATTERNS.md b/.planning/phases/04-cli-scaffolding-i18n-and-mail/04-PATTERNS.md new file mode 100644 index 0000000..ca8733b --- /dev/null +++ b/.planning/phases/04-cli-scaffolding-i18n-and-mail/04-PATTERNS.md @@ -0,0 +1,198 @@ +# Phase 4: CLI scaffolding, i18n and mail — Pattern Map + +**Mapped:** 2026-09-18 +**Scope:** framework repository only; paths with `` describe generated output, not edits to `fonoteka.go`. +**Files classified:** 29 file or file-family targets +**Analogs found:** 24/29 (including partial matches) + +## File Classification + +| New/Modified File or Family | Role | Data Flow | Closest Existing Analog | Match | +|---|---|---|---|---| +| `cmd/summer/main.go` | CLI controller | request-response | `cmd/summer/main.go:26-82` | exact | +| `internal/build/scaffold.go` | service | file I/O | `internal/build/scaffold.go:24-81,219-250` | exact | +| `internal/build/registry.go` (or equivalent) | utility | transform/file I/O | `internal/build/build.go:126-165` | role | +| `internal/build/leaf.go` (or equivalent) | utility | file I/O | `internal/build/scaffold.go:219-250` | partial | +| `internal/build/stubs/*.tmpl` | template/config | transform | no embedded Go templates exist | none | +| `internal/build/build.go` | service | batch/file I/O | `internal/build/build.go:18-75` | exact | +| `pact/capabilities.go` | contracts | request-response | `pact/capabilities.go:11-53` | exact | +| `phrasebook/loader.go` | service | file I/O/transform | `compass/config.go:288-332` | role | +| `phrasebook/translator.go` | service | request-response | `compass/config.go:112-164` | partial | +| `phrasebook/plural.go` | utility | transform | no CLDR or pipe-form parser exists | none | +| `postcard/mailer.go` | service | request-response | `lagoon/connection.go:82-111` | partial | +| `postcard/templates.go` | service | file I/O/transform | `compass/config.go:288-332` | partial | +| `postcard/drivers.go` and `postcard/log.go` | service | request-response | `lagoon/connection.go:26-48` | partial | +| `postcard/smtp.go` | service | request-response | no SMTP sender exists | none | +| `postcard/memory.go` | service | event-driven | `backpack/services.go:9-60` | partial | +| `examples/hello/plugins/base/plugin.go` | provider | file I/O | same file, lines 3-30 | exact | +| `examples/hello/plugins/base/lang/{en,pl}/*.yaml` | asset/config | file I/O | `examples/hello/plugins/base/config/config.yaml` | partial | +| `examples/hello/plugins/base/views/mail/*.htm` | asset/config | file I/O/transform | no mail asset exists | none | +| `examples/hello/config/app.yaml`, `config/mail.yaml` | config | file I/O | `examples/hello/config/app.yaml:1-2` | role | +| `go.mod`, `go.sum` | config | dependency resolution | `go.mod:6-23` | exact | +| `/plugin.go` and `/registry.gen.go` | provider/generated config | batch | `examples/hello/plugins/base/plugin.go:17-30`; `internal/build/build.go:126-143` | role | +| `/models/.go` | model | CRUD | `pact/capabilities.go:51-54` only | partial | +| `/updates/.go`, `updates/migrations.go` | migration | CRUD | `lagoon/migrations_test.go:147-181` | role | +| `/console/.go` | CLI controller | request-response | `examples/hello/plugins/greeter/plugin.go:78-119` | role | +| `/jobs/.go`, `/controllers/.go` and YAML | job/controller | event-driven/request-response | no job or admin controller implementation exists | none | +| `internal/build/build_test.go`, `cmd/summer/main_test.go` | test | batch | same files, `build_test.go:143-242,340-485`; `main_test.go:17-40` | exact | +| `phrasebook/*_test.go`, `postcard/*_test.go` | test | request-response | `pact/capabilities_test.go:87-100`; `backpack/services_test.go` | role | +| `postcard/mailpit_test.go` | integration test | request-response | `lagoon/postgres_test.go:25-103` | role | +| `examples/hello/hello_test.go` | integration test | request-response | same file, lines 59-89, 91-108 | exact | + +The file names within new packages are planning suggestions; D-01–D-21 fix behavior and generated directory shapes, not implementation file splits. `go.sum` changes when dependencies are added. Generated assets under `` are products of `make:*`, with a temporary `examples/hello` copy as the compile/vet fixture. + +## Pattern Assignments + +### CLI command registration — `cmd/summer/main.go` + +Copy `cmd/summer/main.go:26-40` to register all five new commands; copy `:56-82` for argument lookup, working-directory resolution, delegation to `internal/build`, and output. A typical existing command is: + +```go +func makePluginCommand() bonfire.Command { + return bonfire.Command{ + Name: "make:plugin", + Args: []bonfire.Arg{{Name: "id", Required: true}}, + Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { + id, ok := in.Argument("id") + if !ok || id == "" { return fmt.Errorf("make:plugin requires a vendor.plugin id") } + dir, err := os.Getwd() + if err != nil { return err } + created, err := build.MakePlugin(ctx, dir, id) + if err != nil { return err } + out.Printf("created %s\n", created) + return nil + }, + } +} +``` + +For `make:`, expose both ` ` and the in-plugin omitted-ID form from D-17. `bonfire.Arg`/`Flag` definitions are the CLI validation surface; `build.ValidatePluginID` in `internal/build/manifest.go:110-122` supplies plugin ID validation. Add every new command name to `cmd/summer/main_test.go:17-40`. + +### Scaffold, registry and leaf check — `internal/build/` + +`internal/build/scaffold.go:24-81` is the existing create flow: validate ID, locate app root, check `underRoot`, reject an existing target, derive module path, write files, run `go mod tidy`, and preserve the pinned toolchain. `:219-250` parses `plugin.go` with `go/parser` and extracts the literal ID; use this when `` is omitted. `:417-425` is the path containment check. `internal/build/manifest.go:167-189` rejects dangerous module paths. + +For `registry.gen.go`, copy deterministic generation and idempotent output from `internal/build/build.go:126-165`: + +```go +func formatSource(src []byte) ([]byte, error) { + formatted, err := format.Source(src) + if err != nil { return nil, fmt.Errorf("build: format generated go: %w", err) } + return formatted, nil +} + +func writeIfChanged(path string, content []byte) error { + existing, err := os.ReadFile(path) + if err == nil && bytes.Equal(existing, content) { return nil } + if err != nil && !errors.Is(err, os.ErrNotExist) { return fmt.Errorf("build: read %s: %w", path, err) } + if err := os.WriteFile(path, content, 0o644); err != nil { + return fmt.Errorf("build: write %s: %w", path, err) + } + return nil +} +``` + +The source builder in `scaffold.go:159-180` currently uses `strings.Builder` and `format.Source`; D-16 replaces the string builder with `go:embed` + `text/template` under `internal/build/stubs/*.tmpl`. There is no in-repo embedded-template analog. Preserve its error wrapping, formatting, `party.Register(&Plugin{})`, and `go.mod` generation (`:182-216`). Use parsed imports as in `scaffold.go:219-250` and `cmd/summer/main_test.go:42-69` for the `models/` sibling-import check. Run that check from `build.App` before the `go build` subprocess at `internal/build/build.go:33-56`, reporting plugin ID, source path, and import path. + +**Generated registry contract:** Root `plugin.go` may import `models/`, `console/`, `jobs/`, `controllers/`, `updates/`; `models/` imports no sibling. Generated `registry.gen.go` owns only sorted/generated entries. Hand-written `plugin.go` must never be rewritten when a later `make:*` runs. If a hand-written plugin lacks accessors, create the registry and print the one-time accessor instruction (D-12). The existing `writeIfChanged` avoids rewriting identical bytes; use a temporary-file rename for registry updates as research recommends. + +### Generated plugin artifacts + +Use `examples/hello/plugins/base/plugin.go:3-30` as the root-plugin pattern: + +```go +//go:embed config +var configFS embed.FS + +type Plugin struct{} +func (p *Plugin) ID() string { return "golem15.hello" } +func (p *Plugin) Register(*backpack.App) error { return nil } +func (p *Plugin) Boot(*backpack.App) error { return nil } +func (p *Plugin) ConfigFS() fs.FS { return configFS } +func init() { party.Register(&Plugin{}) } +``` + +Add `HasLang`/`HasMailTemplates` embedded FS accessors only when matching asset files exist; empty directories do not satisfy `go:embed`. For generated commands, copy the `bonfire.Command` returned by `examples/hello/plugins/greeter/plugin.go:78-119`, particularly `Run(ctx, in, out)` and colon command names. For migrations, the closest concrete gormigrate set is `lagoon/migrations_test.go:147-181`: + +```go +&gormigrate.Migration{ + ID: "202609170001_create_alpha", + Migrate: func(tx *gorm.DB) error { + return tx.Exec(`CREATE TABLE lagoon_alpha (id BIGSERIAL PRIMARY KEY, name TEXT NOT NULL)`).Error + }, + Rollback: func(tx *gorm.DB) error { + return tx.Exec(`DROP TABLE IF EXISTS lagoon_alpha`).Error + }, +} +``` + +`pact.HasMigrations` returns an ordered `[]*gormigrate.Migration` (`pact/capabilities.go:23-28`); `lagoon/migrations.go:59-81` consumes it in activation order. The model stub should expose itself through `pact.HasModels` (`pact/capabilities.go:51-54`) and its create-table migration through the generated `updates/` set. `make:model --no-migration` omits only the migration. No production model, job, or admin-controller source exists in this repo, so D-13–D-15 and RESEARCH.md define their initial signatures. + +### Optional capabilities and boot wiring — `pact`, `party`, `backpack` + +Define `HasLang` and `HasMailTemplates` beside `pact.HasConfig` in `pact/capabilities.go:16-21`; its shape is `fs.FS` returned by an accessor. `pact/capabilities_test.go:12-16,87-100` shows `fstest.MapFS` fixtures and interface assertions. `party/registry.go:101-124` is the fixed capability/boot point: + +```go +for _, p := range ordered { + if hc, ok := p.(pact.HasConfig); ok && app.Config != nil { + if err := app.Config.MergePlugin(p.ID(), hc.ConfigFS()); err != nil { + return nil, fmt.Errorf("party: config %s: %w", p.ID(), err) + } + } +} +for _, p := range ordered { + if err := p.Register(app); err != nil { return nil, fmt.Errorf("party: register %s: %w", p.ID(), err) } +} +for _, p := range ordered { + if err := p.Boot(app); err != nil { return nil, fmt.Errorf("party: boot %s: %w", p.ID(), err) } +} +``` + +Place phrasebook/postcard asset loading so services are available to plugin `Boot`, while keeping `party` free of an import cycle. Preserve plugin order and name plugin ID plus missing template/layout in returned errors. `examples/hello/plugins/optional/plugin.go:21-30` publishes a service during `Register`; `backpack/app.go:59-74` and `backpack/services.go:20-59` define typed publish/lookup and duplicate-provider errors. `lagoon/connection.go:98-111` is another explicit service publisher. Publish translator and `postcard.Mailer` at app scope, not process scope. + +### Phrasebook — `phrasebook/` + +For embedded YAML loading, use `compass/config.go:288-332`: `fs.WalkDir`, sorted file paths, `fs.ReadFile`, contextual parse errors, then map conversion. Phrasebook must add the `lang//.yaml` path contract, flatten nested keys, and reject duplicate namespace ownership and malformed leaves. The existing config lookup pattern is `compass/config.go:112-164` (`Lookup`, `String`, `LoadSection`), but translation lookup must implement D-04's requested locale → parent → configured fallback → raw key sequence itself. + +`towel/context.go:7-23,55-63` **already supplies** an unexported `localeKey`, `WithLocale`, and `Locale`. `surf/router.go:324-327` writes the `Accept-Language` header there. Use that accessor as the Phase 7 context seam; avoid a second context key that cannot read middleware values. There is no CLDR/pipe parser analog; follow D-02/D-03 and RESEARCH.md: go-i18n selects a category label, phrasebook substitutes `:name`, `:Name`, `:NAME` itself, and pipe conditions are parsed explicitly. `go.mod:6-23` already has `github.com/goccy/go-yaml v1.19.2`; go-i18n is a new dependency. + +### Postcard — `postcard/` + +Use the `compass/config.go:288-332` FS walk/read/error pattern for template registration, but write a dedicated INI-header/`==` parser: no production template parser exists. The actual mail format is fixed by D-06–D-09, including three layout sections and caller-selected `-en` siblings. `postcard.Mailer.Send(ctx, postcard.Message{...})` is a new service; use `lagoon/connection.go:82-111` for typed `mail.*` config loading and app publication. `compass/config.go:157-164` supports `LoadSection("mail", &opts)`; `compass/env.go:31-44` maps `SUMMER_MAIL__...` to `mail.*` automatically. + +For driver errors, copy `lagoon/connection.go:26-48`: validate required settings early, wrap the underlying error with package context, clean up partial resources, and return errors to the caller. SMTP and Goldmark have no in-repo analog. D-07 and RESEARCH.md specify the render chain: `html/template` on Markdown source, Goldmark to HTML, substituted Markdown for text, and internally trusted layout HTML only. Test final HTML with HTML tags, Markdown links, and unsafe URLs; reject subject CR/LF before constructing mail headers. Keep explicit SMTP TLS policy and do not put credentials in log/error text. + +### Test and fixture patterns + +`internal/build/build_test.go:143-242` copies `examples/hello`, calls `MakePlugin`/`AddPlugin`, builds twice, and checks byte stability. The copy helper is at `:410-477`; it rewrites the framework `replace` before running Go commands. Extend this test to create every artifact type, run `go build` **and** `go vet`, check idempotent registry contents, and assert a `models/` sibling import is rejected. `assertScaffoldFiles` at `:340-408` currently treats any new file beyond `go.mod`, `plugin.go`, and `config/.gitkeep` as an error; update its allowlist for D-10/D-16. + +Use `pact/capabilities_test.go:87-100` for `fstest.MapFS` translation/template fixtures. Use `examples/hello/hello_test.go:59-89` to prove service availability through `party.Activate`. `lagoon/postgres_test.go:25-39,92-103` is the Docker gate: full test fails when Docker is unavailable, `-short` skips. Its container start/terminate and bounded timeout at `:25-89` are the closest Mailpit pattern; Mailpit adds HTTP API polling after SMTP send. `go.mod:18-19` already includes testcontainers core; Mailpit is a new image/API fixture. + +## Shared Patterns + +| Concern | Source | Apply To | +|---|---|---| +| Plugin order and named boot errors | `party/registry.go:96-124` | phrasebook/postcard registration and asset validation | +| Optional FS capability | `pact/capabilities.go:16-28`, `examples/hello/plugins/base/plugin.go:12-26` | `HasLang`, `HasMailTemplates`, generated plugin | +| App-scoped services | `backpack/app.go:59-74`, `examples/hello/plugins/optional/plugin.go:21-30` | translator and mailer | +| Layered config/env | `compass/config.go:157-164`, `compass/env.go:31-44` | `app.locale`, `app.fallback_locale`, `mail.*` | +| Embedded file parsing | `compass/config.go:288-332` | translation YAML and mail assets | +| Safe generated paths and source | `internal/build/manifest.go:110-122,167-189`, `internal/build/build.go:146-165` | all `make:*` outputs | +| Locale context | `towel/context.go:55-63`, `surf/router.go:324-327` | phrasebook lookup seam | +| Integration test Docker gate | `lagoon/postgres_test.go:25-39,92-103` | Mailpit SMTP test | + +## No Close Analog Found + +| Target | Why research/decisions must supply the pattern | +|---|---| +| `internal/build/stubs/*.tmpl` | Existing generated Go uses string builders, not embedded templates. | +| `phrasebook/plural.go` | No CLDR category selection or Laravel pipe parser exists. | +| `postcard/smtp.go` | No SMTP sender exists. | +| `examples/hello/plugins/base/views/mail/*.htm` | No Winter shaped mail asset exists in this repository; use CONTEXT.md's PHP canonical reference. | +| Generated `jobs/`, admin `controllers/` and `fields.yaml`/`columns.yaml` | No framework job or admin-controller implementation exists yet; D-14/D-15 define minimum compile contracts. | + +## Metadata + +**Search scope:** `cmd/summer`, `internal/build`, `pact`, `party`, `backpack`, `compass`, `lagoon`, `surf`, `towel`, `examples/hello`, `go.mod`; scanned Go/YAML files with `rg --files` and capability/test patterns with `rg -n`. +**Pattern extraction date:** 2026-09-18. +**Boundary:** No changes to `../fonoteka.go`; Phase 3 flat plugins are not restructured here. diff --git a/.planning/phases/04-cli-scaffolding-i18n-and-mail/04-RESEARCH.md b/.planning/phases/04-cli-scaffolding-i18n-and-mail/04-RESEARCH.md new file mode 100644 index 0000000..63653d8 --- /dev/null +++ b/.planning/phases/04-cli-scaffolding-i18n-and-mail/04-RESEARCH.md @@ -0,0 +1,84 @@ +# Phase 4 Research — CLI scaffolding, i18n and mail + +**Researched:** 2026-09-18 +**Scope:** `summercms.go` only +**Requirements:** CLI-02, I18N-01, I18N-03 + +## Summary + +The phase has three user-visible slices: a developer generates and builds a plugin artifact, a plugin resolves Polish and English strings, and a plugin renders and sends a registered mail template. The binding decisions are in `04-CONTEXT.md` D-01–D-21. The existing `internal/build` and `cmd/summer` code already provides the first `make:plugin` path; `pact` capability interfaces, `backpack` services, and `party.Activate` provide the integration points for the other two slices. No change to `../fonoteka.go` is required. + +The major implementation risk is generated Go code crossing plugin subpackage boundaries. Generate `registry.gen.go` from a deterministic artifact manifest or parsed declarations, format it with `go/format`, and compile a temporary `examples/hello` plugin after every artifact type. A second risk is using go-i18n's message templating when PHP `:name` placeholders must remain literal. Let go-i18n select only a CLDR category, then substitute PHP placeholders in phrasebook. Mail's main risk is treating rendered Markdown or layout HTML as trusted without checking the escaping boundary. + +## Existing code and seams + +| Concern | Existing implementation | Phase 4 extension | +|---|---|---| +| CLI | `cmd/summer/main.go` registers `bonfire.Command` values; `make:plugin` invokes `build.MakePlugin` | Add five `make:*` commands with the existing output/error convention; permit plugin ID omission only when `pluginIDFromGo` identifies the current plugin. | +| Scaffold | `internal/build/scaffold.go` writes `plugin.go` and `go.mod` with Go string builders; `build.App` generates app entry points and runs `go build` | Move source builders to embedded `text/template` files, create Winter directory shape, generate root registry accessors, and run a pre-build leaf-import check. Preserve `AddPlugin` and app manifest behavior. | +| Capabilities | `pact/capabilities.go` has `HasConfig`, `HasMigrations`, `HasCommands`, `HasRoutes` | Add `HasLang` and `HasMailTemplates` as `fs.FS` capabilities, plus minimal job and admin-controller interfaces from D-14/D-15. Compose generated migrations/commands through capability methods in scaffolded `plugin.go`. | +| Boot and services | `backpack.New(cfg)` holds services; `party.Activate` orders plugins and calls Register then Boot | Publish phrasebook translator and postcard mailer through backpack; load plugin assets in activation order, and fail boot with plugin ID plus missing template/layout name. Avoid requiring existing handwritten plugins to opt in. | +| Tests | `internal/build/build_test.go`, `examples/hello`, `go test ./...`; Phase 3 uses `testcontainers-go` for Postgres | Generate all six artifact types in a temp copy of `examples/hello`, then run `go build` and `go vet` there. Add focused phrasebook/postcard tests, plus a Mailpit SMTP integration test under the existing `-short`/Docker convention. | + +The directory layout and `models/` leaf rule in `.planning/notes/plugin-layout-winter-directories.md` are binding. The Phase 3 Fonoteka plugin layout is intentionally deferred; the leaf check must apply to new plugin packages without rewriting that repo. + +## Library findings + +### Translations + +[go-i18n v2.6.1's documented API](https://pkg.go.dev/github.com/nicksnyder/go-i18n/v2@v2.6.1/i18n) exposes `i18n.Message{Zero, One, Two, Few, Many, Other}` and `LocalizeConfig.PluralCount`. Its bundle/localizer selects the CLDR plural variant. The repository's `.planning/research/STACK.md` already selects v2.6.1; keep that version for this phase unless a concrete incompatibility appears. To honor D-03, feed category labels such as `"one"`, `"few"` and `"many"` as the message variants, read the chosen label, then select the original YAML string and perform `:name`, `:Name`, `:NAME` substitution locally. This keeps go-i18n's Go-template evaluation away from PHP translation text. Validate categories against each locale's actual categories and require `other` for maps. Test Polish counts 1/2/5/22 and English 1/2, including 0 and fractional counts. + +[goccy/go-yaml](https://pkg.go.dev/github.com/goccy/go-yaml) already appears in the project's chosen stack and can decode nested maps. Decode into string-keyed data, reject non-string leaves and duplicate full paths, and flatten `group.dot.path`. Treat a map as a plural map only when its keys are CLDR category names; otherwise recurse. This disambiguates nested namespaces from plural values. `fs.WalkDir` over `HasLang.LangFS()` can identify `lang//.yaml` files. Build each plugin's namespace before merging so duplicate ownership fails boot instead of silently replacing another plugin's translation. + +Locale fallback is an explicit phrasebook sequence: requested tag, parent tag, `app.fallback_locale`, then raw key. Go's language matching can help parse tags, but the fallback order must remain D-04's order. The configured default/fallback are `en`; no framework Polish default. Request-context locale resolution is Phase 7, so expose a context accessor now without adding user lookup. + +Laravel pipe forms need a separate parser: explicit `{n}` and `[a,b]` conditions win before category-order forms; only then use the CLDR category result. Test malformed brackets and missing forms as boot-time errors with plugin/file/key. + +### Mail + +[goldmark's documented default](https://github.com/yuin/goldmark) rejects raw HTML and potentially dangerous links unless `html.WithUnsafe` is enabled. Keep that default. D-07's render sequence remains `html/template` on Markdown source, then Goldmark to HTML; the rendered Markdown is the text part. Add a test with variable values containing HTML tags, Markdown link syntax, and `javascript:` URLs. `html/template` protects HTML syntax but does not necessarily neutralize Markdown syntax before Goldmark parses it, so assert the *final* HTML and mail body are safe. Any `template.HTML` used for the trusted, generated HTML part in a layout must be constructed internally, never from caller variables. Use `html/template` for subject and layout rendering too; ensure subjects cannot contain CR/LF header injection. + +[go-mail's current API](https://pkg.go.dev/github.com/wneessen/go-mail) provides `mail.NewClient(host, opts...)`, `WithPort`, TLS policy options, and `DialAndSendWithContext`; `Msg.SetBodyString` supports both plain text and HTML parts. The SMTP driver should use context-aware sending, explicit TLS behavior from `mail.*`, and structured recipient setters. Mailpit's local SMTP endpoint needs an explicit `NoTLS` policy, while production must not silently downgrade. Do not log credentials or full message bodies from SMTP errors. The log driver logs rendered headers and text in dev as D-18 requests, with its use controlled by config. + +[Mailpit's API documentation](https://mailpit.axllent.org/docs/api-v1/) confirms a REST API for stored messages and an interactive `/api/v1/` endpoint. Use a testcontainer with mapped SMTP and HTTP ports. Send through the real go-mail SMTP driver, then poll the HTTP API with a bounded deadline for the recipient/subject and parts. `testcontainers-go` exposes `WithExposedPorts` and mapped ports in its [Go API](https://pkg.go.dev/github.com/testcontainers/testcontainers-go). Pin the Mailpit image by tag in the implementation and keep the integration test out of `go test -short`. + +The parser must distinguish a template's header and Markdown body (`==`) from a layout's header, text wrapper and HTML wrapper (three sections as shown by the PHP reference). Registration should validate path/name mapping and layout references at Boot. D-08 says locale suffixes are literal names selected by the caller; postcard must not add fallback lookup. + +### Scaffolding + +The existing `MakePlugin` already validates `vendor.plugin`, finds the app root, writes `go.mod`, runs `go mod tidy`, and pins toolchain. Keep this behavior. Add `internal/build/stubs/*.tmpl` as `go:embed` assets. Render into memory, pass every Go file through `go/format`, and only then write files. Use stable sorted registry entries and atomic write/rename for `registry.gen.go` so repeated commands are idempotent and a failed template does not leave a partial registry. Refuse duplicate artifact names before writes. + +The generated root `plugin.go` should call generated accessors for migrations and commands, and expose job/admin-controller/model slices as minimum interfaces now. Existing handwritten plugins get the one-time printed accessor line D-12 specifies. Avoid package import cycles: root imports `models/`, `console/`, `jobs/`, `controllers/`, `updates/`; `models/` imports no sibling. A pre-build check can parse each plugin's `models/*.go` imports using `go/parser` and match against its own module path plus sibling directory segment; report plugin ID, source file and offending import. Check embedded assets in `lang/` and `views/mail/` compile when present; Go `embed` patterns fail for empty directories, so scaffold placeholder files that match the actual embed patterns. + +The model stub needs a migration with an explicit table and up/down callbacks. Use `gormigrate.Migration` as the existing Phase 3 capability does, not `AutoMigrate`. The admin-controller stub carries only ID/model/config-directory and YAML placeholders; the job stub carries `Kind()` and `Work(ctx,args)` without River imports. Test both standalone generation and the sequence that appends several entries to one registry. + +## Recommended plan structure + +Use MVP slices, because ROADMAP declares Phase 4 `Mode: mvp`: (1) scaffold and build a plugin with all generated artifact types; (2) load and resolve translations from an embedded plugin; (3) render and send registered mail with memory and SMTP drivers; (4) final unit/integration gate. A test or fixture may start a plan, but each implementation task should leave a developer-visible working path. The final plan should own the complete phase test gate, in keeping with prior phases' unit-test-last convention. Keep root/app checks separate; no `../fonoteka.go` edits. + +## Validation Architecture + +| Requirement | Automated proof | Fast feedback | Full gate | +|---|---|---|---| +| CLI-02 | Generate plugin/model/migration/command/job/admin-controller into a temp `examples/hello` copy; `go build` and `go vet` it; inspect generated registry, YAML assets and imports | `go test ./internal/build ./cmd/summer` | `go vet ./... && go test ./...` plus dedicated scaffold compile test | +| I18N-01 | Embedded pl/en YAML, nested and plural keys, `:name` case variants, pl-PL→pl→en fallback, raw missing key, malformed YAML/pipe failures | `go test ./phrasebook` | Root vet/test and hello plugin smoke | +| I18N-03 | Memory driver assertions for subject, Markdown text, HTML/layout, recipients and boot errors; malicious variable cases; real go-mail SMTP delivery observed through Mailpit HTTP API | `go test ./postcard -short` | `go test ./postcard -run TestSMTPMailpit -count=1` plus root vet/test | + +Sampling: run focused package tests after each task and root `go vet ./... && go test ./...` after each wave. The Mailpit test must fail if Docker is unavailable when run normally and skip only under `-short`, matching the project testcontainers convention. Add `go test -race ./phrasebook ./postcard ./internal/build` at the final gate if quick checks are green. Test boot failure messages and the `models/` sibling-import rejection as part of CLI-02, not only the happy path. + +## Threats and pitfalls to carry into plans + +- **Generated source/import tampering:** validate artifact names as Go identifiers and file paths, reject traversal and module escape, format before write, and enforce `models/` as leaf. Do not rewrite handwritten `plugin.go`. +- **Translation namespace collision or parser ambiguity:** reject duplicate plugin IDs/keys and malformed plural maps/pipe conditions at boot with file/key context. +- **Mail content injection:** test final Markdown-derived HTML, unsafe links, layout content trust, subject CR/LF, and recipient validation. Do not enable Goldmark `WithUnsafe`. +- **SMTP transport downgrade/secrets:** choose TLS policy explicitly; keep passwords out of log/errors; propagate driver failure through `Send`. +- **False green integration:** assert Mailpit API receipt after SMTP send; `go test -short` is not the full sign-off. + +## Source links + +- [go-i18n v2.6.1 package API](https://pkg.go.dev/github.com/nicksnyder/go-i18n/v2@v2.6.1/i18n) +- [goccy/go-yaml package API](https://pkg.go.dev/github.com/goccy/go-yaml) +- [goldmark README and security defaults](https://github.com/yuin/goldmark) +- [go-mail package API](https://pkg.go.dev/github.com/wneessen/go-mail) +- [Mailpit API documentation](https://mailpit.axllent.org/docs/api-v1/) +- [testcontainers-go package API](https://pkg.go.dev/github.com/testcontainers/testcontainers-go)