mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-10-13 06:53:48 +00:00
Search newest Outlook mail first and let trusted providers continue chats
This commit is contained in:
1 parent
8f357c97ed
commit
262287ede9
7 files changed
+110
-63
No files matched your search
+77
-46
@@ -1,3 +1,4 @@
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Xml;
|
||||
@@ -13,6 +14,7 @@ internal sealed class EwsMailClient : IDisposable
|
||||
private const int MAX_RESPONSE_BYTES = 2_000_000;
|
||||
private const int MAX_FOLDERS = 250;
|
||||
private const int FOLDER_PAGE_SIZE = 100;
|
||||
private const int MAX_PARALLEL_FOLDER_SEARCHES = 4;
|
||||
internal const int MAX_RESULTS = 20;
|
||||
internal const int MAX_BODY_CHARACTERS = 20_000;
|
||||
|
||||
@@ -50,61 +52,88 @@ internal sealed class EwsMailClient : IDisposable
|
||||
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(token);
|
||||
deadline.CancelAfter(TimeSpan.FromSeconds(90));
|
||||
var (folders, foldersPartial) = await this.FindFoldersAsync(deadline.Token);
|
||||
|
||||
//
|
||||
// Recent mail is most often the mail the user means. Every folder is therefore asked for
|
||||
// its newest matches, and the newest of all of them win. Stopping at the first folders
|
||||
// with enough hits would return old mail from an archive and leave out yesterday's
|
||||
// message in the inbox.
|
||||
//
|
||||
var resultLock = new Lock();
|
||||
var messages = new List<EwsMessage>();
|
||||
var partial = foldersPartial;
|
||||
var searchedFolders = false;
|
||||
foreach (var folder in folders)
|
||||
try
|
||||
{
|
||||
if (messages.Count == MAX_RESULTS)
|
||||
var options = new ParallelOptions { MaxDegreeOfParallelism = MAX_PARALLEL_FOLDER_SEARCHES, CancellationToken = deadline.Token };
|
||||
await Parallel.ForEachAsync(folders, options, async (folder, folderToken) =>
|
||||
{
|
||||
partial = true;
|
||||
break;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var root = ResponseRoot(await this.SendAsync(BuildFindItem(folder, terms, MAX_RESULTS), deadline.Token), "FindItem");
|
||||
var items = root.Element(T + "Items") ?? throw new EwsMailException("Exchange returned an incomplete search response.");
|
||||
searchedFolders = true;
|
||||
var added = 0;
|
||||
foreach (var item in items.Elements(T + "Message"))
|
||||
var folderResult = await this.SearchFolderAsync(folder, terms, folderToken);
|
||||
lock (resultLock)
|
||||
{
|
||||
if (RequiredId(item, "ParentFolderId") != folder)
|
||||
{
|
||||
partial = true;
|
||||
continue;
|
||||
}
|
||||
var id = RequiredId(item, "ItemId");
|
||||
var subject = Truncate(item.Element(T + "Subject")?.Value ?? string.Empty, 300);
|
||||
var mailbox = item.Element(T + "From")?.Element(T + "Mailbox");
|
||||
var sender = Truncate(mailbox?.Element(T + "EmailAddress")?.Value ?? mailbox?.Element(T + "Name")?.Value ?? string.Empty, 320);
|
||||
var date = Truncate(item.Element(T + "DateTimeReceived")?.Value ?? item.Element(T + "DateTimeSent")?.Value ?? string.Empty, 64);
|
||||
var excerpt = item.Element(T + "Preview")?.Value ?? item.Element(T + "Body")?.Value ?? string.Empty;
|
||||
var webPath = item.Element(T + "WebClientReadFormQueryString")?.Value;
|
||||
messages.Add(new EwsMessage(id, subject, sender, date, Truncate(excerpt, 500), webPath?.Length <= 2048 ? webPath : null));
|
||||
added++;
|
||||
if (messages.Count == MAX_RESULTS)
|
||||
break;
|
||||
messages.AddRange(folderResult.Messages);
|
||||
partial |= folderResult.Partial;
|
||||
searchedFolders |= folderResult.Searched;
|
||||
}
|
||||
|
||||
if (root.Attribute("IncludesLastItemInRange")?.Value != "true" || items.Elements(T + "Message").Count() > added)
|
||||
partial = true;
|
||||
}
|
||||
catch (EwsMailException)
|
||||
{
|
||||
partial = true;
|
||||
}
|
||||
catch (OperationCanceledException) when (!token.IsCancellationRequested)
|
||||
{
|
||||
partial = true;
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (OperationCanceledException) when (!token.IsCancellationRequested)
|
||||
{
|
||||
partial = true;
|
||||
}
|
||||
|
||||
if (!searchedFolders && folders.Count > 0)
|
||||
throw new EwsMailException("Exchange could not search the primary mailbox. Check the VPN connection and EWS access.");
|
||||
|
||||
return new EwsSearchResult(messages, partial);
|
||||
if (messages.Count > MAX_RESULTS)
|
||||
partial = true;
|
||||
|
||||
var newest = messages.OrderByDescending(message => message.ReceivedAt).Take(MAX_RESULTS).ToList();
|
||||
return new EwsSearchResult(newest, partial);
|
||||
}
|
||||
|
||||
private async Task<(List<EwsMessage> Messages, bool Partial, bool Searched)> SearchFolderAsync(string folder, string terms, CancellationToken token)
|
||||
{
|
||||
try
|
||||
{
|
||||
var root = ResponseRoot(await this.SendAsync(BuildFindItem(folder, terms, MAX_RESULTS), token), "FindItem");
|
||||
var items = root.Element(T + "Items") ?? throw new EwsMailException("Exchange returned an incomplete search response.");
|
||||
var partial = root.Attribute("IncludesLastItemInRange")?.Value != "true";
|
||||
var returned = items.Elements(T + "Message").ToList();
|
||||
if (returned.Count > MAX_RESULTS)
|
||||
partial = true;
|
||||
|
||||
var messages = new List<EwsMessage>();
|
||||
foreach (var item in returned.Take(MAX_RESULTS))
|
||||
{
|
||||
if (RequiredId(item, "ParentFolderId") != folder)
|
||||
{
|
||||
partial = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
var id = RequiredId(item, "ItemId");
|
||||
var subject = Truncate(item.Element(T + "Subject")?.Value ?? string.Empty, 300);
|
||||
var mailbox = item.Element(T + "From")?.Element(T + "Mailbox");
|
||||
var sender = Truncate(mailbox?.Element(T + "EmailAddress")?.Value ?? mailbox?.Element(T + "Name")?.Value ?? string.Empty, 320);
|
||||
var rawDate = item.Element(T + "DateTimeReceived")?.Value ?? item.Element(T + "DateTimeSent")?.Value ?? string.Empty;
|
||||
var receivedAt = DateTimeOffset.TryParse(rawDate, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsedDate) ? parsedDate : DateTimeOffset.MinValue;
|
||||
var excerpt = item.Element(T + "Preview")?.Value ?? item.Element(T + "Body")?.Value ?? string.Empty;
|
||||
var webPath = item.Element(T + "WebClientReadFormQueryString")?.Value;
|
||||
messages.Add(new EwsMessage(id, subject, sender, Truncate(rawDate, 64), Truncate(excerpt, 500), webPath?.Length <= 2048 ? webPath : null, receivedAt));
|
||||
}
|
||||
|
||||
return (messages, partial, true);
|
||||
}
|
||||
catch (EwsMailException)
|
||||
{
|
||||
return ([], true, false);
|
||||
}
|
||||
catch (OperationCanceledException) when (!token.IsCancellationRequested)
|
||||
{
|
||||
// The per-request timeout of the HTTP client; the other folders are still searched.
|
||||
return ([], true, false);
|
||||
}
|
||||
}
|
||||
|
||||
internal async Task<EwsReadResult> ReadAsync(string itemId, CancellationToken token)
|
||||
@@ -250,8 +279,10 @@ internal sealed class EwsMailClient : IDisposable
|
||||
new XElement(M + "ItemShape", new XElement(T + "BaseShape", "IdOnly"),
|
||||
Properties("item:ParentFolderId", "item:Subject", "item:DateTimeReceived", "item:DateTimeSent", "item:Preview", "message:From", "item:WebClientReadFormQueryString")),
|
||||
new XElement(M + "IndexedPageItemView", new XAttribute("MaxEntriesReturned", pageSize), new XAttribute("Offset", 0), new XAttribute("BasePoint", "Beginning")),
|
||||
new XElement(M + "QueryString", terms),
|
||||
new XElement(M + "ParentFolderIds", new XElement(T + "FolderId", new XAttribute("Id", folderId)))));
|
||||
new XElement(M + "SortOrder", new XElement(T + "FieldOrder", new XAttribute("Order", "Descending"), new XElement(T + "FieldURI", new XAttribute("FieldURI", "item:DateTimeReceived")))),
|
||||
// The EWS schema requires this order: SortOrder, then ParentFolderIds, then QueryString.
|
||||
new XElement(M + "ParentFolderIds", new XElement(T + "FolderId", new XAttribute("Id", folderId))),
|
||||
new XElement(M + "QueryString", terms)));
|
||||
|
||||
internal static XDocument BuildGetItem(string itemId) => Envelope(new XElement(M + "GetItem",
|
||||
new XElement(M + "ItemShape", new XElement(T + "BaseShape", "IdOnly"), new XElement(T + "BodyType", "Text"),
|
||||
@@ -270,7 +301,7 @@ internal sealed class EwsMailClient : IDisposable
|
||||
public void Dispose() => this.client.Dispose();
|
||||
}
|
||||
|
||||
internal sealed record EwsMessage(string Id, string Subject, string Sender, string Date, string Excerpt, string? WebPath);
|
||||
internal sealed record EwsMessage(string Id, string Subject, string Sender, string Date, string Excerpt, string? WebPath, DateTimeOffset ReceivedAt);
|
||||
internal sealed record EwsSearchResult(IReadOnlyList<EwsMessage> Messages, bool Partial);
|
||||
internal sealed record EwsReadResult(string Subject, string Body, bool Truncated, string? WebPath);
|
||||
internal sealed class EwsMailException(string message) : Exception(message);
|
||||
+4
-2
@@ -40,7 +40,7 @@ public sealed class OutlookMailTool(PromptInjectionGuardService promptInjectionG
|
||||
Function = new()
|
||||
{
|
||||
Name = ToolSelectionRules.OUTLOOK_MAIL_TOOL_ID,
|
||||
DescriptionForLLM = "Search the signed-in employee's primary Outlook mailbox or read one message from a previous search. Works through company Exchange without opening Outlook.",
|
||||
DescriptionForLLM = "Search the signed-in employee's primary Outlook mailbox or read one message from a previous search. Search results are the newest matches first. Works through company Exchange without opening Outlook.",
|
||||
Parameters = ToolParameterSchemaBuilder.Create()
|
||||
.RequiredEnum(OPERATION_ARGUMENT, "Search mail or read a message from a previous result.", "search", "read")
|
||||
.OptionalString(TERMS_ARGUMENT, "Plain search terms, required for search.")
|
||||
@@ -179,8 +179,10 @@ public sealed class OutlookMailTool(PromptInjectionGuardService promptInjectionG
|
||||
{
|
||||
foreach (var key in this.cachedIds.Where(entry => entry.Value.ExpiresAt <= DateTimeOffset.UtcNow).Select(entry => entry.Key).ToList())
|
||||
this.cachedIds.Remove(key);
|
||||
// Every ID lives equally long, so the earliest expiry is the oldest one. The order of
|
||||
// the dictionary's keys says nothing about that once entries were removed.
|
||||
if (this.cachedIds.Count >= MAX_CACHED_IDS)
|
||||
this.cachedIds.Remove(this.cachedIds.Keys.First());
|
||||
this.cachedIds.Remove(this.cachedIds.MinBy(entry => entry.Value.ExpiresAt).Key);
|
||||
var id = Guid.NewGuid().ToString("N");
|
||||
this.cachedIds[id] = new CachedId(ewsId, endpoint.AbsoluteUri, DateTimeOffset.UtcNow.AddMinutes(15));
|
||||
return id;
|
||||
|
||||
Reference in new issue
Block a user