feat(mail): one layout for all mails with logo, greeting, cards and footer
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m1s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
df06ab10c8
commit
46e1137f18
16 files changed
+474
-59
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]
|
## [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
|
## [0.17.4] - 2026-10-31
|
||||||
|
|
||||||
### Fixed
|
### 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.
|
- 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.
|
- 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.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.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
|
[0.17.2]: https://git.dsit.me/sandrodach/MindSift/compare/v0.17.1...v0.17.2
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
[](#)
|
[](#)
|
||||||
[](#-schnellstart)
|
[](#-schnellstart)
|
||||||
[](#-deine-daten-gehören-dir)
|
[](#-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.
|
**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",
|
"name": "mindsift-frontend",
|
||||||
"version": "0.17.4",
|
"version": "0.18.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"version": "0.17.4",
|
"version": "0.18.0",
|
||||||
"license": "Unlicense",
|
"license": "Unlicense",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/inter": "5.3.0",
|
"@fontsource-variable/inter": "5.3.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.17.4",
|
"version": "0.18.0",
|
||||||
"license": "Unlicense",
|
"license": "Unlicense",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+54
-4
@@ -8,12 +8,63 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sandrodach/mindsift/internal/domain"
|
"github.com/sandrodach/mindsift/internal/domain"
|
||||||
|
"github.com/sandrodach/mindsift/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- MULTI-CHANNEL ALARM DISPATCHER (EMAIL, DISCORD, CUSTOM WEBHOOK) ---
|
// --- MULTI-CHANNEL ALARM DISPATCHER (EMAIL, DISCORD, CUSTOM WEBHOOK) ---
|
||||||
|
|
||||||
func (s *Server) sendEmailNotification(toEmail, subject, bodyText string) error {
|
// sendMailContent sends a mail in the common MindSift look (mailtemplate.go), as text and HTML.
|
||||||
return s.sendEmailHTML(toEmail, subject, bodyText, "")
|
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).
|
// sendEmailHTML sends text plus an optional HTML version (multipart/alternative).
|
||||||
@@ -181,8 +232,7 @@ func (s *Server) startAlarmTicker() {
|
|||||||
}
|
}
|
||||||
case "email":
|
case "email":
|
||||||
if a.AlarmEmail != "" {
|
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.sendMailContent(a.AlarmEmail, "⏰ Fällig: "+a.Title, s.dueAlarmMail(a, due)); err == nil {
|
||||||
if err := s.sendEmailNotification(a.AlarmEmail, "⏰ Aufgabe fällig: "+a.Title, emailBody); err == nil {
|
|
||||||
sentAny = true
|
sentAny = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -359,7 +359,12 @@ func (s *Server) handleAdminTestSMTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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())
|
writeError(w, http.StatusBadGateway, "SMTP-Fehler: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -577,10 +577,18 @@ func (s *Server) notifyAccountChange(before *domain.User, password, email bool,
|
|||||||
if email {
|
if email {
|
||||||
what = append(what, "die E-Mail-Adresse deines Kontos wurde auf "+newEmail+" geändert")
|
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",
|
c := mailContent{
|
||||||
before.DisplayName, strings.Join(what, " und "))
|
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() {
|
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 == "" {
|
if toEmail == "" {
|
||||||
toEmail = user.Email
|
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())
|
writeError(w, http.StatusBadGateway, "E-Mail-Versand fehlgeschlagen: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -147,7 +148,27 @@ func buildMessage(from, to, subject, body, html string, now time.Time) []byte {
|
|||||||
fmt.Fprintf(&buf, "--%s\r\n", boundary)
|
fmt.Fprintf(&buf, "--%s\r\n", boundary)
|
||||||
part("text/plain", body)
|
part("text/plain", body)
|
||||||
fmt.Fprintf(&buf, "--%s\r\n", boundary)
|
fmt.Fprintf(&buf, "--%s\r\n", boundary)
|
||||||
|
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)
|
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)
|
fmt.Fprintf(&buf, "--%s--\r\n", boundary)
|
||||||
return buf.Bytes()
|
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"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -27,6 +26,8 @@ const notifyMaxAttempts = 3
|
|||||||
// notifyItem is one line of a bundled message.
|
// notifyItem is one line of a bundled message.
|
||||||
type notifyItem struct {
|
type notifyItem struct {
|
||||||
kind, title, text string
|
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).
|
// 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"]}
|
item := notifyItem{kind: q.Kind, title: q.Payload["title"], text: q.Payload["text"]}
|
||||||
out.add(set.Channels(q.Kind), item)
|
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 {
|
if maxAttempts+1 >= notifyMaxAttempts {
|
||||||
slog.Warn("Benachrichtigungen nach mehreren Fehlversuchen verworfen", "user_id", uid, "count", len(ids), "error", err)
|
slog.Warn("Benachrichtigungen nach mehreren Fehlversuchen verworfen", "user_id", uid, "count", len(ids), "error", err)
|
||||||
_ = s.store.DeleteQueuedNotifications(ids)
|
_ = 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.
|
// 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
|
emailItems, discordItems := out.email, out.discord
|
||||||
u, err := s.store.GetUserByID(userID)
|
u, err := s.store.GetUserByID(userID)
|
||||||
if err != nil || u == nil || !u.IsActive {
|
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 {
|
if len(emailItems) == 1 {
|
||||||
subject = "MindSift: " + emailItems[0].text
|
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
|
firstErr = err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -361,38 +367,86 @@ func (s *Server) sendDiscordDigest(webhookURL, heading string, items []notifyIte
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func notifyText(heading string, items []notifyItem, link string) string {
|
// digestMail: what happened since the last mail (shares, completed shared tasks, watched tasks).
|
||||||
var b strings.Builder
|
func (s *Server) digestMail(name string, items []notifyItem) mailContent {
|
||||||
b.WriteString(heading + "\n\n")
|
entries := make([]mailItem, 0, len(items))
|
||||||
for _, it := range items {
|
for _, it := range items {
|
||||||
b.WriteString("• " + it.text + "\n")
|
entries = append(entries, mailItem{Title: it.text})
|
||||||
}
|
}
|
||||||
if link != "" {
|
intro := "In MindSift ist etwas passiert, das dich betrifft:"
|
||||||
b.WriteString("\nÖffnen: " + link + "\n")
|
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")
|
c := mailContent{
|
||||||
return b.String()
|
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).
|
// summaryMail: the morning overview, grouped into overdue / today / tomorrow.
|
||||||
func notifyHTML(heading string, items []notifyItem, link string) string {
|
func (s *Server) summaryMail(name string, items []notifyItem) mailContent {
|
||||||
var cards strings.Builder
|
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 {
|
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">` +
|
if it.group != g.key {
|
||||||
html.EscapeString(it.text) + `</td></tr><tr><td style="height:8px"></td></tr>`)
|
continue
|
||||||
}
|
}
|
||||||
button := ""
|
meta := it.when
|
||||||
if link != "" {
|
if it.list != "" {
|
||||||
button = `<tr><td style="padding-top:8px"><a href="` + html.EscapeString(link) +
|
if meta != "" {
|
||||||
`" 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>`
|
meta += " · "
|
||||||
}
|
}
|
||||||
return `<!doctype html><html><body style="margin:0;padding:24px;background:#f4f5f7;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif">` +
|
meta += it.list
|
||||||
`<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>` +
|
sec.Items = append(sec.Items, mailItem{Title: it.title, Meta: meta})
|
||||||
`<tr><td style="font-size:15px;color:#1f2328;padding-bottom:16px">` + html.EscapeString(heading) + `</td></tr>` +
|
}
|
||||||
cards.String() + button +
|
counts[g.key] = len(sec.Items)
|
||||||
`<tr><td style="font-size:12px;color:#6b7280;padding-top:20px">Diese Benachrichtigungen stellst du in MindSift unter Kontoeinstellungen → Alarme ein.</td></tr>` +
|
if len(sec.Items) > 0 {
|
||||||
`</table></body></html>`
|
sections = append(sections, sec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, it := range items {
|
||||||
|
if it.group == "more" {
|
||||||
|
outro = it.text + "."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var parts []string
|
||||||
|
if n := counts["overdue"]; n > 0 {
|
||||||
|
parts = append(parts, fmt.Sprintf("%d überfällig", n))
|
||||||
|
}
|
||||||
|
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 ---
|
// --- Daily summary ---
|
||||||
@@ -417,7 +471,7 @@ func (s *Server) sendDailySummaries(now time.Time) {
|
|||||||
}
|
}
|
||||||
var out channelItems
|
var out channelItems
|
||||||
out.add(domain.ChannelChoice{Email: ds.Email, Discord: ds.Discord, Webhook: ds.Webhook, Push: ds.Push}, 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 {
|
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)
|
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 {
|
if len(t.DueDate) >= 16 {
|
||||||
clock = ", " + t.DueDate[11:16] + " Uhr"
|
clock = ", " + t.DueDate[11:16] + " Uhr"
|
||||||
}
|
}
|
||||||
|
at := strings.TrimPrefix(clock, ", ")
|
||||||
switch {
|
switch {
|
||||||
case d < today:
|
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:
|
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:
|
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:
|
default:
|
||||||
later++
|
later++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(items) > 0 && later > 0 {
|
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
|
return items
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,12 +167,18 @@ func TestDailySummary(t *testing.T) {
|
|||||||
if len(*sent) != 1 {
|
if len(*sent) != 1 {
|
||||||
t.Fatalf("expected one summary, got %d", len(*sent))
|
t.Fatalf("expected one summary, got %d", len(*sent))
|
||||||
}
|
}
|
||||||
text := (*sent)[0].text
|
// Grouped into sections, the same in text and HTML; the logo travels inside the mail
|
||||||
for _, want := range []string{"Überfällig", "Altlast", "Heute, 15:30 Uhr: Zahnarzt", "Morgen: Müll raus", "1 weitere"} {
|
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) {
|
if !strings.Contains(text, want) {
|
||||||
t.Fatalf("summary lacks %q:\n%s", want, text)
|
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))
|
srv.sendDailySummaries(now.Add(5 * time.Minute))
|
||||||
if len(*sent) != 1 {
|
if len(*sent) != 1 {
|
||||||
t.Fatal("only one summary per day")
|
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}
|
set.DailySummary = domain.DailySummary{Enabled: true, Time: "07:00", Email: true}
|
||||||
_ = store.SetNotificationSettings(me.ID, set)
|
_ = store.SetNotificationSettings(me.ID, set)
|
||||||
srv.sendDailySummaries(time.Date(2026, 10, 30, 7, 1, 0, 0, time.Local))
|
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)
|
t.Fatalf("group-shared task missing: %+v", *sent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,11 +3,11 @@ package api
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/sandrodach/mindsift/internal/auth"
|
"github.com/sandrodach/mindsift/internal/auth"
|
||||||
"github.com/sandrodach/mindsift/internal/storage"
|
"github.com/sandrodach/mindsift/internal/storage"
|
||||||
@@ -54,11 +54,15 @@ func (s *Server) handleForgotPassword(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
link := strings.TrimRight(s.cfg.BaseURL, "/") + "/?resetToken=" + url.QueryEscape(token)
|
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. "+
|
_ = s.sendMailContent(user.Email, "MindSift: Passwort zurücksetzen", mailContent{
|
||||||
"Über diesen Link kannst du es innerhalb der nächsten Stunde festlegen:\n\n%s\n\n"+
|
Preheader: "Lege ein neues Passwort für dein MindSift-Konto fest",
|
||||||
"Der Link funktioniert nur einmal. Hast du das nicht angefordert, ignoriere diese E-Mail; dein Passwort bleibt unverändert.\n",
|
Greeting: mailGreeting(time.Now(), user.DisplayName),
|
||||||
user.DisplayName, link)
|
Intro: "Für dein MindSift-Konto wurde ein neues Passwort angefordert. Über den Knopf kannst du es innerhalb der nächsten Stunde festlegen.",
|
||||||
_ = s.sendEmailNotification(user.Email, "MindSift: Passwort zurücksetzen", body)
|
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})
|
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 {
|
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)
|
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)
|
t.Fatalf("mail sender must refuse in demo, got %v", err)
|
||||||
}
|
}
|
||||||
blocked := []struct{ method, path, body string }{
|
blocked := []struct{ method, path, body string }{
|
||||||
|
|||||||
@@ -604,6 +604,7 @@ type DueTaskAlarm struct {
|
|||||||
DueDate string
|
DueDate string
|
||||||
Priority string
|
Priority string
|
||||||
UserEmail string
|
UserEmail string
|
||||||
|
UserName string
|
||||||
AlarmChannels []string
|
AlarmChannels []string
|
||||||
AlarmEmail string
|
AlarmEmail string
|
||||||
DiscordWebhookURL string
|
DiscordWebhookURL string
|
||||||
@@ -614,7 +615,7 @@ type DueTaskAlarm struct {
|
|||||||
func (s *Store) GetDueTasksForAlarm() ([]DueTaskAlarm, error) {
|
func (s *Store) GetDueTasksForAlarm() ([]DueTaskAlarm, error) {
|
||||||
rows, err := s.db.Query(
|
rows, err := s.db.Query(
|
||||||
`SELECT t.id, t.user_id, t.content, t.notes, t.due_date, t.priority,
|
`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
|
FROM thoughts t
|
||||||
JOIN users u ON u.id = t.user_id
|
JOIN users u ON u.id = t.user_id
|
||||||
WHERE (t.status = 'kept' OR (t.status = 'converted' AND t.converted_type = 'task'))
|
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
|
var channelsRaw string
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&a.TaskID, &a.UserID, &a.Title, &a.Notes, &a.DueDate, &a.Priority,
|
&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 {
|
); err == nil {
|
||||||
a.AlarmChannels = parseAlarmChannels(channelsRaw)
|
a.AlarmChannels = parseAlarmChannels(channelsRaw)
|
||||||
if a.AlarmEmail == "" {
|
if a.AlarmEmail == "" {
|
||||||
|
|||||||
Reference in new issue
Block a user