Skip to content

feat: add IMAP STARTTLS support with RFC 8314 security enum#119

Closed
codefuturist wants to merge 2 commits intoai-zerolab:mainfrom
codefuturist:feat/imap-starttls
Closed

feat: add IMAP STARTTLS support with RFC 8314 security enum#119
codefuturist wants to merge 2 commits intoai-zerolab:mainfrom
codefuturist:feat/imap-starttls

Conversation

@codefuturist
Copy link
Contributor

Summary

Adds proper IMAP STARTTLS support and replaces the ambiguous use_ssl/start_ssl boolean pair with a clean ConnectionSecurity enum (tls/starttls/none) per RFC 8314.

Problem

The start_ssl config field on EmailServer was only wired for SMTP, never for IMAP. When configuring use_ssl = false + start_ssl = true for 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 security enum field (RFC 8314)

Mode Description IMAP Port SMTP Port
tls Implicit TLS — encrypted from first byte 993 465
starttls STARTTLS — connect plain, upgrade to TLS 143 587
none No encryption (not recommended) 143 25

Changes

  • config.py: Added ConnectionSecurity enum, security field on EmailServer, backward-compat model_validator that derives security from legacy use_ssl/start_ssl, new env vars (MCP_EMAIL_SERVER_IMAP_SECURITY, MCP_EMAIL_SERVER_SMTP_SECURITY), imap_verify_ssl support
  • emails/classic.py: Implemented IMAP STARTTLS via asyncio.loop.start_tls() (same pattern as aiosmtplib), added _create_imap_connection() factory, replaced all 6 direct imap_class() calls with _connect_imap(), added _create_imap_ssl_context() for verify_ssl=false (self-signed certs)
  • Tests: 30+ new tests for enum, backward compat, STARTTLS flow, SSL context, env vars. Updated existing tests to match refactored connection logic.
  • README: Documented new security field, env vars, connection modes, ProtonMail Bridge example

Backward Compatibility

  • Existing TOML configs with use_ssl/start_ssl continue to work unchanged
  • Existing env vars (MCP_EMAIL_SERVER_IMAP_SSL, etc.) still work, new *_SECURITY vars take precedence
  • Default behavior unchanged (security = "tls")

ProtonMail Bridge Example

[emails.incoming]
host = "127.0.0.1"
port = 1143
security = "starttls"
verify_ssl = false

[emails.outgoing]
host = "127.0.0.1"
port = 1025
security = "starttls"
verify_ssl = false

Testing

All 168 tests pass (161 existing + 7 updated + 30 new).

Copilot AI review requested due to automatic review settings February 14, 2026 13:55
@codecov
Copy link

codecov bot commented Feb 14, 2026

Codecov Report

❌ Patch coverage is 93.39623% with 7 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
mcp_email_server/config.py 93.8% 1 Missing and 3 partials ⚠️
mcp_email_server/emails/classic.py 92.6% 3 Missing ⚠️

📢 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>
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 through EmailServer + 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.

Comment on lines 247 to 251
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")

Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines 272 to 276
"""Test download_attachment with special folder like [Gmail]/Sent Mail."""
import asyncio

save_path = str(tmp_path / "attachment.pdf")

mock_imap = AsyncMock()
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +54 to +57
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)

Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +57 to +75
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:
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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:

Copilot uses AI. Check for mistakes.
Comment on lines +124 to +131
# 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,
)
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_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.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants