feat(PROJ-89): BCC auslesen, indexieren, in Sichtbarkeitsprüfung und Portal anzeigen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1d8c063a4f
commit
833ff55e1d
23 files changed
+336
-5
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-1
@@ -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 |
|
||||
|
||||
<!-- Add features above this line -->
|
||||
|
||||
## Next Available ID: PROJ-89
|
||||
## Next Available ID: PROJ-90
|
||||
@@ -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_
|
||||
@@ -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)
|
||||
|
||||
@@ -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 <Alice@Example.com>, 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 <Alice@Example.com>, 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 }) {
|
||||
<span className="font-medium text-muted-foreground">Von:</span>
|
||||
<span className="break-all">{mail.from || "–"}</span>
|
||||
<span className="font-medium text-muted-foreground">An:</span>
|
||||
<span className="break-all">{mail.to || "–"}</span>
|
||||
<AddressList value={mail.to} label="An" />
|
||||
{mail.cc && (
|
||||
<>
|
||||
<span className="font-medium text-muted-foreground">CC:</span>
|
||||
<span className="break-all">{mail.cc}</span>
|
||||
<AddressList value={mail.cc} label="CC" />
|
||||
</>
|
||||
)}
|
||||
{mail.bcc && (
|
||||
<>
|
||||
<span className="font-medium text-muted-foreground">BCC:</span>
|
||||
<AddressList value={mail.bcc} label="BCC" />
|
||||
</>
|
||||
)}
|
||||
<span className="font-medium text-muted-foreground">Datum:</span>
|
||||
|
||||
@@ -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" <max@example.com>).
|
||||
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 <span>–</span>;
|
||||
|
||||
const hidden = addresses.length - limit;
|
||||
const visible = expanded || hidden <= 0 ? addresses : addresses.slice(0, limit);
|
||||
|
||||
return (
|
||||
<span className="min-w-0">
|
||||
<span id={listId} className="break-all">
|
||||
{visible.join(", ")}
|
||||
</span>
|
||||
{hidden > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="ml-1 h-auto p-0 align-baseline"
|
||||
aria-expanded={expanded}
|
||||
aria-controls={listId}
|
||||
aria-label={
|
||||
expanded
|
||||
? `${label}: weniger anzeigen`
|
||||
: `${label}: ${hidden} weitere anzeigen`
|
||||
}
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
>
|
||||
{expanded ? "weniger anzeigen" : `+${hidden} weitere`}
|
||||
</Button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -147,7 +147,19 @@ function SearchResultsTableInner({
|
||||
)}
|
||||
<SnippetLine hit={hit} />
|
||||
</TableCell>
|
||||
<TableCell className="hidden max-w-48 truncate text-sm text-muted-foreground lg:table-cell">{hit.to || "-"}</TableCell>
|
||||
<TableCell className="hidden max-w-48 text-sm text-muted-foreground lg:table-cell">
|
||||
<div className="max-w-48 truncate" title={hit.to || undefined}>{hit.to || "-"}</div>
|
||||
{hit.cc && (
|
||||
<div className="mt-0.5 max-w-48 truncate text-xs text-muted-foreground/80" title={`CC: ${hit.cc}`}>
|
||||
CC: {hit.cc}
|
||||
</div>
|
||||
)}
|
||||
{hit.bcc && (
|
||||
<div className="mt-0.5 max-w-48 truncate text-xs text-muted-foreground/80" title={`BCC: ${hit.bcc}`}>
|
||||
BCC: {hit.bcc}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-sm">
|
||||
{hit.has_attachments ? "📎" : ""}
|
||||
</TableCell>
|
||||
|
||||
@@ -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" },
|
||||
];
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user