-
Notifications
You must be signed in to change notification settings - Fork 1.2k
feat(gmail): add --attachment flag to +send helper #517
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 4 commits
1dd90a3
50df01e
f688f05
1bc2917
f3d0fa7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@googleworkspace/cli": minor | ||
| --- | ||
|
|
||
| Add `--attachment` flag to `gmail +send` helper for sending emails with file attachments. Supports multiple files via repeated flags, auto-detects MIME types from extensions, and validates paths against traversal attacks. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -523,8 +523,9 @@ pub(super) struct MessageBuilder<'a> { | |
| } | ||
|
|
||
| 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 simple and multipart | ||
| /// messages: To, Subject, threading, From, Cc, Bcc. | ||
| fn build_common_headers(&self) -> String { | ||
| debug_assert!( | ||
| !self.to.is_empty(), | ||
| "MessageBuilder: `to` must not be empty" | ||
|
|
@@ -546,15 +547,6 @@ impl MessageBuilder<'_> { | |
| )); | ||
| } | ||
|
|
||
| let content_type = if self.html { | ||
| "text/html; charset=utf-8" | ||
| } else { | ||
| "text/plain; charset=utf-8" | ||
| }; | ||
| headers.push_str(&format!( | ||
| "\r\nMIME-Version: 1.0\r\nContent-Type: {content_type}" | ||
| )); | ||
|
|
||
| if let Some(from) = self.from { | ||
| headers.push_str(&format!( | ||
| "\r\nFrom: {}", | ||
|
|
@@ -578,8 +570,138 @@ impl MessageBuilder<'_> { | |
| )); | ||
| } | ||
|
|
||
| headers | ||
| } | ||
|
|
||
| /// Build the complete RFC 2822 message (headers + blank line + body). | ||
| pub fn build(&self, body: &str) -> String { | ||
| let mut headers = self.build_common_headers(); | ||
|
|
||
| let content_type = if self.html { | ||
| "text/html; charset=utf-8" | ||
| } else { | ||
| "text/plain; charset=utf-8" | ||
| }; | ||
| headers.push_str(&format!( | ||
| "\r\nMIME-Version: 1.0\r\nContent-Type: {content_type}" | ||
| )); | ||
|
|
||
| format!("{}\r\n\r\n{}", headers, body) | ||
| } | ||
|
|
||
| /// Build an RFC 2822 multipart/mixed message with file attachments. | ||
| /// | ||
| /// The text (or HTML) body becomes the first MIME part; each attachment | ||
| /// is base64-encoded as a subsequent part. | ||
| pub fn build_with_attachments( | ||
| &self, | ||
| body: &str, | ||
| attachments: &[Attachment], | ||
| ) -> String { | ||
| if attachments.is_empty() { | ||
| return self.build(body); | ||
| } | ||
|
|
||
| let boundary = generate_mime_boundary(); | ||
| let mut headers = self.build_common_headers(); | ||
|
|
||
| headers.push_str(&format!( | ||
| "\r\nMIME-Version: 1.0\r\nContent-Type: multipart/mixed; boundary=\"{boundary}\"" | ||
| )); | ||
|
|
||
| // Body part | ||
| let body_content_type = if self.html { | ||
| "text/html; charset=utf-8" | ||
| } else { | ||
| "text/plain; charset=utf-8" | ||
| }; | ||
|
|
||
| let mut message = format!( | ||
| "{headers}\r\n\r\n\ | ||
| --{boundary}\r\n\ | ||
| Content-Type: {body_content_type}\r\n\ | ||
| \r\n\ | ||
| {body}\r\n" | ||
| ); | ||
|
|
||
| // Attachment parts | ||
| for att in attachments { | ||
| let encoded = base64::engine::general_purpose::STANDARD.encode(&att.data); | ||
| // Escape backslashes and quotes per RFC 2183 to prevent | ||
| // malformed Content-Disposition headers from filenames like: | ||
| // my"file.txt → my\"file.txt | ||
| let safe_filename = sanitize_header_value(&att.filename) | ||
| .replace('\\', "\\\\") | ||
| .replace('"', "\\\""); | ||
|
||
| message.push_str(&format!( | ||
| "--{boundary}\r\n\ | ||
| Content-Type: {mime_type}\r\n\ | ||
| Content-Transfer-Encoding: base64\r\n\ | ||
| Content-Disposition: attachment; filename=\"{filename}\"\r\n\ | ||
| \r\n\ | ||
| {encoded}\r\n", | ||
| mime_type = att.mime_type, | ||
| filename = safe_filename, | ||
| )); | ||
|
Comment on lines
+631
to
+639
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The current implementation of To ensure the header is correctly formatted according to RFC 2183, you should escape backslashes and double quotes within the filename. let encoded = base64::engine::general_purpose::STANDARD.encode(&att.data);
let sanitized_filename = sanitize_header_value(&att.filename)
.replace('\\', "\\\\")
.replace('"', "\\\"");
message.push_str(&format!(
"--{boundary}\r\n\
Content-Type: {mime_type}\r\n\
Content-Transfer-Encoding: base64\r\n\
Content-Disposition: attachment; filename=\"{filename}\"\r\n\
\r\n\
{encoded}\r\n",
mime_type = att.mime_type,
filename = sanitized_filename,
)); |
||
| } | ||
|
|
||
| // Closing boundary | ||
| message.push_str(&format!("--{boundary}--\r\n")); | ||
| message | ||
| } | ||
| } | ||
|
|
||
| /// A file attachment ready to be included in a MIME message. | ||
| pub(super) struct Attachment { | ||
| pub filename: String, | ||
| pub mime_type: String, | ||
| pub data: Vec<u8>, | ||
| } | ||
|
|
||
| /// Generate a unique MIME boundary string. | ||
| fn generate_mime_boundary() -> String { | ||
| use rand::Rng; | ||
| let mut rng = rand::thread_rng(); | ||
| let random: u128 = rng.gen(); | ||
| format!("gws_{random:032x}") | ||
| } | ||
|
|
||
| /// Guess MIME type from a file extension. Falls back to | ||
| /// `application/octet-stream` for unknown extensions. | ||
| pub(super) fn mime_type_from_extension(filename: &str) -> &'static str { | ||
| let ext = filename | ||
| .rsplit('.') | ||
| .next() | ||
| .unwrap_or("") | ||
| .to_ascii_lowercase(); | ||
| match ext.as_str() { | ||
| "pdf" => "application/pdf", | ||
| "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", | ||
| "txt" => "text/plain", | ||
| "csv" => "text/csv", | ||
| "html" | "htm" => "text/html", | ||
| "json" => "application/json", | ||
| "xml" => "application/xml", | ||
| "zip" => "application/zip", | ||
| "gz" | "gzip" => "application/gzip", | ||
| "tar" => "application/x-tar", | ||
| "png" => "image/png", | ||
| "jpg" | "jpeg" => "image/jpeg", | ||
| "gif" => "image/gif", | ||
| "svg" => "image/svg+xml", | ||
| "webp" => "image/webp", | ||
| "mp3" => "audio/mpeg", | ||
| "mp4" => "video/mp4", | ||
| "wav" => "audio/wav", | ||
| "ics" => "text/calendar", | ||
| "eml" => "message/rfc822", | ||
| _ => "application/octet-stream", | ||
| } | ||
| } | ||
|
|
||
| /// Build the References header value. Returns just the message ID when there | ||
|
|
@@ -734,6 +856,13 @@ impl Helper for GmailHelper { | |
| .help("Treat --body as HTML content (default is plain text)") | ||
| .action(ArgAction::SetTrue), | ||
| ) | ||
| .arg( | ||
| Arg::new("attachment") | ||
| .long("attachment") | ||
| .help("Attach a file (can be repeated for multiple files)") | ||
| .action(ArgAction::Append) | ||
| .value_name("PATH"), | ||
| ) | ||
| .arg( | ||
| Arg::new("dry-run") | ||
| .long("dry-run") | ||
|
|
@@ -745,12 +874,13 @@ impl Helper for GmailHelper { | |
| 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 'Docs' --body 'Files attached.' --attachment a.pdf --attachment b.pdf | ||
| gws gmail +send --to alice@example.com --subject 'Hello' --body '<b>Bold</b> text' --html | ||
|
|
||
| TIPS: | ||
| Handles RFC 2822 formatting and base64 encoding automatically. | ||
| For attachments, use the raw API instead: gws gmail users messages send --json '...'", | ||
| Handles RFC 2822 formatting, MIME encoding, and base64 automatically. | ||
| File MIME types are auto-detected from extensions (PDF, DOCX, PNG, etc.).", | ||
| ), | ||
| ); | ||
|
|
||
|
|
@@ -1938,4 +2068,47 @@ mod tests { | |
| <span dir=\"auto\"><<a href=\"mailto:alice@example.com\">alice@example.com</a>></span>" | ||
| ); | ||
| } | ||
|
|
||
| // --- MIME type detection tests --- | ||
|
|
||
| #[test] | ||
| fn test_mime_type_from_extension_common_types() { | ||
| assert_eq!(mime_type_from_extension("report.pdf"), "application/pdf"); | ||
| assert_eq!( | ||
| mime_type_from_extension("doc.docx"), | ||
| "application/vnd.openxmlformats-officedocument.wordprocessingml.document" | ||
| ); | ||
| assert_eq!(mime_type_from_extension("image.png"), "image/png"); | ||
| assert_eq!(mime_type_from_extension("photo.jpg"), "image/jpeg"); | ||
| assert_eq!(mime_type_from_extension("data.csv"), "text/csv"); | ||
| assert_eq!(mime_type_from_extension("archive.zip"), "application/zip"); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_mime_type_from_extension_case_insensitive() { | ||
| assert_eq!(mime_type_from_extension("FILE.PDF"), "application/pdf"); | ||
| assert_eq!(mime_type_from_extension("IMAGE.PNG"), "image/png"); | ||
| assert_eq!(mime_type_from_extension("Doc.DOCX"), | ||
| "application/vnd.openxmlformats-officedocument.wordprocessingml.document"); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_mime_type_from_extension_unknown_fallback() { | ||
| assert_eq!( | ||
| mime_type_from_extension("file.xyz"), | ||
| "application/octet-stream" | ||
| ); | ||
| assert_eq!( | ||
| mime_type_from_extension("noextension"), | ||
| "application/octet-stream" | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_generate_mime_boundary_is_unique() { | ||
| let b1 = generate_mime_boundary(); | ||
| let b2 = generate_mime_boundary(); | ||
| assert_ne!(b1, b2); | ||
| assert!(b1.starts_with("gws_")); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The current filename sanitization does not correctly handle non-ASCII characters. If a filename contains characters outside the ASCII set (e.g., 'résumé.pdf'), they will be inserted directly into the
Content-Dispositionheader. This violates RFC standards that require headers to be ASCII, resulting in a malformed MIME part that may cause issues for email clients.To properly support international filenames, you should use RFC 2231 encoding for the
filenameparameter. This involves using thefilename*parameter with a specified charset and percent-encoding the filename.For example:
Content-Disposition: attachment; filename*="UTF-8''r%C3%A9sum%C3%A9.pdf"Please consider implementing RFC 2231 encoding for non-ASCII filenames to ensure correctness.