返回 JoyAI-Echo
1 """Email channel implementation using IMAP polling + SMTP replies."""
2
3 import asyncio
4 import html
5 import imaplib
6 import re
7 import smtplib
8 import ssl
9 from datetime import date
10 from email import policy
11 from email.header import decode_header, make_header
12 from email.message import EmailMessage
13 from email.parser import BytesParser
14 from email.utils import parseaddr
15 from fnmatch import fnmatch
16 from pathlib import Path
17 from typing import Any
18
19 from loguru import logger
20 from pydantic import Field
21
22 from nanobot.bus.events import OutboundMessage
23 from nanobot.bus.queue import MessageBus
24 from nanobot.channels.base import BaseChannel
25 from nanobot.config.paths import get_media_dir
26 from nanobot.config.schema import Base
27 from nanobot.utils.helpers import safe_filename
28
29
30 class EmailConfig(Base):
31 """Email channel configuration (IMAP inbound + SMTP outbound)."""
32
33 enabled: bool = False
34 consent_granted: bool = False
35
36 imap_host: str = ""
37 imap_port: int = 993
38 imap_username: str = ""
39 imap_password: str = ""
40 imap_mailbox: str = "INBOX"
41 imap_use_ssl: bool = True
42
43 smtp_host: str = ""
44 smtp_port: int = 587
45 smtp_username: str = ""
46 smtp_password: str = ""
47 smtp_use_tls: bool = True
48 smtp_use_ssl: bool = False
49 from_address: str = ""
50
51 auto_reply_enabled: bool = True
52 poll_interval_seconds: int = 30
53 mark_seen: bool = True
54 max_body_chars: int = 12000
55 subject_prefix: str = "Re: "
56 allow_from: list[str] = Field(default_factory=list)
57
58 # Email authentication verification (anti-spoofing)
59 verify_dkim: bool = True # Require Authentication-Results with dkim=pass
60 verify_spf: bool = True # Require Authentication-Results with spf=pass
61
62 # Attachment handling — set allowed types to enable (e.g. ["application/pdf", "image/*"], or ["*"] for all)
63 allowed_attachment_types: list[str] = Field(default_factory=list)
64 max_attachment_size: int = 2_000_000 # 2MB per attachment
65 max_attachments_per_email: int = 5
66
67
68 class EmailChannel(BaseChannel):
69 """
70 Email channel.
71
72 Inbound:
73 - Poll IMAP mailbox for unread messages.
74 - Convert each message into an inbound event.
75
76 Outbound:
77 - Send responses via SMTP back to the sender address.
78 """
79
80 name = "email"
81 display_name = "Email"
82 _IMAP_MONTHS = (
83 "Jan",
84 "Feb",
85 "Mar",
86 "Apr",
87 "May",
88 "Jun",
89 "Jul",
90 "Aug",
91 "Sep",
92 "Oct",
93 "Nov",
94 "Dec",
95 )
96 _IMAP_RECONNECT_MARKERS = (
97 "disconnected for inactivity",
98 "eof occurred in violation of protocol",
99 "socket error",
100 "connection reset",
101 "broken pipe",
102 "bye",
103 )
104 _IMAP_MISSING_MAILBOX_MARKERS = (
105 "mailbox doesn't exist",
106 "select failed",
107 "no such mailbox",
108 "can't open mailbox",
109 "does not exist",
110 )
111
112 @classmethod
113 def default_config(cls) -> dict[str, Any]:
114 return EmailConfig().model_dump(by_alias=True)
115
116 def __init__(self, config: Any, bus: MessageBus):
117 if isinstance(config, dict):
118 config = EmailConfig.model_validate(config)
119 super().__init__(config, bus)
120 self.config: EmailConfig = config
121 self._self_addresses = self._collect_self_addresses()
122 self._last_subject_by_chat: dict[str, str] = {}
123 self._last_message_id_by_chat: dict[str, str] = {}
124 self._processed_uids: set[str] = set() # Capped to prevent unbounded growth
125 self._MAX_PROCESSED_UIDS = 100000
126
127 async def start(self) -> None:
128 """Start polling IMAP for inbound emails."""
129 if not self.config.consent_granted:
130 logger.warning(
131 "Email channel disabled: consent_granted is false. "
132 "Set channels.email.consentGranted=true after explicit user permission."
133 )
134 return
135
136 if not self._validate_config():
137 return
138
139 self._running = True
140 if not self.config.verify_dkim and not self.config.verify_spf:
141 logger.warning(
142 "Email channel: DKIM and SPF verification are both DISABLED. "
143 "Emails with spoofed From headers will be accepted. "
144 "Set verify_dkim=true and verify_spf=true for anti-spoofing protection."
145 )
146 logger.info("Starting Email channel (IMAP polling mode)...")
147
148 poll_seconds = max(5, int(self.config.poll_interval_seconds))
149 while self._running:
150 try:
151 inbound_items = await asyncio.to_thread(self._fetch_new_messages)
152 for item in inbound_items:
153 sender = item["sender"]
154 subject = item.get("subject", "")
155 message_id = item.get("message_id", "")
156
157 if subject:
158 self._last_subject_by_chat[sender] = subject
159 if message_id:
160 self._last_message_id_by_chat[sender] = message_id
161
162 await self._handle_message(
163 sender_id=sender,
164 chat_id=sender,
165 content=item["content"],
166 media=item.get("media") or None,
167 metadata=item.get("metadata", {}),
168 )
169 except Exception as e:
170 logger.error("Email polling error: {}", e)
171
172 await asyncio.sleep(poll_seconds)
173
174 async def stop(self) -> None:
175 """Stop polling loop."""
176 self._running = False
177
178 async def send(self, msg: OutboundMessage) -> None:
179 """Send email via SMTP."""
180 if not self.config.consent_granted:
181 logger.warning("Skip email send: consent_granted is false")
182 return
183
184 if not self.config.smtp_host:
185 logger.warning("Email channel SMTP host not configured")
186 return
187
188 to_addr = msg.chat_id.strip()
189 if not to_addr:
190 logger.warning("Email channel missing recipient address")
191 return
192
193 # Determine if this is a reply (recipient has sent us an email before)
194 is_reply = to_addr in self._last_subject_by_chat
195 force_send = bool((msg.metadata or {}).get("force_send"))
196
197 # autoReplyEnabled only controls automatic replies, not proactive sends
198 if is_reply and not self.config.auto_reply_enabled and not force_send:
199 logger.info("Skip automatic email reply to {}: auto_reply_enabled is false", to_addr)
200 return
201
202 base_subject = self._last_subject_by_chat.get(to_addr, "nanobot reply")
203 subject = self._reply_subject(base_subject)
204 if msg.metadata and isinstance(msg.metadata.get("subject"), str):
205 override = msg.metadata["subject"].strip()
206 if override:
207 subject = override
208
209 email_msg = EmailMessage()
210 email_msg["From"] = self.config.from_address or self.config.smtp_username or self.config.imap_username
211 email_msg["To"] = to_addr
212 email_msg["Subject"] = subject
213 email_msg.set_content(msg.content or "")
214
215 in_reply_to = self._last_message_id_by_chat.get(to_addr)
216 if in_reply_to:
217 email_msg["In-Reply-To"] = in_reply_to
218 email_msg["References"] = in_reply_to
219
220 try:
221 await asyncio.to_thread(self._smtp_send, email_msg)
222 except Exception as e:
223 logger.error("Error sending email to {}: {}", to_addr, e)
224 raise
225
226 def _validate_config(self) -> bool:
227 missing = []
228 if not self.config.imap_host:
229 missing.append("imap_host")
230 if not self.config.imap_username:
231 missing.append("imap_username")
232 if not self.config.imap_password:
233 missing.append("imap_password")
234 if not self.config.smtp_host:
235 missing.append("smtp_host")
236 if not self.config.smtp_username:
237 missing.append("smtp_username")
238 if not self.config.smtp_password:
239 missing.append("smtp_password")
240
241 if missing:
242 logger.error("Email channel not configured, missing: {}", ', '.join(missing))
243 return False
244 return True
245
246 def _smtp_send(self, msg: EmailMessage) -> None:
247 timeout = 30
248 if self.config.smtp_use_ssl:
249 with smtplib.SMTP_SSL(
250 self.config.smtp_host,
251 self.config.smtp_port,
252 timeout=timeout,
253 ) as smtp:
254 smtp.login(self.config.smtp_username, self.config.smtp_password)
255 smtp.send_message(msg)
256 return
257
258 with smtplib.SMTP(self.config.smtp_host, self.config.smtp_port, timeout=timeout) as smtp:
259 if self.config.smtp_use_tls:
260 smtp.starttls(context=ssl.create_default_context())
261 smtp.login(self.config.smtp_username, self.config.smtp_password)
262 smtp.send_message(msg)
263
264 def _fetch_new_messages(self) -> list[dict[str, Any]]:
265 """Poll IMAP and return parsed unread messages."""
266 return self._fetch_messages(
267 search_criteria=("UNSEEN",),
268 mark_seen=self.config.mark_seen,
269 dedupe=True,
270 limit=0,
271 )
272
273 def fetch_messages_between_dates(
274 self,
275 start_date: date,
276 end_date: date,
277 limit: int = 20,
278 ) -> list[dict[str, Any]]:
279 """
280 Fetch messages in [start_date, end_date) by IMAP date search.
281
282 This is used for historical summarization tasks (e.g. "yesterday").
283 """
284 if end_date <= start_date:
285 return []
286
287 return self._fetch_messages(
288 search_criteria=(
289 "SINCE",
290 self._format_imap_date(start_date),
291 "BEFORE",
292 self._format_imap_date(end_date),
293 ),
294 mark_seen=False,
295 dedupe=False,
296 limit=max(1, int(limit)),
297 )
298
299 def _fetch_messages(
300 self,
301 search_criteria: tuple[str, ...],
302 mark_seen: bool,
303 dedupe: bool,
304 limit: int,
305 ) -> list[dict[str, Any]]:
306 messages: list[dict[str, Any]] = []
307 cycle_uids: set[str] = set()
308
309 for attempt in range(2):
310 try:
311 self._fetch_messages_once(
312 search_criteria,
313 mark_seen,
314 dedupe,
315 limit,
316 messages,
317 cycle_uids,
318 )
319 return messages
320 except Exception as exc:
321 if attempt == 1 or not self._is_stale_imap_error(exc):
322 raise
323 logger.warning("Email IMAP connection went stale, retrying once: {}", exc)
324
325 return messages
326
327 def _fetch_messages_once(
328 self,
329 search_criteria: tuple[str, ...],
330 mark_seen: bool,
331 dedupe: bool,
332 limit: int,
333 messages: list[dict[str, Any]],
334 cycle_uids: set[str],
335 ) -> None:
336 """Fetch messages by arbitrary IMAP search criteria."""
337 mailbox = self.config.imap_mailbox or "INBOX"
338
339 if self.config.imap_use_ssl:
340 client = imaplib.IMAP4_SSL(self.config.imap_host, self.config.imap_port)
341 else:
342 client = imaplib.IMAP4(self.config.imap_host, self.config.imap_port)
343
344 try:
345 client.login(self.config.imap_username, self.config.imap_password)
346 try:
347 status, _ = client.select(mailbox)
348 except Exception as exc:
349 if self._is_missing_mailbox_error(exc):
350 logger.warning("Email mailbox unavailable, skipping poll for {}: {}", mailbox, exc)
351 return messages
352 raise
353 if status != "OK":
354 logger.warning("Email mailbox select returned {}, skipping poll for {}", status, mailbox)
355 return messages
356
357 status, data = client.search(None, *search_criteria)
358 if status != "OK" or not data:
359 return messages
360
361 ids = data[0].split()
362 if limit > 0 and len(ids) > limit:
363 ids = ids[-limit:]
364 for imap_id in ids:
365 status, fetched = client.fetch(imap_id, "(BODY.PEEK[] UID)")
366 if status != "OK" or not fetched:
367 continue
368
369 raw_bytes = self._extract_message_bytes(fetched)
370 if raw_bytes is None:
371 continue
372
373 uid = self._extract_uid(fetched)
374 if uid and uid in cycle_uids:
375 continue
376 if dedupe and uid and uid in self._processed_uids:
377 continue
378
379 parsed = BytesParser(policy=policy.default).parsebytes(raw_bytes)
380 sender = parseaddr(parsed.get("From", ""))[1].strip().lower()
381 if not sender:
382 continue
383 if self._is_self_address(sender):
384 logger.info("Email from {} ignored: matches bot-owned address", sender)
385 self._remember_processed_uid(uid, dedupe, cycle_uids)
386 if mark_seen:
387 client.store(imap_id, "+FLAGS", "\\Seen")
388 continue
389
390 # --- Anti-spoofing: verify Authentication-Results ---
391 spf_pass, dkim_pass = self._check_authentication_results(parsed)
392 if self.config.verify_spf and not spf_pass:
393 logger.warning(
394 "Email from {} rejected: SPF verification failed "
395 "(no 'spf=pass' in Authentication-Results header)",
396 sender,
397 )
398 self._remember_processed_uid(uid, dedupe, cycle_uids)
399 continue
400 if self.config.verify_dkim and not dkim_pass:
401 logger.warning(
402 "Email from {} rejected: DKIM verification failed "
403 "(no 'dkim=pass' in Authentication-Results header)",
404 sender,
405 )
406 self._remember_processed_uid(uid, dedupe, cycle_uids)
407 continue
408
409 subject = self._decode_header_value(parsed.get("Subject", ""))
410 date_value = parsed.get("Date", "")
411 message_id = parsed.get("Message-ID", "").strip()
412 body = self._extract_text_body(parsed)
413
414 if not body:
415 body = "(empty email body)"
416
417 body = body[: self.config.max_body_chars]
418 content = (
419 f"[EMAIL-CONTEXT] Email received.\n"
420 f"From: {sender}\n"
421 f"Subject: {subject}\n"
422 f"Date: {date_value}\n\n"
423 f"{body}"
424 )
425
426 # --- Attachment extraction ---
427 attachment_paths: list[str] = []
428 if self.config.allowed_attachment_types:
429 saved = self._extract_attachments(
430 parsed,
431 uid or "noid",
432 allowed_types=self.config.allowed_attachment_types,
433 max_size=self.config.max_attachment_size,
434 max_count=self.config.max_attachments_per_email,
435 )
436 for p in saved:
437 attachment_paths.append(str(p))
438 content += f"\n[attachment: {p.name} — saved to {p}]"
439
440 metadata = {
441 "message_id": message_id,
442 "subject": subject,
443 "date": date_value,
444 "sender_email": sender,
445 "uid": uid,
446 }
447 messages.append(
448 {
449 "sender": sender,
450 "subject": subject,
451 "message_id": message_id,
452 "content": content,
453 "metadata": metadata,
454 "media": attachment_paths,
455 }
456 )
457
458 self._remember_processed_uid(uid, dedupe, cycle_uids)
459
460 if mark_seen:
461 client.store(imap_id, "+FLAGS", "\\Seen")
462 finally:
463 try:
464 client.logout()
465 except Exception:
466 pass
467
468 def _collect_self_addresses(self) -> set[str]:
469 """Return normalized email addresses owned by this channel instance."""
470 candidates = (
471 self.config.from_address,
472 self.config.smtp_username,
473 self.config.imap_username,
474 )
475 normalized = {
476 addr
477 for candidate in candidates
478 if (addr := self._normalize_address(candidate))
479 }
480 return normalized
481
482 @staticmethod
483 def _normalize_address(value: str) -> str:
484 """Normalize an address or mailbox-like identifier for comparisons."""
485 raw = (value or "").strip()
486 if not raw:
487 return ""
488 parsed = parseaddr(raw)[1].strip().lower()
489 if parsed:
490 return parsed
491 if "@" in raw:
492 return raw.lower()
493 return ""
494
495 def _is_self_address(self, sender: str) -> bool:
496 """Return True when an inbound sender belongs to the bot itself."""
497 normalized_sender = self._normalize_address(sender)
498 return bool(normalized_sender) and normalized_sender in self._self_addresses
499
500 def _remember_processed_uid(self, uid: str, dedupe: bool, cycle_uids: set[str]) -> None:
501 """Track a fetched UID so skipped messages are not reprocessed forever."""
502 if not uid:
503 return
504 cycle_uids.add(uid)
505 if dedupe:
506 self._processed_uids.add(uid)
507 # mark_seen is the primary dedup; this set is a safety net
508 if len(self._processed_uids) > self._MAX_PROCESSED_UIDS:
509 # Evict a random half to cap memory; mark_seen is the primary dedup
510 self._processed_uids = set(list(self._processed_uids)[len(self._processed_uids) // 2:])
511
512 @classmethod
513 def _is_stale_imap_error(cls, exc: Exception) -> bool:
514 message = str(exc).lower()
515 return any(marker in message for marker in cls._IMAP_RECONNECT_MARKERS)
516
517 @classmethod
518 def _is_missing_mailbox_error(cls, exc: Exception) -> bool:
519 message = str(exc).lower()
520 return any(marker in message for marker in cls._IMAP_MISSING_MAILBOX_MARKERS)
521
522 @classmethod
523 def _format_imap_date(cls, value: date) -> str:
524 """Format date for IMAP search (always English month abbreviations)."""
525 month = cls._IMAP_MONTHS[value.month - 1]
526 return f"{value.day:02d}-{month}-{value.year}"
527
528 @staticmethod
529 def _extract_message_bytes(fetched: list[Any]) -> bytes | None:
530 for item in fetched:
531 if isinstance(item, tuple) and len(item) >= 2 and isinstance(item[1], (bytes, bytearray)):
532 return bytes(item[1])
533 return None
534
535 @staticmethod
536 def _extract_uid(fetched: list[Any]) -> str:
537 for item in fetched:
538 if isinstance(item, tuple) and item and isinstance(item[0], (bytes, bytearray)):
539 head = bytes(item[0]).decode("utf-8", errors="ignore")
540 m = re.search(r"UID\s+(\d+)", head)
541 if m:
542 return m.group(1)
543 return ""
544
545 @staticmethod
546 def _decode_header_value(value: str) -> str:
547 if not value:
548 return ""
549 try:
550 return str(make_header(decode_header(value)))
551 except Exception:
552 return value
553
554 @classmethod
555 def _extract_text_body(cls, msg: Any) -> str:
556 """Best-effort extraction of readable body text."""
557 if msg.is_multipart():
558 plain_parts: list[str] = []
559 html_parts: list[str] = []
560 for part in msg.walk():
561 if part.get_content_disposition() == "attachment":
562 continue
563 content_type = part.get_content_type()
564 try:
565 payload = part.get_content()
566 except Exception:
567 payload_bytes = part.get_payload(decode=True) or b""
568 charset = part.get_content_charset() or "utf-8"
569 payload = payload_bytes.decode(charset, errors="replace")
570 if not isinstance(payload, str):
571 continue
572 if content_type == "text/plain":
573 plain_parts.append(payload)
574 elif content_type == "text/html":
575 html_parts.append(payload)
576 if plain_parts:
577 return "\n\n".join(plain_parts).strip()
578 if html_parts:
579 return cls._html_to_text("\n\n".join(html_parts)).strip()
580 return ""
581
582 try:
583 payload = msg.get_content()
584 except Exception:
585 payload_bytes = msg.get_payload(decode=True) or b""
586 charset = msg.get_content_charset() or "utf-8"
587 payload = payload_bytes.decode(charset, errors="replace")
588 if not isinstance(payload, str):
589 return ""
590 if msg.get_content_type() == "text/html":
591 return cls._html_to_text(payload).strip()
592 return payload.strip()
593
594 @staticmethod
595 def _check_authentication_results(parsed_msg: Any) -> tuple[bool, bool]:
596 """Parse Authentication-Results headers for SPF and DKIM verdicts.
597
598 Returns:
599 A tuple of (spf_pass, dkim_pass) booleans.
600 """
601 spf_pass = False
602 dkim_pass = False
603 for ar_header in parsed_msg.get_all("Authentication-Results") or []:
604 ar_lower = ar_header.lower()
605 if re.search(r"\bspf\s*=\s*pass\b", ar_lower):
606 spf_pass = True
607 if re.search(r"\bdkim\s*=\s*pass\b", ar_lower):
608 dkim_pass = True
609 return spf_pass, dkim_pass
610
611 @classmethod
612 def _extract_attachments(
613 cls,
614 msg: Any,
615 uid: str,
616 *,
617 allowed_types: list[str],
618 max_size: int,
619 max_count: int,
620 ) -> list[Path]:
621 """Extract and save email attachments to the media directory.
622
623 Returns list of saved file paths.
624 """
625 if not msg.is_multipart():
626 return []
627
628 saved: list[Path] = []
629 media_dir = get_media_dir("email")
630
631 for part in msg.walk():
632 if len(saved) >= max_count:
633 break
634 if part.get_content_disposition() != "attachment":
635 continue
636
637 content_type = part.get_content_type()
638 if not any(fnmatch(content_type, pat) for pat in allowed_types):
639 logger.debug("Email attachment skipped (type {}): not in allowed list", content_type)
640 continue
641
642 payload = part.get_payload(decode=True)
643 if payload is None:
644 continue
645 if len(payload) > max_size:
646 logger.warning(
647 "Email attachment skipped: size {} exceeds limit {}",
648 len(payload),
649 max_size,
650 )
651 continue
652
653 raw_name = part.get_filename() or "attachment"
654 sanitized = safe_filename(raw_name) or "attachment"
655 dest = media_dir / f"{uid}_{sanitized}"
656
657 try:
658 dest.write_bytes(payload)
659 saved.append(dest)
660 logger.info("Email attachment saved: {}", dest)
661 except Exception as exc:
662 logger.warning("Failed to save email attachment {}: {}", dest, exc)
663
664 return saved
665
666 @staticmethod
667 def _html_to_text(raw_html: str) -> str:
668 text = re.sub(r"<\s*br\s*/?>", "\n", raw_html, flags=re.IGNORECASE)
669 text = re.sub(r"<\s*/\s*p\s*>", "\n", text, flags=re.IGNORECASE)
670 text = re.sub(r"<[^>]+>", "", text)
671 return html.unescape(text)
672
673 def _reply_subject(self, base_subject: str) -> str:
674 subject = (base_subject or "").strip() or "nanobot reply"
675 prefix = self.config.subject_prefix or "Re: "
676 if subject.lower().startswith("re:"):
677 return subject
678 return f"{prefix}{subject}"
679
679 lines PYTHON