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.
@@ -0,0 +1,15 @@
1
+ {
2
+ "mcpServers": {
3
+ "netease-mail": {
4
+ "command": "uv",
5
+ "args": [
6
+ "run",
7
+ "--directory",
8
+ "/Users/tal/Desktop/好未来/MailTool",
9
+ "python",
10
+ "-m",
11
+ "mailtool"
12
+ ]
13
+ }
14
+ }
15
+ }
@@ -0,0 +1,7 @@
1
+ # 完整邮箱地址
2
+ MAIL_USER=
3
+ # 网页邮箱「设置 → 账户与安全 → 客户端设置」生成的授权码,不是登录密码
4
+ MAIL_AUTH_CODE=
5
+ # tal.com 的 MX 在网易杭州机房
6
+ MAIL_IMAP_HOST=imap.163.com
7
+ MAIL_IMAP_PORT=993
@@ -0,0 +1,8 @@
1
+ .venv/
2
+ .env
3
+ __pycache__/
4
+ .pytest_cache/
5
+ *.pyc
6
+ dist/
7
+ build/
8
+ *.egg-info/
@@ -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,4 @@
1
+ from mailtool.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -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)