fixed mailing

This commit is contained in:
ValueOn AG 2025-08-17 00:19:55 +02:00
parent 8592cdd790
commit d642fa0f07
4 changed files with 508 additions and 34 deletions

View file

@ -593,6 +593,23 @@ class HandlingTasks:
else:
action.setError(result.error or "Action execution failed")
logger.error(f"✗ Action failed: {result.error}")
# ⚠️ IMPORTANT: Create error message for failed actions so user can see what went wrong
await self.createActionMessage(action, result, workflow, result_label, [], task_step, task_index)
# Create database log entry for action failure
if total_actions is not None:
self.chatInterface.createWorkflowLog({
"workflowId": workflow.id,
"message": f"❌ Task {task_num} - Action {action_num}/{total_actions} failed: {result.error}",
"type": "error"
})
else:
self.chatInterface.createWorkflowLog({
"workflowId": workflow.id,
"message": f"❌ Task {task_num} - Action {action_num}/? failed: {result.error}",
"type": "error"
})
# Extract document filenames for the ActionResult
document_filenames = []
@ -670,15 +687,20 @@ class HandlingTasks:
# Create a more meaningful message that includes task context
task_objective = task_step.objective if task_step else 'Unknown task'
# Build a user-friendly message
if created_documents and len(created_documents) > 0:
doc_names = [doc.filename if hasattr(doc, 'filename') else str(doc) for doc in created_documents[:3]]
if len(created_documents) > 3:
doc_names.append(f"... and {len(created_documents) - 3} more")
message_text = f"✅ Task {task_index or '?'} - Action {action.execMethod}.{action.execAction} completed\n\nObjective: {task_objective}\n\nGenerated {len(created_documents)} document(s): {', '.join(doc_names)}"
# Build a user-friendly message based on success/failure
if result.success:
if created_documents and len(created_documents) > 0:
doc_names = [doc.filename if hasattr(doc, 'filename') else str(doc) for doc in created_documents[:3]]
if len(created_documents) > 3:
doc_names.append(f"... and {len(created_documents) - 3} more")
message_text = f"✅ Task {task_index or '?'} - Action {action.execMethod}.{action.execAction} completed\n\nObjective: {task_objective}\n\nGenerated {len(created_documents)} document(s): {', '.join(doc_names)}"
else:
message_text = f"✅ Task {task_index or '?'} - Action {action.execMethod}.{action.execAction} completed\n\nObjective: {task_objective}\n\nAction executed successfully"
else:
message_text = f"✅ Task {task_index or '?'} - Action {action.execMethod}.{action.execAction} completed\n\nObjective: {task_objective}\n\nAction executed successfully"
# ⚠️ FAILURE MESSAGE - Show error details to user
error_details = result.error if result.error else "Unknown error occurred"
message_text = f"❌ Task {task_index or '?'} - Action {action.execMethod}.{action.execAction} failed\n\nObjective: {task_objective}\n\nError: {error_details}\n\nPlease check the connection and try again."
message_data = {
"workflowId": workflow.id,
@ -694,6 +716,11 @@ class HandlingTasks:
"documents": created_documents
}
# Add debugging for error messages
if not result.success:
logger.info(f"Creating ERROR message: {message_text}")
logger.info(f"Message data: {message_data}")
message = self.chatInterface.createWorkflowMessage(message_data)
if message:
workflow.messages.append(message)

View file

@ -441,24 +441,53 @@ class ServiceCenter:
return []
def getConnectionReferenceList(self) -> List[str]:
"""Get list of all UserConnection objects as references"""
"""Get list of all UserConnection objects as references with enhanced state information"""
connections = []
# Get user connections through AppObjects interface
user_connections = self.interfaceApp.getUserConnections(self.user.id)
for conn in user_connections:
connections.append(self.getConnectionReferenceFromUserConnection(conn))
# Get enhanced connection reference with state information
enhanced_ref = self.getConnectionReferenceFromUserConnection(conn)
connections.append(enhanced_ref)
# Sort by connection reference
return sorted(connections)
def getConnectionReferenceFromUserConnection(self, connection: UserConnection) -> str:
"""Get connection reference from UserConnection"""
return f"connection:{connection.authority.value}:{connection.externalUsername}:{connection.id}"
"""Get connection reference from UserConnection with enhanced state information"""
# Get token information to check if it's expired
token = None
token_status = "unknown"
try:
token = self.interfaceApp.getToken(connection.authority.value)
if token:
if hasattr(token, 'expiresAt') and token.expiresAt:
import time
current_time = time.time()
if current_time > token.expiresAt:
token_status = "expired"
else:
token_status = "valid"
else:
token_status = "no_expiration"
else:
token_status = "no_token"
except Exception as e:
token_status = f"error: {str(e)}"
# Build enhanced reference with state information
base_ref = f"connection:{connection.authority.value}:{connection.externalUsername}:{connection.id}"
state_info = f" [status:{connection.status.value}, token:{token_status}]"
return base_ref + state_info
def getUserConnectionFromConnectionReference(self, connectionReference: str) -> Optional[UserConnection]:
"""Get UserConnection from reference string"""
"""Get UserConnection from reference string (handles both old and enhanced formats)"""
try:
# Parse reference format: connection:{authority}:{username}:{id}
parts = connectionReference.split(':')
# Parse reference format: connection:{authority}:{username}:{id} [status:..., token:...]
# Remove state information if present
base_reference = connectionReference.split(' [')[0]
parts = base_reference.split(':')
if len(parts) != 4 or parts[0] != "connection":
return None

View file

@ -26,7 +26,17 @@ class MethodOutlook(MethodBase):
"""Get Microsoft connection from connection reference"""
try:
userConnection = self.service.getUserConnectionFromConnectionReference(connectionReference)
if not userConnection or userConnection.authority.value != "msft" or userConnection.status.value != "active":
if not userConnection:
logger.warning(f"No user connection found for reference: {connectionReference}")
return None
if userConnection.authority.value != "msft":
logger.warning(f"Connection {userConnection.id} is not Microsoft (authority: {userConnection.authority.value})")
return None
# Check if connection is active or pending (pending means OAuth in progress)
if userConnection.status.value not in ["active", "pending"]:
logger.warning(f"Connection {userConnection.id} status is not active/pending: {userConnection.status.value}")
return None
# Get the corresponding token for this user and authority
@ -35,6 +45,16 @@ class MethodOutlook(MethodBase):
logger.warning(f"No token found for user {userConnection.userId} and authority {userConnection.authority.value}")
return None
# Check if token is expired
if hasattr(token, 'expiresAt') and token.expiresAt:
import time
current_time = time.time()
if current_time > token.expiresAt:
logger.warning(f"Token for connection {userConnection.id} is expired (expiresAt: {token.expiresAt}, current: {current_time})")
return None
logger.info(f"Successfully retrieved Microsoft connection: {userConnection.id}, status: {userConnection.status.value}, externalId: {userConnection.externalId}")
return {
"id": userConnection.id,
"accessToken": token.tokenAccess,
@ -45,6 +65,118 @@ class MethodOutlook(MethodBase):
logger.error(f"Error getting Microsoft connection: {str(e)}")
return None
def _sanitizeSearchQuery(self, query: str) -> str:
"""
Sanitize and validate search query for Microsoft Graph API
Microsoft Graph API has specific requirements for search queries:
- Escape special characters properly
- Handle search operators correctly
- Ensure query format is valid
"""
if not query:
return ""
# Clean the query
clean_query = query.strip()
# Remove any double quotes that might cause issues
clean_query = clean_query.replace('"', '')
# Handle common search operators
if any(op in clean_query.lower() for op in ['from:', 'to:', 'subject:', 'received:', 'hasattachment:']):
# This is an advanced search query, return as-is
return clean_query
# For basic text search, ensure it's safe for contains() filter
# Remove any characters that might break the OData filter syntax
import re
# Remove or escape characters that could break OData filter syntax
safe_query = re.sub(r'[\\\'"]', '', clean_query)
return safe_query
def _buildSearchParameters(self, query: str, folder: str, limit: int) -> Dict[str, Any]:
"""
Build search parameters for Microsoft Graph API
This method handles the complexity of building search parameters
while avoiding conflicts between $search and $filter parameters.
"""
params = {
"$top": limit,
"$orderby": "receivedDateTime desc"
}
if not query or not query.strip():
# No query specified, just get emails from folder
if folder and folder.lower() != "all":
params["$filter"] = f"parentFolderId eq '{folder}'"
return params
clean_query = self._sanitizeSearchQuery(query)
# Check if this is a complex search query with multiple operators
if any(op in clean_query.lower() for op in ['from:', 'to:', 'subject:', 'received:', 'hasattachment:']):
# This is an advanced search query, use $search
# Microsoft Graph API supports complex search syntax
params["$search"] = f'"{clean_query}"'
logger.info(f"Using advanced search query: {clean_query}")
# Note: When using $search, we cannot combine it with $filter for folder
# We'll need to filter results after the API call
if folder and folder.lower() != "all":
logger.info(f"Will filter results by folder '{folder}' after search")
else:
# Use $filter for basic text search in subject and body
# Microsoft Graph API supports contains() for text search
filter_parts = [f"contains(subject,'{clean_query}') or contains(body/content,'{clean_query}')"]
# Add folder filter if specified
if folder and folder.lower() != "all":
filter_parts.append(f"parentFolderId eq '{folder}'")
# Combine all filter parts
params["$filter"] = " and ".join(f"({part})" for part in filter_parts)
logger.info(f"Using basic text search filter: {clean_query}")
return params
def _getFolderId(self, folder_name: str, connection: Dict[str, Any]) -> Optional[str]:
"""
Get the folder ID for a given folder name
This is needed for proper filtering when using advanced search queries
"""
try:
import requests
graph_url = "https://graph.microsoft.com/v1.0"
headers = {
"Authorization": f"Bearer {connection['accessToken']}",
"Content-Type": "application/json"
}
# Get mail folders
api_url = f"{graph_url}/me/mailFolders"
response = requests.get(api_url, headers=headers)
if response.status_code == 200:
folders_data = response.json()
for folder in folders_data.get("value", []):
if folder.get("displayName", "").lower() == folder_name.lower():
return folder.get("id")
logger.warning(f"Folder '{folder_name}' not found")
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:
"""
@ -284,11 +416,34 @@ class MethodOutlook(MethodBase):
message["attachments"].append(attachment)
# Create the draft message
api_url = f"{graph_url}/me/messages"
# First, get the Drafts folder ID to ensure the draft is created there
drafts_folder_id = self._getFolderId("Drafts", connection)
if drafts_folder_id:
# Create draft in the Drafts folder specifically
api_url = f"{graph_url}/me/mailFolders/{drafts_folder_id}/messages"
logger.info(f"Creating draft in Drafts folder (ID: {drafts_folder_id})")
else:
# Fallback: create in default location
api_url = f"{graph_url}/me/messages"
logger.warning("Could not find Drafts folder, creating draft in default location")
response = requests.post(api_url, headers=headers, json=message)
response.raise_for_status()
draft_data = response.json()
# Get the actual folder information for the created draft
actual_folder = "Drafts"
if drafts_folder_id:
actual_folder = "Drafts"
else:
# Try to determine where the draft was actually created
if "parentFolderId" in draft_data:
actual_folder = f"Folder ID: {draft_data['parentFolderId']}"
else:
actual_folder = "Default location"
draft_result = {
"status": "draft_created",
"messageId": draft_data.get("id", "unknown"),
@ -297,12 +452,13 @@ class MethodOutlook(MethodBase):
"cc": cc,
"bcc": bcc,
"attachments": len(attachments) if attachments else 0,
"draftLocation": "Drafts folder",
"draftLocation": actual_folder,
"draftsFolderId": drafts_folder_id,
"apiResponse": response.status_code,
"draftData": draft_data
}
logger.info(f"Successfully created email draft for {len(to)} recipients with {len(attachments) if attachments else 0} attachments")
logger.info(f"Successfully created email draft for {len(to)} recipients with {len(attachments) if attachments else 0} attachments in {actual_folder}")
except ImportError:
logger.error("requests module not available, falling back to simulation")
@ -420,13 +576,31 @@ class MethodOutlook(MethodBase):
limit = parameters.get("limit", 20)
expectedDocumentFormats = parameters.get("expectedDocumentFormats", [])
if not connectionReference or not query:
# Validate parameters
if not connectionReference:
return self._createResult(
success=False,
data={},
error="Connection reference and query are required"
error="Connection reference is required"
)
if not query or not query.strip():
return self._createResult(
success=False,
data={},
error="Search query is required and cannot be empty"
)
# Validate limit
try:
limit = int(limit)
if limit <= 0 or limit > 1000: # Microsoft Graph API has limits
limit = 20
logger.warning(f"Limit {limit} is out of range, using default value 20")
except (ValueError, TypeError):
limit = 20
logger.warning(f"Invalid limit value, using default value 20")
# Get Microsoft connection
connection = self._getMicrosoftConnection(connectionReference)
if not connection:
@ -449,31 +623,88 @@ class MethodOutlook(MethodBase):
# Build the search API request
api_url = f"{graph_url}/me/messages"
params = {
"$top": limit,
"$orderby": "receivedDateTime desc",
"$search": f'"{query}"'
}
params = self._buildSearchParameters(query, folder, limit)
# Add folder filter if specified
if folder and folder.lower() != "all":
params["$filter"] = f"parentFolderId eq '{folder}'"
logger.info(f"Search API parameters: {params}")
# Make the API call
response = requests.get(api_url, headers=headers, params=params)
# Log response details for debugging
logger.debug(f"Microsoft Graph API response status: {response.status_code}")
logger.debug(f"Microsoft Graph API response headers: {dict(response.headers)}")
if response.status_code != 200:
# Log detailed error information
try:
error_data = response.json()
logger.error(f"Microsoft Graph API error: {response.status_code} - {error_data}")
except:
logger.error(f"Microsoft Graph API error: {response.status_code} - {response.text}")
# Check for specific error types and provide helpful messages
if response.status_code == 400:
logger.error("Bad Request (400) - Check search query format and parameters")
logger.error(f"Search query: '{query}'")
logger.error(f"Search parameters: {params}")
logger.error(f"API URL: {api_url}")
elif response.status_code == 401:
logger.error("Unauthorized (401) - Check access token and permissions")
elif response.status_code == 403:
logger.error("Forbidden (403) - Check API permissions and scopes")
elif response.status_code == 429:
logger.error("Too Many Requests (429) - Rate limit exceeded")
# Fall back to simulation on API error
raise Exception(f"Microsoft Graph API returned {response.status_code}: {response.text}")
response.raise_for_status()
search_data = response.json()
emails = search_data.get("value", [])
logger.info(f"Successfully retrieved {len(emails)} emails from Microsoft Graph API")
# Apply folder filtering if needed and we used $search
if folder and folder.lower() != "all" and "$search" in params:
# Get the actual folder ID for proper filtering
folder_id = self._getFolderId(folder, connection)
if folder_id:
# Filter results by folder ID
filtered_emails = []
for email in emails:
if email.get("parentFolderId") == folder_id:
filtered_emails.append(email)
emails = filtered_emails
logger.info(f"Filtered {len(filtered_emails)} emails for folder '{folder}' (ID: {folder_id}) from {len(search_data.get('value', []))} total results")
else:
# Fallback: try to filter by folder name (less reliable)
filtered_emails = []
for email in emails:
# Check if email has folder information
if hasattr(email, 'parentFolderId') and email.get('parentFolderId'):
if email.get('parentFolderId') == folder:
filtered_emails.append(email)
else:
# If no folder info, include the email (less strict filtering)
filtered_emails.append(email)
logger.debug(f"Email {email.get('id', 'unknown')} has no folder info, including in results")
emails = filtered_emails
logger.info(f"Filtered {len(filtered_emails)} emails for folder '{folder}' (fallback filtering) from {len(search_data.get('value', []))} total results")
search_result = {
"query": query,
"results": search_data.get("value", []),
"count": len(search_data.get("value", [])),
"results": emails,
"count": len(emails),
"folder": folder,
"limit": limit,
"apiResponse": search_data
"apiResponse": search_data,
"searchParams": params
}
logger.info(f"Successfully searched emails with query '{query}', found {len(search_data.get('value', []))} results")
logger.info(f"Successfully searched emails with query '{query}', found {len(emails)} results")
except ImportError:
logger.error("requests module not available, falling back to simulation")
@ -555,6 +786,173 @@ class MethodOutlook(MethodBase):
except Exception as e:
logger.error(f"Error searching emails: {str(e)}")
return self._createResult(
success=False,
data={},
error=str(e)
)
@action
async def listDrafts(self, parameters: Dict[str, Any]) -> ActionResult:
"""
List email drafts in Outlook
Parameters:
connectionReference (str): Reference to the Microsoft connection
folder (str, optional): Folder to search for drafts (default: "Drafts")
limit (int, optional): Maximum number of drafts to list (default: 20)
expectedDocumentFormats (list, optional): Expected document formats with extension, mimeType, description
"""
try:
connectionReference = parameters.get("connectionReference")
folder = parameters.get("folder", "Drafts")
limit = parameters.get("limit", 20)
expectedDocumentFormats = parameters.get("expectedDocumentFormats", [])
if not connectionReference:
return self._createResult(
success=False,
data={},
error="Connection reference is required"
)
# Get Microsoft connection
connection = self._getMicrosoftConnection(connectionReference)
if not connection:
return self._createResult(
success=False,
data={},
error="No valid Microsoft connection found for the provided connection reference"
)
# List drafts using Microsoft Graph API
try:
import requests
# Microsoft Graph API endpoint for messages
graph_url = "https://graph.microsoft.com/v1.0"
headers = {
"Authorization": f"Bearer {connection['accessToken']}",
"Content-Type": "application/json"
}
# Get the folder ID for the specified folder
folder_id = self._getFolderId(folder, connection)
if folder_id:
# List messages in the specific folder
api_url = f"{graph_url}/me/mailFolders/{folder_id}/messages"
logger.info(f"Listing messages in folder '{folder}' (ID: {folder_id})")
else:
# Fallback: list all messages (might include drafts)
api_url = f"{graph_url}/me/messages"
logger.warning(f"Could not find folder '{folder}', listing all messages")
params = {
"$top": limit,
"$orderby": "lastModifiedDateTime desc",
"$select": "id,subject,from,toRecipients,ccRecipients,bccRecipients,receivedDateTime,lastModifiedDateTime,parentFolderId,isDraft"
}
# Make the API call
response = requests.get(api_url, headers=headers, params=params)
response.raise_for_status()
messages_data = response.json()
messages = messages_data.get("value", [])
# Filter for drafts if we're looking at all messages
if not folder_id:
drafts = [msg for msg in messages if msg.get("isDraft", False)]
messages = drafts
logger.info(f"Filtered {len(drafts)} drafts from {len(messages_data.get('value', []))} total messages")
drafts_result = {
"folder": folder,
"folderId": folder_id,
"drafts": messages,
"count": len(messages),
"limit": limit,
"apiResponse": messages_data
}
logger.info(f"Successfully retrieved {len(messages)} drafts from folder '{folder}'")
except ImportError:
logger.error("requests module not available, falling back to simulation")
# Fallback to simulation
drafts_prompt = f"""
Simulate listing email drafts in Microsoft Outlook.
Connection: {connection['id']}
Folder: {folder}
Limit: {limit}
Please provide:
1. List of email drafts with subject, recipients, and modification date
2. Draft status and location information
3. Summary of draft statistics
"""
drafts_result = await self.service.interfaceAiCalls.callAiTextAdvanced(drafts_prompt)
except Exception as e:
logger.error(f"Error listing drafts via Microsoft Graph API: {str(e)}")
# Fallback to simulation on API error
drafts_prompt = f"""
Simulate listing email drafts in Microsoft Outlook.
Connection: {connection['id']}
Folder: {folder}
Limit: {limit}
Please provide:
1. List of email drafts with subject, recipients, and modification date
2. Draft status and location information
3. Summary of draft statistics
"""
drafts_result = await self.service.interfaceAiCalls.callAiTextAdvanced(drafts_prompt)
# Create result data
result_data = {
"connectionReference": connectionReference,
"folder": folder,
"limit": limit,
"draftsResult": drafts_result,
"connection": {
"id": connection["id"],
"authority": "microsoft",
"reference": connectionReference
},
"timestamp": datetime.now(UTC).isoformat()
}
# Determine output format based on expected formats
output_extension = ".json" # Default
output_mime_type = "application/json" # Default
if expectedDocumentFormats and len(expectedDocumentFormats) > 0:
# Use the first expected format
expected_format = expectedDocumentFormats[0]
output_extension = expected_format.get("extension", ".json")
output_mime_type = expected_format.get("mimeType", "application/json")
logger.info(f"Using expected format: {output_extension} ({output_mime_type})")
else:
logger.info("No expected format specified, using default .json format")
return self._createResult(
success=True,
data={
"documents": [
{
"documentName": f"outlook_drafts_list_{datetime.now(UTC).strftime('%Y%m%d_%H%M%S')}{output_extension}",
"documentData": result_data,
"mimeType": output_mime_type
}
]
}
)
except Exception as e:
logger.error(f"Error listing drafts: {str(e)}")
return self._createResult(
success=False,
data={},

View file

@ -28,7 +28,17 @@ class MethodSharepoint(MethodBase):
"""Get Microsoft connection from connection reference"""
try:
userConnection = self.service.getUserConnectionFromConnectionReference(connectionReference)
if not userConnection or userConnection.authority.value != "msft" or userConnection.status.value != "active":
if not userConnection:
logger.warning(f"No user connection found for reference: {connectionReference}")
return None
if userConnection.authority.value != "msft":
logger.warning(f"Connection {userConnection.id} is not Microsoft (authority: {userConnection.authority.value})")
return None
# Check if connection is active or pending (pending means OAuth in progress)
if userConnection.status.value not in ["active", "pending"]:
logger.warning(f"Connection {userConnection.id} status is not active/pending: {userConnection.status.value}")
return None
# Get the corresponding token for this user and authority
@ -37,6 +47,16 @@ class MethodSharepoint(MethodBase):
logger.warning(f"No token found for user {userConnection.userId} and authority {userConnection.authority.value}")
return None
# Check if token is expired
if hasattr(token, 'expiresAt') and token.expiresAt:
import time
current_time = time.time()
if current_time > token.expiresAt:
logger.warning(f"Token for connection {userConnection.id} is expired (expiresAt: {token.expiresAt}, current: {current_time})")
return None
logger.info(f"Successfully retrieved Microsoft connection: {userConnection.id}, status: {userConnection.status.value}, externalId: {userConnection.externalId}")
return {
"id": userConnection.id,
"accessToken": token.tokenAccess,