@@ -40,6 +40,203 @@ pub struct GmailHelper;
4040pub ( super ) const GMAIL_SCOPE : & str = "https://www.googleapis.com/auth/gmail.modify" ;
4141pub ( super ) const PUBSUB_SCOPE : & str = "https://www.googleapis.com/auth/pubsub" ;
4242
43+ pub ( super ) struct OriginalMessage {
44+ pub thread_id : String ,
45+ pub message_id_header : String ,
46+ pub references : String ,
47+ pub from : String ,
48+ pub reply_to : String ,
49+ pub to : String ,
50+ pub cc : String ,
51+ pub subject : String ,
52+ pub date : String ,
53+ pub body_text : String ,
54+ }
55+
56+ impl OriginalMessage {
57+ /// Placeholder used for `--dry-run` to avoid requiring auth/network.
58+ pub ( super ) fn dry_run_placeholder ( message_id : & str ) -> Self {
59+ Self {
60+ thread_id : format ! ( "thread-{message_id}" ) ,
61+ message_id_header : format ! ( "<{message_id}@example.com>" ) ,
62+ references : String :: new ( ) ,
63+ from : "sender@example.com" . to_string ( ) ,
64+ reply_to : String :: new ( ) ,
65+ to : "you@example.com" . to_string ( ) ,
66+ cc : String :: new ( ) ,
67+ subject : "Original subject" . to_string ( ) ,
68+ date : "Thu, 1 Jan 2026 00:00:00 +0000" . to_string ( ) ,
69+ body_text : "Original message body" . to_string ( ) ,
70+ }
71+ }
72+ }
73+
74+ #[ derive( Default ) ]
75+ struct ParsedMessageHeaders {
76+ from : String ,
77+ reply_to : String ,
78+ to : String ,
79+ cc : String ,
80+ subject : String ,
81+ date : String ,
82+ message_id_header : String ,
83+ references : String ,
84+ }
85+
86+ fn append_header_value ( existing : & mut String , value : & str ) {
87+ if !existing. is_empty ( ) {
88+ existing. push ( ' ' ) ;
89+ }
90+ existing. push_str ( value) ;
91+ }
92+
93+ fn parse_message_headers ( headers : & [ Value ] ) -> ParsedMessageHeaders {
94+ let mut parsed = ParsedMessageHeaders :: default ( ) ;
95+
96+ for header in headers {
97+ let name = header. get ( "name" ) . and_then ( |v| v. as_str ( ) ) . unwrap_or ( "" ) ;
98+ let value = header. get ( "value" ) . and_then ( |v| v. as_str ( ) ) . unwrap_or ( "" ) ;
99+
100+ match name {
101+ "From" => parsed. from = value. to_string ( ) ,
102+ "Reply-To" => parsed. reply_to = value. to_string ( ) ,
103+ "To" => parsed. to = value. to_string ( ) ,
104+ "Cc" => parsed. cc = value. to_string ( ) ,
105+ "Subject" => parsed. subject = value. to_string ( ) ,
106+ "Date" => parsed. date = value. to_string ( ) ,
107+ "Message-ID" | "Message-Id" => parsed. message_id_header = value. to_string ( ) ,
108+ "References" => append_header_value ( & mut parsed. references , value) ,
109+ _ => { }
110+ }
111+ }
112+
113+ parsed
114+ }
115+
116+ fn parse_original_message ( msg : & Value ) -> OriginalMessage {
117+ let thread_id = msg
118+ . get ( "threadId" )
119+ . and_then ( |v| v. as_str ( ) )
120+ . unwrap_or ( "" )
121+ . to_string ( ) ;
122+
123+ let snippet = msg
124+ . get ( "snippet" )
125+ . and_then ( |v| v. as_str ( ) )
126+ . unwrap_or ( "" )
127+ . to_string ( ) ;
128+
129+ let parsed_headers = msg
130+ . get ( "payload" )
131+ . and_then ( |p| p. get ( "headers" ) )
132+ . and_then ( |h| h. as_array ( ) )
133+ . map ( |headers| parse_message_headers ( headers) )
134+ . unwrap_or_default ( ) ;
135+
136+ let body_text = msg
137+ . get ( "payload" )
138+ . and_then ( extract_plain_text_body)
139+ . unwrap_or ( snippet) ;
140+
141+ OriginalMessage {
142+ thread_id,
143+ message_id_header : parsed_headers. message_id_header ,
144+ references : parsed_headers. references ,
145+ from : parsed_headers. from ,
146+ reply_to : parsed_headers. reply_to ,
147+ to : parsed_headers. to ,
148+ cc : parsed_headers. cc ,
149+ subject : parsed_headers. subject ,
150+ date : parsed_headers. date ,
151+ body_text,
152+ }
153+ }
154+
155+ pub ( super ) async fn fetch_message_metadata (
156+ client : & reqwest:: Client ,
157+ token : & str ,
158+ message_id : & str ,
159+ ) -> Result < OriginalMessage , GwsError > {
160+ let url = format ! (
161+ "https://gmail.googleapis.com/gmail/v1/users/me/messages/{}" ,
162+ crate :: validate:: encode_path_segment( message_id)
163+ ) ;
164+
165+ let resp = crate :: client:: send_with_retry ( || {
166+ client
167+ . get ( & url)
168+ . bearer_auth ( token)
169+ . query ( & [ ( "format" , "full" ) ] )
170+ } )
171+ . await
172+ . map_err ( |e| GwsError :: Other ( anyhow:: anyhow!( "Failed to fetch message: {e}" ) ) ) ?;
173+
174+ if !resp. status ( ) . is_success ( ) {
175+ let status = resp. status ( ) . as_u16 ( ) ;
176+ let err = resp. text ( ) . await . unwrap_or_default ( ) ;
177+ return Err ( GwsError :: Api {
178+ code : status,
179+ message : format ! ( "Failed to fetch message {message_id}: {err}" ) ,
180+ reason : "fetchFailed" . to_string ( ) ,
181+ enable_url : None ,
182+ } ) ;
183+ }
184+
185+ let msg: Value = resp
186+ . json ( )
187+ . await
188+ . map_err ( |e| GwsError :: Other ( anyhow:: anyhow!( "Failed to parse message: {e}" ) ) ) ?;
189+
190+ Ok ( parse_original_message ( & msg) )
191+ }
192+
193+ fn extract_plain_text_body ( payload : & Value ) -> Option < String > {
194+ let mime_type = payload
195+ . get ( "mimeType" )
196+ . and_then ( |v| v. as_str ( ) )
197+ . unwrap_or ( "" ) ;
198+
199+ if mime_type == "text/plain" {
200+ if let Some ( data) = payload
201+ . get ( "body" )
202+ . and_then ( |b| b. get ( "data" ) )
203+ . and_then ( |d| d. as_str ( ) )
204+ {
205+ if let Ok ( decoded) = URL_SAFE . decode ( data) {
206+ return String :: from_utf8 ( decoded) . ok ( ) ;
207+ }
208+ }
209+ return None ;
210+ }
211+
212+ if let Some ( parts) = payload. get ( "parts" ) . and_then ( |p| p. as_array ( ) ) {
213+ for part in parts {
214+ if let Some ( text) = extract_plain_text_body ( part) {
215+ return Some ( text) ;
216+ }
217+ }
218+ }
219+
220+ None
221+ }
222+
223+ pub ( super ) fn resolve_send_method (
224+ doc : & crate :: discovery:: RestDescription ,
225+ ) -> Result < & crate :: discovery:: RestMethod , GwsError > {
226+ let users_res = doc
227+ . resources
228+ . get ( "users" )
229+ . ok_or_else ( || GwsError :: Discovery ( "Resource 'users' not found" . to_string ( ) ) ) ?;
230+ let messages_res = users_res
231+ . resources
232+ . get ( "messages" )
233+ . ok_or_else ( || GwsError :: Discovery ( "Resource 'users.messages' not found" . to_string ( ) ) ) ?;
234+ messages_res
235+ . methods
236+ . get ( "send" )
237+ . ok_or_else ( || GwsError :: Discovery ( "Method 'users.messages.send' not found" . to_string ( ) ) )
238+ }
239+
43240/// Shared helper: base64-encode a raw RFC 2822 message and send it via
44241/// `users.messages.send`, optionally keeping it in the given thread.
45242pub ( super ) fn build_raw_send_body ( raw_message : & str , thread_id : Option < & str > ) -> Value {
@@ -63,7 +260,7 @@ pub(super) async fn send_raw_email(
63260 let body = build_raw_send_body ( raw_message, thread_id) ;
64261 let body_str = body. to_string ( ) ;
65262
66- let send_method = reply :: resolve_send_method ( doc) ?;
263+ let send_method = resolve_send_method ( doc) ?;
67264 let params = json ! ( { "userId" : "me" } ) ;
68265 let params_str = params. to_string ( ) ;
69266
@@ -479,6 +676,7 @@ TIPS:
479676#[ cfg( test) ]
480677mod tests {
481678 use super :: * ;
679+ use std:: collections:: HashMap ;
482680
483681 #[ test]
484682 fn test_inject_commands ( ) {
@@ -511,4 +709,68 @@ mod tests {
511709 assert_eq ! ( body[ "raw" ] , URL_SAFE . encode( "raw message" ) ) ;
512710 assert ! ( body. get( "threadId" ) . is_none( ) ) ;
513711 }
712+
713+ #[ test]
714+ fn test_parse_original_message_concatenates_repeated_references_headers ( ) {
715+ let msg = json ! ( {
716+ "threadId" : "thread-123" ,
717+ "snippet" : "Snippet fallback" ,
718+ "payload" : {
719+ "mimeType" : "text/html" ,
720+ "headers" : [
721+ { "name" : "From" , "value" : "alice@example.com" } ,
722+ { "name" : "Reply-To" , "value" : "team@example.com" } ,
723+ { "name" : "To" , "value" : "bob@example.com" } ,
724+ { "name" : "Cc" , "value" : "carol@example.com" } ,
725+ { "name" : "Subject" , "value" : "Hello" } ,
726+ { "name" : "Date" , "value" : "Fri, 6 Mar 2026 12:00:00 +0000" } ,
727+ { "name" : "Message-ID" , "value" : "<msg@example.com>" } ,
728+ { "name" : "References" , "value" : "<ref-1@example.com>" } ,
729+ { "name" : "References" , "value" : "<ref-2@example.com>" }
730+ ] ,
731+ "body" : {
732+ "data" : URL_SAFE . encode( "<p>HTML only</p>" )
733+ }
734+ }
735+ } ) ;
736+
737+ let original = parse_original_message ( & msg) ;
738+
739+ assert_eq ! ( original. thread_id, "thread-123" ) ;
740+ assert_eq ! ( original. from, "alice@example.com" ) ;
741+ assert_eq ! ( original. reply_to, "team@example.com" ) ;
742+ assert_eq ! ( original. to, "bob@example.com" ) ;
743+ assert_eq ! ( original. cc, "carol@example.com" ) ;
744+ assert_eq ! ( original. subject, "Hello" ) ;
745+ assert_eq ! ( original. date, "Fri, 6 Mar 2026 12:00:00 +0000" ) ;
746+ assert_eq ! ( original. message_id_header, "<msg@example.com>" ) ;
747+ assert_eq ! (
748+ original. references,
749+ "<ref-1@example.com> <ref-2@example.com>"
750+ ) ;
751+ assert_eq ! ( original. body_text, "Snippet fallback" ) ;
752+ }
753+
754+ #[ test]
755+ fn test_resolve_send_method_finds_gmail_send_method ( ) {
756+ let mut doc = crate :: discovery:: RestDescription :: default ( ) ;
757+ let send_method = crate :: discovery:: RestMethod {
758+ http_method : "POST" . to_string ( ) ,
759+ path : "gmail/v1/users/{userId}/messages/send" . to_string ( ) ,
760+ ..Default :: default ( )
761+ } ;
762+
763+ let mut messages = crate :: discovery:: RestResource :: default ( ) ;
764+ messages. methods . insert ( "send" . to_string ( ) , send_method) ;
765+
766+ let mut users = crate :: discovery:: RestResource :: default ( ) ;
767+ users. resources . insert ( "messages" . to_string ( ) , messages) ;
768+
769+ doc. resources = HashMap :: from ( [ ( "users" . to_string ( ) , users) ] ) ;
770+
771+ let resolved = resolve_send_method ( & doc) . unwrap ( ) ;
772+
773+ assert_eq ! ( resolved. http_method, "POST" ) ;
774+ assert_eq ! ( resolved. path, "gmail/v1/users/{userId}/messages/send" ) ;
775+ }
514776}
0 commit comments