Smail/backend/res_init/verification_delivery.go
smail 0d720fb878
Some checks are pending
Security Checks / security (push) Waiting to run
优化验证码邮件模板
参考用户提供的卡片式 HTML 邮件样式,增加纯文本与 HTML 双格式投递,补充模板安全转义和有效期测试。
2026-10-06 11:30:45 +08:00

215 lines
7.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package res_init
import (
"context"
"fmt"
"net"
"net/url"
"strconv"
"strings"
"git.pcux.cn/xuans/Smail/config"
"git.pcux.cn/xuans/Smail/dto/parsemail"
"git.pcux.cn/xuans/Smail/services/recovery"
"git.pcux.cn/xuans/Smail/services/verification"
appcontext "git.pcux.cn/xuans/Smail/utils/context"
"git.pcux.cn/xuans/Smail/utils/send"
smailsmtp "git.pcux.cn/xuans/Smail/utils/smtp"
)
func newVerificationDelivery() verification.Delivery {
return func(challenge verification.Challenge, code string) error {
ctx, cancel := context.WithTimeout(context.Background(), verification.MailDeliveryTimeout)
defer cancel()
return newVerificationDeliveryWithDependencies(
config.CurrentSMTPRuntimeSettings,
func(settings config.SMTPRuntimeSettings, challenge verification.Challenge, code string) error {
return deliverVerificationWithSMTPRelayContext(ctx, settings, challenge, code)
},
func(challenge verification.Challenge, code string) error {
return deliverVerificationWithProjectMailContext(ctx, challenge, code)
},
)(challenge, code)
}
}
func newVerificationDeliveryWithDependencies(
loadSettings func() (config.SMTPRuntimeSettings, error),
deliverRelay func(config.SMTPRuntimeSettings, verification.Challenge, string) error,
deliverProject func(verification.Challenge, string) error,
) verification.Delivery {
return func(challenge verification.Challenge, code string) error {
if strings.TrimSpace(challenge.Email) == "" || strings.TrimSpace(code) == "" {
return verification.ErrInvalidChallenge
}
if loadSettings == nil || deliverRelay == nil || deliverProject == nil {
return verification.ErrProjectMailUnavailable
}
runtimeSettings, err := loadSettings()
if err != nil {
return fmt.Errorf("load verification mail settings: %w", err)
}
if smtpRelayRequested(runtimeSettings) {
relayErr := deliverRelay(runtimeSettings, challenge, code)
if relayErr == nil {
return nil
}
if smailsmtp.IsDeliveryUncertain(relayErr) {
return relayErr
}
if !smtpRelayComplete(runtimeSettings) {
return relayErr
}
if projectErr := deliverProject(challenge, code); projectErr != nil {
return fmt.Errorf("SMTP 中继投递失败且项目自身投递失败: %w", projectErr)
}
return nil
}
return deliverProject(challenge, code)
}
}
func smtpRelayComplete(settings config.SMTPRuntimeSettings) bool {
relay := settings.Relay
if strings.TrimSpace(relay.RelayHost) == "" || relay.RelayPort < 1 || relay.RelayPort > 65535 ||
strings.TrimSpace(relay.Security) == "" || strings.TrimSpace(relay.From) == "" {
return false
}
return (strings.TrimSpace(relay.Username) == "") == (strings.TrimSpace(settings.Password) == "")
}
func smtpRelayRequested(settings config.SMTPRuntimeSettings) bool {
return settings.Relay.RelayActive()
}
func verificationSMTPSettings(settings config.SMTPRuntimeSettings) (verification.SMTPSettings, error) {
security, err := smtpSecurityMode(settings.Relay.Security)
if err != nil {
return verification.SMTPSettings{}, err
}
return verification.SMTPSettings{
Host: settings.Relay.RelayHost,
Port: settings.Relay.RelayPort,
Username: settings.Relay.Username,
Password: settings.Password,
Security: security,
From: settings.Relay.From,
FromDomain: settings.Relay.FromDomain,
}, nil
}
func deliverVerificationWithSMTPRelay(runtimeSettings config.SMTPRuntimeSettings, challenge verification.Challenge, code string) error {
ctx, cancel := context.WithTimeout(context.Background(), verification.MailDeliveryTimeout)
defer cancel()
return deliverVerificationWithSMTPRelayContext(ctx, runtimeSettings, challenge, code)
}
func deliverVerificationWithSMTPRelayContext(ctx context.Context, runtimeSettings config.SMTPRuntimeSettings, challenge verification.Challenge, code string) error {
settings, err := verificationSMTPSettings(runtimeSettings)
if err != nil {
return err
}
delivery, err := verification.NewSMTPDeliveryContext(ctx, settings)
if err != nil {
return fmt.Errorf("SMTP relay is unavailable: %w", err)
}
if err = delivery.Delivery()(challenge, code); err != nil {
return fmt.Errorf("%w: %w", verification.ErrMailDeliveryFailed, err)
}
return nil
}
func deliverVerificationWithProjectMail(challenge verification.Challenge, code string) error {
ctx, cancel := context.WithTimeout(context.Background(), verification.MailDeliveryTimeout)
defer cancel()
return deliverVerificationWithProjectMailContext(ctx, challenge, code)
}
func deliverVerificationWithProjectMailContext(deliveryContext context.Context, challenge verification.Challenge, code string) error {
if err := deliveryContext.Err(); err != nil {
return err
}
domain := strings.TrimSpace(config.CurrentMailDomains().Primary)
if domain == "" {
return fmt.Errorf("%w: primary mail domain is not configured", verification.ErrProjectMailUnavailable)
}
messageID, err := parsemail.GenerateMsgID(domain)
if err != nil {
return fmt.Errorf("%w: generate message id", verification.ErrProjectMailUnavailable)
}
subject, text := verification.CodeMessage(challenge.Purpose, code)
sender := &parsemail.User{
Name: "Smail 安全中心",
EmailAddress: "no-reply@" + domain,
}
message := &parsemail.Email{
From: sender,
Sender: sender,
To: []*parsemail.User{
{EmailAddress: challenge.Email},
},
Subject: subject,
Text: []byte(text),
HTML: []byte(verification.CodeHTML(challenge, code)),
MsgID: messageID,
}
ctx := &appcontext.Context{
Context: deliveryContext,
Values: make(map[string]any),
}
if deliveryErr, _ := send.SendTriggered(ctx, message, "系统安全验证邮件"); deliveryErr != nil {
return fmt.Errorf("%w: project mail delivery failed", verification.ErrMailDeliveryFailed)
}
return nil
}
// newPublicRecoveryLinkDelivery 固定使用项目自身邮件服务,不能走外部 SMTP 中继。
func newPublicRecoveryLinkDelivery() recovery.PublicLinkDelivery {
return func(email, token string) error {
mailDomain := strings.TrimSpace(config.CurrentMailDomains().Primary)
webDomain := strings.TrimSpace(config.CurrentWebDomain())
if mailDomain == "" || webDomain == "" || strings.TrimSpace(email) == "" || strings.TrimSpace(token) == "" {
return verification.ErrProjectMailUnavailable
}
link, err := publicRecoveryLink(webDomain, token)
if err != nil {
return err
}
messageID, err := parsemail.GenerateMsgID(mailDomain)
if err != nil {
return fmt.Errorf("generate public recovery message id: %w", err)
}
sender := &parsemail.User{Name: "Smail 安全中心", EmailAddress: "no-reply@" + mailDomain}
message := &parsemail.Email{
From: sender, Sender: sender, To: []*parsemail.User{{EmailAddress: email}},
Subject: "Smail 密码恢复链接",
Text: []byte("您好,您正在恢复 Smail 密码。请在五分钟内打开以下一次性链接完成重置:\r\n" + link + "\r\n\r\n如果并非本人操作,请忽略此邮件。"),
MsgID: messageID,
}
ctx := &appcontext.Context{Context: context.Background(), Values: make(map[string]any)}
if deliveryErr, _ := send.SendTriggered(ctx, message, "系统密码恢复链接"); deliveryErr != nil {
return fmt.Errorf("%w: project mail delivery failed", verification.ErrMailDeliveryFailed)
}
return nil
}
}
func publicRecoveryLink(webDomain, token string) (string, error) {
parsed, err := url.Parse("https://" + strings.TrimSpace(webDomain))
if err != nil || parsed.Hostname() == "" || parsed.User != nil || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" {
return "", fmt.Errorf("invalid web domain")
}
port := config.ResolvePublicHTTPSPort(config.CurrentHTTPConfig())
if port < 1 || port > 65535 {
return "", fmt.Errorf("invalid public HTTPS port")
}
if port != 443 && parsed.Port() == "" {
parsed.Host = net.JoinHostPort(parsed.Hostname(), strconv.Itoa(port))
}
parsed.Path = "/rest/"
parsed.Fragment = "/reset-password/" + token
return parsed.String(), nil
}