122 lines
3.6 KiB
Go
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),
|
|
)
|
|
}
|