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/gmail-attachment-handling.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@googleworkspace/cli": patch
---

Improve Gmail attachment handling by padding attachment data and documenting a decode-and-upload flow.
4 changes: 2 additions & 2 deletions crates/google-workspace-cli/registry/recipes.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]]
Expand Down
60 changes: 60 additions & 0 deletions crates/google-workspace-cli/src/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -254,6 +255,7 @@ async fn handle_json_response(
) -> Result<bool, GwsError> {
if let Ok(mut json_val) = serde_json::from_str::<Value>(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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -497,6 +520,7 @@ pub async fn execute_method(

let should_continue = handle_json_response(
&body_text,
method_id,
pagination,
sanitize_template,
sanitize_mode,
Expand Down Expand Up @@ -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();
Expand Down
11 changes: 8 additions & 3 deletions skills/recipe-save-email-attachments/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Loading