mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-09 08:21:50 +00:00
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 <donovan-yohan@users.noreply.github.com>
This commit is contained in:
parent
6baf0016be
commit
8bd00607dc
2 changed files with 249 additions and 39 deletions
|
|
@ -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"]}
|
||||
|
|
|
|||
|
|
@ -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": "<msg-1@example.com>"},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
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: <msg-1@example.com>" in raw_text
|
||||
assert "References: <msg-1@example.com>" 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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue