From c161d42b0613d007a3607c03ea1cdeb02a0345b7 Mon Sep 17 00:00:00 2001 From: gouki Date: Sun, 27 Sep 2026 23:24:46 +0000 Subject: [PATCH] =?UTF-8?q?feat(server):=20=E6=8E=A5=E5=85=A5=20QQ=20?= =?UTF-8?q?=E9=82=AE=E7=AE=B1=20SMTP=20=E5=8F=91=E9=80=81=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E9=AA=8C=E8=AF=81=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 支持 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> --- REMINDER.md | 4 + server/.env.example | 10 + server/README.md | 18 +- server/internal/config/config.go | 24 ++ server/internal/handler/email_auth_test.go | 333 +++++++++++++++++++++ server/internal/service/email_auth.go | 128 +++++++- web-commercial/CHANGELOG.md | 5 + web-commercial/Fetures.md | 9 +- 8 files changed, 515 insertions(+), 16 deletions(-) create mode 100644 server/internal/handler/email_auth_test.go diff --git a/REMINDER.md b/REMINDER.md index e427c59..f7fcd14 100644 --- a/REMINDER.md +++ b/REMINDER.md @@ -14,3 +14,7 @@ - 商业前端的本地 `file:` 依赖可能缓存旧版 `@lunar/core`;生产构建必须先构建 core,并通过显式别名引用最新 `dist`。 - 神煞规则不能统一套用“年干与日干”两个锚点;必须按每个神煞的标准查法指定锚点,并使用 `pp.test` 中的真实命例做回归测试。 - 大运切换必须同时切换该步大运对应的十年流年,不能让当前大运标题与首步大运流年混用。 + +## 2026-09-25:邮件服务方案必须先确认代码可用 + +- 推荐邮箱 SMTP 服务前,先核对后端是否已实现对应协议;不能把“服务商支持 SMTP”说成“当前应用已经可以发信”。必须先完成实现和端到端验证,再指导用户配置凭证。 diff --git a/server/.env.example b/server/.env.example index 004586d..edf0c7b 100644 --- a/server/.env.example +++ b/server/.env.example @@ -9,6 +9,16 @@ DB_NAME=lunar.db JWT_SECRET=replace-with-a-long-random-secret ADMIN_PASSWORD=replace-with-a-strong-admin-password +SMTP_HOST=smtp.qq.com +SMTP_PORT=465 +SMTP_USERNAME=your-qq-email@qq.com +SMTP_PASSWORD=your-qq-smtp-authorization-code +# 可填写已在 QQ 域名邮箱绑定到该账号的域名地址,例如 z@kepo.net;域名需配置 SPF +SMTP_FROM_EMAIL=z@kepo.net +SMTP_TLS_MODE=implicit +SMTP_TLS_CA_FILE= + +# 可选备用通道;未配置 SMTP 时才会使用 POSTMARK_SERVER_TOKEN= POSTMARK_FROM_EMAIL=login@your-domain.com POSTMARK_API_BASE_URL=https://api.postmarkapp.com diff --git a/server/README.md b/server/README.md index 3557def..27f6f0a 100644 --- a/server/README.md +++ b/server/README.md @@ -5,7 +5,7 @@ ## 功能模块 - 微信小程序登录与生辰档案。 -- Postmark 邮箱验证码登录。 +- QQ 邮箱 SMTP 验证码登录,支持 465 隐式 TLS 或 587 STARTTLS;Postmark 可选备用。 - Stripe `4.99 USD` 报告支付。 - Binance Pay `5 USDT` 报告支付。 - 签名 Webhook、幂等事件、退款权益撤销。 @@ -35,7 +35,7 @@ go run ./cmd go test ./... ``` -测试环境文件为本地 `server/.env.test`,不提交任何真实密钥。 +本地测试配置使用 `server/.env.test`。如果文件包含 SMTP 凭证,必须保持 Git 忽略并限制文件权限,绝不提交到仓库。真实发信测试需显式运行 `RUN_QQ_SMTP_LIVE_TEST=1 SMTP_TEST_TO=收件地址 go test ./internal/handler -run '^TestRequestEmailLoginCodeLiveQQSMTP$' -count=1`;该命令会实际发送邮件。 ## 商业支付 API @@ -88,14 +88,22 @@ mysql < migrations/002_commerce.sql ## 环境变量 -完整模板见 `.env.example`。生产环境至少需要: +完整模板见 `.env.example`。邮箱登录配置 QQ SMTP: + +- `SMTP_HOST=smtp.qq.com` +- `SMTP_PORT=465`(或 587) +- `SMTP_USERNAME`:完整 QQ 邮箱地址 +- `SMTP_PASSWORD`:QQ 邮箱生成的 SMTP 授权码,不是邮箱登录密码 +- `SMTP_FROM_EMAIL`:发件地址。生产使用 `z@kepo.net`(`kepo.net` 已通过 QQ 域名邮箱绑定,MX 为 `mxdomain.qq.com`);需在 DNS 添加 SPF 记录 `v=spf1 include:spf.mail.qq.com ~all`,否则 Gmail 等可能拒收或判为垃圾邮件 +- `SMTP_TLS_MODE=implicit`(465)或 `starttls`(587) +- `SMTP_TLS_CA_FILE`:可选的自定义 CA 文件路径 + +QQ 邮箱需先在账户设置中开启 SMTP 服务。SMTP 配置有效时优先使用 SMTP;未配置时才尝试 Postmark。生产环境还需要: - `JWT_SECRET` - `ADMIN_PASSWORD` - `PUBLIC_BASE_URL` - `WEB_APP_URL` -- `POSTMARK_SERVER_TOKEN` -- `POSTMARK_FROM_EMAIL` - `STRIPE_SECRET_KEY` - `STRIPE_WEBHOOK_SECRET` - `BINANCE_PAY_API_KEY` diff --git a/server/internal/config/config.go b/server/internal/config/config.go index 9c89a4e..89f6c6e 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -14,6 +14,7 @@ type Config struct { Stripe StripeConfig BinancePay BinancePayConfig Postmark PostmarkConfig + SMTP SMTPConfig Commerce CommerceConfig Admin AdminConfig Wiki WikiConfig @@ -71,6 +72,20 @@ type PostmarkConfig struct { APIBaseURL string } +type SMTPConfig struct { + Host string + Port string + Username string + Password string + FromEmail string + TLSMode string + TLSCAFile string +} + +func (c SMTPConfig) IsConfigured() bool { + return c.Host != "" || c.Username != "" || c.Password != "" || c.FromEmail != "" +} + type CommerceConfig struct { PublicBaseURL string WebAppURL string @@ -136,6 +151,15 @@ func Load() *Config { FromEmail: getEnv("POSTMARK_FROM_EMAIL", ""), APIBaseURL: getEnv("POSTMARK_API_BASE_URL", "https://api.postmarkapp.com"), }, + SMTP: SMTPConfig{ + Host: getEnv("SMTP_HOST", ""), + Port: getEnv("SMTP_PORT", "465"), + Username: getEnv("SMTP_USERNAME", ""), + Password: getEnv("SMTP_PASSWORD", ""), + FromEmail: getEnv("SMTP_FROM_EMAIL", ""), + TLSMode: getEnv("SMTP_TLS_MODE", "implicit"), + TLSCAFile: getEnv("SMTP_TLS_CA_FILE", ""), + }, Commerce: CommerceConfig{ PublicBaseURL: getEnv("PUBLIC_BASE_URL", "http://localhost:8080"), WebAppURL: getEnv("WEB_APP_URL", "http://localhost:4260"), diff --git a/server/internal/handler/email_auth_test.go b/server/internal/handler/email_auth_test.go new file mode 100644 index 0000000..9d9753e --- /dev/null +++ b/server/internal/handler/email_auth_test.go @@ -0,0 +1,333 @@ +package handler + +import ( + "bufio" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "fmt" + "math/big" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/gouki/lunar-server/internal/config" + "github.com/joho/godotenv" +) + +type smtpTestResult struct { + message string + authenticated bool + err error +} + +type smtpTestServer struct { + port string + caFile string + tlsMode string + results chan smtpTestResult +} + +func startSMTPTestServer(t *testing.T, tlsMode string, rejectAuthentication bool) smtpTestServer { + t.Helper() + certificate, caFile := createSMTPTestCertificate(t) + listener, err := net.Listen("tcp", "localhost:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { listener.Close() }) + results := make(chan smtpTestResult, 1) + port := fmt.Sprint(listener.Addr().(*net.TCPAddr).Port) + go serveSMTPTestConnection(listener, certificate, tlsMode, rejectAuthentication, results) + return smtpTestServer{port: port, caFile: caFile, tlsMode: tlsMode, results: results} +} + +func createSMTPTestCertificate(t *testing.T) (tls.Certificate, string) { + t.Helper() + now := time.Now() + rootKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + rootTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(1), + NotBefore: now.Add(-time.Minute), + NotAfter: now.Add(time.Hour), + IsCA: true, + BasicConstraintsValid: true, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature, + } + rootDER, err := x509.CreateCertificate(rand.Reader, rootTemplate, rootTemplate, &rootKey.PublicKey, rootKey) + if err != nil { + t.Fatal(err) + } + rootCertificate, err := x509.ParseCertificate(rootDER) + if err != nil { + t.Fatal(err) + } + serverKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + serverTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(2), + NotBefore: now.Add(-time.Minute), + NotAfter: now.Add(time.Hour), + DNSNames: []string{"localhost"}, + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + serverDER, err := x509.CreateCertificate(rand.Reader, serverTemplate, rootCertificate, &serverKey.PublicKey, rootKey) + if err != nil { + t.Fatal(err) + } + privateKeyDER, err := x509.MarshalECPrivateKey(serverKey) + if err != nil { + t.Fatal(err) + } + certificate, err := tls.X509KeyPair( + pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: serverDER}), + pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: privateKeyDER}), + ) + if err != nil { + t.Fatal(err) + } + caFile := filepath.Join(t.TempDir(), "smtp-test-ca.pem") + if err := os.WriteFile(caFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: rootDER}), 0600); err != nil { + t.Fatal(err) + } + return certificate, caFile +} + +func serveSMTPTestConnection(listener net.Listener, certificate tls.Certificate, tlsMode string, rejectAuthentication bool, results chan<- smtpTestResult) { + connection, err := listener.Accept() + if err != nil { + results <- smtpTestResult{err: err} + return + } + defer connection.Close() + serverTLSConfig := &tls.Config{Certificates: []tls.Certificate{certificate}, MinVersion: tls.VersionTLS12} + tlsActive := false + if tlsMode == "implicit" { + tlsConnection := tls.Server(connection, serverTLSConfig) + if err := tlsConnection.Handshake(); err != nil { + results <- smtpTestResult{err: err} + return + } + connection = tlsConnection + tlsActive = true + } + reader := bufio.NewReader(connection) + writer := bufio.NewWriter(connection) + writeResponse := func(response string) error { + if _, err := writer.WriteString(response + "\r\n"); err != nil { + return err + } + return writer.Flush() + } + if err := writeResponse("220 localhost ESMTP"); err != nil { + results <- smtpTestResult{err: err} + return + } + var message strings.Builder + for { + command, err := reader.ReadString('\n') + if err != nil { + results <- smtpTestResult{err: err} + return + } + trimmed := strings.TrimRight(command, "\r\n") + switch { + case strings.HasPrefix(trimmed, "EHLO "): + capabilities := "250-localhost\r\n250-AUTH PLAIN\r\n250 OK\r\n" + if tlsMode == "starttls" && !tlsActive { + capabilities = "250-localhost\r\n250-STARTTLS\r\n250 OK\r\n" + } + if _, err := writer.WriteString(capabilities); err != nil { + results <- smtpTestResult{err: err} + return + } + if err := writer.Flush(); err != nil { + results <- smtpTestResult{err: err} + return + } + case trimmed == "STARTTLS": + if err := writeResponse("220 2.0.0 ready to start TLS"); err != nil { + results <- smtpTestResult{err: err} + return + } + tlsConnection := tls.Server(connection, serverTLSConfig) + if err := tlsConnection.Handshake(); err != nil { + results <- smtpTestResult{err: err} + return + } + connection = tlsConnection + reader = bufio.NewReader(connection) + writer = bufio.NewWriter(connection) + tlsActive = true + case strings.HasPrefix(trimmed, "AUTH PLAIN"): + encoded := strings.TrimSpace(strings.TrimPrefix(trimmed, "AUTH PLAIN")) + if encoded == "" { + if err := writeResponse("334 "); err != nil { + results <- smtpTestResult{err: err} + return + } + encoded, err = reader.ReadString('\n') + if err != nil { + results <- smtpTestResult{err: err} + return + } + encoded = strings.TrimSpace(encoded) + } + credentials, decodeErr := base64.StdEncoding.DecodeString(encoded) + parts := strings.Split(string(credentials), "\x00") + authenticated := decodeErr == nil && len(parts) == 3 && parts[1] == "smtp-user@example.test" && parts[2] == "smtp-test-password" + if rejectAuthentication || !authenticated { + writeResponse("535 5.7.8 Authentication credentials invalid") + results <- smtpTestResult{authenticated: false} + return + } + if err := writeResponse("235 2.7.0 Authentication successful"); err != nil { + results <- smtpTestResult{err: err} + return + } + case strings.HasPrefix(trimmed, "MAIL FROM:") || strings.HasPrefix(trimmed, "RCPT TO:"): + if err := writeResponse("250 2.1.0 accepted"); err != nil { + results <- smtpTestResult{err: err} + return + } + case trimmed == "DATA": + if err := writeResponse("354 end with ."); err != nil { + results <- smtpTestResult{err: err} + return + } + for { + line, err := reader.ReadString('\n') + if err != nil { + results <- smtpTestResult{err: err} + return + } + if line == ".\r\n" { + break + } + message.WriteString(strings.TrimPrefix(line, ".")) + } + if err := writeResponse("250 2.0.0 queued"); err != nil { + results <- smtpTestResult{err: err} + return + } + results <- smtpTestResult{message: message.String(), authenticated: true} + case trimmed == "QUIT": + writeResponse("221 2.0.0 bye") + return + default: + if err := writeResponse("250 OK"); err != nil { + results <- smtpTestResult{err: err} + return + } + } + } +} + +func configureEmailCodeAPI(t *testing.T, server smtpTestServer) *gin.Engine { + t.Helper() + t.Setenv("SERVER_ENV", "test") + t.Setenv("DB_TYPE", "sqlite") + t.Setenv("DB_NAME", fmt.Sprintf("file:email-auth-smtp-http-test-%s?mode=memory&cache=shared", server.port)) + t.Setenv("JWT_SECRET", "email-auth-test-secret") + t.Setenv("SMTP_HOST", "localhost") + t.Setenv("SMTP_PORT", server.port) + t.Setenv("SMTP_USERNAME", "smtp-user@example.test") + t.Setenv("SMTP_PASSWORD", "smtp-test-password") + t.Setenv("SMTP_FROM_EMAIL", "login@example.test") + t.Setenv("SMTP_TLS_MODE", server.tlsMode) + t.Setenv("SMTP_TLS_CA_FILE", server.caFile) + t.Setenv("POSTMARK_SERVER_TOKEN", "") + t.Setenv("POSTMARK_FROM_EMAIL", "") + if err := config.InitDB(config.Load()); err != nil { + t.Fatal(err) + } + router := gin.New() + router.POST("/api/auth/email/request", RequestEmailLoginCode) + return router +} + +func TestRequestEmailLoginCodeUsesSMTPViaAPI(t *testing.T) { + gin.SetMode(gin.TestMode) + server := startSMTPTestServer(t, "implicit", false) + router := configureEmailCodeAPI(t, server) + request := httptest.NewRequest(http.MethodPost, "/api/auth/email/request", strings.NewReader(`{"email":"recipient@example.com"}`)) + request.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"code":0`) { + t.Fatalf("expected HTTP 200 code:0, got %d: %s", response.Code, response.Body.String()) + } + result := <-server.results + if result.err != nil { + t.Fatalf("SMTP test server failed: %v", result.err) + } + if !result.authenticated || !strings.Contains(result.message, "To: ") || !strings.Contains(result.message, "10 分钟内有效") { + t.Fatalf("unexpected SMTP message or authentication result: authenticated=%t", result.authenticated) + } +} + +func TestRequestEmailLoginCodeReturnsServiceUnavailableWhenSMTPAuthFails(t *testing.T) { + gin.SetMode(gin.TestMode) + server := startSMTPTestServer(t, "starttls", true) + router := configureEmailCodeAPI(t, server) + request := httptest.NewRequest(http.MethodPost, "/api/auth/email/request", strings.NewReader(`{"email":"recipient@example.com"}`)) + request.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusServiceUnavailable || strings.Contains(response.Body.String(), `"code":0`) { + t.Fatalf("expected HTTP 503 with nonzero code, got %d: %s", response.Code, response.Body.String()) + } + result := <-server.results + if result.err != nil { + t.Fatalf("SMTP test server failed: %v", result.err) + } + if result.authenticated { + t.Fatal("expected SMTP authentication to be rejected") + } +} + +func TestRequestEmailLoginCodeLiveQQSMTP(t *testing.T) { + if os.Getenv("RUN_QQ_SMTP_LIVE_TEST") != "1" { + t.Skip("set RUN_QQ_SMTP_LIVE_TEST=1 to send a real email through QQ SMTP") + } + recipient := os.Getenv("SMTP_TEST_TO") + if recipient == "" { + t.Fatal("SMTP_TEST_TO must be set to the test recipient") + } + testEnvironment, err := godotenv.Read("../../.env.test") + if err != nil { + t.Fatalf("load local SMTP test environment: %v", err) + } + for name, value := range testEnvironment { + t.Setenv(name, value) + } + if err := config.InitDB(config.Load()); err != nil { + t.Fatal(err) + } + router := gin.New() + router.POST("/api/auth/email/request", RequestEmailLoginCode) + requestBody := fmt.Sprintf(`{"email":%q}`, recipient) + request := httptest.NewRequest(http.MethodPost, "/api/auth/email/request", strings.NewReader(requestBody)) + request.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"code":0`) { + t.Fatalf("expected HTTP 200 code:0, got %d: %s", response.Code, response.Body.String()) + } +} diff --git a/server/internal/service/email_auth.go b/server/internal/service/email_auth.go index 30d5c1c..ffa0ec4 100644 --- a/server/internal/service/email_auth.go +++ b/server/internal/service/email_auth.go @@ -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, diff --git a/web-commercial/CHANGELOG.md b/web-commercial/CHANGELOG.md index d34a99f..24ca2b7 100644 --- a/web-commercial/CHANGELOG.md +++ b/web-commercial/CHANGELOG.md @@ -2,6 +2,11 @@ ## [未发布] +### 邮箱验证码 + +- 增加 QQ 邮箱 SMTP 登录验证码发送,支持隐式 TLS 与 STARTTLS;SMTP 凭证只从环境变量读取。 +- SMTP 配置可用时优先发送,未配置时保留 Postmark 备用通道。 + ### 排盘记录与免费层 - 每次排盘保存服务端历史记录,后台新增排盘记录页面。 diff --git a/web-commercial/Fetures.md b/web-commercial/Fetures.md index de349dc..5e5709f 100644 --- a/web-commercial/Fetures.md +++ b/web-commercial/Fetures.md @@ -12,7 +12,7 @@ flowchart TD B --> C[免费查看四柱、五行、大运、流年、刑冲合害、神煞] C --> D{是否解锁深度解读} D -- 否 --> E[继续查看免费排盘] - D -- 是 --> F[Postmark 邮箱验证码登录] + D -- 是 --> F[QQ 邮箱 SMTP 验证码登录] F --> G{选择支付渠道} G --> H[Stripe: 4.99 USD] G --> I[Binance Pay: 5 USDT] @@ -77,7 +77,7 @@ ZM-ABCD-EFGH-JKLM ### 邮箱登录 -- `POST /api/auth/email/request`:通过 Postmark 发送 6 位验证码。 +- `POST /api/auth/email/request`:优先通过 QQ SMTP(TLS)发送 6 位验证码;未配置 SMTP 时可使用 Postmark 备用通道。 - `POST /api/auth/email/verify`:验证验证码并签发用户 JWT。 - 验证码 10 分钟有效、最多尝试 5 次、60 秒内不能重复发送。 @@ -133,9 +133,10 @@ Stripe 校验 `Stripe-Signature` 与 5 分钟时间窗口;Binance Pay 使用 前端复制 `.env.example` 为 `.env.local`。只有明确设置 `VITE_PAYMENT_MODE=demo` 才进入演示支付;未设置时默认使用生产支付,避免线上误开模拟解锁。 -后端配置见 `server/.env.example`,必须准备: +后端配置见 `server/.env.example`,邮箱登录可配置: -- Postmark Server Token 与已验证发件域名。 +- QQ SMTP 主机、端口、完整邮箱地址与 SMTP 授权码;授权码通过环境变量或本地忽略的密钥文件注入,不提交到 Git。 +- Postmark Server Token 与已验证发件域名可作为备用通道。 - Stripe Secret Key 与 Webhook Secret。 - Binance Pay 商户 API Key、Secret Key 与 Webhook 公钥。 - 公开 HTTPS API 域名和 Web 地址。