aboutsummaryrefslogtreecommitdiff
path: root/.ai/scripts/tests/test_cmail_action.py
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-08 23:23:31 -0500
committerCraig Jennings <c@cjennings.net>2026-05-08 23:23:31 -0500
commit65c962e01f0ec7cefe70157f4f11f54638cdc995 (patch)
treea64d7a8bd466cb08e77829ca86e25a221aa64710 /.ai/scripts/tests/test_cmail_action.py
parent1abae87acaba85453ef9b7e1eafe0d6e8e22c4e5 (diff)
downloadrulesets-65c962e01f0ec7cefe70157f4f11f54638cdc995.tar.gz
rulesets-65c962e01f0ec7cefe70157f4f11f54638cdc995.zip
feat: Add cmail IMAP action script and test suite
Add cmail-action.py for IMAP triage operations against Proton Mail Bridge (list-unread, read, mark-read, star, unstar, trash, send, folders) mirroring the Gmail MCP workflow. Also add comprehensive tests for cmail-action, gmail-fetch-attachments, and maildir-flag-manager scripts.
Diffstat (limited to '.ai/scripts/tests/test_cmail_action.py')
-rw-r--r--.ai/scripts/tests/test_cmail_action.py669
1 files changed, 669 insertions, 0 deletions
diff --git a/.ai/scripts/tests/test_cmail_action.py b/.ai/scripts/tests/test_cmail_action.py
new file mode 100644
index 0000000..3f77ca3
--- /dev/null
+++ b/.ai/scripts/tests/test_cmail_action.py
@@ -0,0 +1,669 @@
+"""Tests for cmail-action.py.
+
+Covers:
+- Pure helpers: parse_fetch_metadata, extract_body, _decode_header
+- I/O commands: cmd_list_unread, cmd_read, cmd_trash, _store wrappers,
+ cmd_folders
+- Argparse dispatch (subprocess --help)
+
+Strategy: import the script via importlib.util (filename has a hyphen,
+so a regular `import cmail_action` won't work). Patch
+cmail_action.connect to return a configured MagicMock IMAP4 instance
+for the I/O tests. connect() itself is testability-blocked (network +
+SSL + file I/O); manual smoke testing covers it.
+"""
+
+from __future__ import annotations
+
+import email
+import importlib.util
+import json
+import subprocess
+import sys
+from email.message import EmailMessage
+from email.mime.application import MIMEApplication
+from email.mime.multipart import MIMEMultipart
+from email.mime.text import MIMEText
+from email.policy import default as default_policy
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+SCRIPT_PATH = Path(__file__).resolve().parent.parent / "cmail-action.py"
+
+
+def _load_module():
+ spec = importlib.util.spec_from_file_location("cmail_action", str(SCRIPT_PATH))
+ mod = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(mod)
+ return mod
+
+
+@pytest.fixture(scope="module")
+def cmail_action():
+ return _load_module()
+
+
+# ---------------------------------------------------------------------------
+# parse_fetch_metadata — pure
+# ---------------------------------------------------------------------------
+
+class TestParseFetchMetadata:
+
+ def test_normal_flags_and_size(self, cmail_action):
+ meta = "1 (FLAGS (\\Seen) RFC822.SIZE 12345)"
+ assert cmail_action.parse_fetch_metadata(meta) == {
+ "flags": "\\Seen",
+ "size": 12345,
+ }
+
+ def test_boundary_empty_flags_zero_size(self, cmail_action):
+ meta = "1 (FLAGS () RFC822.SIZE 0)"
+ assert cmail_action.parse_fetch_metadata(meta) == {
+ "flags": "",
+ "size": 0,
+ }
+
+ def test_boundary_multiple_flags(self, cmail_action):
+ meta = "1 (FLAGS (\\Seen \\Flagged \\Recent) RFC822.SIZE 999)"
+ result = cmail_action.parse_fetch_metadata(meta)
+ assert result["flags"] == "\\Seen \\Flagged \\Recent"
+ assert result["size"] == 999
+
+ def test_boundary_no_size_key(self, cmail_action):
+ meta = "1 (FLAGS (\\Recent))"
+ result = cmail_action.parse_fetch_metadata(meta)
+ assert result["flags"] == "\\Recent"
+ assert result["size"] is None
+
+ def test_boundary_no_flags_key(self, cmail_action):
+ meta = "1 (RFC822.SIZE 500)"
+ result = cmail_action.parse_fetch_metadata(meta)
+ assert result["flags"] == ""
+ assert result["size"] == 500
+
+ def test_boundary_metadata_split_across_chunks_concatenated(self, cmail_action):
+ # The bug fix that motivated extracting this helper: imaplib returns
+ # FLAGS / RFC822.SIZE in a non-tuple chunk after the BODY literal
+ # closes. cmd_list_unread now concatenates all chunks, then
+ # parse_fetch_metadata sees the combined string. Verify the parser
+ # handles the combined shape.
+ combined = ("3315 (BODY[HEADER.FIELDS (FROM TO)] {123}"
+ " FLAGS () RFC822.SIZE 65546)")
+ result = cmail_action.parse_fetch_metadata(combined)
+ assert result["flags"] == ""
+ assert result["size"] == 65546
+
+ def test_error_empty_input(self, cmail_action):
+ assert cmail_action.parse_fetch_metadata("") == {"flags": "", "size": None}
+
+ def test_error_malformed_size_value_does_not_raise(self, cmail_action):
+ meta = "1 (RFC822.SIZE notanumber)"
+ result = cmail_action.parse_fetch_metadata(meta)
+ assert result["size"] is None
+
+ def test_error_unclosed_flags_paren_returns_empty_flags(self, cmail_action):
+ # Defensive: parser doesn't find a closing paren after FLAGS (, so
+ # flags stays empty. Size still parses since RFC822.SIZE is found
+ # via the independent token-scan path.
+ meta = "1 (FLAGS (\\Seen RFC822.SIZE 100"
+ result = cmail_action.parse_fetch_metadata(meta)
+ assert result["flags"] == ""
+ assert result["size"] == 100
+
+
+# ---------------------------------------------------------------------------
+# extract_body — pure
+# ---------------------------------------------------------------------------
+
+class TestExtractBody:
+
+ @staticmethod
+ def _multipart_alt(plain="plain text body", html="<p>html body</p>"):
+ # Build with the legacy MIME* constructors, then round-trip
+ # through email.message_from_bytes with the default policy so the
+ # parts are EmailMessage instances with .get_content() — matching
+ # what cmd_read sees when imaplib hands it RFC822 bytes.
+ msg = MIMEMultipart("alternative")
+ if plain is not None:
+ msg.attach(MIMEText(plain, "plain"))
+ if html is not None:
+ msg.attach(MIMEText(html, "html"))
+ return email.message_from_bytes(msg.as_bytes(), policy=default_policy)
+
+ def test_normal_multipart_prefers_text_plain(self, cmail_action):
+ msg = self._multipart_alt(plain="plain wins", html="<p>html loses</p>")
+ assert cmail_action.extract_body(msg) == "plain wins"
+
+ def test_boundary_html_only_multipart_falls_back_to_html(self, cmail_action):
+ msg = self._multipart_alt(plain=None, html="<p>only html</p>")
+ result = cmail_action.extract_body(msg)
+ assert result is not None
+ assert "only html" in result
+
+ def test_boundary_singlepart_returns_content_directly(self, cmail_action):
+ msg = EmailMessage()
+ msg.set_content("single-part body")
+ # set_content adds Content-Type: text/plain by default; result has
+ # a trailing newline from the policy formatter.
+ assert cmail_action.extract_body(msg).strip() == "single-part body"
+
+ def test_error_multipart_with_no_text_parts_returns_none(self, cmail_action):
+ msg = MIMEMultipart("alternative")
+ msg.attach(MIMEApplication(b"binary blob"))
+ # Round-trip for parity with the parser-based path real callers use.
+ parsed = email.message_from_bytes(msg.as_bytes(), policy=default_policy)
+ assert cmail_action.extract_body(parsed) is None
+
+
+# ---------------------------------------------------------------------------
+# _decode_header — pure
+# ---------------------------------------------------------------------------
+
+class TestDecodeHeader:
+
+ def test_normal_string(self, cmail_action):
+ assert cmail_action._decode_header("hello") == "hello"
+
+ def test_boundary_empty_string(self, cmail_action):
+ assert cmail_action._decode_header("") == ""
+
+ def test_boundary_none_returns_empty(self, cmail_action):
+ assert cmail_action._decode_header(None) == ""
+
+ def test_boundary_non_string_coerced_via_str(self, cmail_action):
+ assert cmail_action._decode_header(42) == "42"
+
+
+# ---------------------------------------------------------------------------
+# Helpers for I/O command tests
+# ---------------------------------------------------------------------------
+
+def _build_fetch_response(uid, from_addr="alice@example.com", subject="Hello",
+ size=1500):
+ """Mimic imaplib's FETCH response shape: BODY literal as a tuple,
+ trailing FLAGS/SIZE/close-paren as a separate bytes chunk.
+ """
+ headers = (
+ f"From: {from_addr}\r\n"
+ f"To: c@cjennings.net\r\n"
+ f"Subject: {subject}\r\n"
+ f"Date: Thu, 07 May 2026 12:00:00 -0500\r\n"
+ ).encode()
+ return ("OK", [
+ (f"{uid} (BODY[HEADER.FIELDS (FROM TO SUBJECT DATE)] "
+ f"{{{len(headers)}}}".encode(), headers),
+ f" FLAGS () RFC822.SIZE {size})".encode(),
+ ])
+
+
+# ---------------------------------------------------------------------------
+# cmd_list_unread — mocked imaplib
+# ---------------------------------------------------------------------------
+
+class TestCmdListUnread:
+
+ def test_normal_three_unread(self, cmail_action, capsys):
+ fetch_responses = {
+ b"100": _build_fetch_response("100", "alice@example.com", "Hello", 1500),
+ b"101": _build_fetch_response("101", "bob@example.com", "Howdy", 2000),
+ b"102": _build_fetch_response("102", "carol@example.com", "Hi", 500),
+ }
+
+ def uid_side_effect(cmd, *args):
+ if cmd == "SEARCH":
+ return ("OK", [b"100 101 102"])
+ if cmd == "FETCH":
+ return fetch_responses[args[0]]
+ return ("OK", [b""])
+
+ mock_imap = MagicMock()
+ mock_imap.select.return_value = ("OK", [b""])
+ mock_imap.uid.side_effect = uid_side_effect
+
+ with patch.object(cmail_action, "connect", return_value=mock_imap):
+ cmail_action.cmd_list_unread(SimpleNamespace(limit=50))
+
+ parsed = json.loads(capsys.readouterr().out)
+ assert len(parsed) == 3
+ assert parsed[0]["uid"] == "100"
+ assert parsed[0]["from"] == "alice@example.com"
+ assert parsed[0]["subject"] == "Hello"
+ assert parsed[0]["size"] == 1500
+ assert parsed[2]["uid"] == "102"
+
+ def test_boundary_zero_unread(self, cmail_action, capsys):
+ mock_imap = MagicMock()
+ mock_imap.select.return_value = ("OK", [b""])
+ mock_imap.uid.side_effect = lambda cmd, *a: ("OK", [b""])
+ with patch.object(cmail_action, "connect", return_value=mock_imap):
+ cmail_action.cmd_list_unread(SimpleNamespace(limit=50))
+ assert json.loads(capsys.readouterr().out) == []
+
+ def test_boundary_single_unread(self, cmail_action, capsys):
+ def uid_se(cmd, *args):
+ if cmd == "SEARCH":
+ return ("OK", [b"42"])
+ if cmd == "FETCH":
+ return _build_fetch_response("42", "x@y", "Solo", 100)
+ return ("OK", [b""])
+ mock_imap = MagicMock()
+ mock_imap.select.return_value = ("OK", [b""])
+ mock_imap.uid.side_effect = uid_se
+ with patch.object(cmail_action, "connect", return_value=mock_imap):
+ cmail_action.cmd_list_unread(SimpleNamespace(limit=50))
+ parsed = json.loads(capsys.readouterr().out)
+ assert len(parsed) == 1
+ assert parsed[0]["uid"] == "42"
+
+ def test_boundary_limit_truncates_to_most_recent(self, cmail_action, capsys):
+ # 10 unread, limit=3 — keeps the last 3 (most recent).
+ all_uids = [str(i).encode() for i in range(100, 110)]
+
+ def uid_se(cmd, *args):
+ if cmd == "SEARCH":
+ return ("OK", [b" ".join(all_uids)])
+ if cmd == "FETCH":
+ return _build_fetch_response(args[0].decode(), "x@y", "S", 100)
+ return ("OK", [b""])
+
+ mock_imap = MagicMock()
+ mock_imap.select.return_value = ("OK", [b""])
+ mock_imap.uid.side_effect = uid_se
+ with patch.object(cmail_action, "connect", return_value=mock_imap):
+ cmail_action.cmd_list_unread(SimpleNamespace(limit=3))
+ parsed = json.loads(capsys.readouterr().out)
+ assert [p["uid"] for p in parsed] == ["107", "108", "109"]
+
+ def test_error_search_returns_no(self, cmail_action):
+ mock_imap = MagicMock()
+ mock_imap.select.return_value = ("OK", [b""])
+ mock_imap.uid.return_value = ("NO", [b"server error"])
+ with patch.object(cmail_action, "connect", return_value=mock_imap):
+ with pytest.raises(SystemExit):
+ cmail_action.cmd_list_unread(SimpleNamespace(limit=50))
+
+
+# ---------------------------------------------------------------------------
+# cmd_read — mocked imaplib
+# ---------------------------------------------------------------------------
+
+class TestCmdRead:
+
+ @staticmethod
+ def _rfc822(body="hello world", subject="Test"):
+ msg = EmailMessage()
+ msg["From"] = "alice@example.com"
+ msg["To"] = "c@cjennings.net"
+ msg["Subject"] = subject
+ msg["Date"] = "Thu, 07 May 2026 12:00:00 -0500"
+ msg.set_content(body)
+ return bytes(msg)
+
+ def test_normal_prints_headers_and_body(self, cmail_action, capsys):
+ raw = self._rfc822(body="body content here", subject="subj")
+ mock_imap = MagicMock()
+ mock_imap.select.return_value = ("OK", [b""])
+ mock_imap.uid.return_value = ("OK", [(b"1 (RFC822 {N}", raw)])
+ with patch.object(cmail_action, "connect", return_value=mock_imap):
+ cmail_action.cmd_read(SimpleNamespace(uid=42))
+ out = capsys.readouterr().out
+ assert "From: alice@example.com" in out
+ assert "Subject: subj" in out
+ assert "body content here" in out
+
+ def test_error_uid_not_found(self, cmail_action):
+ mock_imap = MagicMock()
+ mock_imap.select.return_value = ("OK", [b""])
+ # imaplib's shape when the UID has no match: ('OK', [None])
+ mock_imap.uid.return_value = ("OK", [None])
+ with patch.object(cmail_action, "connect", return_value=mock_imap):
+ with pytest.raises(SystemExit):
+ cmail_action.cmd_read(SimpleNamespace(uid=999999))
+
+
+# ---------------------------------------------------------------------------
+# _store wrappers — STORE command shape verification
+# ---------------------------------------------------------------------------
+
+class TestStoreCommands:
+
+ @staticmethod
+ def _capture_calls(cmail_action, cmd_func, uids, store_typ="OK"):
+ calls = []
+ mock_imap = MagicMock()
+ mock_imap.select.return_value = ("OK", [b""])
+
+ def uid_se(cmd, uid, op, flags):
+ calls.append((cmd, op, flags))
+ return (store_typ, [b""])
+
+ mock_imap.uid.side_effect = uid_se
+ with patch.object(cmail_action, "connect", return_value=mock_imap):
+ cmd_func(SimpleNamespace(uids=uids))
+ return calls
+
+ def test_normal_mark_read_uses_plus_seen(self, cmail_action):
+ calls = self._capture_calls(cmail_action, cmail_action.cmd_mark_read, [42])
+ assert calls == [("STORE", "+FLAGS", r"(\Seen)")]
+
+ def test_normal_mark_unread_uses_minus_seen(self, cmail_action):
+ calls = self._capture_calls(cmail_action, cmail_action.cmd_mark_unread, [42])
+ assert calls == [("STORE", "-FLAGS", r"(\Seen)")]
+
+ def test_normal_star_uses_plus_flagged_and_seen(self, cmail_action):
+ calls = self._capture_calls(cmail_action, cmail_action.cmd_star, [42])
+ assert calls == [("STORE", "+FLAGS", r"(\Flagged \Seen)")]
+
+ def test_normal_unstar_uses_minus_flagged(self, cmail_action):
+ calls = self._capture_calls(cmail_action, cmail_action.cmd_unstar, [42])
+ assert calls == [("STORE", "-FLAGS", r"(\Flagged)")]
+
+ def test_boundary_multi_uid_calls_store_per_uid(self, cmail_action):
+ calls = self._capture_calls(
+ cmail_action, cmail_action.cmd_mark_read, [1, 2, 3]
+ )
+ assert len(calls) == 3
+ assert all(c == ("STORE", "+FLAGS", r"(\Seen)") for c in calls)
+
+ def test_error_store_failure_raises_systemexit(self, cmail_action):
+ with pytest.raises(SystemExit):
+ self._capture_calls(
+ cmail_action, cmail_action.cmd_mark_read, [42], store_typ="NO"
+ )
+
+
+# ---------------------------------------------------------------------------
+# cmd_trash — MOVE happy path + COPY+DELETE+EXPUNGE fallback
+# ---------------------------------------------------------------------------
+
+class TestCmdTrash:
+
+ def test_normal_move_succeeds_and_expunges(self, cmail_action):
+ mock_imap = MagicMock()
+ mock_imap.select.return_value = ("OK", [b""])
+ mock_imap.uid.return_value = ("OK", [b""])
+ mock_imap.expunge.return_value = ("OK", [b""])
+ with patch.object(cmail_action, "connect", return_value=mock_imap):
+ cmail_action.cmd_trash(SimpleNamespace(uids=[100, 101]))
+ move_calls = [c for c in mock_imap.uid.call_args_list
+ if c[0][0] == "MOVE"]
+ assert len(move_calls) == 2
+ assert mock_imap.expunge.called
+
+ def test_boundary_move_fails_falls_back_to_copy_then_delete(self, cmail_action):
+ # MOVE returns NO -> fallback path: COPY, then STORE +FLAGS \Deleted,
+ # then EXPUNGE. Verify the sequence executes as documented.
+ seen_cmds = []
+
+ def uid_se(cmd, *args):
+ seen_cmds.append(cmd)
+ if cmd == "MOVE":
+ return ("NO", [b"not supported"])
+ return ("OK", [b""])
+
+ mock_imap = MagicMock()
+ mock_imap.select.return_value = ("OK", [b""])
+ mock_imap.uid.side_effect = uid_se
+ mock_imap.expunge.return_value = ("OK", [b""])
+ with patch.object(cmail_action, "connect", return_value=mock_imap):
+ cmail_action.cmd_trash(SimpleNamespace(uids=[100]))
+ assert seen_cmds == ["MOVE", "COPY", "STORE"]
+ assert mock_imap.expunge.called
+
+ def test_error_copy_also_fails(self, cmail_action):
+ mock_imap = MagicMock()
+ mock_imap.select.return_value = ("OK", [b""])
+ mock_imap.uid.side_effect = lambda cmd, *a: ("NO", [b"both fail"])
+ with patch.object(cmail_action, "connect", return_value=mock_imap):
+ with pytest.raises(SystemExit):
+ cmail_action.cmd_trash(SimpleNamespace(uids=[100]))
+
+
+# ---------------------------------------------------------------------------
+# cmd_folders
+# ---------------------------------------------------------------------------
+
+class TestCmdFolders:
+
+ def test_normal_lists_folders(self, cmail_action, capsys):
+ mock_imap = MagicMock()
+ mock_imap.list.return_value = ("OK", [
+ b'(\\HasNoChildren) "/" "INBOX"',
+ b'(\\HasNoChildren \\Trash) "/" "Trash"',
+ ])
+ with patch.object(cmail_action, "connect", return_value=mock_imap):
+ cmail_action.cmd_folders(SimpleNamespace())
+ out = capsys.readouterr().out
+ assert "INBOX" in out
+ assert "Trash" in out
+
+ def test_error_list_returns_no(self, cmail_action):
+ mock_imap = MagicMock()
+ mock_imap.list.return_value = ("NO", [b"server error"])
+ with patch.object(cmail_action, "connect", return_value=mock_imap):
+ with pytest.raises(SystemExit):
+ cmail_action.cmd_folders(SimpleNamespace())
+
+
+# ---------------------------------------------------------------------------
+# build_message — pure
+# ---------------------------------------------------------------------------
+
+class TestBuildMessage:
+
+ def test_normal_no_attachments_is_singlepart(self, cmail_action):
+ msg = cmail_action.build_message(
+ from_addr="c@cjennings.net",
+ to_addr="recipient@example.com",
+ subject="Hello",
+ body="hello world",
+ )
+ assert msg["From"] == "c@cjennings.net"
+ assert msg["To"] == "recipient@example.com"
+ assert msg["Subject"] == "Hello"
+ assert not msg.is_multipart()
+ assert msg.get_content().strip() == "hello world"
+ assert msg.get_content_type() == "text/plain"
+
+ def test_normal_one_attachment_makes_multipart(self, cmail_action):
+ attachment = ("report.txt", "text", "plain", b"line1\nline2\n")
+ msg = cmail_action.build_message(
+ from_addr="c@cjennings.net",
+ to_addr="recipient@example.com",
+ subject="With file",
+ body="see attached",
+ attachments=[attachment],
+ )
+ assert msg.is_multipart()
+ # Find the attachment part by Content-Disposition.
+ attached_parts = [
+ p for p in msg.iter_attachments()
+ if p.get_filename() == "report.txt"
+ ]
+ assert len(attached_parts) == 1
+ att = attached_parts[0]
+ assert att.get_content_type() == "text/plain"
+ assert att.get_content().rstrip("\n") == "line1\nline2"
+
+ def test_boundary_two_attachments(self, cmail_action):
+ atts = [
+ ("a.txt", "text", "plain", b"alpha"),
+ ("b.bin", "application", "octet-stream", b"\x00\x01\x02"),
+ ]
+ msg = cmail_action.build_message(
+ from_addr="c@cjennings.net",
+ to_addr="recipient@example.com",
+ subject="Two files",
+ body="see attached",
+ attachments=atts,
+ )
+ names = sorted(p.get_filename() for p in msg.iter_attachments())
+ assert names == ["a.txt", "b.bin"]
+
+ def test_boundary_empty_body(self, cmail_action):
+ msg = cmail_action.build_message(
+ from_addr="c@cjennings.net",
+ to_addr="recipient@example.com",
+ subject="Empty",
+ body="",
+ )
+ # Body part exists, content is empty (modulo trailing newline).
+ assert msg.get_content().strip() == ""
+
+ def test_boundary_unicode_preserved_through_serialization(self, cmail_action):
+ msg = cmail_action.build_message(
+ from_addr="c@cjennings.net",
+ to_addr="recipient@example.com",
+ subject="日本語 ñ ü",
+ body="café — naïve résumé",
+ )
+ # Round-trip: serialize, parse, check both Subject and body survived.
+ raw = msg.as_bytes()
+ parsed = email.message_from_bytes(raw, policy=default_policy)
+ assert parsed["Subject"] == "日本語 ñ ü"
+ assert "café" in parsed.get_content()
+
+
+# ---------------------------------------------------------------------------
+# load_attachment — file I/O via tmp_path
+# ---------------------------------------------------------------------------
+
+class TestLoadAttachment:
+
+ def test_normal_text_file(self, cmail_action, tmp_path):
+ p = tmp_path / "notes.txt"
+ p.write_text("hello\n")
+ filename, maintype, subtype, content = cmail_action.load_attachment(p)
+ assert filename == "notes.txt"
+ assert maintype == "text"
+ assert subtype == "plain"
+ assert content == b"hello\n"
+
+ def test_normal_pdf_mime_detected(self, cmail_action, tmp_path):
+ p = tmp_path / "doc.pdf"
+ p.write_bytes(b"%PDF-1.4 fake")
+ filename, maintype, subtype, _ = cmail_action.load_attachment(p)
+ assert filename == "doc.pdf"
+ assert (maintype, subtype) == ("application", "pdf")
+
+ def test_boundary_no_extension_falls_back_to_octet_stream(self, cmail_action, tmp_path):
+ p = tmp_path / "README"
+ p.write_text("readme content")
+ filename, maintype, subtype, _ = cmail_action.load_attachment(p)
+ assert filename == "README"
+ assert (maintype, subtype) == ("application", "octet-stream")
+
+ def test_boundary_empty_file(self, cmail_action, tmp_path):
+ p = tmp_path / "empty.txt"
+ p.write_text("")
+ _, _, _, content = cmail_action.load_attachment(p)
+ assert content == b""
+
+ def test_error_missing_file_raises(self, cmail_action, tmp_path):
+ p = tmp_path / "does-not-exist.txt"
+ with pytest.raises(FileNotFoundError):
+ cmail_action.load_attachment(p)
+
+ def test_error_directory_raises(self, cmail_action, tmp_path):
+ with pytest.raises(IsADirectoryError):
+ cmail_action.load_attachment(tmp_path)
+
+
+# ---------------------------------------------------------------------------
+# cmd_send — mocked smtp_connect
+# ---------------------------------------------------------------------------
+
+class TestCmdSend:
+
+ @staticmethod
+ def _args(to="r@example.com", subject="s", body="b", body_file=None,
+ attach=None, stdin=False):
+ return SimpleNamespace(
+ to=to, subject=subject,
+ body=None if stdin else body,
+ body_file=body_file,
+ attach=attach or [],
+ )
+
+ def test_normal_inline_body_calls_send_message(self, cmail_action):
+ mock_smtp = MagicMock()
+ with patch.object(cmail_action, "smtp_connect", return_value=mock_smtp):
+ cmail_action.cmd_send(self._args(
+ to="recipient@example.com",
+ subject="testing cmail action script",
+ body="lorem ipsum dolor sit amet",
+ ))
+ mock_smtp.send_message.assert_called_once()
+ sent = mock_smtp.send_message.call_args[0][0]
+ assert sent["To"] == "recipient@example.com"
+ assert sent["Subject"] == "testing cmail action script"
+ assert sent["From"] == cmail_action.USER
+ assert "lorem ipsum dolor sit amet" in sent.get_content()
+ mock_smtp.quit.assert_called_once()
+
+ def test_boundary_body_from_file(self, cmail_action, tmp_path):
+ body_file = tmp_path / "body.txt"
+ body_file.write_text("body from file")
+ mock_smtp = MagicMock()
+ with patch.object(cmail_action, "smtp_connect", return_value=mock_smtp):
+ cmail_action.cmd_send(self._args(body=None, body_file=str(body_file)))
+ sent = mock_smtp.send_message.call_args[0][0]
+ assert "body from file" in sent.get_content()
+
+ def test_boundary_with_attachment(self, cmail_action, tmp_path):
+ att = tmp_path / "report.txt"
+ att.write_text("attachment content")
+ mock_smtp = MagicMock()
+ with patch.object(cmail_action, "smtp_connect", return_value=mock_smtp):
+ cmail_action.cmd_send(self._args(attach=[str(att)]))
+ sent = mock_smtp.send_message.call_args[0][0]
+ assert sent.is_multipart()
+ atts = list(sent.iter_attachments())
+ assert len(atts) == 1
+ assert atts[0].get_filename() == "report.txt"
+ assert atts[0].get_content().rstrip("\n") == "attachment content"
+
+ def test_error_missing_attachment_exits_before_smtp(self, cmail_action, tmp_path):
+ # Attachment files are validated first; SMTP is never opened on failure.
+ mock_smtp = MagicMock()
+ with patch.object(cmail_action, "smtp_connect", return_value=mock_smtp):
+ with pytest.raises((SystemExit, FileNotFoundError)):
+ cmail_action.cmd_send(self._args(
+ attach=[str(tmp_path / "does-not-exist.txt")]
+ ))
+ mock_smtp.send_message.assert_not_called()
+
+ def test_error_smtp_send_failure_raises(self, cmail_action):
+ import smtplib
+ mock_smtp = MagicMock()
+ mock_smtp.send_message.side_effect = smtplib.SMTPException("boom")
+ with patch.object(cmail_action, "smtp_connect", return_value=mock_smtp):
+ with pytest.raises((SystemExit, smtplib.SMTPException)):
+ cmail_action.cmd_send(self._args())
+
+
+# ---------------------------------------------------------------------------
+# Argparse — black-box subprocess sanity check
+# ---------------------------------------------------------------------------
+
+class TestArgparseShape:
+
+ def test_normal_help_lists_all_subcommands(self):
+ result = subprocess.run(
+ [sys.executable, str(SCRIPT_PATH), "--help"],
+ capture_output=True, text=True,
+ )
+ assert result.returncode == 0
+ for sub in ("list-unread", "read", "mark-read", "mark-unread",
+ "star", "unstar", "trash", "folders", "send"):
+ assert sub in result.stdout
+
+ def test_error_no_subcommand_exits_nonzero(self):
+ result = subprocess.run(
+ [sys.executable, str(SCRIPT_PATH)],
+ capture_output=True, text=True,
+ )
+ assert result.returncode != 0