Files
EvoScientist-Multi/EvoScientist/channels/email/channel.py
T
Faych 9bd7a37a77 fix: _extract_retry_after returns None for non-retryable errors (#394)
* fix: _extract_retry_after returns None for non-retryable errors

* fix: update _extract_retry_after to handle generic transient errors with default retry delay

* fix: extend base _non_retryable_patterns in channel subclasses

* fix(channels): merge structured SDK error check into non-retryable step

* fix(channels): decouple status code and SDK error code extraction in retry logic

- Independently evaluate HTTP status codes and structured SDK error codes
- Fix misleading doc comments for Feishu and DingTalk patterns
- Remove redundant try-except AttributeError on getattr with default
- Expand test coverage for dual-signal matrix and header parsing

* refactor(channels): simplify status code and SDK error extraction via channel overrides

- Handle httpx and aiohttp exceptions in base Channel class
- Override _extract_status_code and _extract_sdk_error_code in SlackChannel and DiscordChannel
- Replace mock exception types in comprehensive test suite with real httpx and aiohttp errors
- Add dedicated Slack and Discord retry error extraction test suites

* fix(channels): clean up Slack and Discord error code extraction

- Remove defensive string checks and attribute guards in SlackChannel
- Directly access exc.response.status_code and exc.response.get('error') in SlackChannel
- Remove unnecessary _extract_sdk_error_code override in DiscordChannel
- Use real SlackApiError, SlackResponse, and discord.HTTPException in unit tests

* refactor: reorder retry logic to prioritize non-retryable checks, remove aiohttp dependency, and clean up exception handling in base and channel modules.

* test(channels): skip Slack/Discord retry tests when the SDK extra is absent

The retry-extraction tests build real SlackApiError / discord.HTTPException
objects, but slack-sdk and discord.py are optional extras that the dev
dependency group does not install. Under CI's `uv sync --dev` all nine
tests failed with ModuleNotFoundError raised from the channel override.

Gate both test classes with skipif(find_spec(...) is None) so the suite is
green without the extras and the tests still run wherever they are installed.

* refactor(channels): replace retry-delay lookup with _extract_retry_delay

_extract_retry_after still read the server-supplied delay by probing
exc.retry_after and exc.response.headers via getattr/hasattr, the last
remnant of the pattern the extractors moved away from. Replace both steps
with one overridable hook, _extract_retry_delay, implemented against the
real exception types:

- base: httpx.HTTPStatusError -> Retry-After header (httpx.Headers is
  case-insensitive; HTTP-date form remains unsupported)
- SlackChannel: SlackApiError -> Retry-After, matched case-insensitively
  because SlackResponse.headers is a plain dict whose casing depends on the
  HTTP client (same approach as slack_sdk's RateLimitErrorRetryHandler)
- TelegramChannel: telegram.error.RetryAfter.retry_after (int, or timedelta
  under PTB_TIMEDELTA)
- DiscordChannel: discord.RateLimited.retry_after, which the old duck-typed
  getattr matched and would otherwise have been lost

Drop the isinstance(retry, bool) and val >= 0 guards; no SDK produces those.
Delete the test that asserted the duck-typed attribute; add real-object tests
for each override, guarded like the existing SDK-dependent classes.

* ci: install the all-channels extra so SDK-dependent channel tests run

The Slack, Discord, and Telegram retry tests build real SDK exception
objects and are skipped when the SDK is absent. CI only ran `uv sync --dev`,
so those tests never executed there. Install the existing all-channels
extra alongside the dev group; the skipif guards remain for lean local runs.

* fix(channels): honor HTTP-date Retry-After and tolerate malformed values

RFC 9110 allows Retry-After as either delay-seconds or an HTTP-date. The
httpx path treated a date as unparseable and fell back to the 1.0 s default,
so a 503 asking for a specific wait was retried too early. Add
Channel._parse_retry_after, which returns delay-seconds as-is and converts
an HTTP-date to the non-negative seconds until it (tz-less dates read as
UTC).

SlackChannel used a bare float() on the header. A non-numeric value raised
inside the retry predicate, which escapes retry_async and drops the chunk
instead of retrying. Route Slack through the same helper so a bad header
falls back to _rate_limit_delay.

Addresses CodeRabbit review comments on base.py:866 and slack/channel.py:229.

* fix(channels): treat HTTP 400 and 404 as non-retryable

Both are permanent for a given request, so retrying burns the attempt
budget for nothing. Add them to _non_retryable_status_codes alongside
401/403.

Deliberately not a 4xx range check: 408 and 425 are retryable by
definition and 429 is handled by the rate-limit path. A test pins 408 as
still retryable so the range shortcut is not reintroduced later.

Partially addresses CodeRabbit's outside-diff comment on base.py:749-750.

* fix(channels): guard Slack retry extractors against raw aiohttp responses

slack_sdk attaches the bare aiohttp.ClientResponse to SlackApiError when a
JSON-declared body fails to parse. That object has neither status_code nor
get(), so _extract_status_code raised AttributeError inside should_retry,
replacing the original error and skipping the remaining attempts. Narrow
both extractors to SlackResponse/AsyncSlackResponse so such errors fall
through to the message patterns and retry as before. Add a wire-level
regression test against a local aiohttp server.

---------

Co-authored-by: Dinos Papakostas <dinospk1999@gmail.com>
Co-authored-by: X-iZhang <zacharyzhang2022@gmail.com>
2026-09-10 22:25:12 +00:00

460 lines
17 KiB
Python

"""Email channel implementation using IMAP + SMTP."""
import asyncio
import contextlib
import email as email_lib
import email.utils
import html
import imaplib
import logging
import re
import smtplib
import ssl
from dataclasses import dataclass
from datetime import datetime
from email import encoders
from email.header import decode_header, make_header
from email.message import EmailMessage
from email.mime.base import MIMEBase
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.utils import parseaddr
from pathlib import Path
from ..base import Channel, ChannelError, RawIncoming
from ..capabilities import EMAIL as EMAIL_CAPS
from ..config import BaseChannelConfig
from ..mixins import PollingMixin
logger = logging.getLogger(__name__)
def _decode_hdr(raw: str) -> str:
try:
return str(make_header(decode_header(raw))) if raw else ""
except Exception:
return raw or ""
def _strip_html(text: str) -> str:
text = re.sub(r"<br\s*/?>", "\n", text, flags=re.IGNORECASE)
text = re.sub(r"<p[^>]*>", "\n", text, flags=re.IGNORECASE)
text = re.sub(r"</p>", "\n", text, flags=re.IGNORECASE)
text = re.sub(r"<[^>]+>", "", text)
return html.unescape(text).strip()
@dataclass
class EmailConfig(BaseChannelConfig):
imap_host: str = ""
imap_port: int = 993
imap_username: str = ""
imap_password: str = ""
imap_mailbox: str = "INBOX"
imap_use_ssl: bool = True
smtp_host: str = ""
smtp_port: int = 587
smtp_username: str = ""
smtp_password: str = ""
smtp_starttls: bool = (
True # True=STARTTLS (port 587), False=implicit SSL (port 465)
)
from_address: str = ""
poll_interval: int = 30
mark_seen: bool = True
max_body_chars: int = 12000
subject_prefix: str = "Re: "
text_chunk_limit: int = 4096
class EmailChannel(Channel, PollingMixin):
"""Email channel using IMAP polling + SMTP."""
name = "email"
capabilities = EMAIL_CAPS
_non_retryable_patterns = (
*Channel._non_retryable_patterns,
"auth",
"login",
"credential",
)
def __init__(self, config: EmailConfig):
super().__init__(config)
self._imap: imaplib.IMAP4_SSL | imaplib.IMAP4 | None = None
async def start(self) -> None:
cfg = self.config
if not cfg.imap_host or not cfg.imap_username:
raise ChannelError("Email imap_host and imap_username are required")
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, self._connect_imap)
self._running = True
logger.info(
f"Email channel started (IMAP: {cfg.imap_host}, poll {cfg.poll_interval}s)"
)
await self._start_polling()
async def _cleanup(self) -> None:
await self._stop_polling()
if self._imap:
try:
self._imap.close()
self._imap.logout()
except Exception:
pass
self._imap = None
logger.info("Email channel stopped")
def _connect_imap(self) -> None:
cfg = self.config
try:
if cfg.imap_use_ssl:
self._imap = imaplib.IMAP4_SSL(
cfg.imap_host,
cfg.imap_port,
ssl_context=ssl.create_default_context(),
)
else:
self._imap = imaplib.IMAP4(cfg.imap_host, cfg.imap_port)
self._imap.login(cfg.imap_username, cfg.imap_password)
self._imap.select(cfg.imap_mailbox)
except Exception as e:
raise ChannelError(f"IMAP failed: {e}") from e
def _reconnect_imap(self) -> None:
try:
if self._imap:
self._imap.noop()
return
except Exception:
pass
self._connect_imap()
async def _poll_once(self) -> None:
loop = asyncio.get_running_loop()
messages = await loop.run_in_executor(None, self._fetch_unseen)
for m in messages:
await self._process_email(m)
def _fetch_unseen(self) -> list[dict]:
self._reconnect_imap()
results = []
try:
st, data = self._imap.search(None, "UNSEEN")
if st != "OK":
return []
for mid in data[0].split()[-20:]:
st, msg_data = self._imap.fetch(mid, "(RFC822)")
if st != "OK":
continue
msg = email_lib.message_from_bytes(msg_data[0][1])
from_name, from_addr = parseaddr(msg.get("From", ""))
body = self._extract_body(msg)
if len(body) > self.config.max_body_chars:
body = body[: self.config.max_body_chars] + "\n[...truncated]"
# Extract attachments and inline images
attachments = []
if msg.is_multipart():
for part in msg.walk():
content_disp = part.get("Content-Disposition") or ""
content_type = part.get_content_type() or ""
is_attachment = "attachment" in content_disp.lower()
is_inline_image = (
"inline" in content_disp.lower()
and content_type.startswith("image/")
)
# Also detect non-text parts with a filename but no
# Content-Disposition header (common for PDFs, docs,
# etc. sent by some email clients).
is_named_file = (
not is_attachment
and not is_inline_image
and part.get_filename()
and not content_type.startswith("multipart/")
and not content_type.startswith("text/")
)
if is_attachment or is_inline_image or is_named_file:
filename = part.get_filename() or "attachment"
filename = _decode_hdr(filename)
payload_data = part.get_payload(decode=True)
if payload_data:
from ..base import MAX_ATTACHMENT_BYTES, MEDIA_DIR
if len(payload_data) > MAX_ATTACHMENT_BYTES:
attachments.append(
{
"annotation": f"[attachment: {filename} - too large ({len(payload_data)} bytes)]"
}
)
else:
MEDIA_DIR.mkdir(parents=True, exist_ok=True)
local_path = (
MEDIA_DIR / f"email_{mid.decode()}_{filename}"
)
local_path.write_bytes(payload_data)
label = (
"inline-image"
if is_inline_image
else "attachment"
)
attachments.append(
{
"path": str(local_path),
"annotation": f"[{label}: {local_path}]",
}
)
if self.config.mark_seen:
self._imap.store(mid, "+FLAGS", "\\Seen")
results.append(
{
"from_addr": from_addr,
"from_name": _decode_hdr(from_name),
"subject": _decode_hdr(msg.get("Subject", "")),
"body": body,
"message_id": msg.get("Message-ID", ""),
"date": msg.get("Date", ""),
"references": msg.get("References", ""),
"attachments": attachments,
}
)
except Exception as e:
logger.error(f"IMAP fetch: {e}")
return results
def _extract_body(self, msg) -> str:
if msg.is_multipart():
for part in msg.walk():
ct = part.get_content_type()
if ct == "text/plain":
return self._decode_payload(part)
for part in msg.walk():
if part.get_content_type() == "text/html":
return _strip_html(self._decode_payload(part))
return "[no text content]"
text = self._decode_payload(msg)
return _strip_html(text) if msg.get_content_type() == "text/html" else text
@staticmethod
def _decode_payload(part) -> str:
payload = part.get_payload(decode=True)
if not payload:
return ""
charset = part.get_content_charset() or "utf-8"
return payload.decode(charset, errors="replace")
async def _process_email(self, m: dict) -> None:
subject = m["subject"]
text = f"[邮件] 主题: {subject}\n\n{m['body']}" if subject else m["body"]
try:
ts = email_lib.utils.parsedate_to_datetime(m["date"])
except Exception:
ts = datetime.now()
# Process attachments
media_paths: list[str] = []
annotations: list[str] = []
for att in m.get("attachments", []):
if att.get("path"):
media_paths.append(att["path"])
if att.get("annotation"):
annotations.append(att["annotation"])
await self._enqueue_raw(
RawIncoming(
sender_id=m["from_addr"],
chat_id=m["from_addr"],
text=text,
timestamp=ts,
message_id=m["message_id"],
media_files=media_paths,
content_annotations=annotations,
metadata={
"chat_id": m["from_addr"],
"subject": subject,
"original_message_id": m["message_id"],
"references": m["references"],
"backend": "email",
},
)
)
# ── Send ──────────────────────────────────────────────────────
def _is_ready(self) -> bool:
return bool(self.config.smtp_host)
@contextlib.contextmanager
def _smtp_connect(self):
"""Open an SMTP connection as a context manager.
Ensures the connection is closed even if login or send raises.
Uses STARTTLS (port 587) when smtp_starttls=True, otherwise
implicit SSL (port 465).
"""
cfg = self.config
srv = None
try:
if cfg.smtp_starttls:
srv = smtplib.SMTP(cfg.smtp_host, cfg.smtp_port, timeout=30)
srv.starttls()
else:
srv = smtplib.SMTP_SSL(
cfg.smtp_host,
cfg.smtp_port,
context=ssl.create_default_context(),
timeout=30,
)
srv.login(cfg.smtp_username, cfg.smtp_password)
yield srv
finally:
if srv is not None:
try:
srv.quit()
except Exception:
try:
srv.close()
except Exception:
pass
async def _send_chunk(self, chat_id, formatted_text, raw_text, reply_to, metadata):
loop = asyncio.get_running_loop()
try:
await loop.run_in_executor(
None,
self._smtp_send_html,
chat_id,
formatted_text,
raw_text,
metadata or {},
)
except Exception as e:
err_str = str(e).lower()
# Only fall back to plain text for format-related errors, not server rejections
if any(
code in err_str for code in ("550", "553", "554", "auth", "rejected")
):
raise
logger.warning(f"HTML email failed ({e}), falling back to plain text")
await loop.run_in_executor(
None,
self._smtp_send,
chat_id,
raw_text,
metadata or {},
)
def _smtp_send(self, to: str, content: str, meta: dict) -> None:
cfg = self.config
from_addr = cfg.from_address or cfg.smtp_username
logger.debug(f"SMTP plain send: from={from_addr} to={to}")
msg = EmailMessage()
orig_subj = meta.get("subject", "")
msg["Subject"] = (
f"{cfg.subject_prefix}{orig_subj}"
if orig_subj and not orig_subj.lower().startswith("re:")
else (orig_subj or "EvoScientist Reply")
)
msg["From"] = from_addr
msg["To"] = to
orig_id = meta.get("original_message_id", "")
if orig_id:
msg["In-Reply-To"] = orig_id
msg["References"] = f"{meta.get('references', '')} {orig_id}".strip()
msg.set_content(content)
try:
with self._smtp_connect() as srv:
srv.sendmail(from_addr, [to], msg.as_string())
except Exception as e:
logger.error(f"SMTP send failed: from={from_addr} to={to}")
raise RuntimeError("SMTP send failed") from e
def _smtp_send_html(
self, to: str, html_content: str, plain_content: str, meta: dict
) -> None:
"""Send an email with both HTML and plain-text parts."""
cfg = self.config
from_addr = cfg.from_address or cfg.smtp_username
logger.debug(f"SMTP HTML send: from={from_addr} to={to}")
msg = MIMEMultipart("alternative")
orig_subj = meta.get("subject", "")
msg["Subject"] = (
f"{cfg.subject_prefix}{orig_subj}"
if orig_subj and not orig_subj.lower().startswith("re:")
else (orig_subj or "EvoScientist Reply")
)
msg["From"] = from_addr
msg["To"] = to
orig_id = meta.get("original_message_id", "")
if orig_id:
msg["In-Reply-To"] = orig_id
msg["References"] = f"{meta.get('references', '')} {orig_id}".strip()
msg.attach(MIMEText(plain_content, "plain", "utf-8"))
msg.attach(MIMEText(html_content, "html", "utf-8"))
try:
with self._smtp_connect() as srv:
srv.sendmail(from_addr, [to], msg.as_string())
except Exception as e:
logger.error(f"SMTP HTML send failed: from={from_addr} to={to}")
raise RuntimeError("SMTP HTML send failed") from e
# ── Media send (email attachment) ─────────────────────────────
async def _send_media_impl(
self,
recipient: str,
file_path: str,
caption: str = "",
metadata: dict | None = None,
) -> bool:
"""Send a file as an email attachment via SMTP."""
loop = asyncio.get_running_loop()
await loop.run_in_executor(
None,
self._smtp_send_attachment,
recipient,
file_path,
caption,
metadata or {},
)
return True
def _smtp_send_attachment(
self, to: str, file_path: str, caption: str, meta: dict
) -> None:
"""Send an email with a file attachment."""
cfg = self.config
from_addr = cfg.from_address or cfg.smtp_username
logger.debug(f"SMTP attachment send: from={from_addr} to={to} file={file_path}")
msg = MIMEMultipart()
orig_subj = meta.get("subject", "")
msg["Subject"] = (
f"{cfg.subject_prefix}{orig_subj}"
if orig_subj and not orig_subj.lower().startswith("re:")
else (orig_subj or "EvoScientist Reply")
)
msg["From"] = from_addr
msg["To"] = to
orig_id = meta.get("original_message_id", "")
if orig_id:
msg["In-Reply-To"] = orig_id
msg["References"] = f"{meta.get('references', '')} {orig_id}".strip()
# Text body
if caption:
msg.attach(MIMEText(caption, "plain", "utf-8"))
# Attachment
path = Path(file_path)
part = MIMEBase("application", "octet-stream")
part.set_payload(path.read_bytes())
encoders.encode_base64(part)
part.add_header("Content-Disposition", f"attachment; filename={path.name}")
msg.attach(part)
try:
with self._smtp_connect() as srv:
srv.sendmail(from_addr, [to], msg.as_string())
except Exception as e:
logger.error(f"SMTP attachment send failed: from={from_addr} to={to}")
raise RuntimeError("SMTP attachment send failed") from e