Files
aiaa-notification-server/internal/adapter/email.go
T
ryan cecf8e72f5 fix(邮件发送): 适配 MailerSend SMTP 并迁移基础设施至 1Panel
Motivation:
将通知服务部署到 1Panel 生产环境,集成 MailerSend 邮件服务,修复 SMTP 协议中 MAIL FROM 命令因携带显示名而导致的发送失败问题。

Changes:

* 修复 SMTP MAIL FROM 命令传入带显示名的完整地址导致发送失败的问题,新增 extractEmailAddr 提取裸地址
* 更新 SMTP 配置为 MailerSend 真实凭证,From 地址适配其域名验证要求
* 移除 docker-compose 中内置的 MySQL/Redis 服务,改为依赖外部 1Panel 基础设施
* 更新环境变量为生产环境实际凭据
2026-07-31 17:28:35 +08:00

136 lines
3.4 KiB
Go

package adapter
import (
"crypto/tls"
"encoding/json"
"fmt"
"net"
"net/smtp"
"strings"
"aiaa-notification-service/internal/config"
)
type emailConfig struct {
To []string `json:"to"`
}
type EmailSender struct {
cfg config.SMTPConfig
}
func NewEmailSender(cfg config.SMTPConfig) *EmailSender {
return &EmailSender{cfg: cfg}
}
func (s *EmailSender) Type() string { return "email" }
func (s *EmailSender) Send(title, content string, config json.RawMessage) error {
var ecfg emailConfig
if err := json.Unmarshal(config, &ecfg); err != nil {
return fmt.Errorf("parse email config: %w", err)
}
if len(ecfg.To) == 0 {
return fmt.Errorf("email: 'to' list is empty")
}
msg := buildEmail(s.cfg.From, ecfg.To, title, content)
addr := fmt.Sprintf("%s:%d", s.cfg.Host, s.cfg.Port)
if s.cfg.Port == 465 {
return s.sendTLS(addr, ecfg.To, msg)
}
return s.sendSTARTTLS(addr, ecfg.To, msg)
}
func (s *EmailSender) sendTLS(addr string, to []string, msg []byte) error {
tlsCfg := &tls.Config{ServerName: s.cfg.Host}
conn, err := tls.Dial("tcp", addr, tlsCfg)
if err != nil {
return fmt.Errorf("tls dial: %w", err)
}
defer conn.Close()
client, err := smtp.NewClient(conn, s.cfg.Host)
if err != nil {
return fmt.Errorf("smtp client: %w", err)
}
defer client.Quit()
return s.authAndSend(client, to, msg)
}
func (s *EmailSender) sendSTARTTLS(addr string, to []string, msg []byte) error {
conn, err := net.Dial("tcp", addr)
if err != nil {
return fmt.Errorf("dial: %w", err)
}
defer conn.Close()
client, err := smtp.NewClient(conn, s.cfg.Host)
if err != nil {
return fmt.Errorf("smtp client: %w", err)
}
defer client.Quit()
if ok, _ := client.Extension("STARTTLS"); ok {
tlsCfg := &tls.Config{ServerName: s.cfg.Host}
if err := client.StartTLS(tlsCfg); err != nil {
return fmt.Errorf("starttls: %w", err)
}
}
return s.authAndSend(client, to, msg)
}
func (s *EmailSender) authAndSend(client *smtp.Client, to []string, msg []byte) error {
if s.cfg.User != "" {
auth := smtp.PlainAuth("", s.cfg.User, s.cfg.Password, s.cfg.Host)
if err := client.Auth(auth); err != nil {
return fmt.Errorf("auth: %w", err)
}
}
// MAIL FROM must be a bare address; display names like "Name <a@b.c>" are invalid here.
fromAddr := extractEmailAddr(s.cfg.From)
if err := client.Mail(fromAddr); err != nil {
return fmt.Errorf("mail: %w", err)
}
for _, recipient := range to {
if err := client.Rcpt(recipient); err != nil {
return fmt.Errorf("rcpt %s: %w", recipient, err)
}
}
w, err := client.Data()
if err != nil {
return fmt.Errorf("data: %w", err)
}
_, err = w.Write(msg)
if err != nil {
return fmt.Errorf("write: %w", err)
}
return w.Close()
}
func buildEmail(from string, to []string, subject, body string) []byte {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("From: %s\r\n", from))
sb.WriteString(fmt.Sprintf("To: %s\r\n", strings.Join(to, ", ")))
sb.WriteString(fmt.Sprintf("Subject: %s\r\n", subject))
sb.WriteString("MIME-Version: 1.0\r\n")
sb.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
sb.WriteString("\r\n")
sb.WriteString(body)
return []byte(sb.String())
}
// extractEmailAddr returns the bare address from "Name <addr@host>" or the original string.
func extractEmailAddr(from string) string {
start := strings.LastIndex(from, "<")
end := strings.LastIndex(from, ">")
if start >= 0 && end > start {
return strings.TrimSpace(from[start+1 : end])
}
return strings.TrimSpace(from)
}