package verification import ( "errors" "fmt" "mime" "net" "net/mail" "net/smtp" "strconv" "strings" "git.pcux.cn/xuans/Smail/services/logstore" 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") ErrProjectMailUnavailable = errors.New("project mail delivery is unavailable") ErrMailDeliveryFailed = errors.New("verification mail delivery failed") ) 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) err := d.send(d.settings, challenge.Email, message) result := "accepted" details := logstore.MailDetails{Trigger: "系统安全验证邮件", Stage: "SMTP 中继投递", AcceptedCount: 1} if err != nil { result = "failure" details.AcceptedCount = 0 details.Outcome = logstore.MailFailureReason(err) } // 只记录发送元数据,不保存外部邮箱、验证码、邮件内容及中继凭据。 logstore.MailResult(0, "outbound", d.settings.From, 1, len(message), result, details) return err } } // 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, text := CodeMessage(purpose, code) 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\r\n", from, recipient, mime.QEncoding.Encode("UTF-8", title), text)) } // CodeMessage 返回验证码邮件的统一标题和正文,供中继与项目自身投递复用。 func CodeMessage(purpose Purpose, code string) (string, string) { title := "Smail 安全验证码" purposeText := "完成本次安全验证" switch purpose { case PurposeLogin: purposeText = "完成登录二次验证" case PurposePasswordRecover: purposeText = "完成密码恢复验证" case PurposeExternalEmailBind: purposeText = "完成外部邮箱绑定验证" case PurposeExternalEmailChange: purposeText = "完成外部邮箱变更验证" } text := fmt.Sprintf("您好,您正在%s。验证码:%s\r\n验证码有效期为十分钟,请勿向他人泄露。", purposeText, code) return title, text } func clearBytes(data []byte) { for i := range data { data[i] = 0 } }