feat: add IMAP STARTTLS support with RFC 8314 security enum#119
feat: add IMAP STARTTLS support with RFC 8314 security enum#119codefuturist wants to merge 2 commits intoai-zerolab:mainfrom
Conversation
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
Replace ambiguous use_ssl/start_ssl boolean pair with a clean ConnectionSecurity enum (tls/starttls/none) per RFC 8314. Changes: - Add ConnectionSecurity enum to config.py - Add model_validator for backward compat with use_ssl/start_ssl - Implement IMAP STARTTLS transport upgrade via asyncio.loop.start_tls() - Add _create_imap_connection() factory for TLS/STARTTLS/plaintext - Add IMAP verify_ssl support for self-signed certificates - Wire SMTP flags from security enum in EmailClient - Add MCP_EMAIL_SERVER_IMAP_SECURITY/SMTP_SECURITY env vars - Update existing tests to use _connect_imap instead of imap_class - Add comprehensive tests for new security features - Update README with security modes, env vars, and ProtonMail Bridge example Existing configs with use_ssl/start_ssl continue to work unchanged. The start_ssl field was previously ignored for IMAP — this fixes that. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
e6eb81f to
41199ec
Compare
for more information, see https://pre-commit.ci
There was a problem hiding this comment.
Pull request overview
Adds explicit connection security semantics (RFC 8314) and implements IMAP STARTTLS so IMAP connections can be upgraded to TLS when configured, replacing the prior ambiguous use_ssl/start_ssl behavior.
Changes:
- Introduces
ConnectionSecurity(tls/starttls/none) and wires it throughEmailServer+ env/TOML parsing with legacy compatibility. - Implements IMAP STARTTLS upgrade flow and refactors IMAP connection creation behind
_connect_imap()/_create_imap_connection(). - Expands and updates test coverage; updates README documentation for new configuration and env vars.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
mcp_email_server/config.py |
Adds ConnectionSecurity, migrates config/env parsing to security, keeps legacy fields via validator. |
mcp_email_server/emails/classic.py |
Adds IMAP STARTTLS + IMAP SSL context handling; centralizes IMAP connection creation. |
tests/test_imap_starttls.py |
New tests for enum behavior, legacy mapping, env parsing, and STARTTLS upgrade flow. |
tests/test_email_client.py |
Updates tests to patch _connect_imap after IMAP refactor. |
tests/test_email_attachments.py |
Updates tests to patch _connect_imap after IMAP refactor. |
README.md |
Documents new security/*_SECURITY configuration and STARTTLS usage examples. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| async def test_download_attachment_custom_mailbox(self, email_client, tmp_path): | ||
| """Test download_attachment with custom mailbox parameter.""" | ||
| import asyncio | ||
|
|
||
| save_path = str(tmp_path / "attachment.pdf") | ||
|
|
There was a problem hiding this comment.
This inner import asyncio is now unused in this test after switching to _connect_imap (no asyncio.Future() usage anymore). Ruff (F401) will flag this; please remove the import.
| """Test download_attachment with special folder like [Gmail]/Sent Mail.""" | ||
| import asyncio | ||
|
|
||
| save_path = str(tmp_path / "attachment.pdf") | ||
|
|
||
| mock_imap = AsyncMock() |
There was a problem hiding this comment.
This inner import asyncio is now unused in this test after switching to _connect_imap (no asyncio.Future() usage anymore). Ruff (F401) will flag this; please remove the import.
| def test_invalid_use_ssl_and_start_ssl_both_true(self): | ||
| with pytest.raises(ValueError, match="cannot both be true"): | ||
| EmailServer(user_name="u", password="p", host="h", port=993, use_ssl=True, start_ssl=True) | ||
|
|
There was a problem hiding this comment.
The test currently asserts an invalid legacy combination using pytest.raises(Exception), which is overly broad and can hide unexpected failures. Since the model validator raises ValueError, assert that specific exception type (and ideally match part of the message).
| If only old fields are set, derive `security` from them. | ||
| """ | ||
| # If legacy fields are explicitly set and security was not explicitly provided | ||
| # (i.e. it's at default), derive security from legacy fields | ||
| if obj.use_ssl is not None or obj.start_ssl is not None: | ||
| use_ssl = obj.use_ssl if obj.use_ssl is not None else False | ||
| start_ssl = obj.start_ssl if obj.start_ssl is not None else False | ||
|
|
||
| if use_ssl and start_ssl: | ||
| raise ValueError( | ||
| "Invalid configuration: 'use_ssl' and 'start_ssl' cannot both be true. " | ||
| "Use 'security = \"tls\"' for implicit TLS or 'security = \"starttls\"' for STARTTLS." | ||
| ) | ||
|
|
||
| if use_ssl: | ||
| obj.security = ConnectionSecurity.TLS | ||
| elif start_ssl: | ||
| obj.security = ConnectionSecurity.STARTTLS | ||
| else: |
There was a problem hiding this comment.
resolve_security_from_legacy currently overwrites security whenever either legacy field is present, even if security was explicitly provided. This contradicts the docstring (“security takes precedence”) and can downgrade a config (e.g., security="tls" plus start_ssl=False ends up as none). Use obj.model_fields_set (or equivalent) to only derive from legacy fields when security was not set by the user, and avoid treating an explicitly-false start_ssl without use_ssl as meaning plaintext by default.
| If only old fields are set, derive `security` from them. | |
| """ | |
| # If legacy fields are explicitly set and security was not explicitly provided | |
| # (i.e. it's at default), derive security from legacy fields | |
| if obj.use_ssl is not None or obj.start_ssl is not None: | |
| use_ssl = obj.use_ssl if obj.use_ssl is not None else False | |
| start_ssl = obj.start_ssl if obj.start_ssl is not None else False | |
| if use_ssl and start_ssl: | |
| raise ValueError( | |
| "Invalid configuration: 'use_ssl' and 'start_ssl' cannot both be true. " | |
| "Use 'security = \"tls\"' for implicit TLS or 'security = \"starttls\"' for STARTTLS." | |
| ) | |
| if use_ssl: | |
| obj.security = ConnectionSecurity.TLS | |
| elif start_ssl: | |
| obj.security = ConnectionSecurity.STARTTLS | |
| else: | |
| If `security` is not set but old fields are, derive `security` from them. | |
| """ | |
| # Respect explicit `security` configuration: never override it with legacy fields. | |
| fields_set = getattr(obj, "model_fields_set", set()) | |
| if "security" in fields_set: | |
| return obj | |
| legacy_use_ssl_set = "use_ssl" in fields_set | |
| legacy_start_ssl_set = "start_ssl" in fields_set | |
| # If neither legacy field was explicitly set, nothing to do. | |
| if not (legacy_use_ssl_set or legacy_start_ssl_set): | |
| return obj | |
| use_ssl = bool(obj.use_ssl) | |
| start_ssl = bool(obj.start_ssl) | |
| if use_ssl and start_ssl: | |
| raise ValueError( | |
| "Invalid configuration: 'use_ssl' and 'start_ssl' cannot both be true. " | |
| "Use 'security = \"tls\"' for implicit TLS or 'security = \"starttls\"' for STARTTLS." | |
| ) | |
| if use_ssl: | |
| obj.security = ConnectionSecurity.TLS | |
| elif start_ssl: | |
| obj.security = ConnectionSecurity.STARTTLS | |
| else: | |
| # Only treat legacy flags as an explicit request for plaintext when both | |
| # legacy fields were provided (even if set to False). This avoids | |
| # interpreting a lone `start_ssl = false` (or `use_ssl = false`) as | |
| # "force plaintext" and leaves the default `security` intact instead. | |
| if legacy_use_ssl_set and legacy_start_ssl_set: |
| # Upgrade the transport to TLS | ||
| loop = asyncio.get_running_loop() | ||
| tls_transport = await loop.start_tls( | ||
| imap.protocol.transport, | ||
| imap.protocol, | ||
| ssl_context, | ||
| server_hostname=host, | ||
| ) |
There was a problem hiding this comment.
_imap_starttls upgrades the transport using asyncio.get_running_loop().start_tls(), but the IMAP protocol/transport are tied to imap.protocol.loop. If these ever differ, start_tls can raise due to a loop mismatch. Prefer using imap.protocol.loop.start_tls(...) (or otherwise ensure the same loop is used) when upgrading the existing transport.
Summary
Adds proper IMAP STARTTLS support and replaces the ambiguous
use_ssl/start_sslboolean pair with a cleanConnectionSecurityenum (tls/starttls/none) per RFC 8314.Problem
The
start_sslconfig field onEmailServerwas only wired for SMTP, never for IMAP. When configuringuse_ssl = false+start_ssl = truefor IMAP (e.g., Proton Mail Bridge on port 1143), the IMAP connection remained unencrypted — STARTTLS was never issued.The two-boolean design also creates an invalid state (
use_ssl=true, start_ssl=true) with no clear semantics.Solution
New
securityenum field (RFC 8314)tlsstarttlsnoneChanges
config.py: AddedConnectionSecurityenum,securityfield onEmailServer, backward-compatmodel_validatorthat derivessecurityfrom legacyuse_ssl/start_ssl, new env vars (MCP_EMAIL_SERVER_IMAP_SECURITY,MCP_EMAIL_SERVER_SMTP_SECURITY),imap_verify_sslsupportemails/classic.py: Implemented IMAP STARTTLS viaasyncio.loop.start_tls()(same pattern as aiosmtplib), added_create_imap_connection()factory, replaced all 6 directimap_class()calls with_connect_imap(), added_create_imap_ssl_context()forverify_ssl=false(self-signed certs)securityfield, env vars, connection modes, ProtonMail Bridge exampleBackward Compatibility
use_ssl/start_sslcontinue to work unchangedMCP_EMAIL_SERVER_IMAP_SSL, etc.) still work, new*_SECURITYvars take precedencesecurity = "tls")ProtonMail Bridge Example
Testing
All 168 tests pass (161 existing + 7 updated + 30 new).