MailSharp / MailSharp.MailClient / Services / MailCacheStore.cs
Code · 293 lines · 13106 bytes
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293using LiteDB;
using MailSharp.MailClient.Models;

namespace MailSharp.MailClient.Services;

public interface IMailCacheStore
{
	List<MessageListItem>? GetMessageList(int accountId, string folder, TimeSpan maxAge);
	void SaveMessageList(int accountId, string folder, List<MessageListItem> 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<MessageSearchResult>? GetContactMailSearch(int accountId, string email, TimeSpan maxAge);
	void SaveContactMailSearch(int accountId, string email, List<MessageSearchResult> 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<CachedMessageList> MessageLists => _db.GetCollection<CachedMessageList>("messageLists");
	private ILiteCollection<SenderImagePreference> SenderImagePrefs => _db.GetCollection<SenderImagePreference>("senderImagePrefs");
	private ILiteCollection<ImageUrlMapping> ImageUrlMappings => _db.GetCollection<ImageUrlMapping>("imageUrlMappings");
	private ILiteCollection<MessageIdMapping> MessageIdMappings => _db.GetCollection<MessageIdMapping>("messageIdMappings");
	private ILiteCollection<ContactMailSearchCache> ContactMailSearches => _db.GetCollection<ContactMailSearchCache>("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<MessageListItem>? 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<MessageListItem> 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<MessageSearchResult>? 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<MessageSearchResult> 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();
}