@@ -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" ;
@@ -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) .
14451493fn 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
15621617TIPS:
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
16201677TIPS:
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
16521711TIPS:
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
17131774TIPS:
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