diff --git a/apps/server/AliasVault.Api/Controllers/Email/EmailBoxController.cs b/apps/server/AliasVault.Api/Controllers/Email/EmailBoxController.cs index e6e3734b8..f4b267102 100644 --- a/apps/server/AliasVault.Api/Controllers/Email/EmailBoxController.cs +++ b/apps/server/AliasVault.Api/Controllers/Email/EmailBoxController.cs @@ -23,7 +23,7 @@ using Microsoft.EntityFrameworkCore; /// /// DbContext instance. /// UserManager instance. -/// IpBlockListService used to shadow-ban email retrieval from blocked IPs. +/// IpBlockListService used to shadow-block email retrieval from blocked IPs. [ApiVersion("1")] public class EmailBoxController(IAliasServerDbContextFactory dbContextFactory, UserManager userManager, IpBlockListService ipBlockListService) : AuthenticatedRequestController(userManager) { @@ -43,11 +43,8 @@ public class EmailBoxController(IAliasServerDbContextFactory dbContextFactory, U return Unauthorized("Not authenticated."); } - // Shadow-blocked: a shadow-blocked account or blocked IP gets an empty mailbox rather than an explicit error. - if (await IsEmailRetrievalShadowBlockedAsync(user)) - { - return Ok(new MailboxApiModel { Address = to, Subscribed = false, Mails = [] }); - } + // Shadow-block: when active, only emails received before the block took effect are visible. + var shadowCutoff = await ipBlockListService.GetShadowBlockCutoffAsync(user, IpAddressUtility.GetRawIpAddressFromContext(HttpContext)); var sanitizedEmail = to.Trim().ToLower(); @@ -79,9 +76,14 @@ public class EmailBoxController(IAliasServerDbContextFactory dbContextFactory, U }); } - // Retrieve emails from database. - List emails = await context.Emails.AsNoTracking() - .Where(x => x.To == sanitizedEmail) + // Retrieve emails from database (excluding any received after a shadow-block took effect). + var emailQuery = context.Emails.AsNoTracking().Where(x => x.To == sanitizedEmail); + if (shadowCutoff is not null) + { + emailQuery = emailQuery.Where(x => x.DateSystem <= shadowCutoff.Value); + } + + List emails = await emailQuery .Select(x => new MailboxEmailApiModel() { Id = x.Id, @@ -128,18 +130,8 @@ public class EmailBoxController(IAliasServerDbContextFactory dbContextFactory, U return Unauthorized("Not authenticated."); } - // Shadow-blocked: a shadow-blocked account or blocked IP gets an empty result rather than an explicit error. - if (await IsEmailRetrievalShadowBlockedAsync(user)) - { - return Ok(new MailboxBulkResponse - { - Addresses = [], - Mails = [], - PageSize = Math.Min(model.PageSize, 50), - CurrentPage = model.Page, - TotalRecords = 0, - }); - } + // Shadow-block: when active, only emails received before the block took effect are visible. + var shadowCutoff = await ipBlockListService.GetShadowBlockCutoffAsync(user, IpAddressUtility.GetRawIpAddressFromContext(HttpContext)); // Sanitize input. model.Addresses = model.Addresses.Select(x => x.Trim().ToLower()).ToList(); @@ -155,6 +147,11 @@ public class EmailBoxController(IAliasServerDbContextFactory dbContextFactory, U .AsNoTracking() .Where(email => validAddresses.Contains(email.To)); + if (shadowCutoff is not null) + { + query = query.Where(email => email.DateSystem <= shadowCutoff.Value); + } + // Get all emails for the valid addresses. var rows = await query .OrderByDescending(x => x.DateSystem) @@ -196,13 +193,4 @@ public class EmailBoxController(IAliasServerDbContextFactory dbContextFactory, U return Ok(returnValue); } - - /// - /// Determines whether email retrieval for the current request should be shadow-blocked, either because the - /// account is shadow-blocked or because the request originates from a shadow-blocked IP range. - /// - /// The authenticated user. - /// True if email retrieval should return an empty result. - private async Task IsEmailRetrievalShadowBlockedAsync(AliasVaultUser user) - => user.ShadowBlocked || await ipBlockListService.IsBlockedForEmailsAsync(IpAddressUtility.GetRawIpAddressFromContext(HttpContext)); } diff --git a/apps/server/AliasVault.Api/Controllers/Email/EmailController.cs b/apps/server/AliasVault.Api/Controllers/Email/EmailController.cs index 1abf8c014..b61f6faee 100644 --- a/apps/server/AliasVault.Api/Controllers/Email/EmailController.cs +++ b/apps/server/AliasVault.Api/Controllers/Email/EmailController.cs @@ -153,11 +153,8 @@ public class EmailController(ILogger logger, IAliasServerDbCont return (null, Unauthorized("Not authenticated.")); } - // Shadow-blocked: a shadow-blocked account or blocked IP behaves as if the email does not exist. - if (user.ShadowBlocked || await ipBlockListService.IsBlockedForEmailsAsync(IpAddressUtility.GetRawIpAddressFromContext(HttpContext))) - { - return (null, NotFound()); - } + // Shadow-block: when active, emails received after the block took effect behave as if they do not exist. + var shadowCutoff = await ipBlockListService.GetEmailShadowBlockCutoffAsync(user, IpAddressUtility.GetRawIpAddressFromContext(HttpContext)); // Retrieve email from database. var email = await context.Emails @@ -169,6 +166,12 @@ public class EmailController(ILogger logger, IAliasServerDbCont return (null, NotFound("Email not found.")); } + // Hide emails received after a shadow-block took effect. + if (shadowCutoff is not null && email.DateSystem > shadowCutoff.Value) + { + return (null, NotFound()); + } + // See if this user has a valid claim to the email address. var normalizedEmailAddress = email.To.Trim().ToLower(); var emailClaim = await context.UserEmailClaims.FirstOrDefaultAsync(x => x.UserId == user.Id && x.Address == normalizedEmailAddress && !x.Disabled); diff --git a/apps/server/Utilities/AliasVault.Auth/IpAddress/IpBlockListService.cs b/apps/server/Utilities/AliasVault.Auth/IpAddress/IpBlockListService.cs index 345a763ff..0c90cf47d 100644 --- a/apps/server/Utilities/AliasVault.Auth/IpAddress/IpBlockListService.cs +++ b/apps/server/Utilities/AliasVault.Auth/IpAddress/IpBlockListService.cs @@ -41,12 +41,12 @@ public class IpBlockListService(IAliasServerDbContextFactory dbContextFactory) /// The authenticated user. /// The IP address to evaluate. /// The earliest shadow-block timestamp, or null when not shadow-blocked. - public async Task GetEmailShadowBlockCutoffAsync(AliasVaultUser user, IPAddress? ipAddress) + public async Task 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; - // IP-range shadow-block: the earliest matching block determines the cutoff (the most that should be hidden). + // IP-range shadow-block: the earliest matching block determines the cutoff time. if (ipAddress is not null) { await using var dbContext = await dbContextFactory.CreateDbContextAsync();