diff --git a/cmd/archivmail/main.go b/cmd/archivmail/main.go index 0b6cbd2..0277c4b 100644 --- a/cmd/archivmail/main.go +++ b/cmd/archivmail/main.go @@ -27,7 +27,6 @@ import ( imapstore "github.com/archivmail/internal/imap" "github.com/archivmail/internal/imapserver" "github.com/archivmail/internal/index" - "github.com/archivmail/internal/labelstore" ldapcfg "github.com/archivmail/internal/ldapconfig" "github.com/archivmail/internal/mailer" pop3store "github.com/archivmail/internal/pop3" @@ -264,19 +263,10 @@ 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) - // PROJ-26: IMAP Archive Server (read-only access for IMAP clients) if cfg.IMAPServer.Enabled { cfg.IMAPServer.FQDN = cfg.Server.FQDN - imapSrv := imapserver.New(cfg.IMAPServer, mailStore, users, labelSt, audlog, authMgr, logger, tenantSt) + imapSrv := imapserver.New(cfg.IMAPServer, mailStore, users, audlog, authMgr, logger, tenantSt) if err := imapSrv.Start(); err != nil { logger.Error("IMAP server failed to start", "err", err) os.Exit(1) diff --git a/cmd/archivmail/version.go b/cmd/archivmail/version.go index 23ffb4e..841b955 100644 --- a/cmd/archivmail/version.go +++ b/cmd/archivmail/version.go @@ -17,11 +17,10 @@ var Modules = map[string]string{ "imapserver": "1.3", // PROJ-28 FQDN-Greeting (RFC 3501) "auth": "1.3", // JWT, bcrypt cost 12, TOTP "audit": "1.1", // PostgreSQL append-only, QueryFilter - "index": "1.0", // Xapian-Wrapper, Async-Worker, Tenant-Index + "index": "1.1", // Manticore RT-Index, Async-Worker, Tenant-Index "api": "1.8", // PROJ-34 Retention-API + pro-Mandant Endpoints "userstore": "1.3", // domain_auditor Rolle, bcrypt, LDAP-Auth "imap": "1.2", // IMAP-Sync, Scheduler, POP3 - "labelstore": "1.0", // Labels, Tenant-Isolation "tenantstore": "1.3", // PROJ-34 retention_days, GetRetentionDays, SetRetentionDays "ldapconfig": "1.1", // Pro-Mandant LDAP, TLS "mailparser": "1.1", // RFC-2822, MIME, MessageID-Extraktion diff --git a/features/INDEX.md b/features/INDEX.md index 54e6aeb..bcd4800 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 | Deployed | [PROJ-9](PROJ-9-ordner-und-labels.md) | 2026-03-12 | +| PROJ-9 | Ordner- & Label-Verwaltung | Removed | [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 | diff --git a/features/PROJ-9-ordner-und-labels.md b/features/PROJ-9-ordner-und-labels.md index df3a786..e62ce4e 100644 --- a/features/PROJ-9-ordner-und-labels.md +++ b/features/PROJ-9-ordner-und-labels.md @@ -1,6 +1,6 @@ # PROJ-9: Ordner- & Label-Verwaltung -## Status: Deployed +## Status: Removed **Created:** 2026-03-12 **Last Updated:** 2026-04-04 diff --git a/internal/api/label_handlers.go b/internal/api/label_handlers.go deleted file mode 100644 index 7160825..0000000 --- a/internal/api/label_handlers.go +++ /dev/null @@ -1,445 +0,0 @@ -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 -} - diff --git a/internal/api/search_handlers.go b/internal/api/search_handlers.go index a9a7fba..848e31c 100644 --- a/internal/api/search_handlers.go +++ b/internal/api/search_handlers.go @@ -22,7 +22,6 @@ 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") @@ -39,13 +38,6 @@ 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 @@ -131,25 +123,6 @@ 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) - } - } - s.audlog.Log(audit.Entry{ EventType: audit.EventSearch, Username: sess.Username, @@ -168,17 +141,6 @@ 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) } // auditor role: restrict results to mails with no tenant assignment. @@ -238,9 +200,6 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { } } } - if labelMap != nil { - eh.LabelIDs = labelMap[h.ID] - } enriched = append(enriched, eh) } diff --git a/internal/api/server.go b/internal/api/server.go index 9c53a65..925a88c 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -16,7 +16,6 @@ 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" "github.com/archivmail/internal/mailer" pop3store "github.com/archivmail/internal/pop3" @@ -76,7 +75,6 @@ type Server struct { pop3Store *pop3store.Store pop3Importer *pop3store.Importer uploadJobs sync.Map // jobID → *UploadJob - labels *labelstore.Store ldapStore *ldapcfg.Store tenantStore *tenantstore.Store tenantLdapStore *ldapcfg.TenantStore diff --git a/internal/imapserver/server.go b/internal/imapserver/server.go index 395e438..90944e7 100644 --- a/internal/imapserver/server.go +++ b/internal/imapserver/server.go @@ -21,7 +21,6 @@ import ( "github.com/archivmail/config" "github.com/archivmail/internal/audit" "github.com/archivmail/internal/auth" - "github.com/archivmail/internal/labelstore" "github.com/archivmail/internal/storage" "github.com/archivmail/internal/userstore" "github.com/archivmail/pkg/mailparser" @@ -44,7 +43,6 @@ type Server struct { cfg config.IMAPServerConfig mailStore *storage.Store users *userstore.Store - labels *labelstore.Store audit *audit.Logger authMgr *auth.Manager logger *slog.Logger @@ -66,7 +64,6 @@ func New( cfg config.IMAPServerConfig, mailStore *storage.Store, users *userstore.Store, - labels *labelstore.Store, auditLog *audit.Logger, authMgr *auth.Manager, logger *slog.Logger, @@ -76,7 +73,6 @@ func New( cfg: cfg, mailStore: mailStore, users: users, - labels: labels, audit: auditLog, authMgr: authMgr, logger: logger, @@ -428,20 +424,8 @@ func (sess *session) cmdList(tag string, args string) { ref, pattern := parseListArgs(args) _ = ref - // Build mailbox list: INBOX + label-based sub-folders mailboxes := []string{"INBOX"} - if sess.tenantID != nil { - labels, err := sess.server.labels.GetLabelsForUser( - context.Background(), sess.userID, *sess.tenantID, - ) - if err == nil { - for _, l := range labels { - mailboxes = append(mailboxes, "INBOX/"+l.Name) - } - } - } - for _, mbox := range mailboxes { if matchMailbox(pattern, mbox) { attrs := "" @@ -728,47 +712,13 @@ func (sess *session) loadMailsForMailbox(mailbox string) ([]mailEntry, error) { return nil, fmt.Errorf("load mails: %w", err) } - // Label filter setup - var filterLabelID *int64 - if strings.HasPrefix(mailbox, "INBOX/") { - labelName := strings.TrimPrefix(mailbox, "INBOX/") - if sess.tenantID != nil { - labels, err := sess.server.labels.GetLabelsForUser(ctx, sess.userID, *sess.tenantID) - if err == nil { - for _, l := range labels { - if l.Name == labelName { - lid := l.ID - filterLabelID = &lid - break - } - } - } - } - if filterLabelID == nil { - return nil, fmt.Errorf("label not found: %s", mailbox) - } - } else if mailbox != "INBOX" { + if mailbox != "INBOX" { return nil, fmt.Errorf("unknown mailbox: %s", mailbox) } - var labelEmailIDs map[string]bool - if filterLabelID != nil { - emailIDs, err := sess.server.labels.GetEmailIDsByLabel(ctx, *filterLabelID) - if err != nil { - return nil, fmt.Errorf("load label emails: %w", err) - } - labelEmailIDs = make(map[string]bool, len(emailIDs)) - for _, eid := range emailIDs { - labelEmailIDs[eid] = true - } - } - var entries []mailEntry var seqNum uint32 = 1 for _, m := range rawMails { - if labelEmailIDs != nil && !labelEmailIDs[m.ID] { - continue - } uid := uint32(m.UID) if uid == 0 { uid = seqNum // fallback if no UID in DB yet diff --git a/internal/index/index.go b/internal/index/index.go index 6561b7c..73654e8 100644 --- a/internal/index/index.go +++ b/internal/index/index.go @@ -28,7 +28,6 @@ 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 deleted file mode 100644 index 1272532..0000000 --- a/internal/labelstore/store.go +++ /dev/null @@ -1,376 +0,0 @@ -// 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/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. -// 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 ─────────────────────────────────────────────────────────── - -// 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/src/app/admin/page.tsx b/src/app/admin/page.tsx index 02f74ea..8b95e93 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -28,12 +28,6 @@ import { getTenantDomains, addTenantDomain, removeTenantDomain, - getAdminLabels, - createAdminLabel, - deleteAdminLabel, - getLabelRules, - createLabelRule, - deleteLabelRule, getTenantLogoUrl, uploadTenantLogo, deleteTenantLogo, @@ -50,8 +44,6 @@ import { type Tenant, type TenantDefaultUser, type TenantDomain, - type MailLabel, - type LabelRule, getCertInfo, uploadCert, generateSelfSignedCert, @@ -72,7 +64,6 @@ import { SecurityTab } from "@/components/admin/tabs/SecurityTab"; import { LDAPTab } from "@/components/admin/tabs/LDAPTab"; import { TenantLDAPTab } from "@/components/admin/tabs/TenantLDAPTab"; import { TenantsTab } from "@/components/admin/tabs/TenantsTab"; -import { LabelsTab } from "@/components/admin/tabs/LabelsTab"; import { CertTab } from "@/components/admin/tabs/CertTab"; import { ModulesTab } from "@/components/admin/ModulesTab"; import { IMAPSettingsTab } from "@/components/admin/tabs/IMAPSettingsTab"; @@ -235,19 +226,6 @@ export default function AdminPage() { handleSyncLDAPUsers, } = useTenantUsers(); - // Labels state - const [adminLabels, setAdminLabels] = useState([]); - const [adminLabelsLoading, setAdminLabelsLoading] = useState(false); - const [adminLabelsError, setAdminLabelsError] = useState(""); - const [newLabelName, setNewLabelName] = useState(""); - const [newLabelColor, setNewLabelColor] = useState("#ef4444"); - const [labelCreating, setLabelCreating] = useState(false); - const [labelRules, setLabelRules] = useState([]); - const [labelRulesLoading, setLabelRulesLoading] = useState(false); - const [newRuleField, setNewRuleField] = useState("from_domain"); - const [newRuleValue, setNewRuleValue] = useState(""); - const [newRuleLabelId, setNewRuleLabelId] = useState(null); - const [ruleCreating, setRuleCreating] = useState(false); // Certificate state const [certInfo, setCertInfo] = useState(null); @@ -535,87 +513,6 @@ export default function AdminPage() { } }, []); - const loadAdminLabels = useCallback(async () => { - setAdminLabelsLoading(true); - setAdminLabelsError(""); - try { - const data = await getAdminLabels(); - setAdminLabels(data || []); - } catch { - setAdminLabelsError("Labels konnten nicht geladen werden."); - } finally { - setAdminLabelsLoading(false); - } - }, []); - - const loadLabelRules = useCallback(async () => { - setLabelRulesLoading(true); - try { - const data = await getLabelRules(); - setLabelRules(data || []); - } catch { - // ignore - } finally { - setLabelRulesLoading(false); - } - }, []); - - function loadLabelsTab() { - loadAdminLabels(); - loadLabelRules(); - } - - async function handleCreateAdminLabel(e: React.FormEvent) { - e.preventDefault(); - if (!newLabelName.trim()) return; - setLabelCreating(true); - try { - await createAdminLabel(newLabelName.trim(), newLabelColor); - setNewLabelName(""); - setNewLabelColor("#ef4444"); - await loadAdminLabels(); - } catch (err) { - setAdminLabelsError(err instanceof Error ? err.message : "Fehler"); - } finally { - setLabelCreating(false); - } - } - - async function handleDeleteAdminLabel(id: number, name: string) { - if (!window.confirm(`Globales Label "${name}" wirklich loeschen?`)) return; - try { - await deleteAdminLabel(id); - await loadAdminLabels(); - await loadLabelRules(); - } catch (err) { - setAdminLabelsError(err instanceof Error ? err.message : "Fehler"); - } - } - - async function handleCreateRule(e: React.FormEvent) { - e.preventDefault(); - if (!newRuleValue.trim() || !newRuleLabelId) return; - setRuleCreating(true); - try { - await createLabelRule(newRuleField, newRuleValue.trim(), newRuleLabelId); - setNewRuleValue(""); - await loadLabelRules(); - } catch (err) { - setAdminLabelsError(err instanceof Error ? err.message : "Fehler"); - } finally { - setRuleCreating(false); - } - } - - async function handleDeleteRule(id: number) { - if (!window.confirm("Regel wirklich loeschen?")) return; - try { - await deleteLabelRule(id); - await loadLabelRules(); - } catch { - // ignore - } - } async function handleCreateTenant(e: React.FormEvent) { e.preventDefault(); @@ -806,7 +703,6 @@ export default function AdminPage() { {!isSuperAdmin && user?.role === "domain_admin" && ( IMAP )} - {isSuperAdmin && Labels} {isSuperAdmin && Security} {isSuperAdmin && Zertifikat} {isSuperAdmin && Mandanten} @@ -957,34 +853,6 @@ export default function AdminPage() { )} - {isSuperAdmin && ( - - - - )} - {isSuperAdmin && ( ([]); - const [assignedLabelIds, setAssignedLabelIds] = useState([]); - useEffect(() => { if (!user) return; getMail(id) @@ -272,16 +264,9 @@ export default function MailViewPage({ setError(e instanceof Error ? e.message : "Unbekannter Fehler") ) .finally(() => setLoading(false)); - // Load labels - getLabels().then(setAllLabels).catch(() => {}); - loadMailLabels(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [id, user]); - function loadMailLabels() { - getMailLabelIds(id).then(setAssignedLabelIds).catch(() => setAssignedLabelIds([])); - } - async function handleEmlDownload() { setDownloading(true); try { @@ -380,20 +365,6 @@ export default function MailViewPage({ - {/* Labels */} - {allLabels.length > 0 && ( - - - - - - )} - {/* Body */} diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index db4a335..78f7a60 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -5,7 +5,6 @@ import { useRouter } from "next/navigation"; import { useAuth } from "@/hooks/useAuth"; import { searchEmails, exportMailsZIP, uploadMailFilesUser, getUploadProgressUser, type SearchHit, type UploadJob } from "@/lib/api"; import { Navbar } from "@/components/navbar"; -import { LabelList } from "@/components/LabelList"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Card, CardContent } from "@/components/ui/card"; @@ -57,7 +56,6 @@ export default function SearchPage() { const [dateTo, setDateTo] = useState(""); const [sort, setSort] = useState("date_desc"); const [hasAttachment, setHasAttachment] = useState(undefined); - const [selectedLabelId, setSelectedLabelId] = useState(null); const [results, setResults] = useState([]); const [total, setTotal] = useState(0); @@ -96,7 +94,6 @@ export default function SearchPage() { date_to: dateTo || undefined, sort: sort !== "date_desc" ? sort : undefined, has_attachment: hasAttachment, - label_id: selectedLabelId ?? undefined, page: p, page_size: PAGE_SIZE, }); @@ -111,7 +108,7 @@ export default function SearchPage() { setSearching(false); } }, - [query, fromFilter, toFilter, dateFrom, dateTo, sort, hasAttachment, selectedLabelId] + [query, fromFilter, toFilter, dateFrom, dateTo, sort, hasAttachment] ); // Superadmin has no mail access — redirect to admin dashboard @@ -139,13 +136,6 @@ export default function SearchPage() { .finally(() => setSearching(false)); }, [user]); - // Re-search when label selection changes - useEffect(() => { - if (!user || !searched) return; - doSearch(1); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedLabelId]); - function handleSubmit(e: React.FormEvent) { e.preventDefault(); doSearch(1); @@ -234,16 +224,6 @@ export default function SearchPage() { )} {!authLoading && user && (<>
- {/* Label sidebar — nur für Rollen mit eigenen Mails */} - {user.role !== "auditor" && user.role !== "domain_auditor" && ( -
- -
- )} - {/* Main content */}
diff --git a/src/components/LabelList.tsx b/src/components/LabelList.tsx deleted file mode 100644 index ad3ba9b..0000000 --- a/src/components/LabelList.tsx +++ /dev/null @@ -1,308 +0,0 @@ -"use client"; - -import { useState, useEffect, useCallback } from "react"; -import { - getLabels, - createLabel, - updateLabel, - deleteLabel, - type MailLabel, -} from "@/lib/api"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Skeleton } from "@/components/ui/skeleton"; - -const LABEL_COLORS = [ - "#ef4444", - "#f97316", - "#eab308", - "#22c55e", - "#06b6d4", - "#6366f1", - "#a855f7", - "#ec4899", -]; - -interface LabelListProps { - selectedLabelId?: number | null; - onLabelSelect: (id: number | null) => void; -} - -export function LabelList({ selectedLabelId, onLabelSelect }: LabelListProps) { - const [labels, setLabels] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(""); - - // Create form - const [showCreate, setShowCreate] = useState(false); - const [newName, setNewName] = useState(""); - const [newColor, setNewColor] = useState(LABEL_COLORS[0]); - const [creating, setCreating] = useState(false); - - // Edit state - const [editId, setEditId] = useState(null); - const [editName, setEditName] = useState(""); - const [editColor, setEditColor] = useState(""); - const [saving, setSaving] = useState(false); - - // Hover state - const [hoveredId, setHoveredId] = useState(null); - - const loadLabels = useCallback(async () => { - try { - const data = await getLabels(); - setLabels(data || []); - setError(""); - } catch (e) { - setError(e instanceof Error ? e.message : "Fehler beim Laden"); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - loadLabels(); - }, [loadLabels]); - - async function handleCreate(e: React.FormEvent) { - e.preventDefault(); - if (!newName.trim()) return; - setCreating(true); - try { - await createLabel(newName.trim(), newColor); - setNewName(""); - setNewColor(LABEL_COLORS[0]); - setShowCreate(false); - await loadLabels(); - } catch (err) { - setError(err instanceof Error ? err.message : "Fehler beim Erstellen"); - } finally { - setCreating(false); - } - } - - async function handleUpdate(e: React.FormEvent) { - e.preventDefault(); - if (!editId || !editName.trim()) return; - setSaving(true); - try { - await updateLabel(editId, editName.trim(), editColor); - setEditId(null); - await loadLabels(); - } catch (err) { - setError(err instanceof Error ? err.message : "Fehler beim Speichern"); - } finally { - setSaving(false); - } - } - - async function handleDelete(label: MailLabel) { - if (!window.confirm(`Label "${label.name}" wirklich loeschen?`)) return; - try { - await deleteLabel(label.id); - if (selectedLabelId === label.id) onLabelSelect(null); - await loadLabels(); - } catch (err) { - setError(err instanceof Error ? err.message : "Fehler beim Loeschen"); - } - } - - function startEdit(label: MailLabel) { - setEditId(label.id); - setEditName(label.name); - setEditColor(label.color); - } - - if (loading) { - return ( -
- - - - -
- ); - } - - return ( - - ); -} diff --git a/src/components/LabelPicker.tsx b/src/components/LabelPicker.tsx deleted file mode 100644 index e44e69b..0000000 --- a/src/components/LabelPicker.tsx +++ /dev/null @@ -1,141 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { assignLabel, removeLabelFromEmail, type MailLabel } from "@/lib/api"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; - -interface LabelPickerProps { - emailId: string; - assignedLabelIds: number[]; - allLabels: MailLabel[]; - onUpdate: () => void; -} - -export function LabelPicker({ - emailId, - assignedLabelIds, - allLabels, - onUpdate, -}: LabelPickerProps) { - const [open, setOpen] = useState(false); - const [actionLoading, setActionLoading] = useState(null); - - const assignedSet = new Set(assignedLabelIds); - - async function toggleLabel(label: MailLabel) { - setActionLoading(label.id); - try { - if (assignedSet.has(label.id)) { - await removeLabelFromEmail(emailId, label.id); - } else { - await assignLabel(emailId, label.id); - } - onUpdate(); - } catch (e) { - console.error("Label toggle failed:", e); - } finally { - setActionLoading(null); - } - } - - const assignedLabels = allLabels.filter((l) => assignedSet.has(l.id)); - - return ( -
- {assignedLabels.map((label) => ( - - - ))} - - - - - - -

- Labels zuweisen -

- {allLabels.length === 0 && ( -

- Keine Labels vorhanden. -

- )} -
    - {allLabels.map((label) => { - const isAssigned = assignedSet.has(label.id); - const isLoading = actionLoading === label.id; - return ( -
  • - -
  • - ); - })} -
-
-
-
- ); -} diff --git a/src/components/admin/tabs/LabelsTab.tsx b/src/components/admin/tabs/LabelsTab.tsx deleted file mode 100644 index 6382513..0000000 --- a/src/components/admin/tabs/LabelsTab.tsx +++ /dev/null @@ -1,282 +0,0 @@ -"use client"; - -import { type MailLabel, type LabelRule } from "@/lib/api"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Card, CardContent } from "@/components/ui/card"; -import { Skeleton } from "@/components/ui/skeleton"; -import { Alert, AlertDescription } from "@/components/ui/alert"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; - -interface LabelsTabProps { - adminLabels: MailLabel[]; - adminLabelsLoading: boolean; - adminLabelsError: string; - newLabelName: string; - setNewLabelName: (v: string) => void; - newLabelColor: string; - setNewLabelColor: (v: string) => void; - labelCreating: boolean; - labelRules: LabelRule[]; - labelRulesLoading: boolean; - newRuleField: string; - setNewRuleField: (v: string) => void; - newRuleValue: string; - setNewRuleValue: (v: string) => void; - newRuleLabelId: number | null; - setNewRuleLabelId: (v: number | null) => void; - ruleCreating: boolean; - onCreateLabel: (e: React.FormEvent) => void; - onDeleteLabel: (id: number, name: string) => void; - onCreateRule: (e: React.FormEvent) => void; - onDeleteRule: (id: number) => void; -} - -export function LabelsTab({ - adminLabels, - adminLabelsLoading, - adminLabelsError, - newLabelName, - setNewLabelName, - newLabelColor, - setNewLabelColor, - labelCreating, - labelRules, - labelRulesLoading, - newRuleField, - setNewRuleField, - newRuleValue, - setNewRuleValue, - newRuleLabelId, - setNewRuleLabelId, - ruleCreating, - onCreateLabel, - onDeleteLabel, - onCreateRule, - onDeleteRule, -}: LabelsTabProps) { - return ( -
- {adminLabelsError && ( - - {adminLabelsError} - - )} - - {/* Globale Labels */} - - -

Globale Labels

-
-
- - setNewLabelName(e.target.value)} - placeholder="Label-Name" - className="h-8 w-48" - /> -
-
- -
- {["#ef4444","#f97316","#eab308","#22c55e","#06b6d4","#6366f1","#a855f7","#ec4899"].map((c) => ( -
-
- -
- - {adminLabelsLoading ? ( -
- - -
- ) : adminLabels.length === 0 ? ( -

Keine globalen Labels vorhanden.

- ) : ( - - - - Name - Farbe - Aktionen - - - - {adminLabels.map((label) => ( - - {label.name} - - - - - - - - ))} - -
- )} -
-
- - {/* Auto-Regeln */} - - -

Auto-Regeln

-
-
- - -
-
- - setNewRuleValue(e.target.value)} - placeholder="z.B. example.com" - className="h-8 w-48" - /> -
-
- - -
- -
- - {labelRulesLoading ? ( -
- - -
- ) : labelRules.length === 0 ? ( -

Keine Regeln vorhanden.

- ) : ( - - - - Bedingung - Wert - Label - Aktionen - - - - {labelRules.map((rule) => { - const condLabels: Record = { - from_domain: "Absender-Domain", - source: "Import-Quelle", - subject_contains: "Betreff enthaelt", - }; - const matchLabel = adminLabels.find((l) => l.id === rule.label_id); - return ( - - {condLabels[rule.condition_field] || rule.condition_field} - {rule.condition_value} - - {matchLabel ? ( - - - {matchLabel.name} - - ) : ( - ID {rule.label_id} - )} - - - - - - ); - })} - -
- )} -
-
-
- ); -} diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 38fa59e..62a4949 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -131,8 +131,6 @@ export type { SystemStats, SecurityCheck, SecurityAuditResult, - MailLabel, - LabelRule, CertInfo, SelfSignedRequest, ACMERequest, @@ -147,19 +145,6 @@ export { getAuditLog, getSecurityAudit, fixSecurityIssue, - getLabels, - createLabel, - updateLabel, - deleteLabel, - assignLabel, - removeLabelFromEmail, - getMailLabelIds, - createAdminLabel, - getAdminLabels, - deleteAdminLabel, - getLabelRules, - createLabelRule, - deleteLabelRule, getCertInfo, uploadCert, generateSelfSignedCert, diff --git a/src/lib/api/mail.ts b/src/lib/api/mail.ts index 9a169d3..268a632 100644 --- a/src/lib/api/mail.ts +++ b/src/lib/api/mail.ts @@ -123,7 +123,6 @@ export async function searchEmails(params: { date_to?: string; sort?: string; has_attachment?: boolean; - label_id?: number; page?: number; page_size?: number; }): Promise { @@ -135,7 +134,6 @@ export async function searchEmails(params: { if (params.date_to) sp.set("date_to", params.date_to); if (params.sort) sp.set("sort", params.sort); if (params.has_attachment !== undefined) sp.set("has_attachment", String(params.has_attachment)); - if (params.label_id !== undefined) sp.set("label_id", String(params.label_id)); if (params.page) sp.set("page", String(params.page)); if (params.page_size) sp.set("page_size", String(params.page_size)); return request(`/api/search?${sp.toString()}`); diff --git a/src/lib/api/system.ts b/src/lib/api/system.ts index 8d09832..064fca8 100644 --- a/src/lib/api/system.ts +++ b/src/lib/api/system.ts @@ -121,24 +121,6 @@ export interface SecurityAuditResult { run_at: string; } -export interface MailLabel { - id: number; - name: string; - color: string; - owner_id?: number; - tenant_id: number; - is_global: boolean; - created_at: string; -} - -export interface LabelRule { - id: number; - condition_field: "from_domain" | "source" | "subject_contains"; - condition_value: string; - label_id: number; - tenant_id: number; -} - export interface CertInfo { exists: boolean; subject?: string; @@ -227,81 +209,6 @@ export async function fixSecurityIssue(action: string): Promise<{ message: strin }); } -// ── Labels ──────────────────────────────────────────────────────────────────── - -export async function getLabels(): Promise { - return request("/api/labels"); -} - -export async function createLabel(name: string, color: string): Promise { - return request("/api/labels", { - method: "POST", - body: JSON.stringify({ name, color }), - }); -} - -export async function updateLabel(id: number, name: string, color: string): Promise { - return request(`/api/labels/${id}`, { - method: "PATCH", - body: JSON.stringify({ name, color }), - }); -} - -export async function deleteLabel(id: number): Promise { - return request(`/api/labels/${id}`, { method: "DELETE" }); -} - -export async function assignLabel(emailId: string, labelId: number): Promise { - return request(`/api/mails/${emailId}/labels`, { - method: "POST", - body: JSON.stringify({ label_id: labelId }), - }); -} - -export async function removeLabelFromEmail(emailId: string, labelId: number): Promise { - return request(`/api/mails/${emailId}/labels/${labelId}`, { - method: "DELETE", - }); -} - -export async function getMailLabelIds(emailId: string): Promise { - return request(`/api/mails/${emailId}/labels`); -} - -export async function createAdminLabel(name: string, color: string): Promise { - return request("/api/admin/labels", { - method: "POST", - body: JSON.stringify({ name, color }), - }); -} - -export async function getAdminLabels(): Promise { - return request("/api/admin/labels"); -} - -export async function deleteAdminLabel(id: number): Promise { - return request(`/api/admin/labels/${id}`, { method: "DELETE" }); -} - -export async function getLabelRules(): Promise { - return request("/api/admin/label-rules"); -} - -export async function createLabelRule( - condition_field: string, - condition_value: string, - label_id: number -): Promise { - return request("/api/admin/label-rules", { - method: "POST", - body: JSON.stringify({ condition_field, condition_value, label_id }), - }); -} - -export async function deleteLabelRule(id: number): Promise { - return request(`/api/admin/label-rules/${id}`, { method: "DELETE" }); -} - // ── Certificates ────────────────────────────────────────────────────────────── export async function getCertInfo(): Promise {