From 2d269137a57001c64244bd6e0f3f177d8993c856 Mon Sep 17 00:00:00 2001 From: ValueOn AG
Generated: {datetime.now(UTC).strftime('%Y-%m-%d %H:%M:%S UTC')}
") html.append(f"Total Documents Analyzed: {len(validDocuments)}
") html.append("Generated: {datetime.now(UTC).strftime('%Y-%m-%d %H:%M:%S UTC')}
") html.append(f"Total Documents: {len(chatDocuments)}
") diff --git a/modules/methods/methodOutlook.py b/modules/methods/methodOutlook.py index e051b631..8727c00e 100644 --- a/modules/methods/methodOutlook.py +++ b/modules/methods/methodOutlook.py @@ -10,6 +10,7 @@ import json import uuid from modules.chat.methodBase import MethodBase, ActionResult, action +from modules.interfaces.interfaceAppModel import ConnectionStatus logger = logging.getLogger(__name__) @@ -23,35 +24,31 @@ class MethodOutlook(MethodBase): self.description = "Handle Microsoft Outlook email operations" def _getMicrosoftConnection(self, connectionReference: str) -> Optional[Dict[str, Any]]: - """Get Microsoft connection from connection reference""" + """ + Helper function to get Microsoft connection details. + """ try: - userConnection = self.service.getUserConnectionFromConnectionReference(connectionReference) + # Get the connection from the service + userConnection = self.service.getUserConnection(connectionReference) 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})") + logger.error(f"Connection not found: {connectionReference}") 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 - token = self.service.interfaceApp.getToken(userConnection.authority.value) + # Get the token for this connection + token = self.service.getTokenForConnection(userConnection.id) if not token: - logger.warning(f"No token found for user {userConnection.userId} and authority {userConnection.authority.value}") + logger.error(f"Token not found for connection: {userConnection.id}") return None - # Check if token is expired - if hasattr(token, 'expiresAt') and token.expiresAt: - import time - current_time = time.time() - if current_time > token.expiresAt: - logger.warning(f"Token for connection {userConnection.id} is expired (expiresAt: {token.expiresAt}, current: {current_time})") - 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 connection is active + if userConnection.status != ConnectionStatus.active: + logger.error(f"Connection is not active: {userConnection.id}, status: {userConnection.status.value}") + return None logger.info(f"Successfully retrieved Microsoft connection: {userConnection.id}, status: {userConnection.status.value}, externalId: {userConnection.externalId}") @@ -59,11 +56,43 @@ class MethodOutlook(MethodBase): "id": userConnection.id, "accessToken": token.tokenAccess, "refreshToken": token.tokenRefresh, - "scopes": ["Mail.ReadWrite", "User.Read"] # Default Microsoft scopes + "scopes": ["Mail.ReadWrite", "Mail.Send", "Mail.ReadWrite.Shared", "User.Read"] # Valid Microsoft Graph API scopes } except Exception as e: logger.error(f"Error getting Microsoft connection: {str(e)}") return None + + async def _checkPermissions(self, connection: Dict[str, Any]) -> bool: + """ + Check if the current connection has the necessary permissions for Outlook operations. + """ + try: + import requests + + graph_url = "https://graph.microsoft.com/v1.0" + headers = { + "Authorization": f"Bearer {connection['accessToken']}", + "Content-Type": "application/json" + } + + # Test permissions by trying to access the user's mail folder + test_url = f"{graph_url}/me/mailFolders" + response = requests.get(test_url, headers=headers) + + if response.status_code == 200: + logger.info("✅ Permission check passed - connection has necessary mail permissions") + return True + elif response.status_code == 403: + logger.error("❌ Permission denied - connection lacks necessary mail permissions") + logger.error("Required scopes: Mail.ReadWrite, Mail.Send, Mail.ReadWrite.Shared") + return False + else: + logger.warning(f"⚠️ Permission check returned status {response.status_code}") + return False + + except Exception as e: + logger.error(f"Error checking permissions: {str(e)}") + return False def _sanitizeSearchQuery(self, query: str) -> str: """ @@ -104,14 +133,15 @@ class MethodOutlook(MethodBase): while avoiding conflicts between $search and $filter parameters. """ params = { - "$top": limit, - "$orderby": "receivedDateTime desc" + "$top": limit } if not query or not query.strip(): # No query specified, just get emails from folder if folder and folder.lower() != "all": params["$filter"] = f"parentFolderId eq '{folder}'" + # Add orderby for basic queries + params["$orderby"] = "receivedDateTime desc" return params clean_query = self._sanitizeSearchQuery(query) @@ -123,22 +153,28 @@ class MethodOutlook(MethodBase): 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 + # Note: When using $search, we cannot combine it with $orderby or $filter for folder # We'll need to filter results after the API call if folder and folder.lower() != "all": logger.info(f"Will filter results by folder '{folder}' after search") else: - # Use $filter for basic text search 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}')"] + # Use $filter for basic text search, but keep it simple to avoid "InefficientFilter" error + # Microsoft Graph API has limitations on complex filters + if len(clean_query) > 50: + # If query is too long, truncate it to avoid complex filter issues + clean_query = clean_query[:50] + logger.info(f"Query truncated to avoid complex filter: {clean_query}") + + # Use only subject search to keep filter simple + params["$filter"] = f"contains(subject,'{clean_query}')" # Add folder filter if specified if folder and folder.lower() != "all": - filter_parts.append(f"parentFolderId eq '{folder}'") + params["$filter"] = f"{params['$filter']} and 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}") + # Add orderby for basic queries + params["$orderby"] = "receivedDateTime desc" + logger.info(f"Using simple text search filter: {clean_query}") return params @@ -163,11 +199,36 @@ class MethodOutlook(MethodBase): if response.status_code == 200: folders_data = response.json() - for folder in folders_data.get("value", []): + all_folders = folders_data.get("value", []) + + # Log all available folders for debugging + logger.info(f"Available folders: {[f.get('displayName', 'Unknown') for f in all_folders]}") + + # Try exact match first + for folder in all_folders: if folder.get("displayName", "").lower() == folder_name.lower(): + logger.info(f"Found folder '{folder_name}' with ID: {folder.get('id')}") return folder.get("id") - logger.warning(f"Folder '{folder_name}' not found") + # Try common variations for Drafts folder + if folder_name.lower() == "drafts": + draft_variations = ["drafts", "draft", "entwürfe", "entwurf", "brouillons", "brouillon"] + for folder in all_folders: + folder_display_name = folder.get("displayName", "").lower() + if any(variation in folder_display_name for variation in draft_variations): + logger.info(f"Found Drafts folder variation '{folder.get('displayName')}' with ID: {folder.get('id')}") + return folder.get("id") + + # Try common variations for other folders + if folder_name.lower() == "sent items": + sent_variations = ["sent items", "sent", "gesendete elemente", "éléments envoyés"] + for folder in all_folders: + folder_display_name = folder.get("displayName", "").lower() + if any(variation in folder_display_name for variation in sent_variations): + logger.info(f"Found Sent Items folder variation '{folder.get('displayName')}' with ID: {folder.get('id')}") + return folder.get("id") + + logger.warning(f"Folder '{folder_name}' not found. Available folders: {[f.get('displayName', 'Unknown') for f in all_folders]}") return None else: logger.warning(f"Could not retrieve folders: {response.status_code}") @@ -372,7 +433,16 @@ class MethodOutlook(MethodBase): return self._createResult( success=False, data={}, - error="No valid Microsoft connection found for the provided connection reference" + error="Failed to get Microsoft connection" + ) + + # Check permissions before proceeding + permissions_ok = await self._checkPermissions(connection) + if not permissions_ok: + return self._createResult( + success=False, + data={}, + error="Connection lacks necessary permissions. Please re-authenticate with updated permissions." ) # Create email draft using Microsoft Graph API @@ -428,10 +498,24 @@ class MethodOutlook(MethodBase): api_url = f"{graph_url}/me/messages" logger.warning("Could not find Drafts folder, creating draft in default location") + logger.info(f"Creating draft with API URL: {api_url}") + logger.info(f"Draft message data: {json.dumps(message, indent=2)}") + response = requests.post(api_url, headers=headers, json=message) response.raise_for_status() draft_data = response.json() + logger.info(f"Draft creation response: {json.dumps(draft_data, indent=2)}") + + # Verify the draft was created in the correct folder + created_folder_id = draft_data.get("parentFolderId") + if created_folder_id: + if drafts_folder_id and created_folder_id == drafts_folder_id: + logger.info(f"✅ Draft successfully created in Drafts folder (ID: {created_folder_id})") + else: + logger.warning(f"⚠️ Draft created in different folder than expected. Expected: {drafts_folder_id}, Actual: {created_folder_id}") + else: + logger.warning("⚠️ Draft created but no folder ID returned") # Get the actual folder information for the created draft actual_folder = "Drafts" @@ -454,12 +538,25 @@ class MethodOutlook(MethodBase): "attachments": len(attachments) if attachments else 0, "draftLocation": actual_folder, "draftsFolderId": drafts_folder_id, + "createdFolderId": created_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 in {actual_folder}") + # Additional verification: try to retrieve the draft to confirm it exists + try: + verify_url = f"{graph_url}/me/messages/{draft_data.get('id')}" + verify_response = requests.get(verify_url, headers=headers) + if verify_response.status_code == 200: + verify_data = verify_response.json() + logger.info(f"✅ Draft verification successful - Draft ID: {verify_data.get('id')}, Subject: {verify_data.get('subject')}") + else: + logger.warning(f"⚠️ Draft verification failed - Status: {verify_response.status_code}") + except Exception as e: + logger.warning(f"⚠️ Draft verification error: {str(e)}") + except ImportError: logger.error("requests module not available, falling back to simulation") # Fallback to simulation if requests module is not available @@ -953,6 +1050,410 @@ class MethodOutlook(MethodBase): except Exception as e: logger.error(f"Error listing drafts: {str(e)}") + return self._createResult( + success=False, + data={}, + error=str(e) + ) + + @action + async def findDrafts(self, parameters: Dict[str, Any]) -> ActionResult: + """ + Find email drafts across all folders in Outlook + + Parameters: + connectionReference (str): Reference to the Microsoft connection + limit (int, optional): Maximum number of drafts to find (default: 50) + expectedDocumentFormats (list, optional): Expected document formats with extension, mimeType, description + """ + try: + connectionReference = parameters.get("connectionReference") + limit = parameters.get("limit", 50) + expectedDocumentFormats = parameters.get("expectedDocumentFormats", []) + + if not connectionReference: + return 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" + ) + + # Find drafts using Microsoft Graph API + try: + import requests + + # Microsoft Graph API endpoint for messages + graph_url = "https://graph.microsoft.com/v1.0" + headers = { + "Authorization": f"Bearer {connection['accessToken']}", + "Content-Type": "application/json" + } + + # Get all messages and filter for drafts + api_url = f"{graph_url}/me/messages" + params = { + "$top": limit, + "$select": "id,subject,from,toRecipients,ccRecipients,bccRecipients,receivedDateTime,lastModifiedDateTime,parentFolderId,isDraft,webLink", + "$filter": "isDraft eq true" + } + + logger.info(f"Searching for drafts across all folders") + + # Make the API call + response = requests.get(api_url, headers=headers, params=params) + response.raise_for_status() + + messages_data = response.json() + drafts = messages_data.get("value", []) + + # Get folder information for each draft + for draft in drafts: + if "parentFolderId" in draft: + folder_info = self._getFolderNameById(draft["parentFolderId"], connection) + draft["folderName"] = folder_info + + drafts_result = { + "totalDrafts": len(drafts), + "drafts": drafts, + "limit": limit, + "apiResponse": messages_data + } + + logger.info(f"Successfully found {len(drafts)} drafts across all folders") + + except ImportError: + logger.error("requests module not available, falling back to simulation") + # Fallback to simulation + drafts_prompt = f""" + Simulate finding email drafts in Microsoft Outlook. + + Connection: {connection['id']} + Limit: {limit} + + Please provide: + 1. List of email drafts with subject, recipients, and location + 2. Draft status and folder information + 3. Summary of draft statistics + """ + drafts_result = await self.service.interfaceAiCalls.callAiTextAdvanced(drafts_prompt) + except Exception as e: + logger.error(f"Error finding drafts via Microsoft Graph API: {str(e)}") + # Fallback to simulation on API error + drafts_prompt = f""" + Simulate finding email drafts in Microsoft Outlook. + + Connection: {connection['id']} + Limit: {limit} + + Please provide: + 1. List of email drafts with subject, recipients, and location + 2. Draft status and folder information + 3. Summary of draft statistics + """ + drafts_result = await self.service.interfaceAiCalls.callAiTextAdvanced(drafts_prompt) + + # Create result data + result_data = { + "connectionReference": connectionReference, + "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_found_{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 finding drafts: {str(e)}") + return self._createResult( + success=False, + data={}, + error=str(e) + ) + + def _getFolderNameById(self, folder_id: str, connection: Dict[str, Any]) -> str: + """ + Get folder name by folder ID + + This is a helper method to identify which folder a draft is in + """ + try: + import requests + + graph_url = "https://graph.microsoft.com/v1.0" + headers = { + "Authorization": f"Bearer {connection['accessToken']}", + "Content-Type": "application/json" + } + + # Get folder information + api_url = f"{graph_url}/me/mailFolders/{folder_id}" + response = requests.get(api_url, headers=headers) + + if response.status_code == 200: + folder_data = response.json() + return folder_data.get("displayName", f"Unknown Folder ({folder_id})") + else: + return f"Unknown Folder ({folder_id})" + + except Exception as e: + logger.warning(f"Error getting folder name for ID '{folder_id}': {str(e)}") + return f"Unknown Folder ({folder_id})" + + @action + async def checkDraftsFolder(self, parameters: Dict[str, Any]) -> ActionResult: + """ + Check the contents of the Drafts folder directly + + Parameters: + connectionReference (str): Reference to the Microsoft connection + limit (int, optional): Maximum number of drafts to check (default: 20) + expectedDocumentFormats (list, optional): Expected document formats with extension, mimeType, description + """ + try: + connectionReference = parameters.get("connectionReference") + limit = parameters.get("limit", 20) + expectedDocumentFormats = parameters.get("expectedDocumentFormats", []) + + if not connectionReference: + return 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" + ) + + # Check Drafts folder directly + try: + import requests + + # Microsoft Graph API endpoint for messages + graph_url = "https://graph.microsoft.com/v1.0" + headers = { + "Authorization": f"Bearer {connection['accessToken']}", + "Content-Type": "application/json" + } + + # Get the Drafts folder ID + drafts_folder_id = self._getFolderId("Drafts", connection) + + if not drafts_folder_id: + return self._createResult( + success=False, + data={}, + error="Could not find Drafts folder" + ) + + # Get messages directly from Drafts folder + api_url = f"{graph_url}/me/mailFolders/{drafts_folder_id}/messages" + params = { + "$top": limit, + "$select": "id,subject,from,toRecipients,ccRecipients,bccRecipients,receivedDateTime,lastModifiedDateTime,isDraft,webLink", + "$orderby": "lastModifiedDateTime desc" + } + + logger.info(f"Checking Drafts folder directly: {api_url}") + + # Make the API call + response = requests.get(api_url, headers=headers, params=params) + response.raise_for_status() + + messages_data = response.json() + drafts = messages_data.get("value", []) + + # Log detailed information about each draft + for i, draft in enumerate(drafts): + logger.info(f"Draft {i+1}: ID={draft.get('id')}, Subject='{draft.get('subject')}', Modified={draft.get('lastModifiedDateTime')}") + + drafts_result = { + "draftsFolderId": drafts_folder_id, + "totalDrafts": len(drafts), + "drafts": drafts, + "limit": limit, + "apiResponse": messages_data, + "apiUrl": api_url + } + + logger.info(f"Successfully checked Drafts folder: found {len(drafts)} drafts") + + except ImportError: + logger.error("requests module not available, falling back to simulation") + # Fallback to simulation + drafts_prompt = f""" + Simulate checking Drafts folder in Microsoft Outlook. + + Connection: {connection['id']} + Limit: {limit} + + Please provide: + 1. List of email drafts in the Drafts folder + 2. Draft details and status + 3. Summary of draft contents + """ + drafts_result = await self.service.interfaceAiCalls.callAiTextAdvanced(drafts_prompt) + except Exception as e: + logger.error(f"Error checking Drafts folder via Microsoft Graph API: {str(e)}") + # Fallback to simulation on API error + drafts_prompt = f""" + Simulate checking Drafts folder in Microsoft Outlook. + + Connection: {connection['id']} + Limit: {limit} + + Please provide: + 1. List of email drafts in the Drafts folder + 2. Draft details and status + 3. Summary of draft contents + """ + drafts_result = await self.service.interfaceAiCalls.callAiTextAdvanced(drafts_prompt) + + # Create result data + result_data = { + "connectionReference": connectionReference, + "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_folder_check_{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 checking Drafts folder: {str(e)}") + return self._createResult( + success=False, + data={}, + error=str(e) + ) + + @action + async def checkPermissions(self, parameters: Dict[str, Any]) -> ActionResult: + """ + Check if the current Microsoft connection has the necessary permissions for Outlook operations. + + Parameters: + connectionReference (str): Reference to the Microsoft connection to check + """ + try: + connectionReference = parameters.get("connectionReference") + if not connectionReference: + return 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="Failed to get Microsoft connection" + ) + + # Check permissions + permissions_ok = await self._checkPermissions(connection) + + if permissions_ok: + return self._createResult( + success=True, + data={ + "permissions": "✅ All necessary permissions are available", + "scopes": connection.get("scopes", []), + "connectionId": connection.get("id"), + "status": "ready" + } + ) + else: + return self._createResult( + success=False, + 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." + }, + error="Connection lacks necessary permissions for Outlook operations" + ) + + except Exception as e: + logger.error(f"Error checking permissions: {str(e)}") return self._createResult( success=False, data={}, diff --git a/modules/routes/routeSecurityMsft.py b/modules/routes/routeSecurityMsft.py index 59a2898e..bf721fb6 100644 --- a/modules/routes/routeSecurityMsft.py +++ b/modules/routes/routeSecurityMsft.py @@ -39,7 +39,12 @@ CLIENT_SECRET = APP_CONFIG.get("Service_MSFT_CLIENT_SECRET") TENANT_ID = APP_CONFIG.get("Service_MSFT_TENANT_ID", "common") REDIRECT_URI = APP_CONFIG.get("Service_MSFT_REDIRECT_URI") AUTHORITY = f"https://login.microsoftonline.com/{TENANT_ID}" -SCOPES = ["Mail.ReadWrite", "User.Read"] +SCOPES = [ + "Mail.ReadWrite", # Read and write mail + "Mail.Send", # Send mail + "Mail.ReadWrite.Shared", # Access shared mailboxes + "User.Read" # Read user profile +] @router.get("/login") @limiter.limit("5/minute") @@ -69,8 +74,9 @@ async def login( "connectionId": connectionId }) + # MSAL automatically adds openid, profile, offline_access - we just need to provide our business scopes auth_url = msal_app.get_authorization_request_url( - scopes=SCOPES, + scopes=SCOPES, # Only our business scopes - MSAL adds the required ones automatically redirect_uri=REDIRECT_URI, state=state_param, prompt="select_account" # Force account selection screen @@ -104,10 +110,10 @@ async def auth_callback(code: str, state: str, request: Request) -> HTMLResponse client_credential=CLIENT_SECRET ) - # Get token from code + # Get token from code - MSAL automatically handles the required scopes token_response = msal_app.acquire_token_by_authorization_code( code, - scopes=SCOPES, + scopes=SCOPES, # Only our business scopes - MSAL adds the required ones automatically redirect_uri=REDIRECT_URI )