Skip to content

fix(email): brand the transactional email rendering and add a dev-only preview - #258

Merged
fclairamb merged 22 commits into
mainfrom
batch/2026-08-25
Aug 25, 2026
Merged

fclairamb merged 22 commits into
mainfrom
batch/2026-08-25

Conversation

@fclairamb

Copy link
Copy Markdown
Owner

Transactional email rendering was visually anonymous: the entire brand was a text
<span> on a generic grey band, no logo image in any of the 22 templates, and no
way to look at an email while working on it. This fixes the rendering and adds a
dev-only preview so it can be iterated on with eyes rather than blind.

Typed as a fix: no user-facing feature ships here. The preview catalog is gated
behind SP_RUNMODE=test and is invisible in any real deployment.

Two production defects found on the way

Neither was in the spec; both surfaced from actually rendering the emails against
a live server rather than trusting the template source.

  • Every production email would have carried a localhost logo URL. The
    formatter captured config.Server.BaseURL at construction, but the systemconfig
    overlay that applies SP_BASE_URL and the DB-stored server.base_url runs
    afterwards, in InitializeSystemConfig. The base URL is now resolved per
    render via WithBaseURLFunc. Pinned by a test that boots a real server, renders
    before the overlay as a positive control, applies the overlay, and asserts the
    pre-overlay origin is entirely absent from the result.
  • Premailer specificity silently undid the new helper classes. Bare
    .section-title / .eyebrow lost to the generic .content h2 / .content p
    rules, so the classes that replaced the old inline styles rendered as nothing.
    Now scoped as .content h2.section-title etc., with a test whose margin
    assertions fail without the fix.

Preview catalog, test-mode only

  • GET /api/mgmt/email-preview index alongside the existing :template route,
    same SP_RUNMODE=test gate, added to testModeGatedRoutes so the gate test
    covers both directions.
  • Fixture gap closed — custom-domain-demoted, incident-acknowledged and
    incident-comment were previously unpreviewable. 22/22 now.
  • TestEveryShippedTemplateHasFixture enumerates the embedded templates dir
    rather than a hardcoded list, so a new template cannot ship unpreviewable. Fails
    in both directions and asserts base.html is excluded.
  • dash0 page under the already-test-gated /orgs/$org/test layout: template list,
    subject line, HTML iframe / text toggle. Renders through the real Format()
    path, so what you see includes premailer inlining.

Branding

  • base.html moved onto the product palette, with the status banners kept
    semantic — down red, recovered green, escalated/reopened amber, comment slate,
    acknowledged blue. Markup deliberately untouched: still table layout, 600px
    container, bulletproof button. The three templates that carried inline styles
    now use shared classes; zero style= attributes remain outside base.html.
  • SolidPing logo as a remote <img> with the wordmark as alt. No CID —
    email/sender.go's MIME assembly is untouched.
  • Org logo becomes the primary mark where the sending org has one, with
    sent by SolidPing secondary.
  • status-subscriber-* templates take status page branding, not org branding,
    and honor hide_branding by rendering no logo and no attribution at all. A
    subscriber opted into a status page, not into the organization behind it.
  • The html/template struct-field trap — a missing struct field is a render
    error, not an empty string — is handled by routing every branding key through
    a nil-tolerant field helper, guarded by tests that render all templates against
    both map and struct view models, including uptimereport.Data directly.

Dark mode is deliberately out of scope for this pass.

🤖 Generated with Claude Code

fclairamb and others added 21 commits August 25, 2026 12:11
Adds GET /api/mgmt/email-preview (same SP_RUNMODE=test gate as the existing
per-template route) listing every previewable template with its rendered
subject and whether it ships a plaintext part, wrapped in {"data": [...]}.

Fixtures for custom-domain-demoted, incident-acknowledged and incident-comment
close the 19-of-22 gap, and the shipped-template list is now read from the
embedded directory instead of being kept in sync by hand, so a template added
without a fixture fails TestEveryShippedTemplateHasFixture immediately.
New /orgs/$org/test/emails tab under the existing test-tools layout, which
already gates on runMode === "test" and degrades gracefully outside it. Lists
every template from the new index endpoint, shows the rendered subject, and
previews the HTML in an iframe or the plaintext part inline.

Reuses the shipped primitives from the design reference (Card, Button, Badge,
Skeleton, SegmentedControl) — no new pattern to catalog.
Flattens the dashboard tokens (--primary #0072D5, --brand crimson #DA1D69,
ink #0F1A24, wash #F0F4F8, border #DDE2E7) into base.html's single <style>
block, replacing the generic grey/blue, and closes the header ink band with a
crimson rule so an email reads as SolidPing before any image loads. Semantic
status colors keep their meaning (down red, recovered green, escalated/reopened
amber, comment slate, acknowledged blue).

Markup is untouched: still table layout, 600px container, premailer inlining
and the bulletproof button. The three templates carrying ad-hoc inline styles
now use new shared classes (.quote, .eyebrow, .prose, .section-title), so no
template fights the wrapper any more.

Dark mode stays out of scope — no prefers-color-scheme block, by decision.
…n set

base.html's header becomes a remote <img> with an absolute src built from the
server base URL — the same base DashboardURL/DocsURL use — pointing at
/dash0/logo.png, with the wordmark kept as alt text so a client blocking remote
images still shows a legible header. No CID, no change to sender.go's MIME
assembly (out of scope by decision).

The wrapper resolves three branding keys — OrgLogoURL, BrandName, HideBranding
— through a new nil-tolerant 'field' template helper rather than direct .Field
accesses, which is what dodges the struct-view-model trap: html/template errors
on a missing STRUCT field, so a direct access would break every struct-backed
view model at send time. The pre-existing footer accesses (.DashboardURL,
.DocsURL, .UnsubscribeURL) are converted for the same reason.

'absURL' makes a stored /pub/assets/<uid> logo absolute and passes an external
https:// logo through untouched; anything else (javascript:, data:, a bare
relative name) is dropped rather than reaching an <img src>.
…o emails

Adds email.ApplyOrgBranding / ApplyStatusPageBranding and wires them wherever
the sender is already loaded:

- incident mail: notifications.Payload gains OrgName/OrgLogoURL, filled by the
  notification job runner, which already loaded the org row for OrgSlug;
- invitation, membership-request new/decision, paging nudge, custom-domain
  demotion: the org is in hand at each call site;
- uptime report: OrgLogoURL/BrandName become fields on uptimereport.Data, with
  PascalCase JSON tags like the rest of that struct — a camelCase tag there
  would silently unbrand every digest after the job's JSON round trip.

status-subscriber mail takes its branding from the STATUS PAGE instead, and
honors hide_branding: SubscriberUpdateEvent and SubscribeResult carry the
page's logo and white-label flag, and no OrgName is set at all, so neither the
org's logo nor the "<org> — sent by SolidPing" footer can reach a subscriber.

Account and security mail (registration, password reset/changed, welcome,
test-email) has no org context and deliberately stays SolidPing-branded.
…tent rules

Two defects found by running the preview against a real server:

- config.Server.BaseURL is NOT final when the formatter is constructed —
  the systemconfig overlay that applies SP_BASE_URL and the DB-stored
  server.base_url parameter runs afterwards. Capturing it at construction
  pinned every email's logo to the pre-overlay localhost default. The
  formatter now reads it per render via WithBaseURLFunc.
- premailer resolves specificity like a browser, so the bare .section-title /
  .eyebrow helper classes lost their font-size and margins to .content h2 /
  .content p — silently undoing the inline styles they replaced. They are now
  scoped as .content h2.section-title etc., and .meta gets the same treatment
  for a bug that predates this change.
Completes the stage-3 sweep: buildEscalationEmailViewModel already loads the
org row for the slug, so the name and logo cost nothing extra.
Two coverage holes over already-correct code, both mutation-verified:

- internal/app: TestEmailFormatterUsesThePostOverlayBaseURL boots a real
  server, applies the systemconfig overlay AFTER NewServer, and renders an
  email through the wired formatter. Reverting server.go to
  WithBaseURL(cfg.Server.BaseURL) makes it fail; the option-level test in the
  email package does not. A pre-overlay assertion is the positive control.
- internal/email: ApplyOrgBranding had no test at all, while its sibling
  ApplyStatusPageBranding was covered through the real notifier and the
  incident path sets the keys inline. It is now asserted against LITERAL key
  names (a typo'd constant would fool a constant-based assertion) and rendered
  through base.html, with a logo-less positive control.
- internal/handlers/members: one of the six call sites is now covered
  end-to-end — the paging nudge really carries the org's absolute logo URL,
  and falls back to the SolidPing logo without one.
Desktop (1280x800) and mobile (375x812) captures of the organization audit
log page, from the responsive check on spec 2026-08-25-01. Test-org data
only (test@test.com, ::1) — no real names or addresses.
…oint

Rendering fixes (things that were wrong in a real inbox, not in the preview):

- Subject and plaintext parts now render through text/template. The whole set
  parsed as html/template, which escaped every interpolated value: a check named
  "Search & Discovery" reached the inbox as "Search &amp; Discovery" — in the
  subject line. The HTML body still escapes (pinned by a test, since rendering
  it as text would turn user-controlled names into markup injection).
- Every template ships a preheader. Without one the client scrapes the first
  visible text for the inbox preview: the "SolidPing" wordmark, or a raw URL.
- Light rendering is now declared (color-scheme + supported-color-schemes +
  x-apple-disable-message-reformatting). The design is light-only by decision,
  but saying nothing let Apple Mail / Outlook.com / Gmail-Android auto-invert
  it, recoloring the status banner that carries an alert's whole meaning.
- uptime-report.html built its grid from <th>, which base.html does not style:
  centered, unpadded, ragged columns. Fixed, and <th> now falls back to the
  label styling so the next one fails visibly instead of silently.
- Detail tables draw a complete border (border-collapse: separate — with
  collapse the cells' own borders ate the outer edge).
- Action buttons stack full-width on mobile instead of squeezing side by side.
- Timestamps carry their zone (mailTimestamp), and durations read as "15m"
  rather than "15m0s" (mailDuration), keeping seconds below the hour.

Design:

- Recovered mail leads with total downtime; burn alerts lead with burn rate and
  budget left, instead of burying both in a nine-row grid.
- The incident UUID moved out of the facts table into a footnote — support needs
  it, a woken-up reader does not.
- Quoted human text (membership request message, rejection reason) uses the
  blockquote style instead of the monospace break-all box meant for URLs.
- custom-domain-demoted gains a status banner and a real CTA; paging-nudge gains
  the heading every other template has.

Also: devloop rebuilds on .html changes, since the templates are go:embed-ed and
editing one previously did nothing until an unrelated .go file was touched.

Tests: preheader presence, no unresolved keys (checked on the plaintext part —
premailer silently swallows "<no value>" and the element around it in HTML), the
escaping split, <th> absence, the UUID footnote, and mailDuration boundaries.
Conventions written up in wiki/conventions/email-templates.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Visual pass over base.html, all of it degrading cleanly:

- Gradients on the header, the status banners, the accent rule, the buttons and
  the metric/label washes — each paired with the flat color Outlook falls back
  to, since its Word engine ignores background-image entirely (a gradient alone
  would render the header as white text on white).
- Deeper, layered card elevation and a larger radius; roomier detail rows with a
  softer grid; section subheads gain a blue accent rule.
- Status banners lead with a dot glyph and more letter-spacing.
- Metric figures can carry state color: burn rate reads red, budget left amber.
- Headings up to 24px with tighter tracking; a quiet "SolidPing · uptime
  monitoring" signature closes the footer (suppressed for white-label mail).
- Report spans drop zero units: "42m", not "42m 0s".

Tests: a gradient without a solid fallback now fails
(TestPreview_EveryGradientKeepsASolidFallback), formatDuration's zero-unit
boundaries are pinned, and the section-title styling assertion in
branding_test.go moves with the new spacing — it caught this change, which is
what it is for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@fclairamb
fclairamb enabled auto-merge (squash) August 25, 2026 22:05
@fclairamb
fclairamb merged commit b55b8b4 into main Aug 25, 2026
11 checks passed
@fclairamb
fclairamb deleted the batch/2026-08-25 branch August 25, 2026 23:33
fclairamb added a commit that referenced this pull request Aug 25, 2026
🤖 I have created a release *beep* *boop*
---


##
[0.18.2](v0.18.1...v0.18.2)
(2026-08-25)


### Bug Fixes

* **deps:** update go dependencies (non-major)
([#256](#256))
([4c114c0](4c114c0))
* **deps:** update go dependencies (non-major)
([#260](#260))
([441eb21](441eb21))
* **deps:** update module github.com/aws/aws-sdk-go-v2/config to
v1.32.39 ([#261](#261))
([f4daee9](f4daee9))
* **deps:** update module github.com/posthog/posthog-go to v1.24.1
([#259](#259))
([b7074c5](b7074c5))
* **email:** brand the transactional email rendering and add a dev-only
preview ([#258](#258))
([b55b8b4](b55b8b4))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).
This was referenced Sep 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant