Files
simstudioai--sim/apps/sim/lib/messaging/email/validation.server.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

88 lines
3.0 KiB
TypeScript

import type { MxRecord } from 'dns'
import dns from 'dns/promises'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
const logger = createLogger('EmailValidationServer')
const MX_LOOKUP_TIMEOUT_MS = 3000
export interface SignupEmailCheck {
/** Whether the email may proceed to signup. */
allowed: boolean
/** Machine-readable block reason, present only when `allowed` is false. */
reason?: 'no_mx' | 'blocked_mx_backend'
}
/**
* Server-side signup email validation backed by an MX lookup.
*
* Rejects domains that resolve to no mail server (`no_mx`) or to a denylisted
* catch-all backend (`blocked_mx_backend`). Designed to be fail-open: any DNS
* timeout or transient resolver error allows the signup through so legitimate
* users are never blocked by an infrastructure blip. Only a definitive
* "domain has no MX" answer (`ENOTFOUND` / `ENODATA`) blocks.
*
* `blockedMxHosts` are case-insensitive substrings matched against each resolved
* MX exchange — signup-spam botnets rotate throwaway domains but funnel them
* through a few shared catch-all backends, so the MX host is a more stable signal
* than the domain. Sourced from access-control config (AppConfig or env fallback).
*
* Server-only — imports `dns/promises`. Never import from client code. Gated by the caller
* behind `isSignupMxValidationEnabled`; this function performs the check unconditionally.
*/
export async function validateSignupEmailMx(
email: string,
blockedMxHosts: string[]
): Promise<SignupEmailCheck> {
const domain = email.split('@')[1]?.toLowerCase()
if (!domain) return { allowed: true }
let records: MxRecord[]
let timeoutHandle: ReturnType<typeof setTimeout> | undefined
try {
records = await Promise.race([
dns.resolveMx(domain),
new Promise<never>((_, reject) => {
timeoutHandle = setTimeout(
() => reject(new Error('mx_lookup_timeout')),
MX_LOOKUP_TIMEOUT_MS
)
}),
])
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
if (code === 'ENOTFOUND' || code === 'ENODATA') {
logger.info('Blocked signup: domain has no MX record', { domain })
return { allowed: false, reason: 'no_mx' }
}
logger.warn('MX lookup failed; allowing signup (fail-open)', {
domain,
error: getErrorMessage(error),
})
return { allowed: true }
} finally {
if (timeoutHandle) clearTimeout(timeoutHandle)
}
if (!records || records.length === 0) {
logger.info('Blocked signup: domain has no MX record', { domain })
return { allowed: false, reason: 'no_mx' }
}
const match = records.find((record) => {
const exchange = record.exchange.toLowerCase()
return blockedMxHosts.some((host) => exchange.includes(host))
})
if (match) {
logger.info('Blocked signup: denylisted MX backend', {
domain,
exchange: match.exchange,
})
return { allowed: false, reason: 'blocked_mx_backend' }
}
return { allowed: true }
}