Files
paliad/internal/handlers/invite.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

128 lines
3.6 KiB
Go

package handlers
import (
"encoding/json"
"errors"
"net/http"
"mgit.msbls.de/m/patholo/internal/services"
)
// inviteRequest is the JSON body for POST /api/invite. Both fields are
// optional-ish: email is required and validated, message is a free-form
// personal note the sender may include. Kept minimal on purpose — the
// invitation UX is a single email field plus an optional textarea.
type inviteRequest struct {
Email string `json:"email"`
Message string `json:"message,omitempty"`
}
type inviteResponse struct {
ID string `json:"id"`
Remaining int `json:"remaining_today"`
}
// POST /api/invite — send a branded invitation email to a colleague.
//
// Auth: any onboarded user may invite. The HLC email-domain whitelist
// (ALLOWED_EMAIL_DOMAINS) is enforced in-service so callers can't bypass it
// by bypassing the handler.
//
// Rate limit: 10 invitations per 24h per user, enforced in InviteService.
// The response always includes `remaining_today` so the UI can show
// "3 invitations left" without a second request.
func handleInvite(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
if dbSvc.invite == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "invitations unavailable — SMTP not configured",
})
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
me, err := dbSvc.users.GetByID(r.Context(), uid)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
return
}
if me == nil {
writeJSON(w, http.StatusForbidden, map[string]string{
"error": "onboarding required before sending invitations",
})
return
}
var req inviteRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
id, err := dbSvc.invite.Send(r.Context(), uid,
services.Inviter{
DisplayName: me.DisplayName,
Email: me.Email,
Lang: me.Lang,
},
services.InviteInput{
ToEmail: req.Email,
Message: req.Message,
},
)
if err != nil {
switch {
case errors.Is(err, services.ErrInviteInvalidEmail):
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "invalid email address",
})
case errors.Is(err, services.ErrInviteDomainBlocked):
writeJSON(w, http.StatusForbidden, map[string]string{
"error": "recipient domain not on the HLC allow-list",
})
case errors.Is(err, services.ErrInviteRateLimited):
w.Header().Set("Retry-After", "3600")
writeJSON(w, http.StatusTooManyRequests, map[string]string{
"error": "invitation rate limit reached — try again tomorrow",
})
default:
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "failed to send invitation",
})
}
return
}
writeJSON(w, http.StatusCreated, inviteResponse{
ID: id.String(),
Remaining: dbSvc.invite.RemainingToday(uid),
})
}
// GET /api/invite — read-only status: how many invitation slots the caller
// has left in the current 24h window. Cheap to call; frontend can poll this
// before opening the modal to avoid disabling the send button post-hoc.
func handleInviteStatus(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
if dbSvc.invite == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "invitations unavailable — SMTP not configured",
})
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
writeJSON(w, http.StatusOK, map[string]int{
"remaining_today": dbSvc.invite.RemainingToday(uid),
"limit": services.InviteRateLimit,
})
}