Smail/backend/res_init/verification_delivery.go
smail 5604b81ff8
Some checks are pending
Security Checks / security (push) Waiting to run
保存匿名恢复服务开发检查点
2026-09-16 11:51:19 +08:00

183 lines
6.4 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/url"
"strings"
"git.pcux.cn/xuans/Smail/config"
"git.pcux.cn/xuans/Smail/dto/parsemail"
"git.pcux.cn/xuans/Smail/services/verification"
appcontext "git.pcux.cn/xuans/Smail/utils/context"
"git.pcux.cn/xuans/Smail/utils/send"
)
func newVerificationDelivery() verification.Delivery {
return newVerificationDeliveryWithDependencies(
config.CurrentSMTPRuntimeSettings,
deliverVerificationWithSMTPRelay,
deliverVerificationWithProjectMail,
)
}
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 !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 {
settings, err := verificationSMTPSettings(runtimeSettings)
if err != nil {
return err
}
delivery, err := verification.NewSMTPDelivery(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: SMTP relay rejected delivery", verification.ErrMailDeliveryFailed)
}
return nil
}
func deliverVerificationWithProjectMail(challenge verification.Challenge, code string) error {
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: 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
}
// newPublicRecoveryLinkDelivery 固定使用项目自身邮件服务,不能走外部 SMTP 中继。
func newPublicRecoveryLinkDelivery() recovery.PublicLinkDelivery {
return func(email, token string) error {
domain := strings.TrimSpace(config.CurrentMailDomains().Primary)
if domain == "" || strings.TrimSpace(email) == "" || strings.TrimSpace(token) == "" {
return verification.ErrProjectMailUnavailable
}
link, err := publicRecoveryLink(domain, token)
if err != nil {
return err
}
messageID, err := parsemail.GenerateMsgID(domain)
if err != nil {
return fmt.Errorf("generate public recovery message id: %w", err)
}
sender := &parsemail.User{Name: "Smail 安全中心", EmailAddress: "no-reply@" + domain}
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(domain, token string) (string, error) {
parsed, err := url.Parse("https://" + strings.TrimSpace(domain))
if err != nil || parsed.Hostname() == "" || parsed.User != nil || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" {
return "", fmt.Errorf("invalid primary mail domain")
}
if port := config.CurrentHTTPConfig().HTTPSPort; port > 0 && port != 443 && parsed.Port() == "" {
parsed.Host = parsed.Hostname() + fmt.Sprintf(":%d", port)
}
parsed.Path = "/rest/"
parsed.Fragment = "/reset-password/" + token
return parsed.String(), nil
}