ForcePilot/backend/package/yuxi/channel/extensions/email_smtp/plugin.py
Kris 59c6caaa64 feat(email-smtp): 新增SMTP/IMAP邮件渠道插件
实现完整的邮件收发渠道,支持IMAP IDLE实时收信、SMTP发信,包含附件校验、重复消息去重、OAuth2认证、邮件内容解析与引用剥离、邮件发送限流与连接池等功能
2026-05-21 10:45:56 +08:00

274 lines
9.3 KiB
Python

from __future__ import annotations
import asyncio
import logging
from yuxi.channel.capabilities import ChannelCapabilities
from yuxi.channel.extensions.base import BaseChannelPlugin
from yuxi.channel.protocols import SessionResolution
from .config import EmailSmtpConfigAdapter
from .dedupe import EmailDeduplicator
from .format import build_email_system_prompt
from .gateway import EmailSmtpGatewayAdapter
from .outbound import EmailSmtpOutboundAdapter
from .pairing import EmailSmtpPairingAdapter
from .security import EmailSmtpSecurityAdapter
from .session import EmailSmtpSessionAdapter
from .status import EmailSmtpStatusAdapter
from .streaming import EmailSmtpStreamingAdapter
logger = logging.getLogger(__name__)
class EmailSmtpPlugin(BaseChannelPlugin):
id = "email-smtp"
name = "\u90ae\u4ef6 (SMTP/IMAP)"
order = 80
label = "\u90ae\u4ef6"
aliases = ["email", "smtp", "imap", "mail"]
def __init__(self):
self._config = EmailSmtpConfigAdapter()
self._outbound = EmailSmtpOutboundAdapter()
self._dedupe = EmailDeduplicator()
self._gateway = EmailSmtpGatewayAdapter(self._outbound, dedupe=self._dedupe)
self._security = EmailSmtpSecurityAdapter()
self._pairing = EmailSmtpPairingAdapter()
self._session = EmailSmtpSessionAdapter()
self._streaming = EmailSmtpStreamingAdapter(self._outbound)
self._status = EmailSmtpStatusAdapter()
@property
def capabilities(self) -> ChannelCapabilities:
return ChannelCapabilities(
chat_types=["direct"],
message_types=["text"],
reactions=False,
typing_indicator=False,
threads=True,
edit=False,
unsend=False,
reply=True,
media=True,
native_commands=False,
polls=False,
streaming=True,
streaming_mode="block",
block_streaming=True,
block_streaming_chunk_min_chars=800,
block_streaming_chunk_max_chars=1200,
block_streaming_chunk_break_preference="paragraph",
)
def list_account_ids(self, config: dict) -> list[str]:
return self._config.list_account_ids(config)
async def resolve_account(self, account_id: str) -> dict:
return self._config.resolve_account(account_id)
def is_configured(self, account: dict) -> bool:
return self._config.is_configured(account)
def is_enabled(self, account: dict) -> bool:
return self._config.is_enabled(account)
def disabled_reason(self, account: dict) -> str:
return self._config.disabled_reason(account)
def describe_account(self, account: dict) -> dict:
return self._config.describe_account(account)
async def start(self, ctx) -> object:
return await self._gateway.start(ctx)
async def stop(self, ctx) -> None:
await self._gateway.stop(ctx)
async def send_text(
self,
target_id: str,
content: str,
*,
reply_to_id: str | None = None,
thread_id: str | None = None,
account_id: str | None = None,
cc_list: list[str] | None = None,
bcc_list: list[str] | None = None,
) -> None:
await self._outbound.send_text(
target_id,
content,
reply_to_id=reply_to_id,
thread_id=thread_id,
account_id=account_id,
cc_list=cc_list,
bcc_list=bcc_list,
)
async def send_media(
self,
target_id: str,
media_url: str,
media_type: str,
reply_to_id: str | None = None,
thread_id: str | None = None,
account_id: str | None = None,
) -> None:
await self._outbound.send_media(
target_id,
media_url,
media_type,
reply_to_id=reply_to_id,
thread_id=thread_id,
account_id=account_id,
)
async def probe(self, account: dict) -> bool:
return await self._status.probe(account)
def build_summary(self, snapshot: object) -> dict:
return self._status.build_summary(snapshot)
async def check_allowlist(self, peer_id: str, channel_type: str) -> bool:
return self._security.check_allowlist(peer_id)
def resolve_dm_policy(self) -> dict:
return self._security.resolve_dm_policy()
async def generate_code(self, peer_id: str) -> str:
return self._pairing.generate_code(peer_id)
async def verify_code(self, peer_id: str, code: str) -> bool:
return self._pairing.verify_code(peer_id, code)
def normalize_allow_entry(self, entry: str) -> str:
return self._pairing.normalize_allow_entry(entry)
def is_duplicate(self, key: str) -> bool:
return self._dedupe.is_duplicate(key)
def mark_seen(self, key: str) -> None:
self._dedupe.mark(key)
def build_system_prompt(self, context) -> str | None:
return build_email_system_prompt()
def build_context_note(self, context) -> str:
return ""
def extract_thread_id(self, msg: object) -> str | None:
if hasattr(msg, "message_thread_id"):
return msg.message_thread_id
return None
def resolve_session(self, msg: object) -> SessionResolution:
from yuxi.channel.message.models import PeerKind
if hasattr(msg, "sender") and hasattr(msg.sender, "kind"):
if msg.sender.kind == PeerKind.DIRECT:
session = self._session.resolve_session(msg)
return SessionResolution(
kind="direct",
conversation_id=msg.sender.id,
thread_id=session.thread_id,
label=session.label,
)
gid = msg.group.id if hasattr(msg, "group") and msg.group and msg.group.id else "unknown"
return SessionResolution(kind="group", conversation_id=gid)
@property
def streaming_mode(self) -> str:
return self._streaming.streaming_mode
@property
def preview_stream_throttle_ms(self) -> int:
return self._streaming.preview_stream_throttle_ms
@property
def block_streaming_enabled(self) -> bool:
return self._streaming.block_streaming_enabled
@property
def block_streaming_chunk_min_chars(self) -> int:
return self._streaming.block_streaming_chunk_min_chars
@property
def block_streaming_chunk_max_chars(self) -> int:
return self._streaming.block_streaming_chunk_max_chars
@property
def block_streaming_chunk_break_preference(self) -> str:
return self._streaming.block_streaming_chunk_break_preference
@property
def block_streaming_coalesce_defaults(self) -> dict | None:
return self._streaming.block_streaming_coalesce_defaults
def create_block_chunker(self) -> object:
return self._streaming.create_block_chunker()
def create_draft_stream_session(self, target_id: str) -> object:
return self._streaming.create_draft_stream_session(target_id)
@property
def channel_format_instructions(self) -> str | None:
return (
"You are sending email replies via SMTP. "
"Use plain paragraphs separated by blank lines. "
"Use **bold** for emphasis. Do not use Markdown tables or code blocks. "
"Each reply should be concise and focused on the user's question. "
"Do not include greetings like 'Hello' or 'Dear' at the start. "
"Sign-off is optional - the system will add its own footer."
)
@property
def ping_interval_ms(self) -> int:
return 5 * 60 * 1000
async def on_ping(self, account: dict) -> bool:
try:
imap_client = self._gateway.get_imap_client(account.get("account_id", "default"))
if imap_client and imap_client._mailbox:
await asyncio.to_thread(imap_client._mailbox.noop)
return True
except Exception:
return False
async def on_config_changed(self, prev_cfg: dict, next_cfg: dict, account_id: str) -> None:
client = self._gateway.get_imap_client(account_id)
if client:
await client.disconnect()
async def on_account_removed(self, account_id: str) -> None:
client = self._gateway.get_imap_client(account_id)
if client:
await client.disconnect()
async def on_message_received(self, msg: object) -> object | None:
return msg
async def on_message_sending(self, msg: object, content: str) -> str | None:
logger.debug("Sending to %s: %s...", getattr(msg, "target_id", "unknown"), content[:100])
return content
async def before_send_attempt(self, target_id: str, content: str) -> None:
logger.debug("Before send attempt to %s", target_id)
async def after_send_success(self, target_id: str, receipt: object) -> None:
logger.info("Sent successfully to %s", target_id)
async def after_send_failure(self, target_id: str, error: str) -> None:
logger.error("Send failed to %s: %s", target_id, error)
def config_schema(self) -> dict:
return {
"type": "object",
"properties": {
"accounts": {
"type": "object",
"description": "\u591a\u8d26\u6237\u914d\u7f6e",
},
},
}