← All source files

V13 / 7879e8b5740b

EmailClient.cs

Lilith/Core/Email/EmailClient.cs

Changed in this version
CS
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;

namespace Lilith.Core.Email;

/// <summary>Fixed localhost transport. Account credentials never enter this process.</summary>
public sealed class EmailClient
{
    internal static async Task<string> SendAsync(string action, object payload, CancellationToken ct)
    {
        if (action is not ("read" or "sections" or "section" or "sender-rule")) throw new ArgumentException("Unsupported email action.");
        string token = Environment.GetEnvironmentVariable("LILITH_EMAIL_API_TOKEN") ?? "";
        if (token.Length is < 32 or > 256 || token.Any(char.IsControl)) throw new InvalidOperationException("Email service is not configured.");
        int port = int.TryParse(Environment.GetEnvironmentVariable("LILITH_EMAIL_PORT"), out int p) ? p : 5183;
        if (port is < 1024 or > 65535) throw new InvalidOperationException("Invalid email port.");
        using var handler = new HttpClientHandler { AllowAutoRedirect = false, UseProxy = false, UseCookies = false };
        using var http = new HttpClient(handler) { BaseAddress = new Uri($"http://127.0.0.1:{port}/"), Timeout = TimeSpan.FromSeconds(35), MaxResponseContentBufferSize = 128000 };
        http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
        using var response = await http.PostAsJsonAsync("api/v1/mail/" + action, payload, ct);
        if (!response.IsSuccessStatusCode) throw new IOException($"Email request failed (HTTP {(int)response.StatusCode}); no message content was obtained.");
        return await response.Content.ReadAsStringAsync(ct);
    }
    public static string ValidateMode(string mode) => mode is "unread" or "since_last_check" ? mode : throw new ArgumentException("Use unread or since_last_check.");
    public static async Task<string> RequestAsync(string action, string? nickname, string mode, CancellationToken ct)
    {
        ValidateMode(mode);
        string token = Environment.GetEnvironmentVariable("LILITH_EMAIL_API_TOKEN") ?? "";
        if (token.Length < 32 || token.Length > 256 || token.Any(char.IsControl)) throw new InvalidOperationException("Email service is not configured. Set its shared API token in the host environment, never in a chat message.");
        int port = int.TryParse(Environment.GetEnvironmentVariable("LILITH_EMAIL_PORT"), out int configured) ? configured : 5183;
        if (port is < 1024 or > 65535) throw new InvalidOperationException("Invalid email service port.");
        if (action is not ("accounts" or "check" or "count")) throw new ArgumentException("Unsupported email operation.");
        if (action != "accounts" && !System.Text.RegularExpressions.Regex.IsMatch(nickname ?? "", "^[a-z][a-z0-9_-]{0,47}$")) throw new ArgumentException("Specify a configured mailbox nickname.");
        using var handler = new HttpClientHandler { AllowAutoRedirect = false, UseProxy = false, UseCookies = false };
        using var http = new HttpClient(handler) { BaseAddress = new Uri($"http://127.0.0.1:{port}/"), Timeout = TimeSpan.FromSeconds(35), MaxResponseContentBufferSize = 128000 };
        http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
        using var response = action == "accounts" ? await http.GetAsync("api/v1/accounts", ct) : await http.PostAsJsonAsync("api/v1/mail/" + action, new { nickname, mode, limit = 10 }, ct);
        if (!response.IsSuccessStatusCode) throw new IOException($"Email service request failed (HTTP {(int)response.StatusCode}). Check the separate email program's account setup and availability.");
        string json = await response.Content.ReadAsStringAsync(ct);
        using var data = JsonDocument.Parse(json);
        if (action == "count" && (data.RootElement.GetProperty("nickname").GetString() != nickname || data.RootElement.GetProperty("inboxUnread").GetInt32() < 0)) throw new InvalidDataException("Invalid email count receipt.");
        if (action == "check")
        {
            var root = data.RootElement;
            if (root.GetProperty("nickname").GetString() != nickname || root.GetProperty("mode").GetString() != mode || root.GetProperty("messages").GetArrayLength() > 10) throw new InvalidDataException("Invalid email service receipt.");
            if (mode == "since_last_check")
            {
                string batch = root.GetProperty("batchId").GetString() ?? "";
                if (!Guid.TryParseExact(batch, "N", out _)) throw new InvalidDataException("Invalid email batch ID.");
                // Retain a durable local receipt before advancing the remote cursor.
                // A failed acknowledgement leaves the service batch pending for retry.
                string folder = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Lilith", "EmailReceipts");
                Directory.CreateDirectory(folder);
                string path = Path.Combine(folder, batch + ".json"), temporary = path + "." + Guid.NewGuid().ToString("N") + ".tmp";
                try { await File.WriteAllTextAsync(temporary, json, ct); File.Move(temporary, path, true); }
                finally { if (File.Exists(temporary)) File.Delete(temporary); }
                using var ack = await http.PostAsJsonAsync("api/v1/mail/ack", new { nickname, batchId = batch }, ct);
                if (!ack.IsSuccessStatusCode) throw new IOException("Email receipt retained locally, but acknowledgement failed. Retry the check; the service retains its pending batch.");
            }
        }
        return json;
    }
    public static async Task<string> GreetingAsync(CancellationToken ct)
    {
        using var deadline = CancellationTokenSource.CreateLinkedTokenSource(ct);
        deadline.CancelAfter(TimeSpan.FromSeconds(15));
        try
        {
            using var accounts = JsonDocument.Parse(await RequestAsync("accounts", null, "unread", deadline.Token));
            var entries = accounts.RootElement.GetProperty("accounts");
            if (entries.GetArrayLength() == 0 || entries.GetArrayLength() > 20) return JsonSerializer.Serialize(new { available = false, reason = "no_bounded_mailbox_selection" });
            int count = 0; bool dummyOnly = true;
            foreach (var account in entries.EnumerateArray())
            {
                using var result = JsonDocument.Parse(await RequestAsync("count", account.GetProperty("nickname").GetString(), "unread", deadline.Token));
                count = checked(count + result.RootElement.GetProperty("inboxUnread").GetInt32());
                dummyOnly &= result.RootElement.GetProperty("source").GetString() == "dummy";
            }
            return JsonSerializer.Serialize(new { available = true, inbox_unread = count, mailbox_count = entries.GetArrayLength(), dummy_only = dummyOnly, scope = "INBOX; Gmail Primary category is not classified" });
        }
        catch (Exception) when (!ct.IsCancellationRequested)
        { return JsonSerializer.Serialize(new { available = false, reason = "email_service_unavailable" }); }
    }
}