feat: backend service
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user