added email_client helper.
TerminallyLazy committed
Oct 16, 2025 at 18:16 UTC
3f6e55b65c949db1c9ab440e68c4d4f7f7446365
2 files changed
+591
-1
python/helpers/email_client.py
new
+587
@@ -0,0 +1,587 @@
1
+import asyncio
2
+import email
3
+import os
4
+import re
5
+import uuid
6
+from dataclasses import dataclass
7
+from email.header import decode_header
8
+from email.message import Message as EmailMessage
9
+from fnmatch import fnmatch
10
+from typing import Any, Dict, List, Optional, Tuple
11
+
12
+import html2text
13
+from bs4 import BeautifulSoup
14
+from imapclient import IMAPClient
15
+
16
+from python.helpers import files
17
+from python.helpers.errors import RepairableException, format_error
18
+from python.helpers.print_style import PrintStyle
19
+
20
+
21
+@dataclass
22
+class Message:
23
+ """Email message representation with sender, subject, body, and attachments."""
24
+ sender: str
25
+ subject: str
26
+ body: str
27
+ attachments: List[str]
28
+
29
+
30
+class EmailClient:
31
+ """
32
+ Async email client for reading messages from IMAP and Exchange servers.
33
+
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ account_type: str = "imap",
39
+ server: str = "",
40
+ port: int = 993,
41
+ username: str = "",
42
+ password: str = "",
43
+ options: Optional[Dict[str, Any]] = None,
44
+ ):
45
+ """
46
+ Initialize email client with connection parameters.
47
+
48
+ Args:
49
+ account_type: Type of account - "imap" or "exchange"
50
+ server: Mail server address (e.g., "imap.gmail.com")
51
+ port: Server port (default 993 for IMAP SSL)
52
+ username: Email account username
53
+ password: Email account password
54
+ options: Optional configuration dict with keys:
55
+ - ssl: Use SSL/TLS (default: True)
56
+ - timeout: Connection timeout in seconds (default: 30)
57
+ """
58
+ self.account_type = account_type.lower()
59
+ self.server = server
60
+ self.port = port
61
+ self.username = username
62
+ self.password = password
63
+ self.options = options or {}
64
+
65
+ # Default options
66
+ self.ssl = self.options.get("ssl", True)
67
+ self.timeout = self.options.get("timeout", 30)
68
+
69
+ self.client: Optional[IMAPClient] = None
70
+ self.exchange_account = None
71
+
72
+ async def connect(self) -> None:
73
+ """Establish connection to email server."""
74
+ try:
75
+ if self.account_type == "imap":
76
+ await self._connect_imap()
77
+ elif self.account_type == "exchange":
78
+ await self._connect_exchange()
79
+ else:
80
+ raise RepairableException(
81
+ f"Unsupported account type: {self.account_type}. "
82
+ "Supported types: 'imap', 'exchange'"
83
+ )
84
+ except Exception as e:
85
+ err = format_error(e)
86
+ PrintStyle.error(f"Failed to connect to email server: {err}")
87
+ raise RepairableException(f"Email connection failed: {err}") from e
88
+
89
+ async def _connect_imap(self) -> None:
90
+ """Establish IMAP connection."""
91
+ loop = asyncio.get_event_loop()
92
+
93
+ def _sync_connect():
94
+ client = IMAPClient(self.server, port=self.port, ssl=self.ssl, timeout=self.timeout)
95
+ # Increase line length limit to handle large emails (default is 10000)
96
+ # This fixes "line too long" errors for emails with large headers or embedded content
97
+ client._imap._maxline = 100000
98
+ client.login(self.username, self.password)
99
+ return client
100
+
101
+ self.client = await loop.run_in_executor(None, _sync_connect)
102
+ PrintStyle.standard(f"Connected to IMAP server: {self.server}")
103
+
104
+ async def _connect_exchange(self) -> None:
105
+ """Establish Exchange connection."""
106
+ try:
107
+ from exchangelib import Account, Configuration, Credentials, DELEGATE
108
+
109
+ loop = asyncio.get_event_loop()
110
+
111
+ def _sync_connect():
112
+ creds = Credentials(username=self.username, password=self.password)
113
+ config = Configuration(server=self.server, credentials=creds)
114
+ return Account(
115
+ primary_smtp_address=self.username,
116
+ config=config,
117
+ autodiscover=False,
118
+ access_type=DELEGATE
119
+ )
120
+
121
+ self.exchange_account = await loop.run_in_executor(None, _sync_connect)
122
+ PrintStyle.standard(f"Connected to Exchange server: {self.server}")
123
+ except ImportError as e:
124
+ raise RepairableException(
125
+ "exchangelib not installed. Install with: pip install exchangelib>=5.4.3"
126
+ ) from e
127
+
128
+ async def disconnect(self) -> None:
129
+ """Clean up connection."""
130
+ try:
131
+ if self.client:
132
+ loop = asyncio.get_event_loop()
133
+ await loop.run_in_executor(None, self.client.logout)
134
+ self.client = None
135
+ PrintStyle.standard("Disconnected from IMAP server")
136
+ elif self.exchange_account:
137
+ self.exchange_account = None
138
+ PrintStyle.standard("Disconnected from Exchange server")
139
+ except Exception as e:
140
+ PrintStyle.error(f"Error during disconnect: {format_error(e)}")
141
+
142
+ async def read_messages(
143
+ self,
144
+ download_folder: str,
145
+ filter: Optional[Dict[str, Any]] = None,
146
+ ) -> List[Message]:
147
+ """
148
+ Read messages based on filter criteria.
149
+
150
+ Args:
151
+ download_folder: Folder to save attachments (relative to /a0/)
152
+ filter: Filter criteria dict with keys:
153
+ - unread: Boolean to filter unread messages (default: True)
154
+ - sender: Sender pattern with wildcards (e.g., "*@company.com")
155
+ - subject: Subject pattern with wildcards (e.g., "*invoice*")
156
+ - since_date: Optional datetime for date filtering
157
+
158
+ Returns:
159
+ List of Message objects with attachments saved to download_folder
160
+ """
161
+ filter = filter or {}
162
+
163
+ if self.account_type == "imap":
164
+ return await self._fetch_imap_messages(download_folder, filter)
165
+ elif self.account_type == "exchange":
166
+ return await self._fetch_exchange_messages(download_folder, filter)
167
+ else:
168
+ raise RepairableException(f"Unsupported account type: {self.account_type}")
169
+
170
+ async def _fetch_imap_messages(
171
+ self,
172
+ download_folder: str,
173
+ filter: Dict[str, Any],
174
+ ) -> List[Message]:
175
+ """Fetch messages from IMAP server."""
176
+ if not self.client:
177
+ raise RepairableException("IMAP client not connected. Call connect() first.")
178
+
179
+ loop = asyncio.get_event_loop()
180
+ messages: List[Message] = []
181
+
182
+ def _sync_fetch():
183
+ # Select inbox
184
+ self.client.select_folder("INBOX")
185
+
186
+ # Build search criteria
187
+ search_criteria = []
188
+ if filter.get("unread", True):
189
+ search_criteria.append("UNSEEN")
190
+
191
+ if filter.get("since_date"):
192
+ since_date = filter["since_date"]
193
+ search_criteria.append(["SINCE", since_date])
194
+
195
+ # Search for messages
196
+ if not search_criteria:
197
+ search_criteria = ["ALL"]
198
+
199
+ message_ids = self.client.search(search_criteria)
200
+ return message_ids
201
+
202
+ message_ids = await loop.run_in_executor(None, _sync_fetch)
203
+
204
+ if not message_ids:
205
+ PrintStyle.hint("No messages found matching criteria")
206
+ return messages
207
+
208
+ PrintStyle.standard(f"Found {len(message_ids)} messages")
209
+
210
+ # Fetch and process messages
211
+ for msg_id in message_ids:
212
+ try:
213
+ msg = await self._fetch_and_parse_imap_message(msg_id, download_folder, filter)
214
+ if msg:
215
+ messages.append(msg)
216
+ except Exception as e:
217
+ PrintStyle.error(f"Error processing message {msg_id}: {format_error(e)}")
218
+ continue
219
+
220
+ return messages
221
+
222
+ async def _fetch_and_parse_imap_message(
223
+ self,
224
+ msg_id: int,
225
+ download_folder: str,
226
+ filter: Dict[str, Any],
227
+ ) -> Optional[Message]:
228
+ """Fetch and parse a single IMAP message with retry logic for large messages."""
229
+ loop = asyncio.get_event_loop()
230
+
231
+ def _sync_fetch():
232
+ try:
233
+ # Try standard RFC822 fetch first
234
+ return self.client.fetch([msg_id], ["RFC822"])[msg_id]
235
+ except Exception as e:
236
+ error_msg = str(e).lower()
237
+ # If "line too long" error, try fetching in parts
238
+ if "line too long" in error_msg or "fetch_failed" in error_msg:
239
+ PrintStyle.warning(f"Message {msg_id} too large for standard fetch, trying alternative method")
240
+ # Fetch headers and body separately to avoid line length issues
241
+ try:
242
+ envelope = self.client.fetch([msg_id], ["BODY.PEEK[]"])[msg_id]
243
+ return envelope
244
+ except Exception as e2:
245
+ PrintStyle.error(f"Alternative fetch also failed for message {msg_id}: {format_error(e2)}")
246
+ raise
247
+ raise
248
+
249
+ try:
250
+ raw_msg = await loop.run_in_executor(None, _sync_fetch)
251
+
252
+ # Extract email data from response
253
+ if b"RFC822" in raw_msg:
254
+ email_data = raw_msg[b"RFC822"]
255
+ elif b"BODY[]" in raw_msg:
256
+ email_data = raw_msg[b"BODY[]"]
257
+ else:
258
+ PrintStyle.error(f"Unexpected response format for message {msg_id}")
259
+ return None
260
+
261
+ email_msg = email.message_from_bytes(email_data)
262
+
263
+ # Apply sender filter
264
+ sender = self._decode_header(email_msg.get("From", ""))
265
+ if filter.get("sender") and not fnmatch(sender, filter["sender"]):
266
+ return None
267
+
268
+ # Apply subject filter
269
+ subject = self._decode_header(email_msg.get("Subject", ""))
270
+ if filter.get("subject") and not fnmatch(subject, filter["subject"]):
271
+ return None
272
+
273
+ # Parse message
274
+ return await self._parse_message(email_msg, download_folder)
275
+
276
+ except Exception as e:
277
+ PrintStyle.error(f"Failed to fetch/parse message {msg_id}: {format_error(e)}")
278
+ return None
279
+
280
+ async def _fetch_exchange_messages(
281
+ self,
282
+ download_folder: str,
283
+ filter: Dict[str, Any],
284
+ ) -> List[Message]:
285
+ """Fetch messages from Exchange server."""
286
+ if not self.exchange_account:
287
+ raise RepairableException("Exchange account not connected. Call connect() first.")
288
+
289
+ from exchangelib import Q
290
+
291
+ loop = asyncio.get_event_loop()
292
+ messages: List[Message] = []
293
+
294
+ def _sync_fetch():
295
+ # Build query
296
+ query = None
297
+ if filter.get("unread", True):
298
+ query = Q(is_read=False)
299
+
300
+ if filter.get("sender"):
301
+ sender_pattern = filter["sender"].replace("*", "")
302
+ sender_q = Q(sender__contains=sender_pattern)
303
+ query = query & sender_q if query else sender_q
304
+
305
+ if filter.get("subject"):
306
+ subject_pattern = filter["subject"].replace("*", "")
307
+ subject_q = Q(subject__contains=subject_pattern)
308
+ query = query & subject_q if query else subject_q
309
+
310
+ # Fetch messages from inbox
311
+ inbox = self.exchange_account.inbox
312
+ items = inbox.filter(query) if query else inbox.all()
313
+ return list(items)
314
+
315
+ exchange_messages = await loop.run_in_executor(None, _sync_fetch)
316
+
317
+ PrintStyle.standard(f"Found {len(exchange_messages)} Exchange messages")
318
+
319
+ # Process messages
320
+ for ex_msg in exchange_messages:
321
+ try:
322
+ msg = await self._parse_exchange_message(ex_msg, download_folder)
323
+ if msg:
324
+ messages.append(msg)
325
+ except Exception as e:
326
+ PrintStyle.error(f"Error processing Exchange message: {format_error(e)}")
327
+ continue
328
+
329
+ return messages
330
+
331
+ async def _parse_exchange_message(
332
+ self,
333
+ ex_msg,
334
+ download_folder: str,
335
+ ) -> Message:
336
+ """Parse an Exchange message."""
337
+ loop = asyncio.get_event_loop()
338
+
339
+ def _get_body():
340
+ return str(ex_msg.text_body or ex_msg.body or "")
341
+
342
+ body = await loop.run_in_executor(None, _get_body)
343
+
344
+ # Process HTML if present
345
+ if ex_msg.body and str(ex_msg.body).strip().startswith("<"):
346
+ body = self._html_to_text(str(ex_msg.body))
347
+
348
+ # Save attachments
349
+ attachment_paths = []
350
+ if ex_msg.attachments:
351
+ for attachment in ex_msg.attachments:
352
+ if hasattr(attachment, "content"):
353
+ path = await self._save_attachment_bytes(
354
+ attachment.name,
355
+ attachment.content,
356
+ download_folder
357
+ )
358
+ attachment_paths.append(path)
359
+
360
+ return Message(
361
+ sender=str(ex_msg.sender.email_address) if ex_msg.sender else "",
362
+ subject=str(ex_msg.subject or ""),
363
+ body=body,
364
+ attachments=attachment_paths
365
+ )
366
+
367
+ async def _parse_message(
368
+ self,
369
+ email_msg: EmailMessage,
370
+ download_folder: str,
371
+ ) -> Message:
372
+ """
373
+ Parse email message and extract content with inline attachments.
374
+
375
+ Processes multipart messages, converts HTML to text, and maintains
376
+ positional context for inline attachments.
377
+ """
378
+ sender = self._decode_header(email_msg.get("From", ""))
379
+ subject = self._decode_header(email_msg.get("Subject", ""))
380
+
381
+ # Extract body and attachments
382
+ body = ""
383
+ attachment_paths: List[str] = []
384
+ cid_map: Dict[str, str] = {} # Map Content-ID to file paths
385
+ body_parts: List[str] = [] # Track parts in order
386
+
387
+ if email_msg.is_multipart():
388
+ # Process parts in order to maintain attachment positions
389
+ for part in email_msg.walk():
390
+ content_type = part.get_content_type()
391
+ content_disposition = str(part.get("Content-Disposition", ""))
392
+
393
+ # Skip multipart containers
394
+ if part.get_content_maintype() == "multipart":
395
+ continue
396
+
397
+ # Handle attachments
398
+ if "attachment" in content_disposition or part.get("Content-ID"):
399
+ filename = part.get_filename()
400
+ if filename:
401
+ filename = self._decode_header(filename)
402
+ content = part.get_payload(decode=True)
403
+ if content:
404
+ path = await self._save_attachment_bytes(
405
+ filename, content, download_folder
406
+ )
407
+ attachment_paths.append(path)
408
+
409
+ # Map Content-ID for inline images
410
+ cid = part.get("Content-ID")
411
+ if cid:
412
+ cid = cid.strip("<>")
413
+ cid_map[cid] = path
414
+
415
+ # Add positional marker for non-cid attachments
416
+ # (cid attachments are positioned via HTML references)
417
+ if not cid and body_parts:
418
+ body_parts.append(f"\n[file://{path}]\n")
419
+
420
+ # Handle body text
421
+ elif content_type == "text/plain":
422
+ if not body: # Use first text/plain as primary body
423
+ charset = part.get_content_charset() or "utf-8"
424
+ body = part.get_payload(decode=True).decode(charset, errors="ignore")
425
+ body_parts.append(body)
426
+
427
+ elif content_type == "text/html":
428
+ if not body: # Use first text/html as primary body if no text/plain
429
+ charset = part.get_content_charset() or "utf-8"
430
+ html_content = part.get_payload(decode=True).decode(charset, errors="ignore")
431
+ body = self._html_to_text(html_content, cid_map)
432
+ body_parts.append(body)
433
+
434
+ # Combine body parts if we built them up
435
+ if len(body_parts) > 1:
436
+ body = "".join(body_parts)
437
+ else:
438
+ # Single part message
439
+ content_type = email_msg.get_content_type()
440
+ charset = email_msg.get_content_charset() or "utf-8"
441
+ content = email_msg.get_payload(decode=True)
442
+ if content:
443
+ if content_type == "text/html":
444
+ body = self._html_to_text(content.decode(charset, errors="ignore"), cid_map)
445
+ else:
446
+ body = content.decode(charset, errors="ignore")
447
+
448
+ return Message(
449
+ sender=sender,
450
+ subject=subject,
451
+ body=body,
452
+ attachments=attachment_paths
453
+ )
454
+
455
+ def _html_to_text(self, html_content: str, cid_map: Optional[Dict[str, str]] = None) -> str:
456
+ """
457
+ Convert HTML to plain text with inline attachment references.
458
+
459
+ Replaces inline images with [file:///a0/...] markers to maintain
460
+ positional context.
461
+ """
462
+ cid_map = cid_map or {}
463
+
464
+ # Replace cid: references with file paths before conversion
465
+ if cid_map:
466
+ soup = BeautifulSoup(html_content, "html.parser")
467
+ for img in soup.find_all("img"):
468
+ src = img.get("src", "")
469
+ if src.startswith("cid:"):
470
+ cid = src[4:] # Remove "cid:" prefix
471
+ if cid in cid_map:
472
+ # Replace with file path marker
473
+ file_marker = f"[file://{cid_map[cid]}]"
474
+ img.replace_with(soup.new_string(file_marker))
475
+ html_content = str(soup)
476
+
477
+ # Convert HTML to text
478
+ h = html2text.HTML2Text()
479
+ h.ignore_links = False
480
+ h.ignore_images = False
481
+ h.ignore_emphasis = False
482
+ h.body_width = 0 # Don't wrap lines
483
+
484
+ text = h.handle(html_content)
485
+
486
+ # Clean up extra whitespace
487
+ text = re.sub(r"\n{3,}", "\n\n", text) # Max 2 consecutive newlines
488
+ text = text.strip()
489
+
490
+ return text
491
+
492
+ async def _save_attachment_bytes(
493
+ self,
494
+ filename: str,
495
+ content: bytes,
496
+ download_folder: str,
497
+ ) -> str:
498
+ """
499
+ Save attachment to disk and return absolute path.
500
+
501
+ Uses Agent Zero's file helpers for path management.
502
+ """
503
+ # Sanitize filename
504
+ filename = files.safe_file_name(filename)
505
+
506
+ # Generate unique filename if needed
507
+ unique_id = uuid.uuid4().hex[:8]
508
+ name, ext = os.path.splitext(filename)
509
+ unique_filename = f"{name}_{unique_id}{ext}"
510
+
511
+ # Build relative path and save
512
+ relative_path = os.path.join(download_folder, unique_filename)
513
+ files.write_file_bin(relative_path, content)
514
+
515
+ # Return absolute path
516
+ abs_path = files.get_abs_path(relative_path)
517
+ return abs_path
518
+
519
+ def _decode_header(self, header: str) -> str:
520
+ """Decode email header handling various encodings."""
521
+ if not header:
522
+ return ""
523
+
524
+ decoded_parts = []
525
+ for part, encoding in decode_header(header):
526
+ if isinstance(part, bytes):
527
+ decoded_parts.append(part.decode(encoding or "utf-8", errors="ignore"))
528
+ else:
529
+ decoded_parts.append(str(part))
530
+
531
+ return " ".join(decoded_parts)
532
+
533
+
534
+async def read_messages(
535
+ account_type: str = "imap",
536
+ server: str = "",
537
+ port: int = 993,
538
+ username: str = "",
539
+ password: str = "",
540
+ download_folder: str = "tmp/email",
541
+ options: Optional[Dict[str, Any]] = None,
542
+ filter: Optional[Dict[str, Any]] = None,
543
+) -> List[Message]:
544
+ """
545
+ Convenience wrapper for reading email messages.
546
+
547
+ Automatically handles connection and disconnection.
548
+
549
+ Args:
550
+ account_type: "imap" or "exchange"
551
+ server: Mail server address
552
+ port: Server port (default 993 for IMAP SSL)
553
+ username: Email username
554
+ password: Email password
555
+ download_folder: Folder to save attachments (relative to /a0/)
556
+ options: Optional configuration dict
557
+ filter: Filter criteria dict
558
+
559
+ Returns:
560
+ List of Message objects
561
+
562
+ Example:
563
+ from python.helpers.email_client import read_messages
564
+ messages = await read_messages(
565
+ server="imap.gmail.com",
566
+ port=993,
567
+ username=secrets.get("EMAIL_USER"),
568
+ password=secrets.get("EMAIL_PASSWORD"),
569
+ download_folder="tmp/email/inbox",
570
+ filter={"unread": True, "sender": "*@company.com"}
571
+ )
572
+ """
573
+ client = EmailClient(
574
+ account_type=account_type,
575
+ server=server,
576
+ port=port,
577
+ username=username,
578
+ password=password,
579
+ options=options,
580
+ )
581
+
582
+ try:
583
+ await client.connect()
584
+ messages = await client.read_messages(download_folder, filter)
585
+ return messages
586
+ finally:
587
+ await client.disconnect()
requirements.txt
+4
-1
@@ -4,7 +4,7 @@ browser-use==0.5.11
4
docker==7.1.0
5
duckduckgo-search==6.1.12
6
faiss-cpu==1.11.0
7
-fastmcp==2.3.4
7
+fastmcp==2.12.4
8
fasta2a==0.5.0
9
flask[async]==3.0.3
10
flask-basicauth==0.2.0
@@ -42,3 +42,6 @@ crontab==1.0.1
42
pathspec>=0.12.1
43
psutil>=7.0.0
44
soundfile==0.13.1
45
+imapclient>=3.0.1
46
+html2text>=2024.2.26
47
+beautifulsoup4>=4.12.3