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:
Donovan Yohan 2026-05-30 01:51:41 -07:00 committed by Teknium
parent 6baf0016be
commit 8bd00607dc
2 changed files with 249 additions and 39 deletions

View file

@ -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"]}

View file

@ -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")