Files
aliasvault/apps/server/AliasVault.Client/Main/Pages/Emails/Home.razor

748 lines
28 KiB
Plaintext

@page "/emails"
@using System.Net
@using System.Text
@using System.Text.Json
@using AliasVault.Client.Main.Pages.Emails.Models
@using AliasVault.Shared.Models.Spamok
@using AliasVault.Shared.Models.WebApi
@using AliasVault.Shared.Models.WebApi.Email
@using AliasVault.Client.Main.Components
@using AliasVault.Client.Main.Components.Email
@using AliasVault.Client.Main.Services
@inherits MainBase
@inject HttpClient HttpClient
@inject ILogger<Home> Logger
@inject MinDurationLoadingService LoadingService
@using Microsoft.Extensions.Localization
@implements IAsyncDisposable
<LayoutPageTitle>@Localizer["PageTitle"]</LayoutPageTitle>
@if (EmailModalVisible)
{
<EmailModal Email="EmailModalEmail" IsSpamOk="false" OnClose="CloseEmailModal" OnEmailDeleted="RefreshData" />
}
<PageHeader
BreadcrumbItems="@BreadcrumbItems"
Title="@Localizer["PageTitle"]"
Description="@Localizer["PageDescription"]">
<CustomActions>
@if (DbService.Settings.AutoEmailRefresh)
{
<div class="w-3 h-3 mr-2 rounded-full bg-primary-300 border-2 border-primary-100 animate-pulse" title="@Localizer["AutoRefreshEnabledTooltip"]"></div>
}
<RefreshButton OnClick="RefreshData" ButtonText="@Localizer["RefreshButton"]" />
</CustomActions>
</PageHeader>
@if (IsLoading)
{
<div class="px-4">
<!-- Mobile Skeleton -->
<div class="block lg:hidden mt-6">
<div class="bg-white border rounded-lg dark:bg-gray-800 dark:border-gray-700 overflow-hidden">
<ul class="divide-y divide-gray-200 dark:divide-gray-600">
@for (int i = 0; i < 5; i++)
{
<EmailRowSkeleton />
}
</ul>
</div>
</div>
<!-- Desktop Skeleton -->
<div class="hidden lg:flex mt-6 h-[calc(100vh-300px)] min-h-[600px]">
<!-- Left Sidebar Skeleton -->
<div class="w-1/4 bg-white border rounded-l-lg dark:bg-gray-800 dark:border-gray-700 overflow-hidden">
<div class="h-full overflow-y-auto">
<ul class="divide-y divide-gray-200 dark:divide-gray-600">
@for (int i = 0; i < 5; i++)
{
<EmailRowSkeleton />
}
</ul>
</div>
</div>
<!-- Right Panel Skeleton -->
<div class="w-3/4">
<EmailPreviewSkeleton />
</div>
</div>
</div>
}
else if (NoEmailClaims)
{
<div class="p-4 mx-4 mt-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
<div class="px-4 py-2 text-gray-400 rounded">
<p class="text-gray-500 dark:text-gray-400">@Localizer["NoEmailClaimsMessage"]</p>
</div>
</div>
}
else
{
<div class="px-4">
<!-- Mobile Layout (sm and down) - Modal behavior with traditional pagination -->
<div class="block lg:hidden mt-6">
<ResponsivePaginator CurrentPage="CurrentPage" PageSize="PageSize" TotalRecords="TotalRecords" OnPageChanged="HandlePageChanged"/>
<div class="bg-white border rounded-lg dark:bg-gray-800 dark:border-gray-700 overflow-hidden mt-4">
<ul class="divide-y divide-gray-200 dark:divide-gray-600">
@if (EmailList.Count == 0)
{
<li class="p-4 text-center text-gray-500 dark:text-gray-300">
@Localizer["NoEmailsReceivedMessage"]
</li>
}
else
{
@foreach (var email in EmailList)
{
<EmailRow
Email="email"
OnEmailClick="ShowAliasVaultEmailInModal"
IsSelected="false"
IsNewEmail="@(NewEmailIds.Contains(email.Id))" />
}
}
</ul>
</div>
</div>
<!-- Desktop Layout (lg and up) - Sidebar and Preview with Load More -->
<div class="hidden lg:flex mt-6 rounded-lg overflow-hidden">
@if (EmailList.Count == 0)
{
<!-- Single row message for desktop when no emails -->
<div class="w-full bg-white border rounded-lg dark:bg-gray-800 dark:border-gray-700 overflow-hidden">
<div class="p-4 text-center text-gray-500 dark:text-gray-300">
@Localizer["NoEmailsReceivedMessage"]
</div>
</div>
}
else
{
<div class="w-full h-[calc(100vh-300px)] min-h-[600px] flex rounded-lg overflow-hidden">
<!-- Left Sidebar - Email List -->
<div class="w-1/4 bg-white border border-r-0 dark:bg-gray-800 dark:border-gray-700 flex flex-col">
<div class="flex-1 overflow-y-auto" id="email-list-container">
<ul>
@foreach (var email in EmailList)
{
<EmailRow
Email="email"
OnEmailClick="SelectEmailForPreview"
IsSelected="@(SelectedEmailId == email.Id)"
IsNewEmail="@(NewEmailIds.Contains(email.Id))" />
}
<!-- Load More Button for Desktop -->
@if (HasMoreEmails && EmailList.Count > 0)
{
<li class="border-t border-gray-200 dark:border-gray-600 p-3 bg-gray-50 dark:bg-gray-700">
<button @onclick="LoadMoreEmails"
disabled="@IsLoadingMore"
class="w-full px-4 py-2 text-sm font-medium text-primary-600 bg-primary-50 border border-primary-200 rounded-md hover:bg-primary-100 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed dark:text-primary-400 dark:bg-primary-900/20 dark:border-primary-800 dark:hover:bg-primary-900/30">
@if (IsLoadingMore)
{
<span class="flex items-center justify-center">
<svg class="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
@Localizer["LoadingText"]
</span>
}
else
{
<span>@string.Format(Localizer["LoadMoreButtonText"], TotalRecords - EmailList.Count)</span>
}
</button>
</li>
}
</ul>
</div>
</div>
<!-- Right Panel - Email Preview -->
<div class="w-3/4">
<EmailPreview
Email="SelectedEmail"
IsSpamOk="false"
OnEmailDeleted="HandleEmailDeleted"
CredentialId="@GetSelectedEmailCredentialId()"
CredentialName="@GetSelectedEmailCredentialName()"
OnCredentialClick="NavigateToCredential" />
</div>
</div>
}
</div>
</div>
}
@code {
private IStringLocalizer Localizer => LocalizerFactory.Create("Pages.Main.Emails.Home", "AliasVault.Client");
private List<MailListViewModel> EmailList { get; set; } = [];
private bool IsLoading => LoadingService.IsLoading("emails");
private int CurrentPage { get; set; } = 1;
private int PageSize { get; set; } = 50;
private int TotalRecords { get; set; }
private bool EmailModalVisible { get; set; }
private bool NoEmailClaims { get; set; }
private EmailApiModel EmailModalEmail { get; set; } = new();
private int? SelectedEmailId { get; set; }
private EmailApiModel? SelectedEmail { get; set; }
private bool IsLoadingMore { get; set; }
private bool HasMoreEmails => TotalRecords > EmailList.Count;
// Auto-refresh related properties
private const int ACTIVE_TAB_REFRESH_INTERVAL = 2000; // 2 seconds
private CancellationTokenSource? _pollingCts;
private DotNetObjectReference<Home>? _dotNetRef;
private bool _isPageVisible = true;
private HashSet<int> NewEmailIds { get; set; } = new();
private HashSet<int> _knownEmailIds = new();
/// <summary>
/// Callback invoked by JavaScript when the page visibility changes. This is used to start/stop the polling for new emails.
/// </summary>
/// <param name="isVisible">Indicates whether the page is visible or not.</param>
[JSInvokable]
public void OnVisibilityChange(bool isVisible)
{
_isPageVisible = isVisible;
if (isVisible && DbService.Settings.AutoEmailRefresh)
{
// Start polling if visible and auto-refresh is enabled
StartPolling();
}
else
{
// Stop polling if hidden
StopPolling();
}
// If becoming visible, do an immediate refresh
if (isVisible)
{
_ = CheckForNewEmails();
}
}
private void StartPolling()
{
// If already polling, no need to start again
if (_pollingCts != null) {
return;
}
_pollingCts = new CancellationTokenSource();
// Start polling task
_ = PollForNewEmails(_pollingCts.Token);
}
private void StopPolling()
{
if (_pollingCts != null)
{
_pollingCts.Cancel();
_pollingCts.Dispose();
_pollingCts = null;
}
}
private async Task PollForNewEmails(CancellationToken cancellationToken)
{
try
{
while (!cancellationToken.IsCancellationRequested)
{
await CheckForNewEmails();
await Task.Delay(ACTIVE_TAB_REFRESH_INTERVAL, cancellationToken);
}
}
catch (OperationCanceledException)
{
// Normal cancellation, ignore.
}
catch (Exception ex)
{
Logger.LogError(ex, "Error in email refresh polling");
}
}
/// <summary>
/// Load emails from the server with the given parameters.
/// </summary>
private async Task<(List<MailListViewModel> Emails, int TotalRecords, int CurrentPage, int PageSize)?> LoadEmailsFromServerAsync(int page, int pageSize, List<string> emailClaimList)
{
if (emailClaimList.Count == 0)
{
return null;
}
var requestModel = new MailboxBulkRequest
{
Page = page,
PageSize = pageSize,
Addresses = emailClaimList,
};
var request = new HttpRequestMessage(HttpMethod.Post, $"v1/EmailBox/bulk");
request.Content = new StringContent(JsonSerializer.Serialize(requestModel), Encoding.UTF8, "application/json");
try
{
var response = await HttpClient.SendAsync(request);
if (response.IsSuccessStatusCode)
{
var mailbox = await response.Content.ReadFromJsonAsync<MailboxBulkResponse>();
if (mailbox?.Mails != null)
{
var context = await DbService.GetDbContextAsync();
var credentialLookup = await context.Items
.Include(x => x.FieldValues)
.Where(x => !x.IsDeleted && x.DeletedAt == null)
.Where(x => x.FieldValues.Any(fv => fv.FieldKey == AliasClientDb.Models.FieldKey.LoginEmail && fv.Value != null && !fv.IsDeleted))
.Select(x => new {
x.Id,
x.Name,
Email = x.FieldValues.FirstOrDefault(fv => fv.FieldKey == AliasClientDb.Models.FieldKey.LoginEmail && !fv.IsDeleted)!.Value!.ToLower()
})
.GroupBy(x => x.Email)
.ToDictionaryAsync(
g => g.Key,
g => new { Id = g.First().Id, ServiceName = g.First().Name ?? "Unknown" }
);
List<MailboxEmailApiModel> decryptedEmailList;
try
{
decryptedEmailList = await EmailService.DecryptEmailList(mailbox.Mails);
}
catch (InvalidOperationException ex) when (ex.Message.Contains("Sequence contains no matching element"))
{
// Handle case where encryption keys are not available for some emails
Logger.LogWarning(ex, "Failed to decrypt some emails due to missing encryption keys");
return null;
}
var emails = decryptedEmailList.Select(email =>
{
var toEmail = email.ToLocal + "@" + email.ToDomain;
var credentialInfo = credentialLookup.TryGetValue(toEmail.ToLower(), out var info)
? info
: new { Id = Guid.Empty, ServiceName = "Unknown" };
return new MailListViewModel
{
Id = email.Id,
Date = email.DateSystem,
FromName = email.FromDisplay,
FromEmail = email.FromLocal + "@" + email.FromDomain,
ToEmail = toEmail,
Subject = email.Subject,
MessagePreview = email.MessagePreview,
CredentialId = credentialInfo.Id,
CredentialName = credentialInfo.ServiceName,
HasAttachments = email.HasAttachments,
};
}).ToList();
return (emails, mailbox.TotalRecords, mailbox.CurrentPage, mailbox.PageSize);
}
}
else
{
var errorContent = await response.Content.ReadAsStringAsync();
var errorResponse = JsonSerializer.Deserialize<ApiErrorResponse>(errorContent);
switch (response.StatusCode)
{
case HttpStatusCode.BadRequest:
if (errorResponse != null)
{
switch (errorResponse.Code)
{
case "CLAIM_DOES_NOT_EXIST":
GlobalNotificationService.AddErrorMessage(Localizer["ClaimDoesNotExistError"], true);
break;
default:
throw new ArgumentException(errorResponse.Message);
}
}
break;
case HttpStatusCode.Unauthorized:
throw new UnauthorizedAccessException(errorResponse?.Message);
default:
throw new WebException(errorResponse?.Message);
}
}
}
catch (Exception ex)
{
GlobalNotificationService.AddErrorMessage(ex.Message, true);
Logger.LogError(ex, "An error occurred while loading emails from server");
}
return null;
}
/// <summary>
/// Check for new emails without disrupting the current view.
/// </summary>
private async Task CheckForNewEmails()
{
if (!_isPageVisible || !DbService.Settings.AutoEmailRefresh)
{
return;
}
try
{
var emailClaimList = await DbService.GetEmailClaimListAsync();
var result = await LoadEmailsFromServerAsync(1, 5, emailClaimList);
if (result.HasValue)
{
var (newEmails, _, _, _) = result.Value;
// Check for new emails
var newEmailIds = newEmails.Where(email => !_knownEmailIds.Contains(email.Id)).Select(email => email.Id).ToList();
// Update the known email IDs
foreach (var email in newEmails)
{
_knownEmailIds.Add(email.Id);
}
// Add new emails to the list and mark them as new
if (newEmailIds.Count > 0)
{
// Add new emails to the beginning of the list
var emailsToAdd = newEmails.Where(e => newEmailIds.Contains(e.Id)).ToList();
EmailList.InsertRange(0, emailsToAdd);
// Update total records
TotalRecords += emailsToAdd.Count;
// Mark emails as new
NewEmailIds.UnionWith(newEmailIds);
// Remove new email indicators after 30 seconds
_ = Task.Delay(30000).ContinueWith(_ =>
{
NewEmailIds.ExceptWith(newEmailIds);
InvokeAsync(StateHasChanged);
});
StateHasChanged();
}
}
}
catch (Exception ex)
{
Logger.LogError(ex, "An error occurred while checking for new emails");
}
}
/// <inheritdoc />
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
// Create a single object reference for JS interop
_dotNetRef = DotNetObjectReference.Create(this);
await JsInteropService.RegisterVisibilityCallback(_dotNetRef);
// Only start polling if auto-refresh is enabled and page is visible
if (DbService.Settings.AutoEmailRefresh && _isPageVisible)
{
StartPolling();
}
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
// Stop polling
StopPolling();
// Unregister the visibility callback using the same reference
if (_dotNetRef != null)
{
await JsInteropService.UnregisterVisibilityCallback(_dotNetRef);
_dotNetRef.Dispose();
}
}
/// <inheritdoc />
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
if (firstRender)
{
await RefreshData();
}
}
private void HandlePageChanged(int newPage)
{
CurrentPage = newPage;
_ = RefreshData();
}
private async Task RefreshData()
{
LoadingService.StartLoading("emails", 300, StateHasChanged);
NoEmailClaims = false;
CloseEmailModal();
// Clear selected email when refreshing
SelectedEmailId = null;
SelectedEmail = null;
// Reset pagination for fresh load
CurrentPage = 1;
EmailList.Clear();
NewEmailIds.Clear();
_knownEmailIds.Clear();
var emailClaimList = await DbService.GetEmailClaimListAsync();
if (emailClaimList.Count == 0)
{
LoadingService.FinishLoading("emails", StateHasChanged);
NoEmailClaims = true;
return;
}
var result = await LoadEmailsFromServerAsync(CurrentPage, PageSize, emailClaimList);
if (result.HasValue)
{
var (emails, totalRecords, currentPage, pageSize) = result.Value;
await UpdateMailboxEmails(emails, totalRecords, currentPage, pageSize, false);
}
LoadingService.FinishLoading("emails", StateHasChanged);
}
/// <summary>
/// Update the local mailbox emails.
/// </summary>
private async Task UpdateMailboxEmails(List<MailListViewModel> emails, int totalRecords, int currentPage, int pageSize, bool appendToList = false)
{
if (appendToList)
{
EmailList.AddRange(emails);
}
else
{
EmailList = emails;
// Initialize known email IDs for auto-refresh - don't mark existing emails as new
_knownEmailIds = new HashSet<int>(emails.Select(e => e.Id));
// Clear any existing new email indicators since this is the initial load
NewEmailIds.Clear();
}
CurrentPage = currentPage;
PageSize = pageSize;
TotalRecords = totalRecords;
// Auto-select first email on desktop layout if none is selected and emails exist (only for initial load)
if (!appendToList && EmailList.Count > 0 && SelectedEmailId == null)
{
var firstEmail = EmailList[0];
SelectedEmailId = firstEmail.Id;
await LoadSelectedEmailForPreview(firstEmail.Id);
}
}
/// <summary>
/// Load recent emails from AliasVault.
/// </summary>
private async Task ShowAliasVaultEmailInModal(int emailId)
{
// Remove new email mark when email is clicked
NewEmailIds.Remove(emailId);
EmailApiModel? mail = await HttpClient.GetFromJsonAsync<EmailApiModel>($"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)
{
mail = await EmailService.DecryptEmail(mail);
}
EmailModalEmail = mail;
EmailModalVisible = true;
StateHasChanged();
}
}
/// <summary>
/// Close the email modal.
/// </summary>
private void CloseEmailModal()
{
EmailModalVisible = false;
StateHasChanged();
}
/// <summary>
/// Navigate to the credential page.
/// </summary>
private void NavigateToCredential(Guid credentialId)
{
NavigationManager.NavigateTo($"/items/{credentialId}");
}
/// <summary>
/// Select an email for preview (desktop layout).
/// </summary>
private async Task SelectEmailForPreview(int emailId)
{
// Remove new email mark when email is clicked
NewEmailIds.Remove(emailId);
SelectedEmailId = emailId;
await LoadSelectedEmailForPreview(emailId);
}
/// <summary>
/// Load the full email data for preview.
/// </summary>
private async Task LoadSelectedEmailForPreview(int emailId)
{
try
{
EmailApiModel? mail = await HttpClient.GetFromJsonAsync<EmailApiModel>($"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)
{
mail = await EmailService.DecryptEmail(mail);
}
SelectedEmail = mail;
StateHasChanged();
}
}
catch (Exception ex)
{
GlobalNotificationService.AddErrorMessage(string.Format(Localizer["LoadEmailsFailedMessage"], ex.Message), true);
Logger.LogError(ex, "An error occurred while loading email for preview");
}
}
/// <summary>
/// Handle email deletion from preview panel.
/// </summary>
private async Task HandleEmailDeleted(int emailId)
{
// Remove the deleted email from the list
var deletedEmailIndex = EmailList.FindIndex(e => e.Id == emailId);
EmailList.RemoveAll(e => e.Id == emailId);
// Remove from tracking sets
NewEmailIds.Remove(emailId);
_knownEmailIds.Remove(emailId);
// Update total records
TotalRecords = Math.Max(0, TotalRecords - 1);
// Handle selection logic
if (SelectedEmailId == emailId)
{
SelectedEmailId = null;
SelectedEmail = null;
// Try to select the next email in the list
if (EmailList.Count > 0)
{
// If we deleted the first email, select the new first email
if (deletedEmailIndex == 0)
{
var nextEmail = EmailList[0];
SelectedEmailId = nextEmail.Id;
await LoadSelectedEmailForPreview(nextEmail.Id);
}
// If we deleted an email in the middle, select the email at the same index (or the last one if index is out of bounds)
else if (deletedEmailIndex > 0)
{
var newIndex = Math.Min(deletedEmailIndex, EmailList.Count - 1);
var nextEmail = EmailList[newIndex];
SelectedEmailId = nextEmail.Id;
await LoadSelectedEmailForPreview(nextEmail.Id);
}
}
}
StateHasChanged();
}
/// <summary>
/// Get the credential ID for the currently selected email.
/// </summary>
private Guid GetSelectedEmailCredentialId()
{
if (SelectedEmailId == null) return Guid.Empty;
var selectedEmailListItem = EmailList.FirstOrDefault(e => e.Id == SelectedEmailId);
return selectedEmailListItem?.CredentialId ?? Guid.Empty;
}
/// <summary>
/// Get the credential name for the currently selected email.
/// </summary>
private string GetSelectedEmailCredentialName()
{
if (SelectedEmailId == null) return string.Empty;
var selectedEmailListItem = EmailList.FirstOrDefault(e => e.Id == SelectedEmailId);
return selectedEmailListItem?.CredentialName ?? string.Empty;
}
/// <summary>
/// Load more emails for the desktop view.
/// </summary>
private async Task LoadMoreEmails()
{
if (IsLoadingMore || !HasMoreEmails) return;
IsLoadingMore = true;
StateHasChanged();
try
{
var emailClaimList = await DbService.GetEmailClaimListAsync();
var result = await LoadEmailsFromServerAsync(CurrentPage + 1, PageSize, emailClaimList);
if (result.HasValue)
{
var (emails, totalRecords, currentPage, pageSize) = result.Value;
await UpdateMailboxEmails(emails, totalRecords, currentPage, pageSize, true); // Append to existing list
}
}
catch (Exception ex)
{
GlobalNotificationService.AddErrorMessage(string.Format(Localizer["LoadMoreEmailsFailedMessage"], ex.Message), true);
Logger.LogError(ex, "An error occurred while loading more emails");
}
finally
{
IsLoadingMore = false;
StateHasChanged();
}
}
}