hermes-proton/tests/test_mail.py
B.A. Baracus f8b9991207
feat(proton-mail): Hermes skill — IMAP/SMTP tools via Bridge
Full Proton Mail Bridge Hermes skill with 6 tools:
- proton_mail_bridge_status — check daemon health
- proton_mail_list — list inbox/folder messages
- proton_mail_read — read full message by UID (body+headers)
- proton_mail_search — search by subject/from/body/all
- proton_mail_send — send email with CC/BCC support
- proton_mail_reply — reply preserving In-Reply-To/References

Implementation: pure Python stdlib (imaplib + smtplib + email),
no external dependencies. 22 unit tests with mocked IMAP/SMTP.

Follows architecture from ARCHITECTURE.md (section 3).
Per-tool auth via PROTONMAIL_ACCOUNT + PROTONMAIL_BRIDGE_PASSWORD env vars.
Bridge runs on 127.0.0.1:1143 (IMAP TLS) / 127.0.0.1:1025 (SMTP STARTTLS).
2026-06-08 18:31:07 +02:00

330 lines
14 KiB
Python

"""Tests for the proton-mail Hermes skill — IMAP/SMTP via Proton Mail Bridge.
All tests mock imaplib and smtplib. Real Bridge integration is behind a
pytest.mark.skipif guard for systems with Bridge running.
"""
import json
import pytest
from unittest.mock import patch, MagicMock, call
from skills.proton_mail import tools as mail_tools
# ── Fixtures ──────────────────────────────────────────────────────────────
@pytest.fixture
def mock_imap():
"""Mock IMAP4_SSL connection returning a logged-in session."""
with patch("skills.proton_mail.tools.imaplib.IMAP4_SSL") as mock:
inst = mock.return_value
inst.login.return_value = ("OK", [b"Logged in"])
inst.select.return_value = ("OK", [b"42"])
inst.search.return_value = ("OK", [b"1 2 3"])
# Default fetch: one entry (UID 1, basic headers)
inst.fetch.return_value = (
"OK",
[_make_fetch_response(1, "Default Subject", "default@test.com",
"Mon, 1 Jan 2024 10:00:00 +0000")],
)
# Default uid fetch
inst.uid.return_value = (
"OK",
[_make_fetch_response(1, "Default Subject", "default@test.com",
"Mon, 1 Jan 2024 10:00:00 +0000")],
)
inst.logout.return_value = ("OK", [b"Bye"])
yield inst
@pytest.fixture
def mock_smtp():
"""Mock SMTP connection returning a logged-in session."""
with patch("skills.proton_mail.tools.smtplib.SMTP") as mock:
inst = mock.return_value
inst.starttls.return_value = None
inst.login.return_value = None
inst.send_message.return_value = ({}, "test-message-id@bridge")
yield inst
@pytest.fixture
def mock_env_vars():
"""Set Proton Bridge env vars for tests."""
with patch.dict("os.environ", {
"PROTONMAIL_ACCOUNT": "test@proton.me",
"PROTONMAIL_BRIDGE_PASSWORD": "bridge-password-123",
}):
yield
# ── Helpers ───────────────────────────────────────────────────────────────
def _make_fetch_response(uid: int, subject: str, sender: str, date: str,
body: str = "Hello world") -> tuple:
"""Build a realistic IMAP FETCH response tuple.
Returns (uid_data, rfc822_data) as a single-element list per IMAP
protocol — each list item is (header_bytes, message_bytes).
"""
headers = (
f"Subject: {subject}\r\n"
f"From: {sender}\r\n"
f"To: recipient@proton.me\r\n"
f"Date: {date}\r\n"
f"Message-ID: <msg{uid}@proton.me>\r\n"
f"MIME-Version: 1.0\r\n"
f"Content-Type: text/plain; charset=utf-8\r\n"
f"Content-Transfer-Encoding: 7bit\r\n"
f"\r\n"
).encode("utf-8")
msg_bytes = headers + body.encode("utf-8")
uid_wrapper = f"1 (UID {uid} FLAGS (\\Seen))\r\n".encode("utf-8")
return (uid_wrapper, msg_bytes)
# ── Bridge Status ─────────────────────────────────────────────────────────
class TestBridgeStatus:
def test_bridge_running(self, mock_env_vars):
"""Bridge status returns 'running' when IMAP port is reachable."""
with patch("skills.proton_mail.tools._check_port_open",
return_value=True):
result = json.loads(mail_tools.proton_mail_bridge_status({}))
assert result["status"] == "running"
def test_bridge_not_running(self, mock_env_vars):
"""Bridge status returns 'stopped' when IMAP port is unreachable."""
with patch("skills.proton_mail.tools._check_port_open",
return_value=False):
result = json.loads(mail_tools.proton_mail_bridge_status({}))
assert result["status"] == "stopped"
def test_missing_env_vars(self):
"""Bridge status returns error when credentials are missing."""
with patch.dict("os.environ", {}, clear=True):
result = json.loads(mail_tools.proton_mail_bridge_status({}))
assert "error" in result
# ── Mail List ─────────────────────────────────────────────────────────────
class TestMailList:
def test_list_returns_messages(self, mock_imap, mock_env_vars):
"""List returns parsed messages with subject, from, date, uid."""
mock_imap.search.return_value = ("OK", [b"1 2 3"])
mock_imap.fetch.side_effect = [
("OK", [_make_fetch_response(1, "Subject A", "a@test.com", "Mon, 1 Jan 2024 10:00:00 +0000")]),
("OK", [_make_fetch_response(2, "Subject B", "b@test.com", "Tue, 2 Jan 2024 11:00:00 +0000")]),
("OK", [_make_fetch_response(3, "Subject C", "c@test.com", "Wed, 3 Jan 2024 12:00:00 +0000")]),
]
result = json.loads(mail_tools.proton_mail_list({"folder": "INBOX", "limit": 3}))
assert result["success"] is True
assert len(result["messages"]) == 3
assert result["messages"][0]["subject"] == "Subject A"
assert result["messages"][0]["from"] == "a@test.com"
assert result["messages"][2]["uid"] == 3
assert result["folder"] == "INBOX"
def test_list_empty_mailbox(self, mock_imap, mock_env_vars):
"""List returns empty array when no messages match."""
mock_imap.search.return_value = ("OK", [b""])
result = json.loads(mail_tools.proton_mail_list({"folder": "INBOX", "limit": 10}))
assert result["success"] is True
assert result["messages"] == []
def test_list_custom_folder(self, mock_imap, mock_env_vars):
"""List selects a non-INBOX folder."""
mock_imap.search.return_value = ("OK", [b"1 2"])
result = json.loads(mail_tools.proton_mail_list({"folder": "Sent", "limit": 5}))
mock_imap.select.assert_called_with('"Sent"', readonly=True)
assert result["folder"] == "Sent"
def test_list_limits_results(self, mock_imap, mock_env_vars):
"""List respects the limit parameter."""
mock_imap.search.return_value = ("OK", [b"1 2 3 4 5 6 7 8 9 10"])
result = json.loads(mail_tools.proton_mail_list({"folder": "INBOX", "limit": 3}))
assert len(result["messages"]) == 3
def test_list_imap_error(self, mock_imap, mock_env_vars):
"""List returns error on IMAP failure."""
mock_imap.search.return_value = ("NO", [b"Search failed"])
result = json.loads(mail_tools.proton_mail_list({"folder": "INBOX", "limit": 10}))
assert "error" in result
assert result["success"] is False
# ── Mail Read ─────────────────────────────────────────────────────────────
class TestMailRead:
def test_read_returns_full_message(self, mock_imap, mock_env_vars):
"""Read returns subject, from, to, date, body, and uid."""
mock_imap.uid.return_value = (
"OK",
[_make_fetch_response(42, "Fancy Subject", "alice@test.com",
"Thu, 4 Jan 2024 14:00:00 +0000",
"This is the message body.\nWith two lines.")]
)
result = json.loads(mail_tools.proton_mail_read({"uid": 42, "folder": "INBOX"}))
assert result["success"] is True
assert result["uid"] == 42
assert result["subject"] == "Fancy Subject"
assert result["from"] == "alice@test.com"
assert result["body"] == "This is the message body.\nWith two lines."
def test_read_missing_uid(self, mock_env_vars):
"""Read returns error when uid parameter is missing."""
result = json.loads(mail_tools.proton_mail_read({"folder": "INBOX"}))
assert "error" in result
def test_read_uid_not_found(self, mock_imap, mock_env_vars):
"""Read returns error when UID fetch returns empty."""
mock_imap.uid.return_value = ("OK", [None])
result = json.loads(mail_tools.proton_mail_read({"uid": 999, "folder": "INBOX"}))
assert "error" in result
# ── Mail Search ───────────────────────────────────────────────────────────
class TestMailSearch:
def test_search_by_subject(self, mock_imap, mock_env_vars):
"""Search finds messages matching a subject query."""
mock_imap.search.return_value = ("OK", [b"2 3"])
mock_imap.fetch.side_effect = [
("OK", [_make_fetch_response(2, "Meeting at 3pm", "b@test.com", "Tue, 2 Jan 2024 11:00:00 +0000")]),
("OK", [_make_fetch_response(3, "Meeting notes", "c@test.com", "Wed, 3 Jan 2024 12:00:00 +0000")]),
]
result = json.loads(mail_tools.proton_mail_search({
"query": "Meeting", "folder": "INBOX", "limit": 10
}))
assert result["success"] is True
assert len(result["messages"]) == 2
assert all("Meeting" in m["subject"] for m in result["messages"])
def test_search_from_sender(self, mock_imap, mock_env_vars):
"""Search filters by sender."""
mock_imap.search.return_value = ("OK", [b"1"])
mock_imap.fetch.return_value = (
"OK",
[_make_fetch_response(1, "Hello", "specific@test.com", "Mon, 1 Jan 2024 10:00:00 +0000")]
)
result = json.loads(mail_tools.proton_mail_search({
"query": "specific@test.com", "folder": "INBOX",
"limit": 10, "field": "from"
}))
assert result["success"] is True
assert result["messages"][0]["from"] == "specific@test.com"
def test_search_no_results(self, mock_imap, mock_env_vars):
"""Search returns empty when nothing matches."""
mock_imap.search.return_value = ("OK", [b""])
result = json.loads(mail_tools.proton_mail_search({
"query": "zzzzzxxxxx", "folder": "INBOX", "limit": 10
}))
assert result["success"] is True
assert result["messages"] == []
def test_search_requires_query(self, mock_env_vars):
"""Search returns error when query is missing or too short."""
result = json.loads(mail_tools.proton_mail_search({
"folder": "INBOX", "limit": 10
}))
assert "error" in result
# ── Mail Send ─────────────────────────────────────────────────────────────
class TestMailSend:
def test_send_plain_text(self, mock_smtp, mock_env_vars):
"""Send delivers a plain text email."""
result = json.loads(mail_tools.proton_mail_send({
"to": "recipient@example.com",
"subject": "Test Subject",
"body": "Hello from Proton skill!",
}))
assert result["success"] is True
assert "message_id" in result
def test_send_with_cc(self, mock_smtp, mock_env_vars):
"""Send includes CC recipients."""
result = json.loads(mail_tools.proton_mail_send({
"to": "primary@example.com",
"cc": "cc@example.com",
"subject": "Cc Test",
"body": "CC included.",
}))
assert result["success"] is True
assert mock_smtp.send_message.called
def test_send_requires_recipients(self, mock_env_vars):
"""Send returns error when 'to' is missing."""
result = json.loads(mail_tools.proton_mail_send({
"subject": "No Recipient",
"body": "Where does this go?",
}))
assert "error" in result
def test_send_requires_subject(self, mock_env_vars):
"""Send returns error with a helpful message when subject is missing."""
result = json.loads(mail_tools.proton_mail_send({
"to": "recipient@example.com",
"body": "No subject",
}))
assert "error" in result
# ── Mail Reply ────────────────────────────────────────────────────────────
class TestMailReply:
def test_reply_sets_thread_headers(self, mock_imap, mock_smtp, mock_env_vars):
"""Reply reads original, sets In-Reply-To and References headers."""
# Original message fetch
mock_imap.fetch.return_value = (
"OK",
[_make_fetch_response(10, "Original Thread", "original@test.com",
"Fri, 5 Jan 2024 09:00:00 +0000",
"This is the original email.")]
)
result = json.loads(mail_tools.proton_mail_reply({
"uid": 10,
"body": "Thanks for your email!",
}))
assert result["success"] is True
def test_reply_requires_uid(self, mock_env_vars):
"""Reply returns error when uid is missing."""
result = json.loads(mail_tools.proton_mail_reply({
"body": "Missing uid",
}))
assert "error" in result
def test_reply_requires_body(self, mock_env_vars):
"""Reply returns error when body is missing."""
result = json.loads(mail_tools.proton_mail_reply({
"uid": 10,
}))
assert "error" in result