Merge dev into main (v0.18.0)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 26s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 26s
This commit is contained in:
commit
ed1a5e2f9d
16 files changed
+475
-60
No files matched your search
+9
-1
@@ -6,6 +6,13 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.18.0] - 2026-10-31
|
||||
|
||||
### Changed
|
||||
- E-Mails haben ein einheitliches Aussehen im Stil der App: Logo mittig oben, darunter eine Karte mit Begrüßung („Guten Morgen, …“ je nach Tageszeit), dem eigentlichen Inhalt und einem Knopf zur App, unten der Hinweis, warum die E-Mail kommt. Das Logo steckt in der E-Mail selbst (erscheint also auch ohne nachgeladene Bilder und ohne `BASE_URL`); jede E-Mail hat zusätzlich eine gleich aufgebaute Textfassung.
|
||||
- Tageszusammenfassung: Aufgaben stehen in Abschnitten „Überfällig“, „Heute“ und „Morgen“, mit Uhrzeit und Liste darunter.
|
||||
- Fälligkeits-Alarm, Neuigkeiten, Passwort zurücksetzen, Kontoänderung (mit hervorgehobenem Sicherheitshinweis), Test-Alarm und SMTP-Test nutzen dasselbe Layout; bisher kamen die meisten davon als reiner Text.
|
||||
|
||||
## [0.17.4] - 2026-10-31
|
||||
|
||||
### Fixed
|
||||
@@ -423,7 +430,8 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
||||
- Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken.
|
||||
- Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung.
|
||||
|
||||
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.17.4...dev
|
||||
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.18.0...dev
|
||||
[0.18.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.17.4...v0.18.0
|
||||
[0.17.4]: https://git.dsit.me/sandrodach/MindSift/compare/v0.17.3...v0.17.4
|
||||
[0.17.3]: https://git.dsit.me/sandrodach/MindSift/compare/v0.17.2...v0.17.3
|
||||
[0.17.2]: https://git.dsit.me/sandrodach/MindSift/compare/v0.17.1...v0.17.2
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
[](#)
|
||||
[](#-schnellstart)
|
||||
[](#-deine-daten-gehören-dir)
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
|
||||
**MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server.
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mindsift-frontend",
|
||||
"version": "0.17.4",
|
||||
"version": "0.18.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mindsift-frontend",
|
||||
"version": "0.17.4",
|
||||
"version": "0.18.0",
|
||||
"license": "Unlicense",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "5.3.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "mindsift-frontend",
|
||||
"private": true,
|
||||
"version": "0.17.4",
|
||||
"version": "0.18.0",
|
||||
"license": "Unlicense",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
+54
-4
@@ -8,12 +8,63 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/domain"
|
||||
"github.com/sandrodach/mindsift/internal/storage"
|
||||
)
|
||||
|
||||
// --- MULTI-CHANNEL ALARM DISPATCHER (EMAIL, DISCORD, CUSTOM WEBHOOK) ---
|
||||
|
||||
func (s *Server) sendEmailNotification(toEmail, subject, bodyText string) error {
|
||||
return s.sendEmailHTML(toEmail, subject, bodyText, "")
|
||||
// sendMailContent sends a mail in the common MindSift look (mailtemplate.go), as text and HTML.
|
||||
func (s *Server) sendMailContent(toEmail, subject string, c mailContent) error {
|
||||
text, htmlBody := renderMail(c)
|
||||
return s.sendEmailHTML(toEmail, subject, text, htmlBody)
|
||||
}
|
||||
|
||||
// Footers: why a mail came
|
||||
const (
|
||||
mailFooterAlarms = "Du bekommst diese E-Mail, weil du E-Mail-Benachrichtigungen eingeschaltet hast. Ändern kannst du das in MindSift unter Kontoeinstellungen → Alarme."
|
||||
mailFooterAccount = "Diese E-Mail betrifft dein MindSift-Konto und kommt deshalb unabhängig von deinen Benachrichtigungen."
|
||||
)
|
||||
|
||||
// priorityLabel / priorityTone: how a task's priority reads and is coloured in mails
|
||||
func priorityLabel(p string) string {
|
||||
switch p {
|
||||
case "urgent":
|
||||
return "Dringend"
|
||||
case "high":
|
||||
return "Wichtig"
|
||||
case "low":
|
||||
return "Niedrig"
|
||||
}
|
||||
return "Normal"
|
||||
}
|
||||
|
||||
func priorityTone(p string) string {
|
||||
switch p {
|
||||
case "urgent":
|
||||
return "danger"
|
||||
case "high":
|
||||
return "warning"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// dueAlarmMail: one task is due now.
|
||||
func (s *Server) dueAlarmMail(a storage.DueTaskAlarm, due string) mailContent {
|
||||
meta := "Fällig: " + due
|
||||
if a.Priority != "" && a.Priority != "normal" {
|
||||
meta += " · " + priorityLabel(a.Priority)
|
||||
}
|
||||
c := mailContent{
|
||||
Preheader: "Fällig: " + a.Title,
|
||||
Greeting: mailGreeting(time.Now(), a.UserName),
|
||||
Intro: "Eine deiner Aufgaben ist jetzt fällig:",
|
||||
Sections: []mailSection{{Items: []mailItem{{Title: a.Title, Meta: meta, Body: a.Notes, Tone: priorityTone(a.Priority)}}}},
|
||||
Footer: mailFooterAlarms,
|
||||
}
|
||||
if link := s.notifyLink(); link != "" {
|
||||
c.Button = &mailButton{Label: "In MindSift öffnen", URL: link}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// sendEmailHTML sends text plus an optional HTML version (multipart/alternative).
|
||||
@@ -181,8 +232,7 @@ func (s *Server) startAlarmTicker() {
|
||||
}
|
||||
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 {
|
||||
if err := s.sendMailContent(a.AlarmEmail, "⏰ Fällig: "+a.Title, s.dueAlarmMail(a, due)); err == nil {
|
||||
sentAny = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,7 +359,12 @@ func (s *Server) handleAdminTestSMTP(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.sendEmailNotification(toEmail, "MindSift SMTP-Server Test", "Die SMTP-Konfiguration deiner MindSift-Instanz funktioniert einwandfrei!"); err != nil {
|
||||
if err := s.sendMailContent(toEmail, "MindSift: SMTP-Test", mailContent{
|
||||
Preheader: "Die SMTP-Konfiguration funktioniert",
|
||||
Greeting: "Es funktioniert!",
|
||||
Intro: "Die SMTP-Konfiguration deiner MindSift-Instanz ist richtig eingerichtet: Alarme, Tageszusammenfassungen und Passwort-Links kommen an.",
|
||||
Footer: "Diese Test-E-Mail wurde in den Administratoreinstellungen ausgelöst.",
|
||||
}); err != nil {
|
||||
writeError(w, http.StatusBadGateway, "SMTP-Fehler: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -577,10 +577,18 @@ func (s *Server) notifyAccountChange(before *domain.User, password, email bool,
|
||||
if email {
|
||||
what = append(what, "die E-Mail-Adresse deines Kontos wurde auf "+newEmail+" geändert")
|
||||
}
|
||||
body := fmt.Sprintf("Hallo %s,\n\nin MindSift %s.\n\nWarst du das nicht, setze bitte sofort dein Passwort zurück und beende alle Sitzungen in den Kontoeinstellungen, oder wende dich an die Administration.\n",
|
||||
before.DisplayName, strings.Join(what, " und "))
|
||||
c := mailContent{
|
||||
Preheader: "Änderung an deinem MindSift-Konto",
|
||||
Greeting: mailGreeting(time.Now(), before.DisplayName),
|
||||
Intro: "In MindSift " + strings.Join(what, " und ") + ".",
|
||||
Notice: "Warst du das nicht? Setze bitte sofort dein Passwort zurück und beende alle Sitzungen in den Kontoeinstellungen, oder wende dich an die Administration.",
|
||||
Footer: mailFooterAccount,
|
||||
}
|
||||
if link := s.notifyLink(); link != "" {
|
||||
c.Button = &mailButton{Label: "MindSift öffnen", URL: link}
|
||||
}
|
||||
go func() {
|
||||
_ = s.sendEmailNotification(before.Email, "MindSift: Änderung an deinem Konto", body)
|
||||
_ = s.sendMailContent(before.Email, "MindSift: Änderung an deinem Konto", c)
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -639,7 +647,13 @@ func (s *Server) handleTestUserAlarm(w http.ResponseWriter, r *http.Request) {
|
||||
if toEmail == "" {
|
||||
toEmail = user.Email
|
||||
}
|
||||
if err := s.sendEmailNotification(toEmail, "⏰ MindSift Test-Alarm", "Hallo "+user.DisplayName+",\n\ndein E-Mail-Alarmkanal in MindSift ist erfolgreich eingerichtet und einsatzbereit.\n\nViele Grüße\nDeine MindSift Instanz"); err != nil {
|
||||
if err := s.sendMailContent(toEmail, "⏰ MindSift Test-Alarm", mailContent{
|
||||
Preheader: "Dein E-Mail-Alarmkanal funktioniert",
|
||||
Greeting: mailGreeting(time.Now(), user.DisplayName),
|
||||
Intro: "Dein E-Mail-Alarmkanal in MindSift ist eingerichtet und einsatzbereit. So sehen deine Alarme aus:",
|
||||
Sections: []mailSection{{Items: []mailItem{{Title: "Beispiel: Heizung entlüften", Meta: "Fällig: heute, 18:00 Uhr · Wichtig", Tone: "warning"}}}},
|
||||
Footer: mailFooterAlarms,
|
||||
}); err != nil {
|
||||
writeError(w, http.StatusBadGateway, "E-Mail-Versand fehlgeschlagen: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
+22
-1
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -147,7 +148,27 @@ func buildMessage(from, to, subject, body, html string, now time.Time) []byte {
|
||||
fmt.Fprintf(&buf, "--%s\r\n", boundary)
|
||||
part("text/plain", body)
|
||||
fmt.Fprintf(&buf, "--%s\r\n", boundary)
|
||||
part("text/html", html)
|
||||
if strings.Contains(html, "cid:"+mailLogoCID) {
|
||||
// The HTML shows the logo from inside the mail: HTML and image together as multipart/related
|
||||
related := boundary + "-rel"
|
||||
fmt.Fprintf(&buf, "Content-Type: multipart/related; boundary=\"%s\"\r\n\r\n", related)
|
||||
fmt.Fprintf(&buf, "--%s\r\n", related)
|
||||
part("text/html", html)
|
||||
fmt.Fprintf(&buf, "--%s\r\n", related)
|
||||
buf.WriteString("Content-Type: image/png; name=\"mindsift.png\"\r\n")
|
||||
buf.WriteString("Content-Transfer-Encoding: base64\r\n")
|
||||
fmt.Fprintf(&buf, "Content-ID: <%s>\r\n", mailLogoCID)
|
||||
buf.WriteString("Content-Disposition: inline; filename=\"mindsift.png\"\r\n\r\n")
|
||||
enc := base64.StdEncoding.EncodeToString(mailLogoPNG)
|
||||
for len(enc) > 76 {
|
||||
buf.WriteString(enc[:76] + "\r\n")
|
||||
enc = enc[76:]
|
||||
}
|
||||
buf.WriteString(enc + "\r\n")
|
||||
fmt.Fprintf(&buf, "--%s--\r\n", related)
|
||||
} else {
|
||||
part("text/html", html)
|
||||
}
|
||||
fmt.Fprintf(&buf, "--%s--\r\n", boundary)
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
@@ -284,3 +284,30 @@ func TestSenderAddressGetsDisplayName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HTML mails carry the logo as an inline part (multipart/related), plain mails stay single-part
|
||||
func TestMailLayoutEmbedsLogo(t *testing.T) {
|
||||
text, htmlBody := renderMail(mailContent{
|
||||
Greeting: "Hallo Anna",
|
||||
Intro: "Eine Aufgabe ist fällig <jetzt>:",
|
||||
Sections: []mailSection{{Title: "Heute", Items: []mailItem{{Title: "Zahnarzt & Co", Meta: "15:30 Uhr"}}}},
|
||||
Button: &mailButton{Label: "Öffnen", URL: "https://mindsift.example/"},
|
||||
Footer: "Fußzeile",
|
||||
})
|
||||
if !strings.Contains(text, "HEUTE\n• Zahnarzt & Co (15:30 Uhr)") || !strings.Contains(text, "Öffnen: https://mindsift.example/") {
|
||||
t.Fatalf("text version wrong:\n%s", text)
|
||||
}
|
||||
if !strings.Contains(htmlBody, "Zahnarzt & Co") || !strings.Contains(htmlBody, "<jetzt>") {
|
||||
t.Fatalf("HTML must escape content")
|
||||
}
|
||||
msg := string(buildMessage("MindSift <a@example.com>", "b@example.com", "Test", text, htmlBody, time.Now()))
|
||||
for _, want := range []string{"multipart/alternative", "multipart/related", "Content-ID: <" + mailLogoCID + ">", "Content-Type: image/png"} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Fatalf("message lacks %q", want)
|
||||
}
|
||||
}
|
||||
plain := string(buildMessage("a@example.com", "b@example.com", "Test", "nur Text", "", time.Now()))
|
||||
if strings.Contains(plain, "multipart") || strings.Contains(plain, "image/png") {
|
||||
t.Fatalf("plain mail must stay single-part")
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.0 KiB |
@@ -0,0 +1,223 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"html"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// One look for every mail MindSift sends: logo centred on top, a white card with greeting, intro and
|
||||
// the actual content, an optional button, and a footer saying why the mail came. Colours follow the
|
||||
// app's light "Klassik" theme. Mail clients ignore <style> blocks and external CSS, so everything is
|
||||
// inline and laid out with tables. The logo travels inside the mail (cid:), so it shows without
|
||||
// loading remote images and without BASE_URL.
|
||||
|
||||
//go:embed maillogo.png
|
||||
var mailLogoPNG []byte
|
||||
|
||||
const mailLogoCID = "logo@mindsift"
|
||||
|
||||
// Theme colours (frontend/src/themes/theme-light.css), as solid values: many clients drop rgba()
|
||||
const (
|
||||
mailBg = "#f3f4f6"
|
||||
mailPanel = "#ffffff"
|
||||
mailBorder = "#dcdfe4"
|
||||
mailText = "#111827"
|
||||
mailMuted = "#374151"
|
||||
mailFaint = "#6b7280"
|
||||
mailAccent = "#2563eb"
|
||||
mailDanger = "#b91c1c"
|
||||
mailWarning = "#b45309"
|
||||
mailItemBg = "#f8fafc"
|
||||
mailFontList = "'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif"
|
||||
)
|
||||
|
||||
// mailItem is one entry, e.g. a task: title, a line of details, optional text below.
|
||||
type mailItem struct {
|
||||
Title string
|
||||
Meta string // "Heute, 15:30 Uhr · Haushalt"
|
||||
Body string // notes, several lines allowed
|
||||
Tone string // "" (accent), "danger", "warning", "muted"
|
||||
}
|
||||
|
||||
// mailSection groups entries under a small heading ("Überfällig", "Heute").
|
||||
type mailSection struct {
|
||||
Title string
|
||||
Tone string
|
||||
Items []mailItem
|
||||
}
|
||||
|
||||
type mailButton struct{ Label, URL string }
|
||||
|
||||
type mailContent struct {
|
||||
Preheader string // preview line in the inbox
|
||||
Greeting string // "Guten Morgen, Anna"
|
||||
Intro string
|
||||
Notice string // highlighted box, e.g. a security hint
|
||||
Sections []mailSection
|
||||
Outro string // text after the content
|
||||
Button *mailButton
|
||||
Note string // small print under the button
|
||||
Footer string // why this mail came / where to change it
|
||||
}
|
||||
|
||||
// greeting for the time of day: the daily summary in the morning says "Guten Morgen".
|
||||
func mailGreeting(now time.Time, name string) string {
|
||||
g := "Hallo"
|
||||
switch h := now.Hour(); {
|
||||
case h >= 4 && h < 11:
|
||||
g = "Guten Morgen"
|
||||
case h >= 18 || h < 4:
|
||||
g = "Guten Abend"
|
||||
}
|
||||
if name = strings.TrimSpace(name); name != "" {
|
||||
return g + ", " + name
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
func toneColor(tone string) string {
|
||||
switch tone {
|
||||
case "danger":
|
||||
return mailDanger
|
||||
case "warning":
|
||||
return mailWarning
|
||||
case "muted":
|
||||
return mailFaint
|
||||
}
|
||||
return mailAccent
|
||||
}
|
||||
|
||||
// esc escapes text for HTML and keeps line breaks.
|
||||
func esc(s string) string {
|
||||
return strings.ReplaceAll(html.EscapeString(s), "\n", "<br>")
|
||||
}
|
||||
|
||||
// renderMail returns the plain-text and the HTML version of the same content.
|
||||
func renderMail(c mailContent) (text, htmlBody string) {
|
||||
return renderMailText(c), renderMailHTML(c)
|
||||
}
|
||||
|
||||
func renderMailText(c mailContent) string {
|
||||
var b strings.Builder
|
||||
if c.Greeting != "" {
|
||||
b.WriteString(c.Greeting + "\n\n")
|
||||
}
|
||||
if c.Intro != "" {
|
||||
b.WriteString(c.Intro + "\n\n")
|
||||
}
|
||||
if c.Notice != "" {
|
||||
b.WriteString("! " + c.Notice + "\n\n")
|
||||
}
|
||||
for _, sec := range c.Sections {
|
||||
if sec.Title != "" {
|
||||
b.WriteString(strings.ToUpper(sec.Title) + "\n")
|
||||
}
|
||||
for _, it := range sec.Items {
|
||||
b.WriteString("• " + it.Title)
|
||||
if it.Meta != "" {
|
||||
b.WriteString(" (" + it.Meta + ")")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
if it.Body != "" {
|
||||
b.WriteString(" " + strings.ReplaceAll(it.Body, "\n", "\n ") + "\n")
|
||||
}
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if c.Outro != "" {
|
||||
b.WriteString(c.Outro + "\n\n")
|
||||
}
|
||||
if c.Button != nil && c.Button.URL != "" {
|
||||
b.WriteString(c.Button.Label + ": " + c.Button.URL + "\n\n")
|
||||
}
|
||||
if c.Note != "" {
|
||||
b.WriteString(c.Note + "\n\n")
|
||||
}
|
||||
b.WriteString("-- \nMindSift")
|
||||
if c.Footer != "" {
|
||||
b.WriteString("\n" + c.Footer)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func renderMailHTML(c mailContent) string {
|
||||
var b strings.Builder
|
||||
p := func(s string) { b.WriteString(s) }
|
||||
|
||||
p(`<!doctype html><html lang="de"><head><meta charset="utf-8">`)
|
||||
p(`<meta name="viewport" content="width=device-width,initial-scale=1">`)
|
||||
// Light only: clients that invert colours on their own would otherwise turn the card grey
|
||||
p(`<meta name="color-scheme" content="light"><meta name="supported-color-schemes" content="light">`)
|
||||
p(`<title>MindSift</title></head>`)
|
||||
p(`<body style="margin:0;padding:0;background:` + mailBg + `;font-family:` + mailFontList + `;color:` + mailText + `">`)
|
||||
if c.Preheader != "" {
|
||||
p(`<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent">` + esc(c.Preheader) + `</div>`)
|
||||
}
|
||||
p(`<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:` + mailBg + `"><tr><td align="center" style="padding:32px 16px">`)
|
||||
p(`<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width:560px">`)
|
||||
|
||||
// Header: logo and name, centred
|
||||
p(`<tr><td align="center" style="padding-bottom:20px">`)
|
||||
p(`<img src="cid:` + mailLogoCID + `" width="56" height="56" alt="MindSift" style="display:block;width:56px;height:56px;border:0;border-radius:12px">`)
|
||||
p(`<div style="padding-top:10px;font-size:15px;font-weight:700;letter-spacing:0.2px;color:` + mailText + `">MindSift</div>`)
|
||||
p(`</td></tr>`)
|
||||
|
||||
// Card
|
||||
p(`<tr><td style="background:` + mailPanel + `;border:1px solid ` + mailBorder + `;border-radius:8px;padding:28px 28px 24px">`)
|
||||
if c.Greeting != "" {
|
||||
p(`<div style="font-size:20px;font-weight:700;line-height:1.3;color:` + mailText + `;padding-bottom:8px">` + esc(c.Greeting) + `</div>`)
|
||||
}
|
||||
if c.Intro != "" {
|
||||
p(`<div style="font-size:15px;line-height:1.55;color:` + mailMuted + `;padding-bottom:16px">` + esc(c.Intro) + `</div>`)
|
||||
}
|
||||
if c.Notice != "" {
|
||||
p(`<div style="margin:4px 0 18px;padding:12px 14px;border:1px solid #f5c2c2;border-left:3px solid ` + mailDanger + `;border-radius:6px;background:#fdf2f2;font-size:14px;line-height:1.5;color:` + mailDanger + `">` + esc(c.Notice) + `</div>`)
|
||||
}
|
||||
for _, sec := range c.Sections {
|
||||
if sec.Title != "" {
|
||||
p(`<div style="padding:10px 0 8px;font-size:11px;font-weight:700;letter-spacing:0.8px;text-transform:uppercase;color:` + toneColor(sec.Tone) + `">` + esc(sec.Title) + `</div>`)
|
||||
}
|
||||
for _, it := range sec.Items {
|
||||
tone := it.Tone
|
||||
if tone == "" {
|
||||
tone = sec.Tone
|
||||
}
|
||||
p(`<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin-bottom:8px"><tr>`)
|
||||
p(`<td style="background:` + mailItemBg + `;border:1px solid ` + mailBorder + `;border-left:3px solid ` + toneColor(tone) + `;border-radius:6px;padding:11px 14px">`)
|
||||
p(`<div style="font-size:15px;font-weight:600;line-height:1.4;color:` + mailText + `">` + esc(it.Title) + `</div>`)
|
||||
if it.Meta != "" {
|
||||
p(`<div style="padding-top:3px;font-size:13px;line-height:1.4;color:` + mailFaint + `">` + esc(it.Meta) + `</div>`)
|
||||
}
|
||||
if it.Body != "" {
|
||||
p(`<div style="padding-top:8px;font-size:14px;line-height:1.5;color:` + mailMuted + `">` + esc(it.Body) + `</div>`)
|
||||
}
|
||||
p(`</td></tr></table>`)
|
||||
}
|
||||
}
|
||||
if c.Outro != "" {
|
||||
p(`<div style="padding-top:8px;font-size:14px;line-height:1.55;color:` + mailMuted + `">` + esc(c.Outro) + `</div>`)
|
||||
}
|
||||
if c.Button != nil && c.Button.URL != "" {
|
||||
p(`<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin-top:20px"><tr>`)
|
||||
p(`<td style="border-radius:6px;background:` + mailAccent + `"><a href="` + html.EscapeString(c.Button.URL) + `" style="display:inline-block;padding:11px 20px;font-size:14px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:6px">` + esc(c.Button.Label) + `</a></td>`)
|
||||
p(`</tr></table>`)
|
||||
}
|
||||
if c.Note != "" {
|
||||
p(`<div style="padding-top:14px;font-size:12px;line-height:1.5;color:` + mailFaint + `">` + esc(c.Note) + `</div>`)
|
||||
}
|
||||
p(`</td></tr>`)
|
||||
|
||||
// Footer
|
||||
p(`<tr><td align="center" style="padding:18px 12px 0;font-size:12px;line-height:1.5;color:` + mailFaint + `">`)
|
||||
if c.Footer != "" {
|
||||
p(esc(c.Footer) + `<br>`)
|
||||
}
|
||||
p(`MindSift – Der Kopf frei in drei Sekunden.`)
|
||||
p(`</td></tr>`)
|
||||
|
||||
p(`</table></td></tr></table></body></html>`)
|
||||
return b.String()
|
||||
}
|
||||
+89
-33
@@ -4,7 +4,6 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sort"
|
||||
@@ -27,6 +26,8 @@ const notifyMaxAttempts = 3
|
||||
// notifyItem is one line of a bundled message.
|
||||
type notifyItem struct {
|
||||
kind, title, text string
|
||||
// Daily summary only, for the sections of the mail: overdue | today | tomorrow | more
|
||||
group, when, list string
|
||||
}
|
||||
|
||||
// notify queues an event for the recipients that want it (settings, pause, own-change rule).
|
||||
@@ -175,7 +176,7 @@ func (s *Server) flushNotifications(now time.Time) {
|
||||
item := notifyItem{kind: q.Kind, title: q.Payload["title"], text: q.Payload["text"]}
|
||||
out.add(set.Channels(q.Kind), item)
|
||||
}
|
||||
if err := s.deliver(uid, "Neuigkeiten", out); err != nil {
|
||||
if err := s.deliver(uid, "Neuigkeiten", out, false); 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)
|
||||
@@ -209,7 +210,7 @@ func (c *channelItems) add(ch domain.ChannelChoice, items ...notifyItem) {
|
||||
}
|
||||
|
||||
// deliver sends the items to the user's mail address, Discord webhook and/or own webhook.
|
||||
func (s *Server) deliver(userID, heading string, out channelItems) error {
|
||||
func (s *Server) deliver(userID, heading string, out channelItems, summary bool) error {
|
||||
emailItems, discordItems := out.email, out.discord
|
||||
u, err := s.store.GetUserByID(userID)
|
||||
if err != nil || u == nil || !u.IsActive {
|
||||
@@ -225,7 +226,12 @@ func (s *Server) deliver(userID, heading string, out channelItems) error {
|
||||
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 {
|
||||
c := s.digestMail(u.DisplayName, emailItems)
|
||||
if summary {
|
||||
c = s.summaryMail(u.DisplayName, emailItems)
|
||||
}
|
||||
text, htmlBody := renderMail(c)
|
||||
if err := s.sendNotifyEmail(to, subject, text, htmlBody); err != nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
@@ -361,38 +367,86 @@ func (s *Server) sendDiscordDigest(webhookURL, heading string, items []notifyIte
|
||||
return nil
|
||||
}
|
||||
|
||||
func notifyText(heading string, items []notifyItem, link string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(heading + "\n\n")
|
||||
// digestMail: what happened since the last mail (shares, completed shared tasks, watched tasks).
|
||||
func (s *Server) digestMail(name string, items []notifyItem) mailContent {
|
||||
entries := make([]mailItem, 0, len(items))
|
||||
for _, it := range items {
|
||||
b.WriteString("• " + it.text + "\n")
|
||||
entries = append(entries, mailItem{Title: it.text})
|
||||
}
|
||||
if link != "" {
|
||||
b.WriteString("\nÖffnen: " + link + "\n")
|
||||
intro := "In MindSift ist etwas passiert, das dich betrifft:"
|
||||
if len(items) > 1 {
|
||||
intro = "In MindSift ist seit der letzten Nachricht einiges passiert, das dich betrifft:"
|
||||
}
|
||||
b.WriteString("\nDiese Benachrichtigungen stellst du in MindSift unter Kontoeinstellungen → Alarme ein.\n")
|
||||
return b.String()
|
||||
c := mailContent{
|
||||
Preheader: items[0].text,
|
||||
Greeting: mailGreeting(time.Now(), name),
|
||||
Intro: intro,
|
||||
Sections: []mailSection{{Items: entries}},
|
||||
Footer: mailFooterAlarms,
|
||||
}
|
||||
if link := s.notifyLink(); link != "" {
|
||||
c.Button = &mailButton{Label: "In MindSift öffnen", URL: link}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// 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
|
||||
// summaryMail: the morning overview, grouped into overdue / today / tomorrow.
|
||||
func (s *Server) summaryMail(name string, items []notifyItem) mailContent {
|
||||
groups := []struct{ key, title, tone string }{
|
||||
{"overdue", "Überfällig", "danger"},
|
||||
{"today", "Heute", ""},
|
||||
{"tomorrow", "Morgen", "muted"},
|
||||
}
|
||||
var sections []mailSection
|
||||
counts := map[string]int{}
|
||||
outro := ""
|
||||
for _, g := range groups {
|
||||
sec := mailSection{Title: g.title, Tone: g.tone}
|
||||
for _, it := range items {
|
||||
if it.group != g.key {
|
||||
continue
|
||||
}
|
||||
meta := it.when
|
||||
if it.list != "" {
|
||||
if meta != "" {
|
||||
meta += " · "
|
||||
}
|
||||
meta += it.list
|
||||
}
|
||||
sec.Items = append(sec.Items, mailItem{Title: it.title, Meta: meta})
|
||||
}
|
||||
counts[g.key] = len(sec.Items)
|
||||
if len(sec.Items) > 0 {
|
||||
sections = append(sections, sec)
|
||||
}
|
||||
}
|
||||
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>`)
|
||||
if it.group == "more" {
|
||||
outro = it.text + "."
|
||||
}
|
||||
}
|
||||
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>`
|
||||
var parts []string
|
||||
if n := counts["overdue"]; n > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d überfällig", n))
|
||||
}
|
||||
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>`
|
||||
if n := counts["today"]; n > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d heute", n))
|
||||
}
|
||||
if n := counts["tomorrow"]; n > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d morgen", n))
|
||||
}
|
||||
c := mailContent{
|
||||
Preheader: "Deine Aufgaben: " + strings.Join(parts, ", "),
|
||||
Greeting: mailGreeting(time.Now(), name),
|
||||
Intro: "Hier ist dein Überblick für heute – " + strings.Join(parts, ", ") + ".",
|
||||
Sections: sections,
|
||||
Outro: outro,
|
||||
Footer: "Du bekommst diese Zusammenfassung, weil du sie eingeschaltet hast. Uhrzeit und Kanäle stellst du in MindSift unter Kontoeinstellungen → Alarme ein.",
|
||||
}
|
||||
if link := s.notifyLink(); link != "" {
|
||||
c.Button = &mailButton{Label: "Aufgaben öffnen", URL: link}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// --- Daily summary ---
|
||||
@@ -417,7 +471,7 @@ func (s *Server) sendDailySummaries(now time.Time) {
|
||||
}
|
||||
var out channelItems
|
||||
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 {
|
||||
if err := s.deliver(uid, "Deine Aufgaben für heute", out, true); err != nil {
|
||||
slog.Warn("Tageszusammenfassung konnte nicht verschickt werden", "user_id", uid, "error", err)
|
||||
}
|
||||
}
|
||||
@@ -447,19 +501,21 @@ func (s *Server) summaryItems(userID string, now time.Time) []notifyItem {
|
||||
if len(t.DueDate) >= 16 {
|
||||
clock = ", " + t.DueDate[11:16] + " Uhr"
|
||||
}
|
||||
at := strings.TrimPrefix(clock, ", ")
|
||||
switch {
|
||||
case d < today:
|
||||
items = append(items, notifyItem{kind: "due", title: t.Title, text: "Überfällig seit " + domain.HumanDue(t.DueDate) + ": " + label})
|
||||
since := "seit " + domain.HumanDue(t.DueDate)
|
||||
items = append(items, notifyItem{kind: "due", title: t.Title, text: "Überfällig " + since + ": " + label, group: "overdue", when: since, list: t.ListTitle})
|
||||
case d == today:
|
||||
items = append(items, notifyItem{kind: "due", title: t.Title, text: "Heute" + clock + ": " + label})
|
||||
items = append(items, notifyItem{kind: "due", title: t.Title, text: "Heute" + clock + ": " + label, group: "today", when: at, list: t.ListTitle})
|
||||
case d == tomorrow:
|
||||
items = append(items, notifyItem{kind: "due", title: t.Title, text: "Morgen" + clock + ": " + label})
|
||||
items = append(items, notifyItem{kind: "due", title: t.Title, text: "Morgen" + clock + ": " + label, group: "tomorrow", when: at, list: t.ListTitle})
|
||||
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)})
|
||||
items = append(items, notifyItem{kind: "due", text: fmt.Sprintf("Außerdem %d weitere Aufgaben in den nächsten 7 Tagen", later), group: "more"})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
@@ -167,12 +167,18 @@ func TestDailySummary(t *testing.T) {
|
||||
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"} {
|
||||
// Grouped into sections, the same in text and HTML; the logo travels inside the mail
|
||||
text, htmlBody := (*sent)[0].text, (*sent)[0].html
|
||||
for _, want := range []string{"Guten Morgen", "ÜBERFÄLLIG\n• Altlast (seit ", "HEUTE\n• Zahnarzt (15:30 Uhr)", "MORGEN\n• Müll raus", "1 weitere"} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("summary lacks %q:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"cid:" + mailLogoCID, ">Überfällig<", ">Zahnarzt<", "15:30 Uhr"} {
|
||||
if !strings.Contains(htmlBody, want) {
|
||||
t.Fatalf("summary HTML lacks %q", want)
|
||||
}
|
||||
}
|
||||
srv.sendDailySummaries(now.Add(5 * time.Minute))
|
||||
if len(*sent) != 1 {
|
||||
t.Fatal("only one summary per day")
|
||||
@@ -204,7 +210,7 @@ func TestDailySummaryIncludesGroupSharedLists(t *testing.T) {
|
||||
set.DailySummary = domain.DailySummary{Enabled: true, Time: "07:00", Email: true}
|
||||
_ = store.SetNotificationSettings(me.ID, set)
|
||||
srv.sendDailySummaries(time.Date(2026, 10, 30, 7, 1, 0, 0, time.Local))
|
||||
if len(*sent) != 1 || !strings.Contains((*sent)[0].text, "Bericht abgeben (Projekt)") {
|
||||
if len(*sent) != 1 || !strings.Contains((*sent)[0].text, "Bericht abgeben (10:00 Uhr · Projekt)") {
|
||||
t.Fatalf("group-shared task missing: %+v", *sent)
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,11 @@ package api
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/auth"
|
||||
"github.com/sandrodach/mindsift/internal/storage"
|
||||
@@ -54,11 +54,15 @@ func (s *Server) handleForgotPassword(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
link := strings.TrimRight(s.cfg.BaseURL, "/") + "/?resetToken=" + url.QueryEscape(token)
|
||||
body := fmt.Sprintf("Hallo %s,\n\nfür dein MindSift-Konto wurde ein neues Passwort angefordert. "+
|
||||
"Über diesen Link kannst du es innerhalb der nächsten Stunde festlegen:\n\n%s\n\n"+
|
||||
"Der Link funktioniert nur einmal. Hast du das nicht angefordert, ignoriere diese E-Mail; dein Passwort bleibt unverändert.\n",
|
||||
user.DisplayName, link)
|
||||
_ = s.sendEmailNotification(user.Email, "MindSift: Passwort zurücksetzen", body)
|
||||
_ = s.sendMailContent(user.Email, "MindSift: Passwort zurücksetzen", mailContent{
|
||||
Preheader: "Lege ein neues Passwort für dein MindSift-Konto fest",
|
||||
Greeting: mailGreeting(time.Now(), user.DisplayName),
|
||||
Intro: "Für dein MindSift-Konto wurde ein neues Passwort angefordert. Über den Knopf kannst du es innerhalb der nächsten Stunde festlegen.",
|
||||
Button: &mailButton{Label: "Neues Passwort festlegen", URL: link},
|
||||
Note: "Der Link funktioniert nur einmal. Hast du das nicht angefordert, ignoriere diese E-Mail – dein Passwort bleibt unverändert.\n" +
|
||||
"Falls der Knopf nicht geht: " + link,
|
||||
Footer: mailFooterAccount,
|
||||
})
|
||||
}()
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ func TestDemoSendsNothingAndKeepsAccounts(t *testing.T) {
|
||||
if err := srv.sendDiscordNotification("https://discord.com/api/webhooks/1/x", "x", "", "", ""); err != errDemoOutgoing {
|
||||
t.Fatalf("discord sender must refuse in demo, got %v", err)
|
||||
}
|
||||
if err := srv.sendEmailNotification("a@example.com", "x", "x"); err != errDemoOutgoing {
|
||||
if err := srv.sendMailContent("a@example.com", "x", mailContent{Intro: "x"}); err != errDemoOutgoing {
|
||||
t.Fatalf("mail sender must refuse in demo, got %v", err)
|
||||
}
|
||||
blocked := []struct{ method, path, body string }{
|
||||
|
||||
@@ -604,6 +604,7 @@ type DueTaskAlarm struct {
|
||||
DueDate string
|
||||
Priority string
|
||||
UserEmail string
|
||||
UserName string
|
||||
AlarmChannels []string
|
||||
AlarmEmail string
|
||||
DiscordWebhookURL string
|
||||
@@ -614,7 +615,7 @@ type DueTaskAlarm struct {
|
||||
func (s *Store) GetDueTasksForAlarm() ([]DueTaskAlarm, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT t.id, t.user_id, t.content, t.notes, t.due_date, t.priority,
|
||||
u.email, u.alarm_channels, u.alarm_email, u.discord_webhook_url, u.custom_webhook_url, u.notify_before_minutes
|
||||
u.email, u.display_name, u.alarm_channels, u.alarm_email, u.discord_webhook_url, u.custom_webhook_url, u.notify_before_minutes
|
||||
FROM thoughts t
|
||||
JOIN users u ON u.id = t.user_id
|
||||
WHERE (t.status = 'kept' OR (t.status = 'converted' AND t.converted_type = 'task'))
|
||||
@@ -634,7 +635,7 @@ func (s *Store) GetDueTasksForAlarm() ([]DueTaskAlarm, error) {
|
||||
var channelsRaw string
|
||||
if err := rows.Scan(
|
||||
&a.TaskID, &a.UserID, &a.Title, &a.Notes, &a.DueDate, &a.Priority,
|
||||
&a.UserEmail, &channelsRaw, &a.AlarmEmail, &a.DiscordWebhookURL, &a.CustomWebhookURL, &a.NotifyBeforeMinutes,
|
||||
&a.UserEmail, &a.UserName, &channelsRaw, &a.AlarmEmail, &a.DiscordWebhookURL, &a.CustomWebhookURL, &a.NotifyBeforeMinutes,
|
||||
); err == nil {
|
||||
a.AlarmChannels = parseAlarmChannels(channelsRaw)
|
||||
if a.AlarmEmail == "" {
|
||||
|
||||
Reference in new issue
Block a user