Push email & notification localization

This commit is contained in:
2025-02-01 17:53:21 +08:00
parent 820d96f6b0
commit d7ee87433f
22 changed files with 1906 additions and 108 deletions

View File

@ -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 {

View File

@ -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)

View File

@ -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"),

View File

@ -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()
}

View File

@ -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")
}