diff --git a/CHANGELOG.md b/CHANGELOG.md index d510250..674f054 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- `--reply-to-message-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 diff --git a/README.md b/README.md index 25e08f5..174a3d9 100644 --- a/README.md +++ b/README.md @@ -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 --body Z # Threaded reply draft (review, then send); --to defaults to the original sender mog mail drafts send # Attachments diff --git a/internal/cli/ai_help.go b/internal/cli/ai_help.go index be9cd83..804c7d5 100644 --- a/internal/cli/ai_help.go +++ b/internal/cli/ai_help.go @@ -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 # Make a threaded reply DRAFT (kept for review, not sent) mog mail drafts send mog mail drafts delete diff --git a/internal/cli/mail.go b/internal/cli/mail.go index 1c4a253..a4e4d47 100644 --- a/internal/cli/mail.go +++ b/internal/cli/mail.go @@ -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. @@ -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{ @@ -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 @@ -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"` diff --git a/internal/cli/mail_test.go b/internal/cli/mail_test.go index d1fe2df..d93d9f2 100644 --- a/internal/cli/mail_test.go +++ b/internal/cli/mail_test.go @@ -8,6 +8,7 @@ import ( "net/url" "os" "path/filepath" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -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