132 lines
3.9 KiB
Go
132 lines
3.9 KiB
Go
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
|
|
}
|