Initial commit
This commit is contained in:
383
server/tools/email_handling_tool.py
Normal file
383
server/tools/email_handling_tool.py
Normal file
@@ -0,0 +1,383 @@
|
||||
"""
|
||||
tools/email_handling_tool.py — Read/organise email tool for handling accounts.
|
||||
|
||||
NOT in the global tool registry.
|
||||
Instantiated at dispatch time with decrypted account credentials.
|
||||
Passed as extra_tools to agent.run() with force_only_extra_tools=True.
|
||||
|
||||
Deliberately excludes: send, reply, forward, create draft, delete email,
|
||||
expunge folder, any SMTP operation.
|
||||
|
||||
Uses imaplib (stdlib) via asyncio.to_thread — avoids aioimaplib auth issues
|
||||
with some Dovecot configurations.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import email as email_lib
|
||||
import imaplib
|
||||
import logging
|
||||
import re
|
||||
|
||||
from .base import BaseTool, ToolResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EmailHandlingTool(BaseTool):
|
||||
name = "email_handling"
|
||||
description = "Read, organise and manage emails within the configured folders." # overridden in __init__
|
||||
input_schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"operation": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"list_emails",
|
||||
"read_email",
|
||||
"mark_email",
|
||||
"move_email",
|
||||
"list_folders",
|
||||
"create_folder",
|
||||
],
|
||||
"description": "The operation to perform",
|
||||
},
|
||||
"folder": {
|
||||
"type": "string",
|
||||
"description": "IMAP folder name (default: INBOX)",
|
||||
},
|
||||
"uid": {
|
||||
"type": "string",
|
||||
"description": "Email UID for read_email, mark_email, move_email",
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Max emails to return for list_emails (default: 20, max: 100)",
|
||||
},
|
||||
"unread_only": {
|
||||
"type": "boolean",
|
||||
"description": "Only list unread emails (for list_emails)",
|
||||
},
|
||||
"search": {
|
||||
"type": "string",
|
||||
"description": "IMAP SEARCH criteria string (for list_emails)",
|
||||
},
|
||||
"flag": {
|
||||
"type": "string",
|
||||
"enum": ["read", "unread", "flagged", "unflagged", "spam"],
|
||||
"description": "Flag action for mark_email",
|
||||
},
|
||||
"source_folder": {
|
||||
"type": "string",
|
||||
"description": "Source folder for move_email",
|
||||
},
|
||||
"target_folder": {
|
||||
"type": "string",
|
||||
"description": "Target folder for move_email or parent for create_folder",
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "New folder name for create_folder",
|
||||
},
|
||||
},
|
||||
"required": ["operation"],
|
||||
}
|
||||
requires_confirmation = False
|
||||
allowed_in_scheduled_tasks = True
|
||||
|
||||
def __init__(self, account: dict) -> None:
|
||||
"""account: dict with decrypted imap_host/port/username/password."""
|
||||
self._host = account["imap_host"]
|
||||
self._port = int(account.get("imap_port") or 993)
|
||||
self._username = account["imap_username"]
|
||||
self._password = account["imap_password"]
|
||||
raw = account.get("monitored_folders")
|
||||
if raw is None:
|
||||
self._allowed_folders: list[str] | None = None # None = no folder restriction
|
||||
elif isinstance(raw, list):
|
||||
self._allowed_folders = raw if raw else None
|
||||
else:
|
||||
self._allowed_folders = ["INBOX"]
|
||||
if self._allowed_folders:
|
||||
folder_list = ", ".join(repr(f) for f in self._allowed_folders)
|
||||
restriction = f"You may ONLY access these folders (and their subfolders): {folder_list}. Any attempt to read from or move to a folder outside this list will be rejected."
|
||||
else:
|
||||
restriction = "You may access all folders."
|
||||
self.description = (
|
||||
f"Read, organise and manage emails. "
|
||||
f"Operations: list_emails, read_email, mark_email, move_email, list_folders, create_folder. "
|
||||
f"Cannot send, delete or permanently expunge emails. "
|
||||
f"{restriction}"
|
||||
)
|
||||
|
||||
def _check_folder(self, folder: str) -> str | None:
|
||||
"""Return an error string if folder is outside allowed_folders, else None."""
|
||||
if self._allowed_folders is None:
|
||||
return None
|
||||
for allowed in self._allowed_folders:
|
||||
if folder == allowed or folder.startswith(allowed.rstrip("/") + "/"):
|
||||
return None
|
||||
return (
|
||||
f"Folder {folder!r} is outside the allowed folders for this account: "
|
||||
+ ", ".join(repr(f) for f in self._allowed_folders)
|
||||
)
|
||||
|
||||
def _open(self) -> imaplib.IMAP4_SSL:
|
||||
"""Open and authenticate a synchronous IMAP4_SSL connection."""
|
||||
M = imaplib.IMAP4_SSL(self._host, self._port)
|
||||
M.login(self._username, self._password)
|
||||
return M
|
||||
|
||||
async def execute(self, operation: str = "", **kwargs) -> ToolResult:
|
||||
try:
|
||||
if operation == "list_emails":
|
||||
return await self._list_emails(**kwargs)
|
||||
elif operation == "read_email":
|
||||
return await self._read_email(**kwargs)
|
||||
elif operation == "mark_email":
|
||||
return await self._mark_email(**kwargs)
|
||||
elif operation == "move_email":
|
||||
return await self._move_email(**kwargs)
|
||||
elif operation == "list_folders":
|
||||
return await self._list_folders()
|
||||
elif operation == "create_folder":
|
||||
return await self._create_folder(**kwargs)
|
||||
else:
|
||||
return ToolResult(success=False, error=f"Unknown operation: {operation!r}")
|
||||
except Exception as e:
|
||||
logger.error("[email_handling] %s error: %s", operation, e)
|
||||
return ToolResult(success=False, error=str(e))
|
||||
|
||||
# ── Operations (run blocking imaplib calls in a thread) ───────────────────
|
||||
|
||||
async def _list_emails(
|
||||
self,
|
||||
folder: str = "INBOX",
|
||||
limit: int = 20,
|
||||
unread_only: bool = False,
|
||||
search: str = "",
|
||||
**_,
|
||||
) -> ToolResult:
|
||||
err = self._check_folder(folder)
|
||||
if err:
|
||||
return ToolResult(success=False, error=err)
|
||||
limit = min(int(limit), 100)
|
||||
return await asyncio.to_thread(
|
||||
self._sync_list_emails, folder, limit, unread_only, search
|
||||
)
|
||||
|
||||
def _sync_list_emails(
|
||||
self, folder: str, limit: int, unread_only: bool, search: str
|
||||
) -> ToolResult:
|
||||
M = self._open()
|
||||
try:
|
||||
M.select(folder, readonly=True)
|
||||
criteria = search if search else ("UNSEEN" if unread_only else "ALL")
|
||||
typ, data = M.search(None, criteria)
|
||||
if typ != "OK" or not data or not data[0]:
|
||||
return ToolResult(success=True, data={"emails": [], "count": 0, "folder": folder})
|
||||
|
||||
nums = data[0].split()
|
||||
nums = nums[-limit:][::-1] # most recent first
|
||||
|
||||
emails = []
|
||||
for num in nums:
|
||||
typ2, msg_data = M.fetch(
|
||||
num, "(FLAGS BODY.PEEK[HEADER.FIELDS (FROM TO SUBJECT DATE)])"
|
||||
)
|
||||
if typ2 != "OK" or not msg_data or not msg_data[0]:
|
||||
continue
|
||||
flags_str = str(msg_data[0][0]) if isinstance(msg_data[0], tuple) else str(msg_data[0])
|
||||
header_bytes = msg_data[0][1] if isinstance(msg_data[0], tuple) else b""
|
||||
msg = email_lib.message_from_bytes(header_bytes)
|
||||
is_unread = "\\Seen" not in flags_str
|
||||
emails.append({
|
||||
"uid": num.decode() if isinstance(num, bytes) else str(num),
|
||||
"from": msg.get("From", ""),
|
||||
"to": msg.get("To", ""),
|
||||
"subject": msg.get("Subject", ""),
|
||||
"date": msg.get("Date", ""),
|
||||
"unread": is_unread,
|
||||
})
|
||||
|
||||
return ToolResult(success=True, data={"emails": emails, "count": len(emails), "folder": folder})
|
||||
finally:
|
||||
_close(M)
|
||||
|
||||
async def _read_email(self, uid: str = "", folder: str = "INBOX", **_) -> ToolResult:
|
||||
if not uid:
|
||||
return ToolResult(success=False, error="uid is required")
|
||||
err = self._check_folder(folder)
|
||||
if err:
|
||||
return ToolResult(success=False, error=err)
|
||||
return await asyncio.to_thread(self._sync_read_email, uid, folder)
|
||||
|
||||
def _sync_read_email(self, uid: str, folder: str) -> ToolResult:
|
||||
M = self._open()
|
||||
try:
|
||||
M.select(folder, readonly=True)
|
||||
typ, data = M.fetch(uid, "(FLAGS BODY.PEEK[])")
|
||||
if typ != "OK" or not data or not data[0]:
|
||||
return ToolResult(success=False, error=f"Cannot fetch message uid={uid}")
|
||||
|
||||
flags_str = str(data[0][0]) if isinstance(data[0], tuple) else str(data[0])
|
||||
raw = data[0][1] if isinstance(data[0], tuple) else b""
|
||||
msg = email_lib.message_from_bytes(raw)
|
||||
is_unread = "\\Seen" not in flags_str
|
||||
body = _extract_body(msg)
|
||||
|
||||
return ToolResult(success=True, data={
|
||||
"uid": uid,
|
||||
"folder": folder,
|
||||
"from": msg.get("From", ""),
|
||||
"to": msg.get("To", ""),
|
||||
"cc": msg.get("Cc", ""),
|
||||
"subject": msg.get("Subject", ""),
|
||||
"date": msg.get("Date", ""),
|
||||
"unread": is_unread,
|
||||
"body": body[:6000],
|
||||
})
|
||||
finally:
|
||||
_close(M)
|
||||
|
||||
async def _mark_email(
|
||||
self, uid: str = "", folder: str = "INBOX", flag: str = "read", **_
|
||||
) -> ToolResult:
|
||||
if not uid:
|
||||
return ToolResult(success=False, error="uid is required")
|
||||
err = self._check_folder(folder)
|
||||
if err:
|
||||
return ToolResult(success=False, error=err)
|
||||
|
||||
flag_map = {
|
||||
"read": ("+FLAGS", "\\Seen"),
|
||||
"unread": ("-FLAGS", "\\Seen"),
|
||||
"flagged": ("+FLAGS", "\\Flagged"),
|
||||
"unflagged": ("-FLAGS", "\\Flagged"),
|
||||
"spam": ("+FLAGS", "Junk"),
|
||||
}
|
||||
if flag not in flag_map:
|
||||
return ToolResult(success=False, error=f"Unknown flag: {flag!r}")
|
||||
|
||||
return await asyncio.to_thread(self._sync_mark_email, uid, folder, flag_map[flag])
|
||||
|
||||
def _sync_mark_email(self, uid: str, folder: str, flag_op: tuple) -> ToolResult:
|
||||
action, imap_flag = flag_op
|
||||
M = self._open()
|
||||
try:
|
||||
M.select(folder)
|
||||
typ, _ = M.store(uid, action, imap_flag)
|
||||
if typ != "OK":
|
||||
return ToolResult(success=False, error=f"Failed to mark email uid={uid}")
|
||||
return ToolResult(success=True, data={"uid": uid, "flag": action})
|
||||
finally:
|
||||
_close(M)
|
||||
|
||||
async def _move_email(
|
||||
self,
|
||||
uid: str = "",
|
||||
source_folder: str = "INBOX",
|
||||
target_folder: str = "",
|
||||
**_,
|
||||
) -> ToolResult:
|
||||
if not uid:
|
||||
return ToolResult(success=False, error="uid is required")
|
||||
if not target_folder:
|
||||
return ToolResult(success=False, error="target_folder is required")
|
||||
for folder, label in ((source_folder, "source_folder"), (target_folder, "target_folder")):
|
||||
err = self._check_folder(folder)
|
||||
if err:
|
||||
return ToolResult(success=False, error=f"{label}: {err}")
|
||||
return await asyncio.to_thread(self._sync_move_email, uid, source_folder, target_folder)
|
||||
|
||||
def _sync_move_email(self, uid: str, source_folder: str, target_folder: str) -> ToolResult:
|
||||
M = self._open()
|
||||
try:
|
||||
M.select(source_folder)
|
||||
typ, _ = M.copy(uid, target_folder)
|
||||
if typ != "OK":
|
||||
return ToolResult(success=False, error=f"Failed to copy to {target_folder!r}")
|
||||
M.store(uid, "+FLAGS", "\\Deleted")
|
||||
M.expunge()
|
||||
return ToolResult(success=True, data={"uid": uid, "moved_to": target_folder})
|
||||
finally:
|
||||
_close(M)
|
||||
|
||||
async def _list_folders(self, **_) -> ToolResult:
|
||||
return await asyncio.to_thread(self._sync_list_folders)
|
||||
|
||||
def _sync_list_folders(self) -> ToolResult:
|
||||
M = self._open()
|
||||
try:
|
||||
typ, data = M.list()
|
||||
if typ != "OK":
|
||||
return ToolResult(success=False, error="Failed to list folders")
|
||||
|
||||
folders = []
|
||||
for line in data:
|
||||
if not line:
|
||||
continue
|
||||
line_str = line.decode() if isinstance(line, bytes) else str(line)
|
||||
match = re.search(r'"([^"]+)"\s*$', line_str) or re.search(r'(\S+)\s*$', line_str)
|
||||
if match:
|
||||
name = match.group(1)
|
||||
if name and name.upper() != "NIL":
|
||||
folders.append(name)
|
||||
|
||||
if self._allowed_folders is not None:
|
||||
folders = [
|
||||
f for f in folders
|
||||
if any(
|
||||
f == a or f.startswith(a.rstrip("/") + "/")
|
||||
for a in self._allowed_folders
|
||||
)
|
||||
]
|
||||
|
||||
return ToolResult(success=True, data={"folders": folders, "count": len(folders)})
|
||||
finally:
|
||||
_close(M)
|
||||
|
||||
async def _create_folder(self, name: str = "", target_folder: str = "", **_) -> ToolResult:
|
||||
if not name:
|
||||
return ToolResult(success=False, error="name is required")
|
||||
full_name = f"{target_folder}/{name}" if target_folder else name
|
||||
err = self._check_folder(full_name)
|
||||
if err:
|
||||
return ToolResult(success=False, error=err)
|
||||
return await asyncio.to_thread(self._sync_create_folder, full_name)
|
||||
|
||||
def _sync_create_folder(self, full_name: str) -> ToolResult:
|
||||
M = self._open()
|
||||
try:
|
||||
typ, _ = M.create(full_name)
|
||||
if typ != "OK":
|
||||
return ToolResult(success=False, error=f"Failed to create folder {full_name!r}")
|
||||
return ToolResult(success=True, data={"folder": full_name, "created": True})
|
||||
finally:
|
||||
_close(M)
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _close(M: imaplib.IMAP4_SSL) -> None:
|
||||
try:
|
||||
M.logout()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _extract_body(msg: email_lib.message.Message) -> str:
|
||||
if msg.is_multipart():
|
||||
for part in msg.walk():
|
||||
if part.get_content_type() == "text/plain":
|
||||
payload = part.get_payload(decode=True)
|
||||
return payload.decode("utf-8", errors="replace") if payload else ""
|
||||
for part in msg.walk():
|
||||
if part.get_content_type() == "text/html":
|
||||
payload = part.get_payload(decode=True)
|
||||
html = payload.decode("utf-8", errors="replace") if payload else ""
|
||||
return re.sub(r"<[^>]+>", "", html).strip()
|
||||
else:
|
||||
payload = msg.get_payload(decode=True)
|
||||
return payload.decode("utf-8", errors="replace") if payload else ""
|
||||
return ""
|
||||
Reference in New Issue
Block a user