Files
galaxy-game/backend/internal/mail/enqueue.go
T
2026-05-06 10:14:55 +03:00

244 lines
8.9 KiB
Go

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
}