From 5371b1d1c6a56a6550ed42dd7adc4d695bf7297d Mon Sep 17 00:00:00 2001 From: yoge Date: Wed, 25 Mar 2026 18:33:17 +0800 Subject: [PATCH] feat: add dual-engine email service with aliyun smtp and resend routing Route Chinese domains (edu.cn, qq.com, 163.com, etc.) via Aliyun SMTP and international addresses via Resend API. Co-Authored-By: Claude Sonnet 4.6 --- config/config.go | 19 +++++ config/config_dev.yaml | 11 +++ config/config_prod.yaml | 11 +++ main.go | 2 + pkg/email/email.go | 154 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 197 insertions(+) create mode 100644 pkg/email/email.go diff --git a/config/config.go b/config/config.go index 25201cc..399b643 100644 --- a/config/config.go +++ b/config/config.go @@ -16,6 +16,25 @@ type Config struct { Mathpix MathpixConfig `mapstructure:"mathpix"` BaiduOCR BaiduOCRConfig `mapstructure:"baidu_ocr"` Google GoogleOAuthConfig `mapstructure:"google"` + Email EmailConfig `mapstructure:"email"` +} + +type EmailConfig struct { + FromName string `mapstructure:"from_name"` + FromAddr string `mapstructure:"from_addr"` + AliyunSMTP AliyunSMTPConfig `mapstructure:"aliyun_smtp"` + Resend ResendEmailConfig `mapstructure:"resend"` +} + +type AliyunSMTPConfig struct { + Host string `mapstructure:"host"` + Port int `mapstructure:"port"` + Username string `mapstructure:"username"` + Password string `mapstructure:"password"` +} + +type ResendEmailConfig struct { + APIKey string `mapstructure:"api_key"` } type BaiduOCRConfig struct { diff --git a/config/config_dev.yaml b/config/config_dev.yaml index e757707..b50d704 100644 --- a/config/config_dev.yaml +++ b/config/config_dev.yaml @@ -56,3 +56,14 @@ google: client_secret: "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" redirect_uri: "https://app.cloud.texpixel.com:10443/auth/google/callback" proxy: "http://localhost:7890" + +email: + from_name: "TexPixel Support" + from_addr: "support@texpixel.com" + aliyun_smtp: + host: "smtp.qiye.aliyun.com" + port: 465 + username: "support@texpixel.com" + password: "8bPw2W9LlgHSTTfk" + resend: + api_key: "re_xxxxxxxxxxxx" diff --git a/config/config_prod.yaml b/config/config_prod.yaml index 7e51480..38d6b2e 100644 --- a/config/config_prod.yaml +++ b/config/config_prod.yaml @@ -56,3 +56,14 @@ google: client_secret: "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" redirect_uri: "https://texpixel.com/auth/google/callback" proxy: "http://100.115.184.74:7890" + +email: + from_name: "TexPixel Support" + from_addr: "support@texpixel.com" + aliyun_smtp: + host: "smtp.qiye.aliyun.com" + port: 465 + username: "support@texpixel.com" + password: "8bPw2W9LlgHSTTfk" + resend: + api_key: "re_xxxxxxxxxxxx" diff --git a/main.go b/main.go index bdc9ae8..391cc68 100644 --- a/main.go +++ b/main.go @@ -18,6 +18,7 @@ import ( "gitea.com/texpixel/document_ai/pkg/cors" "gitea.com/texpixel/document_ai/pkg/log" "gitea.com/texpixel/document_ai/pkg/middleware" + "gitea.com/texpixel/document_ai/pkg/email" "gitea.com/texpixel/document_ai/pkg/sms" "github.com/gin-gonic/gin" ) @@ -44,6 +45,7 @@ func main() { dao.InitDB(config.GlobalConfig.Database) cache.InitRedisClient(config.GlobalConfig.Redis) sms.InitSmsClient() + email.InitEmailClient() // 设置gin模式 gin.SetMode(config.GlobalConfig.Server.Mode) diff --git a/pkg/email/email.go b/pkg/email/email.go new file mode 100644 index 0000000..919e69f --- /dev/null +++ b/pkg/email/email.go @@ -0,0 +1,154 @@ +package email + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "fmt" + "net/http" + "net/smtp" + "regexp" + "sync" + + "gitea.com/texpixel/document_ai/config" + "gitea.com/texpixel/document_ai/pkg/log" +) + +var ( + once sync.Once + client *Client +) + +// chineseDomainRe matches email domains that should be routed via Aliyun SMTP. +var chineseDomainRe = regexp.MustCompile(`(?i)(\.edu\.cn|qq\.com|163\.com|126\.com|sina\.com|sohu\.com)$`) + +type Client struct { + cfg config.EmailConfig +} + +func InitEmailClient() *Client { + once.Do(func() { + client = &Client{cfg: config.GlobalConfig.Email} + }) + return client +} + +// Send routes the email to the appropriate provider based on the recipient domain. +func Send(ctx context.Context, to, subject, body string) error { + return client.Send(ctx, to, subject, body) +} + +func (c *Client) Send(ctx context.Context, to, subject, body string) error { + atIdx := len(to) - 1 + for i, ch := range to { + if ch == '@' { + atIdx = i + } + } + domain := to[atIdx:] + if chineseDomainRe.MatchString(domain) { + return c.sendViaAliyunSMTP(ctx, to, subject, body) + } + return c.sendViaResend(ctx, to, subject, body) +} + +func (c *Client) sendViaAliyunSMTP(ctx context.Context, to, subject, body string) error { + cfg := c.cfg.AliyunSMTP + addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port) + + tlsConfig := &tls.Config{ServerName: cfg.Host} + conn, err := tls.Dial("tcp", addr, tlsConfig) + if err != nil { + log.Error(ctx, "func", "sendViaAliyunSMTP", "msg", "tls dial failed", "error", err) + return err + } + + smtpClient, err := smtp.NewClient(conn, cfg.Host) + if err != nil { + log.Error(ctx, "func", "sendViaAliyunSMTP", "msg", "smtp new client failed", "error", err) + return err + } + defer smtpClient.Close() + + auth := smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host) + if err = smtpClient.Auth(auth); err != nil { + log.Error(ctx, "func", "sendViaAliyunSMTP", "msg", "smtp auth failed", "error", err) + return err + } + + from := c.cfg.FromAddr + if err = smtpClient.Mail(from); err != nil { + return err + } + if err = smtpClient.Rcpt(to); err != nil { + return err + } + + wc, err := smtpClient.Data() + if err != nil { + return err + } + defer wc.Close() + + if _, err = wc.Write([]byte(buildMessage(c.cfg.FromName, from, to, subject, body))); err != nil { + return err + } + + log.Info(ctx, "func", "sendViaAliyunSMTP", "msg", "email sent via aliyun smtp", "to", to) + return nil +} + +type resendRequest struct { + From string `json:"from"` + To []string `json:"to"` + Subject string `json:"subject"` + Html string `json:"html"` +} + +func (c *Client) sendViaResend(ctx context.Context, to, subject, body string) error { + payload := resendRequest{ + From: fmt.Sprintf("%s <%s>", c.cfg.FromName, c.cfg.FromAddr), + To: []string{to}, + Subject: subject, + Html: body, + } + jsonData, err := json.Marshal(payload) + if err != nil { + return err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.resend.com/emails", bytes.NewReader(jsonData)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+c.cfg.Resend.APIKey) + + resp, err := (&http.Client{}).Do(req) + if err != nil { + log.Error(ctx, "func", "sendViaResend", "msg", "http request failed", "error", err) + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + log.Error(ctx, "func", "sendViaResend", "msg", "resend api returned non-2xx", "status", resp.StatusCode, "to", to) + return fmt.Errorf("resend api returned status: %d", resp.StatusCode) + } + + log.Info(ctx, "func", "sendViaResend", "msg", "email sent via resend", "to", to) + return nil +} + +func buildMessage(fromName, fromAddr, to, subject, body string) string { + var buf bytes.Buffer + buf.WriteString(fmt.Sprintf("From: %s <%s>\r\n", fromName, fromAddr)) + buf.WriteString(fmt.Sprintf("To: %s\r\n", to)) + buf.WriteString(fmt.Sprintf("Subject: %s\r\n", subject)) + buf.WriteString("MIME-Version: 1.0\r\n") + buf.WriteString("Content-Type: text/html; charset=UTF-8\r\n") + buf.WriteString("\r\n") + buf.WriteString(body) + return buf.String() +}