// 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" "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. func (s *Store) CreateAdminLabel(ctx context.Context, name, color string, tenantID int64) (Label, error) { return s.CreateLabel(ctx, name, color, nil, tenantID) } // ── 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() }