V18 / 8d896b8a86e5
MailStore.cs
Services/EmailService/MailStore.cs
Unchanged from the preceding versionCS
using System.Text.Json;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.DataProtection;
namespace Lilith.EmailService;
public sealed class MailStore(string root, IDataProtector protector, IMailReader reader)
{
private readonly SemaphoreSlim gate = new(1, 1);
public static string Nickname(string name) => Regex.IsMatch(name, "^[a-z][a-z0-9_-]{0,47}$") ? name : throw new ArgumentException("Use a lowercase mailbox nickname (letters, digits, underscore or hyphen).");
private string PathFor(string category, string nickname) => Path.Combine(root, category, Nickname(nickname) + ".json");
private async Task WriteAsync<T>(string path, T value, bool encrypt, CancellationToken ct)
{
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
string json = JsonSerializer.Serialize(value);
string temporary = path + "." + Guid.NewGuid().ToString("N") + ".tmp";
try { await File.WriteAllTextAsync(temporary, encrypt ? protector.Protect(json) : json, ct); File.Move(temporary, path, true); }
finally { if (File.Exists(temporary)) File.Delete(temporary); }
}
private async Task<T> ReadAsync<T>(string path, bool encrypt, CancellationToken ct)
{
string text = await File.ReadAllTextAsync(path, ct);
return JsonSerializer.Deserialize<T>(encrypt ? protector.Unprotect(text) : text) ?? throw new InvalidDataException("Invalid local mail state.");
}
public async Task AddAsync(string nickname, string address, string password, CancellationToken ct)
{
Nickname(nickname);
if (!System.Net.Mail.MailAddress.TryCreate(address, out var parsed) || parsed.Address != address || string.IsNullOrWhiteSpace(password)) throw new ArgumentException("A valid Google address and app password are required.");
string path = PathFor("accounts", nickname);
if (File.Exists(path)) throw new IOException("Nickname already exists; existing mailbox configuration was not overwritten.");
foreach (var account in await AccountsAsync(ct)) if (string.Equals(account.Address, address, StringComparison.OrdinalIgnoreCase)) throw new IOException("This address already has a nickname.");
await WriteAsync(path, new MailAccount(nickname, address, "imap.gmail.com", 993, protector.Protect(password.Replace(" ", ""))), false, ct);
}
public async Task<MailAccount[]> AccountsAsync(CancellationToken ct)
{
string folder = Path.Combine(root, "accounts");
if (!Directory.Exists(folder)) return [];
var result = new List<MailAccount>();
foreach (string file in Directory.EnumerateFiles(folder, "*.json")) result.Add(await ReadAsync<MailAccount>(file, false, ct));
return result.ToArray();
}
public async Task AddDummyAsync(string nickname, CancellationToken ct, bool briefing = false)
{
string path = PathFor("accounts", nickname);
if (File.Exists(path)) throw new IOException("Nickname already exists. Existing accounts are never replaced by dummy data.");
if (briefing) await DummyMailReader.CreateBriefingAsync(root, nickname, ct);
else await DummyMailReader.CreateAsync(root, nickname, ct);
await WriteAsync(path, new MailAccount(nickname, nickname + "@example.invalid", "dummy.invalid", 0, protector.Protect("unused-local-dummy"), "dummy"), false, ct);
}
public async Task AddDummyMessageAsync(string nickname, CancellationToken ct)
{
var account = await ReadAsync<MailAccount>(PathFor("accounts", nickname), false, ct);
if (account.Provider != "dummy") throw new InvalidOperationException("This is not a dummy mailbox; real mailboxes are never modified.");
await DummyMailReader.AppendAsync(root, nickname, ct);
}
public async Task<MailBatch> CheckAsync(MailCheck request, CancellationToken ct)
{
Nickname(request.Nickname);
if (request.Mode is not ("unread" or "since_last_check") || request.Limit is < 1 or > 50) throw new ArgumentException("Mode must be unread or since_last_check; limit must be 1-50.");
await gate.WaitAsync(ct);
try
{
var account = await ReadAsync<MailAccount>(PathFor("accounts", request.Nickname), false, ct);
string path = PathFor("state", request.Nickname);
var state = File.Exists(path) ? await ReadAsync<MailState>(path, true, ct) : new();
// Unacknowledged arrivals are replayed exactly, including after restart.
if (request.Mode == "since_last_check" && state.Pending is not null) return state.Pending;
var snapshot = await reader.ReadAsync(account, protector.Unprotect(account.ProtectedPassword), request.Mode, state.Initialized ? state.Validity : 0, state.Cursor, request.Limit, ct);
bool reset = state.Initialized && state.Validity != snapshot.Validity;
var selected = snapshot.Items.Take(request.Limit).ToArray();
uint cursor = selected.Length == 0 ? (reset ? 0 : state.Cursor) : selected.Max(i => i.Uid);
var batch = new MailBatch(reset ? "mailbox_reset" : "completed", request.Nickname, request.Mode, Guid.NewGuid().ToString("N"), snapshot.Validity, cursor, !state.Initialized || reset, snapshot.Items.Length > request.Limit, selected, account.Provider);
if (request.Mode == "since_last_check") await WriteAsync(path, state with { Pending = batch }, true, ct);
return batch;
}
finally { gate.Release(); }
}
public async Task<MailCounts> CountAsync(string nickname, CancellationToken ct)
{
await gate.WaitAsync(ct);
try
{
var account = await ReadAsync<MailAccount>(PathFor("accounts", nickname), false, ct);
return new(nickname, await reader.CountUnreadAsync(account, protector.Unprotect(account.ProtectedPassword), ct), account.Provider);
}
finally { gate.Release(); }
}
public async Task<MailContent> ReadMessageAsync(MailRead request, CancellationToken ct)
{
if (request.Validity == 0 || request.Uid == 0) throw new ArgumentException("A mailbox validity and message UID are required.");
await gate.WaitAsync(ct);
try
{
var account = await ReadAsync<MailAccount>(PathFor("accounts", request.Nickname), false, ct);
return await reader.ReadMessageAsync(account, protector.Unprotect(account.ProtectedPassword), request.Validity, request.Uid, ct);
}
finally { gate.Release(); }
}
public async Task<object> SenderRuleAsync(MailSenderRule request, CancellationToken ct)
{
if (!System.Net.Mail.MailAddress.TryCreate(request.Sender, out var parsed) || parsed.Address != request.Sender) throw new ArgumentException("An exact sender address is required.");
await gate.WaitAsync(ct);
try
{
_ = await ReadAsync<MailAccount>(PathFor("accounts", request.Nickname), false, ct);
string path = PathFor("blocked", request.Nickname);
var senders = File.Exists(path) ? await ReadAsync<HashSet<string>>(path, true, ct) : [];
string sender = request.Sender.ToLowerInvariant();
if (request.Blocked) senders.Add(sender); else senders.Remove(sender);
await WriteAsync(path, senders, true, ct);
return new { status = "completed", nickname = request.Nickname, sender, blocked = request.Blocked, scope = "local sender rule only; Gmail unchanged" };
}
finally { gate.Release(); }
}
public async Task<object> SectionsAsync(string nickname, CancellationToken ct)
{
await gate.WaitAsync(ct);
try
{
_ = await ReadAsync<MailAccount>(PathFor("accounts", nickname), false, ct);
string blocked = PathFor("blocked", nickname), sections = PathFor("sections", nickname);
return new { blockedSenders = File.Exists(blocked) ? await ReadAsync<HashSet<string>>(blocked, true, ct) : [], messages = File.Exists(sections) ? await ReadAsync<Dictionary<string,string>>(sections, true, ct) : [] };
}
finally { gate.Release(); }
}
public async Task<object> SectionAsync(MailSection request, CancellationToken ct)
{
if (request.Section is not ("shopping" or "inbox")) throw new ArgumentException("Only shopping/inbox may be classified automatically.");
// Validate identity against the actual mailbox before retaining a classification.
_ = await ReadMessageAsync(new(request.Nickname, request.Validity, request.Uid), ct);
await gate.WaitAsync(ct);
try
{
string path = PathFor("sections", request.Nickname);
var sections = File.Exists(path) ? await ReadAsync<Dictionary<string,string>>(path, true, ct) : [];
sections[$"{request.Validity}:{request.Uid}"] = request.Section;
await WriteAsync(path, sections, true, ct);
return new { status = "completed", section = request.Section };
}
finally { gate.Release(); }
}
public async Task AckAsync(MailAck ack, CancellationToken ct)
{
await gate.WaitAsync(ct);
try
{
string path = PathFor("state", ack.Nickname);
var state = await ReadAsync<MailState>(path, true, ct);
if (state.LastAck == ack.BatchId) return;
var batch = state.Pending;
if (batch is null || batch.BatchId != ack.BatchId) throw new ArgumentException("Unknown pending batch.");
await WriteAsync(path, new MailState(batch.Validity, batch.Cursor, true, null, batch.BatchId), true, ct);
}
finally { gate.Release(); }
}
}