From 2992cc88accf100ba00289cf4ab7e7fee684e42f Mon Sep 17 00:00:00 2001 From: QLHazyCoder <2825305047@qq.com> Date: Thu, 28 May 2026 06:14:04 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=87=AA=E5=AE=9A=E4=B9=89?= =?UTF-8?q?=E9=82=AE=E7=AE=B1=E6=94=B6=E7=A0=81=E6=A8=A1=E5=BC=8F=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E6=89=8B=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- background.js | 159 +++++++ background/flow-mail-polling.js | 22 + background/verification-flow.js | 9 + core/flow-kernel/settings-schema.js | 33 ++ .../background/steps/fetch-login-code.js | 4 +- .../background/steps/fetch-signup-code.js | 12 +- scripts/custom_mail_helper.py | 428 ++++++++++++++++++ sidepanel/sidepanel.html | 12 + sidepanel/sidepanel.js | 86 ++++ start-custom-mail-helper.bat | 19 + start-custom-mail-helper.command | 16 + ...ackground-flow-mail-polling-module.test.js | 78 ++++ ...ground-settings-schema-persistence.test.js | 65 +++ tests/background-step8-custom-mail.test.js | 91 ++++ ...ackground-verification-flow-module.test.js | 105 +++++ tests/sidepanel-custom-email-pool.test.js | 17 + 项目完整链路说明.md | 2 + 项目文件结构说明.md | 2 + 18 files changed, 1154 insertions(+), 6 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/background.js b/background.js index 2304971..52182be 100644 --- a/background.js +++ b/background.js @@ -682,6 +682,10 @@ 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 CUSTOM_MAIL_RECEIVE_MODE_MANUAL = 'manual'; +const CUSTOM_MAIL_RECEIVE_MODE_HELPER = 'helper'; +const DEFAULT_CUSTOM_MAIL_RECEIVE_MODE = CUSTOM_MAIL_RECEIVE_MODE_MANUAL; +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'; @@ -1396,6 +1400,8 @@ const PERSISTED_SETTING_DEFAULTS = { mailProvider: '163', mail2925Mode: DEFAULT_MAIL_2925_MODE, mail2925UseAccountPool: false, + customMailReceiveMode: DEFAULT_CUSTOM_MAIL_RECEIVE_MODE, + customMailHelperBaseUrl: DEFAULT_CUSTOM_MAIL_HELPER_BASE_URL, emailGenerator: 'duck', customMailProviderPool: [], customEmailPool: [], @@ -1500,6 +1506,8 @@ const SETTINGS_SCHEMA_VIEW_KEYS = Object.freeze([ 'hostedCheckoutPhoneNumber', 'plusHostedCheckoutOauthDelaySeconds', 'mailProvider', + 'customMailReceiveMode', + 'customMailHelperBaseUrl', 'ipProxyEnabled', 'ipProxyService', 'ipProxyMode', @@ -2798,6 +2806,35 @@ function normalizeMail2925Mode(value = '') { : DEFAULT_MAIL_2925_MODE; } +function normalizeCustomMailReceiveMode(value = '') { + return String(value || '').trim().toLowerCase() === CUSTOM_MAIL_RECEIVE_MODE_HELPER + ? CUSTOM_MAIL_RECEIVE_MODE_HELPER + : DEFAULT_CUSTOM_MAIL_RECEIVE_MODE; +} + +function normalizeCustomMailHelperBaseUrl(value = '') { + const trimmed = String(value || '').trim(); + const candidate = trimmed || DEFAULT_CUSTOM_MAIL_HELPER_BASE_URL; + try { + const parsed = new URL(candidate); + if (!['http:', 'https:'].includes(parsed.protocol)) { + return DEFAULT_CUSTOM_MAIL_HELPER_BASE_URL; + } + parsed.hash = ''; + parsed.search = ''; + parsed.pathname = parsed.pathname.replace(/\/+$/, ''); + const path = parsed.pathname === '/' ? '' : parsed.pathname; + return `${parsed.origin}${path}` || DEFAULT_CUSTOM_MAIL_HELPER_BASE_URL; + } catch { + return DEFAULT_CUSTOM_MAIL_HELPER_BASE_URL; + } +} + +function shouldUseCustomMailHelper(state = {}) { + return isCustomMailProvider(state) + && normalizeCustomMailReceiveMode(state?.customMailReceiveMode) === CUSTOM_MAIL_RECEIVE_MODE_HELPER; +} + function normalizeCloudflareTempEmailLookupMode(value = '') { return String(value || '').trim().toLowerCase() === CLOUDFLARE_TEMP_EMAIL_LOOKUP_MODE_REGISTRATION_EMAIL ? CLOUDFLARE_TEMP_EMAIL_LOOKUP_MODE_REGISTRATION_EMAIL @@ -3406,6 +3443,10 @@ function normalizePersistentSettingValue(key, value) { return normalizeMail2925Mode(value); case 'mail2925UseAccountPool': return Boolean(value); + case 'customMailReceiveMode': + return normalizeCustomMailReceiveMode(value); + case 'customMailHelperBaseUrl': + return normalizeCustomMailHelperBaseUrl(value); case 'emailGenerator': return normalizeEmailGenerator(value); case 'customMailProviderPool': @@ -3819,6 +3860,8 @@ function buildSettingsStatePatchFromFlatUpdates(updates = {}) { assignIfUpdated('plusPaymentMethod', ['flows', 'openai', 'plus', 'plusPaymentMethod']); assignIfUpdated('plusAccountAccessStrategy', ['flows', 'openai', 'plus', 'plusAccountAccessStrategy']); assignIfUpdated('mailProvider', ['services', 'email', 'provider']); + assignIfUpdated('customMailReceiveMode', ['services', 'email', 'customReceiveMode']); + assignIfUpdated('customMailHelperBaseUrl', ['services', 'email', 'customHelperBaseUrl']); assignIfUpdated('ipProxyEnabled', ['services', 'proxy', 'enabled']); assignIfUpdated('ipProxyService', ['services', 'proxy', 'provider']); assignIfUpdated('ipProxyMode', ['services', 'proxy', 'mode']); @@ -5306,6 +5349,115 @@ function buildHotmailLocalEndpoint(baseUrl, path) { return new URL(path, `${normalizedBaseUrl}/`).toString(); } +function buildCustomMailLocalEndpoint(baseUrl, path) { + const normalizedBaseUrl = normalizeCustomMailHelperBaseUrl(baseUrl); + return new URL(path, `${normalizedBaseUrl}/`).toString(); +} + +function getCustomMailHelperBaseUrlForState(state = {}) { + return normalizeCustomMailHelperBaseUrl(state?.customMailHelperBaseUrl); +} + +async function requestCustomMailLocalCode(state = {}, 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(getCustomMailHelperBaseUrlForState(state), '/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 || '').trim(), + message: payload?.message || null, + usedTimeFallback: Boolean(payload?.usedTimeFallback), + }; +} + +async function pollCustomMailVerificationCode(step, state, pollPayload = {}) { + if (!shouldUseCustomMailHelper(state)) { + throw new Error(`步骤 ${step}:自定义邮箱当前为手动确认模式,未启用本地助手自动收码。`); + } + + const maxAttempts = Math.max(1, Math.floor(Number(pollPayload.maxAttempts) || 5)); + const intervalMs = Math.max(1000, Number(pollPayload.intervalMs) || 3000); + let lastError = null; + + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + throwIfStopped(); + try { + await addLog(`步骤 ${step}:正在通过自定义邮箱本地助手轮询验证码(${attempt}/${maxAttempts})...`, 'info'); + const fetchResult = await requestCustomMailLocalCode(state, { + ...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: Number(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 账号缺少邮箱地址。'); @@ -13461,6 +13613,7 @@ const flowMailPollingService = self.MultiPageBackgroundFlowMailPolling?.createFl chrome, CLOUDFLARE_TEMP_EMAIL_PROVIDER, CLOUD_MAIL_PROVIDER, + CUSTOM_MAIL_PROVIDER: 'custom', ensureIcloudMailSession: ensureIcloudMailSessionForVerification, ensureMail2925MailboxSession, getMailConfig, @@ -13473,11 +13626,13 @@ const flowMailPollingService = self.MultiPageBackgroundFlowMailPolling?.createFl LUCKMAIL_PROVIDER, pollCloudflareTempEmailVerificationCode, pollCloudMailVerificationCode, + pollCustomMailVerificationCode, pollHotmailVerificationCode, pollLuckmailVerificationCode, pollYydsMailVerificationCode, reuseOrCreateTab, sendToMailContentScriptResilient, + shouldUseCustomMailHelper, throwIfStopped, YYDS_MAIL_PROVIDER, }); @@ -13488,6 +13643,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', @@ -13509,6 +13665,7 @@ const verificationFlowHelpers = self.MultiPageBackgroundVerificationFlow?.create MAIL_2925_VERIFICATION_MAX_ATTEMPTS, pollCloudflareTempEmailVerificationCode, pollCloudMailVerificationCode, + pollCustomMailVerificationCode, pollHotmailVerificationCode, pollLuckmailVerificationCode, pollYydsMailVerificationCode, @@ -13646,6 +13803,7 @@ const step4Executor = self.MultiPageBackgroundStep4?.createStep4Executor({ sendToContentScriptResilient, isRetryableContentScriptTransportError, shouldUseCustomRegistrationEmail, + shouldUseCustomMailHelper, STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS, throwIfStopped, waitForTabStableComplete, @@ -13716,6 +13874,7 @@ const step8Executor = self.MultiPageBackgroundStep8?.createStep8Executor({ sendToContentScriptResilient, setState, shouldUseCustomRegistrationEmail, + shouldUseCustomMailHelper, sleepWithStop, STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS, STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS, diff --git a/background/flow-mail-polling.js b/background/flow-mail-polling.js index 820115b..e54ca1f 100644 --- a/background/flow-mail-polling.js +++ b/background/flow-mail-polling.js @@ -69,6 +69,7 @@ chrome = (typeof globalThis !== 'undefined' ? globalThis.chrome : null), CLOUDFLARE_TEMP_EMAIL_PROVIDER = 'cloudflare-temp-email', CLOUD_MAIL_PROVIDER = 'cloudmail', + CUSTOM_MAIL_PROVIDER = 'custom', ensureIcloudMailSession = null, ensureMail2925MailboxSession = null, getMailConfig = null, @@ -81,11 +82,13 @@ LUCKMAIL_PROVIDER = 'luckmail-api', pollCloudflareTempEmailVerificationCode = null, pollCloudMailVerificationCode = null, + pollCustomMailVerificationCode = null, pollHotmailVerificationCode = null, pollLuckmailVerificationCode = null, pollYydsMailVerificationCode = null, reuseOrCreateTab = async () => null, sendToMailContentScriptResilient = null, + shouldUseCustomMailHelper = null, throwIfStopped = () => {}, YYDS_MAIL_PROVIDER = 'yyds-mail', } = deps; @@ -107,6 +110,10 @@ label: 'Cloud Mail', poll: pollCloudMailVerificationCode, }], + [normalizeProviderId(CUSTOM_MAIL_PROVIDER), { + label: '自定义邮箱本地助手', + poll: pollCustomMailVerificationCode, + }], [normalizeProviderId(YYDS_MAIL_PROVIDER), { label: 'YYDS Mail', poll: pollYydsMailVerificationCode, @@ -167,11 +174,26 @@ return normalizeProviderId(mail?.provider) === '2925'; } + function isCustomMailProvider(mail = {}) { + return normalizeProviderId(mail?.provider) === normalizeProviderId(CUSTOM_MAIL_PROVIDER); + } + + function canUseCustomMailHelper(state = {}) { + if (typeof shouldUseCustomMailHelper === 'function') { + return Boolean(shouldUseCustomMailHelper(state)); + } + return normalizeProviderId(state?.mailProvider) === normalizeProviderId(CUSTOM_MAIL_PROVIDER) + && normalizeProviderId(state?.customMailReceiveMode) === 'helper'; + } + async function pollThroughApiProvider(providerId, step, state, pollPayload, mail, options) { const handler = apiProviderHandlers.get(providerId); if (!handler) { return null; } + if (isCustomMailProvider(mail) && !canUseCustomMailHelper(state)) { + throw new Error('自定义邮箱当前为手动确认模式,未启用本地 helper 自动收码。'); + } if (typeof handler.poll !== 'function') { throw new Error(`${handler.label} 邮箱轮询能力未接入,无法继续执行。`); } diff --git a/background/verification-flow.js b/background/verification-flow.js index c417724..137adb3 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/core/flow-kernel/settings-schema.js b/core/flow-kernel/settings-schema.js index 0f2cd70..c00ad51 100644 --- a/core/flow-kernel/settings-schema.js +++ b/core/flow-kernel/settings-schema.js @@ -88,6 +88,25 @@ return 'oauth'; }; + const normalizeCustomMailHelperBaseUrl = (value = '') => { + const fallback = 'http://127.0.0.1:17374'; + const trimmed = String(value || '').trim(); + const candidate = trimmed || fallback; + try { + const parsed = new URL(candidate); + if (!['http:', 'https:'].includes(parsed.protocol)) { + return fallback; + } + parsed.hash = ''; + parsed.search = ''; + parsed.pathname = parsed.pathname.replace(/\/+$/, ''); + const path = parsed.pathname === '/' ? '' : parsed.pathname; + return `${parsed.origin}${path}` || fallback; + } catch { + return fallback; + } + }; + function getCanonicalFlowIds() { const ids = Array.isArray(getRegisteredFlowIds()) ? getRegisteredFlowIds() @@ -184,6 +203,8 @@ }, email: { provider: '163', + customReceiveMode: 'manual', + customHelperBaseUrl: 'http://127.0.0.1:17374', }, proxy: { enabled: false, @@ -477,6 +498,16 @@ ?? input?.mailProvider ?? defaults.services.email.provider ).trim() || defaults.services.email.provider, + customReceiveMode: String( + nested?.services?.email?.customReceiveMode + ?? input?.customMailReceiveMode + ?? defaults.services.email.customReceiveMode + ).trim().toLowerCase() === 'helper' ? 'helper' : defaults.services.email.customReceiveMode, + customHelperBaseUrl: normalizeCustomMailHelperBaseUrl( + nested?.services?.email?.customHelperBaseUrl + ?? input?.customMailHelperBaseUrl + ?? defaults.services.email.customHelperBaseUrl + ), }, proxy: { enabled: Boolean( @@ -608,6 +639,8 @@ next.hostedCheckoutPhoneNumber = openaiState.plus?.hostedCheckoutPhoneNumber || ''; next.plusHostedCheckoutOauthDelaySeconds = openaiState.plus?.plusHostedCheckoutOauthDelaySeconds ?? 3; next.mailProvider = normalizedState.services.email.provider; + next.customMailReceiveMode = normalizedState.services.email.customReceiveMode; + next.customMailHelperBaseUrl = normalizedState.services.email.customHelperBaseUrl; next.ipProxyEnabled = normalizedState.services.proxy.enabled; next.ipProxyService = normalizedState.services.proxy.provider; next.ipProxyMode = normalizedState.services.proxy.mode; diff --git a/flows/openai/background/steps/fetch-login-code.js b/flows/openai/background/steps/fetch-login-code.js index 1fc5082..ee86414 100644 --- a/flows/openai/background/steps/fetch-login-code.js +++ b/flows/openai/background/steps/fetch-login-code.js @@ -32,6 +32,7 @@ phoneVerificationHelpers = null, setState, shouldUseCustomRegistrationEmail, + shouldUseCustomMailHelper = () => false, sleepWithStop, STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS, STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS, @@ -594,7 +595,7 @@ await addLog(`步骤 ${visibleStep}:已固定当前验证码页显示邮箱 ${displayedVerificationEmail} 作为后续匹配目标。`, 'info'); } - if (shouldUseCustomRegistrationEmail(preparedState)) { + if (shouldUseCustomRegistrationEmail(preparedState) && !shouldUseCustomMailHelper(preparedState)) { await confirmCustomVerificationStepBypass(8, { completionStep: visibleStep, promptStep: visibleStep, @@ -617,6 +618,7 @@ || mail.provider === LUCKMAIL_PROVIDER || mail.provider === CLOUDFLARE_TEMP_EMAIL_PROVIDER || mail.provider === CLOUD_MAIL_PROVIDER + || shouldUseCustomMailHelper(preparedState) ) { 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 4701b8d..82934a1 100644 --- a/flows/openai/background/steps/fetch-signup-code.js +++ b/flows/openai/background/steps/fetch-signup-code.js @@ -26,6 +26,7 @@ sendToContentScriptResilient, isRetryableContentScriptTransportError = () => false, shouldUseCustomRegistrationEmail, + shouldUseCustomMailHelper = () => false, STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS, throwIfStopped, waitForTabStableComplete = null, @@ -93,14 +94,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) && !shouldUseCustomMailHelper(state)) { 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 +121,7 @@ || mail.provider === LUCKMAIL_PROVIDER || mail.provider === CLOUDFLARE_TEMP_EMAIL_PROVIDER || mail.provider === CLOUD_MAIL_PROVIDER + || shouldUseCustomMailHelper(state) ) { await addLog(`步骤 4:正在通过 ${mail.label} 轮询验证码...`); } else if (mail.provider === '2925') { @@ -146,7 +148,7 @@ LUCKMAIL_PROVIDER, CLOUDFLARE_TEMP_EMAIL_PROVIDER, CLOUD_MAIL_PROVIDER, - ].includes(mail.provider); + ].includes(mail.provider) && !shouldUseCustomMailHelper(state); const signupProfile = buildSignupProfileForVerificationStep(); await resolveVerificationStep(4, state, mail, { diff --git a/scripts/custom_mail_helper.py b/scripts/custom_mail_helper.py new file mode 100644 index 0000000..2ba397a --- /dev/null +++ b/scripts/custom_mail_helper.py @@ -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() diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html index ed260b0..26dcee2 100644 --- a/sidepanel/sidepanel.html +++ b/sidepanel/sidepanel.html @@ -553,6 +553,18 @@ + +