D-06: A plugin registers vendor.plugin::mail.name from views/mail/<name>.htm; INI-style subject/description/layout headers precede == and a templated Markdown body.
D-07: html/template substitutes body variables into Markdown, Goldmark renders HTML, and the substituted Markdown becomes the text part; final HTML rejects raw HTML and unsafe links.
D-08: An unsuffixed template is the app-default-locale file and explicit -en siblings are selected by the caller's full dotted name; Send performs no locale selection.
D-09: A plugin maps short layout names to vendor.plugin::mail.layouts.name; each layout has header, text wrapper and HTML wrapper sections using .Content, shared css/brandCss values, and postcard ships a neutral default layout.
D-18: smtp via go-mail, log, and memory drivers share one interface; mail.* config and SUMMER_MAIL__ overrides select driver/connection settings.
D-19: A registered template/layout renders via memory with asserted subject, HTML and text; Mailpit later proves real SMTP receipt, failing when Docker is absent except under -short.
D-20: backpack publishes postcard.Mailer with Send(ctx, postcard.Message{Template, To, Cc, Bcc, ReplyTo, Vars}) error and an optional subject override; no per-template Go type is required.
D-21: Send propagates driver errors without retry; missing template files or unregistered referenced layouts fail Boot with plugin ID and missing name.
path
provides
postcard/templates.go
Winter-shaped template and layout registration/rendering
path
provides
postcard/mailer.go
app-scoped Send service and message contracts
path
provides
postcard/drivers.go
memory, log, and context-aware go-mail SMTP drivers behind one interface
path
provides
postcard/assets/default.htm
neutral framework layout
path
provides
party/registry.go
HasMailTemplates registration and named boot validation
from
to
via
examples/hello/plugins/base/plugin.go
pact.HasMailTemplates
embedded mail FS plus explicit template/layout names
from
to
via
party/registry.go
postcard/mailer.go
ordered registration and app.Publish[postcard.Mailer] before Boot
from
to
via
postcard/mailer.go
postcard/drivers.go
driver.Send after render and validation
Phase Goal
As a plugin developer, I want to generate compiling plugin artifacts, resolve translated strings, and send registered mail, so that I can port WinterCMS plugins into one SummerCMS binary.
Register Winter-shaped mail templates and layouts, render their text and HTML parts, and send via memory, log, or SMTP.
Purpose: Plugin mail can be ported by copying the familiar file shape and selecting a driver through application config.
Output: postcard service, template/layout parser, three drivers, and hello mail assets.
@CLAUDE.md
@.planning/ROADMAP.md
@.planning/REQUIREMENTS.md
@.planning/phases/04-cli-scaffolding-i18n-and-mail/04-CONTEXT.md
@.planning/phases/04-cli-scaffolding-i18n-and-mail/04-RESEARCH.md
@.planning/phases/04-cli-scaffolding-i18n-and-mail/04-PATTERNS.md
@.planning/phases/04-cli-scaffolding-i18n-and-mail/04-01-SUMMARY.md
@.planning/phases/04-cli-scaffolding-i18n-and-mail/04-02-SUMMARY.md
@/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/views/mail/collection_invitation-en.htm
@/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/views/mail/layouts/plytarium.htm
From Plan 01: pact.HasMailTemplates has MailTemplatesFS() fs.FS, MailTemplates() []string, and MailLayouts() map[string]string, with short layout name as map key and full dotted layout name as value. party.Activate can inspect this capability in Requires order after HasConfig merge. backpack.App.Publish[T] and Lookup[T] keep the mailer app-scoped. Define postcard.Message with Template string, To/Cc/Bcc []string, ReplyTo string, Vars map[string]any, and optional Subject string override. Define postcard.Mailer as Send(context.Context, Message) error and a driver contract Send(context.Context, RenderedMessage) error. A RenderedMessage contains validated recipients, subject, HTML and text bodies. The neutral default layout is internal to postcard, while plugin aliases resolve only names registered by that plugin. Read mail.driver, mail.from, mail.smtp.host, mail.smtp.port, mail.smtp.username, mail.smtp.password, mail.smtp.tls, and mail.smtp.timeout from compass. For the log driver, look up an app-scoped *slog.Logger from backpack and use slog.Default only when no app logger was published.
Task 1: Render and send one registered template through memory
postcard/mailer.go, postcard/templates.go, postcard/drivers.go, postcard/assets/default.htm, postcard/mailer_test.go, examples/hello/plugins/base/views/mail/hello.htm, examples/hello/plugins/base/views/mail/hello-en.htm, go.mod, go.sum
postcard/mailer.go (create); postcard/templates.go (create); postcard/drivers.go (create); pact/capabilities.go; backpack/services.go; compass/config.go; /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/views/mail/collection_invitation-en.htm; 04-CONTEXT.md D-06 through D-08 and D-20; 04-RESEARCH.md Mail
Write a failing memory-driver smoke that registers a template, calls Send and checks subject, HTML and text; then implement that path before committing. Parse exact Winter INI header followed by a separator line == and Markdown body, including templated subject. Register only declared dotted names and map each to views/mail/.htm under the owner plugin FS. Render html/template substitutions on Markdown, keep that result as text, then use research-named Goldmark for HTML without unsafe HTML enabled. Validate the final HTML against dangerous schemes and raw HTML; keep any trusted HTML type internal to layout rendering. Add an embedded neutral default layout. Make hello.htm and hello-en.htm distinct, with the caller choosing the full -en name; do no locale lookup in Send. In drivers.go, define the driver contract and store rendered messages in a concurrency-safe memory implementation; Task 3 adds log and SMTP implementations to this same file.
go test ./postcard -run TestMailRenderSmoke -short -count=1 && go vet ./... && go test ./...
Sending through memory yields the chosen template's subject, Markdown text and safe HTML; the -en sibling is used only when named explicitly.
A direct postcard.Mailer send with a registered template succeeds through memory.
Task 2: Register plugin layouts and publish the mailer at Boot
postcard/templates.go, postcard/mailer.go, postcard/mailer_test.go, party/registry.go, examples/hello/plugins/base/plugin.go, examples/hello/plugins/base/views/mail/layouts/hello.htm, examples/hello/hello_test.go
postcard/templates.go; postcard/mailer.go; postcard/mailer_test.go; party/registry.go; pact/capabilities.go; examples/hello/plugins/base/plugin.go; examples/hello/hello_test.go; /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/views/mail/layouts/plytarium.htm; /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/Plugin.php; 04-CONTEXT.md D-09, D-20, D-21
Parse layout files as header, text wrapper, and HTML wrapper split by two == lines; render .Content into each and resolve configured or plugin-provided css and brandCss as shared values. Require a short-name mapping such as hello to golem15.hello::mail.layouts.hello and resolve a template's layout header through that map; default selects postcard's neutral layout. In party.Activate, collect HasMailTemplates catalogs in plugin order and publish postcard.Mailer to backpack before plugin Boot; validate each catalog at its Boot transition and wrap missing-file or unknown-layout errors as party: boot with the full missing name. Add hello plugin FS, explicit template/layout registrations, and an activation smoke that obtains postcard.Mailer from backpack. Keep the framework generic: no Fonoteka-specific layout is copied into it.
go test ./postcard -run 'TestMailRenderSmoke|TestMailLayoutSmoke' -short -count=1 && go -C examples/hello test ./... && go vet ./... && go test ./...
A hello plugin registers its names and sends through the published mailer; text and HTML wrappers both contain content; broken registrations fail Boot with names.
The app-scoped mailer is available to plugin Boot and its layout registration is validated.
Task 3: Add config-selected log and SMTP delivery
postcard/drivers.go, postcard/mailer.go, postcard/mailer_test.go, examples/hello/config/mail.yaml, go.mod, go.sum
postcard/drivers.go; postcard/mailer.go; postcard/mailer_test.go; lagoon/connection.go; compass/config.go; compass/env.go; examples/hello/config/app.yaml; 04-CONTEXT.md D-18 through D-21; 04-RESEARCH.md Mail
Load mail.driver, mail.from, and mail.smtp.host/port/username/password/tls/timeout via compass, including SUMMER_MAIL__ overrides, and select exactly memory, log or smtp. In drivers.go, keep the log and SMTP implementations separate by type and constructor under the shared Driver interface. The log driver writes rendered headers and the text part to a *slog.Logger from backpack for development, using slog.Default only if the app has not published one; exclude credentials. Implement SMTP with research-named go-mail, context-aware sending, structured To/Cc/Bcc/ReplyTo setters, explicit TLS policy including an opt-in NoTLS setting for Mailpit, and no silent production downgrade. Validate addresses and reject CR/LF in subject/header values before constructing the go-mail message. Return any driver failure from Send unchanged except safe package context, without retries or secret/body leakage. Use a deterministic in-process failing driver smoke here; the real Mailpit receipt is Plan 04's dedicated integration gate.
go test ./postcard -run 'TestMailRenderSmoke|TestMailDriverSmoke' -short -count=1 && go vet ./... && go test ./...
mail.driver selects memory/log/smtp from config, the SMTP branch constructs a valid message and propagates errors, and unsafe headers or addresses are rejected before send.
Registered template sends through all three driver contracts and a failing driver surfaces its error to the caller.
<threat_model>
Trust Boundaries
Boundary
Description
Plugin files and caller Vars to rendered mail
Untrusted values enter Markdown, HTML, layouts and headers.
mail.* config to network
SMTP host, TLS mode and credentials control outbound transport.
STRIDE Threat Register
Threat ID
Category
Component
Disposition
Mitigation Plan
T-04-07
Tampering
template/layout registration
mitigate
Validate dotted owner names and paths; fail Boot for missing declared files and unknown layout aliases.
T-04-08
Information disclosure
Markdown/HTML renderer
mitigate
Keep Goldmark unsafe HTML disabled, verify final HTML/URL schemes, and permit trusted layout HTML only from internal generated content.
T-04-09
Tampering
mail headers
mitigate
Reject CR/LF in subject and header values and validate all recipient addresses before go-mail setters.
T-04-10
Information disclosure
SMTP credentials/logging
mitigate
Require explicit TLS policy; redact secrets and bodies from SMTP errors; log driver never logs credentials.
T-04-11
Denial of service
SMTP send
mitigate
Use context-aware send, configured timeout, and return driver errors without retry loops.
T-04-SC
Tampering
Go module resolution
mitigate
Add only research-named Goldmark and go-mail modules; npm/pip/cargo package gate is inapplicable.
</threat_model>
After each task run focused postcard smoke and root go vet ./... plus go test ./.... Run hello activation smoke from its nested module at the end. Confirm final rendered HTML, text and subject separately. Plan 04 owns adversarial and Mailpit integration coverage.
<success_criteria>
I18N-03 has explicit dotted template and short layout registration, Winter-shaped parsing, safe HTML and Markdown text, caller-selected locale suffix, app-scoped Send, and memory/log/SMTP drivers selected by mail.*. Missing registrations and SMTP errors are visible to callers.
</success_criteria>
Create .planning/phases/04-cli-scaffolding-i18n-and-mail/04-03-SUMMARY.md when done.