diff --git a/server/cmd/devloop/main.go b/server/cmd/devloop/main.go index dcae3acf8..dd2832cd3 100644 --- a/server/cmd/devloop/main.go +++ b/server/cmd/devloop/main.go @@ -123,7 +123,7 @@ func run(ctx context.Context, cfg config) error { if err := addWatches(watcher, rootDir); err != nil { return fmt.Errorf("watch setup: %w", err) } - log.Printf("watching server/ for .go changes") + log.Printf("watching server/ for .go and .html changes") rebuilds := make(chan struct{}, 1) go debounce(ctx, watcher, rebuilds) @@ -257,7 +257,11 @@ func shouldRebuild(event fsnotify.Event) bool { return false } name := filepath.Base(event.Name) - if !strings.HasSuffix(name, ".go") { + // .html as well as .go: the email templates in internal/email/templates are + // //go:embed-ed into the binary, so editing one changes nothing until a + // rebuild — which made the /api/mgmt/email-preview loop (whose entire point + // is iterating on those templates) silently serve the previous version. + if !strings.HasSuffix(name, ".go") && !strings.HasSuffix(name, ".html") { return false } if strings.HasSuffix(name, "_test.go") { diff --git a/server/internal/app/email_branding_test.go b/server/internal/app/email_branding_test.go new file mode 100644 index 000000000..eafecb9fd --- /dev/null +++ b/server/internal/app/email_branding_test.go @@ -0,0 +1,71 @@ +package app + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/fclairamb/solidping/server/internal/config" +) + +// TestEmailFormatterUsesThePostOverlayBaseURL pins the WIRING at +// server.go's `email.NewFormatter(email.WithBaseURLFunc(...))`, not just the +// option's behavior. +// +// config.Server.BaseURL is NOT final when NewServer builds the formatter: the +// systemconfig overlay — which is what actually applies SP_BASE_URL and the +// DB-stored `server.base_url` parameter — runs afterwards, in +// InitializeSystemConfig. Capturing the value at construction +// (`WithBaseURL(cfg.Server.BaseURL)`) therefore pins every email to the +// pre-overlay default, which in a real deployment means every transactional +// email ships a localhost logo URL. That regression is invisible to the +// email package's own tests, so it has to be caught here, against a real +// server holding the real *config.Config the overlay mutates. +// +// The pre-overlay assertion is the positive control: without it this test +// would still pass against a formatter that rendered no logo at all. +func TestEmailFormatterUsesThePostOverlayBaseURL(t *testing.T) { + const ( + preOverlayBaseURL = "http://localhost:4000" + postOverlayBaseURL = "https://monitoring.acme.example" + ) + + // Hermetic: an SP_BASE_URL inherited from the developer's shell would win + // over the stored parameter (env > db > default) and mask the assertion. + // Empty reads exactly like unset in systemconfig's overlay loop. Not + // t.Parallel() — t.Setenv forbids it. + t.Setenv("SP_BASE_URL", "") + + r := require.New(t) + ctx := context.Background() + + cfg := &config.Config{} + cfg.Database.Type = dbTypeSQLiteMemory + cfg.Auth.JWTSecret = "email-branding-secret" + cfg.Server.BaseURL = preOverlayBaseURL + + server, err := NewServer(ctx, cfg) + r.NoError(err) + t.Cleanup(func() { _ = server.dbService.Close() }) + + r.NoError(server.Initialize(ctx)) + + _, before, _, err := server.services.EmailFormatter.Format("welcome.html", map[string]any{}) + r.NoError(err) + r.Contains(before, preOverlayBaseURL+"/dash0/logo.png", + "the formatter must render the configured base URL before the overlay") + + // The overlay, exactly as boot performs it — and strictly AFTER NewServer + // already built the formatter. + r.NoError(server.dbService.SetSystemParameter(ctx, "server.base_url", postOverlayBaseURL, false)) + r.NoError(server.InitializeSystemConfig(ctx, cfg)) + r.Equal(postOverlayBaseURL, cfg.Server.BaseURL, "the overlay must have moved the base URL") + + _, after, _, err := server.services.EmailFormatter.Format("welcome.html", map[string]any{}) + r.NoError(err) + r.Contains(after, postOverlayBaseURL+"/dash0/logo.png", + "emails must use the post-overlay base URL; a captured (non-late-bound) base URL fails here") + r.NotContains(after, preOverlayBaseURL, + "no trace of the pre-overlay base URL may survive in a rendered email") +} diff --git a/server/internal/app/server.go b/server/internal/app/server.go index 4c5e62dc7..0f814c67d 100644 --- a/server/internal/app/server.go +++ b/server/internal/app/server.go @@ -335,7 +335,15 @@ func NewServer(ctx context.Context, cfg *config.Config) (*Server, error) { emailSender := email.NewSender(&cfg.Email, slog.Default()) svcList.EmailSender = emailSender - emailFormatter, err := email.NewFormatter() + // The base URL is what makes the logo in base.html absolute — the + // same base DashboardURL / DocsURL are built from, so an email never mixes + // two origins. It is resolved LATE (per render) rather than captured here: + // the systemconfig overlay that applies SP_BASE_URL and the DB-stored + // `server.base_url` parameter runs after this wiring, so capturing the + // value now would pin every email to the pre-overlay default. + emailFormatter, err := email.NewFormatter( + email.WithBaseURLFunc(func() string { return cfg.Server.BaseURL }), + ) if err != nil { return nil, fmt.Errorf("failed to create email formatter: %w", err) } @@ -2055,6 +2063,7 @@ func (s *Server) SetupRoutes(ctx context.Context) { // Not compiled out — just 404s outside test mode, like the routes // above. emailPreviewHandler := emailpreview.NewHandler(s.services.EmailFormatter, s.config) + mgmt.GET("/email-preview", emailPreviewHandler.Index) mgmt.GET("/email-preview/:template", emailPreviewHandler.Preview) } diff --git a/server/internal/app/testapi_routes_gate_test.go b/server/internal/app/testapi_routes_gate_test.go index 3e89d0999..1d93c25e4 100644 --- a/server/internal/app/testapi_routes_gate_test.go +++ b/server/internal/app/testapi_routes_gate_test.go @@ -65,6 +65,7 @@ var testModeGatedRoutes = []gatedRoute{ {"generate data", http.MethodPost, "/api/v1/test/generate-data"}, {"delete all checks", http.MethodDelete, "/api/v1/test/checks/all"}, {"email preview", http.MethodGet, "/api/mgmt/email-preview/welcome.html"}, + {"email preview index", http.MethodGet, "/api/mgmt/email-preview"}, } // TestTestModeRoutesGatedByRunMode is the positive+negative control for the diff --git a/server/internal/customdomain/alert.go b/server/internal/customdomain/alert.go index 361f7197e..e8b86b04e 100644 --- a/server/internal/customdomain/alert.go +++ b/server/internal/customdomain/alert.go @@ -137,12 +137,14 @@ func mailDemoted( } data := map[string]any{ - "OrgName": org.Name, "StatusPageName": page.Name, "Domain": domain, "Diagnostic": diagnostic, "SettingsURL": settingsURL, } + // Operator mail about a status page, addressed to the org's admins: it + // wears the ORG's branding, not the page's. + email.ApplyOrgBranding(data, org.Name, org.LogoURL) for _, member := range members { if !member.Role.AtLeast(models.MemberRoleAdmin) || member.User == nil || member.User.Email == "" { diff --git a/server/internal/email/branding.go b/server/internal/email/branding.go new file mode 100644 index 000000000..9c48517e5 --- /dev/null +++ b/server/internal/email/branding.go @@ -0,0 +1,71 @@ +package email + +// Branding view-model keys. base.html reads these three (through the +// nil-tolerant `field` helper — see formatter.go) to decide what the header +// wears. They are constants because they are referenced from a dozen call +// sites across handlers, jobs and notifiers, and a typo in one of them is a +// silently unbranded email rather than a compile error. +const ( + // KeyOrgName is the organization's display name. Also drives the existing + // footer attribution (" — sent by SolidPing"). + KeyOrgName = "OrgName" + // KeyBrandName is the alt text / wordmark shown for the primary logo. It is + // the org name for operator mail and the status PAGE name for subscriber + // mail, which is why it is a key of its own rather than reusing OrgName. + KeyBrandName = "BrandName" + // KeyOrgLogoURL is the primary logo, stored either as an external https URL + // or as a site-relative "/pub/assets/" path. The formatter's absURL + // helper makes it absolute. + KeyOrgLogoURL = "OrgLogoURL" + // KeyHideBranding is the white-label opt-in: no logo, and no SolidPing + // attribution, anywhere in the message. + KeyHideBranding = "HideBranding" +) + +// ApplyOrgBranding stamps an organization's branding onto an email view model. +// +// Empty values are written as empty strings rather than omitted: base.html +// treats "" as "no org logo" and falls back to the SolidPing logo, so a +// partially-branded org degrades instead of rendering a broken image. +func ApplyOrgBranding(viewModel map[string]any, orgName string, logoURL *string) { + if viewModel == nil { + return + } + + viewModel[KeyOrgName] = orgName + viewModel[KeyBrandName] = orgName + viewModel[KeyOrgLogoURL] = derefString(logoURL) +} + +// ApplyStatusPageBranding stamps a STATUS PAGE's branding onto an email view +// model — used by the status-subscriber templates only. +// +// A subscriber opted into a status page, not into the organization behind it, +// so org branding must never leak into these emails: this deliberately sets +// no OrgName, which is also what keeps the " — sent by SolidPing" footer +// line out of subscriber mail. When the page has hide_branding set, no logo +// and no SolidPing attribution is rendered at all. +func ApplyStatusPageBranding(viewModel map[string]any, pageName string, logoURL *string, hideBranding bool) { + if viewModel == nil { + return + } + + viewModel[KeyBrandName] = pageName + viewModel[KeyHideBranding] = hideBranding + + if hideBranding { + viewModel[KeyOrgLogoURL] = "" + + return + } + + viewModel[KeyOrgLogoURL] = derefString(logoURL) +} + +func derefString(value *string) string { + if value == nil { + return "" + } + + return *value +} diff --git a/server/internal/email/branding_test.go b/server/internal/email/branding_test.go new file mode 100644 index 000000000..5fce65fcc --- /dev/null +++ b/server/internal/email/branding_test.go @@ -0,0 +1,478 @@ +package email + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +const brandingTestBaseURL = "https://solidping.example" + +// brandingViewModel is the minimal incident view model the branding tests +// render — incident-created.html is used because it exercises the full +// wrapper (header, status banner, details table, footer). +func brandingViewModel() map[string]any { + return map[string]any{ + "CheckName": "Production API", + "CheckType": "http", + "IncidentNumber": 42, + "IncidentUID": "inc-123", + "StartedAt": "2026-07-05 10:00:00", + } +} + +func renderBranded(t *testing.T, baseURL string, data any) string { + t.Helper() + + formatter, err := NewFormatter(WithBaseURL(baseURL)) + require.NoError(t, err) + + _, html, _, err := formatter.Format("incident-created.html", data) + require.NoError(t, err) + + return html +} + +// TestBranding_ProductLogoIsAbsoluteWithTextFallback covers the resolved +// decision: a REMOTE (never a CID attachment) whose src is absolute, and +// whose alt is the wordmark so a client blocking remote images still shows a +// legible header. +func TestBranding_ProductLogoIsAbsoluteWithTextFallback(t *testing.T) { + t.Parallel() + + r := require.New(t) + html := renderBranded(t, brandingTestBaseURL, brandingViewModel()) + + r.Contains(html, `src="https://solidping.example/dash0/logo.png"`) + r.Contains(html, `alt="SolidPing"`) + // The wordmark survives next to the mark, so the header reads correctly + // with images off. + r.Contains(html, ">SolidPing<") + // PNG, not SVG: mail-client support for SVG is patchy to non-existent. + r.NotContains(html, "logo.svg") + // No CID: sender.go's MIME assembly is explicitly out of scope. + r.NotContains(html, "cid:") +} + +// TestBranding_NoBaseURLFallsBackToWordmark is the negative control for the +// test above: with nothing to build an absolute URL from, the header renders +// the text wordmark rather than a broken image. +func TestBranding_NoBaseURLFallsBackToWordmark(t *testing.T) { + t.Parallel() + + r := require.New(t) + html := renderBranded(t, "", brandingViewModel()) + + r.NotContains(html, "SolidPing<") +} + +// TestBranding_OrgLogoReplacesTheProductLogo pins stage 3's org branding: the +// org's logo becomes the primary mark, SolidPing drops to the secondary +// "sent by SolidPing" attribution, and a stored site-relative /pub/assets path +// is made absolute (a relative src in an email is just a broken image). +func TestBranding_OrgLogoReplacesTheProductLogo(t *testing.T) { + t.Parallel() + + r := require.New(t) + + data := brandingViewModel() + data["OrgName"] = "Acme Corp" + data["BrandName"] = "Acme Corp" + data["OrgLogoURL"] = "/pub/assets/7c9e6679-7425-40de-944b-e07fc1f90ae7" + + html := renderBranded(t, brandingTestBaseURL, data) + + r.Contains(html, `src="https://solidping.example/pub/assets/7c9e6679-7425-40de-944b-e07fc1f90ae7"`) + r.Contains(html, `alt="Acme Corp"`) + r.Contains(html, "sent by SolidPing") + // The product logo is not shown alongside it — one primary mark only. + r.NotContains(html, "/dash0/logo.png") + // The footer attribution (the existing wording) still names the org. + r.Contains(html, "Acme Corp — sent by SolidPing") +} + +// TestBranding_ExternalOrgLogoPassesThrough — an org may store a full external +// URL instead of an upload; it must not be prefixed with the base URL. +func TestBranding_ExternalOrgLogoPassesThrough(t *testing.T) { + t.Parallel() + + r := require.New(t) + + data := brandingViewModel() + data["OrgLogoURL"] = "https://cdn.acme.com/logo.png" + + html := renderBranded(t, brandingTestBaseURL, data) + + r.Contains(html, `src="https://cdn.acme.com/logo.png"`) + r.NotContains(html, "https://solidping.example/https://cdn.acme.com") +} + +// TestBranding_HideBrandingRendersNoLogo is the white-label requirement from +// the resolved open questions: when the status page sets hide_branding, the +// status-subscriber mail carries NO logo and no SolidPing attribution at all, +// even though a logo URL is present in the view model. +// +// The first half is the positive control — the very same view model WITHOUT +// the flag does render the logo — so a passing test cannot mean "the branding +// never renders anyway". +func TestBranding_HideBrandingRendersNoLogo(t *testing.T) { + t.Parallel() + + r := require.New(t) + + data := brandingViewModel() + data["OrgName"] = "Acme Corp" + data["BrandName"] = "Acme Status" + data["OrgLogoURL"] = "/pub/assets/page-logo" + + shown := renderBranded(t, brandingTestBaseURL, data) + r.Contains(shown, "/pub/assets/page-logo") + r.Contains(shown, "sent by SolidPing") + + data["HideBranding"] = true + hidden := renderBranded(t, brandingTestBaseURL, data) + + r.NotContains(hidden, ". +func TestBranding_NonHTTPLogoIsDropped(t *testing.T) { + t.Parallel() + + for _, hostile := range []string{ + "javascript:alert(1)", + "data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=", + "assets/relative-logo.png", + } { + t.Run(hostile, func(t *testing.T) { + t.Parallel() + + r := require.New(t) + + data := brandingViewModel() + data["OrgLogoURL"] = hostile + + html := renderBranded(t, brandingTestBaseURL, data) + + r.NotContains(html, hostile) + // It falls back to the product logo rather than rendering nothing. + r.Contains(html, "/dash0/logo.png") + }) + } +} + +// structViewModel has none of the branding fields base.html reads. It stands in +// for the real struct-backed view models (uptimereport.Data and friends). +// +// It is rendered through test-email.html rather than an incident template +// because the incident templates read a wider field set of their own — the +// point under test is base.html's wrapper, not the content block. +type structViewModel struct { + Subject string + Heading string + Body string +} + +func renderBrandedStruct(t *testing.T, data any) string { + t.Helper() + + formatter, err := NewFormatter(WithBaseURL(brandingTestBaseURL)) + require.NoError(t, err) + + _, html, _, err := formatter.Format("test-email.html", data) + require.NoError(t, err) + + return html +} + +// TestBranding_StructViewModelWithoutBrandingFields is the guard for the trap +// documented in supportreply.go: html/template ERRORS on a missing STRUCT +// field, so referencing `.OrgLogoURL` directly in base.html would break every +// struct-backed template at send time. Reading the branding keys through the +// nil-tolerant `field` helper is what makes this render instead of failing. +// +// The positive control below proves the same helper still FINDS the field when +// the struct does carry it — otherwise `field` could be returning nil always +// and this test would still pass. +func TestBranding_StructViewModelWithoutBrandingFields(t *testing.T) { + t.Parallel() + + r := require.New(t) + + html := renderBrandedStruct(t, structViewModel{ + Subject: "Hello", Heading: "Hello", Body: "Body text", + }) + + r.Contains(html, "Body text") + r.Contains(html, "/dash0/logo.png") +} + +type brandedStructViewModel struct { + structViewModel + + OrgName string + BrandName string + OrgLogoURL string +} + +func TestBranding_StructViewModelWithBrandingFields(t *testing.T) { + t.Parallel() + + r := require.New(t) + + html := renderBrandedStruct(t, brandedStructViewModel{ + structViewModel: structViewModel{ + Subject: "Hello", Heading: "Hello", Body: "Body text", + }, + OrgName: "Acme Corp", + BrandName: "Acme Corp", + OrgLogoURL: "/pub/assets/struct-logo", + }) + + r.Contains(html, "https://solidping.example/pub/assets/struct-logo") + r.Contains(html, "Acme Corp — sent by SolidPing") +} + +// TestBranding_EveryTemplateRendersWithAndWithoutBranding renders every shipped +// template both bare and with the full branding key set, catching a template +// that would break on either shape. +func TestBranding_EveryTemplateRendersWithAndWithoutBranding(t *testing.T) { + t.Parallel() + + names, err := ShippedTemplateNames() + require.NoError(t, err) + require.NotEmpty(t, names) + + formatter, err := NewFormatter(WithBaseURL(brandingTestBaseURL)) + require.NoError(t, err) + + branded := map[string]any{ + "OrgName": "Acme Corp", "BrandName": "Acme Corp", + "OrgLogoURL": "/pub/assets/logo", "HideBranding": false, + "IncidentNumber": 0, + } + + for _, name := range names { + t.Run(name, func(t *testing.T) { + t.Parallel() + + r := require.New(t) + + for label, data := range map[string]any{ + "bare": map[string]any{"IncidentNumber": 0}, + "branded": branded, + } { + _, html, _, formatErr := formatter.Format(name, data) + r.NoError(formatErr, "%s (%s)", name, label) + r.NotContains(html, "{{", "unresolved template syntax in %s (%s)", name, label) + } + }) + } +} + +func TestFormatterAbsoluteURL(t *testing.T) { + t.Parallel() + + formatter, err := NewFormatter(WithBaseURL("https://solidping.example/")) + require.NoError(t, err) + + value := "ptr" + + cases := map[string]struct { + in any + want string + }{ + "nil": {nil, ""}, + "empty": {"", ""}, + "relative": {"/pub/assets/x", "https://solidping.example/pub/assets/x"}, + "absolute": {"https://cdn.acme.com/l.png", "https://cdn.acme.com/l.png"}, + "plain http": {"http://cdn.acme.com/l.png", "http://cdn.acme.com/l.png"}, + "bare name": {"logo.png", ""}, + "javascript": {"javascript:alert(1)", ""}, + "pointer": {&value, ""}, + "wrong type": {42, ""}, + "whitespace": {" ", ""}, + "trailing pad": {" /pub/a ", "https://solidping.example/pub/a"}, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tc.want, formatter.absoluteURL(tc.in)) + }) + } +} + +func TestFormatterFieldHelper(t *testing.T) { + t.Parallel() + + r := require.New(t) + + r.Nil(field(nil, "X")) + r.Nil(field(map[string]any{}, "X")) + r.Equal("v", field(map[string]any{"X": "v"}, "X")) + r.Equal("v", field(struct{ X string }{X: "v"}, "X")) + r.Equal("v", field(&struct{ X string }{X: "v"}, "X")) + r.Nil(field(struct{ Y string }{}, "X")) + r.Nil(field((*struct{ X string })(nil), "X")) + r.Nil(field(map[int]string{1: "v"}, "X")) + r.Nil(field("not a container", "X")) + // Unexported fields cannot be read through reflection (.Interface() would + // panic), so they must read as absent rather than crash a send. + r.Nil(field(unexportedFieldStruct{value: "v"}, "value")) +} + +// unexportedFieldStruct exists only to prove field() refuses to read an +// unexported field instead of panicking on reflect.Value.Interface(). +type unexportedFieldStruct struct { + value string +} + +// TestBranding_BaseURLIsResolvedLate pins the late-binding contract behind +// WithBaseURLFunc: config.Server.BaseURL is not final when the formatter is +// constructed (the systemconfig overlay applies SP_BASE_URL afterwards), so +// the formatter must read it per render, not capture it once. +// +// Without this, every production email would carry the pre-overlay default +// (a localhost logo URL) — which is exactly the bug a side-car run surfaced. +func TestBranding_BaseURLIsResolvedLate(t *testing.T) { + t.Parallel() + + r := require.New(t) + + current := "http://localhost:4000" + + formatter, err := NewFormatter(WithBaseURLFunc(func() string { return current })) + r.NoError(err) + + _, before, _, err := formatter.Format("welcome.html", map[string]any{}) + r.NoError(err) + r.Contains(before, "http://localhost:4000/dash0/logo.png") + + // The overlay lands after construction. + current = "https://monitoring.acme.com/" + + _, after, _, err := formatter.Format("welcome.html", map[string]any{}) + r.NoError(err) + r.Contains(after, "https://monitoring.acme.com/dash0/logo.png") + r.NotContains(after, "localhost:4000") +} + +// TestBranding_HelperClassesWinOverTheGenericContentRules guards a subtle +// premailer trap: `.content h2` / `.content p` are MORE specific than a lone +// helper class, so a bare `.section-title` silently loses its font-size and +// margins to the generic rule once the CSS is inlined — the exact styling the +// per-template inline styles used to provide. The helpers are therefore +// declared as `.content h2.section-title` / `.content p.eyebrow`. +func TestBranding_HelperClassesWinOverTheGenericContentRules(t *testing.T) { + t.Parallel() + + r := require.New(t) + + formatter, err := NewFormatter(WithBaseURL(brandingTestBaseURL)) + r.NoError(err) + + _, report, _, err := formatter.Format("uptime-report.html", map[string]any{ + "HasData": true, "OrgName": "Acme Corp", "PeriodLabel": "July 2026", + "Checks": []map[string]any{{"Name": "API", "HasData": true, "AvailabilityPct": "99.9"}}, + }) + r.NoError(err) + // The section subhead keeps its own size and spacing, not the generic + // .content h2 ones, and its accent rule survives the inlining. + r.Contains(report, "font-size:16px") + r.Contains(report, "margin:26px 0 10px") + r.Contains(report, "border-left:3px solid #0072d5") + r.NotContains(report, "margin:0 0 16px;font-size:16px") + + _, update, _, err := formatter.Format("status-subscriber-update.html", map[string]any{ + "Subject": "s", "Label": "New incident", "Title": "T", "BodyMarkdown": "B", + "PageName": "Acme Status", + }) + r.NoError(err) + // The eyebrow keeps its uppercase treatment and its tight bottom margin. + r.Contains(update, "text-transform:uppercase") + r.Contains(update, "margin:0 0 6px") +} + +// TestApplyOrgBrandingWritesTheKeysBaseHTMLReads asserts the helper against +// LITERAL key names, deliberately not against the KeyOrgName/KeyBrandName/ +// KeyOrgLogoURL constants it uses: a typo inside a constant would rename the +// key on both sides at once and a constant-based assertion would happily +// follow it, silently unbranding every template that goes through this helper. +func TestApplyOrgBrandingWritesTheKeysBaseHTMLReads(t *testing.T) { + t.Parallel() + + r := require.New(t) + + logo := "/pub/assets/org-logo-uid" + viewModel := map[string]any{"Existing": "kept"} + + ApplyOrgBranding(viewModel, "Acme Corp", &logo) + + r.Equal("Acme Corp", viewModel["OrgName"]) + r.Equal("Acme Corp", viewModel["BrandName"]) + r.Equal("/pub/assets/org-logo-uid", viewModel["OrgLogoURL"]) + r.Equal("kept", viewModel["Existing"], "the helper must not clobber the caller's keys") + + // A logo-less org writes an EMPTY value rather than omitting the key, so + // the wrapper's fallback path is taken explicitly. Presence is asserted + // separately from emptiness on purpose — a missing key is also "empty", + // and that is exactly the case this pins down. + logoless := map[string]any{} + ApplyOrgBranding(logoless, "Acme Corp", nil) + + logoValue, present := logoless["OrgLogoURL"] + r.True(present, "the key must be written, not omitted") + r.Empty(logoValue) + r.Equal("Acme Corp", logoless["OrgName"]) + + // A nil view model is a no-op, not a panic: several callers build the map + // conditionally. + r.NotPanics(func() { ApplyOrgBranding(nil, "Acme Corp", &logo) }) +} + +// TestApplyOrgBrandingRendersThroughTheWrapper is the end-to-end half: the +// keys the helper writes must be the keys base.html actually reads. It is what +// catches a rename on either side — the six call sites (invitation, membership +// request new/decision, paging nudge, custom-domain demotion, escalation) all +// brand exclusively through this helper. +// +// The logo-less case is the positive control: it proves the assertion below is +// about the ORG logo specifically and not satisfied by any at all. +func TestApplyOrgBrandingRendersThroughTheWrapper(t *testing.T) { + t.Parallel() + + r := require.New(t) + + formatter, err := NewFormatter(WithBaseURL(brandingTestBaseURL)) + r.NoError(err) + + logo := "/pub/assets/org-logo-uid" + + branded := map[string]any{"Subject": "s", "Heading": "h", "Body": "b"} + ApplyOrgBranding(branded, "Acme Corp", &logo) + + _, html, _, err := formatter.Format("test-email.html", branded) + r.NoError(err) + r.Contains(html, brandingTestBaseURL+"/pub/assets/org-logo-uid") + r.Contains(html, `alt="Acme Corp"`) + r.Contains(html, "Acme Corp — sent by SolidPing") + r.NotContains(html, "/dash0/logo.png") + + logoless := map[string]any{"Subject": "s", "Heading": "h", "Body": "b"} + ApplyOrgBranding(logoless, "Acme Corp", nil) + + _, plain, _, err := formatter.Format("test-email.html", logoless) + r.NoError(err) + r.NotContains(plain, "/pub/assets/org-logo-uid") + r.Contains(plain, brandingTestBaseURL+"/dash0/logo.png") + // The org is still named in the footer even without a logo. + r.Contains(plain, "Acme Corp — sent by SolidPing") +} diff --git a/server/internal/email/formatter.go b/server/internal/email/formatter.go index e18cc442d..46ec7d695 100644 --- a/server/internal/email/formatter.go +++ b/server/internal/email/formatter.go @@ -6,7 +6,9 @@ import ( "errors" "fmt" "html/template" + "reflect" "strings" + texttemplate "text/template" "github.com/vanng822/go-premailer/premailer" ) @@ -20,22 +22,188 @@ var ( errDictKeyNotString = errors.New("dict: key is not a string") ) +// productLogoPath is the SolidPing logo served on every host, embedded from +// res/logo.png via `make sync-brand-assets`. PNG rather than SVG on purpose: +// SVG support in mail clients is patchy to non-existent. +const productLogoPath = "/dash0/logo.png" + // TemplateFormatter implements the Formatter interface using Go templates. type TemplateFormatter struct { funcMap template.FuncMap + // baseURL resolves the server base URL — the same one DashboardURL / + // DocsURL are built from. Absolute asset URLs (the logo in base.html's + // header) are derived from it. nil, or a func returning "", is legal: the + // templates then fall back to their text wordmark instead of rendering a + // broken image. + baseURL func() string +} + +// Option configures a TemplateFormatter. Variadic rather than a new required +// parameter so the many test constructions of a bare formatter keep compiling. +type Option func(*TemplateFormatter) + +// WithBaseURL pins a fixed server base URL. Convenient for tests; production +// wiring wants WithBaseURLFunc — see why there. +func WithBaseURL(baseURL string) Option { + return WithBaseURLFunc(func() string { return baseURL }) +} + +// WithBaseURLFunc sets a LATE-BOUND server base URL, read on every render. +// +// Late binding is not a nicety here. config.Server.BaseURL is not final when +// the formatter is constructed: NewServer builds it during wiring, and the +// systemconfig overlay (which is what actually applies SP_BASE_URL and the +// DB-stored `server.base_url` parameter) runs afterwards, in +// InitializeSystemConfig. A value captured at construction is therefore the +// pre-overlay default — every email would carry a localhost logo URL in +// production. Closing over the *config.Config the overlay mutates fixes that. +func WithBaseURLFunc(resolve func() string) Option { + return func(f *TemplateFormatter) { + f.baseURL = resolve + } +} + +// base returns the configured base URL with any trailing slash removed. +func (f *TemplateFormatter) base() string { + if f.baseURL == nil { + return "" + } + + return strings.TrimRight(f.baseURL(), "/") } // NewFormatter creates a new template formatter. -func NewFormatter() (*TemplateFormatter, error) { - funcMap := template.FuncMap{ +func NewFormatter(opts ...Option) (*TemplateFormatter, error) { + formatter := &TemplateFormatter{} + + for _, opt := range opts { + opt(formatter) + } + + formatter.funcMap = template.FuncMap{ "upper": strings.ToUpper, "lower": strings.ToLower, "dict": dict, + "field": field, + // absURL and productLogoURL close over the formatter so templates never + // have to know the base URL — no view model carries it. + "absURL": formatter.absoluteURL, + "productLogoURL": formatter.productLogoURL, + } + + return formatter, nil +} + +// productLogoURL returns the absolute URL of the SolidPing logo, or "" when no +// base URL is configured (in which case base.html renders its text wordmark). +func (f *TemplateFormatter) productLogoURL() string { + base := f.base() + if base == "" { + return "" + } + + return base + productLogoPath +} + +// absoluteURL turns a stored logo reference into something an email client can +// actually load. Organization and status-page logos are stored either as an +// external "https://…" URL or as a site-relative "/pub/assets/" path, and +// a relative path in an email is simply a broken image — mail clients have no +// origin to resolve it against. +// +// Anything that is neither absolute-http(s) nor site-relative returns "", +// which the templates render as "no logo" rather than as a broken image. That +// also means a `javascript:` or `data:` value stored by a hostile admin can +// never reach an . +func (f *TemplateFormatter) absoluteURL(value any) string { + raw := strings.TrimSpace(stringify(value)) + if raw == "" { + return "" + } + + if strings.HasPrefix(raw, "https://") || strings.HasPrefix(raw, "http://") { + return raw } - return &TemplateFormatter{ - funcMap: funcMap, - }, nil + base := f.base() + if !strings.HasPrefix(raw, "/") || base == "" { + return "" + } + + return base + raw +} + +// stringify renders the handful of shapes a view-model value can arrive as +// (string, *string, nil) as a plain string. Anything else yields "". +func stringify(value any) string { + switch typed := value.(type) { + case nil: + return "" + case string: + return typed + case *string: + if typed == nil { + return "" + } + + return *typed + case fmt.Stringer: + return typed.String() + default: + return "" + } +} + +// field looks a key up on a view model that may be either a map or a struct, +// returning nil when it is absent. +// +// This exists to dodge the trap documented in supportreply.go: html/template +// ERRORS on a missing struct field (while a missing map key is merely nil), and +// the repo's view models are a mix of map[string]any and structs +// (uptimereport.Data). A new `{{.OrgLogoURL}}` in base.html would therefore +// break every struct-backed template until the field was added to each one — +// silently, at send time. Every branding key base.html reads goes through this +// helper instead, so a view model that does not carry it renders empty. +func field(data any, name string) any { + if data == nil { + return nil + } + + value := reflect.ValueOf(data) + for value.Kind() == reflect.Pointer || value.Kind() == reflect.Interface { + if value.IsNil() { + return nil + } + + value = value.Elem() + } + + // An if-chain rather than a switch on reflect.Kind: only two of the ~27 + // kinds are meaningful here, and enumerating the rest to satisfy an + // exhaustiveness check would be noise. + if value.Kind() == reflect.Map { + if value.Type().Key().Kind() != reflect.String { + return nil + } + + found := value.MapIndex(reflect.ValueOf(name).Convert(value.Type().Key())) + if !found.IsValid() { + return nil + } + + return found.Interface() + } + + if value.Kind() == reflect.Struct { + found := value.FieldByName(name) + if !found.IsValid() || !found.CanInterface() { + return nil + } + + return found.Interface() + } + + return nil } // dict builds a map[string]any from an alternating key/value argument list, @@ -92,6 +260,40 @@ func (f *TemplateFormatter) parseTemplate(templateName string) (*template.Templa return tmpl, nil } +// parseTextTemplate parses the same two files as parseTemplate, but with +// text/template rather than html/template. +// +// The subject and the plaintext alternative are NOT HTML, and rendering them +// through html/template escapes every value it interpolates: a check named +// "Search & Discovery" arrives as "Search & Discovery" — in the SUBJECT +// LINE, where the reader sees it before opening anything, and again in the +// plaintext part that text-only clients and screen readers display. Literal +// template text is untouched by the escaper, which is why this went unnoticed: +// it only corrupts values, and only those carrying & < > " or '. +func (f *TemplateFormatter) parseTextTemplate(templateName string) (*texttemplate.Template, error) { + baseContent, err := templateFS.ReadFile("templates/base.html") + if err != nil { + return nil, fmt.Errorf("reading base template: %w", err) + } + + templateContent, err := templateFS.ReadFile("templates/" + templateName) + if err != nil { + return nil, fmt.Errorf("reading template %s: %w", templateName, err) + } + + tmpl, err := texttemplate.New("base.html").Funcs(f.funcMap).Parse(string(baseContent)) + if err != nil { + return nil, fmt.Errorf("parsing base template: %w", err) + } + + tmpl, err = tmpl.New(templateName).Parse(string(templateContent)) + if err != nil { + return nil, fmt.Errorf("parsing template %s: %w", templateName, err) + } + + return tmpl, nil +} + // Format renders a template with the given data and returns the rendered // subject (from a {{define "subject"}} block, or "" when the template has // none), the HTML body with inlined CSS, and a plaintext alternative (from a @@ -103,12 +305,17 @@ func (f *TemplateFormatter) Format(templateName string, data any) (string, strin return "", "", "", fmt.Errorf("parsing template %s: %w", templateName, err) } - subject, err := f.renderSubject(tmpl, templateName, data) + textTmpl, err := f.parseTextTemplate(templateName) + if err != nil { + return "", "", "", fmt.Errorf("parsing template %s: %w", templateName, err) + } + + subject, err := f.renderSubject(textTmpl, templateName, data) if err != nil { return "", "", "", err } - text, err := f.renderText(tmpl, templateName, data) + text, err := f.renderText(textTmpl, templateName, data) if err != nil { return "", "", "", err } @@ -135,7 +342,7 @@ func (f *TemplateFormatter) Format(templateName string, data any) (string, strin // Returns "" when no subject block is defined — callers may then fall back // to a static subject. func (f *TemplateFormatter) renderSubject( - tmpl *template.Template, templateName string, data any, + tmpl *texttemplate.Template, templateName string, data any, ) (string, error) { subjTmpl := tmpl.Lookup("subject") if subjTmpl == nil { @@ -154,7 +361,7 @@ func (f *TemplateFormatter) renderSubject( // Returns "" when no text block is defined — callers then send HTML-only, // exactly as before this block existed. func (f *TemplateFormatter) renderText( - tmpl *template.Template, templateName string, data any, + tmpl *texttemplate.Template, templateName string, data any, ) (string, error) { textTmpl := tmpl.Lookup("text") if textTmpl == nil { diff --git a/server/internal/email/formatter_test.go b/server/internal/email/formatter_test.go index fa7b72755..ecbe9aaca 100644 --- a/server/internal/email/formatter_test.go +++ b/server/internal/email/formatter_test.go @@ -91,7 +91,12 @@ func TestFormatter_IncidentNumberOmittedWhenZero(t *testing.T) { r.NoError(err) r.NotContains(subject, "#0") r.NotContains(subject, "#") - r.NotContains(html, "#0") + // The HTML assertion is anchored on ">#0" — i.e. "#0" rendered as element + // TEXT — rather than a bare "#0", which the branded palette's hex colors + // (#0f1a24, #0072d5, #0e8f2a) now match inside inlined style attributes. + // The number only ever appears as the content of a or an , so this + // still fails the moment a "#0" is rendered to the reader. + r.NotContains(html, ">#0") r.NotContains(text, "Incident: #") } @@ -624,3 +629,41 @@ func TestDict(t *testing.T) { _, err = dict(1, "a") r.ErrorIs(err, errDictKeyNotString) } + +// TestSubjectAndTextAreNotHTMLEscaped pins the split between the three parts +// Format returns: only the HTML body is HTML. +// +// The whole template set is parsed by html/template, whose escaper rewrites +// every INTERPOLATED value (literal template text is left alone, which is why +// this stayed invisible for so long). A check named "Search & Discovery" +// therefore reached the reader as "Search & Discovery" — in the subject +// line, before they even opened the mail, and again in the plaintext part. +// Subject and text now render through text/template. +// +// The last assertion is the one that matters most: the HTML body must STILL be +// escaped. Rendering everything as text would fix the subject and quietly turn +// every user-controlled name in the HTML part into markup injection. +func TestSubjectAndTextAreNotHTMLEscaped(t *testing.T) { + t.Parallel() + + r := require.New(t) + + formatter, err := NewFormatter() + r.NoError(err) + + const hostile = `Search & Discovery "prod" ` + + viewModel := incidentViewModel() + viewModel["CheckName"] = hostile + + subject, htmlBody, text, err := formatter.Format("incident-created.html", viewModel) + r.NoError(err) + + r.Contains(subject, hostile, "the subject line is a mail header, not HTML") + r.NotContains(subject, "&") + r.Contains(text, hostile, "the plaintext alternative is not HTML either") + r.NotContains(text, "&") + + r.NotContains(htmlBody, "", "the HTML body must keep escaping interpolated values") + r.Contains(htmlBody, "&") +} diff --git a/server/internal/email/supportreply.go b/server/internal/email/supportreply.go index 120ce58a8..77b38c62c 100644 --- a/server/internal/email/supportreply.go +++ b/server/internal/email/supportreply.go @@ -2,6 +2,7 @@ package email import ( "io/fs" + "sort" "strings" ) @@ -126,6 +127,17 @@ func classifiedTemplates() map[string]bool { // templates directory, excluding the base wrapper (which is never rendered on // its own and therefore has no classification of its own). func shippedTemplateNames() ([]string, error) { + return ShippedTemplateNames() +} + +// ShippedTemplateNames lists every template file shipped in the embedded +// templates directory, sorted, excluding base.html. +// +// Exported so other packages enumerate the real directory instead of keeping a +// hand-maintained copy of the list: the email preview package's fixture-coverage +// test reads this, which is what makes "a new template ships without a preview +// fixture" a failing test rather than a silent gap. +func ShippedTemplateNames() ([]string, error) { entries, err := fs.ReadDir(templateFS, "templates") if err != nil { return nil, err @@ -141,6 +153,8 @@ func shippedTemplateNames() ([]string, error) { names = append(names, entry.Name()) } + sort.Strings(names) + return names, nil } diff --git a/server/internal/email/templates/base.html b/server/internal/email/templates/base.html index 2e64b7b22..7c37c3f02 100644 --- a/server/internal/email/templates/base.html +++ b/server/internal/email/templates/base.html @@ -3,73 +3,221 @@ + + + + + + + SolidPing + {{- /* Preheader: the grey line the inbox shows next to the subject. Without + one, clients scrape the first visible text — which for an alert is the + header wordmark, and for a CTA mail the fallback URL. The trailing + zero-width-joiner run stops the client pulling footer text in behind a + short preheader. Hidden with the standard belt-and-braces set (a real + inline style, not a class, so it survives clients that drop