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,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()
|
||||
}
|
||||
Reference in New Issue
Block a user