Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 47 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,23 +63,24 @@ You can also configure the email server using environment variables, which is pa

#### Available Environment Variables

| Variable | Description | Default | Required |
| --------------------------------------------- | ------------------------------------------------ | ------------- | -------- |
| `MCP_EMAIL_SERVER_ACCOUNT_NAME` | Account identifier | `"default"` | No |
| `MCP_EMAIL_SERVER_FULL_NAME` | Display name | Email prefix | No |
| `MCP_EMAIL_SERVER_EMAIL_ADDRESS` | Email address | - | Yes |
| `MCP_EMAIL_SERVER_USER_NAME` | Login username | Same as email | No |
| `MCP_EMAIL_SERVER_PASSWORD` | Email password | - | Yes |
| `MCP_EMAIL_SERVER_IMAP_HOST` | IMAP server host | - | Yes |
| `MCP_EMAIL_SERVER_IMAP_PORT` | IMAP server port | `993` | No |
| `MCP_EMAIL_SERVER_IMAP_SSL` | Enable IMAP SSL | `true` | No |
| `MCP_EMAIL_SERVER_SMTP_HOST` | SMTP server host | - | Yes |
| `MCP_EMAIL_SERVER_SMTP_PORT` | SMTP server port | `465` | No |
| `MCP_EMAIL_SERVER_SMTP_SSL` | Enable SMTP SSL | `true` | No |
| `MCP_EMAIL_SERVER_SMTP_START_SSL` | Enable STARTTLS | `false` | No |
| `MCP_EMAIL_SERVER_ENABLE_ATTACHMENT_DOWNLOAD` | Enable attachment download | `false` | No |
| `MCP_EMAIL_SERVER_SAVE_TO_SENT` | Save sent emails to IMAP Sent folder | `true` | No |
| `MCP_EMAIL_SERVER_SENT_FOLDER_NAME` | Custom Sent folder name (auto-detect if not set) | - | No |
| Variable | Description | Default | Required |
| --------------------------------------------- | ------------------------------------------------- | ------------- | -------- |
| `MCP_EMAIL_SERVER_ACCOUNT_NAME` | Account identifier | `"default"` | No |
| `MCP_EMAIL_SERVER_FULL_NAME` | Display name | Email prefix | No |
| `MCP_EMAIL_SERVER_EMAIL_ADDRESS` | Email address | - | Yes |
| `MCP_EMAIL_SERVER_USER_NAME` | Login username | Same as email | No |
| `MCP_EMAIL_SERVER_PASSWORD` | Email password | - | Yes |
| `MCP_EMAIL_SERVER_IMAP_HOST` | IMAP server host | - | Yes |
| `MCP_EMAIL_SERVER_IMAP_PORT` | IMAP server port | `993` | No |
| `MCP_EMAIL_SERVER_IMAP_SSL` | Enable IMAP SSL | `true` | No |
| `MCP_EMAIL_SERVER_SMTP_HOST` | SMTP server host | - | Yes |
| `MCP_EMAIL_SERVER_SMTP_PORT` | SMTP server port | `465` | No |
| `MCP_EMAIL_SERVER_SMTP_SSL` | Enable SMTP SSL | `true` | No |
| `MCP_EMAIL_SERVER_SMTP_START_SSL` | Enable STARTTLS | `false` | No |
| `MCP_EMAIL_SERVER_SMTP_VERIFY_SSL` | Verify SSL certificates (disable for self-signed) | `true` | No |
| `MCP_EMAIL_SERVER_ENABLE_ATTACHMENT_DOWNLOAD` | Enable attachment download | `false` | No |
| `MCP_EMAIL_SERVER_SAVE_TO_SENT` | Save sent emails to IMAP Sent folder | `true` | No |
| `MCP_EMAIL_SERVER_SENT_FOLDER_NAME` | Custom Sent folder name (auto-detect if not set) | - | No |

### Enabling Attachment Downloads

Expand Down Expand Up @@ -150,6 +151,35 @@ sent_folder_name = "INBOX.Sent"

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

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

If you're using a local mail server with self-signed certificates (like ProtonMail Bridge), you'll need to disable SSL certificate verification:

```json
{
"mcpServers": {
"zerolib-email": {
"command": "uvx",
"args": ["mcp-email-server@latest", "stdio"],
"env": {
"MCP_EMAIL_SERVER_SMTP_VERIFY_SSL": "false"
}
}
}
}
```

Or in TOML configuration:

```toml
[[emails]]
account_name = "protonmail"
# ... other settings ...

[emails.outgoing]
verify_ssl = false
```

For separate IMAP/SMTP credentials, you can also use:

- `MCP_EMAIL_SERVER_IMAP_USER_NAME` / `MCP_EMAIL_SERVER_IMAP_PASSWORD`
Expand Down
5 changes: 5 additions & 0 deletions mcp_email_server/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ class EmailServer(BaseModel):
port: int
use_ssl: bool = True # Usually port 465
start_ssl: bool = False # Usually port 587
verify_ssl: bool = True # Set to False for self-signed certificates (e.g., ProtonMail Bridge)

def masked(self) -> EmailServer:
return self.model_copy(update={"password": "********"})
Expand Down Expand Up @@ -96,6 +97,7 @@ def init(
smtp_port: int = 465,
smtp_ssl: bool = True,
smtp_start_ssl: bool = False,
smtp_verify_ssl: bool = True,
smtp_user_name: str | None = None,
smtp_password: str | None = None,
save_to_sent: bool = True,
Expand All @@ -119,6 +121,7 @@ def init(
port=smtp_port,
use_ssl=smtp_ssl,
start_ssl=smtp_start_ssl,
verify_ssl=smtp_verify_ssl,
),
save_to_sent=save_to_sent,
sent_folder_name=sent_folder_name,
Expand All @@ -141,6 +144,7 @@ def from_env(cls) -> EmailSettings | None:
- MCP_EMAIL_SERVER_SMTP_PORT (default: 465)
- MCP_EMAIL_SERVER_SMTP_SSL (default: true)
- MCP_EMAIL_SERVER_SMTP_START_SSL (default: false)
- MCP_EMAIL_SERVER_SMTP_VERIFY_SSL (default: true)
- MCP_EMAIL_SERVER_SAVE_TO_SENT (default: true)
- MCP_EMAIL_SERVER_SENT_FOLDER_NAME (default: auto-detect)
"""
Expand Down Expand Up @@ -183,6 +187,7 @@ def parse_bool(value: str | None, default: bool = True) -> bool:
smtp_port=int(os.getenv("MCP_EMAIL_SERVER_SMTP_PORT", "465")),
smtp_ssl=parse_bool(os.getenv("MCP_EMAIL_SERVER_SMTP_SSL"), True),
smtp_start_ssl=parse_bool(os.getenv("MCP_EMAIL_SERVER_SMTP_START_SSL"), False),
smtp_verify_ssl=parse_bool(os.getenv("MCP_EMAIL_SERVER_SMTP_VERIFY_SSL"), True),
smtp_user_name=os.getenv("MCP_EMAIL_SERVER_SMTP_USER_NAME", user_name),
smtp_password=os.getenv("MCP_EMAIL_SERVER_SMTP_PASSWORD", password),
imap_user_name=os.getenv("MCP_EMAIL_SERVER_IMAP_USER_NAME", user_name),
Expand Down
21 changes: 21 additions & 0 deletions mcp_email_server/emails/classic.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import email.utils
import mimetypes
import ssl
from collections.abc import AsyncGenerator
from datetime import datetime, timezone
from email.header import Header
Expand Down Expand Up @@ -72,6 +73,20 @@ async def _send_imap_id(imap: aioimaplib.IMAP4 | aioimaplib.IMAP4_SSL) -> None:
logger.warning(f"IMAP ID command failed: {e!s}")


def _create_smtp_ssl_context(verify_ssl: bool) -> ssl.SSLContext | None:
"""Create SSL context for SMTP connections.

Returns None for default verification, or permissive context
for self-signed certificates when verify_ssl=False.
"""
if verify_ssl:
return None
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return ctx


class EmailClient:
def __init__(self, email_server: EmailServer, sender: str | None = None):
self.email_server = email_server
Expand All @@ -81,6 +96,11 @@ def __init__(self, email_server: EmailServer, sender: str | None = None):

self.smtp_use_tls = self.email_server.use_ssl
self.smtp_start_tls = self.email_server.start_ssl
self.smtp_verify_ssl = self.email_server.verify_ssl

def _get_smtp_ssl_context(self) -> ssl.SSLContext | None:
"""Get SSL context for SMTP connections based on verify_ssl setting."""
return _create_smtp_ssl_context(self.smtp_verify_ssl)

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

Expand Down
85 changes: 84 additions & 1 deletion tests/test_email_client.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import asyncio
import email
import ssl
from datetime import datetime, timezone
from email.mime.text import MIMEText
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from mcp_email_server.config import EmailServer
from mcp_email_server.emails.classic import EmailClient
from mcp_email_server.emails.classic import EmailClient, _create_smtp_ssl_context


@pytest.fixture
Expand Down Expand Up @@ -354,3 +355,85 @@ async def test_send_email_without_reply_headers(self, email_client):
msg = call_args[0][0]
assert "In-Reply-To" not in msg
assert "References" not in msg


class TestSmtpSslContext:
"""Tests for SMTP SSL context creation."""

def test_create_smtp_ssl_context_with_verification(self):
"""When verify_ssl=True, should return None (use default verification)."""
result = _create_smtp_ssl_context(verify_ssl=True)
assert result is None

def test_create_smtp_ssl_context_without_verification(self):
"""When verify_ssl=False, should return permissive SSL context."""
result = _create_smtp_ssl_context(verify_ssl=False)

assert result is not None
assert isinstance(result, ssl.SSLContext)
assert result.check_hostname is False
assert result.verify_mode == ssl.CERT_NONE

def test_email_client_get_smtp_ssl_context_default(self):
"""EmailClient should use verify_ssl from EmailServer (default True)."""
server = EmailServer(
user_name="test",
password="test",
host="smtp.example.com",
port=587,
)
client = EmailClient(server)

# Default verify_ssl is True, so should return None
assert client.smtp_verify_ssl is True
assert client._get_smtp_ssl_context() is None

def test_email_client_get_smtp_ssl_context_disabled(self):
"""EmailClient should return permissive context when verify_ssl=False."""
server = EmailServer(
user_name="test",
password="test",
host="smtp.example.com",
port=587,
verify_ssl=False,
)
client = EmailClient(server)

assert client.smtp_verify_ssl is False
ctx = client._get_smtp_ssl_context()
assert ctx is not None
assert ctx.check_hostname is False
assert ctx.verify_mode == ssl.CERT_NONE

@pytest.mark.asyncio
async def test_send_email_passes_tls_context(self):
"""send_email should pass tls_context to SMTP connection."""
server = EmailServer(
user_name="test",
password="test",
host="smtp.example.com",
port=587,
verify_ssl=False,
)
client = EmailClient(server, sender="test@example.com")

mock_smtp = AsyncMock()
mock_smtp.__aenter__.return_value = mock_smtp
mock_smtp.__aexit__.return_value = None
mock_smtp.login = AsyncMock()
mock_smtp.send_message = AsyncMock()

with patch("aiosmtplib.SMTP", return_value=mock_smtp) as mock_smtp_class:
await client.send_email(
recipients=["recipient@example.com"],
subject="Test",
body="Body",
)

# Verify SMTP was called with tls_context
call_kwargs = mock_smtp_class.call_args.kwargs
assert "tls_context" in call_kwargs
ctx = call_kwargs["tls_context"]
assert ctx is not None
assert ctx.check_hostname is False
assert ctx.verify_mode == ssl.CERT_NONE
Loading