Skip to content

Commit 40d1b58

Browse files
authored
feat: add verify_ssl option for SMTP connections (#105)
feat: add verify_ssl option for SMTP connections
2 parents d9e62c6 + 1047e55 commit 40d1b58

File tree

4 files changed

+157
-18
lines changed

4 files changed

+157
-18
lines changed

README.md

Lines changed: 47 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -63,23 +63,24 @@ 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_ENABLE_ATTACHMENT_DOWNLOAD` | Enable attachment download | `false` | No |
81-
| `MCP_EMAIL_SERVER_SAVE_TO_SENT` | Save sent emails to IMAP Sent folder | `true` | No |
82-
| `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_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 |
8384

8485
### Enabling Attachment Downloads
8586

@@ -150,6 +151,35 @@ sent_folder_name = "INBOX.Sent"
150151

151152
**To disable saving to Sent folder**, set `MCP_EMAIL_SERVER_SAVE_TO_SENT=false` or `save_to_sent = false` in your TOML config.
152153

154+
### Self-Signed Certificates (e.g., ProtonMail Bridge)
155+
156+
If you're using a local mail server with self-signed certificates (like ProtonMail Bridge), you'll need to disable SSL certificate verification:
157+
158+
```json
159+
{
160+
"mcpServers": {
161+
"zerolib-email": {
162+
"command": "uvx",
163+
"args": ["mcp-email-server@latest", "stdio"],
164+
"env": {
165+
"MCP_EMAIL_SERVER_SMTP_VERIFY_SSL": "false"
166+
}
167+
}
168+
}
169+
}
170+
```
171+
172+
Or in TOML configuration:
173+
174+
```toml
175+
[[emails]]
176+
account_name = "protonmail"
177+
# ... other settings ...
178+
179+
[emails.outgoing]
180+
verify_ssl = false
181+
```
182+
153183
For separate IMAP/SMTP credentials, you can also use:
154184

155185
- `MCP_EMAIL_SERVER_IMAP_USER_NAME` / `MCP_EMAIL_SERVER_IMAP_PASSWORD`

mcp_email_server/config.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ class EmailServer(BaseModel):
2929
port: int
3030
use_ssl: bool = True # Usually port 465
3131
start_ssl: bool = False # Usually port 587
32+
verify_ssl: bool = True # Set to False for self-signed certificates (e.g., ProtonMail Bridge)
3233

3334
def masked(self) -> EmailServer:
3435
return self.model_copy(update={"password": "********"})
@@ -96,6 +97,7 @@ def init(
9697
smtp_port: int = 465,
9798
smtp_ssl: bool = True,
9899
smtp_start_ssl: bool = False,
100+
smtp_verify_ssl: bool = True,
99101
smtp_user_name: str | None = None,
100102
smtp_password: str | None = None,
101103
save_to_sent: bool = True,
@@ -119,6 +121,7 @@ def init(
119121
port=smtp_port,
120122
use_ssl=smtp_ssl,
121123
start_ssl=smtp_start_ssl,
124+
verify_ssl=smtp_verify_ssl,
122125
),
123126
save_to_sent=save_to_sent,
124127
sent_folder_name=sent_folder_name,
@@ -141,6 +144,7 @@ def from_env(cls) -> EmailSettings | None:
141144
- MCP_EMAIL_SERVER_SMTP_PORT (default: 465)
142145
- MCP_EMAIL_SERVER_SMTP_SSL (default: true)
143146
- MCP_EMAIL_SERVER_SMTP_START_SSL (default: false)
147+
- MCP_EMAIL_SERVER_SMTP_VERIFY_SSL (default: true)
144148
- MCP_EMAIL_SERVER_SAVE_TO_SENT (default: true)
145149
- MCP_EMAIL_SERVER_SENT_FOLDER_NAME (default: auto-detect)
146150
"""
@@ -183,6 +187,7 @@ def parse_bool(value: str | None, default: bool = True) -> bool:
183187
smtp_port=int(os.getenv("MCP_EMAIL_SERVER_SMTP_PORT", "465")),
184188
smtp_ssl=parse_bool(os.getenv("MCP_EMAIL_SERVER_SMTP_SSL"), True),
185189
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),
186191
smtp_user_name=os.getenv("MCP_EMAIL_SERVER_SMTP_USER_NAME", user_name),
187192
smtp_password=os.getenv("MCP_EMAIL_SERVER_SMTP_PASSWORD", password),
188193
imap_user_name=os.getenv("MCP_EMAIL_SERVER_IMAP_USER_NAME", user_name),

mcp_email_server/emails/classic.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import email.utils
22
import mimetypes
3+
import ssl
34
from collections.abc import AsyncGenerator
45
from datetime import datetime, timezone
56
from email.header import Header
@@ -72,6 +73,20 @@ async def _send_imap_id(imap: aioimaplib.IMAP4 | aioimaplib.IMAP4_SSL) -> None:
7273
logger.warning(f"IMAP ID command failed: {e!s}")
7374

7475

76+
def _create_smtp_ssl_context(verify_ssl: bool) -> ssl.SSLContext | None:
77+
"""Create SSL context for SMTP connections.
78+
79+
Returns None for default verification, or permissive context
80+
for self-signed certificates when verify_ssl=False.
81+
"""
82+
if verify_ssl:
83+
return None
84+
ctx = ssl.create_default_context()
85+
ctx.check_hostname = False
86+
ctx.verify_mode = ssl.CERT_NONE
87+
return ctx
88+
89+
7590
class EmailClient:
7691
def __init__(self, email_server: EmailServer, sender: str | None = None):
7792
self.email_server = email_server
@@ -81,6 +96,11 @@ def __init__(self, email_server: EmailServer, sender: str | None = None):
8196

8297
self.smtp_use_tls = self.email_server.use_ssl
8398
self.smtp_start_tls = self.email_server.start_ssl
99+
self.smtp_verify_ssl = self.email_server.verify_ssl
100+
101+
def _get_smtp_ssl_context(self) -> ssl.SSLContext | None:
102+
"""Get SSL context for SMTP connections based on verify_ssl setting."""
103+
return _create_smtp_ssl_context(self.smtp_verify_ssl)
84104

85105
def _parse_email_data(self, raw_email: bytes, email_id: str | None = None) -> dict[str, Any]: # noqa: C901
86106
"""Parse raw email data into a structured dictionary."""
@@ -630,6 +650,7 @@ async def send_email(
630650
port=self.email_server.port,
631651
start_tls=self.smtp_start_tls,
632652
use_tls=self.smtp_use_tls,
653+
tls_context=self._get_smtp_ssl_context(),
633654
) as smtp:
634655
await smtp.login(self.email_server.user_name, self.email_server.password)
635656

tests/test_email_client.py

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import asyncio
22
import email
3+
import ssl
34
from datetime import datetime, timezone
45
from email.mime.text import MIMEText
56
from unittest.mock import AsyncMock, MagicMock, patch
67

78
import pytest
89

910
from mcp_email_server.config import EmailServer
10-
from mcp_email_server.emails.classic import EmailClient
11+
from mcp_email_server.emails.classic import EmailClient, _create_smtp_ssl_context
1112

1213

1314
@pytest.fixture
@@ -354,3 +355,85 @@ async def test_send_email_without_reply_headers(self, email_client):
354355
msg = call_args[0][0]
355356
assert "In-Reply-To" not in msg
356357
assert "References" not in msg
358+
359+
360+
class TestSmtpSslContext:
361+
"""Tests for SMTP SSL context creation."""
362+
363+
def test_create_smtp_ssl_context_with_verification(self):
364+
"""When verify_ssl=True, should return None (use default verification)."""
365+
result = _create_smtp_ssl_context(verify_ssl=True)
366+
assert result is None
367+
368+
def test_create_smtp_ssl_context_without_verification(self):
369+
"""When verify_ssl=False, should return permissive SSL context."""
370+
result = _create_smtp_ssl_context(verify_ssl=False)
371+
372+
assert result is not None
373+
assert isinstance(result, ssl.SSLContext)
374+
assert result.check_hostname is False
375+
assert result.verify_mode == ssl.CERT_NONE
376+
377+
def test_email_client_get_smtp_ssl_context_default(self):
378+
"""EmailClient should use verify_ssl from EmailServer (default True)."""
379+
server = EmailServer(
380+
user_name="test",
381+
password="test",
382+
host="smtp.example.com",
383+
port=587,
384+
)
385+
client = EmailClient(server)
386+
387+
# Default verify_ssl is True, so should return None
388+
assert client.smtp_verify_ssl is True
389+
assert client._get_smtp_ssl_context() is None
390+
391+
def test_email_client_get_smtp_ssl_context_disabled(self):
392+
"""EmailClient should return permissive context when verify_ssl=False."""
393+
server = EmailServer(
394+
user_name="test",
395+
password="test",
396+
host="smtp.example.com",
397+
port=587,
398+
verify_ssl=False,
399+
)
400+
client = EmailClient(server)
401+
402+
assert client.smtp_verify_ssl is False
403+
ctx = client._get_smtp_ssl_context()
404+
assert ctx is not None
405+
assert ctx.check_hostname is False
406+
assert ctx.verify_mode == ssl.CERT_NONE
407+
408+
@pytest.mark.asyncio
409+
async def test_send_email_passes_tls_context(self):
410+
"""send_email should pass tls_context to SMTP connection."""
411+
server = EmailServer(
412+
user_name="test",
413+
password="test",
414+
host="smtp.example.com",
415+
port=587,
416+
verify_ssl=False,
417+
)
418+
client = EmailClient(server, sender="test@example.com")
419+
420+
mock_smtp = AsyncMock()
421+
mock_smtp.__aenter__.return_value = mock_smtp
422+
mock_smtp.__aexit__.return_value = None
423+
mock_smtp.login = AsyncMock()
424+
mock_smtp.send_message = AsyncMock()
425+
426+
with patch("aiosmtplib.SMTP", return_value=mock_smtp) as mock_smtp_class:
427+
await client.send_email(
428+
recipients=["recipient@example.com"],
429+
subject="Test",
430+
body="Body",
431+
)
432+
433+
# Verify SMTP was called with tls_context
434+
call_kwargs = mock_smtp_class.call_args.kwargs
435+
assert "tls_context" in call_kwargs
436+
ctx = call_kwargs["tls_context"]
437+
assert ctx is not None
438+
assert ctx.check_hostname is False
439+
assert ctx.verify_mode == ssl.CERT_NONE

0 commit comments

Comments
 (0)