- 支持 465 隐式 TLS 与 587 STARTTLS,SMTP 配置优先、Postmark 备用 - 发件人支持 QQ 域名邮箱地址(z@kepo.net),凭证仅走环境变量 - HTTP 测试覆盖发送成功与认证失败;真实发信测试需显式开启 RUN_QQ_SMTP_LIVE_TEST Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent
deb32f8fab
commit
c161d42b06
@@ -4,14 +4,21 @@ import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"mime"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"net/smtp"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -123,14 +130,121 @@ func (s *EmailAuthService) hashCode(email, code string) string {
|
||||
}
|
||||
|
||||
func (s *EmailAuthService) sendCode(email, code string) error {
|
||||
if s.config.Postmark.ServerToken == "" || s.config.Postmark.FromEmail == "" {
|
||||
// 开发模式:Postmark 未配置时,验证码打印到服务端控制台,方便本地联调
|
||||
if s.config.Server.Env != "production" {
|
||||
log.Printf("[DEV-EMAIL] 邮箱登录验证码: email=%s code=%s(生产环境将自动通过 Postmark 发送)", email, code)
|
||||
return nil
|
||||
}
|
||||
return errors.New("email service is not configured")
|
||||
if s.config.SMTP.IsConfigured() {
|
||||
return s.sendCodeWithSMTP(email, code)
|
||||
}
|
||||
if s.config.Postmark.ServerToken != "" && s.config.Postmark.FromEmail != "" {
|
||||
return s.sendCodeWithPostmark(email, code)
|
||||
}
|
||||
if s.config.Server.Env != "production" {
|
||||
log.Printf("[DEV-EMAIL] 邮箱登录验证码: email=%s code=%s(生产环境需配置 SMTP 或 Postmark)", email, code)
|
||||
return nil
|
||||
}
|
||||
return errors.New("email service is not configured")
|
||||
}
|
||||
|
||||
func (s *EmailAuthService) sendCodeWithSMTP(email, code string) error {
|
||||
settings := s.config.SMTP
|
||||
port, err := strconv.Atoi(settings.Port)
|
||||
if settings.Host == "" || settings.Username == "" || settings.Password == "" || settings.FromEmail == "" || err != nil || port < 1 || port > 65535 {
|
||||
return errors.New("SMTP configuration is incomplete or invalid")
|
||||
}
|
||||
if settings.TLSMode != "implicit" && settings.TLSMode != "starttls" {
|
||||
return errors.New("SMTP TLS mode must be implicit or starttls")
|
||||
}
|
||||
from, err := mail.ParseAddress(settings.FromEmail)
|
||||
if err != nil || from.Address != settings.FromEmail {
|
||||
return errors.New("SMTP sender address is invalid")
|
||||
}
|
||||
tlsConfig, err := s.smtpTLSConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dialer := &net.Dialer{Timeout: 10 * time.Second}
|
||||
address := net.JoinHostPort(settings.Host, settings.Port)
|
||||
var connection net.Conn
|
||||
if settings.TLSMode == "implicit" {
|
||||
connection, err = tls.DialWithDialer(dialer, "tcp", address, tlsConfig)
|
||||
} else {
|
||||
connection, err = dialer.Dial("tcp", address)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("SMTP connection failed: %w", err)
|
||||
}
|
||||
defer connection.Close()
|
||||
if err := connection.SetDeadline(time.Now().Add(10 * time.Second)); err != nil {
|
||||
return err
|
||||
}
|
||||
client, err := smtp.NewClient(connection, settings.Host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("SMTP session setup failed: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
if settings.TLSMode == "starttls" {
|
||||
if ok, _ := client.Extension("STARTTLS"); !ok {
|
||||
return errors.New("SMTP server does not support STARTTLS")
|
||||
}
|
||||
if err := client.StartTLS(tlsConfig); err != nil {
|
||||
return fmt.Errorf("SMTP TLS negotiation failed: %w", err)
|
||||
}
|
||||
}
|
||||
if err := client.Auth(smtp.PlainAuth("", settings.Username, settings.Password, settings.Host)); err != nil {
|
||||
return fmt.Errorf("SMTP authentication failed: %w", err)
|
||||
}
|
||||
if err := client.Mail(from.Address); err != nil {
|
||||
return fmt.Errorf("SMTP sender rejected: %w", err)
|
||||
}
|
||||
if err := client.Rcpt(email); err != nil {
|
||||
return fmt.Errorf("SMTP recipient rejected: %w", err)
|
||||
}
|
||||
writer, err := client.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("SMTP message rejected: %w", err)
|
||||
}
|
||||
fromHeader := (&mail.Address{Name: "知命网", Address: from.Address}).String()
|
||||
toHeader := (&mail.Address{Address: email}).String()
|
||||
message := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nDate: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\n你的登录验证码是 %s,10 分钟内有效。若非本人操作,请忽略此邮件。\r\n",
|
||||
fromHeader,
|
||||
toHeader,
|
||||
mime.QEncoding.Encode("UTF-8", "知命登录验证码"),
|
||||
time.Now().Format(time.RFC1123Z),
|
||||
code,
|
||||
)
|
||||
if _, err := io.WriteString(writer, message); err != nil {
|
||||
closeErr := writer.Close()
|
||||
if closeErr != nil {
|
||||
return errors.Join(err, closeErr)
|
||||
}
|
||||
return err
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
return fmt.Errorf("SMTP server did not accept the message: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *EmailAuthService) smtpTLSConfig() (*tls.Config, error) {
|
||||
settings := s.config.SMTP
|
||||
config := &tls.Config{MinVersion: tls.VersionTLS12, ServerName: settings.Host}
|
||||
if settings.TLSCAFile == "" {
|
||||
return config, nil
|
||||
}
|
||||
certificatePEM, err := os.ReadFile(settings.TLSCAFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read SMTP CA file: %w", err)
|
||||
}
|
||||
roots, err := x509.SystemCertPool()
|
||||
if err != nil || roots == nil {
|
||||
roots = x509.NewCertPool()
|
||||
}
|
||||
if !roots.AppendCertsFromPEM(certificatePEM) {
|
||||
return nil, errors.New("SMTP CA file contains no valid certificates")
|
||||
}
|
||||
config.RootCAs = roots
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func (s *EmailAuthService) sendCodeWithPostmark(email, code string) error {
|
||||
payload := map[string]string{
|
||||
"From": s.config.Postmark.FromEmail,
|
||||
"To": email,
|
||||
|
||||
Reference in New Issue
Block a user