429 lines
16 KiB
Python
429 lines
16 KiB
Python
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()
|