""" Email agent for creating draft emails with Microsoft Graph API. Creates HTML-formatted email templates with attachments based on input documents. """ import logging import json import base64 import os import msal import requests from typing import Dict, Any, List, Optional from modules.configuration import APP_CONFIG from modules.workflowAgentsRegistry 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"] # Token storage directory self.token_dir = './token_storage' if not os.path.exists(self.token_dir): os.makedirs(self.token_dir) logger.info(f"Created token storage directory: {self.token_dir}") def setDependencies(self, mydom=None): """Set external dependencies for the agent.""" self.mydom = mydom 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", []) # 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 = [] # Add HTML preview document previewDoc = self.formatAgentDocumentOutput( "email_preview.html", htmlPreview, "text/html" ) documents.append(previewDoc) # Add email template as JSON for reference templateJson = json.dumps(emailTemplate, indent=2) templateDoc = self.formatAgentDocumentOutput( "email_template.json", templateJson, "application/json" ) documents.append(templateDoc) # 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 contents hasAttachment = False for content in doc.get("contents", []): # Add extracted text to document contents if content.get("dataExtracted"): documentContents.append(content.get("dataExtracted", "")) # Prepare attachment if it has content data if content.get("data"): # Check if this content should be an attachment # Typically files like PDFs, images, etc. contentType = content.get("contentType", "") if (not contentType.startswith("text/") or contentType in ["application/pdf", "application/msword"]): hasAttachment = True # If document has content to attach, add to attachments if hasAttachment: attachments.append({ "name": docName, "document": doc }) 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. 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 if JSON not found 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): """ Get the current user's token from the token store. Does not attempt to initiate authentication flow. Returns: Tuple of (user info, access token) or (None, None) if no valid token """ try: # Check if we have any token files if not os.path.exists(self.token_dir) or not os.listdir(self.token_dir): logger.warning("No token files found. User needs to authenticate with Microsoft.") return None, None # Find the most recently modified token file token_files = [os.path.join(self.token_dir, f) for f in os.listdir(self.token_dir) if f.endswith('.json')] if not token_files: return None, None most_recent = max(token_files, key=os.path.getmtime) user_id = os.path.basename(most_recent).split('.')[0] # Load the token token_data = self._loadTokenFromFile(user_id) if not token_data or not token_data.get("access_token"): logger.warning(f"No valid token data for user {user_id}") return None, None # Get user info from token user_info = self._getUserInfoFromToken(token_data["access_token"]) if not user_info: # Try to refresh the token if self._refreshToken(user_id): # Load the refreshed token token_data = self._loadTokenFromFile(user_id) if token_data and token_data.get("access_token"): user_info = self._getUserInfoFromToken(token_data["access_token"]) if user_info: return user_info, token_data["access_token"] logger.warning(f"Could not get user info for user {user_id}") return None, None return user_info, token_data["access_token"] except Exception as e: logger.error(f"Error getting current user token: {str(e)}") return None, None def _loadTokenFromFile(self, user_id): """Load token data from a file""" filename = os.path.join(self.token_dir, f"{user_id}.json") if os.path.exists(filename): try: with open(filename, 'r') as f: return json.load(f) except Exception as e: logger.error(f"Error loading token file: {str(e)}") return None return None def _getUserInfoFromToken(self, access_token): """Get user information using the access token""" headers = { 'Authorization': f'Bearer {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() return { "name": user_data.get("displayName", ""), "email": user_data.get("userPrincipalName", ""), "id": user_data.get("id", "") } else: logger.error(f"Error getting user info: {response.status_code} - {response.text}") return None except Exception as e: logger.error(f"Exception getting user info: {str(e)}") return None def _refreshToken(self, user_id): """Refresh the access token using the stored refresh token""" token_data = self._loadTokenFromFile(user_id) 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 tokens in storage token_data["access_token"] = result["access_token"] if "refresh_token" in result: token_data["refresh_token"] = result["refresh_token"] # Save the updated token filename = os.path.join(self.token_dir, f"{user_id}.json") try: with open(filename, 'w') as f: json.dump(token_data, f) logger.info(f"Token saved for user: {user_id}") return True except Exception as e: logger.error(f"Error saving token file: {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 with fixed attachment handling. Directly uses the document's data attribute for attachments. 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 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: # Get the document object doc = attachment.get('document', {}) file_name = attachment.get('name', 'attachment.file') logger.info(f"Processing attachment: {file_name}") # Directly access the data attribute from the document if 'data' in doc: file_content = doc['data'] is_base64 = doc.get('base64Encoded', False) # Determine content type content_type = "application/octet-stream" if 'mimeType' in doc: content_type = doc['mimeType'] elif 'contentType' in doc: content_type = doc['contentType'] # Check if we need to encode the content if not is_base64: logger.info(f"Base64 encoding content for {file_name}") if isinstance(file_content, str): try: # Check if already valid base64 base64.b64decode(file_content) logger.info("Content appears to be valid base64 already") except: # Not valid base64, encode it logger.info("Encoding string content to base64") file_content = base64.b64encode(file_content.encode('utf-8')).decode('utf-8') elif isinstance(file_content, bytes): logger.info("Encoding bytes content to base64") file_content = base64.b64encode(file_content).decode('utf-8') # Add attachment to email data logger.info(f"Adding attachment: {file_name} ({content_type})") attachment_data = { '@odata.type': '#microsoft.graph.fileAttachment', 'name': file_name, 'contentType': content_type, 'contentBytes': file_content } email_data['attachments'].append(attachment_data) logger.info(f"Successfully added attachment: {file_name}") else: logger.warning(f"Document does not contain 'data' attribute: {file_name}") # Try to find data in the fileId if 'fileId' in doc: logger.info(f"Found fileId: {doc['fileId']} - could implement fileId-based attachment lookup here") # Future enhancement: implement file lookup by fileId # Try to create draft using drafts folder endpoint (Option 1) try: logger.info("Attempting to create draft email using drafts folder 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', []))}") response = requests.post( 'https://graph.microsoft.com/v1.0/me/mailFolders/drafts/messages', headers=headers, json=email_data ) if response.status_code >= 200 and response.status_code < 300: logger.info("Successfully created draft email using drafts folder endpoint") return response.json() else: logger.error(f"Drafts folder method failed: {response.status_code} - {response.text}") # Try fallback method with messages endpoint (Option 2) logger.info("Trying fallback with messages endpoint") 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 also failed: {response.status_code} - {response.text}") 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()