Files
pad/internal/email/sender.go
T
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

194 lines
5.4 KiB
Go

// Package email provides transactional email sending via Maileroo.
// When no API key is configured, the server runs without email —
// invitations fall back to CLI-based join codes.
package email
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"sync"
"time"
)
// Sender sends transactional emails via the Maileroo API.
// The from address and name are defaults; individual send methods can
// override them for contextual sender names (e.g. "Dave via Pad").
type Sender struct {
mu sync.RWMutex
apiKey string
fromAddr string
fromName string
baseURL string // Pad's public base URL for generating links
endpoint string // Maileroo API endpoint (overridable for tests)
cloudMode bool // adds getpad.dev marketing footer to outgoing emails
client *http.Client
}
// defaultEndpoint is the Maileroo v2 email sending API.
const defaultEndpoint = "https://smtp.maileroo.com/api/v2/emails"
// NewSender creates a new email sender.
func NewSender(apiKey, fromAddr, fromName, baseURL string) *Sender {
return &Sender{
apiKey: apiKey,
fromAddr: fromAddr,
fromName: fromName,
baseURL: baseURL,
endpoint: defaultEndpoint,
client: &http.Client{Timeout: 15 * time.Second},
}
}
// Configure updates the sender's settings at runtime (e.g. when an admin
// changes platform settings). Thread-safe.
func (s *Sender) Configure(apiKey, fromAddr, fromName, baseURL string) {
s.mu.Lock()
defer s.mu.Unlock()
if apiKey != "" {
s.apiKey = apiKey
}
if fromAddr != "" {
s.fromAddr = fromAddr
}
if fromName != "" {
s.fromName = fromName
}
if baseURL != "" {
s.baseURL = baseURL
}
}
// SetEndpoint overrides the Maileroo API endpoint. Intended for tests that
// stand up an httptest server mimicking Maileroo's v2 API — production
// callers should leave the default in place. Thread-safe.
func (s *Sender) SetEndpoint(url string) {
s.mu.Lock()
defer s.mu.Unlock()
s.endpoint = url
}
// SetCloudMode toggles the getpad.dev marketing footer on outgoing
// transactional emails. Server callers flip it on after Server.SetCloudMode
// fires (see internal/server/server.go). Self-hosted deployments leave it
// off so emails stay neutral. Thread-safe.
func (s *Sender) SetCloudMode(enabled bool) {
s.mu.Lock()
defer s.mu.Unlock()
s.cloudMode = enabled
}
// CloudMode reports whether the marketing footer is currently enabled.
// Templates read it through getCloudMode() so they pick up the same
// snapshot for the entire render of one email. Thread-safe.
func (s *Sender) CloudMode() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.cloudMode
}
// BaseURL returns the configured base URL.
func (s *Sender) BaseURL() string {
s.mu.RLock()
defer s.mu.RUnlock()
return s.baseURL
}
// emailAddress is the Maileroo address format.
type emailAddress struct {
Address string `json:"address"`
DisplayName string `json:"display_name,omitempty"`
}
// mailerooPayload is the request body for the Maileroo v2 API.
type mailerooPayload struct {
From emailAddress `json:"from"`
To []emailAddress `json:"to"`
Subject string `json:"subject"`
HTML string `json:"html,omitempty"`
Plain string `json:"plain,omitempty"`
}
// mailerooResponse is the envelope returned by Maileroo.
type mailerooResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
}
// Send sends an email using the default from address/name.
func (s *Sender) Send(ctx context.Context, to, toName, subject, html, plain string) error {
s.mu.RLock()
fromAddr := s.fromAddr
fromName := s.fromName
endpoint := s.endpoint
s.mu.RUnlock()
return s.sendWith(ctx, endpoint, fromAddr, fromName, to, toName, subject, html, plain)
}
// SendAs sends an email with a custom from name (address stays the same
// since email providers require verified sender domains).
func (s *Sender) SendAs(ctx context.Context, fromName, to, toName, subject, html, plain string) error {
s.mu.RLock()
fromAddr := s.fromAddr
endpoint := s.endpoint
s.mu.RUnlock()
return s.sendWith(ctx, endpoint, fromAddr, fromName, to, toName, subject, html, plain)
}
// sendWith is the internal send implementation.
func (s *Sender) sendWith(ctx context.Context, endpoint, fromAddr, fromName, to, toName, subject, html, plain string) error {
s.mu.RLock()
apiKey := s.apiKey
s.mu.RUnlock()
payload := mailerooPayload{
From: emailAddress{
Address: fromAddr,
DisplayName: fromName,
},
To: []emailAddress{
{Address: to, DisplayName: toName},
},
Subject: subject,
HTML: html,
Plain: plain,
}
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal email payload: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := s.client.Do(req)
if err != nil {
return fmt.Errorf("send email: %w", err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
return fmt.Errorf("maileroo returned %d: %s", resp.StatusCode, string(respBody))
}
var result mailerooResponse
if err := json.Unmarshal(respBody, &result); err != nil {
return fmt.Errorf("decode maileroo response: %w", err)
}
if !result.Success {
return fmt.Errorf("maileroo error: %s", result.Message)
}
return nil
}