Coverage for integrations / channels / extensions / email_adapter.py: 22.6%
469 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
1"""
2Email Channel Adapter
4Implements email messaging via IMAP/SMTP.
5Based on SantaClaw extension patterns for email.
7Features:
8- IMAP for receiving emails
9- SMTP for sending emails
10- Email threading support (In-Reply-To, References headers)
11- HTML and plain text support
12- Attachment handling
13- TLS/SSL support
14- Docker-compatible configuration (container networking)
15- Connection pooling
16- Idle push notifications (IMAP IDLE)
17"""
19from __future__ import annotations
21import asyncio
22import logging
23import os
24import email
25import email.utils
26import email.mime.text
27import email.mime.multipart
28import email.mime.base
29import email.mime.image
30import email.mime.audio
31from email.header import decode_header, make_header
32from email.utils import parseaddr, formataddr
33import imaplib
34import smtplib
35import ssl
36from typing import Optional, List, Dict, Any, Callable, Tuple
37from datetime import datetime
38from dataclasses import dataclass, field
39import re
40import hashlib
41import mimetypes
42import base64
43from pathlib import Path
45try:
46 import aioimaplib
47 HAS_AIOIMAP = True
48except ImportError:
49 HAS_AIOIMAP = False
51try:
52 import aiosmtplib
53 HAS_AIOSMTP = True
54except ImportError:
55 HAS_AIOSMTP = False
57from ..base import (
58 ChannelAdapter,
59 ChannelConfig,
60 ChannelStatus,
61 Message,
62 MessageType,
63 MediaAttachment,
64 SendResult,
65 ChannelConnectionError,
66 ChannelSendError,
67 ChannelRateLimitError,
68)
70logger = logging.getLogger(__name__)
73@dataclass
74class EmailConfig(ChannelConfig):
75 """Email-specific configuration."""
76 # IMAP settings
77 imap_host: str = ""
78 imap_port: int = 993
79 imap_use_ssl: bool = True
80 imap_username: str = ""
81 imap_password: str = ""
83 # SMTP settings
84 smtp_host: str = ""
85 smtp_port: int = 587
86 smtp_use_tls: bool = True
87 smtp_username: str = ""
88 smtp_password: str = ""
90 # Email settings
91 email_address: str = "" # From address
92 display_name: Optional[str] = None
93 reply_to: Optional[str] = None
94 default_subject: str = "Message from Agent"
96 # Folders
97 inbox_folder: str = "INBOX"
98 sent_folder: str = "Sent"
99 archive_folder: Optional[str] = None
101 # Behavior
102 poll_interval: int = 30 # Seconds between IMAP polls
103 use_idle: bool = True # Use IMAP IDLE if available
104 mark_as_read: bool = True
105 archive_after_reply: bool = False
106 html_emails: bool = True
107 max_attachment_size: int = 25 * 1024 * 1024 # 25MB
109 # Docker networking
110 # When running in Docker, use container hostnames
111 docker_mode: bool = False
112 docker_imap_host: Optional[str] = None # e.g., "mailserver"
113 docker_smtp_host: Optional[str] = None # e.g., "mailserver"
116@dataclass
117class EmailThread:
118 """Email thread tracking."""
119 thread_id: str
120 subject: str
121 participants: List[str]
122 message_ids: List[str] = field(default_factory=list)
123 last_message_id: Optional[str] = None
124 references: List[str] = field(default_factory=list)
127class EmailAdapter(ChannelAdapter):
128 """
129 Email messaging adapter using IMAP/SMTP.
131 Supports both sync and async operations. For best performance in
132 async environments, install aioimaplib and aiosmtplib.
134 Docker Configuration:
135 When running in Docker, set docker_mode=True and configure
136 docker_imap_host/docker_smtp_host to use container hostnames
137 for internal communication.
139 Usage:
140 config = EmailConfig(
141 imap_host="imap.gmail.com",
142 imap_username="bot@example.com",
143 imap_password="app-password",
144 smtp_host="smtp.gmail.com",
145 smtp_username="bot@example.com",
146 smtp_password="app-password",
147 email_address="bot@example.com",
148 )
149 adapter = EmailAdapter(config)
150 adapter.on_message(my_handler)
151 await adapter.start()
152 """
154 def __init__(self, config: EmailConfig):
155 super().__init__(config)
156 self.email_config: EmailConfig = config
157 self._imap: Optional[Any] = None
158 self._smtp: Optional[Any] = None
159 self._poll_task: Optional[asyncio.Task] = None
160 self._idle_task: Optional[asyncio.Task] = None
161 self._threads: Dict[str, EmailThread] = {}
162 self._seen_message_ids: set = set()
163 self._use_async = HAS_AIOIMAP and HAS_AIOSMTP
165 @property
166 def name(self) -> str:
167 return "email"
169 def _get_imap_host(self) -> str:
170 """Get IMAP host, considering Docker mode."""
171 if self.email_config.docker_mode and self.email_config.docker_imap_host:
172 return self.email_config.docker_imap_host
173 return self.email_config.imap_host
175 def _get_smtp_host(self) -> str:
176 """Get SMTP host, considering Docker mode."""
177 if self.email_config.docker_mode and self.email_config.docker_smtp_host:
178 return self.email_config.docker_smtp_host
179 return self.email_config.smtp_host
181 async def connect(self) -> bool:
182 """Initialize IMAP and SMTP connections."""
183 if not self.email_config.imap_host or not self.email_config.smtp_host:
184 logger.error("IMAP and SMTP hosts required")
185 return False
187 if not self.email_config.email_address:
188 logger.error("Email address required")
189 return False
191 try:
192 # Connect to IMAP
193 imap_connected = await self._connect_imap()
194 if not imap_connected:
195 logger.error("Failed to connect to IMAP server")
196 return False
198 # Test SMTP connection
199 smtp_ok = await self._test_smtp()
200 if not smtp_ok:
201 logger.error("Failed to connect to SMTP server")
202 return False
204 self.status = ChannelStatus.CONNECTED
205 logger.info(f"Email adapter connected as: {self.email_config.email_address}")
206 return True
208 except Exception as e:
209 logger.error(f"Failed to connect email: {e}")
210 self.status = ChannelStatus.ERROR
211 return False
213 async def _connect_imap(self) -> bool:
214 """Connect to IMAP server."""
215 try:
216 host = self._get_imap_host()
217 port = self.email_config.imap_port
219 if self._use_async:
220 # Async IMAP
221 if self.email_config.imap_use_ssl:
222 self._imap = aioimaplib.IMAP4_SSL(host, port)
223 else:
224 self._imap = aioimaplib.IMAP4(host, port)
226 await self._imap.wait_hello_from_server()
227 await self._imap.login(
228 self.email_config.imap_username,
229 self.email_config.imap_password
230 )
231 else:
232 # Sync IMAP (run in executor)
233 loop = asyncio.get_event_loop()
235 def connect_sync():
236 if self.email_config.imap_use_ssl:
237 context = ssl.create_default_context()
238 imap = imaplib.IMAP4_SSL(host, port, ssl_context=context)
239 else:
240 imap = imaplib.IMAP4(host, port)
242 imap.login(
243 self.email_config.imap_username,
244 self.email_config.imap_password
245 )
246 return imap
248 self._imap = await loop.run_in_executor(None, connect_sync)
250 return True
252 except Exception as e:
253 logger.error(f"IMAP connection error: {e}")
254 return False
256 async def _test_smtp(self) -> bool:
257 """Test SMTP connection."""
258 try:
259 host = self._get_smtp_host()
260 port = self.email_config.smtp_port
262 if self._use_async:
263 # Async SMTP
264 smtp = aiosmtplib.SMTP(
265 hostname=host,
266 port=port,
267 use_tls=not self.email_config.smtp_use_tls, # use_tls means implicit TLS
268 start_tls=self.email_config.smtp_use_tls,
269 )
270 await smtp.connect()
271 await smtp.login(
272 self.email_config.smtp_username,
273 self.email_config.smtp_password
274 )
275 await smtp.quit()
276 else:
277 # Sync SMTP
278 loop = asyncio.get_event_loop()
280 def test_sync():
281 if self.email_config.smtp_use_tls:
282 smtp = smtplib.SMTP(host, port)
283 smtp.starttls()
284 else:
285 context = ssl.create_default_context()
286 smtp = smtplib.SMTP_SSL(host, port, context=context)
288 smtp.login(
289 self.email_config.smtp_username,
290 self.email_config.smtp_password
291 )
292 smtp.quit()
294 await loop.run_in_executor(None, test_sync)
296 return True
298 except Exception as e:
299 logger.error(f"SMTP connection error: {e}")
300 return False
302 async def disconnect(self) -> None:
303 """Disconnect from email servers."""
304 # Cancel polling task
305 if self._poll_task:
306 self._poll_task.cancel()
307 try:
308 await self._poll_task
309 except asyncio.CancelledError:
310 pass
312 if self._idle_task:
313 self._idle_task.cancel()
314 try:
315 await self._idle_task
316 except asyncio.CancelledError:
317 pass
319 # Close IMAP
320 if self._imap:
321 try:
322 if self._use_async:
323 await self._imap.logout()
324 else:
325 loop = asyncio.get_event_loop()
326 await loop.run_in_executor(None, self._imap.logout)
327 except Exception:
328 pass
329 self._imap = None
331 self.status = ChannelStatus.DISCONNECTED
333 async def start(self) -> None:
334 """Start the email adapter and begin polling/IDLE."""
335 await super().start()
337 if self.status == ChannelStatus.CONNECTED:
338 # Start polling for new messages
339 self._poll_task = asyncio.create_task(self._poll_loop())
341 async def _poll_loop(self) -> None:
342 """Poll for new messages."""
343 while self._running and self.status == ChannelStatus.CONNECTED:
344 try:
345 await self._check_new_messages()
346 await asyncio.sleep(self.email_config.poll_interval)
347 except asyncio.CancelledError:
348 break
349 except Exception as e:
350 logger.error(f"Error in poll loop: {e}")
351 await asyncio.sleep(self.email_config.poll_interval)
353 async def _check_new_messages(self) -> None:
354 """Check for new messages in inbox."""
355 if not self._imap:
356 return
358 try:
359 if self._use_async:
360 await self._check_messages_async()
361 else:
362 await self._check_messages_sync()
363 except Exception as e:
364 logger.error(f"Error checking messages: {e}")
365 # Try to reconnect
366 await self._connect_imap()
368 async def _check_messages_async(self) -> None:
369 """Check messages using async IMAP."""
370 try:
371 # Select inbox
372 await self._imap.select(self.email_config.inbox_folder)
374 # Search for unseen messages
375 status, data = await self._imap.search('UNSEEN')
376 if status != 'OK':
377 return
379 message_nums = data[0].split()
381 for num in message_nums:
382 # Fetch message
383 status, msg_data = await self._imap.fetch(num, '(RFC822)')
384 if status != 'OK':
385 continue
387 # Parse and process
388 raw_email = msg_data[1]
389 await self._process_email(raw_email, num)
391 except Exception as e:
392 logger.error(f"Async message check error: {e}")
393 raise
395 async def _check_messages_sync(self) -> None:
396 """Check messages using sync IMAP."""
397 loop = asyncio.get_event_loop()
399 def check_sync():
400 # Select inbox
401 self._imap.select(self.email_config.inbox_folder)
403 # Search for unseen messages
404 status, data = self._imap.search(None, 'UNSEEN')
405 if status != 'OK':
406 return []
408 message_nums = data[0].split()
409 messages = []
411 for num in message_nums:
412 # Fetch message
413 status, msg_data = self._imap.fetch(num, '(RFC822)')
414 if status != 'OK':
415 continue
417 raw_email = msg_data[0][1]
418 messages.append((raw_email, num))
420 return messages
422 messages = await loop.run_in_executor(None, check_sync)
424 for raw_email, num in messages:
425 await self._process_email(raw_email, num)
427 async def _process_email(self, raw_email: bytes, msg_num: bytes) -> None:
428 """Process a raw email message."""
429 try:
430 # Parse email
431 msg = email.message_from_bytes(raw_email)
433 # Get message ID
434 message_id = msg.get('Message-ID', '')
436 # Skip if already seen
437 if message_id in self._seen_message_ids:
438 return
440 self._seen_message_ids.add(message_id)
442 # Convert to unified message
443 unified_msg = self._convert_message(msg)
445 # Mark as read if configured
446 if self.email_config.mark_as_read:
447 await self._mark_as_read(msg_num)
449 # Dispatch to handlers
450 await self._dispatch_message(unified_msg)
452 except Exception as e:
453 logger.error(f"Error processing email: {e}")
455 def _convert_message(self, msg: email.message.Message) -> Message:
456 """Convert email to unified Message format."""
457 # Get sender
458 from_header = msg.get('From', '')
459 sender_name, sender_email = parseaddr(from_header)
461 # Decode sender name if needed
462 if sender_name:
463 try:
464 sender_name = str(make_header(decode_header(sender_name)))
465 except Exception:
466 pass
468 # Get subject
469 subject = msg.get('Subject', '')
470 try:
471 subject = str(make_header(decode_header(subject)))
472 except Exception:
473 pass
475 # Get message ID and threading info
476 message_id = msg.get('Message-ID', f"<{hashlib.md5(str(msg).encode()).hexdigest()}@local>")
477 in_reply_to = msg.get('In-Reply-To')
478 references = msg.get('References', '').split()
480 # Generate thread ID from subject or references
481 thread_id = self._get_thread_id(subject, references, in_reply_to)
483 # Track thread
484 if thread_id not in self._threads:
485 self._threads[thread_id] = EmailThread(
486 thread_id=thread_id,
487 subject=subject,
488 participants=[sender_email],
489 )
491 thread = self._threads[thread_id]
492 thread.message_ids.append(message_id)
493 thread.last_message_id = message_id
494 if references:
495 thread.references = references
497 # Get body and attachments
498 text_body, html_body, attachments = self._extract_body_and_attachments(msg)
500 # Prefer text over HTML
501 text = text_body or self._html_to_text(html_body) if html_body else ""
503 # Convert attachments to MediaAttachment
504 media = []
505 for att_filename, att_data, att_type in attachments:
506 media_type = MessageType.DOCUMENT
507 if att_type and att_type.startswith('image/'):
508 media_type = MessageType.IMAGE
509 elif att_type and att_type.startswith('video/'):
510 media_type = MessageType.VIDEO
511 elif att_type and att_type.startswith('audio/'):
512 media_type = MessageType.AUDIO
514 media.append(MediaAttachment(
515 type=media_type,
516 file_name=att_filename,
517 mime_type=att_type,
518 file_size=len(att_data) if att_data else None,
519 ))
521 # Parse date
522 date_str = msg.get('Date')
523 timestamp = datetime.now()
524 if date_str:
525 try:
526 parsed = email.utils.parsedate_to_datetime(date_str)
527 timestamp = parsed.replace(tzinfo=None)
528 except Exception:
529 pass
531 return Message(
532 id=message_id,
533 channel=self.name,
534 sender_id=sender_email,
535 sender_name=sender_name or sender_email,
536 chat_id=thread_id,
537 text=text,
538 media=media,
539 reply_to_id=in_reply_to,
540 timestamp=timestamp,
541 is_group=len(thread.participants) > 2,
542 raw={
543 'subject': subject,
544 'from': from_header,
545 'to': msg.get('To'),
546 'cc': msg.get('Cc'),
547 'message_id': message_id,
548 'in_reply_to': in_reply_to,
549 'references': references,
550 'html_body': html_body,
551 'attachments': [(f, t) for f, _, t in attachments],
552 },
553 )
555 def _get_thread_id(
556 self,
557 subject: str,
558 references: List[str],
559 in_reply_to: Optional[str],
560 ) -> str:
561 """Generate thread ID from email headers."""
562 # Use first reference or in-reply-to as base
563 if references:
564 return hashlib.md5(references[0].encode()).hexdigest()[:16]
565 if in_reply_to:
566 return hashlib.md5(in_reply_to.encode()).hexdigest()[:16]
568 # Use normalized subject
569 normalized = re.sub(r'^(re:|fwd?:)\s*', '', subject.lower(), flags=re.IGNORECASE)
570 return hashlib.md5(normalized.encode()).hexdigest()[:16]
572 def _extract_body_and_attachments(
573 self,
574 msg: email.message.Message,
575 ) -> Tuple[str, str, List[Tuple[str, bytes, str]]]:
576 """Extract text body, HTML body, and attachments from email."""
577 text_body = ""
578 html_body = ""
579 attachments = []
581 if msg.is_multipart():
582 for part in msg.walk():
583 content_type = part.get_content_type()
584 content_disposition = str(part.get('Content-Disposition', ''))
586 # Check if it's an attachment
587 if 'attachment' in content_disposition:
588 filename = part.get_filename() or 'attachment'
589 try:
590 filename = str(make_header(decode_header(filename)))
591 except Exception:
592 pass
594 data = part.get_payload(decode=True)
595 attachments.append((filename, data, content_type))
596 elif content_type == 'text/plain':
597 payload = part.get_payload(decode=True)
598 if payload:
599 charset = part.get_content_charset() or 'utf-8'
600 text_body = payload.decode(charset, errors='replace')
601 elif content_type == 'text/html':
602 payload = part.get_payload(decode=True)
603 if payload:
604 charset = part.get_content_charset() or 'utf-8'
605 html_body = payload.decode(charset, errors='replace')
606 else:
607 content_type = msg.get_content_type()
608 payload = msg.get_payload(decode=True)
610 if payload:
611 charset = msg.get_content_charset() or 'utf-8'
612 if content_type == 'text/plain':
613 text_body = payload.decode(charset, errors='replace')
614 elif content_type == 'text/html':
615 html_body = payload.decode(charset, errors='replace')
617 return text_body, html_body, attachments
619 def _html_to_text(self, html: str) -> str:
620 """Convert HTML to plain text."""
621 # Simple HTML to text conversion
622 text = re.sub(r'<br\s*/?>', '\n', html)
623 text = re.sub(r'<p[^>]*>', '\n', text)
624 text = re.sub(r'</p>', '', text)
625 text = re.sub(r'<[^>]+>', '', text)
626 text = re.sub(r' ', ' ', text)
627 text = re.sub(r'<', '<', text)
628 text = re.sub(r'>', '>', text)
629 text = re.sub(r'&', '&', text)
630 text = re.sub(r'\n{3,}', '\n\n', text)
631 return text.strip()
633 async def _mark_as_read(self, msg_num: bytes) -> None:
634 """Mark a message as read."""
635 try:
636 if self._use_async:
637 await self._imap.store(msg_num, '+FLAGS', '\\Seen')
638 else:
639 loop = asyncio.get_event_loop()
640 await loop.run_in_executor(
641 None,
642 lambda: self._imap.store(msg_num, '+FLAGS', '\\Seen')
643 )
644 except Exception as e:
645 logger.warning(f"Failed to mark message as read: {e}")
647 async def send_message(
648 self,
649 chat_id: str,
650 text: str,
651 reply_to: Optional[str] = None,
652 media: Optional[List[MediaAttachment]] = None,
653 buttons: Optional[List[Dict]] = None,
654 ) -> SendResult:
655 """Send an email message."""
656 try:
657 # Get thread info
658 thread = self._threads.get(chat_id)
660 # Determine recipient
661 if thread and thread.participants:
662 to_address = thread.participants[0]
663 else:
664 # chat_id might be the email address
665 to_address = chat_id if '@' in chat_id else None
667 if not to_address:
668 return SendResult(success=False, error="No recipient address")
670 # Build email
671 if media or self.email_config.html_emails:
672 msg = email.mime.multipart.MIMEMultipart('mixed')
673 else:
674 msg = email.mime.text.MIMEText(text)
676 # Headers
677 from_addr = formataddr((
678 self.email_config.display_name or '',
679 self.email_config.email_address
680 ))
681 msg['From'] = from_addr
682 msg['To'] = to_address
684 # Subject
685 if thread:
686 subject = thread.subject
687 if not subject.lower().startswith('re:'):
688 subject = f"Re: {subject}"
689 else:
690 subject = self.email_config.default_subject
692 msg['Subject'] = subject
694 # Threading headers
695 msg['Message-ID'] = email.utils.make_msgid()
697 if reply_to:
698 msg['In-Reply-To'] = reply_to
700 if thread:
701 if thread.references:
702 refs = ' '.join(thread.references)
703 if thread.last_message_id and thread.last_message_id not in refs:
704 refs += f" {thread.last_message_id}"
705 msg['References'] = refs
706 elif thread.last_message_id:
707 msg['References'] = thread.last_message_id
709 # Reply-To header
710 if self.email_config.reply_to:
711 msg['Reply-To'] = self.email_config.reply_to
713 # Add body
714 if isinstance(msg, email.mime.multipart.MIMEMultipart):
715 # Add text/html parts
716 alt = email.mime.multipart.MIMEMultipart('alternative')
718 # Plain text
719 text_part = email.mime.text.MIMEText(text, 'plain', 'utf-8')
720 alt.attach(text_part)
722 # HTML if enabled
723 if self.email_config.html_emails:
724 html_text = self._text_to_html(text)
725 html_part = email.mime.text.MIMEText(html_text, 'html', 'utf-8')
726 alt.attach(html_part)
728 msg.attach(alt)
730 # Add attachments
731 if media:
732 for m in media:
733 attachment = await self._create_attachment(m)
734 if attachment:
735 msg.attach(attachment)
737 # Send
738 return await self._send_smtp(to_address, msg)
740 except Exception as e:
741 logger.error(f"Failed to send email: {e}")
742 return SendResult(success=False, error=str(e))
744 def _text_to_html(self, text: str) -> str:
745 """Convert plain text to HTML."""
746 # Escape HTML entities
747 text = text.replace('&', '&')
748 text = text.replace('<', '<')
749 text = text.replace('>', '>')
751 # Convert newlines to <br>
752 text = text.replace('\n', '<br>\n')
754 return f"""<!DOCTYPE html>
755<html>
756<head>
757<meta charset="utf-8">
758</head>
759<body>
760<div style="font-family: Arial, sans-serif; font-size: 14px;">
761{text}
762</div>
763</body>
764</html>"""
766 async def _create_attachment(
767 self,
768 media: MediaAttachment,
769 ) -> Optional[email.mime.base.MIMEBase]:
770 """Create email attachment from MediaAttachment."""
771 try:
772 # Get file data
773 if media.file_path:
774 with open(media.file_path, 'rb') as f:
775 data = f.read()
776 filename = os.path.basename(media.file_path)
777 elif media.url:
778 # Would need to fetch URL - skip for now
779 logger.warning("URL attachments not implemented")
780 return None
781 else:
782 return None
784 # Check size
785 if len(data) > self.email_config.max_attachment_size:
786 logger.warning(f"Attachment too large: {len(data)} bytes")
787 return None
789 # Determine MIME type
790 mime_type = media.mime_type or mimetypes.guess_type(filename)[0] or 'application/octet-stream'
791 maintype, subtype = mime_type.split('/', 1)
793 # Create attachment
794 if maintype == 'text':
795 part = email.mime.text.MIMEText(data.decode('utf-8', errors='replace'), _subtype=subtype)
796 elif maintype == 'image':
797 part = email.mime.image.MIMEImage(data, _subtype=subtype)
798 elif maintype == 'audio':
799 part = email.mime.audio.MIMEAudio(data, _subtype=subtype)
800 else:
801 part = email.mime.base.MIMEBase(maintype, subtype)
802 part.set_payload(data)
803 email.encoders.encode_base64(part)
805 # Set filename
806 part.add_header(
807 'Content-Disposition',
808 'attachment',
809 filename=media.file_name or filename
810 )
812 return part
814 except Exception as e:
815 logger.error(f"Error creating attachment: {e}")
816 return None
818 async def _send_smtp(
819 self,
820 to_address: str,
821 msg: email.message.Message,
822 ) -> SendResult:
823 """Send email via SMTP."""
824 try:
825 host = self._get_smtp_host()
826 port = self.email_config.smtp_port
828 if self._use_async:
829 # Async SMTP
830 smtp = aiosmtplib.SMTP(
831 hostname=host,
832 port=port,
833 use_tls=not self.email_config.smtp_use_tls,
834 start_tls=self.email_config.smtp_use_tls,
835 )
836 await smtp.connect()
837 await smtp.login(
838 self.email_config.smtp_username,
839 self.email_config.smtp_password
840 )
841 await smtp.send_message(msg)
842 await smtp.quit()
843 else:
844 # Sync SMTP
845 loop = asyncio.get_event_loop()
847 def send_sync():
848 if self.email_config.smtp_use_tls:
849 smtp = smtplib.SMTP(host, port)
850 smtp.starttls()
851 else:
852 context = ssl.create_default_context()
853 smtp = smtplib.SMTP_SSL(host, port, context=context)
855 smtp.login(
856 self.email_config.smtp_username,
857 self.email_config.smtp_password
858 )
859 smtp.send_message(msg)
860 smtp.quit()
862 await loop.run_in_executor(None, send_sync)
864 return SendResult(
865 success=True,
866 message_id=msg['Message-ID'],
867 )
869 except Exception as e:
870 logger.error(f"SMTP send error: {e}")
871 return SendResult(success=False, error=str(e))
873 async def edit_message(
874 self,
875 chat_id: str,
876 message_id: str,
877 text: str,
878 buttons: Optional[List[Dict]] = None,
879 ) -> SendResult:
880 """Email doesn't support message editing."""
881 logger.warning("Email doesn't support message editing, sending new message")
882 return await self.send_message(chat_id, text, reply_to=message_id)
884 async def delete_message(self, chat_id: str, message_id: str) -> bool:
885 """
886 Delete an email message.
887 Note: This marks the message as deleted but doesn't expunge.
888 """
889 logger.warning("Email deletion not fully implemented")
890 return False
892 async def send_typing(self, chat_id: str) -> None:
893 """Email doesn't have typing indicators."""
894 pass
896 async def get_chat_info(self, chat_id: str) -> Optional[Dict[str, Any]]:
897 """Get thread information."""
898 thread = self._threads.get(chat_id)
899 if thread:
900 return {
901 'thread_id': thread.thread_id,
902 'subject': thread.subject,
903 'participants': thread.participants,
904 'message_count': len(thread.message_ids),
905 }
906 return None
908 # Email-specific methods
910 async def send_email(
911 self,
912 to: str,
913 subject: str,
914 body: str,
915 cc: Optional[List[str]] = None,
916 bcc: Optional[List[str]] = None,
917 attachments: Optional[List[str]] = None,
918 html_body: Optional[str] = None,
919 ) -> SendResult:
920 """Send an email with full control over headers."""
921 try:
922 # Create message
923 if attachments or html_body:
924 msg = email.mime.multipart.MIMEMultipart('mixed')
925 else:
926 msg = email.mime.text.MIMEText(body)
928 # Headers
929 from_addr = formataddr((
930 self.email_config.display_name or '',
931 self.email_config.email_address
932 ))
933 msg['From'] = from_addr
934 msg['To'] = to
935 msg['Subject'] = subject
936 msg['Message-ID'] = email.utils.make_msgid()
938 if cc:
939 msg['Cc'] = ', '.join(cc)
941 if self.email_config.reply_to:
942 msg['Reply-To'] = self.email_config.reply_to
944 # Body
945 if isinstance(msg, email.mime.multipart.MIMEMultipart):
946 alt = email.mime.multipart.MIMEMultipart('alternative')
948 text_part = email.mime.text.MIMEText(body, 'plain', 'utf-8')
949 alt.attach(text_part)
951 if html_body:
952 html_part = email.mime.text.MIMEText(html_body, 'html', 'utf-8')
953 alt.attach(html_part)
955 msg.attach(alt)
957 # Attachments
958 if attachments:
959 for filepath in attachments:
960 media = MediaAttachment(
961 type=MessageType.DOCUMENT,
962 file_path=filepath,
963 file_name=os.path.basename(filepath),
964 )
965 part = await self._create_attachment(media)
966 if part:
967 msg.attach(part)
969 # Determine all recipients
970 all_recipients = [to]
971 if cc:
972 all_recipients.extend(cc)
973 if bcc:
974 all_recipients.extend(bcc)
976 # Send
977 return await self._send_smtp(to, msg)
979 except Exception as e:
980 logger.error(f"Failed to send email: {e}")
981 return SendResult(success=False, error=str(e))
983 def get_thread(self, thread_id: str) -> Optional[EmailThread]:
984 """Get thread information by ID."""
985 return self._threads.get(thread_id)
987 def list_threads(self) -> List[EmailThread]:
988 """List all tracked threads."""
989 return list(self._threads.values())
992def create_email_adapter(
993 imap_host: str = None,
994 smtp_host: str = None,
995 email_address: str = None,
996 password: str = None,
997 **kwargs
998) -> EmailAdapter:
999 """
1000 Factory function to create Email adapter.
1002 For simple setup, provide same credentials for IMAP and SMTP.
1004 Args:
1005 imap_host: IMAP server host (or set EMAIL_IMAP_HOST env var)
1006 smtp_host: SMTP server host (or set EMAIL_SMTP_HOST env var)
1007 email_address: Email address (or set EMAIL_ADDRESS env var)
1008 password: Email password (or set EMAIL_PASSWORD env var)
1009 **kwargs: Additional config options
1011 Returns:
1012 Configured EmailAdapter
1013 """
1014 imap_host = imap_host or os.getenv("EMAIL_IMAP_HOST")
1015 smtp_host = smtp_host or os.getenv("EMAIL_SMTP_HOST")
1016 email_address = email_address or os.getenv("EMAIL_ADDRESS")
1017 password = password or os.getenv("EMAIL_PASSWORD")
1019 if not imap_host or not smtp_host:
1020 raise ValueError("IMAP and SMTP hosts required")
1021 if not email_address:
1022 raise ValueError("Email address required")
1024 # Use same credentials for IMAP and SMTP if not specified separately
1025 imap_username = kwargs.pop('imap_username', None) or email_address
1026 imap_password = kwargs.pop('imap_password', None) or password
1027 smtp_username = kwargs.pop('smtp_username', None) or email_address
1028 smtp_password = kwargs.pop('smtp_password', None) or password
1030 config = EmailConfig(
1031 imap_host=imap_host,
1032 smtp_host=smtp_host,
1033 email_address=email_address,
1034 imap_username=imap_username,
1035 imap_password=imap_password,
1036 smtp_username=smtp_username,
1037 smtp_password=smtp_password,
1038 **kwargs
1039 )
1040 return EmailAdapter(config)