diff --git a/internal/api/handlers/email.go b/internal/api/handlers/email.go index 751c3821..3c0a7373 100644 --- a/internal/api/handlers/email.go +++ b/internal/api/handlers/email.go @@ -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, diff --git a/internal/mail/layout.go b/internal/mail/layout.go new file mode 100644 index 00000000..f692ccaa --- /dev/null +++ b/internal/mail/layout.go @@ -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 = `
` + "\n" + } + title := "" + if opts.Title != "" { + title = `| ` + + `` + html.EscapeString(label) + `` + + ` |
` + html.EscapeString(text) + `
` +} + +// 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 = ` + + + + + + + + + +{{preheader}}
+
|
trusted
`, + FooterHTML: ``, + }) + if strings.Contains(out, "