From d7ee87433f54b6ad74dfe0fa6d583064daf49c16 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sat, 1 Feb 2025 17:53:21 +0800 Subject: [PATCH] :sparkles: Push email & notification localization --- .idea/dataSources.local.xml | 2 +- .idea/workspace.xml | 37 +++- Dockerfile | 2 + go.mod | 1 + go.sum | 2 + locales/en-US.json | 7 + locales/zh-CN.json | 7 + pkg/internal/http/api/accounts_api.go | 2 +- pkg/internal/http/api/factors_api.go | 2 +- pkg/internal/services/factors.go | 34 ++- pkg/internal/services/i18n.go | 100 +++++++++ pkg/internal/services/tokens.go | 88 ++------ pkg/main.go | 5 + settings.toml | 3 + templates/email/en-US/confirm-deletion.tmpl | 228 ++++++++++++++++++++ templates/email/en-US/email-otp.tmpl | 202 +++++++++++++++++ templates/email/en-US/register-confirm.tmpl | 213 ++++++++++++++++++ templates/email/en-US/reset-password.tmpl | 218 +++++++++++++++++++ templates/email/zh-CN/confirm-deletion.tmpl | 228 ++++++++++++++++++++ templates/email/zh-CN/email-otp.tmpl | 202 +++++++++++++++++ templates/email/zh-CN/register-confirm.tmpl | 213 ++++++++++++++++++ templates/email/zh-CN/reset-password.tmpl | 218 +++++++++++++++++++ 22 files changed, 1906 insertions(+), 108 deletions(-) create mode 100644 locales/en-US.json create mode 100644 locales/zh-CN.json create mode 100644 pkg/internal/services/i18n.go create mode 100644 templates/email/en-US/confirm-deletion.tmpl create mode 100644 templates/email/en-US/email-otp.tmpl create mode 100644 templates/email/en-US/register-confirm.tmpl create mode 100644 templates/email/en-US/reset-password.tmpl create mode 100644 templates/email/zh-CN/confirm-deletion.tmpl create mode 100644 templates/email/zh-CN/email-otp.tmpl create mode 100644 templates/email/zh-CN/register-confirm.tmpl create mode 100644 templates/email/zh-CN/reset-password.tmpl diff --git a/.idea/dataSources.local.xml b/.idea/dataSources.local.xml index 52680d5..6e32d8b 100644 --- a/.idea/dataSources.local.xml +++ b/.idea/dataSources.local.xml @@ -1,6 +1,6 @@ - + " diff --git a/.idea/workspace.xml b/.idea/workspace.xml index ffcf69b..aca036c 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -4,9 +4,29 @@ - + + + + + + + + + + + + + - + + + + + + + + + @@ -193,11 +213,6 @@ - - file://$PROJECT_DIR$/pkg/internal/services/factors.go - 85 - file://$PROJECT_DIR$/pkg/internal/services/notifications.go 93 diff --git a/Dockerfile b/Dockerfile index af19f58..c072bdf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,6 +9,8 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -buildvcs -o /dist ./pkg/main FROM golang:alpine COPY --from=passport-server /dist /passport/server +COPY ./templates /templates +COPY ./locales /locales EXPOSE 8444 diff --git a/go.mod b/go.mod index 5e210ce..626a13e 100644 --- a/go.mod +++ b/go.mod @@ -64,6 +64,7 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/nats-io/nkeys v0.4.7 // indirect github.com/nats-io/nuid v1.0.1 // indirect + github.com/nicksnyder/go-i18n/v2 v2.5.0 // indirect github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect github.com/pkg/errors v0.9.1 // indirect diff --git a/go.sum b/go.sum index 9da9274..9bf6ded 100644 --- a/go.sum +++ b/go.sum @@ -269,6 +269,8 @@ github.com/nats-io/nkeys v0.4.7 h1:RwNJbbIdYCoClSDNY7QVKZlyb/wfT6ugvFCiKy6vDvI= github.com/nats-io/nkeys v0.4.7/go.mod h1:kqXRgRDPlGy7nGaEDMuYzmiJCIAAWDK0IMBtDmGD0nc= github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/nicksnyder/go-i18n/v2 v2.5.0 h1:3wH1gpaekcgGuwzWdSu7JwJhH9Tk87k1ezt0i1p2/Is= +github.com/nicksnyder/go-i18n/v2 v2.5.0/go.mod h1:DrhgsSDZxoAfvVrBVLXoxZn/pN5TXqaDbq7ju94viiQ= github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1GshSTtih8C2gDs04w8dReiOGXrGLNoY= diff --git a/locales/en-US.json b/locales/en-US.json new file mode 100644 index 0000000..dbce47b --- /dev/null +++ b/locales/en-US.json @@ -0,0 +1,7 @@ +{ + "subjectLoginOneTimePassword": "Login verification code", + "shortBodyLoginOneTimePassword": "%s is your login verification code. It will expires in 30 minutes.", + "subjectConfirmRegistration": "Confirm your registration", + "subjectResetPassword": "Reset your password", + "subjectDeleteAccount": "Confirm your account deletion" +} \ No newline at end of file diff --git a/locales/zh-CN.json b/locales/zh-CN.json new file mode 100644 index 0000000..1e247a1 --- /dev/null +++ b/locales/zh-CN.json @@ -0,0 +1,7 @@ +{ + "subjectLoginOneTimePassword": "您的验证码", + "shortBodyLoginOneTimePassword": "%s 是您的登录验证码,它将在 30 分钟后过期。", + "subjectConfirmRegistration": "确认您的注册", + "subjectResetPassword": "重置您的密码", + "subjectDeleteAccount": "确认您的帐户删除" +} \ No newline at end of file diff --git a/pkg/internal/http/api/accounts_api.go b/pkg/internal/http/api/accounts_api.go index 58c1227..e589619 100644 --- a/pkg/internal/http/api/accounts_api.go +++ b/pkg/internal/http/api/accounts_api.go @@ -127,7 +127,7 @@ func updateAccountLanguage(c *fiber.Ctx) error { user := c.Locals("user").(models.Account) var data struct { - Language string `json:"language" validate:"required"` + Language string `json:"language" validate:"required,bcp47_language_tag"` } if err := exts.BindAndValidate(c, &data); err != nil { diff --git a/pkg/internal/http/api/factors_api.go b/pkg/internal/http/api/factors_api.go index 910f662..db23315 100644 --- a/pkg/internal/http/api/factors_api.go +++ b/pkg/internal/http/api/factors_api.go @@ -39,7 +39,7 @@ func requestFactorToken(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusNotFound, err.Error()) } - if sent, err := services.GetFactorCode(factor); err != nil { + if sent, err := services.GetFactorCode(factor, c.IP()); err != nil { return fiber.NewError(fiber.StatusNotFound, err.Error()) } else if !sent { return c.SendStatus(fiber.StatusNoContent) diff --git a/pkg/internal/services/factors.go b/pkg/internal/services/factors.go index 55cf4f2..58b74ef 100644 --- a/pkg/internal/services/factors.go +++ b/pkg/internal/services/factors.go @@ -17,21 +17,6 @@ import ( "github.com/spf13/viper" ) -const EmailPasswordTemplate = `Dear %s, - -We hope this message finds you well. -As part of our ongoing commitment to ensuring the security of your account, we require you to complete the login process by entering the verification code below: - -Your Login Verification Code: %s - -Please use the provided code within the next 2 hours to complete your login. -If you did not request this code, please update your information, maybe your username or email has been leak. - -Thank you for your cooperation in helping us maintain the security of your account. - -Best regards, -%s` - func GetPasswordTypeFactor(userId uint) (models.AuthFactor, error) { var factor models.AuthFactor err := database.C.Where(models.AuthFactor{ @@ -69,7 +54,7 @@ func CountUserFactor(userId uint) int64 { return count } -func GetFactorCode(factor models.AuthFactor) (bool, error) { +func GetFactorCode(factor models.AuthFactor, ip string) (bool, error) { switch factor.Type { case models.InAppNotifyFactor: var user models.Account @@ -91,10 +76,11 @@ func GetFactorCode(factor models.AuthFactor) (bool, error) { err = PushNotification(models.Notification{ Topic: "passport.security.otp", - Title: "Your login one-time-password", - Body: fmt.Sprintf("`%s` is your login verification code. It will expires in 30 minutes.", secret), + Title: GetLocalizedString("subjectLoginOneTimePassword", user.Language), + Body: fmt.Sprintf(GetLocalizedString("shortBodyLoginOneTimePassword", user.Language), secret), Account: user, AccountID: user.ID, + Metadata: map[string]any{"secret": secret}, }, true) if err != nil { log.Warn().Err(err).Uint("factor", factor.ID).Msg("Failed to delivery one-time-password via notify...") @@ -119,8 +105,14 @@ func GetFactorCode(factor models.AuthFactor) (bool, error) { log.Info().Uint("factor", factor.ID).Str("secret", secret).Msg("Published one-time-password to JetStream...") } - subject := fmt.Sprintf("[%s] Login verification code", viper.GetString("name")) - content := fmt.Sprintf(EmailPasswordTemplate, user.Name, secret, viper.GetString("maintainer")) + subject := fmt.Sprintf("[%s] %s", viper.GetString("name"), GetLocalizedString("subjectLoginOneTimePassword", user.Language)) + + content := RenderLocalizedTemplateHTML("email-otp.tmpl", user.Language, map[string]any{ + "Code": secret, + "User": user, + "IP": ip, + "Date": time.Now().Format(time.DateTime), + }) err = gap.Px.PushEmail(pushkit.EmailDeliverRequest{ To: user.GetPrimaryEmail().Content, @@ -148,7 +140,7 @@ func CheckFactor(factor models.AuthFactor, code string) error { fmt.Errorf("invalid password"), ) case models.TimeOtpFactor: - lo.Ternary( + return lo.Ternary( totp.Validate(code, factor.Secret), nil, fmt.Errorf("invalid verification code"), diff --git a/pkg/internal/services/i18n.go b/pkg/internal/services/i18n.go new file mode 100644 index 0000000..0fd4be1 --- /dev/null +++ b/pkg/internal/services/i18n.go @@ -0,0 +1,100 @@ +package services + +import ( + "errors" + "fmt" + "github.com/goccy/go-json" + "github.com/nicksnyder/go-i18n/v2/i18n" + "github.com/rs/zerolog/log" + "github.com/spf13/viper" + "golang.org/x/text/language" + htmpl "html/template" + "os" + "path/filepath" + "strings" +) + +const FallbackLanguage = "en-US" + +var LocaleBundle *i18n.Bundle + +func LoadLocalization() error { + LocaleBundle = i18n.NewBundle(language.English) + LocaleBundle.RegisterUnmarshalFunc("json", json.Unmarshal) + + var count int + + basePath := viper.GetString("locales_dir") + if entries, err := os.ReadDir(basePath); err != nil { + return fmt.Errorf("unable to read locales directory: %v", err) + } else { + for _, entry := range entries { + if entry.IsDir() { + continue + } + if _, err := LocaleBundle.LoadMessageFile(filepath.Join(basePath, entry.Name())); err != nil { + return fmt.Errorf("unable to load localization file %s: %v", entry.Name(), err) + } else { + count++ + } + } + } + + log.Info().Int("locales", count).Msg("Loaded localization files...") + + return nil +} + +func GetLocalizer(lang string) *i18n.Localizer { + return i18n.NewLocalizer(LocaleBundle, lang) +} + +func GetLocalizedString(name string, lang string) string { + localizer := GetLocalizer(lang) + msg, err := localizer.LocalizeMessage(&i18n.Message{ + ID: name, + }) + if err != nil { + log.Warn().Err(err).Str("lang", lang).Str("name", name).Msg("Failed to localize string...") + return name + } + return msg +} + +func GetLocalizedTemplatePath(name string, lang string) string { + basePath := viper.GetString("templates_dir") + filePath := filepath.Join(basePath, lang, name) + + if _, err := os.Stat(filePath); errors.Is(err, os.ErrNotExist) { + // Fallback to English + filePath = filepath.Join(basePath, FallbackLanguage, name) + return filePath + } + + return filePath +} + +func GetLocalizedTemplateHTML(name string, lang string) *htmpl.Template { + path := GetLocalizedTemplatePath(name, lang) + tmpl, err := htmpl.ParseFiles(path) + if err != nil { + log.Warn().Err(err).Str("lang", lang).Str("name", name).Msg("Failed to load localized template...") + return nil + } + + return tmpl +} + +func RenderLocalizedTemplateHTML(name string, lang string, data any) string { + tmpl := GetLocalizedTemplateHTML(name, lang) + if tmpl == nil { + return "" + } + buf := new(strings.Builder) + err := tmpl.Execute(buf, data) + if err != nil { + log.Warn().Err(err).Str("lang", lang).Str("name", name).Msg("Failed to render localized template...") + return "" + } + return buf.String() +} diff --git a/pkg/internal/services/tokens.go b/pkg/internal/services/tokens.go index 5eeb29b..196478f 100644 --- a/pkg/internal/services/tokens.go +++ b/pkg/internal/services/tokens.go @@ -14,54 +14,6 @@ import ( "github.com/spf13/viper" ) -const ConfirmRegistrationTemplate = `Dear %s, - -Thank you for choosing to register with %s. We are excited to welcome you to our community and appreciate your trust in us. - -Your registration details have been successfully received, and you are now a valued member of %s. Here are the confirm link of your registration: - - %s - -As a confirmed registered member, you will have access to all our services. -We encourage you to explore our services and take full advantage of the resources available to you. - -Once again, thank you for choosing us. We look forward to serving you and hope you have a positive experience with us. - -Best regards, -%s` - -const ResetPasswordTemplate = `Dear %s, - -We received a request to reset the password for your account at %s. If you did not request a password reset, please ignore this email. - -To confirm your password reset request and create a new password, please use the link below: - -%s - -This link will expire in 24 hours. If you do not reset your password within this time frame, you will need to submit a new password reset request. - -If you have any questions or need further assistance, please do not hesitate to contact our support team. - -Best regards, -%s` - -const DeleteAccountTemplate = `Dear %s, - -We received a request to delete your account at %s. If you did not request a account deletion, please change your account password right now. -If you changed your mind, please ignore this email. - -To confirm your account deletion request, please use the link below: - -%s - -This link will expire in 24 hours. If you do not use that link within this time frame, you will need to submit an account deletion request. - -If you have any questions or need further assistance, please do not hesitate to contact our support team. -Also, if you want to let us know why you decided to delete your account, send email us (lily@solsynth.dev) and tell us how could we improve our user experience. - -Best regards, -%s` - func ValidateMagicToken(code string, mode models.MagicTokenType) (models.MagicToken, error) { var tk models.MagicToken if err := database.C.Where(models.MagicToken{Code: code, Type: mode}).First(&tk).Error; err != nil { @@ -110,35 +62,25 @@ func NotifyMagicToken(token models.MagicToken) error { switch token.Type { case models.ConfirmMagicToken: link := fmt.Sprintf("%s/flow/accounts/confirm?code=%s", viper.GetString("frontend_app"), token.Code) - subject = fmt.Sprintf("[%s] Confirm your registration", viper.GetString("name")) - content = fmt.Sprintf( - ConfirmRegistrationTemplate, - user.Name, - viper.GetString("name"), - viper.GetString("maintainer"), - link, - viper.GetString("maintainer"), - ) + subject = fmt.Sprintf("[%s] %s", viper.GetString("name"), GetLocalizedString("subjectConfirmRegistration", user.Language)) + content = RenderLocalizedTemplateHTML("register-confirm.tmpl", user.Language, map[string]any{ + "User": user, + "Link": link, + }) case models.ResetPasswordMagicToken: link := fmt.Sprintf("%s/flow/accounts/password-reset?code=%s", viper.GetString("frontend_app"), token.Code) - subject = fmt.Sprintf("[%s] Reset your password", viper.GetString("name")) - content = fmt.Sprintf( - ResetPasswordTemplate, - user.Name, - viper.GetString("name"), - link, - viper.GetString("maintainer"), - ) + subject = fmt.Sprintf("[%s] %s", viper.GetString("name"), GetLocalizedString("subjectResetPassword", user.Language)) + content = RenderLocalizedTemplateHTML("reset-password.tmpl", user.Language, map[string]any{ + "User": user, + "Link": link, + }) case models.DeleteAccountMagicToken: link := fmt.Sprintf("%s/flow/accounts/deletion?code=%s", viper.GetString("frontend_app"), token.Code) - subject = fmt.Sprintf("[%s] Confirm your account deletion", viper.GetString("name")) - content = fmt.Sprintf( - DeleteAccountTemplate, - user.Name, - viper.GetString("name"), - link, - viper.GetString("maintainer"), - ) + subject = fmt.Sprintf("[%s] %s", viper.GetString("name"), GetLocalizedString("subjectDeleteAccount", user.Language)) + content = RenderLocalizedTemplateHTML("confirm-deletion.tmpl", user.Language, map[string]any{ + "User": user, + "Link": link, + }) default: return fmt.Errorf("unsupported magic token type to notify") } diff --git a/pkg/main.go b/pkg/main.go index b168773..3a1f3e3 100644 --- a/pkg/main.go +++ b/pkg/main.go @@ -71,6 +71,11 @@ func main() { log.Info().Msg("Jwt private key loaded.") } + // Load localization + if err := services.LoadLocalization(); err != nil { + log.Fatal().Err(err).Msg("An error occurred when loading localization.") + } + // Connect to database if err := database.NewGorm(); err != nil { log.Fatal().Err(err).Msg("An error occurred when connect to database.") diff --git a/settings.toml b/settings.toml index 5dae74c..753403c 100644 --- a/settings.toml +++ b/settings.toml @@ -5,6 +5,9 @@ bind = "0.0.0.0:8003" grpc_bind = "0.0.0.0:7003" domain = "id.solsynth.dev" +templates_dir = "templates" +locales_dir = "locales" + frontend_app = "https://solsynth.dev" content_endpoint = "https://usercontent.solsynth.dev" diff --git a/templates/email/en-US/confirm-deletion.tmpl b/templates/email/en-US/confirm-deletion.tmpl new file mode 100644 index 0000000..860bd9e --- /dev/null +++ b/templates/email/en-US/confirm-deletion.tmpl @@ -0,0 +1,228 @@ + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ +
+
+

+

+ +
+
Dear, @{{ .User.Name }}
+
+
We received your request of deleting your account on Solar Network. You can click the button below to confirm your request.
+
+
This link will expire within 24 hours.
+
+
We are sorry that you finally chose to delete your account. If you feel any unhappiness while using Solar Network, please contact our customer service to help us improve.
+
+
If you did not request delete, please change your account password and ignore this email.
+
+
If you changed your mind, you can ignore this email.
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + + + + +
+ + + + +
+ Confirm Deletion +
+
+
If the button above doesn't work, you can also use the link below.
{{ .Link }}
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+
Best Regards
Solsynth LLC
Team Solar Network
+
+
+ +
+
+ +
+ + + diff --git a/templates/email/en-US/email-otp.tmpl b/templates/email/en-US/email-otp.tmpl new file mode 100644 index 0000000..2272360 --- /dev/null +++ b/templates/email/en-US/email-otp.tmpl @@ -0,0 +1,202 @@ + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ +
+ + + + + + + + + + + + + + + +
+ + + + + + +
+ +
+
+

+

+ +
+
Dear, @{{ .User.Name }}
+
+
Someone is trying to log in to your account, if that guy is you, you can enter the code below to continue.
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+
{{ .Code }}
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + + + + +
+
Requested from {{ .IP }}
{{ .Date }}
+
+
Best Regards
Solsynth LLC
Team Solar Network
+
+
+ +
+
+ +
+ + + diff --git a/templates/email/en-US/register-confirm.tmpl b/templates/email/en-US/register-confirm.tmpl new file mode 100644 index 0000000..9c9f2bc --- /dev/null +++ b/templates/email/en-US/register-confirm.tmpl @@ -0,0 +1,213 @@ + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ +
+
+

+

+ +
+
Dear, @{{ .User.Name }}
+
+
Thank you for signing up for Solarpass. You are just one step away from gaining access to all the features of Solar Network. Click the button below to confirm your registration.
+
+
This link will expire within 24 hours, after which your unconfirmed account will be reclaimed.
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + + + + +
+ + + + +
+ Confirm Registration +
+
+
If the button above doesn't work, you can also use the link below.
{{ .Link }}
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+
Best Regards
Solsynth LLC
Team Solar Network
+
+
+ +
+
+ +
+ + + diff --git a/templates/email/en-US/reset-password.tmpl b/templates/email/en-US/reset-password.tmpl new file mode 100644 index 0000000..f727174 --- /dev/null +++ b/templates/email/en-US/reset-password.tmpl @@ -0,0 +1,218 @@ + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ +
+
+

+

+ +
+
Dear, @{{ .User.Name }}
+
+
We have received your request to reset your password. You can click the button below to set a new password.
+
+
This link will expire after 24 hours.
+
+
If you did not request a password reset, you can safely ignore this email.
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + + + + +
+ + + + +
+ Reset Password +
+
+
If the button above doesn't work, you can also use the link below.
{{ .Link }}
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+
Best regards,
Solsynth LLC
Team Solar Network
+
+
+ +
+
+ +
+ + + \ No newline at end of file diff --git a/templates/email/zh-CN/confirm-deletion.tmpl b/templates/email/zh-CN/confirm-deletion.tmpl new file mode 100644 index 0000000..ad86d86 --- /dev/null +++ b/templates/email/zh-CN/confirm-deletion.tmpl @@ -0,0 +1,228 @@ + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ +
+
+

+

+ +
+
尊敬的 @{{ .User.Name }}
+
+
我们收到了您删除 Solar Network 帐户的请求。您可以点击下面的按钮确认您的请求。
+
+
这个链接会在 24 小时后过期。
+
+
我们很遗憾你最终选择了删除你的帐号,如果在使用 Solar Network 过程中感到任何不愉快,欢迎联系我们的客户服务来帮助我们改进。
+
+
如果您没有请求删除,请更改您的帐户密码并忽略此电子邮件。
+
+
如果您改变了你的主意,可以忽略这封电子邮件。
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + + + + +
+ + + + +
+ 确认删除 +
+
+
如果上方的按钮不起作用,你也可以使用下方的链接。
{{ .Link }}
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+
此致
索尔幸茨
Solar Network 团队
+
+
+ +
+
+ +
+ + + diff --git a/templates/email/zh-CN/email-otp.tmpl b/templates/email/zh-CN/email-otp.tmpl new file mode 100644 index 0000000..9bd3710 --- /dev/null +++ b/templates/email/zh-CN/email-otp.tmpl @@ -0,0 +1,202 @@ + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ +
+ + + + + + + + + + + + + + + +
+ + + + + + +
+ +
+
+

+

+ +
+
尊敬的 @{{ .User.Name }}
+
+
有人正在尝试登陆你的帐号,如果那个人就是你,输入以下的代码来继续。
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+
{{ .Code }}
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + + + + +
+
来自 {{ .IP }} 的请求
{{ .Date }}
+
+
此致
索尔幸茨
Solar Network 团队
+
+
+ +
+
+ +
+ + + diff --git a/templates/email/zh-CN/register-confirm.tmpl b/templates/email/zh-CN/register-confirm.tmpl new file mode 100644 index 0000000..f0ce5dd --- /dev/null +++ b/templates/email/zh-CN/register-confirm.tmpl @@ -0,0 +1,213 @@ + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ +
+
+

+

+ +
+
尊敬的 @{{ .User.Name }}
+
+
感谢你注册 Solarpass,你只差最后一步就能获得使用 Solar Network 全部功能的权利。点击下方按钮来确认你的注册。
+
+
这个链接会在 24 小时内过期,过期之后,你未确认的帐号也会被回收。
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + + + + +
+ + + + +
+ 确认注册 +
+
+
如果上方的按钮不起作用,你也可以使用下方的链接。
{{ .Link }}
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+
此致
索尔幸茨
Solar Network 团队
+
+
+ +
+
+ +
+ + + diff --git a/templates/email/zh-CN/reset-password.tmpl b/templates/email/zh-CN/reset-password.tmpl new file mode 100644 index 0000000..e8f9a44 --- /dev/null +++ b/templates/email/zh-CN/reset-password.tmpl @@ -0,0 +1,218 @@ + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ +
+
+

+

+ +
+
尊敬的 @{{ .User.Name }}
+
+
我们收到了您重置密码的请求,你可以点击下方按钮设置一个新密码。
+
+
这个链接会在 24 小时后过期。
+
+
如果您没有请求重置密码,你可以安全的忽略这封邮件。
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + + + + +
+ + + + +
+ 重置密码 +
+
+
如果上方的按钮不起作用,你也可以使用下方的链接。
{{ .Link }}
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+
此致
索尔幸茨
Solar Network 团队
+
+
+ +
+
+ +
+ + + \ No newline at end of file