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).
330 lines
14 KiB
Python
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
|