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

+223
View File
@@ -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()
}