244 lines
8.9 KiB
Go
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
|
|
}
|