diff --git a/bin/commands/mail.py b/bin/commands/mail.py index e84cd5c..0e5465e 100644 --- a/bin/commands/mail.py +++ b/bin/commands/mail.py @@ -7,6 +7,7 @@ import re import smtplib from datetime import datetime from email import policy +from email.header import decode_header from email.message import EmailMessage from email.parser import BytesParser from pathlib import Path @@ -51,6 +52,40 @@ def get_smtp(cfg: dict): return smtp +def decode_mime_str(s: str) -> str: + """Decode MIME-encoded header string (e.g. =?utf-8?...?=) to plain text.""" + if not s: + return "" + parts = decode_header(s) + out = [] + for value, charset in parts: + if isinstance(value, bytes): + try: + out.append(value.decode(charset or "utf-8", errors="replace")) + except (LookupError, TypeError): + out.append(value.decode("utf-8", errors="replace")) + else: + out.append(value) + return "".join(out) + + +def uid_fetch(imap, uid, parts): + """Fetch by real IMAP UID. Returns raw bytes or empty bytes if not found.""" + typ, msg_data = imap.uid("FETCH", str(uid), parts) + if typ != "OK" or not msg_data or not msg_data[0]: + return b"" + return msg_data[0][1] + + +def safe_filename(name: str) -> str: + """Strip path separators from a filename to prevent traversal.""" + name = decode_mime_str(name).strip() + name = name.replace("/", "_").replace("\\", "_") + if name in ("", ".", ".."): + return "attachment" + return name + + @click.group() def mail(): """Mail client (IMAP/SMTP). No send command — use draft instead.""" @@ -86,22 +121,21 @@ def config(imap_host, imap_port, smtp_host, smtp_port, email, password): @click.option("--unseen", "-u", is_flag=True, help="Show only unseen messages") @handle_errors def inbox(limit, unseen): - """List inbox messages.""" + """List inbox messages. UID column is the real IMAP UID (stable across sessions).""" cfg = load_config() imap = get_imap(cfg) - imap.select("INBOX") + imap.select("INBOX", readonly=True) criterion = "UNSEEN" if unseen else "ALL" - _, data = imap.search(None, criterion) + _, data = imap.uid("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(f"{'UID':>10} {'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"" + raw_header = uid_fetch(imap, uid.decode(), "(RFC822.HEADER)") header = BytesParser(policy=policy.default).parsebytes(raw_header) subject = header["Subject"] or "(no subject)" @@ -116,7 +150,7 @@ def inbox(limit, unseen): except ValueError: date_str = str(header["Date"])[:19] - click.echo(f"{uid.decode():>6} {date_str:<20} {from_addr:<30} {subject[:50]}") + click.echo(f"{uid.decode():>10} {date_str:<20} {from_addr:<30} {subject[:50]}") imap.close() imap.logout() @@ -127,13 +161,12 @@ def inbox(limit, unseen): @click.option("--raw", is_flag=True, help="Show raw headers") @handle_errors def read(uid, raw): - """Read a message by UID.""" + """Read a message by IMAP UID.""" cfg = load_config() imap = get_imap(cfg) - imap.select("INBOX") + imap.select("INBOX", readonly=True) - _, msg_data = imap.fetch(str(uid), "(RFC822)") - raw_msg = msg_data[0][1] if msg_data[0] else b"" + raw_msg = uid_fetch(imap, uid, "(RFC822)") msg = BytesParser(policy=policy.default).parsebytes(raw_msg) if raw: @@ -143,6 +176,15 @@ def read(uid, raw): click.echo(f"To: {msg['To']}") click.echo(f"Date: {msg['Date']}") click.echo(f"Subject: {msg['Subject']}") + + attachments = [ + safe_filename(p.get_filename() or "") + for p in msg.walk() + if p.get_content_disposition() == "attachment" and p.get_filename() + ] + if attachments: + click.echo(f"Attachments: {', '.join(attachments)}") + click.echo("-" * 60) body = msg.get_body(preferencelist=("plain",)) if body: @@ -154,6 +196,64 @@ def read(uid, raw): imap.logout() +@mail.command() +@click.argument("uid", type=int) +@click.option("--out", "-o", "out_dir", default=".", help="Output directory (default: current dir)") +@click.option("--inline", is_flag=True, help="Also save inline parts (e.g. embedded images)") +@handle_errors +def download(uid, out_dir, inline): + """Download attachments from a message by IMAP UID.""" + cfg = load_config() + imap = get_imap(cfg) + imap.select("INBOX", readonly=True) + + raw_msg = uid_fetch(imap, uid, "(RFC822)") + imap.close() + imap.logout() + + if not raw_msg: + click.echo(f"Message UID {uid} not found.", err=True) + raise click.Abort() + + msg = BytesParser(policy=policy.default).parsebytes(raw_msg) + out = Path(out_dir).expanduser() + out.mkdir(parents=True, exist_ok=True) + + wanted = {"attachment"} + if inline: + wanted.add("inline") + + saved = [] + for part in msg.walk(): + cd = part.get_content_disposition() + if cd not in wanted: + continue + fn = part.get_filename() + if not fn: + continue + fn = safe_filename(fn) + target = out / fn + # avoid overwriting existing files + i = 1 + while target.exists(): + stem, suffix = target.stem, target.suffix + target = out / f"{stem}-{i}{suffix}" + i += 1 + payload = part.get_payload(decode=True) + if not isinstance(payload, (bytes, bytearray)): + continue + target.write_bytes(payload) + saved.append((target, len(payload), cd)) + + if not saved: + click.echo(f"No attachments in UID {uid}.") + return + + for target, size, cd in saved: + marker = "[inline]" if cd == "inline" else " " + click.echo(f"{marker} {target} ({size} bytes)") + + @mail.command() @click.option("--to", required=True, help="Recipient address") @click.option("--cc", help="CC addresses (comma-separated)") @@ -210,15 +310,19 @@ def draft(to, cc, subject, body, attach, html): @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).""" + """Reply to a message by IMAP UID (draft mode — saves to Drafts).""" cfg = load_config() body = body.replace("\\n", "\n").replace("\\t", "\t") imap = get_imap(cfg) - imap.select("INBOX") + imap.select("INBOX", readonly=True) - _, msg_data = imap.fetch(str(uid), "(RFC822)") - raw_orig = msg_data[0][1] if msg_data[0] else b"" + raw_orig = uid_fetch(imap, uid, "(RFC822)") + if not raw_orig: + imap.close() + imap.logout() + click.echo(f"Message UID {uid} not found.", err=True) + raise click.Abort() original = BytesParser(policy=policy.default).parsebytes(raw_orig) msg = EmailMessage() @@ -248,15 +352,19 @@ def reply(uid, body, html): @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).""" + """Forward a message by IMAP UID (draft mode — saves to Drafts).""" cfg = load_config() body = body.replace("\\n", "\n").replace("\\t", "\t") imap = get_imap(cfg) - imap.select("INBOX") + imap.select("INBOX", readonly=True) - _, msg_data = imap.fetch(str(uid), "(RFC822)") - raw_orig = msg_data[0][1] if msg_data[0] else b"" + raw_orig = uid_fetch(imap, uid, "(RFC822)") + if not raw_orig: + imap.close() + imap.logout() + click.echo(f"Message UID {uid} not found.", err=True) + raise click.Abort() original = BytesParser(policy=policy.default).parsebytes(raw_orig) msg = EmailMessage()