From cd19e599cc124665908e2d50dff81fd5de81aaed Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 16 Sep 2025 04:42:53 +0000 Subject: [PATCH] feat: Add IMAP client with CLI Co-authored-by: 723943634 <723943634@qq.com> --- README.md | 49 ++++ imap_client/__init__.py | 5 + imap_client/__main__.py | 5 + .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 241 bytes .../__pycache__/__main__.cpython-313.pyc | Bin 0 -> 234 bytes imap_client/__pycache__/cli.cpython-313.pyc | Bin 0 -> 11480 bytes .../__pycache__/client.cpython-313.pyc | Bin 0 -> 13676 bytes imap_client/cli.py | 202 ++++++++++++++ imap_client/client.py | 263 ++++++++++++++++++ requirements.txt | 1 + 10 files changed, 525 insertions(+) create mode 100644 README.md create mode 100644 imap_client/__init__.py create mode 100644 imap_client/__main__.py create mode 100644 imap_client/__pycache__/__init__.cpython-313.pyc create mode 100644 imap_client/__pycache__/__main__.cpython-313.pyc create mode 100644 imap_client/__pycache__/cli.cpython-313.pyc create mode 100644 imap_client/__pycache__/client.cpython-313.pyc create mode 100644 imap_client/cli.py create mode 100644 imap_client/client.py create mode 100644 requirements.txt diff --git a/README.md b/README.md new file mode 100644 index 0000000..d9bf464 --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +# IMAP 邮件客户端 + +一个基于 Python 标准库的 IMAP 邮件客户端,提供命令行接口: + +- 列出邮箱(mailboxes) +- 列出邮件(按邮箱、查询条件) +- 显示邮件头/正文 +- 下载附件 +- 标记已读/未读、加星/去星 + +## 安装 + +Python 3.9+。无需额外依赖。 + +## 使用 + +```bash +python -m imap_client --help +``` + +常用示例: + +```bash +# 登录并列出邮箱 +python -m imap_client --host imap.example.com --user alice list-mailboxes + +# 列出 INBOX 最近 20 封 +python -m imap_client --host imap.example.com --user alice list --mailbox INBOX --limit 20 + +# 显示某封邮件 +python -m imap_client --host imap.example.com --user alice show --mailbox INBOX --uid 123 + +# 下载附件到当前目录 +python -m imap_client --host imap.example.com --user alice download --mailbox INBOX --uid 123 --out . + +# 标记已读 +python -m imap_client --host imap.example.com --user alice mark --mailbox INBOX --uid 123 --seen true +``` + +可以通过环境变量传递密码:`IMAP_PASSWORD`。也支持交互式提示输入密码。 + +## 安全性 + +- 优先使用 IMAPS (SSL/TLS) 993 端口;如需明文 143,可设置 `--port 143 --starttls`。 +- 密码仅用于会话;不写入磁盘。 + +## 许可证 + +MIT \ No newline at end of file diff --git a/imap_client/__init__.py b/imap_client/__init__.py new file mode 100644 index 0000000..76f3cd9 --- /dev/null +++ b/imap_client/__init__.py @@ -0,0 +1,5 @@ +__all__ = ["IMAPClient", "__version__"] + +__version__ = "0.1.0" + +from .client import IMAPClient # noqa: E402 \ No newline at end of file diff --git a/imap_client/__main__.py b/imap_client/__main__.py new file mode 100644 index 0000000..f5e7aa8 --- /dev/null +++ b/imap_client/__main__.py @@ -0,0 +1,5 @@ +from .cli import main + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/imap_client/__pycache__/__init__.cpython-313.pyc b/imap_client/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f1d8befc21f2b6011c7f20ab0fa6cab2f21a2558 GIT binary patch literal 241 zcmey&%ge<81U;`#WaI$p#~=<2FhLogWq^#S48aV+jQUJP48crAjKR#oEZ$6B%tg!! z4C$#m85%8t5778N6f!8llNp1TxZ3ljRnB ze0*X~PJDb3Gf?0bTQXQ9Sav1DXP^+nEhYW({G#mQg2d!h{mk6Nf_R7;{rLFIyv&mL zc)fzkTO2mI`6;D2sdhymPk^i_76%d^m>C%vZ*YsxkiEdA-oXBVPqcyi8G~FA2T%?G DI}kiP literal 0 HcmV?d00001 diff --git a/imap_client/__pycache__/__main__.cpython-313.pyc b/imap_client/__pycache__/__main__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d592a1cfdaf889feec933427b3420234a42857b7 GIT binary patch literal 234 zcmey&%ge<81dCst$gl;{k3k$5V1hC}>i`*38G;#t8NC_27>gJc7-E=$nXDoh7!o-c z7-ATe7)+r`6&TW))0s6{UNQm&G#PKPB^l?D*`zj rWKFRQkodsN$jEq?LG%L~0}D?_)n#VMn=BlyZXcK#Sfq-8j${A;p(Hm{ literal 0 HcmV?d00001 diff --git a/imap_client/__pycache__/cli.cpython-313.pyc b/imap_client/__pycache__/cli.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c421f9dd58a5a455c5f9b2a73cf4a2a7969c3d1b GIT binary patch literal 11480 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 literal 0 HcmV?d00001 diff --git a/imap_client/__pycache__/client.cpython-313.pyc b/imap_client/__pycache__/client.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f41690bd0fc83a2597e1134184c3eb3a574a6bc5 GIT binary patch literal 13676 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 int: + try: + ivalue = int(value) + except Exception: + raise argparse.ArgumentTypeError("must be an integer") + if ivalue <= 0: + raise argparse.ArgumentTypeError("must be > 0") + return ivalue + + +def add_common_args(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--host", required=True, help="IMAP server host") + parser.add_argument("--port", type=int, default=993, help="IMAP server port (default 993)") + parser.add_argument("--user", required=True, help="Username") + parser.add_argument("--password", help="Password (use IMAP_PASSWORD env or prompt if omitted)") + parser.add_argument("--no-ssl", action="store_true", help="Disable SSL (use IMAP on 143)") + parser.add_argument("--starttls", action="store_true", help="Use STARTTLS after connecting (when --no-ssl)") + parser.add_argument("--timeout", type=int, default=None, help="Socket timeout seconds") + parser.add_argument("--debug", action="store_true", help="Enable imaplib debug output") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="imap_client", + description="Simple IMAP client CLI", + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + # list-mailboxes + p_list_mbx = subparsers.add_parser("list-mailboxes", help="List mailboxes") + add_common_args(p_list_mbx) + + # list + p_list = subparsers.add_parser("list", help="List messages in a mailbox") + add_common_args(p_list) + p_list.add_argument("--mailbox", default="INBOX", help="Mailbox name (default INBOX)") + p_list.add_argument("--criteria", default="ALL", help="IMAP SEARCH criteria (default ALL)") + p_list.add_argument("--limit", type=positive_int, default=50, help="Max messages to show (default 50)") + + # show + p_show = subparsers.add_parser("show", help="Show one message headers") + add_common_args(p_show) + p_show.add_argument("--mailbox", default="INBOX") + p_show.add_argument("--uid", type=int, required=True, help="Message UID") + + # download + p_dl = subparsers.add_parser("download", help="Download attachments") + add_common_args(p_dl) + p_dl.add_argument("--mailbox", default="INBOX") + p_dl.add_argument("--uid", type=int, required=True) + p_dl.add_argument("--out", default=".", help="Destination directory") + p_dl.add_argument("--name-contains", default=None, help="Only save attachments whose name contains substring") + + # mark + p_mark = subparsers.add_parser("mark", help="Set flags on a message") + add_common_args(p_mark) + p_mark.add_argument("--mailbox", default="INBOX") + p_mark.add_argument("--uid", type=int, required=True) + p_mark.add_argument("--seen", choices=["true", "false"], help="Mark seen/unseen") + p_mark.add_argument("--flagged", choices=["true", "false"], help="Mark flagged/unflagged") + + return parser + + +def get_password(args_password: Optional[str]) -> str: + if args_password: + return args_password + env = os.getenv("IMAP_PASSWORD") + if env: + return env + return getpass.getpass("IMAP password: ") + + +def make_client(args: argparse.Namespace) -> IMAPClient: + password = get_password(args.password) + return IMAPClient( + host=args.host, + port=args.port, + username=args.user, + password=password, + use_ssl=not args.no_ssl, + starttls=args.starttls, + timeout=args.timeout, + debug=args.debug, + ) + + +def cmd_list_mailboxes(args: argparse.Namespace) -> int: + with make_client(args) as client: + names = client.list_mailboxes() + for name in names: + print(name) + return 0 + + +def cmd_list(args: argparse.Namespace) -> int: + with make_client(args) as client: + uids = client.search_uids(args.mailbox, args.criteria) + if not uids: + return 0 + # Show latest first + uids_sorted = sorted(uids, reverse=True)[: args.limit] + items = client.fetch_overview(args.mailbox, uids_sorted) + # Sort by the same order as uids_sorted + order = {uid: i for i, uid in enumerate(uids_sorted)} + items.sort(key=lambda x: order.get(x.get("uid", 0), 0)) + for it in items: + uid = it.get("uid") + flags = " ".join(it.get("flags", [])) + subj = it.get("subject", "") + frm = it.get("from", "") + date = it.get("date", "") + print(f"{uid}\t{date}\t{frm}\t{subj}\t{flags}") + return 0 + + +def cmd_show(args: argparse.Namespace) -> int: + from email import policy + from email.parser import BytesParser + + with make_client(args) as client: + msg = client.fetch_message(args.mailbox, args.uid) + # Headers summary + print(f"UID: {args.uid}") + for key in ["From", "To", "Cc", "Date", "Subject"]: + val = msg.get(key) + if val: + print(f"{key}: {val}") + # Show text/plain part (if any) + text = None + if msg.is_multipart(): + for part in msg.walk(): + if part.get_content_type() == "text/plain": + text = part.get_payload(decode=True) + charset = part.get_content_charset() or "utf-8" + try: + print() + print(text.decode(charset, errors="replace")) + except Exception: + print() + print(text.decode("utf-8", errors="replace")) + break + else: + if msg.get_content_type() == "text/plain": + text = msg.get_payload(decode=True) + charset = msg.get_content_charset() or "utf-8" + print() + print(text.decode(charset, errors="replace")) + return 0 + + +def cmd_download(args: argparse.Namespace) -> int: + with make_client(args) as client: + saved = client.download_attachments(args.mailbox, args.uid, args.out, args.name_contains) + for path in saved: + print(path) + return 0 + + +def cmd_mark(args: argparse.Namespace) -> int: + seen = None if args.seen is None else (args.seen == "true") + flagged = None if args.flagged is None else (args.flagged == "true") + with make_client(args) as client: + client.set_flags(args.mailbox, args.uid, seen=seen, flagged=flagged) + return 0 + + +def main(argv: Optional[List[str]] = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + try: + if args.command == "list-mailboxes": + return cmd_list_mailboxes(args) + if args.command == "list": + return cmd_list(args) + if args.command == "show": + return cmd_show(args) + if args.command == "download": + return cmd_download(args) + if args.command == "mark": + return cmd_mark(args) + parser.error("unknown command") + return 2 + except KeyboardInterrupt: + print("Interrupted", file=sys.stderr) + return 130 + except Exception as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file diff --git a/imap_client/client.py b/imap_client/client.py new file mode 100644 index 0000000..9d7fb17 --- /dev/null +++ b/imap_client/client.py @@ -0,0 +1,263 @@ +import imaplib +import ssl +import os +import re +from typing import Iterable, List, Optional, Tuple, Dict, Any +from email import message_from_bytes +from email.header import decode_header +from email.message import Message + + +def decode_mime_words(value: Optional[str]) -> str: + if not value: + return "" + decoded_parts: List[str] = [] + for bytes_or_str, encoding in decode_header(value): + if isinstance(bytes_or_str, bytes): + try: + decoded_parts.append(bytes_or_str.decode(encoding or "utf-8", errors="replace")) + except LookupError: + decoded_parts.append(bytes_or_str.decode("utf-8", errors="replace")) + else: + decoded_parts.append(bytes_or_str) + return "".join(decoded_parts) + + +def sanitize_filename(filename: str) -> str: + # Remove directory separators and control chars + filename = re.sub(r"[\\/\0\r\n\t]", "_", filename) + filename = filename.strip() or "attachment.bin" + return filename + + +class IMAPClient: + """Thin wrapper around imaplib for common IMAP operations.""" + + def __init__( + self, + host: str, + port: int, + username: str, + password: str, + use_ssl: bool = True, + starttls: bool = False, + timeout: Optional[int] = None, + debug: bool = False, + ) -> None: + self.host = host + self.port = port + self.username = username + self.password = password + self.use_ssl = use_ssl + self.starttls = starttls + self.timeout = timeout + self.debug = debug + self._conn: Optional[imaplib.IMAP4] = None + + # Context manager support + def __enter__(self) -> "IMAPClient": + return self.connect() + + def __exit__(self, exc_type, exc, tb) -> None: + self.close() + + # Connection management + def connect(self) -> "IMAPClient": + if self._conn is not None: + return self + if self.use_ssl: + context = ssl.create_default_context() + conn = imaplib.IMAP4_SSL(self.host, self.port, ssl_context=context, timeout=self.timeout) + else: + conn = imaplib.IMAP4(self.host, self.port, timeout=self.timeout) + if self.starttls: + context = ssl.create_default_context() + conn.starttls(ssl_context=context) + if self.debug: + conn.debug = 4 + conn.login(self.username, self.password) + self._conn = conn + return self + + def close(self) -> None: + if self._conn is None: + return + try: + try: + self._conn.close() + except Exception: + # Might already be closed or no mailbox selected + pass + self._conn.logout() + finally: + self._conn = None + + # Helpers + def _ensure_conn(self) -> imaplib.IMAP4: + if self._conn is None: + raise RuntimeError("Not connected. Call connect() first.") + return self._conn + + def select_mailbox(self, mailbox: str, readonly: bool = True) -> int: + conn = self._ensure_conn() + status, data = conn.select(mailbox, readonly=readonly) + if status != "OK": + raise RuntimeError(f"Failed to select mailbox {mailbox}: {status}") + try: + return int(data[0]) + except Exception: + return 0 + + def list_mailboxes(self) -> List[str]: + conn = self._ensure_conn() + status, data = conn.list() + if status != "OK": + raise RuntimeError("Failed to list mailboxes") + names: List[str] = [] + if data is None: + return names + for raw in data: + if not raw: + continue + # raw example: b'(\\HasNoChildren) "/" "INBOX"' + line = raw.decode("utf-8", errors="replace") + # Extract last quoted string + match = re.search(r'"((?:\\.|[^"\\])*)"\s*$', line) + if match: + mailbox = match.group(1).encode("utf-8").decode("unicode_escape") + names.append(mailbox) + else: + # Fallback to last token + parts = line.split(" ") + names.append(parts[-1].strip('"')) + return names + + def search_uids(self, mailbox: str, criteria: str = "ALL") -> List[int]: + conn = self._ensure_conn() + self.select_mailbox(mailbox, readonly=True) + status, data = conn.uid("search", None, criteria) + if status != "OK" or not data: + return [] + ids_str = data[0].decode("utf-8", errors="replace").strip() + if not ids_str: + return [] + return [int(x) for x in ids_str.split()] + + def fetch_overview(self, mailbox: str, uids: Iterable[int]) -> List[Dict[str, Any]]: + uids_list = list(uids) + if not uids_list: + return [] + conn = self._ensure_conn() + self.select_mailbox(mailbox, readonly=True) + set_str = ",".join(str(u) for u in uids_list) + status, data = conn.uid( + "fetch", + set_str, + "(FLAGS BODY.PEEK[HEADER.FIELDS (SUBJECT FROM DATE MESSAGE-ID)])", + ) + if status != "OK" or not data: + return [] + # Parse alternating tuples in data + results: List[Dict[str, Any]] = [] + current: Dict[str, Any] = {} + for item in data: + if item is None: + continue + if isinstance(item, tuple) and len(item) == 2: + meta_bytes, payload = item + meta = meta_bytes.decode("utf-8", errors="replace") + # Extract UID + m = re.search(r"UID (\d+)", meta) + if m: + uid = int(m.group(1)) + current = {"uid": uid, "flags": [], "subject": "", "from": "", "date": ""} + # Parse header + msg = message_from_bytes(payload) + current["subject"] = decode_mime_words(msg.get("Subject")) + current["from"] = decode_mime_words(msg.get("From")) + current["date"] = msg.get("Date", "") + results.append(current) + else: + # FLAGS-only or unexpected chunk + if "FLAGS" in meta and results: + flags_match = re.search(r"FLAGS \(([^)]*)\)", meta) + if flags_match: + flags_raw = flags_match.group(1).strip() + results[-1]["flags"] = flags_raw.split() if flags_raw else [] + elif isinstance(item, bytes): + # Some servers interleave FLAGS bytes lines. Attach to last. + meta = item.decode("utf-8", errors="replace") + if "FLAGS" in meta and results: + flags_match = re.search(r"FLAGS \(([^)]*)\)", meta) + if flags_match: + flags_raw = flags_match.group(1).strip() + results[-1]["flags"] = flags_raw.split() if flags_raw else [] + return results + + def fetch_message(self, mailbox: str, uid: int) -> Message: + conn = self._ensure_conn() + self.select_mailbox(mailbox, readonly=True) + status, data = conn.uid("fetch", str(uid), "(RFC822)") + if status != "OK" or not data or not isinstance(data[0], tuple): + raise RuntimeError(f"Failed to fetch message UID {uid}") + raw = data[0][1] + return message_from_bytes(raw) + + def download_attachments( + self, mailbox: str, uid: int, dest_dir: str, only_substr: Optional[str] = None + ) -> List[str]: + os.makedirs(dest_dir, exist_ok=True) + msg = self.fetch_message(mailbox, uid) + saved_paths: List[str] = [] + for part in msg.walk(): + content_disposition = part.get_content_disposition() + if content_disposition not in ("attachment", "inline"): + continue + filename = part.get_filename() + if not filename: + # Derive from content type + maintype, subtype = (part.get_content_type() or "application/octet-stream").split("/", 1) + filename = f"attachment.{subtype}" + filename = decode_mime_words(filename) + filename = sanitize_filename(filename) + if only_substr and only_substr.lower() not in filename.lower(): + continue + payload = part.get_payload(decode=True) + if payload is None: + continue + full_path = os.path.join(dest_dir, filename) + # Avoid overwrite by appending (n) + base, ext = os.path.splitext(full_path) + counter = 1 + candidate = full_path + while os.path.exists(candidate): + candidate = f"{base} ({counter}){ext}" + counter += 1 + with open(candidate, "wb") as f: + f.write(payload) + saved_paths.append(candidate) + return saved_paths + + def set_flags( + self, + mailbox: str, + uid: int, + seen: Optional[bool] = None, + flagged: Optional[bool] = None, + ) -> None: + if seen is None and flagged is None: + return + conn = self._ensure_conn() + self.select_mailbox(mailbox, readonly=False) + if seen is not None: + flag = r"(\\Seen)" + if seen: + conn.uid("store", str(uid), "+FLAGS.SILENT", flag) + else: + conn.uid("store", str(uid), "-FLAGS.SILENT", flag) + if flagged is not None: + flag = r"(\\Flagged)" + if flagged: + conn.uid("store", str(uid), "+FLAGS.SILENT", flag) + else: + conn.uid("store", str(uid), "-FLAGS.SILENT", flag) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c05be6f --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +# No external dependencies required. Uses Python standard library only. \ No newline at end of file