Skip to content

Commit 255ee77

Browse files
committed
Add email preview and search term highlighting
1 parent b395010 commit 255ee77

3 files changed

Lines changed: 223 additions & 15 deletions

File tree

NetMailArchiver.Web/Pages/Archive/Index.cshtml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,10 @@
105105
? '<i class="fas fa-flag text-danger"></i>'
106106
: '<i class="far fa-flag text-muted"></i>';
107107
108-
$tableBody.append('<tr><td>' + email.date + '</td><td>' + email.from + '</td><td>' + email.subject + '</td>' +
108+
const subjectWithPreview = email.subject +
109+
(email.preview ? '<br><small class="text-muted">' + email.preview + '</small>' : '');
110+
111+
$tableBody.append('<tr><td>' + email.date + '</td><td>' + email.from + '</td><td>' + subjectWithPreview + '</td>' +
109112
'<td class="text-center">' +
110113
'<button class="btn btn-sm btn-link p-1 toggleFavorite" data-email-id="' + email.id + '" title="Favorite">' + favoriteIcon + '</button>' +
111114
'<button class="btn btn-sm btn-link p-1 toggleFollowUp" data-email-id="' + email.id + '" title="Follow Up">' + followUpIcon + '</button>' +

NetMailArchiver.Web/Pages/Archive/Index.cshtml.cs

Lines changed: 209 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using NetMailArchiver.Models;
55
using NToastNotify;
66
using System.IO.Compression;
7+
using System.Text.RegularExpressions;
78

89
namespace 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; }

NetMailArchiver.Web/wwwroot/css/site.css

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,16 @@ h1, h2, h3, h4, h5, h6 {
259259
filter: invert(1);
260260
}
261261

262+
/* Search highlighting */
263+
.search-highlight {
264+
background-color: #fff3cd;
265+
color: #856404;
266+
padding: 2px 4px;
267+
border-radius: 3px;
268+
font-weight: 600;
269+
box-shadow: 0 0 0 2px rgba(255, 193, 7, 0.3);
270+
}
271+
262272
/* Accessibility improvements */
263273
@media (prefers-reduced-motion: reduce) {
264274
*,

0 commit comments

Comments
 (0)