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:
1 parent
b15bab355b
commit
0501118bcd
21 files changed
+1258
-37
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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, "<>"))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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" },
|
||||
];
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user