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
60 changes: 57 additions & 3 deletions internal/cmd/mail.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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{}{}
Expand Down
49 changes: 49 additions & 0 deletions internal/cmd/mail_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions internal/cmd/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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),
Expand Down
27 changes: 27 additions & 0 deletions internal/services/mail/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down