feat: backend service

This commit is contained in:
Ilia Denisov
2026-05-06 10:14:55 +03:00
committed by GitHub
parent 3e2622757e
commit f446c6a2ac
1486 changed files with 49720 additions and 266401 deletions
+131
View File
@@ -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
}