Smail/backend/res_init/verification_delivery.go
smail 41593ee762
Some checks are pending
Security Checks / security (push) Waiting to run
登录支持用户选择邮箱或手机动态码并消除同步发信等待
2026-09-18 20:57:46 +08:00

218 lines
8.0 KiB
Go
Raw 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 {
relay := settings.Relay
return strings.TrimSpace(relay.RelayHost) != "" ||
strings.TrimSpace(relay.Username) != "" ||
strings.TrimSpace(settings.Password) != "" ||
strings.TrimSpace(relay.From) != ""
}
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),
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
}