44using NetMailArchiver . Models ;
55using NToastNotify ;
66using System . IO . Compression ;
7+ using System . Text . RegularExpressions ;
78
89namespace NetMailArchiver . Web . Pages . Archive
910{
@@ -37,6 +38,7 @@ public JsonResult OnGetMails([FromQuery] Guid ImapId, [FromQuery] int page, [Fro
3738
3839 if ( searchBody )
3940 {
41+ // First, do a broad database search (includes HTML)
4042 emailsQuery = emailsQuery . Where ( e =>
4143 ( e . Subject != null && e . Subject . ToLower ( ) . Contains ( searchLower ) ) ||
4244 ( e . From != null && e . From . ToLower ( ) . Contains ( searchLower ) ) ||
@@ -50,25 +52,75 @@ public JsonResult OnGetMails([FromQuery] Guid ImapId, [FromQuery] int page, [Fro
5052 }
5153 }
5254
53- var emails = emailsQuery
54- . OrderByDescending ( x => x . Date )
55- . Skip ( ( page - 1 ) * pageSize )
56- . Take ( pageSize )
57- . Select ( x => new
55+ List < dynamic > emails ;
56+ int fetchMultiplier = 1 ;
57+ int maxAttempts = 5 ;
58+
59+ // Keep fetching until we have enough results after filtering
60+ do
61+ {
62+ var fetchSize = pageSize * fetchMultiplier ;
63+ var skipSize = ( page - 1 ) * pageSize ;
64+
65+ emails = emailsQuery
66+ . OrderByDescending ( x => x . Date )
67+ . Skip ( skipSize )
68+ . Take ( fetchSize )
69+ . Select ( x => new
70+ {
71+ x . Id ,
72+ Date = x . Date . ToString ( "yyyy-MM-dd HH:mm" ) ,
73+ x . From ,
74+ x . Subject ,
75+ x . IsFavorite ,
76+ x . IsFollowUp ,
77+ x . HtmlBody ,
78+ Attachments = x . Attachments . Select ( a => new { a . Id } )
79+ } )
80+ . ToList < dynamic > ( ) ;
81+
82+ // If searching in body, filter out results where the search term is only in HTML tags/CSS
83+ if ( ! string . IsNullOrEmpty ( searchQuery ) && searchBody )
5884 {
59- x . Id ,
60- Date = x . Date . ToString ( "yyyy-MM-dd HH:mm" ) ,
61- x . From ,
62- x . Subject ,
63- x . IsFavorite ,
64- x . IsFollowUp ,
65- Attachments = x . Attachments . Select ( a => new { a . Id } )
66- } ) . ToList ( ) ;
85+ var searchLower = searchQuery . ToLower ( ) ;
86+ emails = emails . Where ( x =>
87+ {
88+ // Check if term is in subject or from
89+ if ( ( x . Subject != null && x . Subject . ToLower ( ) . Contains ( searchLower ) ) ||
90+ ( x . From != null && x . From . ToLower ( ) . Contains ( searchLower ) ) )
91+ {
92+ return true ;
93+ }
94+
95+ // Check if term is in cleaned body text (not just in HTML tags)
96+ var cleanedBody = CleanHtml ( x . HtmlBody ) ;
97+ return cleanedBody . ToLower ( ) . Contains ( searchLower ) ;
98+ } ) . ToList ( ) ;
99+ }
100+
101+ fetchMultiplier ++ ;
102+
103+ } while ( emails . Count < pageSize && emails . Count < emailsQuery . Count ( ) && fetchMultiplier <= maxAttempts ) ;
104+
105+ // Take only the requested page size
106+ emails = emails . Take ( pageSize ) . ToList ( ) ;
107+
108+ var emailsWithPreview = emails . Select ( x => new
109+ {
110+ x . Id ,
111+ x . Date ,
112+ x . From ,
113+ Subject = ! string . IsNullOrWhiteSpace ( searchQuery ) ? HighlightSearchTerm ( x . Subject , searchQuery ) : x . Subject ,
114+ x . IsFavorite ,
115+ x . IsFollowUp ,
116+ Preview = GetSearchPreview ( x . HtmlBody , searchQuery , searchBody , 150 ) ,
117+ x . Attachments
118+ } ) . ToList ( ) ;
67119
68120 var totalEmails = emailsQuery . Count ( ) ;
69121 var hasMorePages = ( page * pageSize ) < totalEmails ;
70122
71- return new JsonResult ( new { emails , hasMorePages } ) ;
123+ return new JsonResult ( new { emails = emailsWithPreview , hasMorePages } ) ;
72124 }
73125
74126 public IActionResult OnGetOpenEmail ( Guid emailId )
@@ -176,6 +228,149 @@ public JsonResult OnPostToggleFollowUp([FromBody] ToggleRequest request)
176228 return new JsonResult ( new { isFollowUp = false } ) ;
177229 }
178230
231+ private string GetTextPreview ( string ? htmlBody , int maxLength = 100 )
232+ {
233+ if ( string . IsNullOrWhiteSpace ( htmlBody ) )
234+ return string . Empty ;
235+
236+ var text = htmlBody ;
237+
238+ // Remove script tags and their content
239+ text = Regex . Replace ( text , @"<script[^>]*>[\s\S]*?</script>" , string . Empty , RegexOptions . IgnoreCase ) ;
240+
241+ // Remove style tags and their content
242+ text = Regex . Replace ( text , @"<style[^>]*>[\s\S]*?</style>" , string . Empty , RegexOptions . IgnoreCase ) ;
243+
244+ // Remove head tag and its content
245+ text = Regex . Replace ( text , @"<head[^>]*>[\s\S]*?</head>" , string . Empty , RegexOptions . IgnoreCase ) ;
246+
247+ // Remove HTML comments
248+ text = Regex . Replace ( text , @"<!--[\s\S]*?-->" , string . Empty ) ;
249+
250+ // Remove all HTML tags
251+ text = Regex . Replace ( text , @"<[^>]+>" , string . Empty ) ;
252+
253+ // Decode HTML entities
254+ text = System . Net . WebUtility . HtmlDecode ( text ) ;
255+
256+ // Remove extra whitespace and newlines
257+ text = Regex . Replace ( text , @"\s+" , " " ) . Trim ( ) ;
258+
259+ // Truncate to maxLength
260+ if ( text . Length > maxLength )
261+ {
262+ text = text . Substring ( 0 , maxLength ) + "..." ;
263+ }
264+
265+ return text ;
266+ }
267+
268+ private string GetSearchPreview ( string ? htmlBody , string ? searchQuery , bool searchBody , int maxLength = 150 )
269+ {
270+ if ( string . IsNullOrWhiteSpace ( htmlBody ) )
271+ return string . Empty ;
272+
273+ // Clean HTML to get plain text
274+ var text = CleanHtml ( htmlBody ) ;
275+
276+ // If no search query, return normal preview
277+ if ( string . IsNullOrWhiteSpace ( searchQuery ) )
278+ {
279+ return TruncateText ( text , maxLength ) ;
280+ }
281+
282+ // If searching in body, find and show context around the search term
283+ if ( searchBody )
284+ {
285+ var index = text . IndexOf ( searchQuery , StringComparison . OrdinalIgnoreCase ) ;
286+
287+ if ( index != - 1 )
288+ {
289+ // Calculate context around the found term
290+ var contextBefore = 60 ;
291+ var contextAfter = 60 ;
292+
293+ var start = Math . Max ( 0 , index - contextBefore ) ;
294+ var end = Math . Min ( text . Length , index + searchQuery . Length + contextAfter ) ;
295+ var length = end - start ;
296+
297+ var preview = text . Substring ( start , length ) ;
298+
299+ // Add ellipsis if we're not at the start/end
300+ if ( start > 0 ) preview = "..." + preview ;
301+ if ( end < text . Length ) preview = preview + "..." ;
302+
303+ // Highlight the search term
304+ preview = HighlightSearchTerm ( preview , searchQuery ) ;
305+
306+ return preview ;
307+ }
308+ }
309+
310+ // If not searching in body or term not found, return normal preview with highlighting
311+ var normalPreview = TruncateText ( text , maxLength ) ;
312+
313+ // Still highlight the search term if it appears in the preview
314+ return HighlightSearchTerm ( normalPreview , searchQuery ) ;
315+ }
316+
317+ private string CleanHtml ( string ? htmlBody )
318+ {
319+ if ( string . IsNullOrWhiteSpace ( htmlBody ) )
320+ return string . Empty ;
321+
322+ var text = htmlBody ;
323+
324+ // Remove script tags and their content
325+ text = Regex . Replace ( text , @"<script[^>]*>[\s\S]*?</script>" , string . Empty , RegexOptions . IgnoreCase ) ;
326+
327+ // Remove style tags and their content
328+ text = Regex . Replace ( text , @"<style[^>]*>[\s\S]*?</style>" , string . Empty , RegexOptions . IgnoreCase ) ;
329+
330+ // Remove head tag and its content
331+ text = Regex . Replace ( text , @"<head[^>]*>[\s\S]*?</head>" , string . Empty , RegexOptions . IgnoreCase ) ;
332+
333+ // Remove HTML comments
334+ text = Regex . Replace ( text , @"<!--[\s\S]*?-->" , string . Empty ) ;
335+
336+ // Remove all HTML tags
337+ text = Regex . Replace ( text , @"<[^>]+>" , string . Empty ) ;
338+
339+ // Decode HTML entities
340+ text = System . Net . WebUtility . HtmlDecode ( text ) ;
341+
342+ // Remove extra whitespace and newlines
343+ text = Regex . Replace ( text , @"\s+" , " " ) . Trim ( ) ;
344+
345+ return text ;
346+ }
347+
348+ private string TruncateText ( string text , int maxLength )
349+ {
350+ if ( text . Length > maxLength )
351+ {
352+ return text . Substring ( 0 , maxLength ) + "..." ;
353+ }
354+ return text ;
355+ }
356+
357+ private string HighlightSearchTerm ( string ? text , string ? searchQuery )
358+ {
359+ if ( string . IsNullOrWhiteSpace ( text ) || string . IsNullOrWhiteSpace ( searchQuery ) )
360+ return text ?? string . Empty ;
361+
362+ // Use regex for case-insensitive replacement and preserve original case
363+ var pattern = Regex . Escape ( searchQuery ) ;
364+ var highlighted = Regex . Replace (
365+ text ,
366+ pattern ,
367+ match => $ "<mark class='search-highlight'>{ match . Value } </mark>",
368+ RegexOptions . IgnoreCase
369+ ) ;
370+
371+ return highlighted ;
372+ }
373+
179374 public class ToggleRequest
180375 {
181376 public Guid EmailId { get ; set ; }
0 commit comments