mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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 2c0a10a561.
* 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
This commit is contained in:
@@ -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'))
|
||||
|
||||
|
||||
@@ -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<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):', {
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user