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,
})
}
+11 -1
View File
@@ -375,8 +375,18 @@ func (sess *session) cmdLogin(tag string, args string) {
return
}
// Authenticate via userstore (direct bcrypt check, bypasses TOTP for IMAP)
// Authenticate: try local bcrypt first, then LDAP fallback via authMgr.
// TOTP is intentionally bypassed for IMAP (protocol has no 2FA support).
user, err := sess.server.users.VerifyPassword(username, password)
if err != nil && sess.server.authMgr != nil {
// Local auth failed — try LDAP fallback through auth.Manager.
// authMgr.Login returns (token, user, totpRequired, err); we only need user.
_, ldapUser, _, ldapErr := sess.server.authMgr.Login(username, password)
if ldapErr == nil && ldapUser != nil {
user = ldapUser
err = nil
}
}
if err != nil {
sess.server.logger.Warn("imapserver: login failed", "user", username, "remote", sess.remoteAddr)
sess.server.audit.Log(audit.Entry{
+154 -5
View File
@@ -265,6 +265,15 @@ func (s *Store) initSchema(ctx context.Context) error {
);
CREATE INDEX IF NOT EXISTS idx_email_attachments_email ON email_attachments (email_id);
`)
if err != nil {
return err
}
// PROJ-38: Mail-Threading
_, err = s.db.Exec(ctx, `
ALTER TABLE emails ADD COLUMN IF NOT EXISTS thread_id TEXT;
ALTER TABLE emails ADD COLUMN IF NOT EXISTS in_reply_to TEXT;
CREATE INDEX IF NOT EXISTS idx_emails_thread ON emails (thread_id);
`)
return err
}
@@ -367,7 +376,12 @@ func (s *Store) Save(ctx context.Context, raw []byte, _ time.Time, tenantID *int
}
if parseErr == nil {
if err := s.insertMeta(ctx, id, pm, len(raw), tenantID, storageID); err != nil {
// PROJ-38: resolve thread before inserting
if pm.InReplyTo != "" || len(pm.References) > 0 {
pm.MessageID = pm.MessageID // no-op; thread resolved inside insertMeta
}
threadID := s.resolveThreadID(ctx, pm)
if err := s.insertMeta(ctx, id, pm, len(raw), tenantID, storageID, threadID); err != nil {
// Race: another goroutine inserted via Message-ID UNIQUE conflict.
// Resolve to the existing record's ID.
if messageID != "" {
@@ -421,6 +435,133 @@ func (s *Store) Save(ctx context.Context, raw []byte, _ time.Time, tenantID *int
return id, nil
}
// resolveThreadID determines the thread_id for a new mail by checking its
// References and In-Reply-To headers against existing mails in the DB.
// Returns the inherited thread_id, or the mail's own MessageID if it starts
// a new thread, or "" if no MessageID is available.
func (s *Store) resolveThreadID(ctx context.Context, pm *mailparser.ParsedMail) string {
if s.db == nil {
return pm.MessageID
}
// Check References in order (oldest first per RFC 5322 §3.6.4).
// The first reference that has a known thread_id wins.
for _, ref := range pm.References {
var tid string
err := s.db.QueryRow(ctx,
`SELECT COALESCE(thread_id, message_id) FROM emails WHERE message_id = $1 LIMIT 1`, ref,
).Scan(&tid)
if err == nil && tid != "" {
return tid
}
}
// Fall back to In-Reply-To
if pm.InReplyTo != "" {
var tid string
err := s.db.QueryRow(ctx,
`SELECT COALESCE(thread_id, message_id) FROM emails WHERE message_id = $1 LIMIT 1`, pm.InReplyTo,
).Scan(&tid)
if err == nil && tid != "" {
return tid
}
}
// New thread — use own Message-ID as root
return pm.MessageID
}
// ThreadInfo holds thread metadata for a single mail.
type ThreadInfo struct {
ThreadID string
ThreadSize int
}
// GetThreadInfo returns thread_id and thread size for a batch of email IDs.
// Only mails that belong to a thread (non-NULL thread_id) are enriched.
func (s *Store) GetThreadInfo(ctx context.Context, ids []string) (map[string]ThreadInfo, error) {
if s.db == nil || len(ids) == 0 {
return nil, nil
}
// Step 1: get thread_ids for the given email IDs
rows, err := s.db.Query(ctx,
`SELECT id, thread_id FROM emails WHERE id = ANY($1) AND thread_id IS NOT NULL`, ids,
)
if err != nil {
return nil, err
}
defer rows.Close()
threadByEmail := map[string]string{}
var threadIDs []string
for rows.Next() {
var emailID, threadID string
if err := rows.Scan(&emailID, &threadID); err == nil {
threadByEmail[emailID] = threadID
threadIDs = append(threadIDs, threadID)
}
}
rows.Close()
if len(threadIDs) == 0 {
return map[string]ThreadInfo{}, nil
}
// Step 2: count mails per thread
rows2, err := s.db.Query(ctx,
`SELECT thread_id, COUNT(*) FROM emails WHERE thread_id = ANY($1) GROUP BY thread_id`, threadIDs,
)
if err != nil {
return nil, err
}
defer rows2.Close()
threadSize := map[string]int{}
for rows2.Next() {
var tid string
var cnt int
if err := rows2.Scan(&tid, &cnt); err == nil {
threadSize[tid] = cnt
}
}
result := make(map[string]ThreadInfo, len(ids))
for emailID, tid := range threadByEmail {
result[emailID] = ThreadInfo{ThreadID: tid, ThreadSize: threadSize[tid]}
}
return result, nil
}
// GetMailsByThread returns all email IDs in a thread, ordered by date ascending.
func (s *Store) GetMailsByThread(ctx context.Context, threadID string, tenantID *int64) ([]string, error) {
if s.db == nil {
return nil, nil
}
var rows pgx.Rows
var err error
if tenantID != nil {
rows, err = s.db.Query(ctx, `
SELECT e.id FROM emails e
JOIN email_refs r ON r.email_id = e.id AND r.tenant_id = $2
WHERE e.thread_id = $1
ORDER BY e.received_at ASC
`, threadID, *tenantID)
} else {
rows, err = s.db.Query(ctx, `
SELECT id FROM emails WHERE thread_id = $1 ORDER BY received_at ASC
`, threadID)
}
if err != nil {
return nil, err
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err == nil {
ids = append(ids, id)
}
}
return ids, nil
}
// lookupByMessageID returns the email ID for a given Message-ID header value,
// or an empty string if not found. Returns an error only on unexpected DB failures.
func (s *Store) lookupByMessageID(ctx context.Context, messageID string) (string, error) {
@@ -636,7 +777,7 @@ func (s *Store) firstAndLastFromFS() (first, last *MailRef, err error) {
// insertMeta inserts parsed email metadata into the emails table.
// Returns an error so the caller can detect UNIQUE-constraint conflicts on message_id.
func (s *Store) insertMeta(ctx context.Context, id string, pm *mailparser.ParsedMail, size int, tenantID *int64, storageID *int64) error {
func (s *Store) insertMeta(ctx context.Context, id string, pm *mailparser.ParsedMail, size int, tenantID *int64, storageID *int64, threadID string) error {
mailTo := strings.Join(pm.To, ", ")
hasAttach := len(pm.Attachments) > 0
@@ -644,16 +785,24 @@ func (s *Store) insertMeta(ctx context.Context, id string, pm *mailparser.Parsed
if pm.MessageID != "" {
msgID = &pm.MessageID
}
var tid *string
if threadID != "" {
tid = &threadID
}
var inReplyTo *string
if pm.InReplyTo != "" {
inReplyTo = &pm.InReplyTo
}
receivedAt := pm.Date
if receivedAt.IsZero() {
receivedAt = time.Now()
}
_, err := s.db.Exec(ctx, `
INSERT INTO emails (id, received_at, mail_from, mail_to, subject, size_bytes, has_attach, tenant_id, message_id, storage_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
INSERT INTO emails (id, received_at, mail_from, mail_to, subject, size_bytes, has_attach, tenant_id, message_id, storage_id, thread_id, in_reply_to)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
ON CONFLICT (id) DO NOTHING
`, id, receivedAt, pm.From, mailTo, pm.Subject, int64(size), hasAttach, tenantID, msgID, storageID)
`, id, receivedAt, pm.From, mailTo, pm.Subject, int64(size), hasAttach, tenantID, msgID, storageID, tid, inReplyTo)
return err
}