Spaces:
Sleeping
Sleeping
| from __future__ import annotations | |
| import json | |
| import mimetypes | |
| import os | |
| import smtplib | |
| from dataclasses import dataclass | |
| from datetime import datetime | |
| from email.message import EmailMessage | |
| from email.utils import make_msgid, formatdate | |
| from email import policy | |
| from email.parser import BytesParser | |
| from pathlib import Path | |
| from typing import Any, Dict, List, Optional | |
| MAIL_DIR = Path(__file__).resolve().parents[1] / "mcp_mail" | |
| MAIL_DIR.mkdir(parents=True, exist_ok=True) | |
| # --- Sending --------------------------------------------------------------- | |
| class SendResult: | |
| success: bool | |
| message_id: Optional[str] | |
| error: Optional[str] = None | |
| def _attach_files(msg: EmailMessage, paths: Optional[List[str]]) -> None: | |
| """ | |
| Attach files (by path) to the outgoing EmailMessage. | |
| - Guesses MIME type via mimetypes; falls back to 'application/octet-stream' | |
| - Reads bytes; filename is the basename of the path | |
| """ | |
| if not paths: | |
| return | |
| for p in paths: | |
| if not p: | |
| continue | |
| fp = Path(p).expanduser() | |
| if not fp.exists() or not fp.is_file(): | |
| raise FileNotFoundError(f"Attachment not found: {p}") | |
| data = fp.read_bytes() | |
| ctype, enc = mimetypes.guess_type(fp.name) | |
| maintype = "application" | |
| subtype = "octet-stream" | |
| if ctype: | |
| maintype, subtype = ctype.split("/", 1) | |
| msg.add_attachment(data, maintype=maintype, subtype=subtype, filename=fp.name) | |
| def send_email_smtp( | |
| from_addr: str, | |
| to_addrs: List[str], | |
| subject: str, | |
| text_body: Optional[str] = None, | |
| html_body: Optional[str] = None, | |
| cc_addrs: Optional[List[str]] = None, | |
| bcc_addrs: Optional[List[str]] = None, | |
| attachments_paths: Optional[List[str]] = None, | |
| smtp_port: Optional[int] = None, | |
| ) -> SendResult: | |
| """ | |
| Build MIME and send via localhost SMTP sink. | |
| If both text and html provided, send multipart/alternative. | |
| Supports file attachments via absolute/relative paths. | |
| Args: | |
| smtp_port: SMTP server port. If not provided, reads from DYNACONF_SERVER_PORTS__EMAIL_SINK env var, defaults to 1025. | |
| """ | |
| if smtp_port is None: | |
| smtp_port = int(os.environ.get("DYNACONF_SERVER_PORTS__EMAIL_SINK", "1025")) | |
| print(f"[Email Utils] Preparing to send email via SMTP at 127.0.0.1:{smtp_port}") | |
| print(f"[Email Utils] From: {from_addr}, To: {to_addrs}, Subject: {subject}") | |
| msg = EmailMessage() | |
| msg["From"] = from_addr | |
| msg["To"] = ", ".join(to_addrs) | |
| if cc_addrs: | |
| msg["Cc"] = ", ".join(cc_addrs) | |
| msg["Subject"] = subject | |
| msg["Date"] = formatdate(localtime=True) | |
| mid = make_msgid() | |
| msg["Message-ID"] = mid | |
| # Body | |
| if text_body and html_body: | |
| msg.set_content(text_body) | |
| msg.add_alternative(html_body, subtype="html") | |
| elif html_body: | |
| msg.set_content("This email contains HTML content.") | |
| msg.add_alternative(html_body, subtype="html") | |
| else: | |
| msg.set_content(text_body or "") | |
| # Attachments | |
| _attach_files(msg, attachments_paths) | |
| recipients = list(to_addrs) | |
| if cc_addrs: | |
| recipients += cc_addrs | |
| if bcc_addrs: | |
| recipients += bcc_addrs | |
| try: | |
| print(f"[Email Utils] Connecting to SMTP server at 127.0.0.1:{smtp_port}...") | |
| with smtplib.SMTP("127.0.0.1", smtp_port) as smtp: | |
| smtp.send_message(msg, from_addr=from_addr, to_addrs=recipients) | |
| print(f"[Email Utils] ✓ Email sent successfully, Message-ID: {mid}") | |
| return SendResult(True, mid) | |
| except Exception as e: | |
| print(f"[Email Utils] ✗ Failed to send email: {e}") | |
| return SendResult(False, None, error=str(e)) | |
| # --- Listing / Reading ----------------------------------------------------- | |
| def _safe_load(path: Path) -> Optional[Dict[str, Any]]: | |
| try: | |
| return json.loads(path.read_text(encoding="utf-8")) | |
| except Exception: | |
| return None | |
| def list_emails_fs(query: Optional[str] = None, limit: int = 50) -> List[Dict[str, Any]]: | |
| """ | |
| Read all ./mcp_mail/*.json, sort by stored_at desc, apply optional free-text filter. | |
| Query (case-insensitive substring): | |
| - Searched fields: subject, from, to (array joined into one string), and text (plaintext body). | |
| - NOT searched: html body, cc, bcc, attachment content (metadata only), Message-ID, | |
| In-Reply-To, References, other headers. | |
| - No regex/wildcards: spaces are literal; "weekly report" matches that exact sequence. | |
| - Unicode: case-insensitive, not accent-insensitive. | |
| - If query is falsy/omitted, return all. | |
| Limit: | |
| - Max number of items returned (>=1). Newest first. | |
| """ | |
| recs: List[Dict[str, Any]] = [] | |
| for p in sorted(MAIL_DIR.glob("*.json")): | |
| data = _safe_load(p) | |
| if not data: | |
| continue | |
| recs.append(data) | |
| def _parse_iso(s: str) -> float: | |
| try: | |
| return datetime.fromisoformat(s).timestamp() | |
| except Exception: | |
| return 0.0 | |
| recs.sort(key=lambda r: _parse_iso(r.get("stored_at", "")), reverse=True) | |
| if query: | |
| q = query.lower() | |
| def hay(r: Dict[str, Any]) -> str: | |
| subj = (r.get("subject") or "").lower() | |
| frm = (r.get("from") or "").lower() | |
| to = " ".join(r.get("to") or []).lower() | |
| text = (r.get("text") or "").lower() | |
| return " | ".join([subj, frm, to, text]) | |
| recs = [r for r in recs if q in hay(r)] | |
| out = [] | |
| for r in recs[: max(1, limit)]: | |
| out.append( | |
| { | |
| "id": r.get("id"), | |
| "subject": r.get("subject"), | |
| "from": r.get("from"), | |
| "to": r.get("to"), | |
| "date": r.get("date"), | |
| } | |
| ) | |
| return out | |
| # Allowed textual attachment types we will extract & return inline | |
| _ALLOWED_CT = { | |
| "text/plain", | |
| "text/csv", | |
| "application/json", | |
| "application/yaml", | |
| "text/yaml", | |
| "application/x-yaml", | |
| } | |
| _ALLOWED_EXT = {".txt", ".csv", ".json", ".yaml", ".yml"} | |
| # Upper bound to avoid returning huge blobs in JSON | |
| _MAX_TEXT_BYTES = 512 * 1024 # 512 KiB | |
| def _is_allowed_attachment(part_filename: Optional[str], content_type: str) -> bool: | |
| if content_type in _ALLOWED_CT: | |
| return True | |
| if part_filename: | |
| ext = Path(part_filename).suffix.lower() | |
| if ext in _ALLOWED_EXT: | |
| return True | |
| return False | |
| def _extract_textual_attachments(eml_path: Path) -> List[Dict[str, Any]]: | |
| """ | |
| Open the .eml and return a list of attachments. For allowed text-y types | |
| (txt/json/yaml/csv), include `text` with UTF-8 decoded content (up to limit). | |
| """ | |
| if not eml_path.exists(): | |
| return [] | |
| raw = eml_path.read_bytes() | |
| msg = BytesParser(policy=policy.default).parsebytes(raw) | |
| results: List[Dict[str, Any]] = [] | |
| for part in msg.walk(): | |
| disp = (part.get_content_disposition() or "").lower() | |
| filename = part.get_filename() | |
| is_attachment = disp == "attachment" or bool(filename) | |
| if not is_attachment: | |
| continue | |
| ctype = part.get_content_type() | |
| payload = part.get_payload(decode=True) or b"" | |
| entry: Dict[str, Any] = { | |
| "filename": filename or "", | |
| "content_type": ctype, | |
| "size_bytes": len(payload), | |
| } | |
| if _is_allowed_attachment(filename, ctype): | |
| # Bound size, decode as UTF-8 with replacement | |
| preview = payload[:_MAX_TEXT_BYTES] | |
| try: | |
| entry["text"] = preview.decode(part.get_content_charset() or "utf-8", errors="replace") | |
| except Exception: | |
| entry["text"] = preview.decode("utf-8", errors="replace") | |
| results.append(entry) | |
| return results | |
| def get_email_fs(email_id: str) -> Dict[str, Any]: | |
| """ | |
| Return the stored JSON AND (if possible) inline textual attachment contents | |
| by parsing the associated .eml file. The returned `attachments` field is | |
| replaced with a richer list that may include `text` for supported types. | |
| """ | |
| path = MAIL_DIR / f"{email_id}.json" | |
| if not path.exists(): | |
| return {"error": {"code": "NOT_FOUND", "message": f"Email id {email_id} not found"}} | |
| data = _safe_load(path) | |
| if data is None: | |
| return {"error": {"code": "READ_ERROR", "message": f"Failed to read {path.name}"}} | |
| eml_path = Path(data.get("raw_path") or "") | |
| # Merge: prefer freshly parsed attachment info from the .eml | |
| enriched_attachments = ( | |
| _extract_textual_attachments(eml_path) if eml_path else (data.get("attachments") or []) | |
| ) | |
| data["attachments"] = enriched_attachments | |
| return data | |