From b97a526722b270db6af07b0655b1a06c49e61f76 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 12 Jun 2026 16:44:52 -0700 Subject: [PATCH 1/3] Revert "improvement(auth): layer disposable-email-domains into signup email validation (#5010)" This reverts commit 2c0a10a5614f45debbbcc4a4d313a9793ce65950. --- .github/workflows/migrations.yml | 13 +------ apps/sim/lib/auth/auth.ts | 11 +----- .../messaging/email/disposable-domains.d.ts | 10 ------ .../email/disposable-domains.server.test.ts | 35 ------------------- .../email/disposable-domains.server.ts | 32 ----------------- apps/sim/package.json | 1 - bun.lock | 3 -- 7 files changed, 2 insertions(+), 103 deletions(-) delete mode 100644 apps/sim/lib/messaging/email/disposable-domains.d.ts delete mode 100644 apps/sim/lib/messaging/email/disposable-domains.server.test.ts delete mode 100644 apps/sim/lib/messaging/email/disposable-domains.server.ts diff --git a/.github/workflows/migrations.yml b/.github/workflows/migrations.yml index 7be6e56b32d..ea5ca453968 100644 --- a/.github/workflows/migrations.yml +++ b/.github/workflows/migrations.yml @@ -69,18 +69,7 @@ jobs: if [ "${ENVIRONMENT}" = "dev" ]; then echo "Dev environment — pushing schema directly (db:push)" - # `--force` only suppresses the data-loss confirm, not drizzle's - # rename-vs-drop prompt, which fires (and crashes, no TTY) when a - # diff both adds and drops tables/columns at once. Turn that opaque - # crash into an actionable failure instead of a bare stack trace. - push_output="$(bun run db:push --force 2>&1)" && push_status=0 || push_status=$? - echo "$push_output" - if [ "$push_status" -ne 0 ]; then - if printf '%s' "$push_output" | grep -q 'Interactive prompts require a TTY'; then - 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." - fi - exit "$push_status" - fi + bun run db:push --force else echo "Applying versioned migrations (db:migrate)" bun run ./scripts/migrate.ts diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 099e14aa7a3..c43a7f56ce5 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -21,7 +21,6 @@ import { organization, } from 'better-auth/plugins' import { emailHarmony } from 'better-auth-harmony' -import { validateEmail as validateEmailWithMailchecker } from 'better-auth-harmony/email' import { and, count, eq, inArray, sql } from 'drizzle-orm' import { headers } from 'next/headers' import Stripe from 'stripe' @@ -79,7 +78,6 @@ import { import { PlatformEvents } from '@/lib/core/telemetry' import { getBaseUrl, isLocalhostUrl, parseOriginList } from '@/lib/core/utils/urls' import { processCredentialDraft } from '@/lib/credentials/draft-processor' -import { isDisposableEmailDomain } from '@/lib/messaging/email/disposable-domains.server' import { sendEmail } from '@/lib/messaging/email/mailer' import { getFromEmailAddress, getPersonalEmailFrom } from '@/lib/messaging/email/utils' import { quickValidateEmail } from '@/lib/messaging/email/validation' @@ -932,14 +930,7 @@ export const auth = betterAuth({ }), }, plugins: [ - ...(isSignupEmailValidationEnabled - ? [ - emailHarmony({ - validator: async (email) => - validateEmailWithMailchecker(email) && !(await isDisposableEmailDomain(email)), - }), - ] - : []), + ...(isSignupEmailValidationEnabled ? [emailHarmony()] : []), ...(env.TURNSTILE_SECRET_KEY ? [ captcha({ diff --git a/apps/sim/lib/messaging/email/disposable-domains.d.ts b/apps/sim/lib/messaging/email/disposable-domains.d.ts deleted file mode 100644 index e182fb4ded6..00000000000 --- a/apps/sim/lib/messaging/email/disposable-domains.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** Ambient types for `disposable-email-domains` — ships JSON arrays with no bundled types. */ -declare module 'disposable-email-domains' { - const domains: string[] - export default domains -} - -declare module 'disposable-email-domains/wildcard.json' { - const baseDomains: string[] - export default baseDomains -} diff --git a/apps/sim/lib/messaging/email/disposable-domains.server.test.ts b/apps/sim/lib/messaging/email/disposable-domains.server.test.ts deleted file mode 100644 index 05eaa593391..00000000000 --- a/apps/sim/lib/messaging/email/disposable-domains.server.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * @vitest-environment node - */ -import { describe, expect, it } from 'vitest' -import { isDisposableEmailDomain } from '@/lib/messaging/email/disposable-domains.server' - -describe('isDisposableEmailDomain', () => { - it('flags a known disposable domain', async () => { - expect(await isDisposableEmailDomain('someone@mailinator.com')).toBe(true) - }) - - it('flags a subdomain of a wildcard base domain', async () => { - expect(await isDisposableEmailDomain('someone@inbox.10mail.org')).toBe(true) - }) - - it('flags the bare wildcard base domain itself', async () => { - expect(await isDisposableEmailDomain('someone@10mail.org')).toBe(true) - }) - - it('is case-insensitive on the domain', async () => { - expect(await isDisposableEmailDomain('Someone@MailInator.com')).toBe(true) - }) - - it('allows a normal provider domain', async () => { - expect(await isDisposableEmailDomain('someone@gmail.com')).toBe(false) - }) - - it('allows a custom catch-all domain that is not on the list', async () => { - expect(await isDisposableEmailDomain('sim6dc088f506@lordfortescue.org.uk')).toBe(false) - }) - - it('returns false for malformed input with no domain', async () => { - expect(await isDisposableEmailDomain('not-an-email')).toBe(false) - }) -}) diff --git a/apps/sim/lib/messaging/email/disposable-domains.server.ts b/apps/sim/lib/messaging/email/disposable-domains.server.ts deleted file mode 100644 index 03bed585f95..00000000000 --- a/apps/sim/lib/messaging/email/disposable-domains.server.ts +++ /dev/null @@ -1,32 +0,0 @@ -let cache: { exact: Set; wildcards: string[] } | undefined - -/** - * Lazily loads the `disposable-email-domains` dataset (~120K exact domains plus - * wildcard base domains) on first use and memoizes it. Deferred behind a dynamic - * import so deployments with signup email validation disabled never load it. - */ -async function loadDisposableData(): Promise<{ exact: Set; wildcards: string[] }> { - if (!cache) { - const [{ default: exactList }, { default: wildcards }] = await Promise.all([ - import('disposable-email-domains'), - import('disposable-email-domains/wildcard.json'), - ]) - cache = { exact: new Set(exactList), wildcards } - } - return cache -} - -/** - * Server-only disposable-email-domain check. Layered alongside better-auth-harmony's - * bundled Mailchecker list at the signup gate. Matches exact domains and any subdomain - * of (or the bare) wildcard base domain. - * - * Never import from client code — the dataset would bloat the browser bundle. - */ -export async function isDisposableEmailDomain(email: string): Promise { - const domain = email.split('@')[1]?.toLowerCase() - if (!domain) return false - const { exact, wildcards } = await loadDisposableData() - if (exact.has(domain)) return true - return wildcards.some((base) => domain === base || domain.endsWith(`.${base}`)) -} diff --git a/apps/sim/package.json b/apps/sim/package.json index 2b22800826b..1c5915a267c 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -126,7 +126,6 @@ "csv-parse": "6.1.0", "date-fns": "4.1.0", "decimal.js": "10.6.0", - "disposable-email-domains": "1.0.62", "docx": "^9.6.1", "docx-preview": "^0.3.7", "drizzle-orm": "^0.45.2", diff --git a/bun.lock b/bun.lock index 3a084c8d47c..447ad2121cc 100644 --- a/bun.lock +++ b/bun.lock @@ -185,7 +185,6 @@ "csv-parse": "6.1.0", "date-fns": "4.1.0", "decimal.js": "10.6.0", - "disposable-email-domains": "1.0.62", "docx": "^9.6.1", "docx-preview": "^0.3.7", "drizzle-orm": "^0.45.2", @@ -2213,8 +2212,6 @@ "dingbat-to-unicode": ["dingbat-to-unicode@1.0.1", "", {}, "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w=="], - "disposable-email-domains": ["disposable-email-domains@1.0.62", "", {}, "sha512-LBQvhRw7mznQTPoyZbsmYeNOZt1pN5aCsx4BAU/3siVFuiM9f2oyKzUaB8v1jbxFjE3aYqYiMo63kAL4pHgfWQ=="], - "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="], "dockerfile-ast": ["dockerfile-ast@0.7.1", "", { "dependencies": { "vscode-languageserver-textdocument": "^1.0.8", "vscode-languageserver-types": "^3.17.3" } }, "sha512-oX/A4I0EhSkGqrFv0YuvPkBUSYp1XiY8O8zAKc8Djglx8ocz+JfOr8gP0ryRMC2myqvDLagmnZaU9ot1vG2ijw=="], From f331a0b8b2b8bf3ff61848acd98f7882f6a921ba Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 12 Jun 2026 17:49:43 -0700 Subject: [PATCH 2/3] feat(mailer): gate outbound email on AppConfig access-control ban list --- apps/sim/lib/messaging/email/mailer.test.ts | 57 +++++++++++++++++++++ apps/sim/lib/messaging/email/mailer.ts | 47 +++++++++++++++-- 2 files changed, 99 insertions(+), 5 deletions(-) diff --git a/apps/sim/lib/messaging/email/mailer.test.ts b/apps/sim/lib/messaging/email/mailer.test.ts index 515dcff6c4c..53da5a8f4e7 100644 --- a/apps/sim/lib/messaging/email/mailer.test.ts +++ b/apps/sim/lib/messaging/email/mailer.test.ts @@ -36,6 +36,17 @@ vi.mock('@/lib/messaging/email/unsubscribe', () => ({ generateUnsubscribeToken: vi.fn(), })) +vi.mock('@/lib/auth/access-control', () => ({ + getAccessControlConfig: vi.fn().mockResolvedValue({ + blockedSignupDomains: [], + blockedEmails: [], + allowedLoginEmails: [], + allowedLoginDomains: [], + blockedEmailMxHosts: [], + }), + isEmailBlockedByAccessControl: vi.fn().mockReturnValue(false), +})) + vi.mock('@/lib/core/config/env', () => createEnvMock({ RESEND_API_KEY: 'test-api-key', @@ -59,6 +70,7 @@ vi.mock('@/lib/messaging/email/utils', () => ({ NO_EMAIL_HEADER_CONTROL_CHARS_REGEX: /^[^\r\n]*$/, })) +import { isEmailBlockedByAccessControl } from '@/lib/auth/access-control' import { type EmailType, hasEmailService, sendBatchEmails, sendEmail } from './mailer' import { generateUnsubscribeToken, isUnsubscribed } from './unsubscribe' @@ -71,6 +83,7 @@ describe('mailer', () => { beforeEach(() => { vi.clearAllMocks() + ;(isEmailBlockedByAccessControl as Mock).mockReturnValue(false) ;(isUnsubscribed as Mock).mockResolvedValue(false) ;(generateUnsubscribeToken as Mock).mockReturnValue('mock-token-123') @@ -195,6 +208,36 @@ describe('mailer', () => { expect(isUnsubscribed).toHaveBeenCalledWith('user1@example.com', 'marketing') }) + it('should skip sending when the recipient is on the ban list', async () => { + ;(isEmailBlockedByAccessControl as Mock).mockReturnValue(true) + + const result = await sendEmail({ + ...testEmailOptions, + emailType: 'transactional', + }) + + expect(result.success).toBe(true) + expect(result.message).toBe('Email skipped (recipient on access-control ban list)') + expect(result.data).toEqual({ id: 'skipped-banned' }) + expect(mockSend).not.toHaveBeenCalled() + expect(isUnsubscribed).not.toHaveBeenCalled() + }) + + it('should drop only the banned recipients from a multi-recipient send', async () => { + ;(isEmailBlockedByAccessControl as Mock).mockImplementation( + (email: string) => email === 'banned@example.com' + ) + + const result = await sendEmail({ + ...testEmailOptions, + to: ['good@example.com', 'banned@example.com'], + emailType: 'transactional', + }) + + expect(result.success).toBe(true) + expect(mockSend).toHaveBeenCalledWith(expect.objectContaining({ to: 'good@example.com' })) + }) + it('should handle general exceptions gracefully', async () => { ;(isUnsubscribed as Mock).mockRejectedValue(new Error('Database connection failed')) @@ -256,6 +299,20 @@ describe('mailer', () => { expect(isUnsubscribed).not.toHaveBeenCalled() }) + it('should skip banned recipients in a batch', async () => { + ;(isEmailBlockedByAccessControl as Mock).mockImplementation( + (email: string) => email === 'user2@example.com' + ) + + const result = await sendBatchEmails({ emails: testBatchEmails }) + + expect(result.results).toHaveLength(2) + const bannedEntry = result.results.find( + (r) => r.message === 'Email skipped (recipient on access-control ban list)' + ) + expect(bannedEntry).toBeDefined() + }) + it('should degrade isUnsubscribed rejections to per-entry failures', async () => { ;(isUnsubscribed as Mock).mockRejectedValue(new Error('Database connection failed')) diff --git a/apps/sim/lib/messaging/email/mailer.ts b/apps/sim/lib/messaging/email/mailer.ts index a285ecc71ee..6b2dbbc84a7 100644 --- a/apps/sim/lib/messaging/email/mailer.ts +++ b/apps/sim/lib/messaging/email/mailer.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' +import { getAccessControlConfig, isEmailBlockedByAccessControl } from '@/lib/auth/access-control' import { processEmailData, shouldSkipForUnsubscribe } from '@/lib/messaging/email/prepare' import { activeProviders } from '@/lib/messaging/email/providers' import type { @@ -36,22 +37,54 @@ const MOCK_EMAIL_RESULT: SendEmailResult = { data: { id: 'mock-email-id' }, } +const SKIPPED_BANNED_RESULT: SendEmailResult = { + success: true, + message: 'Email skipped (recipient on access-control ban list)', + data: { id: 'skipped-banned' }, +} + export function hasEmailService(): boolean { return activeProviders.length > 0 } +/** + * Drop recipients that are on the AppConfig access-control ban list. Returns the + * original options when nothing is banned, options narrowed to the allowed + * recipients when some are, or `null` when every recipient is banned. Config is + * cached (~30s TTL) with an env fallback, so a missing/unreachable AppConfig + * fails open rather than blocking all mail. + */ +async function applyBanList(options: EmailOptions): Promise { + const recipients = Array.isArray(options.to) ? options.to : [options.to] + const config = await getAccessControlConfig() + const allowed = recipients.filter((email) => !isEmailBlockedByAccessControl(email, config)) + if (allowed.length === 0) return null + if (allowed.length === recipients.length) return options + return { ...options, to: allowed.length === 1 ? allowed[0] : allowed } +} + export async function sendEmail(options: EmailOptions): Promise { try { - if (await shouldSkipForUnsubscribe(options)) { - logger.info('Email not sent (user unsubscribed):', { + const allowed = await applyBanList(options) + if (!allowed) { + logger.info('Email not sent (recipient on access-control ban list):', { to: options.to, subject: options.subject, emailType: options.emailType, }) + return SKIPPED_BANNED_RESULT + } + + if (await shouldSkipForUnsubscribe(allowed)) { + logger.info('Email not sent (user unsubscribed):', { + to: allowed.to, + subject: allowed.subject, + emailType: allowed.emailType, + }) return SKIPPED_UNSUBSCRIBED_RESULT } - const data = processEmailData(options) + const data = processEmailData(allowed) if (activeProviders.length === 0) { logger.info('Email not sent (no email service configured):', { @@ -96,10 +129,14 @@ async function prepareBatch(emails: EmailOptions[]): Promise => { try { - if (await shouldSkipForUnsubscribe(email)) { + const allowed = await applyBanList(email) + if (!allowed) { + return { index, data: null, skippedResult: SKIPPED_BANNED_RESULT } + } + if (await shouldSkipForUnsubscribe(allowed)) { return { index, data: null, skippedResult: SKIPPED_UNSUBSCRIBED_RESULT } } - return { index, data: processEmailData(email), skippedResult: null } + return { index, data: processEmailData(allowed), skippedResult: null } } catch (error) { return { index, From c9a340cfe68b0ae73276e4d9e979d9a4638bd6d7 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 12 Jun 2026 18:30:17 -0700 Subject: [PATCH 3/3] ci(migrations): restore dev schema-push TTY rename/drop guard dropped by #5010 revert --- .github/workflows/migrations.yml | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/migrations.yml b/.github/workflows/migrations.yml index ea5ca453968..7be6e56b32d 100644 --- a/.github/workflows/migrations.yml +++ b/.github/workflows/migrations.yml @@ -69,7 +69,18 @@ jobs: if [ "${ENVIRONMENT}" = "dev" ]; then echo "Dev environment — pushing schema directly (db:push)" - bun run db:push --force + # `--force` only suppresses the data-loss confirm, not drizzle's + # rename-vs-drop prompt, which fires (and crashes, no TTY) when a + # diff both adds and drops tables/columns at once. Turn that opaque + # crash into an actionable failure instead of a bare stack trace. + push_output="$(bun run db:push --force 2>&1)" && push_status=0 || push_status=$? + echo "$push_output" + if [ "$push_status" -ne 0 ]; then + if printf '%s' "$push_output" | grep -q 'Interactive prompts require a TTY'; then + 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." + fi + exit "$push_status" + fi else echo "Applying versioned migrations (db:migrate)" bun run ./scripts/migrate.ts