package mail import ( "context" "errors" "fmt" "galaxy/backend/internal/config" gomail "github.com/wneessen/go-mail" "go.uber.org/zap" ) // SMTPClient is the abstraction surface over `wneessen/go-mail` so // tests can stub the wire layer without dialling. Production wires // realSMTPClient. type SMTPClient interface { DialAndSendWithContext(ctx context.Context, msg *gomail.Msg) error } // realSMTPClient adapts *gomail.Client to SMTPClient. The variadic // nature of DialAndSendWithContext is hidden because the worker only // ever sends one message per call. type realSMTPClient struct { inner *gomail.Client } func (c *realSMTPClient) DialAndSendWithContext(ctx context.Context, msg *gomail.Msg) error { return c.inner.DialAndSendWithContext(ctx, msg) } // smtpSender implements SMTPSender on top of an SMTPClient. The // `from` address is captured at construction time from // `BACKEND_SMTP_FROM`. type smtpSender struct { client SMTPClient from string logger *zap.Logger } // NewSMTPSender constructs the production sender bound to the SMTP // relay configured in cfg. The TLS-mode mapping is: // // - "none" → plain TCP, no TLS; // - "starttls" → STARTTLS required (TLSMandatory); // - "tls" → implicit TLS at the configured port (WithSSL). // // PLAIN authentication is enabled when both Username and Password are // non-empty. func NewSMTPSender(cfg config.SMTPConfig, logger *zap.Logger) (SMTPSender, error) { if logger == nil { logger = zap.NewNop() } logger = logger.Named("mail.smtp") opts := []gomail.Option{gomail.WithPort(cfg.Port)} switch cfg.TLSMode { case "none": opts = append(opts, gomail.WithTLSPolicy(gomail.NoTLS)) case "starttls": opts = append(opts, gomail.WithTLSPolicy(gomail.TLSMandatory)) case "tls": opts = append(opts, gomail.WithSSL()) default: return nil, fmt.Errorf("mail: unsupported SMTP TLS mode %q", cfg.TLSMode) } if cfg.Username != "" && cfg.Password != "" { opts = append(opts, gomail.WithSMTPAuth(gomail.SMTPAuthPlain), gomail.WithUsername(cfg.Username), gomail.WithPassword(cfg.Password), ) } cli, err := gomail.NewClient(cfg.Host, opts...) if err != nil { return nil, fmt.Errorf("mail: build smtp client: %w", err) } return &smtpSender{ client: &realSMTPClient{inner: cli}, from: cfg.From, logger: logger, }, nil } // Send renders the OutboundMessage as a *gomail.Msg and dispatches it // through the SMTP client. Address validation is intentional: a // malformed To here means the producer slipped past // normaliseRecipient, which is a programming error and gets wrapped // as Permanent so the worker dead-letters immediately. func (s *smtpSender) Send(ctx context.Context, msg OutboundMessage) error { if len(msg.To) == 0 { return &SendError{Err: errors.New("mail: outbound message has no recipients"), Permanent: true} } m := gomail.NewMsg() if err := m.From(s.from); err != nil { return &SendError{Err: fmt.Errorf("set FROM: %w", err), Permanent: true} } for _, addr := range msg.To { if err := m.AddTo(addr); err != nil { return &SendError{Err: fmt.Errorf("add TO %q: %w", addr, err), Permanent: true} } } m.Subject(msg.Subject) contentType := gomail.ContentType(msg.ContentType) if msg.ContentType == "" { contentType = gomail.TypeTextPlain } m.SetBodyString(contentType, string(msg.Body)) if err := s.client.DialAndSendWithContext(ctx, m); err != nil { permanent := classifySMTPError(err) return &SendError{Err: err, Permanent: permanent} } return nil } // classifySMTPError decides whether err is permanent. A *gomail.SendError // reports its permanence through IsTemp; everything else (dial // failures, context errors, generic I/O) is treated as transient so the // worker retries until MaxAttempts. func classifySMTPError(err error) bool { if err == nil { return false } var sendErr *gomail.SendError if errors.As(err, &sendErr) && sendErr != nil { return !sendErr.IsTemp() } return false }