diff --git a/archive/docs/QA-03-PRUEFPROTOKOLL.md b/archive/docs/QA-03-PRUEFPROTOKOLL.md index d4283f50..cb511ada 100644 --- a/archive/docs/QA-03-PRUEFPROTOKOLL.md +++ b/archive/docs/QA-03-PRUEFPROTOKOLL.md @@ -88,3 +88,128 @@ und nachgewiesen: eDiscovery-Export gegen den offiziellen Referenzdatensatz mit Testrollen (Erfolgsfall und erneut scheiternder Alleingang); Protokoll mit Zielwert/Messwert/ Bewertung abgelegt, offene Restrisiken schriftlich benannt. Freigabe der Compliance-Funktionen aus Sicht dieses Gates erteilt — Gesamtabnahme bleibt QA-05 vorbehalten. + +## Nachtrag CMP-08 (Stand 2026-10-16): Ende-zu-Ende-Nachweis des Adapters gegen echte DMS-/Mail-Testinstanzen + +Schließt den oben in Abschnitt "Restrisiken" benannten Punkt **"eDiscovery-Adapter real nur +fiktiv"**. Neue Produktivkomponente `archive/internal/ediscoveryhttp` (analog +`kekclient`/`rbacclient`): `DMSAdapter` und `MailAdapter` implementieren +`ediscovery.ModuleAdapter` über echtes HTTP gegen die realen REST-Endpunkte von DMS +(`dms/internal/searchapi` `GET /search`, `dms/internal/documentapi` `GET +/documents/{id}/content`) bzw. Mail (`mail/internal/mailapi` `GET /api/v1/mail/messages`, +`GET /api/v1/mail/messages/{id}`, mit Modul-Token `aud=mail`). + +### Echte Testinstanzen (Host 192.168.1.204) + +- **DMS**: echter `dms/internal/searchwiring` + neu ergänzter `dms/internal/documentapi` auf + einem frisch migrierten Schema der bestehenden Dev-Datenbank `dms_dev` + (`NEXARCH_DMS_APP_TENANT_DSN` aus `/etc/nexarch-dev/dms-dev.env`), echtes Manticore + (`TEST_MANTICORE_ADDR`), 4 synthetische PDF-Belege über den echten Upload-/Worker-Pfad + (`dms/internal/upload`, `dms/internal/workerruntime`, `dms/internal/testcorpus`) hochgeladen + und über die echte Volltextsuche auffindbar gemacht — keine Fixture-Doubles. +- **Mail**: echter `mail/internal/mailapi.Server`, mit echtem `mail/internal/search.Client` + (Manticore-Tabelle `mail_documents`, eigener Mandant `cmp08-ediscovery-mail`) und echtem + `mail/internal/storage.Service`; 4 synthetische Nachrichten über `search.Client.Index` + eingetragen. Mail hat noch keinen eigenen `cmd/app`-Produktivserver (siehe + `mail/internal/mailapi`-Paketkommentar) — Modul-Token-Verifikation läuft über die ECHTE + Verifikationslogik `mail/internal/moduleauth.Verifier`, mit einem lokal erzeugten + Test-Schlüsselpaar statt eines laufenden Core-Kontodienstes (`NewTestVerifierAndIssuer`, + dieselbe Konvention wie `mail/internal/mailapi/e2e_test.go`) — siehe Restrisiko unten. +- Beide Instanzen liefen als Wegwerf-Prüfwerkzeuge (`dms/cmd/cmp08-ediscovery-fixture`, + `mail/cmd/cmp08-ediscovery-fixture`), nach dem Testlauf wieder entfernt — nicht Teil dieses + Commits, nur die Produktivkomponente (`ediscoveryhttp`) und der Nachweistest bleiben. +- Alle Testdaten synthetisch (Suchbegriff `CmpAchtEdiscoveryFund`, Absendername + `pruefperson@cmp08.example.test`), keine echten Kunden-/Mandantendaten. + +### Prüfung 1 (Akzeptanzkriterium 1): Export gegen echte, über RET-05 registrierte Instanzen + +Vor dem eigentlichen Such-/Exportlauf registriert der Test beide Instanzen real über das +bestehende RET-05-Interface (`archive/internal/moduleadapter.Register`) gegen die echte +Postgres-Testdatenbank (`TEST_TENANT_DSN`): `module_name=dms/object_type=dms_document` mit +Rückruf-Adresse der echten laufenden DMS-Instanz, `module_name=mail/object_type=mail_message` +mit Rückruf-Adresse der echten laufenden Mail-Instanz. + +| Prüfgröße | Zielwert | Messwert | Bewertung | +|---|---|---|---| +| Registrierungen in `module_registrations` nach Register() | 2 (DMS + Mail) | 2 | bestanden | +| Export erfolgt gegen die registrierten, laufenden Instanzen (nicht Referenzdatensatz) | ja | ja — `ediscoveryhttp.DMSAdapter`/`MailAdapter` rufen die echten Endpunkte auf | bestanden | + +### Prüfung 2 (Akzeptanzkriterium 2): Treffer aus beiden Modulen, prüfsummengesichert + +Test `TestEndToEnd_CMP08_RealDMSAndMailInstances` +(`archive/internal/ediscoveryhttp/e2e_test.go`): `ediscovery.Search()` über beide echten +Adapter, `ediscovery.Export()` baut das ZIP, der Test entpackt es erneut und vergleicht die +SHA-256-Prüfsumme jeder Datei gegen den `manifest.json`-Eintrag. + +| Prüfgröße | Zielwert | Messwert | Bewertung | +|---|---|---|---| +| Treffer mit `module_type=dms_document` im Suchergebnis | ≥ 1 | 4 | bestanden | +| Treffer mit `module_type=mail_message` im Suchergebnis | ≥ 1 | 4 | bestanden | +| Prüfsumme nach Entpacken stimmt für jede Datei (beide Modultypen) | 100 % Übereinstimmung | 100 % (8/8 Dateien) | bestanden | + +### Prüfung 3 (Pflichtprüfung 2): Export-Prüfsummen bei wiederholtem Testlauf identisch + +Zwei unabhängige `Search()+Export()`-Durchläufe INNERHALB desselben Testlaufs (frischer +HTTP-Roundtrip gegen die echten Instanzen, nicht wiederverwendete Treffer), Vergleich über +`modultyp+referenz` statt Listenposition (Reihenfolge der Suchtreffer ist zwischen Läufen nicht +garantiert stabil). Zusätzlich der komplette Test zweimal als VOLLSTÄNDIG unabhängiger +`go test`-Prozessaufruf gegen dieselben laufenden Instanzen wiederholt. + +| Prüfgröße | Zielwert | Messwert | Bewertung | +|---|---|---|---| +| Prüfsummen Lauf 1 vs. Lauf 2 (innerhalb des Tests, je Treffer) | identisch | identisch (8/8) | bestanden | +| Testergebnis über 2 unabhängige Prozessaufrufe hinweg | PASS, PASS | PASS, PASS | bestanden | + +### Build-/Testergebnis (192.168.1.204) + +``` +go build ./... -> clean +go vet ./... -> clean +go test ./internal/ediscoveryhttp/... -v -> PASS (2x unabhaengiger Prozessaufruf) +go test ./... -p 1 -> alle Archive-Pakete bestanden (2x wiederholt) +``` + +Ein einzelner, nicht reproduzierbarer Fehlschlag in `archive/internal/reconcile` +(`TestListDBStorageKeys_ReadsRealFileRevisions`, "erwartet 1 Eintrag, habe 9") trat einmalig +während eines `go test ./... -p 1`-Laufs auf. Isoliert ausgeführt sowie in je zwei weiteren +vollständigen Suite-Läufen (mit UND ohne die CMP-08-Änderungen) bestand dieser Test +durchgehend — kein durch CMP-08 verursachter Regressionsbefund, sondern dieselbe Art +Testisolations-Lücke gegen die geteilte `TEST_TENANT_DSN`-Datenbank, wie sie für das DMS-Modul +bereits dokumentiert ist. Nicht in dieser Kachel behoben (unverändertes, fremdes Paket). + +### Restrisiken (neu, durch CMP-08 gefunden) + +- **Mail hat noch keinen RET-05-Rückruf-EMPFÄNGER**: DMS registriert sich bereits produktiv + selbst gegen RET-05 inkl. echtem Rückruf-Empfänger (`dms/cmd/app` `setupRetention`, + DOC-16, `POST /retention/destroy-callback`). Für Mail existiert dieses Gegenstück noch nicht + (nur der RET-13-Sperrabfrage-Client `mail/internal/retentionclient`) — die in diesem Test + registrierte Rückruf-Adresse der Mail-Instanz ist real erreichbar, aber die Instanz + verarbeitet eingehende Vernichtungs-Rückrufe fachlich noch nicht. Empfehlung: eigenes + Folgeticket, Mail-seitige Rückruf-Verarbeitung analog DOC-16 ergänzen, sobald Mail einen + eigenen `cmd/app` bekommt. +- **Mail-Modul-Token stammt aus einem Test-Schlüsselpaar, nicht vom echten Core-Kontodienst**: + weil Mail noch keinen produktiven Server-Prozess hat, an den ein echter Core-JWKS-Abruf + gebunden werden könnte (anders als bei DMS, dessen `cmd/app` dafür bereits verdrahtet ist). + Die Verifikationslogik selbst (`moduleauth.Verifier`) ist unverändert die Produktivfunktion. + Ein Nachweis mit einem vom echten Core ausgestellten Token (wie in + `mail/internal/mailapi/e2e_test.go` `TestEndToEnd_RealCoreToken`) ist erst möglich, sobald ein + Core-Kontodienst auf demselben Testhost erreichbar ist — außerhalb des Zuschnitts dieser + Kachel (keine neue eDiscovery-Funktionalität, kein Core-Deployment). +- **RET-05-Tenant-Verifikation (Core TEN-01) nicht Teil dieses Nachweises**: die Registrierung + erfolgte über den direkten Aufruf von `moduleadapter.Register` (die reale + Persistenzfunktion), nicht über den HTTP-Endpunkt `archive/cmd/moduleadapter-api` mit + `tenantregistryclient.VerifyTenant` gegen einen echten Core-TEN-01-Dienst — letzterer war auf + diesem Testhost nicht aufgesetzt (separates Infrastrukturthema, keine eDiscovery-Logik). Die + eigentliche RET-05-Speicherlogik (Register/ListRegistrations, Idempotenz) ist bereits durch + RET-05/RET-09 real geprüft und wird hier nicht erneut bewiesen. +- golangci-lint weiterhin nicht auf Host 204 installiert (siehe QA-03 oben, unverändert). + +### Gesamtergebnis CMP-08 + +**Bestanden.** Alle drei Akzeptanzkriterien und beide Pflichtprüfungen real gegen echte, +laufende DMS- und Mail-Testinstanzen (keine Referenzdatensatz-Fixtures) nachgewiesen: +RET-05-Registrierung beider Instanzen, Suchtreffer aus beiden Modulen im Export, +prüfsummengesicherte Übereinstimmung nach Entpacken, Reproduzierbarkeit über zwei +In-Test-Läufe UND zwei unabhängige Prozessaufrufe hinweg. Neue, bisher offene Restrisiken +schriftlich benannt (Mail-seitiger Rückruf-Empfänger fehlt, Core-Token-Ausstellung für Mail +noch nicht end-to-end, RET-05-Tenant-Verifikation nicht mitgeprüft). diff --git a/archive/internal/ediscoveryhttp/e2e_test.go b/archive/internal/ediscoveryhttp/e2e_test.go new file mode 100644 index 00000000..613e218e --- /dev/null +++ b/archive/internal/ediscoveryhttp/e2e_test.go @@ -0,0 +1,190 @@ +// CMP-08 Ende-zu-Ende-Nachweis: ediscovery.Search/Export real gegen +// laufende, echte DMS-/Mail-Testinstanzen (nicht gegen Referenzdatensatz- +// Fixtures wie QA-03), ueber den ediscoveryhttp-Adapter, Instanzen vorher +// real ueber RET-05 (moduleadapter.Register) registriert. Laeuft nur, wenn +// die CMP08_*-Umgebungsvariablen gesetzt sind (siehe +// scripts/cmp08-ediscovery-e2e.sh) - gleiche Konvention wie +// mail/internal/mailapi/e2e_test.go. +package ediscoveryhttp + +import ( + "archive/zip" + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "os" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/archive/internal/ediscovery" + "gitea.perlbach24.de/scripte/nexarch/archive/internal/moduleadapter" +) + +type cmp08Env struct { + dmsBaseURL, dmsCaller, dmsTenant string + mailBaseURL, mailToken, mailTenant string + term, tenantDSN string +} + +func loadCMP08Env(t *testing.T) cmp08Env { + t.Helper() + e := cmp08Env{ + dmsBaseURL: os.Getenv("CMP08_DMS_BASE_URL"), + dmsCaller: os.Getenv("CMP08_DMS_CALLER_ID"), + dmsTenant: os.Getenv("CMP08_DMS_TENANT_SLUG"), + mailBaseURL: os.Getenv("CMP08_MAIL_BASE_URL"), + mailToken: os.Getenv("CMP08_MAIL_TOKEN"), + mailTenant: os.Getenv("CMP08_MAIL_TENANT_SLUG"), + term: os.Getenv("CMP08_SEARCH_TERM"), + tenantDSN: os.Getenv("TEST_TENANT_DSN"), + } + if e.dmsBaseURL == "" || e.mailBaseURL == "" || e.tenantDSN == "" || e.term == "" { + t.Skip("CMP08_*/TEST_TENANT_DSN nicht gesetzt, Ende-zu-Ende-Nachweis uebersprungen") + } + return e +} + +func requireRegistrationPool(t *testing.T, dsn string) *pgxpool.Pool { + t.Helper() + ctx := context.Background() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatalf("pool: %v", err) + } + t.Cleanup(pool.Close) + if _, err := pool.Exec(ctx, ` + CREATE EXTENSION IF NOT EXISTS pgcrypto; + CREATE TABLE IF NOT EXISTS module_registrations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_slug TEXT NOT NULL, module_name TEXT NOT NULL, + object_type TEXT NOT NULL, retention_class TEXT NOT NULL, callback_url TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (module_name, object_type) + ); + `); err != nil { + t.Fatalf("schema: %v", err) + } + t.Cleanup(func() { _, _ = pool.Exec(context.Background(), `TRUNCATE module_registrations`) }) + return pool +} + +// manifestChecksums liest ein ediscovery.Export-ZIP ein, prueft die +// manifest.json-Pruefsumme jeder Datei gegen den tatsaechlich entpackten +// Inhalt (Akzeptanzkriterium 2) und liefert die Pruefsummen, indiziert +// ueber modultyp+referenz statt Listenposition (Suchtreffer-Reihenfolge +// ist zwischen zwei Laeufen nicht garantiert stabil). +func manifestChecksums(t *testing.T, zipBytes []byte) map[string]string { + t.Helper() + zr, err := zip.NewReader(bytes.NewReader(zipBytes), int64(len(zipBytes))) + if err != nil { + t.Fatalf("zip lesen: %v", err) + } + var manifest []ediscovery.ManifestEntry + files := map[string][]byte{} + for _, f := range zr.File { + rc, err := f.Open() + if err != nil { + t.Fatalf("zip-eintrag %q oeffnen: %v", f.Name, err) + } + content, err := io.ReadAll(rc) + _ = rc.Close() + if err != nil { + t.Fatalf("zip-eintrag %q lesen: %v", f.Name, err) + } + if f.Name == "manifest.json" { + if err := json.Unmarshal(content, &manifest); err != nil { + t.Fatalf("manifest dekodieren: %v", err) + } + continue + } + files[f.Name] = content + } + out := map[string]string{} + for _, m := range manifest { + path := m.ModuleType + "/" + m.Filename + content, ok := files[path] + if !ok { + t.Fatalf("manifest verweist auf fehlende datei %q", path) + } + sum := sha256.Sum256(content) + if hex.EncodeToString(sum[:]) != m.ChecksumSHA256 { + t.Fatalf("pruefsumme stimmt nicht fuer %q", path) + } + out[m.ModuleType+"/"+m.Reference] = m.ChecksumSHA256 + } + return out +} + +func runSearchAndExport(t *testing.T, ctx context.Context, adapters []ediscovery.ModuleAdapter, byType map[string]ediscovery.ModuleAdapter, term string) map[string]string { + t.Helper() + hits, errs := ediscovery.Search(ctx, nil, "cmp08-ediscovery", "cmp08-pruefperson", term, adapters) + if len(errs) != 0 { + t.Fatalf("suche gegen reale instanzen fehlgeschlagen: %v", errs) + } + seen := map[string]bool{} + for _, h := range hits { + seen[h.ModuleType] = true + } + if !seen[ModuleTypeDMSDocument] { + t.Fatalf("keine treffer aus dms_document in: %+v", hits) + } + if !seen[ModuleTypeMailMessage] { + t.Fatalf("keine treffer aus mail_message in: %+v", hits) + } + zipBytes, err := ediscovery.Export(ctx, nil, "cmp08-ediscovery", "cmp08-pruefperson", byType, hits) + if err != nil { + t.Fatalf("export fehlgeschlagen: %v", err) + } + return manifestChecksums(t, zipBytes) +} + +// TestEndToEnd_CMP08_RealDMSAndMailInstances deckt alle drei Akzeptanz- +// kriterien ab: Export gegen echte, ueber RET-05 registrierte DMS-/ +// Mail-Testinstanzen (1), Treffer aus beiden Modulen pruefsummengesichert +// im Export (2), Pruefsummen bei wiederholtem Testlauf identisch (3). +func TestEndToEnd_CMP08_RealDMSAndMailInstances(t *testing.T) { + e := loadCMP08Env(t) + ctx := context.Background() + + pool := requireRegistrationPool(t, e.tenantDSN) + if _, err := moduleadapter.Register(ctx, pool, e.dmsTenant, "dms", ModuleTypeDMSDocument, "klasse-ediscovery-test", e.dmsBaseURL+"/retention/destroy-callback"); err != nil { + t.Fatalf("dms via ret-05 registrieren: %v", err) + } + if _, err := moduleadapter.Register(ctx, pool, e.mailTenant, "mail", ModuleTypeMailMessage, "klasse-ediscovery-test", e.mailBaseURL+"/api/v1/mail/messages"); err != nil { + t.Fatalf("mail via ret-05 registrieren: %v", err) + } + regs, err := moduleadapter.ListRegistrations(ctx, pool) + if err != nil { + t.Fatalf("registrierungen lesen: %v", err) + } + if len(regs) != 2 { + t.Fatalf("erwartete 2 ret-05-registrierungen (dms+mail), habe %d: %+v", len(regs), regs) + } + + dmsAdapter := NewDMSAdapter(e.dmsBaseURL, e.dmsCaller, nil) + mailAdapter := NewMailAdapter(e.mailBaseURL, e.mailToken, nil) + adapters := []ediscovery.ModuleAdapter{dmsAdapter, mailAdapter} + byType := map[string]ediscovery.ModuleAdapter{ModuleTypeDMSDocument: dmsAdapter, ModuleTypeMailMessage: mailAdapter} + + run1 := runSearchAndExport(t, ctx, adapters, byType, e.term) + run2 := runSearchAndExport(t, ctx, adapters, byType, e.term) + + if len(run1) == 0 { + t.Fatal("erster lauf lieferte keine pruefsummen") + } + if len(run1) != len(run2) { + t.Fatalf("unterschiedliche trefferzahl zwischen den laeufen: %d vs %d", len(run1), len(run2)) + } + for key, sum1 := range run1 { + sum2, ok := run2[key] + if !ok { + t.Fatalf("treffer %q im zweiten lauf nicht mehr vorhanden", key) + } + if sum1 != sum2 { + t.Fatalf("pruefsumme fuer %q unterscheidet sich zwischen den laeufen: %s vs %s", key, sum1, sum2) + } + } +} diff --git a/archive/internal/ediscoveryhttp/ediscoveryhttp.go b/archive/internal/ediscoveryhttp/ediscoveryhttp.go new file mode 100644 index 00000000..1a1b32cb --- /dev/null +++ b/archive/internal/ediscoveryhttp/ediscoveryhttp.go @@ -0,0 +1,165 @@ +// Package ediscoveryhttp bindet archive/internal/ediscovery.ModuleAdapter +// ueber HTTP an die echten DMS-/Mail-REST-Endpunkte (gleiches Muster wie +// kekclient/rbacclient) - der in CMP-03/QA-03 offen benannte Restrisiko-Punkt +// ("ediscovery.ModuleAdapter wird in Produktion ueber HTTP gebunden, aber nie +// real getestet"), geschlossen durch CMP-08. +package ediscoveryhttp + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + + "gitea.perlbach24.de/scripte/nexarch/archive/internal/ediscovery" + "gitea.perlbach24.de/scripte/nexarch/archive/internal/httpclient" +) + +const ModuleTypeDMSDocument = "dms_document" +const ModuleTypeMailMessage = "mail_message" + +// DMSAdapter ruft DMS GET /search und GET /documents/{id}/content auf +// (dms/internal/searchapi bzw. dms/internal/documentapi). +type DMSAdapter struct { + baseURL string + caller string + http *http.Client +} + +func NewDMSAdapter(baseURL, caller string, client *http.Client) *DMSAdapter { + if client == nil { + client = httpclient.Default() + } + return &DMSAdapter{baseURL: baseURL, caller: caller, http: client} +} + +func (a *DMSAdapter) ModuleType() string { return ModuleTypeDMSDocument } + +type dmsHit struct { + DocumentID string `json:"document_id"` + Title string `json:"title"` +} + +type dmsSearchResponse struct { + Hits []dmsHit `json:"hits"` +} + +func (a *DMSAdapter) Search(ctx context.Context, criteria string) ([]ediscovery.ObjectRef, error) { + q := url.Values{"q": {criteria}, "caller": {a.caller}, "limit": {"100"}} + req, err := http.NewRequestWithContext(ctx, http.MethodGet, a.baseURL+"/search?"+q.Encode(), nil) + if err != nil { + return nil, fmt.Errorf("ediscoveryhttp(dms): anfrage bauen: %w", err) + } + resp, err := a.http.Do(req) + if err != nil { + return nil, fmt.Errorf("ediscoveryhttp(dms): suche fehlgeschlagen: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("ediscoveryhttp(dms): suche status %d", resp.StatusCode) + } + var body dmsSearchResponse + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return nil, fmt.Errorf("ediscoveryhttp(dms): antwort dekodieren: %w", err) + } + refs := make([]ediscovery.ObjectRef, 0, len(body.Hits)) + for _, h := range body.Hits { + refs = append(refs, ediscovery.ObjectRef{ModuleType: ModuleTypeDMSDocument, Reference: h.DocumentID, Filename: h.Title}) + } + return refs, nil +} + +func (a *DMSAdapter) FetchContent(ctx context.Context, reference string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, a.baseURL+"/documents/"+url.PathEscape(reference)+"/content", nil) + if err != nil { + return nil, fmt.Errorf("ediscoveryhttp(dms): anfrage bauen: %w", err) + } + resp, err := a.http.Do(req) + if err != nil { + return nil, fmt.Errorf("ediscoveryhttp(dms): inhalt abrufen: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("ediscoveryhttp(dms): inhalt status %d", resp.StatusCode) + } + return io.ReadAll(io.LimitReader(resp.Body, maxContentBytes)) +} + +// MailAdapter ruft die Mail-API v1 auf (mail/internal/mailapi), mit einem +// vom aufrufenden Kontext ausgestellten Modul-Token (aud=mail). +type MailAdapter struct { + baseURL string + token string + http *http.Client +} + +func NewMailAdapter(baseURL, moduleToken string, client *http.Client) *MailAdapter { + if client == nil { + client = httpclient.Default() + } + return &MailAdapter{baseURL: baseURL, token: moduleToken, http: client} +} + +func (a *MailAdapter) ModuleType() string { return ModuleTypeMailMessage } + +type mailMessageListItem struct { + MessageID string `json:"messageId"` + Subject string `json:"subject"` +} + +type mailListResponse struct { + Messages []mailMessageListItem `json:"messages"` +} + +func (a *MailAdapter) authed(ctx context.Context, path string) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, a.baseURL+path, nil) + if err != nil { + return nil, fmt.Errorf("ediscoveryhttp(mail): anfrage bauen: %w", err) + } + req.Header.Set("Authorization", "Bearer "+a.token) + resp, err := a.http.Do(req) + if err != nil { + return nil, fmt.Errorf("ediscoveryhttp(mail): anfrage senden: %w", err) + } + return resp, nil +} + +func (a *MailAdapter) Search(ctx context.Context, criteria string) ([]ediscovery.ObjectRef, error) { + resp, err := a.authed(ctx, "/api/v1/mail/messages?q="+url.QueryEscape(criteria)) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("ediscoveryhttp(mail): suche status %d", resp.StatusCode) + } + var body mailListResponse + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return nil, fmt.Errorf("ediscoveryhttp(mail): antwort dekodieren: %w", err) + } + refs := make([]ediscovery.ObjectRef, 0, len(body.Messages)) + for _, m := range body.Messages { + refs = append(refs, ediscovery.ObjectRef{ModuleType: ModuleTypeMailMessage, Reference: m.MessageID, Filename: m.MessageID + ".txt"}) + } + return refs, nil +} + +// FetchContent liefert die rohen Antwort-Bytes von GET +// /messages/{id} (Betreff+Text) als Exportinhalt - Mail-Nachrichten haben +// kein "Original-Binaerformat" wie DMS-Dokumente, der strukturierte +// JSON-Datensatz IST der Originalinhalt dieses Treffers. +func (a *MailAdapter) FetchContent(ctx context.Context, reference string) ([]byte, error) { + resp, err := a.authed(ctx, "/api/v1/mail/messages/"+url.PathEscape(reference)) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("ediscoveryhttp(mail): inhalt status %d", resp.StatusCode) + } + return io.ReadAll(io.LimitReader(resp.Body, maxContentBytes)) +} + +const maxContentBytes = 50 << 20