feat(PROJ-9): implement labels backend - DB schema, labelstore, API handlers, Xapian integration
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,7 @@ import (
|
|||||||
"github.com/archivmail/internal/auth"
|
"github.com/archivmail/internal/auth"
|
||||||
imapstore "github.com/archivmail/internal/imap"
|
imapstore "github.com/archivmail/internal/imap"
|
||||||
"github.com/archivmail/internal/index"
|
"github.com/archivmail/internal/index"
|
||||||
|
"github.com/archivmail/internal/labelstore"
|
||||||
ldapcfg "github.com/archivmail/internal/ldapconfig"
|
ldapcfg "github.com/archivmail/internal/ldapconfig"
|
||||||
pop3store "github.com/archivmail/internal/pop3"
|
pop3store "github.com/archivmail/internal/pop3"
|
||||||
"github.com/archivmail/internal/smtpd"
|
"github.com/archivmail/internal/smtpd"
|
||||||
@@ -191,6 +192,15 @@ func main() {
|
|||||||
srv.SetTenants(tenantSt)
|
srv.SetTenants(tenantSt)
|
||||||
srv.SetIndexManager(idxMgr)
|
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
|
// Start SMTP daemon with index worker integration
|
||||||
if cfg.SMTP.Bind == "" {
|
if cfg.SMTP.Bind == "" {
|
||||||
cfg.SMTP.Bind = fmt.Sprintf(":%d", cfg.Server.SMTPPort)
|
cfg.SMTP.Bind = fmt.Sprintf(":%d", cfg.Server.SMTPPort)
|
||||||
|
|||||||
+2
-2
@@ -20,7 +20,7 @@
|
|||||||
| PROJ-6 | Volltext-Suche & Filterung | Deployed | [PROJ-6](PROJ-6-volltext-suche.md) | 2026-03-12 |
|
| 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-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-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-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-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 |
|
| 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-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-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-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-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 |
|
| PROJ-25 | User-Profil & Einstellungen | Deployed | [PROJ-25](PROJ-25-user-profil-einstellungen.md) | 2026-03-18 |
|
||||||
|
|
||||||
|
|||||||
@@ -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:<label_id>" 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_
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
@@ -25,6 +25,7 @@ import (
|
|||||||
"github.com/archivmail/internal/auth"
|
"github.com/archivmail/internal/auth"
|
||||||
imapstore "github.com/archivmail/internal/imap"
|
imapstore "github.com/archivmail/internal/imap"
|
||||||
"github.com/archivmail/internal/index"
|
"github.com/archivmail/internal/index"
|
||||||
|
"github.com/archivmail/internal/labelstore"
|
||||||
ldapcfg "github.com/archivmail/internal/ldapconfig"
|
ldapcfg "github.com/archivmail/internal/ldapconfig"
|
||||||
pop3store "github.com/archivmail/internal/pop3"
|
pop3store "github.com/archivmail/internal/pop3"
|
||||||
"github.com/archivmail/internal/smtpd"
|
"github.com/archivmail/internal/smtpd"
|
||||||
@@ -79,6 +80,7 @@ type Server struct {
|
|||||||
pop3Store *pop3store.Store
|
pop3Store *pop3store.Store
|
||||||
pop3Importer *pop3store.Importer
|
pop3Importer *pop3store.Importer
|
||||||
uploadJobs sync.Map // jobID → *UploadJob
|
uploadJobs sync.Map // jobID → *UploadJob
|
||||||
|
labels *labelstore.Store // PROJ-9: label management
|
||||||
ldapStore *ldapcfg.Store
|
ldapStore *ldapcfg.Store
|
||||||
tenantStore *tenantstore.Store
|
tenantStore *tenantstore.Store
|
||||||
tenantLdapStore *ldapcfg.TenantStore // PROJ-23: per-tenant LDAP config
|
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")
|
dateToStr := r.URL.Query().Get("date_to")
|
||||||
sortParam := r.URL.Query().Get("sort") // "relevance", "date_asc", "date_desc"
|
sortParam := r.URL.Query().Get("sort") // "relevance", "date_asc", "date_desc"
|
||||||
hasAttachStr := r.URL.Query().Get("has_attachment") // "true" or "false"
|
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")
|
pageStr := r.URL.Query().Get("page")
|
||||||
pageSizeStr := r.URL.Query().Get("page_size")
|
pageSizeStr := r.URL.Query().Get("page_size")
|
||||||
|
|
||||||
@@ -616,6 +619,13 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
|||||||
Page: page,
|
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" {
|
if hasAttachStr == "true" {
|
||||||
v := true
|
v := true
|
||||||
req.HasAttachment = &v
|
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())
|
sess := sessionFromCtx(r.Context())
|
||||||
s.audlog.Log(audit.Entry{
|
s.audlog.Log(audit.Entry{
|
||||||
EventType: audit.EventSearch,
|
EventType: audit.EventSearch,
|
||||||
@@ -710,7 +739,19 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
|||||||
Date string `json:"date,omitempty"`
|
Date string `json:"date,omitempty"`
|
||||||
Size int64 `json:"size,omitempty"`
|
Size int64 `json:"size,omitempty"`
|
||||||
HasAttachments bool `json:"has_attachments"`
|
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))
|
enriched := make([]enrichedHit, 0, len(result.Hits))
|
||||||
for _, h := range result.Hits {
|
for _, h := range result.Hits {
|
||||||
eh := enrichedHit{ID: h.ID, Score: h.Score}
|
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
|
eh.HasAttachments = len(pm.Attachments) > 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if labelMap != nil {
|
||||||
|
eh.LabelIDs = labelMap[h.ID]
|
||||||
|
}
|
||||||
enriched = append(enriched, eh)
|
enriched = append(enriched, eh)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ type SearchRequest struct {
|
|||||||
DateFrom *time.Time
|
DateFrom *time.Time
|
||||||
DateTo *time.Time
|
DateTo *time.Time
|
||||||
HasAttachment *bool // nil=no filter, true=only with, false=only without
|
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)
|
Sort string // "relevance", "date_asc", "date_desc" (default: date_desc)
|
||||||
PageSize int
|
PageSize int
|
||||||
Page int
|
Page int
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
Reference in New Issue
Block a user