feat(mail): add download command, stable UID fetch, MIME decode
- Add `mail download` to save attachments by IMAP UID - Switch inbox/read/reply/forward to real IMAP UID (stable across sessions) - Add MIME header decoding for non-ASCII filenames - Add safe_filename() to prevent path traversal - Use readonly mode for inbox/list/read to avoid marking unseen
This commit is contained in:
+127
-19
@@ -7,6 +7,7 @@ import re
|
|||||||
import smtplib
|
import smtplib
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from email import policy
|
from email import policy
|
||||||
|
from email.header import decode_header
|
||||||
from email.message import EmailMessage
|
from email.message import EmailMessage
|
||||||
from email.parser import BytesParser
|
from email.parser import BytesParser
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -51,6 +52,40 @@ def get_smtp(cfg: dict):
|
|||||||
return smtp
|
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()
|
@click.group()
|
||||||
def mail():
|
def mail():
|
||||||
"""Mail client (IMAP/SMTP). No send command — use draft instead."""
|
"""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")
|
@click.option("--unseen", "-u", is_flag=True, help="Show only unseen messages")
|
||||||
@handle_errors
|
@handle_errors
|
||||||
def inbox(limit, unseen):
|
def inbox(limit, unseen):
|
||||||
"""List inbox messages."""
|
"""List inbox messages. UID column is the real IMAP UID (stable across sessions)."""
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
imap = get_imap(cfg)
|
imap = get_imap(cfg)
|
||||||
imap.select("INBOX")
|
imap.select("INBOX", readonly=True)
|
||||||
|
|
||||||
criterion = "UNSEEN" if unseen else "ALL"
|
criterion = "UNSEEN" if unseen else "ALL"
|
||||||
_, data = imap.search(None, criterion)
|
_, data = imap.uid("SEARCH", None, criterion)
|
||||||
uids = data[0].split()
|
uids = data[0].split()
|
||||||
uids = uids[-limit:] if len(uids) > limit else uids
|
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)
|
click.echo("-" * 100)
|
||||||
|
|
||||||
for uid in reversed(uids):
|
for uid in reversed(uids):
|
||||||
_, msg_data = imap.fetch(uid, "(RFC822.HEADER)")
|
raw_header = uid_fetch(imap, uid.decode(), "(RFC822.HEADER)")
|
||||||
raw_header = msg_data[0][1] if msg_data[0] else b""
|
|
||||||
header = BytesParser(policy=policy.default).parsebytes(raw_header)
|
header = BytesParser(policy=policy.default).parsebytes(raw_header)
|
||||||
|
|
||||||
subject = header["Subject"] or "(no subject)"
|
subject = header["Subject"] or "(no subject)"
|
||||||
@@ -116,7 +150,7 @@ def inbox(limit, unseen):
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
date_str = str(header["Date"])[:19]
|
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.close()
|
||||||
imap.logout()
|
imap.logout()
|
||||||
@@ -127,13 +161,12 @@ def inbox(limit, unseen):
|
|||||||
@click.option("--raw", is_flag=True, help="Show raw headers")
|
@click.option("--raw", is_flag=True, help="Show raw headers")
|
||||||
@handle_errors
|
@handle_errors
|
||||||
def read(uid, raw):
|
def read(uid, raw):
|
||||||
"""Read a message by UID."""
|
"""Read a message by IMAP UID."""
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
imap = get_imap(cfg)
|
imap = get_imap(cfg)
|
||||||
imap.select("INBOX")
|
imap.select("INBOX", readonly=True)
|
||||||
|
|
||||||
_, msg_data = imap.fetch(str(uid), "(RFC822)")
|
raw_msg = uid_fetch(imap, uid, "(RFC822)")
|
||||||
raw_msg = msg_data[0][1] if msg_data[0] else b""
|
|
||||||
msg = BytesParser(policy=policy.default).parsebytes(raw_msg)
|
msg = BytesParser(policy=policy.default).parsebytes(raw_msg)
|
||||||
|
|
||||||
if raw:
|
if raw:
|
||||||
@@ -143,6 +176,15 @@ def read(uid, raw):
|
|||||||
click.echo(f"To: {msg['To']}")
|
click.echo(f"To: {msg['To']}")
|
||||||
click.echo(f"Date: {msg['Date']}")
|
click.echo(f"Date: {msg['Date']}")
|
||||||
click.echo(f"Subject: {msg['Subject']}")
|
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)
|
click.echo("-" * 60)
|
||||||
body = msg.get_body(preferencelist=("plain",))
|
body = msg.get_body(preferencelist=("plain",))
|
||||||
if body:
|
if body:
|
||||||
@@ -154,6 +196,64 @@ def read(uid, raw):
|
|||||||
imap.logout()
|
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()
|
@mail.command()
|
||||||
@click.option("--to", required=True, help="Recipient address")
|
@click.option("--to", required=True, help="Recipient address")
|
||||||
@click.option("--cc", help="CC addresses (comma-separated)")
|
@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")
|
@click.option("--html", is_flag=True, help="Body is HTML")
|
||||||
@handle_errors
|
@handle_errors
|
||||||
def reply(uid, body, html):
|
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()
|
cfg = load_config()
|
||||||
body = body.replace("\\n", "\n").replace("\\t", "\t")
|
body = body.replace("\\n", "\n").replace("\\t", "\t")
|
||||||
|
|
||||||
imap = get_imap(cfg)
|
imap = get_imap(cfg)
|
||||||
imap.select("INBOX")
|
imap.select("INBOX", readonly=True)
|
||||||
|
|
||||||
_, msg_data = imap.fetch(str(uid), "(RFC822)")
|
raw_orig = uid_fetch(imap, uid, "(RFC822)")
|
||||||
raw_orig = msg_data[0][1] if msg_data[0] else b""
|
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)
|
original = BytesParser(policy=policy.default).parsebytes(raw_orig)
|
||||||
|
|
||||||
msg = EmailMessage()
|
msg = EmailMessage()
|
||||||
@@ -248,15 +352,19 @@ def reply(uid, body, html):
|
|||||||
@click.option("--html", is_flag=True, help="Body is HTML")
|
@click.option("--html", is_flag=True, help="Body is HTML")
|
||||||
@handle_errors
|
@handle_errors
|
||||||
def forward(uid, to, body, html):
|
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()
|
cfg = load_config()
|
||||||
body = body.replace("\\n", "\n").replace("\\t", "\t")
|
body = body.replace("\\n", "\n").replace("\\t", "\t")
|
||||||
|
|
||||||
imap = get_imap(cfg)
|
imap = get_imap(cfg)
|
||||||
imap.select("INBOX")
|
imap.select("INBOX", readonly=True)
|
||||||
|
|
||||||
_, msg_data = imap.fetch(str(uid), "(RFC822)")
|
raw_orig = uid_fetch(imap, uid, "(RFC822)")
|
||||||
raw_orig = msg_data[0][1] if msg_data[0] else b""
|
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)
|
original = BytesParser(policy=policy.default).parsebytes(raw_orig)
|
||||||
|
|
||||||
msg = EmailMessage()
|
msg = EmailMessage()
|
||||||
|
|||||||
Reference in New Issue
Block a user