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), ) }