add mail command (IMAP/SMTP client, no send, draft-only)

This commit is contained in:
Zhengshou Lai
2026-04-26 10:47:38 +08:00
parent c1df4098b3
commit 04cbd9786e
2 changed files with 285 additions and 1 deletions
+2 -1
View File
@@ -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)
+283
View File
@@ -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", "<br>\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}.")