mailcode 0.1.0__py3-none-any.whl
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.
- mailcode/__init__.py +1 -0
- mailcode/channels/__init__.py +3 -0
- mailcode/channels/email_channel.py +144 -0
- mailcode/cli.py +360 -0
- mailcode/config.py +248 -0
- mailcode/health.py +128 -0
- mailcode/provider_presets.py +51 -0
- mailcode/relay/__init__.py +3 -0
- mailcode/relay/conversation_handler.py +576 -0
- mailcode/relay/email_listener.py +556 -0
- mailcode/relay/security.py +132 -0
- mailcode/relay/stateless_handler.py +101 -0
- mailcode/resources/default.json +36 -0
- mailcode/server.py +40 -0
- mailcode/session_cli.py +149 -0
- mailcode/utils/__init__.py +3 -0
- mailcode/utils/logging.py +48 -0
- mailcode-0.1.0.dist-info/METADATA +8 -0
- mailcode-0.1.0.dist-info/RECORD +23 -0
- mailcode-0.1.0.dist-info/WHEEL +5 -0
- mailcode-0.1.0.dist-info/entry_points.txt +2 -0
- mailcode-0.1.0.dist-info/licenses/LICENSE +21 -0
- mailcode-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
"""对话模式核心 — 通过 claude -p 子进程处理对话邮件 (session-per-file)"""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
import time
|
|
8
|
+
import uuid
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
# MailCode 主目录
|
|
15
|
+
_MAILCODE_HOME = Path.home() / ".config" / "mailcode"
|
|
16
|
+
_CONV_DIR = _MAILCODE_HOME / "conversations"
|
|
17
|
+
_INDEX_FILE = _CONV_DIR / "index.json"
|
|
18
|
+
_SESSION_PREFIX = "session_"
|
|
19
|
+
_SESSION_EXT = ".json"
|
|
20
|
+
|
|
21
|
+
# cwd 提取正则: 匹配邮件正文首行的 `cwd: <path>` 指令
|
|
22
|
+
_CWD_RE = re.compile(r"^cwd:\s*(.+?)\s*$", re.MULTILINE | re.IGNORECASE)
|
|
23
|
+
|
|
24
|
+
# 默认 session TTL (天), 0 或负数 = 不清理
|
|
25
|
+
_DEFAULT_TTL_DAYS = 90
|
|
26
|
+
|
|
27
|
+
# claude -p 子进程超时
|
|
28
|
+
_CLAUDE_TIMEOUT = 300
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ------------------------------------------------------------------ #
|
|
32
|
+
# 模块级纯函数 — 供 ConversationHandler / StatelessHandler 复用
|
|
33
|
+
# ------------------------------------------------------------------ #
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def extract_cwd(body: str) -> Optional[str]:
|
|
37
|
+
"""从邮件正文提取 `cwd: <path>` 指令。
|
|
38
|
+
|
|
39
|
+
- 大小写不敏感、多行匹配
|
|
40
|
+
- 展开 ~, 相对路径相对 Path.cwd() 补全
|
|
41
|
+
- is_dir() 验证, 无效返回 None
|
|
42
|
+
"""
|
|
43
|
+
if not body:
|
|
44
|
+
return None
|
|
45
|
+
m = _CWD_RE.search(body)
|
|
46
|
+
if not m:
|
|
47
|
+
return None
|
|
48
|
+
raw = m.group(1).strip()
|
|
49
|
+
if not raw:
|
|
50
|
+
return None
|
|
51
|
+
expanded = Path(raw).expanduser()
|
|
52
|
+
if not expanded.is_absolute():
|
|
53
|
+
expanded = (Path.cwd() / expanded).resolve()
|
|
54
|
+
if not expanded.is_dir():
|
|
55
|
+
logger.warning("cwd 路径无效: %s", expanded)
|
|
56
|
+
return None
|
|
57
|
+
return str(expanded)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def strip_cwd(body: str) -> str:
|
|
61
|
+
"""从邮件正文剥离 `cwd: <path>` 行, 避免污染对话内容。"""
|
|
62
|
+
if not body:
|
|
63
|
+
return body
|
|
64
|
+
return _CWD_RE.sub("", body).strip()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def call_claude(prompt: str, cwd: str = "") -> Optional[str]:
|
|
68
|
+
"""调用 ``claude -p`` 子进程。失败返回 None。
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
prompt: 完整 prompt
|
|
72
|
+
cwd: 工作目录 (默认 ``Path.home()``)
|
|
73
|
+
"""
|
|
74
|
+
cwd = cwd or str(Path.home())
|
|
75
|
+
try:
|
|
76
|
+
result = subprocess.run(
|
|
77
|
+
["claude", "-p", prompt, "--dangerously-skip-permissions"],
|
|
78
|
+
capture_output=True,
|
|
79
|
+
text=True,
|
|
80
|
+
timeout=_CLAUDE_TIMEOUT,
|
|
81
|
+
cwd=cwd,
|
|
82
|
+
)
|
|
83
|
+
if result.returncode != 0:
|
|
84
|
+
logger.error("claude -p 失败: %s", result.stderr[:500])
|
|
85
|
+
return None
|
|
86
|
+
return result.stdout.strip()
|
|
87
|
+
except subprocess.TimeoutExpired:
|
|
88
|
+
logger.error("claude -p 超时")
|
|
89
|
+
return None
|
|
90
|
+
except FileNotFoundError:
|
|
91
|
+
logger.error("claude 命令未找到, 请确保已安装 Claude Code")
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def send_error_email(email_channel, from_email: str, subject: str, body: str,
|
|
96
|
+
references: str, in_reply_to: str) -> bool:
|
|
97
|
+
"""发送错误通知邮件 (subject 加 Re: 前缀)。
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
email_channel: ``EmailChannel`` 实例 (无状态, 调用方传入)
|
|
101
|
+
from_email: 原邮件发件人 (错误通知收件人)
|
|
102
|
+
subject: 原邮件主题
|
|
103
|
+
body: 通知正文
|
|
104
|
+
references: 原邮件 References 头
|
|
105
|
+
in_reply_to: 原邮件 In-Reply-To 头
|
|
106
|
+
"""
|
|
107
|
+
reply_subject = subject if subject.startswith("Re:") else f"Re: {subject}"
|
|
108
|
+
try:
|
|
109
|
+
send_ok, _ = email_channel.send_reply(
|
|
110
|
+
to_email=from_email,
|
|
111
|
+
subject=reply_subject,
|
|
112
|
+
body=body,
|
|
113
|
+
in_reply_to_msg_id=in_reply_to,
|
|
114
|
+
references=references,
|
|
115
|
+
)
|
|
116
|
+
except Exception as e:
|
|
117
|
+
logger.error("错误通知邮件发送异常: %s", e)
|
|
118
|
+
return False
|
|
119
|
+
if not send_ok:
|
|
120
|
+
logger.error("错误通知邮件发送失败: to=%s", from_email)
|
|
121
|
+
return send_ok
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class ConversationHandler:
|
|
125
|
+
"""通过 claude -p 子进程处理对话邮件。
|
|
126
|
+
|
|
127
|
+
对话数据持久化在 ``~/.config/mailcode/conversations/`` 下:
|
|
128
|
+
- index.json: msg_id → session_id 索引 (O(1) 查找)
|
|
129
|
+
- session_<uuid>.json: 一个 session 一份文件
|
|
130
|
+
|
|
131
|
+
上下文管理、CWD、system prompt 等由 Claude Code 自助负责
|
|
132
|
+
(读取 session 文件 + cwd 下的 CLAUDE.md), MailCode 只做"dumb pipe"。
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
def __init__(self, email_channel):
|
|
136
|
+
self.email_channel = email_channel
|
|
137
|
+
self._ensure_dirs()
|
|
138
|
+
|
|
139
|
+
# ------------------------------------------------------------------ #
|
|
140
|
+
# 目录 / 路径
|
|
141
|
+
# ------------------------------------------------------------------ #
|
|
142
|
+
|
|
143
|
+
def _ensure_dirs(self):
|
|
144
|
+
"""确保对话数据目录存在, 必要时初始化 index.json。"""
|
|
145
|
+
_CONV_DIR.mkdir(parents=True, exist_ok=True)
|
|
146
|
+
if not _INDEX_FILE.exists():
|
|
147
|
+
self._save_index({
|
|
148
|
+
"version": 1,
|
|
149
|
+
"updated_at": time.time(),
|
|
150
|
+
"msg_to_session": {},
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
def _session_path(self, session_id: str) -> Path:
|
|
154
|
+
return _CONV_DIR / f"{_SESSION_PREFIX}{session_id}{_SESSION_EXT}"
|
|
155
|
+
|
|
156
|
+
def _bot_email(self) -> str:
|
|
157
|
+
"""从 email_channel 派生机器人邮箱 (作为 outgoing 的 from / incoming 的 to)。"""
|
|
158
|
+
try:
|
|
159
|
+
return (
|
|
160
|
+
self.email_channel.email_config.get("from", "")
|
|
161
|
+
or self.email_channel.smtp_user
|
|
162
|
+
or ""
|
|
163
|
+
)
|
|
164
|
+
except Exception:
|
|
165
|
+
return ""
|
|
166
|
+
|
|
167
|
+
# ------------------------------------------------------------------ #
|
|
168
|
+
# session_id
|
|
169
|
+
# ------------------------------------------------------------------ #
|
|
170
|
+
|
|
171
|
+
@staticmethod
|
|
172
|
+
def _new_session_id() -> str:
|
|
173
|
+
"""生成 12 位 hex session_id。"""
|
|
174
|
+
return uuid.uuid4().hex[:12]
|
|
175
|
+
|
|
176
|
+
# ------------------------------------------------------------------ #
|
|
177
|
+
# session 读写
|
|
178
|
+
# ------------------------------------------------------------------ #
|
|
179
|
+
|
|
180
|
+
def _empty_session(self, session_id: str) -> dict:
|
|
181
|
+
return {
|
|
182
|
+
"session_id": session_id,
|
|
183
|
+
"cwd": "",
|
|
184
|
+
"created_at": time.time(),
|
|
185
|
+
"last_interaction": time.time(),
|
|
186
|
+
"emails": [],
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
def _load_session(self, session_id: str) -> dict:
|
|
190
|
+
"""加载 session 数据。文件不存在返回空 session; 损坏返回空 + warn。"""
|
|
191
|
+
path = self._session_path(session_id)
|
|
192
|
+
if not path.exists():
|
|
193
|
+
return self._empty_session(session_id)
|
|
194
|
+
try:
|
|
195
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
196
|
+
data = json.load(f)
|
|
197
|
+
except (json.JSONDecodeError, IOError) as e:
|
|
198
|
+
logger.warning("session 文件损坏 (id=%s): %s, 返回空 session", session_id, e)
|
|
199
|
+
return self._empty_session(session_id)
|
|
200
|
+
# 补齐缺失字段
|
|
201
|
+
data.setdefault("session_id", session_id)
|
|
202
|
+
data.setdefault("cwd", "")
|
|
203
|
+
data.setdefault("created_at", time.time())
|
|
204
|
+
data.setdefault("last_interaction", time.time())
|
|
205
|
+
if not isinstance(data.get("emails"), list):
|
|
206
|
+
data["emails"] = []
|
|
207
|
+
return data
|
|
208
|
+
|
|
209
|
+
def _save_session(self, session_id: str, data: dict):
|
|
210
|
+
"""原子写 session 文件 (tmp + replace), 刷新 last_interaction。"""
|
|
211
|
+
data["session_id"] = session_id
|
|
212
|
+
data["last_interaction"] = time.time()
|
|
213
|
+
path = self._session_path(session_id)
|
|
214
|
+
tmp_path = path.with_suffix(path.suffix + ".tmp")
|
|
215
|
+
with open(tmp_path, "w", encoding="utf-8") as f:
|
|
216
|
+
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
217
|
+
tmp_path.replace(path)
|
|
218
|
+
|
|
219
|
+
# ------------------------------------------------------------------ #
|
|
220
|
+
# index 读写
|
|
221
|
+
# ------------------------------------------------------------------ #
|
|
222
|
+
|
|
223
|
+
def _empty_index(self) -> dict:
|
|
224
|
+
return {"version": 1, "updated_at": time.time(), "msg_to_session": {}}
|
|
225
|
+
|
|
226
|
+
def _load_index(self) -> dict:
|
|
227
|
+
"""加载 index.json。文件不存在或损坏返回空 index。"""
|
|
228
|
+
if not _INDEX_FILE.exists():
|
|
229
|
+
return self._empty_index()
|
|
230
|
+
try:
|
|
231
|
+
with open(_INDEX_FILE, "r", encoding="utf-8") as f:
|
|
232
|
+
idx = json.load(f)
|
|
233
|
+
except (json.JSONDecodeError, IOError) as e:
|
|
234
|
+
logger.warning("index.json 损坏: %s, 回退为空", e)
|
|
235
|
+
return self._empty_index()
|
|
236
|
+
if not isinstance(idx.get("msg_to_session"), dict):
|
|
237
|
+
idx["msg_to_session"] = {}
|
|
238
|
+
idx.setdefault("version", 1)
|
|
239
|
+
return idx
|
|
240
|
+
|
|
241
|
+
def _save_index(self, index: dict):
|
|
242
|
+
"""原子写 index.json。"""
|
|
243
|
+
index["updated_at"] = time.time()
|
|
244
|
+
tmp_path = _INDEX_FILE.with_suffix(_INDEX_FILE.suffix + ".tmp")
|
|
245
|
+
with open(tmp_path, "w", encoding="utf-8") as f:
|
|
246
|
+
json.dump(index, f, ensure_ascii=False, indent=2)
|
|
247
|
+
tmp_path.replace(_INDEX_FILE)
|
|
248
|
+
|
|
249
|
+
def _update_index(self, msg_id: str, session_id: str):
|
|
250
|
+
"""把 msg_id → session_id 写入 index。空 msg_id 跳过。"""
|
|
251
|
+
if not msg_id or not session_id:
|
|
252
|
+
return
|
|
253
|
+
index = self._load_index()
|
|
254
|
+
index["msg_to_session"][msg_id] = session_id
|
|
255
|
+
self._save_index(index)
|
|
256
|
+
|
|
257
|
+
def _remove_from_index(self, msg_id: str):
|
|
258
|
+
"""从 index 移除 msg_id 条目。空 msg_id 跳过。"""
|
|
259
|
+
if not msg_id:
|
|
260
|
+
return
|
|
261
|
+
index = self._load_index()
|
|
262
|
+
if msg_id in index["msg_to_session"]:
|
|
263
|
+
del index["msg_to_session"][msg_id]
|
|
264
|
+
self._save_index(index)
|
|
265
|
+
|
|
266
|
+
# ------------------------------------------------------------------ #
|
|
267
|
+
# 查找
|
|
268
|
+
# ------------------------------------------------------------------ #
|
|
269
|
+
|
|
270
|
+
def _find_session_by_msg_id(self, msg_id: str) -> Optional[str]:
|
|
271
|
+
"""通过 msg_id 查找 session_id。index 优先, 全量扫描兜底。
|
|
272
|
+
|
|
273
|
+
返回 session_id 字符串, 找不到返回 None。
|
|
274
|
+
"""
|
|
275
|
+
if not msg_id:
|
|
276
|
+
return None
|
|
277
|
+
key = msg_id.strip()
|
|
278
|
+
bare = key.strip("<>")
|
|
279
|
+
|
|
280
|
+
# 1) index 精确匹配
|
|
281
|
+
index = self._load_index()
|
|
282
|
+
if key in index["msg_to_session"]:
|
|
283
|
+
sid = index["msg_to_session"][key]
|
|
284
|
+
if self._session_path(sid).exists():
|
|
285
|
+
return sid
|
|
286
|
+
# session 文件丢失 → 清理 index 条目后继续
|
|
287
|
+
del index["msg_to_session"][key]
|
|
288
|
+
self._save_index(index)
|
|
289
|
+
|
|
290
|
+
# 2) index 无尖括号兜底
|
|
291
|
+
if bare and bare != key:
|
|
292
|
+
for mid, sid in list(index["msg_to_session"].items()):
|
|
293
|
+
if mid.strip("<>") == bare and self._session_path(sid).exists():
|
|
294
|
+
return sid
|
|
295
|
+
|
|
296
|
+
# 3) 扫描 session_*.json 兜底
|
|
297
|
+
for path in _CONV_DIR.glob(f"{_SESSION_PREFIX}*{_SESSION_EXT}"):
|
|
298
|
+
try:
|
|
299
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
300
|
+
data = json.load(f)
|
|
301
|
+
except (json.JSONDecodeError, IOError):
|
|
302
|
+
continue
|
|
303
|
+
for entry in data.get("emails", []):
|
|
304
|
+
mid = (entry.get("msg_id") or "").strip()
|
|
305
|
+
if not mid:
|
|
306
|
+
continue
|
|
307
|
+
if mid == key or (bare and mid.strip("<>") == bare):
|
|
308
|
+
sid = data.get("session_id") or path.stem[len(_SESSION_PREFIX):]
|
|
309
|
+
# 顺手把匹配的 msg_id 补回 index
|
|
310
|
+
self._update_index(mid, sid)
|
|
311
|
+
return sid
|
|
312
|
+
return None
|
|
313
|
+
|
|
314
|
+
# ------------------------------------------------------------------ #
|
|
315
|
+
# Prompt 构建
|
|
316
|
+
# ------------------------------------------------------------------ #
|
|
317
|
+
|
|
318
|
+
def _build_prompt(self, session_file_path: str) -> str:
|
|
319
|
+
"""构建极简 prompt, 让 Claude 自助读 session 文件。"""
|
|
320
|
+
return (
|
|
321
|
+
f"用户最新邮件已写入 session 文件: {session_file_path}\n\n"
|
|
322
|
+
"请用 Read 工具读取该文件, 了解完整对话上下文 "
|
|
323
|
+
"(emails 字段是邮件列表, direction=incoming/outgoing), "
|
|
324
|
+
"然后回复用户最新邮件。\n\n"
|
|
325
|
+
"回复内容将作为邮件正文发送, 请用纯文本格式。"
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
# ------------------------------------------------------------------ #
|
|
329
|
+
# TTL 清理
|
|
330
|
+
# ------------------------------------------------------------------ #
|
|
331
|
+
|
|
332
|
+
def _get_ttl_days(self) -> int:
|
|
333
|
+
"""读取 session TTL 配置 (天)。0 或负数 = 不清理。默认 90。"""
|
|
334
|
+
try:
|
|
335
|
+
from mailcode.config import load_config
|
|
336
|
+
config = load_config()
|
|
337
|
+
except Exception:
|
|
338
|
+
return _DEFAULT_TTL_DAYS
|
|
339
|
+
session_cfg = config.get("session", {}) or {}
|
|
340
|
+
try:
|
|
341
|
+
return int(session_cfg.get("session_ttl_days", _DEFAULT_TTL_DAYS))
|
|
342
|
+
except (TypeError, ValueError):
|
|
343
|
+
return _DEFAULT_TTL_DAYS
|
|
344
|
+
|
|
345
|
+
def _cleanup_expired_sessions(self, dry_run: bool = False) -> int:
|
|
346
|
+
"""按 TTL 删除过期 session, 损坏文件只 warn 不删。
|
|
347
|
+
|
|
348
|
+
Returns:
|
|
349
|
+
删除的 session 数量 (dry_run 时为 0)
|
|
350
|
+
"""
|
|
351
|
+
ttl = self._get_ttl_days()
|
|
352
|
+
if ttl <= 0:
|
|
353
|
+
return 0
|
|
354
|
+
threshold = time.time() - ttl * 86400
|
|
355
|
+
deleted = 0
|
|
356
|
+
for path in _CONV_DIR.glob(f"{_SESSION_PREFIX}*{_SESSION_EXT}"):
|
|
357
|
+
try:
|
|
358
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
359
|
+
data = json.load(f)
|
|
360
|
+
except (json.JSONDecodeError, IOError) as e:
|
|
361
|
+
logger.warning("session 文件损坏, 跳过清理: %s: %s", path.name, e)
|
|
362
|
+
continue
|
|
363
|
+
last = data.get("last_interaction", 0)
|
|
364
|
+
if last < threshold:
|
|
365
|
+
sid = data.get("session_id") or path.stem[len(_SESSION_PREFIX):]
|
|
366
|
+
if dry_run:
|
|
367
|
+
logger.info("[dry-run] 即将删除过期 session: %s (last=%s)", sid, last)
|
|
368
|
+
continue
|
|
369
|
+
try:
|
|
370
|
+
path.unlink()
|
|
371
|
+
except OSError as e:
|
|
372
|
+
logger.warning("删除 session 文件失败: %s: %s", path, e)
|
|
373
|
+
continue
|
|
374
|
+
# 同步清理 index 中映射到该 session 的所有 msg_id
|
|
375
|
+
index = self._load_index()
|
|
376
|
+
to_remove = [mid for mid, mapped in index["msg_to_session"].items()
|
|
377
|
+
if mapped == sid]
|
|
378
|
+
for mid in to_remove:
|
|
379
|
+
del index["msg_to_session"][mid]
|
|
380
|
+
if to_remove:
|
|
381
|
+
self._save_index(index)
|
|
382
|
+
deleted += 1
|
|
383
|
+
logger.info("已删除过期 session: %s", sid)
|
|
384
|
+
return deleted
|
|
385
|
+
|
|
386
|
+
# ------------------------------------------------------------------ #
|
|
387
|
+
# 主入口
|
|
388
|
+
# ------------------------------------------------------------------ #
|
|
389
|
+
|
|
390
|
+
def handle_email(self, from_email: str, subject: str, body: str,
|
|
391
|
+
references: str = "", in_reply_to: str = "") -> bool:
|
|
392
|
+
"""主入口: 处理一封对话邮件。
|
|
393
|
+
|
|
394
|
+
流程:
|
|
395
|
+
1. 提取 cwd + 剥离
|
|
396
|
+
2. 查找 / 创建 session
|
|
397
|
+
3. 追加 incoming → 存 session → 写 index
|
|
398
|
+
4. 调 claude -p
|
|
399
|
+
5. 错误处理 (发通知邮件)
|
|
400
|
+
6. 追加 outgoing → 存 session → SMTP 发回复
|
|
401
|
+
7. 拿到 our_msg_id 后回填 session + 更新 index
|
|
402
|
+
|
|
403
|
+
Returns:
|
|
404
|
+
True 表示成功发送回复
|
|
405
|
+
"""
|
|
406
|
+
logger.info("处理对话邮件: from=%s subject=%s", from_email, subject)
|
|
407
|
+
|
|
408
|
+
# 1. 提取 cwd + 剥离
|
|
409
|
+
extracted_cwd = extract_cwd(body)
|
|
410
|
+
clean_body = strip_cwd(body) if extracted_cwd is not None else body
|
|
411
|
+
|
|
412
|
+
# 2. 查找 / 创建 session
|
|
413
|
+
session_id = self._find_session_by_msg_id(in_reply_to) if in_reply_to else None
|
|
414
|
+
if session_id:
|
|
415
|
+
session = self._load_session(session_id)
|
|
416
|
+
# 防止 session_id 字段缺失
|
|
417
|
+
session["session_id"] = session_id
|
|
418
|
+
else:
|
|
419
|
+
session_id = self._new_session_id()
|
|
420
|
+
session = self._empty_session(session_id)
|
|
421
|
+
|
|
422
|
+
# 3. session.cwd 粘性 (新邮件指定则覆盖)
|
|
423
|
+
if extracted_cwd:
|
|
424
|
+
session["cwd"] = extracted_cwd
|
|
425
|
+
|
|
426
|
+
# 4. 追加 incoming 邮件
|
|
427
|
+
incoming_email = {
|
|
428
|
+
"direction": "incoming",
|
|
429
|
+
"from": from_email,
|
|
430
|
+
"to": self._bot_email(),
|
|
431
|
+
"subject": subject,
|
|
432
|
+
"body": clean_body,
|
|
433
|
+
"msg_id": "", # IMAP 监听层目前未透传
|
|
434
|
+
"in_reply_to": in_reply_to or None,
|
|
435
|
+
"references": references or None,
|
|
436
|
+
"date": "",
|
|
437
|
+
}
|
|
438
|
+
session["emails"].append(incoming_email)
|
|
439
|
+
|
|
440
|
+
# 5. 保存 session + 更新 index (incoming 暂时无 msg_id 可索引)
|
|
441
|
+
self._save_session(session_id, session)
|
|
442
|
+
|
|
443
|
+
# 6. 构建 prompt + 调 claude
|
|
444
|
+
session_path = str(self._session_path(session_id))
|
|
445
|
+
prompt = self._build_prompt(session_path)
|
|
446
|
+
cwd = session.get("cwd") or str(Path.home())
|
|
447
|
+
response = call_claude(prompt, cwd=cwd)
|
|
448
|
+
|
|
449
|
+
# 7. claude 失败 → 写日志 + 发邮件通知用户
|
|
450
|
+
if response is None:
|
|
451
|
+
logger.error("claude -p 调用失败, 通知用户: from=%s", from_email)
|
|
452
|
+
send_error_email(
|
|
453
|
+
self.email_channel, from_email, subject,
|
|
454
|
+
"抱歉, 处理你的邮件时遇到技术问题。请稍后再试。详细错误已记录到日志。",
|
|
455
|
+
references, in_reply_to,
|
|
456
|
+
)
|
|
457
|
+
return False
|
|
458
|
+
|
|
459
|
+
# 8. 空 response → 写日志 + 发邮件通知用户
|
|
460
|
+
if not response:
|
|
461
|
+
logger.error("claude -p 返回空 response, 通知用户: from=%s", from_email)
|
|
462
|
+
send_error_email(
|
|
463
|
+
self.email_channel, from_email, subject,
|
|
464
|
+
"抱歉, AI 助手这次没有回复内容。请稍后再试, 或换个方式描述你的问题。",
|
|
465
|
+
references, in_reply_to,
|
|
466
|
+
)
|
|
467
|
+
return False
|
|
468
|
+
|
|
469
|
+
# 9. 准备 outgoing 邮件 (msg_id 留空, SMTP 之后回填)
|
|
470
|
+
reply_subject = subject if subject.startswith("Re:") else f"Re: {subject}"
|
|
471
|
+
outgoing_email = {
|
|
472
|
+
"direction": "outgoing",
|
|
473
|
+
"from": self._bot_email(),
|
|
474
|
+
"to": from_email,
|
|
475
|
+
"subject": reply_subject,
|
|
476
|
+
"body": response,
|
|
477
|
+
"msg_id": "",
|
|
478
|
+
"in_reply_to": in_reply_to or None,
|
|
479
|
+
"references": references or None,
|
|
480
|
+
"date": "",
|
|
481
|
+
}
|
|
482
|
+
session["emails"].append(outgoing_email)
|
|
483
|
+
|
|
484
|
+
# 10. 保存 session (在 SMTP 之前, 失败可重发)
|
|
485
|
+
self._save_session(session_id, session)
|
|
486
|
+
|
|
487
|
+
# 11. SMTP 发回复
|
|
488
|
+
send_ok, our_msg_id = self.email_channel.send_reply(
|
|
489
|
+
to_email=from_email,
|
|
490
|
+
subject=reply_subject,
|
|
491
|
+
body=response,
|
|
492
|
+
in_reply_to_msg_id=in_reply_to,
|
|
493
|
+
references=references,
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
if not send_ok:
|
|
497
|
+
logger.error("对话回复发送失败: from=%s", from_email)
|
|
498
|
+
return False
|
|
499
|
+
|
|
500
|
+
# 12. SMTP 成功 → 回填 outgoing.msg_id, 重存 + 更新 index
|
|
501
|
+
if our_msg_id and session["emails"]:
|
|
502
|
+
last = session["emails"][-1]
|
|
503
|
+
if isinstance(last, dict) and last.get("direction") == "outgoing":
|
|
504
|
+
last["msg_id"] = our_msg_id
|
|
505
|
+
self._save_session(session_id, session)
|
|
506
|
+
self._update_index(our_msg_id, session_id)
|
|
507
|
+
|
|
508
|
+
logger.info("对话回复已发送: session=%s msg_id=%s", session_id, our_msg_id)
|
|
509
|
+
return True
|
|
510
|
+
|
|
511
|
+
# ------------------------------------------------------------------ #
|
|
512
|
+
# 管理接口
|
|
513
|
+
# ------------------------------------------------------------------ #
|
|
514
|
+
|
|
515
|
+
def list_sessions(self) -> list[dict]:
|
|
516
|
+
"""列出所有 session (按 last_interaction 降序)。损坏文件 warn 跳过。"""
|
|
517
|
+
sessions = []
|
|
518
|
+
for path in _CONV_DIR.glob(f"{_SESSION_PREFIX}*{_SESSION_EXT}"):
|
|
519
|
+
try:
|
|
520
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
521
|
+
data = json.load(f)
|
|
522
|
+
except (json.JSONDecodeError, IOError) as e:
|
|
523
|
+
logger.warning("session 文件损坏, 跳过 list: %s: %s", path.name, e)
|
|
524
|
+
continue
|
|
525
|
+
emails = data.get("emails", [])
|
|
526
|
+
sid = data.get("session_id") or path.stem[len(_SESSION_PREFIX):]
|
|
527
|
+
sessions.append({
|
|
528
|
+
"session_id": sid,
|
|
529
|
+
"cwd": data.get("cwd", ""),
|
|
530
|
+
"created_at": data.get("created_at", 0),
|
|
531
|
+
"last_interaction": data.get("last_interaction", 0),
|
|
532
|
+
"email_count": len(emails),
|
|
533
|
+
})
|
|
534
|
+
sessions.sort(key=lambda s: s.get("last_interaction", 0), reverse=True)
|
|
535
|
+
return sessions
|
|
536
|
+
|
|
537
|
+
def get_session_status(self, session_id: str) -> Optional[dict]:
|
|
538
|
+
"""查看 session 详情 (含完整 emails 列表)。"""
|
|
539
|
+
path = self._session_path(session_id)
|
|
540
|
+
if not path.exists():
|
|
541
|
+
return None
|
|
542
|
+
try:
|
|
543
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
544
|
+
data = json.load(f)
|
|
545
|
+
except (json.JSONDecodeError, IOError):
|
|
546
|
+
return None
|
|
547
|
+
emails = data.get("emails", [])
|
|
548
|
+
return {
|
|
549
|
+
"session_id": data.get("session_id", session_id),
|
|
550
|
+
"cwd": data.get("cwd", ""),
|
|
551
|
+
"created_at": data.get("created_at", 0),
|
|
552
|
+
"last_interaction": data.get("last_interaction", 0),
|
|
553
|
+
"emails": emails,
|
|
554
|
+
"email_count": len(emails),
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
def terminate_session(self, session_id: str) -> bool:
|
|
558
|
+
"""删除 session 文件, 同步清理 index 中所有指向该 session 的条目。"""
|
|
559
|
+
path = self._session_path(session_id)
|
|
560
|
+
if not path.exists():
|
|
561
|
+
return False
|
|
562
|
+
try:
|
|
563
|
+
path.unlink()
|
|
564
|
+
except OSError as e:
|
|
565
|
+
logger.error("删除 session 文件失败: %s: %s", path, e)
|
|
566
|
+
return False
|
|
567
|
+
# 同步清理 index
|
|
568
|
+
index = self._load_index()
|
|
569
|
+
to_remove = [mid for mid, sid in index["msg_to_session"].items()
|
|
570
|
+
if sid == session_id]
|
|
571
|
+
for mid in to_remove:
|
|
572
|
+
del index["msg_to_session"][mid]
|
|
573
|
+
if to_remove:
|
|
574
|
+
self._save_index(index)
|
|
575
|
+
logger.info("已删除 session: %s (清理 %d 条 index)", session_id, len(to_remove))
|
|
576
|
+
return True
|