mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-10 23:15:40 +00:00
cec056cefe
* 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.
112 lines
4.0 KiB
Go
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 · 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")
|
|
}
|
|
}
|