Spaces:
Running
Running
| from __future__ import annotations | |
| import base64 | |
| import binascii | |
| import mimetypes | |
| import os | |
| from email import message_from_bytes | |
| from email.mime.application import MIMEApplication | |
| from email.mime.audio import MIMEAudio | |
| from email.mime.base import MIMEBase | |
| from email.mime.image import MIMEImage | |
| from email.mime.multipart import MIMEMultipart | |
| from email.mime.text import MIMEText | |
| from typing import Any, Dict, List, Optional | |
| from app.config import get_settings | |
| from app.models.schemas import ( | |
| GmailComposeMessageRequest, | |
| GmailMessageAttachmentMeta, | |
| GmailParsedMessage, | |
| ) | |
| _settings = get_settings() | |
| class GmailMessageValidationError(ValueError): | |
| """Raised when a composed message fails size/content validation.""" | |
| class GmailParseError(ValueError): | |
| """Raised when a Gmail API message payload cannot be parsed.""" | |
| _EXTENSION_TO_MIME: Dict[str, str] = { | |
| ".pdf": "application/pdf", | |
| ".png": "image/png", | |
| ".jpg": "image/jpeg", | |
| ".jpeg": "image/jpeg", | |
| ".gif": "image/gif", | |
| ".bmp": "image/bmp", | |
| ".webp": "image/webp", | |
| ".txt": "text/plain", | |
| ".csv": "text/csv", | |
| ".json": "application/json", | |
| ".xml": "application/xml", | |
| ".zip": "application/zip", | |
| ".doc": "application/msword", | |
| ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", | |
| ".xls": "application/vnd.ms-excel", | |
| ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", | |
| ".ppt": "application/vnd.ms-powerpoint", | |
| ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", | |
| ".mp3": "audio/mpeg", | |
| ".wav": "audio/wav", | |
| ".mp4": "video/mp4", | |
| ".mov": "video/quicktime", | |
| } | |
| _IMMUTABLE_HEADERS = frozenset({ | |
| "from", "to", "subject", "cc", "bcc", "reply-to", | |
| "in-reply-to", "references", "content-type", "mime-version", | |
| "content-transfer-encoding", | |
| }) | |
| def guess_mime_type(filename: str) -> str: | |
| """Best-effort MIME type from a filename; falls back to octet-stream.""" | |
| mime_type, _ = mimetypes.guess_type(filename) | |
| if mime_type is None: | |
| ext = os.path.splitext(filename)[1].lower() | |
| mime_type = _EXTENSION_TO_MIME.get(ext, "application/octet-stream") | |
| return mime_type | |
| def _create_attachment_part(filename: str, content_bytes: bytes, mime_type: str) -> MIMEBase: | |
| main_type, _, sub_type = mime_type.partition("/") | |
| sub_type = sub_type or "octet-stream" | |
| if main_type == "image": | |
| part: MIMEBase = MIMEImage(content_bytes, _subtype=sub_type) | |
| elif main_type == "audio": | |
| part = MIMEAudio(content_bytes, _subtype=sub_type) | |
| elif main_type == "application": | |
| part = MIMEApplication(content_bytes, _subtype=sub_type, Name=filename) | |
| else: | |
| part = MIMEBase(main_type or "application", sub_type) | |
| part.set_payload(content_bytes) | |
| part.add_header("Content-Disposition", f'attachment; filename="{filename}"') | |
| part.add_header("Content-ID", f"<{filename}>") | |
| return part | |
| def build_mime_message( | |
| request: GmailComposeMessageRequest, | |
| ) -> str: | |
| """Build an RFC 2822 message from a structured compose request. | |
| Returns the base64url-encoded message string accepted by the Gmail API. | |
| The ``From`` header is intentionally omitted so Gmail fills it with the | |
| authenticated mailbox address (client-supplied, stateless design). | |
| """ | |
| message = MIMEMultipart("mixed") | |
| to = request.to | |
| if not to: | |
| raise GmailMessageValidationError("At least one 'to' recipient is required.") | |
| message["To"] = ", ".join(to) | |
| message["Subject"] = request.subject | |
| if request.cc: | |
| message["Cc"] = ", ".join(request.cc) | |
| if request.bcc: | |
| message["Bcc"] = ", ".join(request.bcc) | |
| if request.reply_to: | |
| message["Reply-To"] = ", ".join(request.reply_to) | |
| if request.in_reply_to: | |
| message["In-Reply-To"] = request.in_reply_to | |
| if request.references: | |
| message["References"] = " ".join(request.references) | |
| if request.headers: | |
| for key, value in request.headers.items(): | |
| if key.lower() not in _IMMUTABLE_HEADERS: | |
| message[key] = value | |
| alternative = MIMEMultipart("alternative") | |
| if request.body: | |
| alternative.attach(MIMEText(request.body, "plain", "utf-8")) | |
| if request.html_body: | |
| alternative.attach(MIMEText(request.html_body, "html", "utf-8")) | |
| elif request.body: | |
| alternative.attach(MIMEText(request.body, "html", "utf-8")) | |
| if alternative.get_payload(): | |
| message.attach(alternative) | |
| if request.attachments: | |
| total_bytes = sum( | |
| len(_b64_decode(s.content_base64)) for s in request.attachments | |
| ) | |
| if total_bytes > _settings.gmail_max_payload_bytes: | |
| raise GmailMessageValidationError( | |
| f"Total attachment payload of {total_bytes} bytes exceeds the " | |
| f"limit of {_settings.gmail_max_payload_bytes} bytes." | |
| ) | |
| for spec in request.attachments: | |
| content = _b64_decode(spec.content_base64) | |
| if len(content) > _settings.gmail_max_attachment_bytes: | |
| raise GmailMessageValidationError( | |
| f"Attachment '{spec.filename}' is {len(content)} bytes, exceeding " | |
| f"the {_settings.gmail_max_attachment_bytes} byte per-file limit." | |
| ) | |
| mime_type = spec.mime_type or guess_mime_type(spec.filename) | |
| message.attach(_create_attachment_part(spec.filename, content, mime_type)) | |
| return encode_mime_to_base64(message) | |
| def encode_mime_to_base64(mime_message: Any) -> str: | |
| """Base64url-encode a MIME message for the Gmail API.""" | |
| return base64.urlsafe_b64encode(mime_message.as_bytes()).decode("ascii") | |
| def _b64_decode(data: str) -> bytes: | |
| """Decode base64url (or standard base64) into bytes.""" | |
| try: | |
| return base64.urlsafe_b64decode(data.encode("ascii")) | |
| except (binascii.Error, ValueError) as exc: | |
| raise GmailMessageValidationError( | |
| "Attachment content must be valid base64." | |
| ) from exc | |
| def _base64_decode_text(data: str) -> str: | |
| if not data: | |
| return "" | |
| try: | |
| return _b64_decode(data).decode("utf-8", errors="replace") | |
| except GmailMessageValidationError: | |
| return "" | |
| def parse_message(message: Dict[str, Any]) -> GmailParsedMessage: | |
| """Parse a Gmail ``format=full`` or ``format=metadata`` message resource. | |
| Handles the ``raw`` format transparently when present. Recursively extracts | |
| plain/HTML bodies and top-level attachment metadata. | |
| """ | |
| try: | |
| message_id = message.get("id", "") | |
| thread_id = message.get("threadId", "") | |
| label_ids = message.get("labelIds") or [] | |
| snippet = message.get("snippet", "") | |
| history_id = message.get("historyId") | |
| headers: Dict[str, str] = {} | |
| plain_body: Optional[str] = None | |
| html_body: Optional[str] = None | |
| attachments: List[GmailMessageAttachmentMeta] = [] | |
| payload = message.get("payload") or {} | |
| if message.get("raw"): | |
| decoded = _b64_decode(message["raw"]) | |
| parsed = message_from_bytes(decoded) | |
| headers = { | |
| h: v for h, v in parsed.items() | |
| } | |
| if parsed.get_content_type() == "multipart/mixed": | |
| for part in parsed.walk(): | |
| ctype = part.get_content_type() | |
| if ctype == "text/plain": | |
| plain_body = part.get_payload(decode=True).decode("utf-8", errors="replace") | |
| elif ctype == "text/html": | |
| html_body = part.get_payload(decode=True).decode("utf-8", errors="replace") | |
| else: | |
| filename = part.get_filename() | |
| if filename: | |
| attachments.append(GmailMessageAttachmentMeta( | |
| attachment_id="", | |
| filename=filename, | |
| mime_type=part.get_content_type(), | |
| size_bytes=len(part.get_payload(decode=True) or b""), | |
| )) | |
| elif parsed.get_content_type() == "text/plain": | |
| plain_body = parsed.get_payload(decode=True).decode("utf-8", errors="replace") | |
| else: | |
| for header in payload.get("headers") or []: | |
| name = header.get("name", "") | |
| if name: | |
| headers[name] = header.get("value", "") | |
| plain_body, html_body, attachments = _walk_payload(payload) | |
| subject = headers.get("Subject", headers.get("subject", "")) | |
| if subject and headers.get("Subject") is None and "subject" in headers: | |
| subject = headers["subject"] | |
| return GmailParsedMessage( | |
| id=message_id, | |
| thread_id=thread_id, | |
| label_ids=label_ids, | |
| snippet=snippet, | |
| history_id=history_id, | |
| headers=headers, | |
| sender=headers.get("From", ""), | |
| to=headers.get("To", ""), | |
| cc=headers.get("Cc", ""), | |
| bcc=headers.get("Bcc", ""), | |
| subject=subject, | |
| date=headers.get("Date"), | |
| plain_body=plain_body, | |
| html_body=html_body, | |
| attachments=attachments, | |
| ) | |
| except GmailMessageValidationError as exc: | |
| raise GmailParseError(str(exc)) from exc | |
| def _walk_payload( | |
| payload: Dict[str, Any], | |
| ) -> tuple[Optional[str], Optional[str], List[GmailMessageAttachmentMeta]]: | |
| """Recursively extract text bodies and attachment metadata from a payload tree.""" | |
| plain_body: Optional[str] = None | |
| html_body: Optional[str] = None | |
| attachments: List[GmailMessageAttachmentMeta] = [] | |
| def visit(node: Dict[str, Any]) -> None: | |
| nonlocal plain_body, html_body | |
| mime_type = node.get("mimeType", "") | |
| filename = node.get("filename", "") | |
| body = node.get("body") or {} | |
| parts = node.get("parts") or [] | |
| if filename: | |
| attachments.append(GmailMessageAttachmentMeta( | |
| attachment_id=body.get("attachmentId", ""), | |
| filename=filename, | |
| mime_type=mime_type, | |
| size_bytes=int(body.get("size", 0) or 0), | |
| )) | |
| if mime_type == "text/plain" and plain_body is None: | |
| plain_body = _base64_decode_text(body.get("data", "")) | |
| elif mime_type == "text/html" and html_body is None: | |
| html_body = _base64_decode_text(body.get("data", "")) | |
| for child in parts: | |
| visit(child) | |
| visit(payload) | |
| return plain_body, html_body, attachments | |