优化 AWS 邮件验证码过滤与有效字段输出
This commit is contained in:
@@ -0,0 +1,560 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Report new same-day AWS account-risk mail from an IMAP mailbox."""
|
||||
|
||||
import argparse
|
||||
from contextlib import contextmanager
|
||||
import email
|
||||
import html
|
||||
from html.parser import HTMLParser
|
||||
import imaplib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
from uuid import uuid4
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from email import policy
|
||||
from email.header import decode_header
|
||||
from email.utils import parsedate_to_datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
|
||||
EMPTY = "未提取"
|
||||
NO_NEW = "今日无新增AWS账号风控邮件。"
|
||||
REPORT_HISTORY_LIMIT = 48
|
||||
RISK_TERMS = re.compile(
|
||||
r"\bAWS\b|Amazon Web Services|亚马逊云", re.I
|
||||
)
|
||||
WARNING_TERMS = re.compile(
|
||||
r"suspicious|unwanted|compromis|abuse|phishing|on hold|suspend|"
|
||||
r"verification|validate|limited|restricted|disabled|terminate|risk|fraud|可疑|异常|"
|
||||
r"滥用|钓鱼|暂停|停用|验证|限制|风控|未授权|泄露|风险", re.I
|
||||
)
|
||||
EXPLICIT_CLOSURE_SUBJECT = re.compile(
|
||||
r"permanent closure confirmation|永久关闭确认", re.I
|
||||
)
|
||||
SECRET = re.compile(
|
||||
r"\b(?:AKIA|ASIA)[A-Z0-9]{16}\b|"
|
||||
r"\bBearer\s+[A-Za-z0-9._~+/-]+|"
|
||||
r"(?:\b(?:AWS[_ -]*)?SECRET[_ -]*ACCESS[_ -]*KEY\b|"
|
||||
r"\bSECRET[_ -]*KEY\b|\bACCESS[_ -]*KEY\b|"
|
||||
r"\b(?:PASSWORD|PASSWD|TOKEN|API[_ -]*KEY|AUTHORIZATION[_ -]*CODE)\b|"
|
||||
r"授权码|访问密钥|秘密访问密钥)"
|
||||
r"\s*(?:[::=]|\bis\b|为)?\s*['\"]?[^\s'\",。;,;]+['\"]?",
|
||||
re.I,
|
||||
)
|
||||
ACCOUNT_PATTERNS = [
|
||||
re.compile(r"(?i)\b(?:AWS\s*(?:Account|账户|账号)?\s*ID|Account\s*ID)\s*[::#]?\s*\[?\s*(\d{12})\b"),
|
||||
re.compile(r"(?i)\bAWS\s*(?:账户|账号|Account)\s*\[\s*(\d{12})\s*\]"),
|
||||
re.compile(r"(?i)\bAWS\s*(?:账户|账号|Account)\b[^\n]{0,100}?\[\s*(\d{12})\s*\]"),
|
||||
]
|
||||
RESOURCE = re.compile(r"\b(?:i-[0-9a-f]{8,17}|eni-[0-9a-f]{8,17})\b", re.I)
|
||||
DEADLINE = re.compile(r"\b(?:20\d{2}[-/]\d{1,2}[-/]\d{1,2}|\d{1,2}/\d{1,2}/20\d{2})\b")
|
||||
CODE_TERMS = re.compile(
|
||||
r"verification code|security code|authentication code|one[- ]time (?:password|passcode|code)|"
|
||||
r"\bOTP\b|验证码|驗證碼|动态口令|動態口令|一次性(?:密码|密碼|口令)", re.I
|
||||
)
|
||||
CODE_USE = re.compile(
|
||||
r"sign[- ]?in|log[- ]?in|enter.{0,60}code|use.{0,60}code|code.{0,60}(?:expires?|valid)|"
|
||||
r"登录|登入|输入.{0,30}(?:代码|验证码)|(?:代码|验证码).{0,60}(?:过期|有效)|"
|
||||
r"\b\d{4,8}\b", re.I
|
||||
)
|
||||
ACCOUNT_RISK = re.compile(
|
||||
r"(?:account|account access).{0,60}(?:has been placed on hold|is on hold|on hold|"
|
||||
r"(?:(?:has been|is|was|will be|may be)\s+)?(?:suspended|restricted|disabled|terminated|closed)|"
|
||||
r"permanent closure)|(?:suspend|restrict|disable|terminate|close).{0,30}your.{0,10}account|"
|
||||
r"limited your ability|(?:can't|cannot|could not) validate important details|"
|
||||
r"(?:账[户号]|帳[戶號]).{0,30}(?:已暂停|被暂停|将被暂停|已停用|被停用|已关闭|永久关闭|"
|
||||
r"已受限|受限|被限制|受到限制)|(?:暂停|限制|关闭).{0,20}(?:账[户号]|帳[戶號])|"
|
||||
r"(?:resource.{0,80}implicated in|has been implicated in hosting)|"
|
||||
r"access key.{0,100}compromised|detected potentially unwanted activity", re.I
|
||||
)
|
||||
DOCUMENT_REQUEST = re.compile(
|
||||
r"(?:please|must|required to).{0,50}(?:upload|submit|provide).{0,80}"
|
||||
r"(?:document|identification|passport|statement|proof)|"
|
||||
r"(?:请|需要|必须).{0,30}(?:上传|提交|提供).{0,40}(?:材料|证件|身份证|护照|账单|证明)", re.I
|
||||
)
|
||||
RISK_EVIDENCE = re.compile(ACCOUNT_RISK.pattern + "|" + DOCUMENT_REQUEST.pattern, re.I)
|
||||
CONDITIONAL = re.compile(r"\bif\b|如果|假如|倘若|若|如(?:您|你)", re.I)
|
||||
NON_RESPONSE = re.compile(
|
||||
r"\b(?:do not|don't|fail to|failed to|unless)\b|"
|
||||
r"未(?:能|在)|不(?:上传|提交|回复|回应|联系|提供)|"
|
||||
r"没有(?:上传|提交|回复|回应|联系|提供)", re.I
|
||||
)
|
||||
|
||||
|
||||
def has_explicit_risk(text):
|
||||
# Evaluate each clause, so a conditional security tip cannot hide an actual
|
||||
# restriction elsewhere in the same paragraph (or make a code into an alert).
|
||||
for sentence in re.split(r"[\n.!?。!?;;]+|\b(?:but|however)\b|但是|然而", text, flags=re.I):
|
||||
conditional_help = False
|
||||
# A comma does not end an if-clause's scope over its following advice.
|
||||
# Independent account restrictions still qualify in a later clause.
|
||||
for clause in re.split(r"[,,]+", sentence):
|
||||
if CONDITIONAL.search(clause):
|
||||
conditional_help = not bool(NON_RESPONSE.search(clause))
|
||||
for match in RISK_EVIDENCE.finditer(clause):
|
||||
prefix = clause[:match.start()]
|
||||
if CONDITIONAL.search(prefix) and not NON_RESPONSE.search(prefix):
|
||||
continue
|
||||
if conditional_help and DOCUMENT_REQUEST.match(match.group()):
|
||||
continue
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def is_verification_code(subject, body):
|
||||
"""Ignore login codes, but retain explicit restrictions or document requests."""
|
||||
combined = subject + "\n" + body
|
||||
return bool(
|
||||
CODE_TERMS.search(combined)
|
||||
and CODE_USE.search(body)
|
||||
and not (has_explicit_risk(combined) or EXPLICIT_CLOSURE_SUBJECT.search(subject))
|
||||
)
|
||||
|
||||
|
||||
class PlainHTML(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.parts = []
|
||||
self.hidden = False
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
if tag in ("script", "style"):
|
||||
self.hidden = True
|
||||
elif tag in ("br", "p", "div", "li", "tr"):
|
||||
self.parts.append("\n")
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
if tag in ("script", "style"):
|
||||
self.hidden = False
|
||||
elif tag in ("p", "div", "li", "tr"):
|
||||
self.parts.append("\n")
|
||||
|
||||
def handle_data(self, data):
|
||||
if not self.hidden:
|
||||
self.parts.append(data)
|
||||
|
||||
|
||||
def clean(value, limit=240):
|
||||
value = html.unescape(str(value or ""))
|
||||
value = SECRET.sub("[敏感信息已隐藏]", value)
|
||||
value = re.sub(r"https?://\S+", "[链接已隐藏]", value)
|
||||
value = re.sub(r"[\x00-\x1f\x7f]+", " ", value)
|
||||
value = re.sub(r"\s+", " ", value).strip(" |::-\t")
|
||||
if len(value) > limit:
|
||||
prefix = value[:limit]
|
||||
if " " in prefix and len(prefix.rsplit(" ", 1)[0]) >= limit * 0.75:
|
||||
prefix = prefix.rsplit(" ", 1)[0]
|
||||
value = prefix + "…"
|
||||
return value if value else EMPTY
|
||||
|
||||
|
||||
def header_value(message, name):
|
||||
raw = message.get(name, "")
|
||||
if isinstance(raw, str):
|
||||
return raw
|
||||
return "".join(
|
||||
part.decode(charset or "utf-8", errors="replace") if isinstance(part, bytes) else part
|
||||
for part, charset in decode_header(str(raw))
|
||||
)
|
||||
|
||||
|
||||
def body_text(message):
|
||||
plain, rich = [], []
|
||||
for part in message.walk():
|
||||
if part.is_multipart() or part.get_content_disposition() == "attachment":
|
||||
continue
|
||||
kind = part.get_content_type()
|
||||
if kind not in ("text/plain", "text/html"):
|
||||
continue
|
||||
data = part.get_payload(decode=True)
|
||||
if data is None:
|
||||
continue
|
||||
charset = part.get_content_charset() or "utf-8"
|
||||
try:
|
||||
content = data.decode(charset)
|
||||
except (LookupError, UnicodeError):
|
||||
content = data.decode("utf-8", errors="replace")
|
||||
if kind == "text/html":
|
||||
parser = PlainHTML()
|
||||
parser.feed(content)
|
||||
rich.append("".join(parser.parts))
|
||||
else:
|
||||
plain.append(content)
|
||||
# Plain text is preferred; HTML is a fallback, not a duplicate source.
|
||||
return "\n".join(plain if plain else rich)[:200000]
|
||||
|
||||
|
||||
def account_id(text):
|
||||
for pattern in ACCOUNT_PATTERNS:
|
||||
match = pattern.search(text)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return EMPTY
|
||||
|
||||
|
||||
def lines_matching(body, pattern, max_lines=2, exclude=None):
|
||||
result = []
|
||||
for raw in body.splitlines():
|
||||
if re.match(r"\s*(?:Subject|Date|From|To|Cc|主题|日期|发件人|收件人)\s*[::]", raw, re.I):
|
||||
continue
|
||||
line = clean(raw)
|
||||
if line != EMPTY and pattern.search(line) and not (exclude and exclude.search(line)) and line not in result:
|
||||
result.append(line)
|
||||
if len(result) >= max_lines:
|
||||
break
|
||||
return ";".join(result) if result else EMPTY
|
||||
|
||||
|
||||
def risk_type(subject, body):
|
||||
# A specific subject describes the notice more reliably than quoted mail,
|
||||
# security boilerplate, or a generic footer in the body.
|
||||
subject_rules = [
|
||||
(r"permanent closure confirmation|永久关闭确认", "账户永久关闭确认"),
|
||||
(r"验证您的身份|verify your identity|identity verification", "身份验证通知"),
|
||||
(r"phishing|钓鱼", "钓鱼网站滥用报告"),
|
||||
(r"suspicious activity|存在可疑活动|可疑活动", "账号可疑活动警告"),
|
||||
(r"account on hold|账户已暂停|账户被暂停", "账户验证或暂停"),
|
||||
(r"abuse report|滥用报告", "资源滥用报告"),
|
||||
]
|
||||
for pattern, label in subject_rules:
|
||||
if re.search(pattern, subject, re.I):
|
||||
return label
|
||||
body_rules = [
|
||||
(r"(?:hosting content that resembles a phishing website|资源.{0,60}钓鱼网站)", "钓鱼网站滥用报告"),
|
||||
(r"(?:detected potentially unwanted activity|access key.{0,100}compromised|检测到.{0,60}可疑活动)", "账号可疑活动警告"),
|
||||
(r"(?:account has been placed on hold|账户已暂停|账户被暂停)", "账户验证或暂停"),
|
||||
(r"(?:resource.{0,80}implicated in|资源.{0,60}滥用)", "资源滥用报告"),
|
||||
]
|
||||
for pattern, label in body_rules:
|
||||
if re.search(pattern, body, re.I):
|
||||
return label
|
||||
return "账号风控通知"
|
||||
|
||||
|
||||
def extract(message, received):
|
||||
subject = header_value(message, "Subject")
|
||||
body = body_text(message)
|
||||
combined = subject + "\n" + body
|
||||
if is_verification_code(subject, body):
|
||||
return None
|
||||
if not RISK_TERMS.search(combined) or not (
|
||||
WARNING_TERMS.search(combined) or EXPLICIT_CLOSURE_SUBJECT.search(subject)
|
||||
):
|
||||
return None
|
||||
# Only explicit 12-digit IDs with account context qualify. Case IDs and dates do not.
|
||||
identifier = account_id(combined)
|
||||
warning = lines_matching(body, re.compile(
|
||||
r"(?:account has been placed on hold|limited your ability|"
|
||||
r"your account will be suspended|we may suspend your account|"
|
||||
r"(?:resource.{0,80}implicated in|has been implicated in hosting)|"
|
||||
r"access key.{0,100}compromised|"
|
||||
r"detected potentially unwanted activity|can't validate important details|"
|
||||
r"could not validate important details|检测到.{0,60}可疑活动|"
|
||||
r"账户已暂停|账户被暂停|暂时限制.{0,30}AWS|账号.{0,30}将被暂停)", re.I
|
||||
))
|
||||
action = lines_matching(body, re.compile(
|
||||
r"please take action|please upload|please reply|must contact|must respond|"
|
||||
r"to resolve this issue|立即|请.*(?:联系|回复|上传|删除|更换)", re.I
|
||||
), exclude=re.compile(
|
||||
r"如果.{0,50}(?:发现|有任何问题)|若您.{0,50}有任何问题|"
|
||||
r"if you (?:have any questions|notice|discover)", re.I
|
||||
))
|
||||
resources = list(dict.fromkeys(RESOURCE.findall(body)))[:5]
|
||||
deadlines = []
|
||||
for line in body.splitlines():
|
||||
if re.search(r"respond by|contact AWS by|before|until|deadline|due by|之前|截止|有效期", line, re.I):
|
||||
deadlines.extend(DEADLINE.findall(line))
|
||||
return {
|
||||
"received": received,
|
||||
"subject": clean(subject),
|
||||
"account": identifier,
|
||||
"type": risk_type(subject, body),
|
||||
"warning": warning,
|
||||
"resources": "、".join(resources) if resources else EMPTY,
|
||||
"action": action,
|
||||
"deadline": clean(deadlines[0]) if deadlines else EMPTY,
|
||||
}
|
||||
|
||||
|
||||
def format_report(record, zone):
|
||||
lines = ["【AWS账号风控邮件】"]
|
||||
received = record.get("received")
|
||||
if received is not None:
|
||||
lines.append(f"收件时间:{received.astimezone(zone):%Y-%m-%d %H:%M:%S} ({zone.key})")
|
||||
for key, label in (
|
||||
("subject", "邮件主题"), ("account", "AWS账号ID"), ("type", "风险类型"),
|
||||
("warning", "警告信息"), ("resources", "涉及资源"),
|
||||
("action", "要求操作"), ("deadline", "截止时间"),
|
||||
):
|
||||
value = record.get(key)
|
||||
if value is not None and str(value).strip() not in ("", EMPTY):
|
||||
lines.append(f"{label}:{value}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def day_window(zone, date_override=None):
|
||||
today = datetime.fromisoformat(date_override).date() if date_override else datetime.now(zone).date()
|
||||
start = datetime(today.year, today.month, today.day, tzinfo=zone)
|
||||
return start, start + timedelta(days=1)
|
||||
|
||||
|
||||
def parse_internaldate(fetch_meta):
|
||||
match = re.search(rb'INTERNALDATE "([^"]+)"', fetch_meta, re.I)
|
||||
if not match:
|
||||
raise ValueError("IMAP 响应缺少 INTERNALDATE")
|
||||
result = parsedate_to_datetime(match.group(1).decode("ascii"))
|
||||
if result.tzinfo is None:
|
||||
raise ValueError("IMAP INTERNALDATE 缺少时区")
|
||||
return result
|
||||
|
||||
|
||||
def atomic_save(path, state):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temp = path.with_name(path.name + f".{os.getpid()}.tmp")
|
||||
try:
|
||||
with temp.open("w", encoding="utf-8") as stream:
|
||||
json.dump(state, stream, ensure_ascii=False, indent=2)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temp, path)
|
||||
finally:
|
||||
temp.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def load_state(path):
|
||||
if not path.exists():
|
||||
return {"mailboxes": {}}
|
||||
with path.open(encoding="utf-8") as stream:
|
||||
state = json.load(stream)
|
||||
if not isinstance(state.get("mailboxes"), dict):
|
||||
raise ValueError("状态文件格式无效")
|
||||
return state
|
||||
|
||||
|
||||
def report_history(state):
|
||||
history = state.get("report_history", [])
|
||||
if not isinstance(history, list) or any(
|
||||
not isinstance(item, dict)
|
||||
or not isinstance(item.get("id"), str)
|
||||
or not item["id"]
|
||||
or not isinstance(item.get("report"), str)
|
||||
or not item["report"]
|
||||
for item in history
|
||||
):
|
||||
raise ValueError("报告历史格式无效")
|
||||
# Existing state files only held the most recent report.
|
||||
legacy = state.get("last_report")
|
||||
if isinstance(legacy, str) and legacy and not history:
|
||||
return [{"id": "legacy", "report": legacy}]
|
||||
return history
|
||||
|
||||
|
||||
def save_report_batch(state, report):
|
||||
history = report_history(state)
|
||||
if history and history[0]["id"] == "legacy":
|
||||
history = history.copy()
|
||||
batch_id = f"{datetime.now(timezone.utc):%Y%m%dT%H%M%SZ}-{uuid4().hex[:12]}"
|
||||
state["report_history"] = (history + [{"id": batch_id, "report": report}])[-REPORT_HISTORY_LIMIT:]
|
||||
state["last_report"] = report
|
||||
|
||||
|
||||
@contextmanager
|
||||
def state_lock(path):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.with_name(path.name + ".lock").open("a+b") as stream:
|
||||
if os.name == "nt":
|
||||
import msvcrt
|
||||
stream.seek(0)
|
||||
if not stream.read(1):
|
||||
stream.seek(0)
|
||||
stream.write(b"0")
|
||||
stream.flush()
|
||||
stream.seek(0)
|
||||
msvcrt.locking(stream.fileno(), msvcrt.LK_LOCK, 1)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
stream.seek(0)
|
||||
msvcrt.locking(stream.fileno(), msvcrt.LK_UNLCK, 1)
|
||||
else:
|
||||
import fcntl
|
||||
fcntl.flock(stream.fileno(), fcntl.LOCK_EX)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
fcntl.flock(stream.fileno(), fcntl.LOCK_UN)
|
||||
|
||||
|
||||
def check_imap(config, zone, start, end, state_path):
|
||||
with state_lock(state_path):
|
||||
return _check_imap_unlocked(config, zone, start, end, state_path)
|
||||
|
||||
|
||||
def imap_response_reason(data, settings):
|
||||
parts = data if isinstance(data, (list, tuple)) else [data]
|
||||
reason = " ".join(
|
||||
part.decode("utf-8", errors="replace") if isinstance(part, bytes) else str(part)
|
||||
for part in parts if part is not None
|
||||
)
|
||||
for field in ("authorization_code", "username"):
|
||||
value = settings.get(field)
|
||||
if value:
|
||||
reason = reason.replace(str(value), "[敏感信息已隐藏]")
|
||||
return clean(reason)
|
||||
|
||||
|
||||
def send_imap_id(conn, settings):
|
||||
mode = settings.get("send_id", "auto")
|
||||
if mode != "auto" and not isinstance(mode, bool):
|
||||
raise ValueError("IMAP 配置 send_id 只能为 auto、true 或 false")
|
||||
host = str(settings["host"]).lower().rstrip(".")
|
||||
enabled = host == "imap.163.com" if mode == "auto" else mode
|
||||
if not enabled:
|
||||
return
|
||||
try:
|
||||
status, data = conn.xatom("ID", '("name" "IMAPClient" "version" "1.0.0")')
|
||||
except Exception as exc:
|
||||
reason = imap_response_reason(str(exc), settings)
|
||||
raise RuntimeError(f"IMAP ID 命令失败:{type(exc).__name__}: {reason}") from None
|
||||
if status != "OK":
|
||||
raise RuntimeError(f"IMAP ID 命令返回 {status}:{imap_response_reason(data, settings)}")
|
||||
|
||||
|
||||
def _check_imap_unlocked(config, zone, start, end, state_path):
|
||||
settings = config["imap"]
|
||||
for field in ("host", "username", "authorization_code"):
|
||||
if not settings.get(field) or str(settings[field]).startswith("填写"):
|
||||
raise ValueError(f"请填写 IMAP 配置:{field}")
|
||||
state = load_state(state_path)
|
||||
mailbox = settings.get("mailbox", "INBOX")
|
||||
key = f"{settings['username']}@{settings['host']}/{mailbox}"
|
||||
reports = []
|
||||
with imaplib.IMAP4_SSL(settings["host"], int(settings.get("port", 993))) as conn:
|
||||
send_imap_id(conn, settings)
|
||||
try:
|
||||
login_status, login_data = conn.login(settings["username"], settings["authorization_code"])
|
||||
except Exception as exc:
|
||||
raise RuntimeError(
|
||||
f"IMAP 登录失败:{type(exc).__name__}: {imap_response_reason(str(exc), settings)}"
|
||||
) from None
|
||||
if login_status != "OK":
|
||||
raise RuntimeError(f"IMAP 登录失败:{imap_response_reason(login_data, settings)}")
|
||||
status, data = conn.select(mailbox, readonly=True)
|
||||
if status != "OK":
|
||||
raise RuntimeError(f"无法选择 IMAP 邮箱:{imap_response_reason(data, settings)}")
|
||||
validity = (conn.response("UIDVALIDITY")[1] or [b""])[0]
|
||||
if not validity:
|
||||
raise RuntimeError("IMAP 未返回 UIDVALIDITY,无法安全去重")
|
||||
validity = validity.decode("ascii") if isinstance(validity, bytes) else str(validity)
|
||||
entry = state["mailboxes"].setdefault(key, {"uidvalidity": validity, "uids": [], "message_ids": []})
|
||||
if entry.get("uidvalidity") != validity:
|
||||
entry = {"uidvalidity": validity, "uids": [], "message_ids": entry.get("message_ids", [])}
|
||||
state["mailboxes"][key] = entry
|
||||
seen_uids = set(entry["uids"])
|
||||
seen_ids = set(entry["message_ids"])
|
||||
# SEARCH date uses the server's calendar day. Two UTC days of margin
|
||||
# keep the later INTERNALDATE timezone comparison authoritative.
|
||||
search_date = (start.astimezone(timezone.utc) - timedelta(days=2)).strftime("%d-%b-%Y")
|
||||
status, data = conn.uid("SEARCH", None, "SINCE", search_date)
|
||||
if status != "OK":
|
||||
raise RuntimeError("IMAP 搜索失败")
|
||||
for uid in (data[0] or b"").split():
|
||||
uid_str = uid.decode("ascii")
|
||||
if uid_str in seen_uids:
|
||||
continue
|
||||
status, parts = conn.uid("FETCH", uid, "(UID INTERNALDATE BODY.PEEK[])")
|
||||
if status != "OK":
|
||||
raise RuntimeError(f"IMAP 读取 UID {uid_str} 失败")
|
||||
tuples = [part for part in parts if isinstance(part, tuple) and len(part) == 2]
|
||||
if not tuples:
|
||||
raise RuntimeError(f"IMAP UID {uid_str} 无邮件内容")
|
||||
meta, raw = tuples[0]
|
||||
received = parse_internaldate(meta)
|
||||
if not (start <= received.astimezone(zone) < end):
|
||||
continue
|
||||
message = email.message_from_bytes(raw, policy=policy.default)
|
||||
mid = header_value(message, "Message-ID").strip().lower()
|
||||
if mid and mid in seen_ids:
|
||||
entry["uids"].append(uid_str)
|
||||
seen_uids.add(uid_str)
|
||||
continue
|
||||
report = extract(message, received)
|
||||
if report:
|
||||
reports.append(report)
|
||||
entry["uids"].append(uid_str)
|
||||
seen_uids.add(uid_str)
|
||||
if mid:
|
||||
entry["message_ids"].append(mid)
|
||||
seen_ids.add(mid)
|
||||
# Retain bounded report batches alongside deduplication state for explicit recovery.
|
||||
if reports:
|
||||
save_report_batch(state, "\n\n".join(format_report(item, zone) for item in reports))
|
||||
# State is committed only after every message was successfully processed.
|
||||
atomic_save(state_path, state)
|
||||
return reports
|
||||
|
||||
|
||||
def check_eml(folder, zone, start, end):
|
||||
reports = []
|
||||
for path in sorted(folder.glob("*.eml")):
|
||||
message = email.message_from_bytes(path.read_bytes(), policy=policy.default)
|
||||
date = parsedate_to_datetime(header_value(message, "Date"))
|
||||
if date.tzinfo is None:
|
||||
raise ValueError(f"邮件日期缺少时区:{path.name}")
|
||||
if start <= date.astimezone(zone) < end:
|
||||
report = extract(message, date)
|
||||
if report:
|
||||
reports.append(report)
|
||||
return reports
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--config", default="config.json")
|
||||
mode = parser.add_mutually_exclusive_group()
|
||||
mode.add_argument("--eml-dir", type=Path, help="仅本地样本测试,不读写生产状态")
|
||||
mode.add_argument("--replay-last", action="store_true", help="仅重放上次已保存的报告,不连接邮箱")
|
||||
mode.add_argument("--list-report-batches", action="store_true", help="列出可重放报告的批次 ID,不连接邮箱")
|
||||
mode.add_argument("--replay-id", metavar="BATCH_ID", help="按批次 ID 重放报告,不连接邮箱")
|
||||
parser.add_argument("--date", help="本地样本测试日期,YYYY-MM-DD")
|
||||
args = parser.parse_args(argv)
|
||||
if args.date and not args.eml_dir:
|
||||
parser.error("--date 仅限 --eml-dir 本地样本模式")
|
||||
config_path = Path(args.config).resolve()
|
||||
config = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
state_path = Path(config.get("state_file", "state/processed.json"))
|
||||
if not state_path.is_absolute():
|
||||
state_path = config_path.parent / state_path
|
||||
if args.replay_last or args.list_report_batches or args.replay_id:
|
||||
history = report_history(load_state(state_path))
|
||||
if args.list_report_batches:
|
||||
print("\n".join(item["id"] for item in reversed(history)) if history else "没有可重放的报告批次")
|
||||
return
|
||||
if args.replay_last:
|
||||
if not history:
|
||||
raise RuntimeError("没有可重放的上次报告")
|
||||
print(history[-1]["report"])
|
||||
return
|
||||
match = next((item for item in history if item["id"] == args.replay_id), None)
|
||||
if match is None:
|
||||
raise RuntimeError("报告批次 ID 不存在")
|
||||
print(match["report"])
|
||||
return
|
||||
zone = ZoneInfo(config.get("timezone", "Asia/Shanghai"))
|
||||
start, end = day_window(zone, args.date)
|
||||
if args.eml_dir:
|
||||
reports = check_eml(args.eml_dir, zone, start, end)
|
||||
else:
|
||||
reports = check_imap(config, zone, start, end, state_path)
|
||||
print("\n\n".join(format_report(item, zone) for item in reports) if reports else NO_NEW)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception as exc:
|
||||
print(f"邮件检查失败:{type(exc).__name__}: {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
Reference in New Issue
Block a user