// Copyright 2026 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. use super::*; /// Handle the `+send` subcommand. pub(super) async fn handle_send( doc: &crate::discovery::RestDescription, matches: &ArgMatches, ) -> Result<(), GwsError> { let mut config = parse_send_args(matches)?; let dry_run = matches.get_flag("dry-run"); let token = if dry_run { None } else { // Resolve the target method (send or draft) and use its discovery // doc scopes, so the token matches the operation. resolve_sender // gracefully degrades if the token doesn't cover the sendAs.list // endpoint. let method = super::resolve_mail_method(doc, matches.get_flag("draft"))?; let scopes: Vec<&str> = method.scopes.iter().map(|s| s.as_str()).collect(); let t = auth::get_token(&scopes) .await .map_err(|e| GwsError::Auth(format!("Gmail auth failed: {e}")))?; let client = crate::client::build_client()?; config.from = resolve_sender(&client, &t, config.from.as_deref()).await?; Some(t) }; let raw = create_send_raw_message(&config)?; super::dispatch_raw_email(doc, matches, &raw, None, token.as_deref()).await } pub(super) struct SendConfig { pub to: Vec, pub subject: String, pub body: String, pub from: Option>, pub cc: Option>, pub bcc: Option>, pub html: bool, pub attachments: Vec, } fn create_send_raw_message(config: &SendConfig) -> Result { let mb = mail_builder::MessageBuilder::new() .to(to_mb_address_list(&config.to)) .subject(&config.subject); let mb = apply_optional_headers( mb, config.from.as_deref(), config.cc.as_deref(), config.bcc.as_deref(), ); finalize_message(mb, &config.body, config.html, &config.attachments) } fn parse_send_args(matches: &ArgMatches) -> Result { let to = Mailbox::parse_list(matches.get_one::("to").unwrap()); if to.is_empty() { return Err(GwsError::Validation( "--to must specify at least one recipient".to_string(), )); } Ok(SendConfig { to, subject: matches.get_one::("subject").unwrap().to_string(), body: matches.get_one::("body").unwrap().to_string(), from: parse_optional_mailboxes(matches, "from"), cc: parse_optional_mailboxes(matches, "cc"), bcc: parse_optional_mailboxes(matches, "bcc"), html: matches.get_flag("html"), attachments: parse_attachments(matches)?, }) } #[cfg(test)] mod tests { use super::super::tests::{extract_header, strip_qp_soft_breaks}; use super::*; fn make_matches_send(args: &[&str]) -> ArgMatches { let cmd = Command::new("test") .arg(Arg::new("to").long("to")) .arg(Arg::new("subject").long("subject")) .arg(Arg::new("body").long("body")) .arg(Arg::new("from").long("from")) .arg(Arg::new("cc").long("cc")) .arg(Arg::new("bcc").long("bcc")) .arg(Arg::new("html").long("html").action(ArgAction::SetTrue)) .arg( Arg::new("attach") .long("attach") .short('a') .action(ArgAction::Append), ) .arg(Arg::new("draft").long("draft").action(ArgAction::SetTrue)); cmd.try_get_matches_from(args).unwrap() } #[test] fn test_parse_send_args() { let matches = make_matches_send(&[ "test", "--to", "me@example.com", "--subject", "Hi", "--body", "Body", ]); let config = parse_send_args(&matches).unwrap(); assert_eq!(config.to.len(), 1); assert_eq!(config.to[0].email, "me@example.com"); assert_eq!(config.subject, "Hi"); assert_eq!(config.body, "Body"); assert!(config.from.is_none()); assert!(config.cc.is_none()); assert!(config.bcc.is_none()); } #[test] fn test_parse_send_args_with_from() { let matches = make_matches_send(&[ "test", "--to", "me@example.com", "--subject", "Hi", "--body", "Body", "--from", "alias@example.com", ]); let config = parse_send_args(&matches).unwrap(); assert_eq!(config.from.as_ref().unwrap()[0].email, "alias@example.com"); // Whitespace-only --from becomes None let matches = make_matches_send(&[ "test", "--to", "me@example.com", "--subject", "Hi", "--body", "Body", "--from", " ", ]); let config = parse_send_args(&matches).unwrap(); assert!(config.from.is_none()); } #[test] fn test_parse_send_args_with_cc_and_bcc() { let matches = make_matches_send(&[ "test", "--to", "me@example.com", "--subject", "Hi", "--body", "Body", "--cc", "carol@example.com", "--bcc", "secret@example.com", ]); let config = parse_send_args(&matches).unwrap(); assert_eq!(config.cc.as_ref().unwrap()[0].email, "carol@example.com"); assert_eq!(config.bcc.as_ref().unwrap()[0].email, "secret@example.com"); // Whitespace-only values become None let matches = make_matches_send(&[ "test", "--to", "me@example.com", "--subject", "Hi", "--body", "Body", "--cc", " ", "--bcc", "", ]); let config = parse_send_args(&matches).unwrap(); assert!(config.cc.is_none()); assert!(config.bcc.is_none()); } #[test] fn test_parse_send_args_html_flag() { let matches = make_matches_send(&[ "test", "--to", "me@example.com", "--subject", "Hi", "--body", "Bold", "--html", ]); let config = parse_send_args(&matches).unwrap(); assert!(config.html); // Default is false let matches = make_matches_send(&[ "test", "--to", "me@example.com", "--subject", "Hi", "--body", "Plain", ]); let config = parse_send_args(&matches).unwrap(); assert!(!config.html); } #[test] fn test_parse_send_args_empty_to_returns_error() { let matches = make_matches_send(&["test", "--to", "", "--subject", "Hi", "--body", "Body"]); let err = parse_send_args(&matches).err().unwrap(); assert!( err.to_string().contains("--to"), "error should mention --to" ); } #[test] fn test_send_html_raw_message() { let config = SendConfig { to: Mailbox::parse_list("bob@example.com"), subject: "HTML test".to_string(), body: "

Hello world

".to_string(), from: None, cc: None, bcc: None, html: true, attachments: vec![], }; let raw = create_send_raw_message(&config).unwrap(); let decoded = strip_qp_soft_breaks(&raw); assert!(decoded.contains("text/html")); assert!(extract_header(&raw, "To") .unwrap() .contains("bob@example.com")); assert!(extract_header(&raw, "Subject") .unwrap() .contains("HTML test")); assert!(decoded.contains("

Hello world

")); assert!(extract_header(&raw, "Cc").is_none()); } #[test] fn test_send_plain_text_raw_message() { let config = SendConfig { to: Mailbox::parse_list("bob@example.com"), subject: "Hello".to_string(), body: "World".to_string(), from: None, cc: None, bcc: None, html: false, attachments: vec![], }; let raw = create_send_raw_message(&config).unwrap(); assert!(extract_header(&raw, "To") .unwrap() .contains("bob@example.com")); assert!(extract_header(&raw, "Subject").unwrap().contains("Hello")); assert!(raw.contains("text/plain")); assert!(raw.contains("World")); } #[test] fn test_send_with_cc_and_bcc() { let config = SendConfig { to: Mailbox::parse_list("alice@example.com"), subject: "Test".to_string(), body: "Body".to_string(), from: None, cc: Some(Mailbox::parse_list("carol@example.com")), bcc: Some(Mailbox::parse_list("secret@example.com")), html: false, attachments: vec![], }; let raw = create_send_raw_message(&config).unwrap(); assert!(extract_header(&raw, "To") .unwrap() .contains("alice@example.com")); assert!(extract_header(&raw, "Cc") .unwrap() .contains("carol@example.com")); assert!(extract_header(&raw, "Bcc") .unwrap() .contains("secret@example.com")); // Verify no leakage between headers assert!(!extract_header(&raw, "To") .unwrap() .contains("carol@example.com")); assert!(!extract_header(&raw, "To") .unwrap() .contains("secret@example.com")); } #[test] fn test_send_with_from() { let config = SendConfig { to: Mailbox::parse_list("bob@example.com"), subject: "Test".to_string(), body: "Body".to_string(), from: Some(Mailbox::parse_list("alias@example.com")), cc: None, bcc: None, html: false, attachments: vec![], }; let raw = create_send_raw_message(&config).unwrap(); assert!(extract_header(&raw, "From") .unwrap() .contains("alias@example.com")); assert!(extract_header(&raw, "To") .unwrap() .contains("bob@example.com")); } #[test] fn test_send_without_from_has_no_from_header() { let config = SendConfig { to: Mailbox::parse_list("bob@example.com"), subject: "Test".to_string(), body: "Body".to_string(), from: None, cc: None, bcc: None, html: false, attachments: vec![], }; let raw = create_send_raw_message(&config).unwrap(); assert!(extract_header(&raw, "From").is_none()); } #[test] fn test_send_multiple_to_recipients() { let config = SendConfig { to: Mailbox::parse_list("alice@example.com, bob@example.com"), subject: "Group".to_string(), body: "Hi all".to_string(), from: None, cc: None, bcc: None, html: false, attachments: vec![], }; let raw = create_send_raw_message(&config).unwrap(); let to_header = extract_header(&raw, "To").unwrap(); assert!(to_header.contains("alice@example.com")); assert!(to_header.contains("bob@example.com")); } #[test] fn test_send_crlf_injection_in_from_does_not_create_header() { let config = SendConfig { to: Mailbox::parse_list("alice@example.com"), subject: "Test".to_string(), body: "Body".to_string(), from: Some(Mailbox::parse_list( "sender@example.com\r\nBcc: evil@attacker.com", )), cc: None, bcc: None, html: false, attachments: vec![], }; let raw = create_send_raw_message(&config).unwrap(); // The CRLF injection should not create a Bcc header assert!( extract_header(&raw, "Bcc").is_none(), "CRLF injection via --from should not create Bcc header" ); // The From header should contain the sanitized email assert!(extract_header(&raw, "From") .unwrap() .contains("sender@example.com")); } #[test] fn test_send_crlf_injection_in_cc_does_not_create_header() { let config = SendConfig { to: Mailbox::parse_list("alice@example.com"), subject: "Test".to_string(), body: "Body".to_string(), from: None, cc: Some(Mailbox::parse_list("carol@example.com\r\nX-Injected: yes")), bcc: None, html: false, attachments: vec![], }; let raw = create_send_raw_message(&config).unwrap(); // CRLF stripped → "X-Injected: yes" is concatenated into the email, // not emitted as a separate header line assert!( extract_header(&raw, "X-Injected").is_none(), "CRLF injection via --cc should not create X-Injected header" ); assert!(extract_header(&raw, "Cc") .unwrap() .contains("carol@example.com")); } #[test] fn test_send_with_attachment_produces_multipart() { let config = SendConfig { to: Mailbox::parse_list("alice@example.com"), subject: "Report".to_string(), body: "See attached".to_string(), from: None, cc: None, bcc: None, html: false, attachments: vec![Attachment { filename: "report.pdf".to_string(), content_type: "application/pdf".to_string(), data: b"fake pdf".to_vec(), content_id: None, }], }; let raw = create_send_raw_message(&config).unwrap(); assert!(raw.contains("multipart/mixed")); assert!(raw.contains("report.pdf")); assert!(raw.contains("See attached")); assert!(extract_header(&raw, "To") .unwrap() .contains("alice@example.com")); } }