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:
patrickandClaude Sonnet 5.5 committed 2026-10-19 14:44:18 +02:00
1 parent ca54b72a96
commit e39c1f3b60
29 files changed
+732 -77

No files matched your search

+1
View File
@@ -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, " "),
+1
View File
@@ -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, " "),
+1
View File
@@ -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, " "),
+1
View File
@@ -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, " "),
+1
View File
@@ -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, " "),
+2
View File
@@ -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, " "),
+1
View File
@@ -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
View File
@@ -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_
+1 -1
View File
@@ -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
}
+2 -2
View File
@@ -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
}
+71
View File
@@ -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)
}
+101
View File
@@ -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)
}
}
}
}
+1 -1
View File
@@ -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
}
+1 -1
View File
@@ -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
+34 -58
View File
@@ -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++ {
+1 -1
View File
@@ -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
}
}
+1
View File
@@ -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, " "),
+1
View File
@@ -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, " "),
+31
View File
@@ -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()
}
+146
View File
@@ -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")
}
}
+2 -1
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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, " "),
+78
View File
@@ -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), " ")
}
+50
View File
@@ -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
}
+102
View File
@@ -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)
}
}
+5
View File
@@ -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"))
+1
View File
@@ -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" },
];