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.
215 lines
6.0 KiB
Go
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
|
|
}
|
|
|