Skip to content

Commit 0e9ed77

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 0e9ed77

File tree

7 files changed

+814
-139
lines changed

7 files changed

+814
-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: 164 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,71 @@ 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="after")
71+
@classmethod
72+
def resolve_security_from_legacy(cls, obj: EmailServer) -> EmailServer:
73+
"""Derive `security` from deprecated `use_ssl`/`start_ssl` for backward compatibility.
74+
75+
If both old and new fields are set, `security` takes precedence.
76+
If only old fields are set, derive `security` from them.
77+
"""
78+
# If legacy fields are explicitly set and security was not explicitly provided
79+
# (i.e. it's at default), derive security from legacy fields
80+
if obj.use_ssl is not None or obj.start_ssl is not None:
81+
use_ssl = obj.use_ssl if obj.use_ssl is not None else False
82+
start_ssl = obj.start_ssl if obj.start_ssl is not None else False
83+
84+
if use_ssl and start_ssl:
85+
raise ValueError(
86+
"Invalid configuration: 'use_ssl' and 'start_ssl' cannot both be true. "
87+
"Use 'security = \"tls\"' for implicit TLS or 'security = \"starttls\"' for STARTTLS."
88+
)
89+
90+
if use_ssl:
91+
obj.security = ConnectionSecurity.TLS
92+
elif start_ssl:
93+
obj.security = ConnectionSecurity.STARTTLS
94+
else:
95+
obj.security = ConnectionSecurity.NONE
96+
97+
return obj
98+
4299
def masked(self) -> EmailServer:
43100
return self.model_copy(update={"password": "********"})
44101

@@ -101,36 +158,57 @@ def init(
101158
imap_user_name: str | None = None,
102159
imap_password: str | None = None,
103160
imap_port: int = 993,
104-
imap_ssl: bool = True,
161+
imap_security: ConnectionSecurity = ConnectionSecurity.TLS,
162+
imap_verify_ssl: bool = True,
105163
smtp_port: int = 465,
106-
smtp_ssl: bool = True,
107-
smtp_start_ssl: bool = False,
164+
smtp_security: ConnectionSecurity = ConnectionSecurity.TLS,
108165
smtp_verify_ssl: bool = True,
109166
smtp_user_name: str | None = None,
110167
smtp_password: str | None = None,
111168
save_to_sent: bool = True,
112169
sent_folder_name: str | None = None,
170+
# Deprecated parameters for backward compatibility
171+
imap_ssl: bool | None = None,
172+
smtp_ssl: bool | None = None,
173+
smtp_start_ssl: bool | None = None,
113174
) -> EmailSettings:
175+
# Build incoming server config
176+
incoming_kwargs: dict[str, Any] = {
177+
"user_name": imap_user_name or user_name,
178+
"password": imap_password or password,
179+
"host": imap_host,
180+
"port": imap_port,
181+
"verify_ssl": imap_verify_ssl,
182+
}
183+
if imap_ssl is not None:
184+
# Legacy path: use_ssl was explicitly passed
185+
incoming_kwargs["use_ssl"] = imap_ssl
186+
else:
187+
incoming_kwargs["security"] = imap_security
188+
189+
# Build outgoing server config
190+
outgoing_kwargs: dict[str, Any] = {
191+
"user_name": smtp_user_name or user_name,
192+
"password": smtp_password or password,
193+
"host": smtp_host,
194+
"port": smtp_port,
195+
"verify_ssl": smtp_verify_ssl,
196+
}
197+
if smtp_ssl is not None or smtp_start_ssl is not None:
198+
# Legacy path: use_ssl/start_ssl were explicitly passed
199+
if smtp_ssl is not None:
200+
outgoing_kwargs["use_ssl"] = smtp_ssl
201+
if smtp_start_ssl is not None:
202+
outgoing_kwargs["start_ssl"] = smtp_start_ssl
203+
else:
204+
outgoing_kwargs["security"] = smtp_security
205+
114206
return cls(
115207
account_name=account_name,
116208
full_name=full_name,
117209
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-
),
210+
incoming=EmailServer(**incoming_kwargs),
211+
outgoing=EmailServer(**outgoing_kwargs),
134212
save_to_sent=save_to_sent,
135213
sent_folder_name=sent_folder_name,
136214
)
@@ -147,14 +225,19 @@ def from_env(cls) -> EmailSettings | None:
147225
- MCP_EMAIL_SERVER_PASSWORD
148226
- MCP_EMAIL_SERVER_IMAP_HOST
149227
- MCP_EMAIL_SERVER_IMAP_PORT (default: 993)
150-
- MCP_EMAIL_SERVER_IMAP_SSL (default: true)
228+
- MCP_EMAIL_SERVER_IMAP_SECURITY (default: "tls") — "tls", "starttls", or "none"
229+
- MCP_EMAIL_SERVER_IMAP_VERIFY_SSL (default: true)
151230
- MCP_EMAIL_SERVER_SMTP_HOST
152231
- MCP_EMAIL_SERVER_SMTP_PORT (default: 465)
153-
- MCP_EMAIL_SERVER_SMTP_SSL (default: true)
154-
- MCP_EMAIL_SERVER_SMTP_START_SSL (default: false)
232+
- MCP_EMAIL_SERVER_SMTP_SECURITY (default: "tls") — "tls", "starttls", or "none"
155233
- MCP_EMAIL_SERVER_SMTP_VERIFY_SSL (default: true)
156234
- MCP_EMAIL_SERVER_SAVE_TO_SENT (default: true)
157235
- MCP_EMAIL_SERVER_SENT_FOLDER_NAME (default: auto-detect)
236+
237+
Deprecated (still supported for backward compatibility):
238+
- MCP_EMAIL_SERVER_IMAP_SSL → use MCP_EMAIL_SERVER_IMAP_SECURITY instead
239+
- MCP_EMAIL_SERVER_SMTP_SSL → use MCP_EMAIL_SERVER_SMTP_SECURITY instead
240+
- MCP_EMAIL_SERVER_SMTP_START_SSL → use MCP_EMAIL_SERVER_SMTP_SECURITY instead
158241
"""
159242
# Check if minimum required environment variables are set
160243
email_address = os.getenv("MCP_EMAIL_SERVER_EMAIL_ADDRESS")
@@ -176,31 +259,67 @@ def from_env(cls) -> EmailSettings | None:
176259
return None
177260

178261
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-
)
262+
imap_port = int(os.getenv("MCP_EMAIL_SERVER_IMAP_PORT", "993"))
263+
smtp_port = int(os.getenv("MCP_EMAIL_SERVER_SMTP_PORT", "465"))
264+
except ValueError as e:
265+
logger.error(f"Invalid port configuration: {e}")
266+
return None
267+
268+
init_kwargs: dict[str, Any] = {
269+
"account_name": account_name,
270+
"full_name": full_name,
271+
"email_address": email_address,
272+
"user_name": user_name,
273+
"password": password,
274+
"imap_host": imap_host,
275+
"imap_port": imap_port,
276+
"imap_verify_ssl": _parse_bool_env(os.getenv("MCP_EMAIL_SERVER_IMAP_VERIFY_SSL"), True),
277+
"smtp_host": smtp_host,
278+
"smtp_port": smtp_port,
279+
"smtp_verify_ssl": _parse_bool_env(os.getenv("MCP_EMAIL_SERVER_SMTP_VERIFY_SSL"), True),
280+
"smtp_user_name": os.getenv("MCP_EMAIL_SERVER_SMTP_USER_NAME", user_name),
281+
"smtp_password": os.getenv("MCP_EMAIL_SERVER_SMTP_PASSWORD", password),
282+
"imap_user_name": os.getenv("MCP_EMAIL_SERVER_IMAP_USER_NAME", user_name),
283+
"imap_password": os.getenv("MCP_EMAIL_SERVER_IMAP_PASSWORD", password),
284+
"save_to_sent": _parse_bool_env(os.getenv("MCP_EMAIL_SERVER_SAVE_TO_SENT"), True),
285+
"sent_folder_name": os.getenv("MCP_EMAIL_SERVER_SENT_FOLDER_NAME"),
286+
}
287+
288+
cls._resolve_security_env(init_kwargs)
289+
290+
try:
291+
return cls.init(**init_kwargs)
200292
except (ValueError, TypeError) as e:
201293
logger.error(f"Failed to create email settings from environment variables: {e}")
202294
return None
203295

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

0 commit comments

Comments
 (0)