From 138bdf4fd077ee0a2a78714f3004f18738a16ea5 Mon Sep 17 00:00:00 2001 From: DanielSong Date: Sat, 11 Apr 2026 14:38:17 +0800 Subject: [PATCH 1/5] feat: add Cloudflare email generator with routing lifecycle controls --- README.md | 64 ++++++++- background.js | 298 ++++++++++++++++++++++++++++++++++++--- content/duck-mail.js | 63 +++++++-- sidepanel/sidepanel.html | 27 +++- sidepanel/sidepanel.js | 89 +++++++++++- 5 files changed, 497 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 27734f22..1ffa6eab 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ - 自动获取注册验证码与登录验证码 - 支持 `QQ Mail`、`163 Mail`、`Inbucket mailbox` - 支持从 DuckDuckGo Email Protection 自动生成新的 `@duck.com` 地址 +- 支持通过 Cloudflare Email Routing API 自动创建随机邮箱前缀并转发到指定邮箱 - Step 5 同时兼容两种页面: - 页面要求填写 `birthday` - 页面要求填写 `age` @@ -59,6 +60,7 @@ - 你自己的 CPA 管理面板,且页面结构与当前脚本适配 - 至少准备一种验证码接收方式: - DuckDuckGo `@duck.com` + QQ / 163 / Inbucket 转发 + - Cloudflare 自定义域邮箱前缀 + QQ / 163 / Inbucket 转发 - 手动填写一个可收信邮箱 - 如果使用 `QQ` / `163` / `Inbucket`,对应页面需要提前能正常打开 @@ -135,13 +137,67 @@ Step 3 使用的注册邮箱。 来源有两种: - 手动粘贴 -- 点击 `Auto` 从 DuckDuckGo Email Protection 自动获取一个新的 `@duck.com` +- 点击 `获取` 自动生成邮箱(DuckDuckGo 或 Cloudflare) 注意: -- 当前 `Auto` 按钮只负责 DuckDuckGo 地址获取 +- 若 `邮箱生成 = Cloudflare`,需要先配置 `CF 域名`、`CF Zone ID`、`转发到`、`CF Token` +- Cloudflare 的 `转发到` 邮箱必须已在 Cloudflare Email Routing 中验证通过 - 如果你使用 Inbucket,它只是验证码收件箱,不会自动生成 Inbucket 地址 +### `邮箱生成 = Cloudflare` 时的配置 + +- `CF 域名`:例如 `example.xyz` +- `CF Zone ID`:该域名对应的 Zone ID +- `转发到`:真正收信的邮箱(例如 `your163@163.com`,需已验证) +- `CF Token`:Cloudflare API Token(至少包含 `Email Routing Rules Write`) + +#### Cloudflare 参数获取教程(ID / Token / 目标邮箱) + +1. 开启 Email Routing + - Cloudflare Dashboard -> 选择域名 -> `Email` -> `Email Routing` + - 首次使用按向导启用(会自动添加/检查 MX、TXT 记录) + +2. 准备并验证 `转发到` 邮箱 + - 在 `Email Routing` 页面添加 Destination address(例如你的 163 邮箱) + - 去目标邮箱点击验证邮件里的 `Verify email address` + - 回到 Cloudflare,确认该 Destination 状态为 `Verified` + +3. 获取 `CF Zone ID` + - Cloudflare Dashboard -> `Account Home` -> 目标域名 `Overview` + - 页面下方 `API` 区域复制 `Zone ID` + +4. 创建 `CF Token` + - Dashboard -> `My Profile` -> `API Tokens` -> `Create Token` -> `Create Custom Token` + - Permissions 至少添加:`Zone` -> `Email Routing Rules` -> `Write`(或 Edit) + - Zone Resources 选择:`Include` -> `Specific zone` -> 你的域名 + - 创建后复制 token(只显示一次) + +5. 回填到插件侧边栏 + - `邮箱生成` 选 `Cloudflare Routing` + - 填 `CF 域名`、`CF Zone ID`、`转发到`、`CF Token` + - 点击 `保存`,然后点 `获取` 测试一次 + +6. 验证是否生效 + - 插件日志中应看到“已创建转发规则” + - Cloudflare `Email Routing` -> `Routing rules` 可看到新建的随机前缀地址规则 + - 用该前缀邮箱收验证码,邮件应转发到你设置的目标邮箱(如 163) + +> 常见问题 +> +> - 提示 token 权限不足:检查 token 是否包含 `Email Routing Rules Write`,并且作用域是正确的 zone。 +> - 提示目标邮箱不可用:确认 `转发到` 邮箱在 Cloudflare 已验证。 +> - 没有收到转发邮件:检查 Email Routing 是否启用成功,以及该域名的 MX 记录是否仍指向 Cloudflare。 + +脚本会在每次自动获取邮箱时: + +1. 生成一个更自然且不易重复的随机前缀地址(如 `user20260411094530123@example.xyz`) +2. 调用 Cloudflare API 创建该地址到 `转发到` 的 forwarding rule +3. 记录一个 3~5 秒的传播等待窗口,Step 3 会自动等待后再继续 +4. 把该随机地址作为 Step 3 的注册邮箱继续后续流程 +5. Step 9 完成后自动删除本轮创建的 Cloudflare routing rule,避免规则污染 +6. 若你在 Step 3 前多次点击“获取”重新生成邮箱,脚本会先删除上一条 Cloudflare 路由再创建新路由 + ### `Password` - 留空:自动生成强密码 @@ -186,8 +242,8 @@ Step 3 使用的注册邮箱。 1. Step 1 获取 CPA OAuth 链接 2. Step 2 打开 OpenAI 注册页 -3. 尝试自动获取 Duck 邮箱 -4. 如果 Duck 自动获取失败,暂停并等待你在侧边栏填写邮箱后点击 `Continue` +3. 按当前“邮箱生成”配置尝试自动获取邮箱(Duck 或 Cloudflare) +4. 如果自动获取失败,暂停并等待你在侧边栏填写邮箱后点击 `Continue` 5. 继续执行 Step 3 ~ Step 9 也就是说: diff --git a/background.js b/background.js index 125b3a7e..ac8e784e 100644 --- a/background.js +++ b/background.js @@ -8,6 +8,8 @@ const STOP_ERROR_MESSAGE = '流程已被用户停止。'; const HUMAN_STEP_DELAY_MIN = 700; const HUMAN_STEP_DELAY_MAX = 2200; const STEP7_RESTART_MAX_ROUNDS = 8; +const CLOUDFLARE_RULE_PROPAGATION_MIN_MS = 3000; +const CLOUDFLARE_RULE_PROPAGATION_MAX_MS = 5000; initializeSessionStorageAccess(); @@ -20,9 +22,14 @@ const PERSISTED_SETTING_DEFAULTS = { vpsPassword: '', // VPS 面板登录密码,可手动填写。 customPassword: '', // 自定义账号密码;留空时由程序自动生成随机密码。 autoRunSkipFailures: false, // 自动运行遇到失败步骤后,是否继续执行后续流程。 - mailProvider: '163', // 验证码邮箱来源,当前支持 163 / inbucket。 + mailProvider: '163', // 验证码邮箱来源(163 / 163-vip / qq / inbucket)。 + emailGenerator: 'duck', // 注册邮箱生成方式:duck / cloudflare。 inbucketHost: '', // 仅当 mailProvider 为 inbucket 时填写 Inbucket 地址,其他情况保持为空。 inbucketMailbox: '', // 仅当 mailProvider 为 inbucket 时填写邮箱名,其他情况保持为空。 + cloudflareDomain: '', // 仅当 emailGenerator=cloudflare 时填写自定义域名(如 example.xyz)。 + cloudflareZoneId: '', // 仅当 emailGenerator=cloudflare 时填写 Cloudflare Zone ID。 + cloudflareForwardTo: '', // 仅当 emailGenerator=cloudflare 时填写已验证的目标邮箱。 + cloudflareApiToken: '', // 仅当 emailGenerator=cloudflare 时填写 API Token(Zone Email Routing Rules:Edit)。 }; const PERSISTED_SETTING_KEYS = Object.keys(PERSISTED_SETTING_DEFAULTS); @@ -59,6 +66,7 @@ async function getPersistedSettings() { ...PERSISTED_SETTING_DEFAULTS, ...stored, autoRunSkipFailures: Boolean(stored.autoRunSkipFailures ?? PERSISTED_SETTING_DEFAULTS.autoRunSkipFailures), + emailGenerator: normalizeEmailGenerator(stored.emailGenerator ?? PERSISTED_SETTING_DEFAULTS.emailGenerator), }; } @@ -1116,6 +1124,7 @@ async function handleMessage(message, sender) { case 'RESET': { clearStopRequest(); + await cleanupCloudflareRoutingRule(null, { reason: '重置流程前清理 Cloudflare 路由' }); await resetState(); await addLog('流程已重置', 'info'); return { ok: true }; @@ -1175,8 +1184,13 @@ async function handleMessage(message, sender) { if (message.payload.customPassword !== undefined) updates.customPassword = message.payload.customPassword; if (message.payload.autoRunSkipFailures !== undefined) updates.autoRunSkipFailures = Boolean(message.payload.autoRunSkipFailures); if (message.payload.mailProvider !== undefined) updates.mailProvider = message.payload.mailProvider; + if (message.payload.emailGenerator !== undefined) updates.emailGenerator = normalizeEmailGenerator(message.payload.emailGenerator); if (message.payload.inbucketHost !== undefined) updates.inbucketHost = message.payload.inbucketHost; if (message.payload.inbucketMailbox !== undefined) updates.inbucketMailbox = message.payload.inbucketMailbox; + if (message.payload.cloudflareDomain !== undefined) updates.cloudflareDomain = normalizeCloudflareDomain(message.payload.cloudflareDomain); + if (message.payload.cloudflareZoneId !== undefined) updates.cloudflareZoneId = String(message.payload.cloudflareZoneId || '').trim(); + if (message.payload.cloudflareForwardTo !== undefined) updates.cloudflareForwardTo = String(message.payload.cloudflareForwardTo || '').trim().toLowerCase(); + if (message.payload.cloudflareApiToken !== undefined) updates.cloudflareApiToken = String(message.payload.cloudflareApiToken || '').trim(); await setPersistentSettings(updates); await setState(updates); return { ok: true }; @@ -1193,13 +1207,25 @@ async function handleMessage(message, sender) { return { ok: true, email: message.payload.email }; } + case 'FETCH_GENERATED_EMAIL': { + clearStopRequest(); + const state = await getState(); + if (isAutoRunLockedState(state)) { + throw new Error('自动流程运行中,当前不能手动获取邮箱。'); + } + const email = await fetchGeneratedEmail(state, message.payload || {}); + await resumeAutoRun(); + return { ok: true, email }; + } + + // Backward compatibility with previous sidepanel versions. case 'FETCH_DUCK_EMAIL': { clearStopRequest(); const state = await getState(); if (isAutoRunLockedState(state)) { - throw new Error('自动流程运行中,当前不能手动获取 Duck 邮箱。'); + throw new Error('自动流程运行中,当前不能手动获取邮箱。'); } - const email = await fetchDuckEmail(message.payload || {}); + const email = await fetchGeneratedEmail(state, { ...(message.payload || {}), generator: 'duck' }); await resumeAutoRun(); return { ok: true, email }; } @@ -1247,6 +1273,7 @@ async function handleStepData(step, payload) { if (localhostPrefix) { await closeTabsByUrlPrefix(localhostPrefix); } + await cleanupCloudflareRoutingRule(null, { reason: '步骤 9 完成后清理本轮 Cloudflare 路由' }); break; } } @@ -1430,6 +1457,194 @@ async function executeStepAndWait(step, delayAfter = 2000) { } } +function normalizeEmailGenerator(value = '') { + return String(value || '').trim().toLowerCase() === 'cloudflare' ? 'cloudflare' : 'duck'; +} + +function getEmailGeneratorLabel(generator) { + return generator === 'cloudflare' ? 'Cloudflare 邮箱' : 'Duck 邮箱'; +} + +function normalizeCloudflareDomain(rawValue = '') { + let value = String(rawValue || '').trim().toLowerCase(); + if (!value) return ''; + value = value.replace(/^@+/, ''); + value = value.replace(/^https?:\/\//, ''); + value = value.replace(/\/.*$/, ''); + if (!/^[a-z0-9.-]+\.[a-z]{2,}$/.test(value)) return ''; + return value; +} + +function isLikelyEmail(value = '') { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(value || '').trim()); +} + +function generateCloudflareAliasLocalPart() { + const now = new Date(); + const stamp = [ + now.getFullYear(), + String(now.getMonth() + 1).padStart(2, '0'), + String(now.getDate()).padStart(2, '0'), + String(now.getHours()).padStart(2, '0'), + String(now.getMinutes()).padStart(2, '0'), + String(now.getSeconds()).padStart(2, '0'), + ].join(''); + const randomPart = String(Math.floor(Math.random() * 900) + 100); + return `user${stamp}${randomPart}`.toLowerCase(); +} + +function getCloudflareConfig(state) { + const zoneId = String(state.cloudflareZoneId || '').trim(); + const domain = normalizeCloudflareDomain(state.cloudflareDomain); + const forwardTo = String(state.cloudflareForwardTo || '').trim().toLowerCase(); + const apiToken = String(state.cloudflareApiToken || '').trim(); + + if (!zoneId) throw new Error('Cloudflare Zone ID 为空。'); + if (!domain) throw new Error('Cloudflare 域名为空或格式无效。'); + if (!forwardTo || !isLikelyEmail(forwardTo)) throw new Error('Cloudflare 转发邮箱为空或格式无效。'); + if (!apiToken) throw new Error('Cloudflare API Token 为空。'); + + return { zoneId, domain, forwardTo, apiToken }; +} + +async function callCloudflareApi({ path, method = 'GET', token, body = null, timeoutMs = 25000 }) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch(`https://api.cloudflare.com/client/v4${path}`, { + method, + headers: { + Authorization: `Bearer ${token}`, + ...(body ? { 'Content-Type': 'application/json' } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + signal: controller.signal, + }); + + const text = await response.text(); + let data = null; + try { + data = text ? JSON.parse(text) : null; + } catch { + data = null; + } + + if (!response.ok) { + const detail = data?.errors?.[0]?.message || text || `HTTP ${response.status}`; + throw new Error(`Cloudflare API 请求失败:${detail}`); + } + + if (data && data.success === false) { + const detail = data.errors?.[0]?.message || '未知错误'; + throw new Error(`Cloudflare API 返回失败:${detail}`); + } + + return data || {}; + } catch (err) { + if (err?.name === 'AbortError') { + throw new Error('Cloudflare API 请求超时。'); + } + throw err; + } finally { + clearTimeout(timer); + } +} + +async function fetchCloudflareEmail(state, options = {}) { + throwIfStopped(); + if (state?.cloudflareLastRuleId) { + await cleanupCloudflareRoutingRule(state, { + reason: '创建新 Cloudflare 前缀前清理旧路由', + }); + } + + const latestState = await getState(); + const { zoneId, domain, forwardTo, apiToken } = getCloudflareConfig(latestState); + const localPart = String(options.localPart || '').trim().toLowerCase() || generateCloudflareAliasLocalPart(); + const aliasEmail = `${localPart}@${domain}`; + + await addLog(`Cloudflare 邮箱:正在创建前缀 ${aliasEmail} -> ${forwardTo} ...`); + + const payload = { + name: `codex-auto-${localPart}`, + enabled: true, + matchers: [ + { type: 'literal', field: 'to', value: aliasEmail }, + ], + actions: [ + { type: 'forward', value: [forwardTo] }, + ], + }; + + const result = await callCloudflareApi({ + path: `/zones/${encodeURIComponent(zoneId)}/email/routing/rules`, + method: 'POST', + token: apiToken, + body: payload, + }); + + const ruleId = String(result?.result?.id || '').trim(); + const propagationWaitMs = Math.floor( + Math.random() * (CLOUDFLARE_RULE_PROPAGATION_MAX_MS - CLOUDFLARE_RULE_PROPAGATION_MIN_MS + 1) + ) + CLOUDFLARE_RULE_PROPAGATION_MIN_MS; + const aliasReadyAt = Date.now() + propagationWaitMs; + await setState({ + cloudflareLastRuleId: ruleId || null, + cloudflareLastAliasEmail: aliasEmail, + cloudflareAliasReadyAt: aliasReadyAt, + }); + + await setEmailState(aliasEmail); + await addLog( + `Cloudflare 邮箱:已创建转发规则${ruleId ? `(${ruleId})` : ''},地址 ${aliasEmail}。建议等待 ${Math.ceil(propagationWaitMs / 1000)} 秒再触发验证码发送。`, + 'ok' + ); + return aliasEmail; +} + +async function cleanupCloudflareRoutingRule(stateOrNull = null, options = {}) { + const state = stateOrNull || await getState(); + const reason = options.reason || '清理 Cloudflare 路由'; + const ruleId = String(state.cloudflareLastRuleId || '').trim(); + if (!ruleId) { + return false; + } + + let zoneId = ''; + let apiToken = ''; + try { + const cfg = getCloudflareConfig(state); + zoneId = cfg.zoneId; + apiToken = cfg.apiToken; + } catch (err) { + await addLog(`${reason}:存在待清理路由 ${ruleId},但当前 Cloudflare 配置不可用(${err.message})。`, 'warn'); + return false; + } + + try { + await callCloudflareApi({ + path: `/zones/${encodeURIComponent(zoneId)}/email/routing/rules/${encodeURIComponent(ruleId)}`, + method: 'DELETE', + token: apiToken, + }); + await addLog(`${reason}:已删除 Cloudflare 路由 ${ruleId}。`, 'info'); + } catch (err) { + await addLog(`${reason}:删除 Cloudflare 路由 ${ruleId} 失败:${err.message}`, 'warn'); + return false; + } + + const latest = await getState(); + if (String(latest.cloudflareLastRuleId || '').trim() === ruleId) { + await setState({ + cloudflareLastRuleId: null, + cloudflareLastAliasEmail: null, + cloudflareAliasReadyAt: null, + }); + } + return true; +} + async function fetchDuckEmail(options = {}) { throwIfStopped(); const { generateNew = true } = options; @@ -1455,6 +1670,15 @@ async function fetchDuckEmail(options = {}) { return result.email; } +async function fetchGeneratedEmail(state, options = {}) { + const currentState = state || await getState(); + const generator = normalizeEmailGenerator(options.generator ?? currentState.emailGenerator); + if (generator === 'cloudflare') { + return fetchCloudflareEmail(currentState, options); + } + return fetchDuckEmail(options); +} + // ============================================================ // Auto Run Flow // ============================================================ @@ -1463,7 +1687,7 @@ let autoRunActive = false; let autoRunCurrentRun = 0; let autoRunTotalRuns = 1; let autoRunAttemptRun = 0; -const DUCK_EMAIL_MAX_ATTEMPTS = 5; +const EMAIL_FETCH_MAX_ATTEMPTS = 5; const VERIFICATION_POLL_MAX_ROUNDS = 5; const AUTO_STEP_DELAYS = { 1: 2000, @@ -1502,23 +1726,32 @@ async function ensureAutoEmailReady(targetRun, totalRuns, attemptRuns) { return currentState.email; } - let lastDuckError = null; - for (let duckAttempt = 1; duckAttempt <= DUCK_EMAIL_MAX_ATTEMPTS; duckAttempt++) { + const generator = normalizeEmailGenerator(currentState.emailGenerator); + const generatorLabel = getEmailGeneratorLabel(generator); + let lastError = null; + + for (let attempt = 1; attempt <= EMAIL_FETCH_MAX_ATTEMPTS; attempt++) { try { - if (duckAttempt > 1) { - await addLog(`Duck 邮箱:正在进行第 ${duckAttempt}/${DUCK_EMAIL_MAX_ATTEMPTS} 次自动获取重试...`, 'warn'); + if (attempt > 1) { + await addLog(`${generatorLabel}:正在进行第 ${attempt}/${EMAIL_FETCH_MAX_ATTEMPTS} 次自动获取重试...`, 'warn'); } - const duckEmail = await fetchDuckEmail({ generateNew: true }); - await addLog(`=== 目标 ${targetRun}/${totalRuns} 轮:Duck 邮箱已就绪:${duckEmail}(第 ${attemptRuns} 次尝试,Duck 第 ${duckAttempt}/${DUCK_EMAIL_MAX_ATTEMPTS} 次获取)===`, 'ok'); - return duckEmail; + const generatedEmail = await fetchGeneratedEmail(currentState, { generateNew: true, generator }); + await addLog( + `=== 目标 ${targetRun}/${totalRuns} 轮:${generatorLabel}已就绪:${generatedEmail}(第 ${attemptRuns} 次尝试,第 ${attempt}/${EMAIL_FETCH_MAX_ATTEMPTS} 次获取)===`, + 'ok' + ); + return generatedEmail; } catch (err) { - lastDuckError = err; - await addLog(`Duck 邮箱自动获取失败(${duckAttempt}/${DUCK_EMAIL_MAX_ATTEMPTS}):${err.message}`, 'warn'); + lastError = err; + await addLog(`${generatorLabel}自动获取失败(${attempt}/${EMAIL_FETCH_MAX_ATTEMPTS}):${err.message}`, 'warn'); + if (generator === 'cloudflare' && /Zone ID|域名|转发邮箱|Token/.test(String(err.message || ''))) { + break; + } } } - await addLog(`Duck 邮箱自动获取已连续失败 ${DUCK_EMAIL_MAX_ATTEMPTS} 次:${lastDuckError?.message || '未知错误'}`, 'error'); - await addLog(`=== 目标 ${targetRun}/${totalRuns} 轮已暂停:请先获取 Duck 邮箱或手动粘贴邮箱,然后继续 ===`, 'warn'); + await addLog(`${generatorLabel}自动获取已连续失败 ${EMAIL_FETCH_MAX_ATTEMPTS} 次:${lastError?.message || '未知错误'}`, 'error'); + await addLog(`=== 目标 ${targetRun}/${totalRuns} 轮已暂停:请先自动获取邮箱或手动粘贴邮箱,然后继续 ===`, 'warn'); await broadcastAutoRunStatus('waiting_email', { currentRun: targetRun, totalRuns, @@ -1649,17 +1882,27 @@ async function autoRunLoop(totalRuns, options = {}) { if (!useExistingProgress) { // Reset everything at the start of each fresh attempt (keep user settings). const prevState = await getState(); + await cleanupCloudflareRoutingRule(prevState, { + reason: forceFreshTabsNextRun + ? '自动运行放弃旧线程后清理 Cloudflare 路由' + : '自动运行新开一轮前清理 Cloudflare 路由', + }); const keepSettings = { vpsUrl: prevState.vpsUrl, vpsPassword: prevState.vpsPassword, customPassword: prevState.customPassword, - autoRunSkipFailures: prevState.autoRunSkipFailures, - mailProvider: prevState.mailProvider, - inbucketHost: prevState.inbucketHost, - inbucketMailbox: prevState.inbucketMailbox, - ...getAutoRunStatusPayload('running', { currentRun: targetRun, totalRuns, attemptRun: attemptRuns }), - ...(forceFreshTabsNextRun ? { tabRegistry: {} } : {}), - }; + autoRunSkipFailures: prevState.autoRunSkipFailures, + mailProvider: prevState.mailProvider, + emailGenerator: prevState.emailGenerator, + inbucketHost: prevState.inbucketHost, + inbucketMailbox: prevState.inbucketMailbox, + cloudflareDomain: prevState.cloudflareDomain, + cloudflareZoneId: prevState.cloudflareZoneId, + cloudflareForwardTo: prevState.cloudflareForwardTo, + cloudflareApiToken: prevState.cloudflareApiToken, + ...getAutoRunStatusPayload('running', { currentRun: targetRun, totalRuns, attemptRun: attemptRuns }), + ...(forceFreshTabsNextRun ? { tabRegistry: {} } : {}), + }; await resetState(); await setState(keepSettings); chrome.runtime.sendMessage({ type: 'AUTO_RUN_RESET' }).catch(() => { }); @@ -1910,6 +2153,17 @@ async function executeStep3(state) { throw new Error('缺少邮箱地址,请先在侧边栏粘贴邮箱。'); } + const aliasReadyAt = Number(state.cloudflareAliasReadyAt || 0); + const lastAlias = String(state.cloudflareLastAliasEmail || '').trim().toLowerCase(); + const currentEmail = String(state.email || '').trim().toLowerCase(); + if (aliasReadyAt && lastAlias && currentEmail && currentEmail === lastAlias) { + const remainingMs = aliasReadyAt - Date.now(); + if (remainingMs > 0) { + await addLog(`步骤 3:检测到 Cloudflare 新前缀刚创建,等待 ${Math.ceil(remainingMs / 1000)} 秒让路由生效...`, 'warn'); + await sleepWithStop(remainingMs); + } + } + const password = state.customPassword || generatePassword(); await setPasswordState(password); diff --git a/content/duck-mail.js b/content/duck-mail.js index 35fd7f84..36ab4df1 100644 --- a/content/duck-mail.js +++ b/content/duck-mail.js @@ -30,9 +30,35 @@ async function fetchDuckEmail(payload = {}) { 15000 ); + const GENERATE_BUTTON_PATTERN = /generate\s+private\s+duck\s+address|new\s+private\s+duck\s+address|generate\s+new|new\s+address|生成.*duck.*地址|生成.*私有.*地址|生成.*地址|新.*地址/i; + const getAddressInput = () => document.querySelector('input.AutofillSettingsPanel__PrivateDuckAddressValue'); - const getGeneratorButton = () => document.querySelector('button.AutofillSettingsPanel__GeneratorButton') - || Array.from(document.querySelectorAll('button')).find(btn => /generate private duck address/i.test(btn.textContent || '')); + const getGeneratorButton = () => { + const direct = document.querySelector('button.AutofillSettingsPanel__GeneratorButton'); + if (direct) return direct; + + const selectors = [ + 'button[data-testid*="Generator"]', + 'button[class*="Generator"]', + 'button[aria-label*="duck" i]', + 'button[title*="duck" i]', + '[role="button"]', + 'button', + ]; + const candidates = selectors.flatMap((selector) => Array.from(document.querySelectorAll(selector))); + return candidates.find((btn) => { + const text = [ + btn.textContent, + btn.getAttribute?.('aria-label'), + btn.getAttribute?.('title'), + ] + .filter(Boolean) + .join(' ') + .replace(/\s+/g, ' ') + .trim(); + return GENERATE_BUTTON_PATTERN.test(text); + }) || null; + }; const readEmail = () => { const value = getAddressInput()?.value?.trim() || ''; return value.includes('@duck.com') ? value : ''; @@ -55,20 +81,39 @@ async function fetchDuckEmail(payload = {}) { return { email: currentEmail, generated: false }; } - await humanPause(500, 1300); const generatorButton = getGeneratorButton(); if (!generatorButton) { + if (generateNew) { + throw new Error('未找到“生成新 Duck 地址”按钮(可能是页面文案/语言变化、未登录或页面结构更新)。'); + } if (currentEmail) { - log(`Duck 邮箱:正在复用现有地址 ${currentEmail}`, 'warn'); + log(`Duck 邮箱:未找到生成按钮,复用现有地址 ${currentEmail}`, 'warn'); return { email: currentEmail, generated: false }; } throw new Error('未找到“生成 Duck 私有地址”按钮。'); } - generatorButton.click(); - log('Duck 邮箱:已点击“生成 Duck 私有地址”按钮'); + for (let attempt = 1; attempt <= 2; attempt++) { + await humanPause(500, 1300); + if (typeof simulateClick === 'function') { + simulateClick(generatorButton); + } else { + generatorButton.click(); + } + log(`Duck 邮箱:已点击“生成 Duck 私有地址”按钮(${attempt}/2)`); + + try { + const nextEmail = await waitForEmailValue(currentEmail); + log(`Duck 邮箱:地址已就绪 ${nextEmail}`, 'ok'); + return { email: nextEmail, generated: true }; + } catch (err) { + if (attempt >= 2) { + throw err; + } + log('Duck 邮箱:首次生成后地址未变化,准备重试一次...', 'warn'); + await sleep(800); + } + } - const nextEmail = await waitForEmailValue(currentEmail); - log(`Duck 邮箱:地址已就绪 ${nextEmail}`, 'ok'); - return { email: nextEmail, generated: true }; + throw new Error('Duck 地址生成失败。'); } diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html index 98a4d9c9..9ba33e85 100644 --- a/sidepanel/sidepanel.html +++ b/sidepanel/sidepanel.html @@ -64,6 +64,29 @@

多页面

+
+ 邮箱生成 + +
+ + + + diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js index f9ded6f0..64147c53 100644 --- a/sidepanel/sidepanel.js +++ b/sidepanel/sidepanel.js @@ -31,10 +31,19 @@ const btnClearLog = document.getElementById('btn-clear-log'); const inputVpsUrl = document.getElementById('input-vps-url'); const inputVpsPassword = document.getElementById('input-vps-password'); const selectMailProvider = document.getElementById('select-mail-provider'); +const selectEmailGenerator = document.getElementById('select-email-generator'); const rowInbucketHost = document.getElementById('row-inbucket-host'); const inputInbucketHost = document.getElementById('input-inbucket-host'); const rowInbucketMailbox = document.getElementById('row-inbucket-mailbox'); const inputInbucketMailbox = document.getElementById('input-inbucket-mailbox'); +const rowCfDomain = document.getElementById('row-cf-domain'); +const inputCfDomain = document.getElementById('input-cf-domain'); +const rowCfZoneId = document.getElementById('row-cf-zone-id'); +const inputCfZoneId = document.getElementById('input-cf-zone-id'); +const rowCfForwardTo = document.getElementById('row-cf-forward-to'); +const inputCfForwardTo = document.getElementById('input-cf-forward-to'); +const rowCfApiToken = document.getElementById('row-cf-api-token'); +const inputCfApiToken = document.getElementById('input-cf-api-token'); const inputRunCount = document.getElementById('input-run-count'); const inputAutoSkipFailures = document.getElementById('input-auto-skip-failures'); const autoStartModal = document.getElementById('auto-start-modal'); @@ -280,8 +289,13 @@ function collectSettingsPayload() { vpsPassword: inputVpsPassword.value, customPassword: inputPassword.value, mailProvider: selectMailProvider.value, + emailGenerator: selectEmailGenerator.value, inbucketHost: inputInbucketHost.value.trim(), inbucketMailbox: inputInbucketMailbox.value.trim(), + cloudflareDomain: inputCfDomain.value.trim(), + cloudflareZoneId: inputCfZoneId.value.trim(), + cloudflareForwardTo: inputCfForwardTo.value.trim(), + cloudflareApiToken: inputCfApiToken.value.trim(), autoRunSkipFailures: inputAutoSkipFailures.checked, }; } @@ -444,12 +458,27 @@ async function restoreState() { if (state.mailProvider) { selectMailProvider.value = state.mailProvider; } + if (state.emailGenerator) { + selectEmailGenerator.value = state.emailGenerator; + } if (state.inbucketHost) { inputInbucketHost.value = state.inbucketHost; } if (state.inbucketMailbox) { inputInbucketMailbox.value = state.inbucketMailbox; } + if (state.cloudflareDomain) { + inputCfDomain.value = state.cloudflareDomain; + } + if (state.cloudflareZoneId) { + inputCfZoneId.value = state.cloudflareZoneId; + } + if (state.cloudflareForwardTo) { + inputCfForwardTo.value = state.cloudflareForwardTo; + } + if (state.cloudflareApiToken) { + inputCfApiToken.value = state.cloudflareApiToken; + } inputAutoSkipFailures.checked = Boolean(state.autoRunSkipFailures); if (state.stepStatuses) { @@ -483,6 +512,11 @@ function updateMailProviderUI() { const useInbucket = selectMailProvider.value === 'inbucket'; rowInbucketHost.style.display = useInbucket ? '' : 'none'; rowInbucketMailbox.style.display = useInbucket ? '' : 'none'; + const useCloudflare = selectEmailGenerator.value === 'cloudflare'; + rowCfDomain.style.display = useCloudflare ? '' : 'none'; + rowCfZoneId.style.display = useCloudflare ? '' : 'none'; + rowCfForwardTo.style.display = useCloudflare ? '' : 'none'; + rowCfApiToken.style.display = useCloudflare ? '' : 'none'; } // ============================================================ @@ -647,7 +681,7 @@ function escapeHtml(text) { return div.innerHTML; } -async function fetchDuckEmail(options = {}) { +async function fetchGeneratedEmail(options = {}) { const { showFailureToast = true } = options; const defaultLabel = '获取'; btnFetchEmail.disabled = true; @@ -655,16 +689,19 @@ async function fetchDuckEmail(options = {}) { try { const response = await chrome.runtime.sendMessage({ - type: 'FETCH_DUCK_EMAIL', + type: 'FETCH_GENERATED_EMAIL', source: 'sidepanel', - payload: { generateNew: true }, + payload: { + generateNew: true, + generator: selectEmailGenerator.value, + }, }); if (response?.error) { throw new Error(response.error); } if (!response?.email) { - throw new Error('未返回 Duck 邮箱。'); + throw new Error('未返回可用邮箱。'); } inputEmail.value = response.email; @@ -774,7 +811,7 @@ document.querySelectorAll('.step-btn').forEach(btn => { let email = inputEmail.value.trim(); if (!email) { try { - email = await fetchDuckEmail({ showFailureToast: false }); + email = await fetchGeneratedEmail({ showFailureToast: false }); } catch (err) { showToast(`自动获取失败:${err.message},请手动粘贴邮箱后重试。`, 'warn'); return; @@ -797,7 +834,7 @@ document.querySelectorAll('.step-btn').forEach(btn => { }); btnFetchEmail.addEventListener('click', async () => { - await fetchDuckEmail().catch(() => {}); + await fetchGeneratedEmail().catch(() => {}); }); btnTogglePassword.addEventListener('click', () => { @@ -871,7 +908,7 @@ btnAutoRun.addEventListener('click', async () => { btnAutoContinue.addEventListener('click', async () => { const email = inputEmail.value.trim(); if (!email) { - showToast('请先获取或粘贴 DuckDuckGo 邮箱。', 'warn'); + showToast('请先获取或粘贴邮箱。', 'warn'); return; } autoContinueBar.style.display = 'none'; @@ -955,6 +992,12 @@ selectMailProvider.addEventListener('change', () => { saveSettings({ silent: true }).catch(() => {}); }); +selectEmailGenerator.addEventListener('change', () => { + updateMailProviderUI(); + markSettingsDirty(true); + saveSettings({ silent: true }).catch(() => {}); +}); + inputInbucketMailbox.addEventListener('input', () => { markSettingsDirty(true); scheduleSettingsAutoSave(); @@ -971,6 +1014,38 @@ inputInbucketHost.addEventListener('blur', () => { saveSettings({ silent: true }).catch(() => {}); }); +inputCfDomain.addEventListener('input', () => { + markSettingsDirty(true); + scheduleSettingsAutoSave(); +}); +inputCfDomain.addEventListener('blur', () => { + saveSettings({ silent: true }).catch(() => {}); +}); + +inputCfZoneId.addEventListener('input', () => { + markSettingsDirty(true); + scheduleSettingsAutoSave(); +}); +inputCfZoneId.addEventListener('blur', () => { + saveSettings({ silent: true }).catch(() => {}); +}); + +inputCfForwardTo.addEventListener('input', () => { + markSettingsDirty(true); + scheduleSettingsAutoSave(); +}); +inputCfForwardTo.addEventListener('blur', () => { + saveSettings({ silent: true }).catch(() => {}); +}); + +inputCfApiToken.addEventListener('input', () => { + markSettingsDirty(true); + scheduleSettingsAutoSave(); +}); +inputCfApiToken.addEventListener('blur', () => { + saveSettings({ silent: true }).catch(() => {}); +}); + inputAutoSkipFailures.addEventListener('change', () => { markSettingsDirty(true); saveSettings({ silent: true }).catch(() => {}); From a980055933145bfc24b9d06435c41654ca472e5e Mon Sep 17 00:00:00 2001 From: DanielSong Date: Sun, 12 Apr 2026 18:27:02 +0800 Subject: [PATCH 2/5] fix(cloudflare): harden routing lifecycle and protect token exposure --- background.js | 205 +++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 177 insertions(+), 28 deletions(-) diff --git a/background.js b/background.js index ac8e784e..d4ccc4b9 100644 --- a/background.js +++ b/background.js @@ -22,6 +22,7 @@ const PERSISTED_SETTING_DEFAULTS = { vpsPassword: '', // VPS 面板登录密码,可手动填写。 customPassword: '', // 自定义账号密码;留空时由程序自动生成随机密码。 autoRunSkipFailures: false, // 自动运行遇到失败步骤后,是否继续执行后续流程。 + verificationWaitTimeoutSec: 40, // 收验证码单轮等待秒数,超时后会自动请求新验证码继续下一轮。 mailProvider: '163', // 验证码邮箱来源(163 / 163-vip / qq / inbucket)。 emailGenerator: 'duck', // 注册邮箱生成方式:duck / cloudflare。 inbucketHost: '', // 仅当 mailProvider 为 inbucket 时填写 Inbucket 地址,其他情况保持为空。 @@ -33,6 +34,8 @@ const PERSISTED_SETTING_DEFAULTS = { }; const PERSISTED_SETTING_KEYS = Object.keys(PERSISTED_SETTING_DEFAULTS); +const SENSITIVE_STATE_KEYS = new Set(['cloudflareApiToken']); +const CLOUDFLARE_RULE_CLEANUP_CONTEXT_KEY = 'cloudflareRuleCleanupContext'; const DEFAULT_STATE = { currentStep: 0, // 当前流程执行到的步骤编号。 @@ -60,22 +63,50 @@ const DEFAULT_STATE = { autoRunAttemptRun: 0, // 当前轮次的重试序号。 }; -async function getPersistedSettings() { +function stripSensitiveStateFields(source = {}) { + const copy = { ...(source || {}) }; + for (const key of SENSITIVE_STATE_KEYS) { + if (key in copy) { + delete copy[key]; + } + } + return copy; +} + +function isTrustedExtensionPageSender(sender) { + if (!sender || sender.id !== chrome.runtime.id) return false; + const url = String(sender.url || ''); + return url.startsWith(`chrome-extension://${chrome.runtime.id}/`); +} + +async function getPersistedSettings(options = {}) { + const includeSensitive = options.includeSensitive !== false; const stored = await chrome.storage.local.get(PERSISTED_SETTING_KEYS); - return { + const settings = { ...PERSISTED_SETTING_DEFAULTS, ...stored, autoRunSkipFailures: Boolean(stored.autoRunSkipFailures ?? PERSISTED_SETTING_DEFAULTS.autoRunSkipFailures), + verificationWaitTimeoutSec: normalizeVerificationWaitTimeoutSec( + stored.verificationWaitTimeoutSec ?? PERSISTED_SETTING_DEFAULTS.verificationWaitTimeoutSec + ), emailGenerator: normalizeEmailGenerator(stored.emailGenerator ?? PERSISTED_SETTING_DEFAULTS.emailGenerator), }; + return includeSensitive ? settings : stripSensitiveStateFields(settings); } -async function getState() { +async function getState(options = {}) { + const includeSensitive = options.includeSensitive !== false; const [state, persistedSettings] = await Promise.all([ chrome.storage.session.get(null), - getPersistedSettings(), + getPersistedSettings({ includeSensitive }), ]); - return { ...DEFAULT_STATE, ...persistedSettings, ...state }; + const merged = { ...DEFAULT_STATE, ...persistedSettings, ...state }; + return includeSensitive ? merged : stripSensitiveStateFields(merged); +} + +async function getPublicStateForSender(sender) { + const includeSensitive = isTrustedExtensionPageSender(sender); + return getState({ includeSensitive }); } async function initializeSessionStorageAccess() { @@ -86,14 +117,24 @@ async function initializeSessionStorageAccess() { }); console.log(LOG_PREFIX, 'Enabled storage.session for content scripts'); } + if (chrome.storage?.local?.setAccessLevel) { + await chrome.storage.local.setAccessLevel({ + accessLevel: 'TRUSTED_CONTEXTS', + }); + console.log(LOG_PREFIX, 'Restricted storage.local to trusted extension contexts'); + } + await chrome.storage.session.remove([...SENSITIVE_STATE_KEYS]); } catch (err) { console.warn(LOG_PREFIX, 'Failed to enable storage.session for content scripts:', err?.message || err); } } async function setState(updates) { - console.log(LOG_PREFIX, 'storage.set:', JSON.stringify(updates).slice(0, 200)); - await chrome.storage.session.set(updates); + const safeUpdates = stripSensitiveStateFields(updates || {}); + console.log(LOG_PREFIX, 'storage.set:', JSON.stringify(safeUpdates).slice(0, 200)); + if (Object.keys(safeUpdates).length > 0) { + await chrome.storage.session.set(safeUpdates); + } } async function setPersistentSettings(updates) { @@ -102,6 +143,8 @@ async function setPersistentSettings(updates) { if (updates[key] !== undefined) { persistedUpdates[key] = key === 'autoRunSkipFailures' ? Boolean(updates[key]) + : key === 'verificationWaitTimeoutSec' + ? normalizeVerificationWaitTimeoutSec(updates[key]) : updates[key]; } } @@ -142,10 +185,10 @@ async function resetState() { 'tabRegistry', 'sourceLastUrls', ]), - getPersistedSettings(), + getPersistedSettings({ includeSensitive: false }), ]); await chrome.storage.session.clear(); - await chrome.storage.session.set({ + await chrome.storage.session.set(stripSensitiveStateFields({ ...DEFAULT_STATE, ...persistedSettings, seenCodes: prev.seenCodes || [], @@ -153,7 +196,8 @@ async function resetState() { accounts: prev.accounts || [], tabRegistry: prev.tabRegistry || {}, sourceLastUrls: prev.sourceLastUrls || {}, - }); + })); + await clearCloudflareRuleCleanupContext(); } /** @@ -1119,12 +1163,15 @@ async function handleMessage(message, sender) { } case 'GET_STATE': { - return await getState(); + return await getPublicStateForSender(sender); } case 'RESET': { clearStopRequest(); - await cleanupCloudflareRoutingRule(null, { reason: '重置流程前清理 Cloudflare 路由' }); + await cleanupCloudflareRoutingRule(null, { + reason: '重置流程前清理 Cloudflare 路由', + strict: true, + }); await resetState(); await addLog('流程已重置', 'info'); return { ok: true }; @@ -1183,6 +1230,9 @@ async function handleMessage(message, sender) { if (message.payload.vpsPassword !== undefined) updates.vpsPassword = message.payload.vpsPassword; if (message.payload.customPassword !== undefined) updates.customPassword = message.payload.customPassword; if (message.payload.autoRunSkipFailures !== undefined) updates.autoRunSkipFailures = Boolean(message.payload.autoRunSkipFailures); + if (message.payload.verificationWaitTimeoutSec !== undefined) { + updates.verificationWaitTimeoutSec = normalizeVerificationWaitTimeoutSec(message.payload.verificationWaitTimeoutSec); + } if (message.payload.mailProvider !== undefined) updates.mailProvider = message.payload.mailProvider; if (message.payload.emailGenerator !== undefined) updates.emailGenerator = normalizeEmailGenerator(message.payload.emailGenerator); if (message.payload.inbucketHost !== undefined) updates.inbucketHost = message.payload.inbucketHost; @@ -1465,6 +1515,15 @@ function getEmailGeneratorLabel(generator) { return generator === 'cloudflare' ? 'Cloudflare 邮箱' : 'Duck 邮箱'; } +function normalizeVerificationWaitTimeoutSec(value) { + const num = Number(value); + if (!Number.isFinite(num)) return 40; + const rounded = Math.round(num); + if (rounded < 10) return 10; + if (rounded > 300) return 300; + return rounded; +} + function normalizeCloudflareDomain(rawValue = '') { let value = String(rawValue || '').trim().toLowerCase(); if (!value) return ''; @@ -1507,6 +1566,50 @@ function getCloudflareConfig(state) { return { zoneId, domain, forwardTo, apiToken }; } +function normalizeCloudflareCleanupContext(raw) { + if (!raw || typeof raw !== 'object') return null; + + const ruleId = String(raw.ruleId || '').trim(); + const zoneId = String(raw.zoneId || '').trim(); + const apiToken = String(raw.apiToken || '').trim(); + if (!ruleId || !zoneId || !apiToken) return null; + + return { + ruleId, + zoneId, + apiToken, + savedAt: Number(raw.savedAt || 0) || Date.now(), + }; +} + +async function getCloudflareRuleCleanupContext() { + const data = await chrome.storage.local.get([CLOUDFLARE_RULE_CLEANUP_CONTEXT_KEY]); + return normalizeCloudflareCleanupContext(data[CLOUDFLARE_RULE_CLEANUP_CONTEXT_KEY]); +} + +async function setCloudflareRuleCleanupContext(context) { + const normalized = normalizeCloudflareCleanupContext(context); + if (!normalized) { + throw new Error('Cloudflare 路由清理上下文无效,已中止保存。'); + } + await chrome.storage.local.set({ + [CLOUDFLARE_RULE_CLEANUP_CONTEXT_KEY]: normalized, + }); +} + +async function clearCloudflareRuleCleanupContext(expectedRuleId = '') { + const ruleId = String(expectedRuleId || '').trim(); + if (!ruleId) { + await chrome.storage.local.remove(CLOUDFLARE_RULE_CLEANUP_CONTEXT_KEY); + return; + } + + const current = await getCloudflareRuleCleanupContext(); + if (!current || current.ruleId === ruleId) { + await chrome.storage.local.remove(CLOUDFLARE_RULE_CLEANUP_CONTEXT_KEY); + } +} + async function callCloudflareApi({ path, method = 'GET', token, body = null, timeoutMs = 25000 }) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); @@ -1556,6 +1659,7 @@ async function fetchCloudflareEmail(state, options = {}) { if (state?.cloudflareLastRuleId) { await cleanupCloudflareRoutingRule(state, { reason: '创建新 Cloudflare 前缀前清理旧路由', + strict: true, }); } @@ -1585,12 +1689,38 @@ async function fetchCloudflareEmail(state, options = {}) { }); const ruleId = String(result?.result?.id || '').trim(); + if (!ruleId) { + throw new Error('Cloudflare API 未返回路由 ID,为避免失去清理追踪已中止。'); + } + + try { + await setCloudflareRuleCleanupContext({ + ruleId, + zoneId, + apiToken, + savedAt: Date.now(), + }); + } catch (err) { + let rollbackNote = ''; + try { + await callCloudflareApi({ + path: `/zones/${encodeURIComponent(zoneId)}/email/routing/rules/${encodeURIComponent(ruleId)}`, + method: 'DELETE', + token: apiToken, + }); + rollbackNote = '已自动回滚并删除刚创建的路由。'; + } catch (rollbackErr) { + rollbackNote = `回滚删除失败:${rollbackErr.message}`; + } + throw new Error(`Cloudflare 路由创建后无法保存清理凭据(${err.message})。${rollbackNote}`); + } + const propagationWaitMs = Math.floor( Math.random() * (CLOUDFLARE_RULE_PROPAGATION_MAX_MS - CLOUDFLARE_RULE_PROPAGATION_MIN_MS + 1) ) + CLOUDFLARE_RULE_PROPAGATION_MIN_MS; const aliasReadyAt = Date.now() + propagationWaitMs; await setState({ - cloudflareLastRuleId: ruleId || null, + cloudflareLastRuleId: ruleId, cloudflareLastAliasEmail: aliasEmail, cloudflareAliasReadyAt: aliasReadyAt, }); @@ -1606,21 +1736,20 @@ async function fetchCloudflareEmail(state, options = {}) { async function cleanupCloudflareRoutingRule(stateOrNull = null, options = {}) { const state = stateOrNull || await getState(); const reason = options.reason || '清理 Cloudflare 路由'; + const strict = Boolean(options.strict); const ruleId = String(state.cloudflareLastRuleId || '').trim(); if (!ruleId) { return false; } - let zoneId = ''; - let apiToken = ''; - try { - const cfg = getCloudflareConfig(state); - zoneId = cfg.zoneId; - apiToken = cfg.apiToken; - } catch (err) { - await addLog(`${reason}:存在待清理路由 ${ruleId},但当前 Cloudflare 配置不可用(${err.message})。`, 'warn'); + const cleanupContext = await getCloudflareRuleCleanupContext(); + if (!cleanupContext || cleanupContext.ruleId !== ruleId) { + const msg = `${reason}:存在待清理路由 ${ruleId},但未找到创建时清理凭据,已停止自动清理。`; + await addLog(msg, 'warn'); + if (strict) throw new Error(msg); return false; } + const { zoneId, apiToken } = cleanupContext; try { await callCloudflareApi({ @@ -1630,7 +1759,9 @@ async function cleanupCloudflareRoutingRule(stateOrNull = null, options = {}) { }); await addLog(`${reason}:已删除 Cloudflare 路由 ${ruleId}。`, 'info'); } catch (err) { - await addLog(`${reason}:删除 Cloudflare 路由 ${ruleId} 失败:${err.message}`, 'warn'); + const msg = `${reason}:删除 Cloudflare 路由 ${ruleId} 失败:${err.message}`; + await addLog(msg, 'warn'); + if (strict) throw new Error(msg); return false; } @@ -1642,6 +1773,7 @@ async function cleanupCloudflareRoutingRule(stateOrNull = null, options = {}) { cloudflareAliasReadyAt: null, }); } + await clearCloudflareRuleCleanupContext(ruleId); return true; } @@ -1886,6 +2018,7 @@ async function autoRunLoop(totalRuns, options = {}) { reason: forceFreshTabsNextRun ? '自动运行放弃旧线程后清理 Cloudflare 路由' : '自动运行新开一轮前清理 Cloudflare 路由', + strict: true, }); const keepSettings = { vpsUrl: prevState.vpsUrl, @@ -2239,14 +2372,26 @@ function getVerificationCodeLabel(step) { } function getVerificationPollPayload(step, state, overrides = {}) { + const rawIntervalMs = Number(overrides.intervalMs ?? 3000); + const intervalMs = Number.isFinite(rawIntervalMs) ? Math.max(1000, Math.round(rawIntervalMs)) : 3000; + const waitTimeoutSec = normalizeVerificationWaitTimeoutSec( + overrides.verificationWaitTimeoutSec ?? state.verificationWaitTimeoutSec + ); + const autoMaxAttempts = Math.max(1, Math.ceil((waitTimeoutSec * 1000) / intervalMs)); + const rawMaxAttempts = Number(overrides.maxAttempts); + const maxAttempts = Number.isFinite(rawMaxAttempts) + ? Math.max(1, Math.round(rawMaxAttempts)) + : autoMaxAttempts; + if (step === 4) { return { filterAfterTimestamp: state.flowStartTime || 0, senderFilters: ['openai', 'noreply', 'verify', 'auth', 'duckduckgo', 'forward'], - subjectFilters: ['verify', 'verification', 'code', '楠岃瘉', 'confirm'], + subjectFilters: ['verify', 'verification', 'code', '验证', 'confirm', 'otp', 'passcode', 'one-time'], targetEmail: state.email, - maxAttempts: 5, - intervalMs: 3000, + maxAttempts, + intervalMs, + verificationWaitTimeoutSec: waitTimeoutSec, ...overrides, }; } @@ -2254,10 +2399,11 @@ function getVerificationPollPayload(step, state, overrides = {}) { return { filterAfterTimestamp: state.lastEmailTimestamp || state.flowStartTime || 0, senderFilters: ['openai', 'noreply', 'verify', 'auth', 'chatgpt', 'duckduckgo', 'forward'], - subjectFilters: ['verify', 'verification', 'code', '楠岃瘉', 'confirm', 'login'], + subjectFilters: ['verify', 'verification', 'code', '验证', 'confirm', 'login', 'otp', 'passcode', 'one-time'], targetEmail: state.email, - maxAttempts: 5, - intervalMs: 3000, + maxAttempts, + intervalMs, + verificationWaitTimeoutSec: waitTimeoutSec, ...overrides, }; } @@ -2381,6 +2527,9 @@ async function resolveVerificationStep(step, state, mail, options = {}) { const nextFilterAfterTimestamp = options.filterAfterTimestamp ?? null; const requestFreshCodeFirst = Boolean(options.requestFreshCodeFirst); const maxSubmitAttempts = 3; + const waitTimeoutSec = normalizeVerificationWaitTimeoutSec(state.verificationWaitTimeoutSec); + + await addLog(`步骤 ${step}:邮箱单轮等待上限 ${waitTimeoutSec} 秒,超时将自动重发验证码并进入下一轮。`, 'info'); if (requestFreshCodeFirst) { try { From 1b020786a1471421e01a9f583949fe860abb0003 Mon Sep 17 00:00:00 2001 From: DanielSong Date: Sun, 12 Apr 2026 18:27:22 +0800 Subject: [PATCH 3/5] fix(auth-mail): improve verification and mailbox page resilience --- content/inbucket-mail.js | 4 ++- content/mail-163.js | 74 +++++++++++++++++++++++++++++++++++----- content/qq-mail.js | 18 +++++++--- content/signup-page.js | 29 +++++++++++----- content/vps-panel.js | 46 +++++++++++++++++-------- sidepanel/sidepanel.html | 4 +++ sidepanel/sidepanel.js | 26 ++++++++++++++ 7 files changed, 166 insertions(+), 35 deletions(-) diff --git a/content/inbucket-mail.js b/content/inbucket-mail.js index 1de59491..9b5badab 100644 --- a/content/inbucket-mail.js +++ b/content/inbucket-mail.js @@ -136,7 +136,9 @@ function getCurrentMailboxIds() { } async function refreshMailbox() { - const refreshButton = document.querySelector('button[alt="Refresh Mailbox"]'); + const refreshButton = document.querySelector( + 'button[alt="Refresh Mailbox"], button[title*="Refresh"], button[aria-label*="Refresh"], button[alt*="刷新"], button[title*="刷新"], button[aria-label*="刷新"]' + ); if (!refreshButton) return; simulateClick(refreshButton); diff --git a/content/mail-163.js b/content/mail-163.js index 5455a5a3..6079c943 100644 --- a/content/mail-163.js +++ b/content/mail-163.js @@ -17,6 +17,16 @@ if (!isTopFrame) { console.log(MAIL163_PREFIX, 'Skipping child frame'); } else { +function isVisibleElement(el) { + if (!el) return false; + const style = window.getComputedStyle(el); + const rect = el.getBoundingClientRect(); + return style.display !== 'none' + && style.visibility !== 'hidden' + && rect.width > 0 + && rect.height > 0; +} + // Track codes we've already seen — persisted in chrome.storage.session to survive script re-injection let seenCodes = new Set(); @@ -73,6 +83,25 @@ function findMailItems() { return document.querySelectorAll('div[sign="letter"]'); } +function findInboxEntry() { + const candidates = document.querySelectorAll( + '.nui-tree-item-text, [title*="收件箱"], [title*="Inbox"], [aria-label*="收件箱"], [aria-label*="Inbox"]' + ); + return Array.from(candidates).find((el) => { + if (!isVisibleElement(el)) return false; + const text = [ + el.textContent, + el.getAttribute('title'), + el.getAttribute('aria-label'), + ] + .filter(Boolean) + .join(' ') + .replace(/\s+/g, ' ') + .trim(); + return /收件箱|inbox/i.test(text); + }) || null; +} + function getCurrentMailIds() { const ids = new Set(); findMailItems().forEach(item => { @@ -161,11 +190,19 @@ async function handlePollEmail(step, payload) { // Click inbox in sidebar to ensure we're in inbox view log(`步骤 ${step}:正在等待侧边栏加载...`); - try { - const inboxLink = await waitForElement('.nui-tree-item-text[title="收件箱"]', 5000); - inboxLink.click(); + let inboxLink = findInboxEntry(); + if (!inboxLink) { + try { + await waitForElement('.nui-tree-item-text, [title*="Inbox"], [title*="收件箱"]', 5000); + inboxLink = findInboxEntry(); + } catch { + inboxLink = null; + } + } + if (inboxLink) { + simulateClick(inboxLink); log(`步骤 ${step}:已点击收件箱`); - } catch { + } else { log(`步骤 ${step}:未找到收件箱入口,继续尝试后续流程...`, 'warn'); } @@ -284,7 +321,9 @@ async function deleteEmail(item, step) { item.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true })); await sleep(300); - const trashIcon = item.querySelector('[sign="trash"], .nui-ico-delete, [title="删除邮件"]'); + const trashIcon = item.querySelector( + '[sign="trash"], .nui-ico-delete, [title*="删除"], [title*="Delete"], [aria-label*="删除"], [aria-label*="Delete"]' + ); if (trashIcon) { trashIcon.click(); log(`步骤 ${step}:已点击删除图标`, 'ok'); @@ -310,7 +349,7 @@ async function deleteEmail(item, step) { // Click toolbar delete button const toolbarBtns = document.querySelectorAll('.nui-btn .nui-btn-text'); for (const btn of toolbarBtns) { - if (btn.textContent.replace(/\s/g, '').includes('删除')) { + if (/(删除|delete)/i.test(btn.textContent.replace(/\s/g, ''))) { btn.closest('.nui-btn').click(); log(`步骤 ${step}:已点击工具栏删除`, 'ok'); await sleep(1500); @@ -330,10 +369,21 @@ async function deleteEmail(item, step) { // ============================================================ async function refreshInbox() { + // Strategy 1: explicit refresh title/aria/class hooks + const quickRefresh = document.querySelector( + '[title*="刷新"], [title*="Refresh"], [aria-label*="刷新"], [aria-label*="Refresh"], [class*="refresh"]' + ); + if (quickRefresh && isVisibleElement(quickRefresh)) { + simulateClick(quickRefresh); + console.log(MAIL163_PREFIX, 'Clicked quick refresh control'); + await sleep(800); + return; + } + // Try toolbar "刷 新" button const toolbarBtns = document.querySelectorAll('.nui-btn .nui-btn-text'); for (const btn of toolbarBtns) { - if (btn.textContent.replace(/\s/g, '') === '刷新') { + if (/^(刷新|refresh)$/i.test(btn.textContent.replace(/\s/g, ''))) { btn.closest('.nui-btn').click(); console.log(MAIL163_PREFIX, 'Clicked "刷新" button'); await sleep(800); @@ -344,7 +394,7 @@ async function refreshInbox() { // Fallback: click sidebar "收 信" const shouXinBtns = document.querySelectorAll('.ra0'); for (const btn of shouXinBtns) { - if (btn.textContent.replace(/\s/g, '').includes('收信')) { + if (/(收信|inbox)/i.test(btn.textContent.replace(/\s/g, ''))) { btn.click(); console.log(MAIL163_PREFIX, 'Clicked "收信" button'); await sleep(800); @@ -352,6 +402,14 @@ async function refreshInbox() { } } + const inboxEntry = findInboxEntry(); + if (inboxEntry) { + simulateClick(inboxEntry); + console.log(MAIL163_PREFIX, 'Clicked inbox entry as refresh fallback'); + await sleep(800); + return; + } + console.log(MAIL163_PREFIX, 'Could not find refresh button'); } diff --git a/content/qq-mail.js b/content/qq-mail.js index fe21bb58..4f61b063 100644 --- a/content/qq-mail.js +++ b/content/qq-mail.js @@ -62,10 +62,16 @@ async function handlePollEmail(step, payload) { // Wait for mail list to load try { - await waitForElement('.mail-list-page-item', 10000); + await waitForElement('.mail-list-page-item, .mail-list-page, .mail-list', 10000); log(`步骤 ${step}:邮件列表已加载`); } catch { - throw new Error('邮件列表未加载完成,请确认 QQ 邮箱已打开收件箱。'); + await refreshInbox(); + await sleep(1200); + const fallbackList = document.querySelector('.mail-list-page-item, .mail-list-page, .mail-list'); + if (!fallbackList) { + throw new Error('邮件列表未加载完成,请确认 QQ 邮箱已打开收件箱。'); + } + log(`步骤 ${step}:邮件列表加载较慢,已触发刷新后继续。`, 'warn'); } // Step 1: Snapshot existing mail IDs BEFORE we start waiting for new email @@ -139,7 +145,9 @@ async function refreshInbox() { // Try multiple strategies to refresh the mail list // Strategy 1: Click any visible refresh button - const refreshBtn = document.querySelector('[class*="refresh"], [title*="刷新"]'); + const refreshBtn = document.querySelector( + '[class*="refresh"], [title*="刷新"], [title*="Refresh"], [aria-label*="刷新"], [aria-label*="Refresh"]' + ); if (refreshBtn) { simulateClick(refreshBtn); console.log(QQ_MAIL_PREFIX, 'Clicked refresh button'); @@ -148,7 +156,9 @@ async function refreshInbox() { } // Strategy 2: Click inbox in sidebar to reload list - const sidebarInbox = document.querySelector('a[href*="inbox"], [class*="folder-item"][class*="inbox"], [title="收件箱"]'); + const sidebarInbox = document.querySelector( + 'a[href*="inbox"], [class*="folder-item"][class*="inbox"], [title*="收件箱"], [title*="Inbox"], [aria-label*="收件箱"], [aria-label*="Inbox"]' + ); if (sidebarInbox) { simulateClick(sidebarInbox); console.log(QQ_MAIL_PREFIX, 'Clicked sidebar inbox'); diff --git a/content/signup-page.js b/content/signup-page.js index 7fe1f0de..189ad515 100644 --- a/content/signup-page.js +++ b/content/signup-page.js @@ -72,9 +72,9 @@ const VERIFICATION_CODE_INPUT_SELECTOR = [ 'input[inputmode="numeric"]', ].join(', '); -const ONE_TIME_CODE_LOGIN_PATTERN = /使用一次性验证码登录|改用(?:一次性)?验证码(?:登录)?|使用验证码登录|一次性验证码|验证码登录|one[-\s]*time\s*(?:passcode|password|code)|use\s+(?:a\s+)?one[-\s]*time\s*(?:passcode|password|code)(?:\s+instead)?|use\s+(?:a\s+)?code(?:\s+instead)?|sign\s+in\s+with\s+(?:email|code)|email\s+(?:me\s+)?(?:a\s+)?code/i; +const ONE_TIME_CODE_LOGIN_PATTERN = /使用一次性验证码登录|改用(?:一次性)?验证码(?:登录)?|使用验证码登录|一次性验证码|验证码登录|one[-\s]*time\s*(?:passcode|password|code)|use\s+(?:a\s+)?one[-\s]*time\s*(?:passcode|password|code)(?:\s+instead)?|use\s+(?:a\s+)?code(?:\s+instead)?|sign\s+in\s+with\s+(?:email|code)|email\s+(?:me\s+)?(?:a\s+)?code|try\s+another\s+way|other\s+ways|another\s+method|choose\s+another\s+method|use\s+email\s+code|send\s+code\s+to\s+email/i; -const RESEND_VERIFICATION_CODE_PATTERN = /重新发送(?:验证码)?|再次发送(?:验证码)?|重发(?:验证码)?|未收到(?:验证码|邮件)|resend(?:\s+code)?|send\s+(?:a\s+)?new\s+code|send\s+(?:it\s+)?again|request\s+(?:a\s+)?new\s+code|didn'?t\s+receive/i; +const RESEND_VERIFICATION_CODE_PATTERN = /重新发送(?:验证码)?|再次发送(?:验证码)?|重发(?:验证码)?|未收到(?:验证码|邮件)|resend(?:\s+(?:code|verification\s+code))?|send\s+(?:a\s+)?new\s+code|send\s+(?:an?\s+)?(?:email\s+)?verification\s+code|send\s+(?:it\s+)?again|request\s+(?:a\s+)?new\s+code|get\s+(?:a\s+)?new\s+code|didn'?t\s+receive/i; function isVisibleElement(el) { if (!el) return false; @@ -373,7 +373,8 @@ async function step3_fillEmailPassword(payload) { const INVALID_VERIFICATION_CODE_PATTERN = /代码不正确|验证码不正确|验证码错误|code\s+(?:is\s+)?incorrect|invalid\s+code|incorrect\s+code|try\s+again/i; const VERIFICATION_PAGE_PATTERN = /检查您的收件箱|输入我们刚刚向|重新发送电子邮件|重新发送验证码|验证码|代码不正确|email\s+verification/i; -const OAUTH_CONSENT_PAGE_PATTERN = /使用\s*ChatGPT\s*登录到\s*Codex|login\s+to\s+codex|log\s+in\s+to\s+codex|authorize|授权/i; +const OAUTH_CONSENT_PAGE_PATTERN = /使用\s*ChatGPT\s*登录到\s*Codex|login\s+to\s+codex|log\s+in\s+to\s+codex|continue\s+to\s+codex|codex\s+(?:would\s+like\s+to|wants\s+to)\s+access|authorize|authorization|consent|grant\s+access|allow\s+access|授权|同意/i; +const OAUTH_CONSENT_CONTINUE_BUTTON_PATTERN = /继续|continue|authorize|allow|accept|同意|授权|grant/i; const ADD_PHONE_PAGE_PATTERN = /add[\s-]*phone|添加手机号|手机号码|手机号|phone\s+number|telephone/i; const STEP5_SUBMIT_ERROR_PATTERN = /无法根据该信息创建帐户|请重试|unable\s+to\s+create\s+(?:your\s+)?account|couldn'?t\s+create\s+(?:your\s+)?account|something\s+went\s+wrong|invalid\s+(?:birthday|birth|date)|生日|出生日期/i; const SIGNUP_PASSWORD_ERROR_TITLE_PATTERN = /糟糕,出错了|something\s+went\s+wrong|oops/i; @@ -430,12 +431,23 @@ function getPrimaryContinueButton() { const continueBtn = document.querySelector( 'button[type="submit"][data-dd-action-name="Continue"], button[type="submit"]._primary_3rdp0_107' ); - if (continueBtn && isVisibleElement(continueBtn)) { + if (continueBtn && isVisibleElement(continueBtn) && OAUTH_CONSENT_CONTINUE_BUTTON_PATTERN.test(getActionText(continueBtn))) { return continueBtn; } - const buttons = document.querySelectorAll('button, [role="button"]'); - return Array.from(buttons).find((el) => isVisibleElement(el) && /继续|Continue/i.test(el.textContent || '')) || null; + const candidates = document.querySelectorAll( + 'button[type="submit"], form button, button, [role="button"], input[type="submit"], input[type="button"]' + ); + return Array.from(candidates).find((el) => { + if (!isVisibleElement(el)) return false; + return OAUTH_CONSENT_CONTINUE_BUTTON_PATTERN.test(getActionText(el)); + }) || null; +} + +function isLikelyOAuthConsentPageByUrl() { + const path = `${location.pathname || ''} ${location.hash || ''}`.toLowerCase(); + const search = (location.search || '').toLowerCase(); + return /authorize|consent|oauth/.test(path) || /client_id=|redirect_uri=|response_type=/.test(search); } function isVerificationPageStillVisible() { @@ -465,8 +477,9 @@ function isStep8Ready() { if (!continueBtn) return false; if (isVerificationPageStillVisible()) return false; if (isAddPhonePageReady()) return false; - - return OAUTH_CONSENT_PAGE_PATTERN.test(getPageTextSnapshot()); + const pageText = getPageTextSnapshot(); + if (OAUTH_CONSENT_PAGE_PATTERN.test(pageText)) return true; + return isLikelyOAuthConsentPageByUrl() && /codex/i.test(pageText); } function normalizeInlineText(text) { diff --git a/content/vps-panel.js b/content/vps-panel.js index 85c8fcbf..c8881697 100644 --- a/content/vps-panel.js +++ b/content/vps-panel.js @@ -127,19 +127,29 @@ function getStatusBadgeText() { return statusEl ? (statusEl.textContent || '').replace(/\s+/g, ' ').trim() : ''; } +const OAUTH_SUCCESS_BADGE_PATTERN = /认证成功|authentication\s+successful|oauth\s+(?:authentication|auth)\s+successful|\bverified\b|\bsuccess(?:ful)?\b/i; +const OAUTH_TIMEOUT_BADGE_PATTERN = /认证失败[::]?\s*(?:timeout|超时)|timeout\s+waiting\s+for\s+oauth\s+callback|oauth\s+callback.*timeout|等待\s*oauth\s*回调.*超时/i; + function isOAuthCallbackTimeoutFailure(statusText) { - return /认证失败:\s*Timeout waiting for OAuth callback/i.test(statusText || ''); + return OAUTH_TIMEOUT_BADGE_PATTERN.test(statusText || ''); +} + +function isOAuthSuccessStatus(statusText) { + return OAUTH_SUCCESS_BADGE_PATTERN.test(statusText || ''); } -async function waitForExactSuccessBadge(timeout = 30000) { +async function waitForOAuthSuccessBadge(timeout = 30000) { const start = Date.now(); while (Date.now() - start < timeout) { throwIfStopped(); const statusText = getStatusBadgeText(); - if (statusText === '认证成功!') { + if (isOAuthSuccessStatus(statusText)) { return statusText; } + if (isOAuthCallbackTimeoutFailure(statusText)) { + throw new Error(`STEP9_OAUTH_TIMEOUT::${statusText}`); + } await sleep(200); } @@ -152,6 +162,16 @@ async function waitForExactSuccessBadge(timeout = 30000) { : 'CPA 面板长时间未出现“认证成功!”状态徽标。'); } +function findCallbackSubmitButton(urlInput) { + const section = urlInput?.closest('[class*="callbackSection"], .OAuthPage-module__callbackSection___8kA31, form, .card') || document; + const candidates = section.querySelectorAll('button, [role="button"], input[type="submit"], input[type="button"]'); + return Array.from(candidates).find((el) => { + if (!isVisibleElement(el)) return false; + const text = getActionText(el); + return /提交|submit|callback|confirm|verify|send/i.test(text); + }) || null; +} + function findManagementKeyInput() { const candidates = document.querySelectorAll( '.LoginPage-module__loginCard___OgP-R input[type="password"], input[placeholder*="管理密钥"], input[aria-label*="管理密钥"]' @@ -363,18 +383,16 @@ async function step9_vpsVerify(payload) { log(`步骤 9:已填写回调地址:${localhostUrl.slice(0, 80)}...`); // Find and click "提交回调 URL" button - let submitBtn = null; - try { - submitBtn = await waitForElementByText( - '[class*="callbackActions"] button, [class*="callbackSection"] button', - /提交/, - 5000 - ); - } catch { + let submitBtn = findCallbackSubmitButton(urlInput); + if (!submitBtn) { try { - submitBtn = await waitForElementByText('button.btn', /提交回调/, 5000); + submitBtn = await waitForElementByText( + '[class*="callbackActions"] button, [class*="callbackSection"] button, button.btn, button', + /提交|submit|callback|confirm|verify|send/i, + 5000 + ); } catch { - throw new Error('未找到“提交回调 URL”按钮。URL: ' + location.href); + throw new Error('未找到“提交回调 URL / Submit Callback URL”按钮。URL: ' + location.href); } } @@ -382,7 +400,7 @@ async function step9_vpsVerify(payload) { simulateClick(submitBtn); log('步骤 9:已点击“提交回调 URL”,正在等待认证结果...'); - const verifiedStatus = await waitForExactSuccessBadge(); + const verifiedStatus = await waitForOAuthSuccessBadge(); log(`步骤 9:${verifiedStatus}`, 'ok'); reportComplete(9, { localhostUrl, verifiedStatus }); } diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html index 9ba33e85..05a88aee 100644 --- a/sidepanel/sidepanel.html +++ b/sidepanel/sidepanel.html @@ -71,6 +71,10 @@

多页面

+
+ 收码超时(s) + +