13 KiB
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 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 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/<locale>/<group>.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.
goldmark's documented default 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 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 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. 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 handwrittenplugin.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 -shortis not the full sign-off.