// Package mailapi implementiert INT-01: die Mail-spezifischen // REST-Endpunkte (Mail-Liste, Mail-Detail, Anhang-Download) v1. // // Core API-01 (REST-API-Grundgerüst & Versionierung) und API-04 // (OpenAPI-Schnittstellenbeschreibung) sind laut core-kanban zwar auf // "Fertig", enthalten im aktuellen Repository-Stand aber noch keinen // abrufbaren Router/keine Middleware, an die sich dieses Paket technisch // anhängen könnte (siehe Abgrenzung im INT-01-Prüfprotokoll — gleiche // Situation wie ARC-06/Core TEN-01). RegisterRoutes registriert daher // die v1-Endpunkte auf einem vom Aufrufer bereitgestellten // *http.ServeMux mit dem dokumentierten Pfadschema // "/api/v1/mail/..." — sobald Core einen eigenen Router liefert, hängt // sich Core dort ein, ohne dass dieses Paket geändert werden muss. // // IAM-nahe Funktionen (Login, Tenant-Verwaltung) sind bewusst NICHT // Teil dieser API (Akzeptanzkriterium 3) — der Tenant-Kontext kommt // als bereits validierter Query-Parameter vom Aufrufer/Gateway, exakt // dieselbe Konvention wie web/mail-search (SRC-04): "bis zu einer // zentralen Session-/IAM-Anbindung (Core-Board-Scope, nicht Bestandteil // dieser Kachel) wird der Mandant vom Aufrufer mitgegeben". package mailapi import ( "context" "encoding/json" "errors" "net/http" "strconv" "strings" "gitea.perlbach24.de/scripte/nexarch/mail/internal/search" "gitea.perlbach24.de/scripte/nexarch/mail/internal/storage" ) // SearchClient ist die für diese API benötigte Teilmenge von // *search.Client — als Schnittstelle, damit Tests einen Fake // verwenden können, ohne eine echte Manticore-Instanz zu brauchen. type SearchClient interface { Search(ctx context.Context, tenantSlug, queryText string) ([]search.Result, error) GetByMessageID(ctx context.Context, tenantSlug, messageID string) (search.Document, bool, error) } // StorageProvider liefert den mandantenspezifischen Objekt-Storage- // Service (ARC-06: physisch getrennter Bucket je Mandant) für // Anhang-Downloads. Ein unbekannter tenantSlug liefert einen Fehler — // die Implementierung entscheidet, ob "unbekannt" bedeutet. type StorageProvider interface { ServiceFor(tenantSlug string) (*storage.Service, error) } // Server bündelt die Abhängigkeiten der Mail-API v1. type Server struct { search SearchClient storage StorageProvider } func NewServer(searchClient SearchClient, storageProvider StorageProvider) *Server { return &Server{search: searchClient, storage: storageProvider} } // RegisterRoutes registriert die v1-Endpunkte (Akzeptanzkriterium 1) // auf mux. Pfadschema exakt wie im OpenAPI-Beitrag (openapi.yaml, // Akzeptanzkriterium 4) dokumentiert. func (s *Server) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /api/v1/mail/messages", s.handleListMessages) mux.HandleFunc("GET /api/v1/mail/messages/{messageID}", s.handleGetMessage) mux.HandleFunc("GET /api/v1/mail/messages/{messageID}/attachments/{index}", s.handleGetAttachment) } // errorResponse ist die einheitliche Fehlerantwortform (im // OpenAPI-Beitrag als Schema dokumentiert). type errorResponse struct { Error string `json:"error"` } func writeError(w http.ResponseWriter, status int, message string) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(errorResponse{Error: message}) } // requireTenant liest den Pflicht-Query-Parameter "tenant" // (Akzeptanzkriterium 2/Pflichtprüfung 1: Zugriff ohne gültigen // Tenant-Kontext wird abgelehnt). Ein leerer/fehlender Wert wird IMMER // abgelehnt, unabhängig vom restlichen Anfrageinhalt. func requireTenant(w http.ResponseWriter, r *http.Request) (string, bool) { tenant := strings.TrimSpace(r.URL.Query().Get("tenant")) if tenant == "" { writeError(w, http.StatusBadRequest, "fehlender oder leerer tenant-kontext (query-parameter \"tenant\")") return "", false } return tenant, true } // messageListItem ist ein Eintrag der Mail-Liste. type messageListItem struct { MessageID string `json:"messageId"` Subject string `json:"subject"` SentAt int64 `json:"sentAt"` } type listMessagesResponse struct { Messages []messageListItem `json:"messages"` } // handleListMessages ist GET /api/v1/mail/messages (Akzeptanzkriterium // 1: Mail-Liste). Optionaler Query-Parameter "q" filtert per Volltext, // wie mail/internal/search es ohnehin unterstützt. func (s *Server) handleListMessages(w http.ResponseWriter, r *http.Request) { tenant, ok := requireTenant(w, r) if !ok { return } query := r.URL.Query().Get("q") results, err := s.search.Search(r.Context(), tenant, query) if err != nil { writeError(w, http.StatusBadGateway, "suche fehlgeschlagen") return } resp := listMessagesResponse{Messages: make([]messageListItem, 0, len(results))} for _, res := range results { resp.Messages = append(resp.Messages, messageListItem{ MessageID: res.MessageID, Subject: res.Subject, SentAt: res.SentAtUnixEpoch, }) } writeJSON(w, http.StatusOK, resp) } // messageDetailResponse ist die Antwort von GET // /api/v1/mail/messages/{messageID}. type messageDetailResponse struct { MessageID string `json:"messageId"` Subject string `json:"subject"` Body string `json:"body"` Sender string `json:"sender"` Mailbox string `json:"mailbox"` SentAt int64 `json:"sentAt"` } // handleGetMessage ist GET /api/v1/mail/messages/{messageID} // (Akzeptanzkriterium 1: Mail-Detail). Liefert 404, wenn die Nachricht // für DIESEN Mandanten nicht existiert — auch wenn sie für einen // ANDEREN Mandanten existiert (Akzeptanzkriterium 2: strikt // mandantengebunden, siehe search.Client.GetByMessageID). func (s *Server) handleGetMessage(w http.ResponseWriter, r *http.Request) { tenant, ok := requireTenant(w, r) if !ok { return } messageID := r.PathValue("messageID") doc, found, err := s.search.GetByMessageID(r.Context(), tenant, messageID) if err != nil { writeError(w, http.StatusBadGateway, "abruf fehlgeschlagen") return } if !found { writeError(w, http.StatusNotFound, "nachricht nicht gefunden") return } writeJSON(w, http.StatusOK, messageDetailResponse{ MessageID: doc.MessageID, Subject: doc.Subject, Body: doc.Body, Sender: doc.Sender, Mailbox: doc.Mailbox, SentAt: doc.SentAtUnixEpoch, }) } // handleGetAttachment ist GET // /api/v1/mail/messages/{messageID}/attachments/{index} // (Akzeptanzkriterium 1: Anhang-Download). {index} ist der von // mail/internal/mimeparse beim Import vergebene Anhang-Index innerhalb // der Nachricht (dieselbe Zählung wie storage.ObjectKey). // // Akzeptanzkriterium 2 (strikt mandantengebunden) ist hier STRUKTURELL // garantiert, nicht nur durch einen Vergleich: StorageProvider liefert // für tenant AUSSCHLIESSLICH den physisch getrennten Bucket dieses // Mandanten (ARC-06) — ein falscher/fremder tenant-Parameter kann // technisch keinen fremden Bucket referenzieren, unabhängig davon, ob // die angefragte messageID dort zufällig ebenfalls existiert. func (s *Server) handleGetAttachment(w http.ResponseWriter, r *http.Request) { tenant, ok := requireTenant(w, r) if !ok { return } messageID := r.PathValue("messageID") indexStr := r.PathValue("index") index, err := strconv.Atoi(indexStr) if err != nil || index < 0 { writeError(w, http.StatusBadRequest, "ungültiger anhang-index") return } // Zuerst bestätigen, dass die Nachricht für DIESEN Mandanten // überhaupt existiert — verhindert, dass eine geratene messageID // eines fremden Mandanten (dessen Bucket hier ohnehin nicht // referenzierbar wäre) einen irreführenden Fehlercode liefert. if _, found, err := s.search.GetByMessageID(r.Context(), tenant, messageID); err != nil { writeError(w, http.StatusBadGateway, "abruf fehlgeschlagen") return } else if !found { writeError(w, http.StatusNotFound, "nachricht nicht gefunden") return } svc, err := s.storage.ServiceFor(tenant) if err != nil { writeError(w, http.StatusBadRequest, "unbekannter mandant") return } content, err := svc.GetVerified(r.Context(), storage.ObjectKey(messageID, index)) if err != nil { if errors.Is(err, storage.ErrNotFound) { writeError(w, http.StatusNotFound, "anhang nicht gefunden") return } writeError(w, http.StatusBadGateway, "anhang-abruf fehlgeschlagen") return } w.Header().Set("Content-Type", "application/octet-stream") w.WriteHeader(http.StatusOK) _, _ = w.Write(content) } func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) }