From 46e1137f18897d3185a70735686fb26ff3a05b9c Mon Sep 17 00:00:00 2001 From: Sandro Date: Sat, 31 Oct 2026 10:32:33 +0100 Subject: [PATCH] feat(mail): one layout for all mails with logo, greeting, cards and footer Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 10 +- README.md | 2 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- internal/api/alarms.go | 58 ++++++++- internal/api/handlers_admin.go | 7 +- internal/api/handlers_auth.go | 22 +++- internal/api/mail.go | 23 +++- internal/api/mail_test.go | 27 ++++ internal/api/maillogo.png | Bin 0 -> 5115 bytes internal/api/mailtemplate.go | 223 +++++++++++++++++++++++++++++++++ internal/api/notify.go | 122 +++++++++++++----- internal/api/notify_test.go | 12 +- internal/api/password_reset.go | 16 ++- internal/api/s4_test.go | 2 +- internal/storage/thoughts.go | 5 +- 16 files changed, 475 insertions(+), 60 deletions(-) create mode 100644 internal/api/maillogo.png create mode 100644 internal/api/mailtemplate.go 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 0000000000000000000000000000000000000000..82450529c90738ff5e91b3a4698cd15ef89a4b41 GIT binary patch literal 5115 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 literal 0 HcmV?d00001 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