diff --git a/cmd/archivmail-import/main.go b/cmd/archivmail-import/main.go index 9a109a7..e310356 100644 --- a/cmd/archivmail-import/main.go +++ b/cmd/archivmail-import/main.go @@ -126,6 +126,7 @@ func main() { From: pm.From, To: strings.Join(pm.To, " "), CC: strings.Join(pm.CC, " "), + Rcpt: mailStore.DeliveryRcpt(context.Background(), id, raw, pm), Subject: pm.Subject, Body: pm.TextBody + " " + pm.HTMLBody, AttachNames: strings.Join(attachNames, " "), diff --git a/cmd/archivmail/cmd_fix_subjects.go b/cmd/archivmail/cmd_fix_subjects.go index 049fa29..8666ea2 100644 --- a/cmd/archivmail/cmd_fix_subjects.go +++ b/cmd/archivmail/cmd_fix_subjects.go @@ -250,6 +250,7 @@ func reindexOne(ctx context.Context, store *storage.Store, mgr index.TenantIndex From: pm.From, To: strings.Join(pm.To, ", "), CC: strings.Join(pm.CC, ", "), + Rcpt: store.DeliveryRcpt(ctx, id, raw, pm), Subject: pm.Subject, Body: pm.TextBody, AttachNames: strings.Join(attachNames, " "), diff --git a/cmd/archivmail/cmd_import.go b/cmd/archivmail/cmd_import.go index 33b7dac..05403ea 100644 --- a/cmd/archivmail/cmd_import.go +++ b/cmd/archivmail/cmd_import.go @@ -272,6 +272,7 @@ func importMessage(mailStore *storage.Store, idxMgr index.TenantIndexer, raw []b From: pm.From, To: strings.Join(pm.To, " "), CC: strings.Join(pm.CC, " "), + Rcpt: mailStore.DeliveryRcpt(context.Background(), id, raw, pm), Subject: pm.Subject, Body: pm.TextBody + " " + pm.HTMLBody, AttachNames: strings.Join(attachNames, " "), diff --git a/cmd/archivmail/cmd_index_pending.go b/cmd/archivmail/cmd_index_pending.go index a66b1ec..cf2c720 100644 --- a/cmd/archivmail/cmd_index_pending.go +++ b/cmd/archivmail/cmd_index_pending.go @@ -135,6 +135,7 @@ func runIndexPending(args []string) { From: pm.From, To: strings.Join(pm.To, ", "), CC: strings.Join(pm.CC, ", "), + Rcpt: mailStore.DeliveryRcpt(ctx, m.ID, raw, pm), Subject: pm.Subject, Body: pm.TextBody, AttachNames: strings.Join(attachNames, " "), diff --git a/cmd/archivmail/cmd_reindex.go b/cmd/archivmail/cmd_reindex.go index 2f0315c..9ba97b1 100644 --- a/cmd/archivmail/cmd_reindex.go +++ b/cmd/archivmail/cmd_reindex.go @@ -121,6 +121,7 @@ func runReindex(args []string) { From: pm.From, To: strings.Join(pm.To, ", "), CC: strings.Join(pm.CC, ", "), + Rcpt: mailStore.DeliveryRcpt(ctx, id, raw, pm), Subject: pm.Subject, Body: pm.TextBody, AttachNames: strings.Join(attachNames, " "), diff --git a/cmd/archivmail/main.go b/cmd/archivmail/main.go index 0c23a32..4e40ed5 100644 --- a/cmd/archivmail/main.go +++ b/cmd/archivmail/main.go @@ -611,6 +611,7 @@ func submitToWorker(worker *index.TenantIndexWorker, store *storage.Store, raw [ From: pm.From, To: strings.Join(pm.To, ", "), CC: strings.Join(pm.CC, ", "), + Rcpt: store.DeliveryRcpt(context.Background(), id, raw, pm), Subject: pm.Subject, Body: pm.TextBody, AttachNames: strings.Join(attachNames, " "), @@ -753,6 +754,7 @@ func reindexTenant(ctx context.Context, store *storage.Store, mgr index.TenantIn From: pm.From, To: strings.Join(pm.To, ", "), CC: strings.Join(pm.CC, ", "), + Rcpt: store.DeliveryRcpt(ctx, id, raw, pm), Subject: pm.Subject, Body: pm.TextBody, AttachNames: strings.Join(attachNames, " "), diff --git a/docs/PRD.md b/docs/PRD.md index a8988a7..6298431 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -36,6 +36,7 @@ Ein selbst gehostetes, unternehmenstaugliches Mail-Archiv-System, das E-Mails au | P2 | REST API für externe CRM-Anbindung | Planned | | P1 | IMAP Per-Folder UID-Tracking + UIDVALIDITY-Check (Reliability-Fix) | Planned | | P1 | Zusatzadressen pro User für die Mail-Sichtbarkeit (PROJ-87) | Planned | +| P0 | Fix Mail-Sichtbarkeit bei Zustellung über Delivered-To + strikter Adress-Match (PROJ-88) | In Progress | ## Success Metrics - Import und Indexierung von 100.000+ E-Mails ohne Performanceprobleme diff --git a/features/INDEX.md b/features/INDEX.md index 78019ae..5c6979c 100644 --- a/features/INDEX.md +++ b/features/INDEX.md @@ -103,7 +103,8 @@ | PROJ-85 | Fix undeklarierte 8-Bit-Zeichen in Header/Body ohne Encoded-Word | Deployed | [PROJ-85](PROJ-85-fix-undeklarierte-8bit-header-charset.md) | 2026-08-06 | | PROJ-86 | Fix Manticore-Index-Drift (emails_global) + Datumssortierung bei fehlendem Date-Header | Deployed | [PROJ-86](PROJ-86-fix-manticore-index-drift-und-date-sortierung.md) | 2026-09-01 | | PROJ-87 | Zusatzadressen pro User für die Mail-Sichtbarkeit (Rolle "user") | Planned | [PROJ-87](PROJ-87-zusatzadressen-pro-user.md) | 2026-10-19 | +| PROJ-88 | Fix Mail-Sichtbarkeit für Rolle "user" bei Zustellung ohne Adresse in From/To/CC (Delivered-To) + strikter Adress-Match | In Progress | [PROJ-88](PROJ-88-fix-sichtbarkeit-delivered-to.md) | 2026-10-19 | -## Next Available ID: PROJ-88 +## Next Available ID: PROJ-89 diff --git a/features/PROJ-88-fix-sichtbarkeit-delivered-to.md b/features/PROJ-88-fix-sichtbarkeit-delivered-to.md new file mode 100644 index 0000000..49ce62b --- /dev/null +++ b/features/PROJ-88-fix-sichtbarkeit-delivered-to.md @@ -0,0 +1,72 @@ +# PROJ-88: Fix Mail-Sichtbarkeit für Rolle "user" bei Zustellung ohne Adresse in From/To/CC (Delivered-To) + strikter Adress-Match + +## Status: In Progress +**Created:** 2026-10-19 +**Last Updated:** 2026-10-19 + +## Dependencies +- Requires: PROJ-54 (Index-seitige User-Filterung via `AnyAddress`), PROJ-50 (`cc_addr`/`bcc_addr`), PROJ-21 (Tenant-Scope) +- Berührt: PROJ-55/PROJ-63 (Tenant-Isolation), PROJ-70 (IMAP-Rückholung) +- Ergänzt: PROJ-87 (manuelle Zusatzadressen, bleibt separat) + +## Hintergrund / Bug +Ein User der Rolle `user` sieht nur Mails, in denen seine Login-Adresse in From/To/CC/BCC steht. Mails, die per Alias, Verteiler, BCC oder Weiterleitung im Postfach landen, tragen seine Adresse nur im Zustellweg (`Delivered-To`, `X-Original-To`, `Envelope-To`) und sind unsichtbar. + +Beobachtet 2026-10-15, Tenant 1, User 19, IMAP-Konto 1: 2 von 22 Mails des Tages archiviert und indexiert, aber unsichtbar. Beide tragen die Login-Adresse exakt in `Delivered-To` (und den Received-For-Klauseln), in From/To/CC/Reply-To/Sender nicht. Ein Bcc-Header existiert nicht. + +Zusätzlicher Befund: `escapeManticoreMatch` maskiert `.` und `-` nicht und setzt den Match nicht in Anführungszeichen. Eine Adresse wird dadurch vermutlich als UND-Verknüpfung einzelner Wörter (`local`, `domain`, `tld`) gematcht statt als exakte Adresse. Die Liste kann dadurch Metadaten von Mails anderer Adressen zeigen, die der exakte Detailcheck verweigert. + +## User Stories +- Als User möchte ich Mails sehen und öffnen, die mir zugestellt wurden, auch wenn meine Adresse nur im Zustellweg steht (Alias, Verteiler, BCC, Weiterleitung). +- Als User möchte ich nur Mails sehen, die wirklich mir gehören, damit keine fremden Mails durch unscharfes Matching in meiner Liste auftauchen. +- Als Compliance-Verantwortlicher möchte ich, dass Liste und Detailzugriff dieselbe Regel nutzen, damit nichts sichtbar, aber nicht öffenbar ist. + +## Acceptance Criteria +- [ ] Der Parser liefert pro Mail die Zustelladressen aus `Delivered-To`, `X-Original-To` und `Envelope-To`. Pro Headername zählt nur das erste (oberste) Vorkommen, also das vom eigenen Mailserver zuletzt gesetzte. Weitere Vorkommen weiter unten (Weiterleitungsketten, vom Absender mitgeschickte Header) zählen nicht. +- [ ] Vertrauensgrenze: Zustelladressen werden nur für Mails aus Pull-Quellen (`imap`, `pop3`) ausgewertet, bei denen der eigene Mailserver den Header gesetzt hat. Bei SMTP-Einlieferung und Upload/Import (`smtp`, Upload, MBOX/EML) sind die Header vom Absender kontrolliert und werden ignoriert (Feld bleibt leer). +- [ ] Adressen werden mit `net/mail` geparst, lowercase normalisiert; nicht parsbare Werte liefern keine Adresse (fail-closed). +- [ ] Index: neues Textfeld für Zustelladressen (idempotent per `ensureColumn`, auch in `emails_global` und `emails_tenant_N`). Alle Stellen, die `MailDocument` bauen (IMAP, POP3, SMTP/Upload, Import-CLIs, Reindex, index-pending, fix-subjects), befüllen es. +- [ ] `AnyAddress`-Match (Rolle `user`) umfasst From, To, CC, BCC und das neue Zustelladressfeld. +- [ ] `AnyAddress`-Match ist exakt: Adresse wird als Phrase gematcht (in Anführungszeichen, Sonderzeichen maskiert), nicht als UND einzelner Wörter. Test gegen die Match-Konstruktion mit Adressen mit `.`, `-`, `+` und Teilübereinstimmungen (`stefan@a.de` darf `stefan@b.de` nicht treffen). +- [ ] `mailBelongsToUser()` prüft zusätzlich die Zustelladressen mit exaktem Vergleich. Alle Aufrufer (Detail/Anhänge, Threads, OCR, Export, eDiscovery, IMAP-Rückholung) profitieren ohne weitere Änderung. +- [ ] Listen-/Detailkonsistenz: Jede in der Liste für User X sichtbare Mail ist für X öffenbar und umgekehrt. Test mit Mail, die nur im Zustellheader die Adresse trägt. +- [ ] Tenant-Isolation unverändert: Es werden ausschließlich Mails des Tenants des Users durchsucht; kein Cross-Tenant-Treffer durch das neue Feld. +- [ ] Fail-safe bleibt: leere Login-Adresse → `total: 0, hits: []`. +- [ ] Bestandsmails: nach `archivmail reindex` sind die Zustelladressen befüllt. Ohne Reindex verhalten sich Bestandsmails wie bisher (kein Regress). +- [ ] Tests (synthetische Daten): Parser (oberstes vs. weitere Header, ungültige Werte, Groß-/Kleinschreibung), Match-Konstruktion, `mailBelongsToUser` mit Delivered-To, Rollenfilter. + +## Edge Cases +- Mehrere `Delivered-To` im Header (Weiterleitungskette): nur das oberste zählt; andere Adressen erhalten dadurch keine Sichtbarkeit. +- Vom Absender gefälschter `Delivered-To`: steht weiter unten als der eigene und zählt nicht; selbst bei Treffer sieht der Nutzer nur eine Mail mit seiner Adresse im Zustellweg. +- Geteiltes Postfach/Sammelkonto: `Delivered-To` trägt die Postfachadresse. Nur User, deren Login-Adresse exakt dieser Adresse entspricht, sehen die Mails. +- `Envelope-To` mit mehreren Adressen (Komma-Liste): alle geparsten Adressen zählen. +- Adresse mit Plus-Tag (`user+tag@domain`): exakter Vergleich, kein Tag-Stripping. +- Mails ohne diese Header: Verhalten wie bisher. +- Parse-Fehler beim Reindex einer Mail: Mail bleibt wie bisher indexiert, Zustellfeld leer. + +## Technical Requirements +- Security: exakter Match, Tenant-Isolation, keine Lockerung des Fail-safe; keine Änderung am JWT. +- Performance: ein zusätzliches Textfeld im Match; Reindex von ~58.000 Mails auf 132 in Wartungsfenster, nicht während Last. +- Deployment: neues Indexfeld erfordert nach Deploy `archivmail reindex` auf 132 (Freigabe des Users vor Ausführung einholen). + +--- + +## Tech Design (Solution Architect) +_Übersprungen: Bugfix mit klar umrissenem Umfang (Parser-Feld, Indexfeld, Match, `mailBelongsToUser`)._ + +## Implementation Notes (Backend, 2026-10-19) +- **Parser** (`pkg/mailparser/delivery.go`): `ParsedMail.DeliveryAddresses` = oberstes Vorkommen von `Delivered-To`, `X-Original-To`, `Envelope-To` (net/mail, lowercase, dedupliziert, unparsbar → nichts; ein unparsbares oberstes Vorkommen wird NICHT durch ein tieferes ersetzt). Zugriff nur über `TrustedDeliveryAddresses(pm, sourceType)`, liefert nur für `imap`/`pop3` Werte (`IsTrustedDeliverySource`). +- **Index**: neue Textspalte `rcpt_addr` (CREATE TABLE + `ensureColumn`), `MailDocument.Rcpt`, in `IndexSync` und `UpdateAttachmentText` (OCR-REPLACE) mitgeführt. +- **Gemeinsamer Helper** `storage.Store.DeliveryRcpt(ctx, id, raw, pm)` (`internal/storage/delivery_rcpt.go`), genutzt von allen MailDocument-Buildern (IMAP, POP3, Upload, SMTP-Worker `submitToWorker`, `reindexTenant`, reindex, index-pending, fix-subjects, import, archivmail-import). Quelle ist immer `emails.source_type` aus der DB, nicht der Aufrufkontext. Weicht `sha256(raw)` von der ID ab (Message-ID-Dedup auf eine andere gespeicherte Kopie), wird die gespeicherte Kopie geparst — Index und Detailcheck werten damit dieselbe Kopie und dieselbe Quelle aus. +- **Match** (`internal/index/address_match.go`): `AnyAddress` → `@(from_addr,to_addr,cc_addr,bcc_addr,rcpt_addr) ""` als Phrase, Sonderzeichen maskiert. Adresse ohne Buchstabe/Ziffer → leeres Ergebnis (fail-closed). From/To/OwnEmail-Pfade unverändert. +- **Restunschärfe der Phrase**: Manticore tokenisiert an `@ . - +`, daher matcht die Phrase `alice@example.com` auch `x.alice@example.com` oder `alice.example@com`. Deshalb prüft `handleSearch` für Rolle `user` jeden Treffer der Seite zusätzlich mit `mailBelongsToUser` (exakte Regel wie Detailansicht) und verwirft Abweichler; `total` wird um die verworfenen Treffer der Seite korrigiert (kann bei solchen Randfällen auf späteren Seiten minimal überzählen, es werden aber keine Metadaten fremder Mails ausgeliefert). +- **Detailzugriff**: `mailBelongsToUser(pm, email, sourceType)` (verschoben nach `internal/api/mail_ownership.go`) prüft zusätzlich die vertrauenswürdigen Zustelladressen exakt. Alle Aufrufer (Detail, Anhang, Raw, PDF/ZIP-Export, eDiscovery, OCR, Threads, IMAP-Rückholung) laufen über `s.userOwnsMail(ctx, id, pm, email)`, das `source_type` aus der DB holt; Lookup-Fehler → nur Zustellpfad entfällt (nie permissiver als vorher). +- **Nebeneffekt DSGVO-Löschersuchen**: nutzt ebenfalls `AnyAddress` und sucht damit jetzt exakt als Phrase und zusätzlich in `rcpt_addr`. +- **Tests**: `pkg/mailparser/delivery_test.go`, `internal/index/address_match_test.go` (Phrase-vs-Wort-UND gegen simulierte Manticore-Tokenisierung, `stefan@a.de` ≠ `stefan@b.de`, `.`/`-`/`+`, Injection), `internal/api/mail_ownership_test.go` (Header, Vertrauensgrenze, Listen-/Detailkonsistenz). Lokal kein Go-Toolchain vorhanden — Build/Tests nicht lokal ausgeführt. +- **Offen**: BCC wird vom Parser nicht ausgewertet (`bcc_addr` bleibt leer wie bisher). `source_type` ist First-Write-Wins pro Mail: kam eine Mail zuerst per SMTP/Upload, zählen ihre Zustellheader auch bei späterem IMAP-Abruf nicht. Bei Message-ID-Dedup gilt nur der Zustellheader der zuerst gespeicherten Kopie (eine Kopie pro Mail) — weitere Empfänger desselben Verteilers sehen die Mail nur über From/To/CC. Nach Deploy ist `archivmail reindex` nötig (Freigabe des Users einholen). + +## QA Test Results +_To be added by /qa_ + +## Deployment +_To be added by /deploy_ diff --git a/internal/api/ediscovery.go b/internal/api/ediscovery.go index f3130bf..73362fc 100644 --- a/internal/api/ediscovery.go +++ b/internal/api/ediscovery.go @@ -187,7 +187,7 @@ func (s *Server) handleExportEDiscovery(w http.ResponseWriter, r *http.Request) continue } - if userEmail != "" && !mailBelongsToUser(pm, userEmail) { + if userEmail != "" && !s.userOwnsMail(r.Context(), id, pm, userEmail) { continue } diff --git a/internal/api/export.go b/internal/api/export.go index 90d756c..f915517 100644 --- a/internal/api/export.go +++ b/internal/api/export.go @@ -370,7 +370,7 @@ func (s *Server) handleExportPDF(w http.ResponseWriter, r *http.Request) { // user: only own mails; domain_auditor: all tenant mails (no filter) if sess.Role == userstore.RoleUser { u, err := s.users.GetByUsername(sess.Username) - if err != nil || u == nil || !mailBelongsToUser(pm, u.Email) { + if err != nil || u == nil || !s.userOwnsMail(r.Context(), id, pm, u.Email) { writeError(w, http.StatusForbidden, "access denied") return } @@ -511,7 +511,7 @@ func (s *Server) handleExportZIP(w http.ResponseWriter, r *http.Request) { } } // user: only own mails; domain_auditor: all tenant mails (no filter) - if sess.Role == userstore.RoleUser && !mailBelongsToUser(pm, userEmail) { + if sess.Role == userstore.RoleUser && !s.userOwnsMail(r.Context(), id, pm, userEmail) { continue } diff --git a/internal/api/mail_ownership.go b/internal/api/mail_ownership.go new file mode 100644 index 0000000..4f8ff6b --- /dev/null +++ b/internal/api/mail_ownership.go @@ -0,0 +1,71 @@ +package api + +import ( + "context" + "net/mail" + "strings" + + "archivmail/pkg/mailparser" +) + +// emailsFromHeader is fail-closed: an unparseable (possibly attacker-crafted) +// header yields no addresses and therefore grants no access. +func emailsFromHeader(header string) []string { + addrs, err := mail.ParseAddressList(header) + if err != nil { + return nil + } + out := make([]string, len(addrs)) + for i, a := range addrs { + out[i] = strings.ToLower(a.Address) + } + return out +} + +// sourceType must be the stored emails.source_type of the mail: delivery +// headers only count for trusted pull sources (see mailparser). +func mailBelongsToUser(pm *mailparser.ParsedMail, userEmail, sourceType string) bool { + target := strings.ToLower(strings.TrimSpace(userEmail)) + if pm == nil || target == "" { + return false + } + + checkHeader := func(header string) bool { + for _, addr := range emailsFromHeader(header) { + if addr == target { + return true + } + } + return false + } + + if checkHeader(pm.From) { + return true + } + for _, to := range pm.To { + if checkHeader(to) { + return true + } + } + for _, cc := range pm.CC { + if checkHeader(cc) { + return true + } + } + for _, addr := range mailparser.TrustedDeliveryAddresses(pm, sourceType) { + if addr == target { + return true + } + } + return false +} + +// A failed source lookup only drops the delivery-header path; From/To/CC +// still apply, so the result can never be more permissive than before. +func (s *Server) userOwnsMail(ctx context.Context, id string, pm *mailparser.ParsedMail, userEmail string) bool { + src, err := s.store.GetSourceType(ctx, id) + if err != nil { + src = "" + } + return mailBelongsToUser(pm, userEmail, src) +} diff --git a/internal/api/mail_ownership_test.go b/internal/api/mail_ownership_test.go new file mode 100644 index 0000000..3c3c77b --- /dev/null +++ b/internal/api/mail_ownership_test.go @@ -0,0 +1,101 @@ +package api + +import ( + "slices" + "strings" + "testing" + + "archivmail/pkg/mailparser" +) + +func parseTestMail(t *testing.T, headers string) *mailparser.ParsedMail { + t.Helper() + raw := headers + "Subject: test\r\n\r\nbody\r\n" + pm, err := mailparser.Parse([]byte(raw)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + return pm +} + +func TestMailBelongsToUserHeaders(t *testing.T) { + pm := parseTestMail(t, + "From: Carol \r\n"+ + "To: dave@example.org, \"Erin\" \r\n"+ + "Cc: frank@example.org\r\n") + tests := []struct { + email string + want bool + }{ + {"carol@example.org", true}, + {"CAROL@Example.org", true}, + {"dave@example.org", true}, + {"erin+news@example.org", true}, + {"erin@example.org", false}, + {"frank@example.org", true}, + {"carol@example.com", false}, + {"arol@example.org", false}, + {"", false}, + } + for _, tt := range tests { + if got := mailBelongsToUser(pm, tt.email, "smtp"); got != tt.want { + t.Errorf("%q: got %v, want %v", tt.email, got, tt.want) + } + } + if mailBelongsToUser(nil, "carol@example.org", "imap") { + t.Error("nil mail must not grant access") + } +} + +func TestMailBelongsToUserDeliveryTrustBoundary(t *testing.T) { + pm := parseTestMail(t, + "Delivered-To: alice@example.com\r\n"+ + "Delivered-To: mallory@example.net\r\n"+ + "X-Original-To: Team-List@Example.com\r\n"+ + "From: carol@example.org\r\n"+ + "To: dave@example.org\r\n") + + for _, src := range []string{"imap", "pop3"} { + if !mailBelongsToUser(pm, "alice@example.com", src) { + t.Errorf("%s: topmost Delivered-To must grant access", src) + } + if !mailBelongsToUser(pm, "team-list@example.com", src) { + t.Errorf("%s: X-Original-To must grant access", src) + } + if mailBelongsToUser(pm, "mallory@example.net", src) { + t.Errorf("%s: lower Delivered-To must not grant access", src) + } + if mailBelongsToUser(pm, "alice@example.org", src) { + t.Errorf("%s: different domain must not match", src) + } + } + for _, src := range []string{"smtp", "import", ""} { + if mailBelongsToUser(pm, "alice@example.com", src) { + t.Errorf("%q: delivery headers must be ignored for untrusted source", src) + } + if !mailBelongsToUser(pm, "dave@example.org", src) { + t.Errorf("%q: To must still grant access", src) + } + } +} + +// List visibility for role user is driven by MailDocument.Rcpt (built from +// TrustedDeliveryAddresses) plus the same exact re-check; detail access must +// agree with it for every source type. +func TestDeliveryListDetailConsistency(t *testing.T) { + pm := parseTestMail(t, + "Delivered-To: alice@example.com\r\n"+ + "Envelope-To: bob@example.com, carol.x@example.com\r\n"+ + "From: zed@example.org\r\n"+ + "To: list@example.org\r\n") + users := []string{"alice@example.com", "bob@example.com", "carol.x@example.com", "carol@example.com", "x@example.com"} + for _, src := range []string{"imap", "pop3", "smtp", "import", ""} { + rcpt := strings.Fields(strings.Join(mailparser.TrustedDeliveryAddresses(pm, src), " ")) + for _, u := range users { + inIndex := slices.Contains(rcpt, u) + if got := mailBelongsToUser(pm, u, src); got != inIndex { + t.Errorf("source %q user %q: detail=%v, rcpt index=%v", src, u, got, inIndex) + } + } + } +} diff --git a/internal/api/ocr_handlers.go b/internal/api/ocr_handlers.go index a5caa76..453fd0f 100644 --- a/internal/api/ocr_handlers.go +++ b/internal/api/ocr_handlers.go @@ -63,7 +63,7 @@ func (s *Server) handleGetOCRText(w http.ResponseWriter, r *http.Request) { return } u, err := s.users.GetByUsername(sess.Username) - if err != nil || u == nil || !mailBelongsToUser(pm, u.Email) { + if err != nil || u == nil || !s.userOwnsMail(r.Context(), id, pm, u.Email) { writeError(w, http.StatusForbidden, "access denied") return } diff --git a/internal/api/restore_handlers.go b/internal/api/restore_handlers.go index 462a3ed..11fea26 100644 --- a/internal/api/restore_handlers.go +++ b/internal/api/restore_handlers.go @@ -231,7 +231,7 @@ func (s *Server) restoreAccessAllowed(ctx context.Context, sess *auth.Session, u } } // The mail must actually involve the user (From/To/CC). - if !mailBelongsToUser(pm, user.Email) { + if !s.userOwnsMail(ctx, mailID, pm, user.Email) { return false } return true diff --git a/internal/api/search_handlers.go b/internal/api/search_handlers.go index ffb02bb..ecf96f9 100644 --- a/internal/api/search_handlers.go +++ b/internal/api/search_handlers.go @@ -3,7 +3,6 @@ package api import ( "fmt" "net/http" - "net/mail" "strconv" "strings" "time" @@ -201,12 +200,35 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { threadInfo, _ := s.store.GetThreadInfo(r.Context(), hitIDs) receivedAts := s.store.GetReceivedAts(r.Context(), hitIDs) + // PROJ-88: the index phrase match is token-based (x.alice@example.com + // contains the phrase of alice@example.com), so user hits are re-checked + // with the exact detail-access rule before any metadata is returned. + var userSources map[string]string + if req.AnyAddress != "" && sess.Role == userstore.RoleUser { + var srcErr error + userSources, srcErr = s.store.GetSourceTypes(r.Context(), hitIDs) + if srcErr != nil { + userSources = map[string]string{} + } + } + dropped := 0 + enriched := make([]enrichedHit, 0, len(result.Hits)) for _, h := range result.Hits { eh := enrichedHit{ID: h.ID, Score: h.Score, Snippet: h.Snippet, MatchField: h.MatchField} - if raw, err := s.store.Load(h.ID); err == nil { + raw, loadErr := s.store.Load(h.ID) + var pm *mailparser.ParsedMail + parseErr := loadErr + if loadErr == nil { + pm, parseErr = mailparser.Parse(raw) + } + if userSources != nil && (parseErr != nil || !mailBelongsToUser(pm, req.AnyAddress, userSources[h.ID])) { + dropped++ + continue + } + if loadErr == nil { eh.Size = int64(len(raw)) - if pm, err := mailparser.Parse(raw); err == nil { + if parseErr == nil { eh.From = pm.From if len(pm.To) > 0 { eh.To = strings.Join(pm.To, ", ") @@ -234,8 +256,13 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { enriched = append(enriched, eh) } + total := result.Total - dropped + if total < len(enriched) { + total = len(enriched) + } + writeJSON(w, http.StatusOK, map[string]interface{}{ - "total": result.Total, + "total": total, "hits": enriched, }) } @@ -284,7 +311,7 @@ func (s *Server) handleGetMail(w http.ResponseWriter, r *http.Request) { // user: only own mails; domain_auditor: all tenant mails (no filter) if sess.Role == userstore.RoleUser { - if sess.Email == "" || !mailBelongsToUser(pm, sess.Email) { + if sess.Email == "" || !s.userOwnsMail(r.Context(), id, pm, sess.Email) { writeError(w, http.StatusForbidden, "access denied") return } @@ -426,7 +453,7 @@ func (s *Server) handleGetAttachment(w http.ResponseWriter, r *http.Request) { // user: only own mails; domain_auditor: all tenant mails (no filter) if sess.Role == userstore.RoleUser { u, err := s.users.GetByUsername(sess.Username) - if err != nil || u == nil || !mailBelongsToUser(pm, u.Email) { + if err != nil || u == nil || !s.userOwnsMail(r.Context(), id, pm, u.Email) { writeError(w, http.StatusForbidden, "access denied") return } @@ -494,7 +521,7 @@ func (s *Server) handleGetRaw(w http.ResponseWriter, r *http.Request) { return } u, err := s.users.GetByUsername(sess.Username) - if err != nil || u == nil || !mailBelongsToUser(pm, u.Email) { + if err != nil || u == nil || !s.userOwnsMail(r.Context(), id, pm, u.Email) { writeError(w, http.StatusForbidden, "access denied") return } @@ -507,57 +534,6 @@ func (s *Server) handleGetRaw(w http.ResponseWriter, r *http.Request) { w.Write(raw) } -// emailsFromHeader parses a mail header value (e.g. From, To, CC) and returns -// the bare email addresses it contains. Parsing uses net/mail.ParseAddressList -// for correctness. If parsing fails, nil is returned (fail-closed): a -// malformed or attacker-controlled header must not grant access. -func emailsFromHeader(header string) []string { - addrs, err := mail.ParseAddressList(header) - if err != nil { - // SEC: fail-closed — unparseable header grants no access. - return nil - } - out := make([]string, len(addrs)) - for i, a := range addrs { - out[i] = strings.ToLower(a.Address) - } - return out -} - -// mailBelongsToUser checks if the user's email appears in From, To, or CC. -// Addresses are parsed with net/mail.ParseAddressList so that display names -// ("Name ") do not cause false positives or negatives. -// Falls back to case-insensitive substring matching when parsing fails. -func mailBelongsToUser(pm *mailparser.ParsedMail, userEmail string) bool { - target := strings.ToLower(userEmail) - - checkHeader := func(header string) bool { - parsed := emailsFromHeader(header) - for _, addr := range parsed { - // Exact match on parsed address — only valid path after fail-closed parsing. - if addr == target { - return true - } - } - return false - } - - if checkHeader(pm.From) { - return true - } - for _, to := range pm.To { - if checkHeader(to) { - return true - } - } - for _, cc := range pm.CC { - if checkHeader(cc) { - return true - } - } - return false -} - // extractRawHeaders returns the header section of a raw RFC 2822 email. func extractRawHeaders(raw []byte) string { for i := 0; i < len(raw)-3; i++ { diff --git a/internal/api/thread_handlers.go b/internal/api/thread_handlers.go index fa8f286..1747aff 100644 --- a/internal/api/thread_handlers.go +++ b/internal/api/thread_handlers.go @@ -74,7 +74,7 @@ func (s *Server) handleGetThread(w http.ResponseWriter, r *http.Request) { // user isolation if sess.Role == userstore.RoleUser { - if sess.Email == "" || !mailBelongsToUser(pm, strings.ToLower(sess.Email)) { + if sess.Email == "" || !s.userOwnsMail(r.Context(), id, pm, strings.ToLower(sess.Email)) { continue } } diff --git a/internal/api/upload.go b/internal/api/upload.go index 043b816..f457530 100644 --- a/internal/api/upload.go +++ b/internal/api/upload.go @@ -218,6 +218,7 @@ func (s *Server) importRawMessage(ctx context.Context, raw []byte, tenantID *int From: pm.From, To: strings.Join(pm.To, " "), CC: strings.Join(pm.CC, " "), + Rcpt: s.store.DeliveryRcpt(ctx, id, raw, pm), Subject: pm.Subject, Body: pm.TextBody + " " + pm.HTMLBody, AttachNames: strings.Join(attachNames, " "), diff --git a/internal/imap/importer.go b/internal/imap/importer.go index 6a1265e..df7c287 100644 --- a/internal/imap/importer.go +++ b/internal/imap/importer.go @@ -291,6 +291,7 @@ func (imp *Importer) storeAndIndex(raw []byte, tenantID *int64, accountID int64, From: pm.From, To: strings.Join(pm.To, ", "), CC: strings.Join(pm.CC, ", "), + Rcpt: imp.mailStore.DeliveryRcpt(ctx, id, raw, pm), Subject: pm.Subject, Body: pm.TextBody, AttachNames: strings.Join(attachNames, " "), diff --git a/internal/index/address_match.go b/internal/index/address_match.go new file mode 100644 index 0000000..0882a8d --- /dev/null +++ b/internal/index/address_match.go @@ -0,0 +1,31 @@ +package index + +import ( + "strings" + "unicode" +) + +const anyAddressFields = "@(from_addr,to_addr,cc_addr,bcc_addr,rcpt_addr)" + +// Without the phrase quotes Manticore splits the address at '@', '.', '-' +// and ANDs the parts, so alice@a.de would also match "alice@b.de x@a.de". +func anyAddressMatch(addr string) (string, bool) { + addr = strings.ToLower(strings.TrimSpace(addr)) + if !strings.ContainsFunc(addr, func(r rune) bool { return unicode.IsLetter(r) || unicode.IsDigit(r) }) { + return "", false + } + return anyAddressFields + ` "` + escapeManticorePhrase(addr) + `"`, true +} + +func escapeManticorePhrase(s string) string { + const specials = `\()|!@~"/^$=<-'*?&` + var b strings.Builder + b.Grow(len(s) * 2) + for _, c := range s { + if strings.ContainsRune(specials, c) { + b.WriteRune('\\') + } + b.WriteRune(c) + } + return b.String() +} diff --git a/internal/index/address_match_test.go b/internal/index/address_match_test.go new file mode 100644 index 0000000..6f9ffd6 --- /dev/null +++ b/internal/index/address_match_test.go @@ -0,0 +1,146 @@ +package index + +import ( + "strings" + "testing" + "unicode" +) + +// Approximates Manticore's default charset_table: letters/digits form words, +// everything else separates, matching is case-insensitive. +func tokenize(s string) []string { + return strings.FieldsFunc(strings.ToLower(s), func(r rune) bool { + return !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '_' + }) +} + +func phraseOf(t *testing.T, match string) string { + t.Helper() + if !strings.HasPrefix(match, anyAddressFields+` "`) || !strings.HasSuffix(match, `"`) { + t.Fatalf("unexpected match shape: %q", match) + } + inner := match[len(anyAddressFields)+2 : len(match)-1] + var b strings.Builder + escaped := false + for _, c := range inner { + if escaped { + b.WriteRune(c) + escaped = false + continue + } + if c == '\\' { + escaped = true + continue + } + if c == '"' { + t.Fatalf("unescaped quote inside phrase: %q", match) + } + b.WriteRune(c) + } + if escaped { + t.Fatalf("dangling escape in %q", match) + } + return b.String() +} + +func phraseMatches(phrase, field string) bool { + p, f := tokenize(phrase), tokenize(field) + if len(p) == 0 { + return false + } + for i := 0; i+len(p) <= len(f); i++ { + ok := true + for j := range p { + if f[i+j] != p[j] { + ok = false + break + } + } + if ok { + return true + } + } + return false +} + +func andMatches(words, field string) bool { + f := map[string]bool{} + for _, w := range tokenize(field) { + f[w] = true + } + for _, w := range tokenize(words) { + if !f[w] { + return false + } + } + return true +} + +func TestAnyAddressMatchShape(t *testing.T) { + got, ok := anyAddressMatch("Stefan@A.de") + if !ok { + t.Fatal("expected ok") + } + want := `@(from_addr,to_addr,cc_addr,bcc_addr,rcpt_addr) "stefan\@a.de"` + if got != want { + t.Errorf("got %q, want %q", got, want) + } + + got, _ = anyAddressMatch("max-muster+tag@ex-ample.com") + want = `@(from_addr,to_addr,cc_addr,bcc_addr,rcpt_addr) "max\-muster+tag\@ex\-ample.com"` + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestAnyAddressMatchRejectsEmpty(t *testing.T) { + for _, in := range []string{"", " ", "@@", `"."`} { + if _, ok := anyAddressMatch(in); ok { + t.Errorf("%q: expected not ok", in) + } + } +} + +func TestAnyAddressMatchEscapesInjection(t *testing.T) { + m, ok := anyAddressMatch(`alice@example.com" | @body secret "`) + if !ok { + t.Fatal("expected ok") + } + phraseOf(t, m) + if strings.Count(m, `"`)-strings.Count(m, `\"`) != 2 { + t.Errorf("phrase delimiters not intact: %q", m) + } +} + +func TestAnyAddressMatchIsPhraseNotWordAnd(t *testing.T) { + tests := []struct { + addr, field string + want bool + }{ + {"stefan@a.de", "stefan@a.de", true}, + {"stefan@a.de", "Stefan@A.DE", true}, + {"stefan@a.de", "bob@example.com stefan@a.de", true}, + {"stefan@a.de", "stefan@b.de", false}, + {"stefan@a.de", "stefan@b.de x@a.de", false}, + {"stefan@a.de", "a@stefan.de", false}, + {"bob+news@example.com", "bob@example.com", false}, + {"bob+news@example.com", "bob+news@example.com", true}, + {"bob@example.com", "bob+news@example.com", false}, + {"max-muster@ex-ample.com", "max-muster@ex-ample.com", true}, + {"max-muster@ex-ample.com", "muster@ex-ample.com", false}, + {"alice.smith@example.com", "alice@example.com smith@example.com", false}, + } + for _, tt := range tests { + m, ok := anyAddressMatch(tt.addr) + if !ok { + t.Fatalf("%q: not ok", tt.addr) + } + if got := phraseMatches(phraseOf(t, m), tt.field); got != tt.want { + t.Errorf("addr %q vs field %q: got %v, want %v", tt.addr, tt.field, got, tt.want) + } + } + + if !andMatches("stefan@a.de", "stefan@b.de x@a.de") { + t.Error("sanity: word-AND semantics should produce the false positive the phrase avoids") + } +} diff --git a/internal/index/index.go b/internal/index/index.go index cb90335..e664927 100644 --- a/internal/index/index.go +++ b/internal/index/index.go @@ -24,6 +24,7 @@ type MailDocument struct { To string CC string // PROJ-50: CC addresses (space-joined) for DSGVO address search BCC string // PROJ-50: BCC addresses (space-joined) for DSGVO address search + Rcpt string // PROJ-88: trusted delivery addresses (Delivered-To etc.), space-joined Subject string Body string AttachNames string @@ -40,7 +41,7 @@ type SearchRequest struct { From string To string OwnEmail string - AnyAddress string // PROJ-50: match address in from/to/cc/bcc (DSGVO Löschersuchen) + AnyAddress string // PROJ-50/88: exact phrase match in from/to/cc/bcc/rcpt DateFrom *time.Time DateTo *time.Time HasAttachment *bool // nil=no filter, true=only with, false=only without diff --git a/internal/index/manticore.go b/internal/index/manticore.go index 8ba0e50..82167ea 100644 --- a/internal/index/manticore.go +++ b/internal/index/manticore.go @@ -115,6 +115,7 @@ func (idx *manticoreIndex) ensureTable() error { to_addr text, cc_addr text, bcc_addr text, + rcpt_addr text, body text, attachment_names text, attachment_text text, @@ -139,6 +140,9 @@ func (idx *manticoreIndex) ensureTable() error { if err := idx.ensureColumn("bcc_addr", "text"); err != nil { return err } + if err := idx.ensureColumn("rcpt_addr", "text"); err != nil { + return err + } return nil } @@ -183,8 +187,8 @@ func (idx *manticoreIndex) IndexSync(doc MailDocument) error { _, err := idx.db.Exec( fmt.Sprintf(`REPLACE INTO %s - (id, mail_id, subject, from_addr, to_addr, cc_addr, bcc_addr, body, attachment_names, attachment_text, has_attachment, date_ts, size_bytes) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`, idx.table), + (id, mail_id, subject, from_addr, to_addr, cc_addr, bcc_addr, rcpt_addr, body, attachment_names, attachment_text, has_attachment, date_ts, size_bytes) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, idx.table), rowID, doc.ID, doc.Subject, @@ -192,6 +196,7 @@ func (idx *manticoreIndex) IndexSync(doc MailDocument) error { doc.To, doc.CC, doc.BCC, + doc.Rcpt, doc.Body, doc.AttachNames, doc.AttachmentText, @@ -215,24 +220,24 @@ func (idx *manticoreIndex) IndexSync(doc MailDocument) error { func (idx *manticoreIndex) UpdateAttachmentText(mailID, text string) error { rowID := hashMailID(mailID) row := idx.db.QueryRow(fmt.Sprintf( - `SELECT mail_id, subject, from_addr, to_addr, cc_addr, bcc_addr, body, attachment_names, + `SELECT mail_id, subject, from_addr, to_addr, cc_addr, bcc_addr, rcpt_addr, body, attachment_names, has_attachment, date_ts, size_bytes FROM %s WHERE id = ? LIMIT 1`, idx.table), rowID, ) var ( - mid, subj, from, to, cc, bcc, body, attachNames string - hasAttach uint64 - dateTS, sizeBytes int64 + mid, subj, from, to, cc, bcc, rcpt, body, attachNames string + hasAttach uint64 + dateTS, sizeBytes int64 ) - if err := row.Scan(&mid, &subj, &from, &to, &cc, &bcc, &body, &attachNames, &hasAttach, &dateTS, &sizeBytes); err != nil { + if err := row.Scan(&mid, &subj, &from, &to, &cc, &bcc, &rcpt, &body, &attachNames, &hasAttach, &dateTS, &sizeBytes); err != nil { return fmt.Errorf("manticore UpdateAttachmentText %s: load row: %w", idx.table, err) } if _, err := idx.db.Exec( fmt.Sprintf(`REPLACE INTO %s - (id, mail_id, subject, from_addr, to_addr, cc_addr, bcc_addr, body, attachment_names, attachment_text, has_attachment, date_ts, size_bytes) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`, idx.table), - rowID, mid, subj, from, to, cc, bcc, body, attachNames, text, hasAttach, dateTS, sizeBytes, + (id, mail_id, subject, from_addr, to_addr, cc_addr, bcc_addr, rcpt_addr, body, attachment_names, attachment_text, has_attachment, date_ts, size_bytes) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, idx.table), + rowID, mid, subj, from, to, cc, bcc, rcpt, body, attachNames, text, hasAttach, dateTS, sizeBytes, ); err != nil { return fmt.Errorf("manticore UpdateAttachmentText %s: replace: %w", idx.table, err) } @@ -290,7 +295,11 @@ func (idx *manticoreIndex) Search(req SearchRequest) (*SearchResult, error) { matchParts = append(matchParts, fmt.Sprintf("@(from_addr,to_addr) %s", escapeManticoreMatch(req.OwnEmail))) } if req.AnyAddress != "" { - matchParts = append(matchParts, fmt.Sprintf("@(from_addr,to_addr,cc_addr,bcc_addr) %s", escapeManticoreMatch(req.AnyAddress))) + m, ok := anyAddressMatch(req.AnyAddress) + if !ok { + return &SearchResult{}, nil + } + matchParts = append(matchParts, m) } hasMatch := len(matchParts) > 0 diff --git a/internal/pop3/importer.go b/internal/pop3/importer.go index 1b1a082..c2c3ca1 100644 --- a/internal/pop3/importer.go +++ b/internal/pop3/importer.go @@ -169,6 +169,7 @@ func (imp *Importer) storeAndIndex(raw []byte, accountID int64, log *slog.Logger From: pm.From, To: strings.Join(pm.To, ", "), CC: strings.Join(pm.CC, ", "), + Rcpt: imp.mailStore.DeliveryRcpt(ctx, id, raw, pm), Subject: pm.Subject, Body: pm.TextBody, AttachNames: strings.Join(attachNames, " "), diff --git a/internal/storage/delivery_rcpt.go b/internal/storage/delivery_rcpt.go new file mode 100644 index 0000000..6e4ab33 --- /dev/null +++ b/internal/storage/delivery_rcpt.go @@ -0,0 +1,78 @@ +package storage + +import ( + "context" + "crypto/sha256" + "errors" + "fmt" + "strings" + + "github.com/jackc/pgx/v5" + + "archivmail/pkg/mailparser" +) + +// GetSourceType returns "" for legacy mails without source tag and unknown IDs. +func (s *Store) GetSourceType(ctx context.Context, id string) (string, error) { + if s.db == nil { + return "", nil + } + var st *string + err := s.db.QueryRow(ctx, `SELECT source_type FROM emails WHERE id = $1`, id).Scan(&st) + if errors.Is(err, pgx.ErrNoRows) { + return "", nil + } + if err != nil { + return "", fmt.Errorf("storage: get source type: %w", err) + } + if st == nil { + return "", nil + } + return *st, nil +} + +func (s *Store) GetSourceTypes(ctx context.Context, ids []string) (map[string]string, error) { + out := make(map[string]string, len(ids)) + if s.db == nil || len(ids) == 0 { + return out, nil + } + rows, err := s.db.Query(ctx, + `SELECT id, source_type FROM emails WHERE id = ANY($1) AND source_type IS NOT NULL`, ids) + if err != nil { + return nil, fmt.Errorf("storage: get source types: %w", err) + } + defer rows.Close() + for rows.Next() { + var id, st string + if err := rows.Scan(&id, &st); err != nil { + return nil, fmt.Errorf("storage: get source types scan: %w", err) + } + out[id] = st + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("storage: get source types rows: %w", err) + } + return out, nil +} + +// DeliveryRcpt returns the index value for MailDocument.Rcpt. It is derived +// from the stored source_type and the stored raw mail, exactly like the +// detail-access check, so list and detail visibility cannot diverge. raw/pm +// may belong to a Message-ID duplicate whose headers differ from the stored +// copy; in that case the stored copy is parsed instead. +func (s *Store) DeliveryRcpt(ctx context.Context, id string, raw []byte, pm *mailparser.ParsedMail) string { + src, err := s.GetSourceType(ctx, id) + if err != nil || !mailparser.IsTrustedDeliverySource(src) { + return "" + } + if pm == nil || fmt.Sprintf("%x", sha256.Sum256(raw)) != id { + stored, err := s.Load(id) + if err != nil { + return "" + } + if pm, err = mailparser.Parse(stored); err != nil { + return "" + } + } + return strings.Join(mailparser.TrustedDeliveryAddresses(pm, src), " ") +} diff --git a/pkg/mailparser/delivery.go b/pkg/mailparser/delivery.go new file mode 100644 index 0000000..7bfa543 --- /dev/null +++ b/pkg/mailparser/delivery.go @@ -0,0 +1,50 @@ +package mailparser + +import ( + "net/mail" + "strings" +) + +var deliveryHeaders = []string{"Delivered-To", "X-Original-To", "Envelope-To"} + +// Only the topmost occurrence per header counts: it is the one our own +// mailserver added last; lower ones stem from forwarding hops or the sender. +func deliveryAddresses(h mail.Header) []string { + var out []string + seen := make(map[string]struct{}) + for _, name := range deliveryHeaders { + values := h[name] + if len(values) == 0 { + continue + } + addrs, err := mail.ParseAddressList(values[0]) + if err != nil { + continue + } + for _, a := range addrs { + addr := strings.ToLower(strings.TrimSpace(a.Address)) + if addr == "" { + continue + } + if _, dup := seen[addr]; dup { + continue + } + seen[addr] = struct{}{} + out = append(out, addr) + } + } + return out +} + +// Delivery headers are only set by our own mailserver for pull sources; for +// SMTP intake and uploads/imports they are sender-controlled. +func IsTrustedDeliverySource(sourceType string) bool { + return sourceType == "imap" || sourceType == "pop3" +} + +func TrustedDeliveryAddresses(pm *ParsedMail, sourceType string) []string { + if pm == nil || !IsTrustedDeliverySource(sourceType) { + return nil + } + return pm.DeliveryAddresses +} diff --git a/pkg/mailparser/delivery_test.go b/pkg/mailparser/delivery_test.go new file mode 100644 index 0000000..72ee823 --- /dev/null +++ b/pkg/mailparser/delivery_test.go @@ -0,0 +1,102 @@ +package mailparser_test + +import ( + "reflect" + "testing" + + "archivmail/pkg/mailparser" +) + +func parseHeaders(t *testing.T, headers string) *mailparser.ParsedMail { + t.Helper() + raw := headers + + "From: carol@example.org\r\n" + + "To: dave@example.org\r\n" + + "Subject: test\r\n" + + "\r\n" + + "body\r\n" + pm, err := mailparser.Parse([]byte(raw)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + return pm +} + +func TestDeliveryAddresses(t *testing.T) { + tests := []struct { + name string + headers string + want []string + }{ + { + name: "none", + headers: "", + want: nil, + }, + { + name: "topmost delivered-to only", + headers: "Delivered-To: alice@example.com\r\nReceived: from x\r\nDelivered-To: mallory@example.net\r\n", + want: []string{"alice@example.com"}, + }, + { + name: "case normalized", + headers: "Delivered-To: Alice.Smith@Example.COM\r\n", + want: []string{"alice.smith@example.com"}, + }, + { + name: "header name case insensitive", + headers: "DELIVERED-TO: alice@example.com\r\n", + want: []string{"alice@example.com"}, + }, + { + name: "all three headers, dedup", + headers: "Delivered-To: alice@example.com\r\nX-Original-To: team-list@example.com\r\nEnvelope-To: alice@example.com\r\n", + want: []string{"alice@example.com", "team-list@example.com"}, + }, + { + name: "envelope-to list", + headers: "Envelope-To: alice@example.com, bob+news@example.com\r\n", + want: []string{"alice@example.com", "bob+news@example.com"}, + }, + { + name: "angle brackets", + headers: "X-Original-To: \r\n", + want: []string{"alice@example.com"}, + }, + { + name: "invalid topmost is not replaced by lower one", + headers: "Delivered-To: not an address\r\nDelivered-To: mallory@example.net\r\n", + want: nil, + }, + { + name: "invalid value", + headers: "Delivered-To: <<<@@\r\n", + want: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pm := parseHeaders(t, tt.headers) + if !reflect.DeepEqual(pm.DeliveryAddresses, tt.want) { + t.Errorf("DeliveryAddresses = %#v, want %#v", pm.DeliveryAddresses, tt.want) + } + }) + } +} + +func TestTrustedDeliveryAddresses(t *testing.T) { + pm := parseHeaders(t, "Delivered-To: alice@example.com\r\n") + for _, src := range []string{"imap", "pop3"} { + if got := mailparser.TrustedDeliveryAddresses(pm, src); !reflect.DeepEqual(got, []string{"alice@example.com"}) { + t.Errorf("source %q: got %#v", src, got) + } + } + for _, src := range []string{"smtp", "import", "", "IMAP"} { + if got := mailparser.TrustedDeliveryAddresses(pm, src); got != nil { + t.Errorf("source %q must be untrusted, got %#v", src, got) + } + } + if got := mailparser.TrustedDeliveryAddresses(nil, "imap"); got != nil { + t.Errorf("nil mail: got %#v", got) + } +} diff --git a/pkg/mailparser/parser.go b/pkg/mailparser/parser.go index f3237e3..7399592 100644 --- a/pkg/mailparser/parser.go +++ b/pkg/mailparser/parser.go @@ -38,6 +38,9 @@ type ParsedMail struct { Attachments []Attachment Raw []byte + // Sender-controlled unless the source is trusted; read via TrustedDeliveryAddresses. + DeliveryAddresses []string + // Accumulators used while walking MIME parts. Appending to the TextBody / // HTMLBody strings directly is O(n²) for mails with many parts, so parts // are collected here and joined once at the end of Parse. @@ -164,6 +167,8 @@ func Parse(raw []byte) (pmOut *ParsedMail, errOut error) { } } + pm.DeliveryAddresses = deliveryAddresses(msg.Header) + // Subject - decode MIME encoded-words pm.Subject = decodeMIMEHeader(msg.Header.Get("Subject")) diff --git a/src/data/features.ts b/src/data/features.ts index acec3b1..81a7007 100644 --- a/src/data/features.ts +++ b/src/data/features.ts @@ -91,4 +91,5 @@ export const features: Feature[] = [ { id: "PROJ-85", name: "Fix undeklarierte 8-Bit-Zeichen in Header/Body ohne Encoded-Word", status: "Deployed", frontend: false, backend: true, lastUpdated: "2026-08-06", version: "1.0" }, { id: "PROJ-86", name: "Fix Manticore-Index-Drift (emails_global) + Datumssortierung bei fehlendem Date-Header", status: "Deployed", frontend: false, backend: true, lastUpdated: "2026-09-01", version: "1.0" }, { id: "PROJ-87", name: "Zusatzadressen pro User für die Mail-Sichtbarkeit (Rolle \"user\")", status: "Planned", frontend: true, backend: true, lastUpdated: "2026-10-19", version: "1.0" }, + { id: "PROJ-88", name: "Fix Mail-Sichtbarkeit bei Zustellung über Delivered-To + strikter Adress-Match", status: "In Progress", frontend: false, backend: true, lastUpdated: "2026-10-19", version: "1.0" }, ];