// 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 }