Spaces:
Runtime error
Runtime error
File size: 10,364 Bytes
7e94d17 a22a774 7e94d17 a22a774 7e94d17 a22a774 7e94d17 a22a774 7e94d17 a22a774 7e94d17 a22a774 7e94d17 a22a774 7e94d17 a22a774 7e94d17 a22a774 7e94d17 a22a774 7e94d17 a22a774 7e94d17 a22a774 7e94d17 a22a774 7e94d17 a22a774 7e94d17 a22a774 7e94d17 a22a774 7e94d17 a22a774 7e94d17 a22a774 7e94d17 a22a774 7e94d17 a22a774 7e94d17 a22a774 7e94d17 a22a774 7e94d17 a22a774 7e94d17 a22a774 7e94d17 a22a774 7e94d17 a22a774 7e94d17 a22a774 7e94d17 a22a774 7e94d17 a22a774 7e94d17 a22a774 7e94d17 a22a774 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 | """
邮件解析模块 - 解析 .eml 文件,提取关键字段
支持 UTF-8、GBK、GB2312、ISO-8859-1 等编码
"""
import io
import re
import logging
import base64
from dataclasses import dataclass, field
from datetime import datetime
from email import policy
from email.header import decode_header as email_decode_header
from email.message import Message
from pathlib import Path
from typing import Optional, List
import html2text
logger = logging.getLogger(__name__)
# 常见需要解码的邮件编码列表(按优先级)
COMMON_ENCODINGS = ["utf-8", "gbk", "gb2312", "gb18030", "iso-8859-1", "windows-1252"]
@dataclass
class AttachmentInfo:
"""附件信息"""
filename: str
content_type: str
size: int # 字节数
@dataclass
class ParsedEmail:
"""解析后的邮件数据结构"""
filename: str
sender: str
sender_domain: str
recipient: str = ""
recipient_domain: str = ""
subject: str = ""
date: Optional[datetime] = None
date_str: str = ""
body_text: str = ""
body_html: str = ""
body_preview: str = "" # 前200字符,用于展示
attachments: List[AttachmentInfo] = field(default_factory=list)
raw_path: str = ""
parse_error: str = ""
@property
def is_valid(self) -> bool:
return not self.parse_error
def _decode_header_value(header_value: Optional[str]) -> str:
"""
解码邮件头字段(如 Subject、From、To)。
处理 RFC 2047 编码字,如 =?UTF-8?B?xxxxx?=
"""
if not header_value:
return ""
decoded_parts = []
for part_bytes, encoding in email_decode_header(header_value):
if isinstance(part_bytes, bytes):
decoded_parts.append(_decode_bytes(part_bytes, encoding))
else:
decoded_parts.append(str(part_bytes))
return "".join(decoded_parts).strip()
def _decode_bytes(data: bytes, suggested_encoding: Optional[str] = None) -> str:
"""
尝试用多种编码解码字节数据。
优先使用建议的编码,然后依次尝试常见编码。
"""
if not data:
return ""
# 如果指定了编码,先试它
if suggested_encoding:
try:
return data.decode(suggested_encoding, errors="strict")
except (UnicodeDecodeError, LookupError):
pass
# 依次尝试常见编码
for enc in COMMON_ENCODINGS:
try:
return data.decode(enc, errors="strict")
except (UnicodeDecodeError, LookupError):
continue
# 全部失败,用 ignore 模式强制解码
return data.decode("utf-8", errors="ignore")
def _extract_sender_domain(email_addr: str) -> str:
"""从邮箱地址提取主域名"""
match = re.search(r'@([\w.\-]+)', email_addr)
if match:
domain = match.group(1).lower()
parts = domain.split(".")
if len(parts) >= 2:
return parts[-2]
return parts[0]
return ""
def _extract_email_address(header_value: str) -> str:
"""
从邮件头(如 'Alice <alice@example.com>')中提取纯邮箱地址。
"""
if not header_value:
return ""
# 优先匹配尖括号中的地址
match = re.search(r'<([^>]+)>', header_value)
if match:
return match.group(1).lower().strip()
# 否则直接匹配邮箱格式
match = re.search(r'[\w.+\-]+@[\w.\-]+', header_value)
if match:
return match.group(0).lower()
return header_value.strip()
def _html_to_text(html_content: str) -> str:
"""将 HTML 正文转为纯文本"""
if not html_content:
return ""
try:
converter = html2text.HTML2Text()
converter.ignore_links = True
converter.ignore_images = True
converter.ignore_emphasis = False
converter.body_width = 0 # 不换行
text = converter.handle(html_content)
# 清理多余空行
lines = [line.rstrip() for line in text.splitlines()]
cleaned = "\n".join(line for line in lines if line.strip())
return cleaned
except Exception as e:
logger.warning(f"HTML 转文本失败: {e}")
# 降级:用正则去掉 HTML 标签
return re.sub(r'<[^>]+>', ' ', html_content)
def _clean_body(text: str) -> str:
"""清理邮件正文,去除多余空白"""
if not text:
return ""
text = text.replace('\r\n', '\n').replace('\r', '\n')
text = re.sub(r'\n{3,}', '\n\n', text)
return text.strip()
def _decode_part_payload(part) -> str:
"""
解码邮件 MIME 部分的 payload。
处理 base64、quoted-printable 等传输编码,
以及各类字符编码(UTF-8、GBK 等)。
"""
payload = part.get_payload(decode=True)
if not payload:
return ""
# 获取声明的字符编码
charset = part.get_content_charset() or None
return _decode_bytes(payload, charset)
def _parse_msg_common(msg: Message, filename: str) -> ParsedEmail:
"""
从已解析的 email.Message 对象中提取字段(供文件和字节流共用)。
"""
# 解码各项头字段
raw_sender = _decode_header_value(msg.get("From", ""))
raw_recipient = _decode_header_value(msg.get("To", ""))
subject = _decode_header_value(msg.get("Subject", ""))
sender = _extract_email_address(raw_sender)
recipient = _extract_email_address(raw_recipient)
sender_domain = _extract_sender_domain(sender)
recipient_domain = _extract_sender_domain(recipient)
# 日期
date_obj = None
date_str = ""
date_header = msg.get("Date", "")
if date_header:
try:
from email.utils import parsedate_to_datetime
date_obj = parsedate_to_datetime(date_header)
if date_obj:
date_str = date_obj.strftime("%Y-%m-%d")
except Exception as e:
logger.warning(f"日期解析失败 ({filename}): {e}")
# 尝试直接用原始字符串
date_str = date_header.strip()
# 正文和附件
body_text = ""
body_html = ""
attachments = []
if msg.is_multipart():
for part in msg.walk():
content_type = part.get_content_type()
content_disposition = part.get("Content-Disposition", "")
# 处理附件
if "attachment" in content_disposition or \
(content_type not in ("text/plain", "text/html") and part.get_filename()):
filename_att = _decode_header_value(part.get_filename())
if not filename_att:
filename_att = f"attachment_{len(attachments) + 1}"
payload = part.get_payload(decode=True)
attachments.append(AttachmentInfo(
filename=filename_att,
content_type=content_type,
size=len(payload) if payload else 0,
))
continue
# 处理正文
if content_type == "text/plain" and not body_text:
body_text = _decode_part_payload(part)
elif content_type == "text/html" and not body_html:
body_html = _decode_part_payload(part)
else:
# 单部分邮件
content_type = msg.get_content_type()
if content_type == "text/html":
body_html = _decode_part_payload(msg)
else:
body_text = _decode_part_payload(msg)
# 如果只有 HTML 正文,转换为纯文本
if not body_text and body_html:
body_text = _clean_body(_html_to_text(body_html))
elif body_text:
body_text = _clean_body(body_text)
# 如果两者都为空
if not body_text and not body_html:
logger.warning(f"邮件正文为空: {filename}")
body_text = "[邮件正文为空]"
body_preview = body_text[:200].replace('\n', ' ') + ("..." if len(body_text) > 200 else "")
return ParsedEmail(
filename=filename,
sender=sender,
sender_domain=sender_domain,
recipient=recipient,
recipient_domain=recipient_domain,
subject=subject,
date=date_obj,
date_str=date_str,
body_text=body_text,
body_html=body_html,
body_preview=body_preview,
attachments=attachments,
raw_path="",
)
def parse_eml_file(file_path: str) -> ParsedEmail:
"""
解析单个 .eml 文件
Args:
file_path: .eml 文件路径
Returns:
ParsedEmail 对象
"""
filename = Path(file_path).name
try:
from email import message_from_binary_file
with open(file_path, "rb") as f:
msg = message_from_binary_file(f, policy=policy.default)
result = _parse_msg_common(msg, filename)
result.raw_path = file_path
return result
except Exception as e:
logger.error(f"解析 .eml 文件失败 ({filename}): {e}", exc_info=True)
return ParsedEmail(
filename=filename,
sender="",
sender_domain="",
subject="",
body_text="",
body_preview="",
raw_path=file_path,
parse_error=str(e),
)
def parse_eml_bytes(file_bytes: bytes, filename: str) -> ParsedEmail:
"""
从字节流解析 .eml 文件(Gradio 上传的场景)
Args:
file_bytes: 文件字节内容
filename: 原始文件名
Returns:
ParsedEmail 对象
"""
try:
from email import message_from_bytes
msg = message_from_bytes(file_bytes, policy=policy.default)
result = _parse_msg_common(msg, filename)
return result
except Exception as e:
logger.error(f"解析 .eml 字节流失败 ({filename}): {e}", exc_info=True)
return ParsedEmail(
filename=filename,
sender="",
sender_domain="",
subject="",
body_text="",
body_preview="",
parse_error=str(e),
)
def batch_parse_eml_files(file_paths: list[str]):
"""批量解析多个 .eml 文件"""
success = []
failed = []
for path in file_paths:
parsed = parse_eml_file(path)
if parsed.is_valid:
success.append(parsed)
else:
failed.append(parsed)
return {"success": success, "failed": failed}
|