feat: Labels-Feature vollständig entfernen (PROJ-9)
Backend: - internal/labelstore/ gelöscht (Store, Schema, CRUD) - internal/api/label_handlers.go gelöscht (alle Label-Routen) - internal/api/server.go: labels-Feld + SetLabels() entfernt - internal/api/search_handlers.go: label_id-Filter + Enrichment entfernt - internal/index/index.go: LabelID aus SearchRequest entfernt - internal/imapserver/server.go: labels-Feld + labelbasierte Mailboxen entfernt - cmd/archivmail/main.go: labelstore-Init + SetLabels() entfernt - cmd/archivmail/version.go: labelstore-Modul entfernt, index-Kommentar korrigiert Frontend: - LabelList.tsx, LabelPicker.tsx, LabelsTab.tsx gelöscht - src/lib/api/system.ts: MailLabel/LabelRule-Typen + alle Label-Funktionen entfernt - src/lib/api/index.ts: Label-Exports entfernt - src/app/search/page.tsx: LabelList + selectedLabelId State entfernt - src/app/mail/[id]/page.tsx: LabelPicker + Labels-State entfernt - src/app/admin/page.tsx: LabelsTab + alle Label-Handler/State entfernt Docs: - features/PROJ-9: Status auf Removed gesetzt - features/INDEX.md: PROJ-9 auf Removed gesetzt Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+1
-11
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# PROJ-9: Ordner- & Label-Verwaltung
|
||||
|
||||
## Status: Deployed
|
||||
## Status: Removed
|
||||
**Created:** 2026-03-12
|
||||
**Last Updated:** 2026-04-04
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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<MailLabel[]>([]);
|
||||
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<LabelRule[]>([]);
|
||||
const [labelRulesLoading, setLabelRulesLoading] = useState(false);
|
||||
const [newRuleField, setNewRuleField] = useState("from_domain");
|
||||
const [newRuleValue, setNewRuleValue] = useState("");
|
||||
const [newRuleLabelId, setNewRuleLabelId] = useState<number | null>(null);
|
||||
const [ruleCreating, setRuleCreating] = useState(false);
|
||||
|
||||
// Certificate state
|
||||
const [certInfo, setCertInfo] = useState<CertInfo | null>(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" && (
|
||||
<TabsTrigger value="imap-settings">IMAP</TabsTrigger>
|
||||
)}
|
||||
{isSuperAdmin && <TabsTrigger value="labels" onClick={loadLabelsTab}>Labels</TabsTrigger>}
|
||||
{isSuperAdmin && <TabsTrigger value="security">Security</TabsTrigger>}
|
||||
{isSuperAdmin && <TabsTrigger value="cert" onClick={loadCert}>Zertifikat</TabsTrigger>}
|
||||
{isSuperAdmin && <TabsTrigger value="tenants" onClick={loadTenants}>Mandanten</TabsTrigger>}
|
||||
@@ -957,34 +853,6 @@ export default function AdminPage() {
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{isSuperAdmin && (
|
||||
<TabsContent value="labels">
|
||||
<LabelsTab
|
||||
adminLabels={adminLabels}
|
||||
adminLabelsLoading={adminLabelsLoading}
|
||||
adminLabelsError={adminLabelsError}
|
||||
newLabelName={newLabelName}
|
||||
setNewLabelName={setNewLabelName}
|
||||
newLabelColor={newLabelColor}
|
||||
setNewLabelColor={setNewLabelColor}
|
||||
labelCreating={labelCreating}
|
||||
labelRules={labelRules}
|
||||
labelRulesLoading={labelRulesLoading}
|
||||
newRuleField={newRuleField}
|
||||
setNewRuleField={setNewRuleField}
|
||||
newRuleValue={newRuleValue}
|
||||
setNewRuleValue={setNewRuleValue}
|
||||
newRuleLabelId={newRuleLabelId}
|
||||
setNewRuleLabelId={setNewRuleLabelId}
|
||||
ruleCreating={ruleCreating}
|
||||
onCreateLabel={handleCreateAdminLabel}
|
||||
onDeleteLabel={handleDeleteAdminLabel}
|
||||
onCreateRule={handleCreateRule}
|
||||
onDeleteRule={handleDeleteRule}
|
||||
/>
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{isSuperAdmin && (
|
||||
<TabsContent value="cert">
|
||||
<CertTab
|
||||
|
||||
@@ -7,15 +7,11 @@ import {
|
||||
downloadMailAttachment,
|
||||
downloadMailRaw,
|
||||
exportMailPDF,
|
||||
getLabels,
|
||||
getMailLabelIds,
|
||||
type MailDetail,
|
||||
type MailAttachment,
|
||||
type MailLabel,
|
||||
} from "@/lib/api";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { Navbar } from "@/components/navbar";
|
||||
import { LabelPicker } from "@/components/LabelPicker";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
@@ -260,10 +256,6 @@ export default function MailViewPage({
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [pdfLoading, setPdfLoading] = useState(false);
|
||||
|
||||
// Labels state
|
||||
const [allLabels, setAllLabels] = useState<MailLabel[]>([]);
|
||||
const [assignedLabelIds, setAssignedLabelIds] = useState<number[]>([]);
|
||||
|
||||
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({
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
{/* Labels */}
|
||||
{allLabels.length > 0 && (
|
||||
<Card>
|
||||
<CardContent className="pt-4 pb-4">
|
||||
<LabelPicker
|
||||
emailId={id}
|
||||
assignedLabelIds={assignedLabelIds}
|
||||
allLabels={allLabels}
|
||||
onUpdate={loadMailLabels}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Body */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
|
||||
+1
-21
@@ -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<boolean | undefined>(undefined);
|
||||
const [selectedLabelId, setSelectedLabelId] = useState<number | null>(null);
|
||||
|
||||
const [results, setResults] = useState<SearchHit[]>([]);
|
||||
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 && (<>
|
||||
<div className="flex gap-6">
|
||||
{/* Label sidebar — nur für Rollen mit eigenen Mails */}
|
||||
{user.role !== "auditor" && user.role !== "domain_auditor" && (
|
||||
<div className="hidden md:block w-48 shrink-0">
|
||||
<LabelList
|
||||
selectedLabelId={selectedLabelId}
|
||||
onLabelSelect={setSelectedLabelId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
|
||||
@@ -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<MailLabel[]>([]);
|
||||
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<number | null>(null);
|
||||
const [editName, setEditName] = useState("");
|
||||
const [editColor, setEditColor] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Hover state
|
||||
const [hoveredId, setHoveredId] = useState<number | null>(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 (
|
||||
<div className="space-y-2 p-2" aria-label="Labels laden">
|
||||
<Skeleton className="h-5 w-24" />
|
||||
<Skeleton className="h-6 w-full" />
|
||||
<Skeleton className="h-6 w-full" />
|
||||
<Skeleton className="h-6 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="space-y-2" aria-label="Label-Filter">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">Labels</h3>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-destructive">{error}</p>
|
||||
)}
|
||||
|
||||
{/* Label list */}
|
||||
<ul className="space-y-0.5">
|
||||
{labels.map((label) => (
|
||||
<li
|
||||
key={label.id}
|
||||
className={`group flex items-center gap-2 rounded-md px-2 py-1.5 text-sm cursor-pointer transition-colors ${
|
||||
selectedLabelId === label.id
|
||||
? "bg-muted font-medium"
|
||||
: "hover:bg-muted/50"
|
||||
}`}
|
||||
onMouseEnter={() => setHoveredId(label.id)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
onClick={() =>
|
||||
onLabelSelect(selectedLabelId === label.id ? null : label.id)
|
||||
}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onLabelSelect(selectedLabelId === label.id ? null : label.id);
|
||||
}
|
||||
}}
|
||||
aria-pressed={selectedLabelId === label.id}
|
||||
aria-label={`Label ${label.name}${label.is_global ? " (global)" : ""}`}
|
||||
>
|
||||
<span
|
||||
className="inline-block h-3 w-3 rounded-full shrink-0"
|
||||
style={{ backgroundColor: label.color }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="truncate flex-1">{label.name}</span>
|
||||
{label.is_global && (
|
||||
<span className="text-xs text-muted-foreground" title="Globales Label" aria-label="Globales Label">
|
||||
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
{!label.is_global && hoveredId === label.id && (
|
||||
<span className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
title="Bearbeiten"
|
||||
aria-label={`Label ${label.name} bearbeiten`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
startEdit(label);
|
||||
}}
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
title="Loeschen"
|
||||
aria-label={`Label ${label.name} loeschen`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(label);
|
||||
}}
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{labels.length === 0 && !error && (
|
||||
<p className="text-xs text-muted-foreground px-2">Keine Labels vorhanden.</p>
|
||||
)}
|
||||
|
||||
{/* Edit inline form */}
|
||||
{editId !== null && (
|
||||
<form onSubmit={handleUpdate} className="space-y-2 rounded-md border p-2">
|
||||
<Input
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
placeholder="Label-Name"
|
||||
className="h-7 text-sm"
|
||||
autoFocus
|
||||
aria-label="Label-Name bearbeiten"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{LABEL_COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
className={`h-5 w-5 rounded-full border-2 transition-transform ${
|
||||
editColor === c ? "border-foreground scale-110" : "border-transparent"
|
||||
}`}
|
||||
style={{ backgroundColor: c }}
|
||||
onClick={() => setEditColor(c)}
|
||||
aria-label={`Farbe ${c}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button type="submit" size="sm" className="h-7 text-xs" disabled={saving}>
|
||||
{saving ? "..." : "Speichern"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setEditId(null)}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Create form */}
|
||||
{showCreate ? (
|
||||
<form onSubmit={handleCreate} className="space-y-2 rounded-md border p-2">
|
||||
<Input
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder="Neues Label"
|
||||
className="h-7 text-sm"
|
||||
autoFocus
|
||||
aria-label="Neuer Label-Name"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{LABEL_COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
className={`h-5 w-5 rounded-full border-2 transition-transform ${
|
||||
newColor === c ? "border-foreground scale-110" : "border-transparent"
|
||||
}`}
|
||||
style={{ backgroundColor: c }}
|
||||
onClick={() => setNewColor(c)}
|
||||
aria-label={`Farbe ${c}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button type="submit" size="sm" className="h-7 text-xs" disabled={creating || !newName.trim()}>
|
||||
{creating ? "..." : "Erstellen"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setShowCreate(false)}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start text-xs text-muted-foreground h-7"
|
||||
onClick={() => setShowCreate(true)}
|
||||
>
|
||||
+ Neues Label
|
||||
</Button>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -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<number | null>(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 (
|
||||
<div className="flex flex-wrap items-center gap-2" aria-label="E-Mail-Labels">
|
||||
{assignedLabels.map((label) => (
|
||||
<Badge
|
||||
key={label.id}
|
||||
variant="secondary"
|
||||
className="gap-1.5 text-xs font-normal"
|
||||
style={{
|
||||
borderColor: label.color,
|
||||
borderWidth: "1px",
|
||||
borderStyle: "solid",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="inline-block h-2 w-2 rounded-full shrink-0"
|
||||
style={{ backgroundColor: label.color }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{label.name}
|
||||
</Badge>
|
||||
))}
|
||||
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs text-muted-foreground"
|
||||
aria-label="Label hinzufuegen oder entfernen"
|
||||
>
|
||||
+ Label
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-56 p-2" align="start">
|
||||
<p className="mb-2 text-xs font-medium text-muted-foreground">
|
||||
Labels zuweisen
|
||||
</p>
|
||||
{allLabels.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground py-2 text-center">
|
||||
Keine Labels vorhanden.
|
||||
</p>
|
||||
)}
|
||||
<ul className="space-y-0.5 max-h-60 overflow-y-auto">
|
||||
{allLabels.map((label) => {
|
||||
const isAssigned = assignedSet.has(label.id);
|
||||
const isLoading = actionLoading === label.id;
|
||||
return (
|
||||
<li key={label.id}>
|
||||
<button
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-muted/50 transition-colors disabled:opacity-50"
|
||||
onClick={() => toggleLabel(label)}
|
||||
disabled={isLoading}
|
||||
aria-pressed={isAssigned}
|
||||
aria-label={`Label ${label.name} ${isAssigned ? "entfernen" : "zuweisen"}`}
|
||||
>
|
||||
<span
|
||||
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border ${
|
||||
isAssigned
|
||||
? "bg-primary border-primary text-primary-foreground"
|
||||
: "border-muted-foreground/30"
|
||||
}`}
|
||||
>
|
||||
{isAssigned && (
|
||||
<svg
|
||||
className="h-3 w-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={3}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className="inline-block h-2.5 w-2.5 rounded-full shrink-0"
|
||||
style={{ backgroundColor: label.color }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="truncate">{label.name}</span>
|
||||
{isLoading && (
|
||||
<span className="ml-auto text-xs text-muted-foreground">...</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="mt-4 space-y-6">
|
||||
{adminLabelsError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{adminLabelsError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Globale Labels */}
|
||||
<Card>
|
||||
<CardContent className="pt-6 space-y-4">
|
||||
<h3 className="text-sm font-semibold">Globale Labels</h3>
|
||||
<form onSubmit={onCreateLabel} className="flex items-end gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="label-name" className="text-xs">Name</Label>
|
||||
<Input
|
||||
id="label-name"
|
||||
value={newLabelName}
|
||||
onChange={(e) => setNewLabelName(e.target.value)}
|
||||
placeholder="Label-Name"
|
||||
className="h-8 w-48"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Farbe</Label>
|
||||
<div className="flex gap-1.5">
|
||||
{["#ef4444","#f97316","#eab308","#22c55e","#06b6d4","#6366f1","#a855f7","#ec4899"].map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
className={`h-6 w-6 rounded-full border-2 transition-transform ${
|
||||
newLabelColor === c ? "border-foreground scale-110" : "border-transparent"
|
||||
}`}
|
||||
style={{ backgroundColor: c }}
|
||||
onClick={() => setNewLabelColor(c)}
|
||||
aria-label={`Farbe ${c}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit" size="sm" disabled={labelCreating || !newLabelName.trim()}>
|
||||
{labelCreating ? "..." : "Anlegen"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{adminLabelsLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
</div>
|
||||
) : adminLabels.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Keine globalen Labels vorhanden.</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead className="w-24">Farbe</TableHead>
|
||||
<TableHead className="w-24">Aktionen</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{adminLabels.map((label) => (
|
||||
<TableRow key={label.id}>
|
||||
<TableCell className="font-medium">{label.name}</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
className="inline-block h-4 w-4 rounded-full"
|
||||
style={{ backgroundColor: label.color }}
|
||||
aria-label={`Farbe ${label.color}`}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive h-7"
|
||||
onClick={() => onDeleteLabel(label.id, label.name)}
|
||||
>
|
||||
Loeschen
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Auto-Regeln */}
|
||||
<Card>
|
||||
<CardContent className="pt-6 space-y-4">
|
||||
<h3 className="text-sm font-semibold">Auto-Regeln</h3>
|
||||
<form onSubmit={onCreateRule} className="flex items-end gap-3 flex-wrap">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="rule-field" className="text-xs">Bedingung</Label>
|
||||
<Select value={newRuleField} onValueChange={setNewRuleField}>
|
||||
<SelectTrigger id="rule-field" className="h-8 w-44 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="from_domain">Absender-Domain</SelectItem>
|
||||
<SelectItem value="source">Import-Quelle</SelectItem>
|
||||
<SelectItem value="subject_contains">Betreff enthaelt</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="rule-value" className="text-xs">Wert</Label>
|
||||
<Input
|
||||
id="rule-value"
|
||||
value={newRuleValue}
|
||||
onChange={(e) => setNewRuleValue(e.target.value)}
|
||||
placeholder="z.B. example.com"
|
||||
className="h-8 w-48"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="rule-label" className="text-xs">Label</Label>
|
||||
<Select
|
||||
value={newRuleLabelId !== null ? String(newRuleLabelId) : ""}
|
||||
onValueChange={(v) => setNewRuleLabelId(Number(v))}
|
||||
>
|
||||
<SelectTrigger id="rule-label" className="h-8 w-44 text-xs">
|
||||
<SelectValue placeholder="Label waehlen..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{adminLabels.map((l) => (
|
||||
<SelectItem key={l.id} value={String(l.id)}>
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
className="inline-block h-2.5 w-2.5 rounded-full"
|
||||
style={{ backgroundColor: l.color }}
|
||||
/>
|
||||
{l.name}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button type="submit" size="sm" disabled={ruleCreating || !newRuleValue.trim() || !newRuleLabelId}>
|
||||
{ruleCreating ? "..." : "Regel anlegen"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{labelRulesLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
</div>
|
||||
) : labelRules.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Keine Regeln vorhanden.</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Bedingung</TableHead>
|
||||
<TableHead>Wert</TableHead>
|
||||
<TableHead>Label</TableHead>
|
||||
<TableHead className="w-24">Aktionen</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{labelRules.map((rule) => {
|
||||
const condLabels: Record<string, string> = {
|
||||
from_domain: "Absender-Domain",
|
||||
source: "Import-Quelle",
|
||||
subject_contains: "Betreff enthaelt",
|
||||
};
|
||||
const matchLabel = adminLabels.find((l) => l.id === rule.label_id);
|
||||
return (
|
||||
<TableRow key={rule.id}>
|
||||
<TableCell className="text-sm">{condLabels[rule.condition_field] || rule.condition_field}</TableCell>
|
||||
<TableCell className="text-sm font-mono">{rule.condition_value}</TableCell>
|
||||
<TableCell>
|
||||
{matchLabel ? (
|
||||
<span className="flex items-center gap-2 text-sm">
|
||||
<span
|
||||
className="inline-block h-3 w-3 rounded-full"
|
||||
style={{ backgroundColor: matchLabel.color }}
|
||||
/>
|
||||
{matchLabel.name}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">ID {rule.label_id}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive h-7"
|
||||
onClick={() => onDeleteRule(rule.id)}
|
||||
>
|
||||
Loeschen
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<SearchResponse> {
|
||||
@@ -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<SearchResponse>(`/api/search?${sp.toString()}`);
|
||||
|
||||
@@ -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<MailLabel[]> {
|
||||
return request<MailLabel[]>("/api/labels");
|
||||
}
|
||||
|
||||
export async function createLabel(name: string, color: string): Promise<MailLabel> {
|
||||
return request<MailLabel>("/api/labels", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name, color }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateLabel(id: number, name: string, color: string): Promise<void> {
|
||||
return request<void>(`/api/labels/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ name, color }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteLabel(id: number): Promise<void> {
|
||||
return request<void>(`/api/labels/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function assignLabel(emailId: string, labelId: number): Promise<void> {
|
||||
return request<void>(`/api/mails/${emailId}/labels`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ label_id: labelId }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeLabelFromEmail(emailId: string, labelId: number): Promise<void> {
|
||||
return request<void>(`/api/mails/${emailId}/labels/${labelId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export async function getMailLabelIds(emailId: string): Promise<number[]> {
|
||||
return request<number[]>(`/api/mails/${emailId}/labels`);
|
||||
}
|
||||
|
||||
export async function createAdminLabel(name: string, color: string): Promise<MailLabel> {
|
||||
return request<MailLabel>("/api/admin/labels", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name, color }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAdminLabels(): Promise<MailLabel[]> {
|
||||
return request<MailLabel[]>("/api/admin/labels");
|
||||
}
|
||||
|
||||
export async function deleteAdminLabel(id: number): Promise<void> {
|
||||
return request<void>(`/api/admin/labels/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function getLabelRules(): Promise<LabelRule[]> {
|
||||
return request<LabelRule[]>("/api/admin/label-rules");
|
||||
}
|
||||
|
||||
export async function createLabelRule(
|
||||
condition_field: string,
|
||||
condition_value: string,
|
||||
label_id: number
|
||||
): Promise<LabelRule> {
|
||||
return request<LabelRule>("/api/admin/label-rules", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ condition_field, condition_value, label_id }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteLabelRule(id: number): Promise<void> {
|
||||
return request<void>(`/api/admin/label-rules/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
// ── Certificates ──────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getCertInfo(): Promise<CertInfo> {
|
||||
|
||||
Reference in New Issue
Block a user