diff --git a/.changeset/gmail-attachment-handling.md b/.changeset/gmail-attachment-handling.md new file mode 100644 index 00000000..ea5376fe --- /dev/null +++ b/.changeset/gmail-attachment-handling.md @@ -0,0 +1,5 @@ +--- +"@googleworkspace/cli": patch +--- + +Improve Gmail attachment handling by padding attachment data and documenting a decode-and-upload flow. diff --git a/crates/google-workspace-cli/registry/recipes.toml b/crates/google-workspace-cli/registry/recipes.toml index 8440ca1b..8eab26c0 100644 --- a/crates/google-workspace-cli/registry/recipes.toml +++ b/crates/google-workspace-cli/registry/recipes.toml @@ -322,8 +322,8 @@ services = [ "gmail", "drive" ] steps = [ "Search for emails with attachments: `gws gmail users messages list --params '{\"userId\": \"me\", \"q\": \"has:attachment from:client@example.com\"}' --format table`", "Get message details: `gws gmail users messages get --params '{\"userId\": \"me\", \"id\": \"MESSAGE_ID\"}'`", - "Download attachment: `gws gmail users messages attachments get --params '{\"userId\": \"me\", \"messageId\": \"MESSAGE_ID\", \"id\": \"ATTACHMENT_ID\"}'`", - "Upload to Drive folder: `gws drive +upload --file ./attachment.pdf --parent FOLDER_ID`" + "Download and decode attachment bytes: `gws gmail users messages attachments get --params '{\"userId\": \"me\", \"messageId\": \"MESSAGE_ID\", \"id\": \"ATTACHMENT_ID\"}' | jq -r '.data' | python3 -c 'import base64,sys; d=sys.stdin.read().strip(); sys.stdout.buffer.write(base64.urlsafe_b64decode(d + \"=\" * (-len(d) % 4)))' > attachment.pdf`", + "Upload to Drive folder: `gws drive +upload ./attachment.pdf --parent FOLDER_ID`" ] [[recipes]] diff --git a/crates/google-workspace-cli/src/executor.rs b/crates/google-workspace-cli/src/executor.rs index 46f31ac4..59c3d02d 100644 --- a/crates/google-workspace-cli/src/executor.rs +++ b/crates/google-workspace-cli/src/executor.rs @@ -243,6 +243,7 @@ async fn build_http_request( #[allow(clippy::too_many_arguments)] async fn handle_json_response( body_text: &str, + method_id: &str, pagination: &PaginationConfig, sanitize_template: Option<&str>, sanitize_mode: &crate::helpers::modelarmor::SanitizeMode, @@ -254,6 +255,7 @@ async fn handle_json_response( ) -> Result { if let Ok(mut json_val) = serde_json::from_str::(body_text) { *pages_fetched += 1; + normalize_json_response(method_id, &mut json_val); // Run Model Armor sanitization if --sanitize is enabled if let Some(template) = sanitize_template { @@ -336,6 +338,27 @@ async fn handle_json_response( Ok(false) } +fn normalize_json_response(method_id: &str, json_val: &mut Value) { + if method_id == "gmail.users.messages.attachments.get" { + pad_gmail_attachment_data(json_val); + } +} + +fn pad_gmail_attachment_data(json_val: &mut Value) { + let Some(data) = json_val.get_mut("data") else { + return; + }; + let Some(raw) = data.as_str() else { + return; + }; + + let padding = (4 - raw.len() % 4) % 4; + if padding > 0 { + let padded = format!("{}{}", raw, "=".repeat(padding)); + *data = Value::String(padded); + } +} + /// Handle a binary response by streaming it to a file. async fn handle_binary_response( response: reqwest::Response, @@ -497,6 +520,7 @@ pub async fn execute_method( let should_continue = handle_json_response( &body_text, + method_id, pagination, sanitize_template, sanitize_mode, @@ -2286,6 +2310,42 @@ fn test_get_value_type_helper() { assert_eq!(get_value_type(&json!({"a": 1})), "object"); } +#[test] +fn test_pad_gmail_attachment_data_adds_missing_padding() { + let mut value = json!({ + "data": "aGVsbG8", + "size": 5 + }); + + normalize_json_response("gmail.users.messages.attachments.get", &mut value); + + assert_eq!(value["data"], "aGVsbG8="); +} + +#[test] +fn test_pad_gmail_attachment_data_leaves_padded_value_unchanged() { + let mut value = json!({ + "data": "aGVsbG8=", + "size": 5 + }); + + normalize_json_response("gmail.users.messages.attachments.get", &mut value); + + assert_eq!(value["data"], "aGVsbG8="); +} + +#[test] +fn test_pad_gmail_attachment_data_ignores_other_methods() { + let mut value = json!({ + "data": "aGVsbG8", + "size": 5 + }); + + normalize_json_response("gmail.users.messages.get", &mut value); + + assert_eq!(value["data"], "aGVsbG8"); +} + #[tokio::test] async fn test_post_without_body_sets_content_length_zero() { let client = reqwest::Client::new(); diff --git a/skills/recipe-save-email-attachments/SKILL.md b/skills/recipe-save-email-attachments/SKILL.md index 2ea287ee..7237d1cb 100644 --- a/skills/recipe-save-email-attachments/SKILL.md +++ b/skills/recipe-save-email-attachments/SKILL.md @@ -24,6 +24,11 @@ Find Gmail messages with attachments and save them to a Google Drive folder. 1. Search for emails with attachments: `gws gmail users messages list --params '{"userId": "me", "q": "has:attachment from:client@example.com"}' --format table` 2. Get message details: `gws gmail users messages get --params '{"userId": "me", "id": "MESSAGE_ID"}'` -3. Download attachment: `gws gmail users messages attachments get --params '{"userId": "me", "messageId": "MESSAGE_ID", "id": "ATTACHMENT_ID"}'` -4. Upload to Drive folder: `gws drive +upload --file ./attachment.pdf --parent FOLDER_ID` - +3. Download and decode attachment bytes: + ```bash + gws gmail users messages attachments get --params '{"userId": "me", "messageId": "MESSAGE_ID", "id": "ATTACHMENT_ID"}' \ + | jq -r '.data' \ + | python3 -c 'import base64,sys; d=sys.stdin.read().strip(); sys.stdout.buffer.write(base64.urlsafe_b64decode(d + "=" * (-len(d) % 4)))' \ + > attachment.pdf + ``` +4. Upload to Drive folder: `gws drive +upload ./attachment.pdf --parent FOLDER_ID`