# Copyright (c) 2025 Patrick Motsch # All rights reserved. import logging import json import base64 import requests from typing import Dict, Any from modules.datamodels.datamodelChat import ActionResult, ActionDocument logger = logging.getLogger(__name__) async def composeAndDraftEmailWithContext(self, parameters: Dict[str, Any]) -> ActionResult: try: connectionReference = parameters.get("connectionReference") to = parameters.get("to") or [] # Optional for drafts - can save draft without recipients context = parameters.get("context") documentList = parameters.get("documentList") or [] cc = parameters.get("cc") or [] bcc = parameters.get("bcc") or [] emailStyle = parameters.get("emailStyle") or "business" maxLength = parameters.get("maxLength") or 1000 # Direct content from upstream (e.g. AI node): skip internal AI, use subject/body/to directly email_content = parameters.get("emailContent") if isinstance(email_content, dict): direct_subject = email_content.get("subject") direct_body = email_content.get("body") direct_to = email_content.get("to") if direct_subject and direct_body: subject = str(direct_subject).strip() body = str(direct_body).strip() to = [direct_to] if isinstance(direct_to, str) else (direct_to or []) if isinstance(to, str): to = [to] ai_attachments = [] # Jump to create-email section (see below) else: direct_subject = parameters.get("subject") direct_body = parameters.get("body") if direct_subject and direct_body: subject = str(direct_subject).strip() body = str(direct_body).strip() if isinstance(to, str): to = [to] ai_attachments = [] else: subject = None body = None ai_attachments = None use_direct_content = bool(subject and body) if not use_direct_content: # Original path: require connectionReference and context if not connectionReference or not context: return ActionResult.isFailure(error="connectionReference and context are required") elif not connectionReference: return ActionResult.isFailure(error="connectionReference is required") # Convert single values to lists for all recipient parameters if isinstance(to, str): to = [to] if isinstance(cc, str): cc = [cc] if isinstance(bcc, str): bcc = [bcc] if isinstance(documentList, str): documentList = [documentList] # Get Microsoft connection connection = self.connection.getMicrosoftConnection(connectionReference) if not connection: return ActionResult.isFailure(error="No valid Microsoft connection found") # Check permissions permissions_ok = await self.connection.checkPermissions(connection) if not permissions_ok: return ActionResult.isFailure(error="Connection lacks necessary permissions for Outlook operations") # Prepare documents for AI processing (only when using AI path) from modules.datamodels.datamodelDocref import DocumentReferenceList chatDocuments = [] if not use_direct_content and documentList: # Convert to DocumentReferenceList if needed if isinstance(documentList, DocumentReferenceList): docRefList = documentList elif isinstance(documentList, list): docRefList = DocumentReferenceList.from_string_list(documentList) elif isinstance(documentList, str): docRefList = DocumentReferenceList.from_string_list([documentList]) else: docRefList = DocumentReferenceList(references=[]) chatDocuments = self.services.chat.getChatDocumentsFromDocumentList(docRefList) if not use_direct_content: # Create AI prompt for email composition # Build document reference list for AI with expanded list contents when possible doc_references = documentList doc_list_text = "" if doc_references: lines = ["Available_Document_References:"] for ref in doc_references: # Each item is a label: resolve to its document list and render contained items from modules.datamodels.datamodelDocref import DocumentReferenceList list_docs = self.services.chat.getChatDocumentsFromDocumentList(DocumentReferenceList.from_string_list([ref])) or [] if list_docs: for d in list_docs: doc_ref_label = self.services.chat.getDocumentReferenceFromChatDocument(d) lines.append(f"- {doc_ref_label}") else: lines.append(" - (no documents)") doc_list_text = "\n" + "\n".join(lines) else: doc_list_text = "Available_Document_References: (No documents available for attachment)" # Escape only the user-controlled context to prevent prompt injection escaped_context = context.replace('"', '\\"').replace('\n', '\\n').replace('\r', '\\r') # Build recipients text for prompt recipients_text = f"Recipients: {to}" if to else "Recipients: (not specified - this is a draft)" ai_prompt = f"""Compose an email based on this context: ------- {escaped_context} ------- {recipients_text} Style: {emailStyle} Max length: {maxLength} characters {doc_list_text} Based on the context, decide which documents to attach. CRITICAL: Use EXACT document references from Available_Document_References above. For individual documents: ALWAYS use docItem:: format (include filename) Return JSON: {{ "subject": "subject line", "body": "email body (HTML allowed)", "attachments": ["docItem::"] }} """ # Call AI service to generate email content try: ai_response = await self.services.ai.callAiPlanning( prompt=ai_prompt, placeholders=None, debugType="email_composition" ) # Parse AI response try: ai_content = ai_response # Extract JSON from AI response if "```json" in ai_content: json_start = ai_content.find("```json") + 7 json_end = ai_content.find("```", json_start) json_content = ai_content[json_start:json_end].strip() elif "{" in ai_content and "}" in ai_content: json_start = ai_content.find("{") json_end = ai_content.rfind("}") + 1 json_content = ai_content[json_start:json_end] else: json_content = ai_content email_data = json.loads(json_content) subject = email_data.get("subject", "") body = email_data.get("body", "") ai_attachments = email_data.get("attachments", []) if not subject or not body: return ActionResult.isFailure(error="AI did not generate valid subject and body") # Use AI-selected attachments if provided, otherwise use all documents normalized_ai_attachments = [] if documentList: try: available_refs = [documentList] if isinstance(documentList, str) else documentList from modules.datamodels.datamodelDocref import DocumentReferenceList available_docs = self.services.chat.getChatDocumentsFromDocumentList(DocumentReferenceList.from_string_list(available_refs)) or [] except Exception: available_docs = [] # Normalize AI attachments to a list of strings if isinstance(ai_attachments, str): ai_attachments = [ai_attachments] elif isinstance(ai_attachments, list): ai_attachments = [a for a in ai_attachments if isinstance(a, str)] if ai_attachments: try: ai_refs = [ai_attachments] if isinstance(ai_attachments, str) else ai_attachments from modules.datamodels.datamodelDocref import DocumentReferenceList ai_docs = self.services.chat.getChatDocumentsFromDocumentList(DocumentReferenceList.from_string_list(ai_refs)) or [] except Exception: ai_docs = [] # Intersect by document id available_ids = {getattr(d, 'id', None) for d in available_docs} selected_docs = [d for d in ai_docs if getattr(d, 'id', None) in available_ids] if selected_docs: # Map selected ChatDocuments back to docItem references (with full filename) documentList = [self.services.chat.getDocumentReferenceFromChatDocument(d) for d in selected_docs] # Normalize ai_attachments to full format for storage normalized_ai_attachments = documentList.copy() logger.info(f"AI selected {len(documentList)} documents for attachment (resolved via ChatDocuments)") else: # No intersection; use all available documents documentList = [self.services.chat.getDocumentReferenceFromChatDocument(d) for d in available_docs] normalized_ai_attachments = documentList.copy() logger.warning("AI selected attachments not found in available documents, using all documents") else: # No AI selection; use all available documents documentList = [self.services.chat.getDocumentReferenceFromChatDocument(d) for d in available_docs] normalized_ai_attachments = documentList.copy() logger.warning("AI did not specify attachments, using all available documents") else: logger.info("No documents provided in documentList; skipping attachment processing") except json.JSONDecodeError as e: logger.error(f"Failed to parse AI response as JSON: {str(e)}") logger.error(f"AI response content: {ai_response}") return ActionResult.isFailure(error="AI response was not valid JSON format") except Exception as e: logger.error(f"Error calling AI service: {str(e)}") return ActionResult.isFailure(error=f"Failed to generate email content: {str(e)}") # Now create the email with AI-generated content try: graph_url = "https://graph.microsoft.com/v1.0" headers = { "Authorization": f"Bearer {connection['accessToken']}", "Content-Type": "application/json" } # Clean and format body content cleaned_body = body.strip() # Check if body is already HTML if cleaned_body.startswith('') or cleaned_body.startswith('') or '
' in cleaned_body: html_body = cleaned_body else: # Convert plain text to proper HTML formatting html_body = cleaned_body.replace('\n', '
') html_body = f"{html_body}" # Build the email message message = { "subject": subject, "body": { "contentType": "HTML", "content": html_body }, "toRecipients": [{"emailAddress": {"address": email}} for email in to], "ccRecipients": [{"emailAddress": {"address": email}} for email in cc] if cc else [], "bccRecipients": [{"emailAddress": {"address": email}} for email in bcc] if bcc else [] } # Add documents as attachments if provided # Supports: 1) inline ActionDocuments (dict with documentData from e.g. sharepoint.downloadFile) # 2) docItem:... references (chat workflow documents) if documentList: message["attachments"] = [] for attachment_ref in documentList: base64_content = None attach_name = "attachment" attach_mime = "application/octet-stream" # Inline document: dict/object with documentData (from automation2 upstream, e.g. sharepoint.downloadFile) is_inline = isinstance(attachment_ref, dict) and attachment_ref.get("documentData") if not is_inline and hasattr(attachment_ref, "documentData"): is_inline = bool(getattr(attachment_ref, "documentData", None)) if is_inline: doc = attachment_ref base64_content = doc.get("documentData") if isinstance(doc, dict) else getattr(doc, "documentData", None) attach_name = (doc.get("documentName") or doc.get("fileName")) if isinstance(doc, dict) else (getattr(doc, "documentName", None) or getattr(doc, "fileName", "attachment")) attach_mime = (doc.get("mimeType") or attach_mime) if isinstance(doc, dict) else (getattr(doc, "mimeType", None) or attach_mime) if base64_content and attach_name: message["attachments"].append({ "@odata.type": "#microsoft.graph.fileAttachment", "name": attach_name, "contentType": attach_mime, "contentBytes": base64_content }) continue # fileId in validationMetadata: resolve via getFileData (avoids large base64 in pipeline) file_id = None if isinstance(attachment_ref, dict): vm = attachment_ref.get("validationMetadata") or {} file_id = vm.get("fileId") elif hasattr(attachment_ref, "validationMetadata"): vm = getattr(attachment_ref, "validationMetadata") or {} file_id = vm.get("fileId") if isinstance(vm, dict) else None if file_id: try: file_content = self.services.chat.getFileData(file_id) if file_content: base64_content = base64.b64encode(file_content if isinstance(file_content, bytes) else str(file_content).encode("utf-8")).decode("utf-8") name = (attachment_ref.get("documentName") or attachment_ref.get("fileName", "attachment")) if isinstance(attachment_ref, dict) else (getattr(attachment_ref, "documentName", None) or getattr(attachment_ref, "fileName", "attachment")) mime = (attachment_ref.get("mimeType") or attach_mime) if isinstance(attachment_ref, dict) else (getattr(attachment_ref, "mimeType", None) or attach_mime) message["attachments"].append({ "@odata.type": "#microsoft.graph.fileAttachment", "name": name, "contentType": mime, "contentBytes": base64_content }) continue except Exception as e: logger.warning("Could not load file %s for attachment: %s", file_id, e) # docItem:... reference (chat workflow) – only when it's a string ref attachment_docs = [] if isinstance(attachment_ref, str) and attachment_ref.strip(): from modules.datamodels.datamodelDocref import DocumentReferenceList attachment_docs = self.services.chat.getChatDocumentsFromDocumentList(DocumentReferenceList.from_string_list([attachment_ref])) if attachment_docs: for doc in attachment_docs: fid = getattr(doc, 'fileId', None) if fid: try: file_content = self.services.chat.getFileData(fid) if file_content: cb = file_content if isinstance(file_content, bytes) else str(file_content).encode('utf-8') message["attachments"].append({ "@odata.type": "#microsoft.graph.fileAttachment", "name": doc.fileName, "contentType": doc.mimeType or "application/octet-stream", "contentBytes": base64.b64encode(cb).decode('utf-8') }) except Exception as e: logger.error("Error reading attachment file %s: %s", doc.fileName, e) # Create the draft message drafts_folder_id = self.folderManagement.getFolderId("Drafts", connection) if drafts_folder_id: api_url = f"{graph_url}/me/mailFolders/{drafts_folder_id}/messages" else: api_url = f"{graph_url}/me/messages" logger.warning("Could not find Drafts folder, creating draft in default location") response = requests.post(api_url, headers=headers, json=message) if response.status_code in [200, 201]: draft_data = response.json() draft_id = draft_data.get("id", "Unknown") # Create draft result data with full draft information draftResultData = { "status": "draft", "message": "Email draft created successfully with AI-generated content", "draftId": draft_id, "folder": "Drafts (Entwürfe)", "mailbox": connection.get('userEmail', 'Unknown'), "subject": subject, "body": body, "recipients": to, "cc": cc, "bcc": bcc, "attachments": len(documentList) if documentList else 0, "aiSelectedAttachments": normalized_ai_attachments if normalized_ai_attachments else "all documents", "aiGenerated": True, "context": context, "emailStyle": emailStyle, "timestamp": self.services.utils.timestampGetUtc(), "draftData": draft_data } # Extract attachment filenames for validation metadata attachmentFilenames = [] attachmentReferences = [] if documentList: # Inline docs (dict with documentName): use directly string_refs = [r for r in documentList if isinstance(r, str)] inline_docs = [r for r in documentList if isinstance(r, dict)] for d in inline_docs: name = d.get("documentName") or d.get("fileName") if name: attachmentFilenames.append(name) if string_refs: try: from modules.datamodels.datamodelDocref import DocumentReferenceList attached_docs = self.services.chat.getChatDocumentsFromDocumentList(DocumentReferenceList.from_string_list(string_refs)) or [] attachmentFilenames.extend(getattr(doc, 'fileName', '') for doc in attached_docs if getattr(doc, 'fileName', None)) attachmentReferences = normalized_ai_attachments if normalized_ai_attachments else [self.services.chat.getDocumentReferenceFromChatDocument(d) for d in attached_docs] except Exception: pass # Create validation metadata for content validator validationMetadata = { "actionType": "outlook.composeAndDraftEmailWithContext", "emailRecipients": to, "emailCc": cc, "emailBcc": bcc, "emailSubject": subject, "emailAttachments": attachmentFilenames, "emailAttachmentReferences": attachmentReferences, "emailAttachmentCount": len(attachmentFilenames), "emailStyle": emailStyle, "hasAttachments": len(attachmentFilenames) > 0 } return ActionResult( success=True, documents=[ActionDocument( documentName=f"ai_generated_email_draft_{self._format_timestamp_for_filename()}.json", documentData=json.dumps(draftResultData, indent=2), mimeType="application/json", validationMetadata=validationMetadata )] ) else: logger.error(f"Failed to create draft. Status: {response.status_code}, Response: {response.text}") return ActionResult.isFailure(error=f"Failed to create email draft: {response.status_code} - {response.text}") except Exception as e: logger.error(f"Error creating email via Microsoft Graph API: {str(e)}") return ActionResult.isFailure(error=f"Failed to create email: {str(e)}") except Exception as e: logger.error(f"Error in composeAndDraftEmailWithContext: {str(e)}") return ActionResult.isFailure(error=str(e))