feat(notify): web push as a notification channel
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m15s

- VAPID keys from VAPID_* or generated once (push_keys, migration 9);
  devices in push_subscriptions, forgotten on 404/410 from the push service
- push column for every event (also due tasks) and the daily summary,
  bundles as one notification; /push/key, /devices, /subscribe,
  /unsubscribe, /test
- settings: switch push on per device, test it, see the devices; the
  service worker shows notifications and focuses MindSift on click
- tests decrypt a sent push like a browser (RFC 8291) and check VAPID

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-30 21:21:55 +01:00
1 parent 1a49e6b304
commit 38b8c92899
27 files changed
+918 -27

No files matched your search

+30 -3
View File
@@ -190,7 +190,7 @@ func (s *Server) flushNotifications(now time.Time) {
// channelItems collects what goes out on each channel.
type channelItems struct {
email, discord, webhook []notifyItem
email, discord, webhook, push []notifyItem
}
func (c *channelItems) add(ch domain.ChannelChoice, items ...notifyItem) {
@@ -203,6 +203,9 @@ func (c *channelItems) add(ch domain.ChannelChoice, items ...notifyItem) {
if ch.Webhook {
c.webhook = append(c.webhook, items...)
}
if ch.Push {
c.push = append(c.push, items...)
}
}
// deliver sends the items to the user's mail address, Discord webhook and/or own webhook.
@@ -236,6 +239,11 @@ func (s *Server) deliver(userID, heading string, out channelItems) error {
firstErr = err
}
}
if len(out.push) > 0 {
if err := s.sendPush(userID, pushDigest(heading, out.push, s.notifyLink())); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
@@ -254,6 +262,25 @@ func (s *Server) sendNotifyDiscord(webhookURL, heading string, items []notifyIte
return s.sendDiscordDigest(webhookURL, heading, items)
}
// pushDigest: one notification per bundle; a single event is its own title
func pushDigest(heading string, items []notifyItem, link string) pushMessage {
if link == "" {
link = "/"
}
if len(items) == 1 {
return pushMessage{Title: "MindSift", Body: items[0].text, URL: link, Tag: "digest"}
}
lines := make([]string, 0, 5)
for i, it := range items {
if i == 4 {
lines = append(lines, fmt.Sprintf("… und %d weitere", len(items)-4))
break
}
lines = append(lines, "• "+it.text)
}
return pushMessage{Title: "MindSift: " + heading, Body: strings.Join(lines, "\n"), URL: link, Tag: "digest"}
}
func (s *Server) sendNotifyWebhook(webhookURL, heading string, items []notifyItem) error {
if s.notifyWebhookHook != nil {
return s.notifyWebhookHook(webhookURL, heading, items)
@@ -377,7 +404,7 @@ func (s *Server) sendDailySummaries(now time.Time) {
for _, uid := range s.store.ActiveUserIDs() {
set := s.store.NotificationSettingsOf(uid)
ds := set.DailySummary
if set.Paused || !ds.Enabled || (!ds.Email && !ds.Discord && !ds.Webhook) {
if set.Paused || !ds.Enabled || (!ds.Email && !ds.Discord && !ds.Webhook && !ds.Push) {
continue
}
at, err := time.ParseInLocation("2006-01-02 15:04", day+" "+ds.Time, now.Location())
@@ -389,7 +416,7 @@ func (s *Server) sendDailySummaries(now time.Time) {
continue
}
var out channelItems
out.add(domain.ChannelChoice{Email: ds.Email, Discord: ds.Discord, Webhook: ds.Webhook}, items...)
out.add(domain.ChannelChoice{Email: ds.Email, Discord: ds.Discord, Webhook: ds.Webhook, Push: ds.Push}, items...)
if err := s.deliver(uid, "Deine Aufgaben für heute", out); err != nil {
slog.Warn("Tageszusammenfassung konnte nicht verschickt werden", "user_id", uid, "error", err)
}