From 53c48049ad8ca618caa79144eba0f3790cfe00f3 Mon Sep 17 00:00:00 2001 From: Oliver Kuegow Date: Mon, 8 Jun 2026 16:26:37 +0200 Subject: [PATCH] feat(mail): add --attachment flag to send, drafts create and replies mog could not attach files. Add a repeatable `--attachment ` flag to `mail send` and `mail drafts create`: each file becomes an inline base64 microsoft.graph.fileAttachment (typed payload struct), content type inferred from the extension (fallback application/octet-stream). Paths are validated up front with clear errors: empty path, missing file, directory, and files at/over Graph's 3 MB inline limit. Replies (`--reply-to-message-id`) also support attachments. The single-step /reply action cannot carry inline attachments, so the reply is built via createReply, then PATCH/attach/send. The quoted original is preserved: text replies pass the user's text as the createReply comment; HTML replies prepend the user's HTML before the quoted draft body. On a pre-send failure the orphaned draft is deleted (best effort); if sending fails the draft is kept and named in the error. Also fixes the existing non-attachment reply path, which set both `comment` and `message.body` (Graph wants one or the other, can 400): now comment for text, message.body for HTML. Tests cover buildAttachments (content-type, empty/missing/dir/oversize), the send + drafts-create payloads, and the reply flow end to end: call order createReply->patch->attach->send, the attachment/recipient payloads, HTML quote prepending, and the createReply/attach/send error paths incl. orphan-draft cleanup. README and --ai-help updated. Closes #10 --- CHANGELOG.md | 3 + README.md | 3 +- internal/cli/ai_help.go | 1 + internal/cli/mail.go | 181 +++++++++++++++++++++++++++---- internal/cli/mail_test.go | 222 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 390 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d510250..2ba22f7 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 +- `--attachment ` flag (repeatable) for `mail send` and `mail drafts create`. Files are attached as inline base64 `fileAttachment` objects with the content type inferred from the extension. Paths are validated up front (empty path, missing file, directory, and the 3 MB inline-attachment limit are rejected with clear errors). Replies (`--reply-to-message-id`) also support `--attachment`: since the single-step `/reply` action cannot carry inline attachments, the reply is built as a draft (`createReply`), populated, attached, and then sent. (#10) + ## [0.3.1] - 2026-01-26 ### Added diff --git a/README.md b/README.md index 25e08f5..a1e8a3d 100644 --- a/README.md +++ b/README.md @@ -205,11 +205,12 @@ mog mail search # Search messages mog mail search "*" --max 10 # Recent messages mog mail get # Read a message mog mail send --to X --subject Y --body Z +mog mail send --to X --subject Y --body Z --attachment ./file.pdf # Attach file(s); repeatable mog mail folders # List folders # Drafts mog mail drafts list -mog mail drafts create --to X --subject Y --body Z +mog mail drafts create --to X --subject Y --body Z [--attachment ./file.pdf] mog mail drafts send # Attachments diff --git a/internal/cli/ai_help.go b/internal/cli/ai_help.go index be9cd83..97c07c9 100644 --- a/internal/cli/ai_help.go +++ b/internal/cli/ai_help.go @@ -51,6 +51,7 @@ mog mail send [flags] --body # Body text --body-file # Read body from file (- for stdin) --body-html # HTML body + --attachment # Attach a file (repeatable) mog mail folders # List mail folders diff --git a/internal/cli/mail.go b/internal/cli/mail.go index 1c4a253..383f968 100644 --- a/internal/cli/mail.go +++ b/internal/cli/mail.go @@ -2,11 +2,14 @@ package cli import ( "context" + "encoding/base64" "encoding/json" "fmt" "io" + "mime" "net/url" "os" + "path/filepath" "strings" "time" @@ -140,6 +143,7 @@ type MailSendCmd struct { Body string `help:"Message body"` BodyFile string `help:"Read body from file (- for stdin)" name:"body-file"` BodyHTML string `help:"HTML body" name:"body-html"` + Attachment []string `help:"Attach a file; repeatable" name:"attachment"` ReplyToMessageID string `help:"Reply to message ID" name:"reply-to-message-id"` } @@ -178,21 +182,29 @@ func (c *MailSendCmd) Run(root *Root) error { // Reply to existing message if c.ReplyToMessageID != "" { messageID := graph.ResolveID(c.ReplyToMessageID) - message := map[string]interface{}{ - "body": map[string]string{ - "contentType": contentType, - "content": body, - }, - "toRecipients": formatRecipients(c.To), - } - addRecipientsIfPresent(message, c.Cc, c.Bcc) - replyMsg := map[string]interface{}{ - "message": message, - "comment": body, - } - _, err = client.Post(ctx, fmt.Sprintf("/me/messages/%s/reply", messageID), replyMsg) - if err != nil { - return err + if len(c.Attachment) > 0 { + // The single-step /reply action cannot carry inline attachments, + // so build a reply draft, populate it, attach the files, then send. + if err := c.sendReplyWithAttachments(ctx, client, messageID, contentType, body); err != nil { + return err + } + } else { + message := map[string]interface{}{ + "toRecipients": formatRecipients(c.To), + } + addRecipientsIfPresent(message, c.Cc, c.Bcc) + replyMsg := map[string]interface{}{"message": message} + // Graph's /reply accepts EITHER comment OR message.body, not both + // (sending both can return 400). Use comment for text replies so the + // quoted original is kept; set the body directly for HTML replies. + if contentType == "html" { + message["body"] = map[string]string{"contentType": "html", "content": body} + } else { + replyMsg["comment"] = body + } + if _, err = client.Post(ctx, fmt.Sprintf("/me/messages/%s/reply", messageID), replyMsg); err != nil { + return err + } } } else { message := map[string]interface{}{ @@ -204,6 +216,13 @@ func (c *MailSendCmd) Run(root *Root) error { "toRecipients": formatRecipients(c.To), } addRecipientsIfPresent(message, c.Cc, c.Bcc) + if len(c.Attachment) > 0 { + atts, err := buildAttachments(c.Attachment) + if err != nil { + return err + } + message["attachments"] = atts + } msg := map[string]interface{}{ "message": message, } @@ -217,6 +236,66 @@ func (c *MailSendCmd) Run(root *Root) error { return nil } +// sendReplyWithAttachments replies to messageID with file attachments. The +// single-step /reply action cannot carry inline attachments, so this creates a +// reply draft (createReply, which keeps the quoted original), sets recipients, +// adds each attachment, and sends it. On a pre-send failure the orphaned draft +// is deleted (best effort); if sending itself fails the draft is left in Drafts +// and named in the error. +func (c *MailSendCmd) sendReplyWithAttachments(ctx context.Context, client graph.Client, messageID, contentType, body string) error { + atts, err := buildAttachments(c.Attachment) + if err != nil { + return err + } + + // For text replies, pass the user's text as the createReply comment so the + // draft keeps the quoted original. HTML is prepended below instead (a + // comment would be HTML-escaped). + createBody := map[string]interface{}{} + if contentType != "html" { + createBody["comment"] = body + } + data, err := client.Post(ctx, fmt.Sprintf("/me/messages/%s/createReply", messageID), createBody) + if err != nil { + return 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 + cleanup := func() { _ = client.Delete(ctx, fmt.Sprintf("/me/messages/%s", draftID)) } + + update := map[string]interface{}{"toRecipients": formatRecipients(c.To)} + addRecipientsIfPresent(update, c.Cc, c.Bcc) + if contentType == "html" { + quoted := "" + if draft.Body != nil { + quoted = draft.Body.Content + } + update["body"] = map[string]string{"contentType": "html", "content": body + "

" + quoted} + } + if _, err := client.Patch(ctx, fmt.Sprintf("/me/messages/%s", draftID), update); err != nil { + cleanup() + return fmt.Errorf("failed to prepare reply draft %s (removed): %w", graph.FormatID(draftID), err) + } + + for _, a := range atts { + if _, err := client.Post(ctx, fmt.Sprintf("/me/messages/%s/attachments", draftID), a); err != nil { + cleanup() + return fmt.Errorf("failed to attach %q to reply draft %s (removed): %w", a.Name, graph.FormatID(draftID), err) + } + } + + if _, err := client.Post(ctx, fmt.Sprintf("/me/messages/%s/send", draftID), nil); err != nil { + return fmt.Errorf("reply draft %s was prepared but sending failed; it is still in your Drafts: %w", graph.FormatID(draftID), err) + } + return nil +} + // MailFoldersCmd lists mail folders. type MailFoldersCmd struct{} @@ -308,10 +387,11 @@ 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)"` + Subject string `help:"Subject line"` + Body string `help:"Message body"` + BodyFile string `help:"Read body from file" name:"body-file"` + Attachment []string `help:"Attach a file; repeatable" name:"attachment"` } // Run executes drafts create. @@ -338,6 +418,13 @@ func (c *MailDraftsCreateCmd) Run(root *Root) error { }, "toRecipients": formatRecipients(c.To), } + if len(c.Attachment) > 0 { + atts, err := buildAttachments(c.Attachment) + if err != nil { + return err + } + msg["attachments"] = atts + } ctx := context.Background() data, err := client.Post(ctx, "/me/messages", msg) @@ -543,6 +630,62 @@ func addRecipientsIfPresent(msg map[string]interface{}, cc, bcc []string) { } } +// buildAttachments reads each file path and builds a Microsoft Graph +// fileAttachment object (base64-encoded inline). The content type is inferred +// from the file extension. This is suitable for small files; large files would +// require an upload session, which can be added later. +// maxInlineAttachmentBytes is Microsoft Graph's ceiling for inline (base64) +// file attachments on a message. Larger files require an upload session, which +// is not yet supported here — so they are rejected with a clear message rather +// than failing later with an opaque Graph error. +const maxInlineAttachmentBytes = 3 * 1024 * 1024 + +// fileAttachment is a Microsoft Graph fileAttachment payload object. +type fileAttachment struct { + ODataType string `json:"@odata.type"` + Name string `json:"name"` + ContentType string `json:"contentType"` + ContentBytes string `json:"contentBytes"` +} + +// buildAttachments validates each path and builds an inline base64 Graph +// fileAttachment. The content type is inferred from the extension (falling back +// to application/octet-stream). Empty paths, missing files, directories and +// files at/over the 3 MB inline limit are rejected up front. +func buildAttachments(paths []string) ([]fileAttachment, error) { + atts := make([]fileAttachment, 0, len(paths)) + for _, p := range paths { + if strings.TrimSpace(p) == "" { + return nil, fmt.Errorf("attachment path is empty") + } + info, err := os.Stat(p) + if err != nil { + return nil, fmt.Errorf("attachment %q: %w", p, err) + } + if info.IsDir() { + return nil, fmt.Errorf("attachment %q is a directory", p) + } + if info.Size() >= maxInlineAttachmentBytes { + return nil, fmt.Errorf("attachment %q is %.1f MB; inline attachments must be under 3 MB", p, float64(info.Size())/(1024*1024)) + } + data, err := os.ReadFile(p) + if err != nil { + return nil, fmt.Errorf("failed to read attachment %q: %w", p, err) + } + contentType := mime.TypeByExtension(filepath.Ext(p)) + if contentType == "" { + contentType = "application/octet-stream" + } + atts = append(atts, fileAttachment{ + ODataType: "#microsoft.graph.fileAttachment", + Name: filepath.Base(p), + ContentType: contentType, + ContentBytes: base64.StdEncoding.EncodeToString(data), + }) + } + return atts, nil +} + func printMessage(msg Message, verbose bool) { read := "●" if msg.IsRead { diff --git a/internal/cli/mail_test.go b/internal/cli/mail_test.go index d1fe2df..784b054 100644 --- a/internal/cli/mail_test.go +++ b/internal/cli/mail_test.go @@ -3,11 +3,13 @@ package cli import ( "bytes" "context" + "encoding/base64" "encoding/json" "errors" "net/url" "os" "path/filepath" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -970,3 +972,223 @@ func mustJSON(data interface{}) []byte { } return b } + +func TestBuildAttachments(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "hello.txt") + require.NoError(t, os.WriteFile(p, []byte("hi there"), 0o600)) + + atts, err := buildAttachments([]string{p}) + require.NoError(t, err) + require.Len(t, atts, 1) + assert.Equal(t, "#microsoft.graph.fileAttachment", atts[0].ODataType) + assert.Equal(t, "hello.txt", atts[0].Name) + assert.Contains(t, atts[0].ContentType, "text/plain") + assert.Equal(t, base64.StdEncoding.EncodeToString([]byte("hi there")), atts[0].ContentBytes) + + // Unknown extension falls back to octet-stream. + bin := filepath.Join(dir, "blob.unknownext") + require.NoError(t, os.WriteFile(bin, []byte{0x00, 0x01}, 0o600)) + atts, err = buildAttachments([]string{bin}) + require.NoError(t, err) + assert.Equal(t, "application/octet-stream", atts[0].ContentType) + + // Empty path, missing file and a directory are rejected with clear errors. + _, err = buildAttachments([]string{""}) + assert.Error(t, err) + _, err = buildAttachments([]string{filepath.Join(dir, "does-not-exist.txt")}) + assert.Error(t, err) + _, err = buildAttachments([]string{dir}) + assert.Error(t, err) + + // Files at/over the 3 MB inline limit are rejected before upload. + big := filepath.Join(dir, "big.bin") + require.NoError(t, os.WriteFile(big, make([]byte, maxInlineAttachmentBytes), 0o600)) + _, err = buildAttachments([]string{big}) + assert.Error(t, err) +} + +func TestMailAttachmentsInPayload(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "doc.txt") + require.NoError(t, os.WriteFile(p, []byte("payload"), 0o600)) + + // decodeAttachments marshals the captured POST body and pulls out the + // attachments array (optionally nested under a key, e.g. "message"). + decodeAttachments := func(body interface{}, key string) []map[string]interface{} { + raw, err := json.Marshal(body) + require.NoError(t, err) + var m map[string]interface{} + require.NoError(t, json.Unmarshal(raw, &m)) + container := m + if key != "" { + inner, ok := m[key].(map[string]interface{}) + require.True(t, ok, "expected %q object in payload", key) + container = inner + } + arr, ok := container["attachments"].([]interface{}) + require.True(t, ok, "expected attachments array in payload") + out := make([]map[string]interface{}, 0, len(arr)) + for _, a := range arr { + out = append(out, a.(map[string]interface{})) + } + return out + } + + t.Run("send wraps attachments under message", func(t *testing.T) { + var gotPath string + var gotBody interface{} + mock := &testutil.MockClient{ + PostFunc: func(ctx context.Context, path string, body interface{}) ([]byte, error) { + gotPath, gotBody = path, body + return nil, nil + }, + } + root := &Root{ClientFactory: mockClientFactory(mock)} + err := (&MailSendCmd{To: []string{"a@b.de"}, Subject: "S", Body: "B", Attachment: []string{p}}).Run(root) + require.NoError(t, err) + assert.Equal(t, "/me/sendMail", gotPath) + atts := decodeAttachments(gotBody, "message") + require.Len(t, atts, 1) + assert.Equal(t, "#microsoft.graph.fileAttachment", atts[0]["@odata.type"]) + assert.Equal(t, "doc.txt", atts[0]["name"]) + assert.Equal(t, base64.StdEncoding.EncodeToString([]byte("payload")), atts[0]["contentBytes"]) + }) + + t.Run("drafts create posts attachments on the message", func(t *testing.T) { + var gotPath string + var gotBody interface{} + mock := &testutil.MockClient{ + PostFunc: func(ctx context.Context, path string, body interface{}) ([]byte, error) { + gotPath, gotBody = path, body + return mustJSON(map[string]interface{}{"id": "d1"}), nil + }, + } + root := &Root{ClientFactory: mockClientFactory(mock)} + err := (&MailDraftsCreateCmd{To: []string{"a@b.de"}, Subject: "S", Body: "B", Attachment: []string{p}}).Run(root) + require.NoError(t, err) + assert.Equal(t, "/me/messages", gotPath) + atts := decodeAttachments(gotBody, "") + require.Len(t, atts, 1) + assert.Equal(t, "doc.txt", atts[0]["name"]) + }) + +} + +func TestMailReplyWithAttachments(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "doc.txt") + require.NoError(t, os.WriteFile(p, []byte("payload"), 0o600)) + + type call struct{ op, path string } + toMap := func(body interface{}) map[string]interface{} { + raw, _ := json.Marshal(body) + var m map[string]interface{} + _ = json.Unmarshal(raw, &m) + return m + } + // newMock records the ordered (method, path) calls. failAt injects an error + // at the named step (createReply/patch/attach/send); "" means all succeed. + newMock := func(failAt string, replyBody map[string]interface{}) (*testutil.MockClient, *[]call, *map[string]interface{}, *map[string]interface{}) { + var calls []call + var lastPatch, lastAttach map[string]interface{} + mock := &testutil.MockClient{ + PostFunc: func(ctx context.Context, path string, body interface{}) ([]byte, error) { + calls = append(calls, call{"POST", path}) + switch { + case strings.HasSuffix(path, "/createReply"): + if failAt == "createReply" { + return nil, errors.New("boom") + } + draft := map[string]interface{}{"id": "reply-draft-1"} + if replyBody != nil { + draft["body"] = replyBody + } + return mustJSON(draft), nil + case strings.HasSuffix(path, "/attachments"): + lastAttach = toMap(body) + if failAt == "attach" { + return nil, errors.New("boom") + } + case strings.HasSuffix(path, "/send"): + if failAt == "send" { + return nil, errors.New("boom") + } + } + return nil, nil + }, + PatchFunc: func(ctx context.Context, path string, body interface{}) ([]byte, error) { + calls = append(calls, call{"PATCH", path}) + lastPatch = toMap(body) + if failAt == "patch" { + return nil, errors.New("boom") + } + return nil, nil + }, + DeleteFunc: func(ctx context.Context, path string) error { + calls = append(calls, call{"DELETE", path}) + return nil + }, + } + return mock, &calls, &lastPatch, &lastAttach + } + + t.Run("text reply: createReply→patch→attach→send in order, quote via comment", func(t *testing.T) { + mock, calls, lastPatch, lastAttach := newMock("", nil) + root := &Root{ClientFactory: mockClientFactory(mock)} + err := (&MailSendCmd{To: []string{"a@b.de"}, Cc: []string{"c@b.de"}, Body: "hi", Attachment: []string{p}, ReplyToMessageID: "m1"}).Run(root) + require.NoError(t, err) + require.Equal(t, []call{ + {"POST", "/me/messages/m1/createReply"}, + {"PATCH", "/me/messages/reply-draft-1"}, + {"POST", "/me/messages/reply-draft-1/attachments"}, + {"POST", "/me/messages/reply-draft-1/send"}, + }, *calls) + // Recipients are set on the draft; text body is NOT patched (kept via comment). + assert.NotNil(t, (*lastPatch)["toRecipients"]) + assert.NotNil(t, (*lastPatch)["ccRecipients"]) + _, hasBody := (*lastPatch)["body"] + assert.False(t, hasBody, "text reply keeps the quote via comment, no body PATCH") + // Attachment payload is correct. + assert.Equal(t, "#microsoft.graph.fileAttachment", (*lastAttach)["@odata.type"]) + assert.Equal(t, "doc.txt", (*lastAttach)["name"]) + assert.Equal(t, base64.StdEncoding.EncodeToString([]byte("payload")), (*lastAttach)["contentBytes"]) + }) + + t.Run("html reply prepends user html before the quoted draft body", func(t *testing.T) { + mock, _, lastPatch, _ := newMock("", map[string]interface{}{"contentType": "html", "content": "quote"}) + root := &Root{ClientFactory: mockClientFactory(mock)} + err := (&MailSendCmd{To: []string{"a@b.de"}, BodyHTML: "hi", Attachment: []string{p}, ReplyToMessageID: "m1"}).Run(root) + require.NoError(t, err) + b, ok := (*lastPatch)["body"].(map[string]interface{}) + require.True(t, ok, "html reply must PATCH the body") + assert.Equal(t, "html", b["contentType"]) + assert.Contains(t, b["content"], "hi") + assert.Contains(t, b["content"], "quote") + }) + + t.Run("createReply failure: surfaces error, no further calls", func(t *testing.T) { + mock, calls, _, _ := newMock("createReply", nil) + root := &Root{ClientFactory: mockClientFactory(mock)} + err := (&MailSendCmd{To: []string{"a@b.de"}, Body: "hi", Attachment: []string{p}, ReplyToMessageID: "m1"}).Run(root) + assert.Error(t, err) + require.Equal(t, []call{{"POST", "/me/messages/m1/createReply"}}, *calls) + }) + + t.Run("attach failure: deletes orphan draft, does not send", func(t *testing.T) { + mock, calls, _, _ := newMock("attach", nil) + root := &Root{ClientFactory: mockClientFactory(mock)} + err := (&MailSendCmd{To: []string{"a@b.de"}, Body: "hi", Attachment: []string{p}, ReplyToMessageID: "m1"}).Run(root) + assert.Error(t, err) + assert.Contains(t, *calls, call{"DELETE", "/me/messages/reply-draft-1"}) + assert.NotContains(t, *calls, call{"POST", "/me/messages/reply-draft-1/send"}) + }) + + t.Run("send failure: keeps the draft (no delete)", func(t *testing.T) { + mock, calls, _, _ := newMock("send", nil) + root := &Root{ClientFactory: mockClientFactory(mock)} + err := (&MailSendCmd{To: []string{"a@b.de"}, Body: "hi", Attachment: []string{p}, ReplyToMessageID: "m1"}).Run(root) + assert.Error(t, err) + assert.NotContains(t, *calls, call{"DELETE", "/me/messages/reply-draft-1"}) + }) +}