"""Mail client for SYSU Coremail (IMAP/SMTP).""" import base64 import imaplib import json import re import smtplib from datetime import datetime from email import policy from email.message import EmailMessage from email.parser import BytesParser from pathlib import Path import click from bin.utils import handle_errors CONFIG_PATH = Path.home() / ".myclaude" / "mail.json" def load_config(): """Load mail config.""" if not CONFIG_PATH.exists(): click.echo("Mail not configured. Run: mytoolkit mail config", err=True) raise click.Abort() with open(CONFIG_PATH, encoding="utf-8") as f: return json.load(f) def decode_password(pwd: str) -> str: """Decode base64-encoded password.""" try: return base64.b64decode(pwd).decode("utf-8") except Exception: return pwd def get_imap(cfg: dict): """Connect to IMAP server.""" mail = imaplib.IMAP4_SSL(cfg["imap_host"], cfg.get("imap_port", 993)) pwd = decode_password(cfg["password"]) mail.login(cfg["email"], pwd) return mail def get_smtp(cfg: dict): """Connect to SMTP server.""" smtp = smtplib.SMTP_SSL(cfg["smtp_host"], cfg.get("smtp_port", 465)) pwd = decode_password(cfg["password"]) smtp.login(cfg["email"], pwd) return smtp @click.group() def mail(): """Mail client (IMAP/SMTP). No send command — use draft instead.""" pass @mail.command() @click.option("--imap-host", default="mail.sysu.edu.cn", help="IMAP server host") @click.option("--imap-port", default=993, type=int, help="IMAP server port") @click.option("--smtp-host", default="mail.sysu.edu.cn", help="SMTP server host") @click.option("--smtp-port", default=465, type=int, help="SMTP server port") @click.option("--email", prompt="Email address", help="Email address") @click.option("--password", prompt="Password", hide_input=True, help="Email password") @handle_errors def config(imap_host, imap_port, smtp_host, smtp_port, email, password): """Configure mail account.""" CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) cfg = { "imap_host": imap_host, "imap_port": imap_port, "smtp_host": smtp_host, "smtp_port": smtp_port, "email": email, "password": base64.b64encode(password.encode()).decode(), } with open(CONFIG_PATH, "w", encoding="utf-8") as f: json.dump(cfg, f, indent=2) click.echo(f"Config saved to {CONFIG_PATH}") @mail.command() @click.option("--limit", "-n", default=20, type=int, help="Number of messages to show") @click.option("--unseen", "-u", is_flag=True, help="Show only unseen messages") @handle_errors def inbox(limit, unseen): """List inbox messages.""" cfg = load_config() imap = get_imap(cfg) imap.select("INBOX") criterion = "UNSEEN" if unseen else "ALL" _, data = imap.search(None, criterion) uids = data[0].split() uids = uids[-limit:] if len(uids) > limit else uids click.echo(f"{'UID':>6} {'Date':<20} {'From':<30} Subject") click.echo("-" * 100) for uid in reversed(uids): _, msg_data = imap.fetch(uid, "(RFC822.HEADER)") raw_header = msg_data[0][1] if msg_data[0] else b"" header = BytesParser(policy=policy.default).parsebytes(raw_header) subject = header["Subject"] or "(no subject)" from_addr = header["From"] or "" from_addr = re.sub(r"<.*>", "", from_addr).strip() or from_addr from_addr = from_addr[:28] date_str = "" if header["Date"]: try: dt = datetime.strptime(header["Date"][:31], "%a, %d %b %Y %H:%M:%S") date_str = dt.strftime("%Y-%m-%d %H:%M") except ValueError: date_str = str(header["Date"])[:19] click.echo(f"{uid.decode():>6} {date_str:<20} {from_addr:<30} {subject[:50]}") imap.close() imap.logout() @mail.command() @click.argument("uid", type=int) @click.option("--raw", is_flag=True, help="Show raw headers") @handle_errors def read(uid, raw): """Read a message by UID.""" cfg = load_config() imap = get_imap(cfg) imap.select("INBOX") _, msg_data = imap.fetch(str(uid), "(RFC822)") raw_msg = msg_data[0][1] if msg_data[0] else b"" msg = BytesParser(policy=policy.default).parsebytes(raw_msg) if raw: click.echo(msg.as_string()) else: click.echo(f"From: {msg['From']}") click.echo(f"To: {msg['To']}") click.echo(f"Date: {msg['Date']}") click.echo(f"Subject: {msg['Subject']}") click.echo("-" * 60) body = msg.get_body(preferencelist=("plain",)) if body: click.echo(body.get_content()) else: click.echo("(no plain text body)") imap.close() imap.logout() @mail.command() @click.option("--to", required=True, help="Recipient address") @click.option("--cc", help="CC addresses (comma-separated)") @click.option("--subject", "-s", required=True, help="Subject") @click.option("--body", "-b", required=True, help="Body text (\\n expanded)") @click.option("--attach", multiple=True, help="Attachment path(s)") @click.option("--html", is_flag=True, help="Body is HTML") @handle_errors def draft(to, cc, subject, body, attach, html): """Create a draft email (does NOT send). Saves to drafts folder.""" cfg = load_config() body = body.replace("\\n", "\n").replace("\\t", "\t") msg = EmailMessage() msg["From"] = cfg["email"] msg["To"] = to if cc: msg["Cc"] = cc msg["Subject"] = subject if html: msg.set_content("Draft email (HTML)", subtype="plain") msg.add_alternative(body, subtype="html") else: msg.set_content(body) for path in attach: p = Path(path) if not p.exists(): click.echo(f"Attachment not found: {path}", err=True) continue with open(p, "rb") as f: msg.add_attachment( f.read(), maintype="application", subtype="octet-stream", filename=p.name, ) imap = get_imap(cfg) imap.select("INBOX") imap.append("Drafts", None, None, msg.as_bytes()) imap.close() imap.logout() click.echo("Draft saved to Drafts folder.") click.echo(f" To: {to}") click.echo(f" Subject: {subject}") @mail.command() @click.argument("uid", type=int) @click.option("--body", "-b", required=True, help="Reply body") @click.option("--html", is_flag=True, help="Body is HTML") @handle_errors def reply(uid, body, html): """Reply to a message by UID (draft mode — saves to Drafts).""" cfg = load_config() body = body.replace("\\n", "\n").replace("\\t", "\t") imap = get_imap(cfg) imap.select("INBOX") _, msg_data = imap.fetch(str(uid), "(RFC822)") raw_orig = msg_data[0][1] if msg_data[0] else b"" original = BytesParser(policy=policy.default).parsebytes(raw_orig) msg = EmailMessage() msg["From"] = cfg["email"] msg["To"] = original["Reply-To"] or original["From"] msg["Subject"] = f"Re: {original['Subject']}" if not str(original['Subject'] or '').startswith("Re:") else str(original['Subject']) msg["In-Reply-To"] = original["Message-ID"] msg["References"] = original["References"] or original["Message-ID"] if html: msg.set_content("Reply draft", subtype="plain") msg.add_alternative(body, subtype="html") else: msg.set_content(body) imap.append("Drafts", None, None, msg.as_bytes()) imap.close() imap.logout() click.echo(f"Reply draft saved for UID {uid}.") @mail.command() @click.argument("uid", type=int) @click.option("--to", required=True, help="Forward to") @click.option("--body", "-b", default="", help="Additional body text") @click.option("--html", is_flag=True, help="Body is HTML") @handle_errors def forward(uid, to, body, html): """Forward a message by UID (draft mode — saves to Drafts).""" cfg = load_config() body = body.replace("\\n", "\n").replace("\\t", "\t") imap = get_imap(cfg) imap.select("INBOX") _, msg_data = imap.fetch(str(uid), "(RFC822)") raw_orig = msg_data[0][1] if msg_data[0] else b"" original = BytesParser(policy=policy.default).parsebytes(raw_orig) msg = EmailMessage() msg["From"] = cfg["email"] msg["To"] = to msg["Subject"] = f"Fwd: {original['Subject']}" orig_body = "" b = original.get_body(preferencelist=("plain",)) if b: orig_body = b.get_content() if html: msg.set_content("Forwarded message", subtype="plain") content = f"{body}\n\n--- Forwarded message ---\nFrom: {original['From']}\nDate: {original['Date']}\nSubject: {original['Subject']}\n\n{orig_body}" msg.add_alternative(content.replace("\n", "
\n"), subtype="html") else: msg.set_content(f"{body}\n\n--- Forwarded message ---\nFrom: {original['From']}\nDate: {original['Date']}\nSubject: {original['Subject']}\n\n{orig_body}") imap.append("Drafts", None, None, msg.as_bytes()) imap.close() imap.logout() click.echo(f"Forward draft saved for UID {uid}.")