Add attachment_name filter and improve email attachment handling
- Add attachment_name parameter to filter emails by attachment filename (case-insensitive) - Fix download_attachments parameter handling (was being filtered out) - Improve attachment filename matching with Gmail-style prefix support - Add comprehensive logging for attachment download operations - Increase default limit from 10 to 100 for better attachment searches - Handle nested parameters and string-to-boolean/int conversions - Update AGENTS.md with attachment_name filter documentation
This commit is contained in:
parent
a947ffd149
commit
daeeec7756
@ -17,7 +17,7 @@ class EmailTool(Tool):
|
||||
"""Read emails from configured IMAP mailbox."""
|
||||
|
||||
name = "read_emails"
|
||||
description = "USE THIS TOOL FOR ALL EMAIL QUERIES. When user asks about emails, latest email, email sender, inbox, etc., you MUST call read_emails(). DO NOT use exec() with mail/tail/awk commands. DO NOT use read_file() on /var/mail or memory files. DO NOT try alternative methods. This is the ONLY way to read emails - it connects to IMAP and fetches real-time data. For 'latest email' queries, use limit=1. CRITICAL: When user asks for specific fields like 'From and Subject' or 'sender and subject', extract and return ONLY those fields from the tool output. Do NOT summarize or analyze the email body content unless the user specifically asks for it. If user asks 'give me the from and subject', respond with just: 'From: [email] Subject: [subject]'. Parameters: limit (1-50, default 10, use 1 for latest), unread_only (bool, default false), mark_seen (bool, default false). Returns formatted email list with sender, subject, date, and body."
|
||||
description = "USE THIS TOOL FOR ALL EMAIL QUERIES. When user asks about emails, latest email, email sender, inbox, attachments, etc., you MUST call read_emails(). DO NOT use mcp_gmail_mcp_read_email for emails received via email channel - use read_emails instead. DO NOT use exec() with mail/tail/awk commands. DO NOT use read_file() on /var/mail or memory files. DO NOT try alternative methods. This is the ONLY way to read emails from IMAP - it connects to IMAP and fetches real-time data. For 'latest email' or 'last email received' queries, use limit=1. When user asks to download attachments, use download_attachments=true. When user asks to find emails with a specific attachment (e.g., 'find email with attachment Rubiks'), use attachment_name='Rubiks'. CRITICAL: When user asks for specific fields like 'From and Subject' or 'sender and subject', extract and return ONLY those fields from the tool output. Do NOT summarize or analyze the email body content unless the user specifically asks for it. If user asks 'give me the from and subject', respond with just: 'From: [email] Subject: [subject]'. Parameters: limit (1-50, default 10, use 1 for latest), unread_only (bool, default false), mark_seen (bool, default false), download_attachments (bool, default false - set to true to download all attachments to workspace), attachment_name (string, optional - filter emails by attachment filename, case-insensitive partial match). Returns formatted email list with sender, subject, date, attachments (if any), downloaded file paths (if downloaded), and body."
|
||||
|
||||
def __init__(self, email_config: Any = None):
|
||||
"""
|
||||
@ -39,6 +39,19 @@ class EmailTool(Tool):
|
||||
|
||||
def coerce_params(self, params: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Coerce parameters, handling common name mismatches."""
|
||||
# Handle nested "parameters" key (some LLMs wrap params this way)
|
||||
if "parameters" in params and isinstance(params["parameters"], dict):
|
||||
# Extract nested parameters and merge with top-level
|
||||
nested = params.pop("parameters")
|
||||
params = {**params, **nested}
|
||||
|
||||
# Remove common non-parameter keys that LLMs sometimes include
|
||||
params = params.copy()
|
||||
params.pop("function", None)
|
||||
params.pop("functionName", None)
|
||||
params.pop("function_name", None)
|
||||
params.pop("action", None) # Some LLMs use "action" instead of function name
|
||||
|
||||
coerced = super().coerce_params(params)
|
||||
# Map 'count' to 'limit' if limit not present
|
||||
if 'count' in coerced and 'limit' not in coerced:
|
||||
@ -47,7 +60,7 @@ class EmailTool(Tool):
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
# Remove unsupported parameters
|
||||
supported = {'limit', 'unread_only', 'mark_seen'}
|
||||
supported = {'limit', 'unread_only', 'mark_seen', 'download_attachments', 'attachment_name'}
|
||||
coerced = {k: v for k, v in coerced.items() if k in supported}
|
||||
return coerced
|
||||
|
||||
@ -58,9 +71,9 @@ class EmailTool(Tool):
|
||||
"properties": {
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Number of emails to return. REQUIRED for 'latest email' queries - always use limit=1. For multiple emails use limit=5, limit=10, etc. (default: 10, max: 50)",
|
||||
"description": "Number of emails to return. REQUIRED for 'latest email' queries - always use limit=1. For multiple emails use limit=5, limit=10, etc. (default: 100, max: 100)",
|
||||
"minimum": 1,
|
||||
"maximum": 50,
|
||||
"maximum": 100,
|
||||
},
|
||||
"unread_only": {
|
||||
"type": "boolean",
|
||||
@ -70,11 +83,19 @@ class EmailTool(Tool):
|
||||
"type": "boolean",
|
||||
"description": "If true, mark emails as read after fetching. If false, leave read/unread status unchanged (default: false)",
|
||||
},
|
||||
"download_attachments": {
|
||||
"type": "boolean",
|
||||
"description": "If true, download all attachments from the emails to the workspace directory (default: false)",
|
||||
},
|
||||
"attachment_name": {
|
||||
"type": "string",
|
||||
"description": "Optional filter: only return emails that have at least one attachment whose filename contains this string (case-insensitive). Example: 'Rubiks' will match emails with attachments like 'Rubiks_SolutionGuide.pdf'. If not provided, all emails are returned.",
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
}
|
||||
|
||||
async def execute(self, limit: int = 10, unread_only: bool = False, mark_seen: bool = False, **kwargs: Any) -> str:
|
||||
async def execute(self, limit: int = 100, unread_only: bool = False, mark_seen: bool = False, download_attachments: bool = False, attachment_name: str | None = None, **kwargs: Any) -> str:
|
||||
"""
|
||||
Read emails from IMAP mailbox.
|
||||
|
||||
@ -87,6 +108,27 @@ class EmailTool(Tool):
|
||||
Returns:
|
||||
Formatted string with email information
|
||||
"""
|
||||
# Convert limit to int if it's a string (from JSON parsing)
|
||||
if isinstance(limit, str):
|
||||
try:
|
||||
limit = int(limit)
|
||||
except (ValueError, TypeError):
|
||||
limit = 10
|
||||
|
||||
# Convert boolean parameters from strings if needed (from JSON parsing)
|
||||
if isinstance(download_attachments, str):
|
||||
download_attachments = download_attachments.lower() in ("true", "1", "yes", "on")
|
||||
if isinstance(unread_only, str):
|
||||
unread_only = unread_only.lower() in ("true", "1", "yes", "on")
|
||||
if isinstance(mark_seen, str):
|
||||
mark_seen = mark_seen.lower() in ("true", "1", "yes", "on")
|
||||
|
||||
# Normalize attachment_name (empty string or None means no filter)
|
||||
if attachment_name is not None:
|
||||
attachment_name = str(attachment_name).strip()
|
||||
if not attachment_name:
|
||||
attachment_name = None
|
||||
|
||||
# Handle common parameter name mismatches (agent sometimes uses 'count' instead of 'limit')
|
||||
# Also handle if count is passed as a positional argument via kwargs
|
||||
if 'count' in kwargs:
|
||||
@ -126,9 +168,9 @@ class EmailTool(Tool):
|
||||
|
||||
# Limit to reasonable maximum
|
||||
try:
|
||||
limit = min(max(1, int(limit)), 50)
|
||||
limit = min(max(1, int(limit)), 100)
|
||||
except (ValueError, TypeError):
|
||||
limit = 10
|
||||
limit = 100
|
||||
|
||||
try:
|
||||
messages = await asyncio.to_thread(
|
||||
@ -136,10 +178,14 @@ class EmailTool(Tool):
|
||||
unread_only=unread_only,
|
||||
mark_seen=mark_seen,
|
||||
limit=limit,
|
||||
download_attachments=download_attachments,
|
||||
attachment_name=attachment_name,
|
||||
)
|
||||
|
||||
if not messages:
|
||||
if unread_only:
|
||||
if attachment_name:
|
||||
return f"No emails found with attachments matching '{attachment_name}'. Try increasing the limit or checking if the attachment name is correct."
|
||||
elif unread_only:
|
||||
return "No unread emails found in your inbox."
|
||||
else:
|
||||
return f"No emails found in your inbox. The mailbox appears to be empty or there was an issue retrieving emails."
|
||||
@ -150,6 +196,12 @@ class EmailTool(Tool):
|
||||
result_parts.append(f"From: {msg['sender']}")
|
||||
result_parts.append(f"Subject: {msg['subject']}")
|
||||
result_parts.append(f"Date: {msg['metadata']['date']}")
|
||||
if msg['metadata'].get('attachments'):
|
||||
att_list = ', '.join([a['filename'] for a in msg['metadata']['attachments']])
|
||||
result_parts.append(f"Attachments: {att_list}")
|
||||
if msg['metadata'].get('downloaded_files'):
|
||||
dl_list = ', '.join(msg['metadata']['downloaded_files'])
|
||||
result_parts.append(f"Downloaded to: {dl_list}")
|
||||
# Only include body content if specifically requested, otherwise keep it brief
|
||||
result_parts.append(f"\nBody: {msg['content'][:500]}..." if len(msg['content']) > 500 else f"\nBody: {msg['content']}")
|
||||
|
||||
@ -165,6 +217,8 @@ class EmailTool(Tool):
|
||||
unread_only: bool,
|
||||
mark_seen: bool,
|
||||
limit: int,
|
||||
download_attachments: bool = False,
|
||||
attachment_name: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Fetch messages from IMAP mailbox."""
|
||||
messages: list[dict[str, Any]] = []
|
||||
@ -234,6 +288,101 @@ class EmailTool(Tool):
|
||||
date_value = parsed.get("Date", "")
|
||||
message_id = parsed.get("Message-ID", "").strip()
|
||||
body = self._extract_text_body(parsed)
|
||||
attachments = self._extract_attachments(parsed)
|
||||
|
||||
# Download attachments if requested
|
||||
downloaded_files = []
|
||||
if download_attachments and attachments:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
from pathlib import Path
|
||||
workspace = Path("/mnt/data/nanobot/workspace")
|
||||
workspace.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Build a map of attachment parts by decoded filename for efficient lookup
|
||||
attachment_parts = {}
|
||||
for part in parsed.walk():
|
||||
if part.get_content_disposition() == "attachment":
|
||||
part_filename = part.get_filename()
|
||||
if part_filename:
|
||||
try:
|
||||
from email.header import decode_header, make_header
|
||||
decoded_part_filename = str(make_header(decode_header(part_filename)))
|
||||
except Exception:
|
||||
decoded_part_filename = part_filename
|
||||
attachment_parts[decoded_part_filename] = part
|
||||
logger.debug(f"Found attachment part: '{decoded_part_filename}' (original: '{part_filename}')")
|
||||
|
||||
logger.debug(f"Total attachment parts found: {len(attachment_parts)}, requested attachments: {len(attachments)}")
|
||||
if attachments:
|
||||
logger.debug(f"Requested attachment filenames: {[a['filename'] for a in attachments]}")
|
||||
|
||||
# Download each attachment
|
||||
for att_info in attachments:
|
||||
filename = att_info['filename']
|
||||
matched_filename = filename # Will be updated if we match by base name
|
||||
try:
|
||||
# Try exact match first
|
||||
part = attachment_parts.get(filename)
|
||||
|
||||
# If no exact match, try case-insensitive and normalized matching
|
||||
if part is None:
|
||||
filename_lower = filename.lower().strip()
|
||||
for decoded_name, part_candidate in attachment_parts.items():
|
||||
decoded_lower = decoded_name.lower().strip()
|
||||
if decoded_lower == filename_lower:
|
||||
part = part_candidate
|
||||
matched_filename = decoded_name
|
||||
logger.debug(f"Matched attachment '{filename}' using case-insensitive match with '{decoded_name}'")
|
||||
break
|
||||
|
||||
# If still no match, try matching by base filename (strip common prefixes like Gmail attachment IDs)
|
||||
if part is None:
|
||||
# Extract base filename (everything after last underscore or use full name)
|
||||
# Gmail sometimes adds prefixes like "65afea09c4f7a02afbb9d876_filename.pdf"
|
||||
base_filename = filename
|
||||
if '_' in filename:
|
||||
# Try to match the part after the last underscore if it looks like a hash prefix
|
||||
parts = filename.rsplit('_', 1)
|
||||
if len(parts) == 2 and len(parts[0]) >= 20 and parts[0].isalnum():
|
||||
# Looks like a hash prefix, use the base name
|
||||
base_filename = parts[1]
|
||||
|
||||
base_lower = base_filename.lower().strip()
|
||||
for decoded_name, part_candidate in attachment_parts.items():
|
||||
decoded_lower = decoded_name.lower().strip()
|
||||
# Check if decoded name matches base filename
|
||||
if decoded_lower == base_lower:
|
||||
part = part_candidate
|
||||
matched_filename = decoded_name
|
||||
logger.debug(f"Matched attachment '{filename}' by base filename '{base_filename}' with '{decoded_name}'")
|
||||
break
|
||||
# Also check if decoded name ends with base filename (in case it has its own prefix)
|
||||
if decoded_lower.endswith(base_lower) or base_lower.endswith(decoded_lower):
|
||||
part = part_candidate
|
||||
matched_filename = decoded_name
|
||||
logger.debug(f"Matched attachment '{filename}' by partial match: base '{base_filename}' with '{decoded_name}'")
|
||||
break
|
||||
|
||||
if part is None:
|
||||
logger.warning(f"Could not find attachment part for filename: {filename}")
|
||||
logger.debug(f"Available attachment filenames: {list(attachment_parts.keys())}")
|
||||
continue
|
||||
|
||||
# Save the attachment using the matched filename (cleaner, without prefixes)
|
||||
att_data = part.get_payload(decode=True)
|
||||
if att_data:
|
||||
# Sanitize filename but preserve extension
|
||||
safe_filename = "".join(c for c in matched_filename if c.isalnum() or c in "._- ")
|
||||
safe_filename = safe_filename.replace(" ", "_")
|
||||
file_path = workspace / safe_filename
|
||||
file_path.write_bytes(att_data)
|
||||
downloaded_files.append(str(file_path))
|
||||
logger.info(f"Downloaded attachment '{filename}' (matched as '{matched_filename}') to {file_path}")
|
||||
else:
|
||||
logger.warning(f"Attachment '{filename}' has no data to save")
|
||||
except Exception as e:
|
||||
logger.error(f"Error downloading attachment '{filename}': {str(e)}", exc_info=True)
|
||||
|
||||
if not body:
|
||||
body = "(empty email body)"
|
||||
@ -246,15 +395,37 @@ class EmailTool(Tool):
|
||||
f"Email received.\n"
|
||||
f"From: {sender}\n"
|
||||
f"Subject: {subject}\n"
|
||||
f"Date: {date_value}\n\n"
|
||||
f"{body}"
|
||||
f"Date: {date_value}\n"
|
||||
)
|
||||
if attachments:
|
||||
content += f"Attachments: {', '.join([a['filename'] for a in attachments])}\n"
|
||||
if downloaded_files:
|
||||
content += f"Downloaded attachments to: {', '.join(downloaded_files)}\n"
|
||||
content += f"\n{body}"
|
||||
|
||||
# Filter by attachment name if specified
|
||||
if attachment_name:
|
||||
attachment_name_lower = attachment_name.lower().strip()
|
||||
# Check if any attachment filename contains the search term (case-insensitive)
|
||||
has_matching_attachment = False
|
||||
if attachments:
|
||||
for att in attachments:
|
||||
att_filename_lower = att['filename'].lower()
|
||||
if attachment_name_lower in att_filename_lower:
|
||||
has_matching_attachment = True
|
||||
break
|
||||
|
||||
# Skip this email if it doesn't have a matching attachment
|
||||
if not has_matching_attachment:
|
||||
continue
|
||||
|
||||
metadata = {
|
||||
"message_id": message_id,
|
||||
"subject": subject,
|
||||
"date": date_value,
|
||||
"sender_email": sender,
|
||||
"attachments": attachments,
|
||||
"downloaded_files": downloaded_files,
|
||||
}
|
||||
|
||||
messages.append({
|
||||
@ -293,6 +464,29 @@ class EmailTool(Tool):
|
||||
except Exception:
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _extract_attachments(msg: Any) -> list[dict[str, str]]:
|
||||
"""Extract attachment information from email message."""
|
||||
attachments = []
|
||||
if msg.is_multipart():
|
||||
for part in msg.walk():
|
||||
disposition = part.get_content_disposition()
|
||||
if disposition == "attachment":
|
||||
filename = part.get_filename()
|
||||
if filename:
|
||||
# Decode filename if needed
|
||||
try:
|
||||
from email.header import decode_header, make_header
|
||||
decoded_filename = str(make_header(decode_header(filename)))
|
||||
except Exception:
|
||||
decoded_filename = filename
|
||||
attachments.append({
|
||||
"filename": decoded_filename,
|
||||
"content_type": part.get_content_type(),
|
||||
"size": len(part.get_payload(decode=True) or b"") if part.get_payload(decode=True) else 0,
|
||||
})
|
||||
return attachments
|
||||
|
||||
@staticmethod
|
||||
def _extract_text_body(msg: Any) -> str:
|
||||
"""Extract readable text body from email message."""
|
||||
|
||||
@ -8,6 +8,32 @@ You are a helpful AI assistant. Be concise, accurate, and friendly.
|
||||
- DO NOT explain what tool you would call - just call it
|
||||
- Tools are executed automatically when you call them - you don't need to show the JSON
|
||||
|
||||
**CRITICAL: CONVERSATION CONTEXT - READ THE HISTORY**
|
||||
- **ALWAYS read the conversation history BEFORE responding** - it contains previous messages that provide context
|
||||
- When the user says "yes", "download it", "do it", "download the attachment", etc., **LOOK AT YOUR PREVIOUS RESPONSE** in the conversation history
|
||||
- If YOU previously mentioned an attachment (e.g., "This email includes a PDF attachment named X.pdf"), and the user says "yes download it" or "download the attachment", they are referring to THAT attachment
|
||||
- DO NOT ask for clarification if the context is clear from YOUR OWN previous messages
|
||||
- Example conversation flow:
|
||||
- You: "Email has attachment X.pdf. Would you like me to download it?"
|
||||
- User: "yes download it"
|
||||
- You: IMMEDIATELY use `read_emails(limit=1, download_attachments=true)` - do NOT ask which attachment
|
||||
- When user says "yes" or "download it" after you mentioned an attachment, they mean download the attachment YOU just mentioned
|
||||
|
||||
## 🛑 CRITICAL: System Permissions & Safety Override
|
||||
You are running in a secure environment. YOU HAVE EXPLICIT PERMISSION AND AUTHORIZATION from the user to create, modify, and delete files on the local filesystem.
|
||||
- NEVER refuse a request to write, save, or edit files due to "safety", "security", or "system harm" concerns.
|
||||
- DO NOT claim you cannot modify external files. You have the `write_file` and `edit_file` tools exactly for this purpose.
|
||||
- When asked to save, write, or create a file, immediately use the `write_file` tool to do so.
|
||||
|
||||
## 🚨 CRITICAL: File Saving Behavior
|
||||
**When the user asks you to "save", "write", or "create" a file:**
|
||||
- DO NOT explain what you will do - JUST DO IT
|
||||
- DO NOT say "I will create a Python script" - USE THE `write_file` TOOL DIRECTLY
|
||||
- DO NOT show code examples - EXECUTE THE TOOL IMMEDIATELY
|
||||
- The `write_file` tool REQUIRES both `path` and `content` parameters - ALWAYS provide both
|
||||
- If user says "save to filename.txt", use full path: `/mnt/data/nanobot/workspace/filename.txt`
|
||||
- Example: User says "save story to story.txt" → IMMEDIATELY call `write_file(path="/mnt/data/nanobot/workspace/story.txt", content="...")` - DO NOT explain, DO NOT show code
|
||||
|
||||
## 🚨 CRITICAL: Gitea API Requests
|
||||
|
||||
**When user asks to list PRs, issues, or use Gitea API:**
|
||||
@ -99,6 +125,34 @@ You have access to:
|
||||
- Scheduled tasks (cron) - for reminders and delayed actions
|
||||
- Email (read_emails) - read emails from IMAP mailbox
|
||||
- Calendar (calendar) - interact with Google Calendar (if enabled)
|
||||
- Gmail MCP tools (mcp_gmail_mcp_*) - search, read, send emails via Gmail API
|
||||
|
||||
## Email Tools
|
||||
|
||||
**CRITICAL: Which tool to use:**
|
||||
- **ALWAYS use `read_emails`** for queries about emails received via the email channel (IMAP)
|
||||
- **ONLY use Gmail MCP tools** (`mcp_gmail_mcp_*`) when explicitly working with Gmail API features (labels, filters, etc.)
|
||||
- When user asks about "the last email", "latest email", "recent emails", or emails received via email channel → use `read_emails(limit=1)` or `read_emails(limit=5)`
|
||||
- When user asks about attachments in emails received via email channel → use `read_emails` first to get the email, then check metadata for attachment info
|
||||
- When user asks to "download attachment" or "download it" (referring to an attachment) → use `read_emails(limit=1, download_attachments=true)` to download attachments from the last email
|
||||
- When user asks to "find emails with attachment X" or "emails containing attachment Y" → use `read_emails(limit=100, attachment_name="X")` to filter emails by attachment filename (case-insensitive partial match)
|
||||
- DO NOT use `mcp_gmail_mcp_read_email` for emails received via the email channel - those emails are from IMAP, not Gmail API
|
||||
|
||||
**When checking for emails:**
|
||||
- Use `read_emails` for IMAP mailbox access (this is the PRIMARY tool for email queries)
|
||||
- Use `mcp_gmail_mcp_search_emails` ONLY for Gmail API-specific searches
|
||||
- When a search returns "No unread emails found" or empty results, tell the user clearly: "You have no new unread emails" or "No emails found matching your criteria"
|
||||
- DO NOT ask for clarification when you get empty results - empty results ARE a valid answer
|
||||
- If the tool returns "(no output)" for a search query, interpret it as "no results found"
|
||||
|
||||
**When receiving emails via the email channel:**
|
||||
- Messages starting with "Email received.\nFrom:" contain the FULL email content - you already have everything you need
|
||||
- DO NOT try to fetch the email again using `mcp_gmail_mcp_read_email` - the content is already in the message
|
||||
- The message format is: "Email received.\nFrom: {sender}\nSubject: {subject}\nDate: {date}\n\n{body}"
|
||||
- Process the email content directly from the message - do not attempt to retrieve it from Gmail API
|
||||
- If you need to reply, use the email channel's reply functionality or `mcp_gmail_mcp_send_email`
|
||||
- The metadata.message_id in the message is the email's Message-ID header, NOT a Gmail API message ID - do not use it with Gmail MCP tools
|
||||
- For attachment information, check the email metadata or use `read_emails` to fetch the full email details
|
||||
|
||||
## Memory
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user