Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/Inbix.Core/Abstractions/IAliasResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,7 @@ public interface IAliasResolver
{
/// <summary>True if the full address is a known, enabled alias on an accepted domain.</summary>
Task<bool> IsDeliverableAsync(string address, CancellationToken ct = default);

/// <summary>Drop cached deliverability so alias create/enable/disable/delete take effect immediately.</summary>
void Invalidate();
}
21 changes: 17 additions & 4 deletions src/Inbix.Data/Json/JsonDataStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -169,11 +169,12 @@ private async Task LoadMessagesAsync(string dir, Dictionary<long, StoredMessage>
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
Expand All @@ -183,6 +184,15 @@ private async Task LoadMessagesAsync(string dir, Dictionary<long, StoredMessage>
}
}

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<List<AuditEntry>> LoadAuditAsync(CancellationToken ct)
{
var list = new List<AuditEntry>();
Expand Down Expand Up @@ -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<string> WindowsReserved = new(StringComparer.OrdinalIgnoreCase)
Expand Down
6 changes: 5 additions & 1 deletion src/Inbix.Data/Services/AliasService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,21 @@ public sealed class AliasService : IAliasService
{
private readonly IAliasRepository _aliases;
private readonly IMessageRepository _messages;
private readonly IAliasResolver _resolver;
private readonly ILogger<AliasService> _logger;

public AliasService(IAliasRepository aliases, IMessageRepository messages, ILogger<AliasService> logger)
public AliasService(IAliasRepository aliases, IMessageRepository messages, IAliasResolver resolver, ILogger<AliasService> logger)
{
_aliases = aliases;
_messages = messages;
_resolver = resolver;
_logger = logger;
}

public async Task<AliasCreated> 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);
Expand Down Expand Up @@ -52,6 +55,7 @@ public async Task<int> 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;
}
Expand Down
18 changes: 14 additions & 4 deletions src/Inbix.Data/Services/CachingAliasResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ public sealed class CachingAliasResolver : IAliasResolver
private readonly IAliasRepository _aliases;
private readonly HashSet<string> _domains;
private readonly ConcurrentDictionary<string, (bool deliverable, DateTimeOffset expires)> _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<InbixOptions> options)
{
Expand Down Expand Up @@ -62,12 +65,19 @@ public async Task<bool> IsDeliverableAsync(string address, CancellationToken ct

private async Task<bool> 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;
}
}
8 changes: 7 additions & 1 deletion src/Inbix.Data/Sqlite/SqliteConnectionFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<InbixOptions> options)
Expand Down Expand Up @@ -83,7 +84,12 @@ public async Task<DbConnection> 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)
Expand Down
9 changes: 8 additions & 1 deletion src/Inbix.Imap/ImapServerHostedService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading
Loading