"""
Microsoft Outlook Email Operations Module
This module provides actions for composing and sending emails via Microsoft Outlook using the Microsoft Graph API.
ACTION CONTRACT DEFINITION:
==========================
1. COMPOSE EMAIL ACTION (composeEmail):
====================================
Purpose: Use AI to compose professional email content
Input Parameters:
- context (str): Email context/requirements
- recipient (str, optional): Recipient information
- attachments (List[str], optional): Available documents to reference
- tone (str, optional): Email tone (formal, casual, etc.)
- expectedDocumentFormats (list, optional): Ignored - always produces JSON
Output Contract:
The action produces a JSON document with this EXACT structure:
{
"context": "original context",
"recipient": "recipient info",
"tone": "email tone",
"timestamp": "ISO timestamp",
"usage": "usage description",
"to": ["email@example.com"],
"subject": "Email subject",
"body": "Email body content",
"cc": [],
"bcc": [],
"attachments": ["docItem:uuid:filename.pdf"]
}
Key Points:
- Email fields (to, subject, body, cc, bcc, attachments) are at ROOT LEVEL
- NOT wrapped in a "composedEmail" field
- Always produces .json format regardless of expectedDocumentFormats
- AI response is validated and parsed before output
2. SEND EMAIL ACTION (sendEmail):
==============================
Purpose: Send the composed email via Outlook (creates draft)
Input Parameters:
- connectionReference (str): Microsoft connection reference
- composedEmail (str): Reference to composed email document (docItem:...)
- expectedDocumentFormats (list, optional): Expected output formats
Input Contract:
The composedEmail document MUST have this EXACT structure:
{
"to": ["email@example.com"],
"subject": "Email subject",
"body": "Email body content",
"cc": [],
"bcc": [],
"attachments": ["docItem:uuid:filename.pdf"]
}
Key Points:
- Email fields must be at ROOT LEVEL
- NOT wrapped in a nested structure
- Reads file content from database using fileId
- Creates email draft in Outlook Drafts folder
- Returns success/failure status
DATA FLOW:
==========
composeEmail → JSON Document → sendEmail → Outlook Draft
The contract ensures that composeEmail outputs exactly what sendEmail expects to consume.
"""
import logging
from typing import Dict, Any, List, Optional
from datetime import datetime, UTC
import json
import uuid
from modules.chat.methodBase import MethodBase, action
from modules.interfaces.interfaceChatModel import ActionResult
from modules.interfaces.interfaceAppModel import ConnectionStatus
logger = logging.getLogger(__name__)
class MethodOutlook(MethodBase):
"""Outlook method implementation for email operations"""
def __init__(self, serviceCenter: Any):
"""Initialize the Outlook method"""
super().__init__(serviceCenter)
self.name = "outlook"
self.description = "Handle Microsoft Outlook email operations"
def _getMicrosoftConnection(self, connectionReference: str) -> Optional[Dict[str, Any]]:
"""
Helper function to get Microsoft connection details.
"""
try:
# Get the connection from the service
userConnection = self.service.getUserConnectionFromConnectionReference(connectionReference)
if not userConnection:
logger.error(f"Connection not found: {connectionReference}")
return None
# Get the token for this specific connection
token = self.service.interfaceApp.getTokenForConnection(userConnection.id)
if not token:
logger.error(f"Token not found for connection: {userConnection.id}")
return None
# Check if token is expired
if hasattr(token, 'expiresAt') and token.expiresAt:
import time
current_time = time.time()
if current_time > token.expiresAt:
logger.error(f"Token for connection {userConnection.id} is expired (expiresAt: {token.expiresAt}, current: {current_time})")
return None
# Check if connection is active
if userConnection.status.value != "active":
logger.error(f"Connection is not active: {userConnection.id}, status: {userConnection.status.value}")
return None
logger.info(f"Successfully retrieved Microsoft connection: {userConnection.id}, status: {userConnection.status.value}, externalId: {userConnection.externalId}")
return {
"id": userConnection.id,
"accessToken": token.tokenAccess,
"refreshToken": token.tokenRefresh,
"scopes": ["Mail.ReadWrite", "Mail.Send", "Mail.ReadWrite.Shared", "User.Read"] # Valid Microsoft Graph API scopes
}
except Exception as e:
logger.error(f"Error getting Microsoft connection: {str(e)}")
return None
async def _checkPermissions(self, connection: Dict[str, Any]) -> bool:
"""
Check if the current connection has the necessary permissions for Outlook operations.
"""
try:
import requests
graph_url = "https://graph.microsoft.com/v1.0"
headers = {
"Authorization": f"Bearer {connection['accessToken']}",
"Content-Type": "application/json"
}
# Test permissions by trying to access the user's mail folder
test_url = f"{graph_url}/me/mailFolders"
response = requests.get(test_url, headers=headers)
if response.status_code == 200:
logger.info("✅ Permission check passed - connection has necessary mail permissions")
return True
elif response.status_code == 403:
logger.error("❌ Permission denied - connection lacks necessary mail permissions")
logger.error("Required scopes: Mail.ReadWrite, Mail.Send, Mail.ReadWrite.Shared")
return False
else:
logger.warning(f"⚠️ Permission check returned status {response.status_code}")
return False
except Exception as e:
logger.error(f"Error checking permissions: {str(e)}")
return False
def _sanitizeSearchQuery(self, query: str) -> str:
"""
Sanitize and validate search query for Microsoft Graph API
Microsoft Graph API has specific requirements for search queries:
- Escape special characters properly
- Handle search operators correctly
- Ensure query format is valid
"""
if not query:
return ""
# Clean the query
clean_query = query.strip()
# Remove any double quotes that might cause issues
clean_query = clean_query.replace('"', '')
# Handle common search operators
if any(op in clean_query.lower() for op in ['from:', 'to:', 'subject:', 'received:', 'hasattachment:']):
# This is an advanced search query, return as-is
return clean_query
# For basic text search, ensure it's safe for contains() filter
# Remove any characters that might break the OData filter syntax
import re
# Remove or escape characters that could break OData filter syntax
safe_query = re.sub(r'[\\\'"]', '', clean_query)
return safe_query
def _buildSearchParameters(self, query: str, folder: str, limit: int) -> Dict[str, Any]:
"""
Build search parameters for Microsoft Graph API
This method handles the complexity of building search parameters
while avoiding conflicts between $search and $filter parameters.
"""
params = {
"$top": limit
}
if not query or not query.strip():
# No query specified, just get emails from folder
if folder and folder.lower() != "all":
params["$filter"] = f"parentFolderId eq '{folder}'"
# Add orderby for basic queries
params["$orderby"] = "receivedDateTime desc"
return params
clean_query = self._sanitizeSearchQuery(query)
# Check if this is a complex search query with multiple operators
if any(op in clean_query.lower() for op in ['from:', 'to:', 'subject:', 'received:', 'hasattachment:']):
# This is an advanced search query, use $search
# Microsoft Graph API supports complex search syntax
params["$search"] = f'"{clean_query}"'
logger.info(f"Using advanced search query: {clean_query}")
# Note: When using $search, we cannot combine it with $orderby or $filter for folder
# We'll need to filter results after the API call
if folder and folder.lower() != "all":
logger.info(f"Will filter results by folder '{folder}' after search")
else:
# Use $filter for basic text search, but keep it simple to avoid "InefficientFilter" error
# Microsoft Graph API has limitations on complex filters
if len(clean_query) > 50:
# If query is too long, truncate it to avoid complex filter issues
clean_query = clean_query[:50]
logger.info(f"Query truncated to avoid complex filter: {clean_query}")
# Use only subject search to keep filter simple
params["$filter"] = f"contains(subject,'{clean_query}')"
# Add folder filter if specified
if folder and folder.lower() != "all":
params["$filter"] = f"{params['$filter']} and parentFolderId eq '{folder}'"
# Add orderby for basic queries
params["$orderby"] = "receivedDateTime desc"
logger.info(f"Using simple text search filter: {clean_query}")
return params
def _getFolderId(self, folder_name: str, connection: Dict[str, Any]) -> Optional[str]:
"""
Get the folder ID for a given folder name
This is needed for proper filtering when using advanced search queries
"""
try:
import requests
graph_url = "https://graph.microsoft.com/v1.0"
headers = {
"Authorization": f"Bearer {connection['accessToken']}",
"Content-Type": "application/json"
}
# Get mail folders
api_url = f"{graph_url}/me/mailFolders"
response = requests.get(api_url, headers=headers)
if response.status_code == 200:
folders_data = response.json()
all_folders = folders_data.get("value", [])
# Log all available folders for debugging
logger.info(f"Available folders: {[f.get('displayName', 'Unknown') for f in all_folders]}")
# Try exact match first
for folder in all_folders:
if folder.get("displayName", "").lower() == folder_name.lower():
logger.info(f"Found folder '{folder_name}' with ID: {folder.get('id')}")
return folder.get("id")
# Try common variations for Drafts folder
if folder_name.lower() == "drafts":
draft_variations = ["drafts", "draft", "entwürfe", "entwurf", "brouillons", "brouillon"]
for folder in all_folders:
folder_display_name = folder.get("displayName", "").lower()
if any(variation in folder_display_name for variation in draft_variations):
logger.info(f"Found Drafts folder variation '{folder.get('displayName')}' with ID: {folder.get('id')}")
return folder.get("id")
# Try common variations for other folders
if folder_name.lower() == "sent items":
sent_variations = ["sent items", "sent", "gesendete elemente", "éléments envoyés"]
for folder in all_folders:
folder_display_name = folder.get("displayName", "").lower()
if any(variation in folder_display_name for variation in sent_variations):
logger.info(f"Found Sent Items folder variation '{folder.get('displayName')}' with ID: {folder.get('id')}")
return folder.get("id")
logger.warning(f"Folder '{folder_name}' not found. Available folders: {[f.get('displayName', 'Unknown') for f in all_folders]}")
return None
else:
logger.warning(f"Could not retrieve folders: {response.status_code}")
return None
except Exception as e:
logger.warning(f"Error getting folder ID for '{folder_name}': {str(e)}")
return None
@action
async def readEmails(self, parameters: Dict[str, Any]) -> ActionResult:
"""
Read emails from Outlook
Parameters:
connectionReference (str): Reference to the Microsoft connection
folder (str, optional): Email folder to read from (default: "Inbox")
limit (int, optional): Maximum number of emails to read (default: 10)
filter (str, optional): Filter criteria for emails
expectedDocumentFormats (list, optional): Expected document formats with extension, mimeType, description
"""
try:
connectionReference = parameters.get("connectionReference")
folder = parameters.get("folder", "Inbox")
limit = parameters.get("limit", 10)
filter = parameters.get("filter")
expectedDocumentFormats = parameters.get("expectedDocumentFormats", [])
if not connectionReference:
return ActionResult.failure(error="Connection reference is required")
# Get Microsoft connection
connection = self._getMicrosoftConnection(connectionReference)
if not connection:
return ActionResult.failure(error="No valid Microsoft connection found for the provided connection reference")
# Read emails using Microsoft Graph API
try:
import requests
# Microsoft Graph API endpoint for messages
graph_url = "https://graph.microsoft.com/v1.0"
headers = {
"Authorization": f"Bearer {connection['accessToken']}",
"Content-Type": "application/json"
}
# Build the API request
api_url = f"{graph_url}/me/mailFolders/{folder}/messages"
params = {
"$top": limit,
"$orderby": "receivedDateTime desc"
}
if filter:
params["$filter"] = filter
# Make the API call
response = requests.get(api_url, headers=headers, params=params)
response.raise_for_status()
emails_data = response.json()
email_data = {
"emails": emails_data.get("value", []),
"count": len(emails_data.get("value", [])),
"folder": folder,
"filter": filter,
"apiResponse": emails_data
}
logger.info(f"Successfully retrieved {len(emails_data.get('value', []))} emails from {folder}")
except ImportError:
logger.error("requests module not available")
return ActionResult.failure(error="requests module not available")
except Exception as e:
logger.error(f"Error reading emails from Microsoft Graph API: {str(e)}")
return ActionResult.failure(error=f"Failed to read emails: {str(e)}")
# Determine output format based on expected formats
output_extension = ".json" # Default
output_mime_type = "application/json" # Default
if expectedDocumentFormats and len(expectedDocumentFormats) > 0:
# Use the first expected format
expected_format = expectedDocumentFormats[0]
output_extension = expected_format.get("extension", ".json")
output_mime_type = expected_format.get("mimeType", "application/json")
logger.info(f"Using expected format: {output_extension} ({output_mime_type})")
else:
logger.info("No expected format specified, using default .json format")
return ActionResult.success(
documents=[{
"documentName": f"outlook_emails_{datetime.now(UTC).strftime('%Y%m%d_%H%M%S')}.json",
"documentData": {
"connectionReference": connectionReference,
"folder": folder,
"limit": limit,
"filter": filter,
"emails": email_data,
"connection": {
"id": connection["id"],
"authority": "microsoft",
"reference": connectionReference
},
"timestamp": datetime.now(UTC).isoformat()
},
"mimeType": "application/json"
}],
resultLabel="outlook_emails"
)
except Exception as e:
logger.error(f"Error reading emails: {str(e)}")
return ActionResult.failure(
error=str(e)
)
@action
async def sendEmail(self, parameters: Dict[str, Any]) -> ActionResult:
"""
Send email via Outlook using composed email content
This action takes a composed email document and sends it via Outlook.
The composed email must contain all necessary email details (recipients, subject, body, attachments).
Parameters:
connectionReference (str): Reference to the Microsoft connection
composedEmail (str): Reference to the composed email document (docItem:...)
expectedDocumentFormats (list, optional): Expected document formats with extension, mimeType, description
Input Contract:
The composedEmail document must have the following structure:
{
"to": ["email@example.com"],
"subject": "Email subject",
"body": "Email body content",
"cc": [],
"bcc": [],
"attachments": ["docItem:uuid:filename.pdf"]
}
Note: Email fields must be at root level, not wrapped in a nested structure.
"""
try:
connectionReference = parameters.get("connectionReference")
composed_email_ref = parameters.get("composedEmail")
expectedDocumentFormats = parameters.get("expectedDocumentFormats", [])
if not connectionReference or not composed_email_ref:
return ActionResult.failure(
error="Connection reference and composed email reference are required"
)
# Get Microsoft connection
connection = self._getMicrosoftConnection(connectionReference)
if not connection:
return ActionResult.failure(error="Failed to get Microsoft connection")
# Get the composed email document
logger.info(f"Getting composed email document: {composed_email_ref}")
composed_email_docs = self.service.getChatDocumentsFromDocumentList([composed_email_ref])
if not composed_email_docs or len(composed_email_docs) == 0:
logger.error(f"Could not find composed email document: {composed_email_ref}")
return ActionResult.failure(error=f"Could not find composed email document: {composed_email_ref}")
logger.info(f"Found {len(composed_email_docs)} composed email documents")
composed_email_doc = composed_email_docs[0]
logger.info(f"Composed email document: {composed_email_doc}")
# Extract email details from the composed email document
try:
logger.info(f"Extracting email details from document...")
# Get the actual file content from the database
# The document object has fileId, but we need to read the actual file content
file_id = getattr(composed_email_doc, 'fileId', None)
if not file_id:
logger.error("Document has no fileId attribute")
return ActionResult.failure(error="Composed email document has no fileId")
logger.info(f"Reading file content from fileId: {file_id}")
# Read the actual file content from the database
try:
# Use the correct service interface to read file data
file_content = self.service.getFileData(file_id)
if not file_content:
logger.error(f"Failed to read file content for fileId: {file_id}")
return ActionResult.failure(error="Failed to read composed email file content")
logger.info(f"Successfully read file content, length: {len(str(file_content))}")
# Convert bytes to string if needed
if isinstance(file_content, bytes):
email_data = file_content.decode('utf-8')
logger.info(f"Converted bytes to string, content length: {len(email_data)}")
else:
email_data = str(file_content)
logger.info(f"Content is already string, length: {len(email_data)}")
# Debug: show first 200 characters of content
preview = email_data[:200] + "..." if len(email_data) > 200 else email_data
logger.info(f"Content preview: {repr(preview)}")
except Exception as e:
logger.error(f"Error reading file content: {str(e)}")
return ActionResult.failure(error=f"Failed to read file content: {str(e)}")
# Parse the email data (should be JSON)
if isinstance(email_data, str):
import json
try:
# First try to parse as direct JSON
parsed_email_data = json.loads(email_data)
logger.info("Successfully parsed email data as direct JSON")
email_data = parsed_email_data # Now email_data is the parsed dictionary
except json.JSONDecodeError as e:
logger.error(f"JSON parsing error: {str(e)}")
logger.error(f"Content that failed to parse: {repr(email_data[:500])}")
# If that fails, try to extract JSON from HTML content
logger.info("Direct JSON parsing failed, trying to extract from HTML content...")
import re
# Look for JSON content within HTML tags or as a script
json_pattern = r'\{[^{}]*"to"[^{}]*"subject"[^{}]*"body"[^{}]*\}'
json_match = re.search(json_pattern, email_data, re.DOTALL)
if json_match:
try:
extracted_json = json_match.group(0)
parsed_email_data = json.loads(extracted_json)
logger.info("Successfully extracted and parsed JSON from HTML content")
email_data = parsed_email_data # Now email_data is the parsed dictionary
except json.JSONDecodeError as e2:
logger.error(f"Failed to parse extracted JSON: {str(e2)}")
logger.error(f"Extracted content: {repr(extracted_json)}")
return ActionResult.failure(error="Could not parse JSON content from composed email document")
else:
logger.error("No JSON content found in HTML document")
return ActionResult.failure(error="Composed email document content is not valid JSON and no JSON could be extracted")
else:
logger.error(f"Unexpected email_data type: {type(email_data)}")
return ActionResult.failure(error=f"Unexpected email data type: {type(email_data)}, expected string")
# At this point, email_data should be a parsed dictionary
logger.info(f"Final email_data type: {type(email_data)}")
if isinstance(email_data, dict):
logger.info(f"Available keys: {list(email_data.keys())}")
# Extract email fields - now they should be at root level
to = email_data.get("to", [])
subject = email_data.get("subject", "")
body = email_data.get("body", "")
cc = email_data.get("cc", [])
bcc = email_data.get("bcc", [])
attachments = email_data.get("attachments", [])
# Validate required fields
if not to or not subject or not body:
logger.error(f"Missing required fields. Available keys: {list(email_data.keys())}")
return ActionResult.failure(error="Composed email must contain 'to', 'subject', and 'body' fields")
logger.info(f"Extracted email details: to={to}, subject='{subject}', body length={len(body)}, attachments={len(attachments)}")
except Exception as e:
logger.error(f"Error parsing composed email document: {str(e)}")
return ActionResult.failure(error=f"Failed to parse composed email document: {str(e)}")
# Check permissions before proceeding
logger.info("Checking Microsoft Graph API permissions...")
permissions_ok = await self._checkPermissions(connection)
if not permissions_ok:
logger.error("Permission check failed")
return ActionResult.failure(error="Connection lacks necessary permissions for Outlook operations")
logger.info("Permission check passed")
# Create email draft using Microsoft Graph API
try:
import requests
# Microsoft Graph API endpoint for creating draft messages
graph_url = "https://graph.microsoft.com/v1.0"
headers = {
"Authorization": f"Bearer {connection['accessToken']}",
"Content-Type": "application/json"
}
# Clean and format body content
cleaned_body = body.strip()
# Check if body is already HTML
if cleaned_body.startswith('') or cleaned_body.startswith('
') or '
' in cleaned_body:
# Body is already HTML, use as-is
html_body = cleaned_body
logger.info("Body content is already HTML formatted")
else:
# Convert plain text to proper HTML formatting
# Replace newlines with
tags and wrap in proper HTML structure
html_body = cleaned_body.replace('\n', '
')
html_body = f"{html_body}"
logger.info("Converted plain text to HTML format")
# Build the email message
message = {
"subject": subject,
"body": {
"contentType": "HTML",
"content": html_body
},
"toRecipients": [{"emailAddress": {"address": email}} for email in to],
"ccRecipients": [{"emailAddress": {"address": email}} for email in cc] if cc else [],
"bccRecipients": [{"emailAddress": {"address": email}} for email in bcc] if bcc else []
}
# Add attachments if provided
if attachments:
message["attachments"] = []
for attachment_ref in attachments:
logger.info(f"Processing attachment: {attachment_ref}")
# Get attachment document from service center
attachment_docs = self.service.getChatDocumentsFromDocumentList([attachment_ref])
if attachment_docs:
for doc in attachment_docs:
logger.info(f"Found attachment document: {doc.filename}, fileId: {getattr(doc, 'fileId', 'None')}")
# Get the actual file content using fileId
file_id = getattr(doc, 'fileId', None)
if file_id:
try:
# Read the actual file content
file_content = self.service.getFileData(file_id)
if file_content:
# Convert to base64 for Graph API
import base64
if isinstance(file_content, bytes):
content_bytes = file_content
else:
content_bytes = str(file_content).encode('utf-8')
base64_content = base64.b64encode(content_bytes).decode('utf-8')
# Create attachment object for Graph API
attachment = {
"@odata.type": "#microsoft.graph.fileAttachment",
"name": doc.filename,
"contentType": doc.mimeType or "application/octet-stream",
"contentBytes": base64_content
}
message["attachments"].append(attachment)
logger.info(f"✅ Successfully added attachment: {doc.filename} (size: {len(content_bytes)} bytes)")
else:
logger.warning(f"⚠️ No content found for attachment: {doc.filename}")
except Exception as e:
logger.error(f"❌ Error reading attachment file {doc.filename}: {str(e)}")
else:
logger.warning(f"⚠️ Attachment document has no fileId: {doc.filename}")
else:
logger.warning(f"⚠️ No attachment documents found for reference: {attachment_ref}")
# Create the draft message
# First, get the Drafts folder ID to ensure the draft is created there
drafts_folder_id = self._getFolderId("Drafts", connection)
if drafts_folder_id:
# Create draft in the Drafts folder specifically
api_url = f"{graph_url}/me/mailFolders/{drafts_folder_id}/messages"
logger.info(f"Creating draft in Drafts folder (ID: {drafts_folder_id})")
logger.info(f"Target folder: Drafts (Entwürfe)")
logger.info(f"Mailbox account: {connection.get('userEmail', 'Unknown')}")
else:
# Fallback: create in default location
api_url = f"{graph_url}/me/messages"
logger.warning("Could not find Drafts folder, creating draft in default location")
logger.info(f"Mailbox account: {connection.get('userEmail', 'Unknown')}")
logger.info(f"Creating draft with API URL: {api_url}")
logger.info(f"Email body preview: {html_body[:200]}...")
logger.info(f"Number of attachments: {len(message.get('attachments', []))}")
if message.get('attachments'):
for i, att in enumerate(message['attachments']):
logger.info(f" Attachment {i+1}: {att['name']} ({att['contentType']}) - Content size: {len(att['contentBytes'])} chars")
logger.info(f"Draft message data: {json.dumps(message, indent=2)}")
response = requests.post(api_url, headers=headers, json=message)
if response.status_code in [200, 201]:
draft_data = response.json()
draft_id = draft_data.get("id", "Unknown")
logger.info(f"✅ Email draft created successfully!")
logger.info(f"📧 Draft ID: {draft_id}")
logger.info(f"📁 Stored in: Drafts folder (Entwürfe)")
logger.info(f"📬 Mailbox: {connection.get('userEmail', 'Unknown')}")
logger.info(f"🔗 Draft URL: {api_url}")
# Return success with draft information
# Create document reference in standard format
document_reference = f"docItem:{uuid.uuid4()}:email_draft_created_{datetime.now(UTC).strftime('%Y%m%d_%H%M%S')}.json"
return ActionResult(
success=True,
documents=[{
"documentName": f"email_draft_created_{datetime.now(UTC).strftime('%Y%m%d_%H%M%S')}.json",
"documentData": {
"status": "success",
"message": "Email draft created successfully",
"draftId": draft_id,
"folder": "Drafts (Entwürfe)",
"mailbox": connection.get('userEmail', 'Unknown'),
"subject": subject,
"recipients": to,
"timestamp": datetime.now(UTC).isoformat()
},
"mimeType": "application/json"
}],
resultLabel="email_draft_created"
)
else:
logger.error(f"Failed to create draft. Status: {response.status_code}, Response: {response.text}")
return ActionResult.failure(error=f"Failed to create email draft: {response.status_code} - {response.text}")
except ImportError:
logger.error("requests module not available")
return ActionResult.failure(error="requests module not available")
except Exception as e:
logger.error(f"Error creating email draft via Microsoft Graph API: {str(e)}")
return ActionResult.failure(error=f"Failed to create email draft: {str(e)}")
return ActionResult(
success=True,
documents=[{
"documentName": f"outlook_email_draft_{datetime.now(UTC).strftime('%Y%m%d_%H%M%S')}.json",
"documentData": {
"connectionReference": connectionReference,
"composedEmailReference": composed_email_ref,
"extractedEmail": {
"to": to,
"subject": subject,
"body": body,
"cc": cc,
"bcc": bcc,
"attachments": attachments
},
"draftResult": draft_result,
"connection": {
"id": connection["id"],
"authority": "microsoft",
"reference": connectionReference
},
"timestamp": datetime.now(UTC).isoformat()
},
"mimeType": "application/json"
}],
resultLabel="outlook_email_draft"
)
except Exception as e:
logger.error(f"Error creating email draft: {str(e)}")
return ActionResult.failure(error=str(e))
@action
async def searchEmails(self, parameters: Dict[str, Any]) -> ActionResult:
"""
Search emails in Outlook
Parameters:
connectionReference (str): Reference to the Microsoft connection
query (str): Search query
folder (str, optional): Folder to search in (default: "All")
limit (int, optional): Maximum number of results (default: 20)
expectedDocumentFormats (list, optional): Expected document formats with extension, mimeType, description
"""
try:
connectionReference = parameters.get("connectionReference")
query = parameters.get("query")
folder = parameters.get("folder", "All")
limit = parameters.get("limit", 20)
expectedDocumentFormats = parameters.get("expectedDocumentFormats", [])
# Validate parameters
if not connectionReference:
return ActionResult.failure(error="Connection reference is required")
if not query or not query.strip():
return ActionResult.failure(error="Search query is required and cannot be empty")
# Validate limit
try:
limit = int(limit)
if limit <= 0 or limit > 1000: # Microsoft Graph API has limits
limit = 20
logger.warning(f"Limit {limit} is out of range, using default value 20")
except (ValueError, TypeError):
limit = 20
logger.warning(f"Invalid limit value, using default value 20")
# Get Microsoft connection
connection = self._getMicrosoftConnection(connectionReference)
if not connection:
return ActionResult.failure(error="No valid Microsoft connection found for the provided connection reference")
# Search emails using Microsoft Graph API
try:
import requests
# Microsoft Graph API endpoint for searching messages
graph_url = "https://graph.microsoft.com/v1.0"
headers = {
"Authorization": f"Bearer {connection['accessToken']}",
"Content-Type": "application/json"
}
# Build the search API request
api_url = f"{graph_url}/me/messages"
params = self._buildSearchParameters(query, folder, limit)
logger.info(f"Search API parameters: {params}")
# Make the API call
response = requests.get(api_url, headers=headers, params=params)
# Log response details for debugging
logger.debug(f"Microsoft Graph API response status: {response.status_code}")
logger.debug(f"Microsoft Graph API response headers: {dict(response.headers)}")
if response.status_code != 200:
# Log detailed error information
try:
error_data = response.json()
logger.error(f"Microsoft Graph API error: {response.status_code} - {error_data}")
except:
logger.error(f"Microsoft Graph API error: {response.status_code} - {response.text}")
# Check for specific error types and provide helpful messages
if response.status_code == 400:
logger.error("Bad Request (400) - Check search query format and parameters")
logger.error(f"Search query: '{query}'")
logger.error(f"Search parameters: {params}")
logger.error(f"API URL: {api_url}")
elif response.status_code == 401:
logger.error("Unauthorized (401) - Check access token and permissions")
elif response.status_code == 403:
logger.error("Forbidden (403) - Check API permissions and scopes")
elif response.status_code == 429:
logger.error("Too Many Requests (429) - Rate limit exceeded")
raise Exception(f"Microsoft Graph API returned {response.status_code}: {response.text}")
response.raise_for_status()
search_data = response.json()
emails = search_data.get("value", [])
logger.info(f"Successfully retrieved {len(emails)} emails from Microsoft Graph API")
# Apply folder filtering if needed and we used $search
if folder and folder.lower() != "all" and "$search" in params:
# Get the actual folder ID for proper filtering
folder_id = self._getFolderId(folder, connection)
if folder_id:
# Filter results by folder ID
filtered_emails = []
for email in emails:
if email.get("parentFolderId") == folder_id:
filtered_emails.append(email)
emails = filtered_emails
logger.info(f"Filtered {len(filtered_emails)} emails for folder '{folder}' (ID: {folder_id}) from {len(search_data.get('value', []))} total results")
else:
# Fallback: try to filter by folder name (less reliable)
filtered_emails = []
for email in emails:
# Check if email has folder information
if hasattr(email, 'parentFolderId') and email.get('parentFolderId'):
if email.get('parentFolderId') == folder:
filtered_emails.append(email)
else:
# If no folder info, include the email (less strict filtering)
filtered_emails.append(email)
logger.debug(f"Email {email.get('id', 'unknown')} has no folder info, including in results")
emails = filtered_emails
logger.info(f"Filtered {len(filtered_emails)} emails for folder '{folder}' (fallback filtering) from {len(search_data.get('value', []))} total results")
search_result = {
"query": query,
"results": emails,
"count": len(emails),
"folder": folder,
"limit": limit,
"apiResponse": search_data,
"searchParams": params
}
logger.info(f"Successfully searched emails with query '{query}', found {len(emails)} results")
except ImportError:
logger.error("requests module not available")
return ActionResult.failure(error="requests module not available")
except Exception as e:
logger.error(f"Error searching emails via Microsoft Graph API: {str(e)}")
return ActionResult.failure(error=f"Failed to search emails: {str(e)}")
# Determine output format based on expected formats
output_extension = ".json" # Default
output_mime_type = "application/json" # Default
if expectedDocumentFormats and len(expectedDocumentFormats) > 0:
# Use the first expected format
expected_format = expectedDocumentFormats[0]
output_extension = expected_format.get("extension", ".json")
output_mime_type = expected_format.get("mimeType", "application/json")
logger.info(f"Using expected format: {output_extension} ({output_mime_type})")
else:
logger.info("No expected format specified, using default .json format")
return ActionResult(
success=True,
documents=[{
"documentName": f"outlook_email_search_{datetime.now(UTC).strftime('%Y%m%d_%H%M%S')}.json",
"documentData": {
"connectionReference": connectionReference,
"query": query,
"folder": folder,
"limit": limit,
"searchResults": search_result,
"connection": {
"id": connection["id"],
"authority": "microsoft",
"reference": connectionReference
},
"timestamp": datetime.now(UTC).isoformat()
},
"mimeType": "application/json"
}],
resultLabel="outlook_email_search"
)
except Exception as e:
logger.error(f"Error searching emails: {str(e)}")
return ActionResult.failure(error=str(e))
async def listDrafts(self, parameters: Dict[str, Any]) -> ActionResult:
"""
List email drafts in Outlook
Parameters:
connectionReference (str): Reference to the Microsoft connection
folder (str, optional): Folder to search for drafts (default: "Drafts")
limit (int, optional): Maximum number of drafts to list (default: 20)
expectedDocumentFormats (list, optional): Expected document formats with extension, mimeType, description
"""
try:
connectionReference = parameters.get("connectionReference")
folder = parameters.get("folder", "Drafts")
limit = parameters.get("limit", 20)
expectedDocumentFormats = parameters.get("expectedDocumentFormats", [])
if not connectionReference:
return ActionResult.failure(error="Connection reference is required")
# Get Microsoft connection
connection = self._getMicrosoftConnection(connectionReference)
if not connection:
return ActionResult.failure(error="No valid Microsoft connection found for the provided connection reference")
# List drafts using Microsoft Graph API
try:
import requests
# Microsoft Graph API endpoint for messages
graph_url = "https://graph.microsoft.com/v1.0"
headers = {
"Authorization": f"Bearer {connection['accessToken']}",
"Content-Type": "application/json"
}
# Get the folder ID for the specified folder
folder_id = self._getFolderId(folder, connection)
if folder_id:
# List messages in the specific folder
api_url = f"{graph_url}/me/mailFolders/{folder_id}/messages"
logger.info(f"Listing messages in folder '{folder}' (ID: {folder_id})")
else:
# Fallback: list all messages (might include drafts)
api_url = f"{graph_url}/me/messages"
logger.warning(f"Could not find folder '{folder}', listing all messages")
params = {
"$top": limit,
"$orderby": "lastModifiedDateTime desc",
"$select": "id,subject,from,toRecipients,ccRecipients,bccRecipients,receivedDateTime,lastModifiedDateTime,parentFolderId,isDraft"
}
# Make the API call
response = requests.get(api_url, headers=headers, params=params)
response.raise_for_status()
messages_data = response.json()
messages = messages_data.get("value", [])
# Filter for drafts if we're looking at all messages
if not folder_id:
drafts = [msg for msg in messages if msg.get("isDraft", False)]
messages = drafts
logger.info(f"Filtered {len(drafts)} drafts from {len(messages_data.get('value', []))} total messages")
drafts_result = {
"folder": folder,
"folderId": folder_id,
"drafts": messages,
"count": len(messages),
"limit": limit,
"apiResponse": messages_data
}
logger.info(f"Successfully retrieved {len(messages)} drafts from folder '{folder}'")
except ImportError:
logger.error("requests module not available")
return ActionResult.failure(error="requests module not available")
except Exception as e:
logger.error(f"Error listing drafts via Microsoft Graph API: {str(e)}")
return ActionResult.failure(error=f"Failed to list drafts: {str(e)}")
# Determine output format based on expected formats
output_extension = ".json" # Default
output_mime_type = "application/json" # Default
if expectedDocumentFormats and len(expectedDocumentFormats) > 0:
# Use the first expected format
expected_format = expectedDocumentFormats[0]
output_extension = expected_format.get("extension", ".json")
output_mime_type = expected_format.get("mimeType", "application/json")
logger.info(f"Using expected format: {output_extension} ({output_mime_type})")
else:
logger.info("No expected format specified, using default .json format")
return ActionResult(
success=True,
documents=[{
"documentName": f"outlook_drafts_list_{datetime.now(UTC).strftime('%Y%m%d_%H%M%S')}.json",
"documentData": {
"connectionReference": connectionReference,
"folder": folder,
"limit": limit,
"draftsResult": drafts_result,
"connection": {
"id": connection["id"],
"authority": "microsoft",
"reference": connectionReference
},
"timestamp": datetime.now(UTC).isoformat()
},
"mimeType": "application/json"
}],
resultLabel="outlook_drafts_list"
)
except Exception as e:
logger.error(f"Error listing drafts: {str(e)}")
return ActionResult.failure(error=str(e))
async def findDrafts(self, parameters: Dict[str, Any]) -> ActionResult:
"""
Find email drafts across all folders in Outlook
Parameters:
connectionReference (str): Reference to the Microsoft connection
limit (int, optional): Maximum number of drafts to find (default: 50)
expectedDocumentFormats (list, optional): Expected document formats with extension, mimeType, description
"""
try:
connectionReference = parameters.get("connectionReference")
limit = parameters.get("limit", 50)
expectedDocumentFormats = parameters.get("expectedDocumentFormats", [])
if not connectionReference:
return ActionResult.failure(error="Connection reference is required")
# Get Microsoft connection
connection = self._getMicrosoftConnection(connectionReference)
if not connection:
return ActionResult.failure(error="No valid Microsoft connection found for the provided connection reference")
# Find drafts using Microsoft Graph API
try:
import requests
# Microsoft Graph API endpoint for messages
graph_url = "https://graph.microsoft.com/v1.0"
headers = {
"Authorization": f"Bearer {connection['accessToken']}",
"Content-Type": "application/json"
}
# Get all messages and filter for drafts
api_url = f"{graph_url}/me/messages"
params = {
"$top": limit,
"$select": "id,subject,from,toRecipients,ccRecipients,bccRecipients,receivedDateTime,lastModifiedDateTime,parentFolderId,isDraft,webLink",
"$filter": "isDraft eq true"
}
logger.info(f"Searching for drafts across all folders")
# Make the API call
response = requests.get(api_url, headers=headers, params=params)
response.raise_for_status()
messages_data = response.json()
drafts = messages_data.get("value", [])
# Get folder information for each draft
for draft in drafts:
if "parentFolderId" in draft:
folder_info = self._getFolderNameById(draft["parentFolderId"], connection)
draft["folderName"] = folder_info
drafts_result = {
"totalDrafts": len(drafts),
"drafts": drafts,
"limit": limit,
"apiResponse": messages_data
}
logger.info(f"Successfully found {len(drafts)} drafts across all folders")
except ImportError:
logger.error("requests module not available")
return ActionResult.failure(error="requests module not available")
except Exception as e:
logger.error(f"Error finding drafts via Microsoft Graph API: {str(e)}")
return ActionResult.failure(error=f"Failed to find drafts: {str(e)}")
# Determine output format based on expected formats
output_extension = ".json" # Default
output_mime_type = "application/json" # Default
if expectedDocumentFormats and len(expectedDocumentFormats) > 0:
# Use the first expected format
expected_format = expectedDocumentFormats[0]
output_extension = expected_format.get("extension", ".json")
output_mime_type = expected_format.get("mimeType", "application/json")
logger.info(f"Using expected format: {output_extension} ({output_mime_type})")
else:
logger.info("No expected format specified, using default .json format")
return ActionResult(
success=True,
documents=[{
"documentName": f"outlook_drafts_found_{datetime.now(UTC).strftime('%Y%m%d_%H%M%S')}.json",
"documentData": {
"connectionReference": connectionReference,
"limit": limit,
"draftsResult": drafts_result,
"connection": {
"id": connection["id"],
"authority": "microsoft",
"reference": connectionReference
},
"timestamp": datetime.now(UTC).isoformat()
},
"mimeType": "application/json"
}],
resultLabel="outlook_drafts_found"
)
except Exception as e:
logger.error(f"Error finding drafts: {str(e)}")
return ActionResult.failure(error=str(e))
def _getFolderNameById(self, folder_id: str, connection: Dict[str, Any]) -> str:
"""
Get folder name by folder ID
This is a helper method to identify which folder a draft is in
"""
try:
import requests
graph_url = "https://graph.microsoft.com/v1.0"
headers = {
"Authorization": f"Bearer {connection['accessToken']}",
"Content-Type": "application/json"
}
# Get folder information
api_url = f"{graph_url}/me/mailFolders/{folder_id}"
response = requests.get(api_url, headers=headers)
if response.status_code == 200:
folder_data = response.json()
return folder_data.get("displayName", f"Unknown Folder ({folder_id})")
else:
return f"Unknown Folder ({folder_id})"
except Exception as e:
logger.warning(f"Error getting folder name for ID '{folder_id}': {str(e)}")
return f"Unknown Folder ({folder_id})"
async def checkDraftsFolder(self, parameters: Dict[str, Any]) -> ActionResult:
"""
Check the contents of the Drafts folder directly
Parameters:
connectionReference (str): Reference to the Microsoft connection
limit (int, optional): Maximum number of drafts to check (default: 20)
expectedDocumentFormats (list, optional): Expected document formats with extension, mimeType, description
"""
try:
connectionReference = parameters.get("connectionReference")
limit = parameters.get("limit", 20)
expectedDocumentFormats = parameters.get("expectedDocumentFormats", [])
if not connectionReference:
return ActionResult.failure(error="Connection reference is required")
# Get Microsoft connection
connection = self._getMicrosoftConnection(connectionReference)
if not connection:
return ActionResult.failure(error="No valid Microsoft connection found for the provided connection reference")
# Check Drafts folder directly
try:
import requests
# Microsoft Graph API endpoint for messages
graph_url = "https://graph.microsoft.com/v1.0"
headers = {
"Authorization": f"Bearer {connection['accessToken']}",
"Content-Type": "application/json"
}
# Get the Drafts folder ID
drafts_folder_id = self._getFolderId("Drafts", connection)
if not drafts_folder_id:
return ActionResult.failure(error="Could not find Drafts folder")
# Get messages directly from Drafts folder
api_url = f"{graph_url}/me/mailFolders/{drafts_folder_id}/messages"
params = {
"$top": limit,
"$select": "id,subject,from,toRecipients,ccRecipients,bccRecipients,receivedDateTime,lastModifiedDateTime,isDraft,webLink",
"$orderby": "lastModifiedDateTime desc"
}
logger.info(f"Checking Drafts folder directly: {api_url}")
# Make the API call
response = requests.get(api_url, headers=headers, params=params)
response.raise_for_status()
messages_data = response.json()
drafts = messages_data.get("value", [])
# Log detailed information about each draft
for i, draft in enumerate(drafts):
logger.info(f"Draft {i+1}: ID={draft.get('id')}, Subject='{draft.get('subject')}', Modified={draft.get('lastModifiedDateTime')}")
drafts_result = {
"draftsFolderId": drafts_folder_id,
"totalDrafts": len(drafts),
"drafts": drafts,
"limit": limit,
"apiResponse": messages_data,
"apiUrl": api_url
}
logger.info(f"Successfully checked Drafts folder: found {len(drafts)} drafts")
except ImportError:
logger.error("requests module not available")
return ActionResult.failure(error="requests module not available")
except Exception as e:
logger.error(f"Error checking Drafts folder via Microsoft Graph API: {str(e)}")
return ActionResult.failure(error=f"Failed to check Drafts folder: {str(e)}")
# Determine output format based on expected formats
output_extension = ".json" # Default
output_mime_type = "application/json" # Default
if expectedDocumentFormats and len(expectedDocumentFormats) > 0:
# Use the first expected format
expected_format = expectedDocumentFormats[0]
output_extension = expected_format.get("extension", ".json")
output_mime_type = expected_format.get("mimeType", "application/json")
logger.info(f"Using expected format: {output_extension} ({output_mime_type})")
else:
logger.info("No expected format specified, using default .json format")
return ActionResult(
success=True,
documents=[{
"documentName": f"outlook_drafts_folder_check_{datetime.now(UTC).strftime('%Y%m%d_%H%M%S')}.json",
"documentData": {
"connectionReference": connectionReference,
"limit": limit,
"draftsResult": drafts_result,
"connection": {
"id": connection["id"],
"authority": "microsoft",
"reference": connectionReference
},
"timestamp": datetime.now(UTC).isoformat()
},
"mimeType": "application/json"
}],
resultLabel="outlook_drafts_folder_check"
)
except Exception as e:
logger.error(f"Error checking Drafts folder: {str(e)}")
return ActionResult.failure(error=str(e))
@action
async def composeEmail(self, parameters: Dict[str, Any]) -> ActionResult:
"""
Compose email content using AI based on context and requirements
This action uses AI to generate professional email content including:
- Subject line
- Body content
- Recipient suggestions
- Attachment recommendations
Parameters:
context (str): Email context/requirements
recipient (str, optional): Recipient information
attachments (List[str], optional): Available documents to reference
tone (str, optional): Email tone (formal, casual, etc.)
documentList (List[str], optional): List of document references to include in context for AI composition (NOT as attachments)
attachmentDocumentList (List[str], optional): List of document references to include as email attachments (separate from composition context)
expectedDocumentFormats (list, optional): Expected output formats
Output Contract:
The action produces a JSON document with the following structure:
{
"context": "original context",
"recipient": "recipient info",
"tone": "email tone",
"timestamp": "ISO timestamp",
"usage": "usage description",
"to": ["email@example.com"],
"subject": "Email subject",
"body": "Email body content with document content integrated",
"cc": [],
"bcc": [],
"attachments": ["docItem:uuid:filename.pdf", "docItem:uuid:filename.html"]
}
Note: Email fields (to, subject, body, cc, bcc, attachments) are at root level
for direct consumption by sendEmail action. All documents from documentList are included as attachments.
"""
try:
context = parameters.get("context")
recipient = parameters.get("recipient", "")
attachments = parameters.get("attachments", [])
tone = parameters.get("tone", "professional")
documentList = parameters.get("documentList", [])
attachmentDocumentList = parameters.get("attachmentDocumentList", [])
expectedDocumentFormats = parameters.get("expectedDocumentFormats", [])
if not context:
return ActionResult.failure(error="Context is required for email composition")
# Process input documents to extract content for AI context (NOT as attachments)
document_content_summary = ""
composition_documents = []
if documentList:
logger.info(f"Processing {len(documentList)} input documents for email composition context")
try:
# Get document content from service center
docs = self.service.getChatDocumentsFromDocumentList(documentList)
if docs:
for doc in docs:
composition_documents.append(doc)
# Extract content for AI context using proper document service
try:
if hasattr(doc, 'fileId') and doc.fileId:
# Use the document content extraction service instead of raw file reading
try:
extracted_content = await self.service.extractContentFromDocument(
prompt="Extract readable text content for email composition",
document=doc
)
if extracted_content and extracted_content.contents:
# Get the first content item's data
content_text = ""
for content_item in extracted_content.contents:
if hasattr(content_item, 'data') and content_item.data:
content_text += content_item.data + " "
if content_text.strip():
# Truncate content for AI context (avoid token limits)
content_preview = content_text[:1000] + "..." if len(content_text) > 1000 else content_text
document_content_summary += f"\nDocument: {doc.filename}\nContent Preview: {content_preview}\n"
logger.info(f"Extracted content preview from {doc.filename}: {len(content_text)} characters")
else:
logger.info(f"No readable text content extracted from {doc.filename} (binary file)")
else:
logger.info(f"No content extracted from {doc.filename} (binary file)")
except Exception as extract_error:
# If content extraction fails, log info instead of warning (this is normal for binary files)
logger.info(f"Could not extract text content from {doc.filename}: {str(extract_error)} (this is normal for binary files like PDFs)")
else:
logger.warning(f"Document {doc.filename} has no fileId")
except Exception as e:
logger.warning(f"Error processing document {doc.filename}: {str(e)}")
else:
logger.warning("No documents found from documentList")
except Exception as e:
logger.error(f"Error processing documentList: {str(e)}")
# Process attachment documents separately (these will be actual email attachments)
all_attachments = []
if attachmentDocumentList:
logger.info(f"Processing {len(attachmentDocumentList)} documents as email attachments")
try:
# Get attachment documents from service center
attachment_docs = self.service.getChatDocumentsFromDocumentList(attachmentDocumentList)
if attachment_docs:
for doc in attachment_docs:
# Add to attachments list
all_attachments.append(f"docItem:{doc.id}:{doc.filename}")
logger.info(f"Added attachment: {doc.filename}")
else:
logger.warning("No attachment documents found from attachmentDocumentList")
except Exception as e:
logger.error(f"Error processing attachmentDocumentList: {str(e)}")
# Add any explicit attachments to the list
if attachments:
all_attachments.extend(attachments)
logger.info(f"Added {len(attachments)} explicit attachments to email")
# Remove duplicates while preserving order
seen = set()
unique_attachments = []
for att in all_attachments:
if att not in seen:
seen.add(att)
unique_attachments.append(att)
logger.info(f"Total unique attachments for email: {len(unique_attachments)}")
logger.info(f"Documents used for composition context: {len(composition_documents)}")
if document_content_summary:
logger.info(f"Document content summary length: {len(document_content_summary)} characters")
logger.info(f"Document content preview: {document_content_summary[:200]}...")
# Build AI prompt for email composition
ai_prompt = f"""
Compose a professional email based on the following requirements:
Context: {context}
Recipient: {recipient if recipient else 'Not specified'}
Tone: {tone}
{f"Document Content Summary (for email composition context):{document_content_summary}" if document_content_summary else "No documents provided for composition context"}
Email Attachments: {unique_attachments if unique_attachments else 'None'}
Instructions:
1. Use the document content summary to create a meaningful email body that references the actual content
2. Include specific details from the documents in the email body
3. Make the email relevant to the documents being shared
4. Ensure the email body integrates information from the documents naturally
5. The attachments list contains documents that will be physically attached to the email
Please provide a JSON response with the following EXACT structure:
{{
"to": ["recipient@email.com"],
"subject": "Email Subject",
"body": "Email body content with proper greeting, document content integration, and closing",
"cc": [],
"bcc": [],
"attachments": {unique_attachments}
}}
IMPORTANT: Return ONLY the JSON object above, no additional text or formatting.
The response must be valid JSON that can be parsed directly.
"""
# Call AI to compose the email
try:
composed_email = await self.service.interfaceAiCalls.callAiTextAdvanced(ai_prompt)
# Parse the AI response to ensure it's valid JSON
try:
import json
# Clean the response and parse as JSON
cleaned_response = composed_email.strip()
if cleaned_response.startswith('```json'):
cleaned_response = cleaned_response[7:]
if cleaned_response.endswith('```'):
cleaned_response = cleaned_response[:-3]
cleaned_response = cleaned_response.strip()
# Parse to validate JSON structure
email_data = json.loads(cleaned_response)
# Validate required fields
required_fields = ["to", "subject", "body"]
missing_fields = [field for field in required_fields if field not in email_data]
if missing_fields:
raise ValueError(f"Missing required fields: {missing_fields}")
logger.info("AI response successfully parsed and validated")
except json.JSONDecodeError as e:
logger.error(f"AI response is not valid JSON: {str(e)}")
return ActionResult.failure(error=f"AI response is not valid JSON: {str(e)}")
except ValueError as e:
logger.error(f"AI response missing required fields: {str(e)}")
return ActionResult.failure(error=f"AI response missing required fields: {str(e)}")
# Create result data - output the email data directly, not wrapped
result_data = {
"context": context,
"recipient": recipient,
"tone": tone,
"timestamp": datetime.now(UTC).isoformat(),
"usage": "This document contains a composed email that can be used with the sendEmail action",
"compositionDocuments": len(composition_documents),
"attachmentDocuments": len(unique_attachments),
# Include the email data directly at root level for sendEmail to consume
**email_data # This spreads the email fields (to, subject, body, cc, bcc, attachments) directly
}
# Ensure attachments are properly set from our processed list
if unique_attachments:
result_data["attachments"] = unique_attachments
logger.info(f"Final email attachments: {unique_attachments}")
logger.info(f"Email composition completed: {len(composition_documents)} documents used for context, {len(unique_attachments)} documents as attachments")
# Determine output format - ALWAYS use JSON for email composition
# This action must produce JSON for sendEmail to parse correctly
output_extension = ".json"
output_mime_type = "application/json"
# Ignore any expectedDocumentFormats - this action has a fixed output format
if expectedDocumentFormats and len(expectedDocumentFormats) > 0:
logger.info(f"Ignoring expected format '{expectedDocumentFormats[0].get('extension', 'unknown')}' - composeEmail always produces JSON")
logger.info(f"composeEmail action always produces: {output_extension} ({output_mime_type})")
return ActionResult(
success=True,
documents=[{
"documentName": f"composed_email_{datetime.now(UTC).strftime('%Y%m%d_%H%M%S')}.json",
"documentData": result_data,
"mimeType": "application/json"
}],
resultLabel="composed_email"
)
except Exception as e:
logger.error(f"Error calling AI for email composition: {str(e)}")
return ActionResult.failure(error=f"Failed to compose email: {str(e)}")
except Exception as e:
logger.error(f"Error composing email: {str(e)}")
return ActionResult.failure(error=str(e))
async def checkPermissions(self, parameters: Dict[str, Any]) -> ActionResult:
"""
Check if the current Microsoft connection has the necessary permissions for Outlook operations.
Parameters:
connectionReference (str): Reference to the Microsoft connection to check
"""
try:
connectionReference = parameters.get("connectionReference")
if not connectionReference:
return ActionResult.failure(error="Connection reference is required")
# Get Microsoft connection
connection = self._getMicrosoftConnection(connectionReference)
if not connection:
return ActionResult.failure(error="Failed to get Microsoft connection")
# Check permissions
permissions_ok = await self._checkPermissions(connection)
if permissions_ok:
return ActionResult(
success=True,
documents=[{
"documentName": f"outlook_permissions_check_{datetime.now(UTC).strftime('%Y%m%d_%H%M%S')}.json",
"documentData": {
"permissions": "✅ All necessary permissions are available",
"scopes": connection.get("scopes", []),
"connectionId": connection.get("id"),
"status": "ready"
},
"mimeType": "application/json"
}],
resultLabel="permissions_ready"
)
else:
return ActionResult(
success=False,
documents=[{
"documentName": f"outlook_permissions_check_{datetime.now(UTC).strftime('%Y%m%d_%H%M%S')}.json",
"documentData": {
"permissions": "❌ Missing necessary permissions",
"requiredScopes": ["Mail.ReadWrite", "Mail.Send", "Mail.ReadWrite.Shared", "User.Read"],
"currentScopes": connection.get("scopes", []),
"connectionId": connection.get("id"),
"status": "needs_reauthentication",
"message": "Please re-authenticate your Microsoft connection to get updated permissions."
},
"mimeType": "application/json"
}],
error="Connection lacks necessary permissions for Outlook operations",
resultLabel="permissions_missing"
)
except Exception as e:
logger.error(f"Error checking permissions: {str(e)}")
return ActionResult.failure(error=str(e))