gateway/modules/workflows/methods/methodOutlook.py
2025-10-20 12:22:01 +02:00

1454 lines
68 KiB
Python

"""
Microsoft Outlook Email Operations Module
"""
import base64
import re
import logging
from typing import Dict, Any, List, Optional
from datetime import datetime, UTC
import json
import requests
from modules.workflows.methods.methodBase import MethodBase, action
from modules.datamodels.datamodelChat import ActionResult, ActionDocument
from modules.datamodels.datamodelAi import AiCallOptions
logger = logging.getLogger(__name__)
class MethodOutlook(MethodBase):
"""Outlook method implementation for email operations"""
def __init__(self, services):
"""Initialize the Outlook method"""
super().__init__(services)
self.name = "outlook"
self.description = "Handle Microsoft Outlook email operations"
def _format_timestamp_for_filename(self) -> str:
"""Format current timestamp as YYYYMMDD-hhmmss for filenames."""
return datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
def _getMicrosoftConnection(self, connectionReference: str) -> Optional[Dict[str, Any]]:
"""
Helper function to get Microsoft connection details.
"""
try:
logger.debug(f"Getting Microsoft connection for reference: {connectionReference}")
# Get the connection from the service
userConnection = self.services.workflow.getUserConnectionFromConnectionReference(connectionReference)
if not userConnection:
logger.error(f"Connection not found: {connectionReference}")
return None
logger.debug(f"Found connection: {userConnection.id}, status: {userConnection.status.value}, authority: {userConnection.authority.value}")
# Get a fresh token for this connection
token = self.services.workflow.getFreshConnectionToken(userConnection.id)
if not token:
logger.error(f"Fresh token not found for connection: {userConnection.id}")
logger.debug(f"Connection details: {userConnection}")
return None
logger.debug(f"Fresh token retrieved for connection {userConnection.id}")
# 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
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:
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:
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()
# Handle folder specifications first
if clean_query.lower().startswith('folder:'):
folder_name = clean_query[7:].strip()
if folder_name:
# Return the folder specification as-is
return clean_query
# Remove any double quotes that might cause issues
clean_query = clean_query.replace('"', '')
# Handle common search operators
# Recognize Graph operators including both singular and plural forms for hasAttachments
lowered = clean_query.lower()
if any(op in lowered for op in ['from:', 'to:', 'subject:', 'received:', 'hasattachment:', 'hasattachments:']):
# 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
# 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":
# Use folder name directly for well-known folders, or get folder ID
if folder.lower() in ["inbox", "drafts", "sentitems", "deleteditems"]:
params["$filter"] = f"parentFolderId eq '{folder}'"
else:
# For custom folders, we need to get the folder ID first
# This will be handled by the calling method
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 folder specification (e.g., "folder:Drafts", "folder:Inbox")
if clean_query.lower().startswith('folder:'):
folder_name = clean_query[7:].strip() # Remove "folder:" prefix
if folder_name:
# This is a folder specification, not a text search
# Just filter by folder and return
params["$filter"] = f"parentFolderId eq '{folder_name}'"
params["$orderby"] = "receivedDateTime desc"
return params
# Check if this is a complex search query with multiple operators
# Recognize Graph operators including both singular and plural forms for hasAttachments
lowered = clean_query.lower()
if any(op in lowered for op in ['from:', 'to:', 'subject:', 'received:', 'hasattachment:', 'hasattachments:']):
# This is an advanced search query, use $search
# Microsoft Graph API supports complex search syntax
params["$search"] = f'"{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
# Folder filtering will be done after the API call
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]
# Use only subject search to keep filter simple
# Handle wildcard queries specially
if clean_query == "*" or clean_query == "":
# For wildcard or empty query, don't use contains filter
# Just use folder filter if specified
if folder and folder.lower() != "all":
params["$filter"] = f"parentFolderId eq '{folder}'"
else:
# No filter needed for wildcard search across all folders
pass
else:
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"
return params
def _buildGraphFilter(self, filter_text: str) -> Dict[str, str]:
"""
Build proper Microsoft Graph API filter parameters based on filter text
Args:
filter_text (str): The filter text to process
Returns:
Dict[str, str]: Dictionary with either $filter or $search parameter
"""
if not filter_text:
return {}
filter_text = filter_text.strip()
# Handle folder specifications (e.g., "folder:Drafts", "folder:Inbox")
if filter_text.lower().startswith('folder:'):
folder_name = filter_text[7:].strip() # Remove "folder:" prefix
if folder_name:
# This is a folder specification, return empty to let the main method handle it
return {}
# Handle search queries (from:, to:, subject:, etc.) - check this FIRST
# Support both singular and plural forms for hasAttachments
lt = filter_text.lower()
if any(lt.startswith(prefix) for prefix in ['from:', 'to:', 'subject:', 'received:', 'hasattachment:', 'hasattachments:']):
return {"$search": f'"{filter_text}"'}
# Handle email address filters (only if it's NOT a search query)
if '@' in filter_text and '.' in filter_text and ' ' not in filter_text and not filter_text.startswith('from:'):
return {"$filter": f"from/fromAddress/address eq '{filter_text}'"}
# Handle OData filter conditions (contains 'eq', 'ne', 'gt', 'lt', etc.)
if any(op in filter_text.lower() for op in [' eq ', ' ne ', ' gt ', ' lt ', ' ge ', ' le ', ' and ', ' or ']):
return {"$filter": filter_text}
# Handle text content - search in subject
return {"$filter": f"contains(subject,'{filter_text}')"}
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:
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", [])
# Try exact match first
for folder in all_folders:
if folder.get("displayName", "").lower() == folder_name.lower():
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):
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):
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:
"""
GENERAL:
- Purpose: Read emails and metadata from a mailbox folder.
- Input requirements: connectionReference (required); optional folder, limit, filter, outputMimeType.
- Output format: JSON with emails and metadata.
Parameters:
- connectionReference (str, required): Microsoft connection label.
- folder (str, optional): Folder to read from. Default: Inbox.
- limit (int, optional): Maximum items to return. Must be > 0. Default: 1000.
- filter (str, optional): Sender, query operators, or subject text.
- outputMimeType (str, optional): MIME type for output file. Options: "application/json" (default), "text/plain", "text/csv". Default: "application/json".
"""
try:
connectionReference = parameters.get("connectionReference")
folder = parameters.get("folder", "Inbox")
limit = parameters.get("limit", 10)
filter = parameters.get("filter")
outputMimeType = parameters.get("outputMimeType", "application/json")
if not connectionReference:
return ActionResult.isFailure(error="Connection reference is required")
# Validate limit parameter
if limit <= 0:
limit = 1000
logger.warning(f"Invalid limit value ({limit}), using default value 1000")
# Validate filter parameter if provided
if filter:
# Remove any potentially dangerous characters that could break the filter
filter = filter.strip()
if len(filter) > 100:
logger.warning(f"Filter too long ({len(filter)} chars), truncating to 100 characters")
filter = filter[:100]
# Get Microsoft connection
connection = self._getMicrosoftConnection(connectionReference)
if not connection:
return ActionResult.isFailure(error="No valid Microsoft connection found for the provided connection reference")
# Read emails using Microsoft Graph API
try:
# 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:
# Build the API request with folder ID
api_url = f"{graph_url}/me/mailFolders/{folder_id}/messages"
else:
# Fallback: use folder name directly (for well-known folders like "Inbox")
api_url = f"{graph_url}/me/mailFolders/{folder}/messages"
logger.warning(f"Could not find folder ID for '{folder}', using folder name directly")
params = {
"$top": limit,
"$orderby": "receivedDateTime desc"
}
if filter:
# Build proper Graph API filter parameters
filter_params = self._buildGraphFilter(filter)
params.update(filter_params)
# If using $search, remove $orderby as they can't be combined
if "$search" in params:
params.pop("$orderby", None)
# Filter applied
# Make the API call
response = requests.get(api_url, headers=headers, params=params)
if response.status_code != 200:
logger.error(f"Graph API error: {response.status_code} - {response.text}")
logger.error(f"Request URL: {response.url}")
logger.error(f"Request headers: {headers}")
logger.error(f"Request 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,
"apiMetadata": {
"@odata.context": emails_data.get("@odata.context"),
"@odata.count": emails_data.get("@odata.count"),
"@odata.nextLink": emails_data.get("@odata.nextLink")
}
}
except ImportError:
logger.error("requests module not available")
return ActionResult.isFailure(error="requests module not available")
except requests.exceptions.HTTPError as e:
if e.response.status_code == 400:
logger.error(f"Bad Request (400) - Invalid filter or parameter: {e.response.text}")
return ActionResult.isFailure(error=f"Invalid filter syntax. Please check your filter parameter. Error: {e.response.text}")
elif e.response.status_code == 401:
logger.error("Unauthorized (401) - Access token may be expired or invalid")
return ActionResult.isFailure(error="Authentication failed. Please check your connection and try again.")
elif e.response.status_code == 403:
logger.error("Forbidden (403) - Insufficient permissions to access emails")
return ActionResult.isFailure(error="Insufficient permissions to read emails from this folder.")
else:
logger.error(f"HTTP Error {e.response.status_code}: {e.response.text}")
return ActionResult.isFailure(error=f"HTTP Error {e.response.status_code}: {e.response.text}")
except Exception as e:
logger.error(f"Error reading emails from Microsoft Graph API: {str(e)}")
return ActionResult.isFailure(error=f"Failed to read emails: {str(e)}")
# Determine output format based on MIME type
mime_type_mapping = {
"application/json": ".json",
"text/plain": ".txt",
"text/csv": ".csv"
}
output_extension = mime_type_mapping.get(outputMimeType, ".json")
output_mime_type = outputMimeType
logger.info(f"Using output format: {output_extension} ({output_mime_type})")
# Create result data as JSON string
result_data = {
"connectionReference": connectionReference,
"folder": folder,
"limit": limit,
"filter": filter,
"emails": email_data,
"connection": {
"id": connection["id"],
"authority": "microsoft",
"reference": connectionReference
},
"timestamp": self.services.utils.timestampGetUtc()
}
return ActionResult.isSuccess(
documents=[ActionDocument(
documentName=f"outlook_emails_{self._format_timestamp_for_filename()}.json",
documentData=json.dumps(result_data, indent=2),
mimeType="application/json"
)]
)
except Exception as e:
logger.error(f"Error reading emails: {str(e)}")
return ActionResult.isFailure(
error=str(e)
)
@action
async def searchEmails(self, parameters: Dict[str, Any]) -> ActionResult:
"""
GENERAL:
- Purpose: Search emails by query and return matching items with metadata.
- Input requirements: connectionReference (required); query (required); optional folder, limit, outputMimeType.
- Output format: JSON with search results and metadata.
Parameters:
- connectionReference (str, required): Microsoft connection label.
- query (str, required): Search expression.
- folder (str, optional): Folder scope or All. Default: All.
- limit (int, optional): Maximum items to return. Must be > 0. Default: 1000.
- outputMimeType (str, optional): MIME type for output file. Options: "application/json" (default), "text/plain", "text/csv". Default: "application/json".
"""
try:
connectionReference = parameters.get("connectionReference")
query = parameters.get("query")
folder = parameters.get("folder", "All")
limit = parameters.get("limit", 1000)
outputMimeType = parameters.get("outputMimeType", "application/json")
# Validate parameters
if not connectionReference:
return ActionResult.isFailure(error="Connection reference is required")
# Validate limit parameter
if limit <= 0:
limit = 1000
logger.warning(f"Invalid limit value ({limit}), using default value 1000")
if not query or not query.strip():
return ActionResult.isFailure(error="Search query is required and cannot be empty")
# Check if this is a folder specification query
if query.strip().lower().startswith('folder:'):
folder_name = query.strip()[7:].strip() # Remove "folder:" prefix
if not folder_name:
return ActionResult.isFailure(error="Invalid folder specification. Use format 'folder:FolderName'")
logger.info(f"Search query is a folder specification: {folder_name}")
# Validate limit
try:
limit = int(limit)
if limit <= 0:
limit = 1000
logger.warning(f"Invalid limit value (<=0), using default value 1000")
elif limit > 1000: # Microsoft Graph API has limits
limit = 1000
logger.warning(f"Limit {limit} exceeds maximum (1000), using 1000")
except (ValueError, TypeError):
limit = 1000
logger.warning(f"Invalid limit value, using default value 1000")
# Get Microsoft connection
connection = self._getMicrosoftConnection(connectionReference)
if not connection:
return ActionResult.isFailure(error="No valid Microsoft connection found for the provided connection reference")
# Search emails using Microsoft Graph API
try:
# 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"
}
# Get the folder ID for the specified folder if needed
folder_id = None
if folder and folder.lower() != "all":
folder_id = self._getFolderId(folder, connection)
if folder_id:
logger.debug(f"Found folder ID for '{folder}': {folder_id}")
else:
logger.warning(f"Could not find folder ID for '{folder}', using folder name directly")
# Build the search API request
api_url = f"{graph_url}/me/messages"
params = self._buildSearchParameters(query, folder_id or folder, limit)
# Log search parameters for debugging
logger.debug(f"Search query: '{query}'")
logger.debug(f"Search folder: '{folder}'")
logger.debug(f"Search parameters: {params}")
logger.debug(f"API URL: {api_url}")
# Make the API call
response = requests.get(api_url, headers=headers, params=params)
# Log response details for debugging
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", [])
# 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.debug(f"Applied folder filtering: {len(filtered_emails)} emails found in folder {folder}")
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)
emails = filtered_emails
logger.debug(f"Applied fallback folder filtering: {len(filtered_emails)} emails found in folder {folder}")
# Special handling for folder specification queries
if query.strip().lower().startswith('folder:'):
folder_name = query.strip()[7:].strip()
folder_id = self._getFolderId(folder_name, connection)
if folder_id:
# Filter results to only include emails from the specified folder
filtered_emails = []
for email in emails:
if email.get("parentFolderId") == folder_id:
filtered_emails.append(email)
emails = filtered_emails
logger.debug(f"Applied folder specification filtering: {len(filtered_emails)} emails found in folder {folder_name}")
else:
logger.warning(f"Could not find folder ID for folder specification: {folder_name}")
search_result = {
"query": query,
"results": emails,
"count": len(emails),
"folder": folder,
"limit": limit,
"apiMetadata": {
"@odata.context": search_data.get("@odata.context"),
"@odata.count": search_data.get("@odata.count"),
"@odata.nextLink": search_data.get("@odata.nextLink")
},
"searchParams": params
}
except ImportError:
logger.error("requests module not available")
return ActionResult.isFailure(error="requests module not available")
except Exception as e:
logger.error(f"Error searching emails via Microsoft Graph API: {str(e)}")
return ActionResult.isFailure(error=f"Failed to search emails: {str(e)}")
# Determine output format based on MIME type
mime_type_mapping = {
"application/json": ".json",
"text/plain": ".txt",
"text/csv": ".csv"
}
output_extension = mime_type_mapping.get(outputMimeType, ".json")
output_mime_type = outputMimeType
logger.info(f"Using output format: {output_extension} ({output_mime_type})")
result_data = {
"connectionReference": connectionReference,
"query": query,
"folder": folder,
"limit": limit,
"searchResults": search_result,
"connection": {
"id": connection["id"],
"authority": "microsoft",
"reference": connectionReference
},
"timestamp": self.services.utils.timestampGetUtc()
}
return ActionResult(
success=True,
documents=[ActionDocument(
documentName=f"outlook_email_search_{self._format_timestamp_for_filename()}.json",
documentData=json.dumps(result_data, indent=2),
mimeType="application/json"
)]
)
except Exception as e:
logger.error(f"Error searching emails: {str(e)}")
return ActionResult.isFailure(error=str(e))
async def listDrafts(self, parameters: Dict[str, Any]) -> ActionResult:
"""
GENERAL:
- Purpose: List draft emails from a folder.
- Input requirements: connectionReference (required); optional folder, limit, outputMimeType.
- Output format: JSON with draft items and metadata.
Parameters:
- connectionReference (str, required): Microsoft connection label.
- folder (str, optional): Drafts folder to list. Default: Drafts.
- limit (int, optional): Maximum items to return. Must be > 0. Default: 1000.
- outputMimeType (str, optional): MIME type for output file. Options: "application/json" (default), "text/plain", "text/csv". Default: "application/json".
"""
try:
connectionReference = parameters.get("connectionReference")
folder = parameters.get("folder", "Drafts")
limit = parameters.get("limit", 1000)
outputMimeType = parameters.get("outputMimeType", "application/json")
if not connectionReference:
return ActionResult.isFailure(error="Connection reference is required")
# Get Microsoft connection
connection = self._getMicrosoftConnection(connectionReference)
if not connection:
return ActionResult.isFailure(error="No valid Microsoft connection found for the provided connection reference")
# List drafts using Microsoft Graph API
try:
# 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"
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
drafts_result = {
"folder": folder,
"folderId": folder_id,
"drafts": messages,
"count": len(messages),
"limit": limit,
"apiResponse": messages_data
}
except ImportError:
logger.error("requests module not available")
return ActionResult.isFailure(error="requests module not available")
except Exception as e:
logger.error(f"Error listing drafts via Microsoft Graph API: {str(e)}")
return ActionResult.isFailure(error=f"Failed to list drafts: {str(e)}")
# Determine output format based on MIME type
mime_type_mapping = {
"application/json": ".json",
"text/plain": ".txt",
"text/csv": ".csv"
}
output_extension = mime_type_mapping.get(outputMimeType, ".json")
output_mime_type = outputMimeType
logger.info(f"Using output format: {output_extension} ({output_mime_type})")
result_data = {
"connectionReference": connectionReference,
"folder": folder,
"limit": limit,
"draftsResult": drafts_result,
"connection": {
"id": connection["id"],
"authority": "microsoft",
"reference": connectionReference
},
"timestamp": self.services.utils.timestampGetUtc()
}
return ActionResult(
success=True,
documents=[ActionDocument(
documentName=f"outlook_drafts_list_{self._format_timestamp_for_filename()}.json",
documentData=json.dumps(result_data, indent=2),
mimeType="application/json"
)]
)
except Exception as e:
logger.error(f"Error listing drafts: {str(e)}")
return ActionResult.isFailure(error=str(e))
async def findDrafts(self, parameters: Dict[str, Any]) -> ActionResult:
"""
GENERAL:
- Purpose: Find draft emails across folders.
- Input requirements: connectionReference (required); optional limit, outputMimeType.
- Output format: JSON with drafts and metadata.
Parameters:
- connectionReference (str, required): Microsoft connection label.
- limit (int, optional): Maximum items to return. Default: 50.
- outputMimeType (str, optional): MIME type for output file. Options: "application/json" (default), "text/plain", "text/csv". Default: "application/json".
"""
try:
connectionReference = parameters.get("connectionReference")
limit = parameters.get("limit", 50)
outputMimeType = parameters.get("outputMimeType", "application/json")
if not connectionReference:
return ActionResult.isFailure(error="Connection reference is required")
# Get Microsoft connection
connection = self._getMicrosoftConnection(connectionReference)
if not connection:
return ActionResult.isFailure(error="No valid Microsoft connection found for the provided connection reference")
# Find drafts using Microsoft Graph API
try:
# 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"
}
# 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
}
except ImportError:
logger.error("requests module not available")
return ActionResult.isFailure(error="requests module not available")
except Exception as e:
logger.error(f"Error finding drafts via Microsoft Graph API: {str(e)}")
return ActionResult.isFailure(error=f"Failed to find drafts: {str(e)}")
# Determine output format based on MIME type
mime_type_mapping = {
"application/json": ".json",
"text/plain": ".txt",
"text/csv": ".csv"
}
output_extension = mime_type_mapping.get(outputMimeType, ".json")
output_mime_type = outputMimeType
logger.info(f"Using output format: {output_extension} ({output_mime_type})")
result_data = {
"connectionReference": connectionReference,
"limit": limit,
"draftsResult": drafts_result,
"connection": {
"id": connection["id"],
"authority": "microsoft",
"reference": connectionReference
},
"timestamp": self.services.utils.timestampGetUtc()
}
return ActionResult(
success=True,
documents=[ActionDocument(
documentName=f"outlook_drafts_found_{self._format_timestamp_for_filename()}.json",
documentData=json.dumps(result_data, indent=2),
mimeType="application/json"
)]
)
except Exception as e:
logger.error(f"Error finding drafts: {str(e)}")
return ActionResult.isFailure(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:
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:
"""
GENERAL:
- Purpose: Check contents of the Drafts folder.
- Input requirements: connectionReference (required); optional limit, outputMimeType.
- Output format: JSON with drafts and metadata.
Parameters:
- connectionReference (str, required): Microsoft connection label.
- limit (int, optional): Maximum items to return. Default: 20.
- outputMimeType (str, optional): MIME type for output file. Options: "application/json" (default), "text/plain", "text/csv". Default: "application/json".
"""
try:
connectionReference = parameters.get("connectionReference")
limit = parameters.get("limit", 20)
outputMimeType = parameters.get("outputMimeType", "application/json")
if not connectionReference:
return ActionResult.isFailure(error="Connection reference is required")
# Get Microsoft connection
connection = self._getMicrosoftConnection(connectionReference)
if not connection:
return ActionResult.isFailure(error="No valid Microsoft connection found for the provided connection reference")
# Check Drafts folder directly
try:
# 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.isFailure(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"
}
# 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", [])
drafts_result = {
"draftsFolderId": drafts_folder_id,
"totalDrafts": len(drafts),
"drafts": drafts,
"limit": limit,
"apiResponse": messages_data,
"apiUrl": api_url
}
except ImportError:
logger.error("requests module not available")
return ActionResult.isFailure(error="requests module not available")
except Exception as e:
logger.error(f"Error checking Drafts folder via Microsoft Graph API: {str(e)}")
return ActionResult.isFailure(error=f"Failed to check Drafts folder: {str(e)}")
# Determine output format based on MIME type
mime_type_mapping = {
"application/json": ".json",
"text/plain": ".txt",
"text/csv": ".csv"
}
output_extension = mime_type_mapping.get(outputMimeType, ".json")
output_mime_type = outputMimeType
logger.info(f"Using output format: {output_extension} ({output_mime_type})")
result_data = {
"connectionReference": connectionReference,
"limit": limit,
"draftsResult": drafts_result,
"connection": {
"id": connection["id"],
"authority": "microsoft",
"reference": connectionReference
},
"timestamp": self.services.utils.timestampGetUtc()
}
return ActionResult(
success=True,
documents=[ActionDocument(
documentName=f"outlook_drafts_folder_check_{self._format_timestamp_for_filename()}.json",
documentData=json.dumps(result_data, indent=2),
mimeType="application/json"
)]
)
except Exception as e:
logger.error(f"Error checking Drafts folder: {str(e)}")
return ActionResult.isFailure(error=str(e))
@action
async def composeAndSendEmailWithContext(self, parameters: Dict[str, Any]) -> ActionResult:
"""
GENERAL:
- Purpose: Compose email content using AI from context and optional documents, then create a draft/send.
- Input requirements: connectionReference (required); to (required); context (required); optional documentList, cc, bcc, emailStyle, maxLength.
- Output format: JSON confirmation with AI-generated draft/send metadata.
Parameters:
- connectionReference (str, required): Microsoft connection label.
- to (list, required): Recipient email addresses.
- context (str, required): Detailled context for composing the email.
- documentList (list, optional): Document references for context/attachments.
- cc (list, optional): CC recipients.
- bcc (list, optional): BCC recipients.
- emailStyle (str, optional): formal | casual | business. Default: business.
- maxLength (int, optional): Maximum length for generated content. Default: 1000.
"""
try:
connectionReference = parameters.get("connectionReference")
to = parameters.get("to")
context = parameters.get("context")
documentList = parameters.get("documentList", [])
cc = parameters.get("cc", [])
bcc = parameters.get("bcc", [])
emailStyle = parameters.get("emailStyle", "business")
maxLength = parameters.get("maxLength", 1000)
if not connectionReference or not to or not context:
return ActionResult.isFailure(error="connectionReference, to, and context are required")
# Convert single values to lists for all recipient parameters
if isinstance(to, str):
to = [to]
if isinstance(cc, str):
cc = [cc]
if isinstance(bcc, str):
bcc = [bcc]
if isinstance(documentList, str):
documentList = [documentList]
# Get Microsoft connection
connection = self._getMicrosoftConnection(connectionReference)
if not connection:
return ActionResult.isFailure(error="No valid Microsoft connection found")
# Check permissions
permissions_ok = await self._checkPermissions(connection)
if not permissions_ok:
return ActionResult.isFailure(error="Connection lacks necessary permissions for Outlook operations")
# Prepare documents for AI processing
chatDocuments = []
if documentList:
chatDocuments = self.services.workflow.getChatDocumentsFromDocumentList(documentList)
# Create AI prompt for email composition
# Build document reference list for AI with expanded list contents when possible
doc_references = documentList
doc_list_text = ""
if doc_references:
lines = ["Available_Document_References:"]
workflow_obj = getattr(self.services, 'currentWorkflow', None)
for ref in doc_references:
# Each item is a label: resolve to its document list and render contained items
list_docs = self.services.workflow.getChatDocumentsFromDocumentList([ref]) or []
if list_docs:
for d in list_docs:
doc_ref_label = self.services.workflow.getDocumentReferenceFromChatDocument(d)
lines.append(f"- {doc_ref_label}")
else:
lines.append(" - (no documents)")
doc_list_text = "\n" + "\n".join(lines)
else:
doc_list_text = "Available_Document_References: (No documents available for attachment)"
# Escape only the user-controlled context to prevent prompt injection
escaped_context = context.replace('"', '\\"').replace('\n', '\\n').replace('\r', '\\r')
ai_prompt = f"""Compose an email based on this context:
-------
{escaped_context}
-------
Recipients: {to}
Style: {emailStyle}
Max length: {maxLength} characters
{doc_list_text}
Based on the context, decide which documents to attach.
Return JSON:
{{
"subject": "subject line",
"body": "email body (HTML allowed)",
"attachments": ["doc_ref1", "doc_ref2"],
"continuation": null
}}
LOOP_INSTRUCTION"""
# Call AI service to generate email content
try:
ai_response = await self.services.ai.callAiDocuments(
prompt=ai_prompt,
documents=chatDocuments,
options=AiCallOptions(
operationType="email_composition",
priority="normal",
compressPrompt=False,
compressContext=True,
processDocumentsIndividually=False, # Process all documents together for email composition
processingMode="detailed",
resultFormat="json",
maxCost=0.50,
maxProcessingTime=30
)
)
# Parse AI response
try:
ai_content = ai_response
# Extract JSON from AI response
if "```json" in ai_content:
json_start = ai_content.find("```json") + 7
json_end = ai_content.find("```", json_start)
json_content = ai_content[json_start:json_end].strip()
elif "{" in ai_content and "}" in ai_content:
json_start = ai_content.find("{")
json_end = ai_content.rfind("}") + 1
json_content = ai_content[json_start:json_end]
else:
json_content = ai_content
email_data = json.loads(json_content)
subject = email_data.get("subject", "")
body = email_data.get("body", "")
ai_attachments = email_data.get("attachments", [])
if not subject or not body:
return ActionResult.isFailure(error="AI did not generate valid subject and body")
# Use AI-selected attachments if provided, otherwise use all documents
if documentList:
try:
available_refs = [documentList] if isinstance(documentList, str) else documentList
available_docs = self.services.workflow.getChatDocumentsFromDocumentList(available_refs) or []
except Exception:
available_docs = []
# Normalize AI attachments to a list of strings
if isinstance(ai_attachments, str):
ai_attachments = [ai_attachments]
elif isinstance(ai_attachments, list):
ai_attachments = [a for a in ai_attachments if isinstance(a, str)]
if ai_attachments:
try:
ai_refs = [ai_attachments] if isinstance(ai_attachments, str) else ai_attachments
ai_docs = self.services.workflow.getChatDocumentsFromDocumentList(ai_refs) or []
except Exception:
ai_docs = []
# Intersect by document id
available_ids = {getattr(d, 'id', None) for d in available_docs}
selected_docs = [d for d in ai_docs if getattr(d, 'id', None) in available_ids]
if selected_docs:
# Map selected ChatDocuments back to docItem references
documentList = [self.services.workflow.getDocumentReferenceFromChatDocument(d) for d in selected_docs]
logger.info(f"AI selected {len(documentList)} documents for attachment (resolved via ChatDocuments)")
else:
# No intersection; use all available documents
documentList = [self.services.workflow.getDocumentReferenceFromChatDocument(d) for d in available_docs]
logger.warning("AI selected attachments not found in available documents, using all documents")
else:
# No AI selection; use all available documents
documentList = [self.services.workflow.getDocumentReferenceFromChatDocument(d) for d in available_docs]
logger.warning("AI did not specify attachments, using all available documents")
else:
logger.info("No documents provided in documentList; skipping attachment processing")
except json.JSONDecodeError as e:
logger.error(f"Failed to parse AI response as JSON: {str(e)}")
logger.error(f"AI response content: {ai_response}")
return ActionResult.isFailure(error="AI response was not valid JSON format")
except Exception as e:
logger.error(f"Error calling AI service: {str(e)}")
return ActionResult.isFailure(error=f"Failed to generate email content: {str(e)}")
# Now create the email with AI-generated content
try:
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('<html>') or cleaned_body.startswith('<body>') or '<br>' in cleaned_body:
html_body = cleaned_body
else:
# Convert plain text to proper HTML formatting
html_body = cleaned_body.replace('\n', '<br>')
html_body = f"<html><body>{html_body}</body></html>"
# 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 documents as attachments if provided
if documentList:
message["attachments"] = []
for attachment_ref in documentList:
# Get attachment document from service center
attachment_docs = self.services.workflow.getChatDocumentsFromDocumentList([attachment_ref])
if attachment_docs:
for doc in attachment_docs:
file_id = getattr(doc, 'fileId', None)
if file_id:
try:
file_content = self.services.workflow.getFileData(file_id)
if file_content:
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')
attachment = {
"@odata.type": "#microsoft.graph.fileAttachment",
"name": doc.fileName,
"contentType": doc.mimeType or "application/octet-stream",
"contentBytes": base64_content
}
message["attachments"].append(attachment)
except Exception as e:
logger.error(f"Error reading attachment file {doc.fileName}: {str(e)}")
# Create the draft message
drafts_folder_id = self._getFolderId("Drafts", connection)
if drafts_folder_id:
api_url = f"{graph_url}/me/mailFolders/{drafts_folder_id}/messages"
else:
api_url = f"{graph_url}/me/messages"
logger.warning("Could not find Drafts folder, creating draft in default location")
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")
result_data = {
"status": "success",
"message": "Email draft created successfully with AI-generated content",
"draftId": draft_id,
"folder": "Drafts (Entwürfe)",
"mailbox": connection.get('userEmail', 'Unknown'),
"subject": subject,
"body": body,
"recipients": to,
"cc": cc,
"bcc": bcc,
"attachments": len(documentList),
"aiSelectedAttachments": ai_attachments if ai_attachments else "all documents",
"aiGenerated": True,
"context": context,
"emailStyle": emailStyle,
"timestamp": self.services.utils.timestampGetUtc()
}
return ActionResult(
success=True,
documents=[ActionDocument(
documentName=f"ai_generated_email_draft_{self._format_timestamp_for_filename()}.json",
documentData=json.dumps(result_data, indent=2),
mimeType="application/json"
)]
)
else:
logger.error(f"Failed to create draft. Status: {response.status_code}, Response: {response.text}")
return ActionResult.isFailure(error=f"Failed to create email draft: {response.status_code} - {response.text}")
except Exception as e:
logger.error(f"Error creating email via Microsoft Graph API: {str(e)}")
return ActionResult.isFailure(error=f"Failed to create email: {str(e)}")
except Exception as e:
logger.error(f"Error in composeAndSendEmailWithContext: {str(e)}")
return ActionResult.isFailure(error=str(e))
async def checkPermissions(self, parameters: Dict[str, Any]) -> ActionResult:
"""
GENERAL:
- Purpose: Verify that the connection has required permissions for Outlook operations.
- Input requirements: connectionReference (required).
- Output format: JSON with permission status and details.
Parameters:
- connectionReference (str, required): Microsoft connection label to check.
"""
try:
connectionReference = parameters.get("connectionReference")
if not connectionReference:
return ActionResult.isFailure(error="Connection reference is required")
# Get Microsoft connection
connection = self._getMicrosoftConnection(connectionReference)
if not connection:
return ActionResult.isFailure(error="Failed to get Microsoft connection")
# Check permissions
permissions_ok = await self._checkPermissions(connection)
if permissions_ok:
result_data = {
"permissions": "✅ All necessary permissions are available",
"scopes": connection.get("scopes", []),
"connectionId": connection.get("id"),
"status": "ready"
}
return ActionResult(
success=True,
documents=[ActionDocument(
documentName=f"outlook_permissions_check_{self._format_timestamp_for_filename()}.json",
documentData=json.dumps(result_data, indent=2),
mimeType="application/json"
)]
)
else:
result_data = {
"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."
}
return ActionResult(
success=False,
documents=[ActionDocument(
documentName=f"outlook_permissions_check_{self._format_timestamp_for_filename()}.json",
documentData=json.dumps(result_data, indent=2),
mimeType="application/json"
)],
error="Connection lacks necessary permissions for Outlook operations"
)
except Exception as e:
logger.error(f"Error checking permissions: {str(e)}")
return ActionResult.isFailure(error=str(e))