Skip to content

Commit cac3378

Browse files
Merge pull request #381 from songmeo/main
Send emails with attachments
2 parents 24dd57a + 46eeeb6 commit cac3378

4 files changed

Lines changed: 168 additions & 7 deletions

File tree

README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -749,6 +749,30 @@ cp .env.oauth21 .env
749749
| `batch_modify_gmail_message_labels` | Complete | Batch modify labels |
750750
| `start_google_auth` | Complete | Legacy OAuth 2.0 auth (disabled when OAuth 2.1 is enabled) |
751751

752+
<details>
753+
<summary><b>📎 Email Attachments</b> <sub><sup>← Send emails with files</sup></sub></summary>
754+
755+
Both `send_gmail_message` and `draft_gmail_message` support attachments via two methods:
756+
757+
**Option 1: File Path** (local server only)
758+
```python
759+
attachments=[{"path": "/path/to/report.pdf"}]
760+
```
761+
Reads file from disk, auto-detects MIME type. Optional `filename` override.
762+
763+
**Option 2: Base64 Content** (works everywhere)
764+
```python
765+
attachments=[{
766+
"filename": "report.pdf",
767+
"content": "JVBERi0xLjQK...", # base64-encoded
768+
"mime_type": "application/pdf" # optional
769+
}]
770+
```
771+
772+
**⚠️ Centrally Hosted Servers**: When the MCP server runs remotely (cloud, shared instance), it cannot access your local filesystem. Use **Option 2** with base64-encoded content. Your MCP client must encode files before sending.
773+
774+
</details>
775+
752776
</td>
753777
<td width="50%" valign="top">
754778

gdrive/drive_tools.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,7 @@ async def get_drive_file_download_url(
236236
export_format: Optional export format for Google native files.
237237
Options: 'pdf', 'docx', 'xlsx', 'csv', 'pptx'.
238238
If not specified, uses sensible defaults (PDF for Docs/Slides, XLSX for Sheets).
239+
For Sheets: supports 'csv', 'pdf', or 'xlsx' (default).
239240
240241
Returns:
241242
str: Download URL and file metadata. The file is available at the URL for 1 hour.

gmail/gmail_tools.py

Lines changed: 142 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,15 @@
88
import asyncio
99
import base64
1010
import ssl
11+
import mimetypes
12+
from pathlib import Path
1113
from html.parser import HTMLParser
1214
from typing import Optional, List, Dict, Literal, Any
1315

1416
from email.mime.text import MIMEText
17+
from email.mime.multipart import MIMEMultipart
18+
from email.mime.base import MIMEBase
19+
from email import encoders
1520

1621
from fastapi import Body
1722
from pydantic import Field
@@ -235,9 +240,10 @@ def _prepare_gmail_message(
235240
references: Optional[str] = None,
236241
body_format: Literal["plain", "html"] = "plain",
237242
from_email: Optional[str] = None,
243+
attachments: Optional[List[Dict[str, str]]] = None,
238244
) -> tuple[str, Optional[str]]:
239245
"""
240-
Prepare a Gmail message with threading support.
246+
Prepare a Gmail message with threading and attachment support.
241247
242248
Args:
243249
subject: Email subject
@@ -250,6 +256,7 @@ def _prepare_gmail_message(
250256
references: Optional chain of Message-IDs for proper threading
251257
body_format: Content type for the email body ('plain' or 'html')
252258
from_email: Optional sender email address
259+
attachments: Optional list of attachments. Each can have 'path' (file path) OR 'content' (base64) + 'filename'
253260
254261
Returns:
255262
Tuple of (raw_message, thread_id) where raw_message is base64 encoded
@@ -264,7 +271,82 @@ def _prepare_gmail_message(
264271
if normalized_format not in {"plain", "html"}:
265272
raise ValueError("body_format must be either 'plain' or 'html'.")
266273

267-
message = MIMEText(body, normalized_format)
274+
# Use multipart if attachments are provided
275+
if attachments:
276+
message = MIMEMultipart()
277+
message.attach(MIMEText(body, normalized_format))
278+
279+
# Process attachments
280+
for attachment in attachments:
281+
file_path = attachment.get("path")
282+
filename = attachment.get("filename")
283+
content_base64 = attachment.get("content")
284+
mime_type = attachment.get("mime_type")
285+
286+
try:
287+
# If path is provided, read and encode the file
288+
if file_path:
289+
path_obj = Path(file_path)
290+
if not path_obj.exists():
291+
logger.error(f"File not found: {file_path}")
292+
continue
293+
294+
# Read file content
295+
with open(path_obj, "rb") as f:
296+
file_data = f.read()
297+
298+
# Use provided filename or extract from path
299+
if not filename:
300+
filename = path_obj.name
301+
302+
# Auto-detect MIME type if not provided
303+
if not mime_type:
304+
mime_type, _ = mimetypes.guess_type(str(path_obj))
305+
if not mime_type:
306+
mime_type = "application/octet-stream"
307+
308+
# If content is provided (base64), decode it
309+
elif content_base64:
310+
if not filename:
311+
logger.warning("Skipping attachment: missing filename")
312+
continue
313+
314+
file_data = base64.b64decode(content_base64)
315+
316+
if not mime_type:
317+
mime_type = "application/octet-stream"
318+
319+
else:
320+
logger.warning("Skipping attachment: missing both path and content")
321+
continue
322+
323+
# Create MIME attachment
324+
main_type, sub_type = mime_type.split("/", 1)
325+
part = MIMEBase(main_type, sub_type)
326+
part.set_payload(file_data)
327+
encoders.encode_base64(part)
328+
329+
# Sanitize filename to prevent header injection and ensure valid quoting
330+
safe_filename = (
331+
(filename or "")
332+
.replace("\r", "")
333+
.replace("\n", "")
334+
.replace("\\", "\\\\")
335+
.replace('"', r"\"")
336+
)
337+
338+
part.add_header(
339+
"Content-Disposition", f'attachment; filename="{safe_filename}"'
340+
)
341+
342+
message.attach(part)
343+
logger.info(f"Attached file: {filename} ({len(file_data)} bytes)")
344+
except Exception as e:
345+
logger.error(f"Failed to attach {filename or file_path}: {e}")
346+
continue
347+
else:
348+
message = MIMEText(body, normalized_format)
349+
268350
message["Subject"] = reply_subject
269351

270352
# Add sender if provided
@@ -919,16 +1001,29 @@ async def send_gmail_message(
9191001
references: Optional[str] = Body(
9201002
None, description="Optional chain of Message-IDs for proper threading."
9211003
),
1004+
attachments: Optional[List[Dict[str, str]]] = Body(
1005+
None,
1006+
description='Optional list of attachments. Each can have: "path" (file path, auto-encodes), OR "content" (standard base64, not urlsafe) + "filename". Optional "mime_type". Example: [{"path": "/path/to/file.pdf"}] or [{"filename": "doc.pdf", "content": "base64data", "mime_type": "application/pdf"}]',
1007+
),
9221008
) -> str:
9231009
"""
924-
Sends an email using the user's Gmail account. Supports both new emails and replies.
1010+
Sends an email using the user's Gmail account. Supports both new emails and replies with optional attachments.
9251011
Supports Gmail's "Send As" feature to send from configured alias addresses.
9261012
9271013
Args:
9281014
to (str): Recipient email address.
9291015
subject (str): Email subject.
9301016
body (str): Email body content.
9311017
body_format (Literal['plain', 'html']): Email body format. Defaults to 'plain'.
1018+
attachments (Optional[List[Dict[str, str]]]): Optional list of attachments. Each dict can contain:
1019+
Option 1 - File path (auto-encodes):
1020+
- 'path' (required): File path to attach
1021+
- 'filename' (optional): Override filename
1022+
- 'mime_type' (optional): Override MIME type (auto-detected if not provided)
1023+
Option 2 - Base64 content:
1024+
- 'content' (required): Standard base64-encoded file content (not urlsafe)
1025+
- 'filename' (required): Name of the file
1026+
- 'mime_type' (optional): MIME type (defaults to 'application/octet-stream')
9321027
cc (Optional[str]): Optional CC email address.
9331028
bcc (Optional[str]): Optional BCC email address.
9341029
from_email (Optional[str]): Optional 'Send As' alias email address. The alias must be
@@ -971,6 +1066,28 @@ async def send_gmail_message(
9711066
body="Here's the latest update..."
9721067
)
9731068
1069+
# Send an email with attachments (using file path)
1070+
send_gmail_message(
1071+
to="user@example.com",
1072+
subject="Report",
1073+
body="Please see attached report.",
1074+
attachments=[{
1075+
"path": "/path/to/report.pdf"
1076+
}]
1077+
)
1078+
1079+
# Send an email with attachments (using base64 content)
1080+
send_gmail_message(
1081+
to="user@example.com",
1082+
subject="Report",
1083+
body="Please see attached report.",
1084+
attachments=[{
1085+
"filename": "report.pdf",
1086+
"content": "JVBERi0xLjQK...", # base64 encoded PDF
1087+
"mime_type": "application/pdf"
1088+
}]
1089+
)
1090+
9741091
# Send a reply
9751092
send_gmail_message(
9761093
to="user@example.com",
@@ -982,7 +1099,7 @@ async def send_gmail_message(
9821099
)
9831100
"""
9841101
logger.info(
985-
f"[send_gmail_message] Invoked. Email: '{user_google_email}', Subject: '{subject}'"
1102+
f"[send_gmail_message] Invoked. Email: '{user_google_email}', Subject: '{subject}', Attachments: {len(attachments) if attachments else 0}"
9861103
)
9871104

9881105
# Prepare the email message
@@ -999,6 +1116,7 @@ async def send_gmail_message(
9991116
references=references,
10001117
body_format=body_format,
10011118
from_email=sender_email,
1119+
attachments=attachments if attachments else None,
10021120
)
10031121

10041122
send_body = {"raw": raw_message}
@@ -1012,6 +1130,9 @@ async def send_gmail_message(
10121130
service.users().messages().send(userId="me", body=send_body).execute
10131131
)
10141132
message_id = sent_message.get("id")
1133+
1134+
if attachments:
1135+
return f"Email sent with {len(attachments)} attachment(s)! Message ID: {message_id}"
10151136
return f"Email sent! Message ID: {message_id}"
10161137

10171138

@@ -1043,9 +1164,13 @@ async def draft_gmail_message(
10431164
references: Optional[str] = Body(
10441165
None, description="Optional chain of Message-IDs for proper threading."
10451166
),
1167+
attachments: Optional[List[Dict[str, str]]] = Body(
1168+
None,
1169+
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).",
1170+
),
10461171
) -> str:
10471172
"""
1048-
Creates a draft email in the user's Gmail account. Supports both new drafts and reply drafts.
1173+
Creates a draft email in the user's Gmail account. Supports both new drafts and reply drafts with optional attachments.
10491174
Supports Gmail's "Send As" feature to draft from configured alias addresses.
10501175
10511176
Args:
@@ -1062,6 +1187,15 @@ async def draft_gmail_message(
10621187
thread_id (Optional[str]): Optional Gmail thread ID to reply within. When provided, creates a reply draft.
10631188
in_reply_to (Optional[str]): Optional Message-ID of the message being replied to. Used for proper threading.
10641189
references (Optional[str]): Optional chain of Message-IDs for proper threading. Should include all previous Message-IDs.
1190+
attachments (List[Dict[str, str]]): Optional list of attachments. Each dict can contain:
1191+
Option 1 - File path (auto-encodes):
1192+
- 'path' (required): File path to attach
1193+
- 'filename' (optional): Override filename
1194+
- 'mime_type' (optional): Override MIME type (auto-detected if not provided)
1195+
Option 2 - Base64 content:
1196+
- 'content' (required): Standard base64-encoded file content (not urlsafe)
1197+
- 'filename' (required): Name of the file
1198+
- 'mime_type' (optional): MIME type (defaults to 'application/octet-stream')
10651199
10661200
Returns:
10671201
str: Confirmation message with the created draft's ID.
@@ -1136,6 +1270,7 @@ async def draft_gmail_message(
11361270
in_reply_to=in_reply_to,
11371271
references=references,
11381272
from_email=sender_email,
1273+
attachments=attachments,
11391274
)
11401275

11411276
# Create a draft instead of sending
@@ -1150,7 +1285,8 @@ async def draft_gmail_message(
11501285
service.users().drafts().create(userId="me", body=draft_body).execute
11511286
)
11521287
draft_id = created_draft.get("id")
1153-
return f"Draft created! Draft ID: {draft_id}"
1288+
attachment_info = f" with {len(attachments)} attachment(s)" if attachments else ""
1289+
return f"Draft created{attachment_info}! Draft ID: {draft_id}"
11541290

11551291

11561292
def _format_thread_content(thread_data: dict, thread_id: str) -> str:

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)