Skip to content

Commit fb9e481

Browse files
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 2c0a10a. * 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
1 parent 98948c0 commit fb9e481

2 files changed

Lines changed: 99 additions & 5 deletions

File tree

apps/sim/lib/messaging/email/mailer.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,17 @@ vi.mock('@/lib/messaging/email/unsubscribe', () => ({
3636
generateUnsubscribeToken: vi.fn(),
3737
}))
3838

39+
vi.mock('@/lib/auth/access-control', () => ({
40+
getAccessControlConfig: vi.fn().mockResolvedValue({
41+
blockedSignupDomains: [],
42+
blockedEmails: [],
43+
allowedLoginEmails: [],
44+
allowedLoginDomains: [],
45+
blockedEmailMxHosts: [],
46+
}),
47+
isEmailBlockedByAccessControl: vi.fn().mockReturnValue(false),
48+
}))
49+
3950
vi.mock('@/lib/core/config/env', () =>
4051
createEnvMock({
4152
RESEND_API_KEY: 'test-api-key',
@@ -59,6 +70,7 @@ vi.mock('@/lib/messaging/email/utils', () => ({
5970
NO_EMAIL_HEADER_CONTROL_CHARS_REGEX: /^[^\r\n]*$/,
6071
}))
6172

73+
import { isEmailBlockedByAccessControl } from '@/lib/auth/access-control'
6274
import { type EmailType, hasEmailService, sendBatchEmails, sendEmail } from './mailer'
6375
import { generateUnsubscribeToken, isUnsubscribed } from './unsubscribe'
6476

@@ -71,6 +83,7 @@ describe('mailer', () => {
7183

7284
beforeEach(() => {
7385
vi.clearAllMocks()
86+
;(isEmailBlockedByAccessControl as Mock).mockReturnValue(false)
7487
;(isUnsubscribed as Mock).mockResolvedValue(false)
7588
;(generateUnsubscribeToken as Mock).mockReturnValue('mock-token-123')
7689

@@ -195,6 +208,36 @@ describe('mailer', () => {
195208
expect(isUnsubscribed).toHaveBeenCalledWith('user1@example.com', 'marketing')
196209
})
197210

211+
it('should skip sending when the recipient is on the ban list', async () => {
212+
;(isEmailBlockedByAccessControl as Mock).mockReturnValue(true)
213+
214+
const result = await sendEmail({
215+
...testEmailOptions,
216+
emailType: 'transactional',
217+
})
218+
219+
expect(result.success).toBe(true)
220+
expect(result.message).toBe('Email skipped (recipient on access-control ban list)')
221+
expect(result.data).toEqual({ id: 'skipped-banned' })
222+
expect(mockSend).not.toHaveBeenCalled()
223+
expect(isUnsubscribed).not.toHaveBeenCalled()
224+
})
225+
226+
it('should drop only the banned recipients from a multi-recipient send', async () => {
227+
;(isEmailBlockedByAccessControl as Mock).mockImplementation(
228+
(email: string) => email === 'banned@example.com'
229+
)
230+
231+
const result = await sendEmail({
232+
...testEmailOptions,
233+
to: ['good@example.com', 'banned@example.com'],
234+
emailType: 'transactional',
235+
})
236+
237+
expect(result.success).toBe(true)
238+
expect(mockSend).toHaveBeenCalledWith(expect.objectContaining({ to: 'good@example.com' }))
239+
})
240+
198241
it('should handle general exceptions gracefully', async () => {
199242
;(isUnsubscribed as Mock).mockRejectedValue(new Error('Database connection failed'))
200243

@@ -256,6 +299,20 @@ describe('mailer', () => {
256299
expect(isUnsubscribed).not.toHaveBeenCalled()
257300
})
258301

302+
it('should skip banned recipients in a batch', async () => {
303+
;(isEmailBlockedByAccessControl as Mock).mockImplementation(
304+
(email: string) => email === 'user2@example.com'
305+
)
306+
307+
const result = await sendBatchEmails({ emails: testBatchEmails })
308+
309+
expect(result.results).toHaveLength(2)
310+
const bannedEntry = result.results.find(
311+
(r) => r.message === 'Email skipped (recipient on access-control ban list)'
312+
)
313+
expect(bannedEntry).toBeDefined()
314+
})
315+
259316
it('should degrade isUnsubscribed rejections to per-entry failures', async () => {
260317
;(isUnsubscribed as Mock).mockRejectedValue(new Error('Database connection failed'))
261318

apps/sim/lib/messaging/email/mailer.ts

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createLogger } from '@sim/logger'
22
import { getErrorMessage } from '@sim/utils/errors'
3+
import { getAccessControlConfig, isEmailBlockedByAccessControl } from '@/lib/auth/access-control'
34
import { processEmailData, shouldSkipForUnsubscribe } from '@/lib/messaging/email/prepare'
45
import { activeProviders } from '@/lib/messaging/email/providers'
56
import type {
@@ -36,22 +37,54 @@ const MOCK_EMAIL_RESULT: SendEmailResult = {
3637
data: { id: 'mock-email-id' },
3738
}
3839

40+
const SKIPPED_BANNED_RESULT: SendEmailResult = {
41+
success: true,
42+
message: 'Email skipped (recipient on access-control ban list)',
43+
data: { id: 'skipped-banned' },
44+
}
45+
3946
export function hasEmailService(): boolean {
4047
return activeProviders.length > 0
4148
}
4249

50+
/**
51+
* Drop recipients that are on the AppConfig access-control ban list. Returns the
52+
* original options when nothing is banned, options narrowed to the allowed
53+
* recipients when some are, or `null` when every recipient is banned. Config is
54+
* cached (~30s TTL) with an env fallback, so a missing/unreachable AppConfig
55+
* fails open rather than blocking all mail.
56+
*/
57+
async function applyBanList(options: EmailOptions): Promise<EmailOptions | null> {
58+
const recipients = Array.isArray(options.to) ? options.to : [options.to]
59+
const config = await getAccessControlConfig()
60+
const allowed = recipients.filter((email) => !isEmailBlockedByAccessControl(email, config))
61+
if (allowed.length === 0) return null
62+
if (allowed.length === recipients.length) return options
63+
return { ...options, to: allowed.length === 1 ? allowed[0] : allowed }
64+
}
65+
4366
export async function sendEmail(options: EmailOptions): Promise<SendEmailResult> {
4467
try {
45-
if (await shouldSkipForUnsubscribe(options)) {
46-
logger.info('Email not sent (user unsubscribed):', {
68+
const allowed = await applyBanList(options)
69+
if (!allowed) {
70+
logger.info('Email not sent (recipient on access-control ban list):', {
4771
to: options.to,
4872
subject: options.subject,
4973
emailType: options.emailType,
5074
})
75+
return SKIPPED_BANNED_RESULT
76+
}
77+
78+
if (await shouldSkipForUnsubscribe(allowed)) {
79+
logger.info('Email not sent (user unsubscribed):', {
80+
to: allowed.to,
81+
subject: allowed.subject,
82+
emailType: allowed.emailType,
83+
})
5184
return SKIPPED_UNSUBSCRIBED_RESULT
5285
}
5386

54-
const data = processEmailData(options)
87+
const data = processEmailData(allowed)
5588

5689
if (activeProviders.length === 0) {
5790
logger.info('Email not sent (no email service configured):', {
@@ -96,10 +129,14 @@ async function prepareBatch(emails: EmailOptions[]): Promise<PreparedBatchEntry[
96129
return Promise.all(
97130
emails.map(async (email, index): Promise<PreparedBatchEntry> => {
98131
try {
99-
if (await shouldSkipForUnsubscribe(email)) {
132+
const allowed = await applyBanList(email)
133+
if (!allowed) {
134+
return { index, data: null, skippedResult: SKIPPED_BANNED_RESULT }
135+
}
136+
if (await shouldSkipForUnsubscribe(allowed)) {
100137
return { index, data: null, skippedResult: SKIPPED_UNSUBSCRIBED_RESULT }
101138
}
102-
return { index, data: processEmailData(email), skippedResult: null }
139+
return { index, data: processEmailData(allowed), skippedResult: null }
103140
} catch (error) {
104141
return {
105142
index,

0 commit comments

Comments
 (0)