From becf881f0894a5568b3ca9a78401304b68ae0e3e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 16 Sep 2025 04:34:32 +0000 Subject: [PATCH] feat: Add IMAP client CLI Co-authored-by: 723943634 <723943634@qq.com> --- imap_client/README.md | 50 ++++ imap_client/imap_client/__init__.py | 4 + imap_client/imap_client/__main__.py | 153 ++++++++++++ .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 196 bytes .../__pycache__/__main__.cpython-313.pyc | Bin 0 -> 9418 bytes .../__pycache__/imap_client.cpython-313.pyc | Bin 0 -> 14717 bytes imap_client/imap_client/imap_client.py | 235 ++++++++++++++++++ imap_client/pyproject.toml | 18 ++ 8 files changed, 460 insertions(+) create mode 100644 imap_client/README.md create mode 100644 imap_client/imap_client/__init__.py create mode 100644 imap_client/imap_client/__main__.py create mode 100644 imap_client/imap_client/__pycache__/__init__.cpython-313.pyc create mode 100644 imap_client/imap_client/__pycache__/__main__.cpython-313.pyc create mode 100644 imap_client/imap_client/__pycache__/imap_client.cpython-313.pyc create mode 100644 imap_client/imap_client/imap_client.py create mode 100644 imap_client/pyproject.toml diff --git a/imap_client/README.md b/imap_client/README.md new file mode 100644 index 0000000..55dc959 --- /dev/null +++ b/imap_client/README.md @@ -0,0 +1,50 @@ +IMAP Mail Client (CLI) + +Simple Python IMAP CLI client using the standard library. Supports listing mailboxes, searching messages, showing headers/body, flag operations, moving and deleting messages. + +Quick start + +1) Ensure Python 3.9+ is installed. +2) No third-party dependencies required. + +Environment variables (optional) + +- IMAP_HOST +- IMAP_PORT +- IMAP_SSL ("1" or "0") +- IMAP_STARTTLS ("1" or "0") +- IMAP_USER +- IMAP_PASSWORD + +Usage + +List available commands: + +```bash +python -m imap_client --help +``` + +Examples + +```bash +# List mailboxes +python -m imap_client --host imap.example.com --ssl 1 --user alice --password secret mailboxes + +# List unseen messages in INBOX, limiting to 20 +python -m imap_client --host imap.example.com --ssl 1 --user alice --password secret list --mailbox INBOX --unseen --limit 20 + +# Show a message by UID with headers and text body +python -m imap_client --host imap.example.com --ssl 1 --user alice --password secret show --mailbox INBOX --uid 12345 --headers --body + +# Move messages to Archive +python -m imap_client --host imap.example.com --ssl 1 --user alice --password secret move --mailbox INBOX --uids 12345,12346 --to Archive + +# Delete and expunge +python -m imap_client --host imap.example.com --ssl 1 --user alice --password secret delete --mailbox INBOX --uids 12345,12346 +python -m imap_client --host imap.example.com --ssl 1 --user alice --password secret expunge --mailbox INBOX +``` + +Security + +- Prefer using app-specific passwords or environment variables. Avoid passing credentials via shell history when possible. + diff --git a/imap_client/imap_client/__init__.py b/imap_client/imap_client/__init__.py new file mode 100644 index 0000000..84b6217 --- /dev/null +++ b/imap_client/imap_client/__init__.py @@ -0,0 +1,4 @@ +__all__ = ["__version__"] + +__version__ = "0.1.0" + diff --git a/imap_client/imap_client/__main__.py b/imap_client/imap_client/__main__.py new file mode 100644 index 0000000..ad4c43d --- /dev/null +++ b/imap_client/imap_client/__main__.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +import argparse +import os +import sys +from typing import List + +from .imap_client import ImapClient, ImapConfig, ImapError, load_config_from_env + + +def parse_uids(uids_csv: str) -> List[int]: + if not uids_csv: + return [] + return [int(x) for x in uids_csv.split(',') if x.strip()] + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="imap-client", description="IMAP mail client CLI") + parser.add_argument("--host", default=os.environ.get("IMAP_HOST"), help="IMAP host") + parser.add_argument("--port", type=int, default=int(os.environ.get("IMAP_PORT", "993")), help="IMAP port") + parser.add_argument("--ssl", type=int, choices=[0, 1], default=int(os.environ.get("IMAP_SSL", "1")), help="Use SSL (default 1)") + parser.add_argument("--starttls", type=int, choices=[0, 1], default=int(os.environ.get("IMAP_STARTTLS", "0")), help="Use STARTTLS") + parser.add_argument("--user", default=os.environ.get("IMAP_USER"), help="Username") + parser.add_argument("--password", default=os.environ.get("IMAP_PASSWORD"), help="Password") + parser.add_argument("--timeout", type=int, default=int(os.environ.get("IMAP_TIMEOUT", "30")), help="Socket timeout seconds") + + sub = parser.add_subparsers(dest="command", required=True) + + sub.add_parser("mailboxes", help="List mailboxes") + + list_p = sub.add_parser("list", help="List message UIDs in a mailbox") + list_p.add_argument("--mailbox", default="INBOX") + list_p.add_argument("--limit", type=int, default=50) + list_p.add_argument("--unseen", action="store_true") + list_p.add_argument("--since", help="Since date in format 01-Jan-2024") + list_p.add_argument("--before", help="Before date in format 01-Jan-2024") + + show_p = sub.add_parser("show", help="Show message headers/body") + show_p.add_argument("--mailbox", default="INBOX") + show_p.add_argument("--uid", type=int, required=True) + show_p.add_argument("--headers", action="store_true") + show_p.add_argument("--body", action="store_true") + + flags_add = sub.add_parser("flag-add", help="Add flags to messages") + flags_add.add_argument("--mailbox", default="INBOX") + flags_add.add_argument("--uids", required=True, help="CSV of UIDs") + flags_add.add_argument("--flags", required=True, help="Space-separated flags, e.g. \\Seen \\Flagged") + + flags_rm = sub.add_parser("flag-remove", help="Remove flags from messages") + flags_rm.add_argument("--mailbox", default="INBOX") + flags_rm.add_argument("--uids", required=True) + flags_rm.add_argument("--flags", required=True) + + move_p = sub.add_parser("move", help="Move messages to another mailbox") + move_p.add_argument("--mailbox", default="INBOX") + move_p.add_argument("--uids", required=True) + move_p.add_argument("--to", required=True, dest="destination") + + del_p = sub.add_parser("delete", help="Mark messages as deleted") + del_p.add_argument("--mailbox", default="INBOX") + del_p.add_argument("--uids", required=True) + + expunge_p = sub.add_parser("expunge", help="Permanently remove messages marked as deleted") + expunge_p.add_argument("--mailbox", default="INBOX") + + return parser + + +def create_client_from_args(args: argparse.Namespace) -> ImapClient: + overrides = { + "host": args.host, + "port": args.port, + "use_ssl": bool(args.ssl), + "use_starttls": bool(args.starttls), + "username": args.user, + "password": args.password, + "timeout_seconds": args.timeout, + } + cfg = load_config_from_env(overrides) + if not cfg.host: + raise SystemExit("--host is required (or set IMAP_HOST)") + return ImapClient(cfg) + + +def main(argv: List[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + + client = create_client_from_args(args) + try: + client.connect() + + if args.command == "mailboxes": + for m in client.list_mailboxes(): + print(m) + return 0 + + if args.command == "list": + uids = client.list_messages( + mailbox=args.mailbox, + limit=args.limit, + unseen=args.unseen, + since=args.since, + before=args.before, + ) + for u in uids: + print(u) + return 0 + + if args.command == "show": + if args.headers: + print(client.fetch_headers(args.mailbox, args.uid)) + if args.body: + print(client.fetch_body_text(args.mailbox, args.uid)) + if not args.headers and not args.body: + print(client.fetch_headers(args.mailbox, args.uid)) + print() + print(client.fetch_body_text(args.mailbox, args.uid)) + return 0 + + if args.command == "flag-add": + client.add_flags(args.mailbox, parse_uids(args.uids), args.flags.split()) + return 0 + + if args.command == "flag-remove": + client.remove_flags(args.mailbox, parse_uids(args.uids), args.flags.split()) + return 0 + + if args.command == "move": + client.move(args.mailbox, parse_uids(args.uids), args.destination) + return 0 + + if args.command == "delete": + client.delete(args.mailbox, parse_uids(args.uids)) + return 0 + + if args.command == "expunge": + client.expunge(args.mailbox) + return 0 + + parser.error("Unknown command") + return 2 + + except ImapError as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + finally: + client.logout() + + +if __name__ == "__main__": + raise SystemExit(main()) + diff --git a/imap_client/imap_client/__pycache__/__init__.cpython-313.pyc b/imap_client/imap_client/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..82dc69935b127201aa6de244f2b763993a335044 GIT binary patch literal 196 zcmey&%ge<81Z^))WEcVI#~=<2FhUuh`GAb648aWgj71E=j75y;Oq$HMxZ~r?Qj3Z+ z^Yh~4t5^;64D}5BG?{L($Hyn;TZlX-=wL5j#*j$Q{LkK;i>4BO~JtZix>42KEPh LqK(`|tUyr!ADl9t literal 0 HcmV?d00001 diff --git a/imap_client/imap_client/__pycache__/__main__.cpython-313.pyc b/imap_client/imap_client/__pycache__/__main__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..86234e65a5385b1bb0224168594c8ed56c029e2f GIT binary patch literal 9418 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 literal 0 HcmV?d00001 diff --git a/imap_client/imap_client/__pycache__/imap_client.cpython-313.pyc b/imap_client/imap_client/__pycache__/imap_client.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5ee046e34e192608c679f445d10569f913f6e4b2 GIT binary patch literal 14717 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 None: + self.config = config + self._conn: Optional[imaplib.IMAP4] = None + + # ----- Connection management ----- + def connect(self) -> None: + try: + socket.setdefaulttimeout(self.config.timeout_seconds) + if self.config.use_ssl: + self._conn = imaplib.IMAP4_SSL(self.config.host, self.config.port) + else: + self._conn = imaplib.IMAP4(self.config.host, self.config.port) + if self.config.use_starttls: + self._conn.starttls(ssl_context=ssl.create_default_context()) + + if self.config.username: + self.login(self.config.username, self.config.password or "") + except (imaplib.IMAP4.error, socket.timeout, OSError) as exc: + raise ImapError(f"Failed to connect/login: {exc}") from exc + + def login(self, username: str, password: str) -> None: + self._ensure_conn() + try: + assert self._conn is not None + typ, _ = self._conn.login(username, password) + if typ != "OK": + raise ImapError("Login failed") + except imaplib.IMAP4.error as exc: + raise ImapError(f"Login failed: {exc}") from exc + + def logout(self) -> None: + if self._conn is not None: + with contextlib.suppress(Exception): + self._conn.logout() + self._conn = None + + def _ensure_conn(self) -> None: + if self._conn is None: + raise ImapError("Not connected") + + # ----- Mailboxes ----- + def list_mailboxes(self) -> List[str]: + self._ensure_conn() + assert self._conn is not None + typ, data = self._conn.list() + if typ != "OK": + raise ImapError("LIST failed") + mailboxes: List[str] = [] + for line in data or []: + if not line: + continue + decoded = line.decode(errors="ignore") + # Format: (* FLAGS) "/" "INBOX" + m = re.search(r"\"([^\"]+)\"\s*$", decoded) + if m: + mailboxes.append(m.group(1)) + else: + # Fallback to last token + mailboxes.append(decoded.split()[-1].strip('"')) + return mailboxes + + def ensure_selected(self, mailbox: str) -> Tuple[str, List[bytes]]: + self._ensure_conn() + assert self._conn is not None + typ, data = self._conn.select(mailbox, readonly=False) + if typ != "OK": + raise ImapError(f"SELECT {mailbox} failed") + return typ, data + + # ----- Search / List ----- + def search(self, mailbox: str, criteria: Sequence[str]) -> List[int]: + self.ensure_selected(mailbox) + assert self._conn is not None + typ, data = self._conn.search(None, *criteria) + if typ != "OK": + raise ImapError(f"SEARCH failed: {' '.join(criteria)}") + if not data or not data[0]: + return [] + uids = [int(x) for x in data[0].split()] + return uids + + def list_messages( + self, + mailbox: str, + limit: Optional[int] = None, + unseen: bool = False, + since: Optional[str] = None, + before: Optional[str] = None, + ) -> List[int]: + criteria: List[str] = ["UID", "1:*"] + if unseen: + criteria.append("UNSEEN") + if since: + criteria.extend(["SINCE", since]) + if before: + criteria.extend(["BEFORE", before]) + uids = self.search(mailbox, criteria) + uids.sort(reverse=True) + if limit is not None: + uids = uids[:limit] + uids.sort() + return uids + + # ----- Fetch ----- + def fetch_headers(self, mailbox: str, uid: int) -> str: + self.ensure_selected(mailbox) + assert self._conn is not None + typ, data = self._conn.uid("FETCH", str(uid), "(BODY.PEEK[HEADER])") + if typ != "OK" or not data or not isinstance(data[0], tuple): + raise ImapError("FETCH headers failed") + raw = data[0][1] + msg = email.message_from_bytes(raw, policy=email.policy.default) + return msg.as_string() + + def fetch_body_text(self, mailbox: str, uid: int) -> str: + self.ensure_selected(mailbox) + assert self._conn is not None + typ, data = self._conn.uid("FETCH", str(uid), "(BODY.PEEK[])") + if typ != "OK" or not data or not isinstance(data[0], tuple): + raise ImapError("FETCH body failed") + raw = data[0][1] + msg = email.message_from_bytes(raw, policy=email.policy.default) + + if msg.is_multipart(): + for part in msg.walk(): + content_type = part.get_content_type() + disposition = part.get_content_disposition() + if disposition == "attachment": + continue + if content_type == "text/plain": + return part.get_content() + # Fallback to first non-attachment part + for part in msg.walk(): + if part.get_content_disposition() == "attachment": + continue + try: + return part.get_content() + except Exception: + continue + return "" + else: + return msg.get_content() + + # ----- Flags / Move / Delete ----- + def add_flags(self, mailbox: str, uids: Iterable[int], flags: Sequence[str]) -> None: + self.ensure_selected(mailbox) + assert self._conn is not None + uid_set = ",".join(str(u) for u in uids) + flag_list = "(" + " ".join(flags) + ")" + typ, _ = self._conn.uid("STORE", uid_set, "+FLAGS.SILENT", flag_list) + if typ != "OK": + raise ImapError("ADD flags failed") + + def remove_flags(self, mailbox: str, uids: Iterable[int], flags: Sequence[str]) -> None: + self.ensure_selected(mailbox) + assert self._conn is not None + uid_set = ",".join(str(u) for u in uids) + flag_list = "(" + " ".join(flags) + ")" + typ, _ = self._conn.uid("STORE", uid_set, "-FLAGS.SILENT", flag_list) + if typ != "OK": + raise ImapError("REMOVE flags failed") + + def move(self, mailbox: str, uids: Iterable[int], destination_mailbox: str) -> None: + self.ensure_selected(mailbox) + assert self._conn is not None + uid_set = ",".join(str(u) for u in uids) + # Try MOVE extension + typ, _ = self._conn.uid("MOVE", uid_set, destination_mailbox) + if typ == "OK": + return + # Fallback: COPY + delete + expunge + typ, _ = self._conn.uid("COPY", uid_set, destination_mailbox) + if typ != "OK": + raise ImapError("COPY failed during move") + self.add_flags(mailbox, [int(u) for u in uid_set.split(",")], ["\\Deleted"]) + self.expunge(mailbox) + + def delete(self, mailbox: str, uids: Iterable[int]) -> None: + self.ensure_selected(mailbox) + self.add_flags(mailbox, uids, ["\\Deleted"]) + + def expunge(self, mailbox: str) -> None: + self.ensure_selected(mailbox) + assert self._conn is not None + typ, _ = self._conn.expunge() + if typ != "OK": + raise ImapError("EXPUNGE failed") + + +def load_config_from_env(overrides: Optional[dict] = None) -> ImapConfig: + cfg = ImapConfig( + host=os.environ.get("IMAP_HOST", ""), + port=int(os.environ.get("IMAP_PORT", "993")), + use_ssl=os.environ.get("IMAP_SSL", "1") == "1", + use_starttls=os.environ.get("IMAP_STARTTLS", "0") == "1", + username=os.environ.get("IMAP_USER"), + password=os.environ.get("IMAP_PASSWORD"), + timeout_seconds=int(os.environ.get("IMAP_TIMEOUT", "30")), + ) + if overrides: + for key, value in overrides.items(): + if hasattr(cfg, key): + setattr(cfg, key, value) + return cfg + diff --git a/imap_client/pyproject.toml b/imap_client/pyproject.toml new file mode 100644 index 0000000..4a19004 --- /dev/null +++ b/imap_client/pyproject.toml @@ -0,0 +1,18 @@ +[project] +name = "imap-client-cli" +version = "0.1.0" +description = "Simple IMAP mail client CLI using Python stdlib" +readme = "README.md" +requires-python = ">=3.9" +authors = [ + { name = "Your Name" } +] +dependencies = [] + +[project.scripts] +imap-client = "imap_client.__main__:main" + +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" +