Files
xarmian cec056cefe feat(email): cloud-mode marketing footer in transactional emails (TASK-907) (#317)
* feat(email): cloud-mode marketing footer in transactional emails (TASK-907)

Extracts a shared HTML/plain shell helper for the five existing
transactional-email templates (SendInvitation, SendWelcome,
SendPasswordReset, SendPaymentFailed, SendTest) and adds a Cloud-only
marketing footer that mirrors the auth-page AuthFooter component:
GitHub / Docs / Changelog / Privacy / Terms link list plus a
"© <year> Pad · Perpetual Software" copyright line.

Self-hosted output (the default for any pad instance NOT in
PAD_CLOUD/PAD_MODE=cloud) is byte-equivalent to the prior inline
templates: same wordmark header, same body, same footer-note disclosure,
no marketing links. Operators ship Pad under their own brand and
getpad.dev's link list would be wrong on their notifications.

Plumbing:

  - email.Sender gains a cloudMode bool + SetCloudMode/CloudMode
    accessors. Configure() does not touch cloudMode (it's set
    independently from API-key/from-addr config).
  - Server.SetCloudMode now propagates to s.email.SetCloudMode(true)
    so existing senders pick up the flag.
  - Server.SetEmailSender propagates s.cloudMode → e.cloudMode when
    email is wired AFTER cloud mode (handles the cmd/pad/main.go
    ordering where SetEmailSender is called from main).
  - Server.reconfigureEmail() (admin-settings reload path) does the
    same so an admin reconfiguring email mid-flight doesn't end up
    with a sender stuck in self-hosted mode.

The email accent color (#2563eb) is preserved from the prior templates
— it has known contrast properties on white email backgrounds. Email
is light-themed for cross-client readability; the dark-theme tokens
from docs/brand.md §3 are for in-app/auth surfaces, not transactional
mail.

Pinned with three regression tests:
  - self-hosted shell renders no Cloud-only markers
  - Cloud shell renders the link list in canonical order (GitHub →
    Docs → Changelog → Privacy → Terms)
  - plain-text shell branches identically

Visual contract: docs/brand.md §7 (link order) and §6 (Pad wordmark).
Companion to AuthHeader, AuthFooter, +error.svelte, and UserMenuResources
already shipped on PLAN-900.

Test plan:
- go build ./... — clean
- go vet ./... — clean
- go test ./... — all pass (including new shell_test.go cases)
- web/npm run check — 0 errors
- web/npm run build — clean

* fix(email): full canonical link list per Codex (round 2)

Codex caught that the Cloud-mode email footer carried only 5 of the 9
canonical links from docs/brand.md §7 (GitHub / Docs / Changelog /
Privacy / Terms — omitted Contribute / FAQ / Security / Sub-processors).
The brand spec §1 says transactional emails get "Full parity" with the
auth-page AuthFooter; my trim violated that contract.

Add the four missing links to both the HTML and plain-text shells in
the canonical order: GitHub → Docs → Changelog → Contribute → FAQ →
Security → Privacy → Terms → Sub-processors. Update the regression
tests to pin all 9 markers + their pairwise ordering.

The "keep emails small" instinct that motivated the trim was a real
design concern but not strong enough to defy the brand spec. If we
later decide email needs a reduced subset, the right move is to
update §7 in docs/brand.md FIRST (acknowledging email as a surface
with a smaller link list) and trim the implementation to match.
2026-04-30 00:09:52 -04:00

122 lines
5.0 KiB
Go

package email
import (
"fmt"
"strings"
"time"
)
// buildHTMLShell wraps body HTML in the standard transactional-email
// chrome — header (Pad wordmark) + body + footer note + (Cloud only)
// marketing footer (copyright line + canonical link list).
//
// Self-hosted output is byte-equivalent to the prior inline templates so
// operators see no visible change after this refactor — they ship Pad
// under their own brand and getpad.dev marketing links would be wrong on
// their notifications.
//
// Visual contract: docs/brand.md §6 (header) + §7 (footer link order).
// Email is light-themed for cross-client readability — emails generally
// don't follow the dark theme of the in-app surfaces. The accent color
// (#2563eb) is kept from the prior templates because it has known
// contrast properties on light backgrounds.
//
// The bodyHTML parameter is interpolated verbatim — callers must
// HTML-escape any user-controlled content before passing it in. The
// footerNote is a single-paragraph "you received this because…"
// disclosure rendered in the small grey footer text directly above
// the optional marketing block.
func buildHTMLShell(bodyHTML, footerNoteHTML string, cloudMode bool) string {
var b strings.Builder
b.WriteString(`<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #1a1a1a; max-width: 560px; margin: 0 auto; padding: 40px 20px;">
<div style="margin-bottom: 32px;">
<strong style="font-size: 18px;">Pad</strong>
</div>
`)
b.WriteString(bodyHTML)
b.WriteString(` <hr style="border: none; border-top: 1px solid #e5e5e5; margin: 32px 0;" />
`)
if footerNoteHTML != "" {
b.WriteString(` <p style="font-size: 12px; color: #999;">
`)
b.WriteString(footerNoteHTML)
b.WriteString(`
</p>
`)
}
if cloudMode {
// Marketing footer block — full canonical link list from docs/brand.md
// §7, in the order the brand spec mandates: GitHub, Docs, Changelog,
// Contribute, FAQ, Security, Privacy, Terms, Sub-processors. The
// auth-page AuthFooter component carries the same nine links; emails
// get the same set so the brand spec's "Full parity" promise for
// transactional mail (§1) is honored. Codex caught a trimmed subset
// on first review (TASK-907 round 1).
year := time.Now().UTC().Year()
b.WriteString(fmt.Sprintf(` <p style="font-size: 11px; color: #999; line-height: 1.5; margin-top: 16px;">
<a href="https://github.com/PerpetualSoftware/pad" style="color: #999; text-decoration: underline;">GitHub</a>
&nbsp;·&nbsp;
<a href="https://getpad.dev/docs" style="color: #999; text-decoration: underline;">Docs</a>
&nbsp;·&nbsp;
<a href="https://getpad.dev/changelog" style="color: #999; text-decoration: underline;">Changelog</a>
&nbsp;·&nbsp;
<a href="https://getpad.dev/contribute" style="color: #999; text-decoration: underline;">Contribute</a>
&nbsp;·&nbsp;
<a href="https://getpad.dev/faq" style="color: #999; text-decoration: underline;">FAQ</a>
&nbsp;·&nbsp;
<a href="https://getpad.dev/security" style="color: #999; text-decoration: underline;">Security</a>
&nbsp;·&nbsp;
<a href="https://getpad.dev/privacy" style="color: #999; text-decoration: underline;">Privacy</a>
&nbsp;·&nbsp;
<a href="https://getpad.dev/terms" style="color: #999; text-decoration: underline;">Terms</a>
&nbsp;·&nbsp;
<a href="https://getpad.dev/subprocessors" style="color: #999; text-decoration: underline;">Sub-processors</a>
</p>
<p style="font-size: 11px; color: #aaa; margin-top: 8px;">
&copy; %d Pad &middot; Perpetual Software
</p>
`, year))
}
b.WriteString(`</body>
</html>`)
return b.String()
}
// buildPlainShell builds the plain-text body for transactional emails.
// Self-hosted output is byte-equivalent to the prior templates. Cloud
// output adds a small marketing-link block and copyright line below the
// per-template footer note. Plain text intentionally stays minimal —
// no chrome, no decorative separators beyond the dashed rule.
func buildPlainShell(bodyText, footerNoteText string, cloudMode bool) string {
var b strings.Builder
b.WriteString(bodyText)
if footerNoteText != "" {
b.WriteString("\n\n---\n")
b.WriteString(footerNoteText)
}
if cloudMode {
// Same nine canonical links as the HTML branch (docs/brand.md §7).
// Plain text uses fixed-width labels for legibility in monospaced
// mail clients; URL alignment doesn't matter visually but reads
// cleanly when piped through a screen reader.
year := time.Now().UTC().Year()
b.WriteString(fmt.Sprintf(`
GitHub: https://github.com/PerpetualSoftware/pad
Docs: https://getpad.dev/docs
Changelog: https://getpad.dev/changelog
Contribute: https://getpad.dev/contribute
FAQ: https://getpad.dev/faq
Security: https://getpad.dev/security
Privacy: https://getpad.dev/privacy
Terms: https://getpad.dev/terms
Sub-processors: https://getpad.dev/subprocessors
(c) %d Pad — Perpetual Software`, year))
}
return b.String()
}