using LiteDB; using MailSharp.MailClient.Models; namespace MailSharp.MailClient.Services; public interface IMessageIndexStore { FolderSyncState? GetSyncState(int accountId, string folder); void SaveSyncState(int accountId, string folder, uint uidValidity, uint uidNext, int messageCount); List GetMessages(int accountId, string folder); IndexedMessage? GetMessage(int accountId, string folder, uint uid); int GetMessageCount(int accountId, string folder); // Full rebuild: replaces every row for this folder in one go (used on UIDVALIDITY change, i.e. // the server considers the mailbox rebuilt, or the first time a folder is indexed). void ReplaceFolder(int accountId, string folder, List messages); // Incremental add (new UIDs since the last check) - doesn't touch existing rows. void UpsertMessages(int accountId, string folder, List messages); // Reconciliation deletes (messages gone from IMAP - removed or moved elsewhere, by this app or // another IMAP client) and folder-deletion cleanup (Settings > Mappen beheren). void RemoveMessages(int accountId, string folder, IEnumerable uids); void RemoveFolder(int accountId, string folder); // Keeps the index in sync with this app's own actions immediately, rather than waiting for the // next EnsureFolderIndexedAsync check to notice. void MarkRead(int accountId, string folder, IEnumerable uids, bool read); void MarkFlagged(int accountId, string folder, IEnumerable uids, bool flagged); // Self-heal for a message indexed before ResolveMessageDate's INTERNALDATE fallback existed (see // ImapService.GetMessageAsync) - a normal index rebuild only happens on UIDVALIDITY change, so an // already-broken date otherwise never gets corrected on its own. No-ops if the uid isn't indexed. void UpdateDate(int accountId, string folder, uint uid, DateTimeOffset date); long GetTotalSize(int accountId, string folder); int GetUnreadCount(int accountId, string folder); // Unread count among only the most recent `recentCount` messages (by UID) - what's actually // shown to the user (see ImapService.GetFoldersAsync/GetMessageAsync). A raw folder-wide unread // total isn't a useful number in practice: it counts a message from ten years ago, or 20,000 // untouched messages sitting in Trash, exactly the same as something that arrived five minutes // ago - numbers nobody's actually trying to act on. Ordered/limited at the query level (not // GetMessages().Take()) so this stays cheap even for a folder with tens of thousands of rows. int GetRecentUnreadCount(int accountId, string folder, int recentCount); // Same scope as GetRecentUnreadCount, but the actual UIDs rather than just the count - purely // for diagnostics (see ImapService.GetFoldersAsync), so "the badge says 1" can be answered with // "which one" directly from the same query the badge itself uses, instead of cross-referencing a // differently-timed background job's own view of things. List GetRecentUnreadUids(int accountId, string folder, int recentCount); // Every folder this account currently has any indexed messages/sync state for - used by the // Maintenance page's message-count metrics to aggregate across folders without needing the // caller to already know the account's folder names. List GetIndexedFolders(int accountId); // Filters in-memory across every indexed folder for this account - LiteDB isn't a full-text // search engine, but at the scale of one mailbox's message count this is fast enough (same // approach the old live-IMAP scan used after fetching, just without the IMAP round trip). List SearchByAddress(int accountId, string address); } // Holds a single long-lived LiteDatabase connection for the app's lifetime (registered as a // singleton), same pattern as the other *Store classes - a fresh connection per call would race on // the OS file lock under concurrent requests. public class LiteDbMessageIndexStore : IMessageIndexStore, IDisposable { private readonly LiteDatabase _db; public LiteDbMessageIndexStore(IWebHostEnvironment env) { var dir = Path.Combine(env.ContentRootPath, "App_Data"); Directory.CreateDirectory(dir); _db = new LiteDatabase(Path.Combine(dir, "messageindex.db")); Messages.EnsureIndex(x => x.Key); Messages.EnsureIndex(x => x.AccountId); } private ILiteCollection Messages => _db.GetCollection("messages"); private ILiteCollection SyncStates => _db.GetCollection("folderSyncStates"); private static string Key(int accountId, string folder) => $"{accountId}:{folder}"; private static string MessageId(int accountId, string folder, uint uid) => $"{accountId}:{folder}:{uid}"; public FolderSyncState? GetSyncState(int accountId, string folder) => SyncStates.FindById(Key(accountId, folder)); public void SaveSyncState(int accountId, string folder, uint uidValidity, uint uidNext, int messageCount) => SyncStates.Upsert(new FolderSyncState { Id = Key(accountId, folder), UidValidity = uidValidity, UidNext = uidNext, MessageCount = messageCount }); public List GetMessages(int accountId, string folder) => [.. Messages.Find(x => x.Key == Key(accountId, folder))]; // O(1) primary-key lookup (unlike GetMessages, which scans/returns every row for the folder) - // for callers that only need one message's current state, e.g. checking whether it's still // unread before deciding whether a cache-hit read needs an IMAP round trip after all (see // ImapService.LoadMimeMessageAsync). public IndexedMessage? GetMessage(int accountId, string folder, uint uid) => Messages.FindById(MessageId(accountId, folder, uid)); public int GetMessageCount(int accountId, string folder) => Messages.Count(x => x.Key == Key(accountId, folder)); public void ReplaceFolder(int accountId, string folder, List messages) { var key = Key(accountId, folder); Messages.DeleteMany(x => x.Key == key); Stamp(accountId, folder, messages); if (messages.Count > 0) Messages.InsertBulk(messages); } public void UpsertMessages(int accountId, string folder, List messages) { Stamp(accountId, folder, messages); foreach (var m in messages) Messages.Upsert(m); } private static void Stamp(int accountId, string folder, List messages) { var key = Key(accountId, folder); foreach (var m in messages) { m.Id = MessageId(accountId, folder, m.Uid); m.Key = key; m.AccountId = accountId; m.Folder = folder; } } public void RemoveMessages(int accountId, string folder, IEnumerable uids) { foreach (var uid in uids) Messages.Delete(MessageId(accountId, folder, uid)); } public void RemoveFolder(int accountId, string folder) { var key = Key(accountId, folder); Messages.DeleteMany(x => x.Key == key); SyncStates.Delete(Key(accountId, folder)); } public void MarkRead(int accountId, string folder, IEnumerable uids, bool read) { foreach (var uid in uids) { var m = Messages.FindById(MessageId(accountId, folder, uid)); if (m == null || m.IsRead == read) continue; m.IsRead = read; Messages.Update(m); } } public void UpdateDate(int accountId, string folder, uint uid, DateTimeOffset date) { var m = Messages.FindById(MessageId(accountId, folder, uid)); if (m == null || m.Date == date) return; m.Date = date; Messages.Update(m); } public void MarkFlagged(int accountId, string folder, IEnumerable uids, bool flagged) { foreach (var uid in uids) { var m = Messages.FindById(MessageId(accountId, folder, uid)); if (m == null || m.IsFlagged == flagged) continue; m.IsFlagged = flagged; Messages.Update(m); } } public long GetTotalSize(int accountId, string folder) { var key = Key(accountId, folder); return Messages.Find(x => x.Key == key).Sum(x => x.SizeBytes); } public int GetUnreadCount(int accountId, string folder) => Messages.Count(x => x.Key == Key(accountId, folder) && !x.IsRead); public int GetRecentUnreadCount(int accountId, string folder, int recentCount) => Messages.Query() .Where(x => x.Key == Key(accountId, folder)) .OrderByDescending(x => x.Uid) .Limit(recentCount) .ToEnumerable() .Count(x => !x.IsRead); public List GetRecentUnreadUids(int accountId, string folder, int recentCount) => [.. Messages.Query() .Where(x => x.Key == Key(accountId, folder)) .OrderByDescending(x => x.Uid) .Limit(recentCount) .ToEnumerable() .Where(x => !x.IsRead) .Select(x => x.Uid)]; public List GetIndexedFolders(int accountId) => [.. SyncStates.Find(x => x.Id.StartsWith(accountId + ":")) .Select(x => x.Id[(accountId.ToString().Length + 1)..])]; public List SearchByAddress(int accountId, string address) { // From/To/Cc are always written as "" rather than null (see FetchIndexedMessagesAsync), but // tolerate null here anyway defensively - a document from a different code path or a partial // write shouldn't crash every future search. return [.. Messages.Find(x => x.AccountId == accountId) .Where(m => (m.From ?? "").Contains(address, StringComparison.OrdinalIgnoreCase) || (m.To ?? "").Contains(address, StringComparison.OrdinalIgnoreCase) || (m.Cc ?? "").Contains(address, StringComparison.OrdinalIgnoreCase))]; } public void Dispose() => _db.Dispose(); }