package verification import ( "errors" "fmt" "mime" "net" "net/mail" "net/smtp" "strconv" "strings" smailsmtp "git.pcux.cn/xuans/Smail/utils/smtp" ) type SecurityMode string const ( SecurityStartTLS SecurityMode = "starttls" SecurityImplicitTLS SecurityMode = "tls" SecurityPlain SecurityMode = "plain" ) var ( ErrInvalidSMTPConfig = errors.New("invalid SMTP configuration") ErrUnsupportedSMTP = errors.New("unsupported SMTP security mode") ErrSMTPRelayUnavailable = errors.New("SMTP relay is unavailable") ) type SMTPSettings struct { Host string Port int Username string Password string Security SecurityMode From string FromDomain string } type SMTPDelivery struct { settings SMTPSettings send func(SMTPSettings, string, []byte) error } func NewSMTPDelivery(settings SMTPSettings) (*SMTPDelivery, error) { if err := validateSMTPSettings(settings); err != nil { return nil, err } return &SMTPDelivery{settings: settings, send: sendSMTPMessage}, nil } func (d *SMTPDelivery) Delivery() Delivery { return func(challenge Challenge, code string) error { if d == nil || d.send == nil || challenge.Email == "" || code == "" { return ErrInvalidChallenge } message := buildCodeMessage(d.settings.From, challenge.Email, code, challenge.Purpose) defer clearBytes(message) return d.send(d.settings, challenge.Email, message) } } // NewLazySMTPDelivery 延迟读取 SMTP 中继配置,避免中继未配置时阻塞本地邮件服务启动。 func NewLazySMTPDelivery(load func() (SMTPSettings, error)) Delivery { return func(challenge Challenge, code string) error { if load == nil || challenge.Email == "" || code == "" { return ErrSMTPRelayUnavailable } settings, err := load() if err != nil { return fmt.Errorf("load SMTP relay settings: %w", err) } delivery, err := NewSMTPDelivery(settings) if err != nil { return fmt.Errorf("SMTP relay is unavailable: %w", err) } return delivery.Delivery()(challenge, code) } } func IssueWithSMTP(store *Store, request IssueRequest, settings SMTPSettings) (Challenge, error) { if store == nil { return Challenge{}, ErrInvalidChallenge } delivery, err := NewSMTPDelivery(settings) if err != nil { return Challenge{}, err } return store.Issue(request, delivery.Delivery()) } func validateSMTPSettings(settings SMTPSettings) error { host := strings.TrimSpace(settings.Host) if !isValidSMTPHost(host) { return ErrInvalidSMTPConfig } from := strings.TrimSpace(settings.From) parsedFrom, err := mail.ParseAddress(from) if err != nil || parsedFrom.Address != from || strings.ContainsAny(from, "\r\n") { return ErrInvalidSMTPConfig } if settings.Port <= 0 || settings.Port > 65535 { return ErrInvalidSMTPConfig } switch settings.Security { case SecurityStartTLS, SecurityImplicitTLS: case SecurityPlain: if !isLocalSMTPHost(host) { return ErrInvalidSMTPConfig } default: return ErrUnsupportedSMTP } if settings.Username == "" && settings.Password != "" { return ErrInvalidSMTPConfig } return nil } func isLocalSMTPHost(host string) bool { normalized := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".") if normalized == "localhost" { return true } ip := net.ParseIP(normalized) return ip != nil && ip.IsLoopback() } func isValidSMTPHost(host string) bool { if host == "" || strings.ContainsAny(host, "@/\\\r\n\t ") { return false } if net.ParseIP(host) != nil { return true } if strings.Contains(host, ":") { return false } if len(host) > 253 || strings.HasPrefix(host, ".") || strings.HasSuffix(host, ".") { return false } for _, label := range strings.Split(host, ".") { if label == "" || len(label) > 63 || strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") { return false } for _, r := range label { if (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') && (r < '0' || r > '9') && r != '-' { return false } } } return true } func sendSMTPMessage(settings SMTPSettings, recipient string, message []byte) error { addr := net.JoinHostPort(settings.Host, strconv.Itoa(settings.Port)) var auth smtp.Auth if settings.Username != "" { auth = smtp.PlainAuth("", settings.Username, settings.Password, settings.Host) } fromDomain := settings.FromDomain if fromDomain == "" { parts := strings.SplitN(settings.From, "@", 2) if len(parts) == 2 { fromDomain = parts[1] } } switch settings.Security { case SecurityStartTLS: return smailsmtp.SendMail(settings.Host, addr, auth, settings.From, fromDomain, []string{recipient}, message) case SecurityImplicitTLS: return smailsmtp.SendMailWithTls(settings.Host, addr, auth, settings.From, fromDomain, []string{recipient}, message) case SecurityPlain: return smailsmtp.SendMailUnsafe(settings.Host, addr, auth, settings.From, fromDomain, []string{recipient}, message) default: return ErrUnsupportedSMTP } } func buildCodeMessage(from, recipient string, code string, purpose Purpose) []byte { title := "Smail 安全验证码" purposeText := "完成本次安全验证" switch purpose { case PurposeLogin: purposeText = "完成登录二次验证" case PurposePasswordRecover: purposeText = "完成密码恢复验证" case PurposeExternalEmailBind: purposeText = "完成外部邮箱绑定验证" case PurposeExternalEmailChange: purposeText = "完成外部邮箱变更验证" } return []byte(fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n您好,您正在%s。验证码:%s\r\n验证码有效期为十分钟,请勿向他人泄露。\r\n", from, recipient, mime.QEncoding.Encode("UTF-8", title), purposeText, code)) } func clearBytes(data []byte) { for i := range data { data[i] = 0 } }