package email import ( "context" "crypto/tls" "fmt" "strings" "time" gomail "gopkg.in/gomail.v2" ) // Message 发信参数 type Message struct { To []string Cc []string Bcc []string ReplyTo []string Subject string TextBody string HTMLBody string Attachments []string } type Client interface { IsEnabled() bool Config() Config Send(ctx context.Context, msg Message) error } type clientImpl struct { cfg Config } func NewClient(cfg Config) Client { return &clientImpl{cfg: cfg} } func (c *clientImpl) IsEnabled() bool { return c.cfg.Enabled && strings.TrimSpace(c.cfg.Host) != "" && c.cfg.Port > 0 && strings.TrimSpace(c.cfg.FromAddress) != "" } func (c *clientImpl) Config() Config { return c.cfg } func (c *clientImpl) Send(ctx context.Context, msg Message) error { if !c.IsEnabled() { return fmt.Errorf("email client is disabled or misconfigured") } if len(msg.To) == 0 { return fmt.Errorf("email recipient is empty") } if strings.TrimSpace(msg.Subject) == "" { return fmt.Errorf("email subject is empty") } if strings.TrimSpace(msg.TextBody) == "" && strings.TrimSpace(msg.HTMLBody) == "" { return fmt.Errorf("email body is empty") } m := gomail.NewMessage() m.SetAddressHeader("From", c.cfg.FromAddress, c.cfg.FromName) m.SetHeader("To", msg.To...) if len(msg.Cc) > 0 { m.SetHeader("Cc", msg.Cc...) } if len(msg.Bcc) > 0 { m.SetHeader("Bcc", msg.Bcc...) } if len(msg.ReplyTo) > 0 { m.SetHeader("Reply-To", msg.ReplyTo...) } m.SetHeader("Subject", msg.Subject) if strings.TrimSpace(msg.TextBody) != "" && strings.TrimSpace(msg.HTMLBody) != "" { m.SetBody("text/plain", msg.TextBody) m.AddAlternative("text/html", msg.HTMLBody) } else if strings.TrimSpace(msg.HTMLBody) != "" { m.SetBody("text/html", msg.HTMLBody) } else { m.SetBody("text/plain", msg.TextBody) } for _, attachment := range msg.Attachments { if strings.TrimSpace(attachment) == "" { continue } m.Attach(attachment) } timeout := c.cfg.TimeoutSeconds if timeout <= 0 { timeout = 15 } dialer := gomail.NewDialer(c.cfg.Host, c.cfg.Port, c.cfg.Username, c.cfg.Password) if c.cfg.UseTLS { dialer.TLSConfig = &tls.Config{ ServerName: c.cfg.Host, InsecureSkipVerify: c.cfg.InsecureSkipVerify, } // 465 端口通常要求直接 TLS(Implicit TLS)。 if c.cfg.Port == 465 { dialer.SSL = true } } sendCtx := ctx cancel := func() {} if timeout > 0 { sendCtx, cancel = context.WithTimeout(ctx, time.Duration(timeout)*time.Second) } defer cancel() done := make(chan error, 1) go func() { done <- dialer.DialAndSend(m) }() select { case <-sendCtx.Done(): return fmt.Errorf("send email canceled: %w", sendCtx.Err()) case err := <-done: if err != nil { return fmt.Errorf("send email failed: %w", err) } return nil } }