diff --git a/src/Inbix.Core/Abstractions/IAliasResolver.cs b/src/Inbix.Core/Abstractions/IAliasResolver.cs
index bfe057e..18a5953 100644
--- a/src/Inbix.Core/Abstractions/IAliasResolver.cs
+++ b/src/Inbix.Core/Abstractions/IAliasResolver.cs
@@ -8,4 +8,7 @@ public interface IAliasResolver
{
/// True if the full address is a known, enabled alias on an accepted domain.
Task IsDeliverableAsync(string address, CancellationToken ct = default);
+
+ /// Drop cached deliverability so alias create/enable/disable/delete take effect immediately.
+ void Invalidate();
}
diff --git a/src/Inbix.Data/Json/JsonDataStore.cs b/src/Inbix.Data/Json/JsonDataStore.cs
index 1a650cd..3617f7e 100644
--- a/src/Inbix.Data/Json/JsonDataStore.cs
+++ b/src/Inbix.Data/Json/JsonDataStore.cs
@@ -169,11 +169,12 @@ private async Task LoadMessagesAsync(string dir, Dictionary
if (m is null) continue;
m.CurrentPath = file;
- // De-duplicate by id (a crash mid-move can leave two copies): keep the newest, delete the older.
+ // De-duplicate by id (a crash mid-move can leave two copies). Prefer the copy whose *content*
+ // is more advanced — parsing/junk state — rather than filesystem mtime, which is coarse and can
+ // even run backwards across servers on NFS.
if (messages.TryGetValue(m.Id, out var existing))
{
- var keepNew = File.GetLastWriteTimeUtc(file) >= File.GetLastWriteTimeUtc(existing.CurrentPath!);
- if (keepNew) { _io.TryDelete(existing.CurrentPath!); messages[m.Id] = m; }
+ if (PreferCandidate(m, file, existing)) { _io.TryDelete(existing.CurrentPath!); messages[m.Id] = m; }
else { _io.TryDelete(file); }
}
else
@@ -183,6 +184,15 @@ private async Task LoadMessagesAsync(string dir, Dictionary
}
}
+ private static bool PreferCandidate(StoredMessage candidate, string candidateFile, StoredMessage existing)
+ {
+ if (candidate.Parsed != existing.Parsed) return candidate.Parsed; // parsing only adds data
+ var c = candidate.StateChangedAt ?? DateTimeOffset.MinValue;
+ var e = existing.StateChangedAt ?? DateTimeOffset.MinValue;
+ if (c != e) return c > e; // latest junk/unjunk/sweep wins
+ return File.GetLastWriteTimeUtc(candidateFile) >= File.GetLastWriteTimeUtc(existing.CurrentPath!);
+ }
+
private async Task> LoadAuditAsync(CancellationToken ct)
{
var list = new List();
@@ -303,7 +313,10 @@ internal string AllocateFolderName(string localPart, string domain, bool isCatch
var withDomain = SafeFolder($"{localPart}@{domain}");
if (!taken.Contains(withDomain)) return withDomain;
- return $"{baseName}-{Guid.NewGuid():N}"[..Math.Min(baseName.Length + 9, 40)];
+ string candidate;
+ do { candidate = $"{baseName}-{Guid.NewGuid():N}"[..Math.Min(baseName.Length + 9, 40)]; }
+ while (taken.Contains(candidate));
+ return candidate;
}
private static readonly HashSet WindowsReserved = new(StringComparer.OrdinalIgnoreCase)
diff --git a/src/Inbix.Data/Services/AliasService.cs b/src/Inbix.Data/Services/AliasService.cs
index 7e59046..98e75f0 100644
--- a/src/Inbix.Data/Services/AliasService.cs
+++ b/src/Inbix.Data/Services/AliasService.cs
@@ -13,18 +13,21 @@ public sealed class AliasService : IAliasService
{
private readonly IAliasRepository _aliases;
private readonly IMessageRepository _messages;
+ private readonly IAliasResolver _resolver;
private readonly ILogger _logger;
- public AliasService(IAliasRepository aliases, IMessageRepository messages, ILogger logger)
+ public AliasService(IAliasRepository aliases, IMessageRepository messages, IAliasResolver resolver, ILogger logger)
{
_aliases = aliases;
_messages = messages;
+ _resolver = resolver;
_logger = logger;
}
public async Task CreateAsync(string localPart, string domain, string? notes, CancellationToken ct = default)
{
var alias = await _aliases.CreateAsync(localPart, domain, notes, ct).ConfigureAwait(false);
+ _resolver.Invalidate(); // the new alias must be deliverable immediately (no 30s cache lag)
var migrated = 0;
var catchAll = await _aliases.GetCatchAllAsync(ct).ConfigureAwait(false);
@@ -52,6 +55,7 @@ public async Task DeleteAsync(long aliasId, CancellationToken ct = default)
migrated = await _messages.ReassignAllAsync(aliasId, catchAll.Id, ct).ConfigureAwait(false);
await _aliases.DeleteAsync(aliasId, ct).ConfigureAwait(false);
+ _resolver.Invalidate(); // stop treating the deleted address as deliverable
_logger.LogInformation("Deleted alias {Address}; moved {Count} message(s) to the catch-all", alias.Address, migrated);
return migrated;
}
diff --git a/src/Inbix.Data/Services/CachingAliasResolver.cs b/src/Inbix.Data/Services/CachingAliasResolver.cs
index d981cd5..7c91265 100644
--- a/src/Inbix.Data/Services/CachingAliasResolver.cs
+++ b/src/Inbix.Data/Services/CachingAliasResolver.cs
@@ -19,7 +19,10 @@ public sealed class CachingAliasResolver : IAliasResolver
private readonly IAliasRepository _aliases;
private readonly HashSet _domains;
private readonly ConcurrentDictionary _cache = new();
- private (bool enabled, DateTimeOffset expires) _catchAll;
+ // A reference field published with volatile semantics — a tuple field would be a torn read across the
+ // concurrent RCPT threads (it's wider than a machine word).
+ private sealed record CatchAllState(bool Enabled, DateTimeOffset Expires);
+ private volatile CatchAllState? _catchAll;
public CachingAliasResolver(IAliasRepository aliases, IOptions options)
{
@@ -62,12 +65,19 @@ public async Task IsDeliverableAsync(string address, CancellationToken ct
private async Task CatchAllEnabledAsync(DateTimeOffset now, CancellationToken ct)
{
- if (_catchAll.expires > now)
- return _catchAll.enabled;
+ var cached = _catchAll;
+ if (cached is not null && cached.Expires > now)
+ return cached.Enabled;
var catchAll = await _aliases.GetCatchAllAsync(ct).ConfigureAwait(false);
var enabled = catchAll is { Enabled: true };
- _catchAll = (enabled, now.Add(Ttl));
+ _catchAll = new CatchAllState(enabled, now.Add(Ttl));
return enabled;
}
+
+ public void Invalidate()
+ {
+ _cache.Clear();
+ _catchAll = null;
+ }
}
diff --git a/src/Inbix.Data/Sqlite/SqliteConnectionFactory.cs b/src/Inbix.Data/Sqlite/SqliteConnectionFactory.cs
index 1909ffe..5970a99 100644
--- a/src/Inbix.Data/Sqlite/SqliteConnectionFactory.cs
+++ b/src/Inbix.Data/Sqlite/SqliteConnectionFactory.cs
@@ -35,6 +35,7 @@ public sealed class SqliteConnectionFactory : IDbConnectionFactory, IAsyncDispos
// Exclusive mode only: serialise all access through one shared connection.
private readonly SemaphoreSlim _gate = new(1, 1);
+ private static readonly TimeSpan GateTimeout = TimeSpan.FromSeconds(60);
private SqliteConnection? _shared;
public SqliteConnectionFactory(IOptions options)
@@ -83,7 +84,12 @@ public async Task OpenConnectionAsync(CancellationToken ct = defau
// Exclusive mode: acquire the gate, ensure the shared connection is healthy, and hand out a
// non-owning lease. The caller's dispose releases the gate (see LeasedSqliteConnection).
- await _gate.WaitAsync(ct).ConfigureAwait(false);
+ // A bounded wait turns a re-entrant OpenConnectionAsync (nested lease → self-deadlock) or a stuck
+ // operation into a clear exception instead of hanging the whole app forever.
+ if (!await _gate.WaitAsync(GateTimeout, ct).ConfigureAwait(false))
+ throw new TimeoutException(
+ "Timed out waiting for the SQLite exclusive-connection gate. This usually means a re-entrant " +
+ "OpenConnectionAsync (a repository method that opens a second connection while holding one) or a stuck operation.");
try
{
if (_shared is null || _shared.State != ConnectionState.Open)
diff --git a/src/Inbix.Imap/ImapServerHostedService.cs b/src/Inbix.Imap/ImapServerHostedService.cs
index 90a2063..a7faae1 100644
--- a/src/Inbix.Imap/ImapServerHostedService.cs
+++ b/src/Inbix.Imap/ImapServerHostedService.cs
@@ -42,8 +42,15 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
}
X509Certificate2? certificate = null;
- if (!string.IsNullOrWhiteSpace(_options.CertificatePath) && File.Exists(_options.CertificatePath))
+ if (!string.IsNullOrWhiteSpace(_options.CertificatePath))
+ {
+ // Fail fast rather than silently serving PLAINTEXT (leaking credentials) when a cert was
+ // configured but is missing/unloadable.
+ if (!File.Exists(_options.CertificatePath))
+ throw new FileNotFoundException(
+ $"Inbix:Imap:CertificatePath '{_options.CertificatePath}' was not found. Fix the path, or clear it to serve plaintext intentionally.");
certificate = X509CertificateLoader.LoadPkcs12FromFile(_options.CertificatePath, _options.CertificatePassword);
+ }
var listener = new TcpListener(IPAddress.Any, _options.Port);
listener.Start();
diff --git a/src/Inbix.Imap/ImapSession.cs b/src/Inbix.Imap/ImapSession.cs
index d4b9d11..b92a9b9 100644
--- a/src/Inbix.Imap/ImapSession.cs
+++ b/src/Inbix.Imap/ImapSession.cs
@@ -22,8 +22,18 @@ public sealed class ImapSession
private readonly IInboxNotifier _notifier;
private readonly ILogger _logger;
+ // Read timeouts stop idle/slowloris connections from holding a session slot forever.
+ private static readonly TimeSpan PreAuthTimeout = TimeSpan.FromSeconds(60);
+ private static readonly TimeSpan IdleTimeout = TimeSpan.FromMinutes(30);
+ private const int MaxFailedLogins = 5;
+ private const int PreAuthLiteralCap = 8 * 1024; // literals before auth are tiny (LOGIN)
+ private const int PostAuthLiteralCap = 64 * 1024 * 1024;
+
private bool _authenticated;
+ private int _failedLogins;
+ private bool _closing; // set after too many failed logins → drop the connection
private string? _selectedName;
+ private bool _writable; // current selection allows \Deleted/EXPUNGE (SELECT + AllowDelete, not EXAMINE)
private List _selected = [];
private readonly HashSet _deleted = []; // message ids flagged \Deleted this session (AllowDelete)
@@ -47,7 +57,20 @@ public async Task RunAsync(CancellationToken ct)
while (!ct.IsCancellationRequested)
{
- var line = await ReadCommandAsync(ct).ConfigureAwait(false);
+ string? line;
+ using (var readCts = CancellationTokenSource.CreateLinkedTokenSource(ct))
+ {
+ readCts.CancelAfter(_authenticated ? IdleTimeout : PreAuthTimeout);
+ try
+ {
+ line = await ReadCommandAsync(readCts.Token).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (!ct.IsCancellationRequested)
+ {
+ await SendAsync("* BYE idle timeout\r\n", ct).ConfigureAwait(false); // slowloris / idle protection
+ break;
+ }
+ }
if (line is null) break;
var sp = line.IndexOf(' ');
@@ -57,16 +80,23 @@ public async Task RunAsync(CancellationToken ct)
try
{
- if (await DispatchAsync(tag, rest, ct).ConfigureAwait(false)) break; // LOGOUT
+ if (await DispatchAsync(tag, rest, ct).ConfigureAwait(false)) break; // LOGOUT / forced close
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
- _logger.LogWarning(ex, "IMAP command failed: {Command}", rest);
+ // Log only the command verb, never the arguments (they can contain a LOGIN password).
+ _logger.LogWarning(ex, "IMAP command failed: {Command}", Verb(rest));
await SendAsync($"{tag} BAD internal error\r\n", ct).ConfigureAwait(false);
}
}
}
+ private static string Verb(string rest)
+ {
+ var sp = rest.IndexOf(' ');
+ return sp < 0 ? rest : rest[..sp];
+ }
+
private static string Capabilities() => "IMAP4rev1 IDLE AUTH=PLAIN";
private async Task DispatchAsync(string tag, string rest, CancellationToken ct)
@@ -88,10 +118,10 @@ private async Task DispatchAsync(string tag, string rest, CancellationToke
return true;
case "LOGIN":
await LoginAsync(tag, tokens, ct).ConfigureAwait(false);
- return false;
+ return _closing;
case "AUTHENTICATE":
await AuthenticateAsync(tag, tokens, ct).ConfigureAwait(false);
- return false;
+ return _closing;
}
if (!_authenticated)
@@ -121,11 +151,14 @@ private async Task DispatchAsync(string tag, string rest, CancellationToke
case "EXPUNGE":
await ExpungeAsync(tag, restrictUids: null, byUid: false, ct).ConfigureAwait(false); break;
case "IDLE":
- await IdleAsync(tag, ct).ConfigureAwait(false); break;
+ if (await IdleAsync(tag, ct).ConfigureAwait(false)) return true; // idle timeout → drop
+ break;
case "CLOSE":
- if (_options.AllowDelete && _selectedName is not null)
- await DoExpungeAsync(silent: true, restrictUids: null, ct).ConfigureAwait(false); // CLOSE expunges silently
- _selectedName = null; _selected = []; _deleted.Clear();
+ // Only expunge on CLOSE when the mailbox was opened read-write (SELECT + AllowDelete).
+ // A read-only (EXAMINE) mailbox must never delete on close.
+ if (_writable && _selectedName is not null)
+ await DoExpungeAsync(silent: true, restrictUids: null, ct).ConfigureAwait(false);
+ _selectedName = null; _selected = []; _deleted.Clear(); _writable = false;
await SendAsync($"{tag} OK CLOSE completed\r\n", ct).ConfigureAwait(false); break;
case "SUBSCRIBE":
case "UNSUBSCRIBE":
@@ -177,12 +210,17 @@ private async Task FinishAuthAsync(string tag, string user, string pass, Cancell
if (Verify(user, pass))
{
_authenticated = true;
+ _failedLogins = 0;
await SendAsync($"{tag} OK LOGIN completed\r\n", ct).ConfigureAwait(false);
+ return;
}
- else
+
+ await Task.Delay(300, ct).ConfigureAwait(false); // throttle
+ await SendAsync($"{tag} NO [AUTHENTICATIONFAILED] Invalid credentials\r\n", ct).ConfigureAwait(false);
+ if (++_failedLogins >= MaxFailedLogins)
{
- await Task.Delay(300, ct).ConfigureAwait(false); // token throttle
- await SendAsync($"{tag} NO [AUTHENTICATIONFAILED] Invalid credentials\r\n", ct).ConfigureAwait(false);
+ await SendAsync("* BYE too many failed login attempts\r\n", ct).ConfigureAwait(false);
+ _closing = true; // drop the connection to blunt brute-forcing
}
}
@@ -230,7 +268,8 @@ private static System.Text.RegularExpressions.Regex PatternRegex(string pattern)
{
// IMAP wildcards: * matches anything (incl. hierarchy), % matches within one level.
var escaped = System.Text.RegularExpressions.Regex.Escape(pattern).Replace("\\*", ".*").Replace("%", "[^/]*");
- return new System.Text.RegularExpressions.Regex("^" + escaped + "$", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
+ return new System.Text.RegularExpressions.Regex("^" + escaped + "$",
+ System.Text.RegularExpressions.RegexOptions.IgnoreCase, TimeSpan.FromMilliseconds(100)); // guard against ReDoS
}
private async Task SelectAsync(string tag, List tokens, bool examine, CancellationToken ct)
@@ -246,6 +285,7 @@ private async Task SelectAsync(string tag, List tokens, bool examine, Ca
// SELECT is read-write only when deletes are allowed; EXAMINE is always read-only.
var writable = _options.AllowDelete && !examine;
+ _writable = writable;
var uidNext = (_selected.Count > 0 ? _selected[^1].Id : 0) + 1;
var sb = new StringBuilder();
sb.Append(writable ? "* FLAGS (\\Seen \\Deleted)\r\n" : "* FLAGS (\\Seen)\r\n");
@@ -386,11 +426,13 @@ private async Task SearchAsync(string tag, List tokens, bool byUid, Canc
private async Task StoreAsync(string tag, List tokens, bool byUid, CancellationToken ct)
{
+ if (_selectedName is null) { await SendAsync($"{tag} NO No mailbox selected\r\n", ct).ConfigureAwait(false); return; }
var targets = Resolve(tokens.Count > 1 ? tokens[1] : "", byUid);
- if (!_options.AllowDelete)
+ // \Deleted is only honoured on a read-write selection (SELECT + AllowDelete, not EXAMINE); otherwise
+ // STORE is a no-op that just echoes \Seen so clients don't error.
+ if (!_writable)
{
- // Read-only: accept but persist nothing. Echo \Seen back so clients don't error.
foreach (var (seq, _) in targets)
await SendAsync($"* {seq} FETCH (FLAGS (\\Seen))\r\n", ct).ConfigureAwait(false);
await SendAsync($"{tag} OK {(byUid ? "UID " : "")}STORE completed\r\n", ct).ConfigureAwait(false);
@@ -428,9 +470,10 @@ private async Task StoreAsync(string tag, List tokens, bool byUid, Cance
private async Task ExpungeAsync(string tag, HashSet? restrictUids, bool byUid, CancellationToken ct)
{
if (_selectedName is null) { await SendAsync($"{tag} NO No mailbox selected\r\n", ct).ConfigureAwait(false); return; }
- if (!_options.AllowDelete)
+ if (!_writable)
{
- await SendAsync($"{tag} NO [CANNOT] Inbix mailboxes are read-only (set Inbix:Imap:AllowDelete to enable)\r\n", ct).ConfigureAwait(false);
+ // Read-only (or EXAMINE, or AllowDelete off): never expunge.
+ await SendAsync($"{tag} NO [CANNOT] Mailbox is read-only (open with SELECT and set Inbix:Imap:AllowDelete to enable deletes)\r\n", ct).ConfigureAwait(false);
return;
}
await DoExpungeAsync(silent: false, restrictUids, ct).ConfigureAwait(false);
@@ -463,22 +506,26 @@ private async Task DoExpungeAsync(bool silent, HashSet? restrictUids, Canc
// ---- IDLE ----
- private async Task IdleAsync(string tag, CancellationToken ct)
+ // Returns true when the connection should be dropped (idle timeout).
+ private async Task IdleAsync(string tag, CancellationToken ct)
{
- if (_selectedName is null) { await SendAsync($"{tag} BAD No mailbox selected\r\n", ct).ConfigureAwait(false); return; }
+ if (_selectedName is null) { await SendAsync($"{tag} BAD No mailbox selected\r\n", ct).ConfigureAwait(false); return false; }
var signal = Channel.CreateUnbounded();
void OnEvent(InboxEvent _) => signal.Writer.TryWrite(true);
_notifier.Received += OnEvent;
await SendAsync("+ idling\r\n", ct).ConfigureAwait(false);
+
+ using var idleCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ idleCts.CancelAfter(IdleTimeout); // don't let an IDLE connection hold a slot forever
+ var readDone = ReadLineAsync(idleCts.Token);
+ var timedOut = false;
try
{
- var readDone = ReadLineAsync(ct);
while (true)
{
var wake = signal.Reader.WaitToReadAsync(ct).AsTask();
- var done = await Task.WhenAny(readDone, wake).ConfigureAwait(false);
- if (done == readDone) break; // client sent DONE (or disconnected)
+ if (await Task.WhenAny(readDone, wake).ConfigureAwait(false) == readDone) break; // client sent DONE / disconnected / timed out
while (signal.Reader.TryRead(out _)) { }
var refreshed = await _mailboxes.GetMessagesAsync(_selectedName, ct).ConfigureAwait(false);
@@ -488,12 +535,17 @@ private async Task IdleAsync(string tag, CancellationToken ct)
await SendAsync($"* {_selected.Count} EXISTS\r\n", ct).ConfigureAwait(false);
}
}
+ try { await readDone.ConfigureAwait(false); } // observe the read (avoid unobserved exception)
+ catch (OperationCanceledException) when (!ct.IsCancellationRequested) { timedOut = true; }
}
finally
{
_notifier.Received -= OnEvent;
}
+
+ if (timedOut) { await SendAsync("* BYE idle timeout\r\n", ct).ConfigureAwait(false); return true; }
await SendAsync($"{tag} OK IDLE terminated\r\n", ct).ConfigureAwait(false);
+ return false;
}
// ---- Sequence / item parsing ----
@@ -649,7 +701,10 @@ private static List Tokenize(string s)
var inner = line[(brace + 1)..^1];
var nonSync = inner.EndsWith('+');
if (nonSync) inner = inner[..^1];
- if (int.TryParse(inner, out var n) && n >= 0 && n <= 64 * 1024 * 1024)
+ // Cap literals — tiny before auth (only LOGIN needs one) — so an unauthenticated peer
+ // can't force a large allocation. An over-cap literal falls through and the command is rejected.
+ var literalCap = _authenticated ? PostAuthLiteralCap : PreAuthLiteralCap;
+ if (int.TryParse(inner, out var n) && n >= 0 && n <= literalCap)
{
sb.Append(line[..brace]);
if (!nonSync) await SendAsync("+ Ready for literal\r\n", ct).ConfigureAwait(false);
diff --git a/src/Inbix.Web/Program.cs b/src/Inbix.Web/Program.cs
index 088f72d..9da1863 100644
--- a/src/Inbix.Web/Program.cs
+++ b/src/Inbix.Web/Program.cs
@@ -69,7 +69,10 @@
options.SlidingExpiration = true;
options.Cookie.Name = "inbix.auth";
options.Cookie.HttpOnly = true;
- options.Cookie.SameSite = SameSiteMode.Lax;
+ // Strict (not Lax) so the session cookie is never sent on ANY cross-site request — closes CSRF on
+ // the route-only admin POSTs (backup/reload/reindex, junk/unjunk) incl. the Lax+POST window. Trade-off:
+ // following an external link into the UI shows the login page until you navigate within the app.
+ options.Cookie.SameSite = SameSiteMode.Strict;
options.Cookie.SecurePolicy = requireHttps ? CookieSecurePolicy.Always : CookieSecurePolicy.SameAsRequest;
});
diff --git a/tests/Inbix.Tests/DataLayerTests.cs b/tests/Inbix.Tests/DataLayerTests.cs
index 414baee..c583f41 100644
--- a/tests/Inbix.Tests/DataLayerTests.cs
+++ b/tests/Inbix.Tests/DataLayerTests.cs
@@ -101,6 +101,21 @@ public async Task Disabled_Alias_Is_Not_Deliverable()
Assert.False(await fresh.IsDeliverableAsync("github@mydomain.com"));
}
+ [Fact]
+ public async Task Creating_Alias_Invalidates_Resolver_Negative_Cache()
+ {
+ var resolver = _sp.GetRequiredService();
+ var aliasService = _sp.GetRequiredService();
+
+ // Prime the negative cache: not deliverable yet, and the "no" is cached for the TTL.
+ Assert.False(await resolver.IsDeliverableAsync("fresh@mydomain.com"));
+
+ await aliasService.CreateAsync("fresh", "mydomain.com", null);
+
+ // Without cache invalidation this would stay false for up to 30s; the new alias must accept mail now.
+ Assert.True(await resolver.IsDeliverableAsync("fresh@mydomain.com"));
+ }
+
public Task DisposeAsync() => Task.CompletedTask;
public void Dispose()
diff --git a/tests/Inbix.Tests/ImapDeleteTests.cs b/tests/Inbix.Tests/ImapDeleteTests.cs
index c37a7b5..76bff22 100644
--- a/tests/Inbix.Tests/ImapDeleteTests.cs
+++ b/tests/Inbix.Tests/ImapDeleteTests.cs
@@ -84,6 +84,34 @@ public async Task Client_Delete_Removes_The_Message_From_The_Server()
Assert.Single(await messages.ListByAliasAsync(_aliasId, 50, 0));
}
+ [Fact]
+ public async Task Examine_Is_Read_Only_Even_With_AllowDelete()
+ {
+ var messages = _host.Services.GetRequiredService();
+ Assert.Equal(2, (await messages.ListByAliasAsync(_aliasId, 50, 0)).Count);
+
+ // A non-compliant client marks \Deleted on an EXAMINE (read-only) mailbox and CLOSEs.
+ using var tcp = new TcpClient();
+ await tcp.ConnectAsync(IPAddress.Loopback, _port);
+ using var r = new StreamReader(tcp.GetStream());
+ await using var w = new StreamWriter(tcp.GetStream()) { NewLine = "\r\n", AutoFlush = true };
+ await r.ReadLineAsync();
+ await Cmd(w, r, "a1 LOGIN admin admin", "a1");
+ await Cmd(w, r, "a2 EXAMINE INBOX", "a2"); // read-only open
+ await Cmd(w, r, "a3 STORE 1 +FLAGS (\\Deleted)", "a3");
+ await Cmd(w, r, "a4 CLOSE", "a4"); // must NOT expunge from a read-only mailbox
+
+ Assert.Equal(2, (await messages.ListByAliasAsync(_aliasId, 50, 0)).Count); // nothing deleted
+ }
+
+ private static async Task Cmd(StreamWriter w, StreamReader r, string command, string tag)
+ {
+ await w.WriteLineAsync(command);
+ string? line;
+ while ((line = await r.ReadLineAsync()) is not null)
+ if (line.StartsWith(tag + " ", StringComparison.Ordinal)) break;
+ }
+
private static int FreePort()
{
var l = new TcpListener(IPAddress.Loopback, 0);