← All source files

V15 / 313404a015e8

ImapMailReader.cs

Services/EmailService/ImapMailReader.cs

Unchanged from the preceding version
CS
using MailKit;
using MailKit.Net.Imap;
using MailKit.Search;
using MailKit.Security;

namespace Lilith.EmailService;

public sealed class ImapMailReader : IMailReader
{
    public async Task<MailContent> ReadMessageAsync(MailAccount account, string password, uint validity, uint uid, CancellationToken ct)
    {
        using var client = new ImapClient { Timeout = 20000 };
        await client.ConnectAsync(account.Host, account.Port, SecureSocketOptions.SslOnConnect, ct);
        await client.AuthenticateAsync(account.Address, password, ct);
        await client.Inbox.OpenAsync(FolderAccess.ReadOnly, ct);
        if (client.Inbox.UidValidity != validity) throw new InvalidOperationException("Mailbox identity changed; check the mailbox again.");
        var id = new UniqueId(uid);
        var summaries = await client.Inbox.FetchAsync(new[] { id }, MessageSummaryItems.Size | MessageSummaryItems.Flags, ct);
        var summary = summaries.SingleOrDefault() ?? throw new FileNotFoundException("Message no longer exists.");
        if (summary.Size is null || summary.Size > 2_000_000) throw new IOException("Message is too large for bounded reading.");
        var message = await client.Inbox.GetMessageAsync(id, ct, new DownloadLimit());
        var body = MailBodyText.Extract(message.TextBody, message.HtmlBody);
        var sender = message.From.Mailboxes.FirstOrDefault();
        string Bound(string? value, int max) => new((value ?? "").Where(c => !char.IsControl(c)).Take(max).ToArray());
        var item = new MailItem(uid, Bound(sender?.Name, 200), Bound(sender?.Address, 320), Bound(message.Subject, 500), message.Date, !(summary.Flags ?? MessageFlags.None).HasFlag(MessageFlags.Seen));
        await client.DisconnectAsync(true, ct);
        return new("completed", account.Nickname, validity, item, body.Text, body.Truncated, "imap");
    }
    private sealed class DownloadLimit : ITransferProgress
    {
        public void Report(long bytesTransferred, long totalSize) => Report(bytesTransferred);
        public void Report(long bytesTransferred) { if (bytesTransferred > 2_000_000) throw new IOException("Message exceeded the reading limit."); }
    }
    public async Task<int> CountUnreadAsync(MailAccount account, string password, CancellationToken ct)
    {
        using var client = new ImapClient { Timeout = 20000 };
        await client.ConnectAsync(account.Host, account.Port, SecureSocketOptions.SslOnConnect, ct);
        await client.AuthenticateAsync(account.Address, password, ct);
        await client.Inbox.OpenAsync(FolderAccess.ReadOnly, ct);
        int count = (await client.Inbox.SearchAsync(SearchQuery.NotSeen, ct)).Count;
        await client.DisconnectAsync(true, ct);
        return count;
    }
    public async Task<MailSnapshot> ReadAsync(MailAccount account, string password, string mode, uint validity, uint cursor, int limit, CancellationToken ct)
    {
        using var client = new ImapClient { Timeout = 20000 };
        await client.ConnectAsync(account.Host, account.Port, SecureSocketOptions.SslOnConnect, ct);
        await client.AuthenticateAsync(account.Address, password, ct);
        var inbox = client.Inbox;
        await inbox.OpenAsync(FolderAccess.ReadOnly, ct);
        bool continuation = mode == "since_last_check" && validity == inbox.UidValidity && validity != 0;
        var query = mode == "unread" ? SearchQuery.NotSeen : SearchQuery.All;
        if (continuation)
        {
            if (cursor == uint.MaxValue) return new(inbox.UidValidity, []);
            query = query.And(SearchQuery.Uids(new UniqueIdRange(new UniqueId(cursor + 1), UniqueId.MaxValue)));
        }
        var ids = await inbox.SearchAsync(query, ct);
        // One extra item indicates more data. First checks intentionally seed the
        // latest bounded inbox window; later checks drain arrivals oldest first.
        var selected = (continuation ? ids.OrderBy(id => id.Id) : ids.OrderByDescending(id => id.Id)).Take(limit + 1).ToArray();
        if (selected.Length == 0) return new(inbox.UidValidity, []);
        var summaries = await inbox.FetchAsync(selected, MessageSummaryItems.UniqueId | MessageSummaryItems.Envelope | MessageSummaryItems.Flags, ct);
        string Bound(string? value, int max) => new((value ?? "").Where(c => !char.IsControl(c)).Take(max).ToArray());
        var items = summaries.Select(s => {
            var envelope = s.Envelope ?? throw new InvalidDataException("IMAP server omitted a requested envelope.");
            var sender = envelope.From?.Mailboxes.FirstOrDefault();
            return new MailItem(s.UniqueId.Id, Bound(sender?.Name, 200), Bound(sender?.Address, 320), Bound(envelope.Subject, 500), envelope.Date, !(s.Flags ?? MessageFlags.None).HasFlag(MessageFlags.Seen));
        });
        await client.DisconnectAsync(true, ct);
        return new(inbox.UidValidity, (continuation ? items.OrderBy(i => i.Uid) : items.OrderByDescending(i => i.Uid)).ToArray());
    }
}