Skip to content
Open
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- `--reply-to-message-id <id>` flag for `mail drafts create`. Creates the draft as a threaded reply (`createReply`, keeping the quoted original) and leaves it in the Drafts folder for review instead of sending. `--to`/`--subject` become optional — recipients default to the original sender and are only overridden when supplied. `--cc`/`--bcc` are honoured; if setting recipients fails, the half-built draft is deleted and the deletion outcome is reported. Also adds `--cc`/`--bcc` to `mail drafts create` for the non-reply case.

## [0.3.1] - 2026-01-26

### Added
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ mog mail folders # List folders
# Drafts
mog mail drafts list
mog mail drafts create --to X --subject Y --body Z
mog mail drafts create --reply-to-message-id <id> --body Z # Threaded reply draft (review, then send); --to defaults to the original sender
mog mail drafts send <draftId>

# Attachments
Expand Down
3 changes: 2 additions & 1 deletion internal/cli/ai_help.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ mog mail send [flags]
mog mail folders # List mail folders

mog mail drafts list
mog mail drafts create [flags] # Same flags as send
mog mail drafts create [flags] # To/Subject/Body/Cc/Bcc/BodyFile
--reply-to-message-id <id> # Make a threaded reply DRAFT (kept for review, not sent)
mog mail drafts send <draftId>
mog mail drafts delete <draftId>

Expand Down
76 changes: 70 additions & 6 deletions internal/cli/mail.go
Original file line number Diff line number Diff line change
Expand Up @@ -308,10 +308,13 @@ func (c *MailDraftsListCmd) Run(root *Root) error {

// MailDraftsCreateCmd creates a draft.
type MailDraftsCreateCmd struct {
To []string `help:"Recipient(s)"`
Subject string `help:"Subject line"`
Body string `help:"Message body"`
BodyFile string `help:"Read body from file" name:"body-file"`
To []string `help:"Recipient(s)"`
Cc []string `help:"CC recipient(s)"`
Bcc []string `help:"BCC recipient(s)"`
Subject string `help:"Subject line"`
Body string `help:"Message body"`
BodyFile string `help:"Read body from file" name:"body-file"`
ReplyToMessageID string `help:"Create the draft as a threaded reply to this message ID (keeps the quoted original); --to/--subject become optional" name:"reply-to-message-id"`
}

// Run executes drafts create.
Expand All @@ -325,11 +328,24 @@ func (c *MailDraftsCreateCmd) Run(root *Root) error {
if c.BodyFile != "" {
data, err := os.ReadFile(c.BodyFile)
if err != nil {
return err
return fmt.Errorf("failed to read body file: %w", err)
}
body = string(data)
}

ctx := context.Background()

// Threaded reply draft: keep the quoted original and conversation threading,
// but leave it in Drafts for review instead of sending.
if c.ReplyToMessageID != "" {
draftID, err := c.createReplyDraft(ctx, client, graph.ResolveID(c.ReplyToMessageID), body)
if err != nil {
return err
}
fmt.Printf("✓ Reply draft created: %s\n", graph.FormatID(draftID))
return nil
}

msg := map[string]interface{}{
"subject": c.Subject,
"body": map[string]string{
Expand All @@ -338,8 +354,8 @@ func (c *MailDraftsCreateCmd) Run(root *Root) error {
},
"toRecipients": formatRecipients(c.To),
}
addRecipientsIfPresent(msg, c.Cc, c.Bcc)

ctx := context.Background()
data, err := client.Post(ctx, "/me/messages", msg)
if err != nil {
return err
Expand All @@ -354,6 +370,54 @@ func (c *MailDraftsCreateCmd) Run(root *Root) error {
return nil
}

// createReplyDraft builds a threaded reply draft and returns its ID WITHOUT
// sending it. Graph's createReply pre-fills the recipients from the original
// message and keeps the quoted original; the user's text is passed as the
// comment so it lands above the quote. Supplied --to/--cc/--bcc override or
// extend the pre-filled recipients. The draft is left in the Drafts folder for
// review. If setting recipients fails, deletion of the half-built draft is
// attempted and its outcome is reported in the error so the message never
// claims the draft was removed when it actually remains.
func (c *MailDraftsCreateCmd) createReplyDraft(ctx context.Context, client graph.Client, messageID, body string) (string, error) {
createBody := map[string]interface{}{}
if body != "" {
createBody["comment"] = body
}
data, err := client.Post(ctx, fmt.Sprintf("/me/messages/%s/createReply", messageID), createBody)
if err != nil {
return "", fmt.Errorf("failed to create reply draft for message %s: %w", graph.FormatID(messageID), err)
}
var draft Message
if err := json.Unmarshal(data, &draft); err != nil {
return "", err
}
if draft.ID == "" {
return "", fmt.Errorf("createReply returned no draft message id")
}
draftID := draft.ID

// Only override recipients the user actually supplied; otherwise keep the
// original sender that createReply already filled in.
update := map[string]interface{}{}
if len(c.To) > 0 {
update["toRecipients"] = formatRecipients(c.To)
}
addRecipientsIfPresent(update, c.Cc, c.Bcc)
if len(update) > 0 {
if _, err := client.Patch(ctx, fmt.Sprintf("/me/messages/%s", draftID), update); err != nil {
// cleanup deletes the half-built draft and describes the outcome, so a
// failed deletion is never reported as a successful removal.
cleanup := fmt.Sprintf("orphaned draft %s removed", graph.FormatID(draftID))
if delErr := client.Delete(ctx, fmt.Sprintf("/me/messages/%s", draftID)); delErr != nil {
cleanup = fmt.Sprintf("orphaned draft %s could NOT be removed (%v)", graph.FormatID(draftID), delErr)
}
return "", fmt.Errorf("failed to set recipients on reply draft (%s): %w", cleanup, err)
}
}

return draftID, nil
}

// MailDraftsSendCmd sends a draft.
type MailDraftsSendCmd struct {
ID string `arg:"" help:"Draft ID"`
Expand Down
128 changes: 128 additions & 0 deletions internal/cli/mail_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"net/url"
"os"
"path/filepath"
"strings"
"testing"

"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -553,6 +554,133 @@ func TestMailDraftsCreateCmd_Run(t *testing.T) {
}
}

func TestMailDraftsCreateCmd_Reply(t *testing.T) {
t.Run("with explicit recipient patches and does not send", func(t *testing.T) {
var postPaths []string
var createReplyBody interface{}
patchCalled := false
mock := &testutil.MockClient{
PostFunc: func(ctx context.Context, path string, body interface{}) ([]byte, error) {
postPaths = append(postPaths, path)
if strings.Contains(path, "/createReply") {
createReplyBody = body
return mustJSON(map[string]interface{}{"id": "reply-draft-123"}), nil
}
return []byte(`{}`), nil
},
PatchFunc: func(ctx context.Context, path string, body interface{}) ([]byte, error) {
patchCalled = true
return []byte(`{}`), nil
},
}
cmd := &MailDraftsCreateCmd{
To: []string{"to@example.com"},
Body: "My reply",
ReplyToMessageID: "orig-msg-123",
}
root := &Root{ClientFactory: mockClientFactory(mock)}

var err error
output := captureOutput(func() { err = cmd.Run(root) })

require.NoError(t, err)
assert.Contains(t, output, "Reply draft created")

foundCreateReply := false
for _, p := range postPaths {
assert.NotContains(t, p, "/send", "a reply DRAFT must never be sent")
if strings.Contains(p, "/createReply") {
foundCreateReply = true
}
}
assert.True(t, foundCreateReply, "expected a createReply POST")
assert.True(t, patchCalled, "expected recipients PATCH when --to is supplied")
if m, ok := createReplyBody.(map[string]interface{}); assert.True(t, ok, "createReply body should be a map") {
assert.Equal(t, "My reply", m["comment"])
}
})

t.Run("without recipient keeps original and skips patch", func(t *testing.T) {
patchCalled := false
mock := &testutil.MockClient{
PostFunc: func(ctx context.Context, path string, body interface{}) ([]byte, error) {
if strings.Contains(path, "/createReply") {
return mustJSON(map[string]interface{}{"id": "reply-draft-456"}), nil
}
return []byte(`{}`), nil
},
PatchFunc: func(ctx context.Context, path string, body interface{}) ([]byte, error) {
patchCalled = true
return []byte(`{}`), nil
},
}
cmd := &MailDraftsCreateCmd{
Body: "My reply",
ReplyToMessageID: "orig-msg-123",
}
root := &Root{ClientFactory: mockClientFactory(mock)}

var err error
output := captureOutput(func() { err = cmd.Run(root) })

require.NoError(t, err)
assert.Contains(t, output, "Reply draft created")
assert.False(t, patchCalled, "no recipients supplied: must keep createReply's original recipients")
})

t.Run("createReply API error surfaces", func(t *testing.T) {
mock := &testutil.MockClient{
PostFunc: func(ctx context.Context, path string, body interface{}) ([]byte, error) {
return nil, errors.New("API error")
},
}
cmd := &MailDraftsCreateCmd{Body: "x", ReplyToMessageID: "orig-msg-123"}
root := &Root{ClientFactory: mockClientFactory(mock)}
err := cmd.Run(root)
assert.Error(t, err)
})

t.Run("createReply without an id is an error", func(t *testing.T) {
mock := &testutil.MockClient{
PostFunc: func(ctx context.Context, path string, body interface{}) ([]byte, error) {
return []byte(`{}`), nil // no "id"
},
}
cmd := &MailDraftsCreateCmd{Body: "x", ReplyToMessageID: "orig-msg-123"}
root := &Root{ClientFactory: mockClientFactory(mock)}
err := cmd.Run(root)
require.Error(t, err)
assert.Contains(t, err.Error(), "no draft message id")
})

t.Run("recipient patch failure removes the orphaned draft", func(t *testing.T) {
deletedPath := ""
mock := &testutil.MockClient{
PostFunc: func(ctx context.Context, path string, body interface{}) ([]byte, error) {
return mustJSON(map[string]interface{}{"id": "reply-draft-789"}), nil
},
PatchFunc: func(ctx context.Context, path string, body interface{}) ([]byte, error) {
return nil, errors.New("patch boom")
},
DeleteFunc: func(ctx context.Context, path string) error {
deletedPath = path
return nil
},
}
cmd := &MailDraftsCreateCmd{
To: []string{"to@example.com"},
Body: "x",
ReplyToMessageID: "orig-msg-123",
}
root := &Root{ClientFactory: mockClientFactory(mock)}
err := cmd.Run(root)
require.Error(t, err)
assert.Contains(t, err.Error(), "recipients")
assert.Contains(t, err.Error(), "removed", "should report the orphan was cleaned up")
assert.NotEmpty(t, deletedPath, "the orphaned draft must be deleted on patch failure")
})
}

func TestMailDraftsSendCmd_Run(t *testing.T) {
tests := []struct {
name string
Expand Down