From 8f34d3c1bf7a93e38c6930bf4d82ed96b193d99f Mon Sep 17 00:00:00 2001 From: pae23 <3509734+pae23@users.noreply.github.com> Date: Wed, 11 Mar 2026 03:47:29 +0100 Subject: [PATCH 1/5] feat(gmail): add --attachment flag to +send, +reply, +reply-all, +forward MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add file attachment support to Gmail helper commands. The --attachment flag accepts comma-separated file paths and builds a proper MIME multipart/mixed message with base64-encoded attachments. When no attachments are provided, behavior is identical to before (simple text/plain message). Content-Type is auto-detected from file extension for 25+ common types, falling back to application/octet-stream. Usage: gws gmail +send --to user@example.com --subject 'Report' \ --body 'See attached' --attachment report.pdf gws gmail +send --to user@example.com --subject 'Files' \ --body 'Multiple files' --attachment 'a.pdf,b.zip' No new dependencies — uses existing rand and base64 crates. --- src/helpers/gmail/forward.rs | 21 ++- src/helpers/gmail/mod.rs | 268 ++++++++++++++++++++++++++++++++++- src/helpers/gmail/reply.rs | 23 ++- src/helpers/gmail/send.rs | 7 +- 4 files changed, 304 insertions(+), 15 deletions(-) diff --git a/src/helpers/gmail/forward.rs b/src/helpers/gmail/forward.rs index 9200b48..710ff04 100644 --- a/src/helpers/gmail/forward.rs +++ b/src/helpers/gmail/forward.rs @@ -36,6 +36,11 @@ pub(super) async fn handle_forward( (orig, Some(t)) }; + let attachments = match parse_optional_trimmed(matches, "attachment") { + Some(paths) => read_attachments(&paths)?, + None => Vec::new(), + }; + let subject = build_forward_subject(&original.subject); let envelope = ForwardEnvelope { to: &config.to, @@ -45,7 +50,7 @@ pub(super) async fn handle_forward( subject: &subject, body: config.body_text.as_deref(), }; - let raw = create_forward_raw_message(&envelope, &original); + let raw = create_forward_raw_message(&envelope, &original, &attachments); super::send_raw_email( doc, @@ -87,7 +92,11 @@ fn build_forward_subject(original_subject: &str) -> String { } } -fn create_forward_raw_message(envelope: &ForwardEnvelope, original: &OriginalMessage) -> String { +fn create_forward_raw_message( + envelope: &ForwardEnvelope, + original: &OriginalMessage, + attachments: &[Attachment], +) -> String { let references = build_references(&original.references, &original.message_id_header); let builder = MessageBuilder { to: envelope.to, @@ -107,7 +116,7 @@ fn create_forward_raw_message(envelope: &ForwardEnvelope, original: &OriginalMes None => forwarded_block, }; - builder.build(&body) + builder.build_with_attachments(&body, attachments) } fn format_forwarded_message(original: &OriginalMessage) -> String { @@ -187,7 +196,7 @@ mod tests { subject: "Fwd: Hello", body: None, }; - let raw = create_forward_raw_message(&envelope, &original); + let raw = create_forward_raw_message(&envelope, &original, &[]); assert!(raw.contains("To: dave@example.com")); assert!(raw.contains("Subject: Fwd: Hello")); @@ -224,7 +233,7 @@ mod tests { subject: "Fwd: Hello", body: Some("FYI see below"), }; - let raw = create_forward_raw_message(&envelope, &original); + let raw = create_forward_raw_message(&envelope, &original, &[]); assert!(raw.contains("Cc: eve@example.com")); assert!(raw.contains("Bcc: secret@example.com")); @@ -256,7 +265,7 @@ mod tests { subject: "Fwd: Hello", body: None, }; - let raw = create_forward_raw_message(&envelope, &original); + let raw = create_forward_raw_message(&envelope, &original, &[]); assert!(raw.contains("In-Reply-To: ")); assert!( diff --git a/src/helpers/gmail/mod.rs b/src/helpers/gmail/mod.rs index bada616..80d04ed 100644 --- a/src/helpers/gmail/mod.rs +++ b/src/helpers/gmail/mod.rs @@ -272,6 +272,78 @@ pub(super) fn encode_header_value(value: &str) -> String { encoded_words.join("\r\n ") } +/// A file attachment to include in an outgoing email. +pub(super) struct Attachment { + pub filename: String, + pub content_type: String, + pub data: Vec, +} + +/// Guess the MIME content type from a file extension. +pub(super) fn guess_content_type(filename: &str) -> &'static str { + let ext = std::path::Path::new(filename) + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_lowercase(); + match ext.as_str() { + "pdf" => "application/pdf", + "zip" => "application/zip", + "gz" | "gzip" => "application/gzip", + "tar" => "application/x-tar", + "txt" => "text/plain", + "md" => "text/markdown", + "html" | "htm" => "text/html", + "csv" => "text/csv", + "json" => "application/json", + "xml" => "application/xml", + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "gif" => "image/gif", + "svg" => "image/svg+xml", + "webp" => "image/webp", + "doc" => "application/msword", + "docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "xls" => "application/vnd.ms-excel", + "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "ppt" => "application/vnd.ms-powerpoint", + "pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "eml" => "message/rfc822", + _ => "application/octet-stream", + } +} + +/// Read attachment files from a comma-separated list of paths. +pub(super) fn read_attachments(paths_csv: &str) -> Result, GwsError> { + let mut attachments = Vec::new(); + for path_str in paths_csv.split(',') { + let path_str = path_str.trim(); + if path_str.is_empty() { + continue; + } + let path = std::path::Path::new(path_str); + let data = std::fs::read(path).map_err(|e| { + GwsError::Other(anyhow::anyhow!( + "Failed to read attachment '{}': {}", + path_str, + e + )) + })?; + let filename = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(path_str) + .to_string(); + let content_type = guess_content_type(&filename).to_string(); + attachments.push(Attachment { + filename, + content_type, + data, + }); + } + Ok(attachments) +} + /// In-Reply-To and References values for threading a reply or forward. pub(super) struct ThreadingHeaders<'a> { pub in_reply_to: &'a str, @@ -334,6 +406,100 @@ impl MessageBuilder<'_> { format!("{}\r\n\r\n{}", headers, body) } + + /// Build a complete RFC 2822 message, optionally with MIME attachments. + /// + /// When `attachments` is empty, delegates to `build()` for backward + /// compatibility. When attachments are present, produces a + /// multipart/mixed message per RFC 2046. + pub fn build_with_attachments(&self, body: &str, attachments: &[Attachment]) -> String { + if attachments.is_empty() { + return self.build(body); + } + + use base64::engine::general_purpose::STANDARD; + use rand::Rng; + + // Generate a random boundary string. + let mut rng = rand::thread_rng(); + let boundary = format!("{:016x}{:016x}", rng.gen::(), rng.gen::()); + + debug_assert!( + !self.to.is_empty(), + "MessageBuilder: `to` must not be empty" + ); + + let mut headers = format!( + "To: {}\r\nSubject: {}", + sanitize_header_value(self.to), + encode_header_value(&sanitize_header_value(self.subject)), + ); + + if let Some(ref threading) = self.threading { + headers.push_str(&format!( + "\r\nIn-Reply-To: {}\r\nReferences: {}", + sanitize_header_value(threading.in_reply_to), + sanitize_header_value(threading.references), + )); + } + + headers.push_str(&format!( + "\r\nMIME-Version: 1.0\r\nContent-Type: multipart/mixed; boundary=\"{}\"", + boundary + )); + + if let Some(from) = self.from { + headers.push_str(&format!("\r\nFrom: {}", sanitize_header_value(from))); + } + + if let Some(cc) = self.cc { + headers.push_str(&format!("\r\nCc: {}", sanitize_header_value(cc))); + } + + if let Some(bcc) = self.bcc { + headers.push_str(&format!("\r\nBcc: {}", sanitize_header_value(bcc))); + } + + // Start the multipart body. + let mut message = format!("{}\r\n\r\n", headers); + + // Text body part. + message.push_str(&format!( + "--{}\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n{}\r\n", + boundary, body + )); + + // Attachment parts. + for att in attachments { + let encoded = STANDARD.encode(&att.data); + // Fold base64 into 76-character lines per RFC 2045. + let folded: String = encoded + .as_bytes() + .chunks(76) + .map(|chunk| std::str::from_utf8(chunk).unwrap()) + .collect::>() + .join("\r\n"); + + message.push_str(&format!( + "--{}\r\n\ + Content-Type: {}; name=\"{}\"\r\n\ + Content-Disposition: attachment; filename=\"{}\"\r\n\ + Content-Transfer-Encoding: base64\r\n\ + \r\n\ + {}\r\n", + boundary, + att.content_type, + sanitize_header_value(&att.filename), + sanitize_header_value(&att.filename), + folded, + )); + } + + // Closing boundary. + message.push_str(&format!("--{}--\r\n", boundary)); + + message + } } /// Build the References header value. Returns just the message ID when there @@ -481,6 +647,12 @@ impl Helper for GmailHelper { .help("BCC email address(es), comma-separated") .value_name("EMAILS"), ) + .arg( + Arg::new("attachment") + .long("attachment") + .help("File path(s) to attach, comma-separated") + .value_name("PATHS"), + ) .arg( Arg::new("dry-run") .long("dry-run") @@ -493,10 +665,12 @@ EXAMPLES: gws gmail +send --to alice@example.com --subject 'Hello' --body 'Hi Alice!' gws gmail +send --to alice@example.com --subject 'Hello' --body 'Hi!' --cc bob@example.com gws gmail +send --to alice@example.com --subject 'Hello' --body 'Hi!' --bcc secret@example.com + gws gmail +send --to alice@example.com --subject 'Report' --body 'See attached' --attachment report.pdf + gws gmail +send --to alice@example.com --subject 'Files' --body 'Multiple' --attachment 'a.pdf,b.zip' TIPS: Handles RFC 2822 formatting and base64 encoding automatically. - For HTML bodies or attachments, use the raw API instead: gws gmail users messages send --json '...'", + For HTML bodies, use the raw API instead: gws gmail users messages send --json '...'", ), ); @@ -577,6 +751,12 @@ TIPS: .help("BCC email address(es), comma-separated") .value_name("EMAILS"), ) + .arg( + Arg::new("attachment") + .long("attachment") + .help("File path(s) to attach, comma-separated") + .value_name("PATHS"), + ) .arg( Arg::new("dry-run") .long("dry-run") @@ -640,6 +820,12 @@ TIPS: .help("BCC email address(es), comma-separated") .value_name("EMAILS"), ) + .arg( + Arg::new("attachment") + .long("attachment") + .help("File path(s) to attach, comma-separated") + .value_name("PATHS"), + ) .arg( Arg::new("remove") .long("remove") @@ -712,6 +898,12 @@ TIPS: .help("Optional note to include above the forwarded message") .value_name("TEXT"), ) + .arg( + Arg::new("attachment") + .long("attachment") + .help("File path(s) to attach, comma-separated") + .value_name("PATHS"), + ) .arg( Arg::new("dry-run") .long("dry-run") @@ -1130,6 +1322,80 @@ mod tests { ); } + #[test] + fn test_build_with_attachments_empty() { + let builder = MessageBuilder { + to: "test@example.com", + subject: "Hello", + from: None, + cc: None, + bcc: None, + threading: None, + }; + + let with_attachments = builder.build_with_attachments("World", &[]); + let without_attachments = builder.build("World"); + + assert_eq!(with_attachments, without_attachments); + } + + #[test] + fn test_build_with_attachments_single() { + let builder = MessageBuilder { + to: "test@example.com", + subject: "Report", + from: None, + cc: None, + bcc: None, + threading: None, + }; + + let attachments = vec![Attachment { + filename: "report.pdf".to_string(), + content_type: "application/pdf".to_string(), + data: b"fake pdf content".to_vec(), + }]; + + let raw = builder.build_with_attachments("See attached", &attachments); + + // Verify multipart structure. + assert!(raw.contains("Content-Type: multipart/mixed; boundary=\"")); + assert!(raw.contains("MIME-Version: 1.0")); + assert!(raw.contains("To: test@example.com")); + assert!(raw.contains("Subject: Report")); + + // Verify text body part. + assert!(raw.contains("Content-Type: text/plain; charset=utf-8\r\n\r\nSee attached")); + + // Verify attachment part. + assert!(raw.contains("Content-Type: application/pdf; name=\"report.pdf\"")); + assert!(raw.contains("Content-Disposition: attachment; filename=\"report.pdf\"")); + assert!(raw.contains("Content-Transfer-Encoding: base64")); + + // Verify closing boundary. + let boundary_start = raw.find("boundary=\"").unwrap() + 10; + let boundary_end = raw[boundary_start..].find('"').unwrap() + boundary_start; + let boundary = &raw[boundary_start..boundary_end]; + assert!(raw.contains(&format!("--{}--", boundary))); + } + + #[test] + fn test_build_with_attachments_content_type_detection() { + assert_eq!(guess_content_type("report.pdf"), "application/pdf"); + assert_eq!(guess_content_type("archive.zip"), "application/zip"); + assert_eq!(guess_content_type("notes.txt"), "text/plain"); + assert_eq!(guess_content_type("readme.md"), "text/markdown"); + assert_eq!(guess_content_type("photo.png"), "image/png"); + assert_eq!(guess_content_type("photo.jpg"), "image/jpeg"); + assert_eq!(guess_content_type("photo.jpeg"), "image/jpeg"); + assert_eq!(guess_content_type("doc.docx"), "application/vnd.openxmlformats-officedocument.wordprocessingml.document"); + assert_eq!(guess_content_type("sheet.xlsx"), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + assert_eq!(guess_content_type("data.csv"), "text/csv"); + assert_eq!(guess_content_type("page.html"), "text/html"); + assert_eq!(guess_content_type("unknown.xyz"), "application/octet-stream"); + assert_eq!(guess_content_type("noext"), "application/octet-stream"); + } + #[test] fn test_resolve_send_method_finds_gmail_send_method() { let mut doc = crate::discovery::RestDescription::default(); diff --git a/src/helpers/gmail/reply.rs b/src/helpers/gmail/reply.rs index 0582b51..302d83a 100644 --- a/src/helpers/gmail/reply.rs +++ b/src/helpers/gmail/reply.rs @@ -94,7 +94,12 @@ pub(super) async fn handle_reply( body: &config.body_text, }; - let raw = create_reply_raw_message(&envelope, &original); + let attachments = match parse_optional_trimmed(matches, "attachment") { + Some(paths) => read_attachments(&paths)?, + None => Vec::new(), + }; + + let raw = create_reply_raw_message(&envelope, &original, &attachments); let auth_token = token.as_ref().map(|(t, _)| t.as_str()); super::send_raw_email(doc, matches, &raw, Some(&original.thread_id), auth_token).await @@ -385,7 +390,11 @@ fn build_reply_subject(original_subject: &str) -> String { } } -fn create_reply_raw_message(envelope: &ReplyEnvelope, original: &OriginalMessage) -> String { +fn create_reply_raw_message( + envelope: &ReplyEnvelope, + original: &OriginalMessage, + attachments: &[Attachment], +) -> String { let builder = MessageBuilder { to: envelope.to, subject: envelope.subject, @@ -400,7 +409,7 @@ fn create_reply_raw_message(envelope: &ReplyEnvelope, original: &OriginalMessage let quoted = format_quoted_original(original); let body = format!("{}\r\n\r\n{}", envelope.body, quoted); - builder.build(&body) + builder.build_with_attachments(&body, attachments) } fn format_quoted_original(original: &OriginalMessage) -> String { @@ -486,7 +495,7 @@ mod tests { references: "", body: "My reply", }; - let raw = create_reply_raw_message(&envelope, &original); + let raw = create_reply_raw_message(&envelope, &original, &[]); assert!(raw.contains("To: alice@example.com")); assert!(raw.contains("Subject: Re: Hello")); @@ -524,7 +533,7 @@ mod tests { references: "", body: "Reply with all headers", }; - let raw = create_reply_raw_message(&envelope, &original); + let raw = create_reply_raw_message(&envelope, &original, &[]); assert!(raw.contains("Cc: carol@example.com")); assert!(raw.contains("Bcc: secret@example.com")); @@ -1281,7 +1290,7 @@ mod tests { references: "", body: "Adding Dave", }; - let raw = create_reply_raw_message(&envelope, &original); + let raw = create_reply_raw_message(&envelope, &original, &[]); assert!(raw.contains("To: alice@example.com, dave@example.com")); assert!(!raw.contains("Cc:")); @@ -1335,7 +1344,7 @@ mod tests { references: "", body: "Hi Bob, nice to meet you!", }; - let raw = create_reply_raw_message(&envelope, &original); + let raw = create_reply_raw_message(&envelope, &original, &[]); assert!(raw.contains("To: bob@example.com")); assert!(!raw.contains("Cc:")); diff --git a/src/helpers/gmail/send.rs b/src/helpers/gmail/send.rs index 785b472..95a9e68 100644 --- a/src/helpers/gmail/send.rs +++ b/src/helpers/gmail/send.rs @@ -6,6 +6,11 @@ pub(super) async fn handle_send( ) -> Result<(), GwsError> { let config = parse_send_args(matches); + let attachments = match parse_optional_trimmed(matches, "attachment") { + Some(paths) => read_attachments(&paths)?, + None => Vec::new(), + }; + let raw = MessageBuilder { to: &config.to, subject: &config.subject, @@ -14,7 +19,7 @@ pub(super) async fn handle_send( bcc: config.bcc.as_deref(), threading: None, } - .build(&config.body_text); + .build_with_attachments(&config.body_text, &attachments); super::send_raw_email(doc, matches, &raw, None, None).await } From f43b0b54900fc6233a481cedfd4fc6f46978fef5 Mon Sep 17 00:00:00 2001 From: pae23 <3509734+pae23@users.noreply.github.com> Date: Wed, 11 Mar 2026 03:51:21 +0100 Subject: [PATCH 2/5] chore: add changeset for gmail attachment feature --- .changeset/gmail-attachment-support.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/gmail-attachment-support.md diff --git a/.changeset/gmail-attachment-support.md b/.changeset/gmail-attachment-support.md new file mode 100644 index 0000000..157ec19 --- /dev/null +++ b/.changeset/gmail-attachment-support.md @@ -0,0 +1,7 @@ +--- +"@googleworkspace/cli": minor +--- + +feat(gmail): add --attachment flag to +send, +reply, +reply-all, +forward + +Adds file attachment support to Gmail helper commands. The `--attachment` flag accepts comma-separated file paths and builds a proper MIME multipart/mixed message with base64-encoded attachments and auto-detected Content-Type. From 3538f28876535ca248cd1eb85a63d7606a44965b Mon Sep 17 00:00:00 2001 From: pae23 <3509734+pae23@users.noreply.github.com> Date: Wed, 11 Mar 2026 03:54:07 +0100 Subject: [PATCH 3/5] fix: address Gemini review feedback - Extract shared header-building logic into `build_headers()` private method, eliminating duplication between `build()` and `build_with_attachments()`. - Add `escape_quoted_string()` helper to properly escape backslashes and double quotes in MIME filename parameters (RFC 2045/2822). --- src/helpers/gmail/mod.rs | 66 ++++++++++++++-------------------------- 1 file changed, 23 insertions(+), 43 deletions(-) diff --git a/src/helpers/gmail/mod.rs b/src/helpers/gmail/mod.rs index 80d04ed..9afc9fa 100644 --- a/src/helpers/gmail/mod.rs +++ b/src/helpers/gmail/mod.rs @@ -364,9 +364,20 @@ pub(super) struct MessageBuilder<'a> { pub threading: Option>, } +/// Escape a filename for use in a MIME quoted-string parameter. +/// Backslashes and double quotes are escaped per RFC 2045/2822. +fn escape_quoted_string(s: &str) -> String { + sanitize_header_value(s) + .replace('\\', "\\\\") + .replace('"', "\\\"") +} + impl MessageBuilder<'_> { - /// Build the complete RFC 2822 message (headers + blank line + body). - pub fn build(&self, body: &str) -> String { + /// Build the common RFC 2822 headers shared by both plain and multipart + /// messages. The `content_type_line` parameter supplies the Content-Type + /// header value (e.g. "text/plain; charset=utf-8" or + /// "multipart/mixed; boundary=\"...\""). + fn build_headers(&self, content_type_line: &str) -> String { debug_assert!( !self.to.is_empty(), "MessageBuilder: `to` must not be empty" @@ -388,7 +399,7 @@ impl MessageBuilder<'_> { )); } - headers.push_str("\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=utf-8"); + headers.push_str(&format!("\r\nMIME-Version: 1.0\r\nContent-Type: {}", content_type_line)); if let Some(from) = self.from { headers.push_str(&format!("\r\nFrom: {}", sanitize_header_value(from))); @@ -404,6 +415,12 @@ impl MessageBuilder<'_> { headers.push_str(&format!("\r\nBcc: {}", sanitize_header_value(bcc))); } + headers + } + + /// Build the complete RFC 2822 message (headers + blank line + body). + pub fn build(&self, body: &str) -> String { + let headers = self.build_headers("text/plain; charset=utf-8"); format!("{}\r\n\r\n{}", headers, body) } @@ -424,41 +441,7 @@ impl MessageBuilder<'_> { let mut rng = rand::thread_rng(); let boundary = format!("{:016x}{:016x}", rng.gen::(), rng.gen::()); - debug_assert!( - !self.to.is_empty(), - "MessageBuilder: `to` must not be empty" - ); - - let mut headers = format!( - "To: {}\r\nSubject: {}", - sanitize_header_value(self.to), - encode_header_value(&sanitize_header_value(self.subject)), - ); - - if let Some(ref threading) = self.threading { - headers.push_str(&format!( - "\r\nIn-Reply-To: {}\r\nReferences: {}", - sanitize_header_value(threading.in_reply_to), - sanitize_header_value(threading.references), - )); - } - - headers.push_str(&format!( - "\r\nMIME-Version: 1.0\r\nContent-Type: multipart/mixed; boundary=\"{}\"", - boundary - )); - - if let Some(from) = self.from { - headers.push_str(&format!("\r\nFrom: {}", sanitize_header_value(from))); - } - - if let Some(cc) = self.cc { - headers.push_str(&format!("\r\nCc: {}", sanitize_header_value(cc))); - } - - if let Some(bcc) = self.bcc { - headers.push_str(&format!("\r\nBcc: {}", sanitize_header_value(bcc))); - } + let headers = self.build_headers(&format!("multipart/mixed; boundary=\"{}\"", boundary)); // Start the multipart body. let mut message = format!("{}\r\n\r\n", headers); @@ -480,6 +463,7 @@ impl MessageBuilder<'_> { .collect::>() .join("\r\n"); + let safe_filename = escape_quoted_string(&att.filename); message.push_str(&format!( "--{}\r\n\ Content-Type: {}; name=\"{}\"\r\n\ @@ -487,11 +471,7 @@ impl MessageBuilder<'_> { Content-Transfer-Encoding: base64\r\n\ \r\n\ {}\r\n", - boundary, - att.content_type, - sanitize_header_value(&att.filename), - sanitize_header_value(&att.filename), - folded, + boundary, att.content_type, safe_filename, safe_filename, folded, )); } From 875ae35dec078dece03e746ad1da007411acc39b Mon Sep 17 00:00:00 2001 From: pae23 <3509734+pae23@users.noreply.github.com> Date: Wed, 11 Mar 2026 04:02:43 +0100 Subject: [PATCH 4/5] fix: address Gemini review round 2 - Change --attachment from comma-separated to ArgAction::Append (multiple --attachment flags, no more fragile split on comma) - Add path canonicalization and regular-file check in read_attachments - Remove unwrap() in base64 folding, use direct byte-index slicing --- src/helpers/gmail/forward.rs | 4 +-- src/helpers/gmail/mod.rs | 65 +++++++++++++++++++++++++----------- src/helpers/gmail/reply.rs | 4 +-- src/helpers/gmail/send.rs | 4 +-- 4 files changed, 51 insertions(+), 26 deletions(-) diff --git a/src/helpers/gmail/forward.rs b/src/helpers/gmail/forward.rs index 710ff04..b4f7c9f 100644 --- a/src/helpers/gmail/forward.rs +++ b/src/helpers/gmail/forward.rs @@ -36,8 +36,8 @@ pub(super) async fn handle_forward( (orig, Some(t)) }; - let attachments = match parse_optional_trimmed(matches, "attachment") { - Some(paths) => read_attachments(&paths)?, + let attachments = match matches.get_many::("attachment") { + Some(paths) => read_attachments(&paths.cloned().collect::>())?, None => Vec::new(), }; diff --git a/src/helpers/gmail/mod.rs b/src/helpers/gmail/mod.rs index 9afc9fa..ea58d5a 100644 --- a/src/helpers/gmail/mod.rs +++ b/src/helpers/gmail/mod.rs @@ -313,23 +313,39 @@ pub(super) fn guess_content_type(filename: &str) -> &'static str { } } -/// Read attachment files from a comma-separated list of paths. -pub(super) fn read_attachments(paths_csv: &str) -> Result, GwsError> { +/// Read attachment files from a list of paths. +/// +/// Each path is canonicalized to resolve symlinks and `..` components; the +/// function rejects paths that do not point to a regular file. +pub(super) fn read_attachments(paths: &[String]) -> Result, GwsError> { let mut attachments = Vec::new(); - for path_str in paths_csv.split(',') { + for path_str in paths { let path_str = path_str.trim(); if path_str.is_empty() { continue; } let path = std::path::Path::new(path_str); - let data = std::fs::read(path).map_err(|e| { + let canonical = path.canonicalize().map_err(|e| { + GwsError::Other(anyhow::anyhow!( + "Failed to resolve attachment path '{}': {}", + path_str, + e + )) + })?; + if !canonical.is_file() { + return Err(GwsError::Other(anyhow::anyhow!( + "Attachment path '{}' is not a regular file", + path_str, + ))); + } + let data = std::fs::read(&canonical).map_err(|e| { GwsError::Other(anyhow::anyhow!( "Failed to read attachment '{}': {}", path_str, e )) })?; - let filename = path + let filename = canonical .file_name() .and_then(|n| n.to_str()) .unwrap_or(path_str) @@ -456,12 +472,17 @@ impl MessageBuilder<'_> { for att in attachments { let encoded = STANDARD.encode(&att.data); // Fold base64 into 76-character lines per RFC 2045. - let folded: String = encoded - .as_bytes() - .chunks(76) - .map(|chunk| std::str::from_utf8(chunk).unwrap()) - .collect::>() - .join("\r\n"); + // Base64 output is pure ASCII, so byte-index slicing is safe. + let mut folded = String::with_capacity(encoded.len() + (encoded.len() / 76) * 2); + let mut offset = 0; + while offset < encoded.len() { + if offset > 0 { + folded.push_str("\r\n"); + } + let end = (offset + 76).min(encoded.len()); + folded.push_str(&encoded[offset..end]); + offset = end; + } let safe_filename = escape_quoted_string(&att.filename); message.push_str(&format!( @@ -630,8 +651,9 @@ impl Helper for GmailHelper { .arg( Arg::new("attachment") .long("attachment") - .help("File path(s) to attach, comma-separated") - .value_name("PATHS"), + .help("File path to attach (may be repeated)") + .value_name("PATH") + .action(ArgAction::Append), ) .arg( Arg::new("dry-run") @@ -646,7 +668,7 @@ EXAMPLES: gws gmail +send --to alice@example.com --subject 'Hello' --body 'Hi!' --cc bob@example.com gws gmail +send --to alice@example.com --subject 'Hello' --body 'Hi!' --bcc secret@example.com gws gmail +send --to alice@example.com --subject 'Report' --body 'See attached' --attachment report.pdf - gws gmail +send --to alice@example.com --subject 'Files' --body 'Multiple' --attachment 'a.pdf,b.zip' + gws gmail +send --to alice@example.com --subject 'Files' --body 'Multiple' --attachment a.pdf --attachment b.zip TIPS: Handles RFC 2822 formatting and base64 encoding automatically. @@ -734,8 +756,9 @@ TIPS: .arg( Arg::new("attachment") .long("attachment") - .help("File path(s) to attach, comma-separated") - .value_name("PATHS"), + .help("File path to attach (may be repeated)") + .value_name("PATH") + .action(ArgAction::Append), ) .arg( Arg::new("dry-run") @@ -803,8 +826,9 @@ TIPS: .arg( Arg::new("attachment") .long("attachment") - .help("File path(s) to attach, comma-separated") - .value_name("PATHS"), + .help("File path to attach (may be repeated)") + .value_name("PATH") + .action(ArgAction::Append), ) .arg( Arg::new("remove") @@ -881,8 +905,9 @@ TIPS: .arg( Arg::new("attachment") .long("attachment") - .help("File path(s) to attach, comma-separated") - .value_name("PATHS"), + .help("File path to attach (may be repeated)") + .value_name("PATH") + .action(ArgAction::Append), ) .arg( Arg::new("dry-run") diff --git a/src/helpers/gmail/reply.rs b/src/helpers/gmail/reply.rs index 302d83a..de4def9 100644 --- a/src/helpers/gmail/reply.rs +++ b/src/helpers/gmail/reply.rs @@ -94,8 +94,8 @@ pub(super) async fn handle_reply( body: &config.body_text, }; - let attachments = match parse_optional_trimmed(matches, "attachment") { - Some(paths) => read_attachments(&paths)?, + let attachments = match matches.get_many::("attachment") { + Some(paths) => read_attachments(&paths.cloned().collect::>())?, None => Vec::new(), }; diff --git a/src/helpers/gmail/send.rs b/src/helpers/gmail/send.rs index 95a9e68..18ecb23 100644 --- a/src/helpers/gmail/send.rs +++ b/src/helpers/gmail/send.rs @@ -6,8 +6,8 @@ pub(super) async fn handle_send( ) -> Result<(), GwsError> { let config = parse_send_args(matches); - let attachments = match parse_optional_trimmed(matches, "attachment") { - Some(paths) => read_attachments(&paths)?, + let attachments = match matches.get_many::("attachment") { + Some(paths) => read_attachments(&paths.cloned().collect::>())?, None => Vec::new(), }; From eb62e53f1a843295b97ee462f4ca0d3f685d8549 Mon Sep 17 00:00:00 2001 From: pae23 <3509734+pae23@users.noreply.github.com> Date: Wed, 11 Mar 2026 15:05:16 +0100 Subject: [PATCH 5/5] fix: address Gemini review round 3 - Validate attachment paths stay under CWD (path traversal protection) consistent with validate.rs patterns - Encode non-ASCII filenames using RFC 2231 (filename*=UTF-8''...) for correct display in all email clients - Add #[derive(Debug)] to Attachment struct - Add tests for path traversal rejection, RFC 2231 encoding, and filename quoting --- src/helpers/gmail/mod.rs | 98 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 91 insertions(+), 7 deletions(-) diff --git a/src/helpers/gmail/mod.rs b/src/helpers/gmail/mod.rs index ea58d5a..259523d 100644 --- a/src/helpers/gmail/mod.rs +++ b/src/helpers/gmail/mod.rs @@ -273,6 +273,7 @@ pub(super) fn encode_header_value(value: &str) -> String { } /// A file attachment to include in an outgoing email. +#[derive(Debug)] pub(super) struct Attachment { pub filename: String, pub content_type: String, @@ -315,16 +316,25 @@ pub(super) fn guess_content_type(filename: &str) -> &'static str { /// Read attachment files from a list of paths. /// -/// Each path is canonicalized to resolve symlinks and `..` components; the -/// function rejects paths that do not point to a regular file. +/// Each path is resolved relative to the current working directory and +/// canonicalized to resolve symlinks and `..` components. The function +/// rejects paths that resolve outside CWD (path traversal protection, +/// consistent with `validate.rs`) or that do not point to a regular file. pub(super) fn read_attachments(paths: &[String]) -> Result, GwsError> { + let cwd = std::env::current_dir().map_err(|e| { + GwsError::Other(anyhow::anyhow!("Failed to determine current directory: {e}")) + })?; + let canonical_cwd = cwd.canonicalize().map_err(|e| { + GwsError::Other(anyhow::anyhow!("Failed to canonicalize current directory: {e}")) + })?; + let mut attachments = Vec::new(); for path_str in paths { let path_str = path_str.trim(); if path_str.is_empty() { continue; } - let path = std::path::Path::new(path_str); + let path = cwd.join(path_str); let canonical = path.canonicalize().map_err(|e| { GwsError::Other(anyhow::anyhow!( "Failed to resolve attachment path '{}': {}", @@ -332,6 +342,13 @@ pub(super) fn read_attachments(paths: &[String]) -> Result, GwsE e )) })?; + if !canonical.starts_with(&canonical_cwd) { + return Err(GwsError::Other(anyhow::anyhow!( + "Attachment '{}' resolves to '{}' which is outside the current directory", + path_str, + canonical.display(), + ))); + } if !canonical.is_file() { return Err(GwsError::Other(anyhow::anyhow!( "Attachment path '{}' is not a regular file", @@ -388,6 +405,29 @@ fn escape_quoted_string(s: &str) -> String { .replace('"', "\\\"") } +/// Encode a filename for MIME Content-Type/Content-Disposition headers. +/// +/// For ASCII-only filenames, returns a simple `name="filename"` pair. +/// For non-ASCII filenames, uses RFC 2231 encoding (`name*=UTF-8''...`) +/// which is supported by all modern email clients. +fn encode_mime_filename(param: &str, filename: &str) -> String { + if filename.is_ascii() { + format!("{}=\"{}\"", param, escape_quoted_string(filename)) + } else { + // RFC 2231: parameter*=charset'language'value + // Percent-encode non-ASCII bytes and RFC 5987 special chars. + let mut encoded = String::new(); + for &byte in filename.as_bytes() { + if byte.is_ascii_alphanumeric() || b"!#$&+-.^_`|~".contains(&byte) { + encoded.push(byte as char); + } else { + encoded.push_str(&format!("%{:02X}", byte)); + } + } + format!("{}*=UTF-8''{}", param, encoded) + } +} + impl MessageBuilder<'_> { /// Build the common RFC 2822 headers shared by both plain and multipart /// messages. The `content_type_line` parameter supplies the Content-Type @@ -484,15 +524,16 @@ impl MessageBuilder<'_> { offset = end; } - let safe_filename = escape_quoted_string(&att.filename); + let ct_name = encode_mime_filename("name", &att.filename); + let cd_filename = encode_mime_filename("filename", &att.filename); message.push_str(&format!( "--{}\r\n\ - Content-Type: {}; name=\"{}\"\r\n\ - Content-Disposition: attachment; filename=\"{}\"\r\n\ + Content-Type: {}; {}\r\n\ + Content-Disposition: attachment; {}\r\n\ Content-Transfer-Encoding: base64\r\n\ \r\n\ {}\r\n", - boundary, att.content_type, safe_filename, safe_filename, folded, + boundary, att.content_type, ct_name, cd_filename, folded, )); } @@ -1423,4 +1464,47 @@ mod tests { assert_eq!(resolved.http_method, "POST"); assert_eq!(resolved.path, "gmail/v1/users/{userId}/messages/send"); } + + #[test] + fn test_encode_mime_filename_ascii() { + assert_eq!( + encode_mime_filename("name", "report.pdf"), + "name=\"report.pdf\"" + ); + } + + #[test] + fn test_encode_mime_filename_with_quotes() { + assert_eq!( + encode_mime_filename("name", "my \"file\".pdf"), + "name=\"my \\\"file\\\".pdf\"" + ); + } + + #[test] + fn test_encode_mime_filename_non_ascii() { + let result = encode_mime_filename("filename", "résumé.pdf"); + assert!(result.starts_with("filename*=UTF-8''")); + assert!(result.contains("r%C3%A9sum%C3%A9.pdf")); + } + + #[test] + fn test_read_attachments_rejects_path_traversal() { + let paths = vec!["../../../etc/passwd".to_string()]; + let result = read_attachments(&paths); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("outside the current directory") || err.contains("Failed to resolve"), + "Expected path traversal rejection, got: {err}" + ); + } + + #[test] + fn test_read_attachments_rejects_empty_paths() { + let paths = vec![" ".to_string(), "".to_string()]; + let result = read_attachments(&paths); + assert!(result.is_ok()); + assert!(result.unwrap().is_empty()); + } }