mailcode 0.1.2__tar.gz → 0.1.4__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.4}/PKG-INFO +1 -1
- mailcode-0.1.4/mailcode/__init__.py +1 -0
- {mailcode-0.1.2 → mailcode-0.1.4}/mailcode/relay/email_listener.py +110 -15
- {mailcode-0.1.2 → mailcode-0.1.4}/mailcode/utils/claude_runner.py +7 -6
- {mailcode-0.1.2 → mailcode-0.1.4}/mailcode.egg-info/PKG-INFO +1 -1
- {mailcode-0.1.2 → mailcode-0.1.4}/pyproject.toml +1 -1
- mailcode-0.1.2/mailcode/__init__.py +0 -1
- {mailcode-0.1.2 → mailcode-0.1.4}/LICENSE +0 -0
- {mailcode-0.1.2 → mailcode-0.1.4}/README.md +0 -0
- {mailcode-0.1.2 → mailcode-0.1.4}/mailcode/channels/__init__.py +0 -0
- {mailcode-0.1.2 → mailcode-0.1.4}/mailcode/channels/email_channel.py +0 -0
- {mailcode-0.1.2 → mailcode-0.1.4}/mailcode/cli.py +0 -0
- {mailcode-0.1.2 → mailcode-0.1.4}/mailcode/config.py +0 -0
- {mailcode-0.1.2 → mailcode-0.1.4}/mailcode/health.py +0 -0
- {mailcode-0.1.2 → mailcode-0.1.4}/mailcode/provider_presets.py +0 -0
- {mailcode-0.1.2 → mailcode-0.1.4}/mailcode/relay/__init__.py +0 -0
- {mailcode-0.1.2 → mailcode-0.1.4}/mailcode/relay/conversation_handler.py +0 -0
- {mailcode-0.1.2 → mailcode-0.1.4}/mailcode/relay/scheduler.py +0 -0
- {mailcode-0.1.2 → mailcode-0.1.4}/mailcode/relay/security.py +0 -0
- {mailcode-0.1.2 → mailcode-0.1.4}/mailcode/relay/stateless_handler.py +0 -0
- {mailcode-0.1.2 → mailcode-0.1.4}/mailcode/resources/default.json +0 -0
- {mailcode-0.1.2 → mailcode-0.1.4}/mailcode/schedule_cli.py +0 -0
- {mailcode-0.1.2 → mailcode-0.1.4}/mailcode/server.py +0 -0
- {mailcode-0.1.2 → mailcode-0.1.4}/mailcode/session_cli.py +0 -0
- {mailcode-0.1.2 → mailcode-0.1.4}/mailcode/utils/__init__.py +0 -0
- {mailcode-0.1.2 → mailcode-0.1.4}/mailcode/utils/logging.py +0 -0
- {mailcode-0.1.2 → mailcode-0.1.4}/mailcode.egg-info/SOURCES.txt +0 -0
- {mailcode-0.1.2 → mailcode-0.1.4}/mailcode.egg-info/dependency_links.txt +0 -0
- {mailcode-0.1.2 → mailcode-0.1.4}/mailcode.egg-info/entry_points.txt +0 -0
- {mailcode-0.1.2 → mailcode-0.1.4}/mailcode.egg-info/top_level.txt +0 -0
- {mailcode-0.1.2 → mailcode-0.1.4}/setup.cfg +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.4"
|
|
@@ -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":
|
|
@@ -359,11 +386,12 @@ class IMAPListener:
|
|
|
359
386
|
|
|
360
387
|
uids = messages[0].split()
|
|
361
388
|
if not uids:
|
|
389
|
+
logger.info("SEARCH UNSEEN 无结果 (可能收到非新邮件的 IMAP 通知)")
|
|
362
390
|
return results
|
|
363
391
|
|
|
364
392
|
for uid_bytes in uids:
|
|
365
393
|
uid = uid_bytes.decode()
|
|
366
|
-
status, msg_data = mail.fetch(uid_bytes, "(
|
|
394
|
+
status, msg_data = mail.fetch(uid_bytes, "(BODY.PEEK[])")
|
|
367
395
|
if status != "OK":
|
|
368
396
|
continue
|
|
369
397
|
|
|
@@ -377,9 +405,11 @@ class IMAPListener:
|
|
|
377
405
|
in_reply_to = msg.get("In-Reply-To", "")
|
|
378
406
|
|
|
379
407
|
if self._is_own_message(msg):
|
|
408
|
+
logger.debug(f"跳过自身邮件: {subject}")
|
|
380
409
|
continue
|
|
381
410
|
|
|
382
411
|
if self._is_duplicate(msg_id, uid):
|
|
412
|
+
logger.debug(f"跳过重复邮件: UID={uid} MsgID={msg_id}")
|
|
383
413
|
continue
|
|
384
414
|
|
|
385
415
|
sender_email = parseaddr(from_header)[1].lower() or from_header.lower()
|
|
@@ -394,6 +424,7 @@ class IMAPListener:
|
|
|
394
424
|
logger.info(f"邮件认证状态 [{sender_email}]: {auth_reason}")
|
|
395
425
|
|
|
396
426
|
if not self.security_checker.is_sender_allowed(sender_email):
|
|
427
|
+
logger.info(f"发件人不在白名单中 [{sender_email}], 已跳过")
|
|
397
428
|
continue
|
|
398
429
|
|
|
399
430
|
body = self._extract_body(msg)
|
|
@@ -539,6 +570,7 @@ class IMAPListener:
|
|
|
539
570
|
finally:
|
|
540
571
|
print("监听器已停止")
|
|
541
572
|
if not dry_run:
|
|
573
|
+
self._prune_old_sent_messages()
|
|
542
574
|
self._save_state({
|
|
543
575
|
"processed_uids": list(self.processed_uids),
|
|
544
576
|
"sent_messages": self.sent_messages,
|
|
@@ -548,31 +580,48 @@ class IMAPListener:
|
|
|
548
580
|
if not self.security_checker.config.get("allowed_senders"):
|
|
549
581
|
logger.warning("发件人白名单为空,所有邮件将被拒绝处理。请在配置文件中设置 allowed_senders")
|
|
550
582
|
self._init_baseline()
|
|
583
|
+
self._prune_old_sent_messages()
|
|
551
584
|
self._save_state({
|
|
552
585
|
"processed_uids": list(self.processed_uids),
|
|
553
586
|
"sent_messages": self.sent_messages,
|
|
554
587
|
})
|
|
555
588
|
iteration = 0
|
|
589
|
+
backoff = _Backoff()
|
|
556
590
|
while not self._stopped.is_set():
|
|
557
591
|
iteration += 1
|
|
558
592
|
if max_iterations and iteration > max_iterations:
|
|
559
593
|
break
|
|
560
594
|
|
|
561
|
-
|
|
595
|
+
try:
|
|
596
|
+
emails = self.fetch_unread_emails(dry_run=dry_run)
|
|
562
597
|
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
598
|
+
for email_entry in emails:
|
|
599
|
+
if dry_run:
|
|
600
|
+
continue
|
|
566
601
|
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
602
|
+
success, message = self.process_email(email_entry, dry_run=dry_run)
|
|
603
|
+
status_icon = "OK" if success else "FAIL"
|
|
604
|
+
logger.info(f"{status_icon} {message}")
|
|
570
605
|
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
606
|
+
self._prune_old_sent_messages()
|
|
607
|
+
if not dry_run:
|
|
608
|
+
self._save_state({
|
|
609
|
+
"processed_uids": list(self.processed_uids),
|
|
610
|
+
"sent_messages": self.sent_messages,
|
|
611
|
+
})
|
|
612
|
+
|
|
613
|
+
backoff.reset()
|
|
614
|
+
|
|
615
|
+
except (ConnectionError, EOFError, socket.timeout, imaplib.IMAP4.abort) as e:
|
|
616
|
+
delay = backoff.next_delay()
|
|
617
|
+
self._log_connection_error(e, attempt=backoff._n, next_delay=delay)
|
|
618
|
+
if self._stopped.wait(timeout=delay):
|
|
619
|
+
break
|
|
620
|
+
continue
|
|
621
|
+
except imaplib.IMAP4.error as e:
|
|
622
|
+
host = self.imap_config.get("host", "?")
|
|
623
|
+
logger.error(f"IMAP 协议错误, 放弃轮询 [{host}]: {e}")
|
|
624
|
+
break
|
|
576
625
|
|
|
577
626
|
if self._stopped.wait(timeout=self.check_interval):
|
|
578
627
|
break
|
|
@@ -581,6 +630,7 @@ class IMAPListener:
|
|
|
581
630
|
if not self.security_checker.config.get("allowed_senders"):
|
|
582
631
|
logger.warning("发件人白名单为空,所有邮件将被拒绝处理。请在配置文件中设置 allowed_senders")
|
|
583
632
|
self._init_baseline()
|
|
633
|
+
self._prune_old_sent_messages()
|
|
584
634
|
self._save_state({
|
|
585
635
|
"processed_uids": list(self.processed_uids),
|
|
586
636
|
"sent_messages": self.sent_messages,
|
|
@@ -609,6 +659,8 @@ class IMAPListener:
|
|
|
609
659
|
|
|
610
660
|
iteration = 0
|
|
611
661
|
backoff = _Backoff()
|
|
662
|
+
HEALTH_CHECK_INTERVAL = 60 # 秒: IDLE 健康检查周期
|
|
663
|
+
health_check_every = max(1, HEALTH_CHECK_INTERVAL // self._idle_timeout)
|
|
612
664
|
|
|
613
665
|
try:
|
|
614
666
|
while not self._stopped.is_set():
|
|
@@ -630,13 +682,49 @@ class IMAPListener:
|
|
|
630
682
|
break
|
|
631
683
|
mail = self._reconnect()
|
|
632
684
|
mail.select("INBOX")
|
|
685
|
+
try:
|
|
686
|
+
mail.noop()
|
|
687
|
+
except Exception:
|
|
688
|
+
pass
|
|
689
|
+
self._last_connect_time = time.monotonic()
|
|
690
|
+
logger.info("IDLE 收到事件, 已重连")
|
|
633
691
|
|
|
634
692
|
if self._stopped.is_set():
|
|
635
693
|
break
|
|
636
694
|
|
|
695
|
+
# 健康检查: 每 60s 一次 NOOP 探测死连接
|
|
696
|
+
# 必须在 got_event 分支之后 (mail 是重连后的新连接),
|
|
697
|
+
# 避免与 _wait_for_idle_old 后台 IDLE daemon 线程撞协议
|
|
698
|
+
if iteration % health_check_every == 0:
|
|
699
|
+
logger.debug(f"IDLE 健康检查 iter={iteration}")
|
|
700
|
+
try:
|
|
701
|
+
mail.noop()
|
|
702
|
+
except (ConnectionError, EOFError, socket.timeout, imaplib.IMAP4.abort) as e:
|
|
703
|
+
logger.warning(f"IDLE 健康检查失败 ({type(e).__name__}), 触发重连")
|
|
704
|
+
raise
|
|
705
|
+
|
|
706
|
+
# 预判性重连: QQ 邮箱约 2h 静默断连, 提前至 FORCED_RECONNECT_INTERVAL 秒主动重建
|
|
707
|
+
if not got_event and time.monotonic() - self._last_connect_time >= self.FORCED_RECONNECT_INTERVAL:
|
|
708
|
+
logger.info("预判性重连: 连接已持续 %d 秒", self.FORCED_RECONNECT_INTERVAL)
|
|
709
|
+
mail = self._reconnect()
|
|
710
|
+
mail.select("INBOX")
|
|
711
|
+
try:
|
|
712
|
+
mail.noop()
|
|
713
|
+
except Exception:
|
|
714
|
+
pass
|
|
715
|
+
self._last_connect_time = time.monotonic()
|
|
716
|
+
|
|
637
717
|
# 复用现有连接, 避免每轮 fetch 再开一个 (163 反滥用触发点)
|
|
638
718
|
emails = self.fetch_unread_emails(dry_run=dry_run, mail=mail)
|
|
639
719
|
|
|
720
|
+
# 重连后首轮 fetch 若拉到累积未读, 显式记录
|
|
721
|
+
if iteration <= health_check_every + 1 and emails:
|
|
722
|
+
logger.info(f"重连后首轮 fetch 拉到 {len(emails)} 封累积未读")
|
|
723
|
+
|
|
724
|
+
# got_event 但无结果: 可能是非新邮件的 IMAP 通知 (flag 变更/BYE/keepalive)
|
|
725
|
+
if got_event and not emails:
|
|
726
|
+
logger.info("IDLE 事件后无新邮件 (可能为非新邮件的 IMAP 通知)")
|
|
727
|
+
|
|
640
728
|
for email_entry in emails:
|
|
641
729
|
if dry_run:
|
|
642
730
|
continue
|
|
@@ -646,6 +734,7 @@ class IMAPListener:
|
|
|
646
734
|
logger.info(f"{status_icon} {message}")
|
|
647
735
|
|
|
648
736
|
if not dry_run:
|
|
737
|
+
self._prune_old_sent_messages()
|
|
649
738
|
self._save_state({
|
|
650
739
|
"processed_uids": list(self.processed_uids),
|
|
651
740
|
"sent_messages": self.sent_messages,
|
|
@@ -663,6 +752,12 @@ class IMAPListener:
|
|
|
663
752
|
try:
|
|
664
753
|
mail = self._reconnect()
|
|
665
754
|
mail.select("INBOX")
|
|
755
|
+
try:
|
|
756
|
+
mail.noop()
|
|
757
|
+
except Exception:
|
|
758
|
+
pass
|
|
759
|
+
self._last_connect_time = time.monotonic()
|
|
760
|
+
logger.info("退避重连成功, 准备拉取累积未读")
|
|
666
761
|
except imaplib.IMAP4.error as auth_err:
|
|
667
762
|
# 重连时再认证失败 = 配置问题, 放弃
|
|
668
763
|
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
|