Files
owa-mcp/owa_mcp/tools/email.py
T
2026-07-06 01:58:46 +03:00

1177 lines
38 KiB
Python

"""Email tools for the OWA MCP server.
Provides tools for reading, sending, replying, forwarding, and managing
emails via the OWA Exchange API.
"""
import json
from mcp.server.fastmcp import Context
from owa_mcp.server import mcp, AppContext
from owa_mcp.owa_client import OWAClient, SessionExpiredError
from owa_mcp.server_lifecycle import require_client
from owa_mcp.utils import html_to_text, extract_links_from_html
def _get_client(ctx: Context) -> OWAClient:
"""Extract the OWAClient from the MCP lifespan context."""
app_ctx: AppContext = ctx.request_context.lifespan_context
return require_client(app_ctx.client)
def _get_change_key(client: OWAClient, item_id: str) -> str | None:
"""Fetch the ChangeKey for an item via GetItem (IdOnly).
OWA requires the ChangeKey on write operations like reply/forward.
"""
payload = {
"__type": "GetItemJsonRequest:#Exchange",
"Header": {
"__type": "JsonRequestHeaders:#Exchange",
"RequestServerVersion": "Exchange2013",
},
"Body": {
"__type": "GetItemRequest:#Exchange",
"ItemShape": {
"__type": "ItemResponseShape:#Exchange",
"BaseShape": "IdOnly",
},
"ItemIds": [{"__type": "ItemId:#Exchange", "Id": item_id}],
},
}
data = client.request("GetItem", payload)
for msg in client.extract_items(data):
if "Items" in msg:
for item in msg["Items"]:
return item.get("ItemId", {}).get("ChangeKey")
return None
def _extract_email_summary(item: dict) -> dict:
"""Extract a summary dict from a FindItem result item."""
item_type = item.get("__type", "Message:#Exchange")
is_meeting = any(
t in item_type
for t in ("MeetingRequest", "MeetingResponse", "MeetingCancellation")
)
# Resolve sender from From -> Organizer -> Sender
from_data = item.get("From", {}).get("Mailbox", {})
if not from_data:
from_data = item.get("Organizer", {}).get("Mailbox", {})
if not from_data:
from_data = item.get("Sender", {}).get("Mailbox", {})
email = {
"subject": item.get("Subject", "(No subject)"),
"from": from_data.get("EmailAddress", ""),
"from_name": from_data.get("Name", ""),
"date": item.get(
"DateTimeSent",
item.get("DateTimeReceived", item.get("DateTimeCreated", "")),
),
"is_read": item.get("IsRead", False),
"has_attachments": item.get("HasAttachments", False),
"item_id": item.get("ItemId", {}).get("Id", ""),
"size": item.get("Size", 0),
"is_meeting": is_meeting,
"type": "Meeting" if is_meeting else "Email",
"preview": item.get("Preview", ""),
"has_links": False,
}
# Basic recipients from DisplayTo/DisplayCc
email["to"] = (
[t.strip() for t in item.get("DisplayTo", "").split(";") if t.strip()]
if item.get("DisplayTo")
else []
)
email["cc"] = (
[c.strip() for c in item.get("DisplayCc", "").split(";") if c.strip()]
if item.get("DisplayCc")
else []
)
if is_meeting:
email["location"] = item.get("Location", "")
email["start"] = item.get(
"Start", item.get("StartWallClock", item.get("ReminderDueBy", ""))
)
email["end"] = item.get("End", item.get("EndWallClock", ""))
return email
def _get_item_details(client: OWAClient, item_id: str) -> dict:
"""Get full email details (body, recipients, attachments) via GetItem."""
payload = {
"__type": "GetItemJsonRequest:#Exchange",
"Header": {
"__type": "JsonRequestHeaders:#Exchange",
"RequestServerVersion": "V2017_08_18",
},
"Body": {
"__type": "GetItemRequest:#Exchange",
"ItemShape": {
"__type": "ItemResponseShape:#Exchange",
"BaseShape": "AllProperties",
"BodyType": "HTML",
},
"ItemIds": [{"__type": "ItemId:#Exchange", "Id": item_id}],
},
}
data = client.request("GetItem", payload)
result = {
"item_id": item_id,
"subject": "",
"from": "",
"from_name": "",
"date": "",
"to": [],
"cc": [],
"bcc": [],
"body": "",
"body_type": "Text",
"is_read": False,
"has_attachments": False,
"has_links": False,
"importance": "Normal",
"attachments": [],
}
for msg in client.extract_items(data):
if "Items" not in msg:
continue
for item in msg["Items"]:
result["subject"] = item.get("Subject", "(No subject)")
# Sender
from_data = item.get("From", {}).get("Mailbox", {})
if not from_data:
from_data = item.get("Sender", {}).get("Mailbox", {})
result["from"] = from_data.get("EmailAddress", "")
result["from_name"] = from_data.get("Name", "")
result["date"] = item.get(
"DateTimeSent",
item.get("DateTimeReceived", item.get("DateTimeCreated", "")),
)
result["is_read"] = item.get("IsRead", False)
result["has_attachments"] = item.get("HasAttachments", False)
result["importance"] = item.get("Importance", "Normal")
# Body
body_val = item.get("Body", {}).get("Value", "")
body_type = item.get("Body", {}).get("BodyType", "Text")
if body_type == "HTML":
result["has_links"] = bool(extract_links_from_html(body_val))
result["body"] = html_to_text(body_val)
else:
result["body"] = body_val
result["body_type"] = body_type
# To recipients
for r in item.get("ToRecipients", []):
name = r.get("Name", "")
addr = r.get("EmailAddress", "")
if name and addr:
result["to"].append(f"{name} <{addr}>")
elif addr:
result["to"].append(addr)
# CC recipients
for r in item.get("CcRecipients", []):
name = r.get("Name", "")
addr = r.get("EmailAddress", "")
if name and addr:
result["cc"].append(f"{name} <{addr}>")
elif addr:
result["cc"].append(addr)
# BCC recipients
for r in item.get("BccRecipients", []):
name = r.get("Name", "")
addr = r.get("EmailAddress", "")
if name and addr:
result["bcc"].append(f"{name} <{addr}>")
elif addr:
result["bcc"].append(addr)
# Attachments (with IDs for download)
for att in item.get("Attachments", []):
result["attachments"].append(
{
"name": att.get("Name", ""),
"size": att.get("Size", 0),
"content_type": att.get("ContentType", ""),
"attachment_id": att.get("AttachmentId", {}).get("Id", ""),
"is_inline": att.get("IsInline", False),
}
)
# Meeting-specific fields
item_type = item.get("__type", "")
if any(
t in item_type
for t in ("MeetingRequest", "MeetingResponse", "MeetingCancellation", "CalendarItem")
):
result["location"] = item.get(
"Location",
item.get("EnhancedLocation", {}).get("DisplayName", ""),
)
result["start"] = item.get("Start", "")
result["end"] = item.get("End", "")
result["required_attendees"] = []
result["optional_attendees"] = []
for a in item.get("RequiredAttendees", []):
mb = a.get("Mailbox", {})
name = mb.get("Name", "")
addr = mb.get("EmailAddress", "")
if name and addr:
result["required_attendees"].append(f"{name} <{addr}>")
elif addr:
result["required_attendees"].append(addr)
for a in item.get("OptionalAttendees", []):
mb = a.get("Mailbox", {})
name = mb.get("Name", "")
addr = mb.get("EmailAddress", "")
if name and addr:
result["optional_attendees"].append(f"{name} <{addr}>")
elif addr:
result["optional_attendees"].append(addr)
return result
return result
def _build_recipient_list(emails: str) -> list[dict]:
"""Build a list of Mailbox dicts from a comma-separated email string.
NOTE: OWA rejects __type annotations on recipient Mailbox dicts for
CreateItem (Message). Use plain dicts without __type.
"""
recipients = []
for addr in emails.split(","):
addr = addr.strip()
if addr:
recipients.append(
{
"Name": addr,
"EmailAddress": addr,
"RoutingType": "SMTP",
}
)
return recipients
# ------------------------------------------------------------------
# Tools
# ------------------------------------------------------------------
@mcp.tool()
def get_emails(
folder: str = "Inbox",
limit: int = 10,
offset: int = 0,
include_body: bool = False,
unread_only: bool = False,
ids_only: bool = False,
ctx: Context = None,
) -> str:
"""Get emails from a mailbox folder.
Args:
folder: Folder name (Inbox, Sent, Drafts, Deleted, Junk, or custom name).
limit: Maximum number of emails to return (default 10, max 50).
offset: Number of emails to skip for pagination.
include_body: If True, fetch full body for each email (slower).
unread_only: If True, only return unread emails.
ids_only: If True, return only item IDs and dates (compact, for bulk ops).
Max limit raised to 500 in this mode.
"""
try:
client = _get_client(ctx)
# Clamp limit (higher cap for ids_only)
max_limit = 500 if ids_only else 50
if limit > max_limit:
limit = max_limit
# Resolve folder name to ID
folder_id = client.get_folder_id(folder)
if not folder_id:
return json.dumps({"error": f"Folder '{folder}' not found."})
# Build FindItem payload
if ids_only:
item_shape = {
"__type": "ItemResponseShape:#Exchange",
"BaseShape": "IdOnly",
"AdditionalProperties": [
{
"__type": "PropertyUri:#Exchange",
"FieldURI": "DateTimeReceived",
},
{
"__type": "PropertyUri:#Exchange",
"FieldURI": "Subject",
},
],
}
else:
item_shape = {
"__type": "ItemResponseShape:#Exchange",
"BaseShape": "AllProperties",
}
find_body = {
"__type": "FindItemRequest:#Exchange",
"ItemShape": item_shape,
"ParentFolderIds": [
{"__type": "FolderId:#Exchange", "Id": folder_id}
],
"Traversal": "Shallow",
"Paging": {
"__type": "IndexedPageView:#Exchange",
"BasePoint": "Beginning",
"Offset": offset,
"MaxEntriesReturned": limit,
},
"SortOrder": [
{
"__type": "SortResults:#Exchange",
"Order": "Descending",
"Path": {
"__type": "PropertyUri:#Exchange",
"FieldURI": "DateTimeReceived",
},
}
],
}
# Add filter for unread only
if unread_only:
find_body["Restriction"] = {
"__type": "RestrictionType:#Exchange",
"Item": {
"__type": "IsEqualTo:#Exchange",
"FieldURIOrConstant": {
"__type": "FieldURIOrConstantType:#Exchange",
"Item": {
"__type": "ConstantValueType:#Exchange",
"Value": "false",
},
},
"Path": {
"__type": "PropertyUri:#Exchange",
"FieldURI": "IsRead",
},
},
}
payload = {
"__type": "FindItemJsonRequest:#Exchange",
"Header": {
"__type": "JsonRequestHeaders:#Exchange",
"RequestServerVersion": "Exchange2013",
},
"Body": find_body,
}
data = client.request("FindItem", payload)
items = []
for msg in client.extract_items(data):
if "RootFolder" in msg and "Items" in msg["RootFolder"]:
items = msg["RootFolder"]["Items"]
break
if not items:
return json.dumps(
{"item_ids": [], "count": 0} if ids_only
else {"emails": [], "count": 0}
)
if ids_only:
result = []
for item in items:
result.append({
"item_id": item.get("ItemId", {}).get("Id", ""),
"date": item.get("DateTimeReceived", ""),
"subject": item.get("Subject", ""),
})
return json.dumps({"item_ids": result, "count": len(result)})
emails = []
for item in items:
email = _extract_email_summary(item)
if include_body and email["item_id"]:
details = _get_item_details(client, email["item_id"])
email["to"] = details["to"]
email["cc"] = details["cc"]
email["body"] = details["body"]
email["has_links"] = details.get("has_links", False)
if email.get("is_meeting"):
email["location"] = details.get("location", "")
email["start"] = details.get("start", "") or email.get("start", "")
email["end"] = details.get("end", "") or email.get("end", "")
email["required_attendees"] = details.get("required_attendees", [])
email["optional_attendees"] = details.get("optional_attendees", [])
emails.append(email)
return json.dumps({"emails": emails, "count": len(emails)})
except SessionExpiredError as e:
return json.dumps({"error": str(e)})
except Exception as e:
return json.dumps({"error": f"Failed to get emails: {e}"})
@mcp.tool()
def get_email(item_id: str, ctx: Context = None) -> str:
"""Get a single email with full body and details.
Args:
item_id: The Exchange ItemId of the email to retrieve.
"""
try:
client = _get_client(ctx)
result = _get_item_details(client, item_id)
return json.dumps(result)
except SessionExpiredError as e:
return json.dumps({"error": str(e)})
except Exception as e:
return json.dumps({"error": f"Failed to get email: {e}"})
@mcp.tool()
def send_email(
to: str,
subject: str,
body: str,
cc: str = "",
bcc: str = "",
importance: str = "Normal",
is_html: bool = False,
ctx: Context = None,
) -> str:
"""Send a new email.
Args:
to: Comma-separated list of recipient email addresses.
subject: Email subject line.
body: Email body text.
cc: Comma-separated CC recipients (optional).
bcc: Comma-separated BCC recipients (optional).
importance: Email importance: Low, Normal, or High (default Normal).
is_html: If True, body is treated as HTML. Otherwise plain text.
"""
try:
client = _get_client(ctx)
to_recipients = _build_recipient_list(to)
if not to_recipients:
return json.dumps({"error": "At least one recipient is required."})
message = {
"__type": "Message:#Exchange",
"Subject": subject,
"Body": {
"__type": "BodyContentType:#Exchange",
"BodyType": "HTML" if is_html else "Text",
"Value": body,
},
"Importance": importance,
"ToRecipients": to_recipients,
}
if cc:
message["CcRecipients"] = _build_recipient_list(cc)
if bcc:
message["BccRecipients"] = _build_recipient_list(bcc)
payload = {
"__type": "CreateItemJsonRequest:#Exchange",
"Header": {
"__type": "JsonRequestHeaders:#Exchange",
"RequestServerVersion": "V2017_08_18",
},
"Body": {
"__type": "CreateItemRequest:#Exchange",
"Items": [message],
"MessageDisposition": "SendAndSaveCopy",
},
}
data = client.request("CreateItem", payload)
for msg in client.extract_items(data):
if msg.get("ResponseClass") == "Success":
return json.dumps({"success": True, "message": "Email sent."})
elif msg.get("ResponseClass") == "Error":
return json.dumps(
{"error": msg.get("MessageText", "Failed to send email.")}
)
return json.dumps({"success": True, "message": "Email sent."})
except SessionExpiredError as e:
return json.dumps({"error": str(e)})
except Exception as e:
return json.dumps({"error": f"Failed to send email: {e}"})
@mcp.tool()
def reply_email(
item_id: str,
body: str,
reply_all: bool = False,
ctx: Context = None,
) -> str:
"""Reply to an email.
Args:
item_id: The Exchange ItemId of the email to reply to.
body: Reply body text.
reply_all: If True, reply to all recipients. Otherwise reply to sender only.
"""
try:
client = _get_client(ctx)
change_key = _get_change_key(client, item_id)
if not change_key:
return json.dumps({"error": "Could not resolve item ChangeKey."})
item_type = "ReplyAllToItem:#Exchange" if reply_all else "ReplyToItem:#Exchange"
reply_item = {
"__type": item_type,
"ReferenceItemId": {
"__type": "ItemId:#Exchange",
"Id": item_id,
"ChangeKey": change_key,
},
"NewBodyContent": {
"__type": "BodyContentType:#Exchange",
"BodyType": "Text",
"Value": body,
},
}
payload = {
"__type": "CreateItemJsonRequest:#Exchange",
"Header": {
"__type": "JsonRequestHeaders:#Exchange",
"RequestServerVersion": "V2017_08_18",
},
"Body": {
"__type": "CreateItemRequest:#Exchange",
"Items": [reply_item],
"MessageDisposition": "SendAndSaveCopy",
},
}
data = client.request("CreateItem", payload)
for msg in client.extract_items(data):
if msg.get("ResponseClass") == "Error":
return json.dumps(
{"error": msg.get("MessageText", "Failed to send reply.")}
)
return json.dumps({"success": True, "message": "Reply sent."})
except SessionExpiredError as e:
return json.dumps({"error": str(e)})
except Exception as e:
return json.dumps({"error": f"Failed to reply: {e}"})
@mcp.tool()
def forward_email(
item_id: str,
to: str,
body: str = "",
ctx: Context = None,
) -> str:
"""Forward an email to other recipients.
Args:
item_id: The Exchange ItemId of the email to forward.
to: Comma-separated list of recipient email addresses.
body: Optional message to include above the forwarded content.
"""
try:
client = _get_client(ctx)
to_recipients = _build_recipient_list(to)
if not to_recipients:
return json.dumps({"error": "At least one recipient is required."})
change_key = _get_change_key(client, item_id)
if not change_key:
return json.dumps({"error": "Could not resolve item ChangeKey."})
forward_item = {
"__type": "ForwardItem:#Exchange",
"ReferenceItemId": {
"__type": "ItemId:#Exchange",
"Id": item_id,
"ChangeKey": change_key,
},
"ToRecipients": to_recipients,
}
if body:
forward_item["NewBodyContent"] = {
"__type": "BodyContentType:#Exchange",
"BodyType": "Text",
"Value": body,
}
payload = {
"__type": "CreateItemJsonRequest:#Exchange",
"Header": {
"__type": "JsonRequestHeaders:#Exchange",
"RequestServerVersion": "V2017_08_18",
},
"Body": {
"__type": "CreateItemRequest:#Exchange",
"Items": [forward_item],
"MessageDisposition": "SendAndSaveCopy",
},
}
data = client.request("CreateItem", payload)
for msg in client.extract_items(data):
if msg.get("ResponseClass") == "Error":
return json.dumps(
{"error": msg.get("MessageText", "Failed to forward.")}
)
return json.dumps({"success": True, "message": "Email forwarded."})
except SessionExpiredError as e:
return json.dumps({"error": str(e)})
except Exception as e:
return json.dumps({"error": f"Failed to forward email: {e}"})
@mcp.tool()
def mark_email_read(
item_ids: list[str],
is_read: bool = True,
ctx: Context = None,
) -> str:
"""Mark one or more emails as read or unread.
Args:
item_ids: List of Exchange ItemIds to update.
is_read: True to mark as read, False to mark as unread (default True).
"""
try:
client = _get_client(ctx)
changes = []
for iid in item_ids:
change_key = _get_change_key(client, iid)
item_id_dict = {"__type": "ItemId:#Exchange", "Id": iid}
if change_key:
item_id_dict["ChangeKey"] = change_key
changes.append(
{
"__type": "ItemChange:#Exchange",
"ItemId": item_id_dict,
"Updates": [
{
"__type": "SetItemField:#Exchange",
"Path": {
"__type": "PropertyUri:#Exchange",
"FieldURI": "IsRead",
},
"Item": {
"__type": "Message:#Exchange",
"IsRead": is_read,
},
}
],
}
)
payload = {
"__type": "UpdateItemJsonRequest:#Exchange",
"Header": {
"__type": "JsonRequestHeaders:#Exchange",
"RequestServerVersion": "V2017_08_18",
},
"Body": {
"__type": "UpdateItemRequest:#Exchange",
"ItemChanges": changes,
"ConflictResolution": "AutoResolve",
"MessageDisposition": "SaveOnly",
},
}
data = client.request("UpdateItem", payload)
errors = []
for msg in client.extract_items(data):
if msg.get("ResponseClass") == "Error":
errors.append(msg.get("MessageText", "Unknown error"))
if errors:
return json.dumps({"error": "; ".join(errors)})
status = "read" if is_read else "unread"
return json.dumps(
{
"success": True,
"message": f"Marked {len(item_ids)} email(s) as {status}.",
}
)
except SessionExpiredError as e:
return json.dumps({"error": str(e)})
except Exception as e:
return json.dumps({"error": f"Failed to update emails: {e}"})
@mcp.tool()
def move_email(
item_ids: list[str],
target_folder: str,
ctx: Context = None,
) -> str:
"""Move one or more emails to a different folder.
Args:
item_ids: List of Exchange ItemIds to move.
target_folder: Destination folder name (e.g. Inbox, Sent, Deleted, or custom).
"""
try:
client = _get_client(ctx)
folder_id = client.get_folder_id(target_folder)
if not folder_id:
return json.dumps({"error": f"Folder '{target_folder}' not found."})
items = [
{"__type": "ItemId:#Exchange", "Id": iid} for iid in item_ids
]
payload = {
"__type": "MoveItemJsonRequest:#Exchange",
"Header": {
"__type": "JsonRequestHeaders:#Exchange",
"RequestServerVersion": "V2017_08_18",
},
"Body": {
"__type": "MoveItemRequest:#Exchange",
"ItemIds": items,
"ToFolderId": {
"__type": "TargetFolderId:#Exchange",
"BaseFolderId": {
"__type": "FolderId:#Exchange",
"Id": folder_id,
},
},
},
}
data = client.request("MoveItem", payload)
errors = []
for msg in client.extract_items(data):
if msg.get("ResponseClass") == "Error":
errors.append(msg.get("MessageText", "Unknown error"))
if errors:
return json.dumps({"error": "; ".join(errors)})
return json.dumps(
{
"success": True,
"message": f"Moved {len(item_ids)} email(s) to '{target_folder}'.",
}
)
except SessionExpiredError as e:
return json.dumps({"error": str(e)})
except Exception as e:
return json.dumps({"error": f"Failed to move emails: {e}"})
@mcp.tool()
def delete_email(
item_ids: list[str],
permanent: bool = False,
ctx: Context = None,
) -> str:
"""Delete one or more emails.
Args:
item_ids: List of Exchange ItemIds to delete.
permanent: If True, permanently delete (HardDelete). Otherwise move to Deleted Items.
"""
try:
client = _get_client(ctx)
items = [
{"__type": "ItemId:#Exchange", "Id": iid} for iid in item_ids
]
payload = {
"__type": "DeleteItemJsonRequest:#Exchange",
"Header": {
"__type": "JsonRequestHeaders:#Exchange",
"RequestServerVersion": "V2017_08_18",
},
"Body": {
"__type": "DeleteItemRequest:#Exchange",
"ItemIds": items,
"DeleteType": "HardDelete" if permanent else "MoveToDeletedItems",
},
}
data = client.request("DeleteItem", payload)
errors = []
for msg in client.extract_items(data):
if msg.get("ResponseClass") == "Error":
errors.append(msg.get("MessageText", "Unknown error"))
if errors:
return json.dumps({"error": "; ".join(errors)})
action = "permanently deleted" if permanent else "moved to Deleted Items"
return json.dumps(
{
"success": True,
"message": f"{len(item_ids)} email(s) {action}.",
}
)
except SessionExpiredError as e:
return json.dumps({"error": str(e)})
except Exception as e:
return json.dumps({"error": f"Failed to delete emails: {e}"})
@mcp.tool()
def download_attachments(
item_id: str,
target_folder: str = "/tmp/attachments",
ctx: Context = None,
) -> str:
"""Download all file attachments from an email to disk.
Args:
item_id: The Exchange ItemId of the email to download attachments from.
target_folder: Local directory to save files (default /tmp/attachments).
"""
import os
try:
client = _get_client(ctx)
# Get email details to find attachment IDs
details = _get_item_details(client, item_id)
attachments = details.get("attachments", [])
if not attachments:
return json.dumps({"success": True, "downloaded": [], "count": 0,
"message": "No attachments found."})
# Filter to non-inline file attachments with IDs
file_attachments = [
a for a in attachments
if a.get("attachment_id") and not a.get("is_inline", False)
]
if not file_attachments:
return json.dumps({"success": True, "downloaded": [], "count": 0,
"message": "No downloadable file attachments."})
os.makedirs(target_folder, exist_ok=True)
downloaded = []
errors = []
used_names: set[str] = set()
for att in file_attachments:
try:
content, filename, content_type = client.download_file(
att["attachment_id"]
)
# Sanitize filename
filename = os.path.basename(filename)
if not filename:
filename = att.get("name", "attachment") or "attachment"
# Handle collisions
base_name = filename
name_part, _, ext_part = base_name.rpartition(".")
if not name_part:
name_part = base_name
ext_part = ""
counter = 1
while filename.lower() in used_names:
if ext_part:
filename = f"{name_part}_{counter}.{ext_part}"
else:
filename = f"{name_part}_{counter}"
counter += 1
used_names.add(filename.lower())
filepath = os.path.join(target_folder, filename)
with open(filepath, "wb") as f:
f.write(content)
downloaded.append({
"name": filename,
"path": filepath,
"size": len(content),
"content_type": content_type,
})
except Exception as e:
errors.append({
"name": att.get("name", "unknown"),
"error": str(e),
})
result = {
"success": len(errors) == 0,
"downloaded": downloaded,
"count": len(downloaded),
}
if errors:
result["errors"] = errors
return json.dumps(result)
except SessionExpiredError as e:
return json.dumps({"error": str(e)})
except Exception as e:
return json.dumps({"error": f"Failed to download attachments: {e}"})
@mcp.tool()
def get_email_links(
item_id: str,
ctx: Context = None,
) -> str:
"""Extract all hyperlinks from an email's HTML body.
Args:
item_id: The Exchange ItemId of the email to extract links from.
"""
try:
client = _get_client(ctx)
# Fetch email with HTML body
payload = {
"__type": "GetItemJsonRequest:#Exchange",
"Header": {
"__type": "JsonRequestHeaders:#Exchange",
"RequestServerVersion": "V2017_08_18",
},
"Body": {
"__type": "GetItemRequest:#Exchange",
"ItemShape": {
"__type": "ItemResponseShape:#Exchange",
"BaseShape": "IdOnly",
"BodyType": "HTML",
"AdditionalProperties": [
{
"__type": "PropertyUri:#Exchange",
"FieldURI": "Subject",
},
{
"__type": "PropertyUri:#Exchange",
"FieldURI": "Body",
},
],
},
"ItemIds": [{"__type": "ItemId:#Exchange", "Id": item_id}],
},
}
data = client.request("GetItem", payload)
subject = ""
links = []
for msg in client.extract_items(data):
if "Items" not in msg:
continue
for item in msg["Items"]:
subject = item.get("Subject", "")
body_val = item.get("Body", {}).get("Value", "")
links = extract_links_from_html(body_val)
break
return json.dumps({
"item_id": item_id,
"subject": subject,
"links": links,
"count": len(links),
})
except SessionExpiredError as e:
return json.dumps({"error": str(e)})
except Exception as e:
return json.dumps({"error": f"Failed to extract links: {e}"})
# ------------------------------------------------------------------
# Search (full-text search via FindItem + ContainsExpression)
# ------------------------------------------------------------------
_FIELD_URI = {
"subject": "item:Subject",
"body": "item:Body",
"from": "message:From",
}
def _contains_expression(field_uri: str, value: str) -> dict:
return {
"__type": "ContainsExpression:#Exchange",
"ContainmentMode": "Substring",
"ContainmentComparison": "IgnoreCase",
"Path": {
"__type": "PathToUnindexedField:#Exchange",
"FieldURI": field_uri,
},
"Constant": {
"__type": "ConstantValue:#Exchange",
"Value": value,
},
}
def _build_search_restriction(query: str, scope: str) -> dict | None:
if scope == "all":
return {
"__type": "Or:#Exchange",
"Items": [
_contains_expression("item:Subject", query),
_contains_expression("item:Body", query),
],
}
field_uri = _FIELD_URI.get(scope)
if not field_uri:
return None
return _contains_expression(field_uri, query)
@mcp.tool(
name="search_emails",
description=(
"Search emails by text across one or all folders. "
"Supports searching by subject, body, sender, or all fields combined."
),
)
async def search_emails(
ctx: Context,
query: str,
folder_id: str | None = None,
max_results: int = 20,
search_scope: str = "all",
) -> str:
"""Search emails by text content.
Args:
query: The text to search for.
folder_id: Optional folder ID to limit the search. When omitted,
searches across all mail folders (deep traversal).
max_results: Maximum number of results to return (default 20, max 100).
search_scope: Where to search — ``"all"`` (subject + body, default),
``"subject"``, ``"body"``, or ``"from"``.
"""
client = _get_client(ctx)
if not query.strip():
return json.dumps({"error": "query must not be empty", "results": []})
max_results = max(1, min(max_results, 100))
if search_scope not in ("all", "subject", "body", "from"):
return json.dumps({"error": f"invalid search_scope: {search_scope}", "results": []})
restriction = _build_search_restriction(query.strip(), search_scope)
if restriction is None:
return json.dumps({"error": f"unsupported search_scope: {search_scope}", "results": []})
if folder_id:
parent_folder = [{"__type": "FolderId:#Exchange", "Id": folder_id}]
else:
parent_folder = [{"__type": "DistinguishedFolderId:#Exchange", "Id": "msgfolderroot"}]
payload = {
"__type": "FindItemJsonRequest:#Exchange",
"Header": {
"__type": "JsonRequestHeaders:#Exchange",
"RequestServerVersion": "Exchange2013",
},
"Body": {
"__type": "FindItemRequest:#Exchange",
"ItemShape": {
"__type": "ItemResponseShape:#Exchange",
"BaseShape": "AllProperties",
"BodyType": "HTML",
},
"ParentFolderIds": parent_folder,
"Restriction": restriction,
"Traversal": "Shallow" if folder_id else "Deep",
},
}
try:
data = client.request("FindItem", payload)
results = []
for msg in client.extract_items(data):
items = msg.get("RootFolder", {}).get("Items", [])
if not items:
items = msg.get("Items", [])
for item in items:
if len(results) >= max_results:
break
summary = _extract_email_summary(item)
# add body_preview
body_html = item.get("Body", {}).get("_", "")
body_type = item.get("Body", {}).get("BodyType", "HTML")
if body_type == "HTML" and body_html:
import re
body_preview = re.sub(r"<[^>]+>", " ", body_html)
body_preview = " ".join(body_preview.split())[:200]
else:
body_preview = body_html[:200]
summary["body_preview"] = body_preview
summary["folder_id"] = item.get("ParentFolderId", {}).get("Id", "")
results.append(summary)
if len(results) >= max_results:
break
return json.dumps({
"query": query.strip(),
"search_scope": search_scope,
"folder_id": folder_id or "all",
"total_results": len(results),
"results": results,
}, ensure_ascii=False)
except SessionExpiredError as e:
return json.dumps({"error": str(e)})
except Exception as e:
return json.dumps({"error": f"Search failed: {e}"})