fix(quick-261029-d9j): pick the mail language from Accept-Language, add mail.fallback_locale, Polish subjects

- preferred_locale and the request locale are normalised (comma lists,
  q-weights, region subtags, case) to pl or en by NormalizeMailLocale, so a
  raw Accept-Language such as en-GB,en;q=0.9 selects the -en templates
- new golem15.user.mail.fallback_locale (default pl) is used when neither
  names a supported language; ResolveMailLocaleFor applies it, and Boot
  rejects any value other than pl or en
- activate, reactivate and restore Polish templates get Polish subjects
- no template, route or payload changes; MailTemplate and
  ResolveMailLocale keep their signatures
This commit is contained in:
Jakub Zych
2026-10-29 09:47:42 +01:00
parent 91af10b572
commit 6bf219f50a
13 changed files with 487 additions and 28 deletions

View File

@@ -2,27 +2,128 @@ package classes
import (
"context"
"fmt"
"strconv"
"strings"
"git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/towel"
)
// MailTemplate appends -en for English and leaves every other locale,
// including empty and pl, on the unsuffixed Polish template name.
const (
// mailFallbackLocaleKey names the mail language used when neither the
// user nor the request names one the plugin has mail for.
mailFallbackLocaleKey = "golem15.user.mail.fallback_locale"
// mailLocalePL is the unsuffixed (Polish) template set and the default
// fallback.
mailLocalePL = "pl"
// mailLocaleEN is the -en template set.
mailLocaleEN = "en"
)
// supportedMailLocale reports whether the plugin has mail in lang.
func supportedMailLocale(lang string) bool {
return lang == mailLocalePL || lang == mailLocaleEN
}
// NormalizeMailLocale reduces a locale value to the language the plugin has
// mail for: pl, en or "". It accepts a plain code (en, PL), a region form
// (en-GB, pl_PL) and an Accept-Language list with q-weights
// (en-GB,en;q=0.9). Entries whose q does not parse, lies outside 0..1 or is
// 0 are dropped, as are languages other than pl and en (the wildcard
// included); of the rest the one with the highest q wins, ties going to the
// earliest. The result never holds anything but pl, en or "".
func NormalizeMailLocale(raw string) string {
best, bestQ := "", -1.0
for _, entry := range strings.Split(raw, ",") {
parts := strings.Split(entry, ";")
tag := strings.ToLower(strings.TrimSpace(parts[0]))
if i := strings.IndexAny(tag, "-_"); i >= 0 {
tag = tag[:i]
}
if !supportedMailLocale(tag) {
continue
}
q, ok := 1.0, true
for _, param := range parts[1:] {
param = strings.TrimSpace(param)
if len(param) < 2 || !strings.EqualFold(param[:2], "q=") {
continue
}
v, err := strconv.ParseFloat(strings.TrimSpace(param[2:]), 64)
if err != nil || v < 0 || v > 1 {
ok = false
break
}
q = v
}
if !ok || q == 0 {
continue
}
if q > bestQ {
best, bestQ = tag, q
}
}
return best
}
// MailTemplate appends -en when locale normalises to English
// (NormalizeMailLocale) and leaves every other value, empty included, on the
// unsuffixed Polish template name. Callers that want the application's
// configured fallback resolve the locale with ResolveMailLocaleFor first.
func MailTemplate(base, locale string) string {
if locale == "en" {
if NormalizeMailLocale(locale) == mailLocaleEN {
return base + "-en"
}
return base
}
// ResolveMailLocale prefers the user's saved locale, then the request locale.
// ResolveMailLocale prefers the user's saved locale, then the request locale,
// each normalised by NormalizeMailLocale; a value naming neither pl nor en is
// skipped. It returns pl, en or "" (neither names a supported language).
func ResolveMailLocale(ctx context.Context, user *models.User) string {
if user != nil && user.PreferredLocale != nil && *user.PreferredLocale != "" {
return *user.PreferredLocale
if user != nil && user.PreferredLocale != nil {
if loc := NormalizeMailLocale(*user.PreferredLocale); loc != "" {
return loc
}
}
if loc, ok := towel.Locale(ctx); ok {
return loc
if ctx != nil {
if raw, ok := towel.Locale(ctx); ok {
return NormalizeMailLocale(raw)
}
}
return ""
}
// MailFallbackLocale returns golem15.user.mail.fallback_locale normalised to
// pl or en: the mail language when neither the user nor the request names
// one the plugin has mail for. The default is pl (the unsuffixed
// templates); an English-only site sets en, for example through
// SUMMER_GOLEM15__USER__MAIL__FALLBACK_LOCALE=en. A missing app, config or
// blank value gives pl; any other value gives pl and an error naming the key.
func MailFallbackLocale(app *backpack.App) (string, error) {
if app == nil || app.Config == nil {
return mailLocalePL, nil
}
raw := strings.TrimSpace(app.Config.String(mailFallbackLocaleKey))
if raw == "" {
return mailLocalePL, nil
}
if loc := NormalizeMailLocale(raw); loc != "" {
return loc, nil
}
return mailLocalePL, fmt.Errorf("golem15.user: %s must be pl or en, got %q", mailFallbackLocaleKey, raw)
}
// ResolveMailLocaleFor returns the language to mail user in, always pl or
// en: ResolveMailLocale, then the application's MailFallbackLocale. It is the
// helper other plugins use to mail in the user's language with the
// application's fallback; an invalid fallback (rejected at boot) counts as pl.
func ResolveMailLocaleFor(ctx context.Context, app *backpack.App, user *models.User) string {
if loc := ResolveMailLocale(ctx, user); loc != "" {
return loc
}
loc, _ := MailFallbackLocale(app)
return loc
}

View File

@@ -1,16 +1,187 @@
package classes
import "testing"
import (
"context"
"strings"
"testing"
"git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/towel"
)
func TestMailTemplate(t *testing.T) {
base := "golem15.user::mail.reactivate"
if got := MailTemplate(base, "pl"); got != base {
t.Fatalf("pl = %s", got)
}
if got := MailTemplate(base, ""); got != base {
t.Fatalf("empty = %s", got)
}
if got := MailTemplate(base, "en"); got != base+"-en" {
t.Fatalf("en = %s", got)
for _, tc := range []struct {
locale, want string
}{
{"pl", base},
{"", base},
{"en", base + "-en"},
{"en-GB", base + "-en"},
{"en-GB,en;q=0.9", base + "-en"},
{"de", base},
{"pl-PL,en;q=0.5", base},
} {
if got := MailTemplate(base, tc.locale); got != tc.want {
t.Errorf("MailTemplate(%q) = %s, want %s", tc.locale, got, tc.want)
}
}
}
func TestNormalizeMailLocale(t *testing.T) {
for _, tc := range []struct {
raw, want string
}{
{"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"},
{"en;q=0,pl", "pl"},
{"en;q=0", ""},
{"en;q=abc,pl;q=0.1", "pl"},
{"en;q=1.5", ""},
{"english", ""},
{"de", ""},
{"*", ""},
{"", ""},
{" ", ""},
} {
if got := NormalizeMailLocale(tc.raw); got != tc.want {
t.Errorf("NormalizeMailLocale(%q) = %q, want %q", tc.raw, got, tc.want)
}
}
}
func TestResolveMailLocale(t *testing.T) {
str := func(s string) *string { return &s }
header := "en-GB,en;q=0.9"
for _, tc := range []struct {
name string
saved *string
nilUser bool
request string
want string
}{
{"saved region form wins", str("en-GB"), false, "pl", "en"},
{"saved upper case", str("PL"), false, "", "pl"},
{"unsupported saved falls through", str("de"), false, header, "en"},
{"nil user, raw header", nil, true, header, "en"},
{"nil user, no request locale", nil, true, "", ""},
{"empty saved, unsupported request", str(""), false, "fr", ""},
} {
ctx := context.Background()
if tc.request != "" {
ctx = towel.WithLocale(ctx, tc.request)
}
var user *models.User
if !tc.nilUser {
user = &models.User{PreferredLocale: tc.saved}
}
if got := ResolveMailLocale(ctx, user); got != tc.want {
t.Errorf("%s: got %q, want %q", tc.name, got, tc.want)
}
}
}
func mailTestApp(t *testing.T, environ ...string) *backpack.App {
t.Helper()
cfg, err := compass.Open(compass.Options{Dir: t.TempDir(), Env: "development", Environ: append([]string{"SUMMER_ENV=development"}, environ...)})
if err != nil {
t.Fatal(err)
}
return backpack.New(cfg)
}
func TestMailFallbackLocale(t *testing.T) {
if got, err := MailFallbackLocale(nil); got != "pl" || err != nil {
t.Errorf("nil app: %q %v", got, err)
}
if got, err := MailFallbackLocale(&backpack.App{}); got != "pl" || err != nil {
t.Errorf("nil config: %q %v", got, err)
}
if got, err := MailFallbackLocale(mailTestApp(t)); got != "pl" || err != nil {
t.Errorf("no key: %q %v", got, err)
}
for _, tc := range []struct {
value, want string
bad bool
}{
{"", "pl", false},
{" ", "pl", false},
{"en", "en", false},
{"EN-gb", "en", false},
{"pl", "pl", false},
{"de", "pl", true},
} {
app := mailTestApp(t)
if err := app.Config.Set("golem15.user.mail.fallback_locale", tc.value); err != nil {
t.Fatal(err)
}
got, err := MailFallbackLocale(app)
if got != tc.want {
t.Errorf("%q: got %q, want %q", tc.value, got, tc.want)
}
if tc.bad {
if err == nil || !strings.Contains(err.Error(), "golem15.user.mail.fallback_locale") || !strings.Contains(err.Error(), `"de"`) {
t.Errorf("%q: error %v", tc.value, err)
}
} else if err != nil {
t.Errorf("%q: unexpected error %v", tc.value, err)
}
}
app := mailTestApp(t, "SUMMER_GOLEM15__USER__MAIL__FALLBACK_LOCALE=en")
if got, err := MailFallbackLocale(app); got != "en" || err != nil {
t.Errorf("env override: %q %v", got, err)
}
}
func TestResolveMailLocaleFor(t *testing.T) {
const restore = "golem15.user::mail.restore"
user := &models.User{}
headerCtx := towel.WithLocale(context.Background(), "en-GB,en;q=0.9")
// The reported bug: raw Accept-Language, no preferred_locale, default config.
if got := MailTemplate(restore, ResolveMailLocaleFor(headerCtx, mailTestApp(t), user)); got != restore+"-en" {
t.Errorf("raw header: %s", got)
}
if got := MailTemplate(restore, ResolveMailLocaleFor(context.Background(), mailTestApp(t), user)); got != restore {
t.Errorf("default fallback: %s", got)
}
if got := ResolveMailLocaleFor(context.Background(), nil, nil); got != "pl" {
t.Errorf("nil app and user: %q", got)
}
set := mailTestApp(t)
if err := set.Config.Set("golem15.user.mail.fallback_locale", "en"); err != nil {
t.Fatal(err)
}
if got := MailTemplate(restore, ResolveMailLocaleFor(context.Background(), set, user)); got != restore+"-en" {
t.Errorf("fallback en via Set: %s", got)
}
env := mailTestApp(t, "SUMMER_GOLEM15__USER__MAIL__FALLBACK_LOCALE=en")
if got := MailTemplate(restore, ResolveMailLocaleFor(context.Background(), env, user)); got != restore+"-en" {
t.Errorf("fallback en via env: %s", got)
}
// The user and the request still come first.
if got := ResolveMailLocaleFor(towel.WithLocale(context.Background(), "pl-PL,en;q=0.5"), set, user); got != "pl" {
t.Errorf("Polish-first browser on an en fallback: %q", got)
}
bad := mailTestApp(t)
if err := bad.Config.Set("golem15.user.mail.fallback_locale", "de"); err != nil {
t.Fatal(err)
}
if got := ResolveMailLocaleFor(context.Background(), bad, user); got != "pl" {
t.Errorf("invalid fallback: %q", got)
}
}