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}