@@ -5,6 +5,20 @@ const MAX_TOTAL_RECIPIENTS = 50
55const MAX_ATTACHMENTS = 20
66const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024
77const MAX_TOTAL_ATTACHMENT_BYTES = 40 * 1024 * 1024
8+ const DEFAULT_GLOBAL_DAILY_SEND_LIMIT = 200
9+ const DEFAULT_NEW_MAILBOX_SEND_LIMIT = 5
10+ const DEFAULT_NEW_MAILBOX_SEND_WINDOW_HOURS = 24
11+
12+ const KNOWN_ABUSE_SUBJECTS = new Set ( [
13+ 'action required: verify your account' ,
14+ 'payment required for your account' ,
15+ 'your account requires attention' ,
16+ 'your account is past due' ,
17+ 'important: account update needed' ,
18+ 'security alert: account activity' ,
19+ 'notice: account status change' ,
20+ 'urgent: account verification required' ,
21+ ] )
822
923export async function handleSend ( request : Request , env : Env , mailbox ?: string , ctx ?: ExecutionContext ) : Promise < Response > {
1024 if ( ! env . RESEND_API_KEY ) {
@@ -172,6 +186,27 @@ export async function handleSend(request: Request, env: Env, mailbox?: string, c
172186 // Check suppression list for all recipients (fail-closed: reject if check fails)
173187 // Normalize to lowercase: suppression_list stores lowercase, but senders may use mixed case
174188 const allRecipients = [ ...body . to , ...( body . cc ?? [ ] ) , ...( body . bcc ?? [ ] ) ] . map ( e => e . toLowerCase ( ) )
189+
190+ if ( mailbox ) {
191+ try {
192+ const abuseResult = await checkOutboundAbuseGuard ( env , mailbox , {
193+ subject : body . subject ,
194+ text : body . text ?? '' ,
195+ html : body . html ?? '' ,
196+ recipients : allRecipients ,
197+ } )
198+ if ( abuseResult ) {
199+ return Response . json ( { error : abuseResult } , { status : 400 } )
200+ }
201+ } catch ( err ) {
202+ console . error ( 'Outbound abuse guard failed, rejecting send:' , err )
203+ return Response . json (
204+ { error : 'Unable to verify message safety, please try again' } ,
205+ { status : 503 } ,
206+ )
207+ }
208+ }
209+
175210 try {
176211 const suppressedCheck = await checkSuppressionList ( env , allRecipients )
177212 if ( suppressedCheck ) {
@@ -404,6 +439,99 @@ export async function checkSuppressionList(
404439 return row ?? null
405440}
406441
442+ type OutboundAbuseInput = {
443+ subject : string
444+ text : string
445+ html : string
446+ recipients : string [ ]
447+ }
448+
449+ async function checkOutboundAbuseGuard (
450+ env : Env ,
451+ mailbox : string ,
452+ input : OutboundAbuseInput ,
453+ ) : Promise < string | null > {
454+ if ( env . SEND_ABUSE_GUARD_ENABLED === '0' || env . SEND_ABUSE_GUARD_ENABLED === 'false' ) {
455+ return null
456+ }
457+
458+ const risk = scoreOutboundRisk ( input )
459+ if ( risk . score < 3 ) return null
460+
461+ let mailboxCreatedAt : string | null = null
462+ let inboundCount = 0
463+ try {
464+ const tokenRow = await env . DB . prepare (
465+ 'SELECT created_at FROM auth_tokens WHERE mailbox = ? LIMIT 1'
466+ ) . bind ( mailbox ) . first < { created_at : string } > ( )
467+ mailboxCreatedAt = tokenRow ?. created_at ?? null
468+
469+ const inboundRow = await env . DB . prepare (
470+ "SELECT COUNT(*) as count FROM emails WHERE mailbox = ? AND direction = 'inbound'"
471+ ) . bind ( mailbox ) . first < { count : number } > ( )
472+ inboundCount = inboundRow ?. count ?? 0
473+ } catch ( err ) {
474+ const msg = err instanceof Error ? err . message : String ( err )
475+ if ( msg . includes ( 'no such table' ) ) return null
476+ throw err
477+ }
478+
479+ const ageHours = mailboxCreatedAt ? hoursSince ( mailboxCreatedAt ) : null
480+ const newOrUntrustedMailbox =
481+ inboundCount === 0 ||
482+ ageHours === null ||
483+ ageHours < DEFAULT_NEW_MAILBOX_SEND_WINDOW_HOURS
484+
485+ if ( KNOWN_ABUSE_SUBJECTS . has ( input . subject . trim ( ) . toLowerCase ( ) ) && newOrUntrustedMailbox ) {
486+ return 'Message rejected by abuse protection: high-risk account/payment/security template'
487+ }
488+
489+ if ( risk . score >= 5 && newOrUntrustedMailbox ) {
490+ return `Message rejected by abuse protection: ${ risk . flags . slice ( 0 , 3 ) . join ( ', ' ) } `
491+ }
492+
493+ return null
494+ }
495+
496+ function scoreOutboundRisk ( input : OutboundAbuseInput ) : { score : number ; flags : string [ ] } {
497+ const subject = input . subject . trim ( ) . toLowerCase ( )
498+ const body = `${ input . text } \n${ stripHtml ( input . html ) } ` . toLowerCase ( )
499+ const content = `${ subject } \n${ body } `
500+ const flags : string [ ] = [ ]
501+ let score = 0
502+
503+ if ( KNOWN_ABUSE_SUBJECTS . has ( subject ) ) {
504+ flags . push ( 'known abuse subject' )
505+ score += 5
506+ }
507+ if ( / \b ( a c t i o n r e q u i r e d | u r g e n t | i m m e d i a t e a c t i o n | r e q u i r e s a t t e n t i o n ) \b / . test ( subject ) ) {
508+ flags . push ( 'urgency language' )
509+ score += 1
510+ }
511+ if ( / \b ( v e r i f y | v e r i f i c a t i o n | c o n f i r m | u p d a t e ) \b / . test ( content ) && / \b ( a c c o u n t | l o g i n | i d e n t i t y | e m a i l ) \b / . test ( content ) ) {
512+ flags . push ( 'account verification language' )
513+ score += 2
514+ }
515+ if ( / \b ( p a y m e n t r e q u i r e d | p a s t d u e | o v e r d u e | u n p a i d | b i l l i n g i s s u e ) \b / . test ( content ) ) {
516+ flags . push ( 'payment pressure language' )
517+ score += 2
518+ }
519+ if ( / \b ( s e c u r i t y a l e r t | a c c o u n t a c t i v i t y | s t a t u s c h a n g e | s u s p i c i o u s a c t i v i t y ) \b / . test ( content ) ) {
520+ flags . push ( 'security alert impersonation language' )
521+ score += 2
522+ }
523+ if ( / \b ( c l i c k h e r e | s i g n i n | l o g i n | l o g i n | r e s e t p a s s w o r d | u p d a t e y o u r a c c o u n t ) \b / . test ( content ) && / h t t p s ? : \/ \/ / . test ( content ) ) {
524+ flags . push ( 'credential-action link' )
525+ score += 2
526+ }
527+ if ( input . recipients . length > 10 ) {
528+ flags . push ( 'bulk recipient fanout' )
529+ score += 1
530+ }
531+
532+ return { score, flags }
533+ }
534+
407535/**
408536 * Check daily send rate limit for a mailbox using atomic increment-then-verify.
409537 * Increments the counter first (atomic), then checks the new count against the limit.
@@ -415,6 +543,12 @@ async function checkDailySendLimit(env: Env, mailbox: string): Promise<string |
415543 try {
416544 const today = new Date ( ) . toISOString ( ) . slice ( 0 , 10 ) // YYYY-MM-DD
417545 const dailyLimit = env . DAILY_SEND_LIMIT ? parseInt ( env . DAILY_SEND_LIMIT as string , 10 ) : 100
546+ const globalDailyLimit = readNonNegativeInt ( env . GLOBAL_DAILY_SEND_LIMIT , DEFAULT_GLOBAL_DAILY_SEND_LIMIT )
547+ const newMailboxLimit = readNonNegativeInt ( env . NEW_MAILBOX_SEND_LIMIT , DEFAULT_NEW_MAILBOX_SEND_LIMIT )
548+ const newMailboxWindowHours = readNonNegativeInt (
549+ env . NEW_MAILBOX_SEND_WINDOW_HOURS ,
550+ DEFAULT_NEW_MAILBOX_SEND_WINDOW_HOURS ,
551+ )
418552
419553 // Atomic increment
420554 await env . DB . prepare (
@@ -434,7 +568,28 @@ async function checkDailySendLimit(env: Env, mailbox: string): Promise<string |
434568 ) . bind ( mailbox , today ) . run ( )
435569 return `Daily send limit reached (${ row . count - 1 } /${ dailyLimit } )`
436570 }
437- // Under limit: increment already applied, no further action needed
571+
572+ if ( row && newMailboxLimit > 0 && row . count > newMailboxLimit ) {
573+ const tokenRow = await env . DB . prepare (
574+ 'SELECT created_at FROM auth_tokens WHERE mailbox = ? LIMIT 1'
575+ ) . bind ( mailbox ) . first < { created_at : string } > ( )
576+ const ageHours = tokenRow ?. created_at ? hoursSince ( tokenRow . created_at ) : null
577+ if ( ageHours === null || ageHours < newMailboxWindowHours ) {
578+ await decrementDailySendCount ( env , mailbox , today )
579+ return `New mailbox send limit reached (${ newMailboxLimit } /${ newMailboxWindowHours } h warmup window)`
580+ }
581+ }
582+
583+ if ( globalDailyLimit > 0 ) {
584+ const globalRow = await env . DB . prepare (
585+ 'SELECT COALESCE(SUM(count), 0) as count FROM daily_send_counts WHERE date = ?'
586+ ) . bind ( today ) . first < { count : number } > ( )
587+ if ( ( globalRow ?. count ?? 0 ) > globalDailyLimit ) {
588+ await decrementDailySendCount ( env , mailbox , today )
589+ return `Global daily send limit reached (${ globalDailyLimit } /day)`
590+ }
591+ }
592+ // Under limits: increment already applied, no further action needed
438593 } catch ( err ) {
439594 // Fail-open: rate limit check failure should not block sending
440595 const msg = err instanceof Error ? err . message : String ( err )
@@ -445,6 +600,12 @@ async function checkDailySendLimit(env: Env, mailbox: string): Promise<string |
445600 return null
446601}
447602
603+ async function decrementDailySendCount ( env : Env , mailbox : string , date : string ) : Promise < void > {
604+ await env . DB . prepare (
605+ 'UPDATE daily_send_counts SET count = count - 1 WHERE mailbox = ? AND date = ?'
606+ ) . bind ( mailbox , date ) . run ( )
607+ }
608+
448609/** @deprecated Rate limit counting is now handled inside checkDailySendLimit atomically. */
449610async function incrementDailySendCount ( env : Env , _mailbox : string ) : Promise < void > {
450611 // No-op: counting is now part of checkDailySendLimit's atomic increment-then-verify
@@ -464,3 +625,20 @@ function base64DecodedLength(value: string): number {
464625 const padding = value . endsWith ( '==' ) ? 2 : value . endsWith ( '=' ) ? 1 : 0
465626 return Math . max ( 0 , Math . floor ( value . length * 3 / 4 ) - padding )
466627}
628+
629+ function stripHtml ( html : string ) : string {
630+ return html . replace ( / < [ ^ > ] * > / g, ' ' )
631+ }
632+
633+ function hoursSince ( value : string ) : number | null {
634+ const normalized = value . includes ( 'T' ) ? value : `${ value . replace ( ' ' , 'T' ) } Z`
635+ const parsed = Date . parse ( normalized )
636+ if ( ! Number . isFinite ( parsed ) ) return null
637+ return Math . max ( 0 , ( Date . now ( ) - parsed ) / 3_600_000 )
638+ }
639+
640+ function readNonNegativeInt ( value : string | undefined , fallback : number ) : number {
641+ if ( ! value ) return fallback
642+ const parsed = Number . parseInt ( value , 10 )
643+ return Number . isFinite ( parsed ) && parsed >= 0 ? parsed : fallback
644+ }
0 commit comments