diff --git a/cmd/archivmail/cmd_imap_verify.go b/cmd/archivmail/cmd_imap_verify.go new file mode 100644 index 0000000..c73ed75 --- /dev/null +++ b/cmd/archivmail/cmd_imap_verify.go @@ -0,0 +1,118 @@ +package main + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "flag" + "fmt" + "io" + "os" + + "github.com/jackc/pgx/v5/pgxpool" + "golang.org/x/crypto/hkdf" + + "archivmail/config" + imapstore "archivmail/internal/imap" +) + +func runIMAPVerify(args []string) { + fs := flag.NewFlagSet("imap-verify", flag.ExitOnError) + configPath := fs.String("config", "/etc/archivmail/config.yml", "path to config file") + accountID := fs.Int64("account", 0, "IMAP account ID (0 = all accounts)") + folder := fs.String("folder", "", "check only this folder") + jsonOut := fs.Bool("json", false, "machine-readable JSON output") + fs.Parse(args) + + cfg, err := config.Load(*configPath) + if err != nil { + fmt.Fprintf(os.Stderr, "imap-verify: load config: %v\n", err) + os.Exit(2) + } + + aesKey, err := deriveIMAPKey(cfg.API.Secret) + if err != nil { + fmt.Fprintf(os.Stderr, "imap-verify: key derivation failed\n") + os.Exit(2) + } + + pcfg, err := pgxpool.ParseConfig(cfg.Database.DSN()) + if err != nil { + fmt.Fprintf(os.Stderr, "imap-verify: invalid database config\n") + os.Exit(2) + } + // Server-enforced guarantee that this command cannot write to the DB. + pcfg.ConnConfig.RuntimeParams["default_transaction_read_only"] = "on" + pcfg.MaxConns = 2 + + ctx := context.Background() + pool, err := pgxpool.NewWithConfig(ctx, pcfg) + if err != nil { + fmt.Fprintf(os.Stderr, "imap-verify: database connect: %v\n", err) + os.Exit(2) + } + defer pool.Close() + + st := imapstore.NewReadOnlyStore(pool, aesKey) + var accounts []imapstore.Account + if *accountID > 0 { + acc, err := st.Get(ctx, *accountID) + if err != nil { + fmt.Fprintf(os.Stderr, "imap-verify: account %d not found\n", *accountID) + os.Exit(2) + } + accounts = []imapstore.Account{*acc} + } else { + accounts, err = st.ListAll(ctx) + if err != nil { + fmt.Fprintf(os.Stderr, "imap-verify: list accounts: %v\n", err) + os.Exit(2) + } + } + + lookup := imapstore.NewArchiveLookup(pool) + reports := make([]imapstore.AccountReport, 0, len(accounts)) + for i := range accounts { + acc := &accounts[i] + reports = append(reports, verifyIMAPAccount(ctx, st, acc, lookup, *folder)) + } + + pool.Close() + + code := imapstore.VerifyExitCode(reports) + if *jsonOut { + if err := imapstore.WriteVerifyJSON(os.Stdout, reports); err != nil { + fmt.Fprintf(os.Stderr, "imap-verify: write json: %v\n", err) + code = 2 + } + } else { + imapstore.WriteVerifyText(os.Stdout, reports) + } + os.Exit(code) +} + +func verifyIMAPAccount(ctx context.Context, st *imapstore.Store, acc *imapstore.Account, lookup imapstore.ArchiveLookup, folder string) imapstore.AccountReport { + password, err := st.GetPassword(ctx, acc.ID) + if err != nil { + return imapstore.FailedAccountReport(acc.ID, fmt.Errorf("decrypt password failed"), nil) + } + secrets := []string{password, acc.Username} + + src, closeFn, err := imapstore.DialVerifySource(acc, password) + if err != nil { + return imapstore.FailedAccountReport(acc.ID, err, secrets) + } + defer closeFn() + + return imapstore.VerifyAccount(ctx, acc.ID, src, lookup, acc.ExcludedFolders, folder, secrets) +} + +// Must match the derivation in main() exactly, otherwise stored IMAP +// passwords cannot be decrypted. +func deriveIMAPKey(secret string) (string, error) { + raw := make([]byte, 32) + if _, err := io.ReadFull(hkdf.New(sha256.New, []byte(secret), []byte("archivmail-aes-v1"), nil), raw); err != nil { + return "", err + } + return hex.EncodeToString(raw), nil +} diff --git a/cmd/archivmail/cmd_import.go b/cmd/archivmail/cmd_import.go index 44e2a57..6a29a5d 100644 --- a/cmd/archivmail/cmd_import.go +++ b/cmd/archivmail/cmd_import.go @@ -318,7 +318,8 @@ Commands: restore Backup aus 'archivmail backup' zurückspielen (PROJ-66) update Auf neueste Version aktualisieren (führt update.sh aus) status Healthcheck für DB, Manticore und Storage - version Version anzeigen + imap-verify IMAP-Postfächer read-only gegen das Archiv abgleichen (PROJ-93) + version Version anzeigen help Diese Hilfe anzeigen archivmail import [flags] @@ -369,5 +370,12 @@ archivmail rethread [flags] archivmail status [flags] --config Pfad zur Konfigurationsdatei (Standard: /etc/archivmail/config.yml) --json Maschinenlesbare JSON-Ausgabe + +archivmail imap-verify [flags] + --config Pfad zur Konfigurationsdatei (Standard: /etc/archivmail/config.yml) + --account IMAP-Konto-ID (0 = alle Konten) + --folder Nur diesen Ordner prüfen + --json Maschinenlesbare JSON-Ausgabe + Exit-Code: 0 vollständig, 1 fehlende/nicht prüfbare Mails, 2 Fehler `, AppVersion) } diff --git a/cmd/archivmail/main.go b/cmd/archivmail/main.go index 01b3f8a..4ce9872 100644 --- a/cmd/archivmail/main.go +++ b/cmd/archivmail/main.go @@ -90,6 +90,9 @@ func main() { case "reconcile": runReconcile(os.Args[2:]) return + case "imap-verify": + runIMAPVerify(os.Args[2:]) + return case "update": runUpdate(os.Args[2:]) return diff --git a/features/INDEX.md b/features/INDEX.md index 724c1cd..0cffcda 100644 --- a/features/INDEX.md +++ b/features/INDEX.md @@ -107,7 +107,9 @@ | PROJ-89 | From/To/CC/BCC vollständig auslesen und im Portal darstellen | In Review | [PROJ-89](PROJ-89-adressfelder-from-to-cc-bcc-anzeigen.md) | 2026-10-22 | | PROJ-90 | Sichtbarkeit aller Mails eines IMAP-Kontos für den Konto-Owner (Rolle "user") | In Progress | [PROJ-90](PROJ-90-sichtbarkeit-imap-konto-owner.md) | 2026-10-29 | | PROJ-91 | Envelope-Absender (Return-Path) durchsuchbar machen | In Progress | [PROJ-91](PROJ-91-envelope-absender-suche.md) | 2026-10-29 | +| PROJ-92 | Weiterleitung in Liste und Detail anzeigen | In Progress | [PROJ-92](PROJ-92-weiterleitung-anzeigen.md) | 2026-10-29 | +| PROJ-93 | IMAP-Vollständigkeitsprüfung (Postfach vs. Archiv) | In Progress | [PROJ-93](PROJ-93-imap-vollstaendigkeitspruefung.md) | 2026-10-29 | -## Next Available ID: PROJ-92 +## Next Available ID: PROJ-94 diff --git a/features/PROJ-92-weiterleitung-anzeigen.md b/features/PROJ-92-weiterleitung-anzeigen.md new file mode 100644 index 0000000..6638762 --- /dev/null +++ b/features/PROJ-92-weiterleitung-anzeigen.md @@ -0,0 +1,52 @@ +# PROJ-92: Weiterleitung in Liste und Detail anzeigen + +## Status: In Progress +**Created:** 2026-10-29 +**Last Updated:** 2026-10-29 + +## Dependencies +- Requires: PROJ-91 (`ParsedMail.ReturnPath`), PROJ-89 (Adressfelder im Portal) + +## Hintergrund +Weitergeleitete Mails tragen als Envelope-Absender (`Return-Path`) die Adresse, über die weitergeleitet wurde (z. B. `stefan.perlbach@asb-magdeburg.de`), im Header-`From` steht der echte Autor. Im Portal ist die Weiterleitung nicht erkennbar. + +## User Stories +- Als User möchte ich im Mail-Detail sehen, dass eine Mail weitergeleitet wurde und worüber. +- Als User möchte ich in der Trefferliste sehen, woher eine weitergeleitete Mail kommt. + +## Acceptance Criteria +- [ ] Backend berechnet `forwarded_via` (string, `omitempty`): die Return-Path-Adresse, wenn sie nicht der Header-From-Adresse entspricht UND eine Empfängeradresse der Mail ist (in To, CC, BCC oder in den Zustelladressen `Delivered-To`/`X-Original-To`/`Envelope-To`). Sonst leer (Newsletter/Mailinglisten/Bounces lösen nichts aus). +- [ ] `forwarded_via` steht in jedem Treffer der Suchliste und in der Mail-Detail-Antwort. Keine Änderung der Sichtbarkeits- oder Zugriffsregeln, `Return-Path` wird nicht für Zugriff verwendet. +- [ ] Detail: gut sichtbarer Hinweis "Weitergeleitet über " neben den Adressfeldern; Header-Absender bleibt unverändert als "Von". +- [ ] Liste: bei gesetztem `forwarded_via` ein kleines Badge/Zusatz "via " (Tooltip mit voller Adresse); ohne Wert nichts. +- [ ] Light/Dark-Mode, Mobilansicht ohne Layoutbruch. Textausgabe ausschließlich über React-Escaping (Adresse ist Mail-Header-Inhalt, kein HTML). +- [ ] Tests: Erkennungslogik (Weiterleitung, gleiche Adresse, Newsletter-Bounce, leerer Return-Path, Groß-/Kleinschreibung). + +## Edge Cases +- Return-Path == From-Adresse: keine Weiterleitung. +- Return-Path leer / `<>`: keine Anzeige. +- Absender-Adresse in Großschreibung: Vergleich lowercase. + +## Technical Requirements +- Security: reine Anzeige; keine Zugriffswirkung; XSS-sicher. +- Performance: aus bereits geparster Mail, keine zusätzliche DB-Abfrage. +- Kein Reindex nötig. + +## Implementation Notes + +### Backend (2026-10-29) +- `pkg/mailparser/forwarded.go`: reine Funktion `ForwardedVia(pm)`. Liefert `pm.ReturnPath` (lowercase), wenn nicht leer/`<>`, ungleich Header-From und als Empfänger vorhanden (To, CC, BCC oder `pm.DeliveryAddresses` = Delivered-To/X-Original-To/Envelope-To). Bewusst `DeliveryAddresses` statt `TrustedDeliveryAddresses`, weil das Feld nur angezeigt wird. Adressen werden per `net/mail` normalisiert und lowercase verglichen. +- Suche (`handleSearch`): Treffer-Struct als `searchHit` nach `internal/api/search_hit.go` ausgelagert, neues Feld `forwarded_via` (omitempty). +- Detail (`handleGetMail`): `forwarded_via` wird nur gesetzt, wenn ein Wert vorhanden ist (`setForwardedVia`). +- Thread (`handleGetThread`): `mailSummary` hat ebenfalls `forwarded_via` (omitempty). +- Keine Zugriffswirkung: `ForwardedVia`/`ReturnPath` wird in `mail_ownership.go` und anderen Zugriffsprüfungen nicht verwendet, abgesichert durch `TestForwardedViaGrantsNoAccess`. Keine zusätzliche DB-Abfrage, kein Reindex. +- Tests: `pkg/mailparser/forwarded_test.go` (Weiterleitung über CC/BCC/Envelope-To/Delivered-To, Return-Path == From, Newsletter-Bounce, leer/`<>`, Groß-/Kleinschreibung, mehrere Empfänger) und `internal/api/forwarded_via_test.go` (JSON-Feld in Treffer und Detail vorhanden bzw. weggelassen). `go vet`, Tests und Build im Scratch-Verzeichnis auf 192.168.1.132 grün. + +### Frontend (2026-10-29) +- Typen: `forwarded_via?: string` in `SearchHit`, `MailDetail` und `ThreadMail` (`src/lib/api/mail.ts`; Re-Export über `index.ts` unverändert, da Typen bereits exportiert). +- Neue Komponente `src/components/mail/ForwardedViaBadge.tsx` auf Basis von shadcn `Badge` (variant `secondary`, lucide-Icon `Forward`), rendert nichts ohne Adresse. Adresse nur als React-Text, volle Adresse im `title`. +- Detail (`src/app/mail/[id]/page.tsx`): eigene Zeile "Weiterleitung:" direkt unter "Von:" mit Badge "Weitergeleitet über " (`break-all`, kein Überlauf mobil). "Von" bleibt Header-Absender. +- Liste (`src/components/search/SearchResultsTable.tsx`): kompaktes Badge "via " (abgeschnitten via `truncate`) unter dem Absender in der Von-Spalte (ab md) bzw. in der Mobil-Zeile unter dem Betreff. +- Thread-Liste: nur Typ ergänzt, keine Anzeige (Thread-Einträge zeigen bisher nur Betreff/Datum). +- Light/Dark über vorhandene `secondary`-Tokens. +- Prüfung: `tsc --noEmit` ok, `npm run build` ok; `npm run lint` meldet 1 vorhandenen Fehler in `AttachmentPreviewDialog.tsx` (react-hooks/set-state-in-effect) und 1 Warnung in `PdfCanvasPreview.tsx`, beide nicht von PROJ-92. diff --git a/features/PROJ-93-imap-vollstaendigkeitspruefung.md b/features/PROJ-93-imap-vollstaendigkeitspruefung.md new file mode 100644 index 0000000..0a11cb2 --- /dev/null +++ b/features/PROJ-93-imap-vollstaendigkeitspruefung.md @@ -0,0 +1,55 @@ +# PROJ-93: IMAP-Vollständigkeitsprüfung (Postfach vs. Archiv) + +## Status: In Progress +**Created:** 2026-10-29 +**Last Updated:** 2026-10-29 + +## Dependencies +- Requires: PROJ-3 (IMAP-Import), PROJ-8 (Auto-Sync), PROJ-52 (Reconciliation) + +## Hintergrund +Das Archiv soll den Posteingang widerspiegeln. Pro Mail wird keine IMAP-UID gespeichert, ein Abgleich Postfach gegen Archiv ist bisher nicht möglich. + +## User Stories +- Als Admin möchte ich prüfen, ob jede Mail im IMAP-Postfach (je Ordner) im Archiv liegt, ohne Mail-Inhalte zu sehen. + +## Acceptance Criteria +- [ ] CLI `archivmail imap-verify [-config …] [-account ] [-folder ] [-json]`, strikt lesend: keine Schreibzugriffe auf Postfach (nur `BODY.PEEK`, keine Flags setzen, nichts löschen/verschieben/als gelesen markieren), keine Schreibzugriffe auf DB/Index/Store. +- [ ] Pro Konto und synchronisiertem, nicht ausgeschlossenem Ordner: UIDs und Message-ID-Header vom Server holen (nur `HEADER.FIELDS (MESSAGE-ID)`), mit `emails.message_id` abgleichen (gleiche Normalisierung wie der Importer). +- [ ] Ausgabe nur Zahlen und IDs: pro Konto/Ordner: Anzahl im Postfach, im Archiv gefunden, fehlend, ohne Message-ID. Fehlende als Liste `Ordner:UID` (keine Betreffs/Adressen/Inhalte). `-json` maschinenlesbar. +- [ ] Zugangsdaten werden wie im Importer entschlüsselt und nie ausgegeben oder geloggt. +- [ ] Mails ohne Message-ID werden separat gezählt (Abgleich nur über Message-ID). +- [ ] Ausgeschlossene Ordner (`excluded_folders`) werden übersprungen und als solche ausgewiesen. +- [ ] Tests (synthetisch): Abgleichslogik mit Fake-Server-Antworten, Normalisierung, Zählung fehlender, keine Credentials in der Ausgabe. + +## Edge Cases +- Dieselbe Message-ID in mehreren Ordnern/Konten: gilt als vorhanden, wenn in `emails` vorhanden (Dedup). +- IMAP-Fehler/Timeout: Ordner als fehlgeschlagen ausweisen, nie als "vollständig". +- Große Postfächer: Batch-Abfragen, kein Laden ganzer Mails. + +## Technical Requirements +- Security/DSGVO: keine Mail-Inhalte, keine Adressen in der Ausgabe; Credentials nie ausgeben. +- Kein Deploy-Risiko für den Daemon: eigener Subbefehl. + +## Implementation Notes +**Dateien:** `cmd/archivmail/cmd_imap_verify.go` (CLI), `internal/imap/verify.go` (Abgleichslogik, rein testbar), `internal/imap/verify_source.go` (IMAP-Quelle, DB-Lookup, Read-only-Store), `internal/imap/verify_output.go` (Text/JSON, Exit-Code), `internal/imap/verify_test.go`. Subbefehl in `main.go` registriert, Hilfe in `printHelp()` (`cmd_import.go`). + +**Aufruf:** `archivmail imap-verify [--config /etc/archivmail/config.yml] [--account ] [--folder ] [--json]` (Go-Flags, `-x` und `--x` gleichwertig). Exit-Code 0 = alles vollständig, 1 = fehlende oder nicht prüfbare Mails, 2 = mindestens ein Konto/Ordner fehlgeschlagen. + +**Read-only-Garantien:** +- IMAP: `EXAMINE` (`SelectOptions{ReadOnly: true}`), `UID SEARCH ALL`, `UID FETCH (UID BODY.PEEK[HEADER.FIELDS (MESSAGE-ID)])` in Batches à 500, Header auf 64 KiB begrenzt. Keine STORE/EXPUNGE/MOVE/COPY. +- DB: eigener pgx-Pool mit `default_transaction_read_only=on` (vom PostgreSQL-Server erzwungen). `imap.New()` wird bewusst nicht benutzt, weil es Migrationen (DDL) ausführt; stattdessen `imap.NewReadOnlyStore(pool, secret)` ohne Migrationen. Kein Zugriff auf Store/Manticore, kein Audit-Eintrag (keine schreibende Aktion; Audit-Log-Write wäre selbst ein DB-Write). +- Passwort-Entschlüsselung wie im Daemon (HKDF `archivmail-aes-v1` aus `api.secret`, dann `Store.GetPassword`); die HKDF-Ableitung ist in `deriveIMAPKey()` dupliziert, weil sie in `main()` inline steht. + +**Abgleich:** Message-ID-Normalisierung über `mailparser.Parse(header)` → `pm.MessageID`, also identisch zu `storage.Save`. Lookup `SELECT message_id FROM emails WHERE message_id = ANY($1)` in Batches à 1000, Message-IDs pro Ordner dedupliziert; Vorhandensein gilt global (Dedup über Ordner/Konten/Mandanten). Ausgeschlossen = `excluded_folders` des Kontos (gleiche Regel wie Importer/Scheduler; automatisch erkannte Junk/Trash-Ordner werden wie im Sync geprüft). `\Noselect`/`\NonExistent`-Ordner werden als „nicht auswählbar“ ausgewiesen statt als Fehler. + +**Status-Logik pro Ordner/Konto:** `complete` nur wenn fehlend = 0 und ohne Message-ID = 0; `incomplete` bei fehlenden; `unverified` wenn nur Mails ohne Message-ID übrig sind; `failed` bei IMAP-/DB-Fehler (Konto ist dann nie `complete`). Unbekannter `--folder` → `failed`. + +**Ausgabe/DSGVO:** nur Konto-ID, Ordnernamen, Zähler und `Ordner:UID`. Kein Kontoname, kein Benutzername, keine Message-IDs, Betreffs oder Adressen. Fehlertexte werden vor der Ausgabe um Passwort und IMAP-Benutzernamen bereinigt (`RedactSecrets`). + +**Tests (synthetisch, Fake-Quelle):** Normalisierung (Klammern, Whitespace, Groß-/Kleinschreibung, gefaltete Header), Zählung fehlender inkl. sortierter UIDs, Dedup, Ausschluss/Noselect werden nicht gefetcht, Fehlerfälle (Ordner, Lookup, LIST) → `failed` + Exit 2, Ordnerfilter, keine Credentials/Message-IDs in Text- und JSON-Ausgabe. `go vet ./...`, `go test ./internal/... ./pkg/...`, `go build ./cmd/...` auf 132 im Scratch-Verzeichnis grün. + +**Bekannte Grenzen:** +- Mails, deren vollständiger Rohtext beim Import nicht parsebar war, haben in `emails` keine `message_id` (Dedup lief dann über SHA-256) und erscheinen hier als „fehlend“, obwohl archiviert. +- Alte Datensätze ohne nachgetragene `message_id` erscheinen ebenfalls als fehlend. +- Noch nicht gegen ein echtes Postfach ausgeführt. diff --git a/internal/api/forwarded_via_test.go b/internal/api/forwarded_via_test.go new file mode 100644 index 0000000..8089032 --- /dev/null +++ b/internal/api/forwarded_via_test.go @@ -0,0 +1,70 @@ +package api + +import ( + "encoding/json" + "testing" + + "archivmail/pkg/mailparser" +) + +const forwardedHeaders = "Return-Path: \r\n" + + "Envelope-To: fwd@example.com\r\n" + + "From: carol@example.org\r\n" + + "To: dave@example.org\r\n" + +const directHeaders = "Return-Path: \r\n" + + "From: carol@example.org\r\n" + + "To: dave@example.org\r\n" + +func TestSearchHitForwardedViaJSON(t *testing.T) { + for _, tc := range []struct { + headers string + want string + }{{forwardedHeaders, "fwd@example.com"}, {directHeaders, ""}} { + pm := parseTestMail(t, tc.headers) + b, err := json.Marshal(searchHit{ID: "x", ForwardedVia: mailparser.ForwardedVia(pm)}) + if err != nil { + t.Fatal(err) + } + var m map[string]interface{} + if err := json.Unmarshal(b, &m); err != nil { + t.Fatal(err) + } + got, present := m["forwarded_via"] + if tc.want == "" { + if present { + t.Errorf("forwarded_via must be omitted, got %v", got) + } + } else if got != tc.want { + t.Errorf("forwarded_via = %v, want %q (json %s)", got, tc.want, b) + } + } +} + +func TestDetailForwardedVia(t *testing.T) { + resp := map[string]interface{}{"id": "x"} + setForwardedVia(resp, parseTestMail(t, forwardedHeaders)) + if resp["forwarded_via"] != "fwd@example.com" { + t.Errorf("forwarded_via = %v", resp["forwarded_via"]) + } + resp = map[string]interface{}{"id": "x"} + setForwardedVia(resp, parseTestMail(t, directHeaders)) + if _, ok := resp["forwarded_via"]; ok { + t.Error("forwarded_via must be absent for direct mail") + } +} + +func TestForwardedViaGrantsNoAccess(t *testing.T) { + pm := parseTestMail(t, "Return-Path: \r\n"+ + "Envelope-To: fwd@example.com\r\n"+ + "From: carol@example.org\r\n"+ + "To: dave@example.org\r\n") + if mailparser.ForwardedVia(pm) != "fwd@example.com" { + t.Fatal("precondition failed") + } + for _, src := range []string{"smtp", "upload", "import"} { + if mailBelongsToAnyAddress(pm, []string{"fwd@example.com"}, src) { + t.Errorf("source %s: forwarded_via must not grant access", src) + } + } +} diff --git a/internal/api/search_handlers.go b/internal/api/search_handlers.go index 24f9b28..57c3f50 100644 --- a/internal/api/search_handlers.go +++ b/internal/api/search_handlers.go @@ -163,24 +163,6 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { Success: true, }) - // Enrich hits with metadata (from, subject, date, size, attachments, thread). - type enrichedHit struct { - ID string `json:"id"` - 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"` - HasAttachments bool `json:"has_attachments"` - ThreadID string `json:"thread_id,omitempty"` - ThreadSize int `json:"thread_size,omitempty"` - Snippet string `json:"snippet,omitempty"` // PROJ-44: HTML excerpt with match tags - MatchField string `json:"match_field,omitempty"` // PROJ-44: subject|body|attachment_text|... - } - // Global auditor (no tenant_id): restrict results to mails with no tenant // assignment. A tenant-scoped auditor is already constrained by the // per-tenant index / tenant fallback filter above (PROJ-55). @@ -218,9 +200,9 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { } dropped := 0 - enriched := make([]enrichedHit, 0, len(result.Hits)) + enriched := make([]searchHit, 0, len(result.Hits)) for _, h := range result.Hits { - eh := enrichedHit{ID: h.ID, Score: h.Score, Snippet: h.Snippet, MatchField: h.MatchField} + eh := searchHit{ID: h.ID, Score: h.Score, Snippet: h.Snippet, MatchField: h.MatchField} raw, loadErr := s.store.Load(h.ID) var pm *mailparser.ParsedMail parseErr := loadErr @@ -240,6 +222,7 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { } eh.CC = strings.Join(pm.CC, ", ") eh.BCC = strings.Join(pm.BCC, ", ") + eh.ForwardedVia = mailparser.ForwardedVia(pm) eh.Subject = pm.Subject if !pm.Date.IsZero() { eh.Date = pm.Date.UTC().Format(time.RFC3339) @@ -384,7 +367,7 @@ func (s *Server) handleGetMail(w http.ResponseWriter, r *http.Request) { } } - writeJSON(w, http.StatusOK, map[string]interface{}{ + resp := map[string]interface{}{ "id": id, "from": pm.From, "to": strings.Join(pm.To, ", "), @@ -404,7 +387,9 @@ func (s *Server) handleGetMail(w http.ResponseWriter, r *http.Request) { "ocr_chars": ocrChars, "retain_until": retainUntil, "retain_until_source": retainSource, - }) + } + setForwardedVia(resp, pm) + writeJSON(w, http.StatusOK, resp) } func (s *Server) handleGetAttachment(w http.ResponseWriter, r *http.Request) { diff --git a/internal/api/search_hit.go b/internal/api/search_hit.go new file mode 100644 index 0000000..8a514b9 --- /dev/null +++ b/internal/api/search_hit.go @@ -0,0 +1,27 @@ +package api + +import "archivmail/pkg/mailparser" + +type searchHit struct { + ID string `json:"id"` + Score float64 `json:"score"` + From string `json:"from,omitempty"` + To string `json:"to,omitempty"` + CC string `json:"cc,omitempty"` + BCC string `json:"bcc,omitempty"` + ForwardedVia string `json:"forwarded_via,omitempty"` + Subject string `json:"subject,omitempty"` + Date string `json:"date,omitempty"` + Size int64 `json:"size,omitempty"` + HasAttachments bool `json:"has_attachments"` + ThreadID string `json:"thread_id,omitempty"` + ThreadSize int `json:"thread_size,omitempty"` + Snippet string `json:"snippet,omitempty"` // PROJ-44: HTML excerpt with match tags + MatchField string `json:"match_field,omitempty"` // PROJ-44: subject|body|attachment_text|... +} + +func setForwardedVia(resp map[string]interface{}, pm *mailparser.ParsedMail) { + if fv := mailparser.ForwardedVia(pm); fv != "" { + resp["forwarded_via"] = fv + } +} diff --git a/internal/api/thread_handlers.go b/internal/api/thread_handlers.go index d19c0d4..bca00e0 100644 --- a/internal/api/thread_handlers.go +++ b/internal/api/thread_handlers.go @@ -53,12 +53,13 @@ func (s *Server) handleGetThread(w http.ResponseWriter, r *http.Request) { } type mailSummary struct { - ID string `json:"id"` - From string `json:"from,omitempty"` - To string `json:"to,omitempty"` - Subject string `json:"subject"` - Date string `json:"date,omitempty"` - Size int `json:"size"` + ID string `json:"id"` + From string `json:"from,omitempty"` + To string `json:"to,omitempty"` + ForwardedVia string `json:"forwarded_via,omitempty"` + Subject string `json:"subject"` + Date string `json:"date,omitempty"` + Size int `json:"size"` } var access *mailAccess @@ -97,12 +98,13 @@ func (s *Server) handleGetThread(w http.ResponseWriter, r *http.Request) { } mails = append(mails, mailSummary{ - ID: id, - From: pm.From, - To: strings.Join(pm.To, ", "), - Subject: pm.Subject, - Date: dateStr, - Size: len(raw), + ID: id, + From: pm.From, + To: strings.Join(pm.To, ", "), + ForwardedVia: mailparser.ForwardedVia(pm), + Subject: pm.Subject, + Date: dateStr, + Size: len(raw), }) } diff --git a/internal/imap/verify.go b/internal/imap/verify.go new file mode 100644 index 0000000..3e59f5e --- /dev/null +++ b/internal/imap/verify.go @@ -0,0 +1,200 @@ +package imap + +import ( + "context" + "fmt" + "sort" + "strings" + + "archivmail/pkg/mailparser" +) + +const ( + VerifyComplete = "complete" + VerifyIncomplete = "incomplete" + VerifyUnverified = "unverified" + VerifyFailed = "failed" + VerifyExcluded = "excluded" + VerifyNoSelect = "noselect" +) + +type VerifyMessage struct { + UID uint32 + Header []byte +} + +type VerifyFolder struct { + Name string + NoSelect bool +} + +type VerifySource interface { + Folders() ([]VerifyFolder, error) + Messages(folder string) ([]VerifyMessage, error) +} + +type ArchiveLookup func(ctx context.Context, messageIDs []string) (map[string]bool, error) + +type FolderReport struct { + Folder string `json:"folder"` + Status string `json:"status"` + InMailbox int `json:"in_mailbox"` + Found int `json:"found"` + Missing int `json:"missing"` + NoMessageID int `json:"no_message_id"` + MissingUIDs []uint32 `json:"missing_uids,omitempty"` + Error string `json:"error,omitempty"` +} + +type AccountReport struct { + AccountID int64 `json:"account_id"` + Status string `json:"status"` + Error string `json:"error,omitempty"` + InMailbox int `json:"in_mailbox"` + Found int `json:"found"` + Missing int `json:"missing"` + NoMessageID int `json:"no_message_id"` + FoldersFailed int `json:"folders_failed"` + Folders []FolderReport `json:"folders"` +} + +// NormalizeMessageID must yield exactly what storage.Save writes into +// emails.message_id, hence it goes through mailparser.Parse instead of +// re-implementing the header handling. +func NormalizeMessageID(header []byte) string { + if len(header) == 0 { + return "" + } + pm, err := mailparser.Parse(header) + if err != nil || pm == nil { + return "" + } + return pm.MessageID +} + +func ClassifyFolder(folder string, msgs []VerifyMessage, present map[string]bool) FolderReport { + r := FolderReport{Folder: folder, InMailbox: len(msgs)} + for _, m := range msgs { + id := NormalizeMessageID(m.Header) + switch { + case id == "": + r.NoMessageID++ + case present[id]: + r.Found++ + default: + r.Missing++ + r.MissingUIDs = append(r.MissingUIDs, m.UID) + } + } + sort.Slice(r.MissingUIDs, func(i, j int) bool { return r.MissingUIDs[i] < r.MissingUIDs[j] }) + switch { + case r.Missing > 0: + r.Status = VerifyIncomplete + case r.NoMessageID > 0: + r.Status = VerifyUnverified + default: + r.Status = VerifyComplete + } + return r +} + +func VerifyAccount(ctx context.Context, accountID int64, src VerifySource, lookup ArchiveLookup, excludedFolders []string, onlyFolder string, secrets []string) AccountReport { + rep := AccountReport{AccountID: accountID} + + folders, err := src.Folders() + if err != nil { + rep.Status = VerifyFailed + rep.Error = RedactSecrets(fmt.Sprintf("list folders: %v", err), secrets) + return rep + } + + excluded := make(map[string]bool, len(excludedFolders)) + for _, f := range excludedFolders { + excluded[f] = true + } + + matched := false + for _, f := range folders { + if onlyFolder != "" && f.Name != onlyFolder { + continue + } + matched = true + rep.Folders = append(rep.Folders, verifyFolder(ctx, f, src, lookup, excluded[f.Name], secrets)) + } + if onlyFolder != "" && !matched { + rep.Folders = append(rep.Folders, FolderReport{Folder: onlyFolder, Status: VerifyFailed, Error: "folder not found on server"}) + } + + finalizeAccount(&rep) + return rep +} + +func verifyFolder(ctx context.Context, f VerifyFolder, src VerifySource, lookup ArchiveLookup, isExcluded bool, secrets []string) FolderReport { + if isExcluded { + return FolderReport{Folder: f.Name, Status: VerifyExcluded} + } + if f.NoSelect { + return FolderReport{Folder: f.Name, Status: VerifyNoSelect} + } + + msgs, err := src.Messages(f.Name) + if err != nil { + return FolderReport{Folder: f.Name, Status: VerifyFailed, Error: RedactSecrets(err.Error(), secrets)} + } + + ids := make([]string, 0, len(msgs)) + seen := make(map[string]bool, len(msgs)) + for _, m := range msgs { + id := NormalizeMessageID(m.Header) + if id == "" || seen[id] { + continue + } + seen[id] = true + ids = append(ids, id) + } + + present := map[string]bool{} + if len(ids) > 0 { + present, err = lookup(ctx, ids) + if err != nil { + return FolderReport{Folder: f.Name, Status: VerifyFailed, InMailbox: len(msgs), Error: RedactSecrets(fmt.Sprintf("archive lookup: %v", err), secrets)} + } + } + return ClassifyFolder(f.Name, msgs, present) +} + +func finalizeAccount(rep *AccountReport) { + for _, f := range rep.Folders { + rep.InMailbox += f.InMailbox + rep.Found += f.Found + rep.Missing += f.Missing + rep.NoMessageID += f.NoMessageID + if f.Status == VerifyFailed { + rep.FoldersFailed++ + } + } + switch { + case rep.Error != "" || rep.FoldersFailed > 0: + rep.Status = VerifyFailed + case rep.Missing > 0: + rep.Status = VerifyIncomplete + case rep.NoMessageID > 0: + rep.Status = VerifyUnverified + default: + rep.Status = VerifyComplete + } +} + +func FailedAccountReport(accountID int64, err error, secrets []string) AccountReport { + return AccountReport{AccountID: accountID, Status: VerifyFailed, Error: RedactSecrets(err.Error(), secrets)} +} + +func RedactSecrets(msg string, secrets []string) string { + for _, s := range secrets { + if s == "" { + continue + } + msg = strings.ReplaceAll(msg, s, "***") + } + return msg +} diff --git a/internal/imap/verify_output.go b/internal/imap/verify_output.go new file mode 100644 index 0000000..a170289 --- /dev/null +++ b/internal/imap/verify_output.go @@ -0,0 +1,77 @@ +package imap + +import ( + "encoding/json" + "fmt" + "io" + "strings" +) + +var verifyStatusLabel = map[string]string{ + VerifyComplete: "vollständig", + VerifyIncomplete: "unvollständig", + VerifyUnverified: "nicht prüfbar (Mails ohne Message-ID)", + VerifyFailed: "fehlgeschlagen", + VerifyExcluded: "ausgeschlossen", + VerifyNoSelect: "nicht auswählbar (\\Noselect)", +} + +func WriteVerifyJSON(w io.Writer, reports []AccountReport) error { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + if reports == nil { + reports = []AccountReport{} + } + return enc.Encode(map[string]any{"accounts": reports}) +} + +func WriteVerifyText(w io.Writer, reports []AccountReport) { + var tot AccountReport + for _, r := range reports { + fmt.Fprintf(w, "Konto %d: %s\n", r.AccountID, verifyStatusLabel[r.Status]) + if r.Error != "" { + fmt.Fprintf(w, " Fehler: %s\n", r.Error) + } + var missing []string + for _, f := range r.Folders { + switch f.Status { + case VerifyExcluded, VerifyNoSelect: + fmt.Fprintf(w, " %-30s %s\n", f.Folder, verifyStatusLabel[f.Status]) + case VerifyFailed: + fmt.Fprintf(w, " %-30s fehlgeschlagen: %s\n", f.Folder, f.Error) + default: + fmt.Fprintf(w, " %-30s Postfach %d Archiv %d fehlend %d ohne Message-ID %d [%s]\n", + f.Folder, f.InMailbox, f.Found, f.Missing, f.NoMessageID, verifyStatusLabel[f.Status]) + } + for _, uid := range f.MissingUIDs { + missing = append(missing, fmt.Sprintf("%s:%d", f.Folder, uid)) + } + } + if len(missing) > 0 { + fmt.Fprintf(w, " Fehlend (Ordner:UID): %s\n", strings.Join(missing, " ")) + } + tot.InMailbox += r.InMailbox + tot.Found += r.Found + tot.Missing += r.Missing + tot.NoMessageID += r.NoMessageID + tot.FoldersFailed += r.FoldersFailed + if r.Error != "" { + tot.FoldersFailed++ + } + } + fmt.Fprintf(w, "Gesamt: Konten %d Postfach %d Archiv %d fehlend %d ohne Message-ID %d fehlgeschlagen %d\n", + len(reports), tot.InMailbox, tot.Found, tot.Missing, tot.NoMessageID, tot.FoldersFailed) +} + +func VerifyExitCode(reports []AccountReport) int { + code := 0 + for _, r := range reports { + switch r.Status { + case VerifyFailed: + return 2 + case VerifyIncomplete, VerifyUnverified: + code = 1 + } + } + return code +} diff --git a/internal/imap/verify_source.go b/internal/imap/verify_source.go new file mode 100644 index 0000000..abf4a8c --- /dev/null +++ b/internal/imap/verify_source.go @@ -0,0 +1,175 @@ +package imap + +import ( + "context" + "crypto/sha256" + "fmt" + "io" + "strings" + + imapv2 "github.com/emersion/go-imap/v2" + "github.com/emersion/go-imap/v2/imapclient" + "github.com/jackc/pgx/v5/pgxpool" +) + +const ( + verifyFetchBatch = 500 + verifyLookupBatch = 1000 + maxHeaderBytes = 64 << 10 +) + +type imapVerifySource struct { + c *Conn +} + +func DialVerifySource(acc *Account, password string) (VerifySource, func(), error) { + c, err := Connect(acc.Host, acc.Port, acc.TLS) + if err != nil { + return nil, nil, fmt.Errorf("connect: %w", err) + } + c.SetFetchDeadline() + if err := c.Login(acc.Username, password).Wait(); err != nil { + _ = c.Close() + return nil, nil, fmt.Errorf("login: %w", err) + } + c.ClearDeadline() + closeFn := func() { + c.SetFetchDeadline() + _ = c.Logout().Wait() + _ = c.Close() + } + return &imapVerifySource{c: c}, closeFn, nil +} + +func (s *imapVerifySource) Folders() ([]VerifyFolder, error) { + s.c.SetFetchDeadline() + defer s.c.ClearDeadline() + mailboxes, err := s.c.List("", "*", nil).Collect() + if err != nil { + return nil, fmt.Errorf("imap list: %w", err) + } + out := make([]VerifyFolder, 0, len(mailboxes)) + for _, mb := range mailboxes { + f := VerifyFolder{Name: mb.Mailbox} + for _, a := range mb.Attrs { + if strings.EqualFold(string(a), string(imapv2.MailboxAttrNoSelect)) || + strings.EqualFold(string(a), string(imapv2.MailboxAttrNonExistent)) { + f.NoSelect = true + } + } + out = append(out, f) + } + return out, nil +} + +func (s *imapVerifySource) Messages(folder string) ([]VerifyMessage, error) { + s.c.SetFetchDeadline() + defer s.c.ClearDeadline() + + // EXAMINE instead of SELECT: the mailbox is opened read-only, so the + // server cannot change flags as a side effect of this check. + if _, err := s.c.Select(folder, &imapv2.SelectOptions{ReadOnly: true}).Wait(); err != nil { + return nil, fmt.Errorf("examine: %w", err) + } + sd, err := s.c.UIDSearch(&imapv2.SearchCriteria{}, nil).Wait() + if err != nil { + return nil, fmt.Errorf("uid search: %w", err) + } + uids := sd.AllUIDs() + + msgs := make([]VerifyMessage, 0, len(uids)) + for i := 0; i < len(uids); i += verifyFetchBatch { + end := min(i+verifyFetchBatch, len(uids)) + s.c.SetFetchDeadline() + batch, err := fetchMessageIDHeaders(s.c.Client, uids[i:end]) + if err != nil { + return nil, fmt.Errorf("fetch at offset %d: %w", i, err) + } + msgs = append(msgs, batch...) + } + return msgs, nil +} + +func fetchMessageIDHeaders(c *imapclient.Client, uids []imapv2.UID) ([]VerifyMessage, error) { + opts := &imapv2.FetchOptions{ + UID: true, + BodySection: []*imapv2.FetchItemBodySection{{ + Specifier: imapv2.PartSpecifierHeader, + HeaderFields: []string{"Message-ID"}, + Peek: true, + }}, + } + cmd := c.Fetch(imapv2.UIDSetNum(uids...), opts) + + var out []VerifyMessage + var readErr error + for { + msg := cmd.Next() + if msg == nil { + break + } + var vm VerifyMessage + for { + item := msg.Next() + if item == nil { + break + } + switch it := item.(type) { + case imapclient.FetchItemDataUID: + vm.UID = uint32(it.UID) + case imapclient.FetchItemDataBodySection: + if it.Literal == nil { + continue + } + b, err := io.ReadAll(io.LimitReader(it.Literal, maxHeaderBytes)) + if err != nil && readErr == nil { + readErr = err + } + _, _ = io.Copy(io.Discard, it.Literal) + vm.Header = b + } + } + if vm.UID != 0 { + out = append(out, vm) + } + } + if err := cmd.Close(); err != nil { + return nil, fmt.Errorf("fetch close: %w", err) + } + if readErr != nil { + return nil, fmt.Errorf("read header: %w", readErr) + } + return out, nil +} + +func NewArchiveLookup(pool *pgxpool.Pool) ArchiveLookup { + return func(ctx context.Context, ids []string) (map[string]bool, error) { + found := make(map[string]bool, len(ids)) + for i := 0; i < len(ids); i += verifyLookupBatch { + end := min(i+verifyLookupBatch, len(ids)) + rows, err := pool.Query(ctx, `SELECT message_id FROM emails WHERE message_id = ANY($1)`, ids[i:end]) + if err != nil { + return nil, fmt.Errorf("query emails: %w", err) + } + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + rows.Close() + return nil, fmt.Errorf("scan message_id: %w", err) + } + found[id] = true + } + rows.Close() + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("rows: %w", err) + } + } + return found, nil + } +} + +// NewReadOnlyStore skips the migrations New() runs, so imap-verify never +// issues DDL against the database. +func NewReadOnlyStore(pool *pgxpool.Pool, secret string) *Store { + return &Store{pool: pool, encKey: sha256.Sum256([]byte(secret))} +} diff --git a/internal/imap/verify_test.go b/internal/imap/verify_test.go new file mode 100644 index 0000000..6647385 --- /dev/null +++ b/internal/imap/verify_test.go @@ -0,0 +1,222 @@ +package imap + +import ( + "bytes" + "context" + "errors" + "reflect" + "strings" + "testing" +) + +type fakeSource struct { + folders []VerifyFolder + folderErr error + messages map[string][]VerifyMessage + messageErr map[string]error + requested []string +} + +func (f *fakeSource) Folders() ([]VerifyFolder, error) { return f.folders, f.folderErr } + +func (f *fakeSource) Messages(folder string) ([]VerifyMessage, error) { + f.requested = append(f.requested, folder) + if err := f.messageErr[folder]; err != nil { + return nil, err + } + return f.messages[folder], nil +} + +func hdr(id string) []byte { + if id == "" { + return []byte("\r\n") + } + return []byte("Message-ID: " + id + "\r\n\r\n") +} + +func archiveOf(ids ...string) (ArchiveLookup, *[][]string) { + set := map[string]bool{} + for _, id := range ids { + set[id] = true + } + var calls [][]string + return func(_ context.Context, q []string) (map[string]bool, error) { + calls = append(calls, append([]string(nil), q...)) + out := map[string]bool{} + for _, id := range q { + if set[id] { + out[id] = true + } + } + return out, nil + }, &calls +} + +func TestNormalizeMessageID(t *testing.T) { + cases := map[string]string{ + "Message-ID: \r\n\r\n": "abc@example.test", + "Message-Id: \r\n\r\n": "abc@example.test", + "MESSAGE-ID: abc@example.test\r\n\r\n": "abc@example.test", + "Message-ID:\r\n \r\n\r\n": "folded@example.test", + "\r\n": "", + "": "", + } + for in, want := range cases { + if got := NormalizeMessageID([]byte(in)); got != want { + t.Errorf("NormalizeMessageID(%q) = %q, want %q", in, got, want) + } + } +} + +func TestVerifyAccountCountsAndDedup(t *testing.T) { + src := &fakeSource{ + folders: []VerifyFolder{{Name: "INBOX"}, {Name: "Archiv"}, {Name: "Spam"}, {Name: "[Root]", NoSelect: true}}, + messages: map[string][]VerifyMessage{ + "INBOX": { + {UID: 1, Header: hdr("")}, + {UID: 2, Header: hdr("")}, + {UID: 7, Header: hdr("")}, + {UID: 3, Header: hdr("")}, + {UID: 4, Header: hdr("")}, + }, + "Archiv": { + {UID: 10, Header: hdr("")}, + }, + "Spam": {{UID: 99, Header: hdr("")}}, + }, + } + lookup, _ := archiveOf("a@example.test", "b@example.test") + + rep := VerifyAccount(context.Background(), 5, src, lookup, []string{"Spam"}, "", nil) + + if rep.Status != VerifyIncomplete { + t.Fatalf("status = %q", rep.Status) + } + if rep.InMailbox != 6 || rep.Found != 3 || rep.Missing != 2 || rep.NoMessageID != 1 { + t.Fatalf("totals = %+v", rep) + } + byName := map[string]FolderReport{} + for _, f := range rep.Folders { + byName[f.Folder] = f + } + if !reflect.DeepEqual(byName["INBOX"].MissingUIDs, []uint32{3, 7}) { + t.Errorf("missing uids = %v", byName["INBOX"].MissingUIDs) + } + if byName["Archiv"].Status != VerifyComplete || byName["Archiv"].Found != 1 { + t.Errorf("Archiv = %+v", byName["Archiv"]) + } + if byName["Spam"].Status != VerifyExcluded { + t.Errorf("Spam = %+v", byName["Spam"]) + } + if byName["[Root]"].Status != VerifyNoSelect { + t.Errorf("[Root] = %+v", byName["[Root]"]) + } + for _, f := range src.requested { + if f == "Spam" || f == "[Root]" { + t.Errorf("excluded/noselect folder %q was fetched", f) + } + } +} + +func TestVerifyFolderDedupesLookupIDs(t *testing.T) { + src := &fakeSource{ + folders: []VerifyFolder{{Name: "INBOX"}}, + messages: map[string][]VerifyMessage{"INBOX": { + {UID: 1, Header: hdr("")}, + {UID: 2, Header: hdr("")}, + }}, + } + lookup, calls := archiveOf("dup@example.test") + rep := VerifyAccount(context.Background(), 1, src, lookup, nil, "", nil) + if rep.Status != VerifyComplete || rep.Found != 2 { + t.Fatalf("rep = %+v", rep) + } + if len(*calls) != 1 || len((*calls)[0]) != 1 { + t.Fatalf("lookup calls = %v", *calls) + } +} + +func TestVerifyFolderFailureNeverComplete(t *testing.T) { + src := &fakeSource{ + folders: []VerifyFolder{{Name: "INBOX"}, {Name: "Sent"}}, + messages: map[string][]VerifyMessage{"INBOX": {{UID: 1, Header: hdr("")}}}, + messageErr: map[string]error{"Sent": errors.New("i/o timeout")}, + } + lookup, _ := archiveOf("a@example.test") + rep := VerifyAccount(context.Background(), 1, src, lookup, nil, "", nil) + if rep.Status != VerifyFailed || rep.FoldersFailed != 1 { + t.Fatalf("rep = %+v", rep) + } + if VerifyExitCode([]AccountReport{rep}) != 2 { + t.Fatal("exit code must signal failure") + } + + failingLookup := func(context.Context, []string) (map[string]bool, error) { + return nil, errors.New("db down") + } + rep = VerifyAccount(context.Background(), 1, src, failingLookup, nil, "INBOX", nil) + if rep.Status != VerifyFailed { + t.Fatalf("lookup failure: %+v", rep) + } + + rep = VerifyAccount(context.Background(), 1, &fakeSource{folderErr: errors.New("list broken")}, lookup, nil, "", nil) + if rep.Status != VerifyFailed { + t.Fatalf("list failure: %+v", rep) + } +} + +func TestVerifyFolderFilter(t *testing.T) { + src := &fakeSource{ + folders: []VerifyFolder{{Name: "INBOX"}, {Name: "Sent"}}, + messages: map[string][]VerifyMessage{"Sent": {{UID: 1, Header: hdr("")}}}, + } + lookup, _ := archiveOf("a@example.test") + rep := VerifyAccount(context.Background(), 1, src, lookup, nil, "Sent", nil) + if len(rep.Folders) != 1 || rep.Folders[0].Folder != "Sent" || rep.Status != VerifyComplete { + t.Fatalf("rep = %+v", rep) + } + rep = VerifyAccount(context.Background(), 1, src, lookup, nil, "Nope", nil) + if rep.Status != VerifyFailed { + t.Fatalf("unknown folder must fail: %+v", rep) + } +} + +func TestUnverifiedWhenOnlyNoMessageID(t *testing.T) { + r := ClassifyFolder("INBOX", []VerifyMessage{{UID: 1, Header: hdr("")}}, nil) + if r.Status != VerifyUnverified || r.NoMessageID != 1 { + t.Fatalf("r = %+v", r) + } +} + +func TestOutputContainsNoCredentials(t *testing.T) { + const password = "S3cr3t-Pa55w0rd!" + const username = "imapuser@example.test" + src := &fakeSource{ + folders: []VerifyFolder{{Name: "INBOX"}, {Name: "Sent"}}, + messages: map[string][]VerifyMessage{"INBOX": {{UID: 42, Header: hdr("")}}}, + messageErr: map[string]error{"Sent": errors.New("NO [AUTHENTICATIONFAILED] " + username + " with " + password)}, + } + lookup, _ := archiveOf() + secrets := []string{password, username} + reports := []AccountReport{ + VerifyAccount(context.Background(), 3, src, lookup, nil, "", secrets), + FailedAccountReport(4, errors.New("login: bad password "+password+" for "+username), secrets), + } + + var text, js bytes.Buffer + WriteVerifyText(&text, reports) + if err := WriteVerifyJSON(&js, reports); err != nil { + t.Fatal(err) + } + for _, out := range []string{text.String(), js.String()} { + if strings.Contains(out, password) || strings.Contains(out, username) { + t.Fatalf("credentials leaked:\n%s", out) + } + if strings.Contains(out, "x@example.test") { + t.Fatalf("message-id leaked:\n%s", out) + } + } + if !strings.Contains(text.String(), "INBOX:42") { + t.Fatalf("missing Ordner:UID entry:\n%s", text.String()) + } +} diff --git a/pkg/mailparser/forwarded.go b/pkg/mailparser/forwarded.go new file mode 100644 index 0000000..2fbf52c --- /dev/null +++ b/pkg/mailparser/forwarded.go @@ -0,0 +1,36 @@ +package mailparser + +import ( + "net/mail" + "strings" +) + +// Display only: Return-Path is sender-controlled and must never drive access decisions. +func ForwardedVia(pm *ParsedMail) string { + if pm == nil { + return "" + } + rp := normalizeAddress(pm.ReturnPath) + if rp == "" || rp == normalizeAddress(pm.From) { + return "" + } + for _, list := range [][]string{pm.To, pm.CC, pm.BCC, pm.DeliveryAddresses} { + for _, a := range list { + if normalizeAddress(a) == rp { + return rp + } + } + } + return "" +} + +func normalizeAddress(s string) string { + s = strings.TrimSpace(s) + if s == "" || s == "<>" { + return "" + } + if a, err := mail.ParseAddress(s); err == nil { + return strings.ToLower(strings.TrimSpace(a.Address)) + } + return strings.ToLower(strings.Trim(s, "<>")) +} diff --git a/pkg/mailparser/forwarded_test.go b/pkg/mailparser/forwarded_test.go new file mode 100644 index 0000000..cb279d9 --- /dev/null +++ b/pkg/mailparser/forwarded_test.go @@ -0,0 +1,135 @@ +package mailparser_test + +import ( + "testing" + + "archivmail/pkg/mailparser" +) + +func parseRaw(t *testing.T, headers string) *mailparser.ParsedMail { + t.Helper() + pm, err := mailparser.Parse([]byte(headers + "Subject: test\r\n\r\nbody\r\n")) + if err != nil { + t.Fatalf("Parse: %v", err) + } + return pm +} + +func TestForwardedVia(t *testing.T) { + tests := []struct { + name string + headers string + want string + }{ + { + name: "forward via cc", + headers: "Return-Path: \r\n" + + "From: Carol \r\n" + + "To: dave@example.org\r\n" + + "Cc: Fwd \r\n", + want: "fwd@example.com", + }, + { + name: "forward via envelope-to", + headers: "Return-Path: \r\n" + + "Envelope-To: fwd@example.com\r\n" + + "From: carol@example.org\r\n" + + "To: dave@example.org\r\n", + want: "fwd@example.com", + }, + { + name: "forward via delivered-to", + headers: "Return-Path: \r\n" + + "Delivered-To: fwd@example.com\r\n" + + "From: carol@example.org\r\n" + + "To: dave@example.org\r\n", + want: "fwd@example.com", + }, + { + name: "forward via bcc", + headers: "Return-Path: \r\n" + + "From: carol@example.org\r\n" + + "To: dave@example.org\r\n" + + "Bcc: fwd@example.com\r\n", + want: "fwd@example.com", + }, + { + name: "return-path equals from", + headers: "Return-Path: \r\n" + + "From: Carol \r\n" + + "To: dave@example.org, carol@example.org\r\n", + want: "", + }, + { + name: "return-path equals from case-insensitive", + headers: "Return-Path: \r\n" + + "From: CAROL@example.org\r\n" + + "To: carol@example.org\r\n", + want: "", + }, + { + name: "newsletter bounce address not a recipient", + headers: "Return-Path: \r\n" + + "From: news@example.net\r\n" + + "To: dave@example.org\r\n", + want: "", + }, + { + name: "no return-path", + headers: "From: carol@example.org\r\n" + + "To: dave@example.org\r\n", + want: "", + }, + { + name: "null return-path", + headers: "Return-Path: <>\r\n" + + "From: carol@example.org\r\n" + + "To: dave@example.org\r\n", + want: "", + }, + { + name: "mixed case recipient", + headers: "Return-Path: \r\n" + + "From: carol@example.org\r\n" + + "To: FWD@example.com\r\n", + want: "fwd@example.com", + }, + { + name: "multiple recipients", + headers: "Return-Path: \r\n" + + "From: carol@example.org\r\n" + + "To: dave@example.org, Erin , fwd@example.com\r\n" + + "Cc: frank@example.org\r\n", + want: "fwd@example.com", + }, + { + name: "multiple recipients without match", + headers: "Return-Path: \r\n" + + "From: carol@example.org\r\n" + + "To: dave@example.org, erin@example.org\r\n" + + "Cc: frank@example.org\r\n", + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := mailparser.ForwardedVia(parseRaw(t, tt.headers)); got != tt.want { + t.Errorf("ForwardedVia = %q, want %q", got, tt.want) + } + }) + } +} + +func TestForwardedViaDirectFields(t *testing.T) { + if got := mailparser.ForwardedVia(nil); got != "" { + t.Errorf("nil: got %q", got) + } + pm := &mailparser.ParsedMail{ + From: "carol@example.org", + ReturnPath: "fwd@example.com", + DeliveryAddresses: []string{"fwd@example.com"}, + } + if got := mailparser.ForwardedVia(pm); got != "fwd@example.com" { + t.Errorf("delivery address: got %q", got) + } +} diff --git a/src/app/mail/[id]/page.tsx b/src/app/mail/[id]/page.tsx index 2eeca49..2db5329 100644 --- a/src/app/mail/[id]/page.tsx +++ b/src/app/mail/[id]/page.tsx @@ -25,6 +25,7 @@ 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 { ForwardedViaBadge } from "@/components/mail/ForwardedViaBadge"; import { FileText } from "lucide-react"; // ── Helpers ──────────────────────────────────────────────────────────────── @@ -180,6 +181,14 @@ function MailHeaderGrid({ mail }: { mail: MailDetail }) {
Von: {mail.from || "–"} + {mail.forwarded_via && ( + <> + Weiterleitung: + + + + + )} An: {mail.cc && ( diff --git a/src/components/mail/ForwardedViaBadge.tsx b/src/components/mail/ForwardedViaBadge.tsx new file mode 100644 index 0000000..018093c --- /dev/null +++ b/src/components/mail/ForwardedViaBadge.tsx @@ -0,0 +1,29 @@ +import { Forward } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; + +interface ForwardedViaBadgeProps { + address?: string; + compact?: boolean; + className?: string; +} + +export function ForwardedViaBadge({ address, compact = false, className }: ForwardedViaBadgeProps) { + if (!address) return null; + const label = compact ? "via" : "Weitergeleitet über"; + return ( + + + ); +} diff --git a/src/components/search/SearchResultsTable.tsx b/src/components/search/SearchResultsTable.tsx index 6bd054c..fdbddf8 100644 --- a/src/components/search/SearchResultsTable.tsx +++ b/src/components/search/SearchResultsTable.tsx @@ -7,6 +7,7 @@ import { sanitizeSnippet } from "@/lib/sanitize"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { Checkbox } from "@/components/ui/checkbox"; +import { ForwardedViaBadge } from "@/components/mail/ForwardedViaBadge"; import { Table, TableBody, @@ -129,7 +130,12 @@ function SearchResultsTableInner({ ? new Date(hit.date).toLocaleString("de-DE", { dateStyle: "short", timeStyle: "short" }) : "-"} - {hit.from || "-"} + +
{hit.from || "-"}
+ {hit.forwarded_via && ( + + )} +
{hit.subject || "(kein Betreff)"} @@ -145,6 +151,11 @@ function SearchResultsTableInner({ {hit.from}
)} + {hit.forwarded_via && ( +
+ +
+ )}
diff --git a/src/data/features.ts b/src/data/features.ts index 538abac..0e1e257 100644 --- a/src/data/features.ts +++ b/src/data/features.ts @@ -95,4 +95,6 @@ export const features: Feature[] = [ { id: "PROJ-89", name: "From/To/CC/BCC vollständig auslesen und im Portal darstellen", status: "In Review", frontend: true, backend: true, lastUpdated: "2026-10-22", version: "1.0" }, { id: "PROJ-90", name: "Sichtbarkeit aller Mails eines IMAP-Kontos für den Konto-Owner", status: "In Progress", frontend: false, backend: true, lastUpdated: "2026-10-29", version: "1.0" }, { id: "PROJ-91", name: "Envelope-Absender (Return-Path) durchsuchbar machen", status: "In Progress", frontend: false, backend: true, lastUpdated: "2026-10-29", version: "1.0" }, + { id: "PROJ-92", name: "Weiterleitung in Liste und Detail anzeigen", status: "In Progress", frontend: true, backend: true, lastUpdated: "2026-10-29", version: "1.0" }, + { id: "PROJ-93", name: "IMAP-Vollständigkeitsprüfung (Postfach vs. Archiv)", status: "In Progress", frontend: false, backend: true, lastUpdated: "2026-10-29", version: "1.0" }, ]; diff --git a/src/lib/api/mail.ts b/src/lib/api/mail.ts index a403c9b..21b9820 100644 --- a/src/lib/api/mail.ts +++ b/src/lib/api/mail.ts @@ -19,6 +19,7 @@ export interface SearchHit { to?: string; cc?: string; bcc?: string; + forwarded_via?: string; subject?: string; date?: string; size?: number; @@ -34,6 +35,7 @@ export interface ThreadMail { id: string; from?: string; to?: string; + forwarded_via?: string; subject: string; date?: string; size: number; @@ -63,6 +65,7 @@ export interface MailDetail { to: string; cc?: string; bcc?: string; + forwarded_via?: string; subject: string; date: string; size: number;