diff --git a/README.md b/README.md
index 45a9c3d..951cb41 100644
--- a/README.md
+++ b/README.md
@@ -64,11 +64,11 @@ A production-ready MCP server that integrates all major Google Workspace service
**@ Gmail** • **≡ Drive** • **⧖ Calendar** **≡ Docs**
-- Complete Gmail management, end to end coverage
+- Complete Gmail management, end-to-end coverage
- Full calendar management with advanced features
- File operations with Office format support
- Document creation, editing & comments
-- Deep, exhaustive support for fine grained editing
+- Deep, exhaustive support for fine-grained editing
---
diff --git a/gmail/gmail_tools.py b/gmail/gmail_tools.py
index d054bcb..3962ff4 100644
--- a/gmail/gmail_tools.py
+++ b/gmail/gmail_tools.py
@@ -19,9 +19,10 @@ from email import encoders
from email.utils import formataddr
from pydantic import Field
+from googleapiclient.errors import HttpError
from auth.service_decorator import require_google_service
-from core.utils import handle_http_errors, validate_file_path
+from core.utils import handle_http_errors, validate_file_path, UserInputError
from core.server import server
from auth.scopes import (
GMAIL_SEND_SCOPE,
@@ -172,6 +173,74 @@ def _format_body_content(text_body: str, html_body: str) -> str:
return "[No readable content found]"
+def _append_signature_to_body(
+ body: str, body_format: Literal["plain", "html"], signature_html: str
+) -> str:
+ """Append a Gmail signature to the outgoing body, preserving body format."""
+ if not signature_html or not signature_html.strip():
+ return body
+
+ if body_format == "html":
+ separator = "
" if body.strip() else ""
+ return f"{body}{separator}{signature_html}"
+
+ signature_text = _html_to_text(signature_html).strip()
+ if not signature_text:
+ return body
+ separator = "\n\n" if body.strip() else ""
+ return f"{body}{separator}{signature_text}"
+
+
+async def _get_send_as_signature_html(service, from_email: Optional[str] = None) -> str:
+ """
+ Fetch signature HTML from Gmail send-as settings.
+
+ Returns empty string when the account has no signature configured or the
+ OAuth token cannot access settings endpoints.
+ """
+ try:
+ response = await asyncio.to_thread(
+ service.users().settings().sendAs().list(userId="me").execute
+ )
+ except HttpError as e:
+ status = getattr(getattr(e, "resp", None), "status", None)
+ if status in {401, 403}:
+ logger.info(
+ "Skipping Gmail signature fetch: missing auth/scope for settings endpoint."
+ )
+ return ""
+ logger.warning(f"Failed to fetch Gmail send-as signatures: {e}")
+ return ""
+ except Exception as e:
+ logger.warning(f"Failed to fetch Gmail send-as signatures: {e}")
+ return ""
+
+ send_as_entries = response.get("sendAs", [])
+ if not send_as_entries:
+ return ""
+
+ if from_email:
+ from_email_normalized = from_email.strip().lower()
+ for entry in send_as_entries:
+ if entry.get("sendAsEmail", "").strip().lower() == from_email_normalized:
+ return entry.get("signature", "") or ""
+
+ for entry in send_as_entries:
+ if entry.get("isPrimary"):
+ return entry.get("signature", "") or ""
+
+ return send_as_entries[0].get("signature", "") or ""
+
+
+def _format_attachment_result(attached_count: int, requested_count: int) -> str:
+ """Format attachment result message for user-facing responses."""
+ if requested_count <= 0:
+ return ""
+ if attached_count == requested_count:
+ return f" with {attached_count} attachment(s)"
+ return f" with {attached_count}/{requested_count} attachment(s) attached"
+
+
def _extract_attachments(payload: dict) -> List[Dict[str, Any]]:
"""
Extract attachment metadata from a Gmail message payload.
@@ -241,7 +310,7 @@ def _prepare_gmail_message(
from_email: Optional[str] = None,
from_name: Optional[str] = None,
attachments: Optional[List[Dict[str, str]]] = None,
-) -> tuple[str, Optional[str]]:
+) -> tuple[str, Optional[str], int]:
"""
Prepare a Gmail message with threading and attachment support.
@@ -260,7 +329,7 @@ def _prepare_gmail_message(
attachments: Optional list of attachments. Each can have 'path' (file path) OR 'content' (base64) + 'filename'
Returns:
- Tuple of (raw_message, thread_id) where raw_message is base64 encoded
+ Tuple of (raw_message, thread_id, attached_count) where raw_message is base64 encoded
"""
# Handle reply subject formatting
reply_subject = subject
@@ -273,6 +342,7 @@ def _prepare_gmail_message(
raise ValueError("body_format must be either 'plain' or 'html'.")
# Use multipart if attachments are provided
+ attached_count = 0
if attachments:
message = MIMEMultipart()
message.attach(MIMEText(body, normalized_format))
@@ -344,6 +414,7 @@ def _prepare_gmail_message(
)
message.attach(part)
+ attached_count += 1
logger.info(f"Attached file: {filename} ({len(file_data)} bytes)")
except Exception as e:
logger.error(f"Failed to attach {filename or file_path}: {e}")
@@ -382,7 +453,7 @@ def _prepare_gmail_message(
# Encode message
raw_message = base64.urlsafe_b64encode(message.as_bytes()).decode()
- return raw_message, thread_id
+ return raw_message, thread_id, attached_count
def _generate_gmail_web_url(item_id: str, account_index: int = 0) -> str:
@@ -1177,7 +1248,7 @@ async def send_gmail_message(
# Prepare the email message
# Use from_email (Send As alias) if provided, otherwise default to authenticated user
sender_email = from_email or user_google_email
- raw_message, thread_id_final = _prepare_gmail_message(
+ raw_message, thread_id_final, attached_count = _prepare_gmail_message(
subject=subject,
body=body,
to=to,
@@ -1192,6 +1263,12 @@ async def send_gmail_message(
attachments=attachments if attachments else None,
)
+ requested_attachment_count = len(attachments or [])
+ if requested_attachment_count > 0 and attached_count == 0:
+ raise UserInputError(
+ "No valid attachments were added. Verify each attachment path/content and retry."
+ )
+
send_body = {"raw": raw_message}
# Associate with thread if provided
@@ -1204,8 +1281,11 @@ async def send_gmail_message(
)
message_id = sent_message.get("id")
- if attachments:
- return f"Email sent with {len(attachments)} attachment(s)! Message ID: {message_id}"
+ if requested_attachment_count > 0:
+ attachment_info = _format_attachment_result(
+ attached_count, requested_attachment_count
+ )
+ return f"Email sent{attachment_info}! Message ID: {message_id}"
return f"Email sent! Message ID: {message_id}"
@@ -1271,6 +1351,12 @@ async def draft_gmail_message(
description="Optional list of attachments. Each can have: 'path' (file path, auto-encodes), OR 'content' (standard base64, not urlsafe) + 'filename'. Optional 'mime_type' (auto-detected from path if not provided).",
),
] = None,
+ include_signature: Annotated[
+ bool,
+ Field(
+ description="Whether to append the Gmail signature from Settings > Signature when available. Defaults to true.",
+ ),
+ ] = True,
) -> str:
"""
Creates a draft email in the user's Gmail account. Supports both new drafts and reply drafts with optional attachments.
@@ -1300,6 +1386,8 @@ async def draft_gmail_message(
- 'content' (required): Standard base64-encoded file content (not urlsafe)
- 'filename' (required): Name of the file
- 'mime_type' (optional): MIME type (defaults to 'application/octet-stream')
+ include_signature (bool): Whether to append Gmail signature HTML from send-as settings.
+ If unavailable (e.g., missing gmail.settings.basic scope), the draft is still created without signature.
Returns:
str: Confirmation message with the created draft's ID.
@@ -1363,9 +1451,16 @@ async def draft_gmail_message(
# Prepare the email message
# Use from_email (Send As alias) if provided, otherwise default to authenticated user
sender_email = from_email or user_google_email
- raw_message, thread_id_final = _prepare_gmail_message(
+ draft_body = body
+ if include_signature:
+ signature_html = await _get_send_as_signature_html(
+ service, from_email=sender_email
+ )
+ draft_body = _append_signature_to_body(draft_body, body_format, signature_html)
+
+ raw_message, thread_id_final, attached_count = _prepare_gmail_message(
subject=subject,
- body=body,
+ body=draft_body,
body_format=body_format,
to=to,
cc=cc,
@@ -1378,6 +1473,12 @@ async def draft_gmail_message(
attachments=attachments,
)
+ requested_attachment_count = len(attachments or [])
+ if requested_attachment_count > 0 and attached_count == 0:
+ raise UserInputError(
+ "No valid attachments were added. Verify each attachment path/content and retry."
+ )
+
# Create a draft instead of sending
draft_body = {"message": {"raw": raw_message}}
@@ -1390,7 +1491,9 @@ async def draft_gmail_message(
service.users().drafts().create(userId="me", body=draft_body).execute
)
draft_id = created_draft.get("id")
- attachment_info = f" with {len(attachments)} attachment(s)" if attachments else ""
+ attachment_info = _format_attachment_result(
+ attached_count, requested_attachment_count
+ )
return f"Draft created{attachment_info}! Draft ID: {draft_id}"
|