package server import ( "context" "errors" "fmt" "net/http" "strings" "galaxy/backend/internal/adminconsole" "galaxy/backend/internal/diplomail" "galaxy/backend/internal/mail" "galaxy/backend/internal/notification" "galaxy/backend/internal/server/clientip" "galaxy/backend/internal/server/middleware/basicauth" "github.com/gin-gonic/gin" "github.com/google/uuid" "go.uber.org/zap" ) // MailAdmin is the subset of the mail service the console uses. type MailAdmin interface { AdminListDeliveries(ctx context.Context, page, pageSize int) (mail.AdminListDeliveriesPage, error) AdminGetDelivery(ctx context.Context, deliveryID uuid.UUID) (mail.Delivery, error) AdminListAttempts(ctx context.Context, deliveryID uuid.UUID) ([]mail.Attempt, error) AdminResendDelivery(ctx context.Context, deliveryID uuid.UUID) (mail.Delivery, error) AdminListDeadLetters(ctx context.Context, page, pageSize int) (mail.AdminListDeadLettersPage, error) } // NotificationAdmin is the subset of the notification service the console uses. type NotificationAdmin interface { AdminListNotifications(ctx context.Context, page, pageSize int) (notification.AdminListNotificationsPage, error) AdminListDeadLetters(ctx context.Context, page, pageSize int) (notification.AdminListDeadLettersPage, error) AdminListMalformed(ctx context.Context, page, pageSize int) (notification.AdminListMalformedPage, error) } // DiplomailAdmin is the subset of the diplomail service the console uses. type DiplomailAdmin interface { SendAdminMultiGameBroadcast(ctx context.Context, in diplomail.SendMultiGameBroadcastInput) ([]diplomail.Message, int, error) } const consoleSnapshotPageSize = 50 // MailPage renders GET /_gm/mail — paginated deliveries plus a dead-letter snapshot. func (h *AdminConsoleHandlers) MailPage() gin.HandlerFunc { return func(c *gin.Context) { if h.mail == nil { h.renderMessage(c, http.StatusServiceUnavailable, "mail", "Mail", "Mail administration is not available.", "bad", "/_gm/") return } page := parsePositiveQueryInt(c.Query("page"), 1) pageSize := parsePositiveQueryInt(c.Query("page_size"), 50) ctx := c.Request.Context() deliveries, err := h.mail.AdminListDeliveries(ctx, page, pageSize) if err != nil { h.logger.Error("admin console: list deliveries", zap.Error(err)) h.renderMessage(c, http.StatusInternalServerError, "mail", "Mail", "Failed to load deliveries.", "bad", "/_gm/") return } dead, err := h.mail.AdminListDeadLetters(ctx, 1, consoleSnapshotPageSize) if err != nil { h.logger.Error("admin console: list mail dead-letters", zap.Error(err)) h.renderMessage(c, http.StatusInternalServerError, "mail", "Mail", "Failed to load dead-letters.", "bad", "/_gm/") return } h.render(c, http.StatusOK, "mail", "mail", "Mail", toMailData(deliveries, dead)) } } // MailDeliveryDetail renders GET /_gm/mail/deliveries/:delivery_id. func (h *AdminConsoleHandlers) MailDeliveryDetail() gin.HandlerFunc { return func(c *gin.Context) { if h.mail == nil { h.renderMessage(c, http.StatusServiceUnavailable, "mail", "Mail", "Mail administration is not available.", "bad", "/_gm/") return } deliveryID, ok := parseConsoleDeliveryID(c, h) if !ok { return } ctx := c.Request.Context() delivery, err := h.mail.AdminGetDelivery(ctx, deliveryID) if err != nil { if errors.Is(err, mail.ErrDeliveryNotFound) { h.renderMessage(c, http.StatusNotFound, "mail", "Delivery not found", "No such delivery.", "bad", "/_gm/mail") return } h.logger.Error("admin console: get delivery", zap.Error(err)) h.renderMessage(c, http.StatusInternalServerError, "mail", "Mail", "Failed to load the delivery.", "bad", "/_gm/mail") return } attempts, err := h.mail.AdminListAttempts(ctx, deliveryID) if err != nil { h.logger.Error("admin console: list attempts", zap.Error(err)) h.renderMessage(c, http.StatusInternalServerError, "mail", "Mail", "Failed to load attempts.", "bad", "/_gm/mail") return } h.render(c, http.StatusOK, "mail_delivery", "mail", "Delivery", toMailDeliveryDetail(delivery, attempts)) } } // MailResend handles POST /_gm/mail/deliveries/:delivery_id/resend. func (h *AdminConsoleHandlers) MailResend() gin.HandlerFunc { return func(c *gin.Context) { if h.mail == nil { h.renderMessage(c, http.StatusServiceUnavailable, "mail", "Mail", "Mail administration is not available.", "bad", "/_gm/") return } deliveryID, ok := parseConsoleDeliveryID(c, h) if !ok { return } back := "/_gm/mail/deliveries/" + deliveryID.String() if _, err := h.mail.AdminResendDelivery(c.Request.Context(), deliveryID); err != nil { h.logger.Error("admin console: resend delivery", zap.Error(err)) h.renderMessage(c, http.StatusInternalServerError, "mail", "Resend failed", "Failed to resend the delivery (it may already be sent).", "bad", back) return } c.Redirect(http.StatusSeeOther, back) } } // NotificationsPage renders GET /_gm/notifications — notifications, dead-letters, // and malformed intents on one overview page. func (h *AdminConsoleHandlers) NotificationsPage() gin.HandlerFunc { return func(c *gin.Context) { if h.notifications == nil { h.renderMessage(c, http.StatusServiceUnavailable, "mail", "Notifications", "Notification administration is not available.", "bad", "/_gm/") return } ctx := c.Request.Context() notifications, err := h.notifications.AdminListNotifications(ctx, 1, consoleSnapshotPageSize) if err != nil { h.logger.Error("admin console: list notifications", zap.Error(err)) h.renderMessage(c, http.StatusInternalServerError, "mail", "Notifications", "Failed to load notifications.", "bad", "/_gm/") return } dead, err := h.notifications.AdminListDeadLetters(ctx, 1, consoleSnapshotPageSize) if err != nil { h.logger.Error("admin console: list notification dead-letters", zap.Error(err)) h.renderMessage(c, http.StatusInternalServerError, "mail", "Notifications", "Failed to load dead-letters.", "bad", "/_gm/") return } malformed, err := h.notifications.AdminListMalformed(ctx, 1, consoleSnapshotPageSize) if err != nil { h.logger.Error("admin console: list malformed intents", zap.Error(err)) h.renderMessage(c, http.StatusInternalServerError, "mail", "Notifications", "Failed to load malformed intents.", "bad", "/_gm/") return } h.render(c, http.StatusOK, "notifications", "mail", "Notifications", toNotificationsData(notifications, dead, malformed)) } } // BroadcastForm renders GET /_gm/broadcast. func (h *AdminConsoleHandlers) BroadcastForm() gin.HandlerFunc { return func(c *gin.Context) { if h.diplomail == nil { h.renderMessage(c, http.StatusServiceUnavailable, "mail", "Broadcast", "Broadcast is not available.", "bad", "/_gm/") return } h.render(c, http.StatusOK, "broadcast", "mail", "Broadcast", nil) } } // BroadcastSend handles POST /_gm/broadcast — multi-game admin broadcast. func (h *AdminConsoleHandlers) BroadcastSend() gin.HandlerFunc { return func(c *gin.Context) { if h.diplomail == nil { h.renderMessage(c, http.StatusServiceUnavailable, "mail", "Broadcast", "Broadcast is not available.", "bad", "/_gm/") return } username, _ := basicauth.UsernameFromContext(c.Request.Context()) gameIDs, err := parseGameIDList(c.PostForm("game_ids")) if err != nil { h.renderMessage(c, http.StatusBadRequest, "mail", "Invalid input", "Game IDs must be valid UUIDs.", "bad", "/_gm/broadcast") return } _, total, err := h.diplomail.SendAdminMultiGameBroadcast(c.Request.Context(), diplomail.SendMultiGameBroadcastInput{ CallerUsername: username, Scope: strings.TrimSpace(c.PostForm("scope")), GameIDs: gameIDs, RecipientScope: strings.TrimSpace(c.PostForm("recipients")), Subject: strings.TrimSpace(c.PostForm("subject")), Body: c.PostForm("body"), SenderIP: clientip.ExtractSourceIP(c), }) if err != nil { if errors.Is(err, diplomail.ErrInvalidInput) { h.renderMessage(c, http.StatusBadRequest, "mail", "Invalid input", "The broadcast was rejected: check the scope, recipients, and body.", "bad", "/_gm/broadcast") return } h.logger.Error("admin console: broadcast", zap.Error(err)) h.renderMessage(c, http.StatusInternalServerError, "mail", "Broadcast failed", "Failed to send the broadcast.", "bad", "/_gm/broadcast") return } h.renderMessage(c, http.StatusOK, "mail", "Broadcast sent", fmt.Sprintf("Broadcast delivered to %d recipients.", total), "ok", "/_gm/broadcast") } } // parseConsoleDeliveryID parses the delivery_id path parameter, rendering a // console message page on failure. func parseConsoleDeliveryID(c *gin.Context, h *AdminConsoleHandlers) (uuid.UUID, bool) { parsed, err := uuid.Parse(c.Param("delivery_id")) if err != nil { h.renderMessage(c, http.StatusBadRequest, "mail", "Invalid input", "delivery_id must be a valid UUID.", "bad", "/_gm/mail") return uuid.Nil, false } return parsed, true } // parseGameIDList parses a comma-separated list of UUIDs, ignoring blanks. func parseGameIDList(raw string) ([]uuid.UUID, error) { fields := strings.Split(raw, ",") ids := make([]uuid.UUID, 0, len(fields)) for _, field := range fields { field = strings.TrimSpace(field) if field == "" { continue } parsed, err := uuid.Parse(field) if err != nil { return nil, err } ids = append(ids, parsed) } return ids, nil } func toMailData(deliveries mail.AdminListDeliveriesPage, dead mail.AdminListDeadLettersPage) adminconsole.MailData { data := adminconsole.MailData{ Deliveries: make([]adminconsole.MailDeliveryRow, 0, len(deliveries.Items)), DeadLetters: make([]adminconsole.MailDeadLetterRow, 0, len(dead.Items)), Page: deliveries.Page, PageSize: deliveries.PageSize, Total: deliveries.Total, PrevPage: deliveries.Page - 1, NextPage: deliveries.Page + 1, HasPrev: deliveries.Page > 1, HasNext: int64(deliveries.Page*deliveries.PageSize) < deliveries.Total, } for _, d := range deliveries.Items { data.Deliveries = append(data.Deliveries, adminconsole.MailDeliveryRow{ DeliveryID: d.DeliveryID.String(), Template: d.TemplateID, Status: d.Status, Attempts: d.Attempts, NextAttempt: fmtConsoleTimePtr(d.NextAttemptAt), Created: fmtConsoleTime(d.CreatedAt), }) } for _, d := range dead.Items { data.DeadLetters = append(data.DeadLetters, adminconsole.MailDeadLetterRow{ DeliveryID: d.DeliveryID.String(), Reason: d.Reason, Archived: fmtConsoleTime(d.ArchivedAt), }) } return data } func toMailDeliveryDetail(d mail.Delivery, attempts []mail.Attempt) adminconsole.MailDeliveryDetail { detail := adminconsole.MailDeliveryDetail{ DeliveryID: d.DeliveryID.String(), Template: d.TemplateID, Status: d.Status, Attempts: d.Attempts, NextAttempt: fmtConsoleTimePtr(d.NextAttemptAt), LastError: d.LastError, Created: fmtConsoleTime(d.CreatedAt), Sent: fmtConsoleTimePtr(d.SentAt), DeadLettered: fmtConsoleTimePtr(d.DeadLetteredAt), CanResend: d.Status != mail.StatusSent, AttemptRows: make([]adminconsole.MailAttemptRow, 0, len(attempts)), } for _, a := range attempts { detail.AttemptRows = append(detail.AttemptRows, adminconsole.MailAttemptRow{ AttemptNo: a.AttemptNo, Outcome: a.Outcome, Started: fmtConsoleTime(a.StartedAt), Finished: fmtConsoleTimePtr(a.FinishedAt), Error: a.Error, }) } return detail } func toNotificationsData(notifications notification.AdminListNotificationsPage, dead notification.AdminListDeadLettersPage, malformed notification.AdminListMalformedPage) adminconsole.NotificationsData { data := adminconsole.NotificationsData{ Notifications: make([]adminconsole.NotificationRow, 0, len(notifications.Items)), DeadLetters: make([]adminconsole.NotificationDeadLetterRow, 0, len(dead.Items)), Malformed: make([]adminconsole.MalformedRow, 0, len(malformed.Items)), } for _, n := range notifications.Items { data.Notifications = append(data.Notifications, adminconsole.NotificationRow{ NotificationID: n.NotificationID.String(), Kind: n.Kind, UserID: optionalUUID(n.UserID), Created: fmtConsoleTime(n.CreatedAt), }) } for _, d := range dead.Items { data.DeadLetters = append(data.DeadLetters, adminconsole.NotificationDeadLetterRow{ NotificationID: d.NotificationID.String(), RouteID: d.RouteID.String(), Reason: d.Reason, Archived: fmtConsoleTime(d.ArchivedAt), }) } for _, m := range malformed.Items { data.Malformed = append(data.Malformed, adminconsole.MalformedRow{ ID: m.ID.String(), Reason: m.Reason, Received: fmtConsoleTime(m.ReceivedAt), }) } return data } // optionalUUID renders a nullable user id; system-scoped rows have none. func optionalUUID(id *uuid.UUID) string { if id == nil { return "—" } return id.String() }