Skip to content

Commit 68ef4c9

Browse files
ColinCopilot
andcommitted
feat: add security-variant integration tests with defensive IMAP cleanup
Add TLS and STARTTLS integration tests across all tiers: - 16 new security tests (Tier 1): Implicit TLS IMAP/SMTP, STARTTLS SMTP, security mismatch detection, verify_ssl rejection of self-signed certs - 3 new TLS Docker roundtrip tests (Tier 2): SMTPS/IMAPS end-to-end - Self-signed certificate generation fixture (cryptography library) - TLS-aware SMTP/IMAP fixtures for both tiers Add defensive IMAP resource cleanup in test_imap_connection(): - _force_close_imap() helper closes transport and cancels _client_task - Prevents leaked connections when IMAP operations fail or time out - Workaround for aioimaplib#128 (tasks ignore asyncio cancellation) Blocked tests (commented out with issue references): - IMAP mismatch: plaintext-on-TLS + verify_ssl rejection (aioimaplib#128) - Docker STARTTLS roundtrip: GreenMail lacks STARTTLS support (greenmail#135) Update dependency versions: cryptography>=44.0.0 added to integration group. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 1a6550b commit 68ef4c9

File tree

10 files changed

+875
-25
lines changed

10 files changed

+875
-25
lines changed

Makefile

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,25 @@ build: clean-build ## Build wheel file
4040

4141
.PHONY: clean-build
4242
clean-build: ## Clean build artifacts
43-
@echo "🚀 Removing build artifacts"
44-
@uv run python -c "import shutil; import os; shutil.rmtree('dist') if os.path.exists('dist') else None"
43+
@rm -rf dist build *.egg-info
44+
45+
.PHONY: clean
46+
clean: clean-build ## Remove build, test, and cache artifacts
47+
@find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
48+
@find . -type f -name "*.py[co]" -delete 2>/dev/null || true
49+
@rm -rf .pytest_cache .coverage coverage.xml htmlcov .mypy_cache .ruff_cache .hypothesis
50+
@echo "✨ Clean complete"
51+
52+
.PHONY: clean-docker
53+
clean-docker: ## Stop and remove Docker test containers and volumes
54+
@echo "🐳 Cleaning Docker test environment"
55+
@docker compose -f tests/docker/docker-compose.yml down -v --remove-orphans 2>/dev/null || true
56+
@echo "✨ Docker clean complete"
57+
58+
.PHONY: clean-all
59+
clean-all: clean clean-docker ## Remove all artifacts including tox, docs, and Docker
60+
@rm -rf .tox .nox site
61+
@echo "✨ Deep clean complete"
4562

4663
.PHONY: publish
4764
publish: ## Publish a release to PyPI.

mcp_email_server/emails/classic.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,8 +166,30 @@ async def _create_imap_connection(
166166
return imap
167167

168168

169+
def _force_close_imap(imap: aioimaplib.IMAP4 | aioimaplib.IMAP4_SSL) -> None:
170+
"""Force-close an IMAP connection's transport and cancel internal tasks.
171+
172+
Defensive cleanup for aioimaplib connections that may not shut down cleanly
173+
after logout(). aioimaplib's _client_task and transport do not support
174+
asyncio cancellation properly.
175+
See: https://github.com/iroco-co/aioimaplib/issues/128
176+
"""
177+
with contextlib.suppress(Exception):
178+
if (
179+
hasattr(imap, "protocol")
180+
and imap.protocol
181+
and hasattr(imap.protocol, "transport")
182+
and imap.protocol.transport
183+
):
184+
imap.protocol.transport.close()
185+
with contextlib.suppress(Exception):
186+
if hasattr(imap, "_client_task") and not imap._client_task.done():
187+
imap._client_task.cancel()
188+
189+
169190
async def test_imap_connection(server: EmailServer, timeout: int = 10) -> str:
170191
"""Test IMAP connection and login. Returns a user-friendly status message."""
192+
imap = None
171193
try:
172194
imap = await asyncio.wait_for(_create_imap_connection(server), timeout=timeout)
173195
try:
@@ -190,6 +212,9 @@ async def test_imap_connection(server: EmailServer, timeout: int = 10) -> str:
190212
return f"❌ IMAP error: {e}"
191213
except Exception as e:
192214
return f"❌ IMAP error: {e}"
215+
finally:
216+
if imap is not None:
217+
_force_close_imap(imap)
193218

194219

195220
async def test_smtp_connection(server: EmailServer, timeout: int = 10) -> str:

pyproject.toml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,12 @@ dev = [
5252
"mkdocstrings[python]>=0.26.1",
5353
]
5454
integration = [
55-
"pytest-localserver>=0.9.0",
56-
"aiosmtpd>=1.4.0",
55+
"pytest-localserver>=0.10.0",
56+
"aiosmtpd>=1.4.6",
57+
"cryptography>=44.0.0",
5758
]
5859
docker = [
59-
"pytest-docker>=3.1.0",
60+
"pytest-docker>=3.2.5",
6061
]
6162

6263
[build-system]

tests/docker/conftest.py

Lines changed: 119 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
"""Fixtures for Docker-based integration tests (Tier 2).
22
3-
GreenMail provides a real SMTP+IMAP server for full sendread roundtrip tests.
3+
GreenMail provides a real SMTP+IMAP server for full send/read roundtrip tests.
44
Tests auto-skip if Docker is not available.
5+
6+
Security modes tested:
7+
- NONE: SMTP port 3025, IMAP port 3143 (plaintext)
8+
- TLS: SMTPS port 3465, IMAPS port 3993 (Implicit TLS, self-signed)
9+
- STARTTLS: skipped (greenmail#135 — GreenMail does not support STARTTLS)
510
"""
611

712
from __future__ import annotations
@@ -20,6 +25,8 @@
2025

2126
GREENMAIL_SMTP_PORT = 3025
2227
GREENMAIL_IMAP_PORT = 3143
28+
GREENMAIL_SMTPS_PORT = 3465
29+
GREENMAIL_IMAPS_PORT = 3993
2330

2431

2532
def pytest_collection_modifyitems(config, items):
@@ -59,6 +66,12 @@ def _greenmail_is_ready() -> bool:
5966
# Test IMAP socket
6067
with socket.create_connection(("127.0.0.1", GREENMAIL_IMAP_PORT), timeout=3):
6168
pass
69+
# Test SMTPS socket (TLS port)
70+
with socket.create_connection(("127.0.0.1", GREENMAIL_SMTPS_PORT), timeout=3):
71+
pass
72+
# Test IMAPS socket (TLS port)
73+
with socket.create_connection(("127.0.0.1", GREENMAIL_IMAPS_PORT), timeout=3):
74+
pass
6275
return True
6376
except Exception:
6477
return False
@@ -77,14 +90,21 @@ def greenmail(docker_services):
7790
"smtp_port": GREENMAIL_SMTP_PORT,
7891
"imap_host": "127.0.0.1",
7992
"imap_port": GREENMAIL_IMAP_PORT,
93+
"smtps_port": GREENMAIL_SMTPS_PORT,
94+
"imaps_port": GREENMAIL_IMAPS_PORT,
8095
"user": GREENMAIL_USER,
8196
"password": GREENMAIL_PASSWORD,
8297
}
8398

8499

100+
# ---------------------------------------------------------------------------
101+
# Plaintext (NONE) fixtures
102+
# ---------------------------------------------------------------------------
103+
104+
85105
@pytest.fixture()
86106
def greenmail_smtp_server(greenmail) -> EmailServer:
87-
"""EmailServer config for GreenMail SMTP."""
107+
"""EmailServer config for GreenMail SMTP (plaintext)."""
88108
return EmailServer(
89109
host=greenmail["smtp_host"],
90110
port=greenmail["smtp_port"],
@@ -97,7 +117,7 @@ def greenmail_smtp_server(greenmail) -> EmailServer:
97117

98118
@pytest.fixture()
99119
def greenmail_imap_server(greenmail) -> EmailServer:
100-
"""EmailServer config for GreenMail IMAP."""
120+
"""EmailServer config for GreenMail IMAP (plaintext)."""
101121
return EmailServer(
102122
host=greenmail["imap_host"],
103123
port=greenmail["imap_port"],
@@ -110,11 +130,105 @@ def greenmail_imap_server(greenmail) -> EmailServer:
110130

111131
@pytest.fixture()
112132
def greenmail_smtp_client(greenmail_smtp_server) -> EmailClient:
113-
"""EmailClient wired to GreenMail SMTP."""
133+
"""EmailClient wired to GreenMail SMTP (plaintext)."""
114134
return EmailClient(greenmail_smtp_server, sender=GREENMAIL_USER)
115135

116136

117137
@pytest.fixture()
118138
def greenmail_imap_client(greenmail_imap_server) -> EmailClient:
119-
"""EmailClient wired to GreenMail IMAP."""
139+
"""EmailClient wired to GreenMail IMAP (plaintext)."""
120140
return EmailClient(greenmail_imap_server, sender=GREENMAIL_USER)
141+
142+
143+
# ---------------------------------------------------------------------------
144+
# Implicit TLS fixtures (SMTPS / IMAPS)
145+
# ---------------------------------------------------------------------------
146+
147+
148+
@pytest.fixture()
149+
def greenmail_smtps_server(greenmail) -> EmailServer:
150+
"""EmailServer config for GreenMail SMTPS (Implicit TLS)."""
151+
return EmailServer(
152+
host=greenmail["smtp_host"],
153+
port=greenmail["smtps_port"],
154+
user_name=greenmail["user"],
155+
password=greenmail["password"],
156+
security=ConnectionSecurity.TLS,
157+
verify_ssl=False,
158+
)
159+
160+
161+
@pytest.fixture()
162+
def greenmail_imaps_server(greenmail) -> EmailServer:
163+
"""EmailServer config for GreenMail IMAPS (Implicit TLS)."""
164+
return EmailServer(
165+
host=greenmail["imap_host"],
166+
port=greenmail["imaps_port"],
167+
user_name=greenmail["user"],
168+
password=greenmail["password"],
169+
security=ConnectionSecurity.TLS,
170+
verify_ssl=False,
171+
)
172+
173+
174+
@pytest.fixture()
175+
def greenmail_smtps_client(greenmail_smtps_server) -> EmailClient:
176+
"""EmailClient wired to GreenMail SMTPS (Implicit TLS)."""
177+
return EmailClient(greenmail_smtps_server, sender=GREENMAIL_USER)
178+
179+
180+
@pytest.fixture()
181+
def greenmail_imaps_client(greenmail_imaps_server) -> EmailClient:
182+
"""EmailClient wired to GreenMail IMAPS (Implicit TLS)."""
183+
return EmailClient(greenmail_imaps_server, sender=GREENMAIL_USER)
184+
185+
186+
# Note: GreenMail does not support STARTTLS on plaintext ports.
187+
# See https://github.com/greenmail-mail-test/greenmail/issues/135
188+
# STARTTLS is tested at Tier 1 (SMTP via aiosmtpd) and unit tests (IMAP).
189+
190+
191+
# ---------------------------------------------------------------------------
192+
# STARTTLS fixtures (upgrade on plaintext ports)
193+
# TODO(greenmail#135): GreenMail does not advertise STARTTLS on plaintext ports.
194+
# These fixtures are kept for when GreenMail adds STARTTLS support.
195+
# See: https://github.com/greenmail-mail-test/greenmail/issues/135
196+
# ---------------------------------------------------------------------------
197+
198+
199+
@pytest.fixture()
200+
def greenmail_smtp_starttls_server(greenmail) -> EmailServer:
201+
"""EmailServer config for GreenMail SMTP with STARTTLS."""
202+
return EmailServer(
203+
host=greenmail["smtp_host"],
204+
port=greenmail["smtp_port"],
205+
user_name=greenmail["user"],
206+
password=greenmail["password"],
207+
security=ConnectionSecurity.STARTTLS,
208+
verify_ssl=False,
209+
)
210+
211+
212+
@pytest.fixture()
213+
def greenmail_imap_starttls_server(greenmail) -> EmailServer:
214+
"""EmailServer config for GreenMail IMAP with STARTTLS."""
215+
return EmailServer(
216+
host=greenmail["imap_host"],
217+
port=greenmail["imap_port"],
218+
user_name=greenmail["user"],
219+
password=greenmail["password"],
220+
security=ConnectionSecurity.STARTTLS,
221+
verify_ssl=False,
222+
)
223+
224+
225+
@pytest.fixture()
226+
def greenmail_smtp_starttls_client(greenmail_smtp_starttls_server) -> EmailClient:
227+
"""EmailClient wired to GreenMail SMTP with STARTTLS."""
228+
return EmailClient(greenmail_smtp_starttls_server, sender=GREENMAIL_USER)
229+
230+
231+
@pytest.fixture()
232+
def greenmail_imap_starttls_client(greenmail_imap_starttls_server) -> EmailClient:
233+
"""EmailClient wired to GreenMail IMAP with STARTTLS."""
234+
return EmailClient(greenmail_imap_starttls_server, sender=GREENMAIL_USER)

0 commit comments

Comments
 (0)