diff --git a/CHANGELOG.md b/CHANGELOG.md index fe96b0a..6ccc3cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 8dd2fe4..741582a 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![Self-Hosted](https://img.shields.io/badge/100%25-Self--Hosted-2563eb?style=flat-square)](#) [![Docker](https://img.shields.io/badge/Deployment-Single%20Container-2496ed?style=flat-square&logo=docker&logoColor=white)](#-schnellstart) [![SQLite](https://img.shields.io/badge/Daten-SQLite-003b57?style=flat-square&logo=sqlite&logoColor=white)](#-deine-daten-gehören-dir) -[![Version](https://img.shields.io/badge/Version-v0.17.4-16a34a?style=flat-square)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/Version-v0.18.0-16a34a?style=flat-square)](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. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d4a6e4c..b7e5153 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 13cfaa6..67768e7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "mindsift-frontend", "private": true, - "version": "0.17.4", + "version": "0.18.0", "license": "Unlicense", "type": "module", "scripts": { diff --git a/internal/api/alarms.go b/internal/api/alarms.go index 20514d8..2b54fd6 100644 --- a/internal/api/alarms.go +++ b/internal/api/alarms.go @@ -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 } } diff --git a/internal/api/handlers_admin.go b/internal/api/handlers_admin.go index 32f08e7..d6ef733 100644 --- a/internal/api/handlers_admin.go +++ b/internal/api/handlers_admin.go @@ -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 } diff --git a/internal/api/handlers_auth.go b/internal/api/handlers_auth.go index 33d1d56..3f001b7 100644 --- a/internal/api/handlers_auth.go +++ b/internal/api/handlers_auth.go @@ -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 } diff --git a/internal/api/mail.go b/internal/api/mail.go index 1b3ac27..f191899 100644 --- a/internal/api/mail.go +++ b/internal/api/mail.go @@ -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() } diff --git a/internal/api/mail_test.go b/internal/api/mail_test.go index 226d895..705d634 100644 --- a/internal/api/mail_test.go +++ b/internal/api/mail_test.go @@ -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 :", + 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 ", "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") + } +} diff --git a/internal/api/maillogo.png b/internal/api/maillogo.png new file mode 100644 index 0000000..8245052 Binary files /dev/null and b/internal/api/maillogo.png differ diff --git a/internal/api/mailtemplate.go b/internal/api/mailtemplate.go new file mode 100644 index 0000000..096f3cf --- /dev/null +++ b/internal/api/mailtemplate.go @@ -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