package mail import ( "context" "fmt" netmail "net/mail" "strings" "time" "github.com/google/uuid" "go.uber.org/zap" ) // contentTypeTextPlain is the RFC 2046 text/plain MIME type stored in // `mail_payloads.content_type` for plain-text bodies. const contentTypeTextPlain = "text/plain" // TemplateLoginCode is the template_id stored in `mail_deliveries` for // the auth-issued login code. The value matches the kind in the // notification catalog (`README.md` §10) so future cross-reporting // stays consistent. const TemplateLoginCode = "auth.login_code" // EnqueueLoginCode renders the auth login-code email and inserts the // outbox row. Each call gets a fresh server-side idempotency_key so // the unique constraint cannot accidentally suppress a legitimate // re-issue; double-enqueue protection lives in the auth challenge // throttle (see `auth.Service.SendEmailCode`). func (s *Service) EnqueueLoginCode(ctx context.Context, email, code string, ttl time.Duration) error { addr, err := normaliseRecipient(email) if err != nil { return err } subject, body := renderLoginCode(code, ttl) args := EnqueueArgs{ DeliveryID: uuid.New(), TemplateID: TemplateLoginCode, IdempotencyKey: uuid.NewString(), Recipients: []string{addr}, ContentType: contentTypeTextPlain, Subject: subject, Body: []byte(body), } inserted, err := s.deps.Store.InsertEnqueue(ctx, args) if err != nil { return fmt.Errorf("mail: enqueue login code: %w", err) } if !inserted { // Cannot happen given the random key, but keeps the invariant // explicit for readers grep-ing for unexpected paths. s.deps.Logger.Warn("login-code enqueue collided on random idempotency key", zap.String("delivery_id", args.DeliveryID.String())) } return nil } // EnqueueTemplate is the generic producer surface used by future // notification fan-out . Caller supplies a stable // idempotencyKey so re-deliveries of the same logical event are // deduplicated by the (template_id, idempotency_key) UNIQUE // constraint. func (s *Service) EnqueueTemplate(ctx context.Context, templateID, recipient string, payload map[string]any, idempotencyKey string) error { if strings.TrimSpace(idempotencyKey) == "" { return fmt.Errorf("mail: idempotency_key must not be empty") } addr, err := normaliseRecipient(recipient) if err != nil { return err } render, ok := templateRenderers[templateID] if !ok { return fmt.Errorf("%w: %q", ErrUnknownTemplate, templateID) } subject, body, err := render(payload) if err != nil { return fmt.Errorf("mail: render template %q: %w", templateID, err) } args := EnqueueArgs{ DeliveryID: uuid.New(), TemplateID: templateID, IdempotencyKey: idempotencyKey, Recipients: []string{addr}, ContentType: contentTypeTextPlain, Subject: subject, Body: []byte(body), } if _, err := s.deps.Store.InsertEnqueue(ctx, args); err != nil { return fmt.Errorf("mail: enqueue template: %w", err) } return nil } // normaliseRecipient trims whitespace and validates the address with // stdlib RFC 5322 parsing. Empty / malformed addresses are rejected // with ErrInvalidRecipient. The returned string is the canonical form // (`mail.Address.Address`) without any display name. func normaliseRecipient(addr string) (string, error) { trimmed := strings.TrimSpace(addr) if trimmed == "" { return "", ErrInvalidRecipient } parsed, err := netmail.ParseAddress(trimmed) if err != nil { return "", ErrInvalidRecipient } return parsed.Address, nil } // templateRenderers is the inline catalog of mail templates the // notification module dispatches against. The implementation added // `auth.login_code`; The implementation added the rest of the email-bearing // kinds enumerated in `README.md` §10. Each renderer takes the // producer-supplied payload map and returns (subject, body) or an // error when required fields are missing or wrongly typed. var templateRenderers = map[string]func(map[string]any) (string, string, error){ TemplateLoginCode: func(payload map[string]any) (string, string, error) { code, _ := payload["code"].(string) if code == "" { return "", "", fmt.Errorf("payload.code must be a non-empty string") } ttl, _ := payload["ttl"].(time.Duration) subject, body := renderLoginCode(code, ttl) return subject, body, nil }, "lobby.invite.received": func(payload map[string]any) (string, string, error) { gameID := payloadString(payload, "game_id") inviter := payloadString(payload, "inviter_user_id") subject := "You have a new Galaxy game invite" body := fmt.Sprintf( "You have been invited to a Galaxy game.\n\nGame: %s\nInviter: %s\n\nOpen the Galaxy client to accept or decline.\n", gameID, inviter, ) return subject, body, nil }, "lobby.application.approved": func(payload map[string]any) (string, string, error) { gameID := payloadString(payload, "game_id") subject := "Your Galaxy application was approved" body := fmt.Sprintf( "Your application to join the Galaxy game %s has been approved. The game owner will start the match when ready.\n", gameID, ) return subject, body, nil }, "lobby.application.rejected": func(payload map[string]any) (string, string, error) { gameID := payloadString(payload, "game_id") subject := "Your Galaxy application was rejected" body := fmt.Sprintf( "Your application to join the Galaxy game %s has been rejected. You can apply to other public games from the lobby.\n", gameID, ) return subject, body, nil }, "lobby.membership.removed": func(payload map[string]any) (string, string, error) { gameID := payloadString(payload, "game_id") reason := payloadString(payload, "reason") subject := "You were removed from a Galaxy game" body := fmt.Sprintf( "Your membership in the Galaxy game %s has been removed.\n\nReason: %s\n", gameID, fallbackString(reason, "no reason provided"), ) return subject, body, nil }, "lobby.membership.blocked": func(payload map[string]any) (string, string, error) { gameID := payloadString(payload, "game_id") subject := "You were blocked from a Galaxy game" body := fmt.Sprintf( "Your membership in the Galaxy game %s has been blocked. Please contact the game owner if this is unexpected.\n", gameID, ) return subject, body, nil }, "lobby.race_name.pending": func(payload map[string]any) (string, string, error) { raceName := payloadString(payload, "race_name") expiresAt := payloadString(payload, "expires_at") subject := "Your Galaxy race name is awaiting registration" body := fmt.Sprintf( "Congratulations — your Galaxy race name %q has reached pending registration. Confirm registration before %s to lock it permanently.\n", raceName, fallbackString(expiresAt, "the listed deadline"), ) return subject, body, nil }, "runtime.image_pull_failed": func(payload map[string]any) (string, string, error) { gameID := payloadString(payload, "game_id") imageRef := payloadString(payload, "image_ref") subject := "Galaxy runtime: image pull failed" body := fmt.Sprintf( "Image pull failed while preparing engine container for game %s.\n\nimage_ref: %s\n\nReview the runtime operation log for details.\n", gameID, fallbackString(imageRef, "unknown"), ) return subject, body, nil }, "runtime.container_start_failed": func(payload map[string]any) (string, string, error) { gameID := payloadString(payload, "game_id") subject := "Galaxy runtime: container start failed" body := fmt.Sprintf( "Engine container start failed for game %s.\n\nReview the runtime operation log and Docker daemon logs for details.\n", gameID, ) return subject, body, nil }, "runtime.start_config_invalid": func(payload map[string]any) (string, string, error) { gameID := payloadString(payload, "game_id") reason := payloadString(payload, "reason") subject := "Galaxy runtime: start config invalid" body := fmt.Sprintf( "Engine container start was rejected by configuration validation for game %s.\n\nReason: %s\n", gameID, fallbackString(reason, "no reason provided"), ) return subject, body, nil }, } // payloadString fetches a string field from a notification payload // without panicking on missing or wrongly-typed entries; an empty // string is the documented fallback. func payloadString(payload map[string]any, key string) string { v, _ := payload[key].(string) return v } // fallbackString returns alt when value is empty. func fallbackString(value, alt string) string { if strings.TrimSpace(value) == "" { return alt } return value } // renderLoginCode builds the English plain-text body used for the // `auth.login_code` template. Localisation is deferred to a future // stage (see `backend/README.md` and `backend/docs/`). func renderLoginCode(code string, ttl time.Duration) (subject, body string) { subject = fmt.Sprintf("Galaxy login code: %s", code) minutes := int(ttl.Round(time.Minute) / time.Minute) if minutes <= 0 { minutes = 1 } body = fmt.Sprintf( "Your one-time Galaxy login code is %s.\n\nThe code expires in %d minutes. If you did not request it, you can ignore this email.\n", code, minutes, ) return subject, body }