From 77f7f1f1264dc4a272cdb937478be8789bab7b61 Mon Sep 17 00:00:00 2001 From: Malo Bourgon Date: Wed, 18 Mar 2026 10:37:56 -0700 Subject: [PATCH 1/2] feat(gmail): forward original attachments and preserve inline images Include original message attachments on +forward by default, matching Gmail web behavior. Add --no-original-attachments flag to opt out (skips file attachments but preserves inline images in HTML mode). Preserve cid: inline images in HTML mode for both +forward and +reply/+reply-all by building the correct multipart/related MIME structure via mail-builder's MimePart API. Gmail's API rewrites Content-Disposition: inline to attachment in multipart/mixed, so explicit multipart/related is required. In plain-text mode, inline images are not included for both forward and reply, matching Gmail web behavior. Key implementation details: - Single-pass MIME payload walker replaces separate text/html extractors - OriginalPart metadata type with lazy attachment data fetching - Part classification uses Content-Disposition to distinguish regular attachments from inline images (some clients set Content-ID on both) - Content-ID and content_type sanitized against CRLF header injection - Size preflight before downloading original attachments - Remote filename sanitization (not rejection) for sender-controlled names - Walker does not recurse into hydratable parts (e.g., message/rfc822) --- .../original-attachments-and-inline-images.md | 10 + skills/gws-gmail-forward/SKILL.md | 10 +- skills/gws-gmail-reply-all/SKILL.md | 2 +- skills/gws-gmail-reply/SKILL.md | 2 +- src/helpers/gmail/forward.rs | 227 +++- src/helpers/gmail/mod.rs | 969 ++++++++++++++++-- src/helpers/gmail/reply.rs | 81 +- src/helpers/gmail/send.rs | 1 + 8 files changed, 1223 insertions(+), 79 deletions(-) create mode 100644 .changeset/original-attachments-and-inline-images.md diff --git a/.changeset/original-attachments-and-inline-images.md b/.changeset/original-attachments-and-inline-images.md new file mode 100644 index 00000000..49fd1ae6 --- /dev/null +++ b/.changeset/original-attachments-and-inline-images.md @@ -0,0 +1,10 @@ +--- +"@googleworkspace/cli": minor +--- + +Forward original attachments by default and preserve inline images in HTML mode. + +`+forward` now includes the original message's attachments and inline images by default, +matching Gmail web behavior. Use `--no-original-attachments` to opt out. +`+reply`/`+reply-all` with `--html` preserve inline images in the quoted body via +`multipart/related`. In plain-text mode, inline images are not included (matching Gmail web). diff --git a/skills/gws-gmail-forward/SKILL.md b/skills/gws-gmail-forward/SKILL.md index 71d7fa41..bab37b1e 100644 --- a/skills/gws-gmail-forward/SKILL.md +++ b/skills/gws-gmail-forward/SKILL.md @@ -30,6 +30,7 @@ gws gmail +forward --message-id --to | `--to` | ✓ | — | Recipient email address(es), comma-separated | | `--from` | — | — | Sender address (for send-as/alias; omit to use account default) | | `--body` | — | — | Optional note to include above the forwarded message (plain text, or HTML with --html) | +| `--no-original-attachments` | — | — | Do not include file attachments from the original message (inline images in --html mode are preserved) | | `--attach` | — | — | Attach a file (can be specified multiple times) | | `--cc` | — | — | CC email address(es), comma-separated | | `--bcc` | — | — | BCC email address(es), comma-separated | @@ -44,14 +45,19 @@ gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --body 'FYI se gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --cc eve@example.com gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --body '

FYI

' --html gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com -a notes.pdf +gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --no-original-attachments ``` ## Tips - Includes the original message with sender, date, subject, and recipients. -- Use -a/--attach to add file attachments. Can be specified multiple times. +- Original attachments are included by default (matching Gmail web behavior). +- With --html, inline images are also preserved via cid: references. +- In plain-text mode, inline images are not included (matching Gmail web). +- Use --no-original-attachments to forward without the original message's files. +- Use -a/--attach to add extra file attachments. Can be specified multiple times. +- Combined size of original and user attachments is limited to 25MB. - With --html, the forwarded block uses Gmail's gmail_quote CSS classes and preserves HTML formatting. Use fragment tags (

, , , etc.) — no / wrapper needed. -- With --html, inline images in the forwarded message (cid: references) will appear broken. Externally hosted images are unaffected. ## See Also diff --git a/skills/gws-gmail-reply-all/SKILL.md b/skills/gws-gmail-reply-all/SKILL.md index 92efcdda..e75fb025 100644 --- a/skills/gws-gmail-reply-all/SKILL.md +++ b/skills/gws-gmail-reply-all/SKILL.md @@ -57,7 +57,7 @@ gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Notes attached' -a notes.p - The command fails if no To recipient remains after exclusions and --to additions. - Use -a/--attach to add file attachments. Can be specified multiple times. - With --html, the quoted block uses Gmail's gmail_quote CSS classes and preserves HTML formatting. Use fragment tags (

, , , etc.) — no / wrapper needed. -- With --html, inline images in the quoted message (cid: references) will appear broken. Externally hosted images are unaffected. +- With --html, inline images in the quoted message are preserved via cid: references. ## See Also diff --git a/skills/gws-gmail-reply/SKILL.md b/skills/gws-gmail-reply/SKILL.md index 5a23469b..0496a3ab 100644 --- a/skills/gws-gmail-reply/SKILL.md +++ b/skills/gws-gmail-reply/SKILL.md @@ -53,7 +53,7 @@ gws gmail +reply --message-id 18f1a2b3c4d --body 'Updated version' -a updated.do - --to adds extra recipients to the To field. - Use -a/--attach to add file attachments. Can be specified multiple times. - With --html, the quoted block uses Gmail's gmail_quote CSS classes and preserves HTML formatting. Use fragment tags (

, , , etc.) — no / wrapper needed. -- With --html, inline images in the quoted message (cid: references) will appear broken. Externally hosted images are unaffected. +- With --html, inline images in the quoted message are preserved via cid: references. - For reply-all, use +reply-all instead. ## See Also diff --git a/src/helpers/gmail/forward.rs b/src/helpers/gmail/forward.rs index d5f20103..efc56240 100644 --- a/src/helpers/gmail/forward.rs +++ b/src/helpers/gmail/forward.rs @@ -23,21 +23,49 @@ pub(super) async fn handle_forward( let dry_run = matches.get_flag("dry-run"); - let (original, token) = if dry_run { + let (original, token, client) = if dry_run { ( OriginalMessage::dry_run_placeholder(&config.message_id), None, + None, ) } else { let t = auth::get_token(&[GMAIL_SCOPE]) .await .map_err(|e| GwsError::Auth(format!("Gmail auth failed: {e}")))?; - let client = crate::client::build_client()?; - let orig = fetch_message_metadata(&client, &t, &config.message_id).await?; - config.from = resolve_sender(&client, &t, config.from.as_deref()).await?; - (orig, Some(t)) + let c = crate::client::build_client()?; + let orig = fetch_message_metadata(&c, &t, &config.message_id).await?; + config.from = resolve_sender(&c, &t, config.from.as_deref()).await?; + (orig, Some(t), Some(c)) }; + // Select which original parts to include: + // - --no-original-attachments: skip regular file attachments, but still + // include inline images in HTML mode (they're part of the body, not + // "attachments" in the UI sense) + // - Plain-text mode: drop inline images entirely (matching Gmail web) + // - HTML mode: include inline images (rendered via cid: in multipart/related) + let mut all_attachments = config.attachments; + if let (Some(client), Some(token)) = (&client, &token) { + let selected: Vec<_> = original + .parts + .iter() + .filter(|p| include_original_part(p, config.html, config.no_original_attachments)) + .cloned() + .collect(); + + fetch_and_merge_original_parts( + client, + token, + &config.message_id, + &selected, + &mut all_attachments, + ) + .await?; + } else { + eprintln!("Note: original attachments not included in dry-run preview"); + } + let subject = build_forward_subject(&original.subject); let refs = build_references_chain(&original); let envelope = ForwardEnvelope { @@ -54,7 +82,7 @@ pub(super) async fn handle_forward( }, }; - let raw = create_forward_raw_message(&envelope, &original, &config.attachments)?; + let raw = create_forward_raw_message(&envelope, &original, &all_attachments)?; super::send_raw_email( doc, @@ -66,6 +94,21 @@ pub(super) async fn handle_forward( .await } +/// Whether an original MIME part should be included when forwarding. +/// +/// - Regular attachments are included unless `--no-original-attachments` is set. +/// - Inline images are included only in HTML mode (matching Gmail web, which +/// strips them from plain-text forwards). +fn include_original_part(part: &OriginalPart, html: bool, no_original_attachments: bool) -> bool { + if no_original_attachments && !part.is_inline() { + return false; // skip regular attachments when flag is set + } + if !html && part.is_inline() { + return false; // skip inline images in plain-text mode + } + true +} + // --- Data structures --- pub(super) struct ForwardConfig { @@ -77,6 +120,7 @@ pub(super) struct ForwardConfig { pub body: Option, pub html: bool, pub attachments: Vec, + pub no_original_attachments: bool, } struct ForwardEnvelope<'a> { @@ -213,6 +257,7 @@ fn parse_forward_args(matches: &ArgMatches) -> Result { body: parse_optional_trimmed(matches, "body"), html: matches.get_flag("html"), attachments: parse_attachments(matches)?, + no_original_attachments: matches.get_flag("no-original-attachments"), }) } @@ -460,6 +505,11 @@ mod tests { Arg::new("dry-run") .long("dry-run") .action(ArgAction::SetTrue), + ) + .arg( + Arg::new("no-original-attachments") + .long("no-original-attachments") + .action(ArgAction::SetTrue), ); cmd.try_get_matches_from(args).unwrap() } @@ -474,6 +524,21 @@ mod tests { assert!(config.cc.is_none()); assert!(config.bcc.is_none()); assert!(config.body.is_none()); + assert!(!config.no_original_attachments); + } + + #[test] + fn test_parse_forward_args_no_original_attachments() { + let matches = make_forward_matches(&[ + "test", + "--message-id", + "abc123", + "--to", + "dave@example.com", + "--no-original-attachments", + ]); + let config = parse_forward_args(&matches).unwrap(); + assert!(config.no_original_attachments); } #[test] @@ -774,6 +839,7 @@ mod tests { filename: "report.pdf".to_string(), content_type: "application/pdf".to_string(), data: b"fake pdf".to_vec(), + content_id: None, }]; let raw = create_forward_raw_message(&envelope, &original, &attachments).unwrap(); @@ -782,4 +848,153 @@ mod tests { assert!(raw.contains("FYI, see attached")); assert!(raw.contains("Forwarded message")); } + + #[test] + fn test_create_forward_raw_message_html_with_inline_image() { + let original = OriginalMessage { + thread_id: Some("t1".to_string()), + message_id: "abc@example.com".to_string(), + from: Mailbox::parse("alice@example.com"), + to: vec![Mailbox::parse("bob@example.com")], + subject: "Photo".to_string(), + date: Some("Mon, 1 Jan 2026 00:00:00 +0000".to_string()), + body_text: "See photo".to_string(), + body_html: Some("

See

".to_string()), + ..Default::default() + }; + + let refs = build_references_chain(&original); + let to = Mailbox::parse_list("dave@example.com"); + let envelope = ForwardEnvelope { + to: &to, + cc: None, + bcc: None, + from: None, + subject: "Fwd: Photo", + body: None, + html: true, + threading: ThreadingHeaders { + in_reply_to: &original.message_id, + references: &refs, + }, + }; + // Simulate original inline image + regular attachment + let attachments = vec![ + Attachment { + filename: "baby.jpg".to_string(), + content_type: "image/jpeg".to_string(), + data: b"fake jpeg".to_vec(), + content_id: Some("baby@example.com".to_string()), + }, + Attachment { + filename: "report.pdf".to_string(), + content_type: "application/pdf".to_string(), + data: b"fake pdf".to_vec(), + content_id: None, + }, + ]; + let raw = create_forward_raw_message(&envelope, &original, &attachments).unwrap(); + + // Should have multipart/mixed > multipart/related + attachment + assert!(raw.contains("multipart/mixed")); + assert!(raw.contains("multipart/related")); + assert!(raw.contains("Content-ID: ")); + assert!(raw.contains("report.pdf")); + } + + #[test] + fn test_create_forward_raw_message_plain_text_no_inline_images() { + // In plain-text mode, inline images are filtered out upstream by the + // handler (matching Gmail web, which strips them entirely). Only regular + // attachments reach create_forward_raw_message. + let original = OriginalMessage { + thread_id: Some("t1".to_string()), + message_id: "abc@example.com".to_string(), + from: Mailbox::parse("alice@example.com"), + to: vec![Mailbox::parse("bob@example.com")], + subject: "Photo".to_string(), + date: Some("Mon, 1 Jan 2026 00:00:00 +0000".to_string()), + body_text: "See photo".to_string(), + ..Default::default() + }; + + let refs = build_references_chain(&original); + let to = Mailbox::parse_list("dave@example.com"); + let envelope = ForwardEnvelope { + to: &to, + cc: None, + bcc: None, + from: None, + subject: "Fwd: Photo", + body: None, + html: false, + threading: ThreadingHeaders { + in_reply_to: &original.message_id, + references: &refs, + }, + }; + // Only regular attachment — inline images are filtered out by the handler + let attachments = vec![Attachment { + filename: "report.pdf".to_string(), + content_type: "application/pdf".to_string(), + data: b"fake pdf".to_vec(), + content_id: None, + }]; + let raw = create_forward_raw_message(&envelope, &original, &attachments).unwrap(); + + assert!(!raw.contains("multipart/related")); + assert!(raw.contains("multipart/mixed")); + assert!(raw.contains("report.pdf")); + // No inline images in plain-text forward + assert!(!raw.contains("Content-ID")); + } + + // --- include_original_part filter matrix --- + + fn make_part(inline: bool) -> OriginalPart { + OriginalPart { + filename: "test".to_string(), + content_type: "image/png".to_string(), + size: 100, + attachment_id: "ATT1".to_string(), + content_id: if inline { + Some("cid@example.com".to_string()) + } else { + None + }, + } + } + + #[test] + fn test_include_original_part_default_html_includes_all() { + let regular = make_part(false); + let inline = make_part(true); + assert!(include_original_part(®ular, true, false)); + assert!(include_original_part(&inline, true, false)); + } + + #[test] + fn test_include_original_part_default_plain_drops_inline() { + let regular = make_part(false); + let inline = make_part(true); + assert!(include_original_part(®ular, false, false)); + assert!(!include_original_part(&inline, false, false)); + } + + #[test] + fn test_include_original_part_no_attachments_html_keeps_inline() { + let regular = make_part(false); + let inline = make_part(true); + // Key behavior: --no-original-attachments skips files but keeps inline images + assert!(!include_original_part(®ular, true, true)); + assert!(include_original_part(&inline, true, true)); + } + + #[test] + fn test_include_original_part_no_attachments_plain_drops_everything() { + let regular = make_part(false); + let inline = make_part(true); + assert!(!include_original_part(®ular, false, true)); + assert!(!include_original_part(&inline, false, true)); + } } diff --git a/src/helpers/gmail/mod.rs b/src/helpers/gmail/mod.rs index 524b3c6b..5f64030d 100644 --- a/src/helpers/gmail/mod.rs +++ b/src/helpers/gmail/mod.rs @@ -142,6 +142,34 @@ pub(super) fn strip_angle_brackets(id: &str) -> &str { .unwrap_or(id.trim()) } +/// Metadata for an attachment or inline image from the original message's MIME payload. +/// +/// Binary data is NOT stored here — it is fetched separately via `fetch_original_parts` +/// after the metadata parse, using the `attachment_id`. +#[derive(Debug, Clone)] +pub(super) struct OriginalPart { + /// Filename from the MIME part. Synthesized as `"part-{index}.{ext}"` when absent. + pub filename: String, + /// MIME content type (e.g., `"image/png"`, `"application/pdf"`). + pub content_type: String, + /// Size in bytes from the Gmail API `body.size` field. + pub size: u64, + /// Gmail API attachment ID for fetching binary data. + pub attachment_id: String, + /// Content-ID for inline images (bare, no angle brackets). + /// When present, the part is an inline image referenced via `cid:` URLs in the HTML body. + /// When absent, the part is a regular file attachment. + pub content_id: Option, +} + +impl OriginalPart { + /// Whether this part is an inline image (has a Content-ID and is not explicitly + /// `Content-Disposition: attachment`) vs a regular file attachment. + pub fn is_inline(&self) -> bool { + self.content_id.is_some() + } +} + /// A parsed Gmail message fetched via the API, used as context for reply/forward. /// /// `from` is always populated — `parse_original_message` returns an error when @@ -165,6 +193,10 @@ pub(super) struct OriginalMessage { pub date: Option, pub body_text: String, pub body_html: Option, + /// Attachments and inline images from the original MIME payload (metadata only). + /// Binary data is fetched separately via `fetch_original_parts`. + #[serde(skip_serializing)] + pub parts: Vec, } impl OriginalMessage { @@ -290,12 +322,16 @@ fn parse_original_message(msg: &Value) -> Result { ))); } - let body_text = msg + let PayloadContents { + body_text: extracted_text, + body_html, + parts: original_parts, + } = msg .get("payload") - .and_then(extract_plain_text_body) - .unwrap_or(snippet); + .map(extract_payload_contents) + .unwrap_or_default(); - let body_html = msg.get("payload").and_then(extract_html_body); + let body_text = extracted_text.unwrap_or(snippet); // Parse references: split on whitespace and strip any angle brackets, producing bare IDs let references = parsed_headers @@ -321,6 +357,7 @@ fn parse_original_message(msg: &Value) -> Result { date, body_text, body_html, + parts: original_parts, }) } @@ -641,56 +678,307 @@ fn parse_profile_display_name(body: &Value) -> Option { .map(sanitize_control_chars) } -fn extract_body_by_mime(payload: &Value, target_mime: &str) -> Option { - let mime_type = payload - .get("mimeType") - .and_then(|v| v.as_str()) - .unwrap_or(""); +/// Fetch binary data for a single attachment from the Gmail API. +/// +/// Calls `GET /users/me/messages/{messageId}/attachments/{attachmentId}`, +/// decodes the base64url `data` field, and returns raw bytes. +async fn fetch_attachment_data( + client: &reqwest::Client, + token: &str, + message_id: &str, + attachment_id: &str, +) -> Result, GwsError> { + let url = format!( + "https://gmail.googleapis.com/gmail/v1/users/me/messages/{}/attachments/{}", + crate::validate::encode_path_segment(message_id), + crate::validate::encode_path_segment(attachment_id), + ); - if mime_type == target_mime { - if let Some(data) = payload - .get("body") - .and_then(|b| b.get("data")) - .and_then(|d| d.as_str()) - { - match URL_SAFE.decode(data) { - Ok(decoded) => match String::from_utf8(decoded) { - Ok(s) => return Some(s), - Err(e) => { - eprintln!( - "Warning: {target_mime} body is not valid UTF-8: {}", - sanitize_for_terminal(&e.to_string()) - ); - } - }, - Err(e) => { - eprintln!( - "Warning: {target_mime} body has invalid base64: {}", - sanitize_for_terminal(&e.to_string()) - ); - } - } + let resp = crate::client::send_with_retry(|| client.get(&url).bearer_auth(token)) + .await + .map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to fetch attachment: {e}")))?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let err = resp + .text() + .await + .unwrap_or_else(|_| "(error body unreadable)".to_string()); + return Err(build_api_error( + status, + &err, + &format!("Failed to fetch attachment {attachment_id} from message {message_id}"), + )); + } + + let body: Value = resp + .json() + .await + .map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to parse attachment JSON: {e}")))?; + + let data_str = body.get("data").and_then(|v| v.as_str()).ok_or_else(|| { + GwsError::Other(anyhow::anyhow!( + "Attachment response missing 'data' field for {attachment_id}" + )) + })?; + + URL_SAFE + .decode(data_str) + .map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to decode attachment data: {e}"))) +} + +/// Fetch binary data for selected original parts, converting them to `Attachment`s. +/// +/// Performs a size preflight check using metadata before downloading, then fetches +/// parts sequentially. `existing_bytes` is the cumulative size of user-supplied +/// `--attach` files, counted against the combined size limit. +pub(super) async fn fetch_original_parts( + client: &reqwest::Client, + token: &str, + message_id: &str, + parts: &[OriginalPart], + existing_bytes: u64, +) -> Result, GwsError> { + // Size preflight: check metadata sizes before downloading anything + let total_metadata_size: u64 = parts.iter().map(|p| p.size).sum(); + if existing_bytes + total_metadata_size > MAX_TOTAL_ATTACHMENT_BYTES { + return Err(GwsError::Validation(format!( + "Original attachments ({:.1} MB) plus user attachments ({:.1} MB) exceed {}MB limit", + total_metadata_size as f64 / (1024.0 * 1024.0), + existing_bytes as f64 / (1024.0 * 1024.0), + MAX_TOTAL_ATTACHMENT_BYTES / (1024 * 1024), + ))); + } + + eprintln!( + "Fetching {} original attachment(s) ({:.1} MB)...", + parts.len(), + total_metadata_size as f64 / (1024.0 * 1024.0), + ); + + let mut attachments = Vec::with_capacity(parts.len()); + let mut actual_bytes = existing_bytes; + + for part in parts { + let data = fetch_attachment_data(client, token, message_id, &part.attachment_id).await?; + + actual_bytes += data.len() as u64; + if actual_bytes > MAX_TOTAL_ATTACHMENT_BYTES { + return Err(GwsError::Validation(format!( + "Total attachment size exceeds {}MB limit (after downloading '{}')", + MAX_TOTAL_ATTACHMENT_BYTES / (1024 * 1024), + part.filename, + ))); } - return None; + + attachments.push(Attachment { + filename: part.filename.clone(), + content_type: part.content_type.clone(), + data, + content_id: part.content_id.clone(), + }); } - if let Some(parts) = payload.get("parts").and_then(|p| p.as_array()) { - for part in parts { - if let Some(body) = extract_body_by_mime(part, target_mime) { - return Some(body); + Ok(attachments) +} + +/// Fetch selected original parts and merge them into an existing attachment list. +/// +/// Shared by `+forward` and `+reply`/`+reply-all` handlers. The caller is +/// responsible for filtering `parts` to the desired subset before calling +/// this function. +pub(super) async fn fetch_and_merge_original_parts( + client: &reqwest::Client, + token: &str, + message_id: &str, + parts: &[OriginalPart], + attachments: &mut Vec, +) -> Result<(), GwsError> { + if parts.is_empty() { + return Ok(()); + } + let user_bytes: u64 = attachments.iter().map(|a| a.data.len() as u64).sum(); + let fetched = fetch_original_parts(client, token, message_id, parts, user_bytes).await?; + attachments.extend(fetched); + Ok(()) +} + +/// Everything extracted from the MIME payload in a single recursive pass: +/// the plain text body, HTML body, and attachment/inline part metadata. +#[derive(Default)] +struct PayloadContents { + body_text: Option, + body_html: Option, + parts: Vec, +} + +/// Decode a base64url-encoded text body part, returning the string on success. +fn decode_text_body(data: &str, mime_label: &str) -> Option { + match URL_SAFE.decode(data) { + Ok(decoded) => match String::from_utf8(decoded) { + Ok(s) => Some(s), + Err(e) => { + eprintln!( + "Warning: {mime_label} body is not valid UTF-8: {}", + sanitize_for_terminal(&e.to_string()) + ); + None } + }, + Err(e) => { + eprintln!( + "Warning: {mime_label} body has invalid base64: {}", + sanitize_for_terminal(&e.to_string()) + ); + None } } +} - None +/// Synthesize a filename from the part index and MIME type when no filename is present. +/// e.g., `"image/png"` at index 1 → `"part-1.png"`. +fn synthesize_filename(part_index: usize, mime_type: &str) -> String { + let ext = mime_type + .split('/') + .nth(1) + .map(|sub| match sub { + "jpeg" => "jpg", + "svg+xml" => "svg", + "octet-stream" => "bin", + other => other, + }) + .unwrap_or("bin"); + format!("part-{part_index}.{ext}") } -fn extract_plain_text_body(payload: &Value) -> Option { - extract_body_by_mime(payload, "text/plain") +/// Sanitize a remote filename: strip ASCII control characters and fall back to +/// a synthesized name if the result is empty. Unlike `--attach` (where we reject +/// bad paths), remote filenames are sender-controlled and should not fail the operation. +fn sanitize_remote_filename(raw: &str, part_index: usize, mime_type: &str) -> String { + let cleaned: String = raw.chars().filter(|c| !c.is_ascii_control()).collect(); + let cleaned = cleaned.trim(); + if cleaned.is_empty() { + synthesize_filename(part_index, mime_type) + } else { + cleaned.to_string() + } } -fn extract_html_body(payload: &Value) -> Option { - extract_body_by_mime(payload, "text/html") +/// Get a header value from a MIME part's headers array, case-insensitive. +fn get_part_header<'a>(part: &'a Value, name: &str) -> Option<&'a str> { + part.get("headers") + .and_then(|h| h.as_array()) + .and_then(|headers| { + headers.iter().find_map(|h| { + let n = h.get("name").and_then(|v| v.as_str()).unwrap_or(""); + if n.eq_ignore_ascii_case(name) { + h.get("value").and_then(|v| v.as_str()) + } else { + None + } + }) + }) +} + +/// Walk the MIME payload tree in a single pass, collecting the text body, HTML body, +/// and metadata for all attachment/inline parts. +fn extract_payload_contents(payload: &Value) -> PayloadContents { + let mut contents = PayloadContents::default(); + extract_payload_recursive(payload, &mut contents, &mut 0); + contents +} + +fn extract_payload_recursive( + part: &Value, + contents: &mut PayloadContents, + part_counter: &mut usize, +) { + let mime_type = part.get("mimeType").and_then(|v| v.as_str()).unwrap_or(""); + + let filename = part.get("filename").and_then(|v| v.as_str()).unwrap_or(""); + + let body = part.get("body"); + + let attachment_id = body + .and_then(|b| b.get("attachmentId")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + + let body_data = body.and_then(|b| b.get("data")).and_then(|d| d.as_str()); + + let body_size = body + .and_then(|b| b.get("size")) + .and_then(|v| v.as_u64()) + .unwrap_or(0); + + let content_id_header = get_part_header(part, "Content-ID"); + + // Primary signal: does this part have fetchable binary data? + let is_hydratable = !attachment_id.is_empty(); + + // A body text part has inline body.data, no attachmentId, no filename, and no Content-ID. + let is_body_text_part = + !is_hydratable && filename.is_empty() && content_id_header.is_none() && body_data.is_some(); + + if is_body_text_part { + // body_data is guaranteed Some by the is_body_text_part check above. + let data = body_data.unwrap(); + if mime_type == "text/plain" && contents.body_text.is_none() { + contents.body_text = decode_text_body(data, "text/plain"); + } else if mime_type == "text/html" && contents.body_html.is_none() { + contents.body_html = decode_text_body(data, "text/html"); + } + } else if is_hydratable { + // This part has fetchable data — classify as inline or attachment + let index = *part_counter; + *part_counter += 1; + + // Classify as inline only when Content-ID is present AND + // Content-Disposition is not explicitly "attachment". Gmail gives + // Content-IDs to regular attachments too (e.g., PDFs), so Content-ID + // alone is not sufficient — we must check disposition. + let disposition_header = get_part_header(part, "Content-Disposition"); + let explicitly_attachment = disposition_header + .map(|d| d.to_ascii_lowercase().starts_with("attachment")) + .unwrap_or(false); + + // Sanitize Content-ID: strip angle brackets and control characters. + // Content-ID is sender-controlled; CR/LF could inject MIME headers via + // mail-builder's MessageId, which writes the value raw inside <...>. + // Treat as absent when the part is explicitly an attachment. + let content_id = if explicitly_attachment { + None + } else { + content_id_header + .map(|cid| sanitize_control_chars(strip_angle_brackets(cid))) + .filter(|cid| !cid.is_empty()) + }; + + let resolved_filename = if !filename.is_empty() { + sanitize_remote_filename(filename, index, mime_type) + } else { + synthesize_filename(index, mime_type) + }; + + contents.parts.push(OriginalPart { + filename: resolved_filename, + content_type: sanitize_control_chars(mime_type), + size: body_size, + attachment_id: attachment_id.to_string(), + content_id, + }); + // Do NOT recurse into hydratable parts. A message/rfc822 attachment or + // other encapsulated multipart has its own MIME subtree — recursing would + // incorrectly pull the attached message's body text and nested parts into + // the top-level message. + } else { + // Only recurse into non-hydratable container nodes (multipart/mixed, etc.) + if let Some(child_parts) = part.get("parts").and_then(|p| p.as_array()) { + for child in child_parts { + extract_payload_recursive(child, contents, part_counter); + } + } + } } /// Resolve the HTML body for quoting or forwarding: use the original HTML @@ -888,20 +1176,69 @@ pub(super) fn apply_optional_headers<'x>( } /// Set the body (plain or HTML), add any attachments, and write the finished message to a string. +/// +/// When the message is HTML and contains inline parts (with `content_id`), builds a +/// `multipart/related` container so `cid:` references render correctly. Gmail's API +/// rewrites `Content-Disposition: inline` to `attachment` when parts sit in +/// `multipart/mixed`, so the explicit `multipart/related` structure is required. pub(super) fn finalize_message( mb: mail_builder::MessageBuilder<'_>, body: impl Into, html: bool, attachments: &[Attachment], ) -> Result { - let mb = if html { - mb.html_body(body.into()) + use mail_builder::mime::MimePart; + + let body_str = body.into(); + + let (inline, regular): (Vec<_>, Vec<_>) = attachments.iter().partition(|a| a.is_inline()); + + let mb = if html && !inline.is_empty() { + // Build multipart/related: HTML body + inline image parts + let mut related_parts: Vec> = + vec![MimePart::new("text/html", body_str.as_str())]; + for att in &inline { + let cid = att + .content_id + .as_deref() + .expect("partitioned by content_id presence"); + related_parts.push( + MimePart::new(att.content_type.as_str(), att.data.as_slice()) + .inline() + .cid(cid), + ); + } + let related = MimePart::new("multipart/related", related_parts); + + if regular.is_empty() { + // Just multipart/related — no outer mixed wrapper needed + mb.body(related) + } else { + // Wrap in multipart/mixed with regular attachments + let mut mixed_parts = vec![related]; + for att in ®ular { + mixed_parts.push( + MimePart::new(att.content_type.as_str(), att.data.as_slice()) + .attachment(att.filename.as_str()), + ); + } + mb.body(MimePart::new("multipart/mixed", mixed_parts)) + } } else { - mb.text_body(body.into()) + // No inline images, or plain-text mode — all parts become regular attachments. + // Callers strip inline parts in plain-text mode (matching Gmail web), so + // only regular attachments should reach here. If any inline parts do arrive, + // they are treated as regular attachments (defense-in-depth). + let mb = if html { + mb.html_body(body_str) + } else { + mb.text_body(body_str) + }; + attachments.iter().fold(mb, |mb, att| { + mb.attachment(&att.content_type, &att.filename, att.data.as_slice()) + }) }; - let mb = attachments.iter().fold(mb, |mb, att| { - mb.attachment(&att.content_type, &att.filename, att.data.as_slice()) - }); + mb.write_to_string() .map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to serialize email: {e}"))) } @@ -929,17 +1266,28 @@ pub(super) fn parse_optional_mailboxes(matches: &ArgMatches, name: &str) -> Opti /// base64-encoded attachments. 25MB raw attachments ≈ 33MB with base64 + overhead. const MAX_TOTAL_ATTACHMENT_BYTES: u64 = 25 * 1024 * 1024; -/// A file attachment read from disk, ready to add to a message. +/// A file attachment ready to add to an outgoing message. /// -/// `content_type` is inferred from the file extension via `mime_guess2`, -/// falling back to `application/octet-stream` for unknown extensions. -/// `filename` is the basename extracted from the path; mail-builder handles -/// RFC 2231 encoding for non-ASCII filenames in the Content-Disposition header. +/// Created either from a local file (`--attach`, where `content_type` is +/// inferred from the extension via `mime_guess2`) or from an original +/// message's MIME part (`fetch_original_parts`, where `content_type` comes +/// from the Gmail API). mail-builder handles RFC 2231 encoding for non-ASCII +/// filenames in the Content-Disposition header. #[derive(Debug)] pub(super) struct Attachment { pub filename: String, pub content_type: String, pub data: Vec, + /// When present, this part is an inline image. Used by `finalize_message` to + /// place the part inside a `multipart/related` container with `.inline().cid()`. + pub content_id: Option, +} + +impl Attachment { + /// Whether this attachment is an inline image (has a Content-ID) vs a regular file. + pub fn is_inline(&self) -> bool { + self.content_id.is_some() + } } /// Read and validate attachments from `--attach` arguments. @@ -1004,6 +1352,7 @@ pub(super) fn parse_attachments(matches: &ArgMatches) -> Result, filename: filename.to_string(), content_type, data, + content_id: None, }); } @@ -1275,8 +1624,7 @@ TIPS: Use -a/--attach to add file attachments. Can be specified multiple times. With --html, the quoted block uses Gmail's gmail_quote CSS classes and preserves HTML formatting. \ Use fragment tags (

, , , etc.) — no / wrapper needed. - With --html, inline images in the quoted message (cid: references) will appear broken. \ -Externally hosted images are unaffected. + With --html, inline images in the quoted message are preserved via cid: references. For reply-all, use +reply-all instead.", ), ); @@ -1311,8 +1659,7 @@ TIPS: Use -a/--attach to add file attachments. Can be specified multiple times. With --html, the quoted block uses Gmail's gmail_quote CSS classes and preserves HTML formatting. \ Use fragment tags (

, , , etc.) — no / wrapper needed. - With --html, inline images in the quoted message (cid: references) will appear broken. \ -Externally hosted images are unaffected.", + With --html, inline images in the quoted message are preserved via cid: references.", ), ); @@ -1345,6 +1692,12 @@ Externally hosted images are unaffected.", .long("body") .help("Optional note to include above the forwarded message (plain text, or HTML with --html)") .value_name("TEXT"), + ) + .arg( + Arg::new("no-original-attachments") + .long("no-original-attachments") + .help("Do not include file attachments from the original message (inline images in --html mode are preserved)") + .action(ArgAction::SetTrue), ), ) .after_help( @@ -1355,14 +1708,18 @@ EXAMPLES: gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --cc eve@example.com gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --body '

FYI

' --html gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com -a notes.pdf + gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --no-original-attachments TIPS: Includes the original message with sender, date, subject, and recipients. - Use -a/--attach to add file attachments. Can be specified multiple times. + Original attachments are included by default (matching Gmail web behavior). + With --html, inline images are also preserved via cid: references. + In plain-text mode, inline images are not included (matching Gmail web). + Use --no-original-attachments to forward without the original message's files. + Use -a/--attach to add extra file attachments. Can be specified multiple times. + Combined size of original and user attachments is limited to 25MB. With --html, the forwarded block uses Gmail's gmail_quote CSS classes and preserves HTML formatting. \ -Use fragment tags (

, , , etc.) — no / wrapper needed. - With --html, inline images in the forwarded message (cid: references) will appear broken. \ -Externally hosted images are unaffected.", +Use fragment tags (

, , , etc.) — no / wrapper needed.", ), ); @@ -1552,6 +1909,16 @@ mod tests { use super::*; use std::collections::HashMap; + /// Test-only wrapper: extract the plain text body from a payload using the single-pass walker. + fn extract_plain_text_body(payload: &Value) -> Option { + extract_payload_contents(payload).body_text + } + + /// Test-only wrapper: extract the HTML body from a payload using the single-pass walker. + fn extract_html_body(payload: &Value) -> Option { + extract_payload_contents(payload).body_html + } + // --- Shared test helpers --- /// Extract a header value from raw RFC 5322 output, handling folded lines. @@ -1672,6 +2039,7 @@ mod tests { assert!(d.date.is_none()); assert!(d.body_text.is_empty()); assert!(d.body_html.is_none()); + assert!(d.parts.is_empty()); } #[test] @@ -2516,6 +2884,7 @@ mod tests { filename: "report.pdf".to_string(), content_type: "application/pdf".to_string(), data: b"fake pdf data".to_vec(), + content_id: None, }; let mb = mail_builder::MessageBuilder::new() .to(MbAddress::new_address(None::<&str>, "test@example.com")) @@ -2535,11 +2904,13 @@ mod tests { filename: "a.pdf".to_string(), content_type: "application/pdf".to_string(), data: b"pdf data".to_vec(), + content_id: None, }, Attachment { filename: "b.csv".to_string(), content_type: "text/csv".to_string(), data: b"csv data".to_vec(), + content_id: None, }, ]; let mb = mail_builder::MessageBuilder::new() @@ -2558,6 +2929,7 @@ mod tests { filename: "image.png".to_string(), content_type: "image/png".to_string(), data: vec![0x89, 0x50, 0x4E, 0x47], + content_id: None, }; let mb = mail_builder::MessageBuilder::new() .to(MbAddress::new_address(None::<&str>, "test@example.com")) @@ -2652,7 +3024,6 @@ mod tests { #[test] fn test_parse_attachments_size_limit_accumulates() { - use std::io::Write; let dir = tempfile::tempdir_in(".").unwrap(); // Create two files whose combined size exceeds MAX_TOTAL_ATTACHMENT_BYTES @@ -2902,6 +3273,171 @@ mod tests { ); } + // --- Payload walker tests --- + + fn base64url(s: &str) -> String { + URL_SAFE.encode(s) + } + + #[test] + fn test_extract_payload_contents_simple() { + let text_data = base64url("Hello plain text"); + let html_data = base64url("

Hello HTML

"); + let payload = json!({ + "mimeType": "multipart/alternative", + "parts": [ + { "mimeType": "text/plain", "body": { "data": text_data, "size": 16 } }, + { "mimeType": "text/html", "body": { "data": html_data, "size": 18 } }, + ] + }); + let contents = extract_payload_contents(&payload); + assert_eq!(contents.body_text.as_deref(), Some("Hello plain text")); + assert_eq!(contents.body_html.as_deref(), Some("

Hello HTML

")); + assert!(contents.parts.is_empty()); + } + + #[test] + fn test_extract_payload_contents_with_attachment() { + let text_data = base64url("Body text"); + let payload = json!({ + "mimeType": "multipart/mixed", + "parts": [ + { "mimeType": "text/plain", "body": { "data": text_data, "size": 9 } }, + { + "mimeType": "application/pdf", + "filename": "report.pdf", + "body": { "attachmentId": "ATT123", "size": 1024 }, + "headers": [ + { "name": "Content-Disposition", "value": "attachment; filename=\"report.pdf\"" } + ] + } + ] + }); + let contents = extract_payload_contents(&payload); + assert_eq!(contents.body_text.as_deref(), Some("Body text")); + assert_eq!(contents.parts.len(), 1); + assert_eq!(contents.parts[0].filename, "report.pdf"); + assert_eq!(contents.parts[0].content_type, "application/pdf"); + assert_eq!(contents.parts[0].attachment_id, "ATT123"); + assert_eq!(contents.parts[0].size, 1024); + assert!(!contents.parts[0].is_inline()); + assert!(contents.parts[0].content_id.is_none()); + } + + #[test] + fn test_extract_payload_contents_with_inline_image() { + let text_data = base64url("Body"); + let html_data = base64url("

See

"); + let payload = json!({ + "mimeType": "multipart/related", + "parts": [ + { + "mimeType": "multipart/alternative", + "parts": [ + { "mimeType": "text/plain", "body": { "data": text_data, "size": 4 } }, + { "mimeType": "text/html", "body": { "data": html_data, "size": 40 } }, + ] + }, + { + "mimeType": "image/png", + "filename": "photo.png", + "body": { "attachmentId": "INLINE1", "size": 5000 }, + "headers": [ + { "name": "Content-ID", "value": "" }, + { "name": "Content-Disposition", "value": "inline; filename=\"photo.png\"" } + ] + } + ] + }); + let contents = extract_payload_contents(&payload); + assert_eq!(contents.parts.len(), 1); + assert!(contents.parts[0].is_inline()); + assert_eq!( + contents.parts[0].content_id.as_deref(), + Some("img1@example.com") + ); + assert_eq!(contents.parts[0].filename, "photo.png"); + } + + #[test] + fn test_extract_payload_contents_no_filename_synthesis() { + let payload = json!({ + "mimeType": "multipart/mixed", + "parts": [ + { "mimeType": "text/plain", "body": { "data": base64url("hi"), "size": 2 } }, + { + "mimeType": "image/jpeg", + "filename": "", + "body": { "attachmentId": "ATT_NO_NAME", "size": 500 }, + "headers": [] + } + ] + }); + let contents = extract_payload_contents(&payload); + assert_eq!(contents.parts.len(), 1); + assert_eq!(contents.parts[0].filename, "part-0.jpg"); + assert!(!contents.parts[0].is_inline()); + } + + #[test] + fn test_content_id_normalization() { + let payload = json!({ + "mimeType": "image/png", + "filename": "logo.png", + "body": { "attachmentId": "CID_TEST", "size": 100 }, + "headers": [ + { "name": "Content-ID", "value": "" } + ] + }); + let contents = extract_payload_contents(&payload); + assert_eq!(contents.parts.len(), 1); + // Angle brackets should be stripped + assert_eq!( + contents.parts[0].content_id.as_deref(), + Some("logo@company.com") + ); + } + + #[test] + fn test_content_id_crlf_injection_sanitized() { + // Content-ID is sender-controlled; CR/LF could inject MIME headers. + // Verify that control characters are stripped. + let payload = json!({ + "mimeType": "image/png", + "filename": "evil.png", + "body": { "attachmentId": "INJECT_TEST", "size": 100 }, + "headers": [ + { "name": "Content-ID", "value": "" } + ] + }); + let contents = extract_payload_contents(&payload); + assert_eq!(contents.parts.len(), 1); + // CR/LF stripped, part is still inline + assert!(contents.parts[0].is_inline()); + let cid = contents.parts[0].content_id.as_deref().unwrap(); + assert!(!cid.contains('\r')); + assert!(!cid.contains('\n')); + assert_eq!(cid, "img1@example.comX-Injected: yes"); + } + + #[test] + fn test_content_id_all_control_chars_becomes_none() { + // A Content-ID that is entirely control characters should be treated as absent, + // making the part a regular attachment instead of inline. + let payload = json!({ + "mimeType": "image/png", + "filename": "weird.png", + "body": { "attachmentId": "EMPTY_CID", "size": 100 }, + "headers": [ + { "name": "Content-ID", "value": "<\r\n>" } + ] + }); + let contents = extract_payload_contents(&payload); + assert_eq!(contents.parts.len(), 1); + assert!(!contents.parts[0].is_inline()); + assert!(contents.parts[0].content_id.is_none()); + } + #[test] fn test_parse_profile_display_name_empty() { let body = serde_json::json!({}); @@ -2990,4 +3526,313 @@ mod tests { _ => panic!("Expected GwsError::Api"), } } + + #[test] + fn test_attachment_with_content_id_and_disposition_attachment_is_not_inline() { + // Gmail gives Content-IDs to regular attachments (e.g., PDFs). A part + // with Content-Disposition: attachment should be classified as a regular + // attachment regardless of Content-ID presence. + let payload = json!({ + "mimeType": "application/pdf", + "filename": "report.pdf", + "body": { "attachmentId": "PDF1", "size": 50000 }, + "headers": [ + { "name": "Content-Disposition", "value": "attachment; filename=\"report.pdf\"" }, + { "name": "Content-ID", "value": "" } + ] + }); + let contents = extract_payload_contents(&payload); + assert_eq!(contents.parts.len(), 1); + // Should be classified as regular attachment, NOT inline + assert!(!contents.parts[0].is_inline()); + assert!(contents.parts[0].content_id.is_none()); + } + + #[test] + fn test_extract_payload_contents_does_not_recurse_into_attachments() { + // A message/rfc822 attachment has its own MIME subtree. The walker + // should NOT recurse into it — the attached message's body and parts + // should not leak into the top-level message. + let payload = json!({ + "mimeType": "multipart/mixed", + "parts": [ + { + "mimeType": "text/plain", + "body": { "data": base64url("Outer body"), "size": 10 } + }, + { + "mimeType": "message/rfc822", + "filename": "attached.eml", + "body": { "attachmentId": "EML1", "size": 5000 }, + "headers": [], + "parts": [ + { + "mimeType": "text/plain", + "body": { "data": base64url("Inner body — should NOT be extracted"), "size": 40 } + }, + { + "mimeType": "application/pdf", + "filename": "inner.pdf", + "body": { "attachmentId": "INNER_ATT", "size": 1000 }, + "headers": [] + } + ] + } + ] + }); + let contents = extract_payload_contents(&payload); + // Should extract the outer body text + assert_eq!(contents.body_text.as_deref(), Some("Outer body")); + // Should have exactly one part: the message/rfc822 attachment + assert_eq!(contents.parts.len(), 1); + assert_eq!(contents.parts[0].filename, "attached.eml"); + assert_eq!(contents.parts[0].attachment_id, "EML1"); + // The inner body and inner attachment should NOT appear + assert_ne!( + contents.body_text.as_deref(), + Some("Inner body \u{2014} should NOT be extracted") + ); + } + + #[test] + fn test_header_case_insensitive() { + let payload = json!({ + "mimeType": "image/gif", + "filename": "spacer.gif", + "body": { "attachmentId": "CASE_TEST", "size": 43 }, + "headers": [ + { "name": "content-id", "value": "" }, + { "name": "content-disposition", "value": "inline" } + ] + }); + let contents = extract_payload_contents(&payload); + assert_eq!(contents.parts.len(), 1); + assert!(contents.parts[0].is_inline()); + assert_eq!( + contents.parts[0].content_id.as_deref(), + Some("spacer@example.com") + ); + } + + #[test] + fn test_filename_control_char_sanitization() { + let payload = json!({ + "mimeType": "application/pdf", + "filename": "report\x00\x0d.pdf", + "body": { "attachmentId": "SANITIZE_TEST", "size": 100 }, + "headers": [] + }); + let contents = extract_payload_contents(&payload); + assert_eq!(contents.parts.len(), 1); + assert_eq!(contents.parts[0].filename, "report.pdf"); + } + + // --- finalize_message MIME structure tests --- + + #[test] + fn test_finalize_message_html_inline_creates_multipart_related() { + let attachments = vec![Attachment { + filename: "photo.png".to_string(), + content_type: "image/png".to_string(), + data: vec![0x89, 0x50, 0x4E, 0x47], + content_id: Some("img1@example.com".to_string()), + }]; + let mb = mail_builder::MessageBuilder::new() + .to(MbAddress::new_address(None::<&str>, "test@example.com")) + .subject("test"); + let raw = finalize_message( + mb, + "

See

", + true, + &attachments, + ) + .unwrap(); + + assert!(raw.contains("multipart/related")); + assert!(raw.contains("text/html")); + assert!(raw.contains("Content-ID: ")); + // Should NOT be multipart/mixed since there are no regular attachments + assert!(!raw.contains("multipart/mixed")); + } + + #[test] + fn test_finalize_message_html_inline_and_attachment() { + let attachments = vec![ + Attachment { + filename: "photo.png".to_string(), + content_type: "image/png".to_string(), + data: vec![0x89, 0x50], + content_id: Some("img1@example.com".to_string()), + }, + Attachment { + filename: "report.pdf".to_string(), + content_type: "application/pdf".to_string(), + data: b"pdf data".to_vec(), + content_id: None, + }, + ]; + let mb = mail_builder::MessageBuilder::new() + .to(MbAddress::new_address(None::<&str>, "test@example.com")) + .subject("test"); + let raw = finalize_message(mb, "

HTML body

", true, &attachments).unwrap(); + + // Should have multipart/mixed wrapping multipart/related + regular attachment + assert!(raw.contains("multipart/mixed")); + assert!(raw.contains("multipart/related")); + assert!(raw.contains("Content-ID: ")); + assert!(raw.contains("report.pdf")); + } + + #[test] + fn test_finalize_message_plain_text_downgrades_inline_to_attachment() { + let attachments = vec![Attachment { + filename: "photo.png".to_string(), + content_type: "image/png".to_string(), + data: vec![0x89, 0x50], + content_id: Some("img1@example.com".to_string()), + }]; + let mb = mail_builder::MessageBuilder::new() + .to(MbAddress::new_address(None::<&str>, "test@example.com")) + .subject("test"); + let raw = finalize_message(mb, "Plain text body", false, &attachments).unwrap(); + + // Should NOT use multipart/related in plain text mode + assert!(!raw.contains("multipart/related")); + // Should be a regular attachment + assert!(raw.contains("multipart/mixed")); + assert!(raw.contains("photo.png")); + // Content-ID should NOT appear + assert!(!raw.contains("Content-ID: ")); + } + + // --- parse_original_message end-to-end with parts --- + + #[test] + fn test_parse_original_message_populates_parts() { + let msg = json!({ + "threadId": "thread1", + "snippet": "fallback", + "payload": { + "mimeType": "multipart/mixed", + "headers": [ + { "name": "From", "value": "alice@example.com" }, + { "name": "To", "value": "bob@example.com" }, + { "name": "Subject", "value": "Files" }, + { "name": "Message-ID", "value": "" }, + ], + "parts": [ + { + "mimeType": "text/plain", + "body": { "data": base64url("Hello"), "size": 5 } + }, + { + "mimeType": "application/pdf", + "filename": "report.pdf", + "body": { "attachmentId": "ATT1", "size": 2048 }, + "headers": [] + }, + { + "mimeType": "image/png", + "filename": "photo.png", + "body": { "attachmentId": "ATT2", "size": 4096 }, + "headers": [ + { "name": "Content-ID", "value": "" } + ] + } + ] + } + }); + let original = parse_original_message(&msg).unwrap(); + assert_eq!(original.body_text, "Hello"); + assert_eq!(original.parts.len(), 2); + // First part: regular attachment + assert_eq!(original.parts[0].filename, "report.pdf"); + assert!(!original.parts[0].is_inline()); + assert_eq!(original.parts[0].attachment_id, "ATT1"); + // Second part: inline image + assert_eq!(original.parts[1].filename, "photo.png"); + assert!(original.parts[1].is_inline()); + assert_eq!( + original.parts[1].content_id.as_deref(), + Some("img1@example.com") + ); + } + + // --- finalize_message with multiple inline images --- + + #[test] + fn test_finalize_message_html_multiple_inline_images() { + let attachments = vec![ + Attachment { + filename: "img1.png".to_string(), + content_type: "image/png".to_string(), + data: vec![0x89, 0x50], + content_id: Some("img1@example.com".to_string()), + }, + Attachment { + filename: "img2.jpg".to_string(), + content_type: "image/jpeg".to_string(), + data: vec![0xFF, 0xD8], + content_id: Some("img2@example.com".to_string()), + }, + ]; + let mb = mail_builder::MessageBuilder::new() + .to(MbAddress::new_address(None::<&str>, "test@example.com")) + .subject("test"); + let raw = finalize_message( + mb, + "

", + true, + &attachments, + ) + .unwrap(); + + assert!(raw.contains("multipart/related")); + assert!(raw.contains("Content-ID: ")); + assert!(raw.contains("Content-ID: ")); + } + + // --- synthesize_filename direct tests --- + + #[test] + fn test_synthesize_filename_jpeg() { + assert_eq!(synthesize_filename(0, "image/jpeg"), "part-0.jpg"); + } + + #[test] + fn test_synthesize_filename_svg() { + assert_eq!(synthesize_filename(1, "image/svg+xml"), "part-1.svg"); + } + + #[test] + fn test_synthesize_filename_octet_stream() { + assert_eq!( + synthesize_filename(2, "application/octet-stream"), + "part-2.bin" + ); + } + + #[test] + fn test_synthesize_filename_no_slash() { + assert_eq!(synthesize_filename(0, "weirdtype"), "part-0.bin"); + } + + // --- sanitize_remote_filename edge cases --- + + #[test] + fn test_sanitize_remote_filename_all_control_chars() { + // All control characters → falls back to synthesized name + assert_eq!( + sanitize_remote_filename("\x00\x01\x02", 0, "application/pdf"), + "part-0.pdf" + ); + } + + #[test] + fn test_sanitize_remote_filename_whitespace_only() { + assert_eq!( + sanitize_remote_filename(" ", 0, "image/png"), + "part-0.png" + ); + } } diff --git a/src/helpers/gmail/reply.rs b/src/helpers/gmail/reply.rs index af76cb9e..43a70747 100644 --- a/src/helpers/gmail/reply.rs +++ b/src/helpers/gmail/reply.rs @@ -23,30 +23,31 @@ pub(super) async fn handle_reply( let mut config = parse_reply_args(matches)?; let dry_run = matches.get_flag("dry-run"); - let (original, token, self_email) = if dry_run { + let (original, token, self_email, client) = if dry_run { ( OriginalMessage::dry_run_placeholder(&config.message_id), None, None, + None, ) } else { let t = auth::get_token(&[GMAIL_SCOPE]) .await .map_err(|e| GwsError::Auth(format!("Gmail auth failed: {e}")))?; - let client = crate::client::build_client()?; - let orig = fetch_message_metadata(&client, &t, &config.message_id).await?; - config.from = resolve_sender(&client, &t, config.from.as_deref()).await?; + let c = crate::client::build_client()?; + let orig = fetch_message_metadata(&c, &t, &config.message_id).await?; + config.from = resolve_sender(&c, &t, config.from.as_deref()).await?; // For reply-all, always fetch the primary email for self-dedup and // self-reply detection. The resolved sender may be an alias that differs from the primary // address — both must be excluded from recipients. from_alias_email // (extracted from config.from below) handles the alias; self_email // handles the primary. let self_addr = if reply_all { - Some(fetch_user_email(&client, &t).await?) + Some(fetch_user_email(&c, &t).await?) } else { None }; - (orig, Some(t), self_addr) + (orig, Some(t), self_addr, Some(c)) }; let self_email = self_email.as_deref(); @@ -105,7 +106,29 @@ pub(super) async fn handle_reply( html: config.html, }; - let raw = create_reply_raw_message(&envelope, &original, &config.attachments)?; + // Fetch inline images for HTML replies only. In plain-text mode, inline + // images are dropped entirely — matching Gmail web, which strips them from + // both plain-text replies and plain-text forwards. + let mut all_attachments = config.attachments; + if let (true, Some(client), Some(token)) = (config.html, &client, &token) { + let inline_parts: Vec<_> = original + .parts + .iter() + .filter(|p| p.is_inline()) + .cloned() + .collect(); + + fetch_and_merge_original_parts( + client, + token, + &config.message_id, + &inline_parts, + &mut all_attachments, + ) + .await?; + } + + let raw = create_reply_raw_message(&envelope, &original, &all_attachments)?; super::send_raw_email( doc, @@ -1499,6 +1522,7 @@ mod tests { filename: "notes.txt".to_string(), content_type: "text/plain".to_string(), data: b"some notes".to_vec(), + content_id: None, }]; let raw = create_reply_raw_message(&envelope, &original, &attachments).unwrap(); @@ -1507,4 +1531,47 @@ mod tests { assert!(raw.contains("See attached notes")); assert!(raw.contains("> Original body")); } + + #[test] + fn test_create_reply_raw_message_html_with_inline_image() { + let original = OriginalMessage { + thread_id: Some("t1".to_string()), + message_id: "abc@example.com".to_string(), + from: Mailbox::parse("alice@example.com"), + to: vec![Mailbox::parse("bob@example.com")], + subject: "Photo".to_string(), + date: Some("Mon, 1 Jan 2026 00:00:00 +0000".to_string()), + body_text: "See photo".to_string(), + body_html: Some("

See

".to_string()), + ..Default::default() + }; + + let refs = build_references_chain(&original); + let to = vec![Mailbox::parse("alice@example.com")]; + let envelope = ReplyEnvelope { + to: &to, + cc: None, + bcc: None, + from: None, + subject: "Re: Photo", + threading: ThreadingHeaders { + in_reply_to: &original.message_id, + references: &refs, + }, + body: "Nice photo!", + html: true, + }; + let attachments = vec![Attachment { + filename: "photo.png".to_string(), + content_type: "image/png".to_string(), + data: vec![0x89, 0x50], + content_id: Some("photo@example.com".to_string()), + }]; + let raw = create_reply_raw_message(&envelope, &original, &attachments).unwrap(); + + // Should produce multipart/related for inline image in HTML reply + assert!(raw.contains("multipart/related")); + assert!(raw.contains("Content-ID: ")); + assert!(!raw.contains("multipart/mixed")); + } } diff --git a/src/helpers/gmail/send.rs b/src/helpers/gmail/send.rs index 185e6fdd..73ce226a 100644 --- a/src/helpers/gmail/send.rs +++ b/src/helpers/gmail/send.rs @@ -445,6 +445,7 @@ mod tests { filename: "report.pdf".to_string(), content_type: "application/pdf".to_string(), data: b"fake pdf".to_vec(), + content_id: None, }], }; let raw = create_send_raw_message(&config).unwrap(); From 93997d93a1c5ecfed9235004b60c4da1f80fdb0a Mon Sep 17 00:00:00 2001 From: Malo Bourgon Date: Wed, 18 Mar 2026 15:42:40 -0700 Subject: [PATCH 2/2] feat(gmail): add --draft flag to +send, +reply, +reply-all, +forward When --draft is set, calls users.drafts.create instead of users.messages.send. Message construction is identical; only the API method and metadata wrapper change. Threaded drafts (replies and forwards) preserve threadId in the draft metadata. --- .changeset/gmail-draft-flag.md | 5 + skills/gws-gmail-forward/SKILL.md | 3 + skills/gws-gmail-reply-all/SKILL.md | 3 + skills/gws-gmail-reply/SKILL.md | 3 + skills/gws-gmail-send/SKILL.md | 3 + src/helpers/gmail/forward.rs | 5 +- src/helpers/gmail/mod.rs | 136 ++++++++++++++++++++++++---- src/helpers/gmail/reply.rs | 5 +- src/helpers/gmail/send.rs | 17 ++-- 9 files changed, 150 insertions(+), 30 deletions(-) create mode 100644 .changeset/gmail-draft-flag.md diff --git a/.changeset/gmail-draft-flag.md b/.changeset/gmail-draft-flag.md new file mode 100644 index 00000000..aef91101 --- /dev/null +++ b/.changeset/gmail-draft-flag.md @@ -0,0 +1,5 @@ +--- +"@googleworkspace/cli": minor +--- + +Add `--draft` flag to Gmail `+send`, `+reply`, `+reply-all`, and `+forward` helpers to save messages as drafts instead of sending them immediately diff --git a/skills/gws-gmail-forward/SKILL.md b/skills/gws-gmail-forward/SKILL.md index bab37b1e..e2a23082 100644 --- a/skills/gws-gmail-forward/SKILL.md +++ b/skills/gws-gmail-forward/SKILL.md @@ -36,6 +36,7 @@ gws gmail +forward --message-id --to | `--bcc` | — | — | BCC email address(es), comma-separated | | `--html` | — | — | Treat --body as HTML content (default is plain text) | | `--dry-run` | — | — | Show the request that would be sent without executing it | +| `--draft` | — | — | Save as draft instead of sending | ## Examples @@ -46,6 +47,7 @@ gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --cc eve@examp gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --body '

FYI

' --html gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com -a notes.pdf gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --no-original-attachments +gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --draft ``` ## Tips @@ -58,6 +60,7 @@ gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --no-original- - Use -a/--attach to add extra file attachments. Can be specified multiple times. - Combined size of original and user attachments is limited to 25MB. - With --html, the forwarded block uses Gmail's gmail_quote CSS classes and preserves HTML formatting. Use fragment tags (

, , , etc.) — no / wrapper needed. +- Use --draft to save the forward as a draft instead of sending it immediately. ## See Also diff --git a/skills/gws-gmail-reply-all/SKILL.md b/skills/gws-gmail-reply-all/SKILL.md index e75fb025..6a552567 100644 --- a/skills/gws-gmail-reply-all/SKILL.md +++ b/skills/gws-gmail-reply-all/SKILL.md @@ -35,6 +35,7 @@ gws gmail +reply-all --message-id --body | `--bcc` | — | — | BCC email address(es), comma-separated | | `--html` | — | — | Treat --body as HTML content (default is plain text) | | `--dry-run` | — | — | Show the request that would be sent without executing it | +| `--draft` | — | — | Save as draft instead of sending | | `--remove` | — | — | Exclude recipients from the outgoing reply (comma-separated emails) | ## Examples @@ -45,6 +46,7 @@ gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Updated' --remove bob@exam gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Adding Eve' --cc eve@example.com gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Noted' --html gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Notes attached' -a notes.pdf +gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Draft reply' --draft ``` ## Tips @@ -58,6 +60,7 @@ gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Notes attached' -a notes.p - Use -a/--attach to add file attachments. Can be specified multiple times. - With --html, the quoted block uses Gmail's gmail_quote CSS classes and preserves HTML formatting. Use fragment tags (

, , , etc.) — no / wrapper needed. - With --html, inline images in the quoted message are preserved via cid: references. +- Use --draft to save the reply as a draft instead of sending it immediately. ## See Also diff --git a/skills/gws-gmail-reply/SKILL.md b/skills/gws-gmail-reply/SKILL.md index 0496a3ab..759f1de3 100644 --- a/skills/gws-gmail-reply/SKILL.md +++ b/skills/gws-gmail-reply/SKILL.md @@ -35,6 +35,7 @@ gws gmail +reply --message-id --body | `--bcc` | — | — | BCC email address(es), comma-separated | | `--html` | — | — | Treat --body as HTML content (default is plain text) | | `--dry-run` | — | — | Show the request that would be sent without executing it | +| `--draft` | — | — | Save as draft instead of sending | ## Examples @@ -44,6 +45,7 @@ gws gmail +reply --message-id 18f1a2b3c4d --body 'Looping in Carol' --cc carol@e gws gmail +reply --message-id 18f1a2b3c4d --body 'Adding Dave' --to dave@example.com gws gmail +reply --message-id 18f1a2b3c4d --body 'Bold reply' --html gws gmail +reply --message-id 18f1a2b3c4d --body 'Updated version' -a updated.docx +gws gmail +reply --message-id 18f1a2b3c4d --body 'Draft reply' --draft ``` ## Tips @@ -54,6 +56,7 @@ gws gmail +reply --message-id 18f1a2b3c4d --body 'Updated version' -a updated.do - Use -a/--attach to add file attachments. Can be specified multiple times. - With --html, the quoted block uses Gmail's gmail_quote CSS classes and preserves HTML formatting. Use fragment tags (

, , , etc.) — no / wrapper needed. - With --html, inline images in the quoted message are preserved via cid: references. +- Use --draft to save the reply as a draft instead of sending it immediately. - For reply-all, use +reply-all instead. ## See Also diff --git a/skills/gws-gmail-send/SKILL.md b/skills/gws-gmail-send/SKILL.md index aa2d82b9..35c5379c 100644 --- a/skills/gws-gmail-send/SKILL.md +++ b/skills/gws-gmail-send/SKILL.md @@ -35,6 +35,7 @@ gws gmail +send --to --subject --body | `--bcc` | — | — | BCC email address(es), comma-separated | | `--html` | — | — | Treat --body as HTML content (default is plain text) | | `--dry-run` | — | — | Show the request that would be sent without executing it | +| `--draft` | — | — | Save as draft instead of sending | ## Examples @@ -45,6 +46,7 @@ gws gmail +send --to alice@example.com --subject 'Hello' --body 'Bold tex gws gmail +send --to alice@example.com --subject 'Hello' --body 'Hi!' --from alias@example.com gws gmail +send --to alice@example.com --subject 'Report' --body 'See attached' -a report.pdf gws gmail +send --to alice@example.com --subject 'Files' --body 'Two files' -a a.pdf -a b.csv +gws gmail +send --to alice@example.com --subject 'Hello' --body 'Hi!' --draft ``` ## Tips @@ -53,6 +55,7 @@ gws gmail +send --to alice@example.com --subject 'Files' --body 'Two files' -a a - Use --from to send from a configured send-as alias instead of your primary address. - Use -a/--attach to add file attachments. Can be specified multiple times. Total size limit: 25MB. - With --html, use fragment tags (

, , ,
, etc.) — no / wrapper needed. +- Use --draft to save the message as a draft instead of sending it immediately. > [!CAUTION] > This is a **write** command — confirm with the user before executing. diff --git a/src/helpers/gmail/forward.rs b/src/helpers/gmail/forward.rs index efc56240..0c19d4e1 100644 --- a/src/helpers/gmail/forward.rs +++ b/src/helpers/gmail/forward.rs @@ -84,7 +84,7 @@ pub(super) async fn handle_forward( let raw = create_forward_raw_message(&envelope, &original, &all_attachments)?; - super::send_raw_email( + super::dispatch_raw_email( doc, matches, &raw, @@ -510,7 +510,8 @@ mod tests { Arg::new("no-original-attachments") .long("no-original-attachments") .action(ArgAction::SetTrue), - ); + ) + .arg(Arg::new("draft").long("draft").action(ArgAction::SetTrue)); cmd.try_get_matches_from(args).unwrap() } diff --git a/src/helpers/gmail/mod.rs b/src/helpers/gmail/mod.rs index 5f64030d..b4542453 100644 --- a/src/helpers/gmail/mod.rs +++ b/src/helpers/gmail/mod.rs @@ -42,6 +42,9 @@ use std::pin::Pin; pub struct GmailHelper; +/// Broad scope used by reply/forward handlers for both message metadata +/// fetching and the final send/draft operation. Covers `messages.send`, +/// `drafts.create`, and read access in a single token. pub(super) const GMAIL_SCOPE: &str = "https://www.googleapis.com/auth/gmail.modify"; pub(super) const GMAIL_READONLY_SCOPE: &str = "https://www.googleapis.com/auth/gmail.readonly"; pub(super) const PUBSUB_SCOPE: &str = "https://www.googleapis.com/auth/pubsub"; @@ -1359,7 +1362,7 @@ pub(super) fn parse_attachments(matches: &ArgMatches) -> Result, Ok(attachments) } -pub(super) fn resolve_send_method( +fn resolve_send_method( doc: &crate::discovery::RestDescription, ) -> Result<&crate::discovery::RestMethod, GwsError> { let users_res = doc @@ -1376,30 +1379,70 @@ pub(super) fn resolve_send_method( .ok_or_else(|| GwsError::Discovery("Method 'users.messages.send' not found".to_string())) } -/// Build the JSON metadata for `users.messages.send` via the upload endpoint. -/// Only contains `threadId` when replying/forwarding — the raw RFC 5322 message -/// is sent as the media part, not base64-encoded in a `raw` field. -fn build_send_metadata(thread_id: Option<&str>) -> Option { - thread_id.map(|id| json!({ "threadId": id }).to_string()) +fn resolve_draft_method( + doc: &crate::discovery::RestDescription, +) -> Result<&crate::discovery::RestMethod, GwsError> { + let users_res = doc + .resources + .get("users") + .ok_or_else(|| GwsError::Discovery("Resource 'users' not found".to_string()))?; + let drafts_res = users_res + .resources + .get("drafts") + .ok_or_else(|| GwsError::Discovery("Resource 'users.drafts' not found".to_string()))?; + drafts_res + .methods + .get("create") + .ok_or_else(|| GwsError::Discovery("Method 'users.drafts.create' not found".to_string())) +} + +/// Resolve either `users.drafts.create` or `users.messages.send` based on the draft flag. +pub(super) fn resolve_mail_method( + doc: &crate::discovery::RestDescription, + draft: bool, +) -> Result<&crate::discovery::RestMethod, GwsError> { + if draft { + resolve_draft_method(doc) + } else { + resolve_send_method(doc) + } +} + +/// Build the JSON metadata for the upload endpoint. +/// +/// For `users.messages.send`: `{"threadId": "..."}` (only when replying/forwarding); +/// returns `None` for new messages. +/// For `users.drafts.create`: `{"message": {"threadId": "..."}}` when replying/forwarding, +/// or `{"message": {}}` for a new draft (wrapper is always required). +fn build_send_metadata(thread_id: Option<&str>, draft: bool) -> Option { + if draft { + let message = match thread_id { + Some(id) => json!({ "message": { "threadId": id } }), + None => json!({ "message": {} }), + }; + Some(message.to_string()) + } else { + thread_id.map(|id| json!({ "threadId": id }).to_string()) + } } -pub(super) async fn send_raw_email( +pub(super) async fn dispatch_raw_email( doc: &crate::discovery::RestDescription, matches: &ArgMatches, raw_message: &str, thread_id: Option<&str>, existing_token: Option<&str>, ) -> Result<(), GwsError> { - let metadata = build_send_metadata(thread_id); - - let send_method = resolve_send_method(doc)?; + let draft = matches.get_flag("draft"); + let metadata = build_send_metadata(thread_id, draft); + let method = resolve_mail_method(doc, draft)?; let params = json!({ "userId": "me" }); let params_str = params.to_string(); let (token, auth_method) = match existing_token { Some(t) => (Some(t.to_string()), executor::AuthMethod::OAuth), None => { - let scopes: Vec<&str> = send_method.scopes.iter().map(|s| s.as_str()).collect(); + let scopes: Vec<&str> = method.scopes.iter().map(|s| s.as_str()).collect(); match auth::get_token(&scopes).await { Ok(t) => (Some(t), executor::AuthMethod::OAuth), Err(e) if matches.get_flag("dry-run") => { @@ -1419,7 +1462,7 @@ pub(super) async fn send_raw_email( executor::execute_method( doc, - send_method, + method, Some(¶ms_str), metadata.as_deref(), token.as_deref(), @@ -1438,10 +1481,15 @@ pub(super) async fn send_raw_email( ) .await?; + if draft && !matches.get_flag("dry-run") { + eprintln!("Tip: copy the draft \"id\" from the response above, then send with:"); + eprintln!(" gws gmail users.drafts.send --body '{{\"id\":\"\"}}'"); + } + Ok(()) } -/// Add --attach, --cc, --bcc, --html, and --dry-run arguments shared by all mail subcommands. +/// Add common arguments shared by all mail subcommands (--attach, --cc, --bcc, --html, --dry-run, --draft). fn common_mail_args(cmd: Command) -> Command { cmd.arg( Arg::new("attach") @@ -1475,6 +1523,12 @@ fn common_mail_args(cmd: Command) -> Command { .help("Show the request that would be sent without executing it") .action(ArgAction::SetTrue), ) + .arg( + Arg::new("draft") + .long("draft") + .help("Save as draft instead of sending") + .action(ArgAction::SetTrue), + ) } /// Add arguments shared by +reply and +reply-all (everything except --remove). @@ -1558,12 +1612,14 @@ EXAMPLES: gws gmail +send --to alice@example.com --subject 'Hello' --body 'Hi!' --from alias@example.com gws gmail +send --to alice@example.com --subject 'Report' --body 'See attached' -a report.pdf gws gmail +send --to alice@example.com --subject 'Files' --body 'Two files' -a a.pdf -a b.csv + gws gmail +send --to alice@example.com --subject 'Hello' --body 'Hi!' --draft TIPS: Handles RFC 5322 formatting, MIME encoding, and base64 automatically. Use --from to send from a configured send-as alias instead of your primary address. Use -a/--attach to add file attachments. Can be specified multiple times. Total size limit: 25MB. - With --html, use fragment tags (

, , ,
, etc.) — no / wrapper needed.", + With --html, use fragment tags (

, , ,
, etc.) — no / wrapper needed. + Use --draft to save the message as a draft instead of sending it immediately.", ), ); @@ -1616,6 +1672,7 @@ EXAMPLES: gws gmail +reply --message-id 18f1a2b3c4d --body 'Adding Dave' --to dave@example.com gws gmail +reply --message-id 18f1a2b3c4d --body 'Bold reply' --html gws gmail +reply --message-id 18f1a2b3c4d --body 'Updated version' -a updated.docx + gws gmail +reply --message-id 18f1a2b3c4d --body 'Draft reply' --draft TIPS: Automatically sets In-Reply-To, References, and threadId headers. @@ -1625,6 +1682,7 @@ TIPS: With --html, the quoted block uses Gmail's gmail_quote CSS classes and preserves HTML formatting. \ Use fragment tags (

, , , etc.) — no / wrapper needed. With --html, inline images in the quoted message are preserved via cid: references. + Use --draft to save the reply as a draft instead of sending it immediately. For reply-all, use +reply-all instead.", ), ); @@ -1648,6 +1706,7 @@ EXAMPLES: gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Adding Eve' --cc eve@example.com gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Noted' --html gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Notes attached' -a notes.pdf + gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Draft reply' --draft TIPS: Replies to the sender and all original To/CC recipients. @@ -1659,7 +1718,8 @@ TIPS: Use -a/--attach to add file attachments. Can be specified multiple times. With --html, the quoted block uses Gmail's gmail_quote CSS classes and preserves HTML formatting. \ Use fragment tags (

, , , etc.) — no / wrapper needed. - With --html, inline images in the quoted message are preserved via cid: references.", + With --html, inline images in the quoted message are preserved via cid: references. + Use --draft to save the reply as a draft instead of sending it immediately.", ), ); @@ -1709,6 +1769,7 @@ EXAMPLES: gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --body '

FYI

' --html gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com -a notes.pdf gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --no-original-attachments + gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --draft TIPS: Includes the original message with sender, date, subject, and recipients. @@ -1719,7 +1780,8 @@ TIPS: Use -a/--attach to add extra file attachments. Can be specified multiple times. Combined size of original and user attachments is limited to 25MB. With --html, the forwarded block uses Gmail's gmail_quote CSS classes and preserves HTML formatting. \ -Use fragment tags (

, , , etc.) — no / wrapper needed.", +Use fragment tags (

, , , etc.) — no / wrapper needed. + Use --draft to save the forward as a draft instead of sending it immediately.", ), ); @@ -2268,14 +2330,29 @@ mod tests { #[test] fn test_build_send_metadata_with_thread_id() { - let metadata = build_send_metadata(Some("thread-123")).unwrap(); + let metadata = build_send_metadata(Some("thread-123"), false).unwrap(); let parsed: Value = serde_json::from_str(&metadata).unwrap(); assert_eq!(parsed["threadId"], "thread-123"); } #[test] fn test_build_send_metadata_without_thread_id() { - assert!(build_send_metadata(None).is_none()); + assert!(build_send_metadata(None, false).is_none()); + } + + #[test] + fn test_build_send_metadata_draft_with_thread_id() { + let metadata = build_send_metadata(Some("thread-123"), true).unwrap(); + let parsed: Value = serde_json::from_str(&metadata).unwrap(); + assert_eq!(parsed["message"]["threadId"], "thread-123"); + } + + #[test] + fn test_build_send_metadata_draft_without_thread_id() { + let metadata = build_send_metadata(None, true).unwrap(); + let parsed: Value = serde_json::from_str(&metadata).unwrap(); + assert!(parsed["message"].is_object()); + assert!(parsed["message"].get("threadId").is_none()); } #[test] @@ -2401,6 +2478,29 @@ mod tests { assert_eq!(resolved.path, "gmail/v1/users/{userId}/messages/send"); } + #[test] + fn test_resolve_draft_method_finds_gmail_drafts_create_method() { + let mut doc = crate::discovery::RestDescription::default(); + let create_method = crate::discovery::RestMethod { + http_method: "POST".to_string(), + path: "gmail/v1/users/{userId}/drafts".to_string(), + ..Default::default() + }; + + let mut drafts = crate::discovery::RestResource::default(); + drafts.methods.insert("create".to_string(), create_method); + + let mut users = crate::discovery::RestResource::default(); + users.resources.insert("drafts".to_string(), drafts); + + doc.resources = HashMap::from([("users".to_string(), users)]); + + let resolved = resolve_draft_method(&doc).unwrap(); + + assert_eq!(resolved.http_method, "POST"); + assert_eq!(resolved.path, "gmail/v1/users/{userId}/drafts"); + } + #[test] fn test_html_escape() { assert_eq!(html_escape("Hello World"), "Hello World"); diff --git a/src/helpers/gmail/reply.rs b/src/helpers/gmail/reply.rs index 43a70747..6e5b8c21 100644 --- a/src/helpers/gmail/reply.rs +++ b/src/helpers/gmail/reply.rs @@ -130,7 +130,7 @@ pub(super) async fn handle_reply( let raw = create_reply_raw_message(&envelope, &original, &all_attachments)?; - super::send_raw_email( + super::dispatch_raw_email( doc, matches, &raw, @@ -683,7 +683,8 @@ mod tests { Arg::new("dry-run") .long("dry-run") .action(ArgAction::SetTrue), - ); + ) + .arg(Arg::new("draft").long("draft").action(ArgAction::SetTrue)); cmd.try_get_matches_from(args).unwrap() } diff --git a/src/helpers/gmail/send.rs b/src/helpers/gmail/send.rs index 73ce226a..c40e6480 100644 --- a/src/helpers/gmail/send.rs +++ b/src/helpers/gmail/send.rs @@ -25,12 +25,12 @@ pub(super) async fn handle_send( let token = if dry_run { None } else { - // Use the discovery doc scopes (e.g. gmail.send) rather than hardcoding - // gmail.modify, so credentials limited to narrower send-only scopes still - // work. resolve_sender gracefully degrades if the token doesn't cover the - // sendAs.list endpoint. - let send_method = super::resolve_send_method(doc)?; - let scopes: Vec<&str> = send_method.scopes.iter().map(|s| s.as_str()).collect(); + // Resolve the target method (send or draft) and use its discovery + // doc scopes, so the token matches the operation. resolve_sender + // gracefully degrades if the token doesn't cover the sendAs.list + // endpoint. + let method = super::resolve_mail_method(doc, matches.get_flag("draft"))?; + let scopes: Vec<&str> = method.scopes.iter().map(|s| s.as_str()).collect(); let t = auth::get_token(&scopes) .await .map_err(|e| GwsError::Auth(format!("Gmail auth failed: {e}")))?; @@ -41,7 +41,7 @@ pub(super) async fn handle_send( let raw = create_send_raw_message(&config)?; - super::send_raw_email(doc, matches, &raw, None, token.as_deref()).await + super::dispatch_raw_email(doc, matches, &raw, None, token.as_deref()).await } pub(super) struct SendConfig { @@ -108,7 +108,8 @@ mod tests { .long("attach") .short('a') .action(ArgAction::Append), - ); + ) + .arg(Arg::new("draft").long("draft").action(ArgAction::SetTrue)); cmd.try_get_matches_from(args).unwrap() }