Skip to content

Send emails with attachments - #381

Merged
taylorwilsdon merged 11 commits into
taylorwilsdon:mainfrom
songmeo:main
Jan 30, 2026
Merged

Send emails with attachments #381
taylorwilsdon merged 11 commits into
taylorwilsdon:mainfrom
songmeo:main

Conversation

@songmeo

@songmeo songmeo commented Jan 23, 2026

Copy link
Copy Markdown

Fix #375

Song Meo added 2 commits January 23, 2026 02:33
- Add export_format='pdf' option for Google Sheets in get_drive_file_download_url
- Previously only supported csv and xlsx formats for Sheets
- Update docstring and parameter documentation
- Tested with production Google Sheet and confirmed working
- Add file path and base64 content attachment support
- Auto-detect MIME types from file extensions
- Support attachments in both send_gmail_message and draft_gmail_message
- Accept attachments via 'path' (file path) or 'content' (base64) + 'filename'
- Note: FastMCP schema generation issue prevents List[Dict] from appearing in MCP tool schema
@taylorwilsdon
taylorwilsdon requested review from Copilot and taylorwilsdon and removed request for Copilot January 23, 2026 17:20
@taylorwilsdon taylorwilsdon self-assigned this Jan 23, 2026
@taylorwilsdon
taylorwilsdon requested a review from Copilot January 23, 2026 17:20

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds attachment support to Gmail operations and extends Google Sheets export capabilities to include PDF format.

Changes:

  • Added attachment support for sending Gmail messages and creating drafts, supporting both file paths (auto-encoded) and base64 content
  • Extended Google Sheets export to support PDF format in addition to existing XLSX and CSV options

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.

File Description
gmail/gmail_tools.py Added attachment handling logic with support for file paths and base64 content, updated function signatures and documentation for send_gmail_message and draft_gmail_message
gdrive/drive_tools.py Added PDF export support for Google Sheets in get_drive_file_download_url

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread gmail/gmail_tools.py Outdated
references: Optional[str] = Body(
None, description="Optional chain of Message-IDs for proper threading."
),
attachments_json: Optional[str] = Body(

Copilot AI Jan 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parameter is named attachments_json but the actual parameter used in the function body is attachments (line 1077, 1092, 1107-1108). The parameter name should match what's used throughout the function.

Suggested change
attachments_json: Optional[str] = Body(
attachments: Optional[str] = Body(

Copilot uses AI. Check for mistakes.
Comment thread gmail/gmail_tools.py Outdated
subject (str): Email subject.
body (str): Email body content.
body_format (Literal['plain', 'html']): Email body format. Defaults to 'plain'.
attachments (List[Dict[str, str]]): Optional list of attachments. Each dict can contain:

Copilot AI Jan 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documentation describes a parameter named attachments, but the function signature declares attachments_json. These names should be consistent.

Suggested change
attachments (List[Dict[str, str]]): Optional list of attachments. Each dict can contain:
attachments_json (Optional[str]): Optional JSON array of attachments. Each element is a dict that can contain:

Copilot uses AI. Check for mistakes.
Comment thread gmail/gmail_tools.py Outdated
"""
logger.info(
f"[send_gmail_message] Invoked. Email: '{user_google_email}', Subject: '{subject}'"
f"[send_gmail_message] Invoked. Email: '{user_google_email}', Subject: '{subject}', Attachments: {len(attachments)}"

Copilot AI Jan 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will raise an error when attachments is None. The code attempts to call len() on a potentially None value. Should check if attachments exists before calling len(), e.g., len(attachments) if attachments else 0.

Suggested change
f"[send_gmail_message] Invoked. Email: '{user_google_email}', Subject: '{subject}', Attachments: {len(attachments)}"
f"[send_gmail_message] Invoked. Email: '{user_google_email}', Subject: '{subject}', Attachments: {len(attachments) if attachments else 0}"

Copilot uses AI. Check for mistakes.
Comment thread gmail/gmail_tools.py Outdated
Comment on lines +329 to +330
part.add_header(
"Content-Disposition", f"attachment; filename={filename}"

Copilot AI Jan 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The filename should be properly quoted and sanitized in the Content-Disposition header to prevent header injection attacks. Use f'attachment; filename=\"{filename}\"' with proper escaping or use email.utils.encode_rfc2231().

Suggested change
part.add_header(
"Content-Disposition", f"attachment; filename={filename}"
# Sanitize filename to prevent header injection and ensure valid quoting
safe_filename = (filename or "").replace("\r", "").replace("\n", "")
safe_filename = safe_filename.replace("\\", "\\\\").replace('"', r"\"")
part.add_header(
"Content-Disposition", f'attachment; filename="{safe_filename}"'

Copilot uses AI. Check for mistakes.
Comment thread gmail/gmail_tools.py Outdated
Comment on lines +1136 to +1137
attachments: List[Dict[str, str]] = Body(
default_factory=list,

Copilot AI Jan 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using default_factory=list creates a mutable default argument. This should be Body(default=None) or use Optional[List[Dict[str, str]]] with None as default, similar to the pattern used in send_gmail_message.

Suggested change
attachments: List[Dict[str, str]] = Body(
default_factory=list,
attachments: Optional[List[Dict[str, str]]] = Body(
default=None,

Copilot uses AI. Check for mistakes.
@songmeo songmeo changed the title Allow export Sheets with PDF and send emails with attachments Send emails with attachments Jan 28, 2026
@martinschenk

Copy link
Copy Markdown
Contributor

Hi! Really looking forward to this feature. We're using google_workspace_mcp extensively for email automation via Claude Code and attachment support would be a great addition. Thanks for the implementation @songmeo! 👍

@songmeo

songmeo commented Jan 29, 2026

Copy link
Copy Markdown
Author

Thank you, @martinschenk. I’ve been using my fork with this feature for the past week, and it’s been working well for us. Looking forward to the code review.

@taylorwilsdon
taylorwilsdon requested a review from Copilot January 30, 2026 00:23

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 3 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread gmail/gmail_tools.py Outdated
logger.warning("Skipping attachment: missing filename")
continue

file_data = base64.urlsafe_b64decode(content_base64)

Copilot AI Jan 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using urlsafe_b64decode for user-provided base64 content is incorrect. The Gmail API uses urlsafe_b64encode for the final message encoding, but attachment content from users should be decoded using standard base64.b64decode. Use base64.b64decode(content_base64) instead to handle standard base64-encoded attachment content.

Suggested change
file_data = base64.urlsafe_b64decode(content_base64)
file_data = base64.b64decode(content_base64)

Copilot uses AI. Check for mistakes.
Comment thread gmail/gmail_tools.py Outdated
continue

# Create MIME attachment
part = MIMEBase(*mime_type.split("/"))

Copilot AI Jan 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If mime_type contains more than one '/' character (though uncommon, it's possible with malformed input), split('/') will produce more than 2 elements, causing MIMEBase to receive too many arguments. Use mime_type.split('/', 1) to ensure only two parts (maintype and subtype) are extracted.

Suggested change
part = MIMEBase(*mime_type.split("/"))
main_type, sub_type = mime_type.split("/", 1)
part = MIMEBase(main_type, sub_type)

Copilot uses AI. Check for mistakes.
Comment thread gmail/gmail_tools.py Outdated
Comment on lines +331 to +332
safe_filename = (filename or "").replace("\r", "").replace("\n", "")
safe_filename = safe_filename.replace("\\", "\\\\").replace('"', r"\"")

Copilot AI Jan 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The filename sanitization logic is duplicated as separate steps. Consider consolidating into a single chain or extracting to a helper function for better maintainability, especially if this sanitization logic needs to be updated or reused.

Suggested change
safe_filename = (filename or "").replace("\r", "").replace("\n", "")
safe_filename = safe_filename.replace("\\", "\\\\").replace('"', r"\"")
safe_filename = (
(filename or "")
.replace("\r", "")
.replace("\n", "")
.replace("\\", "\\\\")
.replace('"', r"\"")
)

Copilot uses AI. Check for mistakes.
Comment thread gmail/gmail_tools.py Outdated
),
attachments: Optional[List[Dict[str, str]]] = Body(
None,
description='Optional list of attachments. Each can have: "path" (file path, auto-encodes), OR "content" (base64) + "filename". Optional "mime_type". Example: [{"path": "/path/to/file.pdf"}] or [{"filename": "doc.pdf", "content": "base64data", "mime_type": "application/pdf"}]',

Copilot AI Jan 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The description should clarify that 'content' expects standard base64 encoding (not urlsafe). This helps API consumers understand the expected format for base64-encoded attachments.

Copilot uses AI. Check for mistakes.
@taylorwilsdon
taylorwilsdon merged commit cac3378 into taylorwilsdon:main Jan 30, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature Request: Export PDF with Google Drive tool

4 participants