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

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-10-04 12:26:26 +02:00
1 parent 93be539e50
commit c4400c0ff6
343 files changed
+26051 -3122

No files matched your search

@@ -48,7 +48,7 @@ public static class DataSourceReindexWarning
var candidates = GetDataSourcesUsing(settingsManager, before.Id)
.Where(dataSource => EmbeddingChangeImpact.AffectsStoredIndex(dataSource, before, after))
.Cast<IDataSource>()
.Cast<IDataSourceBase>()
.ToList();
if (candidates.Count == 0)
@@ -69,7 +69,7 @@ public static class DataSourceReindexWarning
/// <param name="token">The cancellation token.</param>
/// <returns>True when the edit may be saved.</returns>
public static async Task<bool> ConfirmDataSourceChangeAsync(IDialogService dialogService, SettingsManager settingsManager, DataSourceEmbeddingService embeddingService,
IInternalDataSource before, IInternalDataSource after, CancellationToken token = default)
IIndexedDataSource before, IIndexedDataSource after, CancellationToken token = default)
{
// Without a provider nothing is embedded at all, so nothing can be lost:
if (!DataSourceEmbeddingProviders.TryResolve(settingsManager, after, out var afterProvider))
@@ -112,7 +112,7 @@ public static class DataSourceReindexWarning
if (embeddingProvider == EmbeddingProvider.NONE)
return string.Empty;
var affected = GetDataSourcesUsing(settingsManager, embeddingProvider.Id).Cast<IDataSource>().ToList();
var affected = GetDataSourcesUsing(settingsManager, embeddingProvider.Id).Cast<IDataSourceBase>().ToList();
if (affected.Count == 0)
return string.Empty;
@@ -129,14 +129,15 @@ public static class DataSourceReindexWarning
}
/// <summary>
/// The data sources which are indexed with a given embedding provider.
/// The data sources and mailboxes which are indexed with a given embedding provider.
/// </summary>
/// <param name="settingsManager">The settings holding the data sources.</param>
/// <param name="settingsManager">The settings holding the data sources and the mailboxes.</param>
/// <param name="embeddingProviderId">The id of the embedding provider.</param>
/// <returns>The data sources pointing at that embedding provider.</returns>
private static IReadOnlyList<IInternalDataSource> GetDataSourcesUsing(SettingsManager settingsManager, string embeddingProviderId) =>
/// <returns>The data sources and mailboxes pointing at that embedding provider.</returns>
private static IReadOnlyList<IIndexedDataSource> GetDataSourcesUsing(SettingsManager settingsManager, string embeddingProviderId) =>
settingsManager.ConfigurationData.DataSources
.OfType<IInternalDataSource>()
.Concat(settingsManager.ConfigurationData.Mailboxes.Cast<IIndexedDataSource>())
.Where(dataSource => embeddingProviderId.Equals(dataSource.EmbeddingId, StringComparison.OrdinalIgnoreCase))
.ToList();
@@ -145,7 +146,7 @@ public static class DataSourceReindexWarning
/// </summary>
/// <param name="dataSources">The data sources to name.</param>
/// <returns>The Markdown list.</returns>
private static string FormatDataSourceNames(IReadOnlyList<IDataSource> dataSources)
private static string FormatDataSourceNames(IReadOnlyList<IDataSourceBase> dataSources)
{
var names = dataSources
.Select(dataSource => dataSource.Name)
@@ -159,7 +160,7 @@ public static class DataSourceReindexWarning
return string.Join(Environment.NewLine, lines);
}
private static async Task<bool> ConfirmAsync(IDialogService dialogService, IReadOnlyList<IDataSource> affected, bool usesCloudEmbedding)
private static async Task<bool> ConfirmAsync(IDialogService dialogService, IReadOnlyList<IDataSourceBase> affected, bool usesCloudEmbedding)
{
if (affected.Count == 0)
return true;
@@ -0,0 +1,73 @@
namespace AIStudio.Tools.Databases.IndexStore;
/// <remarks>
/// The mail tools search, count and read mails through these. Each one asks about one mailbox,
/// so a mail of another mailbox never turns up, whatever id a caller hands in.
/// </remarks>
public abstract partial class IndexStoreClient
{
/// <summary>
/// Lists the mails of a mailbox which meet the conditions, the most recently received first.
/// </summary>
/// <param name="dataSourceId">The mailbox.</param>
/// <param name="filter">The conditions.</param>
/// <param name="offset">How many mails to skip, for the pages after the first one.</param>
/// <param name="limit">How many mails to list at most.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>The ids of the mails. The order stays the same between calls, so pages neither repeat nor skip a mail.</returns>
public abstract Task<IReadOnlyList<string>> QueryMailsAsync(string dataSourceId, MailFilter filter, int offset, int limit, CancellationToken token);
/// <summary>
/// Reads the ids of every chunk of the mails which meet the conditions, to restrict a vector search to them.
/// </summary>
/// <remarks>
/// The list can be long: it holds every chunk of every matching mail, and a broad condition
/// matches most of a mailbox. A caller without any condition does better to search the whole
/// store and drop what GetMailSummariesAsync does not know.
/// </remarks>
/// <param name="dataSourceId">The mailbox.</param>
/// <param name="filter">The conditions.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>The chunk ids, which are the ids of their points in the vector store.</returns>
public abstract Task<IReadOnlyList<string>> GetMailChunkIdsAsync(string dataSourceId, MailFilter filter, CancellationToken token);
/// <summary>
/// Searches the chunks of the mails which meet the conditions by their words, with BM25.
/// </summary>
/// <param name="dataSourceId">The mailbox.</param>
/// <param name="query">The words to search for.</param>
/// <param name="filter">The conditions.</param>
/// <param name="maxMatches">How many chunks to return at most.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>The chunks, best first; the id of their document is the id of their mail.</returns>
public abstract Task<IReadOnlyList<IndexStoreSearchResult>> SearchMailChunksAsync(string dataSourceId, string query, MailFilter filter, int maxMatches, CancellationToken token);
/// <summary>
/// Counts the mails of a mailbox which meet the conditions.
/// </summary>
/// <param name="dataSourceId">The mailbox.</param>
/// <param name="filter">The conditions.</param>
/// <param name="grouping">How to break the number down.</param>
/// <param name="maxGroups">How many groups to return at most, the largest ones.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>The total and the largest groups.</returns>
public abstract Task<MailCountResult> CountMailsAsync(string dataSourceId, MailFilter filter, MailCountGrouping grouping, int maxGroups, CancellationToken token);
/// <summary>
/// Reads what a list of mails shows about each of the given ones.
/// </summary>
/// <param name="dataSourceId">The mailbox.</param>
/// <param name="mailIds">The ids of the mails, in the order the list shows them.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>The summaries in the given order. A mail this mailbox does not hold, or which lies nowhere any more, is left out.</returns>
public abstract Task<IReadOnlyList<MailSummary>> GetMailSummariesAsync(string dataSourceId, IReadOnlyList<string> mailIds, CancellationToken token);
/// <summary>
/// Finds a mail by its Message-ID, e.g., the one another mail replies to.
/// </summary>
/// <param name="dataSourceId">The mailbox.</param>
/// <param name="messageId">The Message-ID, without angle brackets.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>The id of the mail, the one received first when there are copies; null when the mailbox holds no such mail.</returns>
public abstract Task<string?> FindMailByMessageIdAsync(string dataSourceId, string messageId, CancellationToken token);
}
@@ -0,0 +1,207 @@
namespace AIStudio.Tools.Databases.IndexStore;
/// <remarks>
/// Every mail is a document like any other: its row in the files, its chunks, its vectors. What
/// follows is what only a mail has, and it rests on that document. So a mail is stored after its
/// last chunk, and deleting its document takes all of this along.
///
/// A mail is addressed by its id, which is the id of its document. Folders are addressed by their
/// path, and have to be stored before the mails which lie in them.
/// </remarks>
public abstract partial class IndexStoreClient
{
/// <summary>
/// Reads the folders of a mailbox and how far their sync got.
/// </summary>
/// <param name="dataSourceId">The mailbox.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>The folders, ordered by their path.</returns>
public abstract Task<IReadOnlyList<MailFolderRecord>> GetMailFoldersAsync(string dataSourceId, CancellationToken token);
/// <summary>
/// Stores a folder of a mailbox, or how far its sync got.
/// </summary>
/// <remarks>
/// A UIDVALIDITY other than the stored one voids every UID stored for the folder, so its
/// locations are dropped. Mails left without any location are orphaned, not deleted: the sync
/// finds them again under their new UIDs and links them anew, without embedding them again.
/// That is why a new UIDVALIDITY has to be stored before the first location under it, not
/// at the end of the pass: by then, it would drop the very locations the pass just linked.
/// </remarks>
/// <param name="dataSourceId">The mailbox.</param>
/// <param name="folder">The folder, identified by its path.</param>
/// <param name="token">The cancellation token.</param>
public abstract Task UpsertMailFolderAsync(string dataSourceId, MailFolderRecord folder, CancellationToken token);
/// <summary>
/// Removes a folder which is gone from the server, or no longer part of the mailbox.
/// </summary>
/// <remarks>
/// Its mails are not deleted along with it, only orphaned when they lie nowhere else: they may
/// just have moved to a folder the sync gets to later.
/// </remarks>
/// <param name="dataSourceId">The mailbox.</param>
/// <param name="folderPath">The path of the folder.</param>
/// <param name="token">The cancellation token.</param>
public abstract Task DeleteMailFolderAsync(string dataSourceId, string folderPath, CancellationToken token);
/// <summary>
/// Stores what the index keeps about a mail beyond its chunks, replacing what it kept before.
/// </summary>
/// <remarks>
/// Only ever after the last chunk of the mail: indexing a document deletes it first, and that
/// takes everything stored here along. When the mail was seen before, the earlier of both
/// first sightings stays. A UID names one mail only, so should the index still hold one of the
/// given locations for another mail, that location moves over, and the other mail is orphaned
/// when it lies nowhere else.
/// </remarks>
/// <param name="dataSourceId">The mailbox.</param>
/// <param name="mail">The mail, with at least one location in a stored folder.</param>
/// <param name="token">The cancellation token.</param>
public abstract Task UpsertMailAsync(string dataSourceId, MailRecord mail, CancellationToken token);
/// <summary>
/// Reads everything the index keeps about a mail beyond its chunks.
/// </summary>
/// <param name="dataSourceId">The mailbox.</param>
/// <param name="mailId">The id of the mail.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>The mail, or null when the mailbox holds no such mail.</returns>
public abstract Task<MailRecord?> GetMailAsync(string dataSourceId, string mailId, CancellationToken token);
/// <summary>
/// Links a mail the index already holds to one more place on the server, without embedding it again.
/// </summary>
/// <remarks>
/// This is how a moved mail and a mail under a new UIDVALIDITY are taken care of. The mail stops
/// being an orphan, and a location held for another mail moves over as with UpsertMailAsync.
/// </remarks>
/// <param name="dataSourceId">The mailbox.</param>
/// <param name="mailId">The id of the mail.</param>
/// <param name="location">The place, in a stored folder.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>True when the mail was linked, false when the mailbox holds no such mail and it has to be indexed.</returns>
public abstract Task<bool> AddMailLocationAsync(string dataSourceId, string mailId, MailLocationRecord location, CancellationToken token);
/// <summary>
/// Forgets places on the server where mails no longer lie.
/// </summary>
/// <remarks>
/// Mails left without any location are orphaned, not deleted, cf. GetOrphanedMailsAsync.
/// </remarks>
/// <param name="dataSourceId">The mailbox.</param>
/// <param name="folderPath">The path of the folder.</param>
/// <param name="uids">The UIDs which are gone from that folder.</param>
/// <param name="token">The cancellation token.</param>
public abstract Task RemoveMailLocationsAsync(string dataSourceId, string folderPath, IReadOnlyCollection<long> uids, CancellationToken token);
/// <summary>
/// Reads which UIDs of a folder the index holds, and their flags.
/// </summary>
/// <param name="dataSourceId">The mailbox.</param>
/// <param name="folderPath">The path of the folder.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>The flags by UID, empty when the folder is not stored.</returns>
public abstract Task<IReadOnlyDictionary<long, MailFlags>> GetMailLocationsAsync(string dataSourceId, string folderPath, CancellationToken token);
/// <summary>
/// Stores the flags of mails in one folder. Flags of UIDs the index does not hold are ignored.
/// </summary>
/// <param name="dataSourceId">The mailbox.</param>
/// <param name="folderPath">The path of the folder.</param>
/// <param name="flagsByUid">The flags by UID.</param>
/// <param name="token">The cancellation token.</param>
public abstract Task UpdateMailFlagsAsync(string dataSourceId, string folderPath, IReadOnlyDictionary<long, MailFlags> flagsByUid, CancellationToken token);
/// <summary>
/// Reads the mails which lost their last location before the given point in time.
/// </summary>
/// <remarks>
/// A moved mail disappears from one folder before it shows up in the other one, maybe only in
/// the next run. Asking with the start of the current run therefore leaves out every mail
/// which became an orphan during that run, and those get one more run to be found again.
/// </remarks>
/// <param name="dataSourceId">The mailbox.</param>
/// <param name="orphanedBefore">Only mails orphaned before this point in time.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>The keys of their documents, to delete them like any other document.</returns>
public abstract Task<IReadOnlyList<string>> GetOrphanedMailsAsync(string dataSourceId, DateTimeOffset orphanedBefore, CancellationToken token);
/// <summary>
/// Reads how the sync of a mailbox as a whole stands.
/// </summary>
/// <param name="dataSourceId">The mailbox.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>The state; every value is null for a mailbox which was never synced.</returns>
public abstract Task<MailboxSyncState> GetMailboxSyncStateAsync(string dataSourceId, CancellationToken token);
/// <summary>
/// Holds back a sync which would remove many mails from the index at once, until the user decides.
/// </summary>
/// <remarks>
/// A count other than the one held back so far voids an earlier approval: the user agreed to
/// a number, not to whatever a later sync comes up with.
/// </remarks>
/// <param name="dataSourceId">The mailbox, which has to be stored as a data source.</param>
/// <param name="removalCount">How many mails the sync would remove from the index.</param>
/// <param name="token">The cancellation token.</param>
public abstract Task HoldBackMailRemovalAsync(string dataSourceId, int removalCount, CancellationToken token);
/// <summary>
/// Records that the user agreed to the held back removal.
/// </summary>
/// <param name="dataSourceId">The mailbox.</param>
/// <param name="removalCount">The count the user was shown and agreed to.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>True when exactly that removal was held back and is now approved. False when nothing or another count is held back by now, and the user has to be asked again.</returns>
public abstract Task<bool> ApprovePendingMailRemovalAsync(string dataSourceId, int removalCount, CancellationToken token);
/// <summary>
/// Records a complete sync of a mailbox. A removal held back before has been dealt with by then.
/// </summary>
/// <param name="dataSourceId">The mailbox, which has to be stored as a data source.</param>
/// <param name="completedUtc">When the sync ended.</param>
/// <param name="token">The cancellation token.</param>
public abstract Task CompleteMailboxSyncAsync(string dataSourceId, DateTimeOffset completedUtc, CancellationToken token);
/// <summary>
/// Reads whether the server of a mailbox refused a sign-in which nobody has dealt with yet.
/// </summary>
/// <param name="dataSourceId">The mailbox.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>The refused sign-in, or null when AI Studio may sign in.</returns>
public abstract Task<MailboxAuthFailure?> GetMailboxAuthFailureAsync(string dataSourceId, CancellationToken token);
/// <summary>
/// Records a sign-in the server refused. From then on, AI Studio does not sign in on its own,
/// not even after a restart, until the failure is cleared.
/// </summary>
/// <remarks>
/// Kept apart from everything else about the mailbox, so it survives a rebuild of the index,
/// and stored for a mailbox which was never indexed as well: the very first sign-in can fail.
/// </remarks>
/// <param name="dataSourceId">The mailbox.</param>
/// <param name="failure">The refused sign-in.</param>
/// <param name="token">The cancellation token.</param>
public abstract Task UpsertMailboxAuthFailureAsync(string dataSourceId, MailboxAuthFailure failure, CancellationToken token);
/// <summary>
/// Clears a refused sign-in, when the user saved a new password, asked for another try, or
/// deleted the mailbox.
/// </summary>
/// <param name="dataSourceId">The mailbox.</param>
/// <param name="token">The cancellation token.</param>
public abstract Task ClearMailboxAuthFailureAsync(string dataSourceId, CancellationToken token);
/// <summary>
/// Lists every mailbox the index keeps something of: an index, a refused sign-in, or both.
/// </summary>
/// <remarks>
/// The refused sign-ins count on their own, since they outlive the index on purpose. What is
/// listed here for a mailbox which is no longer configured is left over, cf.
/// DataSourceEmbeddingService.DeleteOrphanedMailboxIndexesAsync.
/// </remarks>
/// <param name="token">The cancellation token.</param>
/// <returns>The ids of the mailboxes, each once.</returns>
public abstract Task<IReadOnlyCollection<string>> GetStoredMailboxIdsAsync(CancellationToken token);
}
@@ -2,7 +2,7 @@ using AIStudio.Tools.Services;
namespace AIStudio.Tools.Databases.IndexStore;
public abstract class IndexStoreClient(string name, string path) : DatabaseClient(name, path)
public abstract partial class IndexStoreClient(string name, string path) : DatabaseClient(name, path)
{
public abstract Task<DataSourceEmbeddingManifest> GetManifestAsync(string dataSourceId, CancellationToken token);
@@ -19,6 +19,20 @@ internal sealed class IndexStoreDbContext(DbContextOptions<IndexStoreDbContext>
public DbSet<IndexStoreSearchResultEntity> SearchResults => this.Set<IndexStoreSearchResultEntity>();
public DbSet<MailMessageEntity> MailMessages => this.Set<MailMessageEntity>();
public DbSet<MailAddressEntity> MailAddresses => this.Set<MailAddressEntity>();
public DbSet<MailPartEntity> MailParts => this.Set<MailPartEntity>();
public DbSet<MailFolderEntity> MailFolders => this.Set<MailFolderEntity>();
public DbSet<MailLocationEntity> MailLocations => this.Set<MailLocationEntity>();
public DbSet<MailboxSyncStateEntity> MailboxSyncStates => this.Set<MailboxSyncStateEntity>();
public DbSet<MailboxAuthStateEntity> MailboxAuthStates => this.Set<MailboxAuthStateEntity>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
var utcDateTimeOffsetConverter = new IndexStoreDateTimeOffsetConverter();
@@ -124,6 +138,171 @@ internal sealed class IndexStoreDbContext(DbContextOptions<IndexStoreDbContext>
entity.Property(result => result.LastWriteUtc).HasConversion(utcDateTimeOffsetConverter);
entity.Property(result => result.EmbeddedAtUtc).HasConversion(utcDateTimeOffsetConverter);
});
modelBuilder.Entity<MailMessageEntity>(entity =>
{
entity.ToTable("mail_messages");
entity.HasKey(mail => mail.ParentFileId);
entity.Property(mail => mail.ParentFileId).HasColumnName("parent_file_id");
entity.Property(mail => mail.DataSourceId).HasColumnName("data_source_id").IsRequired();
entity.Property(mail => mail.MessageId).HasColumnName("message_id").IsRequired();
entity.Property(mail => mail.InReplyTo).HasColumnName("in_reply_to").IsRequired();
entity.Property(mail => mail.ReferenceMessageIds).HasColumnName("reference_message_ids").IsRequired();
entity.Property(mail => mail.SentAtUtc).HasColumnName("sent_at_utc").HasConversion(utcDateTimeOffsetConverter);
entity.Property(mail => mail.ReceivedAtUtc).HasColumnName("received_at_utc").HasConversion(utcDateTimeOffsetConverter).IsRequired();
entity.Property(mail => mail.Importance).HasColumnName("importance").IsRequired();
entity.Property(mail => mail.EncryptionKind).HasColumnName("encryption_kind").IsRequired();
entity.Property(mail => mail.MailHash).HasColumnName("mail_hash").IsRequired();
entity.Property(mail => mail.FirstSeenUtc).HasColumnName("first_seen_utc").HasConversion(utcDateTimeOffsetConverter).IsRequired();
entity.Property(mail => mail.OrphanedAtUtc).HasColumnName("orphaned_at_utc").HasConversion(utcDateTimeOffsetConverter);
entity.HasIndex(mail => new { mail.DataSourceId, mail.ReceivedAtUtc }).HasDatabaseName("idx_mail_messages_data_source_received");
entity.HasIndex(mail => new { mail.DataSourceId, mail.MessageId }).HasDatabaseName("idx_mail_messages_data_source_message_id");
entity
.HasOne<EmbeddingStateFileEntity>()
.WithOne()
.HasForeignKey<MailMessageEntity>(mail => mail.ParentFileId)
.OnDelete(DeleteBehavior.Cascade);
entity
.HasOne<EmbeddingStateDataSourceEntity>()
.WithMany()
.HasForeignKey(mail => mail.DataSourceId)
.OnDelete(DeleteBehavior.Cascade);
entity
.HasMany(mail => mail.Addresses)
.WithOne(address => address.Message)
.HasForeignKey(address => address.ParentFileId)
.OnDelete(DeleteBehavior.Cascade);
entity
.HasMany(mail => mail.Parts)
.WithOne(part => part.Message)
.HasForeignKey(part => part.ParentFileId)
.OnDelete(DeleteBehavior.Cascade);
entity
.HasMany(mail => mail.Locations)
.WithOne(location => location.Message)
.HasForeignKey(location => location.ParentFileId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<MailAddressEntity>(entity =>
{
entity.ToTable("mail_addresses");
entity.HasKey(address => address.Id);
entity.Property(address => address.Id).HasColumnName("id").ValueGeneratedOnAdd();
entity.Property(address => address.ParentFileId).HasColumnName("parent_file_id").IsRequired();
entity.Property(address => address.Role).HasColumnName("role").IsRequired();
entity.Property(address => address.Position).HasColumnName("position");
entity.Property(address => address.Address).HasColumnName("address").UseCollation("NOCASE").IsRequired();
entity.Property(address => address.DisplayName).HasColumnName("display_name").IsRequired();
entity.HasIndex(address => new { address.ParentFileId, address.Role, address.Position }).HasDatabaseName("idx_mail_addresses_parent_file_role_position").IsUnique();
entity.HasIndex(address => address.Address).HasDatabaseName("idx_mail_addresses_address");
});
modelBuilder.Entity<MailPartEntity>(entity =>
{
entity.ToTable("mail_parts");
entity.HasKey(part => part.Id);
entity.Property(part => part.Id).HasColumnName("id").ValueGeneratedOnAdd();
entity.Property(part => part.ParentFileId).HasColumnName("parent_file_id").IsRequired();
entity.Property(part => part.Kind).HasColumnName("kind").IsRequired();
entity.Property(part => part.Position).HasColumnName("position");
entity.Property(part => part.Name).HasColumnName("name").IsRequired();
entity.Property(part => part.ContentType).HasColumnName("content_type").IsRequired();
entity.Property(part => part.PartSize).HasColumnName("part_size");
entity.Property(part => part.Text).HasColumnName("text");
entity.Property(part => part.TextState).HasColumnName("text_state").IsRequired();
entity.HasIndex(part => new { part.ParentFileId, part.Kind, part.Position }).HasDatabaseName("idx_mail_parts_parent_file_kind_position").IsUnique();
});
modelBuilder.Entity<MailFolderEntity>(entity =>
{
entity.ToTable("mail_folders");
entity.HasKey(folder => folder.Id);
entity.Property(folder => folder.Id).HasColumnName("id").ValueGeneratedOnAdd();
entity.Property(folder => folder.DataSourceId).HasColumnName("data_source_id").IsRequired();
entity.Property(folder => folder.Path).HasColumnName("path").IsRequired();
entity.Property(folder => folder.SpecialUse).HasColumnName("special_use").IsRequired();
entity.Property(folder => folder.UidValidity).HasColumnName("uid_validity");
entity.Property(folder => folder.UidNext).HasColumnName("uid_next");
entity.Property(folder => folder.HighestModSeq).HasColumnName("highest_mod_seq");
entity.Property(folder => folder.ServerMessageCount).HasColumnName("server_message_count");
entity.Property(folder => folder.ServerUnseenCount).HasColumnName("server_unseen_count");
entity.Property(folder => folder.InitialSyncCompletedUtc).HasColumnName("initial_sync_completed_utc").HasConversion(utcDateTimeOffsetConverter);
entity.HasIndex(folder => new { folder.DataSourceId, folder.Path }).HasDatabaseName("idx_mail_folders_data_source_path").IsUnique();
entity
.HasOne<EmbeddingStateDataSourceEntity>()
.WithMany()
.HasForeignKey(folder => folder.DataSourceId)
.OnDelete(DeleteBehavior.Cascade);
entity
.HasMany(folder => folder.Locations)
.WithOne(location => location.Folder)
.HasForeignKey(location => location.FolderId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<MailLocationEntity>(entity =>
{
entity.ToTable("mail_locations");
entity.HasKey(location => location.Id);
entity.Property(location => location.Id).HasColumnName("id").ValueGeneratedOnAdd();
entity.Property(location => location.ParentFileId).HasColumnName("parent_file_id").IsRequired();
entity.Property(location => location.FolderId).HasColumnName("folder_id");
entity.Property(location => location.Uid).HasColumnName("uid");
entity.Property(location => location.IsSeen).HasColumnName("is_seen");
entity.Property(location => location.IsFlagged).HasColumnName("is_flagged");
entity.Property(location => location.IsAnswered).HasColumnName("is_answered");
entity.HasIndex(location => new { location.FolderId, location.Uid }).HasDatabaseName("idx_mail_locations_folder_uid").IsUnique();
entity.HasIndex(location => location.ParentFileId).HasDatabaseName("idx_mail_locations_parent_file");
});
modelBuilder.Entity<MailboxSyncStateEntity>(entity =>
{
entity.ToTable("mailbox_sync_state");
entity.HasKey(state => state.DataSourceId);
entity.Property(state => state.DataSourceId).HasColumnName("data_source_id");
entity.Property(state => state.LastSyncCompletedUtc).HasColumnName("last_sync_completed_utc").HasConversion(utcDateTimeOffsetConverter);
entity.Property(state => state.PendingRemovalCount).HasColumnName("pending_removal_count");
entity.Property(state => state.PendingRemovalApprovedUtc).HasColumnName("pending_removal_approved_utc").HasConversion(utcDateTimeOffsetConverter);
entity
.HasOne<EmbeddingStateDataSourceEntity>()
.WithOne()
.HasForeignKey<MailboxSyncStateEntity>(state => state.DataSourceId)
.OnDelete(DeleteBehavior.Cascade);
});
//
// Without any relationship on purpose, cf. MailboxAuthStateEntity: a refused sign-in has
// to outlive a rebuild of the index.
//
modelBuilder.Entity<MailboxAuthStateEntity>(entity =>
{
entity.ToTable("mailbox_auth_state");
entity.HasKey(state => state.DataSourceId);
entity.Property(state => state.DataSourceId).HasColumnName("data_source_id");
entity.Property(state => state.FailedAtUtc).HasColumnName("failed_at_utc").HasConversion(utcDateTimeOffsetConverter).IsRequired();
entity.Property(state => state.FailureMessage).HasColumnName("failure_message").IsRequired();
});
}
private static string BuildConnectionString(string databasePath) => new SqliteConnectionStringBuilder
@@ -10,6 +10,7 @@ internal static class IndexStoreSchemaMigrator
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Migrations.PermanentIndexingFailures))]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Migrations.DropFileConfidenceLevel))]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Migrations.DropDataSourceName))]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Migrations.MailboxIndex))]
public static async Task MigrateAsync(IndexStoreDbContext context, CancellationToken token)
{
await context.Database.MigrateAsync(token);
@@ -0,0 +1,30 @@
namespace AIStudio.Tools.Databases.IndexStore;
/// <summary>
/// One address from the header of a mail.
/// </summary>
internal sealed class MailAddressEntity
{
public int Id { get; set; }
public string ParentFileId { get; set; } = string.Empty;
/// <summary>
/// The header the address comes from: FROM, SENDER, REPLY_TO, TO, CC or BCC, stored by name.
/// </summary>
public string Role { get; set; } = string.Empty;
/// <summary>
/// The place of the address within its header, starting at zero.
/// </summary>
public int Position { get; set; }
public string Address { get; set; } = string.Empty;
/// <summary>
/// The name shown next to the address, empty when the header gives none.
/// </summary>
public string DisplayName { get; set; } = string.Empty;
public MailMessageEntity? Message { get; set; }
}
@@ -0,0 +1,11 @@
using AIStudio.Tools.Mail;
namespace AIStudio.Tools.Databases.IndexStore;
/// <summary>
/// One address from the header of a mail.
/// </summary>
/// <param name="Role">The header the address comes from.</param>
/// <param name="Address">The address itself.</param>
/// <param name="DisplayName">The name shown next to it, empty when the header gives none.</param>
public sealed record MailAddressRecord(MailAddressRole Role, string Address, string DisplayName);
@@ -0,0 +1,9 @@
namespace AIStudio.Tools.Databases.IndexStore;
/// <summary>
/// The number of mails in one group, e.g., one folder or one sender.
/// </summary>
/// <param name="Key">The folder path or the sender address.</param>
/// <param name="DisplayName">A name the sender uses, empty for folders and for senders without one.</param>
/// <param name="Count">How many mails belong to the group.</param>
public sealed record MailCountGroup(string Key, string DisplayName, long Count);
@@ -0,0 +1,22 @@
namespace AIStudio.Tools.Databases.IndexStore;
/// <summary>
/// How to break down the number of mails in a mailbox.
/// </summary>
public enum MailCountGrouping
{
/// <summary>
/// Only the total.
/// </summary>
NONE,
/// <summary>
/// By the folders the mails lie in. A mail in two folders counts in both.
/// </summary>
FOLDER,
/// <summary>
/// By the address in the From header.
/// </summary>
SENDER,
}
@@ -0,0 +1,8 @@
namespace AIStudio.Tools.Databases.IndexStore;
/// <summary>
/// How many mails of a mailbox meet some conditions.
/// </summary>
/// <param name="TotalCount">How many mails meet them, each mail counted once.</param>
/// <param name="Groups">The largest groups, largest first; empty without a grouping.</param>
public sealed record MailCountResult(long TotalCount, IReadOnlyList<MailCountGroup> Groups);
@@ -0,0 +1,69 @@
using AIStudio.Tools.Mail;
namespace AIStudio.Tools.Databases.IndexStore;
/// <summary>
/// Conditions a mail has to meet. A condition left out holds for every mail.
/// </summary>
/// <remarks>
/// A mail counts as read, flagged or answered when it is so in any of its folders: whoever read
/// the copy in the inbox read the mail, even when another copy in an archive still says unread.
/// Mails which lost their last place on the server never match, since they are gone or about to
/// show up somewhere else.
/// </remarks>
public sealed record MailFilter
{
/// <summary>
/// A part of the address or the name of the sender, as the From or Sender header gives it.
/// </summary>
public string? From { get; init; }
/// <summary>
/// A part of the address or the name of a recipient, as the To, Cc or Bcc header gives it.
/// </summary>
public string? To { get; init; }
/// <summary>
/// Only mails which arrived at the server at this point in time or later.
/// </summary>
public DateTimeOffset? ReceivedSinceUtc { get; init; }
/// <summary>
/// Only mails which arrived at the server before this point in time.
/// </summary>
public DateTimeOffset? ReceivedBeforeUtc { get; init; }
public bool? IsUnread { get; init; }
public bool? IsFlagged { get; init; }
public bool? IsEncrypted { get; init; }
public MailImportance? Importance { get; init; }
public bool? HasAttachments { get; init; }
/// <summary>
/// Only mails which lie in one of these folders, given by their full paths.
/// </summary>
/// <remarks>
/// An empty collection matches no mail at all. It never turns into "any folder": a caller
/// whose folder names matched no folder must not get the whole mailbox back.
/// </remarks>
public IReadOnlyCollection<string>? FolderPaths { get; init; }
/// <summary>
/// Whether any condition is set at all.
/// </summary>
public bool HasConditions =>
!string.IsNullOrWhiteSpace(this.From)
|| !string.IsNullOrWhiteSpace(this.To)
|| this.ReceivedSinceUtc is not null
|| this.ReceivedBeforeUtc is not null
|| this.IsUnread is not null
|| this.IsFlagged is not null
|| this.IsEncrypted is not null
|| this.Importance is not null
|| this.HasAttachments is not null
|| this.FolderPaths is not null;
}
@@ -0,0 +1,9 @@
namespace AIStudio.Tools.Databases.IndexStore;
/// <summary>
/// The flags of a mail at one of its locations.
/// </summary>
/// <param name="IsSeen">Whether the mail was read (\Seen).</param>
/// <param name="IsFlagged">Whether the mail is flagged (\Flagged).</param>
/// <param name="IsAnswered">Whether the mail was answered (\Answered).</param>
public readonly record struct MailFlags(bool IsSeen, bool IsFlagged, bool IsAnswered);
@@ -0,0 +1,58 @@
namespace AIStudio.Tools.Databases.IndexStore;
/// <summary>
/// One folder of a mailbox and how far its sync got.
/// </summary>
internal sealed class MailFolderEntity
{
public int Id { get; set; }
public string DataSourceId { get; set; } = string.Empty;
/// <summary>
/// The full path of the folder, as the server names it.
/// </summary>
public string Path { get; set; } = string.Empty;
/// <summary>
/// The special use the server announces for the folder, e.g., SENT, or NONE, stored by name.
/// </summary>
public string SpecialUse { get; set; } = string.Empty;
/// <summary>
/// The UIDVALIDITY the stored locations of this folder belong to.
/// </summary>
/// <remarks>
/// When the server reports another one, every UID stored for this folder is void.
/// </remarks>
public long UidValidity { get; set; }
/// <summary>
/// The UIDNEXT at the end of the last complete pass over the folder, or null until one completed.
/// </summary>
public long? UidNext { get; set; }
/// <summary>
/// The HIGHESTMODSEQ at the end of the last complete pass over the folder, or null until one
/// completed, or when the server does not support CONDSTORE.
/// </summary>
public long? HighestModSeq { get; set; }
/// <summary>
/// How many mails the folder holds on the server (STATUS MESSAGES), or null when not read yet.
/// </summary>
public long? ServerMessageCount { get; set; }
/// <summary>
/// How many of them are unread (STATUS UNSEEN), or null when not read yet.
/// </summary>
public long? ServerUnseenCount { get; set; }
/// <summary>
/// When every mail of the folder which belongs into the index was in it for the first time, or
/// null while that first pass is still running.
/// </summary>
public DateTimeOffset? InitialSyncCompletedUtc { get; set; }
public List<MailLocationEntity> Locations { get; set; } = [];
}
@@ -0,0 +1,24 @@
using AIStudio.Tools.Mail;
namespace AIStudio.Tools.Databases.IndexStore;
/// <summary>
/// One folder of a mailbox and how far its sync got.
/// </summary>
/// <param name="Path">The full path of the folder, as the server names it.</param>
/// <param name="SpecialUse">What the server says the folder is for.</param>
/// <param name="UidValidity">The UIDVALIDITY the stored locations of this folder belong to.</param>
/// <param name="UidNext">The UIDNEXT at the end of the last complete pass, or null until one completed.</param>
/// <param name="HighestModSeq">The HIGHESTMODSEQ at the end of the last complete pass, or null until one completed or without CONDSTORE.</param>
/// <param name="ServerMessageCount">How many mails the folder holds on the server, or null when not read yet.</param>
/// <param name="ServerUnseenCount">How many of them are unread, or null when not read yet.</param>
/// <param name="InitialSyncCompletedUtc">When the first pass over the folder completed, or null while it runs.</param>
public sealed record MailFolderRecord(
string Path,
MailFolderSpecialUse SpecialUse,
long UidValidity,
long? UidNext,
long? HighestModSeq,
long? ServerMessageCount,
long? ServerUnseenCount,
DateTimeOffset? InitialSyncCompletedUtc);
@@ -0,0 +1,33 @@
namespace AIStudio.Tools.Databases.IndexStore;
/// <summary>
/// One place on the server where a mail lies.
/// </summary>
/// <remarks>
/// A mail can lie in more than one folder, and moving it changes only its locations, never its
/// embedding. The flags are kept here, not with the mail, because IMAP keeps them per folder: a
/// copy in another folder has flags of its own.
/// </remarks>
internal sealed class MailLocationEntity
{
public int Id { get; set; }
public string ParentFileId { get; set; } = string.Empty;
public int FolderId { get; set; }
/// <summary>
/// The UID of the mail within its folder, valid for the UIDVALIDITY of that folder.
/// </summary>
public long Uid { get; set; }
public bool IsSeen { get; set; }
public bool IsFlagged { get; set; }
public bool IsAnswered { get; set; }
public MailMessageEntity? Message { get; set; }
public MailFolderEntity? Folder { get; set; }
}
@@ -0,0 +1,9 @@
namespace AIStudio.Tools.Databases.IndexStore;
/// <summary>
/// One place on the server where a mail lies.
/// </summary>
/// <param name="FolderPath">The full path of the folder, as the server names it.</param>
/// <param name="Uid">The UID of the mail within that folder.</param>
/// <param name="Flags">The flags of the mail there.</param>
public sealed record MailLocationRecord(string FolderPath, long Uid, MailFlags Flags);
@@ -0,0 +1,87 @@
namespace AIStudio.Tools.Databases.IndexStore;
/// <summary>
/// What the index knows about one mail beyond its text, one row per mail and mailbox.
/// </summary>
/// <remarks>
/// The mail itself is a row in embedded_files, whose primary key this row shares. Indexing a mail
/// again deletes that row first, and the cascade takes this one with it. So this row is written
/// after the last chunk of a mail, never before.
/// </remarks>
internal sealed class MailMessageEntity
{
public string ParentFileId { get; set; } = string.Empty;
public string DataSourceId { get; set; } = string.Empty;
/// <summary>
/// The Message-ID header, empty when the mail has none.
/// </summary>
public string MessageId { get; set; } = string.Empty;
/// <summary>
/// The Message-ID the In-Reply-To header names, empty when there is none.
/// </summary>
public string InReplyTo { get; set; } = string.Empty;
/// <summary>
/// The Message-IDs of the References header, oldest first and separated by spaces, the way the
/// header itself lists them. A Message-ID cannot contain a space, so nothing gets lost.
/// </summary>
public string ReferenceMessageIds { get; set; } = string.Empty;
/// <summary>
/// When the sender says the mail was written (Date header), or null when it does not say so readably.
/// </summary>
public DateTimeOffset? SentAtUtc { get; set; }
/// <summary>
/// When the mail arrived at the server (INTERNALDATE).
/// </summary>
/// <remarks>
/// Unlike the Date header, the sender has no say in this one. It is also the date IMAP compares
/// against when the time range of a mailbox gets searched.
/// </remarks>
public DateTimeOffset ReceivedAtUtc { get; set; }
/// <summary>
/// LOW, NORMAL or HIGH, stored by name.
/// </summary>
public string Importance { get; set; } = string.Empty;
/// <summary>
/// How the content of the mail is encrypted, NONE when it is not, stored by name.
/// </summary>
/// <remarks>
/// There is no separate flag for whether a mail is encrypted: it would only be a second copy of
/// this column, free to disagree with it. A kind written by a newer version still is not NONE,
/// so such a mail still counts as encrypted.
/// </remarks>
public string EncryptionKind { get; set; } = string.Empty;
/// <summary>
/// A hash over the mail as the server delivered it, independent of how AI Studio turns it into text.
/// </summary>
public string MailHash { get; set; } = string.Empty;
/// <summary>
/// When AI Studio found this mail for the first time.
/// </summary>
public DateTimeOffset FirstSeenUtc { get; set; }
/// <summary>
/// When the mail lost its last location, or null while it has one.
/// </summary>
/// <remarks>
/// A mail which is moved disappears from one folder before it shows up in the other one, maybe
/// only in the next run. Removing it at once would embed it again when it reappears, so it stays
/// for one more run.
/// </remarks>
public DateTimeOffset? OrphanedAtUtc { get; set; }
public List<MailAddressEntity> Addresses { get; set; } = [];
public List<MailPartEntity> Parts { get; set; } = [];
public List<MailLocationEntity> Locations { get; set; } = [];
}
@@ -0,0 +1,49 @@
namespace AIStudio.Tools.Databases.IndexStore;
/// <summary>
/// One part of a mail as AI Studio read it: the header block, the body or an attachment.
/// </summary>
/// <remarks>
/// The chunks hold the same text, but cut into overlapping pieces. Reading a mail as a whole, or
/// checking its header block, needs it in one piece, and without asking the server again.
/// </remarks>
internal sealed class MailPartEntity
{
public int Id { get; set; }
public string ParentFileId { get; set; } = string.Empty;
/// <summary>
/// HEADERS, BODY or ATTACHMENT, stored by name.
/// </summary>
public string Kind { get; set; } = string.Empty;
/// <summary>
/// The place of the part among the parts of its kind, starting at zero.
/// </summary>
public int Position { get; set; }
/// <summary>
/// The file name of an attachment, empty for the other parts.
/// </summary>
public string Name { get; set; } = string.Empty;
public string ContentType { get; set; } = string.Empty;
/// <summary>
/// The size of the part on the server, in bytes.
/// </summary>
public long PartSize { get; set; }
/// <summary>
/// The text of the part, or null when there is none. TextState says why.
/// </summary>
public string? Text { get; set; }
/// <summary>
/// Whether the text could be read and, when not, why, stored by name.
/// </summary>
public string TextState { get; set; } = string.Empty;
public MailMessageEntity? Message { get; set; }
}
@@ -0,0 +1,14 @@
using AIStudio.Tools.Mail;
namespace AIStudio.Tools.Databases.IndexStore;
/// <summary>
/// One part of a mail as AI Studio read it.
/// </summary>
/// <param name="Kind">The header block, the body or an attachment.</param>
/// <param name="Name">The file name of an attachment, empty for the other parts.</param>
/// <param name="ContentType">The content type of the part.</param>
/// <param name="PartSize">The size of the part on the server, in bytes.</param>
/// <param name="Text">The text of the part, or null when there is none.</param>
/// <param name="TextState">Whether the text could be read and, when not, why.</param>
public sealed record MailPartRecord(MailPartKind Kind, string Name, string ContentType, long PartSize, string? Text, MailPartTextState TextState);
@@ -0,0 +1,38 @@
using AIStudio.Tools.Mail;
namespace AIStudio.Tools.Databases.IndexStore;
/// <summary>
/// What the index keeps about one mail beyond its chunks.
/// </summary>
/// <remarks>
/// The addresses come back grouped by their role and the parts by their kind, each group in the
/// order it was stored in. The locations come back ordered by folder and UID.
/// </remarks>
/// <param name="MailId">The id of the mail, which is the id of its document.</param>
/// <param name="MessageId">The Message-ID header, empty when the mail has none.</param>
/// <param name="InReplyTo">The Message-ID the In-Reply-To header names, empty when there is none.</param>
/// <param name="ReferenceMessageIds">The Message-IDs of the References header, oldest first.</param>
/// <param name="SentAtUtc">When the sender says the mail was written, or null when it does not say so readably.</param>
/// <param name="ReceivedAtUtc">When the mail arrived at the server.</param>
/// <param name="Importance">How important the sender marked the mail.</param>
/// <param name="EncryptionKind">How the content of the mail is encrypted, NONE when it is not.</param>
/// <param name="MailHash">A hash over the mail as the server delivered it.</param>
/// <param name="FirstSeenUtc">When AI Studio found the mail for the first time.</param>
/// <param name="Addresses">The addresses from the header of the mail.</param>
/// <param name="Parts">The parts of the mail as AI Studio read them.</param>
/// <param name="Locations">Where the mail lies on the server, at least one place.</param>
public sealed record MailRecord(
string MailId,
string MessageId,
string InReplyTo,
IReadOnlyList<string> ReferenceMessageIds,
DateTimeOffset? SentAtUtc,
DateTimeOffset ReceivedAtUtc,
MailImportance Importance,
MailEncryptionKind EncryptionKind,
string MailHash,
DateTimeOffset FirstSeenUtc,
IReadOnlyList<MailAddressRecord> Addresses,
IReadOnlyList<MailPartRecord> Parts,
IReadOnlyList<MailLocationRecord> Locations);
@@ -0,0 +1,32 @@
using AIStudio.Tools.Mail;
namespace AIStudio.Tools.Databases.IndexStore;
/// <summary>
/// What a list of mails shows about one of them, without its text.
/// </summary>
/// <param name="MailId">The id of the mail.</param>
/// <param name="Subject">The subject of the mail.</param>
/// <param name="ReceivedAtUtc">When the mail arrived at the server.</param>
/// <param name="SentAtUtc">When the sender says the mail was written, or null when it does not say so readably.</param>
/// <param name="MessageId">The Message-ID header, empty when the mail has none.</param>
/// <param name="InReplyTo">The Message-ID the In-Reply-To header names, empty when there is none.</param>
/// <param name="Addresses">The addresses from the header, grouped by their role.</param>
/// <param name="FolderPaths">The folders the mail lies in, ordered by path.</param>
/// <param name="Flags">The flags of the mail; each one is set when it is set in any folder.</param>
/// <param name="Importance">How important the sender marked the mail.</param>
/// <param name="EncryptionKind">How the content of the mail is encrypted, NONE when it is not.</param>
/// <param name="AttachmentNames">The file names of the attachments, in their order.</param>
public sealed record MailSummary(
string MailId,
string Subject,
DateTimeOffset ReceivedAtUtc,
DateTimeOffset? SentAtUtc,
string MessageId,
string InReplyTo,
IReadOnlyList<MailAddressRecord> Addresses,
IReadOnlyList<string> FolderPaths,
MailFlags Flags,
MailImportance Importance,
MailEncryptionKind EncryptionKind,
IReadOnlyList<string> AttachmentNames);
@@ -0,0 +1,29 @@
namespace AIStudio.Tools.Databases.IndexStore;
/// <summary>
/// A sign-in the server of a mailbox refused.
/// </summary>
/// <param name="FailedAtUtc">When the server refused the sign-in.</param>
/// <param name="FailureMessage">What the server answered, empty when it gave no reason.</param>
public sealed record MailboxAuthFailure(DateTimeOffset FailedAtUtc, string FailureMessage)
{
/// <summary>
/// How much of the answer of the server is kept. The reason is in the first sentence, if at all.
/// </summary>
public const int MAX_FAILURE_MESSAGE_LENGTH = 500;
/// <summary>
/// Records a sign-in the server refused just now, together with what it answered.
/// </summary>
/// <remarks>
/// The answer often says why, e.g., that the provider requires an app password. It may name
/// the user as well, which is why it is kept for the settings of the mailbox and never logged.
/// </remarks>
/// <param name="serverAnswer">What the server answered.</param>
/// <returns>The refused sign-in.</returns>
public static MailboxAuthFailure FromServerAnswer(string serverAnswer)
{
var trimmedAnswer = serverAnswer.Trim();
return new(DateTimeOffset.UtcNow, trimmedAnswer.Length > MAX_FAILURE_MESSAGE_LENGTH ? trimmedAnswer[..MAX_FAILURE_MESSAGE_LENGTH] : trimmedAnswer);
}
}
@@ -0,0 +1,21 @@
namespace AIStudio.Tools.Databases.IndexStore;
/// <summary>
/// A sign-in the server refused. While the row exists, AI Studio does not try to sign in again.
/// </summary>
/// <remarks>
/// The row has no foreign key on purpose. Rebuilding the index deletes the data source row and
/// everything which cascades from it, and a refused sign-in has to survive that: otherwise every
/// rebuild would try the refused password again, which is how a directory account gets locked.
/// </remarks>
internal sealed class MailboxAuthStateEntity
{
public string DataSourceId { get; set; } = string.Empty;
public DateTimeOffset FailedAtUtc { get; set; }
/// <summary>
/// What the server answered, empty when it gave no reason.
/// </summary>
public string FailureMessage { get; set; } = string.Empty;
}
@@ -0,0 +1,9 @@
namespace AIStudio.Tools.Databases.IndexStore;
/// <summary>
/// How the sync of a mailbox as a whole stands.
/// </summary>
/// <param name="LastSyncCompletedUtc">When the last complete sync ended, or null until one did.</param>
/// <param name="PendingRemovalCount">How many mails a sync would remove from the index at once, held back until the user decides, or null when nothing is held back.</param>
/// <param name="PendingRemovalApprovedUtc">When the user agreed to exactly that removal, or null while they did not.</param>
public sealed record MailboxSyncState(DateTimeOffset? LastSyncCompletedUtc, int? PendingRemovalCount, DateTimeOffset? PendingRemovalApprovedUtc);
@@ -0,0 +1,29 @@
namespace AIStudio.Tools.Databases.IndexStore;
/// <summary>
/// How the sync of a mailbox as a whole stands.
/// </summary>
internal sealed class MailboxSyncStateEntity
{
public string DataSourceId { get; set; } = string.Empty;
/// <summary>
/// When the last complete sync of the mailbox ended, or null until one did.
/// </summary>
public DateTimeOffset? LastSyncCompletedUtc { get; set; }
/// <summary>
/// How many mails a sync would remove from the index at once, held back until the user decides,
/// or null when nothing is held back.
/// </summary>
/// <remarks>
/// Nothing on the server is touched either way. But mails removed from the index have to be
/// embedded again should they come back, which costs time and, with a cloud provider, money.
/// </remarks>
public int? PendingRemovalCount { get; set; }
/// <summary>
/// When the user agreed to the held back removal, or null while they did not.
/// </summary>
public DateTimeOffset? PendingRemovalApprovedUtc { get; set; }
}
@@ -0,0 +1,251 @@
#nullable disable
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
namespace AIStudio.Tools.Databases.IndexStore.Migrations;
/// <summary>
/// Adds what the index keeps about mailboxes beyond the text of their mails.
/// </summary>
/// <remarks>
/// Every mail is a row in embedded_files like any document, so search, chunks and full-text index
/// stay the same for both. These tables hold what only a mail has: its addresses, its parts, the
/// folders and places it lies in, and how far the sync of each mailbox got. All of them go with
/// their data source, except mailbox_auth_state: a refused sign-in has to survive a rebuild.
/// </remarks>
[DbContext(typeof(IndexStoreDbContext))]
[Migration("20261001000000_MailboxIndex")]
public partial class MailboxIndex : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "mail_messages",
columns: table => new
{
parent_file_id = table.Column<string>(type: "TEXT", nullable: false),
data_source_id = table.Column<string>(type: "TEXT", nullable: false),
message_id = table.Column<string>(type: "TEXT", nullable: false),
in_reply_to = table.Column<string>(type: "TEXT", nullable: false),
reference_message_ids = table.Column<string>(type: "TEXT", nullable: false),
sent_at_utc = table.Column<string>(type: "TEXT", nullable: true),
received_at_utc = table.Column<string>(type: "TEXT", nullable: false),
importance = table.Column<string>(type: "TEXT", nullable: false),
encryption_kind = table.Column<string>(type: "TEXT", nullable: false),
mail_hash = table.Column<string>(type: "TEXT", nullable: false),
first_seen_utc = table.Column<string>(type: "TEXT", nullable: false),
orphaned_at_utc = table.Column<string>(type: "TEXT", nullable: true),
},
constraints: table =>
{
table.PrimaryKey("PK_mail_messages", mail => mail.parent_file_id);
table.ForeignKey(
name: "FK_mail_messages_data_sources_data_source_id",
column: mail => mail.data_source_id,
principalTable: "data_sources",
principalColumn: "data_source_id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_mail_messages_embedded_files_parent_file_id",
column: mail => mail.parent_file_id,
principalTable: "embedded_files",
principalColumn: "parent_file_id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "mail_folders",
columns: table => new
{
id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
data_source_id = table.Column<string>(type: "TEXT", nullable: false),
path = table.Column<string>(type: "TEXT", nullable: false),
special_use = table.Column<string>(type: "TEXT", nullable: false),
uid_validity = table.Column<long>(type: "INTEGER", nullable: false),
uid_next = table.Column<long>(type: "INTEGER", nullable: true),
highest_mod_seq = table.Column<long>(type: "INTEGER", nullable: true),
server_message_count = table.Column<long>(type: "INTEGER", nullable: true),
server_unseen_count = table.Column<long>(type: "INTEGER", nullable: true),
initial_sync_completed_utc = table.Column<string>(type: "TEXT", nullable: true),
},
constraints: table =>
{
table.PrimaryKey("PK_mail_folders", folder => folder.id);
table.ForeignKey(
name: "FK_mail_folders_data_sources_data_source_id",
column: folder => folder.data_source_id,
principalTable: "data_sources",
principalColumn: "data_source_id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "mail_addresses",
columns: table => new
{
id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
parent_file_id = table.Column<string>(type: "TEXT", nullable: false),
role = table.Column<string>(type: "TEXT", nullable: false),
position = table.Column<int>(type: "INTEGER", nullable: false),
address = table.Column<string>(type: "TEXT", nullable: false, collation: "NOCASE"),
display_name = table.Column<string>(type: "TEXT", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_mail_addresses", address => address.id);
table.ForeignKey(
name: "FK_mail_addresses_mail_messages_parent_file_id",
column: address => address.parent_file_id,
principalTable: "mail_messages",
principalColumn: "parent_file_id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "mail_parts",
columns: table => new
{
id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
parent_file_id = table.Column<string>(type: "TEXT", nullable: false),
kind = table.Column<string>(type: "TEXT", nullable: false),
position = table.Column<int>(type: "INTEGER", nullable: false),
name = table.Column<string>(type: "TEXT", nullable: false),
content_type = table.Column<string>(type: "TEXT", nullable: false),
part_size = table.Column<long>(type: "INTEGER", nullable: false),
text = table.Column<string>(type: "TEXT", nullable: true),
text_state = table.Column<string>(type: "TEXT", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_mail_parts", part => part.id);
table.ForeignKey(
name: "FK_mail_parts_mail_messages_parent_file_id",
column: part => part.parent_file_id,
principalTable: "mail_messages",
principalColumn: "parent_file_id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "mail_locations",
columns: table => new
{
id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
parent_file_id = table.Column<string>(type: "TEXT", nullable: false),
folder_id = table.Column<int>(type: "INTEGER", nullable: false),
uid = table.Column<long>(type: "INTEGER", nullable: false),
is_seen = table.Column<bool>(type: "INTEGER", nullable: false),
is_flagged = table.Column<bool>(type: "INTEGER", nullable: false),
is_answered = table.Column<bool>(type: "INTEGER", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_mail_locations", location => location.id);
table.ForeignKey(
name: "FK_mail_locations_mail_folders_folder_id",
column: location => location.folder_id,
principalTable: "mail_folders",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_mail_locations_mail_messages_parent_file_id",
column: location => location.parent_file_id,
principalTable: "mail_messages",
principalColumn: "parent_file_id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "mailbox_sync_state",
columns: table => new
{
data_source_id = table.Column<string>(type: "TEXT", nullable: false),
last_sync_completed_utc = table.Column<string>(type: "TEXT", nullable: true),
pending_removal_count = table.Column<int>(type: "INTEGER", nullable: true),
pending_removal_approved_utc = table.Column<string>(type: "TEXT", nullable: true),
},
constraints: table =>
{
table.PrimaryKey("PK_mailbox_sync_state", state => state.data_source_id);
table.ForeignKey(
name: "FK_mailbox_sync_state_data_sources_data_source_id",
column: state => state.data_source_id,
principalTable: "data_sources",
principalColumn: "data_source_id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "mailbox_auth_state",
columns: table => new
{
data_source_id = table.Column<string>(type: "TEXT", nullable: false),
failed_at_utc = table.Column<string>(type: "TEXT", nullable: false),
failure_message = table.Column<string>(type: "TEXT", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_mailbox_auth_state", state => state.data_source_id);
});
migrationBuilder.CreateIndex(
name: "idx_mail_messages_data_source_message_id",
table: "mail_messages",
columns: ["data_source_id", "message_id"]);
migrationBuilder.CreateIndex(
name: "idx_mail_messages_data_source_received",
table: "mail_messages",
columns: ["data_source_id", "received_at_utc"]);
migrationBuilder.CreateIndex(
name: "idx_mail_folders_data_source_path",
table: "mail_folders",
columns: ["data_source_id", "path"],
unique: true);
migrationBuilder.CreateIndex(
name: "idx_mail_addresses_address",
table: "mail_addresses",
column: "address");
migrationBuilder.CreateIndex(
name: "idx_mail_addresses_parent_file_role_position",
table: "mail_addresses",
columns: ["parent_file_id", "role", "position"],
unique: true);
migrationBuilder.CreateIndex(
name: "idx_mail_parts_parent_file_kind_position",
table: "mail_parts",
columns: ["parent_file_id", "kind", "position"],
unique: true);
migrationBuilder.CreateIndex(
name: "idx_mail_locations_folder_uid",
table: "mail_locations",
columns: ["folder_id", "uid"],
unique: true);
migrationBuilder.CreateIndex(
name: "idx_mail_locations_parent_file",
table: "mail_locations",
column: "parent_file_id");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "mailbox_auth_state");
migrationBuilder.DropTable(name: "mailbox_sync_state");
migrationBuilder.DropTable(name: "mail_locations");
migrationBuilder.DropTable(name: "mail_parts");
migrationBuilder.DropTable(name: "mail_addresses");
migrationBuilder.DropTable(name: "mail_folders");
migrationBuilder.DropTable(name: "mail_messages");
}
}
@@ -315,6 +315,325 @@ partial class IndexStoreDbContextModelSnapshot : ModelSnapshot
entity.ToView("embedding_chunk_search_results");
});
modelBuilder.Entity("AIStudio.Tools.Databases.IndexStore.MailAddressEntity", entity =>
{
entity.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasColumnName("id")
.HasAnnotation("Sqlite:Autoincrement", true);
entity.Property<string>("Address")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("address")
.UseCollation("NOCASE");
entity.Property<string>("DisplayName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("display_name");
entity.Property<string>("ParentFileId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("parent_file_id");
entity.Property<int>("Position")
.HasColumnType("INTEGER")
.HasColumnName("position");
entity.Property<string>("Role")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("role");
entity.HasKey("Id");
entity.HasIndex("Address")
.HasDatabaseName("idx_mail_addresses_address");
entity.HasIndex("ParentFileId", "Role", "Position")
.IsUnique()
.HasDatabaseName("idx_mail_addresses_parent_file_role_position");
entity.ToTable("mail_addresses");
});
modelBuilder.Entity("AIStudio.Tools.Databases.IndexStore.MailFolderEntity", entity =>
{
entity.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasColumnName("id")
.HasAnnotation("Sqlite:Autoincrement", true);
entity.Property<string>("DataSourceId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("data_source_id");
entity.Property<long?>("HighestModSeq")
.HasColumnType("INTEGER")
.HasColumnName("highest_mod_seq");
entity.Property<DateTimeOffset?>("InitialSyncCompletedUtc")
.HasConversion(utcDateTimeOffsetConverter)
.HasColumnType("TEXT")
.HasColumnName("initial_sync_completed_utc");
entity.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("path");
entity.Property<long?>("ServerMessageCount")
.HasColumnType("INTEGER")
.HasColumnName("server_message_count");
entity.Property<long?>("ServerUnseenCount")
.HasColumnType("INTEGER")
.HasColumnName("server_unseen_count");
entity.Property<string>("SpecialUse")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("special_use");
entity.Property<long?>("UidNext")
.HasColumnType("INTEGER")
.HasColumnName("uid_next");
entity.Property<long>("UidValidity")
.HasColumnType("INTEGER")
.HasColumnName("uid_validity");
entity.HasKey("Id");
entity.HasIndex("DataSourceId", "Path")
.IsUnique()
.HasDatabaseName("idx_mail_folders_data_source_path");
entity.ToTable("mail_folders");
});
modelBuilder.Entity("AIStudio.Tools.Databases.IndexStore.MailLocationEntity", entity =>
{
entity.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasColumnName("id")
.HasAnnotation("Sqlite:Autoincrement", true);
entity.Property<int>("FolderId")
.HasColumnType("INTEGER")
.HasColumnName("folder_id");
entity.Property<bool>("IsAnswered")
.HasColumnType("INTEGER")
.HasColumnName("is_answered");
entity.Property<bool>("IsFlagged")
.HasColumnType("INTEGER")
.HasColumnName("is_flagged");
entity.Property<bool>("IsSeen")
.HasColumnType("INTEGER")
.HasColumnName("is_seen");
entity.Property<string>("ParentFileId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("parent_file_id");
entity.Property<long>("Uid")
.HasColumnType("INTEGER")
.HasColumnName("uid");
entity.HasKey("Id");
entity.HasIndex("ParentFileId")
.HasDatabaseName("idx_mail_locations_parent_file");
entity.HasIndex("FolderId", "Uid")
.IsUnique()
.HasDatabaseName("idx_mail_locations_folder_uid");
entity.ToTable("mail_locations");
});
modelBuilder.Entity("AIStudio.Tools.Databases.IndexStore.MailMessageEntity", entity =>
{
entity.Property<string>("ParentFileId")
.HasColumnType("TEXT")
.HasColumnName("parent_file_id");
entity.Property<string>("DataSourceId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("data_source_id");
entity.Property<string>("EncryptionKind")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("encryption_kind");
entity.Property<DateTimeOffset>("FirstSeenUtc")
.HasConversion(utcDateTimeOffsetConverter)
.HasColumnType("TEXT")
.HasColumnName("first_seen_utc");
entity.Property<string>("Importance")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("importance");
entity.Property<string>("InReplyTo")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("in_reply_to");
entity.Property<string>("MailHash")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("mail_hash");
entity.Property<string>("MessageId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("message_id");
entity.Property<DateTimeOffset?>("OrphanedAtUtc")
.HasConversion(utcDateTimeOffsetConverter)
.HasColumnType("TEXT")
.HasColumnName("orphaned_at_utc");
entity.Property<DateTimeOffset>("ReceivedAtUtc")
.HasConversion(utcDateTimeOffsetConverter)
.HasColumnType("TEXT")
.HasColumnName("received_at_utc");
entity.Property<string>("ReferenceMessageIds")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("reference_message_ids");
entity.Property<DateTimeOffset?>("SentAtUtc")
.HasConversion(utcDateTimeOffsetConverter)
.HasColumnType("TEXT")
.HasColumnName("sent_at_utc");
entity.HasKey("ParentFileId");
entity.HasIndex("DataSourceId", "MessageId")
.HasDatabaseName("idx_mail_messages_data_source_message_id");
entity.HasIndex("DataSourceId", "ReceivedAtUtc")
.HasDatabaseName("idx_mail_messages_data_source_received");
entity.ToTable("mail_messages");
});
modelBuilder.Entity("AIStudio.Tools.Databases.IndexStore.MailPartEntity", entity =>
{
entity.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasColumnName("id")
.HasAnnotation("Sqlite:Autoincrement", true);
entity.Property<string>("ContentType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("content_type");
entity.Property<string>("Kind")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("kind");
entity.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
entity.Property<string>("ParentFileId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("parent_file_id");
entity.Property<long>("PartSize")
.HasColumnType("INTEGER")
.HasColumnName("part_size");
entity.Property<int>("Position")
.HasColumnType("INTEGER")
.HasColumnName("position");
entity.Property<string>("Text")
.HasColumnType("TEXT")
.HasColumnName("text");
entity.Property<string>("TextState")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("text_state");
entity.HasKey("Id");
entity.HasIndex("ParentFileId", "Kind", "Position")
.IsUnique()
.HasDatabaseName("idx_mail_parts_parent_file_kind_position");
entity.ToTable("mail_parts");
});
modelBuilder.Entity("AIStudio.Tools.Databases.IndexStore.MailboxAuthStateEntity", entity =>
{
entity.Property<string>("DataSourceId")
.HasColumnType("TEXT")
.HasColumnName("data_source_id");
entity.Property<DateTimeOffset>("FailedAtUtc")
.HasConversion(utcDateTimeOffsetConverter)
.HasColumnType("TEXT")
.HasColumnName("failed_at_utc");
entity.Property<string>("FailureMessage")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("failure_message");
entity.HasKey("DataSourceId");
entity.ToTable("mailbox_auth_state");
});
modelBuilder.Entity("AIStudio.Tools.Databases.IndexStore.MailboxSyncStateEntity", entity =>
{
entity.Property<string>("DataSourceId")
.HasColumnType("TEXT")
.HasColumnName("data_source_id");
entity.Property<DateTimeOffset?>("LastSyncCompletedUtc")
.HasConversion(utcDateTimeOffsetConverter)
.HasColumnType("TEXT")
.HasColumnName("last_sync_completed_utc");
entity.Property<DateTimeOffset?>("PendingRemovalApprovedUtc")
.HasConversion(utcDateTimeOffsetConverter)
.HasColumnType("TEXT")
.HasColumnName("pending_removal_approved_utc");
entity.Property<int?>("PendingRemovalCount")
.HasColumnType("INTEGER")
.HasColumnName("pending_removal_count");
entity.HasKey("DataSourceId");
entity.ToTable("mailbox_sync_state");
});
modelBuilder.Entity("AIStudio.Tools.Databases.IndexStore.EmbeddingStateFileEntity", entity =>
{
entity.HasOne("AIStudio.Tools.Databases.IndexStore.EmbeddingStateDataSourceEntity", "DataSource")
@@ -348,6 +667,80 @@ partial class IndexStoreDbContextModelSnapshot : ModelSnapshot
entity.Navigation("DataSource");
});
modelBuilder.Entity("AIStudio.Tools.Databases.IndexStore.MailAddressEntity", entity =>
{
entity.HasOne("AIStudio.Tools.Databases.IndexStore.MailMessageEntity", "Message")
.WithMany("Addresses")
.HasForeignKey("ParentFileId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
entity.Navigation("Message");
});
modelBuilder.Entity("AIStudio.Tools.Databases.IndexStore.MailFolderEntity", entity =>
{
entity.HasOne("AIStudio.Tools.Databases.IndexStore.EmbeddingStateDataSourceEntity", null)
.WithMany()
.HasForeignKey("DataSourceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("AIStudio.Tools.Databases.IndexStore.MailLocationEntity", entity =>
{
entity.HasOne("AIStudio.Tools.Databases.IndexStore.MailFolderEntity", "Folder")
.WithMany("Locations")
.HasForeignKey("FolderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
entity.HasOne("AIStudio.Tools.Databases.IndexStore.MailMessageEntity", "Message")
.WithMany("Locations")
.HasForeignKey("ParentFileId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
entity.Navigation("Folder");
entity.Navigation("Message");
});
modelBuilder.Entity("AIStudio.Tools.Databases.IndexStore.MailMessageEntity", entity =>
{
entity.HasOne("AIStudio.Tools.Databases.IndexStore.EmbeddingStateDataSourceEntity", null)
.WithMany()
.HasForeignKey("DataSourceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
entity.HasOne("AIStudio.Tools.Databases.IndexStore.EmbeddingStateFileEntity", null)
.WithOne()
.HasForeignKey("AIStudio.Tools.Databases.IndexStore.MailMessageEntity", "ParentFileId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("AIStudio.Tools.Databases.IndexStore.MailPartEntity", entity =>
{
entity.HasOne("AIStudio.Tools.Databases.IndexStore.MailMessageEntity", "Message")
.WithMany("Parts")
.HasForeignKey("ParentFileId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
entity.Navigation("Message");
});
modelBuilder.Entity("AIStudio.Tools.Databases.IndexStore.MailboxSyncStateEntity", entity =>
{
entity.HasOne("AIStudio.Tools.Databases.IndexStore.EmbeddingStateDataSourceEntity", null)
.WithOne()
.HasForeignKey("AIStudio.Tools.Databases.IndexStore.MailboxSyncStateEntity", "DataSourceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("AIStudio.Tools.Databases.IndexStore.EmbeddingStateDataSourceEntity", entity =>
{
entity.Navigation("Files");
@@ -359,6 +752,20 @@ partial class IndexStoreDbContextModelSnapshot : ModelSnapshot
{
entity.Navigation("Chunks");
});
modelBuilder.Entity("AIStudio.Tools.Databases.IndexStore.MailFolderEntity", entity =>
{
entity.Navigation("Locations");
});
modelBuilder.Entity("AIStudio.Tools.Databases.IndexStore.MailMessageEntity", entity =>
{
entity.Navigation("Addresses");
entity.Navigation("Locations");
entity.Navigation("Parts");
});
#pragma warning restore 612, 618
}
}
@@ -0,0 +1,17 @@
namespace AIStudio.Tools.Databases.IndexStore;
public sealed partial class NoIndexStoreClient
{
public override Task<IReadOnlyList<string>> QueryMailsAsync(string dataSourceId, MailFilter filter, int offset, int limit, CancellationToken token) => Task.FromResult<IReadOnlyList<string>>([]);
public override Task<IReadOnlyList<string>> GetMailChunkIdsAsync(string dataSourceId, MailFilter filter, CancellationToken token) => Task.FromResult<IReadOnlyList<string>>([]);
public override Task<IReadOnlyList<IndexStoreSearchResult>> SearchMailChunksAsync(string dataSourceId, string query, MailFilter filter, int maxMatches, CancellationToken token) =>
Task.FromResult<IReadOnlyList<IndexStoreSearchResult>>([]);
public override Task<MailCountResult> CountMailsAsync(string dataSourceId, MailFilter filter, MailCountGrouping grouping, int maxGroups, CancellationToken token) => Task.FromResult(new MailCountResult(0, []));
public override Task<IReadOnlyList<MailSummary>> GetMailSummariesAsync(string dataSourceId, IReadOnlyList<string> mailIds, CancellationToken token) => Task.FromResult<IReadOnlyList<MailSummary>>([]);
public override Task<string?> FindMailByMessageIdAsync(string dataSourceId, string messageId, CancellationToken token) => Task.FromResult<string?>(null);
}
@@ -0,0 +1,41 @@
namespace AIStudio.Tools.Databases.IndexStore;
public sealed partial class NoIndexStoreClient
{
public override Task<IReadOnlyList<MailFolderRecord>> GetMailFoldersAsync(string dataSourceId, CancellationToken token) => Task.FromResult<IReadOnlyList<MailFolderRecord>>([]);
public override Task UpsertMailFolderAsync(string dataSourceId, MailFolderRecord folder, CancellationToken token) => Task.CompletedTask;
public override Task DeleteMailFolderAsync(string dataSourceId, string folderPath, CancellationToken token) => Task.CompletedTask;
public override Task UpsertMailAsync(string dataSourceId, MailRecord mail, CancellationToken token) => Task.CompletedTask;
public override Task<MailRecord?> GetMailAsync(string dataSourceId, string mailId, CancellationToken token) => Task.FromResult<MailRecord?>(null);
public override Task<bool> AddMailLocationAsync(string dataSourceId, string mailId, MailLocationRecord location, CancellationToken token) => Task.FromResult(false);
public override Task RemoveMailLocationsAsync(string dataSourceId, string folderPath, IReadOnlyCollection<long> uids, CancellationToken token) => Task.CompletedTask;
public override Task<IReadOnlyDictionary<long, MailFlags>> GetMailLocationsAsync(string dataSourceId, string folderPath, CancellationToken token) =>
Task.FromResult<IReadOnlyDictionary<long, MailFlags>>(new Dictionary<long, MailFlags>());
public override Task UpdateMailFlagsAsync(string dataSourceId, string folderPath, IReadOnlyDictionary<long, MailFlags> flagsByUid, CancellationToken token) => Task.CompletedTask;
public override Task<IReadOnlyList<string>> GetOrphanedMailsAsync(string dataSourceId, DateTimeOffset orphanedBefore, CancellationToken token) => Task.FromResult<IReadOnlyList<string>>([]);
public override Task<MailboxSyncState> GetMailboxSyncStateAsync(string dataSourceId, CancellationToken token) => Task.FromResult(new MailboxSyncState(null, null, null));
public override Task HoldBackMailRemovalAsync(string dataSourceId, int removalCount, CancellationToken token) => Task.CompletedTask;
public override Task<bool> ApprovePendingMailRemovalAsync(string dataSourceId, int removalCount, CancellationToken token) => Task.FromResult(false);
public override Task CompleteMailboxSyncAsync(string dataSourceId, DateTimeOffset completedUtc, CancellationToken token) => Task.CompletedTask;
public override Task<MailboxAuthFailure?> GetMailboxAuthFailureAsync(string dataSourceId, CancellationToken token) => Task.FromResult<MailboxAuthFailure?>(null);
public override Task UpsertMailboxAuthFailureAsync(string dataSourceId, MailboxAuthFailure failure, CancellationToken token) => Task.CompletedTask;
public override Task ClearMailboxAuthFailureAsync(string dataSourceId, CancellationToken token) => Task.CompletedTask;
public override Task<IReadOnlyCollection<string>> GetStoredMailboxIdsAsync(CancellationToken token) => Task.FromResult<IReadOnlyCollection<string>>([]);
}
@@ -3,7 +3,7 @@ using AIStudio.Tools.Services;
namespace AIStudio.Tools.Databases.IndexStore;
public sealed class NoIndexStoreClient(string name, string? unavailableReason, DatabaseClientStatus status = DatabaseClientStatus.UNAVAILABLE) : IndexStoreClient(name, string.Empty)
public sealed partial class NoIndexStoreClient(string name, string? unavailableReason, DatabaseClientStatus status = DatabaseClientStatus.UNAVAILABLE) : IndexStoreClient(name, string.Empty)
{
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(NoIndexStoreClient).Namespace, nameof(NoIndexStoreClient));
@@ -0,0 +1,254 @@
using AIStudio.Tools.Mail;
using Microsoft.EntityFrameworkCore;
namespace AIStudio.Tools.Databases.IndexStore;
public sealed partial class SqliteIndexStoreClientImplementation
{
private const string LIKE_ESCAPE = "\\";
private const string FROM_ROLE = nameof(MailAddressRole.FROM);
private const string ATTACHMENT_KIND = nameof(MailPartKind.ATTACHMENT);
private const string NOT_ENCRYPTED = nameof(MailEncryptionKind.NONE);
private static readonly string[] SENDER_ROLES = [nameof(MailAddressRole.FROM), nameof(MailAddressRole.SENDER)];
private static readonly string[] RECIPIENT_ROLES = [nameof(MailAddressRole.TO), nameof(MailAddressRole.CC), nameof(MailAddressRole.BCC)];
public override async Task<IReadOnlyList<string>> QueryMailsAsync(string dataSourceId, MailFilter filter, int offset, int limit, CancellationToken token)
{
ArgumentOutOfRangeException.ThrowIfNegative(offset);
if (limit <= 0)
return [];
await using var context = this.CreateContext();
return await FilterMails(context, dataSourceId, filter)
.OrderByDescending(mail => mail.ReceivedAtUtc)
.ThenBy(mail => mail.ParentFileId)
.Skip(offset)
.Take(limit)
.Select(mail => mail.ParentFileId)
.ToListAsync(token);
}
public override async Task<IReadOnlyList<string>> GetMailChunkIdsAsync(string dataSourceId, MailFilter filter, CancellationToken token)
{
await using var context = this.CreateContext();
var mailIds = FilterMails(context, dataSourceId, filter).Select(mail => mail.ParentFileId);
return await context.EmbeddingChunks
.AsNoTracking()
.Where(chunk => mailIds.Contains(chunk.ParentFileId))
.Select(chunk => chunk.ChunkId)
.ToListAsync(token);
}
public override async Task<IReadOnlyList<IndexStoreSearchResult>> SearchMailChunksAsync(string dataSourceId, string query, MailFilter filter, int maxMatches, CancellationToken token)
{
if (maxMatches <= 0)
return [];
var ftsQuery = BuildFtsQuery(query);
if (string.IsNullOrWhiteSpace(ftsQuery))
return [];
await using var context = this.CreateContext();
var mailIds = FilterMails(context, dataSourceId, filter).Select(mail => mail.ParentFileId);
var results = await InSearchOrder(MatchChunks(context, dataSourceId, ftsQuery).Where(result => mailIds.Contains(result.ParentFileId)))
.Take(maxMatches)
.ToListAsync(token);
return results.Select(ToSearchResult).ToList();
}
public override async Task<MailCountResult> CountMailsAsync(string dataSourceId, MailFilter filter, MailCountGrouping grouping, int maxGroups, CancellationToken token)
{
await using var context = this.CreateContext();
var mails = FilterMails(context, dataSourceId, filter);
var totalCount = await mails.LongCountAsync(token);
if (totalCount == 0 || maxGroups <= 0 || grouping is MailCountGrouping.NONE)
return new MailCountResult(totalCount, []);
//
// A mail can lie twice in one folder, as two copies under two UIDs, and it can name the same
// sender in From twice. Either way it is one mail, so the groups count distinct mails:
//
var mailIds = mails.Select(mail => mail.ParentFileId);
var groupQuery = grouping switch
{
MailCountGrouping.FOLDER => context.MailLocations
.Where(location => mailIds.Contains(location.ParentFileId))
.GroupBy(location => location.Folder!.Path)
.Select(group => new { group.Key, DisplayName = (string?)string.Empty, Count = group.Select(location => location.ParentFileId).Distinct().LongCount() }),
MailCountGrouping.SENDER => context.MailAddresses
.Where(address => address.Role == FROM_ROLE && mailIds.Contains(address.ParentFileId))
.GroupBy(address => address.Address)
.Select(group => new { group.Key, DisplayName = group.Max(address => address.DisplayName), Count = group.Select(address => address.ParentFileId).Distinct().LongCount() }),
_ => throw new ArgumentOutOfRangeException(nameof(grouping), grouping, "There is no such grouping."),
};
var groups = await groupQuery
.OrderByDescending(group => group.Count)
.ThenBy(group => group.Key)
.Take(maxGroups)
.ToListAsync(token);
return new MailCountResult(totalCount, groups.Select(group => new MailCountGroup(group.Key, group.DisplayName ?? string.Empty, group.Count)).ToList());
}
public override async Task<IReadOnlyList<MailSummary>> GetMailSummariesAsync(string dataSourceId, IReadOnlyList<string> mailIds, CancellationToken token)
{
if (mailIds.Count == 0)
return [];
var wantedIds = mailIds.Distinct(StringComparer.Ordinal).ToArray();
await using var context = this.CreateContext();
var mails = await context.MailMessages
.AsNoTracking()
.Where(mail => mail.DataSourceId == dataSourceId && mail.OrphanedAtUtc == null && wantedIds.Contains(mail.ParentFileId))
.ToDictionaryAsync(mail => mail.ParentFileId, StringComparer.Ordinal, token);
if (mails.Count == 0)
return [];
var foundIds = mails.Keys.ToArray();
var subjects = await context.EmbeddedFiles
.AsNoTracking()
.Where(file => foundIds.Contains(file.ParentFileId))
.ToDictionaryAsync(file => file.ParentFileId, file => file.FileName, StringComparer.Ordinal, token);
var addresses = (await context.MailAddresses
.AsNoTracking()
.Where(address => foundIds.Contains(address.ParentFileId))
.ToListAsync(token))
.ToLookup(address => address.ParentFileId, StringComparer.Ordinal);
var locations = (await context.MailLocations
.AsNoTracking()
.Where(location => foundIds.Contains(location.ParentFileId))
.Select(location => new { location.ParentFileId, location.Folder!.Path, location.IsSeen, location.IsFlagged, location.IsAnswered })
.ToListAsync(token))
.ToLookup(location => location.ParentFileId, StringComparer.Ordinal);
var attachmentNames = (await context.MailParts
.AsNoTracking()
.Where(part => foundIds.Contains(part.ParentFileId) && part.Kind == ATTACHMENT_KIND)
.OrderBy(part => part.Position)
.Select(part => new { part.ParentFileId, part.Name })
.ToListAsync(token))
.ToLookup(part => part.ParentFileId, part => part.Name, StringComparer.Ordinal);
return wantedIds
.Where(mails.ContainsKey)
.Select(mailId =>
{
var mail = mails[mailId];
var mailLocations = locations[mailId].ToList();
return new MailSummary(
mailId,
subjects.GetValueOrDefault(mailId, string.Empty),
mail.ReceivedAtUtc,
mail.SentAtUtc,
mail.MessageId,
mail.InReplyTo,
ToMailAddressRecords(addresses[mailId]),
mailLocations.Select(location => location.Path).Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal).ToList(),
new MailFlags(mailLocations.Any(location => location.IsSeen), mailLocations.Any(location => location.IsFlagged), mailLocations.Any(location => location.IsAnswered)),
ParseStoredName(mail.Importance, MailImportance.NORMAL),
ParseStoredName(mail.EncryptionKind, MailEncryptionKind.UNKNOWN),
attachmentNames[mailId].ToList());
})
.ToList();
}
public override async Task<string?> FindMailByMessageIdAsync(string dataSourceId, string messageId, CancellationToken token)
{
if (string.IsNullOrWhiteSpace(messageId))
return null;
await using var context = this.CreateContext();
return await context.MailMessages
.AsNoTracking()
.Where(mail => mail.DataSourceId == dataSourceId && mail.OrphanedAtUtc == null && mail.MessageId == messageId)
.OrderBy(mail => mail.ReceivedAtUtc)
.ThenBy(mail => mail.ParentFileId)
.Select(mail => mail.ParentFileId)
.FirstOrDefaultAsync(token);
}
/// <summary>
/// The mails of a mailbox which meet the conditions, as a query to build on.
/// </summary>
private static IQueryable<MailMessageEntity> FilterMails(IndexStoreDbContext context, string dataSourceId, MailFilter filter)
{
var mails = context.MailMessages
.AsNoTracking()
.Where(mail => mail.DataSourceId == dataSourceId && mail.OrphanedAtUtc == null);
if (!string.IsNullOrWhiteSpace(filter.From))
{
var pattern = ToContainsPattern(filter.From);
mails = mails.Where(mail => mail.Addresses.Any(address => SENDER_ROLES.Contains(address.Role) && (EF.Functions.Like(address.Address, pattern, LIKE_ESCAPE) || EF.Functions.Like(address.DisplayName, pattern, LIKE_ESCAPE))));
}
if (!string.IsNullOrWhiteSpace(filter.To))
{
var pattern = ToContainsPattern(filter.To);
mails = mails.Where(mail => mail.Addresses.Any(address => RECIPIENT_ROLES.Contains(address.Role) && (EF.Functions.Like(address.Address, pattern, LIKE_ESCAPE) || EF.Functions.Like(address.DisplayName, pattern, LIKE_ESCAPE))));
}
if (filter.ReceivedSinceUtc is { } receivedSince)
mails = mails.Where(mail => mail.ReceivedAtUtc >= receivedSince);
if (filter.ReceivedBeforeUtc is { } receivedBefore)
mails = mails.Where(mail => mail.ReceivedAtUtc < receivedBefore);
if (filter.IsUnread is { } isUnread)
mails = isUnread
? mails.Where(mail => !mail.Locations.Any(location => location.IsSeen))
: mails.Where(mail => mail.Locations.Any(location => location.IsSeen));
if (filter.IsFlagged is { } isFlagged)
mails = isFlagged
? mails.Where(mail => mail.Locations.Any(location => location.IsFlagged))
: mails.Where(mail => !mail.Locations.Any(location => location.IsFlagged));
if (filter.IsEncrypted is { } isEncrypted)
mails = isEncrypted
? mails.Where(mail => mail.EncryptionKind != NOT_ENCRYPTED)
: mails.Where(mail => mail.EncryptionKind == NOT_ENCRYPTED);
if (filter.Importance is { } importance)
{
var importanceName = importance.ToString();
mails = mails.Where(mail => mail.Importance == importanceName);
}
if (filter.HasAttachments is { } hasAttachments)
mails = hasAttachments
? mails.Where(mail => mail.Parts.Any(part => part.Kind == ATTACHMENT_KIND))
: mails.Where(mail => !mail.Parts.Any(part => part.Kind == ATTACHMENT_KIND));
if (filter.FolderPaths is { } folderPaths)
{
var paths = folderPaths.ToArray();
mails = mails.Where(mail => mail.Locations.Any(location => paths.Contains(location.Folder!.Path)));
}
return mails;
}
/// <summary>
/// A LIKE pattern which finds the text anywhere, with any % or _ in it taken literally.
/// </summary>
private static string ToContainsPattern(string text)
{
var escaped = text.Trim()
.Replace(LIKE_ESCAPE, LIKE_ESCAPE + LIKE_ESCAPE, StringComparison.Ordinal)
.Replace("%", LIKE_ESCAPE + "%", StringComparison.Ordinal)
.Replace("_", LIKE_ESCAPE + "_", StringComparison.Ordinal);
return $"%{escaped}%";
}
}
@@ -0,0 +1,532 @@
using AIStudio.Settings.DataModel;
using AIStudio.Tools.Mail;
using Microsoft.EntityFrameworkCore;
namespace AIStudio.Tools.Databases.IndexStore;
public sealed partial class SqliteIndexStoreClientImplementation
{
/// <summary>
/// The type a mailbox is stored under in data_sources, as the embedding service writes it.
/// </summary>
private const string MAILBOX_DATA_SOURCE_TYPE = nameof(DataSourceType.MAILBOX);
public override async Task<IReadOnlyList<MailFolderRecord>> GetMailFoldersAsync(string dataSourceId, CancellationToken token)
{
await using var context = this.CreateContext();
var folders = await context.MailFolders
.AsNoTracking()
.Where(folder => folder.DataSourceId == dataSourceId)
.OrderBy(folder => folder.Path)
.ToListAsync(token);
return folders.Select(ToMailFolderRecord).ToList();
}
public override async Task UpsertMailFolderAsync(string dataSourceId, MailFolderRecord folder, CancellationToken token)
{
await using var context = this.CreateContext();
await using var transaction = await context.Database.BeginTransactionAsync(token);
var folderEntity = await context.MailFolders.FirstOrDefaultAsync(entity => entity.DataSourceId == dataSourceId && entity.Path == folder.Path, token);
if (folderEntity is null)
{
folderEntity = new MailFolderEntity
{
DataSourceId = dataSourceId,
Path = folder.Path,
};
context.MailFolders.Add(folderEntity);
}
else if (folderEntity.UidValidity != folder.UidValidity)
{
var folderId = folderEntity.Id;
await DropMailLocationsAsync(context, context.MailLocations.Where(location => location.FolderId == folderId), token);
}
ApplyMailFolder(folderEntity, folder);
await context.SaveChangesAsync(token);
await transaction.CommitAsync(token);
}
public override async Task DeleteMailFolderAsync(string dataSourceId, string folderPath, CancellationToken token)
{
await using var context = this.CreateContext();
await using var transaction = await context.Database.BeginTransactionAsync(token);
if (await FindMailFolderIdAsync(context, dataSourceId, folderPath, token) is not { } folderId)
return;
await DropMailLocationsAsync(context, context.MailLocations.Where(location => location.FolderId == folderId), token);
await context.MailFolders.Where(folder => folder.Id == folderId).ExecuteDeleteAsync(token);
await transaction.CommitAsync(token);
}
public override async Task UpsertMailAsync(string dataSourceId, MailRecord mail, CancellationToken token)
{
ValidateMail(mail);
await using var context = this.CreateContext();
await using var transaction = await context.Database.BeginTransactionAsync(token);
var folderIds = await GetMailFolderIdsAsync(context, dataSourceId, mail.Locations, token);
var mailEntity = await context.MailMessages.FirstOrDefaultAsync(entity => entity.ParentFileId == mail.MailId, token);
if (mailEntity is null)
{
mailEntity = new MailMessageEntity
{
ParentFileId = mail.MailId,
FirstSeenUtc = mail.FirstSeenUtc,
};
context.MailMessages.Add(mailEntity);
}
else
{
await context.MailAddresses.Where(address => address.ParentFileId == mail.MailId).ExecuteDeleteAsync(token);
await context.MailParts.Where(part => part.ParentFileId == mail.MailId).ExecuteDeleteAsync(token);
await context.MailLocations.Where(location => location.ParentFileId == mail.MailId).ExecuteDeleteAsync(token);
}
ApplyMail(mailEntity, dataSourceId, mail);
context.MailAddresses.AddRange(ToMailAddressEntities(mail));
context.MailParts.AddRange(ToMailPartEntities(mail));
var displacedMailIds = await ClaimMailLocationsAsync(context, mail.MailId, mail.Locations, folderIds, token);
await context.SaveChangesAsync(token);
await OrphanMailsWithoutLocationsAsync(context, displacedMailIds, token);
await transaction.CommitAsync(token);
}
public override async Task<MailRecord?> GetMailAsync(string dataSourceId, string mailId, CancellationToken token)
{
await using var context = this.CreateContext();
var mail = await context.MailMessages
.AsNoTracking()
.AsSplitQuery()
.Include(entity => entity.Addresses)
.Include(entity => entity.Parts)
.Include(entity => entity.Locations)
.ThenInclude(location => location.Folder)
.FirstOrDefaultAsync(entity => entity.DataSourceId == dataSourceId && entity.ParentFileId == mailId, token);
return mail is null ? null : ToMailRecord(mail);
}
public override async Task<bool> AddMailLocationAsync(string dataSourceId, string mailId, MailLocationRecord location, CancellationToken token)
{
await using var context = this.CreateContext();
await using var transaction = await context.Database.BeginTransactionAsync(token);
var mailEntity = await context.MailMessages.FirstOrDefaultAsync(entity => entity.DataSourceId == dataSourceId && entity.ParentFileId == mailId, token);
if (mailEntity is null)
return false;
var folderIds = await GetMailFolderIdsAsync(context, dataSourceId, [location], token);
var displacedMailIds = await ClaimMailLocationsAsync(context, mailId, [location], folderIds, token);
mailEntity.OrphanedAtUtc = null;
await context.SaveChangesAsync(token);
await OrphanMailsWithoutLocationsAsync(context, displacedMailIds, token);
await transaction.CommitAsync(token);
return true;
}
public override async Task RemoveMailLocationsAsync(string dataSourceId, string folderPath, IReadOnlyCollection<long> uids, CancellationToken token)
{
if (uids.Count == 0)
return;
await using var context = this.CreateContext();
await using var transaction = await context.Database.BeginTransactionAsync(token);
if (await FindMailFolderIdAsync(context, dataSourceId, folderPath, token) is not { } folderId)
return;
foreach (var uidBatch in uids.Chunk(CHUNK_UPSERT_BATCH_SIZE))
await DropMailLocationsAsync(context, context.MailLocations.Where(location => location.FolderId == folderId && uidBatch.Contains(location.Uid)), token);
await transaction.CommitAsync(token);
}
public override async Task<IReadOnlyDictionary<long, MailFlags>> GetMailLocationsAsync(string dataSourceId, string folderPath, CancellationToken token)
{
await using var context = this.CreateContext();
return await context.MailLocations
.AsNoTracking()
.Where(location => location.Folder!.DataSourceId == dataSourceId && location.Folder.Path == folderPath)
.ToDictionaryAsync(location => location.Uid, location => new MailFlags(location.IsSeen, location.IsFlagged, location.IsAnswered), token);
}
public override async Task UpdateMailFlagsAsync(string dataSourceId, string folderPath, IReadOnlyDictionary<long, MailFlags> flagsByUid, CancellationToken token)
{
if (flagsByUid.Count == 0)
return;
await using var context = this.CreateContext();
await using var transaction = await context.Database.BeginTransactionAsync(token);
if (await FindMailFolderIdAsync(context, dataSourceId, folderPath, token) is not { } folderId)
return;
foreach (var uidBatch in flagsByUid.Keys.Chunk(CHUNK_UPSERT_BATCH_SIZE))
{
token.ThrowIfCancellationRequested();
var locations = await context.MailLocations
.Where(location => location.FolderId == folderId && uidBatch.Contains(location.Uid))
.ToListAsync(token);
foreach (var location in locations)
{
var flags = flagsByUid[location.Uid];
location.IsSeen = flags.IsSeen;
location.IsFlagged = flags.IsFlagged;
location.IsAnswered = flags.IsAnswered;
}
await context.SaveChangesAsync(token);
context.ChangeTracker.Clear();
}
await transaction.CommitAsync(token);
}
public override async Task<IReadOnlyList<string>> GetOrphanedMailsAsync(string dataSourceId, DateTimeOffset orphanedBefore, CancellationToken token)
{
await using var context = this.CreateContext();
return await context.MailMessages
.AsNoTracking()
.Where(mail => mail.DataSourceId == dataSourceId && mail.OrphanedAtUtc != null && mail.OrphanedAtUtc < orphanedBefore)
.Join(context.EmbeddedFiles, mail => mail.ParentFileId, file => file.ParentFileId, (_, file) => file.AbsolutePath)
.ToListAsync(token);
}
public override async Task<MailboxSyncState> GetMailboxSyncStateAsync(string dataSourceId, CancellationToken token)
{
await using var context = this.CreateContext();
var state = await context.MailboxSyncStates
.AsNoTracking()
.FirstOrDefaultAsync(entity => entity.DataSourceId == dataSourceId, token);
return state is null
? new MailboxSyncState(null, null, null)
: new MailboxSyncState(state.LastSyncCompletedUtc, state.PendingRemovalCount, state.PendingRemovalApprovedUtc);
}
public override async Task HoldBackMailRemovalAsync(string dataSourceId, int removalCount, CancellationToken token)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(removalCount);
await using var context = this.CreateContext();
var state = await GetOrAddMailboxSyncStateAsync(context, dataSourceId, token);
if (state.PendingRemovalCount != removalCount)
{
state.PendingRemovalCount = removalCount;
state.PendingRemovalApprovedUtc = null;
}
await context.SaveChangesAsync(token);
}
public override async Task<bool> ApprovePendingMailRemovalAsync(string dataSourceId, int removalCount, CancellationToken token)
{
//
// One statement, so a sync cannot change the held back count between reading and writing
// it. Then the user would agree to a number they were never shown.
//
DateTimeOffset? approvedUtc = DateTimeOffset.UtcNow;
await using var context = this.CreateContext();
var approved = await context.MailboxSyncStates
.Where(state => state.DataSourceId == dataSourceId && state.PendingRemovalCount == removalCount)
.ExecuteUpdateAsync(setters => setters.SetProperty(state => state.PendingRemovalApprovedUtc, approvedUtc), token);
return approved > 0;
}
public override async Task CompleteMailboxSyncAsync(string dataSourceId, DateTimeOffset completedUtc, CancellationToken token)
{
await using var context = this.CreateContext();
var state = await GetOrAddMailboxSyncStateAsync(context, dataSourceId, token);
state.LastSyncCompletedUtc = completedUtc;
state.PendingRemovalCount = null;
state.PendingRemovalApprovedUtc = null;
await context.SaveChangesAsync(token);
}
public override async Task<MailboxAuthFailure?> GetMailboxAuthFailureAsync(string dataSourceId, CancellationToken token)
{
await using var context = this.CreateContext();
return await context.MailboxAuthStates
.AsNoTracking()
.Where(state => state.DataSourceId == dataSourceId)
.Select(state => new MailboxAuthFailure(state.FailedAtUtc, state.FailureMessage))
.FirstOrDefaultAsync(token);
}
public override async Task UpsertMailboxAuthFailureAsync(string dataSourceId, MailboxAuthFailure failure, CancellationToken token)
{
await using var context = this.CreateContext();
var state = await context.MailboxAuthStates.FirstOrDefaultAsync(entity => entity.DataSourceId == dataSourceId, token);
if (state is null)
{
state = new MailboxAuthStateEntity
{
DataSourceId = dataSourceId,
};
context.MailboxAuthStates.Add(state);
}
state.FailedAtUtc = failure.FailedAtUtc;
state.FailureMessage = failure.FailureMessage;
await context.SaveChangesAsync(token);
}
public override async Task ClearMailboxAuthFailureAsync(string dataSourceId, CancellationToken token)
{
await using var context = this.CreateContext();
await context.MailboxAuthStates
.Where(state => state.DataSourceId == dataSourceId)
.ExecuteDeleteAsync(token);
}
public override async Task<IReadOnlyCollection<string>> GetStoredMailboxIdsAsync(CancellationToken token)
{
await using var context = this.CreateContext();
var indexedIds = await context.DataSources
.AsNoTracking()
.Where(dataSource => dataSource.DataSourceType == MAILBOX_DATA_SOURCE_TYPE)
.Select(dataSource => dataSource.DataSourceId)
.ToListAsync(token);
var refusedIds = await context.MailboxAuthStates
.AsNoTracking()
.Select(state => state.DataSourceId)
.ToListAsync(token);
return indexedIds.Concat(refusedIds).ToHashSet(StringComparer.OrdinalIgnoreCase);
}
private static async Task<MailboxSyncStateEntity> GetOrAddMailboxSyncStateAsync(IndexStoreDbContext context, string dataSourceId, CancellationToken token)
{
var state = await context.MailboxSyncStates.FirstOrDefaultAsync(entity => entity.DataSourceId == dataSourceId, token);
if (state is not null)
return state;
state = new MailboxSyncStateEntity
{
DataSourceId = dataSourceId,
};
context.MailboxSyncStates.Add(state);
return state;
}
private static void ValidateMail(MailRecord mail)
{
if (mail.Locations.Count == 0)
throw new ArgumentException("A mail is only stored together with at least one place where it lies.", nameof(mail));
if (mail.Locations.DistinctBy(location => (location.FolderPath, location.Uid)).Count() != mail.Locations.Count)
throw new ArgumentException("A mail cannot lie at the same place twice.", nameof(mail));
//
// The Message-IDs are stored separated by spaces, which a Message-ID can never contain. One
// which does anyway would come back as two, so it is refused instead of being stored:
//
if (mail.ReferenceMessageIds.Any(messageId => messageId.Length == 0 || messageId.Any(char.IsWhiteSpace)))
throw new ArgumentException("A Message-ID can neither be empty nor contain white space.", nameof(mail));
}
private static async Task<int?> FindMailFolderIdAsync(IndexStoreDbContext context, string dataSourceId, string folderPath, CancellationToken token) =>
await context.MailFolders
.Where(folder => folder.DataSourceId == dataSourceId && folder.Path == folderPath)
.Select(folder => (int?)folder.Id)
.FirstOrDefaultAsync(token);
private static async Task<IReadOnlyDictionary<string, int>> GetMailFolderIdsAsync(IndexStoreDbContext context, string dataSourceId, IEnumerable<MailLocationRecord> locations, CancellationToken token)
{
var folderPaths = locations.Select(location => location.FolderPath).Distinct(StringComparer.Ordinal).ToArray();
var folderIds = await context.MailFolders
.Where(folder => folder.DataSourceId == dataSourceId && folderPaths.Contains(folder.Path))
.ToDictionaryAsync(folder => folder.Path, folder => folder.Id, StringComparer.Ordinal, token);
//
// The message names neither the folder nor the mailbox: folder names can tell a lot about
// a person, and this message may well end up in the log.
//
if (folderPaths.Any(folderPath => !folderIds.ContainsKey(folderPath)))
throw new InvalidOperationException("A mail lies in a folder the index does not hold for its mailbox. A folder has to be stored before the mails in it.");
return folderIds;
}
/// <summary>
/// Gives a mail the given places, taking them away from any other mail which still holds one.
/// </summary>
/// <returns>The ids of the mails which lost a place, to orphan those left without any.</returns>
private static async Task<IReadOnlyList<string>> ClaimMailLocationsAsync(IndexStoreDbContext context, string mailId, IEnumerable<MailLocationRecord> locations, IReadOnlyDictionary<string, int> folderIds, CancellationToken token)
{
var displacedMailIds = new List<string>();
foreach (var location in locations)
{
var folderId = folderIds[location.FolderPath];
var uid = location.Uid;
var heldLocations = context.MailLocations.Where(existing => existing.FolderId == folderId && existing.Uid == uid);
displacedMailIds.AddRange(await heldLocations.Select(existing => existing.ParentFileId).ToListAsync(token));
await heldLocations.ExecuteDeleteAsync(token);
context.MailLocations.Add(new MailLocationEntity
{
ParentFileId = mailId,
FolderId = folderId,
Uid = uid,
IsSeen = location.Flags.IsSeen,
IsFlagged = location.Flags.IsFlagged,
IsAnswered = location.Flags.IsAnswered,
});
}
return displacedMailIds;
}
/// <summary>
/// Removes the given places and orphans the mails which are left without any.
/// </summary>
private static async Task DropMailLocationsAsync(IndexStoreDbContext context, IQueryable<MailLocationEntity> locations, CancellationToken token)
{
var affectedMailIds = await locations.Select(location => location.ParentFileId).Distinct().ToListAsync(token);
await locations.ExecuteDeleteAsync(token);
await OrphanMailsWithoutLocationsAsync(context, affectedMailIds, token);
}
private static async Task OrphanMailsWithoutLocationsAsync(IndexStoreDbContext context, IReadOnlyCollection<string> mailIds, CancellationToken token)
{
if (mailIds.Count == 0)
return;
DateTimeOffset? orphanedAtUtc = DateTimeOffset.UtcNow;
foreach (var mailIdBatch in mailIds.Distinct(StringComparer.Ordinal).Chunk(CHUNK_UPSERT_BATCH_SIZE))
await context.MailMessages
.Where(mail => mailIdBatch.Contains(mail.ParentFileId) && mail.OrphanedAtUtc == null && !mail.Locations.Any())
.ExecuteUpdateAsync(setters => setters.SetProperty(mail => mail.OrphanedAtUtc, orphanedAtUtc), token);
}
private static void ApplyMailFolder(MailFolderEntity folderEntity, MailFolderRecord folder)
{
folderEntity.SpecialUse = folder.SpecialUse.ToString();
folderEntity.UidValidity = folder.UidValidity;
folderEntity.UidNext = folder.UidNext;
folderEntity.HighestModSeq = folder.HighestModSeq;
folderEntity.ServerMessageCount = folder.ServerMessageCount;
folderEntity.ServerUnseenCount = folder.ServerUnseenCount;
folderEntity.InitialSyncCompletedUtc = folder.InitialSyncCompletedUtc;
}
private static void ApplyMail(MailMessageEntity mailEntity, string dataSourceId, MailRecord mail)
{
mailEntity.DataSourceId = dataSourceId;
mailEntity.MessageId = mail.MessageId;
mailEntity.InReplyTo = mail.InReplyTo;
mailEntity.ReferenceMessageIds = string.Join(' ', mail.ReferenceMessageIds);
mailEntity.SentAtUtc = mail.SentAtUtc;
mailEntity.ReceivedAtUtc = mail.ReceivedAtUtc;
mailEntity.Importance = mail.Importance.ToString();
mailEntity.EncryptionKind = mail.EncryptionKind.ToString();
mailEntity.MailHash = mail.MailHash;
mailEntity.OrphanedAtUtc = null;
if (mail.FirstSeenUtc < mailEntity.FirstSeenUtc)
mailEntity.FirstSeenUtc = mail.FirstSeenUtc;
}
private static IEnumerable<MailAddressEntity> ToMailAddressEntities(MailRecord mail) =>
NumberWithinGroups(mail.Addresses, address => address.Role).Select(numbered => new MailAddressEntity
{
ParentFileId = mail.MailId,
Role = numbered.Item.Role.ToString(),
Position = numbered.Position,
Address = numbered.Item.Address,
DisplayName = numbered.Item.DisplayName,
});
private static IEnumerable<MailPartEntity> ToMailPartEntities(MailRecord mail) =>
NumberWithinGroups(mail.Parts, part => part.Kind).Select(numbered => new MailPartEntity
{
ParentFileId = mail.MailId,
Kind = numbered.Item.Kind.ToString(),
Position = numbered.Position,
Name = numbered.Item.Name,
ContentType = numbered.Item.ContentType,
PartSize = numbered.Item.PartSize,
Text = numbered.Item.Text,
TextState = numbered.Item.TextState.ToString(),
});
/// <summary>
/// Numbers the items within their group, in the order they come in, starting at zero for each group.
/// </summary>
private static IEnumerable<(T Item, int Position)> NumberWithinGroups<T, TGroup>(IEnumerable<T> items, Func<T, TGroup> groupOf) where TGroup : notnull
{
var nextPositions = new Dictionary<TGroup, int>();
foreach (var item in items)
{
var group = groupOf(item);
var position = nextPositions.GetValueOrDefault(group);
nextPositions[group] = position + 1;
yield return (item, position);
}
}
private static MailFolderRecord ToMailFolderRecord(MailFolderEntity folder) => new(
folder.Path,
ParseStoredName(folder.SpecialUse, MailFolderSpecialUse.UNKNOWN),
folder.UidValidity,
folder.UidNext,
folder.HighestModSeq,
folder.ServerMessageCount,
folder.ServerUnseenCount,
folder.InitialSyncCompletedUtc);
private static MailRecord ToMailRecord(MailMessageEntity mail) => new(
mail.ParentFileId,
mail.MessageId,
mail.InReplyTo,
mail.ReferenceMessageIds.Split(' ', StringSplitOptions.RemoveEmptyEntries),
mail.SentAtUtc,
mail.ReceivedAtUtc,
ParseStoredName(mail.Importance, MailImportance.NORMAL),
ParseStoredName(mail.EncryptionKind, MailEncryptionKind.UNKNOWN),
mail.MailHash,
mail.FirstSeenUtc,
ToMailAddressRecords(mail.Addresses),
mail.Parts
.Select(part => (Kind: ParseStoredName(part.Kind, MailPartKind.UNKNOWN), Part: part))
.OrderBy(entry => entry.Kind)
.ThenBy(entry => entry.Part.Position)
.Select(entry => new MailPartRecord(entry.Kind, entry.Part.Name, entry.Part.ContentType, entry.Part.PartSize, entry.Part.Text, ParseStoredName(entry.Part.TextState, MailPartTextState.UNKNOWN)))
.ToList(),
mail.Locations
.OrderBy(location => location.Folder!.Path, StringComparer.Ordinal)
.ThenBy(location => location.Uid)
.Select(location => new MailLocationRecord(location.Folder!.Path, location.Uid, new MailFlags(location.IsSeen, location.IsFlagged, location.IsAnswered)))
.ToList());
/// <summary>
/// The addresses of a mail, grouped by their role and in their order within each header.
/// </summary>
private static IReadOnlyList<MailAddressRecord> ToMailAddressRecords(IEnumerable<MailAddressEntity> addresses) => addresses
.Select(address => (Role: ParseStoredName(address.Role, MailAddressRole.UNKNOWN), Address: address))
.OrderBy(entry => entry.Role)
.ThenBy(entry => entry.Address.Position)
.Select(entry => new MailAddressRecord(entry.Role, entry.Address.Address, entry.Address.DisplayName))
.ToList();
/// <remarks>
/// A row written by a newer version may name a value this one does not know. The row still
/// says everything else it says, so only that one value falls back.
/// </remarks>
private static TEnum ParseStoredName<TEnum>(string name, TEnum fallback) where TEnum : struct, Enum =>
Enum.TryParse<TEnum>(name, ignoreCase: true, out var value) && Enum.IsDefined(value) ? value : fallback;
}
@@ -3,6 +3,7 @@ using System.Globalization;
using System.Text.RegularExpressions;
using AIStudio.Settings;
using AIStudio.Tools.Mail;
using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.Services;
@@ -10,7 +11,7 @@ using Microsoft.EntityFrameworkCore;
namespace AIStudio.Tools.Databases.IndexStore;
public sealed class SqliteIndexStoreClientImplementation(string name, string databasePath, string basePath, string version) : IndexStoreClient(name, basePath)
public sealed partial class SqliteIndexStoreClientImplementation(string name, string databasePath, string basePath, string version) : IndexStoreClient(name, basePath)
{
private const string DATABASE_NAME = "SQLite";
private const string DATABASE_FILENAME = "rag-index.sqlite3";
@@ -88,6 +89,13 @@ public sealed class SqliteIndexStoreClientImplementation(string name, string dat
yield return (TB("Indexed data sources"), OrUnknown(snapshot.DataSourceCount));
yield return (TB("Indexed files"), OrUnknown(snapshot.FileCount));
yield return (TB("Permanently skipped files"), OrUnknown(snapshot.FailureCount));
// Only once a mailbox brought any mails. Without one, the lines would say nothing but zero:
if (snapshot.MailCount > 0 || snapshot.MailFailureCount > 0)
{
yield return (TB("Indexed mails"), OrUnknown(snapshot.MailCount));
yield return (TB("Permanently skipped mails"), OrUnknown(snapshot.MailFailureCount));
}
}
public override async Task<DataSourceIndexState?> GetDataSourceStateAsync(string dataSourceId, CancellationToken token)
@@ -310,48 +318,61 @@ public sealed class SqliteIndexStoreClientImplementation(string name, string dat
if (string.IsNullOrWhiteSpace(ftsQuery))
return [];
//
// Chunks of the same score keep the order of their rows. The results are cut into pages by
// asking for more of them each time, cf. RetrievalPaging. If ties could fall differently
// with every limit, a page might show a chunk again or skip one.
//
await using var context = this.CreateContext();
var results = await context.SearchResults
.FromSqlInterpolated($"""
SELECT
c.chunk_id AS ChunkId,
c.parent_file_id AS ParentFileId,
ds.data_source_id AS DataSourceId,
ds.data_source_type AS DataSourceType,
f.absolute_path AS AbsolutePath,
f.file_name AS FileName,
f.relative_path AS RelativePath,
f.file_type AS FileType,
c.page_number AS PageNumber,
c.chunk_index AS ChunkIndex,
c.chunk_text AS ChunkText,
bm25(embedding_chunks_fts) AS Score,
f.fingerprint AS Fingerprint,
f.file_size AS FileSize,
f.creation_utc AS CreationUtc,
f.last_write_utc AS LastWriteUtc,
c.embedded_at_utc AS EmbeddedAtUtc,
f.chunk_count AS ChunkCount
FROM embedding_chunks_fts
JOIN embedding_chunks c ON c.id = embedding_chunks_fts.rowid
JOIN embedded_files f ON f.parent_file_id = c.parent_file_id
JOIN data_sources ds ON ds.data_source_id = f.data_source_id
WHERE ds.data_source_id = {dataSourceId}
AND embedding_chunks_fts MATCH {ftsQuery}
ORDER BY Score, c.id
LIMIT {maxMatches}
""")
.AsNoTracking()
var results = await InSearchOrder(MatchChunks(context, dataSourceId, ftsQuery))
.Take(maxMatches)
.ToListAsync(token);
return results.Select(ToSearchResult).ToList();
}
/// <summary>
/// The chunks of a data source which match a full-text query, unordered, as a query to build on.
/// </summary>
/// <remarks>
/// Kept free of ORDER BY and LIMIT, so EF Core can wrap it and filter it further before either
/// of them applies, cf. InSearchOrder.
/// </remarks>
private static IQueryable<IndexStoreSearchResultEntity> MatchChunks(IndexStoreDbContext context, string dataSourceId, string ftsQuery) => context.SearchResults
.FromSqlInterpolated($"""
SELECT
c.chunk_id AS ChunkId,
c.parent_file_id AS ParentFileId,
ds.data_source_id AS DataSourceId,
ds.data_source_type AS DataSourceType,
f.absolute_path AS AbsolutePath,
f.file_name AS FileName,
f.relative_path AS RelativePath,
f.file_type AS FileType,
c.page_number AS PageNumber,
c.chunk_index AS ChunkIndex,
c.chunk_text AS ChunkText,
bm25(embedding_chunks_fts) AS Score,
f.fingerprint AS Fingerprint,
f.file_size AS FileSize,
f.creation_utc AS CreationUtc,
f.last_write_utc AS LastWriteUtc,
c.embedded_at_utc AS EmbeddedAtUtc,
f.chunk_count AS ChunkCount
FROM embedding_chunks_fts
JOIN embedding_chunks c ON c.id = embedding_chunks_fts.rowid
JOIN embedded_files f ON f.parent_file_id = c.parent_file_id
JOIN data_sources ds ON ds.data_source_id = f.data_source_id
WHERE ds.data_source_id = {dataSourceId}
AND embedding_chunks_fts MATCH {ftsQuery}
""")
.AsNoTracking();
/// <remarks>
/// Chunks of the same score keep a fixed order, by their document and their place in it. The
/// results are cut into pages by asking for more of them each time, cf. RetrievalPaging. If
/// ties could fall differently with every limit, a page might show a chunk again or skip one.
/// </remarks>
private static IQueryable<IndexStoreSearchResultEntity> InSearchOrder(IQueryable<IndexStoreSearchResultEntity> results) => results
.OrderBy(result => result.Score)
.ThenBy(result => result.ParentFileId)
.ThenBy(result => result.ChunkIndex);
public override async Task DeleteDataSourceAsync(string dataSourceId, CancellationToken token)
{
await using var context = this.CreateContext();
@@ -403,7 +424,8 @@ public sealed class SqliteIndexStoreClientImplementation(string name, string dat
/// Everything the display info reads out of the database in one go.
/// </summary>
/// <remarks>
/// Every property is empty when its probe could not answer. The caller turns that into "unknown".
/// Every property is empty or null when its probe could not answer. The caller turns that into "unknown".
/// The documents of mailboxes are counted apart from files, told apart by their keys, cf. MailContentKey.
/// </remarks>
private sealed record DisplaySnapshot
{
@@ -415,15 +437,21 @@ public sealed class SqliteIndexStoreClientImplementation(string name, string dat
public string TableCount { get; init; } = string.Empty;
public string DataSourceCount { get; init; } = string.Empty;
public int? DataSourceCount { get; init; }
public string FileCount { get; init; } = string.Empty;
public int? FileCount { get; init; }
public string FailureCount { get; init; } = string.Empty;
public int? FailureCount { get; init; }
public int? MailCount { get; init; }
public int? MailFailureCount { get; init; }
}
private static string OrUnknown(string value) => string.IsNullOrWhiteSpace(value) ? TB("unknown") : value;
private static string OrUnknown(int? count) => count?.CompactCount() ?? TB("unknown");
private async Task<DisplaySnapshot> ReadDisplaySnapshotAsync()
{
var token = CancellationToken.None;
@@ -436,9 +464,11 @@ public sealed class SqliteIndexStoreClientImplementation(string name, string dat
JournalMode = (await QueryScalarTextAsync(context, "PRAGMA journal_mode;", token)).ToUpperInvariant(),
SchemaVersion = await GetSchemaVersionAsync(context, token),
TableCount = await GetTableCountAsync(context, token),
DataSourceCount = await FormatCountAsync(context.DataSources, token),
FileCount = await FormatCountAsync(context.EmbeddedFiles, token),
FailureCount = await FormatCountAsync(context.PermanentIndexingFailures, token),
DataSourceCount = await TryCountAsync(context.DataSources, token),
FileCount = await TryCountAsync(context.EmbeddedFiles.Where(file => !file.AbsolutePath.StartsWith(MailContentKey.PREFIX)), token),
FailureCount = await TryCountAsync(context.PermanentIndexingFailures.Where(failure => !failure.AbsolutePath.StartsWith(MailContentKey.PREFIX)), token),
MailCount = await TryCountAsync(context.EmbeddedFiles.Where(file => file.AbsolutePath.StartsWith(MailContentKey.PREFIX)), token),
MailFailureCount = await TryCountAsync(context.PermanentIndexingFailures.Where(failure => failure.AbsolutePath.StartsWith(MailContentKey.PREFIX)), token),
};
}
catch (Exception exception)
@@ -523,15 +553,15 @@ public sealed class SqliteIndexStoreClientImplementation(string name, string dat
}
}
private static async Task<string> FormatCountAsync<T>(IQueryable<T> query, CancellationToken token) where T : class
private static async Task<int?> TryCountAsync<T>(IQueryable<T> query, CancellationToken token) where T : class
{
try
{
return (await query.CountAsync(token)).CompactCount();
return await query.CountAsync(token);
}
catch
{
return string.Empty;
return null;
}
}
@@ -31,6 +31,9 @@ public sealed class NoVectorStoreClient(string name, string? unavailableReason,
public override Task<IReadOnlyList<VectorSearchResult>> SearchEmbeddingAsync(string storeName, IReadOnlyList<float> vector, int maxMatches, CancellationToken token) =>
Task.FromException<IReadOnlyList<VectorSearchResult>>(this.CreateUnavailableException());
public override Task<IReadOnlyList<VectorSearchResult>> SearchEmbeddingAsync(string storeName, IReadOnlyList<float> vector, int maxMatches, VectorSearchFilter filter, CancellationToken token) =>
Task.FromException<IReadOnlyList<VectorSearchResult>>(this.CreateUnavailableException());
public override Task DeleteEmbeddingByFile(string storeName, string filePath, CancellationToken token) =>
Task.FromException(this.CreateUnavailableException());
@@ -125,7 +125,19 @@ public sealed class QdrantEdgeClientImplementation(
public override Task InsertEmbedding(string storeName, IReadOnlyList<VectorStoragePoint> points, CancellationToken token) =>
rustService.ExecuteDatabaseOperation(DATABASE_NAME, INSERT_PATH, new InsertEmbeddingRequest(storeName, points), token);
public override async Task<IReadOnlyList<VectorSearchResult>> SearchEmbeddingAsync(string storeName, IReadOnlyList<float> vector, int maxMatches, CancellationToken token)
public override Task<IReadOnlyList<VectorSearchResult>> SearchEmbeddingAsync(string storeName, IReadOnlyList<float> vector, int maxMatches, CancellationToken token) =>
this.QueryEmbeddingAsync(storeName, vector, maxMatches, null, token);
public override async Task<IReadOnlyList<VectorSearchResult>> SearchEmbeddingAsync(string storeName, IReadOnlyList<float> vector, int maxMatches, VectorSearchFilter filter, CancellationToken token)
{
if (maxMatches <= 0 || filter.MatchesNothing)
return [];
var candidates = await this.QueryEmbeddingAsync(storeName, vector, filter.GetCandidateCount(maxMatches), filter.GetRequestPointIds(), token);
return filter.Apply(candidates, maxMatches);
}
private async Task<IReadOnlyList<VectorSearchResult>> QueryEmbeddingAsync(string storeName, IReadOnlyList<float> vector, int maxMatches, IReadOnlyList<string>? pointIds, CancellationToken token)
{
if (maxMatches <= 0)
return [];
@@ -133,7 +145,7 @@ public sealed class QdrantEdgeClientImplementation(
return await rustService.ExecuteDatabaseQuery<SearchEmbeddingRequest, List<VectorSearchResult>>(
DATABASE_NAME,
SEARCH_PATH,
new SearchEmbeddingRequest(storeName, vector, maxMatches),
new SearchEmbeddingRequest(storeName, vector, maxMatches, pointIds),
token) ?? [];
}
@@ -162,7 +174,8 @@ public sealed class QdrantEdgeClientImplementation(
private sealed record InsertEmbeddingRequest(string StoreName, IReadOnlyList<VectorStoragePoint> Points);
private sealed record SearchEmbeddingRequest(string StoreName, IReadOnlyList<float> Vector, int MaxMatches);
/// <param name="PointIds">The only points the search may return, or null for the whole store.</param>
private sealed record SearchEmbeddingRequest(string StoreName, IReadOnlyList<float> Vector, int MaxMatches, IReadOnlyList<string>? PointIds);
private sealed record DeleteEmbeddingByFileRequest(string StoreName, string FilePath);
@@ -0,0 +1,67 @@
namespace AIStudio.Tools.Databases.VectorStore;
/// <summary>
/// Restricts a vector search to some of the points in a store, e.g., to the chunks of those mails
/// which match the other conditions of a search.
/// </summary>
/// <remarks>
/// A filter without any point matches nothing, and a search with it finds nothing. It never turns
/// into a search of the whole store: a caller whose other conditions matched nothing must not get
/// back exactly what those conditions ruled out.
///
/// Up to MAX_POINT_IDS_PER_REQUEST ids travel along with the search, and the vector store looks at
/// those points only. A longer list would make every single request large, so the search then asks
/// for OVERSAMPLING_FACTOR times as many matches without a restriction and drops the foreign ones
/// afterward. That can leave fewer matches than asked for, when the filter covers only a small part
/// of the store.
///
/// Either way, the matches are checked against the filter once more before anybody sees them. A
/// filtered search therefore never returns a point outside the filter, whatever the vector store did.
/// </remarks>
/// <param name="pointIds">The ids of the points the search may return. Each has to be a GUID.</param>
/// <exception cref="FormatException">One of the ids is not a GUID.</exception>
public sealed class VectorSearchFilter(IEnumerable<string> pointIds)
{
/// <summary>
/// The most point ids which are sent along with one search.
/// </summary>
internal const int MAX_POINT_IDS_PER_REQUEST = 10_000;
/// <summary>
/// How many more matches a search asks for when the filter is too long to be sent along.
/// </summary>
internal const int OVERSAMPLING_FACTOR = 4;
private readonly HashSet<Guid> pointIds = pointIds.Select(Guid.Parse).ToHashSet();
/// <summary>
/// Whether no point at all passes the filter, so there is nothing to search.
/// </summary>
public bool MatchesNothing => this.pointIds.Count == 0;
private bool IsSentAlong => this.pointIds.Count <= MAX_POINT_IDS_PER_REQUEST;
/// <summary>
/// The point ids to send along with the search.
/// </summary>
/// <returns>The ids, or null when there are too many and the matches get filtered afterward.</returns>
public IReadOnlyList<string>? GetRequestPointIds() => this.IsSentAlong ? this.pointIds.Select(id => id.ToString()).ToList() : null;
/// <summary>
/// How many matches to ask the vector store for.
/// </summary>
/// <param name="maxMatches">How many matches the caller wants.</param>
/// <returns>The number of matches to ask for.</returns>
public int GetCandidateCount(int maxMatches) => this.IsSentAlong ? maxMatches : (int)Math.Min((long)maxMatches * OVERSAMPLING_FACTOR, int.MaxValue);
/// <summary>
/// Keeps the matches which pass the filter, in their order.
/// </summary>
/// <param name="candidates">The matches of the vector store, best first.</param>
/// <param name="maxMatches">How many matches the caller wants.</param>
/// <returns>At most maxMatches matches, all of them inside the filter.</returns>
public IReadOnlyList<VectorSearchResult> Apply(IEnumerable<VectorSearchResult> candidates, int maxMatches) =>
candidates.Where(this.Contains).Take(maxMatches).ToList();
private bool Contains(VectorSearchResult candidate) => Guid.TryParse(candidate.PointId, out var pointId) && this.pointIds.Contains(pointId);
}
@@ -8,6 +8,17 @@ public abstract class VectorStoreClient(string name, string path): DatabaseClien
public abstract Task<IReadOnlyList<VectorSearchResult>> SearchEmbeddingAsync(string storeName, IReadOnlyList<float> vector, int maxMatches, CancellationToken token);
/// <summary>
/// Searches only those points of a store which pass the filter.
/// </summary>
/// <param name="storeName">The name of the store.</param>
/// <param name="vector">The vector to search for.</param>
/// <param name="maxMatches">How many matches to return at most.</param>
/// <param name="filter">The points the search may return. Without any point, it finds nothing.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>The matches, best first, all of them inside the filter.</returns>
public abstract Task<IReadOnlyList<VectorSearchResult>> SearchEmbeddingAsync(string storeName, IReadOnlyList<float> vector, int maxMatches, VectorSearchFilter filter, CancellationToken token);
public abstract Task DeleteEmbeddingByFile(string storeName, string filePath, CancellationToken token);
public abstract Task OptimizeVectorStore(string storeName, CancellationToken token);
@@ -60,6 +60,27 @@ public static class ExternalHttpClientTimeout
ValidateServerCertificateWithCustomRootCertificates(host, certificate, chain, sslPolicyErrors, customRootCertificateCache, trustPolicy);
}
/// <summary>
/// The certificate validation for a TLS connection which does not go through HTTP, e.g., to an IMAP server.
/// </summary>
/// <remarks>
/// The same rules apply as to HTTPS: the system trust first, and the configured root certificates
/// only for the allowed hosts. This matters most in a Flatpak, which does not see the root
/// certificates of the OS, so that a server with an internal CA fails without the bundle.
/// </remarks>
/// <param name="host">The host the connection goes to, as an IDN host.</param>
/// <param name="trustPolicy">Whether the configured root certificates may be used.</param>
/// <returns>The callback, or null when no configured root certificates are usable and the system trust decides alone.</returns>
public static RemoteCertificateValidationCallback? CreateServerCertificateValidationCallback(string host, ExternalHttpTrustPolicy trustPolicy)
{
var customRootCertificateCache = GetCustomRootCertificateCache();
if (!customRootCertificateCache.State.IsUsable)
return null;
return (_, certificate, chain, sslPolicyErrors) =>
ValidateServerCertificateWithCustomRootCertificates(host, certificate, chain, sslPolicyErrors, customRootCertificateCache, trustPolicy);
}
public static ExternalHttpCustomRootCertificateState CustomRootCertificateState => GetCustomRootCertificateCache().State;
public static string GetTimeoutDescription()
@@ -500,7 +521,6 @@ public static class ExternalHttpClientTimeout
{
var chainStatuses = FormatChainStatusesForLog(chain.ChainStatus);
var elementStatuses = chain.ChainElements
.Cast<X509ChainElement>()
.Select((element, index) => $"element {index}: {FormatChainStatusesForLog(element.ChainElementStatus)}")
.ToList();
LOGGER.Value.LogError($"Rejected external HTTPS certificate for '{HostForLog(host)}' after validation with configured custom root certificates. TLS policy errors: {sslPolicyErrors}. Chain statuses: {chainStatuses}. Chain element statuses: {string.Join("; ", elementStatuses)}");
@@ -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&lt;zero-width space&gt;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,
}
@@ -0,0 +1,71 @@
using AIStudio.Dialogs;
using AIStudio.Settings;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.Services;
using DialogOptions = AIStudio.Dialogs.DialogOptions;
namespace AIStudio.Tools;
/// <summary>
/// Opens the settings of a mailbox, and stores what the user changed in them.
/// </summary>
/// <remarks>
/// Kept here rather than in the places which offer it -- the data source table and the background
/// embeddings page, which leads to it from a refused sign-in -- so that saving a mailbox works the
/// same way everywhere: the settings are stored, and the mailbox is synced right after.
/// </remarks>
public static class MailboxEditing
{
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(MailboxEditing).Namespace, nameof(MailboxEditing));
/// <summary>
/// Lets the user edit a mailbox, and stores and syncs it when they save.
/// </summary>
/// <remarks>
/// The caller tells the rest of the app that the configuration changed, as the sender of that message.
/// </remarks>
/// <param name="dialogService">The dialog service to show the settings with.</param>
/// <param name="settingsManager">The settings, which hold the mailbox.</param>
/// <param name="embeddingService">The service which syncs the mailbox.</param>
/// <param name="mailboxId">The id of the mailbox.</param>
/// <returns>True when the user saved the mailbox. False when they cancelled, or the mailbox is gone or managed by the organization.</returns>
public static async Task<bool> EditAsync(IDialogService dialogService, SettingsManager settingsManager, DataSourceEmbeddingService embeddingService, string mailboxId)
{
var mailboxes = settingsManager.ConfigurationData.Mailboxes;
var position = mailboxes.FindIndex(mailbox => mailbox.Id == mailboxId);
if (position < 0 || mailboxes[position].IsEnterpriseConfiguration)
return false;
var storedMailbox = mailboxes[position];
var availableEmbeddings = settingsManager.GetAllEmbeddingProviders()
.Select(provider => new ConfigurationSelectData<string>(provider.Name, provider.Id))
.ToList();
var dialogParameters = new DialogParameters<DataSourceMailboxDialog>
{
{ x => x.IsEditing, true },
{ x => x.DataSource, storedMailbox },
{ x => x.LockSource, await embeddingService.ShouldLockDataSourceOriginAsync(storedMailbox.Id) },
{ x => x.AvailableEmbeddings, availableEmbeddings }
};
var dialogReference = await dialogService.ShowAsync<DataSourceMailboxDialog>(TB("Edit Mailbox"), dialogParameters, DialogOptions.FULLSCREEN);
var dialogResult = await dialogReference.Result;
if (dialogResult is null || dialogResult.Canceled)
return false;
// Found anew, since the list may have changed while the dialog was open:
position = mailboxes.IndexOf(storedMailbox);
if (position < 0)
return false;
var editedMailbox = (DataSourceMailbox)dialogResult.Data!;
mailboxes[position] = editedMailbox;
await settingsManager.StoreSettings();
await embeddingService.QueueDataSourceAsync(editedMailbox);
return true;
}
}
@@ -18,7 +18,8 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
private List<PluginConfigurationObject> configObjects = [];
private List<DataMandatoryInfo> mandatoryInfos = [];
private List<DataIntroduction> introductions = [];
private List<DataMailboxProvider> mailboxProviders = [];
/// <summary>
/// The list of configuration objects. Configuration objects are, e.g., providers or chat templates.
/// </summary>
@@ -36,6 +37,12 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
/// </summary>
public IReadOnlyList<DataIntroduction> Introductions => this.introductions;
/// <summary>
/// The mail servers this configuration plugin offers for new mailboxes.
/// Mail servers are live plugin content and are not persisted to ConfigurationData.
/// </summary>
public IReadOnlyList<DataMailboxProvider> MailboxProviders => this.mailboxProviders;
/// <summary>
/// True/false when explicitly configured in the plugin, otherwise null.
/// </summary>
@@ -194,7 +201,8 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
this.configObjects.Clear();
this.mandatoryInfos.Clear();
this.introductions.Clear();
this.mailboxProviders.Clear();
// Ensure that the main CONFIG table exists and is a valid Lua table:
if (!this.State.Environment["CONFIG"].TryRead<LuaTable>(out var mainTable))
{
@@ -250,6 +258,9 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
// Config: allow the user to add transcription providers?
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.AllowUserToAddTranscriptionProvider, this.Id, settingsTable, dryRun);
// Config: allow the user to add mailboxes?
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.AllowUserToAddMailbox, this.Id, settingsTable, dryRun);
// Config: allow the user to import plugin archives?
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.AllowUserToImportPlugins, this.Id, settingsTable, dryRun);
@@ -278,7 +289,7 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
ManagedConfiguration.TryProcessConfiguration(x => x.Tools, x => x.EnableTools, this.Id, settingsTable, dryRun);
ManagedConfiguration.TryProcessConfiguration(x => x.Tools, x => x.DisabledToolIds, this.Id, settingsTable, dryRun);
// Config: minimum provider confidence per tool
// Config: minimum provider confidence per tool collection; a tool outside of one forms its own
ManagedConfiguration.TryProcessConfiguration(x => x.Tools, x => x.MinimumProviderConfidenceByToolId, this.Id, settingsTable, dryRun);
//
@@ -307,6 +318,12 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
// Config: data source security settings
ManagedConfiguration.TryProcessConfiguration(x => x.DataSourceSecurity, x => x.TrustedProviderIds, this.Id, settingsTable, dryRun);
// Config: the least strict outbound data restriction a mailbox may have
ManagedConfiguration.TryProcessConfiguration(x => x.MailboxSettings, x => x.MinimumOutboundDataRestriction, this.Id, settingsTable, dryRun);
// Config: mailboxes only on the mail servers of the organization?
ManagedConfiguration.TryProcessConfiguration(x => x.MailboxSettings, x => x.AllowOnlyOrganizationMailServers, this.Id, settingsTable, dryRun);
// Config: data source selection agent settings
ManagedConfiguration.TryProcessConfiguration(x => x.AgentDataSourceSelection, x => x.PreselectAgentOptions, this.Id, settingsTable, dryRun);
ManagedConfiguration.TryProcessConfiguration(x => x.AgentDataSourceSelection, x => x.PreselectedAgentProvider, Guid.Empty, this.Id, settingsTable, dryRun);
@@ -353,6 +370,9 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
// Handle configured introductions:
this.TryReadIntroductions(mainTable);
// Handle configured mail servers:
this.TryReadMailboxProviders(mainTable);
// Config: preselected provider?
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.PreselectedProvider, Guid.Empty, this.Id, settingsTable, dryRun);
@@ -415,7 +435,7 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
if (configuredValue.Type is not LuaValueType.Table || !configuredValue.TryRead<LuaTable>(out var configuredTable))
{
message = $"The setting '{SETTING_NAME}' must be a table of tool IDs and confidence levels.";
message = $"The setting '{SETTING_NAME}' must be a table of tool or collection IDs and confidence levels.";
return false;
}
@@ -429,7 +449,7 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
!Enum.IsDefined(confidenceLevel) ||
confidenceLevel is ConfidenceLevel.UNKNOWN)
{
message = $"The setting '{SETTING_NAME}' contains an invalid tool ID or confidence level. Allowed confidence levels are NONE, UNTRUSTED, VERY_LOW, LOW, MODERATE, MEDIUM, and HIGH.";
message = $"The setting '{SETTING_NAME}' contains an invalid tool or collection ID or confidence level. Allowed confidence levels are NONE, UNTRUSTED, VERY_LOW, LOW, MODERATE, MEDIUM, and HIGH.";
return false;
}
}
@@ -784,4 +804,25 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
LOG.LogWarning("The table 'INTRODUCTIONS' entry at index {Index} does not contain a valid introduction (config plugin id: {ConfigPluginId}).", i, this.Id);
}
}
private void TryReadMailboxProviders(LuaTable mainTable)
{
if (!mainTable.TryGetValue("MAILBOX_PROVIDERS", out var mailboxProvidersValue) || !mailboxProvidersValue.TryRead<LuaTable>(out var mailboxProvidersTable))
return;
for (var i = 1; i <= mailboxProvidersTable.ArrayLength; i++)
{
var luaMailboxProviderValue = mailboxProvidersTable[i];
if (!luaMailboxProviderValue.TryRead<LuaTable>(out var luaMailboxProviderTable))
{
LOG.LogWarning("The table 'MAILBOX_PROVIDERS' entry at index {Index} is not a valid table (config plugin id: {ConfigPluginId}).", i, this.Id);
continue;
}
if (DataMailboxProvider.TryParseConfiguration(i, luaMailboxProviderTable, this.Id, LOG, out var mailboxProvider))
this.mailboxProviders.Add(mailboxProvider);
else
LOG.LogWarning("The table 'MAILBOX_PROVIDERS' entry at index {Index} does not contain a valid mail server (config plugin id: {ConfigPluginId}).", i, this.Id);
}
}
}
@@ -449,6 +449,18 @@ public static partial class PluginFactory
.ToList();
}
/// <summary>
/// Collects the mail servers which the running configuration plugins offer for new mailboxes.
/// </summary>
/// <returns>The mail servers of all configuration plugins, ordered by name, with every ID resolved to one winner.</returns>
public static IReadOnlyList<DataMailboxProvider> GetMailboxProviders()
{
return ResolveLivePluginContent<PluginConfiguration, DataMailboxProvider>("mailbox provider", plugin => plugin.MailboxProviders)
.OrderBy(provider => provider.Name, StringComparer.CurrentCultureIgnoreCase)
.ThenBy(provider => provider.Id, StringComparer.Ordinal)
.ToList();
}
/// <summary>
/// Collects what the running model plugins declare about models.
/// </summary>
@@ -0,0 +1,26 @@
namespace AIStudio.Tools.RAG;
/// <summary>
/// What kept one retrieval from covering the whole data source.
/// </summary>
/// <param name="queryWrittenByUser">Whether the query is the user's own message, which decides who hears about its problems.</param>
public sealed class RetrievalRun(bool queryWrittenByUser)
{
// Both channels search at the same time:
private readonly Lock gapLock = new();
private readonly HashSet<RetrievalGap> gaps = [];
public bool QueryWrittenByUser => queryWrittenByUser;
public void Add(RetrievalGap gap)
{
lock (this.gapLock)
this.gaps.Add(gap);
}
public IReadOnlyList<RetrievalGap> GetGaps()
{
lock (this.gapLock)
return this.gaps.Order().ToList();
}
}
@@ -15,4 +15,15 @@ public readonly record struct PromptInjectionSource(PromptInjectionSourceKind Ki
public static PromptInjectionSource RetrievalContext(string dataSourceName, string path) => new(PromptInjectionSourceKind.RETRIEVAL_CONTEXT, $"{dataSourceName}: {path}");
public static PromptInjectionSource DataSourceDescription(string dataSourceName) => new(PromptInjectionSourceKind.DATA_SOURCE_DESCRIPTION, dataSourceName);
/// <summary>
/// The content of the mails of one mailbox, its header fields included.
/// </summary>
/// <remarks>
/// Named after the mailbox alone, never after a mail: the label ends up in the log, which keeps
/// no subjects and no addresses. It also makes all mails of a mailbox one source, so a sync
/// which filtered ten mails reports one mailbox instead of ten mails.
/// </remarks>
/// <param name="mailboxName">The name of the mailbox.</param>
public static PromptInjectionSource MailContent(string mailboxName) => new(PromptInjectionSourceKind.MAIL_CONTENT, mailboxName);
}
@@ -8,4 +8,5 @@ public enum PromptInjectionSourceKind
CHAT_ATTACHMENT,
RETRIEVAL_CONTEXT,
DATA_SOURCE_DESCRIPTION,
MAIL_CONTENT,
}
@@ -13,6 +13,7 @@ public static class PromptInjectionSourceKindExtensions
PromptInjectionSourceKind.CHAT_ATTACHMENT => TB("Chat attachment"),
PromptInjectionSourceKind.RETRIEVAL_CONTEXT => TB("Retrieved context"),
PromptInjectionSourceKind.DATA_SOURCE_DESCRIPTION => TB("Data source description"),
PromptInjectionSourceKind.MAIL_CONTENT => TB("Mail content"),
_ => TB("Unknown"),
};
}
@@ -296,7 +296,7 @@ public sealed class AssistantPluginGenerationService(ToolRegistry toolRegistry,
var builder = new StringBuilder();
foreach (var tool in activeTools)
builder.AppendLine($"- {tool.Definition.Id}: {tool.Definition.Function.DescriptionForLLM}");
builder.AppendLine($"- {tool.Id}: {tool.DescriptionForLLM}");
return builder.ToString().TrimEnd();
}
@@ -868,7 +868,7 @@ public sealed class AssistantPluginGenerationService(ToolRegistry toolRegistry,
var alreadyRequested = RequestedToolIds(previousVersion).ToHashSet(StringComparer.Ordinal);
return toolIds
.Where(toolId => !alreadyRequested.Contains(toolId) && toolRegistry.GetDefinition(toolId) is null)
.Where(toolId => !alreadyRequested.Contains(toolId) && !toolRegistry.IsKnown(toolId))
.ToList();
}
@@ -18,12 +18,13 @@ public sealed record ConfigurationPluginDeleteSummary(
int DocumentAnalysisPolicies,
int LockedSettings,
int MandatoryInfos,
int Introductions)
int Introductions,
int MailboxProviders)
{
/// <summary>
/// An empty summary, used when the configuration plugin is not running and we cannot tell what it configured.
/// </summary>
public static readonly ConfigurationPluginDeleteSummary EMPTY = new(0, 0, 0, 0, 0, 0, 0, 0, 0, 0);
public static readonly ConfigurationPluginDeleteSummary EMPTY = new(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0);
/// <summary>
/// True when the deletion affects anything beyond the plugin directory.
@@ -38,5 +39,6 @@ public sealed record ConfigurationPluginDeleteSummary(
this.DocumentAnalysisPolicies > 0 ||
this.LockedSettings > 0 ||
this.MandatoryInfos > 0 ||
this.Introductions > 0;
this.Introductions > 0 ||
this.MailboxProviders > 0;
}
@@ -15,6 +15,7 @@ namespace AIStudio.Tools.Services;
/// <param name="DeclaredSettings">How many settings the configuration takes over.</param>
/// <param name="MandatoryInfos">How many mandatory information texts users must accept.</param>
/// <param name="Introductions">How many introductions the configuration adds to the welcome page.</param>
/// <param name="MailboxProviders">How many mail servers the configuration offers for new mailboxes.</param>
public sealed record ConfigurationPluginImportSummary(
IReadOnlyList<ConfigurationPluginDestination> Destinations,
int ChatTemplates,
@@ -22,7 +23,8 @@ public sealed record ConfigurationPluginImportSummary(
int DocumentAnalysisPolicies,
int DeclaredSettings,
int MandatoryInfos,
int Introductions)
int Introductions,
int MailboxProviders)
{
/// <summary>
/// True when the configuration sets up anything at all.
@@ -34,5 +36,6 @@ public sealed record ConfigurationPluginImportSummary(
this.DocumentAnalysisPolicies > 0 ||
this.DeclaredSettings > 0 ||
this.MandatoryInfos > 0 ||
this.Introductions > 0;
this.Introductions > 0 ||
this.MailboxProviders > 0;
}
@@ -0,0 +1,27 @@
namespace AIStudio.Tools.Services;
/// <summary>
/// What a data source waits for the user to decide, beyond the failures its list explains.
/// </summary>
public enum DataSourceAttention
{
/// <summary>
/// Nothing to decide.
/// </summary>
NONE,
/// <summary>
/// The server refused the sign-in, and AI Studio does not sign in again on its own.
/// </summary>
AUTH_FAILED,
/// <summary>
/// A run would remove many documents from the index at once, and waits for the user to agree.
/// </summary>
MASS_REMOVAL_PENDING,
/// <summary>
/// The organization allows only its own mail servers, and the mailbox is on another one. AI Studio does not connect to it until the organization allows the server.
/// </summary>
SERVER_NOT_ALLOWED,
}
@@ -12,13 +12,14 @@ namespace AIStudio.Tools.Services;
/// rejected API key, an unreachable provider, and a file nobody may read were one and the same
/// list entry. The surrounding fields are what lets the UI offer the matching way out.
/// </remarks>
/// <param name="FilePath">The file that failed or the name of the data source when the failure was not about one file.</param>
/// <param name="DocumentKey">The key of the document that failed, for a file its full path, or the name of the data source when the failure was not about one document.</param>
/// <param name="Reason">What to tell the user about it, ready to show.</param>
/// <param name="OccurredAtUtc">When it happened, so the list still makes sense when the user looks at it later.</param>
/// <param name="FailureReason">What kind of failure it was. Everything that did not come from a provider stays at NONE.</param>
/// <param name="StatusCode">What the provider answered, where it answered at all.</param>
/// <param name="EmbeddingProviderName">The embedding provider that was asked.</param>
/// <param name="ExtractionCode">Why reading the file failed, where the failure was about reading it at all.</param>
/// <param name="IsPermanent">Whether the file stays out of the index until it changes.</param>
public sealed record DataSourceEmbeddingFailure(string FilePath, string Reason, DateTimeOffset OccurredAtUtc, ProviderRequestFailureReason FailureReason = ProviderRequestFailureReason.NONE,
HttpStatusCode? StatusCode = null, string EmbeddingProviderName = "", FileExtractionErrorCode ExtractionCode = FileExtractionErrorCode.NONE, bool IsPermanent = false);
/// <param name="ExtractionCode">Why reading the document failed, where the failure was about reading it at all.</param>
/// <param name="IsPermanent">Whether the document stays out of the index until it changes.</param>
/// <param name="DisplayName">How the document is called for the user, e.g. the name of a file or the subject of a mail. Empty where the run did not know it, e.g. for a failure stored by an earlier run.</param>
public sealed record DataSourceEmbeddingFailure(string DocumentKey, string Reason, DateTimeOffset OccurredAtUtc, ProviderRequestFailureReason FailureReason = ProviderRequestFailureReason.NONE,
HttpStatusCode? StatusCode = null, string EmbeddingProviderName = "", FileExtractionErrorCode ExtractionCode = FileExtractionErrorCode.NONE, bool IsPermanent = false, string DisplayName = "");
@@ -1,3 +1,3 @@
namespace AIStudio.Tools.Services;
public sealed record DataSourceEmbeddingOverview(DataSourceEmbeddingState State, int IndexedFiles, int TotalFiles, int FailedFiles);
public sealed record DataSourceEmbeddingOverview(DataSourceEmbeddingState State, int IndexedDocuments, int TotalDocuments, int FailedDocuments);
@@ -7,11 +7,11 @@ namespace AIStudio.Tools.Services;
internal static class DataSourceEmbeddingProviders
{
public static bool TryResolve(SettingsManager settingsManager, IDataSource dataSource, [NotNullWhen(true)] out EmbeddingProvider? embeddingProvider)
public static bool TryResolve(SettingsManager settingsManager, IDataSourceBase dataSource, [NotNullWhen(true)] out EmbeddingProvider? embeddingProvider)
{
embeddingProvider = settingsManager.ConfigurationData.EmbeddingProviders.FirstOrDefault(provider =>
dataSource is IInternalDataSource internalDataSource &&
provider.Id.Equals(internalDataSource.EmbeddingId, StringComparison.OrdinalIgnoreCase));
dataSource is IIndexedDataSource indexedDataSource &&
provider.Id.Equals(indexedDataSource.EmbeddingId, StringComparison.OrdinalIgnoreCase));
return embeddingProvider != default && embeddingProvider.UsedLLMProvider is not LLMProviders.NONE;
}
@@ -0,0 +1,23 @@
namespace AIStudio.Tools.Services;
/// <summary>
/// Why an indexing run of a data source was started.
/// </summary>
internal enum DataSourceEmbeddingRefreshMode
{
STARTUP_HASH_CHECK,
HASH_CHECK,
WATCHER_HASH_CHECK,
MANUAL_RETRY,
/// <summary>
/// Carries on where the last run of the same data source stopped after its share of the work.
/// </summary>
CONTINUATION,
/// <summary>
/// The interval came round at which a data source is looked at again, since nothing reports its
/// changes, e.g. a mailbox on a server.
/// </summary>
INTERVAL_CHECK,
}
@@ -0,0 +1,143 @@
using System.Globalization;
using AIStudio.Settings;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.Mail;
using AIStudio.Tools.Services.Indexing;
namespace AIStudio.Tools.Services;
public sealed partial class DataSourceEmbeddingService
{
internal const int DEFAULT_CHUNK_OVERLAP_TOKEN_LENGTH = 300;
/// <summary>
/// What this build writes next to a chunk besides its text. Raise it whenever that changes.
/// </summary>
/// <remarks>
/// A stored chunk keeps the metadata of the run which wrote it, and nothing recomputes it: the
/// fingerprint of a file says whether the file changed, not whether we got better at reading
/// it. Raising this number makes the embedding signature differ, which drops the index and
/// builds it again — the only way corrected page numbers reach a data source somebody indexed
/// earlier.
///
/// Version 2: the page of a chunk is taken from the runtime metadata instead of being read back
/// out of the chunk text, which is what left Word and OpenDocument files, and passages
/// continuing across a page break, without a page.
/// </remarks>
private const string CHUNK_METADATA_VERSION = "2";
/// <summary>
/// What this build makes of a mail before it is cut into chunks. Raise it whenever that changes.
/// </summary>
/// <remarks>
/// The counterpart of CHUNK_METADATA_VERSION for mailboxes alone. A mail on the server never
/// changes, so nothing reads it again once it is indexed, however differently MailTextBuilder
/// would write it today. Raising this number rebuilds the index of every mailbox and leaves
/// every other data source alone.
///
/// Version 1: the header block and the text from the HTML part, without attachments.
///
/// Version 2: the text of attached documents follows, each attachment under a line naming it.
/// The signature of a signed mail no longer counts as an attachment.
/// </remarks>
private const string MAIL_TEXT_VERSION = "2";
/// <summary>
/// Works out how the text of a data source is cut for a given embedding provider.
/// </summary>
/// <remarks>
/// Static, because the answer follows from its two arguments alone. That lets the embedding
/// signature be built for a configuration which is not stored yet, which is what the dialogs ask
/// before they save a change.
/// </remarks>
/// <param name="dataSource">The data source whose own chunk settings apply.</param>
/// <param name="embeddingProvider">The embedding provider whose token limit caps them.</param>
/// <returns>The chunk size and overlap which are actually used.</returns>
internal static ChunkingOptions GetChunkingOptions(IDataSourceBase dataSource, EmbeddingProvider embeddingProvider)
{
var providerMaxChunkTokenLength = Math.Max(1, embeddingProvider.EffectiveTokenLimit);
var dataSourceMaxChunkTokenLength = dataSource is IIndexedDataSource { MaxChunkTokenLength: > 0 } indexedDataSource
? indexedDataSource.MaxChunkTokenLength
: 0;
var maxChunkTokenLength = dataSourceMaxChunkTokenLength > 0
? Math.Min(dataSourceMaxChunkTokenLength, providerMaxChunkTokenLength)
: providerMaxChunkTokenLength;
var configuredOverlapTokenLength = dataSource is IIndexedDataSource overlapDataSource
? overlapDataSource.ChunkOverlapTokenLength
: DEFAULT_CHUNK_OVERLAP_TOKEN_LENGTH;
var overlapTokenLength = Math.Clamp(configuredOverlapTokenLength, 0, Math.Max(0, maxChunkTokenLength - 1));
return new(maxChunkTokenLength, overlapTokenLength);
}
/// <summary>
/// Describes how the vectors of a data source were made.
/// </summary>
/// <remarks>
/// What appears here decides when stored embeddings are thrown away: a signature differing from
/// the persisted one drops the whole index and builds it again. So it names the embedding model,
/// where it runs, how the text was cut for it, and the chunk metadata version — the things a
/// vector actually depends on.
///
/// Two of them are less obvious than they look. The Hugging Face inference provider belongs to
/// where the model runs: the same model name served by another backend is another vector source.
/// And a custom tokenizer enters through its content, not through its path, because a tokenizer
/// is stored under the name it came with — almost always tokenizer.json — so swapping one for
/// another lands on the identical path, while moving the data directory changes every path
/// without changing a single tokenizer.
///
/// The chunk settings enter only as what they amount to, never as what somebody typed. A data
/// source storing 0 means "follow the embedding provider", and writing that provider's own limit
/// into the field changes nothing about how the text is cut. Carrying the typed numbers as well
/// made that a different signature, so opening the expert settings of a data source — which
/// fills an empty limit with the provider's — threw the whole index away for nothing.
///
/// The confidence level a data source asks of a provider is deliberately not among them. It
/// changes no vector, and it is enforced live on every request anyway: DataSourceService checks
/// it against the participating chat providers and against the embedding provider, and this
/// service checks it again before each indexing run. It was part of this signature once, which
/// re-embedded every file of a data source whenever somebody raised or lowered it — real money
/// at a cloud embedding provider, for nothing.
///
/// A mailbox appends the version of its mail text, cf. MAIL_TEXT_VERSION, and up to which size
/// it reads attachments. A mail is never read again once it is indexed, so reading attachments
/// from now on, or larger ones, reaches the mails indexed before only by indexing them anew. The
/// dialog asks before it does that, as for every change of this signature. Nothing else is
/// appended for the other kinds of data source, so their stored signatures stay valid.
/// </remarks>
internal static string BuildEmbeddingSignature(IDataSourceBase dataSource, EmbeddingProvider embeddingProvider, ChunkingOptions chunkingOptions)
{
var signature = string.Join('|',
CHUNK_METADATA_VERSION,
embeddingProvider.Id,
embeddingProvider.UsedLLMProvider,
embeddingProvider.Model.Id,
embeddingProvider.Host,
embeddingProvider.Hostname,
embeddingProvider.HFInferenceProvider,
embeddingProvider.TokenizerFingerprint,
embeddingProvider.EffectiveTokenLimit,
chunkingOptions.MaxChunkTokenLength,
chunkingOptions.OverlapTokenLength);
if (dataSource is not DataSourceMailbox mailbox)
return signature;
var attachments = MailAttachmentRules.GetMaxSizeMegabytes(mailbox) is { } maxSizeMegabytes
? maxSizeMegabytes.ToString(CultureInfo.InvariantCulture)
: "none";
return $"{signature}|mail:{MAIL_TEXT_VERSION}|attachments:{attachments}";
}
/// <summary>
/// Describes how the vectors of a data source were made, working the chunking out along the way.
/// </summary>
/// <param name="dataSource">The data source the vectors belong to.</param>
/// <param name="embeddingProvider">The embedding provider which makes them.</param>
/// <returns>The signature of this pairing.</returns>
internal static string BuildEmbeddingSignature(IDataSourceBase dataSource, EmbeddingProvider embeddingProvider) =>
BuildEmbeddingSignature(dataSource, embeddingProvider, GetChunkingOptions(dataSource, embeddingProvider));
}
@@ -1,3 +1,4 @@
using AIStudio.Settings.DataModel;
using AIStudio.Tools.Databases.IndexStore;
using AIStudio.Tools.Databases.VectorStore;
@@ -22,7 +23,7 @@ public sealed partial class DataSourceEmbeddingService
/// <param name="dataSourceId">The data source to build anew.</param>
public async Task RepairDataSourceAsync(string dataSourceId)
{
if (!this.TryGetConfiguredDataSource(dataSourceId, out var dataSource) || !this.IsSupportedInternalDataSource(dataSource))
if (!this.TryGetConfiguredIndexedSource(dataSourceId, out var dataSource))
return;
logger.LogWarning(
@@ -37,11 +38,99 @@ public sealed partial class DataSourceEmbeddingService
await this.ResetPersistedStateAsync(dataSourceId, null, null, CancellationToken.None);
this.statuses.TryRemove(dataSourceId, out _);
this.PublishStatusChanged();
PublishStatusChanged();
await this.QueueDataSourceAsync(dataSource, true, DataSourceEmbeddingRefreshMode.MANUAL_RETRY);
}
/// <summary>
/// Records that the user agreed to the removal a mailbox held back, and syncs the mailbox to carry it out.
/// </summary>
/// <remarks>
/// The count is the one the status showed the user, cf. DataSourceEmbeddingStatus.PendingRemovalCount.
/// The agreement holds for exactly that number: should a sync have come up with another one in the
/// meantime, nothing is agreed to, and the user is asked anew. The sync which carries the removal
/// out signs in like any other, so a recorded sign-in failure still keeps it from the server.
/// </remarks>
/// <param name="dataSourceId">The mailbox.</param>
/// <param name="removalCount">How many mails the user agreed to remove from the index.</param>
/// <returns>True when the agreement was recorded. False when the number changed in the meantime, or the index cannot be reached.</returns>
public async Task<bool> ApprovePendingMailRemovalAsync(string dataSourceId, int removalCount)
{
if (!this.TryGetConfiguredIndexedSource(dataSourceId, out var dataSource) || dataSource is not DataSourceMailbox)
return false;
var indexStore = await databaseClientProvider.GetIndexStoreAsync(CancellationToken.None);
if (!await indexStore.ApprovePendingMailRemovalAsync(dataSourceId, removalCount, CancellationToken.None))
{
logger.LogInformation("The removal held back for mailbox '{DataSourceId}' was not approved, since another number is held back by now or the index store is unavailable.", dataSourceId);
return false;
}
logger.LogInformation("The user approved removing {RemovalCount} mails from the index of mailbox '{DataSourceId}'.", removalCount, dataSourceId);
await this.QueueDataSourceAsync(dataSource, true, DataSourceEmbeddingRefreshMode.HASH_CHECK);
return true;
}
/// <summary>
/// Deletes what the index keeps of mailboxes which are no longer configured.
/// </summary>
/// <remarks>
/// Such leftovers come about in two ways. An older version of AI Studio does not know mailboxes
/// and saves the settings without them. Or the stores could not be reached while the user
/// deleted a mailbox. Either way, nothing would ever delete the index then, nor a refused
/// sign-in kept next to it.
///
/// Only with settings which were loaded and may be written. Settings which could not be read
/// look like settings without a single mailbox, and every index would be deleted for that. Both
/// stores have to be there as well: the row in the index store is all that leads to the
/// collection in the vector store, so deleting the one without the other would leave the other
/// behind for good.
///
/// Runs before the first run, so none of the mailboxes is in use. The password stays in the OS
/// keyring: its entry carries the name of the mailbox, which nothing here knows anymore.
/// </remarks>
/// <param name="token">The cancellation token.</param>
private async Task DeleteOrphanedMailboxIndexesAsync(CancellationToken token)
{
if (!settingsManager.HasCompletedInitialSettingsLoad || settingsManager.SettingsWriteBlocked)
{
logger.LogInformation("Leaving the indexes of mailboxes alone, since the settings could not be loaded completely. SettingsWriteBlockReason={SettingsWriteBlockReason}.", settingsManager.SettingsWriteBlockReason);
return;
}
try
{
var vectorStore = await databaseClientProvider.GetVectorStoreAsync(token);
var indexStore = await databaseClientProvider.GetIndexStoreAsync(token);
if (!vectorStore.IsAvailable || !indexStore.IsAvailable)
return;
var configuredIds = settingsManager.ConfigurationData.Mailboxes
.Select(mailbox => mailbox.Id)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var mailboxId in await indexStore.GetStoredMailboxIdsAsync(token))
{
if (configuredIds.Contains(mailboxId))
continue;
await this.ResetPersistedStateAsync(mailboxId, vectorStore, indexStore, token);
await indexStore.ClearMailboxAuthFailureAsync(mailboxId, token);
logger.LogInformation("Deleted the index of mailbox '{DataSourceId}', which is no longer configured.", mailboxId);
}
}
catch (OperationCanceledException) when (token.IsCancellationRequested)
{
throw;
}
catch (Exception exception)
{
// Nothing depends on it: whatever is left over is tried again on the next start.
logger.LogWarning(exception, "Could not delete the indexes of mailboxes which are no longer configured.");
}
}
private async Task ResetPersistedStateAsync(
string dataSourceId,
VectorStoreClient? vectorStore,
@@ -1,17 +1,19 @@
using System.Collections.Concurrent;
using AIStudio.Settings;
using AIStudio.Settings.DataModel;
namespace AIStudio.Tools.Services;
/// <remarks>
/// A watcher here is whatever an indexer uses to notice that one of its data sources changed, a file
/// system watcher for local files, an interval for mailboxes. The indexers own them; this service
/// only decides when they run.
/// </remarks>
public sealed partial class DataSourceEmbeddingService
{
private const int WATCHER_DEBOUNCE_SECONDS = 2;
private readonly ConcurrentDictionary<string, DataSourceWatcherRegistration> watchers = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, CancellationTokenSource> watcherDebounceTokens = new(StringComparer.OrdinalIgnoreCase);
private readonly object watcherDebounceLock = new();
/// <summary>
/// Lets every indexer track changes to its data sources, or stops all tracking.
/// </summary>
/// <remarks>
/// Tracking runs only while local data sources refresh on their own, and not before the startup
/// hash check is done.
/// </remarks>
private void RefreshWatchers()
{
if (!settingsManager.ConfigurationData.App.DataSourceIndexing.AutomaticRefresh)
@@ -27,263 +29,56 @@ public sealed partial class DataSourceEmbeddingService
return;
}
var supportedSources = settingsManager.ConfigurationData.DataSources
.Where(this.IsSupportedInternalDataSource)
.ToDictionary(source => source.Id, StringComparer.OrdinalIgnoreCase);
foreach (var existingWatcherId in this.watchers.Keys.Except(supportedSources.Keys, StringComparer.OrdinalIgnoreCase).ToList())
this.RemoveWatcher(existingWatcherId);
foreach (var dataSource in supportedSources.Values)
this.EnsureWatcher(dataSource);
}
private void EnsureWatcher(IDataSource dataSource)
{
if (!settingsManager.ConfigurationData.App.DataSourceIndexing.AutomaticRefresh)
return;
var configuration = GetWatchConfiguration(dataSource);
if (configuration is null)
return;
if (this.watchers.TryGetValue(dataSource.Id, out var existingRegistration))
{
if (IsSameWatchConfiguration(existingRegistration.Configuration, configuration))
return;
this.RemoveWatcher(dataSource.Id);
}
var watcher = this.CreateWatcher(dataSource.Id, configuration);
if (watcher is null)
return;
if (!this.watchers.TryAdd(dataSource.Id, new DataSourceWatcherRegistration(watcher, configuration)))
watcher.Dispose();
}
private FileSystemWatcher? CreateWatcher(string dataSourceId, DataSourceWatcherConfiguration configuration)
{
try
{
var watcher = new FileSystemWatcher(configuration.RootPath)
{
Filter = configuration.Filter,
IncludeSubdirectories = configuration.IncludeSubdirectories,
NotifyFilter = NotifyFilters.FileName | NotifyFilters.DirectoryName | NotifyFilters.LastWrite | NotifyFilters.CreationTime | NotifyFilters.Size,
};
watcher.Changed += (_, args) => this.OnWatchedDataSourceChanged(dataSourceId, configuration, args);
watcher.Deleted += (_, args) => this.OnWatchedDataSourceChanged(dataSourceId, configuration, args);
watcher.Created += (_, args) => this.OnWatchedDataSourceChanged(dataSourceId, configuration, args);
watcher.Renamed += (_, args) => this.OnWatchedDataSourceChanged(dataSourceId, configuration, args);
watcher.Error += (_, args) =>
{
logger.LogWarning(args.GetException(), "The file watcher for data source '{DataSourceId}' failed. Recreating it.", dataSourceId);
this.RemoveWatcher(dataSourceId);
this.EnsureWatcher(dataSourceId);
this.ScheduleWatchedDataSourceRefresh(dataSourceId);
};
watcher.EnableRaisingEvents = true;
return watcher;
}
catch (Exception exception)
{
logger.LogWarning(exception, "Failed to create file watcher for data source '{DataSourceId}' at '{RootPath}'.", dataSourceId, configuration.RootPath);
return null;
}
var supportedSources = this.GetConfiguredIndexedSources();
foreach (var indexer in this.indexers)
indexer.TrackChanges(supportedSources.Where(indexer.Supports).ToList(), this.RequestRunAsync);
}
private void RemoveWatcher(string dataSourceId)
{
this.CancelPendingWatcherRefresh(dataSourceId);
if (this.watchers.TryRemove(dataSourceId, out var registration))
registration.Watcher.Dispose();
foreach (var indexer in this.indexers)
indexer.StopTracking(dataSourceId);
}
private void RemoveAllWatchers()
{
foreach (var watcherId in this.watchers.Keys.ToList())
this.RemoveWatcher(watcherId);
foreach (var indexer in this.indexers)
indexer.StopTrackingAll();
}
private void DisposeWatchers()
{
this.CancelAllPendingWatcherRefreshes();
foreach (var registration in this.watchers.Values)
registration.Watcher.Dispose();
this.watchers.Clear();
foreach (var indexer in this.indexers)
indexer.Dispose();
}
private void OnWatchedDataSourceChanged(string dataSourceId, DataSourceWatcherConfiguration configuration, FileSystemEventArgs args)
/// <summary>
/// Queues the run an indexer asked for after it noticed a change, or because its interval came round.
/// </summary>
/// <param name="dataSourceId">The id of the data source which changed.</param>
/// <param name="refreshMode">Why the indexer asks.</param>
private async Task RequestRunAsync(string dataSourceId, DataSourceEmbeddingRefreshMode refreshMode)
{
if (!this.IsRelevantWatcherEvent(configuration, args))
if (!this.TryGetConfiguredIndexedSource(dataSourceId, out var dataSource))
return;
if (this.statuses.TryGetValue(dataSourceId, out var status) && IsWaitingForSignIn(status))
{
logger.LogDebug(
"Ignoring file system change for data source '{DataSourceId}' at '{Path}' (event={ChangeType}) because the path is not part of the RAG index.",
dataSourceId,
args.FullPath,
args.ChangeType);
logger.LogDebug("Skipped the requested run of data source '{DataSourceId}' because its sign-in failed and waits for the user. RefreshMode={RefreshMode}.", dataSourceId, refreshMode);
return;
}
logger.LogDebug(
"Detected relevant file system change for data source '{DataSourceId}' at '{Path}' (event={ChangeType}). Scheduling a debounced embedding run.",
dataSourceId,
args.FullPath,
args.ChangeType);
this.ScheduleWatchedDataSourceRefresh(dataSourceId);
await this.QueueDataSourceAsync(dataSource, true, refreshMode);
}
private void ScheduleWatchedDataSourceRefresh(string dataSourceId)
{
if (!settingsManager.ConfigurationData.App.DataSourceIndexing.AutomaticRefresh)
return;
var debounceToken = new CancellationTokenSource();
lock (this.watcherDebounceLock)
{
if (this.watcherDebounceTokens.Remove(dataSourceId, out var existingToken))
existingToken.Cancel();
this.watcherDebounceTokens[dataSourceId] = debounceToken;
}
_ = Task.Run(async () =>
{
try
{
await Task.Delay(TimeSpan.FromSeconds(WATCHER_DEBOUNCE_SECONDS), debounceToken.Token);
if (!this.TryCompletePendingWatcherRefresh(dataSourceId, debounceToken))
return;
var dataSource = settingsManager.ConfigurationData.DataSources
.FirstOrDefault(source => source.Id.Equals(dataSourceId, StringComparison.OrdinalIgnoreCase));
if (dataSource is not null)
{
logger.LogInformation("Queueing data source '{DataSourceName}' ({DataSourceId}) after file system changes settled. The hash pipeline will reindex only changed files.", dataSource.Name, dataSource.Id);
await this.QueueDataSourceAsync(dataSource, true, DataSourceEmbeddingRefreshMode.WATCHER_HASH_CHECK);
}
}
catch (OperationCanceledException)
{
}
catch (Exception exception)
{
logger.LogWarning(exception, "Failed to queue watched data source '{DataSourceId}' after a file system change.", dataSourceId);
}
finally
{
debounceToken.Dispose();
}
});
}
private void EnsureWatcher(string dataSourceId)
{
var dataSource = settingsManager.ConfigurationData.DataSources
.FirstOrDefault(source => source.Id.Equals(dataSourceId, StringComparison.OrdinalIgnoreCase));
if (dataSource is not null)
this.EnsureWatcher(dataSource);
}
private void CancelPendingWatcherRefresh(string dataSourceId)
{
lock (this.watcherDebounceLock)
{
if (this.watcherDebounceTokens.Remove(dataSourceId, out var token))
token.Cancel();
}
}
private void CancelAllPendingWatcherRefreshes()
{
lock (this.watcherDebounceLock)
{
foreach (var token in this.watcherDebounceTokens.Values)
token.Cancel();
this.watcherDebounceTokens.Clear();
}
}
private bool TryCompletePendingWatcherRefresh(string dataSourceId, CancellationTokenSource debounceToken)
{
lock (this.watcherDebounceLock)
{
if (!this.watcherDebounceTokens.TryGetValue(dataSourceId, out var currentToken) || !ReferenceEquals(currentToken, debounceToken))
return false;
this.watcherDebounceTokens.Remove(dataSourceId);
return true;
}
}
private bool IsRelevantWatcherEvent(DataSourceWatcherConfiguration configuration, FileSystemEventArgs args)
{
if (args is RenamedEventArgs renamedArgs)
{
return this.IsRelevantWatcherPath(configuration, renamedArgs.FullPath, args.ChangeType)
|| this.IsRelevantWatcherPath(configuration, renamedArgs.OldFullPath, args.ChangeType);
}
return this.IsRelevantWatcherPath(configuration, args.FullPath, args.ChangeType);
}
private bool IsRelevantWatcherPath(DataSourceWatcherConfiguration configuration, string path, WatcherChangeTypes changeType)
{
if (string.IsNullOrWhiteSpace(path))
return false;
var fileName = Path.GetFileName(path);
if (string.IsNullOrWhiteSpace(fileName))
return true;
if (!configuration.IncludeSubdirectories && !string.Equals(fileName, configuration.Filter, StringComparison.OrdinalIgnoreCase))
return false;
if (Directory.Exists(path))
return true;
if (IsSkippedRagFileName(fileName))
return false;
if (this.IsSupportedRagFilePath(path))
return true;
return changeType is WatcherChangeTypes.Deleted or WatcherChangeTypes.Renamed
&& string.IsNullOrWhiteSpace(Path.GetExtension(path));
}
private static DataSourceWatcherConfiguration? GetWatchConfiguration(IDataSource dataSource) => dataSource switch
{
DataSourceLocalDirectory localDirectory when Directory.Exists(localDirectory.Path) => new DataSourceWatcherConfiguration(
localDirectory.Path,
"*.*",
true),
DataSourceLocalFile localFile when File.Exists(localFile.FilePath) && !string.IsNullOrWhiteSpace(Path.GetDirectoryName(localFile.FilePath)) => new DataSourceWatcherConfiguration(
Path.GetDirectoryName(localFile.FilePath)!,
Path.GetFileName(localFile.FilePath),
false),
_ => null,
};
private static bool IsSameWatchConfiguration(DataSourceWatcherConfiguration left, DataSourceWatcherConfiguration right)
{
return left.IncludeSubdirectories == right.IncludeSubdirectories
&& string.Equals(left.RootPath, right.RootPath, StringComparison.OrdinalIgnoreCase)
&& string.Equals(left.Filter, right.Filter, StringComparison.OrdinalIgnoreCase);
}
private sealed record DataSourceWatcherConfiguration(string RootPath, string Filter, bool IncludeSubdirectories);
private sealed record DataSourceWatcherRegistration(FileSystemWatcher Watcher, DataSourceWatcherConfiguration Configuration);
/// <summary>
/// Whether a data source waits for the user since its sign-in failed.
/// </summary>
/// <remarks>
/// Then only the user starts its next run, by saving a new password or with a retry. The indexer
/// would not sign in on any other run either, but every one of them would make the row of the
/// data source change from failed to queued and back, every interval anew.
/// </remarks>
/// <param name="status">The current status of the data source.</param>
internal static bool IsWaitingForSignIn(DataSourceEmbeddingStatus status) => status.Attention is DataSourceAttention.AUTH_FAILED;
}
File diff suppressed because it is too large. Load diff
@@ -4,38 +4,49 @@ using AIStudio.Tools.PluginSystem;
namespace AIStudio.Tools.Services;
/// <remarks>
/// CurrentFileBlock and CurrentFilePage are null rather than zero while nothing is known about
/// them: a file which is only about to start has no first block, and not every kind of document
/// has pages to count. Block numbers start at one, the way the page states them.
/// Counts documents, whatever a document is for the kind of data source: a file for a folder, a
/// mail for a mailbox. Which word the user reads is the business of the page showing it.
///
/// CurrentDocumentBlock and CurrentDocumentPage are null rather than zero while nothing is known
/// about them: a document which is only about to start has no first block, and not every kind of
/// document has pages to count. Block numbers start at one, the way the page states them.
///
/// VectorStoreUnreadable says why a data source failed, not only that it did. The UI needs that
/// difference to offer the repair for this one case, and it is carried as its own flag so nothing
/// has to read it back out of the message in LastError.
/// has to read it back out of the message in LastError. Attention does the same for what the user
/// has to decide, and PendingRemovalCount is the number a held back removal asks about: agreeing to
/// it hands that very number back, cf. ApprovePendingMailRemovalAsync.
///
/// LastSyncUtc is when the data source was last worked through as a whole, for those which are
/// synced rather than watched, such as mailboxes. It stays null for the others.
/// </remarks>
public sealed record DataSourceEmbeddingStatus(
string DataSourceId,
string DataSourceName,
DataSourceType DataSourceType,
DataSourceEmbeddingState State,
int TotalFiles,
int IndexedFiles,
int FailedFiles,
string CurrentFile,
int TotalDocuments,
int IndexedDocuments,
int FailedDocuments,
string CurrentDocument,
string LastError,
IReadOnlyList<DataSourceEmbeddingFailure> Failures,
int PermanentlySkippedFiles = 0,
int? CurrentFileBlock = null,
int? CurrentFilePage = null,
bool VectorStoreUnreadable = false)
int PermanentlySkippedDocuments = 0,
int? CurrentDocumentBlock = null,
int? CurrentDocumentPage = null,
bool VectorStoreUnreadable = false,
DataSourceAttention Attention = DataSourceAttention.NONE,
int? PendingRemovalCount = null,
DateTimeOffset? LastSyncUtc = null)
{
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(DataSourceEmbeddingStatus).Namespace, nameof(DataSourceEmbeddingStatus));
/// <remarks>
/// Files which were skipped for good are done, even though nothing was indexed of them.
/// Documents which were skipped for good are done, even though nothing was indexed of them.
/// Leaving them out would keep the bar short of the end for a data source which has nothing
/// left to do.
/// </remarks>
public int ProgressPercent => this.TotalFiles <= 0 ? 0 : Math.Clamp((int)Math.Round((this.IndexedFiles + this.PermanentlySkippedFiles) * 100d / this.TotalFiles), 0, 100);
public int ProgressPercent => this.TotalDocuments <= 0 ? 0 : Math.Clamp((int)Math.Round((this.IndexedDocuments + this.PermanentlySkippedDocuments) * 100d / this.TotalDocuments), 0, 100);
public string StateLabel => this.State switch
{
@@ -1,8 +1,6 @@
using AIStudio.Chat;
using AIStudio.Provider;
using AIStudio.Settings;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.Databases;
using AIStudio.Tools.Databases.IndexStore;
using AIStudio.Tools.Databases.VectorStore;
using AIStudio.Tools.PluginSystem;
@@ -11,19 +9,10 @@ using AIStudio.Tools.Rust;
namespace AIStudio.Tools.Services;
public sealed class DataSourceLocalRetrievalService(
SettingsManager settingsManager, RustService rustService, DatabaseClientProvider databaseClientProvider,
DataSourceEmbeddingService embeddingService, ILogger<DataSourceLocalRetrievalService> logger)
public sealed class DataSourceLocalRetrievalService(LocalIndexSearchService indexSearch, ILogger<DataSourceLocalRetrievalService> logger)
{
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(DataSourceLocalRetrievalService).Namespace, nameof(DataSourceLocalRetrievalService));
//
// Which gaps the user was already told about in this session. Retrieval runs for every single
// message, so without this one broken embedding provider would put a warning on every prompt.
//
private readonly HashSet<string> reportedRetrievalGaps = new(StringComparer.Ordinal);
private readonly Lock retrievalGapLock = new();
private enum RetrievalChannel
{
VECTOR,
@@ -54,31 +43,6 @@ public sealed class DataSourceLocalRetrievalService(
int Rank);
// ReSharper restore NotAccessedPositionalProperty.Local
/// <summary>
/// What kept one retrieval from covering the whole data source.
/// </summary>
/// <param name="queryWrittenByUser">Whether the query is the user's own message, which decides who hears about its problems.</param>
private sealed class RetrievalRun(bool queryWrittenByUser)
{
// Both channels search at the same time:
private readonly Lock gapLock = new();
private readonly HashSet<RetrievalGap> gaps = [];
public bool QueryWrittenByUser => queryWrittenByUser;
public void Add(RetrievalGap gap)
{
lock (this.gapLock)
this.gaps.Add(gap);
}
public IReadOnlyList<RetrievalGap> GetGaps()
{
lock (this.gapLock)
return this.gaps.Order().ToList();
}
}
public Task<IReadOnlyList<IRetrievalContext>> RetrieveDataAsync(DataSourceLocalFile dataSource, IContent lastUserPrompt, ChatThread thread, CancellationToken token = default) =>
this.RetrieveDataAsync(dataSource, lastUserPrompt, token);
@@ -121,20 +85,18 @@ public sealed class DataSourceLocalRetrievalService(
// chunks are still in place and the keyword search would happily answer from them while
// the vector search finds nothing.
//
if (await embeddingService.IsAwaitingReindexAsync(dataSource, token))
{
logger.LogWarning("Skipping local retrieval for data source '{DataSourceName}' ({DataSourceId}) because its index has to be built anew.", dataSource.Name, dataSource.Id);
await this.ReportRetrievalGapAsync(dataSource, run, RetrievalGap.NOT_SEARCHED, "index-rebuilding", string.Format(TB("The data source '{0}' was left out of the answer: it is being indexed again and cannot be searched until that is finished."), dataSource.Name));
if (await indexSearch.IsAwaitingReindexAsync(dataSource, run, token))
return RetrievalPage.EMPTY with { Gaps = run.GetGaps() };
}
var collectionName = DataSourceEmbeddingNames.GetCollectionName(dataSource.Id);
var vectorTask = this.SearchVectorAsync(dataSource, query, window, collectionName, run, token);
var bm25Task = this.SearchBm25Async(dataSource, query, window, run, token);
var vectorTask = indexSearch.SearchVectorsAsync(dataSource, query, window, filter: null, run, token);
var bm25Task = indexSearch.SearchKeywordsAsync(dataSource, window, indexStore => indexStore.SearchChunksAsync(dataSource.Id, query, window, token), run, token);
await Task.WhenAll(vectorTask, bm25Task);
token.ThrowIfCancellationRequested();
this.LogVectorResults(dataSource, vectorTask.Result);
this.LogBm25Results(dataSource, bm25Task.Result);
var (hits, hasMore) = RetrievalPaging.Merge(
vectorTask.Result.Select((result, index) => FromVectorResult(result, index + 1)).ToList(),
bm25Task.Result.Select((result, index) => FromBm25Result(result, index + 1)).ToList(),
@@ -163,242 +125,6 @@ public sealed class DataSourceLocalRetrievalService(
return new RetrievalPage(contexts, hasMore) { Gaps = gaps };
}
private async Task<IReadOnlyList<VectorSearchResult>> SearchVectorAsync(
IInternalDataSource dataSource,
string query,
int maxMatches,
string collectionName,
RetrievalRun run,
CancellationToken token)
{
try
{
var vectorStore = await databaseClientProvider.GetVectorStoreAsync(token);
if (!vectorStore.IsAvailable)
{
logger.LogWarning(
"Skipping vector retrieval for data source '{DataSourceName}' ({DataSourceId}) because vector store '{VectorStoreName}' is unavailable.",
dataSource.Name,
dataSource.Id,
vectorStore.Name);
await this.ReportRetrievalGapAsync(dataSource, run, RetrievalGap.PARTLY_SEARCHED, "no-vector-store", string.Format(TB("The data source '{0}' was left out of the answer: its local index is not available."), dataSource.Name));
return [];
}
if (!DataSourceEmbeddingProviders.TryResolve(settingsManager, dataSource, out var embeddingProvider))
{
logger.LogWarning("Skipping vector retrieval for data source '{DataSourceName}' ({DataSourceId}) because the selected embedding provider is not available.", dataSource.Name, dataSource.Id);
await this.ReportRetrievalGapAsync(dataSource, run, RetrievalGap.PARTLY_SEARCHED, "no-embedding-provider", string.Format(TB("The data source '{0}' was left out of the answer: its embedding provider is not available. Please check it in the settings."), dataSource.Name));
return [];
}
if (!await this.QueryFitsEmbeddingProviderAsync(dataSource, embeddingProvider, query, run, token))
return [];
var provider = embeddingProvider.CreateProvider();
var vectors = await provider.EmbedTextAsync(embeddingProvider.Model, settingsManager, token, [query]);
token.ThrowIfCancellationRequested();
var vector = vectors.FirstOrDefault();
if (vector is null || vector.Count == 0)
{
logger.LogWarning("Skipping vector retrieval for data source '{DataSourceName}' ({DataSourceId}) because query embedding returned no vector.", dataSource.Name, dataSource.Id);
await this.ReportRetrievalGapAsync(dataSource, run, RetrievalGap.PARTLY_SEARCHED, "no-query-vector", string.Format(TB("The data source '{0}' was left out of the answer: its embedding provider '{1}' did not return a vector to search with."), dataSource.Name, embeddingProvider.Name));
return [];
}
var results = this.LimitSearchResults(
dataSource,
"vector",
await vectorStore.SearchEmbeddingAsync(collectionName, vector, maxMatches, token),
maxMatches);
this.LogVectorResults(dataSource, results);
return results;
}
catch (OperationCanceledException) when (token.IsCancellationRequested)
{
throw;
}
catch (ProviderRequestException exception)
{
//
// The embedding provider named the cause and what to do about it. That sentence is
// worth far more to the user than the fact that a search came back empty:
//
logger.LogWarning(
exception,
"Vector retrieval failed for data source '{DataSourceName}' ({DataSourceId}) because the embedding provider failed. FailureReason={FailureReason}, StatusCode={StatusCode}.",
dataSource.Name, dataSource.Id, exception.FailureReason, exception.StatusCode);
await this.ReportRetrievalGapAsync(dataSource, run, RetrievalGap.PARTLY_SEARCHED, $"provider-{exception.FailureReason}", string.Format(TB("The data source '{0}' was left out of the answer. {1}"), dataSource.Name, exception.UserMessage));
return [];
}
catch (VectorStoreUnreadableException exception)
{
//
// Its own gap key, because this is not a search which went wrong but an index which has
// to be built anew. Saying that once per session is what turns a silently shortened
// answer into one the user can do something about.
//
logger.LogWarning(exception, "Vector retrieval failed for data source '{DataSourceName}' ({DataSourceId}) because its vector store cannot be read.", dataSource.Name, dataSource.Id);
await this.ReportRetrievalGapAsync(dataSource, run, RetrievalGap.PARTLY_SEARCHED, "vector-store-unreadable", string.Format(TB("The data source '{0}' was left out of the answer: its index cannot be read anymore. You can repair it in your data source settings."), dataSource.Name));
return [];
}
catch (Exception exception)
{
logger.LogWarning(exception, "Vector retrieval failed for data source '{DataSourceName}' ({DataSourceId}).", dataSource.Name, dataSource.Id);
await this.ReportRetrievalGapAsync(dataSource, run, RetrievalGap.PARTLY_SEARCHED, "vector-search-failed", string.Format(TB("The data source '{0}' was left out of the answer because searching it failed."), dataSource.Name));
return [];
}
}
/// <summary>
/// Records that a data source cannot fully take part in answering, and tells the user once.
/// </summary>
/// <remarks>
/// A failed search is not an error of the chat: the model still answers, only without what
/// this data source knows. Saying so once is what keeps somebody from trusting an answer
/// which was put together without half of its sources. Saying it with every prompt would be
/// worse than saying nothing, which is why every gap is reported once per session.
///
/// The retrieval records every gap regardless, cf. RetrievalPage.Gaps: whoever asked for the
/// page has to know each time, not once per session.
/// </remarks>
/// <param name="dataSource">The data source which could not be searched.</param>
/// <param name="run">The retrieval this gap belongs to.</param>
/// <param name="gap">What the gap means for the search.</param>
/// <param name="gapKey">What kind of gap this is, so a different problem is reported again.</param>
/// <param name="userMessage">What to tell the user.</param>
private async Task ReportRetrievalGapAsync(IInternalDataSource dataSource, RetrievalRun run, RetrievalGap gap, string gapKey, string userMessage)
{
run.Add(gap);
if (!IsForTheUser(gap, run.QueryWrittenByUser))
return;
lock (this.retrievalGapLock)
{
if (!this.reportedRetrievalGaps.Add($"{dataSource.Id}::{gapKey}"))
return;
}
await MessageBus.INSTANCE.SendWarning(new(Icons.Material.Filled.SearchOff, userMessage));
}
/// <summary>
/// Whether the user has to hear about a gap.
/// </summary>
/// <remarks>
/// Problems of the data source are for the user, since only the user can fix them. Problems of
/// the query are for whoever wrote it. When the model worked the query out, telling the user
/// their message was too long would be wrong, and the model learns about it from the page and
/// can search with a shorter one.
/// </remarks>
/// <param name="gap">What the gap means for the search.</param>
/// <param name="queryWrittenByUser">Whether the query is the user's own message.</param>
/// <returns>True when the user has to be told.</returns>
internal static bool IsForTheUser(RetrievalGap gap, bool queryWrittenByUser) => gap is not RetrievalGap.QUERY_NOT_SEARCHABLE || queryWrittenByUser;
private async Task<bool> QueryFitsEmbeddingProviderAsync(
IInternalDataSource dataSource,
EmbeddingProvider embeddingProvider,
string query,
RetrievalRun run,
CancellationToken token)
{
var providerTokenLimit = Math.Max(1, embeddingProvider.EffectiveTokenLimit);
if (query.Length > RustService.MAX_TOKEN_COUNT_REQUEST_TEXT_LENGTH)
{
logger.LogWarning(
"Skipping vector retrieval for data source '{DataSourceName}' ({DataSourceId}) because the query has {CharacterCount} characters and exceeds the safe tokenizer request length of {MaxCharacterCount}. ProviderTokenLimit={ProviderTokenLimit}.",
dataSource.Name,
dataSource.Id,
query.Length,
RustService.MAX_TOKEN_COUNT_REQUEST_TEXT_LENGTH,
providerTokenLimit);
await this.ReportRetrievalGapAsync(dataSource, run, RetrievalGap.QUERY_NOT_SEARCHABLE, "query-too-long", string.Format(TB("The data source '{0}' was left out of the answer because your message is too long to search with."), dataSource.Name));
return false;
}
var tokenCountResponse = await rustService.GetTokenCount(embeddingProvider, query, token);
if (tokenCountResponse is not { Success: true })
{
logger.LogWarning(
"Skipping vector retrieval for data source '{DataSourceName}' ({DataSourceId}) because the token count for embedding provider '{EmbeddingProviderName}' could not be determined. Reason='{Reason}'.",
dataSource.Name,
dataSource.Id,
embeddingProvider.Name,
tokenCountResponse?.Message ?? "No response was returned by the tokenizer service.");
await this.ReportRetrievalGapAsync(dataSource, run, RetrievalGap.PARTLY_SEARCHED, "no-token-count", string.Format(TB("The data source '{0}' was left out of the answer: the tokenizer of its embedding provider '{1}' is not available."), dataSource.Name, embeddingProvider.Name));
return false;
}
var queryTokenCount = tokenCountResponse.Value.TokenCount;
if (queryTokenCount > providerTokenLimit)
{
logger.LogWarning(
"Skipping vector retrieval for data source '{DataSourceName}' ({DataSourceId}) because the query has {QueryTokenCount} tokens, exceeding embedding provider '{EmbeddingProviderName}' limit of {ProviderTokenLimit} tokens.",
dataSource.Name,
dataSource.Id,
queryTokenCount,
embeddingProvider.Name,
providerTokenLimit);
await this.ReportRetrievalGapAsync(dataSource, run, RetrievalGap.QUERY_NOT_SEARCHABLE, "query-over-token-limit", string.Format(TB("The data source '{0}' was left out of the answer because your message is longer than its embedding provider '{1}' accepts."), dataSource.Name, embeddingProvider.Name));
return false;
}
return true;
}
private async Task<IReadOnlyList<IndexStoreSearchResult>> SearchBm25Async(IInternalDataSource dataSource, string query, int maxMatches, RetrievalRun run, CancellationToken token)
{
try
{
var indexStore = await databaseClientProvider.GetIndexStoreAsync(token);
if (!indexStore.IsAvailable)
{
logger.LogWarning(
"Skipping BM25 retrieval for data source '{DataSourceName}' ({DataSourceId}) because local RAG index '{DatabaseName}' is unavailable.",
dataSource.Name,
dataSource.Id,
indexStore.Name);
run.Add(RetrievalGap.PARTLY_SEARCHED);
return [];
}
var results = this.LimitSearchResults(
dataSource,
"BM25",
await indexStore.SearchChunksAsync(dataSource.Id, query, maxMatches, token),
maxMatches);
this.LogBm25Results(dataSource, results);
return results;
}
catch (OperationCanceledException) when (token.IsCancellationRequested)
{
throw;
}
catch (Exception exception)
{
logger.LogWarning(exception, "BM25 retrieval failed for data source '{DataSourceName}' ({DataSourceId}).", dataSource.Name, dataSource.Id);
run.Add(RetrievalGap.PARTLY_SEARCHED);
return [];
}
}
private IReadOnlyList<T> LimitSearchResults<T>(IInternalDataSource dataSource, string searchName, IReadOnlyList<T> results, int maxMatches)
{
if (results.Count <= maxMatches)
return results;
logger.LogWarning(
"Local RAG {SearchName} search returned {ReturnedHits} chunks for data source '{DataSourceName}' ({DataSourceId}), which exceeds the requested maximum {MaxMatches}. Truncating to it.",
searchName,
results.Count,
dataSource.Name,
dataSource.Id,
maxMatches);
return results.Take(maxMatches).ToList();
}
private static LocalRetrievalHit FromVectorResult(VectorSearchResult result, int rank) =>
new(
RetrievalChannel.VECTOR,
@@ -19,7 +19,7 @@ internal static class EmbeddingChangeImpact
/// <param name="before">The embedding provider as it is stored.</param>
/// <param name="after">The embedding provider as it would be stored.</param>
/// <returns>True when the stored index would be discarded.</returns>
public static bool AffectsStoredIndex(IDataSource dataSource, EmbeddingProvider before, EmbeddingProvider after) =>
public static bool AffectsStoredIndex(IDataSourceBase dataSource, EmbeddingProvider before, EmbeddingProvider after) =>
!string.Equals(
DataSourceEmbeddingService.BuildEmbeddingSignature(dataSource, before),
DataSourceEmbeddingService.BuildEmbeddingSignature(dataSource, after),
@@ -39,7 +39,7 @@ internal static class EmbeddingChangeImpact
/// <param name="after">The data source as it would be stored.</param>
/// <param name="afterProvider">The embedding provider it would point at.</param>
/// <returns>True when the stored index would be discarded.</returns>
public static bool AffectsStoredIndex(IDataSource before, EmbeddingProvider beforeProvider, IDataSource after, EmbeddingProvider afterProvider) =>
public static bool AffectsStoredIndex(IDataSourceBase before, EmbeddingProvider beforeProvider, IDataSourceBase after, EmbeddingProvider afterProvider) =>
!string.Equals(
DataSourceEmbeddingService.BuildEmbeddingSignature(before, beforeProvider),
DataSourceEmbeddingService.BuildEmbeddingSignature(after, afterProvider),
Loaded 100 of 343 files, more files were not shown because too many files have changed in this diff. Show more