add mail command (IMAP/SMTP client, no send, draft-only)
This commit is contained in:
@@ -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}.")
|
||||
Reference in New Issue
Block a user