Compare commits

..

1 Commits

Author SHA1 Message Date
jason 395f0e2029 feat: initial commit from workspace-mcp
Check Maintainer Edits Enabled / check-maintainer-edits (pull_request) Has been cancelled
Check Maintainer Edits Enabled / check-maintainer-edits-internal (pull_request) Has been cancelled
Docker Build and Push to GHCR / build-and-push (pull_request) Has been cancelled
Ruff / ruff (pull_request) Has been cancelled
2026-03-17 19:23:33 -05:00
11 changed files with 487 additions and 129 deletions
View File
@@ -1,104 +0,0 @@
You are an expert Python engineer with a specific expertise around FastMCP-based Python Model Context Protocol (MCP) servers.
# 📄 Project Context
This repository (`google_workspace_mcp`) is a productiongrade FastMCP server that exposes Google Workspacetooling (Gmail, Calendar, Drive, Docs, Sheets, Slides, Chat, Tasks, Forms, Contacts, Search) to LLM clients.
Key architectural pillars:
* **FastMCP 3.x** for server/runtime, tool registration, validation and transports.
* **Async Google client libraries** with OAuth2.1 desktopflow and multiuser token caching.
* Strict typing & *pydanticv2* models for request/response schemas.
* Highconcurrency, stateless worker model (FastAPI/Starlette under the hood).
* Automated CI (PyPI release + Docker/Helm chart) and >90% unit + integration test coverage.
# 🎯 Review Goals & Priorities
1. **Correctness & Protocol Compliance**
* Ensure new/modified tools conform to the MCP JSONSchema generated by FastMCP (@mcp.tool).
* Validate that their signatures remain LLMfriendly (primitive or Pydantic types only).
* Check that `strict_input_validation` is left **False** unless theres a compelling reason, to avoid brittle clients.
2. **Security & Privacy**
* No secrets, refresh tokens or PII logged or leaked in exceptions or event streams.
* All outbound Google API calls must respect enduser OAuth scopes *least privilege*.
* Ratelimit & quota errors should be caught and surfaced as MCP `ToolExecutionError`.
3. **Robustness & Performance**
* Prefer `asyncio` Google APIs (via `google-apis-async`) or run blocking calls inside `run_in_executor`.
* Avoid longrunning operations (>30s) inside request context instruct to stream partial results or schedule background tasks.
* Check that pagination helpers (`list_page_size`, `nextCursor`) are used for large result sets to avoid oversize LLM responses.
4. **API Design & UX**
* Tool names: imperative, camelCase, ≤3 words.
* Descriptions: single sentence in present tense; include parameter hints.
* Response payloads: concise, redact verbose HTML/email bodies by default; offer links or IDs to fetch full content.
* Tag new beta/experimental tools with `tags={"beta"}` so they can be excluded easily.
5. **Google Workspace Best Practices**
* Batch API requests where possible (`gmail.users().messages().batchModify`, `sheets.batchUpdate`).
* Use incremental Sync tokens to avoid refetching full collections.
* Respect user locale/timezone when constructing dates.
6. **Testing & CI**
* Unit tests for every tool; integration tests for highrisk flows (email send, file upload).
* Mock Google APIs with `httpretty` + canned fixtures do **not** hit live services in CI.
* Contract tests: regenerate FastMCP OpenAIschema and diff against committed goldens.
# 🚦 LowValue Feedback to Skip
* Pure formatting issues already covered by **Black+Ruff** precommit.
* Renaming variables for subjective style only.
* Suggesting synchronous alternatives when async is intentional.
* Requesting additional comments for trivial getters/setters.
# 🧭 Review Structure Template
<details>
<summary>Sample highimpact review comment</summary>
```markdown
### ✅ _gmail.sendMessage_ Input schema issue
`reply_to` parameter is optional in code but **required** in docstring/schema.
FastMCP coerces `"None"` strings to `None`, but with `strict_input_validation=False` a client could still pass an empty string which causes Google API 400.
* [ ] Set `reply_to: str | None = None` and document.
* [ ] Add unit test: sending email without `reply_to` succeeds.
_Why it matters_: prevents uservisible 400 errors and keeps schema truthful.
```
</details>
# 📑 Files & Folders To Ignore
| Path/Pattern | Reason |
| ------------------------------------- | ---------------------------------------- |
| `*.dxt`, `glama.json`, `fastmcp.json` | Deployment descriptors |
| `helm-chart/**`, `Dockerfile` | Infra packaging reviewed separately |
| `tests/fixtures/**` | Static fixtures only review if failing |
# 🔑 Style Guide (TL;DR)
* **PEP8** + Black (120col) + Ruff (see `pyproject.toml`).
* Mandatory **type hints** (mypy strict).
* Use **fstrings**, avoid `%` formatting.
* Prefer `async`/`await`; limit `time.sleep`.
* One public class/function per file unless tightly coupled.
# 🙏 Tone Expectations for Copilot
Be concise, actionable, and prioritize **developer experience**.
Highlight *why* an issue matters (DX, security, correctness) before *how* to fix.
Cap recommendations at **35 per file** to prevent noise. Do not feel compelled to come up with 3. Only present HIGH VALUE recommendations.
Use codefenced suggestions where helpful.
---
*End of .instructions.md*
+15 -9
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>
""" """
+33 -3
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
+22 -1
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:
@@ -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)",
}, },
+6
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",
+94
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,
+24 -8
View File
@@ -7,12 +7,13 @@ 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
sys.stdout = io.StringIO() if sys.platform == "darwin":
sys.stdout = io.StringIO()
# Check for CLI mode early - before loading oauth_config # Check for CLI mode early - before loading oauth_config
# CLI mode requires OAuth 2.0 since there's no MCP session context # CLI mode requires OAuth 2.0 since there's no MCP session context
@@ -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
sys.stdout = _original_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
if captured: if captured:
print(captured, end="", file=sys.stderr) print(captured, end="", file=sys.stderr)
+4 -4
View File
@@ -49,10 +49,10 @@ email = "taylor@taylorwilsdon.com"
[project.urls] [project.urls]
Homepage = "https://workspacemcp.com" Homepage = "https://workspacemcp.com"
Repository = "https://github.com/taylorwilsdon/google_workspace_mcp" Repository = "https://git.alwisp.com/jason/google-mcp.git"
Documentation = "https://github.com/taylorwilsdon/google_workspace_mcp#readme" Documentation = "https://git.alwisp.com/jason/google-mcp#readme"
Issues = "https://github.com/taylorwilsdon/google_workspace_mcp/issues" Issues = "https://git.alwisp.com/jason/google-mcp/issues"
Changelog = "https://github.com/taylorwilsdon/google_workspace_mcp/releases" Changelog = "https://git.alwisp.com/jason/google-mcp/releases"
[project.scripts] [project.scripts]
workspace-mcp = "main:main" workspace-mcp = "main:main"
+288
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