From a69bcbaf10a7eae053fd738c487a6ce459696782 Mon Sep 17 00:00:00 2001 From: Sandro Date: Fri, 30 Oct 2026 09:49:13 +0100 Subject: [PATCH] feat(notify): bundled notifications, task watchers, daily summary - Per-user routing (own mail / own Discord webhook) for task alarms (sent at once), shares, completed shared tasks and watched tasks; settings as JSON on the user (GET /auth/me, PATCH /auth/settings). - Everything but alarms is queued and sent as one message per user after NOTIFY_DIGEST_MINUTES (default 5); mails now multipart with an HTML version, Discord as one embed. Three failed attempts drop it. - Own changes never notify the actor, except on watched tasks when asked for; "paused" stops everything including alarms. - Task watchers: POST/DELETE /tasks/{id}/watch, isWatching on tasks. - Daily summary at a chosen time: overdue, today, tomorrow, plus the count of the next 7 days; nothing due, nothing sent. - Migration 7; jobs do not run in the demo. Co-Authored-By: Claude Opus 5.5 --- .env.example | 3 + CHANGELOG.md | 5 + Doku/api/rest.md | 3 +- Doku/architecture.md | 4 + Doku/database.md | 2 + docker-compose.yml | 1 + internal/api/alarms.go | 28 +- internal/api/handlers_auth.go | 12 + internal/api/handlers_documents.go | 11 + internal/api/handlers_folders.go | 1 + internal/api/handlers_lists.go | 3 + internal/api/handlers_tasklists.go | 3 + internal/api/handlers_thoughts.go | 6 + internal/api/mail.go | 38 ++- internal/api/mail_test.go | 14 +- internal/api/notify.go | 417 +++++++++++++++++++++++++++++ internal/api/notify_test.go | 160 +++++++++++ internal/api/server.go | 7 + internal/config/config.go | 4 + internal/domain/models.go | 63 +++++ internal/storage/migrations.go | 1 + internal/storage/notifications.go | 253 +++++++++++++++++ 22 files changed, 1018 insertions(+), 21 deletions(-) create mode 100644 internal/api/notify.go create mode 100644 internal/api/notify_test.go create mode 100644 internal/storage/notifications.go diff --git a/.env.example b/.env.example index 195bde3..aa809fa 100644 --- a/.env.example +++ b/.env.example @@ -67,6 +67,9 @@ SMTP_FROM= # Name, den Mailprogramme als Absender zeigen (wenn SMTP_FROM nur eine Adresse ist). # Ein Name direkt in SMTP_FROM ("Mein Dienst ") hat Vorrang. SMTP_FROM_NAME=MindSift +# Benachrichtigungen (Freigaben, beobachtete Aufgaben …) werden gesammelt und höchstens alle so viele +# Minuten als eine Nachricht verschickt. Fällige Aufgaben kommen immer sofort. +NOTIFY_DIGEST_MINUTES=5 # true = nur verschlüsselt senden. SMTP_TLS=true # true = selbstsignierte Zertifikate akzeptieren (nur für interne Mailserver). diff --git a/CHANGELOG.md b/CHANGELOG.md index fb810dd..dcc1b4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V ## [Unreleased] +### Added +- Benachrichtigungen pro Ereignis und Kanal: fällige Aufgaben (sofort), Freigabe erhalten, geteilte Aufgabe erledigt, beobachtete Aufgaben – jeweils per eigener E-Mail und/oder eigenem Discord-Webhook. Alles außer fälligen Aufgaben kommt gebündelt (höchstens alle 5 Minuten eine Nachricht, `NOTIFY_DIGEST_MINUTES`), E-Mails jetzt mit HTML-Layout. Eigene Änderungen lösen nichts aus (bei beobachteten Aufgaben zuschaltbar), ein Schalter pausiert alles. +- Aufgaben beobachten: Knopf im Aufgaben-Detail; jede Änderung daran wird gemeldet. API: `POST/DELETE /tasks/{id}/watch`. +- Tageszusammenfassung zur selbst gewählten Uhrzeit: überfällige, heutige und morgige Aufgaben mit Uhrzeit, dazu die Anzahl der nächsten 7 Tage; ohne fällige Aufgaben keine Nachricht. + ### Fixed - In eigene Notiz umwandeln: Der Link erscheint sofort im Punkt (auch hinter Proxys ohne Live-Verbindung), ohne Konfliktkopien bei gleichzeitigem Weitertippen; die neue Notiz steht sofort in der Seitenleiste. Strg+Z oder „Rückgängig“ im Hinweis holen den Ast zurück und legen die neue Notiz in den Papierkorb. diff --git a/Doku/api/rest.md b/Doku/api/rest.md index 678bdb0..4612e57 100644 --- a/Doku/api/rest.md +++ b/Doku/api/rest.md @@ -24,6 +24,7 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}` | POST | `/documents/{id}/extract/undo` | Legt die ausgelagerte Notiz `{id}` wieder in den Papierkorb (nur ihr Ersteller) | | POST | `/documents/{id}/merge` | `{sourceId}`: hängt die (eigene) Quellnotiz als Überschrift mit Unterpunkten an diese Notiz an, Quelle in den Papierkorb → `{targetId, sourceId, sourceTitle, headingId}` | | POST | `/documents/{id}/merge/undo` | `{sourceId, headingId}`: Abschnitt wieder entfernen, Quelle wiederherstellen | +| POST/DELETE | `/tasks/{id}/watch` | Aufgabe beobachten bzw. nicht mehr → `{watching}`; `404` ohne Zugriff. Aufgaben-Antworten tragen `isWatching` | | GET | `/link-preview?url=https://…` | Vorschau einer Web-Seite (`title`, `description`, `siteName`, `hasImage`, `hasIcon`, `ok`, `key`), vom Server geholt und 7 Tage zwischengespeichert; `422` ohne https, `403` in der Demo, `429` zu viele Abrufe | | GET | `/link-preview/{key}/image` bzw. `/icon` | Bild bzw. Icon einer Vorschau | | POST | `/voice/transcribe` | Nur im Server-Modus der Spracherkennung: `multipart/form-data` mit `file` (Aufnahme, z. B. WebM/Opus oder MP4/AAC) → `{text}`. `404` ohne `STT_URL`, `413` zu groß, `429` Stundenlimit, `503` ausgelastet, `502` Whisper-Fehler (Details im Log) | @@ -40,7 +41,7 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}` | GET | `/auth/sessions` | Angemeldete Geräte: `[{id, createdAt, lastUsedAt, userAgent, ip, current}]` | | DELETE | `/auth/sessions/{id}` | Ein Gerät abmelden; `404` bei fremder oder unbekannter Sitzung | | POST | `/auth/revoke-all` | Alle Sitzungen beenden (`token_version` +1) | -| PATCH | `/auth/settings` | `displayName`, `email`, `password`, `avatarUrl`, `alarmChannels`, `alarmEmail`, `discordWebhookUrl`, `customWebhookUrl`, `notifyBeforeMinutes`. Neue `email` oder `password` nur mit `currentPassword` (`403` bei falschem, `429` bei Login-Sperre); nach neuem Passwort sind andere Geräte abgemeldet, das neue Token kommt im Header `X-Session-Token` | +| PATCH | `/auth/settings` | `displayName`, `email`, `password`, `avatarUrl`, `alarmChannels`, `alarmEmail`, `discordWebhookUrl`, `customWebhookUrl`, `notifyBeforeMinutes`. `notificationSettings` (`paused`, `includeOwnWatched`, `events.{due,shared,taskDone,watched}.{email,discord}`, `dailySummary.{enabled,time,email,discord}`) für Benachrichtigungen. Neue `email` oder `password` nur mit `currentPassword` (`403` bei falschem, `429` bei Login-Sperre); nach neuem Passwort sind andere Geräte abgemeldet, das neue Token kommt im Header `X-Session-Token` | | POST | `/auth/alarms/test` | Test-Alarm (`channel`: `email`/`discord`/`webhook`, optional `target`) | | GET | `/users/directory`, `/collaborators` | Personen, die man sehen und mit denen man teilen kann (je nach `sharingMode`, [sharing-and-permissions.md](../sharing-and-permissions.md)) | | GET | `/stats` | Zähler fürs Dashboard (u. a. `inboxCount`, `tasksOpenCount`, `listItemsOpenCount` = offene Einträge aller sichtbaren Listen) | diff --git a/Doku/architecture.md b/Doku/architecture.md index 4d11b87..af0fdba 100644 --- a/Doku/architecture.md +++ b/Doku/architecture.md @@ -99,6 +99,10 @@ Bilder und Audio liegen in `$DATA_DIR/media//.`, beschrieben in `internal/storage/restructure.go`: **Ast auslagern** liest den Ast und legt die neue Notiz an (Titel eindeutig, gleiche direkte und Gruppen-Freigaben, Besitzer der Ursprungsnotiz bekommt Schreibrecht). Den Ast in der Ursprungsnotiz ersetzt der Editor selbst durch `[[Titel]]` (Hashtags bleiben am Link) – als eigene Änderung über die Sync-Engine, damit sie nicht mit ungesendeten Eingaben kollidiert und Strg+Z sie zurücknimmt; dabei legt `/extract/undo` die neue Notiz in den Papierkorb. **Zusammenführen** hängt die Quelle als `h2`-Überschrift mit ihren Punkten als Unterpunkte an die Zielnotiz an (ebenfalls über Operationen, offene Editoren folgen live) und legt die Quelle in den Papierkorb; „Rückgängig“ löscht die Überschrift samt Unterpunkten und stellt die Quelle wieder her. Die Aktion läuft im Namen des Editor-Clients (`clientId`), damit dessen eigene Block-Sperren nicht stören. Bilder und Aufnahmen funktionieren weiter, weil der Zugriff über die Notizen läuft, die sie zeigen. +## Benachrichtigungen + +`internal/api/notify.go`, `internal/storage/notifications.go` (Migration 7). Jede Person wählt pro Ereignisart die Kanäle – eigene E-Mail (Alarm-Adresse, sonst Konto-Adresse) und eigener Discord-Webhook aus den Alarmen –, gespeichert als JSON in `users.notification_settings` (`GET /auth/me`, `PATCH /auth/settings` → `notificationSettings`). Ereignisse: `due` (Fälligkeits-Alarm, sofort über den Alarm-Ticker), `shared` (Notiz, Ordner, Aufgabenliste oder Liste geteilt; bei Gruppen alle Mitglieder), `taskDone` (Aufgabe einer geteilten Liste erledigt), `watched` (jede Änderung an einer beobachteten Aufgabe, `task_watchers`, `POST/DELETE /tasks/{id}/watch`). Alles außer `due` landet in `notification_queue`; ein Job im Minutentakt schickt je Person eine gebündelte Nachricht, sobald das älteste Ereignis `NOTIFY_DIGEST_MINUTES` (Standard 5) alt ist (E-Mail als Text + HTML, Discord als ein Embed mit höchstens 10 Zeilen), nach 3 Fehlversuchen wird verworfen. Eigene Änderungen benachrichtigen nie den Handelnden, außer bei beobachteten Aufgaben mit `includeOwnWatched`. `paused` stoppt alles, auch Alarme. **Tageszusammenfassung:** zur gewählten Uhrzeit (Server-Zeitzone, innerhalb von 30 Minuten, einmal pro Tag) überfällige, heutige und morgige offene Aufgaben samt Anzahl der folgenden 7 Tage; nichts fällig → keine Nachricht. Im Demo-Modus laufen die Jobs nicht. + ## Link-Vorschau Punkte mit einer reinen https-Adresse zeigen darüber eine Karte (`LinkPreviewCard`, `lib/linkPreview.ts`). `GET /link-preview?url=` holt die Seite serverseitig mit demselben Schutz wie Webhooks (nur https, keine internen Adressen, Weiterleitungen geprüft), liest `og:*`/`twitter:*`, ``, `description` und das Icon aus dem `<head>` (höchstens 1 MB HTML, Bild 2 MB, Icon 100 KB, kein SVG) und speichert alles in `link_previews` (Migration 6): Erfolg 7 Tage, nichts Brauchbares 1 Tag, nach 30 Tagen gelöscht. Bilder kommen über `GET /link-preview/{key}/image|icon` mit Login. Pro Adresse höchstens 300 Abrufe in 15 Minuten (Antworten aus dem Cache zählen nicht). Ausblenden einer Karte macht aus der Adresse `[Titel](url)` (ein benannter Link hat keine Karte), damit synchronisiert die Wahl mit der Notiz; „Link-Vorschauen anzeigen“ unter Darstellung gilt pro Gerät. Im Demo-Modus aus. diff --git a/Doku/database.md b/Doku/database.md index b5ecbba..ecbccb7 100644 --- a/Doku/database.md +++ b/Doku/database.md @@ -12,6 +12,8 @@ SQLite-Datei `mindsift.db` (Standard: `$DATA_DIR/mindsift.db`), geöffnet mit `j | `user_invites` | Einladungscodes | `code`, `email`, `role`, `organization_id`, `expires_at`, `used_by` | | `media` | Bilder und Audio in Notizen (Migration 5); Datei unter `$DATA_DIR/media/<doc_id>/<id>.<ext>` | `id`, `doc_id`, `owner_id`, `kind` (`image`/`audio`), `mime`, `size`, `source_url` (kopiertes externes Bild), `created_at` | | `link_previews` | Zwischengespeicherte Link-Vorschauen (Migration 6) | `key` (Hash der URL), `url`, `title`, `description`, `site_name`, `image`/`icon` (BLOB, mit `*_mime`), `ok`, `fetched_at` | +| `notification_queue` | Wartende Benachrichtigungen bis zum gebündelten Versand (Migration 7) | `user_id`, `kind` (`shared`/`taskDone`/`watched`), `payload` (JSON), `created_at`, `attempts` | +| `task_watchers` | Beobachtete Aufgaben | `task_id`, `user_id`, `created_at` | | `sessions` | Angemeldete Geräte (Migration 4, ersetzt `revoked_tokens`) | `id` (= `jti` des Tokens), `user_id`, `created_at`, `last_used_at` (höchstens alle 5 Minuten aktualisiert), `user_agent`, `ip`, `revoked_at` (leer = aktiv) | | `user_themes` | Eigene CSS-Themes eines Benutzers | `user_id` (FK, `ON DELETE CASCADE`), `name`, `css_content` | | `api_keys` | Webhook-/API-Schlüssel | `key_prefix`, `key_hash` (SHA-256), `scopes` (nicht ausgewertet), `last_used_at` | diff --git a/docker-compose.yml b/docker-compose.yml index 83f3c5c..e00922c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,6 +30,7 @@ services: - SMTP_PASS=${SMTP_PASS:-} - SMTP_FROM=${SMTP_FROM:-} - SMTP_FROM_NAME=${SMTP_FROM_NAME:-MindSift} + - NOTIFY_DIGEST_MINUTES=${NOTIFY_DIGEST_MINUTES:-5} - SMTP_TLS=${SMTP_TLS:-true} - SMTP_SKIP_VERIFY=${SMTP_SKIP_VERIFY:-false} # Impressum / Datenschutz diff --git a/internal/api/alarms.go b/internal/api/alarms.go index f362e94..33766da 100644 --- a/internal/api/alarms.go +++ b/internal/api/alarms.go @@ -13,6 +13,11 @@ import ( // --- MULTI-CHANNEL ALARM DISPATCHER (EMAIL, DISCORD, CUSTOM WEBHOOK) --- func (s *Server) sendEmailNotification(toEmail, subject, bodyText string) error { + return s.sendEmailHTML(toEmail, subject, bodyText, "") +} + +// sendEmailHTML sends text plus an optional HTML version (multipart/alternative). +func (s *Server) sendEmailHTML(toEmail, subject, bodyText, html string) error { if s.cfg.DemoMode { return errDemoOutgoing } @@ -20,7 +25,7 @@ func (s *Server) sendEmailNotification(toEmail, subject, bodyText string) error if err != nil { return err } - if err := sendMail(cfg, toEmail, subject, bodyText); err != nil { + if err := sendMail(cfg, toEmail, subject, bodyText, html); err != nil { step := "" if se, ok := err.(*smtpStepError); ok { step = se.Step @@ -123,6 +128,27 @@ func (s *Server) startAlarmTicker() { if !alarmDue(a.DueDate, a.NotifyBeforeMinutes, now) { continue } + // Channels come from the notification settings (row "due"); a pause holds the alarm back + set := s.store.NotificationSettingsOf(a.UserID) + if set.Paused { + continue + } + channels := []string{} + if set.Events.Due.Email { + channels = append(channels, "email") + } + if set.Events.Due.Discord { + channels = append(channels, "discord") + } + for _, ch := range a.AlarmChannels { + if ch == "webhook" { + channels = append(channels, "webhook") + } + } + if a.AlarmEmail == "" { + a.AlarmEmail = a.UserEmail + } + a.AlarmChannels = channels if len(a.AlarmChannels) == 0 { continue } diff --git a/internal/api/handlers_auth.go b/internal/api/handlers_auth.go index 8def15f..74d2d42 100644 --- a/internal/api/handlers_auth.go +++ b/internal/api/handlers_auth.go @@ -341,6 +341,8 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) { return } stats, _ := s.store.GetDashboardStats(userID) + ns := s.store.NotificationSettingsOf(userID) + user.NotificationSettings = &ns writeJSON(w, http.StatusOK, map[string]interface{}{ "user": user, "stats": stats, @@ -448,6 +450,8 @@ func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request NotifyBeforeMinutes *int `json:"notifyBeforeMinutes"` // Needed to change password or e-mail address (FLAW-30) CurrentPassword string `json:"currentPassword"` + // Which notifications go where (notify.go) + NotificationSettings *domain.NotificationSettings `json:"notificationSettings"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "Ungültiges JSON") @@ -528,6 +532,14 @@ func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request if changesPassword || changesEmail { s.notifyAccountChange(before, changesPassword, changesEmail, u.Email) } + if req.NotificationSettings != nil { + if err := s.store.SetNotificationSettings(userID, *req.NotificationSettings); err != nil { + writeError(w, http.StatusInternalServerError, "Benachrichtigungen konnten nicht gespeichert werden") + return + } + } + ns := s.store.NotificationSettingsOf(userID) + u.NotificationSettings = &ns writeJSON(w, http.StatusOK, u) } diff --git a/internal/api/handlers_documents.go b/internal/api/handlers_documents.go index aacfcca..6626459 100644 --- a/internal/api/handlers_documents.go +++ b/internal/api/handlers_documents.go @@ -262,6 +262,9 @@ func (s *Server) handleCreateDocShare(w http.ResponseWriter, r *http.Request) { if req.TeamID != "" { share, err := s.store.CreateDocTeamShare(userID, docID, req.TeamID, req.Permission) + if err == nil { + defer s.notifyShared(userID, "team", req.TeamID, "die Notiz", s.docTitle(userID, docID)) + } if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return @@ -286,6 +289,7 @@ func (s *Server) handleCreateDocShare(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, err.Error()) return } + s.notifyShared(userID, "user", share.UserID, "die Notiz", s.docTitle(userID, docID)) s.broadcastDocToAuthorizedUsers(docID, "DOC_CREATED") s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED") writeJSON(w, http.StatusCreated, share) @@ -313,3 +317,10 @@ func (s *Server) handleGetCollaborators(w http.ResponseWriter, r *http.Request) } writeJSON(w, http.StatusOK, users) } + +func (s *Server) docTitle(userID, docID string) string { + if d, err := s.store.GetDocumentByID(userID, docID); err == nil && d.Title != "" { + return d.Title + } + return "Unbenannte Notiz" +} diff --git a/internal/api/handlers_folders.go b/internal/api/handlers_folders.go index 39d2cd2..5b09b91 100644 --- a/internal/api/handlers_folders.go +++ b/internal/api/handlers_folders.go @@ -212,6 +212,7 @@ func (s *Server) handleCreateFolderShare(w http.ResponseWriter, r *http.Request) writeError(w, http.StatusBadRequest, err.Error()) return } + s.notifyShared(userID, share.TargetType, share.TargetID, "den Ordner", s.store.SharedFolderName(folderID)) s.wsHub.BroadcastToUsers(s.store.GetSharedFolderUserIDs(folderID), "WORKSPACE_FOLDERS_UPDATED", map[string]string{"folderId": folderID}) if docIDs, err := s.store.GetSharedFolderDocIDs(folderID); err == nil { for _, docID := range docIDs { diff --git a/internal/api/handlers_lists.go b/internal/api/handlers_lists.go index 169a8a0..5a281a2 100644 --- a/internal/api/handlers_lists.go +++ b/internal/api/handlers_lists.go @@ -107,6 +107,9 @@ func (s *Server) handleCreateListShare(w http.ResponseWriter, r *http.Request) { return } + if l, err := s.store.GetListByID(currentUserID, listID); err == nil { + s.notifyShared(currentUserID, targetType, share.TargetID, "die Liste", l.Title) + } if targetType == "team" { members, _ := s.store.GetTeamMembers(share.TargetID) for _, tm := range members { diff --git a/internal/api/handlers_tasklists.go b/internal/api/handlers_tasklists.go index 8fac112..25a359e 100644 --- a/internal/api/handlers_tasklists.go +++ b/internal/api/handlers_tasklists.go @@ -125,6 +125,9 @@ func (s *Server) handleCreateTaskListShare(w http.ResponseWriter, r *http.Reques return } s.broadcastTaskListChange(listID, nil, "TASK_LIST_UPDATED") + if tl, err := s.store.GetTaskListByID(userID, listID); err == nil { + s.notifyShared(userID, req.TargetType, req.TargetID, "die Aufgabenliste", tl.Title) + } writeJSON(w, http.StatusCreated, share) } diff --git a/internal/api/handlers_thoughts.go b/internal/api/handlers_thoughts.go index a0ed90a..07bbf1c 100644 --- a/internal/api/handlers_thoughts.go +++ b/internal/api/handlers_thoughts.go @@ -94,6 +94,7 @@ func (s *Server) handleGetThoughts(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, err.Error()) return } + s.markWatching(userID, thoughts) writeJSON(w, http.StatusOK, thoughts) } @@ -278,6 +279,8 @@ func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) { s.notifyDocChanged(docID, head) } s.wsHub.BroadcastToUsers(append(s.taskAudience(before), s.taskAudience(updated)...), "TASK_UPDATED", updated) + s.notifyTaskChange(userID, before, updated, false) + updated.IsWatching = s.store.WatchedTaskIDs(userID)[updated.ID] writeJSON(w, http.StatusOK, updated) } @@ -304,6 +307,9 @@ func (s *Server) handleDeleteThought(w http.ResponseWriter, r *http.Request) { writeStoreError(w, http.StatusInternalServerError, err) return } + if th != nil { + s.notifyTaskChange(userID, th, nil, true) + } s.wsHub.BroadcastToUsers(audience, "TASK_DELETED", map[string]string{"id": thoughtID}) writeJSON(w, http.StatusOK, map[string]bool{"deleted": true}) } diff --git a/internal/api/mail.go b/internal/api/mail.go index 763cfd8..1b3ac27 100644 --- a/internal/api/mail.go +++ b/internal/api/mail.go @@ -117,7 +117,7 @@ func senderAddress(raw, name string) (envelope, header string, err error) { // buildMessage creates an RFC 5322 message: Date and Message-ID headers, RFC 2047 encoded subject // (umlauts, emoji) and a quoted-printable UTF-8 body with CRLF line endings. -func buildMessage(from, to, subject, body string, now time.Time) []byte { +func buildMessage(from, to, subject, body, html string, now time.Time) []byte { idBytes := make([]byte, 12) _, _ = rand.Read(idBytes) domain := ehloName(from) @@ -129,12 +129,26 @@ func buildMessage(from, to, subject, body string, now time.Time) []byte { fmt.Fprintf(&buf, "Date: %s\r\n", now.Format(time.RFC1123Z)) fmt.Fprintf(&buf, "Message-ID: <%s@%s>\r\n", hex.EncodeToString(idBytes), domain) buf.WriteString("MIME-Version: 1.0\r\n") - buf.WriteString("Content-Type: text/plain; charset=\"utf-8\"\r\n") - buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n") - qp := quotedprintable.NewWriter(&buf) - _, _ = qp.Write([]byte(strings.ReplaceAll(strings.ReplaceAll(body, "\r\n", "\n"), "\n", "\r\n"))) - _ = qp.Close() - buf.WriteString("\r\n") + part := func(contentType, text string) { + fmt.Fprintf(&buf, "Content-Type: %s; charset=\"utf-8\"\r\n", contentType) + buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n") + qp := quotedprintable.NewWriter(&buf) + _, _ = qp.Write([]byte(strings.ReplaceAll(strings.ReplaceAll(text, "\r\n", "\n"), "\n", "\r\n"))) + _ = qp.Close() + buf.WriteString("\r\n") + } + if html == "" { + part("text/plain", body) + return buf.Bytes() + } + // Text for simple clients, HTML for the rest + boundary := "ms-" + hex.EncodeToString(idBytes) + fmt.Fprintf(&buf, "Content-Type: multipart/alternative; boundary=\"%s\"\r\n\r\n", boundary) + fmt.Fprintf(&buf, "--%s\r\n", boundary) + part("text/plain", body) + fmt.Fprintf(&buf, "--%s\r\n", boundary) + part("text/html", html) + fmt.Fprintf(&buf, "--%s--\r\n", boundary) return buf.Bytes() } @@ -199,14 +213,14 @@ func authenticate(client *smtp.Client, cfg smtpSettings) error { // sendMail sends one mail. If AUTH PLAIN is refused and the server also offers LOGIN, it reconnects once // and tries LOGIN; a 451 on both points to the mail server's SASL backend. -func sendMail(cfg smtpSettings, to, subject, body string) error { - err := sendMailOnce(cfg, to, subject, body) +func sendMail(cfg smtpSettings, to, subject, body, html string) error { + err := sendMailOnce(cfg, to, subject, body, html) var first *authAttemptError if cfg.authMech != "" || !errors.As(err, &first) || first.mech != "PLAIN" || !first.loginOffered { return err } cfg.authMech = "LOGIN" - retryErr := sendMailOnce(cfg, to, subject, body) + retryErr := sendMailOnce(cfg, to, subject, body, html) var second *authAttemptError if retryErr == nil || !errors.As(retryErr, &second) { return retryErr @@ -220,7 +234,7 @@ func sendMail(cfg smtpSettings, to, subject, body string) error { // sendMailOnce runs the SMTP dialogue step by step instead of smtp.SendMail, so it can introduce itself // properly, honour SkipVerify and report the exact step a server refuses. -func sendMailOnce(cfg smtpSettings, to, subject, body string) error { +func sendMailOnce(cfg smtpSettings, to, subject, body, html string) error { fromAddr, fromHeader, err := senderAddress(cfg.From, cfg.FromName) if err != nil { return &smtpStepError{"Absender", err} @@ -278,7 +292,7 @@ func sendMailOnce(cfg smtpSettings, to, subject, body string) error { if err != nil { return &smtpStepError{"DATA", err} } - if _, err := wc.Write(buildMessage(fromHeader, toHeader, subject, body, time.Now())); err != nil { + if _, err := wc.Write(buildMessage(fromHeader, toHeader, subject, body, html, time.Now())); err != nil { _ = wc.Close() return &smtpStepError{"DATA", err} } diff --git a/internal/api/mail_test.go b/internal/api/mail_test.go index d50d553..226d895 100644 --- a/internal/api/mail_test.go +++ b/internal/api/mail_test.go @@ -175,13 +175,13 @@ func TestSendMailStartTLSWithSelfSignedCertificate(t *testing.T) { f := startFakeSMTP(t, "") // The certificate is self-signed: refused unless verification is switched off - err := sendMail(f.settings(false), "anna@example.com", "Test", "Hallo") + err := sendMail(f.settings(false), "anna@example.com", "Test", "Hallo", "") var stepErr *smtpStepError if !errors.As(err, &stepErr) || stepErr.Step != "STARTTLS" { t.Fatalf("expected a STARTTLS error with verification on, got %v", err) } - if err := sendMail(f.settings(true), "anna@example.com", "⏰ Aufgabe fällig: Müll", "Zeile 1\nZeile 2 mit Ümlaut"); err != nil { + if err := sendMail(f.settings(true), "anna@example.com", "⏰ Aufgabe fällig: Müll", "Zeile 1\nZeile 2 mit Ümlaut", ""); err != nil { t.Fatalf("send with SkipVerify failed: %v", err) } f.mu.Lock() @@ -206,7 +206,7 @@ func TestSendMailStartTLSWithSelfSignedCertificate(t *testing.T) { func TestSendMailReportsTheRefusingStep(t *testing.T) { f := startFakeSMTP(t, "451 4.3.2 Internal server error") - err := sendMail(f.settings(true), "anna@example.com", "Test", "Hallo") + err := sendMail(f.settings(true), "anna@example.com", "Test", "Hallo", "") var stepErr *smtpStepError if !errors.As(err, &stepErr) || stepErr.Step != "RCPT TO" || !strings.Contains(err.Error(), "451") { t.Fatalf("expected the 451 to be reported at RCPT TO, got %v", err) @@ -218,7 +218,7 @@ func TestSendMailWithDisplayNameSender(t *testing.T) { f := startFakeSMTP(t, "") cfg := f.settings(true) cfg.From = "MindSift Alarme <alarm@firma.de>" - if err := sendMail(cfg, "Anna <anna@example.com>", "Test", "Hallo"); err != nil { + if err := sendMail(cfg, "Anna <anna@example.com>", "Test", "Hallo", ""); err != nil { t.Fatalf("send failed: %v", err) } f.mu.Lock() @@ -244,7 +244,7 @@ func TestAuthFallsBackToLogin(t *testing.T) { f := startFakeSMTP(t, "") f.authMechs = "PLAIN LOGIN" f.authFail["PLAIN"] = "451 4.3.2 Internal server error" - if err := sendMail(f.settings(true), "anna@example.com", "Test", "Hallo"); err != nil { + if err := sendMail(f.settings(true), "anna@example.com", "Test", "Hallo", ""); err != nil { t.Fatalf("LOGIN fallback should succeed: %v", err) } f.mu.Lock() @@ -257,7 +257,7 @@ func TestAuthFallsBackToLogin(t *testing.T) { func TestAuthLoginOnlyAndBothFailing(t *testing.T) { f := startFakeSMTP(t, "") f.authMechs = "LOGIN" - if err := sendMail(f.settings(true), "anna@example.com", "Test", "Hallo"); err != nil { + if err := sendMail(f.settings(true), "anna@example.com", "Test", "Hallo", ""); err != nil { t.Fatalf("LOGIN-only server: %v", err) } @@ -265,7 +265,7 @@ func TestAuthLoginOnlyAndBothFailing(t *testing.T) { g.authMechs = "PLAIN LOGIN" g.authFail["PLAIN"] = "451 4.3.2 Internal server error" g.authFail["LOGIN"] = "451 4.3.2 Internal server error" - err := sendMail(g.settings(true), "anna@example.com", "Test", "Hallo") + err := sendMail(g.settings(true), "anna@example.com", "Test", "Hallo", "") if err == nil || !strings.Contains(err.Error(), "PLAIN") || !strings.Contains(err.Error(), "LOGIN") || !strings.Contains(err.Error(), "SASL") { t.Fatalf("expected both mechanisms and the server hint in the error, got %v", err) } diff --git a/internal/api/notify.go b/internal/api/notify.go new file mode 100644 index 0000000..0960103 --- /dev/null +++ b/internal/api/notify.go @@ -0,0 +1,417 @@ +package api + +import ( + "bytes" + "encoding/json" + "fmt" + "html" + "log/slog" + "net/http" + "sort" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/sandrodach/mindsift/internal/auth" + "github.com/sandrodach/mindsift/internal/domain" +) + +// Notifications besides task alarms: events go into a queue per recipient and are sent bundled, one +// message per recipient and channel once the oldest event is NOTIFY_DIGEST_MINUTES old. Each user picks +// the channels per event kind (own mail address, own Discord webhook from the alarm settings), can +// pause everything and gets a daily summary of due tasks at a chosen time. Own changes never notify the +// one who made them, except on watched tasks when the user asked for it. The demo sends nothing. + +const notifyMaxAttempts = 3 + +// notifyItem is one line of a bundled message. +type notifyItem struct { + kind, title, text string +} + +// notify queues an event for the recipients that want it (settings, pause, own-change rule). +func (s *Server) notify(kind, actorID string, recipients []string, payload map[string]string) { + if s.cfg.DemoMode { + return + } + seen := map[string]bool{} + for _, uid := range recipients { + if uid == "" || seen[uid] { + continue + } + seen[uid] = true + set := s.store.NotificationSettingsOf(uid) + if set.Paused { + continue + } + if uid == actorID && !(kind == "watched" && set.IncludeOwnWatched) { + continue + } + if ch := set.Channels(kind); !ch.Email && !ch.Discord { + continue + } + if err := s.store.QueueNotification(uid, kind, payload); err != nil { + slog.Warn("Benachrichtigung konnte nicht vorgemerkt werden", "user_id", uid, "error", err) + } + } +} + +func (s *Server) actorName(userID string) string { + if u, err := s.store.GetUserByID(userID); err == nil && u != nil && u.DisplayName != "" { + return u.DisplayName + } + return "Jemand" +} + +// notifyShared tells the people a note, folder or list was shared with (teams: all members). +func (s *Server) notifyShared(actorID, targetType, targetID, what, title string) { + var recipients []string + if targetType == "team" { + members, _ := s.store.GetTeamMembers(targetID) + for _, m := range members { + recipients = append(recipients, m.UserID) + } + } else { + recipients = []string{targetID} + } + s.notify("shared", actorID, recipients, map[string]string{ + "title": title, + "text": fmt.Sprintf("%s hat %s „%s“ mit dir geteilt", s.actorName(actorID), what, title), + }) +} + +// notifyTaskChange covers watchers (any change) and list members (task completed). +func (s *Server) notifyTaskChange(actorID string, before, after *domain.Thought, deleted bool) { + t := after + if t == nil { + t = before + } + if t == nil { + return + } + title := strings.TrimSpace(strings.SplitN(t.Content, "\n", 2)[0]) + name := s.actorName(actorID) + var change string + switch { + case deleted: + change = "gelöscht" + case before != nil && after != nil && !before.IsCompleted && after.IsCompleted: + change = "erledigt" + case before != nil && after != nil && before.IsCompleted && !after.IsCompleted: + change = "wieder geöffnet" + case before != nil && after != nil && before.DueDate != after.DueDate: + change = "Fälligkeit geändert auf " + domain.HumanDue(after.DueDate) + case before != nil && after != nil && before.Content != after.Content: + change = "Text geändert" + case before != nil && after != nil && before.Priority != after.Priority: + change = "Priorität geändert auf " + after.Priority + case before != nil && after != nil && before.TaskListID != after.TaskListID: + change = "in eine andere Liste verschoben" + default: + change = "geändert" + } + payload := map[string]string{"title": title, "text": fmt.Sprintf("%s hat „%s“ %s", name, title, change)} + watchers := s.store.TaskWatchers(t.ID) + s.notify("watched", actorID, watchers, payload) + + if change == "erledigt" && t.TaskListID != "" { + watching := map[string]bool{} + for _, w := range watchers { + watching[w] = true + } + var members []string + for _, uid := range s.taskAudience(t) { + if !watching[uid] { // watchers already hear about it + members = append(members, uid) + } + } + s.notify("taskDone", actorID, members, payload) + } + if deleted { + s.store.ForgetTaskWatchers(t.ID) + } +} + +// --- Background jobs --- + +func (s *Server) startNotificationJobs() { + ticker := time.NewTicker(time.Minute) + defer ticker.Stop() + for now := range ticker.C { + s.flushNotifications(now) + s.sendDailySummaries(now) + } +} + +// flushNotifications sends one bundled message per recipient whose oldest event waited long enough. +func (s *Server) flushNotifications(now time.Time) { + minutes := s.cfg.NotifyDigestMinutes + if minutes < 1 { + minutes = 5 + } + window := time.Duration(minutes) * time.Minute + users, err := s.store.DueNotificationUsers(now.Add(-window)) + if err != nil { + slog.Error("Benachrichtigungen konnten nicht gelesen werden", "error", err) + return + } + for _, uid := range users { + queued, err := s.store.QueuedNotifications(uid) + if err != nil || len(queued) == 0 { + continue + } + set := s.store.NotificationSettingsOf(uid) + var emailItems, discordItems []notifyItem + ids := make([]int64, 0, len(queued)) + maxAttempts := 0 + for _, q := range queued { + ids = append(ids, q.ID) + if q.Attempts > maxAttempts { + maxAttempts = q.Attempts + } + if set.Paused { + continue + } + item := notifyItem{kind: q.Kind, title: q.Payload["title"], text: q.Payload["text"]} + ch := set.Channels(q.Kind) + if ch.Email { + emailItems = append(emailItems, item) + } + if ch.Discord { + discordItems = append(discordItems, item) + } + } + if err := s.deliver(uid, "Neuigkeiten", emailItems, discordItems); err != nil { + if maxAttempts+1 >= notifyMaxAttempts { + slog.Warn("Benachrichtigungen nach mehreren Fehlversuchen verworfen", "user_id", uid, "count", len(ids), "error", err) + _ = s.store.DeleteQueuedNotifications(ids) + } else { + _ = s.store.BumpQueuedNotifications(ids) + } + continue + } + _ = s.store.DeleteQueuedNotifications(ids) + } +} + +// deliver sends the items to the user's mail address and/or Discord webhook. +func (s *Server) deliver(userID, heading string, emailItems, discordItems []notifyItem) error { + u, err := s.store.GetUserByID(userID) + if err != nil || u == nil || !u.IsActive { + return nil // account gone: nothing to deliver + } + var firstErr error + if len(emailItems) > 0 { + to := u.AlarmEmail + if to == "" { + to = u.Email + } + subject := "MindSift: " + heading + if len(emailItems) == 1 { + subject = "MindSift: " + emailItems[0].text + } + if err := s.sendNotifyEmail(to, subject, notifyText(heading, emailItems, s.notifyLink()), notifyHTML(heading, emailItems, s.notifyLink())); err != nil { + firstErr = err + } + } + if len(discordItems) > 0 && u.DiscordWebhookURL != "" { + if err := s.sendNotifyDiscord(u.DiscordWebhookURL, heading, discordItems); err != nil && firstErr == nil { + firstErr = err + } + } + return firstErr +} + +// sendNotifyEmail / sendNotifyDiscord are replaced in tests. +func (s *Server) sendNotifyEmail(to, subject, text, htmlBody string) error { + if s.notifyEmailHook != nil { + return s.notifyEmailHook(to, subject, text, htmlBody) + } + return s.sendEmailHTML(to, subject, text, htmlBody) +} + +func (s *Server) sendNotifyDiscord(webhookURL, heading string, items []notifyItem) error { + if s.notifyDiscordHook != nil { + return s.notifyDiscordHook(webhookURL, heading, items) + } + return s.sendDiscordDigest(webhookURL, heading, items) +} + +func (s *Server) notifyLink() string { + if !s.cfg.BaseURLSet { + return "" + } + return strings.TrimRight(s.cfg.BaseURL, "/") + "/" +} + +// sendDiscordDigest posts one embed with a line per item (at most 10). +func (s *Server) sendDiscordDigest(webhookURL, heading string, items []notifyItem) error { + if s.cfg.DemoMode { + return errDemoOutgoing + } + allowPrivate := s.allowPrivateWebhooks() + if err := validateWebhookURL(webhookURL, true, allowPrivate); err != nil { + return err + } + lines := make([]string, 0, 11) + for i, it := range items { + if i == 10 { + lines = append(lines, fmt.Sprintf("… und %d weitere", len(items)-10)) + break + } + lines = append(lines, "• "+it.text) + } + body, _ := json.Marshal(map[string]interface{}{ + "embeds": []map[string]interface{}{{ + "title": "MindSift: " + heading, + "description": strings.Join(lines, "\n"), + "color": 2450411, + }}, + }) + resp, err := guardedClient(allowPrivate).Post(webhookURL, "application/json", bytes.NewReader(body)) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + return fmt.Errorf("HTTP Status %d", resp.StatusCode) + } + return nil +} + +func notifyText(heading string, items []notifyItem, link string) string { + var b strings.Builder + b.WriteString(heading + "\n\n") + for _, it := range items { + b.WriteString("• " + it.text + "\n") + } + if link != "" { + b.WriteString("\nÖffnen: " + link + "\n") + } + b.WriteString("\nDiese Benachrichtigungen stellst du in MindSift unter Kontoeinstellungen → Alarme ein.\n") + return b.String() +} + +// notifyHTML: simple table layout with inline styles, no external resources (works in most clients). +func notifyHTML(heading string, items []notifyItem, link string) string { + var cards strings.Builder + for _, it := range items { + cards.WriteString(`<tr><td style="padding:12px 16px;border:1px solid #e3e6ea;border-radius:8px;background:#ffffff;font-size:14px;color:#1f2328">` + + html.EscapeString(it.text) + `</td></tr><tr><td style="height:8px"></td></tr>`) + } + button := "" + if link != "" { + button = `<tr><td style="padding-top:8px"><a href="` + html.EscapeString(link) + + `" style="display:inline-block;padding:10px 18px;background:#2563eb;color:#ffffff;text-decoration:none;border-radius:6px;font-size:14px">In MindSift öffnen</a></td></tr>` + } + return `<!doctype html><html><body style="margin:0;padding:24px;background:#f4f5f7;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif">` + + `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:560px;margin:0 auto">` + + `<tr><td style="font-size:18px;font-weight:bold;color:#2563eb;padding-bottom:4px">MindSift</td></tr>` + + `<tr><td style="font-size:15px;color:#1f2328;padding-bottom:16px">` + html.EscapeString(heading) + `</td></tr>` + + cards.String() + button + + `<tr><td style="font-size:12px;color:#6b7280;padding-top:20px">Diese Benachrichtigungen stellst du in MindSift unter Kontoeinstellungen → Alarme ein.</td></tr>` + + `</table></body></html>` +} + +// --- Daily summary --- + +// sendDailySummaries sends each user who wants one their summary in the 30 minutes after the chosen +// time, once a day. Nothing due → no message. +func (s *Server) sendDailySummaries(now time.Time) { + day := now.Format("2006-01-02") + for _, uid := range s.store.ActiveUserIDs() { + set := s.store.NotificationSettingsOf(uid) + ds := set.DailySummary + if set.Paused || !ds.Enabled || (!ds.Email && !ds.Discord) { + continue + } + at, err := time.ParseInLocation("2006-01-02 15:04", day+" "+ds.Time, now.Location()) + if err != nil || now.Before(at) || now.Sub(at) >= 30*time.Minute { + continue + } + items := s.summaryItems(uid, now) + if len(items) == 0 || !s.store.ClaimDailySummary(uid, day) { + continue + } + var mail, discord []notifyItem + if ds.Email { + mail = items + } + if ds.Discord { + discord = items + } + if err := s.deliver(uid, "Deine Aufgaben für heute", mail, discord); err != nil { + slog.Warn("Tageszusammenfassung konnte nicht verschickt werden", "user_id", uid, "error", err) + } + } +} + +// summaryItems: overdue, today, tomorrow (with time), plus how many follow in the next 7 days. +func (s *Server) summaryItems(userID string, now time.Time) []notifyItem { + today := now.Format("2006-01-02") + tomorrow := now.AddDate(0, 0, 1).Format("2006-01-02") + tasks, err := s.store.OpenTasksDueBefore(userID, now.AddDate(0, 0, 8).Format("2006-01-02")) + if err != nil { + return nil + } + sort.SliceStable(tasks, func(i, j int) bool { return tasks[i].DueDate < tasks[j].DueDate }) + var items []notifyItem + later := 0 + for _, t := range tasks { + d := t.DueDate + if len(d) > 10 { + d = d[:10] + } + label := t.Title + if t.ListTitle != "" { + label += " (" + t.ListTitle + ")" + } + clock := "" + if len(t.DueDate) >= 16 { + clock = ", " + t.DueDate[11:16] + " Uhr" + } + switch { + case d < today: + items = append(items, notifyItem{kind: "due", title: t.Title, text: "Überfällig seit " + domain.HumanDue(t.DueDate) + ": " + label}) + case d == today: + items = append(items, notifyItem{kind: "due", title: t.Title, text: "Heute" + clock + ": " + label}) + case d == tomorrow: + items = append(items, notifyItem{kind: "due", title: t.Title, text: "Morgen" + clock + ": " + label}) + default: + later++ + } + } + if len(items) > 0 && later > 0 { + items = append(items, notifyItem{kind: "due", text: fmt.Sprintf("Außerdem %d weitere Aufgaben in den nächsten 7 Tagen", later)}) + } + return items +} + +// --- Watching tasks --- + +// handleWatchTask: POST (watch) / DELETE (stop) /tasks/{id}/watch +func (s *Server) handleWatchTask(w http.ResponseWriter, r *http.Request) { + userID := auth.GetUserID(r.Context()) + taskID := chi.URLParam(r, "id") + if _, err := s.store.GetThoughtByID(userID, taskID); err != nil { + writeError(w, http.StatusNotFound, "Aufgabe nicht gefunden") + return + } + on := r.Method == http.MethodPost + if err := s.store.WatchTask(taskID, userID, on); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]bool{"watching": on}) +} + +// markWatching sets IsWatching on tasks for the requesting user. +func (s *Server) markWatching(userID string, thoughts []domain.Thought) { + watched := s.store.WatchedTaskIDs(userID) + if len(watched) == 0 { + return + } + for i := range thoughts { + thoughts[i].IsWatching = watched[thoughts[i].ID] + } +} diff --git a/internal/api/notify_test.go b/internal/api/notify_test.go new file mode 100644 index 0000000..fce410f --- /dev/null +++ b/internal/api/notify_test.go @@ -0,0 +1,160 @@ +package api + +import ( + "net/http" + "strings" + "testing" + "time" + + "github.com/sandrodach/mindsift/internal/domain" +) + +type sentNote struct{ channel, to, subject, text, html string } + +func notifyServer(t *testing.T) (*Server, *[]sentNote) { + t.Helper() + srv, _, _ := setupTestServer(t) + srv.cfg.NotifyDigestMinutes = 5 + var sent []sentNote + srv.notifyEmailHook = func(to, subject, text, html string) error { + sent = append(sent, sentNote{channel: "email", to: to, subject: subject, text: text, html: html}) + return nil + } + srv.notifyDiscordHook = func(url, heading string, items []notifyItem) error { + lines := make([]string, 0, len(items)) + for _, it := range items { + lines = append(lines, it.text) + } + sent = append(sent, sentNote{channel: "discord", to: url, text: strings.Join(lines, "\n")}) + return nil + } + return srv, &sent +} + +func TestWatchedTaskChangesAreBundled(t *testing.T) { + srv, sent := notifyServer(t) + store := srv.store + router := srv.Router() + owner, ownerToken := newTestUser(t, store, "owner@example.com") + watcher, watcherToken := newTestUser(t, store, "watcher@example.com") + list, _ := store.CreateTaskList(owner.ID, "", "", "Haushalt", "🏠", "#000000") + task, _ := store.CreateThoughtWithDetails(owner.ID, "Fenster putzen", "normal", "manual", "", list.ID, "todo", "") + if _, err := store.AddTaskListShare(list.ID, "user", watcher.ID, "editor", owner.ID); err != nil { + t.Fatal(err) + } + if rec := sessionCall(router, "POST", "/api/v1/tasks/"+task.ID+"/watch", watcherToken, "", ""); rec.Code != http.StatusOK { + t.Fatalf("watch: %d %s", rec.Code, rec.Body.String()) + } + _, strangerToken := newTestUser(t, store, "fremd@example.com") + if rec := sessionCall(router, "POST", "/api/v1/tasks/"+task.ID+"/watch", strangerToken, "", ""); rec.Code != http.StatusNotFound { + t.Fatalf("stranger watch: want 404, got %d", rec.Code) + } + + // Two changes by the owner within the window → one bundled mail for the watcher, none for the owner + sessionCall(router, "PATCH", "/api/v1/thoughts/"+task.ID, ownerToken, `{"content":"Fenster putzen (innen)"}`, "") + sessionCall(router, "PATCH", "/api/v1/thoughts/"+task.ID, ownerToken, `{"isCompleted":true}`, "") + srv.flushNotifications(time.Now()) + if len(*sent) != 0 { + t.Fatalf("nothing may go out before the window ends: %+v", *sent) + } + srv.flushNotifications(time.Now().Add(6 * time.Minute)) + var mails []sentNote + for _, n := range *sent { + if n.channel == "email" { + mails = append(mails, n) + } + } + if len(mails) != 1 || mails[0].to != "watcher@example.com" || !strings.Contains(mails[0].text, "Text geändert") || + !strings.Contains(mails[0].text, "erledigt") || !strings.Contains(mails[0].html, "<html") { + t.Fatalf("expected one bundled HTML mail to the watcher, got %+v", *sent) + } + *sent = nil + srv.flushNotifications(time.Now().Add(20 * time.Minute)) + if len(*sent) != 0 { + t.Fatal("queue must be empty after sending") + } +} + +func TestOwnChangesPauseAndChannels(t *testing.T) { + srv, sent := notifyServer(t) + store := srv.store + router := srv.Router() + me, myToken := newTestUser(t, store, "me@example.com") + task, _ := store.CreateThoughtWithDetails(me.ID, "Steuer", "normal", "manual", "", "", "todo", "") + _ = store.WatchTask(task.ID, me.ID, true) + + // Own change on a watched task: nothing … + sessionCall(router, "PATCH", "/api/v1/thoughts/"+task.ID, myToken, `{"priority":"high"}`, "") + srv.flushNotifications(time.Now().Add(10 * time.Minute)) + if len(*sent) != 0 { + t.Fatalf("own change must not notify: %+v", *sent) + } + // … unless asked for, then only over the chosen channel + set := store.NotificationSettingsOf(me.ID) + set.IncludeOwnWatched = true + set.Events.Watched = domain.ChannelChoice{Discord: true} + _ = store.SetNotificationSettings(me.ID, set) + hook := "https://discord.com/api/webhooks/1/x" + _, _ = store.UpdateUserFull(me.ID, nil, nil, nil, nil, nil, nil, &hook, nil, nil, nil) + sessionCall(router, "PATCH", "/api/v1/thoughts/"+task.ID, myToken, `{"priority":"low"}`, "") + srv.flushNotifications(time.Now().Add(10 * time.Minute)) + if len(*sent) != 1 || (*sent)[0].channel != "discord" { + t.Fatalf("expected one Discord message, got %+v", *sent) + } + + // Paused: nothing at all + *sent = nil + set.Paused = true + _ = store.SetNotificationSettings(me.ID, set) + sessionCall(router, "PATCH", "/api/v1/thoughts/"+task.ID, myToken, `{"priority":"urgent"}`, "") + srv.flushNotifications(time.Now().Add(10 * time.Minute)) + if len(*sent) != 0 { + t.Fatalf("paused must send nothing: %+v", *sent) + } + + // Settings round trip over the profile API + rec := sessionCall(router, "PATCH", "/api/v1/auth/settings", myToken, `{"notificationSettings":{"paused":false,"events":{"shared":{"email":false,"discord":true}},"dailySummary":{"enabled":true,"time":"6:5"}}}`, "") + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"time":"07:00"`) || !strings.Contains(rec.Body.String(), `"shared":{"email":false,"discord":true}`) { + t.Fatalf("settings: %d %s", rec.Code, rec.Body.String()) + } +} + +func TestDailySummary(t *testing.T) { + srv, sent := notifyServer(t) + store := srv.store + me, _ := newTestUser(t, store, "summary@example.com") + now := time.Date(2026, 10, 30, 7, 0, 30, 0, time.Local) + set := store.NotificationSettingsOf(me.ID) + set.DailySummary = domain.DailySummary{Enabled: true, Time: "07:00", Email: true} + _ = store.SetNotificationSettings(me.ID, set) + + srv.sendDailySummaries(now) + if len(*sent) != 0 { + t.Fatal("nothing due → no summary") + } + for _, td := range [][2]string{{"Altlast", "2026-10-28"}, {"Zahnarzt", "2026-10-30T15:30"}, {"Müll raus", "2026-10-31"}, {"Später", "2026-11-04"}} { + th, _ := store.CreateThoughtWithDetails(me.ID, td[0], "normal", "manual", td[1], "", "todo", "") + if _, err := store.UpdateThoughtTriage(me.ID, th.ID, "keep", "task", "", td[1], ""); err != nil { // triaged into a task + t.Fatal(err) + } + } + + srv.sendDailySummaries(now.Add(-2 * time.Minute)) // before the chosen time + if len(*sent) != 0 { + t.Fatal("no summary before the chosen time") + } + srv.sendDailySummaries(now) + if len(*sent) != 1 { + t.Fatalf("expected one summary, got %d", len(*sent)) + } + text := (*sent)[0].text + for _, want := range []string{"Überfällig", "Altlast", "Heute, 15:30 Uhr: Zahnarzt", "Morgen: Müll raus", "1 weitere"} { + if !strings.Contains(text, want) { + t.Fatalf("summary lacks %q:\n%s", want, text) + } + } + srv.sendDailySummaries(now.Add(5 * time.Minute)) + if len(*sent) != 1 { + t.Fatal("only one summary per day") + } +} diff --git a/internal/api/server.go b/internal/api/server.go index 75e46a0..ae0f1d1 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -34,6 +34,10 @@ type Server struct { streamTickets streamTickets // one-time tickets for WebSocket/SSE (streamticket.go) loginGuard *loginGuard // brute-force protection (loginguard.go) voice *voiceService // self-hosted speech recognition (voice.go) + + // Replace the delivery of bundled notifications in tests (notify.go) + notifyEmailHook func(to, subject, text, html string) error + notifyDiscordHook func(webhookURL, heading string, items []notifyItem) error } func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS fs.FS) *Server { @@ -62,6 +66,7 @@ func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS } if !cfg.DemoMode { go srv.startAlarmTicker() // the demo sends nothing (FLAW-33) + go srv.startNotificationJobs() go srv.startBackupScheduler() } return srv @@ -136,6 +141,8 @@ func (s *Server) Router() http.Handler { r.Post("/api/v1/documents/{id}/media/fetch", s.notInDemo(s.handleFetchMedia)) r.Get("/api/v1/media/{id}", s.handleGetMedia) r.Post("/api/v1/documents/{id}/extract", s.handleExtractBranch) + r.Post("/api/v1/tasks/{id}/watch", s.handleWatchTask) + r.Delete("/api/v1/tasks/{id}/watch", s.handleWatchTask) r.Post("/api/v1/documents/{id}/extract/undo", s.handleUndoExtract) r.Post("/api/v1/documents/{id}/merge", s.handleMergeDocuments) r.Post("/api/v1/documents/{id}/merge/undo", s.handleUndoMerge) diff --git a/internal/config/config.go b/internal/config/config.go index a6d94db..df0fff1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -51,6 +51,9 @@ type Config struct { // Images and audio in notes (MEDIA_*) Media MediaConfig + + // NotifyDigestMinutes: notifications (except task alarms) are bundled over this many minutes + NotifyDigestMinutes int } // MediaConfig limits uploads of images and audio recordings in notes. Sizes in MB; QuotaMB 0 = no limit @@ -237,6 +240,7 @@ func Load() *Config { PerUserPerHour: envInt("STT_PER_USER_PER_HOUR", 60, 1, 100000), TimeoutSeconds: envInt("STT_TIMEOUT_SECONDS", 120, 5, 3600), }, + NotifyDigestMinutes: envInt("NOTIFY_DIGEST_MINUTES", 5, 1, 1440), Media: MediaConfig{ MaxImageMB: envInt("MEDIA_MAX_IMAGE_MB", 15, 1, 1000), MaxAudioMB: envInt("MEDIA_MAX_AUDIO_MB", 50, 1, 2000), diff --git a/internal/domain/models.go b/internal/domain/models.go index 82dd916..26cbbb1 100644 --- a/internal/domain/models.go +++ b/internal/domain/models.go @@ -135,6 +135,67 @@ type User struct { NotifyBeforeMinutes int `json:"notifyBeforeMinutes"` OrganizationID string `json:"organizationId,omitempty"` CreatedAt string `json:"createdAt"` + // Only filled for the user's own profile (GET /auth/me, PATCH /auth/settings) + NotificationSettings *NotificationSettings `json:"notificationSettings,omitempty"` +} + +// NotificationSettings: which events reach a user over which channel, the daily summary and a pause. +type NotificationSettings struct { + Paused bool `json:"paused"` + IncludeOwnWatched bool `json:"includeOwnWatched"` + Events NotificationEvents `json:"events"` + DailySummary DailySummary `json:"dailySummary"` +} + +type NotificationEvents struct { + Due ChannelChoice `json:"due"` // task alarms (sent at once) + Shared ChannelChoice `json:"shared"` // something was shared with me + TaskDone ChannelChoice `json:"taskDone"` // a shared task was completed + Watched ChannelChoice `json:"watched"` // a watched task changed +} + +type ChannelChoice struct { + Email bool `json:"email"` + Discord bool `json:"discord"` +} + +type DailySummary struct { + Enabled bool `json:"enabled"` + Time string `json:"time"` // HH:MM, server time zone + Email bool `json:"email"` + Discord bool `json:"discord"` +} + +// DefaultNotificationSettings for users who never saved any; task alarms follow the old alarm channels. +func DefaultNotificationSettings(alarmChannels []string) NotificationSettings { + due := ChannelChoice{} + for _, c := range alarmChannels { + due.Email = due.Email || c == "email" + due.Discord = due.Discord || c == "discord" + } + return NotificationSettings{ + Events: NotificationEvents{ + Due: due, + Shared: ChannelChoice{Email: true}, + Watched: ChannelChoice{Email: true, Discord: true}, + }, + DailySummary: DailySummary{Time: "07:00", Email: true}, + } +} + +// Channels returns the choice for an event kind (due, shared, taskDone, watched). +func (n NotificationSettings) Channels(kind string) ChannelChoice { + switch kind { + case "due": + return n.Events.Due + case "shared": + return n.Events.Shared + case "taskDone": + return n.Events.TaskDone + case "watched": + return n.Events.Watched + } + return ChannelChoice{} } type UserInvite struct { @@ -167,6 +228,8 @@ type SubTask struct { } type Thought struct { + // IsWatching: the requesting user gets notified about changes (task watchers) + IsWatching bool `json:"isWatching,omitempty"` ID string `json:"id"` UserID string `json:"userId"` Content string `json:"content"` diff --git a/internal/storage/migrations.go b/internal/storage/migrations.go index 7da8890..070d69b 100644 --- a/internal/storage/migrations.go +++ b/internal/storage/migrations.go @@ -27,6 +27,7 @@ var migrations = []migration{ {4, "Sitzungen", (*Store).migrateSessions}, {5, "Medien", (*Store).migrateMedia}, {6, "Link-Vorschauen", (*Store).migrateLinkPreviews}, + {7, "Benachrichtigungen", (*Store).migrateNotifications}, } func (s *Store) migrate() error { diff --git a/internal/storage/notifications.go b/internal/storage/notifications.go new file mode 100644 index 0000000..ce6c5ee --- /dev/null +++ b/internal/storage/notifications.go @@ -0,0 +1,253 @@ +package storage + +import ( + "encoding/json" + "strings" + "time" + + "github.com/sandrodach/mindsift/internal/domain" +) + +// Notifications: per-user settings (JSON on the user), a queue that is sent bundled every few minutes, +// task watchers and the bookkeeping for the daily summary. + +func (s *Store) migrateNotifications() error { + if err := s.addColumnIfMissing("users", "notification_settings", "TEXT NOT NULL DEFAULT ''"); err != nil { + return err + } + if err := s.addColumnIfMissing("users", "last_daily_summary", "TEXT NOT NULL DEFAULT ''"); err != nil { + return err + } + return s.execInTx( + stmt(`CREATE TABLE IF NOT EXISTS notification_queue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + kind TEXT NOT NULL, + payload TEXT NOT NULL, + created_at TEXT NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0 + )`), + stmt(`CREATE INDEX IF NOT EXISTS idx_notification_queue_user ON notification_queue(user_id, created_at)`), + stmt(`CREATE TABLE IF NOT EXISTS task_watchers ( + task_id TEXT NOT NULL, + user_id TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (task_id, user_id) + )`), + ) +} + +// NotificationSettingsOf returns the user's settings, defaults filled in for users who never saved any. +func (s *Store) NotificationSettingsOf(userID string) domain.NotificationSettings { + var raw, channels string + _ = s.db.QueryRow(`SELECT notification_settings, alarm_channels FROM users WHERE id = ?`, userID).Scan(&raw, &channels) + out := domain.DefaultNotificationSettings(parseAlarmChannels(channels)) + if raw != "" { + _ = json.Unmarshal([]byte(raw), &out) + } + if !validClock(out.DailySummary.Time) { + out.DailySummary.Time = "07:00" + } + return out +} + +func validClock(v string) bool { + t, err := time.Parse("15:04", v) + return err == nil && t.Format("15:04") == v +} + +func (s *Store) SetNotificationSettings(userID string, n domain.NotificationSettings) error { + if !validClock(n.DailySummary.Time) { + n.DailySummary.Time = "07:00" + } + raw, _ := json.Marshal(n) + _, err := s.db.Exec(`UPDATE users SET notification_settings = ? WHERE id = ?`, string(raw), userID) + return err +} + +// QueuedNotification is one event waiting to be sent bundled. +type QueuedNotification struct { + ID int64 + UserID string + Kind string + Payload map[string]string + CreatedAt string + Attempts int +} + +func (s *Store) QueueNotification(userID, kind string, payload map[string]string) error { + raw, _ := json.Marshal(payload) + _, err := s.db.Exec(`INSERT INTO notification_queue (user_id, kind, payload, created_at) VALUES (?, ?, ?, ?)`, + userID, kind, string(raw), time.Now().UTC().Format(time.RFC3339)) + return err +} + +// DueNotificationUsers lists users whose oldest waiting event is older than the bundling window. +func (s *Store) DueNotificationUsers(olderThan time.Time) ([]string, error) { + rows, err := s.db.Query(`SELECT user_id FROM notification_queue GROUP BY user_id HAVING MIN(created_at) <= ?`, + olderThan.UTC().Format(time.RFC3339)) + if err != nil { + return nil, err + } + defer rows.Close() + var out []string + for rows.Next() { + var id string + if rows.Scan(&id) == nil { + out = append(out, id) + } + } + return out, rows.Err() +} + +func (s *Store) QueuedNotifications(userID string) ([]QueuedNotification, error) { + rows, err := s.db.Query(`SELECT id, user_id, kind, payload, created_at, attempts FROM notification_queue WHERE user_id = ? ORDER BY id`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []QueuedNotification + for rows.Next() { + var q QueuedNotification + var raw string + if rows.Scan(&q.ID, &q.UserID, &q.Kind, &raw, &q.CreatedAt, &q.Attempts) == nil { + _ = json.Unmarshal([]byte(raw), &q.Payload) + out = append(out, q) + } + } + return out, rows.Err() +} + +func (s *Store) DeleteQueuedNotifications(ids []int64) error { + for _, id := range ids { + if _, err := s.db.Exec(`DELETE FROM notification_queue WHERE id = ?`, id); err != nil { + return err + } + } + return nil +} + +func (s *Store) BumpQueuedNotifications(ids []int64) error { + for _, id := range ids { + if _, err := s.db.Exec(`UPDATE notification_queue SET attempts = attempts + 1 WHERE id = ?`, id); err != nil { + return err + } + } + return nil +} + +// --- Task watchers --- + +func (s *Store) WatchTask(taskID, userID string, on bool) error { + if on { + _, err := s.db.Exec(`INSERT OR IGNORE INTO task_watchers (task_id, user_id, created_at) VALUES (?, ?, ?)`, taskID, userID, nowUTC()) + return err + } + _, err := s.db.Exec(`DELETE FROM task_watchers WHERE task_id = ? AND user_id = ?`, taskID, userID) + return err +} + +func (s *Store) TaskWatchers(taskID string) []string { + rows, err := s.db.Query(`SELECT user_id FROM task_watchers WHERE task_id = ?`, taskID) + if err != nil { + return nil + } + defer rows.Close() + var out []string + for rows.Next() { + var id string + if rows.Scan(&id) == nil { + out = append(out, id) + } + } + return out +} + +// WatchedTaskIDs is the set of tasks the user watches (to mark them in task lists). +func (s *Store) WatchedTaskIDs(userID string) map[string]bool { + out := map[string]bool{} + rows, err := s.db.Query(`SELECT task_id FROM task_watchers WHERE user_id = ?`, userID) + if err != nil { + return out + } + defer rows.Close() + for rows.Next() { + var id string + if rows.Scan(&id) == nil { + out[id] = true + } + } + return out +} + +func (s *Store) ForgetTaskWatchers(taskID string) { + _, _ = s.db.Exec(`DELETE FROM task_watchers WHERE task_id = ?`, taskID) +} + +// --- Daily summary --- + +// SummaryTask is an open task with a due date, for the daily summary. +type SummaryTask struct { + Title, DueDate, ListTitle string +} + +// ActiveUserIDs lists all active accounts (the summary job checks their settings). +func (s *Store) ActiveUserIDs() []string { + rows, err := s.db.Query(`SELECT id FROM users WHERE is_active = 1`) + if err != nil { + return nil + } + defer rows.Close() + var out []string + for rows.Next() { + var id string + if rows.Scan(&id) == nil { + out = append(out, id) + } + } + return out +} + +// OpenTasksDueBefore lists the user's open tasks (own ones and those of shared task lists) due before +// the given day (YYYY-MM-DD, exclusive), oldest first. +func (s *Store) OpenTasksDueBefore(userID, beforeDay string) ([]SummaryTask, error) { + rows, err := s.db.Query(`SELECT t.content, t.due_date, COALESCE(l.title, '') + FROM thoughts t LEFT JOIN task_lists l ON l.id = t.task_list_id + WHERE (t.status = 'kept' OR (t.status = 'converted' AND t.converted_type = 'task')) + AND t.is_completed = 0 AND t.due_date != '' AND substr(t.due_date, 1, 10) < ? + AND (t.user_id = ? OR (t.task_list_id != '' AND t.task_list_id IN (SELECT task_list_id FROM task_list_shares WHERE target_type = 'user' AND target_id = ?))) + ORDER BY t.due_date`, beforeDay, userID, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []SummaryTask + for rows.Next() { + var t SummaryTask + if rows.Scan(&t.Title, &t.DueDate, &t.ListTitle) == nil { + t.Title = strings.TrimSpace(strings.SplitN(t.Title, "\n", 2)[0]) + out = append(out, t) + } + } + return out, rows.Err() +} + +// ClaimDailySummary marks today's summary as sent; false when it was already sent today. +func (s *Store) ClaimDailySummary(userID, day string) bool { + res, err := s.db.Exec(`UPDATE users SET last_daily_summary = ? WHERE id = ? AND last_daily_summary != ?`, day, userID, day) + if err != nil { + return false + } + n, _ := res.RowsAffected() + return n == 1 +} + +// SharedFolderName is the display name of a shared workspace folder. +func (s *Store) SharedFolderName(folderID string) string { + var name string + _ = s.db.QueryRow(`SELECT name FROM workspace_shared_folders WHERE id = ?`, folderID).Scan(&name) + if name == "" { + name = "Ordner" + } + return name +}