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

112 lines
4.0 KiB
Go

package email
import (
"strings"
"testing"
)
// TestBuildHTMLShellSelfHostedNeutral pins that self-hosted output
// carries no getpad.dev marketing footer. Operators ship Pad under
// their own brand and would not want the hosted-service footer
// imposed on transactional emails sent from their deployment.
func TestBuildHTMLShellSelfHostedNeutral(t *testing.T) {
out := buildHTMLShell("<p>body</p>", "footer note", false)
if !strings.Contains(out, "<strong style=\"font-size: 18px;\">Pad</strong>") {
t.Error("self-hosted should keep the Pad wordmark header")
}
if !strings.Contains(out, "<p>body</p>") {
t.Error("self-hosted should include the body content verbatim")
}
if !strings.Contains(out, "footer note") {
t.Error("self-hosted should include the footer note")
}
// The Cloud-only artifacts must not appear in self-hosted output.
cloudOnly := []string{
"github.com/PerpetualSoftware/pad",
"getpad.dev/docs",
"getpad.dev/changelog",
"getpad.dev/contribute",
"getpad.dev/faq",
"getpad.dev/security",
"getpad.dev/privacy",
"getpad.dev/terms",
"getpad.dev/subprocessors",
"Perpetual Software",
}
for _, marker := range cloudOnly {
if strings.Contains(out, marker) {
t.Errorf("self-hosted output unexpectedly contained Cloud-only marker %q", marker)
}
}
}
// TestBuildHTMLShellCloudIncludesMarketingFooter pins the Cloud-mode
// marketing-footer link order from docs/brand.md §7 and the copyright
// line. Full canonical order: GitHub → Docs → Changelog → Contribute
// → FAQ → Security → Privacy → Terms → Sub-processors. Tests check
// pairwise substring positions rather than using a regex so a future
// code review can read the assertion at a glance.
func TestBuildHTMLShellCloudIncludesMarketingFooter(t *testing.T) {
out := buildHTMLShell("<p>body</p>", "footer note", true)
for _, marker := range []string{
"github.com/PerpetualSoftware/pad",
"getpad.dev/docs",
"getpad.dev/changelog",
"getpad.dev/contribute",
"getpad.dev/faq",
"getpad.dev/security",
"getpad.dev/privacy",
"getpad.dev/terms",
"getpad.dev/subprocessors",
"Pad &middot; Perpetual Software",
} {
if !strings.Contains(out, marker) {
t.Errorf("Cloud output missing marketing-footer marker %q", marker)
}
}
// Order matters per docs/brand.md §7. Pinning pairwise positions
// across the full canonical list so a future reorder forces the
// test author to revisit the brand spec contract intentionally.
pairs := [][2]string{
{"github.com/PerpetualSoftware/pad", "getpad.dev/docs"},
{"getpad.dev/docs", "getpad.dev/changelog"},
{"getpad.dev/changelog", "getpad.dev/contribute"},
{"getpad.dev/contribute", "getpad.dev/faq"},
{"getpad.dev/faq", "getpad.dev/security"},
{"getpad.dev/security", "getpad.dev/privacy"},
{"getpad.dev/privacy", "getpad.dev/terms"},
{"getpad.dev/terms", "getpad.dev/subprocessors"},
}
for _, pair := range pairs {
if strings.Index(out, pair[0]) > strings.Index(out, pair[1]) {
t.Errorf("Cloud footer link order violated: %q must precede %q", pair[0], pair[1])
}
}
}
// TestBuildPlainShellMatchesHTMLBranching ensures the plain-text shell
// follows the same self-hosted vs Cloud branching as the HTML helper.
// Plain text has its own minimal format (no chrome) but must still
// gate the marketing-link block on cloudMode.
func TestBuildPlainShellMatchesHTMLBranching(t *testing.T) {
selfHosted := buildPlainShell("hello", "received because…", false)
cloud := buildPlainShell("hello", "received because…", true)
if !strings.Contains(selfHosted, "received because…") {
t.Error("self-hosted plain shell should include the footer note")
}
if strings.Contains(selfHosted, "github.com/PerpetualSoftware/pad") {
t.Error("self-hosted plain shell should NOT include marketing links")
}
if !strings.Contains(cloud, "github.com/PerpetualSoftware/pad") ||
!strings.Contains(cloud, "getpad.dev/docs") ||
!strings.Contains(cloud, "Perpetual Software") {
t.Error("Cloud plain shell should include the marketing link block + copyright")
}
}