mxctl 0.4.1__tar.gz → 0.4.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. {mxctl-0.4.1 → mxctl-0.4.2}/ARCHITECTURE.md +1 -1
  2. {mxctl-0.4.1 → mxctl-0.4.2}/CHANGELOG.md +9 -0
  3. {mxctl-0.4.1 → mxctl-0.4.2}/PKG-INFO +1 -1
  4. mxctl-0.4.2/demo/init-demo.gif +0 -0
  5. mxctl-0.4.2/demo/unsubscribe-demo.gif +0 -0
  6. {mxctl-0.4.1 → mxctl-0.4.2}/pyproject.toml +1 -1
  7. {mxctl-0.4.1 → mxctl-0.4.2}/src/mxctl/__init__.py +1 -1
  8. {mxctl-0.4.1 → mxctl-0.4.2}/src/mxctl/commands/mail/actions.py +4 -13
  9. {mxctl-0.4.1 → mxctl-0.4.2}/src/mxctl/commands/mail/system.py +1 -13
  10. {mxctl-0.4.1 → mxctl-0.4.2}/tests/test_100_coverage.py +4 -4
  11. mxctl-0.4.1/demo/init-demo.gif +0 -0
  12. mxctl-0.4.1/demo/unsubscribe-demo.gif +0 -0
  13. {mxctl-0.4.1 → mxctl-0.4.2}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  14. {mxctl-0.4.1 → mxctl-0.4.2}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  15. {mxctl-0.4.1 → mxctl-0.4.2}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  16. {mxctl-0.4.1 → mxctl-0.4.2}/.github/dependabot.yml +0 -0
  17. {mxctl-0.4.1 → mxctl-0.4.2}/.github/workflows/ci.yml +0 -0
  18. {mxctl-0.4.1 → mxctl-0.4.2}/.github/workflows/release.yml +0 -0
  19. {mxctl-0.4.1 → mxctl-0.4.2}/.gitignore +0 -0
  20. {mxctl-0.4.1 → mxctl-0.4.2}/.pre-commit-config.yaml +0 -0
  21. {mxctl-0.4.1 → mxctl-0.4.2}/CODE_OF_CONDUCT.md +0 -0
  22. {mxctl-0.4.1 → mxctl-0.4.2}/CONTRIBUTING.md +0 -0
  23. {mxctl-0.4.1 → mxctl-0.4.2}/LICENSE +0 -0
  24. {mxctl-0.4.1 → mxctl-0.4.2}/README.md +0 -0
  25. {mxctl-0.4.1 → mxctl-0.4.2}/SECURITY.md +0 -0
  26. {mxctl-0.4.1 → mxctl-0.4.2}/demo/ai-demo.gif +0 -0
  27. {mxctl-0.4.1 → mxctl-0.4.2}/demo/batch-delete-demo.gif +0 -0
  28. {mxctl-0.4.1 → mxctl-0.4.2}/demo/demo.gif +0 -0
  29. {mxctl-0.4.1 → mxctl-0.4.2}/demo/demo.tape +0 -0
  30. {mxctl-0.4.1 → mxctl-0.4.2}/demo/preview.png +0 -0
  31. {mxctl-0.4.1 → mxctl-0.4.2}/demo/splash.png +0 -0
  32. {mxctl-0.4.1 → mxctl-0.4.2}/src/mxctl/__main__.py +0 -0
  33. {mxctl-0.4.1 → mxctl-0.4.2}/src/mxctl/api.py +0 -0
  34. {mxctl-0.4.1 → mxctl-0.4.2}/src/mxctl/commands/__init__.py +0 -0
  35. {mxctl-0.4.1 → mxctl-0.4.2}/src/mxctl/commands/mail/__init__.py +0 -0
  36. {mxctl-0.4.1 → mxctl-0.4.2}/src/mxctl/commands/mail/accounts.py +0 -0
  37. {mxctl-0.4.1 → mxctl-0.4.2}/src/mxctl/commands/mail/ai.py +0 -0
  38. {mxctl-0.4.1 → mxctl-0.4.2}/src/mxctl/commands/mail/analytics.py +0 -0
  39. {mxctl-0.4.1 → mxctl-0.4.2}/src/mxctl/commands/mail/attachments.py +0 -0
  40. {mxctl-0.4.1 → mxctl-0.4.2}/src/mxctl/commands/mail/batch.py +0 -0
  41. {mxctl-0.4.1 → mxctl-0.4.2}/src/mxctl/commands/mail/compose.py +0 -0
  42. {mxctl-0.4.1 → mxctl-0.4.2}/src/mxctl/commands/mail/composite.py +0 -0
  43. {mxctl-0.4.1 → mxctl-0.4.2}/src/mxctl/commands/mail/inbox_tools.py +0 -0
  44. {mxctl-0.4.1 → mxctl-0.4.2}/src/mxctl/commands/mail/manage.py +0 -0
  45. {mxctl-0.4.1 → mxctl-0.4.2}/src/mxctl/commands/mail/messages.py +0 -0
  46. {mxctl-0.4.1 → mxctl-0.4.2}/src/mxctl/commands/mail/setup.py +0 -0
  47. {mxctl-0.4.1 → mxctl-0.4.2}/src/mxctl/commands/mail/templates.py +0 -0
  48. {mxctl-0.4.1 → mxctl-0.4.2}/src/mxctl/commands/mail/todoist_integration.py +0 -0
  49. {mxctl-0.4.1 → mxctl-0.4.2}/src/mxctl/commands/mail/undo.py +0 -0
  50. {mxctl-0.4.1 → mxctl-0.4.2}/src/mxctl/config.py +0 -0
  51. {mxctl-0.4.1 → mxctl-0.4.2}/src/mxctl/main.py +0 -0
  52. {mxctl-0.4.1 → mxctl-0.4.2}/src/mxctl/util/__init__.py +0 -0
  53. {mxctl-0.4.1 → mxctl-0.4.2}/src/mxctl/util/applescript.py +0 -0
  54. {mxctl-0.4.1 → mxctl-0.4.2}/src/mxctl/util/applescript_templates.py +0 -0
  55. {mxctl-0.4.1 → mxctl-0.4.2}/src/mxctl/util/dates.py +0 -0
  56. {mxctl-0.4.1 → mxctl-0.4.2}/src/mxctl/util/formatting.py +0 -0
  57. {mxctl-0.4.1 → mxctl-0.4.2}/src/mxctl/util/mail_helpers.py +0 -0
  58. {mxctl-0.4.1 → mxctl-0.4.2}/tests/__init__.py +0 -0
  59. {mxctl-0.4.1 → mxctl-0.4.2}/tests/conftest.py +0 -0
  60. {mxctl-0.4.1 → mxctl-0.4.2}/tests/test_ai_classification.py +0 -0
  61. {mxctl-0.4.1 → mxctl-0.4.2}/tests/test_applescript.py +0 -0
  62. {mxctl-0.4.1 → mxctl-0.4.2}/tests/test_commands.py +0 -0
  63. {mxctl-0.4.1 → mxctl-0.4.2}/tests/test_compose_batch_errors.py +0 -0
  64. {mxctl-0.4.1 → mxctl-0.4.2}/tests/test_config.py +0 -0
  65. {mxctl-0.4.1 → mxctl-0.4.2}/tests/test_count.py +0 -0
  66. {mxctl-0.4.1 → mxctl-0.4.2}/tests/test_dates.py +0 -0
  67. {mxctl-0.4.1 → mxctl-0.4.2}/tests/test_error_paths.py +0 -0
  68. {mxctl-0.4.1 → mxctl-0.4.2}/tests/test_formatting.py +0 -0
  69. {mxctl-0.4.1 → mxctl-0.4.2}/tests/test_mail_helpers.py +0 -0
  70. {mxctl-0.4.1 → mxctl-0.4.2}/tests/test_manage.py +0 -0
  71. {mxctl-0.4.1 → mxctl-0.4.2}/tests/test_new_coverage.py +0 -0
  72. {mxctl-0.4.1 → mxctl-0.4.2}/tests/test_resolve_context.py +0 -0
  73. {mxctl-0.4.1 → mxctl-0.4.2}/tests/test_setup.py +0 -0
  74. {mxctl-0.4.1 → mxctl-0.4.2}/tests/test_stats_undo.py +0 -0
  75. {mxctl-0.4.1 → mxctl-0.4.2}/tests/test_templates.py +0 -0
  76. {mxctl-0.4.1 → mxctl-0.4.2}/tests/test_wave1_coverage.py +0 -0
@@ -87,4 +87,4 @@ Batch commands (`batch-read`, `batch-move`, `batch-delete`, `batch-flag`) log th
87
87
 
88
88
  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.
89
89
 
90
- 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`.
90
+ 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. Five unreachable defensive guards are marked with `# pragma: no cover`.
@@ -6,6 +6,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.4.2] - 2026-02-28
10
+
11
+ ### Fixed
12
+
13
+ - `mxctl --version` now reports the correct version at runtime (was stuck at `0.3.0` since the rename)
14
+ - Consolidated duplicate `get_headers()` / `get_raw_headers()` code in system.py
15
+ - Moved delayed inline imports to module level in actions.py (subprocess, sys, get_gmail_accounts)
16
+ - Fixed ARCHITECTURE.md stale claim: "Six" → "Five" pragma:no cover guards
17
+
9
18
  ## [0.4.1] - 2026-02-27
10
19
 
11
20
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mxctl
3
- Version: 0.4.1
3
+ Version: 0.4.2
4
4
  Summary: Apple Mail from your terminal
5
5
  Project-URL: Homepage, https://github.com/Jscoats/mxctl
6
6
  Project-URL: Repository, https://github.com/Jscoats/mxctl
Binary file
Binary file
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "mxctl"
7
- version = "0.4.1"
7
+ version = "0.4.2"
8
8
  description = "Apple Mail from your terminal"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,3 +1,3 @@
1
1
  """mxctl: Apple Mail from your terminal."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.4.2"
@@ -5,11 +5,12 @@ import re
5
5
  import socket
6
6
  import ssl
7
7
  import subprocess
8
+ import sys
8
9
  import urllib.error
9
10
  import urllib.parse
10
11
  import urllib.request
11
12
 
12
- from mxctl.config import APPLESCRIPT_TIMEOUT_SHORT, FIELD_SEPARATOR, resolve_account
13
+ from mxctl.config import APPLESCRIPT_TIMEOUT_SHORT, FIELD_SEPARATOR, get_gmail_accounts, resolve_account
13
14
  from mxctl.util.applescript import escape, run, validate_msg_id
14
15
  from mxctl.util.applescript_templates import set_message_property
15
16
  from mxctl.util.formatting import die, format_output, truncate
@@ -355,8 +356,6 @@ def cmd_unsubscribe(args) -> None:
355
356
 
356
357
  def cmd_junk(args) -> None:
357
358
  """Mark a message as junk or spam."""
358
- import sys
359
-
360
359
  account, mailbox, _, _ = resolve_message_context(args)
361
360
  message_id = validate_msg_id(args.id)
362
361
 
@@ -397,8 +396,6 @@ def _try_not_junk_in_mailbox(
397
396
  Returns the message subject string on success, None if the message or mailbox
398
397
  was not found.
399
398
  """
400
- import subprocess as _subprocess
401
-
402
399
  if subject and sender:
403
400
  # Search by subject + sender — avoids stale-ID problem after cross-mailbox moves
404
401
  subj_esc = escape(subject)
@@ -429,7 +426,7 @@ def _try_not_junk_in_mailbox(
429
426
  return msgSubject
430
427
  end tell
431
428
  """
432
- result = _subprocess.run(
429
+ result = subprocess.run(
433
430
  ["osascript", "-e", script],
434
431
  capture_output=True,
435
432
  text=True,
@@ -457,8 +454,6 @@ def not_junk(account: str, message_id: int, custom_mailbox: str | None = None) -
457
454
 
458
455
  Returns a result dict on success, or raises SystemExit on failure.
459
456
  """
460
- import sys
461
-
462
457
  acct_escaped = escape(account)
463
458
  inbox_mailbox = resolve_mailbox(account, "INBOX")
464
459
  inbox_escaped = escape(inbox_mailbox)
@@ -470,8 +465,6 @@ def not_junk(account: str, message_id: int, custom_mailbox: str | None = None) -
470
465
  orig_subject = ""
471
466
  orig_sender = ""
472
467
  try:
473
- import subprocess as _subprocess
474
-
475
468
  fetch_script = f"""
476
469
  tell application "Mail"
477
470
  set acct to account "{acct_escaped}"
@@ -480,7 +473,7 @@ def not_junk(account: str, message_id: int, custom_mailbox: str | None = None) -
480
473
  return (subject of theMsg) & "{FIELD_SEPARATOR}" & (sender of theMsg)
481
474
  end tell
482
475
  """
483
- fetch_result = _subprocess.run(
476
+ fetch_result = subprocess.run(
484
477
  ["osascript", "-e", fetch_script],
485
478
  capture_output=True,
486
479
  text=True,
@@ -500,8 +493,6 @@ def not_junk(account: str, message_id: int, custom_mailbox: str | None = None) -
500
493
  # Build a prioritized list of junk folder candidates
501
494
  junk_primary = resolve_mailbox(account, "Junk")
502
495
  candidates = [junk_primary]
503
- from mxctl.config import get_gmail_accounts
504
-
505
496
  if account in get_gmail_accounts():
506
497
  if "[Gmail]/Spam" not in candidates:
507
498
  candidates.append("[Gmail]/Spam")
@@ -39,19 +39,7 @@ def cmd_check(args) -> None:
39
39
 
40
40
  def get_headers(account: str, mailbox: str, message_id: int) -> dict:
41
41
  """Return parsed email headers dict for the given message."""
42
- acct_escaped = escape(account)
43
- mb_escaped = escape(mailbox)
44
-
45
- script = f"""
46
- tell application "Mail"
47
- set mb to mailbox "{mb_escaped}" of account "{acct_escaped}"
48
- set theMsg to first message of mb whose id is {message_id}
49
- return all headers of theMsg
50
- end tell
51
- """
52
-
53
- result = run(script)
54
- return parse_email_headers(result)
42
+ return parse_email_headers(get_raw_headers(account, mailbox, message_id))
55
43
 
56
44
 
57
45
  def get_raw_headers(account: str, mailbox: str, message_id: int) -> str:
@@ -486,7 +486,7 @@ class TestNotJunkGmailPaths:
486
486
  from mxctl.commands.mail.actions import cmd_not_junk
487
487
 
488
488
  monkeypatch.setattr("mxctl.commands.mail.actions.resolve_account", lambda _: "Gmail")
489
- monkeypatch.setattr("mxctl.config.get_gmail_accounts", lambda: ["Gmail"])
489
+ monkeypatch.setattr("mxctl.commands.mail.actions.get_gmail_accounts", lambda: ["Gmail"])
490
490
  # resolve_mailbox("Junk") returns "[Gmail]/Spam" for Gmail, so candidates start with that
491
491
  monkeypatch.setattr("mxctl.commands.mail.actions.resolve_mailbox", lambda acct, mb: "[Gmail]/Spam" if mb == "Junk" else mb)
492
492
 
@@ -517,7 +517,7 @@ class TestNotJunkGmailPaths:
517
517
  from mxctl.commands.mail.actions import cmd_not_junk
518
518
 
519
519
  monkeypatch.setattr("mxctl.commands.mail.actions.resolve_account", lambda _: "iCloud")
520
- monkeypatch.setattr("mxctl.config.get_gmail_accounts", lambda: [])
520
+ monkeypatch.setattr("mxctl.commands.mail.actions.get_gmail_accounts", lambda: [])
521
521
  monkeypatch.setattr("mxctl.commands.mail.actions.resolve_mailbox", lambda acct, mb: mb)
522
522
 
523
523
  # Mock the subprocess for fetching original subject/sender - SUCCEEDS
@@ -542,7 +542,7 @@ class TestNotJunkGmailPaths:
542
542
  from mxctl.commands.mail.actions import cmd_not_junk
543
543
 
544
544
  monkeypatch.setattr("mxctl.commands.mail.actions.resolve_account", lambda _: "iCloud")
545
- monkeypatch.setattr("mxctl.config.get_gmail_accounts", lambda: [])
545
+ monkeypatch.setattr("mxctl.commands.mail.actions.get_gmail_accounts", lambda: [])
546
546
  monkeypatch.setattr("mxctl.commands.mail.actions.resolve_mailbox", lambda acct, mb: mb)
547
547
 
548
548
  # Mock subprocess.run to raise an exception (e.g. OSError)
@@ -564,7 +564,7 @@ class TestNotJunkGmailPaths:
564
564
  from mxctl.commands.mail.actions import cmd_not_junk
565
565
 
566
566
  monkeypatch.setattr("mxctl.commands.mail.actions.resolve_account", lambda _: "Gmail")
567
- monkeypatch.setattr("mxctl.config.get_gmail_accounts", lambda: ["Gmail"])
567
+ monkeypatch.setattr("mxctl.commands.mail.actions.get_gmail_accounts", lambda: ["Gmail"])
568
568
  # resolve_mailbox returns "Junk" as-is (not mapping to [Gmail]/Spam),
569
569
  # so [Gmail]/Spam is NOT already in candidates and gets appended
570
570
  monkeypatch.setattr("mxctl.commands.mail.actions.resolve_mailbox", lambda acct, mb: mb)
Binary file
Binary file
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes