diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go
index 79968e1..8d87527 100644
--- a/backend/cmd/backend/main.go
+++ b/backend/cmd/backend/main.go
@@ -382,6 +382,9 @@ func run(ctx context.Context) (err error) {
Runtime: runtimeSvc,
EngineVersions: engineVersionSvc,
Operators: adminSvc,
+ Mail: mailSvc,
+ Notifications: notifSvc,
+ Diplomail: diplomailSvc,
Logger: logger,
})
diff --git a/backend/docs/admin-console.md b/backend/docs/admin-console.md
index 9694a11..74e908d 100644
--- a/backend/docs/admin-console.md
+++ b/backend/docs/admin-console.md
@@ -105,6 +105,11 @@ changes.
| `/_gm/operators/{user}/disable` | POST | Disable an operator. |
| `/_gm/operators/{user}/enable` | POST | Re-enable an operator. |
| `/_gm/operators/{user}/reset-password` | POST | Reset an operator's password. |
+| `/_gm/mail` | GET | Mail deliveries (paginated) + a dead-letter snapshot. |
+| `/_gm/mail/deliveries/{id}` | GET | Delivery detail with its attempts. |
+| `/_gm/mail/deliveries/{id}/resend`| POST | Re-enqueue a non-sent delivery. |
+| `/_gm/notifications` | GET | Notifications, dead-letters, and malformed intents overview. |
+| `/_gm/broadcast` | GET/POST | Admin multi-game diplomatic broadcast. |
Each page reuses the same service layer as the corresponding `/api/v1/admin/*`
JSON endpoint; the console adds no business logic. Collection-mutating POSTs are
diff --git a/backend/internal/adminconsole/assets/console.css b/backend/internal/adminconsole/assets/console.css
index b4550df..6b276b7 100644
--- a/backend/internal/adminconsole/assets/console.css
+++ b/backend/internal/adminconsole/assets/console.css
@@ -100,3 +100,4 @@ button.danger { background: var(--danger); color: #1a0606; }
code { background: var(--bg); padding: 0.05rem 0.3rem; border-radius: 4px; }
.actions { display: flex; flex-wrap: wrap; gap: 0.6rem; margin: 0.8rem 0; }
.actions form { margin: 0; }
+.subnav { color: var(--ink-dim); margin: -0.3rem 0 1rem; font-size: 0.9rem; }
diff --git a/backend/internal/adminconsole/mail.go b/backend/internal/adminconsole/mail.go
new file mode 100644
index 0000000..f6983d7
--- /dev/null
+++ b/backend/internal/adminconsole/mail.go
@@ -0,0 +1,86 @@
+package adminconsole
+
+// MailDeliveryRow is one line in the mail deliveries table.
+type MailDeliveryRow struct {
+ DeliveryID string
+ Template string
+ Status string
+ Attempts int32
+ NextAttempt string
+ Created string
+}
+
+// MailDeadLetterRow is one line in the mail dead-letters table.
+type MailDeadLetterRow struct {
+ DeliveryID string
+ Reason string
+ Archived string
+}
+
+// MailData is the view model for the mail page: a paginated deliveries list
+// plus a snapshot of dead-letters.
+type MailData struct {
+ Deliveries []MailDeliveryRow
+ DeadLetters []MailDeadLetterRow
+ Page int
+ PageSize int
+ Total int64
+ HasPrev bool
+ HasNext bool
+ PrevPage int
+ NextPage int
+}
+
+// MailAttemptRow is one delivery attempt on the mail detail page.
+type MailAttemptRow struct {
+ AttemptNo int32
+ Outcome string
+ Started string
+ Finished string
+ Error string
+}
+
+// MailDeliveryDetail is the view model for a single delivery.
+type MailDeliveryDetail struct {
+ DeliveryID string
+ Template string
+ Status string
+ Attempts int32
+ NextAttempt string
+ LastError string
+ Created string
+ Sent string
+ DeadLettered string
+ CanResend bool
+ AttemptRows []MailAttemptRow
+}
+
+// NotificationRow is one line in the notifications table.
+type NotificationRow struct {
+ NotificationID string
+ Kind string
+ UserID string
+ Created string
+}
+
+// NotificationDeadLetterRow is one line in the notification dead-letters table.
+type NotificationDeadLetterRow struct {
+ NotificationID string
+ RouteID string
+ Reason string
+ Archived string
+}
+
+// MalformedRow is one line in the malformed-intents table.
+type MalformedRow struct {
+ ID string
+ Reason string
+ Received string
+}
+
+// NotificationsData is the view model for the notifications overview page.
+type NotificationsData struct {
+ Notifications []NotificationRow
+ DeadLetters []NotificationDeadLetterRow
+ Malformed []MalformedRow
+}
diff --git a/backend/internal/adminconsole/templates/pages/broadcast.gohtml b/backend/internal/adminconsole/templates/pages/broadcast.gohtml
new file mode 100644
index 0000000..317ac33
--- /dev/null
+++ b/backend/internal/adminconsole/templates/pages/broadcast.gohtml
@@ -0,0 +1,21 @@
+{{define "content" -}}
+{{$csrf := .CSRFToken}}
+
Broadcast
+
+
+{{- end}}
diff --git a/backend/internal/adminconsole/templates/pages/mail.gohtml b/backend/internal/adminconsole/templates/pages/mail.gohtml
new file mode 100644
index 0000000..ea4a93e
--- /dev/null
+++ b/backend/internal/adminconsole/templates/pages/mail.gohtml
@@ -0,0 +1,32 @@
+{{define "content" -}}
+Mail
+
+{{with .Data}}
+
+Deliveries
+
+| Delivery | Template | Status | Attempts | Next attempt | Created |
+
+{{range .Deliveries}}
+{{.DeliveryID}} | {{.Template}} | {{.Status}} | {{.Attempts}} | {{if .NextAttempt}}{{.NextAttempt}}{{else}}—{{end}} | {{.Created}} |
+{{else}}| no deliveries |
{{end}}
+
+
+
+
+
+Dead-letters
+
+| Delivery | Reason | Archived |
+
+{{range .DeadLetters}}{{.DeliveryID}} | {{.Reason}} | {{.Archived}} |
+{{else}}| no dead-letters |
{{end}}
+
+
+
+{{end}}
+{{- end}}
diff --git a/backend/internal/adminconsole/templates/pages/mail_delivery.gohtml b/backend/internal/adminconsole/templates/pages/mail_delivery.gohtml
new file mode 100644
index 0000000..d587259
--- /dev/null
+++ b/backend/internal/adminconsole/templates/pages/mail_delivery.gohtml
@@ -0,0 +1,33 @@
+{{define "content" -}}
+{{$csrf := .CSRFToken}}
+{{with .Data}}
+« mail
+Delivery
+
+
+- Delivery ID:
{{.DeliveryID}}
+- Template: {{.Template}}
+- Status: {{.Status}}
+- Attempts: {{.Attempts}}
+- Next attempt: {{if .NextAttempt}}{{.NextAttempt}}{{else}}—{{end}}
+- Created: {{.Created}}
+- Sent: {{if .Sent}}{{.Sent}}{{else}}—{{end}}
+- Dead-lettered: {{if .DeadLettered}}{{.DeadLettered}}{{else}}—{{end}}
+- Last error: {{if .LastError}}{{.LastError}}{{else}}—{{end}}
+
+{{if .CanResend}}
+
+{{else}}Already sent — resend is not available.
{{end}}
+
+
+Attempts
+
+| # | Outcome | Started | Finished | Error |
+
+{{range .AttemptRows}}| {{.AttemptNo}} | {{.Outcome}} | {{.Started}} | {{if .Finished}}{{.Finished}}{{else}}—{{end}} | {{.Error}} |
+{{else}}| no attempts |
{{end}}
+
+
+
+{{end}}
+{{- end}}
diff --git a/backend/internal/adminconsole/templates/pages/notifications.gohtml b/backend/internal/adminconsole/templates/pages/notifications.gohtml
new file mode 100644
index 0000000..eb1d27d
--- /dev/null
+++ b/backend/internal/adminconsole/templates/pages/notifications.gohtml
@@ -0,0 +1,27 @@
+{{define "content" -}}
+Notifications
+
+{{with .Data}}
+
+Recent notifications
+| ID | Kind | User | Created |
+{{range .Notifications}}{{.NotificationID}} | {{.Kind}} | {{.UserID}} | {{.Created}} |
+{{else}}| none |
{{end}}
+
+
+
+Dead-letters
+| Notification | Route | Reason | Archived |
+{{range .DeadLetters}}{{.NotificationID}} | {{.RouteID}} | {{.Reason}} | {{.Archived}} |
+{{else}}| none |
{{end}}
+
+
+
+Malformed intents
+| ID | Reason | Received |
+{{range .Malformed}}{{.ID}} | {{.Reason}} | {{.Received}} |
+{{else}}| none |
{{end}}
+
+
+{{end}}
+{{- end}}
diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go
index d58e4ee..a23badc 100644
--- a/backend/internal/server/handlers_admin_console.go
+++ b/backend/internal/server/handlers_admin_console.go
@@ -33,6 +33,9 @@ type AdminConsoleHandlers struct {
runtime RuntimeAdmin
engineVersions EngineVersionAdmin
operators OperatorAdmin
+ mail MailAdmin
+ notifications NotificationAdmin
+ diplomail DiplomailAdmin
logger *zap.Logger
}
@@ -51,6 +54,9 @@ type AdminConsoleDeps struct {
Runtime RuntimeAdmin
EngineVersions EngineVersionAdmin
Operators OperatorAdmin
+ Mail MailAdmin
+ Notifications NotificationAdmin
+ Diplomail DiplomailAdmin
Logger *zap.Logger
}
@@ -92,6 +98,9 @@ func NewAdminConsoleHandlers(deps AdminConsoleDeps) *AdminConsoleHandlers {
runtime: deps.Runtime,
engineVersions: deps.EngineVersions,
operators: deps.Operators,
+ mail: deps.Mail,
+ notifications: deps.Notifications,
+ diplomail: deps.Diplomail,
logger: logger.Named("http.admin.console"),
}
}
diff --git a/backend/internal/server/handlers_admin_console_mail.go b/backend/internal/server/handlers_admin_console_mail.go
new file mode 100644
index 0000000..cfee71a
--- /dev/null
+++ b/backend/internal/server/handlers_admin_console_mail.go
@@ -0,0 +1,327 @@
+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()
+}
diff --git a/backend/internal/server/handlers_admin_console_mail_test.go b/backend/internal/server/handlers_admin_console_mail_test.go
new file mode 100644
index 0000000..449e82f
--- /dev/null
+++ b/backend/internal/server/handlers_admin_console_mail_test.go
@@ -0,0 +1,242 @@
+package server
+
+import (
+ "context"
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+
+ "galaxy/backend/internal/adminconsole"
+ "galaxy/backend/internal/diplomail"
+ "galaxy/backend/internal/mail"
+ "galaxy/backend/internal/notification"
+ "galaxy/backend/internal/server/middleware/basicauth"
+
+ "github.com/google/uuid"
+ "go.uber.org/zap"
+)
+
+type fakeMailAdmin struct {
+ deliveries mail.AdminListDeliveriesPage
+ dead mail.AdminListDeadLettersPage
+ delivery mail.Delivery
+ getErr error
+ attempts []mail.Attempt
+ resendCalls int
+}
+
+func (f *fakeMailAdmin) AdminListDeliveries(context.Context, int, int) (mail.AdminListDeliveriesPage, error) {
+ return f.deliveries, nil
+}
+func (f *fakeMailAdmin) AdminGetDelivery(context.Context, uuid.UUID) (mail.Delivery, error) {
+ return f.delivery, f.getErr
+}
+func (f *fakeMailAdmin) AdminListAttempts(context.Context, uuid.UUID) ([]mail.Attempt, error) {
+ return f.attempts, nil
+}
+func (f *fakeMailAdmin) AdminResendDelivery(context.Context, uuid.UUID) (mail.Delivery, error) {
+ f.resendCalls++
+ return f.delivery, nil
+}
+func (f *fakeMailAdmin) AdminListDeadLetters(context.Context, int, int) (mail.AdminListDeadLettersPage, error) {
+ return f.dead, nil
+}
+
+type fakeNotificationAdmin struct {
+ notifications notification.AdminListNotificationsPage
+ dead notification.AdminListDeadLettersPage
+ malformed notification.AdminListMalformedPage
+}
+
+func (f *fakeNotificationAdmin) AdminListNotifications(context.Context, int, int) (notification.AdminListNotificationsPage, error) {
+ return f.notifications, nil
+}
+func (f *fakeNotificationAdmin) AdminListDeadLetters(context.Context, int, int) (notification.AdminListDeadLettersPage, error) {
+ return f.dead, nil
+}
+func (f *fakeNotificationAdmin) AdminListMalformed(context.Context, int, int) (notification.AdminListMalformedPage, error) {
+ return f.malformed, nil
+}
+
+type fakeDiplomailAdmin struct {
+ total int
+ err error
+ broadcastCalls int
+ last diplomail.SendMultiGameBroadcastInput
+}
+
+func (f *fakeDiplomailAdmin) SendAdminMultiGameBroadcast(_ context.Context, in diplomail.SendMultiGameBroadcastInput) ([]diplomail.Message, int, error) {
+ f.broadcastCalls++
+ f.last = in
+ if f.err != nil {
+ return nil, 0, f.err
+ }
+ return nil, f.total, nil
+}
+
+func mailConsoleRouter(t *testing.T, m MailAdmin, n NotificationAdmin, d DiplomailAdmin) (http.Handler, *adminconsole.CSRF) {
+ t.Helper()
+ csrf := adminconsole.NewCSRF([]byte("test-key"))
+ handler, err := NewRouter(RouterDependencies{
+ Logger: zap.NewNop(),
+ AdminVerifier: basicauth.NewStaticVerifier("secret"),
+ AdminConsole: NewAdminConsoleHandlers(AdminConsoleDeps{CSRF: csrf, Mail: m, Notifications: n, Diplomail: d}),
+ })
+ if err != nil {
+ t.Fatalf("NewRouter: %v", err)
+ }
+ return handler, csrf
+}
+
+func TestConsoleMailPage(t *testing.T) {
+ id := uuid.New()
+ m := &fakeMailAdmin{
+ deliveries: mail.AdminListDeliveriesPage{
+ Items: []mail.Delivery{{DeliveryID: id, TemplateID: "auth.login_code", Status: "pending", CreatedAt: time.Now()}},
+ Page: 1, PageSize: 50, Total: 1,
+ },
+ dead: mail.AdminListDeadLettersPage{
+ Items: []mail.DeadLetter{{DeliveryID: uuid.New(), Reason: "smtp 550", ArchivedAt: time.Now()}},
+ },
+ }
+ router, _ := mailConsoleRouter(t, m, nil, nil)
+
+ rec := consoleGet(t, router, "/_gm/mail")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
+ }
+ for _, want := range []string{"auth.login_code", "pending", "Dead-letters", "smtp 550"} {
+ if !strings.Contains(rec.Body.String(), want) {
+ t.Errorf("mail page missing %q", want)
+ }
+ }
+}
+
+func TestConsoleMailDeliveryDetail(t *testing.T) {
+ id := uuid.New()
+ m := &fakeMailAdmin{
+ delivery: mail.Delivery{DeliveryID: id, TemplateID: "auth.login_code", Status: "pending", Attempts: 2},
+ attempts: []mail.Attempt{{AttemptNo: 1, Outcome: "transient_failure", StartedAt: time.Now(), Error: "timeout"}},
+ }
+ router, _ := mailConsoleRouter(t, m, nil, nil)
+
+ rec := consoleGet(t, router, "/_gm/mail/deliveries/"+id.String())
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
+ }
+ for _, want := range []string{id.String(), "auth.login_code", "Attempts", "transient_failure", "Resend"} {
+ if !strings.Contains(rec.Body.String(), want) {
+ t.Errorf("delivery detail missing %q", want)
+ }
+ }
+}
+
+func TestConsoleMailDeliveryDetailNotFound(t *testing.T) {
+ m := &fakeMailAdmin{getErr: mail.ErrDeliveryNotFound}
+ router, _ := mailConsoleRouter(t, m, nil, nil)
+
+ rec := consoleGet(t, router, "/_gm/mail/deliveries/"+uuid.New().String())
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("status = %d, want 404", rec.Code)
+ }
+}
+
+func TestConsoleMailResend(t *testing.T) {
+ id := uuid.New()
+ m := &fakeMailAdmin{delivery: mail.Delivery{DeliveryID: id}}
+ router, csrf := mailConsoleRouter(t, m, nil, nil)
+
+ rec := consolePost(t, router, "/_gm/mail/deliveries/"+id.String()+"/resend", "_csrf="+csrf.Token("ops"))
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("status = %d, want 303", rec.Code)
+ }
+ if m.resendCalls != 1 {
+ t.Errorf("AdminResendDelivery called %d times, want 1", m.resendCalls)
+ }
+}
+
+func TestConsoleMailResendRejectsBadCSRF(t *testing.T) {
+ id := uuid.New()
+ m := &fakeMailAdmin{delivery: mail.Delivery{DeliveryID: id}}
+ router, _ := mailConsoleRouter(t, m, nil, nil)
+
+ rec := consolePost(t, router, "/_gm/mail/deliveries/"+id.String()+"/resend", "")
+ if rec.Code != http.StatusForbidden {
+ t.Fatalf("status = %d, want 403", rec.Code)
+ }
+ if m.resendCalls != 0 {
+ t.Error("resend must not run without a CSRF token")
+ }
+}
+
+func TestConsoleNotificationsPage(t *testing.T) {
+ n := &fakeNotificationAdmin{
+ notifications: notification.AdminListNotificationsPage{Items: []notification.Notification{{NotificationID: uuid.New(), Kind: "lobby.invite.received"}}},
+ dead: notification.AdminListDeadLettersPage{Items: []notification.DeadLetter{{NotificationID: uuid.New(), RouteID: uuid.New(), Reason: "push gone"}}},
+ malformed: notification.AdminListMalformedPage{Items: []notification.MalformedIntent{{ID: uuid.New(), Reason: "bad shape"}}},
+ }
+ router, _ := mailConsoleRouter(t, nil, n, nil)
+
+ rec := consoleGet(t, router, "/_gm/notifications")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
+ }
+ for _, want := range []string{"lobby.invite.received", "push gone", "bad shape", "Malformed intents"} {
+ if !strings.Contains(rec.Body.String(), want) {
+ t.Errorf("notifications page missing %q", want)
+ }
+ }
+}
+
+func TestConsoleBroadcastForm(t *testing.T) {
+ router, _ := mailConsoleRouter(t, nil, nil, &fakeDiplomailAdmin{})
+
+ rec := consoleGet(t, router, "/_gm/broadcast")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200", rec.Code)
+ }
+ if !strings.Contains(rec.Body.String(), "Send broadcast") {
+ t.Error("broadcast form missing")
+ }
+}
+
+func TestConsoleBroadcastSend(t *testing.T) {
+ d := &fakeDiplomailAdmin{total: 5}
+ router, csrf := mailConsoleRouter(t, nil, nil, d)
+
+ form := "_csrf=" + csrf.Token("ops") + "&scope=all_running&recipients=active&body=hello"
+ rec := consolePost(t, router, "/_gm/broadcast", form)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
+ }
+ if !strings.Contains(rec.Body.String(), "5 recipients") {
+ t.Errorf("broadcast result missing recipient count; body=%s", rec.Body.String())
+ }
+ if d.broadcastCalls != 1 || d.last.Scope != "all_running" || d.last.Body != "hello" || d.last.CallerUsername != "ops" {
+ t.Errorf("broadcast input = %+v (calls=%d)", d.last, d.broadcastCalls)
+ }
+}
+
+func TestConsoleBroadcastSendBadGameIDs(t *testing.T) {
+ d := &fakeDiplomailAdmin{}
+ router, csrf := mailConsoleRouter(t, nil, nil, d)
+
+ form := "_csrf=" + csrf.Token("ops") + "&scope=selected&game_ids=not-a-uuid&body=hello"
+ rec := consolePost(t, router, "/_gm/broadcast", form)
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, want 400", rec.Code)
+ }
+ if d.broadcastCalls != 0 {
+ t.Error("broadcast must not run with invalid game ids")
+ }
+}
+
+func TestConsoleMailUnavailable(t *testing.T) {
+ router, _ := mailConsoleRouter(t, nil, nil, nil)
+
+ rec := consoleGet(t, router, "/_gm/mail")
+ if rec.Code != http.StatusServiceUnavailable {
+ t.Fatalf("status = %d, want 503", rec.Code)
+ }
+}
diff --git a/backend/internal/server/router.go b/backend/internal/server/router.go
index 4e5c3e5..5a44de0 100644
--- a/backend/internal/server/router.go
+++ b/backend/internal/server/router.go
@@ -414,6 +414,13 @@ func registerAdminConsoleRoutes(router *gin.Engine, deps RouterDependencies) {
group.POST("/operators/:username/disable", deps.AdminConsole.OperatorDisable())
group.POST("/operators/:username/enable", deps.AdminConsole.OperatorEnable())
group.POST("/operators/:username/reset-password", deps.AdminConsole.OperatorResetPassword())
+
+ group.GET("/mail", deps.AdminConsole.MailPage())
+ group.GET("/mail/deliveries/:delivery_id", deps.AdminConsole.MailDeliveryDetail())
+ group.POST("/mail/deliveries/:delivery_id/resend", deps.AdminConsole.MailResend())
+ group.GET("/notifications", deps.AdminConsole.NotificationsPage())
+ group.GET("/broadcast", deps.AdminConsole.BroadcastForm())
+ group.POST("/broadcast", deps.AdminConsole.BroadcastSend())
}
// allowedMethodsForPath returns the comma-separated list of methods
diff --git a/integration/admin_console_test.go b/integration/admin_console_test.go
new file mode 100644
index 0000000..2f378ff
--- /dev/null
+++ b/integration/admin_console_test.go
@@ -0,0 +1,63 @@
+package integration_test
+
+import (
+ "context"
+ "io"
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+
+ "galaxy/integration/testenv"
+)
+
+// TestAdminConsole_ThroughGateway verifies the full operator-console pipe:
+// the edge gateway reverse-proxies `/_gm/*` to the backend, the backend
+// enforces the admin Basic Auth and relays its 401 challenge unchanged, and an
+// authenticated request renders the server-side dashboard.
+func TestAdminConsole_ThroughGateway(t *testing.T) {
+ plat := testenv.Bootstrap(t, testenv.BootstrapOptions{})
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ base := plat.Gateway.HTTPURL
+ client := &http.Client{Timeout: 10 * time.Second}
+
+ // Unauthenticated: the backend's 401 + Basic challenge must reach the client
+ // through the gateway.
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/_gm/", nil)
+ if err != nil {
+ t.Fatalf("build request: %v", err)
+ }
+ resp, err := client.Do(req)
+ if err != nil {
+ t.Fatalf("GET /_gm/ (no auth): %v", err)
+ }
+ body, _ := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("no-auth status = %d, want 401; body=%s", resp.StatusCode, body)
+ }
+ if !strings.Contains(resp.Header.Get("WWW-Authenticate"), "Basic") {
+ t.Fatalf("WWW-Authenticate = %q, want a Basic challenge", resp.Header.Get("WWW-Authenticate"))
+ }
+
+ // Authenticated with the bootstrap operator: the dashboard renders.
+ req, err = http.NewRequestWithContext(ctx, http.MethodGet, base+"/_gm/", nil)
+ if err != nil {
+ t.Fatalf("build request: %v", err)
+ }
+ req.SetBasicAuth(plat.Backend.AdminUser, plat.Backend.AdminPassword)
+ resp, err = client.Do(req)
+ if err != nil {
+ t.Fatalf("GET /_gm/ (auth): %v", err)
+ }
+ body, _ = io.ReadAll(resp.Body)
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("authenticated status = %d, want 200; body=%s", resp.StatusCode, body)
+ }
+ if !strings.Contains(string(body), "Dashboard") {
+ t.Fatalf("dashboard body missing the heading; got: %s", body)
+ }
+}