diff --git a/.planning/quick/261029-d9j-user-plugin-mail-locale-normalize-accept/261029-d9j-PLAN.md b/.planning/quick/261029-d9j-user-plugin-mail-locale-normalize-accept/261029-d9j-PLAN.md new file mode 100644 index 00000000..978038d6 --- /dev/null +++ b/.planning/quick/261029-d9j-user-plugin-mail-locale-normalize-accept/261029-d9j-PLAN.md @@ -0,0 +1,226 @@ +--- +phase: quick-261029-d9j +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin/classes/mail.go + - /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin/classes/mail_test.go + - /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin/controllers/api_controller.go + - /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin/controllers/registration.go + - /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin/controllers/users_admin_controller.go + - /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin/config/config.yaml + - /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin/plugin.go + - /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin/mail_locale_test.go + - /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin/views/mail/activate.htm + - /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin/views/mail/reactivate.htm + - /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin/views/mail/restore.htm + - /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin/mail_subjects_test.go + - /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin/README.md +autonomous: true +requirements: + - quick-261029-d9j + +estimate: + tokens: 55000 + raw_tokens: 55000 + tasks: 3 + confidence: low + +must_haves: + truths: + - "A POST /_user/api/v1/forgot-password for a user without preferred_locale, whose request locale is the raw header value en-GB,en;q=0.9, sends golem15.user::mail.restore-en (the reported bug, per D-01)" + - "preferred_locale values with region subtags or other casing (en-GB, EN, en_US, pl-PL) pick the same mail as en / pl; an unsupported preferred_locale (de) falls through to the request locale (D-01)" + - "When neither the user nor the request names pl or en, the mail language is golem15.user.mail.fallback_locale; its default pl keeps every existing Polish app on the unsuffixed templates exactly as before, and SUMMER_GOLEM15__USER__MAIL__FALLBACK_LOCALE=en makes an English-only app send the -en templates (D-02)" + - "A non-empty golem15.user.mail.fallback_locale that is neither pl nor en (after normalisation) fails plugin Boot with an error naming the key and the value" + - "activate.htm, reactivate.htm and restore.htm carry Polish subject lines; invite.htm keeps its Polish subject; every -en template and the layout are byte-identical to before (D-03)" + - "Template names, routes, request and response payloads are unchanged; MailTemplate(base, locale) and ResolveMailLocale(ctx, user) keep their signatures, so the external caller in sm-mfa-plugin still compiles (D-04)" + artifacts: + - path: /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin/classes/mail.go + provides: "NormalizeMailLocale, MailFallbackLocale, ResolveMailLocaleFor; MailTemplate and ResolveMailLocale normalise" + - path: /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin/config/config.yaml + provides: "mail.fallback_locale default pl" + - path: /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin/classes/mail_test.go + provides: "table tests for normalisation, resolution order and fallback (no Docker)" + - path: /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin/mail_subjects_test.go + provides: "TestPolishMailSubjects: each Polish template's rendered subject differs from its -en twin (no Docker)" + key_links: + - from: "controllers mailName (all five call sites: registration.go FinishLogin, api_controller.go reactivate/restore/activate, users_admin_controller.go invite)" + to: "classes.ResolveMailLocaleFor(ctx, app, user)" + via: "mailName gains the *backpack.App parameter" + - from: "plugin.go Boot" + to: "classes.MailFallbackLocale(app)" + via: "boot-time validation of golem15.user.mail.fallback_locale" +--- + + +Fix the user plugin's mail language selection so an English-only app (no translate plugin, raw Accept-Language on the request context) stops sending the Polish reset mail with an English subject. + +Purpose: `classes.MailTemplate` adds `-en` only for the exact value `en`, while the framework stores the raw Accept-Language header (for example `en-GB,en;q=0.9`) as the request locale when no LocaleResolver is published, so the Polish template always wins; and three Polish templates carry English subjects. This is a shared core plugin: the fix is additive and backwards compatible (D-01, D-04). + +Output: locale normalisation plus a configurable fallback in `classes/mail.go`, controllers passing the app, the `golem15.user.mail.fallback_locale` key (default `pl`), Polish subjects, standalone tests, README updates, one commit in sm-user-plugin. + +Locked decisions (user, 2026-10-29), cited below as D-01..D-06: +- D-01: fix in sm-user-plugin only, no framework change; normalise Accept-Language-style values (comma list, q-weights, region subtags, case) to the best language the plugin has mail for (en, pl), and apply the same normalisation to preferred_locale. +- D-02: backwards-compatible fallback config key under golem15.user.*, default pl; English-only apps set en, also through the SUMMER_ env override. +- D-03: translate the English subject lines of the Polish templates into Polish; -en templates unchanged. +- D-04: no breaking changes: template names, routes, payloads stay. +- D-05: README updated in the same commit as the code; table unit tests (no Docker) for normalisation and fallback, plus a Polish-subject test. +- D-06: commit in sm-user-plugin with explicit paths, no co-author tags; no tag, push or app pointer bumps; report which tests ran. + +Claude's discretion choices (documented): +- Key name `golem15.user.mail.fallback_locale` (env `SUMMER_GOLEM15__USER__MAIL__FALLBACK_LOCALE`): it is only used when user and request name no supported language, so "fallback" (as in Laravel's app.fallback_locale) describes it better than the suggested `mail.locale`, which reads like a forced language. +- An invalid non-empty value fails Boot, like golem15.user.turnstile, password.public_routes and backend_links.request_ttl already do. Default and unset never fail. +- New exported helpers follow the plugin's `...For(app)` convention (MintAPITokenFor, APITokenScopesFor). +- Polish subjects: activate "Aktywuj konto", reactivate "Witaj ponownie", restore "Ustaw nowe hasło" (each matches its body's wording and the informal "Cześć"). +- Resolution order stays user first, then request, then fallback, per D-01/D-02. Consequence to note in README: on an app with fallback en, a visitor whose browser sends a Polish-first Accept-Language still gets Polish mail. + + + +@~/.claude/msd-core/workflows/execute-plan.md +@~/.claude/msd-core/templates/summary.md + + + +@/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/CLAUDE.md +@/media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin/classes/mail.go +@/media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin/classes/mail_test.go + +Working repo for every task: /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin (its own git repo; go.mod replaces the framework with ../summercms.go, so tests build standalone). There is no CLAUDE.md in the plugin; follow the summercms.go CLAUDE.md rules. + +Facts gathered during planning (do not re-derive): +- Framework behaviour (not to be changed, D-01): /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/modules/surf/router.go `(*Router).locale` stores the LocaleResolver's code when one is published, else `req.Header.Get("Accept-Language")` verbatim, via `towel.WithLocale`; read with `towel.Locale(ctx) (string, bool)`. +- mailName call sites (unexported helper in controllers/api_controller.go, ~line 1160): controllers/registration.go ~line 121 (FinishLogin, reactivate), controllers/api_controller.go ~lines 163 (reactivate), 479 (restore), 940 (activate), controllers/users_admin_controller.go ~line 381 (invite; `app := c.application()` is already defined a few lines above). Each site already has an `app *backpack.App` in scope (it is passed to sendTemplateVars). Console commands send no mail. +- External caller: /media/nvme/dev/golem15/summercms.io/summercms/sm-mfa-plugin/classes/email.go calls `userclasses.MailTemplate(EmailCodeTemplate, userclasses.ResolveMailLocale(ctx, user))`. Both signatures MUST stay (D-04). Do not edit sm-mfa-plugin (D-01 scope). +- Existing test classes/user_groups_test.go TestUserClassHelpers asserts ResolveMailLocale: saved pl wins over request en, empty saved falls to request en, no locale gives "". It must keep passing unchanged. +- Config access pattern: `app.Config.String(key)` / `app.Config.Lookup(key)`, guarded by `app != nil && app.Config != nil` (see classes/throttle.go, classes/api_tokens.go). classes already imports backpack. +- Standalone app for tests: classes/user_groups_test.go ~line 144 builds one with `compass.Open(compass.Options{Dir: t.TempDir(), Env: "development", Environ: [...]})` then `backpack.New(cfg)`; an Environ entry `SUMMER_GOLEM15__USER__MAIL__FALLBACK_LOCALE=en` maps to `golem15.user.mail.fallback_locale` (compass env.go splits on `__`, lowercases). Root package: `bareApp(t)` (admin_users_edge_test.go) and `app.Config.MergePlugin("golem15.user", configFS)` (password_routes_test.go ~line 145) load the plugin's config.yaml defaults. +- Mail rendering without an app or DB: root mail_test.go `publishMemoryMail` registers the catalog with `cat.Register(p.ID(), p.MailTemplatesFS(), p.MailTemplates(), p.MailLayouts())`, builds `postcard.NewMailer(cat, postcard.NewMemoryDriver(), postcard.Options{From: ...})`; `MemoryDriver.Messages()` returns `[]postcard.RenderedMessage` with a `Subject` field. +- Docker-backed root tests use `sessionApp(t, "0s")`, `insertUser`, `publishMemoryMail`, `assertTemplate`; newer ones start with `if testing.Short() { t.Skip("Postgres (testcontainers)") }`. `postJSON` calls a handler directly, so the router's locale middleware does not run: put the locale on the request context yourself with `towel.WithLocale`. +- Baseline (planning run, 2026-10-29): `go test -short -count=1 ./...` passes everything except TestRegisterCORSPath ("config/http.yaml not found"; it needs an application checkout, as the README Testing section says). Docker is available on this machine. +- Plugin tree was clean at planning time. Several sessions work in parallel: run `git status --short` before editing and before staging; never stage files you did not change. + + + + + + Task 1: Normalised mail locale with configured fallback, wired through every mailName call site + /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin/classes/mail.go, /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin/classes/mail_test.go, /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin/controllers/api_controller.go, /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin/controllers/registration.go, /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin/controllers/users_admin_controller.go + classes/mail.go, classes/mail_test.go, classes/user_groups_test.go (lines 90-160: TestUserClassHelpers and the compass/backpack app pattern), controllers/api_controller.go around mailName (~1160) and its three callers, controllers/registration.go ~121, controllers/users_admin_controller.go ~370-385 + + - NormalizeMailLocale table (D-01): "en"→en, "pl"→pl, "EN"→en, "en-GB"→en, "en_US"→en, "pl-PL"→pl, " en-gb ; q=0.9 "→en, "en-GB,en;q=0.9"→en, "pl-PL,pl;q=0.9,en-US;q=0.8,en;q=0.7"→pl, "de-DE,de;q=0.9,en;q=0.8"→en, "de-DE,en;q=0.5,pl;q=0.7"→pl, "en;q=0.5,pl;q=0.5"→en (tie keeps header order), "en;q=0,pl"→pl, "en;q=0"→"", "en;q=abc,pl;q=0.1"→pl (unparsable q drops that entry), "en;q=1.5"→"" (out of range drops it), "english"→"", "de"→"", "*"→"", ""→"", " "→"". + - MailTemplate(base, locale): "en", "en-GB", "en-GB,en;q=0.9" give base+"-en"; "pl", "", "de", "pl-PL,en;q=0.5" give base (existing pl/""/en cases still pass). + - ResolveMailLocale(ctx, user), signature unchanged: saved "en-GB" with request "pl" → en; saved "PL" → pl; saved "de" with request "en-GB,en;q=0.9" → en (unsupported saved value falls through); nil user with request "en-GB,en;q=0.9" → en; nil user, no request locale → ""; saved "" with request "fr" → "". + - MailFallbackLocale(app): nil app → ("pl", nil); app without the key → ("pl", nil); "" or whitespace → ("pl", nil); "en" → ("en", nil); "EN-gb" → ("en", nil); "pl" → ("pl", nil); "de" → ("pl", error whose text contains golem15.user.mail.fallback_locale and the quoted value "de"); env override through compass Environ SUMMER_GOLEM15__USER__MAIL__FALLBACK_LOCALE=en → ("en", nil). + - ResolveMailLocaleFor(ctx, app, user) always returns "pl" or "en": user/request win when supported; otherwise the fallback (on a fallback error it uses "pl"). End-to-end case for the reported bug: towel.WithLocale(ctx, "en-GB,en;q=0.9"), user with nil PreferredLocale, default config → MailTemplate("golem15.user::mail.restore", ResolveMailLocaleFor(...)) == "golem15.user::mail.restore-en"; same user with no request locale and fallback en (via Config.Set and via the SUMMER_ env) → restore-en; with default config → "golem15.user::mail.restore". + + +Write the tests first in classes/mail_test.go (extend the existing TestMailTemplate; add TestNormalizeMailLocale, TestResolveMailLocale, TestMailFallbackLocale and TestResolveMailLocaleFor as table tests covering every behavior row above), run them red, then implement in classes/mail.go. All of this runs without Docker (D-05). Do not edit classes/user_groups_test.go. + +In classes/mail.go (standard library only: strings, strconv, fmt; no golang.org/x/text, no new dependency per CLAUDE.md): +- Unexported constants: the config key golem15.user.mail.fallback_locale, the default locale pl, and the supported set pl and en (pl is the unsuffixed template, en the -en one). +- NormalizeMailLocale(raw string) string (D-01): split raw on commas; for each entry split on semicolons, the first part is the tag (trimmed, lowercased, cut at the first hyphen or underscore to its primary subtag); a q parameter (q=, case-insensitive, trimmed) is parsed with strconv.ParseFloat, default 1; drop the entry when q does not parse, is outside 0..1, or is 0; keep only primary subtags in the supported set (the wildcard and anything else are ignored). Return the kept entry with the highest q, ties going to the earliest; "" when none. Keep it a single linear pass with no regexp; a header is attacker-controlled input but the output is limited to the two-value allowlist. +- MailTemplate(base, locale): unchanged signature; append -en when NormalizeMailLocale(locale) is en, else return base. Update the doc comment: other values, empty included, give the unsuffixed Polish template; callers that want the configured fallback resolve the locale with ResolveMailLocaleFor first. +- ResolveMailLocale(ctx, user): unchanged signature (D-04, sm-mfa-plugin calls it). Return NormalizeMailLocale of the user's PreferredLocale when that is supported, else NormalizeMailLocale of towel.Locale(ctx) when supported, else "". Doc comment: returns pl, en or "". +- MailFallbackLocale(app *backpack.App) (string, error) (D-02): nil app or nil Config, or a missing/blank value, gives pl and nil. Otherwise normalise the trimmed value; a supported result is returned with nil; an unsupported one returns pl and an error formatted like the plugin's other config errors, for example: golem15.user: golem15.user.mail.fallback_locale must be pl or en, got "de". Doc comment names the key, the default, and SUMMER_GOLEM15__USER__MAIL__FALLBACK_LOCALE. +- ResolveMailLocaleFor(ctx context.Context, app *backpack.App, user *models.User) string: ResolveMailLocale, then the MailFallbackLocale value when that is "" (ignore the error here, Boot validates it in Task 2). Doc comment: always pl or en; the helper other plugins use to mail in the user's language with the application's fallback. + +In controllers/api_controller.go change mailName to take the application: mailName(ctx context.Context, app *backpack.App, user *models.User, base string) string, returning classes.MailTemplate(base, classes.ResolveMailLocaleFor(ctx, app, user)). Update all five call sites listed in the context section to pass their app (registration.go FinishLogin, api_controller.go reactivate/restore/activate, users_admin_controller.go invite, which computes the template before AfterCommit and already has app := c.application() above it). Template names, routes and payloads stay as they are (D-04). + +Do not commit yet: the single commit is made in Task 3 together with the README (D-05). + + + cd /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin && go build ./... && go vet ./classes ./controllers && go test -short -count=1 ./classes -run 'TestMailTemplate|TestNormalizeMailLocale|TestResolveMailLocale|TestMailFallbackLocale|TestUserClassHelpers' && N=$(grep -rn 'mailName(ctx, app, ' controllers/*.go | wc -l) && test "$N" -eq 5 + + All listed classes tests pass (TestUserClassHelpers untouched and green); go build ./... compiles every mailName caller with the app parameter (five call sites); the reported bug case (raw en-GB,en;q=0.9 request locale, no preferred_locale) resolves to golem15.user::mail.restore-en in a standalone test; MailTemplate and ResolveMailLocale keep their signatures. + + + + Task 2: golem15.user.mail.fallback_locale default, Boot validation and the forgot-password request case + /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin/config/config.yaml, /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin/plugin.go, /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin/mail_locale_test.go + config/config.yaml, plugin.go Boot (~73-110) and bootBackendLinks (~200-215) for the boot-validation pattern, classes/user_lookup.go JWTSecret (~131-140), admin_users_edge_test.go bareApp (~80), password_routes_test.go (~140-155, MergePlugin with configFS), mail_test.go (publishMemoryMail, assertTemplate, softDelete), session_test.go postJSON (~622) and insertUser (~581) + + - TestMailFallbackLocaleConfig (no Docker): bareApp plus MergePlugin("golem15.user", configFS) → MailFallbackLocale is ("pl", nil) and the key is present with value pl; after Config.Set to en → en; to EN-gb → en; to de → error naming the key. + - TestMailFallbackLocaleBoot (no Docker): bareApp with a non-empty golem15.user.jwt.secret and golem15.user.mail.fallback_locale set to de → (&Plugin{}).Boot(app) returns an error containing golem15.user.mail.fallback_locale; with the key unset Boot does not fail on this check (assert only that any Boot error, if one occurs later for unrelated reasons such as a missing database, does not mention the key). + - TestMailLocaleFromRequest (Postgres, skipped under -short): forgot-password for a user with NULL preferred_locale and request context locale en-GB,en;q=0.9 → golem15.user::mail.restore-en; another user, no request locale, default config → golem15.user::mail.restore; after Config.Set fallback en, a third user with no request locale → restore-en; a user with preferred_locale en-GB and request locale pl → restore-en. + + +config/config.yaml (D-02): add a top-level mail group with fallback_locale: pl, placed after the activation group, with a short comment in the file's existing comment style: the language of the plugin's mails when neither the user's preferred_locale nor the request locale (the translate plugin's locale, else the Accept-Language header) names one the plugin has mail for (pl or en); pl keeps the unsuffixed Polish templates; an English-only site sets en (SUMMER_GOLEM15__USER__MAIL__FALLBACK_LOCALE=en); any other value fails boot. + +plugin.go Boot: right after classes.JWTSecret succeeds, call classes.MailFallbackLocale(app) and return its error, so a bad value fails boot before anything is started (same pattern as bootBackendLinks validating request_ttl). The default and an unset key never fail, so existing apps are unaffected (D-04). + +New root-package test file mail_locale_test.go (package user) with the three tests in the behavior block. For TestMailLocaleFromRequest start with the testing.Short skip used by the newer Postgres tests, reuse sessionApp(t, "0s"), publishMemoryMail, insertUser and assertTemplate, and build each forgot-password request with httptest.NewRequest (POST /_user/api/v1/forgot-password, JSON body with the email, Content-Type application/json, Host example.com) whose context carries towel.WithLocale with the raw header value, then call controllers.ForgotPassword(app) directly (postJSON cannot set the context). Use distinct emails (for example locale-header@example.test, locale-none@example.test, locale-fallback@example.test, locale-saved@example.test); set preferred_locale with gdb.Model(user).Update as softDelete does. Do not change the existing TestMail. + +Run the Postgres test too: Docker was available at planning time. If the Docker socket is denied although the user is in the docker group, retry through sudo -u golem as the summercms.go CLAUDE.md describes; if it still cannot run, say so in the summary instead of skipping silently (D-06). + +Do not commit yet (Task 3 makes the single commit). + + + cd /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin && go vet . && go test -short -count=1 -run 'TestMailFallbackLocaleConfig|TestMailFallbackLocaleBoot|TestPasswordPublicRoutes' . && go test -count=1 -run 'TestMailLocaleFromRequest|TestMail$' . + + config.yaml carries mail.fallback_locale: pl; Boot rejects an unsupported value naming the key; the standalone config and Boot tests pass; TestMailLocaleFromRequest and the existing TestMail pass against Postgres (or the summary states exactly why the Postgres run was not possible). + + + + Task 3: Polish subjects, subject test, README, single commit + /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin/views/mail/activate.htm, /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin/views/mail/reactivate.htm, /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin/views/mail/restore.htm, /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin/mail_subjects_test.go, /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin/README.md + views/mail/*.htm and views/mail/layouts/user.htm (already reviewed at planning: only activate.htm, reactivate.htm and restore.htm have English subjects; invite.htm has the Polish subject "Utworzono dla Ciebie konto"; the layout has no translatable text), README.md lines 56 (Mail templates feature bullet), 241 (classes API reference line), 266-298 (Configuration table) and 319-end (Testing) + +Templates (D-03): change only the subject line of the three Polish templates: activate.htm to Aktywuj konto, reactivate.htm to Witaj ponownie, restore.htm to Ustaw nowe hasło (UTF-8, same quoting as invite.htm). Leave description, layout, body, invite.htm, every -en template and layouts/user.htm untouched. + +New root-package test mail_subjects_test.go, TestPolishMailSubjects (no Docker, no app): register the catalog exactly as publishMemoryMail does, but with a bare postcard.NewMailer over a postcard.NewMemoryDriver and no backpack app. For every name in (&Plugin{}).MailTemplates() that has no -en suffix and whose name plus -en is also in the list, send both with vars covering Name, Link, Code, Hours and Login (both casings, as the controllers do) and assert: the Polish subject is non-empty and differs from the -en twin's subject. Fail with a message naming the template and both subjects. This keeps a future Polish template with a copied English subject from slipping through. + +README.md (D-05), same commit as the code: +- Features, Mail templates bullet: the locale follows the user's preferred_locale, then the request locale (the translate plugin's, else the raw Accept-Language header), each normalised (comma lists, q-weights, region subtags, case) to pl or en by NormalizeMailLocale, then golem15.user.mail.fallback_locale (ResolveMailLocaleFor); the Polish templates have Polish subjects. +- API reference classes line: name the mail locale helpers MailTemplate, ResolveMailLocale, ResolveMailLocaleFor, NormalizeMailLocale and MailFallbackLocale, and say that a plugin mailing in the user's language uses ResolveMailLocaleFor to get the application's fallback (ResolveMailLocale returns "" when neither user nor request names a supported language). Use neutral wording, no consuming application names. +- Configuration table: a row for golem15.user.mail.fallback_locale, default pl, after the golem15.user.activation.activation_ttl_hours row: the mail language when the user's preferred_locale and the request locale name neither pl nor en; pl keeps the unsuffixed templates, en (SUMMER_GOLEM15__USER__MAIL__FALLBACK_LOCALE=en) suits English-only sites; region forms such as en-GB are accepted; any other value fails boot; read on every send. Add that the user and request still come first, so a Polish-first browser gets Polish mail on an app whose fallback is en. +- Testing: one paragraph naming TestNormalizeMailLocale, TestResolveMailLocale, TestResolveMailLocaleFor, TestMailFallbackLocale (classes, -short), TestMailFallbackLocaleConfig, TestMailFallbackLocaleBoot and TestPolishMailSubjects (root, -short) and TestMailLocaleFromRequest (Postgres, skipped under -short). +Every identifier you name must exist in the package. + +Final gates, then commit (D-06): run go vet ./... and go test -short -count=1 ./... in the plugin; the only allowed failure is the pre-existing TestRegisterCORSPath (needs an application checkout), which the verify command skips with -skip. If the plugin's HEAD is no longer 91af10b when you start (another session committed), replace that sha in the verify command with the HEAD you started from and say so in the summary. Then git status --short, and commit in sm-user-plugin staging only these paths explicitly: classes/mail.go, classes/mail_test.go, controllers/api_controller.go, controllers/registration.go, controllers/users_admin_controller.go, config/config.yaml, plugin.go, mail_locale_test.go, mail_subjects_test.go, views/mail/activate.htm, views/mail/reactivate.htm, views/mail/restore.htm, README.md. Message: fix(quick-261029-d9j): pick the mail language from Accept-Language, add mail.fallback_locale, Polish subjects, with a short body (normalised preferred_locale and request locale, fallback key default pl, Boot rejects other values, Polish subjects for activate/reactivate/restore, no template, route or payload changes). No Co-Authored-By or other attribution trailer (the user's global rule overrides any attribution reminder). No tag, no push, no submodule pointer bump in any app, no edit to sm-mfa-plugin. + + + cd /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin && go test -short -count=1 -run TestPolishMailSubjects . && grep -q '^subject = "Ustaw nowe hasło"' views/mail/restore.htm && grep -q '^subject = "Aktywuj konto"' views/mail/activate.htm && grep -q '^subject = "Witaj ponownie"' views/mail/reactivate.htm && git diff --quiet 91af10b572cad5272337cc5d6f245c4d67ec1c82 -- views/mail/activate-en.htm views/mail/restore-en.htm views/mail/reactivate-en.htm views/mail/invite-en.htm views/mail/invite.htm views/mail/layouts/user.htm && grep -q 'golem15.user.mail.fallback_locale' README.md && go vet ./... && go test -short -count=1 -skip '^TestRegisterCORSPath$' ./... && MSG=$(git log -1 --format=%B) && SUBJ=$(git log -1 --format=%s) && test "$SUBJ" != "chore: require SummerCMS v0.5.2" && ! printf '%s' "$MSG" | grep -qi 'co-authored-by' && git status --short + + TestPolishMailSubjects passes; the three Polish subjects are in place and the -en templates, invite.htm and the layout are unchanged; README Features, API reference, Configuration and Testing describe the new behaviour; go vet ./... is clean and go test -short ./... passes with only the pre-existing TestRegisterCORSPath skipped; one new commit on top of 91af10b in sm-user-plugin with exactly the listed paths and no co-author trailer; nothing pushed or tagged. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| client -> user plugin API | The Accept-Language header (stored raw as the request locale) and the registration preferred_locale field are attacker-controlled strings | +| operator -> config | golem15.user.mail.fallback_locale comes from config.yaml or the SUMMER_ environment | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-261029d9j-01 | Tampering | classes.NormalizeMailLocale / MailTemplate | medium | mitigate | Output is restricted to the allowlist pl/en/""; the template name is only ever base or base+"-en", so no header value can select another template or path | +| T-261029d9j-02 | Denial of service | classes.NormalizeMailLocale | low | mitigate | Single linear pass with strings.Split and strconv.ParseFloat, no regexp or backtracking; header size is already bounded by the server's header limits | +| T-261029d9j-03 | Tampering | golem15.user.mail.fallback_locale | low | mitigate | Boot rejects any value that does not normalise to pl or en, naming the key; default pl | +| T-261029d9j-04 | Information disclosure | mail language choice | low | accept | The mail goes to the account owner only; the chosen language reveals nothing beyond what the requester sent | +| T-261029d9j-SC | Tampering | npm/pip/cargo/go installs | low | accept | No package installs or new dependencies in this plan (standard library only) | + + + +- Standalone (no Docker): go test -short -count=1 ./classes and the root TestMailFallbackLocaleConfig, TestMailFallbackLocaleBoot, TestPolishMailSubjects pass. +- Postgres: TestMailLocaleFromRequest and TestMail pass (or the summary says why they could not run). +- go vet ./... clean; go test -short ./... fails only on the pre-existing TestRegisterCORSPath. +- sm-mfa-plugin untouched and its call into MailTemplate/ResolveMailLocale still matches the signatures (go build of the user plugin plus unchanged signatures; optionally cd ../sm-mfa-plugin && go build ./... if its go.mod replaces the user plugin locally, report the result without editing it). + + + +- An English-only app gets English reset, activation, reactivation and invitation mails for a request with Accept-Language en-GB,en;q=0.9 and no preferred_locale, or with golem15.user.mail.fallback_locale set to en. +- Polish apps with no new config behave exactly as before, except that the three Polish templates now have Polish subjects. +- One commit in sm-user-plugin containing code, templates, tests and README; no co-author trailer; no tag, push or app pointer bumps. +- The summary lists which tests ran (standalone and Postgres) and notes the follow-up for the user: sm-mfa-plugin's emailed-code mail gains the normalisation through ResolveMailLocale but not the fallback until it switches to ResolveMailLocaleFor. + + + +Create `/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/.planning/quick/261029-d9j-user-plugin-mail-locale-normalize-accept/261029-d9j-SUMMARY.md` when done + diff --git a/.planning/quick/261029-d9j-user-plugin-mail-locale-normalize-accept/261029-d9j-SUMMARY.md b/.planning/quick/261029-d9j-user-plugin-mail-locale-normalize-accept/261029-d9j-SUMMARY.md new file mode 100644 index 00000000..0482da60 --- /dev/null +++ b/.planning/quick/261029-d9j-user-plugin-mail-locale-normalize-accept/261029-d9j-SUMMARY.md @@ -0,0 +1,130 @@ +--- +phase: quick-261029-d9j +plan: 01 +subsystem: sm-user-plugin (mail) +tags: [user-plugin, mail, locale, accept-language, config] +status: complete +requires: [] +provides: + - classes.NormalizeMailLocale, classes.MailFallbackLocale, classes.ResolveMailLocaleFor + - golem15.user.mail.fallback_locale (default pl, env SUMMER_GOLEM15__USER__MAIL__FALLBACK_LOCALE) +affects: + - sm-mfa-plugin (calls MailTemplate/ResolveMailLocale; gains normalisation, not the fallback) +tech-stack: + added: [] + patterns: ["...For(app) helper convention", "boot-time config validation naming the key"] +key-files: + created: + - sm-user-plugin/mail_locale_test.go + - sm-user-plugin/mail_subjects_test.go + modified: + - sm-user-plugin/classes/mail.go + - sm-user-plugin/classes/mail_test.go + - sm-user-plugin/controllers/api_controller.go + - sm-user-plugin/controllers/registration.go + - sm-user-plugin/controllers/users_admin_controller.go + - sm-user-plugin/config/config.yaml + - sm-user-plugin/plugin.go + - sm-user-plugin/views/mail/activate.htm + - sm-user-plugin/views/mail/reactivate.htm + - sm-user-plugin/views/mail/restore.htm + - sm-user-plugin/README.md +decisions: + - "Config key golem15.user.mail.fallback_locale (default pl), env SUMMER_GOLEM15__USER__MAIL__FALLBACK_LOCALE; a non-empty value other than pl/en (after normalisation) fails plugin Boot" + - "Mail locale resolution order: user preferred_locale, then request locale, then the fallback; each value normalised to pl or en by NormalizeMailLocale" + - "Polish subjects: activate Aktywuj konto, reactivate Witaj ponownie, restore Ustaw nowe hasło" +metrics: + duration: ~25m + completed: 2026-10-29 +estimate: + tokens: 55000 + tasks: 3 +actuals: + tokens: 8731 + tasks: 3 + commits: 1 +plan_head_before: 91af10b572cad5272337cc5d6f245c4d67ec1c82 +plan_head_after: 6bf219f50a76bdcb21314416f9cbaa9db7e0d970 +--- + +# Quick 261029-d9j: user plugin mail locale normalisation and fallback Summary + +The user plugin now normalises `preferred_locale` and the raw `Accept-Language` request locale (comma lists, q-weights, region subtags, case) to `pl` or `en`. When neither names one, it uses the new `golem15.user.mail.fallback_locale` (default `pl`). The reported bug is fixed: `en-GB,en;q=0.9` with no saved locale now sends `golem15.user::mail.restore-en`. The three Polish templates that had English subjects now have Polish ones. + +`commits`, `plan_head_before` and `plan_head_after` refer to the **sm-user-plugin** repository, where all code landed. Nothing in summercms.go changed except this file. + +## Commit + +| Repo | Hash | Message | +|------|------|---------| +| sm-user-plugin | `6bf219f` (6bf219f50a76bdcb21314416f9cbaa9db7e0d970) | fix(quick-261029-d9j): pick the mail language from Accept-Language, add mail.fallback_locale, Polish subjects | + +The commit sits directly on top of `91af10b` (the HEAD at planning time, unchanged when execution started) on sm-user-plugin `master`. Its 13 paths were staged explicitly. It has no co-author trailer. Nothing was tagged, pushed or bumped in any app, and sm-mfa-plugin was not edited. + +## What changed + +- `classes/mail.go`: + - `NormalizeMailLocale(raw)` is a single linear pass with no regexp, and its output is limited to `pl`, `en` or `""`. + - `MailTemplate(base, locale)` normalises before choosing `-en`. + - `ResolveMailLocale(ctx, user)` normalises both sources, and a saved value it does not support falls through to the request. Its signature is unchanged. + - `MailFallbackLocale(app) (string, error)` is new. + - `ResolveMailLocaleFor(ctx, app, user)` is new and always returns `pl` or `en`. +- `controllers`: `mailName` now takes `*backpack.App` and calls `ResolveMailLocaleFor`. All five call sites pass `app`: registration.go FinishLogin, api_controller.go reactivate/restore/activate, and users_admin_controller.go invite. +- `config/config.yaml`: new `mail.fallback_locale: pl`, with a comment. +- `plugin.go`: Boot calls `classes.MailFallbackLocale(app)` right after `JWTSecret` and returns its error. +- The `views/mail/*.htm` subjects are now `Aktywuj konto`, `Witaj ponownie` and `Ustaw nowe hasło`. Every `-en` template, `invite.htm` and `layouts/user.htm` are byte-identical to `91af10b` (checked with `git diff --quiet`). +- `README.md` was updated in four places: the Mail templates feature bullet, a `classes` API reference bullet for the five helpers, a Configuration row for `golem15.user.mail.fallback_locale`, and a Testing paragraph. + +## New config key and env var + +- Key: `golem15.user.mail.fallback_locale`, default `pl`. +- Env override: `SUMMER_GOLEM15__USER__MAIL__FALLBACK_LOCALE=en` (an English-only app sets this). +- Accepts region and case variants (`EN-gb` becomes `en`). If it is blank or unset it means `pl`. Any other value fails Boot with `golem15.user: golem15.user.mail.fallback_locale must be pl or en, got "de"`. +- Behaviour note, also in the README: the user and the request still win over the fallback. On an app with fallback `en`, a browser that sends a Polish-first Accept-Language still gets Polish mail. + +## Tests run + +All runs were in /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin, with Docker available. + +- TDD RED: the new classes tests failed to build before the implementation (`undefined: ResolveMailLocaleFor`, ...). They pass after it. +- `go test -short -count=1 ./classes -run 'TestMailTemplate|TestNormalizeMailLocale|TestResolveMailLocale|TestMailFallbackLocale|TestUserClassHelpers'`: all PASS (TestMailTemplate, TestNormalizeMailLocale, TestResolveMailLocale, TestMailFallbackLocale, TestResolveMailLocaleFor, TestUserClassHelpers unchanged). +- `go test -short -count=1 -run 'TestMailFallbackLocaleConfig|TestMailFallbackLocaleBoot|TestPasswordPublicRoutes' .`: PASS. TestPasswordPublicRoutesAnswer404 was SKIP, which is its own pre-existing -short skip. +- `go test -count=1 -run 'TestMailLocaleFromRequest|TestMail$' .` (Postgres via testcontainers): TestMailLocaleFromRequest PASS, TestMail PASS. +- `go test -short -count=1 -run TestPolishMailSubjects .`: PASS. As a sanity check, temporarily putting back `subject = "Reset your password"` in restore.htm made it fail with `golem15.user::mail.restore: Polish subject "Reset your password", English subject "Reset your password"`. The file was then restored. +- `go vet ./...`: clean. `gofmt -l .`: clean. +- `go test -short -count=1 -skip '^TestRegisterCORSPath$' ./...`: all packages ok. +- `go test -count=1 -skip '^TestRegisterCORSPath$' ./...` (full Postgres suite): all packages ok (root 76.7s, classes 40.5s, updates 11.6s, middleware, models). +- Pre-existing failure, run on its own and excluded from the gates above: `TestRegisterCORSPath` fails with `register_test.go:361: config/http.yaml not found`. It needs an application checkout, as the README Testing section says. +- sm-mfa-plugin `go build ./...`: OK. Its go.mod pins `git.golem15.com/golem15/sm-user-plugin v0.1.12` with no local replace, so this build used the tagged version and does not exercise the change. Compatibility rests on `MailTemplate(base, locale)` and `ResolveMailLocale(ctx, user)` keeping their signatures, which they do. + +## Deviations from Plan + +None that change behaviour. Two small additions: +- `ResolveMailLocale` skips `towel.Locale` when the context is nil. This is defensive, and no existing caller passes nil. +- The tests cover a few more cases than the plan listed: + - `MailFallbackLocale` with a nil Config. + - `ResolveMailLocaleFor` with a nil app and nil user (gives `pl`). + - A Polish-first browser on an `en` fallback (gives `pl`). + - An invalid fallback (gives `pl`). + +## Threat model + +- T-01: output is allowlisted to `pl`/`en`/`""`, so a template name is only ever `base` or `base-en`. +- T-02: parsing is a single linear pass with `strings.Split` and `strconv.ParseFloat`, with no regexp. +- T-03: Boot rejects an invalid fallback and names the key. + +All three are mitigated as planned. No new surface outside the threat model. + +## Known Stubs + +None. + +## Follow-ups + +- **sm-mfa-plugin**: its emailed-code mail (`classes/email.go`, `userclasses.MailTemplate(EmailCodeTemplate, userclasses.ResolveMailLocale(ctx, user))`) gets the normalisation once it requires a user-plugin version containing `6bf219f`. It does not get the fallback until it switches to `userclasses.ResolveMailLocaleFor(ctx, app, user)`. +- **Release tag**: sm-user-plugin is at an untagged commit. The user decides the tag (for example the next v0.1.x patch after the current tag), the push, and the pointer bumps in consuming apps. An English-only app then sets `SUMMER_GOLEM15__USER__MAIL__FALLBACK_LOCALE=en` if its visitors' browsers may not send English first. + +## Self-Check: PASSED + +- FOUND: sm-user-plugin/classes/mail.go, classes/mail_test.go, mail_locale_test.go, mail_subjects_test.go, config/config.yaml, README.md +- FOUND: commit 6bf219f in sm-user-plugin (`git rev-list --count 91af10b..HEAD` = 1)