feat(server): 接入 QQ 邮箱 SMTP 发送登录验证码
Build and Publish Server / build (push) Successful in 2m6s

- 支持 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:
gouki
2026-09-27 23:24:46 +00:00
co-authored by Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent deb32f8fab
commit c161d42b06
8 changed files with 515 additions and 16 deletions
+121 -7
View File
@@ -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,