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.
- {mxctl-0.4.1 → mxctl-0.4.2}/ARCHITECTURE.md +1 -1
- {mxctl-0.4.1 → mxctl-0.4.2}/CHANGELOG.md +9 -0
- {mxctl-0.4.1 → mxctl-0.4.2}/PKG-INFO +1 -1
- mxctl-0.4.2/demo/init-demo.gif +0 -0
- mxctl-0.4.2/demo/unsubscribe-demo.gif +0 -0
- {mxctl-0.4.1 → mxctl-0.4.2}/pyproject.toml +1 -1
- {mxctl-0.4.1 → mxctl-0.4.2}/src/mxctl/__init__.py +1 -1
- {mxctl-0.4.1 → mxctl-0.4.2}/src/mxctl/commands/mail/actions.py +4 -13
- {mxctl-0.4.1 → mxctl-0.4.2}/src/mxctl/commands/mail/system.py +1 -13
- {mxctl-0.4.1 → mxctl-0.4.2}/tests/test_100_coverage.py +4 -4
- mxctl-0.4.1/demo/init-demo.gif +0 -0
- mxctl-0.4.1/demo/unsubscribe-demo.gif +0 -0
- {mxctl-0.4.1 → mxctl-0.4.2}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {mxctl-0.4.1 → mxctl-0.4.2}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {mxctl-0.4.1 → mxctl-0.4.2}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {mxctl-0.4.1 → mxctl-0.4.2}/.github/dependabot.yml +0 -0
- {mxctl-0.4.1 → mxctl-0.4.2}/.github/workflows/ci.yml +0 -0
- {mxctl-0.4.1 → mxctl-0.4.2}/.github/workflows/release.yml +0 -0
- {mxctl-0.4.1 → mxctl-0.4.2}/.gitignore +0 -0
- {mxctl-0.4.1 → mxctl-0.4.2}/.pre-commit-config.yaml +0 -0
- {mxctl-0.4.1 → mxctl-0.4.2}/CODE_OF_CONDUCT.md +0 -0
- {mxctl-0.4.1 → mxctl-0.4.2}/CONTRIBUTING.md +0 -0
- {mxctl-0.4.1 → mxctl-0.4.2}/LICENSE +0 -0
- {mxctl-0.4.1 → mxctl-0.4.2}/README.md +0 -0
- {mxctl-0.4.1 → mxctl-0.4.2}/SECURITY.md +0 -0
- {mxctl-0.4.1 → mxctl-0.4.2}/demo/ai-demo.gif +0 -0
- {mxctl-0.4.1 → mxctl-0.4.2}/demo/batch-delete-demo.gif +0 -0
- {mxctl-0.4.1 → mxctl-0.4.2}/demo/demo.gif +0 -0
- {mxctl-0.4.1 → mxctl-0.4.2}/demo/demo.tape +0 -0
- {mxctl-0.4.1 → mxctl-0.4.2}/demo/preview.png +0 -0
- {mxctl-0.4.1 → mxctl-0.4.2}/demo/splash.png +0 -0
- {mxctl-0.4.1 → mxctl-0.4.2}/src/mxctl/__main__.py +0 -0
- {mxctl-0.4.1 → mxctl-0.4.2}/src/mxctl/api.py +0 -0
- {mxctl-0.4.1 → mxctl-0.4.2}/src/mxctl/commands/__init__.py +0 -0
- {mxctl-0.4.1 → mxctl-0.4.2}/src/mxctl/commands/mail/__init__.py +0 -0
- {mxctl-0.4.1 → mxctl-0.4.2}/src/mxctl/commands/mail/accounts.py +0 -0
- {mxctl-0.4.1 → mxctl-0.4.2}/src/mxctl/commands/mail/ai.py +0 -0
- {mxctl-0.4.1 → mxctl-0.4.2}/src/mxctl/commands/mail/analytics.py +0 -0
- {mxctl-0.4.1 → mxctl-0.4.2}/src/mxctl/commands/mail/attachments.py +0 -0
- {mxctl-0.4.1 → mxctl-0.4.2}/src/mxctl/commands/mail/batch.py +0 -0
- {mxctl-0.4.1 → mxctl-0.4.2}/src/mxctl/commands/mail/compose.py +0 -0
- {mxctl-0.4.1 → mxctl-0.4.2}/src/mxctl/commands/mail/composite.py +0 -0
- {mxctl-0.4.1 → mxctl-0.4.2}/src/mxctl/commands/mail/inbox_tools.py +0 -0
- {mxctl-0.4.1 → mxctl-0.4.2}/src/mxctl/commands/mail/manage.py +0 -0
- {mxctl-0.4.1 → mxctl-0.4.2}/src/mxctl/commands/mail/messages.py +0 -0
- {mxctl-0.4.1 → mxctl-0.4.2}/src/mxctl/commands/mail/setup.py +0 -0
- {mxctl-0.4.1 → mxctl-0.4.2}/src/mxctl/commands/mail/templates.py +0 -0
- {mxctl-0.4.1 → mxctl-0.4.2}/src/mxctl/commands/mail/todoist_integration.py +0 -0
- {mxctl-0.4.1 → mxctl-0.4.2}/src/mxctl/commands/mail/undo.py +0 -0
- {mxctl-0.4.1 → mxctl-0.4.2}/src/mxctl/config.py +0 -0
- {mxctl-0.4.1 → mxctl-0.4.2}/src/mxctl/main.py +0 -0
- {mxctl-0.4.1 → mxctl-0.4.2}/src/mxctl/util/__init__.py +0 -0
- {mxctl-0.4.1 → mxctl-0.4.2}/src/mxctl/util/applescript.py +0 -0
- {mxctl-0.4.1 → mxctl-0.4.2}/src/mxctl/util/applescript_templates.py +0 -0
- {mxctl-0.4.1 → mxctl-0.4.2}/src/mxctl/util/dates.py +0 -0
- {mxctl-0.4.1 → mxctl-0.4.2}/src/mxctl/util/formatting.py +0 -0
- {mxctl-0.4.1 → mxctl-0.4.2}/src/mxctl/util/mail_helpers.py +0 -0
- {mxctl-0.4.1 → mxctl-0.4.2}/tests/__init__.py +0 -0
- {mxctl-0.4.1 → mxctl-0.4.2}/tests/conftest.py +0 -0
- {mxctl-0.4.1 → mxctl-0.4.2}/tests/test_ai_classification.py +0 -0
- {mxctl-0.4.1 → mxctl-0.4.2}/tests/test_applescript.py +0 -0
- {mxctl-0.4.1 → mxctl-0.4.2}/tests/test_commands.py +0 -0
- {mxctl-0.4.1 → mxctl-0.4.2}/tests/test_compose_batch_errors.py +0 -0
- {mxctl-0.4.1 → mxctl-0.4.2}/tests/test_config.py +0 -0
- {mxctl-0.4.1 → mxctl-0.4.2}/tests/test_count.py +0 -0
- {mxctl-0.4.1 → mxctl-0.4.2}/tests/test_dates.py +0 -0
- {mxctl-0.4.1 → mxctl-0.4.2}/tests/test_error_paths.py +0 -0
- {mxctl-0.4.1 → mxctl-0.4.2}/tests/test_formatting.py +0 -0
- {mxctl-0.4.1 → mxctl-0.4.2}/tests/test_mail_helpers.py +0 -0
- {mxctl-0.4.1 → mxctl-0.4.2}/tests/test_manage.py +0 -0
- {mxctl-0.4.1 → mxctl-0.4.2}/tests/test_new_coverage.py +0 -0
- {mxctl-0.4.1 → mxctl-0.4.2}/tests/test_resolve_context.py +0 -0
- {mxctl-0.4.1 → mxctl-0.4.2}/tests/test_setup.py +0 -0
- {mxctl-0.4.1 → mxctl-0.4.2}/tests/test_stats_undo.py +0 -0
- {mxctl-0.4.1 → mxctl-0.4.2}/tests/test_templates.py +0 -0
- {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.
|
|
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
|
|
Binary file
|
|
Binary file
|
|
@@ -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 =
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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)
|
mxctl-0.4.1/demo/init-demo.gif
DELETED
|
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
|
|
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
|