From 09ad42b926e0f2be727284cf99559316940cccad Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 17:41:00 +0000 Subject: [PATCH] Add messages draft command for creating draft emails Wire up draft creation through the existing Gmail Drafts plumbing. The new `messages draft` command mirrors `messages send` flags but calls Users.Drafts.Create instead of Messages.Send, returning the created draft ID. Shared MIME assembly is factored out of SendParts/runMessagesSend so send and draft stay in sync. https://claude.ai/code/session_01BWTsRtPMNRbk9mjzYbBHDa --- .gitignore | 1 + CLAUDE.md | 18 +++++++++++++++ main.go | 26 +++++++++++++++++++++ messages.go | 45 ++++++++++++++++++++++++++++++++----- pkg/gwcli/connection.go | 50 ++++++++++++++++++++++++++++++++++------- 5 files changed, 126 insertions(+), 14 deletions(-) diff --git a/.gitignore b/.gitignore index 9672811..b051d82 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ .*.sw[op] .envrc cmdg +/gwcli .worktrees/ /dist/ *.gwcli diff --git a/CLAUDE.md b/CLAUDE.md index f590876..612058e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -156,6 +156,24 @@ for _, l := range conn.Labels() { System labels (INBOX, UNREAD, SENT, etc.) are uppercase IDs. +### Sending and Drafting + +`messages send` and `messages draft` share the same flags (`--to`, `--subject`, +`--body`, `--cc`, `--bcc`, `--attach`, `--html`, `--thread-id`) and the same +MIME assembly (`buildOutgoingMessage` in `messages.go`, `buildPartsMessage` in +`connection.go`). The body is read from stdin when `--body` is empty. The only +difference is the final API call: `send` calls `conn.SendParts` +(`Users.Messages.Send`, which returns no message ID), while `draft` calls +`conn.DraftParts` (`Users.Drafts.Create`, which returns the draft ID, surfaced +as `draftId` in `--json`). Unlike `send`, `draft` does not require `--to` or +`--subject` (a partial draft is valid). + +```bash +gwcli messages draft --to bob@example.com --subject "Hi" --body "Draft me" +echo "body from stdin" | gwcli messages draft --to bob@example.com --subject "Hi" +gwcli messages draft --to bob@example.com --subject "Hi" --json # -> {"status":"created","draftId":"..."} +``` + ### Email Output Formats The `gwcli messages read` command supports three output formats: diff --git a/main.go b/main.go index 59b0882..bc44ac4 100644 --- a/main.go +++ b/main.go @@ -102,6 +102,17 @@ type CLI struct { ThreadID string `help:"Reply to thread" name:"thread-id"` } `cmd:"" help:"Send email"` + Draft struct { + To []string `help:"Recipients"` + Subject string `help:"Subject line"` + Body string `help:"Message body (or read from stdin)"` + Cc []string `help:"CC recipients"` + Bcc []string `help:"BCC recipients"` + Attach []string `help:"File attachments" type:"existingfile"` + HTML bool `help:"Compose as HTML"` + ThreadID string `help:"Associate with thread" name:"thread-id"` + } `cmd:"" help:"Create a draft email"` + Delete struct { MessageID string `arg:"" optional:"" help:"Message ID"` Stdin bool `help:"Read IDs from stdin"` @@ -494,6 +505,21 @@ func main() { os.Exit(2) } + case "messages draft": + cmdCtx := context.Background() + conn, err := getConnection(cli.Config, cli.User, cli.Verbose) + if err != nil { + out.writeError(err) + os.Exit(3) + } + + if err := runMessagesDraft(cmdCtx, conn, cli.Messages.Draft.To, cli.Messages.Draft.Cc, cli.Messages.Draft.Bcc, + cli.Messages.Draft.Subject, cli.Messages.Draft.Body, cli.Messages.Draft.Attach, + cli.Messages.Draft.HTML, cli.Messages.Draft.ThreadID, out); err != nil { + out.writeError(err) + os.Exit(2) + } + case "messages delete": cmdCtx := context.Background() conn, err := getConnection(cli.Config, cli.User, cli.Verbose) diff --git a/messages.go b/messages.go index 8800cd0..c4fc4fd 100644 --- a/messages.go +++ b/messages.go @@ -732,8 +732,10 @@ func runMessagesSearch(ctx context.Context, conn *gwcli.CmdG, query string, limi return out.writeTable(headers, rows) } -// runMessagesSend sends an email message -func runMessagesSend(ctx context.Context, conn *gwcli.CmdG, to, cc, bcc []string, subject, body string, attachments []string, html bool, threadID string, out *outputWriter) error { +// buildOutgoingMessage assembles the headers and MIME parts for an outgoing +// email, reading the body from stdin when it is empty. Shared by send and +// draft. +func buildOutgoingMessage(to, cc, bcc []string, subject, body string, attachments []string, html bool) (mail.Header, []*gwcli.Part, error) { // Read body from stdin if not provided if body == "" { scanner := bufio.NewScanner(os.Stdin) @@ -742,7 +744,7 @@ func runMessagesSend(ctx context.Context, conn *gwcli.CmdG, to, cc, bcc []string lines = append(lines, scanner.Text()) } if err := scanner.Err(); err != nil { - return fmt.Errorf("error reading body from stdin: %w", err) + return nil, nil, fmt.Errorf("error reading body from stdin: %w", err) } body = strings.Join(lines, "\n") } @@ -767,7 +769,7 @@ func runMessagesSend(ctx context.Context, conn *gwcli.CmdG, to, cc, bcc []string for _, path := range attachments { data, err := os.ReadFile(path) if err != nil { - return fmt.Errorf("failed to read attachment %s: %w", path, err) + return nil, nil, fmt.Errorf("failed to read attachment %s: %w", path, err) } filename := filepath.Base(path) @@ -805,6 +807,16 @@ func runMessagesSend(ctx context.Context, conn *gwcli.CmdG, to, cc, bcc []string headers["Bcc"] = []string{strings.Join(bcc, ", ")} } + return headers, parts, nil +} + +// runMessagesSend sends an email message +func runMessagesSend(ctx context.Context, conn *gwcli.CmdG, to, cc, bcc []string, subject, body string, attachments []string, html bool, threadID string, out *outputWriter) error { + headers, parts, err := buildOutgoingMessage(to, cc, bcc, subject, body, attachments, html) + if err != nil { + return err + } + // Determine multipart type multipartType := "mixed" @@ -812,8 +824,7 @@ func runMessagesSend(ctx context.Context, conn *gwcli.CmdG, to, cc, bcc []string // Note: SendParts API does not return the message ID of the sent message. // This is a limitation of the Gmail API's messages.send endpoint when using // raw RFC822 format. The API only confirms successful submission. - err := conn.SendParts(ctx, gwcli.ThreadID(threadID), multipartType, headers, parts) - if err != nil { + if err := conn.SendParts(ctx, gwcli.ThreadID(threadID), multipartType, headers, parts); err != nil { return fmt.Errorf("failed to send message: %w", err) } @@ -826,6 +837,28 @@ func runMessagesSend(ctx context.Context, conn *gwcli.CmdG, to, cc, bcc []string return nil } +// runMessagesDraft creates a draft email instead of sending it. +func runMessagesDraft(ctx context.Context, conn *gwcli.CmdG, to, cc, bcc []string, subject, body string, attachments []string, html bool, threadID string, out *outputWriter) error { + headers, parts, err := buildOutgoingMessage(to, cc, bcc, subject, body, attachments, html) + if err != nil { + return err + } + + multipartType := "mixed" + + draftID, err := conn.DraftParts(ctx, gwcli.ThreadID(threadID), multipartType, headers, parts) + if err != nil { + return fmt.Errorf("failed to create draft: %w", err) + } + + if out.json { + return out.writeJSON(map[string]string{"status": "created", "draftId": draftID}) + } + + out.writeMessage(fmt.Sprintf("Draft created: %s", draftID)) + return nil +} + // runMessagesDelete deletes messages (moves to trash) func runMessagesDelete(ctx context.Context, conn *gwcli.CmdG, messageID string, stdin bool, verbose bool, out *outputWriter) error { var ids []string diff --git a/pkg/gwcli/connection.go b/pkg/gwcli/connection.go index 89f5532..b045fc8 100644 --- a/pkg/gwcli/connection.go +++ b/pkg/gwcli/connection.go @@ -710,6 +710,43 @@ func ParseUserMessage(in string) (mail.Header, *Part, error) { // head: Email header. // parts: Email parts. func (c *CmdG) SendParts(ctx context.Context, threadID ThreadID, mp string, head mail.Header, parts []*Part) error { + msgs, err := buildPartsMessage(mp, head, parts) + if err != nil { + return err + } + log.Infof("Final message: %q", msgs) + return c.send(ctx, threadID, msgs) +} + +// DraftParts creates a draft from a multipart message and returns the +// created draft ID. Arguments mirror SendParts. +func (c *CmdG) DraftParts(ctx context.Context, threadID ThreadID, mp string, head mail.Header, parts []*Part) (string, error) { + msgs, err := buildPartsMessage(mp, head, parts) + if err != nil { + return "", err + } + log.Infof("Final draft message: %q", msgs) + + var draftID string + err = wrapLogRPC("gmail.Users.Drafts.Create", func() error { + d, err := c.gmail.Users.Drafts.Create(email, &gmail.Draft{ + Message: &gmail.Message{ + Raw: MIMEEncode(msgs), + ThreadId: string(threadID), + }, + }).Context(ctx).Do() + if err != nil { + return err + } + draftID = d.Id + return nil + }, "email=%q threadID=%q msg=%q", email, threadID, msgs) + return draftID, err +} + +// buildPartsMessage assembles a raw RFC822 multipart message from headers +// and parts. Shared by SendParts and DraftParts. +func buildPartsMessage(mp string, head mail.Header, parts []*Part) (string, error) { var mbuf bytes.Buffer w := multipart.NewWriter(&mbuf) @@ -717,14 +754,14 @@ func (c *CmdG) SendParts(ctx context.Context, threadID ThreadID, mp string, head for _, p := range parts { p2, err := w.CreatePart(p.Header) if err != nil { - return errors.Wrapf(err, "failed to create part") + return "", errors.Wrapf(err, "failed to create part") } if _, err := p2.Write([]byte(p.Contents)); err != nil { - return errors.Wrapf(err, "assembling part") + return "", errors.Wrapf(err, "assembling part") } } if err := w.Close(); err != nil { - return errors.Wrapf(err, "closing multipart") + return "", errors.Wrapf(err, "closing multipart") } addrHeader := map[string]bool{ @@ -744,7 +781,7 @@ func (c *CmdG) SendParts(ctx context.Context, threadID ThreadID, mp string, head } as, err := mail.ParseAddressList(v) if err != nil { - return errors.Wrapf(err, "parsing address list %q, which is %q", k, v) + return "", errors.Wrapf(err, "parsing address list %q, which is %q", k, v) } var ass []string for _, a := range as { @@ -766,10 +803,7 @@ func (c *CmdG) SendParts(ctx context.Context, threadID ThreadID, mp string, head sort.Strings(hlines) hlines = append(hlines, fmt.Sprintf(`Content-Type: multipart/%s; boundary="%s"`, mp, w.Boundary())) hlines = append(hlines, `Content-Disposition: inline`) - msgs := strings.Join(hlines, "\r\n") + "\r\n\r\n" + mbuf.String() - - log.Infof("Final message: %q", msgs) - return c.send(ctx, threadID, msgs) + return strings.Join(hlines, "\r\n") + "\r\n\r\n" + mbuf.String(), nil } func (c *CmdG) send(ctx context.Context, threadID ThreadID, msg string) (err error) {