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
+475
-60
No files matched your search
+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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user