diff --git a/apps/server/AliasVault.Admin/Main/Models/UserUsageStatistics.cs b/apps/server/AliasVault.Admin/Main/Models/UserUsageStatistics.cs index 52f9d20d9..6adbd267e 100644 --- a/apps/server/AliasVault.Admin/Main/Models/UserUsageStatistics.cs +++ b/apps/server/AliasVault.Admin/Main/Models/UserUsageStatistics.cs @@ -41,4 +41,19 @@ public class UserUsageStatistics /// Gets or sets the number of emails received in the last 72 hours. /// public int RecentReceivedEmails72h { get; set; } + + /// + /// Gets or sets the total number of email attachments (all-time). + /// + public int TotalEmailAttachments { get; set; } + + /// + /// Gets or sets the total storage size of email attachments in bytes (all-time). + /// + public long TotalEmailAttachmentStorage { get; set; } + + /// + /// Gets the total storage size of email attachments in MB for display purposes. + /// + public double TotalEmailAttachmentStorageMB => TotalEmailAttachmentStorage / (1024.0 * 1024.0); } diff --git a/apps/server/AliasVault.Admin/Main/Pages/EmailStorageStats.razor b/apps/server/AliasVault.Admin/Main/Pages/EmailStorageStats.razor new file mode 100644 index 000000000..d5d34f09f --- /dev/null +++ b/apps/server/AliasVault.Admin/Main/Pages/EmailStorageStats.razor @@ -0,0 +1,426 @@ +@page "/email-storage-stats" +@inherits MainBase +@using AliasVault.Admin.Services +@using AliasVault.RazorComponents +@using AliasVault.RazorComponents.Tables +@using Microsoft.EntityFrameworkCore +@inject StatisticsService StatisticsService +@inject IAliasServerDbContextFactory ContextFactory +@implements IDisposable + + + + + + + +
+ + @if (LoadingStats) + { +
+ @for (int i = 0; i < 3; i++) + { +
+
+
+
+
+
+
+
+ } +
+ } + else + { +
+
+
+
+

Total Emails

+

@TotalEmails.ToString("N0")

+
+
+
+ +
+
+
+

Email Attachments

+

@TotalAttachments.ToString("N0")

+
+
+
+ +
+
+
+

Storage Used

+

@TotalStorageMB.ToString("N2") MB

+
+
+
+
+ } + + +
+
+
+

Email Attachments

+

All email attachments stored in the system. The content of the attachments is encrypted with the user's public key and are unreadable by the server.

+
+
+ + @if (LoadingAttachments) + { +
+
+ @for (int i = 0; i < 5; i++) + { +
+
+
+
+
+
+
+
+
+ } +
+
+ } + else if (Attachments?.Any() == true) + { +
+ + @foreach (var attachment in Attachments) + { + + @attachment.Id + + @attachment.Filename +
@attachment.MimeType
+
+ @FormatFileSize(attachment.Filesize) + + @attachment.EmailId + + + @if (!string.IsNullOrEmpty(attachment.UserEmail)) + { + + @attachment.UserEmail + + } + else + { + Unknown + } + + + + +
+ } +
+
+ +
+ +
+ } + else + { +
+
+ + + +
+

No attachments found

+

No email attachments exist in the system.

+
+ } +
+ + @if (ErrorMessage != null) + { +
+
+
+ + + +
+
+

Error Loading Data

+

@ErrorMessage

+
+
+
+ } +
+ +@code { + private bool LoadingStats = true; + private bool LoadingAttachments = true; + private string? ErrorMessage; + private bool _disposed = false; + + private int TotalEmails = 0; + private int TotalAttachments = 0; + private double TotalStorageMB = 0; + + private List? Attachments; + private int CurrentPage = 1; + private int TotalRecords = 0; + + private string SortColumn { get; set; } = "Filesize"; + private SortDirection SortDirection { get; set; } = SortDirection.Descending; + + private readonly List _tableColumns = [ + new TableColumn { Title = "ID", PropertyName = "Id" }, + new TableColumn { Title = "Filename", PropertyName = "Filename" }, + new TableColumn { Title = "File Size", PropertyName = "Filesize" }, + new TableColumn { Title = "Email ID", PropertyName = "EmailId" }, + new TableColumn { Title = "User", PropertyName = "UserEmail" }, + new TableColumn { Title = "Actions", Sortable = false }, + ]; + + public class EmailAttachmentModel + { + public int Id { get; set; } + public string Filename { get; set; } = string.Empty; + public string MimeType { get; set; } = string.Empty; + public int Filesize { get; set; } + public int EmailId { get; set; } + public string? UserId { get; set; } + public string? UserEmail { get; set; } + } + + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = "Emails", Url = "/emails" }); + BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = "Email Storage Stats" }); + + await RefreshData(); + } + + private void SafeStateHasChanged() + { + if (!_disposed) + { + InvokeAsync(StateHasChanged); + } + } + + private async Task RefreshData() + { + if (_disposed) return; + + LoadingStats = true; + LoadingAttachments = true; + ErrorMessage = null; + SafeStateHasChanged(); + + try + { + await using var context = await ContextFactory.CreateDbContextAsync(); + + // Load statistics + TotalEmails = await context.Emails.CountAsync(); + TotalAttachments = await context.EmailAttachments.CountAsync(); + + var totalBytes = TotalAttachments > 0 + ? await context.EmailAttachments.SumAsync(a => (long)a.Filesize) + : 0L; + TotalStorageMB = totalBytes / (1024.0 * 1024.0); + + LoadingStats = false; + SafeStateHasChanged(); + + // Load attachments + await LoadAttachments(); + } + catch (Exception ex) + { + if (!_disposed) + { + ErrorMessage = $"Error loading data: {ex.Message}"; + LoadingStats = false; + LoadingAttachments = false; + SafeStateHasChanged(); + } + } + } + + private async Task LoadAttachments() + { + if (_disposed) return; + + LoadingAttachments = true; + SafeStateHasChanged(); + + try + { + await using var context = await ContextFactory.CreateDbContextAsync(); + + TotalRecords = await context.EmailAttachments.CountAsync(); + + var skip = (CurrentPage - 1) * 20; + + // Apply sorting and pagination + var sortedQuery = ApplySort(context.EmailAttachments); + + var attachmentData = await sortedQuery + .Skip(skip) + .Take(20) + .ToListAsync(); + + // Then map to view models to avoid disposed context issues + if (!_disposed) + { + Attachments = attachmentData.Select(a => new EmailAttachmentModel + { + Id = a.Id, + Filename = a.Filename, + MimeType = a.MimeType, + Filesize = a.Filesize, + EmailId = a.EmailId, + UserId = a.Email?.EncryptionKey?.UserId, + UserEmail = a.Email?.EncryptionKey?.User?.Email + }).ToList(); + } + } + catch (Exception ex) + { + if (!_disposed) + { + ErrorMessage = $"Error loading attachments: {ex.Message}"; + } + } + finally + { + if (!_disposed) + { + LoadingAttachments = false; + SafeStateHasChanged(); + } + } + } + + private async Task HandlePageChanged(int newPage) + { + CurrentPage = newPage; + await LoadAttachments(); + } + + private async Task HandleSortChanged((string column, SortDirection direction) sort) + { + SortColumn = sort.column; + SortDirection = sort.direction; + CurrentPage = 1; // Reset to first page when sorting changes + await LoadAttachments(); + } + + private IQueryable ApplySort(IQueryable query) + { + // Always include navigation properties for all sorting scenarios + query = query.Include(a => a.Email) + .ThenInclude(e => e.EncryptionKey) + .ThenInclude(k => k.User); + + switch (SortColumn) + { + case "Id": + query = SortDirection == SortDirection.Ascending + ? query.OrderBy(x => x.Id) + : query.OrderByDescending(x => x.Id); + break; + case "Filename": + query = SortDirection == SortDirection.Ascending + ? query.OrderBy(x => x.Filename) + : query.OrderByDescending(x => x.Filename); + break; + case "Filesize": + query = SortDirection == SortDirection.Ascending + ? query.OrderBy(x => x.Filesize) + : query.OrderByDescending(x => x.Filesize); + break; + case "EmailId": + query = SortDirection == SortDirection.Ascending + ? query.OrderBy(x => x.EmailId) + : query.OrderByDescending(x => x.EmailId); + break; + case "UserEmail": + query = SortDirection == SortDirection.Ascending + ? query.OrderBy(x => x.Email.EncryptionKey.User.Email) + : query.OrderByDescending(x => x.Email.EncryptionKey.User.Email); + break; + default: + query = query.OrderByDescending(x => x.Filesize); + break; + } + + return query; + } + + private async Task DeleteAttachment(int attachmentId) + { + var confirmed = await ConfirmModalService.ShowConfirmation( + "Delete Attachment", + "Are you sure you want to delete this attachment? This action cannot be undone.", + "Delete", + "Cancel"); + + if (!confirmed) return; + + try + { + await using var context = await ContextFactory.CreateDbContextAsync(); + + var attachment = await context.EmailAttachments.FindAsync(attachmentId); + if (attachment != null) + { + context.EmailAttachments.Remove(attachment); + await context.SaveChangesAsync(); + + // Refresh data after deletion + await RefreshData(); + } + } + catch (Exception ex) + { + if (!_disposed) + { + ErrorMessage = $"Error deleting attachment: {ex.Message}"; + SafeStateHasChanged(); + } + } + } + + private static string FormatFileSize(int bytes) + { + if (bytes < 1024) + return $"{bytes} B"; + else if (bytes < 1024 * 1024) + return $"{bytes / 1024.0:F1} KB"; + else + return $"{bytes / (1024.0 * 1024.0):F1} MB"; + } + + public void Dispose() + { + _disposed = true; + } +} \ No newline at end of file diff --git a/apps/server/AliasVault.Admin/Main/Pages/Emails.razor b/apps/server/AliasVault.Admin/Main/Pages/Emails.razor index 6861dea6c..0c2a10602 100644 --- a/apps/server/AliasVault.Admin/Main/Pages/Emails.razor +++ b/apps/server/AliasVault.Admin/Main/Pages/Emails.razor @@ -7,8 +7,14 @@ + Description="This page shows all received mails by this AliasVault server. All email fields except 'To' are encrypted with the public key of the user and are unreadable by the server."> + + + + + Email Storage Stats + diff --git a/apps/server/AliasVault.Admin/Main/Pages/Users/View/Index.razor b/apps/server/AliasVault.Admin/Main/Pages/Users/View/Index.razor index e1e37e2ee..d9f486ad3 100644 --- a/apps/server/AliasVault.Admin/Main/Pages/Users/View/Index.razor +++ b/apps/server/AliasVault.Admin/Main/Pages/Users/View/Index.razor @@ -35,7 +35,7 @@ else {

Usage Statistics

-
+
@UserUsageStats.TotalCredentials.ToString("N0")
Total Credentials
@@ -60,6 +60,14 @@ else
(+@UserUsageStats.RecentReceivedEmails72h.ToString("N0") in last 72h)
}
+
+
@UserUsageStats.TotalEmailAttachments.ToString("N0")
+
Email Attachments
+ @if (UserUsageStats.TotalEmailAttachmentStorageMB > 0) + { +
@UserUsageStats.TotalEmailAttachmentStorageMB.ToString("N2") MB
+ } +
} diff --git a/apps/server/AliasVault.Admin/Services/StatisticsService.cs b/apps/server/AliasVault.Admin/Services/StatisticsService.cs index 127392f0b..b1a66c3fa 100644 --- a/apps/server/AliasVault.Admin/Services/StatisticsService.cs +++ b/apps/server/AliasVault.Admin/Services/StatisticsService.cs @@ -167,6 +167,15 @@ public class StatisticsService .Where(e => e.EncryptionKey.UserId == userId && e.DateSystem >= cutoffDate) .CountAsync(); + // Get email attachment statistics (all-time) + var emailAttachmentQuery = context.EmailAttachments + .Where(a => a.Email.EncryptionKey.UserId == userId); + + stats.TotalEmailAttachments = await emailAttachmentQuery.CountAsync(); + stats.TotalEmailAttachmentStorage = stats.TotalEmailAttachments > 0 + ? await emailAttachmentQuery.SumAsync(a => (long)a.Filesize) + : 0L; + return stats; } diff --git a/apps/server/AliasVault.Admin/wwwroot/css/tailwind.css b/apps/server/AliasVault.Admin/wwwroot/css/tailwind.css index 68b1651f4..0d40973c3 100644 --- a/apps/server/AliasVault.Admin/wwwroot/css/tailwind.css +++ b/apps/server/AliasVault.Admin/wwwroot/css/tailwind.css @@ -759,6 +759,10 @@ video { margin-top: 2rem; } +.mb-8 { + margin-bottom: 2rem; +} + .line-clamp-1 { overflow: hidden; display: -webkit-box; @@ -918,6 +922,14 @@ video { width: 100%; } +.w-24 { + width: 6rem; +} + +.w-32 { + width: 8rem; +} + .max-w-2xl { max-width: 42rem; } @@ -1187,6 +1199,10 @@ video { border-left-width: 4px; } +.border-t { + border-top-width: 1px; +} + .border-gray-200 { --tw-border-opacity: 1; border-color: rgb(229 231 235 / var(--tw-border-opacity)); @@ -1402,6 +1418,11 @@ video { background-color: rgb(234 179 8 / var(--tw-bg-opacity)); } +.bg-purple-100 { + --tw-bg-opacity: 1; + background-color: rgb(243 232 255 / var(--tw-bg-opacity)); +} + .bg-opacity-50 { --tw-bg-opacity: 0.5; } @@ -1430,6 +1451,10 @@ video { padding: 1.5rem; } +.p-8 { + padding: 2rem; +} + .px-1 { padding-left: 0.25rem; padding-right: 0.25rem; @@ -1637,6 +1662,10 @@ video { font-weight: 600; } +.uppercase { + text-transform: uppercase; +} + .italic { font-style: italic; } @@ -1764,6 +1793,11 @@ video { color: rgb(34 197 94 / var(--tw-text-opacity)); } +.text-blue-500 { + --tw-text-opacity: 1; + color: rgb(59 130 246 / var(--tw-text-opacity)); +} + .underline { text-decoration-line: underline; } @@ -1923,6 +1957,16 @@ video { color: rgb(154 93 38 / var(--tw-text-opacity)); } +.hover\:text-blue-800:hover { + --tw-text-opacity: 1; + color: rgb(30 64 175 / var(--tw-text-opacity)); +} + +.hover\:text-red-900:hover { + --tw-text-opacity: 1; + color: rgb(127 29 29 / var(--tw-text-opacity)); +} + .hover\:underline:hover { text-decoration-line: underline; } @@ -2152,6 +2196,15 @@ video { background-color: rgb(17 24 39 / 0.5); } +.dark\:bg-purple-900:is(.dark *) { + --tw-bg-opacity: 1; + background-color: rgb(88 28 135 / var(--tw-bg-opacity)); +} + +.dark\:bg-red-800\/10:is(.dark *) { + background-color: rgb(153 27 27 / 0.1); +} + .dark\:bg-opacity-80:is(.dark *) { --tw-bg-opacity: 0.8; } @@ -2256,6 +2309,16 @@ video { color: rgb(254 240 138 / var(--tw-text-opacity)); } +.dark\:text-gray-500:is(.dark *) { + --tw-text-opacity: 1; + color: rgb(107 114 128 / var(--tw-text-opacity)); +} + +.dark\:text-purple-300:is(.dark *) { + --tw-text-opacity: 1; + color: rgb(216 180 254 / var(--tw-text-opacity)); +} + .dark\:placeholder-gray-400:is(.dark *)::-moz-placeholder { --tw-placeholder-opacity: 1; color: rgb(156 163 175 / var(--tw-placeholder-opacity)); @@ -2320,6 +2383,16 @@ video { color: rgb(255 255 255 / var(--tw-text-opacity)); } +.dark\:hover\:text-blue-300:hover:is(.dark *) { + --tw-text-opacity: 1; + color: rgb(147 197 253 / var(--tw-text-opacity)); +} + +.dark\:hover\:text-red-300:hover:is(.dark *) { + --tw-text-opacity: 1; + color: rgb(252 165 165 / var(--tw-text-opacity)); +} + .dark\:focus\:border-blue-500:focus:is(.dark *) { --tw-border-opacity: 1; border-color: rgb(59 130 246 / var(--tw-border-opacity)); @@ -2560,6 +2633,10 @@ video { grid-template-columns: repeat(4, minmax(0, 1fr)); } + .lg\:grid-cols-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + .lg\:flex-row { flex-direction: row; }