fix(PROJ-88): Mail-Sichtbarkeit für Rolle user bei Zustellung über Delivered-To + exakter Adress-Match
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ca54b72a96
commit
e39c1f3b60
29 files changed
+732
-77
No files matched your search
@@ -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, " "),
|
||||
|
||||
@@ -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, " "),
|
||||
|
||||
@@ -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, " "),
|
||||
|
||||
@@ -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, " "),
|
||||
|
||||
@@ -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, " "),
|
||||
|
||||
@@ -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, " "),
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-1
@@ -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 |
|
||||
|
||||
<!-- Add features above this line -->
|
||||
|
||||
## Next Available ID: PROJ-88
|
||||
## Next Available ID: PROJ-89
|
||||
@@ -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) "<adresse>"` 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_
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 <carol@example.org>\r\n"+
|
||||
"To: dave@example.org, \"Erin\" <erin+news@example.org>\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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <addr>") 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++ {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, " "),
|
||||
|
||||
@@ -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, " "),
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+20
-11
@@ -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
|
||||
|
||||
@@ -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, " "),
|
||||
|
||||
@@ -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), " ")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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: <alice@example.com>\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)
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
|
||||
|
||||
@@ -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" },
|
||||
];
|
||||
Reference in new issue
Block a user