@@ -42,6 +42,9 @@ use std::pin::Pin;
4242
4343pub 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.
4548pub ( super ) const GMAIL_SCOPE : & str = "https://www.googleapis.com/auth/gmail.modify" ;
4649pub ( super ) const GMAIL_READONLY_SCOPE : & str = "https://www.googleapis.com/auth/gmail.readonly" ;
4750pub ( 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) .
14501498fn 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
15671622TIPS:
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
16251682TIPS:
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
16571716TIPS:
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
17181779TIPS:
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" ) ;
0 commit comments