@@ -717,8 +717,7 @@ async function collectHeaderMatches(
717717 if ( score > 0 ) matches . push ( { score, hit : toHit ( msg ) } ) ;
718718 return ++ scanned < MAX_AGGREGATE_SCAN ;
719719 } ) ;
720- const strong = matches . filter ( ( m ) => m . score >= minTermMatches ( terms ) ) ;
721- const kept = strong . length > 0 ? strong : matches ;
720+ const kept = keepBestMatches ( matches , terms ) ;
722721 kept . sort ( ( a , b ) => b . score - a . score || b . hit . date . localeCompare ( a . hit . date ) ) ;
723722 return {
724723 hits : kept . slice ( 0 , cap ) . map ( ( m ) => m . hit ) ,
@@ -727,6 +726,15 @@ async function collectHeaderMatches(
727726 } ;
728727}
729728
729+ /**
730+ * Keep the matches scoring at least {@link minTermMatches}; if none does, fall back to every match with
731+ * at least one term, so a header fallback never comes back empty while some header mentions a term.
732+ */
733+ function keepBestMatches < T extends { score : number } > ( matches : T [ ] , terms : string [ ] ) : T [ ] {
734+ const strong = matches . filter ( ( m ) => m . score >= minTermMatches ( terms ) ) ;
735+ return strong . length > 0 ? strong : matches ;
736+ }
737+
730738/** ISO timestamp at minute precision ("2026-09-11T09:36Z"); seconds cost tokens but add no meaning. */
731739function compactDate ( date : Date | string | number | undefined ) : string {
732740 return date ? `${ new Date ( date ) . toISOString ( ) . slice ( 0 , 16 ) } Z` : "" ;
@@ -982,24 +990,30 @@ async function handleAggregateMessages(
982990 if ( folderRef ?. folderId ) queryInfo . folderId = folderRef . folderId ;
983991
984992 const countMatches = async ( info : QueryInfo , terms : string [ ] | null , timeoutMs : number ) => {
985- const counts = new Map < string , number > ( ) ;
986- let scanned = 0 ;
993+ // With terms, headers are scored like search_messages' fallback and only the kept ones are counted.
994+ const matches : Array < { score : number ; msg : browser . messages . MessageHeader } > = [ ] ;
987995 let reachedCap = false ;
988996 const { complete, timedOut } = await scanHeaders ( info , abortSignal , timeoutMs , ( msg ) => {
989997 if ( filters . subjectFilter && ! ( msg . subject ?? "" ) . toLowerCase ( ) . includes ( filters . subjectFilter ) ) return true ;
998+ let score = 1 ;
990999 if ( terms ) {
9911000 const text = headerText ( msg ) ;
992- if ( terms . filter ( ( t ) => text . includes ( t ) ) . length < minTermMatches ( terms ) ) return true ;
1001+ score = terms . filter ( ( t ) => text . includes ( t ) ) . length ;
1002+ if ( score === 0 ) return true ;
9931003 }
994- scanned ++ ;
1004+ matches . push ( { score, msg } ) ;
1005+ reachedCap = matches . length >= MAX_AGGREGATE_SCAN ;
1006+ return ! reachedCap ;
1007+ } ) ;
1008+ const counted = terms ? keepBestMatches ( matches , terms ) : matches ;
1009+ const counts = new Map < string , number > ( ) ;
1010+ for ( const { msg } of counted ) {
9951011 for ( const key of groupKeys ( msg , groupBy ) ) {
9961012 counts . set ( key , ( counts . get ( key ) ?? 0 ) + 1 ) ;
9971013 }
998- reachedCap = scanned >= MAX_AGGREGATE_SCAN ;
999- return ! reachedCap ;
1000- } ) ;
1014+ }
10011015 // A failed or stalled page flags the scan as capped so a partial aggregate is not reported as complete.
1002- return { counts, scanned, capped : reachedCap || ! complete , timedOut } ;
1016+ return { counts, scanned : counted . length , capped : reachedCap || ! complete , timedOut } ;
10031017 } ;
10041018
10051019 // Like search_messages, a stalled full-text aggregate falls back to matching terms against headers.
0 commit comments