diff --git a/src/AliasVault.Api/Controllers/AuthController.cs b/src/AliasVault.Api/Controllers/AuthController.cs index 9fe97dba0..b8fe5f61d 100644 --- a/src/AliasVault.Api/Controllers/AuthController.cs +++ b/src/AliasVault.Api/Controllers/AuthController.cs @@ -240,7 +240,7 @@ public class AuthController(IDbContextFactory dbContextFac var newRefreshToken = GenerateRefreshToken(); // Add new refresh token. - await context.AliasVaultUserRefreshTokens.AddAsync(new AliasVaultUserRefreshToken + context.AliasVaultUserRefreshTokens.Add(new AliasVaultUserRefreshToken { UserId = user.Id, DeviceIdentifier = deviceIdentifier, @@ -526,7 +526,7 @@ public class AuthController(IDbContextFactory dbContextFac context.AliasVaultUserRefreshTokens.RemoveRange(existingTokens); // Add new refresh token. - await context.AliasVaultUserRefreshTokens.AddAsync(new AliasVaultUserRefreshToken + context.AliasVaultUserRefreshTokens.Add(new AliasVaultUserRefreshToken { UserId = user.Id, DeviceIdentifier = deviceIdentifier, diff --git a/src/AliasVault.Api/Controllers/Email/EmailController.cs b/src/AliasVault.Api/Controllers/Email/EmailController.cs index 2bf1b9685..0022e662b 100644 --- a/src/AliasVault.Api/Controllers/Email/EmailController.cs +++ b/src/AliasVault.Api/Controllers/Email/EmailController.cs @@ -19,10 +19,11 @@ using Microsoft.EntityFrameworkCore; /// /// Email controller for retrieving emails from the database. /// +/// ILogger instance. /// DbContext instance. /// UserManager instance. [ApiVersion("1")] -public class EmailController(IDbContextFactory dbContextFactory, UserManager userManager) : AuthenticatedRequestController(userManager) +public class EmailController(ILogger logger, IDbContextFactory dbContextFactory, UserManager userManager) : AuthenticatedRequestController(userManager) { /// /// Get the newest version of the vault for the current user. @@ -34,31 +35,15 @@ public class EmailController(IDbContextFactory dbContextFa { await using var context = await dbContextFactory.CreateDbContextAsync(); - var user = await GetCurrentUserAsync(); - if (user is null) + var (email, errorResult) = await AuthenticateAndRetrieveEmailAsync(id, context); + if (errorResult != 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."); + return errorResult; } var returnEmail = new EmailApiModel { - Id = email.Id, + Id = email!.Id, Subject = email.Subject, FromDomain = email.FromDomain, FromLocal = email.FromLocal, @@ -87,4 +72,75 @@ public class EmailController(IDbContextFactory dbContextFa return Ok(returnEmail); } + + /// + /// Deletes an email for the current user. + /// + /// The email ID to delete. + /// A response indicating the success or failure of the deletion. + [HttpDelete(template: "{id}", Name = "DeleteEmail")] + public async Task DeleteEmail(int id) + { + await using var context = await dbContextFactory.CreateDbContextAsync(); + + var (email, errorResult) = await AuthenticateAndRetrieveEmailAsync(id, context); + if (errorResult != null) + { + return errorResult; + } + + // Delete associated attachments + context.EmailAttachments.RemoveRange(email!.Attachments); + + // Delete the email + context.Emails.Remove(email); + + try + { + await context.SaveChangesAsync(); + return Ok(); + } + catch (Exception ex) + { + // Log the exception + logger.LogError(ex, "An error occurred while deleting email with ID {id}.", id); + return StatusCode(500, $"An error occurred while deleting the email: {ex.Message}"); + } + } + + /// + /// Authenticates the user and retrieves the requested email. + /// + /// The email ID to retrieve. + /// The database context. + /// A tuple containing the authenticated user, the email, and an IActionResult if there's an error. + private async Task<(Email? Email, IActionResult? ErrorResult)> AuthenticateAndRetrieveEmailAsync(int id, AliasServerDbContext context) + { + var user = await GetCurrentUserAsync(); + if (user is null) + { + return (null, Unauthorized("Not authenticated.")); + } + + // Retrieve email from database. + var email = await context.Emails + .Include(x => x.Attachments) + .FirstOrDefaultAsync(x => x.Id == id); + + if (email is null) + { + return (null, 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 (null, Unauthorized("User does not have a claim to this email address.")); + } + + return (email, null); + } } diff --git a/src/AliasVault.Api/Controllers/VaultController.cs b/src/AliasVault.Api/Controllers/VaultController.cs index bd769a88a..1ec730f60 100644 --- a/src/AliasVault.Api/Controllers/VaultController.cs +++ b/src/AliasVault.Api/Controllers/VaultController.cs @@ -116,7 +116,7 @@ public class VaultController(ILogger logger, IDbContextFactory< context.Vaults.RemoveRange(vaultsToDelete); // Add the new vault and commit to database. - await context.Vaults.AddAsync(newVault); + context.Vaults.Add(newVault); await context.SaveChangesAsync(); // Update user email claims if email addresses have been supplied. @@ -176,7 +176,7 @@ public class VaultController(ILogger logger, IDbContextFactory< { try { - await context.UserEmailClaims.AddAsync(new UserEmailClaim + context.UserEmailClaims.Add(new UserEmailClaim { UserId = userId, Address = sanitizedEmail, diff --git a/src/AliasVault.Client/Main/Components/Email/EmailModal.razor b/src/AliasVault.Client/Main/Components/Email/EmailModal.razor index 14dd4abd0..a2053e68f 100644 --- a/src/AliasVault.Client/Main/Components/Email/EmailModal.razor +++ b/src/AliasVault.Client/Main/Components/Email/EmailModal.razor @@ -1,6 +1,9 @@ @using AliasVault.Shared.Models.Spamok @using AliasVault.Shared.Utilities @inject JsInteropService JsInteropService +@inject GlobalNotificationService GlobalNotificationService +@inject IHttpClientFactory HttpClientFactory +@inject HttpClient HttpClient
@@ -33,8 +36,9 @@
-
- +
+ +
@@ -59,6 +63,12 @@ [Parameter] public EventCallback OnClose { get; set; } + /// + /// Callback when an email is deleted. + /// + [Parameter] + public EventCallback OnEmailDeleted { get; set; } + /// /// The message body to display /// @@ -73,6 +83,89 @@ return OnClose.InvokeAsync(false); } + /// + /// Delete the current email. + /// + private async Task DeleteEmail() + { + if (Email == null) + { + return; + } + + if (IsSpamOk) + { + await DeleteEmailSpamOk(); + } + else + { + await DeleteEmailAliasVault(); + } + } + + /// + /// Delete the current email in SpamOk. + /// + private async Task DeleteEmailSpamOk() + { + if (Email == null) + { + return; + } + + try + { + var client = HttpClientFactory.CreateClient("EmailClient"); + var response = await client.DeleteAsync($"https://api.spamok.com/v2/Email/{Email.ToLocal}/{Email.Id}"); + if (response.IsSuccessStatusCode) + { + GlobalNotificationService.AddSuccessMessage("Email deleted successfully", true); + await OnEmailDeleted.InvokeAsync(Email.Id); + await Close(); + } + else + { + var errorMessage = await response.Content.ReadAsStringAsync(); + GlobalNotificationService.AddErrorMessage($"Failed to delete email: {errorMessage}", true); + } + } + catch (Exception ex) + { + GlobalNotificationService.AddErrorMessage($"An error occurred: {ex.Message}", true); + } + } + + /// + /// Delete the current email in AliasVault. + /// + private async Task DeleteEmailAliasVault() + { + if (Email == null) + { + return; + } + + try + { + var response = await HttpClient.DeleteAsync($"api/v1/Email/{Email.Id}"); + if (response.IsSuccessStatusCode) + { + GlobalNotificationService.AddSuccessMessage("Email deleted successfully", true); + await OnEmailDeleted.InvokeAsync(Email.Id); + await Close(); + } + else + { + var errorMessage = await response.Content.ReadAsStringAsync(); + GlobalNotificationService.AddErrorMessage($"Failed to delete email: {errorMessage}", true); + } + } + catch (Exception ex) + { + GlobalNotificationService.AddErrorMessage($"An error occurred: {ex.Message}", true); + } + } + /// protected override async Task OnInitializedAsync() { diff --git a/src/AliasVault.Client/Main/Components/Email/RecentEmails.razor b/src/AliasVault.Client/Main/Components/Email/RecentEmails.razor index 7e227c231..da56d8ee1 100644 --- a/src/AliasVault.Client/Main/Components/Email/RecentEmails.razor +++ b/src/AliasVault.Client/Main/Components/Email/RecentEmails.razor @@ -14,7 +14,7 @@ @if (EmailModalVisible) { - + } @if (ShowComponent) diff --git a/src/AliasVault.Client/Main/Pages/Emails/Home.razor b/src/AliasVault.Client/Main/Pages/Emails/Home.razor index 7e8163b7c..13108fc3f 100644 --- a/src/AliasVault.Client/Main/Pages/Emails/Home.razor +++ b/src/AliasVault.Client/Main/Pages/Emails/Home.razor @@ -13,7 +13,7 @@ @if (EmailModalVisible) { - + }
@@ -44,7 +44,7 @@ else
-
+

Inbox

diff --git a/src/AliasVault.Client/Services/CredentialService.cs b/src/AliasVault.Client/Services/CredentialService.cs index 15326c704..0b2880be7 100644 --- a/src/AliasVault.Client/Services/CredentialService.cs +++ b/src/AliasVault.Client/Services/CredentialService.cs @@ -160,7 +160,7 @@ public sealed class CredentialService(HttpClient httpClient, DbService dbService login.Attachments.Add(attachment); } - await context.Credentials.AddAsync(login); + context.Credentials.Add(login); // Add password. login.Passwords.Add(loginObject.Passwords.First()); diff --git a/src/AliasVault.Client/Services/Database/DbService.cs b/src/AliasVault.Client/Services/Database/DbService.cs index 5bdfa4f75..072b2b3d6 100644 --- a/src/AliasVault.Client/Services/Database/DbService.cs +++ b/src/AliasVault.Client/Services/Database/DbService.cs @@ -526,7 +526,7 @@ public sealed class DbService : IDisposable CreatedAt = DateTime.Now, UpdatedAt = DateTime.Now, }; - await _dbContext.EncryptionKeys.AddAsync(encryptionKey); + _dbContext.EncryptionKeys.Add(encryptionKey); return encryptionKey; } } diff --git a/src/AliasVault.Client/wwwroot/index.template.html b/src/AliasVault.Client/wwwroot/index.template.html index 5ab34962f..8eea190e3 100644 --- a/src/AliasVault.Client/wwwroot/index.template.html +++ b/src/AliasVault.Client/wwwroot/index.template.html @@ -77,30 +77,32 @@ const randomIndex = Math.floor(Math.random() * securityQuotes.length); quoteElement.textContent = `"${securityQuotes[randomIndex]}"`; - window.addEventListener('load', function() { + function manageLoadingScreen() { const startTime = new Date().getTime(); const minDisplayTime = 1000; + const checkInterval = 500; - function hideLoadingScreen() { - document.getElementById('loading-screen').style.display = 'none'; - document.getElementById('app').style.removeProperty('visibility'); - } + const appElement = document.getElementById('app'); + const loadingScreen = document.getElementById('loading-screen'); - function checkElapsedTime() { - const currentTime = new Date().getTime(); - const elapsedTime = currentTime - startTime; + appElement.style.visibility = 'hidden'; + loadingScreen.style.display = 'flex'; - if (elapsedTime >= minDisplayTime) { - hideLoadingScreen(); - } else { - setTimeout(hideLoadingScreen, minDisplayTime - elapsedTime); + const checkContentAndTime = () => { + const elapsedTime = new Date().getTime() - startTime; + const hasContent = appElement.innerHTML.trim() !== ''; + + if ((elapsedTime >= minDisplayTime && hasContent)) { + loadingScreen.style.display = 'none'; + appElement.style.removeProperty('visibility'); + clearInterval(intervalId); } - } + }; - document.getElementById('app').style.visibility = 'hidden'; - document.getElementById('loading-screen').style.display = 'flex'; - checkElapsedTime(); - }); + const intervalId = setInterval(checkContentAndTime, checkInterval); + } + + window.addEventListener('load', manageLoadingScreen); diff --git a/src/Services/AliasVault.SmtpService/Handlers/DatabaseMessageStore.cs b/src/Services/AliasVault.SmtpService/Handlers/DatabaseMessageStore.cs index 3777b2955..b7e780300 100644 --- a/src/Services/AliasVault.SmtpService/Handlers/DatabaseMessageStore.cs +++ b/src/Services/AliasVault.SmtpService/Handlers/DatabaseMessageStore.cs @@ -357,7 +357,7 @@ public class DatabaseMessageStore(ILogger logger, Config c newEmail = EmailEncryption.EncryptEmail(newEmail, userEncryptionKey); // Insert the email into the database. - await dbContext.Emails.AddAsync(newEmail); + dbContext.Emails.Add(newEmail); await dbContext.SaveChangesAsync(); return newEmail.Id; diff --git a/src/Tests/AliasVault.E2ETests/Tests/Client/EmailDecryptionTests.cs b/src/Tests/AliasVault.E2ETests/Tests/Client/EmailDecryptionTests.cs index 96a7e4f8e..ddfa98529 100644 --- a/src/Tests/AliasVault.E2ETests/Tests/Client/EmailDecryptionTests.cs +++ b/src/Tests/AliasVault.E2ETests/Tests/Client/EmailDecryptionTests.cs @@ -46,12 +46,13 @@ public class EmailDecryptionTests : ClientPlaywrightTest } /// - /// Test if received email encrypted by server can be successfully decrypted by client. + /// Test if received email encrypted by server can be successfully decrypted by client + /// and then be deleted by client. /// /// Async task. [Test] [Order(1)] - public async Task EmailEncryptionDecryptionTest() + public async Task EmailEncryptionDecryptionDeleteTest() { // Create credential which should automatically create claim on server during database sync. const string serviceName = "Test Service"; @@ -124,6 +125,16 @@ public class EmailDecryptionTests : ClientPlaywrightTest // 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(); + + // Wait for the email delete confirm message to show up. + await WaitForUrlAsync("emails**", "Email deleted successfully"); + + // Assert that the email is no longer visible on the page. + var body = await Page.TextContentAsync("body"); + Assert.That(body, Does.Not.Contain(textSubject), "Email not deleted from page after deletion. Check email deletion logic."); } /// diff --git a/src/Utilities/AliasVault.AuthLogging/AuthLoggingService.cs b/src/Utilities/AliasVault.AuthLogging/AuthLoggingService.cs index c1fe881a1..bf0ba9c61 100644 --- a/src/Utilities/AliasVault.AuthLogging/AuthLoggingService.cs +++ b/src/Utilities/AliasVault.AuthLogging/AuthLoggingService.cs @@ -48,7 +48,7 @@ public class AuthLoggingService(IServiceProvider serviceProvider, IHttpContextAc IsSuspiciousActivity = false, }; - await dbContext.AuthLogs.AddAsync(authAttempt); + dbContext.AuthLogs.Add(authAttempt); await dbContext.SaveChangesAsync(); } @@ -82,7 +82,7 @@ public class AuthLoggingService(IServiceProvider serviceProvider, IHttpContextAc IsSuspiciousActivity = false, }; - await dbContext.AuthLogs.AddAsync(authAttempt); + dbContext.AuthLogs.Add(authAttempt); await dbContext.SaveChangesAsync(); } diff --git a/src/Utilities/AliasVault.WorkerStatus/StatusWorker.cs b/src/Utilities/AliasVault.WorkerStatus/StatusWorker.cs index 2535ebd2e..08255a37d 100644 --- a/src/Utilities/AliasVault.WorkerStatus/StatusWorker.cs +++ b/src/Utilities/AliasVault.WorkerStatus/StatusWorker.cs @@ -84,7 +84,7 @@ public class StatusWorker(ILogger logger, FuncNew current status. private async Task GetServiceStatus() { - var entry = await GetOrCreateInitialStatusRecord(); + var entry = GetOrCreateInitialStatusRecord(); if (!string.IsNullOrEmpty(entry.DesiredStatus) && entry.CurrentStatus != entry.DesiredStatus) { @@ -155,7 +155,7 @@ public class StatusWorker(ILogger logger, Func /// Retrieves status record or creates an initial status record if it does not exist. /// - private async Task GetOrCreateInitialStatusRecord() + private WorkerServiceStatus GetOrCreateInitialStatusRecord() { var entry = _dbContext.WorkerServiceStatuses.FirstOrDefault(x => x.ServiceName == globalServiceStatus.ServiceName); if (entry != null) @@ -169,7 +169,7 @@ public class StatusWorker(ILogger logger, Func