diff --git a/internal/api/label_handlers.go b/internal/api/label_handlers.go index 4ff31e1..7160825 100644 --- a/internal/api/label_handlers.go +++ b/internal/api/label_handlers.go @@ -26,6 +26,7 @@ func (s *Server) SetLabels(store *labelstore.Store) { 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)) @@ -46,7 +47,7 @@ func (s *Server) handleGetLabels(w http.ResponseWriter, r *http.Request) { return } - tenantID := s.labelTenantID(sess) + 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) @@ -92,9 +93,13 @@ func (s *Server) handleCreateLabel(w http.ResponseWriter, r *http.Request) { return } - tenantID := s.labelTenantID(sess) + 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, tenantID) + 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") @@ -253,6 +258,29 @@ func (s *Server) handleRemoveLabel(w http.ResponseWriter, r *http.Request) { // ── 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) { @@ -300,7 +328,7 @@ func (s *Server) handleGetLabelRules(w http.ResponseWriter, r *http.Request) { return } sess := sessionFromCtx(r.Context()) - tenantID := s.labelTenantID(sess) + tenantID := s.labelTenantIDInt64(sess) rules, err := s.labels.GetLabelRules(r.Context(), tenantID) if err != nil { @@ -340,15 +368,19 @@ func (s *Server) handleCreateLabelRule(w http.ResponseWriter, r *http.Request) { // Validate allowed condition fields. allowed := map[string]bool{ "from": true, "to": true, "subject": true, "domain": true, - "has_attachment": true, + "from_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) + 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") @@ -372,7 +404,7 @@ func (s *Server) handleDeleteLabelRule(w http.ResponseWriter, r *http.Request) { return } - tenantID := s.labelTenantID(sess) + 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") @@ -384,8 +416,14 @@ func (s *Server) handleDeleteLabelRule(w http.ResponseWriter, r *http.Request) { // ── 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 { +// 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 } diff --git a/internal/labelstore/store.go b/internal/labelstore/store.go index 7b91b20..1272532 100644 --- a/internal/labelstore/store.go +++ b/internal/labelstore/store.go @@ -241,8 +241,56 @@ func (s *Store) GetEmailIDsByLabel(ctx context.Context, labelID int64) ([]string // ── 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) +// tenantID may be nil for superadmin-global labels (stored as NULL). +func (s *Store) CreateAdminLabel(ctx context.Context, name, color string, tenantID *int64) (Label, error) { + var l Label + err := s.db.QueryRow(ctx, ` + INSERT INTO labels (name, color, owner_id, tenant_id) + VALUES ($1, $2, NULL, $3) + RETURNING id, name, color, owner_id, tenant_id, created_at + `, name, color, tenantID).Scan(&l.ID, &l.Name, &l.Color, &l.OwnerID, &l.TenantID, &l.CreatedAt) + if err != nil { + return Label{}, fmt.Errorf("labelstore: create admin label: %w", err) + } + l.IsGlobal = true + return l, nil +} + +// GetAdminLabels returns all global labels (owner_id IS NULL) for superadmin +// (tenantID == nil) or for a specific tenant. +func (s *Store) GetAdminLabels(ctx context.Context, tenantID *int64) ([]Label, error) { + var rows interface{ Next() bool; Scan(...any) error; Close(); Err() error } + var err error + if tenantID == nil { + rows, err = s.db.Query(ctx, ` + SELECT id, name, color, owner_id, tenant_id, created_at + FROM labels + WHERE owner_id IS NULL AND tenant_id IS NULL + ORDER BY name + `) + } else { + rows, err = s.db.Query(ctx, ` + SELECT id, name, color, owner_id, tenant_id, created_at + FROM labels + WHERE owner_id IS NULL AND tenant_id = $1 + ORDER BY name + `, *tenantID) + } + if err != nil { + return nil, fmt.Errorf("labelstore: get admin 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 admin label: %w", err) + } + l.IsGlobal = true + labels = append(labels, l) + } + return labels, rows.Err() } // ── Label Rules ───────────────────────────────────────────────────────────