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.
- mcp_email_server/__init__.py +0 -0
- mcp_email_server/app.py +54 -0
- mcp_email_server/cli.py +38 -0
- mcp_email_server/config.py +229 -0
- mcp_email_server/emails/__init__.py +27 -0
- mcp_email_server/emails/classic.py +253 -0
- mcp_email_server/emails/dispatcher.py +18 -0
- mcp_email_server/emails/models.py +32 -0
- mcp_email_server/emails/provider/__init__.py +0 -0
- mcp_email_server/log.py +9 -0
- mcp_email_server/tools/__init__.py +0 -0
- mcp_email_server/tools/claude_desktop_config.json +8 -0
- mcp_email_server/tools/installer.py +155 -0
- mcp_email_server/ui.py +455 -0
- mcp_email_server-0.0.3.dist-info/METADATA +92 -0
- mcp_email_server-0.0.3.dist-info/RECORD +19 -0
- mcp_email_server-0.0.3.dist-info/WHEEL +4 -0
- mcp_email_server-0.0.3.dist-info/entry_points.txt +2 -0
- mcp_email_server-0.0.3.dist-info/licenses/LICENSE +28 -0
|
File without changes
|
mcp_email_server/app.py
ADDED
|
@@ -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
|
mcp_email_server/cli.py
ADDED
|
@@ -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
|
mcp_email_server/log.py
ADDED
|
File without changes
|