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:
sysops
2026-03-18 08:32:30 +01:00
parent bd09992441
commit 5a6289c83d
8 changed files with 995 additions and 2 deletions
+407
View File
@@ -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
}
+44
View File
@@ -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)
}