using System.Net.Sockets; using AIStudio.Settings.DataModel; using AIStudio.Tools.Databases.IndexStore; using MailKit; using MailKit.Net.Imap; using MailKit.Search; using MailKit.Security; using MimeKit; namespace AIStudio.Tools.Mail; /// /// A connection to the IMAP server of a mailbox: connects, signs in, and works with the folders. /// /// /// Every sign-in is exactly one attempt with the password. An IMAP client would otherwise try one /// mechanism after the other and the LOGIN command last, and a directory such as Active Directory /// counts each rejected attempt on its way to locking the account. For the same reason, nothing is /// sent at all when the password is empty or a setting is unknown to this version.

/// Before signing in, the caller has to make sure that no failed sign-in is on record for this /// mailbox. Whatever fails arrives as MailboxConnectionException with its failure code, and nothing /// here writes to the log: the answers of a server may name the user, so the caller logs the /// mailbox ID and the failure code instead. ///
public sealed class ImapMailboxConnector : IAsyncDisposable { /// /// The longest folder name accepted when creating a folder. /// public const int MAX_FOLDER_NAME_LENGTH = 200; /// /// The largest text part which is fetched, in bytes. Nobody writes a mail that long; a part this /// large is a generated report or a newsletter gone wrong, and its mail is indexed by its header /// block alone. /// public const long MAX_TEXT_PART_BYTES = 4 * 1024 * 1024; /// /// How many UIDs go into one FETCH. A long list of scattered UIDs makes a long command line, and /// some servers refuse lines beyond a few kilobytes. /// private const int MAX_UIDS_PER_FETCH = 500; private const string PLAIN_MECHANISM = "PLAIN"; /// /// The IMAP section of the text of a mail, i.e., everything below its header. /// private const string TEXT_SECTION = "TEXT"; /// /// Characters IMAP reserves as wildcards in LIST, which therefore make no folder name. /// private static readonly char[] LIST_WILDCARDS = ['*', '%']; /// /// How long a single command may take until the connection counts as lost. /// private static readonly TimeSpan COMMAND_TIMEOUT = TimeSpan.FromMinutes(1); /// /// How long saying goodbye to the server may take. The connection is closed either way. /// private static readonly TimeSpan DISCONNECT_TIMEOUT = TimeSpan.FromSeconds(5); private readonly ImapClient client = new() { Timeout = (int)COMMAND_TIMEOUT.TotalMilliseconds, // // Revocation lists are not checked, as with the HTTP connections of AI Studio. Otherwise a // server with an internal CA would fail as soon as the list of that CA is out of reach, // which is common inside a Flatpak and outside the company network. // CheckCertificateRevocation = false, }; /// /// The folder the reading methods work in, cf. OpenFolderAsync. /// private IMailFolder? openFolder; /// /// Whether the server tells which mails changed since a point it handed out before (CONDSTORE). /// Without it, the flags of every mail have to be fetched to find the changed ones. /// public bool SupportsChangeTracking => this.client.Capabilities.HasFlag(ImapCapabilities.CondStore); /// /// Connects to the IMAP server of the mailbox and signs in. /// /// The mailbox to connect to. /// The password, as stored in the OS keyring or just typed in. /// Which mail servers the organization allows. Every connection passes here, so this is where a server it does not allow is refused. /// The cancellation token. /// The connection or the sign-in failed. public async Task ConnectAsync(DataSourceMailbox mailbox, string password, MailServerPolicy policy, CancellationToken token) { if (!TryGetSocketOptions(mailbox.TransportSecurity, out var socketOptions) || mailbox.AuthMethod is not MailboxAuthMethod.PASSWORD || !MailServerHosts.TryGetIdnHost(mailbox.Host, out var idnHost) || mailbox.Port is < 1 or > 65535 || string.IsNullOrWhiteSpace(mailbox.Username) || string.IsNullOrEmpty(password)) throw new MailboxConnectionException(MailboxConnectionFailure.INVALID_SETTINGS, "The mailbox settings are incomplete, or this version of AI Studio does not know them."); if (!policy.IsAllowed(mailbox.Host)) throw new MailboxConnectionException(MailboxConnectionFailure.SERVER_NOT_ALLOWED, "The organization allows only its own mail servers, and the server of this mailbox is none of them."); this.client.ServerCertificateValidationCallback = ExternalHttpClientTimeout.CreateServerCertificateValidationCallback(idnHost, ExternalHttpTrustPolicy.ALLOW_CUSTOM_ROOTS_WHEN_HOST_WHITELISTED); try { await this.client.ConnectAsync(idnHost, mailbox.Port, socketOptions, token); } catch (NotSupportedException e) { // Thrown when the server does not offer STARTTLS. The connection never goes on without it: throw new MailboxConnectionException(MailboxConnectionFailure.TLS_FAILED, "The IMAP server does not offer STARTTLS.", e); } catch (Exception e) when (Classify(e, token) is { } failure) { throw new MailboxConnectionException(failure, $"Connecting to the IMAP server failed: {failure}.", e); } try { await this.SignInAsync(mailbox.Username, password, token); } catch (Exception e) when (Classify(e, token) is { } failure) { throw new MailboxConnectionException(failure, $"Signing in to the IMAP server failed: {failure}.", e); } } /// /// Lists all folders of the mailbox, ordered by their full names. /// /// The server could not list the folders. public async Task> GetFoldersAsync(CancellationToken token) { try { var folders = new Dictionary(StringComparer.Ordinal); foreach (var folderNamespace in this.client.PersonalNamespaces) { foreach (var folder in await this.client.GetFoldersAsync(folderNamespace, StatusItems.None, false, token)) { if (!folder.Attributes.HasFlag(FolderAttributes.NonExistent)) folders.TryAdd(folder.FullName, ToServerFolder(folder)); } } // Servers whose personal namespace starts below the inbox, e.g. "INBOX.", do not list it: if (this.client.Inbox is { } inbox) folders.TryAdd(inbox.FullName, ToServerFolder(inbox)); return folders.Values.OrderBy(folder => folder.FullName, StringComparer.Ordinal).ToList(); } catch (Exception e) when (Classify(e, token) is { } failure) { throw new MailboxConnectionException(failure, $"Listing the folders failed: {failure}.", e); } } /// /// Creates a folder for mails. /// /// The full name of the folder to create it in, or empty for the top level. /// The name of the new folder, cf. IsValidFolderName. /// The cancellation token. /// The folder the server created. /// The name is no valid folder name. /// The server did not create the folder, e.g., because it exists already. public async Task CreateFolderAsync(string parentFullName, string name, CancellationToken token) { try { var parent = string.IsNullOrEmpty(parentFullName) ? this.client.GetFolder(this.client.PersonalNamespaces[0]) : await this.client.GetFolderAsync(parentFullName, token); var folderName = name.Trim(); if (!IsValidFolderName(folderName, parent.DirectorySeparator)) throw new ArgumentException("The folder name is empty, too long, or contains a character the server reserves.", nameof(name)); var createdFolder = await parent.CreateAsync(folderName, true, token); if (createdFolder is null) throw new MailboxConnectionException(MailboxConnectionFailure.SERVER_ERROR, "The IMAP server did not report the folder it created."); return ToServerFolder(createdFolder); } catch (Exception e) when (Classify(e, token) is { } failure) { throw new MailboxConnectionException(failure, $"Creating the folder failed: {failure}.", e); } } /// /// Opens a folder for the reading methods below, and reads how it stands. /// /// /// The folder is opened read-only (EXAMINE), so nothing fetched from it can mark a mail as read, /// whatever the server makes of the request. The unread count comes from STATUS, which is asked /// before the folder is opened: a server need not answer STATUS for the folder which is open. /// /// The full path of the folder. /// The cancellation token. /// How the folder stands. /// The server could not open the folder. public async Task OpenFolderAsync(string folderPath, CancellationToken token) { try { var folder = await this.client.GetFolderAsync(folderPath, token); await folder.StatusAsync(StatusItems.Unread, token); var unseenCount = folder.Unread; await folder.OpenAsync(FolderAccess.ReadOnly, token); this.openFolder = folder; var highestModSeq = this.SupportsChangeTracking && folder.HighestModSeq > 0 ? (long)folder.HighestModSeq : (long?)null; return new(folder.UidValidity, folder.UidNext?.Id, highestModSeq, folder.Count, unseenCount); } catch (Exception e) when (Classify(e, token) is { } failure) { throw new MailboxConnectionException(failure, $"Opening a folder failed: {failure}.", e); } } /// /// Finds the mails of the open folder which belong into the index. /// /// /// Those which arrived since the given day, and every flagged one, however old. The day counts /// as a whole, in the time zone of the server, since that is how IMAP compares dates. /// /// The first day of the period, or null for all mails. /// The cancellation token. /// Their UIDs, in ascending order. /// The server could not search the folder. public async Task> SearchIndexedMailsAsync(DateTimeOffset? receivedSince, CancellationToken token) { var folder = this.GetOpenFolder(); var query = receivedSince is { } since ? SearchQuery.DeliveredAfter(since.UtcDateTime.Date).Or(SearchQuery.Flagged) : SearchQuery.All; try { var uids = await folder.SearchAsync(query, token); return uids.Select(uid => (long)uid.Id).Order().ToList(); } catch (Exception e) when (Classify(e, token) is { } failure) { throw new MailboxConnectionException(failure, $"Searching a folder failed: {failure}.", e); } } /// /// Reads the flags of mails in the open folder. /// /// The UIDs to ask about. /// With CONDSTORE, only mails changed after this HIGHESTMODSEQ are reported. Null asks about all of them. /// The cancellation token. /// The flags by UID. A mail which is gone, or did not change, is missing. /// The server could not read the flags. public async Task> FetchFlagsAsync(IReadOnlyCollection uids, long? changedSinceModSeq, CancellationToken token) { var request = new FetchRequest(MessageSummaryItems.UniqueId | MessageSummaryItems.Flags); if (changedSinceModSeq is { } modSeq && this.SupportsChangeTracking) request.ChangedSince = (ulong)modSeq; var flagsByUid = new Dictionary(); foreach (var summary in await this.FetchAsync(uids, request, token)) flagsByUid[summary.UniqueId.Id] = MailSummaryReader.ReadFlags(summary.Flags); return flagsByUid; } /// /// Reads what the sync needs to know about mails in the open folder before it fetches any text. /// /// /// That is the complete header block, the structure, the size, the arrival time and the flags, /// and the ids by which the server tells a mail apart in every folder, wherever it knows them: /// the EMAILID with OBJECTID, the X-GM-MSGID with Gmail. Neither is asked for elsewhere, since a /// server answers an item it does not know with an error. /// /// The UIDs of the mails. /// The cancellation token. /// One summary per mail which still exists. /// The server could not read the mails. public async Task> FetchSummariesAsync(IReadOnlyCollection uids, CancellationToken token) { var items = MessageSummaryItems.UniqueId | MessageSummaryItems.Flags | MessageSummaryItems.InternalDate | MessageSummaryItems.Size | MessageSummaryItems.BodyStructure | MessageSummaryItems.Headers; if (this.client.Capabilities.HasFlag(ImapCapabilities.ObjectID)) items |= MessageSummaryItems.EmailId; if (this.client.Capabilities.HasFlag(ImapCapabilities.GMailExt1)) items |= MessageSummaryItems.GMailMessageId; return await this.FetchAsync(uids, new FetchRequest(items), token); } /// /// Fetches the text parts of a mail in the open folder, and nothing else of it. /// /// /// Attachments stay on the server, cf. FetchAttachmentAsync. A part larger than /// MAX_TEXT_PART_BYTES is left out as well. /// /// The summary of the mail, with its structure. /// The cancellation token. /// The HTML and the plain text part, each null when the mail has none. /// The server could not deliver the parts. public async Task FetchTextPartsAsync(IMessageSummary summary, CancellationToken token) { try { var htmlBody = await this.FetchTextPartAsync(summary.UniqueId, summary.HtmlBody, token); var textBody = await this.FetchTextPartAsync(summary.UniqueId, summary.TextBody, token); return new(htmlBody, textBody); } catch (Exception e) when (Classify(e, token) is { } failure) { throw new MailboxConnectionException(failure, $"Fetching the text of a mail failed: {failure}.", e); } } /// /// Fetches one attachment of a mail in the open folder, and writes it out as the file it was. /// /// /// The attachment arrives in pieces, and each is written out as soon as it is there, cf. /// MailAttachmentPieces. So even a large attachment takes little memory. Whether it is wanted /// at all is checked beforehand, cf. MailAttachmentRules. Only fetching it counts as a failure of /// the connection. Decoding or writing it out is a failure of this one attachment, e.g. a full disk. /// /// The UID of the mail. /// The attachment, from the structure of the mail. /// Where the decoded attachment is written to. It is left open. /// The cancellation token. /// The server could not deliver the attachment. /// The attachment cannot be decoded, or the server delivered more than it was asked for. public Task FetchAttachmentAsync(UniqueId uid, BodyPartBasic part, Stream destination, CancellationToken token) { var folder = this.GetOpenFolder(); // A mail which consists of this one part has it as its text, cf. RFC 3501, section 6.4.5: var section = part.PartSpecifier.Length > 0 ? part.PartSpecifier : TEXT_SECTION; return MailAttachmentPieces.WriteDecodedAsync((offset, pieceToken) => FetchAttachmentPieceAsync(folder, uid, section, offset, pieceToken), part.ContentTransferEncoding, destination, MailAttachmentPieces.PIECE_BYTES, token); } /// /// Whether a name can become a folder below a parent with this hierarchy delimiter. /// /// The name, without leading or trailing whitespace. /// The hierarchy delimiter of the server, or the null character when it has none. public static bool IsValidFolderName(string name, char directorySeparator) { if (string.IsNullOrWhiteSpace(name) || name.Length > MAX_FOLDER_NAME_LENGTH || name != name.Trim()) return false; if (name.Any(char.IsControl) || name.IndexOfAny(LIST_WILDCARDS) >= 0) return false; return directorySeparator is '\0' || !name.Contains(directorySeparator); } /// /// Which failure an exception of the IMAP client stands for. /// /// The failure, or null when the exception is none of the connection, e.g., a cancellation by the user. internal static MailboxConnectionFailure? Classify(Exception exception, CancellationToken token) => exception switch { OperationCanceledException when token.IsCancellationRequested => null, AuthenticationException or SaslException => MailboxConnectionFailure.AUTHENTICATION_FAILED, SslHandshakeException => MailboxConnectionFailure.TLS_FAILED, // A timeout may surface as any of these, a cancellation the user never asked for included: SocketException or IOException or TimeoutException or OperationCanceledException => MailboxConnectionFailure.NETWORK_UNAVAILABLE, ProtocolException or CommandException or FolderNotFoundException => MailboxConnectionFailure.SERVER_ERROR, _ => null, }; /// /// What a folder is for, from the attributes the server lists it with. /// /// /// A folder may carry more than one of them. The trash and the junk folder come first, since the /// synchronization leaves them out, and then the folders AI Studio writes to. /// internal static MailFolderSpecialUse ToSpecialUse(FolderAttributes attributes) { if (attributes.HasFlag(FolderAttributes.Trash)) return MailFolderSpecialUse.TRASH; if (attributes.HasFlag(FolderAttributes.Junk)) return MailFolderSpecialUse.JUNK; if (attributes.HasFlag(FolderAttributes.Sent)) return MailFolderSpecialUse.SENT; if (attributes.HasFlag(FolderAttributes.Drafts)) return MailFolderSpecialUse.DRAFTS; if (attributes.HasFlag(FolderAttributes.All)) return MailFolderSpecialUse.ALL; if (attributes.HasFlag(FolderAttributes.Archive)) return MailFolderSpecialUse.ARCHIVE; if (attributes.HasFlag(FolderAttributes.Flagged)) return MailFolderSpecialUse.FLAGGED; if (attributes.HasFlag(FolderAttributes.Important)) return MailFolderSpecialUse.IMPORTANT; return MailFolderSpecialUse.NONE; } private IMailFolder GetOpenFolder() => this.openFolder is { IsOpen: true } folder ? folder : throw new InvalidOperationException("No folder is open. Call OpenFolderAsync first."); /// /// Fetches from the open folder, a limited number of UIDs per command. /// private async Task> FetchAsync(IReadOnlyCollection uids, IFetchRequest request, CancellationToken token) { var folder = this.GetOpenFolder(); var summaries = new List(uids.Count); try { foreach (var batch in uids.Order().Chunk(MAX_UIDS_PER_FETCH)) { var uidSet = new UniqueIdSet(batch.Select(uid => new UniqueId((uint)uid)), SortOrder.Ascending); summaries.AddRange(await folder.FetchAsync(uidSet, request, token)); } } catch (Exception e) when (Classify(e, token) is { } failure) { throw new MailboxConnectionException(failure, $"Fetching mails failed: {failure}.", e); } return summaries; } private static async Task FetchAttachmentPieceAsync(IMailFolder folder, UniqueId uid, string section, int offset, CancellationToken token) { try { return await folder.GetStreamAsync(uid, section, offset, MailAttachmentPieces.PIECE_BYTES, token); } catch (Exception e) when (Classify(e, token) is { } failure) { throw new MailboxConnectionException(failure, $"Fetching an attachment failed: {failure}.", e); } } private async Task FetchTextPartAsync(UniqueId uid, BodyPartText? part, CancellationToken token) { if (part is null || part.Octets > MAX_TEXT_PART_BYTES) return null; var entity = await this.GetOpenFolder().GetBodyPartAsync(uid, part, token); return entity is TextPart textPart ? textPart.Text : null; } private async Task SignInAsync(string username, string password, CancellationToken token) { if (this.client.AuthenticationMechanisms.Contains(PLAIN_MECHANISM)) { await this.client.AuthenticateAsync(new SaslMechanismPlain(username, password), token); return; } if (this.client.Capabilities.HasFlag(ImapCapabilities.LoginDisabled)) throw new MailboxConnectionException(MailboxConnectionFailure.SERVER_ERROR, "The IMAP server offers no way to sign in with a password."); // // With no mechanism left to try, the client signs in with the LOGIN command alone: // this.client.AuthenticationMechanisms.Clear(); await this.client.AuthenticateAsync(username, password, token); } private static bool TryGetSocketOptions(MailboxTransportSecurity transportSecurity, out SecureSocketOptions socketOptions) { // // Never StartTlsWhenAvailable or Auto: both carry on without encryption when the server // does not offer it, and the password would travel in plain text. // socketOptions = transportSecurity switch { MailboxTransportSecurity.SSL_ON_CONNECT => SecureSocketOptions.SslOnConnect, MailboxTransportSecurity.STARTTLS => SecureSocketOptions.StartTls, _ => SecureSocketOptions.None, }; return socketOptions is not SecureSocketOptions.None; } private static MailServerFolder ToServerFolder(IMailFolder folder) => new( folder.FullName, folder.Name, folder.ParentFolder?.FullName ?? string.Empty, folder.DirectorySeparator, ToSpecialUse(folder.Attributes), folder.Attributes.HasFlag(FolderAttributes.Inbox) || folder.FullName.Equals("INBOX", StringComparison.OrdinalIgnoreCase), !folder.Attributes.HasFlag(FolderAttributes.NoSelect)); #region Implementation of IAsyncDisposable public async ValueTask DisposeAsync() { if (this.client.IsConnected) { using var timeout = new CancellationTokenSource(DISCONNECT_TIMEOUT); try { await this.client.DisconnectAsync(true, timeout.Token); } catch (Exception e) when (Classify(e, CancellationToken.None) is not null) { // The connection closes either way, and nothing waits for the goodbye. } } this.client.Dispose(); } #endregion }