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();