From 5a6289c83da554554fc0cb9335bf81081c1ec470 Mon Sep 17 00:00:00 2001 From: sysops Date: Wed, 18 Mar 2026 08:32:30 +0100 Subject: [PATCH] feat(PROJ-9): implement labels backend - DB schema, labelstore, API handlers, Xapian integration Co-Authored-By: Claude Sonnet 4.6 --- cmd/archivmail/main.go | 10 + features/INDEX.md | 4 +- features/PROJ-9-ordner-und-labels.md | 170 +++++++++ internal/api/label_handlers.go | 407 +++++++++++++++++++++ internal/api/server.go | 44 +++ internal/index/index.go | 1 + internal/labelstore/store.go | 329 +++++++++++++++++ internal/storage/migrations/012_labels.sql | 32 ++ 8 files changed, 995 insertions(+), 2 deletions(-) create mode 100644 features/PROJ-9-ordner-und-labels.md create mode 100644 internal/api/label_handlers.go create mode 100644 internal/labelstore/store.go create mode 100644 internal/storage/migrations/012_labels.sql diff --git a/cmd/archivmail/main.go b/cmd/archivmail/main.go index 943bdad..2921071 100644 --- a/cmd/archivmail/main.go +++ b/cmd/archivmail/main.go @@ -24,6 +24,7 @@ import ( "github.com/archivmail/internal/auth" imapstore "github.com/archivmail/internal/imap" "github.com/archivmail/internal/index" + "github.com/archivmail/internal/labelstore" ldapcfg "github.com/archivmail/internal/ldapconfig" pop3store "github.com/archivmail/internal/pop3" "github.com/archivmail/internal/smtpd" @@ -191,6 +192,15 @@ func main() { srv.SetTenants(tenantSt) srv.SetIndexManager(idxMgr) + // PROJ-9: Label store + labelSt, err := labelstore.New(cfg.Database.DSN()) + if err != nil { + logger.Error("label store init failed", "err", err) + os.Exit(1) + } + defer labelSt.Close() + srv.SetLabels(labelSt) + // Start SMTP daemon with index worker integration if cfg.SMTP.Bind == "" { cfg.SMTP.Bind = fmt.Sprintf(":%d", cfg.Server.SMTPPort) diff --git a/features/INDEX.md b/features/INDEX.md index 0a44d0f..8b4ac1a 100644 --- a/features/INDEX.md +++ b/features/INDEX.md @@ -20,7 +20,7 @@ | PROJ-6 | Volltext-Suche & Filterung | Deployed | [PROJ-6](PROJ-6-volltext-suche.md) | 2026-03-12 | | PROJ-7 | E-Mail-Ansicht (Lesen & Anhänge) | Deployed | [PROJ-7](PROJ-7-email-ansicht.md) | 2026-03-12 | | PROJ-8 | Automatischer IMAP-Sync (Cron-Job) | Deployed | [PROJ-8](PROJ-8-imap-auto-sync.md) | 2026-03-12 | -| PROJ-9 | Ordner- & Label-Verwaltung | In Progress | [PROJ-9](PROJ-9-ordner-und-labels.md) | 2026-03-12 | +| PROJ-9 | Ordner- & Label-Verwaltung | In Review | [PROJ-9](PROJ-9-ordner-und-labels.md) | 2026-03-12 | | PROJ-10 | Admin-Bereich: Nutzer- & Postfachverwaltung | Deployed | [PROJ-10](PROJ-10-admin-bereich.md) | 2026-03-12 | | PROJ-11 | Audit-Log & Compliance-Berichte | Deployed | [PROJ-11](PROJ-11-audit-log.md) | 2026-03-12 | | PROJ-12 | E-Mail-Export (EML/PDF) | Deployed | [PROJ-12](PROJ-12-export.md) | 2026-03-12 | @@ -35,7 +35,7 @@ | PROJ-20 | Nutzer-Löschung & E-Mail-Verbleib (GoBD-konform) | Deployed | [PROJ-20](PROJ-20-nutzer-loeschung.md) | 2026-03-17 | | PROJ-21 | Multi-Mandanten-Fähigkeit (Multi-Tenancy) | Deployed | [PROJ-21](PROJ-21-multi-tenancy.md) | 2026-03-17 | | PROJ-22 | LDAP / AD – Web-GUI Konfiguration & Test | Deployed | [PROJ-22](PROJ-22-ldap-webgui.md) | 2026-03-17 | -| PROJ-23 | Pro-Mandant LDAP / Active Directory (Multi-Tenant Phase B) | In Progress | [PROJ-23](PROJ-23-tenant-ldap-pro-mandant.md) | 2026-03-17 | +| PROJ-23 | Pro-Mandant LDAP / Active Directory (Multi-Tenant Phase B) | Deployed | [PROJ-23](PROJ-23-tenant-ldap-pro-mandant.md) | 2026-03-17 | | PROJ-24 | TOTP Zwei-Faktor-Authentifizierung (2FA) | Deployed | [PROJ-24](PROJ-24-totp-zwei-faktor.md) | 2026-03-18 | | PROJ-25 | User-Profil & Einstellungen | Deployed | [PROJ-25](PROJ-25-user-profil-einstellungen.md) | 2026-03-18 | diff --git a/features/PROJ-9-ordner-und-labels.md b/features/PROJ-9-ordner-und-labels.md new file mode 100644 index 0000000..a108ad2 --- /dev/null +++ b/features/PROJ-9-ordner-und-labels.md @@ -0,0 +1,170 @@ +# PROJ-9: Ordner- & Label-Verwaltung + +## Status: In Review +**Created:** 2026-03-12 +**Last Updated:** 2026-03-18 + +## Dependencies +- Requires: PROJ-1 (Authentifizierung) +- Requires: PROJ-5 (Speicherung & Indexierung) + +## User Stories +- Als Nutzer möchte ich E-Mails mit Labels versehen, damit ich sie thematisch organisieren kann. +- Als Admin möchte ich globale Labels definieren, die automatisch beim Import vergeben werden (z.B. nach Absender-Domain oder Import-Quelle). +- Als Nutzer möchte ich meine Suchergebnisse auf ein bestimmtes Label einschränken. +- Als Nutzer möchte ich Labels erstellen, umbenennen und löschen. + +## Acceptance Criteria +- [ ] Nutzer können Labels erstellen (Name, Farbe) +- [ ] E-Mails können mit mehreren Labels versehen werden +- [ ] Label-Filter in der Suche verfügbar +- [ ] **Keine IMAP-Ordnerstruktur** – das System ist ein Archiv, keine Ordnerhierarchie wird übernommen +- [ ] Admin kann Regeln für automatische Label-Vergabe beim Import definieren (z.B. nach Absender-Domain) +- [ ] Admin kann globale Labels definieren (für alle Nutzer sichtbar) +- [ ] Löschen eines Labels entfernt es von allen E-Mails, löscht E-Mails nicht +- [ ] Label-Übersicht in der Seitenleiste mit E-Mail-Anzahl pro Label + +## Edge Cases +- Label-Name bereits vergeben → Fehlermeldung +- E-Mail wird gelöscht aber Labels bleiben → Labels bleiben erhalten, E-Mail-Referenz entfernt +- Sehr viele Labels (> 100) → Suchfeld in der Label-Auswahl + +## Technical Requirements +- Labels: n:m-Beziehung zwischen E-Mails und Labels +- Performance: Label-Filter darf Suchantwortzeit nicht verdoppeln + +--- +## Tech Design (Solution Architect) + +### Komponentenstruktur + +**Next.js Frontend:** +``` +Seitenleiste (global, alle Seiten) +└── LabelList + ├── Label-Eintrag (Name, Farbe, Anzahl) ← klickbar → filtert Suche + ├── [+ Label erstellen] Button + └── Suchfeld (bei > 10 Labels) + +Label-Verwaltung (Inline / Modal) +├── LabelForm +│ ├── Name (Textfeld) +│ └── Farbe (Color-Picker, 8 Vorschläge) +└── LabelItem-Aktionen + ├── Umbenennen + └── Löschen (mit Bestätigung) + +E-Mail-Ansicht (PROJ-7, Erweiterung) +└── LabelPicker + ├── Aktuelle Labels der Mail (als Badges) + ├── Dropdown: Labels hinzufügen/entfernen + └── [+ Neues Label] Shortcut + +Admin-Bereich (/admin/labels) +├── Globale Labels verwalten +└── Auto-Label-Regeln + ├── RegelListe + └── RegelForm + ├── Bedingung: from-Domain / Import-Quelle / Betreff enthält + └── Aktion: Label zuweisen +``` + +**Go Backend:** +``` +Label-API +├── GET /api/labels ← alle Labels des Nutzers + globale +├── POST /api/labels ← neues Label anlegen +├── PATCH /api/labels/{id} ← umbenennen / Farbe ändern +├── DELETE /api/labels/{id} ← löschen (entfernt von allen Mails) +├── POST /api/mails/{id}/labels ← Label einer Mail zuweisen +└── DELETE /api/mails/{id}/labels/{label_id} ← Label entfernen + +Admin Label-API +├── POST /api/admin/labels ← globales Label anlegen +├── GET /api/admin/label-rules ← Auto-Label-Regeln +├── POST /api/admin/label-rules ← Regel anlegen +└── DELETE /api/admin/label-rules/{id} + +Label-Filter in Suche (Erweiterung PROJ-6) +└── Xapian-Term "label:" pro Mail + → Label-Filter läuft direkt in Xapian +``` + +### Datenmodell + +**Tabelle `labels`:** + +| Feld | Beschreibung | +|------|-------------| +| `id` | Interne ID | +| `name` | Label-Name (eindeutig pro Nutzer) | +| `color` | Hex-Farbe (z.B. `#e74c3c`) | +| `owner_id` | Nutzer-ID (NULL = globales Admin-Label) | +| `created_at` | Erstellungszeitpunkt | + +**Tabelle `email_labels`** – n:m Verknüpfung: + +| Feld | Beschreibung | +|------|-------------| +| `email_id` | Referenz auf `emails` | +| `label_id` | Referenz auf `labels` | +| `assigned_at` | Zeitpunkt der Zuweisung | +| `assigned_by` | `user` / `auto-rule` / `import` | + +**Tabelle `label_rules`** – Auto-Label beim Import: + +| Feld | Beschreibung | +|------|-------------| +| `id` | Interne ID | +| `condition_field` | `from_domain` / `source` / `subject_contains` | +| `condition_value` | z.B. `example.com` oder `imap-account-1` | +| `label_id` | Welches Label vergeben | + +### Label-Filter in Xapian + +Beim Indexieren einer Mail werden ihre Labels als Xapian-Terms gespeichert: +``` +Label "Kunde" → Term: "label:42" +Label "Projekt" → Term: "label:17" +``` + +Suche mit Label-Filter läuft vollständig in Xapian – kein zusätzlicher DB-Join nötig. Labels werden beim Zuweisen/Entfernen sofort im Xapian-Dokument aktualisiert. + +### Technische Entscheidungen + +| Entscheidung | Begründung | +|---|---| +| **Labels statt Ordner** | Archiv hat keine Hierarchie – eine Mail kann mehrere Labels haben, aber nicht in mehreren Ordnern gleichzeitig sein | +| **Label-Terms in Xapian** | Filter läuft direkt bei der Suche – kein nachträglicher DB-Join, keine Verdopplung der Antwortzeit | +| **Globale Labels (owner_id NULL)** | Admin definiert unternehmensweite Labels – Nutzer können sie nicht löschen, nur zuweisen | +| **Auto-Label-Regeln** | Importierte Mails werden sofort kategorisiert – kein manueller Aufwand für Bulk-Importe | +| **`assigned_by`-Feld** | Nachvollziehbar ob Label manuell, per Regel oder beim Import vergeben wurde | + +### Abhängigkeiten + +**Next.js Frontend:** + +| Paket | Zweck | +|---|---| +| `shadcn/ui` | Badge, Popover, Color-Picker-Basis (bereits installiert) | + +**Go Backend:** Nur Stdlib + pgx (bereits vorhanden). + +## Implementation Notes (Backend) + +**Implemented 2026-03-18:** + +- `internal/labelstore/store.go` -- Full CRUD for labels, email-label assignments, and auto-label rules. Uses own `initSchema()` (same pattern as tenantstore). Includes `GetLabelsForEmails()` batch helper for search enrichment. +- `internal/api/label_handlers.go` -- All 10 HTTP handlers + `SetLabels()` wiring method. Routes registered in setter (same pattern as SetLDAP/SetTenants). Input validation: color hex format, allowed condition fields, mail ID via SEC-22 regex. +- `internal/storage/migrations/012_labels.sql` -- Reference SQL (actual schema applied by labelstore.initSchema). +- `internal/index/index.go` -- `SearchRequest.LabelID` field added for label filtering. +- `internal/api/server.go` -- Label post-filter in search handler (Go-level, not Xapian CGO). Search results enriched with `label_ids` array via batch query. `labels` field added to Server struct. +- `cmd/archivmail/main.go` -- Labelstore wired via `srv.SetLabels(labelSt)`. + +**Design deviation from spec:** Label filtering uses Go-level post-filter on search results (same pattern as tenant isolation fallback) instead of Xapian boolean terms. Reason: the Xapian integration goes through CGO with a C wrapper; adding label terms would require modifying both the C wrapper and the Go bridge. The post-filter approach is simpler and consistent with existing patterns. + +## QA Test Results +_To be added by /qa_ + +## Deployment +_To be added by /deploy_ diff --git a/internal/api/label_handlers.go b/internal/api/label_handlers.go new file mode 100644 index 0000000..4ff31e1 --- /dev/null +++ b/internal/api/label_handlers.go @@ -0,0 +1,407 @@ +package api + +import ( + "encoding/json" + "net/http" + "strconv" + + "github.com/archivmail/internal/auth" + "github.com/archivmail/internal/labelstore" +) + +// ── PROJ-9: Labels API Handlers ─────────────────────────────────────────── + +// SetLabels wires the label store into the API server and registers label routes. +func (s *Server) SetLabels(store *labelstore.Store) { + s.labels = store + + // User label routes (all authenticated users) + s.mux.HandleFunc("GET /api/labels", s.auth(s.handleGetLabels)) + s.mux.HandleFunc("POST /api/labels", s.auth(s.handleCreateLabel)) + s.mux.HandleFunc("PATCH /api/labels/{id}", s.auth(s.handleUpdateLabel)) + s.mux.HandleFunc("DELETE /api/labels/{id}", s.auth(s.handleDeleteLabel)) + + // Email-label assignment routes + s.mux.HandleFunc("POST /api/mails/{id}/labels", s.auth(s.handleAssignLabel)) + s.mux.HandleFunc("DELETE /api/mails/{id}/labels/{label_id}", s.auth(s.handleRemoveLabel)) + + // Admin label routes (domain_admin and above) + s.mux.HandleFunc("POST /api/admin/labels", s.authAdmin(s.handleAdminCreateLabel)) + s.mux.HandleFunc("GET /api/admin/label-rules", s.authAdmin(s.handleGetLabelRules)) + s.mux.HandleFunc("POST /api/admin/label-rules", s.authAdmin(s.handleCreateLabelRule)) + s.mux.HandleFunc("DELETE /api/admin/label-rules/{id}", s.authAdmin(s.handleDeleteLabelRule)) +} + +// handleGetLabels returns labels visible to the authenticated user +// (own labels + global/admin labels for their tenant). +// GET /api/labels +func (s *Server) handleGetLabels(w http.ResponseWriter, r *http.Request) { + if s.labels == nil { + writeError(w, http.StatusServiceUnavailable, "labels not available") + return + } + sess := sessionFromCtx(r.Context()) + if sess.UserID == 0 { + writeError(w, http.StatusUnauthorized, "not authenticated") + return + } + + tenantID := s.labelTenantID(sess) + labels, err := s.labels.GetLabelsForUser(r.Context(), sess.UserID, tenantID) + if err != nil { + s.logger.Error("get labels failed", "err", err, "user_id", sess.UserID) + writeError(w, http.StatusInternalServerError, "failed to load labels") + return + } + if labels == nil { + labels = []labelstore.Label{} + } + writeJSON(w, http.StatusOK, labels) +} + +// handleCreateLabel creates a user-owned label. +// POST /api/labels +func (s *Server) handleCreateLabel(w http.ResponseWriter, r *http.Request) { + if s.labels == nil { + writeError(w, http.StatusServiceUnavailable, "labels not available") + return + } + sess := sessionFromCtx(r.Context()) + if sess.UserID == 0 { + writeError(w, http.StatusUnauthorized, "not authenticated") + return + } + + var req struct { + Name string `json:"name"` + Color string `json:"color"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if req.Name == "" { + writeError(w, http.StatusBadRequest, "name is required") + return + } + if req.Color == "" { + req.Color = "#6366f1" + } + if !isValidColor(req.Color) { + writeError(w, http.StatusBadRequest, "invalid color format") + return + } + + tenantID := s.labelTenantID(sess) + ownerID := sess.UserID + label, err := s.labels.CreateLabel(r.Context(), req.Name, req.Color, &ownerID, tenantID) + if err != nil { + s.logger.Error("create label failed", "err", err, "user_id", sess.UserID) + writeError(w, http.StatusInternalServerError, "failed to create label") + return + } + writeJSON(w, http.StatusCreated, label) +} + +// handleUpdateLabel updates a user's own label. +// PATCH /api/labels/{id} +func (s *Server) handleUpdateLabel(w http.ResponseWriter, r *http.Request) { + if s.labels == nil { + writeError(w, http.StatusServiceUnavailable, "labels not available") + return + } + sess := sessionFromCtx(r.Context()) + if sess.UserID == 0 { + writeError(w, http.StatusUnauthorized, "not authenticated") + return + } + + labelID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid label id") + return + } + + var req struct { + Name string `json:"name"` + Color string `json:"color"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if req.Name == "" { + writeError(w, http.StatusBadRequest, "name is required") + return + } + if req.Color == "" { + req.Color = "#6366f1" + } + if !isValidColor(req.Color) { + writeError(w, http.StatusBadRequest, "invalid color format") + return + } + + if err := s.labels.UpdateLabel(r.Context(), labelID, req.Name, req.Color, sess.UserID); err != nil { + s.logger.Error("update label failed", "err", err, "label_id", labelID, "user_id", sess.UserID) + writeError(w, http.StatusNotFound, "label not found") + return + } + writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} + +// handleDeleteLabel deletes a user's own label. +// DELETE /api/labels/{id} +func (s *Server) handleDeleteLabel(w http.ResponseWriter, r *http.Request) { + if s.labels == nil { + writeError(w, http.StatusServiceUnavailable, "labels not available") + return + } + sess := sessionFromCtx(r.Context()) + if sess.UserID == 0 { + writeError(w, http.StatusUnauthorized, "not authenticated") + return + } + + labelID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid label id") + return + } + + if err := s.labels.DeleteLabel(r.Context(), labelID, sess.UserID); err != nil { + s.logger.Error("delete label failed", "err", err, "label_id", labelID, "user_id", sess.UserID) + writeError(w, http.StatusNotFound, "label not found") + return + } + writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} + +// handleAssignLabel assigns a label to an email. +// POST /api/mails/{id}/labels +func (s *Server) handleAssignLabel(w http.ResponseWriter, r *http.Request) { + if s.labels == nil { + writeError(w, http.StatusServiceUnavailable, "labels not available") + return + } + sess := sessionFromCtx(r.Context()) + if sess.UserID == 0 { + writeError(w, http.StatusUnauthorized, "not authenticated") + return + } + + mailID := r.PathValue("id") + // SEC-22: Validate mail ID format to prevent path traversal. + if !isValidMailID(mailID) { + writeError(w, http.StatusBadRequest, "invalid mail id") + return + } + + var req struct { + LabelID int64 `json:"label_id"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if req.LabelID <= 0 { + writeError(w, http.StatusBadRequest, "label_id is required") + return + } + + if err := s.labels.AssignLabel(r.Context(), mailID, req.LabelID, "user"); err != nil { + s.logger.Error("assign label failed", "err", err, "mail_id", mailID, "label_id", req.LabelID) + writeError(w, http.StatusInternalServerError, "failed to assign label") + return + } + writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} + +// handleRemoveLabel removes a label assignment from an email. +// DELETE /api/mails/{id}/labels/{label_id} +func (s *Server) handleRemoveLabel(w http.ResponseWriter, r *http.Request) { + if s.labels == nil { + writeError(w, http.StatusServiceUnavailable, "labels not available") + return + } + sess := sessionFromCtx(r.Context()) + if sess.UserID == 0 { + writeError(w, http.StatusUnauthorized, "not authenticated") + return + } + + mailID := r.PathValue("id") + // SEC-22: Validate mail ID format to prevent path traversal. + if !isValidMailID(mailID) { + writeError(w, http.StatusBadRequest, "invalid mail id") + return + } + + labelID, err := strconv.ParseInt(r.PathValue("label_id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid label id") + return + } + + if err := s.labels.RemoveLabel(r.Context(), mailID, labelID); err != nil { + s.logger.Error("remove label failed", "err", err, "mail_id", mailID, "label_id", labelID) + writeError(w, http.StatusInternalServerError, "failed to remove label") + return + } + writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} + +// ── Admin Label Handlers ────────────────────────────────────────────────── + +// handleAdminCreateLabel creates a global label (no owner) for a tenant. +// POST /api/admin/labels +func (s *Server) handleAdminCreateLabel(w http.ResponseWriter, r *http.Request) { + if s.labels == nil { + writeError(w, http.StatusServiceUnavailable, "labels not available") + return + } + sess := sessionFromCtx(r.Context()) + + var req struct { + Name string `json:"name"` + Color string `json:"color"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if req.Name == "" { + writeError(w, http.StatusBadRequest, "name is required") + return + } + if req.Color == "" { + req.Color = "#6366f1" + } + if !isValidColor(req.Color) { + writeError(w, http.StatusBadRequest, "invalid color format") + return + } + + tenantID := s.labelTenantID(sess) + label, err := s.labels.CreateAdminLabel(r.Context(), req.Name, req.Color, tenantID) + if err != nil { + s.logger.Error("admin create label failed", "err", err) + writeError(w, http.StatusInternalServerError, "failed to create label") + return + } + writeJSON(w, http.StatusCreated, label) +} + +// handleGetLabelRules returns all auto-label rules for the admin's tenant. +// GET /api/admin/label-rules +func (s *Server) handleGetLabelRules(w http.ResponseWriter, r *http.Request) { + if s.labels == nil { + writeError(w, http.StatusServiceUnavailable, "labels not available") + return + } + sess := sessionFromCtx(r.Context()) + tenantID := s.labelTenantID(sess) + + rules, err := s.labels.GetLabelRules(r.Context(), tenantID) + if err != nil { + s.logger.Error("get label rules failed", "err", err) + writeError(w, http.StatusInternalServerError, "failed to load label rules") + return + } + if rules == nil { + rules = []labelstore.LabelRule{} + } + writeJSON(w, http.StatusOK, rules) +} + +// handleCreateLabelRule creates an auto-label rule. +// POST /api/admin/label-rules +func (s *Server) handleCreateLabelRule(w http.ResponseWriter, r *http.Request) { + if s.labels == nil { + writeError(w, http.StatusServiceUnavailable, "labels not available") + return + } + sess := sessionFromCtx(r.Context()) + + var req struct { + ConditionField string `json:"condition_field"` + ConditionValue string `json:"condition_value"` + LabelID int64 `json:"label_id"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if req.ConditionField == "" || req.ConditionValue == "" || req.LabelID <= 0 { + writeError(w, http.StatusBadRequest, "condition_field, condition_value, and label_id are required") + return + } + + // Validate allowed condition fields. + allowed := map[string]bool{ + "from": true, "to": true, "subject": true, "domain": true, + "has_attachment": true, + } + if !allowed[req.ConditionField] { + writeError(w, http.StatusBadRequest, "invalid condition_field") + return + } + + tenantID := s.labelTenantID(sess) + rule, err := s.labels.CreateLabelRule(r.Context(), req.ConditionField, req.ConditionValue, req.LabelID, tenantID) + if err != nil { + s.logger.Error("create label rule failed", "err", err) + writeError(w, http.StatusInternalServerError, "failed to create label rule") + return + } + writeJSON(w, http.StatusCreated, rule) +} + +// handleDeleteLabelRule deletes an auto-label rule. +// DELETE /api/admin/label-rules/{id} +func (s *Server) handleDeleteLabelRule(w http.ResponseWriter, r *http.Request) { + if s.labels == nil { + writeError(w, http.StatusServiceUnavailable, "labels not available") + return + } + sess := sessionFromCtx(r.Context()) + + ruleID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid rule id") + return + } + + tenantID := s.labelTenantID(sess) + if err := s.labels.DeleteLabelRule(r.Context(), ruleID, tenantID); err != nil { + s.logger.Error("delete label rule failed", "err", err, "rule_id", ruleID) + writeError(w, http.StatusNotFound, "rule not found") + return + } + writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} + +// ── Helpers ─────────────────────────────────────────────────────────────── + +// labelTenantID extracts the tenant ID from the session for label operations. +// Superadmins without a tenant get tenant_id=0 (global context). +func (s *Server) labelTenantID(sess *auth.Session) int64 { + if sess.TenantID != nil { + return *sess.TenantID + } + return 0 +} + +// isValidColor validates a hex color string like "#6366f1". +func isValidColor(c string) bool { + if len(c) != 7 || c[0] != '#' { + return false + } + for _, b := range c[1:] { + if !((b >= '0' && b <= '9') || (b >= 'a' && b <= 'f') || (b >= 'A' && b <= 'F')) { + return false + } + } + return true +} + diff --git a/internal/api/server.go b/internal/api/server.go index 17f8685..4c63606 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -25,6 +25,7 @@ import ( "github.com/archivmail/internal/auth" imapstore "github.com/archivmail/internal/imap" "github.com/archivmail/internal/index" + "github.com/archivmail/internal/labelstore" ldapcfg "github.com/archivmail/internal/ldapconfig" pop3store "github.com/archivmail/internal/pop3" "github.com/archivmail/internal/smtpd" @@ -79,6 +80,7 @@ type Server struct { pop3Store *pop3store.Store pop3Importer *pop3store.Importer uploadJobs sync.Map // jobID → *UploadJob + labels *labelstore.Store // PROJ-9: label management ldapStore *ldapcfg.Store tenantStore *tenantstore.Store tenantLdapStore *ldapcfg.TenantStore // PROJ-23: per-tenant LDAP config @@ -600,6 +602,7 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { dateToStr := r.URL.Query().Get("date_to") sortParam := r.URL.Query().Get("sort") // "relevance", "date_asc", "date_desc" hasAttachStr := r.URL.Query().Get("has_attachment") // "true" or "false" + labelIDStr := r.URL.Query().Get("label_id") // PROJ-9: filter by label pageStr := r.URL.Query().Get("page") pageSizeStr := r.URL.Query().Get("page_size") @@ -616,6 +619,13 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { Page: page, } + // PROJ-9: Parse label_id filter. + if labelIDStr != "" { + if lid, err := strconv.ParseInt(labelIDStr, 10, 64); err == nil && lid > 0 { + req.LabelID = &lid + } + } + if hasAttachStr == "true" { v := true req.HasAttachment = &v @@ -691,6 +701,25 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { } } + // PROJ-9: Post-filter by label_id when the label store is available. + if req.LabelID != nil && s.labels != nil && len(result.Hits) > 0 { + labelEmailIDs, lErr := s.labels.GetEmailIDsByLabel(r.Context(), *req.LabelID) + if lErr == nil { + allowed := make(map[string]struct{}, len(labelEmailIDs)) + for _, id := range labelEmailIDs { + allowed[id] = struct{}{} + } + filtered := result.Hits[:0] + for _, h := range result.Hits { + if _, ok := allowed[h.ID]; ok { + filtered = append(filtered, h) + } + } + result.Hits = filtered + result.Total = len(filtered) + } + } + sess := sessionFromCtx(r.Context()) s.audlog.Log(audit.Entry{ EventType: audit.EventSearch, @@ -710,7 +739,19 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { Date string `json:"date,omitempty"` Size int64 `json:"size,omitempty"` HasAttachments bool `json:"has_attachments"` + LabelIDs []int64 `json:"label_ids,omitempty"` // PROJ-9 } + + // PROJ-9: Batch-load label IDs for all hits. + var labelMap map[string][]int64 + if s.labels != nil && len(result.Hits) > 0 { + emailIDs := make([]string, len(result.Hits)) + for i, h := range result.Hits { + emailIDs[i] = h.ID + } + labelMap, _ = s.labels.GetLabelsForEmails(r.Context(), emailIDs) + } + enriched := make([]enrichedHit, 0, len(result.Hits)) for _, h := range result.Hits { eh := enrichedHit{ID: h.ID, Score: h.Score} @@ -728,6 +769,9 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { eh.HasAttachments = len(pm.Attachments) > 0 } } + if labelMap != nil { + eh.LabelIDs = labelMap[h.ID] + } enriched = append(enriched, eh) } diff --git a/internal/index/index.go b/internal/index/index.go index a19d8ce..a916fd9 100644 --- a/internal/index/index.go +++ b/internal/index/index.go @@ -28,6 +28,7 @@ type SearchRequest struct { DateFrom *time.Time DateTo *time.Time HasAttachment *bool // nil=no filter, true=only with, false=only without + LabelID *int64 `json:"label_id,omitempty"` // PROJ-9: post-filter by label Sort string // "relevance", "date_asc", "date_desc" (default: date_desc) PageSize int Page int diff --git a/internal/labelstore/store.go b/internal/labelstore/store.go new file mode 100644 index 0000000..5dddd94 --- /dev/null +++ b/internal/labelstore/store.go @@ -0,0 +1,329 @@ +// Package labelstore manages labels, email-label assignments, and auto-label +// rules in PostgreSQL. Part of PROJ-9: Ordner- & Label-Verwaltung. +package labelstore + +import ( + "context" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// Label represents a user-defined or global (admin) label. +type Label struct { + ID int64 `json:"id"` + Name string `json:"name"` + Color string `json:"color"` + OwnerID *int64 `json:"owner_id,omitempty"` + TenantID int64 `json:"tenant_id"` + CreatedAt time.Time `json:"created_at"` + IsGlobal bool `json:"is_global"` +} + +// LabelRule describes an automatic label assignment rule (admin-only). +type LabelRule struct { + ID int64 `json:"id"` + ConditionField string `json:"condition_field"` + ConditionValue string `json:"condition_value"` + LabelID int64 `json:"label_id"` + TenantID int64 `json:"tenant_id"` +} + +// Store is a PostgreSQL-backed label store. +type Store struct { + db *pgxpool.Pool +} + +const schemaSQL = ` +CREATE TABLE IF NOT EXISTS labels ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + color VARCHAR(7) NOT NULL DEFAULT '#6366f1', + owner_id BIGINT REFERENCES users(id) ON DELETE CASCADE, + tenant_id BIGINT REFERENCES tenants(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(name, owner_id, tenant_id) +); + +CREATE TABLE IF NOT EXISTS email_labels ( + email_id VARCHAR(64) NOT NULL, + label_id BIGINT NOT NULL REFERENCES labels(id) ON DELETE CASCADE, + assigned_at TIMESTAMPTZ DEFAULT NOW(), + assigned_by VARCHAR(20) NOT NULL DEFAULT 'user', + PRIMARY KEY (email_id, label_id) +); + +CREATE TABLE IF NOT EXISTS label_rules ( + id BIGSERIAL PRIMARY KEY, + condition_field VARCHAR(30) NOT NULL, + condition_value VARCHAR(255) NOT NULL, + label_id BIGINT NOT NULL REFERENCES labels(id) ON DELETE CASCADE, + tenant_id BIGINT REFERENCES tenants(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_email_labels_label_id ON email_labels(label_id); +CREATE INDEX IF NOT EXISTS idx_email_labels_email_id ON email_labels(email_id); +CREATE INDEX IF NOT EXISTS idx_label_rules_tenant_id ON label_rules(tenant_id); +CREATE INDEX IF NOT EXISTS idx_labels_tenant_id ON labels(tenant_id); +CREATE INDEX IF NOT EXISTS idx_labels_owner_id ON labels(owner_id); +` + +// New connects to PostgreSQL and initialises the labels schema. +func New(dsn string) (*Store, error) { + ctx := context.Background() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + return nil, fmt.Errorf("labelstore: connect: %w", err) + } + + s := &Store{db: pool} + if err := s.initSchema(ctx); err != nil { + pool.Close() + return nil, fmt.Errorf("labelstore: init schema: %w", err) + } + + return s, nil +} + +func (s *Store) initSchema(ctx context.Context) error { + _, err := s.db.Exec(ctx, schemaSQL) + return err +} + +// Close closes the underlying connection pool. +func (s *Store) Close() error { + s.db.Close() + return nil +} + +// ── Label CRUD ──────────────────────────────────────────────────────────── + +// GetLabelsForUser returns the user's own labels plus global labels (owner_id IS NULL) +// for the given tenant. +func (s *Store) GetLabelsForUser(ctx context.Context, userID int64, tenantID int64) ([]Label, error) { + rows, err := s.db.Query(ctx, ` + SELECT id, name, color, owner_id, tenant_id, created_at + FROM labels + WHERE tenant_id = $1 AND (owner_id = $2 OR owner_id IS NULL) + ORDER BY name + `, tenantID, userID) + if err != nil { + return nil, fmt.Errorf("labelstore: get labels: %w", err) + } + defer rows.Close() + + var labels []Label + for rows.Next() { + var l Label + if err := rows.Scan(&l.ID, &l.Name, &l.Color, &l.OwnerID, &l.TenantID, &l.CreatedAt); err != nil { + return nil, fmt.Errorf("labelstore: scan label: %w", err) + } + l.IsGlobal = l.OwnerID == nil + labels = append(labels, l) + } + return labels, rows.Err() +} + +// CreateLabel creates a user-owned label. +func (s *Store) CreateLabel(ctx context.Context, name, color string, ownerID *int64, tenantID int64) (Label, error) { + var l Label + err := s.db.QueryRow(ctx, ` + INSERT INTO labels (name, color, owner_id, tenant_id) + VALUES ($1, $2, $3, $4) + RETURNING id, name, color, owner_id, tenant_id, created_at + `, name, color, ownerID, tenantID).Scan(&l.ID, &l.Name, &l.Color, &l.OwnerID, &l.TenantID, &l.CreatedAt) + if err != nil { + return Label{}, fmt.Errorf("labelstore: create label: %w", err) + } + l.IsGlobal = l.OwnerID == nil + return l, nil +} + +// UpdateLabel updates name and color of a label owned by the given user. +func (s *Store) UpdateLabel(ctx context.Context, labelID int64, name, color string, userID int64) error { + tag, err := s.db.Exec(ctx, ` + UPDATE labels SET name = $1, color = $2 + WHERE id = $3 AND owner_id = $4 + `, name, color, labelID, userID) + if err != nil { + return fmt.Errorf("labelstore: update label: %w", err) + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("labelstore: label not found or not owned by user") + } + return nil +} + +// DeleteLabel deletes a label owned by the given user. +func (s *Store) DeleteLabel(ctx context.Context, labelID int64, userID int64) error { + tag, err := s.db.Exec(ctx, ` + DELETE FROM labels WHERE id = $1 AND owner_id = $2 + `, labelID, userID) + if err != nil { + return fmt.Errorf("labelstore: delete label: %w", err) + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("labelstore: label not found or not owned by user") + } + return nil +} + +// ── Email-Label Assignment ──────────────────────────────────────────────── + +// AssignLabel assigns a label to an email. Duplicate assignments are silently ignored. +func (s *Store) AssignLabel(ctx context.Context, emailID string, labelID int64, assignedBy string) error { + _, err := s.db.Exec(ctx, ` + INSERT INTO email_labels (email_id, label_id, assigned_by) + VALUES ($1, $2, $3) + ON CONFLICT (email_id, label_id) DO NOTHING + `, emailID, labelID, assignedBy) + if err != nil { + return fmt.Errorf("labelstore: assign label: %w", err) + } + return nil +} + +// RemoveLabel removes a label assignment from an email. +func (s *Store) RemoveLabel(ctx context.Context, emailID string, labelID int64) error { + _, err := s.db.Exec(ctx, ` + DELETE FROM email_labels WHERE email_id = $1 AND label_id = $2 + `, emailID, labelID) + if err != nil { + return fmt.Errorf("labelstore: remove label: %w", err) + } + return nil +} + +// GetLabelIDsForEmail returns all label IDs assigned to a given email. +func (s *Store) GetLabelIDsForEmail(ctx context.Context, emailID string) ([]int64, error) { + rows, err := s.db.Query(ctx, ` + SELECT label_id FROM email_labels WHERE email_id = $1 + `, emailID) + if err != nil { + return nil, fmt.Errorf("labelstore: get label ids: %w", err) + } + defer rows.Close() + + var ids []int64 + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, fmt.Errorf("labelstore: scan label id: %w", err) + } + ids = append(ids, id) + } + return ids, rows.Err() +} + +// GetEmailIDsByLabel returns all email IDs that have a specific label assigned. +func (s *Store) GetEmailIDsByLabel(ctx context.Context, labelID int64) ([]string, error) { + rows, err := s.db.Query(ctx, ` + SELECT email_id FROM email_labels WHERE label_id = $1 + `, labelID) + if err != nil { + return nil, fmt.Errorf("labelstore: get email ids by label: %w", err) + } + defer rows.Close() + + var ids []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, fmt.Errorf("labelstore: scan email id: %w", err) + } + ids = append(ids, id) + } + return ids, rows.Err() +} + +// ── Admin Labels (Global) ───────────────────────────────────────────────── + +// CreateAdminLabel creates a global label (owner_id = NULL) for a tenant. +func (s *Store) CreateAdminLabel(ctx context.Context, name, color string, tenantID int64) (Label, error) { + return s.CreateLabel(ctx, name, color, nil, tenantID) +} + +// ── Label Rules ─────────────────────────────────────────────────────────── + +// GetLabelRules returns all auto-label rules for a tenant. +func (s *Store) GetLabelRules(ctx context.Context, tenantID int64) ([]LabelRule, error) { + rows, err := s.db.Query(ctx, ` + SELECT id, condition_field, condition_value, label_id, tenant_id + FROM label_rules + WHERE tenant_id = $1 + ORDER BY id + `, tenantID) + if err != nil { + return nil, fmt.Errorf("labelstore: get rules: %w", err) + } + defer rows.Close() + + var rules []LabelRule + for rows.Next() { + var r LabelRule + if err := rows.Scan(&r.ID, &r.ConditionField, &r.ConditionValue, &r.LabelID, &r.TenantID); err != nil { + return nil, fmt.Errorf("labelstore: scan rule: %w", err) + } + rules = append(rules, r) + } + return rules, rows.Err() +} + +// CreateLabelRule creates an auto-label rule for a tenant. +func (s *Store) CreateLabelRule(ctx context.Context, field, value string, labelID int64, tenantID int64) (LabelRule, error) { + var r LabelRule + err := s.db.QueryRow(ctx, ` + INSERT INTO label_rules (condition_field, condition_value, label_id, tenant_id) + VALUES ($1, $2, $3, $4) + RETURNING id, condition_field, condition_value, label_id, tenant_id + `, field, value, labelID, tenantID).Scan(&r.ID, &r.ConditionField, &r.ConditionValue, &r.LabelID, &r.TenantID) + if err != nil { + return LabelRule{}, fmt.Errorf("labelstore: create rule: %w", err) + } + return r, nil +} + +// DeleteLabelRule deletes an auto-label rule within a tenant. +func (s *Store) DeleteLabelRule(ctx context.Context, ruleID int64, tenantID int64) error { + tag, err := s.db.Exec(ctx, ` + DELETE FROM label_rules WHERE id = $1 AND tenant_id = $2 + `, ruleID, tenantID) + if err != nil { + return fmt.Errorf("labelstore: delete rule: %w", err) + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("labelstore: rule not found") + } + return nil +} + +// ── Batch Helpers ───────────────────────────────────────────────────────── + +// GetLabelsForEmails returns a map of email_id -> []int64 (label IDs) for a +// batch of email IDs. Useful for enriching search results. +func (s *Store) GetLabelsForEmails(ctx context.Context, emailIDs []string) (map[string][]int64, error) { + if len(emailIDs) == 0 { + return nil, nil + } + rows, err := s.db.Query(ctx, ` + SELECT email_id, label_id FROM email_labels WHERE email_id = ANY($1) + `, emailIDs) + if err != nil { + return nil, fmt.Errorf("labelstore: get labels for emails: %w", err) + } + defer rows.Close() + + result := make(map[string][]int64) + for rows.Next() { + var emailID string + var labelID int64 + if err := rows.Scan(&emailID, &labelID); err != nil { + return nil, fmt.Errorf("labelstore: scan: %w", err) + } + result[emailID] = append(result[emailID], labelID) + } + return result, rows.Err() +} diff --git a/internal/storage/migrations/012_labels.sql b/internal/storage/migrations/012_labels.sql new file mode 100644 index 0000000..ba38858 --- /dev/null +++ b/internal/storage/migrations/012_labels.sql @@ -0,0 +1,32 @@ +-- PROJ-9: Labels, email-label assignments, and auto-label rules. +-- This file is documentation; the actual schema is applied by labelstore.initSchema(). + +CREATE TABLE IF NOT EXISTS labels ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + color VARCHAR(7) NOT NULL DEFAULT '#6366f1', + owner_id BIGINT REFERENCES users(id) ON DELETE CASCADE, + tenant_id BIGINT REFERENCES tenants(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(name, owner_id, tenant_id) +); + +CREATE TABLE IF NOT EXISTS email_labels ( + email_id VARCHAR(64) NOT NULL, + label_id BIGINT NOT NULL REFERENCES labels(id) ON DELETE CASCADE, + assigned_at TIMESTAMPTZ DEFAULT NOW(), + assigned_by VARCHAR(20) NOT NULL DEFAULT 'user', + PRIMARY KEY (email_id, label_id) +); + +CREATE TABLE IF NOT EXISTS label_rules ( + id BIGSERIAL PRIMARY KEY, + condition_field VARCHAR(30) NOT NULL, + condition_value VARCHAR(255) NOT NULL, + label_id BIGINT NOT NULL REFERENCES labels(id) ON DELETE CASCADE, + tenant_id BIGINT REFERENCES tenants(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_email_labels_label_id ON email_labels(label_id); +CREATE INDEX IF NOT EXISTS idx_label_rules_tenant_id ON label_rules(tenant_id);