From a5d2bf4d7c4c66b8cf82384df1a64ba7cf763a03 Mon Sep 17 00:00:00 2001 From: Javier Errazuriz Date: Fri, 13 Mar 2026 19:27:39 -0300 Subject: [PATCH] feat: add mail draft subcommand Add `mog mail draft` to create draft messages via Graph API POST to /me/messages. Recipients are optional so drafts can be completed in Outlook. Includes --dry-run support and tests for flag parsing. Uses Mail.ReadWrite scope (required for creating messages). Co-Authored-By: Claude Opus 4.6 --- internal/cmd/mail.go | 60 +++++++++++++++++++++++++++++-- internal/cmd/mail_test.go | 49 +++++++++++++++++++++++++ internal/cmd/runtime.go | 2 ++ internal/services/mail/service.go | 27 ++++++++++++++ 4 files changed, 135 insertions(+), 3 deletions(-) diff --git a/internal/cmd/mail.go b/internal/cmd/mail.go index fbd78a3..b65bb42 100644 --- a/internal/cmd/mail.go +++ b/internal/cmd/mail.go @@ -11,9 +11,10 @@ import ( ) type MailCmd struct { - List MailListCmd `cmd:"" help:"List messages"` - Get MailGetCmd `cmd:"" help:"Get message by ID"` - Send MailSendCmd `cmd:"" help:"Send a new message"` + List MailListCmd `cmd:"" help:"List messages"` + Get MailGetCmd `cmd:"" help:"Get message by ID"` + Send MailSendCmd `cmd:"" help:"Send a new message"` + Draft MailDraftCmd `cmd:"" help:"Create a draft message"` } type MailListCmd struct { @@ -148,6 +149,59 @@ func (c *MailSendCmd) Run(ctx context.Context) error { return nil } +type MailDraftCmd struct { + To []string `name:"to" help:"Recipient email (repeat or comma-separate)"` + Subject string `name:"subject" required:"" help:"Email subject"` + Body string `name:"body" help:"Plain text body"` + User string `name:"user" help:"App-only target user override (UPN or user ID)"` + DryRun bool `name:"dry-run" help:"Preview draft creation without saving"` +} + +func (c *MailDraftCmd) Run(ctx context.Context) error { + rt, err := resolveRuntime(ctx, capMailDraft) + if err != nil { + return err + } + targetUser, err := resolveAppOnlyTargetUser(rt.Profile, c.User) + if err != nil { + return err + } + + recipients := splitCSV(c.To) + + if c.DryRun { + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{ + "dry_run": true, + "action": "mail.draft", + "to": recipients, + "subject": c.Subject, + "body_len": len(c.Body), + }) + } + if len(recipients) > 0 { + fmt.Fprintf(os.Stdout, "Dry run: would create draft to %s with subject %q\n", strings.Join(recipients, ", "), c.Subject) + } else { + fmt.Fprintf(os.Stdout, "Dry run: would create draft with subject %q (no recipients)\n", c.Subject) + } + return nil + } + + svc := mail.New(rt.Graph, targetUser) + result, err := svc.Draft(ctx, recipients, c.Subject, strings.TrimSpace(c.Body)) + if err != nil { + return err + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, result) + } + + id := stringValue(result["id"]) + fmt.Fprintf(os.Stdout, "Draft created (id: %s)\n", id) + return nil +} + func splitCSV(values []string) []string { out := make([]string, 0) seen := map[string]struct{}{} diff --git a/internal/cmd/mail_test.go b/internal/cmd/mail_test.go index e299861..f3f5829 100644 --- a/internal/cmd/mail_test.go +++ b/internal/cmd/mail_test.go @@ -29,6 +29,55 @@ func TestMailSendQuoteFlagParsesWithoutBody(t *testing.T) { } } +func TestMailDraftFlagParsesWithoutTo(t *testing.T) { + parser, cli, err := newParser("test") + if err != nil { + t.Fatalf("newParser failed: %v", err) + } + + args := []string{ + "mail", "draft", + "--subject", "Draft subject", + "--body", "Draft body", + } + if _, err := parser.Parse(args); err != nil { + t.Fatalf("parse failed: %v", err) + } + + if cli.Mail.Draft.Subject != "Draft subject" { + t.Fatalf("unexpected subject: %q", cli.Mail.Draft.Subject) + } + if cli.Mail.Draft.Body != "Draft body" { + t.Fatalf("unexpected body: %q", cli.Mail.Draft.Body) + } + if len(cli.Mail.Draft.To) != 0 { + t.Fatalf("expected no recipients, got %v", cli.Mail.Draft.To) + } +} + +func TestMailDraftFlagParsesWithTo(t *testing.T) { + parser, cli, err := newParser("test") + if err != nil { + t.Fatalf("newParser failed: %v", err) + } + + args := []string{ + "mail", "draft", + "--to", "a@example.com,b@example.com", + "--subject", "Draft subject", + } + if _, err := parser.Parse(args); err != nil { + t.Fatalf("parse failed: %v", err) + } + + if len(cli.Mail.Draft.To) == 0 { + t.Fatalf("expected recipients, got none") + } + if cli.Mail.Draft.Subject != "Draft subject" { + t.Fatalf("unexpected subject: %q", cli.Mail.Draft.Subject) + } +} + func TestComposeQuotedReplyBodyAppendsQuoteBlock(t *testing.T) { source := map[string]any{ "sentDateTime": "2026-02-18T13:00:00Z", diff --git a/internal/cmd/runtime.go b/internal/cmd/runtime.go index b55b406..0b988b8 100644 --- a/internal/cmd/runtime.go +++ b/internal/cmd/runtime.go @@ -24,6 +24,7 @@ const ( capMailList runtimeCapability = "mail.list" capMailGet runtimeCapability = "mail.get" capMailSend runtimeCapability = "mail.send" + capMailDraft runtimeCapability = "mail.draft" capCalendarList runtimeCapability = "calendar.list" capCalendarGet runtimeCapability = "calendar.get" capCalendarCreate runtimeCapability = "calendar.create" @@ -69,6 +70,7 @@ var capabilityRules = map[runtimeCapability]capabilityRule{ capMailList: delegatedOrAppOnlyRule(), capMailGet: delegatedOrAppOnlyRule(), capMailSend: delegatedOrAppOnlyRule(), + capMailDraft: delegatedOrAppOnlyRule(), capCalendarList: delegatedOnlyRule(calendarAppOnlyMessage), capCalendarGet: delegatedOnlyRule(calendarAppOnlyMessage), capCalendarCreate: delegatedOnlyRule(calendarAppOnlyMessage), diff --git a/internal/services/mail/service.go b/internal/services/mail/service.go index 46fbf28..817646f 100644 --- a/internal/services/mail/service.go +++ b/internal/services/mail/service.go @@ -13,6 +13,7 @@ import ( var listMailScopes = []string{"Mail.Read"} var getMailScopes = []string{"Mail.Read"} var sendMailScopes = []string{"Mail.Send"} +var draftMailScopes = []string{"Mail.ReadWrite"} type Service struct { client *graph.Client @@ -93,6 +94,32 @@ func (s *Service) Send(ctx context.Context, to []string, subject string, body st return err } +func (s *Service) Draft(ctx context.Context, to []string, subject string, body string) (map[string]any, error) { + toRecipients := make([]map[string]any, 0, len(to)) + for _, address := range to { + address = strings.TrimSpace(address) + if address == "" { + continue + } + toRecipients = append(toRecipients, map[string]any{ + "emailAddress": map[string]any{"address": address}, + }) + } + + payload := map[string]any{ + "subject": subject, + "body": map[string]any{ + "contentType": "Text", + "content": body, + }, + "toRecipients": toRecipients, + } + + var result map[string]any + err := s.client.DoJSON(ctx, http.MethodPost, s.messagesEndpoint(), nil, payload, draftMailScopes, &result) + return result, err +} + func (s *Service) messagesEndpoint() string { if s.appOnlyUser != "" { return "/users/" + url.PathEscape(s.appOnlyUser) + "/messages"