Files
aliasvault/apps/server/AliasVault.Client/Main/Components/Email/RecentEmails.razor
2025-07-16 11:28:28 +02:00

504 lines
19 KiB
Plaintext

@using System.Net
@using System.Text.Json
@using AliasVault.Shared.Models.Spamok
@using AliasVault.Shared.Models.WebApi
@using AliasVault.Client.Main.Services
@inherits ComponentBase
@inject IHttpClientFactory HttpClientFactory
@inject HttpClient HttpClient
@inject JsInteropService JsInteropService
@inject DbService DbService
@inject EmailService EmailService
@using AliasVault.Shared.Core
@inject ILogger<RecentEmails> Logger
@inject MinDurationLoadingService LoadingService
@inject IStringLocalizerFactory LocalizerFactory
@implements IAsyncDisposable
@using Microsoft.Extensions.Localization
@if (EmailModalVisible)
{
<EmailModal Email="@Email" IsSpamOk="@IsSpamOk" OnClose="CloseEmailModal" OnEmailDeleted="ManualRefresh" />
}
@if (ShowComponent)
{
<div class="p-4 mb-4 bg-white border border-gray-200 rounded-lg shadow-sm 2xl:col-span-2 dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<div class="flex justify-between">
<div>
<h3 class="mb-4 text-xl font-semibold dark:text-white">@Localizer["EmailSectionTitle"]</h3>
</div>
<div class="flex justify-end items-center space-x-2">
@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>
}
<button id="recent-email-refresh" @onclick="ManualRefresh" type="button" class="text-blue-700 border border-blue-700 hover:bg-blue-700 hover:text-white focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-full text-sm p-2 text-center inline-flex items-center dark:border-blue-500 dark:text-blue-500 dark:hover:text-white dark:focus:ring-blue-800 dark:hover:bg-blue-500">
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
</button>
</div>
</div>
@if (IsLoading)
{
<div class="flex flex-col mt-6">
<div class="overflow-x-auto rounded-lg">
<div class="inline-block min-w-full align-middle">
<div class="overflow-hidden shadow sm:rounded-lg">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-600">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th scope="col" class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white">
@Localizer["SubjectColumn"]
</th>
<th scope="col" class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white">
@Localizer["DateColumn"]
</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-800">
@for (int i = 0; i < 2; i++)
{
<tr class="hover:bg-gray-50 dark:hover:bg-gray-600">
<td class="p-4">
<SkeletonBase Height="20" AdditionalClasses="w-48">
<div class="w-full h-full bg-gray-300 dark:bg-gray-700 rounded"></div>
</SkeletonBase>
</td>
<td class="p-4">
<SkeletonBase Height="20" AdditionalClasses="w-24">
<div class="w-full h-full bg-gray-300 dark:bg-gray-700 rounded"></div>
</SkeletonBase>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>
}
else if (!string.IsNullOrEmpty(Error))
{
<AlertMessageError Message="@Error" />
}
else if (MailboxEmails.Count == 0)
{
<div class="text-gray-500 dark:text-gray-400">@Localizer["NoEmailsReceivedMessage"]</div>
}
else
{
<div class="flex flex-col mt-6">
<div class="overflow-x-auto rounded-lg">
<div class="inline-block min-w-full align-middle">
<div class="overflow-hidden shadow sm:rounded-lg">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-600">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th scope="col" class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white">
@Localizer["SubjectColumn"]
</th>
<th scope="col" class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white">
@Localizer["DateColumn"]
</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-800">
@foreach (var mail in MailboxEmails)
{
<tr class="hover:bg-gray-50 dark:hover:bg-gray-600">
<td class="p-4 text-sm font-normal text-gray-900 whitespace-nowrap dark:text-white">
@if (mail.Subject.Length > 30)
{
<span class="cursor-pointer" @onclick="() => OpenEmail(mail.Id)">@(mail.Subject.Substring(0, 30))...</span>
}
else
{
<span class="cursor-pointer" @onclick="() => OpenEmail(mail.Id)">@mail.Subject</span>
}
</td>
<td class="p-4 text-sm font-normal text-gray-500 whitespace-nowrap dark:text-gray-400">
<span class="cursor-pointer" @onclick="() => OpenEmail(mail.Id)">@mail.DateSystem.ToString("yyyy-MM-dd")</span>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>
}
</div>
}
@code {
/// <summary>
/// The email address to show recent emails for.
/// </summary>
[Parameter]
public string? EmailAddress { get; set; } = string.Empty;
private List<MailboxEmailApiModel> MailboxEmails { get; set; } = new();
private bool ShowComponent { get; set; } = false;
private EmailApiModel Email { get; set; } = new();
private bool EmailModalVisible { get; set; }
private string Error { get; set; } = string.Empty;
private bool IsLoading => LoadingService.IsLoading("recentemails");
private bool IsSpamOk { get; set; } = false;
private IStringLocalizer Localizer => LocalizerFactory.Create("Components.Main.Email.RecentEmails", "AliasVault.Client");
private const int ACTIVE_TAB_REFRESH_INTERVAL = 2000; // 2 seconds
private CancellationTokenSource? _pollingCts;
private DotNetObjectReference<RecentEmails>? _dotNetRef;
private bool _isPageVisible = true;
/// <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)
{
_ = ManualRefresh();
}
}
private void StartPolling()
{
// If already polling, no need to start again
if (_pollingCts != null) {
return;
}
_pollingCts = new CancellationTokenSource();
// Start polling task
_ = PollForEmails(_pollingCts.Token);
}
private void StopPolling()
{
if (_pollingCts != null)
{
_pollingCts.Cancel();
_pollingCts.Dispose();
_pollingCts = null;
}
}
private async Task PollForEmails(CancellationToken cancellationToken)
{
try
{
while (!cancellationToken.IsCancellationRequested)
{
await LoadRecentEmailsAsync();
await Task.Delay(ACTIVE_TAB_REFRESH_INTERVAL, cancellationToken);
}
}
catch (OperationCanceledException)
{
// Normal cancellation, ignore.
}
catch (Exception ex)
{
Logger.LogError(ex, "Error in email refresh polling");
}
}
/// <inheritdoc />
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
if (EmailAddress is null)
{
return;
}
// Check if email has a known SpamOK domain, if not, don't show this component.
ShowComponent = EmailService.IsAliasVaultSupportedDomain(EmailAddress);
IsSpamOk = EmailService.IsSpamOkDomain(EmailAddress);
// 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 (!ShowComponent)
{
return;
}
if (firstRender)
{
await ManualRefresh();
}
}
/// <inheritdoc />
protected override void OnParametersSet()
{
base.OnParametersSet();
if (EmailAddress is null)
{
return;
}
IsSpamOk = EmailService.IsSpamOkDomain(EmailAddress);
}
/// <summary>
/// Manually refresh the emails.
/// </summary>
/// <returns></returns>
private async Task ManualRefresh()
{
LoadingService.StartLoading("recentemails", 300, StateHasChanged);
StateHasChanged();
CloseEmailModal();
await LoadRecentEmailsAsync();
LoadingService.FinishLoading("recentemails", StateHasChanged);
StateHasChanged();
}
/// <summary>
/// (Re)load recent emails by making an API call to the server.
/// </summary>
/// <returns>Task.</returns>
private async Task LoadRecentEmailsAsync()
{
if (!ShowComponent || EmailAddress is null)
{
return;
}
// Get email prefix, which is the part before the @ symbol.
string emailPrefix = EmailAddress.Split('@')[0];
if (EmailService.IsSpamOkDomain(EmailAddress))
{
await LoadSpamOkEmails(emailPrefix);
}
else if (EmailService.IsAliasVaultDomain(EmailAddress))
{
await LoadAliasVaultEmails();
}
StateHasChanged();
}
/// <summary>
/// Open the email modal.
/// </summary>
private async Task OpenEmail(int emailId)
{
if (EmailAddress is null)
{
return;
}
// Get email prefix, which is the part before the @ symbol.
string emailPrefix = EmailAddress.Split('@')[0];
if (EmailService.IsSpamOkDomain(EmailAddress))
{
await ShowSpamOkEmailInModal(emailPrefix, emailId);
}
else if (EmailService.IsAliasVaultDomain(EmailAddress))
{
await ShowAliasVaultEmailInModal(emailId);
}
}
/// <summary>
/// Load recent emails from SpamOK.
/// </summary>
private async Task LoadSpamOkEmails(string emailPrefix)
{
// 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");
var request = new HttpRequestMessage(HttpMethod.Get, $"https://api.spamok.com/v2/EmailBox/{emailPrefix}");
request.Headers.Add("X-Asdasd-Platform-Id", "av-web");
request.Headers.Add("X-Asdasd-Platform-Version", AppInfo.GetFullVersion());
var response = await client.SendAsync(request);
if (response.IsSuccessStatusCode)
{
var mailbox = await response.Content.ReadFromJsonAsync<MailboxApiModel>();
if (mailbox != null)
{
// Show maximum of 10 recent emails.
MailboxEmails = mailbox.Mails.Take(10).ToList();
}
}
}
/// <summary>
/// Load recent emails from SpamOK.
/// </summary>
private async Task ShowSpamOkEmailInModal(string emailPrefix, int emailId)
{
var client = HttpClientFactory.CreateClient("EmailClient");
var request = new HttpRequestMessage(HttpMethod.Get, $"https://api.spamok.com/v2/Email/{emailPrefix}/{emailId}");
request.Headers.Add("X-Asdasd-Platform-Id", "av-web");
request.Headers.Add("X-Asdasd-Platform-Version", AppInfo.GetFullVersion());
var response = await client.SendAsync(request);
if (response.IsSuccessStatusCode)
{
var mail = await response.Content.ReadFromJsonAsync<EmailApiModel>();
if (mail != null)
{
Email = mail;
EmailModalVisible = true;
StateHasChanged();
}
}
}
/// <summary>
/// Load recent emails from AliasVault.
/// </summary>
private async Task LoadAliasVaultEmails()
{
var request = new HttpRequestMessage(HttpMethod.Get, $"v1/EmailBox/{EmailAddress}");
try
{
var response = await HttpClient.SendAsync(request);
if (response.IsSuccessStatusCode)
{
var mailbox = await response.Content.ReadFromJsonAsync<MailboxApiModel>();
await UpdateMailboxEmails(mailbox);
}
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_MATCH_USER":
Error = Localizer["EmailAddressInUseError"];
break;
case "CLAIM_DOES_NOT_EXIST":
Error = Localizer["EmailLoadError"];
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)
{
Error = ex.Message;
Logger.LogError(ex, "An error occurred while loading AliasVault emails.");
}
}
/// <summary>
/// Update the mailbox emails and decrypt the subject locally.
/// </summary>
private async Task UpdateMailboxEmails(MailboxApiModel? mailbox)
{
if (mailbox?.Mails != null)
{
// Show maximum of 10 recent emails.
MailboxEmails = mailbox.Mails.Take(10).ToList();
}
MailboxEmails = await EmailService.DecryptEmailList(MailboxEmails);
Error = string.Empty;
}
/// <summary>
/// Load recent emails from AliasVault.
/// </summary>
private async Task ShowAliasVaultEmailInModal(int 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);
}
Email = mail;
EmailModalVisible = true;
StateHasChanged();
}
}
/// <summary>
/// Close the email modal.
/// </summary>
private void CloseEmailModal()
{
EmailModalVisible = false;
StateHasChanged();
}
}