feat(PROJ-26,PROJ-38): IMAP LDAP-Auth + Mail-Threading
This commit is contained in:
+5
-4
@@ -39,7 +39,7 @@
|
|||||||
| PROJ-24 | TOTP Zwei-Faktor-Authentifizierung (2FA) | Deployed | [PROJ-24](PROJ-24-totp-zwei-faktor.md) | 2026-03-18 |
|
| 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-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-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 |
|
| 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-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-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-36 | gzip-Kompression + storage_objects-Tabelle | Deployed | [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-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 |
|
||||||
|
|
||||||
<!-- Add features above this line -->
|
<!-- Add features above this line -->
|
||||||
|
|
||||||
## Next Available ID: PROJ-38
|
## Next Available ID: PROJ-39
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# PROJ-26: IMAP-Server-Schnittstelle (Read-Only Archivzugriff)
|
# PROJ-26: IMAP-Server-Schnittstelle (Read-Only Archivzugriff)
|
||||||
|
|
||||||
## Status: In Progress
|
## Status: Deployed
|
||||||
**Created:** 2026-03-18
|
**Created:** 2026-03-18
|
||||||
**Last Updated:** 2026-03-18
|
**Last Updated:** 2026-04-05
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
- Requires: PROJ-1 (Authentifizierung & Rollen) — Login via Benutzername/Passwort
|
- Requires: PROJ-1 (Authentifizierung & Rollen) — Login via Benutzername/Passwort
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
|||||||
Success: true,
|
Success: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Enrich hits with metadata (from, subject, date, size, attachments).
|
// Enrich hits with metadata (from, subject, date, size, attachments, thread).
|
||||||
type enrichedHit struct {
|
type enrichedHit struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Score float64 `json:"score"`
|
Score float64 `json:"score"`
|
||||||
@@ -141,6 +141,8 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
|||||||
Date string `json:"date,omitempty"`
|
Date string `json:"date,omitempty"`
|
||||||
Size int64 `json:"size,omitempty"`
|
Size int64 `json:"size,omitempty"`
|
||||||
HasAttachments bool `json:"has_attachments"`
|
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.
|
// 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))
|
enriched := make([]enrichedHit, 0, len(result.Hits))
|
||||||
for _, h := range result.Hits {
|
for _, h := range result.Hits {
|
||||||
eh := enrichedHit{ID: h.ID, Score: h.Score}
|
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)
|
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)
|
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{}{
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
"id": id,
|
"id": id,
|
||||||
"from": pm.From,
|
"from": pm.From,
|
||||||
@@ -303,6 +325,7 @@ func (s *Server) handleGetMail(w http.ResponseWriter, r *http.Request) {
|
|||||||
"attachments": attachments,
|
"attachments": attachments,
|
||||||
"verify_ok": verifyOK,
|
"verify_ok": verifyOK,
|
||||||
"verified_at": verifiedAt,
|
"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/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}", 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/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/mails/{id}/raw", s.auth(s.requireMailAccess(s.handleGetRaw)))
|
||||||
s.mux.HandleFunc("GET /api/admin/services", s.authAdmin(s.handleListServices))
|
s.mux.HandleFunc("GET /api/admin/services", s.authAdmin(s.handleListServices))
|
||||||
s.mux.HandleFunc("POST /api/admin/services/{name}/action", s.authAdmin(s.handleServiceAction))
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -375,8 +375,18 @@ func (sess *session) cmdLogin(tag string, args string) {
|
|||||||
return
|
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)
|
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 {
|
if err != nil {
|
||||||
sess.server.logger.Warn("imapserver: login failed", "user", username, "remote", sess.remoteAddr)
|
sess.server.logger.Warn("imapserver: login failed", "user", username, "remote", sess.remoteAddr)
|
||||||
sess.server.audit.Log(audit.Entry{
|
sess.server.audit.Log(audit.Entry{
|
||||||
|
|||||||
+154
-5
@@ -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);
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,7 +376,12 @@ func (s *Store) Save(ctx context.Context, raw []byte, _ time.Time, tenantID *int
|
|||||||
}
|
}
|
||||||
|
|
||||||
if parseErr == nil {
|
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.
|
// Race: another goroutine inserted via Message-ID UNIQUE conflict.
|
||||||
// Resolve to the existing record's ID.
|
// Resolve to the existing record's ID.
|
||||||
if messageID != "" {
|
if messageID != "" {
|
||||||
@@ -421,6 +435,133 @@ func (s *Store) Save(ctx context.Context, raw []byte, _ time.Time, tenantID *int
|
|||||||
return id, nil
|
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,
|
// 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.
|
// 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) {
|
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.
|
// insertMeta inserts parsed email metadata into the emails table.
|
||||||
// Returns an error so the caller can detect UNIQUE-constraint conflicts on message_id.
|
// 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, ", ")
|
mailTo := strings.Join(pm.To, ", ")
|
||||||
hasAttach := len(pm.Attachments) > 0
|
hasAttach := len(pm.Attachments) > 0
|
||||||
|
|
||||||
@@ -644,16 +785,24 @@ func (s *Store) insertMeta(ctx context.Context, id string, pm *mailparser.Parsed
|
|||||||
if pm.MessageID != "" {
|
if pm.MessageID != "" {
|
||||||
msgID = &pm.MessageID
|
msgID = &pm.MessageID
|
||||||
}
|
}
|
||||||
|
var tid *string
|
||||||
|
if threadID != "" {
|
||||||
|
tid = &threadID
|
||||||
|
}
|
||||||
|
var inReplyTo *string
|
||||||
|
if pm.InReplyTo != "" {
|
||||||
|
inReplyTo = &pm.InReplyTo
|
||||||
|
}
|
||||||
|
|
||||||
receivedAt := pm.Date
|
receivedAt := pm.Date
|
||||||
if receivedAt.IsZero() {
|
if receivedAt.IsZero() {
|
||||||
receivedAt = time.Now()
|
receivedAt = time.Now()
|
||||||
}
|
}
|
||||||
_, err := s.db.Exec(ctx, `
|
_, 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)
|
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)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||||
ON CONFLICT (id) DO NOTHING
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ type ParsedMail struct {
|
|||||||
CC []string
|
CC []string
|
||||||
Subject string
|
Subject string
|
||||||
MessageID 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
|
TextBody string
|
||||||
HTMLBody string
|
HTMLBody string
|
||||||
Date time.Time
|
Date time.Time
|
||||||
@@ -81,6 +83,21 @@ func Parse(raw []byte) (*ParsedMail, error) {
|
|||||||
msgID := msg.Header.Get("Message-Id")
|
msgID := msg.Header.Get("Message-Id")
|
||||||
pm.MessageID = strings.Trim(msgID, "<>")
|
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
|
// Date — try go-message parser first, then fallback formats, then zero
|
||||||
if d, err := msg.Header.Date(); err == nil {
|
if d, err := msg.Header.Date(); err == nil {
|
||||||
pm.Date = d
|
pm.Date = d
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import { use, useEffect, useRef, useState } from "react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {
|
import {
|
||||||
getMail,
|
getMail,
|
||||||
|
getThread,
|
||||||
downloadMailAttachment,
|
downloadMailAttachment,
|
||||||
downloadMailRaw,
|
downloadMailRaw,
|
||||||
exportMailPDF,
|
exportMailPDF,
|
||||||
type MailDetail,
|
type MailDetail,
|
||||||
type MailAttachment,
|
type MailAttachment,
|
||||||
|
type ThreadMail,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { Navbar } from "@/components/navbar";
|
import { Navbar } from "@/components/navbar";
|
||||||
@@ -255,11 +257,20 @@ export default function MailViewPage({
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [downloading, setDownloading] = useState(false);
|
const [downloading, setDownloading] = useState(false);
|
||||||
const [pdfLoading, setPdfLoading] = useState(false);
|
const [pdfLoading, setPdfLoading] = useState(false);
|
||||||
|
const [thread, setThread] = useState<ThreadMail[] | null>(null);
|
||||||
|
const [threadOpen, setThreadOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
getMail(id)
|
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) =>
|
.catch((e) =>
|
||||||
setError(e instanceof Error ? e.message : "Unbekannter Fehler")
|
setError(e instanceof Error ? e.message : "Unbekannter Fehler")
|
||||||
)
|
)
|
||||||
@@ -392,6 +403,44 @@ export default function MailViewPage({
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Thread panel */}
|
||||||
|
{thread && thread.length > 1 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setThreadOpen((v) => !v)}
|
||||||
|
className="flex items-center gap-2 text-sm font-medium hover:text-foreground text-muted-foreground"
|
||||||
|
>
|
||||||
|
<svg className={`w-4 h-4 transition-transform ${threadOpen ? "rotate-90" : ""}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
Konversation ({thread.length} Mails)
|
||||||
|
</button>
|
||||||
|
</CardHeader>
|
||||||
|
{threadOpen && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<CardContent className="pt-3 space-y-1">
|
||||||
|
{thread.map((m) => (
|
||||||
|
<Link
|
||||||
|
key={m.id}
|
||||||
|
href={`/mail/${m.id}`}
|
||||||
|
className={`flex items-center justify-between gap-3 rounded-md px-3 py-2 text-sm hover:bg-muted transition-colors ${m.id === id ? "bg-muted font-medium" : ""}`}
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1 truncate">
|
||||||
|
{m.subject || "(kein Betreff)"}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-xs text-muted-foreground">
|
||||||
|
{m.date ? new Date(m.date).toLocaleDateString("de-DE") : "–"}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>)}
|
</>)}
|
||||||
|
|||||||
@@ -409,7 +409,14 @@ export default function SearchPage() {
|
|||||||
: "-"}
|
: "-"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="max-w-[14rem] truncate text-sm">{hit.from || "-"}</TableCell>
|
<TableCell className="max-w-[14rem] truncate text-sm">{hit.from || "-"}</TableCell>
|
||||||
<TableCell className="font-medium">{hit.subject || "(kein Betreff)"}</TableCell>
|
<TableCell className="font-medium">
|
||||||
|
<span>{hit.subject || "(kein Betreff)"}</span>
|
||||||
|
{hit.thread_size && hit.thread_size > 1 && (
|
||||||
|
<span className="ml-2 inline-flex items-center rounded-full bg-muted px-1.5 py-0.5 text-xs text-muted-foreground font-normal">
|
||||||
|
{hit.thread_size}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
<TableCell className="max-w-[12rem] truncate text-sm text-muted-foreground">{hit.to || "-"}</TableCell>
|
<TableCell className="max-w-[12rem] truncate text-sm text-muted-foreground">{hit.to || "-"}</TableCell>
|
||||||
<TableCell className="text-center text-sm">
|
<TableCell className="text-center text-sm">
|
||||||
{hit.has_attachments ? "📎" : ""}
|
{hit.has_attachments ? "📎" : ""}
|
||||||
|
|||||||
@@ -11,6 +11,23 @@ export interface SearchHit {
|
|||||||
date?: string;
|
date?: string;
|
||||||
size?: number;
|
size?: number;
|
||||||
has_attachments?: boolean;
|
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 {
|
export interface SearchResponse {
|
||||||
@@ -39,6 +56,7 @@ export interface MailDetail {
|
|||||||
attachments: MailAttachment[];
|
attachments: MailAttachment[];
|
||||||
verify_ok: boolean | null;
|
verify_ok: boolean | null;
|
||||||
verified_at: string | null;
|
verified_at: string | null;
|
||||||
|
thread_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ImapFolder {
|
export interface ImapFolder {
|
||||||
@@ -283,6 +301,12 @@ export async function getPop3Progress(id: number): Promise<Pop3Account> {
|
|||||||
return request<Pop3Account>(`/api/pop3/${id}/progress`);
|
return request<Pop3Account>(`/api/pop3/${id}/progress`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Thread ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getThread(threadID: string): Promise<ThreadResponse> {
|
||||||
|
return request<ThreadResponse>(`/api/threads/${encodeURIComponent(threadID)}`);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Export ────────────────────────────────────────────────────────────────────
|
// ── Export ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function exportMailPDF(id: string): Promise<{ blob: Blob; filename: string }> {
|
export async function exportMailPDF(id: string): Promise<{ blob: Blob; filename: string }> {
|
||||||
|
|||||||
Reference in New Issue
Block a user