修复自定义邮箱收码模式默认手动

This commit is contained in:
QLHazyCoder
2026-05-28 06:14:04 +08:00
parent 1960b6e21a
commit 2992cc88ac
18 changed files with 1154 additions and 6 deletions
+428
View File
@@ -0,0 +1,428 @@
import email
import html
import imaplib
import json
import os
import re
import secrets
import sqlite3
import ssl
import string
import traceback
from datetime import datetime, timezone
from email.header import decode_header
from email.utils import getaddresses, parseaddr, parsedate_to_datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
def load_dotenv_file(path):
if not os.path.exists(path):
return
with open(path, "r", encoding="utf-8") as env_file:
for raw_line in env_file:
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip()
if not key or key in os.environ:
continue
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"):
value = value[1:-1]
os.environ[key] = value
PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
load_dotenv_file(os.path.join(PROJECT_ROOT, ".env"))
HOST = "127.0.0.1"
PORT = int(os.environ.get("FLOWPILOT_CUSTOM_MAIL_HELPER_PORT", "17374"))
IMAP_HOST = os.environ.get("FLOWPILOT_CUSTOM_IMAP_HOST", "imap.mxhichina.com")
IMAP_PORT = int(os.environ.get("FLOWPILOT_CUSTOM_IMAP_PORT", "993"))
IMAP_USER = os.environ.get("FLOWPILOT_CUSTOM_IMAP_USER", "")
IMAP_PASS = os.environ.get("FLOWPILOT_CUSTOM_IMAP_PASS", "")
IMAP_MAILBOX = os.environ.get("FLOWPILOT_CUSTOM_IMAP_MAILBOX", "INBOX")
REQUEST_TIMEOUT_SECONDS = int(os.environ.get("FLOWPILOT_CUSTOM_IMAP_TIMEOUT", "45"))
DEFAULT_TOP = 20
RANDOM_EMAIL_DB_PATH = os.environ.get(
"FLOWPILOT_RANDOM_EMAIL_DB_PATH",
os.path.join(PROJECT_ROOT, "data", "custom-mail-helper.sqlite3"),
)
RANDOM_EMAIL_MAX_COUNT = int(os.environ.get("FLOWPILOT_RANDOM_EMAIL_MAX_COUNT", "20"))
PUBLIC_ENV_KEYS = ["FLOWPILOT_SUB2API_REDIRECT_URI"]
DEFAULT_MAIL_FROM_ALLOW = [
"no-reply@codeium.com",
"noreply@codeium.com",
"no-reply@windsurf.com",
"noreply@windsurf.com",
"noreply@tm.openai.com",
"noreply@tm1.openai.com",
]
def json_response(handler, status, payload):
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
handler.send_response(status)
handler.send_header("Content-Type", "application/json; charset=utf-8")
handler.send_header("Content-Length", str(len(body)))
handler.send_header("Access-Control-Allow-Origin", "*")
handler.send_header("Access-Control-Allow-Headers", "Content-Type")
handler.send_header("Access-Control-Allow-Methods", "POST, OPTIONS")
handler.end_headers()
handler.wfile.write(body)
def read_json_payload(handler):
length = int(handler.headers.get("Content-Length", "0") or 0)
raw = handler.rfile.read(length) if length > 0 else b"{}"
try:
return json.loads(raw.decode("utf-8"))
except Exception as exc:
raise RuntimeError(f"Invalid JSON payload: {exc}") from exc
def get_public_env_payload():
return {key: os.environ.get(key, "") for key in PUBLIC_ENV_KEYS}
def decode_mime_header(value):
if not value:
return ""
parts = []
for chunk, charset in decode_header(value):
if isinstance(chunk, bytes):
parts.append(chunk.decode(charset or "utf-8", errors="ignore"))
else:
parts.append(str(chunk))
return "".join(parts).strip()
def extract_text_part(message):
if message.is_multipart():
html_text = ""
for part in message.walk():
if part.get_content_maintype() == "multipart":
continue
if "attachment" in str(part.get("Content-Disposition") or "").lower():
continue
payload = part.get_payload(decode=True) or b""
charset = part.get_content_charset() or "utf-8"
text = payload.decode(charset, errors="ignore").strip()
if part.get_content_type() == "text/plain" and text:
return text
if part.get_content_type() == "text/html" and text and not html_text:
html_text = re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", html.unescape(text))).strip()
return html_text
payload = message.get_payload(decode=True) or b""
charset = message.get_content_charset() or "utf-8"
text = payload.decode(charset, errors="ignore").strip()
if message.get_content_type() == "text/html":
return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", html.unescape(text))).strip()
return text
def to_timestamp_ms(raw_date):
if not raw_date:
return 0
try:
parsed = parsedate_to_datetime(raw_date)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return int(parsed.timestamp() * 1000)
except Exception:
return 0
def to_iso_string(timestamp_ms):
if not timestamp_ms:
return ""
return datetime.fromtimestamp(timestamp_ms / 1000, tz=timezone.utc).isoformat().replace("+00:00", "Z")
def parse_addresses(value):
return [addr.strip().lower() for _, addr in getaddresses([str(value or "")]) if addr.strip()]
def normalize_domain(value):
domain = str(value or "").strip().lower()
if domain.startswith("@"):
domain = domain[1:]
if not re.fullmatch(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+", domain):
raise RuntimeError("Invalid domain")
return domain
def parse_generation_count(value):
if value in (None, ""):
return 1
try:
count = int(value)
except Exception as exc:
raise RuntimeError("n must be an integer") from exc
if count < 1:
raise RuntimeError("n must be >= 1")
if count > RANDOM_EMAIL_MAX_COUNT:
raise RuntimeError(f"n must be <= {RANDOM_EMAIL_MAX_COUNT}")
return count
def ensure_random_email_db():
db_dir = os.path.dirname(RANDOM_EMAIL_DB_PATH)
if db_dir:
os.makedirs(db_dir, exist_ok=True)
connection = sqlite3.connect(RANDOM_EMAIL_DB_PATH)
try:
connection.execute("""
CREATE TABLE IF NOT EXISTS generated_emails (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
prefix TEXT NOT NULL,
domain TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
""")
connection.execute("CREATE INDEX IF NOT EXISTS idx_generated_emails_domain ON generated_emails(domain)")
connection.commit()
return connection
except Exception:
connection.close()
raise
def random_email_prefix():
length = secrets.randbelow(5) + 8
return "".join(secrets.choice(string.ascii_lowercase) for _ in range(length))
def generate_random_emails(payload):
domain = normalize_domain((payload or {}).get("domain"))
count = parse_generation_count((payload or {}).get("n"))
emails = []
connection = ensure_random_email_db()
try:
attempts = 0
max_attempts = max(100, count * 20)
while len(emails) < count and attempts < max_attempts:
attempts += 1
prefix = random_email_prefix()
email_address = f"{prefix}@{domain}"
try:
connection.execute(
"INSERT INTO generated_emails(email, prefix, domain) VALUES (?, ?, ?)",
(email_address, prefix, domain),
)
emails.append(email_address)
except sqlite3.IntegrityError:
continue
if len(emails) != count:
connection.rollback()
raise RuntimeError("Unable to generate enough unique emails")
connection.commit()
return {"email": emails[0] if emails else "", "emails": emails, "domain": domain, "count": len(emails)}
finally:
connection.close()
def normalize_message(message_id, raw_bytes):
parsed = email.message_from_bytes(raw_bytes)
sender_name, sender_addr = parseaddr(parsed.get("From", ""))
subject = decode_mime_header(parsed.get("Subject", ""))
body = extract_text_part(parsed)
timestamp_ms = to_timestamp_ms(parsed.get("Date"))
return {
"id": str(message_id),
"mailbox": IMAP_MAILBOX,
"subject": subject,
"from": {
"emailAddress": {
"address": sender_addr.strip().lower(),
"name": sender_name.strip(),
}
},
"to": parse_addresses(parsed.get("To", "")),
"cc": parse_addresses(parsed.get("Cc", "")),
"deliveredTo": parse_addresses(parsed.get("Delivered-To", "")),
"bodyPreview": body[:500],
"body": {"content": body},
"receivedDateTime": to_iso_string(timestamp_ms),
"receivedTimestamp": timestamp_ms,
}
def fetch_recent_messages(top=DEFAULT_TOP):
if not IMAP_USER or not IMAP_PASS:
raise RuntimeError("Missing FLOWPILOT_CUSTOM_IMAP_USER/FLOWPILOT_CUSTOM_IMAP_PASS")
context = ssl.create_default_context()
client = imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT, ssl_context=context, timeout=REQUEST_TIMEOUT_SECONDS)
try:
client.login(IMAP_USER, IMAP_PASS)
status, _ = client.select(IMAP_MAILBOX)
if status != "OK":
raise RuntimeError(f"Mailbox not found: {IMAP_MAILBOX}")
status, data = client.search(None, "ALL")
if status != "OK" or not data or not data[0]:
return []
message_ids = data[0].split()
selected_ids = list(reversed(message_ids[-max(1, min(int(top or DEFAULT_TOP), 50)):]))
messages = []
for message_id in selected_ids:
fetch_status, fetch_data = client.fetch(message_id, "(RFC822)")
if fetch_status != "OK" or not fetch_data:
continue
raw_bytes = b""
for item in fetch_data:
if isinstance(item, tuple) and len(item) >= 2:
raw_bytes = item[1]
break
if raw_bytes:
messages.append(normalize_message(message_id.decode("utf-8", errors="ignore"), raw_bytes))
messages.sort(key=lambda item: int(item.get("receivedTimestamp") or 0), reverse=True)
return messages
finally:
try:
client.logout()
except Exception:
pass
def extract_code(text, code_patterns=None):
source = str(text or "")
for pattern in code_patterns or []:
try:
source_pattern = str((pattern or {}).get("source") or "").strip()
if not source_pattern:
continue
flags = str((pattern or {}).get("flags") or "").lower()
re_flags = 0
if "i" in flags:
re_flags |= re.IGNORECASE
if "m" in flags:
re_flags |= re.MULTILINE
if "s" in flags:
re_flags |= re.DOTALL
match = re.search(source_pattern, source, flags=re_flags)
if match:
groups = [str(match.group(i) or "").strip() for i in range(1, (match.lastindex or 0) + 1)]
return next((item for item in groups if item), str(match.group(0) or "").strip())
except re.error:
continue
for pattern in [
r"(?:代码为|验证码[^0-9]*?)[\s:]*(\d{6})",
r"(?:log-?in\s+code|enter\s+this\s+code)[^0-9]{0,24}(\d{6})",
r"code(?:\s+is|[\s:])+(\d{6})",
r"\b(\d{6})\b",
]:
match = re.search(pattern, source, flags=re.IGNORECASE)
if match:
return match.group(1)
return ""
def message_matches_target(message, target_email):
target = str(target_email or "").strip().lower()
if not target:
return True
recipients = set(message.get("to") or []) | set(message.get("cc") or []) | set(message.get("deliveredTo") or [])
return target in recipients
def select_latest_code(messages, payload):
target_email = str(payload.get("targetEmail") or "").strip().lower()
filter_after_timestamp = int(payload.get("filterAfterTimestamp") or 0)
excluded = {str(item).strip() for item in payload.get("excludeCodes") or [] if str(item).strip()}
sender_filters = [str(item).strip().lower() for item in payload.get("senderFilters") or [] if str(item).strip()]
if not sender_filters:
sender_filters = DEFAULT_MAIL_FROM_ALLOW
subject_filters = [str(item).strip().lower() for item in payload.get("subjectFilters") or [] if str(item).strip()]
required_keywords = [str(item).strip().lower() for item in payload.get("requiredKeywords") or [] if str(item).strip()]
def candidate(message, apply_time_filter):
timestamp = int(message.get("receivedTimestamp") or 0)
if apply_time_filter and filter_after_timestamp and timestamp and timestamp < filter_after_timestamp:
return None
if not message_matches_target(message, target_email):
return None
sender = str(message.get("from", {}).get("emailAddress", {}).get("address", "")).lower()
subject = str(message.get("subject") or "")
preview = str(message.get("bodyPreview") or "")
body = str((message.get("body") or {}).get("content") or "")
combined = " ".join([sender, subject, preview, body]).lower()
if sender_filters and sender not in sender_filters and not any(item in combined for item in sender_filters):
return None
if subject_filters and not any(item in combined for item in subject_filters):
return None
if required_keywords and not any(item in combined for item in required_keywords):
return None
code = extract_code("\n".join([subject, preview, body, sender]), payload.get("codePatterns") or [])
if not code or code in excluded:
return None
return {"code": code, "message": message}
for use_time_fallback in [False, True]:
matches = [item for item in (candidate(message, not use_time_fallback) for message in messages) if item]
if matches:
matches.sort(key=lambda item: int(item["message"].get("receivedTimestamp") or 0), reverse=True)
best = matches[0]
return {"code": best["code"], "message": best["message"], "usedTimeFallback": use_time_fallback}
return {"code": "", "message": None, "usedTimeFallback": False}
class CustomMailHelperHandler(BaseHTTPRequestHandler):
def do_OPTIONS(self):
self.send_response(204)
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
self.send_header("Access-Control-Allow-Methods", "POST, OPTIONS")
self.end_headers()
def do_POST(self):
try:
payload = read_json_payload(self)
if self.path == "/messages":
messages = fetch_recent_messages(payload.get("top") or DEFAULT_TOP)
json_response(self, 200, {"ok": True, "messages": messages})
return
if self.path == "/code":
messages = fetch_recent_messages(payload.get("top") or DEFAULT_TOP)
selected = select_latest_code(messages, payload)
json_response(self, 200, {
"ok": True,
"code": selected["code"],
"message": selected["message"],
"usedTimeFallback": selected["usedTimeFallback"],
})
return
if self.path == "/health":
json_response(self, 200, {"ok": True})
return
if self.path == "/env":
json_response(self, 200, {"ok": True, "env": get_public_env_payload()})
return
if self.path == "/random-email":
result = generate_random_emails(payload)
json_response(self, 200, {"ok": True, **result})
return
json_response(self, 404, {"ok": False, "error": f"Unsupported path: {self.path}"})
except Exception as exc:
traceback.print_exc()
json_response(self, 500, {"ok": False, "error": str(exc)})
def main():
server = ThreadingHTTPServer((HOST, PORT), CustomMailHelperHandler)
print(f"Custom mail helper listening on http://{HOST}:{PORT}", flush=True)
print(f"IMAP host={IMAP_HOST}:{IMAP_PORT} user={IMAP_USER or '(unset)'} mailbox={IMAP_MAILBOX}", flush=True)
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
server.server_close()
if __name__ == "__main__":
main()