gateway/modules/agentEmail.py
2025-05-10 21:36:18 +02:00

612 lines
No EOL
25 KiB
Python

"""
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 requests
import msal
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"]
# 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.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", [])
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 = """
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Microsoft Authentication Required</title>
<style>
body { font-family: Arial, sans-serif; margin: 0; padding: 20px; background-color: #f5f5f5; }
.container { max-width: 800px; margin: 0 auto; background-color: white; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); padding: 30px; }
h1 { color: #0078d4; margin-top: 0; }
.note { background-color: #fff4e5; border-left: 4px solid #ff8c00; padding: 15px; margin: 20px 0; }
</style>
</head>
<body>
<div class="container">
<h1>Microsoft Authentication Required</h1>
<p>To create email templates and drafts, you need to authenticate with your Microsoft account.</p>
<p>The application will now initiate the Microsoft authentication process. Please follow the instructions in the authentication window.</p>
<div class="note">
<p><strong>Note:</strong> You only need to authenticate once. Your session will be remembered for future email operations.</p>
</div>
</div>
</body>
</html>
"""
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"<html><body><p>This email is regarding your request: {prompt}</p></body></html>"
}
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"<html><body><p>This email is regarding your request: {prompt}</p></body></html>"
}
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"""
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Email Preview: {emailTemplate.get('subject', 'Email Template')}</title>
<style>
body {{ font-family: Arial, sans-serif; margin: 0; padding: 0; background-color: #f5f5f5; }}
.email-container {{ max-width: 600px; margin: 20px auto; background-color: white; border: 1px solid #ddd; border-radius: 5px; overflow: hidden; }}
.email-header {{ background-color: #f0f0f0; padding: 15px; border-bottom: 1px solid #ddd; }}
.email-content {{ padding: 20px; }}
.email-footer {{ background-color: #f0f0f0; padding: 15px; border-top: 1px solid #ddd; font-size: 12px; color: #666; }}
.field {{ margin-bottom: 10px; }}
.field-label {{ font-weight: bold; color: #555; }}
.email-body {{ margin-top: 20px; padding-top: 20px; border-top: 1px solid #eee; }}
</style>
</head>
<body>
<div class="email-container">
<div class="email-header">
<h2>Email Template Preview</h2>
</div>
<div class="email-content">
<div class="field">
<div class="field-label">To:</div>
<div>{emailTemplate.get('recipient', 'recipient@example.com')}</div>
</div>
<div class="field">
<div class="field-label">Subject:</div>
<div>{emailTemplate.get('subject', 'No Subject')}</div>
</div>
<div class="email-body">
{emailTemplate.get('htmlBody', '<p>No content</p>')}
</div>
</div>
<div class="email-footer">
<p>This is a preview of the email template. The actual email may appear differently in various email clients.</p>
</div>
</div>
</body>
</html>
"""
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
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()
return token_data.get("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()