package server import ( "context" "errors" "net/http" "time" "galaxy/backend/internal/mail" "galaxy/backend/internal/server/handlers" "galaxy/backend/internal/server/httperr" "github.com/gin-gonic/gin" "github.com/google/uuid" "go.uber.org/zap" ) // AdminMailHandlers groups the admin-side mail-outbox handlers under // `/api/v1/admin/mail/*`. The wiring connects real bodies backed by // `*mail.Service`; tests that supply a nil service fall back to the // Stage-3 placeholder body so the contract test continues to validate // the OpenAPI envelope without booting Postgres. type AdminMailHandlers struct { svc *mail.Service logger *zap.Logger } // NewAdminMailHandlers constructs the handler set. svc may be nil — in // that case every handler returns 501 not_implemented, matching the // pre-Stage-5.6 placeholder. logger may also be nil; zap.NewNop is // used in that case. func NewAdminMailHandlers(svc *mail.Service, logger *zap.Logger) *AdminMailHandlers { if logger == nil { logger = zap.NewNop() } return &AdminMailHandlers{svc: svc, logger: logger.Named("http.admin.mail")} } // ListDeliveries handles GET /api/v1/admin/mail/deliveries. func (h *AdminMailHandlers) ListDeliveries() gin.HandlerFunc { if h.svc == nil { return handlers.NotImplemented("adminMailListDeliveries") } return func(c *gin.Context) { page := parsePositiveQueryInt(c.Query("page"), 1) pageSize := parsePositiveQueryInt(c.Query("page_size"), 50) ctx := c.Request.Context() out, err := h.svc.AdminListDeliveries(ctx, page, pageSize) if err != nil { respondMailError(c, h.logger, "admin mail list deliveries", ctx, err) return } c.JSON(http.StatusOK, mailDeliveryListToWire(out)) } } // GetDelivery handles GET /api/v1/admin/mail/deliveries/{delivery_id}. func (h *AdminMailHandlers) GetDelivery() gin.HandlerFunc { if h.svc == nil { return handlers.NotImplemented("adminMailGetDelivery") } return func(c *gin.Context) { deliveryID, ok := parseDeliveryIDParam(c) if !ok { return } ctx := c.Request.Context() d, err := h.svc.AdminGetDelivery(ctx, deliveryID) if err != nil { respondMailError(c, h.logger, "admin mail get delivery", ctx, err) return } c.JSON(http.StatusOK, mailDeliveryToWire(d)) } } // ListDeliveryAttempts handles GET /api/v1/admin/mail/deliveries/{delivery_id}/attempts. func (h *AdminMailHandlers) ListDeliveryAttempts() gin.HandlerFunc { if h.svc == nil { return handlers.NotImplemented("adminMailListDeliveryAttempts") } return func(c *gin.Context) { deliveryID, ok := parseDeliveryIDParam(c) if !ok { return } ctx := c.Request.Context() attempts, err := h.svc.AdminListAttempts(ctx, deliveryID) if err != nil { respondMailError(c, h.logger, "admin mail list attempts", ctx, err) return } c.JSON(http.StatusOK, mailAttemptListToWire(attempts)) } } // ResendDelivery handles POST /api/v1/admin/mail/deliveries/{delivery_id}/resend. func (h *AdminMailHandlers) ResendDelivery() gin.HandlerFunc { if h.svc == nil { return handlers.NotImplemented("adminMailResendDelivery") } return func(c *gin.Context) { deliveryID, ok := parseDeliveryIDParam(c) if !ok { return } ctx := c.Request.Context() d, err := h.svc.AdminResendDelivery(ctx, deliveryID) if err != nil { respondMailError(c, h.logger, "admin mail resend delivery", ctx, err) return } c.JSON(http.StatusAccepted, mailDeliveryToWire(d)) } } // ListDeadLetters handles GET /api/v1/admin/mail/dead-letters. func (h *AdminMailHandlers) ListDeadLetters() gin.HandlerFunc { if h.svc == nil { return handlers.NotImplemented("adminMailListDeadLetters") } return func(c *gin.Context) { page := parsePositiveQueryInt(c.Query("page"), 1) pageSize := parsePositiveQueryInt(c.Query("page_size"), 50) ctx := c.Request.Context() out, err := h.svc.AdminListDeadLetters(ctx, page, pageSize) if err != nil { respondMailError(c, h.logger, "admin mail list dead-letters", ctx, err) return } c.JSON(http.StatusOK, mailDeadLetterListToWire(out)) } } // parseDeliveryIDParam reads `delivery_id` from the path. On invalid // input it writes the standard 400 envelope and returns // (uuid.Nil, false). func parseDeliveryIDParam(c *gin.Context) (uuid.UUID, bool) { parsed, err := uuid.Parse(c.Param("delivery_id")) if err != nil { httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "delivery_id must be a valid UUID") return uuid.Nil, false } return parsed, true } // respondMailError translates the mail-domain sentinels to HTTP. Any // other error is logged and surfaced as 500 internal_error so the // handler always emits the documented envelope. func respondMailError(c *gin.Context, logger *zap.Logger, op string, ctx context.Context, err error) { switch { case errors.Is(err, mail.ErrDeliveryNotFound): httperr.Abort(c, http.StatusNotFound, httperr.CodeNotFound, "mail delivery not found") case errors.Is(err, mail.ErrResendOnSent): httperr.Abort(c, http.StatusConflict, httperr.CodeConflict, "delivery already sent") case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded): httperr.Abort(c, http.StatusServiceUnavailable, httperr.CodeServiceUnavailable, "request cancelled") default: logger.Error(op+" failed", zap.Error(err)) httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "internal server error") } _ = ctx } // Wire DTOs mirror the schemas in `backend/openapi.yaml`. type mailDeliveryWire struct { DeliveryID string `json:"delivery_id"` TemplateID string `json:"template_id"` IdempotencyKey string `json:"idempotency_key,omitempty"` Status string `json:"status"` Attempts int32 `json:"attempts"` NextAttemptAt *string `json:"next_attempt_at,omitempty"` CreatedAt string `json:"created_at"` } type mailDeliveryListWire struct { Items []mailDeliveryWire `json:"items"` Page int `json:"page"` PageSize int `json:"page_size"` Total int64 `json:"total"` } type mailAttemptWire struct { AttemptID string `json:"attempt_id"` DeliveryID string `json:"delivery_id"` AttemptNo int32 `json:"attempt_no"` StartedAt string `json:"started_at"` FinishedAt *string `json:"finished_at,omitempty"` Outcome string `json:"outcome,omitempty"` Error string `json:"error,omitempty"` } type mailAttemptListWire struct { Items []mailAttemptWire `json:"items"` } type mailDeadLetterWire struct { DeadLetterID string `json:"dead_letter_id"` DeliveryID string `json:"delivery_id"` ArchivedAt string `json:"archived_at"` Reason string `json:"reason,omitempty"` } type mailDeadLetterListWire struct { Items []mailDeadLetterWire `json:"items"` Page int `json:"page"` PageSize int `json:"page_size"` Total int64 `json:"total"` } func mailDeliveryToWire(d mail.Delivery) mailDeliveryWire { out := mailDeliveryWire{ DeliveryID: d.DeliveryID.String(), TemplateID: d.TemplateID, IdempotencyKey: d.IdempotencyKey, Status: d.Status, Attempts: d.Attempts, CreatedAt: d.CreatedAt.UTC().Format(time.RFC3339Nano), } if d.NextAttemptAt != nil { s := d.NextAttemptAt.UTC().Format(time.RFC3339Nano) out.NextAttemptAt = &s } return out } func mailDeliveryListToWire(p mail.AdminListDeliveriesPage) mailDeliveryListWire { items := make([]mailDeliveryWire, 0, len(p.Items)) for _, d := range p.Items { items = append(items, mailDeliveryToWire(d)) } return mailDeliveryListWire{ Items: items, Page: p.Page, PageSize: p.PageSize, Total: p.Total, } } func mailAttemptToWire(a mail.Attempt) mailAttemptWire { out := mailAttemptWire{ AttemptID: a.AttemptID.String(), DeliveryID: a.DeliveryID.String(), AttemptNo: a.AttemptNo, StartedAt: a.StartedAt.UTC().Format(time.RFC3339Nano), Outcome: a.Outcome, Error: a.Error, } if a.FinishedAt != nil { s := a.FinishedAt.UTC().Format(time.RFC3339Nano) out.FinishedAt = &s } return out } func mailAttemptListToWire(items []mail.Attempt) mailAttemptListWire { out := mailAttemptListWire{Items: make([]mailAttemptWire, 0, len(items))} for _, a := range items { out.Items = append(out.Items, mailAttemptToWire(a)) } return out } func mailDeadLetterToWire(dl mail.DeadLetter) mailDeadLetterWire { return mailDeadLetterWire{ DeadLetterID: dl.DeadLetterID.String(), DeliveryID: dl.DeliveryID.String(), ArchivedAt: dl.ArchivedAt.UTC().Format(time.RFC3339Nano), Reason: dl.Reason, } } func mailDeadLetterListToWire(p mail.AdminListDeadLettersPage) mailDeadLetterListWire { items := make([]mailDeadLetterWire, 0, len(p.Items)) for _, dl := range p.Items { items = append(items, mailDeadLetterToWire(dl)) } return mailDeadLetterListWire{ Items: items, Page: p.Page, PageSize: p.PageSize, Total: p.Total, } }