feat(notify): bundled notifications, task watchers, daily summary
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m4s

- 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 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-30 09:49:13 +01:00
1 parent 362dd3b4c6
commit a69bcbaf10
22 files changed
+1018 -21

No files matched your search

+27 -1
View File
@@ -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
}
+12
View File
@@ -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)
}
+11
View File
@@ -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"
}
+1
View File
@@ -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 {
+3
View File
@@ -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 {
+3
View File
@@ -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)
}
+6
View File
@@ -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})
}
+26 -12
View File
@@ -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}
}
+7 -7
View File
@@ -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)
}
+417
View File
@@ -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]
}
}
+160
View File
@@ -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")
}
}
+7
View File
@@ -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)