Files
paliad/internal/services/invite_service.go
m 11217f7bfa feat: email service — SMTP + deadline reminders + invitations (t-paliad-021)
- internal/services/mail_service.go: SMTP/TLS sender (implicit TLS on 465),
  html/template rendering, branded base layout + content templates, silent
  no-op when SMTP_* unset.
- internal/services/reminder_service.go: hourly scanner for Fristen that are
  overdue / due tomorrow / due within the week (Monday digest). Dedup via
  paliad.reminder_log (24h window).
- internal/services/invite_service.go: POST /api/invite flow with domain
  whitelist, in-memory 10/day/user rate limit, audit row in
  paliad.invitations.
- internal/handlers/invite.go: POST + GET /api/invite handlers.
- Sidebar "Kolleg:in einladen" button + modal on every page.
- migration 016: paliad.reminder_log, paliad.invitations, users.lang column.
- docker-compose: SMTP_* + PALIAD_BASE_URL env vars.
- docs/feature-roadmap.md: documented Supabase auth-SMTP routing as open
  question; current pilot keeps identity mails on Supabase default sender.

Rationale: get Paliad off Supabase's best-effort outbound for the
inbox-facing stuff (reminders, invitations) and move deadline nudges from
passive dashboard to active email. Custom Supabase auth SMTP is blocked on
the shared ydb.youpc.org instance — deferred until Paliad has its own
project or GoTrue webhook relay.
2026-04-20 12:34:38 +02:00

224 lines
6.3 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 != nil && *inviter.Lang == "en" {
lang = "en"
}
subject := inviteSubject(lang, inviter.DisplayName)
if err := s.mail.SendTemplate(TemplateData{
To: to,
Subject: subject,
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
}
func inviteSubject(lang, inviterName string) string {
if lang == "en" {
return fmt.Sprintf("[Paliad] %s invites you to Paliad", inviterName)
}
return fmt.Sprintf("[Paliad] %s lädt Sie zu Paliad ein", inviterName)
}