mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-10-18 22:38:00 +00:00
Added mailboxes as local data sources (#1022)
Build and Release / Determine run mode (push) Waiting to run
Build and Release / Read metadata (push) Blocked by required conditions
Build and Release / Sync Flatpak repo (push) Blocked by required conditions
Build and Release / Collect Flatpak artifacts (push) Blocked by required conditions
Build and Release / Verify (push) Waiting to run
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions
Build and Release / Determine run mode (push) Waiting to run
Build and Release / Read metadata (push) Blocked by required conditions
Build and Release / Sync Flatpak repo (push) Blocked by required conditions
Build and Release / Collect Flatpak artifacts (push) Blocked by required conditions
Build and Release / Verify (push) Waiting to run
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions
This commit is contained in:
1 parent
93be539e50
commit
c4400c0ff6
343 files changed
+26051
-3122
No files matched your search
@@ -0,0 +1,538 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// A connection to the IMAP server of a mailbox: connects, signs in, and works with the folders.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.<br/><br/>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public sealed class ImapMailboxConnector : IAsyncDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// The longest folder name accepted when creating a folder.
|
||||
/// </summary>
|
||||
public const int MAX_FOLDER_NAME_LENGTH = 200;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public const long MAX_TEXT_PART_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private const int MAX_UIDS_PER_FETCH = 500;
|
||||
|
||||
private const string PLAIN_MECHANISM = "PLAIN";
|
||||
|
||||
/// <summary>
|
||||
/// The IMAP section of the text of a mail, i.e., everything below its header.
|
||||
/// </summary>
|
||||
private const string TEXT_SECTION = "TEXT";
|
||||
|
||||
/// <summary>
|
||||
/// Characters IMAP reserves as wildcards in LIST, which therefore make no folder name.
|
||||
/// </summary>
|
||||
private static readonly char[] LIST_WILDCARDS = ['*', '%'];
|
||||
|
||||
/// <summary>
|
||||
/// How long a single command may take until the connection counts as lost.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan COMMAND_TIMEOUT = TimeSpan.FromMinutes(1);
|
||||
|
||||
/// <summary>
|
||||
/// How long saying goodbye to the server may take. The connection is closed either way.
|
||||
/// </summary>
|
||||
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,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The folder the reading methods work in, cf. OpenFolderAsync.
|
||||
/// </summary>
|
||||
private IMailFolder? openFolder;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public bool SupportsChangeTracking => this.client.Capabilities.HasFlag(ImapCapabilities.CondStore);
|
||||
|
||||
/// <summary>
|
||||
/// Connects to the IMAP server of the mailbox and signs in.
|
||||
/// </summary>
|
||||
/// <param name="mailbox">The mailbox to connect to.</param>
|
||||
/// <param name="password">The password, as stored in the OS keyring or just typed in.</param>
|
||||
/// <param name="policy">Which mail servers the organization allows. Every connection passes here, so this is where a server it does not allow is refused.</param>
|
||||
/// <param name="token">The cancellation token.</param>
|
||||
/// <exception cref="MailboxConnectionException">The connection or the sign-in failed.</exception>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lists all folders of the mailbox, ordered by their full names.
|
||||
/// </summary>
|
||||
/// <exception cref="MailboxConnectionException">The server could not list the folders.</exception>
|
||||
public async Task<IReadOnlyList<MailServerFolder>> GetFoldersAsync(CancellationToken token)
|
||||
{
|
||||
try
|
||||
{
|
||||
var folders = new Dictionary<string, MailServerFolder>(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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a folder for mails.
|
||||
/// </summary>
|
||||
/// <param name="parentFullName">The full name of the folder to create it in, or empty for the top level.</param>
|
||||
/// <param name="name">The name of the new folder, cf. IsValidFolderName.</param>
|
||||
/// <param name="token">The cancellation token.</param>
|
||||
/// <returns>The folder the server created.</returns>
|
||||
/// <exception cref="ArgumentException">The name is no valid folder name.</exception>
|
||||
/// <exception cref="MailboxConnectionException">The server did not create the folder, e.g., because it exists already.</exception>
|
||||
public async Task<MailServerFolder> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens a folder for the reading methods below, and reads how it stands.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
/// <param name="folderPath">The full path of the folder.</param>
|
||||
/// <param name="token">The cancellation token.</param>
|
||||
/// <returns>How the folder stands.</returns>
|
||||
/// <exception cref="MailboxConnectionException">The server could not open the folder.</exception>
|
||||
public async Task<MailFolderState> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the mails of the open folder which belong into the index.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
/// <param name="receivedSince">The first day of the period, or null for all mails.</param>
|
||||
/// <param name="token">The cancellation token.</param>
|
||||
/// <returns>Their UIDs, in ascending order.</returns>
|
||||
/// <exception cref="MailboxConnectionException">The server could not search the folder.</exception>
|
||||
public async Task<IReadOnlyList<long>> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the flags of mails in the open folder.
|
||||
/// </summary>
|
||||
/// <param name="uids">The UIDs to ask about.</param>
|
||||
/// <param name="changedSinceModSeq">With CONDSTORE, only mails changed after this HIGHESTMODSEQ are reported. Null asks about all of them.</param>
|
||||
/// <param name="token">The cancellation token.</param>
|
||||
/// <returns>The flags by UID. A mail which is gone, or did not change, is missing.</returns>
|
||||
/// <exception cref="MailboxConnectionException">The server could not read the flags.</exception>
|
||||
public async Task<IReadOnlyDictionary<long, MailFlags>> FetchFlagsAsync(IReadOnlyCollection<long> 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<long, MailFlags>();
|
||||
foreach (var summary in await this.FetchAsync(uids, request, token))
|
||||
flagsByUid[summary.UniqueId.Id] = MailSummaryReader.ReadFlags(summary.Flags);
|
||||
|
||||
return flagsByUid;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads what the sync needs to know about mails in the open folder before it fetches any text.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
/// <param name="uids">The UIDs of the mails.</param>
|
||||
/// <param name="token">The cancellation token.</param>
|
||||
/// <returns>One summary per mail which still exists.</returns>
|
||||
/// <exception cref="MailboxConnectionException">The server could not read the mails.</exception>
|
||||
public async Task<IReadOnlyList<IMessageSummary>> FetchSummariesAsync(IReadOnlyCollection<long> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the text parts of a mail in the open folder, and nothing else of it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Attachments stay on the server, cf. FetchAttachmentAsync. A part larger than
|
||||
/// MAX_TEXT_PART_BYTES is left out as well.
|
||||
/// </remarks>
|
||||
/// <param name="summary">The summary of the mail, with its structure.</param>
|
||||
/// <param name="token">The cancellation token.</param>
|
||||
/// <returns>The HTML and the plain text part, each null when the mail has none.</returns>
|
||||
/// <exception cref="MailboxConnectionException">The server could not deliver the parts.</exception>
|
||||
public async Task<MailTextParts> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches one attachment of a mail in the open folder, and writes it out as the file it was.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
/// <param name="uid">The UID of the mail.</param>
|
||||
/// <param name="part">The attachment, from the structure of the mail.</param>
|
||||
/// <param name="destination">Where the decoded attachment is written to. It is left open.</param>
|
||||
/// <param name="token">The cancellation token.</param>
|
||||
/// <exception cref="MailboxConnectionException">The server could not deliver the attachment.</exception>
|
||||
/// <exception cref="InvalidDataException">The attachment cannot be decoded, or the server delivered more than it was asked for.</exception>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether a name can become a folder below a parent with this hierarchy delimiter.
|
||||
/// </summary>
|
||||
/// <param name="name">The name, without leading or trailing whitespace.</param>
|
||||
/// <param name="directorySeparator">The hierarchy delimiter of the server, or the null character when it has none.</param>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Which failure an exception of the IMAP client stands for.
|
||||
/// </summary>
|
||||
/// <returns>The failure, or null when the exception is none of the connection, e.g., a cancellation by the user.</returns>
|
||||
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,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// What a folder is for, from the attributes the server lists it with.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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.");
|
||||
|
||||
/// <summary>
|
||||
/// Fetches from the open folder, a limited number of UIDs per command.
|
||||
/// </summary>
|
||||
private async Task<IReadOnlyList<IMessageSummary>> FetchAsync(IReadOnlyCollection<long> uids, IFetchRequest request, CancellationToken token)
|
||||
{
|
||||
var folder = this.GetOpenFolder();
|
||||
var summaries = new List<IMessageSummary>(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<Stream> 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<string?> 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
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace AIStudio.Tools.Mail;
|
||||
|
||||
/// <summary>
|
||||
/// The header an address of a mail comes from.
|
||||
/// </summary>
|
||||
public enum MailAddressRole
|
||||
{
|
||||
/// <summary>
|
||||
/// A role this version does not know, e.g. stored by a newer version.
|
||||
/// </summary>
|
||||
UNKNOWN,
|
||||
|
||||
FROM,
|
||||
|
||||
/// <summary>
|
||||
/// Who actually sent the mail when that is somebody else than the author, e.g. an assistant or a mailing list.
|
||||
/// </summary>
|
||||
SENDER,
|
||||
|
||||
REPLY_TO,
|
||||
TO,
|
||||
CC,
|
||||
|
||||
/// <summary>
|
||||
/// Only known for mails the user sent, whose copy keeps the blind copies.
|
||||
/// </summary>
|
||||
BCC,
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using MimeKit;
|
||||
using MimeKit.IO;
|
||||
using MimeKit.IO.Filters;
|
||||
using MimeKit.Utils;
|
||||
|
||||
namespace AIStudio.Tools.Mail;
|
||||
|
||||
/// <summary>
|
||||
/// Writes out an attachment as the file it was, from the pieces a server delivers it in.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// An IMAP client holds every answer of the server in memory as a whole, so an attachment fetched
|
||||
/// at once would take as much memory as it is large. Fetched piece by piece, with each piece decoded
|
||||
/// and written out at once, an attachment takes no more memory than one piece, however large it is.
|
||||
/// This keeps AI Studio usable on machines with little memory, too.
|
||||
/// </remarks>
|
||||
public static class MailAttachmentPieces
|
||||
{
|
||||
/// <summary>
|
||||
/// How large a piece is, as it travels.
|
||||
/// </summary>
|
||||
public const int PIECE_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
/// <summary>
|
||||
/// The largest attachment, as it travels, which can be fetched at all.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The IMAP client addresses a piece by an offset of type int. With Base64, this allows for files
|
||||
/// of about 1.5 GB, whereas mail servers rarely accept a mail a tenth as large.
|
||||
/// </remarks>
|
||||
public const long MAX_OCTETS = int.MaxValue;
|
||||
|
||||
private const int COPY_BUFFER_BYTES = 81_920;
|
||||
|
||||
/// <summary>
|
||||
/// Fetches an attachment piece by piece and writes it out decoded.
|
||||
/// </summary>
|
||||
/// <param name="fetchPieceAsync">Fetches the piece which starts at the given offset and is at most pieceBytes long. A shorter piece is the last one, and a piece beyond the end is empty.</param>
|
||||
/// <param name="transferEncoding">The transfer encoding of the attachment, e.g. "base64", or null when the mail names none.</param>
|
||||
/// <param name="destination">Where the decoded attachment goes. It is left open.</param>
|
||||
/// <param name="pieceBytes">How large a piece is, cf. PIECE_BYTES.</param>
|
||||
/// <param name="token">The cancellation token.</param>
|
||||
/// <exception cref="InvalidDataException">The transfer encoding is unknown, or the server delivered more than it was asked for.</exception>
|
||||
public static async Task WriteDecodedAsync(Func<int, CancellationToken, Task<Stream>> fetchPieceAsync, string? transferEncoding, Stream destination, int pieceBytes, CancellationToken token)
|
||||
{
|
||||
var encoding = GetEncoding(transferEncoding);
|
||||
await using var decoded = new FilteredStream(destination);
|
||||
if (encoding is ContentEncoding.Base64 or ContentEncoding.QuotedPrintable or ContentEncoding.UUEncode)
|
||||
decoded.Add(DecoderFilter.Create(encoding));
|
||||
|
||||
var buffer = new byte[COPY_BUFFER_BYTES];
|
||||
for (var offset = 0L; offset <= MAX_OCTETS; offset += pieceBytes)
|
||||
{
|
||||
var pieceLength = 0L;
|
||||
await using (var piece = await fetchPieceAsync((int)offset, token))
|
||||
{
|
||||
int read;
|
||||
while ((read = await piece.ReadAsync(buffer, token)) > 0)
|
||||
{
|
||||
pieceLength += read;
|
||||
if (pieceLength > pieceBytes)
|
||||
throw new InvalidDataException("The server delivered a piece of an attachment larger than asked for.");
|
||||
|
||||
await decoded.WriteAsync(buffer.AsMemory(0, read), token);
|
||||
}
|
||||
}
|
||||
|
||||
// A piece shorter than asked for is the last one:
|
||||
if (pieceLength < pieceBytes)
|
||||
{
|
||||
await decoded.FlushAsync(token);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidDataException("The attachment is larger than any attachment which can be fetched.");
|
||||
}
|
||||
|
||||
private static ContentEncoding GetEncoding(string? transferEncoding)
|
||||
{
|
||||
// Without a transfer encoding, the content travels as it is, cf. RFC 2045, section 6.1:
|
||||
if (string.IsNullOrWhiteSpace(transferEncoding))
|
||||
return ContentEncoding.Default;
|
||||
|
||||
if (MimeUtils.TryParse(transferEncoding, out ContentEncoding encoding))
|
||||
return encoding;
|
||||
|
||||
throw new InvalidDataException("The attachment has a transfer encoding unknown to AI Studio.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using AIStudio.Settings.DataModel;
|
||||
using AIStudio.Tools.Rust;
|
||||
using AIStudio.Tools.Validation;
|
||||
|
||||
using MailKit;
|
||||
|
||||
using MimeKit;
|
||||
|
||||
namespace AIStudio.Tools.Mail;
|
||||
|
||||
/// <summary>
|
||||
/// Decides which attachments of a mail count as such, and which of them get their text read.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Everything here works on the structure of a mail, so nothing of an attachment is fetched before
|
||||
/// it is clear that its text is wanted. Read are documents alone, the same types a local folder
|
||||
/// indexes. Whatever else a mail carries, e.g. images or archives, is known by its name only.
|
||||
/// </remarks>
|
||||
public static class MailAttachmentRules
|
||||
{
|
||||
private const string BASE64_ENCODING = "base64";
|
||||
|
||||
private const long BYTES_PER_MEGABYTE = 1024L * 1024L;
|
||||
|
||||
/// <summary>
|
||||
/// Whether a part is the signature of a signed mail rather than something the sender attached.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A signed mail carries its signature as a part of its own, which many programs mark as an
|
||||
/// attachment. A reader never sees it as one, since a mail program shows a seal instead, and it
|
||||
/// has no text to read.
|
||||
/// </remarks>
|
||||
/// <param name="contentType">The content type of the part.</param>
|
||||
public static bool IsSignature(ContentType contentType) =>
|
||||
contentType.IsMimeType("application", "pkcs7-signature")
|
||||
|| contentType.IsMimeType("application", "x-pkcs7-signature")
|
||||
|| contentType.IsMimeType("application", "pgp-signature");
|
||||
|
||||
/// <summary>
|
||||
/// Up to which size the text of an attachment is read.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A size beyond what the dialog allows, e.g. from a settings file written by hand, is brought
|
||||
/// back into that range. Attachments are a cost of their own, and no value should open them up
|
||||
/// without bounds.
|
||||
/// </remarks>
|
||||
/// <param name="mailbox">The mailbox.</param>
|
||||
/// <returns>The size in megabytes, or null when the mailbox reads no attachments at all.</returns>
|
||||
public static int? GetMaxSizeMegabytes(DataSourceMailbox mailbox) => mailbox.IndexAttachments
|
||||
? Math.Clamp(mailbox.MaxAttachmentSizeMegabytes, DataSourceValidation.MIN_ATTACHMENT_SIZE_MEGABYTES, DataSourceValidation.MAX_ATTACHMENT_SIZE_MEGABYTES)
|
||||
: null;
|
||||
|
||||
/// <summary>
|
||||
/// The file type of an attachment, from its name or else from its content type.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only ever used after checking it against the known document types, which is what keeps a
|
||||
/// name of the sender's choosing from becoming part of a path.
|
||||
/// </remarks>
|
||||
/// <param name="part">The attachment.</param>
|
||||
/// <returns>The extension in lower case and without a dot, or empty when there is none.</returns>
|
||||
public static string GetExtension(BodyPartBasic part)
|
||||
{
|
||||
var extension = Path.GetExtension(MailTextNormalization.NormalizeHeaderValue(part.FileName)).TrimStart('.');
|
||||
if (extension.Length is 0 && MimeTypes.TryGetExtension(part.ContentType.MimeType, out var contentTypeExtension))
|
||||
extension = contentTypeExtension.TrimStart('.');
|
||||
|
||||
return extension.ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How large an attachment is once it is decoded, as far as the structure of the mail tells.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The server reports the size as the attachment travels, which with Base64 is a third more than
|
||||
/// the file the user sees. The estimate errs a little to the large side, since it counts the line
|
||||
/// breaks of the encoding as well.
|
||||
/// </remarks>
|
||||
/// <param name="part">The attachment.</param>
|
||||
/// <returns>The estimated size in bytes.</returns>
|
||||
public static long EstimateDecodedSize(BodyPartBasic part) => string.Equals(part.ContentTransferEncoding, BASE64_ENCODING, StringComparison.OrdinalIgnoreCase)
|
||||
? part.Octets / 4L * 3L
|
||||
: part.Octets;
|
||||
|
||||
/// <summary>
|
||||
/// Why the text of an attachment is not read.
|
||||
/// </summary>
|
||||
/// <param name="part">The attachment.</param>
|
||||
/// <param name="maxSizeMegabytes">Up to which size attachments are read, or null when none are, cf. GetMaxSizeMegabytes.</param>
|
||||
/// <returns>The reason, or null when the text is to be read.</returns>
|
||||
public static MailPartTextState? GetReasonToSkip(BodyPartBasic part, int? maxSizeMegabytes)
|
||||
{
|
||||
if (maxSizeMegabytes is not { } maxSize)
|
||||
return MailPartTextState.ATTACHMENTS_DISABLED;
|
||||
|
||||
if (!FileTypes.IsAllowedExtension(GetExtension(part), FileTypes.DOCUMENT))
|
||||
return MailPartTextState.UNSUPPORTED_TYPE;
|
||||
|
||||
// Beyond what can be fetched at all, an attachment is too large whatever the limit:
|
||||
if (EstimateDecodedSize(part) > maxSize * BYTES_PER_MEGABYTE || part.Octets > MailAttachmentPieces.MAX_OCTETS)
|
||||
return MailPartTextState.TOO_LARGE;
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace AIStudio.Tools.Mail;
|
||||
|
||||
/// <summary>
|
||||
/// Which part of a mail its text was read from.
|
||||
/// </summary>
|
||||
public enum MailBodySource
|
||||
{
|
||||
/// <summary>
|
||||
/// No part: the mail is encrypted, or it has no text at all.
|
||||
/// </summary>
|
||||
NONE,
|
||||
|
||||
/// <summary>
|
||||
/// The HTML part, which is what a reader of the mail sees.
|
||||
/// </summary>
|
||||
HTML,
|
||||
|
||||
/// <summary>
|
||||
/// The plain text part, because the mail has no HTML part or nothing visible remains of it.
|
||||
/// </summary>
|
||||
PLAIN_TEXT,
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace AIStudio.Tools.Mail;
|
||||
|
||||
/// <summary>
|
||||
/// Gives a mail the key of its document in the index, the same in every folder it lies in.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The key follows the mail rather than its place: a mail moved to another folder, or found again
|
||||
/// after the server reset the UIDs of a folder (UIDVALIDITY), keeps its key, so only its location
|
||||
/// changes and nothing gets embedded again. In the order the server can tell, it comes from:
|
||||
/// <list type="number">
|
||||
/// <item>the EMAILID of RFC 8474, which the server keeps for a mail across all its folders;</item>
|
||||
/// <item>the X-GM-MSGID, which is the same for Gmail;</item>
|
||||
/// <item>a hash over Message-ID, Date, From, Subject and the size, all of which a server reports
|
||||
/// without the mail itself being fetched. Two mails alike in all of these are copies of one mail.</item>
|
||||
/// </list>
|
||||
/// The key is "mail:" and a SHA-256 in hex, whichever of the three it comes from. The value it
|
||||
/// hashes names its origin, so an EMAILID which happens to read like a Gmail id cannot meet one.
|
||||
/// The key goes through no Path API, see "Indexed data sources" in AGENTS.md.
|
||||
/// </remarks>
|
||||
public static class MailContentKey
|
||||
{
|
||||
public const string PREFIX = "mail:";
|
||||
|
||||
/// <summary>
|
||||
/// Builds the key of a mail.
|
||||
/// </summary>
|
||||
/// <param name="identity">What the server reported about the mail.</param>
|
||||
/// <returns>The key, starting with "mail:".</returns>
|
||||
public static string Create(MailIdentity identity)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(identity.EmailId))
|
||||
return Hash($"emailid\n{identity.EmailId.Trim()}");
|
||||
|
||||
if (identity.GmailMessageId is { } gmailMessageId)
|
||||
return Hash($"x-gm-msgid\n{gmailMessageId.ToString(CultureInfo.InvariantCulture)}");
|
||||
|
||||
var fields = new StringBuilder("headers");
|
||||
fields.Append('\n').Append(NormalizeMessageId(identity.MessageId));
|
||||
fields.Append('\n').Append(identity.Date?.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture));
|
||||
fields.Append('\n').Append(string.Join(',', identity.FromAddresses.Select(address => address.Trim().ToLowerInvariant())));
|
||||
fields.Append('\n').Append(NormalizeWhitespace(identity.Subject));
|
||||
fields.Append('\n').Append(identity.Size.ToString(CultureInfo.InvariantCulture));
|
||||
return Hash(fields.ToString());
|
||||
}
|
||||
|
||||
private static string Hash(string value) => PREFIX + Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(value)));
|
||||
|
||||
/// <summary>
|
||||
/// The Message-ID without its angle brackets, which one reader keeps and the next one drops.
|
||||
/// </summary>
|
||||
private static string NormalizeMessageId(string? messageId)
|
||||
{
|
||||
var trimmed = messageId?.Trim() ?? string.Empty;
|
||||
return trimmed.Length > 1 && trimmed[0] == '<' && trimmed[^1] == '>' ? trimmed[1..^1].Trim() : trimmed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A subject with every run of whitespace as one space, since folding a long header line
|
||||
/// leaves line breaks behind which not every reader unfolds alike.
|
||||
/// </summary>
|
||||
private static string NormalizeWhitespace(string? text) => string.Join(' ', (text ?? string.Empty).Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries));
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using MailKit;
|
||||
using MimeKit;
|
||||
|
||||
namespace AIStudio.Tools.Mail;
|
||||
|
||||
/// <summary>
|
||||
/// Finds out whether AI Studio can read the content of a mail.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The structure of a mail tells most of it, so the sync asks before it fetches anything: from the
|
||||
/// BODYSTRUCTURE the server describes, the encrypted blob never has to be downloaded. A parsed mail
|
||||
/// gives the same answer through its MIME tree. Only a PGP block within the text needs the text
|
||||
/// itself, see ContainsInlinePgpMessage.
|
||||
///
|
||||
/// An encrypted part counts wherever it sits, apart from mails attached to this one: some gateways
|
||||
/// wrap an encrypted mail together with a disclaimer of their own, and that disclaimer is not what
|
||||
/// the mail says. An attached mail keeps its protection to itself, though, because the mail around
|
||||
/// it is readable.
|
||||
///
|
||||
/// A mail which is merely signed in the clear (multipart/signed) stays readable: its first part is
|
||||
/// the content as it is, and the signature next to it is no encryption.
|
||||
/// </remarks>
|
||||
public static class MailEncryptionDetection
|
||||
{
|
||||
/// <summary>
|
||||
/// The line which opens an encrypted PGP block, from RFC 4880. A signed block opens with
|
||||
/// "-----BEGIN PGP SIGNED MESSAGE-----" instead and stays readable.
|
||||
/// </summary>
|
||||
private const string PGP_MESSAGE_ARMOR = "-----BEGIN PGP MESSAGE-----";
|
||||
|
||||
/// <summary>
|
||||
/// The name Outlook gives the attachment which holds a mail protected by Information Rights Management.
|
||||
/// </summary>
|
||||
private const string IRM_FILE_NAME = "message.rpmsg";
|
||||
|
||||
/// <summary>
|
||||
/// Reads the encryption from the structure the server describes.
|
||||
/// </summary>
|
||||
/// <param name="bodyStructure">The BODYSTRUCTURE of the mail, or null when the server sent none.</param>
|
||||
/// <returns>How the content is encrypted, NONE when it is readable as far as the structure tells.</returns>
|
||||
public static MailEncryptionKind Detect(BodyPart? bodyStructure) => bodyStructure switch
|
||||
{
|
||||
// An attached mail keeps its protection to itself:
|
||||
BodyPartMessage => MailEncryptionKind.NONE,
|
||||
|
||||
BodyPartMultipart multipart => DetectMultipart(multipart.ContentType, multipart.BodyParts.Select(Detect)),
|
||||
BodyPartBasic part => DetectPart(part.ContentType, part.FileName),
|
||||
_ => MailEncryptionKind.NONE,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Reads the encryption from the MIME tree of a parsed mail.
|
||||
/// </summary>
|
||||
/// <param name="body">The body of the mail, or null when only its header block was loaded.</param>
|
||||
/// <returns>How the content is encrypted, NONE when it is readable as far as the structure tells.</returns>
|
||||
public static MailEncryptionKind Detect(MimeEntity? body) => body switch
|
||||
{
|
||||
// An attached mail keeps its protection to itself:
|
||||
MessagePart => MailEncryptionKind.NONE,
|
||||
|
||||
Multipart multipart => DetectMultipart(multipart.ContentType, multipart.Select(Detect)),
|
||||
MimePart part => DetectPart(part.ContentType, part.FileName),
|
||||
_ => MailEncryptionKind.NONE,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Whether the text of a mail holds an encrypted PGP block, which the structure cannot show:
|
||||
/// such a mail is plain text like any other.
|
||||
/// </summary>
|
||||
/// <param name="text">The text of the mail as it was sent, HTML or plain.</param>
|
||||
/// <returns>True when the text holds such a block.</returns>
|
||||
public static bool ContainsInlinePgpMessage(string? text) => text?.Contains(PGP_MESSAGE_ARMOR, StringComparison.Ordinal) is true;
|
||||
|
||||
private static MailEncryptionKind DetectMultipart(ContentType contentType, IEnumerable<MailEncryptionKind> partKinds)
|
||||
{
|
||||
// RFC 1847 knows no other use of multipart/encrypted than PGP/MIME in practice:
|
||||
if (contentType.IsMimeType("multipart", "encrypted"))
|
||||
return MailEncryptionKind.PGP_MIME;
|
||||
|
||||
return partKinds.FirstOrDefault(kind => kind is not MailEncryptionKind.NONE);
|
||||
}
|
||||
|
||||
private static MailEncryptionKind DetectPart(ContentType contentType, string? fileName)
|
||||
{
|
||||
if (contentType.IsMimeType("application", "pkcs7-mime") || contentType.IsMimeType("application", "x-pkcs7-mime"))
|
||||
{
|
||||
//
|
||||
// Signed data wraps the content into the signature, so it cannot be read without
|
||||
// unpacking the envelope. Everything else is encrypted, and so is a part without any
|
||||
// smime-type: Outlook sends enveloped data like that, too.
|
||||
//
|
||||
return contentType.Parameters.TryGetValue("smime-type", out string? smimeType) && "signed-data".Equals(smimeType, StringComparison.OrdinalIgnoreCase)
|
||||
? MailEncryptionKind.SMIME_OPAQUE_SIGNED
|
||||
: MailEncryptionKind.SMIME;
|
||||
}
|
||||
|
||||
// Some programs send the S/MIME envelope as plain bytes, recognizable by its name only:
|
||||
if (fileName?.EndsWith(".p7m", StringComparison.OrdinalIgnoreCase) is true)
|
||||
return MailEncryptionKind.SMIME;
|
||||
|
||||
if (contentType.IsMimeType("application", "x-microsoft-rpmsg-message") || IRM_FILE_NAME.Equals(fileName, StringComparison.OrdinalIgnoreCase))
|
||||
return MailEncryptionKind.MICROSOFT_IRM;
|
||||
|
||||
return MailEncryptionKind.NONE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
namespace AIStudio.Tools.Mail;
|
||||
|
||||
/// <summary>
|
||||
/// How the content of a mail is protected so that AI Studio cannot read it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Of such a mail, only its header block is indexed. A mail which is merely signed in the clear
|
||||
/// (multipart/signed) stays readable and is NONE.
|
||||
/// </remarks>
|
||||
public enum MailEncryptionKind
|
||||
{
|
||||
/// <summary>
|
||||
/// The content is readable.
|
||||
/// </summary>
|
||||
NONE,
|
||||
|
||||
/// <summary>
|
||||
/// A kind this version does not know, e.g. stored by a newer version. Such a mail still counts as encrypted.
|
||||
/// </summary>
|
||||
UNKNOWN,
|
||||
|
||||
/// <summary>
|
||||
/// S/MIME, enveloped data.
|
||||
/// </summary>
|
||||
SMIME,
|
||||
|
||||
/// <summary>
|
||||
/// S/MIME with the signature wrapped around the content, which then cannot be read without the S/MIME envelope.
|
||||
/// </summary>
|
||||
SMIME_OPAQUE_SIGNED,
|
||||
|
||||
/// <summary>
|
||||
/// PGP/MIME, multipart/encrypted.
|
||||
/// </summary>
|
||||
PGP_MIME,
|
||||
|
||||
/// <summary>
|
||||
/// A PGP message block within the text of the mail.
|
||||
/// </summary>
|
||||
PGP_INLINE,
|
||||
|
||||
/// <summary>
|
||||
/// Microsoft Information Rights Management, i.e., a message.rpmsg attachment.
|
||||
/// </summary>
|
||||
MICROSOFT_IRM,
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
namespace AIStudio.Tools.Mail;
|
||||
|
||||
/// <summary>
|
||||
/// The folders of a mailbox the sync works through.
|
||||
/// </summary>
|
||||
/// <param name="Folders">The folders, the inbox first and the others by their full names.</param>
|
||||
/// <param name="RootFolderFound">False when the mailbox is limited to a root folder the server no longer lists, e.g. because somebody renamed it.</param>
|
||||
public sealed record MailFolderSelection(IReadOnlyList<MailServerFolder> Folders, bool RootFolderFound)
|
||||
{
|
||||
/// <summary>
|
||||
/// Picks the folders of a mailbox the sync works through.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Every folder which can hold mails, at or below the root folder when the mailbox has one. The
|
||||
/// trash and the junk folder never count: what lies there was thrown away or never wanted, and
|
||||
/// the root folder cannot be one of them either.
|
||||
///
|
||||
/// The virtual folders for flagged and for important mails count only when they are the root
|
||||
/// folder. Each of them shows mails which lie in another folder as well, so the sync would only
|
||||
/// fetch the same mails twice. The folder holding all mails is different: at Gmail, an archived
|
||||
/// mail lies there and nowhere else.
|
||||
/// </remarks>
|
||||
/// <param name="serverFolders">The folders as the server lists them.</param>
|
||||
/// <param name="rootFolder">The full name of the root folder, or empty for the whole mailbox.</param>
|
||||
/// <returns>The selection.</returns>
|
||||
public static MailFolderSelection Select(IReadOnlyList<MailServerFolder> serverFolders, string rootFolder)
|
||||
{
|
||||
//
|
||||
// Empty, not blank, stands for the whole mailbox: the empty name is what the folder picker
|
||||
// stores for it. A name of nothing but spaces is a folder name like any other. When the server
|
||||
// does not list such a folder, the root folder counts as missing and the sync stops. Reading
|
||||
// it as the whole mailbox instead would quietly widen the sync to everything, at the cost of
|
||||
// embedding all of it.
|
||||
//
|
||||
var hasRootFolder = !string.IsNullOrEmpty(rootFolder);
|
||||
var folders = serverFolders
|
||||
.Where(folder => folder is { CanSelect: true, SpecialUse: not (MailFolderSpecialUse.TRASH or MailFolderSpecialUse.JUNK) })
|
||||
.Where(folder => !hasRootFolder || IsAtOrBelow(folder, rootFolder))
|
||||
.Where(folder => folder.SpecialUse is not (MailFolderSpecialUse.FLAGGED or MailFolderSpecialUse.IMPORTANT) || (hasRootFolder && IsRootFolder(folder, rootFolder)))
|
||||
.OrderByDescending(folder => folder.IsInbox)
|
||||
.ThenBy(folder => folder.FullName, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var rootFolderFound = !hasRootFolder || serverFolders.Any(folder => IsRootFolder(folder, rootFolder));
|
||||
return new(folders, rootFolderFound);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether a folder is the root folder, which IMAP names case-insensitively only for the inbox.
|
||||
/// </summary>
|
||||
private static bool IsRootFolder(MailServerFolder folder, string rootFolder) =>
|
||||
folder.FullName.Equals(rootFolder, folder.IsInbox ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal);
|
||||
|
||||
private static bool IsAtOrBelow(MailServerFolder folder, string rootFolder)
|
||||
{
|
||||
if (IsRootFolder(folder, rootFolder))
|
||||
return true;
|
||||
|
||||
// A server without a hierarchy has no folders below another one:
|
||||
if (folder.DirectorySeparator is '\0')
|
||||
return false;
|
||||
|
||||
//
|
||||
// Only the inbox is matched case-insensitively, and folders below it inherit that, since
|
||||
// the server lists them as "INBOX/…" whatever the user typed:
|
||||
//
|
||||
var comparison = rootFolder.Equals("INBOX", StringComparison.OrdinalIgnoreCase) ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
|
||||
return folder.FullName.StartsWith(rootFolder + folder.DirectorySeparator, comparison);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace AIStudio.Tools.Mail;
|
||||
|
||||
/// <summary>
|
||||
/// What a folder is for, as the server announces it (RFC 6154 and RFC 8457).
|
||||
/// </summary>
|
||||
public enum MailFolderSpecialUse
|
||||
{
|
||||
/// <summary>
|
||||
/// An ordinary folder.
|
||||
/// </summary>
|
||||
NONE,
|
||||
|
||||
/// <summary>
|
||||
/// A special use this version does not know, e.g. stored by a newer version.
|
||||
/// </summary>
|
||||
UNKNOWN,
|
||||
|
||||
/// <summary>
|
||||
/// A virtual folder holding every mail of the mailbox, e.g. "All Mail" at Gmail.
|
||||
/// </summary>
|
||||
ALL,
|
||||
|
||||
ARCHIVE,
|
||||
DRAFTS,
|
||||
|
||||
/// <summary>
|
||||
/// A virtual folder holding every flagged mail.
|
||||
/// </summary>
|
||||
FLAGGED,
|
||||
|
||||
IMPORTANT,
|
||||
JUNK,
|
||||
SENT,
|
||||
TRASH,
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace AIStudio.Tools.Mail;
|
||||
|
||||
/// <summary>
|
||||
/// How a folder stands on the server when the sync opens it.
|
||||
/// </summary>
|
||||
/// <param name="UidValidity">The UIDVALIDITY. When it differs from the stored one, every stored UID of the folder is void.</param>
|
||||
/// <param name="UidNext">The UID the next mail will get, or null when the server does not say.</param>
|
||||
/// <param name="HighestModSeq">The HIGHESTMODSEQ, or null without CONDSTORE.</param>
|
||||
/// <param name="MessageCount">How many mails the folder holds (STATUS MESSAGES).</param>
|
||||
/// <param name="UnseenCount">How many of them are unread (STATUS UNSEEN).</param>
|
||||
public sealed record MailFolderState(long UidValidity, long? UidNext, long? HighestModSeq, long MessageCount, long UnseenCount);
|
||||
@@ -0,0 +1,63 @@
|
||||
using AIStudio.Tools.Databases.IndexStore;
|
||||
|
||||
namespace AIStudio.Tools.Mail;
|
||||
|
||||
/// <summary>
|
||||
/// What a sync pass has to do in one folder, worked out from what the index holds and what the server reports.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The server is asked which mails belong into the index -- those of the period, and every flagged
|
||||
/// one -- and the difference to the index says the rest. A mail which was deleted, moved away, fell
|
||||
/// out of the period or lost its flag drops out alike; that it may lie elsewhere still is found out
|
||||
/// by its key, not here.
|
||||
/// </remarks>
|
||||
/// <param name="UidValidityChanged">Whether the server voided every UID of the folder. The stored locations go, and every mail counts as new: the ones the index holds are linked again by their key, without being embedded again.</param>
|
||||
/// <param name="NewUids">The UIDs the index does not hold for this folder, the newest first.</param>
|
||||
/// <param name="GoneUids">The UIDs the index holds for this folder which no longer belong into it, in ascending order.</param>
|
||||
/// <param name="KeptUids">The UIDs the index holds and keeps, whose flags may have changed.</param>
|
||||
/// <param name="ChecksFlags">Whether the flags of the kept mails have to be fetched at all.</param>
|
||||
/// <param name="FlagsChangedSinceModSeq">Asks only about mails changed since this HIGHESTMODSEQ, or null to ask about every kept mail.</param>
|
||||
public sealed record MailFolderSyncPlan(bool UidValidityChanged, IReadOnlyList<long> NewUids, IReadOnlyList<long> GoneUids, IReadOnlyList<long> KeptUids, bool ChecksFlags, long? FlagsChangedSinceModSeq)
|
||||
{
|
||||
/// <summary>
|
||||
/// Works out the plan for one folder.
|
||||
/// </summary>
|
||||
/// <param name="storedFolder">The folder as the index stores it, or null when the index does not know it yet.</param>
|
||||
/// <param name="storedLocations">The UIDs the index holds for the folder, with their flags.</param>
|
||||
/// <param name="serverState">How the folder stands on the server.</param>
|
||||
/// <param name="indexedUids">The UIDs which belong into the index, as the server found them.</param>
|
||||
/// <returns>The plan.</returns>
|
||||
public static MailFolderSyncPlan Create(MailFolderRecord? storedFolder, IReadOnlyDictionary<long, MailFlags> storedLocations, MailFolderState serverState, IReadOnlyCollection<long> indexedUids)
|
||||
{
|
||||
var uidValidityChanged = storedFolder is not null && storedFolder.UidValidity != serverState.UidValidity;
|
||||
HashSet<long> knownUids = uidValidityChanged ? [] : storedLocations.Keys.ToHashSet();
|
||||
var wantedUids = indexedUids.ToHashSet();
|
||||
|
||||
var newUids = wantedUids.Where(uid => !knownUids.Contains(uid)).OrderDescending().ToList();
|
||||
var goneUids = knownUids.Where(uid => !wantedUids.Contains(uid)).Order().ToList();
|
||||
var keptUids = knownUids.Where(wantedUids.Contains).Order().ToList();
|
||||
|
||||
//
|
||||
// With CONDSTORE, the HIGHESTMODSEQ of the last complete pass says whether any flag changed
|
||||
// since, and if so, the server names the changed mails itself. Without it, or after a pass
|
||||
// which did not complete, only fetching every flag tells.
|
||||
//
|
||||
if (keptUids.Count is 0)
|
||||
return new(uidValidityChanged, newUids, goneUids, keptUids, false, null);
|
||||
|
||||
if (storedFolder?.HighestModSeq is { } storedModSeq && serverState.HighestModSeq is { } currentModSeq)
|
||||
return new(uidValidityChanged, newUids, goneUids, keptUids, currentModSeq != storedModSeq, storedModSeq);
|
||||
|
||||
return new(uidValidityChanged, newUids, goneUids, keptUids, true, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Picks the flags which differ from what the index holds.
|
||||
/// </summary>
|
||||
/// <param name="storedLocations">The UIDs the index holds for the folder, with their flags.</param>
|
||||
/// <param name="fetchedFlags">The flags the server reported.</param>
|
||||
/// <returns>The changed flags by UID. A UID the index does not hold is left out.</returns>
|
||||
public static IReadOnlyDictionary<long, MailFlags> GetChangedFlags(IReadOnlyDictionary<long, MailFlags> storedLocations, IReadOnlyDictionary<long, MailFlags> fetchedFlags) => fetchedFlags
|
||||
.Where(entry => storedLocations.TryGetValue(entry.Key, out var storedFlags) && storedFlags != entry.Value)
|
||||
.ToDictionary(entry => entry.Key, entry => entry.Value);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using MimeKit;
|
||||
using MimeKit.Utils;
|
||||
|
||||
namespace AIStudio.Tools.Mail;
|
||||
|
||||
/// <summary>
|
||||
/// Reads the header fields of a mail which carry addresses, Message-IDs and the date.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// One place for all readers of a header block, the text of a mail as well as what the index
|
||||
/// keeps about it, so that both name the same people and the same date.
|
||||
/// </remarks>
|
||||
public static class MailHeaders
|
||||
{
|
||||
/// <summary>
|
||||
/// Reads the addresses of all headers of one kind, e.g. both To lines of a mail which has two.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Parsed from the raw header rather than from its decoded text: a display name may carry an
|
||||
/// encoded comma, as in "=?utf-8?q?Doe=2C_John?=", which decoded first would split one person
|
||||
/// into two. The members of a group are read as addresses of their own.
|
||||
/// </remarks>
|
||||
/// <param name="headers">The header block.</param>
|
||||
/// <param name="headerId">The header, e.g. To.</param>
|
||||
/// <returns>The addresses in their order, empty when there is no such header or none can be read.</returns>
|
||||
public static IReadOnlyList<MailboxAddress> ReadMailboxes(HeaderList headers, HeaderId headerId)
|
||||
{
|
||||
var mailboxes = new List<MailboxAddress>();
|
||||
foreach (var header in headers.Where(header => header.Id == headerId))
|
||||
{
|
||||
if (InternetAddressList.TryParse(ParserOptions.Default, header.RawValue, out var list))
|
||||
mailboxes.AddRange(list.Mailboxes);
|
||||
}
|
||||
|
||||
return mailboxes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the Message-IDs of a header, without their angle brackets.
|
||||
/// </summary>
|
||||
/// <param name="headers">The header block.</param>
|
||||
/// <param name="headerId">The header, e.g. References.</param>
|
||||
/// <returns>The Message-IDs in their order, empty when there is no such header.</returns>
|
||||
public static IReadOnlyList<string> ReadMessageIds(HeaderList headers, HeaderId headerId)
|
||||
{
|
||||
var value = headers[headerId];
|
||||
return string.IsNullOrWhiteSpace(value) ? [] : MimeUtils.EnumerateReferences(value).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads when the sender says the mail was written.
|
||||
/// </summary>
|
||||
/// <param name="headers">The header block.</param>
|
||||
/// <returns>The Date header, or null when there is none or it cannot be read.</returns>
|
||||
public static DateTimeOffset? ReadDate(HeaderList headers) => DateUtils.TryParse(headers[HeaderId.Date] ?? string.Empty, out var date) ? date : null;
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
|
||||
using AIStudio.Tools.Web;
|
||||
|
||||
using HtmlAgilityPack;
|
||||
|
||||
namespace AIStudio.Tools.Mail;
|
||||
|
||||
/// <summary>
|
||||
/// Turns the HTML part of a mail into the Markdown a reader of that mail would see.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A mail comes from a stranger, and HTML lets the stranger write text nobody sees but every model
|
||||
/// reads. Before the conversion, everything goes which the reader would not see or which does
|
||||
/// something on its own:
|
||||
/// <list type="bullet">
|
||||
/// <item>scripts, styles, comments and embedded objects;</item>
|
||||
/// <item>hidden elements, by the rules web pages are read with (HtmlContentRules) and those only
|
||||
/// a mail needs: a mail runs no scripts, so text with font-size:0 or opacity:0, or squeezed into
|
||||
/// max-height:0 with overflow:hidden, never becomes visible;</item>
|
||||
/// <item>every image, since the user did not ask to load any;</item>
|
||||
/// <item>links which run code or carry data of their own (javascript:, vbscript:, data:), so only
|
||||
/// their text remains.</item>
|
||||
/// </list>
|
||||
/// Tables which only lay out a newsletter turn into plain blocks, see IsLayoutTable. The
|
||||
/// conversion resolves the HTML entities, so the prompt injection filter reads the text the way
|
||||
/// the reader does.
|
||||
/// </remarks>
|
||||
public static class MailHtmlConverter
|
||||
{
|
||||
private static readonly HashSet<string> REMOVED_ELEMENT_NAMES = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"head", "title", "meta", "link", "base", "script", "style", "noscript", "template", "iframe", "frame", "frameset",
|
||||
"object", "embed", "applet", "canvas", "svg", "math", "img", "picture", "video", "audio", "source", "track", "map", "area",
|
||||
"button", "input", "select", "textarea", "colgroup", "col"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The parts of a table. A table within a table is not one of them: it is judged on its own.
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> TABLE_PART_NAMES = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"thead", "tbody", "tfoot", "tr", "td", "th", "caption"
|
||||
};
|
||||
|
||||
private static readonly string[] URL_ATTRIBUTE_NAMES = ["href", "src", "action", "formaction", "background", "poster", "xlink:href"];
|
||||
|
||||
/// <summary>
|
||||
/// Converts the HTML part of a mail.
|
||||
/// </summary>
|
||||
/// <param name="html">The HTML part, already decoded from its transfer encoding and charset.</param>
|
||||
/// <returns>The Markdown, empty when nothing visible remains.</returns>
|
||||
public static string ToMarkdown(string html)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(html))
|
||||
return string.Empty;
|
||||
|
||||
var document = new HtmlDocument();
|
||||
document.LoadHtml(html);
|
||||
|
||||
// HtmlAgilityPack annotates SelectSingleNode as never returning null, which it does without a body:
|
||||
// ReSharper disable once NullCoalescingConditionIsAlwaysNotNullAccordingToAPIContract
|
||||
var root = document.DocumentNode.SelectSingleNode("//body") ?? document.DocumentNode;
|
||||
|
||||
RemoveInvisibleNodes(root);
|
||||
RemoveScriptAndDataUrls(root);
|
||||
FlattenLayoutTables(root);
|
||||
|
||||
string markdown;
|
||||
try
|
||||
{
|
||||
markdown = HTMLParser.ParseToMarkdown(root.InnerHtml);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
//
|
||||
// What the converter fails on depends on the HTML it got, and a mail should not lose its
|
||||
// text over it. Everything hidden is gone from the tree already, so its plain text is
|
||||
// what the reader sees, merely without the formatting:
|
||||
//
|
||||
markdown = WebUtility.HtmlDecode(root.InnerText);
|
||||
}
|
||||
|
||||
return MailTextNormalization.NormalizeBody(markdown);
|
||||
}
|
||||
|
||||
private static void RemoveInvisibleNodes(HtmlNode root)
|
||||
{
|
||||
var invisibleNodes = root.Descendants()
|
||||
.Where(node => node.NodeType is HtmlNodeType.Comment || REMOVED_ELEMENT_NAMES.Contains(node.Name) || HtmlContentRules.IsHiddenByMarkup(node) || IsHiddenInMail(node))
|
||||
.Reverse()
|
||||
.ToList();
|
||||
|
||||
foreach (var node in invisibleNodes)
|
||||
node.Remove();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether an inline style hides an element in a mail, beyond what hides it on a web page as well.
|
||||
/// </summary>
|
||||
private static bool IsHiddenInMail(HtmlNode node)
|
||||
{
|
||||
var style = HtmlContentRules.GetInlineStyle(node);
|
||||
if (style.Count is 0)
|
||||
return false;
|
||||
|
||||
// Outlook's own switch for hiding an element:
|
||||
if (style.GetValueOrDefault("mso-hide") is "all")
|
||||
return true;
|
||||
|
||||
if (IsZeroLength(style.GetValueOrDefault("font-size")) || IsZeroLength(style.GetValueOrDefault("opacity")))
|
||||
return true;
|
||||
|
||||
return style.GetValueOrDefault("overflow") is "hidden" && (IsZeroLength(style.GetValueOrDefault("max-height")) || IsZeroLength(style.GetValueOrDefault("height")));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether a style value is zero, whatever its unit: 0, 0px, 0.0em and 0% all are.
|
||||
/// </summary>
|
||||
private static bool IsZeroLength(string? value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
return false;
|
||||
|
||||
var numberLength = 0;
|
||||
while (numberLength < value.Length && (char.IsAsciiDigit(value[numberLength]) || value[numberLength] is '.' or '+' or '-'))
|
||||
numberLength++;
|
||||
|
||||
return numberLength > 0 && double.TryParse(value.AsSpan(0, numberLength), NumberStyles.Float, CultureInfo.InvariantCulture, out var number) && number == 0;
|
||||
}
|
||||
|
||||
private static void RemoveScriptAndDataUrls(HtmlNode root)
|
||||
{
|
||||
foreach (var node in root.DescendantsAndSelf())
|
||||
{
|
||||
foreach (var attributeName in URL_ATTRIBUTE_NAMES)
|
||||
{
|
||||
if (HtmlContentRules.IsScriptOrDataUrl(node.GetAttributeValue(attributeName, string.Empty)))
|
||||
node.Attributes.Remove(attributeName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Turns the tables which only arrange a newsletter into plain blocks, cell by cell.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Mail programs know little CSS, so newsletters build their whole layout from tables nested in
|
||||
/// tables. Converted as tables, each would become a Markdown table with the rest of the mail in
|
||||
/// its cells, which costs tokens and tells nothing. A table which holds figures stays a table.
|
||||
/// </remarks>
|
||||
private static void FlattenLayoutTables(HtmlNode root)
|
||||
{
|
||||
// Decided for all tables before the first one changes, since a table holding another one is a layout table:
|
||||
var layoutTables = root.Descendants("table").Where(IsLayoutTable).ToList();
|
||||
foreach (var table in layoutTables)
|
||||
{
|
||||
foreach (var node in table.Descendants().Where(node => TABLE_PART_NAMES.Contains(node.Name) && OwningTable(node) == table).ToList())
|
||||
node.Name = "div";
|
||||
|
||||
table.Name = "div";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether a table only arranges the content around it: it says so (role=presentation), it
|
||||
/// holds another table, or none of its rows has more than one cell.
|
||||
/// </summary>
|
||||
private static bool IsLayoutTable(HtmlNode table)
|
||||
{
|
||||
if (table.GetAttributeValue("role", string.Empty).Equals("presentation", StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
|
||||
var ownNodes = table.Descendants().Where(node => OwningTable(node) == table).ToList();
|
||||
if (ownNodes.Any(node => node.Name.Equals("table", StringComparison.OrdinalIgnoreCase)))
|
||||
return true;
|
||||
|
||||
return ownNodes
|
||||
.Where(node => node.Name.Equals("tr", StringComparison.OrdinalIgnoreCase))
|
||||
.All(row => row.ChildNodes.Count(cell => cell.Name is "td" or "th") <= 1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The closest table above a node, i.e., the table a row or cell belongs to.
|
||||
/// </summary>
|
||||
private static HtmlNode? OwningTable(HtmlNode node) => node.Ancestors().FirstOrDefault(ancestor => ancestor.Name.Equals("table", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace AIStudio.Tools.Mail;
|
||||
|
||||
/// <summary>
|
||||
/// What tells one mail apart from all others on a server, as MailContentKey reads it.
|
||||
/// </summary>
|
||||
/// <param name="EmailId">The EMAILID of RFC 8474, or null when the server does not know OBJECTID.</param>
|
||||
/// <param name="GmailMessageId">The X-GM-MSGID, or null when the server is not Gmail.</param>
|
||||
/// <param name="MessageId">The Message-ID header, or null when the mail has none.</param>
|
||||
/// <param name="Date">The Date header, or null when the mail has none or it cannot be read.</param>
|
||||
/// <param name="FromAddresses">The addresses of the From header, in their order.</param>
|
||||
/// <param name="Subject">The subject, or null when the mail has none.</param>
|
||||
/// <param name="Size">The size of the mail on the server (RFC822.SIZE), in bytes.</param>
|
||||
public sealed record MailIdentity(string? EmailId, ulong? GmailMessageId, string? MessageId, DateTimeOffset? Date, IReadOnlyList<string> FromAddresses, string? Subject, long Size);
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace AIStudio.Tools.Mail;
|
||||
|
||||
/// <summary>
|
||||
/// How important the sender marked a mail, read from the Importance, X-Priority and Priority headers.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// There is no value for an unknown importance on purpose: RFC 2156 says that a mail without any
|
||||
/// such header is a normal one, so NORMAL is what the mail says, not a guess. It comes first so
|
||||
/// that a value nobody set is the normal one as well, never LOW.
|
||||
/// </remarks>
|
||||
public enum MailImportance
|
||||
{
|
||||
NORMAL,
|
||||
LOW,
|
||||
HIGH,
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using MimeKit;
|
||||
|
||||
namespace AIStudio.Tools.Mail;
|
||||
|
||||
/// <summary>
|
||||
/// Reads how important the sender marked a mail.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Three headers say so, from three different eras, and mail programs send whichever they like,
|
||||
/// often more than one. They are read in this order, and the first one with a value AI Studio
|
||||
/// understands decides:
|
||||
/// <list type="number">
|
||||
/// <item>Importance (RFC 2156): low, normal or high. Outlook sends it.</item>
|
||||
/// <item>X-Priority: a number from 1 (highest) to 5 (lowest), often followed by a word such as "1 (Highest)".</item>
|
||||
/// <item>Priority (RFC 2156): non-urgent, normal or urgent.</item>
|
||||
/// </list>
|
||||
/// A header with a value nobody defined is skipped rather than read as normal, so that the next
|
||||
/// header still gets its say.
|
||||
/// </remarks>
|
||||
public static class MailImportanceDetection
|
||||
{
|
||||
/// <summary>
|
||||
/// Reads the importance from the header block of a mail.
|
||||
/// </summary>
|
||||
/// <param name="headers">The header block.</param>
|
||||
/// <returns>The importance, NORMAL when no header says otherwise.</returns>
|
||||
public static MailImportance Detect(HeaderList headers) =>
|
||||
ReadImportance(headers[HeaderId.Importance]) ??
|
||||
ReadXPriority(headers[HeaderId.XPriority]) ??
|
||||
ReadPriority(headers[HeaderId.Priority]) ??
|
||||
MailImportance.NORMAL;
|
||||
|
||||
private static MailImportance? ReadImportance(string? value) => value?.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"low" => MailImportance.LOW,
|
||||
"normal" => MailImportance.NORMAL,
|
||||
"high" => MailImportance.HIGH,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static MailImportance? ReadXPriority(string? value)
|
||||
{
|
||||
// Only the leading digit counts, as in "2 (High)", and it has to stand alone:
|
||||
var trimmed = value?.TrimStart();
|
||||
if (string.IsNullOrEmpty(trimmed) || (trimmed.Length > 1 && char.IsDigit(trimmed[1])))
|
||||
return null;
|
||||
|
||||
return trimmed[0] switch
|
||||
{
|
||||
'1' or '2' => MailImportance.HIGH,
|
||||
'3' => MailImportance.NORMAL,
|
||||
'4' or '5' => MailImportance.LOW,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
private static MailImportance? ReadPriority(string? value) => value?.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"non-urgent" => MailImportance.LOW,
|
||||
"normal" => MailImportance.NORMAL,
|
||||
"urgent" => MailImportance.HIGH,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace AIStudio.Tools.Mail;
|
||||
|
||||
/// <summary>
|
||||
/// Which part of a mail some stored text belongs to.
|
||||
/// </summary>
|
||||
public enum MailPartKind
|
||||
{
|
||||
/// <summary>
|
||||
/// A kind this version does not know, e.g. stored by a newer version.
|
||||
/// </summary>
|
||||
UNKNOWN,
|
||||
|
||||
/// <summary>
|
||||
/// The complete header block, as the server delivered it.
|
||||
/// </summary>
|
||||
HEADERS,
|
||||
|
||||
/// <summary>
|
||||
/// The text of the mail.
|
||||
/// </summary>
|
||||
BODY,
|
||||
|
||||
ATTACHMENT,
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace AIStudio.Tools.Mail;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the text of a part of a mail could be read and, when not, why.
|
||||
/// </summary>
|
||||
public enum MailPartTextState
|
||||
{
|
||||
/// <summary>
|
||||
/// A state this version does not know, e.g. stored by a newer version.
|
||||
/// </summary>
|
||||
UNKNOWN,
|
||||
|
||||
/// <summary>
|
||||
/// The text was read and is stored.
|
||||
/// </summary>
|
||||
EXTRACTED,
|
||||
|
||||
/// <summary>
|
||||
/// The mailbox is set to leave attachments out.
|
||||
/// </summary>
|
||||
ATTACHMENTS_DISABLED,
|
||||
|
||||
/// <summary>
|
||||
/// The attachment is larger than the mailbox allows to read.
|
||||
/// </summary>
|
||||
TOO_LARGE,
|
||||
|
||||
/// <summary>
|
||||
/// The attachment is of a type AI Studio cannot read text from, e.g. an image.
|
||||
/// </summary>
|
||||
UNSUPPORTED_TYPE,
|
||||
|
||||
/// <summary>
|
||||
/// Reading the text was tried but failed.
|
||||
/// </summary>
|
||||
EXTRACTION_FAILED,
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace AIStudio.Tools.Mail;
|
||||
|
||||
/// <summary>
|
||||
/// Whether a sync may remove the mails it found gone from the index.
|
||||
/// </summary>
|
||||
public enum MailRemovalDecision
|
||||
{
|
||||
/// <summary>
|
||||
/// The mails go: they are few, or the user agreed to exactly this removal.
|
||||
/// </summary>
|
||||
PROCEED,
|
||||
|
||||
/// <summary>
|
||||
/// The mails stay in the index until the user decides, since rebuilding them would be costly.
|
||||
/// </summary>
|
||||
HOLD_BACK,
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using AIStudio.Tools.Databases.IndexStore;
|
||||
|
||||
namespace AIStudio.Tools.Mail;
|
||||
|
||||
/// <summary>
|
||||
/// Keeps a sync from removing a large part of a mailbox from the index without asking.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Nothing on the server is touched either way. But a mail removed from the index has to be
|
||||
/// embedded again should it come back, and much of a mailbox drops out at once for reasons the user
|
||||
/// may not have meant: a smaller folder or period was picked, a folder was renamed on the server,
|
||||
/// or the server lists a folder as empty for a while. Embedding all of that again costs money with
|
||||
/// a cloud provider and hours with a large mailbox, so the sync asks first.
|
||||
///
|
||||
/// A few mails are not worth a question, however large their share: a mailbox of fifty mails is
|
||||
/// embedded again in no time. Nor is a small share of a large mailbox, such as the mails of one day
|
||||
/// leaving the period.
|
||||
/// </remarks>
|
||||
public static class MailRemovalGuard
|
||||
{
|
||||
/// <summary>
|
||||
/// How many mails a removal has to reach at least before anybody is asked.
|
||||
/// </summary>
|
||||
public const int MIN_REMOVAL_COUNT = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Which share of the indexed mails a removal has to reach at least before anybody is asked.
|
||||
/// </summary>
|
||||
public const double MIN_REMOVAL_SHARE = 0.2;
|
||||
|
||||
/// <summary>
|
||||
/// Whether a removal is large enough to ask about.
|
||||
/// </summary>
|
||||
/// <param name="indexedCount">How many the index holds.</param>
|
||||
/// <param name="removalCount">How many would go.</param>
|
||||
/// <returns>True when the user has to agree first.</returns>
|
||||
public static bool IsMassRemoval(int indexedCount, int removalCount) => removalCount >= MIN_REMOVAL_COUNT && removalCount >= indexedCount * MIN_REMOVAL_SHARE;
|
||||
|
||||
/// <summary>
|
||||
/// Decides whether a sync may remove what it found gone.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The user agrees to a number, not to whatever a sync comes up with: an approval counts only
|
||||
/// for the very count which was held back and shown, cf. ApprovePendingMailRemovalAsync.
|
||||
/// </remarks>
|
||||
/// <param name="indexedCount">How many the index holds.</param>
|
||||
/// <param name="removalCount">How many would go.</param>
|
||||
/// <param name="syncState">How the sync of the mailbox stands, with any removal held back before.</param>
|
||||
/// <returns>The decision.</returns>
|
||||
public static MailRemovalDecision Decide(int indexedCount, int removalCount, MailboxSyncState syncState)
|
||||
{
|
||||
if (!IsMassRemoval(indexedCount, removalCount))
|
||||
return MailRemovalDecision.PROCEED;
|
||||
|
||||
return syncState is { PendingRemovalApprovedUtc: not null } && syncState.PendingRemovalCount == removalCount
|
||||
? MailRemovalDecision.PROCEED
|
||||
: MailRemovalDecision.HOLD_BACK;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace AIStudio.Tools.Mail;
|
||||
|
||||
/// <summary>
|
||||
/// A folder as the IMAP server lists it.
|
||||
/// </summary>
|
||||
/// <param name="FullName">The full path as the server names it, including the server's own hierarchy delimiter.</param>
|
||||
/// <param name="Name">The last part of the path, as shown in a folder tree.</param>
|
||||
/// <param name="ParentFullName">The full name of the folder this one lies in, or empty at the top level.</param>
|
||||
/// <param name="DirectorySeparator">The hierarchy delimiter of the server, or the null character when it has none.</param>
|
||||
/// <param name="SpecialUse">What the folder is for, as the server announces it.</param>
|
||||
/// <param name="IsInbox">Whether this is the inbox, which IMAP names INBOX in any case.</param>
|
||||
/// <param name="CanSelect">Whether the folder can hold mails. A folder which cannot only groups other folders.</param>
|
||||
public sealed record MailServerFolder(string FullName, string Name, string ParentFullName, char DirectorySeparator, MailFolderSpecialUse SpecialUse, bool IsInbox, bool CanSelect);
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace AIStudio.Tools.Mail;
|
||||
|
||||
public static class MailServerHosts
|
||||
{
|
||||
/// <summary>
|
||||
/// The host in the form certificates and the allowed hosts for root certificates use.
|
||||
/// </summary>
|
||||
/// <param name="host">The host as somebody wrote it, e.g., with umlauts or surrounding white space.</param>
|
||||
/// <param name="idnHost">The host in its ASCII form, or an IP address as written.</param>
|
||||
/// <returns>True when the text is a host name or an IP address.</returns>
|
||||
public static bool TryGetIdnHost(string host, out string idnHost)
|
||||
{
|
||||
idnHost = host.Trim();
|
||||
switch (Uri.CheckHostName(idnHost))
|
||||
{
|
||||
case UriHostNameType.IPv4:
|
||||
case UriHostNameType.IPv6:
|
||||
return true;
|
||||
|
||||
case UriHostNameType.Dns:
|
||||
try
|
||||
{
|
||||
idnHost = new IdnMapping().GetAscii(idnHost);
|
||||
return true;
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether two hosts name the same server, however each was written.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Case, umlauts against their ASCII form, and a closing dot make no difference. A text which
|
||||
/// is no host at all is the same as nothing.
|
||||
/// </remarks>
|
||||
/// <param name="host">The one host.</param>
|
||||
/// <param name="otherHost">The other host.</param>
|
||||
/// <returns>True when both are hosts and name the same server.</returns>
|
||||
public static bool AreSame(string host, string otherHost) =>
|
||||
TryGetIdnHost(host, out var idnHost) &&
|
||||
TryGetIdnHost(otherHost, out var otherIdnHost) &&
|
||||
idnHost.TrimEnd('.').Equals(otherIdnHost.TrimEnd('.'), StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Tools.PluginSystem;
|
||||
|
||||
namespace AIStudio.Tools.Mail;
|
||||
|
||||
/// <summary>
|
||||
/// Which mail servers AI Studio may connect to, and whose mailboxes the AI may read.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Read anew for every decision rather than kept: both parts come from configuration plugins, which
|
||||
/// may change while AI Studio runs.
|
||||
/// </remarks>
|
||||
/// <param name="AllowsOnlyOrganizationMailServers">Whether the organization allows only its own mail servers, see DataMailboxes.AllowOnlyOrganizationMailServers.</param>
|
||||
/// <param name="OrganizationHosts">The hosts of the mail servers which the configuration plugins offer.</param>
|
||||
public sealed record MailServerPolicy(bool AllowsOnlyOrganizationMailServers, IReadOnlyList<string> OrganizationHosts)
|
||||
{
|
||||
/// <summary>
|
||||
/// Every server: what holds as long as no organization rules otherwise.
|
||||
/// </summary>
|
||||
public static readonly MailServerPolicy ANY_SERVER = new(false, []);
|
||||
|
||||
/// <summary>
|
||||
/// Reads the policy which holds right now.
|
||||
/// </summary>
|
||||
/// <param name="settingsManager">The settings, which hold the switch of the organization.</param>
|
||||
/// <returns>The policy.</returns>
|
||||
public static MailServerPolicy Read(SettingsManager settingsManager) => new(
|
||||
settingsManager.ConfigurationData.MailboxSettings.AllowOnlyOrganizationMailServers,
|
||||
PluginFactory.GetMailboxProviders().Select(provider => provider.Host).ToList());
|
||||
|
||||
/// <summary>
|
||||
/// Whether AI Studio may connect to a server, and the AI read the mailboxes on it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Port and encryption make no difference: a mailbox on the right host but another port is
|
||||
/// still on the server of the organization.
|
||||
/// </remarks>
|
||||
/// <param name="host">The host of the server.</param>
|
||||
/// <returns>True when the server is allowed.</returns>
|
||||
public bool IsAllowed(string host) => !this.AllowsOnlyOrganizationMailServers || this.OrganizationHosts.Any(organizationHost => MailServerHosts.AreSame(organizationHost, host));
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
using AIStudio.Tools.Databases.IndexStore;
|
||||
|
||||
using MailKit;
|
||||
|
||||
using MimeKit;
|
||||
|
||||
namespace AIStudio.Tools.Mail;
|
||||
|
||||
/// <summary>
|
||||
/// Turns what the server reported about a mail into what AI Studio keeps of it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Everything is read from the header block the sync fetches with every mail, not from the
|
||||
/// ENVELOPE of IMAP: a server fills in Sender and Reply-To from From when the mail has neither, and
|
||||
/// the trust check of a later step has to know whether the sender set them.
|
||||
/// </remarks>
|
||||
public static class MailSummaryReader
|
||||
{
|
||||
/// <summary>
|
||||
/// Reads what tells the mail apart from all others on the server, for MailContentKey.
|
||||
/// </summary>
|
||||
/// <param name="summary">The summary, fetched with its header block.</param>
|
||||
/// <returns>The identity of the mail.</returns>
|
||||
public static MailIdentity ReadIdentity(IMessageSummary summary)
|
||||
{
|
||||
var headers = GetHeaders(summary);
|
||||
return new(
|
||||
summary.EmailId,
|
||||
summary.GMailMessageId,
|
||||
headers[HeaderId.MessageId],
|
||||
MailHeaders.ReadDate(headers),
|
||||
MailHeaders.ReadMailboxes(headers, HeaderId.From).Select(mailbox => mailbox.Address).ToList(),
|
||||
headers[HeaderId.Subject],
|
||||
summary.Size ?? 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Puts together what MailTextBuilder reads a mail from.
|
||||
/// </summary>
|
||||
/// <param name="summary">The summary, fetched with its header block and its structure.</param>
|
||||
/// <param name="textParts">The text parts of the mail, or null when they were not fetched because the mail is encrypted.</param>
|
||||
/// <returns>The source for MailTextBuilder.</returns>
|
||||
public static MailTextSource ReadTextSource(IMessageSummary summary, MailTextParts? textParts)
|
||||
{
|
||||
var attachmentNames = ReadAttachments(summary)
|
||||
.Select(attachment => attachment.FileName ?? string.Empty)
|
||||
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||
.ToList();
|
||||
|
||||
return new(GetHeaders(summary), textParts?.HtmlBody, textParts?.TextBody, attachmentNames, MailEncryptionDetection.Detect(summary.Body));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads which attachments a reader of the mail sees, from its structure.
|
||||
/// </summary>
|
||||
/// <param name="summary">The summary, fetched with its structure.</param>
|
||||
/// <returns>The attachments in their order, without the signature of a signed mail, cf. MailAttachmentRules.IsSignature.</returns>
|
||||
public static IReadOnlyList<BodyPartBasic> ReadAttachments(IMessageSummary summary) => summary.Attachments
|
||||
.Where(attachment => !MailAttachmentRules.IsSignature(attachment.ContentType))
|
||||
.ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Reads every address of the header block together with the header it comes from.
|
||||
/// </summary>
|
||||
/// <param name="headers">The header block.</param>
|
||||
/// <returns>The addresses, grouped by their header in the order From, Sender, Reply-To, To, Cc, Bcc.</returns>
|
||||
public static IReadOnlyList<MailAddressRecord> ReadAddresses(HeaderList headers)
|
||||
{
|
||||
(HeaderId HeaderId, MailAddressRole Role)[] roles =
|
||||
[
|
||||
(HeaderId.From, MailAddressRole.FROM),
|
||||
(HeaderId.Sender, MailAddressRole.SENDER),
|
||||
(HeaderId.ReplyTo, MailAddressRole.REPLY_TO),
|
||||
(HeaderId.To, MailAddressRole.TO),
|
||||
(HeaderId.Cc, MailAddressRole.CC),
|
||||
(HeaderId.Bcc, MailAddressRole.BCC),
|
||||
];
|
||||
|
||||
return roles
|
||||
.SelectMany(role => MailHeaders.ReadMailboxes(headers, role.HeaderId).Select(mailbox => new MailAddressRecord(
|
||||
role.Role,
|
||||
MailTextNormalization.NormalizeHeaderValue(mailbox.Address),
|
||||
MailTextNormalization.NormalizeHeaderValue(mailbox.Name))))
|
||||
.Where(address => address.Address.Length > 0)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the flags AI Studio keeps of a mail.
|
||||
/// </summary>
|
||||
/// <param name="flags">The flags the server reported, or null when it reported none.</param>
|
||||
/// <returns>The flags.</returns>
|
||||
public static MailFlags ReadFlags(MessageFlags? flags)
|
||||
{
|
||||
var value = flags ?? MessageFlags.None;
|
||||
return new(value.HasFlag(MessageFlags.Seen), value.HasFlag(MessageFlags.Flagged), value.HasFlag(MessageFlags.Answered));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the header block out the way the server delivered it, encoded words and all.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is what the trust check reads, and it needs the header block as it is: the decoded
|
||||
/// text of a header hides whether it was encoded, and how.
|
||||
/// </remarks>
|
||||
/// <param name="headers">The header block.</param>
|
||||
/// <returns>The header block as text.</returns>
|
||||
public static string ReadHeaderBlock(HeaderList headers)
|
||||
{
|
||||
using var stream = new MemoryStream();
|
||||
headers.WriteTo(FormatOptions.Default, stream);
|
||||
return Encoding.UTF8.GetString(stream.ToArray());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hashes the mail as the server delivered it, independent of how AI Studio turns it into text.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A mail on an IMAP server never changes, so its header block, its structure and its size are
|
||||
/// enough to tell it from any other mail, without fetching the whole of it.
|
||||
/// </remarks>
|
||||
/// <param name="summary">The summary, fetched with its header block and its structure.</param>
|
||||
/// <returns>The hash in hex.</returns>
|
||||
public static string ComputeMailHash(IMessageSummary summary)
|
||||
{
|
||||
var source = string.Join('\n',
|
||||
ReadHeaderBlock(GetHeaders(summary)),
|
||||
summary.Body?.ToString() ?? string.Empty,
|
||||
(summary.Size ?? 0).ToString(CultureInfo.InvariantCulture));
|
||||
|
||||
return Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(source)));
|
||||
}
|
||||
|
||||
private static HeaderList GetHeaders(IMessageSummary summary) => summary.Headers ?? throw new InvalidOperationException("The mail was fetched without its header block.");
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace AIStudio.Tools.Mail;
|
||||
|
||||
/// <summary>
|
||||
/// One mail as text, the way AI Studio indexes it and a model gets to read it.
|
||||
/// </summary>
|
||||
/// <param name="HeaderBlock">The lines written above the text: who wrote to whom, when, and about what.</param>
|
||||
/// <param name="Body">The text of the mail, empty when it is encrypted or has none.</param>
|
||||
/// <param name="Subject">The subject on one line, empty when the mail has none.</param>
|
||||
/// <param name="EncryptionKind">How the content is encrypted, NONE when it is readable.</param>
|
||||
/// <param name="Importance">How important the sender marked the mail.</param>
|
||||
/// <param name="BodySource">Which part of the mail the text was read from.</param>
|
||||
public sealed record MailText(string HeaderBlock, string Body, string Subject, MailEncryptionKind EncryptionKind, MailImportance Importance, MailBodySource BodySource)
|
||||
{
|
||||
/// <summary>
|
||||
/// The header block and the text together, which is what gets cut into chunks: the first
|
||||
/// chunk starts with the header block, so a search for a sender or a subject finds the mail.
|
||||
/// </summary>
|
||||
public string FullText => this.Body.Length is 0 ? this.HeaderBlock : $"{this.HeaderBlock}\n\n{this.Body}";
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
using MimeKit;
|
||||
|
||||
namespace AIStudio.Tools.Mail;
|
||||
|
||||
/// <summary>
|
||||
/// Turns a mail into the text AI Studio indexes: a header block, then the text a reader of the mail sees.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The text comes from the HTML part whenever there is one. It is what the reader sees, while
|
||||
/// the plain text part is only what the sender's program claims the mail says, and the two may
|
||||
/// say different things. The plain text part is read when there is no HTML part, or when nothing
|
||||
/// visible remains of it. See MailHtmlConverter for what the HTML loses on the way.
|
||||
///
|
||||
/// An encrypted mail keeps its header block alone, together with a line saying that and how it is
|
||||
/// encrypted. Nothing of the encrypted content gets in, neither its blob nor the names of its
|
||||
/// attachments, and a model can tell the user why it does not know what the mail says.
|
||||
///
|
||||
/// The header block is written in English whatever the language of the app, since it is data for
|
||||
/// the index and the model, like the field names of a tool result. All of it comes from the
|
||||
/// sender and goes through the prompt injection filter just as the body does.
|
||||
///
|
||||
/// The folder is left out of the header block on purpose. A mail which moves to another folder is
|
||||
/// not embedded again, so a folder written into its text would soon name the wrong one. Where a
|
||||
/// mail lies is known from its locations in the index instead.
|
||||
/// </remarks>
|
||||
public static class MailTextBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// How many addresses of one header are named before the rest is only counted. A mail to a
|
||||
/// whole department would fill the first chunk with addresses otherwise.
|
||||
/// </summary>
|
||||
private const int MAX_LISTED_ADDRESSES = 10;
|
||||
|
||||
/// <summary>
|
||||
/// How many attachments are named before the rest is only counted.
|
||||
/// </summary>
|
||||
private const int MAX_LISTED_ATTACHMENTS = 20;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the text of one mail.
|
||||
/// </summary>
|
||||
/// <param name="source">The parts of the mail.</param>
|
||||
/// <returns>The text of the mail.</returns>
|
||||
public static MailText Build(MailTextSource source)
|
||||
{
|
||||
var encryptionKind = source.StructureEncryption;
|
||||
if (encryptionKind is MailEncryptionKind.NONE && (MailEncryptionDetection.ContainsInlinePgpMessage(source.HtmlBody) || MailEncryptionDetection.ContainsInlinePgpMessage(source.TextBody)))
|
||||
encryptionKind = MailEncryptionKind.PGP_INLINE;
|
||||
|
||||
var isReadable = encryptionKind is MailEncryptionKind.NONE;
|
||||
var importance = MailImportanceDetection.Detect(source.Headers);
|
||||
var subject = MailTextNormalization.NormalizeHeaderValue(source.Headers[HeaderId.Subject]);
|
||||
IReadOnlyList<string> attachmentNames = isReadable ? source.AttachmentNames : [];
|
||||
|
||||
var headerBlock = BuildHeaderBlock(source.Headers, subject, importance, attachmentNames, encryptionKind);
|
||||
var (body, bodySource) = isReadable ? ReadBody(source) : (string.Empty, MailBodySource.NONE);
|
||||
return new(headerBlock, body, subject, encryptionKind, importance, bodySource);
|
||||
}
|
||||
|
||||
private static (string Body, MailBodySource Source) ReadBody(MailTextSource source)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(source.HtmlBody))
|
||||
{
|
||||
var markdown = MailHtmlConverter.ToMarkdown(source.HtmlBody);
|
||||
if (markdown.Length > 0)
|
||||
return (markdown, MailBodySource.HTML);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(source.TextBody))
|
||||
return (string.Empty, MailBodySource.NONE);
|
||||
|
||||
var text = MailTextNormalization.NormalizeBody(source.TextBody).Trim();
|
||||
return text.Length > 0 ? (text, MailBodySource.PLAIN_TEXT) : (string.Empty, MailBodySource.NONE);
|
||||
}
|
||||
|
||||
private static string BuildHeaderBlock(HeaderList headers, string subject, MailImportance importance, IReadOnlyList<string> attachmentNames, MailEncryptionKind encryptionKind)
|
||||
{
|
||||
var block = new StringBuilder();
|
||||
AppendLine(block, "From", FormatAddresses(headers, HeaderId.From));
|
||||
AppendLine(block, "To", FormatAddresses(headers, HeaderId.To));
|
||||
AppendLine(block, "Cc", FormatAddresses(headers, HeaderId.Cc));
|
||||
AppendLine(block, "Subject", subject);
|
||||
AppendLine(block, "Date", MailHeaders.ReadDate(headers)?.ToString("yyyy-MM-dd HH:mm zzz", CultureInfo.InvariantCulture) ?? string.Empty);
|
||||
AppendLine(block, "Importance", importance switch
|
||||
{
|
||||
MailImportance.HIGH => "high",
|
||||
MailImportance.LOW => "low",
|
||||
|
||||
// Normal is what nearly every mail is, so it is not worth a line:
|
||||
_ => string.Empty,
|
||||
});
|
||||
AppendLine(block, "Attachments", FormatList(attachmentNames.Select(MailTextNormalization.NormalizeHeaderValue).Where(name => name.Length > 0).ToList(), MAX_LISTED_ATTACHMENTS));
|
||||
AppendLine(block, "Content", DescribeEncryption(encryptionKind));
|
||||
return block.ToString().TrimEnd('\n');
|
||||
}
|
||||
|
||||
private static void AppendLine(StringBuilder block, string label, string value)
|
||||
{
|
||||
if (value.Length > 0)
|
||||
block.Append(label).Append(": ").Append(value).Append('\n');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Names the addresses of all headers of one kind, e.g. both To lines of a mail which has two.
|
||||
/// </summary>
|
||||
private static string FormatAddresses(HeaderList headers, HeaderId headerId) =>
|
||||
FormatList(MailHeaders.ReadMailboxes(headers, headerId).Select(FormatAddress).Where(address => address.Length > 0).ToList(), MAX_LISTED_ADDRESSES);
|
||||
|
||||
private static string FormatAddress(MailboxAddress mailbox)
|
||||
{
|
||||
var address = MailTextNormalization.NormalizeHeaderValue(mailbox.Address);
|
||||
var name = MailTextNormalization.NormalizeHeaderValue(mailbox.Name);
|
||||
if (name.Length is 0 || name.Equals(address, StringComparison.OrdinalIgnoreCase))
|
||||
return address;
|
||||
|
||||
return address.Length is 0 ? name : $"{name} <{address}>";
|
||||
}
|
||||
|
||||
private static string FormatList(IReadOnlyList<string> items, int maxListed)
|
||||
{
|
||||
if (items.Count <= maxListed)
|
||||
return string.Join(", ", items);
|
||||
|
||||
return $"{string.Join(", ", items.Take(maxListed))}, and {(items.Count - maxListed).ToString(CultureInfo.InvariantCulture)} more";
|
||||
}
|
||||
|
||||
private static string DescribeEncryption(MailEncryptionKind encryptionKind) => encryptionKind switch
|
||||
{
|
||||
MailEncryptionKind.NONE => string.Empty,
|
||||
MailEncryptionKind.SMIME => "encrypted with S/MIME, AI Studio cannot read it",
|
||||
MailEncryptionKind.SMIME_OPAQUE_SIGNED => "wrapped into an S/MIME signature, AI Studio cannot read it",
|
||||
MailEncryptionKind.PGP_MIME => "encrypted with PGP/MIME, AI Studio cannot read it",
|
||||
MailEncryptionKind.PGP_INLINE => "encrypted with PGP, AI Studio cannot read it",
|
||||
MailEncryptionKind.MICROSOFT_IRM => "protected by Microsoft Information Rights Management, AI Studio cannot read it",
|
||||
_ => "encrypted, AI Studio cannot read it",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using System.Text;
|
||||
|
||||
namespace AIStudio.Tools.Mail;
|
||||
|
||||
/// <summary>
|
||||
/// Cleans up the text of a mail, its header fields included, before anybody reads it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Characters nobody sees go first. They are the ones the prompt injection filter of the runtime
|
||||
/// treats as invisible (normalize::is_invisible), plus the soft hyphen and the combining grapheme
|
||||
/// joiner. Newsletters pad their preview text with long runs of them, and the filter would report
|
||||
/// every such newsletter as an attack for carrying invisible characters. Removing them costs the
|
||||
/// filter nothing, as it reads past them anyway: "ig<zero-width space>nore" is "ignore" to it.
|
||||
/// The two extra characters are ones the filter does not read past, so without them a hidden word
|
||||
/// gets whole again for it.
|
||||
/// </remarks>
|
||||
public static class MailTextNormalization
|
||||
{
|
||||
private const char NO_BREAK_SPACE = (char)0x00A0;
|
||||
|
||||
/// <summary>
|
||||
/// Cleans up the body: unified line breaks, no trailing whitespace, at most one empty line in a row.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A line which starts with a no-break space loses its whole indentation. That is padding, and
|
||||
/// four spaces of it would make the line a code block in Markdown. Other indentation stays, as
|
||||
/// it is what nests the lists of the converted HTML.
|
||||
/// </remarks>
|
||||
/// <param name="text">The body as it was converted.</param>
|
||||
/// <returns>The cleaned-up body.</returns>
|
||||
public static string NormalizeBody(string text)
|
||||
{
|
||||
var lines = WithoutInvisibleCharacters(text)
|
||||
.Replace("\r\n", "\n", StringComparison.Ordinal)
|
||||
.Replace('\r', '\n')
|
||||
.Split('\n')
|
||||
.Select(line => line.Length > 0 && line[0] == NO_BREAK_SPACE ? line.TrimStart(NO_BREAK_SPACE, ' ') : line)
|
||||
.Select(line => line.Replace(NO_BREAK_SPACE, ' ').TrimEnd());
|
||||
|
||||
var body = new StringBuilder();
|
||||
var emptyLines = 0;
|
||||
foreach (var line in lines)
|
||||
{
|
||||
if (line.Length is 0)
|
||||
{
|
||||
emptyLines++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (body.Length > 0)
|
||||
body.Append('\n', emptyLines > 0 ? 2 : 1);
|
||||
|
||||
body.Append(line);
|
||||
emptyLines = 0;
|
||||
}
|
||||
|
||||
return body.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cleans up a header field, e.g. a subject, a display name, or a file name, into one line.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// An encoded header may decode to line breaks. Kept, they would let a subject start lines of
|
||||
/// its own, e.g. a forged "From:" line in the header block AI Studio writes above the text.
|
||||
/// </remarks>
|
||||
/// <param name="value">The decoded value.</param>
|
||||
/// <returns>The value on one line, with every run of whitespace and control characters as one space.</returns>
|
||||
public static string NormalizeHeaderValue(string? value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
return string.Empty;
|
||||
|
||||
var cleaned = WithoutInvisibleCharacters(value).Select(character => char.IsControl(character) || character == NO_BREAK_SPACE ? ' ' : character);
|
||||
return string.Join(' ', string.Concat(cleaned).Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
|
||||
}
|
||||
|
||||
private static string WithoutInvisibleCharacters(string text) => text.Any(IsInvisible)
|
||||
? string.Concat(text.Where(character => !IsInvisible(character)))
|
||||
: text;
|
||||
|
||||
private static bool IsInvisible(char character) => (int)character is
|
||||
0x00AD or 0x034F or 0xFEFF or
|
||||
(>= 0x200B and <= 0x200F) or
|
||||
(>= 0x2060 and <= 0x2064) or
|
||||
(>= 0x2066 and <= 0x2069);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace AIStudio.Tools.Mail;
|
||||
|
||||
/// <summary>
|
||||
/// The text parts of a mail as the server sent them, decoded from their transfer encoding and charset.
|
||||
/// </summary>
|
||||
/// <param name="HtmlBody">The HTML part a reader sees, or null when there is none or it is too large to fetch.</param>
|
||||
/// <param name="TextBody">The plain text part, or null likewise.</param>
|
||||
public sealed record MailTextParts(string? HtmlBody, string? TextBody);
|
||||
@@ -0,0 +1,35 @@
|
||||
using MimeKit;
|
||||
|
||||
namespace AIStudio.Tools.Mail;
|
||||
|
||||
/// <summary>
|
||||
/// What MailTextBuilder reads one mail from.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Not a whole mail on purpose: the sync fetches the header block and the structure first, and
|
||||
/// the text parts only of a mail which turns out readable. The attachments themselves are never
|
||||
/// part of this, only their names.
|
||||
/// </remarks>
|
||||
/// <param name="Headers">The complete header block of the mail.</param>
|
||||
/// <param name="HtmlBody">The HTML part a reader sees, decoded from its transfer encoding and charset, or null when there is none.</param>
|
||||
/// <param name="TextBody">The plain text part, decoded likewise, or null when there is none.</param>
|
||||
/// <param name="AttachmentNames">The file names of the attachments, in their order, without the signature of a signed mail.</param>
|
||||
/// <param name="StructureEncryption">How the structure of the mail says its content is encrypted, see MailEncryptionDetection.</param>
|
||||
public sealed record MailTextSource(HeaderList Headers, string? HtmlBody, string? TextBody, IReadOnlyList<string> AttachmentNames, MailEncryptionKind StructureEncryption)
|
||||
{
|
||||
/// <summary>
|
||||
/// Takes everything from a mail which was fetched or loaded as a whole.
|
||||
/// </summary>
|
||||
/// <param name="message">The parsed mail.</param>
|
||||
/// <returns>The source for MailTextBuilder.</returns>
|
||||
public static MailTextSource FromMessage(MimeMessage message)
|
||||
{
|
||||
var attachmentNames = message.Attachments
|
||||
.Where(attachment => !MailAttachmentRules.IsSignature(attachment.ContentType))
|
||||
.Select(attachment => attachment.ContentDisposition?.FileName ?? attachment.ContentType.Name ?? string.Empty)
|
||||
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||
.ToList();
|
||||
|
||||
return new(message.Headers, message.HtmlBody, message.TextBody, attachmentNames, MailEncryptionDetection.Detect(message.Body));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace AIStudio.Tools.Mail;
|
||||
|
||||
/// <summary>
|
||||
/// A connection to the IMAP server of a mailbox failed, for the reason its failure code names.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The message is meant for developers and names neither the user nor the server. The inner
|
||||
/// exception may well do so, since servers tend to repeat the username in their answers. Log the
|
||||
/// failure code and the type of the inner exception, never its message.
|
||||
/// </remarks>
|
||||
public sealed class MailboxConnectionException(MailboxConnectionFailure failure, string message, Exception? innerException = null) : Exception(message, innerException)
|
||||
{
|
||||
/// <summary>
|
||||
/// Why the connection failed.
|
||||
/// </summary>
|
||||
public MailboxConnectionFailure Failure { get; } = failure;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace AIStudio.Tools.Mail;
|
||||
|
||||
/// <summary>
|
||||
/// Why a connection to the IMAP server of a mailbox failed.
|
||||
/// </summary>
|
||||
public enum MailboxConnectionFailure
|
||||
{
|
||||
/// <summary>
|
||||
/// The server rejected the username or the password. AI Studio does not try again on its own, because every rejected attempt brings the account closer to being locked.
|
||||
/// </summary>
|
||||
AUTHENTICATION_FAILED,
|
||||
|
||||
/// <summary>
|
||||
/// The server could not be reached or stopped answering, e.g., without a VPN connection. Says nothing about the password.
|
||||
/// </summary>
|
||||
NETWORK_UNAVAILABLE,
|
||||
|
||||
/// <summary>
|
||||
/// No encrypted connection came about: the certificate of the server was not trusted, or the server does not offer STARTTLS.
|
||||
/// </summary>
|
||||
TLS_FAILED,
|
||||
|
||||
/// <summary>
|
||||
/// The server refused a command, or answered in a way the IMAP client did not understand.
|
||||
/// </summary>
|
||||
SERVER_ERROR,
|
||||
|
||||
/// <summary>
|
||||
/// The settings of the mailbox are incomplete, or this version of AI Studio does not know them. Nothing was sent to the server.
|
||||
/// </summary>
|
||||
INVALID_SETTINGS,
|
||||
|
||||
/// <summary>
|
||||
/// The organization allows only its own mail servers, and the server of the mailbox is none of them. Nothing was sent to the server.
|
||||
/// </summary>
|
||||
SERVER_NOT_ALLOWED,
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using AIStudio.Tools.PluginSystem;
|
||||
|
||||
namespace AIStudio.Tools.Mail;
|
||||
|
||||
public static class MailboxConnectionFailureExtensions
|
||||
{
|
||||
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(MailboxConnectionFailureExtensions).Namespace, nameof(MailboxConnectionFailureExtensions));
|
||||
|
||||
/// <summary>
|
||||
/// What went wrong, and what the user can do about it.
|
||||
/// </summary>
|
||||
public static string GetDescription(this MailboxConnectionFailure failure) => failure switch
|
||||
{
|
||||
MailboxConnectionFailure.AUTHENTICATION_FAILED => TB("The server rejected the username or the password. Some providers require an app password instead of your usual password."),
|
||||
MailboxConnectionFailure.NETWORK_UNAVAILABLE => TB("The server could not be reached. Please check the host and the port, and whether you need a VPN connection."),
|
||||
MailboxConnectionFailure.TLS_FAILED => TB("No encrypted connection to the server could be established. When your organization uses a certificate authority of its own, enable the additional root certificates in the app settings, select the bundle with its root certificate, and add the host of the server to the allowed hosts. Your IT department can also configure this for you."),
|
||||
MailboxConnectionFailure.SERVER_ERROR => TB("The server reported an error. Please try again later."),
|
||||
MailboxConnectionFailure.INVALID_SETTINGS => TB("The settings of this mailbox are incomplete, or they were made by a newer version of AI Studio."),
|
||||
MailboxConnectionFailure.SERVER_NOT_ALLOWED => TB("Your organization allows mailboxes only on its own mail servers, and this server is none of them. AI Studio does not connect to it, and the AI does not read this mailbox."),
|
||||
|
||||
_ => TB("The connection to the server failed for an unknown reason."),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace AIStudio.Tools.Mail;
|
||||
|
||||
/// <summary>
|
||||
/// A mailbox which the provider of a chat may not read, or which is not configured any more.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The mail tools offer only the mailboxes a provider may read, so this happens when the settings
|
||||
/// changed after a request was prepared: the user lowered the confidence of a provider, switched a
|
||||
/// preview off, or deleted the mailbox. Whoever catches it refuses the call. The message is meant
|
||||
/// for the log and names the mailbox by its id only.
|
||||
/// </remarks>
|
||||
/// <param name="mailboxId">The id of the mailbox.</param>
|
||||
public sealed class MailboxNotReadableException(string mailboxId) : Exception($"The mailbox '{mailboxId}' is not configured, or the provider of the chat may not read it.")
|
||||
{
|
||||
public string MailboxId => mailboxId;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace AIStudio.Tools.Mail;
|
||||
|
||||
/// <summary>
|
||||
/// What a provider requires before AI Studio can sign in to its IMAP server.
|
||||
/// </summary>
|
||||
[Flags]
|
||||
public enum MailboxProviderRequirements
|
||||
{
|
||||
NONE = 0,
|
||||
|
||||
/// <summary>
|
||||
/// An app password instead of the usual password, in every case.
|
||||
/// </summary>
|
||||
APP_PASSWORD = 1,
|
||||
|
||||
/// <summary>
|
||||
/// An app password instead of the usual password, once two-factor authentication is enabled.
|
||||
/// </summary>
|
||||
APP_PASSWORD_WITH_TWO_FACTOR = 2,
|
||||
|
||||
/// <summary>
|
||||
/// The user enables the IMAP access in the settings of the webmail first.
|
||||
/// </summary>
|
||||
IMAP_ACTIVATION = 4,
|
||||
|
||||
/// <summary>
|
||||
/// The IT department enables IMAP on the server first, as Exchange ships with it switched off.
|
||||
/// </summary>
|
||||
ADMIN_ACTIVATION = 8,
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using AIStudio.Settings.DataModel;
|
||||
|
||||
namespace AIStudio.Tools.Mail;
|
||||
|
||||
/// <summary>
|
||||
/// The IMAP settings of a well-known provider, to fill in the mailbox dialog.
|
||||
/// </summary>
|
||||
/// <param name="Name">The name of the provider, the same in every language.</param>
|
||||
/// <param name="Host">The host of its IMAP server, or empty when every organization runs a server of its own.</param>
|
||||
/// <param name="Port">The port of its IMAP server.</param>
|
||||
/// <param name="TransportSecurity">How the connection is encrypted, the way the provider recommends.</param>
|
||||
/// <param name="UsernameFormat">What the provider expects as the username.</param>
|
||||
/// <param name="Requirements">What the provider requires before AI Studio can sign in.</param>
|
||||
/// <param name="HelpUrl">The page of the provider which explains what the user has to do first.</param>
|
||||
/// <param name="IconUrl">The logo of the provider, shipped with AI Studio, or empty when there is none.</param>
|
||||
public sealed record MailboxProviderTemplate(string Name, string Host, int Port, MailboxTransportSecurity TransportSecurity, MailboxUsernameFormat UsernameFormat, MailboxProviderRequirements Requirements, string HelpUrl, string IconUrl);
|
||||
@@ -0,0 +1,38 @@
|
||||
using AIStudio.Tools.PluginSystem;
|
||||
|
||||
namespace AIStudio.Tools.Mail;
|
||||
|
||||
public static class MailboxProviderTemplateExtensions
|
||||
{
|
||||
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(MailboxProviderTemplateExtensions).Namespace, nameof(MailboxProviderTemplateExtensions));
|
||||
|
||||
/// <summary>
|
||||
/// What the user enters as the username at this provider.
|
||||
/// </summary>
|
||||
public static string GetDescription(this MailboxUsernameFormat format) => format switch
|
||||
{
|
||||
MailboxUsernameFormat.EMAIL_ADDRESS => TB("Your full e-mail address."),
|
||||
MailboxUsernameFormat.ADDRESS_NAME_PART => TB("The part of your e-mail address before the @ sign. When that does not work, try your full e-mail address."),
|
||||
MailboxUsernameFormat.DOMAIN_ACCOUNT => string.Format(TB("Your account in the directory of your organization, either as {0} or as {1}. Your IT department knows which form your server expects."), @"DOMAIN\username", "username@domain"),
|
||||
|
||||
_ => TB("The username your provider gave you."),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// What the user has to do before AI Studio can sign in, one text per requirement.
|
||||
/// </summary>
|
||||
public static IEnumerable<string> GetDescriptions(this MailboxProviderRequirements requirements)
|
||||
{
|
||||
if (requirements.HasFlag(MailboxProviderRequirements.IMAP_ACTIVATION))
|
||||
yield return TB("Enable the IMAP access in the settings of your webmail first. The provider may switch it off again after a longer time without use.");
|
||||
|
||||
if (requirements.HasFlag(MailboxProviderRequirements.ADMIN_ACTIVATION))
|
||||
yield return TB("Your IT department has to enable IMAP for the server and for your mailbox first.");
|
||||
|
||||
if (requirements.HasFlag(MailboxProviderRequirements.APP_PASSWORD))
|
||||
yield return TB("This provider requires an app password, which you create in the security settings of your account. Your usual password does not work here.");
|
||||
|
||||
if (requirements.HasFlag(MailboxProviderRequirements.APP_PASSWORD_WITH_TWO_FACTOR))
|
||||
yield return TB("When two-factor authentication is enabled for your account, this provider requires an app password, which you create in the security settings of your account.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using AIStudio.Settings.DataModel;
|
||||
|
||||
namespace AIStudio.Tools.Mail;
|
||||
|
||||
/// <summary>
|
||||
/// The IMAP settings of well-known providers, read from their own documentation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A fixed table on purpose: AI Studio never asks a third party which server belongs to an
|
||||
/// address, since that would tell it whose mailbox is being set up. Each entry was checked against
|
||||
/// the help pages of the provider on 2026-10-01. When the provider documents more than one way to
|
||||
/// connect, the entry takes the one it recommends, or TLS from the first byte on.<br/><br/>
|
||||
/// Microsoft 365 and Outlook.com are missing on purpose: they no longer accept a password over
|
||||
/// IMAP, only a sign-in with Microsoft.<br/><br/>
|
||||
/// The logos come from Simple Icons, see the README next to them. STRATO, Posteo, and Microsoft
|
||||
/// Exchange Server have none there, so they go without.
|
||||
/// </remarks>
|
||||
public static class MailboxProviderTemplates
|
||||
{
|
||||
private const int IMAPS_PORT = MailboxTransportSecurityExtensions.SSL_ON_CONNECT_PORT;
|
||||
|
||||
private const string ICON_ROOT = "/images/provider-icons";
|
||||
|
||||
public static readonly IReadOnlyList<MailboxProviderTemplate> ALL =
|
||||
[
|
||||
// Google allows app passwords only with 2-Step Verification, and recommends a sign-in with Google instead:
|
||||
new("Gmail", "imap.gmail.com", IMAPS_PORT, MailboxTransportSecurity.SSL_ON_CONNECT, MailboxUsernameFormat.EMAIL_ADDRESS, MailboxProviderRequirements.APP_PASSWORD, "https://support.google.com/accounts/answer/185833", $"{ICON_ROOT}/gmail.svg"),
|
||||
|
||||
new("GMX", "imap.gmx.net", IMAPS_PORT, MailboxTransportSecurity.SSL_ON_CONNECT, MailboxUsernameFormat.EMAIL_ADDRESS, MailboxProviderRequirements.IMAP_ACTIVATION | MailboxProviderRequirements.APP_PASSWORD_WITH_TWO_FACTOR, "https://hilfe.gmx.net/pop-imap/einschalten.html", $"{ICON_ROOT}/gmx.svg"),
|
||||
new("WEB.DE", "imap.web.de", IMAPS_PORT, MailboxTransportSecurity.SSL_ON_CONNECT, MailboxUsernameFormat.EMAIL_ADDRESS, MailboxProviderRequirements.IMAP_ACTIVATION | MailboxProviderRequirements.APP_PASSWORD_WITH_TWO_FACTOR, "https://hilfe.web.de/pop-imap/einschalten.html", $"{ICON_ROOT}/web-de.svg"),
|
||||
new("IONOS", "imap.ionos.de", IMAPS_PORT, MailboxTransportSecurity.SSL_ON_CONNECT, MailboxUsernameFormat.EMAIL_ADDRESS, MailboxProviderRequirements.NONE, "https://www.ionos.de/hilfe/e-mail/allgemeine-themen/serverinformationen-fuer-imap-pop3-und-smtp/", $"{ICON_ROOT}/ionos.svg"),
|
||||
new("STRATO", "imap.strato.de", IMAPS_PORT, MailboxTransportSecurity.SSL_ON_CONNECT, MailboxUsernameFormat.EMAIL_ADDRESS, MailboxProviderRequirements.NONE, "https://www.strato.de/faq/mail/so-lauten-die-strato-e-mail-server", string.Empty),
|
||||
new("mailbox.org", "imap.mailbox.org", IMAPS_PORT, MailboxTransportSecurity.SSL_ON_CONNECT, MailboxUsernameFormat.EMAIL_ADDRESS, MailboxProviderRequirements.APP_PASSWORD_WITH_TWO_FACTOR, "https://kb.mailbox.org/en/private/e-mail/e-mail-configuration/", $"{ICON_ROOT}/mailbox-org.svg"),
|
||||
new("Hetzner (konsoleH)", "mail.your-server.de", IMAPS_PORT, MailboxTransportSecurity.SSL_ON_CONNECT, MailboxUsernameFormat.EMAIL_ADDRESS, MailboxProviderRequirements.NONE, "https://docs.hetzner.com/de/konsoleh/account-management/email/setting-up-an-email-account/", $"{ICON_ROOT}/hetzner.svg"),
|
||||
|
||||
// The host is the same for every address at Posteo, whatever its domain:
|
||||
new("Posteo", "posteo.de", IMAPS_PORT, MailboxTransportSecurity.SSL_ON_CONNECT, MailboxUsernameFormat.EMAIL_ADDRESS, MailboxProviderRequirements.NONE, "https://posteo.de/en/help/how-do-i-set-up-posteo-in-an-email-client-pop3-imap-and-smtp", string.Empty),
|
||||
|
||||
new("iCloud Mail", "imap.mail.me.com", IMAPS_PORT, MailboxTransportSecurity.SSL_ON_CONNECT, MailboxUsernameFormat.ADDRESS_NAME_PART, MailboxProviderRequirements.APP_PASSWORD, "https://support.apple.com/en-us/102654", $"{ICON_ROOT}/icloud.svg"),
|
||||
|
||||
// Every organization runs a server of its own, and IMAP ships switched off:
|
||||
new("Microsoft Exchange Server", string.Empty, IMAPS_PORT, MailboxTransportSecurity.SSL_ON_CONNECT, MailboxUsernameFormat.DOMAIN_ACCOUNT, MailboxProviderRequirements.ADMIN_ACTIVATION, "https://learn.microsoft.com/en-us/exchange/clients/pop3-and-imap4/configure-imap4", string.Empty),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace AIStudio.Tools.Mail;
|
||||
|
||||
/// <summary>
|
||||
/// What a provider expects as the username for its IMAP server.
|
||||
/// </summary>
|
||||
public enum MailboxUsernameFormat
|
||||
{
|
||||
/// <summary>
|
||||
/// The full e-mail address.
|
||||
/// </summary>
|
||||
EMAIL_ADDRESS,
|
||||
|
||||
/// <summary>
|
||||
/// The part of the e-mail address before the @ sign, e.g., at iCloud.
|
||||
/// </summary>
|
||||
ADDRESS_NAME_PART,
|
||||
|
||||
/// <summary>
|
||||
/// The account in the directory of the organization, e.g., at an Exchange server.
|
||||
/// </summary>
|
||||
DOMAIN_ACCOUNT,
|
||||
}
|
||||
Reference in new issue
Block a user