diff --git a/.env.example b/.env.example index f21b3aa..90ed59f 100644 --- a/.env.example +++ b/.env.example @@ -36,6 +36,8 @@ AUTH_SECRET=dev-only-secret-change-me-0123456789abcdef # Required in production. Without it auth emails are logged, not sent. # With SECRETS_PROVIDER=doppler it comes from Doppler instead (this is ignored). RESEND_API_KEY= +# Required in production: a sender on your Resend-verified domain (an +# example.com address is refused there). FROM_EMAIL=noreply@example.com # Google / Apple sign-in (optional, docs/sign-in.md). A diff --git a/apps/admin/internal/config/config.go b/apps/admin/internal/config/config.go index e4c6009..fd99650 100644 --- a/apps/admin/internal/config/config.go +++ b/apps/admin/internal/config/config.go @@ -84,6 +84,16 @@ func Load(getenv func(string) string) (Config, error) { default: errs = append(errs, fmt.Errorf("APP_ENV must be development, test or production, got %q", cfg.Env)) } + // Unset, FROM_EMAIL falls back to the example sender, which no mail + // provider will send from: every invite would fail, only in the log. + if cfg.Env == Production { + switch { + case get("FROM_EMAIL", "") == "": + errs = append(errs, errors.New("FROM_EMAIL is required in production (a sender on your verified email domain)")) + case !realSender(cfg.FromEmail): + errs = append(errs, fmt.Errorf("FROM_EMAIL must be a sender on your verified email domain in production, got %q", cfg.FromEmail)) + } + } // ADMIN_PORT first: locally .env sets PORT for the web app. Railway sets // PORT per service. @@ -119,6 +129,25 @@ func Load(getenv func(string) string) (Config, error) { return cfg, nil } +// realSender reports whether addr ("a@b.c" or "Name ") is an email +// address off the reserved example and test domains (RFC 2606, RFC 6761). +// deploy/platform's reservedSenderDomains refuses the same domains before a +// deploy: keep the two lists the same. +func realSender(addr string) bool { + addr = strings.TrimSuffix(strings.TrimSpace(addr), ">") + at := strings.LastIndex(addr, "@") + if at <= 0 || at == len(addr)-1 { + return false + } + domain := strings.TrimSuffix(strings.ToLower(addr[at+1:]), ".") + for _, reserved := range []string{"example.com", "example.net", "example.org", "example", "test", "invalid", "localhost"} { + if domain == reserved || strings.HasSuffix(domain, "."+reserved) { + return false + } + } + return true +} + func parseOrigin(key, raw string) (*url.URL, error) { if raw == "" { return nil, fmt.Errorf("%s is required (e.g. http://:3001)", key) diff --git a/apps/web/internal/config/config.go b/apps/web/internal/config/config.go index e0c3dc4..61b39d3 100644 --- a/apps/web/internal/config/config.go +++ b/apps/web/internal/config/config.go @@ -68,6 +68,16 @@ func Load(getenv func(string) string) (Config, error) { default: errs = append(errs, fmt.Errorf("APP_ENV must be development, test or production, got %q", cfg.Env)) } + // Unset, FROM_EMAIL falls back to the example sender, which no mail + // provider will send from: every auth email would fail, only in the log. + if cfg.Env == Production { + switch { + case get("FROM_EMAIL", "") == "": + errs = append(errs, errors.New("FROM_EMAIL is required in production (a sender on your verified email domain)")) + case !realSender(cfg.FromEmail): + errs = append(errs, fmt.Errorf("FROM_EMAIL must be a sender on your verified email domain in production, got %q", cfg.FromEmail)) + } + } port := get("PORT", "3000") cfg.Addr = net.JoinHostPort("0.0.0.0", port) @@ -103,6 +113,25 @@ func Load(getenv func(string) string) (Config, error) { return cfg, nil } +// realSender reports whether addr ("a@b.c" or "Name ") is an email +// address off the reserved example and test domains (RFC 2606, RFC 6761). +// deploy/platform's reservedSenderDomains refuses the same domains before a +// deploy: keep the two lists the same. +func realSender(addr string) bool { + addr = strings.TrimSuffix(strings.TrimSpace(addr), ">") + at := strings.LastIndex(addr, "@") + if at <= 0 || at == len(addr)-1 { + return false + } + domain := strings.TrimSuffix(strings.ToLower(addr[at+1:]), ".") + for _, reserved := range []string{"example.com", "example.net", "example.org", "example", "test", "invalid", "localhost"} { + if domain == reserved || strings.HasSuffix(domain, "."+reserved) { + return false + } + } + return true +} + func parseOrigin(key, raw string) (*url.URL, error) { if raw == "" { return nil, fmt.Errorf("%s is required (e.g. http://:3000)", key) diff --git a/docs/deploy.md b/docs/deploy.md index e96a414..487ddca 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -79,4 +79,4 @@ Off by default. `previews: true` (needs `staging`) turns on Railway PR environme `/healthz` is only the deploy gate: Railway checks it until a new deployment goes live, never afterwards. It answers 503 when Doppler secrets go stale (`SECRETS_MAX_STALENESS`), so never wire it up as a liveness or restart probe, or a Doppler outage turns into a restart loop. Services restart only when the process exits (`ON_FAILURE`, 5 retries). -Every service runs with `APP_ENV=production` (email verification on, `RESEND_API_KEY` required), `CLIENT_IP_HEADER=X-Real-IP` and `PORT=3000`. The server refuses to boot with a missing or invalid value and lists every problem. +Every service runs with `APP_ENV=production` (email verification on, `RESEND_API_KEY` required, and a real `FROM_EMAIL`: see "A real sender is required" above), `CLIENT_IP_HEADER=X-Real-IP` and `PORT=3000`. The server refuses to boot with a missing or invalid value and lists every problem. diff --git a/scripts/release-smoke.sh b/scripts/release-smoke.sh index 142dfb0..6617da3 100755 --- a/scripts/release-smoke.sh +++ b/scripts/release-smoke.sh @@ -9,8 +9,8 @@ # exists), with the binaries' own `migrate`, as pre-deploy does. # 3. boot both with APP_ENV=production and only the config production # requires (no Doppler, no optional integrations; a dummy -# RESEND_API_KEY, which production requires) and wait for -# /healthz 200. +# RESEND_API_KEY and a FROM_EMAIL off the example domains, which +# production requires; nothing is sent) and wait for /healthz 200. # # Env: SMOKE_DATABASE_URL, SMOKE_ADMIN_DATABASE_URL empty databases to use # (`make release-smoke` and CI create them); SMOKE_BOOT_SECONDS, @@ -25,6 +25,9 @@ readonly default_admin_port=3391 readonly poll_seconds=0.2 # between /healthz attempts readonly http_ok=200 readonly log_tail_lines=40 # of a failed app's log +# Production refuses an unset or example.com sender; nothing is ever sent +# (the Resend key is a dummy), so any unreserved domain will do. +readonly smoke_from_email=release-smoke@go-fast-stack.dev app_db=${SMOKE_DATABASE_URL:?SMOKE_DATABASE_URL: an empty database for the web app} admin_db=${SMOKE_ADMIN_DATABASE_URL:-} @@ -75,10 +78,11 @@ done admin=false [ -x "$work/admin" ] && [ -n "$admin_db" ] && admin=true web_env=(APP_ENV=production PORT="$web_port" PUBLIC_URL="http://127.0.0.1:$web_port" - DATABASE_URL="$app_db" AUTH_SECRET=release-smoke-web-auth-secret-0123456789 RESEND_API_KEY=re_release_smoke_unused) + DATABASE_URL="$app_db" AUTH_SECRET=release-smoke-web-auth-secret-0123456789 RESEND_API_KEY=re_release_smoke_unused + FROM_EMAIL="$smoke_from_email") admin_env=(APP_ENV=production ADMIN_PORT="$admin_port" ADMIN_PUBLIC_URL="http://127.0.0.1:$admin_port" DATABASE_URL="$app_db" ADMIN_DATABASE_URL="$admin_db" ADMIN_AUTH_SECRET=release-smoke-admin-auth-secret-0123456789 - RESEND_API_KEY=re_release_smoke_unused) + RESEND_API_KEY=re_release_smoke_unused FROM_EMAIL="$smoke_from_email") run() { env -i PATH="$PATH" HOME="$HOME" "$@"; } step migrate run "${web_env[@]}" "$work/server" migrate >"$work/web-migrate.log" 2>&1 ||