diff --git a/src/AliasVault.Api/Controllers/Email/EmailBoxController.cs b/src/AliasVault.Api/Controllers/Email/EmailBoxController.cs index 28573a7aa..0aefad46a 100644 --- a/src/AliasVault.Api/Controllers/Email/EmailBoxController.cs +++ b/src/AliasVault.Api/Controllers/Email/EmailBoxController.cs @@ -77,7 +77,6 @@ public class EmailBoxController(IDbContextFactory dbContex { Id = x.Id, Subject = x.Subject, - FromDisplay = ConversionHelper.ConvertFromToFromDisplay(x.From), FromDomain = x.FromDomain, FromLocal = x.FromLocal, ToDomain = x.ToDomain, @@ -140,7 +139,7 @@ public class EmailBoxController(IDbContextFactory dbContex { Id = x.Id, Subject = x.Subject, - FromDisplay = ConversionHelper.ConvertFromToFromDisplay(x.From), + FromDisplay = x.From, FromDomain = x.FromDomain, FromLocal = x.FromLocal, ToDomain = x.ToDomain, diff --git a/src/AliasVault.Api/Controllers/Email/EmailController.cs b/src/AliasVault.Api/Controllers/Email/EmailController.cs index dc9ef851e..0022e662b 100644 --- a/src/AliasVault.Api/Controllers/Email/EmailController.cs +++ b/src/AliasVault.Api/Controllers/Email/EmailController.cs @@ -45,7 +45,6 @@ public class EmailController(ILogger logger, IDbContextFactory< { Id = email!.Id, Subject = email.Subject, - FromDisplay = ConversionHelper.ConvertFromToFromDisplay(email.From), FromDomain = email.FromDomain, FromLocal = email.FromLocal, ToDomain = email.ToDomain, @@ -71,12 +70,6 @@ public class EmailController(ILogger logger, IDbContextFactory< returnEmail.Attachments = attachments; - // Enrich HTML by changing all anchor tags to open in new tab - if (returnEmail.MessageHtml != null && !string.IsNullOrEmpty(email.MessageHtml)) - { - returnEmail.MessageHtml = ConversionHelper.ConvertAnchorTagsToOpenInNewTab(email.MessageHtml); - } - return Ok(returnEmail); } diff --git a/src/AliasVault.Api/Helpers/ConversionHelper.cs b/src/AliasVault.Api/Helpers/ConversionHelper.cs deleted file mode 100644 index 2186521b7..000000000 --- a/src/AliasVault.Api/Helpers/ConversionHelper.cs +++ /dev/null @@ -1,60 +0,0 @@ -//----------------------------------------------------------------------- -// -// Copyright (c) lanedirt. All rights reserved. -// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. -// -//----------------------------------------------------------------------- - -namespace AliasVault.Api.Helpers; - -using System.Text.RegularExpressions; - -/// -/// Class which contains various helper methods for data conversion. -/// -public static class ConversionHelper -{ - /// - /// Extract only displayname from full "From" string. E.g. "John Doe" [johndoe@john.com] becomes "John Doe". - /// - /// The full from string. - /// Stripped displayname. - public static string ConvertFromToFromDisplay(string from) - { - // Get the display name from the From field, which is everything before the first < and after the first > - string fromDisplay = from; - if (!from.Contains('<')) - { - return fromDisplay; - } - - // Remove everything after the last < until the last > - fromDisplay = from.Substring(0, from.LastIndexOf('<')); - - // Remove any double quotes - fromDisplay = fromDisplay.Replace("\"", string.Empty); - - // Trim any whitespace - fromDisplay = fromDisplay.Trim(); - - return fromDisplay; - } - - /// - /// Convert all anchor tags to open in a new tab. - /// - /// HTML input. - /// HTML with all anchor tags converted to open in a new tab when clicked on. - public static string ConvertAnchorTagsToOpenInNewTab(string html) - { - // Match any ", - m => $"", - RegexOptions.IgnoreCase | RegexOptions.Singleline, - TimeSpan.FromSeconds(1)); - - return html; - } -} diff --git a/src/AliasVault.Client/Main/Components/Email/EmailModal.razor b/src/AliasVault.Client/Main/Components/Email/EmailModal.razor index ab6a89277..a2053e68f 100644 --- a/src/AliasVault.Client/Main/Components/Email/EmailModal.razor +++ b/src/AliasVault.Client/Main/Components/Email/EmailModal.razor @@ -1,4 +1,5 @@ @using AliasVault.Shared.Models.Spamok +@using AliasVault.Shared.Utilities @inject JsInteropService JsInteropService @inject GlobalNotificationService GlobalNotificationService @inject IHttpClientFactory HttpClientFactory @@ -31,7 +32,7 @@
-
@@ -176,17 +177,17 @@ // Check if there is HTML content, if not, then set default viewtype to plain if (Email.MessageHtml is not null && !string.IsNullOrWhiteSpace(Email.MessageHtml)) { - // No HTML is available - EmailBody = Email.MessageHtml; + // HTML is available + EmailBody = ConversionUtility.ConvertAnchorTagsToOpenInNewTab(Email.MessageHtml); } else if (Email.MessagePlain is not null) { - // HTML is available + // No HTML but plain text is available EmailBody = Email.MessagePlain; } else { - // No HTML is available + // No HTML and no plain text available EmailBody = "[This email has no body.]"; } } diff --git a/src/AliasVault.Shared/AliasVault.Shared.csproj b/src/AliasVault.Shared/AliasVault.Shared.csproj index 49688d792..ca31b8d36 100644 --- a/src/AliasVault.Shared/AliasVault.Shared.csproj +++ b/src/AliasVault.Shared/AliasVault.Shared.csproj @@ -21,6 +21,7 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/AliasVault.Shared/Utilities/ConversionUtility.cs b/src/AliasVault.Shared/Utilities/ConversionUtility.cs new file mode 100644 index 000000000..cb539b2df --- /dev/null +++ b/src/AliasVault.Shared/Utilities/ConversionUtility.cs @@ -0,0 +1,71 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.Shared.Utilities; + +using HtmlAgilityPack; + +/// +/// Class which contains various helper methods for data conversion. +/// +public static class ConversionUtility +{ + /// + /// Convert all anchor tags to open in a new tab. + /// + /// HTML input. + /// HTML with all anchor tags converted to open in a new tab when clicked on. + 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(relAttr.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries)); + relValues.Add("noopener"); + relValues.Add("noreferrer"); + anchor.SetAttributeValue("rel", string.Join(" ", relValues)); + } + } + } + + return doc.DocumentNode.OuterHtml; + } + catch (Exception ex) + { + // Log the exception + Console.WriteLine($"Error in ConvertAnchorTagsToOpenInNewTab: {ex.Message}"); + + // Return the original HTML if an error occurs + return html; + } + } +} diff --git a/src/Tests/AliasVault.E2ETests/Tests/Client/EmailDecryptionTests.cs b/src/Tests/AliasVault.E2ETests/Tests/Client/EmailDecryptionTests.cs index 1da33a210..ddfa98529 100644 --- a/src/Tests/AliasVault.E2ETests/Tests/Client/EmailDecryptionTests.cs +++ b/src/Tests/AliasVault.E2ETests/Tests/Client/EmailDecryptionTests.cs @@ -78,8 +78,21 @@ public class EmailDecryptionTests : ClientPlaywrightTest message.To.Add(new MailboxAddress("Test Recipient", email)); const string textSubject = "Encrypted Email Subject"; const string textBody = "This is a test email plain."; + const string htmlBody = @" + + +

Test Email

+

This is a test email with HTML content.

+

Sample anchor tag: Example Link

+ + "; message.Subject = textSubject; - message.Body = new BodyBuilder { TextBody = textBody }.ToMessageBody(); + var bodyBuilder = new BodyBuilder + { + TextBody = textBody, + HtmlBody = htmlBody, + }; + message.Body = bodyBuilder.ToMessageBody(); await SendMessageToSmtpServer(message); // Assert that email was received by the server. @@ -109,6 +122,10 @@ public class EmailDecryptionTests : ClientPlaywrightTest await Page.Locator("text=" + textSubject).First.ClickAsync(); await WaitForUrlAsync("emails**", "Delete"); + // Assert that the anchor tag in the email iframe has target="_blank" attribute. + var anchorTag = await Page.Locator("iframe").First.GetAttributeAsync("srcdoc"); + Assert.That(anchorTag, Does.Contain("target=\"_blank\""), "Anchor tag in email iframe does not have target=\"_blank\" attribute. Check email decryption logic."); + // Click the delete button to delete the email. await Page.Locator("id=delete-email").First.ClickAsync(); diff --git a/src/Tests/AliasVault.E2ETests/Tests/Client/TwoFactorAuth/CodeLockoutTests.cs b/src/Tests/AliasVault.E2ETests/Tests/Client/TwoFactorAuth/CodeLockoutTests.cs index 7fa35da00..c6331621a 100644 --- a/src/Tests/AliasVault.E2ETests/Tests/Client/TwoFactorAuth/CodeLockoutTests.cs +++ b/src/Tests/AliasVault.E2ETests/Tests/Client/TwoFactorAuth/CodeLockoutTests.cs @@ -22,7 +22,7 @@ public class CodeLockoutTests : TwoFactorAuthBase /// /// Async task. [Test] - public async Task TwoFactorAuthLockoutTest() + public async Task TwoFactorAuthCodeLockoutTest() { await DisableTwoFactorIfEnabled(); await EnableTwoFactor(); diff --git a/src/Tests/AliasVault.E2ETests/Tests/Client/TwoFactorAuth/RecoveryLockoutTests.cs b/src/Tests/AliasVault.E2ETests/Tests/Client/TwoFactorAuth/RecoveryLockoutTests.cs index be78f6c64..9db037a6e 100644 --- a/src/Tests/AliasVault.E2ETests/Tests/Client/TwoFactorAuth/RecoveryLockoutTests.cs +++ b/src/Tests/AliasVault.E2ETests/Tests/Client/TwoFactorAuth/RecoveryLockoutTests.cs @@ -22,7 +22,7 @@ public class RecoveryLockoutTests : TwoFactorAuthBase /// /// Async task. [Test] - public async Task TwoFactorAuthLockoutTest() + public async Task TwoFactorAuthRecoveryLockoutTest() { await DisableTwoFactorIfEnabled(); await EnableTwoFactor(); diff --git a/src/Tests/AliasVault.UnitTests/Helpers/ConversionHelperTest.cs b/src/Tests/AliasVault.UnitTests/Utilities/ConversionUtilityTest.cs similarity index 67% rename from src/Tests/AliasVault.UnitTests/Helpers/ConversionHelperTest.cs rename to src/Tests/AliasVault.UnitTests/Utilities/ConversionUtilityTest.cs index fb5a4a205..9452da016 100644 --- a/src/Tests/AliasVault.UnitTests/Helpers/ConversionHelperTest.cs +++ b/src/Tests/AliasVault.UnitTests/Utilities/ConversionUtilityTest.cs @@ -1,32 +1,20 @@ //----------------------------------------------------------------------- -// +// // Copyright (c) lanedirt. All rights reserved. // Licensed under the MIT license. See LICENSE.md file in the project root for full license information. // //----------------------------------------------------------------------- -namespace AliasVault.Tests.Helpers; +namespace AliasVault.Tests.Utilities; -using AliasVault.Api.Helpers; +using System.Text.RegularExpressions; +using AliasVault.Shared.Utilities; /// /// Tests for the CsvImportExport class. /// -public class ConversionHelperTest +public partial class ConversionUtilityTest { - /// - /// Tests the conversion of an email address with a display name to just the display name. - /// - [Test] - public void TestFromConversion() - { - string from = "\"My full Name\" "; - string convertedFrom = ConversionHelper.ConvertFromToFromDisplay(from); - - // Check that conversion works as expected. - Assert.That(convertedFrom, Is.EqualTo("My full Name")); - } - /// /// Tests the conversion of a simple anchor tag to open in a new tab. /// @@ -34,7 +22,7 @@ public class ConversionHelperTest public void TestAnchorTabConversionSimple() { string anchorHtml = ""; - string convertedAnchorTags = ConversionHelper.ConvertAnchorTagsToOpenInNewTab(anchorHtml); + string convertedAnchorTags = ConversionUtility.ConvertAnchorTagsToOpenInNewTab(anchorHtml); // Check that conversion works as expected. Assert.That(convertedAnchorTags, Does.Contain("target=\"_blank\"")); @@ -47,7 +35,7 @@ public class ConversionHelperTest public void TestAnchorTabConversionComplex1() { string anchorHtml = "Start hier met de training >>>"; - string convertedAnchorTags = ConversionHelper.ConvertAnchorTagsToOpenInNewTab(anchorHtml); + string convertedAnchorTags = ConversionUtility.ConvertAnchorTagsToOpenInNewTab(anchorHtml); // Check that conversion works as expected. Assert.That(convertedAnchorTags, Does.Contain("target=\"_blank\"")); @@ -60,7 +48,7 @@ public class ConversionHelperTest public void TestAnchorTabConversionComplex2() { string anchorHtml = ""; - string convertedAnchorTags = ConversionHelper.ConvertAnchorTagsToOpenInNewTab(anchorHtml); + string convertedAnchorTags = ConversionUtility.ConvertAnchorTagsToOpenInNewTab(anchorHtml); // Check that conversion works as expected. Assert.That(convertedAnchorTags, Does.Contain("target=\"_blank\"")); @@ -73,9 +61,54 @@ public class ConversionHelperTest public void TestAnchorTabConversionComplex3() { string anchorHtml = "Ontvang nu jouw prijs  >"; - string convertedAnchorTags = ConversionHelper.ConvertAnchorTagsToOpenInNewTab(anchorHtml); + string convertedAnchorTags = ConversionUtility.ConvertAnchorTagsToOpenInNewTab(anchorHtml); // Check that conversion works as expected. Assert.That(convertedAnchorTags, Does.Contain("target=\"_blank\"")); } + + /// + /// Tests the conversion of a complex anchor tag within a table cell to open in a new tab. + /// + [Test] + public void TestAnchorTabConversionComplex4() + { + string anchorHtml = "https://www.maxmind.com/en/account/set-password?token=FEA9D6D78B624D6BB048687F4D0A2DD9"; + string convertedAnchorTags = ConversionUtility.ConvertAnchorTagsToOpenInNewTab(anchorHtml); + + // Check that conversion works as expected. + Assert.That(convertedAnchorTags, Does.Contain("target=\"_blank\"")); + } + + /// + /// Tests the conversion of a complex anchor tag with existing target="_blank" attribute to + /// not get a second attribute after conversion. + /// + [Test] + public void TestAnchorTabConversionComplex5() + { + string anchorHtml = "test anchor text"; + string convertedAnchorTags = ConversionUtility.ConvertAnchorTagsToOpenInNewTab(anchorHtml); + + int targetBlankCount = TargetBlankRegex().Matches(convertedAnchorTags).Count; + + Assert.Multiple(() => + { + // Check that only one target="_blank" appears. + Assert.That(targetBlankCount, Is.EqualTo(1), "There should be exactly one target=\"_blank\" attribute."); + + // Ensure other attributes are preserved + Assert.That(convertedAnchorTags, Does.Contain("href=\"test.html\""), "The href attribute should be preserved."); + + // Check that the anchor text is preserved + Assert.That(convertedAnchorTags, Does.Contain(">test anchor text"), "The anchor text should be preserved."); + }); + } + + /// + /// Regex to match an anchor tag with an href attribute, generated at compile time. + /// + /// + [GeneratedRegex("target=\"_blank\"")] + private static partial Regex TargetBlankRegex(); }