@@ -327,4 +327,172 @@ describe('Bounces', () => {
327327 } ) ;
328328 } ) ;
329329 } ) ;
330+
331+ describe ( 'checkBouncesWithAliases' , ( ) => {
332+ const aliasNormalizationConfig = JSON . stringify ( [
333+ { domain : 'example.com' , regex : '\\+.*' , replace : '' } ,
334+ ] ) ;
335+
336+ it ( 'uses regular check when aliasCheckEnabled is false' , async ( ) => {
337+ const config : BouncesConfig = {
338+ ...defaultBouncesConfig ,
339+ aliasCheckEnabled : false ,
340+ emailAliasNormalization : aliasNormalizationConfig ,
341+ } ;
342+ const db : BounceDb = {
343+ emailBounces : {
344+ findByEmail : jest . fn ( ) . mockResolvedValue ( [ ] ) ,
345+ } ,
346+ } ;
347+ const bounces = new Bounces ( config , db ) ;
348+
349+ await bounces . check ( '[email protected] ' , 'verifyEmail' ) ; 350+
351+ // Should only call once with the original email
352+ expect ( db . emailBounces . findByEmail ) . toHaveBeenCalledTimes ( 1 ) ;
353+ expect ( db . emailBounces . findByEmail ) . toHaveBeenCalledWith (
354+ 355+ ) ;
356+ } ) ;
357+
358+ it ( 'queries both normalized and wildcard emails when aliasCheckEnabled is true' , async ( ) => {
359+ const config : BouncesConfig = {
360+ ...defaultBouncesConfig ,
361+ aliasCheckEnabled : true ,
362+ emailAliasNormalization : aliasNormalizationConfig ,
363+ } ;
364+ const db : BounceDb = {
365+ emailBounces : {
366+ findByEmail : jest . fn ( ) . mockResolvedValue ( [ ] ) ,
367+ } ,
368+ } ;
369+ const bounces = new Bounces ( config , db ) ;
370+
371+ await bounces . check ( '[email protected] ' , 'verifyEmail' ) ; 372+
373+ // Should call twice: once for normalized, once for wildcard
374+ expect ( db . emailBounces . findByEmail ) . toHaveBeenCalledTimes ( 2 ) ;
375+ expect ( db . emailBounces . findByEmail ) . toHaveBeenCalledWith (
376+ 377+ ) ;
378+ expect ( db . emailBounces . findByEmail ) . toHaveBeenCalledWith (
379+ 'test+%@example.com'
380+ ) ;
381+ } ) ;
382+
383+ it ( 'throws error when alias bounces exceed threshold' , async ( ) => {
384+ const config : BouncesConfig = {
385+ ...defaultBouncesConfig ,
386+ aliasCheckEnabled : true ,
387+ emailAliasNormalization : aliasNormalizationConfig ,
388+ hard : { 0 : daysInMs ( 30 ) } ,
389+ } ;
390+ const bouncedAt = mockNow - daysInMs ( 10 ) ;
391+ const db : BounceDb = {
392+ emailBounces : {
393+ findByEmail : jest . fn ( ) . mockImplementation ( ( email : string ) => {
394+ if ( email === '[email protected] ' ) { 395+ return Promise . resolve ( [
396+ {
397+ 398+ bounceType : BOUNCE_TYPE_HARD ,
399+ createdAt : bouncedAt ,
400+ } ,
401+ ] ) ;
402+ }
403+ return Promise . resolve ( [ ] ) ;
404+ } ) ,
405+ } ,
406+ } ;
407+ const bounces = new Bounces ( config , db ) ;
408+
409+ // Email with alias should fail because root email has a bounce
410+ await expect (
411+ bounces . check ( '[email protected] ' , 'verifyEmail' ) 412+ ) . rejects . toMatchObject ( AppError . emailBouncedHard ( bouncedAt ) ) ;
413+ } ) ;
414+
415+ it ( 'merges and deduplicates bounces from both queries' , async ( ) => {
416+ const config : BouncesConfig = {
417+ ...defaultBouncesConfig ,
418+ aliasCheckEnabled : true ,
419+ emailAliasNormalization : aliasNormalizationConfig ,
420+ hard : { 2 : daysInMs ( 30 ) } , // Allow 2 bounces before throwing
421+ } ;
422+ const bounce1At = mockNow - daysInMs ( 5 ) ;
423+ const bounce2At = mockNow - daysInMs ( 10 ) ;
424+ const duplicateBounceAt = mockNow - daysInMs ( 15 ) ;
425+
426+ const db : BounceDb = {
427+ emailBounces : {
428+ findByEmail : jest . fn ( ) . mockImplementation ( ( email : string ) => {
429+ if ( email === '[email protected] ' ) { 430+ return Promise . resolve ( [
431+ {
432+ 433+ bounceType : BOUNCE_TYPE_HARD ,
434+ createdAt : bounce1At ,
435+ } ,
436+ {
437+ 438+ bounceType : BOUNCE_TYPE_HARD ,
439+ createdAt : duplicateBounceAt ,
440+ } ,
441+ ] ) ;
442+ }
443+ if ( email === 'test+%@example.com' ) {
444+ return Promise . resolve ( [
445+ {
446+ 447+ bounceType : BOUNCE_TYPE_HARD ,
448+ createdAt : bounce2At ,
449+ } ,
450+ // Duplicate entry (same email and createdAt as normalized query)
451+ {
452+ 453+ bounceType : BOUNCE_TYPE_HARD ,
454+ createdAt : duplicateBounceAt ,
455+ } ,
456+ ] ) ;
457+ }
458+ return Promise . resolve ( [ ] ) ;
459+ } ) ,
460+ } ,
461+ } ;
462+ const bounces = new Bounces ( config , db ) ;
463+
464+ // Should throw because we have 3 unique hard bounces (one duplicate removed)
465+ await expect (
466+ bounces . check ( '[email protected] ' , 'verifyEmail' ) 467+ ) . rejects . toMatchObject ( AppError . emailBouncedHard ( bounce1At ) ) ;
468+ } ) ;
469+
470+ it ( 'does not apply alias normalization for domains not in config' , async ( ) => {
471+ const config : BouncesConfig = {
472+ ...defaultBouncesConfig ,
473+ aliasCheckEnabled : true ,
474+ emailAliasNormalization : aliasNormalizationConfig , // Only example.com configured
475+ } ;
476+ const db : BounceDb = {
477+ emailBounces : {
478+ findByEmail : jest . fn ( ) . mockResolvedValue ( [ ] ) ,
479+ } ,
480+ } ;
481+ const bounces = new Bounces ( config , db ) ;
482+
483+ await bounces . check ( '[email protected] ' , 'verifyEmail' ) ; 484+
485+ // For non-configured domain, both queries should use the original email
486+ // (no transformation applied)
487+ expect ( db . emailBounces . findByEmail ) . toHaveBeenCalledTimes ( 2 ) ;
488+ expect ( db . emailBounces . findByEmail ) . toHaveBeenNthCalledWith (
489+ 1 ,
490+ 491+ ) ;
492+ expect ( db . emailBounces . findByEmail ) . toHaveBeenNthCalledWith (
493+ 2 ,
494+ 495+ ) ;
496+ } ) ;
497+ } ) ;
330498} ) ;
0 commit comments