Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/add-attachment-flag.md
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.
201 changes: 187 additions & 14 deletions src/helpers/gmail/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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: {}",
Expand All @@ -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('"', "\\\"");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

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-Disposition header. 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 filename parameter. This involves using the filename* 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.

Copy link
Copy Markdown
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 handling attachment filenames in the Content-Disposition header does not correctly handle non-ASCII characters. Sending a filename like résumé.pdf will result in a malformed header for many email clients, as the value is not encoded according to RFC 2231/5987. This can lead to garbled or incorrect filenames for the recipient.

A robust solution involves implementing RFC 2231 encoding for the filename parameter (e.g., filename*=UTF-8''r%C3%A9sum%C3%A9.pdf).

As a simpler, more compatible (though not strictly standard for this header) alternative, you could use RFC 2047 encoding, which is already used elsewhere in this file for other headers. However, you would need a variant of encode_header_value that doesn't produce folded lines.

At a minimum, you should consider sanitizing filenames to be ASCII-only to prevent malformed headers, even if it impacts user experience for international filenames.

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
Copy link
Copy Markdown
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 of sanitize_header_value only removes CR and LF characters. A filename containing a double quote (") will break the Content-Disposition header's filename parameter, as it will be prematurely terminated. For example, a filename my"file.txt would result in a malformed header: filename="my"file.txt".

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
Expand Down Expand Up @@ -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")
Expand All @@ -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.).",
),
);

Expand Down Expand Up @@ -1938,4 +2068,47 @@ mod tests {
<span dir=\"auto\">&lt;<a href=\"mailto:alice@example.com\">alice@example.com</a>&gt;</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_"));
}
}
Loading
Loading