Skip to content

Commit 87051dc

Browse files
ColinCopilot
andcommitted
feat: add IMAP STARTTLS support with RFC 8314 security enum
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>
1 parent 9a1e992 commit 87051dc

File tree

7 files changed

+825
-139
lines changed

7 files changed

+825
-139
lines changed

README.md

Lines changed: 62 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -63,24 +63,36 @@ You can also configure the email server using environment variables, which is pa
6363

6464
#### Available Environment Variables
6565

66-
| Variable | Description | Default | Required |
67-
| --------------------------------------------- | ------------------------------------------------- | ------------- | -------- |
68-
| `MCP_EMAIL_SERVER_ACCOUNT_NAME` | Account identifier | `"default"` | No |
69-
| `MCP_EMAIL_SERVER_FULL_NAME` | Display name | Email prefix | No |
70-
| `MCP_EMAIL_SERVER_EMAIL_ADDRESS` | Email address | - | Yes |
71-
| `MCP_EMAIL_SERVER_USER_NAME` | Login username | Same as email | No |
72-
| `MCP_EMAIL_SERVER_PASSWORD` | Email password | - | Yes |
73-
| `MCP_EMAIL_SERVER_IMAP_HOST` | IMAP server host | - | Yes |
74-
| `MCP_EMAIL_SERVER_IMAP_PORT` | IMAP server port | `993` | No |
75-
| `MCP_EMAIL_SERVER_IMAP_SSL` | Enable IMAP SSL | `true` | No |
76-
| `MCP_EMAIL_SERVER_SMTP_HOST` | SMTP server host | - | Yes |
77-
| `MCP_EMAIL_SERVER_SMTP_PORT` | SMTP server port | `465` | No |
78-
| `MCP_EMAIL_SERVER_SMTP_SSL` | Enable SMTP SSL | `true` | No |
79-
| `MCP_EMAIL_SERVER_SMTP_START_SSL` | Enable STARTTLS | `false` | No |
80-
| `MCP_EMAIL_SERVER_SMTP_VERIFY_SSL` | Verify SSL certificates (disable for self-signed) | `true` | No |
81-
| `MCP_EMAIL_SERVER_ENABLE_ATTACHMENT_DOWNLOAD` | Enable attachment download | `false` | No |
82-
| `MCP_EMAIL_SERVER_SAVE_TO_SENT` | Save sent emails to IMAP Sent folder | `true` | No |
83-
| `MCP_EMAIL_SERVER_SENT_FOLDER_NAME` | Custom Sent folder name (auto-detect if not set) | - | No |
66+
| Variable | Description | Default | Required |
67+
| --------------------------------------------- | ------------------------------------------------------ | ------------- | -------- |
68+
| `MCP_EMAIL_SERVER_ACCOUNT_NAME` | Account identifier | `"default"` | No |
69+
| `MCP_EMAIL_SERVER_FULL_NAME` | Display name | Email prefix | No |
70+
| `MCP_EMAIL_SERVER_EMAIL_ADDRESS` | Email address | - | Yes |
71+
| `MCP_EMAIL_SERVER_USER_NAME` | Login username | Same as email | No |
72+
| `MCP_EMAIL_SERVER_PASSWORD` | Email password | - | Yes |
73+
| `MCP_EMAIL_SERVER_IMAP_HOST` | IMAP server host | - | Yes |
74+
| `MCP_EMAIL_SERVER_IMAP_PORT` | IMAP server port | `993` | No |
75+
| `MCP_EMAIL_SERVER_IMAP_SECURITY` | IMAP connection security: `tls`, `starttls`, or `none` | `tls` | No |
76+
| `MCP_EMAIL_SERVER_IMAP_VERIFY_SSL` | Verify IMAP SSL certificates | `true` | No |
77+
| `MCP_EMAIL_SERVER_SMTP_HOST` | SMTP server host | - | Yes |
78+
| `MCP_EMAIL_SERVER_SMTP_PORT` | SMTP server port | `465` | No |
79+
| `MCP_EMAIL_SERVER_SMTP_SECURITY` | SMTP connection security: `tls`, `starttls`, or `none` | `tls` | No |
80+
| `MCP_EMAIL_SERVER_SMTP_VERIFY_SSL` | Verify SMTP SSL certificates | `true` | No |
81+
| `MCP_EMAIL_SERVER_ENABLE_ATTACHMENT_DOWNLOAD` | Enable attachment download | `false` | No |
82+
| `MCP_EMAIL_SERVER_SAVE_TO_SENT` | Save sent emails to IMAP Sent folder | `true` | No |
83+
| `MCP_EMAIL_SERVER_SENT_FOLDER_NAME` | Custom Sent folder name (auto-detect if not set) | - | No |
84+
85+
> **Deprecated:** `MCP_EMAIL_SERVER_IMAP_SSL`, `MCP_EMAIL_SERVER_SMTP_SSL`, and `MCP_EMAIL_SERVER_SMTP_START_SSL` still work for backward compatibility but are superseded by the `*_SECURITY` variables above.
86+
87+
#### Connection Security Modes
88+
89+
The `security` field (or `*_SECURITY` env var) controls how the connection to the mail server is encrypted, per [RFC 8314](https://tools.ietf.org/html/rfc8314):
90+
91+
| Mode | Description | IMAP Port | SMTP Port |
92+
| ---------- | ----------------------------------------------------- | --------- | --------- |
93+
| `tls` | **Implicit TLS** — encrypted from the first byte | 993 | 465 |
94+
| `starttls` | **STARTTLS** — connect plaintext, then upgrade to TLS | 143 | 587 |
95+
| `none` | **No encryption** — plaintext only (not recommended) | 143 | 25 |
8496

8597
### Enabling Attachment Downloads
8698

@@ -153,7 +165,7 @@ sent_folder_name = "INBOX.Sent"
153165

154166
### Self-Signed Certificates (e.g., ProtonMail Bridge)
155167

156-
If you're using a local mail server with self-signed certificates (like ProtonMail Bridge), you'll need to disable SSL certificate verification:
168+
If you're using a local mail server with self-signed certificates (like ProtonMail Bridge), you'll need to disable SSL certificate verification for both IMAP and SMTP:
157169

158170
```json
159171
{
@@ -162,6 +174,7 @@ If you're using a local mail server with self-signed certificates (like ProtonMa
162174
"command": "uvx",
163175
"args": ["mcp-email-server@latest", "stdio"],
164176
"env": {
177+
"MCP_EMAIL_SERVER_IMAP_VERIFY_SSL": "false",
165178
"MCP_EMAIL_SERVER_SMTP_VERIFY_SSL": "false"
166179
}
167180
}
@@ -176,7 +189,37 @@ Or in TOML configuration:
176189
account_name = "protonmail"
177190
# ... other settings ...
178191

192+
[emails.incoming]
193+
verify_ssl = false
194+
195+
[emails.outgoing]
196+
verify_ssl = false
197+
```
198+
199+
#### ProtonMail Bridge Example
200+
201+
ProtonMail Bridge uses STARTTLS on local ports with self-signed certificates:
202+
203+
```toml
204+
[[emails]]
205+
account_name = "protonmail"
206+
full_name = "Your Name"
207+
email_address = "you@proton.me"
208+
209+
[emails.incoming]
210+
host = "127.0.0.1"
211+
port = 1143
212+
user_name = "you@proton.me"
213+
password = "your-bridge-password"
214+
security = "starttls"
215+
verify_ssl = false
216+
179217
[emails.outgoing]
218+
host = "127.0.0.1"
219+
port = 1025
220+
user_name = "you@proton.me"
221+
password = "your-bridge-password"
222+
security = "starttls"
180223
verify_ssl = false
181224
```
182225

mcp_email_server/config.py

Lines changed: 170 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import datetime
44
import os
5+
from enum import Enum
56
from pathlib import Path
67
from typing import Any
78
from zoneinfo import ZoneInfo
@@ -30,15 +31,77 @@ def _parse_bool_env(value: str | None, default: bool = False) -> bool:
3031
CONFIG_PATH = Path(os.getenv("MCP_EMAIL_SERVER_CONFIG_PATH", DEFAULT_CONFIG_PATH)).expanduser().resolve()
3132

3233

34+
class ConnectionSecurity(str, Enum):
35+
"""Connection security mode per RFC 8314.
36+
37+
- TLS: Implicit TLS — encrypted from the first byte (IMAP port 993, SMTP port 465)
38+
- STARTTLS: Connect plaintext, then upgrade via STARTTLS command (IMAP port 143, SMTP port 587)
39+
- NONE: No encryption (not recommended, only for trusted local connections)
40+
"""
41+
42+
TLS = "tls"
43+
STARTTLS = "starttls"
44+
NONE = "none"
45+
46+
47+
def _parse_security_env(value: str | None, default: ConnectionSecurity | None = None) -> ConnectionSecurity | None:
48+
"""Parse ConnectionSecurity from environment variable string."""
49+
if value is None:
50+
return default
51+
try:
52+
return ConnectionSecurity(value.lower())
53+
except ValueError:
54+
logger.warning(f"Invalid security value '{value}', using default")
55+
return default
56+
57+
3358
class EmailServer(BaseModel):
3459
user_name: str
3560
password: str
3661
host: str
3762
port: int
38-
use_ssl: bool = True # Usually port 465
39-
start_ssl: bool = False # Usually port 587
63+
security: ConnectionSecurity = ConnectionSecurity.TLS
4064
verify_ssl: bool = True # Set to False for self-signed certificates (e.g., ProtonMail Bridge)
4165

66+
# Deprecated: use `security` instead. Kept for backward compatibility with existing configs.
67+
use_ssl: bool | None = None
68+
start_ssl: bool | None = None
69+
70+
@model_validator(mode="before")
71+
@classmethod
72+
def resolve_security_from_legacy(cls, data: Any) -> Any:
73+
"""Derive `security` from deprecated `use_ssl`/`start_ssl` for backward compatibility.
74+
75+
If the new `security` field is explicitly set, it takes precedence.
76+
If only legacy fields are set, `security` is derived from them.
77+
"""
78+
if not isinstance(data, dict):
79+
return data
80+
81+
has_security = "security" in data
82+
use_ssl = data.get("use_ssl")
83+
start_ssl = data.get("start_ssl")
84+
85+
# Only derive from legacy fields when `security` was NOT explicitly provided
86+
if not has_security and (use_ssl is not None or start_ssl is not None):
87+
use_ssl_val = use_ssl if use_ssl is not None else False
88+
start_ssl_val = start_ssl if start_ssl is not None else False
89+
90+
if use_ssl_val and start_ssl_val:
91+
raise ValueError(
92+
"Invalid configuration: 'use_ssl' and 'start_ssl' cannot both be true. "
93+
"Use 'security = \"tls\"' for implicit TLS or 'security = \"starttls\"' for STARTTLS."
94+
)
95+
96+
if use_ssl_val:
97+
data["security"] = ConnectionSecurity.TLS
98+
elif start_ssl_val:
99+
data["security"] = ConnectionSecurity.STARTTLS
100+
else:
101+
data["security"] = ConnectionSecurity.NONE
102+
103+
return data
104+
42105
def masked(self) -> EmailServer:
43106
return self.model_copy(update={"password": "********"})
44107

@@ -101,36 +164,57 @@ def init(
101164
imap_user_name: str | None = None,
102165
imap_password: str | None = None,
103166
imap_port: int = 993,
104-
imap_ssl: bool = True,
167+
imap_security: ConnectionSecurity = ConnectionSecurity.TLS,
168+
imap_verify_ssl: bool = True,
105169
smtp_port: int = 465,
106-
smtp_ssl: bool = True,
107-
smtp_start_ssl: bool = False,
170+
smtp_security: ConnectionSecurity = ConnectionSecurity.TLS,
108171
smtp_verify_ssl: bool = True,
109172
smtp_user_name: str | None = None,
110173
smtp_password: str | None = None,
111174
save_to_sent: bool = True,
112175
sent_folder_name: str | None = None,
176+
# Deprecated parameters for backward compatibility
177+
imap_ssl: bool | None = None,
178+
smtp_ssl: bool | None = None,
179+
smtp_start_ssl: bool | None = None,
113180
) -> EmailSettings:
181+
# Build incoming server config
182+
incoming_kwargs: dict[str, Any] = {
183+
"user_name": imap_user_name or user_name,
184+
"password": imap_password or password,
185+
"host": imap_host,
186+
"port": imap_port,
187+
"verify_ssl": imap_verify_ssl,
188+
}
189+
if imap_ssl is not None:
190+
# Legacy path: use_ssl was explicitly passed
191+
incoming_kwargs["use_ssl"] = imap_ssl
192+
else:
193+
incoming_kwargs["security"] = imap_security
194+
195+
# Build outgoing server config
196+
outgoing_kwargs: dict[str, Any] = {
197+
"user_name": smtp_user_name or user_name,
198+
"password": smtp_password or password,
199+
"host": smtp_host,
200+
"port": smtp_port,
201+
"verify_ssl": smtp_verify_ssl,
202+
}
203+
if smtp_ssl is not None or smtp_start_ssl is not None:
204+
# Legacy path: use_ssl/start_ssl were explicitly passed
205+
if smtp_ssl is not None:
206+
outgoing_kwargs["use_ssl"] = smtp_ssl
207+
if smtp_start_ssl is not None:
208+
outgoing_kwargs["start_ssl"] = smtp_start_ssl
209+
else:
210+
outgoing_kwargs["security"] = smtp_security
211+
114212
return cls(
115213
account_name=account_name,
116214
full_name=full_name,
117215
email_address=email_address,
118-
incoming=EmailServer(
119-
user_name=imap_user_name or user_name,
120-
password=imap_password or password,
121-
host=imap_host,
122-
port=imap_port,
123-
use_ssl=imap_ssl,
124-
),
125-
outgoing=EmailServer(
126-
user_name=smtp_user_name or user_name,
127-
password=smtp_password or password,
128-
host=smtp_host,
129-
port=smtp_port,
130-
use_ssl=smtp_ssl,
131-
start_ssl=smtp_start_ssl,
132-
verify_ssl=smtp_verify_ssl,
133-
),
216+
incoming=EmailServer(**incoming_kwargs),
217+
outgoing=EmailServer(**outgoing_kwargs),
134218
save_to_sent=save_to_sent,
135219
sent_folder_name=sent_folder_name,
136220
)
@@ -147,14 +231,19 @@ def from_env(cls) -> EmailSettings | None:
147231
- MCP_EMAIL_SERVER_PASSWORD
148232
- MCP_EMAIL_SERVER_IMAP_HOST
149233
- MCP_EMAIL_SERVER_IMAP_PORT (default: 993)
150-
- MCP_EMAIL_SERVER_IMAP_SSL (default: true)
234+
- MCP_EMAIL_SERVER_IMAP_SECURITY (default: "tls") — "tls", "starttls", or "none"
235+
- MCP_EMAIL_SERVER_IMAP_VERIFY_SSL (default: true)
151236
- MCP_EMAIL_SERVER_SMTP_HOST
152237
- MCP_EMAIL_SERVER_SMTP_PORT (default: 465)
153-
- MCP_EMAIL_SERVER_SMTP_SSL (default: true)
154-
- MCP_EMAIL_SERVER_SMTP_START_SSL (default: false)
238+
- MCP_EMAIL_SERVER_SMTP_SECURITY (default: "tls") — "tls", "starttls", or "none"
155239
- MCP_EMAIL_SERVER_SMTP_VERIFY_SSL (default: true)
156240
- MCP_EMAIL_SERVER_SAVE_TO_SENT (default: true)
157241
- MCP_EMAIL_SERVER_SENT_FOLDER_NAME (default: auto-detect)
242+
243+
Deprecated (still supported for backward compatibility):
244+
- MCP_EMAIL_SERVER_IMAP_SSL → use MCP_EMAIL_SERVER_IMAP_SECURITY instead
245+
- MCP_EMAIL_SERVER_SMTP_SSL → use MCP_EMAIL_SERVER_SMTP_SECURITY instead
246+
- MCP_EMAIL_SERVER_SMTP_START_SSL → use MCP_EMAIL_SERVER_SMTP_SECURITY instead
158247
"""
159248
# Check if minimum required environment variables are set
160249
email_address = os.getenv("MCP_EMAIL_SERVER_EMAIL_ADDRESS")
@@ -176,31 +265,67 @@ def from_env(cls) -> EmailSettings | None:
176265
return None
177266

178267
try:
179-
return cls.init(
180-
account_name=account_name,
181-
full_name=full_name,
182-
email_address=email_address,
183-
user_name=user_name,
184-
password=password,
185-
imap_host=imap_host,
186-
imap_port=int(os.getenv("MCP_EMAIL_SERVER_IMAP_PORT", "993")),
187-
imap_ssl=_parse_bool_env(os.getenv("MCP_EMAIL_SERVER_IMAP_SSL"), True),
188-
smtp_host=smtp_host,
189-
smtp_port=int(os.getenv("MCP_EMAIL_SERVER_SMTP_PORT", "465")),
190-
smtp_ssl=_parse_bool_env(os.getenv("MCP_EMAIL_SERVER_SMTP_SSL"), True),
191-
smtp_start_ssl=_parse_bool_env(os.getenv("MCP_EMAIL_SERVER_SMTP_START_SSL"), False),
192-
smtp_verify_ssl=_parse_bool_env(os.getenv("MCP_EMAIL_SERVER_SMTP_VERIFY_SSL"), True),
193-
smtp_user_name=os.getenv("MCP_EMAIL_SERVER_SMTP_USER_NAME", user_name),
194-
smtp_password=os.getenv("MCP_EMAIL_SERVER_SMTP_PASSWORD", password),
195-
imap_user_name=os.getenv("MCP_EMAIL_SERVER_IMAP_USER_NAME", user_name),
196-
imap_password=os.getenv("MCP_EMAIL_SERVER_IMAP_PASSWORD", password),
197-
save_to_sent=_parse_bool_env(os.getenv("MCP_EMAIL_SERVER_SAVE_TO_SENT"), True),
198-
sent_folder_name=os.getenv("MCP_EMAIL_SERVER_SENT_FOLDER_NAME"),
199-
)
268+
imap_port = int(os.getenv("MCP_EMAIL_SERVER_IMAP_PORT", "993"))
269+
smtp_port = int(os.getenv("MCP_EMAIL_SERVER_SMTP_PORT", "465"))
270+
except ValueError as e:
271+
logger.error(f"Invalid port configuration: {e}")
272+
return None
273+
274+
init_kwargs: dict[str, Any] = {
275+
"account_name": account_name,
276+
"full_name": full_name,
277+
"email_address": email_address,
278+
"user_name": user_name,
279+
"password": password,
280+
"imap_host": imap_host,
281+
"imap_port": imap_port,
282+
"imap_verify_ssl": _parse_bool_env(os.getenv("MCP_EMAIL_SERVER_IMAP_VERIFY_SSL"), True),
283+
"smtp_host": smtp_host,
284+
"smtp_port": smtp_port,
285+
"smtp_verify_ssl": _parse_bool_env(os.getenv("MCP_EMAIL_SERVER_SMTP_VERIFY_SSL"), True),
286+
"smtp_user_name": os.getenv("MCP_EMAIL_SERVER_SMTP_USER_NAME", user_name),
287+
"smtp_password": os.getenv("MCP_EMAIL_SERVER_SMTP_PASSWORD", password),
288+
"imap_user_name": os.getenv("MCP_EMAIL_SERVER_IMAP_USER_NAME", user_name),
289+
"imap_password": os.getenv("MCP_EMAIL_SERVER_IMAP_PASSWORD", password),
290+
"save_to_sent": _parse_bool_env(os.getenv("MCP_EMAIL_SERVER_SAVE_TO_SENT"), True),
291+
"sent_folder_name": os.getenv("MCP_EMAIL_SERVER_SENT_FOLDER_NAME"),
292+
}
293+
294+
cls._resolve_security_env(init_kwargs)
295+
296+
try:
297+
return cls.init(**init_kwargs)
200298
except (ValueError, TypeError) as e:
201299
logger.error(f"Failed to create email settings from environment variables: {e}")
202300
return None
203301

302+
@staticmethod
303+
def _resolve_security_env(init_kwargs: dict[str, Any]) -> None:
304+
"""Resolve IMAP/SMTP security from env vars, preferring new over legacy."""
305+
imap_security_env = os.getenv("MCP_EMAIL_SERVER_IMAP_SECURITY")
306+
smtp_security_env = os.getenv("MCP_EMAIL_SERVER_SMTP_SECURITY")
307+
308+
if imap_security_env is not None:
309+
security = _parse_security_env(imap_security_env)
310+
if security is not None:
311+
init_kwargs["imap_security"] = security
312+
else:
313+
imap_ssl_env = os.getenv("MCP_EMAIL_SERVER_IMAP_SSL")
314+
if imap_ssl_env is not None:
315+
init_kwargs["imap_ssl"] = _parse_bool_env(imap_ssl_env, True)
316+
317+
if smtp_security_env is not None:
318+
security = _parse_security_env(smtp_security_env)
319+
if security is not None:
320+
init_kwargs["smtp_security"] = security
321+
else:
322+
smtp_ssl_env = os.getenv("MCP_EMAIL_SERVER_SMTP_SSL")
323+
smtp_start_ssl_env = os.getenv("MCP_EMAIL_SERVER_SMTP_START_SSL")
324+
if smtp_ssl_env is not None:
325+
init_kwargs["smtp_ssl"] = _parse_bool_env(smtp_ssl_env, True)
326+
if smtp_start_ssl_env is not None:
327+
init_kwargs["smtp_start_ssl"] = _parse_bool_env(smtp_start_ssl_env, False)
328+
204329
def masked(self) -> EmailSettings:
205330
return self.model_copy(
206331
update={

0 commit comments

Comments
 (0)