Skip to content

Commit 4ee48d5

Browse files
committed
feat(gmail): add --draft flag to +send, +reply, +reply-all, +forward
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.
1 parent 186e88c commit 4ee48d5

File tree

9 files changed

+150
-30
lines changed

9 files changed

+150
-30
lines changed

.changeset/gmail-draft-flag.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@googleworkspace/cli": minor
3+
---
4+
5+
Add `--draft` flag to Gmail `+send`, `+reply`, `+reply-all`, and `+forward` helpers to save messages as drafts instead of sending them immediately

crates/google-workspace-cli/src/helpers/gmail/forward.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ pub(super) async fn handle_forward(
8484

8585
let raw = create_forward_raw_message(&envelope, &original, &all_attachments)?;
8686

87-
super::send_raw_email(
87+
super::dispatch_raw_email(
8888
doc,
8989
matches,
9090
&raw,
@@ -510,7 +510,8 @@ mod tests {
510510
Arg::new("no-original-attachments")
511511
.long("no-original-attachments")
512512
.action(ArgAction::SetTrue),
513-
);
513+
)
514+
.arg(Arg::new("draft").long("draft").action(ArgAction::SetTrue));
514515
cmd.try_get_matches_from(args).unwrap()
515516
}
516517

crates/google-workspace-cli/src/helpers/gmail/mod.rs

Lines changed: 118 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ use std::pin::Pin;
4242

4343
pub struct GmailHelper;
4444

45+
/// Broad scope used by reply/forward handlers for both message metadata
46+
/// fetching and the final send/draft operation. Covers `messages.send`,
47+
/// `drafts.create`, and read access in a single token.
4548
pub(super) const GMAIL_SCOPE: &str = "https://www.googleapis.com/auth/gmail.modify";
4649
pub(super) const GMAIL_READONLY_SCOPE: &str = "https://www.googleapis.com/auth/gmail.readonly";
4750
pub(super) const PUBSUB_SCOPE: &str = "https://www.googleapis.com/auth/pubsub";
@@ -1364,7 +1367,7 @@ pub(super) fn parse_attachments(matches: &ArgMatches) -> Result<Vec<Attachment>,
13641367
Ok(attachments)
13651368
}
13661369

1367-
pub(super) fn resolve_send_method(
1370+
fn resolve_send_method(
13681371
doc: &crate::discovery::RestDescription,
13691372
) -> Result<&crate::discovery::RestMethod, GwsError> {
13701373
let users_res = doc
@@ -1381,30 +1384,70 @@ pub(super) fn resolve_send_method(
13811384
.ok_or_else(|| GwsError::Discovery("Method 'users.messages.send' not found".to_string()))
13821385
}
13831386

1384-
/// Build the JSON metadata for `users.messages.send` via the upload endpoint.
1385-
/// Only contains `threadId` when replying/forwarding — the raw RFC 5322 message
1386-
/// is sent as the media part, not base64-encoded in a `raw` field.
1387-
fn build_send_metadata(thread_id: Option<&str>) -> Option<String> {
1388-
thread_id.map(|id| json!({ "threadId": id }).to_string())
1387+
fn resolve_draft_method(
1388+
doc: &crate::discovery::RestDescription,
1389+
) -> Result<&crate::discovery::RestMethod, GwsError> {
1390+
let users_res = doc
1391+
.resources
1392+
.get("users")
1393+
.ok_or_else(|| GwsError::Discovery("Resource 'users' not found".to_string()))?;
1394+
let drafts_res = users_res
1395+
.resources
1396+
.get("drafts")
1397+
.ok_or_else(|| GwsError::Discovery("Resource 'users.drafts' not found".to_string()))?;
1398+
drafts_res
1399+
.methods
1400+
.get("create")
1401+
.ok_or_else(|| GwsError::Discovery("Method 'users.drafts.create' not found".to_string()))
1402+
}
1403+
1404+
/// Resolve either `users.drafts.create` or `users.messages.send` based on the draft flag.
1405+
pub(super) fn resolve_mail_method(
1406+
doc: &crate::discovery::RestDescription,
1407+
draft: bool,
1408+
) -> Result<&crate::discovery::RestMethod, GwsError> {
1409+
if draft {
1410+
resolve_draft_method(doc)
1411+
} else {
1412+
resolve_send_method(doc)
1413+
}
1414+
}
1415+
1416+
/// Build the JSON metadata for the upload endpoint.
1417+
///
1418+
/// For `users.messages.send`: `{"threadId": "..."}` (only when replying/forwarding);
1419+
/// returns `None` for new messages.
1420+
/// For `users.drafts.create`: `{"message": {"threadId": "..."}}` when replying/forwarding,
1421+
/// or `{"message": {}}` for a new draft (wrapper is always required).
1422+
fn build_send_metadata(thread_id: Option<&str>, draft: bool) -> Option<String> {
1423+
if draft {
1424+
let message = match thread_id {
1425+
Some(id) => json!({ "message": { "threadId": id } }),
1426+
None => json!({ "message": {} }),
1427+
};
1428+
Some(message.to_string())
1429+
} else {
1430+
thread_id.map(|id| json!({ "threadId": id }).to_string())
1431+
}
13891432
}
13901433

1391-
pub(super) async fn send_raw_email(
1434+
pub(super) async fn dispatch_raw_email(
13921435
doc: &crate::discovery::RestDescription,
13931436
matches: &ArgMatches,
13941437
raw_message: &str,
13951438
thread_id: Option<&str>,
13961439
existing_token: Option<&str>,
13971440
) -> Result<(), GwsError> {
1398-
let metadata = build_send_metadata(thread_id);
1399-
1400-
let send_method = resolve_send_method(doc)?;
1441+
let draft = matches.get_flag("draft");
1442+
let metadata = build_send_metadata(thread_id, draft);
1443+
let method = resolve_mail_method(doc, draft)?;
14011444
let params = json!({ "userId": "me" });
14021445
let params_str = params.to_string();
14031446

14041447
let (token, auth_method) = match existing_token {
14051448
Some(t) => (Some(t.to_string()), executor::AuthMethod::OAuth),
14061449
None => {
1407-
let scopes: Vec<&str> = send_method.scopes.iter().map(|s| s.as_str()).collect();
1450+
let scopes: Vec<&str> = method.scopes.iter().map(|s| s.as_str()).collect();
14081451
match auth::get_token(&scopes).await {
14091452
Ok(t) => (Some(t), executor::AuthMethod::OAuth),
14101453
Err(e) if matches.get_flag("dry-run") => {
@@ -1424,7 +1467,7 @@ pub(super) async fn send_raw_email(
14241467

14251468
executor::execute_method(
14261469
doc,
1427-
send_method,
1470+
method,
14281471
Some(&params_str),
14291472
metadata.as_deref(),
14301473
token.as_deref(),
@@ -1443,10 +1486,15 @@ pub(super) async fn send_raw_email(
14431486
)
14441487
.await?;
14451488

1489+
if draft && !matches.get_flag("dry-run") {
1490+
eprintln!("Tip: copy the draft \"id\" from the response above, then send with:");
1491+
eprintln!(" gws gmail users.drafts.send --body '{{\"id\":\"<draft-id>\"}}'");
1492+
}
1493+
14461494
Ok(())
14471495
}
14481496

1449-
/// Add --attach, --cc, --bcc, --html, and --dry-run arguments shared by all mail subcommands.
1497+
/// Add common arguments shared by all mail subcommands (--attach, --cc, --bcc, --html, --dry-run, --draft).
14501498
fn common_mail_args(cmd: Command) -> Command {
14511499
cmd.arg(
14521500
Arg::new("attach")
@@ -1480,6 +1528,12 @@ fn common_mail_args(cmd: Command) -> Command {
14801528
.help("Show the request that would be sent without executing it")
14811529
.action(ArgAction::SetTrue),
14821530
)
1531+
.arg(
1532+
Arg::new("draft")
1533+
.long("draft")
1534+
.help("Save as draft instead of sending")
1535+
.action(ArgAction::SetTrue),
1536+
)
14831537
}
14841538

14851539
/// Add arguments shared by +reply and +reply-all (everything except --remove).
@@ -1563,12 +1617,14 @@ EXAMPLES:
15631617
gws gmail +send --to alice@example.com --subject 'Hello' --body 'Hi!' --from alias@example.com
15641618
gws gmail +send --to alice@example.com --subject 'Report' --body 'See attached' -a report.pdf
15651619
gws gmail +send --to alice@example.com --subject 'Files' --body 'Two files' -a a.pdf -a b.csv
1620+
gws gmail +send --to alice@example.com --subject 'Hello' --body 'Hi!' --draft
15661621
15671622
TIPS:
15681623
Handles RFC 5322 formatting, MIME encoding, and base64 automatically.
15691624
Use --from to send from a configured send-as alias instead of your primary address.
15701625
Use -a/--attach to add file attachments. Can be specified multiple times. Total size limit: 25MB.
1571-
With --html, use fragment tags (<p>, <b>, <a>, <br>, etc.) — no <html>/<body> wrapper needed.",
1626+
With --html, use fragment tags (<p>, <b>, <a>, <br>, etc.) — no <html>/<body> wrapper needed.
1627+
Use --draft to save the message as a draft instead of sending it immediately.",
15721628
),
15731629
);
15741630

@@ -1621,6 +1677,7 @@ EXAMPLES:
16211677
gws gmail +reply --message-id 18f1a2b3c4d --body 'Adding Dave' --to dave@example.com
16221678
gws gmail +reply --message-id 18f1a2b3c4d --body '<b>Bold reply</b>' --html
16231679
gws gmail +reply --message-id 18f1a2b3c4d --body 'Updated version' -a updated.docx
1680+
gws gmail +reply --message-id 18f1a2b3c4d --body 'Draft reply' --draft
16241681
16251682
TIPS:
16261683
Automatically sets In-Reply-To, References, and threadId headers.
@@ -1630,6 +1687,7 @@ TIPS:
16301687
With --html, the quoted block uses Gmail's gmail_quote CSS classes and preserves HTML formatting. \
16311688
Use fragment tags (<p>, <b>, <a>, etc.) — no <html>/<body> wrapper needed.
16321689
With --html, inline images in the quoted message are preserved via cid: references.
1690+
Use --draft to save the reply as a draft instead of sending it immediately.
16331691
For reply-all, use +reply-all instead.",
16341692
),
16351693
);
@@ -1653,6 +1711,7 @@ EXAMPLES:
16531711
gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Adding Eve' --cc eve@example.com
16541712
gws gmail +reply-all --message-id 18f1a2b3c4d --body '<i>Noted</i>' --html
16551713
gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Notes attached' -a notes.pdf
1714+
gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Draft reply' --draft
16561715
16571716
TIPS:
16581717
Replies to the sender and all original To/CC recipients.
@@ -1664,7 +1723,8 @@ TIPS:
16641723
Use -a/--attach to add file attachments. Can be specified multiple times.
16651724
With --html, the quoted block uses Gmail's gmail_quote CSS classes and preserves HTML formatting. \
16661725
Use fragment tags (<p>, <b>, <a>, etc.) — no <html>/<body> wrapper needed.
1667-
With --html, inline images in the quoted message are preserved via cid: references.",
1726+
With --html, inline images in the quoted message are preserved via cid: references.
1727+
Use --draft to save the reply as a draft instead of sending it immediately.",
16681728
),
16691729
);
16701730

@@ -1714,6 +1774,7 @@ EXAMPLES:
17141774
gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --body '<p>FYI</p>' --html
17151775
gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com -a notes.pdf
17161776
gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --no-original-attachments
1777+
gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --draft
17171778
17181779
TIPS:
17191780
Includes the original message with sender, date, subject, and recipients.
@@ -1724,7 +1785,8 @@ TIPS:
17241785
Use -a/--attach to add extra file attachments. Can be specified multiple times.
17251786
Combined size of original and user attachments is limited to 25MB.
17261787
With --html, the forwarded block uses Gmail's gmail_quote CSS classes and preserves HTML formatting. \
1727-
Use fragment tags (<p>, <b>, <a>, etc.) — no <html>/<body> wrapper needed.",
1788+
Use fragment tags (<p>, <b>, <a>, etc.) — no <html>/<body> wrapper needed.
1789+
Use --draft to save the forward as a draft instead of sending it immediately.",
17281790
),
17291791
);
17301792

@@ -2273,14 +2335,29 @@ mod tests {
22732335

22742336
#[test]
22752337
fn test_build_send_metadata_with_thread_id() {
2276-
let metadata = build_send_metadata(Some("thread-123")).unwrap();
2338+
let metadata = build_send_metadata(Some("thread-123"), false).unwrap();
22772339
let parsed: Value = serde_json::from_str(&metadata).unwrap();
22782340
assert_eq!(parsed["threadId"], "thread-123");
22792341
}
22802342

22812343
#[test]
22822344
fn test_build_send_metadata_without_thread_id() {
2283-
assert!(build_send_metadata(None).is_none());
2345+
assert!(build_send_metadata(None, false).is_none());
2346+
}
2347+
2348+
#[test]
2349+
fn test_build_send_metadata_draft_with_thread_id() {
2350+
let metadata = build_send_metadata(Some("thread-123"), true).unwrap();
2351+
let parsed: Value = serde_json::from_str(&metadata).unwrap();
2352+
assert_eq!(parsed["message"]["threadId"], "thread-123");
2353+
}
2354+
2355+
#[test]
2356+
fn test_build_send_metadata_draft_without_thread_id() {
2357+
let metadata = build_send_metadata(None, true).unwrap();
2358+
let parsed: Value = serde_json::from_str(&metadata).unwrap();
2359+
assert!(parsed["message"].is_object());
2360+
assert!(parsed["message"].get("threadId").is_none());
22842361
}
22852362

22862363
#[test]
@@ -2406,6 +2483,29 @@ mod tests {
24062483
assert_eq!(resolved.path, "gmail/v1/users/{userId}/messages/send");
24072484
}
24082485

2486+
#[test]
2487+
fn test_resolve_draft_method_finds_gmail_drafts_create_method() {
2488+
let mut doc = crate::discovery::RestDescription::default();
2489+
let create_method = crate::discovery::RestMethod {
2490+
http_method: "POST".to_string(),
2491+
path: "gmail/v1/users/{userId}/drafts".to_string(),
2492+
..Default::default()
2493+
};
2494+
2495+
let mut drafts = crate::discovery::RestResource::default();
2496+
drafts.methods.insert("create".to_string(), create_method);
2497+
2498+
let mut users = crate::discovery::RestResource::default();
2499+
users.resources.insert("drafts".to_string(), drafts);
2500+
2501+
doc.resources = HashMap::from([("users".to_string(), users)]);
2502+
2503+
let resolved = resolve_draft_method(&doc).unwrap();
2504+
2505+
assert_eq!(resolved.http_method, "POST");
2506+
assert_eq!(resolved.path, "gmail/v1/users/{userId}/drafts");
2507+
}
2508+
24092509
#[test]
24102510
fn test_html_escape() {
24112511
assert_eq!(html_escape("Hello World"), "Hello World");

crates/google-workspace-cli/src/helpers/gmail/reply.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ pub(super) async fn handle_reply(
130130

131131
let raw = create_reply_raw_message(&envelope, &original, &all_attachments)?;
132132

133-
super::send_raw_email(
133+
super::dispatch_raw_email(
134134
doc,
135135
matches,
136136
&raw,
@@ -683,7 +683,8 @@ mod tests {
683683
Arg::new("dry-run")
684684
.long("dry-run")
685685
.action(ArgAction::SetTrue),
686-
);
686+
)
687+
.arg(Arg::new("draft").long("draft").action(ArgAction::SetTrue));
687688
cmd.try_get_matches_from(args).unwrap()
688689
}
689690

crates/google-workspace-cli/src/helpers/gmail/send.rs

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,12 @@ pub(super) async fn handle_send(
2525
let token = if dry_run {
2626
None
2727
} else {
28-
// Use the discovery doc scopes (e.g. gmail.send) rather than hardcoding
29-
// gmail.modify, so credentials limited to narrower send-only scopes still
30-
// work. resolve_sender gracefully degrades if the token doesn't cover the
31-
// sendAs.list endpoint.
32-
let send_method = super::resolve_send_method(doc)?;
33-
let scopes: Vec<&str> = send_method.scopes.iter().map(|s| s.as_str()).collect();
28+
// Resolve the target method (send or draft) and use its discovery
29+
// doc scopes, so the token matches the operation. resolve_sender
30+
// gracefully degrades if the token doesn't cover the sendAs.list
31+
// endpoint.
32+
let method = super::resolve_mail_method(doc, matches.get_flag("draft"))?;
33+
let scopes: Vec<&str> = method.scopes.iter().map(|s| s.as_str()).collect();
3434
let t = auth::get_token(&scopes)
3535
.await
3636
.map_err(|e| GwsError::Auth(format!("Gmail auth failed: {e}")))?;
@@ -41,7 +41,7 @@ pub(super) async fn handle_send(
4141

4242
let raw = create_send_raw_message(&config)?;
4343

44-
super::send_raw_email(doc, matches, &raw, None, token.as_deref()).await
44+
super::dispatch_raw_email(doc, matches, &raw, None, token.as_deref()).await
4545
}
4646

4747
pub(super) struct SendConfig {
@@ -108,7 +108,8 @@ mod tests {
108108
.long("attach")
109109
.short('a')
110110
.action(ArgAction::Append),
111-
);
111+
)
112+
.arg(Arg::new("draft").long("draft").action(ArgAction::SetTrue));
112113
cmd.try_get_matches_from(args).unwrap()
113114
}
114115

skills/gws-gmail-forward/SKILL.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ gws gmail +forward --message-id <ID> --to <EMAILS>
3737
| `--bcc` ||| BCC email address(es), comma-separated |
3838
| `--html` ||| Treat --body as HTML content (default is plain text) |
3939
| `--dry-run` ||| Show the request that would be sent without executing it |
40+
| `--draft` ||| Save as draft instead of sending |
4041

4142
## Examples
4243

@@ -47,6 +48,7 @@ gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --cc eve@examp
4748
gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --body '<p>FYI</p>' --html
4849
gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com -a notes.pdf
4950
gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --no-original-attachments
51+
gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --draft
5052
```
5153

5254
## Tips
@@ -59,6 +61,7 @@ gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --no-original-
5961
- Use -a/--attach to add extra file attachments. Can be specified multiple times.
6062
- Combined size of original and user attachments is limited to 25MB.
6163
- With --html, the forwarded block uses Gmail's gmail_quote CSS classes and preserves HTML formatting. Use fragment tags (<p>, <b>, <a>, etc.) — no <html>/<body> wrapper needed.
64+
- Use --draft to save the forward as a draft instead of sending it immediately.
6265

6366
## See Also
6467

skills/gws-gmail-reply-all/SKILL.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ gws gmail +reply-all --message-id <ID> --body <TEXT>
3636
| `--bcc` ||| BCC email address(es), comma-separated |
3737
| `--html` ||| Treat --body as HTML content (default is plain text) |
3838
| `--dry-run` ||| Show the request that would be sent without executing it |
39+
| `--draft` ||| Save as draft instead of sending |
3940
| `--remove` ||| Exclude recipients from the outgoing reply (comma-separated emails) |
4041

4142
## Examples
@@ -46,6 +47,7 @@ gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Updated' --remove bob@exam
4647
gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Adding Eve' --cc eve@example.com
4748
gws gmail +reply-all --message-id 18f1a2b3c4d --body '<i>Noted</i>' --html
4849
gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Notes attached' -a notes.pdf
50+
gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Draft reply' --draft
4951
```
5052

5153
## Tips
@@ -59,6 +61,7 @@ gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Notes attached' -a notes.p
5961
- Use -a/--attach to add file attachments. Can be specified multiple times.
6062
- With --html, the quoted block uses Gmail's gmail_quote CSS classes and preserves HTML formatting. Use fragment tags (<p>, <b>, <a>, etc.) — no <html>/<body> wrapper needed.
6163
- With --html, inline images in the quoted message are preserved via cid: references.
64+
- Use --draft to save the reply as a draft instead of sending it immediately.
6265

6366
## See Also
6467

0 commit comments

Comments
 (0)