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:
Theodore Li
2026-06-12 21:39:33 -04:00
committed by GitHub
parent 98948c0d9d
commit fb9e481f32
2 changed files with 99 additions and 5 deletions
@@ -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'))
+42 -5
View File
@@ -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,