From b252172cc70d94e8ab267dfecb839eca7d0cdf6f Mon Sep 17 00:00:00 2001 From: sysops Date: Sun, 5 Apr 2026 20:17:41 +0200 Subject: [PATCH] feat(PROJ-26,PROJ-38): IMAP LDAP-Auth + Mail-Threading --- features/INDEX.md | 9 +- features/PROJ-26-imap-server-schnittstelle.md | 4 +- internal/api/search_handlers.go | 25 ++- internal/api/server.go | 1 + internal/api/thread_handlers.go | 86 ++++++++++ internal/imapserver/server.go | 12 +- internal/storage/storage.go | 159 +++++++++++++++++- pkg/mailparser/parser.go | 17 ++ src/app/mail/[id]/page.tsx | 51 +++++- src/app/search/page.tsx | 9 +- src/lib/api/mail.ts | 24 +++ 11 files changed, 382 insertions(+), 15 deletions(-) create mode 100644 internal/api/thread_handlers.go diff --git a/features/INDEX.md b/features/INDEX.md index 825495e..12972dc 100644 --- a/features/INDEX.md +++ b/features/INDEX.md @@ -39,7 +39,7 @@ | PROJ-24 | TOTP Zwei-Faktor-Authentifizierung (2FA) | Deployed | [PROJ-24](PROJ-24-totp-zwei-faktor.md) | 2026-03-18 | | PROJ-25 | User-Profil & Einstellungen | Deployed | [PROJ-25](PROJ-25-user-profil-einstellungen.md) | 2026-03-18 | -| PROJ-26 | IMAP-Server-Schnittstelle (Read-Only Archivzugriff) | In Progress | [PROJ-26](PROJ-26-imap-server-schnittstelle.md) | 2026-03-18 | +| PROJ-26 | IMAP-Server-Schnittstelle (Read-Only Archivzugriff) | Deployed | [PROJ-26](PROJ-26-imap-server-schnittstelle.md) | 2026-03-18 | | PROJ-27 | Container-Ready (Dockerfile + Env-Vars) | In Review | [PROJ-27](PROJ-27-container-ready.md) | 2026-03-28 | | PROJ-28 | Self-Service Onboarding (Sign-up, E-Mail-Verifikation, Passwort-Reset) | In Progress | [PROJ-28](PROJ-28-self-service-onboarding.md) | 2026-03-28 | @@ -52,9 +52,10 @@ | PROJ-34 | Retention-Policy + Löschsperre (GoBD-Compliance) | Deployed | [PROJ-34](PROJ-34-retention-policy.md) | 2026-03-31 | | PROJ-35 | OCR & Anhang-Volltext-Indexierung | Planned | [PROJ-35](PROJ-35-ocr-anhang-volltext.md) | 2026-04-04 | -| PROJ-36 | gzip-Kompression + storage_objects-Tabelle | In Progress | [PROJ-36](PROJ-36-compression-storage-objects.md) | 2026-04-05 | -| PROJ-37 | Attachment-Deduplication (Hash-basiert) | In Progress | [PROJ-37](PROJ-37-attachment-deduplication.md) | 2026-04-05 | +| PROJ-36 | gzip-Kompression + storage_objects-Tabelle | Deployed | [PROJ-36](PROJ-36-compression-storage-objects.md) | 2026-04-05 | +| PROJ-37 | Attachment-Deduplication (Hash-basiert) | Deployed | [PROJ-37](PROJ-37-attachment-deduplication.md) | 2026-04-05 | +| PROJ-38 | Mail-Threading (In-Reply-To / References) | In Progress | [PROJ-38](PROJ-38-mail-threading.md) | 2026-04-05 | -## Next Available ID: PROJ-38 +## Next Available ID: PROJ-39 diff --git a/features/PROJ-26-imap-server-schnittstelle.md b/features/PROJ-26-imap-server-schnittstelle.md index c69bfc9..bd91602 100644 --- a/features/PROJ-26-imap-server-schnittstelle.md +++ b/features/PROJ-26-imap-server-schnittstelle.md @@ -1,8 +1,8 @@ # PROJ-26: IMAP-Server-Schnittstelle (Read-Only Archivzugriff) -## Status: In Progress +## Status: Deployed **Created:** 2026-03-18 -**Last Updated:** 2026-03-18 +**Last Updated:** 2026-04-05 ## Dependencies - Requires: PROJ-1 (Authentifizierung & Rollen) — Login via Benutzername/Passwort diff --git a/internal/api/search_handlers.go b/internal/api/search_handlers.go index 848e31c..14e08ca 100644 --- a/internal/api/search_handlers.go +++ b/internal/api/search_handlers.go @@ -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, }) } diff --git a/internal/api/server.go b/internal/api/server.go index 925a88c..ca6c6bb 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -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)) diff --git a/internal/api/thread_handlers.go b/internal/api/thread_handlers.go new file mode 100644 index 0000000..84a319e --- /dev/null +++ b/internal/api/thread_handlers.go @@ -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, + }) +} diff --git a/internal/imapserver/server.go b/internal/imapserver/server.go index 90944e7..a07fac2 100644 --- a/internal/imapserver/server.go +++ b/internal/imapserver/server.go @@ -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{ diff --git a/internal/storage/storage.go b/internal/storage/storage.go index c92ac09..8d78ec2 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -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 } diff --git a/pkg/mailparser/parser.go b/pkg/mailparser/parser.go index 2f5e5f9..512beec 100644 --- a/pkg/mailparser/parser.go +++ b/pkg/mailparser/parser.go @@ -28,6 +28,8 @@ type ParsedMail struct { CC []string Subject string MessageID string + InReplyTo string // In-Reply-To header (single message-id, no angle brackets) + References []string // References header (list of message-ids, no angle brackets) TextBody string HTMLBody string Date time.Time @@ -81,6 +83,21 @@ func Parse(raw []byte) (*ParsedMail, error) { msgID := msg.Header.Get("Message-Id") pm.MessageID = strings.Trim(msgID, "<>") + // In-Reply-To - strip angle brackets + if irt := msg.Header.Get("In-Reply-To"); irt != "" { + pm.InReplyTo = strings.Trim(strings.TrimSpace(irt), "<>") + } + + // References - space-separated list of message-ids + if refs := msg.Header.Get("References"); refs != "" { + for _, r := range strings.Fields(refs) { + r = strings.Trim(r, "<>") + if r != "" { + pm.References = append(pm.References, r) + } + } + } + // Date — try go-message parser first, then fallback formats, then zero if d, err := msg.Header.Date(); err == nil { pm.Date = d diff --git a/src/app/mail/[id]/page.tsx b/src/app/mail/[id]/page.tsx index e7ef09f..98a631d 100644 --- a/src/app/mail/[id]/page.tsx +++ b/src/app/mail/[id]/page.tsx @@ -4,11 +4,13 @@ import { use, useEffect, useRef, useState } from "react"; import Link from "next/link"; import { getMail, + getThread, downloadMailAttachment, downloadMailRaw, exportMailPDF, type MailDetail, type MailAttachment, + type ThreadMail, } from "@/lib/api"; import { useAuth } from "@/hooks/useAuth"; import { Navbar } from "@/components/navbar"; @@ -255,11 +257,20 @@ export default function MailViewPage({ const [loading, setLoading] = useState(true); const [downloading, setDownloading] = useState(false); const [pdfLoading, setPdfLoading] = useState(false); + const [thread, setThread] = useState(null); + const [threadOpen, setThreadOpen] = useState(false); useEffect(() => { if (!user) return; getMail(id) - .then(setMail) + .then((m) => { + setMail(m); + if (m.thread_id) { + getThread(m.thread_id).then((t) => { + if (t.total > 1) setThread(t.mails); + }).catch(() => {}); + } + }) .catch((e) => setError(e instanceof Error ? e.message : "Unbekannter Fehler") ) @@ -392,6 +403,44 @@ export default function MailViewPage({ )} + + {/* Thread panel */} + {thread && thread.length > 1 && ( + + + + + {threadOpen && ( + <> + + + {thread.map((m) => ( + + + {m.subject || "(kein Betreff)"} + + + {m.date ? new Date(m.date).toLocaleDateString("de-DE") : "–"} + + + ))} + + + )} + + )} )} )} diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index 78f7a60..82d826a 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -409,7 +409,14 @@ export default function SearchPage() { : "-"} {hit.from || "-"} - {hit.subject || "(kein Betreff)"} + + {hit.subject || "(kein Betreff)"} + {hit.thread_size && hit.thread_size > 1 && ( + + {hit.thread_size} + + )} + {hit.to || "-"} {hit.has_attachments ? "📎" : ""} diff --git a/src/lib/api/mail.ts b/src/lib/api/mail.ts index 268a632..8cd57ba 100644 --- a/src/lib/api/mail.ts +++ b/src/lib/api/mail.ts @@ -11,6 +11,23 @@ export interface SearchHit { date?: string; size?: number; has_attachments?: boolean; + thread_id?: string; + thread_size?: number; +} + +export interface ThreadMail { + id: string; + from?: string; + to?: string; + subject: string; + date?: string; + size: number; +} + +export interface ThreadResponse { + thread_id: string; + total: number; + mails: ThreadMail[]; } export interface SearchResponse { @@ -39,6 +56,7 @@ export interface MailDetail { attachments: MailAttachment[]; verify_ok: boolean | null; verified_at: string | null; + thread_id?: string; } export interface ImapFolder { @@ -283,6 +301,12 @@ export async function getPop3Progress(id: number): Promise { return request(`/api/pop3/${id}/progress`); } +// ── Thread ──────────────────────────────────────────────────────────────────── + +export async function getThread(threadID: string): Promise { + return request(`/api/threads/${encodeURIComponent(threadID)}`); +} + // ── Export ──────────────────────────────────────────────────────────────────── export async function exportMailPDF(id: string): Promise<{ blob: Blob; filename: string }> {