591 lines
25 KiB
Python
591 lines
25 KiB
Python
#!/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
|
||
)
|
||
ACCESS_VALIDATED = re.compile(
|
||
r"your\s+request\s+for\s+accessing\s+AWS\s+resources\s+has\s+been\s+"
|
||
r"(?:successfully\s+)?validated\b|"
|
||
r"您访问\s*AWS\s*资源的请求已(?:得到验证|通过验证|验证成功)", re.I
|
||
)
|
||
RISK_ALERT_SUBJECT = re.compile(
|
||
r"suspicious activity|security (?:alert|warning)|abuse report|phishing|compromis|"
|
||
r"可疑活动|异常活动|滥用|钓鱼|泄露|安全警告", 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)
|
||
RESOURCE_RISK_ACTION = re.compile(
|
||
r"(?:please|must|required to|take action).{0,80}(?:remove|delete|stop|disable|revoke|rotate)"
|
||
r".{0,100}(?:phishing|abus(?:e|ive)|malicious|compromised|unauthorized)|"
|
||
r"(?:请|必须|需要|立即).{0,40}(?:删除|移除|停止|停用|撤销|轮换|更换)"
|
||
r".{0,60}(?:钓鱼|滥用|恶意|泄露|未授权)", re.I
|
||
)
|
||
ACCESS_NOTICE_RISK_EVIDENCE = re.compile(
|
||
RISK_EVIDENCE.pattern + "|" + RESOURCE_RISK_ACTION.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, evidence=RISK_EVIDENCE):
|
||
# 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 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())
|
||
or RESOURCE_RISK_ACTION.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))
|
||
)
|
||
|
||
|
||
def is_access_validated_notice(subject, body):
|
||
"""Ignore completed resource-access verification, while keeping risk notices."""
|
||
combined = subject + "\n" + body
|
||
return bool(
|
||
ACCESS_VALIDATED.search(combined)
|
||
and not (has_explicit_risk(combined, ACCESS_NOTICE_RISK_EVIDENCE)
|
||
or EXPLICIT_CLOSURE_SUBJECT.search(subject)
|
||
or RISK_ALERT_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) or is_access_validated_notice(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)
|