feat(PROJ-92,PROJ-93): Weiterleitung in Liste/Detail anzeigen, imap-verify Vollständigkeitsprüfung

PROJ-92: forwarded_via in Suchtreffern, Detail und Threads; Badge im Portal.
PROJ-93: archivmail imap-verify gleicht Message-IDs im IMAP-Postfach mit dem
Archiv ab (strikt lesend, nur Zahlen und Ordner:UID).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
patrickandClaude Sonnet 5.5 committed 2026-10-29 14:20:34 +01:00
1 parent b15bab355b
commit 0501118bcd
21 files changed
+1258 -37

No files matched your search

+118
View File
@@ -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
}
+9 -1
View File
@@ -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)
}
+3
View File
@@ -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
+3 -1
View File
@@ -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 |
<!-- Add features above this line -->
## Next Available ID: PROJ-92
## Next Available ID: PROJ-94
@@ -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 <Adresse>" neben den Adressfeldern; Header-Absender bleibt unverändert als "Von".
- [ ] Liste: bei gesetztem `forwarded_via` ein kleines Badge/Zusatz "via <Adresse>" (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 <Adresse>" (`break-all`, kein Überlauf mobil). "Von" bleibt Header-Absender.
- Liste (`src/components/search/SearchResultsTable.tsx`): kompaktes Badge "via <Adresse>" (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.
@@ -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 <id>] [-folder <name>] [-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 <id>] [--folder <name>] [--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.
+70
View File
@@ -0,0 +1,70 @@
package api
import (
"encoding/json"
"testing"
"archivmail/pkg/mailparser"
)
const forwardedHeaders = "Return-Path: <fwd@example.com>\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: <carol@example.org>\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: <fwd@example.com>\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)
}
}
}
+7 -22
View File
@@ -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 <b>match</b> 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) {
+27
View File
@@ -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 <b>match</b> 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
}
}
+14 -12
View File
@@ -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),
})
}
+200
View File
@@ -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
}
+77
View File
@@ -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
}
+175
View File
@@ -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))}
}
+222
View File
@@ -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: <abc@example.test>\r\n\r\n": "abc@example.test",
"Message-Id: <abc@example.test> \r\n\r\n": "abc@example.test",
"MESSAGE-ID: abc@example.test\r\n\r\n": "abc@example.test",
"Message-ID:\r\n <folded@example.test>\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("<a@example.test>")},
{UID: 2, Header: hdr("<b@example.test>")},
{UID: 7, Header: hdr("<missing2@example.test>")},
{UID: 3, Header: hdr("<missing1@example.test>")},
{UID: 4, Header: hdr("")},
},
"Archiv": {
{UID: 10, Header: hdr("<a@example.test>")},
},
"Spam": {{UID: 99, Header: hdr("<spam@example.test>")}},
},
}
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("<dup@example.test>")},
{UID: 2, Header: hdr("<dup@example.test>")},
}},
}
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("<a@example.test>")}}},
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("<a@example.test>")}}},
}
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("<x@example.test>")}}},
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())
}
}
+36
View File
@@ -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, "<>"))
}
+135
View File
@@ -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: <fwd@example.com>\r\n" +
"From: Carol <carol@example.org>\r\n" +
"To: dave@example.org\r\n" +
"Cc: Fwd <fwd@example.com>\r\n",
want: "fwd@example.com",
},
{
name: "forward via envelope-to",
headers: "Return-Path: <fwd@example.com>\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: <fwd@example.com>\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: <fwd@example.com>\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: <carol@example.org>\r\n" +
"From: Carol <carol@example.org>\r\n" +
"To: dave@example.org, carol@example.org\r\n",
want: "",
},
{
name: "return-path equals from case-insensitive",
headers: "Return-Path: <Carol@Example.ORG>\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: <bounce-123@mailer.example.net>\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: <Fwd@Example.COM>\r\n" +
"From: carol@example.org\r\n" +
"To: FWD@example.com\r\n",
want: "fwd@example.com",
},
{
name: "multiple recipients",
headers: "Return-Path: <fwd@example.com>\r\n" +
"From: carol@example.org\r\n" +
"To: dave@example.org, Erin <erin@example.org>, fwd@example.com\r\n" +
"Cc: frank@example.org\r\n",
want: "fwd@example.com",
},
{
name: "multiple recipients without match",
headers: "Return-Path: <fwd@example.com>\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)
}
}
+9
View File
@@ -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 }) {
<div className="grid grid-cols-[6rem_1fr] gap-x-4 gap-y-1.5 text-sm">
<span className="font-medium text-muted-foreground">Von:</span>
<span className="break-all">{mail.from || "–"}</span>
{mail.forwarded_via && (
<>
<span className="font-medium text-muted-foreground">Weiterleitung:</span>
<span className="flex min-w-0">
<ForwardedViaBadge address={mail.forwarded_via} />
</span>
</>
)}
<span className="font-medium text-muted-foreground">An:</span>
<AddressList value={mail.to} label="An" />
{mail.cc && (
+29
View File
@@ -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 (
<Badge
variant="secondary"
title={`Weitergeleitet über ${address}`}
className={cn(
"max-w-full min-w-0 gap-1 font-normal hover:bg-secondary",
compact ? "px-1.5 py-0 text-[10px]" : "text-xs",
className,
)}
>
<Forward className={cn("shrink-0", compact ? "h-3 w-3" : "h-3.5 w-3.5")} aria-hidden="true" />
<span className="shrink-0">{label}</span>
<span className={cn("min-w-0", compact ? "truncate" : "break-all")}>{address}</span>
</Badge>
);
}
+12 -1
View File
@@ -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" })
: "-"}
</TableCell>
<TableCell className="hidden max-w-56 truncate text-sm md:table-cell">{hit.from || "-"}</TableCell>
<TableCell className="hidden max-w-56 text-sm md:table-cell">
<div className="truncate">{hit.from || "-"}</div>
{hit.forwarded_via && (
<ForwardedViaBadge address={hit.forwarded_via} compact className="mt-0.5" />
)}
</TableCell>
<TableCell className="max-w-[60vw] font-medium sm:max-w-none">
<div className="flex items-center gap-2">
<span className="truncate">{hit.subject || "(kein Betreff)"}</span>
@@ -145,6 +151,11 @@ function SearchResultsTableInner({
{hit.from}
</div>
)}
{hit.forwarded_via && (
<div className="mt-0.5 flex min-w-0 md:hidden">
<ForwardedViaBadge address={hit.forwarded_via} compact />
</div>
)}
<SnippetLine hit={hit} />
</TableCell>
<TableCell className="hidden max-w-48 text-sm text-muted-foreground lg:table-cell">
+2
View File
@@ -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" },
];
+3
View File
@@ -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;