Files
galaxy-game/backend/internal/mail/deps.go
T
2026-05-06 10:14:55 +03:00

122 lines
3.6 KiB
Go

package mail
import (
"context"
"errors"
"time"
"galaxy/backend/internal/config"
"github.com/google/uuid"
"go.uber.org/zap"
)
// SMTPSender is the wire-level boundary the worker uses to deliver an
// outbox row through SMTP. Implementations are expected to be
// concurrency-safe and to honour ctx cancellation: the worker passes a
// per-row context bounded by the configured operation timeout.
//
// `Send` is the single point where transient-vs-permanent classification
// happens; the returned error carries IsPermanent to let the worker
// decide between schedule-a-retry and dead-letter.
type SMTPSender interface {
Send(ctx context.Context, msg OutboundMessage) error
}
// OutboundMessage is the rendered, recipient-addressed payload handed
// to SMTPSender. From is taken from BACKEND_SMTP_FROM at construction
// time, so producers and the worker never set it directly.
type OutboundMessage struct {
To []string
Subject string
ContentType string
Body []byte
}
// SendError augments a regular error with a permanence classification.
// Permanent errors (RFC 5321 5xx, malformed addresses, oversize body)
// dead-letter the row immediately on the next attempt; transient ones
// (4xx, network) trigger the backoff schedule.
type SendError struct {
Err error
Permanent bool
}
// Error returns the underlying error string.
func (e *SendError) Error() string {
if e == nil || e.Err == nil {
return ""
}
return e.Err.Error()
}
// Unwrap exposes the underlying error for errors.Is / errors.As.
func (e *SendError) Unwrap() error {
if e == nil {
return nil
}
return e.Err
}
// IsPermanent reports whether err is a *SendError marked Permanent.
// Non-SendError values are treated as transient by default — the
// worker will retry until MaxAttempts.
func IsPermanent(err error) bool {
if err == nil {
return false
}
var se *SendError
if errors.As(err, &se) && se != nil {
return se.Permanent
}
return false
}
// AdminNotifier is the outbound surface mail uses to flag a dead-letter
// to operators. The canonical notification wiring lives in `cmd/backend/main.go` and publisher; until
// then NewNoopAdminNotifier ships a logger-only stub matching the
// pattern used elsewhere in `backend/internal/*`.
type AdminNotifier interface {
OnDeadLetter(ctx context.Context, deliveryID uuid.UUID, templateID, reason string)
}
// Deps aggregates every collaborator the Service depends on.
//
// Store and SMTP must be non-nil. Admin defaults to a no-op publisher
// when omitted; Now defaults to time.Now; Logger defaults to
// zap.NewNop. Config carries the worker interval and max-attempts
// derived from `BACKEND_MAIL_*`.
type Deps struct {
Store *Store
SMTP SMTPSender
Admin AdminNotifier
Config config.MailConfig
// Now overrides time.Now for deterministic tests. A nil Now defaults
// to time.Now in NewService.
Now func() time.Time
// Logger is named under "mail" by NewService. Nil falls back to
// zap.NewNop.
Logger *zap.Logger
}
// NewNoopAdminNotifier returns an AdminNotifier that logs every
// dead-letter event at warn level and never blocks. The canonical implementation replaces // it with the real notification publisher.
func NewNoopAdminNotifier(logger *zap.Logger) AdminNotifier {
if logger == nil {
logger = zap.NewNop()
}
return &noopAdminNotifier{logger: logger.Named("notify.noop")}
}
type noopAdminNotifier struct {
logger *zap.Logger
}
func (n *noopAdminNotifier) OnDeadLetter(_ context.Context, deliveryID uuid.UUID, templateID, reason string) {
n.logger.Warn("mail dead-letter (noop publisher)",
zap.String("delivery_id", deliveryID.String()),
zap.String("template_id", templateID),
zap.String("reason", reason),
)
}