diff --git a/README.md b/README.md index fbd61bd..7829f60 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ It owns the `users` table extensions, `user_throttle`, `jwt_blacklist`, `user_ap ``` Boot compiles the catalog from the activated plugins (`classes.CompileAPITokenScopes`, published as `*classes.APITokenScopeCatalog` and as `pact.MemberTokenScopeCatalog`) and fails on a name that does not pass `pact.ValidTokenScopeName`, a built-in redeclared, a reserved name `backend` or `offline_access`, a name two plugins declare (the error names both), an empty label or description, an `Implies` other than empty, `read` or `write` (the error names the plugin and that value), or a label or description phrase key (containing `::`) that the translator does not have. `Implies` is only those three values: empty means the declared scope is not implied, and the built-ins `read`, `write` and `ai` keep it empty. Implied names are not stored on a token row or a fingerprint. `POST /tokens` and `classes.MintAPITokenFor(app, …)` accept the scopes of the application's own catalog (`classes.APITokenScopesFor(app)`) and answer an undeclared one with the usual 422 `The selected scope is invalid.`; the console's `user:token:create` uses `MintAPITokenFor` too. Applications mint with `MintAPITokenFor`. The older `classes.MintAPIToken` is deprecated; it keeps its signature and accepts the built-ins plus every scope a catalog compiled in the same process declared, a fallback for existing callers; production runs one application per process, so that is the application's own catalog, while a test binary that boots several applications shares it (`classes.ResetDeclaredAPITokenScopes` clears it, for tests only). `user.scope:`, `middleware.Scope` and `APIToken.HasScope` check a declared scope like a built-in, and `GET /tokens` lists it in `scopes`. `GET /tokens/scopes` (`jwt.auth`) answers `{"data":[{"name","label","description","built_in"}]}`, the built-ins first, then the declared scopes in activation order, with phrase-key labels and descriptions translated for the request locale. The admin `g15_` token catalog never shows these scopes (it reads `pact.HasTokenScopes` only). `classes.AllowedAPITokenScopes` stays the built-in list. -- **Mail templates.** Activation, reset, reactivation and invitation mails in Polish and English (`golem15.user::mail.*`, layout `golem15.user::mail.layouts.user`); the locale follows the user (`ResolveMailLocale`). The invitation (`golem15.user::mail.invite`) is sent when an administrator creates a user with "Send invitation by email" set; it carries the user's login and an activation link, never the password. +- **Mail templates.** Activation, reset, reactivation and invitation mails in Polish and English (`golem15.user::mail.*`, layout `golem15.user::mail.layouts.user`); the Polish templates have Polish subjects. The language follows the user's `preferred_locale`, then the request locale (the translate plugin's, else the raw `Accept-Language` header), each normalised to `pl` or `en` by `NormalizeMailLocale` (comma lists, q-weights, region subtags such as `en-GB`, case), then `golem15.user.mail.fallback_locale` (`ResolveMailLocaleFor`). The invitation (`golem15.user::mail.invite`) is sent when an administrator creates a user with "Send invitation by email" set; it carries the user's login and an activation link, never the password. - **Extension events.** `RegisterEvent` fires after a user row is created; its `Payload` is a copy of the register input (extra keys a client sent included, `password` and `password_confirmation` never), so it is untrusted client input. `CreatedEvent` (`classes.CreatedEventName` `user.Created`) fires from API register and admin create. `/register` logs a failing `RegisterEvent` or `CreatedEvent` listener at Error level and continues: a committed registration is never undone or failed by a listener. `DeletingEvent` (`classes.DeletingEventName` `user.Deleting`) fires from `FormAfterDelete` before `ForceDeleteCleanup` / unscoped delete, inside the same transaction. `GetApiArrayEvent` lets other plugins merge keys into the user payload. - **Registration for other plugins.** `RegisterUser`, `FireRegisterEvent`, `IssueToken` and `APIArray` are the steps `POST /register` runs, exported so another plugin (a first-run setup endpoint, for example) creates a user with the same validation, hashing, event, token and payload. - **OAuth providers.** `GET /oauth-providers` lists the social sign-in providers a social-login plugin has enabled, read on every request from its published `classes.OAuthProviderSource`. Without one it answers the empty list, as before. @@ -257,6 +257,7 @@ Packages: - `LoginEvent` / `LoginEventName` (see Login event). - `DeviceShortCodeAlphabet`, `NewDeviceToken()`, `NewDeviceShortCode()`, `NormalizeDeviceShortCode(s)`, `DeviceAuthTTL(app)`, `DeviceAuthURLFor(app, token, shortCode)`, `DeviceAuthURL(app, token)` (the same with the token in place of the short code), `DeviceDisplayName(stored, userAgent)`, `RevokeDeviceSessions(ctx, db, userIDs...)` (revokes the users' pending and confirmed sessions; the plugin calls it for every `AccessRevokedEvent`), `FollowDeviceJTI(ctx, db, oldJTI, newJTI)` (moves a used session to a refreshed token's jti and reports a session revoked meanwhile; `POST /refresh` calls it and refuses the refresh then) and `HandOverDeviceJTI(ctx, db, oldJTI, newJTI)` (the same move without the report), the device authorization helpers; device tokens are hashed with `HashAPIToken`. - `TwoFactorAdmin` (`HasTwoFactor(ctx, tx, userID)`, `Reset(ctx, tx, user)`) and `PermissionResetTwoFactor`, the Reset 2FA slot (see Admin: Reset 2FA), and `(*AccessRevokedEvent).PasswordChanged()` (see Password-change hook). + - `MailTemplate(base, locale)`, `ResolveMailLocale(ctx, user)`, `ResolveMailLocaleFor(ctx, app, user)`, `NormalizeMailLocale(raw)` and `MailFallbackLocale(app)`, the mail language helpers. `ResolveMailLocale` returns `pl`, `en` or `""` (neither the user nor the request names a supported language); a plugin that mails in the user's language calls `ResolveMailLocaleFor`, which adds the application's fallback and always returns `pl` or `en`, and passes its result to `MailTemplate`. - `HandleGenerator`, `AssignHandle(ctx, db, user)`, `CreatedEvent` / `CreatedEventName`, `DeletingEvent` / `DeletingEventName`, `DeactivatingEvent` / `DeactivatingEventName`, `AuthorizeUser` and `RegisterUserAuthorizer`. - `middleware`: `APITokenGuard`, `Scope`, `Permission` and `BearerToken`. - `models`: `User`, `Throttle`, `Organisation`, `UserGroup`, `UsersGroup` (the `users_groups` pivot), `APIToken`, `OAuthIdentity`, `FrontendPermission`, `BackendLink`, `BackendLinkRequest`, `DeviceAuthSession` (with `DeviceStatusPending`, `DeviceStatusConfirmed`, `DeviceStatusUsed`, `DeviceStatusExpired` and `DeviceStatusRevoked`) and `PermissionSet`. `PermissionSet` reads a stored permission column tolerantly (NULL, an empty string, `[]`, numeric strings) and writes a JSON object of integers; `ParsePermissionSet` and `PermissionSet.Text` do the same for a column that is a plain string on its model. `UserGroup.UsersCount` is filled only by a query that selects `users_count` (the admin list does). `Organisation.Members` is the relation behind the members manager, `Organisation.AttachRelations` declares its avatar (`OrganisationAvatarField`) and its logo (`OrganisationLogoField`), and `Slugify` is the slug rule. `User.FilterScope` answers the admin list scope `FilterByGroup`, and `User.AttachRelations` declares the avatar (`AvatarField`). `User.RegisterFillable` is the registration allow-list; `User.Fillable` is the wider mass-assignment backstop of the admin Users form (cabana intersects it with the form's fields) and of trusted application code. @@ -280,6 +281,7 @@ Defaults live in `config/config.yaml` and are merged under `golem15.user.*`. Any | `golem15.user.activation.activate_mode` | `auto` | `auto`, `user` (mailed code) or `admin`. | | `golem15.user.activation.reset_ttl_minutes` | `60` | Password reset code lifetime. | | `golem15.user.activation.activation_ttl_hours` | `72` | Activation code lifetime. | +| `golem15.user.mail.fallback_locale` | `pl` | 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. The user and the request still come first, so a browser that asks for Polish first gets Polish mail on an application whose fallback is `en`. | | `golem15.user.registration.allow_registration` | `true` | Whether `POST /register` accepts new accounts. | | `golem15.user.registration.use_register_throttle` | `true` | Whether registration is limited to three accounts per IP an hour. | | `golem15.user.throttle.attempt_limit` | `5` | Failed logins before suspension. | @@ -325,6 +327,8 @@ go test ./... # full run, starts Postgres with testcontainers (needs D `TestRegisterCORSPath` reads the host application's `config/http.yaml`, so run the tests inside an application checkout where the plugin is mounted at `plugins/golem15/user`. +The mail language is pinned by `TestNormalizeMailLocale`, `TestResolveMailLocale`, `TestResolveMailLocaleFor` and `TestMailFallbackLocale` (in `classes`, `-short`), `TestMailFallbackLocaleConfig`, `TestMailFallbackLocaleBoot` and `TestPolishMailSubjects` (root, `-short`; the last renders each Polish template next to its `-en` twin and requires a different subject) and `TestMailLocaleFromRequest` (Postgres, skipped under `-short`: `POST /forgot-password` with a raw `Accept-Language` request locale, the default and an `en` fallback, and a saved `en-GB`). + `TestCommandsDocumented` (in `-short`) logs, without failing, each command whose help lacks a description, long help, examples or an option description (`bonfire.CheckDocs`). `TestRegisterIgnoresProtectedFields` posts organisation, IP and account-flag keys to `/register` and `RegisterUser` and checks that only the allow-listed columns and the server-side IP are stored; `TestRegisterListenerErrors` checks that failing register and created listeners do not fail the registration. diff --git a/classes/mail.go b/classes/mail.go index 43d6b84..c58fcae 100644 --- a/classes/mail.go +++ b/classes/mail.go @@ -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 +} diff --git a/classes/mail_test.go b/classes/mail_test.go index b609f63..d6b9950 100644 --- a/classes/mail_test.go +++ b/classes/mail_test.go @@ -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) } } diff --git a/config/config.yaml b/config/config.yaml index 35b9c3d..0b319bb 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -15,6 +15,14 @@ activation: activate_mode: auto reset_ttl_minutes: 60 activation_ttl_hours: 72 +mail: + # The language of this 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. + fallback_locale: pl registration: allow_registration: true use_register_throttle: true diff --git a/controllers/api_controller.go b/controllers/api_controller.go index 95c8e55..da3e053 100644 --- a/controllers/api_controller.go +++ b/controllers/api_controller.go @@ -160,7 +160,7 @@ func Login(app *backpack.App) http.HandlerFunc { writeOpaque500(w) return } - sendTemplateVars(ctx, app, mailName(ctx, user, "golem15.user::mail.reactivate"), user.Email, mailVars(displayName(user), "", "")) + sendTemplateVars(ctx, app, mailName(ctx, app, user, "golem15.user::mail.reactivate"), user.Email, mailVars(displayName(user), "", "")) } // A failed last_seen write is logged and never fails the login. if err := classes.TouchLastSeen(ctx, gdb, user.ID); err != nil { @@ -476,7 +476,7 @@ func ForgotPassword(app *backpack.App) http.HandlerFunc { pluginLog.Error("golem15.user: issue reset code", "err", err) } else { packed := strconv.FormatUint(uint64(user.ID), 10) + "!" + code - sendTemplateVars(ctx, app, mailName(ctx, user, "golem15.user::mail.restore"), user.Email, mailVars(displayName(user), resetLink(app, user.ID, code), packed)) + sendTemplateVars(ctx, app, mailName(ctx, app, user, "golem15.user::mail.restore"), user.Email, mailVars(displayName(user), resetLink(app, user.ID, code), packed)) } } writeJSON(w, http.StatusOK, map[string]any{"message": forgotPasswordBody}) @@ -937,7 +937,7 @@ func Register(app *backpack.App) http.HandlerFunc { packed := strconv.FormatUint(uint64(user.ID), 10) + "!" + code vars = mailVars(displayName(user), activationLink(app, user.ID, code), packed) } - sendTemplateVars(ctx, app, mailName(ctx, user, "golem15.user::mail.activate"), user.Email, vars) + sendTemplateVars(ctx, app, mailName(ctx, app, user, "golem15.user::mail.activate"), user.Email, vars) writeJSON(w, http.StatusOK, map[string]any{"message": "Activation email sent"}) return } @@ -1157,8 +1157,8 @@ func blacklistGrace(app *backpack.App) time.Duration { return time.Duration(secs) * time.Second } -func mailName(ctx context.Context, user *models.User, base string) string { - return classes.MailTemplate(base, classes.ResolveMailLocale(ctx, user)) +func mailName(ctx context.Context, app *backpack.App, user *models.User, base string) string { + return classes.MailTemplate(base, classes.ResolveMailLocaleFor(ctx, app, user)) } func sendTemplate(ctx context.Context, app *backpack.App, template, to string) { diff --git a/controllers/registration.go b/controllers/registration.go index 4bbc08b..15d3d51 100644 --- a/controllers/registration.go +++ b/controllers/registration.go @@ -118,7 +118,7 @@ func FinishLogin(ctx context.Context, app *backpack.App, db *gorm.DB, user *mode return err } user.DeletedAt = gorm.DeletedAt{} - sendTemplateVars(ctx, app, mailName(ctx, user, "golem15.user::mail.reactivate"), user.Email, mailVars(displayName(user), "", "")) + sendTemplateVars(ctx, app, mailName(ctx, app, user, "golem15.user::mail.reactivate"), user.Email, mailVars(displayName(user), "", "")) } if ip != "" { if err := db.WithContext(ctx).Unscoped().Model(user).Update("last_ip_address", ip).Error; err != nil { diff --git a/controllers/users_admin_controller.go b/controllers/users_admin_controller.go index 91525a5..4d14536 100644 --- a/controllers/users_admin_controller.go +++ b/controllers/users_admin_controller.go @@ -378,7 +378,7 @@ func (c usersAdminController) FormAfterCreate(ctx context.Context, model any) er vars["login"], vars["Login"] = email, email hours := c.configInt("golem15.user.activation.activation_ttl_hours", 72) vars["hours"], vars["Hours"] = hours, hours - template := mailName(ctx, user, "golem15.user::mail.invite") + template := mailName(ctx, app, user, "golem15.user::mail.invite") lagoon.AfterCommit(ctx, tx, func(ctx context.Context, _ *gorm.DB) { sendTemplateVars(ctx, app, template, email, vars) }) diff --git a/mail_locale_test.go b/mail_locale_test.go new file mode 100644 index 0000000..e7fa70e --- /dev/null +++ b/mail_locale_test.go @@ -0,0 +1,114 @@ +package user + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "git.golem15.com/golem15/sm-user-plugin/classes" + "git.golem15.com/golem15/sm-user-plugin/controllers" + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/towel" +) + +const mailFallbackKey = "golem15.user.mail.fallback_locale" + +// TestMailFallbackLocaleConfig reads golem15.user.mail.fallback_locale from +// the plugin's config.yaml default and from overrides. +func TestMailFallbackLocaleConfig(t *testing.T) { + app := bareApp(t) + if err := app.Config.MergePlugin("golem15.user", configFS); err != nil { + t.Fatal(err) + } + if v, ok := app.Config.Lookup(mailFallbackKey); !ok || v != "pl" { + t.Fatalf("config.yaml default = %#v (set %v), want pl", v, ok) + } + if got, err := classes.MailFallbackLocale(app); got != "pl" || err != nil { + t.Fatalf("default: %q %v", got, err) + } + for _, tc := range []struct{ value, want string }{{"en", "en"}, {"EN-gb", "en"}} { + if err := app.Config.Set(mailFallbackKey, tc.value); err != nil { + t.Fatal(err) + } + if got, err := classes.MailFallbackLocale(app); got != tc.want || err != nil { + t.Errorf("%q: %q %v", tc.value, got, err) + } + } + if err := app.Config.Set(mailFallbackKey, "de"); err != nil { + t.Fatal(err) + } + if _, err := classes.MailFallbackLocale(app); err == nil || !strings.Contains(err.Error(), mailFallbackKey) { + t.Errorf("de: %v", err) + } +} + +// TestMailFallbackLocaleBoot fails Boot on an unsupported fallback before +// anything is started, and leaves an unset key alone. +func TestMailFallbackLocaleBoot(t *testing.T) { + withSecret := func(t *testing.T) *backpack.App { + app := bareApp(t) + if err := app.Config.Set("golem15.user.jwt.secret", "mail-locale-boot-secret"); err != nil { + t.Fatal(err) + } + return app + } + app := withSecret(t) + if err := app.Config.Set(mailFallbackKey, "de"); err != nil { + t.Fatal(err) + } + if err := (&Plugin{}).Boot(app); err == nil || !strings.Contains(err.Error(), mailFallbackKey) { + t.Fatalf("Boot with fallback de: %v", err) + } + if err := (&Plugin{}).Boot(withSecret(t)); err != nil && strings.Contains(err.Error(), mailFallbackKey) { + t.Fatalf("Boot with the key unset: %v", err) + } +} + +// TestMailLocaleFromRequest sends the reset mail through POST +// /forgot-password with the request locale the router stores when no +// translate plugin is installed: the raw Accept-Language header. +func TestMailLocaleFromRequest(t *testing.T) { + if testing.Short() { + t.Skip("Postgres (testcontainers)") + } + app, gdb := sessionApp(t, "0s") + rec, _ := publishMemoryMail(t, app) + + forgot := func(t *testing.T, email, locale string) { + t.Helper() + req := httptest.NewRequest(http.MethodPost, "/_user/api/v1/forgot-password", strings.NewReader(`{"email":"`+email+`"}`)) + req.Host = "example.com" + req.Header.Set("Content-Type", "application/json") + if locale != "" { + req = req.WithContext(towel.WithLocale(req.Context(), locale)) + } + w := httptest.NewRecorder() + controllers.ForgotPassword(app)(w, req) + if w.Code >= 300 { + t.Fatalf("forgot-password %s: %d %s", email, w.Code, w.Body.String()) + } + } + + insertUser(t, gdb, "locale-header@example.test", "correct-horse") + forgot(t, "locale-header@example.test", "en-GB,en;q=0.9") + assertTemplate(t, rec, "golem15.user::mail.restore-en", "locale-header@example.test") + + insertUser(t, gdb, "locale-none@example.test", "correct-horse") + forgot(t, "locale-none@example.test", "") + assertTemplate(t, rec, "golem15.user::mail.restore", "locale-none@example.test") + + if err := app.Config.Set(mailFallbackKey, "en"); err != nil { + t.Fatal(err) + } + insertUser(t, gdb, "locale-fallback@example.test", "correct-horse") + forgot(t, "locale-fallback@example.test", "") + assertTemplate(t, rec, "golem15.user::mail.restore-en", "locale-fallback@example.test") + + saved := insertUser(t, gdb, "locale-saved@example.test", "correct-horse") + if err := gdb.Model(saved).Update("preferred_locale", "en-GB").Error; err != nil { + t.Fatal(err) + } + forgot(t, "locale-saved@example.test", "pl") + assertTemplate(t, rec, "golem15.user::mail.restore-en", "locale-saved@example.test") +} diff --git a/mail_subjects_test.go b/mail_subjects_test.go new file mode 100644 index 0000000..26153bd --- /dev/null +++ b/mail_subjects_test.go @@ -0,0 +1,58 @@ +package user + +import ( + "context" + "strings" + "testing" + + "git.golem15.com/golem15/summercms/modules/postcard" +) + +// TestPolishMailSubjects renders every Polish template next to its -en twin +// and requires a subject of its own, so a Polish template with a copied +// English subject does not slip through. +func TestPolishMailSubjects(t *testing.T) { + p := &Plugin{} + cat := postcard.NewCatalog() + if err := cat.Register(p.ID(), p.MailTemplatesFS(), p.MailTemplates(), p.MailLayouts()); err != nil { + t.Fatal(err) + } + names := map[string]bool{} + for _, name := range p.MailTemplates() { + names[name] = true + } + vars := map[string]any{ + "name": "Ada", "Name": "Ada", + "link": "https://example.com/link", "Link": "https://example.com/link", + "code": "1!abc", "Code": "1!abc", + "hours": 72, "Hours": 72, + "login": "ada@example.test", "Login": "ada@example.test", + } + subject := func(t *testing.T, template string) string { + t.Helper() + mem := postcard.NewMemoryDriver() + mailer := postcard.NewMailer(cat, mem, postcard.Options{From: "noreply@example.test"}) + if err := mailer.Send(context.Background(), postcard.Message{Template: template, To: []string{"ada@example.test"}, Vars: vars}); err != nil { + t.Fatalf("%s: %v", template, err) + } + msgs := mem.Messages() + if len(msgs) != 1 { + t.Fatalf("%s: %d messages", template, len(msgs)) + } + return msgs[0].Subject + } + pairs := 0 + for _, name := range p.MailTemplates() { + if strings.HasSuffix(name, "-en") || !names[name+"-en"] { + continue + } + pairs++ + pl, en := subject(t, name), subject(t, name+"-en") + if pl == "" || pl == en { + t.Errorf("%s: Polish subject %q, English subject %q", name, pl, en) + } + } + if pairs == 0 { + t.Fatal("no Polish/English template pairs") + } +} diff --git a/plugin.go b/plugin.go index 4d22c51..9d0731d 100644 --- a/plugin.go +++ b/plugin.go @@ -76,6 +76,9 @@ func (p *Plugin) Boot(app *backpack.App) error { if err != nil { return err } + if _, err := classes.MailFallbackLocale(app); err != nil { + return err + } // Serve publishes the database after Boot, so the GORM hooks, the // blacklist store and its sweep are installed on lagoon.Publish, or at // once when the database is already published. diff --git a/views/mail/activate.htm b/views/mail/activate.htm index 60f399a..518f775 100644 --- a/views/mail/activate.htm +++ b/views/mail/activate.htm @@ -1,4 +1,4 @@ -subject = "Activate your account" +subject = "Aktywuj konto" description = "Aktywacja konta" layout = "user" == diff --git a/views/mail/reactivate.htm b/views/mail/reactivate.htm index 7241977..54f7cfc 100644 --- a/views/mail/reactivate.htm +++ b/views/mail/reactivate.htm @@ -1,4 +1,4 @@ -subject = "Welcome back" +subject = "Witaj ponownie" description = "Konto zostało ponownie aktywowane" layout = "user" == diff --git a/views/mail/restore.htm b/views/mail/restore.htm index de41a80..191e459 100644 --- a/views/mail/restore.htm +++ b/views/mail/restore.htm @@ -1,4 +1,4 @@ -subject = "Reset your password" +subject = "Ustaw nowe hasło" description = "Reset hasła" layout = "user" ==