← All source files

V18 / 8d896b8a86e5

LocalMailApi.cs

Services/EmailService/LocalMailApi.cs

Unchanged from the preceding version
CS
using System.Net;
using System.Security.Cryptography;
using System.Text;

namespace Lilith.EmailService;

public static class LocalMailApi
{
    public static WebApplication Create(MailStore store, string token, int port)
    {
        if (token.Length < 32 || token.Length > 256 || token.Any(char.IsControl)) throw new ArgumentException("Set LILITH_EMAIL_API_TOKEN to a random secret of 32-256 characters.");
        if (port is < 1024 or > 65535) throw new ArgumentException("Invalid localhost port.");
        var builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = [] });
        builder.Logging.ClearProviders(); // Mail metadata, secrets and provider errors never enter HTTP logs.
        builder.WebHost.ConfigureKestrel(options => { options.Listen(IPAddress.Loopback, port); options.Limits.MaxRequestBodySize = 4096; });
        var app = builder.Build();
        app.Use(async (context, next) =>
        {
            context.Response.Headers.CacheControl = "no-store";
            if (!IPAddress.IsLoopback(context.Connection.RemoteIpAddress ?? IPAddress.None) ||
                context.Request.Headers.ContainsKey("Origin") || context.Request.Host.Host != "127.0.0.1")
            { context.Response.StatusCode = 403; return; }
            if (context.Request.Path == "/health") { await next(context); return; }
            byte[] expected = SHA256.HashData(Encoding.UTF8.GetBytes("Bearer " + token));
            byte[] actual = SHA256.HashData(Encoding.UTF8.GetBytes(context.Request.Headers.Authorization.ToString()));
            if (!CryptographicOperations.FixedTimeEquals(expected, actual)) { context.Response.StatusCode = 401; return; }
            using var deadline = CancellationTokenSource.CreateLinkedTokenSource(context.RequestAborted);
            deadline.CancelAfter(TimeSpan.FromSeconds(30));
            context.RequestAborted = deadline.Token;
            try { await next(context); }
            catch (BadHttpRequestException) { await Error(context, 400, "invalid_request"); }
            catch (ArgumentException) { await Error(context, 400, "invalid_request"); }
            catch (InvalidOperationException) { await Error(context, 409, "mailbox_changed_or_invalid_state"); }
            catch (FileNotFoundException) { await Error(context, 404, "mailbox_not_configured"); }
            catch (MailKit.Security.AuthenticationException) { await Error(context, 502, "mailbox_authentication_failed"); }
            catch (OperationCanceledException) { if (!context.Response.HasStarted) context.Response.StatusCode = 504; }
            catch (Exception) { await Error(context, 502, "mailbox_unavailable"); }
        });
        app.MapGet("/health", () => Results.Json(new { status = "ready", api = 1 }));
        app.MapGet("/api/v1/accounts", async (CancellationToken ct) => Results.Json(new { accounts = (await store.AccountsAsync(ct)).Select(a => new { nickname = a.Nickname, source = a.Provider }), defaultMode = "unread" }));
        app.MapPost("/api/v1/mail/check", async (MailCheck request, CancellationToken ct) => Results.Json(await store.CheckAsync(request, ct)));
        app.MapPost("/api/v1/mail/count", async (MailCheck request, CancellationToken ct) => Results.Json(await store.CountAsync(request.Nickname, ct)));
        app.MapPost("/api/v1/mail/read", async (MailRead request, CancellationToken ct) => Results.Json(await store.ReadMessageAsync(request, ct)));
        app.MapPost("/api/v1/mail/sections", async (MailCheck request, CancellationToken ct) => Results.Json(await store.SectionsAsync(request.Nickname, ct)));
        app.MapPost("/api/v1/mail/section", async (MailSection request, CancellationToken ct) => Results.Json(await store.SectionAsync(request, ct)));
        app.MapPost("/api/v1/mail/sender-rule", async (MailSenderRule request, CancellationToken ct) => Results.Json(await store.SenderRuleAsync(request, ct)));
        app.MapPost("/api/v1/mail/ack", async (MailAck ack, CancellationToken ct) => { await store.AckAsync(ack, ct); return Results.Json(new { status = "acknowledged" }); });
        return app;
    }
    private static async Task Error(HttpContext context, int status, string code)
    {
        if (context.Response.HasStarted) return;
        context.Response.StatusCode = status;
        await context.Response.WriteAsJsonAsync(new { status = "error", code });
    }
}