""" Email agent for generating and sending emails. Provides email template generation and sending capabilities. """ import logging from typing import Dict, Any, List, Tuple import json import os import requests import base64 from datetime import datetime import re from bs4 import BeautifulSoup import msal from modules.shared.configuration import APP_CONFIG from modules.workflow.agentBase import AgentBase logger = logging.getLogger(__name__) class AgentEmail(AgentBase): """AI-driven agent for creating email templates and drafts using Microsoft Graph API""" def __init__(self): """Initialize the email agent""" super().__init__() self.name = "email" self.label = "Email Templates" self.description = "Creates email templates with HTML-formatted body and attachments from input documents" self.capabilities = [ "emailDrafting", "contentFormatting", "htmlTemplates", "documentAttachment", "msftGraphIntegration" ] # Initialize configuration self.client_id = None self.client_secret = None self.tenant_id = None self.redirect_uri = None self.authority = None self.scopes = ["Mail.ReadWrite", "User.Read"] # API base URL for Microsoft authentication self.api_base_url = APP_CONFIG.get("APP_API_URL", "(no-url)") def setDependencies(self, mydom=None): """Set external dependencies for the agent.""" self._loadConfiguration() def _loadConfiguration(self): """Load Microsoft Graph API configuration from config files""" try: self.client_id = APP_CONFIG.get("Agent_Mail_MSFT_CLIENT_ID") self.client_secret = APP_CONFIG.get("Agent_Mail_MSFT_CLIENT_SECRET") self.tenant_id = APP_CONFIG.get("Agent_Mail_MSFT_TENANT_ID", "common") self.redirect_uri = APP_CONFIG.get("Agent_Mail_MSFT_REDIRECT_URI") # Set authority URL self.authority = f"https://login.microsoftonline.com/{self.tenant_id}" logger.info(f"Email agent initialized with tenant ID: {self.tenant_id}") logger.info(f"Redirect URI: {self.redirect_uri}") except Exception as e: logger.error(f"Error loading Microsoft Graph configuration: {str(e)}") async def processTask(self, task: Dict[str, Any]) -> Dict[str, Any]: """ Process a task by creating an email template based on input documents. Sends a login request to the frontend if Microsoft authentication is required. Args: task: Task dictionary with prompt, inputDocuments, outputSpecifications Returns: Dictionary with feedback and documents """ try: # Extract task information prompt = task.get("prompt", "") inputDocuments = task.get("inputDocuments", []) outputSpecs = task.get("outputSpecifications", []) # Check AI service if not self.mydom: return { "feedback": "The Email agent requires an AI service to function.", "documents": [] } # Check Microsoft authentication status user_info, access_token = self._getCurrentUserToken() # If not authenticated, trigger frontend authentication flow if not user_info or not access_token: # Create authentication instruction document auth_instructions = self._createFrontendAuthTriggerDocument() # Return feedback with authentication trigger log for frontend return { "feedback": "⚠️ Microsoft authentication required. Please complete the authentication process when prompted.", "documents": [auth_instructions], "log": { "message": "doMsftLogin", "type": "system", "details": "Microsoft authentication required to create email drafts" } } # Extract document data from input documentContents, attachments = self._processInputDocuments(inputDocuments) # Generate email subject and body using AI emailTemplate = await self._generateEmailTemplate(prompt, documentContents) # Create HTML preview of the email htmlPreview = self._createHtmlPreview(emailTemplate) # Attempt to create a draft email using Microsoft Graph API draft_result, user_email = self._createDraftEmail( emailTemplate["recipient"], emailTemplate["subject"], emailTemplate["htmlBody"], attachments ) # Prepare output documents documents = [] # Process output specifications for spec in outputSpecs: label = spec.get("label", "") description = spec.get("description", "") if label.endswith(".html"): # Create the HTML template file templateDoc = self.formatAgentDocumentOutput( label, emailTemplate["htmlBody"], # Use the actual HTML body, not the preview "text/html" ) documents.append(templateDoc) elif label.endswith(".json"): # Create JSON template if requested templateJson = json.dumps(emailTemplate, indent=2) templateDoc = self.formatAgentDocumentOutput( label, templateJson, "application/json" ) documents.append(templateDoc) else: # Default to preview for other cases previewDoc = self.formatAgentDocumentOutput( label, htmlPreview, "text/html" ) documents.append(previewDoc) # Prepare feedback message if draft_result: feedback = f"Email draft created successfully for {user_email}. The subject is: '{emailTemplate['subject']}'" if attachments: feedback += f" with {len(attachments)} attachment(s)" feedback += ". You can open and edit it in your Outlook draft folder." else: feedback = "Email template created but could not save as draft. HTML preview and template are available as documents." return { "feedback": feedback, "documents": documents } except Exception as e: logger.error(f"Error in email creation: {str(e)}", exc_info=True) return { "feedback": f"Error creating email template: {str(e)}", "documents": [] } def _createFrontendAuthTriggerDocument(self) -> Dict[str, Any]: """ Create a simple document that explains authentication is required. This document is minimal as the actual authentication will be handled by frontend. Returns: Document dictionary """ html_content = """ Microsoft Authentication Required

Microsoft Authentication Required

To create email templates and drafts, you need to authenticate with your Microsoft account.

The application will now initiate the Microsoft authentication process. Please follow the instructions in the authentication window.

Note: You only need to authenticate once. Your session will be remembered for future email operations.

""" return self.formatAgentDocumentOutput( "microsoft_authentication.html", html_content, "text/html" ) def _processInputDocuments(self, documents: List[Dict[str, Any]]) -> tuple: """ Process input documents to extract content and prepare attachments. Args: documents: List of input documents Returns: Tuple of (document content text, list of attachments) """ documentContents = [] attachments = [] for doc in documents: docName = doc.get("name", "unnamed") if doc.get("ext"): docName = f"{docName}.{doc.get('ext')}" # Add document name to contents documentContents.append(f"\n\n--- {docName} ---\n") # Process document data directly if doc.get("data"): # Add to attachments with proper metadata attachments.append({ "name": docName, "document": { "data": doc["data"], "mimeType": doc.get("mimeType", "application/octet-stream"), "base64Encoded": doc.get("base64Encoded", False) } }) documentContents.append(f"Document attached: {docName}") else: documentContents.append(f"Document referenced: {docName}") return "\n".join(documentContents), attachments async def _generateEmailTemplate(self, prompt: str, documentContents: str) -> Dict[str, Any]: """ Generate email template using AI. Args: prompt: The task prompt documentContents: Extracted document content Returns: Email template dictionary with recipient, subject, body """ emailPrompt = f""" Create an email based on the following request: REQUEST: {prompt} DOCUMENT CONTENTS: {documentContents[:2000]}... (truncated if longer) Generate an email template with: 1. A relevant recipient (use placeholder or derive from content if possible) 2. A concise but descriptive subject line 3. A professional HTML-formatted email body 4. Appropriate greeting and closing Format your response as JSON with these fields: - recipient: email address - subject: subject line - plainBody: plain text version - htmlBody: HTML formatted version Only return valid JSON. No preamble or explanations. """ try: response = await self.mydom.callAi([ {"role": "system", "content": "You are an email template specialist. Create professional emails. Respond with valid JSON only."}, {"role": "user", "content": emailPrompt} ], produceUserAnswer=True) # Extract JSON from response jsonStart = response.find('{') jsonEnd = response.rfind('}') + 1 if jsonStart >= 0 and jsonEnd > jsonStart: template = json.loads(response[jsonStart:jsonEnd]) return template else: # Fallback plan logger.warning(f"Not able creating email template, generating fallback plan") return { "recipient": "recipient@example.com", "subject": "Information Regarding Your Request", "plainBody": f"This email is regarding your request: {prompt}", "htmlBody": f"

This email is regarding your request: {prompt}

" } except Exception as e: logger.warning(f"Error generating email template: {str(e)}") return { "recipient": "recipient@example.com", "subject": "Information Regarding Your Request", "plainBody": f"This email is regarding your request: {prompt}", "htmlBody": f"

This email is regarding your request: {prompt}

" } def _createHtmlPreview(self, emailTemplate: Dict[str, Any]) -> str: """ Create an HTML preview of the email template. Args: emailTemplate: Email template dictionary Returns: HTML string for preview """ html = f""" Email Preview: {emailTemplate.get('subject', 'Email Template')}

Email Template Preview

To:
{emailTemplate.get('recipient', 'recipient@example.com')}
Subject:
{emailTemplate.get('subject', 'No Subject')}
""" return html def _getCurrentUserToken(self) -> tuple: """ Get the current user's Microsoft token using the current user context. Returns tuple of (user_info, access_token) or (None, None) if not authenticated. """ try: if not self.mydom: logger.error("No mydom interface available") return None, None # Get token data from database using LucyDOMInterface token_data = self.mydom.getMsftToken() if not token_data: logger.info("No Microsoft token found for user") return None, None # Verify token is still valid if not self._verifyToken(token_data.get("access_token")): logger.info("Token invalid, attempting refresh") if not self._refreshToken(token_data): logger.info("Token refresh failed") return None, None # Get updated token data after refresh token_data = self.mydom.getMsftToken() # Get user info from token data user_info = token_data.get("user_info") if not user_info: # If user_info is not in token_data, try to get it from the token headers = { 'Authorization': f'Bearer {token_data.get("access_token", "")}', 'Content-Type': 'application/json' } try: response = requests.get('https://graph.microsoft.com/v1.0/me', headers=headers) if response.status_code == 200: user_data = response.json() user_info = { "name": user_data.get("displayName", ""), "email": user_data.get("userPrincipalName", ""), "id": user_data.get("id", "") } # Update token data with user info token_data["user_info"] = user_info self.mydom.saveMsftToken(token_data) logger.info(f"Retrieved and stored user info for {user_info.get('name', 'Unknown User')}") else: logger.warning(f"Failed to get user info: {response.status_code} - {response.text}") return None, None except Exception as e: logger.error(f"Error getting user info: {str(e)}") return None, None logger.info(f"Retrieved user info for {user_info.get('name', 'Unknown User')}") return user_info, token_data.get("access_token") except Exception as e: logger.error(f"Error getting current user token: {str(e)}") return None, None def _verifyToken(self, token: str) -> bool: """Verify the access token is valid""" try: headers = { 'Authorization': f'Bearer {token}', 'Content-Type': 'application/json' } response = requests.get('https://graph.microsoft.com/v1.0/me', headers=headers) return response.status_code == 200 except Exception as e: logger.error(f"Error verifying token: {str(e)}") return False def _refreshToken(self, token_data: Dict[str, Any]) -> bool: """Refresh the access token using the stored refresh token""" try: if not token_data or not token_data.get("refresh_token"): logger.warning("No refresh token available") return False msal_app = msal.ConfidentialClientApplication( self.client_id, authority=self.authority, client_credential=self.client_secret ) result = msal_app.acquire_token_by_refresh_token( token_data["refresh_token"], scopes=self.scopes ) if "error" in result: logger.error(f"Error refreshing token: {result.get('error')}") return False # Update token data token_data["access_token"] = result["access_token"] if "refresh_token" in result: token_data["refresh_token"] = result["refresh_token"] # Save updated token self.mydom.saveMsftToken(token_data) logger.info("Access token refreshed successfully") return True except Exception as e: logger.error(f"Error refreshing token: {str(e)}") return False def _createDraftEmail(self, recipient, subject, body, attachments=None): """Create a draft email using Microsoft Graph API""" try: # Get current user token user_info, access_token = self._getCurrentUserToken() if not user_info or not access_token: logger.warning("No authenticated user found, cannot create draft email") return False, None # Create draft email using Graph API email_result = self._createGraphDraftEmail(access_token, recipient, subject, body, attachments) if email_result: return True, user_info.get("email") else: return False, user_info.get("email") except Exception as e: logger.error(f"Error in creating draft email: {str(e)}") return False, None def _createGraphDraftEmail(self, access_token, recipient, subject, body, attachments=None): """ Create a draft email using Microsoft Graph API. Treats all files as binary attachments without content analysis. Args: access_token: Microsoft Graph access token recipient: Email recipient subject: Email subject body: HTML body of the email attachments: List of attachments Returns: Draft result or None if failed """ headers = { 'Authorization': f'Bearer {access_token}', 'Content-Type': 'application/json' } # Prepare email data with proper structure email_data = { 'subject': subject, 'body': { 'contentType': 'HTML', 'content': body }, 'toRecipients': [ { 'emailAddress': { 'address': recipient } } ] } # Add attachments if available if attachments and len(attachments) > 0: email_data['attachments'] = [] for attachment in attachments: doc = attachment.get('document', {}) file_name = attachment.get('name', 'attachment.file') logger.info(f"Processing attachment: {file_name}") # Get the document data directly file_content = doc.get('data') if not file_content: logger.warning(f"No data found for attachment: {file_name}") continue # Get content type from document metadata mime_type = doc.get('mimeType', 'application/octet-stream') is_base64 = doc.get('base64Encoded', False) # Handle content encoding try: if is_base64: # Content is already base64 encoded content_bytes = file_content else: # Content needs to be base64 encoded if isinstance(file_content, str): # For text files, encode the string to bytes first content_bytes = base64.b64encode(file_content.encode('utf-8')).decode('utf-8') elif isinstance(file_content, bytes): # For binary files, encode directly content_bytes = base64.b64encode(file_content).decode('utf-8') else: logger.warning(f"Unexpected content type for {file_name}") continue # Calculate size from decoded content decoded_size = len(base64.b64decode(content_bytes)) # Add attachment to email data logger.info(f"Adding attachment: {file_name} ({mime_type}, size: {decoded_size} bytes)") attachment_data = { '@odata.type': '#microsoft.graph.fileAttachment', 'name': file_name, 'contentType': mime_type, 'contentBytes': content_bytes, 'isInline': False, 'size': decoded_size } email_data['attachments'].append(attachment_data) logger.info(f"Successfully added attachment: {file_name}") except Exception as e: logger.error(f"Error processing attachment {file_name}: {str(e)}") continue # Try to create draft using drafts folder endpoint try: logger.info("Attempting to create draft email using messages endpoint") logger.info(f"Email data structure: subject={subject}, recipient={recipient}, " + f"has_attachments={bool(email_data.get('attachments'))}, " + f"attachment_count={len(email_data.get('attachments', []))}") # Create the draft message response = requests.post( 'https://graph.microsoft.com/v1.0/me/messages', headers=headers, json=email_data ) if response.status_code >= 200 and response.status_code < 300: logger.info("Successfully created draft email using messages endpoint") return response.json() else: logger.error(f"Messages endpoint method failed: {response.status_code} - {response.text}") logger.error(f"Request headers: {headers}") logger.error(f"Request body: {json.dumps(email_data, indent=2)}") return None except Exception as e: logger.error(f"Exception creating draft email: {str(e)}", exc_info=True) return None # Factory function for the Email agent def getAgentEmail(): """Returns an instance of the Email agent.""" return AgentEmail()