From fb9e481f32a0fec623841c9d13263c26bcb85189 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 12 Jun 2026 18:39:33 -0700 Subject: [PATCH] feat(mailer): gate outbound email on AppConfig access-control ban list (#5018) * Revert "improvement(auth): layer disposable-email-domains into signup email validation (#5010)" This reverts commit 2c0a10a5614f45debbbcc4a4d313a9793ce65950. * feat(mailer): gate outbound email on AppConfig access-control ban list * ci(migrations): restore dev schema-push TTY rename/drop guard dropped by #5010 revert --- 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 515dcff6c4..53da5a8f4e 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 a285ecc71e..6b2dbbc84a 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,