diff --git a/cmd/archivmail-import/main.go b/cmd/archivmail-import/main.go index e310356..183e2cb 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, " "), + BCC: strings.Join(pm.BCC, " "), Rcpt: mailStore.DeliveryRcpt(context.Background(), id, raw, pm), Subject: pm.Subject, Body: pm.TextBody + " " + pm.HTMLBody, diff --git a/cmd/archivmail/cmd_fix_subjects.go b/cmd/archivmail/cmd_fix_subjects.go index 8666ea2..bda504f 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, ", "), + BCC: strings.Join(pm.BCC, ", "), Rcpt: store.DeliveryRcpt(ctx, id, raw, pm), Subject: pm.Subject, Body: pm.TextBody, diff --git a/cmd/archivmail/cmd_import.go b/cmd/archivmail/cmd_import.go index 05403ea..cc87ecf 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, " "), + BCC: strings.Join(pm.BCC, " "), Rcpt: mailStore.DeliveryRcpt(context.Background(), id, raw, pm), Subject: pm.Subject, Body: pm.TextBody + " " + pm.HTMLBody, diff --git a/cmd/archivmail/cmd_index_pending.go b/cmd/archivmail/cmd_index_pending.go index cf2c720..7ec653f 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, ", "), + BCC: strings.Join(pm.BCC, ", "), Rcpt: mailStore.DeliveryRcpt(ctx, m.ID, raw, pm), Subject: pm.Subject, Body: pm.TextBody, diff --git a/cmd/archivmail/cmd_reindex.go b/cmd/archivmail/cmd_reindex.go index 972d3cf..08abaad 100644 --- a/cmd/archivmail/cmd_reindex.go +++ b/cmd/archivmail/cmd_reindex.go @@ -133,6 +133,7 @@ func runReindex(args []string) { From: pm.From, To: strings.Join(pm.To, ", "), CC: strings.Join(pm.CC, ", "), + BCC: strings.Join(pm.BCC, ", "), Rcpt: mailStore.DeliveryRcpt(ctx, id, raw, pm), Subject: pm.Subject, Body: pm.TextBody, diff --git a/cmd/archivmail/main.go b/cmd/archivmail/main.go index 4e40ed5..ed27900 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, ", "), + BCC: strings.Join(pm.BCC, ", "), Rcpt: store.DeliveryRcpt(context.Background(), id, raw, pm), Subject: pm.Subject, Body: pm.TextBody, @@ -754,6 +755,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, ", "), + BCC: strings.Join(pm.BCC, ", "), Rcpt: store.DeliveryRcpt(ctx, id, raw, pm), Subject: pm.Subject, Body: pm.TextBody, diff --git a/docs/PRD.md b/docs/PRD.md index 2f4bbf3..87f154d 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -37,6 +37,7 @@ Ein selbst gehostetes, unternehmenstaugliches Mail-Archiv-System, das E-Mails au | 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 Review | +| P1 | From/To/CC/BCC vollständig auslesen und im Portal darstellen (PROJ-89) | 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 9850e17..587843d 100644 --- a/features/INDEX.md +++ b/features/INDEX.md @@ -104,7 +104,8 @@ | 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 Review | [PROJ-88](PROJ-88-fix-sichtbarkeit-delivered-to.md) | 2026-10-19 | +| PROJ-89 | From/To/CC/BCC vollständig auslesen und im Portal darstellen | In Progress | [PROJ-89](PROJ-89-adressfelder-from-to-cc-bcc-anzeigen.md) | 2026-10-22 | -## Next Available ID: PROJ-89 +## Next Available ID: PROJ-90 diff --git a/features/PROJ-89-adressfelder-from-to-cc-bcc-anzeigen.md b/features/PROJ-89-adressfelder-from-to-cc-bcc-anzeigen.md new file mode 100644 index 0000000..d03448b --- /dev/null +++ b/features/PROJ-89-adressfelder-from-to-cc-bcc-anzeigen.md @@ -0,0 +1,70 @@ +# PROJ-89: From/To/CC/BCC vollständig auslesen und im Portal darstellen + +## Status: In Progress +**Created:** 2026-10-22 +**Last Updated:** 2026-10-22 + +## Dependencies +- Requires: PROJ-50 (`cc_addr`/`bcc_addr` im Index) +- Berührt: PROJ-54/PROJ-88 (Sichtbarkeit Rolle `user`, `mailBelongsToUser`, `AnyAddress`), PROJ-87 (Zusatzadressen, BCC-Konsistenz) + +## Hintergrund +Der Parser liest From, To, CC und Zustelladressen, aber nicht den Bcc-Header. `bcc_addr` im Index bleibt deshalb leer. Die Detailansicht zeigt From, To und CC (CC nur wenn vorhanden), die Trefferliste zeigt From und To. Wer eine Mail als BCC, über Verteiler oder Weiterleitung erhalten hat, sieht in der Oberfläche nicht, an wen sie noch ging. + +## Entscheidungen (mit Nutzer geklärt, 2026-10-22) +- Anzeige in Detailansicht UND Trefferliste. +- BCC bestimmt auch die Sichtbarkeit für Rolle `user`: `bcc_addr` wird befüllt, `mailBelongsToUser` prüft BCC mit, Index-Filter und Detailprüfung bleiben gleich streng. + +## User Stories +- Als User möchte ich in der Detailansicht Absender, alle Empfänger (To), CC und BCC sehen, um nachzuvollziehen, an wen die Mail ging. +- Als User möchte ich in der Trefferliste neben Absender und To auch CC und BCC erkennen, damit ich Mails über einen Verteiler oder eine Weiterleitung zuordnen kann. +- Als User möchte ich Mails sehen und öffnen, in denen meine Adresse nur im BCC der archivierten Kopie steht. +- Als Compliance-Verantwortlicher möchte ich, dass Liste und Detailzugriff dieselbe Regel nutzen, auch für BCC. + +## Acceptance Criteria +- [ ] Parser liest den Bcc-Header (`net/mail`, lowercase, dedupliziert, unparsbar → keine Adresse, fail-closed) und liefert `ParsedMail.BCC`. +- [ ] Alle Stellen, die `MailDocument` bauen, befüllen `BCC` (IMAP, POP3, Upload, SMTP, Import-CLIs, Reindex, index-pending, fix-subjects). +- [ ] `mailBelongsToUser` prüft zusätzlich BCC mit exaktem Vergleich; alle Aufrufer (Detail, Anhänge, Threads, OCR, Export, eDiscovery, IMAP-Rückholung) profitieren. Test: Liste und Detail liefern für BCC-only-Mails dasselbe Ergebnis. +- [ ] API Mail-Detail liefert `from`, `to`, `cc`, `bcc` als vollständige Listen; Trefferliste liefert zusätzlich `cc` und `bcc` je Treffer. +- [ ] Detailansicht zeigt From, To, CC, BCC; leere Felder werden ausgeblendet; lange Listen umbrechen oder sind einklappbar. +- [ ] Trefferliste zeigt To wie bisher; CC und BCC als Zusatzinfo (breite Spalte oder Tooltip), ohne Layoutbruch auf schmalen Bildschirmen. +- [ ] Bestandsmails: BCC erscheint in Detailansicht sofort (aus der gespeicherten Mail geparst); Sichtbarkeit und Listen-Anzeige über den Index erst nach `archivmail reindex`. +- [ ] Tenant-Isolation und Fail-safe (leere Login-Adresse → `total: 0`) unverändert. +- [ ] Tests mit synthetischen Daten: Parser (Bcc vorhanden, fehlend, ungültig, mehrere), `mailBelongsToUser` mit BCC, Konsistenz Liste/Detail. + +## Edge Cases +- Archivierte Kopien tragen den Bcc-Header meist nur in der Sender-Kopie; Zustellkopien haben ihn nicht. Dann bleibt BCC leer, kein Fehler. +- Ungültiger oder fremdkodierter Bcc-Header → keine Adresse, Mail bleibt sonst unverändert. +- Sehr viele Empfänger → Anzeige gekürzt mit "+N weitere", vollständig im Detail. +- BCC-Adressen sind für alle sichtbar, die die Mail öffnen dürfen (kein zusätzliches Rechtekonzept). + +## Technical Requirements +- Security: BCC darf Sichtbarkeit nur über den exakten Adressvergleich gewähren (PROJ-88-Regel); keine Lockerung des Fail-safe. +- Performance: kein zusätzlicher Index-Roundtrip; Parser-Mehraufwand vernachlässigbar. +- Deployment: Reindex auf 132 nach Deploy (Freigabe des Users einholen). + +--- +## Tech Design (Solution Architect) +_Übersprungen: kleine Erweiterung (Parser-Feld, Indexfeld, Prüfung, Anzeige)._ + +## Implementation Notes +### Backend (2026-10-22) +- **Parser** (`pkg/mailparser/bcc.go`, `parser.go`): `ParsedMail.BCC` aus dem Bcc-Header (net/mail, lowercase, dedupliziert, unparsbar → nil). To/CC unverändert (nicht lowercased, wie bisher). +- **Index**: alle MailDocument-Builder befüllen `BCC` (`bcc_addr`) mit demselben Trennzeichen wie `CC` am jeweiligen Ort: IMAP, POP3, Upload, `submitToWorker`, `reindexTenant`, reindex, index-pending, fix-subjects, import, archivmail-import. Keine Schemaänderung (Spalte existiert seit PROJ-50). +- **Sichtbarkeit**: `mailBelongsToUser` prüft `pm.BCC` exakt wie CC; unabhängig von `source_type` (Bcc ist Mailinhalt, kein Zustellheader). `AnyAddress` matcht `bcc_addr` bereits; der exakte Nachcheck in `handleSearch` deckt BCC damit automatisch ab. +- **API**: Detail (`GET /api/mails/{id}`) neu `bcc` (string, kommagetrennt, leer wenn keiner); `from`/`to`/`cc` unverändert. Trefferliste: je Hit neu `cc` und `bcc` (string, kommagetrennt, `omitempty`). +- **Tests**: `pkg/mailparser/bcc_test.go` (fehlend, einzeln, mehrere/Displayname/Dedup/Case, ungültig, leer), `internal/api/mail_ownership_test.go` (BCC-only → true, fremde/Teil-Adressen → false, ungültiger Bcc → false, Konsistenz Index-BCC/Detail). Lokal kein Go-Toolchain: Build/Tests nicht ausgeführt. +- **Offen**: v1-API, PDF-Export und eDiscovery geben BCC noch nicht aus. Bei Message-ID-Dedup stammt `bcc_addr` (wie To/CC) aus der eingehenden Kopie, die Detailansicht aus der gespeicherten; der exakte Nachcheck in der Liste verhindert Metadaten-Leaks. Bestandsmails: `archivmail reindex` nötig (Freigabe einholen). + +### Frontend (2026-10-22) +- **Typen** (`src/lib/api/mail.ts`): `MailDetail.bcc?`, `SearchHit.cc?`/`bcc?` (optional, kommagetrennt). +- **`src/components/mail/AddressList.tsx`**: `AddressList` zeigt bis 5 Adressen, darüber "+N weitere" per shadcn-`Button` (variant link, `aria-expanded`/`aria-controls`) auf-/zuklappbar. `splitAddresses` trennt nur an Kommas außerhalb von Anführungszeichen und `<...>` (Displaynamen wie `"Muster, Max"` bleiben ganz). +- **Detailansicht** (`MailHeaderGrid`): An, CC und neu BCC über `AddressList`; CC/BCC nur wenn nicht leer. +- **Trefferliste** (`SearchResultsTable`): Spalte "An" (ab `lg`) zeigt unter To kleine, abgeschwächte Zeilen "CC: …"/"BCC: …" (truncate, voller Text im `title`), nur wenn nicht leer; auf schmalen Bildschirmen weiterhin ausgeblendet. +- **Geprüft**: `npx tsc --noEmit`, ESLint auf geänderten Dateien und `npm run build` lokal fehlerfrei; Splitter mit synthetischen Adressen (example.com) per Node-Skript geprüft. Kein Browser-Test. + +## QA Test Results +_To be added by /qa_ + +## Deployment +_To be added by /deploy_ diff --git a/internal/api/mail_ownership.go b/internal/api/mail_ownership.go index 4f8ff6b..d9c0a60 100644 --- a/internal/api/mail_ownership.go +++ b/internal/api/mail_ownership.go @@ -52,6 +52,11 @@ func mailBelongsToUser(pm *mailparser.ParsedMail, userEmail, sourceType string) return true } } + for _, bcc := range pm.BCC { + if checkHeader(bcc) { + return true + } + } for _, addr := range mailparser.TrustedDeliveryAddresses(pm, sourceType) { if addr == target { return true @@ -60,7 +65,7 @@ func mailBelongsToUser(pm *mailparser.ParsedMail, userEmail, sourceType string) return false } -// A failed source lookup only drops the delivery-header path; From/To/CC +// A failed source lookup only drops the delivery-header path; From/To/CC/BCC // 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) diff --git a/internal/api/mail_ownership_test.go b/internal/api/mail_ownership_test.go index 3c3c77b..8bcab24 100644 --- a/internal/api/mail_ownership_test.go +++ b/internal/api/mail_ownership_test.go @@ -99,3 +99,51 @@ func TestDeliveryListDetailConsistency(t *testing.T) { } } } + +func TestMailBelongsToUserBCC(t *testing.T) { + pm := parseTestMail(t, + "From: zed@example.org\r\n"+ + "To: list@example.org\r\n"+ + "Bcc: Alice , bob+tag@example.com\r\n") + tests := []struct { + email string + want bool + }{ + {"alice@example.com", true}, + {"bob+tag@example.com", true}, + {"bob@example.com", false}, + {"mallory@example.net", false}, + {"alice@example.org", false}, + } + for _, src := range []string{"imap", "smtp", ""} { + for _, tt := range tests { + if got := mailBelongsToUser(pm, tt.email, src); got != tt.want { + t.Errorf("source %q %q: got %v, want %v", src, tt.email, got, tt.want) + } + } + } + + unparseable := parseTestMail(t, + "From: zed@example.org\r\n"+ + "To: list@example.org\r\n"+ + "Bcc: alice@example.com <<<\r\n") + if mailBelongsToUser(unparseable, "alice@example.com", "imap") { + t.Error("unparseable Bcc must not grant access") + } +} + +// bcc_addr is indexed from pm.BCC and matched by AnyAddress; detail access +// must agree with an exact match against that same list. +func TestBCCListDetailConsistency(t *testing.T) { + pm := parseTestMail(t, + "From: zed@example.org\r\n"+ + "To: list@example.org\r\n"+ + "Bcc: alice@example.com, carol.x@example.com\r\n") + bccIndex := strings.Split(strings.Join(pm.BCC, ", "), ", ") + for _, u := range []string{"alice@example.com", "carol.x@example.com", "carol@example.com", "x@example.com", "bob@example.com"} { + inIndex := slices.Contains(bccIndex, u) + if got := mailBelongsToUser(pm, u, "smtp"); got != inIndex { + t.Errorf("user %q: detail=%v, bcc index=%v", u, got, inIndex) + } + } +} diff --git a/internal/api/search_handlers.go b/internal/api/search_handlers.go index 8576ee0..009f806 100644 --- a/internal/api/search_handlers.go +++ b/internal/api/search_handlers.go @@ -166,6 +166,8 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { Score float64 `json:"score"` From string `json:"from,omitempty"` To string `json:"to,omitempty"` + CC string `json:"cc,omitempty"` + BCC string `json:"bcc,omitempty"` Subject string `json:"subject,omitempty"` Date string `json:"date,omitempty"` Size int64 `json:"size,omitempty"` @@ -233,6 +235,8 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { if len(pm.To) > 0 { eh.To = strings.Join(pm.To, ", ") } + eh.CC = strings.Join(pm.CC, ", ") + eh.BCC = strings.Join(pm.BCC, ", ") eh.Subject = pm.Subject if !pm.Date.IsZero() { eh.Date = pm.Date.UTC().Format(time.RFC3339) @@ -382,6 +386,7 @@ func (s *Server) handleGetMail(w http.ResponseWriter, r *http.Request) { "from": pm.From, "to": strings.Join(pm.To, ", "), "cc": strings.Join(pm.CC, ", "), + "bcc": strings.Join(pm.BCC, ", "), "subject": pm.Subject, "date": dateStr, "size": len(raw), diff --git a/internal/api/upload.go b/internal/api/upload.go index f457530..4271641 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, " "), + BCC: strings.Join(pm.BCC, " "), Rcpt: s.store.DeliveryRcpt(ctx, id, raw, pm), Subject: pm.Subject, Body: pm.TextBody + " " + pm.HTMLBody, diff --git a/internal/imap/importer.go b/internal/imap/importer.go index df7c287..7ce2561 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, ", "), + BCC: strings.Join(pm.BCC, ", "), Rcpt: imp.mailStore.DeliveryRcpt(ctx, id, raw, pm), Subject: pm.Subject, Body: pm.TextBody, diff --git a/internal/pop3/importer.go b/internal/pop3/importer.go index c2c3ca1..c7e9484 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, ", "), + BCC: strings.Join(pm.BCC, ", "), Rcpt: imp.mailStore.DeliveryRcpt(ctx, id, raw, pm), Subject: pm.Subject, Body: pm.TextBody, diff --git a/pkg/mailparser/bcc.go b/pkg/mailparser/bcc.go new file mode 100644 index 0000000..41101fc --- /dev/null +++ b/pkg/mailparser/bcc.go @@ -0,0 +1,30 @@ +package mailparser + +import ( + "net/mail" + "strings" +) + +func bccAddresses(header string) []string { + if strings.TrimSpace(header) == "" { + return nil + } + addrs, err := mail.ParseAddressList(header) + if err != nil { + return nil + } + var out []string + seen := make(map[string]struct{}) + 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 +} diff --git a/pkg/mailparser/bcc_test.go b/pkg/mailparser/bcc_test.go new file mode 100644 index 0000000..31de48c --- /dev/null +++ b/pkg/mailparser/bcc_test.go @@ -0,0 +1,61 @@ +package mailparser_test + +import ( + "reflect" + "testing" +) + +func TestBCCAddresses(t *testing.T) { + tests := []struct { + name string + headers string + want []string + }{ + { + name: "missing", + headers: "", + want: nil, + }, + { + name: "single", + headers: "Bcc: alice@example.com\r\n", + want: []string{"alice@example.com"}, + }, + { + name: "multiple with display names, lowercased and deduplicated", + headers: "Bcc: Alice , bob@example.com, ALICE@example.com\r\n", + want: []string{"alice@example.com", "bob@example.com"}, + }, + { + name: "unparseable yields nothing", + headers: "Bcc: not an address <<<\r\n", + want: nil, + }, + { + name: "empty header", + headers: "Bcc: \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.BCC, tt.want) { + t.Errorf("BCC = %#v, want %#v", pm.BCC, tt.want) + } + }) + } +} + +func TestBCCDoesNotAffectToCC(t *testing.T) { + pm := parseHeaders(t, "Cc: erin@example.com\r\nBcc: frank@example.com\r\n") + if !reflect.DeepEqual(pm.To, []string{"dave@example.org"}) { + t.Errorf("To = %#v", pm.To) + } + if !reflect.DeepEqual(pm.CC, []string{"erin@example.com"}) { + t.Errorf("CC = %#v", pm.CC) + } + if !reflect.DeepEqual(pm.BCC, []string{"frank@example.com"}) { + t.Errorf("BCC = %#v", pm.BCC) + } +} diff --git a/pkg/mailparser/parser.go b/pkg/mailparser/parser.go index 7399592..c71810d 100644 --- a/pkg/mailparser/parser.go +++ b/pkg/mailparser/parser.go @@ -28,6 +28,7 @@ type ParsedMail struct { From string To []string CC []string + BCC []string Subject string MessageID string InReplyTo string // In-Reply-To header (single message-id, no angle brackets) @@ -167,6 +168,8 @@ func Parse(raw []byte) (pmOut *ParsedMail, errOut error) { } } + pm.BCC = bccAddresses(msg.Header.Get("Bcc")) + pm.DeliveryAddresses = deliveryAddresses(msg.Header) // Subject - decode MIME encoded-words diff --git a/src/app/mail/[id]/page.tsx b/src/app/mail/[id]/page.tsx index 955cd0d..2eeca49 100644 --- a/src/app/mail/[id]/page.tsx +++ b/src/app/mail/[id]/page.tsx @@ -24,6 +24,7 @@ import { Alert, AlertDescription } from "@/components/ui/alert"; import { OcrBadge } from "@/components/ocr-badge"; import { RestoreMailButton } from "@/components/mail/RestoreMailButton"; import { AttachmentPreviewDialog } from "@/components/mail/AttachmentPreviewDialog"; +import { AddressList } from "@/components/mail/AddressList"; import { FileText } from "lucide-react"; // ── Helpers ──────────────────────────────────────────────────────────────── @@ -180,11 +181,17 @@ function MailHeaderGrid({ mail }: { mail: MailDetail }) { Von: {mail.from || "–"} An: - {mail.to || "–"} + {mail.cc && ( <> CC: - {mail.cc} + + + )} + {mail.bcc && ( + <> + BCC: + )} Datum: diff --git a/src/components/mail/AddressList.tsx b/src/components/mail/AddressList.tsx new file mode 100644 index 0000000..f8e436d --- /dev/null +++ b/src/components/mail/AddressList.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { useId, useState } from "react"; +import { Button } from "@/components/ui/button"; + +// Commas inside quoted display names or <...> must not split ("Muster, Max" ). +export function splitAddresses(value: string | undefined): string[] { + if (!value) return []; + const parts: string[] = []; + let current = ""; + let inQuotes = false; + let angleDepth = 0; + for (let i = 0; i < value.length; i++) { + const ch = value[i]; + if (ch === "\\" && inQuotes && i + 1 < value.length) { + current += ch + value[++i]; + continue; + } + if (ch === '"') inQuotes = !inQuotes; + else if (!inQuotes && ch === "<") angleDepth++; + else if (!inQuotes && ch === ">" && angleDepth > 0) angleDepth--; + if (ch === "," && !inQuotes && angleDepth === 0) { + if (current.trim()) parts.push(current.trim()); + current = ""; + continue; + } + current += ch; + } + if (current.trim()) parts.push(current.trim()); + return parts; +} + +interface AddressListProps { + value: string | undefined; + limit?: number; + label?: string; +} + +export function AddressList({ value, limit = 5, label = "Empfänger" }: AddressListProps) { + const [expanded, setExpanded] = useState(false); + const listId = useId(); + const addresses = splitAddresses(value); + + if (addresses.length === 0) return –; + + const hidden = addresses.length - limit; + const visible = expanded || hidden <= 0 ? addresses : addresses.slice(0, limit); + + return ( + + + {visible.join(", ")} + + {hidden > 0 && ( + + )} + + ); +} diff --git a/src/components/search/SearchResultsTable.tsx b/src/components/search/SearchResultsTable.tsx index 2584071..6bd054c 100644 --- a/src/components/search/SearchResultsTable.tsx +++ b/src/components/search/SearchResultsTable.tsx @@ -147,7 +147,19 @@ function SearchResultsTableInner({ )} - {hit.to || "-"} + +
{hit.to || "-"}
+ {hit.cc && ( +
+ CC: {hit.cc} +
+ )} + {hit.bcc && ( +
+ BCC: {hit.bcc} +
+ )} +
{hit.has_attachments ? "📎" : ""} diff --git a/src/data/features.ts b/src/data/features.ts index d3b23d0..740f76f 100644 --- a/src/data/features.ts +++ b/src/data/features.ts @@ -92,4 +92,5 @@ export const features: Feature[] = [ { 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 Review", frontend: false, backend: true, lastUpdated: "2026-10-19", version: "1.0" }, + { id: "PROJ-89", name: "From/To/CC/BCC vollständig auslesen und im Portal darstellen", status: "In Progress", frontend: true, backend: true, lastUpdated: "2026-10-22", version: "1.0" }, ]; diff --git a/src/lib/api/mail.ts b/src/lib/api/mail.ts index f8674f0..a403c9b 100644 --- a/src/lib/api/mail.ts +++ b/src/lib/api/mail.ts @@ -17,6 +17,8 @@ export interface SearchHit { score: number; from?: string; to?: string; + cc?: string; + bcc?: string; subject?: string; date?: string; size?: number; @@ -60,6 +62,7 @@ export interface MailDetail { from: string; to: string; cc?: string; + bcc?: string; subject: string; date: string; size: number;