Skip to content
Closed
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
81 changes: 62 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,24 +63,36 @@ 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_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 |
| 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_SECURITY` | IMAP connection security: `tls`, `starttls`, or `none` | `tls` | No |
| `MCP_EMAIL_SERVER_IMAP_VERIFY_SSL` | Verify IMAP SSL certificates | `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_SECURITY` | SMTP connection security: `tls`, `starttls`, or `none` | `tls` | No |
| `MCP_EMAIL_SERVER_SMTP_VERIFY_SSL` | Verify SMTP SSL certificates | `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 |

> **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.
#### Connection Security Modes

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):

| Mode | Description | IMAP Port | SMTP Port |
| ---------- | ----------------------------------------------------- | --------- | --------- |
| `tls` | **Implicit TLS** — encrypted from the first byte | 993 | 465 |
| `starttls` | **STARTTLS** — connect plaintext, then upgrade to TLS | 143 | 587 |
| `none` | **No encryption** — plaintext only (not recommended) | 143 | 25 |

### Enabling Attachment Downloads

Expand Down Expand Up @@ -153,7 +165,7 @@ sent_folder_name = "INBOX.Sent"

### 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:
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:

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

[emails.incoming]
verify_ssl = false

[emails.outgoing]
verify_ssl = false
```

#### ProtonMail Bridge Example

ProtonMail Bridge uses STARTTLS on local ports with self-signed certificates:

```toml
[[emails]]
account_name = "protonmail"
full_name = "Your Name"
email_address = "you@proton.me"

[emails.incoming]
host = "127.0.0.1"
port = 1143
user_name = "you@proton.me"
password = "your-bridge-password"
security = "starttls"
verify_ssl = false

[emails.outgoing]
host = "127.0.0.1"
port = 1025
user_name = "you@proton.me"
password = "your-bridge-password"
security = "starttls"
verify_ssl = false
```

Expand Down
202 changes: 157 additions & 45 deletions mcp_email_server/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import datetime
import os
from enum import Enum
from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo
Expand All @@ -22,15 +23,60 @@
CONFIG_PATH = Path(os.getenv("MCP_EMAIL_SERVER_CONFIG_PATH", DEFAILT_CONFIG_PATH)).expanduser().resolve()


class ConnectionSecurity(str, Enum):
"""Connection security mode per RFC 8314.

- TLS: Implicit TLS — encrypted from the first byte (IMAP port 993, SMTP port 465)
- STARTTLS: Connect plaintext, then upgrade via STARTTLS command (IMAP port 143, SMTP port 587)
- NONE: No encryption (not recommended, only for trusted local connections)
"""

TLS = "tls"
STARTTLS = "starttls"
NONE = "none"


class EmailServer(BaseModel):
user_name: str
password: str
host: str
port: int
use_ssl: bool = True # Usually port 465
start_ssl: bool = False # Usually port 587
security: ConnectionSecurity = ConnectionSecurity.TLS
verify_ssl: bool = True # Set to False for self-signed certificates (e.g., ProtonMail Bridge)

# Deprecated: use `security` instead. Kept for backward compatibility with existing configs.
use_ssl: bool | None = None
start_ssl: bool | None = None

@model_validator(mode="after")
@classmethod
def resolve_security_from_legacy(cls, obj: EmailServer) -> EmailServer:
"""Derive `security` from deprecated `use_ssl`/`start_ssl` for backward compatibility.

If both old and new fields are set, `security` takes precedence.
If only old fields are set, derive `security` from them.
"""
# If legacy fields are explicitly set and security was not explicitly provided
# (i.e. it's at default), derive security from legacy fields
if obj.use_ssl is not None or obj.start_ssl is not None:
use_ssl = obj.use_ssl if obj.use_ssl is not None else False
start_ssl = obj.start_ssl if obj.start_ssl is not None else False

if use_ssl and start_ssl:
raise ValueError(
"Invalid configuration: 'use_ssl' and 'start_ssl' cannot both be true. "
"Use 'security = \"tls\"' for implicit TLS or 'security = \"starttls\"' for STARTTLS."
)

if use_ssl:
obj.security = ConnectionSecurity.TLS
elif start_ssl:
obj.security = ConnectionSecurity.STARTTLS
else:
Comment on lines +57 to +75
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolve_security_from_legacy currently overwrites security whenever either legacy field is present, even if security was explicitly provided. This contradicts the docstring (“security takes precedence”) and can downgrade a config (e.g., security="tls" plus start_ssl=False ends up as none). Use obj.model_fields_set (or equivalent) to only derive from legacy fields when security was not set by the user, and avoid treating an explicitly-false start_ssl without use_ssl as meaning plaintext by default.

Suggested change
If only old fields are set, derive `security` from them.
"""
# If legacy fields are explicitly set and security was not explicitly provided
# (i.e. it's at default), derive security from legacy fields
if obj.use_ssl is not None or obj.start_ssl is not None:
use_ssl = obj.use_ssl if obj.use_ssl is not None else False
start_ssl = obj.start_ssl if obj.start_ssl is not None else False
if use_ssl and start_ssl:
raise ValueError(
"Invalid configuration: 'use_ssl' and 'start_ssl' cannot both be true. "
"Use 'security = \"tls\"' for implicit TLS or 'security = \"starttls\"' for STARTTLS."
)
if use_ssl:
obj.security = ConnectionSecurity.TLS
elif start_ssl:
obj.security = ConnectionSecurity.STARTTLS
else:
If `security` is not set but old fields are, derive `security` from them.
"""
# Respect explicit `security` configuration: never override it with legacy fields.
fields_set = getattr(obj, "model_fields_set", set())
if "security" in fields_set:
return obj
legacy_use_ssl_set = "use_ssl" in fields_set
legacy_start_ssl_set = "start_ssl" in fields_set
# If neither legacy field was explicitly set, nothing to do.
if not (legacy_use_ssl_set or legacy_start_ssl_set):
return obj
use_ssl = bool(obj.use_ssl)
start_ssl = bool(obj.start_ssl)
if use_ssl and start_ssl:
raise ValueError(
"Invalid configuration: 'use_ssl' and 'start_ssl' cannot both be true. "
"Use 'security = \"tls\"' for implicit TLS or 'security = \"starttls\"' for STARTTLS."
)
if use_ssl:
obj.security = ConnectionSecurity.TLS
elif start_ssl:
obj.security = ConnectionSecurity.STARTTLS
else:
# Only treat legacy flags as an explicit request for plaintext when both
# legacy fields were provided (even if set to False). This avoids
# interpreting a lone `start_ssl = false` (or `use_ssl = false`) as
# "force plaintext" and leaves the default `security` intact instead.
if legacy_use_ssl_set and legacy_start_ssl_set:

Copilot uses AI. Check for mistakes.
obj.security = ConnectionSecurity.NONE

return obj

def masked(self) -> EmailServer:
return self.model_copy(update={"password": "********"})

Expand Down Expand Up @@ -93,36 +139,57 @@ def init(
imap_user_name: str | None = None,
imap_password: str | None = None,
imap_port: int = 993,
imap_ssl: bool = True,
imap_security: ConnectionSecurity = ConnectionSecurity.TLS,
imap_verify_ssl: bool = True,
smtp_port: int = 465,
smtp_ssl: bool = True,
smtp_start_ssl: bool = False,
smtp_security: ConnectionSecurity = ConnectionSecurity.TLS,
smtp_verify_ssl: bool = True,
smtp_user_name: str | None = None,
smtp_password: str | None = None,
save_to_sent: bool = True,
sent_folder_name: str | None = None,
# Deprecated parameters for backward compatibility
imap_ssl: bool | None = None,
smtp_ssl: bool | None = None,
smtp_start_ssl: bool | None = None,
) -> EmailSettings:
# Build incoming server config
incoming_kwargs: dict[str, Any] = {
"user_name": imap_user_name or user_name,
"password": imap_password or password,
"host": imap_host,
"port": imap_port,
"verify_ssl": imap_verify_ssl,
}
if imap_ssl is not None:
# Legacy path: use_ssl was explicitly passed
incoming_kwargs["use_ssl"] = imap_ssl
else:
incoming_kwargs["security"] = imap_security

# Build outgoing server config
outgoing_kwargs: dict[str, Any] = {
"user_name": smtp_user_name or user_name,
"password": smtp_password or password,
"host": smtp_host,
"port": smtp_port,
"verify_ssl": smtp_verify_ssl,
}
if smtp_ssl is not None or smtp_start_ssl is not None:
# Legacy path: use_ssl/start_ssl were explicitly passed
if smtp_ssl is not None:
outgoing_kwargs["use_ssl"] = smtp_ssl
if smtp_start_ssl is not None:
outgoing_kwargs["start_ssl"] = smtp_start_ssl
else:
outgoing_kwargs["security"] = smtp_security

return cls(
account_name=account_name,
full_name=full_name,
email_address=email_address,
incoming=EmailServer(
user_name=imap_user_name or user_name,
password=imap_password or password,
host=imap_host,
port=imap_port,
use_ssl=imap_ssl,
),
outgoing=EmailServer(
user_name=smtp_user_name or user_name,
password=smtp_password or password,
host=smtp_host,
port=smtp_port,
use_ssl=smtp_ssl,
start_ssl=smtp_start_ssl,
verify_ssl=smtp_verify_ssl,
),
incoming=EmailServer(**incoming_kwargs),
outgoing=EmailServer(**outgoing_kwargs),
save_to_sent=save_to_sent,
sent_folder_name=sent_folder_name,
)
Expand All @@ -139,14 +206,19 @@ def from_env(cls) -> EmailSettings | None:
- MCP_EMAIL_SERVER_PASSWORD
- MCP_EMAIL_SERVER_IMAP_HOST
- MCP_EMAIL_SERVER_IMAP_PORT (default: 993)
- MCP_EMAIL_SERVER_IMAP_SSL (default: true)
- MCP_EMAIL_SERVER_IMAP_SECURITY (default: "tls") — "tls", "starttls", or "none"
- MCP_EMAIL_SERVER_IMAP_VERIFY_SSL (default: true)
- MCP_EMAIL_SERVER_SMTP_HOST
- 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_SECURITY (default: "tls") — "tls", "starttls", or "none"
- 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)

Deprecated (still supported for backward compatibility):
- MCP_EMAIL_SERVER_IMAP_SSL → use MCP_EMAIL_SERVER_IMAP_SECURITY instead
- MCP_EMAIL_SERVER_SMTP_SSL → use MCP_EMAIL_SERVER_SMTP_SECURITY instead
- MCP_EMAIL_SERVER_SMTP_START_SSL → use MCP_EMAIL_SERVER_SMTP_SECURITY instead
"""
# Check if minimum required environment variables are set
email_address = os.getenv("MCP_EMAIL_SERVER_EMAIL_ADDRESS")
Expand All @@ -161,6 +233,17 @@ def parse_bool(value: str | None, default: bool = True) -> bool:
return default
return value.lower() in ("true", "1", "yes", "on")

def parse_security(
value: str | None, default: ConnectionSecurity = ConnectionSecurity.TLS
) -> ConnectionSecurity:
if value is None:
return default
try:
return ConnectionSecurity(value.lower())
except ValueError:
logger.warning(f"Invalid security value '{value}', using default '{default.value}'")
return default

# Get all environment variables with defaults
account_name = os.getenv("MCP_EMAIL_SERVER_ACCOUNT_NAME", "default")
full_name = os.getenv("MCP_EMAIL_SERVER_FULL_NAME", email_address.split("@")[0])
Expand All @@ -173,28 +256,57 @@ def parse_bool(value: str | None, default: bool = True) -> bool:
logger.warning("Missing required email configuration environment variables (IMAP_HOST or SMTP_HOST)")
return None

# Resolve security settings: new env vars take precedence over legacy
imap_security_env = os.getenv("MCP_EMAIL_SERVER_IMAP_SECURITY")
smtp_security_env = os.getenv("MCP_EMAIL_SERVER_SMTP_SECURITY")
imap_ssl_env = os.getenv("MCP_EMAIL_SERVER_IMAP_SSL")
smtp_ssl_env = os.getenv("MCP_EMAIL_SERVER_SMTP_SSL")
smtp_start_ssl_env = os.getenv("MCP_EMAIL_SERVER_SMTP_START_SSL")

try:
imap_port = int(os.getenv("MCP_EMAIL_SERVER_IMAP_PORT", "993"))
smtp_port = int(os.getenv("MCP_EMAIL_SERVER_SMTP_PORT", "465"))
except ValueError as e:
logger.error(f"Invalid port configuration: {e}")
return None

init_kwargs: dict[str, Any] = {
"account_name": account_name,
"full_name": full_name,
"email_address": email_address,
"user_name": user_name,
"password": password,
"imap_host": imap_host,
"imap_port": imap_port,
"imap_verify_ssl": parse_bool(os.getenv("MCP_EMAIL_SERVER_IMAP_VERIFY_SSL"), True),
"smtp_host": smtp_host,
"smtp_port": smtp_port,
"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),
"imap_password": os.getenv("MCP_EMAIL_SERVER_IMAP_PASSWORD", password),
"save_to_sent": parse_bool(os.getenv("MCP_EMAIL_SERVER_SAVE_TO_SENT"), True),
"sent_folder_name": os.getenv("MCP_EMAIL_SERVER_SENT_FOLDER_NAME"),
}

# IMAP security
if imap_security_env is not None:
init_kwargs["imap_security"] = parse_security(imap_security_env)
elif imap_ssl_env is not None:
init_kwargs["imap_ssl"] = parse_bool(imap_ssl_env, True)

# SMTP security
if smtp_security_env is not None:
init_kwargs["smtp_security"] = parse_security(smtp_security_env)
elif smtp_ssl_env is not None or smtp_start_ssl_env is not None:
if smtp_ssl_env is not None:
init_kwargs["smtp_ssl"] = parse_bool(smtp_ssl_env, True)
if smtp_start_ssl_env is not None:
init_kwargs["smtp_start_ssl"] = parse_bool(smtp_start_ssl_env, False)

try:
return cls.init(
account_name=account_name,
full_name=full_name,
email_address=email_address,
user_name=user_name,
password=password,
imap_host=imap_host,
imap_port=int(os.getenv("MCP_EMAIL_SERVER_IMAP_PORT", "993")),
imap_ssl=parse_bool(os.getenv("MCP_EMAIL_SERVER_IMAP_SSL"), True),
smtp_host=smtp_host,
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),
imap_password=os.getenv("MCP_EMAIL_SERVER_IMAP_PASSWORD", password),
save_to_sent=parse_bool(os.getenv("MCP_EMAIL_SERVER_SAVE_TO_SENT"), True),
sent_folder_name=os.getenv("MCP_EMAIL_SERVER_SENT_FOLDER_NAME"),
)
return cls.init(**init_kwargs)
except (ValueError, TypeError) as e:
logger.error(f"Failed to create email settings from environment variables: {e}")
return None
Expand Down
Loading
Loading