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

1""" 

2Email Channel Adapter 

3 

4Implements email messaging via IMAP/SMTP. 

5Based on SantaClaw extension patterns for email. 

6 

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""" 

18 

19from __future__ import annotations 

20 

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 

44 

45try: 

46 import aioimaplib 

47 HAS_AIOIMAP = True 

48except ImportError: 

49 HAS_AIOIMAP = False 

50 

51try: 

52 import aiosmtplib 

53 HAS_AIOSMTP = True 

54except ImportError: 

55 HAS_AIOSMTP = False 

56 

57from ..base import ( 

58 ChannelAdapter, 

59 ChannelConfig, 

60 ChannelStatus, 

61 Message, 

62 MessageType, 

63 MediaAttachment, 

64 SendResult, 

65 ChannelConnectionError, 

66 ChannelSendError, 

67 ChannelRateLimitError, 

68) 

69 

70logger = logging.getLogger(__name__) 

71 

72 

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 = "" 

82 

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 = "" 

89 

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" 

95 

96 # Folders 

97 inbox_folder: str = "INBOX" 

98 sent_folder: str = "Sent" 

99 archive_folder: Optional[str] = None 

100 

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 

108 

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" 

114 

115 

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) 

125 

126 

127class EmailAdapter(ChannelAdapter): 

128 """ 

129 Email messaging adapter using IMAP/SMTP. 

130 

131 Supports both sync and async operations. For best performance in 

132 async environments, install aioimaplib and aiosmtplib. 

133 

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. 

138 

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 """ 

153 

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 

164 

165 @property 

166 def name(self) -> str: 

167 return "email" 

168 

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 

174 

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 

180 

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 

186 

187 if not self.email_config.email_address: 

188 logger.error("Email address required") 

189 return False 

190 

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 

197 

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 

203 

204 self.status = ChannelStatus.CONNECTED 

205 logger.info(f"Email adapter connected as: {self.email_config.email_address}") 

206 return True 

207 

208 except Exception as e: 

209 logger.error(f"Failed to connect email: {e}") 

210 self.status = ChannelStatus.ERROR 

211 return False 

212 

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 

218 

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) 

225 

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() 

234 

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) 

241 

242 imap.login( 

243 self.email_config.imap_username, 

244 self.email_config.imap_password 

245 ) 

246 return imap 

247 

248 self._imap = await loop.run_in_executor(None, connect_sync) 

249 

250 return True 

251 

252 except Exception as e: 

253 logger.error(f"IMAP connection error: {e}") 

254 return False 

255 

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 

261 

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() 

279 

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) 

287 

288 smtp.login( 

289 self.email_config.smtp_username, 

290 self.email_config.smtp_password 

291 ) 

292 smtp.quit() 

293 

294 await loop.run_in_executor(None, test_sync) 

295 

296 return True 

297 

298 except Exception as e: 

299 logger.error(f"SMTP connection error: {e}") 

300 return False 

301 

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 

311 

312 if self._idle_task: 

313 self._idle_task.cancel() 

314 try: 

315 await self._idle_task 

316 except asyncio.CancelledError: 

317 pass 

318 

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 

330 

331 self.status = ChannelStatus.DISCONNECTED 

332 

333 async def start(self) -> None: 

334 """Start the email adapter and begin polling/IDLE.""" 

335 await super().start() 

336 

337 if self.status == ChannelStatus.CONNECTED: 

338 # Start polling for new messages 

339 self._poll_task = asyncio.create_task(self._poll_loop()) 

340 

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) 

352 

353 async def _check_new_messages(self) -> None: 

354 """Check for new messages in inbox.""" 

355 if not self._imap: 

356 return 

357 

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() 

367 

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) 

373 

374 # Search for unseen messages 

375 status, data = await self._imap.search('UNSEEN') 

376 if status != 'OK': 

377 return 

378 

379 message_nums = data[0].split() 

380 

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 

386 

387 # Parse and process 

388 raw_email = msg_data[1] 

389 await self._process_email(raw_email, num) 

390 

391 except Exception as e: 

392 logger.error(f"Async message check error: {e}") 

393 raise 

394 

395 async def _check_messages_sync(self) -> None: 

396 """Check messages using sync IMAP.""" 

397 loop = asyncio.get_event_loop() 

398 

399 def check_sync(): 

400 # Select inbox 

401 self._imap.select(self.email_config.inbox_folder) 

402 

403 # Search for unseen messages 

404 status, data = self._imap.search(None, 'UNSEEN') 

405 if status != 'OK': 

406 return [] 

407 

408 message_nums = data[0].split() 

409 messages = [] 

410 

411 for num in message_nums: 

412 # Fetch message 

413 status, msg_data = self._imap.fetch(num, '(RFC822)') 

414 if status != 'OK': 

415 continue 

416 

417 raw_email = msg_data[0][1] 

418 messages.append((raw_email, num)) 

419 

420 return messages 

421 

422 messages = await loop.run_in_executor(None, check_sync) 

423 

424 for raw_email, num in messages: 

425 await self._process_email(raw_email, num) 

426 

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) 

432 

433 # Get message ID 

434 message_id = msg.get('Message-ID', '') 

435 

436 # Skip if already seen 

437 if message_id in self._seen_message_ids: 

438 return 

439 

440 self._seen_message_ids.add(message_id) 

441 

442 # Convert to unified message 

443 unified_msg = self._convert_message(msg) 

444 

445 # Mark as read if configured 

446 if self.email_config.mark_as_read: 

447 await self._mark_as_read(msg_num) 

448 

449 # Dispatch to handlers 

450 await self._dispatch_message(unified_msg) 

451 

452 except Exception as e: 

453 logger.error(f"Error processing email: {e}") 

454 

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) 

460 

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 

467 

468 # Get subject 

469 subject = msg.get('Subject', '') 

470 try: 

471 subject = str(make_header(decode_header(subject))) 

472 except Exception: 

473 pass 

474 

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() 

479 

480 # Generate thread ID from subject or references 

481 thread_id = self._get_thread_id(subject, references, in_reply_to) 

482 

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 ) 

490 

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 

496 

497 # Get body and attachments 

498 text_body, html_body, attachments = self._extract_body_and_attachments(msg) 

499 

500 # Prefer text over HTML 

501 text = text_body or self._html_to_text(html_body) if html_body else "" 

502 

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 

513 

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 )) 

520 

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 

530 

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 ) 

554 

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] 

567 

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] 

571 

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 = [] 

580 

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', '')) 

585 

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 

593 

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) 

609 

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') 

616 

617 return text_body, html_body, attachments 

618 

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'&nbsp;', ' ', text) 

627 text = re.sub(r'&lt;', '<', text) 

628 text = re.sub(r'&gt;', '>', text) 

629 text = re.sub(r'&amp;', '&', text) 

630 text = re.sub(r'\n{3,}', '\n\n', text) 

631 return text.strip() 

632 

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}") 

646 

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) 

659 

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 

666 

667 if not to_address: 

668 return SendResult(success=False, error="No recipient address") 

669 

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) 

675 

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 

683 

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 

691 

692 msg['Subject'] = subject 

693 

694 # Threading headers 

695 msg['Message-ID'] = email.utils.make_msgid() 

696 

697 if reply_to: 

698 msg['In-Reply-To'] = reply_to 

699 

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 

708 

709 # Reply-To header 

710 if self.email_config.reply_to: 

711 msg['Reply-To'] = self.email_config.reply_to 

712 

713 # Add body 

714 if isinstance(msg, email.mime.multipart.MIMEMultipart): 

715 # Add text/html parts 

716 alt = email.mime.multipart.MIMEMultipart('alternative') 

717 

718 # Plain text 

719 text_part = email.mime.text.MIMEText(text, 'plain', 'utf-8') 

720 alt.attach(text_part) 

721 

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) 

727 

728 msg.attach(alt) 

729 

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) 

736 

737 # Send 

738 return await self._send_smtp(to_address, msg) 

739 

740 except Exception as e: 

741 logger.error(f"Failed to send email: {e}") 

742 return SendResult(success=False, error=str(e)) 

743 

744 def _text_to_html(self, text: str) -> str: 

745 """Convert plain text to HTML.""" 

746 # Escape HTML entities 

747 text = text.replace('&', '&amp;') 

748 text = text.replace('<', '&lt;') 

749 text = text.replace('>', '&gt;') 

750 

751 # Convert newlines to <br> 

752 text = text.replace('\n', '<br>\n') 

753 

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>""" 

765 

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 

783 

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 

788 

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) 

792 

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) 

804 

805 # Set filename 

806 part.add_header( 

807 'Content-Disposition', 

808 'attachment', 

809 filename=media.file_name or filename 

810 ) 

811 

812 return part 

813 

814 except Exception as e: 

815 logger.error(f"Error creating attachment: {e}") 

816 return None 

817 

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 

827 

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() 

846 

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) 

854 

855 smtp.login( 

856 self.email_config.smtp_username, 

857 self.email_config.smtp_password 

858 ) 

859 smtp.send_message(msg) 

860 smtp.quit() 

861 

862 await loop.run_in_executor(None, send_sync) 

863 

864 return SendResult( 

865 success=True, 

866 message_id=msg['Message-ID'], 

867 ) 

868 

869 except Exception as e: 

870 logger.error(f"SMTP send error: {e}") 

871 return SendResult(success=False, error=str(e)) 

872 

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) 

883 

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 

891 

892 async def send_typing(self, chat_id: str) -> None: 

893 """Email doesn't have typing indicators.""" 

894 pass 

895 

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 

907 

908 # Email-specific methods 

909 

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) 

927 

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() 

937 

938 if cc: 

939 msg['Cc'] = ', '.join(cc) 

940 

941 if self.email_config.reply_to: 

942 msg['Reply-To'] = self.email_config.reply_to 

943 

944 # Body 

945 if isinstance(msg, email.mime.multipart.MIMEMultipart): 

946 alt = email.mime.multipart.MIMEMultipart('alternative') 

947 

948 text_part = email.mime.text.MIMEText(body, 'plain', 'utf-8') 

949 alt.attach(text_part) 

950 

951 if html_body: 

952 html_part = email.mime.text.MIMEText(html_body, 'html', 'utf-8') 

953 alt.attach(html_part) 

954 

955 msg.attach(alt) 

956 

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) 

968 

969 # Determine all recipients 

970 all_recipients = [to] 

971 if cc: 

972 all_recipients.extend(cc) 

973 if bcc: 

974 all_recipients.extend(bcc) 

975 

976 # Send 

977 return await self._send_smtp(to, msg) 

978 

979 except Exception as e: 

980 logger.error(f"Failed to send email: {e}") 

981 return SendResult(success=False, error=str(e)) 

982 

983 def get_thread(self, thread_id: str) -> Optional[EmailThread]: 

984 """Get thread information by ID.""" 

985 return self._threads.get(thread_id) 

986 

987 def list_threads(self) -> List[EmailThread]: 

988 """List all tracked threads.""" 

989 return list(self._threads.values()) 

990 

991 

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. 

1001 

1002 For simple setup, provide same credentials for IMAP and SMTP. 

1003 

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 

1010 

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") 

1018 

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") 

1023 

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 

1029 

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)