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.
- mailcode/__init__.py +1 -0
- mailcode/channels/__init__.py +3 -0
- mailcode/channels/email_channel.py +144 -0
- mailcode/cli.py +360 -0
- mailcode/config.py +248 -0
- mailcode/health.py +128 -0
- mailcode/provider_presets.py +51 -0
- mailcode/relay/__init__.py +3 -0
- mailcode/relay/conversation_handler.py +576 -0
- mailcode/relay/email_listener.py +556 -0
- mailcode/relay/security.py +132 -0
- mailcode/relay/stateless_handler.py +101 -0
- mailcode/resources/default.json +36 -0
- mailcode/server.py +40 -0
- mailcode/session_cli.py +149 -0
- mailcode/utils/__init__.py +3 -0
- mailcode/utils/logging.py +48 -0
- mailcode-0.1.0.dist-info/METADATA +8 -0
- mailcode-0.1.0.dist-info/RECORD +23 -0
- mailcode-0.1.0.dist-info/WHEEL +5 -0
- mailcode-0.1.0.dist-info/entry_points.txt +2 -0
- mailcode-0.1.0.dist-info/licenses/LICENSE +21 -0
- mailcode-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
"""IMAP 邮件监听器(简化版 — 仅支持对话路由)"""
|
|
2
|
+
|
|
3
|
+
import imaplib
|
|
4
|
+
import email
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import threading
|
|
8
|
+
import logging
|
|
9
|
+
from datetime import datetime, timedelta
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import TYPE_CHECKING, Dict, List, Optional, Tuple
|
|
12
|
+
from email.header import decode_header
|
|
13
|
+
from email.utils import parseaddr
|
|
14
|
+
|
|
15
|
+
from mailcode.config import get_imap_config, get_email_config, get_auth_policy, is_session_enabled
|
|
16
|
+
from mailcode.channels.email_channel import EmailChannel
|
|
17
|
+
from mailcode.relay.security import SecurityChecker
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from mailcode.relay.conversation_handler import ConversationHandler
|
|
21
|
+
from mailcode.relay.stateless_handler import StatelessHandler
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger("mailcode")
|
|
24
|
+
|
|
25
|
+
imaplib.Commands["ID"] = ("NONAUTH", "AUTH", "SELECTED")
|
|
26
|
+
|
|
27
|
+
_MAILCODE_HOME = Path.home() / ".config" / "mailcode"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class IMAPListener:
|
|
31
|
+
def __init__(self, imap_config=None, email_config=None, smtp_config=None):
|
|
32
|
+
self.imap_config = imap_config or get_imap_config()
|
|
33
|
+
self.email_config = email_config or get_email_config()
|
|
34
|
+
self.smtp_config = smtp_config
|
|
35
|
+
|
|
36
|
+
_MAILCODE_HOME.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
self.state_path = _MAILCODE_HOME / "state.json"
|
|
38
|
+
|
|
39
|
+
self.security_checker = SecurityChecker()
|
|
40
|
+
self.email_channel = EmailChannel(smtp_config=self.smtp_config, email_config=self.email_config)
|
|
41
|
+
self._conv_handler: Optional["ConversationHandler"] = None
|
|
42
|
+
self._stateless_handler: Optional["StatelessHandler"] = None
|
|
43
|
+
|
|
44
|
+
self.check_interval = self.email_config.get("check_interval", 5)
|
|
45
|
+
self.processed_uids: set = set()
|
|
46
|
+
self.sent_messages: list = []
|
|
47
|
+
self._load_state()
|
|
48
|
+
|
|
49
|
+
self._idle_timeout = 60
|
|
50
|
+
self._idle_ready = threading.Event()
|
|
51
|
+
self._idle_mail = None
|
|
52
|
+
self._idle_thread: Optional[threading.Thread] = None
|
|
53
|
+
self._mail: Optional[imaplib.IMAP4_SSL] = None
|
|
54
|
+
self._connected = False
|
|
55
|
+
self._stopped = threading.Event()
|
|
56
|
+
|
|
57
|
+
def stop(self):
|
|
58
|
+
"""向监听循环发出干净退出信号。从信号处理程序调用。"""
|
|
59
|
+
self._stopped.set()
|
|
60
|
+
if self._idle_mail is not None:
|
|
61
|
+
try:
|
|
62
|
+
self._idle_mail.logout()
|
|
63
|
+
except Exception:
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
def _load_state(self):
|
|
67
|
+
"""加载 state.json, 同步 self.processed_uids 和 self.sent_messages。"""
|
|
68
|
+
if not self.state_path.exists():
|
|
69
|
+
self.processed_uids = set()
|
|
70
|
+
self.sent_messages = []
|
|
71
|
+
return
|
|
72
|
+
try:
|
|
73
|
+
with open(self.state_path, "r", encoding="utf-8") as f:
|
|
74
|
+
data = json.load(f)
|
|
75
|
+
self.processed_uids = set(data.get("processed_uids", []))
|
|
76
|
+
self.sent_messages = list(data.get("sent_messages", []))
|
|
77
|
+
except Exception:
|
|
78
|
+
self.processed_uids = set()
|
|
79
|
+
self.sent_messages = []
|
|
80
|
+
|
|
81
|
+
def _save_state(self, state: dict):
|
|
82
|
+
"""原子写 state 到 state.json。state 应包含 processed_uids + sent_messages。"""
|
|
83
|
+
with open(self.state_path, "w", encoding="utf-8") as f:
|
|
84
|
+
json.dump(state, f, ensure_ascii=False, indent=2)
|
|
85
|
+
|
|
86
|
+
def _prune_old_sent_messages(self):
|
|
87
|
+
"""清理 7 天前的 sent_messages, 限制 processed_uids 上限。"""
|
|
88
|
+
cutoff = datetime.now() - timedelta(days=7)
|
|
89
|
+
filtered = []
|
|
90
|
+
for msg in self.sent_messages:
|
|
91
|
+
sent_at = msg.get("sent_at", "")
|
|
92
|
+
if sent_at:
|
|
93
|
+
try:
|
|
94
|
+
msg_time = datetime.fromisoformat(sent_at)
|
|
95
|
+
if msg_time > cutoff:
|
|
96
|
+
filtered.append(msg)
|
|
97
|
+
except Exception:
|
|
98
|
+
filtered.append(msg)
|
|
99
|
+
self.sent_messages = filtered
|
|
100
|
+
if len(self.processed_uids) > 10000:
|
|
101
|
+
self.processed_uids = set()
|
|
102
|
+
|
|
103
|
+
def _init_baseline(self):
|
|
104
|
+
"""建立启动基线:将当前所有 UNSEEN 邮件标记为已处理,后续只响应新邮件"""
|
|
105
|
+
try:
|
|
106
|
+
mail = self._connect()
|
|
107
|
+
mail.select("INBOX")
|
|
108
|
+
status, messages = mail.search(None, "UNSEEN")
|
|
109
|
+
if status == "OK" and messages[0]:
|
|
110
|
+
count = 0
|
|
111
|
+
for uid_bytes in messages[0].split():
|
|
112
|
+
self.processed_uids.add(uid_bytes.decode())
|
|
113
|
+
count += 1
|
|
114
|
+
logger.info(f"邮件基线已建立: {count} 封历史未读邮件不处理")
|
|
115
|
+
mail.logout()
|
|
116
|
+
except Exception as e:
|
|
117
|
+
logger.warning(f"建立邮件基线失败: {e}")
|
|
118
|
+
|
|
119
|
+
def _connect(self) -> imaplib.IMAP4_SSL:
|
|
120
|
+
host = self.imap_config.get("host", "imap.qq.com")
|
|
121
|
+
port = self.imap_config.get("port", 993)
|
|
122
|
+
user = self.imap_config.get("user", "")
|
|
123
|
+
password = self.imap_config.get("pass", "")
|
|
124
|
+
|
|
125
|
+
mail = imaplib.IMAP4_SSL(host, port)
|
|
126
|
+
mail.sock.settimeout(15)
|
|
127
|
+
# 部分邮件服务商(如网易)要求在登录前发送 ID 指令
|
|
128
|
+
try:
|
|
129
|
+
mail._simple_command("ID", '("name" "mailcode" "vendor" "mailcode" "support-email" "' + user + '")')
|
|
130
|
+
except Exception:
|
|
131
|
+
pass
|
|
132
|
+
mail.login(user, password)
|
|
133
|
+
try:
|
|
134
|
+
mail._simple_command("ID", '("name" "mailcode" "vendor" "mailcode" "support-email" "' + user + '")')
|
|
135
|
+
except Exception:
|
|
136
|
+
pass
|
|
137
|
+
self._mail = mail
|
|
138
|
+
self._connected = True
|
|
139
|
+
return mail
|
|
140
|
+
|
|
141
|
+
def _wait_for_idle(self, mail: imaplib.IMAP4_SSL) -> bool:
|
|
142
|
+
if self._idle_thread and self._idle_thread.is_alive():
|
|
143
|
+
self._idle_thread.join(timeout=3)
|
|
144
|
+
|
|
145
|
+
def idle_thread():
|
|
146
|
+
try:
|
|
147
|
+
while self._idle_ready.is_set():
|
|
148
|
+
mail.idle()
|
|
149
|
+
response = mail.idle_response()
|
|
150
|
+
if response:
|
|
151
|
+
self._idle_mail = mail
|
|
152
|
+
self._idle_ready.clear()
|
|
153
|
+
except Exception:
|
|
154
|
+
logger.exception("IDLE 线程异常")
|
|
155
|
+
|
|
156
|
+
self._idle_ready.set()
|
|
157
|
+
self._idle_thread = threading.Thread(target=idle_thread, daemon=True)
|
|
158
|
+
self._idle_thread.start()
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
self._idle_ready.wait(timeout=self._idle_timeout)
|
|
162
|
+
except Exception:
|
|
163
|
+
pass
|
|
164
|
+
|
|
165
|
+
return not self._idle_ready.is_set()
|
|
166
|
+
|
|
167
|
+
def _reconnect(self) -> imaplib.IMAP4_SSL:
|
|
168
|
+
try:
|
|
169
|
+
self._idle_mail.logout()
|
|
170
|
+
except Exception:
|
|
171
|
+
pass
|
|
172
|
+
self._connected = False
|
|
173
|
+
# 重建 _idle_ready 事件并 set,让 _wait_for_idle 能重新启动 IDLE 线程
|
|
174
|
+
self._idle_ready = threading.Event()
|
|
175
|
+
self._idle_ready.set()
|
|
176
|
+
return self._connect()
|
|
177
|
+
|
|
178
|
+
def _decode_email_header(self, header_value: str) -> str:
|
|
179
|
+
if not header_value:
|
|
180
|
+
return ""
|
|
181
|
+
decoded_parts = []
|
|
182
|
+
for part, encoding in decode_header(header_value):
|
|
183
|
+
if isinstance(part, bytes):
|
|
184
|
+
try:
|
|
185
|
+
decoded_parts.append(part.decode(encoding or "utf-8", errors="replace"))
|
|
186
|
+
except Exception:
|
|
187
|
+
decoded_parts.append(part.decode("utf-8", errors="replace"))
|
|
188
|
+
else:
|
|
189
|
+
decoded_parts.append(part)
|
|
190
|
+
return "".join(decoded_parts)
|
|
191
|
+
|
|
192
|
+
def _extract_body(self, msg) -> str:
|
|
193
|
+
body = ""
|
|
194
|
+
if msg.is_multipart():
|
|
195
|
+
for part in msg.walk():
|
|
196
|
+
content_type = part.get_content_type()
|
|
197
|
+
if content_type == "text/plain":
|
|
198
|
+
payload = part.get_payload(decode=True)
|
|
199
|
+
if payload:
|
|
200
|
+
charset = part.get_content_charset() or "utf-8"
|
|
201
|
+
body = payload.decode(charset, errors="replace")
|
|
202
|
+
break
|
|
203
|
+
else:
|
|
204
|
+
payload = msg.get_payload(decode=True)
|
|
205
|
+
if payload:
|
|
206
|
+
charset = msg.get_content_charset() or "utf-8"
|
|
207
|
+
body = payload.decode(charset, errors="replace")
|
|
208
|
+
return body
|
|
209
|
+
|
|
210
|
+
def _clean_body(self, body: str) -> str:
|
|
211
|
+
lines = body.split("\n")
|
|
212
|
+
cleaned_lines = []
|
|
213
|
+
|
|
214
|
+
for line in lines:
|
|
215
|
+
if re.match(r"-+ ?Original Message", line) or re.match(r".* On .* wrote:", line):
|
|
216
|
+
break
|
|
217
|
+
|
|
218
|
+
if line.startswith(">"):
|
|
219
|
+
continue
|
|
220
|
+
|
|
221
|
+
if line.strip().startswith("-- "):
|
|
222
|
+
break
|
|
223
|
+
|
|
224
|
+
if any(greeting in line.lower() for greeting in ["sent from", "best regards", "thanks", "regards", "sincerely"]):
|
|
225
|
+
if len(line.strip()) < 50:
|
|
226
|
+
break
|
|
227
|
+
|
|
228
|
+
cleaned_lines.append(line)
|
|
229
|
+
|
|
230
|
+
body = "\n".join(cleaned_lines)
|
|
231
|
+
body = re.sub(r"\n{3,}", "\n\n", body)
|
|
232
|
+
return body.strip()
|
|
233
|
+
|
|
234
|
+
def _is_own_message(self, msg) -> bool:
|
|
235
|
+
if msg.get("X-MailCode-Remote-Token"):
|
|
236
|
+
return True
|
|
237
|
+
if msg.get("X-OpenCode-Remote-Token"):
|
|
238
|
+
return True
|
|
239
|
+
return False
|
|
240
|
+
|
|
241
|
+
def _is_duplicate(self, msg_id: str, uid: str) -> bool:
|
|
242
|
+
if uid in self.processed_uids:
|
|
243
|
+
return True
|
|
244
|
+
|
|
245
|
+
for msg in self.sent_messages:
|
|
246
|
+
if msg.get("message_id") == msg_id:
|
|
247
|
+
return True
|
|
248
|
+
|
|
249
|
+
return False
|
|
250
|
+
|
|
251
|
+
def fetch_unread_emails(self, dry_run: bool = False) -> List[Dict]:
|
|
252
|
+
results = []
|
|
253
|
+
mail = None
|
|
254
|
+
try:
|
|
255
|
+
mail = self._connect()
|
|
256
|
+
mail.select("INBOX")
|
|
257
|
+
|
|
258
|
+
status, messages = mail.search(None, "UNSEEN")
|
|
259
|
+
if status != "OK":
|
|
260
|
+
return results
|
|
261
|
+
|
|
262
|
+
uids = messages[0].split()
|
|
263
|
+
if not uids:
|
|
264
|
+
return results
|
|
265
|
+
|
|
266
|
+
for uid_bytes in uids:
|
|
267
|
+
uid = uid_bytes.decode()
|
|
268
|
+
status, msg_data = mail.fetch(uid_bytes, "(RFC822)")
|
|
269
|
+
if status != "OK":
|
|
270
|
+
continue
|
|
271
|
+
|
|
272
|
+
raw_email = msg_data[0][1]
|
|
273
|
+
msg = email.message_from_bytes(raw_email)
|
|
274
|
+
|
|
275
|
+
msg_id = msg.get("Message-ID", "") or msg.get("Message-Id", "") or uid
|
|
276
|
+
from_header = self._decode_email_header(msg.get("From", ""))
|
|
277
|
+
subject = self._decode_email_header(msg.get("Subject", ""))
|
|
278
|
+
references = msg.get("References", "")
|
|
279
|
+
in_reply_to = msg.get("In-Reply-To", "")
|
|
280
|
+
|
|
281
|
+
if self._is_own_message(msg):
|
|
282
|
+
continue
|
|
283
|
+
|
|
284
|
+
if self._is_duplicate(msg_id, uid):
|
|
285
|
+
continue
|
|
286
|
+
|
|
287
|
+
sender_email = parseaddr(from_header)[1].lower() or from_header.lower()
|
|
288
|
+
|
|
289
|
+
auth_header = self._decode_email_header(msg.get("Authentication-Results", ""))
|
|
290
|
+
policy = get_auth_policy()
|
|
291
|
+
auth_valid, auth_reason = SecurityChecker.verify_auth_results(auth_header, policy)
|
|
292
|
+
if not auth_valid:
|
|
293
|
+
logger.warning(f"邮件认证失败 [{sender_email}]: {auth_reason}")
|
|
294
|
+
continue
|
|
295
|
+
elif auth_reason != "OK":
|
|
296
|
+
logger.info(f"邮件认证状态 [{sender_email}]: {auth_reason}")
|
|
297
|
+
|
|
298
|
+
if not self.security_checker.is_sender_allowed(sender_email):
|
|
299
|
+
continue
|
|
300
|
+
|
|
301
|
+
body = self._extract_body(msg)
|
|
302
|
+
cleaned_body = self._clean_body(body)
|
|
303
|
+
|
|
304
|
+
entry = {
|
|
305
|
+
"uid": uid,
|
|
306
|
+
"message_id": msg_id,
|
|
307
|
+
"from": from_header,
|
|
308
|
+
"sender_email": sender_email,
|
|
309
|
+
"subject": subject,
|
|
310
|
+
"body": cleaned_body,
|
|
311
|
+
"references": references,
|
|
312
|
+
"in_reply_to": in_reply_to,
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if dry_run:
|
|
316
|
+
logger.info(f"DRY RUN - UID: {uid}")
|
|
317
|
+
logger.info(f"DRY RUN - From: {from_header}")
|
|
318
|
+
logger.info(f"DRY RUN - Subject: {subject}")
|
|
319
|
+
logger.info(f"DRY RUN - Auth-Results: {auth_header[:200] if auth_header else '(无)'}")
|
|
320
|
+
logger.info(f"DRY RUN - Auth-Status: {auth_reason}")
|
|
321
|
+
logger.info(f"DRY RUN - Cleaned Body:\n{cleaned_body[:500]}")
|
|
322
|
+
results.append(entry)
|
|
323
|
+
continue
|
|
324
|
+
|
|
325
|
+
results.append(entry)
|
|
326
|
+
self.processed_uids.add(uid)
|
|
327
|
+
|
|
328
|
+
except Exception as e:
|
|
329
|
+
logger.error(f"IMAP 监听错误: {e}")
|
|
330
|
+
finally:
|
|
331
|
+
if mail is not None:
|
|
332
|
+
try:
|
|
333
|
+
mail.logout()
|
|
334
|
+
except Exception:
|
|
335
|
+
pass
|
|
336
|
+
|
|
337
|
+
return results
|
|
338
|
+
|
|
339
|
+
def process_email(self, email_entry: Dict, dry_run: bool = False,
|
|
340
|
+
force_session: Optional[bool] = None) -> Tuple[bool, str]:
|
|
341
|
+
"""处理一封邮件: 路由到 conversation / stateless handler。
|
|
342
|
+
|
|
343
|
+
路由表:
|
|
344
|
+
dry_run=True → (True, "dry_run")
|
|
345
|
+
force_session=True → _handle_via_conversation
|
|
346
|
+
force_session=False → _handle_via_stateless
|
|
347
|
+
force_session=None + is_session_enabled()=True → _handle_via_conversation
|
|
348
|
+
force_session=None + is_session_enabled()=False → _handle_via_stateless
|
|
349
|
+
|
|
350
|
+
Args:
|
|
351
|
+
email_entry: fetch_unread_emails 产出的 entry dict
|
|
352
|
+
dry_run: dry-run 模式, 仅日志, 不真发
|
|
353
|
+
force_session: 显式覆盖 config 中 session.enabled
|
|
354
|
+
|
|
355
|
+
Returns:
|
|
356
|
+
(success: bool, mode: str), mode ∈ {"conversation", "stateless", "dry_run"}
|
|
357
|
+
"""
|
|
358
|
+
if dry_run:
|
|
359
|
+
logger.info(
|
|
360
|
+
f"DRY RUN - 邮件: from={email_entry.get('sender_email')} subject={email_entry.get('subject')}"
|
|
361
|
+
)
|
|
362
|
+
return True, "dry_run"
|
|
363
|
+
|
|
364
|
+
# 决定走 conversation 还是 stateless
|
|
365
|
+
if force_session is None:
|
|
366
|
+
use_conversation = is_session_enabled()
|
|
367
|
+
else:
|
|
368
|
+
use_conversation = force_session
|
|
369
|
+
|
|
370
|
+
if use_conversation:
|
|
371
|
+
return self._handle_via_conversation(email_entry)
|
|
372
|
+
return self._handle_via_stateless(email_entry)
|
|
373
|
+
|
|
374
|
+
def _handle_via_conversation(self, email_entry: Dict) -> Tuple[bool, str]:
|
|
375
|
+
"""路由到 ConversationHandler, 处理多轮对话邮件。"""
|
|
376
|
+
if self._conv_handler is None:
|
|
377
|
+
from mailcode.relay.conversation_handler import ConversationHandler
|
|
378
|
+
self._conv_handler = ConversationHandler(
|
|
379
|
+
email_channel=self.email_channel,
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
success = self._conv_handler.handle_email(
|
|
383
|
+
from_email=email_entry["sender_email"],
|
|
384
|
+
subject=email_entry.get("subject", ""),
|
|
385
|
+
body=email_entry.get("body", ""),
|
|
386
|
+
references=email_entry.get("references", ""),
|
|
387
|
+
in_reply_to=email_entry.get("in_reply_to", ""),
|
|
388
|
+
)
|
|
389
|
+
mode = "conversation" if success else "conversation_failed"
|
|
390
|
+
return (success, mode)
|
|
391
|
+
|
|
392
|
+
def _handle_via_stateless(self, email_entry: Dict) -> Tuple[bool, str]:
|
|
393
|
+
"""路由到 StatelessHandler, 处理单次回复邮件。
|
|
394
|
+
|
|
395
|
+
- ``is_session_enabled()=False`` 时: 走 fallback (新默认)
|
|
396
|
+
- ``force_session=False`` 时: 显式单次回复 (CLI 调试)
|
|
397
|
+
"""
|
|
398
|
+
# 提示 fallback 路径, 但仅在"配置没开 session"时 (force_session=False 显式无歧义)
|
|
399
|
+
if not is_session_enabled():
|
|
400
|
+
logger.info("session 关闭, 使用单次回复")
|
|
401
|
+
|
|
402
|
+
if self._stateless_handler is None:
|
|
403
|
+
from mailcode.relay.stateless_handler import StatelessHandler
|
|
404
|
+
self._stateless_handler = StatelessHandler(
|
|
405
|
+
email_channel=self.email_channel,
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
success = self._stateless_handler.handle_email(
|
|
409
|
+
from_email=email_entry["sender_email"],
|
|
410
|
+
subject=email_entry.get("subject", ""),
|
|
411
|
+
body=email_entry.get("body", ""),
|
|
412
|
+
references=email_entry.get("references", ""),
|
|
413
|
+
in_reply_to=email_entry.get("in_reply_to", ""),
|
|
414
|
+
)
|
|
415
|
+
mode = "stateless" if success else "stateless_failed"
|
|
416
|
+
return (success, mode)
|
|
417
|
+
|
|
418
|
+
def listen(self, dry_run: bool = False, max_iterations: Optional[int] = None, use_idle: bool = False):
|
|
419
|
+
mode = "IDLE 长连接" if use_idle else f"轮询({self.check_interval}秒)"
|
|
420
|
+
print(f"IMAP 监听器启动 ({mode})")
|
|
421
|
+
if dry_run:
|
|
422
|
+
print("Dry-run 模式: 仅显示邮件,不注入命令")
|
|
423
|
+
print("按 Ctrl+C 停止")
|
|
424
|
+
|
|
425
|
+
try:
|
|
426
|
+
if use_idle:
|
|
427
|
+
self._listen_idle(dry_run, max_iterations)
|
|
428
|
+
else:
|
|
429
|
+
self._listen_poll(dry_run, max_iterations)
|
|
430
|
+
except KeyboardInterrupt:
|
|
431
|
+
pass
|
|
432
|
+
finally:
|
|
433
|
+
print("监听器已停止")
|
|
434
|
+
if not dry_run:
|
|
435
|
+
self._save_state({
|
|
436
|
+
"processed_uids": list(self.processed_uids),
|
|
437
|
+
"sent_messages": self.sent_messages,
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
def _listen_poll(self, dry_run: bool, max_iterations: Optional[int]):
|
|
441
|
+
if not self.security_checker.config.get("allowed_senders"):
|
|
442
|
+
logger.warning("发件人白名单为空,所有邮件将被拒绝处理。请在配置文件中设置 allowed_senders")
|
|
443
|
+
self._init_baseline()
|
|
444
|
+
self._save_state({
|
|
445
|
+
"processed_uids": list(self.processed_uids),
|
|
446
|
+
"sent_messages": self.sent_messages,
|
|
447
|
+
})
|
|
448
|
+
iteration = 0
|
|
449
|
+
while not self._stopped.is_set():
|
|
450
|
+
iteration += 1
|
|
451
|
+
if max_iterations and iteration > max_iterations:
|
|
452
|
+
break
|
|
453
|
+
|
|
454
|
+
emails = self.fetch_unread_emails(dry_run=dry_run)
|
|
455
|
+
|
|
456
|
+
for email_entry in emails:
|
|
457
|
+
if dry_run:
|
|
458
|
+
continue
|
|
459
|
+
|
|
460
|
+
success, message = self.process_email(email_entry, dry_run=dry_run)
|
|
461
|
+
status_icon = "OK" if success else "FAIL"
|
|
462
|
+
logger.info(f"{status_icon} {message}")
|
|
463
|
+
|
|
464
|
+
if not dry_run:
|
|
465
|
+
self._save_state({
|
|
466
|
+
"processed_uids": list(self.processed_uids),
|
|
467
|
+
"sent_messages": self.sent_messages,
|
|
468
|
+
})
|
|
469
|
+
|
|
470
|
+
if self._stopped.wait(timeout=self.check_interval):
|
|
471
|
+
break
|
|
472
|
+
|
|
473
|
+
def _listen_idle(self, dry_run: bool, max_iterations: Optional[int]):
|
|
474
|
+
if not self.security_checker.config.get("allowed_senders"):
|
|
475
|
+
logger.warning("发件人白名单为空,所有邮件将被拒绝处理。请在配置文件中设置 allowed_senders")
|
|
476
|
+
self._init_baseline()
|
|
477
|
+
self._save_state({
|
|
478
|
+
"processed_uids": list(self.processed_uids),
|
|
479
|
+
"sent_messages": self.sent_messages,
|
|
480
|
+
})
|
|
481
|
+
mail = self._connect()
|
|
482
|
+
mail.select("INBOX")
|
|
483
|
+
|
|
484
|
+
# 检测服务器是否支持 IMAP IDLE
|
|
485
|
+
capabilities = getattr(mail, 'capabilities', None) or ()
|
|
486
|
+
if 'IDLE' not in capabilities:
|
|
487
|
+
logger.warning("IMAP 服务器不支持 IDLE,自动切换为轮询模式")
|
|
488
|
+
try:
|
|
489
|
+
mail.logout()
|
|
490
|
+
except Exception:
|
|
491
|
+
pass
|
|
492
|
+
self._connected = False
|
|
493
|
+
self._mail = None
|
|
494
|
+
return self._listen_poll(dry_run, max_iterations)
|
|
495
|
+
|
|
496
|
+
iteration = 0
|
|
497
|
+
|
|
498
|
+
try:
|
|
499
|
+
while not self._stopped.is_set():
|
|
500
|
+
iteration += 1
|
|
501
|
+
if max_iterations and iteration > max_iterations:
|
|
502
|
+
break
|
|
503
|
+
|
|
504
|
+
if self._stopped.is_set():
|
|
505
|
+
break
|
|
506
|
+
|
|
507
|
+
if self._wait_for_idle(mail):
|
|
508
|
+
if self._stopped.is_set():
|
|
509
|
+
break
|
|
510
|
+
mail = self._reconnect()
|
|
511
|
+
mail.select("INBOX")
|
|
512
|
+
|
|
513
|
+
if self._stopped.is_set():
|
|
514
|
+
break
|
|
515
|
+
|
|
516
|
+
emails = self.fetch_unread_emails(dry_run=dry_run)
|
|
517
|
+
|
|
518
|
+
for email_entry in emails:
|
|
519
|
+
if dry_run:
|
|
520
|
+
continue
|
|
521
|
+
|
|
522
|
+
success, message = self.process_email(email_entry, dry_run=dry_run)
|
|
523
|
+
status_icon = "OK" if success else "FAIL"
|
|
524
|
+
logger.info(f"{status_icon} {message}")
|
|
525
|
+
|
|
526
|
+
if not dry_run:
|
|
527
|
+
self._save_state({
|
|
528
|
+
"processed_uids": list(self.processed_uids),
|
|
529
|
+
"sent_messages": self.sent_messages,
|
|
530
|
+
})
|
|
531
|
+
finally:
|
|
532
|
+
try:
|
|
533
|
+
mail.logout()
|
|
534
|
+
except Exception:
|
|
535
|
+
pass
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
if __name__ == "__main__":
|
|
539
|
+
import argparse
|
|
540
|
+
|
|
541
|
+
parser = argparse.ArgumentParser(description="IMAP 邮件监听器")
|
|
542
|
+
parser.add_argument("--dry-run", action="store_true", help="仅显示邮件,不注入命令")
|
|
543
|
+
parser.add_argument("--once", action="store_true", help="只执行一次轮询")
|
|
544
|
+
parser.add_argument("--idle", action="store_true", help="使用 IMAP IDLE 长连接模式")
|
|
545
|
+
args = parser.parse_args()
|
|
546
|
+
|
|
547
|
+
listener = IMAPListener()
|
|
548
|
+
|
|
549
|
+
if args.once:
|
|
550
|
+
emails = listener.fetch_unread_emails(dry_run=args.dry_run)
|
|
551
|
+
logger.info(f"发现 {len(emails)} 封新邮件")
|
|
552
|
+
for email_entry in emails:
|
|
553
|
+
success, message = listener.process_email(email_entry, dry_run=args.dry_run)
|
|
554
|
+
logger.info(f"{'OK' if success else 'FAIL'} {message}")
|
|
555
|
+
else:
|
|
556
|
+
listener.listen(dry_run=args.dry_run, use_idle=args.idle)
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
from mailcode.config import get_security_config
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SecurityChecker:
|
|
7
|
+
def __init__(self):
|
|
8
|
+
self.config = get_security_config()
|
|
9
|
+
self.blocked_patterns = self.config.get("blocked_commands", [])
|
|
10
|
+
self.allowed_senders = self.config.get("allowed_senders", [])
|
|
11
|
+
|
|
12
|
+
def is_command_safe(self, command: str) -> tuple[bool, str]:
|
|
13
|
+
if not command or not command.strip():
|
|
14
|
+
return False, "命令为空"
|
|
15
|
+
|
|
16
|
+
command_lower = command.lower()
|
|
17
|
+
|
|
18
|
+
dangerous_patterns = [
|
|
19
|
+
(r"rm\s+-rf\s+/", "危险: 递归删除根目录"),
|
|
20
|
+
(r"sudo\s+rm", "危险: sudo 删除操作"),
|
|
21
|
+
(r"chmod\s+777", "危险: 过度宽松的权限"),
|
|
22
|
+
(r"curl.*\|.*sh", "危险: curl pipe sh"),
|
|
23
|
+
(r"wget.*\|.*sh", "危险: wget pipe sh"),
|
|
24
|
+
(r":\(\).*\|.*sh", "危险: fork bomb pipe sh"),
|
|
25
|
+
(r">\s*/dev/sd", "危险: 直接写入块设备"),
|
|
26
|
+
(r"dd\s+if=.*of=/dev/", "危险: dd 直接写入设备"),
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
for pattern, reason in dangerous_patterns:
|
|
30
|
+
if re.search(pattern, command_lower):
|
|
31
|
+
return False, reason
|
|
32
|
+
|
|
33
|
+
for blocked in self.blocked_patterns:
|
|
34
|
+
try:
|
|
35
|
+
if re.search(blocked, command_lower):
|
|
36
|
+
return False, f"命令匹配黑名单: {blocked}"
|
|
37
|
+
except re.error:
|
|
38
|
+
if blocked in command_lower:
|
|
39
|
+
return False, f"命令匹配黑名单: {blocked}"
|
|
40
|
+
|
|
41
|
+
return True, "OK"
|
|
42
|
+
|
|
43
|
+
def is_sender_allowed(self, sender_email: str) -> bool:
|
|
44
|
+
"""检查发件人是否在白名单中。
|
|
45
|
+
|
|
46
|
+
白名单支持两种写法:
|
|
47
|
+
- 全邮箱精确匹配:'you@example.com'
|
|
48
|
+
- 域名后缀匹配:以 @ 开头的字符串,如 '@example.com'
|
|
49
|
+
"""
|
|
50
|
+
if not self.allowed_senders:
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
sender_lower = sender_email.lower().strip()
|
|
54
|
+
if "@" not in sender_lower:
|
|
55
|
+
return False
|
|
56
|
+
|
|
57
|
+
for allowed in self.allowed_senders:
|
|
58
|
+
a = allowed.lower().strip()
|
|
59
|
+
if not a:
|
|
60
|
+
continue
|
|
61
|
+
if a.startswith("@"):
|
|
62
|
+
# 后缀匹配:sender 必须以 @<domain> 结尾
|
|
63
|
+
if sender_lower.endswith(a):
|
|
64
|
+
return True
|
|
65
|
+
else:
|
|
66
|
+
# 全邮箱精确匹配
|
|
67
|
+
if sender_lower == a:
|
|
68
|
+
return True
|
|
69
|
+
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
def validate_command(self, command: str, sender_email: str) -> tuple[bool, str]:
|
|
73
|
+
if not self.is_sender_allowed(sender_email):
|
|
74
|
+
return False, "发件人不在白名单中"
|
|
75
|
+
|
|
76
|
+
return self.is_command_safe(command)
|
|
77
|
+
|
|
78
|
+
@staticmethod
|
|
79
|
+
def verify_auth_results(auth_header: str, policy: str = "warn") -> tuple[bool, str]:
|
|
80
|
+
if policy == "off":
|
|
81
|
+
return True, "auth 校验已关闭"
|
|
82
|
+
|
|
83
|
+
if not auth_header or not auth_header.strip():
|
|
84
|
+
if policy == "strict":
|
|
85
|
+
return False, "邮件缺少 Authentication-Results 头"
|
|
86
|
+
return True, "无 Authentication-Results 头(warn 模式放行)"
|
|
87
|
+
|
|
88
|
+
unfolded = re.sub(r"\n[ \t]+", " ", auth_header)
|
|
89
|
+
|
|
90
|
+
dkim = re.search(
|
|
91
|
+
r"dkim=(pass|fail|softfail|none|neutral|temperror|permerror)",
|
|
92
|
+
unfolded, re.IGNORECASE
|
|
93
|
+
)
|
|
94
|
+
spf = re.search(
|
|
95
|
+
r"spf=(pass|fail|softfail|none|neutral|temperror|permerror)",
|
|
96
|
+
unfolded, re.IGNORECASE
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
dkim_val = dkim.group(1).lower() if dkim else "missing"
|
|
100
|
+
spf_val = spf.group(1).lower() if spf else "missing"
|
|
101
|
+
|
|
102
|
+
def _is_error(v: str) -> bool:
|
|
103
|
+
return v in ("temperror", "permerror")
|
|
104
|
+
|
|
105
|
+
if _is_error(dkim_val) or _is_error(spf_val):
|
|
106
|
+
return True, f"auth 临时/永久错误(放行): dkim={dkim_val}, spf={spf_val}"
|
|
107
|
+
|
|
108
|
+
def _is_not_pass(v: str) -> bool:
|
|
109
|
+
return v != "pass"
|
|
110
|
+
|
|
111
|
+
if _is_not_pass(dkim_val) or _is_not_pass(spf_val):
|
|
112
|
+
if policy == "strict":
|
|
113
|
+
return False, f"邮件认证失败: dkim={dkim_val}, spf={spf_val}"
|
|
114
|
+
return True, f"auth 未完全通过(warn 模式放行): dkim={dkim_val}, spf={spf_val}"
|
|
115
|
+
|
|
116
|
+
return True, "OK"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
if __name__ == "__main__":
|
|
120
|
+
sc = SecurityChecker()
|
|
121
|
+
|
|
122
|
+
test_commands = [
|
|
123
|
+
"ls -la",
|
|
124
|
+
"rm -rf /tmp/test",
|
|
125
|
+
"sudo rm -rf /",
|
|
126
|
+
"curl http://example.com | sh",
|
|
127
|
+
"echo hello",
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
for cmd in test_commands:
|
|
131
|
+
safe, reason = sc.is_command_safe(cmd)
|
|
132
|
+
print(f"{'✅' if safe else '❌'} {cmd}: {reason}")
|