diff --git a/.changeset/gmail-attachment-support.md b/.changeset/gmail-attachment-support.md new file mode 100644 index 00000000..157ec199 --- /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. diff --git a/src/helpers/gmail/forward.rs b/src/helpers/gmail/forward.rs index 9200b48f..b4f7c9f7 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 matches.get_many::("attachment") { + Some(paths) => read_attachments(&paths.cloned().collect::>())?, + 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 bada6160..259523d1 100644 --- a/src/helpers/gmail/mod.rs +++ b/src/helpers/gmail/mod.rs @@ -272,6 +272,111 @@ pub(super) fn encode_header_value(value: &str) -> String { encoded_words.join("\r\n ") } +/// A file attachment to include in an outgoing email. +#[derive(Debug)] +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 list of paths. +/// +/// 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 = cwd.join(path_str); + let canonical = path.canonicalize().map_err(|e| { + GwsError::Other(anyhow::anyhow!( + "Failed to resolve attachment path '{}': {}", + path_str, + 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", + path_str, + ))); + } + let data = std::fs::read(&canonical).map_err(|e| { + GwsError::Other(anyhow::anyhow!( + "Failed to read attachment '{}': {}", + path_str, + e + )) + })?; + let filename = canonical + .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, @@ -292,9 +397,43 @@ 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('"', "\\\"") +} + +/// 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 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" @@ -316,7 +455,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))); @@ -332,8 +471,77 @@ 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) } + + /// 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::()); + + let headers = self.build_headers(&format!("multipart/mixed; boundary=\"{}\"", boundary)); + + // 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. + // 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 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: {}; {}\r\n\ + Content-Disposition: attachment; {}\r\n\ + Content-Transfer-Encoding: base64\r\n\ + \r\n\ + {}\r\n", + boundary, att.content_type, ct_name, cd_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 +689,13 @@ impl Helper for GmailHelper { .help("BCC email address(es), comma-separated") .value_name("EMAILS"), ) + .arg( + Arg::new("attachment") + .long("attachment") + .help("File path to attach (may be repeated)") + .value_name("PATH") + .action(ArgAction::Append), + ) .arg( Arg::new("dry-run") .long("dry-run") @@ -493,10 +708,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 --attachment 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 +794,13 @@ TIPS: .help("BCC email address(es), comma-separated") .value_name("EMAILS"), ) + .arg( + Arg::new("attachment") + .long("attachment") + .help("File path to attach (may be repeated)") + .value_name("PATH") + .action(ArgAction::Append), + ) .arg( Arg::new("dry-run") .long("dry-run") @@ -640,6 +864,13 @@ TIPS: .help("BCC email address(es), comma-separated") .value_name("EMAILS"), ) + .arg( + Arg::new("attachment") + .long("attachment") + .help("File path to attach (may be repeated)") + .value_name("PATH") + .action(ArgAction::Append), + ) .arg( Arg::new("remove") .long("remove") @@ -712,6 +943,13 @@ TIPS: .help("Optional note to include above the forwarded message") .value_name("TEXT"), ) + .arg( + Arg::new("attachment") + .long("attachment") + .help("File path to attach (may be repeated)") + .value_name("PATH") + .action(ArgAction::Append), + ) .arg( Arg::new("dry-run") .long("dry-run") @@ -1130,6 +1368,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(); @@ -1152,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()); + } } diff --git a/src/helpers/gmail/reply.rs b/src/helpers/gmail/reply.rs index 0582b514..de4def93 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 matches.get_many::("attachment") { + Some(paths) => read_attachments(&paths.cloned().collect::>())?, + 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 785b4721..18ecb23c 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 matches.get_many::("attachment") { + Some(paths) => read_attachments(&paths.cloned().collect::>())?, + 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 }