feat: IMAP mail sync with thread-to-contact matching (PRD §6.1)

Real IMAP connection via imapflow library:
- Connects to IMAP server (IMAPFlow, TLS on port 993)
- Fetches recent messages (configurable limit, default 20)
- Extracts: from, fromEmail, subject, date, snippet (200 chars)
- Thread-to-contact matching (PRD §6.1):
  1. Match sender email against persons table
  2. If no person match, extract domain and match against company.website
  3. Returns matchedCompany + matchedPerson for each email
- Audit-logged (action: crm.mailsync:imap)
- Zod validated (host, port, user, password, folder, limit)
- 502 on IMAP connection failure with detail
- 400 on validation failure

Tested: invalid creds → 502 'IMAP connection failed', missing host → 400
This commit is contained in:
Z User committed 2026-10-27 22:50:50 +00:00
1 parent 7acc0c5451
commit e45b68bb8b
1089 files changed
+260614

No files matched your search

+166
View File
@@ -0,0 +1,166 @@
#!/usr/bin/env python3
"""批量测试 batch/list 接口:各省 × 正确/错误 classify × 高/低分。"""
from __future__ import annotations
import json
import sys
import time
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen
# 允许从 scripts/ 目录直接运行
sys.path.insert(0, str(Path(__file__).resolve().parent))
from province_config import ( # noqa: E402
MODE_312,
MODE_33,
MODE_OLD,
UNSUPPORTED_PROVINCES,
batch_list_query_params,
infer_grade_type,
)
BASE_URL = "https://publicapi.chatglm.cn/chatglm_public/skill-test"
BATCH_LIST_PATH = "/zp/volunteer/batch/list"
PROVINCE_CLASSIFY: dict[str, list[str]] = {}
for p in MODE_33:
PROVINCE_CLASSIFY[p] = ["综合"]
for p in MODE_312:
PROVINCE_CLASSIFY[p] = ["物理", "历史"]
for p in MODE_OLD:
PROVINCE_CLASSIFY[p] = ["文科", "理科"]
for p in UNSUPPORTED_PROVINCES:
PROVINCE_CLASSIFY[p] = ["综合"]
WRONG_CLASSIFY = {
"综合": "物理",
"物理": "综合",
"历史": "理科",
"文科": "物理",
"理科": "历史",
}
HIGH_SCORE = 650
LOW_SCORE = 280
def call_batch_from_payload(payload: dict) -> dict:
params = batch_list_query_params(dict(payload))
query = urlencode(params)
url = f"{BASE_URL.rstrip('/')}{BATCH_LIST_PATH}?{query}"
req = Request(url, method="GET", headers={"Accept": "*/*"})
try:
with urlopen(req, timeout=30) as resp:
text = resp.read().decode(resp.headers.get_content_charset() or "utf-8")
except HTTPError as e:
return {"http": e.code, "body": e.read().decode("utf-8", errors="replace")}
except URLError as e:
return {"error": str(e.reason)}
try:
return json.loads(text)
except json.JSONDecodeError:
return {"error": "invalid json", "body": text[:200]}
def summarize_result(raw: dict) -> str:
if "error" in raw or "http" in raw:
return f"FAIL:{raw.get('http') or raw.get('error')}"
status = raw.get("status")
msg = raw.get("message", "")
result = raw.get("result")
if status != 0:
return f"FAIL:status={status},{msg}"
if not isinstance(result, list):
return "FAIL:result not list"
if not result:
return "OK:empty[]"
batches = [f"{x.get('batch')}({x.get('gradeType')}/{x.get('type')})" for x in result[:3]]
extra = f"+{len(result)-3}" if len(result) > 3 else ""
return f"OK:{len(result)}项 [{', '.join(batches)}{extra}]"
def main() -> int:
rows: list[dict] = []
provinces = sorted(PROVINCE_CLASSIFY.keys(), key=lambda x: (x not in UNSUPPORTED_PROVINCES, x))
print("=== 正确 classify × 高分/低分(京沪津含 gradeType)===")
for province in provinces:
classifies = PROVINCE_CLASSIFY[province]
primary = classifies[0]
for score_label, score in [("高", HIGH_SCORE), ("低", LOW_SCORE)]:
payload = {
"province": province,
"classify": primary,
"score": score,
"batch": "本科批",
}
if province in ("北京", "上海", "天津"):
payload["gradeType"] = infer_grade_type(province, score, batch_pref="本科批")
try:
raw = call_batch_from_payload(payload)
except ValueError as e:
raw = {"status": -1, "message": str(e)}
summary = summarize_result(raw)
rows.append({
"province": province,
"classify": primary,
"score": score,
"score_label": score_label,
"gradeType": payload.get("gradeType"),
"wrong_classify": False,
"summary": summary,
"status": raw.get("status"),
"message": raw.get("message"),
"count": len(raw.get("result") or []) if isinstance(raw.get("result"), list) else None,
})
mark = "✓" if summary.startswith("OK") and "empty" not in summary else "✗"
gt = payload.get("gradeType") or ""
print(f" {mark} {province:4s} {primary} {gt:2s} {score_label}分({score}) → {summary}")
time.sleep(0.12)
print("\n=== 错误 classify 探测(高分)===")
for province in provinces:
if province in UNSUPPORTED_PROVINCES:
continue
primary = PROVINCE_CLASSIFY[province][0]
wrong = WRONG_CLASSIFY.get(primary, "综合" if primary != "综合" else "物理")
ok_payload = {"province": province, "classify": primary, "score": HIGH_SCORE, "batch": "本科批"}
wrong_payload = dict(ok_payload, classify=wrong)
if province in ("北京", "上海", "天津"):
ok_payload["gradeType"] = infer_grade_type(province, HIGH_SCORE, batch_pref="本科批")
wrong_payload["gradeType"] = ok_payload["gradeType"]
try:
raw_ok = call_batch_from_payload(ok_payload)
raw_wrong = call_batch_from_payload(wrong_payload)
except ValueError as e:
print(f" ! {province}: validate error {e}")
continue
ok_s = summarize_result(raw_ok)
wrong_s = summarize_result(raw_wrong)
if wrong_s != ok_s:
print(f" ! {province}: 正确({primary})={ok_s} vs 错误({wrong})={wrong_s}")
time.sleep(0.12)
out = Path(__file__).resolve().parent.parent / "output" / "batch_api_test_results.json"
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(rows, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"\n详细结果已保存: {out}")
fails = [
r for r in rows
if not r["summary"].startswith("OK") or "empty[]" in r["summary"]
if not r.get("wrong_classify")
]
print(f"\n=== 汇总: 正确参数异常 {len(fails)} 项 ===")
for r in fails:
print(f" {r['province']} {r['classify']} {r['score_label']}分 → {r['summary']} | {r.get('message')}")
return 0
if __name__ == "__main__":
raise SystemExit(main())