# 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.
509 lines
16 KiB
Python
509 lines
16 KiB
Python
import logging
|
|
import re
|
|
from base64 import urlsafe_b64decode, urlsafe_b64encode
|
|
from datetime import datetime, timedelta
|
|
from email.message import EmailMessage
|
|
from email.mime.text import MIMEText
|
|
from enum import Enum
|
|
from typing import Any
|
|
|
|
from arcade_tdk import ToolContext
|
|
from bs4 import BeautifulSoup
|
|
from google.oauth2.credentials import Credentials
|
|
from googleapiclient.discovery import build
|
|
|
|
from arcade_gmail.enums import (
|
|
GmailAction,
|
|
GmailReplyToWhom,
|
|
)
|
|
from arcade_gmail.exceptions import GmailServiceError, GmailToolError
|
|
|
|
logging.basicConfig(
|
|
level=logging.DEBUG,
|
|
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class DateRange(Enum):
|
|
TODAY = "today"
|
|
YESTERDAY = "yesterday"
|
|
LAST_7_DAYS = "last_7_days"
|
|
LAST_30_DAYS = "last_30_days"
|
|
THIS_MONTH = "this_month"
|
|
LAST_MONTH = "last_month"
|
|
THIS_YEAR = "this_year"
|
|
|
|
def to_date_query(self) -> str:
|
|
today = datetime.now()
|
|
result = "after:"
|
|
comparison_date = today
|
|
|
|
if self == DateRange.YESTERDAY:
|
|
comparison_date = today - timedelta(days=1)
|
|
elif self == DateRange.LAST_7_DAYS:
|
|
comparison_date = today - timedelta(days=7)
|
|
elif self == DateRange.LAST_30_DAYS:
|
|
comparison_date = today - timedelta(days=30)
|
|
elif self == DateRange.THIS_MONTH:
|
|
comparison_date = today.replace(day=1)
|
|
elif self == DateRange.LAST_MONTH:
|
|
comparison_date = (today.replace(day=1) - timedelta(days=1)).replace(day=1)
|
|
elif self == DateRange.THIS_YEAR:
|
|
comparison_date = today.replace(month=1, day=1)
|
|
elif self == DateRange.LAST_MONTH:
|
|
comparison_date = (today.replace(month=1, day=1) - timedelta(days=1)).replace(
|
|
month=1, day=1
|
|
)
|
|
|
|
return result + comparison_date.strftime("%Y/%m/%d")
|
|
|
|
|
|
def build_email_message(
|
|
recipient: str,
|
|
subject: str,
|
|
body: str,
|
|
cc: list[str] | None = None,
|
|
bcc: list[str] | None = None,
|
|
replying_to: dict[str, Any] | None = None,
|
|
action: GmailAction = GmailAction.SEND,
|
|
) -> dict[str, Any]:
|
|
if replying_to:
|
|
body = build_reply_body(body, replying_to)
|
|
|
|
message: EmailMessage | MIMEText
|
|
|
|
if action == GmailAction.SEND:
|
|
message = EmailMessage()
|
|
message.set_content(body)
|
|
elif action == GmailAction.DRAFT:
|
|
message = MIMEText(body)
|
|
|
|
message["To"] = recipient
|
|
message["Subject"] = subject
|
|
|
|
if cc:
|
|
message["Cc"] = ",".join(cc)
|
|
if bcc:
|
|
message["Bcc"] = ",".join(bcc)
|
|
if replying_to:
|
|
message["In-Reply-To"] = replying_to["header_message_id"]
|
|
message["References"] = f"{replying_to['header_message_id']}, {replying_to['references']}"
|
|
|
|
encoded_message = urlsafe_b64encode(message.as_bytes()).decode()
|
|
|
|
data = {"raw": encoded_message}
|
|
|
|
if replying_to:
|
|
data["threadId"] = replying_to["thread_id"]
|
|
|
|
return data
|
|
|
|
|
|
def _build_gmail_service(context: ToolContext) -> Any:
|
|
"""
|
|
Private helper function to build and return the Gmail service client.
|
|
|
|
Args:
|
|
context (ToolContext): The context containing authorization details.
|
|
|
|
Returns:
|
|
googleapiclient.discovery.Resource: An authorized Gmail API service instance.
|
|
"""
|
|
try:
|
|
credentials = Credentials(
|
|
context.authorization.token
|
|
if context.authorization and context.authorization.token
|
|
else ""
|
|
)
|
|
except Exception as e:
|
|
raise GmailServiceError(message="Failed to build Gmail service.", developer_message=str(e))
|
|
|
|
return build("gmail", "v1", credentials=credentials)
|
|
|
|
|
|
def build_gmail_query_string(
|
|
sender: str | None = None,
|
|
recipient: str | None = None,
|
|
subject: str | None = None,
|
|
body: str | None = None,
|
|
date_range: DateRange | None = None,
|
|
label: str | None = None,
|
|
) -> str:
|
|
"""Helper function to build a query string
|
|
for Gmail list_emails_by_header and search_threads tools.
|
|
"""
|
|
query = []
|
|
if sender:
|
|
query.append(f"from:{sender}")
|
|
if recipient:
|
|
query.append(f"to:{recipient}")
|
|
if subject:
|
|
query.append(f"subject:{subject}")
|
|
if body:
|
|
query.append(body)
|
|
if date_range:
|
|
query.append(date_range.to_date_query())
|
|
if label:
|
|
query.append(f"label:{label}")
|
|
return " ".join(query)
|
|
|
|
|
|
def get_label_ids(service: Any, label_names: list[str]) -> dict[str, str]:
|
|
"""
|
|
Retrieve label IDs for given label names.
|
|
Returns a dictionary mapping label names to their IDs.
|
|
|
|
Args:
|
|
service: Authenticated Gmail API service instance.
|
|
label_names: List of label names to retrieve IDs for.
|
|
|
|
Returns:
|
|
A dictionary mapping found label names to their corresponding IDs.
|
|
"""
|
|
try:
|
|
# Fetch all existing labels from Gmail
|
|
labels = service.users().labels().list(userId="me").execute().get("labels", [])
|
|
except Exception as e:
|
|
raise GmailToolError(message="Failed to list labels.", developer_message=str(e)) from e
|
|
|
|
# Create a mapping from label names to their IDs
|
|
label_id_map = {label["name"]: label["id"] for label in labels}
|
|
|
|
found_labels = {}
|
|
for name in label_names:
|
|
label_id = label_id_map.get(name)
|
|
if label_id:
|
|
found_labels[name] = label_id
|
|
else:
|
|
logger.warning(f"Label '{name}' does not exist")
|
|
|
|
return found_labels
|
|
|
|
|
|
def fetch_messages(service: Any, query_string: str, limit: int) -> list[dict[str, Any]]:
|
|
"""
|
|
Helper function to fetch messages from Gmail API for the list_emails_by_header tool.
|
|
"""
|
|
response = (
|
|
service.users()
|
|
.messages()
|
|
.list(userId="me", q=query_string, maxResults=limit or 100)
|
|
.execute()
|
|
)
|
|
return response.get("messages", []) # type: ignore[no-any-return]
|
|
|
|
|
|
def remove_none_values(params: dict) -> dict:
|
|
"""
|
|
Remove None values from a dictionary.
|
|
:param params: The dictionary to clean
|
|
:return: A new dictionary with None values removed
|
|
"""
|
|
return {k: v for k, v in params.items() if v is not None}
|
|
|
|
|
|
def build_reply_recipients(
|
|
replying_to: dict[str, Any], current_user_email_address: str, reply_to_whom: GmailReplyToWhom
|
|
) -> str:
|
|
if reply_to_whom == GmailReplyToWhom.ONLY_THE_SENDER:
|
|
recipients = [replying_to["from"]]
|
|
elif reply_to_whom == GmailReplyToWhom.EVERY_RECIPIENT:
|
|
recipients = [replying_to["from"], *replying_to["to"].split(",")]
|
|
else:
|
|
raise ValueError(f"Unsupported reply_to_whom value: {reply_to_whom}")
|
|
|
|
recipients = [
|
|
email_address.strip()
|
|
for email_address in recipients
|
|
if email_address.strip().lower() != current_user_email_address.lower().strip()
|
|
]
|
|
|
|
return ", ".join(recipients)
|
|
|
|
|
|
def get_draft_url(draft_id: str) -> str:
|
|
return f"https://mail.google.com/mail/u/0/#drafts/{draft_id}"
|
|
|
|
|
|
def get_sent_email_url(sent_email_id: str) -> str:
|
|
return f"https://mail.google.com/mail/u/0/#sent/{sent_email_id}"
|
|
|
|
|
|
def get_email_details(service: Any, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
"""
|
|
Retrieves full message data for each message ID in the given list and extracts email details.
|
|
|
|
:param service: Authenticated Gmail API service instance.
|
|
:param messages: A list of dictionaries, each representing a message with an 'id' key.
|
|
:return: A list of dictionaries, each containing parsed email details.
|
|
"""
|
|
|
|
emails = []
|
|
for msg in messages:
|
|
try:
|
|
# Fetch the full message data from Gmail using the message ID
|
|
email_data = service.users().messages().get(userId="me", id=msg["id"]).execute()
|
|
# Parse the raw email data into a structured form
|
|
email_details = parse_plain_text_email(email_data)
|
|
# Only add the details if parsing was successful
|
|
if email_details:
|
|
emails.append(email_details)
|
|
except Exception as e:
|
|
# Log any errors encountered while trying to fetch or parse a message
|
|
raise GmailToolError(
|
|
message=f"Error reading email {msg['id']}.", developer_message=str(e)
|
|
)
|
|
return emails
|
|
|
|
|
|
def get_email_in_trash_url(email_id: str) -> str:
|
|
return f"https://mail.google.com/mail/u/0/#trash/{email_id}"
|
|
|
|
|
|
def parse_draft_email(draft_email_data: dict[str, Any]) -> dict[str, str]:
|
|
"""
|
|
Parse draft email data and extract relevant information.
|
|
|
|
Args:
|
|
draft_email_data (Dict[str, Any]): Raw draft email data from Gmail API.
|
|
|
|
Returns:
|
|
dict[str, str]: Parsed draft email details
|
|
"""
|
|
message = draft_email_data.get("message", {})
|
|
payload = message.get("payload", {})
|
|
headers = {d["name"].lower(): d["value"] for d in payload.get("headers", [])}
|
|
|
|
body_data = _get_email_plain_text_body(payload)
|
|
|
|
return {
|
|
"id": draft_email_data.get("id", ""),
|
|
"thread_id": draft_email_data.get("threadId", ""),
|
|
"from": headers.get("from", ""),
|
|
"date": headers.get("internaldate", ""),
|
|
"subject": headers.get("subject", ""),
|
|
"body": _clean_email_body(body_data) if body_data else "",
|
|
}
|
|
|
|
|
|
def _clean_email_body(body: str | None) -> str:
|
|
"""
|
|
Remove HTML tags and clean up email body text while preserving most content.
|
|
|
|
Args:
|
|
body (str): The raw email body text.
|
|
|
|
Returns:
|
|
str: Cleaned email body text.
|
|
"""
|
|
if not body:
|
|
return ""
|
|
|
|
try:
|
|
# Remove HTML tags using BeautifulSoup
|
|
soup = BeautifulSoup(body, "html.parser")
|
|
text = soup.get_text(separator=" ")
|
|
|
|
# Clean up the text
|
|
cleaned_text = _clean_text(text)
|
|
|
|
return cleaned_text.strip()
|
|
except Exception:
|
|
logger.exception("Error cleaning email body")
|
|
return body
|
|
|
|
|
|
def _get_email_plain_text_body(payload: dict[str, Any]) -> str | None:
|
|
"""
|
|
Extract email body from payload, handling 'multipart/alternative' parts.
|
|
|
|
Args:
|
|
payload (Dict[str, Any]): Email payload data.
|
|
|
|
Returns:
|
|
str | None: Decoded email body or None if not found.
|
|
"""
|
|
# Direct body extraction
|
|
if "body" in payload and payload["body"].get("data"):
|
|
return _clean_email_body(urlsafe_b64decode(payload["body"]["data"]).decode())
|
|
|
|
# Handle multipart and alternative parts
|
|
return _clean_email_body(_extract_plain_body(payload.get("parts", [])))
|
|
|
|
|
|
def _extract_plain_body(parts: list) -> str | None:
|
|
"""
|
|
Recursively extract the email body from parts, handling both plain text and HTML.
|
|
|
|
Args:
|
|
parts (List[Dict[str, Any]]): List of email parts.
|
|
|
|
Returns:
|
|
str | None: Decoded and cleaned email body or None if not found.
|
|
"""
|
|
for part in parts:
|
|
mime_type = part.get("mimeType")
|
|
|
|
if mime_type == "text/plain" and "data" in part.get("body", {}):
|
|
return urlsafe_b64decode(part["body"]["data"]).decode()
|
|
|
|
elif mime_type.startswith("multipart/"):
|
|
subparts = part.get("parts", [])
|
|
body = _extract_plain_body(subparts)
|
|
if body:
|
|
return body
|
|
|
|
return _extract_html_body(parts)
|
|
|
|
|
|
def _extract_html_body(parts: list) -> str | None:
|
|
"""
|
|
Recursively extract the email body from parts, handling only HTML.
|
|
|
|
Args:
|
|
parts (List[Dict[str, Any]]): List of email parts.
|
|
|
|
Returns:
|
|
str | None: Decoded and cleaned email body or None if not found.
|
|
"""
|
|
for part in parts:
|
|
mime_type = part.get("mimeType")
|
|
|
|
if mime_type == "text/html" and "data" in part.get("body", {}):
|
|
html_content = urlsafe_b64decode(part["body"]["data"]).decode()
|
|
return html_content
|
|
|
|
elif mime_type.startswith("multipart/"):
|
|
subparts = part.get("parts", [])
|
|
body = _extract_html_body(subparts)
|
|
if body:
|
|
return body
|
|
|
|
return None
|
|
|
|
|
|
def _clean_text(text: str) -> str:
|
|
"""
|
|
Clean up the text while preserving most content.
|
|
|
|
Args:
|
|
text (str): The input text.
|
|
|
|
Returns:
|
|
str: Cleaned text.
|
|
"""
|
|
# 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
|
|
|
|
|
|
def parse_plain_text_email(email_data: dict[str, Any]) -> dict[str, Any]:
|
|
"""
|
|
Parse email data and extract relevant information.
|
|
Only returns the plain text body.
|
|
|
|
Args:
|
|
email_data (dict[str, Any]): Raw email data from Gmail API.
|
|
|
|
Returns:
|
|
dict[str, str]: Parsed email details
|
|
"""
|
|
payload = email_data.get("payload", {})
|
|
headers = {d["name"].lower(): d["value"] for d in payload.get("headers", [])}
|
|
|
|
body_data = _get_email_plain_text_body(payload)
|
|
|
|
email_details = {
|
|
"id": email_data.get("id", ""),
|
|
"thread_id": email_data.get("threadId", ""),
|
|
"label_ids": email_data.get("labelIds", []),
|
|
"history_id": email_data.get("historyId", ""),
|
|
"snippet": email_data.get("snippet", ""),
|
|
"to": headers.get("to", ""),
|
|
"cc": headers.get("cc", ""),
|
|
"from": headers.get("from", ""),
|
|
"reply_to": headers.get("reply-to", ""),
|
|
"in_reply_to": headers.get("in-reply-to", ""),
|
|
"references": headers.get("references", ""),
|
|
"header_message_id": headers.get("message-id", ""),
|
|
"date": headers.get("date", ""),
|
|
"subject": headers.get("subject", ""),
|
|
"body": body_data or "",
|
|
}
|
|
|
|
return email_details
|
|
|
|
|
|
def build_reply_body(body: str, replying_to: dict[str, Any]) -> str:
|
|
attribution = f"On {replying_to['date']}, {replying_to['from']} wrote:"
|
|
lines = replying_to["plain_text_body"].split("\n")
|
|
quoted_plain = "\n".join([f"> {line}" for line in lines])
|
|
return f"{body}\n\n{attribution}\n\n{quoted_plain}"
|
|
|
|
|
|
def parse_multipart_email(email_data: dict[str, Any]) -> dict[str, Any]:
|
|
"""
|
|
Parse email data and extract relevant information.
|
|
Returns the plain text and HTML body along with the images.
|
|
|
|
Args:
|
|
email_data (Dict[str, Any]): Raw email data from Gmail API.
|
|
|
|
Returns:
|
|
dict[str, Any]: Parsed email details
|
|
"""
|
|
|
|
payload = email_data.get("payload", {})
|
|
headers = {d["name"].lower(): d["value"] for d in payload.get("headers", [])}
|
|
|
|
# Extract different parts of the email
|
|
plain_text_body = _get_email_plain_text_body(payload)
|
|
html_body = _get_email_html_body(payload)
|
|
|
|
email_details = {
|
|
"id": email_data.get("id", ""),
|
|
"thread_id": email_data.get("threadId", ""),
|
|
"label_ids": email_data.get("labelIds", []),
|
|
"history_id": email_data.get("historyId", ""),
|
|
"snippet": email_data.get("snippet", ""),
|
|
"to": headers.get("to", ""),
|
|
"cc": headers.get("cc", ""),
|
|
"from": headers.get("from", ""),
|
|
"reply_to": headers.get("reply-to", ""),
|
|
"in_reply_to": headers.get("in-reply-to", ""),
|
|
"references": headers.get("references", ""),
|
|
"header_message_id": headers.get("message-id", ""),
|
|
"date": headers.get("date", ""),
|
|
"subject": headers.get("subject", ""),
|
|
"plain_text_body": plain_text_body or _clean_email_body(html_body),
|
|
"html_body": html_body or "",
|
|
}
|
|
|
|
return email_details
|
|
|
|
|
|
def _get_email_html_body(payload: dict[str, Any]) -> str | None:
|
|
"""
|
|
Extract email html body from payload, handling 'multipart/alternative' parts.
|
|
|
|
Args:
|
|
payload (Dict[str, Any]): Email payload data.
|
|
|
|
Returns:
|
|
str | None: Decoded email body or None if not found.
|
|
"""
|
|
# Direct body extraction
|
|
if "body" in payload and payload["body"].get("data"):
|
|
return urlsafe_b64decode(payload["body"]["data"]).decode()
|
|
|
|
# Handle multipart and alternative parts
|
|
return _extract_html_body(payload.get("parts", []))
|