arcade-mcp/toolkits/outlook_mail/arcade_outlook_mail/message.py
Eric Gustin 07c52100f3
Split and rename multiple toolkits (#438)
# PR Description
## Split toolkits

This PR splits the `Microsoft`, `Google`, and `Search` toolkits into
multiple toolkits each.
 * `Microsoft` --> `OutlookCalendar`, `OutlookMail`.
* `Google` -----> `GoogleCalendar`, `GoogleContacts`, `GoogleDocs`,
`GoogleDrive`, `Gmail`, `GoogleSheets`
* `Search` -----> `GoogleFinance`, `GoogleFlights`, `GoogleHotels`,
`GoogleJobs`, `GoogleMaps`, `GoogleNews`, `GoogleSearch`,
`GoogleShopping`, `Walmart`, `Youtube`

> The original monolithic toolkits (`Microsoft`, `Google`, `Search`) are
not removed in this PR. The plan is to keep those toolkits around while
we
> 1. Stop documenting the toolkits, 
> 2. Stop displaying the toolkits in the dashboard, and 
> 3. Help customers migrate over to the new split toolkits.

## Rename toolkits
This PR renames the following toolkits 
* `Web` ------------> `Firecrawl`
* `CodeSandbox` ---> `E2B`

> The `Web` and `CodeSandbox` toolkits are not removed in this PR. The
plan is to keep them around while we
> 1. Stop documenting the toolkits, 
> 2. Stop displaying the toolkits in the dashboard, and 
> 3. Help customers migrate over to the new renamed toolkits.

## Rename tools
Since toolkit names were changed, this called for some tools to be
renamed as well.
* `GoogleSearch.SearchGoogle` ----------------> `GoogleSearch.Search`
* `GoogleShopping.SearchShoppingProducts` --->
`GoogleShopping.SearchProducts`
* `Walmart.SearchWalmartProducts` ------------> `Walmart.SearchProducts`
* `Walmart.GetWalmartProductDetails` --------->
`Walmart.GetProductDetails`
* `Youtube.SearchYoutubeVideos` -------------->
`Youtube.SearchForVideos`

## Google File Picker
Improvements to the Google File Picker experience were also added in
this PR.

The following tools will ALWAYS provide llm_instructions in their
response to "let the end-user know that they have the option to select
more files via the file picker url if they want to":
* `GoogleDocs.SearchDocuments`
* `GoogleDocs.SearchAndRetrieveDocuments`
* `GoogleDrive.GetFileTreeStructure`

The following tools will only provide the file picker URL if a 404 or
403 from the Google API:
* `GoogleDocs.InsertTextAtEndOfDocument`
* `GoogleDocs.GetDocumentById`
* `GoogleSheets.GetSpreadsheet`
* `GoogleSheets.WriteToCell`

Also, a standalone `GoogleDrive.GenerateGoogleFilePickerUrl` tool
exists.

## Other
* The `SearchDocuments` and `SearchAndRetrieveDocuments` tools used to
be organized within the Drive portion of the Google toolkit, but I moved
these into the new GoogleDocs toolkit because they are specific to Docs.

# Progress

- [x] `OutlookCalendar`
- [x] `OutlookMail`
- [x] `GoogleFinance`
- [x] `GoogleFlights`
- [x] `GoogleHotels`
- [x] `GoogleJobs`
- [x] `GoogleMaps`
- [x] `GoogleNews`
- [x] `GoogleSearch`
- [x] `GoogleShopping`
- [x] `Walmart`
- [x] `Youtube`
- [x] `GoogleCalendar`
- [x] `GoogleContacts`
- [x] `GoogleDocs`
- [x] `GoogleDrive`
- [x] `Gmail`
- [x] `GoogleSheets`
- [x] `Firecrawl`
- [x] `E2B`
- [x] File picker

# Discussion
* Repeated code is a consequence of splitting toolkits that use the same
provider. I am open to any ideas that would allow multiple toolkits to
reference common code. Comment your ideas in this PR.
2025-07-09 16:00:09 -07:00

218 lines
8.8 KiB
Python

import re
from dataclasses import dataclass, field
from typing import Any
from bs4 import BeautifulSoup
from msgraph.generated.models.body_type import BodyType
from msgraph.generated.models.email_address import EmailAddress
from msgraph.generated.models.item_body import ItemBody
from msgraph.generated.models.message import Message as GraphMessage
from msgraph.generated.models.recipient import Recipient as GraphRecipient
@dataclass
class Recipient:
"""A recipient of an email message."""
email_address: str = ""
name: str = ""
@classmethod
def from_sdk(cls, recipient: GraphRecipient) -> "Recipient":
"""Convert a Microsoft Graph SDK Recipient object to a Recipient dataclass."""
address = (
recipient.email_address.address
if recipient and recipient.email_address and recipient.email_address.address
else ""
)
name = (
recipient.email_address.name
if recipient and recipient.email_address and recipient.email_address.name
else ""
)
return cls(email_address=address, name=name)
def to_dict(self) -> dict[str, str]:
return {"email_address": self.email_address, "name": self.name}
def to_sdk(self) -> GraphRecipient:
"""Converts the Recipient dataclass to a Microsoft Graph SDK Recipient object."""
recipient = GraphRecipient()
email_address = EmailAddress()
email_address.address = self.email_address
email_address.name = self.name
recipient.email_address = email_address
return recipient
@dataclass
class Message:
"""An email message in Outlook."""
bcc_recipients: list[Recipient] = field(default_factory=list)
cc_recipients: list[Recipient] = field(default_factory=list)
reply_to: list[Recipient] = field(default_factory=list)
to_recipients: list[Recipient] = field(default_factory=list)
from_: Recipient = field(default_factory=Recipient)
subject: str = ""
body: str = ""
conversation_id: str = ""
conversation_index: str = ""
flag: dict[str, str] = field(default_factory=dict)
has_attachments: bool = False
importance: str = ""
is_read: bool = False
received_date_time: str = ""
web_link: str = ""
is_draft: bool = True
message_id: str = "" # The unique identifier of the email message. Read-only.
@staticmethod
def _safe_str(value: Any) -> str:
if not value:
return ""
if isinstance(value, bytes | bytearray):
return value.decode("utf-8", errors="ignore")
return str(value)
@staticmethod
def _safe_bool(value: Any) -> bool:
return bool(value)
@staticmethod
def _parse_body(mime: str) -> str:
if not mime:
return ""
soup = BeautifulSoup(mime, "html.parser")
text = soup.get_text(separator=" ")
# Replace multiple newlines with a single newline
text = re.sub(r"\n+", "\n", text)
# Replace multiple spaces with a single space
text = re.sub(r"\s+", " ", text)
# Remove leading/trailing whitespace from each line
text = "\n".join(line.strip() for line in text.split("\n"))
return text
@staticmethod
def _parse_importance(value: Any) -> str:
return value.value if getattr(value, "value", None) else ""
@staticmethod
def _parse_flag(flag: Any) -> dict[str, str]:
if not flag:
return {"flag_status": "", "due_date_time": ""}
status = flag.flag_status.value if getattr(flag, "flag_status", None) else ""
due = ""
if getattr(flag, "due_date_time", None) and getattr(flag.due_date_time, "date_time", None):
due = Message._safe_str(flag.due_date_time.date_time)
return {"flag_status": status, "due_date_time": due}
@classmethod
def from_sdk(cls, msg: GraphMessage) -> "Message":
"""Convert a Microsoft Graph SDK Message object to a Message dataclass."""
text = cls._parse_body(msg.body.content if msg.body and msg.body.content else "")
return cls(
bcc_recipients=[
Recipient.from_sdk(recipient) for recipient in msg.bcc_recipients or []
],
cc_recipients=[Recipient.from_sdk(recipient) for recipient in msg.cc_recipients or []],
reply_to=[Recipient.from_sdk(recipient) for recipient in msg.reply_to or []],
to_recipients=[Recipient.from_sdk(recipient) for recipient in msg.to_recipients or []],
from_=Recipient.from_sdk(msg.from_) if msg.from_ else Recipient(),
subject=cls._safe_str(msg.subject),
body=text,
conversation_id=cls._safe_str(msg.conversation_id),
conversation_index=(
msg.conversation_index.decode("utf-8", errors="ignore")
if isinstance(msg.conversation_index, bytes | bytearray)
else cls._safe_str(msg.conversation_index)
),
flag=cls._parse_flag(msg.flag),
has_attachments=cls._safe_bool(msg.has_attachments),
importance=cls._parse_importance(msg.importance),
is_read=cls._safe_bool(msg.is_read),
received_date_time=(
msg.received_date_time.isoformat() if msg.received_date_time else ""
),
web_link=cls._safe_str(msg.web_link),
is_draft=cls._safe_bool(msg.is_draft),
message_id=cls._safe_str(msg.id),
)
def to_sdk(self) -> GraphMessage:
"""Converts the Message dataclass to a Microsoft Graph SDK Message object."""
sdk_msg = GraphMessage()
sdk_msg.subject = self.subject
body_obj = ItemBody()
body_obj.content = self.body
body_obj.content_type = BodyType.Text
sdk_msg.body = body_obj
sdk_msg.is_draft = self.is_draft
sdk_msg.to_recipients = [r.to_sdk() for r in self.to_recipients]
sdk_msg.cc_recipients = [r.to_sdk() for r in self.cc_recipients]
sdk_msg.bcc_recipients = [r.to_sdk() for r in self.bcc_recipients]
sdk_msg.reply_to = [r.to_sdk() for r in self.reply_to]
return sdk_msg
def to_dict(self) -> dict[str, Any]:
"""Converts the Message dataclass to a dictionary."""
return {
"bcc_recipients": [recipient.to_dict() for recipient in self.bcc_recipients],
"cc_recipients": [recipient.to_dict() for recipient in self.cc_recipients],
"reply_to": [recipient.to_dict() for recipient in self.reply_to],
"to_recipients": [recipient.to_dict() for recipient in self.to_recipients],
"from": self.from_.to_dict(),
"subject": self.subject,
"body": self.body,
"conversation_id": self.conversation_id,
"conversation_index": self.conversation_index,
"flag": self.flag,
"has_attachments": self.has_attachments,
"importance": self.importance,
"is_read": self.is_read,
"received_date_time": self.received_date_time,
"web_link": self.web_link,
"is_draft": self.is_draft,
"message_id": self.message_id,
}
def update_recipient_lists(
self,
to_add: list[str] | None = None,
to_remove: list[str] | None = None,
cc_add: list[str] | None = None,
cc_remove: list[str] | None = None,
bcc_add: list[str] | None = None,
bcc_remove: list[str] | None = None,
) -> None:
"""Update each recipient list of the message.
This function updates the recipient lists of the message by first adding new recipients
and then removing existing recipients. Therefore, if an email address is both
added and removed, then it will not be included in the returned list.
"""
for attr, add_emails_input, remove_emails_input in (
("to_recipients", to_add, to_remove),
("cc_recipients", cc_add, cc_remove),
("bcc_recipients", bcc_add, bcc_remove),
):
current_recipients = getattr(self, attr) or []
# Add recipients
existing_emails = {r.email_address.lower() for r in current_recipients}
new_additions = [
Recipient(email_address=email)
for email in (add_emails_input or [])
if email.lower() not in existing_emails
]
# Remove recipients
updated_list = current_recipients + new_additions
remove_emails = {email.lower() for email in (remove_emails_input or [])}
updated_list = [
recipient
for recipient in updated_list
if recipient.email_address.lower() not in remove_emails
]
# Update the message's attribute with the new list
setattr(self, attr, updated_list)