From 04cbd9786e55e55bfb86fbbbcb4a990c81e3cfee Mon Sep 17 00:00:00 2001 From: Zhengshou Lai Date: Sun, 26 Apr 2026 10:47:38 +0800 Subject: [PATCH] add mail command (IMAP/SMTP client, no send, draft-only) --- bin/cli.py | 3 +- bin/commands/mail.py | 283 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 285 insertions(+), 1 deletion(-) create mode 100644 bin/commands/mail.py diff --git a/bin/cli.py b/bin/cli.py index 2a3e56a..58e4912 100644 --- a/bin/cli.py +++ b/bin/cli.py @@ -2,7 +2,7 @@ import click -from bin.commands import pdf, image, latex, video, bib, utils, env_cmd, ssh_cmd, git_cmd +from bin.commands import pdf, image, latex, video, bib, utils, env_cmd, ssh_cmd, git_cmd, mail from bin.commands.self_mgmt import update_cmd, uninstall_cmd, self_cmd @@ -23,6 +23,7 @@ cli.add_command(utils.utils) cli.add_command(env_cmd) cli.add_command(ssh_cmd) cli.add_command(git_cmd) +cli.add_command(mail.mail) cli.add_command(update_cmd) cli.add_command(uninstall_cmd) cli.add_command(self_cmd) diff --git a/bin/commands/mail.py b/bin/commands/mail.py new file mode 100644 index 0000000..ad9bcbf --- /dev/null +++ b/bin/commands/mail.py @@ -0,0 +1,283 @@ +"""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}.")