From 4938129367c279ba469fa81e2ee7fd43f56f2d9e Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Mon, 4 Aug 2025 21:25:34 +0200 Subject: [PATCH] Add per user email limits configurable through admin (#1075) --- .../Main/Pages/Users/View/Index.razor | 92 +- .../Databases/AliasServerDb/AliasVaultUser.cs | 10 + ...04182808_AddPerUserEmailLimits.Designer.cs | 926 ++++++++++++++++++ .../20250804182808_AddPerUserEmailLimits.cs | 40 + ...s => AliasServerDbContextModelSnapshot.cs} | 8 +- .../Tasks/EmailCleanupTask.cs | 72 +- .../Tasks/EmailQuotaCleanupTask.cs | 63 +- 7 files changed, 1175 insertions(+), 36 deletions(-) create mode 100644 apps/server/Databases/AliasServerDb/Migrations/20250804182808_AddPerUserEmailLimits.Designer.cs create mode 100644 apps/server/Databases/AliasServerDb/Migrations/20250804182808_AddPerUserEmailLimits.cs rename apps/server/Databases/AliasServerDb/Migrations/{AliasServerDbContextPostgresqlModelSnapshot.cs => AliasServerDbContextModelSnapshot.cs} (99%) 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 d9f486ad3..00a4cf7f7 100644 --- a/apps/server/AliasVault.Admin/Main/Pages/Users/View/Index.razor +++ b/apps/server/AliasVault.Admin/Main/Pages/Users/View/Index.razor @@ -75,7 +75,7 @@ else
- + @@ -115,6 +115,52 @@ else + + + +
Registered at @User.CreatedAt.ToString("yyyy-MM-dd HH:mm")
Email Limits + @if (!IsEditingEmailLimits) + { +
+
+ + Max Emails: @(User.MaxEmails == 0 ? "Unlimited" : User.MaxEmails.ToString("N0")) + +
+
+ + Max Age: @(User.MaxEmailAgeDays == 0 ? "Unlimited" : User.MaxEmailAgeDays + " days") + +
+
+ +
+
+ } + else + { +
+
+ +
+ + (0 = unlimited) +
+
+
+ +
+ + days (0 = unlimited) +
+
+
+ + +
+
+ } +
@@ -135,7 +181,7 @@ else
-

UserRefreshTokens (Logged in devices)

+

Logged in devices

@@ -185,6 +231,9 @@ else private List VaultList { get; set; } = []; private List AuthLogList { get; set; } = []; private UserUsageStatistics? UserUsageStats { get; set; } + private bool IsEditingEmailLimits { get; set; } + private int EditMaxEmails { get; set; } + private int EditMaxEmailAgeDays { get; set; } /// protected override async Task OnInitializedAsync() @@ -427,4 +476,43 @@ Do you want to proceed with the restoration?")) { await RefreshData(); } } + + /// + /// Starts editing email limits for the user. + /// + private void StartEditingEmailLimits() + { + IsEditingEmailLimits = true; + EditMaxEmails = User!.MaxEmails; + EditMaxEmailAgeDays = User.MaxEmailAgeDays; + } + + /// + /// Cancels editing email limits. + /// + private void CancelEditingEmailLimits() + { + IsEditingEmailLimits = false; + EditMaxEmails = 0; + EditMaxEmailAgeDays = 0; + } + + /// + /// Saves the email limits for the user. + /// + private async Task SaveEmailLimits() + { + await using var dbContext = await DbContextFactory.CreateDbContextAsync(); + User = await dbContext.AliasVaultUsers.FindAsync(Id); + + if (User != null) + { + User.MaxEmails = EditMaxEmails; + User.MaxEmailAgeDays = EditMaxEmailAgeDays; + await dbContext.SaveChangesAsync(); + IsEditingEmailLimits = false; + await RefreshData(); + GlobalNotificationService.AddSuccessMessage("Email limits updated successfully.", true); + } + } } diff --git a/apps/server/Databases/AliasServerDb/AliasVaultUser.cs b/apps/server/Databases/AliasServerDb/AliasVaultUser.cs index f05401e10..5eeffc77c 100644 --- a/apps/server/Databases/AliasServerDb/AliasVaultUser.cs +++ b/apps/server/Databases/AliasServerDb/AliasVaultUser.cs @@ -34,6 +34,16 @@ public class AliasVaultUser : IdentityUser /// public DateTime UpdatedAt { get; set; } + /// + /// Gets or sets the maximum number of emails for all of user's aliases. 0 means unlimited. + /// + public int MaxEmails { get; set; } = 0; + + /// + /// Gets or sets the maximum age of emails in days. Emails older than this will be deleted. 0 means unlimited. + /// + public int MaxEmailAgeDays { get; set; } = 0; + /// /// Gets or sets the collection of vaults. /// diff --git a/apps/server/Databases/AliasServerDb/Migrations/20250804182808_AddPerUserEmailLimits.Designer.cs b/apps/server/Databases/AliasServerDb/Migrations/20250804182808_AddPerUserEmailLimits.Designer.cs new file mode 100644 index 000000000..43ad6d90f --- /dev/null +++ b/apps/server/Databases/AliasServerDb/Migrations/20250804182808_AddPerUserEmailLimits.Designer.cs @@ -0,0 +1,926 @@ +// +using System; +using AliasServerDb; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace AliasServerDb.Migrations +{ + [DbContext(typeof(AliasServerDbContext))] + [Migration("20250804182808_AddPerUserEmailLimits")] + partial class AddPerUserEmailLimits + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.4") + .HasAnnotation("Proxies:ChangeTracking", false) + .HasAnnotation("Proxies:CheckEquality", false) + .HasAnnotation("Proxies:LazyLoading", true) + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("AliasServerDb.AdminRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("NormalizedName") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("AdminRoles"); + }); + + modelBuilder.Entity("AliasServerDb.AdminUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .HasColumnType("text"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LastPasswordChanged") + .HasColumnType("timestamp with time zone"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasColumnType("text"); + + b.Property("NormalizedUserName") + .HasColumnType("text"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("AdminUsers"); + }); + + modelBuilder.Entity("AliasServerDb.AliasVaultRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("NormalizedName") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("AliasVaultRoles"); + }); + + modelBuilder.Entity("AliasServerDb.AliasVaultUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("Blocked") + .HasColumnType("boolean"); + + b.Property("ConcurrencyStamp") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("MaxEmailAgeDays") + .HasColumnType("integer"); + + b.Property("MaxEmails") + .HasColumnType("integer"); + + b.Property("NormalizedEmail") + .HasColumnType("text"); + + b.Property("NormalizedUserName") + .HasColumnType("text"); + + b.Property("PasswordChangedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserName") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("AliasVaultUsers"); + }); + + modelBuilder.Entity("AliasServerDb.AliasVaultUserRefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeviceIdentifier") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ExpireDate") + .HasMaxLength(255) + .HasColumnType("timestamp with time zone"); + + b.Property("IpAddress") + .HasMaxLength(45) + .HasColumnType("character varying(45)"); + + b.Property("PreviousTokenValue") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AliasVaultUserRefreshTokens"); + }); + + modelBuilder.Entity("AliasServerDb.AuthLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AdditionalInfo") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Browser") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Client") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Country") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("DeviceType") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EventType") + .HasColumnType("integer"); + + b.Property("FailureReason") + .HasColumnType("integer"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("IsSuccess") + .HasColumnType("boolean"); + + b.Property("IsSuspiciousActivity") + .HasColumnType("boolean"); + + b.Property("OperatingSystem") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequestPath") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UserAgent") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "EventType" }, "IX_EventType"); + + b.HasIndex(new[] { "IpAddress" }, "IX_IpAddress"); + + b.HasIndex(new[] { "Timestamp" }, "IX_Timestamp"); + + b.HasIndex(new[] { "Username", "IsSuccess", "Timestamp" }, "IX_Username_IsSuccess_Timestamp") + .IsDescending(false, false, true); + + b.HasIndex(new[] { "Username", "Timestamp" }, "IX_Username_Timestamp") + .IsDescending(false, true); + + b.ToTable("AuthLogs"); + }); + + modelBuilder.Entity("AliasServerDb.Email", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("DateSystem") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedSymmetricKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("From") + .IsRequired() + .HasColumnType("text"); + + b.Property("FromDomain") + .IsRequired() + .HasColumnType("text"); + + b.Property("FromLocal") + .IsRequired() + .HasColumnType("text"); + + b.Property("MessageHtml") + .HasColumnType("text"); + + b.Property("MessagePlain") + .HasColumnType("text"); + + b.Property("MessagePreview") + .HasColumnType("text"); + + b.Property("MessageSource") + .IsRequired() + .HasColumnType("text"); + + b.Property("PushNotificationSent") + .HasColumnType("boolean"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("text"); + + b.Property("To") + .IsRequired() + .HasColumnType("text"); + + b.Property("ToDomain") + .IsRequired() + .HasColumnType("text"); + + b.Property("ToLocal") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserEncryptionKeyId") + .HasMaxLength(255) + .HasColumnType("uuid"); + + b.Property("Visible") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.HasIndex("DateSystem"); + + b.HasIndex("PushNotificationSent"); + + b.HasIndex("ToLocal"); + + b.HasIndex("UserEncryptionKeyId"); + + b.HasIndex("Visible"); + + b.ToTable("Emails"); + }); + + modelBuilder.Entity("AliasServerDb.EmailAttachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Bytes") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("EmailId") + .HasColumnType("integer"); + + b.Property("Filename") + .IsRequired() + .HasColumnType("text"); + + b.Property("Filesize") + .HasColumnType("integer"); + + b.Property("MimeType") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("EmailId"); + + b.ToTable("EmailAttachments"); + }); + + modelBuilder.Entity("AliasServerDb.Log", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Application") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Exception") + .IsRequired() + .HasColumnType("text"); + + b.Property("Level") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("LogEvent") + .IsRequired() + .HasColumnType("text") + .HasColumnName("LogEvent"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("MessageTemplate") + .IsRequired() + .HasColumnType("text"); + + b.Property("Properties") + .IsRequired() + .HasColumnType("text"); + + b.Property("SourceContext") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("TimeStamp") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Application"); + + b.HasIndex("TimeStamp"); + + b.ToTable("Logs", (string)null); + }); + + modelBuilder.Entity("AliasServerDb.ServerSetting", b => + { + b.Property("Key") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("Key"); + + b.ToTable("ServerSettings"); + }); + + modelBuilder.Entity("AliasServerDb.TaskRunnerJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("EndTime") + .HasColumnType("time without time zone"); + + b.Property("ErrorMessage") + .HasColumnType("text"); + + b.Property("IsOnDemand") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("RunDate") + .HasColumnType("timestamp with time zone"); + + b.Property("StartTime") + .HasColumnType("time without time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("TaskRunnerJobs"); + }); + + modelBuilder.Entity("AliasServerDb.UserEmailClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AddressDomain") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AddressLocal") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Disabled") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.HasKey("Id"); + + b.HasIndex("Address") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("UserEmailClaims"); + }); + + modelBuilder.Entity("AliasServerDb.UserEncryptionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsPrimary") + .HasColumnType("boolean"); + + b.Property("PublicKey") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserEncryptionKeys"); + }); + + modelBuilder.Entity("AliasServerDb.Vault", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Client") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CredentialsCount") + .HasColumnType("integer"); + + b.Property("EmailClaimsCount") + .HasColumnType("integer"); + + b.Property("EncryptionSettings") + .IsRequired() + .HasColumnType("text"); + + b.Property("EncryptionType") + .IsRequired() + .HasColumnType("text"); + + b.Property("FileSize") + .HasColumnType("integer"); + + b.Property("RevisionNumber") + .HasColumnType("bigint"); + + b.Property("Salt") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("VaultBlob") + .IsRequired() + .HasColumnType("text"); + + b.Property("Verifier") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Vaults"); + }); + + modelBuilder.Entity("AliasVault.WorkerStatus.Database.WorkerServiceStatus", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CurrentStatus") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("DesiredStatus") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Heartbeat") + .HasColumnType("timestamp with time zone"); + + b.Property("ServiceName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.HasKey("Id"); + + b.ToTable("WorkerServiceStatuses"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("text"); + + b.Property("Xml") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("RoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("UserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.ToTable("UserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.ToTable("UserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", (string)null); + }); + + modelBuilder.Entity("AliasServerDb.AliasVaultUserRefreshToken", b => + { + b.HasOne("AliasServerDb.AliasVaultUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("AliasServerDb.Email", b => + { + b.HasOne("AliasServerDb.UserEncryptionKey", "EncryptionKey") + .WithMany("Emails") + .HasForeignKey("UserEncryptionKeyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EncryptionKey"); + }); + + modelBuilder.Entity("AliasServerDb.EmailAttachment", b => + { + b.HasOne("AliasServerDb.Email", "Email") + .WithMany("Attachments") + .HasForeignKey("EmailId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Email"); + }); + + modelBuilder.Entity("AliasServerDb.UserEmailClaim", b => + { + b.HasOne("AliasServerDb.AliasVaultUser", "User") + .WithMany("EmailClaims") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("User"); + }); + + modelBuilder.Entity("AliasServerDb.UserEncryptionKey", b => + { + b.HasOne("AliasServerDb.AliasVaultUser", "User") + .WithMany("EncryptionKeys") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("AliasServerDb.Vault", b => + { + b.HasOne("AliasServerDb.AliasVaultUser", "User") + .WithMany("Vaults") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("AliasServerDb.AliasVaultUser", b => + { + b.Navigation("EmailClaims"); + + b.Navigation("EncryptionKeys"); + + b.Navigation("Vaults"); + }); + + modelBuilder.Entity("AliasServerDb.Email", b => + { + b.Navigation("Attachments"); + }); + + modelBuilder.Entity("AliasServerDb.UserEncryptionKey", b => + { + b.Navigation("Emails"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/apps/server/Databases/AliasServerDb/Migrations/20250804182808_AddPerUserEmailLimits.cs b/apps/server/Databases/AliasServerDb/Migrations/20250804182808_AddPerUserEmailLimits.cs new file mode 100644 index 000000000..8a16dcf41 --- /dev/null +++ b/apps/server/Databases/AliasServerDb/Migrations/20250804182808_AddPerUserEmailLimits.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace AliasServerDb.Migrations +{ + /// + public partial class AddPerUserEmailLimits : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "MaxEmailAgeDays", + table: "AliasVaultUsers", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "MaxEmails", + table: "AliasVaultUsers", + type: "integer", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "MaxEmailAgeDays", + table: "AliasVaultUsers"); + + migrationBuilder.DropColumn( + name: "MaxEmails", + table: "AliasVaultUsers"); + } + } +} diff --git a/apps/server/Databases/AliasServerDb/Migrations/AliasServerDbContextPostgresqlModelSnapshot.cs b/apps/server/Databases/AliasServerDb/Migrations/AliasServerDbContextModelSnapshot.cs similarity index 99% rename from apps/server/Databases/AliasServerDb/Migrations/AliasServerDbContextPostgresqlModelSnapshot.cs rename to apps/server/Databases/AliasServerDb/Migrations/AliasServerDbContextModelSnapshot.cs index 75f1e071f..fd1377f29 100644 --- a/apps/server/Databases/AliasServerDb/Migrations/AliasServerDbContextPostgresqlModelSnapshot.cs +++ b/apps/server/Databases/AliasServerDb/Migrations/AliasServerDbContextModelSnapshot.cs @@ -17,7 +17,7 @@ namespace AliasServerDb.Migrations { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "9.0.3") + .HasAnnotation("ProductVersion", "9.0.4") .HasAnnotation("Proxies:ChangeTracking", false) .HasAnnotation("Proxies:CheckEquality", false) .HasAnnotation("Proxies:LazyLoading", true) @@ -147,6 +147,12 @@ namespace AliasServerDb.Migrations b.Property("LockoutEnd") .HasColumnType("timestamp with time zone"); + b.Property("MaxEmailAgeDays") + .HasColumnType("integer"); + + b.Property("MaxEmails") + .HasColumnType("integer"); + b.Property("NormalizedEmail") .HasColumnType("text"); diff --git a/apps/server/Services/AliasVault.TaskRunner/Tasks/EmailCleanupTask.cs b/apps/server/Services/AliasVault.TaskRunner/Tasks/EmailCleanupTask.cs index cc89afa87..480797d7c 100644 --- a/apps/server/Services/AliasVault.TaskRunner/Tasks/EmailCleanupTask.cs +++ b/apps/server/Services/AliasVault.TaskRunner/Tasks/EmailCleanupTask.cs @@ -43,22 +43,70 @@ public class EmailCleanupTask : IMaintenanceTask public async Task ExecuteAsync(CancellationToken cancellationToken) { var settings = await _settingsService.GetAllSettingsAsync(); - if (settings.EmailRetentionDays <= 0) + await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + + var totalEmailsDeleted = 0; + + // First handle global retention settings + if (settings.EmailRetentionDays > 0) { - return; + var globalCutoffDate = DateTime.UtcNow.AddDays(-settings.EmailRetentionDays); + + // Delete the emails based on global settings + var globalEmailsDeleted = await dbContext.Emails + .Where(x => x.DateSystem < globalCutoffDate) + .ExecuteDeleteAsync(cancellationToken); + + if (globalEmailsDeleted > 0) + { + totalEmailsDeleted += globalEmailsDeleted; + _logger.LogWarning( + "Deleted {EmailCount} emails older than {Days} days (global setting)", + globalEmailsDeleted, + settings.EmailRetentionDays); + } } - await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - var cutoffDate = DateTime.UtcNow.AddDays(-settings.EmailRetentionDays); + // Now handle per-user age limits + var usersWithAgeLimits = await dbContext.AliasVaultUsers + .Where(u => u.MaxEmailAgeDays > 0) + .Select(u => new { u.Id, u.UserName, u.MaxEmailAgeDays }) + .ToListAsync(cancellationToken); - // Delete the emails - var emailsDeleted = await dbContext.Emails - .Where(x => x.DateSystem < cutoffDate) - .ExecuteDeleteAsync(cancellationToken); + foreach (var user in usersWithAgeLimits) + { + var userCutoffDate = DateTime.UtcNow.AddDays(-user.MaxEmailAgeDays); - _logger.LogWarning( - "Deleted {EmailCount} emails older than {Days} days", - emailsDeleted, - settings.EmailRetentionDays); + // Get all email addresses for this user + var userAddresses = await dbContext.UserEmailClaims + .Where(c => c.UserId == user.Id) + .Select(c => c.Address) + .ToListAsync(cancellationToken); + + if (userAddresses.Any()) + { + // Delete emails older than user's limit + var userEmailsDeleted = await dbContext.Emails + .Where(e => userAddresses.Contains(e.To) && e.DateSystem < userCutoffDate) + .ExecuteDeleteAsync(cancellationToken); + + if (userEmailsDeleted > 0) + { + totalEmailsDeleted += userEmailsDeleted; + _logger.LogWarning( + "Deleted {EmailCount} emails older than {Days} days for user {UserName} (user-specific setting)", + userEmailsDeleted, + user.MaxEmailAgeDays, + user.UserName); + } + } + } + + if (totalEmailsDeleted > 0) + { + _logger.LogWarning( + "Total emails deleted by age cleanup: {TotalEmails}", + totalEmailsDeleted); + } } } diff --git a/apps/server/Services/AliasVault.TaskRunner/Tasks/EmailQuotaCleanupTask.cs b/apps/server/Services/AliasVault.TaskRunner/Tasks/EmailQuotaCleanupTask.cs index 4de3631d6..eef74dec2 100644 --- a/apps/server/Services/AliasVault.TaskRunner/Tasks/EmailQuotaCleanupTask.cs +++ b/apps/server/Services/AliasVault.TaskRunner/Tasks/EmailQuotaCleanupTask.cs @@ -43,35 +43,53 @@ public class EmailQuotaCleanupTask : IMaintenanceTask public async Task ExecuteAsync(CancellationToken cancellationToken) { var settings = await _settingsService.GetAllSettingsAsync(); - if (settings.MaxEmailsPerUser <= 0) - { - return; - } - await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - // Get all users with their email claims - var userEmailClaims = await dbContext.UserEmailClaims - .Select(c => new { c.UserId, c.Address }) + // Get all users with their email claims and limits + var usersWithClaims = await (from u in dbContext.AliasVaultUsers + join c in dbContext.UserEmailClaims on u.Id equals c.UserId + select new { u.Id, u.UserName, u.MaxEmails, c.Address }) .ToListAsync(cancellationToken); var totalEmailsDeleted = 0; var usersProcessed = 0; - // Group email claims by user - foreach (var userGroup in userEmailClaims.GroupBy(c => c.UserId)) + // Group by user + foreach (var userGroup in usersWithClaims.GroupBy(x => new { x.Id, x.UserName, x.MaxEmails })) { - var userAddresses = userGroup.Select(c => c.Address).ToList(); + // Determine the effective limit for this user + int effectiveLimit; + string limitSource; + + if (userGroup.Key.MaxEmails > 0) + { + // User has a specific limit + effectiveLimit = userGroup.Key.MaxEmails; + limitSource = "user-specific"; + } + else if (settings.MaxEmailsPerUser > 0) + { + // Use global limit + effectiveLimit = settings.MaxEmailsPerUser; + limitSource = "global"; + } + else + { + // No limits apply + continue; + } + + var userAddresses = userGroup.Select(x => x.Address).ToList(); // Get total email count for this user var emailCount = await dbContext.Emails .Where(e => userAddresses.Contains(e.To)) .CountAsync(cancellationToken); - if (emailCount > settings.MaxEmailsPerUser) + if (emailCount > effectiveLimit) { // Calculate how many emails need to be deleted - var deleteCount = emailCount - settings.MaxEmailsPerUser; + var deleteCount = emailCount - effectiveLimit; // Delete the oldest emails - attachments will be cascade deleted var emailsDeleted = await dbContext.Emails @@ -85,18 +103,21 @@ public class EmailQuotaCleanupTask : IMaintenanceTask totalEmailsDeleted += emailsDeleted; usersProcessed++; _logger.LogWarning( - "Deleted {EmailCount} emails for user {UserId} to maintain quota of {MaxEmails}", + "Deleted {EmailCount} emails for user {Username} to maintain quota of {MaxEmails} ({LimitSource} setting)", emailsDeleted, - userGroup.Key, - settings.MaxEmailsPerUser); + userGroup.Key.UserName, + effectiveLimit, + limitSource); } } } - _logger.LogWarning( - "Deleted {TotalEmails} emails across {UserCount} users to maintain quota of {MaxEmails} max emails per user", - totalEmailsDeleted, - usersProcessed, - settings.MaxEmailsPerUser); + if (totalEmailsDeleted > 0) + { + _logger.LogWarning( + "Total emails deleted by quota cleanup: {TotalEmails} across {UserCount} users", + totalEmailsDeleted, + usersProcessed); + } } }