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.
Files changed (31) hide show
  1. {mailcode-0.1.2 → mailcode-0.1.3}/PKG-INFO +1 -1
  2. mailcode-0.1.3/mailcode/__init__.py +1 -0
  3. {mailcode-0.1.2 → mailcode-0.1.3}/mailcode/relay/email_listener.py +102 -15
  4. {mailcode-0.1.2 → mailcode-0.1.3}/mailcode/utils/claude_runner.py +7 -6
  5. {mailcode-0.1.2 → mailcode-0.1.3}/mailcode.egg-info/PKG-INFO +1 -1
  6. {mailcode-0.1.2 → mailcode-0.1.3}/pyproject.toml +1 -1
  7. mailcode-0.1.2/mailcode/__init__.py +0 -1
  8. {mailcode-0.1.2 → mailcode-0.1.3}/LICENSE +0 -0
  9. {mailcode-0.1.2 → mailcode-0.1.3}/README.md +0 -0
  10. {mailcode-0.1.2 → mailcode-0.1.3}/mailcode/channels/__init__.py +0 -0
  11. {mailcode-0.1.2 → mailcode-0.1.3}/mailcode/channels/email_channel.py +0 -0
  12. {mailcode-0.1.2 → mailcode-0.1.3}/mailcode/cli.py +0 -0
  13. {mailcode-0.1.2 → mailcode-0.1.3}/mailcode/config.py +0 -0
  14. {mailcode-0.1.2 → mailcode-0.1.3}/mailcode/health.py +0 -0
  15. {mailcode-0.1.2 → mailcode-0.1.3}/mailcode/provider_presets.py +0 -0
  16. {mailcode-0.1.2 → mailcode-0.1.3}/mailcode/relay/__init__.py +0 -0
  17. {mailcode-0.1.2 → mailcode-0.1.3}/mailcode/relay/conversation_handler.py +0 -0
  18. {mailcode-0.1.2 → mailcode-0.1.3}/mailcode/relay/scheduler.py +0 -0
  19. {mailcode-0.1.2 → mailcode-0.1.3}/mailcode/relay/security.py +0 -0
  20. {mailcode-0.1.2 → mailcode-0.1.3}/mailcode/relay/stateless_handler.py +0 -0
  21. {mailcode-0.1.2 → mailcode-0.1.3}/mailcode/resources/default.json +0 -0
  22. {mailcode-0.1.2 → mailcode-0.1.3}/mailcode/schedule_cli.py +0 -0
  23. {mailcode-0.1.2 → mailcode-0.1.3}/mailcode/server.py +0 -0
  24. {mailcode-0.1.2 → mailcode-0.1.3}/mailcode/session_cli.py +0 -0
  25. {mailcode-0.1.2 → mailcode-0.1.3}/mailcode/utils/__init__.py +0 -0
  26. {mailcode-0.1.2 → mailcode-0.1.3}/mailcode/utils/logging.py +0 -0
  27. {mailcode-0.1.2 → mailcode-0.1.3}/mailcode.egg-info/SOURCES.txt +0 -0
  28. {mailcode-0.1.2 → mailcode-0.1.3}/mailcode.egg-info/dependency_links.txt +0 -0
  29. {mailcode-0.1.2 → mailcode-0.1.3}/mailcode.egg-info/entry_points.txt +0 -0
  30. {mailcode-0.1.2 → mailcode-0.1.3}/mailcode.egg-info/top_level.txt +0 -0
  31. {mailcode-0.1.2 → mailcode-0.1.3}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mailcode
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Summary: Email ↔ AI Agent bidirectional remote command bridge
5
5
  License: MIT
6
6
  Requires-Python: >=3.9
@@ -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
- with open(self.state_path, "w", encoding="utf-8") as f:
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
- return not self._idle_ready.is_set()
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, "(RFC822)")
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
- emails = self.fetch_unread_emails(dry_run=dry_run)
591
+ try:
592
+ emails = self.fetch_unread_emails(dry_run=dry_run)
562
593
 
563
- for email_entry in emails:
564
- if dry_run:
565
- continue
594
+ for email_entry in emails:
595
+ if dry_run:
596
+ continue
566
597
 
567
- success, message = self.process_email(email_entry, dry_run=dry_run)
568
- status_icon = "OK" if success else "FAIL"
569
- logger.info(f"{status_icon} {message}")
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
- if not dry_run:
572
- self._save_state({
573
- "processed_uids": list(self.processed_uids),
574
- "sent_messages": self.sent_messages,
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 -p 子进程超时 (秒)
15
- CLAUDE_TIMEOUT_SECONDS = 300
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 -p`` 子进程。失败返回 None。
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", "-p", prompt, "--dangerously-skip-permissions"],
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 -p 失败: %s", result.stderr[:500])
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 -p 超时")
40
+ logger.error("claude stdin 超时")
40
41
  return None
41
42
  except FileNotFoundError:
42
43
  logger.error("claude 命令未找到, 请确保已安装 Claude Code")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mailcode
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Summary: Email ↔ AI Agent bidirectional remote command bridge
5
5
  License: MIT
6
6
  Requires-Python: >=3.9
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "mailcode"
7
- version = "0.1.2"
7
+ version = "0.1.3"
8
8
  description = "Email ↔ AI Agent bidirectional remote command bridge"
9
9
  requires-python = ">=3.9"
10
10
  license = {text = "MIT"}
@@ -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