feat: backend service
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user