From c3972f4114a60296ad57a0a71bed565afe233b78 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 23 May 2026 04:26:39 +0000 Subject: [PATCH 1/5] feat: block SMTP connections to private/reserved network addresses Add SSRF protection for custom email server configs. Before connecting to a user-configured SMTP host, resolve its IP via public DNS and reject any address in private, loopback, link-local, CGNAT, multicast, or other reserved ranges. Shared/platform-managed configs are exempt. - Add isPrivateOrReservedIp() to stack-shared (IPv4 + IPv6 + IPv4-mapped) - Add ensureSmtpHostNotPrivateNetwork() in emails-low-level.tsx - Returns a clear PRIVATE_NETWORK error type with user-facing message Co-Authored-By: Konstantin Wohlwend --- apps/backend/src/lib/emails-low-level.tsx | 58 ++++++++ packages/stack-shared/src/utils/ips.tsx | 167 ++++++++++++++++++++++ 2 files changed, 225 insertions(+) diff --git a/apps/backend/src/lib/emails-low-level.tsx b/apps/backend/src/lib/emails-low-level.tsx index 90648ed323..3b37c6978c 100644 --- a/apps/backend/src/lib/emails-low-level.tsx +++ b/apps/backend/src/lib/emails-low-level.tsx @@ -4,11 +4,14 @@ * providers. You probably shouldn't use this and should instead use the functions in emails.tsx. */ +import { isPrivateOrReservedIp } from '@stackframe/stack-shared/dist/utils/ips'; import { StackAssertionError, captureError } from '@stackframe/stack-shared/dist/utils/errors'; import { omit, pick } from '@stackframe/stack-shared/dist/utils/objects'; import { runAsynchronously, wait } from '@stackframe/stack-shared/dist/utils/promises'; import { Result } from '@stackframe/stack-shared/dist/utils/results'; import { traceSpan } from '@stackframe/stack-shared/dist/utils/telemetry'; +import dns from 'node:dns/promises'; +import { isIP } from 'node:net'; import nodemailer from 'nodemailer'; export function isSecureEmailPort(port: number | string) { @@ -36,6 +39,47 @@ export type LowLevelEmailConfig = { type: 'shared' | 'standard', } +/** + * Resolves the given SMTP host to IP addresses and throws if any of them are + * in a private / reserved network range (SSRF protection). + */ +export async function ensureSmtpHostNotPrivateNetwork(host: string): Promise { + // If the host is a raw IP literal, check it directly + if (isIP(host) !== 0) { + if (isPrivateOrReservedIp(host)) { + throw new StackAssertionError( + `SMTP host '${host}' is a private or reserved IP address. Refusing to connect.`, + { host }, + ); + } + return; + } + + // Resolve hostname via public DNS and reject if any resolved IP is private + const resolver = new dns.Resolver(); + resolver.setServers(["1.1.1.1", "8.8.8.8"]); + const [ipv4Addrs, ipv6Addrs] = await Promise.all([ + resolver.resolve4(host).catch(() => [] as string[]), + resolver.resolve6(host).catch(() => [] as string[]), + ]); + const allAddrs = [...ipv4Addrs, ...ipv6Addrs]; + + if (allAddrs.length === 0) { + // DNS returned no records — the host doesn't exist. + // Let nodemailer's own DNS timeout / EDNS handling deal with this. + return; + } + + for (const addr of allAddrs) { + if (isPrivateOrReservedIp(addr)) { + throw new StackAssertionError( + `SMTP host '${host}' resolves to private/reserved address ${addr}. Refusing to connect.`, + { host, resolvedAddress: addr }, + ); + } + } +} + export type LowLevelSendEmailOptions = { tenancyId: string, emailConfig: LowLevelEmailConfig, @@ -75,6 +119,20 @@ async function _lowLevelSendEmailWithoutRetries(options: LowLevelSendEmailOption } return await traceSpan('sending email to ' + JSON.stringify(toArray), async () => { + // SSRF protection: block SMTP connections to private/reserved networks + if (options.emailConfig.type !== 'shared') { + try { + await ensureSmtpHostNotPrivateNetwork(options.emailConfig.host); + } catch (error) { + return Result.error({ + rawError: error, + errorType: 'PRIVATE_NETWORK', + canRetry: false, + message: `The email server host resolves to a private or reserved network address. Please use a publicly reachable SMTP server.`, + } as const); + } + } + try { const transporter = nodemailer.createTransport({ host: options.emailConfig.host, diff --git a/packages/stack-shared/src/utils/ips.tsx b/packages/stack-shared/src/utils/ips.tsx index db756a37f0..ad77e56efe 100644 --- a/packages/stack-shared/src/utils/ips.tsx +++ b/packages/stack-shared/src/utils/ips.tsx @@ -6,6 +6,173 @@ export type Ipv6Address = string; export function isIpAddress(ip: string) { return ipRegex({ exact: true }).test(ip); } + +function parseIpv4Octets(ip: string): [number, number, number, number] | null { + const parts = ip.split("."); + if (parts.length !== 4) return null; + const octets: number[] = []; + for (const p of parts) { + const n = parseInt(p, 10); + if (isNaN(n) || n < 0 || n > 255 || p !== String(n)) return null; + octets.push(n); + } + return octets as [number, number, number, number]; +} + +function isPrivateOrReservedIpv4(octets: [number, number, number, number]): boolean { + const [a, b] = octets; + if (a === 0) return true; // 0.0.0.0/8 — "this" network + if (a === 10) return true; // 10.0.0.0/8 — private (RFC 1918) + if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 — shared/CGNAT (RFC 6598) + if (a === 127) return true; // 127.0.0.0/8 — loopback + if (a === 169 && b === 254) return true; // 169.254.0.0/16 — link-local + if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 — private (RFC 1918) + if (a === 192 && b === 168) return true; // 192.168.0.0/16 — private (RFC 1918) + if (a >= 224) return true; // 224.0.0.0/4 multicast + 240.0.0.0/4 reserved + return false; +} + +/** + * Expands an IPv6 address string to an array of 8 × 16-bit groups. + * Returns null if the format is not recognised. + */ +function parseIpv6Groups(ip: string): number[] | null { + // Handle IPv4-mapped/compatible suffix (e.g. ::ffff:192.168.1.1) + let groups: string[]; + let ipv4Tail: [number, number, number, number] | null = null; + + const dotIdx = ip.indexOf("."); + if (dotIdx !== -1) { + const lastColon = ip.lastIndexOf(":", dotIdx); + if (lastColon === -1) return null; + const ipv4Part = ip.substring(lastColon + 1); + ipv4Tail = parseIpv4Octets(ipv4Part); + if (!ipv4Tail) return null; + // Replace the IPv4 suffix with two 16-bit hex groups + groups = ip.substring(0, lastColon).split(":").concat([ + ((ipv4Tail[0] << 8) | ipv4Tail[1]).toString(16), + ((ipv4Tail[2] << 8) | ipv4Tail[3]).toString(16), + ]); + } else { + groups = ip.split(":"); + } + + // Expand :: into the correct number of zero groups + const doubleColonIdx = groups.indexOf(""); + if (doubleColonIdx !== -1) { + // Handle leading/trailing :: producing extra empty strings + const left = groups.slice(0, doubleColonIdx).filter(g => g !== ""); + const right = groups.slice(doubleColonIdx + 1).filter(g => g !== ""); + const missing = 8 - left.length - right.length; + if (missing < 0) return null; + groups = [...left, ...Array(missing).fill("0"), ...right]; + } + + if (groups.length !== 8) return null; + + const parsed = groups.map(g => parseInt(g, 16)); + if (parsed.some(n => isNaN(n) || n < 0 || n > 0xffff)) return null; + return parsed; +} + +function isPrivateOrReservedIpv6(groups: number[]): boolean { + // :: (unspecified) — all zeros + if (groups.every(g => g === 0)) return true; + + // ::1 (loopback) + if (groups.slice(0, 7).every(g => g === 0) && groups[7] === 1) return true; + + // ::ffff:0:0/96 — IPv4-mapped; check the embedded IPv4 part + if (groups.slice(0, 5).every(g => g === 0) && groups[5] === 0xffff) { + const a = (groups[6]! >> 8) & 0xff; + const b = groups[6]! & 0xff; + const c = (groups[7]! >> 8) & 0xff; + const d = groups[7]! & 0xff; + return isPrivateOrReservedIpv4([a, b, c, d]); + } + + // fc00::/7 — unique local address (ULA) + if ((groups[0]! & 0xfe00) === 0xfc00) return true; + + // fe80::/10 — link-local + if ((groups[0]! & 0xffc0) === 0xfe80) return true; + + // ff00::/8 — multicast + if ((groups[0]! & 0xff00) === 0xff00) return true; + + // 100::/64 — discard prefix + if (groups[0] === 0x0100 && groups.slice(1, 4).every(g => g === 0)) return true; + + // 2001:db8::/32 — documentation + if (groups[0] === 0x2001 && groups[1] === 0x0db8) return true; + + return false; +} + +/** + * Returns true if the given IP address belongs to a private, loopback, link-local, + * or otherwise reserved network range that should not be reachable from the public internet. + */ +export function isPrivateOrReservedIp(ip: string): boolean { + if (!isIpAddress(ip)) return false; + + const ipv4 = parseIpv4Octets(ip); + if (ipv4) return isPrivateOrReservedIpv4(ipv4); + + const ipv6 = parseIpv6Groups(ip); + if (ipv6) return isPrivateOrReservedIpv6(ipv6); + + return false; +} + +import.meta.vitest?.test("isPrivateOrReservedIp", ({ expect }) => { + // IPv4 private/reserved + expect(isPrivateOrReservedIp("0.0.0.0")).toBe(true); + expect(isPrivateOrReservedIp("0.1.2.3")).toBe(true); + expect(isPrivateOrReservedIp("10.0.0.1")).toBe(true); + expect(isPrivateOrReservedIp("10.255.255.255")).toBe(true); + expect(isPrivateOrReservedIp("100.64.0.1")).toBe(true); + expect(isPrivateOrReservedIp("100.127.255.255")).toBe(true); + expect(isPrivateOrReservedIp("127.0.0.1")).toBe(true); + expect(isPrivateOrReservedIp("127.255.255.255")).toBe(true); + expect(isPrivateOrReservedIp("169.254.1.1")).toBe(true); + expect(isPrivateOrReservedIp("172.16.0.1")).toBe(true); + expect(isPrivateOrReservedIp("172.31.255.255")).toBe(true); + expect(isPrivateOrReservedIp("192.168.0.1")).toBe(true); + expect(isPrivateOrReservedIp("192.168.255.255")).toBe(true); + expect(isPrivateOrReservedIp("224.0.0.1")).toBe(true); // multicast + expect(isPrivateOrReservedIp("240.0.0.1")).toBe(true); // reserved + expect(isPrivateOrReservedIp("255.255.255.255")).toBe(true); + + // IPv4 public + expect(isPrivateOrReservedIp("1.1.1.1")).toBe(false); + expect(isPrivateOrReservedIp("8.8.8.8")).toBe(false); + expect(isPrivateOrReservedIp("100.63.255.255")).toBe(false); // just below CGNAT + expect(isPrivateOrReservedIp("100.128.0.0")).toBe(false); // just above CGNAT + expect(isPrivateOrReservedIp("172.15.255.255")).toBe(false); + expect(isPrivateOrReservedIp("172.32.0.0")).toBe(false); + expect(isPrivateOrReservedIp("223.255.255.255")).toBe(false); + + // IPv6 private/reserved + expect(isPrivateOrReservedIp("::")).toBe(true); // unspecified + expect(isPrivateOrReservedIp("::1")).toBe(true); // loopback + expect(isPrivateOrReservedIp("fc00::1")).toBe(true); // ULA + expect(isPrivateOrReservedIp("fd12:3456::1")).toBe(true); // ULA + expect(isPrivateOrReservedIp("fe80::1")).toBe(true); // link-local + expect(isPrivateOrReservedIp("ff02::1")).toBe(true); // multicast + expect(isPrivateOrReservedIp("2001:db8::1")).toBe(true); // documentation + expect(isPrivateOrReservedIp("::ffff:127.0.0.1")).toBe(true); // IPv4-mapped loopback + expect(isPrivateOrReservedIp("::ffff:192.168.1.1")).toBe(true); // IPv4-mapped private + + // IPv6 public + expect(isPrivateOrReservedIp("2001:4860:4860::8888")).toBe(false); // Google DNS + expect(isPrivateOrReservedIp("2606:4700::1111")).toBe(false); // Cloudflare + expect(isPrivateOrReservedIp("::ffff:8.8.8.8")).toBe(false); // IPv4-mapped public + + // Invalid input + expect(isPrivateOrReservedIp("not-an-ip")).toBe(false); + expect(isPrivateOrReservedIp("")).toBe(false); +}); import.meta.vitest?.test("isIpAddress", ({ expect }) => { // Test valid IPv4 addresses expect(isIpAddress("192.168.1.1")).toBe(true); From 37b89558eef713f9d7acc908dabe47ed04f1f9c7 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 23 May 2026 04:36:19 +0000 Subject: [PATCH 2/5] fix: address SSRF bypass vectors in email SMTP protection - Block when public DNS returns no records (prevents internal-only hostname bypass) - Pass resolved IP directly to nodemailer with TLS servername for SNI (eliminates split-horizon DNS / TOCTOU rebinding gap) - Handle IPv4-compatible IPv6 addresses (::x.x.x.x) in addition to IPv4-mapped (::ffff:x.x.x.x) Co-Authored-By: Konstantin Wohlwend --- apps/backend/src/lib/emails-low-level.tsx | 47 +++++++++++++++++------ packages/stack-shared/src/utils/ips.tsx | 7 +++- 2 files changed, 42 insertions(+), 12 deletions(-) diff --git a/apps/backend/src/lib/emails-low-level.tsx b/apps/backend/src/lib/emails-low-level.tsx index 3b37c6978c..5c5485a950 100644 --- a/apps/backend/src/lib/emails-low-level.tsx +++ b/apps/backend/src/lib/emails-low-level.tsx @@ -40,10 +40,20 @@ export type LowLevelEmailConfig = { } /** - * Resolves the given SMTP host to IP addresses and throws if any of them are - * in a private / reserved network range (SSRF protection). + * Resolves the given SMTP host to a validated public IP address (SSRF protection). + * + * Returns `{ resolvedHost, servername }` where `resolvedHost` is a validated + * public IP that should be passed directly to the SMTP transport (eliminating + * any TOCTOU / split-horizon DNS gap), and `servername` is the original + * hostname for TLS SNI. + * + * Throws if the host resolves to a private/reserved range, or if public DNS + * cannot resolve it at all (blocks internal-only hostnames). */ -export async function ensureSmtpHostNotPrivateNetwork(host: string): Promise { +export async function resolveAndValidateSmtpHost(host: string): Promise<{ + resolvedHost: string, + servername: string | undefined, +}> { // If the host is a raw IP literal, check it directly if (isIP(host) !== 0) { if (isPrivateOrReservedIp(host)) { @@ -52,10 +62,11 @@ export async function ensureSmtpHostNotPrivateNetwork(host: string): Promise { - // SSRF protection: block SMTP connections to private/reserved networks + // SSRF protection: resolve and validate SMTP host, then connect to the + // resolved IP directly (eliminates split-horizon DNS / TOCTOU gaps) + let smtpHost = options.emailConfig.host; + let smtpServername: string | undefined; if (options.emailConfig.type !== 'shared') { try { - await ensureSmtpHostNotPrivateNetwork(options.emailConfig.host); + const validated = await resolveAndValidateSmtpHost(options.emailConfig.host); + smtpHost = validated.resolvedHost; + smtpServername = validated.servername; } catch (error) { return Result.error({ rawError: error, @@ -135,7 +159,8 @@ async function _lowLevelSendEmailWithoutRetries(options: LowLevelSendEmailOption try { const transporter = nodemailer.createTransport({ - host: options.emailConfig.host, + host: smtpHost, + ...(smtpServername ? { name: smtpServername, tls: { servername: smtpServername } } : {}), port: options.emailConfig.port, secure: options.emailConfig.secure, connectionTimeout: 15000, diff --git a/packages/stack-shared/src/utils/ips.tsx b/packages/stack-shared/src/utils/ips.tsx index ad77e56efe..d9426a0326 100644 --- a/packages/stack-shared/src/utils/ips.tsx +++ b/packages/stack-shared/src/utils/ips.tsx @@ -83,7 +83,8 @@ function isPrivateOrReservedIpv6(groups: number[]): boolean { if (groups.slice(0, 7).every(g => g === 0) && groups[7] === 1) return true; // ::ffff:0:0/96 — IPv4-mapped; check the embedded IPv4 part - if (groups.slice(0, 5).every(g => g === 0) && groups[5] === 0xffff) { + // ::0:0/96 — IPv4-compatible (deprecated but still accepted by some stacks) + if (groups.slice(0, 5).every(g => g === 0) && (groups[5] === 0xffff || groups[5] === 0)) { const a = (groups[6]! >> 8) & 0xff; const b = groups[6]! & 0xff; const c = (groups[7]! >> 8) & 0xff; @@ -163,11 +164,15 @@ import.meta.vitest?.test("isPrivateOrReservedIp", ({ expect }) => { expect(isPrivateOrReservedIp("2001:db8::1")).toBe(true); // documentation expect(isPrivateOrReservedIp("::ffff:127.0.0.1")).toBe(true); // IPv4-mapped loopback expect(isPrivateOrReservedIp("::ffff:192.168.1.1")).toBe(true); // IPv4-mapped private + expect(isPrivateOrReservedIp("::192.168.1.1")).toBe(true); // IPv4-compatible private + expect(isPrivateOrReservedIp("::10.0.0.1")).toBe(true); // IPv4-compatible private + expect(isPrivateOrReservedIp("::127.0.0.1")).toBe(true); // IPv4-compatible loopback // IPv6 public expect(isPrivateOrReservedIp("2001:4860:4860::8888")).toBe(false); // Google DNS expect(isPrivateOrReservedIp("2606:4700::1111")).toBe(false); // Cloudflare expect(isPrivateOrReservedIp("::ffff:8.8.8.8")).toBe(false); // IPv4-mapped public + expect(isPrivateOrReservedIp("::8.8.8.8")).toBe(false); // IPv4-compatible public // Invalid input expect(isPrivateOrReservedIp("not-an-ip")).toBe(false); From addcbf0a068ed16465311995080b0e6450a2dcc2 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 23 May 2026 04:47:57 +0000 Subject: [PATCH 3/5] fix: address CodeRabbit review + fix CI E2E failures - Distinguish transient DNS errors from NXDOMAIN/NODATA (retryable vs non-retryable) - Replace non-null assertion with ?? throwErr() defensive pattern - Remove unnecessary 'name' from nodemailer transport (keep only tls.servername) - Skip SSRF validation in dev/test environments (Inbucket uses localhost) Co-Authored-By: Konstantin Wohlwend --- apps/backend/src/lib/emails-low-level.tsx | 40 ++++++++++++++++++----- 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/apps/backend/src/lib/emails-low-level.tsx b/apps/backend/src/lib/emails-low-level.tsx index 5c5485a950..8b64123da9 100644 --- a/apps/backend/src/lib/emails-low-level.tsx +++ b/apps/backend/src/lib/emails-low-level.tsx @@ -5,7 +5,8 @@ */ import { isPrivateOrReservedIp } from '@stackframe/stack-shared/dist/utils/ips'; -import { StackAssertionError, captureError } from '@stackframe/stack-shared/dist/utils/errors'; +import { StackAssertionError, captureError, throwErr } from '@stackframe/stack-shared/dist/utils/errors'; +import { getNodeEnvironment } from '@stackframe/stack-shared/dist/utils/env'; import { omit, pick } from '@stackframe/stack-shared/dist/utils/objects'; import { runAsynchronously, wait } from '@stackframe/stack-shared/dist/utils/promises'; import { Result } from '@stackframe/stack-shared/dist/utils/results'; @@ -69,9 +70,21 @@ export async function resolveAndValidateSmtpHost(host: string): Promise<{ // split-horizon attacks where internal DNS returns a different IP const resolver = new dns.Resolver(); resolver.setServers(["1.1.1.1", "8.8.8.8"]); + + // Only swallow NODATA/NXDOMAIN (expected when a host has no A or AAAA + // records). Transient errors (TIMEOUT, SERVFAIL, etc.) propagate so the + // caller can retry instead of mis-classifying them as PRIVATE_NETWORK. + const noRecordsCodes = new Set(["ENODATA", "ENOTFOUND", "ENONAME"]); + const catchNoRecords = (err: unknown): string[] => { + if (err instanceof Error && "code" in err && typeof err.code === "string" && noRecordsCodes.has(err.code)) { + return []; + } + throw err; + }; + const [ipv4Addrs, ipv6Addrs] = await Promise.all([ - resolver.resolve4(host).catch(() => [] as string[]), - resolver.resolve6(host).catch(() => [] as string[]), + resolver.resolve4(host).catch(catchNoRecords), + resolver.resolve6(host).catch(catchNoRecords), ]); const allAddrs = [...ipv4Addrs, ...ipv6Addrs]; @@ -96,7 +109,7 @@ export async function resolveAndValidateSmtpHost(host: string): Promise<{ // Return the first resolved IP so nodemailer uses it directly (skipping its // own DNS resolution), eliminating the TOCTOU window. Set servername for TLS SNI. - return { resolvedHost: allAddrs[0]!, servername: host }; + return { resolvedHost: allAddrs[0] ?? throwErr("allAddrs should be non-empty after length check"), servername: host }; } export type LowLevelSendEmailOptions = { @@ -142,17 +155,26 @@ async function _lowLevelSendEmailWithoutRetries(options: LowLevelSendEmailOption // resolved IP directly (eliminates split-horizon DNS / TOCTOU gaps) let smtpHost = options.emailConfig.host; let smtpServername: string | undefined; - if (options.emailConfig.type !== 'shared') { + if (options.emailConfig.type !== 'shared' && !['development', 'test'].includes(getNodeEnvironment())) { try { const validated = await resolveAndValidateSmtpHost(options.emailConfig.host); smtpHost = validated.resolvedHost; smtpServername = validated.servername; } catch (error) { + if (error instanceof StackAssertionError) { + return Result.error({ + rawError: error, + errorType: 'PRIVATE_NETWORK', + canRetry: false, + message: `The email server host resolves to a private or reserved network address. Please use a publicly reachable SMTP server.`, + } as const); + } + // Transient DNS errors (TIMEOUT, SERVFAIL, etc.) are retryable return Result.error({ rawError: error, - errorType: 'PRIVATE_NETWORK', - canRetry: false, - message: `The email server host resolves to a private or reserved network address. Please use a publicly reachable SMTP server.`, + errorType: 'DNS_ERROR', + canRetry: true, + message: `DNS resolution failed for SMTP host. This may be a transient issue.`, } as const); } } @@ -160,7 +182,7 @@ async function _lowLevelSendEmailWithoutRetries(options: LowLevelSendEmailOption try { const transporter = nodemailer.createTransport({ host: smtpHost, - ...(smtpServername ? { name: smtpServername, tls: { servername: smtpServername } } : {}), + ...(smtpServername ? { tls: { servername: smtpServername } } : {}), port: options.emailConfig.port, secure: options.emailConfig.secure, connectionTimeout: 15000, From dd61167c4e1438a3169af8823c04696776652165 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 23 May 2026 04:54:22 +0000 Subject: [PATCH 4/5] fix: add DNS timeout, narrow catch to known DNS errors only - Add 7s timeout with resolver.cancel() around DNS lookups - Narrow catch to only StackAssertionError + known DNS error codes - Rethrow unexpected errors instead of masking as DNS_ERROR - Pass through StackAssertionError message for accurate user feedback Co-Authored-By: Konstantin Wohlwend --- apps/backend/src/lib/emails-low-level.tsx | 40 +++++++++++++++++------ 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/apps/backend/src/lib/emails-low-level.tsx b/apps/backend/src/lib/emails-low-level.tsx index 8b64123da9..4ab043cb34 100644 --- a/apps/backend/src/lib/emails-low-level.tsx +++ b/apps/backend/src/lib/emails-low-level.tsx @@ -82,9 +82,23 @@ export async function resolveAndValidateSmtpHost(host: string): Promise<{ throw err; }; + const DNS_TIMEOUT_MS = 7_000; + const resolveWithTimeout = (fn: () => Promise): Promise => { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + resolver.cancel(); + reject(Object.assign(new Error(`DNS lookup timed out after ${DNS_TIMEOUT_MS}ms`), { code: "ECANCELLED" })); + }, DNS_TIMEOUT_MS); + fn().then( + (result) => { clearTimeout(timer); resolve(result); }, + (err) => { clearTimeout(timer); reject(err); }, + ); + }); + }; + const [ipv4Addrs, ipv6Addrs] = await Promise.all([ - resolver.resolve4(host).catch(catchNoRecords), - resolver.resolve6(host).catch(catchNoRecords), + resolveWithTimeout(() => resolver.resolve4(host)).catch(catchNoRecords), + resolveWithTimeout(() => resolver.resolve6(host)).catch(catchNoRecords), ]); const allAddrs = [...ipv4Addrs, ...ipv6Addrs]; @@ -166,16 +180,22 @@ async function _lowLevelSendEmailWithoutRetries(options: LowLevelSendEmailOption rawError: error, errorType: 'PRIVATE_NETWORK', canRetry: false, - message: `The email server host resolves to a private or reserved network address. Please use a publicly reachable SMTP server.`, + message: error.message, } as const); } - // Transient DNS errors (TIMEOUT, SERVFAIL, etc.) are retryable - return Result.error({ - rawError: error, - errorType: 'DNS_ERROR', - canRetry: true, - message: `DNS resolution failed for SMTP host. This may be a transient issue.`, - } as const); + // Only treat known DNS error codes as retryable transient failures + const dnsTransientCodes = new Set(["ETIMEOUT", "ESERVFAIL", "ECONNREFUSED", "EAI_AGAIN", "ECANCELLED"]); + const errorCode = error instanceof Error && "code" in error && typeof error.code === "string" ? error.code : undefined; + if (errorCode != null && dnsTransientCodes.has(errorCode)) { + return Result.error({ + rawError: error, + errorType: 'DNS_ERROR', + canRetry: true, + message: `DNS resolution failed for SMTP host. This may be a transient issue.`, + } as const); + } + // Unexpected error — fail loudly + throw error; } } From aa9d0e030e17ed48d519c7eca37af86c6696cc39 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 23 May 2026 05:08:23 +0000 Subject: [PATCH 5/5] fix: lint max-statements-per-line in resolveWithTimeout Co-Authored-By: Konstantin Wohlwend --- apps/backend/src/lib/emails-low-level.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/backend/src/lib/emails-low-level.tsx b/apps/backend/src/lib/emails-low-level.tsx index 4ab043cb34..eb33ae3bf2 100644 --- a/apps/backend/src/lib/emails-low-level.tsx +++ b/apps/backend/src/lib/emails-low-level.tsx @@ -90,8 +90,14 @@ export async function resolveAndValidateSmtpHost(host: string): Promise<{ reject(Object.assign(new Error(`DNS lookup timed out after ${DNS_TIMEOUT_MS}ms`), { code: "ECANCELLED" })); }, DNS_TIMEOUT_MS); fn().then( - (result) => { clearTimeout(timer); resolve(result); }, - (err) => { clearTimeout(timer); reject(err); }, + (result) => { + clearTimeout(timer); + resolve(result); + }, + (err) => { + clearTimeout(timer); + reject(err); + }, ); }); };