mailcode 0.1.2__tar.gz → 0.1.3__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.
- {mailcode-0.1.2 → mailcode-0.1.3}/PKG-INFO +1 -1
- mailcode-0.1.3/mailcode/__init__.py +1 -0
- {mailcode-0.1.2 → mailcode-0.1.3}/mailcode/relay/email_listener.py +102 -15
- {mailcode-0.1.2 → mailcode-0.1.3}/mailcode/utils/claude_runner.py +7 -6
- {mailcode-0.1.2 → mailcode-0.1.3}/mailcode.egg-info/PKG-INFO +1 -1
- {mailcode-0.1.2 → mailcode-0.1.3}/pyproject.toml +1 -1
- mailcode-0.1.2/mailcode/__init__.py +0 -1
- {mailcode-0.1.2 → mailcode-0.1.3}/LICENSE +0 -0
- {mailcode-0.1.2 → mailcode-0.1.3}/README.md +0 -0
- {mailcode-0.1.2 → mailcode-0.1.3}/mailcode/channels/__init__.py +0 -0
- {mailcode-0.1.2 → mailcode-0.1.3}/mailcode/channels/email_channel.py +0 -0
- {mailcode-0.1.2 → mailcode-0.1.3}/mailcode/cli.py +0 -0
- {mailcode-0.1.2 → mailcode-0.1.3}/mailcode/config.py +0 -0
- {mailcode-0.1.2 → mailcode-0.1.3}/mailcode/health.py +0 -0
- {mailcode-0.1.2 → mailcode-0.1.3}/mailcode/provider_presets.py +0 -0
- {mailcode-0.1.2 → mailcode-0.1.3}/mailcode/relay/__init__.py +0 -0
- {mailcode-0.1.2 → mailcode-0.1.3}/mailcode/relay/conversation_handler.py +0 -0
- {mailcode-0.1.2 → mailcode-0.1.3}/mailcode/relay/scheduler.py +0 -0
- {mailcode-0.1.2 → mailcode-0.1.3}/mailcode/relay/security.py +0 -0
- {mailcode-0.1.2 → mailcode-0.1.3}/mailcode/relay/stateless_handler.py +0 -0
- {mailcode-0.1.2 → mailcode-0.1.3}/mailcode/resources/default.json +0 -0
- {mailcode-0.1.2 → mailcode-0.1.3}/mailcode/schedule_cli.py +0 -0
- {mailcode-0.1.2 → mailcode-0.1.3}/mailcode/server.py +0 -0
- {mailcode-0.1.2 → mailcode-0.1.3}/mailcode/session_cli.py +0 -0
- {mailcode-0.1.2 → mailcode-0.1.3}/mailcode/utils/__init__.py +0 -0
- {mailcode-0.1.2 → mailcode-0.1.3}/mailcode/utils/logging.py +0 -0
- {mailcode-0.1.2 → mailcode-0.1.3}/mailcode.egg-info/SOURCES.txt +0 -0
- {mailcode-0.1.2 → mailcode-0.1.3}/mailcode.egg-info/dependency_links.txt +0 -0
- {mailcode-0.1.2 → mailcode-0.1.3}/mailcode.egg-info/entry_points.txt +0 -0
- {mailcode-0.1.2 → mailcode-0.1.3}/mailcode.egg-info/top_level.txt +0 -0
- {mailcode-0.1.2 → mailcode-0.1.3}/setup.cfg +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.3"
|
|
@@ -6,6 +6,7 @@ import json
|
|
|
6
6
|
import random
|
|
7
7
|
import re
|
|
8
8
|
import socket
|
|
9
|
+
import time
|
|
9
10
|
import threading
|
|
10
11
|
import logging
|
|
11
12
|
from datetime import datetime, timedelta
|
|
@@ -83,6 +84,10 @@ class IMAPListener:
|
|
|
83
84
|
self._active_idle_mail: Optional[imaplib.IMAP4_SSL] = None
|
|
84
85
|
self._stopped = threading.Event()
|
|
85
86
|
|
|
87
|
+
# 预判性重连: QQ 邮箱约 2h 静默断连, 提前至 90min 主动重建连接
|
|
88
|
+
self.FORCED_RECONNECT_INTERVAL = 5400 # 秒, 可被测试覆盖改写
|
|
89
|
+
self._last_connect_time = time.monotonic()
|
|
90
|
+
|
|
86
91
|
def stop(self):
|
|
87
92
|
"""向监听循环发出干净退出信号。从信号处理程序调用。
|
|
88
93
|
|
|
@@ -120,8 +125,10 @@ class IMAPListener:
|
|
|
120
125
|
|
|
121
126
|
def _save_state(self, state: dict):
|
|
122
127
|
"""原子写 state 到 state.json。state 应包含 processed_uids + sent_messages。"""
|
|
123
|
-
|
|
128
|
+
tmp_path = self.state_path.with_suffix(self.state_path.suffix + ".tmp")
|
|
129
|
+
with open(tmp_path, "w", encoding="utf-8") as f:
|
|
124
130
|
json.dump(state, f, ensure_ascii=False, indent=2)
|
|
131
|
+
tmp_path.replace(self.state_path)
|
|
125
132
|
|
|
126
133
|
def _prune_old_sent_messages(self):
|
|
127
134
|
"""清理 7 天前的 sent_messages, 限制 processed_uids 上限。"""
|
|
@@ -228,7 +235,22 @@ class IMAPListener:
|
|
|
228
235
|
except Exception:
|
|
229
236
|
pass
|
|
230
237
|
|
|
231
|
-
|
|
238
|
+
got_event = not self._idle_ready.is_set()
|
|
239
|
+
|
|
240
|
+
# 超时后必须退出 idle_thread, 否则主线程后续的 NOOP/SELECT 会与
|
|
241
|
+
# idle_thread 中阻塞的 mail.idle_response() 撞协议, 导致 socket error.
|
|
242
|
+
if not got_event:
|
|
243
|
+
self._idle_ready.clear()
|
|
244
|
+
try:
|
|
245
|
+
mail.idle_done()
|
|
246
|
+
except Exception:
|
|
247
|
+
pass
|
|
248
|
+
_t = self._idle_thread
|
|
249
|
+
self._idle_thread = None
|
|
250
|
+
if _t and _t.is_alive():
|
|
251
|
+
_t.join(timeout=3)
|
|
252
|
+
|
|
253
|
+
return got_event
|
|
232
254
|
|
|
233
255
|
def _wait_for_idle_new(self, mail: imaplib.IMAP4_SSL) -> bool:
|
|
234
256
|
"""Py3.13+ IDLE 路径: 同步 `with mail.idle(duration=...)` 等待.
|
|
@@ -352,6 +374,11 @@ class IMAPListener:
|
|
|
352
374
|
if owns_connection:
|
|
353
375
|
mail = self._connect()
|
|
354
376
|
mail.select("INBOX")
|
|
377
|
+
# NOOP 刷新任何残留的挂起响应, 避免后续 SEARCH 读到错误响应
|
|
378
|
+
try:
|
|
379
|
+
mail.noop()
|
|
380
|
+
except Exception:
|
|
381
|
+
pass
|
|
355
382
|
|
|
356
383
|
status, messages = mail.search(None, "UNSEEN")
|
|
357
384
|
if status != "OK":
|
|
@@ -363,7 +390,7 @@ class IMAPListener:
|
|
|
363
390
|
|
|
364
391
|
for uid_bytes in uids:
|
|
365
392
|
uid = uid_bytes.decode()
|
|
366
|
-
status, msg_data = mail.fetch(uid_bytes, "(
|
|
393
|
+
status, msg_data = mail.fetch(uid_bytes, "(BODY.PEEK[])")
|
|
367
394
|
if status != "OK":
|
|
368
395
|
continue
|
|
369
396
|
|
|
@@ -539,6 +566,7 @@ class IMAPListener:
|
|
|
539
566
|
finally:
|
|
540
567
|
print("监听器已停止")
|
|
541
568
|
if not dry_run:
|
|
569
|
+
self._prune_old_sent_messages()
|
|
542
570
|
self._save_state({
|
|
543
571
|
"processed_uids": list(self.processed_uids),
|
|
544
572
|
"sent_messages": self.sent_messages,
|
|
@@ -548,31 +576,48 @@ class IMAPListener:
|
|
|
548
576
|
if not self.security_checker.config.get("allowed_senders"):
|
|
549
577
|
logger.warning("发件人白名单为空,所有邮件将被拒绝处理。请在配置文件中设置 allowed_senders")
|
|
550
578
|
self._init_baseline()
|
|
579
|
+
self._prune_old_sent_messages()
|
|
551
580
|
self._save_state({
|
|
552
581
|
"processed_uids": list(self.processed_uids),
|
|
553
582
|
"sent_messages": self.sent_messages,
|
|
554
583
|
})
|
|
555
584
|
iteration = 0
|
|
585
|
+
backoff = _Backoff()
|
|
556
586
|
while not self._stopped.is_set():
|
|
557
587
|
iteration += 1
|
|
558
588
|
if max_iterations and iteration > max_iterations:
|
|
559
589
|
break
|
|
560
590
|
|
|
561
|
-
|
|
591
|
+
try:
|
|
592
|
+
emails = self.fetch_unread_emails(dry_run=dry_run)
|
|
562
593
|
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
594
|
+
for email_entry in emails:
|
|
595
|
+
if dry_run:
|
|
596
|
+
continue
|
|
566
597
|
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
598
|
+
success, message = self.process_email(email_entry, dry_run=dry_run)
|
|
599
|
+
status_icon = "OK" if success else "FAIL"
|
|
600
|
+
logger.info(f"{status_icon} {message}")
|
|
570
601
|
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
602
|
+
self._prune_old_sent_messages()
|
|
603
|
+
if not dry_run:
|
|
604
|
+
self._save_state({
|
|
605
|
+
"processed_uids": list(self.processed_uids),
|
|
606
|
+
"sent_messages": self.sent_messages,
|
|
607
|
+
})
|
|
608
|
+
|
|
609
|
+
backoff.reset()
|
|
610
|
+
|
|
611
|
+
except (ConnectionError, EOFError, socket.timeout, imaplib.IMAP4.abort) as e:
|
|
612
|
+
delay = backoff.next_delay()
|
|
613
|
+
self._log_connection_error(e, attempt=backoff._n, next_delay=delay)
|
|
614
|
+
if self._stopped.wait(timeout=delay):
|
|
615
|
+
break
|
|
616
|
+
continue
|
|
617
|
+
except imaplib.IMAP4.error as e:
|
|
618
|
+
host = self.imap_config.get("host", "?")
|
|
619
|
+
logger.error(f"IMAP 协议错误, 放弃轮询 [{host}]: {e}")
|
|
620
|
+
break
|
|
576
621
|
|
|
577
622
|
if self._stopped.wait(timeout=self.check_interval):
|
|
578
623
|
break
|
|
@@ -581,6 +626,7 @@ class IMAPListener:
|
|
|
581
626
|
if not self.security_checker.config.get("allowed_senders"):
|
|
582
627
|
logger.warning("发件人白名单为空,所有邮件将被拒绝处理。请在配置文件中设置 allowed_senders")
|
|
583
628
|
self._init_baseline()
|
|
629
|
+
self._prune_old_sent_messages()
|
|
584
630
|
self._save_state({
|
|
585
631
|
"processed_uids": list(self.processed_uids),
|
|
586
632
|
"sent_messages": self.sent_messages,
|
|
@@ -609,6 +655,8 @@ class IMAPListener:
|
|
|
609
655
|
|
|
610
656
|
iteration = 0
|
|
611
657
|
backoff = _Backoff()
|
|
658
|
+
HEALTH_CHECK_INTERVAL = 60 # 秒: IDLE 健康检查周期
|
|
659
|
+
health_check_every = max(1, HEALTH_CHECK_INTERVAL // self._idle_timeout)
|
|
612
660
|
|
|
613
661
|
try:
|
|
614
662
|
while not self._stopped.is_set():
|
|
@@ -630,13 +678,45 @@ class IMAPListener:
|
|
|
630
678
|
break
|
|
631
679
|
mail = self._reconnect()
|
|
632
680
|
mail.select("INBOX")
|
|
681
|
+
try:
|
|
682
|
+
mail.noop()
|
|
683
|
+
except Exception:
|
|
684
|
+
pass
|
|
685
|
+
self._last_connect_time = time.monotonic()
|
|
686
|
+
logger.info("IDLE 收到事件, 已重连")
|
|
633
687
|
|
|
634
688
|
if self._stopped.is_set():
|
|
635
689
|
break
|
|
636
690
|
|
|
691
|
+
# 健康检查: 每 60s 一次 NOOP 探测死连接
|
|
692
|
+
# 必须在 got_event 分支之后 (mail 是重连后的新连接),
|
|
693
|
+
# 避免与 _wait_for_idle_old 后台 IDLE daemon 线程撞协议
|
|
694
|
+
if iteration % health_check_every == 0:
|
|
695
|
+
logger.debug(f"IDLE 健康检查 iter={iteration}")
|
|
696
|
+
try:
|
|
697
|
+
mail.noop()
|
|
698
|
+
except (ConnectionError, EOFError, socket.timeout, imaplib.IMAP4.abort) as e:
|
|
699
|
+
logger.warning(f"IDLE 健康检查失败 ({type(e).__name__}), 触发重连")
|
|
700
|
+
raise
|
|
701
|
+
|
|
702
|
+
# 预判性重连: QQ 邮箱约 2h 静默断连, 提前至 FORCED_RECONNECT_INTERVAL 秒主动重建
|
|
703
|
+
if not got_event and time.monotonic() - self._last_connect_time >= self.FORCED_RECONNECT_INTERVAL:
|
|
704
|
+
logger.info("预判性重连: 连接已持续 %d 秒", self.FORCED_RECONNECT_INTERVAL)
|
|
705
|
+
mail = self._reconnect()
|
|
706
|
+
mail.select("INBOX")
|
|
707
|
+
try:
|
|
708
|
+
mail.noop()
|
|
709
|
+
except Exception:
|
|
710
|
+
pass
|
|
711
|
+
self._last_connect_time = time.monotonic()
|
|
712
|
+
|
|
637
713
|
# 复用现有连接, 避免每轮 fetch 再开一个 (163 反滥用触发点)
|
|
638
714
|
emails = self.fetch_unread_emails(dry_run=dry_run, mail=mail)
|
|
639
715
|
|
|
716
|
+
# 重连后首轮 fetch 若拉到累积未读, 显式记录
|
|
717
|
+
if iteration <= health_check_every + 1 and emails:
|
|
718
|
+
logger.info(f"重连后首轮 fetch 拉到 {len(emails)} 封累积未读")
|
|
719
|
+
|
|
640
720
|
for email_entry in emails:
|
|
641
721
|
if dry_run:
|
|
642
722
|
continue
|
|
@@ -646,6 +726,7 @@ class IMAPListener:
|
|
|
646
726
|
logger.info(f"{status_icon} {message}")
|
|
647
727
|
|
|
648
728
|
if not dry_run:
|
|
729
|
+
self._prune_old_sent_messages()
|
|
649
730
|
self._save_state({
|
|
650
731
|
"processed_uids": list(self.processed_uids),
|
|
651
732
|
"sent_messages": self.sent_messages,
|
|
@@ -663,6 +744,12 @@ class IMAPListener:
|
|
|
663
744
|
try:
|
|
664
745
|
mail = self._reconnect()
|
|
665
746
|
mail.select("INBOX")
|
|
747
|
+
try:
|
|
748
|
+
mail.noop()
|
|
749
|
+
except Exception:
|
|
750
|
+
pass
|
|
751
|
+
self._last_connect_time = time.monotonic()
|
|
752
|
+
logger.info("退避重连成功, 准备拉取累积未读")
|
|
666
753
|
except imaplib.IMAP4.error as auth_err:
|
|
667
754
|
# 重连时再认证失败 = 配置问题, 放弃
|
|
668
755
|
logger.error(f"IMAP 认证失败, 放弃重试: {auth_err}")
|
|
@@ -11,12 +11,12 @@ from typing import Optional
|
|
|
11
11
|
|
|
12
12
|
logger = logging.getLogger(__name__)
|
|
13
13
|
|
|
14
|
-
# claude
|
|
15
|
-
CLAUDE_TIMEOUT_SECONDS =
|
|
14
|
+
# claude 子进程超时 (秒) — 默认 24h, 覆盖定时任务写长文的场景
|
|
15
|
+
CLAUDE_TIMEOUT_SECONDS = 86400
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
def call_claude(prompt: str, cwd: str = "") -> Optional[str]:
|
|
19
|
-
"""调用 ``claude
|
|
19
|
+
"""调用 ``claude`` 子进程 (stdin 传 prompt)。失败返回 None。
|
|
20
20
|
|
|
21
21
|
Args:
|
|
22
22
|
prompt: 完整 prompt
|
|
@@ -25,18 +25,19 @@ def call_claude(prompt: str, cwd: str = "") -> Optional[str]:
|
|
|
25
25
|
cwd = cwd or str(Path.home())
|
|
26
26
|
try:
|
|
27
27
|
result = subprocess.run(
|
|
28
|
-
["claude", "
|
|
28
|
+
["claude", "--dangerously-skip-permissions"],
|
|
29
|
+
input=prompt,
|
|
29
30
|
capture_output=True,
|
|
30
31
|
text=True,
|
|
31
32
|
timeout=CLAUDE_TIMEOUT_SECONDS,
|
|
32
33
|
cwd=cwd,
|
|
33
34
|
)
|
|
34
35
|
if result.returncode != 0:
|
|
35
|
-
logger.error("claude
|
|
36
|
+
logger.error("claude stdin 失败: %s", result.stderr[:500])
|
|
36
37
|
return None
|
|
37
38
|
return result.stdout.strip()
|
|
38
39
|
except subprocess.TimeoutExpired:
|
|
39
|
-
logger.error("claude
|
|
40
|
+
logger.error("claude stdin 超时")
|
|
40
41
|
return None
|
|
41
42
|
except FileNotFoundError:
|
|
42
43
|
logger.error("claude 命令未找到, 请确保已安装 Claude Code")
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.1.2"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|