diff --git a/README.md b/README.md index ffef8ff..ee0429f 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,16 @@ - `2925` 旧的“只填前缀”使用方式已经不再推荐,应该改为填写完整基邮箱 - 如果你手动填写了与当前 `Gmail / 2925 provide` 基邮箱不匹配的完整邮箱,侧边栏会在保存或执行 Step 3 时拦截 +## 2026-04-23 更新补充:自定义邮箱池 + +本次版本新增 `自定义邮箱池` 生成方式,用于把一批已经准备好的邮箱按顺序分配给自动流程: + +- 在 `邮箱生成` 中选择 `自定义邮箱池` +- 在新出现的 `邮箱池` 文本框里按“每行一个邮箱”填写 +- `Auto` 运行次数会自动跟随邮箱池数量,无需再手动对齐轮数 +- 同一目标轮次的失败重试会继续复用当前轮邮箱,不会提前跳到下一个 +- 实际收码仍然走当前 `Mail` 对应的邮箱服务,因此应保证邮箱池里的地址与当前收码链路匹配 + ## 快速开始 如果你只是想先跑通一套最稳的组合,建议直接按下面三种方案之一配置。 @@ -118,9 +128,9 @@ 1. `CPA` 填你的管理面板 OAuth 页面地址 2. `Mail` 选择 `QQ Mail`、`163 Mail` 或 `163 VIP Mail` -3. `邮箱生成` 选择 `DuckDuckGo` 或 `Cloudflare` +3. `邮箱生成` 选择 `DuckDuckGo`、`Cloudflare` 或 `自定义邮箱池` 4. 若你选择 `Cloudflare`,先按下文把 Cloudflare Email Routing 配好 -5. 点击 `获取` 生成邮箱,或手动粘贴一个你能收信的邮箱 +5. 若你选择 `自定义邮箱池`,就在 `邮箱池` 中按行填入邮箱;否则点击 `获取` 生成邮箱,或手动粘贴一个你能收信的邮箱 6. 先单步验证 `Step 1 ~ Step 4` 7. 验证没问题后再点右上角 `Auto` @@ -132,7 +142,15 @@ 4. Step 1 会直接在 SUB2API 后台生成 OAuth 链接 5. Step 10 会把 localhost 回调提交回 SUB2API,并直接创建 OpenAI 账号 -### 方案 C:`Hotmail 账号池` +### 方案 C:`Codex2API + QQ / 163 / 163 VIP` + +1. `来源` 选择 `Codex2API` +2. 填好 `Codex2API` 后台地址、管理密钥 +3. `Mail` 与 `邮箱生成` 的配置方式同方案 A +4. Step 7 会直接通过 Codex2API 协议 `/api/admin/oauth/generate-auth-url` 生成 OAuth 链接 +5. Step 10 会把 localhost 回调中的 `code / state` 通过 `/api/admin/oauth/exchange-code` 直接提交给 Codex2API + +### 方案 D:`Hotmail 账号池` 1. `Mail` 选择 `Hotmail` 2. 在 `Hotmail 账号池` 中添加 `邮箱 / Client ID / Refresh Token` @@ -140,7 +158,7 @@ 4. 通过后再执行步骤或 `Auto` 5. 当前项目中,`Mail = Hotmail` 时会直接使用账号池里的邮箱作为注册邮箱,不再走 `Duck / Cloudflare` 自动生成 -### 方案 D:`2925 账号池` +### 方案 E:`2925 账号池` 1. `Mail` 选择 `2925` 2. 在 `2925 账号池` 中添加 `邮箱 / 密码` @@ -177,6 +195,20 @@ Step 1 和 Step 10 都依赖这个地址。 插件会在 Step 1 和 Step 10 自动从 `/api/v1/admin/proxies/all` 解析这个代理,并在 OAuth 链接生成、授权码交换和账号创建请求中附带 `proxy_id`。如果名称匹配到多个代理,请改填代理 ID;留空则不会发送 `proxy_id`。 +### `Codex2API` + +当 `来源 = Codex2API` 时,需要配置: + +- `Codex2API`:后台账号管理页地址,默认 `http://localhost:8080/admin/accounts` +- `管理密钥`:Codex2API 的 `Admin Secret` + +插件会在: + +- Step 7 调用 `POST /api/admin/oauth/generate-auth-url` 生成授权链接 +- Step 10 调用 `POST /api/admin/oauth/exchange-code` 完成 localhost callback 的授权码交换并创建账号 + +这条来源是协议直连,不依赖 Codex2API 后台页面的“添加账号 / OAuth 授权 / 生成授权链接”按钮 DOM。 + ### `Mail` 支持五种验证码来源: @@ -327,17 +359,18 @@ Step 3 使用的注册邮箱。 来源有两种: - 手动粘贴 -- 点击 `获取` 自动生成邮箱(DuckDuckGo 或 Cloudflare) +- 按当前生成方式自动生成或分配邮箱(DuckDuckGo / Cloudflare / 自定义邮箱池) 注意: - 若 `邮箱生成 = Cloudflare`,插件里只需要维护 `CF 域名` +- 若 `邮箱生成 = 自定义邮箱池`,需要在 `邮箱池` 文本框中按行维护邮箱列表 - `CF 域名` 支持保存多个,并通过下拉框切换当前要生成的域名 - Cloudflare 侧的转发规则、Catch-all、路由目标邮箱等,都需要你自己提前在 Cloudflare 后台配置好 - 当 `Mail = Hotmail` 时,这个输入框由账号池自动同步当前账号邮箱 - 当 `Mail = Hotmail` 时,Step 3 会直接使用 Hotmail 账号池里的邮箱;`Duck / Cloudflare` 不参与自动邮箱生成 - 若你准备走 `Cloudflare`,更推荐把 `Mail` 设为 `QQ / 163 / 163 VIP`;`Inbucket` 仅在它能真实接收外部邮件并完成 Cloudflare 验证时再使用 -- 当前 `Auto` 按钮只负责 DuckDuckGo 地址获取 +- `Auto` 会按当前“邮箱生成”配置自动获取或分配邮箱;若当前是 `自定义邮箱池`,则会按邮箱池顺序取用 - 如果你使用 Inbucket,它只是验证码收件箱,不会自动生成 Inbucket 地址 ### `邮箱生成 = Cloudflare` 时的配置 @@ -487,7 +520,7 @@ Cloudflare 模式下,插件不会再调用 Cloudflare API 创建路由。 1. Step 1 打开 `https://chatgpt.com/` 2. 根据 `Mail` 选择邮箱来源 3. 如果 `Mail = Hotmail`,会从账号池自动分配一个可用账号 -4. 如果不是 Hotmail,则按当前“邮箱生成”配置尝试自动获取邮箱(Duck / Cloudflare / iCloud 等) +4. 如果不是 Hotmail,则按当前“邮箱生成”配置尝试自动获取或分配邮箱(Duck / Cloudflare / iCloud / 自定义邮箱池等) 5. Step 2 点击注册、填写邮箱,并按真实落地页进入密码页或直接进入邮箱验证码页 6. 如果自动获取失败,暂停并等待你在侧边栏填写邮箱后点击 `Continue` 7. 继续执行 Step 3 ~ Step 10 @@ -528,6 +561,7 @@ Cloudflare 模式下,插件不会再调用 Cloudflare API 创建路由。 - 使用自定义密码或自动生成密码 - 在密码页填写密码并提交注册表单 - 后台会在真正把 Step 3 记为完成前,再确认页面是否已经推进;如果此时出现认证页 `重试` 页面,或 `/email-verification` 上的 `405 / Route Error` 重试页,会先通过共享恢复逻辑最多自动点击 5 次 `重试` 尝试恢复,再继续后续链路 +- Step 3 收尾阶段如果页面切换导致旧内容脚本失联,后台单次消息等待不会再卡住超过当前收尾预算;若最终仍未恢复,则会输出中文的步骤级错误,而不是直接暴露底层英文通信超时 实际使用的密码会写入会话状态,并同步到侧边栏显示。 diff --git a/background.js b/background.js index 3d4fc59..aaf6ed8 100644 --- a/background.js +++ b/background.js @@ -158,6 +158,7 @@ const OAUTH_FLOW_TIMEOUT_MS = 5 * 60 * 1000; const SUB2API_STEP1_RESPONSE_TIMEOUT_MS = 90000; const SUB2API_STEP9_RESPONSE_TIMEOUT_MS = 120000; const DEFAULT_SUB2API_URL = 'https://sub2api.hisence.fun/admin/accounts'; +const DEFAULT_CODEX2API_URL = 'http://localhost:8080/admin/accounts'; const DEFAULT_SUB2API_GROUP_NAME = 'codex'; const DEFAULT_SUB2API_PROXY_NAME = ''; const DEFAULT_SUB2API_REDIRECT_URI = 'http://localhost:1455/auth/callback'; @@ -254,6 +255,8 @@ const PERSISTED_SETTING_DEFAULTS = { sub2apiPassword: '', sub2apiGroupName: DEFAULT_SUB2API_GROUP_NAME, sub2apiDefaultProxyName: DEFAULT_SUB2API_PROXY_NAME, + codex2apiUrl: DEFAULT_CODEX2API_URL, + codex2apiAdminKey: '', customPassword: '', autoRunSkipFailures: false, autoRunFallbackThreadIntervalMinutes: 0, @@ -272,6 +275,7 @@ const PERSISTED_SETTING_DEFAULTS = { accountRunHistoryHelperBaseUrl: DEFAULT_ACCOUNT_RUN_HISTORY_HELPER_BASE_URL, gmailBaseEmail: '', mail2925BaseEmail: '', + currentMail2925AccountId: '', emailPrefix: '', inbucketHost: '', inbucketMailbox: '', @@ -284,6 +288,7 @@ const PERSISTED_SETTING_DEFAULTS = { cloudflareTempEmailAdminAuth: '', cloudflareTempEmailCustomAuth: '', cloudflareTempEmailReceiveMailbox: '', + cloudflareTempEmailUseRandomSubdomain: false, cloudflareTempEmailDomain: '', cloudflareTempEmailDomains: [], hotmailAccounts: [], @@ -331,6 +336,8 @@ const DEFAULT_STATE = { sub2apiGroupId: null, // SUB2API 目标分组 ID。 sub2apiDraftName: null, // SUB2API 本轮预生成的账号名称。 sub2apiProxyId: null, // SUB2API 本轮使用的代理 ID。 + codex2apiSessionId: null, // Codex2API OAuth 会话 ID。 + codex2apiOAuthState: null, // Codex2API OAuth state。 flowStartTime: null, // 当前流程开始时间。 tabRegistry: {}, // 程序维护的标签页注册表。 sourceLastUrls: {}, // 各来源页面最近一次打开的地址记录。 @@ -697,7 +704,14 @@ function getCustomEmailPoolEmailForRun(state = {}, targetRun = 1) { } function normalizePanelMode(value = '') { - return String(value || '').trim().toLowerCase() === 'sub2api' ? 'sub2api' : 'cpa'; + const normalized = String(value || '').trim().toLowerCase(); + if (normalized === 'sub2api') { + return 'sub2api'; + } + if (normalized === 'codex2api') { + return 'codex2api'; + } + return 'cpa'; } function normalizeMailProvider(value = '') { @@ -873,6 +887,7 @@ function getCloudflareTempEmailConfig(state = {}) { adminAuth: String(state.cloudflareTempEmailAdminAuth || ''), customAuth: String(state.cloudflareTempEmailCustomAuth || ''), receiveMailbox: normalizeCloudflareTempEmailReceiveMailbox(state.cloudflareTempEmailReceiveMailbox), + useRandomSubdomain: Boolean(state.cloudflareTempEmailUseRandomSubdomain), domain: normalizeCloudflareTempEmailDomain(state.cloudflareTempEmailDomain), domains: normalizeCloudflareTempEmailDomains(state.cloudflareTempEmailDomains), }; @@ -918,6 +933,10 @@ function normalizePersistentSettingValue(key, value) { return String(value || '').trim(); case 'sub2apiDefaultProxyName': return String(value || '').trim(); + case 'codex2apiUrl': + return normalizeCodex2ApiUrl(value); + case 'codex2apiAdminKey': + return String(value || '').trim(); case 'customPassword': return String(value || ''); case 'autoRunSkipFailures': @@ -943,6 +962,7 @@ function normalizePersistentSettingValue(key, value) { return normalizeCustomEmailPool(value); case 'autoDeleteUsedIcloudAlias': case 'accountRunHistoryTextEnabled': + case 'cloudflareTempEmailUseRandomSubdomain': return Boolean(value); case 'icloudHostPreference': return normalizeIcloudHost(value) || 'auto'; @@ -950,6 +970,7 @@ function normalizePersistentSettingValue(key, value) { return normalizeAccountRunHistoryHelperBaseUrl(value); case 'gmailBaseEmail': case 'mail2925BaseEmail': + case 'currentMail2925AccountId': case 'emailPrefix': return String(value || '').trim(); case 'inbucketHost': @@ -3692,11 +3713,31 @@ function normalizeSub2ApiUrl(rawUrl) { return parsed.toString(); } +function normalizeCodex2ApiUrl(rawUrl) { + if (typeof navigationUtils !== 'undefined' && navigationUtils?.normalizeCodex2ApiUrl) { + return navigationUtils.normalizeCodex2ApiUrl(rawUrl); + } + const input = (rawUrl || '').trim() || DEFAULT_CODEX2API_URL; + const withProtocol = /^https?:\/\//i.test(input) ? input : `http://${input}`; + const parsed = new URL(withProtocol); + if (!parsed.pathname || parsed.pathname === '/' || parsed.pathname === '/admin') { + parsed.pathname = '/admin/accounts'; + } + parsed.hash = ''; + return parsed.toString(); +} + function getPanelMode(state = {}) { if (typeof navigationUtils !== 'undefined' && navigationUtils?.getPanelMode) { return navigationUtils.getPanelMode(state); } - return state.panelMode === 'sub2api' ? 'sub2api' : 'cpa'; + if (state.panelMode === 'sub2api') { + return 'sub2api'; + } + if (state.panelMode === 'codex2api') { + return 'codex2api'; + } + return 'cpa'; } function getPanelModeLabel(modeOrState) { @@ -3704,7 +3745,13 @@ function getPanelModeLabel(modeOrState) { return navigationUtils.getPanelModeLabel(modeOrState); } const mode = typeof modeOrState === 'string' ? modeOrState : getPanelMode(modeOrState); - return mode === 'sub2api' ? 'SUB2API' : 'CPA'; + if (mode === 'sub2api') { + return 'SUB2API'; + } + if (mode === 'codex2api') { + return 'Codex2API'; + } + return 'CPA'; } function isSignupPageHost(hostname = '') { @@ -4008,6 +4055,7 @@ function isRetryableContentScriptTransportError(error) { } const navigationUtils = self.MultiPageBackgroundNavigationUtils?.createNavigationUtils({ + DEFAULT_CODEX2API_URL, DEFAULT_SUB2API_URL, normalizeLocalCpaStep9Mode, }); @@ -4201,6 +4249,8 @@ function getDownstreamStateResets(step) { sub2apiOAuthState: null, sub2apiGroupId: null, sub2apiDraftName: null, + codex2apiSessionId: null, + codex2apiOAuthState: null, flowStartTime: null, password: null, lastEmailTimestamp: null, @@ -4986,6 +5036,8 @@ async function handleStepData(step, payload) { if (payload.sub2apiGroupId !== undefined) updates.sub2apiGroupId = payload.sub2apiGroupId || null; if (payload.sub2apiDraftName !== undefined) updates.sub2apiDraftName = payload.sub2apiDraftName || null; if (payload.sub2apiProxyId !== undefined) updates.sub2apiProxyId = payload.sub2apiProxyId || null; + if (payload.codex2apiSessionId !== undefined) updates.codex2apiSessionId = payload.codex2apiSessionId || null; + if (payload.codex2apiOAuthState !== undefined) updates.codex2apiOAuthState = payload.codex2apiOAuthState || null; if (Object.keys(updates).length) { await setState(updates); } @@ -5534,6 +5586,7 @@ const mail2925SessionManager = self.MultiPageBackgroundMail2925Session?.createMa sleepWithStop, throwIfStopped, upsertMail2925AccountInList, + waitForTabComplete, waitForTabUrlMatch, }); @@ -6302,6 +6355,7 @@ const panelBridge = self.MultiPageBackgroundPanelBridge?.createPanelBridge({ closeConflictingTabsForSource, ensureContentScriptReadyOnTab, getPanelMode, + normalizeCodex2ApiUrl, normalizeSub2ApiUrl, rememberSourceLastUrl, sendToContentScript, @@ -6322,6 +6376,7 @@ const signupFlowHelpers = self.MultiPageSignupFlowHelpers?.createSignupFlowHelpe isGeneratedAliasProvider, isReusableGeneratedAliasEmail, isSignupEmailVerificationPageUrl, + isRetryableContentScriptTransportError, isHotmailProvider, isLuckmailProvider, isSignupPasswordPageUrl, @@ -6477,6 +6532,7 @@ const step10Executor = self.MultiPageBackgroundStep10?.createStep10Executor({ getTabId, isLocalhostOAuthCallbackUrl, isTabAlive, + normalizeCodex2ApiUrl, normalizeSub2ApiUrl, rememberSourceLastUrl, reuseOrCreateTab, @@ -6543,6 +6599,7 @@ const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter getPendingAutoRunTimerPlan, getSourceLabel, getState, + getTabId, getStopRequested: () => stopRequested, handleCloudflareSecurityBlocked, handleAutoRunLoopUnhandledError, @@ -6554,6 +6611,7 @@ const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter isLocalhostOAuthCallbackUrl, isLuckmailProvider, isStopError, + isTabAlive, launchAutoRunTimerPlan, listIcloudAliases, listLuckmailPurchasesForManagement, @@ -6925,7 +6983,7 @@ async function runPreStep6CookieCleanup() { async function refreshOAuthUrlBeforeStep6(state) { if (state?.contributionModeExpected && !state?.contributionMode) { - throw new Error('步骤 7:当前自动流程预期使用贡献模式,但运行态 contributionMode 已丢失,已阻止回退到普通 CPA / SUB2API 链路。请重新进入贡献模式后再点击自动。'); + throw new Error('步骤 7:当前自动流程预期使用贡献模式,但运行态 contributionMode 已丢失,已阻止回退到普通 CPA / SUB2API / Codex2API 链路。请重新进入贡献模式后再点击自动。'); } if (state?.contributionMode && contributionOAuthManager?.startContributionFlow) { await addLog('步骤 7:contributionMode=true,走公开贡献接口,正在申请 OAuth 登录地址...', 'info'); @@ -6941,7 +6999,7 @@ async function refreshOAuthUrlBeforeStep6(state) { await handleStepData(1, { oauthUrl }); return oauthUrl; } - await addLog(`步骤 7:contributionMode=false,走普通 CPA / SUB2API 链路(当前面板:${getPanelModeLabel(state)}),正在刷新 OAuth 登录地址...`, 'info'); + await addLog(`步骤 7:contributionMode=false,走普通 CPA / SUB2API / Codex2API 链路(当前面板:${getPanelModeLabel(state)}),正在刷新 OAuth 登录地址...`, 'info'); console.log(LOG_PREFIX, '[refreshOAuthUrlBeforeStep6] requesting fresh OAuth directly from panel'); const refreshResult = await requestOAuthUrlFromPanel(state, { logLabel: '步骤 7' }); await handleStepData(1, refreshResult); @@ -7642,7 +7700,7 @@ async function executeContributionStep10(state) { async function executeStep10(state) { if (state?.contributionModeExpected && !state?.contributionMode) { - throw new Error('步骤 10:当前自动流程预期使用贡献模式,但运行态 contributionMode 已丢失,已阻止回退到普通 CPA / SUB2API 提交。请重新进入贡献模式后再点击自动。'); + throw new Error('步骤 10:当前自动流程预期使用贡献模式,但运行态 contributionMode 已丢失,已阻止回退到普通 CPA / SUB2API / Codex2API 提交。请重新进入贡献模式后再点击自动。'); } if (state?.contributionMode) { return executeContributionStep10(state); diff --git a/background/contribution-oauth.js b/background/contribution-oauth.js index 7dc5608..e95cbc3 100644 --- a/background/contribution-oauth.js +++ b/background/contribution-oauth.js @@ -599,6 +599,7 @@ body: { nickname: buildNickname(currentState, options.nickname), qq: buildContributionQq(currentState, options.qq), + email: normalizeString(currentState.email), source: 'cpa', channel: 'codex-extension', }, diff --git a/background/generated-email-helpers.js b/background/generated-email-helpers.js index f5ec2b3..be121ea 100644 --- a/background/generated-email-helpers.js +++ b/background/generated-email-helpers.js @@ -148,6 +148,7 @@ const requestedName = String(options.localPart || options.name || '').trim().toLowerCase() || generateCloudflareAliasLocalPart(); const payload = { enablePrefix: true, + enableRandomSubdomain: Boolean(config.useRandomSubdomain), name: requestedName, domain: config.domain, }; @@ -254,26 +255,41 @@ ? options.mail2925Mode : currentState.mail2925Mode; const generator = normalizeEmailGenerator(options.generator ?? currentState.emailGenerator); + const mergedState = { + ...currentState, + mailProvider: provider || currentState.mailProvider, + mail2925Mode, + emailGenerator: generator, + }; + if (options.gmailBaseEmail !== undefined) { + mergedState.gmailBaseEmail = String(options.gmailBaseEmail || '').trim(); + } + if (options.mail2925BaseEmail !== undefined) { + mergedState.mail2925BaseEmail = String(options.mail2925BaseEmail || '').trim(); + } + if (options.customEmailPool !== undefined) { + mergedState.customEmailPool = options.customEmailPool; + } if (generator === 'custom') { throw new Error('当前邮箱生成方式为自定义邮箱,请直接填写注册邮箱。'); } if (generator === CUSTOM_EMAIL_POOL_GENERATOR) { - return fetchCustomEmailPoolEmail(currentState, options); + return fetchCustomEmailPoolEmail(mergedState, options); } const shouldUseManagedAlias = typeof isGeneratedAliasProvider === 'function' - ? isGeneratedAliasProvider(currentState, mail2925Mode) + ? isGeneratedAliasProvider(mergedState, mail2925Mode) : false; if (shouldUseManagedAlias) { - return fetchManagedAliasEmail(currentState, options); + return fetchManagedAliasEmail(mergedState, options); } if (generator === 'icloud') { return fetchIcloudHideMyEmail(); } if (generator === 'cloudflare') { - return fetchCloudflareEmail(currentState, options); + return fetchCloudflareEmail(mergedState, options); } if (generator === CLOUDFLARE_TEMP_EMAIL_GENERATOR) { - return fetchCloudflareTempEmailAddress(currentState, options); + return fetchCloudflareTempEmailAddress(mergedState, options); } return fetchDuckEmail(options); } diff --git a/background/mail-2925-session.js b/background/mail-2925-session.js index c78a350..502362a 100644 --- a/background/mail-2925-session.js +++ b/background/mail-2925-session.js @@ -25,6 +25,7 @@ sleepWithStop, throwIfStopped, upsertMail2925AccountInList, + waitForTabComplete, waitForTabUrlMatch, } = deps; @@ -45,6 +46,9 @@ ]; const MAIL2925_LIMIT_ERROR_PREFIX = 'MAIL2925_LIMIT_REACHED::'; const MAIL2925_THREAD_TERMINATED_ERROR_PREFIX = 'MAIL2925_THREAD_TERMINATED::'; + const MAIL2925_LOGIN_MESSAGE_RETRY_WINDOW_MS = 15000; + const MAIL2925_LOGIN_RESPONSE_TIMEOUT_MS = 120000; + const MAIL2925_LOGIN_PAGE_RECOVERY_TIMEOUT_MS = 120000; function getMail2925MailConfig() { return { @@ -95,6 +99,14 @@ return getErrorMessage(error).startsWith(MAIL2925_THREAD_TERMINATED_ERROR_PREFIX); } + function isRetryableMail2925TransportError(error) { + const message = getErrorMessage(error).toLowerCase(); + return message.includes('receiving end does not exist') + || message.includes('message port closed') + || message.includes('content script on') + || message.includes('did not respond'); + } + async function syncMail2925Accounts(accounts) { const normalized = normalizeMail2925Accounts(accounts); await setPersistentSettings({ mail2925Accounts: normalized }); @@ -168,6 +180,10 @@ } } + function normalizeMailboxEmail(value = '') { + return String(value || '').trim().toLowerCase(); + } + async function setCurrentMail2925Account(accountId, options = {}) { const { logMessage = '', updateLastUsedAt = false } = options; const state = await getState(); @@ -186,6 +202,7 @@ await syncMail2925Accounts(accounts.map((item) => (item.id === account.id ? nextAccount : item))); } + await setPersistentSettings({ currentMail2925AccountId: nextAccount.id }); await setState({ currentMail2925AccountId: nextAccount.id }); broadcastDataUpdate({ currentMail2925AccountId: nextAccount.id }); if (logMessage) { @@ -210,6 +227,7 @@ await syncMail2925Accounts(accounts.map((item) => (item.id === account.id ? nextAccount : item))); if (state.currentMail2925AccountId === account.id && nextAccount.enabled === false) { + await setPersistentSettings({ currentMail2925AccountId: '' }); await setState({ currentMail2925AccountId: null }); broadcastDataUpdate({ currentMail2925AccountId: null }); } @@ -224,6 +242,7 @@ await syncMail2925Accounts(nextAccounts); if (state.currentMail2925AccountId === accountId) { + await setPersistentSettings({ currentMail2925AccountId: '' }); await setState({ currentMail2925AccountId: null }); broadcastDataUpdate({ currentMail2925AccountId: null }); } @@ -239,6 +258,7 @@ await syncMail2925Accounts(nextAccounts); if (state.currentMail2925AccountId && !findMail2925Account(nextAccounts, state.currentMail2925AccountId)) { + await setPersistentSettings({ currentMail2925AccountId: '' }); await setState({ currentMail2925AccountId: null }); broadcastDataUpdate({ currentMail2925AccountId: null }); } @@ -391,16 +411,56 @@ return removedCount; } + async function recoverMail2925LoginPageAfterTransportError(tabId) { + const numericTabId = Number(tabId); + if (!Number.isInteger(numericTabId) || numericTabId <= 0) { + return; + } + + const currentUrl = (await getMail2925TabUrlById(numericTabId)) || await getMail2925CurrentTabUrl(); + await addLog( + `2925:登录提交后页面发生跳转或重载,正在等待当前标签页恢复后继续确认登录态。当前地址:${currentUrl || 'unknown'}`, + 'warn' + ); + + if (typeof waitForTabComplete === 'function') { + const completedTab = await waitForTabComplete(numericTabId, { + timeoutMs: MAIL2925_LOGIN_PAGE_RECOVERY_TIMEOUT_MS, + retryDelayMs: 300, + }); + await addLog( + `2925:登录跳转等待结束,当前标签地址:${String(completedTab?.url || '').trim() || 'unknown'}`, + completedTab?.url ? 'info' : 'warn' + ); + } + + if (typeof ensureContentScriptReadyOnTab === 'function') { + await ensureContentScriptReadyOnTab(MAIL2925_SOURCE, numericTabId, { + inject: MAIL2925_INJECT, + injectSource: MAIL2925_INJECT_SOURCE, + timeoutMs: MAIL2925_LOGIN_PAGE_RECOVERY_TIMEOUT_MS, + retryDelayMs: 800, + logMessage: '步骤 0:2925 登录后页面仍在跳转,正在等待邮箱页重新就绪...', + }); + } + + const recoveredUrl = (await getMail2925TabUrlById(numericTabId)) || await getMail2925CurrentTabUrl(); + await addLog(`2925:登录跳转恢复后当前标签地址:${recoveredUrl || 'unknown'}`, 'info'); + } + async function ensureMail2925MailboxSession(options = {}) { const { accountId = null, forceRelogin = false, actionLabel = '确保 2925 邮箱登录态', allowLoginWhenOnLoginPage = true, + expectedMailboxEmail = '', } = options; + const normalizedExpectedMailboxEmail = normalizeMailboxEmail(expectedMailboxEmail); + let account = null; - if (forceRelogin) { + if (forceRelogin || (allowLoginWhenOnLoginPage && normalizedExpectedMailboxEmail)) { account = await ensureMail2925AccountForFlow({ allowAllocate: true, preferredAccountId: accountId, @@ -477,16 +537,16 @@ } } - if (!forceRelogin && !isMail2925LoginUrl(openedUrl)) { + if (!forceRelogin && !isMail2925LoginUrl(openedUrl) && !normalizedExpectedMailboxEmail) { await addLog('2925:当前邮箱页未跳转到登录页,将直接复用已登录会话。', 'info'); return buildSuccessPayload(); } - if (!forceRelogin && !allowLoginWhenOnLoginPage) { + if (!forceRelogin && isMail2925LoginUrl(openedUrl) && !allowLoginWhenOnLoginPage) { await failMailboxSession(`2925:${actionLabel}失败,当前页面已跳转到登录页,且当前未启用 2925 账号池,不执行自动登录。`); } - if (!account) { + if (!account && (forceRelogin || allowLoginWhenOnLoginPage)) { account = await ensureMail2925AccountForFlow({ allowAllocate: true, preferredAccountId: accountId, @@ -509,35 +569,51 @@ } let result; - try { + const sendEnsureSessionRequest = async () => { const beforeSendUrl = (await getMail2925TabUrlById(tabId)) || await getMail2925CurrentTabUrl(); await addLog(`2925:发送 ENSURE_MAIL2925_SESSION 前当前地址:${beforeSendUrl || 'unknown'}`, 'info'); - result = await sendLoginMessage( + return sendLoginMessage( MAIL2925_SOURCE, { type: 'ENSURE_MAIL2925_SESSION', step: 0, source: 'background', payload: { - email: account.email, - password: account.password, + email: account?.email || '', + password: account?.password || '', forceLogin: forceRelogin, + allowLoginWhenOnLoginPage, }, }, { - timeoutMs: 50000, + timeoutMs: MAIL2925_LOGIN_MESSAGE_RETRY_WINDOW_MS, retryDelayMs: 800, - responseTimeoutMs: 50000, + responseTimeoutMs: MAIL2925_LOGIN_RESPONSE_TIMEOUT_MS, logMessage: '步骤 0:2925 登录页通信异常,正在等待页面恢复...', } ); + }; + try { + result = await sendEnsureSessionRequest(); } catch (err) { - const message = `2925:${actionLabel}失败(${getErrorMessage(err) || '40 秒内未进入收件箱'})。`; - const stopped = await stopAutoRunForMail2925LoginFailure(`${message}已按手动停止逻辑暂停自动流程。`); - if (stopped) { - throw new Error('流程已被用户停止。'); + if (isRetryableMail2925TransportError(err)) { + try { + await recoverMail2925LoginPageAfterTransportError(tabId); + await addLog('2925:页面恢复完成,正在重新确认登录态...', 'info'); + result = await sendEnsureSessionRequest(); + } catch (recoveryErr) { + err = recoveryErr; + } + } + + if (!result) { + const message = `2925:${actionLabel}失败(${getErrorMessage(err) || '登录结果确认超时'})。`; + const stopped = await stopAutoRunForMail2925LoginFailure(`${message}已按手动停止逻辑暂停自动流程。`); + if (stopped) { + throw new Error('流程已被用户停止。'); + } + throw err; } - throw err; } if (result?.error) { @@ -546,10 +622,44 @@ if (result?.limitReached) { throw new Error(`${MAIL2925_LIMIT_ERROR_PREFIX}${result.limitMessage || '子邮箱已达上限邮箱'}`); } + const actualMailboxEmail = normalizeMailboxEmail(result?.mailboxEmail || ''); + if (normalizedExpectedMailboxEmail && actualMailboxEmail && actualMailboxEmail !== normalizedExpectedMailboxEmail) { + if (allowLoginWhenOnLoginPage) { + await addLog( + `2925:当前邮箱页显示账号 ${actualMailboxEmail},与目标账号 ${normalizedExpectedMailboxEmail} 不一致,准备登出当前账号并登录目标账号。`, + 'warn' + ); + return ensureMail2925MailboxSession({ + accountId: account?.id || accountId || null, + forceRelogin: true, + allowLoginWhenOnLoginPage: true, + expectedMailboxEmail: normalizedExpectedMailboxEmail, + actionLabel, + }); + } + await failMailboxSession( + `2925:${actionLabel}失败,当前邮箱页显示账号 ${actualMailboxEmail},与目标账号 ${normalizedExpectedMailboxEmail} 不一致,且当前未启用 2925 账号池。` + ); + } + if (normalizedExpectedMailboxEmail && !actualMailboxEmail && result?.currentView === 'mailbox') { + await addLog('2925:未能识别当前邮箱页顶部邮箱地址,已跳过邮箱一致性校验。', 'warn'); + } if (!result?.loggedIn) { await failMailboxSession(`2925:${actionLabel}失败,登录后仍未进入收件箱。`); } + if (!account) { + await addLog('2925:未触发自动登录,继续复用当前已登录会话。', 'info'); + return { + account: null, + mail: getMail2925MailConfig(), + result: { + ...result, + usedExistingSession: true, + }, + }; + } + await patchMail2925Account(account.id, { lastLoginAt: Date.now(), lastError: '', @@ -613,6 +723,7 @@ }); if (!nextAccount) { + await setPersistentSettings({ currentMail2925AccountId: '' }); await setState({ currentMail2925AccountId: null }); broadcastDataUpdate({ currentMail2925AccountId: null }); if (typeof requestStop === 'function') { diff --git a/background/message-router.js b/background/message-router.js index b8580fd..c293b16 100644 --- a/background/message-router.js +++ b/background/message-router.js @@ -40,6 +40,7 @@ getPendingAutoRunTimerPlan, getSourceLabel, getState, + getTabId, getStopRequested, handleAutoRunLoopUnhandledError, importSettingsBundle, @@ -50,6 +51,7 @@ isLocalhostOAuthCallbackUrl, isLuckmailProvider, isStopError, + isTabAlive, launchAutoRunTimerPlan, listIcloudAliases, listLuckmailPurchasesForManagement, @@ -108,6 +110,23 @@ return appendAccountRunRecord(status, state, reason); } + async function ensureManualStepPrerequisites(step) { + if (step !== 4) { + return; + } + + const signupTabId = typeof getTabId === 'function' + ? await getTabId('signup-page') + : null; + const signupTabAlive = signupTabId && typeof isTabAlive === 'function' + ? await isTabAlive('signup-page') + : Boolean(signupTabId); + + if (!signupTabId || !signupTabAlive) { + throw new Error('手动执行步骤 4 前,请先执行步骤 1 或步骤 2,确保认证页仍然打开并停留在验证码页。'); + } + } + async function handleStepData(step, payload) { switch (step) { case 1: { @@ -121,6 +140,8 @@ if (payload.sub2apiGroupId !== undefined) updates.sub2apiGroupId = payload.sub2apiGroupId || null; if (payload.sub2apiDraftName !== undefined) updates.sub2apiDraftName = payload.sub2apiDraftName || null; if (payload.sub2apiProxyId !== undefined) updates.sub2apiProxyId = payload.sub2apiProxyId || null; + if (payload.codex2apiSessionId !== undefined) updates.codex2apiSessionId = payload.codex2apiSessionId || null; + if (payload.codex2apiOAuthState !== undefined) updates.codex2apiOAuthState = payload.codex2apiOAuthState || null; if (Object.keys(updates).length) { await setState(updates); } @@ -402,6 +423,9 @@ await ensureManualInteractionAllowed('手动执行步骤'); } const step = message.payload.step; + if (message.source === 'sidepanel') { + await ensureManualStepPrerequisites(step); + } if (message.source === 'sidepanel') { await invalidateDownstreamAfterStepRestart(step, { logLabel: `步骤 ${step} 重新执行` }); } diff --git a/background/navigation-utils.js b/background/navigation-utils.js index 2c7b793..edde364 100644 --- a/background/navigation-utils.js +++ b/background/navigation-utils.js @@ -3,6 +3,7 @@ })(typeof self !== 'undefined' ? self : globalThis, function createBackgroundNavigationUtilsModule() { function createNavigationUtils(deps = {}) { const { + DEFAULT_CODEX2API_URL, DEFAULT_SUB2API_URL, normalizeLocalCpaStep9Mode, } = deps; @@ -27,13 +28,36 @@ return parsed.toString(); } + function normalizeCodex2ApiUrl(rawUrl) { + const input = (rawUrl || '').trim() || DEFAULT_CODEX2API_URL; + const withProtocol = /^https?:\/\//i.test(input) ? input : `http://${input}`; + const parsed = new URL(withProtocol); + if (!parsed.pathname || parsed.pathname === '/' || parsed.pathname === '/admin') { + parsed.pathname = '/admin/accounts'; + } + parsed.hash = ''; + return parsed.toString(); + } + function getPanelMode(state = {}) { - return state.panelMode === 'sub2api' ? 'sub2api' : 'cpa'; + if (state.panelMode === 'sub2api') { + return 'sub2api'; + } + if (state.panelMode === 'codex2api') { + return 'codex2api'; + } + return 'cpa'; } function getPanelModeLabel(modeOrState) { const mode = typeof modeOrState === 'string' ? modeOrState : getPanelMode(modeOrState); - return mode === 'sub2api' ? 'SUB2API' : 'CPA'; + if (mode === 'sub2api') { + return 'SUB2API'; + } + if (mode === 'codex2api') { + return 'Codex2API'; + } + return 'CPA'; } function isSignupPageHost(hostname = '') { @@ -124,6 +148,14 @@ || candidate.pathname.startsWith('/login') || candidate.pathname === '/' ); + case 'codex2api-panel': + return Boolean(reference) + && candidate.origin === reference.origin + && ( + candidate.pathname.startsWith('/admin/accounts') + || candidate.pathname === '/admin' + || candidate.pathname === '/' + ); default: return false; } @@ -160,6 +192,7 @@ isSignupPageHost, isSignupPasswordPageUrl, matchesSourceUrlFamily, + normalizeCodex2ApiUrl, normalizeSub2ApiUrl, parseUrlSafely, shouldBypassStep9ForLocalCpa, diff --git a/background/panel-bridge.js b/background/panel-bridge.js index ad97e6c..a667960 100644 --- a/background/panel-bridge.js +++ b/background/panel-bridge.js @@ -8,6 +8,7 @@ closeConflictingTabsForSource, ensureContentScriptReadyOnTab, getPanelMode, + normalizeCodex2ApiUrl, normalizeSub2ApiUrl, rememberSourceLastUrl, sendToContentScript, @@ -17,7 +18,74 @@ SUB2API_STEP1_RESPONSE_TIMEOUT_MS, } = deps; + function normalizeAdminKey(value = '') { + return String(value || '').trim(); + } + + function extractStateFromAuthUrl(authUrl = '') { + try { + return new URL(authUrl).searchParams.get('state') || ''; + } catch { + return ''; + } + } + + function getCodex2ApiErrorMessage(payload, responseStatus = 500) { + const candidates = [ + payload?.error, + payload?.message, + payload?.detail, + payload?.reason, + ]; + const message = candidates + .map((value) => String(value || '').trim()) + .find(Boolean); + return message || `Codex2API 请求失败(HTTP ${responseStatus})。`; + } + + async function fetchCodex2ApiJson(origin, path, options = {}) { + const timeoutMs = Math.max(1000, Math.floor(Number(options.timeoutMs) || 30000)); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch(`${origin}${path}`, { + method: options.method || 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'X-Admin-Key': normalizeAdminKey(options.adminKey), + }, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + signal: controller.signal, + }); + + let payload = {}; + try { + payload = await response.json(); + } catch { + payload = {}; + } + + if (!response.ok) { + throw new Error(getCodex2ApiErrorMessage(payload, response.status)); + } + + return payload; + } catch (error) { + if (error?.name === 'AbortError') { + throw new Error('Codex2API 请求超时,请稍后重试。'); + } + throw error; + } finally { + clearTimeout(timer); + } + } + async function requestOAuthUrlFromPanel(state, options = {}) { + if (getPanelMode(state) === 'codex2api') { + return requestCodex2ApiOAuthUrl(state, options); + } if (getPanelMode(state) === 'sub2api') { return requestSub2ApiOAuthUrl(state, options); } @@ -74,6 +142,39 @@ return result || {}; } + async function requestCodex2ApiOAuthUrl(state, options = {}) { + const { logLabel = 'OAuth 刷新' } = options; + const codex2apiUrl = normalizeCodex2ApiUrl(state.codex2apiUrl); + const adminKey = normalizeAdminKey(state.codex2apiAdminKey); + + if (!adminKey) { + throw new Error('尚未配置 Codex2API 管理密钥,请先在侧边栏填写。'); + } + + const origin = new URL(codex2apiUrl).origin; + await addLog(`${logLabel}:正在通过 Codex2API 协议生成 OAuth 授权链接...`); + + const result = await fetchCodex2ApiJson(origin, '/api/admin/oauth/generate-auth-url', { + adminKey, + method: 'POST', + body: {}, + }); + + const oauthUrl = String(result?.auth_url || result?.authUrl || '').trim(); + const sessionId = String(result?.session_id || result?.sessionId || '').trim(); + const oauthState = extractStateFromAuthUrl(oauthUrl); + + if (!oauthUrl || !sessionId) { + throw new Error('Codex2API 未返回有效的 auth_url 或 session_id。'); + } + + return { + oauthUrl, + codex2apiSessionId: sessionId, + codex2apiOAuthState: oauthState || null, + }; + } + async function requestSub2ApiOAuthUrl(state, options = {}) { const { logLabel = 'OAuth 刷新' } = options; const sub2apiUrl = normalizeSub2ApiUrl(state.sub2apiUrl); @@ -135,6 +236,7 @@ return { requestOAuthUrlFromPanel, + requestCodex2ApiOAuthUrl, requestCpaOAuthUrl, requestSub2ApiOAuthUrl, }; diff --git a/background/signup-flow-helpers.js b/background/signup-flow-helpers.js index 3ba844b..dc64c9b 100644 --- a/background/signup-flow-helpers.js +++ b/background/signup-flow-helpers.js @@ -3,6 +3,7 @@ })(typeof self !== 'undefined' ? self : globalThis, function createSignupFlowHelpersModule() { function createSignupFlowHelpers(deps = {}) { const { + addLog, buildGeneratedAliasEmail, chrome, ensureContentScriptReadyOnTab, @@ -12,6 +13,7 @@ isGeneratedAliasProvider, isReusableGeneratedAliasEmail, isHotmailProvider, + isRetryableContentScriptTransportError = () => false, isLuckmailProvider, isSignupEmailVerificationPageUrl, isSignupPasswordPageUrl, @@ -164,20 +166,32 @@ logMessage: `步骤 ${step}:认证页仍在切换,正在等待页面恢复后继续确认提交流程...`, }); - const result = await sendToContentScriptResilient('signup-page', { - type: 'PREPARE_SIGNUP_VERIFICATION', - step, - source: 'background', - payload: { - password: password || '', - prepareSource: 'step3_finalize', - prepareLogLabel: '步骤 3 收尾', - }, - }, { - timeoutMs: 30000, - retryDelayMs: 700, - logMessage: `步骤 ${step}:密码已提交,正在确认是否进入下一页面,必要时自动恢复重试页...`, - }); + let result; + try { + result = await sendToContentScriptResilient('signup-page', { + type: 'PREPARE_SIGNUP_VERIFICATION', + step, + source: 'background', + payload: { + password: password || '', + prepareSource: 'step3_finalize', + prepareLogLabel: '步骤 3 收尾', + }, + }, { + timeoutMs: 30000, + retryDelayMs: 700, + logMessage: `步骤 ${step}:密码已提交,正在确认是否进入下一页面,必要时自动恢复重试页...`, + }); + } catch (error) { + if (isRetryableContentScriptTransportError(error)) { + const message = `步骤 ${step}:认证页在提交后切换过程中页面通信超时,未能重新就绪,暂时无法确认是否进入下一页面。请重试当前轮。`; + if (typeof addLog === 'function') { + await addLog(message, 'warn'); + } + throw new Error(message); + } + throw error; + } if (result?.error) { throw new Error(result.error); diff --git a/background/steps/fetch-login-code.js b/background/steps/fetch-login-code.js index 8bf53c4..7baad00 100644 --- a/background/steps/fetch-login-code.js +++ b/background/steps/fetch-login-code.js @@ -9,6 +9,7 @@ chrome, CLOUDFLARE_TEMP_EMAIL_PROVIDER, confirmCustomVerificationStepBypass, + ensureMail2925MailboxSession, ensureStep8VerificationPageReady, getOAuthFlowRemainingMs, getOAuthFlowStepTimeoutMs, @@ -57,6 +58,20 @@ return String(value || '').trim().toLowerCase(); } + function getExpectedMail2925MailboxEmail(state = {}) { + if (Boolean(state?.mail2925UseAccountPool)) { + const currentAccountId = String(state?.currentMail2925AccountId || '').trim(); + const accounts = Array.isArray(state?.mail2925Accounts) ? state.mail2925Accounts : []; + const currentAccount = accounts.find((account) => String(account?.id || '') === currentAccountId) || null; + const accountEmail = String(currentAccount?.email || '').trim().toLowerCase(); + if (accountEmail) { + return accountEmail; + } + } + + return String(state?.mail2925BaseEmail || '').trim().toLowerCase(); + } + async function focusOrOpenMailTab(mail) { const alive = await isTabAlive(mail.source); if (alive) { @@ -134,7 +149,17 @@ await addLog(`步骤 8:正在通过 ${mail.label} 轮询验证码...`); } else { await addLog(`步骤 8:正在打开${mail.label}...`); - await focusOrOpenMailTab(mail); + if (mail.provider === '2925' && typeof ensureMail2925MailboxSession === 'function') { + await ensureMail2925MailboxSession({ + accountId: state.currentMail2925AccountId || null, + forceRelogin: false, + allowLoginWhenOnLoginPage: Boolean(state?.mail2925UseAccountPool), + expectedMailboxEmail: getExpectedMail2925MailboxEmail(state), + actionLabel: 'Step 8: ensure 2925 mailbox session', + }); + } else { + await focusOrOpenMailTab(mail); + } if (mail.provider === '2925') { await addLog(`步骤 8:将直接使用当前已登录的 ${mail.label} 轮询验证码。`, 'info'); } diff --git a/background/steps/fetch-signup-code.js b/background/steps/fetch-signup-code.js index c1d9124..77dbeaa 100644 --- a/background/steps/fetch-signup-code.js +++ b/background/steps/fetch-signup-code.js @@ -24,6 +24,20 @@ throwIfStopped, } = deps; + function getExpectedMail2925MailboxEmail(state = {}) { + if (Boolean(state?.mail2925UseAccountPool)) { + const currentAccountId = String(state?.currentMail2925AccountId || '').trim(); + const accounts = Array.isArray(state?.mail2925Accounts) ? state.mail2925Accounts : []; + const currentAccount = accounts.find((account) => String(account?.id || '') === currentAccountId) || null; + const accountEmail = String(currentAccount?.email || '').trim().toLowerCase(); + if (accountEmail) { + return accountEmail; + } + } + + return String(state?.mail2925BaseEmail || '').trim().toLowerCase(); + } + async function focusOrOpenMailTab(mail) { const alive = await isTabAlive(mail.source); if (alive) { @@ -58,7 +72,7 @@ const signupTabId = await getTabId('signup-page'); if (!signupTabId) { - throw new Error('认证页面标签页已关闭,无法继续步骤 4。'); + throw new Error('认证页面标签页已关闭,无法继续步骤 4。请先执行步骤 1 或步骤 2,重新打开认证页后再试。'); } await chrome.tabs.update(signupTabId, { active: true }); @@ -111,6 +125,7 @@ accountId: state.currentMail2925AccountId || null, forceRelogin: false, allowLoginWhenOnLoginPage: Boolean(state?.mail2925UseAccountPool), + expectedMailboxEmail: getExpectedMail2925MailboxEmail(state), actionLabel: '步骤 4:确认 2925 邮箱登录态', }); } else { diff --git a/background/steps/oauth-login.js b/background/steps/oauth-login.js index 596ac9f..0d1c9e8 100644 --- a/background/steps/oauth-login.js +++ b/background/steps/oauth-login.js @@ -23,6 +23,20 @@ throwIfStopped, } = deps; + function isManagementSecretConfigError(error) { + const message = String(typeof error === 'string' ? error : error?.message || '').trim(); + if (!message) { + return false; + } + + const mentionsSecret = /管理密钥|Admin Secret|X-Admin-Key|CPA Key/i.test(message); + if (!mentionsSecret) { + return false; + } + + return /缺少|未配置|请输入|无效|错误|失败|401|认证失败|未授权|unauthorized|invalid/i.test(message); + } + async function executeStep7(state) { if (!state.email) { throw new Error('缺少邮箱地址,请先完成步骤 3。'); @@ -99,6 +113,13 @@ if (isAddPhoneAuthFailure(err)) { throw err; } + if (isManagementSecretConfigError(err)) { + await addLog( + `步骤 7:检测到来源后台管理密钥缺失或错误,不再重试,当前流程停止。原因:${getErrorMessage(err)}`, + 'error' + ); + throw err; + } lastError = err; if (attempt >= STEP6_MAX_ATTEMPTS) { break; diff --git a/background/steps/platform-verify.js b/background/steps/platform-verify.js index 97bc10d..d4e01b4 100644 --- a/background/steps/platform-verify.js +++ b/background/steps/platform-verify.js @@ -12,6 +12,7 @@ getTabId, isLocalhostOAuthCallbackUrl, isTabAlive, + normalizeCodex2ApiUrl, normalizeSub2ApiUrl, rememberSourceLastUrl, reuseOrCreateTab, @@ -21,7 +22,86 @@ SUB2API_STEP9_RESPONSE_TIMEOUT_MS, } = deps; + function normalizeString(value = '') { + return String(value || '').trim(); + } + + function parseLocalhostCallback(rawUrl) { + let parsed; + try { + parsed = new URL(rawUrl); + } catch { + throw new Error('步骤 10 捕获到的 localhost OAuth 回调地址格式无效,请重新执行步骤 9。'); + } + + const code = normalizeString(parsed.searchParams.get('code')); + const state = normalizeString(parsed.searchParams.get('state')); + if (!code || !state) { + throw new Error('步骤 10 捕获到的 localhost OAuth 回调地址缺少 code 或 state,请重新执行步骤 9。'); + } + + return { + url: parsed.toString(), + code, + state, + }; + } + + function getCodex2ApiErrorMessage(payload, responseStatus = 500) { + const details = [ + payload?.error, + payload?.message, + payload?.detail, + payload?.reason, + ] + .map((value) => normalizeString(value)) + .find(Boolean); + return details || `Codex2API 请求失败(HTTP ${responseStatus})。`; + } + + async function fetchCodex2ApiJson(origin, path, options = {}) { + const controller = new AbortController(); + const timeoutMs = Math.max(1000, Math.floor(Number(options.timeoutMs) || 30000)); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch(`${origin}${path}`, { + method: options.method || 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'X-Admin-Key': normalizeString(options.adminKey), + }, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + signal: controller.signal, + }); + + let payload = {}; + try { + payload = await response.json(); + } catch { + payload = {}; + } + + if (!response.ok) { + throw new Error(getCodex2ApiErrorMessage(payload, response.status)); + } + + return payload; + } catch (error) { + if (error?.name === 'AbortError') { + throw new Error('Codex2API 请求超时,请稍后重试。'); + } + throw error; + } finally { + clearTimeout(timer); + } + } + async function executeStep10(state) { + if (getPanelMode(state) === 'codex2api') { + return executeCodex2ApiStep10(state); + } if (getPanelMode(state) === 'sub2api') { return executeSub2ApiStep10(state); } @@ -90,6 +170,48 @@ } } + async function executeCodex2ApiStep10(state) { + if (state.localhostUrl && !isLocalhostOAuthCallbackUrl(state.localhostUrl)) { + throw new Error('步骤 9 捕获到的 localhost OAuth 回调地址无效,请重新执行步骤 9。'); + } + if (!state.localhostUrl) { + throw new Error('缺少 localhost 回调地址,请先完成步骤 9。'); + } + if (!state.codex2apiSessionId) { + throw new Error('缺少 Codex2API 会话信息,请重新执行步骤 7。'); + } + if (!normalizeString(state.codex2apiAdminKey)) { + throw new Error('尚未配置 Codex2API 管理密钥,请先在侧边栏填写。'); + } + + const callback = parseLocalhostCallback(state.localhostUrl); + const expectedState = normalizeString(state.codex2apiOAuthState); + if (expectedState && expectedState !== callback.state) { + throw new Error('Codex2API 回调 state 与当前授权会话不匹配,请重新执行步骤 7。'); + } + + const codex2apiUrl = normalizeCodex2ApiUrl(state.codex2apiUrl); + const origin = new URL(codex2apiUrl).origin; + + await addLog('步骤 10:正在向 Codex2API 提交回调并创建账号...'); + const result = await fetchCodex2ApiJson(origin, '/api/admin/oauth/exchange-code', { + adminKey: state.codex2apiAdminKey, + method: 'POST', + body: { + session_id: state.codex2apiSessionId, + code: callback.code, + state: callback.state, + }, + }); + + const verifiedStatus = normalizeString(result?.message) || 'Codex2API OAuth 账号添加成功'; + await addLog(`步骤 10:${verifiedStatus}`, 'ok'); + await completeStepFromBackground(10, { + localhostUrl: callback.url, + verifiedStatus, + }); + } + async function executeSub2ApiStep10(state) { if (state.localhostUrl && !isLocalhostOAuthCallbackUrl(state.localhostUrl)) { throw new Error('步骤 9 捕获到的 localhost OAuth 回调地址无效,请重新执行步骤 9。'); @@ -161,6 +283,7 @@ return { executeCpaStep10, + executeCodex2ApiStep10, executeStep10, executeSub2ApiStep10, }; diff --git a/background/tab-runtime.js b/background/tab-runtime.js index 54f6dfc..51619cb 100644 --- a/background/tab-runtime.js +++ b/background/tab-runtime.js @@ -376,6 +376,17 @@ return 30000; } + function resolveResponseTimeoutMs(message, requestedResponseTimeoutMs, remainingTimeoutMs = null) { + const fallbackTimeoutMs = getContentScriptResponseTimeoutMs(message); + const requestedTimeoutMs = Number.isFinite(Number(requestedResponseTimeoutMs)) + ? Math.max(1, Math.floor(Number(requestedResponseTimeoutMs))) + : fallbackTimeoutMs; + if (!Number.isFinite(Number(remainingTimeoutMs))) { + return requestedTimeoutMs; + } + return Math.max(1, Math.min(requestedTimeoutMs, Math.floor(Number(remainingTimeoutMs)))); + } + function getMessageDebugLabel(source, message, tabId = null) { const parts = [source || 'unknown', message?.type || 'UNKNOWN']; if (Number.isInteger(message?.step)) parts.push(`step=${message.step}`); @@ -439,7 +450,13 @@ pendingCommands.delete(source); reject(new Error(`Content script on ${source} did not respond in ${timeout / 1000}s. Try refreshing the tab and retry.`)); }, timeout); - pendingCommands.set(source, { message, resolve, reject, timer }); + pendingCommands.set(source, { + message, + resolve, + reject, + timer, + responseTimeoutMs: timeout, + }); console.log(LOG_PREFIX, `Command queued for ${source} (waiting for ready)`); }); } @@ -449,7 +466,7 @@ if (pending) { clearTimeout(pending.timer); pendingCommands.delete(source); - sendTabMessageWithTimeout(tabId, source, pending.message).then(pending.resolve).catch(pending.reject); + sendTabMessageWithTimeout(tabId, source, pending.message, pending.responseTimeoutMs).then(pending.resolve).catch(pending.reject); console.log(LOG_PREFIX, `Flushed queued command to ${source} (tab ${tabId})`); } } @@ -564,13 +581,13 @@ if (!entry || !entry.ready) { throwIfStopped(); - return queueCommand(source, message); + return queueCommand(source, message, responseTimeoutMs); } const alive = await isTabAlive(source); throwIfStopped(); if (!alive) { - return queueCommand(source, message); + return queueCommand(source, message, responseTimeoutMs); } throwIfStopped(); @@ -592,12 +609,18 @@ while (Date.now() - start < timeoutMs) { throwIfStopped(); attempt += 1; + const remainingTimeoutMs = Math.max(1, timeoutMs - (Date.now() - start)); + const effectiveResponseTimeoutMs = resolveResponseTimeoutMs( + message, + responseTimeoutMs, + remainingTimeoutMs + ); try { return await sendToContentScript( source, message, - responseTimeoutMs !== undefined ? { responseTimeoutMs } : {} + { responseTimeoutMs: effectiveResponseTimeoutMs } ); } catch (err) { const retryable = isRetryableContentScriptTransportError(err); @@ -631,12 +654,18 @@ while (Date.now() - start < timeoutMs) { throwIfStopped(); + const remainingTimeoutMs = Math.max(1, timeoutMs - (Date.now() - start)); + const effectiveResponseTimeoutMs = resolveResponseTimeoutMs( + message, + responseTimeoutMs, + remainingTimeoutMs + ); try { return await sendToContentScript( mail.source, message, - responseTimeoutMs !== undefined ? { responseTimeoutMs } : {} + { responseTimeoutMs: effectiveResponseTimeoutMs } ); } catch (err) { if (!isRetryableContentScriptTransportError(err)) { @@ -684,6 +713,7 @@ queueCommand, registerTab, rememberSourceLastUrl, + resolveResponseTimeoutMs, reuseOrCreateTab, sendTabMessageWithTimeout, sendToContentScript, diff --git a/content/mail-163.js b/content/mail-163.js index dd7ba4f..edab71b 100644 --- a/content/mail-163.js +++ b/content/mail-163.js @@ -69,15 +69,141 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { // Find mail items // ============================================================ -function findMailItems() { - return document.querySelectorAll('div[sign="letter"]'); +function normalizeText(value) { + return String(value || '').replace(/\s+/g, ' ').trim(); } -function getCurrentMailIds() { +function isVisibleNode(node) { + if (!node) return false; + if (node.hidden) return false; + + const style = typeof window.getComputedStyle === 'function' + ? window.getComputedStyle(node) + : null; + if (style && (style.display === 'none' || style.visibility === 'hidden')) { + return false; + } + + const rect = typeof node.getBoundingClientRect === 'function' + ? node.getBoundingClientRect() + : null; + if (rect && rect.width <= 0 && rect.height <= 0) { + return false; + } + + return true; +} + +function isLikelyMailItemNode(node) { + if (!isVisibleNode(node)) { + return false; + } + if (node.matches?.('div[sign="letter"]')) { + return true; + } + if (node.querySelector?.('.nui-user, span.da0, [sign="trash"], [title="删除邮件"], [class*="subject"], [class*="sender"]')) { + return true; + } + + const summaryText = normalizeText( + node.getAttribute?.('aria-label') + || node.getAttribute?.('title') + || node.textContent + ); + if (!summaryText) { + return false; + } + + return /发件人|验证码|verification|chatgpt|openai|code/i.test(summaryText); +} + +function findMailItems() { + const selectorGroups = [ + 'div[sign="letter"]', + '[role="option"][aria-label]', + '[role="listitem"][aria-label]', + 'tr[aria-label]', + 'li[aria-label]', + 'div[aria-label]', + ]; + + for (const selector of selectorGroups) { + const matches = Array.from(document.querySelectorAll(selector)).filter(isLikelyMailItemNode); + if (matches.length > 0) { + return matches; + } + } + + return []; +} + +function getMailTextBySelectors(item, selectors = []) { + for (const selector of selectors) { + const candidates = item.querySelectorAll(selector); + for (const candidate of candidates) { + const texts = [ + candidate.getAttribute?.('title'), + candidate.getAttribute?.('aria-label'), + candidate.textContent, + ]; + for (const text of texts) { + const normalized = normalizeText(text); + if (normalized) { + return normalized; + } + } + } + } + return ''; +} + +function getMailSenderText(item) { + return getMailTextBySelectors(item, [ + '.nui-user', + '[class*="sender"]', + '[class*="from"]', + '[data-sender]', + ]); +} + +function getMailSubjectText(item) { + return getMailTextBySelectors(item, [ + 'span.da0', + '[class*="subject"]', + '[data-subject]', + 'strong', + ]); +} + +function getMailRowText(item) { + const ariaLabel = normalizeText(item.getAttribute('aria-label')); + const sender = getMailSenderText(item); + const subject = getMailSubjectText(item); + const fullText = normalizeText(item.textContent || ''); + return normalizeText([ariaLabel, sender, subject, fullText].filter(Boolean).join(' ')); +} + +function getMailItemId(item, index = 0) { + const candidates = [ + item.getAttribute('id'), + item.getAttribute('data-id'), + item.dataset?.id, + item.getAttribute('data-key'), + item.getAttribute('key'), + ].filter(Boolean); + + if (candidates.length > 0) { + return String(candidates[0]); + } + + return `${index}|${getMailRowText(item).slice(0, 240)}`; +} + +function getCurrentMailIds(items = []) { const ids = new Set(); - findMailItems().forEach(item => { - const id = item.getAttribute('id') || ''; - if (id) ids.add(id); + const sourceItems = items.length > 0 ? items : findMailItems(); + sourceItems.forEach((item, index) => { + ids.add(getMailItemId(item, index)); }); return ids; } @@ -90,7 +216,7 @@ function normalizeMinuteTimestamp(timestamp) { } function parseMail163Timestamp(rawText) { - const text = (rawText || '').replace(/\s+/g, ' ').trim(); + const text = normalizeText(rawText); if (!text) return null; let match = text.match(/(\d{4})年(\d{1,2})月(\d{1,2})日\s+(\d{1,2}):(\d{2})/); @@ -107,6 +233,37 @@ function parseMail163Timestamp(rawText) { ).getTime(); } + match = text.match(/今天\s*(\d{1,2}):(\d{2})/); + if (match) { + const [, hour, minute] = match; + const now = new Date(); + return new Date( + now.getFullYear(), + now.getMonth(), + now.getDate(), + Number(hour), + Number(minute), + 0, + 0 + ).getTime(); + } + + match = text.match(/昨天\s*(\d{1,2}):(\d{2})/); + if (match) { + const [, hour, minute] = match; + const now = new Date(); + now.setDate(now.getDate() - 1); + return new Date( + now.getFullYear(), + now.getMonth(), + now.getDate(), + Number(hour), + Number(minute), + 0, + 0 + ).getTime(); + } + match = text.match(/\b(\d{1,2}):(\d{2})\b/); if (match) { const [, hour, minute] = match; @@ -125,18 +282,48 @@ function parseMail163Timestamp(rawText) { return null; } -function getMailTimestamp(item) { - const candidates = []; - const timeCell = item.querySelector('.e00[title], [title*="年"][title*=":"]'); - if (timeCell?.getAttribute('title')) candidates.push(timeCell.getAttribute('title')); - if (timeCell?.textContent) candidates.push(timeCell.textContent); +function isLikelyMailTimestampText(value) { + const text = normalizeText(value); + if (!text) { + return false; + } + return /(\d{4}年\d{1,2}月\d{1,2}日\s+\d{1,2}:\d{2})|今天\s*\d{1,2}:\d{2}|昨天\s*\d{1,2}:\d{2}|\b\d{1,2}:\d{2}\b/.test(text); +} - const titledNodes = item.querySelectorAll('[title]'); - titledNodes.forEach((node) => { - const title = node.getAttribute('title'); - if (title) candidates.push(title); +function collectMailTimestampCandidates(item) { + const candidates = []; + const seen = new Set(); + const pushCandidate = (value) => { + const normalized = normalizeText(value); + if (!normalized || !isLikelyMailTimestampText(normalized) || seen.has(normalized)) { + return; + } + seen.add(normalized); + candidates.push(normalized); + }; + + const priorityNodes = item.querySelectorAll('.e00, [title], [aria-label], time, [class*="time"], [class*="date"]'); + priorityNodes.forEach((node) => { + pushCandidate(node.getAttribute?.('title')); + pushCandidate(node.getAttribute?.('aria-label')); + pushCandidate(node.textContent); }); + const textNodes = item.querySelectorAll('span, div, td, strong, b'); + textNodes.forEach((node) => { + const text = normalizeText(node.textContent); + if (text && text.length <= 24) { + pushCandidate(text); + } + }); + + pushCandidate(item.getAttribute('aria-label')); + pushCandidate(item.getAttribute('title')); + return candidates; +} + +function getMailTimestamp(item) { + const candidates = collectMailTimestampCandidates(item); for (const candidate of candidates) { const parsed = parseMail163Timestamp(candidate); if (parsed) return parsed; @@ -145,6 +332,134 @@ function getMailTimestamp(item) { return null; } +function collectOpenedMailTextCandidates() { + const texts = []; + const seen = new Set(); + const pushText = (value) => { + const normalized = normalizeText(value); + if (!normalized || seen.has(normalized)) { + return; + } + seen.add(normalized); + texts.push(normalized); + }; + + const selectors = [ + '.readHtml', + '[class*="readmail"]', + '[class*="mailread"]', + '[class*="mailBody"]', + '[class*="mailbody"]', + '[class*="mail-content"]', + '[class*="mailContent"]', + '[class*="mail-detail"]', + '[class*="mailDetail"]', + '[class*="detail"] [class*="content"]', + '[class*="read"] [class*="content"]', + '[role="main"]', + ]; + + selectors.forEach((selector) => { + document.querySelectorAll(selector).forEach((node) => { + pushText(node.innerText || node.textContent); + }); + }); + + document.querySelectorAll('iframe').forEach((frame) => { + try { + pushText(frame.contentDocument?.body?.innerText || frame.contentDocument?.body?.textContent); + } catch { + // Ignore cross-frame access errors and keep trying other candidates. + } + }); + + pushText(document.body?.innerText || document.body?.textContent); + return texts.sort((a, b) => b.length - a.length); +} + +function selectOpenedMailTextCandidate(item, candidates = [], options = {}) { + const subject = normalizeText(getMailSubjectText(item)).toLowerCase(); + const sender = normalizeText(getMailSenderText(item)).toLowerCase(); + const excludedSet = new Set((options.excludedTexts || []).map((value) => normalizeText(value))); + const allowExcludedFallback = options.allowExcludedFallback !== false; + + const pickCandidate = (source) => source.find((candidate) => { + const lower = candidate.toLowerCase(); + if (subject && lower.includes(subject)) { + return true; + } + if (sender && lower.includes(sender)) { + return true; + } + return Boolean(extractVerificationCode(candidate) && /chatgpt|openai|verification|验证码|login code/i.test(lower)); + }) || source[0] || ''; + + const filteredCandidates = candidates.filter((candidate) => !excludedSet.has(normalizeText(candidate))); + const preferred = pickCandidate(filteredCandidates); + if (preferred || !allowExcludedFallback) { + return preferred; + } + + return pickCandidate(candidates); +} + +function readOpenedMailText(item, options = {}) { + const candidates = collectOpenedMailTextCandidates(); + return selectOpenedMailTextCandidate(item, candidates, options); +} + +async function returnToInbox() { + const inboxLink = document.querySelector('.nui-tree-item-text[title="收件箱"], [title="收件箱"]'); + if (inboxLink) { + if (typeof simulateClick === 'function') { + simulateClick(inboxLink); + } else { + inboxLink.click(); + } + } + + for (let i = 0; i < 20; i += 1) { + if (findMailItems().length > 0) { + return true; + } + await sleep(250); + } + + return false; +} + +async function openMailAndGetMessageText(item) { + const beforeCandidates = collectOpenedMailTextCandidates(); + const beforeText = selectOpenedMailTextCandidate(item, beforeCandidates); + if (typeof simulateClick === 'function') { + simulateClick(item); + } else { + item.click(); + } + + let openedText = ''; + for (let i = 0; i < 24; i += 1) { + await sleep(250); + const candidate = readOpenedMailText(item, { + excludedTexts: beforeCandidates, + allowExcludedFallback: false, + }); + if (!candidate) { + continue; + } + openedText = candidate; + if (extractVerificationCode(candidate)) { + break; + } + if (candidate !== beforeText && candidate.length > beforeText.length + 24) { + break; + } + } + + await returnToInbox(); + return openedText; +} + function scheduleEmailCleanup(item, step) { setTimeout(() => { Promise.resolve(deleteEmail(item, step)).catch(() => { @@ -199,7 +514,7 @@ async function handlePollEmail(step, payload) { log(`步骤 ${step}:邮件列表已加载,共 ${items.length} 封邮件`); // Snapshot existing mail IDs - const existingMailIds = getCurrentMailIds(); + const existingMailIds = getCurrentMailIds(items); log(`步骤 ${step}:已记录当前 ${existingMailIds.size} 封旧邮件快照`); const FALLBACK_AFTER = 3; @@ -215,38 +530,58 @@ async function handlePollEmail(step, payload) { const allItems = findMailItems(); const useFallback = attempt > FALLBACK_AFTER; - for (const item of allItems) { - const id = item.getAttribute('id') || ''; + for (let index = 0; index < allItems.length; index++) { + const item = allItems[index]; + const id = getMailItemId(item, index); const mailTimestamp = getMailTimestamp(item); const mailMinute = normalizeMinuteTimestamp(mailTimestamp || 0); const passesTimeFilter = !filterAfterMinute || (mailMinute && mailMinute >= filterAfterMinute); - const shouldBypassOldSnapshot = Boolean(filterAfterMinute && passesTimeFilter && mailMinute > 0); if (!passesTimeFilter) { continue; } - if (!useFallback && !shouldBypassOldSnapshot && existingMailIds.has(id)) continue; + if (!useFallback && existingMailIds.has(id)) { + continue; + } - const senderEl = item.querySelector('.nui-user'); - const sender = senderEl ? senderEl.textContent.toLowerCase() : ''; + const sender = getMailSenderText(item).toLowerCase(); + const subject = getMailSubjectText(item); + const rowText = getMailRowText(item); + const ariaLabel = normalizeText(item.getAttribute('aria-label')).toLowerCase(); + const combinedText = normalizeText([subject, ariaLabel, rowText].filter(Boolean).join(' ')); - const subjectEl = item.querySelector('span.da0'); - const subject = subjectEl ? subjectEl.textContent : ''; + if (!mailTimestamp) { + log(`步骤 ${step}:邮件 ${id.slice(0, 60)} 未读取到时间,已跳过时间窗口校验后的文本匹配阶段。`, 'info'); + } - const ariaLabel = (item.getAttribute('aria-label') || '').toLowerCase(); - - const senderMatch = senderFilters.some(f => sender.includes(f.toLowerCase()) || ariaLabel.includes(f.toLowerCase())); - const subjectMatch = subjectFilters.some(f => subject.toLowerCase().includes(f.toLowerCase()) || ariaLabel.includes(f.toLowerCase())); + const senderMatch = senderFilters.some((filter) => { + const normalizedFilter = String(filter || '').toLowerCase(); + return normalizedFilter && (sender.includes(normalizedFilter) || ariaLabel.includes(normalizedFilter) || rowText.toLowerCase().includes(normalizedFilter)); + }); + const subjectMatch = subjectFilters.some((filter) => { + const normalizedFilter = String(filter || '').toLowerCase(); + return normalizedFilter && (subject.toLowerCase().includes(normalizedFilter) || ariaLabel.includes(normalizedFilter) || rowText.toLowerCase().includes(normalizedFilter)); + }); if (senderMatch || subjectMatch) { - const code = extractVerificationCode(subject + ' ' + ariaLabel); + let code = extractVerificationCode(combinedText); + let codeSource = '邮件列表'; + + if (!code) { + const openedText = await openMailAndGetMessageText(item); + code = extractVerificationCode(openedText); + if (code) { + codeSource = '邮件正文'; + } + } + if (code && excludedCodeSet.has(code)) { log(`步骤 ${step}:跳过排除的验证码:${code}`, 'info'); } else if (code && !seenCodes.has(code)) { seenCodes.add(code); persistSeenCodes(); - const source = useFallback && existingMailIds.has(id) ? '回退匹配邮件' : '新邮件'; + const source = useFallback && existingMailIds.has(id) ? `回退匹配${codeSource}` : `新邮件${codeSource}`; const timeLabel = mailTimestamp ? `,时间:${new Date(mailTimestamp).toLocaleString('zh-CN', { hour12: false })}` : ''; log(`步骤 ${step}:已找到验证码:${code}(来源:${source}${timeLabel},主题:${subject.slice(0, 40)})`, 'ok'); diff --git a/content/mail-2925.js b/content/mail-2925.js index 0bbb977..4263f87 100644 --- a/content/mail-2925.js +++ b/content/mail-2925.js @@ -515,8 +515,9 @@ function detectMail2925ViewState() { return { view: 'limit', limitMessage }; } - if (findMailItems().length > 0) { - return { view: 'mailbox', limitMessage: '' }; + const mailboxEmail = getMail2925DisplayedMailboxEmail(); + if (findMailItems().length > 0 || mailboxEmail) { + return { view: 'mailbox', limitMessage: '', mailboxEmail }; } if (findMail2925LoginPasswordInput() && findMail2925LoginEmailInput()) { @@ -531,6 +532,67 @@ function detectMail2925ViewState() { return { view: 'unknown', limitMessage: '' }; } +function getMail2925DisplayedMailboxEmail() { + const directSelectors = [ + '.right-header', + '[class~="right-header"]', + '[class*="right-header"]', + '[class*="user"] [class*="mail"]', + '[class*="user"] [class*="email"]', + '[class*="account"] [class*="mail"]', + '[class*="account"] [class*="email"]', + '[class*="header"] [class*="mail"]', + '[class*="header"] [class*="email"]', + ]; + + for (const selector of directSelectors) { + const candidates = document.querySelectorAll(selector); + for (const candidate of candidates) { + if (!isVisibleNode(candidate) || isMailItemNode(candidate)) { + continue; + } + const email = extractEmails(candidate.textContent || candidate.innerText || '') + .find((value) => /@2925\.com$/i.test(String(value || '').trim())) || ''; + if (email) { + return email; + } + } + } + + const topCandidates = Array.from(document.querySelectorAll('body *')) + .filter((node) => { + if (!isVisibleNode(node) || isMailItemNode(node)) { + return false; + } + const rect = typeof node.getBoundingClientRect === 'function' + ? node.getBoundingClientRect() + : null; + if (!rect) return false; + return rect.top >= 0 && rect.top <= Math.max(window.innerHeight * 0.35, 280); + }) + .map((node) => { + const email = extractEmails(node.textContent || node.innerText || '') + .find((value) => /@2925\.com$/i.test(String(value || '').trim())) || ''; + return { node, email }; + }) + .filter((entry) => entry.email); + + if (!topCandidates.length) { + return ''; + } + + topCandidates.sort((left, right) => { + const leftRect = left.node.getBoundingClientRect(); + const rightRect = right.node.getBoundingClientRect(); + if (leftRect.top !== rightRect.top) { + return leftRect.top - rightRect.top; + } + return leftRect.left - rightRect.left; + }); + + return topCandidates[0]?.email || ''; +} + function isCheckboxChecked(node) { const checkbox = node?.matches?.('input[type="checkbox"], [role="checkbox"]') ? node @@ -873,6 +935,7 @@ async function ensureMail2925Session(payload = {}) { const email = String(payload?.email || '').trim(); const password = String(payload?.password || ''); const forceLogin = Boolean(payload?.forceLogin); + const allowLoginWhenOnLoginPage = payload?.allowLoginWhenOnLoginPage !== false; log(`步骤 0:2925 登录态检查开始,当前地址 ${location.href},forceLogin=${forceLogin ? 'true' : 'false'}`, 'info'); for (let attempt = 0; attempt < 10; attempt += 1) { @@ -893,9 +956,19 @@ async function ensureMail2925Session(payload = {}) { ok: true, loggedIn: true, currentView: 'mailbox', + mailboxEmail: currentState.mailboxEmail || '', }; } if (currentState.view === 'login') { + if (!forceLogin && !allowLoginWhenOnLoginPage) { + return { + ok: false, + loggedIn: false, + currentView: 'login', + requiresLogin: true, + mailboxEmail: '', + }; + } break; } await sleep(500); @@ -908,6 +981,7 @@ async function ensureMail2925Session(payload = {}) { ok: true, loggedIn: true, currentView: 'mailbox', + mailboxEmail: loginState.mailboxEmail || '', }; } if (loginState.view === 'limit') { @@ -919,6 +993,15 @@ async function ensureMail2925Session(payload = {}) { limitMessage: loginState.limitMessage, }; } + if (!forceLogin && !allowLoginWhenOnLoginPage && loginState.view === 'login') { + return { + ok: false, + loggedIn: false, + currentView: 'login', + requiresLogin: true, + mailboxEmail: '', + }; + } const emailInput = findMail2925LoginEmailInput(); const passwordInput = findMail2925LoginPasswordInput(); @@ -951,6 +1034,7 @@ async function ensureMail2925Session(payload = {}) { loggedIn: true, currentView: 'mailbox', usedCredentials: true, + mailboxEmail: finalState.mailboxEmail || getMail2925DisplayedMailboxEmail() || '', }; } diff --git a/content/signup-page.js b/content/signup-page.js index 53881b7..0b74b3e 100644 --- a/content/signup-page.js +++ b/content/signup-page.js @@ -1621,8 +1621,13 @@ function createStep6RecoverableResult(reason, snapshot, options = {}) { }; } -async function createStep6LoginTimeoutRecoverableResult(reason, snapshot, message) { - const resolvedSnapshot = normalizeStep6Snapshot(snapshot || inspectLoginAuthState()); +async function createStep6LoginTimeoutRecoveryTransition(reason, snapshot, message, options = {}) { + const { + loginVerificationRequestedAt = null, + via = 'login_timeout_recovered', + } = options; + let resolvedSnapshot = normalizeStep6Snapshot(snapshot || inspectLoginAuthState()); + let recovered = false; if (resolvedSnapshot?.state === 'login_timeout_error_page') { try { const recoveryResult = await recoverCurrentAuthRetryPage({ @@ -1631,8 +1636,9 @@ async function createStep6LoginTimeoutRecoverableResult(reason, snapshot, messag step: 7, timeoutMs: 12000, }); - if (recoveryResult?.recovered) { - log('步骤 7:登录超时报错页已点击“重试”,准备重新执行当前步骤。', 'warn'); + recovered = Boolean(recoveryResult?.recovered); + if (recovered) { + log('步骤 7:登录超时报错页已点击“重试”,正在按恢复后的页面状态继续当前流程。', 'warn'); } } catch (error) { if (/CF_SECURITY_BLOCKED::/i.test(String(error?.message || error || ''))) { @@ -1642,7 +1648,46 @@ async function createStep6LoginTimeoutRecoverableResult(reason, snapshot, messag } } - return createStep6RecoverableResult(reason, resolvedSnapshot, { + resolvedSnapshot = recovered + ? normalizeStep6Snapshot(await waitForKnownLoginAuthState(4000)) + : normalizeStep6Snapshot(inspectLoginAuthState()); + + if (resolvedSnapshot.state === 'verification_page') { + return { + action: 'done', + result: createStep6SuccessResult(resolvedSnapshot, { + via, + loginVerificationRequestedAt, + }), + }; + } + + if (resolvedSnapshot.state === 'password_page') { + log('步骤 7:登录超时报错页恢复后已进入密码页,继续当前登录流程。', 'warn'); + return { action: 'password', snapshot: resolvedSnapshot }; + } + + if (resolvedSnapshot.state === 'email_page') { + log('步骤 7:登录超时报错页恢复后已回到邮箱输入页,继续当前登录流程。', 'warn'); + return { action: 'email', snapshot: resolvedSnapshot }; + } + + return { + action: 'recoverable', + result: createStep6RecoverableResult(reason, resolvedSnapshot, { + message, + loginVerificationRequestedAt, + }), + }; +} + +async function createStep6LoginTimeoutRecoverableResult(reason, snapshot, message) { + const transition = await createStep6LoginTimeoutRecoveryTransition(reason, snapshot, message); + if (transition?.action === 'done' || transition?.action === 'recoverable') { + return transition.result; + } + + return createStep6RecoverableResult(reason, transition?.snapshot || normalizeStep6Snapshot(inspectLoginAuthState()), { message, }); } @@ -2222,13 +2267,30 @@ async function waitForStep6EmailSubmitTransition(emailSubmittedAt, timeout = 120 } if (snapshot.state === 'login_timeout_error_page') { + const transition = await createStep6LoginTimeoutRecoveryTransition( + 'login_timeout_error_page', + snapshot, + '提交邮箱后进入登录超时报错页。', + { + loginVerificationRequestedAt: emailSubmittedAt, + via: 'email_submit_timeout_recovered', + } + ); + if (transition.action === 'done') { + return { + action: 'done', + result: transition.result, + }; + } + if (transition.action === 'password') { + return { action: 'password', snapshot: transition.snapshot }; + } + if (transition.action === 'email') { + return { action: 'email', snapshot: transition.snapshot }; + } return { action: 'recoverable', - result: await createStep6LoginTimeoutRecoverableResult( - 'login_timeout_error_page', - snapshot, - '提交邮箱后进入登录超时报错页。' - ), + result: transition.result, }; } @@ -2257,13 +2319,30 @@ async function waitForStep6EmailSubmitTransition(emailSubmittedAt, timeout = 120 return { action: 'password', snapshot }; } if (snapshot.state === 'login_timeout_error_page') { + const transition = await createStep6LoginTimeoutRecoveryTransition( + 'login_timeout_error_page', + snapshot, + '提交邮箱后进入登录超时报错页。', + { + loginVerificationRequestedAt: emailSubmittedAt, + via: 'email_submit_timeout_recovered', + } + ); + if (transition.action === 'done') { + return { + action: 'done', + result: transition.result, + }; + } + if (transition.action === 'password') { + return { action: 'password', snapshot: transition.snapshot }; + } + if (transition.action === 'email') { + return { action: 'email', snapshot: transition.snapshot }; + } return { action: 'recoverable', - result: await createStep6LoginTimeoutRecoverableResult( - 'login_timeout_error_page', - snapshot, - '提交邮箱后进入登录超时报错页。' - ), + result: transition.result, }; } if (snapshot.state === 'oauth_consent_page') { @@ -2300,13 +2379,30 @@ async function waitForStep6PasswordSubmitTransition(passwordSubmittedAt, timeout } if (snapshot.state === 'login_timeout_error_page') { + const transition = await createStep6LoginTimeoutRecoveryTransition( + 'login_timeout_error_page', + snapshot, + '提交密码后进入登录超时报错页。', + { + loginVerificationRequestedAt: passwordSubmittedAt, + via: 'password_submit_timeout_recovered', + } + ); + if (transition.action === 'done') { + return { + action: 'done', + result: transition.result, + }; + } + if (transition.action === 'password') { + return { action: 'password', snapshot: transition.snapshot }; + } + if (transition.action === 'email') { + return { action: 'email', snapshot: transition.snapshot }; + } return { action: 'recoverable', - result: await createStep6LoginTimeoutRecoverableResult( - 'login_timeout_error_page', - snapshot, - '提交密码后进入登录超时报错页。' - ), + result: transition.result, }; } @@ -2332,13 +2428,30 @@ async function waitForStep6PasswordSubmitTransition(passwordSubmittedAt, timeout }; } if (snapshot.state === 'login_timeout_error_page') { + const transition = await createStep6LoginTimeoutRecoveryTransition( + 'login_timeout_error_page', + snapshot, + '提交密码后进入登录超时报错页。', + { + loginVerificationRequestedAt: passwordSubmittedAt, + via: 'password_submit_timeout_recovered', + } + ); + if (transition.action === 'done') { + return { + action: 'done', + result: transition.result, + }; + } + if (transition.action === 'password') { + return { action: 'password', snapshot: transition.snapshot }; + } + if (transition.action === 'email') { + return { action: 'email', snapshot: transition.snapshot }; + } return { action: 'recoverable', - result: await createStep6LoginTimeoutRecoverableResult( - 'login_timeout_error_page', - snapshot, - '提交密码后进入登录超时报错页。' - ), + result: transition.result, }; } if (snapshot.state === 'oauth_consent_page') { @@ -2375,11 +2488,22 @@ async function waitForStep6SwitchTransition(loginVerificationRequestedAt, timeou } if (snapshot.state === 'login_timeout_error_page') { - return await createStep6LoginTimeoutRecoverableResult( + const transition = await createStep6LoginTimeoutRecoveryTransition( 'login_timeout_error_page', snapshot, - '切换到一次性验证码登录后进入登录超时报错页。' + '切换到一次性验证码登录后进入登录超时报错页。', + { + loginVerificationRequestedAt, + via: 'switch_to_one_time_code_timeout_recovered', + } ); + if (transition.action === 'done') { + return transition.result; + } + if (transition.action === 'password' || transition.action === 'email') { + return transition; + } + return transition.result; } if (snapshot.state === 'oauth_consent_page') { @@ -2401,11 +2525,22 @@ async function waitForStep6SwitchTransition(loginVerificationRequestedAt, timeou }); } if (snapshot.state === 'login_timeout_error_page') { - return await createStep6LoginTimeoutRecoverableResult( + const transition = await createStep6LoginTimeoutRecoveryTransition( 'login_timeout_error_page', snapshot, - '切换到一次性验证码登录后进入登录超时报错页。' + '切换到一次性验证码登录后进入登录超时报错页。', + { + loginVerificationRequestedAt, + via: 'switch_to_one_time_code_timeout_recovered', + } ); + if (transition.action === 'done') { + return transition.result; + } + if (transition.action === 'password' || transition.action === 'email') { + return transition; + } + return transition.result; } if (snapshot.state === 'oauth_consent_page') { throw new Error(`切换到一次性验证码登录后页面直接进入 OAuth 授权页,未经过登录验证码页。URL: ${snapshot.url}`); @@ -2419,7 +2554,7 @@ async function waitForStep6SwitchTransition(loginVerificationRequestedAt, timeou }); } -async function step6SwitchToOneTimeCodeLogin(snapshot) { +async function step6SwitchToOneTimeCodeLogin(payload, snapshot) { const switchTrigger = snapshot?.switchTrigger || findOneTimeCodeLoginTrigger(); if (!switchTrigger || !isActionEnabled(switchTrigger)) { return createStep6RecoverableResult('missing_one_time_code_trigger', normalizeStep6Snapshot(inspectLoginAuthState()), { @@ -2441,6 +2576,12 @@ async function step6SwitchToOneTimeCodeLogin(snapshot) { via: result.via || 'switch_to_one_time_code_login', }); } + if (result?.action === 'password') { + return step6LoginFromPasswordPage(payload, result.snapshot); + } + if (result?.action === 'email') { + return step6LoginFromEmailPage(payload, result.snapshot); + } return result; } @@ -2452,7 +2593,7 @@ async function step6LoginFromPasswordPage(payload, snapshot) { if (!hasPassword) { if (currentSnapshot.switchTrigger) { log('步骤 7:当前未提供密码,改走一次性验证码登录。', 'warn'); - return step6SwitchToOneTimeCodeLogin(currentSnapshot); + return step6SwitchToOneTimeCodeLogin(payload, currentSnapshot); } return createStep6RecoverableResult('missing_password_and_one_time_code_trigger', currentSnapshot, { @@ -2482,8 +2623,14 @@ async function step6LoginFromPasswordPage(payload, snapshot) { log(`步骤 7:${transition.result.message || '提交密码后仍未进入登录验证码页面,准备重新执行步骤 7。'}`, 'warn'); return transition.result; } + if (transition.action === 'password') { + return step6LoginFromPasswordPage(payload, transition.snapshot); + } + if (transition.action === 'email') { + return step6LoginFromEmailPage(payload, transition.snapshot); + } if (transition.action === 'switch') { - return step6SwitchToOneTimeCodeLogin(transition.snapshot); + return step6SwitchToOneTimeCodeLogin(payload, transition.snapshot); } return createStep6RecoverableResult('password_submit_unknown', normalizeStep6Snapshot(inspectLoginAuthState()), { @@ -2492,7 +2639,7 @@ async function step6LoginFromPasswordPage(payload, snapshot) { } if (currentSnapshot.switchTrigger) { - return step6SwitchToOneTimeCodeLogin(currentSnapshot); + return step6SwitchToOneTimeCodeLogin(payload, currentSnapshot); } return createStep6RecoverableResult('password_page_unactionable', currentSnapshot, { @@ -2532,6 +2679,9 @@ async function step6LoginFromEmailPage(payload, snapshot) { log(`步骤 7:${transition.result.message || '提交邮箱后仍未进入目标页面,准备重新执行步骤 7。'}`, 'warn'); return transition.result; } + if (transition.action === 'email') { + return step6LoginFromEmailPage(payload, transition.snapshot); + } if (transition.action === 'password') { return step6LoginFromPasswordPage(payload, transition.snapshot); } @@ -2545,11 +2695,10 @@ async function step6_login(payload) { const { email } = payload; if (!email) throw new Error('登录时缺少邮箱地址。'); - log(`步骤 7:正在使用 ${email} 登录...`); - const snapshot = normalizeStep6Snapshot(await waitForKnownLoginAuthState(15000)); if (snapshot.state === 'verification_page') { + log('步骤 7:认证页已在登录验证码页,开始确认页面是否稳定。'); return finalizeStep6VerificationReady({ logLabel: '步骤 7 收尾', loginVerificationRequestedAt: null, @@ -2558,19 +2707,39 @@ async function step6_login(payload) { } if (snapshot.state === 'login_timeout_error_page') { - log('步骤 7:检测到登录超时报错,准备重新执行步骤 7。', 'warn'); - return await createStep6LoginTimeoutRecoverableResult( + log('步骤 7:检测到登录超时报错页,先尝试恢复当前页面。', 'warn'); + const transition = await createStep6LoginTimeoutRecoveryTransition( 'login_timeout_error_page', snapshot, - '当前页面处于登录超时报错页。' + '当前页面处于登录超时报错页。', + { + loginVerificationRequestedAt: null, + via: 'login_timeout_initial_recovered', + } ); + if (transition.action === 'done') { + return finalizeStep6VerificationReady({ + logLabel: '步骤 7 收尾', + loginVerificationRequestedAt: transition.result.loginVerificationRequestedAt || null, + via: transition.result.via || 'login_timeout_initial_recovered', + }); + } + if (transition.action === 'email') { + return step6LoginFromEmailPage(payload, transition.snapshot); + } + if (transition.action === 'password') { + return step6LoginFromPasswordPage(payload, transition.snapshot); + } + return transition.result; } if (snapshot.state === 'email_page') { + log(`步骤 7:正在使用 ${email} 登录...`); return step6LoginFromEmailPage(payload, snapshot); } if (snapshot.state === 'password_page') { + log('步骤 7:认证页已在密码页,继续当前登录流程。'); return step6LoginFromPasswordPage(payload, snapshot); } diff --git a/manifest.json b/manifest.json index ced3774..08b28e8 100644 --- a/manifest.json +++ b/manifest.json @@ -1,8 +1,8 @@ { "manifest_version": 3, "name": "codex-oauth-automation-extension", - "version": "5.8", - "version_name": "Pro5.8", + "version": "7.0", + "version_name": "Pro7.0", "description": "用于自动执行多步骤 OAuth 注册流程", "permissions": [ "sidePanel", diff --git a/sidepanel/contribution-mode.js b/sidepanel/contribution-mode.js index 50aedf7..c54081c 100644 --- a/sidepanel/contribution-mode.js +++ b/sidepanel/contribution-mode.js @@ -24,6 +24,8 @@ dom.rowSub2ApiPassword, dom.rowSub2ApiGroup, dom.rowSub2ApiDefaultProxy, + dom.rowCodex2ApiUrl, + dom.rowCodex2ApiAdminKey, dom.rowCustomPassword, dom.rowAccountRunHistoryHelperBaseUrl, ].filter(Boolean); diff --git a/sidepanel/sidepanel.css b/sidepanel/sidepanel.css index f40f857..c96c47d 100644 --- a/sidepanel/sidepanel.css +++ b/sidepanel/sidepanel.css @@ -806,6 +806,17 @@ header { flex-shrink: 0; } +#row-codex2api-url .data-label, +#row-codex2api-admin-key .data-label { + width: 76px; +} + +#row-codex2api-url .data-label { + font-size: 10px; + letter-spacing: 0.02em; + text-transform: none; +} + .data-value { font-size: 13px; color: var(--text-muted); @@ -2393,6 +2404,19 @@ header { line-height: 1.55; color: var(--text-secondary); margin-bottom: 14px; + white-space: pre-line; +} + +.modal-message a, +.modal-alert a { + color: var(--blue); + text-decoration: underline; + text-underline-offset: 2px; +} + +.modal-message a:hover, +.modal-alert a:hover { + color: var(--cyan); } .modal-alert { diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html index 0f3128d..63f7ab0 100644 --- a/sidepanel/sidepanel.html +++ b/sidepanel/sidepanel.html @@ -112,6 +112,7 @@