Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
33a3ace
docs: resolve open questions for email branding and preview UI
fclairamb Aug 25, 2026
e79a427
chore: start implementing email branding and preview UI
fclairamb Aug 25, 2026
5bd5eb8
docs: add implementation plan for email branding and preview UI
fclairamb Aug 25, 2026
3e88f06
feat(email): add a preview index endpoint and close the fixture gap
fclairamb Aug 25, 2026
0720e9c
feat(dash0): add the email template preview page
fclairamb Aug 25, 2026
d74b8ea
style(email): put base.html on the SolidPing product palette
fclairamb Aug 25, 2026
57da0fa
feat(email): render the SolidPing logo, and the sender's own logo whe…
fclairamb Aug 25, 2026
1cf8be1
feat(email): thread the sending org's (or status page's) identity int…
fclairamb Aug 25, 2026
fc96586
docs(wiki): document the email-preview index endpoint
fclairamb Aug 25, 2026
d7feb8f
fix(email): resolve the base URL per render, and beat the generic con…
fclairamb Aug 25, 2026
b2af896
chore: all checks passing for email branding and preview UI
fclairamb Aug 25, 2026
a37e629
feat(email): brand the escalation email with the org too
fclairamb Aug 25, 2026
322f51e
chore: all checks passing for email branding and preview UI
fclairamb Aug 25, 2026
c511d1c
test(email): pin the base-URL wiring and cover ApplyOrgBranding
fclairamb Aug 25, 2026
378eab9
chore: all checks passing for email branding and preview UI
fclairamb Aug 25, 2026
a29cb9f
docs: archive completed spec email branding and preview UI
fclairamb Aug 25, 2026
5894c22
chore(dash0): add the audit log responsive verification screenshots
fclairamb Aug 25, 2026
3bd2a2b
feat(email): improve email rendering, styling, and action button layouts
fclairamb Aug 25, 2026
0e5f63c
feat(email): fix client-rendering traps and lead each mail with its p…
fclairamb Aug 25, 2026
3952fa6
feat(email): give the transactional mail a polish pass
fclairamb Aug 25, 2026
25d99da
Merge branch 'main' into batch/2026-08-25
fclairamb Aug 25, 2026
468d73f
Merge branch 'main' into batch/2026-08-25
fclairamb Aug 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions server/cmd/devloop/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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") {
Expand Down
71 changes: 71 additions & 0 deletions server/internal/app/email_branding_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
11 changes: 10 additions & 1 deletion server/internal/app/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <img> 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)
}
Expand Down Expand Up @@ -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)
}

Expand Down
1 change: 1 addition & 0 deletions server/internal/app/testapi_routes_gate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion server/internal/customdomain/alert.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 == "" {
Expand Down
71 changes: 71 additions & 0 deletions server/internal/email/branding.go
Original file line number Diff line number Diff line change
@@ -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 ("<org> — 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/<uid>" 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 "<org> — 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
}
Loading
Loading