76a9e7a0cc
Automation / Format (push) Waiting to run
Automation / File Labeler (push) Waiting to run
Automation / Gemini Reviewed (push) Waiting to run
Coverage / Coverage (push) Waiting to run
Publish OpenClaw Skills / publish (push) Has been cancelled
Audit / Security Audit (push) Has been cancelled
Automation / Gemini Review (push) Has been cancelled
CI / Detect Changes (push) Has been cancelled
CI / Test (macos-latest) (push) Has been cancelled
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Nix (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Cargo Deny (push) Has been cancelled
CI / Verify Skills (push) Has been cancelled
CI / Lint Skills (push) Has been cancelled
CI / Build (Linux x86_64) (push) Has been cancelled
CI / Build (macos-latest, aarch64-apple-darwin) (push) Has been cancelled
CI / Build (macos-latest, x86_64-apple-darwin) (push) Has been cancelled
CI / Build (ubuntu-latest, aarch64-unknown-linux-gnu) (push) Has been cancelled
CI / Build (windows-latest, x86_64-pc-windows-msvc) (push) Has been cancelled
CI / API Smoketest (push) Has been cancelled
Policy / Policy Check (push) Has been cancelled
Release (Changeset) / Release (push) Has been cancelled
462 lines
15 KiB
Rust
462 lines
15 KiB
Rust
// 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<Mailbox>,
|
|
pub subject: String,
|
|
pub body: String,
|
|
pub from: Option<Vec<Mailbox>>,
|
|
pub cc: Option<Vec<Mailbox>>,
|
|
pub bcc: Option<Vec<Mailbox>>,
|
|
pub html: bool,
|
|
pub attachments: Vec<Attachment>,
|
|
}
|
|
|
|
fn create_send_raw_message(config: &SendConfig) -> Result<String, GwsError> {
|
|
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<SendConfig, GwsError> {
|
|
let to = Mailbox::parse_list(matches.get_one::<String>("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::<String>("subject").unwrap().to_string(),
|
|
body: matches.get_one::<String>("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",
|
|
"<b>Bold</b>",
|
|
"--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: "<p>Hello <b>world</b></p>".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("<p>Hello <b>world</b></p>"));
|
|
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"));
|
|
}
|
|
}
|