Skip to content

Commit 2c0a10a

Browse files
improvement(auth): layer disposable-email-domains into signup email validation (#5010)
* ci(migrations): fail dev schema push with an actionable error on rename/drop prompt `drizzle-kit push --force` only suppresses the data-loss confirm, not the rename-vs-drop disambiguation prompt. That prompt fires whenever a diff both adds and drops tables/columns at once (e.g. migration 0231 created sim_trigger_state while dropping the workspace_notification_* tables), and in CI it crashes with a bare "Interactive prompts require a TTY" stack trace. Catch that specific failure in the dev push step and emit a GitHub error annotation explaining the cause and the fix (drop the stale objects on the dev DB to match schema.ts — the same DROPs the versioned migration already applied to staging/prod), instead of leaving an opaque trace. Exit status is preserved either way. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * improvement(auth): layer disposable-email-domains into signup email validation Compose the disposable-email-domains list (exact + wildcard) into better-auth-harmony's validator alongside its bundled Mailchecker list, so signup rejects an email if either flags it. Server-only module to keep the dataset out of the client bundle. * improvement(auth): defer disposable-domains dataset behind lazy dynamic import Address review: load the ~120K-entry dataset on first use instead of at module import, so deployments with SIGNUP_EMAIL_VALIDATION_ENABLED off never pay the cost. Add a bare wildcard-base test case. --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 020baad commit 2c0a10a

7 files changed

Lines changed: 103 additions & 2 deletions

File tree

.github/workflows/migrations.yml

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,18 @@ jobs:
6969
7070
if [ "${ENVIRONMENT}" = "dev" ]; then
7171
echo "Dev environment — pushing schema directly (db:push)"
72-
bun run db:push --force
72+
# `--force` only suppresses the data-loss confirm, not drizzle's
73+
# rename-vs-drop prompt, which fires (and crashes, no TTY) when a
74+
# diff both adds and drops tables/columns at once. Turn that opaque
75+
# crash into an actionable failure instead of a bare stack trace.
76+
push_output="$(bun run db:push --force 2>&1)" && push_status=0 || push_status=$?
77+
echo "$push_output"
78+
if [ "$push_status" -ne 0 ]; then
79+
if printf '%s' "$push_output" | grep -q 'Interactive prompts require a TTY'; then
80+
echo "::error title=Dev schema push needs manual reconciliation::drizzle-kit push hit an interactive rename/drop prompt that CI cannot answer. The dev DB has drifted from schema.ts: it still holds table(s)/column(s) the schema no longer declares while the schema also adds new ones, so drizzle cannot tell a rename from a drop+create. Fix: drop the stale objects on the dev DB to match schema.ts — the same DROPs the latest versioned migration already applied to staging/prod (grep packages/db/migrations for the most recent DROP TABLE / DROP COLUMN) — then re-run this workflow. --force cannot bypass this prompt."
81+
fi
82+
exit "$push_status"
83+
fi
7384
else
7485
echo "Applying versioned migrations (db:migrate)"
7586
bun run ./scripts/migrate.ts

apps/sim/lib/auth/auth.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
organization,
2222
} from 'better-auth/plugins'
2323
import { emailHarmony } from 'better-auth-harmony'
24+
import { validateEmail as validateEmailWithMailchecker } from 'better-auth-harmony/email'
2425
import { and, count, eq, inArray, sql } from 'drizzle-orm'
2526
import { headers } from 'next/headers'
2627
import Stripe from 'stripe'
@@ -78,6 +79,7 @@ import {
7879
import { PlatformEvents } from '@/lib/core/telemetry'
7980
import { getBaseUrl, isLocalhostUrl, parseOriginList } from '@/lib/core/utils/urls'
8081
import { processCredentialDraft } from '@/lib/credentials/draft-processor'
82+
import { isDisposableEmailDomain } from '@/lib/messaging/email/disposable-domains.server'
8183
import { sendEmail } from '@/lib/messaging/email/mailer'
8284
import { getFromEmailAddress, getPersonalEmailFrom } from '@/lib/messaging/email/utils'
8385
import { quickValidateEmail } from '@/lib/messaging/email/validation'
@@ -930,7 +932,14 @@ export const auth = betterAuth({
930932
}),
931933
},
932934
plugins: [
933-
...(isSignupEmailValidationEnabled ? [emailHarmony()] : []),
935+
...(isSignupEmailValidationEnabled
936+
? [
937+
emailHarmony({
938+
validator: async (email) =>
939+
validateEmailWithMailchecker(email) && !(await isDisposableEmailDomain(email)),
940+
}),
941+
]
942+
: []),
934943
...(env.TURNSTILE_SECRET_KEY
935944
? [
936945
captcha({
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/** Ambient types for `disposable-email-domains` — ships JSON arrays with no bundled types. */
2+
declare module 'disposable-email-domains' {
3+
const domains: string[]
4+
export default domains
5+
}
6+
7+
declare module 'disposable-email-domains/wildcard.json' {
8+
const baseDomains: string[]
9+
export default baseDomains
10+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { isDisposableEmailDomain } from '@/lib/messaging/email/disposable-domains.server'
6+
7+
describe('isDisposableEmailDomain', () => {
8+
it('flags a known disposable domain', async () => {
9+
expect(await isDisposableEmailDomain('someone@mailinator.com')).toBe(true)
10+
})
11+
12+
it('flags a subdomain of a wildcard base domain', async () => {
13+
expect(await isDisposableEmailDomain('someone@inbox.10mail.org')).toBe(true)
14+
})
15+
16+
it('flags the bare wildcard base domain itself', async () => {
17+
expect(await isDisposableEmailDomain('someone@10mail.org')).toBe(true)
18+
})
19+
20+
it('is case-insensitive on the domain', async () => {
21+
expect(await isDisposableEmailDomain('Someone@MailInator.com')).toBe(true)
22+
})
23+
24+
it('allows a normal provider domain', async () => {
25+
expect(await isDisposableEmailDomain('someone@gmail.com')).toBe(false)
26+
})
27+
28+
it('allows a custom catch-all domain that is not on the list', async () => {
29+
expect(await isDisposableEmailDomain('sim6dc088f506@lordfortescue.org.uk')).toBe(false)
30+
})
31+
32+
it('returns false for malformed input with no domain', async () => {
33+
expect(await isDisposableEmailDomain('not-an-email')).toBe(false)
34+
})
35+
})
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
let cache: { exact: Set<string>; wildcards: string[] } | undefined
2+
3+
/**
4+
* Lazily loads the `disposable-email-domains` dataset (~120K exact domains plus
5+
* wildcard base domains) on first use and memoizes it. Deferred behind a dynamic
6+
* import so deployments with signup email validation disabled never load it.
7+
*/
8+
async function loadDisposableData(): Promise<{ exact: Set<string>; wildcards: string[] }> {
9+
if (!cache) {
10+
const [{ default: exactList }, { default: wildcards }] = await Promise.all([
11+
import('disposable-email-domains'),
12+
import('disposable-email-domains/wildcard.json'),
13+
])
14+
cache = { exact: new Set(exactList), wildcards }
15+
}
16+
return cache
17+
}
18+
19+
/**
20+
* Server-only disposable-email-domain check. Layered alongside better-auth-harmony's
21+
* bundled Mailchecker list at the signup gate. Matches exact domains and any subdomain
22+
* of (or the bare) wildcard base domain.
23+
*
24+
* Never import from client code — the dataset would bloat the browser bundle.
25+
*/
26+
export async function isDisposableEmailDomain(email: string): Promise<boolean> {
27+
const domain = email.split('@')[1]?.toLowerCase()
28+
if (!domain) return false
29+
const { exact, wildcards } = await loadDisposableData()
30+
if (exact.has(domain)) return true
31+
return wildcards.some((base) => domain === base || domain.endsWith(`.${base}`))
32+
}

apps/sim/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@
126126
"csv-parse": "6.1.0",
127127
"date-fns": "4.1.0",
128128
"decimal.js": "10.6.0",
129+
"disposable-email-domains": "1.0.62",
129130
"docx": "^9.6.1",
130131
"docx-preview": "^0.3.7",
131132
"drizzle-orm": "^0.45.2",

bun.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)