|
|
@@ -7,6 +7,7 @@ import email
|
|
|
import asyncio
|
|
|
import re
|
|
|
import time
|
|
|
+import base64
|
|
|
from datetime import datetime, timedelta, timezone
|
|
|
import email.policy
|
|
|
from email.message import EmailMessage
|
|
|
@@ -1009,30 +1010,70 @@ class EmailAuthorizationService:
|
|
|
|
|
|
@staticmethod
|
|
|
def _extract_body(msg, only_text: bool = True) -> str:
|
|
|
- # 纯 CPU 计算,不需要 async,保留原样
|
|
|
- body_parts = []
|
|
|
- if msg.is_multipart():
|
|
|
- for part in msg.walk():
|
|
|
- ctype = part.get_content_type()
|
|
|
- if only_text and ctype != "text/plain":
|
|
|
- continue
|
|
|
- if not only_text and ctype not in ["text/plain", "text/html"]:
|
|
|
- continue
|
|
|
- if part.get("Content-Disposition"):
|
|
|
+ text_parts = []
|
|
|
+ image_parts = []
|
|
|
+
|
|
|
+ # 统一处理 multipart 和单体邮件
|
|
|
+ parts = msg.walk() if msg.is_multipart() else [msg]
|
|
|
+
|
|
|
+ for part in parts:
|
|
|
+ # 安全获取属性,转为小写方便判断
|
|
|
+ ctype = str(part.get_content_type()).lower()
|
|
|
+ disposition = str(part.get("Content-Disposition")).lower()
|
|
|
+ content_id = str(part.get("Content-ID")).lower()
|
|
|
+ filename = str(part.get_filename() or "").lower()
|
|
|
+
|
|
|
+ # 1. 提取文字内容 (同时允许 text/plain 和 text/html,防止 VFS 这种只有 HTML 的邮件导致内容为空)
|
|
|
+ if ctype in ["text/plain", "text/html"]:
|
|
|
+ # 忽略明确被标记为真正附件的文本
|
|
|
+ if "attachment" in disposition:
|
|
|
continue
|
|
|
+
|
|
|
charset = part.get_content_charset() or "utf-8"
|
|
|
try:
|
|
|
payload = part.get_payload(decode=True)
|
|
|
if payload:
|
|
|
text = payload.decode(charset, errors="ignore")
|
|
|
- body_parts.append(text)
|
|
|
+ text_parts.append(text)
|
|
|
except Exception:
|
|
|
continue
|
|
|
- else:
|
|
|
- charset = msg.get_content_charset() or "utf-8"
|
|
|
- payload = msg.get_payload(decode=True)
|
|
|
- if payload:
|
|
|
- body_parts.append(payload.decode(charset, errors="ignore"))
|
|
|
+
|
|
|
+ # 2. 提取图片内容 (当 onlyText=False 时触发)
|
|
|
+ elif not only_text:
|
|
|
+ # 兼容 VFS 等发件系统的恶心格式:判断是否为图片
|
|
|
+ # a) 标准的 image/ 开头
|
|
|
+ # b) 带有 Content-ID (如 cid:otp_image) 且不是文字
|
|
|
+ # c) 文件名以图片格式结尾
|
|
|
+ is_image = (
|
|
|
+ ctype.startswith("image/") or
|
|
|
+ (content_id and not ctype.startswith("text/")) or
|
|
|
+ filename.endswith(('.png', '.jpg', '.jpeg', '.gif', '.bmp'))
|
|
|
+ )
|
|
|
+
|
|
|
+ if is_image:
|
|
|
+ try:
|
|
|
+ payload = part.get_payload(decode=True)
|
|
|
+ if payload:
|
|
|
+ # 如果 ctype 不规范 (比如 application/octet-stream),强制转为 image/png 供大模型使用
|
|
|
+ img_type = ctype if ctype.startswith("image/") else "image/png"
|
|
|
+
|
|
|
+ b64_image = base64.b64encode(payload).decode("utf-8")
|
|
|
+ image_data_uri = f"data:{img_type};base64,{b64_image}"
|
|
|
+ image_parts.append(image_data_uri)
|
|
|
+ except Exception:
|
|
|
+ continue
|
|
|
+
|
|
|
+ # 3. 处理文本部分(保留你的原始清理逻辑)
|
|
|
+ raw_text = "\n".join(text_parts)
|
|
|
+ cleaned_text = re.sub(r"\s+", " ", raw_text.strip())
|
|
|
+
|
|
|
+ # 4. 合并文本和图片,用换行符切分
|
|
|
+ final_parts = []
|
|
|
+ if cleaned_text:
|
|
|
+ final_parts.append(cleaned_text)
|
|
|
|
|
|
- body = "\n".join(body_parts)
|
|
|
- return re.sub(r"\s+", " ", body.strip())
|
|
|
+ if image_parts:
|
|
|
+ final_parts.extend(image_parts)
|
|
|
+
|
|
|
+ # 最终返回一个字符串:文本在前,图片数据在后
|
|
|
+ return "\n".join(final_parts)
|