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:
Quick
2026-06-11 21:34:19 -04:00
co-authored by Claude Fable 5
parent ebf3352bda
commit 88ddd2a406
5 changed files with 281 additions and 35 deletions
+7
View File
@@ -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,
+135
View File
@@ -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("&nbsp;&zwnj;", 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;">&#9656;&#xFE0E;</span>&nbsp;&nbsp;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>`
+52
View File
@@ -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 &amp; &lt;b&gt;bold&lt;/b&gt;") {
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&amp;b=&lt;2&gt;"`) {
t.Fatalf("href not escaped: %s", out)
}
if strings.Contains(out, "<now>") {
t.Fatalf("label not escaped: %s", out)
}
}
+20 -11
View File
@@ -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),
}),
}
}
+67 -24
View File
@@ -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("&nbsp;&nbsp;")
}
}
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 ""
}