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>
202 lines
5.7 KiB
Go
202 lines
5.7 KiB
Go
package api
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"fmt"
|
||
"log/slog"
|
||
"time"
|
||
|
||
"github.com/sandrodach/mindsift/internal/domain"
|
||
)
|
||
|
||
// --- 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
|
||
}
|
||
cfg, err := s.smtpSettings()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := sendMail(cfg, toEmail, subject, bodyText, html); err != nil {
|
||
step := ""
|
||
if se, ok := err.(*smtpStepError); ok {
|
||
step = se.Step
|
||
}
|
||
slog.Warn("E-Mail-Versand fehlgeschlagen", "smtp_step", step, "host", cfg.Host, "port", cfg.Port, "to", toEmail, "error", err)
|
||
return err
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *Server) sendDiscordNotification(webhookURL, title, description, dueDate, priority string) error {
|
||
if s.cfg.DemoMode {
|
||
return errDemoOutgoing
|
||
}
|
||
allowPrivate := s.allowPrivateWebhooks()
|
||
if err := validateWebhookURL(webhookURL, true, allowPrivate); err != nil {
|
||
return err
|
||
}
|
||
httpClient := guardedClient(allowPrivate)
|
||
payload := map[string]interface{}{
|
||
"content": "⏰ **MindSift Aufgaben-Alarm**",
|
||
"embeds": []map[string]interface{}{
|
||
{
|
||
"title": title,
|
||
"description": fmt.Sprintf("%s\n\n**Fällig:** %s | **Priorität:** %s", description, dueDate, priority),
|
||
"color": 2450411,
|
||
"footer": map[string]string{
|
||
"text": "MindSift Alarm-System",
|
||
},
|
||
},
|
||
},
|
||
}
|
||
body, err := json.Marshal(payload)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
resp, err := httpClient.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 (s *Server) sendCustomWebhookNotification(webhookURL, event, title, notes, dueDate, priority string) error {
|
||
if s.cfg.DemoMode {
|
||
return errDemoOutgoing
|
||
}
|
||
allowPrivate := s.allowPrivateWebhooks()
|
||
if err := validateWebhookURL(webhookURL, false, allowPrivate); err != nil {
|
||
return err
|
||
}
|
||
httpClient := guardedClient(allowPrivate)
|
||
payload := map[string]interface{}{
|
||
"event": event,
|
||
"title": title,
|
||
"notes": notes,
|
||
"dueDate": dueDate,
|
||
"priority": priority,
|
||
"timestamp": domain.NowISO(),
|
||
}
|
||
body, err := json.Marshal(payload)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
resp, err := httpClient.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
|
||
}
|
||
|
||
// alarmDue reports whether the alarm for a due date should go out now: from the due time (09:00 for
|
||
// all-day tasks) minus the user's lead time on. Unreadable dates never fire.
|
||
func alarmDue(dueDate string, notifyBeforeMinutes int, now time.Time) bool {
|
||
at, ok := domain.DueAlarmAt(dueDate, notifyBeforeMinutes, now.Location())
|
||
return ok && !now.Before(at)
|
||
}
|
||
|
||
func (s *Server) startAlarmTicker() {
|
||
ticker := time.NewTicker(60 * time.Second)
|
||
defer ticker.Stop()
|
||
|
||
for range ticker.C {
|
||
alarms, err := s.store.GetDueTasksForAlarm()
|
||
if err != nil || len(alarms) == 0 {
|
||
continue
|
||
}
|
||
|
||
now := time.Now()
|
||
|
||
for _, a := range alarms {
|
||
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")
|
||
}
|
||
if set.Events.Due.Webhook {
|
||
channels = append(channels, "webhook")
|
||
}
|
||
if set.Events.Due.Push {
|
||
channels = append(channels, "push")
|
||
}
|
||
if a.AlarmEmail == "" {
|
||
a.AlarmEmail = a.UserEmail
|
||
}
|
||
a.AlarmChannels = channels
|
||
if len(a.AlarmChannels) == 0 {
|
||
continue
|
||
}
|
||
|
||
sentAny := false
|
||
due := domain.HumanDue(a.DueDate)
|
||
desc := a.Notes
|
||
if desc == "" {
|
||
desc = "Aufgabe ist jetzt fällig."
|
||
}
|
||
|
||
for _, ch := range a.AlarmChannels {
|
||
switch ch {
|
||
case "discord":
|
||
if a.DiscordWebhookURL != "" {
|
||
if err := s.sendDiscordNotification(a.DiscordWebhookURL, a.Title, desc, due, a.Priority); err == nil {
|
||
sentAny = true
|
||
}
|
||
}
|
||
case "push":
|
||
if err := s.sendPush(a.UserID, pushMessage{Title: "⏰ Aufgabe fällig", Body: a.Title + " – " + due, URL: "/", Tag: "task-" + a.TaskID}); err == nil {
|
||
sentAny = true
|
||
}
|
||
case "webhook":
|
||
if a.CustomWebhookURL != "" {
|
||
if err := s.sendCustomWebhookNotification(a.CustomWebhookURL, "task_due", a.Title, desc, a.DueDate, a.Priority); err == nil {
|
||
sentAny = true
|
||
}
|
||
}
|
||
case "email":
|
||
if a.AlarmEmail != "" {
|
||
emailBody := fmt.Sprintf("Hallo,\n\ndeine Aufgabe in MindSift ist fällig:\n\nAufgabe: %s\nFällig am: %s\nPriorität: %s\nNotizen: %s\n", a.Title, due, a.Priority, desc)
|
||
if err := s.sendEmailNotification(a.AlarmEmail, "⏰ Aufgabe fällig: "+a.Title, emailBody); err == nil {
|
||
sentAny = true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if sentAny {
|
||
if err := s.store.MarkTaskAlarmSent(a.TaskID); err != nil {
|
||
slog.Error("Alarm als gesendet markieren fehlgeschlagen (wird erneut gesendet)", "task_id", a.TaskID, "error", err)
|
||
}
|
||
} else if gaveUp, _ := s.store.RecordTaskAlarmFailure(a.TaskID); gaveUp {
|
||
slog.Warn("Aufgaben-Alarm nach 3 Fehlversuchen aufgegeben", "task_id", a.TaskID, "title", a.Title)
|
||
}
|
||
}
|
||
}
|
||
}
|