package search import ( "bytes" "context" _ "embed" "encoding/json" "fmt" "io" "net/http" "strings" "time" ) // 0001_mail_documents.sql: statisches, versioniertes Schema // (Akzeptanzkriterium 1). Feldnamen hier UND in fields.go müssen // deckungsgleich bleiben — die Konstanten in fields.go sind die einzige // Stelle, aus der Go-Code Feldnamen für Schreib-/Lesezugriffe bezieht. // Manticores SQL-Parser unterstützt keine "--"-Kommentare, daher bleibt // die eingebettete Datei selbst kommentarfrei. // //go:embed migrations/0001_mail_documents.sql var schemaMigration string // Client spricht ausschließlich über die strukturierte Manticore-HTTP- // JSON-API (kein String-Zusammenbau von SQL-Klauseln, siehe fields.go). // Die SQL-Schnittstelle wird nur für EnsureSchema verwendet, und dort // ausschließlich mit dem statischen, eingebetteten Migrationstext — // niemals mit zur Laufzeit zusammengesetzten Werten. type Client struct { baseURL string http *http.Client } func NewClient(baseURL string) *Client { return &Client{ baseURL: strings.TrimRight(baseURL, "/"), http: &http.Client{Timeout: 10 * time.Second}, } } // EnsureSchema legt den Index gemäß dem versionierten, statischen // Migrationstext an (Akzeptanzkriterium 1). Idempotent (CREATE TABLE // IF NOT EXISTS im Migrationstext). func (c *Client) EnsureSchema(ctx context.Context) error { form := "query=" + schemaMigration req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/sql?mode=raw", strings.NewReader(form)) if err != nil { return fmt.Errorf("search: schema-anfrage bauen: %w", err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := c.http.Do(req) if err != nil { return fmt.Errorf("search: schema anlegen: %w", err) } defer func() { _ = resp.Body.Close() }() body, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusOK { return fmt.Errorf("search: schema anlegen, status %d: %s", resp.StatusCode, string(body)) } return nil } // Document ist ein Mail-Suchdokument. Feldnamen im JSON-Tag entsprechen // exakt den Konstanten in fields.go. type Document struct { ID uint64 `json:"-"` TenantSlug string `json:"tenant_slug"` MessageID string `json:"message_id"` Subject string `json:"subject"` Body string `json:"body"` AttachmentText string `json:"attachment_text"` SentAtUnixEpoch int64 `json:"sent_at"` } // Index legt/ersetzt ein Suchdokument (Akzeptanzkriterium 2: Schreibzugriff // ausschließlich über statische, vordefinierte Feldnamen aus dem // Document-Struct — kein dynamischer Feldname möglich). func (c *Client) Index(ctx context.Context, doc Document) error { payload := map[string]any{ "index": IndexName, "id": doc.ID, "doc": doc, } body, err := json.Marshal(payload) if err != nil { return fmt.Errorf("search: dokument serialisieren: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/replace", bytes.NewReader(body)) if err != nil { return fmt.Errorf("search: index-anfrage bauen: %w", err) } req.Header.Set("Content-Type", "application/json") resp, err := c.http.Do(req) if err != nil { return fmt.Errorf("search: dokument indexieren: %w", err) } defer func() { _ = resp.Body.Close() }() respBody, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusOK { return fmt.Errorf("search: dokument indexieren, status %d: %s", resp.StatusCode, string(respBody)) } return nil } // Delete entfernt ein Suchdokument anhand seiner ID (SRC-02 // Akzeptanzkriterium 2: Löschungen werden im Index nachgezogen). Löschen // eines nicht (mehr) vorhandenen Dokuments ist kein Fehler (idempotent). func (c *Client) Delete(ctx context.Context, id uint64) error { payload := map[string]any{ "index": IndexName, "id": id, } body, err := json.Marshal(payload) if err != nil { return fmt.Errorf("search: lösch-anfrage serialisieren: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/delete", bytes.NewReader(body)) if err != nil { return fmt.Errorf("search: lösch-anfrage bauen: %w", err) } req.Header.Set("Content-Type", "application/json") resp, err := c.http.Do(req) if err != nil { return fmt.Errorf("search: dokument löschen: %w", err) } defer func() { _ = resp.Body.Close() }() respBody, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusOK { return fmt.Errorf("search: dokument löschen, status %d: %s", resp.StatusCode, string(respBody)) } return nil } // Result ist ein Suchtreffer. type Result struct { MessageID string Subject string Score int64 SentAtUnixEpoch int64 } // fieldWeights gewichtet Betreff höher als Text, Anhangstext am // niedrigsten (SRC-03 Akzeptanzkriterium 1: Ranking berücksichtigt u.a. // Anhangstreffer) — statische Konstanten, keine dynamischen Feldnamen. var fieldWeights = map[string]any{ FieldSubject: 10, FieldBody: 3, FieldAttachmentText: 1, } // Search sucht queryText innerhalb der Volltextfelder, strikt begrenzt auf // den Mandanten tenantSlug (Akzeptanzkriterium 2: mandantengetrennt // abfragbar) — der Tenant-Filter läuft über ein strukturiertes "equals"- // Feld der JSON-API, niemals über eine interpolierte WHERE-Klausel. // // queryText nutzt Manticores erweiterte Abfragesyntax über den // query_string-Klausel-Typ (Akzeptanzkriterium 3: Phrasensuche mit // Anführungszeichen, Ausschluss mit vorangestelltem "-") — der Wert landet // als reiner JSON-String-Wert, es gibt dabei keinerlei dynamischen // Feld-/Tabellennamen, der beeinflusst werden könnte. Ergebnisse kommen // von Manticore bereits nach Relevanz (BM25, gewichtet über fieldWeights) // absteigend sortiert zurück (Akzeptanzkriterium 1). func (c *Client) Search(ctx context.Context, tenantSlug, queryText string) ([]Result, 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}}, {"query_string": queryText}, }, }, }, "options": map[string]any{ "field_weights": fieldWeights, }, } body, err := json.Marshal(payload) if err != nil { return nil, fmt.Errorf("search: suchanfrage serialisieren: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/search", bytes.NewReader(body)) if err != nil { return nil, fmt.Errorf("search: suchanfrage bauen: %w", err) } req.Header.Set("Content-Type", "application/json") resp, err := c.http.Do(req) if err != nil { return nil, fmt.Errorf("search: suche ausführen: %w", err) } defer func() { _ = resp.Body.Close() }() respBody, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("search: antwort lesen: %w", err) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("search: suche, status %d: %s", resp.StatusCode, string(respBody)) } var parsed searchResponse if err := json.Unmarshal(respBody, &parsed); err != nil { return nil, fmt.Errorf("search: antwort parsen: %w", err) } results := make([]Result, 0, len(parsed.Hits.Hits)) for _, hit := range parsed.Hits.Hits { results = append(results, Result{ MessageID: hit.Source.MessageID, Subject: hit.Source.Subject, Score: hit.Score, SentAtUnixEpoch: hit.Source.SentAtUnixEpoch, }) } return results, nil } type searchResponse struct { Hits struct { Hits []struct { Score int64 `json:"_score"` Source struct { MessageID string `json:"message_id"` Subject string `json:"subject"` SentAtUnixEpoch int64 `json:"sent_at"` } `json:"_source"` } `json:"hits"` } `json:"hits"` }