diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-08 23:23:31 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-08 23:23:31 -0500 |
| commit | 65c962e01f0ec7cefe70157f4f11f54638cdc995 (patch) | |
| tree | a64d7a8bd466cb08e77829ca86e25a221aa64710 /.ai/scripts/tests/test_cmail_action.py | |
| parent | 1abae87acaba85453ef9b7e1eafe0d6e8e22c4e5 (diff) | |
| download | rulesets-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.py | 669 |
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 |
