Skip to content

feat(gmail): add --draft flag to +send, +reply, +reply-all, +forward#571

Draft
malob wants to merge 2 commits intogoogleworkspace:mainfrom
malob:feat/gmail-draft-flag
Draft

feat(gmail): add --draft flag to +send, +reply, +reply-all, +forward#571
malob wants to merge 2 commits intogoogleworkspace:mainfrom
malob:feat/gmail-draft-flag

Conversation

@malob
Copy link
Contributor

@malob malob commented Mar 20, 2026

Description

Adds a --draft flag to +send, +reply, +reply-all, and +forward. When set, calls users.drafts.create instead of users.messages.send. Message construction is identical — only the API method and metadata wrapper change.

Note: Opened as a draft because this PR is based on #589 rather than main to avoid merge conflicts, since that PR rewrites much of the mail-sending code. This should not be merged directly — only the second commit (93997d9) is new work. Happy to rebase onto main once #589 merges.

How it works

  • --draft added to common_mail_args (one place, all four helpers get it)
  • resolve_draft_method navigates users.drafts.create in the discovery doc
  • resolve_mail_method dispatcher selects between send and draft based on the flag
  • dispatch_raw_email (renamed from send_raw_email to reflect its dual role) switches the method and wraps draft metadata in the {"message": {...}} envelope required by the Drafts API
  • Threaded drafts (replies and forwards) preserve threadId in the metadata
  • After successful draft creation, a tip is printed to stderr with the users.drafts.send command (suppressed during --dry-run)

Related issues and PRs

Live testing

Tested against a real Gmail account:

# Test Result
1 +send --draft Draft created with DRAFT label
2 +reply --draft Threaded draft in correct thread
3 +send --draft --dry-run Dry-run output, no tip printed
4 +send (without --draft) Sends normally (no regression)

Test coverage

782 total tests (6 new). New tests cover:

  • build_send_metadata for all four (thread_id, draft) combinations
  • resolve_draft_method discovery doc traversal
  • Absence of threadId in draft metadata when no thread

Dry Run Output:

{
  "body": "{\"message\":{}}",
  "dry_run": true,
  "is_multipart_upload": true,
  "method": "POST",
  "query_params": [],
  "url": "https://gmail.googleapis.com/upload/gmail/v1/users/me/drafts"
}

Checklist:

  • My code follows the AGENTS.md guidelines (no generated google-* crates).
  • I have run cargo fmt --all to format the code perfectly.
  • I have run cargo clippy -- -D warnings and resolved all warnings.
  • I have added tests that prove my fix is effective or that my feature works.
  • I have provided a Changeset file (e.g. via pnpx changeset) to document my changes.

malob added 2 commits March 18, 2026 15:33
Include original message attachments on +forward by default, matching
Gmail web behavior. Add --no-original-attachments flag to opt out
(skips file attachments but preserves inline images in HTML mode).

Preserve cid: inline images in HTML mode for both +forward and
+reply/+reply-all by building the correct multipart/related MIME
structure via mail-builder's MimePart API. Gmail's API rewrites
Content-Disposition: inline to attachment in multipart/mixed, so
explicit multipart/related is required.

In plain-text mode, inline images are not included for both forward
and reply, matching Gmail web behavior.

Key implementation details:
- Single-pass MIME payload walker replaces separate text/html extractors
- OriginalPart metadata type with lazy attachment data fetching
- Part classification uses Content-Disposition to distinguish regular
  attachments from inline images (some clients set Content-ID on both)
- Content-ID and content_type sanitized against CRLF header injection
- Size preflight before downloading original attachments
- Remote filename sanitization (not rejection) for sender-controlled names
- Walker does not recurse into hydratable parts (e.g., message/rfc822)
When --draft is set, calls users.drafts.create instead of
users.messages.send. Message construction is identical; only the
API method and metadata wrapper change. Threaded drafts (replies
and forwards) preserve threadId in the draft metadata.
@changeset-bot
Copy link

changeset-bot bot commented Mar 20, 2026

🦋 Changeset detected

Latest commit: 93997d9

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@googleworkspace/cli Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@googleworkspace-bot googleworkspace-bot added area: skills area: core Core CLI parsing, commands, error handling, utilities labels Mar 20, 2026
@keramblock
Copy link

keramblock commented Mar 24, 2026

Hi @malob, thank you for taking on my issue as well as the others.
I have a small question:
Are you sure that "label management is already possible via the gmail.labels scope"?

In my testing, gmail.labels only covers CRUD on label definitions (users.labels.*), not applying labels to messages — users.messages.modify and users.threads.modify both require gmail.modify, which also grants send/compose.

This is exactly what I'm trying to avoid: I want an agent to organize my inbox with labels without it being able to create or send messages without my explicit permission.

P.S. that would be nice to force that as a draft only mode if possible to not rely on the llm not forgetting to add flag.

@malob
Copy link
Contributor Author

malob commented Mar 24, 2026

You're right. I misstated that. gmail.labels covers CRUD on label definitions (users.labels.*), but applying labels to messages via users.messages.modify requires gmail.modify. And gmail.modify also grants send/compose access, so there's no scope combination that allows both label application and draft creation without also granting send. I'll update the PR description to correct this.

On the draft-only mode point: agreed, --draft as a flag is opt-in, which means the agent can forget it. A few options for enforcement:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: core Core CLI parsing, commands, error handling, utilities area: skills

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants