diff --git a/myclaude/commands/mail.py b/myclaude/commands/mail.py new file mode 100644 index 0000000..6b2a09f --- /dev/null +++ b/myclaude/commands/mail.py @@ -0,0 +1,346 @@ +"""Mail subcommand for myclaude.""" + +from pathlib import Path + +import click +from rich.console import Console +from rich.table import Table + +from myclaude.mail.client import IMAPClient, SMTPClient +from myclaude.mail.config import ( + DEFAULT_IMAP_HOST, + DEFAULT_IMAP_PORT, + DEFAULT_SMTP_HOST, + DEFAULT_SMTP_PORT, + load_config, + save_config, +) +from myclaude.mail.utils import truncate_text + +console = Console() +stderr_console = Console(stderr=True) + + +@click.group("mail") +def mail_cmd() -> None: + """Manage email via IMAP/SMTP (e.g. SYSU Coremail).""" + + +@mail_cmd.command("config") +def mail_config() -> None: + """Interactively configure mail account.""" + console.print("[bold cyan]Mail configuration[/bold cyan]") + existing = load_config() + + def _prompt(text: str, default: str | None = None) -> str: + prompt = text + if default: + prompt = f"{text} [{default}]" + value = click.prompt(prompt, default=default or "", show_default=False) + if isinstance(value, str): + return value.strip() + return str(value).strip() + + imap_host = ( + _prompt( + "IMAP host", + existing.get("imap_host") if existing else DEFAULT_IMAP_HOST, + ) + or DEFAULT_IMAP_HOST + ) + imap_port_str = _prompt( + "IMAP port", + str(existing.get("imap_port")) if existing else str(DEFAULT_IMAP_PORT), + ) or str(DEFAULT_IMAP_PORT) + smtp_host = ( + _prompt( + "SMTP host", + existing.get("smtp_host") if existing else DEFAULT_SMTP_HOST, + ) + or DEFAULT_SMTP_HOST + ) + smtp_port_str = _prompt( + "SMTP port", + str(existing.get("smtp_port")) if existing else str(DEFAULT_SMTP_PORT), + ) or str(DEFAULT_SMTP_PORT) + email = _prompt("Email", existing.get("email") if existing else "") or "" + password = ( + click.prompt("Password", hide_input=True) + if not existing + else click.prompt( + "Password (leave blank to keep existing)", + hide_input=True, + default="", + show_default=False, + ) + ) + + try: + imap_port = int(imap_port_str) + smtp_port = int(smtp_port_str) + except ValueError: + stderr_console.print("[red]Invalid port number.[/red]") + raise SystemExit(1) from None + + config = { + "imap_host": imap_host, + "imap_port": imap_port, + "smtp_host": smtp_host, + "smtp_port": smtp_port, + "email": email, + } + if password: + config["password"] = password + elif existing and existing.get("password"): + config["password"] = existing["password"] + + save_config(config) + console.print("[green]Configuration saved.[/green]") + + +@mail_cmd.command("inbox") +@click.option("--limit", default=20, help="Number of recent messages to show.") +def mail_inbox(limit: int) -> None: + """Show recent inbox summaries.""" + cfg = load_config() + if not cfg: + stderr_console.print( + '[red]No mail config found.[/red] Run "myclaude mail config" first.' + ) + raise SystemExit(1) from None + + try: + with IMAPClient( + cfg["imap_host"], + cfg["imap_port"], + cfg["email"], + cfg["password"], + ) as client: + summaries = client.inbox_summaries(limit=limit) + except Exception as exc: + stderr_console.print(f"[red]IMAP error:[/red] {exc}") + raise SystemExit(1) from None + + if not summaries: + console.print("Inbox is empty.") + return + + table = Table(title="Inbox") + table.add_column("UID", style="cyan", no_wrap=True) + table.add_column("From", style="green") + table.add_column("Subject") + table.add_column("Date", style="dim") + + for s in summaries: + table.add_row( + s["uid"], + truncate_text(s.get("from", ""), 30), + truncate_text(s.get("subject", ""), 50), + s.get("date", ""), + ) + console.print(table) + + +@mail_cmd.command("read") +@click.argument("uid") +def mail_read(uid: str) -> None: + """Read a single message by UID.""" + cfg = load_config() + if not cfg: + stderr_console.print( + '[red]No mail config found.[/red] Run "myclaude mail config" first.' + ) + raise SystemExit(1) from None + + try: + with IMAPClient( + cfg["imap_host"], + cfg["imap_port"], + cfg["email"], + cfg["password"], + ) as client: + msg = client.fetch_message(uid) + except Exception as exc: + stderr_console.print(f"[red]IMAP error:[/red] {exc}") + raise SystemExit(1) from None + + console.print(f"[bold]From:[/bold] {msg['from']}") + console.print(f"[bold]To:[/bold] {msg['to']}") + if msg.get("cc"): + console.print(f"[bold]Cc:[/bold] {msg['cc']}") + console.print(f"[bold]Subject:[/bold] {msg['subject']}") + console.print(f"[bold]Date:[/bold] {msg['date']}") + console.print("-" * 60) + + body = msg["plain"] or msg["html"] + console.print(body or "[dim](no body)[/dim]") + + if msg.get("attachments"): + console.print("\n[bold]Attachments:[/bold]") + for att in msg["attachments"]: + console.print(f" • {att['filename']} ({att['content_type']})") + + +@mail_cmd.command("send") +@click.option("--to", required=True, help="Recipient email address.") +@click.option("--subject", required=True, help="Email subject.") +@click.option("--body", required=True, help="Email body.") +@click.option("--cc", default="", help="Cc recipients (comma-separated).") +@click.option("--bcc", default="", help="Bcc recipients (comma-separated).") +@click.option( + "--attach", + multiple=True, + help="Attachment file path (can be used multiple times).", +) +@click.option("--html", is_flag=True, help="Treat body as HTML.") +def mail_send( + to: str, + subject: str, + body: str, + cc: str, + bcc: str, + attach: tuple[str, ...], + html: bool, +) -> None: + """Send an email.""" + cfg = load_config() + if not cfg: + stderr_console.print( + '[red]No mail config found.[/red] Run "myclaude mail config" first.' + ) + raise SystemExit(1) from None + + body = body.replace("\\n", "\n").replace("\\t", "\t") + + to_addrs = [addr.strip() for addr in to.split(",") if addr.strip()] + cc_addrs = ( + [addr.strip() for addr in cc.split(",") if addr.strip()] if cc else None + ) + bcc_addrs = ( + [addr.strip() for addr in bcc.split(",") if addr.strip()] + if bcc + else None + ) + attachments = [Path(p) for p in attach] + + for p in attachments: + if not p.is_file(): + stderr_console.print(f"[red]Attachment not found:[/red] {p}") + raise SystemExit(1) from None + + try: + client = SMTPClient( + cfg["smtp_host"], + cfg["smtp_port"], + cfg["email"], + cfg["password"], + ) + client.send( + to_addrs=to_addrs, + subject=subject, + body=body, + cc_addrs=cc_addrs, + bcc_addrs=bcc_addrs, + attachments=[str(p) for p in attachments], + is_html=html, + ) + except Exception as exc: + stderr_console.print(f"[red]SMTP error:[/red] {exc}") + raise SystemExit(1) from None + + console.print("[green]Email sent successfully.[/green]") + + +@mail_cmd.command("reply") +@click.argument("uid") +@click.option("--body", required=True, help="Reply body.") +@click.option("--html", is_flag=True, help="Treat body as HTML.") +def mail_reply(uid: str, body: str, html: bool) -> None: + """Reply to a message by UID.""" + cfg = load_config() + if not cfg: + stderr_console.print( + '[red]No mail config found.[/red] Run "myclaude mail config" first.' + ) + raise SystemExit(1) from None + + body = body.replace("\\n", "\n").replace("\\t", "\t") + + try: + with IMAPClient( + cfg["imap_host"], + cfg["imap_port"], + cfg["email"], + cfg["password"], + ) as client: + original = client.fetch_message(uid) + except Exception as exc: + stderr_console.print(f"[red]IMAP error:[/red] {exc}") + raise SystemExit(1) from None + + try: + smtp = SMTPClient( + cfg["smtp_host"], + cfg["smtp_port"], + cfg["email"], + cfg["password"], + ) + smtp.send_reply(original, body=body, is_html=html) + except Exception as exc: + stderr_console.print(f"[red]SMTP error:[/red] {exc}") + raise SystemExit(1) from None + + console.print("[green]Reply sent successfully.[/green]") + + +@mail_cmd.command("forward") +@click.argument("uid") +@click.option("--to", required=True, help="Recipient email address.") +@click.option("--body", default="", help="Optional forwarding note.") +@click.option("--html", is_flag=True, help="Treat body as HTML.") +def mail_forward(uid: str, to: str, body: str, html: bool) -> None: + """Forward a message by UID.""" + cfg = load_config() + if not cfg: + stderr_console.print( + '[red]No mail config found.[/red] Run "myclaude mail config" first.' + ) + raise SystemExit(1) from None + + body = body.replace("\\n", "\n").replace("\\t", "\t") + to_addrs = [addr.strip() for addr in to.split(",") if addr.strip()] + + try: + with IMAPClient( + cfg["imap_host"], + cfg["imap_port"], + cfg["email"], + cfg["password"], + ) as client: + original = client.fetch_message(uid) + raw_original = client.fetch_raw(uid) + except Exception as exc: + stderr_console.print(f"[red]IMAP error:[/red] {exc}") + raise SystemExit(1) from None + + subject = original.get("subject", "") + + try: + smtp = SMTPClient( + cfg["smtp_host"], + cfg["smtp_port"], + cfg["email"], + cfg["password"], + ) + smtp.send_forward( + raw_original=raw_original, + to_addrs=to_addrs, + subject=subject, + body=body or None, + is_html=html, + ) + except Exception as exc: + stderr_console.print(f"[red]SMTP error:[/red] {exc}") + raise SystemExit(1) from None + + console.print("[green]Message forwarded successfully.[/green]") diff --git a/myclaude/mail/__init__.py b/myclaude/mail/__init__.py new file mode 100644 index 0000000..52d077e --- /dev/null +++ b/myclaude/mail/__init__.py @@ -0,0 +1 @@ +"""Mail module for myclaude.""" diff --git a/myclaude/mail/client.py b/myclaude/mail/client.py new file mode 100644 index 0000000..a7e2fc3 --- /dev/null +++ b/myclaude/mail/client.py @@ -0,0 +1,279 @@ +"""IMAP and SMTP client wrappers.""" + +import imaplib +import smtplib +from email.message import EmailMessage +from email.utils import formataddr +from typing import Any + +from myclaude.mail.parser import extract_attachments, extract_text +from myclaude.mail.utils import decode_header_value, format_date + + +class IMAPClient: + """Simple IMAP client wrapper.""" + + def __init__( + self, + host: str, + port: int, + email: str, + password: str, + ) -> None: + self.host = host + self.port = port + self.email = email + self.password = password + self._conn: imaplib.IMAP4_SSL | None = None + + def connect(self) -> "IMAPClient": + self._conn = imaplib.IMAP4_SSL(self.host, self.port) + self._conn.login(self.email, self.password) + return self + + def disconnect(self) -> None: + if self._conn: + try: + self._conn.close() + self._conn.logout() + except Exception: + pass + self._conn = None + + def __enter__(self) -> "IMAPClient": + return self.connect() + + def __exit__(self, *_: Any) -> None: + self.disconnect() + + def inbox_summaries(self, limit: int = 20) -> list[dict[str, Any]]: + """Fetch recent inbox message summaries.""" + assert self._conn is not None + status, _ = self._conn.select("INBOX") + if status != "OK": + raise RuntimeError("Failed to select INBOX") + + _, data = self._conn.search(None, "ALL") + msg_ids = data[0].split() + recent_ids = msg_ids[-limit:] + recent_ids.reverse() + + summaries: list[dict[str, Any]] = [] + for msg_id in recent_ids: + msg_id_str = msg_id.decode("ascii") + _, fetched = self._conn.fetch( + msg_id_str, + "(BODY.PEEK[HEADER.FIELDS (FROM SUBJECT DATE)])", + ) + raw_headers = b"" + for item in fetched: + if isinstance(item, tuple): + raw_headers += item[1] + + from email import message_from_bytes + + header_msg = message_from_bytes(raw_headers) + summaries.append( + { + "uid": msg_id_str, + "from": decode_header_value(header_msg.get("From")), + "subject": decode_header_value(header_msg.get("Subject")), + "date": format_date(header_msg.get("Date")), + } + ) + return summaries + + def fetch_raw(self, uid: str) -> bytes: + """Fetch raw RFC822 bytes by UID.""" + assert self._conn is not None + status, _ = self._conn.select("INBOX") + if status != "OK": + raise RuntimeError("Failed to select INBOX") + _, data = self._conn.fetch(uid, "(RFC822)") + raw = b"" + for item in data: + if isinstance(item, tuple): + raw += item[1] + return raw + + def fetch_message(self, uid: str) -> dict[str, Any]: + """Fetch full message by UID.""" + raw = self.fetch_raw(uid) + + from email import message_from_bytes + + msg = message_from_bytes(raw) + plain, html = extract_text(msg) + attachments = extract_attachments(msg) + + return { + "uid": uid, + "from": decode_header_value(msg.get("From")), + "to": decode_header_value(msg.get("To")), + "cc": decode_header_value(msg.get("Cc")), + "subject": decode_header_value(msg.get("Subject")), + "date": format_date(msg.get("Date")), + "message_id": decode_header_value(msg.get("Message-ID")), + "reply_to": decode_header_value(msg.get("Reply-To")), + "references": decode_header_value(msg.get("References")), + "plain": plain, + "html": html, + "attachments": attachments, + } + + +class SMTPClient: + """Simple SMTP client wrapper.""" + + def __init__( + self, + host: str, + port: int, + email: str, + password: str, + ) -> None: + self.host = host + self.port = port + self.email = email + self.password = password + + def send( + self, + to_addrs: list[str], + subject: str, + body: str, + cc_addrs: list[str] | None = None, + bcc_addrs: list[str] | None = None, + attachments: list[str] | None = None, + is_html: bool = False, + ) -> None: + """Send an email message.""" + msg = EmailMessage() + msg["From"] = formataddr((self.email.split("@")[0], self.email)) + msg["To"] = ", ".join(to_addrs) + if cc_addrs: + msg["Cc"] = ", ".join(cc_addrs) + msg["Subject"] = subject + + content_type = "text/html" if is_html else "text/plain" + msg.set_content(body, subtype=content_type.split("/")[1]) + + if attachments: + for path in attachments: + from pathlib import Path + + p = Path(path) + if not p.is_file(): + raise FileNotFoundError(f"Attachment not found: {path}") + data = p.read_bytes() + msg.add_attachment( + data, + maintype="application", + subtype="octet-stream", + filename=p.name, + ) + + all_recipients = list(to_addrs) + if cc_addrs: + all_recipients.extend(cc_addrs) + if bcc_addrs: + all_recipients.extend(bcc_addrs) + + with smtplib.SMTP_SSL(self.host, self.port) as server: + server.login(self.email, self.password) + server.sendmail(self.email, all_recipients, msg.as_string()) + + def send_reply( + self, + original: dict[str, Any], + body: str, + is_html: bool = False, + ) -> None: + """Send a reply to an existing message.""" + to_addr = original.get("reply_to") or original["from"] + if not to_addr: + raise ValueError("Original message has no From or Reply-To address") + + subject = original.get("subject", "") + if not subject.lower().startswith("re:"): + subject = f"Re: {subject}" + + quote_lines = [ + f"> From: {original.get('from', '')}", + f"> To: {original.get('to', '')}", + f"> Subject: {original.get('subject', '')}", + f"> Date: {original.get('date', '')}", + ">", + ] + for line in (original.get("plain") or original.get("html") or "").splitlines(): + quote_lines.append(f"> {line}") + quoted = "\n".join(quote_lines) + + full_body = f"{body}\n\n{quoted}" if quoted.strip("> ") else body + + msg = EmailMessage() + msg["From"] = formataddr((self.email.split("@")[0], self.email)) + msg["To"] = to_addr + msg["Subject"] = subject + + orig_msg_id = original.get("message_id", "").strip() + if orig_msg_id: + msg["In-Reply-To"] = orig_msg_id + refs = original.get("references", "").strip() + msg["References"] = f"{refs} {orig_msg_id}".strip() if refs else orig_msg_id + + content_type = "text/html" if is_html else "text/plain" + msg.set_content(full_body, subtype=content_type.split("/")[1]) + + with smtplib.SMTP_SSL(self.host, self.port) as server: + server.login(self.email, self.password) + server.sendmail(self.email, [to_addr], msg.as_string()) + + def send_forward( + self, + raw_original: bytes, + to_addrs: list[str], + subject: str | None = None, + body: str | None = None, + cc_addrs: list[str] | None = None, + bcc_addrs: list[str] | None = None, + is_html: bool = False, + ) -> None: + """Forward an existing message as a message/rfc822 attachment.""" + from email import message_from_bytes + + orig_msg = message_from_bytes(raw_original) + orig_subject = decode_header_value(orig_msg.get("Subject")) + if subject is None: + subject = orig_subject + if not subject.lower().startswith("fwd:"): + subject = f"Fwd: {subject}" + + msg = EmailMessage() + msg["From"] = formataddr((self.email.split("@")[0], self.email)) + msg["To"] = ", ".join(to_addrs) + if cc_addrs: + msg["Cc"] = ", ".join(cc_addrs) + msg["Subject"] = subject + + if body: + content_type = "text/html" if is_html else "text/plain" + msg.set_content(body, subtype=content_type.split("/")[1]) + else: + msg.set_content("") + + msg.add_attachment( + orig_msg, + maintype="message", + subtype="rfc822", + ) + + all_recipients = list(to_addrs) + if cc_addrs: + all_recipients.extend(cc_addrs) + if bcc_addrs: + all_recipients.extend(bcc_addrs) + + with smtplib.SMTP_SSL(self.host, self.port) as server: + server.login(self.email, self.password) + server.sendmail(self.email, all_recipients, msg.as_string()) diff --git a/myclaude/mail/config.py b/myclaude/mail/config.py new file mode 100644 index 0000000..649ee1d --- /dev/null +++ b/myclaude/mail/config.py @@ -0,0 +1,53 @@ +"""Mail configuration management.""" + +import base64 +import json +import os +from pathlib import Path +from typing import Any + +DEFAULT_IMAP_HOST = "mail.sysu.edu.cn" +DEFAULT_IMAP_PORT = 993 +DEFAULT_SMTP_HOST = "mail.sysu.edu.cn" +DEFAULT_SMTP_PORT = 465 + + +def _config_path() -> Path: + return Path.home() / ".myclaude" / "mail.json" + + +def _ensure_dir(path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + + +def load_config() -> dict[str, Any] | None: + """Load mail config from ~/.myclaude/mail.json.""" + path = _config_path() + if not path.is_file(): + return None + with path.open("r", encoding="utf-8") as f: + data = json.load(f) + # decode password + if data.get("password"): + data["password"] = base64.b64decode(data["password"]).decode("utf-8") + # allow env overrides + email = os.environ.get("MYCLAUDE_MAIL_EMAIL") + password = os.environ.get("MYCLAUDE_MAIL_PASSWORD") + if email: + data["email"] = email + if password: + data["password"] = password + return data + + +def save_config(data: dict[str, Any]) -> None: + """Save mail config to ~/.myclaude/mail.json.""" + path = _config_path() + _ensure_dir(path) + to_save = dict(data) + if to_save.get("password"): + to_save["password"] = base64.b64encode( + to_save["password"].encode("utf-8") + ).decode("ascii") + with path.open("w", encoding="utf-8") as f: + json.dump(to_save, f, indent=2, ensure_ascii=False) diff --git a/myclaude/mail/parser.py b/myclaude/mail/parser.py new file mode 100644 index 0000000..49b9e49 --- /dev/null +++ b/myclaude/mail/parser.py @@ -0,0 +1,72 @@ +"""Email message parsing utilities.""" + +from email.message import Message +from typing import Any + +from myclaude.mail.utils import decode_header_value, strip_html_tags + + +def _extract_payload(part: Message) -> str: + """Extract text payload from a message part.""" + charset = part.get_content_charset() or "utf-8" + payload = part.get_payload(decode=True) + if isinstance(payload, bytes): + return payload.decode(charset, errors="replace") + return str(payload or "") + + +def extract_text(msg: Message) -> tuple[str, str]: + """Extract plain text and html body from a message. + + Returns (plain_text, html_text). Either may be empty. + """ + plain_parts: list[str] = [] + html_parts: list[str] = [] + + if msg.is_multipart(): + for part in msg.walk(): + ctype = part.get_content_type() + cdisp = str(part.get("Content-Disposition", "")) + if "attachment" in cdisp: + continue + if ctype == "text/plain": + plain_parts.append(_extract_payload(part)) + elif ctype == "text/html": + html_parts.append(_extract_payload(part)) + else: + ctype = msg.get_content_type() + payload = _extract_payload(msg) + if ctype == "text/html": + html_parts.append(payload) + else: + plain_parts.append(payload) + + plain = "\n".join(plain_parts).strip() + html = "\n".join(html_parts).strip() + + if not plain and html: + plain = strip_html_tags(html) + + return plain, html + + +def extract_attachments(msg: Message) -> list[dict[str, Any]]: + """Return list of attachment metadata dicts.""" + attachments: list[dict[str, Any]] = [] + if not msg.is_multipart(): + return attachments + + for part in msg.walk(): + cdisp = str(part.get("Content-Disposition", "")) + if "attachment" not in cdisp: + continue + filename = decode_header_value(part.get_filename()) + if filename: + attachments.append( + { + "filename": filename, + "content_type": part.get_content_type(), + "size": len(part.get_payload(decode=True) or b""), + } + ) + return attachments diff --git a/myclaude/mail/utils.py b/myclaude/mail/utils.py new file mode 100644 index 0000000..ca1ae7c --- /dev/null +++ b/myclaude/mail/utils.py @@ -0,0 +1,60 @@ +"""Mail utility helpers.""" + +import re +from datetime import datetime +from email.header import decode_header +from email.utils import mktime_tz, parsedate_tz +from typing import Any + + +def decode_header_value(value: Any) -> str: + """Decode email header value safely.""" + if value is None: + return "" + raw = value if isinstance(value, str) else str(value) + parts = decode_header(raw) + result: list[str] = [] + for part, charset in parts: + if isinstance(part, bytes): + result.append(part.decode(charset or "utf-8", errors="replace")) + else: + result.append(part) + return "".join(result) + + +def format_date(date_str: str | None) -> str: + """Format email date string to local-friendly form.""" + if not date_str: + return "" + try: + tt = parsedate_tz(date_str) + if tt is None: + return date_str + ts = mktime_tz(tt) + dt = datetime.fromtimestamp(ts) + return dt.strftime("%Y-%m-%d %H:%M") + except Exception: + return date_str + + +def strip_html_tags(html: str) -> str: + """Very basic HTML tag stripping for fallback text extraction.""" + text = re.sub(r"]*>.*?", "", html, flags=re.S) + text = re.sub(r"]*>.*?", "", text, flags=re.S) + # Replace block-level tags with newlines before stripping remaining tags + text = re.sub(r"", "\n", text, flags=re.I) + text = re.sub(r"", "\n", text, flags=re.I) + text = re.sub(r"<[^>]+>", "", text) + text = re.sub(r" ", " ", text) + text = re.sub(r"<", "<", text) + text = re.sub(r">", ">", text) + text = re.sub(r"&", "&", text) + text = re.sub(r"\n\s*\n+", "\n\n", text) + return text.strip() + + +def truncate_text(text: str, max_len: int = 60) -> str: + """Truncate text with ellipsis.""" + if len(text) <= max_len: + return text + return text[: max_len - 1] + "…"