Files
tw/legacy/mail/email_receiver.py

332 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
邮件接收模块 - 监控收件箱,客户发图询价/下单自动处理
流程:
客户发邮件(含图片附件)→ 自动分析图片复杂度 → 回复报价
客户回复"拍了"/"确认" → 创建处理任务 → 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")),
)