feat(mail): INT-01 REST-API v1 für Mail-Zugriff & OpenAPI-Beschreibung

Neues Paket mail/internal/mailapi: drei v1-Endpunkte (Mail-Liste,
Mail-Detail, Anhang-Download). Core API-01 (REST-Grundgerüst) und
API-04 (OpenAPI-Beschreibung) haben im aktuellen Repository-Stand
keinen abrufbaren Router — RegisterRoutes registriert die Endpunkte
deshalb auf einem vom Aufrufer bereitgestellten *http.ServeMux mit dem
dokumentierten Pfadschema /api/v1/mail/..., Core kann sich später dort
einhängen, im Prüfprotokoll begründet (gleiche Situation wie
ARC-06/Core TEN-01).

tenant-Query-Parameter ist auf allen drei Endpunkten Pflicht (fehlender
Kontext -> 400), keine eigene Login-/Session-Logik (IAM bleibt
Core-Board-Sache). Anhang-Download nutzt storage.ObjectKey gegen den
physisch getrennten Bucket des Mandanten (ARC-06) — ein Anhang mit
identischer messageID in einem fremden Mandantenkontext ist strukturell
nicht erreichbar. Neue Methode search.Client.GetByMessageID liefert das
vollständige Suchdokument für Mail-Detail.

openapi.yaml: vollständiger OpenAPI-3-Beitrag für alle drei Endpunkte
inklusive Fehlerantworten. Als neue, gepinnte Abhängigkeit
github.com/getkin/kin-openapi v0.135.0 (bewusst nicht @latest — hätte
das Modul von go 1.24 auf go 1.25 gezwungen) für einen echten
Standard-Validierungslauf gegen das Dokument sowie einen
OpenAPI-Router, der jede implementierte Route real gegen das Dokument
auflöst statt nur Pfad-Strings zu vergleichen.

Alle vier Pflichtprüfungen mit echten Nachweisen: Zugriff ohne
Tenant-Kontext auf allen drei Endpunkten abgelehnt, Vertragstests inkl.
physischer Bucket-Trennung beim Anhang-Download, automatisiertes
Code-Review bestätigt Abwesenheit IAM-naher Bezeichner,
OpenAPI-Dokument validiert fehlerfrei gegen kin-openapi.

go build/go vet/golangci-lint clean, go mod verify clean, gesamtes
Mail-Modul regressionsfrei getestet.
This commit is contained in:
sysops
2026-09-01 17:49:40 +02:00
parent 2d32157de4
commit c9b062062b
8 changed files with 940 additions and 5 deletions
+46
View File
@@ -334,6 +334,52 @@ func (c *Client) Search(ctx context.Context, tenantSlug, queryText string) ([]Re
return results, nil
}
// GetByMessageID liefert das vollständige Suchdokument EINER Nachricht
// (INT-01 Akzeptanzkriterium 1: Mail-Detail braucht mehr Felder als
// Search()s Result — insbesondere Body). ok=false, wenn keine
// Nachricht mit dieser message_id für tenantSlug existiert
// (Akzeptanzkriterium 2: strikt mandantengebunden — eine fremde
// message_id liefert hier KEIN Dokument, weil tenant_slug Teil der
// Pflichtbedingung ist, nicht nur ein optionaler Filter).
func (c *Client) GetByMessageID(ctx context.Context, tenantSlug, messageID string) (Document, bool, error) {
payload := map[string]any{
"index": IndexName,
"query": map[string]any{
"bool": map[string]any{
"must": []map[string]any{
{"equals": map[string]any{FieldTenantSlug: tenantSlug}},
{"equals": map[string]any{FieldMessageID: messageID}},
},
},
},
"limit": 1,
}
body, err := json.Marshal(payload)
if err != nil {
return Document{}, false, fmt.Errorf("search: detailanfrage serialisieren: %w", err)
}
respBody, err := c.doSearchWithSwapRetry(ctx, body)
if err != nil {
return Document{}, false, err
}
var parsed documentSearchResponse
if err := json.Unmarshal(respBody, &parsed); err != nil {
return Document{}, false, fmt.Errorf("search: antwort parsen: %w", err)
}
if len(parsed.Hits.Hits) == 0 {
return Document{}, false, nil
}
return parsed.Hits.Hits[0].Source, true, nil
}
type documentSearchResponse struct {
Hits struct {
Hits []struct {
Source Document `json:"_source"`
} `json:"hits"`
} `json:"hits"`
}
type searchResponse struct {
Hits struct {
Hits []struct {