Files
paliad/internal/services/invite_service.go
m 0e3411c40b feat(admin): /admin/email-templates editor (t-paliad-072)
DB-backed email-template editor for global_admins, replacing the
"Kommt bald" placeholder. Admins can edit invitation, deadline_digest,
and the shared base wrapper for both DE and EN, preview against sample
data, save with versions, and reset to the embedded default.

Backend:
- Migration 026 adds paliad.email_templates (active row per (key, lang))
  and paliad.email_template_versions (append-only, retained 20 deep).
- EmailTemplateService — GetActive falls through to the embedded per-
  language file when no DB row, Save validates parse + structural
  invariants and writes a version, Reset deletes the active row, Restore
  copies a version back. Mutations require DB; reads work without.
- MailService now consults the service for body and subject and falls
  back to the embedded default if the active row is malformed at parse
  time — a corrupt admin save can never wedge the send path.
- Subjects move from Go (buildDigestSubject + inviteSubject) to
  text/template strings stored in the (key, lang) row. Default subjects
  ship with a {{/* keep this phrasing */}} comment pointing at the
  reminder-redesign doc so the SLO framing rationale survives edits.
- Bilingual templates split into per-language files (invitation.de.html
  + .en.html, deadline_digest.de.html + .en.html, base.de.html + .en.html).
  No more {{if eq .Lang}} branching inside templates.
- Handlers under /api/admin/email-templates/* gated by the existing
  RequireAdminFunc(users) admin middleware, same shape as /admin/team.

Frontend:
- /admin/email-templates list page — three cards (one per template),
  each linking to DE + EN editors with their last-modified status.
- /admin/email-templates/{key}?lang=de three-pane editor — subject + body
  textarea + variable docs + actions on the left, sandboxed iframe
  preview + version log on the right. 500 ms debounced live preview;
  save validates server-side (422 on parse error, surfaced inline).
- admin.tsx flips the Email-Templates card from PLANNED to verfügbar.
- 50 new i18n keys (DE + EN) for the editor surface.

Tests: GetActive fallback path, ValidateTemplate happy + sad paths,
SaveRequiresStore on no-DB service, RenderTemplate body + subject
goldens, full SYSTEMAUSFALL/SYSTEM FAILURE subject matrix.

Smoke (knowledge-platform-only run, no DB/auth):
- GET /admin/email-templates → 302 to /login
- GET /api/admin/email-templates → 401
- go build/vet/test clean, bun run build clean

Design: docs/design-email-templates-2026-04-29.md.
2026-04-29 22:09:39 +02:00

215 lines
6.0 KiB
Go

// Package services — InviteService — colleague invitations.
//
// Sends one branded invitation email and records the row in
// paliad.invitations. Rate limiting lives here (not in the handler) so any
// future caller — CLI, admin UI, bulk importer — inherits the same 10/day
// cap without re-implementing it.
//
// The limiter is in-memory on purpose: invitations are rare, process
// restarts are rare, and the consequence of a small bypass during a restart
// (a user might get an 11th invite slot) is negligible. A distributed
// limiter would be overkill here.
package services
import (
"context"
"errors"
"fmt"
"net/mail"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
// InviteRateLimit caps how many invitations a single user may send within
// InviteRateWindow. Task brief defines 10/day.
const (
InviteRateLimit = 10
InviteRateWindow = 24 * time.Hour
)
// Sentinel errors. Handlers map these to HTTP status codes.
var (
ErrInviteRateLimited = errors.New("invitation rate limit exceeded")
ErrInviteInvalidEmail = errors.New("invalid recipient email")
ErrInviteDomainBlocked = errors.New("recipient domain not allowed")
)
// InviteService wires the invitation flow. Allowed domains are checked via
// the supplied function so the handler-level whitelist stays the single
// source of truth (we don't want two separate lists drifting apart).
type InviteService struct {
db *sqlx.DB
mail *MailService
allowedDomains func() []string
baseURL string
mu sync.Mutex
sentBy map[uuid.UUID][]time.Time
clock func() time.Time
}
// NewInviteService wires the service. allowedDomains is the same function
// used by the auth handler so the list stays consistent. baseURL is
// prepended to register links in emails.
func NewInviteService(db *sqlx.DB, mail *MailService, allowedDomains func() []string, baseURL string) *InviteService {
if baseURL == "" {
baseURL = "https://paliad.de"
}
if allowedDomains == nil {
allowedDomains = func() []string { return nil }
}
return &InviteService{
db: db,
mail: mail,
allowedDomains: allowedDomains,
baseURL: baseURL,
sentBy: map[uuid.UUID][]time.Time{},
clock: func() time.Time { return time.Now() },
}
}
// InviteInput is the payload a caller passes to Send.
type InviteInput struct {
ToEmail string
Message string
}
// Send validates, enforces the rate limit, dispatches the email, and writes
// the audit row. Returns the persisted invitation ID on success.
func (s *InviteService) Send(ctx context.Context, fromUserID uuid.UUID, inviter Inviter, in InviteInput) (uuid.UUID, error) {
to := strings.TrimSpace(in.ToEmail)
if _, err := mail.ParseAddress(to); err != nil {
return uuid.Nil, ErrInviteInvalidEmail
}
if !s.domainAllowed(to) {
return uuid.Nil, ErrInviteDomainBlocked
}
if !s.allowInvite(fromUserID) {
return uuid.Nil, ErrInviteRateLimited
}
msg := strings.TrimSpace(in.Message)
lang := "de"
if inviter.Lang == "en" {
lang = "en"
}
if err := s.mail.SendTemplate(TemplateData{
To: to,
Lang: lang,
Name: "invitation",
Data: map[string]any{
"InviterName": inviter.DisplayName,
"InviterEmail": inviter.Email,
"ToEmail": to,
"Message": msg,
"RegisterURL": s.baseURL + "/login",
},
}); err != nil {
return uuid.Nil, fmt.Errorf("send invitation: %w", err)
}
// Audit row written after the send so an SMTP failure doesn't leave a
// phantom "sent" record. Rate-limit slots are burned regardless — a
// user who triggers repeated SMTP errors still counts against the cap,
// keeping worst-case resource use bounded.
id, dbErr := s.insertRow(ctx, fromUserID, to, msg)
if dbErr != nil {
return uuid.Nil, fmt.Errorf("log invitation: %w", dbErr)
}
return id, nil
}
// Inviter carries the sender's display-facing fields so the service doesn't
// need to look them up. The handler fetches the paliad.users row and passes
// it through.
type Inviter struct {
DisplayName string
Email string
Lang string
}
func (s *InviteService) domainAllowed(email string) bool {
domains := s.allowedDomains()
if len(domains) == 0 {
// If the configured whitelist is empty, we decline. Fail-closed is
// safer than accidentally exposing invitations to random domains.
return false
}
parts := strings.SplitN(email, "@", 2)
if len(parts) != 2 {
return false
}
got := strings.ToLower(parts[1])
for _, d := range domains {
if strings.ToLower(d) == got {
return true
}
}
return false
}
// allowInvite both checks and reserves the rate-limit slot in one atomic op,
// so two concurrent calls can't both see "9 used" and both go through.
func (s *InviteService) allowInvite(userID uuid.UUID) bool {
s.mu.Lock()
defer s.mu.Unlock()
now := s.clock()
cutoff := now.Add(-InviteRateWindow)
// Compact: drop stamps older than the window. Sent[] stays short
// (at most InviteRateLimit live entries per user).
seen := s.sentBy[userID]
kept := seen[:0]
for _, t := range seen {
if t.After(cutoff) {
kept = append(kept, t)
}
}
if len(kept) >= InviteRateLimit {
s.sentBy[userID] = kept
return false
}
kept = append(kept, now)
s.sentBy[userID] = kept
return true
}
// RemainingToday reports the unused portion of the rate limit, for surfacing
// a "3 invitations left today" hint on the UI. Read-only; safe to call from
// a handler that only wants to inspect the counter.
func (s *InviteService) RemainingToday(userID uuid.UUID) int {
s.mu.Lock()
defer s.mu.Unlock()
cutoff := s.clock().Add(-InviteRateWindow)
live := 0
for _, t := range s.sentBy[userID] {
if t.After(cutoff) {
live++
}
}
rem := InviteRateLimit - live
if rem < 0 {
return 0
}
return rem
}
func (s *InviteService) insertRow(ctx context.Context, fromUserID uuid.UUID, toEmail, message string) (uuid.UUID, error) {
var id uuid.UUID
err := s.db.GetContext(ctx, &id,
`INSERT INTO paliad.invitations (from_user_id, to_email, message)
VALUES ($1, $2, $3)
RETURNING id`,
fromUserID, toEmail, message,
)
return id, err
}