Repository navigation
fix(email): brand the transactional email rendering and add a dev-only preview - #258
Merged
Merged
Conversation
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 & 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
enabled auto-merge (squash)
August 25, 2026 22:05
Merged
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
Closed
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 noway 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 gatedbehind
SP_RUNMODE=testand 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.
localhostlogo URL. Theformatter captured
config.Server.BaseURLat construction, but the systemconfigoverlay that applies
SP_BASE_URLand the DB-storedserver.base_urlrunsafterwards, in
InitializeSystemConfig. The base URL is now resolved perrender via
WithBaseURLFunc. Pinned by a test that boots a real server, rendersbefore the overlay as a positive control, applies the overlay, and asserts the
pre-overlay origin is entirely absent from the result.
.section-title/.eyebrowlost to the generic.content h2/.content prules, so the classes that replaced the old inline styles rendered as nothing.
Now scoped as
.content h2.section-titleetc., with a test whose marginassertions fail without the fix.
Preview catalog, test-mode only
GET /api/mgmt/email-previewindex alongside the existing:templateroute,same
SP_RUNMODE=testgate, added totestModeGatedRoutesso the gate testcovers both directions.
custom-domain-demoted,incident-acknowledgedandincident-commentwere previously unpreviewable. 22/22 now.TestEveryShippedTemplateHasFixtureenumerates the embedded templates dirrather than a hardcoded list, so a new template cannot ship unpreviewable. Fails
in both directions and asserts
base.htmlis excluded./orgs/$org/testlayout: template list,subject line, HTML iframe / text toggle. Renders through the real
Format()path, so what you see includes premailer inlining.
Branding
base.htmlmoved onto the product palette, with the status banners keptsemantic — 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 outsidebase.html.<img>with the wordmark asalt. No CID —email/sender.go's MIME assembly is untouched.sent by SolidPing secondary.
status-subscriber-*templates take status page branding, not org branding,and honor
hide_brandingby rendering no logo and no attribution at all. Asubscriber opted into a status page, not into the organization behind it.
html/templatestruct-field trap — a missing struct field is a rendererror, not an empty string — is handled by routing every branding key through
a nil-tolerant
fieldhelper, guarded by tests that render all templates againstboth map and struct view models, including
uptimereport.Datadirectly.Dark mode is deliberately out of scope for this pass.
🤖 Generated with Claude Code