← back to Dw Signup Fulfillment
TK-11120: verify links fail-closed on missing PUBLIC_URL + send as info@ (retail-verify localhost-link outage)
0016d65541cf0d10f5e94035de511d75af37e869 · 2026-09-02 13:48:35 -0700 · Steve Abrams
Root cause of the 2026-09-02 retail-verify blast: a backfill job ran with
PUBLIC_URL unset, so verify.baseUrl() fell back to http://127.0.0.1:9856 and
~75 customers got a dead localhost 'Confirm my email' link. Also sent as
steve-office (Gmail rewrote From to steve@ since info@ isn't a send-as alias).
- verify.baseUrl(): FAIL-CLOSED in LIVE — never fall back to loopback; empty
PUBLIC_URL returns '' and startVerification refuses to send (reason=no_public_url).
- config.GEORGE_ACCOUNT default steve-office -> info, so mail is genuinely from info@.
No send, no deploy in this commit — code hardening only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017j4qS38tWq21qYdjxMcFTy
Files touched
M lib/config.jsM lib/verify.js
Diff
commit 0016d65541cf0d10f5e94035de511d75af37e869
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Sep 2 13:48:35 2026 -0700
TK-11120: verify links fail-closed on missing PUBLIC_URL + send as info@ (retail-verify localhost-link outage)
Root cause of the 2026-09-02 retail-verify blast: a backfill job ran with
PUBLIC_URL unset, so verify.baseUrl() fell back to http://127.0.0.1:9856 and
~75 customers got a dead localhost 'Confirm my email' link. Also sent as
steve-office (Gmail rewrote From to steve@ since info@ isn't a send-as alias).
- verify.baseUrl(): FAIL-CLOSED in LIVE — never fall back to loopback; empty
PUBLIC_URL returns '' and startVerification refuses to send (reason=no_public_url).
- config.GEORGE_ACCOUNT default steve-office -> info, so mail is genuinely from info@.
No send, no deploy in this commit — code hardening only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017j4qS38tWq21qYdjxMcFTy
---
lib/config.js | 12 +++++++-----
lib/verify.js | 19 +++++++++++++++++--
2 files changed, 24 insertions(+), 7 deletions(-)
diff --git a/lib/config.js b/lib/config.js
index 8a35842..e52e9b7 100644
--- a/lib/config.js
+++ b/lib/config.js
@@ -93,11 +93,13 @@ const config = {
// George email sender.
GEORGE_URL: process.env.GEORGE_URL || 'http://127.0.0.1:9850',
- // Account that authenticates to George (steve-office Gmail). The FROM address is the
- // monitored office inbox so customer/designer replies land there, not Steve's personal
- // box. GO-LIVE: confirm info@designerwallcoverings.com is a verified send-as alias in
- // the steve-office Gmail, else Gmail rewrites the From to the authenticated address.
- GEORGE_ACCOUNT: process.env.GEORGE_ACCOUNT || 'steve-office',
+ // Account that AUTHENTICATES to George. MUST be 'info' (info@designerwallcoverings.com)
+ // so the visible From is genuinely info@ — Steve's directive 2026-09-02 (TK-11120).
+ // The earlier default 'steve-office' made George send AS steve-office and, because
+ // info@ is NOT a verified send-as alias on that mailbox, Gmail rewrote the visible From
+ // back to steve@designerwallcoverings.com — so the retail-verify blast appeared to come
+ // from Steve personally. Authenticating as the info mailbox fixes the sender identity.
+ GEORGE_ACCOUNT: process.env.GEORGE_ACCOUNT || 'info',
GEORGE_FROM: process.env.GEORGE_FROM || 'info@designerwallcoverings.com',
GEORGE_EXTERNAL_SEND_TOKEN: firstEnv('GEORGE_EXTERNAL_SEND_TOKEN', GEORGE_ENVS),
// Basic-auth credential for George. Resolve from the SAME source the working
diff --git a/lib/verify.js b/lib/verify.js
index 356cdcd..7a23df0 100644
--- a/lib/verify.js
+++ b/lib/verify.js
@@ -62,7 +62,17 @@ function readToken(token) {
return { ok: true, email: payload.e, customerId: payload.c || null };
}
-function baseUrl() { return config.PUBLIC_URL || `http://127.0.0.1:${config.PORT}`; }
+// Public base for the verify link. FAIL-CLOSED in LIVE (TK-11120): a missing PUBLIC_URL
+// must NEVER fall back to the loopback host — a `http://127.0.0.1:PORT/verify` link
+// mailed to a customer is a dead link (this is exactly what shipped to ~75 retail
+// signups on 2026-09-02 when a backfill job ran with PUBLIC_URL unset). Only DRY_RUN/dev
+// may use the loopback base; in LIVE an empty PUBLIC_URL returns '' so callers REFUSE to
+// send rather than send a broken link — same philosophy as the no_secret guard above.
+function baseUrl() {
+ if (config.PUBLIC_URL) return config.PUBLIC_URL;
+ if (config.DRY_RUN) return `http://127.0.0.1:${config.PORT}`;
+ return '';
+}
// Step 1 — email the branded "confirm your email" letter with the verify link.
async function startVerification({ email: to, customerId, firstName }) {
@@ -73,7 +83,12 @@ async function startVerification({ email: to, customerId, firstName }) {
console.warn('[verify] VERIFY_SECRET unset (live) — cannot mint verify token; skipping send.');
return { ok: false, reason: 'no_secret' };
}
- const url = `${baseUrl()}/verify?token=${encodeURIComponent(token)}`;
+ const base = baseUrl();
+ if (!base) {
+ console.warn('[verify] PUBLIC_URL unset in LIVE — refusing to send (would ship a dead localhost verify link). Set PUBLIC_URL and retry.');
+ return { ok: false, reason: 'no_public_url' };
+ }
+ const url = `${base}/verify?token=${encodeURIComponent(token)}`;
const first = firstName || (addr.includes('@') ? addr.split('@')[0] : '');
const tpl = email.verifyEmail({ firstName: first, url, count: config.FREE_SAMPLE_COUNT });
const mail = await email.sendEmail({ to: addr, subject: tpl.subject, html: tpl.html, source: 'retail-verify' });
← d8959b9 TK-11114: backfill sent (68/68) — outage fully remediated
·
back to Dw Signup Fulfillment
·
TK-11120: corrected resend tool + apology template (retail-v a6edf22 →