552 lines
31 KiB
Python
552 lines
31 KiB
Python
# 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 []
|
||
replySourceDocuments = parameters.get("replySourceDocuments") or [] # Original email(s) for reply attachment
|
||
# ``attachments`` (added in 2026-04 for the PWG pilot) is a list of
|
||
# explicit attachment specs that bypass the AI selection step.
|
||
# Supported shapes per item:
|
||
# { name, mimeType, base64Content } – inline bytes (already base64)
|
||
# { name, mimeType, contentRef } – upstream wire variable name
|
||
# from ``parameters[contentRef]``
|
||
# (e.g. ``csv`` produced by
|
||
# ``data.consolidate``)
|
||
# { name, csvFromVariable } – shorthand for CSV ref
|
||
attachmentSpecs = parameters.get("attachments") or []
|
||
if isinstance(attachmentSpecs, dict):
|
||
attachmentSpecs = [attachmentSpecs]
|
||
if not isinstance(attachmentSpecs, list):
|
||
attachmentSpecs = []
|
||
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 = []
|
||
normalized_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 = []
|
||
normalized_ai_attachments = []
|
||
else:
|
||
subject = None
|
||
body = None
|
||
ai_attachments = None
|
||
|
||
use_direct_content = bool(subject and body)
|
||
|
||
# Ensure subject/body are strings (not bytes) for JSON serialization
|
||
if subject and isinstance(subject, bytes):
|
||
subject = subject.decode("utf-8", errors="replace")
|
||
if body and isinstance(body, bytes):
|
||
body = body.decode("utf-8", errors="replace")
|
||
|
||
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:<documentId>:<filename> format (include filename)
|
||
|
||
Return JSON:
|
||
{{
|
||
"subject": "subject line",
|
||
"body": "email body (HTML allowed)",
|
||
"attachments": ["docItem:<documentId>:<filename>"]
|
||
}}
|
||
"""
|
||
|
||
# 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('<html>') or cleaned_body.startswith('<body>') or '<br>' in cleaned_body:
|
||
html_body = cleaned_body
|
||
else:
|
||
# Convert plain text to proper HTML formatting
|
||
html_body = cleaned_body.replace('\n', '<br>')
|
||
html_body = f"<html><body>{html_body}</body></html>"
|
||
|
||
# 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)
|
||
# 3) replySourceDocuments: original email(s) for reply – attach when use_direct_content
|
||
# 4) explicit attachment specs from the new ``attachments`` parameter
|
||
# When use_direct_content: upstream AI doc IS the email body – do not attach it, BUT attach reply sources
|
||
attachments_doc_list = (replySourceDocuments or []) if use_direct_content else (documentList or [])
|
||
# Materialize explicit attachment specs into inline ActionDocument-shaped dicts
|
||
for spec in attachmentSpecs:
|
||
resolved = _resolveAttachmentSpec(spec, parameters)
|
||
if resolved is not None:
|
||
attachments_doc_list = list(attachments_doc_list) + [resolved]
|
||
if attachments_doc_list:
|
||
message["attachments"] = []
|
||
for attachment_ref in attachments_doc_list:
|
||
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
|
||
raw_data = doc.get("documentData") if isinstance(doc, dict) else getattr(doc, "documentData", None)
|
||
vm = doc.get("validationMetadata") or {} if isinstance(doc, dict) else (getattr(doc, "validationMetadata") or {})
|
||
action_type = vm.get("actionType", "") if isinstance(vm, dict) else ""
|
||
# Reply source: email search/read result – convert first email to .eml for proper reply attachment
|
||
if "outlook" in action_type.lower() and "email" in action_type.lower() and raw_data:
|
||
try:
|
||
data = json.loads(raw_data) if isinstance(raw_data, str) else raw_data
|
||
emails_list = []
|
||
if isinstance(data, dict):
|
||
sr = data.get("searchResults") or {}
|
||
emails_list = sr.get("results", []) if isinstance(sr, dict) else []
|
||
if not emails_list:
|
||
ed = data.get("emails") or {}
|
||
emails_list = ed.get("emails", []) if isinstance(ed, dict) else []
|
||
if not emails_list and isinstance(data.get("emails"), list):
|
||
emails_list = data["emails"]
|
||
if emails_list and isinstance(emails_list[0], dict):
|
||
em = emails_list[0]
|
||
fr = em.get("from", em.get("sender", {}))
|
||
addr = fr.get("emailAddress", {}) if isinstance(fr, dict) else {}
|
||
from_addr = addr.get("address", "") or addr.get("name", "")
|
||
subj = em.get("subject", "")
|
||
body_obj = em.get("body") or {}
|
||
body_content = body_obj.get("content", "") if isinstance(body_obj, dict) else str(body_obj)
|
||
eml_lines = [
|
||
f"From: {from_addr}",
|
||
f"Subject: {subj}",
|
||
"MIME-Version: 1.0",
|
||
"Content-Type: text/html; charset=utf-8",
|
||
"",
|
||
body_content or "(no content)"
|
||
]
|
||
eml_bytes = "\n".join(eml_lines).encode("utf-8")
|
||
base64_content = base64.b64encode(eml_bytes).decode("utf-8")
|
||
attach_name = f"original_message_{subj[:30].replace(' ', '_') if subj else 'email'}.eml"
|
||
attach_mime = "message/rfc822"
|
||
except Exception as e:
|
||
logger.debug("Could not convert email JSON to .eml: %s", e)
|
||
base64_content = raw_data
|
||
attach_name = (doc.get("documentName") or doc.get("fileName") or "attachment") 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)
|
||
else:
|
||
base64_content = raw_data
|
||
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:
|
||
# Microsoft Graph expects contentBytes as base64 string; documentData may be bytes (e.g. from ai.generateDocument)
|
||
if isinstance(base64_content, bytes):
|
||
base64_content = base64.b64encode(base64_content).decode("utf-8")
|
||
elif not isinstance(base64_content, str):
|
||
base64_content = base64.b64encode(str(base64_content).encode("utf-8")).decode("utf-8")
|
||
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(message.get("attachments", [])),
|
||
"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 attachments_doc_list:
|
||
# Inline docs (dict with documentName): use directly
|
||
string_refs = [r for r in attachments_doc_list if isinstance(r, str)]
|
||
inline_docs = [r for r in attachments_doc_list 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))
|
||
|
||
|
||
def _resolveAttachmentSpec(spec: Any, parameters: Dict[str, Any]) -> Any:
|
||
"""Resolve one attachment-spec dict into an inline-document shape that the
|
||
existing attachment loop already understands ({documentName, documentData,
|
||
mimeType}).
|
||
|
||
Three input shapes are supported:
|
||
|
||
- ``{name, mimeType, base64Content}``: inline bytes already encoded as
|
||
base64 — used by the agent when synthesising small text attachments.
|
||
- ``{name, mimeType, contentRef}``: pull the bytes from another
|
||
parameter on this node call (i.e. an upstream wire variable, e.g.
|
||
``contentRef='csv'`` reads ``parameters['csv']``).
|
||
- ``{name, csvFromVariable}``: shorthand for the most common case — the
|
||
CSV produced by ``data.consolidate`` arriving via wire.
|
||
|
||
Returns ``None`` for malformed specs (logged) so a single bad spec does
|
||
not abort the whole email draft.
|
||
"""
|
||
if not isinstance(spec, dict):
|
||
return None
|
||
name = spec.get("name") or spec.get("fileName") or "attachment.bin"
|
||
mimeType = spec.get("mimeType") or spec.get("contentType")
|
||
|
||
raw = None
|
||
if "base64Content" in spec and spec.get("base64Content"):
|
||
raw = spec.get("base64Content")
|
||
elif spec.get("csvFromVariable"):
|
||
raw = parameters.get(spec["csvFromVariable"])
|
||
if not mimeType:
|
||
mimeType = "text/csv"
|
||
if not name.lower().endswith(".csv"):
|
||
name = f"{name}.csv"
|
||
elif spec.get("contentRef"):
|
||
raw = parameters.get(spec["contentRef"])
|
||
|
||
if raw is None or raw == "":
|
||
logger.warning("email.draftEmail: attachment spec %r resolved to empty content, skipping", name)
|
||
return None
|
||
|
||
return {
|
||
"documentName": name,
|
||
"documentData": raw,
|
||
"mimeType": mimeType or "application/octet-stream",
|
||
}
|
||
|