yach-mail-tool 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- yach_mail_tool-0.1.0/.cursor/mcp.json +15 -0
- yach_mail_tool-0.1.0/.env.example +7 -0
- yach_mail_tool-0.1.0/.gitignore +8 -0
- yach_mail_tool-0.1.0/PKG-INFO +12 -0
- yach_mail_tool-0.1.0/README.md +84 -0
- yach_mail_tool-0.1.0/mailtool/__init__.py +1 -0
- yach_mail_tool-0.1.0/mailtool/__main__.py +4 -0
- yach_mail_tool-0.1.0/mailtool/cli.py +38 -0
- yach_mail_tool-0.1.0/mailtool/config.py +77 -0
- yach_mail_tool-0.1.0/mailtool/hosts.py +16 -0
- yach_mail_tool-0.1.0/mailtool/install_mcp.py +68 -0
- yach_mail_tool-0.1.0/mailtool/mailbox.py +249 -0
- yach_mail_tool-0.1.0/mailtool/parse.py +189 -0
- yach_mail_tool-0.1.0/mailtool/secrets.py +118 -0
- yach_mail_tool-0.1.0/mailtool/server.py +153 -0
- yach_mail_tool-0.1.0/mailtool/setup_server.py +182 -0
- yach_mail_tool-0.1.0/netease-mail-mcp.spec +35 -0
- yach_mail_tool-0.1.0/pyproject.toml +27 -0
- yach_mail_tool-0.1.0/tests/test_mailbox.py +66 -0
- yach_mail_tool-0.1.0/tests/test_parse.py +91 -0
- yach_mail_tool-0.1.0/tests/test_product.py +94 -0
- yach_mail_tool-0.1.0/uv.lock +1324 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: yach-mail-tool
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: 只读查询网易邮箱的 MCP 服务
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: imapclient>=3.0.1
|
|
7
|
+
Requires-Dist: keyring>=25
|
|
8
|
+
Requires-Dist: mcp>=2
|
|
9
|
+
Provides-Extra: bundle
|
|
10
|
+
Requires-Dist: pyinstaller>=6; extra == 'bundle'
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# 网易邮箱 MCP
|
|
2
|
+
|
|
3
|
+
本地只读查询网易邮箱。发给别人的是编好的 `yach-mail-tool` 程序,不是这份源码。Cursor、Claude Desktop、Claude Code、VS Code 以及其他能配置 MCP 的客户端都可以用。
|
|
4
|
+
|
|
5
|
+
程序通过标准输入输出提供 MCP 工具。邮箱和客户端授权码在本机浏览器页面里填写,优先写入系统钥匙串。Linux 上如果没有钥匙串服务,则写到 `~/.config/netease-mail-mcp/mailbox.json`,权限为仅本人可读。授权码不会出现在工具参数或对话里。
|
|
6
|
+
|
|
7
|
+
`@163.com`、`@126.com`、`@yeah.net` 使用对应的个人邮箱服务器,其他域名使用企业邮杭州机房 `imaphz.qiye.163.com`。
|
|
8
|
+
|
|
9
|
+
## 打包
|
|
10
|
+
|
|
11
|
+
在目标系统上打包。macOS 打出来的程序只能在 macOS 使用,Linux 同理。
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
uv sync --extra bundle
|
|
15
|
+
uv run pyinstaller netease-mail-mcp.spec
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
产物是 `dist/yach-mail-tool`。把这个文件交给用户即可。
|
|
19
|
+
|
|
20
|
+
## 发给 Agent 的安装话术
|
|
21
|
+
|
|
22
|
+
把程序放到本机后,把下面这段原样发给要安装的 Agent。它会找自己的 MCP 配置并写入,不依赖 Cursor、Claude 或 VS Code 的固定路径。
|
|
23
|
+
|
|
24
|
+
```text
|
|
25
|
+
请在你自己的 MCP 配置里安装本地服务 netease-mail,保留已有的其他服务。
|
|
26
|
+
|
|
27
|
+
1. 在本机找到可执行文件 yach-mail-tool 的绝对路径。找不到就停下来告诉我,不要改用别的邮箱工具。
|
|
28
|
+
2. 找到你这个客户端实际读取的 MCP 配置文件并合并进去,不要覆盖整个文件。配置为:
|
|
29
|
+
名称:netease-mail
|
|
30
|
+
command:上一步得到的绝对路径
|
|
31
|
+
不需要 args,不需要 env。
|
|
32
|
+
3. 如果配置分成「命令」和「参数」两栏,命令填绝对路径,参数留空。
|
|
33
|
+
4. 写完后重载 MCP,确认 netease-mail 已出现。
|
|
34
|
+
5. 不要向我索要邮箱或授权码,也不要在命令参数里填写它们。第一次查信时,程序会打开本机浏览器让我填写。
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## 用户安装
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
chmod +x yach-mail-tool
|
|
41
|
+
mv yach-mail-tool /usr/local/bin/
|
|
42
|
+
yach-mail-tool install
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`install` 会写入:
|
|
46
|
+
|
|
47
|
+
| 系统 | 客户端 | 配置文件 |
|
|
48
|
+
|---|---|---|
|
|
49
|
+
| macOS / Linux | Cursor | `~/.cursor/mcp.json` |
|
|
50
|
+
| macOS | Claude Desktop | `~/Library/Application Support/Claude/claude_desktop_config.json` |
|
|
51
|
+
| Linux | Claude Desktop | `~/.config/Claude/claude_desktop_config.json` |
|
|
52
|
+
| macOS / Linux | Claude Code | `~/.claude.json` |
|
|
53
|
+
| macOS | VS Code | `~/Library/Application Support/Code/User/mcp.json` |
|
|
54
|
+
| Linux | VS Code | `~/.config/Code/User/mcp.json` |
|
|
55
|
+
|
|
56
|
+
已有的其他 MCP 配置会保留。某个文件不是合法 JSON 时会跳过,避免把原文件盖坏。
|
|
57
|
+
|
|
58
|
+
其他客户端执行 `yach-mail-tool config`,把打印出的配置贴进自己的 MCP 设置。命令必须是程序的绝对路径。
|
|
59
|
+
|
|
60
|
+
写好后重载该客户端的 MCP。
|
|
61
|
+
|
|
62
|
+
## 第一次查信
|
|
63
|
+
|
|
64
|
+
对话里直接说「看看最近的邮件」。如果这台电脑还没配置过,程序会打开浏览器。页面只监听 `127.0.0.1`,链接带一次性口令。
|
|
65
|
+
|
|
66
|
+
用户填写:
|
|
67
|
+
|
|
68
|
+
- 邮箱地址
|
|
69
|
+
- 网页邮箱「设置 → 账户与安全 → 客户端设置」生成的授权码
|
|
70
|
+
|
|
71
|
+
授权码不是登录密码。保存后关掉页面,回到对话即可。更换授权码时说「重新配置邮箱」,Agent 会调用 `configure_mailbox` 再次打开页面。
|
|
72
|
+
|
|
73
|
+
## 工具
|
|
74
|
+
|
|
75
|
+
| 工具 | 作用 |
|
|
76
|
+
|---|---|
|
|
77
|
+
| `mailbox_status` | 是否已配置,只返回邮箱地址 |
|
|
78
|
+
| `configure_mailbox` | 打开本机浏览器填写授权码 |
|
|
79
|
+
| `list_mail_folders` | 列出文件夹 |
|
|
80
|
+
| `list_messages` | 最近邮件 |
|
|
81
|
+
| `search_messages` | 按发件人、收件人、主题、日期搜索信头 |
|
|
82
|
+
| `get_mail_message` | 按 uid 读纯文本正文和附件名 |
|
|
83
|
+
|
|
84
|
+
读信不会把邮件标成已读,也不能发信、删除或改状态。
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""网易企业邮箱只读 MCP。"""
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from mailtool.install_mcp import install_clients, mcp_entry
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main() -> None:
|
|
10
|
+
action = sys.argv[1] if len(sys.argv) > 1 else ""
|
|
11
|
+
if action in {"", "serve"}:
|
|
12
|
+
from mailtool.server import main as serve
|
|
13
|
+
|
|
14
|
+
serve()
|
|
15
|
+
return
|
|
16
|
+
if action == "setup":
|
|
17
|
+
from mailtool.setup_server import run_browser_setup
|
|
18
|
+
|
|
19
|
+
settings = run_browser_setup()
|
|
20
|
+
print(f"已保存 {settings.user},服务器 {settings.host}")
|
|
21
|
+
return
|
|
22
|
+
if action == "install":
|
|
23
|
+
paths, skipped = install_clients()
|
|
24
|
+
print("已写入这些 MCP 配置,重载对应客户端后即可使用:")
|
|
25
|
+
for path in paths:
|
|
26
|
+
print(path)
|
|
27
|
+
for message in skipped:
|
|
28
|
+
print(message, file=sys.stderr)
|
|
29
|
+
return
|
|
30
|
+
if action == "config":
|
|
31
|
+
print(json.dumps({"mcpServers": {"netease-mail": mcp_entry()}}, ensure_ascii=False, indent=2))
|
|
32
|
+
return
|
|
33
|
+
print("用法: yach-mail-tool [serve|setup|install|config]", file=sys.stderr)
|
|
34
|
+
raise SystemExit(2)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
if __name__ == "__main__":
|
|
38
|
+
main()
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from mailtool.hosts import DEFAULT_PORT, host_for_email
|
|
9
|
+
from mailtool.secrets import load_mailbox
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class MissingCredentials(RuntimeError):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class Settings:
|
|
18
|
+
host: str
|
|
19
|
+
port: int
|
|
20
|
+
user: str
|
|
21
|
+
auth_code: str
|
|
22
|
+
timeout: float
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def load_dotenv() -> None:
|
|
26
|
+
"""读取 .env,不覆盖已经存在的环境变量。设置了 MAILTOOL_ENV 时只读那一个文件。"""
|
|
27
|
+
explicit = os.environ.get("MAILTOOL_ENV", "").strip()
|
|
28
|
+
if explicit:
|
|
29
|
+
path = Path(explicit)
|
|
30
|
+
if path.is_file():
|
|
31
|
+
_apply_env_file(path)
|
|
32
|
+
return
|
|
33
|
+
for path in (Path.cwd() / ".env", Path(__file__).resolve().parents[1] / ".env"):
|
|
34
|
+
if path.is_file():
|
|
35
|
+
_apply_env_file(path)
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def load_settings() -> Settings:
|
|
40
|
+
if not getattr(sys, "frozen", False):
|
|
41
|
+
load_dotenv()
|
|
42
|
+
user = os.environ.get("MAIL_USER", "").strip()
|
|
43
|
+
auth_code = os.environ.get("MAIL_AUTH_CODE", "").strip()
|
|
44
|
+
if user and auth_code:
|
|
45
|
+
host = os.environ.get("MAIL_IMAP_HOST", "").strip() or host_for_email(user)
|
|
46
|
+
port = _parse_port(os.environ.get("MAIL_IMAP_PORT", str(DEFAULT_PORT)))
|
|
47
|
+
return Settings(host=host, port=port, user=user, auth_code=auth_code, timeout=30)
|
|
48
|
+
stored = load_mailbox()
|
|
49
|
+
if stored is None:
|
|
50
|
+
raise MissingCredentials(
|
|
51
|
+
"还没有配置邮箱。请在本机浏览器页面填写邮箱和客户端授权码,不要把授权码发到对话里。"
|
|
52
|
+
)
|
|
53
|
+
return Settings(host=stored.host, port=stored.port, user=stored.user, auth_code=stored.auth_code, timeout=30)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _apply_env_file(path: Path) -> None:
|
|
57
|
+
for raw in path.read_text(encoding="utf-8").splitlines():
|
|
58
|
+
line = raw.strip()
|
|
59
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
60
|
+
continue
|
|
61
|
+
key, value = line.split("=", 1)
|
|
62
|
+
key = key.strip()
|
|
63
|
+
value = value.strip()
|
|
64
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
|
|
65
|
+
value = value[1:-1]
|
|
66
|
+
if key and key not in os.environ:
|
|
67
|
+
os.environ[key] = value
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _parse_port(raw: str) -> int:
|
|
71
|
+
try:
|
|
72
|
+
port = int(raw)
|
|
73
|
+
except ValueError as exc:
|
|
74
|
+
raise RuntimeError("MAIL_IMAP_PORT 必须是数字") from exc
|
|
75
|
+
if not 1 <= port <= 65535:
|
|
76
|
+
raise RuntimeError("MAIL_IMAP_PORT 超出范围")
|
|
77
|
+
return port
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
DEFAULT_HOST = "imaphz.qiye.163.com"
|
|
4
|
+
DEFAULT_PORT = 993
|
|
5
|
+
|
|
6
|
+
_PERSONAL_HOSTS = {
|
|
7
|
+
"163.com": "imap.163.com",
|
|
8
|
+
"126.com": "imap.126.com",
|
|
9
|
+
"yeah.net": "imap.yeah.net",
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def host_for_email(email: str) -> str:
|
|
14
|
+
"""个人邮箱按域名选服务器,其余域名走网易企业邮杭州机房。"""
|
|
15
|
+
domain = email.strip().rsplit("@", 1)[-1].lower()
|
|
16
|
+
return _PERSONAL_HOSTS.get(domain, DEFAULT_HOST)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def client_config_paths(home: Path | None = None) -> dict[str, Path]:
|
|
9
|
+
root = home or Path.home()
|
|
10
|
+
if sys.platform == "darwin":
|
|
11
|
+
return {
|
|
12
|
+
"cursor": root / ".cursor" / "mcp.json",
|
|
13
|
+
"claude-desktop": root / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json",
|
|
14
|
+
"claude-code": root / ".claude.json",
|
|
15
|
+
"vscode": root / "Library" / "Application Support" / "Code" / "User" / "mcp.json",
|
|
16
|
+
}
|
|
17
|
+
return {
|
|
18
|
+
"cursor": root / ".cursor" / "mcp.json",
|
|
19
|
+
"claude-desktop": root / ".config" / "Claude" / "claude_desktop_config.json",
|
|
20
|
+
"claude-code": root / ".claude.json",
|
|
21
|
+
"vscode": root / ".config" / "Code" / "User" / "mcp.json",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def current_command() -> list[str]:
|
|
26
|
+
if getattr(sys, "frozen", False):
|
|
27
|
+
return [sys.executable]
|
|
28
|
+
return [sys.executable, "-m", "mailtool"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def mcp_entry(command: list[str] | None = None) -> dict:
|
|
32
|
+
parts = command or current_command()
|
|
33
|
+
entry = {"command": parts[0]}
|
|
34
|
+
if len(parts) > 1:
|
|
35
|
+
entry["args"] = parts[1:]
|
|
36
|
+
return entry
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def merge_mcp_config(existing: dict, command: list[str] | None = None) -> dict:
|
|
40
|
+
merged = dict(existing)
|
|
41
|
+
servers = dict(merged.get("mcpServers") or {})
|
|
42
|
+
servers["netease-mail"] = mcp_entry(command)
|
|
43
|
+
merged["mcpServers"] = servers
|
|
44
|
+
return merged
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def install_clients(home: Path | None = None, command: list[str] | None = None) -> tuple[list[Path], list[str]]:
|
|
48
|
+
written: list[Path] = []
|
|
49
|
+
skipped: list[str] = []
|
|
50
|
+
for path in client_config_paths(home).values():
|
|
51
|
+
existing: dict = {}
|
|
52
|
+
if path.is_file():
|
|
53
|
+
try:
|
|
54
|
+
loaded = json.loads(path.read_text(encoding="utf-8"))
|
|
55
|
+
except json.JSONDecodeError:
|
|
56
|
+
skipped.append(f"{path} 无法解析,已跳过")
|
|
57
|
+
continue
|
|
58
|
+
if not isinstance(loaded, dict):
|
|
59
|
+
skipped.append(f"{path} 不是 JSON 对象,已跳过")
|
|
60
|
+
continue
|
|
61
|
+
existing = loaded
|
|
62
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
path.write_text(
|
|
64
|
+
json.dumps(merge_mcp_config(existing, command), ensure_ascii=False, indent=2) + "\n",
|
|
65
|
+
encoding="utf-8",
|
|
66
|
+
)
|
|
67
|
+
written.append(path)
|
|
68
|
+
return written, skipped
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from contextlib import contextmanager
|
|
4
|
+
from typing import Iterator
|
|
5
|
+
|
|
6
|
+
from imapclient import IMAPClient
|
|
7
|
+
from imapclient.exceptions import CapabilityError, IMAPClientError
|
|
8
|
+
|
|
9
|
+
from mailtool.config import Settings, load_settings
|
|
10
|
+
from mailtool.parse import (
|
|
11
|
+
DEFAULT_BODY_CHARS,
|
|
12
|
+
MAX_MESSAGE_BYTES,
|
|
13
|
+
clamp_body_chars,
|
|
14
|
+
clamp_limit,
|
|
15
|
+
decode_mime_header,
|
|
16
|
+
envelope_date,
|
|
17
|
+
format_addresses,
|
|
18
|
+
parse_message,
|
|
19
|
+
to_imap_date,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class MailError(RuntimeError):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def friendly_imap_error(exc: Exception) -> str:
|
|
28
|
+
text = str(exc)
|
|
29
|
+
upper = text.upper()
|
|
30
|
+
if "PERMDENY" in upper:
|
|
31
|
+
return "IMAP 被拒绝。请在网页邮箱开启 IMAP,或请管理员允许这个账号使用标准协议。"
|
|
32
|
+
if "AUTHENTICATIONFAILED" in upper or "LOGIN FAIL" in upper or "INVALID" in upper:
|
|
33
|
+
return "登录失败。请确认邮箱地址和客户端授权码,授权码不是网页登录密码。"
|
|
34
|
+
return f"邮箱连接失败:{text}"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@contextmanager
|
|
38
|
+
def connect(settings: Settings | None = None) -> Iterator[IMAPClient]:
|
|
39
|
+
cfg = settings or load_settings()
|
|
40
|
+
client = IMAPClient(cfg.host, port=cfg.port, ssl=True, timeout=cfg.timeout)
|
|
41
|
+
try:
|
|
42
|
+
client.login(cfg.user, cfg.auth_code)
|
|
43
|
+
_identify(client)
|
|
44
|
+
except (IMAPClientError, OSError) as exc:
|
|
45
|
+
client.shutdown()
|
|
46
|
+
raise MailError(friendly_imap_error(exc)) from exc
|
|
47
|
+
try:
|
|
48
|
+
yield client
|
|
49
|
+
finally:
|
|
50
|
+
try:
|
|
51
|
+
client.logout()
|
|
52
|
+
except Exception:
|
|
53
|
+
client.shutdown()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _identify(client: IMAPClient) -> None:
|
|
57
|
+
"""网易邮箱要求登录后发送 IMAP ID,否则 EXAMINE/SELECT 会返回 Unsafe Login。"""
|
|
58
|
+
params = {"name": "MailTool", "version": "0.1.0", "vendor": "MailTool"}
|
|
59
|
+
try:
|
|
60
|
+
if not client.has_capability("ID"):
|
|
61
|
+
raise CapabilityError("ID")
|
|
62
|
+
client.id_(params)
|
|
63
|
+
except CapabilityError:
|
|
64
|
+
_identify_raw(client, params)
|
|
65
|
+
except IMAPClientError as exc:
|
|
66
|
+
raise MailError(f"发送 IMAP ID 失败:{exc}") from exc
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _identify_raw(client: IMAPClient, params: dict[str, str]) -> None:
|
|
70
|
+
import itertools
|
|
71
|
+
|
|
72
|
+
from imapclient.imapclient import _quote, seq_to_parenstr
|
|
73
|
+
|
|
74
|
+
args = seq_to_parenstr(_quote(value) for value in itertools.chain.from_iterable(params.items()))
|
|
75
|
+
typ, data = client._imap._simple_command("ID", args)
|
|
76
|
+
client._checkok("id", typ, data)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def list_folders(client: IMAPClient) -> dict:
|
|
80
|
+
folders = []
|
|
81
|
+
for flags, _delimiter, name in client.list_folders():
|
|
82
|
+
folders.append(
|
|
83
|
+
{
|
|
84
|
+
"name": name,
|
|
85
|
+
"flags": [_as_text(flag) for flag in flags],
|
|
86
|
+
}
|
|
87
|
+
)
|
|
88
|
+
return {"folders": folders}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def list_messages(
|
|
92
|
+
client: IMAPClient,
|
|
93
|
+
*,
|
|
94
|
+
folder: str = "INBOX",
|
|
95
|
+
from_addr: str | None = None,
|
|
96
|
+
to_addr: str | None = None,
|
|
97
|
+
subject: str | None = None,
|
|
98
|
+
since: str | None = None,
|
|
99
|
+
before: str | None = None,
|
|
100
|
+
unseen_only: bool = False,
|
|
101
|
+
limit: int = 20,
|
|
102
|
+
) -> dict:
|
|
103
|
+
_select(client, folder)
|
|
104
|
+
criteria = build_criteria(
|
|
105
|
+
from_addr=from_addr,
|
|
106
|
+
to_addr=to_addr,
|
|
107
|
+
subject=subject,
|
|
108
|
+
since=since,
|
|
109
|
+
before=before,
|
|
110
|
+
unseen_only=unseen_only,
|
|
111
|
+
)
|
|
112
|
+
try:
|
|
113
|
+
uids = client.search(criteria, charset="UTF-8")
|
|
114
|
+
except (IMAPClientError, OSError) as exc:
|
|
115
|
+
raise MailError(f"搜索失败:{exc}") from exc
|
|
116
|
+
limit = clamp_limit(limit)
|
|
117
|
+
chosen = list(reversed(sorted(uids)))[:limit]
|
|
118
|
+
messages = []
|
|
119
|
+
if chosen:
|
|
120
|
+
fetched = client.fetch(chosen, ["ENVELOPE", "FLAGS", "RFC822.SIZE"])
|
|
121
|
+
for uid in chosen:
|
|
122
|
+
item = fetched.get(uid)
|
|
123
|
+
if item:
|
|
124
|
+
messages.append(_summary(uid, item))
|
|
125
|
+
return {"folder": folder, "matched": len(uids), "returned": len(messages), "messages": messages}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def get_message(
|
|
129
|
+
client: IMAPClient,
|
|
130
|
+
*,
|
|
131
|
+
folder: str,
|
|
132
|
+
uid: str,
|
|
133
|
+
max_chars: int = DEFAULT_BODY_CHARS,
|
|
134
|
+
) -> dict:
|
|
135
|
+
numeric = _parse_uid(uid)
|
|
136
|
+
_select(client, folder)
|
|
137
|
+
meta = client.fetch([numeric], ["ENVELOPE", "FLAGS", "RFC822.SIZE"])
|
|
138
|
+
item = meta.get(numeric)
|
|
139
|
+
if not item:
|
|
140
|
+
raise MailError(f"文件夹 {folder} 里没有 UID {uid} 的邮件")
|
|
141
|
+
summary = _summary(numeric, item)
|
|
142
|
+
size = int(item.get(b"RFC822.SIZE") or 0)
|
|
143
|
+
max_chars = clamp_body_chars(max_chars)
|
|
144
|
+
if size > MAX_MESSAGE_BYTES:
|
|
145
|
+
summary.update(
|
|
146
|
+
{
|
|
147
|
+
"body_text": "",
|
|
148
|
+
"truncated": True,
|
|
149
|
+
"attachments": [],
|
|
150
|
+
"note": f"邮件大小 {size} 字节,超过读取上限,只返回信头。",
|
|
151
|
+
}
|
|
152
|
+
)
|
|
153
|
+
return summary
|
|
154
|
+
fetched = client.fetch([numeric], ["BODY.PEEK[]"])
|
|
155
|
+
raw = _body_bytes(fetched.get(numeric, {}))
|
|
156
|
+
if not raw:
|
|
157
|
+
summary.update({"body_text": "", "truncated": False, "attachments": []})
|
|
158
|
+
return summary
|
|
159
|
+
parsed = parse_message(raw, max_chars)
|
|
160
|
+
summary.update(parsed)
|
|
161
|
+
return summary
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def build_criteria(
|
|
165
|
+
*,
|
|
166
|
+
from_addr: str | None,
|
|
167
|
+
to_addr: str | None,
|
|
168
|
+
subject: str | None,
|
|
169
|
+
since: str | None,
|
|
170
|
+
before: str | None,
|
|
171
|
+
unseen_only: bool,
|
|
172
|
+
) -> list[str]:
|
|
173
|
+
criteria: list[str] = ["UNSEEN"] if unseen_only else ["ALL"]
|
|
174
|
+
if _clean(from_addr):
|
|
175
|
+
criteria.extend(["FROM", _clean(from_addr)])
|
|
176
|
+
if _clean(to_addr):
|
|
177
|
+
criteria.extend(["TO", _clean(to_addr)])
|
|
178
|
+
if _clean(subject):
|
|
179
|
+
criteria.extend(["SUBJECT", _clean(subject)])
|
|
180
|
+
if _clean(since):
|
|
181
|
+
criteria.extend(["SINCE", to_imap_date(_clean(since))])
|
|
182
|
+
if _clean(before):
|
|
183
|
+
criteria.extend(["BEFORE", to_imap_date(_clean(before))])
|
|
184
|
+
return criteria
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _select(client: IMAPClient, folder: str) -> None:
|
|
188
|
+
name = folder.strip() or "INBOX"
|
|
189
|
+
if "\n" in name or "\r" in name or len(name) > 200:
|
|
190
|
+
raise MailError("文件夹名称不合法")
|
|
191
|
+
try:
|
|
192
|
+
client.select_folder(name, readonly=True)
|
|
193
|
+
except (IMAPClientError, OSError) as exc:
|
|
194
|
+
raise MailError(f"打不开文件夹 {name}:{exc}") from exc
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _summary(uid: int, item: dict) -> dict:
|
|
198
|
+
envelope = item.get(b"ENVELOPE")
|
|
199
|
+
flags = item.get(b"FLAGS") or ()
|
|
200
|
+
flag_text = {_as_text(flag) for flag in flags}
|
|
201
|
+
subject = ""
|
|
202
|
+
from_text = ""
|
|
203
|
+
to_text = ""
|
|
204
|
+
cc_text = ""
|
|
205
|
+
date_text = ""
|
|
206
|
+
if envelope is not None:
|
|
207
|
+
subject = decode_mime_header(_as_text(getattr(envelope, "subject", "")))
|
|
208
|
+
from_text = format_addresses(getattr(envelope, "from_", None))
|
|
209
|
+
to_text = format_addresses(getattr(envelope, "to", None))
|
|
210
|
+
cc_text = format_addresses(getattr(envelope, "cc", None))
|
|
211
|
+
date_text = envelope_date(getattr(envelope, "date", None))
|
|
212
|
+
return {
|
|
213
|
+
"uid": str(uid),
|
|
214
|
+
"subject": subject,
|
|
215
|
+
"from": from_text,
|
|
216
|
+
"to": to_text,
|
|
217
|
+
"cc": cc_text,
|
|
218
|
+
"date": date_text,
|
|
219
|
+
"seen": "\\Seen" in flag_text,
|
|
220
|
+
"flagged": "\\Flagged" in flag_text,
|
|
221
|
+
"size": int(item.get(b"RFC822.SIZE") or 0),
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _body_bytes(item: dict) -> bytes:
|
|
226
|
+
for key in (b"BODY[]", b"RFC822"):
|
|
227
|
+
value = item.get(key)
|
|
228
|
+
if isinstance(value, bytes):
|
|
229
|
+
return value
|
|
230
|
+
return b""
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _parse_uid(uid: str) -> int:
|
|
234
|
+
text = str(uid).strip()
|
|
235
|
+
if not text.isdigit():
|
|
236
|
+
raise MailError("uid 必须是数字")
|
|
237
|
+
return int(text)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _clean(value: str | None) -> str:
|
|
241
|
+
return (value or "").strip()
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _as_text(value) -> str:
|
|
245
|
+
if value is None:
|
|
246
|
+
return ""
|
|
247
|
+
if isinstance(value, bytes):
|
|
248
|
+
return value.decode("utf-8", errors="replace")
|
|
249
|
+
return str(value)
|