From 31b19e829c0c9abb8127a42e1c4f8d21e72e9342 Mon Sep 17 00:00:00 2001 From: chukk Date: Sat, 23 May 2026 16:04:51 +0800 Subject: [PATCH 01/37] feat: add custom mail verification helper --- .gitignore | 1 + background.js | 107 +++++- background/verification-flow.js | 9 + .../background/steps/fetch-login-code.js | 17 +- .../background/steps/fetch-signup-code.js | 10 +- scripts/custom_mail_helper.py | 338 ++++++++++++++++++ start-custom-mail-helper.bat | 19 + start-custom-mail-helper.command | 16 + tests/background-step4-filter-window.test.js | 36 ++ tests/background-step8-custom-mail.test.js | 60 ++++ ...ackground-verification-flow-module.test.js | 35 ++ 11 files changed, 634 insertions(+), 14 deletions(-) create mode 100644 scripts/custom_mail_helper.py create mode 100644 start-custom-mail-helper.bat create mode 100644 start-custom-mail-helper.command create mode 100644 tests/background-step8-custom-mail.test.js diff --git a/.gitignore b/.gitignore index 9ed4eab3..38370bf0 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ .omx/ /node_modules /.runtime +/.env /docs/新步骤顺序 __pycache__/ *.pyc diff --git a/background.js b/background.js index 62bb91a2..52acc350 100644 --- a/background.js +++ b/background.js @@ -670,6 +670,7 @@ const HOTMAIL_SERVICE_MODE_LOCAL = 'local'; const DEFAULT_HOTMAIL_REMOTE_BASE_URL = ''; const DEFAULT_HOTMAIL_LOCAL_BASE_URL = 'http://127.0.0.1:17373'; const DEFAULT_ACCOUNT_RUN_HISTORY_HELPER_BASE_URL = DEFAULT_HOTMAIL_LOCAL_BASE_URL; +const DEFAULT_CUSTOM_MAIL_HELPER_BASE_URL = 'http://127.0.0.1:17374'; const HOTMAIL_LOCAL_HELPER_TIMEOUT_MS = 45000; const DEFAULT_LUCKMAIL_PROJECT_CODE = 'openai'; const DEFAULT_HERO_SMS_BASE_URL = 'https://hero-sms.com/stubs/handler_api.php'; @@ -5271,6 +5272,106 @@ function buildHotmailLocalEndpoint(baseUrl, path) { return new URL(path, `${normalizedBaseUrl}/`).toString(); } +function buildCustomMailLocalEndpoint(path) { + return new URL(path, `${DEFAULT_CUSTOM_MAIL_HELPER_BASE_URL}/`).toString(); +} + +async function requestCustomMailLocalCode(pollPayload = {}) { + const requestTimeoutMs = HOTMAIL_LOCAL_HELPER_TIMEOUT_MS; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(new Error('timeout')), requestTimeoutMs); + + let response; + try { + response = await fetch(buildCustomMailLocalEndpoint('/code'), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ + top: pollPayload.top || 20, + targetEmail: pollPayload.targetEmail || '', + senderFilters: pollPayload.senderFilters || [], + subjectFilters: pollPayload.subjectFilters || [], + requiredKeywords: pollPayload.requiredKeywords || [], + codePatterns: pollPayload.codePatterns || [], + excludeCodes: pollPayload.excludeCodes || [], + filterAfterTimestamp: Number(pollPayload.filterAfterTimestamp || 0) || 0, + }), + signal: controller.signal, + }); + } catch (err) { + if (err?.name === 'AbortError') { + throw new Error(`自定义邮箱本地助手请求超时(>${Math.round(requestTimeoutMs / 1000)} 秒)`); + } + throw new Error(`自定义邮箱本地助手请求失败:${err.message}`); + } finally { + clearTimeout(timeoutId); + } + + const text = await response.text(); + let payload = {}; + try { + payload = text ? JSON.parse(text) : {}; + } catch { + payload = { raw: text }; + } + + if (!response.ok || payload?.ok === false) { + const errorText = payload?.error || payload?.message || text || `HTTP ${response.status}`; + throw new Error(`自定义邮箱本地助手返回失败:${errorText}`); + } + + return { + code: String(payload?.code || ''), + message: payload?.message || null, + usedTimeFallback: Boolean(payload?.usedTimeFallback), + }; +} + +async function pollCustomMailVerificationCode(step, state, pollPayload = {}) { + const maxAttempts = Number(pollPayload.maxAttempts) || 5; + const intervalMs = Number(pollPayload.intervalMs) || 3000; + let lastError = null; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + throwIfStopped(); + try { + await addLog(`步骤 ${step}:正在通过自定义邮箱本地助手轮询验证码(${attempt}/${maxAttempts})...`, 'info'); + const fetchResult = await requestCustomMailLocalCode({ + ...pollPayload, + targetEmail: pollPayload.targetEmail || state?.email || '', + }); + + if (fetchResult.code) { + if (fetchResult.usedTimeFallback) { + await addLog(`步骤 ${step}:自定义邮箱本地助手使用时间回退后命中验证码。`, 'warn'); + } + await addLog(`步骤 ${step}:已通过自定义邮箱本地助手找到验证码:${fetchResult.code}`, 'ok'); + return { + ok: true, + code: fetchResult.code, + emailTimestamp: fetchResult.message?.receivedTimestamp || Date.now(), + mailId: fetchResult.message?.id || '', + }; + } + + lastError = new Error(`步骤 ${step}:自定义邮箱本地助手暂未返回匹配验证码(${attempt}/${maxAttempts})。`); + await addLog(lastError.message, attempt === maxAttempts ? 'warn' : 'info'); + } catch (err) { + lastError = err; + await addLog(`步骤 ${step}:自定义邮箱本地助手轮询失败:${err.message}`, 'warn'); + } + + if (attempt < maxAttempts) { + await sleepWithStop(intervalMs); + } + } + + throw lastError || new Error(`步骤 ${step}:自定义邮箱本地助手未返回新的匹配验证码。`); +} + async function requestHotmailRemoteMailbox(account, mailbox = 'INBOX') { if (!account?.email) { throw new Error('Hotmail 账号缺少邮箱地址。'); @@ -12537,7 +12638,7 @@ async function ensureAutoEmailReady(targetRun, totalRuns, attemptRuns) { throw new Error(`自定义邮箱号池第 ${targetRun} 个邮箱不存在,请检查号池数量是否与自动轮数一致。`); } await setEmailState(queuedEmail); - await addLog(`=== 目标 ${targetRun}/${totalRuns} 轮:自定义邮箱号池已就绪:${queuedEmail}(第 ${attemptRuns} 次尝试;第 4/8 步仍需手动输入验证码)===`, 'ok'); + await addLog(`=== 目标 ${targetRun}/${totalRuns} 轮:自定义邮箱号池已就绪:${queuedEmail}(第 ${attemptRuns} 次尝试;请确保自定义邮箱本地助手已启动)===`, 'ok'); return queuedEmail; } } @@ -12695,7 +12796,7 @@ async function ensureAutoEmailReady(targetRun, totalRuns, attemptRuns) { throw new Error(`自定义邮箱号池第 ${targetRun} 个邮箱不存在,请检查号池数量是否与自动轮数一致。`); } await setEmailState(queuedEmail); - await addLog(`=== 目标 ${targetRun}/${totalRuns} 轮:自定义邮箱号池已就绪:${queuedEmail}(第 ${attemptRuns} 次尝试;第 4/8 步仍需手动输入验证码)===`, 'ok'); + await addLog(`=== 目标 ${targetRun}/${totalRuns} 轮:自定义邮箱号池已就绪:${queuedEmail}(第 ${attemptRuns} 次尝试;请确保自定义邮箱本地助手已启动)===`, 'ok'); return queuedEmail; } } @@ -13462,6 +13563,7 @@ const verificationFlowHelpers = self.MultiPageBackgroundVerificationFlow?.create closeConflictingTabsForSource, CLOUDFLARE_TEMP_EMAIL_PROVIDER, CLOUD_MAIL_PROVIDER, + CUSTOM_MAIL_PROVIDER: 'custom', completeNodeFromBackground, confirmCustomVerificationStepBypassRequest: (step) => chrome.runtime.sendMessage({ type: 'REQUEST_CUSTOM_VERIFICATION_BYPASS_CONFIRMATION', @@ -13483,6 +13585,7 @@ const verificationFlowHelpers = self.MultiPageBackgroundVerificationFlow?.create MAIL_2925_VERIFICATION_MAX_ATTEMPTS, pollCloudflareTempEmailVerificationCode, pollCloudMailVerificationCode, + pollCustomMailVerificationCode, pollHotmailVerificationCode, pollLuckmailVerificationCode, pollYydsMailVerificationCode, diff --git a/background/verification-flow.js b/background/verification-flow.js index c417724c..137adb35 100644 --- a/background/verification-flow.js +++ b/background/verification-flow.js @@ -12,6 +12,7 @@ closeConflictingTabsForSource, CLOUDFLARE_TEMP_EMAIL_PROVIDER, CLOUD_MAIL_PROVIDER = 'cloudmail', + CUSTOM_MAIL_PROVIDER = 'custom', completeNodeFromBackground, confirmCustomVerificationStepBypassRequest, getNodeIdByStepForState, @@ -29,6 +30,7 @@ MAIL_2925_VERIFICATION_MAX_ATTEMPTS, pollCloudflareTempEmailVerificationCode, pollCloudMailVerificationCode, + pollCustomMailVerificationCode, pollHotmailVerificationCode, pollLuckmailVerificationCode, pollYydsMailVerificationCode, @@ -987,6 +989,13 @@ }, cleanPollOverrides, `轮询${getVerificationCodeLabel(step)}验证码邮箱`); return pollCloudMailVerificationCode(step, state, timedPoll.payload); } + if (mail.provider === CUSTOM_MAIL_PROVIDER && typeof pollCustomMailVerificationCode === 'function') { + const timedPoll = await applyMailPollingTimeBudget(step, { + ...getVerificationPollPayload(step, state), + ...cleanPollOverrides, + }, cleanPollOverrides, `轮询${getVerificationCodeLabel(step)}验证码邮箱`); + return pollCustomMailVerificationCode(step, state, timedPoll.payload); + } if (mail.provider === YYDS_MAIL_PROVIDER) { const timedPoll = await applyMailPollingTimeBudget(step, { ...getVerificationPollPayload(step, state), diff --git a/flows/openai/background/steps/fetch-login-code.js b/flows/openai/background/steps/fetch-login-code.js index 1fc50829..acb5a82e 100644 --- a/flows/openai/background/steps/fetch-login-code.js +++ b/flows/openai/background/steps/fetch-login-code.js @@ -594,14 +594,6 @@ await addLog(`步骤 ${visibleStep}:已固定当前验证码页显示邮箱 ${displayedVerificationEmail} 作为后续匹配目标。`, 'info'); } - if (shouldUseCustomRegistrationEmail(preparedState)) { - await confirmCustomVerificationStepBypass(8, { - completionStep: visibleStep, - promptStep: visibleStep, - }); - return { lastResendAt: latestResendAt }; - } - if (mail.source === 'icloud-mail' && typeof ensureIcloudMailSession === 'function') { await addLog(`步骤 ${visibleStep}:正在确认 iCloud 邮箱登录态...`, 'info'); await ensureIcloudMailSession({ @@ -611,12 +603,21 @@ }); } + if (shouldUseCustomRegistrationEmail(preparedState) && mail.provider !== 'custom') { + await confirmCustomVerificationStepBypass(8, { + completionStep: visibleStep, + promptStep: visibleStep, + }); + return { lastResendAt: latestResendAt }; + } + throwIfStopped(); if ( mail.provider === HOTMAIL_PROVIDER || mail.provider === LUCKMAIL_PROVIDER || mail.provider === CLOUDFLARE_TEMP_EMAIL_PROVIDER || mail.provider === CLOUD_MAIL_PROVIDER + || mail.provider === 'custom' ) { await addLog(`步骤 ${visibleStep}:正在通过 ${mail.label} 轮询验证码...`); } else { diff --git a/flows/openai/background/steps/fetch-signup-code.js b/flows/openai/background/steps/fetch-signup-code.js index 4701b8d7..35dd06d4 100644 --- a/flows/openai/background/steps/fetch-signup-code.js +++ b/flows/openai/background/steps/fetch-signup-code.js @@ -93,14 +93,14 @@ } async function executeSignupEmailVerificationStep(state, stepStartedAt, verificationSessionKey) { - if (shouldUseCustomRegistrationEmail(state)) { + const mail = getMailConfig(state); + if (mail.error) throw new Error(mail.error); + + if (shouldUseCustomRegistrationEmail(state) && mail.provider !== 'custom') { await confirmCustomVerificationStepBypass(4); return; } - const mail = getMailConfig(state); - if (mail.error) throw new Error(mail.error); - const verificationFilterAfterTimestamp = mail.provider === '2925' ? Math.max(0, stepStartedAt - MAIL_2925_FILTER_LOOKBACK_MS) : stepStartedAt; @@ -120,6 +120,7 @@ || mail.provider === LUCKMAIL_PROVIDER || mail.provider === CLOUDFLARE_TEMP_EMAIL_PROVIDER || mail.provider === CLOUD_MAIL_PROVIDER + || mail.provider === 'custom' ) { await addLog(`步骤 4:正在通过 ${mail.label} 轮询验证码...`); } else if (mail.provider === '2925') { @@ -146,6 +147,7 @@ LUCKMAIL_PROVIDER, CLOUDFLARE_TEMP_EMAIL_PROVIDER, CLOUD_MAIL_PROVIDER, + 'custom', ].includes(mail.provider); const signupProfile = buildSignupProfileForVerificationStep(); diff --git a/scripts/custom_mail_helper.py b/scripts/custom_mail_helper.py new file mode 100644 index 00000000..77d7b348 --- /dev/null +++ b/scripts/custom_mail_helper.py @@ -0,0 +1,338 @@ +import email +import html +import imaplib +import json +import os +import re +import ssl +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 +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_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: + return next((str(match.group(i) or "").strip() for i in range(1, (match.lastindex or 0) + 1) if str(match.group(i) or "").strip()), 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 + 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() diff --git a/start-custom-mail-helper.bat b/start-custom-mail-helper.bat new file mode 100644 index 00000000..a168d61d --- /dev/null +++ b/start-custom-mail-helper.bat @@ -0,0 +1,19 @@ +@echo off +setlocal + +cd /d "%~dp0" + +where py >nul 2>nul +if %errorlevel%==0 ( + py -3 scripts\custom_mail_helper.py + goto :eof +) + +where python >nul 2>nul +if %errorlevel%==0 ( + python scripts\custom_mail_helper.py + goto :eof +) + +echo Python 3 not found. Please install Python 3.10+ and try again. +pause diff --git a/start-custom-mail-helper.command b/start-custom-mail-helper.command new file mode 100644 index 00000000..61758a22 --- /dev/null +++ b/start-custom-mail-helper.command @@ -0,0 +1,16 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SCRIPT_DIR" + +if command -v python3 >/dev/null 2>&1; then + exec python3 scripts/custom_mail_helper.py +fi + +if command -v python >/dev/null 2>&1; then + exec python scripts/custom_mail_helper.py +fi + +echo "Python 3 not found. Please install Python 3.10+ and try again." +read -r -p "Press Enter to exit..." diff --git a/tests/background-step4-filter-window.test.js b/tests/background-step4-filter-window.test.js index e9e3a89f..3429b224 100644 --- a/tests/background-step4-filter-window.test.js +++ b/tests/background-step4-filter-window.test.js @@ -6,6 +6,42 @@ const source = fs.readFileSync('flows/openai/background/steps/fetch-signup-code. const globalScope = {}; const api = new Function('self', `${source}; return self.MultiPageBackgroundStep4;`)(globalScope); +test('step 4 routes custom mail provider through resolver instead of manual confirmation', async () => { + let bypassCalls = 0; + let capturedMail = null; + let capturedOptions = null; + const executor = api.createStep4Executor({ + addLog: async () => {}, + chrome: { tabs: { update: async () => {} } }, + completeNodeFromBackground: async () => {}, + confirmCustomVerificationStepBypass: async () => { bypassCalls += 1; }, + getMailConfig: () => ({ provider: 'custom', label: '自定义邮箱' }), + getTabId: async () => 1, + HOTMAIL_PROVIDER: 'hotmail-api', + isTabAlive: async () => false, + LUCKMAIL_PROVIDER: 'luckmail-api', + CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', + CLOUD_MAIL_PROVIDER: 'cloudmail', + resolveVerificationStep: async (_step, _state, mail, options) => { + capturedMail = mail; + capturedOptions = options; + }, + reuseOrCreateTab: async () => {}, + sendToContentScript: async () => ({}), + sendToContentScriptResilient: async () => ({}), + isRetryableContentScriptTransportError: () => false, + shouldUseCustomRegistrationEmail: () => true, + STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000, + throwIfStopped: () => {}, + }); + + await executor.executeStep4({ email: 'target@example.com', mailProvider: 'custom' }); + + assert.equal(bypassCalls, 0); + assert.equal(capturedMail.provider, 'custom'); + assert.equal(capturedOptions.requestFreshCodeFirst, false); +}); + test('step 4 passes a fixed 10-minute lookback window to 2925 mailbox polling', async () => { let capturedOptions = null; let ensureCalls = 0; diff --git a/tests/background-step8-custom-mail.test.js b/tests/background-step8-custom-mail.test.js new file mode 100644 index 00000000..59866ab6 --- /dev/null +++ b/tests/background-step8-custom-mail.test.js @@ -0,0 +1,60 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +const source = fs.readFileSync('flows/openai/background/steps/fetch-login-code.js', 'utf8'); +const globalScope = {}; +const api = new Function('self', `${source}; return self.MultiPageBackgroundStep8;`)(globalScope); + +test('step 8 routes custom mail provider through resolver instead of manual confirmation', async () => { + let bypassCalls = 0; + let capturedMail = null; + let capturedState = null; + let capturedOptions = null; + const executor = api.createStep8Executor({ + addLog: async () => {}, + chrome: { tabs: { update: async () => {} } }, + completeNodeFromBackground: async () => {}, + confirmCustomVerificationStepBypass: async () => { bypassCalls += 1; }, + ensureStep8VerificationPageReady: async () => ({ + state: 'verification_page', + displayedEmail: 'target@example.com', + url: 'https://auth.openai.com/verify', + }), + getMailConfig: () => ({ provider: 'custom', label: '自定义邮箱' }), + getState: async () => ({ mailProvider: 'custom', email: 'target@example.com' }), + getTabId: async () => 1, + HOTMAIL_PROVIDER: 'hotmail-api', + isTabAlive: async () => false, + isVerificationMailPollingError: () => false, + LUCKMAIL_PROVIDER: 'luckmail-api', + CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', + CLOUD_MAIL_PROVIDER: 'cloudmail', + resolveVerificationStep: async (_step, state, mail, options) => { + capturedState = state; + capturedMail = mail; + capturedOptions = options; + }, + rerunStep7ForStep8Recovery: async () => {}, + resolveSignupEmailForFlow: async () => 'target@example.com', + reuseOrCreateTab: async () => {}, + sendToContentScriptResilient: async () => ({}), + setState: async () => {}, + shouldUseCustomRegistrationEmail: () => true, + sleepWithStop: async () => {}, + STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000, + STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS: 1, + throwIfStopped: () => {}, + }); + + await executor.executeStep8({ + mailProvider: 'custom', + email: 'target@example.com', + oauthUrl: 'https://auth.openai.com/oauth', + }); + + assert.equal(bypassCalls, 0); + assert.equal(capturedMail.provider, 'custom'); + assert.equal(capturedState.step8VerificationTargetEmail, 'target@example.com'); + assert.equal(capturedOptions.targetEmail, 'target@example.com'); +}); \ No newline at end of file diff --git a/tests/background-verification-flow-module.test.js b/tests/background-verification-flow-module.test.js index aaecf886..8bdaf8d0 100644 --- a/tests/background-verification-flow-module.test.js +++ b/tests/background-verification-flow-module.test.js @@ -50,3 +50,38 @@ test('verification flow routes YYDS Mail provider to background poller', async ( assert.equal(pollCalls[0].step, 4); assert.equal(pollCalls[0].payload.maxAttempts, 1); }); + +test('verification flow routes custom mail provider to local helper poller', async () => { + const source = fs.readFileSync('background/verification-flow.js', 'utf8'); + const globalScope = {}; + const api = new Function('self', `${source}; return self.MultiPageBackgroundVerificationFlow;`)(globalScope); + const pollCalls = []; + const helpers = api.createVerificationFlowHelpers({ + addLog: async () => {}, + buildVerificationPollPayload: () => ({ maxAttempts: 1, intervalMs: 1, targetEmail: 'target@example.com' }), + CUSTOM_MAIL_PROVIDER: 'custom', + getState: async () => ({}), + getTabId: async () => 1, + isStopError: () => false, + pollCustomMailVerificationCode: async (step, state, payload) => { + pollCalls.push({ step, state, payload }); + return { ok: true, code: '654321', emailTimestamp: 2, mailId: 'custom-msg-1' }; + }, + sendToContentScript: async () => ({}), + setState: async () => {}, + sleepWithStop: async () => {}, + throwIfStopped: () => {}, + }); + + const result = await helpers.pollFreshVerificationCode( + 4, + { mailProvider: 'custom', email: 'target@example.com' }, + { provider: 'custom', label: '自定义邮箱' }, + { disableTimeBudgetCap: true } + ); + + assert.equal(result.code, '654321'); + assert.equal(pollCalls.length, 1); + assert.equal(pollCalls[0].step, 4); + assert.equal(pollCalls[0].payload.targetEmail, 'target@example.com'); +}); From 269742ffe4d4092f6877e32d62072ae6bc5523a7 Mon Sep 17 00:00:00 2001 From: chukk Date: Sat, 23 May 2026 18:50:48 +0800 Subject: [PATCH 02/37] fix: skip post-signup onboarding in step 5 --- flows/openai/content/openai-auth.js | 85 +++++++++++++++++++++++++++++ tests/step5-age-consent.test.js | 3 + tests/step5-direct-complete.test.js | 78 ++++++++++++++++++++++++++ 3 files changed, 166 insertions(+) diff --git a/flows/openai/content/openai-auth.js b/flows/openai/content/openai-auth.js index 2658fd1d..1425a409 100644 --- a/flows/openai/content/openai-auth.js +++ b/flows/openai/content/openai-auth.js @@ -2989,6 +2989,9 @@ function isStep5CompletionChatgptUrl(rawUrl = location.href) { } const path = String(parsed.pathname || ''); + if (/^\/(?:onboarding|questionnaire|survey|personalization|personalize|getting-started|welcome|consult)(?:[/?#]|$)/i.test(path)) { + return false; + } return !/^\/(?:auth\/|create-account\/|email-verification|log-in|add-phone)(?:[/?#]|$)/i.test(path); } catch { return false; @@ -6755,6 +6758,83 @@ function getStep5PostSubmitSuccessState() { return null; } +function isStep5PostSubmitOnboardingPage() { + if (isStep5ProfileStillVisible()) { + return false; + } + + let host = ''; + let path = ''; + try { + const parsed = new URL(String(location.href || '').trim()); + host = String(parsed.hostname || '').toLowerCase(); + path = String(parsed.pathname || '').toLowerCase(); + } catch { + return false; + } + + if (!['chatgpt.com', 'www.chatgpt.com', 'chat.openai.com', 'auth.openai.com', 'auth0.openai.com', 'accounts.openai.com'].includes(host)) { + return false; + } + + if (/\/(?:onboarding|questionnaire|survey|personalization|personalize|getting-started|welcome|consult)(?:[/?#]|$)/i.test(path)) { + return true; + } + + const pageText = getPageTextSnapshot(); + return /(?:what\s+brings\s+you\s+to\s+chatgpt|tell\s+us\s+about\s+yourself|customi[sz]e\s+chatgpt|personalize\s+your\s+experience|how\s+will\s+you\s+use\s+chatgpt|which\s+best\s+describes\s+you|start\s+using\s+chatgpt|skip\s+for\s+now|入门|开始使用|告诉我们|个人化|个性化|问卷|调查|咨询|跳过|稍后|下一步)/i.test(pageText) + && Boolean(findStep5PostSubmitOnboardingAction()); +} + +function findStep5PostSubmitOnboardingAction() { + const candidates = Array.from(document.querySelectorAll('button, [role="button"], a, [role="link"], input[type="button"], input[type="submit"]')); + const scored = []; + + for (const el of candidates) { + if (!isVisibleElement(el) || !isActionEnabled(el)) { + continue; + } + const text = typeof getActionText === 'function' + ? getActionText(el) + : [el?.textContent, el?.value, el?.getAttribute?.('aria-label'), el?.getAttribute?.('title')] + .filter(Boolean) + .join(' ') + .replace(/\s+/g, ' ') + .trim(); + if (!text) { + continue; + } + if (/skip|not\s+now|maybe\s+later|do\s+this\s+later|稍后|以后|跳过|スキップ|後で/i.test(text)) { + scored.push({ el, score: 1 }); + continue; + } + if (/next|continue|start|done|finish|let'?s\s+go|下一步|继续|开始|完成|次へ|続行|始める/i.test(text)) { + scored.push({ el, score: 2 }); + } + } + + scored.sort((a, b) => a.score - b.score); + return scored[0]?.el || null; +} + +async function advanceStep5PostSubmitOnboardingPage() { + if (!isStep5PostSubmitOnboardingPage()) { + return false; + } + + const action = findStep5PostSubmitOnboardingAction(); + if (!action) { + return false; + } + + const text = typeof getActionText === 'function' ? getActionText(action) : String(action?.textContent || action?.value || '').trim(); + log(`步骤 5:检测到注册后的咨询/入门页面,正在点击“${text || '跳过/下一步'}”继续流程。`, 'warn'); + await humanPause(350, 900); + simulateClick(action); + await sleep(1000); + return true; +} + function getStep5SubmitState() { const retryState = getStep5AuthRetryPageState(); const successState = getStep5PostSubmitSuccessState(); @@ -6917,6 +6997,11 @@ async function waitForStep5SubmitOutcome(options = {}) { return successState; } + if (await advanceStep5PostSubmitOnboardingPage()) { + lastSubmitClickAt = Date.now(); + continue; + } + const step5Error = typeof getStep5ErrorText === 'function' ? getStep5ErrorText() : ''; if (step5Error) { lastStep5Error = step5Error; diff --git a/tests/step5-age-consent.test.js b/tests/step5-age-consent.test.js index 0df43671..ae74fac2 100644 --- a/tests/step5-age-consent.test.js +++ b/tests/step5-age-consent.test.js @@ -68,6 +68,9 @@ function getStep5Bundle() { extractFunction('isStep5ProfileStillVisible'), extractFunction('isStep5CompletionChatgptUrl'), extractFunction('getStep5PostSubmitSuccessState'), + extractFunction('findStep5PostSubmitOnboardingAction'), + extractFunction('isStep5PostSubmitOnboardingPage'), + extractFunction('advanceStep5PostSubmitOnboardingPage'), extractFunction('installStep5NavigationCompletionReporter'), extractFunction('waitForStep5SubmitOutcome'), extractFunction('step5_fillNameBirthday'), diff --git a/tests/step5-direct-complete.test.js b/tests/step5-direct-complete.test.js index db01ebb7..afe87537 100644 --- a/tests/step5-direct-complete.test.js +++ b/tests/step5-direct-complete.test.js @@ -63,6 +63,9 @@ function getStep5OutcomeBundle() { extractFunction('isStep5ProfileStillVisible'), extractFunction('isStep5CompletionChatgptUrl'), extractFunction('getStep5PostSubmitSuccessState'), + extractFunction('findStep5PostSubmitOnboardingAction'), + extractFunction('isStep5PostSubmitOnboardingPage'), + extractFunction('advanceStep5PostSubmitOnboardingPage'), extractFunction('installStep5NavigationCompletionReporter'), extractFunction('waitForStep5SubmitOutcome'), ].join('\n'); @@ -1046,6 +1049,81 @@ return { assert.equal(api.snapshot().recoverCalls, 1); }); +test('step 5 advances post-submit onboarding survey page before completing', async () => { + const api = new Function(` +let now = 0; +const clicks = []; +const skipButton = { + textContent: 'Skip for now', + hidden: false, + disabled: false, + getAttribute(name) { + if (name === 'aria-disabled') return 'false'; + return ''; + }, +}; +const location = { + href: 'https://chatgpt.com/onboarding', +}; +const document = { + body: { + innerText: 'What brings you to ChatGPT? Tell us about yourself. Skip for now', + }, + querySelector() { return null; }, + querySelectorAll(selector) { + if (selector === 'button, [role="button"], a, [role="link"], input[type="button"], input[type="submit"]') { + return [skipButton]; + } + return []; + }, +}; + +function throwIfStopped() {} +function log() {} +async function sleep(ms = 0) { now += ms || 250; } +async function humanPause() {} +function simulateClick(el) { + clicks.push(el?.textContent || 'clicked'); + location.href = 'https://chatgpt.com/'; + document.body.innerText = 'ChatGPT'; +} +function isVisibleElement(el) { return Boolean(el) && !el.hidden; } +function isActionEnabled(el) { return Boolean(el) && !el.disabled && el.getAttribute?.('aria-disabled') !== 'true'; } +function getActionText(el) { return el?.textContent || ''; } +function getSignupAuthRetryPathPatterns() { return []; } +function getAuthTimeoutErrorPageState() { return null; } +async function recoverCurrentAuthRetryPage() { throw new Error('should not recover retry page'); } +function createSignupUserAlreadyExistsError() { return new Error('user already exists'); } +function createAuthMaxCheckAttemptsError() { return new Error('max_check_attempts'); } +function getStep5ErrorText() { return ''; } +function getPageTextSnapshot() { return document.body.innerText; } +function isStep5Ready() { return false; } +function isLikelyLoggedInChatgptHomeUrl() { return location.href === 'https://chatgpt.com/'; } +function isOAuthConsentPage() { return false; } +function isAddPhonePageReady() { return false; } + +${extractFunction('isSignupProfilePageUrl')} +${getStep5OutcomeBundle()} + +return { + run() { + return waitForStep5SubmitOutcome({ timeoutMs: 3000 }); + }, + snapshot() { + return { clicks, now }; + }, +}; +`)(); + + const result = await api.run(); + + assert.deepStrictEqual(result, { + state: 'logged_in_home', + url: 'https://chatgpt.com/', + }); + assert.deepStrictEqual(api.snapshot().clicks, ['Skip for now']); +}); + test('step 5 does not treat unknown auth page as left_profile success', () => { const api = new Function(` const location = { From f56f7ce510370dba88def50d18a47f550f34734c Mon Sep 17 00:00:00 2001 From: chukk Date: Sat, 23 May 2026 20:06:08 +0800 Subject: [PATCH 03/37] feat: add HeroSMS operator selection --- background/phone-verification-flow.js | 22 +++ sidepanel/sidepanel.html | 9 + sidepanel/sidepanel.js | 156 ++++++++++++++++++ tests/phone-verification-flow.test.js | 75 ++++++--- ...epanel-phone-verification-settings.test.js | 36 ++++ 5 files changed, 278 insertions(+), 20 deletions(-) diff --git a/background/phone-verification-flow.js b/background/phone-verification-flow.js index 08141efd..868d96e9 100644 --- a/background/phone-verification-flow.js +++ b/background/phone-verification-flow.js @@ -23,6 +23,7 @@ DEFAULT_FIVE_SIM_BASE_URL = 'https://5sim.net/v1', DEFAULT_FIVE_SIM_PRODUCT = 'openai', DEFAULT_FIVE_SIM_OPERATOR = 'any', + DEFAULT_HERO_SMS_OPERATOR = 'any', DEFAULT_FIVE_SIM_COUNTRY_ORDER = ['thailand'], DEFAULT_NEX_SMS_BASE_URL = 'https://api.nexsms.net', DEFAULT_NEX_SMS_COUNTRY_ORDER = [1], @@ -548,6 +549,21 @@ return String(value || '').trim() || fallback; } + function normalizeHeroSmsOperator(value = '', fallback = DEFAULT_HERO_SMS_OPERATOR) { + const normalized = String(value || '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, ''); + if (normalized) { + return normalized; + } + const fallbackNormalized = String(fallback || '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, ''); + return fallbackNormalized || DEFAULT_HERO_SMS_OPERATOR; + } + function inferHeroSmsCountryFromPhoneNumber(phoneNumber = '') { const digits = String(phoneNumber || '').replace(/\D+/g, ''); if (!digits) { @@ -2100,6 +2116,7 @@ provider, apiKey, baseUrl: normalizeUrl(state.heroSmsBaseUrl, DEFAULT_HERO_SMS_BASE_URL), + operator: normalizeHeroSmsOperator(state.heroSmsOperator, DEFAULT_HERO_SMS_OPERATOR), countryCandidates: resolveCountryCandidates(state), }; } @@ -2113,6 +2130,7 @@ provider: PHONE_SMS_PROVIDER_HERO, apiKey, baseUrl: normalizeUrl(state.heroSmsBaseUrl, DEFAULT_HERO_SMS_BASE_URL), + operator: normalizeHeroSmsOperator(state.heroSmsOperator, DEFAULT_HERO_SMS_OPERATOR), countryCandidates: resolveCountryCandidates(state), }; } @@ -2537,6 +2555,10 @@ service: HERO_SMS_SERVICE_CODE, country: countryConfig.id, }; + const operator = normalizeHeroSmsOperator(config?.operator, DEFAULT_HERO_SMS_OPERATOR); + if (operator && operator !== DEFAULT_HERO_SMS_OPERATOR) { + query.operator = operator; + } if (options.maxPrice !== null && options.maxPrice !== undefined) { query.maxPrice = options.maxPrice; if (options.fixedPrice !== false) { diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html index ccc80e7b..3eadc45a 100644 --- a/sidepanel/sidepanel.html +++ b/sidepanel/sidepanel.html @@ -1500,6 +1500,15 @@ +