Move statistic columns from user to group (#2159)

This commit is contained in:
Leendert de Borst
2026-08-01 21:17:18 +02:00
parent 96df232191
commit 5d5e0e8667
10 changed files with 1882 additions and 65 deletions
@@ -185,12 +185,12 @@ else
<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"))
<strong>Max Emails:</strong> @(User.PersonalGroup.MaxEmails == 0 ? "Unlimited" : User.PersonalGroup.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")
<strong>Max Age:</strong> @(User.PersonalGroup.MaxEmailAgeDays == 0 ? "Unlimited" : User.PersonalGroup.MaxEmailAgeDays + " days")
</span>
</div>
<div class="mt-2">
@@ -354,9 +354,9 @@ else
IsLoading = true;
StateHasChanged();
// Load the aliases from the webapi via AliasService.
// Load the aliases from the webapi via AliasService. Eager-load the personal group as it holds the user's email limits.
await using var dbContext = await DbContextFactory.CreateDbContextAsync();
User = await dbContext.AliasVaultUsers.FindAsync(Id);
User = await dbContext.AliasVaultUsers.Include(u => u.PersonalGroup).FirstOrDefaultAsync(u => u.Id == Id);
// Get count of user authenticator tokens.
TwoFactorKeysCount = await dbContext.UserTokens.CountAsync(x => x.UserId == User!.Id && x.Name == "AuthenticatorKey");
@@ -642,8 +642,8 @@ Do you want to proceed with the restoration?")) {
private void StartEditingEmailLimits()
{
IsEditingEmailLimits = true;
EditMaxEmails = User!.MaxEmails;
EditMaxEmailAgeDays = User.MaxEmailAgeDays;
EditMaxEmails = User!.PersonalGroup.MaxEmails;
EditMaxEmailAgeDays = User.PersonalGroup.MaxEmailAgeDays;
}
/// <summary>
@@ -662,12 +662,12 @@ Do you want to proceed with the restoration?")) {
private async Task SaveEmailLimits()
{
await using var dbContext = await DbContextFactory.CreateDbContextAsync();
User = await dbContext.AliasVaultUsers.FindAsync(Id);
var personalGroup = await dbContext.Groups.FirstOrDefaultAsync(g => dbContext.AliasVaultUsers.Any(u => u.Id == Id && u.PersonalGroupId == g.Id));
if (User != null)
if (personalGroup != null)
{
User.MaxEmails = EditMaxEmails;
User.MaxEmailAgeDays = EditMaxEmailAgeDays;
personalGroup.MaxEmails = EditMaxEmails;
personalGroup.MaxEmailAgeDays = EditMaxEmailAgeDays;
await dbContext.SaveChangesAsync();
IsEditingEmailLimits = false;
await RefreshData();
@@ -343,9 +343,8 @@ public class StatisticsService
.Where(e => e.EncryptionKey.UserId == userId)
.CountAsync();
// Get persistent emails received counter from user record (never decremented, even when emails are deleted)
var user = await context.AliasVaultUsers.FindAsync(userId);
stats.TotalEmailsReceivedPersistent = user?.EmailsReceived ?? 0;
// Get persistent emails received counter from the user's personal group (never decremented, even when emails are deleted)
stats.TotalEmailsReceivedPersistent = await context.AliasVaultUsers.Where(u => u.Id == userId).Select(u => u.PersonalGroup.EmailsReceived).FirstOrDefaultAsync();
// Get recent statistics (last 72 hours).
var recentCurrentRevisions = await context.VaultManifests
@@ -53,46 +53,17 @@ public class AliasVaultUser : IdentityUser
/// </summary>
public DateTime? BlockedAt { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the user is marked as shadow-blocked.
/// </summary>
public bool ShadowBlocked { get; set; }
/// <summary>
/// Gets or sets the UTC timestamp when the user was shadow-blocked. Used to only hide emails received after the
/// block occurred. Null when the user has never been shadow-blocked (in which case all emails are hidden while
/// ShadowBlocked is true, as a conservative fallback).
/// </summary>
public DateTime? ShadowBlockedAt { get; set; }
/// <summary>
/// Gets or sets updated timestamp.
/// </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 date of the user's last activity (login, API call, etc.).
/// Updated automatically on successful authentication events.
/// </summary>
public DateTime? LastActivityDate { get; set; }
/// <summary>
/// Gets or sets the total count of emails received by this user across all time.
/// This is a persistent counter that is incremented when emails are received and is never decremented,
/// even when emails are deleted. Used for abuse detection and usage statistics.
/// </summary>
public int EmailsReceived { get; set; } = 0;
/// <summary>
/// Gets or sets the collection of vault unlock keys (KEK/VEK model). Empty for users still on the legacy
/// model where the password-derived key encrypts the vault directly.
@@ -37,6 +37,34 @@ public class Group
/// </summary>
public virtual ICollection<GroupMember> Members { get; set; } = [];
/// <summary>
/// Gets or sets a value indicating whether the group is marked as shadow-blocked.
/// </summary>
public bool ShadowBlocked { get; set; }
/// <summary>
/// Gets or sets the UTC timestamp when the group was shadow-blocked. Used to only hide emails received after the
/// block occurred. Null when the group has never been shadow-blocked.
/// </summary>
public DateTime? ShadowBlockedAt { get; set; }
/// <summary>
/// Gets or sets the maximum number of emails for all aliases owned by this group. 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 total count of emails received by this group's aliases across all time.
/// This is a persistent counter that is incremented when emails are received and is never decremented,
/// even when emails are deleted. Used for abuse detection and usage statistics.
/// </summary>
public int EmailsReceived { get; set; } = 0;
/// <summary>
/// Gets or sets created timestamp.
/// </summary>
@@ -0,0 +1,153 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace AliasServerDb.Migrations
{
/// <inheritdoc />
public partial class MoveEmailAbuseCountersToGroups : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "EmailsReceived",
table: "Groups",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "MaxEmailAgeDays",
table: "Groups",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "MaxEmails",
table: "Groups",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<bool>(
name: "ShadowBlocked",
table: "Groups",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<DateTime>(
name: "ShadowBlockedAt",
table: "Groups",
type: "timestamp with time zone",
nullable: true);
// Move the existing per-user abuse counters and email limits onto each user's personal group.
migrationBuilder.Sql(
"""
UPDATE "Groups" g
SET "EmailsReceived" = u."EmailsReceived",
"MaxEmailAgeDays" = u."MaxEmailAgeDays",
"MaxEmails" = u."MaxEmails",
"ShadowBlocked" = u."ShadowBlocked",
"ShadowBlockedAt" = u."ShadowBlockedAt"
FROM "AliasVaultUsers" u
WHERE u."PersonalGroupId" = g."Id";
""");
migrationBuilder.DropColumn(
name: "EmailsReceived",
table: "AliasVaultUsers");
migrationBuilder.DropColumn(
name: "MaxEmailAgeDays",
table: "AliasVaultUsers");
migrationBuilder.DropColumn(
name: "MaxEmails",
table: "AliasVaultUsers");
migrationBuilder.DropColumn(
name: "ShadowBlocked",
table: "AliasVaultUsers");
migrationBuilder.DropColumn(
name: "ShadowBlockedAt",
table: "AliasVaultUsers");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "EmailsReceived",
table: "AliasVaultUsers",
type: "integer",
nullable: false,
defaultValue: 0);
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);
migrationBuilder.AddColumn<bool>(
name: "ShadowBlocked",
table: "AliasVaultUsers",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<DateTime>(
name: "ShadowBlockedAt",
table: "AliasVaultUsers",
type: "timestamp with time zone",
nullable: true);
// Move the abuse counters and email limits from each user's personal group back onto the user record.
migrationBuilder.Sql(
"""
UPDATE "AliasVaultUsers" u
SET "EmailsReceived" = g."EmailsReceived",
"MaxEmailAgeDays" = g."MaxEmailAgeDays",
"MaxEmails" = g."MaxEmails",
"ShadowBlocked" = g."ShadowBlocked",
"ShadowBlockedAt" = g."ShadowBlockedAt"
FROM "Groups" g
WHERE u."PersonalGroupId" = g."Id";
""");
migrationBuilder.DropColumn(
name: "EmailsReceived",
table: "Groups");
migrationBuilder.DropColumn(
name: "MaxEmailAgeDays",
table: "Groups");
migrationBuilder.DropColumn(
name: "MaxEmails",
table: "Groups");
migrationBuilder.DropColumn(
name: "ShadowBlocked",
table: "Groups");
migrationBuilder.DropColumn(
name: "ShadowBlockedAt",
table: "Groups");
}
}
}
@@ -144,9 +144,6 @@ namespace AliasServerDb.Migrations
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<int>("EmailsReceived")
.HasColumnType("integer");
b.Property<DateTime?>("LastActivityDate")
.HasColumnType("timestamp with time zone");
@@ -156,12 +153,6 @@ 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");
@@ -186,12 +177,6 @@ namespace AliasServerDb.Migrations
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<bool>("ShadowBlocked")
.HasColumnType("boolean");
b.Property<DateTime?>("ShadowBlockedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("SrpIdentity")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
@@ -558,11 +543,26 @@ namespace AliasServerDb.Migrations
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("EmailsReceived")
.HasColumnType("integer");
b.Property<int>("MaxEmailAgeDays")
.HasColumnType("integer");
b.Property<int>("MaxEmails")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<bool>("ShadowBlocked")
.HasColumnType("boolean");
b.Property<DateTime?>("ShadowBlockedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Type")
.HasColumnType("integer");
@@ -67,10 +67,10 @@ public class EmailCleanupTask : IMaintenanceTask
}
}
// Now handle per-user age limits
// Now handle per-user age limits. The email age limit is stored on the user's personal group.
var usersWithAgeLimits = await dbContext.AliasVaultUsers
.Where(u => u.MaxEmailAgeDays > 0)
.Select(u => new { u.Id, u.UserName, u.MaxEmailAgeDays })
.Where(u => u.PersonalGroup.MaxEmailAgeDays > 0)
.Select(u => new { u.Id, u.UserName, u.PersonalGroup.MaxEmailAgeDays })
.ToListAsync(cancellationToken);
foreach (var user in usersWithAgeLimits)
@@ -45,11 +45,12 @@ public class EmailQuotaCleanupTask : IMaintenanceTask
var settings = await _settingsService.GetAllSettingsAsync();
await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
// Get all users with their email claims and limits
// Get all users with their email claims and limits. The email limit is stored on the user's personal group.
var usersWithClaims = await (from u in dbContext.AliasVaultUsers
join m in dbContext.VaultManifests on u.PersonalGroupId equals m.OwnerGroupId
join g in dbContext.Groups on u.PersonalGroupId equals g.Id
join m in dbContext.VaultManifests on g.Id equals m.OwnerGroupId
join c in dbContext.EmailClaims on (Guid?)m.ManifestId equals c.VaultManifestId
select new { u.Id, u.UserName, u.MaxEmails, u.LastActivityDate, u.CreatedAt, c.Address })
select new { u.Id, u.UserName, g.MaxEmails, u.LastActivityDate, u.CreatedAt, c.Address })
.ToListAsync(cancellationToken);
// Get minimum activity date which is used to determine if user is active.
@@ -54,8 +54,16 @@ public class IpBlockListService(IAliasServerDbContextFactory dbContextFactory, I
/// <returns>The earliest shadow-block timestamp, or null when not shadow-blocked.</returns>
public async Task<DateTime?> GetShadowBlockCutoffAsync(AliasVaultUser user, IPAddress? ipAddress)
{
// Account-level shadow-block. When the timestamp is unknown, return min timestamp.
DateTime? cutoff = user.ShadowBlocked ? (user.ShadowBlockedAt ?? DateTime.UnixEpoch) : null;
// Account-level shadow-block, stored on the user's personal group. When the timestamp is unknown, return min timestamp.
DateTime? cutoff = null;
await using (var dbContext = await dbContextFactory.CreateDbContextAsync())
{
var personalGroup = await dbContext.Groups.AsNoTracking().Where(g => g.Id == user.PersonalGroupId).Select(g => new { g.ShadowBlocked, g.ShadowBlockedAt }).FirstOrDefaultAsync();
if (personalGroup is not null && personalGroup.ShadowBlocked)
{
cutoff = personalGroup.ShadowBlockedAt ?? DateTime.UnixEpoch;
}
}
// IP-range shadow-block: the earliest matching block determines the cutoff time.
if (ipAddress is not null)