""" 邮件解析模块 - 解析 .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 ')中提取纯邮箱地址。 """ 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}