Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 1 addition & 10 deletions apps/sim/lib/auth/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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({
Expand Down
10 changes: 0 additions & 10 deletions apps/sim/lib/messaging/email/disposable-domains.d.ts

This file was deleted.

35 changes: 0 additions & 35 deletions apps/sim/lib/messaging/email/disposable-domains.server.test.ts

This file was deleted.

32 changes: 0 additions & 32 deletions apps/sim/lib/messaging/email/disposable-domains.server.ts

This file was deleted.

57 changes: 57 additions & 0 deletions apps/sim/lib/messaging/email/mailer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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'

Expand All @@ -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')

Expand Down Expand Up @@ -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'))

Expand Down Expand Up @@ -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'))

Expand Down
47 changes: 42 additions & 5 deletions apps/sim/lib/messaging/email/mailer.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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<EmailOptions | null> {
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<SendEmailResult> {
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):', {
Expand Down Expand Up @@ -96,10 +129,14 @@ async function prepareBatch(emails: EmailOptions[]): Promise<PreparedBatchEntry[
return Promise.all(
emails.map(async (email, index): Promise<PreparedBatchEntry> => {
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,
Expand Down
1 change: 0 additions & 1 deletion apps/sim/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 0 additions & 3 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading