feat(PROJ-26,PROJ-38): IMAP LDAP-Auth + Mail-Threading
This commit is contained in:
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user