Add Microsoft.ListEmailsByProperty tool (#386)
This commit is contained in:
parent
8d0d77af10
commit
e764205e6c
7 changed files with 280 additions and 6 deletions
|
|
@ -2,6 +2,7 @@ from arcade_microsoft.outlook_mail.tools import (
|
|||
create_and_send_email,
|
||||
create_draft_email,
|
||||
list_emails,
|
||||
list_emails_by_property,
|
||||
list_emails_in_folder,
|
||||
reply_to_email,
|
||||
send_draft_email,
|
||||
|
|
@ -11,6 +12,7 @@ from arcade_microsoft.outlook_mail.tools import (
|
|||
__all__ = [
|
||||
# Read
|
||||
"list_emails",
|
||||
"list_emails_by_property",
|
||||
"list_emails_in_folder",
|
||||
# Send
|
||||
"create_and_send_email",
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ from msgraph.generated.users.item.messages.messages_request_builder import (
|
|||
|
||||
from arcade_microsoft.client import get_client
|
||||
from arcade_microsoft.outlook_mail.constants import DEFAULT_MESSAGE_FIELDS
|
||||
from arcade_microsoft.outlook_mail.enums import ReplyType
|
||||
from arcade_microsoft.outlook_mail.enums import EmailFilterProperty, FilterOperator, ReplyType
|
||||
|
||||
|
||||
def remove_none_values(data: dict) -> dict:
|
||||
|
|
@ -23,15 +23,54 @@ def remove_none_values(data: dict) -> dict:
|
|||
return {k: v for k, v in data.items() if v is not None}
|
||||
|
||||
|
||||
def _create_filter_expression(
|
||||
property_: EmailFilterProperty | None = None,
|
||||
operator: FilterOperator | None = None,
|
||||
value: str | None = None,
|
||||
) -> str | None:
|
||||
if property_ and operator and value:
|
||||
property_value = property_.value
|
||||
operator_value = operator.value
|
||||
|
||||
# Never use quotes around 'value' for booleans and numerics
|
||||
value_quote = "'"
|
||||
if value.lower() in ["true", "false"] or value.isdigit():
|
||||
value_quote = ""
|
||||
|
||||
# Handle function operators (e.g., contains, startsWith, endsWith)
|
||||
if operator.is_function():
|
||||
filter_expr = f"{operator_value}({property_value}, {value_quote}{value}{value_quote})"
|
||||
else:
|
||||
# Handle comparison operators (e.g., eq, ne, gt, ge, lt, le)
|
||||
filter_expr = f"{property_value} {operator_value} {value_quote}{value}{value_quote}"
|
||||
|
||||
if property_value == EmailFilterProperty.RECEIVED_DATE_TIME:
|
||||
filter_expr = filter_expr
|
||||
else: # Since receivedDateTime is in orderby, it must be in filter: https://learn.microsoft.com/en-us/graph/api/user-list-messages?view=graph-rest-1.0&tabs=http#optional-query-parameters
|
||||
filter_expr = f"receivedDateTime ge 1900-01-01T00:00:00Z and {filter_expr}"
|
||||
|
||||
return filter_expr
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def prepare_list_emails_request_config(
|
||||
limit: int,
|
||||
property_: EmailFilterProperty | None = None,
|
||||
operator: FilterOperator | None = None,
|
||||
value: str | None = None,
|
||||
) -> MailFolderMessagesRequestBuilder.MessagesRequestBuilderGetRequestConfiguration:
|
||||
limit = max(1, min(limit, 100)) # limit must be between 1 and 100
|
||||
"""Prepare a request configuration for listing emails."""
|
||||
limit = max(1, min(limit, 100)) # limit must be between 1 and 100
|
||||
|
||||
orderby = ["receivedDateTime DESC"]
|
||||
filter_expr = _create_filter_expression(property_, operator, value)
|
||||
|
||||
query_params = MailFolderMessagesRequestBuilder.MessagesRequestBuilderGetQueryParameters(
|
||||
count=True,
|
||||
select=DEFAULT_MESSAGE_FIELDS,
|
||||
orderby=["receivedDateTime DESC"],
|
||||
orderby=orderby,
|
||||
filter=filter_expr,
|
||||
top=limit,
|
||||
)
|
||||
return MailFolderMessagesRequestBuilder.MessagesRequestBuilderGetRequestConfiguration(
|
||||
|
|
|
|||
|
|
@ -21,3 +21,45 @@ class ReplyType(str, Enum):
|
|||
|
||||
REPLY = "reply"
|
||||
REPLY_ALL = "reply_all"
|
||||
|
||||
|
||||
class EmailFilterProperty(str, Enum):
|
||||
"""The property to filter the emails by."""
|
||||
|
||||
# Basic properties
|
||||
SUBJECT = "subject"
|
||||
CONVERSATION_ID = "conversationId"
|
||||
RECEIVED_DATE_TIME = "receivedDateTime"
|
||||
SENDER = "sender/emailAddress/address"
|
||||
|
||||
|
||||
class FilterOperator(str, Enum):
|
||||
"""The operator to use for the filter.
|
||||
|
||||
For a full list of possible operators, see: https://learn.microsoft.com/en-us/graph/filter-query-parameter?tabs=http#operators-and-functions-supported-in-filter-expressions
|
||||
"""
|
||||
|
||||
# Equality operators
|
||||
EQUAL = "eq" # example: $filter=conversationId eq 'hello'
|
||||
NOT_EQUAL = "ne" # example: $filter=subject ne 'hello'
|
||||
|
||||
# Relational operators
|
||||
GREATER_THAN = "gt" # example: $filter=receivedDateTime gt 2024-01-01
|
||||
GREATER_THAN_OR_EQUAL_TO = "ge" # example: $filter=receivedDateTime ge 2024-01-01
|
||||
LESS_THAN = "lt" # example: $filter=receivedDateTime lt 2024-01-01
|
||||
LESS_THAN_OR_EQUAL_TO = "le" # example: $filter=receivedDateTime le 2024-01-01
|
||||
|
||||
# Functions
|
||||
STARTS_WITH = "startsWith" # example: $filter=startsWith(subject, 'hello')
|
||||
ENDS_WITH = "endsWith" # example: $filter=endsWith(subject, 'hello')
|
||||
CONTAINS = "contains" # example: $filter=contains(subject, 'hello')
|
||||
|
||||
def is_operator(self) -> bool:
|
||||
"""Check if the operator is a comparison operator."""
|
||||
operators = [self.EQUAL, self.NOT_EQUAL]
|
||||
return self in operators
|
||||
|
||||
def is_function(self) -> bool:
|
||||
"""Check if the operator is a function."""
|
||||
functions = [self.STARTS_WITH, self.ENDS_WITH, self.CONTAINS]
|
||||
return self in functions
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
from arcade_microsoft.outlook_mail.tools.read import (
|
||||
list_emails,
|
||||
list_emails_by_property,
|
||||
list_emails_in_folder,
|
||||
)
|
||||
from arcade_microsoft.outlook_mail.tools.send import (
|
||||
|
|
@ -15,6 +16,7 @@ from arcade_microsoft.outlook_mail.tools.write import (
|
|||
__all__ = [
|
||||
# Read
|
||||
"list_emails",
|
||||
"list_emails_by_property",
|
||||
"list_emails_in_folder",
|
||||
# Send
|
||||
"create_and_send_email",
|
||||
|
|
|
|||
|
|
@ -10,7 +10,11 @@ from arcade_microsoft.outlook_mail._utils import (
|
|||
prepare_list_emails_request_config,
|
||||
remove_none_values,
|
||||
)
|
||||
from arcade_microsoft.outlook_mail.enums import WellKnownFolderNames
|
||||
from arcade_microsoft.outlook_mail.enums import (
|
||||
EmailFilterProperty,
|
||||
FilterOperator,
|
||||
WellKnownFolderNames,
|
||||
)
|
||||
from arcade_microsoft.outlook_mail.message import Message
|
||||
|
||||
|
||||
|
|
@ -87,3 +91,32 @@ async def list_emails_in_folder(
|
|||
}
|
||||
result = remove_none_values(result)
|
||||
return result
|
||||
|
||||
|
||||
@tool(requires_auth=Microsoft(scopes=["Mail.Read"]))
|
||||
async def list_emails_by_property(
|
||||
context: ToolContext,
|
||||
property: Annotated[EmailFilterProperty, "The property to filter the emails by."], # noqa: A002
|
||||
operator: Annotated[FilterOperator, "The operator to use for the filter."],
|
||||
value: Annotated[str, "The value to filter the emails by"],
|
||||
limit: Annotated[int, "The number of messages to return. Max is 100. Defaults to 5."] = 5,
|
||||
pagination_token: Annotated[
|
||||
str | None, "The pagination token to continue a previous request"
|
||||
] = None,
|
||||
) -> Annotated[dict, "A dictionary containing a list of emails"]:
|
||||
"""List emails in the user's mailbox across all folders filtering by a property."""
|
||||
client = get_client(context.get_auth_token_or_empty())
|
||||
request_config = prepare_list_emails_request_config(limit, property, operator, value)
|
||||
message_builder = client.me.messages
|
||||
|
||||
response = await fetch_emails(message_builder, pagination_token, request_config)
|
||||
messages = [Message.from_sdk(msg).to_dict() for msg in response.value or []]
|
||||
pagination_token = response.odata_next_link
|
||||
|
||||
result = {
|
||||
"messages": messages,
|
||||
"num_messages": len(messages),
|
||||
"pagination_token": pagination_token,
|
||||
}
|
||||
result = remove_none_values(result)
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -1,13 +1,20 @@
|
|||
from datetime import timedelta
|
||||
|
||||
from arcade.sdk import ToolCatalog
|
||||
from arcade.sdk.eval import (
|
||||
BinaryCritic,
|
||||
DatetimeCritic,
|
||||
EvalRubric,
|
||||
EvalSuite,
|
||||
ExpectedToolCall,
|
||||
tool_eval,
|
||||
)
|
||||
|
||||
from arcade_microsoft.outlook_mail import list_emails, list_emails_in_folder
|
||||
from arcade_microsoft.outlook_mail import (
|
||||
list_emails,
|
||||
list_emails_by_property,
|
||||
list_emails_in_folder,
|
||||
)
|
||||
from arcade_microsoft.outlook_mail.enums import WellKnownFolderNames
|
||||
from evals.outlook_mail.additional_messages import (
|
||||
list_emails_with_pagination_token_additional_messages,
|
||||
|
|
@ -23,10 +30,11 @@ rubric = EvalRubric(
|
|||
catalog = ToolCatalog()
|
||||
catalog.add_tool(list_emails, "Microsoft")
|
||||
catalog.add_tool(list_emails_in_folder, "Microsoft")
|
||||
catalog.add_tool(list_emails_by_property, "Microsoft")
|
||||
|
||||
|
||||
@tool_eval()
|
||||
def outlook_mail_eval_suite() -> EvalSuite:
|
||||
def outlook_mail_read_eval_suite() -> EvalSuite:
|
||||
"""Create an evaluation suite for Outlook Mail tools."""
|
||||
suite = EvalSuite(
|
||||
name="Outlook Mail Tools Evaluation",
|
||||
|
|
@ -107,3 +115,96 @@ def outlook_mail_eval_suite() -> EvalSuite:
|
|||
)
|
||||
|
||||
return suite
|
||||
|
||||
|
||||
@tool_eval()
|
||||
def outlook_mail_list_emails_by_property_eval_suite() -> EvalSuite:
|
||||
"""Create an evaluation suite for Outlook Mail tools."""
|
||||
suite = EvalSuite(
|
||||
name="Outlook Mail Tools Evaluation",
|
||||
system_message=("You are an AI that has access to tools to send, read, and write emails."),
|
||||
catalog=catalog,
|
||||
rubric=rubric,
|
||||
)
|
||||
|
||||
suite.add_case(
|
||||
name="List emails by subject",
|
||||
user_message="get all emails that talk about The Green Bottle",
|
||||
expected_tool_calls=[
|
||||
ExpectedToolCall(
|
||||
func=list_emails_by_property,
|
||||
args={
|
||||
"property": "subject",
|
||||
"operator": "contains",
|
||||
"value": "The Green Bottle",
|
||||
},
|
||||
),
|
||||
],
|
||||
critics=[
|
||||
BinaryCritic(critic_field="property", weight=1 / 3),
|
||||
BinaryCritic(critic_field="operator", weight=1 / 3),
|
||||
BinaryCritic(critic_field="value", weight=1 / 3),
|
||||
],
|
||||
)
|
||||
|
||||
suite.extend_case(
|
||||
name="List emails by thread",
|
||||
user_message="get all emails in my thread 1k2jh324h92f24krjb34mtb43kj4bk3tmn34b3k4nnm3tb34mntb34mntb3m4bt3mn4bt3mn4btmnb34tmnb3t4mnb==34tkjh", # noqa: E501
|
||||
expected_tool_calls=[
|
||||
ExpectedToolCall(
|
||||
func=list_emails_by_property,
|
||||
args={
|
||||
"property": "conversationId",
|
||||
"operator": "eq",
|
||||
"value": "1k2jh324h92f24krjb34mtb43kj4bk3tmn34b3k4nnm3tb34mntb34mntb3m4bt3mn4bt3mn4btmnb34tmnb3t4mnb==34tkjh", # noqa: E501
|
||||
},
|
||||
),
|
||||
],
|
||||
critics=[
|
||||
BinaryCritic(critic_field="property", weight=1 / 3),
|
||||
BinaryCritic(critic_field="operator", weight=1 / 3),
|
||||
BinaryCritic(critic_field="value", weight=1 / 3),
|
||||
],
|
||||
)
|
||||
|
||||
suite.extend_case(
|
||||
name="List emails by date",
|
||||
user_message="Today is May 1st 2025. Get all emails that are a year old or older",
|
||||
expected_tool_calls=[
|
||||
ExpectedToolCall(
|
||||
func=list_emails_by_property,
|
||||
args={
|
||||
"property": "receivedDateTime",
|
||||
"operator": "le",
|
||||
"value": "2024-05-01T00:00:00Z",
|
||||
},
|
||||
),
|
||||
],
|
||||
critics=[
|
||||
BinaryCritic(critic_field="property", weight=1 / 3),
|
||||
BinaryCritic(critic_field="operator", weight=1 / 3),
|
||||
DatetimeCritic(critic_field="value", weight=1 / 3, tolerance=timedelta(days=1)),
|
||||
],
|
||||
)
|
||||
|
||||
suite.extend_case(
|
||||
name="List emails by sender",
|
||||
user_message="get all of my correspondence with the folks over at arcade.dev",
|
||||
expected_tool_calls=[
|
||||
ExpectedToolCall(
|
||||
func=list_emails_by_property,
|
||||
args={
|
||||
"property": "sender/emailAddress/address",
|
||||
"operator": "contains",
|
||||
"value": "arcade.dev",
|
||||
},
|
||||
),
|
||||
],
|
||||
critics=[
|
||||
BinaryCritic(critic_field="property", weight=1 / 3),
|
||||
BinaryCritic(critic_field="operator", weight=1 / 3),
|
||||
BinaryCritic(critic_field="value", weight=1 / 3),
|
||||
],
|
||||
)
|
||||
|
||||
return suite
|
||||
|
|
|
|||
55
toolkits/microsoft/tests/outlook_mail/test_utils.py
Normal file
55
toolkits/microsoft/tests/outlook_mail/test_utils.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import pytest
|
||||
|
||||
from arcade_microsoft.outlook_mail._utils import _create_filter_expression
|
||||
from arcade_microsoft.outlook_mail.enums import EmailFilterProperty, FilterOperator
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"property_, operator, value, expected_filter_expr",
|
||||
[
|
||||
(
|
||||
EmailFilterProperty.SUBJECT,
|
||||
FilterOperator.EQUAL,
|
||||
"Hello",
|
||||
"receivedDateTime ge 1900-01-01T00:00:00Z and subject eq 'Hello'",
|
||||
),
|
||||
(
|
||||
EmailFilterProperty.SUBJECT,
|
||||
FilterOperator.STARTS_WITH,
|
||||
"He",
|
||||
"receivedDateTime ge 1900-01-01T00:00:00Z and startsWith(subject, 'He')",
|
||||
),
|
||||
(
|
||||
EmailFilterProperty.CONVERSATION_ID,
|
||||
FilterOperator.EQUAL,
|
||||
"12345askdfjh=wef67890",
|
||||
"receivedDateTime ge 1900-01-01T00:00:00Z and conversationId eq '12345askdfjh=wef67890'", # noqa: E501
|
||||
),
|
||||
(
|
||||
EmailFilterProperty.CONVERSATION_ID,
|
||||
FilterOperator.NOT_EQUAL,
|
||||
"67890",
|
||||
"receivedDateTime ge 1900-01-01T00:00:00Z and conversationId ne 67890",
|
||||
),
|
||||
(
|
||||
EmailFilterProperty.RECEIVED_DATE_TIME,
|
||||
FilterOperator.GREATER_THAN,
|
||||
"2024-01-01",
|
||||
"receivedDateTime gt '2024-01-01'",
|
||||
),
|
||||
(
|
||||
EmailFilterProperty.SENDER,
|
||||
FilterOperator.EQUAL,
|
||||
"a@ex.com",
|
||||
"receivedDateTime ge 1900-01-01T00:00:00Z and sender/emailAddress/address eq 'a@ex.com'", # noqa: E501
|
||||
),
|
||||
(
|
||||
EmailFilterProperty.SENDER,
|
||||
FilterOperator.CONTAINS,
|
||||
"joe",
|
||||
"receivedDateTime ge 1900-01-01T00:00:00Z and contains(sender/emailAddress/address, 'joe')", # noqa: E501
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_create_filter_expression(property_, operator, value, expected_filter_expr):
|
||||
assert _create_filter_expression(property_, operator, value) == expected_filter_expr
|
||||
Loading…
Reference in a new issue