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:
patrickandClaude Sonnet 5.5 committed 2026-10-22 00:46:21 +02:00
1 parent 1d8c063a4f
commit 833ff55e1d
23 files changed
+336 -5

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, " "),
BCC: strings.Join(pm.BCC, " "),
Rcpt: mailStore.DeliveryRcpt(context.Background(), id, raw, pm),
Subject: pm.Subject,
Body: pm.TextBody + " " + pm.HTMLBody,
+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, ", "),
BCC: strings.Join(pm.BCC, ", "),
Rcpt: store.DeliveryRcpt(ctx, id, raw, pm),
Subject: pm.Subject,
Body: pm.TextBody,
+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, " "),
BCC: strings.Join(pm.BCC, " "),
Rcpt: mailStore.DeliveryRcpt(context.Background(), id, raw, pm),
Subject: pm.Subject,
Body: pm.TextBody + " " + pm.HTMLBody,
+1
View File
@@ -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,
+1
View File
@@ -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,
+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, ", "),
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,
+1
View File
@@ -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
View File
@@ -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_
+6 -1
View File
@@ -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)
+48
View File
@@ -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)
}
}
}
+5
View File
@@ -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),
+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, " "),
BCC: strings.Join(pm.BCC, " "),
Rcpt: s.store.DeliveryRcpt(ctx, id, raw, pm),
Subject: pm.Subject,
Body: pm.TextBody + " " + pm.HTMLBody,
+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, ", "),
BCC: strings.Join(pm.BCC, ", "),
Rcpt: imp.mailStore.DeliveryRcpt(ctx, id, raw, pm),
Subject: pm.Subject,
Body: pm.TextBody,
+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, ", "),
BCC: strings.Join(pm.BCC, ", "),
Rcpt: imp.mailStore.DeliveryRcpt(ctx, id, raw, pm),
Subject: pm.Subject,
Body: pm.TextBody,
+30
View File
@@ -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
}
+61
View File
@@ -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)
}
}
+3
View File
@@ -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
+9 -2
View File
@@ -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>
+74
View File
@@ -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>
);
}
+13 -1
View File
@@ -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>
+1
View File
@@ -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" },
];
+3
View File
@@ -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;