mseep-email-client 0.1.1__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.
@@ -0,0 +1,8 @@
1
+ from . import server
2
+ import asyncio
3
+
4
+ def main():
5
+ """Main entry point for the package."""
6
+ asyncio.run(server.main())
7
+
8
+ __all__ = ['main', 'server']
email_client/server.py ADDED
@@ -0,0 +1,482 @@
1
+ from typing import Any
2
+ import asyncio
3
+ from datetime import datetime, timedelta
4
+ import email
5
+ import imaplib
6
+ import smtplib
7
+ import logging
8
+ from email.mime.text import MIMEText
9
+ from email.mime.multipart import MIMEMultipart
10
+ import os
11
+ from dotenv import load_dotenv
12
+ from mcp.server.models import InitializationOptions
13
+ import mcp.types as types
14
+ from mcp.server import NotificationOptions, Server
15
+ import mcp.server.stdio
16
+
17
+ # Configure logging
18
+ logging.basicConfig(
19
+ level=logging.DEBUG,
20
+ format='%(asctime)s - %(levelname)s - %(message)s',
21
+ filename='email_client.log'
22
+ )
23
+
24
+ # Load environment variables from .env file
25
+ load_dotenv()
26
+
27
+ # Email configuration
28
+ EMAIL_CONFIG = {
29
+ "email": os.getenv("EMAIL_ADDRESS", "your.email@gmail.com"),
30
+ "password": os.getenv("EMAIL_PASSWORD", "your-app-specific-password"),
31
+ "imap_server": os.getenv("IMAP_SERVER", "imap.gmail.com"),
32
+ "smtp_server": os.getenv("SMTP_SERVER", "smtp.gmail.com"),
33
+ "smtp_port": int(os.getenv("SMTP_PORT", "587"))
34
+ }
35
+
36
+ # Constants
37
+ SEARCH_TIMEOUT = 60 # seconds
38
+ MAX_EMAILS = 100
39
+
40
+ server = Server("email")
41
+
42
+ def format_email_summary(msg_data: tuple) -> dict:
43
+ """Format an email message into a summary dict with basic information."""
44
+ email_body = email.message_from_bytes(msg_data[0][1])
45
+
46
+ return {
47
+ "id": msg_data[0][0].split()[0].decode(), # Get the email ID
48
+ "from": email_body.get("From", "Unknown"),
49
+ "date": email_body.get("Date", "Unknown"),
50
+ "subject": email_body.get("Subject", "No Subject"),
51
+ }
52
+
53
+ def format_email_content(msg_data: tuple) -> dict:
54
+ """Format an email message into a dict with full content."""
55
+ email_body = email.message_from_bytes(msg_data[0][1])
56
+
57
+ # Extract body content
58
+ body = ""
59
+ if email_body.is_multipart():
60
+ # Handle multipart messages
61
+ for part in email_body.walk():
62
+ if part.get_content_type() == "text/plain":
63
+ body = part.get_payload(decode=True).decode()
64
+ break
65
+ elif part.get_content_type() == "text/html":
66
+ # If no plain text found, use HTML content
67
+ if not body:
68
+ body = part.get_payload(decode=True).decode()
69
+ else:
70
+ # Handle non-multipart messages
71
+ body = email_body.get_payload(decode=True).decode()
72
+
73
+ return {
74
+ "from": email_body.get("From", "Unknown"),
75
+ "to": email_body.get("To", "Unknown"),
76
+ "date": email_body.get("Date", "Unknown"),
77
+ "subject": email_body.get("Subject", "No Subject"),
78
+ "content": body
79
+ }
80
+
81
+ async def search_emails_async(mail: imaplib.IMAP4_SSL, search_criteria: str) -> list[dict]:
82
+ """Asynchronously search emails with timeout."""
83
+ loop = asyncio.get_event_loop()
84
+ try:
85
+ _, messages = await loop.run_in_executor(None, lambda: mail.search(None, search_criteria))
86
+ if not messages[0]:
87
+ return []
88
+
89
+ email_list = []
90
+ for num in messages[0].split()[:MAX_EMAILS]: # Limit to MAX_EMAILS
91
+ _, msg_data = await loop.run_in_executor(None, lambda: mail.fetch(num, '(RFC822)'))
92
+ email_list.append(format_email_summary(msg_data))
93
+
94
+ return email_list
95
+ except Exception as e:
96
+ raise Exception(f"Error searching emails: {str(e)}")
97
+
98
+ async def get_email_content_async(mail: imaplib.IMAP4_SSL, email_id: str) -> dict:
99
+ """Asynchronously get full content of a specific email."""
100
+ loop = asyncio.get_event_loop()
101
+ try:
102
+ _, msg_data = await loop.run_in_executor(None, lambda: mail.fetch(email_id, '(RFC822)'))
103
+ return format_email_content(msg_data)
104
+ except Exception as e:
105
+ raise Exception(f"Error fetching email content: {str(e)}")
106
+
107
+ async def count_emails_async(mail: imaplib.IMAP4_SSL, search_criteria: str) -> int:
108
+ """Asynchronously count emails matching the search criteria."""
109
+ loop = asyncio.get_event_loop()
110
+ try:
111
+ _, messages = await loop.run_in_executor(None, lambda: mail.search(None, search_criteria))
112
+ return len(messages[0].split()) if messages[0] else 0
113
+ except Exception as e:
114
+ raise Exception(f"Error counting emails: {str(e)}")
115
+
116
+ async def send_email_async(
117
+ to_addresses: list[str],
118
+ subject: str,
119
+ content: str,
120
+ cc_addresses: list[str] | None = None
121
+ ) -> None:
122
+ """Asynchronously send an email."""
123
+ try:
124
+ # Create message
125
+ msg = MIMEMultipart()
126
+ msg['From'] = EMAIL_CONFIG["email"]
127
+ msg['To'] = ', '.join(to_addresses)
128
+ if cc_addresses:
129
+ msg['Cc'] = ', '.join(cc_addresses)
130
+ msg['Subject'] = subject
131
+
132
+ # Add body
133
+ msg.attach(MIMEText(content, 'plain', 'utf-8'))
134
+
135
+ # Connect to SMTP server and send email
136
+ def send_sync():
137
+ with smtplib.SMTP(EMAIL_CONFIG["smtp_server"], EMAIL_CONFIG["smtp_port"]) as server:
138
+ server.set_debuglevel(1) # Enable debug output
139
+ logging.debug(f"Connecting to {EMAIL_CONFIG['smtp_server']}:{EMAIL_CONFIG['smtp_port']}")
140
+
141
+ # Start TLS
142
+ logging.debug("Starting TLS")
143
+ server.starttls()
144
+
145
+ # Login
146
+ logging.debug(f"Logging in as {EMAIL_CONFIG['email']}")
147
+ server.login(EMAIL_CONFIG["email"], EMAIL_CONFIG["password"])
148
+
149
+ # Send email
150
+ all_recipients = to_addresses + (cc_addresses or [])
151
+ logging.debug(f"Sending email to: {all_recipients}")
152
+ result = server.send_message(msg, EMAIL_CONFIG["email"], all_recipients)
153
+
154
+ if result:
155
+ # send_message returns a dict of failed recipients
156
+ raise Exception(f"Failed to send to some recipients: {result}")
157
+
158
+ logging.debug("Email sent successfully")
159
+
160
+ # Run the synchronous send function in the executor
161
+ loop = asyncio.get_event_loop()
162
+ await loop.run_in_executor(None, send_sync)
163
+
164
+ except Exception as e:
165
+ logging.error(f"Error in send_email_async: {str(e)}")
166
+ raise
167
+
168
+ @server.list_tools()
169
+ async def handle_list_tools() -> list[types.Tool]:
170
+ """
171
+ List available tools.
172
+ Each tool specifies its arguments using JSON Schema validation.
173
+ """
174
+ return [
175
+ types.Tool(
176
+ name="search-emails",
177
+ description="Search emails within a date range and/or with specific keywords",
178
+ inputSchema={
179
+ "type": "object",
180
+ "properties": {
181
+ "start_date": {
182
+ "type": "string",
183
+ "description": "Start date in YYYY-MM-DD format (optional)",
184
+ },
185
+ "end_date": {
186
+ "type": "string",
187
+ "description": "End date in YYYY-MM-DD format (optional)",
188
+ },
189
+ "keyword": {
190
+ "type": "string",
191
+ "description": "Keyword to search in email subject and body (optional)",
192
+ },
193
+ "folder": {
194
+ "type": "string",
195
+ "description": "Folder to search in ('inbox' or 'sent', defaults to 'inbox')",
196
+ "enum": ["inbox", "sent"],
197
+ },
198
+ },
199
+ },
200
+ ),
201
+ types.Tool(
202
+ name="get-email-content",
203
+ description="Get the full content of a specific email by its ID",
204
+ inputSchema={
205
+ "type": "object",
206
+ "properties": {
207
+ "email_id": {
208
+ "type": "string",
209
+ "description": "The ID of the email to retrieve",
210
+ },
211
+ },
212
+ "required": ["email_id"],
213
+ },
214
+ ),
215
+ types.Tool(
216
+ name="count-daily-emails",
217
+ description="Count emails received for each day in a date range",
218
+ inputSchema={
219
+ "type": "object",
220
+ "properties": {
221
+ "start_date": {
222
+ "type": "string",
223
+ "description": "Start date in YYYY-MM-DD format",
224
+ },
225
+ "end_date": {
226
+ "type": "string",
227
+ "description": "End date in YYYY-MM-DD format",
228
+ },
229
+ },
230
+ "required": ["start_date", "end_date"],
231
+ },
232
+ ),
233
+ types.Tool(
234
+ name="send-email",
235
+ description="CONFIRMATION STEP: Actually send the email after user confirms the details. Before calling this, first show the email details to the user for confirmation. Required fields: recipients (to), subject, and content. Optional: CC recipients.",
236
+ inputSchema={
237
+ "type": "object",
238
+ "properties": {
239
+ "to": {
240
+ "type": "array",
241
+ "items": {"type": "string"},
242
+ "description": "List of recipient email addresses (confirmed)",
243
+ },
244
+ "subject": {
245
+ "type": "string",
246
+ "description": "Confirmed email subject",
247
+ },
248
+ "content": {
249
+ "type": "string",
250
+ "description": "Confirmed email content",
251
+ },
252
+ "cc": {
253
+ "type": "array",
254
+ "items": {"type": "string"},
255
+ "description": "List of CC recipient email addresses (optional, confirmed)",
256
+ },
257
+ },
258
+ "required": ["to", "subject", "content"],
259
+ },
260
+ ),
261
+ ]
262
+
263
+ @server.call_tool()
264
+ async def handle_call_tool(
265
+ name: str, arguments: dict | None
266
+ ) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]:
267
+ """
268
+ Handle tool execution requests.
269
+ Tools can search emails and return results.
270
+ """
271
+ if not arguments:
272
+ arguments = {}
273
+
274
+ try:
275
+ if name == "send-email":
276
+ to_addresses = arguments.get("to", [])
277
+ subject = arguments.get("subject", "")
278
+ content = arguments.get("content", "")
279
+ cc_addresses = arguments.get("cc", [])
280
+
281
+ if not to_addresses:
282
+ return [types.TextContent(
283
+ type="text",
284
+ text="At least one recipient email address is required."
285
+ )]
286
+
287
+ try:
288
+ logging.info("Attempting to send email")
289
+ logging.info(f"To: {to_addresses}")
290
+ logging.info(f"Subject: {subject}")
291
+ logging.info(f"CC: {cc_addresses}")
292
+
293
+ async with asyncio.timeout(SEARCH_TIMEOUT):
294
+ await send_email_async(to_addresses, subject, content, cc_addresses)
295
+ return [types.TextContent(
296
+ type="text",
297
+ text="Email sent successfully! Check email_client.log for detailed logs."
298
+ )]
299
+ except asyncio.TimeoutError:
300
+ logging.error("Operation timed out while sending email")
301
+ return [types.TextContent(
302
+ type="text",
303
+ text="Operation timed out while sending email."
304
+ )]
305
+ except Exception as e:
306
+ error_msg = str(e)
307
+ logging.error(f"Failed to send email: {error_msg}")
308
+ return [types.TextContent(
309
+ type="text",
310
+ text=f"Failed to send email: {error_msg}\n\nPlease check:\n1. Email and password are correct in .env\n2. SMTP settings are correct\n3. Less secure app access is enabled (for Gmail)\n4. Using App Password if 2FA is enabled"
311
+ )]
312
+
313
+ # Connect to IMAP server using predefined credentials
314
+ mail = imaplib.IMAP4_SSL(EMAIL_CONFIG["imap_server"])
315
+ mail.login(EMAIL_CONFIG["email"], EMAIL_CONFIG["password"])
316
+
317
+ if name == "search-emails":
318
+ # 选择文件夹
319
+ folder = arguments.get("folder", "inbox") # 默认选择收件箱
320
+ if folder == "sent":
321
+ mail.select('"[Gmail]/Sent Mail"') # 对于 Gmail
322
+ else:
323
+ mail.select("inbox")
324
+
325
+ # Get optional parameters
326
+ start_date = arguments.get("start_date")
327
+ end_date = arguments.get("end_date")
328
+ keyword = arguments.get("keyword")
329
+
330
+ # If no dates provided, default to last 7 days
331
+ if not start_date:
332
+ start_date = datetime.now() - timedelta(days=7)
333
+ start_date = start_date.strftime("%d-%b-%Y")
334
+ else:
335
+ start_date = datetime.strptime(start_date, "%Y-%m-%d").strftime("%d-%b-%Y")
336
+
337
+ if not end_date:
338
+ end_date = datetime.now().strftime("%d-%b-%Y")
339
+ else:
340
+ # Convert end_date to datetime object once
341
+ end_date_obj = datetime.strptime(end_date, "%Y-%m-%d")
342
+ end_date = end_date_obj.strftime("%d-%b-%Y")
343
+
344
+ # Build search criteria
345
+ if start_date == end_date:
346
+ # If searching for a single day
347
+ search_criteria = f'ON "{start_date}"'
348
+ else:
349
+ # Calculate next day using the already converted end_date_obj
350
+ next_day = (end_date_obj + timedelta(days=1)).strftime("%d-%b-%Y")
351
+ search_criteria = f'SINCE "{start_date}" BEFORE "{next_day}"'
352
+
353
+ if keyword:
354
+ # Fix: Properly combine keyword search with date criteria
355
+ keyword_criteria = f'(OR SUBJECT "{keyword}" BODY "{keyword}")'
356
+ search_criteria = f'({keyword_criteria} {search_criteria})'
357
+
358
+ logging.debug(f"Search criteria: {search_criteria}") # Add debug logging
359
+
360
+ try:
361
+ async with asyncio.timeout(SEARCH_TIMEOUT):
362
+ email_list = await search_emails_async(mail, search_criteria)
363
+
364
+ if not email_list:
365
+ return [types.TextContent(
366
+ type="text",
367
+ text="No emails found matching the criteria."
368
+ )]
369
+
370
+ # Format the results as a table
371
+ result_text = "Found emails:\n\n"
372
+ result_text += "ID | From | Date | Subject\n"
373
+ result_text += "-" * 80 + "\n"
374
+
375
+ for email in email_list:
376
+ result_text += f"{email['id']} | {email['from']} | {email['date']} | {email['subject']}\n"
377
+
378
+ result_text += "\nUse get-email-content with an email ID to view the full content of a specific email."
379
+
380
+ return [types.TextContent(
381
+ type="text",
382
+ text=result_text
383
+ )]
384
+
385
+ except asyncio.TimeoutError:
386
+ return [types.TextContent(
387
+ type="text",
388
+ text="Search operation timed out. Please try with a more specific search criteria."
389
+ )]
390
+
391
+ elif name == "get-email-content":
392
+ email_id = arguments.get("email_id")
393
+ if not email_id:
394
+ return [types.TextContent(
395
+ type="text",
396
+ text="Email ID is required."
397
+ )]
398
+
399
+ try:
400
+ async with asyncio.timeout(SEARCH_TIMEOUT):
401
+ email_content = await get_email_content_async(mail, email_id)
402
+
403
+ result_text = (
404
+ f"From: {email_content['from']}\n"
405
+ f"To: {email_content['to']}\n"
406
+ f"Date: {email_content['date']}\n"
407
+ f"Subject: {email_content['subject']}\n"
408
+ f"\nContent:\n{email_content['content']}"
409
+ )
410
+
411
+ return [types.TextContent(
412
+ type="text",
413
+ text=result_text
414
+ )]
415
+
416
+ except asyncio.TimeoutError:
417
+ return [types.TextContent(
418
+ type="text",
419
+ text="Operation timed out while fetching email content."
420
+ )]
421
+
422
+ elif name == "count-daily-emails":
423
+ start_date = datetime.strptime(arguments["start_date"], "%Y-%m-%d")
424
+ end_date = datetime.strptime(arguments["end_date"], "%Y-%m-%d")
425
+
426
+ result_text = "Daily email counts:\n\n"
427
+ result_text += "Date | Count\n"
428
+ result_text += "-" * 30 + "\n"
429
+
430
+ current_date = start_date
431
+ while current_date <= end_date:
432
+ date_str = current_date.strftime("%d-%b-%Y")
433
+ search_criteria = f'(ON "{date_str}")'
434
+
435
+ try:
436
+ async with asyncio.timeout(SEARCH_TIMEOUT):
437
+ count = await count_emails_async(mail, search_criteria)
438
+ result_text += f"{current_date.strftime('%Y-%m-%d')} | {count}\n"
439
+ except asyncio.TimeoutError:
440
+ result_text += f"{current_date.strftime('%Y-%m-%d')} | Timeout\n"
441
+
442
+ current_date += timedelta(days=1)
443
+
444
+ return [types.TextContent(
445
+ type="text",
446
+ text=result_text
447
+ )]
448
+
449
+ else:
450
+ raise ValueError(f"Unknown tool: {name}")
451
+
452
+ except Exception as e:
453
+ return [types.TextContent(
454
+ type="text",
455
+ text=f"Error: {str(e)}"
456
+ )]
457
+ finally:
458
+ try:
459
+ mail.close()
460
+ mail.logout()
461
+ except:
462
+ pass
463
+
464
+ async def main():
465
+ # Run the server using stdin/stdout streams
466
+ async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
467
+ await server.run(
468
+ read_stream,
469
+ write_stream,
470
+ InitializationOptions(
471
+ server_name="email",
472
+ server_version="0.1.0",
473
+ capabilities=server.get_capabilities(
474
+ notification_options=NotificationOptions(),
475
+ experimental_capabilities={},
476
+ ),
477
+ ),
478
+ )
479
+
480
+ if __name__ == "__main__":
481
+ asyncio.run(main())
482
+
@@ -0,0 +1,14 @@
1
+ Metadata-Version: 2.4
2
+ Name: mseep-email_client
3
+ Version: 0.1.1
4
+ Summary: Email search and management tool using MCP protocol
5
+ Author-email: mseep <support@skydeck.ai>
6
+ Requires-Python: >=3.12
7
+ Description-Content-Type: text/plain
8
+ License-File: LICENSE
9
+ Requires-Dist: httpx>=0.28.1
10
+ Requires-Dist: mcp>=1.1.2
11
+ Requires-Dist: python-dotenv>=1.0.0
12
+ Dynamic: license-file
13
+
14
+ Package managed by MseeP.ai
@@ -0,0 +1,8 @@
1
+ email_client/__init__.py,sha256=VAtgRH_6BN5yJ0H_PKgtEIOvEKLNyObj_Dflvzyxluw,153
2
+ email_client/server.py,sha256=W0jS5pSMSypu73dN-ficp3JlPY-2dvvYNhinII6dous,18951
3
+ mseep_email_client-0.1.1.dist-info/licenses/LICENSE,sha256=m_oniMdXFxy0QcK0EItJvbgyPCfltZH43P2YF2PPel8,1067
4
+ mseep_email_client-0.1.1.dist-info/METADATA,sha256=-rM8HD8XEwZ1PryLv746dp8be5hVUyRfSgclZ_LtKHw,389
5
+ mseep_email_client-0.1.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
6
+ mseep_email_client-0.1.1.dist-info/entry_points.txt,sha256=Wrdh3ByhKYjXV3U7gGwNSwgVq0PJGvwSYxKzJBALmVU,51
7
+ mseep_email_client-0.1.1.dist-info/top_level.txt,sha256=CVGSEJ0RIrALXhHil0q3wE1HoTZdqf1DM1PRXhgQTxc,13
8
+ mseep_email_client-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ email-client = email_client:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Zilong Xue
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ email_client