Compare commits

...

22 Commits

Author SHA1 Message Date
Taylor Wilsdon
ff35fd7950 Merge pull request #578 from Bortlesboat/feat/555-auto-reply-headers
Some checks failed
Docker Build and Push to GHCR / build-and-push (push) Has been cancelled
Ruff / ruff (push) Has been cancelled
feat(gmail): auto-populate In-Reply-To/References for reply drafts
2026-03-17 15:32:32 -04:00
Taylor Wilsdon
894564f378 refac tests 2026-03-17 15:32:14 -04:00
Taylor Wilsdon
a87ac1737d add test 2026-03-17 14:34:21 -04:00
Taylor Wilsdon
464c182829 Merge branch 'main' of github.com:taylorwilsdon/google_workspace_mcp into feat/555-auto-reply-headers 2026-03-17 14:33:41 -04:00
Taylor Wilsdon
01ffcc1fc6 Merge pull request #565 from reinlemmens/fix/calendar-meeting-links
Display meeting links in calendar event output
2026-03-17 14:32:37 -04:00
Taylor Wilsdon
156a1f20ef Merge pull request #573 from seidnerj/fix/oauth-response-pages
fix: improve OAuth response pages for browser compatibility
2026-03-17 11:49:10 -04:00
Taylor Wilsdon
0dfed9081b Merge pull request #577 from Bortlesboat/fix/567-darwin-stdout
fix: suppress platform string output to stdout on macOS
2026-03-17 11:35:09 -04:00
Taylor Wilsdon
ffcea58fdc refac 2026-03-17 10:42:32 -04:00
Taylor Wilsdon
96df53c5e9 refac 2026-03-17 10:34:30 -04:00
Taylor Wilsdon
16ce566d88 refac 2026-03-17 10:21:58 -04:00
Taylor Wilsdon
86a159a364 Merge branch 'main' of github.com:taylorwilsdon/google_workspace_mcp into feat/555-auto-reply-headers 2026-03-17 10:11:51 -04:00
Taylor Wilsdon
441f052dca refac 2026-03-17 08:43:55 -04:00
Taylor Wilsdon
9ef6f72e26 Merge branch 'main' of github.com:taylorwilsdon/google_workspace_mcp into feat/555-auto-reply-headers 2026-03-17 08:37:20 -04:00
Taylor Wilsdon
2ab22ee630 refac 2026-03-16 14:44:45 -04:00
Taylor Wilsdon
e2c08b5f69 Merge branch 'main' of github.com:taylorwilsdon/google_workspace_mcp into fix/calendar-meeting-links 2026-03-16 14:42:18 -04:00
Bortlesboat
ebc3fcb044 feat(gmail): auto-populate In-Reply-To/References when thread_id is provided
When draft_gmail_message is called with a thread_id but without
in_reply_to or references headers, fetch the thread via the Gmail API
to extract Message-ID headers. This ensures reply drafts render inline
in Gmail's thread view instead of appearing as ghost drafts.

Fixes #555
2026-03-15 17:17:15 -04:00
seidnerj
4bdc96a554 fix: use consistent "tab" wording in success page CTAs
Change "Close Window" to "Close Tab" and "This window will close" to
"This tab will close" on the success page to match the rest of the PR.
2026-03-14 07:35:18 +02:00
seidnerj
31e27b76b6 fix: improve OAuth response pages for browser compatibility
window.close() is blocked by modern browsers for tabs not opened via
window.open(). The success page's close button and auto-close timer
silently fail as a result.

- Add tryClose() that attempts window.close() and falls back to
  showing "You can close this tab manually" after 500ms
- Remove ineffective auto-close scripts from error pages
2026-03-14 01:37:18 +02:00
Rein Lemmens
59b3a2492c Reject heading_level and named_style_type together in validation
Add mutual exclusion guard so clients get a clear error instead of
named_style_type silently overriding heading_level.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 21:20:17 +01:00
Rein Lemmens
9285a5f97e Revert "Extract shared named style constant and use keyword args"
This reverts commit 00796f39c6.
2026-03-12 21:15:57 +01:00
Rein Lemmens
00796f39c6 Extract shared named style constant and use keyword args
- Extract VALID_NAMED_STYLE_TYPES constant in docs_helpers.py, reuse in validation_manager.py
- Switch build_paragraph_style call to keyword arguments for clarity and resilience

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 21:15:39 +01:00
Rein Lemmens
e441ade35f Display meeting links in calendar event output
Conference/meeting links (Google Meet, Zoom, etc.) were fetched from the
API but never included in get_events() output. Extract video entry point
from conferenceData (with hangoutLink fallback) and display it in all
three output modes: single detailed, multi detailed, and basic list.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 21:08:16 +01:00
8 changed files with 483 additions and 21 deletions

View File

@@ -26,8 +26,7 @@ def create_error_response(error_message: str, status_code: int = 400) -> HTMLRes
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 40px auto; padding: 20px; text-align: center;"> <body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 40px auto; padding: 20px; text-align: center;">
<h2 style="color: #d32f2f;">Authentication Error</h2> <h2 style="color: #d32f2f;">Authentication Error</h2>
<p>{error_message}</p> <p>{error_message}</p>
<p>Please ensure you grant the requested permissions. You can close this window and try again.</p> <p>Please ensure you grant the requested permissions. You can close this tab and try again.</p>
<script>setTimeout(function() {{ window.close(); }}, 10000);</script>
</body> </body>
</html> </html>
""" """
@@ -176,9 +175,17 @@ def create_success_response(verified_user_id: Optional[str] = None) -> HTMLRespo
}} }}
</style> </style>
<script> <script>
setTimeout(function() {{ function tryClose() {{
window.close(); window.close();
}}, 10000); // If window.close() was blocked by the browser, update the UI
setTimeout(function() {{
var btn = document.querySelector('.button');
if (btn) btn.textContent = 'You can close this tab manually';
var ac = document.querySelector('.auto-close');
if (ac) ac.style.display = 'none';
}}, 500);
}}
setTimeout(tryClose, 10000);
</script> </script>
</head> </head>
<body> <body>
@@ -189,10 +196,10 @@ def create_success_response(verified_user_id: Optional[str] = None) -> HTMLRespo
You've been authenticated as <span class="user-id">{user_display}</span> You've been authenticated as <span class="user-id">{user_display}</span>
</div> </div>
<div class="message"> <div class="message">
Your credentials have been securely saved. You can now close this window and retry your original command. Your credentials have been securely saved. You can now close this tab and retry your original command.
</div> </div>
<button class="button" onclick="window.close()">Close Window</button> <button class="button" onclick="tryClose()">Close Tab</button>
<div class="auto-close">This window will close automatically in 10 seconds</div> <div class="auto-close">This tab will close automatically in 10 seconds</div>
</div> </div>
</body> </body>
</html>""" </html>"""
@@ -215,8 +222,7 @@ def create_server_error_response(error_detail: str) -> HTMLResponse:
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 40px auto; padding: 20px; text-align: center;"> <body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 40px auto; padding: 20px; text-align: center;">
<h2 style="color: #d32f2f;">Authentication Processing Error</h2> <h2 style="color: #d32f2f;">Authentication Processing Error</h2>
<p>An unexpected error occurred while processing your authentication: {error_detail}</p> <p>An unexpected error occurred while processing your authentication: {error_detail}</p>
<p>Please try again. You can close this window.</p> <p>Please try again. You can close this tab.</p>
<script>setTimeout(function() {{ window.close(); }}, 10000);</script>
</body> </body>
</html> </html>
""" """

View File

@@ -172,6 +172,21 @@ def _preserve_existing_fields(
event_body[field_name] = new_value event_body[field_name] = new_value
def _get_meeting_link(item: Dict[str, Any]) -> str:
"""Extract video meeting link from event conference data or hangoutLink."""
conference_data = item.get("conferenceData")
if conference_data and "entryPoints" in conference_data:
for entry_point in conference_data["entryPoints"]:
if entry_point.get("entryPointType") == "video":
uri = entry_point.get("uri", "")
if uri:
return uri
hangout_link = item.get("hangoutLink", "")
if hangout_link:
return hangout_link
return ""
def _format_attendee_details( def _format_attendee_details(
attendees: List[Dict[str, Any]], indent: str = " " attendees: List[Dict[str, Any]], indent: str = " "
) -> str: ) -> str:
@@ -448,6 +463,8 @@ async def get_events(
) )
attendee_details_str = _format_attendee_details(attendees, indent=" ") attendee_details_str = _format_attendee_details(attendees, indent=" ")
meeting_link = _get_meeting_link(item)
event_details = ( event_details = (
f"Event Details:\n" f"Event Details:\n"
f"- Title: {summary}\n" f"- Title: {summary}\n"
@@ -456,6 +473,10 @@ async def get_events(
f"- Description: {description}\n" f"- Description: {description}\n"
f"- Location: {location}\n" f"- Location: {location}\n"
f"- Color ID: {color_id}\n" f"- Color ID: {color_id}\n"
)
if meeting_link:
event_details += f"- Meeting Link: {meeting_link}\n"
event_details += (
f"- Attendees: {attendee_emails}\n" f"- Attendees: {attendee_emails}\n"
f"- Attendee Details: {attendee_details_str}\n" f"- Attendee Details: {attendee_details_str}\n"
) )
@@ -494,10 +515,16 @@ async def get_events(
) )
attendee_details_str = _format_attendee_details(attendees, indent=" ") attendee_details_str = _format_attendee_details(attendees, indent=" ")
meeting_link = _get_meeting_link(item)
event_detail_parts = ( event_detail_parts = (
f'- "{summary}" (Starts: {start_time}, Ends: {end_time})\n' f'- "{summary}" (Starts: {start_time}, Ends: {end_time})\n'
f" Description: {description}\n" f" Description: {description}\n"
f" Location: {location}\n" f" Location: {location}\n"
)
if meeting_link:
event_detail_parts += f" Meeting Link: {meeting_link}\n"
event_detail_parts += (
f" Attendees: {attendee_emails}\n" f" Attendees: {attendee_emails}\n"
f" Attendee Details: {attendee_details_str}\n" f" Attendee Details: {attendee_details_str}\n"
) )
@@ -513,9 +540,12 @@ async def get_events(
event_details_list.append(event_detail_parts) event_details_list.append(event_detail_parts)
else: else:
# Basic output format # Basic output format
event_details_list.append( meeting_link = _get_meeting_link(item)
f'- "{summary}" (Starts: {start_time}, Ends: {end_time}) ID: {item_event_id} | Link: {link}' basic_line = f'- "{summary}" (Starts: {start_time}, Ends: {end_time})'
) if meeting_link:
basic_line += f" Meeting: {meeting_link}"
basic_line += f" ID: {item_event_id} | Link: {link}"
event_details_list.append(basic_line)
if event_id: if event_id:
# Single event basic output # Single event basic output

View File

@@ -1463,6 +1463,7 @@ async def update_paragraph_style(
indent_end: float = None, indent_end: float = None,
space_above: float = None, space_above: float = None,
space_below: float = None, space_below: float = None,
named_style_type: str = None,
list_type: str = None, list_type: str = None,
list_nesting_level: int = None, list_nesting_level: int = None,
) -> str: ) -> str:
@@ -1488,6 +1489,8 @@ async def update_paragraph_style(
indent_end: Right/end indent in points indent_end: Right/end indent in points
space_above: Space above paragraph in points (e.g., 12 for one line) space_above: Space above paragraph in points (e.g., 12 for one line)
space_below: Space below paragraph in points space_below: Space below paragraph in points
named_style_type: Direct named style type - 'NORMAL_TEXT', 'TITLE', 'SUBTITLE',
'HEADING_1' through 'HEADING_6'. Mutually exclusive with heading_level.
list_type: Create a list from existing paragraphs ('UNORDERED' for bullets, 'ORDERED' for numbers) list_type: Create a list from existing paragraphs ('UNORDERED' for bullets, 'ORDERED' for numbers)
list_nesting_level: Nesting level for lists (0-8, where 0 is top level, default is 0) list_nesting_level: Nesting level for lists (0-8, where 0 is top level, default is 0)
Use higher levels for nested/indented list items Use higher levels for nested/indented list items
@@ -1546,12 +1549,30 @@ async def update_paragraph_style(
if list_nesting_level < 0 or list_nesting_level > 8: if list_nesting_level < 0 or list_nesting_level > 8:
return "Error: list_nesting_level must be between 0 and 8" return "Error: list_nesting_level must be between 0 and 8"
# Validate named_style_type
if named_style_type is not None and heading_level is not None:
return "Error: heading_level and named_style_type are mutually exclusive; provide only one"
if named_style_type is not None:
valid_styles = [
"NORMAL_TEXT", "TITLE", "SUBTITLE",
"HEADING_1", "HEADING_2", "HEADING_3",
"HEADING_4", "HEADING_5", "HEADING_6",
]
if named_style_type not in valid_styles:
return f"Error: Invalid named_style_type '{named_style_type}'. Must be one of: {', '.join(valid_styles)}"
# Build paragraph style object # Build paragraph style object
paragraph_style = {} paragraph_style = {}
fields = [] fields = []
# Handle named_style_type (direct named style)
if named_style_type is not None:
paragraph_style["namedStyleType"] = named_style_type
fields.append("namedStyleType")
# Handle heading level (named style) # Handle heading level (named style)
if heading_level is not None: elif heading_level is not None:
if heading_level < 0 or heading_level > 6: if heading_level < 0 or heading_level > 6:
return "Error: heading_level must be between 0 (normal text) and 6" return "Error: heading_level must be between 0 (normal text) and 6"
if heading_level == 0: if heading_level == 0:

View File

@@ -475,6 +475,7 @@ class BatchOperationManager:
"indent_end", "indent_end",
"space_above", "space_above",
"space_below", "space_below",
"named_style_type",
], ],
"description": "Apply paragraph-level styling (headings, alignment, spacing, indentation)", "description": "Apply paragraph-level styling (headings, alignment, spacing, indentation)",
}, },

View File

@@ -316,6 +316,12 @@ class ValidationManager:
"At least one paragraph style parameter must be provided (heading_level, alignment, line_spacing, indent_first_line, indent_start, indent_end, space_above, space_below, or named_style_type)", "At least one paragraph style parameter must be provided (heading_level, alignment, line_spacing, indent_first_line, indent_start, indent_end, space_above, space_below, or named_style_type)",
) )
if heading_level is not None and named_style_type is not None:
return (
False,
"heading_level and named_style_type are mutually exclusive; provide only one",
)
if named_style_type is not None: if named_style_type is not None:
valid_styles = [ valid_styles = [
"NORMAL_TEXT", "NORMAL_TEXT",

View File

@@ -7,6 +7,7 @@ This module provides MCP tools for interacting with the Gmail API.
import logging import logging
import asyncio import asyncio
import base64 import base64
import re
import ssl import ssl
import mimetypes import mimetypes
from html.parser import HTMLParser from html.parser import HTMLParser
@@ -441,6 +442,91 @@ def _extract_headers(payload: dict, header_names: List[str]) -> Dict[str, str]:
return headers return headers
def _parse_message_id_chain(header_value: Optional[str]) -> List[str]:
"""Extract Message-IDs from a reply header value."""
if not header_value:
return []
message_ids = re.findall(r"<[^>]+>", header_value)
if message_ids:
return message_ids
return header_value.split()
def _derive_reply_headers(
thread_message_ids: List[str],
in_reply_to: Optional[str],
references: Optional[str],
) -> tuple[Optional[str], Optional[str]]:
"""Fill missing reply headers while preserving caller intent."""
derived_in_reply_to = in_reply_to
derived_references = references
if not thread_message_ids:
return derived_in_reply_to, derived_references
if not derived_in_reply_to:
reference_chain = _parse_message_id_chain(derived_references)
derived_in_reply_to = (
reference_chain[-1] if reference_chain else thread_message_ids[-1]
)
if not derived_references:
if derived_in_reply_to and derived_in_reply_to in thread_message_ids:
reply_index = thread_message_ids.index(derived_in_reply_to)
derived_references = " ".join(thread_message_ids[: reply_index + 1])
elif derived_in_reply_to:
derived_references = derived_in_reply_to
else:
derived_references = " ".join(thread_message_ids)
return derived_in_reply_to, derived_references
async def _fetch_thread_message_ids(service, thread_id: str) -> List[str]:
"""
Fetch Message-ID headers from a Gmail thread for reply threading.
Args:
service: Gmail API service instance
thread_id: Gmail thread ID
Returns:
Message-IDs in thread order. Returns an empty list on failure.
"""
try:
thread = await asyncio.to_thread(
service.users()
.threads()
.get(
userId="me",
id=thread_id,
format="metadata",
metadataHeaders=["Message-ID"],
)
.execute
)
messages = thread.get("messages", [])
if not messages:
return []
# Collect all Message-IDs in thread order
message_ids = []
for msg in messages:
headers = _extract_headers(msg.get("payload", {}), ["Message-ID"])
mid = headers.get("Message-ID")
if mid:
message_ids.append(mid)
return message_ids
except Exception as e:
logger.warning(
f"Failed to fetch thread Message-IDs for thread {thread_id}: {e}"
)
return []
def _prepare_gmail_message( def _prepare_gmail_message(
subject: str, subject: str,
body: str, body: str,
@@ -1645,6 +1731,14 @@ async def draft_gmail_message(
else: else:
draft_body = _append_signature_to_body(draft_body, body_format, signature_html) draft_body = _append_signature_to_body(draft_body, body_format, signature_html)
# Auto-populate In-Reply-To and References when thread_id is provided
# but headers are missing, to ensure the draft renders inline in Gmail
if thread_id and (not in_reply_to or not references):
thread_message_ids = await _fetch_thread_message_ids(service, thread_id)
in_reply_to, references = _derive_reply_headers(
thread_message_ids, in_reply_to, references
)
raw_message, thread_id_final, attached_count = _prepare_gmail_message( raw_message, thread_id_final, attached_count = _prepare_gmail_message(
subject=subject, subject=subject,
body=draft_body, body=draft_body,

28
main.py
View File

@@ -7,11 +7,12 @@ import sys
from importlib import metadata, import_module from importlib import metadata, import_module
from dotenv import load_dotenv from dotenv import load_dotenv
# Prevent any stray output (e.g. platform identifiers like "darwin" on macOS) # Prevent any stray startup output on macOS (e.g. platform identifiers) from
# from corrupting the MCP JSON-RPC handshake on stdout. We capture anything # corrupting the MCP JSON-RPC handshake on stdout. We capture anything written
# written to stdout during module-level initialisation and replay it to stderr # to stdout during module-level initialisation and replay it to stderr so that
# so that diagnostic information is not lost. # diagnostic information is not lost.
_original_stdout = sys.stdout _original_stdout = sys.stdout
if sys.platform == "darwin":
sys.stdout = io.StringIO() sys.stdout = io.StringIO()
# Check for CLI mode early - before loading oauth_config # Check for CLI mode early - before loading oauth_config
@@ -125,10 +126,25 @@ def narrow_permissions_to_services(
} }
def _restore_stdout(): def _restore_stdout() -> None:
"""Restore the real stdout and replay any captured output to stderr.""" """Restore the real stdout and replay any captured output to stderr."""
captured = sys.stdout.getvalue() captured_stdout = sys.stdout
# Idempotent: if already restored, nothing to do.
if captured_stdout is _original_stdout:
return
captured = ""
required_stringio_methods = ("getvalue", "write", "flush")
try:
if all(
callable(getattr(captured_stdout, method_name, None))
for method_name in required_stringio_methods
):
captured = captured_stdout.getvalue()
finally:
sys.stdout = _original_stdout sys.stdout = _original_stdout
if captured: if captured:
print(captured, end="", file=sys.stderr) print(captured, end="", file=sys.stderr)

View File

@@ -0,0 +1,288 @@
import base64
import os
import sys
from unittest.mock import Mock
import pytest
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")))
from core.utils import UserInputError
from gmail.gmail_tools import draft_gmail_message
def _unwrap(tool):
"""Unwrap FunctionTool + decorators to the original async function."""
fn = tool.fn if hasattr(tool, "fn") else tool
while hasattr(fn, "__wrapped__"):
fn = fn.__wrapped__
return fn
def _thread_response(*message_ids):
return {
"messages": [
{
"payload": {
"headers": [{"name": "Message-ID", "value": message_id}],
}
}
for message_id in message_ids
]
}
@pytest.mark.asyncio
async def test_draft_gmail_message_reports_actual_attachment_count(
tmp_path, monkeypatch
):
monkeypatch.setenv("ALLOWED_FILE_DIRS", str(tmp_path))
attachment_path = tmp_path / "sample.txt"
attachment_path.write_text("hello attachment", encoding="utf-8")
mock_service = Mock()
mock_service.users().drafts().create().execute.return_value = {"id": "draft123"}
result = await _unwrap(draft_gmail_message)(
service=mock_service,
user_google_email="user@example.com",
to="recipient@example.com",
subject="Attachment test",
body="Please see attached.",
attachments=[{"path": str(attachment_path)}],
include_signature=False,
)
assert "Draft created with 1 attachment(s)! Draft ID: draft123" in result
create_kwargs = (
mock_service.users.return_value.drafts.return_value.create.call_args.kwargs
)
raw_message = create_kwargs["body"]["message"]["raw"]
raw_bytes = base64.urlsafe_b64decode(raw_message)
assert b"Content-Disposition: attachment;" in raw_bytes
assert b"sample.txt" in raw_bytes
@pytest.mark.asyncio
async def test_draft_gmail_message_raises_when_no_attachments_are_added(
tmp_path, monkeypatch
):
monkeypatch.setenv("ALLOWED_FILE_DIRS", str(tmp_path))
missing_path = tmp_path / "missing.txt"
mock_service = Mock()
mock_service.users().drafts().create().execute.return_value = {"id": "draft123"}
with pytest.raises(UserInputError, match="No valid attachments were added"):
await _unwrap(draft_gmail_message)(
service=mock_service,
user_google_email="user@example.com",
to="recipient@example.com",
subject="Attachment test",
body="Please see attached.",
attachments=[{"path": str(missing_path)}],
include_signature=False,
)
@pytest.mark.asyncio
async def test_draft_gmail_message_appends_gmail_signature_html():
mock_service = Mock()
mock_service.users().drafts().create().execute.return_value = {"id": "draft_sig"}
mock_service.users().settings().sendAs().list().execute.return_value = {
"sendAs": [
{
"sendAsEmail": "user@example.com",
"isPrimary": True,
"signature": "<div>Best,<br>Alice</div>",
}
]
}
result = await _unwrap(draft_gmail_message)(
service=mock_service,
user_google_email="user@example.com",
to="recipient@example.com",
subject="Signature test",
body="<p>Hello</p>",
body_format="html",
include_signature=True,
)
assert "Draft created! Draft ID: draft_sig" in result
create_kwargs = (
mock_service.users.return_value.drafts.return_value.create.call_args.kwargs
)
raw_message = create_kwargs["body"]["message"]["raw"]
raw_text = base64.urlsafe_b64decode(raw_message).decode("utf-8", errors="ignore")
assert "<p>Hello</p>" in raw_text
assert "Best,<br>Alice" in raw_text
@pytest.mark.asyncio
async def test_draft_gmail_message_autofills_reply_headers_from_thread():
mock_service = Mock()
mock_service.users().drafts().create().execute.return_value = {"id": "draft_reply"}
mock_service.users().threads().get().execute.return_value = _thread_response(
"<msg1@example.com>",
"<msg2@example.com>",
"<msg3@example.com>",
)
result = await _unwrap(draft_gmail_message)(
service=mock_service,
user_google_email="user@example.com",
to="recipient@example.com",
subject="Meeting tomorrow",
body="Thanks for the update.",
thread_id="thread123",
include_signature=False,
)
# Verify threads().get() was called with correct parameters
thread_get_kwargs = (
mock_service.users.return_value.threads.return_value.get.call_args.kwargs
)
assert thread_get_kwargs["userId"] == "me"
assert thread_get_kwargs["id"] == "thread123"
assert thread_get_kwargs["format"] == "metadata"
assert "Message-ID" in thread_get_kwargs["metadataHeaders"]
assert "Draft created! Draft ID: draft_reply" in result
create_kwargs = (
mock_service.users.return_value.drafts.return_value.create.call_args.kwargs
)
raw_message = create_kwargs["body"]["message"]["raw"]
raw_text = base64.urlsafe_b64decode(raw_message).decode("utf-8", errors="ignore")
assert "In-Reply-To: <msg3@example.com>" in raw_text
assert (
"References: <msg1@example.com> <msg2@example.com> <msg3@example.com>"
in raw_text
)
assert create_kwargs["body"]["message"]["threadId"] == "thread123"
@pytest.mark.asyncio
async def test_draft_gmail_message_uses_explicit_in_reply_to_when_filling_references():
mock_service = Mock()
mock_service.users().drafts().create().execute.return_value = {"id": "draft_reply"}
mock_service.users().threads().get().execute.return_value = _thread_response(
"<msg1@example.com>",
"<msg2@example.com>",
"<msg3@example.com>",
)
await _unwrap(draft_gmail_message)(
service=mock_service,
user_google_email="user@example.com",
to="recipient@example.com",
subject="Meeting tomorrow",
body="Replying to an earlier message.",
thread_id="thread123",
in_reply_to="<msg2@example.com>",
include_signature=False,
)
create_kwargs = (
mock_service.users.return_value.drafts.return_value.create.call_args.kwargs
)
raw_message = create_kwargs["body"]["message"]["raw"]
raw_text = base64.urlsafe_b64decode(raw_message).decode("utf-8", errors="ignore")
assert "In-Reply-To: <msg2@example.com>" in raw_text
assert "References: <msg1@example.com> <msg2@example.com>" in raw_text
assert "<msg3@example.com>" not in raw_text
@pytest.mark.asyncio
async def test_draft_gmail_message_uses_explicit_references_when_filling_in_reply_to():
mock_service = Mock()
mock_service.users().drafts().create().execute.return_value = {"id": "draft_reply"}
mock_service.users().threads().get().execute.return_value = _thread_response(
"<msg1@example.com>",
"<msg2@example.com>",
"<msg3@example.com>",
)
await _unwrap(draft_gmail_message)(
service=mock_service,
user_google_email="user@example.com",
to="recipient@example.com",
subject="Meeting tomorrow",
body="Replying to an earlier message.",
thread_id="thread123",
references="<msg1@example.com> <msg2@example.com>",
include_signature=False,
)
create_kwargs = (
mock_service.users.return_value.drafts.return_value.create.call_args.kwargs
)
raw_message = create_kwargs["body"]["message"]["raw"]
raw_text = base64.urlsafe_b64decode(raw_message).decode("utf-8", errors="ignore")
assert "In-Reply-To: <msg2@example.com>" in raw_text
assert "References: <msg1@example.com> <msg2@example.com>" in raw_text
assert "<msg3@example.com>" not in raw_text
@pytest.mark.asyncio
async def test_draft_gmail_message_gracefully_degrades_when_thread_fetch_fails():
mock_service = Mock()
mock_service.users().drafts().create().execute.return_value = {"id": "draft_reply"}
mock_service.users().threads().get().execute.side_effect = RuntimeError("boom")
result = await _unwrap(draft_gmail_message)(
service=mock_service,
user_google_email="user@example.com",
to="recipient@example.com",
subject="Meeting tomorrow",
body="Thanks for the update.",
thread_id="thread123",
include_signature=False,
)
assert "Draft created! Draft ID: draft_reply" in result
create_kwargs = (
mock_service.users.return_value.drafts.return_value.create.call_args.kwargs
)
raw_message = create_kwargs["body"]["message"]["raw"]
raw_text = base64.urlsafe_b64decode(raw_message).decode("utf-8", errors="ignore")
assert "In-Reply-To:" not in raw_text
assert "References:" not in raw_text
@pytest.mark.asyncio
async def test_draft_gmail_message_gracefully_degrades_when_thread_has_no_messages():
mock_service = Mock()
mock_service.users().drafts().create().execute.return_value = {"id": "draft_reply"}
mock_service.users().threads().get().execute.return_value = {"messages": []}
result = await _unwrap(draft_gmail_message)(
service=mock_service,
user_google_email="user@example.com",
to="recipient@example.com",
subject="Meeting tomorrow",
body="Thanks for the update.",
thread_id="thread123",
include_signature=False,
)
assert "Draft created! Draft ID: draft_reply" in result
create_kwargs = (
mock_service.users.return_value.drafts.return_value.create.call_args.kwargs
)
raw_message = create_kwargs["body"]["message"]["raw"]
raw_text = base64.urlsafe_b64decode(raw_message).decode("utf-8", errors="ignore")
assert "In-Reply-To:" not in raw_text
assert "References:" not in raw_text