From 1bf52e3fddca9cc436ef186646974acdc0443e5c Mon Sep 17 00:00:00 2001 From: an9xyz Date: Tue, 2 Jun 2026 15:10:02 +0800 Subject: [PATCH] feat(i18n): externalize email templates + lang-aware send chain (#221) Add modules/base/common/emailtmpl: per-language (zh-CN/en-US) subject/HTML/ plaintext email templates embedded via go:embed and rendered through a text/template + html/template split, so Subject headers are not HTML-escaped while bodies keep XSS-safe escaping of user-controlled fields. Thread a lang parameter through the email send chain: - SendVerifyCode takes a lang arg, renders from emailtmpl, and is upgraded to SendTransactionalHTML (plaintext fallback + transactional headers reduce silent spam-filter drops). - Space owner/member invite emails render via emailtmpl; the role label and the anonymous-inviter fallback are localized in-template instead of hardcoded in Go, and invite links now carry &lang=. - Add i18n.OutboundLanguage(ctx): negotiated language with OCTO_DEFAULT_LANGUAGE fallback. Per #221 it resolves to the default for now (verify-code requester == recipient; invites are async with no recipient uid); the lang param is threaded end-to-end so a future per-recipient resolver plugs in without touching the send or template layers. Client-visible verify-code/login API errors are already localized via ResponseErrorL (#188/#197); this only English-cleans the now-invisible service-layer error sentinels. --- modules/base/common/emailtmpl/loader.go | 245 ++++++++++++++++++ modules/base/common/emailtmpl/loader_test.go | 213 +++++++++++++++ .../en-US/space_invite_member.html.tmpl | 16 ++ .../en-US/space_invite_member.subject.tmpl | 1 + .../en-US/space_invite_member.text.tmpl | 8 + .../en-US/space_invite_owner.html.tmpl | 17 ++ .../en-US/space_invite_owner.subject.tmpl | 1 + .../en-US/space_invite_owner.text.tmpl | 9 + .../templates/en-US/verify_code.html.tmpl | 8 + .../templates/en-US/verify_code.subject.tmpl | 1 + .../templates/en-US/verify_code.text.tmpl | 3 + .../zh-CN/space_invite_member.html.tmpl | 16 ++ .../zh-CN/space_invite_member.subject.tmpl | 1 + .../zh-CN/space_invite_member.text.tmpl | 8 + .../zh-CN/space_invite_owner.html.tmpl | 17 ++ .../zh-CN/space_invite_owner.subject.tmpl | 1 + .../zh-CN/space_invite_owner.text.tmpl | 9 + .../templates/zh-CN/verify_code.html.tmpl | 8 + .../templates/zh-CN/verify_code.subject.tmpl | 1 + .../templates/zh-CN/verify_code.text.tmpl | 3 + modules/base/common/service_email.go | 53 ++-- modules/space/email_invite_sender.go | 32 ++- modules/space/email_invite_sender_test.go | 7 +- modules/space/email_invite_template.go | 122 ++------- modules/space/email_invite_template_test.go | 92 ++++--- modules/user/api_emaillogin.go | 7 +- pkg/i18n/ctx.go | 17 ++ 27 files changed, 750 insertions(+), 166 deletions(-) create mode 100644 modules/base/common/emailtmpl/loader.go create mode 100644 modules/base/common/emailtmpl/loader_test.go create mode 100644 modules/base/common/emailtmpl/templates/en-US/space_invite_member.html.tmpl create mode 100644 modules/base/common/emailtmpl/templates/en-US/space_invite_member.subject.tmpl create mode 100644 modules/base/common/emailtmpl/templates/en-US/space_invite_member.text.tmpl create mode 100644 modules/base/common/emailtmpl/templates/en-US/space_invite_owner.html.tmpl create mode 100644 modules/base/common/emailtmpl/templates/en-US/space_invite_owner.subject.tmpl create mode 100644 modules/base/common/emailtmpl/templates/en-US/space_invite_owner.text.tmpl create mode 100644 modules/base/common/emailtmpl/templates/en-US/verify_code.html.tmpl create mode 100644 modules/base/common/emailtmpl/templates/en-US/verify_code.subject.tmpl create mode 100644 modules/base/common/emailtmpl/templates/en-US/verify_code.text.tmpl create mode 100644 modules/base/common/emailtmpl/templates/zh-CN/space_invite_member.html.tmpl create mode 100644 modules/base/common/emailtmpl/templates/zh-CN/space_invite_member.subject.tmpl create mode 100644 modules/base/common/emailtmpl/templates/zh-CN/space_invite_member.text.tmpl create mode 100644 modules/base/common/emailtmpl/templates/zh-CN/space_invite_owner.html.tmpl create mode 100644 modules/base/common/emailtmpl/templates/zh-CN/space_invite_owner.subject.tmpl create mode 100644 modules/base/common/emailtmpl/templates/zh-CN/space_invite_owner.text.tmpl create mode 100644 modules/base/common/emailtmpl/templates/zh-CN/verify_code.html.tmpl create mode 100644 modules/base/common/emailtmpl/templates/zh-CN/verify_code.subject.tmpl create mode 100644 modules/base/common/emailtmpl/templates/zh-CN/verify_code.text.tmpl diff --git a/modules/base/common/emailtmpl/loader.go b/modules/base/common/emailtmpl/loader.go new file mode 100644 index 00000000..bc48e84c --- /dev/null +++ b/modules/base/common/emailtmpl/loader.go @@ -0,0 +1,245 @@ +// Package emailtmpl renders outbound transactional email (subject + HTML + +// plaintext) from per-language templates embedded at build time. +// +// It is the i18n surface for email *bodies*, parallel to — but deliberately +// independent of — pkg/i18n's error-code localizer. The code localizer +// (codes.Register + Localizer.Translate) carries single-line, parameterized +// error messages keyed by `err.(shared|server).*`; multi-line HTML email is a +// different shape and intentionally uses its own embedded template tree rather +// than being forced through that registry. +// +// Each logical email is three sibling files under templates/{lang}/: +// +// {key}.subject.tmpl — text/template (plain header, NOT html-escaped) +// {key}.html.tmpl — html/template (auto-escaped, XSS-safe) +// {key}.text.tmpl — text/template (plaintext alternative part) +// +// subject/text use text/template and html uses html/template on purpose: +// rendering a Subject header through html/template would turn "A & B" into +// "A & B"; rendering HTML through text/template would drop XSS escaping on +// user-controlled fields (inviter name, space name, ...). +package emailtmpl + +import ( + "bytes" + "embed" + "fmt" + htmltemplate "html/template" + "io/fs" + "strings" + "sync" + texttemplate "text/template" + + octoi18n "github.com/Mininglamp-OSS/octo-server/pkg/i18n" +) + +//go:embed templates +var templatesFS embed.FS + +// Message keys — one per logical email. Kept as constants so call sites and +// the template tree cannot silently drift. +const ( + KeyVerifyCode = "verify_code" + KeySpaceInviteOwner = "space_invite_owner" + KeySpaceInviteMember = "space_invite_member" +) + +// fallbackLanguage is the language guaranteed to carry a complete template set +// and doubles as the source language. lookup() falls back to it when a +// requested language is missing a file. TestTemplateCompleteness asserts every +// supported language is fully covered, so this is defensive (e.g. a future +// language added with a partial set) rather than a production hot path. +const fallbackLanguage = octoi18n.SourceLanguage // "en-US" + +// Rendered is the output of Render: the three parts a transactional email +// needs. Subject is trimmed (no stray trailing newline leaking into the SMTP +// header); HTML and Text are emitted verbatim. +type Rendered struct { + Subject string + HTML string + Text string +} + +// VerifyCodeData drives the verify_code template. +type VerifyCodeData struct { + Code string +} + +// SpaceInviteOwnerData drives the space_invite_owner template. An empty +// InviterName is handled inside the template (localized "Octo admin" fallback) +// so the fallback text itself is translated rather than hardcoded in Go. +// AcceptURL is template.URL so html/template treats the already-escaped link as +// a safe URL instead of re-filtering it. +type SpaceInviteOwnerData struct { + InviterName string + PlannedName string + PlannedDesc string + AcceptURL htmltemplate.URL +} + +// SpaceInviteMemberData drives the space_invite_member template. IsAdmin +// selects the localized role label inside the template (was a hardcoded +// "成员"/"管理员" branch in Go). +type SpaceInviteMemberData struct { + InviterName string + SpaceName string + IsAdmin bool + AcceptURL htmltemplate.URL +} + +type compiledSet struct { + subject *texttemplate.Template + html *htmltemplate.Template + text *texttemplate.Template +} + +var ( + loadOnce sync.Once + compiled map[string]*compiledSet // key: "{lang}/{msgKey}" + loadErr error +) + +// Render produces the subject/HTML/text for a message key in the requested +// language. lang is normalized to the supported matrix; an unsupported/missing +// language falls back to fallbackLanguage. Call sites that have no per-recipient +// signal should pass i18n.OutboundLanguage(ctx), which already resolves to +// OCTO_DEFAULT_LANGUAGE. +func Render(key, lang string, data any) (Rendered, error) { + loadOnce.Do(load) + if loadErr != nil { + return Rendered{}, loadErr + } + cs, err := lookup(key, lang) + if err != nil { + return Rendered{}, err + } + subject, err := execText(cs.subject, data) + if err != nil { + return Rendered{}, fmt.Errorf("emailtmpl: render subject %s/%s: %w", lang, key, err) + } + html, err := execHTML(cs.html, data) + if err != nil { + return Rendered{}, fmt.Errorf("emailtmpl: render html %s/%s: %w", lang, key, err) + } + text, err := execText(cs.text, data) + if err != nil { + return Rendered{}, fmt.Errorf("emailtmpl: render text %s/%s: %w", lang, key, err) + } + return Rendered{ + Subject: strings.TrimSpace(subject), + HTML: html, + Text: text, + }, nil +} + +func lookup(key, lang string) (*compiledSet, error) { + if norm, ok := octoi18n.MatchSupportedLanguage(lang); ok { + lang = norm + } + if cs, ok := compiled[lang+"/"+key]; ok { + return cs, nil + } + if cs, ok := compiled[fallbackLanguage+"/"+key]; ok { + return cs, nil + } + return nil, fmt.Errorf("emailtmpl: no template for key=%q lang=%q", key, lang) +} + +func execText(t *texttemplate.Template, data any) (string, error) { + var buf bytes.Buffer + if err := t.Execute(&buf, data); err != nil { + return "", err + } + return buf.String(), nil +} + +func execHTML(t *htmltemplate.Template, data any) (string, error) { + var buf bytes.Buffer + if err := t.Execute(&buf, data); err != nil { + return "", err + } + return buf.String(), nil +} + +// load walks the embedded template tree once and compiles every file into the +// `compiled` map. A parse error or an incomplete set (missing subject/html/text +// for some key) is captured in loadErr and surfaced on the first Render call — +// fail-loud at startup rather than rendering a half-built email at runtime. +func load() { + compiled = map[string]*compiledSet{} + walkErr := fs.WalkDir(templatesFS, "templates", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() || !strings.HasSuffix(path, ".tmpl") { + return nil + } + // path: templates/{lang}/{key}.{kind}.tmpl + rel := strings.TrimPrefix(path, "templates/") + lang, file, ok := strings.Cut(rel, "/") + if !ok { + return fmt.Errorf("emailtmpl: unexpected template path %q", path) + } + name := strings.TrimSuffix(file, ".tmpl") + key, kind, ok := cutLast(name, ".") + if !ok { + return fmt.Errorf("emailtmpl: template %q must be {key}.{kind}.tmpl", path) + } + data, rerr := templatesFS.ReadFile(path) + if rerr != nil { + return rerr + } + setKey := lang + "/" + key + cs := compiled[setKey] + if cs == nil { + cs = &compiledSet{} + compiled[setKey] = cs + } + switch kind { + case "subject": + t, perr := texttemplate.New(setKey + ".subject").Parse(string(data)) + if perr != nil { + return fmt.Errorf("emailtmpl: parse %s: %w", path, perr) + } + cs.subject = t + case "html": + t, perr := htmltemplate.New(setKey + ".html").Parse(string(data)) + if perr != nil { + return fmt.Errorf("emailtmpl: parse %s: %w", path, perr) + } + cs.html = t + case "text": + t, perr := texttemplate.New(setKey + ".text").Parse(string(data)) + if perr != nil { + return fmt.Errorf("emailtmpl: parse %s: %w", path, perr) + } + cs.text = t + default: + return fmt.Errorf("emailtmpl: unknown template kind %q in %s", kind, path) + } + return nil + }) + if walkErr != nil { + loadErr = walkErr + return + } + for k, cs := range compiled { + if cs.subject == nil || cs.html == nil || cs.text == nil { + loadErr = fmt.Errorf( + "emailtmpl: incomplete set %q (subject=%t html=%t text=%t)", + k, cs.subject != nil, cs.html != nil, cs.text != nil) + return + } + } +} + +// cutLast splits name on the last occurrence of sep ("verify_code.subject" → +// "verify_code", "subject"). Needed because message keys may contain no dots +// today but kinds always sit after the final dot. +func cutLast(name, sep string) (before, after string, found bool) { + i := strings.LastIndex(name, sep) + if i < 0 { + return name, "", false + } + return name[:i], name[i+len(sep):], true +} diff --git a/modules/base/common/emailtmpl/loader_test.go b/modules/base/common/emailtmpl/loader_test.go new file mode 100644 index 00000000..41288d8c --- /dev/null +++ b/modules/base/common/emailtmpl/loader_test.go @@ -0,0 +1,213 @@ +package emailtmpl + +import ( + htmltemplate "html/template" + "strings" + "testing" + + octoi18n "github.com/Mininglamp-OSS/octo-server/pkg/i18n" +) + +func TestRenderVerifyCode(t *testing.T) { + tests := []struct { + name string + lang string + wantSubject string + wantInHTML string + wantInText string + }{ + { + name: "zh-CN", + lang: "zh-CN", + wantSubject: "Octo 验证码", + wantInHTML: "您的验证码为", + wantInText: "您的 Octo 验证码为:123456", + }, + { + name: "en-US", + lang: "en-US", + wantSubject: "Octo verification code", + wantInHTML: "Your verification code is", + wantInText: "Your Octo verification code is: 123456", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Render(KeyVerifyCode, tt.lang, VerifyCodeData{Code: "123456"}) + if err != nil { + t.Fatalf("Render: %v", err) + } + if got.Subject != tt.wantSubject { + t.Errorf("Subject = %q, want %q", got.Subject, tt.wantSubject) + } + if !strings.Contains(got.HTML, "123456") || !strings.Contains(got.HTML, tt.wantInHTML) { + t.Errorf("HTML missing code or marker: %q", got.HTML) + } + if !strings.Contains(got.Text, tt.wantInText) { + t.Errorf("Text = %q, want substring %q", got.Text, tt.wantInText) + } + }) + } +} + +func TestRenderSpaceInviteOwner(t *testing.T) { + data := SpaceInviteOwnerData{ + InviterName: "Alice", + PlannedName: "Team A", + PlannedDesc: "desc", + AcceptURL: htmltemplate.URL("https://x.test/v1/space/email-invite?token=abc&lang=en-US"), + } + got, err := Render(KeySpaceInviteOwner, "en-US", data) + if err != nil { + t.Fatalf("Render: %v", err) + } + if !strings.Contains(got.Subject, "Team A") { + t.Errorf("Subject = %q, want Team A", got.Subject) + } + // In HTML, "&" is correctly entity-escaped to "&" (browsers decode it + // back); template.URL only suppresses URL *filtering*, not HTML escaping. + for _, want := range []string{"Alice", "Team A", "desc", "token=abc&lang=en-US"} { + if !strings.Contains(got.HTML, want) { + t.Errorf("HTML missing %q: %q", want, got.HTML) + } + } + // Plaintext keeps the raw ampersand. + if !strings.Contains(got.Text, "https://x.test/v1/space/email-invite?token=abc&lang=en-US") { + t.Errorf("Text missing accept URL: %q", got.Text) + } +} + +// Empty InviterName must fall back to a *localized* admin label, not a blank +// or a hardcoded Chinese string in the en-US path. +func TestRenderOwnerInviterFallbackLocalized(t *testing.T) { + data := SpaceInviteOwnerData{PlannedName: "S"} + en, err := Render(KeySpaceInviteOwner, "en-US", data) + if err != nil { + t.Fatalf("Render en: %v", err) + } + if !strings.Contains(en.HTML, "An Octo admin") { + t.Errorf("en fallback inviter missing: %q", en.HTML) + } + zh, err := Render(KeySpaceInviteOwner, "zh-CN", data) + if err != nil { + t.Fatalf("Render zh: %v", err) + } + if !strings.Contains(zh.HTML, "Octo 管理员") { + t.Errorf("zh fallback inviter missing: %q", zh.HTML) + } +} + +func TestRenderMemberRoleLabelLocalized(t *testing.T) { + tests := []struct { + lang string + admin bool + wantSub string // role label substring expected in HTML + }{ + {"zh-CN", true, "管理员"}, + {"zh-CN", false, "成员"}, + {"en-US", true, "administrator"}, + {"en-US", false, "member"}, + } + for _, tt := range tests { + got, err := Render(KeySpaceInviteMember, tt.lang, SpaceInviteMemberData{ + SpaceName: "S", + IsAdmin: tt.admin, + AcceptURL: htmltemplate.URL("https://x.test/?token=t"), + }) + if err != nil { + t.Fatalf("Render %s admin=%v: %v", tt.lang, tt.admin, err) + } + if !strings.Contains(got.HTML, tt.wantSub) { + t.Errorf("%s admin=%v HTML missing role %q: %q", tt.lang, tt.admin, tt.wantSub, got.HTML) + } + } +} + +// HTML part must escape user-controlled fields (XSS), while subject/text (plain +// headers/body) must NOT html-escape — proving the text/template vs +// html/template split is wired correctly. +func TestRenderEscapingBoundary(t *testing.T) { + data := SpaceInviteMemberData{ + InviterName: ``, + SpaceName: "A & B", + AcceptURL: htmltemplate.URL("https://x.test/?token=t"), + } + got, err := Render(KeySpaceInviteMember, "en-US", data) + if err != nil { + t.Fatalf("Render: %v", err) + } + if strings.Contains(got.HTML, "") { + t.Errorf("HTML did not escape script payload: %q", got.HTML) + } + if !strings.Contains(got.HTML, "<script>") { + t.Errorf("HTML expected escaped script, got: %q", got.HTML) + } + // Plaintext alternative keeps the raw ampersand (no &). + if !strings.Contains(got.Text, "A & B") { + t.Errorf("Text should keep raw ampersand: %q", got.Text) + } +} + +// AcceptURL is template.URL, so html/template must emit the already-escaped +// query string without re-encoding it (e.g. %2B must NOT become %252B). The +// literal "&" is still entity-escaped to "&" in HTML, which is expected; +// the plaintext part carries the verbatim URL. +func TestRenderAcceptURLNotMangled(t *testing.T) { + raw := "https://x.test/v1/space/email-invite?token=a%2Bb&lang=zh-CN" + got, err := Render(KeySpaceInviteMember, "zh-CN", SpaceInviteMemberData{ + SpaceName: "S", + AcceptURL: htmltemplate.URL(raw), + }) + if err != nil { + t.Fatalf("Render: %v", err) + } + // %2B preserved (not re-encoded) and "&" entity-escaped in HTML. + if !strings.Contains(got.HTML, "token=a%2Bb&lang=zh-CN") { + t.Errorf("HTML mangled accept URL: %q", got.HTML) + } + // Plaintext keeps the verbatim URL. + if !strings.Contains(got.Text, raw) { + t.Errorf("Text mangled accept URL, want %q in: %q", raw, got.Text) + } +} + +func TestRenderUnknownKey(t *testing.T) { + if _, err := Render("does_not_exist", "en-US", nil); err == nil { + t.Fatal("expected error for unknown key") + } +} + +// Unsupported/empty language must fall back rather than error. +func TestRenderLanguageFallback(t *testing.T) { + for _, lang := range []string{"", "fr-FR", "xx"} { + got, err := Render(KeyVerifyCode, lang, VerifyCodeData{Code: "000000"}) + if err != nil { + t.Fatalf("Render lang=%q: %v", lang, err) + } + if !strings.Contains(got.HTML, "000000") { + t.Errorf("lang=%q fallback render missing code: %q", lang, got.HTML) + } + } +} + +// Every supported language must carry a complete template set for every key. +// This is the guard that lets lookup()'s fallback stay defensive-only. +func TestTemplateCompleteness(t *testing.T) { + loadOnce.Do(load) + if loadErr != nil { + t.Fatalf("load: %v", loadErr) + } + keys := []string{KeyVerifyCode, KeySpaceInviteOwner, KeySpaceInviteMember} + for _, lang := range octoi18n.SupportedLanguages() { + for _, key := range keys { + cs, ok := compiled[lang+"/"+key] + if !ok { + t.Errorf("missing template set %s/%s", lang, key) + continue + } + if cs.subject == nil || cs.html == nil || cs.text == nil { + t.Errorf("incomplete set %s/%s", lang, key) + } + } + } +} diff --git a/modules/base/common/emailtmpl/templates/en-US/space_invite_member.html.tmpl b/modules/base/common/emailtmpl/templates/en-US/space_invite_member.html.tmpl new file mode 100644 index 00000000..fd78f39c --- /dev/null +++ b/modules/base/common/emailtmpl/templates/en-US/space_invite_member.html.tmpl @@ -0,0 +1,16 @@ + + +
+

Octo Space invitation

+

+ {{if .InviterName}}{{.InviterName}}{{else}}An Octo admin{{end}} invites you to join the team space {{.SpaceName}} as {{if .IsAdmin}}an administrator{{else}}a member{{end}}. +

+

+ Accept invitation +

+

+ If the button doesn't work, copy and paste this link into your browser:
+ {{.AcceptURL}} +

+

If you weren't expecting this invitation, you can safely ignore this email.

+
\ No newline at end of file diff --git a/modules/base/common/emailtmpl/templates/en-US/space_invite_member.subject.tmpl b/modules/base/common/emailtmpl/templates/en-US/space_invite_member.subject.tmpl new file mode 100644 index 00000000..5f7ff13c --- /dev/null +++ b/modules/base/common/emailtmpl/templates/en-US/space_invite_member.subject.tmpl @@ -0,0 +1 @@ +Octo invites you to join the team space "{{.SpaceName}}" \ No newline at end of file diff --git a/modules/base/common/emailtmpl/templates/en-US/space_invite_member.text.tmpl b/modules/base/common/emailtmpl/templates/en-US/space_invite_member.text.tmpl new file mode 100644 index 00000000..40577341 --- /dev/null +++ b/modules/base/common/emailtmpl/templates/en-US/space_invite_member.text.tmpl @@ -0,0 +1,8 @@ +Octo Space invitation + +{{if .InviterName}}{{.InviterName}}{{else}}An Octo admin{{end}} invites you to join the team space "{{.SpaceName}}" as {{if .IsAdmin}}an administrator{{else}}a member{{end}}. + +Click the link below to accept the invitation: +{{.AcceptURL}} + +If you weren't expecting this invitation, you can safely ignore this email. \ No newline at end of file diff --git a/modules/base/common/emailtmpl/templates/en-US/space_invite_owner.html.tmpl b/modules/base/common/emailtmpl/templates/en-US/space_invite_owner.html.tmpl new file mode 100644 index 00000000..663dc429 --- /dev/null +++ b/modules/base/common/emailtmpl/templates/en-US/space_invite_owner.html.tmpl @@ -0,0 +1,17 @@ + + +
+

Octo Space invitation

+

+ {{if .InviterName}}{{.InviterName}}{{else}}An Octo admin{{end}} invites you to create and own the team space {{.PlannedName}}. +

+ {{if .PlannedDesc}}

Description: {{.PlannedDesc}}

{{end}} +

+ Accept and create space +

+

+ If the button doesn't work, copy and paste this link into your browser:
+ {{.AcceptURL}} +

+

If you weren't expecting this invitation, you can safely ignore this email.

+
\ No newline at end of file diff --git a/modules/base/common/emailtmpl/templates/en-US/space_invite_owner.subject.tmpl b/modules/base/common/emailtmpl/templates/en-US/space_invite_owner.subject.tmpl new file mode 100644 index 00000000..306af93c --- /dev/null +++ b/modules/base/common/emailtmpl/templates/en-US/space_invite_owner.subject.tmpl @@ -0,0 +1 @@ +Octo invites you to create the team space "{{.PlannedName}}" \ No newline at end of file diff --git a/modules/base/common/emailtmpl/templates/en-US/space_invite_owner.text.tmpl b/modules/base/common/emailtmpl/templates/en-US/space_invite_owner.text.tmpl new file mode 100644 index 00000000..6c00cfd5 --- /dev/null +++ b/modules/base/common/emailtmpl/templates/en-US/space_invite_owner.text.tmpl @@ -0,0 +1,9 @@ +Octo Space invitation + +{{if .InviterName}}{{.InviterName}}{{else}}An Octo admin{{end}} invites you to create and own the team space "{{.PlannedName}}". +{{if .PlannedDesc}}Description: {{.PlannedDesc}} +{{end}} +Click the link below to accept the invitation and create the space: +{{.AcceptURL}} + +If you weren't expecting this invitation, you can safely ignore this email. \ No newline at end of file diff --git a/modules/base/common/emailtmpl/templates/en-US/verify_code.html.tmpl b/modules/base/common/emailtmpl/templates/en-US/verify_code.html.tmpl new file mode 100644 index 00000000..d80a154a --- /dev/null +++ b/modules/base/common/emailtmpl/templates/en-US/verify_code.html.tmpl @@ -0,0 +1,8 @@ +
+

Octo

+

Your verification code is:

+
+{{.Code}} +
+

This code is valid for 5 minutes. Please do not share it with anyone.

+
\ No newline at end of file diff --git a/modules/base/common/emailtmpl/templates/en-US/verify_code.subject.tmpl b/modules/base/common/emailtmpl/templates/en-US/verify_code.subject.tmpl new file mode 100644 index 00000000..a9fe0a10 --- /dev/null +++ b/modules/base/common/emailtmpl/templates/en-US/verify_code.subject.tmpl @@ -0,0 +1 @@ +Octo verification code \ No newline at end of file diff --git a/modules/base/common/emailtmpl/templates/en-US/verify_code.text.tmpl b/modules/base/common/emailtmpl/templates/en-US/verify_code.text.tmpl new file mode 100644 index 00000000..5c13a3e9 --- /dev/null +++ b/modules/base/common/emailtmpl/templates/en-US/verify_code.text.tmpl @@ -0,0 +1,3 @@ +Your Octo verification code is: {{.Code}} + +This code is valid for 5 minutes. Please do not share it with anyone. \ No newline at end of file diff --git a/modules/base/common/emailtmpl/templates/zh-CN/space_invite_member.html.tmpl b/modules/base/common/emailtmpl/templates/zh-CN/space_invite_member.html.tmpl new file mode 100644 index 00000000..1fe6aca3 --- /dev/null +++ b/modules/base/common/emailtmpl/templates/zh-CN/space_invite_member.html.tmpl @@ -0,0 +1,16 @@ + + +
+

Octo Space 邀请

+

+ {{if .InviterName}}{{.InviterName}}{{else}}Octo 管理员{{end}} 邀请你以 {{if .IsAdmin}}管理员{{else}}成员{{end}} 身份加入团队空间 {{.SpaceName}}。 +

+

+ 接受邀请 +

+

+ 若按钮无法点击,请复制下方链接到浏览器打开:
+ {{.AcceptURL}} +

+

如果你并未预期此邀请,可忽略此邮件。

+
\ No newline at end of file diff --git a/modules/base/common/emailtmpl/templates/zh-CN/space_invite_member.subject.tmpl b/modules/base/common/emailtmpl/templates/zh-CN/space_invite_member.subject.tmpl new file mode 100644 index 00000000..9a27fea6 --- /dev/null +++ b/modules/base/common/emailtmpl/templates/zh-CN/space_invite_member.subject.tmpl @@ -0,0 +1 @@ +Octo 邀请你加入团队空间「{{.SpaceName}}」 \ No newline at end of file diff --git a/modules/base/common/emailtmpl/templates/zh-CN/space_invite_member.text.tmpl b/modules/base/common/emailtmpl/templates/zh-CN/space_invite_member.text.tmpl new file mode 100644 index 00000000..d5bebb06 --- /dev/null +++ b/modules/base/common/emailtmpl/templates/zh-CN/space_invite_member.text.tmpl @@ -0,0 +1,8 @@ +Octo Space 邀请 + +{{if .InviterName}}{{.InviterName}}{{else}}Octo 管理员{{end}} 邀请你以{{if .IsAdmin}}管理员{{else}}成员{{end}}身份加入团队空间「{{.SpaceName}}」。 + +点击下方链接接受邀请: +{{.AcceptURL}} + +如果你并未预期此邀请,可忽略此邮件。 \ No newline at end of file diff --git a/modules/base/common/emailtmpl/templates/zh-CN/space_invite_owner.html.tmpl b/modules/base/common/emailtmpl/templates/zh-CN/space_invite_owner.html.tmpl new file mode 100644 index 00000000..5ff39428 --- /dev/null +++ b/modules/base/common/emailtmpl/templates/zh-CN/space_invite_owner.html.tmpl @@ -0,0 +1,17 @@ + + +
+

Octo Space 邀请

+

+ {{if .InviterName}}{{.InviterName}}{{else}}Octo 管理员{{end}} 邀请你创建并成为团队空间 {{.PlannedName}} 的所有者。 +

+ {{if .PlannedDesc}}

空间描述:{{.PlannedDesc}}

{{end}} +

+ 接受邀请并创建空间 +

+

+ 若按钮无法点击,请复制下方链接到浏览器打开:
+ {{.AcceptURL}} +

+

如果你并未预期此邀请,可忽略此邮件。

+
\ No newline at end of file diff --git a/modules/base/common/emailtmpl/templates/zh-CN/space_invite_owner.subject.tmpl b/modules/base/common/emailtmpl/templates/zh-CN/space_invite_owner.subject.tmpl new file mode 100644 index 00000000..ea2126ba --- /dev/null +++ b/modules/base/common/emailtmpl/templates/zh-CN/space_invite_owner.subject.tmpl @@ -0,0 +1 @@ +Octo 邀请你创建团队空间「{{.PlannedName}}」 \ No newline at end of file diff --git a/modules/base/common/emailtmpl/templates/zh-CN/space_invite_owner.text.tmpl b/modules/base/common/emailtmpl/templates/zh-CN/space_invite_owner.text.tmpl new file mode 100644 index 00000000..26040d79 --- /dev/null +++ b/modules/base/common/emailtmpl/templates/zh-CN/space_invite_owner.text.tmpl @@ -0,0 +1,9 @@ +Octo Space 邀请 + +{{if .InviterName}}{{.InviterName}}{{else}}Octo 管理员{{end}} 邀请你创建并成为团队空间「{{.PlannedName}}」的所有者。 +{{if .PlannedDesc}}空间描述:{{.PlannedDesc}} +{{end}} +点击下方链接接受邀请并创建空间: +{{.AcceptURL}} + +如果你并未预期此邀请,可忽略此邮件。 \ No newline at end of file diff --git a/modules/base/common/emailtmpl/templates/zh-CN/verify_code.html.tmpl b/modules/base/common/emailtmpl/templates/zh-CN/verify_code.html.tmpl new file mode 100644 index 00000000..868d6db6 --- /dev/null +++ b/modules/base/common/emailtmpl/templates/zh-CN/verify_code.html.tmpl @@ -0,0 +1,8 @@ +
+

Octo

+

您的验证码为:

+
+{{.Code}} +
+

验证码 5 分钟内有效,请勿泄露给他人。

+
\ No newline at end of file diff --git a/modules/base/common/emailtmpl/templates/zh-CN/verify_code.subject.tmpl b/modules/base/common/emailtmpl/templates/zh-CN/verify_code.subject.tmpl new file mode 100644 index 00000000..bb425012 --- /dev/null +++ b/modules/base/common/emailtmpl/templates/zh-CN/verify_code.subject.tmpl @@ -0,0 +1 @@ +Octo 验证码 \ No newline at end of file diff --git a/modules/base/common/emailtmpl/templates/zh-CN/verify_code.text.tmpl b/modules/base/common/emailtmpl/templates/zh-CN/verify_code.text.tmpl new file mode 100644 index 00000000..198d3b8a --- /dev/null +++ b/modules/base/common/emailtmpl/templates/zh-CN/verify_code.text.tmpl @@ -0,0 +1,3 @@ +您的 Octo 验证码为:{{.Code}} + +验证码 5 分钟内有效,请勿泄露给他人。 \ No newline at end of file diff --git a/modules/base/common/service_email.go b/modules/base/common/service_email.go index c4a6fa8e..376430cf 100644 --- a/modules/base/common/service_email.go +++ b/modules/base/common/service_email.go @@ -16,6 +16,7 @@ import ( "github.com/Mininglamp-OSS/octo-lib/config" "github.com/Mininglamp-OSS/octo-lib/pkg/log" + "github.com/Mininglamp-OSS/octo-server/modules/base/common/emailtmpl" "go.uber.org/zap" ) @@ -23,8 +24,9 @@ const CacheKeyEmailCode = "emailcode:" // IEmailService 邮件服务接口 type IEmailService interface { - // 发送验证码 - SendVerifyCode(ctx context.Context, email string, codeType CodeType) error + // 发送验证码。lang 为收件人内容语言(BCP-47),用于渲染主题与正文; + // 调用方通常传 i18n.OutboundLanguage(ctx),其会兜底到 OCTO_DEFAULT_LANGUAGE。 + SendVerifyCode(ctx context.Context, email string, codeType CodeType, lang string) error // 验证验证码(销毁缓存) Verify(ctx context.Context, email, code string, codeType CodeType) error // SendHTMLEmail 发送一封 HTML 邮件(不走频率限制 / 验证码缓存,由调用方自己控制) @@ -72,10 +74,14 @@ func NewEmailService(ctx *config.Context, settings SMTPSettingsProvider) *EmailS // 1-minute resend cooldown is still active. It is a client-actionable condition // (HTTP 429), not an internal failure — callers should branch on it with // errors.Is rather than collapsing it onto a generic send-failure code. -var ErrEmailSendRateLimited = errors.New("发送过于频繁,请1分钟后再试") +var ErrEmailSendRateLimited = errors.New("email resend cooldown active, retry in 1 minute") -// SendVerifyCode 发送验证码 -func (s *EmailService) SendVerifyCode(ctx context.Context, email string, codeType CodeType) error { +// SendVerifyCode 发送验证码。 +// +// 主题/正文由 emailtmpl 按 lang 渲染(外置 per-lang 模板,issue #221);走 +// SendTransactionalHTML 而非极简 sendEmail —— 验证码是高价值事务邮件,带 +// plaintext 兜底 + 标准事务邮件 header 可显著降低被反垃圾静默丢弃的概率。 +func (s *EmailService) SendVerifyCode(ctx context.Context, email string, codeType CodeType, lang string) error { // 检查发送频率限制 rateLimitKey := fmt.Sprintf("email_rate_limit:%s", email) exists, err := s.ctx.GetRedisConn().GetString(rateLimitKey) @@ -89,11 +95,19 @@ func (s *EmailService) SendVerifyCode(ctx context.Context, email string, codeTyp // 生成6位验证码 code, err := generateSecureVerifyCode(6) if err != nil { - s.Error("生成验证码失败", zap.Error(err)) - return errors.New("系统错误,请稍后重试") + s.Error("generate verify code", zap.Error(err)) + return errors.New("internal error, please retry") } s.Info("发送邮箱验证码", zap.String("email", email)) + rendered, err := emailtmpl.Render(emailtmpl.KeyVerifyCode, lang, emailtmpl.VerifyCodeData{Code: code}) + if err != nil { + // 渲染失败属于配置/构建问题(模板缺失/损坏),不应把验证码写进缓存后 + // 却发不出邮件 —— 在写缓存与限速之前先 fail。 + s.Error("render verify-code email", zap.String("lang", lang), zap.Error(err)) + return errors.New("internal error, please retry") + } + cacheKey := fmt.Sprintf("%s%d@%s", CacheKeyEmailCode, codeType, email) err = s.ctx.GetRedisConn().SetAndExpire(cacheKey, code, time.Minute*5) if err != nil { @@ -106,16 +120,7 @@ func (s *EmailService) SendVerifyCode(ctx context.Context, email string, codeTyp return err } - subject := "Octo 验证码" - body := fmt.Sprintf(`
-

Octo

-

您的验证码为:

-
-%s -
-

验证码 5 分钟内有效,请勿泄露给他人。

-
`, code) - return s.sendEmail(ctx, email, subject, body) + return s.SendTransactionalHTML(ctx, email, rendered.Subject, rendered.HTML, rendered.Text) } // SendHTMLEmail 直接发送一封 HTML 邮件。subject/body 由调用方负责,本方法 @@ -129,7 +134,7 @@ func (s *EmailService) SendVerifyCode(ctx context.Context, email string, codeTyp // 邮件请改用 SendTransactionalHTML,带 plaintext 兜底和完整事务邮件 header。 func (s *EmailService) SendHTMLEmail(ctx context.Context, to, subject, htmlBody string) error { if to == "" { - return errors.New("收件人不能为空") + return errors.New("recipient must not be empty") } return s.sendEmail(ctx, to, subject, htmlBody) } @@ -153,11 +158,11 @@ func (s *EmailService) SendHTMLEmail(ctx context.Context, to, subject, htmlBody // htmlBody 为空时,plaintext 仍会被发出(降级体验,但通路工作)。 func (s *EmailService) SendTransactionalHTML(ctx context.Context, to, subject, htmlBody, plainBody string) error { if to == "" { - return errors.New("收件人不能为空") + return errors.New("recipient must not be empty") } smtpAddr, fromAddr, pwd := s.resolveSMTP() if smtpAddr == "" || fromAddr == "" || pwd == "" { - return errors.New("邮件服务未配置,请联系管理员") + return errors.New("email service not configured") } toSan, fromSan, subjectSan := sanitizeHeader(to), sanitizeHeader(fromAddr), sanitizeHeader(subject) msg, err := buildTransactionalMessage(fromSan, toSan, subjectSan, htmlBody, plainBody) @@ -173,7 +178,7 @@ func (s *EmailService) sendEmail(ctx context.Context, to, subject, body string) smtpAddr, fromAddr, pwd := s.resolveSMTP() if smtpAddr == "" || fromAddr == "" || pwd == "" { - return errors.New("邮件服务未配置,请联系管理员") + return errors.New("email service not configured") } toSan, fromSan, subjectSan := sanitizeHeader(to), sanitizeHeader(fromAddr), sanitizeHeader(subject) @@ -370,7 +375,7 @@ func (s *EmailService) Verify(ctx context.Context, email, code string, codeType return err } if locked != "" { - return errors.New("验证失败次数过多,请10分钟后再试") + return errors.New("too many failed attempts, locked for 10 minutes") } // 支持测试验证码(仅限非 release 模式;release 下即便配置了 SMSCode 也不会匹配) @@ -406,10 +411,10 @@ func (s *EmailService) Verify(ctx context.Context, email, code string, codeType if failCount >= 3 { s.ctx.GetRedisConn().SetAndExpire(lockKey, "1", time.Minute*10) - return errors.New("验证失败次数过多,已锁定10分钟") + return errors.New("too many failed attempts, locked for 10 minutes") } s.ctx.GetRedisConn().SetAndExpire(failCountKey, fmt.Sprintf("%d", failCount), time.Minute*10) s.Info("邮箱验证码错误", zap.String("email", email)) - return errors.New("验证码无效!") + return errors.New("invalid verification code") } diff --git a/modules/space/email_invite_sender.go b/modules/space/email_invite_sender.go index 8041cd9e..20a9cbff 100644 --- a/modules/space/email_invite_sender.go +++ b/modules/space/email_invite_sender.go @@ -6,7 +6,9 @@ import ( "time" commonapi "github.com/Mininglamp-OSS/octo-server/modules/base/common" + "github.com/Mininglamp-OSS/octo-server/modules/base/common/emailtmpl" common "github.com/Mininglamp-OSS/octo-server/modules/common" + octoi18n "github.com/Mininglamp-OSS/octo-server/pkg/i18n" "go.uber.org/zap" ) @@ -15,10 +17,13 @@ import ( const inviteEmailSendTimeout = 30 * time.Second // inviteEmailSender 抽象掉具体的 SMTP 发送实现,便于在测试中替换为 recorder。 -// 故意保持 1 个方法 —— 发邮件的所有上层细节(subject/html 构造、链接拼接)都 -// 在 dispatchInviteEmail 中完成。 +// 故意保持 1 个方法 —— 发邮件的所有上层细节(subject/html/plain 构造、链接拼接) +// 都在 dispatchInviteEmail 中完成。 +// +// 走 SendTransactionalHTML(而非 SendHTMLEmail):邀请是高价值事务邮件,带 +// plaintext 兜底 + 标准事务邮件 header 可显著降低被反垃圾静默丢弃的概率。 type inviteEmailSender interface { - SendHTMLEmail(ctx context.Context, to, subject, htmlBody string) error + SendTransactionalHTML(ctx context.Context, to, subject, htmlBody, plainBody string) error } // inviteEmailSenderOverride 测试钩子:非 nil 时优先于默认 EmailService。 @@ -68,10 +73,16 @@ func (s *Space) dispatchInviteEmail(inv *spaceEmailInviteModel, rawToken string) ctx, cancel := context.WithTimeout(context.Background(), inviteEmailSendTimeout) defer cancel() + // 收件人语言:邀请是异步发送、收件人未必是已注册用户(无 uid)、且 ≠ 请求 + // 发起人,因此本期(issue #221 决策 A)统一渲染为 OCTO_DEFAULT_LANGUAGE。 + // OutboundLanguage 在脱离请求的 background ctx 下自然退回默认语言;lang 参数 + // 贯穿渲染与链接,将来接入 ResolveUserLanguage(recipient) 即在此处替换。 + lang := octoi18n.OutboundLanguage(ctx) + // 落地页由后端在 BaseURL/v1/space/email-invite 提供(emailInvitePage handler); // H5BaseURL 在多数部署里是独立 H5 服务,不会响应该路径。这里必须用 BaseURL, // 否则邮件链接会 404。修正于 PR #1194 review (W1)。 - acceptURL := emailInviteAcceptURL(s.ctx.GetConfig().External.BaseURL, rawToken) + acceptURL := emailInviteAcceptURL(s.ctx.GetConfig().External.BaseURL, rawToken, lang) if acceptURL == "" { s.Warn("跳过邀请邮件:未配置 External.BaseURL", zap.String("alert", "email_invite_send_skipped_no_base"), @@ -88,13 +99,12 @@ func (s *Space) dispatchInviteEmail(inv *spaceEmailInviteModel, rawToken string) } var ( - subject string - body string - bErr error + rendered emailtmpl.Rendered + bErr error ) switch inv.InviteType { case EmailInviteTypeOwner: - subject, body, bErr = buildOwnerInviteEmail(inv, inviterName, acceptURL) + rendered, bErr = buildOwnerInviteEmail(inv, inviterName, lang, acceptURL) case EmailInviteTypeMember: spaceName := "" sp, sErr := s.db.querySpaceByID(inv.SpaceId) @@ -104,7 +114,7 @@ func (s *Space) dispatchInviteEmail(inv *spaceEmailInviteModel, rawToken string) } else if sp != nil { spaceName = sp.Name } - subject, body, bErr = buildMemberInviteEmail(inv, inviterName, spaceName, acceptURL) + rendered, bErr = buildMemberInviteEmail(inv, inviterName, spaceName, lang, acceptURL) default: s.Warn("未知 invite_type,跳过邮件发送", zap.Int("inviteType", inv.InviteType)) return @@ -116,7 +126,9 @@ func (s *Space) dispatchInviteEmail(inv *spaceEmailInviteModel, rawToken string) return } - if err := s.currentInviteSender().SendHTMLEmail(ctx, inv.Email, subject, body); err != nil { + if err := s.currentInviteSender().SendTransactionalHTML( + ctx, inv.Email, rendered.Subject, rendered.HTML, rendered.Text, + ); err != nil { s.Error("发送邀请邮件失败", zap.String("alert", "email_invite_send_failed"), zap.Error(err), diff --git a/modules/space/email_invite_sender_test.go b/modules/space/email_invite_sender_test.go index 04fa380b..da4ae768 100644 --- a/modules/space/email_invite_sender_test.go +++ b/modules/space/email_invite_sender_test.go @@ -26,16 +26,17 @@ type recordingInviteSender struct { type sentInviteEmail struct { To string Subject string - Body string + Body string // htmlBody + Plain string // plainBody } func newRecordingSender() *recordingInviteSender { return &recordingInviteSender{done: make(chan struct{}, 4)} } -func (r *recordingInviteSender) SendHTMLEmail(_ context.Context, to, subject, body string) error { +func (r *recordingInviteSender) SendTransactionalHTML(_ context.Context, to, subject, htmlBody, plainBody string) error { r.mu.Lock() - r.calls = append(r.calls, sentInviteEmail{to, subject, body}) + r.calls = append(r.calls, sentInviteEmail{To: to, Subject: subject, Body: htmlBody, Plain: plainBody}) r.mu.Unlock() select { case r.done <- struct{}{}: diff --git a/modules/space/email_invite_template.go b/modules/space/email_invite_template.go index da2ef1d4..62c816f0 100644 --- a/modules/space/email_invite_template.go +++ b/modules/space/email_invite_template.go @@ -1,11 +1,12 @@ package space import ( - "bytes" "fmt" - "html/template" + htmltemplate "html/template" "net/url" "strings" + + "github.com/Mininglamp-OSS/octo-server/modules/base/common/emailtmpl" ) // emailInviteAcceptPath 后端承接邀请落地页的 API 路径。与 space_join_approve.html @@ -15,111 +16,40 @@ const emailInviteAcceptPath = "/v1/space/email-invite" // emailInviteAcceptURL 用 External.BaseURL 拼出邀请接受链接。base 为空时返回空串, // 由调用方决定是否跳过发送(典型场景:本地开发未配置 BaseURL)。 -func emailInviteAcceptURL(base, rawToken string) string { +// +// lang 非空时追加 ?...&lang=,让落地页与邮件用同一语言渲染(issue #221)。 +func emailInviteAcceptURL(base, rawToken, lang string) string { base = strings.TrimSpace(base) if base == "" { return "" } base = strings.TrimRight(base, "/") - return fmt.Sprintf("%s%s?token=%s", base, emailInviteAcceptPath, url.QueryEscape(rawToken)) -} - -// inviterDisplay 收件方看到的邀请人名;为空时回退到通用文案。 -func inviterDisplay(name string) string { - name = strings.TrimSpace(name) - if name == "" { - return "Octo 管理员" + link := fmt.Sprintf("%s%s?token=%s", base, emailInviteAcceptPath, url.QueryEscape(rawToken)) + if lang != "" { + link += "&lang=" + url.QueryEscape(lang) } - return name + return link } -type ownerEmailData struct { - InviterName string - PlannedName string - PlannedDesc string - AcceptURL template.URL // 链接已在拼接前 url.QueryEscape;URL 类型避免 html/template 误转义 -} - -type memberEmailData struct { - InviterName string - SpaceName string - RoleLabel string - AcceptURL template.URL -} - -var ( - ownerEmailTpl = template.Must(template.New("owner_invite").Parse(` - -
-

Octo Space 邀请

-

- {{.InviterName}} 邀请你创建并成为团队空间 {{.PlannedName}} 的所有者。 -

- {{if .PlannedDesc}}

空间描述:{{.PlannedDesc}}

{{end}} -

- 接受邀请并创建空间 -

-

- 若按钮无法点击,请复制下方链接到浏览器打开:
- {{.AcceptURL}} -

-

如果你并未预期此邀请,可忽略此邮件。

-
`)) - - memberEmailTpl = template.Must(template.New("member_invite").Parse(` - -
-

Octo Space 邀请

-

- {{.InviterName}} 邀请你以 {{.RoleLabel}} 身份加入团队空间 {{.SpaceName}}。 -

-

- 接受邀请 -

-

- 若按钮无法点击,请复制下方链接到浏览器打开:
- {{.AcceptURL}} -

-

如果你并未预期此邀请,可忽略此邮件。

-
`)) -) - -// buildOwnerInviteEmail 构造 owner 邀请邮件 (subject, html)。模板使用 html/template -// 自动转义所有用户输入字段,杜绝 XSS 注入。 -// -// 模板由 template.Must 编译期校验,且写入 bytes.Buffer 不会失败;当前实现返回 -// error 仅是为了未来若引入用户提供的方法/字段可失败时不会被静默吞掉。 -func buildOwnerInviteEmail(inv *spaceEmailInviteModel, inviterName, acceptURL string) (string, string, error) { - data := ownerEmailData{ - InviterName: inviterDisplay(inviterName), +// buildOwnerInviteEmail 构造 owner 邀请邮件。文案外置于 emailtmpl 的 per-lang +// 模板,html/template 自动转义用户输入字段(InviterName/PlannedName/...)杜绝 +// XSS;空 InviterName 的兜底文案由模板按语言提供,不再在 Go 里硬编码中文。 +func buildOwnerInviteEmail(inv *spaceEmailInviteModel, inviterName, lang, acceptURL string) (emailtmpl.Rendered, error) { + return emailtmpl.Render(emailtmpl.KeySpaceInviteOwner, lang, emailtmpl.SpaceInviteOwnerData{ + InviterName: strings.TrimSpace(inviterName), PlannedName: inv.PlannedName, PlannedDesc: inv.PlannedDescription, - AcceptURL: template.URL(acceptURL), - } - subject := fmt.Sprintf("Octo 邀请你创建团队空间「%s」", inv.PlannedName) - var buf bytes.Buffer - if err := ownerEmailTpl.Execute(&buf, data); err != nil { - return "", "", fmt.Errorf("渲染 owner 邀请邮件失败: %w", err) - } - return subject, buf.String(), nil + AcceptURL: htmltemplate.URL(acceptURL), + }) } -// buildMemberInviteEmail 构造 member 邀请邮件 (subject, html)。 -func buildMemberInviteEmail(inv *spaceEmailInviteModel, inviterName, spaceName, acceptURL string) (string, string, error) { - roleLabel := "成员" - if inv.Role == EmailInviteRoleAdmin { - roleLabel = "管理员" - } - data := memberEmailData{ - InviterName: inviterDisplay(inviterName), +// buildMemberInviteEmail 构造 member 邀请邮件。角色标签(成员/管理员)由模板按 +// IsAdmin 分支本地化,不再在 Go 里硬编码中文标签。 +func buildMemberInviteEmail(inv *spaceEmailInviteModel, inviterName, spaceName, lang, acceptURL string) (emailtmpl.Rendered, error) { + return emailtmpl.Render(emailtmpl.KeySpaceInviteMember, lang, emailtmpl.SpaceInviteMemberData{ + InviterName: strings.TrimSpace(inviterName), SpaceName: spaceName, - RoleLabel: roleLabel, - AcceptURL: template.URL(acceptURL), - } - subject := fmt.Sprintf("Octo 邀请你加入团队空间「%s」", spaceName) - var buf bytes.Buffer - if err := memberEmailTpl.Execute(&buf, data); err != nil { - return "", "", fmt.Errorf("渲染 member 邀请邮件失败: %w", err) - } - return subject, buf.String(), nil + IsAdmin: inv.Role == EmailInviteRoleAdmin, + AcceptURL: htmltemplate.URL(acceptURL), + }) } diff --git a/modules/space/email_invite_template_test.go b/modules/space/email_invite_template_test.go index f4e338b1..8c55ee55 100644 --- a/modules/space/email_invite_template_test.go +++ b/modules/space/email_invite_template_test.go @@ -9,20 +9,24 @@ import ( func TestEmailInviteAcceptURL(t *testing.T) { t.Run("无尾斜杠", func(t *testing.T) { - got := emailInviteAcceptURL("https://h5.example.com", "abc") + got := emailInviteAcceptURL("https://h5.example.com", "abc", "") assert.Equal(t, "https://h5.example.com/v1/space/email-invite?token=abc", got) }) t.Run("有尾斜杠", func(t *testing.T) { - got := emailInviteAcceptURL("https://h5.example.com/", "abc") + got := emailInviteAcceptURL("https://h5.example.com/", "abc", "") assert.Equal(t, "https://h5.example.com/v1/space/email-invite?token=abc", got) }) t.Run("token 走 URL 转义(防止 ?/& 截断查询串)", func(t *testing.T) { - got := emailInviteAcceptURL("https://h5.example.com", "a/b?c=d") + got := emailInviteAcceptURL("https://h5.example.com", "a/b?c=d", "") assert.Contains(t, got, "token=a%2Fb%3Fc%3Dd") }) + t.Run("lang 非空时追加 &lang=", func(t *testing.T) { + got := emailInviteAcceptURL("https://h5.example.com", "abc", "en-US") + assert.Equal(t, "https://h5.example.com/v1/space/email-invite?token=abc&lang=en-US", got) + }) t.Run("空 base 返回空串", func(t *testing.T) { - assert.Equal(t, "", emailInviteAcceptURL("", "abc")) - assert.Equal(t, "", emailInviteAcceptURL(" ", "abc")) + assert.Equal(t, "", emailInviteAcceptURL("", "abc", "zh-CN")) + assert.Equal(t, "", emailInviteAcceptURL(" ", "abc", "zh-CN")) }) } @@ -34,14 +38,28 @@ func TestBuildOwnerInviteEmail_ContainsKeyFields(t *testing.T) { InviteType: EmailInviteTypeOwner, } link := "https://h5.example.com/space-email-invite.html?token=tok123" - subject, body, err := buildOwnerInviteEmail(inv, "Alice", link) + got, err := buildOwnerInviteEmail(inv, "Alice", "zh-CN", link) assert.NoError(t, err) - assert.Contains(t, subject, "我的团队") - assert.Contains(t, body, "我的团队") - assert.Contains(t, body, "Alice") - assert.Contains(t, body, link) - assert.Contains(t, body, "做大事") + assert.Contains(t, got.Subject, "我的团队") + assert.Contains(t, got.HTML, "我的团队") + assert.Contains(t, got.HTML, "Alice") + assert.Contains(t, got.HTML, link) + assert.Contains(t, got.HTML, "做大事") + // plaintext 兜底部分也应带链接与空间名 + assert.Contains(t, got.Text, link) + assert.Contains(t, got.Text, "我的团队") +} + +func TestBuildOwnerInviteEmail_EnglishLang(t *testing.T) { + inv := &spaceEmailInviteModel{ + PlannedName: "My Team", + InviteType: EmailInviteTypeOwner, + } + got, err := buildOwnerInviteEmail(inv, "Alice", "en-US", "https://h5.example.com/x?token=t") + assert.NoError(t, err) + assert.Contains(t, got.Subject, "My Team") + assert.Contains(t, got.HTML, "invites you to create") } func TestBuildOwnerInviteEmail_EscapesHTML(t *testing.T) { @@ -50,22 +68,25 @@ func TestBuildOwnerInviteEmail_EscapesHTML(t *testing.T) { PlannedDescription: "", InviteType: EmailInviteTypeOwner, } - _, body, err := buildOwnerInviteEmail(inv, "Bb", "https://h5.example.com/x?token=t") + got, err := buildOwnerInviteEmail(inv, "Bb", "zh-CN", "https://h5.example.com/x?token=t") assert.NoError(t, err) - // 危险标签必须被转义,绝不出现在原文中 - assert.NotContains(t, body, "") - assert.NotContains(t, body, "") - assert.Contains(t, body, "<script>") - assert.NotContains(t, body, "Bb") + // 危险标签必须被转义,绝不出现在 HTML 原文中 + assert.NotContains(t, got.HTML, "") + assert.NotContains(t, got.HTML, "") + assert.Contains(t, got.HTML, "<script>") + assert.NotContains(t, got.HTML, "Bb") } -func TestBuildOwnerInviteEmail_AnonymousInviter(t *testing.T) { +func TestBuildOwnerInviteEmail_AnonymousInviterLocalized(t *testing.T) { inv := &spaceEmailInviteModel{PlannedName: "X", InviteType: EmailInviteTypeOwner} - _, body, err := buildOwnerInviteEmail(inv, "", "https://h5.example.com/?token=t") + zh, err := buildOwnerInviteEmail(inv, "", "zh-CN", "https://h5.example.com/?token=t") assert.NoError(t, err) - // 匿名时给出兜底文案,不要出现裸 "by " 或空白 - assert.NotContains(t, body, "by ") + assert.Contains(t, zh.HTML, "Octo 管理员") + + en, err := buildOwnerInviteEmail(inv, "", "en-US", "https://h5.example.com/?token=t") + assert.NoError(t, err) + assert.Contains(t, en.HTML, "An Octo admin") } func TestBuildMemberInviteEmail_RoleLabel(t *testing.T) { @@ -73,27 +94,34 @@ func TestBuildMemberInviteEmail_RoleLabel(t *testing.T) { t.Run("普通成员", func(t *testing.T) { inv := &spaceEmailInviteModel{Role: EmailInviteRoleMember, InviteType: EmailInviteTypeMember} - subj, body, err := buildMemberInviteEmail(inv, "Alice", "Acme", link) + got, err := buildMemberInviteEmail(inv, "Alice", "Acme", "zh-CN", link) assert.NoError(t, err) - assert.Contains(t, subj, "Acme") - assert.Contains(t, body, "Acme") - assert.Contains(t, body, link) - assert.Contains(t, body, "Alice") - assert.True(t, strings.Contains(body, "成员") && !strings.Contains(body, "管理员")) + assert.Contains(t, got.Subject, "Acme") + assert.Contains(t, got.HTML, "Acme") + assert.Contains(t, got.HTML, link) + assert.Contains(t, got.HTML, "Alice") + assert.True(t, strings.Contains(got.HTML, "成员") && !strings.Contains(got.HTML, "管理员")) }) t.Run("管理员", func(t *testing.T) { inv := &spaceEmailInviteModel{Role: EmailInviteRoleAdmin, InviteType: EmailInviteTypeMember} - _, body, err := buildMemberInviteEmail(inv, "Alice", "Acme", link) + got, err := buildMemberInviteEmail(inv, "Alice", "Acme", "zh-CN", link) + assert.NoError(t, err) + assert.Contains(t, got.HTML, "管理员") + }) + + t.Run("英文-管理员", func(t *testing.T) { + inv := &spaceEmailInviteModel{Role: EmailInviteRoleAdmin, InviteType: EmailInviteTypeMember} + got, err := buildMemberInviteEmail(inv, "Alice", "Acme", "en-US", link) assert.NoError(t, err) - assert.Contains(t, body, "管理员") + assert.Contains(t, got.HTML, "administrator") }) } func TestBuildMemberInviteEmail_EscapesHTML(t *testing.T) { inv := &spaceEmailInviteModel{Role: EmailInviteRoleMember, InviteType: EmailInviteTypeMember} - _, body, err := buildMemberInviteEmail(inv, "", "", "https://h5.example.com/?token=t") + got, err := buildMemberInviteEmail(inv, "", "", "zh-CN", "https://h5.example.com/?token=t") assert.NoError(t, err) - assert.NotContains(t, body, "") - assert.Contains(t, body, "<svg") + assert.NotContains(t, got.HTML, "") + assert.Contains(t, got.HTML, "<svg") } diff --git a/modules/user/api_emaillogin.go b/modules/user/api_emaillogin.go index e401aa15..2b08ce16 100644 --- a/modules/user/api_emaillogin.go +++ b/modules/user/api_emaillogin.go @@ -12,6 +12,7 @@ import ( commonapi "github.com/Mininglamp-OSS/octo-server/modules/base/common" common "github.com/Mininglamp-OSS/octo-server/modules/common" "github.com/Mininglamp-OSS/octo-server/pkg/errcode" + octoi18n "github.com/Mininglamp-OSS/octo-server/pkg/i18n" "github.com/opentracing/opentracing-go" "go.uber.org/zap" ) @@ -65,7 +66,11 @@ func (u *User) emailSendCode(c *wkhttp.Context) { } emailService := commonapi.NewEmailService(u.ctx, common.EnsureSystemSettings(u.ctx)) - if err := emailService.SendVerifyCode(context.Background(), req.Email, commonapi.CodeType(req.CodeType)); err != nil { + // 验证码场景:请求发起人即收件人,用其请求级语言协商(Accept-Language / + // ?lang= / cookie),无信号时 OutboundLanguage 兜底到 OCTO_DEFAULT_LANGUAGE。 + // 发送 ctx 仍用 Background(脱离请求超时),lang 单独取自请求 ctx。 + lang := octoi18n.OutboundLanguage(c.Request.Context()) + if err := emailService.SendVerifyCode(context.Background(), req.Email, commonapi.CodeType(req.CodeType), lang); err != nil { // 1 分钟重发冷却是客户端可处理状态 → 429(文案可见),其余(Redis/SMTP) // 才是 5xx 内部故障。 if errors.Is(err, commonapi.ErrEmailSendRateLimited) { diff --git a/pkg/i18n/ctx.go b/pkg/i18n/ctx.go index 511ee460..2e34b9ef 100644 --- a/pkg/i18n/ctx.go +++ b/pkg/i18n/ctx.go @@ -72,6 +72,23 @@ func LanguageOrDefault(ctx context.Context, fallback string) string { return fallback } +// OutboundLanguage 解析「发出方内容」(邮件等)应使用的语言。 +// +// 与请求响应不同,邮件常在脱离请求 ctx 的路径上生成(异步 goroutine、未登录 +// 的验证码发送),此时 ctx 里没有早期协商决策,也没有 UserInfo。本函数把 +// LanguageFromContext 的结果与 OCTO_DEFAULT_LANGUAGE 兜底合并:拿得到协商语言 +// 就用它,否则退回部署默认语言。 +// +// 这样调用点写法统一(一行拿 lang),且当未来把请求 ctx 或收件人语言接入这条 +// 链路时无需改动发送层与模板层——结果会自动从 default 切换到真实语言。 +func OutboundLanguage(ctx context.Context) string { + def, err := DefaultLanguageFromEnv() + if err != nil { + def = DefaultLanguage + } + return LanguageOrDefault(ctx, def) +} + func languageSourcePriority(source LanguageSource) int { switch source { case LanguageSourceTrustedHeader: