Add per user email limits configurable through admin (#1075)

This commit is contained in:
Leendert de Borst
2025-08-04 21:25:34 +02:00
committed by Leendert de Borst
parent 984f5a2c52
commit 4938129367
7 changed files with 1175 additions and 36 deletions

View File

@@ -75,7 +75,7 @@ else
<div class="w-full mb-4 overflow-x-auto">
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400">
<tbody>
<tr class="border-b dark:border-gray-700">
<tr class="">
<th scope="row" class="px-4 py-3 font-medium text-gray-900 whitespace-nowrap dark:text-white">Registered at</th>
<td class="px-4 py-3">@User.CreatedAt.ToString("yyyy-MM-dd HH:mm")</td>
</tr>
@@ -115,6 +115,52 @@ else
</div>
</td>
</tr>
<tr>
<th scope="row" class="px-4 py-3 font-medium text-gray-900 whitespace-nowrap dark:text-white">Email Limits</th>
<td class="px-4 py-3">
@if (!IsEditingEmailLimits)
{
<div class="flex flex-col space-y-2">
<div class="flex items-center space-x-2">
<span class="text-gray-700 dark:text-gray-300">
<strong>Max Emails:</strong> @(User.MaxEmails == 0 ? "Unlimited" : User.MaxEmails.ToString("N0"))
</span>
</div>
<div class="flex items-center space-x-2">
<span class="text-gray-700 dark:text-gray-300">
<strong>Max Age:</strong> @(User.MaxEmailAgeDays == 0 ? "Unlimited" : User.MaxEmailAgeDays + " days")
</span>
</div>
<div class="mt-2">
<Button Color="primary" OnClick="@(() => StartEditingEmailLimits())">Edit Limits</Button>
</div>
</div>
}
else
{
<div class="space-y-3 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<div>
<label class="block mb-1 text-sm font-medium text-gray-900 dark:text-white">Max Emails</label>
<div class="flex items-center space-x-2">
<input type="number" min="0" @bind="EditMaxEmails" class="bg-white border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 block w-32 p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500" />
<span class="text-sm text-gray-500 dark:text-gray-400">(0 = unlimited)</span>
</div>
</div>
<div>
<label class="block mb-1 text-sm font-medium text-gray-900 dark:text-white">Max Email Age</label>
<div class="flex items-center space-x-2">
<input type="number" min="0" @bind="EditMaxEmailAgeDays" class="bg-white border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 block w-32 p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500" />
<span class="text-sm text-gray-500 dark:text-gray-400">days (0 = unlimited)</span>
</div>
</div>
<div class="flex space-x-2 pt-2">
<Button Color="success" OnClick="SaveEmailLimits">Save</Button>
<Button Color="secondary" OnClick="CancelEditingEmailLimits">Cancel</Button>
</div>
</div>
}
</td>
</tr>
</tbody>
</table>
</div>
@@ -135,7 +181,7 @@ else
<div class="p-4 mb-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<div class="items-center">
<div>
<h3 class="mb-1 text-xl font-bold text-gray-900 dark:text-white">UserRefreshTokens (Logged in devices)</h3>
<h3 class="mb-1 text-xl font-bold text-gray-900 dark:text-white">Logged in devices</h3>
<RefreshTokenTable RefreshTokenList="@RefreshTokenList" OnRevokeToken="@RevokeRefreshToken" OnRevokeAllTokens="@RevokeAllTokens" />
</div>
@@ -185,6 +231,9 @@ else
private List<Vault> VaultList { get; set; } = [];
private List<AuthLog> AuthLogList { get; set; } = [];
private UserUsageStatistics? UserUsageStats { get; set; }
private bool IsEditingEmailLimits { get; set; }
private int EditMaxEmails { get; set; }
private int EditMaxEmailAgeDays { get; set; }
/// <inheritdoc />
protected override async Task OnInitializedAsync()
@@ -427,4 +476,43 @@ Do you want to proceed with the restoration?")) {
await RefreshData();
}
}
/// <summary>
/// Starts editing email limits for the user.
/// </summary>
private void StartEditingEmailLimits()
{
IsEditingEmailLimits = true;
EditMaxEmails = User!.MaxEmails;
EditMaxEmailAgeDays = User.MaxEmailAgeDays;
}
/// <summary>
/// Cancels editing email limits.
/// </summary>
private void CancelEditingEmailLimits()
{
IsEditingEmailLimits = false;
EditMaxEmails = 0;
EditMaxEmailAgeDays = 0;
}
/// <summary>
/// Saves the email limits for the user.
/// </summary>
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);
}
}
}

View File

@@ -34,6 +34,16 @@ public class AliasVaultUser : IdentityUser
/// </summary>
public DateTime UpdatedAt { get; set; }
/// <summary>
/// Gets or sets the maximum number of emails for all of user's aliases. 0 means unlimited.
/// </summary>
public int MaxEmails { get; set; } = 0;
/// <summary>
/// Gets or sets the maximum age of emails in days. Emails older than this will be deleted. 0 means unlimited.
/// </summary>
public int MaxEmailAgeDays { get; set; } = 0;
/// <summary>
/// Gets or sets the collection of vaults.
/// </summary>

View File

@@ -0,0 +1,926 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<string>("Id")
.HasColumnType("text");
b.Property<string>("ConcurrencyStamp")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("NormalizedName")
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("AdminRoles");
});
modelBuilder.Entity("AliasServerDb.AdminUser", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("ConcurrencyStamp")
.HasColumnType("text");
b.Property<string>("Email")
.HasColumnType("text");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<DateTime?>("LastPasswordChanged")
.HasColumnType("timestamp with time zone");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasColumnType("text");
b.Property<string>("NormalizedUserName")
.HasColumnType("text");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("UserName")
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("AdminUsers");
});
modelBuilder.Entity("AliasServerDb.AliasVaultRole", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("ConcurrencyStamp")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("NormalizedName")
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("AliasVaultRoles");
});
modelBuilder.Entity("AliasServerDb.AliasVaultUser", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<bool>("Blocked")
.HasColumnType("boolean");
b.Property<string>("ConcurrencyStamp")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.HasColumnType("text");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<int>("MaxEmailAgeDays")
.HasColumnType("integer");
b.Property<int>("MaxEmails")
.HasColumnType("integer");
b.Property<string>("NormalizedEmail")
.HasColumnType("text");
b.Property<string>("NormalizedUserName")
.HasColumnType("text");
b.Property<DateTime>("PasswordChangedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UserName")
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("AliasVaultUsers");
});
modelBuilder.Entity("AliasServerDb.AliasVaultUserRefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DeviceIdentifier")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<DateTime>("ExpireDate")
.HasMaxLength(255)
.HasColumnType("timestamp with time zone");
b.Property<string>("IpAddress")
.HasMaxLength(45)
.HasColumnType("character varying(45)");
b.Property<string>("PreviousTokenValue")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("UserId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AliasVaultUserRefreshTokens");
});
modelBuilder.Entity("AliasServerDb.AuthLog", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("AdditionalInfo")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("Browser")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Client")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Country")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("DeviceType")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("EventType")
.HasColumnType("integer");
b.Property<int?>("FailureReason")
.HasColumnType("integer");
b.Property<string>("IpAddress")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<bool>("IsSuccess")
.HasColumnType("boolean");
b.Property<bool>("IsSuspiciousActivity")
.HasColumnType("boolean");
b.Property<string>("OperatingSystem")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("RequestPath")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<DateTime>("Timestamp")
.HasColumnType("timestamp with time zone");
b.Property<string>("UserAgent")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("Date")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("DateSystem")
.HasColumnType("timestamp with time zone");
b.Property<string>("EncryptedSymmetricKey")
.IsRequired()
.HasColumnType("text");
b.Property<string>("From")
.IsRequired()
.HasColumnType("text");
b.Property<string>("FromDomain")
.IsRequired()
.HasColumnType("text");
b.Property<string>("FromLocal")
.IsRequired()
.HasColumnType("text");
b.Property<string>("MessageHtml")
.HasColumnType("text");
b.Property<string>("MessagePlain")
.HasColumnType("text");
b.Property<string>("MessagePreview")
.HasColumnType("text");
b.Property<string>("MessageSource")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("PushNotificationSent")
.HasColumnType("boolean");
b.Property<string>("Subject")
.IsRequired()
.HasColumnType("text");
b.Property<string>("To")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ToDomain")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ToLocal")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("UserEncryptionKeyId")
.HasMaxLength(255)
.HasColumnType("uuid");
b.Property<bool>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<byte[]>("Bytes")
.IsRequired()
.HasColumnType("bytea");
b.Property<DateTime>("Date")
.HasColumnType("timestamp with time zone");
b.Property<int>("EmailId")
.HasColumnType("integer");
b.Property<string>("Filename")
.IsRequired()
.HasColumnType("text");
b.Property<int>("Filesize")
.HasColumnType("integer");
b.Property<string>("MimeType")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("EmailId");
b.ToTable("EmailAttachments");
});
modelBuilder.Entity("AliasServerDb.Log", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Application")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Exception")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Level")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("LogEvent")
.IsRequired()
.HasColumnType("text")
.HasColumnName("LogEvent");
b.Property<string>("Message")
.IsRequired()
.HasColumnType("text");
b.Property<string>("MessageTemplate")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Properties")
.IsRequired()
.HasColumnType("text");
b.Property<string>("SourceContext")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<DateTime>("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<string>("Key")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("Key");
b.ToTable("ServerSettings");
});
modelBuilder.Entity("AliasServerDb.TaskRunnerJob", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<TimeOnly?>("EndTime")
.HasColumnType("time without time zone");
b.Property<string>("ErrorMessage")
.HasColumnType("text");
b.Property<bool>("IsOnDemand")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<DateTime>("RunDate")
.HasColumnType("timestamp with time zone");
b.Property<TimeOnly>("StartTime")
.HasColumnType("time without time zone");
b.Property<int>("Status")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("TaskRunnerJobs");
});
modelBuilder.Entity("AliasServerDb.UserEmailClaim", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Address")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("AddressDomain")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("AddressLocal")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("Disabled")
.HasColumnType("boolean");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsPrimary")
.HasColumnType("boolean");
b.Property<string>("PublicKey")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UserId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("UserEncryptionKeys");
});
modelBuilder.Entity("AliasServerDb.Vault", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Client")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("CredentialsCount")
.HasColumnType("integer");
b.Property<int>("EmailClaimsCount")
.HasColumnType("integer");
b.Property<string>("EncryptionSettings")
.IsRequired()
.HasColumnType("text");
b.Property<string>("EncryptionType")
.IsRequired()
.HasColumnType("text");
b.Property<int>("FileSize")
.HasColumnType("integer");
b.Property<long>("RevisionNumber")
.HasColumnType("bigint");
b.Property<string>("Salt")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UserId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("VaultBlob")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Verifier")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("CurrentStatus")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("DesiredStatus")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<DateTime>("Heartbeat")
.HasColumnType("timestamp with time zone");
b.Property<string>("ServiceName")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.HasKey("Id");
b.ToTable("WorkerServiceStatuses");
});
modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("FriendlyName")
.HasColumnType("text");
b.Property<string>("Xml")
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("DataProtectionKeys");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("RoleId")
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("RoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("UserId")
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("UserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<string>("UserId")
.HasColumnType("text");
b.HasKey("LoginProvider", "ProviderKey");
b.ToTable("UserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("RoleId")
.HasColumnType("text");
b.HasKey("UserId", "RoleId");
b.ToTable("UserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("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
}
}
}

View File

@@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace AliasServerDb.Migrations
{
/// <inheritdoc />
public partial class AddPerUserEmailLimits : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "MaxEmailAgeDays",
table: "AliasVaultUsers",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "MaxEmails",
table: "AliasVaultUsers",
type: "integer",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "MaxEmailAgeDays",
table: "AliasVaultUsers");
migrationBuilder.DropColumn(
name: "MaxEmails",
table: "AliasVaultUsers");
}
}
}

View File

@@ -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<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<int>("MaxEmailAgeDays")
.HasColumnType("integer");
b.Property<int>("MaxEmails")
.HasColumnType("integer");
b.Property<string>("NormalizedEmail")
.HasColumnType("text");

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}