mcp-email-server 0.0.3__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.
File without changes
@@ -0,0 +1,54 @@
1
+ from datetime import datetime
2
+
3
+ from mcp.server.fastmcp import FastMCP
4
+
5
+ from mcp_email_server.config import (
6
+ AccountAttributes,
7
+ EmailSettings,
8
+ ProviderSettings,
9
+ get_settings,
10
+ )
11
+ from mcp_email_server.emails.dispatcher import dispatch_handler
12
+ from mcp_email_server.emails.models import EmailPageResponse
13
+
14
+ mcp = FastMCP("email")
15
+
16
+
17
+ @mcp.resource("email://{account_name}")
18
+ async def get_account(account_name: str) -> EmailSettings | ProviderSettings | None:
19
+ settings = get_settings()
20
+ return settings.get_account(account_name, masked=True)
21
+
22
+
23
+ @mcp.tool()
24
+ async def list_available_accounts() -> list[AccountAttributes]:
25
+ settings = get_settings()
26
+ return [account.masked() for account in settings.get_accounts()]
27
+
28
+
29
+ @mcp.tool()
30
+ async def add_email_account(email: EmailSettings) -> None:
31
+ settings = get_settings()
32
+ settings.add_email(email)
33
+ settings.store()
34
+
35
+
36
+ @mcp.tool()
37
+ async def page_email(
38
+ account_name: str,
39
+ page: int = 1,
40
+ page_size: int = 10,
41
+ before: datetime | None = None,
42
+ after: datetime | None = None,
43
+ include: str | None = None,
44
+ ) -> EmailPageResponse:
45
+ handler = dispatch_handler(account_name)
46
+
47
+ return await handler.get_emails(page=page, page_size=page_size, before=before, after=after, include=include)
48
+
49
+
50
+ @mcp.tool()
51
+ async def send_email(account_name: str, recipient: str, subject: str, body: str) -> None:
52
+ handler = dispatch_handler(account_name)
53
+ await handler.send_email(recipient, subject, body)
54
+ return
@@ -0,0 +1,38 @@
1
+ import typer
2
+
3
+ from mcp_email_server.app import mcp
4
+ from mcp_email_server.config import delete_settings
5
+
6
+ app = typer.Typer()
7
+
8
+
9
+ @app.command()
10
+ def stdio():
11
+ mcp.run(transport="stdio")
12
+
13
+
14
+ @app.command()
15
+ def sse(
16
+ host: str = "localhost",
17
+ port: int = 9557,
18
+ ):
19
+ mcp.settings.host = host
20
+ mcp.settings.port = port
21
+ mcp.run(transport="sse")
22
+
23
+
24
+ @app.command()
25
+ def ui():
26
+ from mcp_email_server.ui import main as ui_main
27
+
28
+ ui_main()
29
+
30
+
31
+ @app.command()
32
+ def reset():
33
+ delete_settings()
34
+ typer.echo("✅ Config reset")
35
+
36
+
37
+ if __name__ == "__main__":
38
+ app(["stdio"])
@@ -0,0 +1,229 @@
1
+ from __future__ import annotations
2
+
3
+ import datetime
4
+ import os
5
+ from pathlib import Path
6
+
7
+ import tomli_w
8
+ from pydantic import BaseModel, Field, model_validator
9
+ from pydantic_settings import (
10
+ BaseSettings,
11
+ PydanticBaseSettingsSource,
12
+ SettingsConfigDict,
13
+ TomlConfigSettingsSource,
14
+ )
15
+
16
+ from mcp_email_server.log import logger
17
+
18
+ DEFAILT_CONFIG_PATH = "~/.config/zerolib/mcp_email_server/config.toml"
19
+
20
+ CONFIG_PATH = Path(os.getenv("MCP_EMAIL_SERVER_CONFIG_PATH", DEFAILT_CONFIG_PATH)).expanduser().resolve()
21
+
22
+
23
+ class EmailServer(BaseModel):
24
+ user_name: str
25
+ password: str
26
+ host: str
27
+ port: int
28
+ use_ssl: bool = True # Usually port 465
29
+ start_ssl: bool = False # Usually port 587
30
+
31
+ def masked(self) -> EmailServer:
32
+ return self.model_copy(update={"password": "********"})
33
+
34
+
35
+ class AccountAttributes(BaseModel):
36
+ account_name: str
37
+ description: str = ""
38
+ created_at: datetime.datetime = Field(default_factory=datetime.datetime.now)
39
+ updated_at: datetime.datetime = Field(default_factory=datetime.datetime.now)
40
+
41
+ @model_validator(mode="after")
42
+ @classmethod
43
+ def update_updated_at(cls, obj: AccountAttributes) -> AccountAttributes:
44
+ """Update updated_at field."""
45
+ # must disable validation to avoid infinite loop
46
+ obj.model_config["validate_assignment"] = False
47
+
48
+ # update updated_at field
49
+ obj.updated_at = datetime.datetime.now()
50
+
51
+ # enable validation again
52
+ obj.model_config["validate_assignment"] = True
53
+ return obj
54
+
55
+ def __eq__(self, other: object) -> bool:
56
+ if not isinstance(other, AccountAttributes):
57
+ return NotImplemented
58
+ return self.model_dump(exclude={"created_at", "updated_at"}) == other.model_dump(
59
+ exclude={"created_at", "updated_at"}
60
+ )
61
+
62
+ def masked(self) -> AccountAttributes:
63
+ return self.model_copy()
64
+
65
+
66
+ class EmailSettings(AccountAttributes):
67
+ full_name: str
68
+ email_address: str
69
+ incoming: EmailServer
70
+ outgoing: EmailServer
71
+
72
+ @classmethod
73
+ def init(
74
+ cls,
75
+ *,
76
+ account_name: str,
77
+ full_name: str,
78
+ email_address: str,
79
+ user_name: str,
80
+ password: str,
81
+ imap_host: str,
82
+ smtp_host: str,
83
+ imap_user_name: str | None = None,
84
+ imap_password: str | None = None,
85
+ imap_port: int = 993,
86
+ imap_ssl: bool = True,
87
+ smtp_port: int = 465,
88
+ smtp_ssl: bool = True,
89
+ smtp_start_ssl: bool = False,
90
+ smtp_user_name: str | None = None,
91
+ smtp_password: str | None = None,
92
+ ) -> EmailSettings:
93
+ return cls(
94
+ account_name=account_name,
95
+ full_name=full_name,
96
+ email_address=email_address,
97
+ incoming=EmailServer(
98
+ user_name=imap_user_name or user_name,
99
+ password=imap_password or password,
100
+ host=imap_host,
101
+ port=imap_port,
102
+ use_ssl=imap_ssl,
103
+ ),
104
+ outgoing=EmailServer(
105
+ user_name=smtp_user_name or user_name,
106
+ password=smtp_password or password,
107
+ host=smtp_host,
108
+ port=smtp_port,
109
+ use_ssl=smtp_ssl,
110
+ start_ssl=smtp_start_ssl,
111
+ ),
112
+ )
113
+
114
+ def masked(self) -> EmailSettings:
115
+ return self.model_copy(
116
+ update={
117
+ "incoming": self.incoming.masked(),
118
+ "outgoing": self.outgoing.masked(),
119
+ }
120
+ )
121
+
122
+
123
+ class ProviderSettings(AccountAttributes):
124
+ provider_name: str
125
+ api_key: str
126
+
127
+ def masked(self) -> AccountAttributes:
128
+ return self.model_copy(update={"api_key": "********"})
129
+
130
+
131
+ class Settings(BaseSettings):
132
+ emails: list[EmailSettings] = []
133
+ providers: list[ProviderSettings] = []
134
+ db_location: str = CONFIG_PATH.with_name("db.sqlite3").as_posix()
135
+
136
+ model_config = SettingsConfigDict(toml_file=CONFIG_PATH, validate_assignment=True, revalidate_instances="always")
137
+
138
+ def add_email(self, email: EmailSettings) -> None:
139
+ """Use re-assigned for validation to work."""
140
+ self.emails = [email, *self.emails]
141
+
142
+ def add_provider(self, provider: ProviderSettings) -> None:
143
+ """Use re-assigned for validation to work."""
144
+ self.providers = [provider, *self.providers]
145
+
146
+ def delete_email(self, account_name: str) -> None:
147
+ """Use re-assigned for validation to work."""
148
+ self.emails = [email for email in self.emails if email.account_name != account_name]
149
+
150
+ def delete_provider(self, account_name: str) -> None:
151
+ """Use re-assigned for validation to work."""
152
+ self.providers = [provider for provider in self.providers if provider.account_name != account_name]
153
+
154
+ def get_account(self, account_name: str, masked: bool = False) -> EmailSettings | ProviderSettings | None:
155
+ for email in self.emails:
156
+ if email.account_name == account_name:
157
+ return email if not masked else email.masked()
158
+ for provider in self.providers:
159
+ if provider.account_name == account_name:
160
+ return provider if not masked else provider.masked()
161
+ return None
162
+
163
+ def get_accounts(self, masked: bool = False) -> list[EmailSettings | ProviderSettings]:
164
+ accounts = self.emails + self.providers
165
+ if masked:
166
+ return [account.masked() for account in accounts]
167
+ return accounts
168
+
169
+ @model_validator(mode="after")
170
+ @classmethod
171
+ def check_unique_account_names(cls, obj: Settings) -> Settings:
172
+ account_names = set()
173
+ for email in obj.emails:
174
+ if email.account_name in account_names:
175
+ raise ValueError(f"Duplicate account name {email.account_name}")
176
+ account_names.add(email.account_name)
177
+ for provider in obj.providers:
178
+ if provider.account_name in account_names:
179
+ raise ValueError(f"Duplicate account name {provider.account_name}")
180
+ account_names.add(provider.account_name)
181
+
182
+ return obj
183
+
184
+ @classmethod
185
+ def settings_customise_sources(
186
+ cls,
187
+ settings_cls: type[BaseSettings],
188
+ init_settings: PydanticBaseSettingsSource,
189
+ env_settings: PydanticBaseSettingsSource,
190
+ dotenv_settings: PydanticBaseSettingsSource,
191
+ file_secret_settings: PydanticBaseSettingsSource,
192
+ ) -> tuple[PydanticBaseSettingsSource, ...]:
193
+ return (TomlConfigSettingsSource(settings_cls),)
194
+
195
+ def _to_toml(self) -> str:
196
+ data = self.model_dump()
197
+ return tomli_w.dumps(data)
198
+
199
+ def store(self) -> None:
200
+ toml_file = self.model_config["toml_file"]
201
+ toml_file.parent.mkdir(parents=True, exist_ok=True)
202
+ toml_file.write_text(self._to_toml())
203
+ logger.info(f"Settings stored in {toml_file}")
204
+
205
+
206
+ _settings = None
207
+
208
+
209
+ def get_settings(reload: bool = False) -> Settings:
210
+ global _settings
211
+ if not _settings or reload:
212
+ logger.info(f"Loading settings from {CONFIG_PATH}")
213
+ _settings = Settings()
214
+ return _settings
215
+
216
+
217
+ def store_settings(settings: Settings | None = None) -> None:
218
+ if not settings:
219
+ settings = get_settings()
220
+ settings.store()
221
+ return
222
+
223
+
224
+ def delete_settings() -> None:
225
+ if not CONFIG_PATH.exists():
226
+ logger.info(f"Settings file {CONFIG_PATH} does not exist")
227
+ return
228
+ CONFIG_PATH.unlink()
229
+ logger.info(f"Deleted settings file {CONFIG_PATH}")
@@ -0,0 +1,27 @@
1
+ import abc
2
+ from datetime import datetime
3
+ from typing import TYPE_CHECKING
4
+
5
+ if TYPE_CHECKING:
6
+ from mcp_email_server.emails.models import EmailPageResponse
7
+
8
+
9
+ class EmailHandler(abc.ABC):
10
+ @abc.abstractmethod
11
+ async def get_emails(
12
+ self,
13
+ page: int = 1,
14
+ page_size: int = 10,
15
+ before: datetime | None = None,
16
+ after: datetime | None = None,
17
+ include: str | None = None,
18
+ ) -> "EmailPageResponse":
19
+ """
20
+ Get emails
21
+ """
22
+
23
+ @abc.abstractmethod
24
+ async def send_email(self, recipient: str, subject: str, body: str) -> None:
25
+ """
26
+ Send email
27
+ """
@@ -0,0 +1,253 @@
1
+ import email.utils
2
+ from collections.abc import AsyncGenerator
3
+ from datetime import datetime
4
+ from email.mime.text import MIMEText
5
+ from email.parser import BytesParser
6
+ from email.policy import default
7
+ from typing import Any
8
+
9
+ import aioimaplib
10
+ import aiosmtplib
11
+
12
+ from mcp_email_server.config import EmailServer, EmailSettings
13
+ from mcp_email_server.emails import EmailHandler
14
+ from mcp_email_server.emails.models import EmailData, EmailPageResponse
15
+
16
+
17
+ class EmailClient:
18
+ def __init__(self, email_server: EmailServer, sender: str | None = None):
19
+ self.email_server = email_server
20
+ self.sender = sender or email_server.user_name
21
+
22
+ self.imap_class = aioimaplib.IMAP4_SSL if self.email_server.use_ssl else aioimaplib.IMAP4
23
+
24
+ self.smtp_use_tls = self.email_server.use_ssl
25
+ self.smtp_start_tls = self.email_server.start_ssl
26
+
27
+ def _parse_email_data(self, raw_email: bytes) -> dict[str, Any]: # noqa: C901
28
+ """Parse raw email data into a structured dictionary."""
29
+ parser = BytesParser(policy=default)
30
+ email_message = parser.parsebytes(raw_email)
31
+
32
+ # Extract email parts
33
+ subject = email_message.get("Subject", "")
34
+ sender = email_message.get("From", "")
35
+ date_str = email_message.get("Date", "")
36
+
37
+ # Parse date
38
+ try:
39
+ date_tuple = email.utils.parsedate_tz(date_str)
40
+ date = datetime.fromtimestamp(email.utils.mktime_tz(date_tuple)) if date_tuple else datetime.now()
41
+ except Exception:
42
+ date = datetime.now()
43
+
44
+ # Get body content
45
+ body = ""
46
+ attachments = []
47
+
48
+ if email_message.is_multipart():
49
+ for part in email_message.walk():
50
+ content_type = part.get_content_type()
51
+ content_disposition = str(part.get("Content-Disposition", ""))
52
+
53
+ # Handle attachments
54
+ if "attachment" in content_disposition:
55
+ filename = part.get_filename()
56
+ if filename:
57
+ attachments.append(filename)
58
+ # Handle text parts
59
+ elif content_type == "text/plain":
60
+ body_part = part.get_payload(decode=True)
61
+ if body_part:
62
+ charset = part.get_content_charset("utf-8")
63
+ try:
64
+ body += body_part.decode(charset)
65
+ except UnicodeDecodeError:
66
+ body += body_part.decode("utf-8", errors="replace")
67
+ else:
68
+ # Handle plain text emails
69
+ payload = email_message.get_payload(decode=True)
70
+ if payload:
71
+ charset = email_message.get_content_charset("utf-8")
72
+ try:
73
+ body = payload.decode(charset)
74
+ except UnicodeDecodeError:
75
+ body = payload.decode("utf-8", errors="replace")
76
+
77
+ return {
78
+ "subject": subject,
79
+ "from": sender,
80
+ "body": body,
81
+ "date": date,
82
+ "attachments": attachments,
83
+ }
84
+
85
+ async def get_emails_stream( # noqa: C901
86
+ self,
87
+ page: int = 1,
88
+ page_size: int = 10,
89
+ before: datetime | None = None,
90
+ after: datetime | None = None,
91
+ include: str | None = None,
92
+ ) -> AsyncGenerator[dict[str, Any], None]:
93
+ imap = self.imap_class(self.email_server.host, self.email_server.port)
94
+ try:
95
+ # Wait for the connection to be established
96
+ await imap._client_task
97
+ await imap.wait_hello_from_server()
98
+
99
+ # Login and select inbox
100
+ await imap.login(self.email_server.user_name, self.email_server.password)
101
+ await imap.select("INBOX")
102
+
103
+ # Build search criteria
104
+ search_criteria = []
105
+ if before:
106
+ search_criteria.extend(["BEFORE", before.isoformat()])
107
+ if after:
108
+ search_criteria.extend(["AFTER", after.isoformat()])
109
+ if include:
110
+ search_criteria.extend(["TEXT", include])
111
+
112
+ # If no specific criteria, search for ALL
113
+ if not search_criteria:
114
+ search_criteria = ["ALL"]
115
+
116
+ # Search for messages
117
+ _, messages = await imap.search(*search_criteria)
118
+ message_ids = messages[0].split()
119
+ start = (page - 1) * page_size
120
+ end = start + page_size
121
+
122
+ # Fetch each message
123
+ for _, message_id in enumerate(message_ids[start:end]):
124
+ try:
125
+ # Convert message_id from bytes to string
126
+ message_id_str = message_id.decode("utf-8")
127
+
128
+ # Use the string version of the message ID
129
+ _, data = await imap.fetch(message_id_str, "RFC822")
130
+
131
+ # Find the email data in the response
132
+ raw_email = None
133
+
134
+ # The actual email content is in the bytearray at index 1
135
+ if len(data) > 1 and isinstance(data[1], bytearray) and len(data[1]) > 0:
136
+ raw_email = bytes(data[1])
137
+ else:
138
+ # Fallback to searching through all items
139
+ for _, item in enumerate(data):
140
+ if isinstance(item, (bytes, bytearray)) and len(item) > 100:
141
+ # Skip header lines that contain FETCH
142
+ if isinstance(item, bytes) and b"FETCH" in item:
143
+ continue
144
+ # This is likely the email content
145
+ raw_email = bytes(item) if isinstance(item, bytearray) else item
146
+ break
147
+
148
+ if raw_email:
149
+ try:
150
+ parsed_email = self._parse_email_data(raw_email)
151
+ yield parsed_email
152
+ except Exception as e:
153
+ # Log error but continue with other emails
154
+ print(f"Error parsing email: {e!s}")
155
+ else:
156
+ print(f"Could not find email data in response for message ID: {message_id_str}")
157
+ except Exception as e:
158
+ print(f"Error fetching message {message_id}: {e!s}")
159
+ finally:
160
+ # Ensure we logout properly
161
+ try:
162
+ await imap.logout()
163
+ except Exception as e:
164
+ print(f"Error during logout: {e}")
165
+
166
+ async def get_email_count(
167
+ self,
168
+ before: datetime | None = None,
169
+ after: datetime | None = None,
170
+ include: str | None = None,
171
+ ) -> int:
172
+ imap = self.imap_class(self.email_server.host, self.email_server.port)
173
+ try:
174
+ # Wait for the connection to be established
175
+ await imap._client_task
176
+ await imap.wait_hello_from_server()
177
+
178
+ # Login and select inbox
179
+ await imap.login(self.email_server.user_name, self.email_server.password)
180
+ await imap.select("INBOX")
181
+
182
+ # Build search criteria
183
+ search_criteria = []
184
+ if before:
185
+ search_criteria.extend(["BEFORE", before.isoformat()])
186
+ if after:
187
+ search_criteria.extend(["AFTER", after.isoformat()])
188
+ if include:
189
+ search_criteria.extend(["TEXT", include])
190
+
191
+ # If no specific criteria, search for ALL
192
+ if not search_criteria:
193
+ search_criteria = ["ALL"]
194
+
195
+ # Search for messages and count them
196
+ _, messages = await imap.search(*search_criteria)
197
+ return len(messages[0].split())
198
+ finally:
199
+ # Ensure we logout properly
200
+ try:
201
+ await imap.logout()
202
+ except Exception as e:
203
+ print(f"Error during logout: {e}")
204
+
205
+ async def send_email(self, recipient: str, subject: str, body: str):
206
+ msg = MIMEText(body)
207
+ msg["Subject"] = subject
208
+ msg["From"] = self.sender
209
+ msg["To"] = recipient
210
+
211
+ async with aiosmtplib.SMTP(
212
+ hostname=self.email_server.host,
213
+ port=self.email_server.port,
214
+ start_tls=self.smtp_start_tls,
215
+ use_tls=self.smtp_use_tls,
216
+ ) as smtp:
217
+ await smtp.login(self.email_server.user_name, self.email_server.password)
218
+ await smtp.send_message(msg)
219
+
220
+
221
+ class ClassicEmailHandler(EmailHandler):
222
+ def __init__(self, email_settings: EmailSettings):
223
+ self.email_settings = email_settings
224
+ self.incoming_client = EmailClient(email_settings.incoming)
225
+ self.outgoing_client = EmailClient(
226
+ email_settings.outgoing,
227
+ sender=f"{email_settings.full_name} <{email_settings.email_address}>",
228
+ )
229
+
230
+ async def get_emails(
231
+ self,
232
+ page: int = 1,
233
+ page_size: int = 10,
234
+ before: datetime | None = None,
235
+ after: datetime | None = None,
236
+ include: str | None = None,
237
+ ) -> EmailPageResponse:
238
+ emails = []
239
+ async for email_data in self.incoming_client.get_emails_stream(page, page_size, before, after, include):
240
+ emails.append(EmailData.from_email(email_data))
241
+ total = await self.incoming_client.get_email_count(before, after, include)
242
+ return EmailPageResponse(
243
+ page=page,
244
+ page_size=page_size,
245
+ before=before,
246
+ after=after,
247
+ include=include,
248
+ emails=emails,
249
+ total=total,
250
+ )
251
+
252
+ async def send_email(self, recipient: str, subject: str, body: str) -> None:
253
+ await self.outgoing_client.send_email(recipient, subject, body)
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from mcp_email_server.config import EmailSettings, ProviderSettings, get_settings
6
+ from mcp_email_server.emails.classic import ClassicEmailHandler
7
+
8
+ if TYPE_CHECKING:
9
+ from mcp_email_server.emails import EmailHandler
10
+
11
+
12
+ def dispatch_handler(account_name: str) -> EmailHandler:
13
+ settings = get_settings()
14
+ account = settings.get_account(account_name)
15
+ if isinstance(account, ProviderSettings):
16
+ raise NotImplementedError
17
+ if isinstance(account, EmailSettings):
18
+ return ClassicEmailHandler(account)
@@ -0,0 +1,32 @@
1
+ from datetime import datetime
2
+ from typing import Any
3
+
4
+ from pydantic import BaseModel
5
+
6
+
7
+ class EmailData(BaseModel):
8
+ subject: str
9
+ sender: str
10
+ body: str
11
+ date: datetime
12
+ attachments: list[str]
13
+
14
+ @classmethod
15
+ def from_email(cls, email: dict[str, Any]):
16
+ return cls(
17
+ subject=email["subject"],
18
+ sender=email["from"],
19
+ body=email["body"],
20
+ date=email["date"],
21
+ attachments=email["attachments"],
22
+ )
23
+
24
+
25
+ class EmailPageResponse(BaseModel):
26
+ page: int
27
+ page_size: int
28
+ before: datetime | None
29
+ after: datetime | None
30
+ include: str | None
31
+ emails: list[EmailData]
32
+ total: int
File without changes
@@ -0,0 +1,9 @@
1
+ import os
2
+
3
+ USER_DEFINED_LOG_LEVEL = os.getenv("MCP_EMAIL_SERVER_LOG_LEVEL", "INFO")
4
+
5
+ os.environ["LOGURU_LEVEL"] = USER_DEFINED_LOG_LEVEL
6
+
7
+ from loguru import logger # noqa: E402
8
+
9
+ __all__ = ["logger"]
File without changes
@@ -0,0 +1,8 @@
1
+ {
2
+ "mcpServers": {
3
+ "zerolib-email": {
4
+ "command": "{{ ENTRYPOINT }}",
5
+ "args": ["stdio"]
6
+ }
7
+ }
8
+ }