Skip to content
Merged
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
.*.sw[op]
.envrc
cmdg
/gwcli
.worktrees/
/dist/
*.gwcli
Expand Down
18 changes: 18 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
26 changes: 26 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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)
Expand Down
45 changes: 39 additions & 6 deletions messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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")
}
Expand All @@ -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)
Expand Down Expand Up @@ -805,15 +807,24 @@ 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"

// Send the message
// 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)
}

Expand All @@ -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
Expand Down
50 changes: 42 additions & 8 deletions pkg/gwcli/connection.go
Original file line number Diff line number Diff line change
Expand Up @@ -710,21 +710,58 @@ 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)

// Create mail contents.
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{
Expand All @@ -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 {
Expand All @@ -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) {
Expand Down
Loading