using LiteDB; using MailSharp.MailClient.Models; namespace MailSharp.MailClient.Services; public interface IMailCacheStore { List? GetMessageList(int accountId, string folder, TimeSpan maxAge); void SaveMessageList(int accountId, string folder, List messages); void MarkMessageRead(int accountId, string folder, uint uid); // Messages are cached as raw .eml files on disk (see App_Data/cache/messages), organized by // the logged-in email address and named after the message's own Message-ID - not the IMAP // uid, which is only unique within a single folder and changes when a message is moved. Not // as LiteDB blobs, and not as a pre-processed snapshot: the .eml is the untouched source, so a // schema change to MessageDetail never requires invalidating the cache. byte[]? GetMessageEml(string emailAddress, string messageId); void SaveMessageEml(string emailAddress, string messageId, byte[] emlBytes); // (account, folder, uid) is the only thing known before contacting IMAP, so this maps it to // the Message-ID used above once we've fetched the message at least once. string? GetMessageIdForUid(int accountId, string folder, uint uid); void SaveMessageIdForUid(int accountId, string folder, uint uid, string messageId); bool GetAllowExternalImages(int accountId, string senderEmail); void SetAllowExternalImages(int accountId, string senderEmail, bool allow); // Caches the (expensive - walks every folder over IMAP) result of searching all mail to/from // a contact, keyed by (account, email). Same maxAge/forceRefresh pattern as GetMessageList. List? GetContactMailSearch(int accountId, string email, TimeSpan maxAge); void SaveContactMailSearch(int accountId, string email, List results); // Attachments are cached as loose files on disk under // App_Data/cache/attachments/{emailAddress}/{messageId}/{partIndex}_{fileName} - not LiteDB // blobs - saved the first time they're viewed or downloaded, keyed like the .eml/image // caches so a moved message doesn't invalidate anything. byte[]? GetAttachmentContent(string emailAddress, string messageId, int partIndex); void SaveAttachmentContent(string emailAddress, string messageId, int partIndex, string fileName, byte[] content); // Image disk cache: bytes are stored as loose files under // App_Data/cache/images/{emailAddress}/{messageId}/{fileName} (fileName is "01.jpg" etc., see // ImageSanitizer) so it's obvious which images belong to which message - not inside LiteDB; // only the (email, messageId, fileName) -> source URL mapping (needed so the // /cache/images/... handler can download on first request) lives in LiteDB. string? GetImageSourceUrl(string emailAddress, string messageId, string fileName); void SaveImageSourceUrl(string emailAddress, string messageId, string fileName, string url); string? GetCachedImagePath(string emailAddress, string messageId, string fileName); string SaveCachedImageFile(string emailAddress, string messageId, string fileName, byte[] content); // Aggregate view of every cache layer this store manages, for the Maintenance page's background // process panel - both the small structured LiteDB entries (message list/contact search TTL // caches, image/uid mappings) and the loose files on disk (.eml/attachment/image caches), since // those are the two very different things "how much is cached" could mean here. CacheStats GetStats(); } // Holds a single long-lived LiteDatabase connection for the app's lifetime (registered as a // singleton) for the small structured metadata. LiteDatabase is thread-safe for concurrent // operations; opening/closing a new connection per call instead would race on the OS file lock // under concurrent requests (e.g. clicking through several messages fires overlapping fetches). public class LiteDbMailCacheStore : IMailCacheStore, IDisposable { private readonly LiteDatabase _db; private readonly string _messagesDir; private readonly string _imagesDir; private readonly string _attachmentsDir; public LiteDbMailCacheStore(IWebHostEnvironment env) { var appDataDir = Path.Combine(env.ContentRootPath, "App_Data"); Directory.CreateDirectory(appDataDir); _db = new LiteDatabase(Path.Combine(appDataDir, "mailcache.db")); var cacheRoot = Path.Combine(appDataDir, "cache"); _messagesDir = Path.Combine(cacheRoot, "messages"); _imagesDir = Path.Combine(cacheRoot, "images"); _attachmentsDir = Path.Combine(cacheRoot, "attachments"); Directory.CreateDirectory(_messagesDir); Directory.CreateDirectory(_imagesDir); Directory.CreateDirectory(_attachmentsDir); } private ILiteCollection MessageLists => _db.GetCollection("messageLists"); private ILiteCollection SenderImagePrefs => _db.GetCollection("senderImagePrefs"); private ILiteCollection ImageUrlMappings => _db.GetCollection("imageUrlMappings"); private ILiteCollection MessageIdMappings => _db.GetCollection("messageIdMappings"); private ILiteCollection ContactMailSearches => _db.GetCollection("contactMailSearches"); private static string ListKey(int accountId, string folder) => $"{accountId}:{folder}"; private static string SenderKey(int accountId, string senderEmail) => $"{accountId}:{senderEmail.Trim().ToLowerInvariant()}"; private static string UidMappingKey(int accountId, string folder, uint uid) => $"{accountId}:{folder}:{uid}"; private static string ContactMailSearchKey(int accountId, string email) => $"{accountId}:{email.Trim().ToLowerInvariant()}"; public List? GetMessageList(int accountId, string folder, TimeSpan maxAge) { var entry = MessageLists.FindById(ListKey(accountId, folder)); if (entry == null) return null; if (DateTimeOffset.UtcNow - entry.CachedAt > maxAge) return null; return entry.Messages; } public void SaveMessageList(int accountId, string folder, List messages) { MessageLists.Upsert(new CachedMessageList { Id = ListKey(accountId, folder), CachedAt = DateTimeOffset.UtcNow, Messages = messages }); } public void MarkMessageRead(int accountId, string folder, uint uid) { var col = MessageLists; var entry = col.FindById(ListKey(accountId, folder)); if (entry == null) return; var item = entry.Messages.FirstOrDefault(m => m.Uid == uid); if (item == null || item.IsRead) return; item.IsRead = true; col.Update(entry); } private string MessageEmlPath(string emailAddress, string messageId) { var dir = Path.Combine(_messagesDir, SanitizeForPath(emailAddress)); Directory.CreateDirectory(dir); return Path.Combine(dir, $"{SanitizeForPath(messageId)}.eml"); } public byte[]? GetMessageEml(string emailAddress, string messageId) { var path = MessageEmlPath(emailAddress, messageId); return File.Exists(path) ? File.ReadAllBytes(path) : null; } public void SaveMessageEml(string emailAddress, string messageId, byte[] emlBytes) => File.WriteAllBytes(MessageEmlPath(emailAddress, messageId), emlBytes); public string? GetMessageIdForUid(int accountId, string folder, uint uid) => MessageIdMappings.FindById(UidMappingKey(accountId, folder, uid))?.MessageId; public void SaveMessageIdForUid(int accountId, string folder, uint uid, string messageId) => MessageIdMappings.Upsert(new MessageIdMapping { Id = UidMappingKey(accountId, folder, uid), MessageId = messageId }); public bool GetAllowExternalImages(int accountId, string senderEmail) { if (string.IsNullOrWhiteSpace(senderEmail)) return false; var entry = SenderImagePrefs.FindById(SenderKey(accountId, senderEmail)); return entry?.AllowImages ?? false; } public void SetAllowExternalImages(int accountId, string senderEmail, bool allow) { if (string.IsNullOrWhiteSpace(senderEmail)) return; SenderImagePrefs.Upsert(new SenderImagePreference { Id = SenderKey(accountId, senderEmail), AllowImages = allow }); } public List? GetContactMailSearch(int accountId, string email, TimeSpan maxAge) { var entry = ContactMailSearches.FindById(ContactMailSearchKey(accountId, email)); if (entry == null) return null; if (DateTimeOffset.UtcNow - entry.CachedAt > maxAge) return null; return entry.Results; } public void SaveContactMailSearch(int accountId, string email, List results) => ContactMailSearches.Upsert(new ContactMailSearchCache { Id = ContactMailSearchKey(accountId, email), CachedAt = DateTimeOffset.UtcNow, Results = results }); private string AttachmentDir(string emailAddress, string messageId) { var dir = Path.Combine(_attachmentsDir, SanitizeForPath(emailAddress), SanitizeForPath(messageId)); Directory.CreateDirectory(dir); return dir; } public byte[]? GetAttachmentContent(string emailAddress, string messageId, int partIndex) { var dir = AttachmentDir(emailAddress, messageId); var prefix = $"{partIndex:D2}_"; var match = Directory.EnumerateFiles(dir).FirstOrDefault(f => Path.GetFileName(f).StartsWith(prefix, StringComparison.Ordinal)); return match != null ? File.ReadAllBytes(match) : null; } public void SaveAttachmentContent(string emailAddress, string messageId, int partIndex, string fileName, byte[] content) { var path = Path.Combine(AttachmentDir(emailAddress, messageId), $"{partIndex:D2}_{SanitizeForPath(fileName)}"); File.WriteAllBytes(path, content); } private static string ImageMappingKey(string emailAddress, string messageId, string fileName) => $"{emailAddress.Trim().ToLowerInvariant()}:{messageId}:{fileName}"; private string ImageDir(string emailAddress, string messageId) { var dir = Path.Combine(_imagesDir, SanitizeForPath(emailAddress), SanitizeForPath(messageId)); Directory.CreateDirectory(dir); return dir; } public string? GetImageSourceUrl(string emailAddress, string messageId, string fileName) => ImageUrlMappings.FindById(ImageMappingKey(emailAddress, messageId, fileName))?.Url; public void SaveImageSourceUrl(string emailAddress, string messageId, string fileName, string url) { var key = ImageMappingKey(emailAddress, messageId, fileName); if (ImageUrlMappings.FindById(key) != null) return; ImageUrlMappings.Insert(new ImageUrlMapping { Id = key, Url = url }); } public string? GetCachedImagePath(string emailAddress, string messageId, string fileName) { var dir = ImageDir(emailAddress, messageId); var exactPath = Path.Combine(dir, fileName); if (File.Exists(exactPath)) return exactPath; // The source URL had no extension, so the file may have been saved with one guessed from // the download's Content-Type (see ImageCacheController) - look for "fileName.*" too. if (!fileName.Contains('.')) { return Directory.EnumerateFiles(dir, fileName + ".*").FirstOrDefault(); } return null; } public string SaveCachedImageFile(string emailAddress, string messageId, string fileName, byte[] content) { var path = Path.Combine(ImageDir(emailAddress, messageId), fileName); File.WriteAllBytes(path, content); return path; } private static string SanitizeForPath(string s) => new([.. s.Select(c => Path.GetInvalidFileNameChars().Contains(c) || c is '/' or '\\' ? '_' : c)]); public CacheStats GetStats() => new() { MessageListCacheEntries = MessageLists.Count(), ContactMailSearchEntries = ContactMailSearches.Count(), SenderImagePreferences = SenderImagePrefs.Count(), ImageUrlMappings = ImageUrlMappings.Count(), MessageIdMappings = MessageIdMappings.Count(), CachedMessages = DirStats(_messagesDir), CachedAttachments = DirStats(_attachmentsDir), CachedImages = DirStats(_imagesDir) }; // Walks the cache directory tree to count files/bytes - these are plain files (not tracked in // LiteDB, see the class remarks above), so there's no cheaper source of truth for "how much is // on disk" than actually looking. Fine at admin-page request frequency; not called per-request // during normal mail usage. private static DirCacheStats DirStats(string dir) { if (!Directory.Exists(dir)) return new DirCacheStats(); long bytes = 0; var count = 0; foreach (var file in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories)) { bytes += new FileInfo(file).Length; count++; } return new DirCacheStats { FileCount = count, TotalBytes = bytes }; } public void Dispose() => _db.Dispose(); } public class DirCacheStats { public int FileCount { get; set; } public long TotalBytes { get; set; } } public class CacheStats { public int MessageListCacheEntries { get; set; } public int ContactMailSearchEntries { get; set; } public int SenderImagePreferences { get; set; } public int ImageUrlMappings { get; set; } public int MessageIdMappings { get; set; } public DirCacheStats CachedMessages { get; set; } = new(); public DirCacheStats CachedAttachments { get; set; } = new(); public DirCacheStats CachedImages { get; set; } = new(); }