Skip to content

feat(gmail): add --attachment flag to +send, +reply, +reply-all, +forward#395

Open
pae23 wants to merge 5 commits intogoogleworkspace:mainfrom
pae23:feature/gmail-attachment-support
Open

feat(gmail): add --attachment flag to +send, +reply, +reply-all, +forward#395
pae23 wants to merge 5 commits intogoogleworkspace:mainfrom
pae23:feature/gmail-attachment-support

Conversation

@pae23
Copy link

@pae23 pae23 commented Mar 11, 2026

Summary

Adds file attachment support to Gmail helper commands (+send, +reply, +reply-all, +forward).

Currently, sending emails with attachments requires manually constructing a base64-encoded MIME message and passing it via --json '{"raw": "..."}', which hits the Linux execve argument size limit (~128KB) for any non-trivial attachment. This PR adds a simple --attachment flag that handles MIME construction automatically.

Changes

  • New --attachment flag on +send, +reply, +reply-all, +forward — accepts comma-separated file paths
  • Attachment struct and read_attachments() helper in mod.rs
  • build_with_attachments() method on MessageBuilder — builds multipart/mixed MIME with:
    • Random boundary via existing rand crate
    • First part: text/plain body (charset=utf-8)
    • Subsequent parts: base64-encoded file data (76-char lines per RFC 2045)
    • Content-Type auto-detected from file extension (25+ common types)
    • Content-Disposition: attachment with filename
  • Backward compatible — when no --attachment is provided, delegates to existing build() method (identical output)
  • No new dependencies — uses existing rand and base64 crates

Usage

# Single attachment
gws gmail +send --to alice@example.com --subject 'Report' \
  --body 'See attached' --attachment report.pdf

# Multiple attachments
gws gmail +send --to alice@example.com --subject 'Files' \
  --body 'Multiple files' --attachment 'report.pdf,data.csv,fix.zip'

# Reply with attachment
gws gmail +reply --message-id 18f1a2b3c4d \
  --body 'Here is the updated version' --attachment updated.docx

# Forward with attachment
gws gmail +forward --message-id 18f1a2b3c4d --to bob@example.com \
  --body 'Adding context' --attachment notes.pdf

Test plan

  • cargo build passes
  • cargo test — all 97 gmail tests pass (3 new tests added)
  • New tests: test_build_with_attachments_empty, test_build_with_attachments_single, test_build_with_attachments_content_type_detection
  • Manual test: send email with PDF attachment via +send
  • Manual test: send email with multiple attachments
  • Manual test: verify backward compatibility (send without --attachment)

🤖 Generated with Claude Code

@changeset-bot
Copy link

changeset-bot bot commented Mar 11, 2026

🦋 Changeset detected

Latest commit: eb62e53

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@googleworkspace/cli Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@googleworkspace-bot googleworkspace-bot added the area: core Core CLI parsing, commands, error handling, utilities label Mar 11, 2026
@google-cla
Copy link

google-cla bot commented Mar 11, 2026

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

@gemini-code-assist
Copy link
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request significantly enhances the Gmail helper commands by introducing a --attachment flag, enabling users to easily send emails with file attachments. This change streamlines the process by automating the complex MIME message construction, which previously required manual base64 encoding and could hit argument size limits. The new functionality is integrated seamlessly into existing commands, improving usability without adding new external dependencies.

Highlights

  • New --attachment flag: Introduced a --attachment flag for +send, +reply, +reply-all, and +forward commands, allowing users to easily attach files to emails.
  • Automatic MIME construction: Implemented automatic construction of multipart/mixed MIME messages for attachments, handling base64 encoding and content-type detection based on file extensions.
  • Attachment handling utilities: Added an Attachment struct and helper functions (read_attachments, guess_content_type) to manage attachment data and metadata.
  • Backward compatibility and no new dependencies: Ensured the new functionality is backward compatible, delegating to the existing build() method when no attachments are provided, and utilized existing rand and base64 crates without introducing new dependencies.
Changelog
  • src/helpers/gmail/forward.rs
    • Updated handle_forward to parse the --attachment flag and read attachment files.
    • Modified create_forward_raw_message function signature to accept an attachments slice.
    • Changed the message building call from builder.build() to builder.build_with_attachments().
    • Adjusted existing tests to pass an empty attachment slice to create_forward_raw_message.
  • src/helpers/gmail/mod.rs
    • Added Attachment struct to represent file attachments with filename, content type, and data.
    • Implemented guess_content_type function for detecting MIME types from common file extensions.
    • Created read_attachments function to read attachment files from a comma-separated list of paths.
    • Introduced build_with_attachments method to MessageBuilder for constructing multipart/mixed MIME messages with attachments.
    • Added the --attachment argument definition to the GmailHelper for send, reply, reply-all, and forward commands.
    • Updated command examples and tips in the help text to reflect the new attachment functionality.
    • Added new unit tests: test_build_with_attachments_empty, test_build_with_attachments_single, and test_build_with_attachments_content_type_detection.
  • src/helpers/gmail/reply.rs
    • Updated handle_reply to parse the --attachment flag and read attachment files.
    • Modified create_reply_raw_message function signature to accept an attachments slice.
    • Changed the message building call from builder.build() to builder.build_with_attachments().
    • Adjusted existing tests to pass an empty attachment slice to create_reply_raw_message.
  • src/helpers/gmail/send.rs
    • Updated handle_send to parse the --attachment flag and read attachment files.
    • Changed the message building call from builder.build() to builder.build_with_attachments().
Activity
  • Added 3 new tests to cover the attachment functionality, including empty attachments, single attachments, and content type detection.
  • Confirmed that cargo build and cargo test pass with the new changes.
  • Planned manual tests to verify sending emails with single and multiple attachments, and to confirm backward compatibility.
  • The pull request was generated with Claude Code.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Generative AI Prohibited Use Policy, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces file attachment support for several Gmail helper commands, which is a great feature. My review focuses on the new MIME message construction logic. I've found a critical issue where filenames with special characters are not properly escaped, which can lead to corrupted emails. I've also noted significant code duplication in the header generation logic that should be refactored to improve maintainability. The rest of the implementation looks good.

Comment on lines +483 to +495
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,
));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The filename and name parameters for attachments are not correctly escaped. If a filename contains a double quote (") or a backslash (\), it will break the multipart/mixed MIME structure, leading to a corrupt email message. The sanitize_header_value function only removes newlines and does not handle characters that are special inside a quoted-string.

To fix this, you should escape backslashes and double quotes in the filename. For better readability, you could extract this logic into a helper function.

Additionally, non-ASCII characters in filenames are not handled according to RFCs, which can lead to display issues in email clients. The standard way to handle this is with RFC 2231 encoding.

Suggested change
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,
));
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).replace('\\', "\\\\").replace('"', "\\\""),
sanitize_header_value(&att.filename).replace('\\', "\\\\").replace('"', "\\\""),
folded,
));

Comment on lines +432 to +461
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)));
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This block of code for building email headers is almost an exact copy of the logic in the build method (lines 375-405). This duplication makes the code harder to maintain, as any changes to header logic would need to be applied in two places, increasing the risk of bugs. Consider extracting the common header-building logic into a private helper method within impl MessageBuilder and calling it from both build and build_with_attachments.

@pae23
Copy link
Author

pae23 commented Mar 11, 2026

I have signed the CLA - https://cla.developers.google.com/clas

@googleworkspace-bot
Copy link
Collaborator

/gemini review

1 similar comment
@googleworkspace-bot
Copy link
Collaborator

/gemini review

@pae23 pae23 force-pushed the feature/gmail-attachment-support branch 2 times, most recently from 5ea1c1e to b4fa490 Compare March 11, 2026 02:56
pae23 added 3 commits March 11, 2026 03:56
…ward

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.
- 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).
@pae23 pae23 force-pushed the feature/gmail-attachment-support branch from b4fa490 to 3538f28 Compare March 11, 2026 02:56
Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a valuable feature for adding file attachments to emails via the Gmail helper commands. However, a High severity Path Traversal vulnerability has been identified in the read_attachments function, as it reads arbitrary files from the file system based on user-supplied paths without validation. This needs to be addressed by ensuring all paths are within a safe directory, consistent with existing security practices in src/validate.rs. Additionally, the method for parsing attachment paths is not robust for filenames containing commas, and the base64 encoding and line-folding logic contains an unwrap() which could be made more efficient and safer.

Comment on lines +324 to +325
let path = std::path::Path::new(path_str);
let data = std::fs::read(path).map_err(|e| {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The read_attachments function reads files from paths provided directly via the --attachment CLI flag without any validation. This allows for Path Traversal, where an attacker (or a malicious prompt in an LLM-integrated environment) could read arbitrary files that the user running the CLI has access to. Given that this tool is explicitly designed to be used with LLM agents (as noted in src/validate.rs), this is a significant security risk. You should use a validation helper to ensure that all attachment paths are restricted to the current working directory or another safe location.

/// Read attachment files from a comma-separated list of paths.
pub(super) fn read_attachments(paths_csv: &str) -> Result<Vec<Attachment>, GwsError> {
let mut attachments = Vec::new();
for path_str in paths_csv.split(',') {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Using split(',') to parse file paths is not robust, as it will incorrectly split filenames that contain commas. This prevents users from attaching such files.

A more idiomatic and robust approach for command-line tools is to allow the --attachment flag to be specified multiple times. This can be easily configured in clap by using ArgAction::Append.

This would require changes in a few places:

  1. Argument Parsing: Modify the .arg() definition for attachment in inject_commands to use .action(clap::ArgAction::Append).
  2. Handler Logic: In handle_send, handle_reply, etc., retrieve the paths using matches.get_many::<String>("attachment") which provides an iterator.
  3. This Function: Update read_attachments to accept an iterator of paths instead of a single comma-separated string.

This design change would make the feature more reliable and align better with user expectations for handling multiple file inputs.

Comment on lines +457 to +464
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::<Vec<_>>()
.join("\r\n");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This implementation for encoding and folding the base64 string has a couple of issues:

  1. It uses .unwrap(), which could panic. While base64 is ASCII and from_utf8 should not fail, it's better to avoid unwrap() in production code.
  2. It's inefficient. It first encodes the entire file into a single large String, then iterates over its bytes to create a Vec<&str>, and finally joins them into another new String. This involves multiple allocations for large files.

A more robust and efficient approach is to use the base64 crate's display helpers for line wrapping, which streams the encoding and wrapping process without intermediate allocations.

Suggested change
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::<Vec<_>>()
.join("\r\n");
use base64::display::{Base64Display, LineWrap};
// Fold base64 into 76-character lines per RFC 2045.
let folded = Base64Display::new(&att.data, &STANDARD)
.with_config(LineWrap::Wrap(76, base64::display::LineEnding::CRLF));

@googleworkspace-bot
Copy link
Collaborator

/gemini review

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request adds file attachment support to Gmail helper commands. A security review identified a high-severity path traversal vulnerability where user-supplied attachment paths are not validated against the current working directory, which is a requirement for LLM safety in this project. Additionally, double quotes in filenames are not escaped, posing a medium-severity risk. Beyond the security concerns, the implementation could be improved by using the base64 crate's built-in line wrapping for better maintainability and performance, and by converting synchronous file I/O to asynchronous to avoid blocking the runtime.

Comment on lines +324 to +325
let path = std::path::Path::new(path_str);
let data = std::fs::read(path).map_err(|e| {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

This function has a high-severity path traversal vulnerability: read_attachments reads files from user-supplied paths without validation, which could allow a malicious actor to exfiltrate sensitive files. It's crucial to use crate::validate::validate_safe_dir_path to restrict attachment paths to the current working directory. Additionally, the current implementation uses synchronous file I/O (std::fs::read) within an async context, which can block the Tokio runtime. Consider refactoring to use asynchronous file I/O for better performance and to prevent blocking.

Suggested change
let path = std::path::Path::new(path_str);
let data = std::fs::read(path).map_err(|e| {
let path = crate::validate::validate_safe_dir_path(path_str)?;
let data = std::fs::read(&path).map_err(|e| {

Comment on lines +474 to +481
boundary, att.content_type, safe_filename, safe_filename, folded,
));
}

// Closing boundary.
message.push_str(&format!("--{}--\r\n", boundary));

message
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The current implementation for folding base64-encoded lines is done manually. The base64 crate provides built-in support for MIME line wrapping (76 characters per line with CRLF), which is more idiomatic and can be more performant. Using the library's feature simplifies the code and improves maintainability.

Suggested change
boundary, att.content_type, safe_filename, safe_filename, folded,
));
}
// Closing boundary.
message.push_str(&format!("--{}--\r\n", boundary));
message
let folded = base64::engine::general_purpose::STANDARD
.with_config(
base64::engine::LineWrap::Mime,
base64::alphabet::STANDARD.into_padding(),
)
.encode(&att.data);

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a much-needed feature for adding attachments to emails via helper commands. The implementation is comprehensive, covering MIME message construction, content type detection, and argument parsing for multiple commands.

My review focuses on improving robustness and efficiency. I've identified a few high-severity issues:

  • The current implementation reads entire files into memory, which poses a risk of high memory usage and crashes for large attachments.
  • The fallback logic for determining attachment filenames could leak local path information.
  • The Base64 line-wrapping is implemented manually, while a more efficient and idiomatic method is available in the base64 crate.

Addressing these points will make the new feature more robust and performant. The changes are otherwise well-structured and the addition of tests is great.

continue;
}
let path = std::path::Path::new(path_str);
let data = std::fs::read(path).map_err(|e| {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

std::fs::read(path) reads the entire file into memory. For large attachments, this can lead to very high memory usage and potentially cause the CLI to crash with an out-of-memory error. Since a key motivation for this feature is to overcome size limits, it's important to handle large files gracefully.

A more robust solution would involve streaming the file content directly during the MIME part construction instead of pre-loading it into a Vec<u8>.

Comment on lines +332 to +336
let filename = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(path_str)
.to_string();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Using unwrap_or(path_str) as a fallback for the filename is risky. If path.file_name() returns None, the entire input path string is used as the filename. This can leak local filesystem paths (e.g., /home/user/file.txt) into the email attachment's filename parameter, which is an information leak and results in an invalid filename. It's safer to fail with a validation error if a proper filename cannot be determined.

Suggested change
let filename = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(path_str)
.to_string();
let filename = path
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| GwsError::Validation(format!("Could not determine filename for path: {}", path_str)))?
.to_string();

Comment on lines +457 to +464
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::<Vec<_>>()
.join("\r\n");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The current implementation manually folds the base64-encoded string into 76-character lines. This is less efficient and more verbose than using the built-in capabilities of the base64 crate.

The base64 crate provides the MIME engine which correctly implements RFC 2045 line wrapping (76 characters per line, terminated by \r\n). Using it will make the code more concise, idiomatic, and performant.

To apply this, you'll also need to change the use statement on line 437 from STANDARD to MIME:
use base64::engine::general_purpose::MIME;

Suggested change
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::<Vec<_>>()
.join("\r\n");
let folded = MIME.encode(&att.data);

- 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
@googleworkspace-bot
Copy link
Collaborator

/gemini review

@pae23 pae23 force-pushed the feature/gmail-attachment-support branch from 109f6ab to 875ae35 Compare March 11, 2026 03:03
@googleworkspace-bot
Copy link
Collaborator

/gemini review

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a valuable feature for adding attachments to emails via helper commands, with new logic for MIME message construction, attachment reading, and content type guessing. A comprehensive security audit found no high or critical vulnerabilities, indicating the implementation follows secure coding practices, particularly in handling user-supplied file paths and constructing MIME messages. However, an issue was identified regarding the handling of comma-separated file paths for the --attachment flag, which should be addressed.

Comment on lines +652 to +656
Arg::new("attachment")
.long("attachment")
.help("File path to attach (may be repeated)")
.value_name("PATH")
.action(ArgAction::Append),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The --attachment flag is defined to accept repeated occurrences, but it doesn't handle comma-separated values as described in the pull request summary and usage examples (--attachment 'report.pdf,data.csv,fix.zip'). Currently, a comma-separated string would be treated as a single, invalid filename.

To support both repeated flags and comma-separated values, you should add value_delimiter(',') to the argument definition. It would also be good to update the help text to reflect this.

This change should be applied to the argument definitions for +send (here), +reply (around line 757), +reply-all (around line 827), and +forward (around line 906).

Suggested change
Arg::new("attachment")
.long("attachment")
.help("File path to attach (may be repeated)")
.value_name("PATH")
.action(ArgAction::Append),
Arg::new("attachment")
.long("attachment")
.help("File path(s) to attach (comma-separated, or use flag repeatedly)")
.value_name("PATH")
.action(ArgAction::Append)
.value_delimiter(','),

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces attachment support to the Gmail helper commands. However, a critical path traversal vulnerability has been identified in the read_attachments function, allowing arbitrary files to be read and attached without proper directory validation, posing a significant information disclosure risk, especially for LLM agents. Additionally, there's a correctness issue with handling non-ASCII filenames in MIME headers, which could lead to garbled display in email clients.

Comment on lines +328 to +341
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| {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The read_attachments function is vulnerable to path traversal. Using path.canonicalize() on user-supplied paths without validating that the resulting canonical path is within a safe directory (e.g., the current working directory) allows for the reading and exfiltration of arbitrary files. This is a critical information disclosure risk, especially for a tool used by LLM agents, as it bypasses existing security models designed to restrict file access. To remediate this, ensure that the resolved attachment path is strictly confined to the current working directory.

        let canonical = path.canonicalize().map_err(|e| {
            GwsError::Validation(format!(
                "Failed to resolve attachment path '{}': {}",
                path_str,
                e
            ))
        })?;

        let cwd = std::env::current_dir().map_err(|e| {
            GwsError::Validation(format!("Failed to determine current directory: {}", e))
        })?;

        if !canonical.starts_with(&cwd) {
            return Err(GwsError::Validation(format!(
                "Attachment path '{}' resolves outside the current directory, which is not allowed.",
                path_str
            )));
        }

        if !canonical.is_file() {
            return Err(GwsError::Validation(format!(
                "Attachment path '{}' is not a regular file",
                path_str,
            )));
        }

Comment on lines +487 to +496
let safe_filename = escape_quoted_string(&att.filename);
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, safe_filename, safe_filename, folded,
));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The current implementation for attachment filenames does not correctly handle non-ASCII characters. Using non-ASCII characters directly in MIME headers is not robustly supported by all email clients and can lead to garbled or incorrect filenames for the recipient.

To ensure maximum compatibility, you should use the encoding specified in RFC 2231 for parameter values. This involves percent-encoding the filename and using the * suffix (e.g., filename*=UTF-8''...).

The suggested code below implements this by checking if the filename is ASCII. If not, it generates an RFC 2231-compliant parameter.

            let mut content_type_line = format!("Content-Type: {}", att.content_type);
            let mut disposition_line = "Content-Disposition: attachment".to_string();

            if att.filename.is_ascii() {
                let safe_filename = escape_quoted_string(&att.filename);
                content_type_line.push_str(&format!("; name=\"{}\"", safe_filename));
                disposition_line.push_str(&format!("; filename=\"{}\"", safe_filename));
            } else {
                // RFC 2231 for non-ASCII filenames.
                let mut encoded = String::new();
                for &byte in att.filename.as_bytes() {
                    match byte {
                        b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => encoded.push(byte as char),
                        _ => encoded.push_str(&format!("%{:02X}", byte)),
                    }
                }
                let rfc2231_param = format!("UTF-8''{}", encoded);
                content_type_line.push_str(&format!("; name*={}", rfc2231_param));
                disposition_line.push_str(&format!("; filename*={}", rfc2231_param));
            }

            message.push_str(&format!(
                "--{}\r\n{}\r\n{}\r\nContent-Transfer-Encoding: base64\r\n\r\n{}\r\n",
                boundary, content_type_line, disposition_line, folded
            ));

- 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
@pae23 pae23 force-pushed the feature/gmail-attachment-support branch from 6561d7a to eb62e53 Compare March 11, 2026 14:05
@googleworkspace-bot
Copy link
Collaborator

/gemini review

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a valuable feature for adding attachments to emails. The implementation is well-structured, especially the MIME message construction and the security measure against path traversal. However, I've identified a couple of issues within the read_attachments function. Firstly, it doesn't handle comma-separated file paths as documented, which is a functional bug. Secondly, there's a potential information leak in how attachment filenames are determined. I've provided a single, comprehensive code suggestion to address both points, which should make the feature robust and secure.

Comment on lines +332 to +376
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,
});
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This loop has two issues that should be addressed:

  1. Missing comma-separated path handling: The pull request description and examples indicate that the --attachment flag supports comma-separated paths (e.g., --attachment file1.pdf,file2.txt). The current implementation does not split the value by commas, and instead attempts to find a file with a literal name containing commas, which will fail.

  2. Unsafe filename fallback: Using .unwrap_or(path_str) can be unsafe. If canonical.file_name() fails to produce a filename, it falls back to using the raw path_str. This could leak local directory structure in the attachment's filename (e.g., if path_str was path/to/file.pdf) and is not robust. It's safer to return an error if a valid filename cannot be determined from the canonical path.

The suggested change below resolves both issues by introducing a nested loop to process comma-separated paths and by ensuring filename resolution is handled safely.

    for path_list_str in paths {
        for path_str in path_list_str.split(',') {
            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())
                .ok_or_else(|| {
                    GwsError::Other(anyhow::anyhow!(
                        "Could not determine filename for attachment: {}",
                        canonical.display()
                    ))
                })?
                .to_string();
            let content_type = guess_content_type(&filename).to_string();
            attachments.push(Attachment {
                filename,
                content_type,
                data,
            });
        }
    }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: core Core CLI parsing, commands, error handling, utilities

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants