Skip to content
Open
Show file tree
Hide file tree
Changes from all 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.
299 changes: 285 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,181 @@ 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);
let disposition = encode_content_disposition(&att.filename);
message.push_str(&format!(
"--{boundary}\r\n\
Content-Type: {mime_type}\r\n\
Content-Transfer-Encoding: base64\r\n\
{disposition}\r\n\
\r\n\
{encoded}\r\n",
mime_type = att.mime_type,
));
Comment on lines +631 to +639
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 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 a Content-Disposition header for an attachment.
///
/// For ASCII-only filenames, uses the simple `filename="..."` form with
/// backslash/quote escaping per RFC 2183.
///
/// For filenames containing non-ASCII characters, adds an RFC 2231/5987
/// `filename*` parameter with UTF-8 percent-encoding so that international
/// filenames display correctly in email clients.
fn encode_content_disposition(filename: &str) -> String {
let sanitized = sanitize_header_value(filename);
let is_ascii = sanitized.bytes().all(|b| b.is_ascii() && b != b'\0');

if is_ascii {
// Simple form: escape backslashes and quotes
let escaped = sanitized.replace('\\', "\\\\").replace('"', "\\\"");
format!("Content-Disposition: attachment; filename=\"{escaped}\"")
} else {
// RFC 2231: filename*=UTF-8''percent-encoded-name
// Also include a plain ASCII fallback for older clients.
let ascii_fallback = sanitized
.chars()
.map(|c| if c.is_ascii() && c != '"' && c != '\\' { c } else { '_' })
.collect::<String>();
let encoded = percent_encode_filename(&sanitized);
format!(
"Content-Disposition: attachment; filename=\"{ascii_fallback}\"; \
filename*=UTF-8''{encoded}"
)
}
}

/// Percent-encode a filename for RFC 2231 `filename*` parameter.
/// Encodes all non-ASCII bytes and RFC 5987 attr-char special characters.
fn percent_encode_filename(s: &str) -> String {
let mut out = String::with_capacity(s.len() * 3);
for byte in s.bytes() {
// RFC 5987 attr-char allows: ALPHA DIGIT ! # $ & + - . ^ _ ` | ~
if byte.is_ascii_alphanumeric()
|| matches!(byte, b'!' | b'#' | b'$' | b'&' | b'+' | b'-'
| b'.' | b'^' | b'_' | b'`' | b'|' | b'~')
{
out.push(byte as char);
} else {
out.push_str(&format!("%{:02X}", byte));
}
}
out
}

/// Build the References header value. Returns just the message ID when there
Expand Down Expand Up @@ -734,6 +899,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 +917,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 +2111,102 @@ 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_"));
}

// --- Content-Disposition encoding tests ---

#[test]
fn test_encode_content_disposition_ascii() {
let header = encode_content_disposition("report.pdf");
assert_eq!(
header,
"Content-Disposition: attachment; filename=\"report.pdf\""
);
}

#[test]
fn test_encode_content_disposition_escapes_quotes() {
let header = encode_content_disposition("my\"file.pdf");
assert!(header.contains("filename=\"my\\\"file.pdf\""), "got: {header}");
}

#[test]
fn test_encode_content_disposition_escapes_backslash() {
let header = encode_content_disposition("path\\file.pdf");
assert!(header.contains("filename=\"path\\\\file.pdf\""), "got: {header}");
}

#[test]
fn test_encode_content_disposition_non_ascii_uses_rfc2231() {
let header = encode_content_disposition("résumé.pdf");
// Should have both ASCII fallback and RFC 2231 filename*
assert!(header.contains("filename=\"r_sum_.pdf\""), "missing ASCII fallback: {header}");
assert!(header.contains("filename*=UTF-8''r%C3%A9sum%C3%A9.pdf"), "missing RFC 2231: {header}");
}

#[test]
fn test_encode_content_disposition_swedish_chars() {
let header = encode_content_disposition("ärende_åtgärd.pdf");
assert!(header.contains("filename*=UTF-8''"), "should use RFC 2231 for Swedish chars: {header}");
assert!(header.contains("%C3%A4"), "should encode ä: {header}");
assert!(header.contains("%C3%A5"), "should encode å: {header}");
}

#[test]
fn test_percent_encode_filename_ascii() {
assert_eq!(percent_encode_filename("report.pdf"), "report.pdf");
}

#[test]
fn test_percent_encode_filename_spaces() {
assert_eq!(percent_encode_filename("my report.pdf"), "my%20report.pdf");
}

#[test]
fn test_percent_encode_filename_unicode() {
let encoded = percent_encode_filename("résumé.pdf");
assert_eq!(encoded, "r%C3%A9sum%C3%A9.pdf");
}
}
Loading
Loading