Improve email load speed in web app (#2138)
This commit is contained in:
@@ -166,6 +166,11 @@
|
||||
/// </summary>
|
||||
private string EmailBody = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The id of the email whose body is currently rendered.
|
||||
/// </summary>
|
||||
private int? _renderedEmailId;
|
||||
|
||||
/// <summary>
|
||||
/// Show confirmation modal before deleting email.
|
||||
/// </summary>
|
||||
@@ -279,6 +284,14 @@
|
||||
{
|
||||
await base.OnParametersSetAsync();
|
||||
|
||||
// Only (re)compute the rendered body when the email actually changes.
|
||||
if (Email?.Id == _renderedEmailId)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_renderedEmailId = Email?.Id;
|
||||
|
||||
if (Email != null)
|
||||
{
|
||||
// Pick an initial view mode based on what content is available.
|
||||
@@ -295,6 +308,10 @@
|
||||
EmailBody = Localizer["NoEmailBody"];
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
EmailBody = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -168,13 +168,20 @@ else
|
||||
|
||||
<!-- Right Panel - Email Preview -->
|
||||
<div class="w-3/4">
|
||||
<EmailPreview
|
||||
Email="SelectedEmail"
|
||||
IsSpamOk="false"
|
||||
OnEmailDeleted="HandleEmailDeleted"
|
||||
CredentialId="@GetCredentialIdForEmail(SelectedEmailId)"
|
||||
CredentialName="@GetCredentialNameForEmail(SelectedEmailId)"
|
||||
OnCredentialClick="NavigateToCredential" />
|
||||
@if (IsPreviewLoading)
|
||||
{
|
||||
<EmailPreviewSkeleton />
|
||||
}
|
||||
else
|
||||
{
|
||||
<EmailPreview
|
||||
Email="SelectedEmail"
|
||||
IsSpamOk="false"
|
||||
OnEmailDeleted="HandleEmailDeleted"
|
||||
CredentialId="@GetCredentialIdForEmail(SelectedEmailId)"
|
||||
CredentialName="@GetCredentialNameForEmail(SelectedEmailId)"
|
||||
OnCredentialClick="NavigateToCredential" />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -195,6 +202,8 @@ else
|
||||
private EmailApiModel EmailModalEmail { get; set; } = new();
|
||||
private int? SelectedEmailId { get; set; }
|
||||
private EmailApiModel? SelectedEmail { get; set; }
|
||||
private bool IsPreviewLoading { get; set; }
|
||||
private int _previewLoadSequence;
|
||||
private bool IsLoadingMore { get; set; }
|
||||
private bool HasMoreEmails => TotalRecords > EmailList.Count;
|
||||
|
||||
@@ -635,8 +644,6 @@ else
|
||||
|
||||
// Highlight the clicked row immediately.
|
||||
SelectedEmailId = emailId;
|
||||
StateHasChanged();
|
||||
await Task.Delay(1);
|
||||
|
||||
await LoadSelectedEmailForPreview(emailId);
|
||||
}
|
||||
@@ -646,9 +653,23 @@ else
|
||||
/// </summary>
|
||||
private async Task LoadSelectedEmailForPreview(int emailId)
|
||||
{
|
||||
var sequence = ++_previewLoadSequence;
|
||||
|
||||
// Show a loading placeholder immediately and yield so the browser can paint the skeleton
|
||||
// before any heavy synchronous work (decrypt/sanitize) blocks the single Blazor WASM thread.
|
||||
SelectedEmail = null;
|
||||
IsPreviewLoading = true;
|
||||
StateHasChanged();
|
||||
await Task.Delay(1);
|
||||
|
||||
try
|
||||
{
|
||||
EmailApiModel? mail = await HttpClient.GetFromJsonAsync<EmailApiModel>($"v1/Email/{emailId}");
|
||||
if (sequence != _previewLoadSequence)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (mail != null)
|
||||
{
|
||||
// Decrypt the email content locally.
|
||||
@@ -659,8 +680,12 @@ else
|
||||
mail = await EmailService.DecryptEmail(mail);
|
||||
}
|
||||
|
||||
if (sequence != _previewLoadSequence)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SelectedEmail = mail;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -668,6 +693,14 @@ else
|
||||
GlobalNotificationService.AddErrorMessage(string.Format(Localizer["LoadEmailsFailedMessage"], ex.Message), true);
|
||||
Logger.LogError(ex, "An error occurred while loading email for preview");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (sequence == _previewLoadSequence)
|
||||
{
|
||||
IsPreviewLoading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="HtmlAgilityPack" Version="1.12.4" />
|
||||
<PackageReference Include="HtmlSanitizer" Version="9.0.892" />
|
||||
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.556">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
|
||||
namespace AliasVault.Shared.Utilities;
|
||||
|
||||
using AngleSharp.Dom;
|
||||
using Ganss.Xss;
|
||||
using HtmlAgilityPack;
|
||||
|
||||
/// <summary>
|
||||
/// Class which contains various helper methods for data conversion.
|
||||
@@ -16,11 +16,9 @@ using HtmlAgilityPack;
|
||||
public static class ConversionUtility
|
||||
{
|
||||
/// <summary>
|
||||
/// Lazy-initialized HTML sanitizer instance configured for safe email viewing.
|
||||
/// Removes all script tags, event handlers, and other XSS vectors while preserving
|
||||
/// safe HTML for email display.
|
||||
/// Factory that creates and configures an <see cref="HtmlSanitizer"/> instance for safe email viewing.
|
||||
/// </summary>
|
||||
private static readonly Lazy<HtmlSanitizer> EmailSanitizer = new(() =>
|
||||
private static readonly Func<HtmlSanitizer> CreateEmailSanitizer = () =>
|
||||
{
|
||||
var sanitizer = new HtmlSanitizer();
|
||||
|
||||
@@ -110,6 +108,36 @@ public static class ConversionUtility
|
||||
sanitizer.AllowedTags.Remove("base");
|
||||
sanitizer.AllowedTags.Remove("applet");
|
||||
|
||||
return sanitizer;
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Lazy-initialized HTML sanitizer instance configured for safe email viewing.
|
||||
/// Removes all script tags, event handlers, and other XSS vectors while preserving
|
||||
/// safe HTML for email display.
|
||||
/// </summary>
|
||||
private static readonly Lazy<HtmlSanitizer> EmailSanitizer = new(CreateEmailSanitizer);
|
||||
|
||||
/// <summary>
|
||||
/// Lazy-initialized HTML sanitizer that, in addition to sanitizing, rewrites anchor tags to
|
||||
/// open in a new tab. This is done as part of the single sanitize pass (via <see cref="HtmlSanitizer.PostProcessNode"/>)
|
||||
/// so that displaying an email only requires parsing the (potentially large) HTML once instead of twice.
|
||||
/// </summary>
|
||||
private static readonly Lazy<HtmlSanitizer> EmailViewSanitizer = new(() =>
|
||||
{
|
||||
var sanitizer = CreateEmailSanitizer();
|
||||
sanitizer.PostProcessNode += (_, e) =>
|
||||
{
|
||||
if (e.Node is IElement element && element.NodeName.Equals("A", StringComparison.OrdinalIgnoreCase) && element.HasAttribute("href"))
|
||||
{
|
||||
element.SetAttribute("target", "_blank");
|
||||
|
||||
var relValues = new HashSet<string>((element.GetAttribute("rel") ?? string.Empty).Split(' ', StringSplitOptions.RemoveEmptyEntries), StringComparer.OrdinalIgnoreCase);
|
||||
relValues.Add("noopener");
|
||||
relValues.Add("noreferrer");
|
||||
element.SetAttribute("rel", string.Join(" ", relValues));
|
||||
}
|
||||
};
|
||||
return sanitizer;
|
||||
});
|
||||
|
||||
@@ -158,68 +186,16 @@ public static class ConversionUtility
|
||||
return html;
|
||||
}
|
||||
|
||||
// First sanitize to remove XSS vectors
|
||||
var sanitizedHtml = SanitizeHtmlForEmailViewing(html);
|
||||
|
||||
// Then convert anchor tags to open in new tab
|
||||
return ConvertAnchorTagsToOpenInNewTab(sanitizedHtml);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert all anchor tags to open in a new tab.
|
||||
/// </summary>
|
||||
/// <param name="html">HTML input.</param>
|
||||
/// <returns>HTML with all anchor tags converted to open in a new tab when clicked on.</returns>
|
||||
/// <remarks>
|
||||
/// Note: same implementation exists in browser extension Typescript version in ConversionUtility.ts.
|
||||
/// </remarks>
|
||||
public static string ConvertAnchorTagsToOpenInNewTab(string html)
|
||||
{
|
||||
try
|
||||
{
|
||||
var doc = new HtmlDocument();
|
||||
doc.LoadHtml(html);
|
||||
|
||||
var anchors = doc.DocumentNode.SelectNodes("//a[@href]");
|
||||
if (anchors != null)
|
||||
{
|
||||
foreach (var anchor in anchors)
|
||||
{
|
||||
var targetAttr = anchor.Attributes["target"];
|
||||
if (targetAttr == null)
|
||||
{
|
||||
anchor.SetAttributeValue("target", "_blank");
|
||||
}
|
||||
else if (targetAttr.Value != "_blank")
|
||||
{
|
||||
targetAttr.Value = "_blank";
|
||||
}
|
||||
|
||||
// Add rel="noopener noreferrer" for security
|
||||
var relAttr = anchor.Attributes["rel"];
|
||||
if (relAttr == null)
|
||||
{
|
||||
anchor.SetAttributeValue("rel", "noopener noreferrer");
|
||||
}
|
||||
else if (!relAttr.Value.Contains("noopener") || !relAttr.Value.Contains("noreferrer"))
|
||||
{
|
||||
var relValues = new HashSet<string>(relAttr.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries));
|
||||
relValues.Add("noopener");
|
||||
relValues.Add("noreferrer");
|
||||
anchor.SetAttributeValue("rel", string.Join(" ", relValues));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return doc.DocumentNode.OuterHtml;
|
||||
// Sanitize and rewrite anchor tags to open in a new tab in a single parse pass.
|
||||
return EmailViewSanitizer.Value.Sanitize(html);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Log the exception
|
||||
Console.WriteLine($"Error in ConvertAnchorTagsToOpenInNewTab: {ex.Message}");
|
||||
|
||||
// Return the original HTML if an error occurs
|
||||
return html;
|
||||
// Log the exception and return empty string to prevent potential XSS.
|
||||
Console.WriteLine($"Error in SanitizeAndPrepareEmailHtml: {ex.Message}");
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ public class ConversionUtilityTest
|
||||
public void TestAnchorTabConversionSimple()
|
||||
{
|
||||
string anchorHtml = "<a href=\"https://dutchamzmasters.lt.acemlnb.com/Prod/link-tracker?redirectUrl=aHR0cHMlM0ElMkYlMkZ3d3cuZHV0Y2hhbXptYXN0ZXJzLmNvbSUyRnRoYW5rLXlvdTloN3poZ3Rp&sig=CpED3rRPX48ddoWTUZURadAYPYgPppT312jUNnvUCPo5&iat=1679512450&a=%7C%7C25799960%7C%7C&account=dutchamzmasters%2Eactivehosted%2Ecom&email=DQeVbqE%2Fy2FD5V3I2cvSxXjJCI3Tg5qfUHKGneOhzjJYZ1kM3LVZcQ%3D%3D%3AvdAW7N7fs1pZlI1ib%2BNbsMYz5m4FssAR&s=5241db963ffe25d6f4b762fc00038ee2&i=163A299A10A816\"></a>";
|
||||
string convertedAnchorTags = ConversionUtility.ConvertAnchorTagsToOpenInNewTab(anchorHtml);
|
||||
string convertedAnchorTags = ConversionUtility.SanitizeAndPrepareEmailHtml(anchorHtml);
|
||||
|
||||
// Check that conversion works as expected.
|
||||
Assert.That(convertedAnchorTags, Does.Contain("target=\"_blank\""));
|
||||
@@ -35,7 +35,7 @@ public class ConversionUtilityTest
|
||||
public void TestAnchorTabConversionComplex1()
|
||||
{
|
||||
string anchorHtml = "<a\nhref=\"https://dutchamzmasters.lt.acemlnb.com/Prod/link-tracker?redirectUrl=aHR0cHMlM0ElMkYlMkZ3d3cuZHV0Y2hhbXptYXN0ZXJzLmNvbSUyRnRoYW5rLXlvdTloN3poZ3Rp&sig=CpED3rRPX48ddoWTUZURadAYPYgPppT312jUNnvUCPo5&iat=1679512450&a=%7C%7C25799960%7C%7C&account=dutchamzmasters%2Eactivehosted%2Ecom&email=DQeVbqE%2Fy2FD5V3I2cvSxXjJCI3Tg5qfUHKGneOhzjJYZ1kM3LVZcQ%3D%3D%3AvdAW7N7fs1pZlI1ib%2BNbsMYz5m4FssAR&s=5241db963ffe25d6f4b762fc00038ee2&i=163A299A10A816\" data-ac-default-color=\"1\" style=\"margin: 0; outline: none; padding: 0; color: #045FB4; text-decoration: underline; font-weight: bold;\"><span style=\"color: ; font-size: inherit; font-weight: inherit; line-height: inherit; text-decoration: inherit;\">Start hier met de training >>></span></a>";
|
||||
string convertedAnchorTags = ConversionUtility.ConvertAnchorTagsToOpenInNewTab(anchorHtml);
|
||||
string convertedAnchorTags = ConversionUtility.SanitizeAndPrepareEmailHtml(anchorHtml);
|
||||
|
||||
// Check that conversion works as expected.
|
||||
Assert.That(convertedAnchorTags, Does.Contain("target=\"_blank\""));
|
||||
@@ -48,7 +48,7 @@ public class ConversionUtilityTest
|
||||
public void TestAnchorTabConversionComplex2()
|
||||
{
|
||||
string anchorHtml = "<div class=\"btn btn--flat btn--large\" style=\"Margin-bottom: 20px;text-align: center;\">\n <!--[if !mso]><!--><a style=\"border-radius: 4px;display: inline-block;font-size: 14px;font-weight: bold;line-height: 24px;padding: 12px 24px;text-align: center;text-decoration: none !important;transition: opacity 0.1s ease-in;color: #212529 !important;background-color: #ffdd55;font-family: Open Sans, sans-serif;\" href=\"https://eazegamesbv.cmail19.com/t/j-l-sktidll-ddhdthjhkj-j/\">Haal je beloning op</a><!--<![endif]-->\n <!--[if mso]><p style=\"line-height:0;margin:0;\"> </p><v:roundrect xmlns:v=\"urn:schemas-microsoft-com:vml\" href=\"https://eazegamesbv.cmail19.com/t/j-l-sktidll-ddhdthjhkj-j/\" style=\"width:136.5pt\" arcsize=\"9%\" fillcolor=\"#FFDD55\" stroke=\"f\"><v:textbox style=\"mso-fit-shape-to-text:t\" inset=\"0pt,8.25pt,0pt,8.25pt\"><center style=\"font-size:14px;line-height:24px;color:#212529;font-family:Open Sans,sans-serif;font-weight:bold;mso-line-height-rule:exactly;mso-text-raise:1.5px\">Haal je beloning op</center></v:textbox></v:roundrect><![endif]--></div>";
|
||||
string convertedAnchorTags = ConversionUtility.ConvertAnchorTagsToOpenInNewTab(anchorHtml);
|
||||
string convertedAnchorTags = ConversionUtility.SanitizeAndPrepareEmailHtml(anchorHtml);
|
||||
|
||||
// Check that conversion works as expected.
|
||||
Assert.That(convertedAnchorTags, Does.Contain("target=\"_blank\""));
|
||||
@@ -61,7 +61,7 @@ public class ConversionUtilityTest
|
||||
public void TestAnchorTabConversionComplex3()
|
||||
{
|
||||
string anchorHtml = "<td style=\"word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; vertical-align: top; font-family: Helvetica, Arial, sans-serif; font-weight: normal; margin: 0; Margin: 0; font-size: 16px; line-height: 1.3; text-align: center; color: #fefefe; background: #f17130; border-radius: 5px; border: 0 solid #f17130; width: 400px; padding: 5px; border-collapse: collapse;\"><a href=\"https://click.info.wijkopenautos.nl/f/a/chSbfTJZeP5dGZaVjOIUlw~~/AABMyAA~/RgRnzW26P0SwaHR0cHM6Ly93d3cud2lqa29wZW5hdXRvcy5ubC9pbnNwZWN0aW9uL2UwZTg0M2Y4NGEzZDRjYjc4MDYwMGU2NDEzNzc1NmEyLz9NSUQ9TkxfQ1JNXzFfM18wXzE5NjY3MF8yNDE5NTgwMTEyNDdfMSZ0bXM9MTcwOTg5Mzc4MyZ1dG1fc291cmNlPUNSTSZ1dG1fbWVkaXVtPWVtYWlsJnV0bV9jYW1wYWlnbj0zXzdXBXNwY2V1Qgpl6bro6mX9yH0mUhJidWxhYmVlckBhc2Rhc2QubmxYBAAAAAw~\" style=\"margin: 0; Margin: 0; line-height: 1.3; font-family: Helvetica, Arial, sans-serif; font-size: 16px; font-weight: bold; color: #fefefe; text-decoration: none; display: inline-block; background: #f17130; border: 0 solid #f17130; width: 400px; text-align: center; padding: 5px; border-radius: 5px;\">Ontvang nu jouw prijs <b>></b></a></td>";
|
||||
string convertedAnchorTags = ConversionUtility.ConvertAnchorTagsToOpenInNewTab(anchorHtml);
|
||||
string convertedAnchorTags = ConversionUtility.SanitizeAndPrepareEmailHtml(anchorHtml);
|
||||
|
||||
// Check that conversion works as expected.
|
||||
Assert.That(convertedAnchorTags, Does.Contain("target=\"_blank\""));
|
||||
@@ -74,7 +74,7 @@ public class ConversionUtilityTest
|
||||
public void TestAnchorTabConversionComplex4()
|
||||
{
|
||||
string anchorHtml = "<a href=\"https://www.maxmind.com/en/account/set-password?token=FEA9D6D78B624D6BB048687F4D0A2DD9\">https://www<span>.</span>maxmind<span>.</span>com/en/account/set-password?token=FEA9D6D78B624D6BB048687F4D0A2DD9</a>";
|
||||
string convertedAnchorTags = ConversionUtility.ConvertAnchorTagsToOpenInNewTab(anchorHtml);
|
||||
string convertedAnchorTags = ConversionUtility.SanitizeAndPrepareEmailHtml(anchorHtml);
|
||||
|
||||
// Check that conversion works as expected.
|
||||
Assert.That(convertedAnchorTags, Does.Contain("target=\"_blank\""));
|
||||
@@ -88,7 +88,7 @@ public class ConversionUtilityTest
|
||||
public void TestAnchorTabConversionComplex5()
|
||||
{
|
||||
string anchorHtml = "<a href=\"test.html\" target=\"_blank\">test anchor text</a>";
|
||||
string convertedAnchorTags = ConversionUtility.ConvertAnchorTagsToOpenInNewTab(anchorHtml);
|
||||
string convertedAnchorTags = ConversionUtility.SanitizeAndPrepareEmailHtml(anchorHtml);
|
||||
|
||||
int targetBlankCount = Regex.Matches(convertedAnchorTags, "target=\"_blank\"", RegexOptions.NonBacktracking).Count;
|
||||
|
||||
@@ -104,4 +104,48 @@ public class ConversionUtilityTest
|
||||
Assert.That(convertedAnchorTags, Does.Contain(">test anchor text</a>"), "The anchor text should be preserved.");
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that the single-pass sanitize-and-prepare both rewrites anchors to open in a new tab
|
||||
/// (with security rel attributes) and strips dangerous content.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void TestSanitizeAndPrepareEmailHtml()
|
||||
{
|
||||
string html = "<div><script>alert('xss')</script><a href=\"https://example.com\">link</a></div>";
|
||||
string result = ConversionUtility.SanitizeAndPrepareEmailHtml(html);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
// Anchors are rewritten to open in a new tab with security attributes.
|
||||
Assert.That(result, Does.Contain("target=\"_blank\""), "The anchor should open in a new tab.");
|
||||
Assert.That(result, Does.Contain("noopener"), "The anchor should have rel=noopener.");
|
||||
Assert.That(result, Does.Contain("noreferrer"), "The anchor should have rel=noreferrer.");
|
||||
|
||||
// Dangerous content is removed.
|
||||
Assert.That(result, Does.Not.Contain("<script"), "Script tags should be stripped.");
|
||||
|
||||
// Safe content is preserved.
|
||||
Assert.That(result, Does.Contain("href=\"https://example.com\""), "The href should be preserved.");
|
||||
Assert.That(result, Does.Contain(">link</a>"), "The anchor text should be preserved.");
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that an existing target attribute is normalized to _blank without duplicating it.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void TestSanitizeAndPrepareEmailHtmlNormalizesExistingTarget()
|
||||
{
|
||||
string html = "<a href=\"https://example.com\" target=\"_self\">link</a>";
|
||||
string result = ConversionUtility.SanitizeAndPrepareEmailHtml(html);
|
||||
|
||||
int targetCount = Regex.Matches(result, "target=", RegexOptions.NonBacktracking).Count;
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(targetCount, Is.EqualTo(1), "There should be exactly one target attribute.");
|
||||
Assert.That(result, Does.Contain("target=\"_blank\""), "The target should be normalized to _blank.");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user