From 976b3fbfc0c6cb017618b6b63b9ebf9d792a9082 Mon Sep 17 00:00:00 2001 From: Keith Fawcett Date: Wed, 8 Jul 2026 07:46:17 -0700 Subject: [PATCH 1/2] fix(mailer): bound SMTP timeouts so transport failures beat the gateway timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified in prod (xispark mail-test): a host that silently drops SYNs (xispark.com:465) hung nodemailer for its DEFAULT 2-minute connectionTimeout — longer than Cloudflare's ~100s proxy limit — so the admin saw an opaque 504 instead of our 502 with the transport error. Real sends (partner invites, signup) could pin requests just as long. connectionTimeout/greetingTimeout 10s, socketTimeout 30s: a healthy handshake completes in single-digit seconds. Also rewrites the test-result hint (the old one implied unsaved changes even when settings were saved) to point at host/port/credentials with the timeout≈wrong-hostname heuristic. Co-Authored-By: Claude Fable 5 --- apps/api/src/mailer.ts | 9 +++++++++ apps/portal/src/pages/admin/Settings.tsx | 3 ++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/api/src/mailer.ts b/apps/api/src/mailer.ts index 9389938d..da482bb5 100644 --- a/apps/api/src/mailer.ts +++ b/apps/api/src/mailer.ts @@ -118,6 +118,15 @@ class RoutingMailer implements Mailer { cfg.smtp.user && cfg.smtp.password ? { user: cfg.smtp.user, pass: cfg.smtp.password } : undefined, + // Nodemailer's default connection timeout is 2 MINUTES — longer + // than the platform gateway's ~60s, so a misconfigured host (wrong + // hostname, filtered port) surfaced as an opaque 504 instead of + // our clean ETIMEDOUT/ECONNREFUSED error, and a real send (partner + // invite, signup) could pin its request just as long. Fail fast: + // a healthy SMTP handshake completes in single-digit seconds. + connectionTimeout: 10_000, + greetingTimeout: 10_000, + socketTimeout: 30_000, }); await transporter.sendMail({ from, diff --git a/apps/portal/src/pages/admin/Settings.tsx b/apps/portal/src/pages/admin/Settings.tsx index 96fd3722..a2cb0812 100644 --- a/apps/portal/src/pages/admin/Settings.tsx +++ b/apps/portal/src/pages/admin/Settings.tsx @@ -933,7 +933,8 @@ function MailSection() { {testResult && (
{testResult.text} - {!testResult.ok && ' — unsaved changes aren’t tested; save first, then retry.'} + {!testResult.ok && + ' — check the host/port/credentials above (a connection timeout usually means a wrong hostname or filtered port). The test uses the last SAVED settings, so save any edits before retrying.'}
)} From 5fd861b66e2743374e48d5c9be22a808712c4074 Mon Sep 17 00:00:00 2001 From: Keith Fawcett Date: Wed, 8 Jul 2026 07:49:49 -0700 Subject: [PATCH 2/2] fix(settings): plain-language placeholder for stored secrets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bullet-character placeholders read as 'your password is still in this field' (user report from xispark) — bullets are the universal signal for a present value. The field's value is actually cleared on save and the secret is write-only server-side; say so in words instead. Co-Authored-By: Claude Fable 5 --- apps/portal/src/pages/admin/Settings.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/portal/src/pages/admin/Settings.tsx b/apps/portal/src/pages/admin/Settings.tsx index a2cb0812..c42c77ac 100644 --- a/apps/portal/src/pages/admin/Settings.tsx +++ b/apps/portal/src/pages/admin/Settings.tsx @@ -882,7 +882,7 @@ function MailSection() { type="password" value={smtpPass} onChange={(e) => setSmtpPass(e.target.value)} - placeholder={hasStoredSmtpPassword ? '•••••••• (leave blank to keep)' : ''} + placeholder={hasStoredSmtpPassword ? 'Saved — leave blank to keep, type to replace' : ''} />