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("GET /api/admin/labels", s.authAdmin(s.handleGetAdminLabels)) 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.labelTenantIDInt64(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 } tenantIDPtr := s.labelTenantID(sess) if tenantIDPtr == nil { writeError(w, http.StatusBadRequest, "superadmin cannot create personal labels without a tenant") return } ownerID := sess.UserID label, err := s.labels.CreateLabel(r.Context(), req.Name, req.Color, &ownerID, *tenantIDPtr) 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 ────────────────────────────────────────────────── // handleGetAdminLabels returns all global labels (owner_id IS NULL) for the admin's tenant. // Superadmins (no tenant) see labels where tenant_id IS NULL. // GET /api/admin/labels func (s *Server) handleGetAdminLabels(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) labels, err := s.labels.GetAdminLabels(r.Context(), tenantID) if err != nil { s.logger.Error("get admin labels failed", "err", err) writeError(w, http.StatusInternalServerError, "failed to load labels") return } if labels == nil { labels = []labelstore.Label{} } writeJSON(w, http.StatusOK, labels) } // 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.labelTenantIDInt64(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, "from_domain": true, "has_attachment": true, } if !allowed[req.ConditionField] { writeError(w, http.StatusBadRequest, "invalid condition_field") return } tenantIDPtr := s.labelTenantID(sess) if tenantIDPtr == nil { writeError(w, http.StatusBadRequest, "superadmin cannot create label rules without a tenant") return } rule, err := s.labels.CreateLabelRule(r.Context(), req.ConditionField, req.ConditionValue, req.LabelID, *tenantIDPtr) 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.labelTenantIDInt64(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. // Returns nil for superadmins without a tenant (stored as NULL in DB). func (s *Server) labelTenantID(sess *auth.Session) *int64 { return sess.TenantID } // labelTenantIDInt64 returns tenant_id as int64 (0 for superadmin with no tenant). // Only use for read queries where a zero result is acceptable (returns empty list). func (s *Server) labelTenantIDInt64(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 }