1454 lines
68 KiB
Python
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))
|
|
|