Compare commits
22 Commits
783806017d
...
ff35fd7950
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff35fd7950 | ||
|
|
894564f378 | ||
|
|
a87ac1737d | ||
|
|
464c182829 | ||
|
|
01ffcc1fc6 | ||
|
|
156a1f20ef | ||
|
|
0dfed9081b | ||
|
|
ffcea58fdc | ||
|
|
96df53c5e9 | ||
|
|
16ce566d88 | ||
|
|
86a159a364 | ||
|
|
441f052dca | ||
|
|
9ef6f72e26 | ||
|
|
2ab22ee630 | ||
|
|
e2c08b5f69 | ||
|
|
ebc3fcb044 | ||
|
|
4bdc96a554 | ||
|
|
31e27b76b6 | ||
|
|
59b3a2492c | ||
|
|
9285a5f97e | ||
|
|
00796f39c6 | ||
|
|
e441ade35f |
@@ -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>
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
32
main.py
32
main.py
@@ -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)
|
||||||
|
|
||||||
|
|||||||
288
tests/gmail/test_draft_gmail_message.py
Normal file
288
tests/gmail/test_draft_gmail_message.py
Normal 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
|
||||||
Reference in New Issue
Block a user