Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/prerna/collaboration/CollaborationPrompts.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.""";
Expand Down
80 changes: 80 additions & 0 deletions src/prerna/collaboration/EmailAttachmentFiles.java
Original file line number Diff line number Diff line change
@@ -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<Map<String, Object>> snapshot(String rootFolder, List<String> 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<Path> sources = new ArrayList<>();
List<byte[]> 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<Map<String, Object>> 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<String, Object> 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<Map<String, Object>> 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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<String, Object> 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<String, Object> output = new LinkedHashMap<>();
output.put("forwarded", uid);
Expand Down Expand Up @@ -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.";
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -468,21 +468,45 @@ public static Map<String, Object> buildMessage(String subject, String body, bool
putRecipients(message, "bccRecipients", bcc);

if (attachments != null && attachments.length > 0) {
List<Map<String, Object>> attached = new ArrayList<>();
for (String path : attachments) {
File file = new File(path);
Map<String, Object> 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<Map<String, Object>> fileAttachments(String[] paths) throws IOException {
List<Map<String, Object>> attached = new ArrayList<>();
if (paths == null) return attached;
for (String path : paths) {
File file = new File(path);
Map<String, Object> 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<String, Object> draft, List<Map<String, Object>> 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<String, Object> 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<String, Object> 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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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();
Expand All @@ -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<String, Object> output = new LinkedHashMap<>();
output.put("repliedTo", uid);
output.put(REPLY_ALL, replyAll);
Expand Down Expand Up @@ -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.";
}
Expand Down
41 changes: 38 additions & 3 deletions src/prerna/reactor/collaboration/WorkComposeEmailReactor.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -66,6 +74,21 @@ public NounMetadata execute() {
throw new IllegalArgumentException("Must pass the email as message");
}
Map<String, Object> 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<String> 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.");
Expand All @@ -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<String, String> getMcpToolMetadata() {
Map<String, String> meta = super.getMcpToolMetadata();
Expand All @@ -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.";
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Loading