From 9c7dfb1ef7de0522e0db1f9b2f84c573ffa68b67 Mon Sep 17 00:00:00 2001 From: Kunal Patel Date: Fri, 2 Oct 2026 11:45:48 -0400 Subject: [PATCH] feat: attach files to email from collaboration --- .../collaboration/CollaborationPrompts.java | 5 ++ .../collaboration/EmailAttachmentFiles.java | 80 +++++++++++++++++++ ...bstractMicrosoftOutlookMessageReactor.java | 17 ++++ .../MicrosoftOutlookForwardMailReactor.java | 13 ++- .../outlook/MicrosoftOutlookMailHelper.java | 46 ++++++++--- .../MicrosoftOutlookReplyMailReactor.java | 8 +- .../WorkComposeEmailReactor.java | 41 +++++++++- .../WorkReadEmailAttachmentReactor.java | 44 ++++++++++ 8 files changed, 234 insertions(+), 20 deletions(-) create mode 100644 src/prerna/collaboration/EmailAttachmentFiles.java create mode 100644 src/prerna/reactor/collaboration/WorkReadEmailAttachmentReactor.java diff --git a/src/prerna/collaboration/CollaborationPrompts.java b/src/prerna/collaboration/CollaborationPrompts.java index 5669b97fb0..0eefbaa5e1 100644 --- a/src/prerna/collaboration/CollaborationPrompts.java +++ b/src/prerna/collaboration/CollaborationPrompts.java @@ -136,6 +136,11 @@ email with ComposeEmail (see Writing emails). Do not ask whether to proceed. If email is open yet, write it with ComposeEmail first. If they turn the send down, do \ not send again; ask what to change. - You cannot save drafts: the owner saves with Save in the editor. + - To attach files you created in this room, call ComposeEmail with attachments as \ + an array of room-relative paths, for example ["hello.txt"]. Set openEmailId when \ + adding them to the email already open; omit message to keep its text. Existing \ + attachments stay. Files appear in the editor for review and are included when the \ + owner saves or sends. Do not tell the owner to attach generated files manually in Outlook. - openEmail.status says where the email is: editing, saved, waiting (for the owner \ to press Send), or sent. Never say an email was sent or saved unless the status or \ a tool result says so. A sent email cannot change: write a new one."""; diff --git a/src/prerna/collaboration/EmailAttachmentFiles.java b/src/prerna/collaboration/EmailAttachmentFiles.java new file mode 100644 index 0000000000..8d5bdeb0b1 --- /dev/null +++ b/src/prerna/collaboration/EmailAttachmentFiles.java @@ -0,0 +1,80 @@ +package prerna.collaboration; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import prerna.reactor.agent.mcp.MCPUtility; + +/** Room-local snapshots keep the reviewed email separate from later file edits. */ +public final class EmailAttachmentFiles { + + public static final String DIRECTORY = ".email-attachments"; + // Inline Graph attachments require small files; leave room for base64 and the body. + public static final int MAX_BYTES = 2_500_000; + public static final int MAX_FILES = 10; + + private EmailAttachmentFiles() { + } + + public static byte[] read(Path path) throws IOException { + try (var input = Files.newInputStream(path)) { + byte[] bytes = input.readNBytes(MAX_BYTES + 1); + if (bytes.length > MAX_BYTES) { + throw new IllegalArgumentException("Email attachments must total at most 2.5 MB."); + } + return bytes; + } + } + + public static String digest(byte[] bytes) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } + + public static List> snapshot(String rootFolder, List paths) throws IOException { + if (paths.size() > MAX_FILES) { + throw new IllegalArgumentException("Attach at most 10 files per ComposeEmail call."); + } + // Validate and read every input before creating anything. + List sources = new ArrayList<>(); + List contents = new ArrayList<>(); + int total = 0; + for (String reference : paths) { + Path source = MCPUtility.resolveContainedMcpFile(rootFolder, reference); + byte[] bytes = read(source); + total += bytes.length; + if (total > MAX_BYTES) { + throw new IllegalArgumentException("Email attachments must total at most 2.5 MB."); + } + sources.add(source); + contents.add(bytes); + } + List> descriptors = new ArrayList<>(); + if (paths.isEmpty()) return descriptors; + Path root = Path.of(rootFolder).toRealPath(); + Path directory = Files.createDirectories(root.resolve(DIRECTORY)).toRealPath(); + if (!directory.startsWith(root)) throw new IllegalArgumentException("Invalid email attachment directory."); + for (int i = 0; i < sources.size(); i++) { + Path copy = Files.createTempDirectory(directory, "file-").resolve(sources.get(i).getFileName()); + Files.write(copy, contents.get(i)); + Map descriptor = new LinkedHashMap<>(); + descriptor.put("path", root.relativize(copy).toString().replace('\\', '/')); + descriptor.put("name", copy.getFileName().toString()); + descriptor.put("size", contents.get(i).length); + descriptor.put("sha256", digest(contents.get(i))); + descriptors.add(descriptor); + } + return descriptors; + } +} diff --git a/src/prerna/io/connector/ms/outlook/AbstractMicrosoftOutlookMessageReactor.java b/src/prerna/io/connector/ms/outlook/AbstractMicrosoftOutlookMessageReactor.java index a4f15a1e51..db5fe3c8be 100644 --- a/src/prerna/io/connector/ms/outlook/AbstractMicrosoftOutlookMessageReactor.java +++ b/src/prerna/io/connector/ms/outlook/AbstractMicrosoftOutlookMessageReactor.java @@ -34,6 +34,9 @@ import org.apache.logging.log4j.Logger; import prerna.io.connector.ms.AbstractMicrosoftReactor; +import prerna.reactor.agent.mcp.MCPUtility; +import java.io.IOException; +import java.util.Map; import prerna.sablecc2.om.GenRowStruct; import prerna.sablecc2.om.execptions.SemossPixelException; @@ -66,6 +69,20 @@ public abstract class AbstractMicrosoftOutlookMessageReactor extends AbstractMic /** How much of a body comes back before it is cut short. */ protected static final int DEFAULT_MAX_BODY_CHARS = 10_000; + /** Attachments are supported only while saving drafts, never by a direct reply/forward send. */ + protected List> draftAttachments(boolean asDraft) throws IOException { + GenRowStruct files = this.store.getGenRowStruct("attachments"); + if (files == null || files.isEmpty()) return List.of(); + if (!asDraft) throw new IllegalArgumentException("Attachments require asDraft=true."); + String[] paths = new String[files.size()]; + for (int i = 0; i < files.size(); i++) { + Object value = files.getNoun(i).getValue(); + if (!(value instanceof String)) throw new IllegalArgumentException("Attachments must be relative file paths."); + paths[i] = MCPUtility.resolveContainedMcpFile(this.insight.getInsightFolder(), (String) value).toString(); + } + return MicrosoftOutlookMailHelper.fileAttachments(paths); + } + /** * Read the message this reactor was pointed at. * diff --git a/src/prerna/io/connector/ms/outlook/MicrosoftOutlookForwardMailReactor.java b/src/prerna/io/connector/ms/outlook/MicrosoftOutlookForwardMailReactor.java index 4b3dc2fa5f..9be324f062 100644 --- a/src/prerna/io/connector/ms/outlook/MicrosoftOutlookForwardMailReactor.java +++ b/src/prerna/io/connector/ms/outlook/MicrosoftOutlookForwardMailReactor.java @@ -66,8 +66,8 @@ public class MicrosoftOutlookForwardMailReactor extends AbstractMicrosoftOutlook private static final String TO = "to"; public MicrosoftOutlookForwardMailReactor() { - this.keysToGet = new String[] { UID, TO, COMMENT, AS_DRAFT, "html" }; - this.keyRequired = new int[] { 1, 1, 0, 0, 0 }; + this.keysToGet = new String[] { UID, TO, COMMENT, AS_DRAFT, "html", "attachments" }; + this.keyRequired = new int[] { 1, 1, 0, 0, 0, 0 }; } @Override @@ -87,11 +87,15 @@ protected NounMetadata executeAuthenticated() { } try { + var attachments = draftAttachments(asDraft); User user = this.insight.getUser(); String accessToken = MicrosoftLoginUtils.getValidAccessToken(user); + MicrosoftOutlookMailHelper helper = new MicrosoftOutlookMailHelper(); Map draft = html - ? new MicrosoftOutlookMailHelper().forwardHtmlDraft(accessToken, uid, to, comment) - : new MicrosoftOutlookMailHelper().forward(accessToken, null, uid, to, comment, asDraft); + ? helper.forwardHtmlDraft(accessToken, uid, to, comment) + : helper.forward(accessToken, null, uid, to, comment, asDraft); + + helper.attachToDraft(accessToken, draft, attachments); Map output = new LinkedHashMap<>(); output.put("forwarded", uid); @@ -122,6 +126,7 @@ public String getReactorDescription() { @Override protected String getDescriptionForKey(String key) { + if ("attachments".equals(key)) return "Optional insight-relative files to add to a saved draft, preserving original attachments; requires asDraft=true."; if ("html".equals(key)) { return "Treat the authored comment as HTML when asDraft=true; defaults to false."; } diff --git a/src/prerna/io/connector/ms/outlook/MicrosoftOutlookMailHelper.java b/src/prerna/io/connector/ms/outlook/MicrosoftOutlookMailHelper.java index 3ddf86fb0e..dbf92d4a38 100644 --- a/src/prerna/io/connector/ms/outlook/MicrosoftOutlookMailHelper.java +++ b/src/prerna/io/connector/ms/outlook/MicrosoftOutlookMailHelper.java @@ -468,21 +468,45 @@ public static Map buildMessage(String subject, String body, bool putRecipients(message, "bccRecipients", bcc); if (attachments != null && attachments.length > 0) { - List> attached = new ArrayList<>(); - for (String path : attachments) { - File file = new File(path); - Map attachment = new LinkedHashMap<>(); - // the only attachment type the simple send takes inline - attachment.put("@odata.type", "#microsoft.graph.fileAttachment"); - attachment.put("name", file.getName()); - attachment.put("contentBytes", Base64.getEncoder().encodeToString(Files.readAllBytes(file.toPath()))); - attached.add(attachment); - } - message.put("attachments", attached); + message.put("attachments", fileAttachments(attachments)); } return message; } + /** Materialize files before creating a remote draft, so local failures cannot send anything. */ + public static List> fileAttachments(String[] paths) throws IOException { + List> attached = new ArrayList<>(); + if (paths == null) return attached; + for (String path : paths) { + File file = new File(path); + Map attachment = new LinkedHashMap<>(); + attachment.put("@odata.type", "#microsoft.graph.fileAttachment"); + // The editor gives uploads a UUID prefix to prevent same-name collisions. + attachment.put("name", file.getName().replaceFirst("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}-", "")); + attachment.put("contentBytes", Base64.getEncoder().encodeToString(Files.readAllBytes(file.toPath()))); + attached.add(attachment); + } + return attached; + } + + /** Add authored files without replacing the native forward's existing attachments. */ + public void attachToDraft(String accessToken, Map draft, List> files) { + if (files.isEmpty()) return; + if (draft == null || !(draft.get("id") instanceof String id) || id.isBlank()) { + throw new IllegalArgumentException("The saved draft attachment target could not be confirmed."); + } + String url = userPath(null) + "/messages/" + encode((String) draft.get("id")) + "/attachments"; + for (Map file : files) { + String response = HttpHelperUtility.postRequestStringBody(url, headers(accessToken), GSON.toJson(file), + ContentType.APPLICATION_JSON, null, null, null); + throwOnError(response, "attach a file to the draft email"); + Map receipt = readMap(response); + if (!(receipt.get("id") instanceof String attachmentId) || attachmentId.isBlank()) { + throw new IllegalArgumentException("The draft attachment could not be confirmed. Check the draft in Outlook."); + } + } + } + /** * Find messages in a mailbox. * diff --git a/src/prerna/io/connector/ms/outlook/MicrosoftOutlookReplyMailReactor.java b/src/prerna/io/connector/ms/outlook/MicrosoftOutlookReplyMailReactor.java index 12ea11ad6c..3cefdb945f 100644 --- a/src/prerna/io/connector/ms/outlook/MicrosoftOutlookReplyMailReactor.java +++ b/src/prerna/io/connector/ms/outlook/MicrosoftOutlookReplyMailReactor.java @@ -75,8 +75,8 @@ public class MicrosoftOutlookReplyMailReactor extends AbstractMicrosoftOutlookMe private static final String REPLY_ALL = "replyAll"; public MicrosoftOutlookReplyMailReactor() { - this.keysToGet = new String[] { UID, COMMENT, REPLY_ALL, AS_DRAFT, "html", "overrideRecipients", "to", "cc" }; - this.keyRequired = new int[] { 1, 1, 0, 0, 0, 0, 0, 0 }; + this.keysToGet = new String[] { UID, COMMENT, REPLY_ALL, AS_DRAFT, "html", "overrideRecipients", "to", "cc", "attachments" }; + this.keyRequired = new int[] { 1, 1, 0, 0, 0, 0, 0, 0, 0 }; } @Override @@ -100,6 +100,7 @@ protected NounMetadata executeAuthenticated() { } try { + var attachments = draftAttachments(asDraft); User user = this.insight.getUser(); String accessToken = MicrosoftLoginUtils.getValidAccessToken(user); MicrosoftOutlookMailHelper helper = new MicrosoftOutlookMailHelper(); @@ -108,6 +109,8 @@ protected NounMetadata executeAuthenticated() { : html ? helper.replyHtmlDraft(accessToken, uid, comment, replyAll) : helper.reply(accessToken, null, uid, comment, replyAll, asDraft); + helper.attachToDraft(accessToken, draft, attachments); + Map output = new LinkedHashMap<>(); output.put("repliedTo", uid); output.put(REPLY_ALL, replyAll); @@ -143,6 +146,7 @@ public String getReactorDescription() { @Override protected String getDescriptionForKey(String key) { + if ("attachments".equals(key)) return "Optional insight-relative files to add to a saved draft; requires asDraft=true."; if ("overrideRecipients".equals(key)) { return "Replace the native To and Cc lists with the supplied lists, including empty lists. Requires html=true and asDraft=true."; } diff --git a/src/prerna/reactor/collaboration/WorkComposeEmailReactor.java b/src/prerna/reactor/collaboration/WorkComposeEmailReactor.java index 94fc1c3bad..f54645fbe2 100644 --- a/src/prerna/reactor/collaboration/WorkComposeEmailReactor.java +++ b/src/prerna/reactor/collaboration/WorkComposeEmailReactor.java @@ -29,6 +29,13 @@ import java.util.LinkedHashMap; import java.util.Map; +import java.util.ArrayList; +import java.util.List; +import java.io.IOException; + +import org.json.JSONObject; +import prerna.collaboration.EmailAttachmentFiles; +import prerna.sablecc2.om.GenRowStruct; import prerna.reactor.agent.mcp.MCPUtility; import prerna.sablecc2.om.nounmeta.NounMetadata; @@ -46,10 +53,11 @@ public class WorkComposeEmailReactor extends AbstractCollaborationReactor { private static final String REPLY_TO = "replyTo"; private static final String FORWARD = "forward"; private static final String OPEN_EMAIL_ID = "openEmailId"; + private static final String ATTACHMENTS = "attachments"; public WorkComposeEmailReactor() { - this.keysToGet = new String[] { MESSAGE, TO, CC, BCC, SUBJECT, REPLY_TO, FORWARD, OPEN_EMAIL_ID }; - this.keyRequired = new int[] { 0, 0, 0, 0, 0, 0, 0, 0 }; + this.keysToGet = new String[] { MESSAGE, TO, CC, BCC, SUBJECT, REPLY_TO, FORWARD, OPEN_EMAIL_ID, ATTACHMENTS }; + this.keyRequired = new int[] { 0, 0, 0, 0, 0, 0, 0, 0, 0 }; } @Override @@ -66,6 +74,21 @@ public NounMetadata execute() { throw new IllegalArgumentException("Must pass the email as message"); } Map out = new LinkedHashMap<>(); + GenRowStruct files = this.store.getGenRowStruct(ATTACHMENTS); + if (files != null && !files.isEmpty()) { + if (this.insight.getRoomId() == null) throw new IllegalArgumentException("Open the email's room first."); + List paths = new ArrayList<>(); + for (int i = 0; i < files.size(); i++) { + Object value = files.getNoun(i).getValue(); + if (!(value instanceof String)) throw new IllegalArgumentException("Attachments must be room-relative file paths."); + paths.add((String) value); + } + try { + out.put(ATTACHMENTS, EmailAttachmentFiles.snapshot(this.insight.getInsightFolder(), paths)); + } catch (IOException e) { + throw new IllegalArgumentException("An attachment could not be prepared. Check that every file exists in this room.", e); + } + } out.put("shown", true); out.put("note", "The email is in the owner's email editor, where they can edit it. Nothing was saved or " + "sent; SendEmail sends it once they press Send."); @@ -76,6 +99,13 @@ private static boolean isBlank(String value) { return value == null || value.isBlank(); } + @Override + public JSONObject getMcpProperties() { + JSONObject properties = super.getMcpProperties(); + properties.getJSONObject(ATTACHMENTS).put("type", "array").put("items", new JSONObject().put("type", "string")); + return properties; + } + @Override public Map getMcpToolMetadata() { Map meta = super.getMcpToolMetadata(); @@ -92,7 +122,12 @@ public String getReactorDescription() { @Override protected String getDescriptionForKey(String key) { - if (MESSAGE.equals(key)) { + if (ATTACHMENTS.equals(key)) { + return "Optional array of files in this room's working directory, using relative paths such as hello.txt. " + + "Adds files to the email's attachment list for the owner to review; existing attachments stay. " + + "Use openEmailId to attach to the open email without changing its text. At most 10 files, 2.5 MB total per call. " + + "Never pass absolute paths. Nothing is sent until the owner presses Send."; + } else if (MESSAGE.equals(key)) { return "The whole email in plain text, greeting to sign-off, written as the owner. No Markdown. " + "For a forward, the short note above the forwarded email. Required, except for a forward or " + "when openEmailId is set and the text does not change."; diff --git a/src/prerna/reactor/collaboration/WorkReadEmailAttachmentReactor.java b/src/prerna/reactor/collaboration/WorkReadEmailAttachmentReactor.java new file mode 100644 index 0000000000..431d5fd5f0 --- /dev/null +++ b/src/prerna/reactor/collaboration/WorkReadEmailAttachmentReactor.java @@ -0,0 +1,44 @@ +package prerna.reactor.collaboration; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.Base64; +import java.util.Map; + +import prerna.collaboration.EmailAttachmentFiles; +import prerna.reactor.agent.mcp.MCPUtility; +import prerna.sablecc2.om.nounmeta.NounMetadata; + +/** Editor-only file transport. Not registered as an agent tool. */ +public class WorkReadEmailAttachmentReactor extends AbstractCollaborationReactor { + + public WorkReadEmailAttachmentReactor() { + this.keysToGet = new String[] { "path", "sha256" }; + this.keyRequired = new int[] { 1, 1 }; + } + + @Override + public NounMetadata execute() { + getUser(); + if (this.insight.getRoomId() == null) throw new IllegalArgumentException("Open the email's room first."); + String reference = getString("path"); + if (reference == null || !reference.startsWith(EmailAttachmentFiles.DIRECTORY + "/")) { + throw new IllegalArgumentException("Expected a prepared email attachment."); + } + try { + Path root = Path.of(this.insight.getInsightFolder()).toRealPath(); + Path file = MCPUtility.resolveContainedMcpFile(root.toString(), reference); + if (!file.startsWith(root.resolve(EmailAttachmentFiles.DIRECTORY))) { + throw new IllegalArgumentException("Expected a prepared email attachment."); + } + byte[] bytes = EmailAttachmentFiles.read(file); + if (!EmailAttachmentFiles.digest(bytes).equals(getString("sha256"))) { + throw new IllegalArgumentException("The email attachment changed. Ask the assistant to attach it again."); + } + return mapResult(Map.of("path", reference, "name", file.getFileName().toString(), "size", bytes.length, + "contentBase64", Base64.getEncoder().encodeToString(bytes))); + } catch (IOException e) { + throw new IllegalArgumentException("The email attachment could not be read. Ask the assistant to attach it again.", e); + } + } +}