read-no-evil-mcp 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. read_no_evil_mcp/__init__.py +36 -0
  2. read_no_evil_mcp/__main__.py +6 -0
  3. read_no_evil_mcp/accounts/__init__.py +21 -0
  4. read_no_evil_mcp/accounts/config.py +93 -0
  5. read_no_evil_mcp/accounts/credentials/__init__.py +6 -0
  6. read_no_evil_mcp/accounts/credentials/base.py +29 -0
  7. read_no_evil_mcp/accounts/credentials/env.py +44 -0
  8. read_no_evil_mcp/accounts/permissions.py +87 -0
  9. read_no_evil_mcp/accounts/service.py +109 -0
  10. read_no_evil_mcp/config.py +98 -0
  11. read_no_evil_mcp/email/__init__.py +6 -0
  12. read_no_evil_mcp/email/connectors/__init__.py +6 -0
  13. read_no_evil_mcp/email/connectors/base.py +148 -0
  14. read_no_evil_mcp/email/connectors/imap.py +288 -0
  15. read_no_evil_mcp/email/connectors/smtp.py +110 -0
  16. read_no_evil_mcp/exceptions.py +44 -0
  17. read_no_evil_mcp/mailbox.py +329 -0
  18. read_no_evil_mcp/models.py +88 -0
  19. read_no_evil_mcp/protection/__init__.py +6 -0
  20. read_no_evil_mcp/protection/heuristic.py +82 -0
  21. read_no_evil_mcp/protection/service.py +110 -0
  22. read_no_evil_mcp/py.typed +0 -0
  23. read_no_evil_mcp/server.py +12 -0
  24. read_no_evil_mcp/tools/__init__.py +16 -0
  25. read_no_evil_mcp/tools/_app.py +6 -0
  26. read_no_evil_mcp/tools/_service.py +54 -0
  27. read_no_evil_mcp/tools/delete_email.py +24 -0
  28. read_no_evil_mcp/tools/get_email.py +64 -0
  29. read_no_evil_mcp/tools/list_accounts.py +20 -0
  30. read_no_evil_mcp/tools/list_emails.py +47 -0
  31. read_no_evil_mcp/tools/list_folders.py +22 -0
  32. read_no_evil_mcp/tools/move_email.py +29 -0
  33. read_no_evil_mcp/tools/send_email.py +43 -0
  34. read_no_evil_mcp-0.2.0.dist-info/METADATA +361 -0
  35. read_no_evil_mcp-0.2.0.dist-info/RECORD +38 -0
  36. read_no_evil_mcp-0.2.0.dist-info/WHEEL +4 -0
  37. read_no_evil_mcp-0.2.0.dist-info/entry_points.txt +2 -0
  38. read_no_evil_mcp-0.2.0.dist-info/licenses/LICENSE +190 -0
@@ -0,0 +1,36 @@
1
+ """A secure email gateway MCP server that protects AI agents from prompt injection attacks in emails."""
2
+
3
+ from read_no_evil_mcp.config import Settings
4
+ from read_no_evil_mcp.email.connectors.base import BaseConnector
5
+ from read_no_evil_mcp.email.connectors.imap import IMAPConnector
6
+ from read_no_evil_mcp.mailbox import PromptInjectionError, SecureMailbox
7
+ from read_no_evil_mcp.models import (
8
+ Attachment,
9
+ Email,
10
+ EmailAddress,
11
+ EmailSummary,
12
+ Folder,
13
+ IMAPConfig,
14
+ ScanResult,
15
+ )
16
+ from read_no_evil_mcp.protection.heuristic import HeuristicScanner
17
+ from read_no_evil_mcp.protection.service import ProtectionService
18
+
19
+ __version__ = "0.1.0"
20
+
21
+ __all__ = [
22
+ "Attachment",
23
+ "BaseConnector",
24
+ "Email",
25
+ "EmailAddress",
26
+ "EmailSummary",
27
+ "Folder",
28
+ "HeuristicScanner",
29
+ "IMAPConfig",
30
+ "IMAPConnector",
31
+ "PromptInjectionError",
32
+ "ProtectionService",
33
+ "ScanResult",
34
+ "SecureMailbox",
35
+ "Settings",
36
+ ]
@@ -0,0 +1,6 @@
1
+ """Entry point for python -m read_no_evil_mcp."""
2
+
3
+ from read_no_evil_mcp.server import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,21 @@
1
+ """Account management module for multi-account support."""
2
+
3
+ from read_no_evil_mcp.accounts.config import (
4
+ AccountConfig,
5
+ BaseAccountConfig,
6
+ IMAPAccountConfig,
7
+ )
8
+ from read_no_evil_mcp.accounts.credentials import CredentialBackend, EnvCredentialBackend
9
+ from read_no_evil_mcp.accounts.permissions import AccountPermissions, PermissionChecker
10
+ from read_no_evil_mcp.accounts.service import AccountService
11
+
12
+ __all__ = [
13
+ "AccountConfig",
14
+ "AccountPermissions",
15
+ "AccountService",
16
+ "BaseAccountConfig",
17
+ "CredentialBackend",
18
+ "EnvCredentialBackend",
19
+ "IMAPAccountConfig",
20
+ "PermissionChecker",
21
+ ]
@@ -0,0 +1,93 @@
1
+ """Account configuration models with discriminated union for multi-connector support."""
2
+
3
+ from typing import Literal
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+ from read_no_evil_mcp.accounts.permissions import AccountPermissions
8
+
9
+
10
+ class BaseAccountConfig(BaseModel):
11
+ """Base configuration shared by all account types.
12
+
13
+ Attributes:
14
+ id: Unique identifier for the account (e.g., "work", "personal").
15
+ """
16
+
17
+ id: str = Field(
18
+ ...,
19
+ min_length=1,
20
+ pattern=r"^[a-zA-Z][a-zA-Z0-9_-]*$",
21
+ description="Unique account identifier (alphanumeric, hyphens, underscores)",
22
+ )
23
+
24
+
25
+ class IMAPAccountConfig(BaseAccountConfig):
26
+ """IMAP-specific account configuration.
27
+
28
+ Attributes:
29
+ type: Connector type, always "imap" for this class.
30
+ host: Email server hostname.
31
+ port: Email server port (default: 993 for IMAP SSL).
32
+ username: Account username/email address.
33
+ ssl: Whether to use SSL/TLS (default: True).
34
+ permissions: Account permissions (default: read-only).
35
+ smtp_host: SMTP server hostname (default: same as IMAP host).
36
+ smtp_port: SMTP server port (default: 587 for STARTTLS).
37
+ smtp_ssl: Use SSL instead of STARTTLS for SMTP (default: False).
38
+ """
39
+
40
+ type: Literal["imap"] = Field(
41
+ default="imap",
42
+ description="Connector type (imap)",
43
+ )
44
+ host: str = Field(..., min_length=1, description="Email server hostname")
45
+ port: int = Field(default=993, ge=1, le=65535, description="Email server port")
46
+ username: str = Field(..., min_length=1, description="Account username/email")
47
+ ssl: bool = Field(default=True, description="Use SSL/TLS connection")
48
+ permissions: AccountPermissions = Field(
49
+ default_factory=AccountPermissions,
50
+ description="Account permissions (default: read-only)",
51
+ )
52
+ smtp_host: str | None = Field(
53
+ default=None,
54
+ description="SMTP server hostname (defaults to IMAP host)",
55
+ )
56
+ smtp_port: int = Field(
57
+ default=587,
58
+ ge=1,
59
+ le=65535,
60
+ description="SMTP server port (default: 587 for STARTTLS)",
61
+ )
62
+ smtp_ssl: bool = Field(
63
+ default=False,
64
+ description="Use SSL instead of STARTTLS for SMTP (default: False)",
65
+ )
66
+ from_address: str | None = Field(
67
+ default=None,
68
+ min_length=1,
69
+ description="Sender email address for outgoing emails (required for send)",
70
+ )
71
+ from_name: str | None = Field(
72
+ default=None,
73
+ description="Display name for outgoing emails (e.g., 'Atlas')",
74
+ )
75
+
76
+
77
+ # Future connectors will follow the same pattern as IMAPAccountConfig:
78
+ # - Inherit from BaseAccountConfig
79
+ # - Add a `type` field with Literal["connector_name"]
80
+ # - Add connector-specific fields
81
+ # Examples: GmailAccountConfig (type="gmail"), MSGraphAccountConfig (type="msgraph")
82
+
83
+ # Discriminated union - Pydantic picks the right type based on "type" field.
84
+ # When adding new connectors, convert AccountConfig to a discriminated union:
85
+ #
86
+ # from typing import Annotated, Union
87
+ # AccountConfig = Annotated[
88
+ # Union[IMAPAccountConfig, GmailAccountConfig, MSGraphAccountConfig],
89
+ # Field(discriminator="type"),
90
+ # ]
91
+ #
92
+ # For now with single type, use simple alias (discriminator activates with Union).
93
+ AccountConfig = IMAPAccountConfig
@@ -0,0 +1,6 @@
1
+ """Credential backends for account authentication."""
2
+
3
+ from read_no_evil_mcp.accounts.credentials.base import CredentialBackend
4
+ from read_no_evil_mcp.accounts.credentials.env import EnvCredentialBackend
5
+
6
+ __all__ = ["CredentialBackend", "EnvCredentialBackend"]
@@ -0,0 +1,29 @@
1
+ """Abstract base class for credential backends."""
2
+
3
+ from abc import ABC, abstractmethod
4
+
5
+ from pydantic import SecretStr
6
+
7
+
8
+ class CredentialBackend(ABC):
9
+ """Abstract interface for credential storage.
10
+
11
+ Credential backends are responsible for retrieving passwords for email accounts.
12
+ Different implementations can retrieve credentials from environment variables,
13
+ keyrings, encrypted files, etc.
14
+ """
15
+
16
+ @abstractmethod
17
+ def get_password(self, account_id: str) -> SecretStr:
18
+ """Retrieve password for the given account.
19
+
20
+ Args:
21
+ account_id: The unique identifier of the account.
22
+
23
+ Returns:
24
+ The password as a SecretStr.
25
+
26
+ Raises:
27
+ CredentialNotFoundError: If the credential is not found.
28
+ """
29
+ ...
@@ -0,0 +1,44 @@
1
+ """Environment variable credential backend."""
2
+
3
+ import os
4
+
5
+ from pydantic import SecretStr
6
+
7
+ from read_no_evil_mcp.accounts.credentials.base import CredentialBackend
8
+ from read_no_evil_mcp.exceptions import CredentialNotFoundError
9
+
10
+
11
+ class EnvCredentialBackend(CredentialBackend):
12
+ """Credential backend using environment variables.
13
+
14
+ Looks for passwords in environment variables named:
15
+ RNOE_ACCOUNT_{ID}_PASSWORD
16
+
17
+ Where {ID} is the account ID in uppercase with hyphens replaced by underscores.
18
+ For example:
19
+ - Account "work" -> RNOE_ACCOUNT_WORK_PASSWORD
20
+ - Account "personal" -> RNOE_ACCOUNT_PERSONAL_PASSWORD
21
+ - Account "my-gmail" -> RNOE_ACCOUNT_MY_GMAIL_PASSWORD
22
+ """
23
+
24
+ def get_password(self, account_id: str) -> SecretStr:
25
+ """Retrieve password from environment variable.
26
+
27
+ Args:
28
+ account_id: The unique identifier of the account.
29
+
30
+ Returns:
31
+ The password as a SecretStr.
32
+
33
+ Raises:
34
+ CredentialNotFoundError: If the environment variable is not set.
35
+ """
36
+ # Normalize account ID: uppercase and replace hyphens with underscores
37
+ normalized_id = account_id.upper().replace("-", "_")
38
+ env_key = f"RNOE_ACCOUNT_{normalized_id}_PASSWORD"
39
+
40
+ value = os.environ.get(env_key)
41
+ if not value:
42
+ raise CredentialNotFoundError(account_id, env_key)
43
+
44
+ return SecretStr(value)
@@ -0,0 +1,87 @@
1
+ """Account permissions model and checker for rights management."""
2
+
3
+ from pydantic import BaseModel
4
+
5
+ from read_no_evil_mcp.exceptions import PermissionDeniedError
6
+
7
+
8
+ class AccountPermissions(BaseModel):
9
+ """Permissions configuration for an email account.
10
+
11
+ Attributes:
12
+ read: Whether reading emails is allowed (default: True).
13
+ delete: Whether deleting emails is allowed (default: False).
14
+ send: Whether sending emails is allowed (default: False).
15
+ move: Whether moving emails between folders is allowed (default: False).
16
+ folders: List of allowed folders, or None for all folders (default: None).
17
+ """
18
+
19
+ read: bool = True
20
+ delete: bool = False
21
+ send: bool = False
22
+ move: bool = False
23
+ folders: list[str] | None = None
24
+
25
+
26
+ class PermissionChecker:
27
+ """Checker for account permissions.
28
+
29
+ Validates operations against account permissions and raises
30
+ PermissionDeniedError if the operation is not allowed.
31
+ """
32
+
33
+ def __init__(self, permissions: AccountPermissions) -> None:
34
+ """Initialize the permission checker.
35
+
36
+ Args:
37
+ permissions: The account permissions to check against.
38
+ """
39
+ self.permissions = permissions
40
+
41
+ def check_read(self) -> None:
42
+ """Check if read access is allowed.
43
+
44
+ Raises:
45
+ PermissionDeniedError: If read access is denied.
46
+ """
47
+ if not self.permissions.read:
48
+ raise PermissionDeniedError("Read access denied for this account")
49
+
50
+ def check_folder(self, folder: str) -> None:
51
+ """Check if access to a specific folder is allowed.
52
+
53
+ Args:
54
+ folder: The folder name to check access for.
55
+
56
+ Raises:
57
+ PermissionDeniedError: If access to the folder is denied.
58
+ """
59
+ if self.permissions.folders is not None and folder not in self.permissions.folders:
60
+ raise PermissionDeniedError(f"Access to folder '{folder}' denied")
61
+
62
+ def check_delete(self) -> None:
63
+ """Check if delete access is allowed.
64
+
65
+ Raises:
66
+ PermissionDeniedError: If delete access is denied.
67
+ """
68
+ if not self.permissions.delete:
69
+ raise PermissionDeniedError("Delete access denied for this account")
70
+
71
+ def check_send(self) -> None:
72
+ """Check if send access is allowed.
73
+
74
+ Raises:
75
+ PermissionDeniedError: If send access is denied.
76
+ """
77
+ if not self.permissions.send:
78
+ raise PermissionDeniedError("Send access denied for this account")
79
+
80
+ def check_move(self) -> None:
81
+ """Check if moving emails is allowed.
82
+
83
+ Raises:
84
+ PermissionDeniedError: If move access is denied.
85
+ """
86
+ if not self.permissions.move:
87
+ raise PermissionDeniedError("Move access denied for this account")
@@ -0,0 +1,109 @@
1
+ """Account service for managing multiple email accounts."""
2
+
3
+ from pydantic import SecretStr
4
+
5
+ from read_no_evil_mcp.accounts.config import AccountConfig
6
+ from read_no_evil_mcp.accounts.credentials.base import CredentialBackend
7
+ from read_no_evil_mcp.email.connectors.base import BaseConnector
8
+ from read_no_evil_mcp.email.connectors.imap import IMAPConnector
9
+ from read_no_evil_mcp.exceptions import AccountNotFoundError, UnsupportedConnectorError
10
+ from read_no_evil_mcp.mailbox import SecureMailbox
11
+ from read_no_evil_mcp.models import IMAPConfig, SMTPConfig
12
+
13
+
14
+ class AccountService:
15
+ """Service for managing multiple email accounts.
16
+
17
+ Provides a unified interface for accessing email accounts by their IDs.
18
+ Handles credential retrieval and connector instantiation.
19
+ """
20
+
21
+ def __init__(
22
+ self,
23
+ accounts: list[AccountConfig],
24
+ credentials: CredentialBackend,
25
+ ) -> None:
26
+ """Initialize the account service.
27
+
28
+ Args:
29
+ accounts: List of account configurations.
30
+ credentials: Backend for retrieving account credentials.
31
+ """
32
+ self._accounts = {a.id: a for a in accounts}
33
+ self._credentials = credentials
34
+
35
+ def list_accounts(self) -> list[str]:
36
+ """Return list of configured account IDs.
37
+
38
+ Returns:
39
+ List of account identifiers in the order they were configured.
40
+ """
41
+ return list(self._accounts.keys())
42
+
43
+ def _create_connector(self, config: AccountConfig, password: SecretStr) -> BaseConnector:
44
+ """Create a connector based on account configuration.
45
+
46
+ Args:
47
+ config: The account configuration.
48
+ password: The account password.
49
+
50
+ Returns:
51
+ A configured connector instance.
52
+
53
+ Raises:
54
+ UnsupportedConnectorError: If the connector type is not supported.
55
+ """
56
+ if config.type == "imap":
57
+ imap_config = IMAPConfig(
58
+ host=config.host,
59
+ port=config.port,
60
+ username=config.username,
61
+ password=password,
62
+ ssl=config.ssl,
63
+ )
64
+
65
+ # Create SMTP config if send permission is enabled
66
+ smtp_config = None
67
+ if config.permissions.send:
68
+ smtp_config = SMTPConfig(
69
+ host=config.smtp_host or config.host,
70
+ port=config.smtp_port,
71
+ username=config.username,
72
+ password=password,
73
+ ssl=config.smtp_ssl,
74
+ )
75
+
76
+ return IMAPConnector(imap_config, smtp_config=smtp_config)
77
+
78
+ raise UnsupportedConnectorError(config.type)
79
+
80
+ def get_mailbox(self, account_id: str) -> SecureMailbox:
81
+ """Create SecureMailbox for the specified account.
82
+
83
+ Args:
84
+ account_id: The unique identifier of the account.
85
+
86
+ Returns:
87
+ A SecureMailbox instance configured for the account.
88
+
89
+ Raises:
90
+ AccountNotFoundError: If the account ID is not found.
91
+ CredentialNotFoundError: If credentials cannot be retrieved.
92
+ UnsupportedConnectorError: If the connector type is not supported.
93
+ """
94
+ config = self._accounts.get(account_id)
95
+ if not config:
96
+ raise AccountNotFoundError(account_id)
97
+
98
+ password = self._credentials.get_password(account_id)
99
+ connector = self._create_connector(config, password)
100
+
101
+ # Use config.from_address, fall back to config.username if not set
102
+ from_address = config.from_address or config.username
103
+
104
+ return SecureMailbox(
105
+ connector,
106
+ config.permissions,
107
+ from_address=from_address,
108
+ from_name=config.from_name,
109
+ )
@@ -0,0 +1,98 @@
1
+ """Configuration settings for read-no-evil-mcp using pydantic-settings."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from pydantic_settings import (
8
+ BaseSettings,
9
+ PydanticBaseSettingsSource,
10
+ SettingsConfigDict,
11
+ )
12
+
13
+ from read_no_evil_mcp.accounts.config import AccountConfig
14
+
15
+
16
+ class YamlConfigSettingsSource(PydanticBaseSettingsSource):
17
+ """Settings source that loads configuration from a YAML file.
18
+
19
+ Looks for config file in the following order:
20
+ 1. RNOE_CONFIG_FILE environment variable
21
+ 2. ./rnoe.yaml (current directory)
22
+ 3. ~/.config/read-no-evil-mcp/config.yaml
23
+ """
24
+
25
+ def get_field_value(self, field: Any, field_name: str) -> tuple[Any, str, bool]:
26
+ """Get field value from YAML config."""
27
+ yaml_data = self._load_yaml_config()
28
+ field_value = yaml_data.get(field_name)
29
+ return field_value, field_name, False
30
+
31
+ def __call__(self) -> dict[str, Any]:
32
+ """Return all settings from YAML config."""
33
+ return self._load_yaml_config()
34
+
35
+ def _load_yaml_config(self) -> dict[str, Any]:
36
+ """Load and cache YAML config file."""
37
+ if not hasattr(self, "_yaml_data"):
38
+ self._yaml_data = self._read_yaml_file()
39
+ return self._yaml_data
40
+
41
+ def _read_yaml_file(self) -> dict[str, Any]:
42
+ """Read YAML config from file."""
43
+ import yaml
44
+
45
+ config_paths = [
46
+ os.environ.get("RNOE_CONFIG_FILE"),
47
+ Path.cwd() / "rnoe.yaml",
48
+ Path.home() / ".config" / "read-no-evil-mcp" / "config.yaml",
49
+ ]
50
+
51
+ for path in config_paths:
52
+ if path and Path(path).exists():
53
+ with open(path) as f:
54
+ data = yaml.safe_load(f)
55
+ return data if data else {}
56
+
57
+ return {}
58
+
59
+
60
+ class Settings(BaseSettings):
61
+ """Application settings loaded from environment variables with RNOE_ prefix.
62
+
63
+ Multi-account configuration via YAML file:
64
+ accounts:
65
+ - id: "work"
66
+ type: "imap"
67
+ host: "mail.company.com"
68
+ username: "user@company.com"
69
+
70
+ Account passwords are retrieved via credential backends
71
+ (e.g., RNOE_ACCOUNT_WORK_PASSWORD environment variable).
72
+ """
73
+
74
+ model_config = SettingsConfigDict(env_prefix="RNOE_")
75
+
76
+ # Multi-account configuration
77
+ accounts: list[AccountConfig] = []
78
+
79
+ # Application defaults
80
+ default_lookback_days: int = 7
81
+
82
+ @classmethod
83
+ def settings_customise_sources(
84
+ cls,
85
+ settings_cls: type[BaseSettings],
86
+ init_settings: PydanticBaseSettingsSource,
87
+ env_settings: PydanticBaseSettingsSource,
88
+ dotenv_settings: PydanticBaseSettingsSource,
89
+ file_secret_settings: PydanticBaseSettingsSource,
90
+ ) -> tuple[PydanticBaseSettingsSource, ...]:
91
+ """Customize settings sources to include YAML config."""
92
+ return (
93
+ init_settings,
94
+ env_settings,
95
+ YamlConfigSettingsSource(settings_cls),
96
+ dotenv_settings,
97
+ file_secret_settings,
98
+ )
@@ -0,0 +1,6 @@
1
+ """Email connectors for read-no-evil-mcp."""
2
+
3
+ from read_no_evil_mcp.email.connectors.base import BaseConnector
4
+ from read_no_evil_mcp.email.connectors.imap import IMAPConnector
5
+
6
+ __all__ = ["BaseConnector", "IMAPConnector"]
@@ -0,0 +1,6 @@
1
+ """Email connectors for read-no-evil-mcp."""
2
+
3
+ from read_no_evil_mcp.email.connectors.base import BaseConnector
4
+ from read_no_evil_mcp.email.connectors.imap import IMAPConnector
5
+
6
+ __all__ = ["BaseConnector", "IMAPConnector"]