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.
Files changed (31) hide show
  1. {mailcode-0.1.2 → mailcode-0.1.4}/PKG-INFO +1 -1
  2. mailcode-0.1.4/mailcode/__init__.py +1 -0
  3. {mailcode-0.1.2 → mailcode-0.1.4}/mailcode/relay/email_listener.py +110 -15
  4. {mailcode-0.1.2 → mailcode-0.1.4}/mailcode/utils/claude_runner.py +7 -6
  5. {mailcode-0.1.2 → mailcode-0.1.4}/mailcode.egg-info/PKG-INFO +1 -1
  6. {mailcode-0.1.2 → mailcode-0.1.4}/pyproject.toml +1 -1
  7. mailcode-0.1.2/mailcode/__init__.py +0 -1
  8. {mailcode-0.1.2 → mailcode-0.1.4}/LICENSE +0 -0
  9. {mailcode-0.1.2 → mailcode-0.1.4}/README.md +0 -0
  10. {mailcode-0.1.2 → mailcode-0.1.4}/mailcode/channels/__init__.py +0 -0
  11. {mailcode-0.1.2 → mailcode-0.1.4}/mailcode/channels/email_channel.py +0 -0
  12. {mailcode-0.1.2 → mailcode-0.1.4}/mailcode/cli.py +0 -0
  13. {mailcode-0.1.2 → mailcode-0.1.4}/mailcode/config.py +0 -0
  14. {mailcode-0.1.2 → mailcode-0.1.4}/mailcode/health.py +0 -0
  15. {mailcode-0.1.2 → mailcode-0.1.4}/mailcode/provider_presets.py +0 -0
  16. {mailcode-0.1.2 → mailcode-0.1.4}/mailcode/relay/__init__.py +0 -0
  17. {mailcode-0.1.2 → mailcode-0.1.4}/mailcode/relay/conversation_handler.py +0 -0
  18. {mailcode-0.1.2 → mailcode-0.1.4}/mailcode/relay/scheduler.py +0 -0
  19. {mailcode-0.1.2 → mailcode-0.1.4}/mailcode/relay/security.py +0 -0
  20. {mailcode-0.1.2 → mailcode-0.1.4}/mailcode/relay/stateless_handler.py +0 -0
  21. {mailcode-0.1.2 → mailcode-0.1.4}/mailcode/resources/default.json +0 -0
  22. {mailcode-0.1.2 → mailcode-0.1.4}/mailcode/schedule_cli.py +0 -0
  23. {mailcode-0.1.2 → mailcode-0.1.4}/mailcode/server.py +0 -0
  24. {mailcode-0.1.2 → mailcode-0.1.4}/mailcode/session_cli.py +0 -0
  25. {mailcode-0.1.2 → mailcode-0.1.4}/mailcode/utils/__init__.py +0 -0
  26. {mailcode-0.1.2 → mailcode-0.1.4}/mailcode/utils/logging.py +0 -0
  27. {mailcode-0.1.2 → mailcode-0.1.4}/mailcode.egg-info/SOURCES.txt +0 -0
  28. {mailcode-0.1.2 → mailcode-0.1.4}/mailcode.egg-info/dependency_links.txt +0 -0
  29. {mailcode-0.1.2 → mailcode-0.1.4}/mailcode.egg-info/entry_points.txt +0 -0
  30. {mailcode-0.1.2 → mailcode-0.1.4}/mailcode.egg-info/top_level.txt +0 -0
  31. {mailcode-0.1.2 → mailcode-0.1.4}/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.4
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.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
- 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":
@@ -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, "(RFC822)")
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
- emails = self.fetch_unread_emails(dry_run=dry_run)
595
+ try:
596
+ emails = self.fetch_unread_emails(dry_run=dry_run)
562
597
 
563
- for email_entry in emails:
564
- if dry_run:
565
- continue
598
+ for email_entry in emails:
599
+ if dry_run:
600
+ continue
566
601
 
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}")
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
- if not dry_run:
572
- self._save_state({
573
- "processed_uids": list(self.processed_uids),
574
- "sent_messages": self.sent_messages,
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 -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.4
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.4"
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