feat(email): add attachment extraction support

Save inbound email attachments to the media directory with configurable
MIME type filtering (glob patterns like "image/*"), per-attachment size
limits, and max attachment count. Filenames are sanitized to prevent
path traversal. Controlled by allowed_attachment_types — empty (default)
means disabled, non-empty enables extraction for matching types.
This commit is contained in:
Ben Lenarts
2026-04-06 15:09:44 +08:00
committed by Xubin Ren
parent 9174a85b4e
commit d0527a8cf4
2 changed files with 303 additions and 0 deletions
+81
View File
@@ -1,6 +1,7 @@
"""Email channel implementation using IMAP polling + SMTP replies."""
import asyncio
from fnmatch import fnmatch
import html
import imaplib
import re
@@ -14,13 +15,17 @@ from email.parser import BytesParser
from email.utils import parseaddr
from typing import Any
from pathlib import Path
from loguru import logger
from pydantic import Field
from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.config.paths import get_media_dir
from nanobot.config.schema import Base
from nanobot.utils.helpers import safe_filename
class EmailConfig(Base):
@@ -55,6 +60,11 @@ class EmailConfig(Base):
verify_dkim: bool = True # Require Authentication-Results with dkim=pass
verify_spf: bool = True # Require Authentication-Results with spf=pass
# Attachment handling — set allowed types to enable (e.g. ["application/pdf", "image/*"], or ["*"] for all)
allowed_attachment_types: list[str] = Field(default_factory=list)
max_attachment_size: int = 2_000_000 # 2MB per attachment
max_attachments_per_email: int = 5
class EmailChannel(BaseChannel):
"""
@@ -153,6 +163,7 @@ class EmailChannel(BaseChannel):
sender_id=sender,
chat_id=sender,
content=item["content"],
media=item.get("media") or None,
metadata=item.get("metadata", {}),
)
except Exception as e:
@@ -404,6 +415,20 @@ class EmailChannel(BaseChannel):
f"{body}"
)
# --- Attachment extraction ---
attachment_paths: list[str] = []
if self.config.allowed_attachment_types:
saved = self._extract_attachments(
parsed,
uid or "noid",
allowed_types=self.config.allowed_attachment_types,
max_size=self.config.max_attachment_size,
max_count=self.config.max_attachments_per_email,
)
for p in saved:
attachment_paths.append(str(p))
content += f"\n[attachment: {p.name} — saved to {p}]"
metadata = {
"message_id": message_id,
"subject": subject,
@@ -418,6 +443,7 @@ class EmailChannel(BaseChannel):
"message_id": message_id,
"content": content,
"metadata": metadata,
"media": attachment_paths,
}
)
@@ -537,6 +563,61 @@ class EmailChannel(BaseChannel):
dkim_pass = True
return spf_pass, dkim_pass
@classmethod
def _extract_attachments(
cls,
msg: Any,
uid: str,
*,
allowed_types: list[str],
max_size: int,
max_count: int,
) -> list[Path]:
"""Extract and save email attachments to the media directory.
Returns list of saved file paths.
"""
if not msg.is_multipart():
return []
saved: list[Path] = []
media_dir = get_media_dir("email")
for part in msg.walk():
if len(saved) >= max_count:
break
if part.get_content_disposition() != "attachment":
continue
content_type = part.get_content_type()
if not any(fnmatch(content_type, pat) for pat in allowed_types):
logger.debug("Email attachment skipped (type {}): not in allowed list", content_type)
continue
payload = part.get_payload(decode=True)
if payload is None:
continue
if len(payload) > max_size:
logger.warning(
"Email attachment skipped: size {} exceeds limit {}",
len(payload),
max_size,
)
continue
raw_name = part.get_filename() or "attachment"
sanitized = safe_filename(raw_name) or "attachment"
dest = media_dir / f"{uid}_{sanitized}"
try:
dest.write_bytes(payload)
saved.append(dest)
logger.info("Email attachment saved: {}", dest)
except Exception as exc:
logger.warning("Failed to save email attachment {}: {}", dest, exc)
return saved
@staticmethod
def _html_to_text(raw_html: str) -> str:
text = re.sub(r"<\s*br\s*/?>", "\n", raw_html, flags=re.IGNORECASE)