File size: 6,349 Bytes
4242667
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
邮件解析模块 - 解析 .msg 文件,提取关键字段
"""
import io
import re
import logging
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Optional

import extract_msg
import html2text

logger = logging.getLogger(__name__)


@dataclass
class ParsedEmail:
    """解析后的邮件数据结构"""
    filename: str
    sender: str
    sender_domain: str
    date: Optional[datetime]
    date_str: str
    subject: str
    body_text: str
    body_preview: str  # 前200字符,用于展示
    raw_path: str = ""
    parse_error: str = ""

    @property
    def is_valid(self) -> bool:
        return not self.parse_error


@dataclass
class EmailParseResult:
    """批量解析结果"""
    success: list = field(default_factory=list)
    failed: list = field(default_factory=list)

    @property
    def total(self) -> int:
        return len(self.success) + len(self.failed)


def _extract_sender_domain(sender: str) -> str:
    """从发件人邮箱地址提取域名"""
    match = re.search(r'@([\w.\-]+)', sender)
    if match:
        domain = match.group(1).lower()
        # 去掉顶级域名,取主域名(如 homelight.com -> homelight)
        parts = domain.split(".")
        if len(parts) >= 2:
            return parts[-2]
        return parts[0]
    return ""


def _html_to_text(html_content: str) -> str:
    """将 HTML 正文转为纯文本"""
    converter = html2text.HTML2Text()
    converter.ignore_links = True
    converter.ignore_images = True
    converter.ignore_emphasis = False
    converter.body_width = 0  # 不换行
    try:
        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')
    # 去除连续3个以上空行
    text = re.sub(r'\n{3,}', '\n\n', text)
    return text.strip()


def parse_msg_file(file_path: str) -> ParsedEmail:
    """
    解析单个 .msg 文件

    Args:
        file_path: .msg 文件路径(或字节数据文件路径)

    Returns:
        ParsedEmail 对象
    """
    filename = Path(file_path).name

    try:
        msg = extract_msg.Message(file_path)

        # 发件人
        sender_raw = msg.sender or ""
        # 尝试提取纯邮箱地址
        email_match = re.search(r'[\w.+\-]+@[\w.\-]+', sender_raw)
        sender = email_match.group(0).lower() if email_match else sender_raw.strip()
        sender_domain = _extract_sender_domain(sender)

        # 日期
        date_obj = None
        date_str = ""
        try:
            if msg.date:
                if isinstance(msg.date, datetime):
                    date_obj = msg.date
                else:
                    from dateutil import parser as dateutil_parser
                    date_obj = dateutil_parser.parse(str(msg.date))
                date_str = date_obj.strftime("%Y-%m-%d")
        except Exception as e:
            logger.warning(f"日期解析失败 ({filename}): {e}")
            date_str = str(msg.date) if msg.date else ""

        # 主题
        subject = (msg.subject or "").strip()

        # 正文:优先使用纯文本,否则转换 HTML
        body_text = ""
        if msg.body:
            body_text = _clean_body(msg.body)

        if not body_text and msg.htmlBody:
            html_content = msg.htmlBody
            if isinstance(html_content, bytes):
                html_content = html_content.decode("utf-8", errors="ignore")
            body_text = _clean_body(_html_to_text(html_content))

        # 若两者都为空,记录警告
        if not body_text:
            logger.warning(f"邮件正文为空: {filename}")
            body_text = "[邮件正文为空]"

        # 预览(前200字符)
        body_preview = body_text[:200].replace('\n', ' ') + ("..." if len(body_text) > 200 else "")

        msg.close()

        return ParsedEmail(
            filename=filename,
            sender=sender,
            sender_domain=sender_domain,
            date=date_obj,
            date_str=date_str,
            subject=subject,
            body_text=body_text,
            body_preview=body_preview,
            raw_path=file_path,
        )

    except Exception as e:
        logger.error(f"解析 .msg 文件失败 ({filename}): {e}", exc_info=True)
        return ParsedEmail(
            filename=filename,
            sender="",
            sender_domain="",
            date=None,
            date_str="",
            subject="",
            body_text="",
            body_preview="",
            raw_path=file_path,
            parse_error=str(e),
        )


def parse_msg_bytes(file_bytes: bytes, filename: str) -> ParsedEmail:
    """
    从字节流解析 .msg 文件(Gradio 上传的场景)

    Args:
        file_bytes: 文件字节内容
        filename: 原始文件名

    Returns:
        ParsedEmail 对象
    """
    import tempfile
    import os

    # 写入临时文件(extract_msg 需要文件路径)
    with tempfile.NamedTemporaryFile(suffix=".msg", delete=False) as tmp:
        tmp.write(file_bytes)
        tmp_path = tmp.name

    try:
        result = parse_msg_file(tmp_path)
        result.filename = filename  # 恢复原始文件名
        return result
    finally:
        try:
            os.unlink(tmp_path)
        except Exception:
            pass


def batch_parse_msg_files(file_paths: list[str]) -> EmailParseResult:
    """
    批量解析多个 .msg 文件

    Args:
        file_paths: .msg 文件路径列表

    Returns:
        EmailParseResult 对象(含成功/失败列表)
    """
    result = EmailParseResult()
    for path in file_paths:
        parsed = parse_msg_file(path)
        if parsed.is_valid:
            result.success.append(parsed)
        else:
            result.failed.append(parsed)
    return result