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