mcp-email-server 0.5.2__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 +206 -0
- mcp_email_server/cli.py +50 -0
- mcp_email_server/config.py +349 -0
- mcp_email_server/emails/__init__.py +77 -0
- mcp_email_server/emails/classic.py +880 -0
- mcp_email_server/emails/dispatcher.py +20 -0
- mcp_email_server/emails/models.py +72 -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 +480 -0
- mcp_email_server-0.5.2.dist-info/METADATA +267 -0
- mcp_email_server-0.5.2.dist-info/RECORD +19 -0
- mcp_email_server-0.5.2.dist-info/WHEEL +4 -0
- mcp_email_server-0.5.2.dist-info/entry_points.txt +2 -0
- mcp_email_server-0.5.2.dist-info/licenses/LICENSE +28 -0
|
File without changes
|
mcp_email_server/app.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from typing import Annotated, Literal
|
|
3
|
+
|
|
4
|
+
from mcp.server.fastmcp import FastMCP
|
|
5
|
+
from pydantic import Field
|
|
6
|
+
|
|
7
|
+
from mcp_email_server.config import (
|
|
8
|
+
AccountAttributes,
|
|
9
|
+
EmailSettings,
|
|
10
|
+
ProviderSettings,
|
|
11
|
+
get_settings,
|
|
12
|
+
)
|
|
13
|
+
from mcp_email_server.emails.dispatcher import dispatch_handler
|
|
14
|
+
from mcp_email_server.emails.models import (
|
|
15
|
+
AttachmentDownloadResponse,
|
|
16
|
+
EmailContentBatchResponse,
|
|
17
|
+
EmailMetadataPageResponse,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
mcp = FastMCP("email")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@mcp.resource("email://{account_name}")
|
|
24
|
+
async def get_account(account_name: str) -> EmailSettings | ProviderSettings | None:
|
|
25
|
+
settings = get_settings()
|
|
26
|
+
return settings.get_account(account_name, masked=True)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@mcp.tool(description="List all configured email accounts with masked credentials.")
|
|
30
|
+
async def list_available_accounts() -> list[AccountAttributes]:
|
|
31
|
+
settings = get_settings()
|
|
32
|
+
return [account.masked() for account in settings.get_accounts()]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@mcp.tool(description="Add a new email account configuration to the settings.")
|
|
36
|
+
async def add_email_account(email: EmailSettings) -> str:
|
|
37
|
+
settings = get_settings()
|
|
38
|
+
settings.add_email(email)
|
|
39
|
+
settings.store()
|
|
40
|
+
return f"Successfully added email account '{email.account_name}'"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@mcp.tool(
|
|
44
|
+
description="List email metadata (email_id, subject, sender, recipients, date) without body content. Returns email_id for use with get_emails_content."
|
|
45
|
+
)
|
|
46
|
+
async def list_emails_metadata(
|
|
47
|
+
account_name: Annotated[str, Field(description="The name of the email account.")],
|
|
48
|
+
page: Annotated[
|
|
49
|
+
int,
|
|
50
|
+
Field(default=1, description="The page number to retrieve (starting from 1)."),
|
|
51
|
+
] = 1,
|
|
52
|
+
page_size: Annotated[int, Field(default=10, description="The number of emails to retrieve per page.")] = 10,
|
|
53
|
+
before: Annotated[
|
|
54
|
+
datetime | None,
|
|
55
|
+
Field(default=None, description="Retrieve emails before this datetime (UTC)."),
|
|
56
|
+
] = None,
|
|
57
|
+
since: Annotated[
|
|
58
|
+
datetime | None,
|
|
59
|
+
Field(default=None, description="Retrieve emails since this datetime (UTC)."),
|
|
60
|
+
] = None,
|
|
61
|
+
subject: Annotated[str | None, Field(default=None, description="Filter emails by subject.")] = None,
|
|
62
|
+
from_address: Annotated[str | None, Field(default=None, description="Filter emails by sender address.")] = None,
|
|
63
|
+
to_address: Annotated[
|
|
64
|
+
str | None,
|
|
65
|
+
Field(default=None, description="Filter emails by recipient address."),
|
|
66
|
+
] = None,
|
|
67
|
+
order: Annotated[
|
|
68
|
+
Literal["asc", "desc"],
|
|
69
|
+
Field(default=None, description="Order emails by field. `asc` or `desc`."),
|
|
70
|
+
] = "desc",
|
|
71
|
+
mailbox: Annotated[str, Field(default="INBOX", description="The mailbox to retrieve emails from.")] = "INBOX",
|
|
72
|
+
) -> EmailMetadataPageResponse:
|
|
73
|
+
handler = dispatch_handler(account_name)
|
|
74
|
+
|
|
75
|
+
return await handler.get_emails_metadata(
|
|
76
|
+
page=page,
|
|
77
|
+
page_size=page_size,
|
|
78
|
+
before=before,
|
|
79
|
+
since=since,
|
|
80
|
+
subject=subject,
|
|
81
|
+
from_address=from_address,
|
|
82
|
+
to_address=to_address,
|
|
83
|
+
order=order,
|
|
84
|
+
mailbox=mailbox,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@mcp.tool(
|
|
89
|
+
description="Get the full content (including body) of one or more emails by their email_id. Use list_emails_metadata first to get the email_id."
|
|
90
|
+
)
|
|
91
|
+
async def get_emails_content(
|
|
92
|
+
account_name: Annotated[str, Field(description="The name of the email account.")],
|
|
93
|
+
email_ids: Annotated[
|
|
94
|
+
list[str],
|
|
95
|
+
Field(
|
|
96
|
+
description="List of email_id to retrieve (obtained from list_emails_metadata). Can be a single email_id or multiple email_ids."
|
|
97
|
+
),
|
|
98
|
+
],
|
|
99
|
+
mailbox: Annotated[str, Field(default="INBOX", description="The mailbox to retrieve emails from.")] = "INBOX",
|
|
100
|
+
) -> EmailContentBatchResponse:
|
|
101
|
+
handler = dispatch_handler(account_name)
|
|
102
|
+
return await handler.get_emails_content(email_ids, mailbox)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@mcp.tool(
|
|
106
|
+
description="Send an email using the specified account. Supports replying to emails with proper threading when in_reply_to is provided.",
|
|
107
|
+
)
|
|
108
|
+
async def send_email(
|
|
109
|
+
account_name: Annotated[str, Field(description="The name of the email account to send from.")],
|
|
110
|
+
recipients: Annotated[list[str], Field(description="A list of recipient email addresses.")],
|
|
111
|
+
subject: Annotated[str, Field(description="The subject of the email.")],
|
|
112
|
+
body: Annotated[str, Field(description="The body of the email.")],
|
|
113
|
+
cc: Annotated[
|
|
114
|
+
list[str] | None,
|
|
115
|
+
Field(default=None, description="A list of CC email addresses."),
|
|
116
|
+
] = None,
|
|
117
|
+
bcc: Annotated[
|
|
118
|
+
list[str] | None,
|
|
119
|
+
Field(default=None, description="A list of BCC email addresses."),
|
|
120
|
+
] = None,
|
|
121
|
+
html: Annotated[
|
|
122
|
+
bool,
|
|
123
|
+
Field(default=False, description="Whether to send the email as HTML (True) or plain text (False)."),
|
|
124
|
+
] = False,
|
|
125
|
+
attachments: Annotated[
|
|
126
|
+
list[str] | None,
|
|
127
|
+
Field(
|
|
128
|
+
default=None,
|
|
129
|
+
description="A list of absolute file paths to attach to the email. Supports common file types (documents, images, archives, etc.).",
|
|
130
|
+
),
|
|
131
|
+
] = None,
|
|
132
|
+
in_reply_to: Annotated[
|
|
133
|
+
str | None,
|
|
134
|
+
Field(
|
|
135
|
+
default=None,
|
|
136
|
+
description="Message-ID of the email being replied to. Enables proper threading in email clients.",
|
|
137
|
+
),
|
|
138
|
+
] = None,
|
|
139
|
+
references: Annotated[
|
|
140
|
+
str | None,
|
|
141
|
+
Field(
|
|
142
|
+
default=None,
|
|
143
|
+
description="Space-separated Message-IDs for the thread chain. Usually includes in_reply_to plus ancestors.",
|
|
144
|
+
),
|
|
145
|
+
] = None,
|
|
146
|
+
) -> str:
|
|
147
|
+
handler = dispatch_handler(account_name)
|
|
148
|
+
await handler.send_email(
|
|
149
|
+
recipients,
|
|
150
|
+
subject,
|
|
151
|
+
body,
|
|
152
|
+
cc,
|
|
153
|
+
bcc,
|
|
154
|
+
html,
|
|
155
|
+
attachments,
|
|
156
|
+
in_reply_to,
|
|
157
|
+
references,
|
|
158
|
+
)
|
|
159
|
+
recipient_str = ", ".join(recipients)
|
|
160
|
+
attachment_info = f" with {len(attachments)} attachment(s)" if attachments else ""
|
|
161
|
+
return f"Email sent successfully to {recipient_str}{attachment_info}"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@mcp.tool(
|
|
165
|
+
description="Delete one or more emails by their email_id. Use list_emails_metadata first to get the email_id."
|
|
166
|
+
)
|
|
167
|
+
async def delete_emails(
|
|
168
|
+
account_name: Annotated[str, Field(description="The name of the email account.")],
|
|
169
|
+
email_ids: Annotated[
|
|
170
|
+
list[str],
|
|
171
|
+
Field(description="List of email_id to delete (obtained from list_emails_metadata)."),
|
|
172
|
+
],
|
|
173
|
+
mailbox: Annotated[str, Field(default="INBOX", description="The mailbox to delete emails from.")] = "INBOX",
|
|
174
|
+
) -> str:
|
|
175
|
+
handler = dispatch_handler(account_name)
|
|
176
|
+
deleted_ids, failed_ids = await handler.delete_emails(email_ids, mailbox)
|
|
177
|
+
|
|
178
|
+
result = f"Successfully deleted {len(deleted_ids)} email(s)"
|
|
179
|
+
if failed_ids:
|
|
180
|
+
result += f", failed to delete {len(failed_ids)} email(s): {', '.join(failed_ids)}"
|
|
181
|
+
return result
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@mcp.tool(
|
|
185
|
+
description="Download an email attachment and save it to the specified path. This feature must be explicitly enabled in settings (enable_attachment_download=true) due to security considerations.",
|
|
186
|
+
)
|
|
187
|
+
async def download_attachment(
|
|
188
|
+
account_name: Annotated[str, Field(description="The name of the email account.")],
|
|
189
|
+
email_id: Annotated[
|
|
190
|
+
str, Field(description="The email ID (obtained from list_emails_metadata or get_emails_content).")
|
|
191
|
+
],
|
|
192
|
+
attachment_name: Annotated[
|
|
193
|
+
str, Field(description="The name of the attachment to download (as shown in the attachments list).")
|
|
194
|
+
],
|
|
195
|
+
save_path: Annotated[str, Field(description="The absolute path where the attachment should be saved.")],
|
|
196
|
+
mailbox: Annotated[str, Field(description="The mailbox to search in (default: INBOX).")] = "INBOX",
|
|
197
|
+
) -> AttachmentDownloadResponse:
|
|
198
|
+
settings = get_settings()
|
|
199
|
+
if not settings.enable_attachment_download:
|
|
200
|
+
msg = (
|
|
201
|
+
"Attachment download is disabled. Set 'enable_attachment_download=true' in settings to enable this feature."
|
|
202
|
+
)
|
|
203
|
+
raise PermissionError(msg)
|
|
204
|
+
|
|
205
|
+
handler = dispatch_handler(account_name)
|
|
206
|
+
return await handler.download_attachment(email_id, attachment_name, save_path, mailbox)
|
mcp_email_server/cli.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from mcp_email_server.app import mcp
|
|
6
|
+
from mcp_email_server.config import delete_settings
|
|
7
|
+
|
|
8
|
+
app = typer.Typer()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@app.command()
|
|
12
|
+
def stdio():
|
|
13
|
+
mcp.run(transport="stdio")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@app.command()
|
|
17
|
+
def sse(
|
|
18
|
+
host: str = "localhost",
|
|
19
|
+
port: int = 9557,
|
|
20
|
+
):
|
|
21
|
+
mcp.settings.host = host
|
|
22
|
+
mcp.settings.port = port
|
|
23
|
+
mcp.run(transport="sse")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@app.command()
|
|
27
|
+
def streamable_http(
|
|
28
|
+
host: str = os.environ.get("MCP_HOST", "localhost"),
|
|
29
|
+
port: int = os.environ.get("MCP_PORT", 9557),
|
|
30
|
+
):
|
|
31
|
+
mcp.settings.host = host
|
|
32
|
+
mcp.settings.port = port
|
|
33
|
+
mcp.run(transport="streamable-http")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@app.command()
|
|
37
|
+
def ui():
|
|
38
|
+
from mcp_email_server.ui import main as ui_main
|
|
39
|
+
|
|
40
|
+
ui_main()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@app.command()
|
|
44
|
+
def reset():
|
|
45
|
+
delete_settings()
|
|
46
|
+
typer.echo("✅ Config reset")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
if __name__ == "__main__":
|
|
50
|
+
app(["stdio"])
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
from zoneinfo import ZoneInfo
|
|
8
|
+
|
|
9
|
+
import tomli_w
|
|
10
|
+
from pydantic import BaseModel, ConfigDict, Field, field_serializer, model_validator
|
|
11
|
+
from pydantic_settings import (
|
|
12
|
+
BaseSettings,
|
|
13
|
+
PydanticBaseSettingsSource,
|
|
14
|
+
SettingsConfigDict,
|
|
15
|
+
TomlConfigSettingsSource,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
from mcp_email_server.log import logger
|
|
19
|
+
|
|
20
|
+
DEFAILT_CONFIG_PATH = "~/.config/zerolib/mcp_email_server/config.toml"
|
|
21
|
+
|
|
22
|
+
CONFIG_PATH = Path(os.getenv("MCP_EMAIL_SERVER_CONFIG_PATH", DEFAILT_CONFIG_PATH)).expanduser().resolve()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class EmailServer(BaseModel):
|
|
26
|
+
user_name: str
|
|
27
|
+
password: str
|
|
28
|
+
host: str
|
|
29
|
+
port: int
|
|
30
|
+
use_ssl: bool = True # Usually port 465
|
|
31
|
+
start_ssl: bool = False # Usually port 587
|
|
32
|
+
|
|
33
|
+
def masked(self) -> EmailServer:
|
|
34
|
+
return self.model_copy(update={"password": "********"})
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class AccountAttributes(BaseModel):
|
|
38
|
+
model_config = ConfigDict(json_encoders={datetime.datetime: lambda v: v.isoformat()})
|
|
39
|
+
account_name: str
|
|
40
|
+
description: str = ""
|
|
41
|
+
created_at: datetime.datetime = Field(default_factory=lambda: datetime.datetime.now(ZoneInfo("UTC")))
|
|
42
|
+
updated_at: datetime.datetime = Field(default_factory=lambda: datetime.datetime.now(ZoneInfo("UTC")))
|
|
43
|
+
|
|
44
|
+
@model_validator(mode="after")
|
|
45
|
+
@classmethod
|
|
46
|
+
def update_updated_at(cls, obj: AccountAttributes) -> AccountAttributes:
|
|
47
|
+
"""Update updated_at field."""
|
|
48
|
+
# must disable validation to avoid infinite loop
|
|
49
|
+
obj.model_config["validate_assignment"] = False
|
|
50
|
+
|
|
51
|
+
# update updated_at field
|
|
52
|
+
obj.updated_at = datetime.datetime.now(ZoneInfo("UTC"))
|
|
53
|
+
|
|
54
|
+
# enable validation again
|
|
55
|
+
obj.model_config["validate_assignment"] = True
|
|
56
|
+
return obj
|
|
57
|
+
|
|
58
|
+
def __eq__(self, other: object) -> bool:
|
|
59
|
+
if not isinstance(other, AccountAttributes):
|
|
60
|
+
return NotImplemented
|
|
61
|
+
return self.model_dump(exclude={"created_at", "updated_at"}) == other.model_dump(
|
|
62
|
+
exclude={"created_at", "updated_at"}
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
@field_serializer("created_at", "updated_at")
|
|
66
|
+
def serialize_datetime(self, v: datetime.datetime) -> str:
|
|
67
|
+
return v.isoformat()
|
|
68
|
+
|
|
69
|
+
def masked(self) -> AccountAttributes:
|
|
70
|
+
return self.model_copy()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class EmailSettings(AccountAttributes):
|
|
74
|
+
full_name: str
|
|
75
|
+
email_address: str
|
|
76
|
+
incoming: EmailServer
|
|
77
|
+
outgoing: EmailServer
|
|
78
|
+
save_to_sent: bool = True # Save sent emails to IMAP Sent folder
|
|
79
|
+
sent_folder_name: str | None = None # Override Sent folder name (auto-detect if None)
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def init(
|
|
83
|
+
cls,
|
|
84
|
+
*,
|
|
85
|
+
account_name: str,
|
|
86
|
+
full_name: str,
|
|
87
|
+
email_address: str,
|
|
88
|
+
user_name: str,
|
|
89
|
+
password: str,
|
|
90
|
+
imap_host: str,
|
|
91
|
+
smtp_host: str,
|
|
92
|
+
imap_user_name: str | None = None,
|
|
93
|
+
imap_password: str | None = None,
|
|
94
|
+
imap_port: int = 993,
|
|
95
|
+
imap_ssl: bool = True,
|
|
96
|
+
smtp_port: int = 465,
|
|
97
|
+
smtp_ssl: bool = True,
|
|
98
|
+
smtp_start_ssl: bool = False,
|
|
99
|
+
smtp_user_name: str | None = None,
|
|
100
|
+
smtp_password: str | None = None,
|
|
101
|
+
save_to_sent: bool = True,
|
|
102
|
+
sent_folder_name: str | None = None,
|
|
103
|
+
) -> EmailSettings:
|
|
104
|
+
return cls(
|
|
105
|
+
account_name=account_name,
|
|
106
|
+
full_name=full_name,
|
|
107
|
+
email_address=email_address,
|
|
108
|
+
incoming=EmailServer(
|
|
109
|
+
user_name=imap_user_name or user_name,
|
|
110
|
+
password=imap_password or password,
|
|
111
|
+
host=imap_host,
|
|
112
|
+
port=imap_port,
|
|
113
|
+
use_ssl=imap_ssl,
|
|
114
|
+
),
|
|
115
|
+
outgoing=EmailServer(
|
|
116
|
+
user_name=smtp_user_name or user_name,
|
|
117
|
+
password=smtp_password or password,
|
|
118
|
+
host=smtp_host,
|
|
119
|
+
port=smtp_port,
|
|
120
|
+
use_ssl=smtp_ssl,
|
|
121
|
+
start_ssl=smtp_start_ssl,
|
|
122
|
+
),
|
|
123
|
+
save_to_sent=save_to_sent,
|
|
124
|
+
sent_folder_name=sent_folder_name,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
@classmethod
|
|
128
|
+
def from_env(cls) -> EmailSettings | None:
|
|
129
|
+
"""Create EmailSettings from environment variables.
|
|
130
|
+
|
|
131
|
+
Expected environment variables:
|
|
132
|
+
- MCP_EMAIL_SERVER_ACCOUNT_NAME (default: "default")
|
|
133
|
+
- MCP_EMAIL_SERVER_FULL_NAME
|
|
134
|
+
- MCP_EMAIL_SERVER_EMAIL_ADDRESS
|
|
135
|
+
- MCP_EMAIL_SERVER_USER_NAME
|
|
136
|
+
- MCP_EMAIL_SERVER_PASSWORD
|
|
137
|
+
- MCP_EMAIL_SERVER_IMAP_HOST
|
|
138
|
+
- MCP_EMAIL_SERVER_IMAP_PORT (default: 993)
|
|
139
|
+
- MCP_EMAIL_SERVER_IMAP_SSL (default: true)
|
|
140
|
+
- MCP_EMAIL_SERVER_SMTP_HOST
|
|
141
|
+
- MCP_EMAIL_SERVER_SMTP_PORT (default: 465)
|
|
142
|
+
- MCP_EMAIL_SERVER_SMTP_SSL (default: true)
|
|
143
|
+
- MCP_EMAIL_SERVER_SMTP_START_SSL (default: false)
|
|
144
|
+
- MCP_EMAIL_SERVER_SAVE_TO_SENT (default: true)
|
|
145
|
+
- MCP_EMAIL_SERVER_SENT_FOLDER_NAME (default: auto-detect)
|
|
146
|
+
"""
|
|
147
|
+
# Check if minimum required environment variables are set
|
|
148
|
+
email_address = os.getenv("MCP_EMAIL_SERVER_EMAIL_ADDRESS")
|
|
149
|
+
password = os.getenv("MCP_EMAIL_SERVER_PASSWORD")
|
|
150
|
+
|
|
151
|
+
if not email_address or not password:
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
# Parse boolean values
|
|
155
|
+
def parse_bool(value: str | None, default: bool = True) -> bool:
|
|
156
|
+
if value is None:
|
|
157
|
+
return default
|
|
158
|
+
return value.lower() in ("true", "1", "yes", "on")
|
|
159
|
+
|
|
160
|
+
# Get all environment variables with defaults
|
|
161
|
+
account_name = os.getenv("MCP_EMAIL_SERVER_ACCOUNT_NAME", "default")
|
|
162
|
+
full_name = os.getenv("MCP_EMAIL_SERVER_FULL_NAME", email_address.split("@")[0])
|
|
163
|
+
user_name = os.getenv("MCP_EMAIL_SERVER_USER_NAME", email_address)
|
|
164
|
+
imap_host = os.getenv("MCP_EMAIL_SERVER_IMAP_HOST")
|
|
165
|
+
smtp_host = os.getenv("MCP_EMAIL_SERVER_SMTP_HOST")
|
|
166
|
+
|
|
167
|
+
# Required fields check
|
|
168
|
+
if not imap_host or not smtp_host:
|
|
169
|
+
logger.warning("Missing required email configuration environment variables (IMAP_HOST or SMTP_HOST)")
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
return cls.init(
|
|
174
|
+
account_name=account_name,
|
|
175
|
+
full_name=full_name,
|
|
176
|
+
email_address=email_address,
|
|
177
|
+
user_name=user_name,
|
|
178
|
+
password=password,
|
|
179
|
+
imap_host=imap_host,
|
|
180
|
+
imap_port=int(os.getenv("MCP_EMAIL_SERVER_IMAP_PORT", "993")),
|
|
181
|
+
imap_ssl=parse_bool(os.getenv("MCP_EMAIL_SERVER_IMAP_SSL"), True),
|
|
182
|
+
smtp_host=smtp_host,
|
|
183
|
+
smtp_port=int(os.getenv("MCP_EMAIL_SERVER_SMTP_PORT", "465")),
|
|
184
|
+
smtp_ssl=parse_bool(os.getenv("MCP_EMAIL_SERVER_SMTP_SSL"), True),
|
|
185
|
+
smtp_start_ssl=parse_bool(os.getenv("MCP_EMAIL_SERVER_SMTP_START_SSL"), False),
|
|
186
|
+
smtp_user_name=os.getenv("MCP_EMAIL_SERVER_SMTP_USER_NAME", user_name),
|
|
187
|
+
smtp_password=os.getenv("MCP_EMAIL_SERVER_SMTP_PASSWORD", password),
|
|
188
|
+
imap_user_name=os.getenv("MCP_EMAIL_SERVER_IMAP_USER_NAME", user_name),
|
|
189
|
+
imap_password=os.getenv("MCP_EMAIL_SERVER_IMAP_PASSWORD", password),
|
|
190
|
+
save_to_sent=parse_bool(os.getenv("MCP_EMAIL_SERVER_SAVE_TO_SENT"), True),
|
|
191
|
+
sent_folder_name=os.getenv("MCP_EMAIL_SERVER_SENT_FOLDER_NAME"),
|
|
192
|
+
)
|
|
193
|
+
except (ValueError, TypeError) as e:
|
|
194
|
+
logger.error(f"Failed to create email settings from environment variables: {e}")
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
def masked(self) -> EmailSettings:
|
|
198
|
+
return self.model_copy(
|
|
199
|
+
update={
|
|
200
|
+
"incoming": self.incoming.masked(),
|
|
201
|
+
"outgoing": self.outgoing.masked(),
|
|
202
|
+
}
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class ProviderSettings(AccountAttributes):
|
|
207
|
+
provider_name: str
|
|
208
|
+
api_key: str
|
|
209
|
+
|
|
210
|
+
def masked(self) -> AccountAttributes:
|
|
211
|
+
return self.model_copy(update={"api_key": "********"})
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _parse_bool_env(value: str | None, default: bool = False) -> bool:
|
|
215
|
+
"""Parse boolean value from environment variable."""
|
|
216
|
+
if value is None:
|
|
217
|
+
return default
|
|
218
|
+
return value.lower() in ("true", "1", "yes", "on")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class Settings(BaseSettings):
|
|
222
|
+
emails: list[EmailSettings] = []
|
|
223
|
+
providers: list[ProviderSettings] = []
|
|
224
|
+
db_location: str = CONFIG_PATH.with_name("db.sqlite3").as_posix()
|
|
225
|
+
enable_attachment_download: bool = False
|
|
226
|
+
|
|
227
|
+
model_config = SettingsConfigDict(toml_file=CONFIG_PATH, validate_assignment=True, revalidate_instances="always")
|
|
228
|
+
|
|
229
|
+
def __init__(self, **data: Any) -> None:
|
|
230
|
+
"""Initialize Settings with support for environment variables."""
|
|
231
|
+
super().__init__(**data)
|
|
232
|
+
|
|
233
|
+
# Check for enable_attachment_download from environment variable
|
|
234
|
+
env_enable_attachment = os.getenv("MCP_EMAIL_SERVER_ENABLE_ATTACHMENT_DOWNLOAD")
|
|
235
|
+
if env_enable_attachment is not None:
|
|
236
|
+
self.enable_attachment_download = _parse_bool_env(env_enable_attachment, False)
|
|
237
|
+
logger.info(f"Set enable_attachment_download={self.enable_attachment_download} from environment variable")
|
|
238
|
+
|
|
239
|
+
# Check for email configuration from environment variables
|
|
240
|
+
env_email = EmailSettings.from_env()
|
|
241
|
+
if env_email:
|
|
242
|
+
# Check if this account already exists (from TOML)
|
|
243
|
+
existing_account = None
|
|
244
|
+
for i, email in enumerate(self.emails):
|
|
245
|
+
if email.account_name == env_email.account_name:
|
|
246
|
+
existing_account = i
|
|
247
|
+
break
|
|
248
|
+
|
|
249
|
+
if existing_account is not None:
|
|
250
|
+
# Replace existing account with env configuration
|
|
251
|
+
self.emails[existing_account] = env_email
|
|
252
|
+
logger.info(f"Overriding email account '{env_email.account_name}' with environment variables")
|
|
253
|
+
else:
|
|
254
|
+
# Add new account from env
|
|
255
|
+
self.emails.insert(0, env_email)
|
|
256
|
+
logger.info(f"Added email account '{env_email.account_name}' from environment variables")
|
|
257
|
+
|
|
258
|
+
def add_email(self, email: EmailSettings) -> None:
|
|
259
|
+
"""Use re-assigned for validation to work."""
|
|
260
|
+
self.emails = [email, *self.emails]
|
|
261
|
+
|
|
262
|
+
def add_provider(self, provider: ProviderSettings) -> None:
|
|
263
|
+
"""Use re-assigned for validation to work."""
|
|
264
|
+
self.providers = [provider, *self.providers]
|
|
265
|
+
|
|
266
|
+
def delete_email(self, account_name: str) -> None:
|
|
267
|
+
"""Use re-assigned for validation to work."""
|
|
268
|
+
self.emails = [email for email in self.emails if email.account_name != account_name]
|
|
269
|
+
|
|
270
|
+
def delete_provider(self, account_name: str) -> None:
|
|
271
|
+
"""Use re-assigned for validation to work."""
|
|
272
|
+
self.providers = [provider for provider in self.providers if provider.account_name != account_name]
|
|
273
|
+
|
|
274
|
+
def get_account(self, account_name: str, masked: bool = False) -> EmailSettings | ProviderSettings | None:
|
|
275
|
+
for email in self.emails:
|
|
276
|
+
if email.account_name == account_name:
|
|
277
|
+
return email if not masked else email.masked()
|
|
278
|
+
for provider in self.providers:
|
|
279
|
+
if provider.account_name == account_name:
|
|
280
|
+
return provider if not masked else provider.masked()
|
|
281
|
+
return None
|
|
282
|
+
|
|
283
|
+
def get_accounts(self, masked: bool = False) -> list[EmailSettings | ProviderSettings]:
|
|
284
|
+
accounts = self.emails + self.providers
|
|
285
|
+
if masked:
|
|
286
|
+
return [account.masked() for account in accounts]
|
|
287
|
+
return accounts
|
|
288
|
+
|
|
289
|
+
@model_validator(mode="after")
|
|
290
|
+
@classmethod
|
|
291
|
+
def check_unique_account_names(cls, obj: Settings) -> Settings:
|
|
292
|
+
account_names = set()
|
|
293
|
+
for email in obj.emails:
|
|
294
|
+
if email.account_name in account_names:
|
|
295
|
+
raise ValueError(f"Duplicate account name {email.account_name}")
|
|
296
|
+
account_names.add(email.account_name)
|
|
297
|
+
for provider in obj.providers:
|
|
298
|
+
if provider.account_name in account_names:
|
|
299
|
+
raise ValueError(f"Duplicate account name {provider.account_name}")
|
|
300
|
+
account_names.add(provider.account_name)
|
|
301
|
+
|
|
302
|
+
return obj
|
|
303
|
+
|
|
304
|
+
@classmethod
|
|
305
|
+
def settings_customise_sources(
|
|
306
|
+
cls,
|
|
307
|
+
settings_cls: type[BaseSettings],
|
|
308
|
+
init_settings: PydanticBaseSettingsSource,
|
|
309
|
+
env_settings: PydanticBaseSettingsSource,
|
|
310
|
+
dotenv_settings: PydanticBaseSettingsSource,
|
|
311
|
+
file_secret_settings: PydanticBaseSettingsSource,
|
|
312
|
+
) -> tuple[PydanticBaseSettingsSource, ...]:
|
|
313
|
+
return (TomlConfigSettingsSource(settings_cls),)
|
|
314
|
+
|
|
315
|
+
def _to_toml(self) -> str:
|
|
316
|
+
data = self.model_dump(exclude_none=True)
|
|
317
|
+
return tomli_w.dumps(data)
|
|
318
|
+
|
|
319
|
+
def store(self) -> None:
|
|
320
|
+
toml_file = self.model_config["toml_file"]
|
|
321
|
+
toml_file.parent.mkdir(parents=True, exist_ok=True)
|
|
322
|
+
toml_file.write_text(self._to_toml())
|
|
323
|
+
logger.info(f"Settings stored in {toml_file}")
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
_settings = None
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def get_settings(reload: bool = False) -> Settings:
|
|
330
|
+
global _settings
|
|
331
|
+
if not _settings or reload:
|
|
332
|
+
logger.info(f"Loading settings from {CONFIG_PATH}")
|
|
333
|
+
_settings = Settings()
|
|
334
|
+
return _settings
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def store_settings(settings: Settings | None = None) -> None:
|
|
338
|
+
if not settings:
|
|
339
|
+
settings = get_settings()
|
|
340
|
+
settings.store()
|
|
341
|
+
return
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def delete_settings() -> None:
|
|
345
|
+
if not CONFIG_PATH.exists():
|
|
346
|
+
logger.info(f"Settings file {CONFIG_PATH} does not exist")
|
|
347
|
+
return
|
|
348
|
+
CONFIG_PATH.unlink()
|
|
349
|
+
logger.info(f"Deleted settings file {CONFIG_PATH}")
|
|
@@ -0,0 +1,77 @@
|
|
|
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 (
|
|
7
|
+
AttachmentDownloadResponse,
|
|
8
|
+
EmailContentBatchResponse,
|
|
9
|
+
EmailMetadataPageResponse,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class EmailHandler(abc.ABC):
|
|
14
|
+
@abc.abstractmethod
|
|
15
|
+
async def get_emails_metadata(
|
|
16
|
+
self,
|
|
17
|
+
page: int = 1,
|
|
18
|
+
page_size: int = 10,
|
|
19
|
+
before: datetime | None = None,
|
|
20
|
+
since: datetime | None = None,
|
|
21
|
+
subject: str | None = None,
|
|
22
|
+
from_address: str | None = None,
|
|
23
|
+
to_address: str | None = None,
|
|
24
|
+
order: str = "desc",
|
|
25
|
+
mailbox: str = "INBOX",
|
|
26
|
+
) -> "EmailMetadataPageResponse":
|
|
27
|
+
"""
|
|
28
|
+
Get email metadata only (without body content) for better performance
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
@abc.abstractmethod
|
|
32
|
+
async def get_emails_content(self, email_ids: list[str], mailbox: str = "INBOX") -> "EmailContentBatchResponse":
|
|
33
|
+
"""
|
|
34
|
+
Get full content (including body) of multiple emails by their email IDs (IMAP UIDs)
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
@abc.abstractmethod
|
|
38
|
+
async def send_email(
|
|
39
|
+
self,
|
|
40
|
+
recipients: list[str],
|
|
41
|
+
subject: str,
|
|
42
|
+
body: str,
|
|
43
|
+
cc: list[str] | None = None,
|
|
44
|
+
bcc: list[str] | None = None,
|
|
45
|
+
html: bool = False,
|
|
46
|
+
attachments: list[str] | None = None,
|
|
47
|
+
) -> None:
|
|
48
|
+
"""
|
|
49
|
+
Send email
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
@abc.abstractmethod
|
|
53
|
+
async def delete_emails(self, email_ids: list[str], mailbox: str = "INBOX") -> tuple[list[str], list[str]]:
|
|
54
|
+
"""
|
|
55
|
+
Delete emails by their IDs. Returns (deleted_ids, failed_ids)
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
@abc.abstractmethod
|
|
59
|
+
async def download_attachment(
|
|
60
|
+
self,
|
|
61
|
+
email_id: str,
|
|
62
|
+
attachment_name: str,
|
|
63
|
+
save_path: str,
|
|
64
|
+
mailbox: str = "INBOX",
|
|
65
|
+
) -> "AttachmentDownloadResponse":
|
|
66
|
+
"""
|
|
67
|
+
Download an email attachment and save it to the specified path.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
email_id: The UID of the email containing the attachment.
|
|
71
|
+
attachment_name: The filename of the attachment to download.
|
|
72
|
+
save_path: The local path where the attachment will be saved.
|
|
73
|
+
mailbox: The mailbox to search in (default: "INBOX").
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
AttachmentDownloadResponse with download result information.
|
|
77
|
+
"""
|