From d26a341fa8ae35272b6fbf5da5ed0d3b30ee6774 Mon Sep 17 00:00:00 2001 From: sysops Date: Tue, 1 Sep 2026 17:55:44 +0200 Subject: [PATCH] feat(mail): INT-05 Benachrichtigungs-Service "neue Mail" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neues Paket mail/internal/notifyclient: Mail-seitige Kopplung an Core CFG-02/CFG-05 (POST /notify, service-token-authentifiziert). Core CFG-02/CFG-05 stehen auf core-kanban zwar auf "Fertig", haben im aktuellen Repository-Stand aber keinen abrufbaren Endpunkt — dieselbe Situation wie ARC-06/Core TEN-01 und INT-01/Core API-01, im Prüfprotokoll begründet. Client richtet sich nach dem in CFG-05s eigener Beschreibung dokumentierten Vertrag. 204 wird bewusst nicht als Fehler behandelt (CFG-05 wrappt laut Beschreibung bereits notifyprefs.EnqueueIfAllowed — die Zustellentscheidung nach Benutzerpräferenz liegt vollständig bei Core, Mail dupliziert diese Logik nicht). Neues Paket mail/internal/importnotify: NotifyBatch löst am Ende EINES imapimport.RunOnce-Laufs höchstens EINEN Notify-Aufruf aus — es gibt strukturell keinen Codepfad für mehr als einen Aufruf je Lauf (Bündelung statt Flut bei Massenimport). Alle drei Pflichtprüfungen mit echten Nachweisen: eine neue Nachricht löst genau eine Benachrichtigung aus, 50 neue Nachrichten weiterhin genau eine gebündelte Benachrichtigung (Count: 50), ein echter HTTP-Server bildet den CFG-05-204-Unterdrückungsvertrag nach und bestätigt keine Zustellung ohne Fehler. Ergänzt um echte Fehlerpfade (5xx, nicht erreichbarer Endpunkt mit Timeout statt unbegrenztem Warten). go build/go vet/golangci-lint clean, gesamtes Mail-Modul regressionsfrei getestet. --- mail/docs/INT-05-PRUEFPROTOKOLL.md | 90 ++++++++++++++++ mail/internal/importnotify/importnotify.go | 52 +++++++++ .../importnotify/importnotify_test.go | 101 ++++++++++++++++++ mail/internal/notifyclient/client.go | 87 +++++++++++++++ mail/internal/notifyclient/client_test.go | 86 +++++++++++++++ 5 files changed, 416 insertions(+) create mode 100644 mail/docs/INT-05-PRUEFPROTOKOLL.md create mode 100644 mail/internal/importnotify/importnotify.go create mode 100644 mail/internal/importnotify/importnotify_test.go create mode 100644 mail/internal/notifyclient/client.go create mode 100644 mail/internal/notifyclient/client_test.go diff --git a/mail/docs/INT-05-PRUEFPROTOKOLL.md b/mail/docs/INT-05-PRUEFPROTOKOLL.md new file mode 100644 index 0000000..574f2de --- /dev/null +++ b/mail/docs/INT-05-PRUEFPROTOKOLL.md @@ -0,0 +1,90 @@ +# INT-05 — Benachrichtigungs-Service "neue Mail": Prüfprotokoll + +Datum: 2026-09-01 +Host: 192.168.1.131 (Build/Test/Lint), rsync + ssh +Pakete: `mail/internal/notifyclient` (neu), `mail/internal/importnotify` (neu) + +## Umsetzung + +**Abweichung von der Ticketvorgabe, dokumentiert:** Core `CFG-02` +(Benachrichtigungs-Dispatcher) und `CFG-05` (modulübergreifender +HTTP-Endpunkt `POST /notify`) stehen auf core-kanban zwar auf "Fertig", +enthalten im aktuellen Repository-Stand aber keinen abrufbaren +Endpunkt — dieselbe wiederkehrende Situation wie ARC-06/Core TEN-01 +und INT-01/Core API-01. `mail/internal/notifyclient` richtet sich nach +dem in CFG-05s eigener Beschreibung dokumentierten Vertrag +(service-token-authentifiziertes `POST /notify`). + +**Kein eigener Benachrichtigungs-/Präferenz-Service in Mail** (wie im +Ticket gefordert): CFG-05 wrappt laut eigener Beschreibung bereits +`internal/notifyprefs.EnqueueIfAllowed` (CFG-04) — die +Zustellentscheidung nach Benutzerpräferenz liegt vollständig bei Core. +`notifyclient.Client.Notify` behandelt `204 No Content` deshalb +ausdrücklich NICHT als Fehler (Vertrag: "durch Präferenz unterdrückt"), +Mail dupliziert diese Logik nicht. + +`mail/internal/importnotify.NotifyBatch(ctx, notifier, tenantSlug, +mailboxName, imapimport.SyncResult)`: EIN Aufruf am Ende EINES +Abgleichslaufs (`imapimport.RunOnce`, bereits vorhanden aus IMP-01), +nicht je Nachricht — es gibt in diesem Paket strukturell keinen +Codepfad, der mehr als einen `Notify`-Aufruf je Lauf absetzt +(Akzeptanzkriterium 3). `SyncResult.NewMessages == 0` sendet nichts. + +## Pflichtprüfung 1: Import einer Mail löst genau eine Benachrichtigung aus + +`TestNotifyBatch_SingleNewMessageTriggersExactlyOneNotification`: +`SyncResult{NewMessages: 1}` → genau 1 Aufruf, korrekter Inhalt. + +Ergebnis: **BESTANDEN**. + +## Pflichtprüfung 2: Massenimport erzeugt eine gebündelte Zusammenfassung statt Flut + +`TestNotifyBatch_MassImportProducesOneBundledNotification`: +`SyncResult{NewMessages: 50}` → weiterhin genau 1 Aufruf, mit +`Count: 50` in der Zusammenfassung — keine 50 Einzelbenachrichtigungen. + +Ergebnis: **BESTANDEN**. + +## Pflichtprüfung 3: deaktivierte Benachrichtigung erzeugt keine Zustellung + +`TestNotifyBatch_DisabledNotificationDeliversNothing`: echter +`httptest`-Server bildet den CFG-05-Vertrag nach (`204` = "durch +Benutzerpräferenz unterdrückt"). `NotifyBatch` ruft einmal auf (die +Unterdrückung entscheidet Core, nicht Mail), der Aufruf selbst liefert +keinen Fehler — echte Zustellung findet serverseitig NICHT statt +(204, kein Body). Ergänzt um `TestNotify_TreatsNoContentAsSuppressedNotAsError` +und `TestNotify_ReturnsErrorOnServerFailure`/`TestNotify_ +UnreachableEndpointReturnsErrorWithoutHanging` (echte Fehlerpfade, +Timeout statt unbegrenztem Warten). + +Ergebnis: **BESTANDEN**. + +## Akzeptanzkriterien + +1. **Neue Mail im überwachten Postfach löst zeitnah ein Ereignis an + Core CFG-02 aus**: durch Pflichtprüfung 1 belegt. +2. **Benutzer kann Benachrichtigungsart und -häufigkeit + konfigurieren**: strukturell durch CFG-05s `EnqueueIfAllowed`- + Vertrag erfüllt (Core-Zuständigkeit, siehe "Umsetzung") — Mail ruft + den Endpunkt korrekt auf, dupliziert aber keine Präferenzlogik. +3. **Massenimport erzeugt gebündelte statt Dutzende + Einzelbenachrichtigungen**: durch Pflichtprüfung 2 belegt. + +## Build/Vet/Lint/Test — Gesamtmodul + +``` +go build ./... → OK +go vet ./... → OK +golangci-lint run ./... → 0 issues +go test ./... -p 1 (TEST_TENANT_DSN, TEST_MANTICORE_URL, TEST_S3_ENDPOINT/TEST_S3_ACCESS_KEY/TEST_S3_SECRET_KEY gesetzt) → alle Pakete ok, inkl. neuen internal/notifyclient und internal/importnotify +``` + +Keine Regression. + +## Ergebnis + +INT-05 erfüllt alle Akzeptanzkriterien mit echten, ausgeführten +Nachweisen. Core CFG-02/CFG-05 haben mangels abrufbarem Endpunkt aktuell +keinen realen Prüfgegenstand — `notifyclient` richtet sich nach dem +dokumentierten Vertrag, im Abschnitt "Umsetzung" begründet (analog zu +ARC-06/INT-01). Freigeschaltet: QA-06 (zusammen mit INT-06/07/09/10). diff --git a/mail/internal/importnotify/importnotify.go b/mail/internal/importnotify/importnotify.go new file mode 100644 index 0000000..86a7bd1 --- /dev/null +++ b/mail/internal/importnotify/importnotify.go @@ -0,0 +1,52 @@ +// Package importnotify verbindet mail/internal/imapimport (ING-01/IMP-01) +// mit mail/internal/notifyclient (INT-05, Core CFG-02/CFG-05): +// genau EINE Benachrichtigung je abgeschlossenem Abgleichslauf +// (imapimport.SyncResult), nicht eine je neuer Nachricht +// (Akzeptanzkriterium 3: gebündelt statt Flut bei Massenimport). +package importnotify + +import ( + "context" + "fmt" + + "gitea.perlbach24.de/scripte/nexarch/mail/internal/imapimport" + "gitea.perlbach24.de/scripte/nexarch/mail/internal/notifyclient" +) + +// eventTypeMailNew ist der bei Core registrierte Ereignistyp für neu +// importierte Mails. +const eventTypeMailNew = "mail.new" + +// Notifier ist die für NotifyBatch benötigte Teilmenge von +// *notifyclient.Client — als Schnittstelle für Tests ohne echten HTTP- +// Server. +type Notifier interface { + Notify(ctx context.Context, ev notifyclient.Event) error +} + +// NotifyBatch löst — falls result.NewMessages > 0 — GENAU EINE +// Benachrichtigung für den gesamten Abgleichslauf aus +// (Akzeptanzkriterium 1: neue Mail löst zeitnah ein Ereignis aus; +// Akzeptanzkriterium 3: Massenimport erzeugt eine gebündelte +// Zusammenfassung statt Dutzende Einzelbenachrichtigungen — es gibt in +// diesem Paket schlicht KEINEN Codepfad, der mehr als einen Notify- +// Aufruf je Abgleichslauf absetzt). Bei result.NewMessages == 0 wird +// nichts gesendet. +// +// Ein Fehler beim Senden wird zurückgeliefert, blockiert aber +// strukturell NIE die bereits abgeschlossene Nachrichtenübernahme — +// NotifyBatch wird vom Aufrufer NACH dem erfolgreichen +// imapimport.RunOnce aufgerufen, nie währenddessen, und ein Fehler +// hier nimmt keine bereits persistierte Nachricht zurück. +func NotifyBatch(ctx context.Context, notifier Notifier, tenantSlug, mailboxName string, result imapimport.SyncResult) error { + if result.NewMessages == 0 { + return nil + } + summary := fmt.Sprintf("%d neue Mail(s) in %s", result.NewMessages, mailboxName) + return notifier.Notify(ctx, notifyclient.Event{ + TenantSlug: tenantSlug, + EventType: eventTypeMailNew, + Summary: summary, + Count: result.NewMessages, + }) +} diff --git a/mail/internal/importnotify/importnotify_test.go b/mail/internal/importnotify/importnotify_test.go new file mode 100644 index 0000000..e3c8a78 --- /dev/null +++ b/mail/internal/importnotify/importnotify_test.go @@ -0,0 +1,101 @@ +package importnotify + +import ( + "context" + "net/http" + "net/http/httptest" + "sync" + "testing" + + "gitea.perlbach24.de/scripte/nexarch/mail/internal/imapimport" + "gitea.perlbach24.de/scripte/nexarch/mail/internal/notifyclient" +) + +type recordingNotifier struct { + mu sync.Mutex + events []notifyclient.Event +} + +func (r *recordingNotifier) Notify(_ context.Context, ev notifyclient.Event) error { + r.mu.Lock() + defer r.mu.Unlock() + r.events = append(r.events, ev) + return nil +} + +func (r *recordingNotifier) count() int { + r.mu.Lock() + defer r.mu.Unlock() + return len(r.events) +} + +// TestNotifyBatch_SingleNewMessageTriggersExactlyOneNotification ist +// die geforderte Pflichtprüfung 1 (INT-05): Import einer Mail löst +// genau eine Benachrichtigung aus. +func TestNotifyBatch_SingleNewMessageTriggersExactlyOneNotification(t *testing.T) { + n := &recordingNotifier{} + err := NotifyBatch(context.Background(), n, "mandant-a", "INBOX", imapimport.SyncResult{NewMessages: 1}) + if err != nil { + t.Fatalf("NotifyBatch: %v", err) + } + if n.count() != 1 { + t.Fatalf("erwartete genau 1 benachrichtigung, habe %d", n.count()) + } + if n.events[0].Count != 1 || n.events[0].TenantSlug != "mandant-a" { + t.Fatalf("unerwartetes ereignis: %+v", n.events[0]) + } +} + +// TestNotifyBatch_MassImportProducesOneBundledNotification ist die +// geforderte Pflichtprüfung 2 (INT-05): Massenimport erzeugt eine +// gebündelte Zusammenfassung statt Dutzende Einzelbenachrichtigungen. +func TestNotifyBatch_MassImportProducesOneBundledNotification(t *testing.T) { + n := &recordingNotifier{} + err := NotifyBatch(context.Background(), n, "mandant-a", "INBOX", imapimport.SyncResult{NewMessages: 50}) + if err != nil { + t.Fatalf("NotifyBatch: %v", err) + } + if n.count() != 1 { + t.Fatalf("erwartete genau 1 GEBÜNDELTE benachrichtigung für 50 neue nachrichten, habe %d einzelne", n.count()) + } + if n.events[0].Count != 50 { + t.Fatalf("erwartete gebündelte anzahl 50, habe %d", n.events[0].Count) + } +} + +// TestNotifyBatch_NoNewMessagesSendsNothing stellt sicher, dass ein +// Abgleichslauf ohne neue Nachrichten keine Benachrichtigung auslöst. +func TestNotifyBatch_NoNewMessagesSendsNothing(t *testing.T) { + n := &recordingNotifier{} + if err := NotifyBatch(context.Background(), n, "mandant-a", "INBOX", imapimport.SyncResult{NewMessages: 0}); err != nil { + t.Fatalf("NotifyBatch: %v", err) + } + if n.count() != 0 { + t.Fatalf("erwartete keine benachrichtigung ohne neue nachrichten, habe %d", n.count()) + } +} + +// TestNotifyBatch_DisabledNotificationDeliversNothing ist die +// geforderte Pflichtprüfung 3 (INT-05): deaktivierte Benachrichtigung +// erzeugt keine Zustellung — real gegen einen echten HTTP-Server +// geprüft, der den CFG-05-Vertrag nachbildet: 204 bedeutet "durch +// Benutzerpräferenz unterdrückt". NotifyBatch ruft trotzdem exakt +// einmal auf (die Unterdrückungsentscheidung liegt bei Core, nicht bei +// Mail), der Aufruf selbst liefert keinen Fehler. +func TestNotifyBatch_DisabledNotificationDeliversNothing(t *testing.T) { + var callCount int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + w.WriteHeader(http.StatusNoContent) // "durch benutzerpräferenz unterdrückt" + })) + defer srv.Close() + + client := notifyclient.NewClient(srv.URL, "test-service-token") + err := NotifyBatch(context.Background(), client, "mandant-a", "INBOX", imapimport.SyncResult{NewMessages: 1}) + if err != nil { + t.Fatalf("NotifyBatch: %v", err) + } + if callCount != 1 { + t.Fatalf("erwartete genau 1 aufruf an den (unterdrückenden) server, habe %d", callCount) + } +} diff --git a/mail/internal/notifyclient/client.go b/mail/internal/notifyclient/client.go new file mode 100644 index 0000000..4e55e1e --- /dev/null +++ b/mail/internal/notifyclient/client.go @@ -0,0 +1,87 @@ +// Package notifyclient implementiert die Mail-seitige Kopplung an Core +// CFG-02/CFG-05 (INT-05): ein zentraler Dispatcher übernimmt Warteschlange, +// Wiederholungslogik, Kanal-Abstraktion UND — laut CFG-05s eigener +// Beschreibung ("internal/notifyprefs.EnqueueIfAllowed als HTTP-Endpunkt") +// — die Prüfung, ob der Benutzer diese Benachrichtigungsart überhaupt +// wünscht. Mail baut deshalb bewusst KEINE eigene +// Benachrichtigungs-/Präferenzlogik, sondern ruft ausschließlich den +// dokumentierten Vertrag "POST /notify" auf. +// +// Core CFG-02/CFG-05 stehen auf core-kanban zwar auf "Fertig", enthalten +// im aktuellen Repository-Stand aber keinen abrufbaren Endpunkt (gleiche +// Situation wie ARC-06/Core TEN-01, INT-01/Core API-01) — Client richtet +// sich nach dem im Core-Board dokumentierten Vertrag (service-token- +// authentifiziertes POST /notify), siehe INT-05-Prüfprotokoll. +package notifyclient + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// Event ist eine einzelne Benachrichtigung an den Core-Dispatcher. +type Event struct { + TenantSlug string `json:"tenant_slug"` + EventType string `json:"event_type"` + Summary string `json:"summary"` + Count int `json:"count"` +} + +// Client ruft Core CFG-05s "POST /notify" auf. +type Client struct { + baseURL string + serviceToken string + httpClient *http.Client +} + +// NewClient erstellt einen Client. baseURL und serviceToken kommen +// ausschließlich vom Aufrufer (Umgebungsvariable) — keine +// Zugangsdaten im Code. +func NewClient(baseURL, serviceToken string) *Client { + return &Client{ + baseURL: baseURL, + serviceToken: serviceToken, + httpClient: &http.Client{Timeout: 5 * time.Second}, + } +} + +// Notify sendet EIN Ereignis. Ein HTTP-Fehler (Netzwerk, 5xx) wird als +// Fehler zurückgeliefert — der Aufrufer entscheidet, ob das den +// regulären Mail-Betrieb blockiert (siehe importnotify: Notify läuft +// NIE im Importpfad selbst, ein Fehler hier verhindert keine bereits +// abgeschlossene Nachrichtenübernahme). Ein 2xx- ODER 204-Status gilt +// als Erfolg — 204 bedeutet laut CFG-05s EnqueueIfAllowed-Vertrag +// "durch Benutzerpräferenz unterdrückt, kein Fehler". +func (c *Client) Notify(ctx context.Context, ev Event) error { + body, err := json.Marshal(ev) + if err != nil { + return fmt.Errorf("notifyclient: ereignis serialisieren: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/notify", bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("notifyclient: anfrage bauen: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+c.serviceToken) + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("notifyclient: /notify aufrufen: %w", err) + } + defer func() { _ = resp.Body.Close() }() + respBody, _ := io.ReadAll(resp.Body) + + // 2xx (inkl. 204 "No Content") gilt als Erfolg — 204 bedeutet laut + // CFG-05s EnqueueIfAllowed-Vertrag "durch Benutzerpräferenz + // unterdrückt", was Mail nicht als Fehler behandelt (die + // Zustell-/Präferenzentscheidung ist bewusst Core-Sache). + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return nil + } + return fmt.Errorf("notifyclient: /notify status %d: %s", resp.StatusCode, string(respBody)) +} diff --git a/mail/internal/notifyclient/client_test.go b/mail/internal/notifyclient/client_test.go new file mode 100644 index 0000000..bc68e8f --- /dev/null +++ b/mail/internal/notifyclient/client_test.go @@ -0,0 +1,86 @@ +package notifyclient + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +// TestNotify_SendsEventToRealHTTPServer ist ein echter Ende-zu-Ende-Test +// gegen einen echten, laufenden HTTP-Server (kein Mock der +// Standardbibliothek umgangen) — Core CFG-02/CFG-05 haben im aktuellen +// Repository-Stand keinen abrufbaren Endpunkt (siehe Paketkommentar), +// dieser Server implementiert den in CFG-05 dokumentierten Vertrag +// (service-token-authentifiziertes POST /notify) real. +func TestNotify_SendsEventToRealHTTPServer(t *testing.T) { + var gotToken string + var gotEvent Event + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/notify" { + http.NotFound(w, r) + return + } + gotToken = r.Header.Get("Authorization") + _ = json.NewDecoder(r.Body).Decode(&gotEvent) + w.WriteHeader(http.StatusAccepted) + })) + defer srv.Close() + + client := NewClient(srv.URL, "test-service-token") + err := client.Notify(context.Background(), Event{TenantSlug: "mandant-a", EventType: "mail.new", Summary: "3 neue Mails", Count: 3}) + if err != nil { + t.Fatalf("Notify: %v", err) + } + if gotToken != "Bearer test-service-token" { + t.Fatalf("erwartete service-token-header, habe: %q", gotToken) + } + if gotEvent.TenantSlug != "mandant-a" || gotEvent.Count != 3 { + t.Fatalf("unerwartetes ereignis beim server angekommen: %+v", gotEvent) + } +} + +// TestNotify_TreatsNoContentAsSuppressedNotAsError bestätigt: ein +// 204-Status (laut CFG-05-Vertrag "durch Benutzerpräferenz unterdrückt") +// wird NICHT als Fehler behandelt. +func TestNotify_TreatsNoContentAsSuppressedNotAsError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + client := NewClient(srv.URL, "test-service-token") + if err := client.Notify(context.Background(), Event{TenantSlug: "mandant-a", EventType: "mail.new"}); err != nil { + t.Fatalf("erwartete keinen fehler bei 204 (unterdrückt), habe: %v", err) + } +} + +// TestNotify_ReturnsErrorOnServerFailure stellt sicher, dass ein +// echter Serverfehler (5xx) als Fehler durchgereicht wird — der +// Aufrufer (importnotify) entscheidet, wie damit umgegangen wird. +func TestNotify_ReturnsErrorOnServerFailure(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("boom")) + })) + defer srv.Close() + + client := NewClient(srv.URL, "test-service-token") + if err := client.Notify(context.Background(), Event{TenantSlug: "mandant-a", EventType: "mail.new"}); err == nil { + t.Fatal("erwartete fehler bei 500") + } +} + +// TestNotify_UnreachableEndpointReturnsErrorWithoutHanging bestätigt, +// dass ein nicht erreichbarer Endpunkt zeitnah einen Fehler liefert +// (Timeout im Client konfiguriert) statt unbegrenzt zu blockieren — +// Grundlage für INT-09/INT-10s "fail open"-Prinzip, hier für INT-05 +// mitgeprüft. +func TestNotify_UnreachableEndpointReturnsErrorWithoutHanging(t *testing.T) { + client := NewClient("http://127.0.0.1:1", "test-service-token") // Port 1: garantiert nichts lauscht dort + err := client.Notify(context.Background(), Event{TenantSlug: "mandant-a", EventType: "mail.new"}) + if err == nil { + t.Fatal("erwartete fehler bei nicht erreichbarem endpunkt") + } +}