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:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user