From cd4238569aab0f5c6387435d7b3b176524d7c5c4 Mon Sep 17 00:00:00 2001 From: JJ Eaton <58495321+jayleaton@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:51:07 +0700 Subject: [PATCH 1/3] fix(config): production refuses an unset or example FROM_EMAIL Both apps fell back to noreply@example.com when FROM_EMAIL was unset, and accepted it in production, where no mail provider sends from it: every verification, reset and invite email would fail, visible only in the log. In production the web and admin configs now refuse an unset FROM_EMAIL and any sender on a reserved example or test domain (RFC 2606/6761), in either "a@b" or "Name " form, so the deploy fails its boot with the reason. Co-Authored-By: Claude Opus 5.5 --- .env.example | 2 ++ apps/admin/internal/config/config.go | 27 +++++++++++++++++++++++++++ apps/web/internal/config/config.go | 27 +++++++++++++++++++++++++++ docs/deploy.md | 2 +- 4 files changed, 57 insertions(+), 1 deletion(-) 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..d3b0785 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,23 @@ 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). +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..5add67d 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,23 @@ 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). +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..8d139ad 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 `FROM_EMAIL`, from `email.from` in `deploy/platform.yaml`, must be a sender on your verified domain: an unset or `example.com` sender is refused), `CLIENT_IP_HEADER=X-Real-IP` and `PORT=3000`. The server refuses to boot with a missing or invalid value and lists every problem. From 363040faae3092ad9104823f67a7acebaf3f80ca Mon Sep 17 00:00:00 2001 From: JJ Eaton <58495321+jayleaton@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:14:53 +0700 Subject: [PATCH 2/3] ci(smoke): the release smoke boots with a FROM_EMAIL off the example domains Production now refuses an unset or example sender, so the smoke (which boots with only what production requires) sets one. Nothing is sent: the Resend key is a dummy. Co-Authored-By: Claude Opus 5.5 --- scripts/release-smoke.sh | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) 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 || From 743c773bbcabcf0215b0bc5efa28189394d217ea Mon Sep 17 00:00:00 2001 From: JJ Eaton <58495321+jayleaton@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:23:22 +0700 Subject: [PATCH 3/3] docs(config): point FROM_EMAIL's checks at each other The apps' realSender and deploy/platform's reservedSenderDomains (#132) refuse the same domains; both now say so. docs/deploy.md links to #132's paragraph instead of repeating it. Co-Authored-By: Claude Opus 5.5 --- apps/admin/internal/config/config.go | 2 ++ apps/web/internal/config/config.go | 2 ++ docs/deploy.md | 2 +- 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/admin/internal/config/config.go b/apps/admin/internal/config/config.go index d3b0785..fd99650 100644 --- a/apps/admin/internal/config/config.go +++ b/apps/admin/internal/config/config.go @@ -131,6 +131,8 @@ func Load(getenv func(string) string) (Config, error) { // 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, "@") diff --git a/apps/web/internal/config/config.go b/apps/web/internal/config/config.go index 5add67d..61b39d3 100644 --- a/apps/web/internal/config/config.go +++ b/apps/web/internal/config/config.go @@ -115,6 +115,8 @@ func Load(getenv func(string) string) (Config, error) { // 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, "@") diff --git a/docs/deploy.md b/docs/deploy.md index 8d139ad..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, and `FROM_EMAIL`, from `email.from` in `deploy/platform.yaml`, must be a sender on your verified domain: an unset or `example.com` sender is refused), `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.