ruff
This commit is contained in:
@@ -8,10 +8,15 @@ import logging
|
||||
import asyncio
|
||||
import base64
|
||||
import ssl
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
from html.parser import HTMLParser
|
||||
from typing import Optional, List, Dict, Literal, Any
|
||||
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.base import MIMEBase
|
||||
from email import encoders
|
||||
|
||||
from fastapi import Body
|
||||
from pydantic import Field
|
||||
@@ -31,6 +36,7 @@ logger = logging.getLogger(__name__)
|
||||
GMAIL_BATCH_SIZE = 25
|
||||
GMAIL_REQUEST_DELAY = 0.1
|
||||
HTML_BODY_TRUNCATE_LIMIT = 20000
|
||||
GMAIL_METADATA_HEADERS = ["Subject", "From", "To", "Cc", "Message-ID", "Date"]
|
||||
|
||||
|
||||
class _HTMLTextExtractor(HTMLParser):
|
||||
@@ -214,9 +220,12 @@ def _extract_headers(payload: dict, header_names: List[str]) -> Dict[str, str]:
|
||||
Dict mapping header names to their values
|
||||
"""
|
||||
headers = {}
|
||||
target_headers = {name.lower(): name for name in header_names}
|
||||
for header in payload.get("headers", []):
|
||||
if header["name"] in header_names:
|
||||
headers[header["name"]] = header["value"]
|
||||
header_name_lower = header["name"].lower()
|
||||
if header_name_lower in target_headers:
|
||||
# Store using the original requested casing
|
||||
headers[target_headers[header_name_lower]] = header["value"]
|
||||
return headers
|
||||
|
||||
|
||||
@@ -232,9 +241,10 @@ def _prepare_gmail_message(
|
||||
body_format: Literal["plain", "html"] = "plain",
|
||||
from_email: Optional[str] = None,
|
||||
from_name: Optional[str] = None,
|
||||
attachments: Optional[List[Dict[str, str]]] = None,
|
||||
) -> tuple[str, Optional[str]]:
|
||||
"""
|
||||
Prepare a Gmail message with threading support.
|
||||
Prepare a Gmail message with threading and attachment support.
|
||||
|
||||
Args:
|
||||
subject: Email subject
|
||||
@@ -248,6 +258,7 @@ def _prepare_gmail_message(
|
||||
body_format: Content type for the email body ('plain' or 'html')
|
||||
from_email: Optional sender email address
|
||||
from_name: Optional sender display name (e.g., "Peter Hartree")
|
||||
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
|
||||
@@ -262,7 +273,82 @@ def _prepare_gmail_message(
|
||||
if normalized_format not in {"plain", "html"}:
|
||||
raise ValueError("body_format must be either 'plain' or 'html'.")
|
||||
|
||||
message = MIMEText(body, normalized_format)
|
||||
# Use multipart if attachments are provided
|
||||
if attachments:
|
||||
message = MIMEMultipart()
|
||||
message.attach(MIMEText(body, normalized_format))
|
||||
|
||||
# Process attachments
|
||||
for attachment in attachments:
|
||||
file_path = attachment.get("path")
|
||||
filename = attachment.get("filename")
|
||||
content_base64 = attachment.get("content")
|
||||
mime_type = attachment.get("mime_type")
|
||||
|
||||
try:
|
||||
# If path is provided, read and encode the file
|
||||
if file_path:
|
||||
path_obj = Path(file_path)
|
||||
if not path_obj.exists():
|
||||
logger.error(f"File not found: {file_path}")
|
||||
continue
|
||||
|
||||
# Read file content
|
||||
with open(path_obj, "rb") as f:
|
||||
file_data = f.read()
|
||||
|
||||
# Use provided filename or extract from path
|
||||
if not filename:
|
||||
filename = path_obj.name
|
||||
|
||||
# Auto-detect MIME type if not provided
|
||||
if not mime_type:
|
||||
mime_type, _ = mimetypes.guess_type(str(path_obj))
|
||||
if not mime_type:
|
||||
mime_type = "application/octet-stream"
|
||||
|
||||
# If content is provided (base64), decode it
|
||||
elif content_base64:
|
||||
if not filename:
|
||||
logger.warning("Skipping attachment: missing filename")
|
||||
continue
|
||||
|
||||
file_data = base64.b64decode(content_base64)
|
||||
|
||||
if not mime_type:
|
||||
mime_type = "application/octet-stream"
|
||||
|
||||
else:
|
||||
logger.warning("Skipping attachment: missing both path and content")
|
||||
continue
|
||||
|
||||
# Create MIME attachment
|
||||
main_type, sub_type = mime_type.split("/", 1)
|
||||
part = MIMEBase(main_type, sub_type)
|
||||
part.set_payload(file_data)
|
||||
encoders.encode_base64(part)
|
||||
|
||||
# Sanitize filename to prevent header injection and ensure valid quoting
|
||||
safe_filename = (
|
||||
(filename or "")
|
||||
.replace("\r", "")
|
||||
.replace("\n", "")
|
||||
.replace("\\", "\\\\")
|
||||
.replace('"', r"\"")
|
||||
)
|
||||
|
||||
part.add_header(
|
||||
"Content-Disposition", f'attachment; filename="{safe_filename}"'
|
||||
)
|
||||
|
||||
message.attach(part)
|
||||
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}")
|
||||
continue
|
||||
else:
|
||||
message = MIMEText(body, normalized_format)
|
||||
|
||||
message["Subject"] = reply_subject
|
||||
|
||||
# Add sender if provided
|
||||
@@ -462,7 +548,7 @@ async def get_gmail_message_content(
|
||||
user_google_email (str): The user's Google email address. Required.
|
||||
|
||||
Returns:
|
||||
str: The message details including subject, sender, recipients (To, Cc), and body content.
|
||||
str: The message details including subject, sender, date, Message-ID, recipients (To, Cc), and body content.
|
||||
"""
|
||||
logger.info(
|
||||
f"[get_gmail_message_content] Invoked. Message ID: '{message_id}', Email: '{user_google_email}'"
|
||||
@@ -478,19 +564,19 @@ async def get_gmail_message_content(
|
||||
userId="me",
|
||||
id=message_id,
|
||||
format="metadata",
|
||||
metadataHeaders=["Subject", "From", "To", "Cc"],
|
||||
metadataHeaders=GMAIL_METADATA_HEADERS,
|
||||
)
|
||||
.execute
|
||||
)
|
||||
|
||||
headers = {
|
||||
h["name"]: h["value"]
|
||||
for h in message_metadata.get("payload", {}).get("headers", [])
|
||||
}
|
||||
headers = _extract_headers(
|
||||
message_metadata.get("payload", {}), GMAIL_METADATA_HEADERS
|
||||
)
|
||||
subject = headers.get("Subject", "(no subject)")
|
||||
sender = headers.get("From", "(unknown sender)")
|
||||
to = headers.get("To", "")
|
||||
cc = headers.get("Cc", "")
|
||||
rfc822_msg_id = headers.get("Message-ID", "")
|
||||
|
||||
# Now fetch the full message to get the body parts
|
||||
message_full = await asyncio.to_thread(
|
||||
@@ -519,8 +605,12 @@ async def get_gmail_message_content(
|
||||
content_lines = [
|
||||
f"Subject: {subject}",
|
||||
f"From: {sender}",
|
||||
f"Date: {headers.get('Date', '(unknown date)')}",
|
||||
]
|
||||
|
||||
if rfc822_msg_id:
|
||||
content_lines.append(f"Message-ID: {rfc822_msg_id}")
|
||||
|
||||
if to:
|
||||
content_lines.append(f"To: {to}")
|
||||
if cc:
|
||||
@@ -563,7 +653,7 @@ async def get_gmail_messages_content_batch(
|
||||
format (Literal["full", "metadata"]): Message format. "full" includes body, "metadata" only headers.
|
||||
|
||||
Returns:
|
||||
str: A formatted list of message contents including subject, sender, recipients (To, Cc), and body (if full format).
|
||||
str: A formatted list of message contents including subject, sender, date, Message-ID, recipients (To, Cc), and body (if full format).
|
||||
"""
|
||||
logger.info(
|
||||
f"[get_gmail_messages_content_batch] Invoked. Message count: {len(message_ids)}, Email: '{user_google_email}'"
|
||||
@@ -596,7 +686,7 @@ async def get_gmail_messages_content_batch(
|
||||
userId="me",
|
||||
id=mid,
|
||||
format="metadata",
|
||||
metadataHeaders=["Subject", "From", "To", "Cc"],
|
||||
metadataHeaders=GMAIL_METADATA_HEADERS,
|
||||
)
|
||||
)
|
||||
else:
|
||||
@@ -628,7 +718,7 @@ async def get_gmail_messages_content_batch(
|
||||
userId="me",
|
||||
id=mid,
|
||||
format="metadata",
|
||||
metadataHeaders=["Subject", "From", "To", "Cc"],
|
||||
metadataHeaders=GMAIL_METADATA_HEADERS,
|
||||
)
|
||||
.execute
|
||||
)
|
||||
@@ -679,15 +769,20 @@ async def get_gmail_messages_content_batch(
|
||||
payload = message.get("payload", {})
|
||||
|
||||
if format == "metadata":
|
||||
headers = _extract_headers(payload, ["Subject", "From", "To", "Cc"])
|
||||
headers = _extract_headers(payload, GMAIL_METADATA_HEADERS)
|
||||
subject = headers.get("Subject", "(no subject)")
|
||||
sender = headers.get("From", "(unknown sender)")
|
||||
to = headers.get("To", "")
|
||||
cc = headers.get("Cc", "")
|
||||
rfc822_msg_id = headers.get("Message-ID", "")
|
||||
|
||||
msg_output = (
|
||||
f"Message ID: {mid}\nSubject: {subject}\nFrom: {sender}\n"
|
||||
f"Date: {headers.get('Date', '(unknown date)')}\n"
|
||||
)
|
||||
if rfc822_msg_id:
|
||||
msg_output += f"Message-ID: {rfc822_msg_id}\n"
|
||||
|
||||
if to:
|
||||
msg_output += f"To: {to}\n"
|
||||
if cc:
|
||||
@@ -697,11 +792,12 @@ async def get_gmail_messages_content_batch(
|
||||
output_messages.append(msg_output)
|
||||
else:
|
||||
# Full format - extract body too
|
||||
headers = _extract_headers(payload, ["Subject", "From", "To", "Cc"])
|
||||
headers = _extract_headers(payload, GMAIL_METADATA_HEADERS)
|
||||
subject = headers.get("Subject", "(no subject)")
|
||||
sender = headers.get("From", "(unknown sender)")
|
||||
to = headers.get("To", "")
|
||||
cc = headers.get("Cc", "")
|
||||
rfc822_msg_id = headers.get("Message-ID", "")
|
||||
|
||||
# Extract both text and HTML bodies using enhanced helper function
|
||||
bodies = _extract_message_bodies(payload)
|
||||
@@ -713,7 +809,11 @@ async def get_gmail_messages_content_batch(
|
||||
|
||||
msg_output = (
|
||||
f"Message ID: {mid}\nSubject: {subject}\nFrom: {sender}\n"
|
||||
f"Date: {headers.get('Date', '(unknown date)')}\n"
|
||||
)
|
||||
if rfc822_msg_id:
|
||||
msg_output += f"Message-ID: {rfc822_msg_id}\n"
|
||||
|
||||
if to:
|
||||
msg_output += f"To: {to}\n"
|
||||
if cc:
|
||||
@@ -894,7 +994,12 @@ async def send_gmail_message(
|
||||
cc: Optional[str] = Body(None, description="Optional CC email address."),
|
||||
bcc: Optional[str] = Body(None, description="Optional BCC email address."),
|
||||
from_name: Optional[str] = Body(
|
||||
None, description="Optional sender display name (e.g., 'Peter Hartree'). If provided, the From header will be formatted as 'Name <email>'."
|
||||
None,
|
||||
description="Optional sender display name (e.g., 'Peter Hartree'). If provided, the From header will be formatted as 'Name <email>'.",
|
||||
),
|
||||
from_email: Optional[str] = Body(
|
||||
None,
|
||||
description="Optional 'Send As' alias email address. Must be configured in Gmail settings (Settings > Accounts > Send mail as). If not provided, uses the authenticated user's email.",
|
||||
),
|
||||
thread_id: Optional[str] = Body(
|
||||
None, description="Optional Gmail thread ID to reply within."
|
||||
@@ -905,19 +1010,36 @@ async def send_gmail_message(
|
||||
references: Optional[str] = Body(
|
||||
None, description="Optional chain of Message-IDs for proper threading."
|
||||
),
|
||||
attachments: Optional[List[Dict[str, str]]] = Body(
|
||||
None,
|
||||
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"}]',
|
||||
),
|
||||
) -> str:
|
||||
"""
|
||||
Sends an email using the user's Gmail account. Supports both new emails and replies.
|
||||
Sends an email using the user's Gmail account. Supports both new emails and replies with optional attachments.
|
||||
Supports Gmail's "Send As" feature to send from configured alias addresses.
|
||||
|
||||
Args:
|
||||
to (str): Recipient email address.
|
||||
subject (str): Email subject.
|
||||
body (str): Email body content.
|
||||
body_format (Literal['plain', 'html']): Email body format. Defaults to 'plain'.
|
||||
attachments (Optional[List[Dict[str, str]]]): Optional list of attachments. Each dict can contain:
|
||||
Option 1 - File path (auto-encodes):
|
||||
- 'path' (required): File path to attach
|
||||
- 'filename' (optional): Override filename
|
||||
- 'mime_type' (optional): Override MIME type (auto-detected if not provided)
|
||||
Option 2 - Base64 content:
|
||||
- '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')
|
||||
cc (Optional[str]): Optional CC email address.
|
||||
bcc (Optional[str]): Optional BCC email address.
|
||||
from_name (Optional[str]): Optional sender display name. If provided, the From header will be formatted as 'Name <email>'.
|
||||
user_google_email (str): The user's Google email address. Required.
|
||||
from_email (Optional[str]): Optional 'Send As' alias email address. The alias must be
|
||||
configured in Gmail settings (Settings > Accounts > Send mail as). If not provided,
|
||||
the email will be sent from the authenticated user's primary email address.
|
||||
user_google_email (str): The user's Google email address. Required for authentication.
|
||||
thread_id (Optional[str]): Optional Gmail thread ID to reply within. When provided, sends a reply.
|
||||
in_reply_to (Optional[str]): Optional Message-ID of the message being replied to. Used for proper threading.
|
||||
references (Optional[str]): Optional chain of Message-IDs for proper threading. Should include all previous Message-IDs.
|
||||
@@ -937,6 +1059,14 @@ async def send_gmail_message(
|
||||
body_format="html"
|
||||
)
|
||||
|
||||
# Send from a configured alias (Send As)
|
||||
send_gmail_message(
|
||||
to="user@example.com",
|
||||
subject="Business Inquiry",
|
||||
body="Hello from my business address...",
|
||||
from_email="business@mydomain.com"
|
||||
)
|
||||
|
||||
# Send an email with CC and BCC
|
||||
send_gmail_message(
|
||||
to="user@example.com",
|
||||
@@ -946,6 +1076,28 @@ async def send_gmail_message(
|
||||
body="Here's the latest update..."
|
||||
)
|
||||
|
||||
# Send an email with attachments (using file path)
|
||||
send_gmail_message(
|
||||
to="user@example.com",
|
||||
subject="Report",
|
||||
body="Please see attached report.",
|
||||
attachments=[{
|
||||
"path": "/path/to/report.pdf"
|
||||
}]
|
||||
)
|
||||
|
||||
# Send an email with attachments (using base64 content)
|
||||
send_gmail_message(
|
||||
to="user@example.com",
|
||||
subject="Report",
|
||||
body="Please see attached report.",
|
||||
attachments=[{
|
||||
"filename": "report.pdf",
|
||||
"content": "JVBERi0xLjQK...", # base64 encoded PDF
|
||||
"mime_type": "application/pdf"
|
||||
}]
|
||||
)
|
||||
|
||||
# Send a reply
|
||||
send_gmail_message(
|
||||
to="user@example.com",
|
||||
@@ -957,10 +1109,12 @@ async def send_gmail_message(
|
||||
)
|
||||
"""
|
||||
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) if attachments else 0}"
|
||||
)
|
||||
|
||||
# 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(
|
||||
subject=subject,
|
||||
body=body,
|
||||
@@ -971,8 +1125,9 @@ async def send_gmail_message(
|
||||
in_reply_to=in_reply_to,
|
||||
references=references,
|
||||
body_format=body_format,
|
||||
from_email=user_google_email,
|
||||
from_email=sender_email,
|
||||
from_name=from_name,
|
||||
attachments=attachments if attachments else None,
|
||||
)
|
||||
|
||||
send_body = {"raw": raw_message}
|
||||
@@ -986,6 +1141,9 @@ async def send_gmail_message(
|
||||
service.users().messages().send(userId="me", body=send_body).execute
|
||||
)
|
||||
message_id = sent_message.get("id")
|
||||
|
||||
if attachments:
|
||||
return f"Email sent with {len(attachments)} attachment(s)! Message ID: {message_id}"
|
||||
return f"Email sent! Message ID: {message_id}"
|
||||
|
||||
|
||||
@@ -1005,7 +1163,12 @@ async def draft_gmail_message(
|
||||
cc: Optional[str] = Body(None, description="Optional CC email address."),
|
||||
bcc: Optional[str] = Body(None, description="Optional BCC email address."),
|
||||
from_name: Optional[str] = Body(
|
||||
None, description="Optional sender display name (e.g., 'Peter Hartree'). If provided, the From header will be formatted as 'Name <email>'."
|
||||
None,
|
||||
description="Optional sender display name (e.g., 'Peter Hartree'). If provided, the From header will be formatted as 'Name <email>'.",
|
||||
),
|
||||
from_email: Optional[str] = Body(
|
||||
None,
|
||||
description="Optional 'Send As' alias email address. Must be configured in Gmail settings (Settings > Accounts > Send mail as). If not provided, uses the authenticated user's email.",
|
||||
),
|
||||
thread_id: Optional[str] = Body(
|
||||
None, description="Optional Gmail thread ID to reply within."
|
||||
@@ -1016,12 +1179,17 @@ async def draft_gmail_message(
|
||||
references: Optional[str] = Body(
|
||||
None, description="Optional chain of Message-IDs for proper threading."
|
||||
),
|
||||
attachments: Optional[List[Dict[str, str]]] = Body(
|
||||
None,
|
||||
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).",
|
||||
),
|
||||
) -> str:
|
||||
"""
|
||||
Creates a draft email in the user's Gmail account. Supports both new drafts and reply drafts.
|
||||
Creates a draft email in the user's Gmail account. Supports both new drafts and reply drafts with optional attachments.
|
||||
Supports Gmail's "Send As" feature to draft from configured alias addresses.
|
||||
|
||||
Args:
|
||||
user_google_email (str): The user's Google email address. Required.
|
||||
user_google_email (str): The user's Google email address. Required for authentication.
|
||||
subject (str): Email subject.
|
||||
body (str): Email body (plain text).
|
||||
body_format (Literal['plain', 'html']): Email body format. Defaults to 'plain'.
|
||||
@@ -1029,9 +1197,21 @@ async def draft_gmail_message(
|
||||
cc (Optional[str]): Optional CC email address.
|
||||
bcc (Optional[str]): Optional BCC email address.
|
||||
from_name (Optional[str]): Optional sender display name. If provided, the From header will be formatted as 'Name <email>'.
|
||||
from_email (Optional[str]): Optional 'Send As' alias email address. The alias must be
|
||||
configured in Gmail settings (Settings > Accounts > Send mail as). If not provided,
|
||||
the draft will be from the authenticated user's primary email address.
|
||||
thread_id (Optional[str]): Optional Gmail thread ID to reply within. When provided, creates a reply draft.
|
||||
in_reply_to (Optional[str]): Optional Message-ID of the message being replied to. Used for proper threading.
|
||||
references (Optional[str]): Optional chain of Message-IDs for proper threading. Should include all previous Message-IDs.
|
||||
attachments (List[Dict[str, str]]): Optional list of attachments. Each dict can contain:
|
||||
Option 1 - File path (auto-encodes):
|
||||
- 'path' (required): File path to attach
|
||||
- 'filename' (optional): Override filename
|
||||
- 'mime_type' (optional): Override MIME type (auto-detected if not provided)
|
||||
Option 2 - Base64 content:
|
||||
- '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')
|
||||
|
||||
Returns:
|
||||
str: Confirmation message with the created draft's ID.
|
||||
@@ -1040,6 +1220,14 @@ async def draft_gmail_message(
|
||||
# Create a new draft
|
||||
draft_gmail_message(subject="Hello", body="Hi there!", to="user@example.com")
|
||||
|
||||
# Create a draft from a configured alias (Send As)
|
||||
draft_gmail_message(
|
||||
subject="Business Inquiry",
|
||||
body="Hello from my business address...",
|
||||
to="user@example.com",
|
||||
from_email="business@mydomain.com"
|
||||
)
|
||||
|
||||
# Create a plaintext draft with CC and BCC
|
||||
draft_gmail_message(
|
||||
subject="Project Update",
|
||||
@@ -1073,7 +1261,7 @@ async def draft_gmail_message(
|
||||
draft_gmail_message(
|
||||
subject="Re: Meeting tomorrow",
|
||||
body="<strong>Thanks for the update!</strong>",
|
||||
body_format="html,
|
||||
body_format="html",
|
||||
to="user@example.com",
|
||||
thread_id="thread_123",
|
||||
in_reply_to="<message123@gmail.com>",
|
||||
@@ -1085,6 +1273,8 @@ 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(
|
||||
subject=subject,
|
||||
body=body,
|
||||
@@ -1095,8 +1285,9 @@ async def draft_gmail_message(
|
||||
thread_id=thread_id,
|
||||
in_reply_to=in_reply_to,
|
||||
references=references,
|
||||
from_email=user_google_email,
|
||||
from_email=sender_email,
|
||||
from_name=from_name,
|
||||
attachments=attachments,
|
||||
)
|
||||
|
||||
# Create a draft instead of sending
|
||||
@@ -1111,7 +1302,8 @@ async def draft_gmail_message(
|
||||
service.users().drafts().create(userId="me", body=draft_body).execute
|
||||
)
|
||||
draft_id = created_draft.get("id")
|
||||
return f"Draft created! Draft ID: {draft_id}"
|
||||
attachment_info = f" with {len(attachments)} attachment(s)" if attachments else ""
|
||||
return f"Draft created{attachment_info}! Draft ID: {draft_id}"
|
||||
|
||||
|
||||
def _format_thread_content(thread_data: dict, thread_id: str) -> str:
|
||||
|
||||
Reference in New Issue
Block a user