diff --git a/src/AliasVault.Api/Controllers/EmailBoxController.cs b/src/AliasVault.Api/Controllers/EmailBoxController.cs new file mode 100644 index 000000000..8e6a1971a --- /dev/null +++ b/src/AliasVault.Api/Controllers/EmailBoxController.cs @@ -0,0 +1,76 @@ +//----------------------------------------------------------------------- +// +// 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.Controllers; + +using AliasServerDb; +using AliasVault.Api.Helpers; +using AliasVault.Shared.Models.Spamok; +using Asp.Versioning; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +/// +/// Email controller for retrieving emails from the database. +/// +/// DbContext instance. +/// UserManager instance. +[ApiVersion("1")] +public class EmailBoxController(IDbContextFactory dbContextFactory, UserManager userManager) : AuthenticatedRequestController(userManager) +{ + /// + /// Get the newest version of the vault for the current user. + /// + /// The full email address including @ sign. + /// List of aliases in JSON format. + [HttpGet(template: "{to}", Name = "GetEmailBox")] + public async Task GetEmailBox(string to) + { + await using var context = await dbContextFactory.CreateDbContextAsync(); + + var user = await GetCurrentUserAsync(); + if (user is null) + { + return Unauthorized("Not authenticated."); + } + + // See if this user has a valid claim to the email address. + var emailClaim = await context.UserEmailClaims + .FirstOrDefaultAsync(x => x.UserId == user.Id && x.Address == to); + + if (emailClaim is null) + { + return Unauthorized("User does not have a claim to this email address."); + } + + // Retrieve emails from database. + List emails = context.Emails.AsNoTracking().Select(x => new MailboxEmailApiModel() + { + Id = x.Id, + Subject = x.Subject, + FromDisplay = ConversionHelper.ConvertFromToFromDisplay(x.From), + FromDomain = x.FromDomain, + FromLocal = x.FromLocal, + ToDomain = x.ToDomain, + ToLocal = x.ToLocal, + Date = x.Date, + DateSystem = x.DateSystem, + SecondsAgo = (int)DateTime.UtcNow.Subtract(x.DateSystem).TotalSeconds, + MessagePreview = x.MessagePreview ?? string.Empty, + EncryptedSymmetricKey = x.EncryptedSymmetricKey, + EncryptionKey = x.EncryptionKey.PublicKey, + }).OrderByDescending(x => x.DateSystem).Take(75).ToList(); + + MailboxApiModel returnValue = new MailboxApiModel(); + returnValue.Address = to; + returnValue.Subscribed = false; + returnValue.Mails = emails; + + return Ok(returnValue); + } +} diff --git a/src/AliasVault.Api/Controllers/EmailController.cs b/src/AliasVault.Api/Controllers/EmailController.cs new file mode 100644 index 000000000..fdbe77a6e --- /dev/null +++ b/src/AliasVault.Api/Controllers/EmailController.cs @@ -0,0 +1,96 @@ +//----------------------------------------------------------------------- +// +// 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.Controllers; + +using AliasServerDb; +using AliasVault.Api.Helpers; +using AliasVault.Shared.Models.Spamok; +using Asp.Versioning; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +/// +/// Email controller for retrieving emails from the database. +/// +/// DbContext instance. +/// UserManager instance. +[ApiVersion("1")] +public class EmailController(IDbContextFactory dbContextFactory, UserManager userManager) : AuthenticatedRequestController(userManager) +{ + /// + /// Get the newest version of the vault for the current user. + /// + /// The email ID to open. + /// List of aliases in JSON format. + [HttpGet(template: "{id}", Name = "GetEmail")] + public async Task GetEmail(int id) + { + await using var context = await dbContextFactory.CreateDbContextAsync(); + + var user = await GetCurrentUserAsync(); + if (user is null) + { + return Unauthorized("Not authenticated."); + } + + // Retrieve email from database. + var email = await context.Emails.Include(x => x.EncryptionKey).AsNoTracking().FirstOrDefaultAsync(x => x.Id == id); + if (email is null) + { + return NotFound("Email not found."); + } + + // See if this user has a valid claim to the email address. + var emailClaim = await context.UserEmailClaims + .FirstOrDefaultAsync(x => x.UserId == user.Id && x.Address == email.To); + + if (emailClaim is null) + { + return Unauthorized("User does not have a claim to this email address."); + } + + var returnEmail = new EmailApiModel + { + Id = email.Id, + Subject = email.Subject, + FromDisplay = ConversionHelper.ConvertFromToFromDisplay(email.From), + FromDomain = email.FromDomain, + FromLocal = email.FromLocal, + ToDomain = email.ToDomain, + ToLocal = email.ToLocal, + Date = email.Date, + DateSystem = DateTime.SpecifyKind(email.DateSystem, DateTimeKind.Utc), + SecondsAgo = (int)DateTime.UtcNow.Subtract(email.DateSystem).TotalSeconds, + MessageHtml = email.MessageHtml, + MessagePlain = email.MessagePlain, + EncryptedSymmetricKey = email.EncryptedSymmetricKey, + EncryptionKey = email.EncryptionKey.PublicKey, + }; + + // Add attachment metadata (without the filebytes) + var attachments = context.EmailAttachments.Where(x => x.EmailId == email.Id).Select(x => new AttachmentApiModel() + { + Id = x.Id, + Email_Id = x.EmailId, + Filename = x.Filename, + MimeType = x.MimeType, + Filesize = x.Filesize, + }).ToList(); + + 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 new file mode 100644 index 000000000..d8df8357d --- /dev/null +++ b/src/AliasVault.Api/Helpers/ConversionHelper.cs @@ -0,0 +1,58 @@ +//----------------------------------------------------------------------- +// +// 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; +using AliasServerDb; + +/// +/// Class which contains various helper methods for data conversion. +/// +public 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("<")) + { + // Remove everything after the last < until the last > + fromDisplay = from.Substring(0, from.LastIndexOf("<", StringComparison.Ordinal)); + + // 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); + + return html; + } +} diff --git a/src/AliasVault.Client/Main/Components/Email/EmailModal.razor b/src/AliasVault.Client/Main/Components/Email/EmailModal.razor index 62c9497b7..3e9697ea5 100644 --- a/src/AliasVault.Client/Main/Components/Email/EmailModal.razor +++ b/src/AliasVault.Client/Main/Components/Email/EmailModal.razor @@ -1,4 +1,4 @@ -@using AliasVault.Client.Main.Models.Spamok +@using AliasVault.Shared.Models.Spamok
diff --git a/src/AliasVault.Client/Main/Components/Email/RecentEmails.razor b/src/AliasVault.Client/Main/Components/Email/RecentEmails.razor index 71c1b72a0..f55bc74e5 100644 --- a/src/AliasVault.Client/Main/Components/Email/RecentEmails.razor +++ b/src/AliasVault.Client/Main/Components/Email/RecentEmails.razor @@ -1,6 +1,10 @@ -@using AliasVault.Client.Main.Models.Spamok +@using AliasVault.Shared.Models.Spamok @inherits ComponentBase @inject IHttpClientFactory HttpClientFactory +@inject HttpClient HttpClient +@inject JsInteropService JsInteropService +@inject DbService DbService +@inject Config Config @if (EmailModalVisible) { @@ -21,6 +25,10 @@ { } + else if (!string.IsNullOrEmpty(Error)) + { + + } else if (MailboxEmails.Count == 0) {
No emails found.
@@ -76,6 +84,7 @@ private bool ShowComponent { get; set; } = false; private EmailApiModel Email { get; set; } = new(); private bool EmailModalVisible { get; set; } + private string Error { get; set; } = string.Empty; /// protected override async Task OnInitializedAsync() @@ -83,12 +92,46 @@ await base.OnInitializedAsync(); // Check if email has a known SpamOK domain, if not, don't show this component. - if (EmailAddress.EndsWith("@landmail.nl")) + if (IsSpamOkDomain(EmailAddress) || IsAliasVaultDomain(EmailAddress)) { ShowComponent = true; } } + /// + /// Returns true if the email address is from a known SpamOK domain. + /// + protected bool IsSpamOkDomain(string email) + { + return email.EndsWith("@spamok.nl") || + email.EndsWith("@spamok.de") || + email.EndsWith("@spamok.es") || + email.EndsWith("@spamok.fr") || + email.EndsWith("@spamok.com") || + email.EndsWith("@spamok.com.ua") || + email.EndsWith("@landmail.nl") || + email.EndsWith("@landmeel.nl") || + email.EndsWith("@asdasd.nl") || + email.EndsWith("@sdfsdf.nl") || + email.EndsWith("@solarflarecorp.com"); + } + + /// + /// Returns true if the email address is from a known AliasVault domain. + /// + protected bool IsAliasVaultDomain(string email) + { + foreach (var domain in Config.SmtpAllowedDomains) + { + if (email.EndsWith(domain)) + { + return true; + } + } + + return false; + } + /// protected override async Task OnAfterRenderAsync(bool firstRender) { @@ -112,19 +155,66 @@ return; } + Error = string.Empty; + IsLoading = true; StateHasChanged(); // Get email prefix, which is the part before the @ symbol. string emailPrefix = EmailAddress.Split('@')[0]; - var client = HttpClientFactory.CreateClient("EmailClient"); - MailboxApiModel? mailbox = await client.GetFromJsonAsync($"https://api.spamok.com/v2/EmailBox/{emailPrefix}"); + MailboxApiModel? mailbox = new(); - if (mailbox?.Mails != null) + if (IsSpamOkDomain(EmailAddress)) { - // Show maximum of 10 recent emails. - MailboxEmails = mailbox.Mails.Take(10).ToList(); + // We construct a new HttpClient to avoid using the default one, which is used for the API and sends + // the Authorization header. We don't want to send the Authorization header to the external email API. + var client = HttpClientFactory.CreateClient("EmailClient"); + mailbox = await client.GetFromJsonAsync($"https://api.spamok.com/v2/EmailBox/{emailPrefix}"); + + if (mailbox?.Mails != null) + { + // Show maximum of 10 recent emails. + MailboxEmails = mailbox.Mails.Take(10).ToList(); + } + } + else if (IsAliasVaultDomain(EmailAddress)) + { + try + { + mailbox = await HttpClient.GetFromJsonAsync($"api/v1/EmailBox/{EmailAddress}"); + if (mailbox?.Mails != null) + { + // Show maximum of 10 recent emails. + MailboxEmails = mailbox.Mails.Take(10).ToList(); + } + + // Loop through emails and decrypt the subject locally. + var context = await DbService.GetDbContextAsync(); + var privateKeys = await context.EncryptionKeys.ToListAsync(); + foreach (var mail in MailboxEmails) + { + var privateKey = privateKeys.FirstOrDefault(x => x.PublicKey == mail.EncryptionKey); + if (privateKey is not null) + { + try + { + var decryptedSymmetricKey = await JsInteropService.DecryptWithPrivateKey(mail.EncryptedSymmetricKey, privateKey.PrivateKey); + mail.Subject = await JsInteropService.SymmetricDecrypt(mail.Subject, Convert.ToBase64String(decryptedSymmetricKey)); + } + catch (Exception ex) + { + Error = ex.Message; + Console.WriteLine(ex); + } + } + } + } + catch (Exception ex) + { + Error = ex.Message; + Console.WriteLine(ex); + } } IsLoading = false; @@ -139,16 +229,59 @@ // Get email prefix, which is the part before the @ symbol. string emailPrefix = EmailAddress.Split('@')[0]; - // Load email from API - var client = HttpClientFactory.CreateClient("EmailClient"); - EmailApiModel? mail = await client.GetFromJsonAsync($"https://api.spamok.com/v2/Email/{emailPrefix}/{emailId}"); - if (mail != null) + if (IsSpamOkDomain(EmailAddress)) { - Email = mail; - EmailModalVisible = true; - StateHasChanged(); + var client = HttpClientFactory.CreateClient("EmailClient"); + EmailApiModel? mail = await client.GetFromJsonAsync($"https://api.spamok.com/v2/Email/{emailPrefix}/{emailId}"); + if (mail != null) + { + Email = mail; + EmailModalVisible = true; + StateHasChanged(); + } } + else if (IsAliasVaultDomain(EmailAddress)) + { + EmailApiModel? mail = await HttpClient.GetFromJsonAsync($"api/v1/Email/{emailId}"); + if (mail != null) + { + // Decrypt the email content locally. + var context = await DbService.GetDbContextAsync(); + var privateKey = await context.EncryptionKeys.FirstOrDefaultAsync(x => x.PublicKey == mail.EncryptionKey); + if (privateKey is not null) + { + try + { + var decryptedSymmetricKey = await JsInteropService.DecryptWithPrivateKey(mail.EncryptedSymmetricKey, privateKey.PrivateKey); + mail.Subject = await JsInteropService.SymmetricDecrypt(mail.Subject, Convert.ToBase64String(decryptedSymmetricKey)); + if (mail.MessageHtml is not null) + { + mail.MessageHtml = await JsInteropService.SymmetricDecrypt(mail.MessageHtml, Convert.ToBase64String(decryptedSymmetricKey)); + } + + if (mail.MessagePlain is not null) + { + mail.MessagePlain = await JsInteropService.SymmetricDecrypt(mail.MessagePlain, Convert.ToBase64String(decryptedSymmetricKey)); + } + + mail.FromDisplay = await JsInteropService.SymmetricDecrypt(mail.FromDisplay, Convert.ToBase64String(decryptedSymmetricKey)); + mail.FromLocal = await JsInteropService.SymmetricDecrypt(mail.FromLocal, Convert.ToBase64String(decryptedSymmetricKey)); + mail.FromDomain = await JsInteropService.SymmetricDecrypt(mail.FromDomain, Convert.ToBase64String(decryptedSymmetricKey)); + } + catch (Exception ex) + { + Error = ex.Message; + } + } + + Email = mail; + EmailModalVisible = true; + StateHasChanged(); + } + + } + } ///
diff --git a/src/AliasVault.Client/Main/Layout/TopMenu.razor b/src/AliasVault.Client/Main/Layout/TopMenu.razor index 961c11dd1..67b8db4b1 100644 --- a/src/AliasVault.Client/Main/Layout/TopMenu.razor +++ b/src/AliasVault.Client/Main/Layout/TopMenu.razor @@ -1,5 +1,4 @@ -@inherits MainBase -@using AliasVault.Client.Main.Pages; +@inherits AliasVault.Client.Main.Pages.MainBase @implements IDisposable
diff --git a/src/AliasVault.Client/Main/Models/CredentialEdit.cs b/src/AliasVault.Client/Main/Models/CredentialEdit.cs index 35bac8257..ddf99afcb 100644 --- a/src/AliasVault.Client/Main/Models/CredentialEdit.cs +++ b/src/AliasVault.Client/Main/Models/CredentialEdit.cs @@ -11,7 +11,7 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using AliasClientDb; -using AliasVault.Client.Models.FormValidation; +using AliasVault.Client.Main.Models.FormValidation; /// /// Credential edit model. diff --git a/src/AliasVault.Client/Main/Models/FormValidation/StringDateFormatAttribute.cs b/src/AliasVault.Client/Main/Models/FormValidation/StringDateFormatAttribute.cs index 7ebc1b877..e8611b92a 100644 --- a/src/AliasVault.Client/Main/Models/FormValidation/StringDateFormatAttribute.cs +++ b/src/AliasVault.Client/Main/Models/FormValidation/StringDateFormatAttribute.cs @@ -5,7 +5,7 @@ // //----------------------------------------------------------------------- -namespace AliasVault.Client.Models.FormValidation; +namespace AliasVault.Client.Main.Models.FormValidation; using System.ComponentModel.DataAnnotations; using System.Globalization; diff --git a/src/AliasVault.Client/Services/CredentialService.cs b/src/AliasVault.Client/Services/CredentialService.cs index c067d1367..48f57d393 100644 --- a/src/AliasVault.Client/Services/CredentialService.cs +++ b/src/AliasVault.Client/Services/CredentialService.cs @@ -14,7 +14,6 @@ using System.Net.Http; using System.Net.Http.Json; using System.Threading.Tasks; using AliasClientDb; -using AliasVault.Client.Models; using AliasVault.Shared.Models; using Microsoft.EntityFrameworkCore; using Identity = AliasGenerators.Identity.Models.Identity; @@ -279,7 +278,7 @@ public class CredentialService(HttpClient httpClient, DbService dbService) try { var apiReturn = - await httpClient.GetFromJsonAsync("api/v1/Favicon/Extract?url=" + url); + await httpClient.GetFromJsonAsync($"api/v1/Favicon/Extract?url={url}"); if (apiReturn != null && apiReturn.Image != null) { credentialObject.Service.Logo = apiReturn.Image; diff --git a/src/AliasVault.Client/Services/JsInteropService.cs b/src/AliasVault.Client/Services/JsInteropService.cs index f04c6fbf9..677c077cf 100644 --- a/src/AliasVault.Client/Services/JsInteropService.cs +++ b/src/AliasVault.Client/Services/JsInteropService.cs @@ -7,7 +7,7 @@ namespace AliasVault.Client.Services; -using System.ComponentModel; +using System.Security.Cryptography; using System.Text.Json; using Microsoft.JSInterop; @@ -93,9 +93,21 @@ public class JsInteropService(IJSRuntime jsRuntime) /// /// Decrypts a ciphertext with a private key. /// - /// Ciphertext to decrypt. + /// Ciphertext to decrypt. /// Private key to use for decryption. /// Decrypted string. - public async Task DecryptWithPrivateKey(string ciphertext, string privateKey) => - await jsRuntime.InvokeAsync("rsaInterop.decryptWithPrivateKey", ciphertext, privateKey); + public async Task DecryptWithPrivateKey(string base64Ciphertext, string privateKey) + { + try + { + // Invoke the JavaScript function and get the result as a byte array + byte[] result = await jsRuntime.InvokeAsync("rsaInterop.decryptWithPrivateKey", base64Ciphertext, privateKey); + return result; + } + catch (JSException ex) + { + Console.Error.WriteLine($"JavaScript decryption error: {ex.Message}"); + throw new CryptographicException("Decryption failed", ex); + } + } } diff --git a/src/AliasVault.Client/wwwroot/js/cryptoInterop.js b/src/AliasVault.Client/wwwroot/js/cryptoInterop.js index 4d5e1803e..11712a5aa 100644 --- a/src/AliasVault.Client/wwwroot/js/cryptoInterop.js +++ b/src/AliasVault.Client/wwwroot/js/cryptoInterop.js @@ -118,29 +118,42 @@ window.rsaInterop = { * Decrypts a ciphertext string using an RSA private key. * @param {string} ciphertext - The base64-encoded ciphertext to decrypt. * @param {string} privateKey - The private key in JWK format. - * @returns {Promise} A promise that resolves to the decrypted plaintext. + * @returns {Promise} A promise that resolves to the decrypted data as a Uint8Array. */ - decryptWithPrivateKey : async function(ciphertext, privateKey) { - const privateKeyObj = await window.crypto.subtle.importKey( - "jwk", - JSON.parse(privateKey), - { - name: "RSA-OAEP", - hash: "SHA-256", - }, - false, - ["decrypt"] - ); + decryptWithPrivateKey: async function(ciphertext, privateKey) { + try { + // Parse the private key + let parsedPrivateKey = JSON.parse(privateKey); - const cipherBuffer = Uint8Array.from(atob(ciphertext), c => c.charCodeAt(0)); - const plaintextBuffer = await window.crypto.subtle.decrypt( - { - name: "RSA-OAEP" - }, - privateKeyObj, - cipherBuffer - ); + // Import the private key + let privateKeyObj = await window.crypto.subtle.importKey( + "jwk", + parsedPrivateKey, + { + name: "RSA-OAEP", + hash: "SHA-256", + }, + true, + ["decrypt"] + ); - return new TextDecoder().decode(plaintextBuffer); + // Decode the base64 ciphertext + let cipherBuffer = Uint8Array.from(atob(ciphertext), c => c.charCodeAt(0)); + + // Decrypt the ciphertext + let plaintextBuffer = await window.crypto.subtle.decrypt( + { + name: "RSA-OAEP", + hash: "SHA-256", + }, + privateKeyObj, + cipherBuffer + ); + + // Return the decrypted data as a Uint8Array + return new Uint8Array(plaintextBuffer); + } catch (error) { + throw new Error(`Failed to decrypt: ${error.message}`); + } } }; diff --git a/src/AliasVault.Client/Main/Models/Spamok/AttachmentApiModel.cs b/src/AliasVault.Shared/Models/Spamok/AttachmentApiModel.cs similarity index 96% rename from src/AliasVault.Client/Main/Models/Spamok/AttachmentApiModel.cs rename to src/AliasVault.Shared/Models/Spamok/AttachmentApiModel.cs index 6f5951c8f..f8f3cb512 100644 --- a/src/AliasVault.Client/Main/Models/Spamok/AttachmentApiModel.cs +++ b/src/AliasVault.Shared/Models/Spamok/AttachmentApiModel.cs @@ -5,7 +5,7 @@ // //----------------------------------------------------------------------- -namespace AliasVault.Client.Main.Models.Spamok; +namespace AliasVault.Shared.Models.Spamok; /// /// Represents an attachment for an email. diff --git a/src/AliasVault.Client/Main/Models/Spamok/Base/EmailApiModelBase.cs b/src/AliasVault.Shared/Models/Spamok/Base/EmailApiModelBase.cs similarity index 79% rename from src/AliasVault.Client/Main/Models/Spamok/Base/EmailApiModelBase.cs rename to src/AliasVault.Shared/Models/Spamok/Base/EmailApiModelBase.cs index 2c96fb79b..599007fe1 100644 --- a/src/AliasVault.Client/Main/Models/Spamok/Base/EmailApiModelBase.cs +++ b/src/AliasVault.Shared/Models/Spamok/Base/EmailApiModelBase.cs @@ -5,7 +5,7 @@ // //----------------------------------------------------------------------- -namespace AliasVault.Client.Main.Models.Spamok.Base; +namespace AliasVault.Shared.Models.Spamok.Base; /// /// Represents a mailbox email API model base. @@ -61,4 +61,15 @@ public abstract class EmailApiModelBase /// Gets or sets the number of seconds ago the email was received. /// public double SecondsAgo { get; set; } + + /// + /// Gets or sets the encrypted symmetric key which was used to encrypt the email message. + /// This key is encrypted with the public key of the user. + /// + public string EncryptedSymmetricKey { get; set; } = string.Empty; + + /// + /// Gets or sets the public key of the user used to encrypt the symmetric key. + /// + public string EncryptionKey { get; set; } = string.Empty; } diff --git a/src/AliasVault.Client/Main/Models/Spamok/EmailApiModel.cs b/src/AliasVault.Shared/Models/Spamok/EmailApiModel.cs similarity index 90% rename from src/AliasVault.Client/Main/Models/Spamok/EmailApiModel.cs rename to src/AliasVault.Shared/Models/Spamok/EmailApiModel.cs index aae8f03cd..875126147 100644 --- a/src/AliasVault.Client/Main/Models/Spamok/EmailApiModel.cs +++ b/src/AliasVault.Shared/Models/Spamok/EmailApiModel.cs @@ -5,9 +5,9 @@ // //----------------------------------------------------------------------- -namespace AliasVault.Client.Main.Models.Spamok; +namespace AliasVault.Shared.Models.Spamok; -using AliasVault.Client.Main.Models.Spamok.Base; +using AliasVault.Shared.Models.Spamok.Base; /// /// Represents an email API model. diff --git a/src/AliasVault.Client/Main/Models/Spamok/MailboxApiModel.cs b/src/AliasVault.Shared/Models/Spamok/MailboxApiModel.cs similarity index 95% rename from src/AliasVault.Client/Main/Models/Spamok/MailboxApiModel.cs rename to src/AliasVault.Shared/Models/Spamok/MailboxApiModel.cs index c042f25fd..d6a272abc 100644 --- a/src/AliasVault.Client/Main/Models/Spamok/MailboxApiModel.cs +++ b/src/AliasVault.Shared/Models/Spamok/MailboxApiModel.cs @@ -5,7 +5,7 @@ // //----------------------------------------------------------------------- -namespace AliasVault.Client.Main.Models.Spamok; +namespace AliasVault.Shared.Models.Spamok; /// /// Represents a mailbox API model. diff --git a/src/AliasVault.Client/Main/Models/Spamok/MailboxEmailApiModel.cs b/src/AliasVault.Shared/Models/Spamok/MailboxEmailApiModel.cs similarity index 87% rename from src/AliasVault.Client/Main/Models/Spamok/MailboxEmailApiModel.cs rename to src/AliasVault.Shared/Models/Spamok/MailboxEmailApiModel.cs index 0e2741690..d76a50069 100644 --- a/src/AliasVault.Client/Main/Models/Spamok/MailboxEmailApiModel.cs +++ b/src/AliasVault.Shared/Models/Spamok/MailboxEmailApiModel.cs @@ -5,9 +5,9 @@ // //----------------------------------------------------------------------- -namespace AliasVault.Client.Main.Models.Spamok; +namespace AliasVault.Shared.Models.Spamok; -using AliasVault.Client.Main.Models.Spamok.Base; +using AliasVault.Shared.Models.Spamok.Base; /// /// Represents a mailbox email API model. diff --git a/src/Databases/AliasServerDb/Email.cs b/src/Databases/AliasServerDb/Email.cs index 4c7643525..fdb420aec 100644 --- a/src/Databases/AliasServerDb/Email.cs +++ b/src/Databases/AliasServerDb/Email.cs @@ -123,5 +123,5 @@ public class Email /// /// Gets or sets the collection of email attachments. /// - public virtual ICollection Attachments { get; set; } = []; + public virtual List Attachments { get; set; } = []; } diff --git a/src/Services/AliasVault.SmtpService/Handlers/DatabaseMessageStore.cs b/src/Services/AliasVault.SmtpService/Handlers/DatabaseMessageStore.cs index 9f390f210..e0807742e 100644 --- a/src/Services/AliasVault.SmtpService/Handlers/DatabaseMessageStore.cs +++ b/src/Services/AliasVault.SmtpService/Handlers/DatabaseMessageStore.cs @@ -6,6 +6,7 @@ //----------------------------------------------------------------------- using Cryptography; +using SmtpServer.Mail; namespace AliasVault.SmtpService.Handlers; @@ -63,13 +64,15 @@ public class DatabaseMessageStore(ILogger logger, Config c stream.Position = 0; var message = await MimeMessage.LoadAsync(stream, cancellationToken); + // Retrieve all addresses from the SMTP transaction which should contain all recipients for this mail instance. var allAddresses = transaction.To .Distinct() .ToList(); - // Limit list to 15 addresses max. (to prevent mailbomb spam abuse) + + // Limit list to 15 addresses maximum (to prevent mailbomb spam abuse). var toAddresses = allAddresses.Take(15).ToList(); - // For every toAddress + foreach (var toAddress in toAddresses) { // Check if toAddress domain is allowed. @@ -131,7 +134,8 @@ public class DatabaseMessageStore(ILogger logger, Config c return SmtpResponse.NoValidRecipientsGiven; } - var insertedId = await InsertEmailIntoDatabase(message, userPublicKey); + // Set the "to" for the email to the actual one we are looping through now. + var insertedId = await InsertEmailIntoDatabase(message, new MailAddress(toAddress.AsAddress()), userPublicKey); logger.LogInformation("Email for {ToAddress} successfully saved into database with ID {insertedId}.", toAddress.User + "@" + toAddress.Host, insertedId); } @@ -149,12 +153,13 @@ public class DatabaseMessageStore(ILogger logger, Config c /// Insert email into database. /// /// MimeMessage to save into database. + /// The recipient for this mail. /// The public key of the user to encrypt the mail contents with. - private async Task InsertEmailIntoDatabase(MimeMessage message, UserEncryptionKey userEncryptionKey) + private async Task InsertEmailIntoDatabase(MimeMessage message, MailAddress toAddress, UserEncryptionKey userEncryptionKey) { var dbContext = await dbContextFactory.CreateDbContextAsync(); - var newEmail = ConvertMimeMessageToEmail(message); + var newEmail = ConvertMimeMessageToEmail(message, toAddress); newEmail = EmailEncryption.EncryptEmail(newEmail, userEncryptionKey); // Insert the email into the database. @@ -168,9 +173,10 @@ public class DatabaseMessageStore(ILogger logger, Config c /// Convert MimeMessage to Email database object. /// /// MimeMessage object. + /// The recipient for this mail. /// Email object. /// - private static Email ConvertMimeMessageToEmail(MimeMessage message) + private static Email ConvertMimeMessageToEmail(MimeMessage message, MailAddress toAddress) { string from = ""; @@ -195,57 +201,23 @@ public class DatabaseMessageStore(ILogger logger, Config c } catch { - // If the above fails, try to find the x-sender in the mail - try - { - MailAddress fromAddress = new MailAddress(message.Headers.First(x => x.Field == "x-sender").Value.ToString()); - fromLocal = fromAddress.User; - fromDomain = fromAddress.Host; - } - catch - { - // If this fails as well, then simply use a blank value - fromLocal = ""; - fromDomain = ""; - } - } - - MailAddress toAddress; - string to; - - // Try to extract to address firstly from x-receiver address.. - try - { - to = message.Headers.First(x => x.Field == "x-receiver").Value.ToString(); - toAddress = new MailAddress(to); - } - catch - { - // If the above fails, try to find the "to" in the mail - try - { - to = message.To.FirstOrDefault()?.ToString() ?? ""; - toAddress = new MailAddress(to); - } - catch - { - // If this fails as well, then simply let it throw an error to the caller. - throw new EmailParseMissingToException("Could not find x-receiver or to address in email."); - } + // If this fails, then simply use a blank value + fromLocal = ""; + fromDomain = ""; } // Create email object var email = new Email(); email.From = from; - email.FromLocal = fromLocal; - email.FromDomain = fromDomain; + email.FromLocal = fromLocal.ToLower(); + email.FromDomain = fromDomain.ToLower(); - email.To = to; // Local part to lowercase, as mailboxes are always lowercase + email.To = toAddress.Address.ToLower(); email.ToLocal = toAddress.User.ToLower(); - email.ToDomain = toAddress.Host; + email.ToDomain = toAddress.Host.ToLower(); - email.Subject = message.Subject ?? ""; + email.Subject = message.Subject ?? string.Empty; email.MessageHtml = message.HtmlBody; email.MessagePlain = message.TextBody; email.MessageSource = message.ToString(); diff --git a/src/Services/AliasVault.SmtpService/Scripts/sendEmailAllowed.sh b/src/Services/AliasVault.SmtpService/Scripts/sendEmailAllowed.sh index c45f3a511..56720cd5e 100755 --- a/src/Services/AliasVault.SmtpService/Scripts/sendEmailAllowed.sh +++ b/src/Services/AliasVault.SmtpService/Scripts/sendEmailAllowed.sh @@ -1 +1 @@ -curl --url "smtp://localhost:25" --mail-from "sender@example.com" --mail-rcpt "yourname@example.tld" --upload-file testEmail1.txt +curl --url "smtp://localhost:25" --mail-from "sender@example.com" --mail-rcpt "test@example.tld" --upload-file testEmail1.txt diff --git a/src/Tests/AliasVault.IntegrationTests/SmtpServer/SmtpServerTests.cs b/src/Tests/AliasVault.IntegrationTests/SmtpServer/SmtpServerTests.cs index 25084bf0f..871d5f46b 100644 --- a/src/Tests/AliasVault.IntegrationTests/SmtpServer/SmtpServerTests.cs +++ b/src/Tests/AliasVault.IntegrationTests/SmtpServer/SmtpServerTests.cs @@ -124,7 +124,7 @@ public class SmtpServerTests var processedEmail = await _testHostBuilder.GetDbContext().Emails.FirstAsync(); // Test non-encrypted field. - Assert.That(processedEmail.To, Is.EqualTo("\"Test Recipient\" ")); + Assert.That(processedEmail.To, Is.EqualTo("claimed@example.tld")); // Decrypt the email and then check all individual fields. processedEmail = EmailEncryption.DecryptEmail(processedEmail, PrivateKey); @@ -159,7 +159,7 @@ public class SmtpServerTests var processedEmail = await _testHostBuilder.GetDbContext().Emails.FirstAsync(); // Test non-encrypted field. - Assert.That(processedEmail.To, Is.EqualTo("\"Test Recipient\" ")); + Assert.That(processedEmail.To, Is.EqualTo("claimed@example.tld")); // Decrypt the email and then check all individual fields. processedEmail = EmailEncryption.DecryptEmail(processedEmail, PrivateKey); @@ -192,7 +192,7 @@ public class SmtpServerTests var processedEmail = await _testHostBuilder.GetDbContext().Emails.FirstAsync(); // Test non-encrypted field. - Assert.That(processedEmail.To, Is.EqualTo("\"Test Recipient\" ")); + Assert.That(processedEmail.To, Is.EqualTo("claimed@example.tld")); // Decrypt the email and then check all individual fields. processedEmail = EmailEncryption.DecryptEmail(processedEmail, PrivateKey); diff --git a/src/Tests/AliasVault.UnitTests/Helpers/ConversionHelperTest.cs b/src/Tests/AliasVault.UnitTests/Helpers/ConversionHelperTest.cs new file mode 100644 index 000000000..fb5a4a205 --- /dev/null +++ b/src/Tests/AliasVault.UnitTests/Helpers/ConversionHelperTest.cs @@ -0,0 +1,81 @@ +//----------------------------------------------------------------------- +// +// 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; + +using AliasVault.Api.Helpers; + +/// +/// Tests for the CsvImportExport class. +/// +public class ConversionHelperTest +{ + /// + /// 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. + /// + [Test] + public void TestAnchorTabConversionSimple() + { + string anchorHtml = ""; + string convertedAnchorTags = ConversionHelper.ConvertAnchorTagsToOpenInNewTab(anchorHtml); + + // Check that conversion works as expected. + Assert.That(convertedAnchorTags, Does.Contain("target=\"_blank\"")); + } + + /// + /// Tests the conversion of a complex anchor tag with multiple attributes to open in a new tab. + /// + [Test] + public void TestAnchorTabConversionComplex1() + { + string anchorHtml = "Start hier met de training >>>"; + string convertedAnchorTags = ConversionHelper.ConvertAnchorTagsToOpenInNewTab(anchorHtml); + + // Check that conversion works as expected. + Assert.That(convertedAnchorTags, Does.Contain("target=\"_blank\"")); + } + + /// + /// Tests the conversion of a complex anchor tag with nested elements to open in a new tab. + /// + [Test] + public void TestAnchorTabConversionComplex2() + { + string anchorHtml = ""; + string convertedAnchorTags = ConversionHelper.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 TestAnchorTabConversionComplex3() + { + string anchorHtml = "Ontvang nu jouw prijs  >"; + string convertedAnchorTags = ConversionHelper.ConvertAnchorTagsToOpenInNewTab(anchorHtml); + + // Check that conversion works as expected. + Assert.That(convertedAnchorTags, Does.Contain("target=\"_blank\"")); + } +} diff --git a/src/Utilities/Cryptography/Encryption.cs b/src/Utilities/Cryptography/Encryption.cs index 7f6318e63..5dec4775d 100644 --- a/src/Utilities/Cryptography/Encryption.cs +++ b/src/Utilities/Cryptography/Encryption.cs @@ -38,10 +38,13 @@ public static class Encryption /// The encrypted symmetric key as a base64-encoded string. public static string EncryptSymmetricKeyWithRsa(byte[] symmetricKey, string publicKey) { - using (var rsa = new RSACryptoServiceProvider()) + using (var rsa = RSA.Create()) { ImportPublicKey(rsa, publicKey); - byte[] encryptedKey = rsa.Encrypt(symmetricKey, true); + rsa.KeySize = 2048; + var rsaParams = RSAEncryptionPadding.OaepSHA256; + + byte[] encryptedKey = rsa.Encrypt(symmetricKey, rsaParams); return Convert.ToBase64String(encryptedKey); } } @@ -54,11 +57,14 @@ public static class Encryption /// The encrypted symmetric key as a base64-encoded string. public static byte[] DecryptSymmetricKeyWithRsa(string ciphertext, string privateKey) { - using (var rsa = new RSACryptoServiceProvider()) + using (var rsa = RSA.Create()) { ImportPrivateKey(rsa, privateKey); + rsa.KeySize = 2048; + var rsaParams = RSAEncryptionPadding.OaepSHA256; + byte[] cipherBytes = Convert.FromBase64String(ciphertext); - return rsa.Decrypt(cipherBytes, true); + return rsa.Decrypt(cipherBytes, rsaParams); } } @@ -178,7 +184,7 @@ public static class Encryption /// /// The RSA provider to import the key into. /// The public key in JWK format. - private static void ImportPublicKey(RSACryptoServiceProvider rsa, string jwk) + private static void ImportPublicKey(RSA rsa, string jwk) { var jwkObj = JsonSerializer.Deserialize(jwk); var n = Base64UrlDecode(jwkObj.GetProperty("n").GetString()!); @@ -198,7 +204,7 @@ public static class Encryption /// /// The RSA provider to import the key into. /// The private key in JWK format. - private static void ImportPrivateKey(RSACryptoServiceProvider rsa, string jwk) + private static void ImportPrivateKey(RSA rsa, string jwk) { var jwkObj = JsonSerializer.Deserialize(jwk); var n = Base64UrlDecode(jwkObj.GetProperty("n").GetString()!);