diff --git a/crates/google-workspace-cli/src/helpers/gmail/mod.rs b/crates/google-workspace-cli/src/helpers/gmail/mod.rs index caeb8b6b..9f20ac04 100644 --- a/crates/google-workspace-cli/src/helpers/gmail/mod.rs +++ b/crates/google-workspace-cli/src/helpers/gmail/mod.rs @@ -1202,9 +1202,15 @@ pub(super) fn finalize_message( let (inline, regular): (Vec<_>, Vec<_>) = attachments.iter().partition(|a| a.is_inline()); let mb = if html && !inline.is_empty() { + let plain_fallback = html_to_plain_text(&body_str); // Build multipart/related: HTML body + inline image parts - let mut related_parts: Vec> = - vec![MimePart::new("text/html", body_str.as_str())]; + let mut related_parts: Vec> = vec![MimePart::new( + "multipart/alternative", + vec![ + MimePart::new("text/plain", plain_fallback), + MimePart::new("text/html", body_str.clone()), + ], + )]; for att in &inline { let cid = att .content_id @@ -1238,7 +1244,8 @@ pub(super) fn finalize_message( // only regular attachments should reach here. If any inline parts do arrive, // they are treated as regular attachments (defense-in-depth). let mb = if html { - mb.html_body(body_str) + mb.text_body(html_to_plain_text(&body_str)) + .html_body(body_str) } else { mb.text_body(body_str) }; @@ -1251,6 +1258,56 @@ pub(super) fn finalize_message( .map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to serialize email: {e}"))) } +fn html_to_plain_text(html: &str) -> String { + let mut text = String::with_capacity(html.len()); + let mut in_tag = false; + let mut tag = String::new(); + + for ch in html.chars() { + match ch { + '<' => { + in_tag = true; + tag.clear(); + } + '>' if in_tag => { + let tag_name = tag + .trim() + .trim_start_matches('/') + .split_whitespace() + .next() + .unwrap_or("") + .to_ascii_lowercase(); + if matches!( + tag_name.as_str(), + "br" | "p" | "div" | "li" | "tr" | "table" | "blockquote" + ) && !text.ends_with('\n') + { + text.push('\n'); + } + in_tag = false; + } + _ if in_tag => tag.push(ch), + _ => text.push(ch), + } + } + + decode_basic_html_entities(&text) + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .collect::>() + .join("\n") +} + +fn decode_basic_html_entities(text: &str) -> String { + text.replace(" ", " ") + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") +} + /// Parse an optional clap argument, trimming whitespace and treating /// empty/whitespace-only values as None. pub(super) fn parse_optional_trimmed(matches: &ArgMatches, name: &str) -> Option { diff --git a/crates/google-workspace-cli/src/helpers/gmail/send.rs b/crates/google-workspace-cli/src/helpers/gmail/send.rs index c40e6480..811e8d9c 100644 --- a/crates/google-workspace-cli/src/helpers/gmail/send.rs +++ b/crates/google-workspace-cli/src/helpers/gmail/send.rs @@ -265,6 +265,9 @@ mod tests { assert!(extract_header(&raw, "Subject") .unwrap() .contains("HTML test")); + assert!(decoded.contains("multipart/alternative")); + assert!(decoded.contains("text/plain")); + assert!(decoded.contains("Hello world")); assert!(decoded.contains("

Hello world

")); assert!(extract_header(&raw, "Cc").is_none()); }