Skip to content

Commit a659258

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 de08972 commit a659258

File tree

6 files changed

+740
-92
lines changed

6 files changed

+740
-92
lines changed

README.md

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -72,16 +72,28 @@ You can also configure the email server using environment variables, which is pa
7272
| `MCP_EMAIL_SERVER_PASSWORD` | Email password | - | Yes |
7373
| `MCP_EMAIL_SERVER_IMAP_HOST` | IMAP server host | - | Yes |
7474
| `MCP_EMAIL_SERVER_IMAP_PORT` | IMAP server port | `993` | No |
75-
| `MCP_EMAIL_SERVER_IMAP_SSL` | Enable IMAP SSL | `true` | 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 |
7677
| `MCP_EMAIL_SERVER_SMTP_HOST` | SMTP server host | - | Yes |
7778
| `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 |
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 |
8181
| `MCP_EMAIL_SERVER_ENABLE_ATTACHMENT_DOWNLOAD` | Enable attachment download | `false` | No |
8282
| `MCP_EMAIL_SERVER_SAVE_TO_SENT` | Save sent emails to IMAP Sent folder | `true` | No |
8383
| `MCP_EMAIL_SERVER_SENT_FOLDER_NAME` | Custom Sent folder name (auto-detect if not set) | - | No |
8484

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 |
96+
8597
### Enabling Attachment Downloads
8698

8799
By default, downloading email attachments is disabled for security reasons. To enable this feature, you can either:
@@ -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: 155 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
@@ -22,15 +23,60 @@
2223
CONFIG_PATH = Path(os.getenv("MCP_EMAIL_SERVER_CONFIG_PATH", DEFAILT_CONFIG_PATH)).expanduser().resolve()
2324

2425

26+
class ConnectionSecurity(str, Enum):
27+
"""Connection security mode per RFC 8314.
28+
29+
- TLS: Implicit TLS — encrypted from the first byte (IMAP port 993, SMTP port 465)
30+
- STARTTLS: Connect plaintext, then upgrade via STARTTLS command (IMAP port 143, SMTP port 587)
31+
- NONE: No encryption (not recommended, only for trusted local connections)
32+
"""
33+
34+
TLS = "tls"
35+
STARTTLS = "starttls"
36+
NONE = "none"
37+
38+
2539
class EmailServer(BaseModel):
2640
user_name: str
2741
password: str
2842
host: str
2943
port: int
30-
use_ssl: bool = True # Usually port 465
31-
start_ssl: bool = False # Usually port 587
44+
security: ConnectionSecurity = ConnectionSecurity.TLS
3245
verify_ssl: bool = True # Set to False for self-signed certificates (e.g., ProtonMail Bridge)
3346

47+
# Deprecated: use `security` instead. Kept for backward compatibility with existing configs.
48+
use_ssl: bool | None = None
49+
start_ssl: bool | None = None
50+
51+
@model_validator(mode="after")
52+
@classmethod
53+
def resolve_security_from_legacy(cls, obj: EmailServer) -> EmailServer:
54+
"""Derive `security` from deprecated `use_ssl`/`start_ssl` for backward compatibility.
55+
56+
If both old and new fields are set, `security` takes precedence.
57+
If only old fields are set, derive `security` from them.
58+
"""
59+
# If legacy fields are explicitly set and security was not explicitly provided
60+
# (i.e. it's at default), derive security from legacy fields
61+
if obj.use_ssl is not None or obj.start_ssl is not None:
62+
use_ssl = obj.use_ssl if obj.use_ssl is not None else False
63+
start_ssl = obj.start_ssl if obj.start_ssl is not None else False
64+
65+
if use_ssl and start_ssl:
66+
raise ValueError(
67+
"Invalid configuration: 'use_ssl' and 'start_ssl' cannot both be true. "
68+
"Use 'security = \"tls\"' for implicit TLS or 'security = \"starttls\"' for STARTTLS."
69+
)
70+
71+
if use_ssl:
72+
obj.security = ConnectionSecurity.TLS
73+
elif start_ssl:
74+
obj.security = ConnectionSecurity.STARTTLS
75+
else:
76+
obj.security = ConnectionSecurity.NONE
77+
78+
return obj
79+
3480
def masked(self) -> EmailServer:
3581
return self.model_copy(update={"password": "********"})
3682

@@ -93,36 +139,57 @@ def init(
93139
imap_user_name: str | None = None,
94140
imap_password: str | None = None,
95141
imap_port: int = 993,
96-
imap_ssl: bool = True,
142+
imap_security: ConnectionSecurity = ConnectionSecurity.TLS,
143+
imap_verify_ssl: bool = True,
97144
smtp_port: int = 465,
98-
smtp_ssl: bool = True,
99-
smtp_start_ssl: bool = False,
145+
smtp_security: ConnectionSecurity = ConnectionSecurity.TLS,
100146
smtp_verify_ssl: bool = True,
101147
smtp_user_name: str | None = None,
102148
smtp_password: str | None = None,
103149
save_to_sent: bool = True,
104150
sent_folder_name: str | None = None,
151+
# Deprecated parameters for backward compatibility
152+
imap_ssl: bool | None = None,
153+
smtp_ssl: bool | None = None,
154+
smtp_start_ssl: bool | None = None,
105155
) -> EmailSettings:
156+
# Build incoming server config
157+
incoming_kwargs: dict[str, Any] = {
158+
"user_name": imap_user_name or user_name,
159+
"password": imap_password or password,
160+
"host": imap_host,
161+
"port": imap_port,
162+
"verify_ssl": imap_verify_ssl,
163+
}
164+
if imap_ssl is not None:
165+
# Legacy path: use_ssl was explicitly passed
166+
incoming_kwargs["use_ssl"] = imap_ssl
167+
else:
168+
incoming_kwargs["security"] = imap_security
169+
170+
# Build outgoing server config
171+
outgoing_kwargs: dict[str, Any] = {
172+
"user_name": smtp_user_name or user_name,
173+
"password": smtp_password or password,
174+
"host": smtp_host,
175+
"port": smtp_port,
176+
"verify_ssl": smtp_verify_ssl,
177+
}
178+
if smtp_ssl is not None or smtp_start_ssl is not None:
179+
# Legacy path: use_ssl/start_ssl were explicitly passed
180+
if smtp_ssl is not None:
181+
outgoing_kwargs["use_ssl"] = smtp_ssl
182+
if smtp_start_ssl is not None:
183+
outgoing_kwargs["start_ssl"] = smtp_start_ssl
184+
else:
185+
outgoing_kwargs["security"] = smtp_security
186+
106187
return cls(
107188
account_name=account_name,
108189
full_name=full_name,
109190
email_address=email_address,
110-
incoming=EmailServer(
111-
user_name=imap_user_name or user_name,
112-
password=imap_password or password,
113-
host=imap_host,
114-
port=imap_port,
115-
use_ssl=imap_ssl,
116-
),
117-
outgoing=EmailServer(
118-
user_name=smtp_user_name or user_name,
119-
password=smtp_password or password,
120-
host=smtp_host,
121-
port=smtp_port,
122-
use_ssl=smtp_ssl,
123-
start_ssl=smtp_start_ssl,
124-
verify_ssl=smtp_verify_ssl,
125-
),
191+
incoming=EmailServer(**incoming_kwargs),
192+
outgoing=EmailServer(**outgoing_kwargs),
126193
save_to_sent=save_to_sent,
127194
sent_folder_name=sent_folder_name,
128195
)
@@ -139,14 +206,19 @@ def from_env(cls) -> EmailSettings | None:
139206
- MCP_EMAIL_SERVER_PASSWORD
140207
- MCP_EMAIL_SERVER_IMAP_HOST
141208
- MCP_EMAIL_SERVER_IMAP_PORT (default: 993)
142-
- MCP_EMAIL_SERVER_IMAP_SSL (default: true)
209+
- MCP_EMAIL_SERVER_IMAP_SECURITY (default: "tls") — "tls", "starttls", or "none"
210+
- MCP_EMAIL_SERVER_IMAP_VERIFY_SSL (default: true)
143211
- MCP_EMAIL_SERVER_SMTP_HOST
144212
- MCP_EMAIL_SERVER_SMTP_PORT (default: 465)
145-
- MCP_EMAIL_SERVER_SMTP_SSL (default: true)
146-
- MCP_EMAIL_SERVER_SMTP_START_SSL (default: false)
213+
- MCP_EMAIL_SERVER_SMTP_SECURITY (default: "tls") — "tls", "starttls", or "none"
147214
- MCP_EMAIL_SERVER_SMTP_VERIFY_SSL (default: true)
148215
- MCP_EMAIL_SERVER_SAVE_TO_SENT (default: true)
149216
- MCP_EMAIL_SERVER_SENT_FOLDER_NAME (default: auto-detect)
217+
218+
Deprecated (still supported for backward compatibility):
219+
- MCP_EMAIL_SERVER_IMAP_SSL → use MCP_EMAIL_SERVER_IMAP_SECURITY instead
220+
- MCP_EMAIL_SERVER_SMTP_SSL → use MCP_EMAIL_SERVER_SMTP_SECURITY instead
221+
- MCP_EMAIL_SERVER_SMTP_START_SSL → use MCP_EMAIL_SERVER_SMTP_SECURITY instead
150222
"""
151223
# Check if minimum required environment variables are set
152224
email_address = os.getenv("MCP_EMAIL_SERVER_EMAIL_ADDRESS")
@@ -161,6 +233,15 @@ def parse_bool(value: str | None, default: bool = True) -> bool:
161233
return default
162234
return value.lower() in ("true", "1", "yes", "on")
163235

236+
def parse_security(value: str | None, default: ConnectionSecurity = ConnectionSecurity.TLS) -> ConnectionSecurity:
237+
if value is None:
238+
return default
239+
try:
240+
return ConnectionSecurity(value.lower())
241+
except ValueError:
242+
logger.warning(f"Invalid security value '{value}', using default '{default.value}'")
243+
return default
244+
164245
# Get all environment variables with defaults
165246
account_name = os.getenv("MCP_EMAIL_SERVER_ACCOUNT_NAME", "default")
166247
full_name = os.getenv("MCP_EMAIL_SERVER_FULL_NAME", email_address.split("@")[0])
@@ -173,28 +254,57 @@ def parse_bool(value: str | None, default: bool = True) -> bool:
173254
logger.warning("Missing required email configuration environment variables (IMAP_HOST or SMTP_HOST)")
174255
return None
175256

257+
# Resolve security settings: new env vars take precedence over legacy
258+
imap_security_env = os.getenv("MCP_EMAIL_SERVER_IMAP_SECURITY")
259+
smtp_security_env = os.getenv("MCP_EMAIL_SERVER_SMTP_SECURITY")
260+
imap_ssl_env = os.getenv("MCP_EMAIL_SERVER_IMAP_SSL")
261+
smtp_ssl_env = os.getenv("MCP_EMAIL_SERVER_SMTP_SSL")
262+
smtp_start_ssl_env = os.getenv("MCP_EMAIL_SERVER_SMTP_START_SSL")
263+
264+
try:
265+
imap_port = int(os.getenv("MCP_EMAIL_SERVER_IMAP_PORT", "993"))
266+
smtp_port = int(os.getenv("MCP_EMAIL_SERVER_SMTP_PORT", "465"))
267+
except ValueError as e:
268+
logger.error(f"Invalid port configuration: {e}")
269+
return None
270+
271+
init_kwargs: dict[str, Any] = {
272+
"account_name": account_name,
273+
"full_name": full_name,
274+
"email_address": email_address,
275+
"user_name": user_name,
276+
"password": password,
277+
"imap_host": imap_host,
278+
"imap_port": imap_port,
279+
"imap_verify_ssl": parse_bool(os.getenv("MCP_EMAIL_SERVER_IMAP_VERIFY_SSL"), True),
280+
"smtp_host": smtp_host,
281+
"smtp_port": smtp_port,
282+
"smtp_verify_ssl": parse_bool(os.getenv("MCP_EMAIL_SERVER_SMTP_VERIFY_SSL"), True),
283+
"smtp_user_name": os.getenv("MCP_EMAIL_SERVER_SMTP_USER_NAME", user_name),
284+
"smtp_password": os.getenv("MCP_EMAIL_SERVER_SMTP_PASSWORD", password),
285+
"imap_user_name": os.getenv("MCP_EMAIL_SERVER_IMAP_USER_NAME", user_name),
286+
"imap_password": os.getenv("MCP_EMAIL_SERVER_IMAP_PASSWORD", password),
287+
"save_to_sent": parse_bool(os.getenv("MCP_EMAIL_SERVER_SAVE_TO_SENT"), True),
288+
"sent_folder_name": os.getenv("MCP_EMAIL_SERVER_SENT_FOLDER_NAME"),
289+
}
290+
291+
# IMAP security
292+
if imap_security_env is not None:
293+
init_kwargs["imap_security"] = parse_security(imap_security_env)
294+
elif imap_ssl_env is not None:
295+
init_kwargs["imap_ssl"] = parse_bool(imap_ssl_env, True)
296+
297+
# SMTP security
298+
if smtp_security_env is not None:
299+
init_kwargs["smtp_security"] = parse_security(smtp_security_env)
300+
elif smtp_ssl_env is not None or smtp_start_ssl_env is not None:
301+
if smtp_ssl_env is not None:
302+
init_kwargs["smtp_ssl"] = parse_bool(smtp_ssl_env, True)
303+
if smtp_start_ssl_env is not None:
304+
init_kwargs["smtp_start_ssl"] = parse_bool(smtp_start_ssl_env, False)
305+
176306
try:
177-
return cls.init(
178-
account_name=account_name,
179-
full_name=full_name,
180-
email_address=email_address,
181-
user_name=user_name,
182-
password=password,
183-
imap_host=imap_host,
184-
imap_port=int(os.getenv("MCP_EMAIL_SERVER_IMAP_PORT", "993")),
185-
imap_ssl=parse_bool(os.getenv("MCP_EMAIL_SERVER_IMAP_SSL"), True),
186-
smtp_host=smtp_host,
187-
smtp_port=int(os.getenv("MCP_EMAIL_SERVER_SMTP_PORT", "465")),
188-
smtp_ssl=parse_bool(os.getenv("MCP_EMAIL_SERVER_SMTP_SSL"), True),
189-
smtp_start_ssl=parse_bool(os.getenv("MCP_EMAIL_SERVER_SMTP_START_SSL"), False),
190-
smtp_verify_ssl=parse_bool(os.getenv("MCP_EMAIL_SERVER_SMTP_VERIFY_SSL"), True),
191-
smtp_user_name=os.getenv("MCP_EMAIL_SERVER_SMTP_USER_NAME", user_name),
192-
smtp_password=os.getenv("MCP_EMAIL_SERVER_SMTP_PASSWORD", password),
193-
imap_user_name=os.getenv("MCP_EMAIL_SERVER_IMAP_USER_NAME", user_name),
194-
imap_password=os.getenv("MCP_EMAIL_SERVER_IMAP_PASSWORD", password),
195-
save_to_sent=parse_bool(os.getenv("MCP_EMAIL_SERVER_SAVE_TO_SENT"), True),
196-
sent_folder_name=os.getenv("MCP_EMAIL_SERVER_SENT_FOLDER_NAME"),
197-
)
307+
return cls.init(**init_kwargs)
198308
except (ValueError, TypeError) as e:
199309
logger.error(f"Failed to create email settings from environment variables: {e}")
200310
return None

0 commit comments

Comments
 (0)