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}}.
+
+ 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 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 @@
+
+
+
\ 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 @@
+
+
+
\ 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(`