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.
@@ -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