Skip to content

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

Open
pae23 wants to merge 1 commit intogoogleworkspace:mainfrom
pae23:feature/gmail-attachment-support
Open

feat(gmail): add --attachment flag to +send, +reply, +reply-all, +forward#395
pae23 wants to merge 1 commit 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 — uses ArgAction::Append so each file gets its own --attachment flag (avoids comma-parsing issues with filenames)
  • Attachment struct and read_attachments() helper — accepts both relative and absolute paths
  • build_with_attachments() method on MessageBuilder — builds multipart/mixed MIME with shared build_headers(), random boundary, RFC 2045 base64 line-folding, content-type auto-detection (25+ types)
  • Filename security: basename only (no path leakage), quote escaping (RFC 2045/2822), RFC 2231 non-ASCII encoding
  • Backward compatible — delegates to build() when no attachments
  • No new dependencies — uses existing rand and base64 crates

Usage

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 "Two files" --attachment a.pdf --attachment b.csv
gws gmail +reply --message-id 18f1a2b3c4d --body "Updated version" --attachment updated.docx
gws gmail +forward --message-id 18f1a2b3c4d --to bob@example.com --attachment notes.pdf

Test plan

  • cargo build passes
  • cargo test — all 147 gmail tests pass (10 new tests added)
  • Manual test: send email with ZIP attachment via +send

Generated with Claude Code

@changeset-bot
Copy link

changeset-bot bot commented Mar 11, 2026

🦋 Changeset detected

Latest commit: 526fa02

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 3 times, most recently 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);

@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
            ));

@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,
            });
        }
    }

@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 several Gmail helper commands, which is a great feature. The implementation is well-structured, introducing a MessageBuilder to handle MIME message construction. However, I've identified a high-severity security vulnerability in the file reading logic that could expose the tool to a Time-of-check to time-of-use (TOCTOU) race condition, potentially allowing it to read unintended files. My review includes a specific comment with a suggested fix for this issue.

Comment on lines +346 to +358
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
))
})?;
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

There is a Time-of-check to time-of-use (TOCTOU) race condition vulnerability here. The code first checks if the canonical path is a file (canonical.is_file()) and then reads the file in a separate operation (std::fs::read(&canonical)). An attacker could potentially replace the file with a symbolic link to a sensitive file (e.g., /etc/passwd) between these two operations. std::fs::read would then follow the symlink and read the sensitive file.

To mitigate this, you should open the file first to get a file handle, then get its metadata from the handle to verify it's a regular file, and finally read the contents from the same file handle. This ensures you are operating on the exact same file that you checked.

Suggested change
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 data = (|| -> Result<Vec<u8>, anyhow::Error> {
use std::io::Read;
let mut file = std::fs::File::open(&canonical)?;
let metadata = file.metadata()?;
if !metadata.is_file() {
anyhow::bail!("'{}' is not a regular file", path_str);
}
let mut buffer = Vec::with_capacity(metadata.len() as usize);
file.read_to_end(&mut buffer)?;
Ok(buffer)
})()
.map_err(|e| {
GwsError::Other(anyhow::anyhow!(
"Failed to read attachment '{}': {}",
path_str,
e
))
})?;

@pae23 pae23 force-pushed the feature/gmail-attachment-support branch from c1eaa8e to 5b383b6 Compare March 14, 2026 12:52
@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 file attachments to Gmail helper commands. The implementation is well-structured, particularly the MessageBuilder refactoring to support multipart messages. However, I've identified a Time-of-check to Time-of-use (TOCTOU) security vulnerability in the file reading logic. My review includes a detailed explanation and a code suggestion to mitigate this issue.

Comment on lines +576 to +595
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
))
})?;
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 current implementation of reading attachments has a Time-of-check to Time-of-use (TOCTOU) race condition vulnerability. The code first canonicalizes and checks if the path is a file, and then reads it in a separate operation (std::fs::read). An attacker with local access could replace the file with a symbolic link to a sensitive file (e.g., ~/.ssh/id_rsa) between the check and the read operation.

To mitigate this, you should open the file first to get a file handle, then perform checks on the handle's metadata, and finally read the content from the handle. This ensures that you are operating on the same file that you checked.

        let mut file = std::fs::File::open(&path).map_err(|e| {
            GwsError::Other(anyhow::anyhow!(
                "Failed to open attachment path '{}': {}",
                path_str,
                e
            ))
        })?;
        let metadata = file.metadata().map_err(|e| {
            GwsError::Other(anyhow::anyhow!(
                "Failed to get metadata for attachment '{}': {}",
                path_str,
                e
            ))
        })?;
        if !metadata.is_file() {
            return Err(GwsError::Other(anyhow::anyhow!(
                "Attachment path '{}' is not a regular file",
                path_str,
            )));
        }
        let mut data = Vec::with_capacity(metadata.len() as usize);
        use std::io::Read;
        file.read_to_end(&mut data).map_err(|e| {
            GwsError::Other(anyhow::anyhow!(
                "Failed to read attachment '{}': {}",
                path_str,
                e
            ))
        })?;
        let canonical = path.canonicalize().map_err(|e| {
            GwsError::Other(anyhow::anyhow!(
                "Failed to resolve attachment path for filename '{}': {}",
                path_str,
                e
            ))
        })?;

@pae23 pae23 force-pushed the feature/gmail-attachment-support branch from 5b383b6 to 3a66b8f Compare March 14, 2026 17:59
@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 significant and well-implemented feature for adding attachments to emails. The code is generally of high quality, with good attention to security details like preventing path leakage and handling non-ASCII filenames. However, I've identified a critical bug that would produce malformed MIME messages, and a high-severity security vulnerability (TOCTOU) in the file reading logic. My review includes suggestions to fix both of these issues, as addressing critical bugs and security vulnerabilities is essential for the feature's integrity and security.

Comment on lines +778 to +786
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,
));
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 use of a multi-line format! macro with indentation will include the leading whitespace in the resulting string. This will create malformed MIME part headers (e.g., Content-Type: ...), which will likely cause email clients to fail when parsing the attachments. The headers for a MIME part must start at the beginning of the line.

            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,
            ));
References
  1. Fixing critical bugs directly related to the feature being implemented is considered within the primary goal of a pull request, or an acceptable exception to prevent shipping broken code.

Comment on lines +583 to +595
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
))
})?;
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

There is a Time-of-check to time-of-use (TOCTOU) race condition vulnerability here. The code first checks if the canonical path is a file (is_file()) and then reads it using std::fs::read(). An attacker could potentially replace the file with a symbolic link to a sensitive file between the check and the read operation. This could lead to the contents of an unintended file being attached to the email.

To mitigate this, you should open the file to get a file handle, then get its metadata to verify it's a regular file, and finally read the contents from the handle. This ensures the check and the read operation are performed on the exact same file.

        let data = (|| -> std::io::Result<Vec<u8>> {
            let mut file = std::fs::File::open(&canonical)?;
            let metadata = file.metadata()?;
            if !metadata.is_file() {
                return Err(std::io::Error::new(
                    std::io::ErrorKind::InvalidInput,
                    "path is not a regular file",
                ));
            }
            let mut data = Vec::with_capacity(metadata.len() as usize);
            use std::io::Read;
            file.read_to_end(&mut data)?;
            Ok(data)
        })()
        .map_err(|e| {
            GwsError::Other(anyhow::anyhow!(
                "Failed to read attachment '{}': {}",
                path_str,
                e
            ))
        })?;
References
  1. Fixing high-severity security vulnerabilities directly related to the feature being implemented is considered within the primary goal of a pull request, or an acceptable exception to prevent shipping insecure code.

…ward

Add file attachment support to Gmail helper commands. Uses
multipart/mixed MIME construction with proper RFC 2045 base64
line-folding and RFC 2231 non-ASCII filename encoding.

Changes from v1:
- Accept absolute paths (no CWD restriction for read-only attachments)
- Use only basename for filenames (no local path leakage)
- Escape quotes in filenames (RFC 2045/2822 compliance)
- RFC 2231 encoding for non-ASCII filenames
- Use --attachment with ArgAction::Append (multiple flags, no comma parsing issues)
- Respect html flag in multipart body part Content-Type
- Rebased cleanly on upstream/main
@pae23 pae23 force-pushed the feature/gmail-attachment-support branch from 3a66b8f to 526fa02 Compare March 14, 2026 18:04
@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 much-needed feature to add attachments to emails via the --attachment flag for +send, +reply, +reply-all, and +forward commands. The implementation is thorough, covering MIME multipart message construction, base64 encoding, and security considerations for filenames. The changes are well-tested. Additionally, the documentation in the skills/ directory should be updated to reflect this new functionality, as it currently contains outdated information.

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