Repository: obot-platform/tools
Pull request #525: Enhance: Add ability to create an draft to reply to a message
Author: login: StrongMonkey
Created: 2025-03-21T20:18:57Z | Merged: 2025-03-24T17:27:22Z

Add abilities to create a draft message to reply to an email.

https://github.com/obot-platform/obot/issues/2251


Diff:
diff --git a/outlook/mail/main.go b/outlook/mail/main.go
index 8ecee88bd..59eef7cb5 100644
--- a/outlook/mail/main.go
+++ b/outlook/mail/main.go
@@ -121,7 +121,6 @@ func main() {
 	}
 }
 
-
 func smartSplit(s, sep string) []string {
 	if s == "" {
 		return []string{} // Return an empty slice if the input is empty
@@ -129,7 +128,6 @@ func smartSplit(s, sep string) []string {
 	return strings.Split(s, sep)
 }
 
-
 func getDraftInfoFromEnv() graph.DraftInfo {
 	var attachments []string
 	if os.Getenv("ATTACHMENTS") != "" {
@@ -137,12 +135,14 @@ func getDraftInfoFromEnv() graph.DraftInfo {
 	}
 
 	info := graph.DraftInfo{
-		Subject:     os.Getenv("SUBJECT"),
-		Body:        os.Getenv("BODY"),
-		Recipients:  smartSplit(os.Getenv("RECIPIENTS"), ","),
-		CC:          smartSplit(os.Getenv("CC"), ","),
-		BCC:         smartSplit(os.Getenv("BCC"), ","),
-		Attachments: attachments,
+		Subject:          os.Getenv("SUBJECT"),
+		Body:             os.Getenv("BODY"),
+		Recipients:       smartSplit(os.Getenv("RECIPIENTS"), ","),
+		CC:               smartSplit(os.Getenv("CC"), ","),
+		BCC:              smartSplit(os.Getenv("BCC"), ","),
+		Attachments:      attachments,
+		ReplyAll:         os.Getenv("REPLY_ALL") == "true",
+		ReplyToMessageID: os.Getenv("REPLY_MESSAGE_ID"),
 	}
 
 	// We need to unset BODY, because if it's still set when we try to write files to the workspace,
diff --git a/outlook/mail/pkg/commands/createDraft.go b/outlook/mail/pkg/commands/createDraft.go
index 441f279d3..9020d212b 100644
--- a/outlook/mail/pkg/commands/createDraft.go
+++ b/outlook/mail/pkg/commands/createDraft.go
@@ -9,6 +9,7 @@ import (
 	"github.com/gptscript-ai/tools/outlook/mail/pkg/global"
 	"github.com/gptscript-ai/tools/outlook/mail/pkg/graph"
 	"github.com/gptscript-ai/tools/outlook/mail/pkg/util"
+	"github.com/microsoftgraph/msgraph-sdk-go/models"
 )
 
 func CreateDraft(ctx context.Context, info graph.DraftInfo) error {
@@ -17,9 +18,17 @@ func CreateDraft(ctx context.Context, info graph.DraftInfo) error {
 		return fmt.Errorf("failed to create client: %w", err)
 	}
 
-	draft, err := graph.CreateDraft(ctx, c, info)
-	if err != nil {
-		return fmt.Errorf("failed to create draft: %w", err)
+	var draft models.Messageable
+	if info.ReplyToMessageID != "" {
+		draft, err = graph.CreateDraftReply(ctx, c, info)
+		if err != nil {
+			return fmt.Errorf("failed to create draft reply: %w", err)
+		}
+	} else {
+		draft, err = graph.CreateDraft(ctx, c, info)
+		if err != nil {
+			return fmt.Errorf("failed to create draft: %w", err)
+		}
 	}
 
 	// Get numerical ID for the draft
diff --git a/outlook/mail/pkg/graph/mail.go b/outlook/mail/pkg/graph/mail.go
index f1c34de72..68be7527d 100644
--- a/outlook/mail/pkg/graph/mail.go
+++ b/outlook/mail/pkg/graph/mail.go
@@ -12,6 +12,7 @@ import (
 	"github.com/gomarkdown/markdown/html"
 	"github.com/gomarkdown/markdown/parser"
 	"github.com/gptscript-ai/go-gptscript"
+	"github.com/gptscript-ai/tools/outlook/common/id"
 	"github.com/gptscript-ai/tools/outlook/mail/pkg/util"
 	abstractions "github.com/microsoft/kiota-abstractions-go"
 	msgraphsdkgo "github.com/microsoftgraph/msgraph-sdk-go"
@@ -125,6 +126,8 @@ type DraftInfo struct {
 	Subject, Body       string
 	Recipients, CC, BCC []string // slice of email addresses
 	Attachments         []string // slice of workspace file paths
+	ReplyAll            bool
+	ReplyToMessageID    string
 }
 
 var (
@@ -176,6 +179,52 @@ func CreateDraft(ctx context.Context, client *msgraphsdkgo.GraphServiceClient, i
 	return draft, nil
 }
 
+func CreateDraftReply(ctx context.Context, client *msgraphsdkgo.GraphServiceClient, info DraftInfo) (models.Messageable, error) {
+	trueMessageID, err := id.GetOutlookID(ctx, info.ReplyToMessageID)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get message ID: %w", err)
+	}
+
+	toReplyMessageDetail, err := client.Me().Messages().ByMessageId(trueMessageID).Get(ctx, nil)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get message: %w", err)
+	}
+
+	requestBody := models.NewMessage()
+	requestBody.SetIsDraft(util.Ptr(true))
+	requestBody.SetSubject(util.Ptr(info.Subject))
+	requestBody.SetToRecipients(emailAddressesToRecipientable(info.Recipients))
+
+	if len(info.CC) > 0 {
+		requestBody.SetCcRecipients(emailAddressesToRecipientable(info.CC))
+	}
+
+	if len(info.BCC) > 0 {
+		requestBody.SetBccRecipients(emailAddressesToRecipientable(info.BCC))
+	}
+
+	for _, file := range info.Attachments {
+		if file == "" {
+			return nil, fmt.Errorf("attachment file path cannot be empty")
+		}
+	}
+
+	replyRequestBody := users.NewItemMessagesItemCreateReplyPostRequestBody()
+	replyRequestBody.SetMessage(requestBody)
+	replyRequestBody.SetComment(util.Ptr(strings.ReplaceAll(info.Body, "\n", "<br>")))
+
+	if info.ReplyAll {
+		requestBody.SetCcRecipients(toReplyMessageDetail.GetCcRecipients())
+		reply, err := client.Me().Messages().ByMessageId(trueMessageID).CreateReplyAll().Post(ctx, replyRequestBody, nil)
+		if err != nil {
+			return nil, fmt.Errorf("failed to create reply all: %w", err)
+		}
+		return reply, nil
+	}
+
+	return client.Me().Messages().ByMessageId(trueMessageID).CreateReply().Post(ctx, replyRequestBody, nil)
+}
+
 func emailAddressesToRecipientable(addresses []string) []models.Recipientable {
 	var recipients []models.Recipientable
 	for _, address := range addresses {
diff --git a/outlook/mail/tool.gpt b/outlook/mail/tool.gpt
index a68a20f1a..d89b5e677 100644
--- a/outlook/mail/tool.gpt
+++ b/outlook/mail/tool.gpt
@@ -69,6 +69,8 @@ Param: recipients: A comma-separated list of email addresses to send the message
 Param: cc: (Optional) A comma-separated list of email addresses to CC on the message. No spaces. Example: person1@example.com,person2@example.com
 Param: bcc: (Optional) A comma-separated list of email addresses to BCC on the message. No spaces. Example: person1@example.com,person2@example.com
 Param: attachments: (Optional) A comma separated list of workspace file paths to attach to the email.
+Param: reply_message_id: (Optional) The ID of the message to reply to.
+Param: reply_all: (Optional, default false) Whether to reply to all. If true, CC will be the original message's CC.
 
 #!${GPTSCRIPT_TOOL_DIR}/bin/gptscript-go-tool createDraft
 


Reviews:
user: login: njhale
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-21T20:32:39Z
user: login: njhale
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-21T20:33:05Z
user: login: StrongMonkey
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T20:35:38Z
user: login: tybalex
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-24T17:25:49Z

Review comments:
diff_hunk: @@ -121,26 +136,62 @@ func main() {
 	}
 }
 
-
 func smartSplit(s, sep string) []string {
 	if s == "" {
 		return []string{} // Return an empty slice if the input is empty
 	}
 	return strings.Split(s, sep)
 }
 
-
-func getDraftInfoFromEnv() graph.DraftInfo {
+func getDraftInfoFromEnv(ctx context.Context) (graph.DraftInfo, error) {
 	var attachments []string
 	if os.Getenv("ATTACHMENTS") != "" {
 		attachments = smartSplit(os.Getenv("ATTACHMENTS"), ",")
 	}
 
+	body := os.Getenv("BODY")
+	replyMessageID := os.Getenv("REPLY_MESSAGE_ID")
+	replyAll := os.Getenv("REPLY_ALL") == "true"
+	cc := os.Getenv("CC")
+
+	if replyMessageID != "" {
+		trueMessageID, err := id.GetOutlookID(ctx, replyMessageID)
+		if err != nil {
+			return graph.DraftInfo{}, fmt.Errorf("failed to get true message ID: %w", err)
+		}
+
+		c, err := client.NewClient(global.ReadOnlyScopes)
+		if err != nil {
+			return graph.DraftInfo{}, fmt.Errorf("failed to create client: %w", err)
+		}
+
+		result, err := graph.GetMessageDetails(ctx, c, trueMessageID)
+		if err != nil {
+			return graph.DraftInfo{}, fmt.Errorf("failed to get message details: %w", err)
+		}
+
+		// Format reply in Outlook web client style
+		originalMessage := fmt.Sprintf("<br><br>**-------- Original Message --------**  <br><br>**From:** %s  <br>**Sent:** %s  <br>**To:** %s  <br>**Subject:** %s  <br><br>%s  <br><br>",
+			*result.GetFrom().GetEmailAddress().GetAddress(),
+			result.GetReceivedDateTime().Format("Monday, January 2, 2006 3:04 PM"),
+			*result.GetToRecipients()[0].GetEmailAddress().GetAddress(),
+			*result.GetSubject(),
+			*result.GetBodyPreview())
path: outlook/mail/main.go
user: login: njhale
body: Should this contain the full body? or is the preview enough?
created_at: 2025-03-21T20:33:05Z
author_association: MEMBER
diff_hunk: @@ -121,26 +136,62 @@ func main() {
 	}
 }
 
-
 func smartSplit(s, sep string) []string {
 	if s == "" {
 		return []string{} // Return an empty slice if the input is empty
 	}
 	return strings.Split(s, sep)
 }
 
-
-func getDraftInfoFromEnv() graph.DraftInfo {
+func getDraftInfoFromEnv(ctx context.Context) (graph.DraftInfo, error) {
 	var attachments []string
 	if os.Getenv("ATTACHMENTS") != "" {
 		attachments = smartSplit(os.Getenv("ATTACHMENTS"), ",")
 	}
 
+	body := os.Getenv("BODY")
+	replyMessageID := os.Getenv("REPLY_MESSAGE_ID")
+	replyAll := os.Getenv("REPLY_ALL") == "true"
+	cc := os.Getenv("CC")
+
+	if replyMessageID != "" {
+		trueMessageID, err := id.GetOutlookID(ctx, replyMessageID)
+		if err != nil {
+			return graph.DraftInfo{}, fmt.Errorf("failed to get true message ID: %w", err)
+		}
+
+		c, err := client.NewClient(global.ReadOnlyScopes)
+		if err != nil {
+			return graph.DraftInfo{}, fmt.Errorf("failed to create client: %w", err)
+		}
+
+		result, err := graph.GetMessageDetails(ctx, c, trueMessageID)
+		if err != nil {
+			return graph.DraftInfo{}, fmt.Errorf("failed to get message details: %w", err)
+		}
+
+		// Format reply in Outlook web client style
+		originalMessage := fmt.Sprintf("<br><br>**-------- Original Message --------**  <br><br>**From:** %s  <br>**Sent:** %s  <br>**To:** %s  <br>**Subject:** %s  <br><br>%s  <br><br>",
+			*result.GetFrom().GetEmailAddress().GetAddress(),
+			result.GetReceivedDateTime().Format("Monday, January 2, 2006 3:04 PM"),
+			*result.GetToRecipients()[0].GetEmailAddress().GetAddress(),
+			*result.GetSubject(),
+			*result.GetBodyPreview())
path: outlook/mail/main.go
user: login: StrongMonkey
body: I think preview is probably fine now... can tweak that later
created_at: 2025-03-21T20:35:38Z
author_association: CONTRIBUTOR

PR comments:
user: login: tybalex
created_at: 2025-03-21T22:15:18Z
body: should we combine these APIs too? https://learn.microsoft.com/en-us/graph/api/message-createreply?view=graph-rest-1.0&tabs=go
https://learn.microsoft.com/en-us/graph/api/message-createreplyall?view=graph-rest-1.0&tabs=go

Or this is sufficient?
author_association: CONTRIBUTOR
user: login: StrongMonkey
created_at: 2025-03-21T22:17:23Z
body: @tybalex oh absolutely, I will look at what these api does.
author_association: CONTRIBUTOR
user: login: tybalex
created_at: 2025-03-21T22:21:16Z
body: > @tybalex oh absolutely, I will look at what these api does.

yeah maybe sending thru that API will result in better formats? not sure just wondering
author_association: CONTRIBUTOR
user: login: StrongMonkey
created_at: 2025-03-24T17:03:44Z
body: @tybalex thanks for pointing all, using the sdk works better. Changed to using sdk now.
author_association: CONTRIBUTOR