- 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.
224 lines
6.3 KiB
Go
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)
|
|
}
|