← All source files

V16 / 6df58cd3ed86

MailStore.cs

Services/EmailService/MailStore.cs

Unchanged from the preceding version
CS
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(); }
    }
}