refactor: migrate workflow to v2 core and archive legacy modules

This commit is contained in:
2026-03-04 21:52:24 +08:00
parent e1ce17f2aa
commit fa61b11b02
156 changed files with 1781 additions and 2066 deletions

View File

@@ -0,0 +1,331 @@
"""
邮件接收模块 - 监控收件箱,客户发图询价/下单自动处理
流程:
客户发邮件(含图片附件)→ 自动分析图片复杂度 → 回复报价
客户回复"拍了"/"确认" → 创建处理任务 → Gemini 作图 → 发结果
"""
import asyncio
import imaplib
import email
import email.header
import os
import tempfile
import logging
from datetime import datetime
from email.header import decode_header
from typing import Optional
logger = logging.getLogger(__name__)
# 支持的图片格式
IMAGE_EXTS = (".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp")
def _decode_str(value: str) -> str:
"""解码邮件头部字段(处理中文编码)"""
if not value:
return ""
parts = decode_header(value)
result = []
for part, charset in parts:
if isinstance(part, bytes):
try:
result.append(part.decode(charset or "utf-8", errors="replace"))
except Exception:
result.append(part.decode("utf-8", errors="replace"))
else:
result.append(part)
return "".join(result)
class EmailReceiver:
"""IMAP 邮件接收器,轮询新邮件并自动处理图片询价"""
def __init__(
self,
imap_host: str = "imap.qq.com",
imap_port: int = 993,
username: str = "",
password: str = "",
poll_interval: int = 30,
):
self.imap_host = imap_host
self.imap_port = imap_port
self.username = username
self.password = password
self.poll_interval = poll_interval
self._running = False
self._send_reply = None # 注入的回复函数
def register_reply_callback(self, callback):
"""注入回复函数(直接用 email_sender 回复)"""
self._send_reply = callback
# ========== 主循环 ==========
async def start(self):
"""启动轮询(作为后台任务运行)"""
self._running = True
logger.info(f"[EmailReceiver] 启动,每 {self.poll_interval}s 检查一次收件箱")
while self._running:
try:
await self._check_inbox()
except Exception as e:
logger.error(f"[EmailReceiver] 轮询异常: {e}")
await asyncio.sleep(self.poll_interval)
def stop(self):
self._running = False
# ========== 收件箱检查 ==========
async def _check_inbox(self):
"""连接 IMAP检查未读邮件"""
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, self._check_inbox_sync)
def _check_inbox_sync(self):
"""同步版收件箱检查(在线程池里跑,避免阻塞事件循环)"""
try:
conn = imaplib.IMAP4_SSL(self.imap_host, self.imap_port)
conn.login(self.username, self.password)
conn.select("INBOX")
# 搜索未读邮件
_, msg_ids = conn.search(None, "UNSEEN")
ids = msg_ids[0].split()
if not ids:
conn.logout()
return
logger.info(f"[EmailReceiver] 发现 {len(ids)} 封未读邮件")
for msg_id in ids:
try:
_, data = conn.fetch(msg_id, "(RFC822)")
raw = data[0][1]
msg = email.message_from_bytes(raw)
self._process_email_sync(msg)
# 标记为已读
conn.store(msg_id, "+FLAGS", "\\Seen")
except Exception as e:
logger.error(f"[EmailReceiver] 处理邮件 {msg_id} 失败: {e}")
conn.logout()
except Exception as e:
logger.error(f"[EmailReceiver] IMAP 连接失败: {e}")
# ========== 邮件处理 ==========
def _process_email_sync(self, msg):
"""处理单封邮件:提取发件人、附件图片,触发分析和回复"""
sender = _decode_str(msg.get("From", ""))
subject = _decode_str(msg.get("Subject", "(无主题)"))
# 提取发件人邮箱地址
sender_email = self._extract_email_addr(sender)
if not sender_email:
logger.warning(f"[EmailReceiver] 无法解析发件人地址: {sender}")
return
logger.info(f"[EmailReceiver] 处理邮件 | 来自: {sender_email} | 主题: {subject}")
# 提取正文
body_text = self._extract_body(msg)
# 提取图片附件
image_paths = self._extract_images(msg)
# 异步触发处理(把同步上下文切回事件循环)
loop = asyncio.new_event_loop()
try:
loop.run_until_complete(
self._handle_email(sender_email, subject, body_text, image_paths)
)
finally:
loop.close()
# 清理临时图片
for p in image_paths:
try:
os.remove(p)
except Exception:
pass
async def _handle_email(
self,
sender_email: str,
subject: str,
body: str,
image_paths: list,
):
"""根据邮件内容决定如何处理"""
body_lower = (body or "").lower()
# ① 有图片附件 → 分析图片,回复报价
if image_paths:
await self._handle_image_inquiry(sender_email, subject, image_paths)
return
# ② 纯文字邮件 → 引导发图
await self._reply_email(
to=sender_email,
subject=f"Re: {subject}",
body=self._html(
"您好!收到您的邮件。<br><br>"
"请将您需要处理的图片作为<b>附件</b>发送过来,我们会尽快为您报价。<br><br>"
"支持格式JPG、PNG、WEBP 等常见图片格式。"
),
)
async def _handle_image_inquiry(
self, sender_email: str, subject: str, image_paths: list
):
"""分析图片,回复报价"""
from image.image_analyzer import image_analyzer
quotes = []
for idx, img_path in enumerate(image_paths, 1):
try:
# image_analyzer 支持本地路径
result = await image_analyzer.analyze(img_path)
price = result.get("price_suggest", 30)
reason = result.get("reason", "")
label = {
"simple": "画面简洁",
"normal": "一般复杂度",
"complex": "细节较多",
"hard": "非常复杂",
}.get(result.get("complexity", ""), "")
quotes.append(
f"图片{idx}{label},建议报价 <b>{price} 元</b>"
+ (f"{reason}" if reason else "")
)
except Exception as e:
logger.error(f"[EmailReceiver] 图片分析失败: {e}")
quotes.append(f"图片{idx}:分析失败,建议报价 30 元")
# 多图打包优惠
n = len(image_paths)
if n >= 5:
tip = f"<br><br>📦 您共发来 <b>{n} 张</b>图片,支持打包优惠,欢迎咨询。"
elif n >= 3:
tip = f"<br><br>📦 您共发来 <b>{n} 张</b>图片3张以上可享9折优惠。"
else:
tip = ""
quote_html = "<br>".join(quotes)
body = self._html(
f"您好!感谢您发来图片,已为您完成分析:<br><br>"
f"{quote_html}{tip}<br><br>"
f"如需处理,请直接在淘宝店铺下单,付款后我们会尽快为您完成制作并发回。<br>"
f"如有疑问欢迎回复此邮件。"
)
await self._reply_email(
to=sender_email,
subject=f"Re: {subject}" if subject else "您的图片报价",
body=body,
)
logger.info(f"[EmailReceiver] 已向 {sender_email} 回复报价")
# ========== 工具方法 ==========
async def _reply_email(self, to: str, subject: str, body: str):
"""发送回复邮件"""
try:
from mail.email_sender import email_sender
result = email_sender.send(to_email=to, subject=subject, body=body)
if not result.get("success"):
logger.error(f"[EmailReceiver] 回复发送失败: {result.get('message')}")
except Exception as e:
logger.error(f"[EmailReceiver] 回复异常: {e}")
def _extract_email_addr(self, from_field: str) -> Optional[str]:
"""从 From 字段提取邮箱地址"""
import re
m = re.search(r'[\w\.\+\-]+@[\w\.\-]+\.\w+', from_field)
return m.group(0) if m else None
def _extract_body(self, msg) -> str:
"""提取邮件纯文本正文"""
body = ""
if msg.is_multipart():
for part in msg.walk():
ct = part.get_content_type()
if ct == "text/plain":
charset = part.get_content_charset() or "utf-8"
try:
body += part.get_payload(decode=True).decode(charset, errors="replace")
except Exception:
pass
else:
charset = msg.get_content_charset() or "utf-8"
try:
body = msg.get_payload(decode=True).decode(charset, errors="replace")
except Exception:
pass
return body.strip()
def _extract_images(self, msg) -> list:
"""提取邮件中的图片附件,保存到临时文件,返回路径列表"""
paths = []
for part in msg.walk():
content_disposition = part.get("Content-Disposition", "")
content_type = part.get_content_type()
is_attachment = "attachment" in content_disposition
is_image_type = content_type.startswith("image/")
filename = part.get_filename()
if filename:
filename = _decode_str(filename)
# 判断是否是图片
if not (is_image_type or (filename and any(
filename.lower().endswith(ext) for ext in IMAGE_EXTS
))):
continue
try:
data = part.get_payload(decode=True)
if not data:
continue
suffix = ".jpg"
if filename:
ext = os.path.splitext(filename)[1].lower()
if ext in IMAGE_EXTS:
suffix = ext
fd, tmp_path = tempfile.mkstemp(suffix=suffix, prefix="email_img_")
with os.fdopen(fd, "wb") as f:
f.write(data)
paths.append(tmp_path)
logger.info(f"[EmailReceiver] 提取图片附件: {filename}{tmp_path}")
except Exception as e:
logger.error(f"[EmailReceiver] 提取附件失败: {e}")
return paths
@staticmethod
def _html(content: str) -> str:
return f"""
<html><body style="font-family:Arial,sans-serif;font-size:14px;color:#333">
{content}
<br><br>
<hr style="border:none;border-top:1px solid #eee">
<p style="color:#999;font-size:12px">修图客服 · 自动回复</p>
</body></html>
"""
# ========== 全局实例(从 .env 读取配置)==========
from dotenv import load_dotenv
load_dotenv()
email_receiver = EmailReceiver(
imap_host="imap.qq.com",
imap_port=993,
username=os.getenv("SMTP_USER", ""),
password=os.getenv("SMTP_PASSWORD", ""),
poll_interval=int(os.getenv("EMAIL_POLL_INTERVAL", "30")),
)