From 8bd00607dc53fabd96e95917b77c9a13d6ead6ba Mon Sep 17 00:00:00 2001 From: Donovan Yohan <34756395+donovan-yohan@users.noreply.github.com> Date: Sat, 30 May 2026 01:51:41 -0700 Subject: [PATCH] fix(google-workspace): handle Gmail header casing case-insensitively Normalize Gmail API message header names to lowercase before lookup so gmail get/search/reply populate to/subject/from regardless of the casing the message was stored with. Emit conventional MIME header casing (To/Subject/Cc/From) on send and reply. Fixes #34806 Co-authored-by: Donovan Yohan --- .../google-workspace/scripts/google_api.py | 82 +++---- tests/skills/test_google_workspace_api.py | 206 ++++++++++++++++++ 2 files changed, 249 insertions(+), 39 deletions(-) diff --git a/skills/productivity/google-workspace/scripts/google_api.py b/skills/productivity/google-workspace/scripts/google_api.py index 231b1b6849f..27855a5158e 100644 --- a/skills/productivity/google-workspace/scripts/google_api.py +++ b/skills/productivity/google-workspace/scripts/google_api.py @@ -129,7 +129,11 @@ def _run_gws(parts: list[str], *, params: dict | None = None, body: dict | None def _headers_dict(msg: dict) -> dict[str, str]: - return {h["name"]: h["value"] for h in msg.get("payload", {}).get("headers", [])} + return { + h["name"].lower(): h["value"] + for h in msg.get("payload", {}).get("headers", []) + if h.get("name") + } def _extract_message_body(msg: dict) -> str: @@ -230,10 +234,10 @@ def gmail_search(args): { "id": msg["id"], "threadId": msg["threadId"], - "from": headers.get("From", ""), - "to": headers.get("To", ""), - "subject": headers.get("Subject", ""), - "date": headers.get("Date", ""), + "from": headers.get("from", ""), + "to": headers.get("to", ""), + "subject": headers.get("subject", ""), + "date": headers.get("date", ""), "snippet": msg.get("snippet", ""), "labels": msg.get("labelIds", []), } @@ -260,10 +264,10 @@ def gmail_search(args): output.append({ "id": msg["id"], "threadId": msg["threadId"], - "from": headers.get("From", ""), - "to": headers.get("To", ""), - "subject": headers.get("Subject", ""), - "date": headers.get("Date", ""), + "from": headers.get("from", ""), + "to": headers.get("to", ""), + "subject": headers.get("subject", ""), + "date": headers.get("date", ""), "snippet": msg.get("snippet", ""), "labels": msg.get("labelIds", []), }) @@ -281,10 +285,10 @@ def gmail_get(args): result = { "id": msg["id"], "threadId": msg["threadId"], - "from": headers.get("From", ""), - "to": headers.get("To", ""), - "subject": headers.get("Subject", ""), - "date": headers.get("Date", ""), + "from": headers.get("from", ""), + "to": headers.get("to", ""), + "subject": headers.get("subject", ""), + "date": headers.get("date", ""), "labels": msg.get("labelIds", []), "body": _extract_message_body(msg), } @@ -300,10 +304,10 @@ def gmail_get(args): result = { "id": msg["id"], "threadId": msg["threadId"], - "from": headers.get("From", ""), - "to": headers.get("To", ""), - "subject": headers.get("Subject", ""), - "date": headers.get("Date", ""), + "from": headers.get("from", ""), + "to": headers.get("to", ""), + "subject": headers.get("subject", ""), + "date": headers.get("date", ""), "labels": msg.get("labelIds", []), "body": _extract_message_body(msg), } @@ -314,12 +318,12 @@ def gmail_get(args): def gmail_send(args): if _gws_binary(): message = MIMEText(args.body, "html" if args.html else "plain") - message["to"] = args.to - message["subject"] = args.subject + message["To"] = args.to + message["Subject"] = args.subject if args.cc: - message["cc"] = args.cc + message["Cc"] = args.cc if args.from_header: - message["from"] = args.from_header + message["From"] = args.from_header raw = base64.urlsafe_b64encode(message.as_bytes()).decode() body = {"raw": raw} @@ -336,12 +340,12 @@ def gmail_send(args): service = build_service("gmail", "v1") message = MIMEText(args.body, "html" if args.html else "plain") - message["to"] = args.to - message["subject"] = args.subject + message["To"] = args.to + message["Subject"] = args.subject if args.cc: - message["cc"] = args.cc + message["Cc"] = args.cc if args.from_header: - message["from"] = args.from_header + message["From"] = args.from_header raw = base64.urlsafe_b64encode(message.as_bytes()).decode() body = {"raw": raw} @@ -367,18 +371,18 @@ def gmail_reply(args): ) headers = _headers_dict(original) - subject = headers.get("Subject", "") + subject = headers.get("subject", "") if not subject.startswith("Re:"): subject = f"Re: {subject}" message = MIMEText(args.body) - message["to"] = headers.get("From", "") - message["subject"] = subject + message["To"] = headers.get("from", "") + message["Subject"] = subject if args.from_header: - message["from"] = args.from_header - if headers.get("Message-ID"): - message["In-Reply-To"] = headers["Message-ID"] - message["References"] = headers["Message-ID"] + message["From"] = args.from_header + if headers.get("message-id"): + message["In-Reply-To"] = headers["message-id"] + message["References"] = headers["message-id"] raw = base64.urlsafe_b64encode(message.as_bytes()).decode() result = _run_gws( @@ -396,18 +400,18 @@ def gmail_reply(args): ).execute() headers = _headers_dict(original) - subject = headers.get("Subject", "") + subject = headers.get("subject", "") if not subject.startswith("Re:"): subject = f"Re: {subject}" message = MIMEText(args.body) - message["to"] = headers.get("From", "") - message["subject"] = subject + message["To"] = headers.get("from", "") + message["Subject"] = subject if args.from_header: - message["from"] = args.from_header - if headers.get("Message-ID"): - message["In-Reply-To"] = headers["Message-ID"] - message["References"] = headers["Message-ID"] + message["From"] = args.from_header + if headers.get("message-id"): + message["In-Reply-To"] = headers["message-id"] + message["References"] = headers["message-id"] raw = base64.urlsafe_b64encode(message.as_bytes()).decode() body = {"raw": raw, "threadId": original["threadId"]} diff --git a/tests/skills/test_google_workspace_api.py b/tests/skills/test_google_workspace_api.py index 30a1441d634..ffb56ce3cb5 100644 --- a/tests/skills/test_google_workspace_api.py +++ b/tests/skills/test_google_workspace_api.py @@ -229,6 +229,212 @@ def test_api_calendar_list_respects_date_range(api_module): assert params["timeMax"] == "2026-04-07T23:59:59Z" +@pytest.mark.parametrize( + "header_names", + [ + ("from", "to", "subject", "date"), + ("From", "To", "Subject", "Date"), + ], +) +def test_api_gmail_get_reads_headers_case_insensitively(api_module, capsys, header_names): + from_name, to_name, subject_name, date_name = header_names + + def fake_run_gws(parts, *, params=None, body=None): + assert parts == ["gmail", "users", "messages", "get"] + assert params == {"userId": "me", "id": "msg-1", "format": "full"} + return { + "id": "msg-1", + "threadId": "thread-1", + "labelIds": ["INBOX"], + "payload": { + "headers": [ + {"name": from_name, "value": "sender@example.com"}, + {"name": to_name, "value": "recipient@example.com"}, + {"name": subject_name, "value": "case bug"}, + {"name": date_name, "value": "Fri, 29 May 2026 12:00:00 +0000"}, + ], + "body": {}, + }, + } + + api_module._run_gws = fake_run_gws + args = api_module.argparse.Namespace(message_id="msg-1", func=api_module.gmail_get) + + api_module.gmail_get(args) + + result = json.loads(capsys.readouterr().out) + assert result["from"] == "sender@example.com" + assert result["to"] == "recipient@example.com" + assert result["subject"] == "case bug" + assert result["date"] == "Fri, 29 May 2026 12:00:00 +0000" + + +@pytest.mark.parametrize( + "header_names", + [ + ("from", "to", "subject", "date"), + ("From", "To", "Subject", "Date"), + ], +) +def test_api_gmail_search_reads_headers_case_insensitively( + api_module, + capsys, + header_names, +): + from_name, to_name, subject_name, date_name = header_names + calls = [] + + def fake_run_gws(parts, *, params=None, body=None): + calls.append({"parts": parts, "params": params, "body": body}) + if parts == ["gmail", "users", "messages", "list"]: + assert params == {"userId": "me", "q": "from:sender", "maxResults": 5} + return {"messages": [{"id": "msg-1"}]} + + assert parts == ["gmail", "users", "messages", "get"] + assert params == { + "userId": "me", + "id": "msg-1", + "format": "metadata", + "metadataHeaders": ["From", "To", "Subject", "Date"], + } + return { + "id": "msg-1", + "threadId": "thread-1", + "labelIds": ["INBOX"], + "snippet": "preview", + "payload": { + "headers": [ + {"name": from_name, "value": "sender@example.com"}, + {"name": to_name, "value": "recipient@example.com"}, + {"name": subject_name, "value": "case bug"}, + {"name": date_name, "value": "Fri, 29 May 2026 12:00:00 +0000"}, + ], + }, + } + + api_module._run_gws = fake_run_gws + args = api_module.argparse.Namespace( + query="from:sender", + max=5, + func=api_module.gmail_search, + ) + + api_module.gmail_search(args) + + assert len(calls) == 2 + result = json.loads(capsys.readouterr().out) + assert result == [ + { + "id": "msg-1", + "threadId": "thread-1", + "from": "sender@example.com", + "to": "recipient@example.com", + "subject": "case bug", + "date": "Fri, 29 May 2026 12:00:00 +0000", + "snippet": "preview", + "labels": ["INBOX"], + } + ] + + +def test_api_gmail_send_uses_conventional_mime_header_casing(api_module): + captured = {} + + def fake_run_gws(parts, *, params=None, body=None): + captured["parts"] = parts + captured["params"] = params + captured["body"] = body + return {"id": "sent-1", "threadId": "thread-1"} + + api_module._run_gws = fake_run_gws + args = api_module.argparse.Namespace( + to="recipient@example.com", + subject="hello", + body="body", + html=False, + cc="copy@example.com", + from_header="sender@example.com", + thread_id="thread-1", + func=api_module.gmail_send, + ) + + api_module.gmail_send(args) + + raw = api_module.base64.urlsafe_b64decode(captured["body"]["raw"]) + raw_text = raw.decode() + assert "To: recipient@example.com" in raw_text + assert "Subject: hello" in raw_text + assert "Cc: copy@example.com" in raw_text + assert "From: sender@example.com" in raw_text + assert "\nto: " not in raw_text + assert "\nsubject: " not in raw_text + + +@pytest.mark.parametrize( + "header_names", + [ + ("from", "subject", "message-id"), + ("From", "Subject", "Message-ID"), + ], +) +def test_api_gmail_reply_reads_headers_case_insensitively_and_uses_conventional_mime_header_casing( + api_module, + header_names, +): + from_name, subject_name, message_id_name = header_names + calls = [] + + def fake_run_gws(parts, *, params=None, body=None): + calls.append({"parts": parts, "params": params, "body": body}) + if parts == ["gmail", "users", "messages", "get"]: + assert params == { + "userId": "me", + "id": "msg-1", + "format": "metadata", + "metadataHeaders": ["From", "Subject", "Message-ID"], + } + return { + "id": "msg-1", + "threadId": "thread-1", + "payload": { + "headers": [ + {"name": from_name, "value": "sender@example.com"}, + {"name": subject_name, "value": "case bug"}, + {"name": message_id_name, "value": ""}, + ], + }, + } + + assert parts == ["gmail", "users", "messages", "send"] + assert params == {"userId": "me"} + return {"id": "sent-1", "threadId": "thread-1"} + + api_module._run_gws = fake_run_gws + args = api_module.argparse.Namespace( + message_id="msg-1", + body="reply body", + from_header="recipient@example.com", + func=api_module.gmail_reply, + ) + + api_module.gmail_reply(args) + + assert len(calls) == 2 + body = calls[1]["body"] + assert body["threadId"] == "thread-1" + raw = api_module.base64.urlsafe_b64decode(body["raw"]) + raw_text = raw.decode() + assert "To: sender@example.com" in raw_text + assert "Subject: Re: case bug" in raw_text + assert "From: recipient@example.com" in raw_text + assert "In-Reply-To: " in raw_text + assert "References: " in raw_text + assert "\nto: " not in raw_text + assert "\nsubject: " not in raw_text + assert "\nin-reply-to: " not in raw_text + assert "\nreferences: " not in raw_text + + def test_api_get_credentials_refresh_persists_authorized_user_type(api_module, monkeypatch): token_path = api_module.TOKEN_PATH _write_token(token_path, token="ya29.old")