diff --git a/modules/chat/documents/documentGeneration.py b/modules/chat/documents/documentGeneration.py index 1a83ebf5..9f9630de 100644 --- a/modules/chat/documents/documentGeneration.py +++ b/modules/chat/documents/documentGeneration.py @@ -41,8 +41,21 @@ class DocumentGenerator: if mime_type == "application/octet-stream": content = getattr(doc, 'content', '') mime_type = detectMimeTypeFromContent(content, doc.filename, self.service) + + # Add result label to filename for document objects too + base_filename = doc.filename + if hasattr(action, 'execResultLabel') and action.execResultLabel: + result_label = action.execResultLabel.strip() + if result_label: + # Check if filename already starts with resultLabel to avoid duplication + if not base_filename.startswith(f"{result_label}-"): + base_filename = f"{result_label}-{base_filename}" + logger.info(f"Added resultLabel '{result_label}' as prefix to document object filename: {base_filename}") + else: + logger.info(f"Document object filename already has resultLabel prefix: {base_filename}") + return { - 'filename': doc.filename, + 'filename': base_filename, 'fileSize': getattr(doc, 'fileSize', 0), 'mimeType': mime_type, 'content': getattr(doc, 'content', ''), @@ -50,8 +63,33 @@ class DocumentGenerator: } elif isinstance(doc, dict): # Dictionary format document - handle both 'documentName' and 'filename' keys - filename = doc.get('documentName', doc.get('filename', \ - f"{action.execMethod}_{action.execAction}_{datetime.now(UTC).strftime('%Y%m%d_%H%M%S')}")) + base_filename = doc.get('documentName', doc.get('filename', '')) + + # Debug logging for resultLabel + if hasattr(action, 'execResultLabel'): + logger.info(f"Action {action.execMethod}.{action.execAction} has execResultLabel: '{action.execResultLabel}' (type: {type(action.execResultLabel)})") + else: + logger.info(f"Action {action.execMethod}.{action.execAction} has NO execResultLabel attribute") + + # If no filename provided, generate one with action info + if not base_filename: + base_filename = f"{action.execMethod}_{action.execAction}_{datetime.now(UTC).strftime('%Y%m%d_%H%M%S')}" + + # ALWAYS add result label to filename for better document selection + # This ensures consistent naming regardless of whether filename was provided or generated + if hasattr(action, 'execResultLabel') and action.execResultLabel: + result_label = action.execResultLabel.strip() + if result_label: + # Check if filename already starts with resultLabel to avoid duplication + if not base_filename.startswith(f"{result_label}-"): + base_filename = f"{result_label}-{base_filename}" + logger.info(f"Added resultLabel '{result_label}' as prefix to filename: {base_filename}") + else: + logger.info(f"Filename already has resultLabel prefix: {base_filename}") + else: + logger.info(f"No resultLabel available for action {action.execMethod}.{action.execAction}") + + filename = base_filename mimeType = doc.get('mimeType', 'application/octet-stream') # Handle documentData structure - it might be a dict with 'content' key or direct content @@ -85,7 +123,23 @@ class DocumentGenerator: else: # Unknown document type logger.warning(f"Unknown document type for action {action.execMethod}.{action.execAction}: {type(doc)}") - filename = f"{action.execMethod}_{action.execAction}_{datetime.now(UTC).strftime('%Y%m%d_%H%M%S')}" + base_filename = f"{action.execMethod}_{action.execAction}_{datetime.now(UTC).strftime('%Y%m%d_%H%M%S')}" + + # ALWAYS add result label to filename for better document selection + # This ensures consistent naming regardless of document type + if hasattr(action, 'execResultLabel') and action.execResultLabel: + result_label = action.execResultLabel.strip() + if result_label: + # Check if filename already starts with resultLabel to avoid duplication + if not base_filename.startswith(f"{result_label}-"): + base_filename = f"{result_label}-{base_filename}" + logger.info(f"Added resultLabel '{result_label}' as prefix to fallback filename: {base_filename}") + else: + logger.info(f"Fallback filename already has resultLabel prefix: {base_filename}") + else: + logger.info(f"No resultLabel available for action {action.execMethod}.{action.execAction}") + + filename = base_filename mimeType = detectMimeTypeFromContent(doc, filename, self.service) return { 'filename': filename, diff --git a/modules/chat/handling/handlingTasks.py b/modules/chat/handling/handlingTasks.py index c588e0a7..27e1cabe 100644 --- a/modules/chat/handling/handlingTasks.py +++ b/modules/chat/handling/handlingTasks.py @@ -368,6 +368,45 @@ class HandlingTasks: continue else: logger.error(f"=== TASK {task_index or '?'} FAILED: {task_step.objective} after {attempt+1} attempts ===") + + # Create user-facing error message for task failure + error_message = f"❌ Task {task_index or '?'} - '{task_step.objective}' failed after {attempt+1} attempts\n\n" + error_message += f"Objective: {task_step.objective}\n\n" + + # Add specific error details if available + if error: + error_message += f"Error: {error}\n\n" + + # Add retry information + error_message += f"Attempts: {attempt+1}\n" + error_message += f"Status: Will retry automatically\n\n" + error_message += "The system will attempt to retry this task. Please wait..." + + # Create workflow message for user + message_data = { + "workflowId": workflow.id, + "role": "assistant", + "message": error_message, + "status": "step", + "sequenceNr": len(workflow.messages) + 1, + "publishedAt": datetime.now(UTC).isoformat(), + "actionId": None, + "actionMethod": "task", + "actionName": "task_retry", + "documentsLabel": None, + "documents": [] + } + + try: + message = self.chatInterface.createWorkflowMessage(message_data) + if message: + workflow.messages.append(message) + logger.info(f"Created user-facing retry message for failed task: {task_step.objective}") + else: + logger.error(f"Failed to create user-facing retry message for failed task: {task_step.objective}") + except Exception as e: + logger.error(f"Error creating user-facing retry message: {str(e)}") + return TaskResult( taskId=task_step.id, status=TaskStatus.FAILED, @@ -376,6 +415,45 @@ class HandlingTasks: error=error ) logger.error(f"=== TASK {task_index or '?'} FAILED AFTER ALL RETRIES: {task_step.objective} ===") + + # Create user-facing error message for task failure + error_message = f"❌ Task {task_index or '?'} - '{task_step.objective}' failed after all retries\n\n" + error_message += f"Objective: {task_step.objective}\n\n" + + # Add specific error details if available + if error and error != "Task failed after all retries.": + error_message += f"Error: {error}\n\n" + + # Add retry information + error_message += f"Retries attempted: {retry_context.retry_count if retry_context else 'Unknown'}\n" + error_message += f"Status: Task failed permanently\n\n" + error_message += "Please check the connection and try again, or contact support if the issue persists." + + # Create workflow message for user + message_data = { + "workflowId": workflow.id, + "role": "assistant", + "message": error_message, + "status": "step", + "sequenceNr": len(workflow.messages) + 1, + "publishedAt": datetime.now(UTC).isoformat(), + "actionId": None, + "actionMethod": "task", + "actionName": "task_failure", + "documentsLabel": None, + "documents": [] + } + + try: + message = self.chatInterface.createWorkflowMessage(message_data) + if message: + workflow.messages.append(message) + logger.info(f"Created user-facing error message for failed task: {task_step.objective}") + else: + logger.error(f"Failed to create user-facing error message for failed task: {task_step.objective}") + except Exception as e: + logger.error(f"Error creating user-facing error message: {str(e)}") + return TaskResult( taskId=task_step.id, status=TaskStatus.FAILED, diff --git a/modules/connectors/connectorDbJson.py b/modules/connectors/connectorDbJson.py index d6ebc877..27b87b2d 100644 --- a/modules/connectors/connectorDbJson.py +++ b/modules/connectors/connectorDbJson.py @@ -383,6 +383,22 @@ class DatabaseConnector: self._tablesCache = {} self._tableMetadataCache = {} + def clearTableCache(self, table: str) -> None: + """Clears cache for a specific table to ensure fresh data.""" + if table in self._tablesCache: + del self._tablesCache[table] + logger.debug(f"Cleared cache for table: {table}") + + if table in self._tableMetadataCache: + del self._tableMetadataCache[table] + logger.debug(f"Cleared metadata cache for table: {table}") + + def clearAllCache(self) -> None: + """Clears all cache to ensure completely fresh data.""" + self._tablesCache.clear() + self._tableMetadataCache.clear() + logger.debug("Cleared all database cache") + # Public API def getTables(self) -> List[str]: diff --git a/modules/interfaces/interfaceAppAccess.py b/modules/interfaces/interfaceAppAccess.py index 262ab3eb..b910c002 100644 --- a/modules/interfaces/interfaceAppAccess.py +++ b/modules/interfaces/interfaceAppAccess.py @@ -235,6 +235,9 @@ class AppAccess: "lastActivity": datetime.now() }) + # Clear cache to ensure fresh data + self.db.clearTableCache("sessions") + return True except Exception as e: diff --git a/modules/interfaces/interfaceAppObjects.py b/modules/interfaces/interfaceAppObjects.py index 56397be9..a3de6f69 100644 --- a/modules/interfaces/interfaceAppObjects.py +++ b/modules/interfaces/interfaceAppObjects.py @@ -194,7 +194,11 @@ class AppObjects: Boolean indicating permission """ return self.access.canModify(table, recordId) - + + def _clearTableCache(self, table: str) -> None: + """Clears the cache for a specific table to ensure fresh data.""" + self.db.clearTableCache(table) + def getInitialId(self, table: str) -> Optional[str]: """Returns the initial ID for a table.""" return self.db.getInitialId(table) @@ -352,6 +356,9 @@ class AppObjects: # Save to connections table self.db.recordCreate("connections", connection.to_dict()) + # Clear cache to ensure fresh data + self._clearTableCache("connections") + return connection except Exception as e: @@ -372,6 +379,9 @@ class AppObjects: # Delete connection self.db.recordDelete("connections", connectionId) + # Clear cache to ensure fresh data + self._clearTableCache("connections") + except Exception as e: logger.error(f"Error removing user connection: {str(e)}") raise ValueError(f"Failed to remove user connection: {str(e)}") @@ -379,8 +389,7 @@ class AppObjects: def authenticateLocalUser(self, username: str, password: str) -> Optional[User]: """Authenticates a user by username and password using local authentication.""" # Clear the users table from cache and reload it - if "users" in self.db._tablesCache: - del self.db._tablesCache["users"] + self._clearTableCache("users") # Get user by username user = self.getUserByUsername(username) @@ -445,6 +454,9 @@ class AppObjects: if not createdRecord or not createdRecord.get("id"): raise ValueError("Failed to create user record") + # Clear cache to ensure fresh data + self._clearTableCache("users") + # Add external connection if provided if externalId and externalUsername: self.addUserConnection( @@ -460,11 +472,8 @@ class AppObjects: if not createdUser or len(createdUser) == 0: raise ValueError("Failed to retrieve created user") - # Clear both table and metadata caches - if hasattr(self.db, '_tablesCache') and "users" in self.db._tablesCache: - del self.db._tablesCache["users"] - if hasattr(self.db, '_tableMetadataCache') and "users" in self.db._tableMetadataCache: - del self.db._tableMetadataCache["users"] + # Clear cache to ensure fresh data (already done above) + # No need for additional cache clearing since _clearTableCache("users") was called return User.from_dict(createdUser[0]) @@ -491,6 +500,9 @@ class AppObjects: # Update user record self.db.recordModify("users", userId, updatedUser.to_dict()) + # Clear cache to ensure fresh data + self._clearTableCache("users") + # Get updated user updatedUser = self.getUser(userId) if not updatedUser: @@ -562,11 +574,8 @@ class AppObjects: if not success: raise ValueError(f"Failed to delete user {userId}") - # Clear both table and metadata caches - if hasattr(self.db, '_tablesCache') and "users" in self.db._tablesCache: - del self.db._tablesCache["users"] - if hasattr(self.db, '_tableMetadataCache') and "users" in self.db._tableMetadataCache: - del self.db._tableMetadataCache["users"] + # Clear cache to ensure fresh data + self._clearTableCache("users") logger.info(f"User {userId} successfully deleted") return True @@ -611,6 +620,9 @@ class AppObjects: if not createdRecord or not createdRecord.get("id"): raise ValueError("Failed to create mandate record") + # Clear cache to ensure fresh data + self._clearTableCache("mandates") + return Mandate.from_dict(createdRecord) def updateMandate(self, mandateId: str, updateData: Dict[str, Any]) -> Mandate: @@ -637,6 +649,9 @@ class AppObjects: # Update mandate record self.db.recordModify("mandates", mandateId, updatedMandate.to_dict()) + # Clear cache to ensure fresh data + self._clearTableCache("mandates") + # Get updated mandate updatedMandate = self.getMandate(mandateId) if not updatedMandate: @@ -665,7 +680,12 @@ class AppObjects: raise ValueError(f"Cannot delete mandate {mandateId} with existing users") # Delete mandate - return self.db.recordDelete("mandates", mandateId) + success = self.db.recordDelete("mandates", mandateId) + + # Clear cache to ensure fresh data + self._clearTableCache("mandates") + + return success except Exception as e: logger.error(f"Error deleting mandate: {str(e)}") @@ -747,6 +767,9 @@ class AppObjects: # Save to database self.db.recordCreate("tokens", token_dict) + # Clear cache to ensure fresh data + self._clearTableCache("tokens") + logger.debug(f"Token saved for user {self.currentUser.id} with authority {token.authority}") except Exception as e: @@ -792,7 +815,10 @@ class AppObjects: # Delete each token for token in tokens: self.db.recordDelete("tokens", token["id"]) - + + # Clear cache to ensure fresh data + self._clearTableCache("tokens") + except Exception as e: logger.error(f"Error deleting token: {str(e)}") raise diff --git a/modules/interfaces/interfaceChatObjects.py b/modules/interfaces/interfaceChatObjects.py index 7105f5de..7101ecd6 100644 --- a/modules/interfaces/interfaceChatObjects.py +++ b/modules/interfaces/interfaceChatObjects.py @@ -121,6 +121,10 @@ class ChatObjects: """Delegate to access control module.""" return self.access.canModify(table, recordId) + def _clearTableCache(self, table: str) -> None: + """Clears the cache for a specific table to ensure fresh data.""" + self.db.clearTableCache(table) + # Utilities def getInitialId(self, table: str) -> Optional[str]: @@ -196,6 +200,9 @@ class ChatObjects: # Create workflow in database created = self.db.recordCreate("workflows", workflowData) + # Clear cache to ensure fresh data + self._clearTableCache("workflows") + # Convert to ChatWorkflow model return ChatWorkflow( id=created["id"], @@ -226,6 +233,9 @@ class ChatObjects: # Update workflow in database updated = self.db.recordModify("workflows", workflowId, workflowData) + # Clear cache to ensure fresh data + self._clearTableCache("workflows") + # Convert to ChatWorkflow model return ChatWorkflow( id=updated["id"], @@ -256,7 +266,12 @@ class ChatObjects: raise PermissionError(f"No permission to delete workflow {workflowId}") # Delete workflow - return self.db.recordDelete("workflows", workflowId) + success = self.db.recordDelete("workflows", workflowId) + + # Clear cache to ensure fresh data + self._clearTableCache("workflows") + + return success # Workflow Messages @@ -328,6 +343,9 @@ class ChatObjects: # Create message in database createdMessage = self.db.recordCreate("workflowMessages", messageData) + # Clear cache to ensure fresh data + self._clearTableCache("workflowMessages") + # Convert to ChatMessage model return ChatMessage( id=createdMessage["id"], @@ -411,6 +429,9 @@ class ChatObjects: updatedMessage = self.db.recordModify("workflowMessages", messageId, messageData) if updatedMessage: logger.debug(f"Message {messageId} updated successfully") + + # Clear cache to ensure fresh data + self._clearTableCache("workflowMessages") else: logger.warning(f"Failed to update message {messageId}") @@ -440,7 +461,12 @@ class ChatObjects: return False # Delete the message from the database - return self.db.recordDelete("workflowMessages", messageId) + success = self.db.recordDelete("workflowMessages", messageId) + + # Clear cache to ensure fresh data + self._clearTableCache("workflowMessages") + + return success except Exception as e: logger.error(f"Error deleting message {messageId}: {str(e)}") return False @@ -709,6 +735,9 @@ class ChatObjects: # Create log in database createdLog = self.db.recordCreate("workflowLogs", log_model.to_dict()) + # Clear cache to ensure fresh data + self._clearTableCache("workflowLogs") + # Return validated ChatLog instance return ChatLog(**createdLog) @@ -1078,6 +1107,9 @@ class ChatObjects: # Create task in database createdTask = self.db.recordCreate("tasks", taskData) + # Clear cache to ensure fresh data + self._clearTableCache("tasks") + # Convert to TaskItem model task = TaskItem( id=createdTask["id"], @@ -1130,6 +1162,9 @@ class ChatObjects: # Update task in database updatedTask = self.db.recordModify("tasks", taskId, taskData) + # Clear cache to ensure fresh data + self._clearTableCache("tasks") + # Convert to TaskItem model return TaskItem( id=updatedTask["id"], @@ -1178,6 +1213,9 @@ class ChatObjects: if taskId in workflowTasks: workflowTasks.remove(taskId) self.updateWorkflow(task.workflowId, {"tasks": workflowTasks}) + + # Clear cache to ensure fresh data + self._clearTableCache("tasks") return True return False diff --git a/modules/interfaces/interfaceComponentObjects.py b/modules/interfaces/interfaceComponentObjects.py index 0041da96..09a149c4 100644 --- a/modules/interfaces/interfaceComponentObjects.py +++ b/modules/interfaces/interfaceComponentObjects.py @@ -235,6 +235,10 @@ class ComponentObjects: """Delegate to access control module.""" return self.access.canModify(table, recordId) + def _clearTableCache(self, table: str) -> None: + """Clears the cache for a specific table to ensure fresh data.""" + self.db.clearTableCache(table) + # Utilities def getInitialId(self, table: str) -> Optional[str]: @@ -279,6 +283,9 @@ class ComponentObjects: if not createdRecord or not createdRecord.get("id"): raise ValueError("Failed to create prompt record") + # Clear cache to ensure fresh data + self._clearTableCache("prompts") + return createdRecord def updatePrompt(self, promptId: str, updateData: Dict[str, Any]) -> Dict[str, Any]: @@ -292,6 +299,9 @@ class ComponentObjects: # Update prompt record directly with the update data self.db.recordModify("prompts", promptId, updateData) + # Clear cache to ensure fresh data + self._clearTableCache("prompts") + # Get updated prompt updatedPrompt = self.getPrompt(promptId) if not updatedPrompt: @@ -313,7 +323,13 @@ class ComponentObjects: if not self._canModify("prompts", promptId): raise PermissionError(f"No permission to delete prompt {promptId}") - return self.db.recordDelete("prompts", promptId) + # Delete prompt + success = self.db.recordDelete("prompts", promptId) + + # Clear cache to ensure fresh data + self._clearTableCache("prompts") + + return success # File Utilities @@ -528,6 +544,10 @@ class ComponentObjects: # Store in database self.db.recordCreate("files", fileItem.to_dict()) + + # Clear cache to ensure fresh data + self._clearTableCache("files") + return fileItem def updateFile(self, fileId: str, updateData: Dict[str, Any]) -> Dict[str, Any]: @@ -545,7 +565,12 @@ class ComponentObjects: updateData["filename"] = self._generateUniqueFilename(updateData["filename"], fileId) # Update file - return self.db.recordModify("files", fileId, updateData) + success = self.db.recordModify("files", fileId, updateData) + + # Clear cache to ensure fresh data + self._clearTableCache("files") + + return success def deleteFile(self, fileId: str) -> bool: """Deletes a file if user has access.""" @@ -576,7 +601,12 @@ class ComponentObjects: logger.warning(f"Error deleting FileData for file {fileId}: {str(e)}") # Delete the FileItem entry - return self.db.recordDelete("files", fileId) + success = self.db.recordDelete("files", fileId) + + # Clear cache to ensure fresh data + self._clearTableCache("files") + + return success except FileNotFoundError as e: raise @@ -634,6 +664,10 @@ class ComponentObjects: } self.db.recordCreate("fileData", fileDataObj) + + # Clear cache to ensure fresh data + self._clearTableCache("fileData") + logger.debug(f"Successfully stored data for file {fileId} (base64Encoded: {base64Encoded})") return True except Exception as e: @@ -668,8 +702,25 @@ class ComponentObjects: # Decode base64 to bytes return base64.b64decode(data) else: - # Convert text to bytes - return data.encode('utf-8') + # Check if this is supposed to be a binary file based on mime type + mimeType = file.mimeType + isTextFormat = self.isTextMimeType(mimeType) + + if isTextFormat: + # This is a text file, encode to bytes as expected + return data.encode('utf-8') + else: + # This is a binary file that was incorrectly stored as text + # Try to decode it as if it was base64 (common fallback scenario) + try: + logger.warning(f"Binary file {fileId} ({mimeType}) was stored as text, attempting base64 decode") + return base64.b64decode(data) + except Exception as base64_error: + logger.error(f"Failed to decode binary file {fileId} as base64: {str(base64_error)}") + # Last resort: return the data as-is (might be corrupted) + logger.warning(f"Returning raw data for file {fileId} - file may be corrupted") + return data.encode('utf-8') if isinstance(data, str) else data + except Exception as e: logger.error(f"Error processing file data for {fileId}: {str(e)}") return None @@ -810,7 +861,11 @@ class ComponentObjects: self.db.recordCreate("fileData", dataUpdate) logger.debug(f"Created new file data for file ID {fileId} (base64Encoded: {base64Encoded})") + # Clear cache to ensure fresh data + self._clearTableCache("fileData") + return True + except Exception as e: logger.error(f"Error updating data for file {fileId}: {str(e)}") return False diff --git a/modules/methods/methodOutlook.py b/modules/methods/methodOutlook.py index 8727c00e..5a2c7f12 100644 --- a/modules/methods/methodOutlook.py +++ b/modules/methods/methodOutlook.py @@ -1,6 +1,78 @@ """ -Outlook method module. -Handles Outlook operations using the Outlook service. +Microsoft Outlook Email Operations Module + +This module provides actions for composing and sending emails via Microsoft Outlook using the Microsoft Graph API. + +ACTION CONTRACT DEFINITION: +========================== + +1. COMPOSE EMAIL ACTION (composeEmail): + ==================================== + + Purpose: Use AI to compose professional email content + + Input Parameters: + - context (str): Email context/requirements + - recipient (str, optional): Recipient information + - attachments (List[str], optional): Available documents to reference + - tone (str, optional): Email tone (formal, casual, etc.) + - expectedDocumentFormats (list, optional): Ignored - always produces JSON + + Output Contract: + The action produces a JSON document with this EXACT structure: + { + "context": "original context", + "recipient": "recipient info", + "tone": "email tone", + "timestamp": "ISO timestamp", + "usage": "usage description", + "to": ["email@example.com"], + "subject": "Email subject", + "body": "Email body content", + "cc": [], + "bcc": [], + "attachments": ["docItem:uuid:filename.pdf"] + } + + Key Points: + - Email fields (to, subject, body, cc, bcc, attachments) are at ROOT LEVEL + - NOT wrapped in a "composedEmail" field + - Always produces .json format regardless of expectedDocumentFormats + - AI response is validated and parsed before output + +2. SEND EMAIL ACTION (sendEmail): + ============================== + + Purpose: Send the composed email via Outlook (creates draft) + + Input Parameters: + - connectionReference (str): Microsoft connection reference + - composedEmail (str): Reference to composed email document (docItem:...) + - expectedDocumentFormats (list, optional): Expected output formats + + Input Contract: + The composedEmail document MUST have this EXACT structure: + { + "to": ["email@example.com"], + "subject": "Email subject", + "body": "Email body content", + "cc": [], + "bcc": [], + "attachments": ["docItem:uuid:filename.pdf"] + } + + Key Points: + - Email fields must be at ROOT LEVEL + - NOT wrapped in a nested structure + - Reads file content from database using fileId + - Creates email draft in Outlook Drafts folder + - Returns success/failure status + +DATA FLOW: +========== +composeEmail → JSON Document → sendEmail → Outlook Draft + +The contract ensures that composeEmail outputs exactly what sendEmail expects to consume. """ import logging @@ -29,24 +101,27 @@ class MethodOutlook(MethodBase): """ try: # Get the connection from the service - userConnection = self.service.getUserConnection(connectionReference) + userConnection = self.service.getUserConnectionFromConnectionReference(connectionReference) if not userConnection: logger.error(f"Connection not found: {connectionReference}") return None # Get the token for this connection - token = self.service.getTokenForConnection(userConnection.id) + token = self.service.interfaceApp.getToken(userConnection.authority.value) if not token: logger.error(f"Token not found for connection: {userConnection.id}") return None - # Check if token is valid - if not token.isValid(): - logger.error(f"Token is invalid for connection: {userConnection.id}") - return None + # Check if token is expired + if hasattr(token, 'expiresAt') and token.expiresAt: + import time + current_time = time.time() + if current_time > token.expiresAt: + logger.error(f"Token for connection {userConnection.id} is expired (expiresAt: {token.expiresAt}, current: {current_time})") + return None # Check if connection is active - if userConnection.status != ConnectionStatus.active: + if userConnection.status.value != "active": logger.error(f"Connection is not active: {userConnection.id}, status: {userConnection.status.value}") return None @@ -310,41 +385,19 @@ class MethodOutlook(MethodBase): logger.info(f"Successfully retrieved {len(emails_data.get('value', []))} emails from {folder}") except ImportError: - logger.error("requests module not available, falling back to simulation") - # Fallback to simulation if requests module is not available - email_prompt = f""" - Simulate reading emails from Microsoft Outlook. - - Connection: {connection['id']} - Folder: {folder} - Limit: {limit} - Filter: {filter or 'None'} - - Please provide: - 1. List of emails with subject, sender, date, and content - 2. Summary of email statistics - 3. Important or urgent emails highlighted - 4. Email categorization if possible - """ - email_data = await self.service.interfaceAiCalls.callAiTextAdvanced(email_prompt) + logger.error("requests module not available") + return self._createResult( + success=False, + data={}, + error="requests module not available" + ) except Exception as e: logger.error(f"Error reading emails from Microsoft Graph API: {str(e)}") - # Fallback to simulation on API error - email_prompt = f""" - Simulate reading emails from Microsoft Outlook. - - Connection: {connection['id']} - Folder: {folder} - Limit: {limit} - Filter: {filter or 'None'} - - Please provide: - 1. List of emails with subject, sender, date, and content - 2. Summary of email statistics - 3. Important or urgent emails highlighted - 4. Email categorization if possible - """ - email_data = await self.service.interfaceAiCalls.callAiTextAdvanced(email_prompt) + return self._createResult( + success=False, + data={}, + error=f"Failed to read emails: {str(e)}" + ) # Create result data result_data = { @@ -398,33 +451,39 @@ class MethodOutlook(MethodBase): @action async def sendEmail(self, parameters: Dict[str, Any]) -> ActionResult: """ - Create email draft in Outlook for sending out + Send email via Outlook using composed email content + + This action takes a composed email document and sends it via Outlook. + The composed email must contain all necessary email details (recipients, subject, body, attachments). Parameters: connectionReference (str): Reference to the Microsoft connection - to (List[str]): List of recipient email addresses - subject (str): Email subject - body (str): Email body content - cc (List[str], optional): CC recipients - bcc (List[str], optional): BCC recipients - attachments (List[str], optional): List of document references to attach + composedEmail (str): Reference to the composed email document (docItem:...) expectedDocumentFormats (list, optional): Expected document formats with extension, mimeType, description + + Input Contract: + The composedEmail document must have the following structure: + { + "to": ["email@example.com"], + "subject": "Email subject", + "body": "Email body content", + "cc": [], + "bcc": [], + "attachments": ["docItem:uuid:filename.pdf"] + } + + Note: Email fields must be at root level, not wrapped in a nested structure. """ try: connectionReference = parameters.get("connectionReference") - to = parameters.get("to") - subject = parameters.get("subject") - body = parameters.get("body") - cc = parameters.get("cc", []) - bcc = parameters.get("bcc", []) - attachments = parameters.get("attachments", []) + composed_email_ref = parameters.get("composedEmail") expectedDocumentFormats = parameters.get("expectedDocumentFormats", []) - if not connectionReference or not to or not subject or not body: + if not connectionReference or not composed_email_ref: return self._createResult( success=False, data={}, - error="Connection reference, to, subject, and body are required" + error="Connection reference and composed email reference are required" ) # Get Microsoft connection @@ -435,16 +494,166 @@ class MethodOutlook(MethodBase): data={}, error="Failed to get Microsoft connection" ) - - # Check permissions before proceeding - permissions_ok = await self._checkPermissions(connection) - if not permissions_ok: + + # Get the composed email document + logger.info(f"Getting composed email document: {composed_email_ref}") + composed_email_docs = self.service.getChatDocumentsFromDocumentList([composed_email_ref]) + if not composed_email_docs or len(composed_email_docs) == 0: + logger.error(f"Could not find composed email document: {composed_email_ref}") return self._createResult( success=False, data={}, - error="Connection lacks necessary permissions. Please re-authenticate with updated permissions." + error=f"Could not find composed email document: {composed_email_ref}" ) + logger.info(f"Found {len(composed_email_docs)} composed email documents") + composed_email_doc = composed_email_docs[0] + logger.info(f"Composed email document: {composed_email_doc}") + + # Extract email details from the composed email document + try: + logger.info(f"Extracting email details from document...") + + # Get the actual file content from the database + # The document object has fileId, but we need to read the actual file content + file_id = getattr(composed_email_doc, 'fileId', None) + if not file_id: + logger.error("Document has no fileId attribute") + return self._createResult( + success=False, + data={}, + error="Composed email document has no fileId" + ) + + logger.info(f"Reading file content from fileId: {file_id}") + + # Read the actual file content from the database + try: + # Use the correct service interface to read file data + file_content = self.service.getFileData(file_id) + if not file_content: + logger.error(f"Failed to read file content for fileId: {file_id}") + return self._createResult( + success=False, + data={}, + error="Failed to read composed email file content" + ) + + logger.info(f"Successfully read file content, length: {len(str(file_content))}") + + # Convert bytes to string if needed + if isinstance(file_content, bytes): + email_data = file_content.decode('utf-8') + logger.info(f"Converted bytes to string, content length: {len(email_data)}") + else: + email_data = str(file_content) + logger.info(f"Content is already string, length: {len(email_data)}") + + # Debug: show first 200 characters of content + preview = email_data[:200] + "..." if len(email_data) > 200 else email_data + logger.info(f"Content preview: {repr(preview)}") + + except Exception as e: + logger.error(f"Error reading file content: {str(e)}") + return self._createResult( + success=False, + data={}, + error=f"Failed to read file content: {str(e)}" + ) + + # Parse the email data (should be JSON) + if isinstance(email_data, str): + import json + try: + # First try to parse as direct JSON + parsed_email_data = json.loads(email_data) + logger.info("Successfully parsed email data as direct JSON") + email_data = parsed_email_data # Now email_data is the parsed dictionary + except json.JSONDecodeError as e: + logger.error(f"JSON parsing error: {str(e)}") + logger.error(f"Content that failed to parse: {repr(email_data[:500])}") + + # If that fails, try to extract JSON from HTML content + logger.info("Direct JSON parsing failed, trying to extract from HTML content...") + import re + + # Look for JSON content within HTML tags or as a script + json_pattern = r'\{[^{}]*"to"[^{}]*"subject"[^{}]*"body"[^{}]*\}' + json_match = re.search(json_pattern, email_data, re.DOTALL) + + if json_match: + try: + extracted_json = json_match.group(0) + parsed_email_data = json.loads(extracted_json) + logger.info("Successfully extracted and parsed JSON from HTML content") + email_data = parsed_email_data # Now email_data is the parsed dictionary + except json.JSONDecodeError as e2: + logger.error(f"Failed to parse extracted JSON: {str(e2)}") + logger.error(f"Extracted content: {repr(extracted_json)}") + return self._createResult( + success=False, + data={}, + error="Could not parse JSON content from composed email document" + ) + else: + logger.error("No JSON content found in HTML document") + return self._createResult( + success=False, + data={}, + error="Composed email document content is not valid JSON and no JSON could be extracted" + ) + else: + logger.error(f"Unexpected email_data type: {type(email_data)}") + return self._createResult( + success=False, + data={}, + error=f"Unexpected email data type: {type(email_data)}, expected string" + ) + + # At this point, email_data should be a parsed dictionary + logger.info(f"Final email_data type: {type(email_data)}") + if isinstance(email_data, dict): + logger.info(f"Available keys: {list(email_data.keys())}") + + # Extract email fields - now they should be at root level + to = email_data.get("to", []) + subject = email_data.get("subject", "") + body = email_data.get("body", "") + cc = email_data.get("cc", []) + bcc = email_data.get("bcc", []) + attachments = email_data.get("attachments", []) + + # Validate required fields + if not to or not subject or not body: + logger.error(f"Missing required fields. Available keys: {list(email_data.keys())}") + return self._createResult( + success=False, + data={}, + error="Composed email must contain 'to', 'subject', and 'body' fields" + ) + + logger.info(f"Extracted email details: to={to}, subject='{subject}', body length={len(body)}, attachments={len(attachments)}") + + except Exception as e: + logger.error(f"Error parsing composed email document: {str(e)}") + return self._createResult( + success=False, + data={}, + error=f"Failed to parse composed email document: {str(e)}" + ) + + # Check permissions before proceeding + logger.info("Checking Microsoft Graph API permissions...") + permissions_ok = await self._checkPermissions(connection) + if not permissions_ok: + logger.error("Permission check failed") + return self._createResult( + success=False, + data={}, + error="Connection lacks necessary permissions for Outlook operations" + ) + logger.info("Permission check passed") + # Create email draft using Microsoft Graph API try: import requests @@ -456,12 +665,27 @@ class MethodOutlook(MethodBase): "Content-Type": "application/json" } + # Clean and format body content + cleaned_body = body.strip() + + # Check if body is already HTML + if cleaned_body.startswith('') or cleaned_body.startswith('
') or '