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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-31 10:32:33 +01:00
1 parent df06ab10c8
commit 46e1137f18
16 files changed
+475 -60

No files matched your search

+89 -33
View File
@@ -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
}