feat(PROJ-26,PROJ-38): IMAP LDAP-Auth + Mail-Threading

This commit is contained in:
sysops
2026-04-05 20:17:41 +02:00
parent 956b5b6d5f
commit b252172cc7
11 changed files with 382 additions and 15 deletions
+24 -1
View File
@@ -131,7 +131,7 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
Success: true,
})
// Enrich hits with metadata (from, subject, date, size, attachments).
// Enrich hits with metadata (from, subject, date, size, attachments, thread).
type enrichedHit struct {
ID string `json:"id"`
Score float64 `json:"score"`
@@ -141,6 +141,8 @@ 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"`
ThreadID string `json:"thread_id,omitempty"`
ThreadSize int `json:"thread_size,omitempty"`
}
// auditor role: restrict results to mails with no tenant assignment.
@@ -169,6 +171,13 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
}
}
// Batch-load thread info for all hits
hitIDs := make([]string, len(result.Hits))
for i, h := range result.Hits {
hitIDs[i] = h.ID
}
threadInfo, _ := s.store.GetThreadInfo(r.Context(), hitIDs)
enriched := make([]enrichedHit, 0, len(result.Hits))
for _, h := range result.Hits {
eh := enrichedHit{ID: h.ID, Score: h.Score}
@@ -200,6 +209,11 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
}
}
}
// PROJ-38: attach thread info
if ti, ok := threadInfo[h.ID]; ok && ti.ThreadSize > 1 {
eh.ThreadID = ti.ThreadID
eh.ThreadSize = ti.ThreadSize
}
enriched = append(enriched, eh)
}
@@ -289,6 +303,14 @@ func (s *Server) handleGetMail(w http.ResponseWriter, r *http.Request) {
verifiedAt = vs.VerifiedAt.UTC().Format(time.RFC3339)
}
// PROJ-38: load thread_id from DB
var threadID string
if ti, err := s.store.GetThreadInfo(r.Context(), []string{id}); err == nil {
if info, ok := ti[id]; ok {
threadID = info.ThreadID
}
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"id": id,
"from": pm.From,
@@ -303,6 +325,7 @@ func (s *Server) handleGetMail(w http.ResponseWriter, r *http.Request) {
"attachments": attachments,
"verify_ok": verifyOK,
"verified_at": verifiedAt,
"thread_id": threadID,
})
}
+1
View File
@@ -200,6 +200,7 @@ func (s *Server) routes() {
s.mux.HandleFunc("GET /api/admin/storage/stats", s.authAdmin(s.handleStorageStats))
s.mux.HandleFunc("GET /api/mails/{id}", s.auth(s.requireMailAccess(s.handleGetMail)))
s.mux.HandleFunc("GET /api/mails/{id}/attachments/{index}", s.auth(s.requireMailAccess(s.handleGetAttachment)))
s.mux.HandleFunc("GET /api/threads/{threadID}", s.auth(s.handleGetThread))
s.mux.HandleFunc("GET /api/mails/{id}/raw", s.auth(s.requireMailAccess(s.handleGetRaw)))
s.mux.HandleFunc("GET /api/admin/services", s.authAdmin(s.handleListServices))
s.mux.HandleFunc("POST /api/admin/services/{name}/action", s.authAdmin(s.handleServiceAction))
+86
View File
@@ -0,0 +1,86 @@
package api
import (
"net/http"
"strings"
"time"
"github.com/archivmail/internal/userstore"
"github.com/archivmail/pkg/mailparser"
)
// handleGetThread returns all mails in a thread, ordered by date ascending.
// Access is tenant-isolated identical to handleGetMail.
//
// GET /api/mail/thread/{threadID}
func (s *Server) handleGetThread(w http.ResponseWriter, r *http.Request) {
threadID := r.PathValue("threadID")
if threadID == "" {
writeError(w, http.StatusBadRequest, "missing thread id")
return
}
sess := sessionFromCtx(r.Context())
tenantID := tenantFromCtx(r.Context())
// domain_auditor without tenant → deny
if sess.Role == userstore.RoleDomainAuditor && tenantID == nil {
writeError(w, http.StatusForbidden, "access denied")
return
}
// For user role, we still filter per mail below
ids, err := s.store.GetMailsByThread(r.Context(), threadID, tenantID)
if err != nil {
writeError(w, http.StatusInternalServerError, "thread lookup failed")
return
}
type mailSummary struct {
ID string `json:"id"`
From string `json:"from,omitempty"`
To string `json:"to,omitempty"`
Subject string `json:"subject"`
Date string `json:"date,omitempty"`
Size int `json:"size"`
}
mails := make([]mailSummary, 0, len(ids))
for _, id := range ids {
raw, err := s.store.Load(id)
if err != nil {
continue
}
pm, err := mailparser.Parse(raw)
if err != nil {
continue
}
// user isolation
if sess.Role == userstore.RoleUser {
if sess.Email == "" || !mailBelongsToUser(pm, strings.ToLower(sess.Email)) {
continue
}
}
var dateStr string
if !pm.Date.IsZero() {
dateStr = pm.Date.UTC().Format(time.RFC3339)
}
mails = append(mails, mailSummary{
ID: id,
From: pm.From,
To: strings.Join(pm.To, ", "),
Subject: pm.Subject,
Date: dateStr,
Size: len(raw),
})
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"thread_id": threadID,
"total": len(mails),
"mails": mails,
})
}