Skip to content

Commit 93997d9

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 77f7f1f commit 93997d9

9 files changed

Lines changed: 150 additions & 30 deletions

File tree

.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

skills/gws-gmail-forward/SKILL.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ gws gmail +forward --message-id <ID> --to <EMAILS>
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

4041
## Examples
4142

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

5153
## Tips
@@ -58,6 +60,7 @@ gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --no-original-
5860
- Use -a/--attach to add extra file attachments. Can be specified multiple times.
5961
- Combined size of original and user attachments is limited to 25MB.
6062
- 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.
63+
- Use --draft to save the forward as a draft instead of sending it immediately.
6164

6265
## See Also
6366

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

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

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

5052
## Tips
@@ -58,6 +60,7 @@ gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Notes attached' -a notes.p
5860
- Use -a/--attach to add file attachments. Can be specified multiple times.
5961
- 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.
6062
- With --html, inline images in the quoted message are preserved via cid: references.
63+
- Use --draft to save the reply as a draft instead of sending it immediately.
6164

6265
## See Also
6366

skills/gws-gmail-reply/SKILL.md

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

3940
## Examples
4041

@@ -44,6 +45,7 @@ gws gmail +reply --message-id 18f1a2b3c4d --body 'Looping in Carol' --cc carol@e
4445
gws gmail +reply --message-id 18f1a2b3c4d --body 'Adding Dave' --to dave@example.com
4546
gws gmail +reply --message-id 18f1a2b3c4d --body '<b>Bold reply</b>' --html
4647
gws gmail +reply --message-id 18f1a2b3c4d --body 'Updated version' -a updated.docx
48+
gws gmail +reply --message-id 18f1a2b3c4d --body 'Draft reply' --draft
4749
```
4850

4951
## Tips
@@ -54,6 +56,7 @@ gws gmail +reply --message-id 18f1a2b3c4d --body 'Updated version' -a updated.do
5456
- Use -a/--attach to add file attachments. Can be specified multiple times.
5557
- 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.
5658
- With --html, inline images in the quoted message are preserved via cid: references.
59+
- Use --draft to save the reply as a draft instead of sending it immediately.
5760
- For reply-all, use +reply-all instead.
5861

5962
## See Also

skills/gws-gmail-send/SKILL.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ gws gmail +send --to <EMAILS> --subject <SUBJECT> --body <TEXT>
3535
| `--bcc` ||| BCC email address(es), comma-separated |
3636
| `--html` ||| Treat --body as HTML content (default is plain text) |
3737
| `--dry-run` ||| Show the request that would be sent without executing it |
38+
| `--draft` ||| Save as draft instead of sending |
3839

3940
## Examples
4041

@@ -45,6 +46,7 @@ gws gmail +send --to alice@example.com --subject 'Hello' --body '<b>Bold</b> tex
4546
gws gmail +send --to alice@example.com --subject 'Hello' --body 'Hi!' --from alias@example.com
4647
gws gmail +send --to alice@example.com --subject 'Report' --body 'See attached' -a report.pdf
4748
gws gmail +send --to alice@example.com --subject 'Files' --body 'Two files' -a a.pdf -a b.csv
49+
gws gmail +send --to alice@example.com --subject 'Hello' --body 'Hi!' --draft
4850
```
4951

5052
## Tips
@@ -53,6 +55,7 @@ gws gmail +send --to alice@example.com --subject 'Files' --body 'Two files' -a a
5355
- Use --from to send from a configured send-as alias instead of your primary address.
5456
- Use -a/--attach to add file attachments. Can be specified multiple times. Total size limit: 25MB.
5557
- With --html, use fragment tags (<p>, <b>, <a>, <br>, etc.) — no <html>/<body> wrapper needed.
58+
- Use --draft to save the message as a draft instead of sending it immediately.
5659

5760
> [!CAUTION]
5861
> This is a **write** command — confirm with the user before executing.

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

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";
@@ -1359,7 +1362,7 @@ pub(super) fn parse_attachments(matches: &ArgMatches) -> Result<Vec<Attachment>,
13591362
Ok(attachments)
13601363
}
13611364

1362-
pub(super) fn resolve_send_method(
1365+
fn resolve_send_method(
13631366
doc: &crate::discovery::RestDescription,
13641367
) -> Result<&crate::discovery::RestMethod, GwsError> {
13651368
let users_res = doc
@@ -1376,30 +1379,70 @@ pub(super) fn resolve_send_method(
13761379
.ok_or_else(|| GwsError::Discovery("Method 'users.messages.send' not found".to_string()))
13771380
}
13781381

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

1386-
pub(super) async fn send_raw_email(
1429+
pub(super) async fn dispatch_raw_email(
13871430
doc: &crate::discovery::RestDescription,
13881431
matches: &ArgMatches,
13891432
raw_message: &str,
13901433
thread_id: Option<&str>,
13911434
existing_token: Option<&str>,
13921435
) -> Result<(), GwsError> {
1393-
let metadata = build_send_metadata(thread_id);
1394-
1395-
let send_method = resolve_send_method(doc)?;
1436+
let draft = matches.get_flag("draft");
1437+
let metadata = build_send_metadata(thread_id, draft);
1438+
let method = resolve_mail_method(doc, draft)?;
13961439
let params = json!({ "userId": "me" });
13971440
let params_str = params.to_string();
13981441

13991442
let (token, auth_method) = match existing_token {
14001443
Some(t) => (Some(t.to_string()), executor::AuthMethod::OAuth),
14011444
None => {
1402-
let scopes: Vec<&str> = send_method.scopes.iter().map(|s| s.as_str()).collect();
1445+
let scopes: Vec<&str> = method.scopes.iter().map(|s| s.as_str()).collect();
14031446
match auth::get_token(&scopes).await {
14041447
Ok(t) => (Some(t), executor::AuthMethod::OAuth),
14051448
Err(e) if matches.get_flag("dry-run") => {
@@ -1419,7 +1462,7 @@ pub(super) async fn send_raw_email(
14191462

14201463
executor::execute_method(
14211464
doc,
1422-
send_method,
1465+
method,
14231466
Some(&params_str),
14241467
metadata.as_deref(),
14251468
token.as_deref(),
@@ -1438,10 +1481,15 @@ pub(super) async fn send_raw_email(
14381481
)
14391482
.await?;
14401483

1484+
if draft && !matches.get_flag("dry-run") {
1485+
eprintln!("Tip: copy the draft \"id\" from the response above, then send with:");
1486+
eprintln!(" gws gmail users.drafts.send --body '{{\"id\":\"<draft-id>\"}}'");
1487+
}
1488+
14411489
Ok(())
14421490
}
14431491

1444-
/// Add --attach, --cc, --bcc, --html, and --dry-run arguments shared by all mail subcommands.
1492+
/// Add common arguments shared by all mail subcommands (--attach, --cc, --bcc, --html, --dry-run, --draft).
14451493
fn common_mail_args(cmd: Command) -> Command {
14461494
cmd.arg(
14471495
Arg::new("attach")
@@ -1475,6 +1523,12 @@ fn common_mail_args(cmd: Command) -> Command {
14751523
.help("Show the request that would be sent without executing it")
14761524
.action(ArgAction::SetTrue),
14771525
)
1526+
.arg(
1527+
Arg::new("draft")
1528+
.long("draft")
1529+
.help("Save as draft instead of sending")
1530+
.action(ArgAction::SetTrue),
1531+
)
14781532
}
14791533

14801534
/// Add arguments shared by +reply and +reply-all (everything except --remove).
@@ -1558,12 +1612,14 @@ EXAMPLES:
15581612
gws gmail +send --to alice@example.com --subject 'Hello' --body 'Hi!' --from alias@example.com
15591613
gws gmail +send --to alice@example.com --subject 'Report' --body 'See attached' -a report.pdf
15601614
gws gmail +send --to alice@example.com --subject 'Files' --body 'Two files' -a a.pdf -a b.csv
1615+
gws gmail +send --to alice@example.com --subject 'Hello' --body 'Hi!' --draft
15611616
15621617
TIPS:
15631618
Handles RFC 5322 formatting, MIME encoding, and base64 automatically.
15641619
Use --from to send from a configured send-as alias instead of your primary address.
15651620
Use -a/--attach to add file attachments. Can be specified multiple times. Total size limit: 25MB.
1566-
With --html, use fragment tags (<p>, <b>, <a>, <br>, etc.) — no <html>/<body> wrapper needed.",
1621+
With --html, use fragment tags (<p>, <b>, <a>, <br>, etc.) — no <html>/<body> wrapper needed.
1622+
Use --draft to save the message as a draft instead of sending it immediately.",
15671623
),
15681624
);
15691625

@@ -1616,6 +1672,7 @@ EXAMPLES:
16161672
gws gmail +reply --message-id 18f1a2b3c4d --body 'Adding Dave' --to dave@example.com
16171673
gws gmail +reply --message-id 18f1a2b3c4d --body '<b>Bold reply</b>' --html
16181674
gws gmail +reply --message-id 18f1a2b3c4d --body 'Updated version' -a updated.docx
1675+
gws gmail +reply --message-id 18f1a2b3c4d --body 'Draft reply' --draft
16191676
16201677
TIPS:
16211678
Automatically sets In-Reply-To, References, and threadId headers.
@@ -1625,6 +1682,7 @@ TIPS:
16251682
With --html, the quoted block uses Gmail's gmail_quote CSS classes and preserves HTML formatting. \
16261683
Use fragment tags (<p>, <b>, <a>, etc.) — no <html>/<body> wrapper needed.
16271684
With --html, inline images in the quoted message are preserved via cid: references.
1685+
Use --draft to save the reply as a draft instead of sending it immediately.
16281686
For reply-all, use +reply-all instead.",
16291687
),
16301688
);
@@ -1648,6 +1706,7 @@ EXAMPLES:
16481706
gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Adding Eve' --cc eve@example.com
16491707
gws gmail +reply-all --message-id 18f1a2b3c4d --body '<i>Noted</i>' --html
16501708
gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Notes attached' -a notes.pdf
1709+
gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Draft reply' --draft
16511710
16521711
TIPS:
16531712
Replies to the sender and all original To/CC recipients.
@@ -1659,7 +1718,8 @@ TIPS:
16591718
Use -a/--attach to add file attachments. Can be specified multiple times.
16601719
With --html, the quoted block uses Gmail's gmail_quote CSS classes and preserves HTML formatting. \
16611720
Use fragment tags (<p>, <b>, <a>, etc.) — no <html>/<body> wrapper needed.
1662-
With --html, inline images in the quoted message are preserved via cid: references.",
1721+
With --html, inline images in the quoted message are preserved via cid: references.
1722+
Use --draft to save the reply as a draft instead of sending it immediately.",
16631723
),
16641724
);
16651725

@@ -1709,6 +1769,7 @@ EXAMPLES:
17091769
gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --body '<p>FYI</p>' --html
17101770
gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com -a notes.pdf
17111771
gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --no-original-attachments
1772+
gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --draft
17121773
17131774
TIPS:
17141775
Includes the original message with sender, date, subject, and recipients.
@@ -1719,7 +1780,8 @@ TIPS:
17191780
Use -a/--attach to add extra file attachments. Can be specified multiple times.
17201781
Combined size of original and user attachments is limited to 25MB.
17211782
With --html, the forwarded block uses Gmail's gmail_quote CSS classes and preserves HTML formatting. \
1722-
Use fragment tags (<p>, <b>, <a>, etc.) — no <html>/<body> wrapper needed.",
1783+
Use fragment tags (<p>, <b>, <a>, etc.) — no <html>/<body> wrapper needed.
1784+
Use --draft to save the forward as a draft instead of sending it immediately.",
17231785
),
17241786
);
17251787

@@ -2268,14 +2330,29 @@ mod tests {
22682330

22692331
#[test]
22702332
fn test_build_send_metadata_with_thread_id() {
2271-
let metadata = build_send_metadata(Some("thread-123")).unwrap();
2333+
let metadata = build_send_metadata(Some("thread-123"), false).unwrap();
22722334
let parsed: Value = serde_json::from_str(&metadata).unwrap();
22732335
assert_eq!(parsed["threadId"], "thread-123");
22742336
}
22752337

22762338
#[test]
22772339
fn test_build_send_metadata_without_thread_id() {
2278-
assert!(build_send_metadata(None).is_none());
2340+
assert!(build_send_metadata(None, false).is_none());
2341+
}
2342+
2343+
#[test]
2344+
fn test_build_send_metadata_draft_with_thread_id() {
2345+
let metadata = build_send_metadata(Some("thread-123"), true).unwrap();
2346+
let parsed: Value = serde_json::from_str(&metadata).unwrap();
2347+
assert_eq!(parsed["message"]["threadId"], "thread-123");
2348+
}
2349+
2350+
#[test]
2351+
fn test_build_send_metadata_draft_without_thread_id() {
2352+
let metadata = build_send_metadata(None, true).unwrap();
2353+
let parsed: Value = serde_json::from_str(&metadata).unwrap();
2354+
assert!(parsed["message"].is_object());
2355+
assert!(parsed["message"].get("threadId").is_none());
22792356
}
22802357

22812358
#[test]
@@ -2401,6 +2478,29 @@ mod tests {
24012478
assert_eq!(resolved.path, "gmail/v1/users/{userId}/messages/send");
24022479
}
24032480

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

0 commit comments

Comments
 (0)