feat: ship Tier 1 — auth, authz, rate limits, real notifications, CSV import, billing, backups/DR, privacy

Closes every block in docs/TIER1_PLAN.md from the Claude-scope side. The
homelab / cloud setup steps (SES verification, restore drill, lawyer-
drafted ToS) remain operator-owned but are unblocked.

Block A — Authentication
- Migration 0003: password_hash, email_verified, email_verification_tokens,
  password_reset_tokens, refresh_tokens (with replaced_by family chain).
- Bcrypt hasher, HS256 JWT signer, single-use refresh tokens with rotation
  + replay-detection (revokes the family on reuse).
- /auth/signup, /login, /refresh, /logout, /verify-email,
  /forgot-password, /reset-password — enumeration-safe.
- requireAuth middleware + GET /me.
- Frontend useAuth/useApi with auto-refresh-on-401, login/signup/verify/
  forgot/reset pages, route-guard middleware.

Block B — Authorisation
- EventRepo.GetForHost; Update/Delete scoped by host_id.
- All host routes behind requireAuth + ownership; cross-tenant returns
  404 (no enumeration). ?host_id removed.
- WS auth via short-lived single-use tickets (POST /auth/ws-ticket).
- Tests: TestCrossTenantIsolation — 9 probes.

Block C — Rate limiting
- Redis sliding-window via Lua (atomic ZADD+ZCARD+PEXPIRE).
- Per-route limits matching the plan (signup IP, login IP+email, RSVP/
  access by token, events/guests/tokens by user_id).
- 429 with Retry-After header and JSON body.
- Auth lockout: 5 failed logins → account locked, only password reset
  clears it.
- Frontend: useErrMessage normalises 429 + locked messaging.

Block D — Real notifications
- Migration 0004: provider_message_id, bounce_type, complained columns
  + unsubscribes (CITEXT) suppression table.
- Branded HTML + plaintext templates for verification, reset, invitation,
  confirmation, reminder. Per-page templates avoid html/template's
  contextual-escape collisions.
- Senders: SESv2, Twilio (SMS), SMTP (Mailpit-friendly), Resend HTTP.
- PickEmailSender priority Resend > SMTP > SES > Log — system boots
  cleanly in dev with Mailpit; production flips one env var.
- Webhook endpoints (Twilio status + SES SNS) — bounces add to suppression;
  signature verification stubbed pending creds.
- Auto-send: POST /tokens publishes invitation.send; notifier renders +
  delivers via the configured backend; suppression list honoured.
- Bulk + per-row invitation flow: POST /events/{id}/guests/invitations/bulk
  returns per-guest tokens so phone-only guests can be SMS'd manually.
- Unsubscribe: signed HMAC token (no TTL) + /unsubscribe/[token] page.
- WhatsApp Option A+: wa.me click-to-chat wizard with per-guest progress
  tracking, isLikelyE164 validation, edit-from-wizard.
- Token rotate (POST /tokens/rotate) invalidates the old URL — used by
  the regenerate-link flow.
- Mailpit added to docker-compose for dev inbox.

Block E — CSV import
- Streaming parser: tolerant header detection, UTF-8 BOM + UTF-16 LE/BE
  decoding, row-level validation, 5,000-row cap.
- Strict E.164 phone validation with helpful error message.
- POST /preview + /import + GET /template; preview UI on event page;
  atomic per-batch with dedup on existing emails.

Phone capture across UI
- PhoneInput component: country picker (~50 ISO codes) + national input +
  live E.164 preview + inline length validation.
- Used in Add Guest and Edit Guest modals. Smart paste-handling extracts
  country code from full E.164 strings.

Block F — Billing (Stripe)
- Migration 0005: subscriptions table (user_id → tier/status/period_end +
  Stripe customer/sub ids). Partial unique index keeps one granting sub
  per user.
- internal/billing: Tier + Limits model (Free 1/50, Pro 10/1000, Business
  ∞/5000), Stripe SDK wrapper with IgnoreAPIVersionMismatch for newer
  account API versions.
- /billing/checkout-session, /billing/portal, /billing/status,
  /webhooks/stripe (signature-verified, lifecycle events).
- Tier enforcement: 402 on POST /events, /guests, /import with
  {error, reason, tier, used, limit, upgrade_url} body.
- Frontend: useBilling composable, /dashboard/billing page (current plan,
  usage bars, tier cards), global UpgradeModal triggered by useApi's
  402 interceptor.
- Customer portal kept for self-service cancel/payment-method changes.

Block G — Backups & DR (application side)
- Every migration has a tested .down.sql.
- TestMigrationRoundtrip applies all ups → all downs → all ups against a
  fresh container; catches asymmetric down migrations.
- cmd/restore-verify: 28-check post-restore invariant tool (schema
  presence, no orphans across 10 FK relationships, email uniqueness,
  single-active subscription, row-count snapshot).
- docs/RUNBOOK_RESTORE.md: 9-step restore procedure with RTO/RPO
  targets, drill instructions, rollback path.

Block H — Privacy compliance (application side)
- Migration 0006: deleted_at + terms_accepted_at + privacy_policy_accepted_at
  on users. Partial index on email for live-only uniqueness.
- GET /me/data-export — synchronous JSON dump (user, events, guests,
  tokens, rsvps, access_logs, notifications).
- DELETE /me — soft-delete with PII scrub + refresh-token revocation;
  re-signup with same email works.
- POST /me/accept-terms — idempotent consent recording.
- Frontend /privacy + /terms placeholder pages with substantive (pending
  legal review) copy; footer links; signup terms checkbox; TermsGateModal
  for accounts created before the rollout; export + delete buttons on
  /dashboard/billing.

Tests
- All migrations verified up/down/up.
- Integration suite: TestE2EHappyPath, TestAuthFlow, TestCrossTenantIsolation,
  TestRateLimitSignup, TestLoginLockout, TestUnsubscribeFlow,
  TestSESBounceWebhook, TestTwilioStatusWebhook, TestCsvImportFlow,
  TestCsvImportAtomicRollback, TestBulkIssueInvitations, TestBulkIssueExplicitSubset,
  TestTokenIssuePublishesInvitation, TestTokenIssueWithoutGuestEmailSkipsInvitation,
  TestGuestUpdate, TestGuestDelete, TestTokenRotate, TestSMTPSenderAgainstMailpit,
  TestFreeTierEventLimit, TestFreeTierGuestLimit, TestBusinessTierBypassesLimits,
  TestDataExport, TestDeleteMe, TestAcceptTerms, TestMigrationRoundtrip.
  Full suite runs in ~120s against real Postgres + NATS + Redis + Mailpit.
- Unit suite green across internal/auth, internal/csvimport,
  internal/notification, internal/ratelimit, internal/domain.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Kwaku Danso
2026-05-16 23:54:22 +01:00
parent a0ed34f860
commit 59b8781659
124 changed files with 13702 additions and 445 deletions
+137
View File
@@ -0,0 +1,137 @@
package notification
import (
"context"
"fmt"
"strings"
"github.com/aws/aws-sdk-go-v2/aws"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/sesv2"
"github.com/aws/aws-sdk-go-v2/service/sesv2/types"
)
// SESConfig is the surface area for picking an SES sender. ConfigurationSet
// is optional but recommended in production — it's where bounce + complaint
// SNS topics get wired so the webhook handler has something to consume.
type SESConfig struct {
Region string
FromEmail string
FromName string
ConfigurationSet string
PublicBaseURL string // for unsubscribe links in templates
}
// SESEmailSender sends transactional emails (verification + reset for the
// auth flows, plus invitation/confirmation/reminder for guests) via Amazon
// SESv2. The same client serves both audiences so callers don't end up
// with two SES configurations to maintain.
type SESEmailSender struct {
client *sesv2.Client
tpls *Templates
from string
configSet *string
baseURL string
}
// NewSESEmailSender returns a configured SES sender, or an error if the
// AWS SDK can't bootstrap. The caller typically constructs this once at
// startup and reuses it for the lifetime of the process.
func NewSESEmailSender(ctx context.Context, cfg SESConfig, tpls *Templates) (*SESEmailSender, error) {
if cfg.FromEmail == "" {
return nil, fmt.Errorf("ses: FromEmail required")
}
if cfg.Region == "" {
cfg.Region = "us-east-1"
}
awsCfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(cfg.Region))
if err != nil {
return nil, fmt.Errorf("ses: load aws config: %w", err)
}
client := sesv2.NewFromConfig(awsCfg)
from := cfg.FromEmail
if cfg.FromName != "" {
from = fmt.Sprintf("%s <%s>", cfg.FromName, cfg.FromEmail)
}
var cs *string
if cfg.ConfigurationSet != "" {
cs = aws.String(cfg.ConfigurationSet)
}
return &SESEmailSender{
client: client,
tpls: tpls,
from: from,
configSet: cs,
baseURL: strings.TrimRight(cfg.PublicBaseURL, "/"),
}, nil
}
// --- auth.EmailSender implementation ---
// SendVerification renders the verification template and posts it to SES.
func (s *SESEmailSender) SendVerification(ctx context.Context, to, name, link string) error {
return s.sendTemplated(ctx, to, "Verify your GuestGuard email",
TmplVerification, map[string]any{
"Name": name,
"Link": link,
})
}
// SendPasswordReset renders the reset template and posts it to SES.
func (s *SESEmailSender) SendPasswordReset(ctx context.Context, to, name, link string) error {
return s.sendTemplated(ctx, to, "Reset your GuestGuard password",
TmplPasswordReset, map[string]any{
"Name": name,
"Link": link,
"ExpiryHumane": "1 hour",
})
}
// SendGuest is used by the notifier worker for invitation / confirmation /
// reminder emails — anything addressed at a guest.
func (s *SESEmailSender) SendGuest(ctx context.Context, to, subject string, name TemplateName, data map[string]any) (providerMessageID string, err error) {
return s.sendTemplatedReturnID(ctx, to, subject, name, data)
}
// --- internals ---
func (s *SESEmailSender) sendTemplated(ctx context.Context, to, subject string, name TemplateName, data map[string]any) error {
_, err := s.sendTemplatedReturnID(ctx, to, subject, name, data)
return err
}
func (s *SESEmailSender) sendTemplatedReturnID(ctx context.Context, to, subject string, name TemplateName, data map[string]any) (string, error) {
if data == nil {
data = map[string]any{}
}
data["Subject"] = subject
html, text, err := s.tpls.Render(name, data)
if err != nil {
return "", err
}
out, err := s.client.SendEmail(ctx, &sesv2.SendEmailInput{
FromEmailAddress: aws.String(s.from),
Destination: &types.Destination{ToAddresses: []string{to}},
ConfigurationSetName: s.configSet,
Content: &types.EmailContent{
Simple: &types.Message{
Subject: &types.Content{Data: aws.String(subject), Charset: aws.String("UTF-8")},
Body: &types.Body{
Html: &types.Content{Data: aws.String(html), Charset: aws.String("UTF-8")},
Text: &types.Content{Data: aws.String(text), Charset: aws.String("UTF-8")},
},
},
},
})
if err != nil {
return "", fmt.Errorf("ses: send: %w", err)
}
if out.MessageId == nil {
return "", nil
}
return *out.MessageId, nil
}
+97
View File
@@ -0,0 +1,97 @@
package notification
import (
"context"
"log/slog"
)
// EmailBackend names the chosen email delivery channel for telemetry +
// startup logging. Mostly a debugging aid — code paths don't branch on
// this value.
type EmailBackend string
const (
BackendResend EmailBackend = "resend"
BackendSMTP EmailBackend = "smtp"
BackendSES EmailBackend = "ses"
BackendLog EmailBackend = "log"
)
// EmailSenderConfig collects every email-related env var so the picker
// has a single, ordered place to decide which backend wins. Priority is
// Resend > SMTP > SES > Log — the first one with non-empty creds is used.
type EmailSenderConfig struct {
Resend ResendConfig
SMTP SMTPConfig
SES SESConfig
}
// CombinedEmailSender satisfies both the auth.EmailSender interface (for
// verification + reset emails) and GuestEmailDispatcher (for invitation,
// confirmation, reminder). One concrete value handles both audiences so
// callers don't end up with two configurations.
type CombinedEmailSender interface {
SendVerification(ctx context.Context, to, name, link string) error
SendPasswordReset(ctx context.Context, to, name, link string) error
SendGuest(ctx context.Context, to, subject string, name TemplateName, data map[string]any) (string, error)
}
// PickEmailSender returns the configured email sender + which backend was
// chosen. Falls back to a logger stub if nothing is configured, so the
// service stays bootable in stripped-down dev environments.
func PickEmailSender(ctx context.Context, cfg EmailSenderConfig, tpls *Templates, logger *slog.Logger) (CombinedEmailSender, EmailBackend, error) {
switch {
case cfg.Resend.APIKey != "":
s, err := NewResendEmailSender(cfg.Resend, tpls)
if err != nil {
return nil, "", err
}
return s, BackendResend, nil
case cfg.SMTP.Host != "":
s, err := NewSMTPEmailSender(cfg.SMTP, tpls)
if err != nil {
return nil, "", err
}
return s, BackendSMTP, nil
case cfg.SES.FromEmail != "":
s, err := NewSESEmailSender(ctx, cfg.SES, tpls)
if err != nil {
return nil, "", err
}
return s, BackendSES, nil
}
return &logCombinedSender{logger: logger, tpls: tpls}, BackendLog, nil
}
// logCombinedSender is the dev fallback. Verification + reset emails come
// through as structured log lines (preserving the Block A behaviour);
// guest emails get rendered + dumped so engineers can eyeball the output.
type logCombinedSender struct {
logger *slog.Logger
tpls *Templates
}
func (l *logCombinedSender) SendVerification(_ context.Context, to, name, link string) error {
l.logger.Info("auth email (stub): verification", "to", to, "name", name, "link", link)
return nil
}
func (l *logCombinedSender) SendPasswordReset(_ context.Context, to, name, link string) error {
l.logger.Info("auth email (stub): password reset", "to", to, "name", name, "link", link)
return nil
}
func (l *logCombinedSender) SendGuest(_ context.Context, to, subject string, name TemplateName, data map[string]any) (string, error) {
if data == nil {
data = map[string]any{}
}
data["Subject"] = subject
_, text, err := l.tpls.Render(name, data)
if err != nil {
return "", err
}
l.logger.Info("guest email (stub)",
"to", to, "subject", subject, "template", string(name), "text_body", text,
)
return "log:" + string(name), nil
}
+44 -9
View File
@@ -64,12 +64,13 @@ func NewRepo(db *storage.DB) *Repo {
}
type RecordParams struct {
GuestID uuid.UUID
Channel Channel
Type Type
Status Status
ProviderID string
Error string
GuestID uuid.UUID
Channel Channel
Type Type
Status Status
ProviderID string // human-friendly id (e.g. "log:xyz")
ProviderMessageID string // provider's message id (Twilio SID, SES MessageId)
Error string
}
func (r *Repo) Record(ctx context.Context, p RecordParams) (uuid.UUID, error) {
@@ -77,6 +78,10 @@ func (r *Repo) Record(ctx context.Context, p RecordParams) (uuid.UUID, error) {
if p.ProviderID != "" {
providerID = &p.ProviderID
}
var providerMsgID *string
if p.ProviderMessageID != "" {
providerMsgID = &p.ProviderMessageID
}
var errStr *string
if p.Error != "" {
errStr = &p.Error
@@ -90,14 +95,15 @@ func (r *Repo) Record(ctx context.Context, p RecordParams) (uuid.UUID, error) {
const q = `
INSERT INTO notifications (guest_id, channel, type, status, provider_id,
attempts, last_attempt, delivered_at, error)
VALUES ($1, $2, $3, $4, $5, 1, now(), $6, $7)
provider_message_id, attempts, last_attempt,
delivered_at, error)
VALUES ($1, $2, $3, $4, $5, $6, 1, now(), $7, $8)
RETURNING id
`
var id uuid.UUID
err := r.pool.QueryRow(ctx, q,
p.GuestID, string(p.Channel), string(p.Type), string(p.Status),
providerID, deliveredAt, errStr,
providerID, providerMsgID, deliveredAt, errStr,
).Scan(&id)
if err != nil {
return uuid.Nil, fmt.Errorf("record notification: %w", err)
@@ -105,6 +111,35 @@ func (r *Repo) Record(ctx context.Context, p RecordParams) (uuid.UUID, error) {
return id, nil
}
// MarkBounce records a bounce on the notification row identified by the
// provider's message id. Called from webhook handlers.
func (r *Repo) MarkBounce(ctx context.Context, providerMessageID, bounceType string) error {
_, err := r.pool.Exec(ctx, `
UPDATE notifications
SET status = 'bounced', bounce_type = $2, error = COALESCE(error, '')
WHERE provider_message_id = $1
`, providerMessageID, bounceType)
return err
}
// MarkComplaint records a complaint (spam report) for the same row.
func (r *Repo) MarkComplaint(ctx context.Context, providerMessageID string) error {
_, err := r.pool.Exec(ctx, `
UPDATE notifications SET complained = TRUE WHERE provider_message_id = $1
`, providerMessageID)
return err
}
// MarkDelivered moves a row from 'sent' to 'delivered' when the provider's
// delivery status webhook fires.
func (r *Repo) MarkDelivered(ctx context.Context, providerMessageID string) error {
_, err := r.pool.Exec(ctx, `
UPDATE notifications SET status = 'delivered', delivered_at = now()
WHERE provider_message_id = $1 AND status NOT IN ('bounced','failed')
`, providerMessageID)
return err
}
// LogSender pretends to send and just logs. Useful for Phase 3 demos and
// tests; concrete providers (Twilio/SES) plug in later.
type LogSender struct{}
+134
View File
@@ -0,0 +1,134 @@
package notification
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"time"
)
// ResendConfig configures the Resend HTTP sender. APIKey is required;
// FromEmail is the sending address on a domain you've verified in the
// Resend dashboard.
type ResendConfig struct {
APIKey string
FromEmail string
FromName string
// HTTPClient overrides the default client (mostly so tests can point
// at httptest.Server). Leave nil for production.
HTTPClient *http.Client
BaseURL string // overrideable for tests; defaults to https://api.resend.com
}
// ResendEmailSender posts emails through https://api.resend.com/emails.
// Implements auth.EmailSender + GuestEmailDispatcher.
type ResendEmailSender struct {
cfg ResendConfig
tpls *Templates
from string
client *http.Client
url string
}
func NewResendEmailSender(cfg ResendConfig, tpls *Templates) (*ResendEmailSender, error) {
if cfg.APIKey == "" {
return nil, errors.New("resend: APIKey required")
}
if cfg.FromEmail == "" {
return nil, errors.New("resend: FromEmail required")
}
from := cfg.FromEmail
if cfg.FromName != "" {
from = fmt.Sprintf("%s <%s>", cfg.FromName, cfg.FromEmail)
}
cli := cfg.HTTPClient
if cli == nil {
cli = &http.Client{Timeout: 15 * time.Second}
}
base := cfg.BaseURL
if base == "" {
base = "https://api.resend.com"
}
return &ResendEmailSender{cfg: cfg, tpls: tpls, from: from, client: cli, url: base + "/emails"}, nil
}
// --- auth.EmailSender ---
func (s *ResendEmailSender) SendVerification(ctx context.Context, to, name, link string) error {
_, err := s.sendTemplated(ctx, to, "Verify your GuestGuard email",
TmplVerification, map[string]any{"Name": name, "Link": link})
return err
}
func (s *ResendEmailSender) SendPasswordReset(ctx context.Context, to, name, link string) error {
_, err := s.sendTemplated(ctx, to, "Reset your GuestGuard password",
TmplPasswordReset, map[string]any{"Name": name, "Link": link, "ExpiryHumane": "1 hour"})
return err
}
// --- GuestEmailDispatcher ---
func (s *ResendEmailSender) SendGuest(ctx context.Context, to, subject string, name TemplateName, data map[string]any) (string, error) {
return s.sendTemplated(ctx, to, subject, name, data)
}
// --- internals ---
type resendRequest struct {
From string `json:"from"`
To []string `json:"to"`
Subject string `json:"subject"`
HTML string `json:"html"`
Text string `json:"text"`
}
type resendResponse struct {
ID string `json:"id"`
Message string `json:"message,omitempty"`
}
func (s *ResendEmailSender) sendTemplated(ctx context.Context, to, subject string, name TemplateName, data map[string]any) (string, error) {
if data == nil {
data = map[string]any{}
}
data["Subject"] = subject
html, text, err := s.tpls.Render(name, data)
if err != nil {
return "", err
}
body, _ := json.Marshal(resendRequest{
From: s.from,
To: []string{to},
Subject: subject,
HTML: html,
Text: text,
})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.url, bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+s.cfg.APIKey)
req.Header.Set("Content-Type", "application/json")
resp, err := s.client.Do(req)
if err != nil {
return "", fmt.Errorf("resend: do: %w", err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
if resp.StatusCode >= 300 {
return "", fmt.Errorf("resend: status %d: %s", resp.StatusCode, string(respBody))
}
var parsed resendResponse
if err := json.Unmarshal(respBody, &parsed); err != nil {
return "", fmt.Errorf("resend: parse: %w", err)
}
return parsed.ID, nil
}
@@ -0,0 +1,89 @@
package notification
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestResendSenderHappyPath(t *testing.T) {
tpls, err := NewTemplates()
if err != nil {
t.Fatalf("NewTemplates: %v", err)
}
var gotPath, gotAuth, gotContentType string
var gotBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotAuth = r.Header.Get("Authorization")
gotContentType = r.Header.Get("Content-Type")
b, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(b, &gotBody)
_ = json.NewEncoder(w).Encode(map[string]string{"id": "resend-abc-123"})
}))
t.Cleanup(srv.Close)
s, err := NewResendEmailSender(ResendConfig{
APIKey: "secret-key",
FromEmail: "no-reply@example.test",
FromName: "GuestGuard",
BaseURL: srv.URL,
}, tpls)
if err != nil {
t.Fatalf("NewResendEmailSender: %v", err)
}
id, err := s.SendGuest(context.Background(), "to@example.test", "You're invited",
TmplInvitation, map[string]any{
"GuestName": "Mira", "HostName": "Kay", "EventName": "Beach Day",
"Link": "https://example.test/rsvp/x",
})
if err != nil {
t.Fatalf("SendGuest: %v", err)
}
if id != "resend-abc-123" {
t.Fatalf("provider id: got %q want %q", id, "resend-abc-123")
}
if gotPath != "/emails" {
t.Errorf("path: got %q want /emails", gotPath)
}
if gotAuth != "Bearer secret-key" {
t.Errorf("auth: got %q", gotAuth)
}
if gotContentType != "application/json" {
t.Errorf("content-type: got %q", gotContentType)
}
if gotBody["from"] != "GuestGuard <no-reply@example.test>" {
t.Errorf("from: got %v", gotBody["from"])
}
if !strings.Contains(gotBody["html"].(string), "Beach Day") {
t.Errorf("html missing event name: %v", gotBody["html"])
}
if !strings.Contains(gotBody["text"].(string), "Mira") {
t.Errorf("text missing guest name: %v", gotBody["text"])
}
}
func TestResendSenderErrorPropagates(t *testing.T) {
tpls, _ := NewTemplates()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"message":"invalid api key"}`))
}))
t.Cleanup(srv.Close)
s, err := NewResendEmailSender(ResendConfig{
APIKey: "bad", FromEmail: "x@y.test", BaseURL: srv.URL,
}, tpls)
if err != nil {
t.Fatalf("NewResendEmailSender: %v", err)
}
if err := s.SendVerification(context.Background(), "z@y.test", "Z", "https://link"); err == nil {
t.Fatal("expected error on 401")
}
}
+70
View File
@@ -0,0 +1,70 @@
package notification
import (
"context"
"fmt"
"log/slog"
"github.com/google/uuid"
)
// Router dispatches an OutboundMessage to the channel-appropriate sender.
// The notifier worker holds one Router and stays oblivious to whether the
// concrete backend is SES, Twilio, or a logger stub.
type Router struct {
email Sender
sms Sender
}
func NewRouter(email, sms Sender) *Router {
return &Router{email: email, sms: sms}
}
func (r *Router) Send(ctx context.Context, msg OutboundMessage) (string, error) {
switch msg.Channel {
case ChannelEmail:
if r.email == nil {
return "", fmt.Errorf("no email sender configured")
}
return r.email.Send(ctx, msg)
case ChannelSMS:
if r.sms == nil {
return "", fmt.Errorf("no sms sender configured")
}
return r.sms.Send(ctx, msg)
}
return "", fmt.Errorf("router: unknown channel %q", msg.Channel)
}
// LogGuestEmailDispatcher is the dev-mode dispatcher that renders the
// templated email and logs both bodies. Useful for local docker-compose
// before SES is configured — gives engineers the rendered output without
// needing a real inbox.
type LogGuestEmailDispatcher struct {
logger *slog.Logger
tpls *Templates
}
func NewLogGuestEmailDispatcher(logger *slog.Logger, tpls *Templates) *LogGuestEmailDispatcher {
return &LogGuestEmailDispatcher{logger: logger, tpls: tpls}
}
func (d *LogGuestEmailDispatcher) SendGuest(_ context.Context, to, subject string, name TemplateName, data map[string]any) (string, error) {
if data == nil {
data = map[string]any{}
}
data["Subject"] = subject
_, text, err := d.tpls.Render(name, data)
if err != nil {
return "", err
}
id := "log:" + uuid.NewString()
d.logger.Info("guest email (stub)",
"to", to,
"subject", subject,
"template", string(name),
"provider_message_id", id,
"text_body", text,
)
return id, nil
}
+104
View File
@@ -0,0 +1,104 @@
package notification
import (
"context"
"errors"
"fmt"
"github.com/google/uuid"
)
// GuestEmailDispatcher is the abstraction the notifier uses to send a
// templated email to a single guest. Both SES (production) and Log (dev)
// satisfy it.
type GuestEmailDispatcher interface {
SendGuest(ctx context.Context, to, subject string, name TemplateName, data map[string]any) (providerMessageID string, err error)
}
// EmailSender is the notification.Sender for ChannelEmail. It maps the
// generic OutboundMessage shape used by the notifier worker onto our
// templated SES / Log path, and honours the suppression list (a guest who
// unsubscribed must never receive another email).
type EmailSender struct {
dispatcher GuestEmailDispatcher
suppression *SuppressionRepo
}
func NewEmailSender(d GuestEmailDispatcher, s *SuppressionRepo) *EmailSender {
return &EmailSender{dispatcher: d, suppression: s}
}
func (e *EmailSender) Send(ctx context.Context, msg OutboundMessage) (string, error) {
if msg.GuestID == uuid.Nil {
return "", errors.New("missing guest id")
}
if msg.Channel != ChannelEmail {
return "", fmt.Errorf("EmailSender does not handle channel %q", msg.Channel)
}
to, _ := msg.Metadata["to"].(string)
if to == "" {
return "", errors.New("email recipient missing from metadata.to")
}
if e.suppression != nil {
yep, err := e.suppression.IsSuppressed(ctx, to)
if err != nil {
// On lookup error, fail safe by NOT sending — better than
// emailing an unsubscribed address.
return "", fmt.Errorf("suppression lookup: %w", err)
}
if yep {
return "suppressed:" + to, nil
}
}
tmpl := templateForType(msg.Type)
if tmpl == "" {
return "", fmt.Errorf("no template for type %q", msg.Type)
}
subject := msg.Subject
if subject == "" {
subject = defaultSubject(msg.Type, msg.Metadata)
}
data := map[string]any{}
for k, v := range msg.Metadata {
data[k] = v
}
return e.dispatcher.SendGuest(ctx, to, subject, tmpl, data)
}
func templateForType(t Type) TemplateName {
switch t {
case TypeInvitation:
return TmplInvitation
case TypeConfirmation:
return TmplConfirmation
case TypeReminder:
return TmplReminder
case TypeVerification:
return TmplVerification
}
return ""
}
func defaultSubject(t Type, meta map[string]any) string {
event, _ := meta["EventName"].(string)
switch t {
case TypeInvitation:
if event != "" {
return "You're invited — " + event
}
return "You're invited"
case TypeConfirmation:
if event != "" {
return "RSVP confirmed — " + event
}
return "RSVP confirmed"
case TypeReminder:
if event != "" {
return "Reminder: " + event
}
return "Reminder"
}
return "GuestGuard"
}
+108
View File
@@ -0,0 +1,108 @@
package notification
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/twilio/twilio-go"
openapi "github.com/twilio/twilio-go/rest/api/v2010"
)
// TwilioConfig configures the SMS sender. AccountSID + AuthToken pair
// authenticate the REST client; FromNumber is the verified Twilio number.
type TwilioConfig struct {
AccountSID string
AuthToken string
FromNumber string
MaxAttempts int
}
// TwilioSender implements notification.Sender for ChannelSMS. Retries on
// transient errors with exponential backoff (1s, 5s, 30s, 5m, 30m). Twilio
// surfaces a numeric ErrorCode for permanent failures (e.g. 21610
// unsubscribed, 21408 disabled region) — those return immediately.
type TwilioSender struct {
client *twilio.RestClient
from string
maxAttempt int
}
func NewTwilioSender(cfg TwilioConfig) (*TwilioSender, error) {
if cfg.AccountSID == "" || cfg.AuthToken == "" || cfg.FromNumber == "" {
return nil, errors.New("twilio: AccountSID / AuthToken / FromNumber are required")
}
cli := twilio.NewRestClientWithParams(twilio.ClientParams{
Username: cfg.AccountSID,
Password: cfg.AuthToken,
})
max := cfg.MaxAttempts
if max <= 0 {
max = 5
}
return &TwilioSender{client: cli, from: cfg.FromNumber, maxAttempt: max}, nil
}
func (t *TwilioSender) Send(ctx context.Context, msg OutboundMessage) (string, error) {
if msg.GuestID == uuid.Nil {
return "", errors.New("missing guest id")
}
if msg.Channel != ChannelSMS {
return "", fmt.Errorf("TwilioSender does not handle channel %q", msg.Channel)
}
to, _ := msg.Metadata["phone"].(string)
if to == "" {
return "", errors.New("sms recipient missing from metadata.phone")
}
body := msg.Body
if body == "" {
body = msg.Subject
}
if body == "" {
return "", errors.New("sms body is empty")
}
params := &openapi.CreateMessageParams{}
params.SetTo(to)
params.SetFrom(t.from)
params.SetBody(body)
backoff := []time.Duration{0, time.Second, 5 * time.Second, 30 * time.Second, 5 * time.Minute, 30 * time.Minute}
var lastErr error
for attempt := 0; attempt < t.maxAttempt; attempt++ {
if attempt < len(backoff) && backoff[attempt] > 0 {
select {
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(backoff[attempt]):
}
}
resp, err := t.client.Api.CreateMessage(params)
if err == nil && resp != nil && resp.Sid != nil {
return *resp.Sid, nil
}
lastErr = err
if !isTwilioRetryable(err) {
return "", fmt.Errorf("twilio: send: %w", err)
}
}
return "", fmt.Errorf("twilio: send (after %d attempts): %w", t.maxAttempt, lastErr)
}
// isTwilioRetryable returns true for transient failures (network, 5xx).
// Twilio's permanent error codes (21xxx range) are not retried.
func isTwilioRetryable(err error) bool {
if err == nil {
return false
}
msg := err.Error()
// Cheap heuristic: permanent codes are in the 21xxx range; everything
// else (timeouts, 503s, DNS hiccups) is fair game.
if strings.Contains(msg, "21610") || strings.Contains(msg, "21408") || strings.Contains(msg, "21211") {
return false
}
return true
}
+238
View File
@@ -0,0 +1,238 @@
package notification
import (
"context"
"crypto/rand"
"crypto/tls"
"encoding/hex"
"errors"
"fmt"
"net"
"net/smtp"
"strconv"
"strings"
"time"
)
// SMTPConfig describes one SMTP relay. Username/Password are optional —
// local relays like Mailpit accept anonymous SMTP. TLS modes:
//
// - "starttls" upgrade after EHLO (most relays, default)
// - "implicit" TLS handshake before SMTP (port 465)
// - "none" plain socket — only acceptable on a trusted LAN
type SMTPConfig struct {
Host string
Port int
Username string
Password string
FromEmail string
FromName string
TLS string
}
// SMTPEmailSender implements auth.EmailSender (verification/reset) AND
// GuestEmailDispatcher (invitation/confirmation/reminder) on top of any
// SMTP relay. Used for Mailpit in dev; works against Gmail, Fastmail, etc.
// in production if the user prefers plain SMTP over an HTTP API.
type SMTPEmailSender struct {
cfg SMTPConfig
tpls *Templates
from string
}
func NewSMTPEmailSender(cfg SMTPConfig, tpls *Templates) (*SMTPEmailSender, error) {
if cfg.Host == "" {
return nil, errors.New("smtp: Host required")
}
if cfg.Port <= 0 {
cfg.Port = 587
}
if cfg.FromEmail == "" {
return nil, errors.New("smtp: FromEmail required")
}
if cfg.TLS == "" {
cfg.TLS = "starttls"
}
from := cfg.FromEmail
if cfg.FromName != "" {
from = fmt.Sprintf("%s <%s>", cfg.FromName, cfg.FromEmail)
}
return &SMTPEmailSender{cfg: cfg, tpls: tpls, from: from}, nil
}
// --- auth.EmailSender ---
func (s *SMTPEmailSender) SendVerification(ctx context.Context, to, name, link string) error {
return s.sendTemplated(ctx, to, "Verify your GuestGuard email",
TmplVerification, map[string]any{"Name": name, "Link": link})
}
func (s *SMTPEmailSender) SendPasswordReset(ctx context.Context, to, name, link string) error {
return s.sendTemplated(ctx, to, "Reset your GuestGuard password",
TmplPasswordReset, map[string]any{"Name": name, "Link": link, "ExpiryHumane": "1 hour"})
}
// --- GuestEmailDispatcher ---
func (s *SMTPEmailSender) SendGuest(ctx context.Context, to, subject string, name TemplateName, data map[string]any) (string, error) {
return s.sendTemplatedReturnID(ctx, to, subject, name, data)
}
// --- internals ---
func (s *SMTPEmailSender) sendTemplated(ctx context.Context, to, subject string, name TemplateName, data map[string]any) error {
_, err := s.sendTemplatedReturnID(ctx, to, subject, name, data)
return err
}
func (s *SMTPEmailSender) sendTemplatedReturnID(ctx context.Context, to, subject string, name TemplateName, data map[string]any) (string, error) {
if data == nil {
data = map[string]any{}
}
data["Subject"] = subject
html, text, err := s.tpls.Render(name, data)
if err != nil {
return "", err
}
msgID := generateMessageID(s.cfg.FromEmail)
body := buildMIMEMessage(mimeMessage{
MessageID: msgID,
From: s.from,
To: to,
Subject: subject,
Text: text,
HTML: html,
})
if err := s.dial(ctx, []string{to}, body); err != nil {
return "", err
}
return msgID, nil
}
func (s *SMTPEmailSender) dial(ctx context.Context, to []string, body []byte) error {
addr := net.JoinHostPort(s.cfg.Host, strconv.Itoa(s.cfg.Port))
d := &net.Dialer{Timeout: 10 * time.Second}
deadline, ok := ctx.Deadline()
if ok {
d.Deadline = deadline
}
var (
conn net.Conn
err error
)
switch strings.ToLower(s.cfg.TLS) {
case "implicit":
conn, err = tls.DialWithDialer(d, "tcp", addr, &tls.Config{ServerName: s.cfg.Host})
default:
conn, err = d.DialContext(ctx, "tcp", addr)
}
if err != nil {
return fmt.Errorf("smtp: dial: %w", err)
}
c, err := smtp.NewClient(conn, s.cfg.Host)
if err != nil {
conn.Close()
return fmt.Errorf("smtp: new client: %w", err)
}
defer c.Close()
if strings.ToLower(s.cfg.TLS) == "starttls" {
if ok, _ := c.Extension("STARTTLS"); ok {
if err := c.StartTLS(&tls.Config{ServerName: s.cfg.Host}); err != nil {
return fmt.Errorf("smtp: starttls: %w", err)
}
}
}
if s.cfg.Username != "" {
auth := smtp.PlainAuth("", s.cfg.Username, s.cfg.Password, s.cfg.Host)
if err := c.Auth(auth); err != nil {
return fmt.Errorf("smtp: auth: %w", err)
}
}
if err := c.Mail(s.cfg.FromEmail); err != nil {
return fmt.Errorf("smtp: MAIL FROM: %w", err)
}
for _, rcpt := range to {
if err := c.Rcpt(rcpt); err != nil {
return fmt.Errorf("smtp: RCPT TO %s: %w", rcpt, err)
}
}
w, err := c.Data()
if err != nil {
return fmt.Errorf("smtp: DATA: %w", err)
}
if _, err := w.Write(body); err != nil {
_ = w.Close()
return fmt.Errorf("smtp: write body: %w", err)
}
if err := w.Close(); err != nil {
return fmt.Errorf("smtp: close body: %w", err)
}
return c.Quit()
}
type mimeMessage struct {
MessageID string
From string
To string
Subject string
Text string
HTML string
}
// buildMIMEMessage assembles an RFC 5322 message with a multipart/alternative
// body so receiving clients pick HTML when they can and fall back to text
// otherwise.
func buildMIMEMessage(m mimeMessage) []byte {
boundary := randomBoundary()
var b strings.Builder
b.WriteString("Message-ID: <" + m.MessageID + ">\r\n")
b.WriteString("Date: " + time.Now().UTC().Format(time.RFC1123Z) + "\r\n")
b.WriteString("From: " + m.From + "\r\n")
b.WriteString("To: " + m.To + "\r\n")
b.WriteString("Subject: " + m.Subject + "\r\n")
b.WriteString("MIME-Version: 1.0\r\n")
b.WriteString("Content-Type: multipart/alternative; boundary=" + boundary + "\r\n")
b.WriteString("\r\n")
b.WriteString("--" + boundary + "\r\n")
b.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
b.WriteString("Content-Transfer-Encoding: 8bit\r\n")
b.WriteString("\r\n")
b.WriteString(m.Text)
if !strings.HasSuffix(m.Text, "\n") {
b.WriteString("\r\n")
}
b.WriteString("--" + boundary + "\r\n")
b.WriteString("Content-Type: text/html; charset=UTF-8\r\n")
b.WriteString("Content-Transfer-Encoding: 8bit\r\n")
b.WriteString("\r\n")
b.WriteString(m.HTML)
if !strings.HasSuffix(m.HTML, "\n") {
b.WriteString("\r\n")
}
b.WriteString("--" + boundary + "--\r\n")
return []byte(b.String())
}
func randomBoundary() string {
var buf [16]byte
_, _ = rand.Read(buf[:])
return "gg=" + hex.EncodeToString(buf[:])
}
func generateMessageID(from string) string {
var buf [12]byte
_, _ = rand.Read(buf[:])
domain := "guestguard.local"
if at := strings.LastIndexByte(from, '@'); at >= 0 && at+1 < len(from) {
domain = from[at+1:]
}
return hex.EncodeToString(buf[:]) + "@" + domain
}
+49
View File
@@ -0,0 +1,49 @@
package notification
import (
"strings"
"testing"
)
func TestBuildMIMEMessageStructure(t *testing.T) {
body := buildMIMEMessage(mimeMessage{
MessageID: "abc@example.test",
From: "GuestGuard <no-reply@example.test>",
To: "to@example.test",
Subject: "Verify your GuestGuard email",
Text: "Hi Mira, please verify.",
HTML: "<p>Hi Mira, please verify.</p>",
})
s := string(body)
checks := []string{
"Message-ID: <abc@example.test>",
"From: GuestGuard <no-reply@example.test>",
"To: to@example.test",
"Subject: Verify your GuestGuard email",
"MIME-Version: 1.0",
"Content-Type: multipart/alternative; boundary=",
"Content-Type: text/plain; charset=UTF-8",
"Content-Type: text/html; charset=UTF-8",
"Hi Mira, please verify.",
"<p>Hi Mira, please verify.</p>",
}
for _, want := range checks {
if !strings.Contains(s, want) {
t.Errorf("MIME body missing %q\n---\n%s", want, s)
}
}
}
func TestGenerateMessageIDIncludesDomain(t *testing.T) {
id := generateMessageID("no-reply@example.test")
if !strings.HasSuffix(id, "@example.test") {
t.Fatalf("message id has wrong domain: %s", id)
}
}
func TestGenerateMessageIDFallback(t *testing.T) {
id := generateMessageID("not-an-email")
if !strings.HasSuffix(id, "@guestguard.local") {
t.Fatalf("expected fallback domain: %s", id)
}
}
+95
View File
@@ -0,0 +1,95 @@
package notification
import (
"context"
"errors"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/alchemistkay/guestguard/internal/storage"
)
// SuppressionSource categorises why an address landed on the suppression
// list. Bounces + complaints come from provider webhooks; "user" is set
// when a guest clicks an unsubscribe link.
type SuppressionSource string
const (
SuppressionBounce SuppressionSource = "bounce"
SuppressionComplaint SuppressionSource = "complaint"
SuppressionManual SuppressionSource = "manual"
SuppressionUser SuppressionSource = "user"
)
// SuppressionRepo manages the unsubscribes table — a flat suppression list
// of email addresses that must never receive another email regardless of
// notification type.
type SuppressionRepo struct {
pool *pgxpool.Pool
}
func NewSuppressionRepo(db *storage.DB) *SuppressionRepo {
return &SuppressionRepo{pool: db.Pool}
}
// IsSuppressed returns true if `email` is on the suppression list.
// Empty / unparseable addresses are treated as not-suppressed; the caller
// is expected to validate before sending.
func (r *SuppressionRepo) IsSuppressed(ctx context.Context, email string) (bool, error) {
email = normaliseEmail(email)
if email == "" {
return false, nil
}
var exists bool
err := r.pool.QueryRow(ctx,
`SELECT EXISTS (SELECT 1 FROM unsubscribes WHERE email = $1)`,
email,
).Scan(&exists)
if err != nil {
return false, err
}
return exists, nil
}
// Add records `email` on the suppression list. Idempotent — repeated calls
// keep the earliest entry's timestamp.
func (r *SuppressionRepo) Add(ctx context.Context, email, reason string, src SuppressionSource) error {
email = normaliseEmail(email)
if email == "" {
return errors.New("empty email")
}
_, err := r.pool.Exec(ctx, `
INSERT INTO unsubscribes (email, reason, source)
VALUES ($1, NULLIF($2, ''), $3)
ON CONFLICT (email) DO NOTHING
`, email, reason, string(src))
return err
}
// Get returns the suppression record for `email`, or pgx.ErrNoRows if not
// found. Mostly used by tests / admin tooling.
func (r *SuppressionRepo) Get(ctx context.Context, email string) (string, SuppressionSource, error) {
var reason *string
var source string
err := r.pool.QueryRow(ctx,
`SELECT reason, source FROM unsubscribes WHERE email = $1`,
normaliseEmail(email),
).Scan(&reason, &source)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return "", "", err
}
return "", "", err
}
r2 := ""
if reason != nil {
r2 = *reason
}
return r2, SuppressionSource(source), nil
}
func normaliseEmail(s string) string {
return strings.ToLower(strings.TrimSpace(s))
}
+116
View File
@@ -0,0 +1,116 @@
package notification
import (
"bytes"
"embed"
"fmt"
htmltemplate "html/template"
"io/fs"
"path"
"strings"
texttemplate "text/template"
)
//go:embed templates
var templatesFS embed.FS
// TemplateName names one of the email templates. There must be a
// matching `<name>.html` and `<name>.txt` under templates/.
type TemplateName string
const (
TmplVerification TemplateName = "verification"
TmplPasswordReset TemplateName = "reset"
TmplInvitation TemplateName = "invitation"
TmplConfirmation TemplateName = "confirmation"
TmplReminder TemplateName = "reminder"
)
// Templates renders branded transactional emails for both HTML and
// plaintext bodies. Loaded once at construction; thread-safe afterwards.
//
// Each page-level HTML template gets its own *html/template.Template
// holding a copy of the `base.html` partial. This avoids html/template's
// per-template contextual-escape pass interfering between pages that all
// define a sibling named "body".
type Templates struct {
html map[string]*htmltemplate.Template // page-name (no ext) -> root
text *texttemplate.Template
}
func NewTemplates() (*Templates, error) {
root, err := fs.Sub(templatesFS, "templates")
if err != nil {
return nil, fmt.Errorf("templates fs: %w", err)
}
baseHTML, err := fs.ReadFile(root, "base.html")
if err != nil {
return nil, fmt.Errorf("read base.html: %w", err)
}
out := &Templates{
html: make(map[string]*htmltemplate.Template),
text: texttemplate.New("__root__"),
}
walk := func(p string, d fs.DirEntry, _ error) error {
if d == nil || d.IsDir() {
return nil
}
base := path.Base(p)
if base == "base.html" {
return nil // partial — folded into each page template below
}
b, err := fs.ReadFile(root, p)
if err != nil {
return err
}
switch {
case strings.HasSuffix(p, ".html"):
name := strings.TrimSuffix(base, ".html")
t := htmltemplate.New(name)
if _, err := t.Parse(string(baseHTML)); err != nil {
return fmt.Errorf("parse _base for %s: %w", p, err)
}
if _, err := t.Parse(string(b)); err != nil {
return fmt.Errorf("parse %s: %w", p, err)
}
out.html[name] = t
case strings.HasSuffix(p, ".txt"):
if _, err := out.text.New(base).Parse(string(b)); err != nil {
return fmt.Errorf("parse %s: %w", p, err)
}
}
return nil
}
if err := fs.WalkDir(root, ".", walk); err != nil {
return nil, err
}
return out, nil
}
// Render returns (htmlBody, textBody) for the named template using data.
func (t *Templates) Render(name TemplateName, data map[string]any) (htmlBody, textBody string, err error) {
if data == nil {
data = map[string]any{}
}
if _, ok := data["Subject"]; !ok {
data["Subject"] = "GuestGuard"
}
htmlTpl, ok := t.html[string(name)]
if !ok {
return "", "", fmt.Errorf("unknown html template %q", name)
}
var hBuf, tBuf bytes.Buffer
// Each page-root template's entry point is "base" (defined by base.html).
if err := htmlTpl.ExecuteTemplate(&hBuf, "base", data); err != nil {
return "", "", fmt.Errorf("render html %s: %w", name, err)
}
if err := t.text.ExecuteTemplate(&tBuf, string(name)+".txt", data); err != nil {
return "", "", fmt.Errorf("render text %s: %w", name, err)
}
return hBuf.String(), tBuf.String(), nil
}
+36
View File
@@ -0,0 +1,36 @@
{{define "base"}}<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{.Subject}}</title>
</head>
<body style="margin:0;padding:0;background:#f7f7f8;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#0f172a;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f7f7f8;padding:32px 16px;">
<tr><td align="center">
<table role="presentation" width="560" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 1px 2px rgba(15,23,42,0.05);">
<tr>
<td style="background:#0a0a0a;padding:20px 28px;">
<span style="display:inline-block;width:10px;height:10px;background:#22c55e;border-radius:50%;vertical-align:middle;"></span>
<span style="color:#fafafa;font-weight:600;font-size:16px;margin-left:8px;vertical-align:middle;">GuestGuard</span>
</td>
</tr>
<tr>
<td style="padding:32px 28px 24px;font-size:15px;line-height:1.55;color:#0f172a;">
{{block "body" .}}{{end}}
</td>
</tr>
<tr>
<td style="padding:0 28px 28px;">
<hr style="border:none;border-top:1px solid #e5e7eb;margin:0 0 16px;">
<p style="font-size:12px;color:#64748b;margin:0;">
You're receiving this because of activity on your GuestGuard account.
{{if .UnsubscribeLink}}<br>If you'd rather not get emails like this, <a href="{{.UnsubscribeLink}}" style="color:#16a34a;">unsubscribe here</a>.{{end}}
</p>
</td>
</tr>
</table>
</td></tr>
</table>
</body>
</html>{{end}}
@@ -0,0 +1,11 @@
{{define "body"}}
<h1 style="font-size:20px;margin:0 0 12px;">RSVP received</h1>
<p style="margin:0 0 16px;">Hi {{.GuestName}},</p>
<p style="margin:0 0 16px;">Thanks for letting {{.HostName}} know — your RSVP for <strong>{{.EventName}}</strong> is confirmed as <strong>{{.Response}}</strong>{{if gt .PlusOnes 0}} with {{.PlusOnes}} plus-one{{if ne .PlusOnes 1}}s{{end}}{{end}}.</p>
{{if or .Venue .EventDate}}
<p style="margin:0 0 16px;color:#64748b;font-size:14px;">
{{if .Venue}}{{.Venue}}{{end}}{{if and .Venue .EventDate}} · {{end}}{{if .EventDate}}{{.EventDate}}{{end}}
</p>
{{end}}
<p style="margin:24px 0 0;font-size:13px;color:#64748b;">You'll get a reminder closer to the date. If your plans change, use the same invitation link to update your reply.</p>
{{end}}
@@ -0,0 +1,10 @@
Hi {{.GuestName}},
Your RSVP for "{{.EventName}}" is confirmed as {{.Response}}{{if gt .PlusOnes 0}} (+{{.PlusOnes}}){{end}}.
{{if .Venue}}{{.Venue}}{{end}}{{if and .Venue .EventDate}} · {{end}}{{if .EventDate}}{{.EventDate}}{{end}}
You'll get a reminder closer to the date. If your plans change, use the
same invitation link to update your reply.
— GuestGuard
@@ -0,0 +1,15 @@
{{define "body"}}
<p style="font-size:13px;letter-spacing:0.2em;text-transform:uppercase;color:#16a34a;margin:0 0 16px;">✦ You're invited</p>
<h1 style="font-size:24px;margin:0 0 4px;color:#0a0a0a;">{{.EventName}}</h1>
{{if or .Venue .EventDate}}
<p style="margin:0 0 24px;color:#64748b;font-size:14px;">
{{if .Venue}}{{.Venue}}{{end}}{{if and .Venue .EventDate}} · {{end}}{{if .EventDate}}{{.EventDate}}{{end}}
</p>
{{end}}
<p style="margin:0 0 20px;">Hi {{.GuestName}}, {{.HostName}} would love to know if you can make it. Use the personal link below to RSVP.</p>
<p style="margin:0 0 24px;text-align:center;">
<a href="{{.Link}}" style="background:#22c55e;color:#0a0a0a;padding:12px 22px;border-radius:8px;font-weight:600;text-decoration:none;display:inline-block;">RSVP now</a>
</p>
<p style="margin:0 0 8px;color:#64748b;font-size:13px;">Or paste this URL into your browser:</p>
<p style="margin:0;word-break:break-all;font-size:12px;color:#0f172a;">{{.Link}}</p>
{{end}}
@@ -0,0 +1,11 @@
You're invited — {{.EventName}}
Hi {{.GuestName}},
{{.HostName}} would love to know if you can make it{{if .EventDate}} on {{.EventDate}}{{end}}{{if .Venue}}, at {{.Venue}}{{end}}.
RSVP here:
{{.Link}}
— GuestGuard
@@ -0,0 +1,7 @@
{{define "body"}}
<h1 style="font-size:20px;margin:0 0 12px;">Reminder: {{.EventName}}</h1>
<p style="margin:0 0 16px;">Hi {{.GuestName}},</p>
<p style="margin:0 0 16px;">Just a quick reminder that <strong>{{.EventName}}</strong> is coming up{{if .EventDate}} on {{.EventDate}}{{end}}{{if .Venue}}, at {{.Venue}}{{end}}.</p>
{{if .Response}}<p style="margin:0 0 16px;">You're down as <strong>{{.Response}}</strong>{{if gt .PlusOnes 0}} with {{.PlusOnes}} plus-one{{if ne .PlusOnes 1}}s{{end}}{{end}}.</p>{{end}}
<p style="margin:24px 0 0;font-size:13px;color:#64748b;">Need to change your plans? Use your invitation link.</p>
{{end}}
@@ -0,0 +1,7 @@
Hi {{.GuestName}},
Reminder: {{.EventName}}{{if .EventDate}} — {{.EventDate}}{{end}}{{if .Venue}} at {{.Venue}}{{end}}.
{{if .Response}}You're down as {{.Response}}{{if gt .PlusOnes 0}} (+{{.PlusOnes}}){{end}}.{{end}}
— GuestGuard
@@ -0,0 +1,11 @@
{{define "body"}}
<h1 style="font-size:20px;margin:0 0 12px;">Reset your password</h1>
<p style="margin:0 0 16px;">Hi {{.Name}},</p>
<p style="margin:0 0 20px;">We received a request to reset the password on your GuestGuard account. Tap the button to choose a new one — the link is valid for {{.ExpiryHumane}}.</p>
<p style="margin:0 0 24px;text-align:center;">
<a href="{{.Link}}" style="background:#22c55e;color:#0a0a0a;padding:12px 22px;border-radius:8px;font-weight:600;text-decoration:none;display:inline-block;">Choose a new password</a>
</p>
<p style="margin:0 0 8px;color:#64748b;font-size:13px;">Or paste this URL into your browser:</p>
<p style="margin:0;word-break:break-all;font-size:12px;color:#0f172a;">{{.Link}}</p>
<p style="margin:24px 0 0;font-size:13px;color:#64748b;">If you didn't ask to reset your password, you can ignore this email — your current password is unchanged.</p>
{{end}}
+11
View File
@@ -0,0 +1,11 @@
Hi {{.Name}},
We received a request to reset your GuestGuard password. The link is valid
for {{.ExpiryHumane}}:
{{.Link}}
If you didn't ask to reset your password, you can ignore this email — your
current password is unchanged.
— GuestGuard
@@ -0,0 +1,11 @@
{{define "body"}}
<h1 style="font-size:20px;margin:0 0 12px;">Verify your email</h1>
<p style="margin:0 0 16px;">Hi {{.Name}}, welcome to GuestGuard.</p>
<p style="margin:0 0 20px;">To finish setting up your account, please confirm this is your email address.</p>
<p style="margin:0 0 24px;text-align:center;">
<a href="{{.Link}}" style="background:#22c55e;color:#0a0a0a;padding:12px 22px;border-radius:8px;font-weight:600;text-decoration:none;display:inline-block;">Verify email</a>
</p>
<p style="margin:0 0 8px;color:#64748b;font-size:13px;">Or paste this URL into your browser:</p>
<p style="margin:0;word-break:break-all;font-size:12px;color:#0f172a;">{{.Link}}</p>
<p style="margin:24px 0 0;font-size:13px;color:#64748b;">If you didn't sign up for GuestGuard, you can ignore this email.</p>
{{end}}
@@ -0,0 +1,9 @@
Hi {{.Name}}, welcome to GuestGuard.
Please verify your email by visiting:
{{.Link}}
If you didn't sign up for GuestGuard, you can ignore this email.
— GuestGuard
+101
View File
@@ -0,0 +1,101 @@
package notification
import (
"strings"
"testing"
)
func TestRenderAllTemplates(t *testing.T) {
tpls, err := NewTemplates()
if err != nil {
t.Fatalf("NewTemplates: %v", err)
}
cases := []struct {
name TemplateName
data map[string]any
wantHTML []string // substrings expected in HTML body
wantText []string // substrings expected in text body
}{
{
name: TmplVerification,
data: map[string]any{
"Name": "Kay",
"Link": "https://example.test/verify-email?token=x",
"Subject": "Verify your GuestGuard email",
"UnsubscribeLink": "https://example.test/unsubscribe/abc",
},
wantHTML: []string{"Verify your email", "Kay", "Verify email", "https://example.test/verify-email?token=x", "unsubscribe here"},
wantText: []string{"Hi Kay", "https://example.test/verify-email?token=x"},
},
{
name: TmplPasswordReset,
data: map[string]any{
"Name": "Kay",
"Link": "https://example.test/reset-password/abc",
"ExpiryHumane": "1 hour",
},
wantHTML: []string{"Reset your password", "1 hour", "https://example.test/reset-password/abc"},
wantText: []string{"reset your GuestGuard password", "1 hour"},
},
{
name: TmplInvitation,
data: map[string]any{
"GuestName": "Mira",
"HostName": "Kay",
"EventName": "Beach Day",
"Venue": "Ocean Park",
"EventDate": "Sat 14 Jun, 4pm",
"Link": "https://example.test/rsvp/tok_x",
},
wantHTML: []string{"You're invited", "Beach Day", "Mira", "Ocean Park", "RSVP now", "https://example.test/rsvp/tok_x"},
wantText: []string{"Beach Day", "Mira", "RSVP here", "https://example.test/rsvp/tok_x"},
},
{
name: TmplConfirmation,
data: map[string]any{
"GuestName": "Mira",
"HostName": "Kay",
"EventName": "Beach Day",
"Venue": "Ocean Park",
"EventDate": "Sat 14 Jun, 4pm",
"Response": "attending",
"PlusOnes": 2,
},
wantHTML: []string{"RSVP received", "Beach Day", "attending", "2 plus-ones"},
wantText: []string{"Beach Day", "attending", "+2"},
},
{
name: TmplReminder,
data: map[string]any{
"GuestName": "Mira",
"EventName": "Beach Day",
"EventDate": "Sat 14 Jun, 4pm",
"Venue": "Ocean Park",
"Response": "attending",
"PlusOnes": 1,
},
wantHTML: []string{"Reminder", "Beach Day", "Ocean Park", "1 plus-one"},
wantText: []string{"Reminder", "Beach Day", "(+1)"},
},
}
for _, tc := range cases {
t.Run(string(tc.name), func(t *testing.T) {
html, text, err := tpls.Render(tc.name, tc.data)
if err != nil {
t.Fatalf("render: %v", err)
}
for _, s := range tc.wantHTML {
if !strings.Contains(html, s) {
t.Errorf("html missing %q\n---\n%s", s, html)
}
}
for _, s := range tc.wantText {
if !strings.Contains(text, s) {
t.Errorf("text missing %q\n---\n%s", s, text)
}
}
})
}
}
+60
View File
@@ -0,0 +1,60 @@
package notification
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"errors"
"strings"
)
// UnsubscribeSigner mints + verifies tamper-proof unsubscribe tokens. Each
// token encodes the email address and an HMAC-SHA256 of the address under
// a server-side secret. The token has no TTL — unsubscribe links should
// keep working forever.
//
// Token shape: base64url(email) + "." + base64url(hmac)
type UnsubscribeSigner struct {
secret []byte
}
func NewUnsubscribeSigner(secret string) *UnsubscribeSigner {
return &UnsubscribeSigner{secret: []byte(secret)}
}
// Sign returns a URL-safe token for the email address. Empty input → empty
// token (caller should validate input first).
func (s *UnsubscribeSigner) Sign(email string) string {
if email == "" {
return ""
}
email = normaliseEmail(email)
mac := hmac.New(sha256.New, s.secret)
mac.Write([]byte(email))
return base64.RawURLEncoding.EncodeToString([]byte(email)) +
"." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
// Verify decodes the token and confirms the HMAC matches, returning the
// owning email address.
func (s *UnsubscribeSigner) Verify(token string) (string, error) {
dot := strings.IndexByte(token, '.')
if dot < 0 {
return "", errors.New("malformed unsubscribe token")
}
emailB, err := base64.RawURLEncoding.DecodeString(token[:dot])
if err != nil {
return "", err
}
sigB, err := base64.RawURLEncoding.DecodeString(token[dot+1:])
if err != nil {
return "", err
}
mac := hmac.New(sha256.New, s.secret)
mac.Write(emailB)
want := mac.Sum(nil)
if !hmac.Equal(sigB, want) {
return "", errors.New("unsubscribe signature mismatch")
}
return string(emailB), nil
}