diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 5a442e4..5455bdd 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -87,4 +87,4 @@ Batch commands (`batch-read`, `batch-move`, `batch-delete`, `batch-flag`) log th Tests live in `tests/` and use `unittest.mock` to mock AppleScript calls. No actual Mail.app interaction happens during testing. Run with `pytest --cov` for coverage. -The suite has 675 tests (100% coverage) across 19 test files covering command parsing, AppleScript output parsing, error paths, date handling, formatting, config resolution, batch operations, undo logging, templates, AI classification logic, unsubscribe HTTP paths, Todoist integration, inbox tools, and bulk export. Six unreachable defensive guards are marked with `# pragma: no cover`. +The suite has 678 tests (100% coverage) across 19 test files covering command parsing, AppleScript output parsing, error paths, date handling, formatting, config resolution, batch operations, undo logging, templates, AI classification logic, unsubscribe HTTP paths, Todoist integration, inbox tools, bulk export, and the public API module. Six unreachable defensive guards are marked with `# pragma: no cover`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f3d55e..6aa010c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added + +- **Public Python API** — new `mxctl.api` module with 56 importable functions for programmatic access to Apple Mail. All command internals refactored to separate data retrieval from CLI presentation. External projects can now `from mxctl.api import get_messages, read_message` without any CLI or argparse dependency. + ## [0.4.0] - 2026-02-25 ### Added diff --git a/README.md b/README.md index ad069eb..04b2cbe 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ > Apple Mail from your terminal. -**50 commands.** Triage with AI, batch-process newsletters, turn emails into Todoist tasks — all from the terminal. Every command supports `--json` for scripting and AI workflows. Zero external dependencies. +**50+ commands.** Triage with AI, batch-process newsletters, turn emails into Todoist tasks — all from the terminal. Every command supports `--json` for scripting and AI workflows. Zero external dependencies.

mxctl demo — inbox, triage, summary, and batch operations @@ -39,7 +39,7 @@ ## Key Features -- **50 Commands** - Everything from basic operations to advanced batch processing +- **50+ Commands** - Everything from basic operations to advanced batch processing - **Any Account, One Interface** - iCloud, Gmail, Outlook, Exchange, IMAP -- whatever Mail.app has, this works with - **Gmail Mailbox Translation** - Automatically maps standard names (`Trash`, `Spam`, `Sent`) to Gmail's `[Gmail]/...` paths - **Built for AI Workflows** - Every command supports `--json` output designed for AI assistants to read and act on @@ -103,7 +103,7 @@ Inbox Overview ------------------------------------------ iCloud 3 unread (47 total) Work Email 12 unread (203 total) - Johnny.Coats84@gmail.com 0 unread (18 total) + Personal Gmail 0 unread (18 total) ------------------------------------------ Total 15 unread ``` @@ -282,13 +282,6 @@ mxctl move 3 --to Archive # Move message [3] Aliases update each time you run a listing command (`list`, `inbox`, `search`, `triage`, `summary`, etc.). Full message IDs still work if you prefer them. JSON output includes both `id` (real) and `alias` (short number). -### JSON Output for Automation -```bash -# Every command supports --json -mxctl inbox --json | jq '.accounts[0].unread_count' -mxctl search "invoice" --json | jq '.[].subject' -``` - ### Export Messages ```bash # Export a single message @@ -301,8 +294,6 @@ mxctl export "Work" --to ~/Documents/mail/ -a "Work Email" mxctl export "INBOX" --to ~/Documents/mail/ -a "iCloud" --after 2026-01-01 ``` -Note: The destination flag is `--to` (not `--dest`). - ### Email Templates ```bash # Create a template @@ -330,66 +321,27 @@ It walks you through selecting your AI assistant (Claude Code, Cursor, or Windsu #### Manual setup -If you prefer to set things up yourself, add this block to your assistant's context file (`~/.claude/CLAUDE.md` for Claude Code, `.cursorrules` for Cursor, `.windsurfrules` for Windsurf): - -````markdown -## mxctl — Apple Mail CLI - -`mxctl` manages Apple Mail from the terminal. Use it to read, triage, and act on email without opening Mail.app. - -Key commands: -- `mxctl inbox` — unread counts across all accounts -- `mxctl triage` — categorize unread mail by urgency -- `mxctl summary` — concise one-liner per unread message -- `mxctl list [-a ACCOUNT] [--unread] [--limit N]` — list messages -- `mxctl read ID [-a ACCOUNT] [-m MAILBOX]` — read a message -- `mxctl search QUERY [--sender]` — search messages -- `mxctl mark-read ID` / `mxctl flag ID` — message actions -- `mxctl batch-move --from-sender ADDR --to-mailbox MAILBOX` — bulk move -- `mxctl batch-delete --older-than DAYS -m MAILBOX` — bulk delete -- `mxctl undo` — roll back the last batch operation -- `mxctl to-todoist ID --project NAME` — turn an email into a task - -Add `--json` to any command for structured output. Run `mxctl --help` for all 50 commands. -Default account is set in `~/.config/mxctl/config.json`. Use `-a "Account Name"` to switch accounts. -```` +Prefer to configure it yourself? Run `mxctl ai-setup --print` to get the raw snippet, then paste it into your assistant's context file (`~/.claude/CLAUDE.md`, `.cursorrules`, `.windsurfrules`, etc.). #### Local AI (Ollama, LM Studio, Aider, etc.) -For local models, use `--print` to dump the raw snippet and pipe it wherever you need: +Use `--print` to get the raw snippet for piping: ```bash -# Copy to clipboard -mxctl ai-setup --print | pbcopy - -# Append to an Ollama Modelfile -mxctl ai-setup --print >> ~/Modelfile - -# Save as a reusable system prompt file -mxctl ai-setup --print > ~/.config/mxctl-prompt.md - -# Pass to Aider -mxctl ai-setup --print > .aider.prompt.md +mxctl ai-setup --print | pbcopy # clipboard +mxctl ai-setup --print >> ~/Modelfile # Ollama +mxctl ai-setup --print > .aider.prompt.md # Aider ``` -`--print` outputs clean markdown with no interactive prompts — it's designed for piping. +For a one-off session, `mxctl --help` gives any AI the full command reference. -#### Ad-hoc: inject the full command list on demand - -For a one-off session with any AI tool, paste the full command reference directly into the chat: +### With Claude Code -```bash -mxctl --help -``` +Just ask in natural language: -The output is concise enough to fit in any context window and gives the AI everything it needs to pick the right command. - -### With Claude Code -```bash -# Just ask Claude to check your mail -"Run mxctl triage and tell me what's urgent" -"Summarize my unread mail and create Todoist tasks for anything that needs action" -``` +> *"Run mxctl triage and tell me what's urgent"* +> +> *"Summarize my unread mail and create Todoist tasks for anything that needs action"* ### With any AI tool ```bash @@ -405,12 +357,11 @@ mxctl triage --json | llm "Draft responses for the urgent items" # Unread count for your status bar mxctl count -# Export to JSON for any workflow -mxctl inbox --json | jq '.accounts[].unread_count' +# Every command supports --json +mxctl inbox --json | jq '.[].unread' +mxctl search "invoice" --json | jq '.[].subject' ``` -The CLI is the bridge between Mail.app and whatever tools you use -- AI, scripts, or both. - ## AI Demos These demos show how an AI assistant (like Claude Code) uses mxctl to manage your inbox conversationally. You say what you want in plain English, and the AI picks the right commands, checks before acting, and reports back. @@ -443,7 +394,7 @@ The AI analyzes which newsletters you actually read vs. ignore, then unsubscribe Built with modern Python patterns: - **Zero runtime dependencies** (stdlib only) -- **Comprehensive test suite** (675 tests) +- **Comprehensive test suite** (678 tests) - **Modular command structure** (16 focused modules) - **AppleScript bridge** for Mail.app communication - **Three-tier account resolution** (explicit flag -> config default -> last-used) @@ -463,7 +414,7 @@ See [ARCHITECTURE.md](ARCHITECTURE.md) for detailed architecture documentation. | **Setup** | `pip install mxctl && mxctl init` | Extensive config | OAuth flows + API keys | Write your own scripts | | **Dependencies** | Zero (stdlib only) | Varies | SDK + auth libraries | None | -**In short:** mutt replaces Mail.app (you lose macOS integration). Provider APIs lock you into one service. Raw AppleScript works but you're building everything from scratch. mxctl gives you 50 structured commands on top of the Mail.app you already use. +**In short:** mutt replaces Mail.app (you lose macOS integration). Provider APIs lock you into one service. Raw AppleScript works but you're building everything from scratch. mxctl gives you 50+ structured commands on top of the Mail.app you already use. ## Contributing @@ -473,10 +424,6 @@ Contributions welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. MIT License - see [LICENSE](LICENSE) file for details. -## Acknowledgments - -Built to automate email workflows without leaving the terminal. - ## Contact - **GitHub:** [@Jscoats](https://github.com/Jscoats) diff --git a/src/mxctl/api.py b/src/mxctl/api.py new file mode 100644 index 0000000..fa84be5 --- /dev/null +++ b/src/mxctl/api.py @@ -0,0 +1,188 @@ +"""Public Python API for mxctl. + +Import data functions for programmatic access to Apple Mail. +CLI behavior is unchanged — this module provides the same data +without formatting or printing. + +Usage: + from mxctl.api import list_messages, read_message + messages = get_messages(account="iCloud", mailbox="INBOX") +""" + +# --- Account & Mailbox --- +from mxctl.commands.mail.accounts import ( + get_accounts, + get_inbox_summary, + get_mailboxes, + get_unread_count, +) + +# --- Actions --- +from mxctl.commands.mail.actions import ( + delete_message, + mark_junk, + move_message, + not_junk, + open_message, + set_flag_status, + set_read_status, +) + +# --- AI / Smart Commands --- +from mxctl.commands.mail.ai import ( + find_related, + get_context, + get_summary, + get_triage, +) + +# --- Analytics --- +from mxctl.commands.mail.analytics import ( + get_digest, + get_flagged_messages, + get_stats, + get_top_senders, +) + +# --- Attachments --- +from mxctl.commands.mail.attachments import ( + get_attachments, + save_attachment, +) + +# --- Batch Operations --- +from mxctl.commands.mail.batch import ( + batch_delete, + batch_flag, + batch_move, + batch_read, +) + +# --- Compose --- +from mxctl.commands.mail.compose import create_draft + +# --- Composite --- +from mxctl.commands.mail.composite import ( + create_forward, + create_reply, + export_message, + export_messages, + get_thread, +) + +# --- Inbox Tools --- +from mxctl.commands.mail.inbox_tools import ( + get_inbox_categories, + get_newsletter_senders, + get_weekly_review, +) + +# --- Mailbox Management --- +from mxctl.commands.mail.manage import ( + create_mailbox, + delete_mailbox, + empty_trash, +) + +# --- Messages --- +from mxctl.commands.mail.messages import ( + get_messages, + read_message, + search_messages, +) + +# --- System --- +from mxctl.commands.mail.system import ( + check_mail_status, + get_headers, + get_raw_headers, + get_rules, + toggle_rule, +) + +# --- Templates --- +from mxctl.commands.mail.templates import ( + create_template, + delete_template, + get_template, + get_templates, +) + +# --- Todoist Integration --- +from mxctl.commands.mail.todoist_integration import create_todoist_task + +# --- Undo --- +from mxctl.commands.mail.undo import ( + list_undo_history, + undo_last, +) + +__all__ = [ + # Account & Mailbox + "get_accounts", + "get_inbox_summary", + "get_mailboxes", + "get_unread_count", + # Messages + "get_messages", + "read_message", + "search_messages", + # Actions + "delete_message", + "mark_junk", + "move_message", + "not_junk", + "open_message", + "set_flag_status", + "set_read_status", + # Attachments + "get_attachments", + "save_attachment", + # Compose + "create_draft", + # Templates + "create_template", + "delete_template", + "get_template", + "get_templates", + # Batch Operations + "batch_delete", + "batch_flag", + "batch_move", + "batch_read", + # Analytics + "get_digest", + "get_flagged_messages", + "get_stats", + "get_top_senders", + # AI / Smart Commands + "find_related", + "get_context", + "get_summary", + "get_triage", + # Inbox Tools + "get_inbox_categories", + "get_newsletter_senders", + "get_weekly_review", + # Composite + "create_forward", + "create_reply", + "export_message", + "export_messages", + "get_thread", + # Mailbox Management + "create_mailbox", + "delete_mailbox", + "empty_trash", + # System + "check_mail_status", + "get_headers", + "get_raw_headers", + "get_rules", + "toggle_rule", + # Undo + "list_undo_history", + "undo_last", + # Todoist Integration + "create_todoist_task", +] diff --git a/src/mxctl/commands/mail/accounts.py b/src/mxctl/commands/mail/accounts.py index 2292ce7..c80299d 100644 --- a/src/mxctl/commands/mail/accounts.py +++ b/src/mxctl/commands/mail/accounts.py @@ -10,13 +10,9 @@ # inbox # --------------------------------------------------------------------------- -def cmd_inbox(args) -> None: - """List unread counts and recent messages, optionally scoped to one account.""" - # Use only the explicitly-passed -a flag, not the config default. - # resolve_account() would return the default account (e.g. iCloud) when no - # flag is given, causing inbox to show only one account instead of all. - account = getattr(args, "account", None) +def get_inbox_summary(account: str | None = None) -> list[dict]: + """Fetch unread counts and recent messages across accounts.""" if account: acct_escaped = escape(account) script = f""" @@ -84,17 +80,8 @@ def cmd_inbox(args) -> None: result = run(script) if not result.strip(): - if not os.path.isfile(CONFIG_FILE): - format_output( - args, - "No mail accounts found or no INBOX mailboxes available.\n" - "Run `mxctl init` to configure your default account.", - ) - else: - format_output(args, "No mail accounts found or no INBOX mailboxes available.") - return + return [] - # Parse once into structured data accounts = [] current = None for line in result.strip().split("\n"): @@ -104,12 +91,14 @@ def cmd_inbox(args) -> None: if parts[0] == "MSG" and len(parts) >= 6: _, acct, msg_id, subject, sender, date = parts[:6] if current: - current["recent_unread"].append({ - "id": int(msg_id) if msg_id.isdigit() else msg_id, - "subject": subject, - "sender": sender, - "date": date, - }) + current["recent_unread"].append( + { + "id": int(msg_id) if msg_id.isdigit() else msg_id, + "subject": subject, + "sender": sender, + "date": date, + } + ) elif len(parts) >= 3: acct, unread, total = parts[:3] current = { @@ -120,6 +109,28 @@ def cmd_inbox(args) -> None: } accounts.append(current) + return accounts + + +def cmd_inbox(args) -> None: + """List unread counts and recent messages, optionally scoped to one account.""" + # Use only the explicitly-passed -a flag, not the config default. + # resolve_account() would return the default account (e.g. iCloud) when no + # flag is given, causing inbox to show only one account instead of all. + account = getattr(args, "account", None) + + accounts = get_inbox_summary(account) + + if not accounts: + if not os.path.isfile(CONFIG_FILE): + format_output( + args, + "No mail accounts found or no INBOX mailboxes available.\nRun `mxctl init` to configure your default account.", + ) + else: + format_output(args, "No mail accounts found or no INBOX mailboxes available.") + return + # Assign sequential aliases across all accounts all_msg_ids = [] for acct_data in accounts: @@ -154,8 +165,9 @@ def cmd_inbox(args) -> None: # accounts # --------------------------------------------------------------------------- -def cmd_accounts(args) -> None: - """List configured mail accounts.""" + +def get_accounts() -> list[dict]: + """Fetch all configured mail accounts.""" script = f""" tell application "Mail" set output to "" @@ -173,22 +185,33 @@ def cmd_accounts(args) -> None: result = run(script) if not result.strip(): - format_output(args, "No mail accounts found.") - return + return [] - # Parse once into structured data accounts = [] for line in result.strip().split("\n"): if not line.strip(): continue parts = line.split(FIELD_SEPARATOR) if len(parts) >= 4: - accounts.append({ - "name": parts[0], - "full_name": parts[1], - "email": parts[2], - "enabled": parts[3].lower() == "true", - }) + accounts.append( + { + "name": parts[0], + "full_name": parts[1], + "email": parts[2], + "enabled": parts[3].lower() == "true", + } + ) + + return accounts + + +def cmd_accounts(args) -> None: + """List configured mail accounts.""" + accounts = get_accounts() + + if not accounts: + format_output(args, "No mail accounts found.") + return # Build text from parsed data text = "Mail Accounts:" @@ -202,10 +225,9 @@ def cmd_accounts(args) -> None: # mailboxes # --------------------------------------------------------------------------- -def cmd_mailboxes(args) -> None: - """List mailboxes with unread counts.""" - account = resolve_account(getattr(args, "account", None)) +def get_mailboxes(account: str | None = None) -> list[dict]: + """Fetch mailboxes with unread counts.""" if account: acct_escaped = escape(account) script = f""" @@ -239,11 +261,8 @@ def cmd_mailboxes(args) -> None: result = run(script) if not result.strip(): - msg = f"No mailboxes found in account '{account}'." if account else "No mailboxes found." - format_output(args, msg) - return + return [] - # Parse once into structured data mailboxes = [] for line in result.strip().split("\n"): if not line.strip(): @@ -252,11 +271,27 @@ def cmd_mailboxes(args) -> None: if account and len(parts) >= 2: mailboxes.append({"name": parts[0], "unread": int(parts[1]) if parts[1].isdigit() else 0}) elif not account and len(parts) >= 3: - mailboxes.append({ - "account": parts[0], - "name": parts[1], - "unread": int(parts[2]) if parts[2].isdigit() else 0, - }) + mailboxes.append( + { + "account": parts[0], + "name": parts[1], + "unread": int(parts[2]) if parts[2].isdigit() else 0, + } + ) + + return mailboxes + + +def cmd_mailboxes(args) -> None: + """List mailboxes with unread counts.""" + account = resolve_account(getattr(args, "account", None)) + + mailboxes = get_mailboxes(account) + + if not mailboxes: + msg = f"No mailboxes found in account '{account}'." if account else "No mailboxes found." + format_output(args, msg) + return # Build text from parsed data header = f"Mailboxes in {account}:" if account else "All Mailboxes:" @@ -274,11 +309,9 @@ def cmd_mailboxes(args) -> None: # count # --------------------------------------------------------------------------- -def cmd_count(args) -> None: - """Print unread message count.""" - account = resolve_account(getattr(args, "account", None)) - mailbox = getattr(args, "mailbox", None) +def get_unread_count(account: str | None = None, mailbox: str | None = None) -> dict: + """Fetch unread message count for an account/mailbox or across all accounts.""" if account: acct_escaped = escape(account) mb = mailbox or "INBOX" @@ -291,10 +324,9 @@ def cmd_count(args) -> None: ''' result = run(script) count = int(result.strip()) if result.strip().isdigit() else 0 - format_output(args, str(count), - json_data={"unread": count, "account": account, "mailbox": mb}) + return {"unread": count, "account": account, "mailbox": mb} else: - script = ''' + script = """ tell application "Mail" set totalUnread to 0 repeat with acct in (every account) @@ -309,17 +341,26 @@ def cmd_count(args) -> None: end repeat return totalUnread end tell - ''' + """ result = run(script) count = int(result.strip()) if result.strip().isdigit() else 0 - format_output(args, str(count), - json_data={"unread": count, "account": "all"}) + return {"unread": count, "account": "all"} + + +def cmd_count(args) -> None: + """Print unread message count.""" + account = resolve_account(getattr(args, "account", None)) + mailbox = getattr(args, "mailbox", None) + + data = get_unread_count(account, mailbox) + format_output(args, str(data["unread"]), json_data=data) # --------------------------------------------------------------------------- # Registration # --------------------------------------------------------------------------- + def register(subparsers) -> None: """Register account-related mail subcommands.""" # inbox diff --git a/src/mxctl/commands/mail/actions.py b/src/mxctl/commands/mail/actions.py index 7c0fbb9..03a11be 100644 --- a/src/mxctl/commands/mail/actions.py +++ b/src/mxctl/commands/mail/actions.py @@ -15,37 +15,131 @@ from mxctl.util.formatting import die, format_output, truncate from mxctl.util.mail_helpers import parse_email_headers, resolve_mailbox, resolve_message_context +# --------------------------------------------------------------------------- +# Data functions (plain args, return dicts, no printing) +# --------------------------------------------------------------------------- -def _mark_read_status(args, read_status: bool) -> None: - account, mailbox, acct_escaped, mb_escaped = resolve_message_context(args) - message_id = validate_msg_id(args.id) + +def set_read_status(account: str, mailbox: str, message_id: int, read_status: bool) -> dict: + """Mark a message as read or unread. Returns result dict.""" + acct_escaped = escape(account) + mb_escaped = escape(mailbox) read_val = "true" if read_status else "false" - script = set_message_property( - f'"{acct_escaped}"', f'"{mb_escaped}"', message_id, - 'read status', read_val - ) + script = set_message_property(f'"{acct_escaped}"', f'"{mb_escaped}"', message_id, "read status", read_val) subject = run(script) status_word = "read" if read_status else "unread" - format_output(args, f"Message '{truncate(subject, 50)}' marked as {status_word}.", - json_data={"id": message_id, "subject": subject, "status": status_word}) + return {"id": message_id, "subject": subject, "status": status_word, "account": account, "mailbox": mailbox} -def _flag_status(args, flagged: bool) -> None: - account, mailbox, acct_escaped, mb_escaped = resolve_message_context(args) - message_id = validate_msg_id(args.id) +def set_flag_status(account: str, mailbox: str, message_id: int, flagged: bool) -> dict: + """Mark a message as flagged or unflagged. Returns result dict.""" + acct_escaped = escape(account) + mb_escaped = escape(mailbox) flagged_val = "true" if flagged else "false" - script = set_message_property( - f'"{acct_escaped}"', f'"{mb_escaped}"', message_id, - 'flagged status', flagged_val - ) + script = set_message_property(f'"{acct_escaped}"', f'"{mb_escaped}"', message_id, "flagged status", flagged_val) subject = run(script) status_word = "flagged" if flagged else "unflagged" - format_output(args, f"Message '{truncate(subject, 50)}' {status_word}.", - json_data={"id": message_id, "subject": subject, "status": status_word}) + return {"id": message_id, "subject": subject, "status": status_word, "account": account, "mailbox": mailbox} + + +def move_message(account: str, source_mailbox: str, message_id: int, dest_mailbox: str) -> dict: + """Move a message to a different mailbox. Returns result dict.""" + acct_escaped = escape(account) + src_escaped = escape(source_mailbox) + dest_escaped = escape(dest_mailbox) + + script = f""" + tell application "Mail" + set srcMb to mailbox "{src_escaped}" of account "{acct_escaped}" + set destMb to mailbox "{dest_escaped}" of account "{acct_escaped}" + set theMsg to first message of srcMb whose id is {message_id} + set msgSubject to subject of theMsg + move theMsg to destMb + return msgSubject + end tell + """ + + subject = run(script) + return {"id": message_id, "subject": subject, "from": source_mailbox, "to": dest_mailbox, "account": account} + + +def delete_message(account: str, mailbox: str, message_id: int) -> dict: + """Delete a message by moving it to Trash. Returns result dict.""" + acct_escaped = escape(account) + mb_escaped = escape(mailbox) + + script = f""" + tell application "Mail" + set mb to mailbox "{mb_escaped}" of account "{acct_escaped}" + set theMsg to first message of mb whose id is {message_id} + set msgSubject to subject of theMsg + delete theMsg + return msgSubject + end tell + """ + + subject = run(script) + return {"id": message_id, "subject": subject, "status": "deleted", "account": account, "mailbox": mailbox} + + +def mark_junk(account: str, mailbox: str, message_id: int) -> dict: + """Mark a message as junk. Returns result dict.""" + acct_escaped = escape(account) + mb_escaped = escape(mailbox) + + script = set_message_property(f'"{acct_escaped}"', f'"{mb_escaped}"', message_id, "junk mail status", "true") + + subject = run(script) + return {"id": message_id, "subject": subject, "status": "junk", "account": account, "mailbox": mailbox} + + +def open_message(account: str, mailbox: str, message_id: int) -> dict: + """Open a message in Mail.app. Returns result dict.""" + acct_escaped = escape(account) + mb_escaped = escape(mailbox) + + script = f""" + tell application "Mail" + set theMb to mailbox "{mb_escaped}" of account "{acct_escaped}" + set theMsg to (first message of theMb whose id is {message_id}) + set msgSubject to subject of theMsg + if (count of message viewers) is 0 then + make new message viewer + end if + set selected mailboxes of first message viewer to {{theMb}} + set selected messages of first message viewer to {{theMsg}} + activate + return msgSubject + end tell + """ + + subject = run(script) + return {"opened": True, "message_id": message_id, "account": account, "mailbox": mailbox, "subject": subject} + + +# --------------------------------------------------------------------------- +# CLI handler helpers (take args, call data functions, print) +# --------------------------------------------------------------------------- + + +def _mark_read_status(args, read_status: bool) -> None: + account, mailbox, _, _ = resolve_message_context(args) + message_id = validate_msg_id(args.id) + result = set_read_status(account, mailbox, message_id, read_status) + status_word = result["status"] + format_output(args, f"Message '{truncate(result['subject'], 50)}' marked as {status_word}.", json_data=result) + + +def _flag_status(args, flagged: bool) -> None: + account, mailbox, _, _ = resolve_message_context(args) + message_id = validate_msg_id(args.id) + result = set_flag_status(account, mailbox, message_id, flagged) + status_word = result["status"] + format_output(args, f"Message '{truncate(result['subject'], 50)}' {status_word}.", json_data=result) def cmd_mark_read(args) -> None: @@ -79,46 +173,19 @@ def cmd_move(args) -> None: die("Both --from and --to mailboxes are required.") message_id = validate_msg_id(args.id) - acct_escaped = escape(account) source = resolve_mailbox(account, source) dest = resolve_mailbox(account, dest) - src_escaped = escape(source) - dest_escaped = escape(dest) - - script = f""" - tell application "Mail" - set srcMb to mailbox "{src_escaped}" of account "{acct_escaped}" - set destMb to mailbox "{dest_escaped}" of account "{acct_escaped}" - set theMsg to first message of srcMb whose id is {message_id} - set msgSubject to subject of theMsg - move theMsg to destMb - return msgSubject - end tell - """ - subject = run(script) - format_output(args, f"Message '{truncate(subject, 50)}' moved from '{source}' to '{dest}'.", - json_data={"id": message_id, "subject": subject, "from": source, "to": dest}) + result = move_message(account, source, message_id, dest) + format_output(args, f"Message '{truncate(result['subject'], 50)}' moved from '{source}' to '{dest}'.", json_data=result) def cmd_delete(args) -> None: """Delete a message by moving it to Trash.""" - account, mailbox, acct_escaped, mb_escaped = resolve_message_context(args) + account, mailbox, _, _ = resolve_message_context(args) message_id = validate_msg_id(args.id) - - script = f""" - tell application "Mail" - set mb to mailbox "{mb_escaped}" of account "{acct_escaped}" - set theMsg to first message of mb whose id is {message_id} - set msgSubject to subject of theMsg - delete theMsg - return msgSubject - end tell - """ - - subject = run(script) - format_output(args, f"Message '{truncate(subject, 50)}' moved to Trash.", - json_data={"id": message_id, "subject": subject, "status": "deleted"}) + result = delete_message(account, mailbox, message_id) + format_output(args, f"Message '{truncate(result['subject'], 50)}' moved to Trash.", json_data=result) # --------------------------------------------------------------------------- @@ -126,7 +193,6 @@ def cmd_delete(args) -> None: # --------------------------------------------------------------------------- - _PRIVATE_NETWORKS = [ ipaddress.ip_network("10.0.0.0/8"), ipaddress.ip_network("172.16.0.0/12"), @@ -196,11 +262,11 @@ def cmd_unsubscribe(args) -> None: unsub_post = " ".join(unsub_post) if not unsub_header: - format_output(args, - f"No unsubscribe option found for '{truncate(subject, 50)}'.\n" - "This email doesn't include a List-Unsubscribe header.", - json_data={"id": message_id, "subject": subject, "unsubscribe": False, - "reason": "No List-Unsubscribe header found"}) + format_output( + args, + f"No unsubscribe option found for '{truncate(subject, 50)}'.\nThis email doesn't include a List-Unsubscribe header.", + json_data={"id": message_id, "subject": subject, "unsubscribe": False, "reason": "No List-Unsubscribe header found"}, + ) return https_urls, mailto_urls = _extract_urls(unsub_header) @@ -218,9 +284,17 @@ def cmd_unsubscribe(args) -> None: text += "\n Mailto:" for u in mailto_urls: text += f"\n {u}" - format_output(args, text, json_data={ - "id": message_id, "subject": subject, "one_click_supported": one_click, - "https_urls": https_urls, "mailto_urls": mailto_urls}) + format_output( + args, + text, + json_data={ + "id": message_id, + "subject": subject, + "one_click_supported": one_click, + "https_urls": https_urls, + "mailto_urls": mailto_urls, + }, + ) return # Attempt one-click unsubscribe (RFC 8058) @@ -240,10 +314,11 @@ def cmd_unsubscribe(args) -> None: ) resp = urllib.request.urlopen(req, timeout=APPLESCRIPT_TIMEOUT_SHORT, context=ctx) status = resp.status - format_output(args, - f"Unsubscribed from '{truncate(subject, 50)}' via one-click (HTTP {status}).", - json_data={"id": message_id, "subject": subject, "unsubscribed": True, - "method": "one-click", "status_code": status}) + format_output( + args, + f"Unsubscribed from '{truncate(subject, 50)}' via one-click (HTTP {status}).", + json_data={"id": message_id, "subject": subject, "unsubscribed": True, "method": "one-click", "status_code": status}, + ) return except (urllib.error.URLError, OSError) as e: # One-click failed, fall through to browser @@ -255,20 +330,21 @@ def cmd_unsubscribe(args) -> None: if https_urls: url = https_urls[0] subprocess.run(["open", url], check=False) - format_output(args, - f"Opened unsubscribe page for '{truncate(subject, 50)}' in browser.", - json_data={"id": message_id, "subject": subject, "unsubscribed": "pending", - "method": "browser", "url": url}) + format_output( + args, + f"Opened unsubscribe page for '{truncate(subject, 50)}' in browser.", + json_data={"id": message_id, "subject": subject, "unsubscribed": "pending", "method": "browser", "url": url}, + ) return # Only mailto available if mailto_urls: addr = mailto_urls[0].replace("mailto:", "") - format_output(args, - f"No HTTPS unsubscribe link. Mailto only:\n {addr}\n" - "Send an email to that address to unsubscribe.", - json_data={"id": message_id, "subject": subject, "unsubscribed": False, - "method": "mailto_only", "mailto": addr}) + format_output( + args, + f"No HTTPS unsubscribe link. Mailto only:\n {addr}\nSend an email to that address to unsubscribe.", + json_data={"id": message_id, "subject": subject, "unsubscribed": False, "method": "mailto_only", "mailto": addr}, + ) return @@ -276,39 +352,37 @@ def cmd_unsubscribe(args) -> None: # junk / not-junk # --------------------------------------------------------------------------- + def cmd_junk(args) -> None: """Mark a message as junk or spam.""" import sys - account, mailbox, acct_escaped, mb_escaped = resolve_message_context(args) - message_id = validate_msg_id(args.id) - script = set_message_property( - f'"{acct_escaped}"', f'"{mb_escaped}"', message_id, - 'junk mail status', 'true' - ) + account, mailbox, _, _ = resolve_message_context(args) + message_id = validate_msg_id(args.id) # Run the AppleScript; if message not found, give a cross-account hint try: - subject = run(script) + result = mark_junk(account, mailbox, message_id) except SystemExit: - # run() already printed the error; add an actionable hint and re-raise + # mark_junk() already printed the error; add an actionable hint and re-raise explicit_account = getattr(args, "account", None) if not explicit_account: print( - "Hint: If this message belongs to another account, use -a ACCOUNT.\n" - " Run `mxctl accounts` to see account names.", + "Hint: If this message belongs to another account, use -a ACCOUNT.\n Run `mxctl accounts` to see account names.", file=sys.stderr, ) raise format_output( args, - f"Message '{truncate(subject, 50)}' marked as junk.", - json_data={"id": message_id, "subject": subject, "status": "junk"} + f"Message '{truncate(result['subject'], 50)}' marked as junk.", + json_data=result, ) -def _try_not_junk_in_mailbox(acct_escaped: str, junk_escaped: str, inbox_escaped: str, message_id: int, subject: str = "", sender: str = "") -> str | None: +def _try_not_junk_in_mailbox( + acct_escaped: str, junk_escaped: str, inbox_escaped: str, message_id: int, subject: str = "", sender: str = "" +) -> str | None: """Try to mark a message as not-junk from a specific mailbox. Uses subprocess directly so that individual mailbox attempts can fail silently @@ -364,17 +438,13 @@ def _try_not_junk_in_mailbox(acct_escaped: str, junk_escaped: str, inbox_escaped if result.returncode == 0: return result.stdout.strip() err_lower = result.stderr.strip().lower() - if ( - "can't get message" in err_lower - or "can't get mailbox" in err_lower - or "no messages matched" in err_lower - ): + if "can't get message" in err_lower or "can't get mailbox" in err_lower or "no messages matched" in err_lower: return None # Unexpected error — return None silently (don't leak internal AppleScript errors to user) return None -def cmd_not_junk(args) -> None: +def not_junk(account: str, message_id: int, custom_mailbox: str | None = None) -> dict: """Mark a message as not junk and move it back to INBOX. Searches the Junk mailbox (and Gmail [Gmail]/Spam for Gmail accounts) because @@ -383,13 +453,11 @@ def cmd_not_junk(args) -> None: Uses subject+sender search (not ID) to find the message in the junk folder, since IDs are mailbox-specific and become stale after a cross-mailbox move. - If a custom -m MAILBOX is given, only that mailbox is tried. + If custom_mailbox is given, only that mailbox is tried. + + Returns a result dict on success, or raises SystemExit on failure. """ import sys - account = resolve_account(getattr(args, "account", None)) - if not account: - die("Account required. Use -a ACCOUNT.") - message_id = validate_msg_id(args.id) acct_escaped = escape(account) inbox_mailbox = resolve_mailbox(account, "INBOX") @@ -403,6 +471,7 @@ def cmd_not_junk(args) -> None: orig_sender = "" try: import subprocess as _subprocess + fetch_script = f""" tell application "Mail" set acct to account "{acct_escaped}" @@ -424,7 +493,6 @@ def cmd_not_junk(args) -> None: except Exception: pass # Non-fatal — fall back to ID-based lookup below - custom_mailbox = getattr(args, "mailbox", None) if custom_mailbox: # User explicitly specified where to look — trust them, single attempt candidates = [resolve_mailbox(account, custom_mailbox)] @@ -433,6 +501,7 @@ def cmd_not_junk(args) -> None: junk_primary = resolve_mailbox(account, "Junk") candidates = [junk_primary] from mxctl.config import get_gmail_accounts + if account in get_gmail_accounts(): if "[Gmail]/Spam" not in candidates: candidates.append("[Gmail]/Spam") @@ -448,16 +517,15 @@ def cmd_not_junk(args) -> None: for junk_mailbox in candidates: junk_escaped = escape(junk_mailbox) subject = _try_not_junk_in_mailbox( - acct_escaped, junk_escaped, inbox_escaped, message_id, - subject=orig_subject, sender=orig_sender, + acct_escaped, + junk_escaped, + inbox_escaped, + message_id, + subject=orig_subject, + sender=orig_sender, ) if subject is not None: - format_output( - args, - f"Message '{truncate(subject, 50)}' marked as not junk and moved to INBOX.", - json_data={"id": message_id, "subject": subject, "status": "not_junk", "moved_to": "INBOX"} - ) - return + return {"id": message_id, "subject": subject, "status": "not_junk", "moved_to": "INBOX"} # All candidates failed — message not found in any junk folder tried = ", ".join(f'"{m}"' for m in candidates) @@ -469,37 +537,40 @@ def cmd_not_junk(args) -> None: sys.exit(1) -def cmd_open(args) -> None: - """Open a message in Mail.app.""" - account, mailbox, acct_escaped, mb_escaped = resolve_message_context(args) - message_id = validate_msg_id(args.id) +def cmd_not_junk(args) -> None: + """Mark a message as not junk and move it back to INBOX. - script = f""" - tell application "Mail" - set theMb to mailbox "{mb_escaped}" of account "{acct_escaped}" - set theMsg to (first message of theMb whose id is {message_id}) - set msgSubject to subject of theMsg - if (count of message viewers) is 0 then - make new message viewer - end if - set selected mailboxes of first message viewer to {{theMb}} - set selected messages of first message viewer to {{theMsg}} - activate - return msgSubject - end tell + Searches the Junk mailbox (and Gmail [Gmail]/Spam for Gmail accounts) because + AppleScript message IDs become invalid in the original mailbox once a message + is moved to Junk. + + Uses subject+sender search (not ID) to find the message in the junk folder, + since IDs are mailbox-specific and become stale after a cross-mailbox move. + If a custom -m MAILBOX is given, only that mailbox is tried. """ + account = resolve_account(getattr(args, "account", None)) + if not account: + die("Account required. Use -a ACCOUNT.") + message_id = validate_msg_id(args.id) + custom_mailbox = getattr(args, "mailbox", None) - subject = run(script) + result = not_junk(account, message_id, custom_mailbox=custom_mailbox) + format_output( + args, + f"Message '{truncate(result['subject'], 50)}' marked as not junk and moved to INBOX.", + json_data=result, + ) + + +def cmd_open(args) -> None: + """Open a message in Mail.app.""" + account, mailbox, _, _ = resolve_message_context(args) + message_id = validate_msg_id(args.id) + result = open_message(account, mailbox, message_id) format_output( args, f"Opened message {message_id} in Mail.app", - json_data={ - "opened": True, - "message_id": message_id, - "account": account, - "mailbox": mailbox, - "subject": subject, - }, + json_data=result, ) @@ -507,6 +578,7 @@ def cmd_open(args) -> None: # Registration # --------------------------------------------------------------------------- + def register(subparsers) -> None: """Register message action subcommands.""" # mark-read diff --git a/src/mxctl/commands/mail/ai.py b/src/mxctl/commands/mail/ai.py index 366be3e..46882a2 100644 --- a/src/mxctl/commands/mail/ai.py +++ b/src/mxctl/commands/mail/ai.py @@ -22,15 +22,15 @@ # summary — ultra-concise one-liner per unread # --------------------------------------------------------------------------- -def cmd_summary(args) -> None: - """Generate an ultra-concise one-liner per unread message.""" + +def get_summary() -> list[dict]: + """Fetch unread messages across all accounts for summary.""" inner_ops = f'set output to output & acctName & "{FIELD_SEPARATOR}" & (id of m) & "{FIELD_SEPARATOR}" & (subject of m) & "{FIELD_SEPARATOR}" & (sender of m) & "{FIELD_SEPARATOR}" & (date received of m) & linefeed' script = inbox_iterator_all_accounts(inner_ops, cap=20) result = run(script, timeout=APPLESCRIPT_TIMEOUT_LONG) if not result.strip(): - format_output(args, "No unread messages.") - return + return [] messages = [] for line in result.strip().split("\n"): @@ -39,6 +39,15 @@ def cmd_summary(args) -> None: msg = parse_message_line(line, ["account", "id", "subject", "sender", "date"], FIELD_SEPARATOR) if msg is not None: messages.append(msg) + return messages + + +def cmd_summary(args) -> None: + """Generate an ultra-concise one-liner per unread message.""" + messages = get_summary() + if not messages: + format_output(args, "No unread messages.") + return save_message_aliases([m["id"] for m in messages]) for i, m in enumerate(messages, 1): @@ -56,16 +65,15 @@ def cmd_summary(args) -> None: # triage — unread grouped by urgency/category # --------------------------------------------------------------------------- -def cmd_triage(args) -> None: - """Group unread messages by urgency and category.""" - account = resolve_account(getattr(args, "account", None)) + +def get_triage(account: str | None = None) -> dict: + """Fetch and categorize unread messages into flagged, people, and notifications.""" inner_ops = f'set output to output & acctName & "{FIELD_SEPARATOR}" & (id of m) & "{FIELD_SEPARATOR}" & (subject of m) & "{FIELD_SEPARATOR}" & (sender of m) & "{FIELD_SEPARATOR}" & (date received of m) & "{FIELD_SEPARATOR}" & (flagged status of m) & linefeed' script = inbox_iterator_all_accounts(inner_ops, cap=30, account=account) result = run(script, timeout=APPLESCRIPT_TIMEOUT_LONG) if not result.strip(): - format_output(args, "No unread messages. Inbox zero!") - return + return {"flagged": [], "people": [], "notifications": []} flagged = [] people = [] @@ -86,6 +94,22 @@ def cmd_triage(args) -> None: else: people.append(msg) + return {"flagged": flagged, "people": people, "notifications": notifications} + + +def cmd_triage(args) -> None: + """Group unread messages by urgency and category.""" + account = resolve_account(getattr(args, "account", None)) + data = get_triage(account=account) + + flagged = data["flagged"] + people = data["people"] + notifications = data["notifications"] + + if not flagged and not people and not notifications: + format_output(args, "No unread messages. Inbox zero!") + return + # Assign sequential aliases across all categories all_messages = flagged + people + notifications save_message_aliases([m["id"] for m in all_messages]) @@ -120,16 +144,15 @@ def cmd_triage(args) -> None: # context — message + full thread history # --------------------------------------------------------------------------- -def cmd_context(args) -> None: - """Show a message with full thread history.""" - account = resolve_account(getattr(args, "account", None)) - if not account: - die("Account required. Use -a ACCOUNT.") - mailbox = getattr(args, "mailbox", None) or DEFAULT_MAILBOX - message_id = validate_msg_id(args.id) - limit = max(1, min(getattr(args, "limit", 50), MAX_MESSAGES_BATCH)) - all_accounts = getattr(args, "all_accounts", False) +def get_context( + account: str, + mailbox: str, + message_id: int, + limit: int = 50, + all_accounts: bool = False, +) -> dict: + """Fetch a message and its full thread history.""" acct_escaped = escape(account) mb_escaped = escape(mailbox) @@ -165,11 +188,11 @@ def cmd_context(args) -> None: # Search for thread messages (current account or all accounts based on flag) if all_accounts: - acct_loop = 'repeat with acct in (every account)\nset acctName to name of acct' - acct_loop_end = 'end repeat' + acct_loop = "repeat with acct in (every account)\nset acctName to name of acct" + acct_loop_end = "end repeat" else: acct_loop = f'set acct to account "{acct_escaped}"\nset acctName to name of acct' - acct_loop_end = '' + acct_loop_end = "" thread_script = f""" tell application "Mail" @@ -205,15 +228,17 @@ def cmd_context(args) -> None: continue p = entry.split(FIELD_SEPARATOR) if len(p) >= 5: - thread_entries.append({ - "id": int(p[0]) if p[0].isdigit() else p[0], - "subject": p[1], - "from": p[2], - "date": p[3], - "body": FIELD_SEPARATOR.join(p[4:]), - }) - - data = { + thread_entries.append( + { + "id": int(p[0]) if p[0].isdigit() else p[0], + "subject": p[1], + "from": p[2], + "date": p[3], + "body": FIELD_SEPARATOR.join(p[4:]), + } + ) + + return { "message": { "id": message_id, "subject": subject, @@ -225,7 +250,34 @@ def cmd_context(args) -> None: "thread": thread_entries, } - text = f"=== Message ===\nFrom: {sender}\nTo: {to_list.rstrip(', ')}\nDate: {date}\nSubject: {subject}\n\n{content}" + +def cmd_context(args) -> None: + """Show a message with full thread history.""" + account = resolve_account(getattr(args, "account", None)) + if not account: + die("Account required. Use -a ACCOUNT.") + mailbox = getattr(args, "mailbox", None) or DEFAULT_MAILBOX + message_id = validate_msg_id(args.id) + limit = max(1, min(getattr(args, "limit", 50), MAX_MESSAGES_BATCH)) + all_accounts = getattr(args, "all_accounts", False) + + data = get_context( + account=account, + mailbox=mailbox, + message_id=message_id, + limit=limit, + all_accounts=all_accounts, + ) + + msg = data["message"] + thread_entries = data["thread"] + subject = msg["subject"] + sender = msg["from"] + to_list = msg["to"] + date = msg["date"] + content = msg["body"] + + text = f"=== Message ===\nFrom: {sender}\nTo: {to_list}\nDate: {date}\nSubject: {subject}\n\n{content}" if thread_entries: text += "\n\n=== Thread History ===" for t in thread_entries: @@ -238,10 +290,9 @@ def cmd_context(args) -> None: # find-related — search + group by conversation # --------------------------------------------------------------------------- -def cmd_find_related(args) -> None: - """Search for messages and group results by conversation.""" - query = args.query +def find_related(query: str) -> dict: + """Search for messages matching query and group by conversation thread.""" # If query is a numeric message ID, look up the message first if query.isdigit(): message_id = int(query) @@ -260,8 +311,7 @@ def cmd_find_related(args) -> None: """ lookup_result = run(lookup_script, timeout=APPLESCRIPT_TIMEOUT_LONG) if not lookup_result.strip(): - format_output(args, f"Message {message_id} not found.") - return + return {} parts = lookup_result.strip().split(FIELD_SEPARATOR) query = normalize_subject(parts[0]) @@ -291,21 +341,37 @@ def cmd_find_related(args) -> None: result = run(script, timeout=APPLESCRIPT_TIMEOUT_LONG) if not result.strip(): - format_output(args, f"No messages found matching '{query}'.") - return + return {} # Group by normalized subject (thread) - threads = defaultdict(list) + threads: dict = defaultdict(list) for line in result.strip().split("\n"): if not line.strip(): continue msg = parse_message_line(line, ["id", "subject", "sender", "date", "mailbox", "account"], FIELD_SEPARATOR) if msg is None: continue - # Normalize subject for grouping normalized = normalize_subject(msg["subject"]).lower() threads[normalized].append(msg) + return dict(threads) + + +def cmd_find_related(args) -> None: + """Search for messages and group results by conversation.""" + query = args.query + threads = find_related(query) + + # Resolve effective query for display (may have been normalized from ID lookup) + # Re-derive display query: if original was digit and threads is empty, it wasn't found + if query.isdigit() and not threads: + format_output(args, f"Message {query} not found.") + return + + if not threads: + format_output(args, f"No messages found matching '{query}'.") + return + # Assign sequential aliases across all threads all_msgs_flat = [] for _, msgs_list in sorted(threads.items(), key=lambda x: -len(x[1])): @@ -314,7 +380,9 @@ def cmd_find_related(args) -> None: for i, m in enumerate(all_msgs_flat, 1): m["alias"] = i - text = f"Related messages for '{query}' ({len(threads)} conversations):" + # Use the resolved query for display (first thread key is closest to it) + display_query = query if not query.isdigit() else next(iter(threads)) + text = f"Related messages for '{display_query}' ({len(threads)} conversations):" for thread_subject, msgs in sorted(threads.items(), key=lambda x: -len(x[1])): text += f"\n\n {thread_subject} ({len(msgs)} messages):" for m in msgs[:5]: @@ -322,13 +390,14 @@ def cmd_find_related(args) -> None: text += f"\n [{m['alias']}] {truncate(sender, 20)} — {m['date']}" if len(msgs) > 5: text += f"\n ... and {len(msgs) - 5} more" - format_output(args, text, json_data=dict(threads)) + format_output(args, text, json_data=threads) # --------------------------------------------------------------------------- # Registration # --------------------------------------------------------------------------- + def register(subparsers) -> None: """Register AI-optimized mail subcommands.""" p = subparsers.add_parser("summary", help="Ultra-concise one-liner per unread (AI-optimized)") diff --git a/src/mxctl/commands/mail/analytics.py b/src/mxctl/commands/mail/analytics.py index c326a74..fd5146a 100644 --- a/src/mxctl/commands/mail/analytics.py +++ b/src/mxctl/commands/mail/analytics.py @@ -25,11 +25,8 @@ # --------------------------------------------------------------------------- -def cmd_top_senders(args) -> None: - """Show most frequent email senders over a time period.""" - days = getattr(args, "days", 30) - limit = getattr(args, "limit", DEFAULT_TOP_SENDERS_LIMIT) - +def get_top_senders(days: int = 30, limit: int = DEFAULT_TOP_SENDERS_LIMIT) -> list[dict]: + """Fetch and rank most frequent senders over the given number of days.""" since_dt = datetime.now() - timedelta(days=days) since_as = to_applescript_date(since_dt) @@ -61,16 +58,28 @@ def cmd_top_senders(args) -> None: result = run(script, timeout=APPLESCRIPT_TIMEOUT_LONG) if not result.strip(): - format_output(args, f"No messages found in the last {days} days.", json_data={"days": days, "senders": []}) - return + return [] counter = Counter(line.strip() for line in result.strip().split("\n") if line.strip()) top = counter.most_common(limit) + return [{"sender": s, "count": c} for s, c in top] + + +def cmd_top_senders(args) -> None: + """Show most frequent email senders over a time period.""" + days = getattr(args, "days", 30) + limit = getattr(args, "limit", DEFAULT_TOP_SENDERS_LIMIT) + + top = get_top_senders(days=days, limit=limit) + + if not top: + format_output(args, f"No messages found in the last {days} days.", json_data={"days": days, "senders": []}) + return text = f"Top {limit} senders (last {days} days):" - for i, (sender, count) in enumerate(top, 1): - text += f"\n {i}. {truncate(sender, 50)} — {count} messages" - format_output(args, text, json_data=[{"sender": s, "count": c} for s, c in top]) + for i, entry in enumerate(top, 1): + text += f"\n {i}. {truncate(entry['sender'], 50)} — {entry['count']} messages" + format_output(args, text, json_data=top) # --------------------------------------------------------------------------- @@ -78,8 +87,8 @@ def cmd_top_senders(args) -> None: # --------------------------------------------------------------------------- -def cmd_digest(args) -> None: - """Show unread messages grouped by sender domain.""" +def get_digest() -> dict: + """Fetch unread messages and group them by sender domain.""" script = f""" tell application "Mail" set output to "" @@ -108,18 +117,16 @@ def cmd_digest(args) -> None: result = run(script, timeout=APPLESCRIPT_TIMEOUT_LONG) if not result.strip(): - format_output(args, "No unread messages. Inbox zero!") - return + return {} # Group by sender domain - groups = defaultdict(list) + groups: dict = defaultdict(list) for line in result.strip().split("\n"): if not line.strip(): continue msg = parse_message_line(line, ["account", "id", "subject", "sender", "date"], FIELD_SEPARATOR) if msg is None: continue - # Extract domain from sender email = extract_email(msg["sender"]) if "@" in email: domain = email.split("@")[1].lower() @@ -127,6 +134,17 @@ def cmd_digest(args) -> None: domain = "other" groups[domain].append(msg) + return dict(groups) + + +def cmd_digest(args) -> None: + """Show unread messages grouped by sender domain.""" + groups = get_digest() + + if not groups: + format_output(args, "No unread messages. Inbox zero!") + return + # Collect all messages into a flat list for sequential aliases all_messages = [] for _domain, msgs in sorted(groups.items(), key=lambda x: -len(x[1])): @@ -144,7 +162,7 @@ def cmd_digest(args) -> None: text += f"\n From: {truncate(m['sender'], 40)}" if len(msgs) > 5: text += f"\n ... and {len(msgs) - 5} more" - format_output(args, text, json_data=dict(groups)) + format_output(args, text, json_data=groups) # --------------------------------------------------------------------------- @@ -152,19 +170,20 @@ def cmd_digest(args) -> None: # --------------------------------------------------------------------------- -def cmd_stats(args) -> None: - """Show message count and unread count for a mailbox, or account-wide stats with --all.""" - show_all = getattr(args, "all", False) - # For --all, we need to know if the user *explicitly* passed -a, not just the resolved default. - # resolve_account() falls back to the configured default (e.g. iCloud), which would cause - # --all without -a to incorrectly use the single-account branch. - explicit_account = getattr(args, "account", None) - account = resolve_account(explicit_account) +def get_stats( + show_all: bool = False, + account: str | None = None, + explicit_account: str | None = None, + mailbox: str = DEFAULT_MAILBOX, +) -> dict: + """Fetch mailbox or account-wide stats. + Returns a dict with keys depending on mode: + - Single mailbox: {"mailbox", "account", "total", "unread"} + - All mailboxes: {"scope", "total_messages", "total_unread", "mailboxes"} + """ if show_all: - # Only use the account branch when the user explicitly specified -a. if explicit_account: - # --all -a ACCOUNT: stats for every mailbox in one account acct_escaped = escape(account) script = f""" tell application "Mail" @@ -185,7 +204,6 @@ def cmd_stats(args) -> None: end tell """ else: - # --all (no -a): stats for every mailbox across ALL accounts script = f""" tell application "Mail" set output to "" @@ -211,16 +229,12 @@ def cmd_stats(args) -> None: result = run(script, timeout=APPLESCRIPT_TIMEOUT_LONG) lines = result.strip().split("\n") if not lines: # pragma: no cover — str.split() always returns at least [""] - scope = f"account '{account}'" if explicit_account else "any account" - format_output(args, f"No mailboxes found in {scope}.", json_data={"mailboxes": []}) - return + return {"scope": account if explicit_account else "all", "total_messages": 0, "total_unread": 0, "mailboxes": []} - # First line has grand totals totals_parts = lines[0].split(FIELD_SEPARATOR) grand_total = int(totals_parts[0]) if len(totals_parts) >= 1 and totals_parts[0].isdigit() else 0 grand_unread = int(totals_parts[1]) if len(totals_parts) >= 2 and totals_parts[1].isdigit() else 0 - # Subsequent lines: acctName|mbName|total|unread mailboxes = [] for line in lines[1:]: if not line.strip(): @@ -236,30 +250,13 @@ def cmd_stats(args) -> None: } ) - # Build text output — use explicit_account so resolved defaults don't bleed in. - scope_label = f"Account: {account}" if explicit_account else "All Accounts" - text = f"{scope_label}\n" - text += f"Total: {grand_total} messages, {grand_unread} unread\n" - text += f"\nMailboxes ({len(mailboxes)}):" - for mb in mailboxes: - acct_prefix = "" if explicit_account else f"[{mb['account']}] " - text += f"\n {acct_prefix}{mb['name']}: {mb['total']} messages, {mb['unread']} unread" - - format_output( - args, - text, - json_data={ - "scope": account if explicit_account else "all", - "total_messages": grand_total, - "total_unread": grand_unread, - "mailboxes": mailboxes, - }, - ) + return { + "scope": account if explicit_account else "all", + "total_messages": grand_total, + "total_unread": grand_unread, + "mailboxes": mailboxes, + } else: - # Single mailbox stats (existing behavior) - if not account: - die("Account required. Use -a ACCOUNT.") - mailbox = getattr(args, "mailbox", None) or DEFAULT_MAILBOX acct_escaped = escape(account) mb_escaped = escape(mailbox) @@ -276,11 +273,47 @@ def cmd_stats(args) -> None: parts = result.split(FIELD_SEPARATOR) total = int(parts[0]) if len(parts) >= 1 and parts[0].isdigit() else 0 unread = int(parts[1]) if len(parts) >= 2 and parts[1].isdigit() else 0 + return {"mailbox": mailbox, "account": account, "total": total, "unread": unread} + + +def cmd_stats(args) -> None: + """Show message count and unread count for a mailbox, or account-wide stats with --all.""" + show_all = getattr(args, "all", False) + # For --all, we need to know if the user *explicitly* passed -a, not just the resolved default. + # resolve_account() falls back to the configured default (e.g. iCloud), which would cause + # --all without -a to incorrectly use the single-account branch. + explicit_account = getattr(args, "account", None) + account = resolve_account(explicit_account) + + if show_all: + data = get_stats(show_all=True, account=account, explicit_account=explicit_account) + mailboxes = data["mailboxes"] + + if not mailboxes: + scope = f"account '{account}'" if explicit_account else "any account" + format_output(args, f"No mailboxes found in {scope}.", json_data={"mailboxes": []}) + return + + scope_label = f"Account: {account}" if explicit_account else "All Accounts" + text = f"{scope_label}\n" + text += f"Total: {data['total_messages']} messages, {data['total_unread']} unread\n" + text += f"\nMailboxes ({len(mailboxes)}):" + for mb in mailboxes: + acct_prefix = "" if explicit_account else f"[{mb['account']}] " + text += f"\n {acct_prefix}{mb['name']}: {mb['total']} messages, {mb['unread']} unread" + format_output(args, text, json_data=data) + else: + # Single mailbox stats (existing behavior) + if not account: + die("Account required. Use -a ACCOUNT.") + mailbox = getattr(args, "mailbox", None) or DEFAULT_MAILBOX + + data = get_stats(show_all=False, account=account, mailbox=mailbox) format_output( args, - f"{mailbox} [{account}]: {total} messages, {unread} unread", - json_data={"mailbox": mailbox, "account": account, "total": total, "unread": unread}, + f"{mailbox} [{account}]: {data['total']} messages, {data['unread']} unread", + json_data=data, ) @@ -289,13 +322,9 @@ def cmd_stats(args) -> None: # --------------------------------------------------------------------------- -def cmd_show_flagged(args) -> None: - """List all flagged messages.""" - account = resolve_account(getattr(args, "account", None)) - limit = validate_limit(getattr(args, "limit", DEFAULT_MESSAGE_LIMIT)) - +def get_flagged_messages(account: str | None = None, limit: int = DEFAULT_MESSAGE_LIMIT) -> list[dict]: + """Fetch all flagged messages, optionally filtered by account.""" if account: - # Search in specific account only acct_escaped = escape(account) script = f""" tell application "Mail" @@ -316,7 +345,6 @@ def cmd_show_flagged(args) -> None: end tell """ else: - # Search across all accounts script = f""" tell application "Mail" set output to "" @@ -340,13 +368,9 @@ def cmd_show_flagged(args) -> None: """ result = run(script, timeout=APPLESCRIPT_TIMEOUT_LONG) - if not result.strip(): - scope = f" in account '{account}'" if account else " across all accounts" - format_output(args, f"No flagged messages found{scope}.", json_data={"flagged_messages": []}) - return + return [] - # Build JSON data messages = [] for line in result.strip().split("\n"): if not line.strip(): @@ -354,12 +378,25 @@ def cmd_show_flagged(args) -> None: msg = parse_message_line(line, ["id", "subject", "sender", "date", "mailbox", "account"], FIELD_SEPARATOR) if msg is not None: messages.append(msg) + return messages + + +def cmd_show_flagged(args) -> None: + """List all flagged messages.""" + account = resolve_account(getattr(args, "account", None)) + limit = validate_limit(getattr(args, "limit", DEFAULT_MESSAGE_LIMIT)) + + messages = get_flagged_messages(account=account, limit=limit) + + if not messages: + scope = f" in account '{account}'" if account else " across all accounts" + format_output(args, f"No flagged messages found{scope}.", json_data={"flagged_messages": []}) + return save_message_aliases([m["id"] for m in messages]) for i, m in enumerate(messages, 1): m["alias"] = i - # Build text output scope = f" in account '{account}'" if account else " across all accounts" text = f"Flagged messages{scope} (showing up to {limit}):" for m in messages: diff --git a/src/mxctl/commands/mail/attachments.py b/src/mxctl/commands/mail/attachments.py index 03a8699..06a7508 100644 --- a/src/mxctl/commands/mail/attachments.py +++ b/src/mxctl/commands/mail/attachments.py @@ -8,48 +8,44 @@ from mxctl.util.mail_helpers import resolve_message_context -def cmd_attachments(args) -> None: - """List attachments on a message.""" - account, mailbox, acct_escaped, mb_escaped = resolve_message_context(args) - message_id = validate_msg_id(args.id) +def get_attachments(account: str, mailbox: str, message_id: int) -> dict: + """Return subject and attachment list for a message. - script = list_attachments(f'"{acct_escaped}"', f'"{mb_escaped}"', message_id) + Returns dict with keys: subject (str), attachments (list[str]). + """ + acct_escaped = escape(account) + mb_escaped = escape(mailbox) + script = list_attachments(f'"{acct_escaped}"', f'"{mb_escaped}"', message_id) result = run(script) lines = result.strip().split("\n") - if len(lines) <= 1: - subject = lines[0] if lines else "Unknown" - format_output( - args, - f"No attachments in message '{truncate(subject, 50)}'.", - json_data={"subject": subject, "attachments": []} - ) - return + subject = lines[0] if lines else "Unknown" + att_list = [a for a in lines[1:] if a.strip()] if len(lines) > 1 else [] + return {"subject": subject, "attachments": att_list} - subject = lines[0] - att_list = [a for a in lines[1:] if a.strip()] - text = f"Attachments in '{truncate(subject, 50)}':" - for i, att in enumerate(att_list, 1): - text += f"\n {i}. {att}" - format_output(args, text, json_data={"subject": subject, "attachments": att_list}) +def save_attachment( + account: str, + mailbox: str, + message_id: int, + attachment: str, + output_dir: str, +) -> dict: + """Save a named attachment from a message to disk. + *attachment* may be an index string (e.g. "1") or a name/prefix. + *output_dir* must be an existing directory path. -def cmd_save_attachment(args) -> None: - """Save an attachment from a message to disk.""" - account, mailbox, acct_escaped, mb_escaped = resolve_message_context(args) - message_id = validate_msg_id(args.id) - attachment = args.attachment - output_dir = sanitize_path(getattr(args, "output_dir", "~/Downloads")) + Returns dict with keys: message_id, subject, attachment, saved_to. + """ + acct_escaped = escape(account) + mb_escaped = escape(mailbox) - # Ensure output directory exists if not os.path.isdir(output_dir): die(f"Output directory does not exist: {output_dir}") - # First, get the list of attachments to resolve index vs name list_script = list_attachments(f'"{acct_escaped}"', f'"{mb_escaped}"', message_id) - result = run(list_script) lines = result.strip().split("\n") @@ -75,7 +71,6 @@ def cmd_save_attachment(args) -> None: if exact_matches: att_name = exact_matches[0] else: - # Try prefix match prefix_matches = [a for a in att_list if a.startswith(attachment)] if len(prefix_matches) == 1: att_name = prefix_matches[0] @@ -93,13 +88,9 @@ def cmd_save_attachment(args) -> None: if not real_save.startswith(real_base + os.sep) and real_save != real_base: die("Unsafe attachment filename: path traversal detected.") - save_path_posix = save_path # Already absolute from sanitize_path + join - - # Escape for AppleScript att_name_escaped = escape(att_name) - save_path_posix_escaped = escape(save_path_posix) + save_path_posix_escaped = escape(save_path) - # AppleScript to save the attachment save_script = f""" tell application "Mail" set mb to mailbox "{mb_escaped}" of account "{acct_escaped}" @@ -119,19 +110,56 @@ def cmd_save_attachment(args) -> None: except SystemExit: die(f"Failed to save attachment '{att_name}'.") - # Verify file was created if not os.path.isfile(save_path): die(f"Attachment save reported success but file not found: {save_path}") + return { + "message_id": message_id, + "subject": subject, + "attachment": att_name, + "saved_to": save_path, + } + + +def cmd_attachments(args) -> None: + """List attachments on a message.""" + account, mailbox, acct_escaped, mb_escaped = resolve_message_context(args) + message_id = validate_msg_id(args.id) + + data = get_attachments(account, mailbox, message_id) + subject = data["subject"] + att_list = data["attachments"] + + if not att_list: + format_output( + args, + f"No attachments in message '{truncate(subject, 50)}'.", + json_data=data, + ) + return + + text = f"Attachments in '{truncate(subject, 50)}':" + for i, att in enumerate(att_list, 1): + text += f"\n {i}. {att}" + format_output(args, text, json_data=data) + + +def cmd_save_attachment(args) -> None: + """Save an attachment from a message to disk.""" + account, mailbox, acct_escaped, mb_escaped = resolve_message_context(args) + message_id = validate_msg_id(args.id) + attachment = args.attachment + output_dir = sanitize_path(getattr(args, "output_dir", "~/Downloads")) + + data = save_attachment(account, mailbox, message_id, attachment, output_dir) + att_name = data["attachment"] + subject = data["subject"] + save_path = data["saved_to"] + format_output( args, f"Saved attachment '{att_name}' from message '{truncate(subject, 50)}' to:\n {save_path}", - json_data={ - "message_id": message_id, - "subject": subject, - "attachment": att_name, - "saved_to": save_path, - } + json_data=data, ) diff --git a/src/mxctl/commands/mail/batch.py b/src/mxctl/commands/mail/batch.py index 73125a0..e6ff21f 100644 --- a/src/mxctl/commands/mail/batch.py +++ b/src/mxctl/commands/mail/batch.py @@ -15,17 +15,12 @@ from mxctl.util.mail_helpers import resolve_mailbox # --------------------------------------------------------------------------- -# batch-read — mark all as read in a mailbox +# Data functions (plain args, return dicts, no printing) # --------------------------------------------------------------------------- -def cmd_batch_read(args) -> None: - """Mark all messages as read in a mailbox.""" - account = resolve_account(getattr(args, "account", None)) - if not account: - die("Account required. Use -a ACCOUNT.") - mailbox = getattr(args, "mailbox", None) or DEFAULT_MAILBOX - limit = getattr(args, "limit", None) or 25 +def batch_read(account: str, mailbox: str, limit: int) -> dict: + """Mark up to `limit` unread messages as read in a mailbox. Returns result dict.""" acct_escaped = escape(account) mb_escaped = escape(mailbox) @@ -46,26 +41,11 @@ def cmd_batch_read(args) -> None: result = run(script) count = int(result) if result.isdigit() else 0 - log_fence_operation("batch-read") - format_output(args, f"Marked {count} messages as read in {mailbox} [{account}] (limit: {limit}).", - json_data={"mailbox": mailbox, "account": account, "marked_read": count, "limit": limit}) - print(f"Note: batch-read operations are not tracked in undo history. Use --limit N to cap scope (current: {limit} messages).", file=sys.stderr) + return {"mailbox": mailbox, "account": account, "marked_read": count, "limit": limit} -# --------------------------------------------------------------------------- -# batch-flag — flag all from a sender -# --------------------------------------------------------------------------- - -def cmd_batch_flag(args) -> None: - """Flag all messages from a specific sender.""" - account = resolve_account(getattr(args, "account", None)) - if not account: - die("Account required. Use -a ACCOUNT.") - sender = getattr(args, "from_sender", None) - if not sender: - die("--from-sender is required.") - limit = getattr(args, "limit", None) or 25 - +def batch_flag(account: str, sender: str, limit: int) -> dict: + """Flag up to `limit` messages from a sender across all mailboxes. Returns result dict.""" acct_escaped = escape(account) sender_escaped = escape(sender) @@ -87,32 +67,11 @@ def cmd_batch_flag(args) -> None: result = run(script) count = int(result) if result.isdigit() else 0 - log_fence_operation("batch-flag") - format_output(args, f"Flagged {count} messages from '{sender}' in account '{account}' (limit: {limit}).", - json_data={"sender": sender, "account": account, "flagged": count, "limit": limit}) - print(f"Note: batch-flag operations are not tracked in undo history. Use --limit N to cap scope (current: {limit} messages).", file=sys.stderr) - - -# --------------------------------------------------------------------------- -# batch-move — move all messages from a sender to a folder -# --------------------------------------------------------------------------- - -def cmd_batch_move(args) -> None: - """Move all messages from a sender to a mailbox.""" - account = resolve_account(getattr(args, "account", None)) - if not account: - die("Account required. Use -a ACCOUNT.") - sender = getattr(args, "from_sender", None) - if not sender: - die("--from-sender is required.") - dest_mailbox = getattr(args, "to_mailbox", None) - if not dest_mailbox: - die("--to-mailbox is required.") - dest_mailbox = resolve_mailbox(account, dest_mailbox) + return {"sender": sender, "account": account, "flagged": count, "limit": limit} - dry_run = getattr(args, "dry_run", False) - limit = getattr(args, "limit", None) +def batch_move(account: str, sender: str, dest_mailbox: str, dry_run: bool = False, limit: int | None = None) -> dict: + """Move messages from a sender to a mailbox. Returns result dict.""" acct_escaped = escape(account) sender_escaped = escape(sender) dest_escaped = escape(dest_mailbox) @@ -133,15 +92,18 @@ def cmd_batch_move(args) -> None: total_count = int(count_result) if count_result.isdigit() else 0 if total_count == 0: - format_output(args, f"No messages found from sender '{sender}'.", - json_data={"sender": sender, "account": account, "moved": 0}) - return + return {"sender": sender, "account": account, "moved": 0, "total_matching": 0} if dry_run: effective_count = min(total_count, limit) if limit else total_count - format_output(args, f"Dry run: Would move {effective_count} messages from '{sender}' to '{dest_mailbox}'.", - json_data={"sender": sender, "to_mailbox": dest_mailbox, "account": account, "would_move": effective_count, "total_matching": total_count, "dry_run": True}) - return + return { + "sender": sender, + "to_mailbox": dest_mailbox, + "account": account, + "would_move": effective_count, + "total_matching": total_count, + "dry_run": True, + } # Actually move the messages and collect their IDs for undo logging limit_clause = f"if moveCount >= {limit} then exit repeat" if limit else "" @@ -178,50 +140,29 @@ def cmd_batch_move(args) -> None: moved = int(lines[0]) if lines and lines[0].isdigit() else 0 message_ids = [int(line) for line in lines[1:] if line.isdigit()] - # Log the operation for undo if moved > 0: - # We don't track source mailbox per-message, so we'll use None - # The undo will move from dest back to the original location log_batch_operation( operation_type="batch-move", account=account, message_ids=message_ids, - source_mailbox=None, # Multiple source mailboxes possible + source_mailbox=None, dest_mailbox=dest_mailbox, sender=sender, ) - format_output(args, f"Moved {moved} messages from '{sender}' to '{dest_mailbox}'.", - json_data={"sender": sender, "to_mailbox": dest_mailbox, "account": account, "moved": moved}) - - -# --------------------------------------------------------------------------- -# batch-delete — delete messages by sender and/or age from a mailbox -# --------------------------------------------------------------------------- - -def cmd_batch_delete(args) -> None: - """Delete messages matching sender and/or age filters.""" - account = resolve_account(getattr(args, "account", None)) - if not account: - die("Account required. Use -a ACCOUNT.") - - mailbox = getattr(args, "mailbox", None) - older_than_days = getattr(args, "older_than", None) - sender = getattr(args, "from_sender", None) - dry_run = getattr(args, "dry_run", False) - force = getattr(args, "force", False) - limit = getattr(args, "limit", None) - - if older_than_days is None and sender is None: - die("Specify --older-than , --from-sender , or both.") - - # --older-than without --from-sender still requires --mailbox for safety - if older_than_days is not None and sender is None and not mailbox: - die("--mailbox is required when using --older-than without --from-sender.") + return {"sender": sender, "to_mailbox": dest_mailbox, "account": account, "moved": moved} - if mailbox: - mailbox = resolve_mailbox(account, mailbox) +def batch_delete( + account: str, + mailbox: str | None, + older_than_days: int | None, + sender: str | None, + dry_run: bool = False, + force: bool = False, + limit: int | None = None, +) -> dict: + """Delete messages matching sender and/or age filters. Returns result dict.""" acct_escaped = escape(account) # Build AppleScript whose-clause @@ -270,25 +211,34 @@ def cmd_batch_delete(args) -> None: total_count = int(count_result) if count_result.isdigit() else 0 if total_count == 0: - format_output(args, f"No messages found {filter_desc} in {scope_desc}.", - json_data={"account": account, "mailbox": mailbox, "sender": sender, - "older_than_days": older_than_days, "deleted": 0}) - return + return { + "account": account, + "mailbox": mailbox, + "sender": sender, + "older_than_days": older_than_days, + "deleted": 0, + "filter_desc": filter_desc, + "scope_desc": scope_desc, + } if dry_run: effective_count = min(total_count, limit) if limit else total_count - format_output(args, f"Dry run: Would delete {effective_count} messages {filter_desc} from {scope_desc}.", - json_data={"account": account, "mailbox": mailbox, "sender": sender, - "older_than_days": older_than_days, "would_delete": effective_count, "total_matching": total_count, "dry_run": True}) - return + return { + "account": account, + "mailbox": mailbox, + "sender": sender, + "older_than_days": older_than_days, + "would_delete": effective_count, + "total_matching": total_count, + "dry_run": True, + "filter_desc": filter_desc, + "scope_desc": scope_desc, + } if not force: die(f"This will delete {total_count} messages {filter_desc} from {scope_desc}. Use --force to confirm.") # Build delete script - # Use "repeat with m in list" (not indexed) so deletions don't shift remaining references. - # Wrap each delete in try/end try so a single failure (e.g. Gmail All Mail quirk) doesn't - # abort the whole batch — failures are silently skipped and the count reflects actual deletes. limit_check = f"if deleteCount >= {limit} then exit repeat" if limit else "" if mailbox: mb_escaped = escape(mailbox) @@ -356,15 +306,170 @@ def cmd_batch_delete(args) -> None: older_than_days=older_than_days, ) - format_output(args, f"Deleted {deleted} messages {filter_desc} from {scope_desc}.", - json_data={"account": account, "mailbox": mailbox, "sender": sender, - "older_than_days": older_than_days, "deleted": deleted}) + return { + "account": account, + "mailbox": mailbox, + "sender": sender, + "older_than_days": older_than_days, + "deleted": deleted, + "filter_desc": filter_desc, + "scope_desc": scope_desc, + } + + +# --------------------------------------------------------------------------- +# batch-read — mark all as read in a mailbox +# --------------------------------------------------------------------------- + + +def cmd_batch_read(args) -> None: + """Mark all messages as read in a mailbox.""" + account = resolve_account(getattr(args, "account", None)) + if not account: + die("Account required. Use -a ACCOUNT.") + mailbox = getattr(args, "mailbox", None) or DEFAULT_MAILBOX + limit = getattr(args, "limit", None) or 25 + + result = batch_read(account, mailbox, limit) + count = result["marked_read"] + log_fence_operation("batch-read") + format_output(args, f"Marked {count} messages as read in {mailbox} [{account}] (limit: {limit}).", json_data=result) + print( + f"Note: batch-read operations are not tracked in undo history. Use --limit N to cap scope (current: {limit} messages).", + file=sys.stderr, + ) + + +# --------------------------------------------------------------------------- +# batch-flag — flag all from a sender +# --------------------------------------------------------------------------- + + +def cmd_batch_flag(args) -> None: + """Flag all messages from a specific sender.""" + account = resolve_account(getattr(args, "account", None)) + if not account: + die("Account required. Use -a ACCOUNT.") + sender = getattr(args, "from_sender", None) + if not sender: + die("--from-sender is required.") + limit = getattr(args, "limit", None) or 25 + + result = batch_flag(account, sender, limit) + count = result["flagged"] + log_fence_operation("batch-flag") + format_output(args, f"Flagged {count} messages from '{sender}' in account '{account}' (limit: {limit}).", json_data=result) + print( + f"Note: batch-flag operations are not tracked in undo history. Use --limit N to cap scope (current: {limit} messages).", + file=sys.stderr, + ) + + +# --------------------------------------------------------------------------- +# batch-move — move all messages from a sender to a folder +# --------------------------------------------------------------------------- + + +def cmd_batch_move(args) -> None: + """Move all messages from a sender to a mailbox.""" + account = resolve_account(getattr(args, "account", None)) + if not account: + die("Account required. Use -a ACCOUNT.") + sender = getattr(args, "from_sender", None) + if not sender: + die("--from-sender is required.") + dest_mailbox = getattr(args, "to_mailbox", None) + if not dest_mailbox: + die("--to-mailbox is required.") + dest_mailbox = resolve_mailbox(account, dest_mailbox) + + dry_run = getattr(args, "dry_run", False) + limit = getattr(args, "limit", None) + + result = batch_move(account, sender, dest_mailbox, dry_run=dry_run, limit=limit) + + if result.get("moved") == 0 and result.get("total_matching") == 0: + format_output(args, f"No messages found from sender '{sender}'.", json_data=result) + return + + if dry_run: + format_output(args, f"Dry run: Would move {result['would_move']} messages from '{sender}' to '{dest_mailbox}'.", json_data=result) + return + + moved = result["moved"] + format_output(args, f"Moved {moved} messages from '{sender}' to '{dest_mailbox}'.", json_data=result) + + +# --------------------------------------------------------------------------- +# batch-delete — delete messages by sender and/or age from a mailbox +# --------------------------------------------------------------------------- + + +def cmd_batch_delete(args) -> None: + """Delete messages matching sender and/or age filters.""" + account = resolve_account(getattr(args, "account", None)) + if not account: + die("Account required. Use -a ACCOUNT.") + + mailbox = getattr(args, "mailbox", None) + older_than_days = getattr(args, "older_than", None) + sender = getattr(args, "from_sender", None) + dry_run = getattr(args, "dry_run", False) + force = getattr(args, "force", False) + limit = getattr(args, "limit", None) + + if older_than_days is None and sender is None: + die("Specify --older-than , --from-sender , or both.") + + # --older-than without --from-sender still requires --mailbox for safety + if older_than_days is not None and sender is None and not mailbox: + die("--mailbox is required when using --older-than without --from-sender.") + + if mailbox: + mailbox = resolve_mailbox(account, mailbox) + + result = batch_delete(account, mailbox, older_than_days, sender, dry_run=dry_run, force=force, limit=limit) + + filter_desc = result.get("filter_desc", "") + scope_desc = result.get("scope_desc", "") + + if result.get("deleted") == 0 and not dry_run and "would_delete" not in result: + format_output( + args, + f"No messages found {filter_desc} in {scope_desc}.", + json_data={"account": account, "mailbox": mailbox, "sender": sender, "older_than_days": older_than_days, "deleted": 0}, + ) + return + + if dry_run: + format_output( + args, + f"Dry run: Would delete {result['would_delete']} messages {filter_desc} from {scope_desc}.", + json_data={ + "account": account, + "mailbox": mailbox, + "sender": sender, + "older_than_days": older_than_days, + "would_delete": result["would_delete"], + "total_matching": result["total_matching"], + "dry_run": True, + }, + ) + return + + deleted = result["deleted"] + format_output( + args, + f"Deleted {deleted} messages {filter_desc} from {scope_desc}.", + json_data={"account": account, "mailbox": mailbox, "sender": sender, "older_than_days": older_than_days, "deleted": deleted}, + ) # --------------------------------------------------------------------------- # Registration # --------------------------------------------------------------------------- + def register(subparsers) -> None: """Register batch mail subcommands.""" p = subparsers.add_parser("batch-read", help="Mark messages as read in a mailbox") diff --git a/src/mxctl/commands/mail/compose.py b/src/mxctl/commands/mail/compose.py index a49b8b0..dbaa198 100644 --- a/src/mxctl/commands/mail/compose.py +++ b/src/mxctl/commands/mail/compose.py @@ -8,44 +8,18 @@ from mxctl.util.formatting import die, format_output -def cmd_draft(args) -> None: - """Create a draft email for manual review and sending.""" - account = resolve_account(getattr(args, "account", None)) - if not account: - die("Account required. Use -a ACCOUNT.") - - to_addr = args.to - subject = args.subject - body = args.body - cc = getattr(args, "cc", None) - bcc = getattr(args, "bcc", None) - - # Handle template loading - template_name = getattr(args, "template", None) - if template_name: - if os.path.isfile(TEMPLATES_FILE): - with file_lock(TEMPLATES_FILE), open(TEMPLATES_FILE) as f: - try: - templates = json.load(f) - except (json.JSONDecodeError, OSError): - die("Templates file is corrupt. Run 'mxctl templates list' to diagnose.") - if template_name not in templates: - die(f"Template '{template_name}' not found. Use 'mxctl templates list' to see available templates.") - template = templates[template_name] - # Apply template, allowing flag overrides - if not subject: - subject = template.get("subject", "") - if not body: - body = template.get("body", "") - else: - die("No templates file found. Create templates with 'mxctl templates create'.") - - # Validate that we have subject and body - if not subject: - die("Subject required. Use --subject or --template.") - if not body: - die("Body required. Use --body or --template.") - +def create_draft( + account: str, + to_addr: str, + subject: str, + body: str, + cc: str | None = None, + bcc: str | None = None, +) -> dict: + """Create a draft email in Mail.app. Returns result dict. + + Has Mail.app side effects (opens a draft window). + """ acct_escaped = escape(account) subject_escaped = escape(subject) body_escaped = escape(body) @@ -54,27 +28,21 @@ def cmd_draft(args) -> None: for addr in to_addr.split(","): addr = addr.strip() if addr: - to_commands.append( - f'make new to recipient at end of to recipients with properties {{address:"{escape(addr)}"}}' - ) + to_commands.append(f'make new to recipient at end of to recipients with properties {{address:"{escape(addr)}"}}') cc_commands = [] if cc: for addr in cc.split(","): addr = addr.strip() if addr: - cc_commands.append( - f'make new cc recipient at end of cc recipients with properties {{address:"{escape(addr)}"}}' - ) + cc_commands.append(f'make new cc recipient at end of cc recipients with properties {{address:"{escape(addr)}"}}') bcc_commands = [] if bcc: for addr in bcc.split(","): addr = addr.strip() if addr: - bcc_commands.append( - f'make new bcc recipient at end of bcc recipients with properties {{address:"{escape(addr)}"}}' - ) + bcc_commands.append(f'make new bcc recipient at end of bcc recipients with properties {{address:"{escape(addr)}"}}') all_recipient_commands = "\n ".join(to_commands + cc_commands + bcc_commands) @@ -107,6 +75,48 @@ def cmd_draft(args) -> None: data["cc"] = cc if bcc: data["bcc"] = bcc + return data + + +def cmd_draft(args) -> None: + """Create a draft email for manual review and sending.""" + account = resolve_account(getattr(args, "account", None)) + if not account: + die("Account required. Use -a ACCOUNT.") + + to_addr = args.to + subject = args.subject + body = args.body + cc = getattr(args, "cc", None) + bcc = getattr(args, "bcc", None) + + # Handle template loading + template_name = getattr(args, "template", None) + if template_name: + if os.path.isfile(TEMPLATES_FILE): + with file_lock(TEMPLATES_FILE), open(TEMPLATES_FILE) as f: + try: + templates = json.load(f) + except (json.JSONDecodeError, OSError): + die("Templates file is corrupt. Run 'mxctl templates list' to diagnose.") + if template_name not in templates: + die(f"Template '{template_name}' not found. Use 'mxctl templates list' to see available templates.") + template = templates[template_name] + # Apply template, allowing flag overrides + if not subject: + subject = template.get("subject", "") + if not body: + body = template.get("body", "") + else: + die("No templates file found. Create templates with 'mxctl templates create'.") + + # Validate that we have subject and body + if not subject: + die("Subject required. Use --subject or --template.") + if not body: + die("Body required. Use --body or --template.") + + data = create_draft(account, to_addr, subject, body, cc=cc, bcc=bcc) text = f"Draft created successfully!\n\nTo: {to_addr}" if cc: diff --git a/src/mxctl/commands/mail/composite.py b/src/mxctl/commands/mail/composite.py index b703449..956290d 100644 --- a/src/mxctl/commands/mail/composite.py +++ b/src/mxctl/commands/mail/composite.py @@ -22,31 +22,21 @@ # export — save message(s) as markdown # --------------------------------------------------------------------------- -def cmd_export(args) -> None: - """Export message(s) as markdown files.""" - account = resolve_account(getattr(args, "account", None)) - if not account: - die("Account required. Use -a ACCOUNT.") - - target = args.target # could be a message ID or mailbox name - dest = args.to - after = getattr(args, "after", None) - - # If target is numeric, it's a single message export - if target.isdigit(): - _export_single(args, int(target), account, getattr(args, "mailbox", None) or DEFAULT_MAILBOX, dest) - else: - _export_bulk(args, target, account, dest, after) - -def _export_single(args, msg_id: int, account: str, mailbox: str, dest: str) -> None: +def export_message( + account: str, + mailbox: str, + message_id: int, + dest: str, +) -> dict: + """Export a single message as a markdown file. Returns dict with path and subject.""" acct_escaped = escape(account) mb_escaped = escape(mailbox) script = f""" tell application "Mail" set mb to mailbox "{mb_escaped}" of account "{acct_escaped}" - set theMsg to first message of mb whose id is {msg_id} + set theMsg to first message of mb whose id is {message_id} set msgSubject to subject of theMsg set msgSender to sender of theMsg set msgDate to date received of theMsg @@ -69,8 +59,8 @@ def _export_single(args, msg_id: int, account: str, mailbox: str, dest: str) -> subject, sender, date, to_list, content = parts[:5] # Build markdown - safe_subject = re.sub(r'[^\w\s-]', '', subject).strip().replace(' ', '-')[:60] - filename = f"{safe_subject}.md" if safe_subject else f"message-{msg_id}.md" + safe_subject = re.sub(r"[^\w\s-]", "", subject).strip().replace(" ", "-")[:60] + filename = f"{safe_subject}.md" if safe_subject else f"message-{message_id}.md" md = f"# {subject}\n\n" md += f"**From:** {sender} \n" @@ -85,7 +75,9 @@ def _export_single(args, msg_id: int, account: str, mailbox: str, dest: str) -> # Guard against path traversal in the generated filename real_filepath = os.path.realpath(os.path.abspath(filepath)) real_dest = os.path.realpath(os.path.abspath(dest_path)) - if not real_filepath.startswith(real_dest + os.sep) and real_filepath != real_dest: # pragma: no cover — re.sub strips dangerous chars before this + if ( + not real_filepath.startswith(real_dest + os.sep) and real_filepath != real_dest + ): # pragma: no cover — re.sub strips dangerous chars before this die("Unsafe export filename: path traversal detected.") else: filepath = dest_path @@ -94,16 +86,23 @@ def _export_single(args, msg_id: int, account: str, mailbox: str, dest: str) -> with open(filepath, "w", encoding="utf-8") as f: f.write(md) - format_output(args, f"Exported to: {filepath}", json_data={"path": filepath, "subject": subject}) + return {"path": filepath, "subject": subject} -def _export_bulk(args, mailbox: str, account: str, dest: str, after: str | None) -> None: +def export_messages( + account: str, + mailbox: str, + dest: str, + after: str | None = None, +) -> dict: + """Export messages from a mailbox as markdown files. Returns dict with directory and count.""" acct_escaped = escape(account) mb_escaped = escape(mailbox) whose = "" if after: from mxctl.util.dates import parse_date, to_applescript_date + dt = parse_date(after) whose = f'whose date received >= date "{to_applescript_date(dt)}"' @@ -138,13 +137,15 @@ def _export_bulk(args, mailbox: str, account: str, dest: str, after: str | None) continue msg_id, subject, sender, date, content = parts[0], parts[1], parts[2], parts[3], FIELD_SEPARATOR.join(parts[4:]) - safe_subject = re.sub(r'[^\w\s-]', '', subject).strip().replace(' ', '-')[:50] + safe_subject = re.sub(r"[^\w\s-]", "", subject).strip().replace(" ", "-")[:50] filename = f"{safe_subject}-{msg_id}.md" if safe_subject else f"message-{msg_id}.md" filepath = os.path.join(dest_dir, filename) real_filepath = os.path.realpath(os.path.abspath(filepath)) real_dest = os.path.realpath(os.path.abspath(dest_dir)) - if not real_filepath.startswith(real_dest + os.sep) and real_filepath != real_dest: # pragma: no cover — re.sub strips dangerous chars before this + if ( + not real_filepath.startswith(real_dest + os.sep) and real_filepath != real_dest + ): # pragma: no cover — re.sub strips dangerous chars before this continue md = f"# {subject}\n\n**From:** {sender} \n**Date:** {date}\n\n---\n\n{content}" @@ -152,24 +153,61 @@ def _export_bulk(args, mailbox: str, account: str, dest: str, after: str | None) f.write(md) exported += 1 - format_output(args, f"Exported {exported} messages to {dest_dir}", - json_data={"directory": dest_dir, "exported": exported}) + return {"directory": dest_dir, "exported": exported} -# --------------------------------------------------------------------------- -# thread — show conversation thread -# --------------------------------------------------------------------------- +# Legacy private wrappers kept for test compatibility +def _export_single(args, msg_id: int, account: str, mailbox: str, dest: str) -> None: + data = export_message(account, mailbox, msg_id, dest) + format_output(args, f"Exported to: {data['path']}", json_data=data) -def cmd_thread(args) -> None: - """Show full conversation thread for a message.""" + +def _export_bulk(args, mailbox: str, account: str, dest: str, after: str | None) -> None: + data = export_messages(account, mailbox, dest, after) + format_output( + args, + f"Exported {data['exported']} messages to {data['directory']}", + json_data=data, + ) + + +def cmd_export(args) -> None: + """Export message(s) as markdown files.""" account = resolve_account(getattr(args, "account", None)) if not account: die("Account required. Use -a ACCOUNT.") - mailbox = getattr(args, "mailbox", None) or DEFAULT_MAILBOX - message_id = validate_msg_id(args.id) - limit = getattr(args, "limit", 100) - all_accounts = getattr(args, "all_accounts", False) + target = args.target # could be a message ID or mailbox name + dest = args.to + after = getattr(args, "after", None) + + # If target is numeric, it's a single message export + if target.isdigit(): + mailbox = getattr(args, "mailbox", None) or DEFAULT_MAILBOX + data = export_message(account, mailbox, int(target), dest) + format_output(args, f"Exported to: {data['path']}", json_data=data) + else: + data = export_messages(account, target, dest, after) + format_output( + args, + f"Exported {data['exported']} messages to {data['directory']}", + json_data=data, + ) + + +# --------------------------------------------------------------------------- +# thread — show conversation thread +# --------------------------------------------------------------------------- + + +def get_thread( + account: str, + mailbox: str, + message_id: int, + limit: int = 100, + all_accounts: bool = False, +) -> dict: + """Fetch thread messages for a given message. Returns dict with thread_subject and messages list.""" acct_escaped = escape(account) mb_escaped = escape(mailbox) @@ -189,11 +227,11 @@ def cmd_thread(args) -> None: # Search for messages with this subject (default: current account only) if all_accounts: - acct_loop = 'repeat with acct in (every account)\nset acctName to name of acct' - acct_loop_end = 'end repeat' + acct_loop = "repeat with acct in (every account)\nset acctName to name of acct" + acct_loop_end = "end repeat" else: acct_loop = f'set acct to account "{acct_escaped}"\nset acctName to name of acct' - acct_loop_end = '' + acct_loop_end = "" script2 = f""" tell application "Mail" @@ -216,18 +254,36 @@ def cmd_thread(args) -> None: """ result = run(script2, timeout=APPLESCRIPT_TIMEOUT_LONG) - if not result.strip(): - format_output(args, f"No thread found for '{subject}'.", - json_data={"thread_subject": thread_subject, "messages": []}) - return messages = [] - for line in result.strip().split("\n"): - if not line.strip(): - continue - msg = parse_message_line(line, ["id", "subject", "sender", "date", "mailbox", "account"], FIELD_SEPARATOR) - if msg is not None: - messages.append(msg) + if result.strip(): + for line in result.strip().split("\n"): + if not line.strip(): + continue + msg = parse_message_line(line, ["id", "subject", "sender", "date", "mailbox", "account"], FIELD_SEPARATOR) + if msg is not None: + messages.append(msg) + + return {"thread_subject": thread_subject, "messages": messages} + + +def cmd_thread(args) -> None: + """Show full conversation thread for a message.""" + account = resolve_account(getattr(args, "account", None)) + if not account: + die("Account required. Use -a ACCOUNT.") + mailbox = getattr(args, "mailbox", None) or DEFAULT_MAILBOX + message_id = validate_msg_id(args.id) + limit = getattr(args, "limit", 100) + all_accounts = getattr(args, "all_accounts", False) + + data = get_thread(account, mailbox, message_id, limit=limit, all_accounts=all_accounts) + thread_subject = data["thread_subject"] + messages = data["messages"] + + if not messages: + format_output(args, f"No thread found for '{thread_subject}'.", json_data=data) + return save_message_aliases([m["id"] for m in messages]) for i, m in enumerate(messages, 1): @@ -244,15 +300,14 @@ def cmd_thread(args) -> None: # reply — create a reply draft # --------------------------------------------------------------------------- -def cmd_reply(args) -> None: - """Create a reply draft for a message.""" - account = resolve_account(getattr(args, "account", None)) - if not account: - die("Account required. Use -a ACCOUNT.") - mailbox = getattr(args, "mailbox", None) or DEFAULT_MAILBOX - message_id = validate_msg_id(args.id) - body = args.body +def create_reply( + account: str, + mailbox: str, + message_id: int, + body: str, +) -> dict: + """Create a reply draft for a message. Returns dict with status, to, and subject.""" acct_escaped = escape(account) mb_escaped = escape(mailbox) @@ -309,24 +364,38 @@ def cmd_reply(args) -> None: run(draft_script) - format_output(args, - f"Reply draft created.\nTo: {reply_to}\nSubject: {reply_subject}\n\nOpen in Mail.app to review and send.", - json_data={"status": "reply_draft_created", "to": reply_to, "subject": reply_subject}) - + return {"status": "reply_draft_created", "to": reply_to, "subject": reply_subject} -# --------------------------------------------------------------------------- -# forward — create a forward draft -# --------------------------------------------------------------------------- -def cmd_forward(args) -> None: - """Create a forward draft for a message.""" +def cmd_reply(args) -> None: + """Create a reply draft for a message.""" account = resolve_account(getattr(args, "account", None)) if not account: die("Account required. Use -a ACCOUNT.") mailbox = getattr(args, "mailbox", None) or DEFAULT_MAILBOX message_id = validate_msg_id(args.id) - to_addr = args.to + body = args.body + + data = create_reply(account, mailbox, message_id, body) + format_output( + args, + f"Reply draft created.\nTo: {data['to']}\nSubject: {data['subject']}\n\nOpen in Mail.app to review and send.", + json_data=data, + ) + + +# --------------------------------------------------------------------------- +# forward — create a forward draft +# --------------------------------------------------------------------------- + +def create_forward( + account: str, + mailbox: str, + message_id: int, + to_addr: str, +) -> dict: + """Create a forward draft for a message. Returns dict with status, to, and subject.""" acct_escaped = escape(account) mb_escaped = escape(mailbox) @@ -380,15 +449,31 @@ def cmd_forward(args) -> None: run(draft_script) - format_output(args, - f"Forward draft created.\nTo: {to_addr}\nSubject: {fwd_subject}\n\nOpen in Mail.app to review and send.", - json_data={"status": "forward_draft_created", "to": to_addr, "subject": fwd_subject}) + return {"status": "forward_draft_created", "to": to_addr, "subject": fwd_subject} + + +def cmd_forward(args) -> None: + """Create a forward draft for a message.""" + account = resolve_account(getattr(args, "account", None)) + if not account: + die("Account required. Use -a ACCOUNT.") + mailbox = getattr(args, "mailbox", None) or DEFAULT_MAILBOX + message_id = validate_msg_id(args.id) + to_addr = args.to + + data = create_forward(account, mailbox, message_id, to_addr) + format_output( + args, + f"Forward draft created.\nTo: {to_addr}\nSubject: {data['subject']}\n\nOpen in Mail.app to review and send.", + json_data=data, + ) # --------------------------------------------------------------------------- # Registration # --------------------------------------------------------------------------- + def register(subparsers) -> None: """Register composite mail subcommands.""" # export diff --git a/src/mxctl/commands/mail/inbox_tools.py b/src/mxctl/commands/mail/inbox_tools.py index feb9bb5..727b6d1 100644 --- a/src/mxctl/commands/mail/inbox_tools.py +++ b/src/mxctl/commands/mail/inbox_tools.py @@ -23,6 +23,7 @@ # Private helpers — AppleScript builders # --------------------------------------------------------------------------- + def _build_process_inbox_script(account: str | None, limit: int) -> str: """Return an AppleScript that scans INBOX(es) for unread messages. @@ -102,9 +103,7 @@ def _build_newsletters_script(account: str | None, mailbox: str, limit: int) -> Otherwise all enabled accounts are scanned up to *limit* total messages. Output rows: sender|read_status """ - msg_row = ( - f'set output to output & (sender of m) & "{FIELD_SEPARATOR}" & (read status of m) & linefeed' - ) + msg_row = f'set output to output & (sender of m) & "{FIELD_SEPARATOR}" & (read status of m) & linefeed' if account: acct_escaped = escape(account) mb_escaped = escape(mailbox) @@ -159,6 +158,43 @@ def _build_newsletters_script(account: str | None, mailbox: str, limit: int) -> # process-inbox — categorize unread messages and suggest actions # --------------------------------------------------------------------------- + +def get_inbox_categories(account: str | None, limit: int) -> dict: + """Categorize unread inbox messages into flagged, people, and notifications. + + Returns dict with keys: total, flagged, people, notifications (each a list of message dicts). + """ + script = _build_process_inbox_script(account, limit) + result = run(script, timeout=APPLESCRIPT_TIMEOUT_LONG) + + flagged = [] + people = [] + notifications = [] + + if result.strip(): + for line in result.strip().split("\n"): + if not line.strip(): + continue + msg = parse_message_line(line, ["account", "id", "subject", "sender", "date", "flagged"], FIELD_SEPARATOR) + if msg is None: + continue + + if msg["flagged"]: + flagged.append(msg) + elif any(p in extract_email(msg["sender"]).lower() for p in NOREPLY_PATTERNS): + notifications.append(msg) + else: + people.append(msg) + + total = len(flagged) + len(people) + len(notifications) + return { + "total": total, + "flagged": flagged, + "people": people, + "notifications": notifications, + } + + def cmd_process_inbox(args) -> None: """Read-only diagnostic: categorize unread messages and output action plan.""" # Use only the explicitly-passed -a flag, not the config default. @@ -167,38 +203,22 @@ def cmd_process_inbox(args) -> None: account = getattr(args, "account", None) limit = validate_limit(getattr(args, "limit", 50)) - script = _build_process_inbox_script(account, limit) - result = run(script, timeout=APPLESCRIPT_TIMEOUT_LONG) - if not result.strip(): + json_data = get_inbox_categories(account, limit) + flagged = json_data["flagged"] + people = json_data["people"] + notifications = json_data["notifications"] + total = json_data["total"] + + if total == 0: format_output(args, "No unread messages found.") return - # Parse and categorize messages - flagged = [] - people = [] - notifications = [] - - for line in result.strip().split("\n"): - if not line.strip(): - continue - msg = parse_message_line(line, ["account", "id", "subject", "sender", "date", "flagged"], FIELD_SEPARATOR) - if msg is None: - continue - - if msg["flagged"]: - flagged.append(msg) - elif any(p in extract_email(msg["sender"]).lower() for p in NOREPLY_PATTERNS): - notifications.append(msg) - else: - people.append(msg) - # Assign sequential aliases across all categories all_messages = flagged + people + notifications save_message_aliases([m["id"] for m in all_messages]) for i, m in enumerate(all_messages, 1): m["alias"] = i - total = len(flagged) + len(people) + len(notifications) text = f"Inbox Processing Plan ({total} unread):" # Suggest actions for each category @@ -210,8 +230,8 @@ def cmd_process_inbox(args) -> None: if len(flagged) > 5: text += f"\n ... and {len(flagged) - 5} more" text += "\n\nSuggested commands:" - text += f"\n mxctl read -a \"{flagged[0]['account']}\"" - text += f"\n mxctl to-todoist -a \"{flagged[0]['account']}\" --priority 4" + text += f'\n mxctl read -a "{flagged[0]["account"]}"' + text += f'\n mxctl to-todoist -a "{flagged[0]["account"]}" --priority 4' if people: text += f"\n\nPEOPLE ({len(people)}) — Requires attention:" @@ -221,8 +241,8 @@ def cmd_process_inbox(args) -> None: if len(people) > 5: text += f"\n ... and {len(people) - 5} more" text += "\n\nSuggested commands:" - text += f"\n mxctl read -a \"{people[0]['account']}\"" - text += f"\n mxctl mark-read -a \"{people[0]['account']}\"" + text += f'\n mxctl read -a "{people[0]["account"]}"' + text += f'\n mxctl mark-read -a "{people[0]["account"]}"' if notifications: text += f"\n\nNOTIFICATIONS ({len(notifications)}) — Bulk actions:" @@ -232,15 +252,9 @@ def cmd_process_inbox(args) -> None: if len(notifications) > 5: text += f"\n ... and {len(notifications) - 5} more" text += "\n\nSuggested commands:" - text += f"\n mxctl batch-read -a \"{notifications[0]['account']}\"" - text += f"\n mxctl unsubscribe -a \"{notifications[0]['account']}\"" + text += f'\n mxctl batch-read -a "{notifications[0]["account"]}"' + text += f'\n mxctl unsubscribe -a "{notifications[0]["account"]}"' - json_data = { - "total": total, - "flagged": flagged, - "people": people, - "notifications": notifications, - } format_output(args, text, json_data=json_data) @@ -248,21 +262,22 @@ def cmd_process_inbox(args) -> None: # clean-newsletters — identify bulk senders + suggest cleanup # --------------------------------------------------------------------------- -def cmd_clean_newsletters(args) -> None: - """Identify likely newsletter senders and suggest batch-move commands.""" - account = resolve_account(getattr(args, "account", None)) - mailbox = getattr(args, "mailbox", None) or DEFAULT_MAILBOX - limit = max(1, min(getattr(args, "limit", 200), MAX_MESSAGES_BATCH)) +def get_newsletter_senders(account: str | None, mailbox: str, limit: int) -> dict: + """Identify likely newsletter senders from a mailbox. + + Returns dict with keys: + - newsletters (list[dict]): identified newsletter senders + - has_messages (bool): False if the mailbox had no messages at all + """ script = _build_newsletters_script(account, mailbox, limit) result = run(script, timeout=APPLESCRIPT_TIMEOUT_LONG) + if not result.strip(): - scope = f"in {mailbox} [{account}]" if account else "in INBOX across all accounts" - format_output(args, f"No messages found {scope}.", json_data={"newsletters": []}) - return + return {"newsletters": [], "has_messages": False} # Group by sender email - sender_stats = defaultdict(lambda: {"total": 0, "unread": 0}) + sender_stats: dict = defaultdict(lambda: {"total": 0, "unread": 0}) for line in result.strip().split("\n"): if not line.strip(): continue @@ -278,19 +293,34 @@ def cmd_clean_newsletters(args) -> None: # Identify likely newsletters newsletters = [] for email, stats in sender_stats.items(): - is_likely_newsletter = ( - stats["total"] >= 3 or - any(pattern in email.lower() for pattern in NOREPLY_PATTERNS) - ) + is_likely_newsletter = stats["total"] >= 3 or any(pattern in email.lower() for pattern in NOREPLY_PATTERNS) if is_likely_newsletter: - newsletters.append({ - "sender": email, - "total_messages": stats["total"], - "unread_messages": stats["unread"], - }) + newsletters.append( + { + "sender": email, + "total_messages": stats["total"], + "unread_messages": stats["unread"], + } + ) # Sort by message count descending newsletters.sort(key=lambda x: x["total_messages"], reverse=True) + return {"newsletters": newsletters, "has_messages": True} + + +def cmd_clean_newsletters(args) -> None: + """Identify likely newsletter senders and suggest batch-move commands.""" + account = resolve_account(getattr(args, "account", None)) + mailbox = getattr(args, "mailbox", None) or DEFAULT_MAILBOX + limit = max(1, min(getattr(args, "limit", 200), MAX_MESSAGES_BATCH)) + + data = get_newsletter_senders(account, mailbox, limit) + newsletters = data["newsletters"] + + if not data.get("has_messages", True): + scope = f"in {mailbox} [{account}]" if account else "in INBOX across all accounts" + format_output(args, f"No messages found {scope}.", json_data={"newsletters": []}) + return if not newsletters: format_output(args, "No newsletter senders identified.", json_data={"newsletters": []}) @@ -305,22 +335,23 @@ def cmd_clean_newsletters(args) -> None: text += f"\n Total: {nl['total_messages']} messages ({nl['unread_messages']} unread)" # Suggest cleanup command - acct_flag = f"-a \"{account}\"" if account else "" - cleanup_cmd = f"mxctl batch-move --from-sender \"{nl['sender']}\" --to-mailbox \"Newsletters\" {acct_flag}" + acct_flag = f'-a "{account}"' if account else "" + cleanup_cmd = f'mxctl batch-move --from-sender "{nl["sender"]}" --to-mailbox "Newsletters" {acct_flag}' text += f"\n Cleanup: {cleanup_cmd}" - format_output(args, text, json_data={"newsletters": newsletters}) + format_output(args, text, json_data=data) # --------------------------------------------------------------------------- # weekly-review — flagged + unreplied + attachment report # --------------------------------------------------------------------------- -def cmd_weekly_review(args) -> None: - """Generate weekly review: flagged, messages with attachments, unreplied from people.""" - account = resolve_account(getattr(args, "account", None)) - days = getattr(args, "days", 7) +def get_weekly_review(account: str | None, days: int) -> dict: + """Generate weekly review data: flagged, attachment, and unreplied messages. + + Returns dict with day/account context plus three message lists. + """ # Calculate date threshold since_dt = datetime.now() - timedelta(days=days) since_as = to_applescript_date(since_dt) @@ -330,38 +361,38 @@ def cmd_weekly_review(args) -> None: # Category 1: Flagged messages (all mailboxes, not date-filtered) flagged_inner = ( - f'set flaggedMsgs to (every message of mb whose flagged status is true)\n' - f' repeat with m in flaggedMsgs\n' + f"set flaggedMsgs to (every message of mb whose flagged status is true)\n" + f" repeat with m in flaggedMsgs\n" f' set output to output & (id of m) & "{FIELD_SEPARATOR}" & (subject of m) & "{FIELD_SEPARATOR}" & (sender of m) & "{FIELD_SEPARATOR}" & (date received of m) & linefeed\n' - f' end repeat' + f" end repeat" ) flagged_script = mailbox_iterator(flagged_inner, account=acct_escaped) # Category 2: Messages with attachments from last N days attachments_inner = ( f'set msgs to (every message of mb whose date received >= date "{since_as}")\n' - f' set msgCount to count of msgs\n' - f' set cap to {MAX_MESSAGES_BATCH}\n' - f' if msgCount < cap then set cap to msgCount\n' - f' repeat with i from 1 to cap\n' - f' set m to item i of msgs\n' - f' if (count of mail attachments of m) > 0 then\n' + f" set msgCount to count of msgs\n" + f" set cap to {MAX_MESSAGES_BATCH}\n" + f" if msgCount < cap then set cap to msgCount\n" + f" repeat with i from 1 to cap\n" + f" set m to item i of msgs\n" + f" if (count of mail attachments of m) > 0 then\n" f' set output to output & (id of m) & "{FIELD_SEPARATOR}" & (subject of m) & "{FIELD_SEPARATOR}" & (sender of m) & "{FIELD_SEPARATOR}" & (date received of m) & "{FIELD_SEPARATOR}" & (count of mail attachments of m) & linefeed\n' - f' end if\n' - f' end repeat' + f" end if\n" + f" end repeat" ) attachments_script = mailbox_iterator(attachments_inner, account=acct_escaped) # Category 3: Unreplied messages from people (last N days, not yet replied to) unreplied_inner = ( f'set msgs to (every message of mb whose date received >= date "{since_as}" and was replied to is false)\n' - f' set msgCount to count of msgs\n' - f' set cap to {MAX_MESSAGES_BATCH}\n' - f' if msgCount < cap then set cap to msgCount\n' - f' repeat with i from 1 to cap\n' - f' set m to item i of msgs\n' + f" set msgCount to count of msgs\n" + f" set cap to {MAX_MESSAGES_BATCH}\n" + f" if msgCount < cap then set cap to msgCount\n" + f" repeat with i from 1 to cap\n" + f" set m to item i of msgs\n" f' set output to output & (id of m) & "{FIELD_SEPARATOR}" & (subject of m) & "{FIELD_SEPARATOR}" & (sender of m) & "{FIELD_SEPARATOR}" & (date received of m) & linefeed\n' - f' end repeat' + f" end repeat" ) unreplied_script = mailbox_iterator(unreplied_inner, account=acct_escaped) @@ -401,10 +432,31 @@ def cmd_weekly_review(args) -> None: if msg is None: continue sender_email = extract_email(msg["sender"]) - # Skip if sender matches noreply patterns if not any(pattern in sender_email.lower() for pattern in NOREPLY_PATTERNS): unreplied_messages.append(msg) + return { + "days": days, + "account": account, + "flagged_count": len(flagged_messages), + "attachment_count": len(attachment_messages), + "unreplied_count": len(unreplied_messages), + "flagged_messages": flagged_messages, + "attachment_messages": attachment_messages, + "unreplied_messages": unreplied_messages, + } + + +def cmd_weekly_review(args) -> None: + """Generate weekly review: flagged, messages with attachments, unreplied from people.""" + account = resolve_account(getattr(args, "account", None)) + days = getattr(args, "days", 7) + + json_data = get_weekly_review(account, days) + flagged_messages = json_data["flagged_messages"] + attachment_messages = json_data["attachment_messages"] + unreplied_messages = json_data["unreplied_messages"] + # Assign sequential aliases across all sections all_messages = flagged_messages + attachment_messages + unreplied_messages save_message_aliases([m["id"] for m in all_messages]) @@ -459,18 +511,6 @@ def cmd_weekly_review(args) -> None: if not flagged_messages and not unreplied_messages and not attachment_messages: text += "\n • Great job! Your inbox is clean." - # Build JSON response - json_data = { - "days": days, - "account": account, - "flagged_count": len(flagged_messages), - "attachment_count": len(attachment_messages), - "unreplied_count": len(unreplied_messages), - "flagged_messages": flagged_messages, - "attachment_messages": attachment_messages, - "unreplied_messages": unreplied_messages, - } - format_output(args, text, json_data=json_data) @@ -478,6 +518,7 @@ def cmd_weekly_review(args) -> None: # Registration # --------------------------------------------------------------------------- + def register(subparsers) -> None: # process-inbox p = subparsers.add_parser("process-inbox", help="Categorize unread messages and suggest actions") diff --git a/src/mxctl/commands/mail/manage.py b/src/mxctl/commands/mail/manage.py index 0d21e1e..182eaff 100644 --- a/src/mxctl/commands/mail/manage.py +++ b/src/mxctl/commands/mail/manage.py @@ -8,13 +8,8 @@ from mxctl.util.mail_helpers import resolve_mailbox -def cmd_create_mailbox(args) -> None: - """Create a new mailbox.""" - account = resolve_account(getattr(args, "account", None)) - if not account: - die("Account required. Use -a ACCOUNT.") - name = args.name - +def create_mailbox(account: str, name: str) -> dict: + """Create a new mailbox in the given account. Returns result dict.""" acct_escaped = escape(account) mb_escaped = escape(name) @@ -27,23 +22,11 @@ def cmd_create_mailbox(args) -> None: """ run(script) - format_output( - args, - f"Mailbox '{name}' created in account '{account}'.", - json_data={"mailbox": name, "account": account, "status": "created"} - ) - - -def cmd_delete_mailbox(args) -> None: - """Delete a mailbox and all its messages.""" - account = resolve_account(getattr(args, "account", None)) - if not account: - die("Account required. Use -a ACCOUNT.") - name = args.name + return {"mailbox": name, "account": account, "status": "created"} - if not getattr(args, "force", False): - die(f"Deleting mailbox '{name}' is permanent and cannot be undone. Re-run with --force to confirm.") +def delete_mailbox(account: str, name: str) -> dict: + """Delete a mailbox and all its messages. Returns result dict with message count.""" acct_escaped = escape(account) mb_escaped = escape(name) @@ -69,36 +52,60 @@ def cmd_delete_mailbox(args) -> None: """ run(delete_script) + return {"mailbox": name, "account": account, "status": "deleted", "messages_deleted": msg_count} + + +def cmd_create_mailbox(args) -> None: + """Create a new mailbox.""" + account = resolve_account(getattr(args, "account", None)) + if not account: + die("Account required. Use -a ACCOUNT.") + name = args.name + + data = create_mailbox(account, name) + format_output( + args, + f"Mailbox '{name}' created in account '{account}'.", + json_data=data, + ) + + +def cmd_delete_mailbox(args) -> None: + """Delete a mailbox and all its messages.""" + account = resolve_account(getattr(args, "account", None)) + if not account: + die("Account required. Use -a ACCOUNT.") + name = args.name + + if not getattr(args, "force", False): + die(f"Deleting mailbox '{name}' is permanent and cannot be undone. Re-run with --force to confirm.") + + data = delete_mailbox(account, name) + msg_count = data["messages_deleted"] warning = f" ({msg_count} messages were deleted)" if msg_count > 0 else "" format_output( args, f"Mailbox '{name}' deleted from account '{account}'.{warning}", - json_data={"mailbox": name, "account": account, "status": "deleted", "messages_deleted": msg_count} + json_data=data, ) -def cmd_empty_trash(args) -> None: - """Empty the Trash via Mail.app's Erase Deleted Items menu. +def empty_trash(account: str | None, all_accounts: bool) -> dict: + """Open the erase-deleted-items dialog for an account or all accounts. - Uses System Events to trigger the menu command, which opens a - confirmation dialog for the user to approve manually. + Returns dict with keys: account (label), status, messages. + status is one of: "already_empty", "confirmation_pending". + Has Mail.app and System Events side effects (opens a confirmation dialog). """ - account = resolve_account(getattr(args, "account", None)) - all_accounts = getattr(args, "all", False) - - if not account and not all_accounts: - die("Account required. Use -a ACCOUNT or --all.") - - # Build the menu item name — Mail.app appends \u2026 (ellipsis) to each entry if all_accounts: menu_item = "In All Accounts\u2026" label = "all accounts" + msg_count = None # unknown when erasing all accounts else: menu_item = f"{account}\u2026" label = account - # Count messages before erase so we can report it - if not all_accounts: + # Count messages before erase so we can report it acct_escaped = escape(account) trash_mb = resolve_mailbox(account, "Trash") trash_mb_escaped = escape(trash_mb) @@ -116,15 +123,7 @@ def cmd_empty_trash(args) -> None: msg_count = 0 if msg_count == 0: - format_output( - args, - f"Trash is already empty for '{account}'.", - json_data={"account": label, "status": "already_empty", - "messages": 0}, - ) - return - else: - msg_count = None # unknown when erasing all accounts + return {"account": label, "status": "already_empty", "messages": 0} # Use System Events to click the menu — this triggers a native # confirmation dialog that the user must approve. @@ -144,7 +143,9 @@ def cmd_empty_trash(args) -> None: try: result = subprocess.run( ["osascript", "-e", ui_script], - capture_output=True, text=True, timeout=15, + capture_output=True, + text=True, + timeout=15, ) if result.returncode != 0: err = result.stderr.strip() @@ -154,12 +155,38 @@ def cmd_empty_trash(args) -> None: except subprocess.TimeoutExpired: die("Timed out waiting for Mail.app menu.") + return {"account": label, "status": "confirmation_pending", "messages": msg_count} + + +def cmd_empty_trash(args) -> None: + """Empty the Trash via Mail.app's Erase Deleted Items menu. + + Uses System Events to trigger the menu command, which opens a + confirmation dialog for the user to approve manually. + """ + account = resolve_account(getattr(args, "account", None)) + all_accounts = getattr(args, "all", False) + + if not account and not all_accounts: + die("Account required. Use -a ACCOUNT or --all.") + + data = empty_trash(account, all_accounts) + label = data["account"] + msg_count = data["messages"] + + if data["status"] == "already_empty": + format_output( + args, + f"Trash is already empty for '{account}'.", + json_data=data, + ) + return + count_msg = f" ({msg_count} messages)" if msg_count is not None else "" format_output( args, f"Erase dialog opened for {label}{count_msg}. Confirm in Mail.app to permanently delete.", - json_data={"account": label, "status": "confirmation_pending", - "messages": msg_count}, + json_data=data, ) diff --git a/src/mxctl/commands/mail/messages.py b/src/mxctl/commands/mail/messages.py index e2ac1ca..fb3cd19 100644 --- a/src/mxctl/commands/mail/messages.py +++ b/src/mxctl/commands/mail/messages.py @@ -19,13 +19,18 @@ # list # --------------------------------------------------------------------------- -def cmd_list(args) -> None: - """List messages in a mailbox with optional filtering.""" - account, mailbox, acct_escaped, mb_escaped = resolve_message_context(args) - limit = validate_limit(getattr(args, "limit", DEFAULT_MESSAGE_LIMIT)) - unread_only = getattr(args, "unread", False) - after = getattr(args, "after", None) - before = getattr(args, "before", None) + +def get_messages( + account: str, + mailbox: str, + limit: int = 25, + unread_only: bool = False, + after: str | None = None, + before: str | None = None, +) -> list[dict]: + """Fetch messages from a mailbox with optional filtering. Returns list of dicts.""" + acct_escaped = escape(account) + mb_escaped = escape(mailbox) filters = [] if unread_only: @@ -60,6 +65,30 @@ def cmd_list(args) -> None: result = run(script) if not result.strip(): + return [] + + messages = [] + for line in result.strip().split("\n"): + if not line.strip(): + continue + msg = parse_message_line(line, ["id", "subject", "sender", "date", "read", "flagged"], FIELD_SEPARATOR) + if msg is not None: + messages.append(msg) + + return messages + + +def cmd_list(args) -> None: + """List messages in a mailbox with optional filtering.""" + account, mailbox, _acct_escaped, _mb_escaped = resolve_message_context(args) + limit = validate_limit(getattr(args, "limit", DEFAULT_MESSAGE_LIMIT)) + unread_only = getattr(args, "unread", False) + after = getattr(args, "after", None) + before = getattr(args, "before", None) + + messages = get_messages(account, mailbox, limit=limit, unread_only=unread_only, after=after, before=before) + + if not messages: filter_desc = [] if unread_only: filter_desc.append("unread") @@ -71,15 +100,6 @@ def cmd_list(args) -> None: format_output(args, f"No messages found in {mailbox}{filter_str}.") return - # Build JSON data and text output - messages = [] - for line in result.strip().split("\n"): - if not line.strip(): - continue - msg = parse_message_line(line, ["id", "subject", "sender", "date", "read", "flagged"], FIELD_SEPARATOR) - if msg is not None: - messages.append(msg) - save_message_aliases([m["id"] for m in messages]) for i, m in enumerate(messages, 1): m["alias"] = i @@ -102,12 +122,11 @@ def cmd_list(args) -> None: # read # --------------------------------------------------------------------------- -def cmd_read(args) -> None: - """Read full message details including headers and body.""" - account, mailbox, acct_escaped, mb_escaped = resolve_message_context(args) - message_id = validate_msg_id(args.id) - short = getattr(args, "short", False) - body_limit = DEFAULT_BODY_LENGTH if not short else 500 + +def read_message(account: str, mailbox: str, message_id: int, body_limit: int = 10000) -> dict: + """Fetch full message details including headers and body. Returns a dict.""" + acct_escaped = escape(account) + mb_escaped = escape(mailbox) script = f""" tell application "Mail" @@ -153,21 +172,32 @@ def cmd_read(args) -> None: parts = result.split(FIELD_SEPARATOR) if len(parts) < 16: - format_output(args, f"Message details: {result}") - return + return {} ( - msg_id, message_id_header, subject, sender, date, - read, flagged, junk, deleted, forwarded, replied, - to_list, cc_list, reply_to, content, att_count, + msg_id, + message_id_header, + subject, + sender, + date, + read, + flagged, + junk, + deleted, + forwarded, + replied, + to_list, + cc_list, + reply_to, + content, + att_count, ) = parts[:16] # U+FFFC (object replacement character) appears where HTML emails embed # inline images. Replace with a readable placeholder. content = content.replace("\ufffc", "[image]") - # Build JSON data - data = { + return { "id": int(msg_id) if msg_id.isdigit() else msg_id, "message_id": message_id_header, "account": account, @@ -188,20 +218,45 @@ def cmd_read(args) -> None: "body": truncate(content, body_limit), } + +def cmd_read(args) -> None: + """Read full message details including headers and body.""" + account, mailbox, _acct_escaped, _mb_escaped = resolve_message_context(args) + message_id = validate_msg_id(args.id) + short = getattr(args, "short", False) + body_limit = DEFAULT_BODY_LENGTH if not short else 500 + + data = read_message(account, mailbox, message_id, body_limit=body_limit) + + if not data: + format_output(args, f"Message {message_id} not found in {mailbox} [{account}].") + return + + # Reconstruct raw string representations for the text output to match + # the original formatting (booleans as "true"/"false", to/cc as comma-joined). + to_str = ", ".join(data["to"]) + cc_str = ", ".join(data["cc"]) + read_str = str(data["read"]).lower() + flagged_str = str(data["flagged"]).lower() + junk_str = str(data["junk"]).lower() + forwarded_str = str(data["forwarded"]).lower() + replied_str = str(data["replied"]).lower() + att_count_str = str(data["attachments"]) + # Build text output - text = f"Message Details:\nID: {msg_id}\nMessage-ID: {message_id_header}" + text = f"Message Details:\nID: {data['id']}\nMessage-ID: {data['message_id']}" text += f"\nAccount: {account}\nMailbox: {mailbox}" - text += f"\n\nSubject: {subject}\nFrom: {sender}\nTo: {to_list.rstrip(',')}" - if cc_list.strip(","): - text += f"\nCC: {cc_list.rstrip(',')}" - if reply_to: - text += f"\nReply-To: {reply_to}" - text += f"\nDate: {date}" + text += f"\n\nSubject: {data['subject']}\nFrom: {data['from']}\nTo: {to_str}" + if cc_str: + text += f"\nCC: {cc_str}" + if data["reply_to"]: + text += f"\nReply-To: {data['reply_to']}" + text += f"\nDate: {data['date']}" text += "\n\nStatus:" - text += f"\n Read: {read} Flagged: {flagged} Junk: {junk}" - text += f"\n Forwarded: {forwarded} Replied: {replied}" - text += f"\n\nAttachments: {att_count}" - text += f"\n\n--- Body ---\n{truncate(content, body_limit)}" + text += f"\n Read: {read_str} Flagged: {flagged_str} Junk: {junk_str}" + text += f"\n Forwarded: {forwarded_str} Replied: {replied_str}" + text += f"\n\nAttachments: {att_count_str}" + text += f"\n\n--- Body ---\n{data['body']}" format_output(args, text, json_data=data) @@ -209,17 +264,16 @@ def cmd_read(args) -> None: # search # --------------------------------------------------------------------------- -def cmd_search(args) -> None: - """Search messages by subject or sender.""" - query = args.query - field = "sender" if getattr(args, "sender", False) else "subject" - account = resolve_account(getattr(args, "account", None)) - mailbox = getattr(args, "mailbox", None) - limit = validate_limit(getattr(args, "limit", DEFAULT_MESSAGE_LIMIT)) +def search_messages( + query: str, + field: str = "subject", + account: str | None = None, + mailbox: str | None = None, + limit: int = 25, +) -> list[dict]: + """Search messages by subject or sender. Returns list of dicts.""" query_escaped = escape(query) - if mailbox and account: - mailbox = resolve_mailbox(account, mailbox) if mailbox and account: acct_escaped = escape(account) @@ -286,15 +340,8 @@ def cmd_search(args) -> None: result = run(script) if not result.strip(): - scope = "" - if mailbox and account: - scope = f" in {mailbox} [{account}]" - elif account: - scope = f" in {account}" - format_output(args, f"No messages found matching '{query}' in {field}{scope}.") - return + return [] - # Build JSON data and text output messages = [] for line in result.strip().split("\n"): if not line.strip(): @@ -307,6 +354,31 @@ def cmd_search(args) -> None: if msg is not None: messages.append(msg) + return messages + + +def cmd_search(args) -> None: + """Search messages by subject or sender.""" + query = args.query + field = "sender" if getattr(args, "sender", False) else "subject" + account = resolve_account(getattr(args, "account", None)) + mailbox = getattr(args, "mailbox", None) + limit = validate_limit(getattr(args, "limit", DEFAULT_MESSAGE_LIMIT)) + + if mailbox and account: + mailbox = resolve_mailbox(account, mailbox) + + messages = search_messages(query, field=field, account=account, mailbox=mailbox, limit=limit) + + if not messages: + scope = "" + if mailbox and account: + scope = f" in {mailbox} [{account}]" + elif account: + scope = f" in {account}" + format_output(args, f"No messages found matching '{query}' in {field}{scope}.") + return + save_message_aliases([m["id"] for m in messages]) for i, m in enumerate(messages, 1): m["alias"] = i @@ -330,6 +402,7 @@ def cmd_search(args) -> None: # Registration # --------------------------------------------------------------------------- + def register(subparsers) -> None: """Register message listing and reading subcommands.""" # list diff --git a/src/mxctl/commands/mail/system.py b/src/mxctl/commands/mail/system.py index 848e735..1d2aa8f 100644 --- a/src/mxctl/commands/mail/system.py +++ b/src/mxctl/commands/mail/system.py @@ -13,8 +13,9 @@ # check — trigger mail fetch # --------------------------------------------------------------------------- -def cmd_check(args) -> None: - """Trigger Mail.app to check for new mail.""" + +def check_mail_status() -> dict: + """Trigger Mail.app to check for new mail. Returns status dict.""" script = """ tell application "Mail" check for new mail @@ -22,21 +23,22 @@ def cmd_check(args) -> None: end tell """ run(script) - format_output(args, "Mail check triggered.", json_data={"status": "checked"}) + return {"status": "checked"} + + +def cmd_check(args) -> None: + """Trigger Mail.app to check for new mail.""" + data = check_mail_status() + format_output(args, "Mail check triggered.", json_data=data) # --------------------------------------------------------------------------- # headers # --------------------------------------------------------------------------- -def cmd_headers(args) -> None: - """Show email headers with authentication details.""" - account = resolve_account(getattr(args, "account", None)) - if not account: - die("Account required. Use -a ACCOUNT.") - mailbox = getattr(args, "mailbox", None) or DEFAULT_MAILBOX - message_id = validate_msg_id(args.id) +def get_headers(account: str, mailbox: str, message_id: int) -> dict: + """Return parsed email headers dict for the given message.""" acct_escaped = escape(account) mb_escaped = escape(mailbox) @@ -49,8 +51,35 @@ def cmd_headers(args) -> None: """ result = run(script) + return parse_email_headers(result) + + +def get_raw_headers(account: str, mailbox: str, message_id: int) -> str: + """Return raw header string for the given message.""" + acct_escaped = escape(account) + mb_escaped = escape(mailbox) + + script = f""" + tell application "Mail" + set mb to mailbox "{mb_escaped}" of account "{acct_escaped}" + set theMsg to first message of mb whose id is {message_id} + return all headers of theMsg + end tell + """ + return run(script) + + +def cmd_headers(args) -> None: + """Show email headers with authentication details.""" + account = resolve_account(getattr(args, "account", None)) + if not account: + die("Account required. Use -a ACCOUNT.") + mailbox = getattr(args, "mailbox", None) or DEFAULT_MAILBOX + message_id = validate_msg_id(args.id) raw = getattr(args, "raw", False) + result = get_raw_headers(account, mailbox, message_id) + if raw: print(result) return @@ -117,19 +146,9 @@ def cmd_headers(args) -> None: # rules — list/enable/disable/apply mail rules # --------------------------------------------------------------------------- -def cmd_rules(args) -> None: - """List or manage mail rules.""" - action = getattr(args, "action", None) - rule_name = getattr(args, "rule_name", None) - if action == "enable" and rule_name: - _toggle_rule(args, rule_name, True) - elif action == "disable" and rule_name: - _toggle_rule(args, rule_name, False) - else: - _list_rules(args) - -def _list_rules(args) -> None: +def get_rules() -> list[dict]: + """Return list of mail rules with name and enabled status.""" script = f""" tell application "Mail" set output to "" @@ -143,8 +162,7 @@ def _list_rules(args) -> None: """ result = run(script) if not result.strip(): - format_output(args, "No mail rules found.") - return + return [] rules = [] for line in result.strip().split("\n"): @@ -153,15 +171,11 @@ def _list_rules(args) -> None: parts = line.split(FIELD_SEPARATOR) if len(parts) >= 2: rules.append({"name": parts[0], "enabled": parts[1].lower() == "true"}) - - text = "Mail Rules:" - for rule in rules: - status = "ON" if rule["enabled"] else "OFF" - text += f"\n [{status}] {rule['name']}" - format_output(args, text, json_data=rules) + return rules -def _toggle_rule(args, name: str, enabled: bool) -> None: +def toggle_rule(name: str, enabled: bool) -> dict: + """Enable or disable a mail rule by name. Returns result dict.""" name_escaped = escape(name) val = "true" if enabled else "false" script = f""" @@ -173,13 +187,45 @@ def _toggle_rule(args, name: str, enabled: bool) -> None: """ result = run(script) word = "enabled" if enabled else "disabled" - format_output(args, f"Rule '{result}' {word}.", json_data={"rule": result, "status": word}) + return {"rule": result, "status": word} + + +def cmd_rules(args) -> None: + """List or manage mail rules.""" + action = getattr(args, "action", None) + rule_name = getattr(args, "rule_name", None) + if action == "enable" and rule_name: + _toggle_rule(args, rule_name, True) + elif action == "disable" and rule_name: + _toggle_rule(args, rule_name, False) + else: + _list_rules(args) + + +def _list_rules(args) -> None: + rules = get_rules() + if not rules: + format_output(args, "No mail rules found.") + return + + text = "Mail Rules:" + for rule in rules: + status = "ON" if rule["enabled"] else "OFF" + text += f"\n [{status}] {rule['name']}" + format_output(args, text, json_data=rules) + + +def _toggle_rule(args, name: str, enabled: bool) -> None: + data = toggle_rule(name, enabled) + word = data["status"] + format_output(args, f"Rule '{data['rule']}' {word}.", json_data=data) # --------------------------------------------------------------------------- # Registration # --------------------------------------------------------------------------- + def register(subparsers) -> None: """Register system mail subcommands.""" p = subparsers.add_parser("check", help="Trigger fetch for new mail") @@ -199,4 +245,3 @@ def register(subparsers) -> None: p.add_argument("rule_name", nargs="?", help="Rule name") p.add_argument("--json", action="store_true", help="Output as JSON") p.set_defaults(func=cmd_rules) - diff --git a/src/mxctl/commands/mail/templates.py b/src/mxctl/commands/mail/templates.py index 9357022..59ef928 100644 --- a/src/mxctl/commands/mail/templates.py +++ b/src/mxctl/commands/mail/templates.py @@ -26,6 +26,39 @@ def _save_templates(templates: dict) -> None: os.chmod(TEMPLATES_FILE, 0o600) +def get_templates() -> list[dict]: + """Return all saved templates as a list of dicts.""" + templates = _load_templates() + return [{"name": name, "subject": data.get("subject", ""), "body": data.get("body", "")} for name, data in templates.items()] + + +def get_template(name: str) -> dict: + """Return a single template by name. Raises SystemExit if not found.""" + templates = _load_templates() + if name not in templates: + die(f"Template '{name}' not found. Use 'mxctl templates list' to see available templates.") + template = templates[name] + return {"name": name, "subject": template.get("subject", ""), "body": template.get("body", "")} + + +def create_template(name: str, subject: str, body: str) -> dict: + """Create or update a template. Returns the saved template dict.""" + templates = _load_templates() + templates[name] = {"subject": subject, "body": body} + _save_templates(templates) + return {"name": name, "subject": subject, "body": body} + + +def delete_template(name: str) -> dict: + """Delete a template by name. Returns confirmation dict.""" + templates = _load_templates() + if name not in templates: + die(f"Template '{name}' not found.") + del templates[name] + _save_templates(templates) + return {"name": name, "deleted": True} + + def cmd_templates_list(args) -> None: """List all saved templates.""" templates = _load_templates() @@ -34,11 +67,7 @@ def cmd_templates_list(args) -> None: format_output(args, "No templates saved.") return - # Build JSON data - template_list = [ - {"name": name, "subject": data.get("subject", ""), "body": data.get("body", "")} - for name, data in templates.items() - ] + template_list = get_templates() # Build text output text = "Email Templates:" @@ -55,7 +84,6 @@ def cmd_templates_list(args) -> None: def cmd_templates_create(args) -> None: """Create or update a template.""" name = args.name - templates = _load_templates() # Check if interactive or flag-based if args.subject is None or args.body is None: @@ -69,10 +97,7 @@ def cmd_templates_create(args) -> None: subject = args.subject body = args.body - templates[name] = {"subject": subject, "body": body} - _save_templates(templates) - - data = {"name": name, "subject": subject, "body": body} + data = create_template(name, subject, body) text = f"Template '{name}' saved successfully!\n\nSubject: {subject}\nBody: {body}" format_output(args, text, json_data=data) @@ -80,16 +105,9 @@ def cmd_templates_create(args) -> None: def cmd_templates_show(args) -> None: """Show a specific template.""" name = args.name - templates = _load_templates() - - if name not in templates: - die(f"Template '{name}' not found. Use 'mxctl templates list' to see available templates.") - - template = templates[name] - subject = template.get("subject", "") - body = template.get("body", "") - - data = {"name": name, "subject": subject, "body": body} + data = get_template(name) + subject = data["subject"] + body = data["body"] text = f"Template: {name}\n\nSubject: {subject}\n\nBody:\n{body}" format_output(args, text, json_data=data) @@ -97,15 +115,7 @@ def cmd_templates_show(args) -> None: def cmd_templates_delete(args) -> None: """Delete a template.""" name = args.name - templates = _load_templates() - - if name not in templates: - die(f"Template '{name}' not found.") - - del templates[name] - _save_templates(templates) - - data = {"name": name, "deleted": True} + data = delete_template(name) text = f"Template '{name}' deleted successfully." format_output(args, text, json_data=data) diff --git a/src/mxctl/commands/mail/todoist_integration.py b/src/mxctl/commands/mail/todoist_integration.py index 4366672..53da859 100644 --- a/src/mxctl/commands/mail/todoist_integration.py +++ b/src/mxctl/commands/mail/todoist_integration.py @@ -18,21 +18,25 @@ # to-todoist — create a Todoist task from an email # --------------------------------------------------------------------------- -def cmd_to_todoist(args) -> None: - """Create a Todoist task from an email.""" - account, mailbox, acct_escaped, mb_escaped = resolve_message_context(args) - message_id = validate_msg_id(args.id) - project = getattr(args, "project", None) - priority = getattr(args, "priority", 1) - due = getattr(args, "due", None) - # Get Todoist API token from config +def create_todoist_task( + account: str, + mailbox: str, + acct_escaped: str, + mb_escaped: str, + message_id: int, + project: str | None = None, + priority: int = 1, + due: str | None = None, +) -> dict: + """Create a Todoist task from an email message. Returns the Todoist API response dict. + + Has Mail.app side effects (reads email) and network side effects (creates Todoist task). + """ cfg = get_config() token = cfg.get("todoist_api_token") if not token: die("Todoist API token not configured. Add 'todoist_api_token' to ~/.config/mxctl/config.json") - # Validate token format before making any network calls (prevents silent hangs - # caused by malformed auth headers or non-string token values) if not isinstance(token, str) or not token.strip(): die("Todoist API token is invalid. Check 'todoist_api_token' in ~/.config/mxctl/config.json") @@ -104,19 +108,12 @@ def cmd_to_todoist(args) -> None: url, data=json.dumps(task_data).encode("utf-8"), headers=headers, - method="POST" + method="POST", ) try: with urllib.request.urlopen(req, context=ssl_context, timeout=APPLESCRIPT_TIMEOUT_SHORT) as response: - response_data = json.loads(response.read().decode("utf-8")) - task_url = response_data.get("url") - - text = f"Created Todoist task: {subject}" - if task_url: - text += f"\nURL: {task_url}" - - format_output(args, text, json_data=response_data) + return json.loads(response.read().decode("utf-8")) except (ssl.SSLError, ssl.CertificateError): die("SSL certificate error. Try running: /usr/bin/python3 /Applications/Python*/Install\\ Certificates.command") except urllib.error.HTTPError as e: @@ -128,10 +125,40 @@ def cmd_to_todoist(args) -> None: die(f"Todoist API timed out creating task (>{APPLESCRIPT_TIMEOUT_SHORT}s). Check your network or try again.") +def cmd_to_todoist(args) -> None: + """Create a Todoist task from an email.""" + account, mailbox, acct_escaped, mb_escaped = resolve_message_context(args) + message_id = validate_msg_id(args.id) + project = getattr(args, "project", None) + priority = getattr(args, "priority", 1) + due = getattr(args, "due", None) + + response_data = create_todoist_task( + account, + mailbox, + acct_escaped, + mb_escaped, + message_id, + project=project, + priority=priority, + due=due, + ) + + subject = response_data.get("content", "") + task_url = response_data.get("url") + + text = f"Created Todoist task: {subject}" + if task_url: + text += f"\nURL: {task_url}" + + format_output(args, text, json_data=response_data) + + # --------------------------------------------------------------------------- # Registration # --------------------------------------------------------------------------- + def register(subparsers) -> None: # to-todoist p = subparsers.add_parser("to-todoist", help="Create Todoist task from email") diff --git a/src/mxctl/commands/mail/undo.py b/src/mxctl/commands/mail/undo.py index 2947e48..067042d 100644 --- a/src/mxctl/commands/mail/undo.py +++ b/src/mxctl/commands/mail/undo.py @@ -77,16 +77,18 @@ def log_batch_operation( ) -> None: """Log a batch operation for potential undo.""" operations = _load_undo_log() - operations.append({ - "timestamp": datetime.now().isoformat(), - "operation": operation_type, - "account": account, - "message_ids": message_ids, - "source_mailbox": source_mailbox, - "dest_mailbox": dest_mailbox, - "sender": sender, - "older_than_days": older_than_days, - }) + operations.append( + { + "timestamp": datetime.now().isoformat(), + "operation": operation_type, + "account": account, + "message_ids": message_ids, + "source_mailbox": source_mailbox, + "dest_mailbox": dest_mailbox, + "sender": sender, + "older_than_days": older_than_days, + } + ) _save_undo_log(operations) @@ -97,49 +99,31 @@ def log_fence_operation(operation_type: str) -> None: skip past these operations and accidentally undo an earlier undoable entry. """ operations = _load_undo_log(include_stale=True) - operations.append({ - "type": "fence", - "operation": operation_type, - "timestamp": datetime.now().isoformat(), - }) + operations.append( + { + "type": "fence", + "operation": operation_type, + "timestamp": datetime.now().isoformat(), + } + ) _save_undo_log(operations) -def cmd_undo_list(args) -> None: - """List recent undoable operations.""" - operations = _load_undo_log() - if not operations: - format_output(args, "No recent batch operations to undo.", - json_data={"operations": []}) - return +# --------------------------------------------------------------------------- +# Data functions (plain args, return dicts/lists, no printing) +# --------------------------------------------------------------------------- - # Build text output - text = f"Recent batch operations ({len(operations)}):" - for i, op in enumerate(reversed(operations), 1): - is_fence = op.get("type") == "fence" - prefix = "[no undo] " if is_fence else "" - ts = op.get("timestamp", "") - text += f"\n {i}. {prefix}{op['operation']} — {ts}" - if not is_fence: - if op.get("sender"): - text += f" from {op['sender']}" - if op.get("source_mailbox"): - text += f" from {op['source_mailbox']}" - if op.get("dest_mailbox"): - text += f" to {op['dest_mailbox']}" - if op.get("older_than_days"): - text += f" (older than {op['older_than_days']} days)" - text += f" ({len(op.get('message_ids', []))} messages)" - format_output(args, text, json_data={"operations": list(reversed(operations))}) +def list_undo_history() -> list[dict]: + """Return the list of recent undoable operations.""" + return _load_undo_log() -def cmd_undo(args) -> None: - """Undo the most recent batch operation.""" - force = getattr(args, "force", False) +def undo_last(force: bool = False) -> dict: + """Undo the most recent batch operation. Returns result dict. - # Load all entries (including stale) so we can give a helpful message when - # there ARE entries but they're older than the freshness window. + Raises SystemExit (via die()) on unrecoverable errors. + """ all_ops = _load_undo_log(include_stale=True) fresh_ops = [op for op in all_ops if _is_fresh(op)] @@ -147,7 +131,6 @@ def cmd_undo(args) -> None: die("No recent batch operations to undo.") if not fresh_ops and not force: - # There are entries but they're all stale — tell the user. most_recent = all_ops[-1] age = _entry_age_minutes(most_recent) age_str = f"{int(age)} minutes ago" if age is not None else "unknown time ago" @@ -161,11 +144,8 @@ def cmd_undo(args) -> None: if not operations: die("No batch operations to undo.") # pragma: no cover — earlier guards catch all empty cases - # Pop the most recent operation — do NOT write the log yet; - # only commit removal after the restore work succeeds. last_op = operations.pop() - # Fence sentinel: operation was run but cannot be undone. if last_op.get("type") == "fence": op_name = last_op.get("operation", "unknown") if not force: @@ -174,7 +154,6 @@ def cmd_undo(args) -> None: f"Use `mxctl undo --list` to see older undoable operations, " f"or `mxctl undo --force` to skip to the next undoable entry." ) - # --force: pop the fence and continue to the next entry if not operations: die("No undoable operations remain after skipping the fence.") last_op = operations.pop() @@ -190,18 +169,12 @@ def cmd_undo(args) -> None: try: if operation_type == "batch-move": - # Reverse move: move messages back from dest - # Note: batch-move can pull from multiple source mailboxes, so we move back to INBOX as default dest_mailbox = last_op.get("dest_mailbox") if not dest_mailbox: die("Incomplete operation data. Cannot undo batch-move.") - # Move messages back from dest to INBOX (safest default since source could be multiple mailboxes) dest_escaped = escape(dest_mailbox) inbox_escaped = escape("INBOX") - - # Build AppleScript to move messages back - # We'll iterate through message_ids and try to move them id_list = ", ".join(str(mid) for mid in message_ids) script = f""" @@ -228,32 +201,30 @@ def cmd_undo(args) -> None: result = run(script, timeout=APPLESCRIPT_TIMEOUT_LONG) moved = int(result) if result.isdigit() else 0 sender = last_op.get("sender", "unknown sender") - _save_undo_log(operations) # commit removal only on success + _save_undo_log(operations) total = len(message_ids) if moved == 0: msg = f"Nothing to restore (0 of {total} messages found — they may have already been moved or deleted)." else: msg = f"Undid batch-move: moved {moved}/{total} messages from '{sender}' back to INBOX from '{dest_mailbox}'." - format_output(args, msg, - json_data={ - "operation": "undo-batch-move", - "account": account, - "from_mailbox": dest_mailbox, - "to_mailbox": "INBOX", - "sender": sender, - "restored": moved, - "total": len(message_ids), - }) + return { + "message": msg, + "operation": "undo-batch-move", + "account": account, + "from_mailbox": dest_mailbox, + "to_mailbox": "INBOX", + "sender": sender, + "restored": moved, + "total": total, + } elif operation_type == "batch-delete": - # Reverse delete: move messages from Trash back to source_mailbox (or INBOX if unknown) source_mailbox = last_op.get("source_mailbox") restore_mailbox = source_mailbox if source_mailbox else "INBOX" restore_note = None if source_mailbox else "Original mailbox unknown; restored to INBOX." trash_escaped = escape("Trash") restore_escaped = escape(restore_mailbox) - id_list = ", ".join(str(mid) for mid in message_ids) script = f""" @@ -288,32 +259,77 @@ def cmd_undo(args) -> None: if restore_note and moved > 0: msg += f" Note: {restore_note}" json_result = { + "message": msg, "operation": "undo-batch-delete", "account": account, "from_mailbox": "Trash", "to_mailbox": restore_mailbox, "sender": sender, "restored": moved, - "total": len(message_ids), + "total": total, } if restore_note: json_result["note"] = restore_note - _save_undo_log(operations) # commit removal only on success - format_output(args, msg, json_data=json_result) + _save_undo_log(operations) + return json_result else: die(f"Unknown operation type '{operation_type}'. Cannot undo.") except (Exception, KeyboardInterrupt): operations.append(last_op) - _save_undo_log(operations) # put it back + _save_undo_log(operations) raise + # Unreachable, but satisfies type checker + return {} # pragma: no cover + + +# --------------------------------------------------------------------------- +# CLI handlers +# --------------------------------------------------------------------------- + + +def cmd_undo_list(args) -> None: + """List recent undoable operations.""" + operations = list_undo_history() + if not operations: + format_output(args, "No recent batch operations to undo.", json_data={"operations": []}) + return + + text = f"Recent batch operations ({len(operations)}):" + for i, op in enumerate(reversed(operations), 1): + is_fence = op.get("type") == "fence" + prefix = "[no undo] " if is_fence else "" + ts = op.get("timestamp", "") + text += f"\n {i}. {prefix}{op['operation']} — {ts}" + if not is_fence: + if op.get("sender"): + text += f" from {op['sender']}" + if op.get("source_mailbox"): + text += f" from {op['source_mailbox']}" + if op.get("dest_mailbox"): + text += f" to {op['dest_mailbox']}" + if op.get("older_than_days"): + text += f" (older than {op['older_than_days']} days)" + text += f" ({len(op.get('message_ids', []))} messages)" + + format_output(args, text, json_data={"operations": list(reversed(operations))}) + + +def cmd_undo(args) -> None: + """Undo the most recent batch operation.""" + force = getattr(args, "force", False) + result = undo_last(force=force) + msg = result.pop("message", "") + format_output(args, msg, json_data=result) + # --------------------------------------------------------------------------- # Registration # --------------------------------------------------------------------------- + def register(subparsers) -> None: """Register undo mail subcommands.""" p = subparsers.add_parser("undo", help="Undo most recent batch operation") diff --git a/src/mxctl/config.py b/src/mxctl/config.py index 96c235c..c226df4 100644 --- a/src/mxctl/config.py +++ b/src/mxctl/config.py @@ -47,13 +47,21 @@ APPLESCRIPT_TIMEOUT_BATCH = 120 # Data separators for AppleScript field/record parsing -FIELD_SEPARATOR = "\x1F" +FIELD_SEPARATOR = "\x1f" RECORD_SEPARATOR = "\x1eEND\x1e" # Common patterns for identifying no-reply / automated senders NOREPLY_PATTERNS = [ - "noreply", "no-reply", "notifications", "mailer-daemon", "donotreply", - "updates@", "news@", "info@", "support@", "billing@", + "noreply", + "no-reply", + "notifications", + "mailer-daemon", + "donotreply", + "updates@", + "news@", + "info@", + "support@", + "billing@", ] _migrated: bool = False @@ -72,6 +80,7 @@ def _migrate_legacy_config() -> None: return # No legacy config to migrate import sys + shutil.copytree(_LEGACY_CONFIG_DIR, CONFIG_DIR) print( f"Migrated config from {_LEGACY_CONFIG_DIR} to {CONFIG_DIR}", @@ -123,6 +132,7 @@ def _load_json(path: str) -> dict: return json.loads(content) except json.JSONDecodeError: import sys + print(f"Warning: {path} contains invalid JSON. Using defaults.", file=sys.stderr) return {} except OSError: @@ -154,6 +164,7 @@ def get_config(required: bool = False, warn: bool = True) -> dict: die("No config found. Run `mxctl init` to set up your default account.") if warn and not _config_warned: import sys + print( "No config found. Run `mxctl init` to set up your default account.", file=sys.stderr, @@ -169,9 +180,7 @@ def get_state() -> dict: def save_message_aliases(aliases: list[int]) -> None: """Save ordered list of message IDs as session aliases to state.""" state = get_state() - state.setdefault("mail", {})["aliases"] = { - str(i + 1): mid for i, mid in enumerate(aliases) - } + state.setdefault("mail", {})["aliases"] = {str(i + 1): mid for i, mid in enumerate(aliases)} _save_json(STATE_FILE, state) diff --git a/src/mxctl/util/applescript.py b/src/mxctl/util/applescript.py index 225df48..7a7ec83 100644 --- a/src/mxctl/util/applescript.py +++ b/src/mxctl/util/applescript.py @@ -34,13 +34,11 @@ def _warn_automation_once() -> None: terminal_app = "Terminal" print( - "Note: macOS will ask for Automation permission to control Mail.app. " - "If prompted, click Allow.", + "Note: macOS will ask for Automation permission to control Mail.app. If prompted, click Allow.", file=sys.stderr, ) print( - f" If you see 'not authorized': System Settings → Privacy & Security → " - f"Automation → {terminal_app} → enable Mail.", + f" If you see 'not authorized': System Settings → Privacy & Security → Automation → {terminal_app} → enable Mail.", file=sys.stderr, ) @@ -89,7 +87,7 @@ def escape(s: str | None) -> str: s = s.replace('"', '\\"') s = s.replace("\n", "\\n") s = s.replace("\r", "\\r") - s = re.sub(r'[\x00-\x1f]', '', s) + s = re.sub(r"[\x00-\x1f]", "", s) return s diff --git a/src/mxctl/util/applescript_templates.py b/src/mxctl/util/applescript_templates.py index 67705aa..35ef2e5 100644 --- a/src/mxctl/util/applescript_templates.py +++ b/src/mxctl/util/applescript_templates.py @@ -32,14 +32,13 @@ def inbox_iterator_all_accounts(inner_operations: str, cap: int = 20, account: s """ if account: from mxctl.util.applescript import escape + acct_escaped = escape(account) outer_open = f'set acct to account "{acct_escaped}"\n set acctName to name of acct' outer_close = "" else: outer_open = ( - "repeat with acct in (every account)\n" - " if enabled of acct then\n" - " set acctName to name of acct" + "repeat with acct in (every account)\n if enabled of acct then\n set acctName to name of acct" ) outer_close = " end if\n end repeat" @@ -69,13 +68,7 @@ def inbox_iterator_all_accounts(inner_operations: str, cap: int = 20, account: s """ -def set_message_property( - account_var: str, - mailbox_var: str, - message_id: int, - property_name: str, - property_value: str -) -> str: +def set_message_property(account_var: str, mailbox_var: str, message_id: int, property_name: str, property_value: str) -> str: """Generate AppleScript to set a message property and return subject. Args: @@ -152,11 +145,7 @@ def mailbox_iterator(inner_operations: str, account: str | None = None) -> str: """ -def list_attachments( - account_var: str, - mailbox_var: str, - message_id: int -) -> str: +def list_attachments(account_var: str, mailbox_var: str, message_id: int) -> str: """Generate AppleScript to list message attachments. Args: diff --git a/src/mxctl/util/mail_helpers.py b/src/mxctl/util/mail_helpers.py index 5989f7c..2d3b2e4 100644 --- a/src/mxctl/util/mail_helpers.py +++ b/src/mxctl/util/mail_helpers.py @@ -185,7 +185,7 @@ def normalize_subject(subject: str) -> str: """ # Loop to handle multiple nested prefixes while True: - normalized = re.sub(r'^(Re|Fwd|Fw|AW|SV|VS):\s*', '', subject, flags=re.IGNORECASE).strip() + normalized = re.sub(r"^(Re|Fwd|Fw|AW|SV|VS):\s*", "", subject, flags=re.IGNORECASE).strip() if normalized == subject: break subject = normalized diff --git a/tests/conftest.py b/tests/conftest.py index 70fb0ee..5fccf73 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,10 +19,7 @@ def mock_run(monkeypatch): def mock_template(inner_ops, cap=20, account=None): return 'tell application "Mail"\nset output to ""\nend tell' - monkeypatch.setattr( - "mxctl.commands.mail.ai.inbox_iterator_all_accounts", - mock_template - ) + monkeypatch.setattr("mxctl.commands.mail.ai.inbox_iterator_all_accounts", mock_template) return mock diff --git a/tests/test_100_coverage.py b/tests/test_100_coverage.py index 651c6be..b442ae5 100644 --- a/tests/test_100_coverage.py +++ b/tests/test_100_coverage.py @@ -39,6 +39,7 @@ # Helpers # --------------------------------------------------------------------------- + def _args(**kwargs): defaults = {"json": False, "account": "iCloud", "mailbox": "INBOX"} defaults.update(kwargs) @@ -88,7 +89,7 @@ def test_mailbox_not_found(self, monkeypatch, capsys): import mxctl.util.applescript as as_mod monkeypatch.setattr(as_mod, "_automation_warned", True) - mock_result = Mock(returncode=1, stderr="Can\u2019t get mailbox \"Junk\" of account \"iCloud\".") + mock_result = Mock(returncode=1, stderr='Can\u2019t get mailbox "Junk" of account "iCloud".') monkeypatch.setattr("mxctl.util.applescript.subprocess.run", lambda *a, **kw: mock_result) with pytest.raises(SystemExit): @@ -250,11 +251,16 @@ def test_inbox_skips_blank_lines(self, monkeypatch, capsys): """Blank lines in AppleScript output are skipped (line 102).""" from mxctl.commands.mail.accounts import cmd_inbox - monkeypatch.setattr("mxctl.commands.mail.accounts.run", Mock(return_value=( - f"iCloud{FIELD_SEPARATOR}5{FIELD_SEPARATOR}100\n" - "\n" # blank line - f"iCloud{FIELD_SEPARATOR}3{FIELD_SEPARATOR}50\n" - ))) + monkeypatch.setattr( + "mxctl.commands.mail.accounts.run", + Mock( + return_value=( + f"iCloud{FIELD_SEPARATOR}5{FIELD_SEPARATOR}100\n" + "\n" # blank line + f"iCloud{FIELD_SEPARATOR}3{FIELD_SEPARATOR}50\n" + ) + ), + ) cmd_inbox(_args(account=None)) out = capsys.readouterr().out @@ -266,11 +272,16 @@ def test_accounts_skips_blank_lines(self, monkeypatch, capsys): # The blank line must be BETWEEN real lines (not trailing) because result.strip() # removes trailing whitespace before split - monkeypatch.setattr("mxctl.commands.mail.accounts.run", Mock(return_value=( - f"iCloud{FIELD_SEPARATOR}John{FIELD_SEPARATOR}john@icloud.com{FIELD_SEPARATOR}true\n" - "\n" - f"Gmail{FIELD_SEPARATOR}Jane{FIELD_SEPARATOR}jane@gmail.com{FIELD_SEPARATOR}true" - ))) + monkeypatch.setattr( + "mxctl.commands.mail.accounts.run", + Mock( + return_value=( + f"iCloud{FIELD_SEPARATOR}John{FIELD_SEPARATOR}john@icloud.com{FIELD_SEPARATOR}true\n" + "\n" + f"Gmail{FIELD_SEPARATOR}Jane{FIELD_SEPARATOR}jane@gmail.com{FIELD_SEPARATOR}true" + ) + ), + ) cmd_accounts(_args()) out = capsys.readouterr().out @@ -302,11 +313,7 @@ def test_mailboxes_skips_blank_lines(self, monkeypatch, capsys): """Blank lines in mailboxes output are skipped (line 250).""" from mxctl.commands.mail.accounts import cmd_mailboxes - monkeypatch.setattr("mxctl.commands.mail.accounts.run", Mock(return_value=( - f"INBOX{FIELD_SEPARATOR}5\n" - "\n" - f"Sent{FIELD_SEPARATOR}0\n" - ))) + monkeypatch.setattr("mxctl.commands.mail.accounts.run", Mock(return_value=(f"INBOX{FIELD_SEPARATOR}5\n\nSent{FIELD_SEPARATOR}0\n"))) cmd_mailboxes(_args(account="iCloud")) out = capsys.readouterr().out @@ -399,7 +406,7 @@ def test_known_error_cant_get_message_returns_none(self, monkeypatch): """can't get message error returns None (line 372).""" from mxctl.commands.mail.actions import _try_not_junk_in_mailbox - mock_result = Mock(returncode=1, stdout="", stderr="Can't get message 42 of mailbox \"Junk\"") + mock_result = Mock(returncode=1, stdout="", stderr='Can\'t get message 42 of mailbox "Junk"') monkeypatch.setattr("subprocess.run", Mock(return_value=mock_result)) result = _try_not_junk_in_mailbox("iCloud", "Junk", "INBOX", 42) @@ -412,8 +419,7 @@ def test_known_error_no_messages_matched_returns_none(self, monkeypatch): mock_result = Mock(returncode=1, stdout="", stderr="No messages matched the search criteria") monkeypatch.setattr("subprocess.run", Mock(return_value=mock_result)) - result = _try_not_junk_in_mailbox("iCloud", "Junk", "INBOX", 42, - subject="Test", sender="sender@x.com") + result = _try_not_junk_in_mailbox("iCloud", "Junk", "INBOX", 42, subject="Test", sender="sender@x.com") assert result is None def test_unexpected_error_returns_none(self, monkeypatch): @@ -482,10 +488,10 @@ def test_not_junk_gmail_adds_candidates(self, monkeypatch, capsys): monkeypatch.setattr("mxctl.commands.mail.actions.resolve_account", lambda _: "Gmail") monkeypatch.setattr("mxctl.config.get_gmail_accounts", lambda: ["Gmail"]) # resolve_mailbox("Junk") returns "[Gmail]/Spam" for Gmail, so candidates start with that - monkeypatch.setattr("mxctl.commands.mail.actions.resolve_mailbox", - lambda acct, mb: "[Gmail]/Spam" if mb == "Junk" else mb) + monkeypatch.setattr("mxctl.commands.mail.actions.resolve_mailbox", lambda acct, mb: "[Gmail]/Spam" if mb == "Junk" else mb) call_count = [0] + def mock_try_not_junk(acct, junk, inbox, msg_id, subject="", sender=""): call_count[0] += 1 if call_count[0] == 2: # Second candidate ([Gmail]/All Mail) succeeds @@ -512,8 +518,7 @@ def test_not_junk_fetches_orig_subject_sender(self, monkeypatch, capsys): monkeypatch.setattr("mxctl.commands.mail.actions.resolve_account", lambda _: "iCloud") monkeypatch.setattr("mxctl.config.get_gmail_accounts", lambda: []) - monkeypatch.setattr("mxctl.commands.mail.actions.resolve_mailbox", - lambda acct, mb: mb) + monkeypatch.setattr("mxctl.commands.mail.actions.resolve_mailbox", lambda acct, mb: mb) # Mock the subprocess for fetching original subject/sender - SUCCEEDS mock_fetch = Mock() @@ -538,8 +543,7 @@ def test_not_junk_fetch_exception_fallback(self, monkeypatch, capsys): monkeypatch.setattr("mxctl.commands.mail.actions.resolve_account", lambda _: "iCloud") monkeypatch.setattr("mxctl.config.get_gmail_accounts", lambda: []) - monkeypatch.setattr("mxctl.commands.mail.actions.resolve_mailbox", - lambda acct, mb: mb) + monkeypatch.setattr("mxctl.commands.mail.actions.resolve_mailbox", lambda acct, mb: mb) # Mock subprocess.run to raise an exception (e.g. OSError) monkeypatch.setattr("subprocess.run", Mock(side_effect=OSError("no such process"))) @@ -563,10 +567,10 @@ def test_not_junk_gmail_junk_separate_from_spam(self, monkeypatch, capsys): monkeypatch.setattr("mxctl.config.get_gmail_accounts", lambda: ["Gmail"]) # resolve_mailbox returns "Junk" as-is (not mapping to [Gmail]/Spam), # so [Gmail]/Spam is NOT already in candidates and gets appended - monkeypatch.setattr("mxctl.commands.mail.actions.resolve_mailbox", - lambda acct, mb: mb) + monkeypatch.setattr("mxctl.commands.mail.actions.resolve_mailbox", lambda acct, mb: mb) call_count = [0] + def mock_try(acct, junk, inbox, msg_id, subject="", sender=""): call_count[0] += 1 if call_count[0] == 2: # [Gmail]/Spam attempt succeeds @@ -614,11 +618,16 @@ def test_digest_blank_line_skipped(self, monkeypatch, capsys): from mxctl.commands.mail.analytics import cmd_digest # Blank line between real data lines (not trailing) to survive strip() - monkeypatch.setattr("mxctl.commands.mail.analytics.run", Mock(return_value=( - f"iCloud{FIELD_SEPARATOR}1{FIELD_SEPARATOR}Hello{FIELD_SEPARATOR}user@example.com{FIELD_SEPARATOR}Monday\n" - "\n" - f"iCloud{FIELD_SEPARATOR}2{FIELD_SEPARATOR}World{FIELD_SEPARATOR}other@example.com{FIELD_SEPARATOR}Tuesday" - ))) + monkeypatch.setattr( + "mxctl.commands.mail.analytics.run", + Mock( + return_value=( + f"iCloud{FIELD_SEPARATOR}1{FIELD_SEPARATOR}Hello{FIELD_SEPARATOR}user@example.com{FIELD_SEPARATOR}Monday\n" + "\n" + f"iCloud{FIELD_SEPARATOR}2{FIELD_SEPARATOR}World{FIELD_SEPARATOR}other@example.com{FIELD_SEPARATOR}Tuesday" + ) + ), + ) cmd_digest(_args()) out = capsys.readouterr().out @@ -628,9 +637,10 @@ def test_digest_domain_other_for_no_at(self, monkeypatch, capsys): """Sender without @ domain falls into 'other' group (line 126).""" from mxctl.commands.mail.analytics import cmd_digest - monkeypatch.setattr("mxctl.commands.mail.analytics.run", Mock(return_value=( - f"iCloud{FIELD_SEPARATOR}1{FIELD_SEPARATOR}Hello{FIELD_SEPARATOR}NoEmailAddress{FIELD_SEPARATOR}Monday\n" - ))) + monkeypatch.setattr( + "mxctl.commands.mail.analytics.run", + Mock(return_value=(f"iCloud{FIELD_SEPARATOR}1{FIELD_SEPARATOR}Hello{FIELD_SEPARATOR}NoEmailAddress{FIELD_SEPARATOR}Monday\n")), + ) cmd_digest(_args()) out = capsys.readouterr().out @@ -656,11 +666,10 @@ def test_stats_all_blank_line_in_mailboxes(self, monkeypatch, capsys): """Blank lines in stats --all mailbox output are skipped (line 226).""" from mxctl.commands.mail.analytics import cmd_stats - monkeypatch.setattr("mxctl.commands.mail.analytics.run", Mock(return_value=( - f"100{FIELD_SEPARATOR}10\n" - "\n" - f"iCloud{FIELD_SEPARATOR}INBOX{FIELD_SEPARATOR}100{FIELD_SEPARATOR}10\n" - ))) + monkeypatch.setattr( + "mxctl.commands.mail.analytics.run", + Mock(return_value=(f"100{FIELD_SEPARATOR}10\n\niCloud{FIELD_SEPARATOR}INBOX{FIELD_SEPARATOR}100{FIELD_SEPARATOR}10\n")), + ) args = _args(account="iCloud", all=True, mailbox=None) cmd_stats(args) @@ -682,11 +691,16 @@ def test_show_flagged_blank_line_skipped(self, monkeypatch, capsys): from mxctl.commands.mail.analytics import cmd_show_flagged # Blank line between data lines to survive strip() - monkeypatch.setattr("mxctl.commands.mail.analytics.run", Mock(return_value=( - f"99{FIELD_SEPARATOR}Task{FIELD_SEPARATOR}x@y.com{FIELD_SEPARATOR}Monday{FIELD_SEPARATOR}INBOX{FIELD_SEPARATOR}iCloud\n" - "\n" - f"100{FIELD_SEPARATOR}Task2{FIELD_SEPARATOR}z@w.com{FIELD_SEPARATOR}Tuesday{FIELD_SEPARATOR}Sent{FIELD_SEPARATOR}iCloud" - ))) + monkeypatch.setattr( + "mxctl.commands.mail.analytics.run", + Mock( + return_value=( + f"99{FIELD_SEPARATOR}Task{FIELD_SEPARATOR}x@y.com{FIELD_SEPARATOR}Monday{FIELD_SEPARATOR}INBOX{FIELD_SEPARATOR}iCloud\n" + "\n" + f"100{FIELD_SEPARATOR}Task2{FIELD_SEPARATOR}z@w.com{FIELD_SEPARATOR}Tuesday{FIELD_SEPARATOR}Sent{FIELD_SEPARATOR}iCloud" + ) + ), + ) cmd_show_flagged(_args(limit=25)) out = capsys.readouterr().out @@ -736,8 +750,7 @@ def test_save_attachment_prefix_ambiguous(self, monkeypatch): "mxctl.commands.mail.attachments.resolve_message_context", lambda _: ("iCloud", "INBOX", "iCloud", "INBOX"), ) - monkeypatch.setattr("mxctl.commands.mail.attachments.run", - Mock(return_value="Subject\nreport-q1.pdf\nreport-q2.pdf")) + monkeypatch.setattr("mxctl.commands.mail.attachments.run", Mock(return_value="Subject\nreport-q1.pdf\nreport-q2.pdf")) args = _args(id=42, attachment="report", output_dir="/tmp") with pytest.raises(SystemExit): @@ -751,8 +764,7 @@ def test_save_attachment_name_not_found(self, monkeypatch): "mxctl.commands.mail.attachments.resolve_message_context", lambda _: ("iCloud", "INBOX", "iCloud", "INBOX"), ) - monkeypatch.setattr("mxctl.commands.mail.attachments.run", - Mock(return_value="Subject\nreal-file.pdf")) + monkeypatch.setattr("mxctl.commands.mail.attachments.run", Mock(return_value="Subject\nreal-file.pdf")) args = _args(id=42, attachment="nonexistent.doc", output_dir="/tmp") with pytest.raises(SystemExit): @@ -766,16 +778,17 @@ def test_save_attachment_prefix_single_match(self, monkeypatch, capsys, tmp_path "mxctl.commands.mail.attachments.resolve_message_context", lambda _: ("iCloud", "INBOX", "iCloud", "INBOX"), ) - monkeypatch.setattr("mxctl.commands.mail.attachments.run", - Mock(side_effect=["Subject\nreport-final.pdf\nother.txt", "saved"])) + monkeypatch.setattr("mxctl.commands.mail.attachments.run", Mock(side_effect=["Subject\nreport-final.pdf\nother.txt", "saved"])) # Create fake file so existence check passes (tmp_path / "report-final.pdf").write_bytes(b"data") original_isfile = os.path.isfile + def patched(p): if p == str(tmp_path / "report-final.pdf"): return True return original_isfile(p) + monkeypatch.setattr("mxctl.commands.mail.attachments.os.path.isfile", patched) args = _args(id=42, attachment="report", output_dir=str(tmp_path)) @@ -792,8 +805,7 @@ def test_save_attachment_path_traversal(self, monkeypatch, tmp_path): "mxctl.commands.mail.attachments.resolve_message_context", lambda _: ("iCloud", "INBOX", "iCloud", "INBOX"), ) - monkeypatch.setattr("mxctl.commands.mail.attachments.run", - Mock(return_value="Subject\n../../etc/passwd")) + monkeypatch.setattr("mxctl.commands.mail.attachments.run", Mock(return_value="Subject\n../../etc/passwd")) args = _args(id=42, attachment="../../etc/passwd", output_dir=str(tmp_path)) with pytest.raises(SystemExit): @@ -827,8 +839,7 @@ def test_save_attachment_file_not_created(self, monkeypatch, tmp_path): "mxctl.commands.mail.attachments.resolve_message_context", lambda _: ("iCloud", "INBOX", "iCloud", "INBOX"), ) - monkeypatch.setattr("mxctl.commands.mail.attachments.run", - Mock(side_effect=["Subject\nfile.pdf", "saved"])) + monkeypatch.setattr("mxctl.commands.mail.attachments.run", Mock(side_effect=["Subject\nfile.pdf", "saved"])) # Don't create the file - it should fail the existence check args = _args(id=42, attachment="file.pdf", output_dir=str(tmp_path)) @@ -851,8 +862,7 @@ def test_batch_delete_no_account_dies(self, monkeypatch): monkeypatch.setattr("mxctl.commands.mail.batch.resolve_account", lambda _: None) with pytest.raises(SystemExit): - cmd_batch_delete(_args(account=None, mailbox="INBOX", older_than=30, - from_sender=None, dry_run=False, force=False, limit=None)) + cmd_batch_delete(_args(account=None, mailbox="INBOX", older_than=30, from_sender=None, dry_run=False, force=False, limit=None)) def test_batch_delete_zero_results(self, monkeypatch, capsys): """batch-delete with zero matching messages reports nothing found (lines 273-276).""" @@ -861,8 +871,7 @@ def test_batch_delete_zero_results(self, monkeypatch, capsys): monkeypatch.setattr("mxctl.commands.mail.batch.resolve_account", lambda _: "iCloud") monkeypatch.setattr("mxctl.commands.mail.batch.run", Mock(return_value="0")) - args = _args(mailbox="INBOX", older_than=30, from_sender=None, - dry_run=False, force=True, limit=None) + args = _args(mailbox="INBOX", older_than=30, from_sender=None, dry_run=False, force=True, limit=None) cmd_batch_delete(args) out = capsys.readouterr().out @@ -876,8 +885,7 @@ def test_batch_delete_force_guard(self, monkeypatch): monkeypatch.setattr("mxctl.commands.mail.batch.run", Mock(return_value="5")) with pytest.raises(SystemExit): - cmd_batch_delete(_args(mailbox="INBOX", older_than=30, from_sender=None, - dry_run=False, force=False, limit=None)) + cmd_batch_delete(_args(mailbox="INBOX", older_than=30, from_sender=None, dry_run=False, force=False, limit=None)) # =========================================================================== @@ -901,8 +909,7 @@ def test_draft_template_applied_no_subject_no_body(self, monkeypatch, capsys, tm monkeypatch.setattr("mxctl.commands.mail.compose.TEMPLATES_FILE", tpl_file) - args = _args(to="x@y.com", subject=None, body=None, - template="greeting", cc=None, bcc=None) + args = _args(to="x@y.com", subject=None, body=None, template="greeting", cc=None, bcc=None) cmd_draft(args) out = capsys.readouterr().out @@ -922,8 +929,7 @@ def test_draft_template_overridden_by_flags(self, monkeypatch, capsys, tmp_path) monkeypatch.setattr("mxctl.commands.mail.compose.TEMPLATES_FILE", tpl_file) - args = _args(to="x@y.com", subject="Override Subject", body="Override Body", - template="greeting", cc=None, bcc=None) + args = _args(to="x@y.com", subject="Override Subject", body="Override Body", template="greeting", cc=None, bcc=None) cmd_draft(args) out = capsys.readouterr().out @@ -942,8 +948,7 @@ def test_draft_template_corrupt_file_dies(self, monkeypatch, tmp_path): monkeypatch.setattr("mxctl.commands.mail.compose.TEMPLATES_FILE", tpl_file) with pytest.raises(SystemExit) as exc_info: - cmd_draft(_args(to="x@y.com", subject=None, body=None, - template="test", cc=None, bcc=None)) + cmd_draft(_args(to="x@y.com", subject=None, body=None, template="test", cc=None, bcc=None)) assert exc_info.value.code == 1 def test_draft_template_file_missing_dies(self, monkeypatch, tmp_path): @@ -951,12 +956,10 @@ def test_draft_template_file_missing_dies(self, monkeypatch, tmp_path): from mxctl.commands.mail.compose import cmd_draft monkeypatch.setattr("mxctl.commands.mail.compose.resolve_account", lambda _: "iCloud") - monkeypatch.setattr("mxctl.commands.mail.compose.TEMPLATES_FILE", - str(tmp_path / "nonexistent.json")) + monkeypatch.setattr("mxctl.commands.mail.compose.TEMPLATES_FILE", str(tmp_path / "nonexistent.json")) with pytest.raises(SystemExit) as exc_info: - cmd_draft(_args(to="x@y.com", subject=None, body=None, - template="any", cc=None, bcc=None)) + cmd_draft(_args(to="x@y.com", subject=None, body=None, template="any", cc=None, bcc=None)) assert exc_info.value.code == 1 @@ -990,10 +993,15 @@ def test_export_single_to_directory(self, monkeypatch, tmp_path, capsys): """Export single message to a directory creates file (line 83-84).""" from mxctl.commands.mail.composite import _export_single - monkeypatch.setattr("mxctl.commands.mail.composite.run", Mock(return_value=( - f"Test Subject{FIELD_SEPARATOR}sender@example.com{FIELD_SEPARATOR}" - f"Monday{FIELD_SEPARATOR}to@example.com{FIELD_SEPARATOR}Body content" - ))) + monkeypatch.setattr( + "mxctl.commands.mail.composite.run", + Mock( + return_value=( + f"Test Subject{FIELD_SEPARATOR}sender@example.com{FIELD_SEPARATOR}" + f"Monday{FIELD_SEPARATOR}to@example.com{FIELD_SEPARATOR}Body content" + ) + ), + ) _export_single(_args(), 42, "iCloud", "INBOX", str(tmp_path)) @@ -1005,10 +1013,15 @@ def test_export_single_path_traversal_dies(self, monkeypatch, tmp_path): from mxctl.commands.mail.composite import _export_single # Subject that creates a traversal path - monkeypatch.setattr("mxctl.commands.mail.composite.run", Mock(return_value=( - f"../../../etc/passwd{FIELD_SEPARATOR}sender@example.com{FIELD_SEPARATOR}" - f"Monday{FIELD_SEPARATOR}to@example.com{FIELD_SEPARATOR}Body" - ))) + monkeypatch.setattr( + "mxctl.commands.mail.composite.run", + Mock( + return_value=( + f"../../../etc/passwd{FIELD_SEPARATOR}sender@example.com{FIELD_SEPARATOR}" + f"Monday{FIELD_SEPARATOR}to@example.com{FIELD_SEPARATOR}Body" + ) + ), + ) # Need to create a scenario where the sanitized path escapes dest_path # The subject gets sanitized, so this path is actually safe. @@ -1019,10 +1032,14 @@ def test_export_single_to_file(self, monkeypatch, tmp_path, capsys): """Export single message to a specific file path (line 90-91).""" from mxctl.commands.mail.composite import _export_single - monkeypatch.setattr("mxctl.commands.mail.composite.run", Mock(return_value=( - f"Test{FIELD_SEPARATOR}sender@example.com{FIELD_SEPARATOR}" - f"Monday{FIELD_SEPARATOR}to@example.com{FIELD_SEPARATOR}Content" - ))) + monkeypatch.setattr( + "mxctl.commands.mail.composite.run", + Mock( + return_value=( + f"Test{FIELD_SEPARATOR}sender@example.com{FIELD_SEPARATOR}Monday{FIELD_SEPARATOR}to@example.com{FIELD_SEPARATOR}Content" + ) + ), + ) filepath = str(tmp_path / "export.md") _export_single(_args(), 42, "iCloud", "INBOX", filepath) @@ -1035,11 +1052,11 @@ def test_export_bulk_skips_malformed_entries(self, monkeypatch, tmp_path, capsys """Bulk export skips entries with too few fields (lines 106-108, 134-138).""" from mxctl.commands.mail.composite import _export_bulk - good_entry = (f"1{FIELD_SEPARATOR}Subject{FIELD_SEPARATOR}sender@example.com" - f"{FIELD_SEPARATOR}Monday{FIELD_SEPARATOR}Body content") + good_entry = f"1{FIELD_SEPARATOR}Subject{FIELD_SEPARATOR}sender@example.com{FIELD_SEPARATOR}Monday{FIELD_SEPARATOR}Body content" bad_entry = "malformed" - monkeypatch.setattr("mxctl.commands.mail.composite.run", - Mock(return_value=f"{good_entry}{RECORD_SEPARATOR}\n{bad_entry}{RECORD_SEPARATOR}\n")) + monkeypatch.setattr( + "mxctl.commands.mail.composite.run", Mock(return_value=f"{good_entry}{RECORD_SEPARATOR}\n{bad_entry}{RECORD_SEPARATOR}\n") + ) _export_bulk(_args(), "INBOX", "iCloud", str(tmp_path), None) @@ -1053,10 +1070,8 @@ def test_export_bulk_path_traversal_skipped(self, monkeypatch, tmp_path, capsys) # This entry has a subject that after sanitization may cause traversal # Actually the regex strips non-word chars, so we need to mock differently # The path traversal check skips the entry silently - entry = (f"1{FIELD_SEPARATOR}Normal{FIELD_SEPARATOR}sender@example.com" - f"{FIELD_SEPARATOR}Monday{FIELD_SEPARATOR}Body") - monkeypatch.setattr("mxctl.commands.mail.composite.run", - Mock(return_value=f"{entry}{RECORD_SEPARATOR}\n")) + entry = f"1{FIELD_SEPARATOR}Normal{FIELD_SEPARATOR}sender@example.com{FIELD_SEPARATOR}Monday{FIELD_SEPARATOR}Body" + monkeypatch.setattr("mxctl.commands.mail.composite.run", Mock(return_value=f"{entry}{RECORD_SEPARATOR}\n")) _export_bulk(_args(), "INBOX", "iCloud", str(tmp_path), None) @@ -1067,10 +1082,8 @@ def test_export_bulk_with_after_date(self, monkeypatch, tmp_path, capsys): """Bulk export with --after uses date filter (line 106-108).""" from mxctl.commands.mail.composite import _export_bulk - entry = (f"1{FIELD_SEPARATOR}Subject{FIELD_SEPARATOR}sender@example.com" - f"{FIELD_SEPARATOR}Monday{FIELD_SEPARATOR}Body") - monkeypatch.setattr("mxctl.commands.mail.composite.run", - Mock(return_value=f"{entry}{RECORD_SEPARATOR}\n")) + entry = f"1{FIELD_SEPARATOR}Subject{FIELD_SEPARATOR}sender@example.com{FIELD_SEPARATOR}Monday{FIELD_SEPARATOR}Body" + monkeypatch.setattr("mxctl.commands.mail.composite.run", Mock(return_value=f"{entry}{RECORD_SEPARATOR}\n")) _export_bulk(_args(), "INBOX", "iCloud", str(tmp_path), "2026-01-01") @@ -1091,12 +1104,19 @@ def test_thread_empty_messages_skips_blank(self, monkeypatch, capsys): from mxctl.commands.mail.composite import cmd_thread # Blank line between data lines to survive strip() - monkeypatch.setattr("mxctl.commands.mail.composite.run", Mock(side_effect=[ - "Subject", - (f"1{FIELD_SEPARATOR}Subject{FIELD_SEPARATOR}sender{FIELD_SEPARATOR}Monday{FIELD_SEPARATOR}INBOX{FIELD_SEPARATOR}iCloud\n" - "\n" - f"2{FIELD_SEPARATOR}Re: Subject{FIELD_SEPARATOR}sender2{FIELD_SEPARATOR}Tuesday{FIELD_SEPARATOR}INBOX{FIELD_SEPARATOR}iCloud"), - ])) + monkeypatch.setattr( + "mxctl.commands.mail.composite.run", + Mock( + side_effect=[ + "Subject", + ( + f"1{FIELD_SEPARATOR}Subject{FIELD_SEPARATOR}sender{FIELD_SEPARATOR}Monday{FIELD_SEPARATOR}INBOX{FIELD_SEPARATOR}iCloud\n" + "\n" + f"2{FIELD_SEPARATOR}Re: Subject{FIELD_SEPARATOR}sender2{FIELD_SEPARATOR}Tuesday{FIELD_SEPARATOR}INBOX{FIELD_SEPARATOR}iCloud" + ), + ] + ), + ) cmd_thread(_args(id=42, limit=100, all_accounts=False)) out = capsys.readouterr().out @@ -1124,10 +1144,8 @@ def test_export_target_bulk(self, monkeypatch, tmp_path, capsys): """export with non-numeric target triggers bulk export (line 39).""" from mxctl.commands.mail.composite import cmd_export - entry = (f"1{FIELD_SEPARATOR}Subject{FIELD_SEPARATOR}sender@example.com" - f"{FIELD_SEPARATOR}Monday{FIELD_SEPARATOR}Body") - monkeypatch.setattr("mxctl.commands.mail.composite.run", - Mock(return_value=f"{entry}{RECORD_SEPARATOR}\n")) + entry = f"1{FIELD_SEPARATOR}Subject{FIELD_SEPARATOR}sender@example.com{FIELD_SEPARATOR}Monday{FIELD_SEPARATOR}Body" + monkeypatch.setattr("mxctl.commands.mail.composite.run", Mock(return_value=f"{entry}{RECORD_SEPARATOR}\n")) args = _args(target="INBOX", to=str(tmp_path), after=None, mailbox="INBOX") cmd_export(args) @@ -1170,6 +1188,7 @@ def test_delete_mailbox_count_fails_gracefully(self, monkeypatch, capsys): # First call (count) raises SystemExit, second call (delete) succeeds call_count = [0] + def mock_run(script): call_count[0] += 1 if call_count[0] == 1: @@ -1192,8 +1211,7 @@ def test_empty_trash_generic_subprocess_error(self, monkeypatch): monkeypatch.setattr("mxctl.commands.mail.manage.resolve_account", lambda _: "iCloud") monkeypatch.setattr("mxctl.commands.mail.manage.run", Mock(return_value="5")) - monkeypatch.setattr("mxctl.commands.mail.manage.subprocess.run", - Mock(return_value=Mock(returncode=1, stderr="Some random error"))) + monkeypatch.setattr("mxctl.commands.mail.manage.subprocess.run", Mock(return_value=Mock(returncode=1, stderr="Some random error"))) with pytest.raises(SystemExit): cmd_empty_trash(_args(all=False)) @@ -1231,15 +1249,20 @@ def test_headers_auth_results_list(self, monkeypatch, capsys): """Multiple Authentication-Results headers joined (line 74).""" from mxctl.commands.mail.system import cmd_headers - monkeypatch.setattr("mxctl.commands.mail.system.run", Mock(return_value=( - "From: sender@example.com\n" - "To: recipient@example.com\n" - "Subject: Test\n" - "Date: Mon, 14 Feb 2026 10:00:00\n" - "Message-Id: \n" - "Authentication-Results: spf=pass\n" - "Authentication-Results: dkim=pass\n" - ))) + monkeypatch.setattr( + "mxctl.commands.mail.system.run", + Mock( + return_value=( + "From: sender@example.com\n" + "To: recipient@example.com\n" + "Subject: Test\n" + "Date: Mon, 14 Feb 2026 10:00:00\n" + "Message-Id: \n" + "Authentication-Results: spf=pass\n" + "Authentication-Results: dkim=pass\n" + ) + ), + ) cmd_headers(_args(id=42, raw=False)) out = capsys.readouterr().out @@ -1249,14 +1272,19 @@ def test_headers_spf_softfail(self, monkeypatch, capsys): """SPF softfail is detected (line 83).""" from mxctl.commands.mail.system import cmd_headers - monkeypatch.setattr("mxctl.commands.mail.system.run", Mock(return_value=( - "From: sender@example.com\n" - "To: recipient@example.com\n" - "Subject: Test\n" - "Date: Mon, 14 Feb 2026 10:00:00\n" - "Message-Id: \n" - "Authentication-Results: spf=softfail\n" - ))) + monkeypatch.setattr( + "mxctl.commands.mail.system.run", + Mock( + return_value=( + "From: sender@example.com\n" + "To: recipient@example.com\n" + "Subject: Test\n" + "Date: Mon, 14 Feb 2026 10:00:00\n" + "Message-Id: \n" + "Authentication-Results: spf=softfail\n" + ) + ), + ) cmd_headers(_args(id=42, raw=False)) out = capsys.readouterr().out @@ -1266,14 +1294,19 @@ def test_headers_received_single_string(self, monkeypatch, capsys): """Single Received header (string, not list) counts as 1 hop (line 96).""" from mxctl.commands.mail.system import cmd_headers - monkeypatch.setattr("mxctl.commands.mail.system.run", Mock(return_value=( - "From: sender@example.com\n" - "To: recipient@example.com\n" - "Subject: Test\n" - "Date: Mon, 14 Feb 2026 10:00:00\n" - "Message-Id: \n" - "Received: from server1\n" - ))) + monkeypatch.setattr( + "mxctl.commands.mail.system.run", + Mock( + return_value=( + "From: sender@example.com\n" + "To: recipient@example.com\n" + "Subject: Test\n" + "Date: Mon, 14 Feb 2026 10:00:00\n" + "Message-Id: \n" + "Received: from server1\n" + ) + ), + ) cmd_headers(_args(id=42, raw=False)) out = capsys.readouterr().out @@ -1283,14 +1316,19 @@ def test_headers_in_reply_to_shown(self, monkeypatch, capsys): """In-Reply-To header shown (line 106).""" from mxctl.commands.mail.system import cmd_headers - monkeypatch.setattr("mxctl.commands.mail.system.run", Mock(return_value=( - "From: sender@example.com\n" - "To: recipient@example.com\n" - "Subject: Re: Test\n" - "Date: Mon, 14 Feb 2026 10:00:00\n" - "Message-Id: \n" - "In-Reply-To: \n" - ))) + monkeypatch.setattr( + "mxctl.commands.mail.system.run", + Mock( + return_value=( + "From: sender@example.com\n" + "To: recipient@example.com\n" + "Subject: Re: Test\n" + "Date: Mon, 14 Feb 2026 10:00:00\n" + "Message-Id: \n" + "In-Reply-To: \n" + ) + ), + ) cmd_headers(_args(id=42, raw=False)) out = capsys.readouterr().out @@ -1300,14 +1338,19 @@ def test_headers_return_path_shown(self, monkeypatch, capsys): """Return-Path header shown (line 108).""" from mxctl.commands.mail.system import cmd_headers - monkeypatch.setattr("mxctl.commands.mail.system.run", Mock(return_value=( - "From: sender@example.com\n" - "To: recipient@example.com\n" - "Subject: Test\n" - "Date: Mon, 14 Feb 2026 10:00:00\n" - "Message-Id: \n" - "Return-Path: \n" - ))) + monkeypatch.setattr( + "mxctl.commands.mail.system.run", + Mock( + return_value=( + "From: sender@example.com\n" + "To: recipient@example.com\n" + "Subject: Test\n" + "Date: Mon, 14 Feb 2026 10:00:00\n" + "Message-Id: \n" + "Return-Path: \n" + ) + ), + ) cmd_headers(_args(id=42, raw=False)) out = capsys.readouterr().out @@ -1317,11 +1360,9 @@ def test_rules_blank_line_skipped(self, monkeypatch, capsys): """Blank lines in rules output are skipped (line 152).""" from mxctl.commands.mail.system import cmd_rules - monkeypatch.setattr("mxctl.commands.mail.system.run", Mock(return_value=( - f"My Rule{FIELD_SEPARATOR}true\n" - "\n" - f"Other Rule{FIELD_SEPARATOR}false\n" - ))) + monkeypatch.setattr( + "mxctl.commands.mail.system.run", Mock(return_value=(f"My Rule{FIELD_SEPARATOR}true\n\nOther Rule{FIELD_SEPARATOR}false\n")) + ) cmd_rules(_args(action=None, rule_name=None)) out = capsys.readouterr().out @@ -1341,8 +1382,7 @@ def test_show_template_no_file_not_found(self, monkeypatch, tmp_path): """Show template when file doesn't exist dies with 'not found' (line 63-67 via _load_templates).""" from mxctl.commands.mail.templates import cmd_templates_show - monkeypatch.setattr("mxctl.commands.mail.templates.TEMPLATES_FILE", - str(tmp_path / "missing.json")) + monkeypatch.setattr("mxctl.commands.mail.templates.TEMPLATES_FILE", str(tmp_path / "missing.json")) with pytest.raises(SystemExit): cmd_templates_show(_args(name="test")) @@ -1351,8 +1391,7 @@ def test_delete_template_no_file_not_found(self, monkeypatch, tmp_path): """Delete template when file doesn't exist dies with 'not found'.""" from mxctl.commands.mail.templates import cmd_templates_delete - monkeypatch.setattr("mxctl.commands.mail.templates.TEMPLATES_FILE", - str(tmp_path / "missing.json")) + monkeypatch.setattr("mxctl.commands.mail.templates.TEMPLATES_FILE", str(tmp_path / "missing.json")) with pytest.raises(SystemExit): cmd_templates_delete(_args(name="test")) @@ -1383,27 +1422,30 @@ class TestTodoistMissingLines: def _todoist_args(self, **kwargs): defaults = { - "json": False, "account": "iCloud", "mailbox": "INBOX", - "id": 42, "project": None, "priority": 1, "due": None, + "json": False, + "account": "iCloud", + "mailbox": "INBOX", + "id": 42, + "project": None, + "priority": 1, + "due": None, } defaults.update(kwargs) return Namespace(**defaults) def _setup_todoist(self, monkeypatch, token="fake-token"): """Common setup for todoist tests.""" - monkeypatch.setattr("mxctl.commands.mail.todoist_integration.get_config", - lambda: {"todoist_api_token": token}) - monkeypatch.setattr("mxctl.commands.mail.todoist_integration.run", - Mock(return_value=f"Subject{FIELD_SEPARATOR}sender@x.com{FIELD_SEPARATOR}Monday")) + monkeypatch.setattr("mxctl.commands.mail.todoist_integration.get_config", lambda: {"todoist_api_token": token}) + monkeypatch.setattr( + "mxctl.commands.mail.todoist_integration.run", Mock(return_value=f"Subject{FIELD_SEPARATOR}sender@x.com{FIELD_SEPARATOR}Monday") + ) def test_message_read_fails(self, monkeypatch): """Too few fields from AppleScript dies (line 56).""" from mxctl.commands.mail.todoist_integration import cmd_to_todoist - monkeypatch.setattr("mxctl.commands.mail.todoist_integration.get_config", - lambda: {"todoist_api_token": "token"}) - monkeypatch.setattr("mxctl.commands.mail.todoist_integration.run", - Mock(return_value="only-subject")) + monkeypatch.setattr("mxctl.commands.mail.todoist_integration.get_config", lambda: {"todoist_api_token": "token"}) + monkeypatch.setattr("mxctl.commands.mail.todoist_integration.run", Mock(return_value="only-subject")) with pytest.raises(SystemExit): cmd_to_todoist(self._todoist_args()) @@ -1413,8 +1455,7 @@ def test_project_ssl_error_dies(self, monkeypatch): from mxctl.commands.mail.todoist_integration import cmd_to_todoist self._setup_todoist(monkeypatch) - monkeypatch.setattr("mxctl.commands.mail.todoist_integration.urllib.request.urlopen", - Mock(side_effect=ssl.SSLError("cert error"))) + monkeypatch.setattr("mxctl.commands.mail.todoist_integration.urllib.request.urlopen", Mock(side_effect=ssl.SSLError("cert error"))) with pytest.raises(SystemExit): cmd_to_todoist(self._todoist_args(project="Work")) @@ -1426,11 +1467,12 @@ def test_project_http_error_dies(self, monkeypatch): self._setup_todoist(monkeypatch) err = urllib.error.HTTPError( url="https://api.todoist.com/api/v1/projects", - code=500, msg="Server Error", hdrs=None, + code=500, + msg="Server Error", + hdrs=None, fp=MagicMock(read=Mock(return_value=b"Internal Server Error")), ) - monkeypatch.setattr("mxctl.commands.mail.todoist_integration.urllib.request.urlopen", - Mock(side_effect=err)) + monkeypatch.setattr("mxctl.commands.mail.todoist_integration.urllib.request.urlopen", Mock(side_effect=err)) with pytest.raises(SystemExit): cmd_to_todoist(self._todoist_args(project="Work")) @@ -1440,8 +1482,9 @@ def test_project_url_error_dies(self, monkeypatch): from mxctl.commands.mail.todoist_integration import cmd_to_todoist self._setup_todoist(monkeypatch) - monkeypatch.setattr("mxctl.commands.mail.todoist_integration.urllib.request.urlopen", - Mock(side_effect=urllib.error.URLError("DNS failure"))) + monkeypatch.setattr( + "mxctl.commands.mail.todoist_integration.urllib.request.urlopen", Mock(side_effect=urllib.error.URLError("DNS failure")) + ) with pytest.raises(SystemExit): cmd_to_todoist(self._todoist_args(project="Work")) @@ -1451,8 +1494,7 @@ def test_project_timeout_dies(self, monkeypatch): from mxctl.commands.mail.todoist_integration import cmd_to_todoist self._setup_todoist(monkeypatch) - monkeypatch.setattr("mxctl.commands.mail.todoist_integration.urllib.request.urlopen", - Mock(side_effect=TimeoutError())) + monkeypatch.setattr("mxctl.commands.mail.todoist_integration.urllib.request.urlopen", Mock(side_effect=TimeoutError())) with pytest.raises(SystemExit): cmd_to_todoist(self._todoist_args(project="Work")) @@ -1468,8 +1510,7 @@ def test_task_due_string_included(self, monkeypatch, capsys): mock_resp.read.return_value = json.dumps(response_payload).encode("utf-8") mock_resp.__enter__ = lambda s: s mock_resp.__exit__ = MagicMock(return_value=False) - monkeypatch.setattr("mxctl.commands.mail.todoist_integration.urllib.request.urlopen", - Mock(return_value=mock_resp)) + monkeypatch.setattr("mxctl.commands.mail.todoist_integration.urllib.request.urlopen", Mock(return_value=mock_resp)) cmd_to_todoist(self._todoist_args(due="tomorrow")) out = capsys.readouterr().out @@ -1480,8 +1521,7 @@ def test_task_ssl_error_dies(self, monkeypatch): from mxctl.commands.mail.todoist_integration import cmd_to_todoist self._setup_todoist(monkeypatch) - monkeypatch.setattr("mxctl.commands.mail.todoist_integration.urllib.request.urlopen", - Mock(side_effect=ssl.SSLError("cert error"))) + monkeypatch.setattr("mxctl.commands.mail.todoist_integration.urllib.request.urlopen", Mock(side_effect=ssl.SSLError("cert error"))) with pytest.raises(SystemExit): cmd_to_todoist(self._todoist_args()) @@ -1491,8 +1531,9 @@ def test_task_url_error_dies(self, monkeypatch): from mxctl.commands.mail.todoist_integration import cmd_to_todoist self._setup_todoist(monkeypatch) - monkeypatch.setattr("mxctl.commands.mail.todoist_integration.urllib.request.urlopen", - Mock(side_effect=urllib.error.URLError("no route"))) + monkeypatch.setattr( + "mxctl.commands.mail.todoist_integration.urllib.request.urlopen", Mock(side_effect=urllib.error.URLError("no route")) + ) with pytest.raises(SystemExit): cmd_to_todoist(self._todoist_args()) @@ -1502,8 +1543,7 @@ def test_task_timeout_dies(self, monkeypatch): from mxctl.commands.mail.todoist_integration import cmd_to_todoist self._setup_todoist(monkeypatch) - monkeypatch.setattr("mxctl.commands.mail.todoist_integration.urllib.request.urlopen", - Mock(side_effect=TimeoutError())) + monkeypatch.setattr("mxctl.commands.mail.todoist_integration.urllib.request.urlopen", Mock(side_effect=TimeoutError())) with pytest.raises(SystemExit): cmd_to_todoist(self._todoist_args()) @@ -1515,11 +1555,12 @@ def test_task_http_error_dies(self, monkeypatch): self._setup_todoist(monkeypatch) err = urllib.error.HTTPError( url="https://api.todoist.com/api/v1/tasks", - code=403, msg="Forbidden", hdrs=None, + code=403, + msg="Forbidden", + hdrs=None, fp=MagicMock(read=Mock(return_value=b"Forbidden")), ) - monkeypatch.setattr("mxctl.commands.mail.todoist_integration.urllib.request.urlopen", - Mock(side_effect=err)) + monkeypatch.setattr("mxctl.commands.mail.todoist_integration.urllib.request.urlopen", Mock(side_effect=err)) with pytest.raises(SystemExit): cmd_to_todoist(self._todoist_args()) @@ -1528,8 +1569,7 @@ def test_invalid_token_dies(self, monkeypatch): """Empty/invalid token string dies (line 37).""" from mxctl.commands.mail.todoist_integration import cmd_to_todoist - monkeypatch.setattr("mxctl.commands.mail.todoist_integration.get_config", - lambda: {"todoist_api_token": ""}) + monkeypatch.setattr("mxctl.commands.mail.todoist_integration.get_config", lambda: {"todoist_api_token": ""}) with pytest.raises(SystemExit): cmd_to_todoist(self._todoist_args()) @@ -1538,8 +1578,7 @@ def test_non_string_token_dies(self, monkeypatch): """Non-string token dies (line 37).""" from mxctl.commands.mail.todoist_integration import cmd_to_todoist - monkeypatch.setattr("mxctl.commands.mail.todoist_integration.get_config", - lambda: {"todoist_api_token": 12345}) + monkeypatch.setattr("mxctl.commands.mail.todoist_integration.get_config", lambda: {"todoist_api_token": 12345}) with pytest.raises(SystemExit): cmd_to_todoist(self._todoist_args()) @@ -1552,9 +1591,7 @@ def test_project_paginated_response(self, monkeypatch, capsys): # First call: GET projects (paginated format) projects_resp = MagicMock() - projects_resp.read.return_value = json.dumps( - {"results": [{"id": "proj_1", "name": "Work"}], "next_cursor": None} - ).encode("utf-8") + projects_resp.read.return_value = json.dumps({"results": [{"id": "proj_1", "name": "Work"}], "next_cursor": None}).encode("utf-8") projects_resp.__enter__ = lambda s: s projects_resp.__exit__ = MagicMock(return_value=False) @@ -1564,9 +1601,66 @@ def test_project_paginated_response(self, monkeypatch, capsys): task_resp.__enter__ = lambda s: s task_resp.__exit__ = MagicMock(return_value=False) - monkeypatch.setattr("mxctl.commands.mail.todoist_integration.urllib.request.urlopen", - Mock(side_effect=[projects_resp, task_resp])) + monkeypatch.setattr("mxctl.commands.mail.todoist_integration.urllib.request.urlopen", Mock(side_effect=[projects_resp, task_resp])) cmd_to_todoist(self._todoist_args(project="Work")) out = capsys.readouterr().out assert "Subject" in out + + +# --------------------------------------------------------------------------- +# api.py — import coverage +# --------------------------------------------------------------------------- + + +class TestApiImports: + """Verify all api.py exports are importable.""" + + def test_api_all_exports(self): + """Every name in __all__ is importable and callable.""" + import mxctl.api as api + + assert hasattr(api, "__all__") + assert len(api.__all__) >= 50 + for name in api.__all__: + obj = getattr(api, name) + assert callable(obj), f"{name} is not callable" + + +# --------------------------------------------------------------------------- +# analytics.py — stats --all empty mailboxes branch (lines 293-295) +# --------------------------------------------------------------------------- + + +class TestStatsAllEmptyMailboxes: + def test_stats_all_no_mailboxes(self, monkeypatch, capsys): + """stats --all with empty result hits the 'no mailboxes' branch.""" + from mxctl.commands.mail.analytics import cmd_stats + + monkeypatch.setattr("mxctl.commands.mail.analytics.resolve_account", lambda _: "iCloud") + monkeypatch.setattr("mxctl.commands.mail.analytics.run", Mock(return_value="")) + + args = _args(account=None, all=True, mailbox=None) + cmd_stats(args) + + out = capsys.readouterr().out + assert "No mailboxes found" in out + + +# --------------------------------------------------------------------------- +# system.py — get_headers() data function (lines 42-54) +# --------------------------------------------------------------------------- + + +class TestGetHeadersDataFunction: + def test_get_headers_returns_parsed_dict(self, monkeypatch): + """get_headers() calls AppleScript and returns parsed header dict.""" + from mxctl.commands.mail.system import get_headers + + raw = "From: sender@example.com\nTo: recipient@example.com\nSubject: Test\n" + monkeypatch.setattr("mxctl.commands.mail.system.run", Mock(return_value=raw)) + + result = get_headers("iCloud", "INBOX", 123) + assert isinstance(result, dict) + assert result.get("From") == "sender@example.com" + assert result.get("Subject") == "Test" diff --git a/tests/test_ai_classification.py b/tests/test_ai_classification.py index 98ab7e1..16b7b96 100644 --- a/tests/test_ai_classification.py +++ b/tests/test_ai_classification.py @@ -43,9 +43,7 @@ def test_noreply_patterns_match_automated_senders(self): ] for email in automated_emails: matched = any(p in email.lower() for p in NOREPLY_PATTERNS) - assert matched, ( - f"Expected '{email}' to match NOREPLY_PATTERNS but it did not" - ) + assert matched, f"Expected '{email}' to match NOREPLY_PATTERNS but it did not" def test_noreply_patterns_do_not_match_personal_senders(self): """NOREPLY_PATTERNS should not flag real personal email addresses.""" @@ -56,22 +54,22 @@ def test_noreply_patterns_do_not_match_personal_senders(self): ] for email in personal_emails: matched = any(p in email.lower() for p in NOREPLY_PATTERNS) - assert not matched, ( - f"Expected '{email}' NOT to match NOREPLY_PATTERNS but it did" - ) + assert not matched, f"Expected '{email}' NOT to match NOREPLY_PATTERNS but it did" def test_cmd_triage_categorizes_noreply_sender_as_notification(self, monkeypatch, capsys): """cmd_triage places a noreply@ sender into NOTIFICATIONS, not PEOPLE.""" from mxctl.commands.mail.ai import cmd_triage - mock_run = Mock(return_value=( - f"iCloud{FIELD_SEPARATOR}10{FIELD_SEPARATOR}Your Receipt{FIELD_SEPARATOR}" - f"noreply@shop.com{FIELD_SEPARATOR}2026-01-01{FIELD_SEPARATOR}false\n" - )) + mock_run = Mock( + return_value=( + f"iCloud{FIELD_SEPARATOR}10{FIELD_SEPARATOR}Your Receipt{FIELD_SEPARATOR}" + f"noreply@shop.com{FIELD_SEPARATOR}2026-01-01{FIELD_SEPARATOR}false\n" + ) + ) monkeypatch.setattr("mxctl.commands.mail.ai.run", mock_run) monkeypatch.setattr( "mxctl.commands.mail.ai.inbox_iterator_all_accounts", - lambda inner_ops, cap=30, account=None: "tell application \"Mail\"\nend tell", + lambda inner_ops, cap=30, account=None: 'tell application "Mail"\nend tell', ) args = argparse.Namespace(account=None, json=False) @@ -86,14 +84,16 @@ def test_cmd_triage_categorizes_display_name_noreply_as_notification(self, monke from mxctl.commands.mail.ai import cmd_triage # Sender has a friendly display name but a no-reply address - mock_run = Mock(return_value=( - f"iCloud{FIELD_SEPARATOR}11{FIELD_SEPARATOR}Weekly Digest{FIELD_SEPARATOR}" - f'"Shop Alerts" {FIELD_SEPARATOR}2026-01-05{FIELD_SEPARATOR}false\n' - )) + mock_run = Mock( + return_value=( + f"iCloud{FIELD_SEPARATOR}11{FIELD_SEPARATOR}Weekly Digest{FIELD_SEPARATOR}" + f'"Shop Alerts" {FIELD_SEPARATOR}2026-01-05{FIELD_SEPARATOR}false\n' + ) + ) monkeypatch.setattr("mxctl.commands.mail.ai.run", mock_run) monkeypatch.setattr( "mxctl.commands.mail.ai.inbox_iterator_all_accounts", - lambda inner_ops, cap=30, account=None: "tell application \"Mail\"\nend tell", + lambda inner_ops, cap=30, account=None: 'tell application "Mail"\nend tell', ) args = argparse.Namespace(account=None, json=False) @@ -252,14 +252,16 @@ def test_summary_sender_display_name_extracted(self, monkeypatch, capsys): """cmd_summary strips angle-bracket email addresses, showing only the display name.""" from mxctl.commands.mail.ai import cmd_summary - mock_run = Mock(return_value=( - f'iCloud{FIELD_SEPARATOR}200{FIELD_SEPARATOR}Hello{FIELD_SEPARATOR}' - f'"Alice Smith" {FIELD_SEPARATOR}2026-02-01\n' - )) + mock_run = Mock( + return_value=( + f"iCloud{FIELD_SEPARATOR}200{FIELD_SEPARATOR}Hello{FIELD_SEPARATOR}" + f'"Alice Smith" {FIELD_SEPARATOR}2026-02-01\n' + ) + ) monkeypatch.setattr("mxctl.commands.mail.ai.run", mock_run) monkeypatch.setattr( "mxctl.commands.mail.ai.inbox_iterator_all_accounts", - lambda inner_ops, cap=20, account=None: "tell application \"Mail\"\nend tell", + lambda inner_ops, cap=20, account=None: 'tell application "Mail"\nend tell', ) args = argparse.Namespace(account=None, json=False) @@ -275,14 +277,16 @@ def test_summary_skips_malformed_lines(self, monkeypatch, capsys): from mxctl.commands.mail.ai import cmd_summary # One valid line, one malformed (only 2 fields) - mock_run = Mock(return_value=( - f"iCloud{FIELD_SEPARATOR}99{FIELD_SEPARATOR}Good Subject{FIELD_SEPARATOR}a@b.com{FIELD_SEPARATOR}2026-01-10\n" - f"bad-line-no-separators\n" - )) + mock_run = Mock( + return_value=( + f"iCloud{FIELD_SEPARATOR}99{FIELD_SEPARATOR}Good Subject{FIELD_SEPARATOR}a@b.com{FIELD_SEPARATOR}2026-01-10\n" + f"bad-line-no-separators\n" + ) + ) monkeypatch.setattr("mxctl.commands.mail.ai.run", mock_run) monkeypatch.setattr( "mxctl.commands.mail.ai.inbox_iterator_all_accounts", - lambda inner_ops, cap=20, account=None: "tell application \"Mail\"\nend tell", + lambda inner_ops, cap=20, account=None: 'tell application "Mail"\nend tell', ) args = argparse.Namespace(account=None, json=False) @@ -297,13 +301,15 @@ def test_summary_json_contains_all_fields(self, monkeypatch, capsys): """cmd_summary JSON output includes account, id, subject, sender, and date fields.""" from mxctl.commands.mail.ai import cmd_summary - mock_run = Mock(return_value=( - f"Work{FIELD_SEPARATOR}555{FIELD_SEPARATOR}Quarterly Report{FIELD_SEPARATOR}boss@work.com{FIELD_SEPARATOR}2026-03-15\n" - )) + mock_run = Mock( + return_value=( + f"Work{FIELD_SEPARATOR}555{FIELD_SEPARATOR}Quarterly Report{FIELD_SEPARATOR}boss@work.com{FIELD_SEPARATOR}2026-03-15\n" + ) + ) monkeypatch.setattr("mxctl.commands.mail.ai.run", mock_run) monkeypatch.setattr( "mxctl.commands.mail.ai.inbox_iterator_all_accounts", - lambda inner_ops, cap=20, account=None: "tell application \"Mail\"\nend tell", + lambda inner_ops, cap=20, account=None: 'tell application "Mail"\nend tell', ) args = argparse.Namespace(account=None, json=True) @@ -327,14 +333,16 @@ def test_triage_all_flagged_shows_no_people_or_notifications(self, monkeypatch, """When every message is flagged, PEOPLE and NOTIFICATIONS sections are absent.""" from mxctl.commands.mail.ai import cmd_triage - mock_run = Mock(return_value=( - f"iCloud{FIELD_SEPARATOR}1{FIELD_SEPARATOR}Urgent A{FIELD_SEPARATOR}a@a.com{FIELD_SEPARATOR}2026-01-01{FIELD_SEPARATOR}true\n" - f"iCloud{FIELD_SEPARATOR}2{FIELD_SEPARATOR}Urgent B{FIELD_SEPARATOR}b@b.com{FIELD_SEPARATOR}2026-01-02{FIELD_SEPARATOR}true\n" - )) + mock_run = Mock( + return_value=( + f"iCloud{FIELD_SEPARATOR}1{FIELD_SEPARATOR}Urgent A{FIELD_SEPARATOR}a@a.com{FIELD_SEPARATOR}2026-01-01{FIELD_SEPARATOR}true\n" + f"iCloud{FIELD_SEPARATOR}2{FIELD_SEPARATOR}Urgent B{FIELD_SEPARATOR}b@b.com{FIELD_SEPARATOR}2026-01-02{FIELD_SEPARATOR}true\n" + ) + ) monkeypatch.setattr("mxctl.commands.mail.ai.run", mock_run) monkeypatch.setattr( "mxctl.commands.mail.ai.inbox_iterator_all_accounts", - lambda inner_ops, cap=30, account=None: "tell application \"Mail\"\nend tell", + lambda inner_ops, cap=30, account=None: 'tell application "Mail"\nend tell', ) args = argparse.Namespace(account=None, json=False) @@ -350,14 +358,16 @@ def test_triage_skips_lines_with_fewer_than_six_fields(self, monkeypatch, capsys from mxctl.commands.mail.ai import cmd_triage # One valid line (6 fields) and one truncated line (5 fields — no flagged status) - mock_run = Mock(return_value=( - f"iCloud{FIELD_SEPARATOR}10{FIELD_SEPARATOR}Valid{FIELD_SEPARATOR}p@p.com{FIELD_SEPARATOR}2026-01-01{FIELD_SEPARATOR}false\n" - f"iCloud{FIELD_SEPARATOR}11{FIELD_SEPARATOR}Truncated{FIELD_SEPARATOR}q@q.com{FIELD_SEPARATOR}2026-01-02\n" - )) + mock_run = Mock( + return_value=( + f"iCloud{FIELD_SEPARATOR}10{FIELD_SEPARATOR}Valid{FIELD_SEPARATOR}p@p.com{FIELD_SEPARATOR}2026-01-01{FIELD_SEPARATOR}false\n" + f"iCloud{FIELD_SEPARATOR}11{FIELD_SEPARATOR}Truncated{FIELD_SEPARATOR}q@q.com{FIELD_SEPARATOR}2026-01-02\n" + ) + ) monkeypatch.setattr("mxctl.commands.mail.ai.run", mock_run) monkeypatch.setattr( "mxctl.commands.mail.ai.inbox_iterator_all_accounts", - lambda inner_ops, cap=30, account=None: "tell application \"Mail\"\nend tell", + lambda inner_ops, cap=30, account=None: 'tell application "Mail"\nend tell', ) args = argparse.Namespace(account=None, json=False) @@ -371,13 +381,15 @@ def test_triage_json_structure_has_correct_keys(self, monkeypatch, capsys): """cmd_triage JSON output is an object with exactly flagged, people, and notifications keys.""" from mxctl.commands.mail.ai import cmd_triage - mock_run = Mock(return_value=( - f"iCloud{FIELD_SEPARATOR}5{FIELD_SEPARATOR}Note{FIELD_SEPARATOR}friend@ex.com{FIELD_SEPARATOR}2026-01-01{FIELD_SEPARATOR}false\n" - )) + mock_run = Mock( + return_value=( + f"iCloud{FIELD_SEPARATOR}5{FIELD_SEPARATOR}Note{FIELD_SEPARATOR}friend@ex.com{FIELD_SEPARATOR}2026-01-01{FIELD_SEPARATOR}false\n" + ) + ) monkeypatch.setattr("mxctl.commands.mail.ai.run", mock_run) monkeypatch.setattr( "mxctl.commands.mail.ai.inbox_iterator_all_accounts", - lambda inner_ops, cap=30, account=None: "tell application \"Mail\"\nend tell", + lambda inner_ops, cap=30, account=None: 'tell application "Mail"\nend tell', ) args = argparse.Namespace(account=None, json=True) @@ -394,13 +406,15 @@ def test_triage_json_message_has_flagged_field(self, monkeypatch, capsys): """Each message dict in triage JSON output includes a boolean 'flagged' field.""" from mxctl.commands.mail.ai import cmd_triage - mock_run = Mock(return_value=( - f"iCloud{FIELD_SEPARATOR}7{FIELD_SEPARATOR}Important{FIELD_SEPARATOR}boss@co.com{FIELD_SEPARATOR}2026-02-10{FIELD_SEPARATOR}true\n" - )) + mock_run = Mock( + return_value=( + f"iCloud{FIELD_SEPARATOR}7{FIELD_SEPARATOR}Important{FIELD_SEPARATOR}boss@co.com{FIELD_SEPARATOR}2026-02-10{FIELD_SEPARATOR}true\n" + ) + ) monkeypatch.setattr("mxctl.commands.mail.ai.run", mock_run) monkeypatch.setattr( "mxctl.commands.mail.ai.inbox_iterator_all_accounts", - lambda inner_ops, cap=30, account=None: "tell application \"Mail\"\nend tell", + lambda inner_ops, cap=30, account=None: 'tell application "Mail"\nend tell', ) args = argparse.Namespace(account=None, json=True) @@ -419,6 +433,7 @@ def test_triage_json_message_has_flagged_field(self, monkeypatch, capsys): # cmd_summary — empty inbox path (line 38 — blank line skip in summary) # =========================================================================== + class TestCmdSummaryBlankLineSkip: """Test that cmd_summary skips blank lines in output (line 38).""" @@ -426,16 +441,18 @@ def test_summary_blank_line_in_output(self, monkeypatch, capsys): """cmd_summary skips blank lines between valid messages.""" from mxctl.commands.mail.ai import cmd_summary - mock_run = Mock(return_value=( - f"iCloud{FIELD_SEPARATOR}100{FIELD_SEPARATOR}First{FIELD_SEPARATOR}a@b.com{FIELD_SEPARATOR}2026-01-01\n" - f"\n" - f" \n" - f"iCloud{FIELD_SEPARATOR}101{FIELD_SEPARATOR}Second{FIELD_SEPARATOR}c@d.com{FIELD_SEPARATOR}2026-01-02\n" - )) + mock_run = Mock( + return_value=( + f"iCloud{FIELD_SEPARATOR}100{FIELD_SEPARATOR}First{FIELD_SEPARATOR}a@b.com{FIELD_SEPARATOR}2026-01-01\n" + f"\n" + f" \n" + f"iCloud{FIELD_SEPARATOR}101{FIELD_SEPARATOR}Second{FIELD_SEPARATOR}c@d.com{FIELD_SEPARATOR}2026-01-02\n" + ) + ) monkeypatch.setattr("mxctl.commands.mail.ai.run", mock_run) monkeypatch.setattr( "mxctl.commands.mail.ai.inbox_iterator_all_accounts", - lambda inner_ops, cap=20, account=None: "tell application \"Mail\"\nend tell", + lambda inner_ops, cap=20, account=None: 'tell application "Mail"\nend tell', ) args = argparse.Namespace(account=None, json=False) @@ -451,6 +468,7 @@ def test_summary_blank_line_in_output(self, monkeypatch, capsys): # cmd_triage — empty inbox and account filter (lines 67-68, 77) # =========================================================================== + class TestCmdTriageEdgeCases: """Additional coverage for cmd_triage edge cases.""" @@ -462,7 +480,7 @@ def test_triage_empty_inbox(self, monkeypatch, capsys): monkeypatch.setattr("mxctl.commands.mail.ai.run", mock_run) monkeypatch.setattr( "mxctl.commands.mail.ai.inbox_iterator_all_accounts", - lambda inner_ops, cap=30, account=None: "tell application \"Mail\"\nend tell", + lambda inner_ops, cap=30, account=None: 'tell application "Mail"\nend tell', ) args = argparse.Namespace(account=None, json=False) @@ -476,16 +494,18 @@ def test_triage_blank_line_skip(self, monkeypatch, capsys): from mxctl.commands.mail.ai import cmd_triage # Put blank lines BETWEEN two valid lines so strip() doesn't remove them - mock_run = Mock(return_value=( - f"iCloud{FIELD_SEPARATOR}10{FIELD_SEPARATOR}Valid A{FIELD_SEPARATOR}p@p.com{FIELD_SEPARATOR}2026-01-01{FIELD_SEPARATOR}false\n" - f"\n" - f" \n" - f"iCloud{FIELD_SEPARATOR}11{FIELD_SEPARATOR}Valid B{FIELD_SEPARATOR}q@q.com{FIELD_SEPARATOR}2026-01-02{FIELD_SEPARATOR}false\n" - )) + mock_run = Mock( + return_value=( + f"iCloud{FIELD_SEPARATOR}10{FIELD_SEPARATOR}Valid A{FIELD_SEPARATOR}p@p.com{FIELD_SEPARATOR}2026-01-01{FIELD_SEPARATOR}false\n" + f"\n" + f" \n" + f"iCloud{FIELD_SEPARATOR}11{FIELD_SEPARATOR}Valid B{FIELD_SEPARATOR}q@q.com{FIELD_SEPARATOR}2026-01-02{FIELD_SEPARATOR}false\n" + ) + ) monkeypatch.setattr("mxctl.commands.mail.ai.run", mock_run) monkeypatch.setattr( "mxctl.commands.mail.ai.inbox_iterator_all_accounts", - lambda inner_ops, cap=30, account=None: "tell application \"Mail\"\nend tell", + lambda inner_ops, cap=30, account=None: 'tell application "Mail"\nend tell', ) args = argparse.Namespace(account=None, json=False) @@ -498,15 +518,19 @@ def test_triage_with_account_filter(self, monkeypatch, capsys): """cmd_triage with -a flag passes account to inbox_iterator_all_accounts.""" from mxctl.commands.mail.ai import cmd_triage - mock_run = Mock(return_value=( - f"Work{FIELD_SEPARATOR}20{FIELD_SEPARATOR}Task{FIELD_SEPARATOR}boss@work.com{FIELD_SEPARATOR}2026-01-01{FIELD_SEPARATOR}false\n" - )) + mock_run = Mock( + return_value=( + f"Work{FIELD_SEPARATOR}20{FIELD_SEPARATOR}Task{FIELD_SEPARATOR}boss@work.com{FIELD_SEPARATOR}2026-01-01{FIELD_SEPARATOR}false\n" + ) + ) monkeypatch.setattr("mxctl.commands.mail.ai.run", mock_run) captured_account = [] + def mock_template(inner_ops, cap=30, account=None): captured_account.append(account) - return "tell application \"Mail\"\nend tell" + return 'tell application "Mail"\nend tell' + monkeypatch.setattr( "mxctl.commands.mail.ai.inbox_iterator_all_accounts", mock_template, @@ -522,6 +546,7 @@ def mock_template(inner_ops, cap=30, account=None): # cmd_context — edge cases (lines 127, 158, 168-169) # =========================================================================== + class TestCmdContextEdgeCases: """Coverage for cmd_context missing lines.""" @@ -551,10 +576,12 @@ def test_context_all_accounts_flag(self, monkeypatch, capsys): from mxctl.commands.mail.ai import cmd_context # First call returns main message; second call returns thread - mock_run = Mock(side_effect=[ - f"Subject{FIELD_SEPARATOR}sender@x.com{FIELD_SEPARATOR}Mon{FIELD_SEPARATOR}to@x.com{FIELD_SEPARATOR}Message body", - "", # No thread messages - ]) + mock_run = Mock( + side_effect=[ + f"Subject{FIELD_SEPARATOR}sender@x.com{FIELD_SEPARATOR}Mon{FIELD_SEPARATOR}to@x.com{FIELD_SEPARATOR}Message body", + "", # No thread messages + ] + ) monkeypatch.setattr("mxctl.commands.mail.ai.run", mock_run) args = argparse.Namespace(account="iCloud", mailbox="INBOX", id=100, limit=50, all_accounts=True, json=False) @@ -569,13 +596,15 @@ def test_context_with_thread_entries(self, monkeypatch, capsys): from mxctl.commands.mail.ai import cmd_context from mxctl.config import RECORD_SEPARATOR - mock_run = Mock(side_effect=[ - f"Meeting Notes{FIELD_SEPARATOR}alice@x.com{FIELD_SEPARATOR}Mon{FIELD_SEPARATOR}bob@x.com{FIELD_SEPARATOR}Main body", - ( - f"200{FIELD_SEPARATOR}Re: Meeting Notes{FIELD_SEPARATOR}bob@x.com{FIELD_SEPARATOR}Tue{FIELD_SEPARATOR}Reply body" - + RECORD_SEPARATOR - ), - ]) + mock_run = Mock( + side_effect=[ + f"Meeting Notes{FIELD_SEPARATOR}alice@x.com{FIELD_SEPARATOR}Mon{FIELD_SEPARATOR}bob@x.com{FIELD_SEPARATOR}Main body", + ( + f"200{FIELD_SEPARATOR}Re: Meeting Notes{FIELD_SEPARATOR}bob@x.com{FIELD_SEPARATOR}Tue{FIELD_SEPARATOR}Reply body" + + RECORD_SEPARATOR + ), + ] + ) monkeypatch.setattr("mxctl.commands.mail.ai.run", mock_run) args = argparse.Namespace(account="iCloud", mailbox="INBOX", id=100, limit=50, all_accounts=False, json=False) @@ -589,10 +618,12 @@ def test_context_json_output(self, monkeypatch, capsys): """cmd_context --json returns structured data.""" from mxctl.commands.mail.ai import cmd_context - mock_run = Mock(side_effect=[ - f"Subject{FIELD_SEPARATOR}s@x.com{FIELD_SEPARATOR}Mon{FIELD_SEPARATOR}t@x.com{FIELD_SEPARATOR}Body here", - "", - ]) + mock_run = Mock( + side_effect=[ + f"Subject{FIELD_SEPARATOR}s@x.com{FIELD_SEPARATOR}Mon{FIELD_SEPARATOR}t@x.com{FIELD_SEPARATOR}Body here", + "", + ] + ) monkeypatch.setattr("mxctl.commands.mail.ai.run", mock_run) args = argparse.Namespace(account="iCloud", mailbox="INBOX", id=100, limit=50, all_accounts=False, json=True) @@ -609,6 +640,7 @@ def test_context_json_output(self, monkeypatch, capsys): # cmd_find_related — edge cases (lines 247-266, 301, 304, 324) # =========================================================================== + class TestCmdFindRelatedEdgeCases: """Coverage for cmd_find_related missing lines.""" @@ -617,13 +649,15 @@ def test_find_related_by_message_id(self, monkeypatch, capsys): from mxctl.commands.mail.ai import cmd_find_related # First call: lookup message by ID; second call: search - mock_run = Mock(side_effect=[ - f"Re: Project Update{FIELD_SEPARATOR}alice@x.com", # lookup returns subject + sender - ( - f"50{FIELD_SEPARATOR}Project Update{FIELD_SEPARATOR}alice@x.com{FIELD_SEPARATOR}Mon{FIELD_SEPARATOR}INBOX{FIELD_SEPARATOR}iCloud\n" - f"51{FIELD_SEPARATOR}Re: Project Update{FIELD_SEPARATOR}bob@x.com{FIELD_SEPARATOR}Tue{FIELD_SEPARATOR}INBOX{FIELD_SEPARATOR}iCloud\n" - ), - ]) + mock_run = Mock( + side_effect=[ + f"Re: Project Update{FIELD_SEPARATOR}alice@x.com", # lookup returns subject + sender + ( + f"50{FIELD_SEPARATOR}Project Update{FIELD_SEPARATOR}alice@x.com{FIELD_SEPARATOR}Mon{FIELD_SEPARATOR}INBOX{FIELD_SEPARATOR}iCloud\n" + f"51{FIELD_SEPARATOR}Re: Project Update{FIELD_SEPARATOR}bob@x.com{FIELD_SEPARATOR}Tue{FIELD_SEPARATOR}INBOX{FIELD_SEPARATOR}iCloud\n" + ), + ] + ) monkeypatch.setattr("mxctl.commands.mail.ai.run", mock_run) args = argparse.Namespace(query="12345", json=False) @@ -663,12 +697,14 @@ def test_find_related_blank_line_skip(self, monkeypatch, capsys): from mxctl.commands.mail.ai import cmd_find_related # Put blank lines BETWEEN two valid lines - mock_run = Mock(return_value=( - f"60{FIELD_SEPARATOR}Topic{FIELD_SEPARATOR}a@b.com{FIELD_SEPARATOR}Mon{FIELD_SEPARATOR}INBOX{FIELD_SEPARATOR}iCloud\n" - f"\n" - f" \n" - f"61{FIELD_SEPARATOR}Topic{FIELD_SEPARATOR}c@d.com{FIELD_SEPARATOR}Tue{FIELD_SEPARATOR}INBOX{FIELD_SEPARATOR}iCloud\n" - )) + mock_run = Mock( + return_value=( + f"60{FIELD_SEPARATOR}Topic{FIELD_SEPARATOR}a@b.com{FIELD_SEPARATOR}Mon{FIELD_SEPARATOR}INBOX{FIELD_SEPARATOR}iCloud\n" + f"\n" + f" \n" + f"61{FIELD_SEPARATOR}Topic{FIELD_SEPARATOR}c@d.com{FIELD_SEPARATOR}Tue{FIELD_SEPARATOR}INBOX{FIELD_SEPARATOR}iCloud\n" + ) + ) monkeypatch.setattr("mxctl.commands.mail.ai.run", mock_run) args = argparse.Namespace(query="Topic", json=False) @@ -681,10 +717,12 @@ def test_find_related_malformed_line_skip(self, monkeypatch, capsys): """cmd_find_related skips malformed lines (line 304).""" from mxctl.commands.mail.ai import cmd_find_related - mock_run = Mock(return_value=( - f"70{FIELD_SEPARATOR}Good{FIELD_SEPARATOR}a@b.com{FIELD_SEPARATOR}Mon{FIELD_SEPARATOR}INBOX{FIELD_SEPARATOR}iCloud\n" - f"bad-line-no-separators\n" - )) + mock_run = Mock( + return_value=( + f"70{FIELD_SEPARATOR}Good{FIELD_SEPARATOR}a@b.com{FIELD_SEPARATOR}Mon{FIELD_SEPARATOR}INBOX{FIELD_SEPARATOR}iCloud\n" + f"bad-line-no-separators\n" + ) + ) monkeypatch.setattr("mxctl.commands.mail.ai.run", mock_run) args = argparse.Namespace(query="Good", json=False) diff --git a/tests/test_applescript.py b/tests/test_applescript.py index a03b42d..7a0305f 100644 --- a/tests/test_applescript.py +++ b/tests/test_applescript.py @@ -59,7 +59,7 @@ def test_smart_quote_account_not_found(self, monkeypatch, capsys): """Smart-quoted can\u2019t get account triggers friendly error.""" mock_result = Mock() mock_result.returncode = 1 - mock_result.stderr = "Can\u2019t get account \"Foo\". (-1728)" + mock_result.stderr = 'Can\u2019t get account "Foo". (-1728)' monkeypatch.setattr("mxctl.util.applescript.subprocess.run", lambda *a, **kw: mock_result) @@ -73,7 +73,7 @@ def test_smart_quote_message_not_found(self, monkeypatch, capsys): """Smart-quoted can\u2019t get message triggers friendly error.""" mock_result = Mock() mock_result.returncode = 1 - mock_result.stderr = "Can\u2019t get message 1 of mailbox \"INBOX\". (-1719)" + mock_result.stderr = 'Can\u2019t get message 1 of mailbox "INBOX". (-1719)' monkeypatch.setattr("mxctl.util.applescript.subprocess.run", lambda *a, **kw: mock_result) @@ -87,7 +87,7 @@ def test_straight_quote_still_works(self, monkeypatch, capsys): """ASCII straight-quoted can't get account still works.""" mock_result = Mock() mock_result.returncode = 1 - mock_result.stderr = "Can't get account \"Bar\". (-1728)" + mock_result.stderr = 'Can\'t get account "Bar". (-1728)' monkeypatch.setattr("mxctl.util.applescript.subprocess.run", lambda *a, **kw: mock_result) diff --git a/tests/test_commands.py b/tests/test_commands.py index bcb582a..cab6d3b 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -10,15 +10,18 @@ # cmd_inbox (accounts.py) # --------------------------------------------------------------------------- + def test_cmd_inbox_basic(monkeypatch, mock_args, capsys): """Smoke test: cmd_inbox displays unread counts across accounts.""" from mxctl.commands.mail.accounts import cmd_inbox - mock_run = Mock(return_value=( - f"iCloud{FIELD_SEPARATOR}2{FIELD_SEPARATOR}10\n" - f"MSG{FIELD_SEPARATOR}iCloud{FIELD_SEPARATOR}123{FIELD_SEPARATOR}Test Subject{FIELD_SEPARATOR}sender@example.com{FIELD_SEPARATOR}Mon Feb 14 2026 10:00:00\n" - f"Example Account{FIELD_SEPARATOR}0{FIELD_SEPARATOR}5\n" - )) + mock_run = Mock( + return_value=( + f"iCloud{FIELD_SEPARATOR}2{FIELD_SEPARATOR}10\n" + f"MSG{FIELD_SEPARATOR}iCloud{FIELD_SEPARATOR}123{FIELD_SEPARATOR}Test Subject{FIELD_SEPARATOR}sender@example.com{FIELD_SEPARATOR}Mon Feb 14 2026 10:00:00\n" + f"Example Account{FIELD_SEPARATOR}0{FIELD_SEPARATOR}5\n" + ) + ) monkeypatch.setattr("mxctl.commands.mail.accounts.run", mock_run) args = mock_args() @@ -82,10 +85,12 @@ def test_cmd_inbox_account_filter(monkeypatch, mock_args, capsys): """Smoke test: cmd_inbox -a filters to a single account.""" from mxctl.commands.mail.accounts import cmd_inbox - mock_run = Mock(return_value=( - f"iCloud{FIELD_SEPARATOR}1{FIELD_SEPARATOR}8\n" - f"MSG{FIELD_SEPARATOR}iCloud{FIELD_SEPARATOR}200{FIELD_SEPARATOR}Filtered Subject{FIELD_SEPARATOR}x@x.com{FIELD_SEPARATOR}Mon\n" - )) + mock_run = Mock( + return_value=( + f"iCloud{FIELD_SEPARATOR}1{FIELD_SEPARATOR}8\n" + f"MSG{FIELD_SEPARATOR}iCloud{FIELD_SEPARATOR}200{FIELD_SEPARATOR}Filtered Subject{FIELD_SEPARATOR}x@x.com{FIELD_SEPARATOR}Mon\n" + ) + ) monkeypatch.setattr("mxctl.commands.mail.accounts.run", mock_run) args = mock_args(account="iCloud") @@ -106,10 +111,7 @@ def test_cmd_inbox_no_account_flag_iterates_all_accounts(monkeypatch, capsys): from mxctl.commands.mail.accounts import cmd_inbox - mock_run = Mock(return_value=( - f"iCloud{FIELD_SEPARATOR}0{FIELD_SEPARATOR}5\n" - f"ASU Gmail{FIELD_SEPARATOR}14{FIELD_SEPARATOR}14\n" - )) + mock_run = Mock(return_value=(f"iCloud{FIELD_SEPARATOR}0{FIELD_SEPARATOR}5\nASU Gmail{FIELD_SEPARATOR}14{FIELD_SEPARATOR}14\n")) monkeypatch.setattr("mxctl.commands.mail.accounts.run", mock_run) # Simulate no -a flag: args.account is None @@ -145,16 +147,19 @@ def test_cmd_inbox_with_account_flag_scopes_to_single_account(monkeypatch, capsy # cmd_list (messages.py) # --------------------------------------------------------------------------- + def test_cmd_list_basic(monkeypatch, mock_args, capsys): """Smoke test: cmd_list displays messages.""" from mxctl.commands.mail.messages import cmd_list - mock_run = Mock(return_value=( - f"123{FIELD_SEPARATOR}Test Subject{FIELD_SEPARATOR}sender@example.com{FIELD_SEPARATOR}" - f"Mon Feb 14 2026{FIELD_SEPARATOR}true{FIELD_SEPARATOR}false\n" - f"124{FIELD_SEPARATOR}Another{FIELD_SEPARATOR}other@example.com{FIELD_SEPARATOR}" - f"Tue Feb 15 2026{FIELD_SEPARATOR}false{FIELD_SEPARATOR}true\n" - )) + mock_run = Mock( + return_value=( + f"123{FIELD_SEPARATOR}Test Subject{FIELD_SEPARATOR}sender@example.com{FIELD_SEPARATOR}" + f"Mon Feb 14 2026{FIELD_SEPARATOR}true{FIELD_SEPARATOR}false\n" + f"124{FIELD_SEPARATOR}Another{FIELD_SEPARATOR}other@example.com{FIELD_SEPARATOR}" + f"Tue Feb 15 2026{FIELD_SEPARATOR}false{FIELD_SEPARATOR}true\n" + ) + ) monkeypatch.setattr("mxctl.commands.mail.messages.run", mock_run) args = mock_args() @@ -172,7 +177,9 @@ def test_cmd_list_json(monkeypatch, mock_args, capsys): """Smoke test: cmd_list --json returns JSON array.""" from mxctl.commands.mail.messages import cmd_list - mock_run = Mock(return_value=f"123{FIELD_SEPARATOR}Test{FIELD_SEPARATOR}sender@ex.com{FIELD_SEPARATOR}Mon{FIELD_SEPARATOR}true{FIELD_SEPARATOR}false\n") + mock_run = Mock( + return_value=f"123{FIELD_SEPARATOR}Test{FIELD_SEPARATOR}sender@ex.com{FIELD_SEPARATOR}Mon{FIELD_SEPARATOR}true{FIELD_SEPARATOR}false\n" + ) monkeypatch.setattr("mxctl.commands.mail.messages.run", mock_run) args = mock_args(json=True) @@ -188,17 +195,20 @@ def test_cmd_list_json(monkeypatch, mock_args, capsys): # cmd_read (messages.py) # --------------------------------------------------------------------------- + def test_cmd_read_basic(monkeypatch, mock_args, capsys): """Smoke test: cmd_read displays full message details.""" from mxctl.commands.mail.messages import cmd_read - mock_run = Mock(return_value=( - f"123{FIELD_SEPARATOR}msg-id-123{FIELD_SEPARATOR}Test Subject{FIELD_SEPARATOR}sender@ex.com{FIELD_SEPARATOR}" - f"Mon Feb 14 2026{FIELD_SEPARATOR}true{FIELD_SEPARATOR}false{FIELD_SEPARATOR}false{FIELD_SEPARATOR}" - f"false{FIELD_SEPARATOR}false{FIELD_SEPARATOR}false{FIELD_SEPARATOR}" - f"to@ex.com,{FIELD_SEPARATOR}cc@ex.com,{FIELD_SEPARATOR}reply@ex.com{FIELD_SEPARATOR}" - f"This is the message body.{FIELD_SEPARATOR}2" - )) + mock_run = Mock( + return_value=( + f"123{FIELD_SEPARATOR}msg-id-123{FIELD_SEPARATOR}Test Subject{FIELD_SEPARATOR}sender@ex.com{FIELD_SEPARATOR}" + f"Mon Feb 14 2026{FIELD_SEPARATOR}true{FIELD_SEPARATOR}false{FIELD_SEPARATOR}false{FIELD_SEPARATOR}" + f"false{FIELD_SEPARATOR}false{FIELD_SEPARATOR}false{FIELD_SEPARATOR}" + f"to@ex.com,{FIELD_SEPARATOR}cc@ex.com,{FIELD_SEPARATOR}reply@ex.com{FIELD_SEPARATOR}" + f"This is the message body.{FIELD_SEPARATOR}2" + ) + ) monkeypatch.setattr("mxctl.commands.mail.messages.run", mock_run) args = mock_args(id=123) @@ -216,13 +226,15 @@ def test_cmd_read_json(monkeypatch, mock_args, capsys): """Smoke test: cmd_read --json returns JSON object.""" from mxctl.commands.mail.messages import cmd_read - mock_run = Mock(return_value=( - f"123{FIELD_SEPARATOR}msg-id{FIELD_SEPARATOR}Test{FIELD_SEPARATOR}sender@ex.com{FIELD_SEPARATOR}" - f"Mon{FIELD_SEPARATOR}true{FIELD_SEPARATOR}false{FIELD_SEPARATOR}false{FIELD_SEPARATOR}" - f"false{FIELD_SEPARATOR}false{FIELD_SEPARATOR}false{FIELD_SEPARATOR}" - f"to@ex.com,{FIELD_SEPARATOR}{FIELD_SEPARATOR}{FIELD_SEPARATOR}" - f"Body text{FIELD_SEPARATOR}0" - )) + mock_run = Mock( + return_value=( + f"123{FIELD_SEPARATOR}msg-id{FIELD_SEPARATOR}Test{FIELD_SEPARATOR}sender@ex.com{FIELD_SEPARATOR}" + f"Mon{FIELD_SEPARATOR}true{FIELD_SEPARATOR}false{FIELD_SEPARATOR}false{FIELD_SEPARATOR}" + f"false{FIELD_SEPARATOR}false{FIELD_SEPARATOR}false{FIELD_SEPARATOR}" + f"to@ex.com,{FIELD_SEPARATOR}{FIELD_SEPARATOR}{FIELD_SEPARATOR}" + f"Body text{FIELD_SEPARATOR}0" + ) + ) monkeypatch.setattr("mxctl.commands.mail.messages.run", mock_run) args = mock_args(id=123, json=True) @@ -238,14 +250,17 @@ def test_cmd_read_json(monkeypatch, mock_args, capsys): # cmd_search (messages.py) # --------------------------------------------------------------------------- + def test_cmd_search_basic(monkeypatch, mock_args, capsys): """Smoke test: cmd_search finds messages.""" from mxctl.commands.mail.messages import cmd_search - mock_run = Mock(return_value=( - f"123{FIELD_SEPARATOR}Test Subject{FIELD_SEPARATOR}sender@ex.com{FIELD_SEPARATOR}" - f"Mon Feb 14{FIELD_SEPARATOR}true{FIELD_SEPARATOR}false{FIELD_SEPARATOR}INBOX{FIELD_SEPARATOR}iCloud\n" - )) + mock_run = Mock( + return_value=( + f"123{FIELD_SEPARATOR}Test Subject{FIELD_SEPARATOR}sender@ex.com{FIELD_SEPARATOR}" + f"Mon Feb 14{FIELD_SEPARATOR}true{FIELD_SEPARATOR}false{FIELD_SEPARATOR}INBOX{FIELD_SEPARATOR}iCloud\n" + ) + ) monkeypatch.setattr("mxctl.commands.mail.messages.run", mock_run) args = mock_args(query="test") @@ -261,10 +276,12 @@ def test_cmd_search_json(monkeypatch, mock_args, capsys): """Smoke test: cmd_search --json returns JSON array.""" from mxctl.commands.mail.messages import cmd_search - mock_run = Mock(return_value=( - f"123{FIELD_SEPARATOR}Test{FIELD_SEPARATOR}sender@ex.com{FIELD_SEPARATOR}" - f"Mon{FIELD_SEPARATOR}true{FIELD_SEPARATOR}false{FIELD_SEPARATOR}INBOX{FIELD_SEPARATOR}iCloud\n" - )) + mock_run = Mock( + return_value=( + f"123{FIELD_SEPARATOR}Test{FIELD_SEPARATOR}sender@ex.com{FIELD_SEPARATOR}" + f"Mon{FIELD_SEPARATOR}true{FIELD_SEPARATOR}false{FIELD_SEPARATOR}INBOX{FIELD_SEPARATOR}iCloud\n" + ) + ) monkeypatch.setattr("mxctl.commands.mail.messages.run", mock_run) args = mock_args(query="test", json=True) @@ -279,6 +296,7 @@ def test_cmd_search_json(monkeypatch, mock_args, capsys): # cmd_mark_read (actions.py) # --------------------------------------------------------------------------- + def test_cmd_mark_read_basic(monkeypatch, mock_args, capsys): """Smoke test: cmd_mark_read marks message as read.""" from mxctl.commands.mail.actions import cmd_mark_read @@ -313,6 +331,7 @@ def test_cmd_mark_read_json(monkeypatch, mock_args, capsys): # cmd_flag (actions.py) # --------------------------------------------------------------------------- + def test_cmd_flag_basic(monkeypatch, mock_args, capsys): """Smoke test: cmd_flag flags a message.""" from mxctl.commands.mail.actions import cmd_flag @@ -347,6 +366,7 @@ def test_cmd_flag_json(monkeypatch, mock_args, capsys): # cmd_delete (actions.py) # --------------------------------------------------------------------------- + def test_cmd_delete_basic(monkeypatch, mock_args, capsys): """Smoke test: cmd_delete moves message to Trash.""" from mxctl.commands.mail.actions import cmd_delete @@ -381,14 +401,17 @@ def test_cmd_delete_json(monkeypatch, mock_args, capsys): # cmd_summary (ai.py) # --------------------------------------------------------------------------- + def test_cmd_summary_basic(monkeypatch, mock_args, capsys): """Smoke test: cmd_summary lists unread messages concisely.""" from mxctl.commands.mail.ai import cmd_summary - mock_run = Mock(return_value=( - f"iCloud{FIELD_SEPARATOR}123{FIELD_SEPARATOR}Test Subject{FIELD_SEPARATOR}sender@ex.com{FIELD_SEPARATOR}Mon Feb 14 2026\n" - f"iCloud{FIELD_SEPARATOR}124{FIELD_SEPARATOR}Another{FIELD_SEPARATOR}other@ex.com{FIELD_SEPARATOR}Tue Feb 15 2026\n" - )) + mock_run = Mock( + return_value=( + f"iCloud{FIELD_SEPARATOR}123{FIELD_SEPARATOR}Test Subject{FIELD_SEPARATOR}sender@ex.com{FIELD_SEPARATOR}Mon Feb 14 2026\n" + f"iCloud{FIELD_SEPARATOR}124{FIELD_SEPARATOR}Another{FIELD_SEPARATOR}other@ex.com{FIELD_SEPARATOR}Tue Feb 15 2026\n" + ) + ) monkeypatch.setattr("mxctl.commands.mail.ai.run", mock_run) args = mock_args() @@ -433,15 +456,18 @@ def test_cmd_summary_empty(monkeypatch, mock_args, capsys): # cmd_triage (ai.py) # --------------------------------------------------------------------------- + def test_cmd_triage_basic(monkeypatch, mock_args, capsys): """Smoke test: cmd_triage groups unread by category.""" from mxctl.commands.mail.ai import cmd_triage - mock_run = Mock(return_value=( - f"iCloud{FIELD_SEPARATOR}123{FIELD_SEPARATOR}Flagged Message{FIELD_SEPARATOR}person@ex.com{FIELD_SEPARATOR}Mon{FIELD_SEPARATOR}true\n" - f"iCloud{FIELD_SEPARATOR}124{FIELD_SEPARATOR}Personal{FIELD_SEPARATOR}friend@ex.com{FIELD_SEPARATOR}Tue{FIELD_SEPARATOR}false\n" - f"iCloud{FIELD_SEPARATOR}125{FIELD_SEPARATOR}Notification{FIELD_SEPARATOR}noreply@ex.com{FIELD_SEPARATOR}Wed{FIELD_SEPARATOR}false\n" - )) + mock_run = Mock( + return_value=( + f"iCloud{FIELD_SEPARATOR}123{FIELD_SEPARATOR}Flagged Message{FIELD_SEPARATOR}person@ex.com{FIELD_SEPARATOR}Mon{FIELD_SEPARATOR}true\n" + f"iCloud{FIELD_SEPARATOR}124{FIELD_SEPARATOR}Personal{FIELD_SEPARATOR}friend@ex.com{FIELD_SEPARATOR}Tue{FIELD_SEPARATOR}false\n" + f"iCloud{FIELD_SEPARATOR}125{FIELD_SEPARATOR}Notification{FIELD_SEPARATOR}noreply@ex.com{FIELD_SEPARATOR}Wed{FIELD_SEPARATOR}false\n" + ) + ) monkeypatch.setattr("mxctl.commands.mail.ai.run", mock_run) args = mock_args() @@ -458,7 +484,9 @@ def test_cmd_triage_json(monkeypatch, mock_args, capsys): """Smoke test: cmd_triage --json returns categorized JSON.""" from mxctl.commands.mail.ai import cmd_triage - mock_run = Mock(return_value=f"iCloud{FIELD_SEPARATOR}123{FIELD_SEPARATOR}Test{FIELD_SEPARATOR}sender@ex.com{FIELD_SEPARATOR}Mon{FIELD_SEPARATOR}false\n") + mock_run = Mock( + return_value=f"iCloud{FIELD_SEPARATOR}123{FIELD_SEPARATOR}Test{FIELD_SEPARATOR}sender@ex.com{FIELD_SEPARATOR}Mon{FIELD_SEPARATOR}false\n" + ) monkeypatch.setattr("mxctl.commands.mail.ai.run", mock_run) args = mock_args(json=True) @@ -474,7 +502,9 @@ def test_cmd_triage_account_filter(monkeypatch, mock_args, capsys): """Smoke test: cmd_triage -a scopes to a single account.""" from mxctl.commands.mail.ai import cmd_triage - mock_run = Mock(return_value=f"iCloud{FIELD_SEPARATOR}123{FIELD_SEPARATOR}Test{FIELD_SEPARATOR}friend@ex.com{FIELD_SEPARATOR}Mon{FIELD_SEPARATOR}false\n") + mock_run = Mock( + return_value=f"iCloud{FIELD_SEPARATOR}123{FIELD_SEPARATOR}Test{FIELD_SEPARATOR}friend@ex.com{FIELD_SEPARATOR}Mon{FIELD_SEPARATOR}false\n" + ) monkeypatch.setattr("mxctl.commands.mail.ai.run", mock_run) args = mock_args(account="iCloud") @@ -492,14 +522,17 @@ def test_cmd_triage_account_filter(monkeypatch, mock_args, capsys): # cmd_show_flagged (analytics.py) # --------------------------------------------------------------------------- + def test_cmd_show_flagged_basic(monkeypatch, mock_args, capsys): """Smoke test: cmd_show_flagged lists flagged messages.""" from mxctl.commands.mail.analytics import cmd_show_flagged - mock_run = Mock(return_value=( - f"123{FIELD_SEPARATOR}Flagged Subject{FIELD_SEPARATOR}sender@ex.com{FIELD_SEPARATOR}" - f"Mon Feb 14{FIELD_SEPARATOR}INBOX{FIELD_SEPARATOR}iCloud\n" - )) + mock_run = Mock( + return_value=( + f"123{FIELD_SEPARATOR}Flagged Subject{FIELD_SEPARATOR}sender@ex.com{FIELD_SEPARATOR}" + f"Mon Feb 14{FIELD_SEPARATOR}INBOX{FIELD_SEPARATOR}iCloud\n" + ) + ) monkeypatch.setattr("mxctl.commands.mail.analytics.run", mock_run) args = mock_args() @@ -515,10 +548,11 @@ def test_cmd_show_flagged_json(monkeypatch, mock_args, capsys): """Smoke test: cmd_show_flagged --json returns JSON array.""" from mxctl.commands.mail.analytics import cmd_show_flagged - mock_run = Mock(return_value=( - f"123{FIELD_SEPARATOR}Test{FIELD_SEPARATOR}sender@ex.com{FIELD_SEPARATOR}" - f"Mon{FIELD_SEPARATOR}INBOX{FIELD_SEPARATOR}iCloud\n" - )) + mock_run = Mock( + return_value=( + f"123{FIELD_SEPARATOR}Test{FIELD_SEPARATOR}sender@ex.com{FIELD_SEPARATOR}Mon{FIELD_SEPARATOR}INBOX{FIELD_SEPARATOR}iCloud\n" + ) + ) monkeypatch.setattr("mxctl.commands.mail.analytics.run", mock_run) args = mock_args(json=True) @@ -533,6 +567,7 @@ def test_cmd_show_flagged_json(monkeypatch, mock_args, capsys): # cmd_open (actions.py) # --------------------------------------------------------------------------- + def test_cmd_open_basic(monkeypatch, mock_args, capsys): """Smoke test: cmd_open opens message in Mail.app.""" from mxctl.commands.mail.actions import cmd_open @@ -583,15 +618,18 @@ def test_cmd_open_viewer_guard(monkeypatch, mock_args, capsys): # cmd_reply (composite.py) # --------------------------------------------------------------------------- + def test_cmd_reply_basic(monkeypatch, mock_args, capsys): """Smoke test: cmd_reply creates a reply draft.""" from mxctl.commands.mail.composite import cmd_reply # run() is called twice: once to read the original, once to create the draft - mock_run = Mock(side_effect=[ - f"Original Subject{chr(0x1F)}Sender Name {chr(0x1F)}Mon Feb 14 2026{chr(0x1F)}Original body text", - "draft created", - ]) + mock_run = Mock( + side_effect=[ + f"Original Subject{chr(0x1F)}Sender Name {chr(0x1F)}Mon Feb 14 2026{chr(0x1F)}Original body text", + "draft created", + ] + ) monkeypatch.setattr("mxctl.commands.mail.composite.run", mock_run) args = mock_args(id=123, body="Thanks for your message.", json=False) @@ -607,10 +645,12 @@ def test_cmd_reply_json(monkeypatch, mock_args, capsys): """Smoke test: cmd_reply --json returns JSON.""" from mxctl.commands.mail.composite import cmd_reply - mock_run = Mock(side_effect=[ - f"Original Subject{chr(0x1F)}sender@example.com{chr(0x1F)}Mon Feb 14 2026{chr(0x1F)}Body", - "draft created", - ]) + mock_run = Mock( + side_effect=[ + f"Original Subject{chr(0x1F)}sender@example.com{chr(0x1F)}Mon Feb 14 2026{chr(0x1F)}Body", + "draft created", + ] + ) monkeypatch.setattr("mxctl.commands.mail.composite.run", mock_run) args = mock_args(id=123, body="Reply text.", json=True) @@ -626,14 +666,17 @@ def test_cmd_reply_json(monkeypatch, mock_args, capsys): # cmd_forward (composite.py) # --------------------------------------------------------------------------- + def test_cmd_forward_basic(monkeypatch, mock_args, capsys): """Smoke test: cmd_forward creates a forward draft.""" from mxctl.commands.mail.composite import cmd_forward - mock_run = Mock(side_effect=[ - f"Original Subject{chr(0x1F)}sender@example.com{chr(0x1F)}Mon Feb 14 2026{chr(0x1F)}Original body", - "draft created", - ]) + mock_run = Mock( + side_effect=[ + f"Original Subject{chr(0x1F)}sender@example.com{chr(0x1F)}Mon Feb 14 2026{chr(0x1F)}Original body", + "draft created", + ] + ) monkeypatch.setattr("mxctl.commands.mail.composite.run", mock_run) args = mock_args(id=123, to="forward@example.com", json=False) @@ -649,10 +692,12 @@ def test_cmd_forward_json(monkeypatch, mock_args, capsys): """Smoke test: cmd_forward --json returns JSON.""" from mxctl.commands.mail.composite import cmd_forward - mock_run = Mock(side_effect=[ - f"Original Subject{chr(0x1F)}sender@example.com{chr(0x1F)}Mon Feb 14 2026{chr(0x1F)}Body", - "draft created", - ]) + mock_run = Mock( + side_effect=[ + f"Original Subject{chr(0x1F)}sender@example.com{chr(0x1F)}Mon Feb 14 2026{chr(0x1F)}Body", + "draft created", + ] + ) monkeypatch.setattr("mxctl.commands.mail.composite.run", mock_run) args = mock_args(id=123, to="forward@example.com", json=True) @@ -668,13 +713,16 @@ def test_cmd_forward_json(monkeypatch, mock_args, capsys): # cmd_export (composite.py) # --------------------------------------------------------------------------- + def test_cmd_export_basic(monkeypatch, mock_args, tmp_path, capsys): """Smoke test: cmd_export writes a markdown file.""" from mxctl.commands.mail.composite import cmd_export - mock_run = Mock(return_value=( - f"Test Subject{chr(0x1F)}sender@example.com{chr(0x1F)}Mon Feb 14 2026{chr(0x1F)}to@example.com, {chr(0x1F)}This is the body." - )) + mock_run = Mock( + return_value=( + f"Test Subject{chr(0x1F)}sender@example.com{chr(0x1F)}Mon Feb 14 2026{chr(0x1F)}to@example.com, {chr(0x1F)}This is the body." + ) + ) monkeypatch.setattr("mxctl.commands.mail.composite.run", mock_run) dest = str(tmp_path) @@ -695,9 +743,11 @@ def test_cmd_export_json(monkeypatch, mock_args, tmp_path, capsys): """Smoke test: cmd_export --json returns JSON.""" from mxctl.commands.mail.composite import cmd_export - mock_run = Mock(return_value=( - f"Test Subject{chr(0x1F)}sender@example.com{chr(0x1F)}Mon Feb 14 2026{chr(0x1F)}to@example.com, {chr(0x1F)}Body text." - )) + mock_run = Mock( + return_value=( + f"Test Subject{chr(0x1F)}sender@example.com{chr(0x1F)}Mon Feb 14 2026{chr(0x1F)}to@example.com, {chr(0x1F)}Body text." + ) + ) monkeypatch.setattr("mxctl.commands.mail.composite.run", mock_run) dest = str(tmp_path) @@ -713,18 +763,21 @@ def test_cmd_export_json(monkeypatch, mock_args, tmp_path, capsys): # cmd_thread (composite.py) # --------------------------------------------------------------------------- + def test_cmd_thread_basic(monkeypatch, mock_args, capsys): """Smoke test: cmd_thread shows conversation thread.""" from mxctl.commands.mail.composite import cmd_thread # run() called twice: first for subject, then for thread messages - mock_run = Mock(side_effect=[ - "Original Subject", - ( - f"100{chr(0x1F)}Re: Original Subject{chr(0x1F)}person@example.com{chr(0x1F)}Mon Feb 14 2026{chr(0x1F)}INBOX{chr(0x1F)}iCloud\n" - f"101{chr(0x1F)}Re: Original Subject{chr(0x1F)}other@example.com{chr(0x1F)}Tue Feb 15 2026{chr(0x1F)}INBOX{chr(0x1F)}iCloud\n" - ), - ]) + mock_run = Mock( + side_effect=[ + "Original Subject", + ( + f"100{chr(0x1F)}Re: Original Subject{chr(0x1F)}person@example.com{chr(0x1F)}Mon Feb 14 2026{chr(0x1F)}INBOX{chr(0x1F)}iCloud\n" + f"101{chr(0x1F)}Re: Original Subject{chr(0x1F)}other@example.com{chr(0x1F)}Tue Feb 15 2026{chr(0x1F)}INBOX{chr(0x1F)}iCloud\n" + ), + ] + ) monkeypatch.setattr("mxctl.commands.mail.composite.run", mock_run) args = mock_args(id=123, json=False, limit=100, all_accounts=False) @@ -741,10 +794,12 @@ def test_cmd_thread_json(monkeypatch, mock_args, capsys): """Smoke test: cmd_thread --json returns JSON array.""" from mxctl.commands.mail.composite import cmd_thread - mock_run = Mock(side_effect=[ - "Original Subject", - f"100{chr(0x1F)}Re: Original Subject{chr(0x1F)}person@example.com{chr(0x1F)}Mon Feb 14 2026{chr(0x1F)}INBOX{chr(0x1F)}iCloud\n", - ]) + mock_run = Mock( + side_effect=[ + "Original Subject", + f"100{chr(0x1F)}Re: Original Subject{chr(0x1F)}person@example.com{chr(0x1F)}Mon Feb 14 2026{chr(0x1F)}INBOX{chr(0x1F)}iCloud\n", + ] + ) monkeypatch.setattr("mxctl.commands.mail.composite.run", mock_run) args = mock_args(id=123, json=True, limit=100, all_accounts=False) @@ -760,17 +815,12 @@ def test_cmd_thread_json(monkeypatch, mock_args, capsys): # cmd_top_senders (analytics.py) # --------------------------------------------------------------------------- + def test_cmd_top_senders_basic(monkeypatch, mock_args, capsys): """Smoke test: cmd_top_senders shows most frequent senders.""" from mxctl.commands.mail.analytics import cmd_top_senders - mock_run = Mock(return_value=( - "alice@example.com\n" - "bob@example.com\n" - "alice@example.com\n" - "alice@example.com\n" - "bob@example.com\n" - )) + mock_run = Mock(return_value=("alice@example.com\nbob@example.com\nalice@example.com\nalice@example.com\nbob@example.com\n")) monkeypatch.setattr("mxctl.commands.mail.analytics.run", mock_run) args = mock_args(days=30, limit=10, json=False) @@ -786,11 +836,7 @@ def test_cmd_top_senders_json(monkeypatch, mock_args, capsys): """Smoke test: cmd_top_senders --json returns JSON array.""" from mxctl.commands.mail.analytics import cmd_top_senders - mock_run = Mock(return_value=( - "alice@example.com\n" - "alice@example.com\n" - "bob@example.com\n" - )) + mock_run = Mock(return_value=("alice@example.com\nalice@example.com\nbob@example.com\n")) monkeypatch.setattr("mxctl.commands.mail.analytics.run", mock_run) args = mock_args(days=30, limit=10, json=True) @@ -806,14 +852,17 @@ def test_cmd_top_senders_json(monkeypatch, mock_args, capsys): # cmd_digest (analytics.py) # --------------------------------------------------------------------------- + def test_cmd_digest_basic(monkeypatch, mock_args, capsys): """Smoke test: cmd_digest shows unread grouped by sender domain.""" from mxctl.commands.mail.analytics import cmd_digest - mock_run = Mock(return_value=( - f"iCloud{chr(0x1F)}123{chr(0x1F)}Newsletter Update{chr(0x1F)}news@example.com{chr(0x1F)}Mon Feb 14 2026\n" - f"iCloud{chr(0x1F)}124{chr(0x1F)}Hello there{chr(0x1F)}friend@personal.org{chr(0x1F)}Tue Feb 15 2026\n" - )) + mock_run = Mock( + return_value=( + f"iCloud{chr(0x1F)}123{chr(0x1F)}Newsletter Update{chr(0x1F)}news@example.com{chr(0x1F)}Mon Feb 14 2026\n" + f"iCloud{chr(0x1F)}124{chr(0x1F)}Hello there{chr(0x1F)}friend@personal.org{chr(0x1F)}Tue Feb 15 2026\n" + ) + ) monkeypatch.setattr("mxctl.commands.mail.analytics.run", mock_run) args = mock_args(json=False) @@ -829,9 +878,7 @@ def test_cmd_digest_json(monkeypatch, mock_args, capsys): """Smoke test: cmd_digest --json returns JSON dict.""" from mxctl.commands.mail.analytics import cmd_digest - mock_run = Mock(return_value=( - f"iCloud{chr(0x1F)}123{chr(0x1F)}Test Subject{chr(0x1F)}sender@example.com{chr(0x1F)}Mon Feb 14 2026\n" - )) + mock_run = Mock(return_value=(f"iCloud{chr(0x1F)}123{chr(0x1F)}Test Subject{chr(0x1F)}sender@example.com{chr(0x1F)}Mon Feb 14 2026\n")) monkeypatch.setattr("mxctl.commands.mail.analytics.run", mock_run) args = mock_args(json=True) @@ -846,6 +893,7 @@ def test_cmd_digest_json(monkeypatch, mock_args, capsys): # cmd_headers (system.py) # --------------------------------------------------------------------------- + def test_cmd_headers_basic(monkeypatch, mock_args, capsys): """Smoke test: cmd_headers shows email header summary.""" from mxctl.commands.mail.system import cmd_headers @@ -901,14 +949,12 @@ def test_cmd_headers_json(monkeypatch, mock_args, capsys): # cmd_rules (system.py) # --------------------------------------------------------------------------- + def test_cmd_rules_basic(monkeypatch, mock_args, capsys): """Smoke test: cmd_rules lists mail rules.""" from mxctl.commands.mail.system import cmd_rules - mock_run = Mock(return_value=( - f"Move Newsletters{chr(0x1F)}true\n" - f"Archive Old Mail{chr(0x1F)}false\n" - )) + mock_run = Mock(return_value=(f"Move Newsletters{chr(0x1F)}true\nArchive Old Mail{chr(0x1F)}false\n")) monkeypatch.setattr("mxctl.commands.mail.system.run", mock_run) args = mock_args(json=False, action=None, rule_name=None) @@ -924,10 +970,7 @@ def test_cmd_rules_json(monkeypatch, mock_args, capsys): """Smoke test: cmd_rules --json returns JSON array.""" from mxctl.commands.mail.system import cmd_rules - mock_run = Mock(return_value=( - f"Move Newsletters{chr(0x1F)}true\n" - f"Archive Old Mail{chr(0x1F)}false\n" - )) + mock_run = Mock(return_value=(f"Move Newsletters{chr(0x1F)}true\nArchive Old Mail{chr(0x1F)}false\n")) monkeypatch.setattr("mxctl.commands.mail.system.run", mock_run) args = mock_args(json=True, action=None, rule_name=None) @@ -943,15 +986,12 @@ def test_cmd_rules_json(monkeypatch, mock_args, capsys): # cmd_attachments (attachments.py) # --------------------------------------------------------------------------- + def test_cmd_attachments_basic(monkeypatch, mock_args, capsys): """Smoke test: cmd_attachments lists message attachments.""" from mxctl.commands.mail.attachments import cmd_attachments - mock_run = Mock(return_value=( - "Test Subject\n" - "report.pdf\n" - "invoice.xlsx\n" - )) + mock_run = Mock(return_value=("Test Subject\nreport.pdf\ninvoice.xlsx\n")) monkeypatch.setattr("mxctl.commands.mail.attachments.run", mock_run) args = mock_args(id=123, json=False) @@ -967,10 +1007,7 @@ def test_cmd_attachments_json(monkeypatch, mock_args, capsys): """Smoke test: cmd_attachments --json returns JSON.""" from mxctl.commands.mail.attachments import cmd_attachments - mock_run = Mock(return_value=( - "Test Subject\n" - "document.pdf\n" - )) + mock_run = Mock(return_value=("Test Subject\ndocument.pdf\n")) monkeypatch.setattr("mxctl.commands.mail.attachments.run", mock_run) args = mock_args(id=123, json=True) @@ -986,15 +1023,18 @@ def test_cmd_attachments_json(monkeypatch, mock_args, capsys): # cmd_context (ai.py) # --------------------------------------------------------------------------- + def test_cmd_context_basic(monkeypatch, mock_args, capsys): """Smoke test: cmd_context shows message with thread history.""" from mxctl.commands.mail.ai import cmd_context # run() called twice: once for main message, once for thread - mock_run = Mock(side_effect=[ - f"Context Subject{chr(0x1F)}sender@example.com{chr(0x1F)}Mon Feb 14 2026{chr(0x1F)}to@example.com, {chr(0x1F)}Main message body.", - "", # empty thread - ]) + mock_run = Mock( + side_effect=[ + f"Context Subject{chr(0x1F)}sender@example.com{chr(0x1F)}Mon Feb 14 2026{chr(0x1F)}to@example.com, {chr(0x1F)}Main message body.", + "", # empty thread + ] + ) monkeypatch.setattr("mxctl.commands.mail.ai.run", mock_run) args = mock_args(id=123, json=False, limit=50, all_accounts=False) @@ -1011,14 +1051,14 @@ def test_cmd_context_json(monkeypatch, mock_args, capsys): """Smoke test: cmd_context --json returns JSON with message and thread.""" from mxctl.commands.mail.ai import cmd_context from mxctl.config import RECORD_SEPARATOR - thread_entry = ( - f"200{chr(0x1F)}Re: Context Subject{chr(0x1F)}other@example.com" - f"{chr(0x1F)}Tue Feb 15 2026{chr(0x1F)}Previous reply body." + + thread_entry = f"200{chr(0x1F)}Re: Context Subject{chr(0x1F)}other@example.com{chr(0x1F)}Tue Feb 15 2026{chr(0x1F)}Previous reply body." + mock_run = Mock( + side_effect=[ + f"Context Subject{chr(0x1F)}sender@example.com{chr(0x1F)}Mon Feb 14 2026{chr(0x1F)}to@example.com, {chr(0x1F)}Main body.", + thread_entry + RECORD_SEPARATOR, + ] ) - mock_run = Mock(side_effect=[ - f"Context Subject{chr(0x1F)}sender@example.com{chr(0x1F)}Mon Feb 14 2026{chr(0x1F)}to@example.com, {chr(0x1F)}Main body.", - thread_entry + RECORD_SEPARATOR, - ]) monkeypatch.setattr("mxctl.commands.mail.ai.run", mock_run) args = mock_args(id=123, json=True, limit=50, all_accounts=False) @@ -1034,6 +1074,7 @@ def test_cmd_context_json(monkeypatch, mock_args, capsys): # cmd_find_related (ai.py) # --------------------------------------------------------------------------- + def test_cmd_find_related_basic(monkeypatch, mock_args, capsys): """Smoke test: cmd_find_related searches and groups by conversation.""" from unittest.mock import Mock @@ -1061,9 +1102,7 @@ def test_cmd_find_related_json(monkeypatch, mock_args, capsys): from mxctl.commands.mail.ai import cmd_find_related - search_result = ( - f"1{FIELD_SEPARATOR}Meeting Notes{FIELD_SEPARATOR}alice@test.com{FIELD_SEPARATOR}Mon Feb 10 2026{FIELD_SEPARATOR}INBOX{FIELD_SEPARATOR}Work" - ) + search_result = f"1{FIELD_SEPARATOR}Meeting Notes{FIELD_SEPARATOR}alice@test.com{FIELD_SEPARATOR}Mon Feb 10 2026{FIELD_SEPARATOR}INBOX{FIELD_SEPARATOR}Work" mock_run = Mock(return_value=search_result) monkeypatch.setattr("mxctl.commands.mail.ai.run", mock_run) @@ -1094,14 +1133,17 @@ def test_cmd_find_related_empty(monkeypatch, mock_args, capsys): # cmd_accounts (accounts.py) # --------------------------------------------------------------------------- + def test_cmd_accounts_basic(monkeypatch, mock_args, capsys): """Smoke test: cmd_accounts lists configured mail accounts.""" from mxctl.commands.mail.accounts import cmd_accounts - mock_run = Mock(return_value=( - f"iCloud{FIELD_SEPARATOR}John Doe{FIELD_SEPARATOR}john@icloud.com{FIELD_SEPARATOR}true\n" - f"Work Gmail{FIELD_SEPARATOR}John Doe{FIELD_SEPARATOR}john@work.com{FIELD_SEPARATOR}false\n" - )) + mock_run = Mock( + return_value=( + f"iCloud{FIELD_SEPARATOR}John Doe{FIELD_SEPARATOR}john@icloud.com{FIELD_SEPARATOR}true\n" + f"Work Gmail{FIELD_SEPARATOR}John Doe{FIELD_SEPARATOR}john@work.com{FIELD_SEPARATOR}false\n" + ) + ) monkeypatch.setattr("mxctl.commands.mail.accounts.run", mock_run) args = mock_args() @@ -1119,9 +1161,7 @@ def test_cmd_accounts_json(monkeypatch, mock_args, capsys): """Smoke test: cmd_accounts --json returns JSON array of accounts.""" from mxctl.commands.mail.accounts import cmd_accounts - mock_run = Mock(return_value=( - f"iCloud{FIELD_SEPARATOR}John Doe{FIELD_SEPARATOR}john@icloud.com{FIELD_SEPARATOR}true\n" - )) + mock_run = Mock(return_value=(f"iCloud{FIELD_SEPARATOR}John Doe{FIELD_SEPARATOR}john@icloud.com{FIELD_SEPARATOR}true\n")) monkeypatch.setattr("mxctl.commands.mail.accounts.run", mock_run) args = mock_args(json=True) @@ -1151,9 +1191,7 @@ def test_cmd_accounts_applescript_content(monkeypatch, mock_args, capsys): """Smoke test: cmd_accounts sends AppleScript that reads account properties.""" from mxctl.commands.mail.accounts import cmd_accounts - mock_run = Mock(return_value=( - f"iCloud{FIELD_SEPARATOR}John Doe{FIELD_SEPARATOR}john@icloud.com{FIELD_SEPARATOR}true\n" - )) + mock_run = Mock(return_value=(f"iCloud{FIELD_SEPARATOR}John Doe{FIELD_SEPARATOR}john@icloud.com{FIELD_SEPARATOR}true\n")) monkeypatch.setattr("mxctl.commands.mail.accounts.run", mock_run) args = mock_args() @@ -1169,15 +1207,18 @@ def test_cmd_accounts_applescript_content(monkeypatch, mock_args, capsys): # cmd_mailboxes (accounts.py) # --------------------------------------------------------------------------- + def test_cmd_mailboxes_basic(monkeypatch, mock_args, capsys): """Smoke test: cmd_mailboxes lists all mailboxes across all accounts.""" from mxctl.commands.mail.accounts import cmd_mailboxes - mock_run = Mock(return_value=( - f"iCloud{FIELD_SEPARATOR}INBOX{FIELD_SEPARATOR}3\n" - f"iCloud{FIELD_SEPARATOR}Sent{FIELD_SEPARATOR}0\n" - f"Work{FIELD_SEPARATOR}INBOX{FIELD_SEPARATOR}1\n" - )) + mock_run = Mock( + return_value=( + f"iCloud{FIELD_SEPARATOR}INBOX{FIELD_SEPARATOR}3\n" + f"iCloud{FIELD_SEPARATOR}Sent{FIELD_SEPARATOR}0\n" + f"Work{FIELD_SEPARATOR}INBOX{FIELD_SEPARATOR}1\n" + ) + ) monkeypatch.setattr("mxctl.commands.mail.accounts.run", mock_run) # Patch resolve_account to return None so the all-accounts code path is taken monkeypatch.setattr("mxctl.commands.mail.accounts.resolve_account", lambda x: None) @@ -1196,10 +1237,7 @@ def test_cmd_mailboxes_json(monkeypatch, mock_args, capsys): """Smoke test: cmd_mailboxes --json returns JSON array of mailboxes.""" from mxctl.commands.mail.accounts import cmd_mailboxes - mock_run = Mock(return_value=( - f"iCloud{FIELD_SEPARATOR}INBOX{FIELD_SEPARATOR}5\n" - f"iCloud{FIELD_SEPARATOR}Sent{FIELD_SEPARATOR}0\n" - )) + mock_run = Mock(return_value=(f"iCloud{FIELD_SEPARATOR}INBOX{FIELD_SEPARATOR}5\niCloud{FIELD_SEPARATOR}Sent{FIELD_SEPARATOR}0\n")) monkeypatch.setattr("mxctl.commands.mail.accounts.run", mock_run) # Patch resolve_account to return None so the all-accounts code path is taken monkeypatch.setattr("mxctl.commands.mail.accounts.resolve_account", lambda x: None) @@ -1217,11 +1255,7 @@ def test_cmd_mailboxes_account_filter(monkeypatch, mock_args, capsys): """Smoke test: cmd_mailboxes -a scopes to a single account.""" from mxctl.commands.mail.accounts import cmd_mailboxes - mock_run = Mock(return_value=( - f"INBOX{FIELD_SEPARATOR}2\n" - f"Sent Messages{FIELD_SEPARATOR}0\n" - f"Junk{FIELD_SEPARATOR}0\n" - )) + mock_run = Mock(return_value=(f"INBOX{FIELD_SEPARATOR}2\nSent Messages{FIELD_SEPARATOR}0\nJunk{FIELD_SEPARATOR}0\n")) monkeypatch.setattr("mxctl.commands.mail.accounts.run", mock_run) args = mock_args(account="iCloud") @@ -1241,6 +1275,7 @@ def test_cmd_mailboxes_account_filter(monkeypatch, mock_args, capsys): # cmd_mark_unread (actions.py) # --------------------------------------------------------------------------- + def test_cmd_mark_unread_basic(monkeypatch, mock_args, capsys): """Smoke test: cmd_mark_unread marks a message as unread.""" from mxctl.commands.mail.actions import cmd_mark_unread @@ -1291,6 +1326,7 @@ def test_cmd_mark_unread_applescript_sets_read_false(monkeypatch, mock_args, cap # cmd_unflag (actions.py) # --------------------------------------------------------------------------- + def test_cmd_unflag_basic(monkeypatch, mock_args, capsys): """Smoke test: cmd_unflag removes flag from a message.""" from mxctl.commands.mail.actions import cmd_unflag @@ -1341,6 +1377,7 @@ def test_cmd_unflag_applescript_sets_flagged_false(monkeypatch, mock_args, capsy # cmd_move (actions.py) # --------------------------------------------------------------------------- + def test_cmd_move_basic(monkeypatch, mock_args, capsys): """Smoke test: cmd_move moves a message between mailboxes.""" from mxctl.commands.mail.actions import cmd_move @@ -1395,6 +1432,7 @@ def test_cmd_move_applescript_uses_mailboxes(monkeypatch, mock_args, capsys): # cmd_junk (actions.py) # --------------------------------------------------------------------------- + def test_cmd_junk_basic(monkeypatch, mock_args, capsys): """Smoke test: cmd_junk marks a message as junk.""" from mxctl.commands.mail.actions import cmd_junk @@ -1445,6 +1483,7 @@ def test_cmd_junk_applescript_sets_junk_true(monkeypatch, mock_args, capsys): # cmd_not_junk (actions.py) # --------------------------------------------------------------------------- + def test_cmd_not_junk_basic(monkeypatch, mock_args, capsys): """Smoke test: cmd_not_junk marks a message as not junk and moves to INBOX.""" from mxctl.commands.mail.actions import cmd_not_junk @@ -1565,6 +1604,7 @@ def mock_run_fail(script): # cmd_check (system.py) # --------------------------------------------------------------------------- + def test_cmd_check_basic(monkeypatch, mock_args, capsys): """Smoke test: cmd_check triggers a mail fetch and reports success.""" from mxctl.commands.mail.system import cmd_check @@ -1616,10 +1656,11 @@ def test_cmd_list_unread_filter(monkeypatch, mock_args, capsys): """cmd_list --unread adds 'read status is false' filter clause (line 32).""" from mxctl.commands.mail.messages import cmd_list - mock_run = Mock(return_value=( - f"10{FIELD_SEPARATOR}Unread Msg{FIELD_SEPARATOR}s@x.com{FIELD_SEPARATOR}" - f"Mon{FIELD_SEPARATOR}false{FIELD_SEPARATOR}false\n" - )) + mock_run = Mock( + return_value=( + f"10{FIELD_SEPARATOR}Unread Msg{FIELD_SEPARATOR}s@x.com{FIELD_SEPARATOR}Mon{FIELD_SEPARATOR}false{FIELD_SEPARATOR}false\n" + ) + ) monkeypatch.setattr("mxctl.commands.mail.messages.run", mock_run) args = mock_args(unread=True) @@ -1636,10 +1677,9 @@ def test_cmd_list_after_filter(monkeypatch, mock_args, capsys): """cmd_list --after adds date received >= filter clause (lines 34-35).""" from mxctl.commands.mail.messages import cmd_list - mock_run = Mock(return_value=( - f"11{FIELD_SEPARATOR}Recent{FIELD_SEPARATOR}s@x.com{FIELD_SEPARATOR}" - f"Mon{FIELD_SEPARATOR}true{FIELD_SEPARATOR}false\n" - )) + mock_run = Mock( + return_value=(f"11{FIELD_SEPARATOR}Recent{FIELD_SEPARATOR}s@x.com{FIELD_SEPARATOR}Mon{FIELD_SEPARATOR}true{FIELD_SEPARATOR}false\n") + ) monkeypatch.setattr("mxctl.commands.mail.messages.run", mock_run) args = mock_args(after="2026-01-01", before=None) @@ -1653,10 +1693,9 @@ def test_cmd_list_before_filter(monkeypatch, mock_args, capsys): """cmd_list --before adds date received < filter clause (lines 37-38).""" from mxctl.commands.mail.messages import cmd_list - mock_run = Mock(return_value=( - f"12{FIELD_SEPARATOR}Old{FIELD_SEPARATOR}s@x.com{FIELD_SEPARATOR}" - f"Mon{FIELD_SEPARATOR}true{FIELD_SEPARATOR}false\n" - )) + mock_run = Mock( + return_value=(f"12{FIELD_SEPARATOR}Old{FIELD_SEPARATOR}s@x.com{FIELD_SEPARATOR}Mon{FIELD_SEPARATOR}true{FIELD_SEPARATOR}false\n") + ) monkeypatch.setattr("mxctl.commands.mail.messages.run", mock_run) args = mock_args(after=None, before="2026-02-01") @@ -1715,12 +1754,14 @@ def test_cmd_list_skips_blank_lines(monkeypatch, mock_args, capsys): """cmd_list skips blank lines in AppleScript output (line 78).""" from mxctl.commands.mail.messages import cmd_list - mock_run = Mock(return_value=( - f"10{FIELD_SEPARATOR}Good{FIELD_SEPARATOR}s@x.com{FIELD_SEPARATOR}Mon{FIELD_SEPARATOR}true{FIELD_SEPARATOR}false\n" - f"\n" - f" \n" - f"11{FIELD_SEPARATOR}Also Good{FIELD_SEPARATOR}t@x.com{FIELD_SEPARATOR}Tue{FIELD_SEPARATOR}false{FIELD_SEPARATOR}false\n" - )) + mock_run = Mock( + return_value=( + f"10{FIELD_SEPARATOR}Good{FIELD_SEPARATOR}s@x.com{FIELD_SEPARATOR}Mon{FIELD_SEPARATOR}true{FIELD_SEPARATOR}false\n" + f"\n" + f" \n" + f"11{FIELD_SEPARATOR}Also Good{FIELD_SEPARATOR}t@x.com{FIELD_SEPARATOR}Tue{FIELD_SEPARATOR}false{FIELD_SEPARATOR}false\n" + ) + ) monkeypatch.setattr("mxctl.commands.mail.messages.run", mock_run) args = mock_args(unread=False, after=None, before=None) @@ -1732,7 +1773,7 @@ def test_cmd_list_skips_blank_lines(monkeypatch, mock_args, capsys): def test_cmd_read_insufficient_parts_fallback(monkeypatch, mock_args, capsys): - """cmd_read with fewer than 16 parts shows raw result (lines 156-157).""" + """cmd_read with fewer than 16 parts shows 'not found' gracefully (no crash).""" from mxctl.commands.mail.messages import cmd_read mock_run = Mock(return_value="partial data only") @@ -1742,17 +1783,19 @@ def test_cmd_read_insufficient_parts_fallback(monkeypatch, mock_args, capsys): cmd_read(args) captured = capsys.readouterr() - assert "Message details: partial data only" in captured.out + assert "not found" in captured.out def test_cmd_search_account_only_no_mailbox(monkeypatch, mock_args, capsys): """cmd_search with account but no mailbox uses account-scoped multi-mailbox script (lines 243-264).""" from mxctl.commands.mail.messages import cmd_search - mock_run = Mock(return_value=( - f"50{FIELD_SEPARATOR}Found{FIELD_SEPARATOR}a@b.com{FIELD_SEPARATOR}" - f"Mon{FIELD_SEPARATOR}true{FIELD_SEPARATOR}false{FIELD_SEPARATOR}INBOX{FIELD_SEPARATOR}iCloud\n" - )) + mock_run = Mock( + return_value=( + f"50{FIELD_SEPARATOR}Found{FIELD_SEPARATOR}a@b.com{FIELD_SEPARATOR}" + f"Mon{FIELD_SEPARATOR}true{FIELD_SEPARATOR}false{FIELD_SEPARATOR}INBOX{FIELD_SEPARATOR}iCloud\n" + ) + ) monkeypatch.setattr("mxctl.commands.mail.messages.run", mock_run) args = mock_args(query="test", sender=False, mailbox=None, limit=25) @@ -1773,10 +1816,12 @@ def test_cmd_search_no_account_no_mailbox_all_accounts(monkeypatch, capsys): from mxctl.commands.mail.messages import cmd_search - mock_run = Mock(return_value=( - f"60{FIELD_SEPARATOR}Global{FIELD_SEPARATOR}x@y.com{FIELD_SEPARATOR}" - f"Mon{FIELD_SEPARATOR}false{FIELD_SEPARATOR}false{FIELD_SEPARATOR}INBOX{FIELD_SEPARATOR}Gmail\n" - )) + mock_run = Mock( + return_value=( + f"60{FIELD_SEPARATOR}Global{FIELD_SEPARATOR}x@y.com{FIELD_SEPARATOR}" + f"Mon{FIELD_SEPARATOR}false{FIELD_SEPARATOR}false{FIELD_SEPARATOR}INBOX{FIELD_SEPARATOR}Gmail\n" + ) + ) monkeypatch.setattr("mxctl.commands.mail.messages.run", mock_run) monkeypatch.setattr("mxctl.commands.mail.messages.resolve_account", lambda _: None) @@ -1794,10 +1839,12 @@ def test_cmd_search_sender_flag(monkeypatch, mock_args, capsys): """cmd_search --sender searches by sender field instead of subject.""" from mxctl.commands.mail.messages import cmd_search - mock_run = Mock(return_value=( - f"70{FIELD_SEPARATOR}Match{FIELD_SEPARATOR}alice@test.com{FIELD_SEPARATOR}" - f"Mon{FIELD_SEPARATOR}true{FIELD_SEPARATOR}false{FIELD_SEPARATOR}INBOX{FIELD_SEPARATOR}iCloud\n" - )) + mock_run = Mock( + return_value=( + f"70{FIELD_SEPARATOR}Match{FIELD_SEPARATOR}alice@test.com{FIELD_SEPARATOR}" + f"Mon{FIELD_SEPARATOR}true{FIELD_SEPARATOR}false{FIELD_SEPARATOR}INBOX{FIELD_SEPARATOR}iCloud\n" + ) + ) monkeypatch.setattr("mxctl.commands.mail.messages.run", mock_run) args = mock_args(query="alice", sender=True, mailbox="INBOX", limit=25) @@ -1863,14 +1910,16 @@ def test_cmd_search_skips_blank_lines(monkeypatch, mock_args, capsys): from mxctl.commands.mail.messages import cmd_search # Blank lines BETWEEN two valid lines - mock_run = Mock(return_value=( - f"80{FIELD_SEPARATOR}Valid{FIELD_SEPARATOR}v@x.com{FIELD_SEPARATOR}" - f"Mon{FIELD_SEPARATOR}true{FIELD_SEPARATOR}false{FIELD_SEPARATOR}INBOX{FIELD_SEPARATOR}iCloud\n" - f"\n" - f" \n" - f"81{FIELD_SEPARATOR}Also Valid{FIELD_SEPARATOR}w@x.com{FIELD_SEPARATOR}" - f"Tue{FIELD_SEPARATOR}false{FIELD_SEPARATOR}false{FIELD_SEPARATOR}INBOX{FIELD_SEPARATOR}iCloud\n" - )) + mock_run = Mock( + return_value=( + f"80{FIELD_SEPARATOR}Valid{FIELD_SEPARATOR}v@x.com{FIELD_SEPARATOR}" + f"Mon{FIELD_SEPARATOR}true{FIELD_SEPARATOR}false{FIELD_SEPARATOR}INBOX{FIELD_SEPARATOR}iCloud\n" + f"\n" + f" \n" + f"81{FIELD_SEPARATOR}Also Valid{FIELD_SEPARATOR}w@x.com{FIELD_SEPARATOR}" + f"Tue{FIELD_SEPARATOR}false{FIELD_SEPARATOR}false{FIELD_SEPARATOR}INBOX{FIELD_SEPARATOR}iCloud\n" + ) + ) monkeypatch.setattr("mxctl.commands.mail.messages.run", mock_run) args = mock_args(query="valid", sender=False, mailbox="INBOX", limit=25) @@ -1885,10 +1934,12 @@ def test_cmd_search_unread_and_flagged_status(monkeypatch, mock_args, capsys): """cmd_search shows UNREAD and FLAGGED status icons (lines 318, 320).""" from mxctl.commands.mail.messages import cmd_search - mock_run = Mock(return_value=( - f"90{FIELD_SEPARATOR}Unread Flagged{FIELD_SEPARATOR}s@x.com{FIELD_SEPARATOR}" - f"Mon{FIELD_SEPARATOR}false{FIELD_SEPARATOR}true{FIELD_SEPARATOR}INBOX{FIELD_SEPARATOR}iCloud\n" - )) + mock_run = Mock( + return_value=( + f"90{FIELD_SEPARATOR}Unread Flagged{FIELD_SEPARATOR}s@x.com{FIELD_SEPARATOR}" + f"Mon{FIELD_SEPARATOR}false{FIELD_SEPARATOR}true{FIELD_SEPARATOR}INBOX{FIELD_SEPARATOR}iCloud\n" + ) + ) monkeypatch.setattr("mxctl.commands.mail.messages.run", mock_run) args = mock_args(query="test", sender=False, mailbox="INBOX", limit=25) @@ -1904,13 +1955,15 @@ def test_cmd_read_short_flag(monkeypatch, mock_args, capsys): from mxctl.commands.mail.messages import cmd_read long_body = "A" * 1000 - mock_run = Mock(return_value=( - f"123{FIELD_SEPARATOR}msg-id{FIELD_SEPARATOR}Subject{FIELD_SEPARATOR}sender@ex.com{FIELD_SEPARATOR}" - f"Mon{FIELD_SEPARATOR}true{FIELD_SEPARATOR}false{FIELD_SEPARATOR}false{FIELD_SEPARATOR}" - f"false{FIELD_SEPARATOR}false{FIELD_SEPARATOR}false{FIELD_SEPARATOR}" - f"to@ex.com,{FIELD_SEPARATOR}{FIELD_SEPARATOR}{FIELD_SEPARATOR}" - f"{long_body}{FIELD_SEPARATOR}0" - )) + mock_run = Mock( + return_value=( + f"123{FIELD_SEPARATOR}msg-id{FIELD_SEPARATOR}Subject{FIELD_SEPARATOR}sender@ex.com{FIELD_SEPARATOR}" + f"Mon{FIELD_SEPARATOR}true{FIELD_SEPARATOR}false{FIELD_SEPARATOR}false{FIELD_SEPARATOR}" + f"false{FIELD_SEPARATOR}false{FIELD_SEPARATOR}false{FIELD_SEPARATOR}" + f"to@ex.com,{FIELD_SEPARATOR}{FIELD_SEPARATOR}{FIELD_SEPARATOR}" + f"{long_body}{FIELD_SEPARATOR}0" + ) + ) monkeypatch.setattr("mxctl.commands.mail.messages.run", mock_run) args = mock_args(id=123, short=True) diff --git a/tests/test_compose_batch_errors.py b/tests/test_compose_batch_errors.py index 330ce24..dd04034 100644 --- a/tests/test_compose_batch_errors.py +++ b/tests/test_compose_batch_errors.py @@ -10,6 +10,7 @@ # Helpers # --------------------------------------------------------------------------- + def _make_args(**kwargs): defaults = {"json": False, "account": "iCloud", "mailbox": "INBOX"} defaults.update(kwargs) @@ -20,6 +21,7 @@ def _make_args(**kwargs): # compose.py: cmd_draft error paths # --------------------------------------------------------------------------- + class TestDraftErrors: def test_draft_no_account_dies(self, monkeypatch): from mxctl.commands.mail.compose import cmd_draft @@ -27,8 +29,7 @@ def test_draft_no_account_dies(self, monkeypatch): monkeypatch.setattr("mxctl.commands.mail.compose.resolve_account", lambda _: None) with pytest.raises(SystemExit): - cmd_draft(_make_args(account=None, to="x@y.com", subject="S", body="B", - template=None, cc=None, bcc=None)) + cmd_draft(_make_args(account=None, to="x@y.com", subject="S", body="B", template=None, cc=None, bcc=None)) def test_draft_no_subject_no_template_dies(self, monkeypatch): from mxctl.commands.mail.compose import cmd_draft @@ -36,8 +37,7 @@ def test_draft_no_subject_no_template_dies(self, monkeypatch): monkeypatch.setattr("mxctl.commands.mail.compose.resolve_account", lambda _: "iCloud") with pytest.raises(SystemExit): - cmd_draft(_make_args(to="x@y.com", subject=None, body="hello", - template=None, cc=None, bcc=None)) + cmd_draft(_make_args(to="x@y.com", subject=None, body="hello", template=None, cc=None, bcc=None)) def test_draft_no_body_no_template_dies(self, monkeypatch): from mxctl.commands.mail.compose import cmd_draft @@ -45,8 +45,7 @@ def test_draft_no_body_no_template_dies(self, monkeypatch): monkeypatch.setattr("mxctl.commands.mail.compose.resolve_account", lambda _: "iCloud") with pytest.raises(SystemExit): - cmd_draft(_make_args(to="x@y.com", subject="hello", body=None, - template=None, cc=None, bcc=None)) + cmd_draft(_make_args(to="x@y.com", subject="hello", body=None, template=None, cc=None, bcc=None)) def test_draft_template_not_found_dies(self, monkeypatch, tmp_path): from mxctl.commands.mail.compose import cmd_draft @@ -61,8 +60,7 @@ def test_draft_template_not_found_dies(self, monkeypatch, tmp_path): monkeypatch.setattr("mxctl.commands.mail.compose.TEMPLATES_FILE", tpl_file) with pytest.raises(SystemExit): - cmd_draft(_make_args(to="x@y.com", subject=None, body=None, - template="missing", cc=None, bcc=None)) + cmd_draft(_make_args(to="x@y.com", subject=None, body=None, template="missing", cc=None, bcc=None)) def test_draft_corrupt_template_file_dies(self, monkeypatch, tmp_path): from mxctl.commands.mail.compose import cmd_draft @@ -76,25 +74,23 @@ def test_draft_corrupt_template_file_dies(self, monkeypatch, tmp_path): monkeypatch.setattr("mxctl.commands.mail.compose.TEMPLATES_FILE", tpl_file) with pytest.raises(SystemExit): - cmd_draft(_make_args(to="x@y.com", subject=None, body=None, - template="any", cc=None, bcc=None)) + cmd_draft(_make_args(to="x@y.com", subject=None, body=None, template="any", cc=None, bcc=None)) def test_draft_no_templates_file_dies(self, monkeypatch, tmp_path): from mxctl.commands.mail.compose import cmd_draft monkeypatch.setattr("mxctl.commands.mail.compose.resolve_account", lambda _: "iCloud") - monkeypatch.setattr("mxctl.commands.mail.compose.TEMPLATES_FILE", - str(tmp_path / "nonexistent.json")) + monkeypatch.setattr("mxctl.commands.mail.compose.TEMPLATES_FILE", str(tmp_path / "nonexistent.json")) with pytest.raises(SystemExit): - cmd_draft(_make_args(to="x@y.com", subject=None, body=None, - template="any", cc=None, bcc=None)) + cmd_draft(_make_args(to="x@y.com", subject=None, body=None, template="any", cc=None, bcc=None)) # --------------------------------------------------------------------------- # batch.py: dry-run effective_count edge cases # --------------------------------------------------------------------------- + class TestBatchMoveEffectiveCount: def test_dry_run_with_limit_caps_count(self, monkeypatch, capsys): from mxctl.commands.mail.batch import cmd_batch_move @@ -103,8 +99,7 @@ def test_dry_run_with_limit_caps_count(self, monkeypatch, capsys): mock_run = Mock(return_value="50") monkeypatch.setattr("mxctl.commands.mail.batch.run", mock_run) - args = _make_args(from_sender="test@x.com", to_mailbox="Archive", - dry_run=True, limit=10) + args = _make_args(from_sender="test@x.com", to_mailbox="Archive", dry_run=True, limit=10) cmd_batch_move(args) out = capsys.readouterr().out @@ -117,8 +112,7 @@ def test_dry_run_without_limit_uses_total(self, monkeypatch, capsys): mock_run = Mock(return_value="25") monkeypatch.setattr("mxctl.commands.mail.batch.run", mock_run) - args = _make_args(from_sender="test@x.com", to_mailbox="Archive", - dry_run=True, limit=None) + args = _make_args(from_sender="test@x.com", to_mailbox="Archive", dry_run=True, limit=None) cmd_batch_move(args) out = capsys.readouterr().out @@ -133,8 +127,7 @@ def test_dry_run_with_limit_caps_count(self, monkeypatch, capsys): mock_run = Mock(return_value="100") monkeypatch.setattr("mxctl.commands.mail.batch.run", mock_run) - args = _make_args(from_sender="spam@x.com", older_than=None, - dry_run=True, limit=20, force=False) + args = _make_args(from_sender="spam@x.com", older_than=None, dry_run=True, limit=20, force=False) cmd_batch_delete(args) out = capsys.readouterr().out @@ -147,8 +140,7 @@ def test_dry_run_without_limit_uses_total(self, monkeypatch, capsys): mock_run = Mock(return_value="42") monkeypatch.setattr("mxctl.commands.mail.batch.run", mock_run) - args = _make_args(from_sender="spam@x.com", older_than=None, - dry_run=True, limit=None, force=False) + args = _make_args(from_sender="spam@x.com", older_than=None, dry_run=True, limit=None, force=False) cmd_batch_delete(args) out = capsys.readouterr().out @@ -159,6 +151,7 @@ def test_dry_run_without_limit_uses_total(self, monkeypatch, capsys): # todoist_integration.py: cmd_to_todoist # --------------------------------------------------------------------------- + class TestCmdToTodoist: def test_to_todoist_missing_token_dies(self, monkeypatch): """Test that missing Todoist API token causes SystemExit.""" @@ -192,9 +185,7 @@ def test_to_todoist_happy_path(self, monkeypatch, capsys): ) # Mock AppleScript run to return message data - mock_run = Mock( - return_value=f"Test Subject{FIELD_SEPARATOR}sender@example.com{FIELD_SEPARATOR}2026-01-15" - ) + mock_run = Mock(return_value=f"Test Subject{FIELD_SEPARATOR}sender@example.com{FIELD_SEPARATOR}2026-01-15") monkeypatch.setattr("mxctl.commands.mail.todoist_integration.run", mock_run) # Mock the urllib HTTP call @@ -217,6 +208,7 @@ def test_to_todoist_happy_path(self, monkeypatch, capsys): # actions.py: cmd_unsubscribe # --------------------------------------------------------------------------- + class TestCmdUnsubscribe: def test_unsubscribe_dry_run_shows_list_unsubscribe_url(self, monkeypatch, capsys): """Test that --dry-run shows the List-Unsubscribe URL from headers.""" @@ -230,13 +222,8 @@ def test_unsubscribe_dry_run_shows_list_unsubscribe_url(self, monkeypatch, capsy # AppleScript returns subject + raw headers containing List-Unsubscribe unsub_url = "https://example.com/unsubscribe?token=abc123" - raw_headers = ( - f"List-Unsubscribe: <{unsub_url}>\n" - "From: newsletter@example.com\n" - ) - mock_run = Mock( - return_value=f"Newsletter Subject{FIELD_SEPARATOR}HEADER_SPLIT{FIELD_SEPARATOR}{raw_headers}" - ) + raw_headers = f"List-Unsubscribe: <{unsub_url}>\nFrom: newsletter@example.com\n" + mock_run = Mock(return_value=f"Newsletter Subject{FIELD_SEPARATOR}HEADER_SPLIT{FIELD_SEPARATOR}{raw_headers}") monkeypatch.setattr("mxctl.commands.mail.actions.run", mock_run) args = _make_args(id=99, dry_run=True, open=False) @@ -251,6 +238,7 @@ def test_unsubscribe_dry_run_shows_list_unsubscribe_url(self, monkeypatch, capsy # compose.py: cmd_draft happy path # --------------------------------------------------------------------------- + class TestDraftHappyPath: def test_draft_creates_draft_successfully(self, monkeypatch, capsys): """Test that cmd_draft succeeds and prints the draft creation message.""" @@ -260,9 +248,9 @@ def test_draft_creates_draft_successfully(self, monkeypatch, capsys): mock_run = Mock(return_value="draft created") monkeypatch.setattr("mxctl.commands.mail.compose.run", mock_run) - args = _make_args(to="recipient@example.com", subject="Hello there", - body="This is the email body.", template=None, - cc=None, bcc=None) + args = _make_args( + to="recipient@example.com", subject="Hello there", body="This is the email body.", template=None, cc=None, bcc=None + ) cmd_draft(args) out = capsys.readouterr().out @@ -279,9 +267,9 @@ def test_draft_with_cc_and_bcc_shows_recipients(self, monkeypatch, capsys): mock_run = Mock(return_value="draft created") monkeypatch.setattr("mxctl.commands.mail.compose.run", mock_run) - args = _make_args(to="recipient@example.com", subject="Meeting", - body="Let's meet.", template=None, - cc="cc@example.com", bcc="bcc@example.com") + args = _make_args( + to="recipient@example.com", subject="Meeting", body="Let's meet.", template=None, cc="cc@example.com", bcc="bcc@example.com" + ) cmd_draft(args) out = capsys.readouterr().out @@ -297,9 +285,7 @@ def test_draft_output_mentions_mail_app(self, monkeypatch, capsys): mock_run = Mock(return_value="draft created") monkeypatch.setattr("mxctl.commands.mail.compose.run", mock_run) - args = _make_args(to="someone@example.com", subject="Test subject", - body="Test body text.", template=None, - cc=None, bcc=None) + args = _make_args(to="someone@example.com", subject="Test subject", body="Test body text.", template=None, cc=None, bcc=None) cmd_draft(args) out = capsys.readouterr().out @@ -314,8 +300,7 @@ def test_draft_applescript_uses_safe_email_address_lookup(self, monkeypatch): mock_run = Mock(return_value="draft created") monkeypatch.setattr("mxctl.commands.mail.compose.run", mock_run) - args = _make_args(to="r@example.com", subject="S", body="B", - template=None, cc=None, bcc=None) + args = _make_args(to="r@example.com", subject="S", body="B", template=None, cc=None, bcc=None) cmd_draft(args) script_sent = mock_run.call_args[0][0] @@ -330,6 +315,7 @@ def test_draft_applescript_uses_safe_email_address_lookup(self, monkeypatch): # batch.py: cmd_batch_read # --------------------------------------------------------------------------- + class TestBatchRead: def test_batch_read_no_account_dies(self, monkeypatch): """Test that cmd_batch_read dies when no account is resolved.""" @@ -389,6 +375,7 @@ def test_batch_read_non_digit_result_treated_as_zero(self, monkeypatch, capsys): # batch.py: cmd_batch_flag # --------------------------------------------------------------------------- + class TestBatchFlag: def test_batch_flag_no_account_dies(self, monkeypatch): """Test that cmd_batch_flag dies when no account is resolved.""" @@ -443,6 +430,7 @@ def test_batch_flag_zero_messages_reports_zero(self, monkeypatch, capsys): # batch.py: cmd_batch_move execution path (non-dry-run) # --------------------------------------------------------------------------- + class TestBatchMoveExecution: def test_batch_move_no_account_dies(self, monkeypatch): """Test that cmd_batch_move dies when no account is resolved.""" @@ -451,8 +439,7 @@ def test_batch_move_no_account_dies(self, monkeypatch): monkeypatch.setattr("mxctl.commands.mail.batch.resolve_account", lambda _: None) with pytest.raises(SystemExit): - cmd_batch_move(_make_args(account=None, from_sender="s@x.com", - to_mailbox="Archive", dry_run=False, limit=None)) + cmd_batch_move(_make_args(account=None, from_sender="s@x.com", to_mailbox="Archive", dry_run=False, limit=None)) def test_batch_move_no_sender_dies(self, monkeypatch): """Test that cmd_batch_move dies when --from-sender is missing.""" @@ -461,8 +448,7 @@ def test_batch_move_no_sender_dies(self, monkeypatch): monkeypatch.setattr("mxctl.commands.mail.batch.resolve_account", lambda _: "iCloud") with pytest.raises(SystemExit): - cmd_batch_move(_make_args(from_sender=None, to_mailbox="Archive", - dry_run=False, limit=None)) + cmd_batch_move(_make_args(from_sender=None, to_mailbox="Archive", dry_run=False, limit=None)) def test_batch_move_no_dest_mailbox_dies(self, monkeypatch): """Test that cmd_batch_move dies when --to-mailbox is missing.""" @@ -471,16 +457,14 @@ def test_batch_move_no_dest_mailbox_dies(self, monkeypatch): monkeypatch.setattr("mxctl.commands.mail.batch.resolve_account", lambda _: "iCloud") with pytest.raises(SystemExit): - cmd_batch_move(_make_args(from_sender="s@x.com", to_mailbox=None, - dry_run=False, limit=None)) + cmd_batch_move(_make_args(from_sender="s@x.com", to_mailbox=None, dry_run=False, limit=None)) def test_batch_move_actually_moves_messages(self, monkeypatch, capsys): """Test the live execution path of cmd_batch_move (not dry-run).""" from mxctl.commands.mail.batch import cmd_batch_move monkeypatch.setattr("mxctl.commands.mail.batch.resolve_account", lambda _: "iCloud") - monkeypatch.setattr("mxctl.commands.mail.batch.resolve_mailbox", - lambda account, mailbox: mailbox) + monkeypatch.setattr("mxctl.commands.mail.batch.resolve_mailbox", lambda account, mailbox: mailbox) # First call returns count (3 messages), second call returns move result # Move result: count on line 0, message IDs on subsequent lines @@ -491,8 +475,7 @@ def test_batch_move_actually_moves_messages(self, monkeypatch, capsys): mock_log = Mock() monkeypatch.setattr("mxctl.commands.mail.batch.log_batch_operation", mock_log) - args = _make_args(from_sender="sender@example.com", to_mailbox="Archive", - dry_run=False, limit=None) + args = _make_args(from_sender="sender@example.com", to_mailbox="Archive", dry_run=False, limit=None) cmd_batch_move(args) out = capsys.readouterr().out @@ -515,13 +498,11 @@ def test_batch_move_zero_matching_messages_skips_move(self, monkeypatch, capsys) from mxctl.commands.mail.batch import cmd_batch_move monkeypatch.setattr("mxctl.commands.mail.batch.resolve_account", lambda _: "iCloud") - monkeypatch.setattr("mxctl.commands.mail.batch.resolve_mailbox", - lambda account, mailbox: mailbox) + monkeypatch.setattr("mxctl.commands.mail.batch.resolve_mailbox", lambda account, mailbox: mailbox) mock_run = Mock(return_value="0") monkeypatch.setattr("mxctl.commands.mail.batch.run", mock_run) - args = _make_args(from_sender="nobody@example.com", to_mailbox="Archive", - dry_run=False, limit=None) + args = _make_args(from_sender="nobody@example.com", to_mailbox="Archive", dry_run=False, limit=None) cmd_batch_move(args) out = capsys.readouterr().out @@ -534,8 +515,7 @@ def test_batch_move_execution_with_limit(self, monkeypatch, capsys): from mxctl.commands.mail.batch import cmd_batch_move monkeypatch.setattr("mxctl.commands.mail.batch.resolve_account", lambda _: "iCloud") - monkeypatch.setattr("mxctl.commands.mail.batch.resolve_mailbox", - lambda account, mailbox: mailbox) + monkeypatch.setattr("mxctl.commands.mail.batch.resolve_mailbox", lambda account, mailbox: mailbox) move_result = "2\n2001\n2002" mock_run = Mock(side_effect=["10", move_result]) @@ -544,8 +524,7 @@ def test_batch_move_execution_with_limit(self, monkeypatch, capsys): mock_log = Mock() monkeypatch.setattr("mxctl.commands.mail.batch.log_batch_operation", mock_log) - args = _make_args(from_sender="bulk@example.com", to_mailbox="Bulk", - dry_run=False, limit=2) + args = _make_args(from_sender="bulk@example.com", to_mailbox="Bulk", dry_run=False, limit=2) cmd_batch_move(args) out = capsys.readouterr().out diff --git a/tests/test_config.py b/tests/test_config.py index 61ce7e6..0b0763e 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -35,9 +35,7 @@ def test_explicit_arg(self, tmp_path, monkeypatch): config_dir = tmp_path / "config" config_dir.mkdir() monkeypatch.setattr("mxctl.config.CONFIG_DIR", str(config_dir)) - monkeypatch.setattr( - "mxctl.config.CONFIG_FILE", str(config_dir / "config.json") - ) + monkeypatch.setattr("mxctl.config.CONFIG_FILE", str(config_dir / "config.json")) monkeypatch.setattr("mxctl.config.STATE_FILE", str(config_dir / "state.json")) result = resolve_account("ExplicitAccount") @@ -53,16 +51,12 @@ def test_config_fallback(self, tmp_path, monkeypatch): config_dir = tmp_path / "config" config_dir.mkdir() monkeypatch.setattr("mxctl.config.CONFIG_DIR", str(config_dir)) - monkeypatch.setattr( - "mxctl.config.CONFIG_FILE", str(config_dir / "config.json") - ) + monkeypatch.setattr("mxctl.config.CONFIG_FILE", str(config_dir / "config.json")) monkeypatch.setattr("mxctl.config.STATE_FILE", str(config_dir / "state.json")) # Set config default (namespaced under "mail") config_file = config_dir / "config.json" - config_file.write_text( - json.dumps({"mail": {"default_account": "ConfigDefault"}}) - ) + config_file.write_text(json.dumps({"mail": {"default_account": "ConfigDefault"}})) result = resolve_account(None) assert result == "ConfigDefault" @@ -71,9 +65,7 @@ def test_state_fallback(self, tmp_path, monkeypatch): config_dir = tmp_path / "config" config_dir.mkdir() monkeypatch.setattr("mxctl.config.CONFIG_DIR", str(config_dir)) - monkeypatch.setattr( - "mxctl.config.CONFIG_FILE", str(config_dir / "config.json") - ) + monkeypatch.setattr("mxctl.config.CONFIG_FILE", str(config_dir / "config.json")) monkeypatch.setattr("mxctl.config.STATE_FILE", str(config_dir / "state.json")) # Set state last-used (namespaced under "mail") @@ -87,9 +79,7 @@ def test_none_when_nothing_set(self, tmp_path, monkeypatch): config_dir = tmp_path / "config" config_dir.mkdir() monkeypatch.setattr("mxctl.config.CONFIG_DIR", str(config_dir)) - monkeypatch.setattr( - "mxctl.config.CONFIG_FILE", str(config_dir / "config.json") - ) + monkeypatch.setattr("mxctl.config.CONFIG_FILE", str(config_dir / "config.json")) monkeypatch.setattr("mxctl.config.STATE_FILE", str(config_dir / "state.json")) result = resolve_account(None) @@ -100,6 +90,7 @@ def test_none_when_nothing_set(self, tmp_path, monkeypatch): # _migrate_legacy_config # =========================================================================== + class TestMigrateLegacyConfig: """Test legacy config migration from ~/.config/my/ to ~/.config/mxctl/.""" @@ -174,6 +165,7 @@ def test_migration_only_runs_once(self, tmp_path, monkeypatch): # Remove the new dir so we can tell if second call runs import shutil + shutil.rmtree(str(new_dir)) cfg_mod._migrate_legacy_config() @@ -185,6 +177,7 @@ def test_migration_only_runs_once(self, tmp_path, monkeypatch): # file_lock retry and timeout # =========================================================================== + class TestFileLock: """Test file_lock retry/timeout paths.""" @@ -264,6 +257,7 @@ def always_fail(fd, operation): # _load_json IOError and edge cases # =========================================================================== + class TestLoadJson: """Test _load_json error handling.""" @@ -355,6 +349,7 @@ def test_load_json_nonexistent_file(self, tmp_path, monkeypatch): # get_config: migration trigger, required=True, warn paths # =========================================================================== + class TestGetConfig: """Test get_config edge cases.""" @@ -425,6 +420,7 @@ def test_get_config_triggers_migration(self, tmp_path, monkeypatch): # save_message_aliases + resolve_alias # =========================================================================== + class TestMessageAliases: """Test save_message_aliases and resolve_alias.""" @@ -461,11 +457,13 @@ def test_resolve_alias_not_found(self, tmp_path, monkeypatch): def test_resolve_alias_non_numeric(self): """Non-numeric value returns None.""" from mxctl.config import resolve_alias + assert resolve_alias("abc") is None assert resolve_alias(None) is None def test_resolve_alias_zero_or_negative(self): """Zero or negative returns None.""" from mxctl.config import resolve_alias + assert resolve_alias(0) is None assert resolve_alias(-1) is None diff --git a/tests/test_count.py b/tests/test_count.py index 5572fc5..ac8dc05 100644 --- a/tests/test_count.py +++ b/tests/test_count.py @@ -7,6 +7,7 @@ # cmd_count (accounts.py) # --------------------------------------------------------------------------- + def test_count_all_accounts(monkeypatch, mock_args, capsys): """count with no -a flag returns total unread across all accounts.""" from mxctl.commands.mail.accounts import cmd_count diff --git a/tests/test_error_paths.py b/tests/test_error_paths.py index 7cb37fd..777a3bb 100644 --- a/tests/test_error_paths.py +++ b/tests/test_error_paths.py @@ -19,9 +19,7 @@ def test_dies_when_account_not_set(self, tmp_path, monkeypatch): config_dir = tmp_path / "config" config_dir.mkdir() monkeypatch.setattr("mxctl.config.CONFIG_DIR", str(config_dir)) - monkeypatch.setattr( - "mxctl.config.CONFIG_FILE", str(config_dir / "config.json") - ) + monkeypatch.setattr("mxctl.config.CONFIG_FILE", str(config_dir / "config.json")) monkeypatch.setattr("mxctl.config.STATE_FILE", str(config_dir / "state.json")) args = Namespace(account=None, mailbox=None) @@ -36,9 +34,7 @@ def test_uses_default_mailbox_when_none(self, tmp_path, monkeypatch): config_dir = tmp_path / "config" config_dir.mkdir() monkeypatch.setattr("mxctl.config.CONFIG_DIR", str(config_dir)) - monkeypatch.setattr( - "mxctl.config.CONFIG_FILE", str(config_dir / "config.json") - ) + monkeypatch.setattr("mxctl.config.CONFIG_FILE", str(config_dir / "config.json")) monkeypatch.setattr("mxctl.config.STATE_FILE", str(config_dir / "state.json")) args = Namespace(account="TestAccount", mailbox=None) @@ -53,17 +49,15 @@ def test_escapes_special_characters(self, tmp_path, monkeypatch): config_dir = tmp_path / "config" config_dir.mkdir() monkeypatch.setattr("mxctl.config.CONFIG_DIR", str(config_dir)) - monkeypatch.setattr( - "mxctl.config.CONFIG_FILE", str(config_dir / "config.json") - ) + monkeypatch.setattr("mxctl.config.CONFIG_FILE", str(config_dir / "config.json")) monkeypatch.setattr("mxctl.config.STATE_FILE", str(config_dir / "state.json")) - args = Namespace(account='Test"Account', mailbox='Mail\\Box') + args = Namespace(account='Test"Account', mailbox="Mail\\Box") _, _, acct_escaped, mb_escaped = resolve_message_context(args) # The escape function should handle quotes and backslashes assert '"' not in acct_escaped or '\\"' in acct_escaped - assert '\\' not in mb_escaped or '\\\\' in mb_escaped + assert "\\" not in mb_escaped or "\\\\" in mb_escaped class TestAppleScriptErrorHandling: @@ -74,14 +68,20 @@ def test_applescript_error_propagates_as_system_exit(self, monkeypatch): from mxctl.commands.mail.batch import cmd_batch_move monkeypatch.setattr("mxctl.commands.mail.batch.resolve_account", lambda _: "iCloud") + # Simulate run() encountering an AppleScript error and calling sys.exit(1) def failing_run(script, **kwargs): raise SystemExit(1) + monkeypatch.setattr("mxctl.commands.mail.batch.run", failing_run) args = Namespace( - account="iCloud", from_sender="spam@example.com", - to_mailbox="Archive", dry_run=False, limit=None, json=False, + account="iCloud", + from_sender="spam@example.com", + to_mailbox="Archive", + dry_run=False, + limit=None, + json=False, ) with pytest.raises(SystemExit) as exc_info: cmd_batch_move(args) @@ -100,12 +100,12 @@ def test_cmd_read_with_malformed_applescript_output(self, monkeypatch, capsys): monkeypatch.setattr("mxctl.commands.mail.messages.run", Mock(return_value=malformed_output)) args = Namespace(account="iCloud", mailbox="INBOX", id=42, short=False, json=False) - # Should NOT raise — cmd_read has a graceful fallback for < 16 fields + # Should NOT raise — cmd_read gracefully handles < 16 fields cmd_read(args) captured = capsys.readouterr() - # The fallback branch prints the raw result under "Message details:" - assert "Message details:" in captured.out + # The refactored code returns empty dict from read_message() and prints "not found" + assert "not found" in captured.out def test_batch_delete_missing_filter_args_dies(self, monkeypatch): """cmd_batch_delete should exit with code 1 when neither --older-than nor --from-sender is given.""" @@ -114,9 +114,14 @@ def test_batch_delete_missing_filter_args_dies(self, monkeypatch): monkeypatch.setattr("mxctl.commands.mail.batch.resolve_account", lambda _: "iCloud") args = Namespace( - account="iCloud", mailbox=None, - older_than=None, from_sender=None, - dry_run=False, force=False, limit=None, json=False, + account="iCloud", + mailbox=None, + older_than=None, + from_sender=None, + dry_run=False, + force=False, + limit=None, + json=False, ) with pytest.raises(SystemExit) as exc_info: cmd_batch_delete(args) @@ -160,8 +165,12 @@ def test_batch_move_dry_run_reports_would_move(self, monkeypatch, capsys): monkeypatch.setattr("mxctl.commands.mail.batch.run", mock_run) args = Namespace( - account="iCloud", from_sender="newsletter@example.com", - to_mailbox="Archive", dry_run=True, limit=None, json=False, + account="iCloud", + from_sender="newsletter@example.com", + to_mailbox="Archive", + dry_run=True, + limit=None, + json=False, ) cmd_batch_move(args) @@ -182,8 +191,12 @@ def test_batch_move_dry_run_respects_limit(self, monkeypatch, capsys): monkeypatch.setattr("mxctl.commands.mail.batch.run", mock_run) args = Namespace( - account="iCloud", from_sender="bulk@example.com", - to_mailbox="Bulk", dry_run=True, limit=10, json=False, + account="iCloud", + from_sender="bulk@example.com", + to_mailbox="Bulk", + dry_run=True, + limit=10, + json=False, ) cmd_batch_move(args) @@ -202,9 +215,14 @@ def test_batch_delete_dry_run_reports_would_delete(self, monkeypatch, capsys): monkeypatch.setattr("mxctl.commands.mail.batch.run", mock_run) args = Namespace( - account="iCloud", mailbox="INBOX", - older_than=30, from_sender=None, - dry_run=True, force=False, limit=None, json=False, + account="iCloud", + mailbox="INBOX", + older_than=30, + from_sender=None, + dry_run=True, + force=False, + limit=None, + json=False, ) cmd_batch_delete(args) @@ -220,6 +238,7 @@ def test_batch_delete_dry_run_reports_would_delete(self, monkeypatch, capsys): # inbox_tools.py: cmd_process_inbox # --------------------------------------------------------------------------- + class TestCmdProcessInbox: """Smoke tests for cmd_process_inbox.""" @@ -270,6 +289,7 @@ def test_process_inbox_categorizes_messages(self, monkeypatch, capsys): # inbox_tools.py: cmd_weekly_review # --------------------------------------------------------------------------- + class TestCmdWeeklyReview: """Smoke tests for cmd_weekly_review.""" @@ -321,6 +341,7 @@ def test_weekly_review_with_flagged_data(self, monkeypatch, capsys): # inbox_tools.py: cmd_clean_newsletters # --------------------------------------------------------------------------- + class TestCmdCleanNewsletters: """Smoke tests for cmd_clean_newsletters.""" @@ -391,6 +412,7 @@ def test_clean_newsletters_no_newsletters_found(self, monkeypatch, capsys): # attachments.py: cmd_save_attachment # --------------------------------------------------------------------------- + class TestCmdSaveAttachment: """Smoke tests for cmd_save_attachment.""" @@ -416,15 +438,21 @@ def test_save_attachment_by_name(self, monkeypatch, capsys, tmp_path): # Patch os.path.isfile to return True for our fake path original_isfile = os.path.isfile + def patched_isfile(p): if p == str(tmp_path / att_name): return True return original_isfile(p) + monkeypatch.setattr("mxctl.commands.mail.attachments.os.path.isfile", patched_isfile) args = Namespace( - account="iCloud", mailbox="INBOX", id=42, - attachment=att_name, output_dir=str(tmp_path), json=False, + account="iCloud", + mailbox="INBOX", + id=42, + attachment=att_name, + output_dir=str(tmp_path), + json=False, ) cmd_save_attachment(args) @@ -446,8 +474,12 @@ def test_save_attachment_no_attachment_dies(self, monkeypatch): monkeypatch.setattr("mxctl.commands.mail.attachments.run", mock_run) args = Namespace( - account="iCloud", mailbox="INBOX", id=42, - attachment="file.pdf", output_dir="/tmp", json=False, + account="iCloud", + mailbox="INBOX", + id=42, + attachment="file.pdf", + output_dir="/tmp", + json=False, ) with pytest.raises(SystemExit): cmd_save_attachment(args) @@ -470,16 +502,21 @@ def test_save_attachment_by_index(self, monkeypatch, capsys, tmp_path): fake_file.write_bytes(b"data") original_isfile = os.path.isfile + def patched_isfile(p): if p == str(tmp_path / att_name): return True return original_isfile(p) + monkeypatch.setattr("mxctl.commands.mail.attachments.os.path.isfile", patched_isfile) args = Namespace( - account="iCloud", mailbox="INBOX", id=42, + account="iCloud", + mailbox="INBOX", + id=42, attachment="1", # index 1 → invoice.pdf - output_dir=str(tmp_path), json=False, + output_dir=str(tmp_path), + json=False, ) cmd_save_attachment(args) diff --git a/tests/test_mail_helpers.py b/tests/test_mail_helpers.py index f04eae4..2bce1b5 100644 --- a/tests/test_mail_helpers.py +++ b/tests/test_mail_helpers.py @@ -72,9 +72,7 @@ def test_duplicate_keys(self): def test_duplicate_keys_multiline(self): raw = "Received: server1\n continuation1\nReceived: server2\n continuation2" headers = parse_email_headers(raw) - assert headers == { - "Received": ["server1 continuation1", "server2 continuation2"] - } + assert headers == {"Received": ["server1 continuation1", "server2 continuation2"]} def test_tab_continuation(self): raw = "Subject: First line\n\tSecond line" diff --git a/tests/test_manage.py b/tests/test_manage.py index 8dcaed2..f0dfdc0 100644 --- a/tests/test_manage.py +++ b/tests/test_manage.py @@ -11,6 +11,7 @@ def test_cmd_empty_trash_single_account(monkeypatch, capsys): """Test empty-trash for a single account.""" + # Mock resolve_account to return the account def mock_resolve_account(account): return "iCloud" @@ -35,6 +36,7 @@ def mock_resolve_account(account): def test_cmd_empty_trash_all_accounts(monkeypatch, capsys): """Test empty-trash with --all flag.""" + def mock_resolve_account(account): return None @@ -54,6 +56,7 @@ def mock_resolve_account(account): def test_cmd_empty_trash_already_empty(monkeypatch, capsys): """Test empty-trash when trash is already empty.""" + def mock_resolve_account(account): return "iCloud" @@ -72,6 +75,7 @@ def mock_resolve_account(account): def test_cmd_empty_trash_json_output(monkeypatch, capsys): """Test empty-trash with JSON output.""" + def mock_resolve_account(account): return "iCloud" @@ -96,6 +100,7 @@ def mock_resolve_account(account): def test_cmd_empty_trash_json_already_empty(monkeypatch, capsys): """Test empty-trash JSON output when already empty.""" + def mock_resolve_account(account): return "iCloud" @@ -116,6 +121,7 @@ def mock_resolve_account(account): def test_cmd_empty_trash_no_account_no_all_flag(monkeypatch): """Test empty-trash fails when neither account nor --all is provided.""" + def mock_resolve_account(account): return None @@ -129,6 +135,7 @@ def mock_resolve_account(account): def test_cmd_empty_trash_menu_not_found(monkeypatch): """Test empty-trash handles menu item not found error.""" + def mock_resolve_account(account): return "InvalidAccount" @@ -139,10 +146,7 @@ def mock_resolve_account(account): monkeypatch.setattr("mxctl.commands.mail.manage.run", mock_run) # Mock subprocess.run to fail with "Can't get menu item" - mock_subprocess = Mock(return_value=Mock( - returncode=1, - stderr="Can't get menu item InvalidAccount… of menu 1" - )) + mock_subprocess = Mock(return_value=Mock(returncode=1, stderr="Can't get menu item InvalidAccount… of menu 1")) monkeypatch.setattr("mxctl.commands.mail.manage.subprocess.run", mock_subprocess) args = Namespace(account="InvalidAccount", all=False, json=False) @@ -153,6 +157,7 @@ def mock_resolve_account(account): def test_cmd_empty_trash_timeout(monkeypatch): """Test empty-trash handles timeout gracefully.""" + def mock_resolve_account(account): return "iCloud" @@ -176,6 +181,7 @@ def mock_subprocess_timeout(*args, **kwargs): def test_cmd_empty_trash_nonzero_message_count_handling(monkeypatch, capsys): """Test empty-trash handles non-numeric message count gracefully.""" + def mock_resolve_account(account): return "iCloud" @@ -195,6 +201,7 @@ def mock_resolve_account(account): def test_cmd_empty_trash_applescript_error_handling(monkeypatch, capsys): """Test empty-trash handles AppleScript errors during count gracefully.""" + def mock_resolve_account(account): return "iCloud" @@ -218,6 +225,7 @@ def mock_run_error(script): # cmd_create_mailbox # --------------------------------------------------------------------------- + def test_cmd_create_mailbox_success(monkeypatch, capsys): """Test create-mailbox calls run() and reports creation.""" monkeypatch.setattr("mxctl.commands.mail.manage.resolve_account", lambda _: "iCloud") @@ -252,6 +260,7 @@ def test_cmd_create_mailbox_no_account_dies(monkeypatch): # cmd_delete_mailbox # --------------------------------------------------------------------------- + def test_cmd_delete_mailbox_without_force_dies(monkeypatch): """Test delete-mailbox exits without --force flag.""" monkeypatch.setattr("mxctl.commands.mail.manage.resolve_account", lambda _: "iCloud") diff --git a/tests/test_new_coverage.py b/tests/test_new_coverage.py index 88c192b..01fd03f 100644 --- a/tests/test_new_coverage.py +++ b/tests/test_new_coverage.py @@ -22,6 +22,7 @@ # Helpers # --------------------------------------------------------------------------- + def _make_args(**kwargs): defaults = { "json": False, @@ -36,41 +37,49 @@ def _make_args(**kwargs): # actions.py — unsubscribe # =========================================================================== + class TestUnsubscribePrivateIpValidation: """Test _is_private_url() rejects private/loopback addresses.""" def test_private_ip_10_x(self): from mxctl.commands.mail.actions import _is_private_url + with patch("socket.gethostbyname", return_value="10.0.0.1"): assert _is_private_url("http://internal.corp/unsub") is True def test_private_ip_172_16(self): from mxctl.commands.mail.actions import _is_private_url + with patch("socket.gethostbyname", return_value="172.20.0.1"): assert _is_private_url("http://internal.corp/unsub") is True def test_private_ip_192_168(self): from mxctl.commands.mail.actions import _is_private_url + with patch("socket.gethostbyname", return_value="192.168.1.1"): assert _is_private_url("http://router.local/unsub") is True def test_loopback_127(self): from mxctl.commands.mail.actions import _is_private_url + with patch("socket.gethostbyname", return_value="127.0.0.1"): assert _is_private_url("http://localhost/unsub") is True def test_public_ip_allowed(self): from mxctl.commands.mail.actions import _is_private_url + with patch("socket.gethostbyname", return_value="93.184.216.34"): assert _is_private_url("https://example.com/unsub") is False def test_dns_failure_blocks(self): from mxctl.commands.mail.actions import _is_private_url + with patch("socket.gethostbyname", side_effect=socket.gaierror("NXDOMAIN")): assert _is_private_url("https://nonexistent.invalid/unsub") is True def test_missing_hostname_blocks(self): from mxctl.commands.mail.actions import _is_private_url + # URL with no hostname assert _is_private_url("file:///etc/hosts") is True @@ -83,11 +92,7 @@ def test_dry_run_shows_links(self, mock_run, capsys): from mxctl.commands.mail.actions import cmd_unsubscribe header_value = "" - mock_run.return_value = ( - f"Weekly Newsletter" - f"{FIELD_SEPARATOR}HEADER_SPLIT{FIELD_SEPARATOR}" - f"List-Unsubscribe: {header_value}\n" - ) + mock_run.return_value = f"Weekly Newsletter{FIELD_SEPARATOR}HEADER_SPLIT{FIELD_SEPARATOR}List-Unsubscribe: {header_value}\n" args = _make_args(id=42, dry_run=True, open=False) cmd_unsubscribe(args) @@ -101,11 +106,7 @@ def test_dry_run_json(self, mock_run, capsys): from mxctl.commands.mail.actions import cmd_unsubscribe header_value = ", " - mock_run.return_value = ( - f"My Newsletter" - f"{FIELD_SEPARATOR}HEADER_SPLIT{FIELD_SEPARATOR}" - f"List-Unsubscribe: {header_value}\n" - ) + mock_run.return_value = f"My Newsletter{FIELD_SEPARATOR}HEADER_SPLIT{FIELD_SEPARATOR}List-Unsubscribe: {header_value}\n" args = _make_args(id=42, dry_run=True, open=False, json=True) cmd_unsubscribe(args) @@ -121,11 +122,7 @@ def test_dry_run_json(self, mock_run, capsys): def test_no_unsubscribe_header(self, mock_run, capsys): from mxctl.commands.mail.actions import cmd_unsubscribe - mock_run.return_value = ( - f"Regular Email" - f"{FIELD_SEPARATOR}HEADER_SPLIT{FIELD_SEPARATOR}" - f"From: sender@example.com\n" - ) + mock_run.return_value = f"Regular Email{FIELD_SEPARATOR}HEADER_SPLIT{FIELD_SEPARATOR}From: sender@example.com\n" args = _make_args(id=42, dry_run=True, open=False) cmd_unsubscribe(args) @@ -221,9 +218,7 @@ def test_opens_browser_when_no_one_click(self, mock_subprocess, mock_run, capsys from mxctl.commands.mail.actions import cmd_unsubscribe mock_run.return_value = ( - f"Digest" - f"{FIELD_SEPARATOR}HEADER_SPLIT{FIELD_SEPARATOR}" - f"List-Unsubscribe: \n" + f"Digest{FIELD_SEPARATOR}HEADER_SPLIT{FIELD_SEPARATOR}List-Unsubscribe: \n" # No List-Unsubscribe-Post header => no one-click ) @@ -242,9 +237,7 @@ def test_mailto_only_shows_address(self, mock_run, capsys): from mxctl.commands.mail.actions import cmd_unsubscribe mock_run.return_value = ( - f"Old Newsletter" - f"{FIELD_SEPARATOR}HEADER_SPLIT{FIELD_SEPARATOR}" - f"List-Unsubscribe: \n" + f"Old Newsletter{FIELD_SEPARATOR}HEADER_SPLIT{FIELD_SEPARATOR}List-Unsubscribe: \n" ) args = _make_args(id=42, dry_run=False, open=False) @@ -259,26 +252,28 @@ class TestExtractUrls: def test_extracts_https(self): from mxctl.commands.mail.actions import _extract_urls + https, mailto = _extract_urls("") assert https == ["https://example.com/unsub"] assert mailto == [] def test_extracts_mailto(self): from mxctl.commands.mail.actions import _extract_urls + https, mailto = _extract_urls("") assert https == [] assert mailto == ["mailto:unsub@example.com"] def test_extracts_both(self): from mxctl.commands.mail.actions import _extract_urls - https, mailto = _extract_urls( - ", " - ) + + https, mailto = _extract_urls(", ") assert https == ["https://example.com/unsub"] assert mailto == ["mailto:unsub@example.com"] def test_empty_header(self): from mxctl.commands.mail.actions import _extract_urls + https, mailto = _extract_urls("") assert https == [] assert mailto == [] @@ -288,6 +283,7 @@ def test_empty_header(self): # todoist_integration.py — HTTP calls # =========================================================================== + class TestTodoistIntegration: """Test cmd_to_todoist with mocked HTTP and AppleScript.""" @@ -312,11 +308,7 @@ def test_success_without_project(self, mock_urlopen, mock_config, mock_run, caps from mxctl.commands.mail.todoist_integration import cmd_to_todoist mock_config.return_value = {"todoist_api_token": "fake-token"} - mock_run.return_value = ( - f"Important Meeting{FIELD_SEPARATOR}" - f"boss@corp.com{FIELD_SEPARATOR}" - f"Monday Jan 1 2026" - ) + mock_run.return_value = f"Important Meeting{FIELD_SEPARATOR}boss@corp.com{FIELD_SEPARATOR}Monday Jan 1 2026" response_payload = { "id": "task_abc123", @@ -346,9 +338,7 @@ def test_success_with_project(self, mock_urlopen, mock_config, mock_run, capsys) from mxctl.commands.mail.todoist_integration import cmd_to_todoist mock_config.return_value = {"todoist_api_token": "fake-token"} - mock_run.return_value = ( - f"Follow up{FIELD_SEPARATOR}alice@example.com{FIELD_SEPARATOR}Tuesday" - ) + mock_run.return_value = f"Follow up{FIELD_SEPARATOR}alice@example.com{FIELD_SEPARATOR}Tuesday" projects_list = [ {"id": "proj_work", "name": "Work"}, @@ -385,9 +375,7 @@ def test_project_not_found_dies(self, mock_urlopen, mock_config, mock_run): from mxctl.commands.mail.todoist_integration import cmd_to_todoist mock_config.return_value = {"todoist_api_token": "fake-token"} - mock_run.return_value = ( - f"Test Email{FIELD_SEPARATOR}x@y.com{FIELD_SEPARATOR}Wednesday" - ) + mock_run.return_value = f"Test Email{FIELD_SEPARATOR}x@y.com{FIELD_SEPARATOR}Wednesday" resp = MagicMock() resp.read.return_value = json.dumps([]).encode("utf-8") @@ -422,9 +410,7 @@ def test_http_error_dies(self, mock_urlopen, mock_config, mock_run): from mxctl.commands.mail.todoist_integration import cmd_to_todoist mock_config.return_value = {"todoist_api_token": "fake-token"} - mock_run.return_value = ( - f"Email{FIELD_SEPARATOR}x@y.com{FIELD_SEPARATOR}Thursday" - ) + mock_run.return_value = f"Email{FIELD_SEPARATOR}x@y.com{FIELD_SEPARATOR}Thursday" err_response = MagicMock() err_response.read.return_value = b"Unauthorized" @@ -449,9 +435,7 @@ def test_creates_task_json_output(self, mock_urlopen, mock_config, mock_run, cap from mxctl.commands.mail.todoist_integration import cmd_to_todoist mock_config.return_value = {"todoist_api_token": "fake-token"} - mock_run.return_value = ( - f"Invoice Due{FIELD_SEPARATOR}billing@shop.com{FIELD_SEPARATOR}Friday" - ) + mock_run.return_value = f"Invoice Due{FIELD_SEPARATOR}billing@shop.com{FIELD_SEPARATOR}Friday" response_payload = {"id": "task_111", "content": "Invoice Due"} mock_resp = MagicMock() @@ -473,6 +457,7 @@ def test_creates_task_json_output(self, mock_urlopen, mock_config, mock_run, cap # inbox_tools.py — smoke tests # =========================================================================== + class TestProcessInbox: """Smoke tests for cmd_process_inbox.""" @@ -616,9 +601,7 @@ def test_identifies_bulk_sender(self, mock_run, capsys, mock_args): from mxctl.commands.mail.inbox_tools import cmd_clean_newsletters # Same sender 4 times (>= 3 is threshold) - rows = "\n".join( - f"digest@weekly.com{FIELD_SEPARATOR}true" for _ in range(4) - ) + rows = "\n".join(f"digest@weekly.com{FIELD_SEPARATOR}true" for _ in range(4)) mock_run.return_value = rows + "\n" args = mock_args() @@ -645,9 +628,7 @@ def test_no_newsletters_found(self, mock_run, capsys, mock_args): def test_json_output(self, mock_run, capsys, mock_args): from mxctl.commands.mail.inbox_tools import cmd_clean_newsletters - rows = "\n".join( - f"updates@service.com{FIELD_SEPARATOR}false" for _ in range(3) - ) + rows = "\n".join(f"updates@service.com{FIELD_SEPARATOR}false" for _ in range(3)) mock_run.return_value = rows + "\n" args = mock_args(json=True) @@ -678,15 +659,12 @@ def test_all_empty(self, mock_run, capsys, mock_args): def test_shows_flagged(self, mock_run, capsys, mock_args): from mxctl.commands.mail.inbox_tools import cmd_weekly_review - flagged_row = ( - f"111{FIELD_SEPARATOR}Action Required{FIELD_SEPARATOR}" - f"boss@work.com{FIELD_SEPARATOR}Mon Jan 01 2026" - ) + flagged_row = f"111{FIELD_SEPARATOR}Action Required{FIELD_SEPARATOR}boss@work.com{FIELD_SEPARATOR}Mon Jan 01 2026" # Three separate run() calls: flagged, attachments, unreplied mock_run.side_effect = [ flagged_row + "\n", # flagged - "", # attachments - "", # unreplied + "", # attachments + "", # unreplied ] args = mock_args(days=7) @@ -699,14 +677,11 @@ def test_shows_flagged(self, mock_run, capsys, mock_args): def test_shows_attachments(self, mock_run, capsys, mock_args): from mxctl.commands.mail.inbox_tools import cmd_weekly_review - attach_row = ( - f"222{FIELD_SEPARATOR}Budget Q1{FIELD_SEPARATOR}" - f"finance@corp.com{FIELD_SEPARATOR}Tue Jan 02 2026{FIELD_SEPARATOR}3" - ) + attach_row = f"222{FIELD_SEPARATOR}Budget Q1{FIELD_SEPARATOR}finance@corp.com{FIELD_SEPARATOR}Tue Jan 02 2026{FIELD_SEPARATOR}3" mock_run.side_effect = [ - "", # flagged - attach_row + "\n", # attachments - "", # unreplied + "", # flagged + attach_row + "\n", # attachments + "", # unreplied ] args = mock_args(days=7) @@ -720,14 +695,11 @@ def test_shows_attachments(self, mock_run, capsys, mock_args): def test_unreplied_skips_noreply(self, mock_run, capsys, mock_args): from mxctl.commands.mail.inbox_tools import cmd_weekly_review - noreply_row = ( - f"333{FIELD_SEPARATOR}Notification{FIELD_SEPARATOR}" - f"noreply@service.com{FIELD_SEPARATOR}Wed Jan 03 2026" - ) + noreply_row = f"333{FIELD_SEPARATOR}Notification{FIELD_SEPARATOR}noreply@service.com{FIELD_SEPARATOR}Wed Jan 03 2026" mock_run.side_effect = [ - "", # flagged - "", # attachments - noreply_row + "\n", # unreplied + "", # flagged + "", # attachments + noreply_row + "\n", # unreplied ] args = mock_args(days=7) @@ -757,6 +729,7 @@ def test_json_output(self, mock_run, capsys, mock_args): # composite.py — _export_bulk with RECORD_SEPARATOR parsing # =========================================================================== + class TestExportBulk: """Test bulk export RECORD_SEPARATOR parsing in _export_bulk.""" @@ -807,8 +780,12 @@ def make_record(msg_id, subject, body): ) result = ( - make_record(1, "First Message", "Body one") + RECORD_SEPARATOR + "\n" - + make_record(2, "Second Message", "Body two") + RECORD_SEPARATOR + "\n" + make_record(1, "First Message", "Body one") + + RECORD_SEPARATOR + + "\n" + + make_record(2, "Second Message", "Body two") + + RECORD_SEPARATOR + + "\n" ) self._run_export_bulk(monkeypatch, result, str(tmp_path)) @@ -827,13 +804,7 @@ def test_empty_result(self, monkeypatch, tmp_path, capsys): def test_skips_malformed_entries(self, monkeypatch, tmp_path, capsys): from mxctl.config import RECORD_SEPARATOR - good = ( - f"10{FIELD_SEPARATOR}" - f"Good Subject{FIELD_SEPARATOR}" - f"x@y.com{FIELD_SEPARATOR}" - f"Monday{FIELD_SEPARATOR}" - f"Content here" - ) + good = f"10{FIELD_SEPARATOR}Good Subject{FIELD_SEPARATOR}x@y.com{FIELD_SEPARATOR}Monday{FIELD_SEPARATOR}Content here" bad = "only-one-field" result = good + RECORD_SEPARATOR + "\n" + bad + RECORD_SEPARATOR + "\n" @@ -848,11 +819,7 @@ def test_body_with_field_separator(self, monkeypatch, tmp_path, capsys): body_with_sep = f"Line 1{FIELD_SEPARATOR}Line 2 (continuation)" record = ( - f"77{FIELD_SEPARATOR}" - f"Complex Body{FIELD_SEPARATOR}" - f"sender@example.com{FIELD_SEPARATOR}" - f"Tuesday{FIELD_SEPARATOR}" - + body_with_sep + f"77{FIELD_SEPARATOR}Complex Body{FIELD_SEPARATOR}sender@example.com{FIELD_SEPARATOR}Tuesday{FIELD_SEPARATOR}" + body_with_sep ) result = record + RECORD_SEPARATOR @@ -871,13 +838,7 @@ def test_export_creates_dest_dir(self, monkeypatch, tmp_path, capsys): new_dir = str(tmp_path / "new_subdir") assert not os.path.exists(new_dir) - msg_data = ( - f"5{FIELD_SEPARATOR}" - f"Test{FIELD_SEPARATOR}" - f"x@y.com{FIELD_SEPARATOR}" - f"Wednesday{FIELD_SEPARATOR}" - f"body" - ) + msg_data = f"5{FIELD_SEPARATOR}Test{FIELD_SEPARATOR}x@y.com{FIELD_SEPARATOR}Wednesday{FIELD_SEPARATOR}body" result = msg_data + RECORD_SEPARATOR self._run_export_bulk(monkeypatch, result, new_dir) @@ -890,13 +851,7 @@ def test_json_output(self, monkeypatch, tmp_path, capsys): from mxctl.commands.mail.composite import _export_bulk from mxctl.config import RECORD_SEPARATOR - msg_data = ( - f"9{FIELD_SEPARATOR}" - f"JSON Test{FIELD_SEPARATOR}" - f"x@y.com{FIELD_SEPARATOR}" - f"Thursday{FIELD_SEPARATOR}" - f"body" - ) + msg_data = f"9{FIELD_SEPARATOR}JSON Test{FIELD_SEPARATOR}x@y.com{FIELD_SEPARATOR}Thursday{FIELD_SEPARATOR}body" result = msg_data + RECORD_SEPARATOR mock_run = Mock(return_value=result) @@ -915,6 +870,7 @@ def test_json_output(self, monkeypatch, tmp_path, capsys): # parse_message_line — new helper in mail_helpers.py # =========================================================================== + class TestParseMessageLine: """Test the parse_message_line() helper added in the refactor.""" @@ -988,6 +944,7 @@ def test_exactly_minimum_fields(self): # Bug fix: to-todoist timeout and token validation # =========================================================================== + class TestTodoistTimeoutAndTokenValidation: """Tests for to-todoist hang fix and token validation.""" @@ -1027,9 +984,7 @@ def test_socket_timeout_on_task_create_dies(self, mock_urlopen, mock_config, moc from mxctl.commands.mail.todoist_integration import cmd_to_todoist mock_config.return_value = {"todoist_api_token": "fake-token"} - mock_run.return_value = ( - f"Subject{FIELD_SEPARATOR}sender@ex.com{FIELD_SEPARATOR}Tuesday" - ) + mock_run.return_value = f"Subject{FIELD_SEPARATOR}sender@ex.com{FIELD_SEPARATOR}Tuesday" mock_urlopen.side_effect = TimeoutError("timed out") args = self._make_args() @@ -1048,9 +1003,7 @@ def test_urlopen_has_timeout_kwarg(self, mock_urlopen, mock_config, mock_run, ca from mxctl.config import APPLESCRIPT_TIMEOUT_SHORT mock_config.return_value = {"todoist_api_token": "fake-token"} - mock_run.return_value = ( - f"Subject{FIELD_SEPARATOR}sender@ex.com{FIELD_SEPARATOR}Tuesday" - ) + mock_run.return_value = f"Subject{FIELD_SEPARATOR}sender@ex.com{FIELD_SEPARATOR}Tuesday" response_payload = {"id": "t1", "content": "Subject"} mock_resp = MagicMock() @@ -1071,6 +1024,7 @@ def test_urlopen_has_timeout_kwarg(self, mock_urlopen, mock_config, mock_run, ca # Bug fix: not-junk uses subject+sender search, not stale ID # =========================================================================== + class TestNotJunkSubjectSenderSearch: """Tests for not-junk search-by-subject+sender fix.""" @@ -1086,10 +1040,7 @@ def test_try_not_junk_uses_subject_sender_when_provided(self): mock_result.stdout = "Test Subject\n" with patch.object(_subprocess, "run", return_value=mock_result) as mock_sp: - result = _try_not_junk_in_mailbox( - "iCloud", "Junk", "INBOX", 99, - subject="Test Subject", sender="sender@example.com" - ) + result = _try_not_junk_in_mailbox("iCloud", "Junk", "INBOX", 99, subject="Test Subject", sender="sender@example.com") assert result == "Test Subject" # The AppleScript passed to osascript should search by subject+sender, not by ID @@ -1110,10 +1061,7 @@ def test_try_not_junk_falls_back_to_id_when_no_subject(self): mock_result.stdout = "Some Subject\n" with patch.object(_subprocess, "run", return_value=mock_result) as mock_sp: - result = _try_not_junk_in_mailbox( - "iCloud", "Junk", "INBOX", 42, - subject="", sender="" - ) + result = _try_not_junk_in_mailbox("iCloud", "Junk", "INBOX", 42, subject="", sender="") assert result == "Some Subject" script = mock_sp.call_args[0][0][2] @@ -1131,10 +1079,7 @@ def test_try_not_junk_returns_none_on_applescript_error(self): mock_result.stderr = "Mail got an error: unexpected internal error" with patch.object(_subprocess, "run", return_value=mock_result): - result = _try_not_junk_in_mailbox( - "iCloud", "Junk", "INBOX", 42, - subject="Subject", sender="sender@example.com" - ) + result = _try_not_junk_in_mailbox("iCloud", "Junk", "INBOX", 42, subject="Subject", sender="sender@example.com") assert result is None # error swallowed, not raised @@ -1164,8 +1109,9 @@ def test_cmd_not_junk_passes_subject_sender_to_helper(self, monkeypatch, capsys) # Verify helper was called with subject and sender keyword args call_kwargs = helper_mock.call_args - assert call_kwargs.kwargs.get("subject") == "My Subject" or \ - (len(call_kwargs[1]) > 0 and call_kwargs[1].get("subject") == "My Subject") + assert call_kwargs.kwargs.get("subject") == "My Subject" or ( + len(call_kwargs[1]) > 0 and call_kwargs[1].get("subject") == "My Subject" + ) assert "alice@example.com" in str(call_kwargs) @@ -1173,6 +1119,7 @@ def test_cmd_not_junk_passes_subject_sender_to_helper(self, monkeypatch, capsys) # inbox_tools.py — additional coverage for missing lines # =========================================================================== + class TestProcessInboxWithAccount: """Tests for process-inbox -a flag (line 67) and category edge cases.""" @@ -1181,11 +1128,7 @@ def test_process_inbox_with_account_flag(self, mock_run, capsys, mock_args): """process-inbox with -a uses single-account script (line 67).""" from mxctl.commands.mail.inbox_tools import cmd_process_inbox - row = ( - f"iCloud{FIELD_SEPARATOR}101{FIELD_SEPARATOR}" - f"Test{FIELD_SEPARATOR}friend@gmail.com{FIELD_SEPARATOR}" - f"Mon{FIELD_SEPARATOR}false" - ) + row = f"iCloud{FIELD_SEPARATOR}101{FIELD_SEPARATOR}Test{FIELD_SEPARATOR}friend@gmail.com{FIELD_SEPARATOR}Mon{FIELD_SEPARATOR}false" mock_run.return_value = row + "\n" # pass account=None to bypass resolve_account (the function reads raw args.account) @@ -1224,7 +1167,7 @@ def test_process_inbox_people_more_than_5(self, mock_run, capsys, mock_args): rows = "" for i in range(7): rows += ( - f"iCloud{FIELD_SEPARATOR}{100+i}{FIELD_SEPARATOR}" + f"iCloud{FIELD_SEPARATOR}{100 + i}{FIELD_SEPARATOR}" f"Person {i}{FIELD_SEPARATOR}p{i}@gmail.com{FIELD_SEPARATOR}" f"Mon{FIELD_SEPARATOR}false\n" ) @@ -1245,7 +1188,7 @@ def test_process_inbox_notifications_more_than_5(self, mock_run, capsys, mock_ar rows = "" for i in range(6): rows += ( - f"iCloud{FIELD_SEPARATOR}{200+i}{FIELD_SEPARATOR}" + f"iCloud{FIELD_SEPARATOR}{200 + i}{FIELD_SEPARATOR}" f"Notification {i}{FIELD_SEPARATOR}noreply@service{i}.com{FIELD_SEPARATOR}" f"Mon{FIELD_SEPARATOR}false\n" ) @@ -1264,15 +1207,9 @@ def test_process_inbox_blank_line_skip(self, mock_run, capsys, mock_args): from mxctl.commands.mail.inbox_tools import cmd_process_inbox good1 = ( - f"iCloud{FIELD_SEPARATOR}10{FIELD_SEPARATOR}" - f"Hello{FIELD_SEPARATOR}alice@example.com{FIELD_SEPARATOR}" - f"Mon{FIELD_SEPARATOR}false" - ) - good2 = ( - f"iCloud{FIELD_SEPARATOR}11{FIELD_SEPARATOR}" - f"World{FIELD_SEPARATOR}bob@example.com{FIELD_SEPARATOR}" - f"Tue{FIELD_SEPARATOR}false" + f"iCloud{FIELD_SEPARATOR}10{FIELD_SEPARATOR}Hello{FIELD_SEPARATOR}alice@example.com{FIELD_SEPARATOR}Mon{FIELD_SEPARATOR}false" ) + good2 = f"iCloud{FIELD_SEPARATOR}11{FIELD_SEPARATOR}World{FIELD_SEPARATOR}bob@example.com{FIELD_SEPARATOR}Tue{FIELD_SEPARATOR}false" # Blank lines BETWEEN two valid lines mock_run.return_value = good1 + "\n\n \n" + good2 + "\n" @@ -1305,9 +1242,7 @@ def test_clean_newsletters_with_account_uses_single_script(self, mock_run, capsy """clean-newsletters with account uses single-account script (line 127).""" from mxctl.commands.mail.inbox_tools import cmd_clean_newsletters - rows = "\n".join( - f"noreply@news.com{FIELD_SEPARATOR}true" for _ in range(3) - ) + rows = "\n".join(f"noreply@news.com{FIELD_SEPARATOR}true" for _ in range(3)) mock_run.return_value = rows + "\n" args = _make_args(account="iCloud", mailbox="INBOX", limit=200) @@ -1322,12 +1257,7 @@ def test_clean_newsletters_blank_line_skip(self, mock_run, capsys, mock_args): """clean-newsletters skips blank lines in output (line 268 area).""" from mxctl.commands.mail.inbox_tools import cmd_clean_newsletters - rows = ( - f"noreply@news.com{FIELD_SEPARATOR}true\n" - f"\n" - f"noreply@news.com{FIELD_SEPARATOR}false\n" - f" \n" - ) + rows = f"noreply@news.com{FIELD_SEPARATOR}true\n\nnoreply@news.com{FIELD_SEPARATOR}false\n \n" mock_run.return_value = rows args = _make_args(account="iCloud", mailbox="INBOX", limit=200) @@ -1345,18 +1275,12 @@ def test_weekly_review_blank_lines_in_flagged(self, mock_run, capsys, mock_args) """weekly-review skips blank lines in flagged results (line 378).""" from mxctl.commands.mail.inbox_tools import cmd_weekly_review - flagged_row1 = ( - f"111{FIELD_SEPARATOR}Action Required{FIELD_SEPARATOR}" - f"boss@work.com{FIELD_SEPARATOR}Mon Jan 01 2026" - ) - flagged_row2 = ( - f"112{FIELD_SEPARATOR}Also Important{FIELD_SEPARATOR}" - f"ceo@work.com{FIELD_SEPARATOR}Tue Jan 02 2026" - ) + flagged_row1 = f"111{FIELD_SEPARATOR}Action Required{FIELD_SEPARATOR}boss@work.com{FIELD_SEPARATOR}Mon Jan 01 2026" + flagged_row2 = f"112{FIELD_SEPARATOR}Also Important{FIELD_SEPARATOR}ceo@work.com{FIELD_SEPARATOR}Tue Jan 02 2026" mock_run.side_effect = [ flagged_row1 + "\n\n \n" + flagged_row2 + "\n", # flagged with blank lines between - "", # attachments - "", # unreplied + "", # attachments + "", # unreplied ] args = mock_args(days=7) @@ -1371,18 +1295,12 @@ def test_weekly_review_blank_lines_in_attachments(self, mock_run, capsys, mock_a """weekly-review skips blank lines in attachment results (line 388).""" from mxctl.commands.mail.inbox_tools import cmd_weekly_review - attach_row1 = ( - f"222{FIELD_SEPARATOR}Budget{FIELD_SEPARATOR}" - f"finance@corp.com{FIELD_SEPARATOR}Tue{FIELD_SEPARATOR}3" - ) - attach_row2 = ( - f"223{FIELD_SEPARATOR}Report{FIELD_SEPARATOR}" - f"hr@corp.com{FIELD_SEPARATOR}Wed{FIELD_SEPARATOR}1" - ) + attach_row1 = f"222{FIELD_SEPARATOR}Budget{FIELD_SEPARATOR}finance@corp.com{FIELD_SEPARATOR}Tue{FIELD_SEPARATOR}3" + attach_row2 = f"223{FIELD_SEPARATOR}Report{FIELD_SEPARATOR}hr@corp.com{FIELD_SEPARATOR}Wed{FIELD_SEPARATOR}1" mock_run.side_effect = [ - "", # flagged - attach_row1 + "\n\n" + attach_row2 + "\n", # attachments with blank between - "", # unreplied + "", # flagged + attach_row1 + "\n\n" + attach_row2 + "\n", # attachments with blank between + "", # unreplied ] args = mock_args(days=7) @@ -1397,18 +1315,12 @@ def test_weekly_review_blank_lines_in_unreplied(self, mock_run, capsys, mock_arg """weekly-review skips blank lines in unreplied results (line 399).""" from mxctl.commands.mail.inbox_tools import cmd_weekly_review - unreplied_row1 = ( - f"333{FIELD_SEPARATOR}Follow Up{FIELD_SEPARATOR}" - f"colleague@work.com{FIELD_SEPARATOR}Wed" - ) - unreplied_row2 = ( - f"334{FIELD_SEPARATOR}Check In{FIELD_SEPARATOR}" - f"friend@gmail.com{FIELD_SEPARATOR}Thu" - ) + unreplied_row1 = f"333{FIELD_SEPARATOR}Follow Up{FIELD_SEPARATOR}colleague@work.com{FIELD_SEPARATOR}Wed" + unreplied_row2 = f"334{FIELD_SEPARATOR}Check In{FIELD_SEPARATOR}friend@gmail.com{FIELD_SEPARATOR}Thu" mock_run.side_effect = [ - "", # flagged - "", # attachments - unreplied_row1 + "\n\n \n" + unreplied_row2 + "\n", # unreplied with blanks between + "", # flagged + "", # attachments + unreplied_row1 + "\n\n \n" + unreplied_row2 + "\n", # unreplied with blanks between ] args = mock_args(days=7) @@ -1424,9 +1336,9 @@ def test_weekly_review_malformed_unreplied_line_skipped(self, mock_run, capsys, from mxctl.commands.mail.inbox_tools import cmd_weekly_review mock_run.side_effect = [ - "", # flagged - "", # attachments - "bad-line-no-sep\n", # unreplied — malformed + "", # flagged + "", # attachments + "bad-line-no-sep\n", # unreplied — malformed ] args = mock_args(days=7) @@ -1441,18 +1353,12 @@ def test_weekly_review_unreplied_filters_noreply(self, mock_run, capsys, mock_ar from mxctl.commands.mail.inbox_tools import cmd_weekly_review # One noreply sender, one real person - noreply_row = ( - f"444{FIELD_SEPARATOR}Auto Notification{FIELD_SEPARATOR}" - f"noreply@service.com{FIELD_SEPARATOR}Thu" - ) - person_row = ( - f"445{FIELD_SEPARATOR}Real Question{FIELD_SEPARATOR}" - f"colleague@work.com{FIELD_SEPARATOR}Thu" - ) + noreply_row = f"444{FIELD_SEPARATOR}Auto Notification{FIELD_SEPARATOR}noreply@service.com{FIELD_SEPARATOR}Thu" + person_row = f"445{FIELD_SEPARATOR}Real Question{FIELD_SEPARATOR}colleague@work.com{FIELD_SEPARATOR}Thu" mock_run.side_effect = [ - "", # flagged - "", # attachments - noreply_row + "\n" + person_row + "\n", # unreplied + "", # flagged + "", # attachments + noreply_row + "\n" + person_row + "\n", # unreplied ] args = mock_args(days=7) @@ -1469,14 +1375,11 @@ def test_weekly_review_flagged_more_than_10(self, mock_run, capsys, mock_args): rows = "" for i in range(12): - rows += ( - f"{i}{FIELD_SEPARATOR}Flag {i}{FIELD_SEPARATOR}" - f"s{i}@x.com{FIELD_SEPARATOR}Mon\n" - ) + rows += f"{i}{FIELD_SEPARATOR}Flag {i}{FIELD_SEPARATOR}s{i}@x.com{FIELD_SEPARATOR}Mon\n" mock_run.side_effect = [ - rows, # flagged - "", # attachments - "", # unreplied + rows, # flagged + "", # attachments + "", # unreplied ] args = mock_args(days=7) @@ -1492,14 +1395,11 @@ def test_weekly_review_attachments_more_than_10(self, mock_run, capsys, mock_arg rows = "" for i in range(11): - rows += ( - f"{i}{FIELD_SEPARATOR}Attach {i}{FIELD_SEPARATOR}" - f"s{i}@x.com{FIELD_SEPARATOR}Mon{FIELD_SEPARATOR}2\n" - ) + rows += f"{i}{FIELD_SEPARATOR}Attach {i}{FIELD_SEPARATOR}s{i}@x.com{FIELD_SEPARATOR}Mon{FIELD_SEPARATOR}2\n" mock_run.side_effect = [ - "", # flagged - rows, # attachments - "", # unreplied + "", # flagged + rows, # attachments + "", # unreplied ] args = mock_args(days=7) @@ -1515,14 +1415,11 @@ def test_weekly_review_unreplied_more_than_10(self, mock_run, capsys, mock_args) rows = "" for i in range(13): - rows += ( - f"{i}{FIELD_SEPARATOR}Reply {i}{FIELD_SEPARATOR}" - f"p{i}@gmail.com{FIELD_SEPARATOR}Mon\n" - ) + rows += f"{i}{FIELD_SEPARATOR}Reply {i}{FIELD_SEPARATOR}p{i}@gmail.com{FIELD_SEPARATOR}Mon\n" mock_run.side_effect = [ - "", # flagged - "", # attachments - rows, # unreplied + "", # flagged + "", # attachments + rows, # unreplied ] args = mock_args(days=7) @@ -1536,14 +1433,11 @@ def test_weekly_review_suggested_actions_unreplied(self, mock_run, capsys, mock_ """weekly-review shows 'Reply to pending messages' when unreplied exist (line 456).""" from mxctl.commands.mail.inbox_tools import cmd_weekly_review - person_row = ( - f"500{FIELD_SEPARATOR}Need Response{FIELD_SEPARATOR}" - f"colleague@work.com{FIELD_SEPARATOR}Mon" - ) + person_row = f"500{FIELD_SEPARATOR}Need Response{FIELD_SEPARATOR}colleague@work.com{FIELD_SEPARATOR}Mon" mock_run.side_effect = [ - "", # flagged - "", # attachments - person_row + "\n", # unreplied + "", # flagged + "", # attachments + person_row + "\n", # unreplied ] args = mock_args(days=7) @@ -1557,14 +1451,11 @@ def test_weekly_review_suggested_actions_attachments(self, mock_run, capsys, moc """weekly-review shows attachment review suggestion when attachments exist.""" from mxctl.commands.mail.inbox_tools import cmd_weekly_review - attach_row = ( - f"600{FIELD_SEPARATOR}Invoice{FIELD_SEPARATOR}" - f"billing@corp.com{FIELD_SEPARATOR}Mon{FIELD_SEPARATOR}1" - ) + attach_row = f"600{FIELD_SEPARATOR}Invoice{FIELD_SEPARATOR}billing@corp.com{FIELD_SEPARATOR}Mon{FIELD_SEPARATOR}1" mock_run.side_effect = [ - "", # flagged - attach_row + "\n", # attachments - "", # unreplied + "", # flagged + attach_row + "\n", # attachments + "", # unreplied ] args = mock_args(days=7) diff --git a/tests/test_templates.py b/tests/test_templates.py index 32ac594..c44fa54 100644 --- a/tests/test_templates.py +++ b/tests/test_templates.py @@ -10,6 +10,7 @@ # Helpers # --------------------------------------------------------------------------- + def _make_args(**kwargs): defaults = {"json": False} defaults.update(kwargs) @@ -20,6 +21,7 @@ def _make_args(**kwargs): # _load_templates / _save_templates # --------------------------------------------------------------------------- + class TestLoadSaveTemplates: def test_load_empty_when_no_file(self, monkeypatch, tmp_path): from mxctl.commands.mail.templates import _load_templates @@ -57,6 +59,7 @@ def test_load_corrupt_json_returns_empty(self, monkeypatch, tmp_path): # cmd_templates_list # --------------------------------------------------------------------------- + class TestTemplatesList: def test_list_empty(self, monkeypatch, capsys, tmp_path): from mxctl.commands.mail.templates import cmd_templates_list @@ -102,6 +105,7 @@ def test_list_json_output(self, monkeypatch, capsys, tmp_path): # cmd_templates_create # --------------------------------------------------------------------------- + class TestTemplatesCreate: def test_create_with_flags(self, monkeypatch, capsys, tmp_path): from mxctl.commands.mail.templates import _load_templates, cmd_templates_create @@ -126,6 +130,7 @@ def test_create_with_flags(self, monkeypatch, capsys, tmp_path): # cmd_templates_show # --------------------------------------------------------------------------- + class TestTemplatesShow: def test_show_existing(self, monkeypatch, capsys, tmp_path): from mxctl.commands.mail.templates import _save_templates, cmd_templates_show @@ -158,6 +163,7 @@ def test_show_nonexistent_dies(self, monkeypatch, tmp_path): # cmd_templates_delete # --------------------------------------------------------------------------- + class TestTemplatesDelete: def test_delete_existing(self, monkeypatch, capsys, tmp_path): from mxctl.commands.mail.templates import _load_templates, _save_templates, cmd_templates_delete