mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-23 19:06:33 +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.
194 lines
5.4 KiB
Go
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
|
|
}
|