@@ -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+
3950vi . 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'
6274import { type EmailType , hasEmailService , sendBatchEmails , sendEmail } from './mailer'
6375import { 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
0 commit comments