"""
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')}
To:
{emailTemplate.get('recipient', 'recipient@example.com')}
Subject:
{emailTemplate.get('subject', 'No Subject')}
{emailTemplate.get('htmlBody', '
No content
')}
"""
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()