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.
File without changes
@@ -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)
@@ -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
+ """