Skip to content

Commit b5f2988

Browse files
committed
fix(gmail): refactor shared reply-forward helpers
1 parent e76b72a commit b5f2988

4 files changed

Lines changed: 271 additions & 197 deletions

File tree

src/helpers/gmail/forward.rs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,15 @@ pub(super) async fn handle_forward(
2424

2525
let (original, token) = if dry_run {
2626
(
27-
super::reply::OriginalMessage::dry_run_placeholder(&config.message_id),
27+
OriginalMessage::dry_run_placeholder(&config.message_id),
2828
None,
2929
)
3030
} else {
3131
let t = auth::get_token(&[GMAIL_SCOPE])
3232
.await
3333
.map_err(|e| GwsError::Auth(format!("Gmail auth failed: {e}")))?;
3434
let client = crate::client::build_client()?;
35-
let orig = super::reply::fetch_message_metadata(&client, &t, &config.message_id).await?;
35+
let orig = fetch_message_metadata(&client, &t, &config.message_id).await?;
3636
(orig, Some(t))
3737
};
3838

@@ -71,7 +71,7 @@ fn create_forward_raw_message(
7171
from: Option<&str>,
7272
subject: &str,
7373
body: Option<&str>,
74-
original: &super::reply::OriginalMessage,
74+
original: &OriginalMessage,
7575
) -> String {
7676
let mut headers = format!(
7777
"To: {}\r\nSubject: {}\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=utf-8",
@@ -94,7 +94,7 @@ fn create_forward_raw_message(
9494
}
9595
}
9696

97-
fn format_forwarded_message(original: &super::reply::OriginalMessage) -> String {
97+
fn format_forwarded_message(original: &OriginalMessage) -> String {
9898
format!(
9999
"---------- Forwarded message ---------\r\n\
100100
From: {}\r\n\
@@ -147,7 +147,7 @@ mod tests {
147147

148148
#[test]
149149
fn test_create_forward_raw_message_without_body() {
150-
let original = super::super::reply::OriginalMessage {
150+
let original = super::super::OriginalMessage {
151151
thread_id: "t1".to_string(),
152152
message_id_header: "<abc@example.com>".to_string(),
153153
references: "".to_string(),
@@ -178,7 +178,7 @@ mod tests {
178178

179179
#[test]
180180
fn test_create_forward_raw_message_with_body_and_cc() {
181-
let original = super::super::reply::OriginalMessage {
181+
let original = super::super::OriginalMessage {
182182
thread_id: "t1".to_string(),
183183
message_id_header: "<abc@example.com>".to_string(),
184184
references: "".to_string(),

src/helpers/gmail/mod.rs

Lines changed: 263 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,203 @@ pub struct GmailHelper;
4040
pub(super) const GMAIL_SCOPE: &str = "https://www.googleapis.com/auth/gmail.modify";
4141
pub(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.
45242
pub(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)]
480677
mod 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

Comments
 (0)