feat(notifications): branded HTML email templates
Replace the bare-bones inline HTML in notification, verification, and admin test emails with a shared branded layout in internal/mail, matching the web UI's Midnight Cinema theme (dark card shell, wordmark, mono episode-code badges, white primary CTA). The shell is built for email clients: tables + inline styles, explicit dark color-scheme, Outlook-safe button, and a width:100%/max-width pattern so the card shrinks correctly on phones. Plain-text bodies, subjects, and the link-free-when-unconfigured guarantee are unchanged; the admin test email gains an HTML body so the SMTP test doubles as a design preview. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -56,6 +56,13 @@ func (h *EmailHandler) HandleTest(w http.ResponseWriter, r *http.Request) {
|
||||
Subject: "Silo test email",
|
||||
TextBody: "This is a test email from your Silo server.\n\n" +
|
||||
"If you received it, outbound email is configured correctly.",
|
||||
HTMLBody: silomail.RenderLayout(silomail.LayoutOptions{
|
||||
Preheader: "Outbound email from your Silo server is configured correctly.",
|
||||
Title: "Outbound email is working",
|
||||
BodyHTML: silomail.EmailParagraph("This is a test email from your Silo server.") +
|
||||
silomail.EmailParagraph("If you're reading it, the SMTP settings are correct and "+
|
||||
"notification emails will look like this one."),
|
||||
}),
|
||||
})
|
||||
response := emailTestResponse{
|
||||
OK: err == nil,
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"html"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Shared visual tokens for Silo's branded emails, mirroring the web UI's
|
||||
// default "Midnight Cinema" theme (web/src/app.css): a near-black canvas,
|
||||
// monochrome type, and a white primary action. Feature packages compose body
|
||||
// fragments with these tokens and wrap them with RenderLayout so every email
|
||||
// the server sends looks like it came from the same product.
|
||||
//
|
||||
// Email-client constraints shape everything here: styles must be inline,
|
||||
// layout must be tables, and colors must be explicit on every element (no
|
||||
// inheritance through client-rewritten DOM). Web fonts don't load in most
|
||||
// clients, so the stacks lead with the brand font and degrade to common
|
||||
// system faces.
|
||||
const (
|
||||
EmailFont = "'Outfit','Avenir Next','Segoe UI',Helvetica,Arial,sans-serif"
|
||||
EmailFontMono = "'SF Mono',SFMono-Regular,Menlo,Consolas,'Liberation Mono',monospace"
|
||||
|
||||
EmailColorCanvas = "#141417" // page background
|
||||
EmailColorCard = "#1c1c20" // content card surface
|
||||
EmailColorBorder = "#2e2e35" // card outline
|
||||
EmailColorText = "#e8e8ec" // primary text
|
||||
EmailColorMuted = "#9696a0" // secondary text, badges, footer
|
||||
EmailColorRule = "#26262c" // hairline row separators
|
||||
EmailColorAction = "#e8e8ec" // primary button background (white-on-dark)
|
||||
EmailColorOnAct = "#141417" // primary button label
|
||||
)
|
||||
|
||||
// LayoutOptions is the content RenderLayout places into the branded shell.
|
||||
type LayoutOptions struct {
|
||||
// Preheader is the hidden inbox-preview snippet shown next to the subject
|
||||
// line. Plain text; optional.
|
||||
Preheader string
|
||||
// Title is the headline at the top of the card. Plain text; optional.
|
||||
Title string
|
||||
// BodyHTML is the card content below the title. Trusted HTML — callers
|
||||
// must escape any user-controlled values before building it.
|
||||
BodyHTML string
|
||||
// FooterHTML is the fine print under the card. Trusted HTML; optional.
|
||||
FooterHTML string
|
||||
}
|
||||
|
||||
// RenderLayout wraps content in Silo's dark branded email shell: wordmark,
|
||||
// content card, and footer. It adds no links of its own, so an email whose
|
||||
// options carry no hrefs renders fully link-free (some features require
|
||||
// that when no external URL is configured).
|
||||
func RenderLayout(opts LayoutOptions) string {
|
||||
preheader := ""
|
||||
if opts.Preheader != "" {
|
||||
// The trailing zwnj/nbsp run pads the preview so clients don't pull
|
||||
// body markup into the snippet after the real preheader text.
|
||||
preheader = `<div style="display:none;max-height:0;overflow:hidden;mso-hide:all;">` +
|
||||
html.EscapeString(opts.Preheader) +
|
||||
strings.Repeat(" ‌", 40) + `</div>` + "\n"
|
||||
}
|
||||
title := ""
|
||||
if opts.Title != "" {
|
||||
title = `<h1 style="margin:0 0 16px;font:600 18px/1.4 ` + EmailFont +
|
||||
`;color:` + EmailColorText + `;">` + html.EscapeString(opts.Title) + `</h1>` + "\n"
|
||||
}
|
||||
footer := ""
|
||||
if opts.FooterHTML != "" {
|
||||
footer = `<tr><td style="padding:18px 6px 0;font:400 12px/1.7 ` + EmailFont +
|
||||
`;color:` + EmailColorMuted + `;">` + opts.FooterHTML + `</td></tr>` + "\n"
|
||||
}
|
||||
|
||||
return strings.NewReplacer(
|
||||
"{{preheader}}", preheader,
|
||||
"{{title}}", title,
|
||||
"{{body}}", opts.BodyHTML,
|
||||
"{{footer}}", footer,
|
||||
"{{font}}", EmailFont,
|
||||
"{{canvas}}", EmailColorCanvas,
|
||||
"{{card}}", EmailColorCard,
|
||||
"{{border}}", EmailColorBorder,
|
||||
"{{text}}", EmailColorText,
|
||||
).Replace(emailShell)
|
||||
}
|
||||
|
||||
// EmailButton renders the primary call-to-action: a white pill on the dark
|
||||
// card, matching the web UI's primary action style. Both arguments are
|
||||
// escaped here. The wrapping table keeps the button shape in Outlook, which
|
||||
// ignores padding on anchors.
|
||||
func EmailButton(label, href string) string {
|
||||
return `<table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr>` +
|
||||
`<td bgcolor="` + EmailColorAction + `" style="background-color:` + EmailColorAction +
|
||||
`;border-radius:8px;mso-padding-alt:12px 24px;">` +
|
||||
`<a href="` + html.EscapeString(href) + `" style="display:inline-block;padding:12px 24px;` +
|
||||
`font:600 14px/1 ` + EmailFont + `;color:` + EmailColorOnAct +
|
||||
`;text-decoration:none;border-radius:8px;">` + html.EscapeString(label) + `</a>` +
|
||||
`</td></tr></table>`
|
||||
}
|
||||
|
||||
// EmailParagraph renders one body paragraph in the standard text style,
|
||||
// escaping the given plain text.
|
||||
func EmailParagraph(text string) string {
|
||||
return `<p style="margin:0 0 16px;font:400 14px/1.6 ` + EmailFont +
|
||||
`;color:` + EmailColorText + `;">` + html.EscapeString(text) + `</p>`
|
||||
}
|
||||
|
||||
// emailShell is the document skeleton. The color-scheme meta plus explicit
|
||||
// bgcolor attributes keep dark-mode-aware clients from inverting the design;
|
||||
// the small stylesheet only tightens padding on narrow screens (supported by
|
||||
// Gmail/Apple Mail, harmlessly ignored elsewhere).
|
||||
const emailShell = `<!DOCTYPE html>
|
||||
<html lang="en" style="color-scheme:dark;supported-color-schemes:dark;">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<meta name="supported-color-schemes" content="dark">
|
||||
<style>
|
||||
@media (max-width: 480px) {
|
||||
.silo-shell { padding: 24px 12px 36px !important; }
|
||||
.silo-card { padding: 24px 20px !important; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background-color:{{canvas}};" bgcolor="{{canvas}}">
|
||||
{{preheader}}<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="{{canvas}}" style="background-color:{{canvas}};">
|
||||
<tr><td align="center" class="silo-shell" style="padding:36px 16px 48px;">
|
||||
<table role="presentation" width="560" cellpadding="0" cellspacing="0" border="0" style="width:100%;max-width:560px;">
|
||||
<tr><td style="padding:0 6px 18px;font:600 12px/1 {{font}};color:{{text}};letter-spacing:7px;"><span style="color:#55555e;">▸︎</span> SILO</td></tr>
|
||||
<tr><td class="silo-card" bgcolor="{{card}}" style="background-color:{{card}};border:1px solid {{border}};border-radius:12px;padding:28px 32px;">
|
||||
{{title}}{{body}}
|
||||
</td></tr>
|
||||
{{footer}}</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`
|
||||
@@ -0,0 +1,52 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRenderLayoutEscapesAndPlacesContent(t *testing.T) {
|
||||
out := RenderLayout(LayoutOptions{
|
||||
Preheader: `sneak <script>alert(1)</script>`,
|
||||
Title: `Title & <b>bold</b>`,
|
||||
BodyHTML: `<p id="body-marker">trusted</p>`,
|
||||
FooterHTML: `<span id="footer-marker">fine print</span>`,
|
||||
})
|
||||
if strings.Contains(out, "<script>") || strings.Contains(out, "<b>bold</b>") {
|
||||
t.Fatalf("preheader/title not escaped:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "Title & <b>bold</b>") {
|
||||
t.Fatalf("escaped title missing:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, `<p id="body-marker">trusted</p>`) {
|
||||
t.Fatalf("body HTML not passed through:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, `<span id="footer-marker">fine print</span>`) {
|
||||
t.Fatalf("footer HTML not passed through:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "SILO") {
|
||||
t.Fatalf("wordmark missing:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Some emails must render fully link-free when no external URL is configured;
|
||||
// the shell itself must therefore never contribute one.
|
||||
func TestRenderLayoutAddsNoLinks(t *testing.T) {
|
||||
out := RenderLayout(LayoutOptions{Title: "Hello", BodyHTML: "<p>hi</p>"})
|
||||
if strings.Contains(out, "href=") {
|
||||
t.Fatalf("layout shell added a link:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "<h1") && strings.Contains(RenderLayout(LayoutOptions{BodyHTML: "x"}), "<h1") {
|
||||
t.Fatalf("empty title should not render an <h1>")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmailButtonEscapes(t *testing.T) {
|
||||
out := EmailButton(`Click "here" <now>`, `https://example.com/?a=1&b=<2>`)
|
||||
if !strings.Contains(out, `href="https://example.com/?a=1&b=<2>"`) {
|
||||
t.Fatalf("href not escaped: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "<now>") {
|
||||
t.Fatalf("label not escaped: %s", out)
|
||||
}
|
||||
}
|
||||
@@ -148,22 +148,31 @@ func composeVerificationEmail(profileName, verifyURL string) emailContent {
|
||||
if profileName != "" {
|
||||
who = "the profile “" + profileName + "”"
|
||||
}
|
||||
expiry := "The link expires in 24 hours. If you didn't request this, ignore this email — " +
|
||||
"nothing will be sent to this address."
|
||||
text := fmt.Sprintf(
|
||||
"This address was entered as the notification destination for %s on a Silo server.\n\n"+
|
||||
"To confirm and start receiving notifications here, open this link:\n\n %s\n\n"+
|
||||
"The link expires in 24 hours. If you didn't request this, ignore this email — "+
|
||||
"nothing will be sent to this address.\n", who, verifyURL)
|
||||
htmlBody := fmt.Sprintf(`<div style="font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;font-size:14px;line-height:1.5;color:#1a1a1a;max-width:560px;">
|
||||
<p style="margin:0 0 8px;">This address was entered as the notification destination for %s on a Silo server.</p>
|
||||
<p style="margin:0 0 16px;">To confirm and start receiving notifications here:</p>
|
||||
<p style="margin:0 0 16px;"><a href="%s" style="background:#6d6df7;color:#fff;text-decoration:none;padding:10px 18px;border-radius:6px;display:inline-block;">Confirm this address</a></p>
|
||||
<hr style="border:none;border-top:1px solid #e5e5e5;margin:16px 0 8px;">
|
||||
<p style="margin:0;font-size:12px;color:#888;">The link expires in 24 hours. If you didn't request this, ignore this email — nothing will be sent to this address.</p>
|
||||
</div>`,
|
||||
html.EscapeString(who), html.EscapeString(verifyURL))
|
||||
"%s\n", who, verifyURL, expiry)
|
||||
|
||||
var body strings.Builder
|
||||
body.WriteString(mail.EmailParagraph(fmt.Sprintf(
|
||||
"This address was entered as the notification destination for %s on a Silo server.", who)))
|
||||
body.WriteString(mail.EmailParagraph("To confirm and start receiving notifications here:"))
|
||||
body.WriteString(mail.EmailButton("Confirm this address", verifyURL))
|
||||
body.WriteString(fmt.Sprintf(
|
||||
`<p style="margin:20px 0 0;font:400 12px/1.7 %s;color:%s;">Or paste this link into your browser:<br>`+
|
||||
`<span style="font:400 12px/1.7 %s;word-break:break-all;">%s</span></p>`,
|
||||
mail.EmailFont, mail.EmailColorMuted, mail.EmailFontMono, html.EscapeString(verifyURL)))
|
||||
|
||||
return emailContent{
|
||||
Subject: "Confirm your Silo notification address",
|
||||
Text: text,
|
||||
HTML: htmlBody,
|
||||
HTML: mail.RenderLayout(mail.LayoutOptions{
|
||||
Preheader: "Confirm this address to start receiving Silo notifications.",
|
||||
Title: "Confirm your notification address",
|
||||
BodyHTML: body.String(),
|
||||
FooterHTML: html.EscapeString(expiry),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"html"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/mail"
|
||||
)
|
||||
|
||||
// emailMaxItemsRendered caps how many lines one email renders; the remainder
|
||||
@@ -218,19 +220,31 @@ func composeNotificationEmail(mode string, rows []DeliveryRow, opts emailCompose
|
||||
rendered := 0
|
||||
total := items.episodes + len(items.requests) + len(items.others)
|
||||
|
||||
writeLine := func(plain, href string) {
|
||||
// writeItem renders one row: plain feeds the text body; code (optional
|
||||
// "S02E03" badge) and label feed the HTML row.
|
||||
writeItem := func(plain, code, label, href string) {
|
||||
rendered++
|
||||
if rendered > emailMaxItemsRendered {
|
||||
return
|
||||
}
|
||||
text.WriteString(" " + plain + "\n")
|
||||
if href != "" {
|
||||
body.WriteString(fmt.Sprintf(
|
||||
`<li style="margin:2px 0;"><a href="%s" style="color:#6d6df7;text-decoration:none;">%s</a></li>`,
|
||||
html.EscapeString(href), html.EscapeString(plain)))
|
||||
} else {
|
||||
body.WriteString(fmt.Sprintf(`<li style="margin:2px 0;">%s</li>`, html.EscapeString(plain)))
|
||||
var inner strings.Builder
|
||||
if code != "" {
|
||||
inner.WriteString(fmt.Sprintf(`<span style="font:500 12px/1 %s;color:%s;">%s</span>`,
|
||||
mail.EmailFontMono, mail.EmailColorMuted, html.EscapeString(code)))
|
||||
if label != "" {
|
||||
inner.WriteString(" ")
|
||||
}
|
||||
}
|
||||
inner.WriteString(html.EscapeString(label))
|
||||
content := inner.String()
|
||||
if href != "" {
|
||||
content = fmt.Sprintf(`<a href="%s" style="color:%s;text-decoration:none;">%s</a>`,
|
||||
html.EscapeString(href), mail.EmailColorText, content)
|
||||
}
|
||||
body.WriteString(fmt.Sprintf(
|
||||
`<li style="margin:0;padding:8px 2px;border-top:1px solid %s;font:400 14px/1.5 %s;color:%s;">%s</li>`,
|
||||
mail.EmailColorRule, mail.EmailFont, mail.EmailColorText, content))
|
||||
}
|
||||
writeHeading := func(title, href string) {
|
||||
text.WriteString(title + "\n")
|
||||
@@ -239,10 +253,15 @@ func composeNotificationEmail(mode string, rows []DeliveryRow, opts emailCompose
|
||||
label = fmt.Sprintf(`<a href="%s" style="color:inherit;text-decoration:none;">%s</a>`,
|
||||
html.EscapeString(href), label)
|
||||
}
|
||||
top := "22px"
|
||||
if body.Len() == 0 {
|
||||
top = "0"
|
||||
}
|
||||
body.WriteString(fmt.Sprintf(
|
||||
`<h3 style="margin:14px 0 4px;font-size:15px;">%s</h3>`, label))
|
||||
`<h2 style="margin:%s 0 6px;font:600 15px/1.4 %s;color:%s;">%s</h2>`,
|
||||
top, mail.EmailFont, mail.EmailColorText, label))
|
||||
}
|
||||
openList := func() { body.WriteString(`<ul style="margin:4px 0;padding-left:20px;">`) }
|
||||
openList := func() { body.WriteString(`<ul style="margin:0;padding:0;list-style:none;">`) }
|
||||
closeList := func() { body.WriteString(`</ul>`) }
|
||||
|
||||
for _, group := range items.series {
|
||||
@@ -256,7 +275,12 @@ func composeNotificationEmail(mode string, rows []DeliveryRow, opts emailCompose
|
||||
if row.EpisodeID != nil {
|
||||
episodeID = *row.EpisodeID
|
||||
}
|
||||
writeLine(episodeLine(row), itemURL(baseURL, episodeID))
|
||||
code := episodeCode(row)
|
||||
label := row.EpisodeTitle
|
||||
if code == "" && label == "" {
|
||||
label = genericEpisodeTitle
|
||||
}
|
||||
writeItem(episodeLine(row), code, label, itemURL(baseURL, episodeID))
|
||||
}
|
||||
closeList()
|
||||
}
|
||||
@@ -268,7 +292,7 @@ func composeNotificationEmail(mode string, rows []DeliveryRow, opts emailCompose
|
||||
if row.SeriesID != nil {
|
||||
seriesID = *row.SeriesID
|
||||
}
|
||||
writeLine(requestLine(row), itemURL(baseURL, seriesID))
|
||||
writeItem(requestLine(row), "", requestLine(row), itemURL(baseURL, seriesID))
|
||||
}
|
||||
closeList()
|
||||
}
|
||||
@@ -276,15 +300,15 @@ func composeNotificationEmail(mode string, rows []DeliveryRow, opts emailCompose
|
||||
writeHeading("Other updates", "")
|
||||
openList()
|
||||
for _, row := range items.others {
|
||||
writeLine(otherLine(row), "")
|
||||
writeItem(otherLine(row), "", otherLine(row), "")
|
||||
}
|
||||
closeList()
|
||||
}
|
||||
if remainder := total - emailMaxItemsRendered; remainder > 0 {
|
||||
more := fmt.Sprintf("…and %d more in your Silo inbox.", remainder)
|
||||
text.WriteString(more + "\n")
|
||||
body.WriteString(fmt.Sprintf(
|
||||
`<p style="margin:8px 0;color:#888;">%s</p>`, html.EscapeString(more)))
|
||||
body.WriteString(fmt.Sprintf(`<p style="margin:14px 0 0;font:400 13px/1.5 %s;color:%s;">%s</p>`,
|
||||
mail.EmailFont, mail.EmailColorMuted, html.EscapeString(more)))
|
||||
}
|
||||
|
||||
forProfile := ""
|
||||
@@ -311,21 +335,21 @@ func composeNotificationEmail(mode string, rows []DeliveryRow, opts emailCompose
|
||||
settingsURL := html.EscapeString(baseURL + "/settings/notifications")
|
||||
footerHTML = strings.Replace(footerHTML,
|
||||
"Settings → Notifications",
|
||||
fmt.Sprintf(`<a href="%s" style="color:#888;">Settings → Notifications</a>`, settingsURL), 1)
|
||||
fmt.Sprintf(`<a href="%s" style="color:%s;">Settings → Notifications</a>`,
|
||||
settingsURL, mail.EmailColorMuted), 1)
|
||||
}
|
||||
if opts.UnsubscribeURL != "" {
|
||||
footer += " To stop these emails, open: " + opts.UnsubscribeURL
|
||||
footerHTML += fmt.Sprintf(` <a href="%s" style="color:#888;">Unsubscribe</a>`,
|
||||
html.EscapeString(opts.UnsubscribeURL))
|
||||
footerHTML += fmt.Sprintf(` <a href="%s" style="color:%s;">Unsubscribe</a>`,
|
||||
html.EscapeString(opts.UnsubscribeURL), mail.EmailColorMuted)
|
||||
}
|
||||
|
||||
htmlBody := fmt.Sprintf(`<div style="font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;font-size:14px;line-height:1.5;color:#1a1a1a;max-width:560px;">
|
||||
<p style="margin:0 0 8px;">%s</p>
|
||||
%s
|
||||
<hr style="border:none;border-top:1px solid #e5e5e5;margin:16px 0 8px;">
|
||||
<p style="margin:0;font-size:12px;color:#888;">%s</p>
|
||||
</div>`,
|
||||
html.EscapeString(intro), body.String(), footerHTML)
|
||||
htmlBody := mail.RenderLayout(mail.LayoutOptions{
|
||||
Preheader: emailPreheader(items),
|
||||
Title: strings.TrimSuffix(intro, ":"),
|
||||
BodyHTML: body.String(),
|
||||
FooterHTML: footerHTML,
|
||||
})
|
||||
|
||||
return emailContent{
|
||||
Subject: emailSubject(mode, items) + subjectFor,
|
||||
@@ -333,3 +357,22 @@ func composeNotificationEmail(mode string, rows []DeliveryRow, opts emailCompose
|
||||
HTML: htmlBody,
|
||||
}
|
||||
}
|
||||
|
||||
// emailPreheader picks the inbox-preview snippet: the first item, the same
|
||||
// way a notification banner would lead with it.
|
||||
func emailPreheader(items emailItems) string {
|
||||
if len(items.series) > 0 && len(items.series[0].episodes) > 0 {
|
||||
line := episodeLine(items.series[0].episodes[0])
|
||||
if title := items.series[0].title; title != untitledSeriesGroup {
|
||||
return title + " · " + line
|
||||
}
|
||||
return line
|
||||
}
|
||||
if len(items.requests) > 0 {
|
||||
return requestLine(items.requests[0])
|
||||
}
|
||||
if len(items.others) > 0 {
|
||||
return otherLine(items.others[0])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user