QLHazyCoder 4 месяцев назад
Родитель
Сommit
40f9b35409
75 измененных файлов с 10479 добавлено и 2466 удалено
  1. 3 0
      .gitignore
  2. 3 2
      README.md
  3. 172 658
      background.js
  4. 166 0
      background/account-run-history.js
  5. 629 0
      background/auto-run-controller.js
  6. 220 0
      background/generated-email-helpers.js
  7. 164 0
      background/logging-status.js
  8. 566 0
      background/message-router.js
  9. 179 0
      background/navigation-utils.js
  10. 145 0
      background/panel-bridge.js
  11. 191 0
      background/signup-flow-helpers.js
  12. 174 0
      background/steps/confirm-oauth.js
  13. 174 0
      background/steps/fetch-login-code.js
  14. 102 0
      background/steps/fetch-signup-code.js
  15. 60 0
      background/steps/fill-password.js
  16. 30 0
      background/steps/fill-profile.js
  17. 110 0
      background/steps/oauth-login.js
  18. 21 0
      background/steps/open-chatgpt.js
  19. 167 0
      background/steps/platform-verify.js
  20. 48 0
      background/steps/registry.js
  21. 72 0
      background/steps/submit-signup-email.js
  22. 654 0
      background/tab-runtime.js
  23. 505 0
      background/verification-flow.js
  24. 7 3
      content/activation-utils.js
  25. 186 91
      content/signup-page.js
  26. 212 33
      content/vps-panel.js
  27. 31 0
      data/step-definitions.js
  28. 0 431
      docs/Hotmail双模式适配开发方案及开发记录.md
  29. 189 0
      docs/refactor/2026-04-16-architecture-refactor-plan.md
  30. 32 1
      docs/仓库协作者AI分析PR与合并标准流程.md
  31. 1 1
      manifest.json
  32. 41 0
      scripts/hotmail_helper.py
  33. 529 0
      sidepanel/hotmail-manager.js
  34. 502 0
      sidepanel/icloud-manager.js
  35. 467 0
      sidepanel/luckmail-manager.js
  36. 182 0
      sidepanel/sidepanel.css
  37. 31 47
      sidepanel/sidepanel.html
  38. 243 950
      sidepanel/sidepanel.js
  39. 10 0
      tests/activation-utils.test.js
  40. 78 48
      tests/auto-run-fresh-attempt-reset.test.js
  41. 197 0
      tests/auto-run-step6-restart.test.js
  42. 103 0
      tests/background-account-history-settings.test.js
  43. 76 0
      tests/background-account-run-history-module.test.js
  44. 17 0
      tests/background-auto-run-module.test.js
  45. 17 0
      tests/background-generated-email-module.test.js
  46. 17 0
      tests/background-logging-status-module.test.js
  47. 17 0
      tests/background-message-router-module.test.js
  48. 127 0
      tests/background-message-router-step2-skip.test.js
  49. 17 0
      tests/background-navigation-utils-module.test.js
  50. 17 0
      tests/background-panel-bridge-module.test.js
  51. 17 0
      tests/background-signup-flow-module.test.js
  52. 134 0
      tests/background-signup-step2-branching.test.js
  53. 21 0
      tests/background-step-modules.test.js
  54. 11 0
      tests/background-step-registry.test.js
  55. 47 0
      tests/background-step5-submit-short-circuit.test.js
  56. 103 0
      tests/background-step6-retry-limit.test.js
  57. 127 0
      tests/background-step7-recovery.test.js
  58. 59 0
      tests/background-tab-runtime-module.test.js
  59. 17 0
      tests/background-verification-flow-module.test.js
  60. 118 0
      tests/sidepanel-account-run-history.test.js
  61. 79 0
      tests/sidepanel-hotmail-manager.test.js
  62. 61 0
      tests/sidepanel-icloud-manager.test.js
  63. 66 0
      tests/sidepanel-luckmail-manager.test.js
  64. 52 48
      tests/signup-page-tab-cleanup.test.js
  65. 42 0
      tests/step-definitions-module.test.js
  66. 217 0
      tests/step5-direct-complete.test.js
  67. 149 0
      tests/step6-login-state.test.js
  68. 99 22
      tests/step8-stop-cleanup.test.js
  69. 62 80
      tests/step9-localhost-cleanup-scope.test.js
  70. 126 0
      tests/step9-status-diagnostics.test.js
  71. 110 0
      tests/verification-flow-polling.test.js
  72. 79 51
      tests/verification-stop-propagation.test.js
  73. 421 0
      项目完整链路说明.md
  74. 192 0
      项目开发规范(AI协作).md
  75. 169 0
      项目文件结构说明.md

+ 3 - 0
.gitignore

@@ -2,3 +2,6 @@
 
 /.github
 /_metadata/
+.vscode
+
+/data/account-run-history.txt

+ 3 - 2
README.md

@@ -415,7 +415,7 @@ Cloudflare 模式下,插件不会再调用 Cloudflare API 创建路由。
 2. 根据 `Mail` 选择邮箱来源
 3. 如果 `Mail = Hotmail`,会从账号池自动分配一个可用账号
 4. 如果不是 Hotmail,则按当前“邮箱生成”配置尝试自动获取邮箱(Duck / Cloudflare / iCloud 等)
-5. Step 2 点击注册、填写邮箱并继续到密码页
+5. Step 2 点击注册、填写邮箱,并按真实落地页进入密码页或直接进入邮箱验证码页
 6. 如果自动获取失败,暂停并等待你在侧边栏填写邮箱后点击 `Continue`
 7. 继续执行 Step 3 ~ Step 9
 
@@ -447,7 +447,7 @@ Cloudflare 模式下,插件不会再调用 Cloudflare API 创建路由。
 - 自动点击进入注册流程
 - 自动填写邮箱
 - 点击 `继续`
-- 等待跳转到 `https://auth.openai.com/create-account/password`
+- 等待真实落地页;进入 `https://auth.openai.com/create-account/password` 时继续 Step 3,进入 `https://auth.openai.com/email-verification` 时自动跳过 Step 3 直接进入 Step 4
 
 ### Step 3: Fill Password
 
@@ -485,6 +485,7 @@ Cloudflare 模式下,插件不会再调用 Cloudflare API 创建路由。
 - 页面要求 `age`
 
 如果页面是生日模式,会填写年月日;如果页面上存在 `input[name='age']`,则直接填写年龄。
+点击 `完成帐户创建` 后,Step 5 会立刻记为完成,不再等待页面跳转结果;自动运行在进入 Step 6 前只会等待当前页面加载完成,不再接管 ChatGPT 跳转或 onboarding 跳过逻辑。
 
 ### Step 6: Login via OAuth
 

Разница между файлами не показана из-за своего большого размера
+ 172 - 658
background.js


+ 166 - 0
background/account-run-history.js

@@ -0,0 +1,166 @@
+(function attachBackgroundAccountRunHistory(root, factory) {
+  root.MultiPageBackgroundAccountRunHistory = factory();
+})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundAccountRunHistoryModule() {
+  function createAccountRunHistoryHelpers(deps = {}) {
+    const {
+      ACCOUNT_RUN_HISTORY_STORAGE_KEY = 'accountRunHistory',
+      addLog,
+      buildLocalHelperEndpoint,
+      chrome,
+      getErrorMessage,
+      getState,
+      normalizeAccountRunHistoryHelperBaseUrl,
+    } = deps;
+
+    function normalizeAccountRunHistory(records) {
+      if (!Array.isArray(records)) {
+        return [];
+      }
+
+      return records
+        .filter((item) => item && typeof item === 'object')
+        .map((item) => ({
+          email: String(item.email || '').trim(),
+          password: String(item.password || '').trim(),
+          status: String(item.status || '').trim().toLowerCase(),
+          recordedAt: String(item.recordedAt || '').trim(),
+          reason: String(item.reason || '').trim(),
+        }))
+        .filter((item) => item.email && item.password && item.status);
+    }
+
+    async function getPersistedAccountRunHistory() {
+      try {
+        const stored = await chrome.storage.local.get(ACCOUNT_RUN_HISTORY_STORAGE_KEY);
+        return normalizeAccountRunHistory(stored[ACCOUNT_RUN_HISTORY_STORAGE_KEY]);
+      } catch (err) {
+        console.warn('[MultiPage:account-run-history] Failed to read account run history:', err?.message || err);
+        return [];
+      }
+    }
+
+    function buildAccountRunHistoryRecord(state = {}, status = '', reason = '') {
+      const email = String(state.email || '').trim();
+      const password = String(state.password || state.customPassword || '').trim();
+      const normalizedStatus = String(status || '').trim().toLowerCase();
+      const normalizedReason = String(reason || '').trim();
+
+      if (!email || !password || !normalizedStatus) {
+        return null;
+      }
+
+      return {
+        email,
+        password,
+        status: normalizedStatus,
+        recordedAt: new Date().toISOString(),
+        reason: normalizedReason,
+      };
+    }
+
+    async function appendAccountRunHistoryRecord(status, stateOverride = null, reason = '') {
+      const state = stateOverride || await getState();
+      const record = buildAccountRunHistoryRecord(state, status, reason);
+      if (!record) {
+        return null;
+      }
+
+      const history = await getPersistedAccountRunHistory();
+      history.push(record);
+      await chrome.storage.local.set({
+        [ACCOUNT_RUN_HISTORY_STORAGE_KEY]: history,
+      });
+      return record;
+    }
+
+    function shouldAppendAccountRunTextFile(state = {}) {
+      if (!Boolean(state.accountRunHistoryTextEnabled)) {
+        return false;
+      }
+
+      const helperBaseUrl = normalizeAccountRunHistoryHelperBaseUrl(state.accountRunHistoryHelperBaseUrl);
+      return Boolean(helperBaseUrl);
+    }
+
+    async function appendAccountRunHistoryTextFile(record, stateOverride = null) {
+      const normalizedRecord = record && typeof record === 'object'
+        ? record
+        : buildAccountRunHistoryRecord(stateOverride || await getState(), '');
+      if (!normalizedRecord?.email || !normalizedRecord?.password || !normalizedRecord?.status) {
+        return null;
+      }
+
+      const state = stateOverride || await getState();
+      if (!shouldAppendAccountRunTextFile(state)) {
+        return null;
+      }
+
+      const helperBaseUrl = normalizeAccountRunHistoryHelperBaseUrl(state.accountRunHistoryHelperBaseUrl);
+      let response;
+      try {
+        response = await fetch(buildLocalHelperEndpoint(helperBaseUrl, '/append-account-log'), {
+          method: 'POST',
+          headers: {
+            'Content-Type': 'application/json',
+            Accept: 'application/json',
+          },
+          body: JSON.stringify({
+            email: normalizedRecord.email,
+            password: normalizedRecord.password,
+            status: normalizedRecord.status,
+            recordedAt: normalizedRecord.recordedAt,
+            reason: normalizedRecord.reason || '',
+          }),
+        });
+      } catch (err) {
+        throw new Error(`账号文本记录写入失败:无法连接本地 helper(${getErrorMessage(err)})`);
+      }
+
+      let payload = null;
+      try {
+        payload = await response.json();
+      } catch (err) {
+        throw new Error(`账号文本记录写入失败:本地 helper 返回了无法解析的响应(${getErrorMessage(err)})`);
+      }
+
+      if (!response.ok || payload?.ok === false) {
+        throw new Error(`账号文本记录写入失败:${payload?.error || `HTTP ${response.status}`}`);
+      }
+
+      return payload?.filePath || '';
+    }
+
+    async function appendAccountRunRecord(status, stateOverride = null, reason = '') {
+      const state = stateOverride || await getState();
+      const record = await appendAccountRunHistoryRecord(status, state, reason);
+      if (!record) {
+        return null;
+      }
+
+      try {
+        const filePath = await appendAccountRunHistoryTextFile(record, state);
+        if (filePath) {
+          await addLog(`账号记录已追加到本地文本:${filePath}`, 'info');
+        }
+      } catch (err) {
+        await addLog(getErrorMessage(err), 'warn');
+      }
+
+      return record;
+    }
+
+    return {
+      appendAccountRunRecord,
+      appendAccountRunHistoryRecord,
+      appendAccountRunHistoryTextFile,
+      buildAccountRunHistoryRecord,
+      getPersistedAccountRunHistory,
+      normalizeAccountRunHistory,
+      shouldAppendAccountRunTextFile,
+    };
+  }
+
+  return {
+    createAccountRunHistoryHelpers,
+  };
+});

+ 629 - 0
background/auto-run-controller.js

@@ -0,0 +1,629 @@
+(function attachBackgroundAutoRunController(root, factory) {
+  root.MultiPageBackgroundAutoRunController = factory();
+})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundAutoRunControllerModule() {
+  function createAutoRunController(deps = {}) {
+    const {
+      addLog,
+      appendAccountRunRecord,
+      AUTO_RUN_MAX_RETRIES_PER_ROUND,
+      AUTO_RUN_RETRY_DELAY_MS,
+      AUTO_RUN_TIMER_KIND_BEFORE_RETRY,
+      AUTO_RUN_TIMER_KIND_BETWEEN_ROUNDS,
+      broadcastAutoRunStatus,
+      broadcastStopToContentScripts,
+      cancelPendingCommands,
+      clearStopRequest,
+      getAutoRunStatusPayload,
+      getErrorMessage,
+      getFirstUnfinishedStep,
+      getPendingAutoRunTimerPlan,
+      getRunningSteps,
+      getState,
+      hasSavedProgress,
+      isRestartCurrentAttemptError,
+      isStopError,
+      launchAutoRunTimerPlan,
+      normalizeAutoRunFallbackThreadIntervalMinutes,
+      persistAutoRunTimerPlan,
+      resetState,
+      runAutoSequenceFromStep,
+      runtime,
+      setState,
+      sleepWithStop,
+      waitForRunningStepsToFinish,
+    } = deps;
+
+    function createAutoRunRoundSummary(round) {
+      return {
+        round,
+        status: 'pending',
+        attempts: 0,
+        failureReasons: [],
+        finalFailureReason: '',
+      };
+    }
+
+    function normalizeAutoRunRoundSummary(summary, round) {
+      const base = createAutoRunRoundSummary(round);
+      if (!summary || typeof summary !== 'object') {
+        return base;
+      }
+
+      const status = String(summary.status || '').trim().toLowerCase();
+      return {
+        round,
+        status: ['pending', 'success', 'failed'].includes(status) ? status : base.status,
+        attempts: Math.max(0, Math.floor(Number(summary.attempts) || 0)),
+        failureReasons: Array.isArray(summary.failureReasons)
+          ? summary.failureReasons.map((item) => String(item || '').trim()).filter(Boolean)
+          : [],
+        finalFailureReason: String(summary.finalFailureReason || '').trim(),
+      };
+    }
+
+    function buildAutoRunRoundSummaries(totalRuns, rawSummaries = []) {
+      return Array.from({ length: totalRuns }, (_, index) => normalizeAutoRunRoundSummary(rawSummaries[index], index + 1));
+    }
+
+    function serializeAutoRunRoundSummaries(totalRuns, roundSummaries = []) {
+      return buildAutoRunRoundSummaries(totalRuns, roundSummaries).map((summary) => ({
+        ...summary,
+        failureReasons: [...summary.failureReasons],
+      }));
+    }
+
+    function getAutoRunRoundRetryCount(summary) {
+      return Math.max(0, Number(summary?.attempts || 0) - 1);
+    }
+
+    function formatAutoRunFailureReasons(reasons = []) {
+      if (!Array.isArray(reasons) || !reasons.length) {
+        return '未知错误';
+      }
+
+      const counts = new Map();
+      for (const reason of reasons) {
+        const normalized = String(reason || '').trim() || '未知错误';
+        counts.set(normalized, (counts.get(normalized) || 0) + 1);
+      }
+
+      return Array.from(counts.entries())
+        .map(([reason, count]) => (count > 1 ? `${reason}(${count}次)` : reason))
+        .join(';');
+    }
+
+    async function logAutoRunFinalSummary(totalRuns, roundSummaries = []) {
+      const summaries = buildAutoRunRoundSummaries(totalRuns, roundSummaries);
+      const successRounds = summaries.filter((item) => item.status === 'success');
+      const failedRounds = summaries.filter((item) => item.status === 'failed');
+      const pendingRounds = summaries.filter((item) => item.status === 'pending');
+
+      await addLog('=== 自动运行汇总 ===', failedRounds.length ? 'warn' : 'ok');
+      await addLog(
+        `总轮数:${totalRuns};成功:${successRounds.length};失败:${failedRounds.length};未完成:${pendingRounds.length}`,
+        failedRounds.length ? 'warn' : 'ok'
+      );
+
+      if (successRounds.length) {
+        await addLog(
+          `成功轮次:${successRounds
+            .map((item) => `第 ${item.round} 轮(重试 ${getAutoRunRoundRetryCount(item)} 次)`)
+            .join(';')}`,
+          'ok'
+        );
+      }
+
+      if (failedRounds.length) {
+        await addLog(
+          `失败轮次:${failedRounds
+            .map((item) => {
+              const retryCount = getAutoRunRoundRetryCount(item);
+              const finalReason = item.finalFailureReason || item.failureReasons[item.failureReasons.length - 1] || '未知错误';
+              const reasonSummary = formatAutoRunFailureReasons(item.failureReasons);
+              return `第 ${item.round} 轮(重试 ${retryCount} 次,最终原因:${finalReason};失败记录:${reasonSummary})`;
+            })
+            .join(';')}`,
+          'error'
+        );
+      }
+
+      if (pendingRounds.length) {
+        await addLog(
+          `未完成轮次:${pendingRounds.map((item) => `第 ${item.round} 轮`).join(';')}`,
+          'warn'
+        );
+      }
+    }
+
+    async function skipAutoRunCountdown() {
+      const state = await getState();
+      const plan = getPendingAutoRunTimerPlan(state);
+      if (!plan || state.autoRunPhase !== 'waiting_interval') {
+        return false;
+      }
+
+      return launchAutoRunTimerPlan('manual', {
+        expectedKinds: [
+          AUTO_RUN_TIMER_KIND_BETWEEN_ROUNDS,
+          AUTO_RUN_TIMER_KIND_BEFORE_RETRY,
+        ],
+      });
+    }
+
+    async function waitBetweenAutoRunRounds(targetRun, totalRuns, roundSummary, options = {}) {
+      const { autoRunSkipFailures = false, roundSummaries = [] } = options;
+      if (totalRuns <= 1 || targetRun >= totalRuns) {
+        return false;
+      }
+
+      const fallbackThreadIntervalMinutes = normalizeAutoRunFallbackThreadIntervalMinutes(
+        (await getState()).autoRunFallbackThreadIntervalMinutes
+      );
+      if (fallbackThreadIntervalMinutes <= 0) {
+        return false;
+      }
+
+      const currentRuntime = runtime.get();
+      const statusLabel = roundSummary?.status === 'failed' ? '失败' : '完成';
+      await addLog(
+        `线程间隔:第 ${targetRun}/${totalRuns} 轮已${statusLabel},等待 ${fallbackThreadIntervalMinutes} 分钟后开始下一轮。`,
+        'info'
+      );
+      await persistAutoRunTimerPlan({
+        kind: AUTO_RUN_TIMER_KIND_BETWEEN_ROUNDS,
+        fireAt: Date.now() + fallbackThreadIntervalMinutes * 60 * 1000,
+        currentRun: targetRun,
+        totalRuns,
+        attemptRun: currentRuntime.autoRunAttemptRun,
+        autoRunSkipFailures,
+        roundSummaries,
+        countdownTitle: '线程间隔中',
+        countdownNote: `第 ${Math.min(targetRun + 1, totalRuns)}/${totalRuns} 轮即将开始`,
+      }, {
+        autoRunSkipFailures,
+        autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries),
+      });
+      runtime.set({ autoRunActive: false });
+      return true;
+    }
+
+    async function waitBeforeAutoRunRetry(targetRun, totalRuns, nextAttemptRun, options = {}) {
+      const { autoRunSkipFailures = false, roundSummaries = [] } = options;
+      const fallbackThreadIntervalMinutes = normalizeAutoRunFallbackThreadIntervalMinutes(
+        (await getState()).autoRunFallbackThreadIntervalMinutes
+      );
+      if (fallbackThreadIntervalMinutes <= 0) {
+        return false;
+      }
+
+      await addLog(
+        `线程间隔:等待 ${fallbackThreadIntervalMinutes} 分钟后开始第 ${targetRun}/${totalRuns} 轮第 ${nextAttemptRun} 次尝试。`,
+        'info'
+      );
+      await persistAutoRunTimerPlan({
+        kind: AUTO_RUN_TIMER_KIND_BEFORE_RETRY,
+        fireAt: Date.now() + fallbackThreadIntervalMinutes * 60 * 1000,
+        currentRun: targetRun,
+        totalRuns,
+        attemptRun: nextAttemptRun,
+        autoRunSkipFailures,
+        roundSummaries,
+        countdownTitle: '线程间隔中',
+        countdownNote: `第 ${targetRun}/${totalRuns} 轮第 ${nextAttemptRun} 次尝试即将开始`,
+      }, {
+        autoRunSkipFailures,
+        autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries),
+      });
+      runtime.set({ autoRunActive: false });
+      return true;
+    }
+
+    async function handleAutoRunLoopUnhandledError(error) {
+      const currentRuntime = runtime.get();
+      console.error('Auto run loop crashed:', error);
+      if (!isStopError(error)) {
+        await addLog(`自动运行异常终止:${getErrorMessage(error) || '未知错误'}`, 'error');
+      }
+
+      runtime.set({ autoRunActive: false });
+      await broadcastAutoRunStatus('stopped', {
+        currentRun: currentRuntime.autoRunCurrentRun,
+        totalRuns: currentRuntime.autoRunTotalRuns,
+        attemptRun: currentRuntime.autoRunAttemptRun,
+      }, {
+        autoRunTimerPlan: null,
+        scheduledAutoRunPlan: null,
+      });
+      clearStopRequest();
+    }
+
+    function startAutoRunLoop(totalRuns, options = {}) {
+      autoRunLoop(totalRuns, options).catch((error) => {
+        handleAutoRunLoopUnhandledError(error).catch(() => {});
+      });
+    }
+
+    async function autoRunLoop(totalRuns, options = {}) {
+      let currentRuntime = runtime.get();
+      if (currentRuntime.autoRunActive) {
+        await addLog('自动运行已在进行中', 'warn');
+        return;
+      }
+
+      clearStopRequest();
+      runtime.set({
+        autoRunActive: true,
+        autoRunTotalRuns: totalRuns,
+        autoRunCurrentRun: 0,
+        autoRunAttemptRun: 0,
+      });
+      currentRuntime = runtime.get();
+
+      const autoRunSkipFailures = Boolean(options.autoRunSkipFailures);
+      const initialMode = options.mode === 'continue' ? 'continue' : 'restart';
+      const resumeCurrentRun = Number.isInteger(options.resumeCurrentRun) && options.resumeCurrentRun > 0
+        ? Math.min(totalRuns, options.resumeCurrentRun)
+        : 1;
+      const resumeAttemptRun = Number.isInteger(options.resumeAttemptRun) && options.resumeAttemptRun > 0
+        ? Math.min(AUTO_RUN_MAX_RETRIES_PER_ROUND + 1, options.resumeAttemptRun)
+        : 1;
+      let continueCurrentOnFirstAttempt = initialMode === 'continue';
+      let forceFreshTabsNextRun = false;
+      let stoppedEarly = false;
+      let parkedByTimer = false;
+      const roundSummaries = buildAutoRunRoundSummaries(totalRuns, options.resumeRoundSummaries);
+
+      if (continueCurrentOnFirstAttempt && resumeCurrentRun > 1) {
+        for (let round = 1; round < resumeCurrentRun; round += 1) {
+          const summary = roundSummaries[round - 1];
+          if (summary.status === 'pending') {
+            summary.status = 'success';
+            if (!summary.attempts) {
+              summary.attempts = 1;
+            }
+          }
+        }
+      }
+
+      let successfulRuns = roundSummaries.filter((item) => item.status === 'success').length;
+      const initialState = await getState();
+      const initialPhase = continueCurrentOnFirstAttempt && getRunningSteps(initialState.stepStatuses).length
+        ? 'waiting_step'
+        : 'running';
+      const showResumePosition = continueCurrentOnFirstAttempt || resumeCurrentRun > 1 || resumeAttemptRun > 1;
+
+      await setState({
+        autoRunSkipFailures,
+        autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries),
+        ...getAutoRunStatusPayload(initialPhase, {
+          currentRun: showResumePosition ? resumeCurrentRun : 0,
+          totalRuns,
+          attemptRun: showResumePosition ? resumeAttemptRun : 0,
+        }),
+      });
+
+      for (let targetRun = resumeCurrentRun; targetRun <= totalRuns; targetRun += 1) {
+        const roundSummary = roundSummaries[targetRun - 1];
+        let roundRecordAppended = false;
+        const resumingCurrentRound = continueCurrentOnFirstAttempt && targetRun === resumeCurrentRun;
+        let attemptRun = resumingCurrentRound ? resumeAttemptRun : 1;
+        let reuseExistingProgress = resumingCurrentRound;
+        const maxAttemptsForRound = autoRunSkipFailures
+          ? AUTO_RUN_MAX_RETRIES_PER_ROUND + 1
+          : Math.max(1, attemptRun);
+
+        while (attemptRun <= maxAttemptsForRound) {
+          runtime.set({
+            autoRunCurrentRun: targetRun,
+            autoRunAttemptRun: attemptRun,
+          });
+          roundSummary.attempts = attemptRun;
+          let startStep = 1;
+          let useExistingProgress = false;
+
+          if (reuseExistingProgress) {
+            let currentState = await getState();
+            if (getRunningSteps(currentState.stepStatuses).length) {
+              currentState = await waitForRunningStepsToFinish({
+                currentRun: targetRun,
+                totalRuns,
+                attemptRun,
+              });
+            }
+            const resumeStep = getFirstUnfinishedStep(currentState.stepStatuses);
+            if (resumeStep && hasSavedProgress(currentState.stepStatuses)) {
+              startStep = resumeStep;
+              useExistingProgress = true;
+            } else if (hasSavedProgress(currentState.stepStatuses)) {
+              await addLog('检测到当前流程已处理完成,本轮将改为从步骤 1 重新开始。', 'info');
+            }
+          }
+
+          if (!useExistingProgress) {
+            const prevState = await getState();
+            const keepSettings = {
+              vpsUrl: prevState.vpsUrl,
+              vpsPassword: prevState.vpsPassword,
+              customPassword: prevState.customPassword,
+              autoRunSkipFailures: prevState.autoRunSkipFailures,
+              autoRunFallbackThreadIntervalMinutes: prevState.autoRunFallbackThreadIntervalMinutes,
+              autoRunDelayEnabled: prevState.autoRunDelayEnabled,
+              autoRunDelayMinutes: prevState.autoRunDelayMinutes,
+              autoStepDelaySeconds: prevState.autoStepDelaySeconds,
+              mailProvider: prevState.mailProvider,
+              emailGenerator: prevState.emailGenerator,
+              emailPrefix: prevState.emailPrefix,
+              inbucketHost: prevState.inbucketHost,
+              inbucketMailbox: prevState.inbucketMailbox,
+              cloudflareDomain: prevState.cloudflareDomain,
+              cloudflareDomains: prevState.cloudflareDomains,
+              autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries),
+              tabRegistry: {},
+              sourceLastUrls: {},
+              ...getAutoRunStatusPayload('running', { currentRun: targetRun, totalRuns, attemptRun }),
+            };
+            await resetState();
+            await setState(keepSettings);
+            deps.chrome.runtime.sendMessage({ type: 'AUTO_RUN_RESET' }).catch(() => { });
+            await sleepWithStop(500);
+          } else {
+            await setState({
+              autoRunSkipFailures,
+              autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries),
+              ...getAutoRunStatusPayload('running', { currentRun: targetRun, totalRuns, attemptRun }),
+            });
+          }
+
+          if (forceFreshTabsNextRun) {
+            await addLog(`上一轮尝试已放弃,当前开始第 ${targetRun}/${totalRuns} 轮第 ${attemptRun} 次尝试。`, 'warn');
+            forceFreshTabsNextRun = false;
+          }
+
+          const appendRoundRecordIfNeeded = async (status, reason = '') => {
+            if (roundRecordAppended) {
+              return;
+            }
+
+            if (typeof appendAccountRunRecord !== 'function') {
+              return;
+            }
+
+            const record = await appendAccountRunRecord(status, null, reason);
+            if (record) {
+              roundRecordAppended = true;
+            }
+          };
+
+          try {
+            deps.throwIfStopped();
+            await broadcastAutoRunStatus('running', {
+              currentRun: targetRun,
+              totalRuns,
+              attemptRun,
+            });
+
+            await runAutoSequenceFromStep(startStep, {
+              targetRun,
+              totalRuns,
+              attemptRuns: attemptRun,
+              continued: useExistingProgress,
+            });
+
+            roundSummary.status = 'success';
+            roundSummary.finalFailureReason = '';
+            successfulRuns += 1;
+            await setState({
+              autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries),
+            });
+            await addLog(`=== 第 ${targetRun}/${totalRuns} 轮完成(第 ${attemptRun} 次尝试成功)===`, 'ok');
+            break;
+          } catch (err) {
+            if (isStopError(err)) {
+              stoppedEarly = true;
+              await appendRoundRecordIfNeeded('stopped', getErrorMessage(err));
+              await addLog(`第 ${targetRun}/${totalRuns} 轮已被用户停止`, 'warn');
+              await broadcastAutoRunStatus('stopped', {
+                currentRun: targetRun,
+                totalRuns,
+                attemptRun,
+              });
+              break;
+            }
+
+            const reason = getErrorMessage(err);
+            roundSummary.failureReasons.push(reason);
+            const canRetry = autoRunSkipFailures && attemptRun < maxAttemptsForRound;
+
+            await setState({
+              autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries),
+            });
+
+            if (canRetry) {
+              const retryIndex = attemptRun;
+              if (isRestartCurrentAttemptError(err)) {
+                await addLog(`第 ${targetRun}/${totalRuns} 轮第 ${attemptRun} 次尝试需要整轮重开:${reason}`, 'warn');
+              } else {
+                await addLog(`第 ${targetRun}/${totalRuns} 轮第 ${attemptRun} 次尝试失败:${reason}`, 'error');
+              }
+              cancelPendingCommands('当前尝试已放弃。');
+              await broadcastStopToContentScripts();
+              await broadcastAutoRunStatus('retrying', {
+                currentRun: targetRun,
+                totalRuns,
+                attemptRun,
+              });
+              forceFreshTabsNextRun = true;
+              await addLog(
+                `自动重试:${Math.round(AUTO_RUN_RETRY_DELAY_MS / 1000)} 秒后开始第 ${targetRun}/${totalRuns} 轮第 ${attemptRun + 1} 次尝试(第 ${retryIndex}/${AUTO_RUN_MAX_RETRIES_PER_ROUND} 次重试)。`,
+                'warn'
+              );
+              try {
+                await sleepWithStop(AUTO_RUN_RETRY_DELAY_MS);
+              } catch (sleepError) {
+                if (isStopError(sleepError)) {
+                  stoppedEarly = true;
+                  await appendRoundRecordIfNeeded('stopped', getErrorMessage(sleepError));
+                  await addLog(`第 ${targetRun}/${totalRuns} 轮已被用户停止`, 'warn');
+                  await broadcastAutoRunStatus('stopped', {
+                    currentRun: targetRun,
+                    totalRuns,
+                    attemptRun,
+                  });
+                  break;
+                }
+                throw sleepError;
+              }
+              try {
+                const parkedForRetry = await waitBeforeAutoRunRetry(targetRun, totalRuns, attemptRun + 1, {
+                  autoRunSkipFailures,
+                  roundSummaries,
+                });
+                if (parkedForRetry) {
+                  parkedByTimer = true;
+                  break;
+                }
+              } catch (sleepError) {
+                if (isStopError(sleepError)) {
+                  stoppedEarly = true;
+                  await appendRoundRecordIfNeeded('stopped', getErrorMessage(sleepError));
+                  await addLog(`第 ${targetRun}/${totalRuns} 轮已被用户停止`, 'warn');
+                  await broadcastAutoRunStatus('stopped', {
+                    currentRun: targetRun,
+                    totalRuns,
+                    attemptRun,
+                  });
+                  break;
+                }
+                throw sleepError;
+              }
+              attemptRun += 1;
+              reuseExistingProgress = false;
+              continue;
+            }
+
+            roundSummary.status = 'failed';
+            roundSummary.finalFailureReason = reason;
+            await setState({
+              autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries),
+            });
+            await appendRoundRecordIfNeeded('failed', reason);
+            if (!autoRunSkipFailures) {
+              cancelPendingCommands('当前轮执行失败。');
+              await broadcastStopToContentScripts();
+              await addLog('自动重试未开启,自动运行将在当前失败后停止。', 'warn');
+              stoppedEarly = true;
+              await broadcastAutoRunStatus('stopped', {
+                currentRun: targetRun,
+                totalRuns,
+                attemptRun,
+              });
+              break;
+            }
+            await addLog(`第 ${targetRun}/${totalRuns} 轮最终失败:${reason}`, 'error');
+            await addLog(
+              targetRun < totalRuns
+                ? `第 ${targetRun}/${totalRuns} 轮已达到 ${AUTO_RUN_MAX_RETRIES_PER_ROUND} 次重试上限,继续下一轮。`
+                : `第 ${targetRun}/${totalRuns} 轮已达到 ${AUTO_RUN_MAX_RETRIES_PER_ROUND} 次重试上限,本次自动运行结束。`,
+              'warn'
+            );
+            cancelPendingCommands('当前轮已达到重试上限。');
+            await broadcastStopToContentScripts();
+            forceFreshTabsNextRun = true;
+            break;
+          } finally {
+            reuseExistingProgress = false;
+            continueCurrentOnFirstAttempt = false;
+          }
+        }
+
+        if (stoppedEarly || parkedByTimer) {
+          break;
+        }
+
+        try {
+          const parkedForNextRound = await waitBetweenAutoRunRounds(targetRun, totalRuns, roundSummary, {
+            autoRunSkipFailures,
+            roundSummaries,
+          });
+          if (parkedForNextRound) {
+            parkedByTimer = true;
+            break;
+          }
+        } catch (sleepError) {
+          if (isStopError(sleepError)) {
+            stoppedEarly = true;
+            await addLog(`第 ${targetRun}/${totalRuns} 轮已被用户停止`, 'warn');
+            await broadcastAutoRunStatus('stopped', {
+              currentRun: targetRun,
+              totalRuns,
+              attemptRun: runtime.get().autoRunAttemptRun,
+            });
+            break;
+          }
+          throw sleepError;
+        }
+      }
+
+      if (parkedByTimer) {
+        runtime.set({ autoRunActive: false });
+        clearStopRequest();
+        return;
+      }
+
+      await setState({
+        autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries),
+      });
+      await logAutoRunFinalSummary(totalRuns, roundSummaries);
+
+      const finalRuntime = runtime.get();
+      if (deps.getStopRequested() || stoppedEarly) {
+        await addLog(`=== 已停止,完成 ${successfulRuns}/${finalRuntime.autoRunTotalRuns} 轮 ===`, 'warn');
+        await broadcastAutoRunStatus('stopped', {
+          currentRun: finalRuntime.autoRunCurrentRun,
+          totalRuns: finalRuntime.autoRunTotalRuns,
+          attemptRun: finalRuntime.autoRunAttemptRun,
+        });
+      } else {
+        await addLog(`=== 全部 ${finalRuntime.autoRunTotalRuns} 轮已执行完成,成功 ${successfulRuns} 轮 ===`, 'ok');
+        await broadcastAutoRunStatus('complete', {
+          currentRun: finalRuntime.autoRunTotalRuns,
+          totalRuns: finalRuntime.autoRunTotalRuns,
+          attemptRun: finalRuntime.autoRunAttemptRun,
+        });
+      }
+      runtime.set({ autoRunActive: false });
+      const afterRuntime = runtime.get();
+      await setState({
+        autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries),
+        autoRunTimerPlan: null,
+        scheduledAutoRunPlan: null,
+        ...getAutoRunStatusPayload(deps.getStopRequested() || stoppedEarly ? 'stopped' : 'complete', {
+          currentRun: deps.getStopRequested() || stoppedEarly ? afterRuntime.autoRunCurrentRun : afterRuntime.autoRunTotalRuns,
+          totalRuns: afterRuntime.autoRunTotalRuns,
+          attemptRun: afterRuntime.autoRunAttemptRun,
+        }),
+      });
+      clearStopRequest();
+    }
+
+    return {
+      autoRunLoop,
+      buildAutoRunRoundSummaries,
+      createAutoRunRoundSummary,
+      formatAutoRunFailureReasons,
+      getAutoRunRoundRetryCount,
+      handleAutoRunLoopUnhandledError,
+      logAutoRunFinalSummary,
+      normalizeAutoRunRoundSummary,
+      serializeAutoRunRoundSummaries,
+      skipAutoRunCountdown,
+      startAutoRunLoop,
+      waitBetweenAutoRunRounds,
+      waitBeforeAutoRunRetry,
+    };
+  }
+
+  return {
+    createAutoRunController,
+  };
+});

+ 220 - 0
background/generated-email-helpers.js

@@ -0,0 +1,220 @@
+(function attachGeneratedEmailHelpers(root, factory) {
+  root.MultiPageGeneratedEmailHelpers = factory();
+})(typeof self !== 'undefined' ? self : globalThis, function createGeneratedEmailHelpersModule() {
+  function createGeneratedEmailHelpers(deps = {}) {
+    const {
+      addLog,
+      buildCloudflareTempEmailHeaders,
+      CLOUDFLARE_TEMP_EMAIL_GENERATOR,
+      DUCK_AUTOFILL_URL,
+      fetch,
+      fetchIcloudHideMyEmail,
+      getCloudflareTempEmailAddressFromResponse,
+      getCloudflareTempEmailConfig,
+      getState,
+      joinCloudflareTempEmailUrl,
+      normalizeCloudflareDomain,
+      normalizeCloudflareTempEmailAddress,
+      normalizeEmailGenerator,
+      reuseOrCreateTab,
+      sendToContentScript,
+      setEmailState,
+      throwIfStopped,
+    } = deps;
+
+    function generateCloudflareAliasLocalPart() {
+      const letters = 'abcdefghijklmnopqrstuvwxyz';
+      const digits = '0123456789';
+      const chars = [];
+
+      for (let i = 0; i < 6; i++) {
+        chars.push(letters[Math.floor(Math.random() * letters.length)]);
+      }
+
+      for (let i = 0; i < 4; i++) {
+        chars.push(digits[Math.floor(Math.random() * digits.length)]);
+      }
+
+      for (let i = chars.length - 1; i > 0; i--) {
+        const j = Math.floor(Math.random() * (i + 1));
+        [chars[i], chars[j]] = [chars[j], chars[i]];
+      }
+
+      return chars.join('');
+    }
+
+    async function fetchCloudflareEmail(state, options = {}) {
+      throwIfStopped();
+      const latestState = state || await getState();
+      const domain = normalizeCloudflareDomain(latestState.cloudflareDomain);
+      if (!domain) {
+        throw new Error('Cloudflare 域名为空或格式无效。');
+      }
+
+      const localPart = String(options.localPart || '').trim().toLowerCase() || generateCloudflareAliasLocalPart();
+      const aliasEmail = `${localPart}@${domain}`;
+
+      await setEmailState(aliasEmail);
+      await addLog(`Cloudflare 邮箱:已生成 ${aliasEmail}`, 'ok');
+      return aliasEmail;
+    }
+
+    function ensureCloudflareTempEmailConfig(state, options = {}) {
+      const {
+        requireAdminAuth = false,
+        requireDomain = false,
+      } = options;
+      const config = getCloudflareTempEmailConfig(state);
+      if (!config.baseUrl) {
+        throw new Error('Cloudflare Temp Email 服务地址为空或格式无效。');
+      }
+      if (requireAdminAuth && !config.adminAuth) {
+        throw new Error('Cloudflare Temp Email 缺少 Admin Auth。');
+      }
+      if (requireDomain && !config.domain) {
+        throw new Error('Cloudflare Temp Email 域名为空或格式无效。');
+      }
+      return config;
+    }
+
+    async function requestCloudflareTempEmailJson(config, path, options = {}) {
+      const {
+        method = 'GET',
+        payload,
+        searchParams,
+        timeoutMs = 20000,
+      } = options;
+
+      const url = new URL(joinCloudflareTempEmailUrl(config.baseUrl, path));
+      if (searchParams && typeof searchParams === 'object') {
+        for (const [key, value] of Object.entries(searchParams)) {
+          if (value === undefined || value === null || value === '') continue;
+          url.searchParams.set(key, String(value));
+        }
+      }
+
+      const controller = new AbortController();
+      const timeoutId = setTimeout(() => controller.abort(new Error('timeout')), timeoutMs);
+
+      let response;
+      try {
+        response = await fetch(url.toString(), {
+          method,
+          headers: buildCloudflareTempEmailHeaders(config, {
+            json: payload !== undefined,
+          }),
+          body: payload !== undefined ? JSON.stringify(payload) : undefined,
+          signal: controller.signal,
+        });
+      } catch (err) {
+        const errorMessage = err?.name === 'AbortError'
+          ? `Cloudflare Temp Email 请求超时(>${Math.round(timeoutMs / 1000)} 秒)`
+          : `Cloudflare Temp Email 请求失败:${err.message}`;
+        throw new Error(errorMessage);
+      } finally {
+        clearTimeout(timeoutId);
+      }
+
+      const text = await response.text();
+      let parsed;
+      try {
+        parsed = text ? JSON.parse(text) : {};
+      } catch {
+        parsed = text;
+      }
+
+      if (!response.ok) {
+        const payloadError = typeof parsed === 'object' && parsed
+          ? (parsed.message || parsed.error || parsed.msg)
+          : '';
+        throw new Error(`Cloudflare Temp Email 请求失败:${payloadError || text || `HTTP ${response.status}`}`);
+      }
+
+      return parsed;
+    }
+
+    async function fetchCloudflareTempEmailAddress(state, options = {}) {
+      throwIfStopped();
+      const latestState = state || await getState();
+      const config = ensureCloudflareTempEmailConfig(latestState, {
+        requireAdminAuth: true,
+        requireDomain: true,
+      });
+      const requestedName = String(options.localPart || options.name || '').trim().toLowerCase() || generateCloudflareAliasLocalPart();
+      const payload = {
+        enablePrefix: true,
+        name: requestedName,
+        domain: config.domain,
+      };
+      const result = await requestCloudflareTempEmailJson(config, '/admin/new_address', {
+        method: 'POST',
+        payload,
+      });
+      const address = normalizeCloudflareTempEmailAddress(getCloudflareTempEmailAddressFromResponse(result));
+      if (!address) {
+        throw new Error('Cloudflare Temp Email 未返回可用邮箱地址。');
+      }
+
+      await setEmailState(address);
+      await addLog(`Cloudflare Temp Email:已生成 ${address}`, 'ok');
+      return address;
+    }
+
+    async function fetchDuckEmail(options = {}) {
+      throwIfStopped();
+      const { generateNew = true } = options;
+
+      await addLog(`Duck 邮箱:正在打开自动填充设置(${generateNew ? '生成新地址' : '复用当前地址'})...`);
+      await reuseOrCreateTab('duck-mail', DUCK_AUTOFILL_URL);
+
+      const result = await sendToContentScript('duck-mail', {
+        type: 'FETCH_DUCK_EMAIL',
+        source: 'background',
+        payload: { generateNew },
+      });
+
+      if (result?.error) {
+        throw new Error(result.error);
+      }
+      if (!result?.email) {
+        throw new Error('未返回 Duck 邮箱地址。');
+      }
+
+      await setEmailState(result.email);
+      await addLog(`Duck 邮箱:${result.generated ? '已生成' : '已读取'} ${result.email}`, 'ok');
+      return result.email;
+    }
+
+    async function fetchGeneratedEmail(state, options = {}) {
+      const currentState = state || await getState();
+      const generator = normalizeEmailGenerator(options.generator ?? currentState.emailGenerator);
+      if (generator === 'custom') {
+        throw new Error('当前邮箱生成方式为自定义邮箱,请直接填写注册邮箱。');
+      }
+      if (generator === 'icloud') {
+        return fetchIcloudHideMyEmail();
+      }
+      if (generator === 'cloudflare') {
+        return fetchCloudflareEmail(currentState, options);
+      }
+      if (generator === CLOUDFLARE_TEMP_EMAIL_GENERATOR) {
+        return fetchCloudflareTempEmailAddress(currentState, options);
+      }
+      return fetchDuckEmail(options);
+    }
+
+    return {
+      ensureCloudflareTempEmailConfig,
+      fetchCloudflareEmail,
+      fetchCloudflareTempEmailAddress,
+      fetchDuckEmail,
+      fetchGeneratedEmail,
+      generateCloudflareAliasLocalPart,
+      requestCloudflareTempEmailJson,
+    };
+  }
+
+  return {
+    createGeneratedEmailHelpers,
+  };
+});

+ 164 - 0
background/logging-status.js

@@ -0,0 +1,164 @@
+(function attachBackgroundLoggingStatus(root, factory) {
+  root.MultiPageBackgroundLoggingStatus = factory();
+})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundLoggingStatusModule() {
+  function createLoggingStatus(deps = {}) {
+    const {
+      chrome,
+      DEFAULT_STATE,
+      getState,
+      isRecoverableStep9AuthFailure,
+      LOG_PREFIX,
+      setState,
+      STOP_ERROR_MESSAGE,
+    } = deps;
+
+    function getSourceLabel(source) {
+      const labels = {
+        'gmail-mail': 'Gmail 邮箱',
+        'sidepanel': '侧边栏',
+        'signup-page': '认证页',
+        'vps-panel': 'CPA 面板',
+        'sub2api-panel': 'SUB2API 后台',
+        'qq-mail': 'QQ 邮箱',
+        'mail-163': '163 邮箱',
+        'mail-2925': '2925 邮箱',
+        'inbucket-mail': 'Inbucket 邮箱',
+        'duck-mail': 'Duck 邮箱',
+        'hotmail-api': 'Hotmail(API对接/本地助手)',
+        'luckmail-api': 'LuckMail(API 购邮)',
+        'cloudflare-temp-email': 'Cloudflare Temp Email',
+      };
+      return labels[source] || source || '未知来源';
+    }
+
+    async function addLog(message, level = 'info') {
+      const state = await getState();
+      const logs = state.logs || [];
+      const entry = { message, level, timestamp: Date.now() };
+      logs.push(entry);
+      if (logs.length > 500) logs.splice(0, logs.length - 500);
+      await setState({ logs });
+      chrome.runtime.sendMessage({ type: 'LOG_ENTRY', payload: entry }).catch(() => { });
+    }
+
+    async function setStepStatus(step, status) {
+      const state = await getState();
+      const statuses = { ...state.stepStatuses };
+      statuses[step] = status;
+      await setState({ stepStatuses: statuses, currentStep: step });
+      chrome.runtime.sendMessage({
+        type: 'STEP_STATUS_CHANGED',
+        payload: { step, status },
+      }).catch(() => { });
+    }
+
+    function getErrorMessage(error) {
+      return String(typeof error === 'string' ? error : error?.message || '');
+    }
+
+    function isVerificationMailPollingError(error) {
+      const message = getErrorMessage(error);
+      return /未在 .*邮箱中找到新的匹配邮件|未在 Hotmail 收件箱中找到新的匹配验证码|邮箱轮询结束,但未获取到验证码|无法获取新的(?:注册|登录)验证码|页面未能重新就绪|页面通信异常|did not respond in \d+s/i.test(message);
+    }
+
+    function getLoginAuthStateLabel(state) {
+      state = state === 'oauth_consent_page' ? 'unknown' : state;
+      switch (state) {
+        case 'verification_page':
+          return '登录验证码页';
+        case 'password_page':
+          return '密码页';
+        case 'email_page':
+          return '邮箱输入页';
+        case 'login_timeout_error_page':
+          return '登录超时报错页';
+        case 'oauth_consent_page':
+          return 'OAuth 授权页';
+        case 'add_phone_page':
+          return '手机号页';
+        default:
+          return '未知页面';
+      }
+    }
+
+    function isRestartCurrentAttemptError(error) {
+      const message = String(typeof error === 'string' ? error : error?.message || '');
+      return /当前邮箱已存在,需要重新开始新一轮/.test(message);
+    }
+
+    function isStep9RecoverableAuthError(error) {
+      const message = String(typeof error === 'string' ? error : error?.message || '');
+      return /STEP9_OAUTH_RETRY::/i.test(message)
+        || isRecoverableStep9AuthFailure(message);
+    }
+
+    function isLegacyStep9RecoverableAuthError(error) {
+      const message = String(typeof error === 'string' ? error : error?.message || '');
+      return /STEP9_OAUTH_TIMEOUT::|认证失败:\s*(?:Timeout waiting for OAuth callback|timeout of \d+ms exceeded)/i.test(message);
+    }
+
+    function isStepDoneStatus(status) {
+      return status === 'completed' || status === 'manual_completed' || status === 'skipped';
+    }
+
+    function getFirstUnfinishedStep(statuses = {}) {
+      for (let step = 1; step <= 9; step++) {
+        if (!isStepDoneStatus(statuses[step] || 'pending')) {
+          return step;
+        }
+      }
+      return null;
+    }
+
+    function hasSavedProgress(statuses = {}) {
+      return Object.values({ ...DEFAULT_STATE.stepStatuses, ...statuses }).some((status) => status !== 'pending');
+    }
+
+    function getRunningSteps(statuses = {}) {
+      return Object.entries({ ...DEFAULT_STATE.stepStatuses, ...statuses })
+        .filter(([, status]) => status === 'running')
+        .map(([step]) => Number(step))
+        .sort((a, b) => a - b);
+    }
+
+    function getAutoRunStatusPayload(phase, payload = {}) {
+      return {
+        autoRunning: phase === 'scheduled'
+          || phase === 'running'
+          || phase === 'waiting_step'
+          || phase === 'waiting_email'
+          || phase === 'retrying'
+          || phase === 'waiting_interval',
+        autoRunPhase: phase,
+        autoRunCurrentRun: payload.currentRun ?? 0,
+        autoRunTotalRuns: payload.totalRuns ?? 1,
+        autoRunAttemptRun: payload.attemptRun ?? 0,
+        scheduledAutoRunAt: Number.isFinite(Number(payload.scheduledAt)) ? Number(payload.scheduledAt) : null,
+        autoRunCountdownAt: Number.isFinite(Number(payload.countdownAt)) ? Number(payload.countdownAt) : null,
+        autoRunCountdownTitle: payload.countdownTitle === undefined ? '' : String(payload.countdownTitle || ''),
+        autoRunCountdownNote: payload.countdownNote === undefined ? '' : String(payload.countdownNote || ''),
+      };
+    }
+
+    return {
+      addLog,
+      getAutoRunStatusPayload,
+      getErrorMessage,
+      getFirstUnfinishedStep,
+      getLoginAuthStateLabel,
+      getRunningSteps,
+      getSourceLabel,
+      hasSavedProgress,
+      isLegacyStep9RecoverableAuthError,
+      isRestartCurrentAttemptError,
+      isStep9RecoverableAuthError,
+      isStepDoneStatus,
+      isVerificationMailPollingError,
+      setStepStatus,
+    };
+  }
+
+  return {
+    createLoggingStatus,
+  };
+});

+ 566 - 0
background/message-router.js

@@ -0,0 +1,566 @@
+(function attachBackgroundMessageRouter(root, factory) {
+  root.MultiPageBackgroundMessageRouter = factory();
+})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundMessageRouterModule() {
+  function createMessageRouter(deps = {}) {
+    const {
+      addLog,
+      appendAccountRunRecord,
+      batchUpdateLuckmailPurchases,
+      buildLocalhostCleanupPrefix,
+      buildLuckmailSessionSettingsPayload,
+      buildPersistentSettingsPayload,
+      broadcastDataUpdate,
+      cancelScheduledAutoRun,
+      checkIcloudSession,
+      clearAutoRunTimerAlarm,
+      clearLuckmailRuntimeState,
+      clearStopRequest,
+      closeLocalhostCallbackTabs,
+      closeTabsByUrlPrefix,
+      deleteHotmailAccount,
+      deleteHotmailAccounts,
+      deleteIcloudAlias,
+      deleteUsedIcloudAliases,
+      disableUsedLuckmailPurchases,
+      doesStepUseCompletionSignal,
+      ensureManualInteractionAllowed,
+      executeStep,
+      executeStepViaCompletionSignal,
+      exportSettingsBundle,
+      fetchGeneratedEmail,
+      finalizeIcloudAliasAfterSuccessfulFlow,
+      findHotmailAccount,
+      flushCommand,
+      getCurrentLuckmailPurchase,
+      getPendingAutoRunTimerPlan,
+      getSourceLabel,
+      getState,
+      getStopRequested,
+      handleAutoRunLoopUnhandledError,
+      importSettingsBundle,
+      invalidateDownstreamAfterStepRestart,
+      isAutoRunLockedState,
+      isHotmailProvider,
+      isLocalhostOAuthCallbackUrl,
+      isLuckmailProvider,
+      isStopError,
+      launchAutoRunTimerPlan,
+      listIcloudAliases,
+      listLuckmailPurchasesForManagement,
+      normalizeHotmailAccounts,
+      normalizeRunCount,
+      AUTO_RUN_TIMER_KIND_SCHEDULED_START,
+      notifyStepComplete,
+      notifyStepError,
+      patchHotmailAccount,
+      registerTab,
+      requestStop,
+      resetState,
+      resumeAutoRun,
+      scheduleAutoRun,
+      selectLuckmailPurchase,
+      setCurrentHotmailAccount,
+      setEmailState,
+      setEmailStateSilently,
+      setIcloudAliasPreservedState,
+      setIcloudAliasUsedState,
+      setLuckmailPurchaseDisabledState,
+      setLuckmailPurchasePreservedState,
+      setLuckmailPurchaseUsedState,
+      setPersistentSettings,
+      setState,
+      setStepStatus,
+      skipAutoRunCountdown,
+      skipStep,
+      startAutoRunLoop,
+      syncHotmailAccounts,
+      testHotmailAccountMailAccess,
+      upsertHotmailAccount,
+      verifyHotmailAccount,
+    } = deps;
+
+    async function appendManualAccountRunRecordIfNeeded(status, stateOverride = null, reason = '') {
+      if (typeof appendAccountRunRecord !== 'function') {
+        return null;
+      }
+
+      const state = stateOverride || await getState();
+      if (isAutoRunLockedState(state)) {
+        return null;
+      }
+
+      return appendAccountRunRecord(status, state, reason);
+    }
+
+    async function handleStepData(step, payload) {
+      switch (step) {
+        case 1: {
+          const updates = {};
+          if (payload.oauthUrl) {
+            updates.oauthUrl = payload.oauthUrl;
+            broadcastDataUpdate({ oauthUrl: payload.oauthUrl });
+          }
+          if (payload.sub2apiSessionId !== undefined) updates.sub2apiSessionId = payload.sub2apiSessionId || null;
+          if (payload.sub2apiOAuthState !== undefined) updates.sub2apiOAuthState = payload.sub2apiOAuthState || null;
+          if (payload.sub2apiGroupId !== undefined) updates.sub2apiGroupId = payload.sub2apiGroupId || null;
+          if (payload.sub2apiDraftName !== undefined) updates.sub2apiDraftName = payload.sub2apiDraftName || null;
+          if (Object.keys(updates).length) {
+            await setState(updates);
+          }
+          break;
+        }
+        case 2:
+          if (payload.email) {
+            await setEmailState(payload.email);
+          }
+          if (payload.skippedPasswordStep) {
+            const latestState = await getState();
+            const step3Status = latestState.stepStatuses?.[3];
+            if (step3Status !== 'running' && step3Status !== 'completed' && step3Status !== 'manual_completed') {
+              await setStepStatus(3, 'skipped');
+              await addLog('步骤 2:提交邮箱后页面直接进入邮箱验证码页,已自动跳过步骤 3。', 'warn');
+            }
+          }
+          break;
+        case 3:
+          if (payload.email) await setEmailState(payload.email);
+          if (payload.signupVerificationRequestedAt) {
+            await setState({ signupVerificationRequestedAt: payload.signupVerificationRequestedAt });
+          }
+          if (payload.loginVerificationRequestedAt) {
+            await setState({ loginVerificationRequestedAt: payload.loginVerificationRequestedAt });
+          }
+          break;
+        case 6:
+          if (payload.loginVerificationRequestedAt) {
+            await setState({ loginVerificationRequestedAt: payload.loginVerificationRequestedAt });
+          }
+          break;
+        case 4:
+          await setState({
+            lastEmailTimestamp: payload.emailTimestamp || null,
+            signupVerificationRequestedAt: null,
+          });
+          break;
+        case 7:
+          await setState({
+            lastEmailTimestamp: payload.emailTimestamp || null,
+            loginVerificationRequestedAt: null,
+          });
+          break;
+        case 8:
+          if (payload.localhostUrl) {
+            if (!isLocalhostOAuthCallbackUrl(payload.localhostUrl)) {
+              throw new Error('步骤 8 返回了无效的 localhost OAuth 回调地址。');
+            }
+            await setState({ localhostUrl: payload.localhostUrl });
+            broadcastDataUpdate({ localhostUrl: payload.localhostUrl });
+          }
+          break;
+        case 9: {
+          if (payload.localhostUrl) {
+            await closeLocalhostCallbackTabs(payload.localhostUrl);
+          }
+          const latestState = await getState();
+          if (latestState.currentHotmailAccountId && isHotmailProvider(latestState)) {
+            await patchHotmailAccount(latestState.currentHotmailAccountId, {
+              used: true,
+              lastUsedAt: Date.now(),
+            });
+            await addLog('当前 Hotmail 账号已自动标记为已用。', 'ok');
+          }
+          if (isLuckmailProvider(latestState)) {
+            const currentPurchase = getCurrentLuckmailPurchase(latestState);
+            if (currentPurchase?.id) {
+              await setLuckmailPurchaseUsedState(currentPurchase.id, true);
+              await addLog(`当前 LuckMail 邮箱 ${currentPurchase.email_address} 已在本地标记为已用。`, 'ok');
+            }
+            await clearLuckmailRuntimeState({ clearEmail: true });
+            await addLog('当前 LuckMail 邮箱运行态已清空,下轮将优先复用未用邮箱或重新购买邮箱。', 'ok');
+          }
+          const localhostPrefix = buildLocalhostCleanupPrefix(payload.localhostUrl);
+          if (localhostPrefix) {
+            await closeTabsByUrlPrefix(localhostPrefix, {
+              excludeUrls: [payload.localhostUrl],
+              excludeLocalhostCallbacks: true,
+            });
+          }
+          await finalizeIcloudAliasAfterSuccessfulFlow(latestState);
+          break;
+        }
+        default:
+          break;
+      }
+    }
+
+    async function handleMessage(message, sender) {
+      switch (message.type) {
+        case 'CONTENT_SCRIPT_READY': {
+          const tabId = sender.tab?.id;
+          if (tabId && message.source) {
+            await registerTab(message.source, tabId);
+            flushCommand(message.source, tabId);
+            await addLog(`内容脚本已就绪:${getSourceLabel(message.source)}(标签页 ${tabId})`);
+          }
+          return { ok: true };
+        }
+
+        case 'LOG': {
+          const { message: msg, level } = message.payload;
+          await addLog(`[${getSourceLabel(message.source)}] ${msg}`, level);
+          return { ok: true };
+        }
+
+        case 'STEP_COMPLETE': {
+          if (getStopRequested()) {
+            await setStepStatus(message.step, 'stopped');
+            await appendManualAccountRunRecordIfNeeded(`step${message.step}_stopped`, null, '流程已被用户停止。');
+            notifyStepError(message.step, '流程已被用户停止。');
+            return { ok: true };
+          }
+          const completionState = message.step === 9 ? await getState() : null;
+          await setStepStatus(message.step, 'completed');
+          await addLog(`步骤 ${message.step} 已完成`, 'ok');
+          await handleStepData(message.step, message.payload);
+          if (message.step === 9 && typeof appendAccountRunRecord === 'function') {
+            await appendAccountRunRecord('success', completionState);
+          }
+          notifyStepComplete(message.step, message.payload);
+          return { ok: true };
+        }
+
+        case 'STEP_ERROR': {
+          if (isStopError(message.error)) {
+            await setStepStatus(message.step, 'stopped');
+            await addLog(`步骤 ${message.step} 已被用户停止`, 'warn');
+            await appendManualAccountRunRecordIfNeeded(`step${message.step}_stopped`, null, message.error);
+            notifyStepError(message.step, message.error);
+          } else {
+            await setStepStatus(message.step, 'failed');
+            await addLog(`步骤 ${message.step} 失败:${message.error}`, 'error');
+            await appendManualAccountRunRecordIfNeeded(`step${message.step}_failed`, null, message.error);
+            notifyStepError(message.step, message.error);
+          }
+          return { ok: true };
+        }
+
+        case 'GET_STATE': {
+          return await getState();
+        }
+
+        case 'RESET': {
+          clearStopRequest();
+          await clearAutoRunTimerAlarm();
+          await resetState();
+          await addLog('流程已重置', 'info');
+          return { ok: true };
+        }
+
+        case 'EXECUTE_STEP': {
+          clearStopRequest();
+          if (message.source === 'sidepanel') {
+            await ensureManualInteractionAllowed('手动执行步骤');
+          }
+          const step = message.payload.step;
+          if (message.source === 'sidepanel') {
+            await invalidateDownstreamAfterStepRestart(step, { logLabel: `步骤 ${step} 重新执行` });
+          }
+          if (message.payload.email) {
+            await setEmailState(message.payload.email);
+          }
+          if (message.payload.emailPrefix !== undefined) {
+            await setPersistentSettings({ emailPrefix: message.payload.emailPrefix });
+            await setState({ emailPrefix: message.payload.emailPrefix });
+          }
+          if (doesStepUseCompletionSignal(step)) {
+            await executeStepViaCompletionSignal(step);
+          } else {
+            await executeStep(step);
+          }
+          return { ok: true };
+        }
+
+        case 'AUTO_RUN': {
+          clearStopRequest();
+          const state = await getState();
+          if (getPendingAutoRunTimerPlan(state)) {
+            throw new Error('已有自动运行倒计时计划,请先取消或立即开始。');
+          }
+          const totalRuns = normalizeRunCount(message.payload?.totalRuns || 1);
+          const autoRunSkipFailures = Boolean(message.payload?.autoRunSkipFailures);
+          const mode = message.payload?.mode === 'continue' ? 'continue' : 'restart';
+          await setState({ autoRunSkipFailures });
+          startAutoRunLoop(totalRuns, { autoRunSkipFailures, mode });
+          return { ok: true };
+        }
+
+        case 'SCHEDULE_AUTO_RUN': {
+          clearStopRequest();
+          const totalRuns = normalizeRunCount(message.payload?.totalRuns || 1);
+          return await scheduleAutoRun(totalRuns, {
+            delayMinutes: message.payload?.delayMinutes,
+            autoRunSkipFailures: Boolean(message.payload?.autoRunSkipFailures),
+            mode: message.payload?.mode,
+          });
+        }
+
+        case 'START_SCHEDULED_AUTO_RUN_NOW': {
+          clearStopRequest();
+          const started = await launchAutoRunTimerPlan('manual', {
+            expectedKinds: [AUTO_RUN_TIMER_KIND_SCHEDULED_START],
+          });
+          if (!started) {
+            throw new Error('当前没有可立即开始的倒计时计划。');
+          }
+          return { ok: true };
+        }
+
+        case 'CANCEL_SCHEDULED_AUTO_RUN': {
+          const cancelled = await cancelScheduledAutoRun();
+          if (!cancelled) {
+            throw new Error('当前没有可取消的倒计时计划。');
+          }
+          return { ok: true };
+        }
+
+        case 'SKIP_AUTO_RUN_COUNTDOWN': {
+          clearStopRequest();
+          const skipped = await skipAutoRunCountdown();
+          if (!skipped) {
+            throw new Error('当前没有可立即开始的倒计时。');
+          }
+          return { ok: true };
+        }
+
+        case 'RESUME_AUTO_RUN': {
+          clearStopRequest();
+          if (message.payload.email) {
+            await setEmailState(message.payload.email);
+          }
+          resumeAutoRun().catch((error) => {
+            handleAutoRunLoopUnhandledError(error).catch(() => {});
+          });
+          return { ok: true };
+        }
+
+        case 'TAKEOVER_AUTO_RUN': {
+          await requestStop({ logMessage: '已确认手动接管,正在停止自动流程并切换为手动控制...' });
+          await addLog('自动流程已切换为手动控制。', 'warn');
+          return { ok: true };
+        }
+
+        case 'SKIP_STEP': {
+          const step = Number(message.payload?.step);
+          return await skipStep(step);
+        }
+
+        case 'SAVE_SETTING': {
+          const updates = buildPersistentSettingsPayload(message.payload || {});
+          const sessionUpdates = buildLuckmailSessionSettingsPayload(message.payload || {});
+          await setPersistentSettings(updates);
+          await setState({
+            ...updates,
+            ...sessionUpdates,
+          });
+          return { ok: true, state: await getState() };
+        }
+
+        case 'EXPORT_SETTINGS': {
+          return { ok: true, ...(await exportSettingsBundle()) };
+        }
+
+        case 'IMPORT_SETTINGS': {
+          const state = await importSettingsBundle(message.payload?.config || null);
+          return { ok: true, state };
+        }
+
+        case 'UPSERT_HOTMAIL_ACCOUNT': {
+          const account = await upsertHotmailAccount(message.payload || {});
+          return { ok: true, account };
+        }
+
+        case 'DELETE_HOTMAIL_ACCOUNT': {
+          await deleteHotmailAccount(String(message.payload?.accountId || ''));
+          return { ok: true };
+        }
+
+        case 'DELETE_HOTMAIL_ACCOUNTS': {
+          const result = await deleteHotmailAccounts(String(message.payload?.mode || 'all'));
+          return { ok: true, ...result };
+        }
+
+        case 'SELECT_HOTMAIL_ACCOUNT': {
+          const account = await setCurrentHotmailAccount(String(message.payload?.accountId || ''), {
+            markUsed: false,
+            syncEmail: true,
+          });
+          return { ok: true, account };
+        }
+
+        case 'PATCH_HOTMAIL_ACCOUNT': {
+          const account = await patchHotmailAccount(
+            String(message.payload?.accountId || ''),
+            message.payload?.updates || {}
+          );
+          return { ok: true, account };
+        }
+
+        case 'VERIFY_HOTMAIL_ACCOUNT':
+        case 'AUTHORIZE_HOTMAIL_ACCOUNT': {
+          const accountId = String(message.payload?.accountId || '');
+          try {
+            const result = await verifyHotmailAccount(accountId);
+            await setCurrentHotmailAccount(result.account.id, { markUsed: false, syncEmail: true });
+            await addLog(`Hotmail 账号 ${result.account.email} 校验通过,可直接用于收信。`, 'ok');
+            return { ok: true, account: result.account, messageCount: result.messageCount };
+          } catch (err) {
+            const state = await getState();
+            const accounts = normalizeHotmailAccounts(state.hotmailAccounts);
+            const target = findHotmailAccount(accounts, accountId);
+            if (target) {
+              target.status = 'error';
+              target.lastError = err.message;
+              await syncHotmailAccounts(accounts.map((item) => (item.id === target.id ? target : item)));
+            }
+            throw err;
+          }
+        }
+
+        case 'TEST_HOTMAIL_ACCOUNT': {
+          const result = await testHotmailAccountMailAccess(String(message.payload?.accountId || ''));
+          return { ok: true, ...result };
+        }
+
+        case 'LIST_LUCKMAIL_PURCHASES': {
+          const purchases = await listLuckmailPurchasesForManagement();
+          return { ok: true, purchases };
+        }
+
+        case 'SELECT_LUCKMAIL_PURCHASE': {
+          const purchase = await selectLuckmailPurchase(message.payload?.purchaseId);
+          return { ok: true, purchase };
+        }
+
+        case 'SET_LUCKMAIL_PURCHASE_USED_STATE': {
+          const result = await setLuckmailPurchaseUsedState(message.payload?.purchaseId, Boolean(message.payload?.used));
+          return { ok: true, ...result };
+        }
+
+        case 'SET_LUCKMAIL_PURCHASE_PRESERVED_STATE': {
+          const purchase = await setLuckmailPurchasePreservedState(message.payload?.purchaseId, Boolean(message.payload?.preserved));
+          return { ok: true, purchase };
+        }
+
+        case 'SET_LUCKMAIL_PURCHASE_DISABLED_STATE': {
+          const purchase = await setLuckmailPurchaseDisabledState(message.payload?.purchaseId, Boolean(message.payload?.disabled));
+          return { ok: true, purchase };
+        }
+
+        case 'BATCH_UPDATE_LUCKMAIL_PURCHASES': {
+          const result = await batchUpdateLuckmailPurchases(message.payload || {});
+          return { ok: true, ...result };
+        }
+
+        case 'DISABLE_USED_LUCKMAIL_PURCHASES': {
+          const result = await disableUsedLuckmailPurchases();
+          return { ok: true, ...result };
+        }
+
+        case 'SET_EMAIL_STATE': {
+          const state = await getState();
+          if (isAutoRunLockedState(state)) {
+            throw new Error('自动流程运行中,当前不能手动修改邮箱。');
+          }
+          const email = String(message.payload?.email || '').trim() || null;
+          await setEmailStateSilently(email);
+          return { ok: true, email };
+        }
+
+        case 'SAVE_EMAIL': {
+          const state = await getState();
+          if (isAutoRunLockedState(state)) {
+            throw new Error('自动流程运行中,当前不能手动修改邮箱。');
+          }
+          await setEmailState(message.payload.email);
+          await resumeAutoRun();
+          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 };
+        }
+
+        case 'FETCH_DUCK_EMAIL': {
+          clearStopRequest();
+          const state = await getState();
+          if (isAutoRunLockedState(state)) {
+            throw new Error('自动流程运行中,当前不能手动获取邮箱。');
+          }
+          const email = await fetchGeneratedEmail(state, { ...(message.payload || {}), generator: 'duck' });
+          await resumeAutoRun();
+          return { ok: true, email };
+        }
+
+        case 'CHECK_ICLOUD_SESSION': {
+          clearStopRequest();
+          return await checkIcloudSession();
+        }
+
+        case 'LIST_ICLOUD_ALIASES': {
+          clearStopRequest();
+          const aliases = await listIcloudAliases();
+          return { ok: true, aliases };
+        }
+
+        case 'SET_ICLOUD_ALIAS_USED_STATE': {
+          clearStopRequest();
+          const result = await setIcloudAliasUsedState(message.payload || {});
+          return { ok: true, ...result };
+        }
+
+        case 'SET_ICLOUD_ALIAS_PRESERVED_STATE': {
+          clearStopRequest();
+          const result = await setIcloudAliasPreservedState(message.payload || {});
+          return { ok: true, ...result };
+        }
+
+        case 'DELETE_ICLOUD_ALIAS': {
+          clearStopRequest();
+          const result = await deleteIcloudAlias(message.payload || {});
+          return { ok: true, ...result };
+        }
+
+        case 'DELETE_USED_ICLOUD_ALIASES': {
+          clearStopRequest();
+          const result = await deleteUsedIcloudAliases();
+          return { ok: true, ...result };
+        }
+
+        case 'STOP_FLOW': {
+          await requestStop();
+          return { ok: true };
+        }
+
+        default:
+          console.warn('Unknown message type:', message.type);
+          return { error: `Unknown message type: ${message.type}` };
+      }
+    }
+
+    return {
+      handleMessage,
+      handleStepData,
+    };
+  }
+
+  return {
+    createMessageRouter,
+  };
+});

+ 179 - 0
background/navigation-utils.js

@@ -0,0 +1,179 @@
+(function attachBackgroundNavigationUtils(root, factory) {
+  root.MultiPageBackgroundNavigationUtils = factory();
+})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundNavigationUtilsModule() {
+  function createNavigationUtils(deps = {}) {
+    const {
+      DEFAULT_SUB2API_URL,
+      normalizeCpaCallbackMode,
+      normalizeLocalCpaStep9Mode,
+    } = deps;
+
+    function parseUrlSafely(rawUrl) {
+      if (!rawUrl) return null;
+      try {
+        return new URL(rawUrl);
+      } catch {
+        return null;
+      }
+    }
+
+    function normalizeSub2ApiUrl(rawUrl) {
+      const input = (rawUrl || '').trim() || DEFAULT_SUB2API_URL;
+      const withProtocol = /^https?:\/\//i.test(input) ? input : `https://${input}`;
+      const parsed = new URL(withProtocol);
+      if (!parsed.pathname || parsed.pathname === '/') {
+        parsed.pathname = '/admin/accounts';
+      }
+      parsed.hash = '';
+      return parsed.toString();
+    }
+
+    function getPanelMode(state = {}) {
+      return state.panelMode === 'sub2api' ? 'sub2api' : 'cpa';
+    }
+
+    function getPanelModeLabel(modeOrState) {
+      const mode = typeof modeOrState === 'string' ? modeOrState : getPanelMode(modeOrState);
+      return mode === 'sub2api' ? 'SUB2API' : 'CPA';
+    }
+
+    function isSignupPageHost(hostname = '') {
+      return ['auth0.openai.com', 'auth.openai.com', 'accounts.openai.com'].includes(hostname);
+    }
+
+    function isSignupEntryHost(hostname = '') {
+      return ['chatgpt.com', 'chat.openai.com'].includes(hostname);
+    }
+
+    function isSignupPasswordPageUrl(rawUrl) {
+      const parsed = parseUrlSafely(rawUrl);
+      if (!parsed) return false;
+      return isSignupPageHost(parsed.hostname)
+        && /\/create-account\/password(?:[/?#]|$)/i.test(parsed.pathname || '');
+    }
+
+    function isSignupEmailVerificationPageUrl(rawUrl) {
+      const parsed = parseUrlSafely(rawUrl);
+      if (!parsed) return false;
+      return isSignupPageHost(parsed.hostname)
+        && /\/email-verification(?:[/?#]|$)/i.test(parsed.pathname || '');
+    }
+
+    function is163MailHost(hostname = '') {
+      return hostname === 'mail.163.com'
+        || hostname.endsWith('.mail.163.com')
+        || hostname === 'webmail.vip.163.com';
+    }
+
+    function isLocalhostOAuthCallbackUrl(rawUrl) {
+      const parsed = parseUrlSafely(rawUrl);
+      if (!parsed) return false;
+      if (!['http:', 'https:'].includes(parsed.protocol)) return false;
+      if (!['localhost', '127.0.0.1'].includes(parsed.hostname)) return false;
+      if (!['/auth/callback', '/codex/callback'].includes(parsed.pathname)) return false;
+
+      const code = (parsed.searchParams.get('code') || '').trim();
+      const state = (parsed.searchParams.get('state') || '').trim();
+      return Boolean(code && state);
+    }
+
+    function isLocalCpaUrl(rawUrl) {
+      const parsed = parseUrlSafely(rawUrl);
+      if (!parsed) return false;
+      if (!['http:', 'https:'].includes(parsed.protocol)) return false;
+      return ['localhost', '127.0.0.1'].includes(parsed.hostname);
+    }
+
+    function shouldBypassStep9ForLocalCpa(state) {
+      return normalizeLocalCpaStep9Mode(state?.localCpaStep9Mode) === 'bypass'
+        && Boolean(state?.localhostUrl)
+        && isLocalCpaUrl(state?.vpsUrl);
+    }
+
+    function shouldSkipLoginVerificationForCpaCallback(state) {
+      return getPanelMode(state) === 'cpa'
+        && normalizeCpaCallbackMode(state?.cpaCallbackMode) === 'step6';
+    }
+
+    function matchesSourceUrlFamily(source, candidateUrl, referenceUrl) {
+      const candidate = parseUrlSafely(candidateUrl);
+      if (!candidate) return false;
+
+      const reference = parseUrlSafely(referenceUrl);
+
+      switch (source) {
+        case 'signup-page':
+          return isSignupPageHost(candidate.hostname) || isSignupEntryHost(candidate.hostname);
+        case 'duck-mail':
+          return candidate.hostname === 'duckduckgo.com' && candidate.pathname.startsWith('/email/');
+        case 'qq-mail':
+          return candidate.hostname === 'mail.qq.com' || candidate.hostname === 'wx.mail.qq.com';
+        case 'mail-163':
+          return is163MailHost(candidate.hostname);
+        case 'gmail-mail':
+          return candidate.hostname === 'mail.google.com';
+        case 'inbucket-mail':
+          return Boolean(reference)
+            && candidate.origin === reference.origin
+            && candidate.pathname.startsWith('/m/');
+        case 'mail-2925':
+          return candidate.hostname === '2925.com' || candidate.hostname === 'www.2925.com';
+        case 'vps-panel':
+          return Boolean(reference)
+            && candidate.origin === reference.origin
+            && candidate.pathname === reference.pathname;
+        case 'sub2api-panel':
+          return Boolean(reference)
+            && candidate.origin === reference.origin
+            && (
+              candidate.pathname.startsWith('/admin/accounts')
+              || candidate.pathname.startsWith('/login')
+              || candidate.pathname === '/'
+            );
+        default:
+          return false;
+      }
+    }
+
+    function getStep8CallbackUrlFromNavigation(details, signupTabId) {
+      if (!Number.isInteger(signupTabId) || !details) return '';
+      if (details.tabId !== signupTabId) return '';
+      if (details.frameId !== 0) return '';
+      return isLocalhostOAuthCallbackUrl(details.url) ? details.url : '';
+    }
+
+    function getStep8CallbackUrlFromTabUpdate(tabId, changeInfo, tab, signupTabId) {
+      if (!Number.isInteger(signupTabId) || tabId !== signupTabId) return '';
+      const candidates = [changeInfo?.url, tab?.url];
+      for (const candidate of candidates) {
+        if (isLocalhostOAuthCallbackUrl(candidate)) {
+          return candidate;
+        }
+      }
+      return '';
+    }
+
+    return {
+      getPanelMode,
+      getPanelModeLabel,
+      getStep8CallbackUrlFromNavigation,
+      getStep8CallbackUrlFromTabUpdate,
+      is163MailHost,
+      isLocalCpaUrl,
+      isLocalhostOAuthCallbackUrl,
+      isSignupEmailVerificationPageUrl,
+      isSignupEntryHost,
+      isSignupPageHost,
+      isSignupPasswordPageUrl,
+      matchesSourceUrlFamily,
+      normalizeSub2ApiUrl,
+      parseUrlSafely,
+      shouldBypassStep9ForLocalCpa,
+      shouldSkipLoginVerificationForCpaCallback,
+    };
+  }
+
+  return {
+    createNavigationUtils,
+  };
+});

+ 145 - 0
background/panel-bridge.js

@@ -0,0 +1,145 @@
+(function attachBackgroundPanelBridge(root, factory) {
+  root.MultiPageBackgroundPanelBridge = factory();
+})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundPanelBridgeModule() {
+  function createPanelBridge(deps = {}) {
+    const {
+      chrome,
+      addLog,
+      closeConflictingTabsForSource,
+      ensureContentScriptReadyOnTab,
+      getPanelMode,
+      normalizeSub2ApiUrl,
+      rememberSourceLastUrl,
+      sendToContentScript,
+      sendToContentScriptResilient,
+      waitForTabUrlFamily,
+      DEFAULT_SUB2API_GROUP_NAME,
+      SUB2API_STEP1_RESPONSE_TIMEOUT_MS,
+    } = deps;
+
+    async function requestOAuthUrlFromPanel(state, options = {}) {
+      if (getPanelMode(state) === 'sub2api') {
+        return requestSub2ApiOAuthUrl(state, options);
+      }
+      return requestCpaOAuthUrl(state, options);
+    }
+
+    async function requestCpaOAuthUrl(state, options = {}) {
+      const { logLabel = 'OAuth 刷新' } = options;
+      if (!state.vpsUrl) {
+        throw new Error('尚未配置 CPA 地址,请先在侧边栏填写。');
+      }
+
+      await addLog(`${logLabel}:正在打开 CPA 面板...`);
+
+      const injectFiles = ['content/activation-utils.js', 'content/utils.js', 'content/vps-panel.js'];
+      await closeConflictingTabsForSource('vps-panel', state.vpsUrl);
+
+      const tab = await chrome.tabs.create({ url: state.vpsUrl, active: true });
+      const tabId = tab.id;
+      await rememberSourceLastUrl('vps-panel', state.vpsUrl);
+
+      await addLog(`${logLabel}:CPA 面板已打开,正在等待页面进入目标地址...`);
+      const matchedTab = await waitForTabUrlFamily('vps-panel', tabId, state.vpsUrl, {
+        timeoutMs: 15000,
+        retryDelayMs: 400,
+      });
+      if (!matchedTab) {
+        await addLog(`${logLabel}:CPA 页面尚未完全进入目标地址,继续尝试连接内容脚本...`, 'warn');
+      }
+
+      await ensureContentScriptReadyOnTab('vps-panel', tabId, {
+        inject: injectFiles,
+        timeoutMs: 45000,
+        retryDelayMs: 900,
+        logMessage: `${logLabel}:CPA 面板仍在加载,正在重试连接内容脚本...`,
+      });
+
+      const result = await sendToContentScriptResilient('vps-panel', {
+        type: 'REQUEST_OAUTH_URL',
+        source: 'background',
+        payload: {
+          vpsPassword: state.vpsPassword,
+          logStep: 6,
+        },
+      }, {
+        timeoutMs: 30000,
+        retryDelayMs: 700,
+        logMessage: `${logLabel}:CPA 面板通信未就绪,正在等待页面恢复...`,
+      });
+
+      if (result?.error) {
+        throw new Error(result.error);
+      }
+      return result || {};
+    }
+
+    async function requestSub2ApiOAuthUrl(state, options = {}) {
+      const { logLabel = 'OAuth 刷新' } = options;
+      const sub2apiUrl = normalizeSub2ApiUrl(state.sub2apiUrl);
+      const groupName = (state.sub2apiGroupName || DEFAULT_SUB2API_GROUP_NAME).trim() || DEFAULT_SUB2API_GROUP_NAME;
+
+      if (!state.sub2apiEmail) {
+        throw new Error('尚未配置 SUB2API 登录邮箱,请先在侧边栏填写。');
+      }
+      if (!state.sub2apiPassword) {
+        throw new Error('尚未配置 SUB2API 登录密码,请先在侧边栏填写。');
+      }
+
+      await addLog(`${logLabel}:正在打开 SUB2API 后台...`);
+
+      const injectFiles = ['content/utils.js', 'content/sub2api-panel.js'];
+      await closeConflictingTabsForSource('sub2api-panel', sub2apiUrl);
+
+      const tab = await chrome.tabs.create({ url: sub2apiUrl, active: true });
+      const tabId = tab.id;
+      await rememberSourceLastUrl('sub2api-panel', sub2apiUrl);
+
+      await addLog(`${logLabel}:SUB2API 页面已打开,正在等待页面进入目标地址...`);
+      const matchedTab = await waitForTabUrlFamily('sub2api-panel', tabId, sub2apiUrl, {
+        timeoutMs: 15000,
+        retryDelayMs: 400,
+      });
+      if (!matchedTab) {
+        await addLog(`${logLabel}:SUB2API 页面尚未稳定,继续尝试连接内容脚本...`, 'warn');
+      }
+
+      await ensureContentScriptReadyOnTab('sub2api-panel', tabId, {
+        inject: injectFiles,
+        injectSource: 'sub2api-panel',
+        timeoutMs: 45000,
+        retryDelayMs: 900,
+        logMessage: `${logLabel}:SUB2API 页面仍在加载,正在重试连接内容脚本...`,
+      });
+
+      const result = await sendToContentScript('sub2api-panel', {
+        type: 'REQUEST_OAUTH_URL',
+        source: 'background',
+        payload: {
+          sub2apiUrl,
+          sub2apiEmail: state.sub2apiEmail,
+          sub2apiPassword: state.sub2apiPassword,
+          sub2apiGroupName: groupName,
+          logStep: 6,
+        },
+      }, {
+        responseTimeoutMs: SUB2API_STEP1_RESPONSE_TIMEOUT_MS,
+      });
+
+      if (result?.error) {
+        throw new Error(result.error);
+      }
+      return result || {};
+    }
+
+    return {
+      requestOAuthUrlFromPanel,
+      requestCpaOAuthUrl,
+      requestSub2ApiOAuthUrl,
+    };
+  }
+
+  return {
+    createPanelBridge,
+  };
+});

+ 191 - 0
background/signup-flow-helpers.js

@@ -0,0 +1,191 @@
+(function attachSignupFlowHelpers(root, factory) {
+  root.MultiPageSignupFlowHelpers = factory();
+})(typeof self !== 'undefined' ? self : globalThis, function createSignupFlowHelpersModule() {
+  function createSignupFlowHelpers(deps = {}) {
+    const {
+      buildGeneratedAliasEmail,
+      chrome,
+      ensureContentScriptReadyOnTab,
+      ensureHotmailAccountForFlow,
+      ensureLuckmailPurchaseForFlow,
+      isGeneratedAliasProvider,
+      isHotmailProvider,
+      isLuckmailProvider,
+      isSignupEmailVerificationPageUrl,
+      isSignupPasswordPageUrl,
+      reuseOrCreateTab,
+      sendToContentScriptResilient,
+      setEmailState,
+      SIGNUP_ENTRY_URL,
+      SIGNUP_PAGE_INJECT_FILES,
+      waitForTabUrlMatch,
+    } = deps;
+
+    async function openSignupEntryTab(step = 1) {
+      const tabId = await reuseOrCreateTab('signup-page', SIGNUP_ENTRY_URL, {
+        inject: SIGNUP_PAGE_INJECT_FILES,
+        injectSource: 'signup-page',
+      });
+
+      await ensureContentScriptReadyOnTab('signup-page', tabId, {
+        inject: SIGNUP_PAGE_INJECT_FILES,
+        injectSource: 'signup-page',
+        timeoutMs: 45000,
+        retryDelayMs: 900,
+        logMessage: `步骤 ${step}:ChatGPT 官网仍在加载,正在重试连接内容脚本...`,
+      });
+
+      return tabId;
+    }
+
+    async function ensureSignupEntryPageReady(step = 1) {
+      const tabId = await openSignupEntryTab(step);
+      const result = await sendToContentScriptResilient('signup-page', {
+        type: 'ENSURE_SIGNUP_ENTRY_READY',
+        step,
+        source: 'background',
+        payload: {},
+      }, {
+        timeoutMs: 20000,
+        retryDelayMs: 700,
+        logMessage: `步骤 ${step}:官网注册入口正在切换,等待页面恢复...`,
+      });
+
+      if (result?.error) {
+        throw new Error(result.error);
+      }
+
+      return { tabId, result: result || {} };
+    }
+
+    function resolveSignupPostEmailState(rawUrl) {
+      if (isSignupPasswordPageUrl(rawUrl)) {
+        return 'password_page';
+      }
+      if (isSignupEmailVerificationPageUrl(rawUrl)) {
+        return 'verification_page';
+      }
+      return '';
+    }
+
+    async function ensureSignupPostEmailPageReadyInTab(tabId, step = 2, options = {}) {
+      const { skipUrlWait = false } = options;
+      let landingUrl = '';
+      let landingState = '';
+
+      if (!skipUrlWait) {
+        const matchedTab = await waitForTabUrlMatch(tabId, (url) => Boolean(resolveSignupPostEmailState(url)), {
+          timeoutMs: 45000,
+          retryDelayMs: 300,
+        });
+        if (!matchedTab) {
+          throw new Error('等待邮箱提交后的页面跳转超时,请检查页面是否仍停留在邮箱输入页。');
+        }
+
+        landingUrl = matchedTab.url || '';
+        landingState = resolveSignupPostEmailState(landingUrl);
+      }
+
+      if (!landingState) {
+        try {
+          const currentTab = await chrome.tabs.get(tabId);
+          landingUrl = landingUrl || currentTab?.url || '';
+          landingState = resolveSignupPostEmailState(landingUrl);
+        } catch {
+          landingUrl = landingUrl || '';
+        }
+      }
+
+      if (!landingState) {
+        throw new Error(`邮箱提交后未能识别当前页面,既不是密码页也不是邮箱验证码页。URL: ${landingUrl || 'unknown'}`);
+      }
+
+      await ensureContentScriptReadyOnTab('signup-page', tabId, {
+        inject: SIGNUP_PAGE_INJECT_FILES,
+        injectSource: 'signup-page',
+        timeoutMs: 45000,
+        retryDelayMs: 900,
+        logMessage: landingState === 'verification_page'
+          ? `步骤 ${step}:邮箱验证码页仍在加载,正在等待页面恢复...`
+          : `步骤 ${step}:密码页仍在加载,正在重试连接内容脚本...`,
+      });
+
+      if (landingState === 'verification_page') {
+        return {
+          ready: true,
+          state: landingState,
+          url: landingUrl,
+        };
+      }
+
+      const result = await sendToContentScriptResilient('signup-page', {
+        type: 'ENSURE_SIGNUP_PASSWORD_PAGE_READY',
+        step,
+        source: 'background',
+        payload: {},
+      }, {
+        timeoutMs: 20000,
+        retryDelayMs: 700,
+        logMessage: `步骤 ${step}:认证页正在切换,等待密码页重新就绪...`,
+      });
+
+      if (result?.error) {
+        throw new Error(result.error);
+      }
+
+      return {
+        ...(result || {}),
+        ready: true,
+        state: landingState,
+        url: landingUrl,
+      };
+    }
+
+    async function ensureSignupPasswordPageReadyInTab(tabId, step = 2, options = {}) {
+      const result = await ensureSignupPostEmailPageReadyInTab(tabId, step, options);
+      if (result.state !== 'password_page') {
+        throw new Error(`当前页面不是密码页,实际落地为 ${result.state || 'unknown'}。URL: ${result.url || 'unknown'}`);
+      }
+      return result;
+    }
+
+    async function resolveSignupEmailForFlow(state) {
+      let resolvedEmail = state.email;
+      if (isHotmailProvider(state)) {
+        const account = await ensureHotmailAccountForFlow({
+          allowAllocate: true,
+          markUsed: true,
+          preferredAccountId: state.currentHotmailAccountId || null,
+        });
+        resolvedEmail = account.email;
+      } else if (isLuckmailProvider(state)) {
+        const purchase = await ensureLuckmailPurchaseForFlow({ allowReuse: true });
+        resolvedEmail = purchase.email_address;
+      } else if (isGeneratedAliasProvider(state)) {
+        resolvedEmail = buildGeneratedAliasEmail(state);
+      }
+
+      if (!resolvedEmail) {
+        throw new Error('缺少邮箱地址,请先在侧边栏粘贴邮箱。');
+      }
+
+      if (resolvedEmail !== state.email) {
+        await setEmailState(resolvedEmail);
+      }
+
+      return resolvedEmail;
+    }
+
+    return {
+      ensureSignupEntryPageReady,
+      ensureSignupPostEmailPageReadyInTab,
+      ensureSignupPasswordPageReadyInTab,
+      openSignupEntryTab,
+      resolveSignupEmailForFlow,
+    };
+  }
+
+  return {
+    createSignupFlowHelpers,
+  };
+});

+ 174 - 0
background/steps/confirm-oauth.js

@@ -0,0 +1,174 @@
+(function attachBackgroundStep8(root, factory) {
+  root.MultiPageBackgroundStep8 = factory();
+})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundStep8Module() {
+  function createStep8Executor(deps = {}) {
+    const {
+      addLog,
+      chrome,
+      cleanupStep8NavigationListeners,
+      clickWithDebugger,
+      completeStepFromBackground,
+      ensureStep8SignupPageReady,
+      getStep8CallbackUrlFromNavigation,
+      getStep8CallbackUrlFromTabUpdate,
+      getStep8EffectLabel,
+      getTabId,
+      isTabAlive,
+      prepareStep8DebuggerClick,
+      reloadStep8ConsentPage,
+      reuseOrCreateTab,
+      sleepWithStop,
+      STEP8_CLICK_RETRY_DELAY_MS,
+      STEP8_MAX_ROUNDS,
+      STEP8_READY_WAIT_TIMEOUT_MS,
+      STEP8_STRATEGIES,
+      throwIfStep8SettledOrStopped,
+      triggerStep8ContentStrategy,
+      waitForStep8ClickEffect,
+      waitForStep8Ready,
+      setWebNavListener,
+      setWebNavCommittedListener,
+      setStep8PendingReject,
+      setStep8TabUpdatedListener,
+    } = deps;
+
+    async function executeStep8(state) {
+      if (!state.oauthUrl) {
+        throw new Error('缺少登录用 OAuth 链接,请先完成步骤 6。');
+      }
+
+      await addLog('步骤 8:正在监听 localhost 回调地址...');
+
+      return new Promise((resolve, reject) => {
+        let resolved = false;
+        let signupTabId = null;
+
+        const cleanupListener = () => {
+          cleanupStep8NavigationListeners();
+          setStep8PendingReject(null);
+        };
+
+        const rejectStep8 = (error) => {
+          if (resolved) return;
+          resolved = true;
+          clearTimeout(timeout);
+          cleanupListener();
+          reject(error);
+        };
+
+        const finalizeStep8Callback = (callbackUrl) => {
+          if (resolved || !callbackUrl) return;
+
+          resolved = true;
+          cleanupListener();
+          clearTimeout(timeout);
+
+          addLog(`步骤 8:已捕获 localhost 地址:${callbackUrl}`, 'ok').then(() => {
+            return completeStepFromBackground(8, { localhostUrl: callbackUrl });
+          }).then(() => {
+            resolve();
+          }).catch((err) => {
+            reject(err);
+          });
+        };
+
+        const timeout = setTimeout(() => {
+          rejectStep8(new Error('120 秒内未捕获到 localhost 回调跳转,步骤 8 的点击可能被拦截了。'));
+        }, 120000);
+
+        setStep8PendingReject((error) => {
+          rejectStep8(error);
+        });
+
+        setWebNavListener((details) => {
+          const callbackUrl = getStep8CallbackUrlFromNavigation(details, signupTabId);
+          finalizeStep8Callback(callbackUrl);
+        });
+
+        setWebNavCommittedListener((details) => {
+          const callbackUrl = getStep8CallbackUrlFromNavigation(details, signupTabId);
+          finalizeStep8Callback(callbackUrl);
+        });
+
+        setStep8TabUpdatedListener((tabId, changeInfo, tab) => {
+          const callbackUrl = getStep8CallbackUrlFromTabUpdate(tabId, changeInfo, tab, signupTabId);
+          finalizeStep8Callback(callbackUrl);
+        });
+
+        (async () => {
+          try {
+            throwIfStep8SettledOrStopped(resolved);
+            signupTabId = await getTabId('signup-page');
+            throwIfStep8SettledOrStopped(resolved);
+
+            if (signupTabId && await isTabAlive('signup-page')) {
+              await chrome.tabs.update(signupTabId, { active: true });
+              await addLog('步骤 8:已切回认证页,正在准备调试器点击...');
+            } else {
+              signupTabId = await reuseOrCreateTab('signup-page', state.oauthUrl);
+              await addLog('步骤 8:已重新打开认证页,正在准备调试器点击...');
+            }
+
+            throwIfStep8SettledOrStopped(resolved);
+            chrome.webNavigation.onBeforeNavigate.addListener(deps.getWebNavListener());
+            chrome.webNavigation.onCommitted.addListener(deps.getWebNavCommittedListener());
+            chrome.tabs.onUpdated.addListener(deps.getStep8TabUpdatedListener());
+            await ensureStep8SignupPageReady(signupTabId, {
+              timeoutMs: 15000,
+              logMessage: '步骤 8:认证页内容脚本尚未就绪,正在等待页面恢复...',
+            });
+
+            for (let round = 1; round <= STEP8_MAX_ROUNDS && !resolved; round++) {
+              throwIfStep8SettledOrStopped(resolved);
+              const pageState = await waitForStep8Ready(signupTabId, STEP8_READY_WAIT_TIMEOUT_MS);
+              if (!pageState?.consentReady) {
+                await sleepWithStop(STEP8_CLICK_RETRY_DELAY_MS);
+                continue;
+              }
+
+              const strategy = STEP8_STRATEGIES[Math.min(round - 1, STEP8_STRATEGIES.length - 1)];
+
+              await addLog(`步骤 8:第 ${round}/${STEP8_MAX_ROUNDS} 轮尝试点击“继续”(${strategy.label})...`);
+
+              if (strategy.mode === 'debugger') {
+                const clickTarget = await prepareStep8DebuggerClick(signupTabId);
+                throwIfStep8SettledOrStopped(resolved);
+                await clickWithDebugger(signupTabId, clickTarget?.rect);
+              } else {
+                await triggerStep8ContentStrategy(signupTabId, strategy.strategy);
+              }
+
+              if (resolved) {
+                return;
+              }
+
+              const effect = await waitForStep8ClickEffect(signupTabId, pageState.url);
+              if (resolved) {
+                return;
+              }
+
+              if (effect.progressed) {
+                await addLog(`步骤 8:检测到本次点击已生效,${getStep8EffectLabel(effect)},继续等待 localhost 回调...`, 'info');
+                break;
+              }
+
+              if (round >= STEP8_MAX_ROUNDS) {
+                throw new Error(`步骤 8:连续 ${STEP8_MAX_ROUNDS} 轮点击“继续”后页面仍无反应。`);
+              }
+
+              await addLog(`步骤 8:${strategy.label} 本轮点击后页面无反应,正在刷新认证页后重试(下一轮 ${round + 1}/${STEP8_MAX_ROUNDS})...`, 'warn');
+              await reloadStep8ConsentPage(signupTabId);
+              await sleepWithStop(STEP8_CLICK_RETRY_DELAY_MS);
+            }
+          } catch (err) {
+            rejectStep8(err);
+          }
+        })();
+      });
+    }
+
+    return { executeStep8 };
+  }
+
+  return { createStep8Executor };
+});

+ 174 - 0
background/steps/fetch-login-code.js

@@ -0,0 +1,174 @@
+(function attachBackgroundStep7(root, factory) {
+  root.MultiPageBackgroundStep7 = factory();
+})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundStep7Module() {
+  function createStep7Executor(deps = {}) {
+    const {
+      addLog,
+      chrome,
+      CLOUDFLARE_TEMP_EMAIL_PROVIDER,
+      confirmCustomVerificationStepBypass,
+      ensureStep7VerificationPageReady,
+      executeStep6,
+      getPanelMode,
+      getMailConfig,
+      getState,
+      getTabId,
+      HOTMAIL_PROVIDER,
+      isTabAlive,
+      isVerificationMailPollingError,
+      LUCKMAIL_PROVIDER,
+      resolveVerificationStep,
+      reuseOrCreateTab,
+      setState,
+      setStepStatus,
+      shouldSkipLoginVerificationForCpaCallback,
+      shouldUseCustomRegistrationEmail,
+      sleepWithStop,
+      STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS,
+      STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS,
+      throwIfStopped,
+    } = deps;
+
+    async function runStep7Attempt(state) {
+      const mail = getMailConfig(state);
+      if (mail.error) throw new Error(mail.error);
+      const stepStartedAt = Date.now();
+      const authTabId = await getTabId('signup-page');
+
+      if (authTabId) {
+        await chrome.tabs.update(authTabId, { active: true });
+      } else {
+        if (!state.oauthUrl) {
+          throw new Error('缺少登录用 OAuth 链接,请先完成步骤 6。');
+        }
+        await reuseOrCreateTab('signup-page', state.oauthUrl);
+      }
+
+      throwIfStopped();
+      await ensureStep7VerificationPageReady();
+      await addLog('步骤 7:登录验证码页面已就绪,开始获取验证码。', 'info');
+
+      if (shouldUseCustomRegistrationEmail(state)) {
+        await confirmCustomVerificationStepBypass(7);
+        return;
+      }
+
+      throwIfStopped();
+      if (mail.provider === HOTMAIL_PROVIDER || mail.provider === LUCKMAIL_PROVIDER || mail.provider === CLOUDFLARE_TEMP_EMAIL_PROVIDER) {
+        await addLog(`步骤 7:正在通过 ${mail.label} 轮询验证码...`);
+      } else {
+        await addLog(`步骤 7:正在打开${mail.label}...`);
+
+        const alive = await isTabAlive(mail.source);
+        if (alive) {
+          if (mail.navigateOnReuse) {
+            await reuseOrCreateTab(mail.source, mail.url, {
+              inject: mail.inject,
+              injectSource: mail.injectSource,
+            });
+          } else {
+            const tabId = await getTabId(mail.source);
+            await chrome.tabs.update(tabId, { active: true });
+          }
+        } else {
+          await reuseOrCreateTab(mail.source, mail.url, {
+            inject: mail.inject,
+            injectSource: mail.injectSource,
+          });
+        }
+      }
+
+      const shouldRefreshOAuthBeforeSubmit = getPanelMode(state) === 'cpa';
+      let step6ReplayCompleted = false;
+
+      await resolveVerificationStep(7, state, mail, {
+        filterAfterTimestamp: mail.provider === HOTMAIL_PROVIDER ? undefined : Math.max(0, stepStartedAt - 60000),
+        requestFreshCodeFirst: false,
+        resendIntervalMs: (mail.provider === HOTMAIL_PROVIDER || mail.provider === '2925')
+          ? 0
+          : STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS,
+        beforeSubmit: shouldRefreshOAuthBeforeSubmit ? async (result) => {
+          if (step6ReplayCompleted) {
+            return;
+          }
+
+          step6ReplayCompleted = true;
+          await addLog(`步骤 7:已拿到登录验证码 ${result.code},先刷新 CPA OAuth 链接并重走步骤 6,再回填验证码。`, 'warn');
+          await rerunStep6ForStep7Recovery({
+            logMessage: '步骤 7:正在重新获取最新 CPA OAuth 链接,并快速重走步骤 6...',
+            skipPreLoginCleanup: true,
+            postStepDelayMs: 1200,
+          });
+          await ensureStep7VerificationPageReady();
+          await addLog('步骤 7:登录验证码页面已重新就绪,开始回填刚才获取到的验证码。', 'info');
+        } : undefined,
+      });
+    }
+
+    async function rerunStep6ForStep7Recovery(options = {}) {
+      const {
+        logMessage = '步骤 7:正在回到步骤 6,重新发起登录验证码流程...',
+        skipPreLoginCleanup = false,
+        postStepDelayMs = 3000,
+      } = options;
+      const currentState = await getState();
+      await addLog(logMessage, 'warn');
+      await executeStep6(currentState, { skipPreLoginCleanup });
+      if (postStepDelayMs > 0) {
+        await sleepWithStop(postStepDelayMs);
+      }
+    }
+
+    async function executeStep7(state) {
+      if (shouldSkipLoginVerificationForCpaCallback(state)) {
+        await setState({
+          lastLoginCode: null,
+          loginVerificationRequestedAt: null,
+        });
+        await setStepStatus(7, 'skipped');
+        await addLog('步骤 7:当前已选择“第六步回调”,本轮无需获取登录验证码。', 'warn');
+        return;
+      }
+
+      let currentState = state;
+      let mailPollingAttempt = 1;
+      let lastMailPollingError = null;
+
+      while (true) {
+        try {
+          await runStep7Attempt(currentState);
+          return;
+        } catch (err) {
+          if (!isVerificationMailPollingError(err)) {
+            throw err;
+          }
+
+          lastMailPollingError = err;
+          if (mailPollingAttempt >= STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS) {
+            break;
+          }
+
+          mailPollingAttempt += 1;
+          await addLog(
+            `步骤 7:检测到邮箱轮询类失败,准备从步骤 6 重新开始(${mailPollingAttempt}/${STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS})...`,
+            'warn'
+          );
+          await rerunStep6ForStep7Recovery();
+          currentState = await getState();
+        }
+      }
+
+      if (lastMailPollingError) {
+        throw new Error(
+          `步骤 7:登录验证码流程在 ${STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS} 轮邮箱轮询恢复后仍未成功。最后一次原因:${lastMailPollingError.message}`
+        );
+      }
+
+      throw new Error('步骤 7:登录验证码流程未成功完成。');
+    }
+
+    return { executeStep7 };
+  }
+
+  return { createStep7Executor };
+});

+ 102 - 0
background/steps/fetch-signup-code.js

@@ -0,0 +1,102 @@
+(function attachBackgroundStep4(root, factory) {
+  root.MultiPageBackgroundStep4 = factory();
+})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundStep4Module() {
+  function createStep4Executor(deps = {}) {
+    const {
+      addLog,
+      chrome,
+      completeStepFromBackground,
+      confirmCustomVerificationStepBypass,
+      getMailConfig,
+      getTabId,
+      HOTMAIL_PROVIDER,
+      isTabAlive,
+      LUCKMAIL_PROVIDER,
+      CLOUDFLARE_TEMP_EMAIL_PROVIDER,
+      resolveVerificationStep,
+      reuseOrCreateTab,
+      sendToContentScriptResilient,
+      shouldUseCustomRegistrationEmail,
+      STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS,
+      throwIfStopped,
+    } = deps;
+
+    async function executeStep4(state) {
+      const mail = getMailConfig(state);
+      if (mail.error) throw new Error(mail.error);
+      const stepStartedAt = Date.now();
+      const signupTabId = await getTabId('signup-page');
+      if (!signupTabId) {
+        throw new Error('认证页面标签页已关闭,无法继续步骤 4。');
+      }
+
+      await chrome.tabs.update(signupTabId, { active: true });
+      throwIfStopped();
+      await addLog('步骤 4:正在确认注册验证码页面是否就绪,必要时自动恢复密码页超时报错...');
+      const prepareResult = await sendToContentScriptResilient(
+        'signup-page',
+        {
+          type: 'PREPARE_SIGNUP_VERIFICATION',
+          step: 4,
+          source: 'background',
+          payload: { password: state.password || state.customPassword || '' },
+        },
+        {
+          timeoutMs: 30000,
+          retryDelayMs: 700,
+          logMessage: '步骤 4:认证页正在切换,等待页面重新就绪后继续检测...',
+        }
+      );
+
+      if (prepareResult && prepareResult.error) {
+        throw new Error(prepareResult.error);
+      }
+      if (prepareResult?.alreadyVerified) {
+        await completeStepFromBackground(4, {});
+        return;
+      }
+
+      if (shouldUseCustomRegistrationEmail(state)) {
+        await confirmCustomVerificationStepBypass(4);
+        return;
+      }
+
+      throwIfStopped();
+      if (mail.provider === HOTMAIL_PROVIDER || mail.provider === LUCKMAIL_PROVIDER || mail.provider === CLOUDFLARE_TEMP_EMAIL_PROVIDER) {
+        await addLog(`步骤 4:正在通过 ${mail.label} 轮询验证码...`);
+      } else {
+        await addLog(`步骤 4:正在打开${mail.label}...`);
+
+        const alive = await isTabAlive(mail.source);
+        if (alive) {
+          if (mail.navigateOnReuse) {
+            await reuseOrCreateTab(mail.source, mail.url, {
+              inject: mail.inject,
+              injectSource: mail.injectSource,
+            });
+          } else {
+            const tabId = await getTabId(mail.source);
+            await chrome.tabs.update(tabId, { active: true });
+          }
+        } else {
+          await reuseOrCreateTab(mail.source, mail.url, {
+            inject: mail.inject,
+            injectSource: mail.injectSource,
+          });
+        }
+      }
+
+      await resolveVerificationStep(4, state, mail, {
+        filterAfterTimestamp: mail.provider === HOTMAIL_PROVIDER ? undefined : stepStartedAt,
+        requestFreshCodeFirst: mail.provider === HOTMAIL_PROVIDER ? false : true,
+        resendIntervalMs: (mail.provider === HOTMAIL_PROVIDER || mail.provider === '2925')
+          ? 0
+          : STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS,
+      });
+    }
+
+    return { executeStep4 };
+  }
+
+  return { createStep4Executor };
+});

+ 60 - 0
background/steps/fill-password.js

@@ -0,0 +1,60 @@
+(function attachBackgroundStep3(root, factory) {
+  root.MultiPageBackgroundStep3 = factory();
+})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundStep3Module() {
+  function createStep3Executor(deps = {}) {
+    const {
+      addLog,
+      chrome,
+      ensureContentScriptReadyOnTab,
+      generatePassword,
+      getTabId,
+      isTabAlive,
+      sendToContentScript,
+      setPasswordState,
+      setState,
+      SIGNUP_PAGE_INJECT_FILES,
+    } = deps;
+
+    async function executeStep3(state) {
+      const resolvedEmail = state.email;
+      if (!resolvedEmail) {
+        throw new Error('缺少邮箱地址,请先完成步骤 2。');
+      }
+
+      const signupTabId = await getTabId('signup-page');
+      if (!signupTabId || !(await isTabAlive('signup-page'))) {
+        throw new Error('认证页面标签页已关闭,请先重新完成步骤 2。');
+      }
+
+      const password = state.customPassword || generatePassword();
+      await setPasswordState(password);
+
+      const accounts = state.accounts || [];
+      accounts.push({ email: resolvedEmail, password, createdAt: new Date().toISOString() });
+      await setState({ accounts });
+
+      await chrome.tabs.update(signupTabId, { active: true });
+      await ensureContentScriptReadyOnTab('signup-page', signupTabId, {
+        inject: SIGNUP_PAGE_INJECT_FILES,
+        injectSource: 'signup-page',
+        timeoutMs: 45000,
+        retryDelayMs: 900,
+        logMessage: '步骤 3:密码页内容脚本未就绪,正在等待页面恢复...',
+      });
+
+      await addLog(
+        `步骤 3:正在填写密码,邮箱为 ${resolvedEmail},密码为${state.customPassword ? '自定义' : '自动生成'}(${password.length} 位)`
+      );
+      await sendToContentScript('signup-page', {
+        type: 'EXECUTE_STEP',
+        step: 3,
+        source: 'background',
+        payload: { email: resolvedEmail, password },
+      });
+    }
+
+    return { executeStep3 };
+  }
+
+  return { createStep3Executor };
+});

+ 30 - 0
background/steps/fill-profile.js

@@ -0,0 +1,30 @@
+(function attachBackgroundStep5(root, factory) {
+  root.MultiPageBackgroundStep5 = factory();
+})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundStep5Module() {
+  function createStep5Executor(deps = {}) {
+    const {
+      addLog,
+      generateRandomBirthday,
+      generateRandomName,
+      sendToContentScript,
+    } = deps;
+
+    async function executeStep5() {
+      const { firstName, lastName } = generateRandomName();
+      const { year, month, day } = generateRandomBirthday();
+
+      await addLog(`步骤 5:已生成姓名 ${firstName} ${lastName},生日 ${year}-${month}-${day}`);
+
+      await sendToContentScript('signup-page', {
+        type: 'EXECUTE_STEP',
+        step: 5,
+        source: 'background',
+        payload: { firstName, lastName, year, month, day },
+      });
+    }
+
+    return { executeStep5 };
+  }
+
+  return { createStep5Executor };
+});

+ 110 - 0
background/steps/oauth-login.js

@@ -0,0 +1,110 @@
+(function attachBackgroundStep6(root, factory) {
+  root.MultiPageBackgroundStep6 = factory();
+})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundStep6Module() {
+  function createStep6Executor(deps = {}) {
+    const {
+      addLog,
+      completeStepFromBackground,
+      getErrorMessage,
+      getLoginAuthStateLabel,
+      getState,
+      isStep6RecoverableResult,
+      isStep6SuccessResult,
+      refreshOAuthUrlBeforeStep6,
+      reuseOrCreateTab,
+      runPreStep6CookieCleanup,
+      sendToContentScriptResilient,
+      shouldSkipLoginVerificationForCpaCallback,
+      skipLoginVerificationStepsForCpaCallback,
+      STEP6_MAX_ATTEMPTS,
+      throwIfStopped,
+    } = deps;
+
+    async function executeStep6(state, options = {}) {
+      const { skipPreLoginCleanup = false } = options;
+      if (shouldSkipLoginVerificationForCpaCallback(state)) {
+        await skipLoginVerificationStepsForCpaCallback();
+        return;
+      }
+      if (!state.email) {
+        throw new Error('缺少邮箱地址,请先完成步骤 3。');
+      }
+
+      if (!skipPreLoginCleanup) {
+        await runPreStep6CookieCleanup();
+      }
+
+      let attempt = 0;
+      let lastError = null;
+
+      while (attempt < STEP6_MAX_ATTEMPTS) {
+        throwIfStopped();
+        attempt += 1;
+        try {
+          const currentState = attempt === 1 ? state : await getState();
+          const password = currentState.password || currentState.customPassword || '';
+          const oauthUrl = await refreshOAuthUrlBeforeStep6(currentState);
+
+          if (attempt === 1) {
+            await addLog('步骤 6:正在打开最新 OAuth 链接并登录...');
+          } else {
+            await addLog(`步骤 6:上一轮失败后,正在进行第 ${attempt} 次尝试(最多 ${STEP6_MAX_ATTEMPTS} 次)...`, 'warn');
+          }
+
+          await reuseOrCreateTab('signup-page', oauthUrl);
+
+          const result = await sendToContentScriptResilient(
+            'signup-page',
+            {
+              type: 'EXECUTE_STEP',
+              step: 6,
+              source: 'background',
+              payload: {
+                email: currentState.email,
+                password,
+              },
+            },
+            {
+              timeoutMs: 180000,
+              retryDelayMs: 700,
+              logMessage: '步骤 6:认证页正在切换,等待页面重新就绪后继续登录...',
+            }
+          );
+
+          if (result?.error) {
+            throw new Error(result.error);
+          }
+
+          if (isStep6SuccessResult(result)) {
+            await completeStepFromBackground(6, {
+              loginVerificationRequestedAt: result.loginVerificationRequestedAt || null,
+            });
+            return;
+          }
+
+          if (isStep6RecoverableResult(result)) {
+            const reasonMessage = result.message
+              || `当前停留在${getLoginAuthStateLabel(result.state)},准备重新执行步骤 6。`;
+            throw new Error(reasonMessage);
+          }
+
+          throw new Error('步骤 6:认证页未返回可识别的登录结果。');
+        } catch (err) {
+          throwIfStopped(err);
+          lastError = err;
+          if (attempt >= STEP6_MAX_ATTEMPTS) {
+            break;
+          }
+
+          await addLog(`步骤 6:第 ${attempt} 次尝试失败,原因:${getErrorMessage(err)};准备重试...`, 'warn');
+        }
+      }
+
+      throw new Error(`步骤 6:判断失败后已重试 ${STEP6_MAX_ATTEMPTS - 1} 次,仍未成功。最后原因:${getErrorMessage(lastError)}`);
+    }
+
+    return { executeStep6 };
+  }
+
+  return { createStep6Executor };
+});

+ 21 - 0
background/steps/open-chatgpt.js

@@ -0,0 +1,21 @@
+(function attachBackgroundStep1(root, factory) {
+  root.MultiPageBackgroundStep1 = factory();
+})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundStep1Module() {
+  function createStep1Executor(deps = {}) {
+    const {
+      addLog,
+      completeStepFromBackground,
+      ensureSignupEntryPageReady,
+    } = deps;
+
+    async function executeStep1() {
+      await addLog('步骤 1:正在打开 ChatGPT 官网...');
+      await ensureSignupEntryPageReady(1);
+      await completeStepFromBackground(1, {});
+    }
+
+    return { executeStep1 };
+  }
+
+  return { createStep1Executor };
+});

+ 167 - 0
background/steps/platform-verify.js

@@ -0,0 +1,167 @@
+(function attachBackgroundStep9(root, factory) {
+  root.MultiPageBackgroundStep9 = factory();
+})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundStep9Module() {
+  function createStep9Executor(deps = {}) {
+    const {
+      addLog,
+      chrome,
+      closeConflictingTabsForSource,
+      completeStepFromBackground,
+      ensureContentScriptReadyOnTab,
+      getPanelMode,
+      getTabId,
+      isLocalhostOAuthCallbackUrl,
+      isTabAlive,
+      normalizeSub2ApiUrl,
+      rememberSourceLastUrl,
+      reuseOrCreateTab,
+      sendToContentScript,
+      sendToContentScriptResilient,
+      shouldBypassStep9ForLocalCpa,
+      SUB2API_STEP9_RESPONSE_TIMEOUT_MS,
+    } = deps;
+
+    async function executeStep9(state) {
+      if (getPanelMode(state) === 'sub2api') {
+        return executeSub2ApiStep9(state);
+      }
+      return executeCpaStep9(state);
+    }
+
+    async function executeCpaStep9(state) {
+      if (state.localhostUrl && !isLocalhostOAuthCallbackUrl(state.localhostUrl)) {
+        throw new Error('步骤 8 捕获到的 localhost OAuth 回调地址无效,请重新执行步骤 8。');
+      }
+      if (!state.localhostUrl) {
+        throw new Error('缺少 localhost 回调地址,请先完成步骤 8。');
+      }
+      if (!state.vpsUrl) {
+        throw new Error('尚未填写 CPA 地址,请先在侧边栏输入。');
+      }
+
+      if (shouldBypassStep9ForLocalCpa(state)) {
+        await addLog('步骤 9:检测到本地 CPA,且当前策略为“跳过第9步”,本轮不再重复提交回调地址。', 'info');
+        await completeStepFromBackground(9, {
+          localhostUrl: state.localhostUrl,
+          verifiedStatus: 'local-auto',
+        });
+        return;
+      }
+
+      await addLog('步骤 9:正在打开 CPA 面板...');
+
+      const injectFiles = ['content/activation-utils.js', 'content/utils.js', 'content/vps-panel.js'];
+      let tabId = await getTabId('vps-panel');
+      const alive = tabId && await isTabAlive('vps-panel');
+
+      if (!alive) {
+        tabId = await reuseOrCreateTab('vps-panel', state.vpsUrl, {
+          inject: injectFiles,
+          reloadIfSameUrl: true,
+        });
+      } else {
+        await closeConflictingTabsForSource('vps-panel', state.vpsUrl, { excludeTabIds: [tabId] });
+        await chrome.tabs.update(tabId, { active: true });
+        await rememberSourceLastUrl('vps-panel', state.vpsUrl);
+      }
+
+      await ensureContentScriptReadyOnTab('vps-panel', tabId, {
+        inject: injectFiles,
+        timeoutMs: 45000,
+        retryDelayMs: 900,
+        logMessage: '步骤 9:CPA 面板仍在加载,正在重试连接...',
+      });
+
+      await addLog('步骤 9:正在填写回调地址...');
+      const result = await sendToContentScriptResilient('vps-panel', {
+        type: 'EXECUTE_STEP',
+        step: 9,
+        source: 'background',
+        payload: { localhostUrl: state.localhostUrl, vpsPassword: state.vpsPassword },
+      }, {
+        timeoutMs: 30000,
+        retryDelayMs: 700,
+        logMessage: '步骤 9:CPA 面板通信未就绪,正在等待页面恢复...',
+      });
+
+      if (result?.error) {
+        throw new Error(result.error);
+      }
+    }
+
+    async function executeSub2ApiStep9(state) {
+      if (state.localhostUrl && !isLocalhostOAuthCallbackUrl(state.localhostUrl)) {
+        throw new Error('步骤 8 捕获到的 localhost OAuth 回调地址无效,请重新执行步骤 8。');
+      }
+      if (!state.localhostUrl) {
+        throw new Error('缺少 localhost 回调地址,请先完成步骤 8。');
+      }
+      if (!state.sub2apiSessionId) {
+        throw new Error('缺少 SUB2API 会话信息,请重新执行步骤 1。');
+      }
+      if (!state.sub2apiEmail) {
+        throw new Error('尚未配置 SUB2API 登录邮箱,请先在侧边栏填写。');
+      }
+      if (!state.sub2apiPassword) {
+        throw new Error('尚未配置 SUB2API 登录密码,请先在侧边栏填写。');
+      }
+
+      const sub2apiUrl = normalizeSub2ApiUrl(state.sub2apiUrl);
+      const injectFiles = ['content/utils.js', 'content/sub2api-panel.js'];
+
+      await addLog('步骤 9:正在打开 SUB2API 后台...');
+
+      let tabId = await getTabId('sub2api-panel');
+      const alive = tabId && await isTabAlive('sub2api-panel');
+
+      if (!alive) {
+        tabId = await reuseOrCreateTab('sub2api-panel', sub2apiUrl, {
+          inject: injectFiles,
+          injectSource: 'sub2api-panel',
+          reloadIfSameUrl: true,
+        });
+      } else {
+        await closeConflictingTabsForSource('sub2api-panel', sub2apiUrl, { excludeTabIds: [tabId] });
+        await chrome.tabs.update(tabId, { active: true });
+        await rememberSourceLastUrl('sub2api-panel', sub2apiUrl);
+      }
+
+      await ensureContentScriptReadyOnTab('sub2api-panel', tabId, {
+        inject: injectFiles,
+        injectSource: 'sub2api-panel',
+      });
+
+      await addLog('步骤 9:正在向 SUB2API 提交回调并创建账号...');
+      const result = await sendToContentScript('sub2api-panel', {
+        type: 'EXECUTE_STEP',
+        step: 9,
+        source: 'background',
+        payload: {
+          localhostUrl: state.localhostUrl,
+          sub2apiUrl,
+          sub2apiEmail: state.sub2apiEmail,
+          sub2apiPassword: state.sub2apiPassword,
+          sub2apiGroupName: state.sub2apiGroupName,
+          sub2apiSessionId: state.sub2apiSessionId,
+          sub2apiOAuthState: state.sub2apiOAuthState,
+          sub2apiGroupId: state.sub2apiGroupId,
+          sub2apiDraftName: state.sub2apiDraftName,
+        },
+      }, {
+        responseTimeoutMs: SUB2API_STEP9_RESPONSE_TIMEOUT_MS,
+      });
+
+      if (result?.error) {
+        throw new Error(result.error);
+      }
+    }
+
+    return {
+      executeCpaStep9,
+      executeStep9,
+      executeSub2ApiStep9,
+    };
+  }
+
+  return { createStep9Executor };
+});

+ 48 - 0
background/steps/registry.js

@@ -0,0 +1,48 @@
+(function attachBackgroundStepRegistry(root, factory) {
+  root.MultiPageBackgroundStepRegistry = factory();
+})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundStepRegistryModule() {
+  function createStepRegistry(definitions = []) {
+    const ordered = (Array.isArray(definitions) ? definitions : [])
+      .map((definition) => ({
+        id: Number(definition?.id),
+        order: Number(definition?.order),
+        key: String(definition?.key || '').trim(),
+        title: String(definition?.title || '').trim(),
+        execute: definition?.execute,
+      }))
+      .filter((definition) => Number.isFinite(definition.id) && typeof definition.execute === 'function')
+      .sort((left, right) => {
+        const leftOrder = Number.isFinite(left.order) ? left.order : left.id;
+        const rightOrder = Number.isFinite(right.order) ? right.order : right.id;
+        return leftOrder - rightOrder;
+      });
+
+    const byId = new Map(ordered.map((definition) => [definition.id, definition]));
+
+    function getStepDefinition(step) {
+      return byId.get(Number(step)) || null;
+    }
+
+    function getOrderedSteps() {
+      return ordered.slice();
+    }
+
+    function executeStep(step, state) {
+      const definition = getStepDefinition(step);
+      if (!definition) {
+        throw new Error(`未知步骤:${step}`);
+      }
+      return definition.execute(state);
+    }
+
+    return {
+      executeStep,
+      getOrderedSteps,
+      getStepDefinition,
+    };
+  }
+
+  return {
+    createStepRegistry,
+  };
+});

+ 72 - 0
background/steps/submit-signup-email.js

@@ -0,0 +1,72 @@
+(function attachBackgroundStep2(root, factory) {
+  root.MultiPageBackgroundStep2 = factory();
+})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundStep2Module() {
+  function createStep2Executor(deps = {}) {
+    const {
+      addLog,
+      chrome,
+      completeStepFromBackground,
+      ensureContentScriptReadyOnTab,
+      ensureSignupEntryPageReady,
+      ensureSignupPostEmailPageReadyInTab,
+      getTabId,
+      isTabAlive,
+      resolveSignupEmailForFlow,
+      sendToContentScriptResilient,
+      SIGNUP_PAGE_INJECT_FILES,
+    } = deps;
+
+    async function executeStep2(state) {
+      const resolvedEmail = await resolveSignupEmailForFlow(state);
+
+      let signupTabId = await getTabId('signup-page');
+      if (!signupTabId || !(await isTabAlive('signup-page'))) {
+        await addLog('步骤 2:未发现可用的注册页标签,正在重新打开 ChatGPT 官网...', 'warn');
+        signupTabId = (await ensureSignupEntryPageReady(2)).tabId;
+      } else {
+        await chrome.tabs.update(signupTabId, { active: true });
+        await ensureContentScriptReadyOnTab('signup-page', signupTabId, {
+          inject: SIGNUP_PAGE_INJECT_FILES,
+          injectSource: 'signup-page',
+          timeoutMs: 45000,
+          retryDelayMs: 900,
+          logMessage: '步骤 2:注册入口页内容脚本未就绪,正在等待页面恢复...',
+        });
+      }
+
+      const step2Result = await sendToContentScriptResilient('signup-page', {
+        type: 'EXECUTE_STEP',
+        step: 2,
+        source: 'background',
+        payload: { email: resolvedEmail },
+      }, {
+        timeoutMs: 20000,
+        retryDelayMs: 700,
+        logMessage: '步骤 2:官网注册入口正在切换,等待页面恢复后继续输入邮箱...',
+      });
+
+      if (step2Result?.error) {
+        throw new Error(step2Result.error);
+      }
+
+      if (!step2Result?.alreadyOnPasswordPage) {
+        await addLog(`步骤 2:邮箱 ${resolvedEmail} 已提交,正在等待页面加载并确认下一步入口...`);
+      }
+
+      const landingResult = await ensureSignupPostEmailPageReadyInTab(signupTabId, 2, {
+        skipUrlWait: Boolean(step2Result?.alreadyOnPasswordPage),
+      });
+
+      await completeStepFromBackground(2, {
+        email: resolvedEmail,
+        nextSignupState: landingResult?.state || 'password_page',
+        nextSignupUrl: landingResult?.url || step2Result?.url || '',
+        skippedPasswordStep: landingResult?.state === 'verification_page',
+      });
+    }
+
+    return { executeStep2 };
+  }
+
+  return { createStep2Executor };
+});

+ 654 - 0
background/tab-runtime.js

@@ -0,0 +1,654 @@
+(function attachBackgroundTabRuntime(root, factory) {
+  root.MultiPageBackgroundTabRuntime = factory();
+})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundTabRuntimeModule() {
+  function createTabRuntime(deps = {}) {
+    const {
+      addLog,
+      chrome,
+      getSourceLabel,
+      getState,
+      isLocalhostOAuthCallbackUrl,
+      isRetryableContentScriptTransportError,
+      LOG_PREFIX,
+      matchesSourceUrlFamily,
+      setState,
+      STOP_ERROR_MESSAGE,
+      throwIfStopped,
+    } = deps;
+
+    const pendingCommands = new Map();
+
+    async function getTabRegistry() {
+      const state = await getState();
+      return state.tabRegistry || {};
+    }
+
+    async function registerTab(source, tabId) {
+      const registry = await getTabRegistry();
+      registry[source] = { tabId, ready: true };
+      await setState({ tabRegistry: registry });
+      console.log(LOG_PREFIX, `Tab registered: ${source} -> ${tabId}`);
+    }
+
+    async function isTabAlive(source) {
+      const registry = await getTabRegistry();
+      const entry = registry[source];
+      if (!entry) return false;
+      try {
+        await chrome.tabs.get(entry.tabId);
+        return true;
+      } catch {
+        registry[source] = null;
+        await setState({ tabRegistry: registry });
+        return false;
+      }
+    }
+
+    async function getTabId(source) {
+      const registry = await getTabRegistry();
+      return registry[source]?.tabId || null;
+    }
+
+    async function rememberSourceLastUrl(source, url) {
+      if (!source || !url) return;
+      const state = await getState();
+      const sourceLastUrls = { ...(state.sourceLastUrls || {}) };
+      sourceLastUrls[source] = url;
+      await setState({ sourceLastUrls });
+    }
+
+    async function closeConflictingTabsForSource(source, currentUrl, options = {}) {
+      const { excludeTabIds = [] } = options;
+      const excluded = new Set(excludeTabIds.filter((id) => Number.isInteger(id)));
+      const state = await getState();
+      const lastUrl = state.sourceLastUrls?.[source];
+      const referenceUrls = [currentUrl, lastUrl].filter(Boolean);
+
+      if (!referenceUrls.length) return;
+
+      const tabs = await chrome.tabs.query({});
+      const matchedIds = tabs
+        .filter((tab) => Number.isInteger(tab.id) && !excluded.has(tab.id))
+        .filter((tab) => referenceUrls.some((refUrl) => matchesSourceUrlFamily(source, tab.url, refUrl)))
+        .map((tab) => tab.id);
+
+      if (!matchedIds.length) return;
+
+      await chrome.tabs.remove(matchedIds).catch(() => { });
+
+      const registry = await getTabRegistry();
+      if (registry[source]?.tabId && matchedIds.includes(registry[source].tabId)) {
+        registry[source] = null;
+        await setState({ tabRegistry: registry });
+      }
+
+      await addLog(`已关闭 ${matchedIds.length} 个旧的${getSourceLabel(source)}标签页。`, 'info');
+    }
+
+    function isLocalhostOAuthCallbackTabMatch(callbackUrl, candidateUrl) {
+      if (!isLocalhostOAuthCallbackUrl(callbackUrl) || !isLocalhostOAuthCallbackUrl(candidateUrl)) {
+        return false;
+      }
+
+      const callback = new URL(callbackUrl);
+      const candidate = new URL(candidateUrl);
+      return callback.origin === candidate.origin
+        && callback.pathname === candidate.pathname
+        && callback.searchParams.get('code') === candidate.searchParams.get('code')
+        && callback.searchParams.get('state') === candidate.searchParams.get('state');
+    }
+
+    async function closeLocalhostCallbackTabs(callbackUrl, options = {}) {
+      if (!isLocalhostOAuthCallbackUrl(callbackUrl)) return 0;
+
+      const { excludeTabIds = [] } = options;
+      const excluded = new Set(excludeTabIds.filter((id) => Number.isInteger(id)));
+      const tabs = await chrome.tabs.query({});
+      const matchedIds = tabs
+        .filter((tab) => Number.isInteger(tab.id) && !excluded.has(tab.id))
+        .filter((tab) => isLocalhostOAuthCallbackTabMatch(callbackUrl, tab.url))
+        .map((tab) => tab.id);
+
+      if (!matchedIds.length) return 0;
+
+      await chrome.tabs.remove(matchedIds).catch(() => { });
+
+      const registry = await getTabRegistry();
+      if (registry['signup-page']?.tabId && matchedIds.includes(registry['signup-page'].tabId)) {
+        registry['signup-page'] = null;
+        await setState({ tabRegistry: registry });
+      }
+
+      await addLog(`已关闭 ${matchedIds.length} 个匹配当前 OAuth callback 的 localhost 残留标签页。`, 'info');
+      return matchedIds.length;
+    }
+
+    function buildLocalhostCleanupPrefix(rawUrl) {
+      if (!isLocalhostOAuthCallbackUrl(rawUrl)) return '';
+      const parsed = new URL(rawUrl);
+      const segments = parsed.pathname.split('/').filter(Boolean);
+      if (!segments.length) return parsed.origin;
+      return `${parsed.origin}/${segments[0]}`;
+    }
+
+    async function closeTabsByUrlPrefix(prefix, options = {}) {
+      if (!prefix) return 0;
+
+      const { excludeTabIds = [], excludeUrls = [], excludeLocalhostCallbacks = false } = options;
+      const excluded = new Set(excludeTabIds.filter((id) => Number.isInteger(id)));
+      const excludedUrls = new Set((Array.isArray(excludeUrls) ? excludeUrls : []).filter(Boolean));
+      const tabs = await chrome.tabs.query({});
+      const matchedIds = tabs
+        .filter((tab) => Number.isInteger(tab.id) && !excluded.has(tab.id))
+        .filter((tab) => typeof tab.url === 'string' && !excludedUrls.has(tab.url))
+        .filter((tab) => !(excludeLocalhostCallbacks && isLocalhostOAuthCallbackUrl(tab.url)))
+        .filter((tab) => typeof tab.url === 'string' && tab.url.startsWith(prefix))
+        .filter((tab) => !isLocalhostOAuthCallbackUrl(tab.url))
+        .map((tab) => tab.id);
+
+      if (!matchedIds.length) return 0;
+
+      await chrome.tabs.remove(matchedIds).catch(() => { });
+      await addLog(`已关闭 ${matchedIds.length} 个匹配 ${prefix} 的 localhost 残留标签页。`, 'info');
+      return matchedIds.length;
+    }
+
+    async function pingContentScriptOnTab(tabId) {
+      if (!Number.isInteger(tabId)) return null;
+      try {
+        return await chrome.tabs.sendMessage(tabId, {
+          type: 'PING',
+          source: 'background',
+          payload: {},
+        });
+      } catch {
+        return null;
+      }
+    }
+
+    async function waitForTabUrlFamily(source, tabId, referenceUrl, options = {}) {
+      const { timeoutMs = 15000, retryDelayMs = 400 } = options;
+      const start = Date.now();
+
+      while (Date.now() - start < timeoutMs) {
+        try {
+          const tab = await chrome.tabs.get(tabId);
+          if (matchesSourceUrlFamily(source, tab.url, referenceUrl)) {
+            return tab;
+          }
+        } catch {
+          return null;
+        }
+        await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
+      }
+      return null;
+    }
+
+    async function waitForTabUrlMatch(tabId, matcher, options = {}) {
+      const { timeoutMs = 15000, retryDelayMs = 400 } = options;
+      const start = Date.now();
+
+      while (Date.now() - start < timeoutMs) {
+        try {
+          const tab = await chrome.tabs.get(tabId);
+          if (matcher(tab.url || '', tab)) {
+            return tab;
+          }
+        } catch {
+          return null;
+        }
+        await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
+      }
+      return null;
+    }
+
+    async function waitForTabComplete(tabId, options = {}) {
+      const { timeoutMs = 15000, retryDelayMs = 300 } = options;
+      const start = Date.now();
+
+      while (Date.now() - start < timeoutMs) {
+        try {
+          const tab = await chrome.tabs.get(tabId);
+          if (tab?.status === 'complete') {
+            return tab;
+          }
+        } catch {
+          return null;
+        }
+        await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
+      }
+
+      try {
+        return await chrome.tabs.get(tabId);
+      } catch {
+        return null;
+      }
+    }
+
+    async function ensureContentScriptReadyOnTab(source, tabId, options = {}) {
+      const {
+        inject = null,
+        injectSource = null,
+        timeoutMs = 30000,
+        retryDelayMs = 700,
+        logMessage = '',
+      } = options;
+
+      const start = Date.now();
+      let lastError = null;
+      let logged = false;
+      let attempt = 0;
+
+      console.log(
+        LOG_PREFIX,
+        `[ensureContentScriptReadyOnTab] start ${source} tab=${tabId}, timeout=${timeoutMs}ms, inject=${Array.isArray(inject) ? inject.join(',') : 'none'}`
+      );
+
+      while (Date.now() - start < timeoutMs) {
+        attempt += 1;
+        const pong = await pingContentScriptOnTab(tabId);
+        if (pong?.ok && (!pong.source || pong.source === source)) {
+          console.log(LOG_PREFIX, `[ensureContentScriptReadyOnTab] ready ${source} tab=${tabId} on attempt ${attempt} after ${Date.now() - start}ms`);
+          await registerTab(source, tabId);
+          return;
+        }
+
+        if (!inject || !inject.length) {
+          throw new Error(`${getSourceLabel(source)} 内容脚本未就绪,且未提供可用的注入文件。`);
+        }
+
+        const registry = await getTabRegistry();
+        if (registry[source]) {
+          registry[source].ready = false;
+          await setState({ tabRegistry: registry });
+        }
+
+        try {
+          if (injectSource) {
+            await chrome.scripting.executeScript({
+              target: { tabId },
+              func: (injectedSource) => {
+                window.__MULTIPAGE_SOURCE = injectedSource;
+              },
+              args: [injectSource],
+            });
+          }
+
+          await chrome.scripting.executeScript({
+            target: { tabId },
+            files: inject,
+          });
+        } catch (err) {
+          lastError = err;
+          console.warn(LOG_PREFIX, `[ensureContentScriptReadyOnTab] inject attempt ${attempt} failed for ${source} tab=${tabId}: ${err?.message || err}`);
+        }
+
+        const pongAfterInject = await pingContentScriptOnTab(tabId);
+        if (pongAfterInject?.ok && (!pongAfterInject.source || pongAfterInject.source === source)) {
+          console.log(LOG_PREFIX, `[ensureContentScriptReadyOnTab] ready after inject ${source} tab=${tabId} on attempt ${attempt} after ${Date.now() - start}ms`);
+          await registerTab(source, tabId);
+          return;
+        }
+
+        if (logMessage && !logged) {
+          console.warn(LOG_PREFIX, `[ensureContentScriptReadyOnTab] ${source} tab=${tabId} still not ready after ${Date.now() - start}ms`);
+          await addLog(logMessage, 'warn');
+          logged = true;
+        }
+
+        await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
+      }
+
+      throw lastError || new Error(`${getSourceLabel(source)} 内容脚本长时间未就绪。`);
+    }
+
+    function getContentScriptResponseTimeoutMs(message) {
+      if (!message || typeof message !== 'object') return 30000;
+      if (message.type === 'EXECUTE_STEP' && Number(message.step) === 6) return 75000;
+      if (message.type === 'POLL_EMAIL') {
+        const maxAttempts = Math.max(1, Number(message.payload?.maxAttempts) || 1);
+        const intervalMs = Math.max(0, Number(message.payload?.intervalMs) || 0);
+        return Math.max(45000, maxAttempts * intervalMs + 25000);
+      }
+      if (message.type === 'FILL_CODE') return Number(message.step) === 7 ? 45000 : 30000;
+      if (message.type === 'PREPARE_SIGNUP_VERIFICATION') return 45000;
+      return 30000;
+    }
+
+    function getMessageDebugLabel(source, message, tabId = null) {
+      const parts = [source || 'unknown', message?.type || 'UNKNOWN'];
+      if (Number.isInteger(message?.step)) parts.push(`step=${message.step}`);
+      if (Number.isInteger(tabId)) parts.push(`tab=${tabId}`);
+      return parts.join(' ');
+    }
+
+    function summarizeMessageResultForDebug(result) {
+      if (result === undefined) return 'undefined';
+      if (result === null) return 'null';
+      if (typeof result !== 'object') return JSON.stringify(result);
+      const summary = {};
+      for (const key of ['ok', 'error', 'stopped', 'source', 'step']) {
+        if (key in result) summary[key] = result[key];
+      }
+      if (result.payload && typeof result.payload === 'object') {
+        summary.payloadKeys = Object.keys(result.payload);
+      }
+      return JSON.stringify(summary);
+    }
+
+    function sendTabMessageWithTimeout(tabId, source, message, responseTimeoutMs = getContentScriptResponseTimeoutMs(message)) {
+      return new Promise((resolve, reject) => {
+        let settled = false;
+        const startedAt = Date.now();
+        const debugLabel = getMessageDebugLabel(source, message, tabId);
+
+        console.log(LOG_PREFIX, `[sendTabMessageWithTimeout] dispatch ${debugLabel}, timeout=${responseTimeoutMs}ms`);
+
+        const timer = setTimeout(() => {
+          if (settled) return;
+          settled = true;
+          const seconds = Math.ceil(responseTimeoutMs / 1000);
+          console.warn(LOG_PREFIX, `[sendTabMessageWithTimeout] timeout ${debugLabel} after ${Date.now() - startedAt}ms`);
+          reject(new Error(`Content script on ${source} did not respond in ${seconds}s. Try refreshing the tab and retry.`));
+        }, responseTimeoutMs);
+
+        chrome.tabs.sendMessage(tabId, message)
+          .then((value) => {
+            const elapsed = Date.now() - startedAt;
+            if (settled) return;
+            settled = true;
+            clearTimeout(timer);
+            console.log(LOG_PREFIX, `[sendTabMessageWithTimeout] response ${debugLabel} after ${elapsed}ms: ${summarizeMessageResultForDebug(value)}`);
+            resolve(value);
+          })
+          .catch((error) => {
+            const elapsed = Date.now() - startedAt;
+            if (settled) return;
+            settled = true;
+            clearTimeout(timer);
+            console.warn(LOG_PREFIX, `[sendTabMessageWithTimeout] rejection ${debugLabel} after ${elapsed}ms: ${error?.message || error}`);
+            reject(error);
+          });
+      });
+    }
+
+    function queueCommand(source, message, timeout = 15000) {
+      return new Promise((resolve, reject) => {
+        const timer = setTimeout(() => {
+          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 });
+        console.log(LOG_PREFIX, `Command queued for ${source} (waiting for ready)`);
+      });
+    }
+
+    function flushCommand(source, tabId) {
+      const pending = pendingCommands.get(source);
+      if (pending) {
+        clearTimeout(pending.timer);
+        pendingCommands.delete(source);
+        sendTabMessageWithTimeout(tabId, source, pending.message).then(pending.resolve).catch(pending.reject);
+        console.log(LOG_PREFIX, `Flushed queued command to ${source} (tab ${tabId})`);
+      }
+    }
+
+    function cancelPendingCommands(reason = STOP_ERROR_MESSAGE) {
+      for (const [source, pending] of pendingCommands.entries()) {
+        clearTimeout(pending.timer);
+        pending.reject(new Error(reason));
+        pendingCommands.delete(source);
+        console.log(LOG_PREFIX, `Cancelled queued command for ${source}`);
+      }
+    }
+
+    async function reuseOrCreateTab(source, url, options = {}) {
+      const alive = await isTabAlive(source);
+      if (alive) {
+        const tabId = await getTabId(source);
+        await closeConflictingTabsForSource(source, url, { excludeTabIds: [tabId] });
+        const currentTab = await chrome.tabs.get(tabId);
+        const sameUrl = currentTab.url === url;
+        const shouldReloadOnReuse = sameUrl && options.reloadIfSameUrl;
+
+        const registry = await getTabRegistry();
+        if (sameUrl) {
+          await chrome.tabs.update(tabId, { active: true });
+          if (shouldReloadOnReuse) {
+            if (registry[source]) registry[source].ready = false;
+            await setState({ tabRegistry: registry });
+            await chrome.tabs.reload(tabId);
+            await new Promise((resolve) => {
+              const timer = setTimeout(() => { chrome.tabs.onUpdated.removeListener(listener); resolve(); }, 30000);
+              const listener = (tid, info) => {
+                if (tid === tabId && info.status === 'complete') {
+                  chrome.tabs.onUpdated.removeListener(listener);
+                  clearTimeout(timer);
+                  resolve();
+                }
+              };
+              chrome.tabs.onUpdated.addListener(listener);
+            });
+          }
+
+          if (options.inject) {
+            if (registry[source]) registry[source].ready = false;
+            await setState({ tabRegistry: registry });
+            if (options.injectSource) {
+              await chrome.scripting.executeScript({
+                target: { tabId },
+                func: (injectedSource) => {
+                  window.__MULTIPAGE_SOURCE = injectedSource;
+                },
+                args: [options.injectSource],
+              });
+            }
+            await chrome.scripting.executeScript({
+              target: { tabId },
+              files: options.inject,
+            });
+            await new Promise((resolve) => setTimeout(resolve, 500));
+          }
+
+          await rememberSourceLastUrl(source, url);
+          return tabId;
+        }
+
+        if (registry[source]) registry[source].ready = false;
+        await setState({ tabRegistry: registry });
+        await chrome.tabs.update(tabId, { url, active: true });
+
+        await new Promise((resolve) => {
+          const timer = setTimeout(() => { chrome.tabs.onUpdated.removeListener(listener); resolve(); }, 30000);
+          const listener = (tid, info) => {
+            if (tid === tabId && info.status === 'complete') {
+              chrome.tabs.onUpdated.removeListener(listener);
+              clearTimeout(timer);
+              resolve();
+            }
+          };
+          chrome.tabs.onUpdated.addListener(listener);
+        });
+
+        if (options.inject) {
+          if (options.injectSource) {
+            await chrome.scripting.executeScript({
+              target: { tabId },
+              func: (injectedSource) => {
+                window.__MULTIPAGE_SOURCE = injectedSource;
+              },
+              args: [options.injectSource],
+            });
+          }
+          await chrome.scripting.executeScript({
+            target: { tabId },
+            files: options.inject,
+          });
+        }
+
+        await new Promise((resolve) => setTimeout(resolve, 500));
+        await rememberSourceLastUrl(source, url);
+        return tabId;
+      }
+
+      await closeConflictingTabsForSource(source, url);
+      const tab = await chrome.tabs.create({ url, active: true });
+
+      if (options.inject) {
+        await new Promise((resolve) => {
+          const timer = setTimeout(() => { chrome.tabs.onUpdated.removeListener(listener); resolve(); }, 30000);
+          const listener = (tabId, info) => {
+            if (tabId === tab.id && info.status === 'complete') {
+              chrome.tabs.onUpdated.removeListener(listener);
+              clearTimeout(timer);
+              resolve();
+            }
+          };
+          chrome.tabs.onUpdated.addListener(listener);
+        });
+        if (options.injectSource) {
+          await chrome.scripting.executeScript({
+            target: { tabId: tab.id },
+            func: (injectedSource) => {
+              window.__MULTIPAGE_SOURCE = injectedSource;
+            },
+            args: [options.injectSource],
+          });
+        }
+        await chrome.scripting.executeScript({
+          target: { tabId: tab.id },
+          files: options.inject,
+        });
+      }
+
+      await rememberSourceLastUrl(source, url);
+      return tab.id;
+    }
+
+    async function sendToContentScript(source, message, options = {}) {
+      throwIfStopped();
+      const { responseTimeoutMs = getContentScriptResponseTimeoutMs(message) } = options;
+      const registry = await getTabRegistry();
+      const entry = registry[source];
+
+      if (!entry || !entry.ready) {
+        throwIfStopped();
+        return queueCommand(source, message);
+      }
+
+      const alive = await isTabAlive(source);
+      throwIfStopped();
+      if (!alive) {
+        return queueCommand(source, message);
+      }
+
+      throwIfStopped();
+      return sendTabMessageWithTimeout(entry.tabId, source, message, responseTimeoutMs);
+    }
+
+    async function sendToContentScriptResilient(source, message, options = {}) {
+      const { timeoutMs = 30000, retryDelayMs = 600, logMessage = '' } = options;
+      const start = Date.now();
+      let lastError = null;
+      let logged = false;
+      let attempt = 0;
+
+      while (Date.now() - start < timeoutMs) {
+        throwIfStopped();
+        attempt += 1;
+
+        try {
+          return await sendToContentScript(source, message);
+        } catch (err) {
+          const retryable = isRetryableContentScriptTransportError(err);
+          if (!retryable) {
+            throw err;
+          }
+
+          lastError = err;
+          if (logMessage && !logged) {
+            await addLog(logMessage, 'warn');
+            logged = true;
+          }
+
+          await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
+        }
+      }
+
+      throw lastError || new Error(`等待 ${getSourceLabel(source)} 重新就绪超时。`);
+    }
+
+    async function sendToMailContentScriptResilient(mail, message, options = {}) {
+      const { timeoutMs = 45000, maxRecoveryAttempts = 2 } = options;
+      const start = Date.now();
+      let lastError = null;
+      let recoveries = 0;
+      let logged = false;
+
+      while (Date.now() - start < timeoutMs) {
+        throwIfStopped();
+
+        try {
+          return await sendToContentScript(mail.source, message);
+        } catch (err) {
+          if (!isRetryableContentScriptTransportError(err)) {
+            throw err;
+          }
+
+          lastError = err;
+          if (!logged) {
+            await addLog(`步骤 ${message.step}:${mail.label} 页面通信异常,正在尝试让邮箱页重新就绪...`, 'warn');
+            logged = true;
+          }
+
+          if (recoveries >= maxRecoveryAttempts) {
+            break;
+          }
+
+          recoveries += 1;
+          await reuseOrCreateTab(mail.source, mail.url, {
+            inject: mail.inject,
+            injectSource: mail.injectSource,
+            reloadIfSameUrl: true,
+          });
+          await new Promise((resolve) => setTimeout(resolve, 800));
+        }
+      }
+
+      throw lastError || new Error(`${mail.label} 页面未能重新就绪。`);
+    }
+
+    return {
+      buildLocalhostCleanupPrefix,
+      cancelPendingCommands,
+      closeConflictingTabsForSource,
+      closeLocalhostCallbackTabs,
+      closeTabsByUrlPrefix,
+      ensureContentScriptReadyOnTab,
+      flushCommand,
+      getContentScriptResponseTimeoutMs,
+      getMessageDebugLabel,
+      getTabId,
+      getTabRegistry,
+      isLocalhostOAuthCallbackTabMatch,
+      isTabAlive,
+      pingContentScriptOnTab,
+      queueCommand,
+      registerTab,
+      rememberSourceLastUrl,
+      reuseOrCreateTab,
+      sendTabMessageWithTimeout,
+      sendToContentScript,
+      sendToContentScriptResilient,
+      sendToMailContentScriptResilient,
+      summarizeMessageResultForDebug,
+      waitForTabComplete,
+      waitForTabUrlFamily,
+      waitForTabUrlMatch,
+    };
+  }
+
+  return {
+    createTabRuntime,
+  };
+});

+ 505 - 0
background/verification-flow.js

@@ -0,0 +1,505 @@
+(function attachBackgroundVerificationFlow(root, factory) {
+  root.MultiPageBackgroundVerificationFlow = factory();
+})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundVerificationFlowModule() {
+  function createVerificationFlowHelpers(deps = {}) {
+    const {
+      addLog,
+      chrome,
+      CLOUDFLARE_TEMP_EMAIL_PROVIDER,
+      completeStepFromBackground,
+      confirmCustomVerificationStepBypassRequest,
+      getHotmailVerificationPollConfig,
+      getHotmailVerificationRequestTimestamp,
+      getState,
+      getTabId,
+      HOTMAIL_PROVIDER,
+      isStopError,
+      LUCKMAIL_PROVIDER,
+      MAIL_2925_VERIFICATION_INTERVAL_MS,
+      MAIL_2925_VERIFICATION_MAX_ATTEMPTS,
+      pollCloudflareTempEmailVerificationCode,
+      pollHotmailVerificationCode,
+      pollLuckmailVerificationCode,
+      sendToContentScript,
+      sendToMailContentScriptResilient,
+      setState,
+      sleepWithStop,
+      throwIfStopped,
+      VERIFICATION_POLL_MAX_ROUNDS,
+    } = deps;
+
+    function getVerificationCodeStateKey(step) {
+      return step === 4 ? 'lastSignupCode' : 'lastLoginCode';
+    }
+
+    function getVerificationCodeLabel(step) {
+      return step === 4 ? '注册' : '登录';
+    }
+
+    async function confirmCustomVerificationStepBypass(step) {
+      const verificationLabel = getVerificationCodeLabel(step);
+      await addLog(`步骤 ${step}:当前为自定义邮箱模式,请手动在页面中输入${verificationLabel}验证码并进入下一页面。`, 'warn');
+
+      let response = null;
+      try {
+        response = await confirmCustomVerificationStepBypassRequest(step);
+      } catch {
+        throw new Error(`步骤 ${step}:无法打开确认弹窗,请先保持侧边栏打开后重试。`);
+      }
+
+      if (response?.error) {
+        throw new Error(response.error);
+      }
+      if (!response?.confirmed) {
+        throw new Error(`步骤 ${step}:已取消手动${verificationLabel}验证码确认。`);
+      }
+
+      await setState({
+        lastEmailTimestamp: null,
+        signupVerificationRequestedAt: null,
+        loginVerificationRequestedAt: null,
+      });
+      await deps.setStepStatus(step, 'skipped');
+      await addLog(`步骤 ${step}:已确认手动完成${verificationLabel}验证码输入,当前步骤已跳过。`, 'warn');
+    }
+
+    function getVerificationPollPayload(step, state, overrides = {}) {
+      const is2925Provider = state?.mailProvider === '2925';
+      if (step === 4) {
+        return {
+          filterAfterTimestamp: getHotmailVerificationRequestTimestamp(4, state),
+          senderFilters: ['openai', 'noreply', 'verify', 'auth', 'duckduckgo', 'forward'],
+          subjectFilters: ['verify', 'verification', 'code', '验证码', 'confirm'],
+          targetEmail: state.email,
+          maxAttempts: is2925Provider ? MAIL_2925_VERIFICATION_MAX_ATTEMPTS : 5,
+          intervalMs: is2925Provider ? MAIL_2925_VERIFICATION_INTERVAL_MS : 3000,
+          ...overrides,
+        };
+      }
+
+      return {
+        filterAfterTimestamp: getHotmailVerificationRequestTimestamp(7, state),
+        senderFilters: ['openai', 'noreply', 'verify', 'auth', 'chatgpt', 'duckduckgo', 'forward'],
+        subjectFilters: ['verify', 'verification', 'code', '验证码', 'confirm', 'login'],
+        targetEmail: state.email,
+        maxAttempts: is2925Provider ? MAIL_2925_VERIFICATION_MAX_ATTEMPTS : 5,
+        intervalMs: is2925Provider ? MAIL_2925_VERIFICATION_INTERVAL_MS : 3000,
+        ...overrides,
+      };
+    }
+
+    async function requestVerificationCodeResend(step) {
+      throwIfStopped();
+      const signupTabId = await getTabId('signup-page');
+      if (!signupTabId) {
+        throw new Error('认证页面标签页已关闭,无法重新请求验证码。');
+      }
+
+      throwIfStopped();
+      await chrome.tabs.update(signupTabId, { active: true });
+      throwIfStopped();
+
+      const result = await sendToContentScript('signup-page', {
+        type: 'RESEND_VERIFICATION_CODE',
+        step,
+        source: 'background',
+        payload: {},
+      });
+
+      if (result && result.error) {
+        throw new Error(result.error);
+      }
+
+      await addLog(`步骤 ${step}:已请求新的${getVerificationCodeLabel(step)}验证码。`, 'warn');
+
+      const requestedAt = Date.now();
+      if (step === 7) {
+        await setState({ loginVerificationRequestedAt: requestedAt });
+      }
+
+      const currentState = await getState();
+      if (currentState.mailProvider === '2925') {
+        const mailTabId = await getTabId('mail-2925');
+        if (mailTabId) {
+          await chrome.tabs.update(mailTabId, { active: true });
+          await addLog(`步骤 ${step}:已切换到 2925 邮箱标签页等待新邮件。`, 'info');
+        }
+      }
+
+      return requestedAt;
+    }
+
+    async function pollFreshVerificationCodeWithResendInterval(step, state, mail, pollOverrides = {}) {
+      const stateKey = getVerificationCodeStateKey(step);
+      const rejectedCodes = new Set();
+      if (state[stateKey]) {
+        rejectedCodes.add(state[stateKey]);
+      }
+      for (const code of (pollOverrides.excludeCodes || [])) {
+        if (code) rejectedCodes.add(code);
+      }
+
+      const {
+        maxRounds: _ignoredMaxRounds,
+        resendIntervalMs: _ignoredResendIntervalMs,
+        lastResendAt: _ignoredLastResendAt,
+        onResendRequestedAt: _ignoredOnResendRequestedAt,
+        ...payloadOverrides
+      } = pollOverrides;
+      const onResendRequestedAt = typeof pollOverrides.onResendRequestedAt === 'function'
+        ? pollOverrides.onResendRequestedAt
+        : null;
+      let lastError = null;
+      let filterAfterTimestamp = payloadOverrides.filterAfterTimestamp ?? getVerificationPollPayload(step, state).filterAfterTimestamp;
+      const maxRounds = pollOverrides.maxRounds || VERIFICATION_POLL_MAX_ROUNDS;
+      const resendIntervalMs = Math.max(0, Number(pollOverrides.resendIntervalMs) || 0);
+      let lastResendAt = Number(pollOverrides.lastResendAt) || 0;
+
+      for (let round = 1; round <= maxRounds; round++) {
+        throwIfStopped();
+        if (round > 1) {
+          lastResendAt = await requestVerificationCodeResend(step);
+          if (onResendRequestedAt) {
+            const nextFilterAfterTimestamp = await onResendRequestedAt(lastResendAt);
+            if (nextFilterAfterTimestamp !== undefined) {
+              filterAfterTimestamp = nextFilterAfterTimestamp;
+            }
+          }
+        }
+
+        while (true) {
+          throwIfStopped();
+          const payload = getVerificationPollPayload(step, state, {
+            ...payloadOverrides,
+            filterAfterTimestamp,
+            excludeCodes: [...rejectedCodes],
+          });
+
+          if (lastResendAt > 0) {
+            const remainingBeforeResendMs = Math.max(0, resendIntervalMs - (Date.now() - lastResendAt));
+            const baseMaxAttempts = Math.max(1, Number(payload.maxAttempts) || 5);
+            const intervalMs = Math.max(1, Number(payload.intervalMs) || 3000);
+            payload.maxAttempts = Math.max(1, Math.min(baseMaxAttempts, Math.floor(remainingBeforeResendMs / intervalMs) + 1));
+          }
+
+          try {
+            const result = await sendToMailContentScriptResilient(
+              mail,
+              {
+                type: 'POLL_EMAIL',
+                step,
+                source: 'background',
+                payload,
+              },
+              {
+                timeoutMs: 45000,
+                maxRecoveryAttempts: 2,
+              }
+            );
+
+            if (result && result.error) {
+              throw new Error(result.error);
+            }
+
+            if (!result || !result.code) {
+              throw new Error(`步骤 ${step}:邮箱轮询结束,但未获取到验证码。`);
+            }
+
+            if (rejectedCodes.has(result.code)) {
+              throw new Error(`步骤 ${step}:再次收到了相同的${getVerificationCodeLabel(step)}验证码:${result.code}`);
+            }
+
+            return {
+              ...result,
+              lastResendAt,
+            };
+          } catch (err) {
+            if (isStopError(err)) {
+              throw err;
+            }
+            lastError = err;
+            await addLog(`步骤 ${step}:${err.message}`, 'warn');
+          }
+
+          const remainingBeforeResendMs = lastResendAt > 0
+            ? Math.max(0, resendIntervalMs - (Date.now() - lastResendAt))
+            : 0;
+          if (remainingBeforeResendMs > 0) {
+            await addLog(
+              `步骤 ${step}:距离下次重新发送验证码还差 ${Math.ceil(remainingBeforeResendMs / 1000)} 秒,继续刷新邮箱(第 ${round}/${maxRounds} 轮)...`,
+              'info'
+            );
+            continue;
+          }
+
+          if (round < maxRounds) {
+            await addLog(`步骤 ${step}:已到 25 秒重发间隔,准备重新发送验证码(第 ${round + 1}/${maxRounds} 轮)...`, 'warn');
+          }
+          break;
+        }
+      }
+
+      throw lastError || new Error(`步骤 ${step}:无法获取新的${getVerificationCodeLabel(step)}验证码。`);
+    }
+
+    async function pollFreshVerificationCode(step, state, mail, pollOverrides = {}) {
+      const { onResendRequestedAt, ...cleanPollOverrides } = pollOverrides;
+
+      if (mail.provider === HOTMAIL_PROVIDER) {
+        const hotmailPollConfig = getHotmailVerificationPollConfig(step);
+        return pollHotmailVerificationCode(step, state, {
+          ...getVerificationPollPayload(step, state),
+          ...hotmailPollConfig,
+          ...cleanPollOverrides,
+        });
+      }
+      if (mail.provider === LUCKMAIL_PROVIDER) {
+        return pollLuckmailVerificationCode(step, state, {
+          ...getVerificationPollPayload(step, state),
+          ...pollOverrides,
+        });
+      }
+      if (mail.provider === CLOUDFLARE_TEMP_EMAIL_PROVIDER) {
+        return pollCloudflareTempEmailVerificationCode(step, state, {
+          ...getVerificationPollPayload(step, state),
+          ...pollOverrides,
+        });
+      }
+
+      if (Number(pollOverrides.resendIntervalMs) > 0) {
+        return pollFreshVerificationCodeWithResendInterval(step, state, mail, pollOverrides);
+      }
+
+      const stateKey = getVerificationCodeStateKey(step);
+      const rejectedCodes = new Set();
+      if (state[stateKey]) {
+        rejectedCodes.add(state[stateKey]);
+      }
+      for (const code of (pollOverrides.excludeCodes || [])) {
+        if (code) rejectedCodes.add(code);
+      }
+
+      let lastError = null;
+      let filterAfterTimestamp = cleanPollOverrides.filterAfterTimestamp ?? getVerificationPollPayload(step, state).filterAfterTimestamp;
+      const maxRounds = pollOverrides.maxRounds || VERIFICATION_POLL_MAX_ROUNDS;
+
+      for (let round = 1; round <= maxRounds; round++) {
+        throwIfStopped();
+        if (round > 1) {
+          const requestedAt = await requestVerificationCodeResend(step);
+          if (typeof onResendRequestedAt === 'function') {
+            const nextFilterAfterTimestamp = await onResendRequestedAt(requestedAt);
+            if (nextFilterAfterTimestamp !== undefined) {
+              filterAfterTimestamp = nextFilterAfterTimestamp;
+            }
+          }
+        }
+
+        const payload = getVerificationPollPayload(step, state, {
+          ...cleanPollOverrides,
+          filterAfterTimestamp,
+          excludeCodes: [...rejectedCodes],
+        });
+
+        try {
+          const result = await sendToMailContentScriptResilient(
+            mail,
+            {
+              type: 'POLL_EMAIL',
+              step,
+              source: 'background',
+              payload,
+            },
+            {
+              timeoutMs: 45000,
+              maxRecoveryAttempts: 2,
+            }
+          );
+
+          if (result && result.error) {
+            throw new Error(result.error);
+          }
+
+          if (!result || !result.code) {
+            throw new Error(`步骤 ${step}:邮箱轮询结束,但未获取到验证码。`);
+          }
+
+          if (rejectedCodes.has(result.code)) {
+            throw new Error(`步骤 ${step}:再次收到了相同的${getVerificationCodeLabel(step)}验证码:${result.code}`);
+          }
+
+          return result;
+        } catch (err) {
+          if (isStopError(err)) {
+            throw err;
+          }
+          lastError = err;
+          await addLog(`步骤 ${step}:${err.message}`, 'warn');
+          if (round < maxRounds) {
+            await addLog(`步骤 ${step}:将重新发送验证码后重试(${round + 1}/${maxRounds})...`, 'warn');
+          }
+        }
+      }
+
+      throw lastError || new Error(`步骤 ${step}:无法获取新的${getVerificationCodeLabel(step)}验证码。`);
+    }
+
+    async function submitVerificationCode(step, code) {
+      const signupTabId = await getTabId('signup-page');
+      if (!signupTabId) {
+        throw new Error('认证页面标签页已关闭,无法填写验证码。');
+      }
+
+      await chrome.tabs.update(signupTabId, { active: true });
+      const result = await sendToContentScript('signup-page', {
+        type: 'FILL_CODE',
+        step,
+        source: 'background',
+        payload: { code },
+      });
+
+      if (result && result.error) {
+        throw new Error(result.error);
+      }
+
+      return result || {};
+    }
+
+    async function resolveVerificationStep(step, state, mail, options = {}) {
+      const stateKey = getVerificationCodeStateKey(step);
+      const rejectedCodes = new Set();
+      const hotmailPollConfig = mail.provider === HOTMAIL_PROVIDER
+        ? getHotmailVerificationPollConfig(step)
+        : null;
+      const beforeSubmit = typeof options.beforeSubmit === 'function'
+        ? options.beforeSubmit
+        : null;
+      const ignorePersistedLastCode = Boolean(hotmailPollConfig?.ignorePersistedLastCode);
+      if (state[stateKey] && !ignorePersistedLastCode) {
+        rejectedCodes.add(state[stateKey]);
+      }
+
+      let nextFilterAfterTimestamp = options.filterAfterTimestamp ?? null;
+      const requestFreshCodeFirst = options.requestFreshCodeFirst !== undefined
+        ? Boolean(options.requestFreshCodeFirst)
+        : (hotmailPollConfig?.requestFreshCodeFirst ?? false);
+      const maxSubmitAttempts = 3;
+      const resendIntervalMs = Math.max(0, Number(options.resendIntervalMs) || 0);
+      let lastResendAt = Number(options.lastResendAt) || 0;
+
+      const updateFilterAfterTimestampForStep7 = async (requestedAt) => {
+        if (step !== 7 || !requestedAt) {
+          return nextFilterAfterTimestamp;
+        }
+
+        if (mail.provider === HOTMAIL_PROVIDER) {
+          nextFilterAfterTimestamp = getHotmailVerificationRequestTimestamp(7, {
+            ...state,
+            loginVerificationRequestedAt: requestedAt,
+          });
+        } else {
+          nextFilterAfterTimestamp = Math.max(0, Number(requestedAt) - 60000);
+        }
+
+        return nextFilterAfterTimestamp;
+      };
+
+      if (requestFreshCodeFirst) {
+        try {
+          lastResendAt = await requestVerificationCodeResend(step);
+          await updateFilterAfterTimestampForStep7(lastResendAt);
+          await addLog(`步骤 ${step}:已先请求一封新的${getVerificationCodeLabel(step)}验证码,再开始轮询邮箱。`, 'warn');
+        } catch (err) {
+          if (isStopError(err)) {
+            throw err;
+          }
+          await addLog(`步骤 ${step}:首次重新获取验证码失败:${err.message},将继续使用当前时间窗口轮询。`, 'warn');
+        }
+      }
+
+      if (mail.provider === HOTMAIL_PROVIDER) {
+        const initialDelayMs = Number(options.initialDelayMs ?? hotmailPollConfig.initialDelayMs) || 0;
+        if (initialDelayMs > 0) {
+          await addLog(`步骤 ${step}:等待 ${Math.round(initialDelayMs / 1000)} 秒,让 Hotmail 验证码邮件先到达...`, 'info');
+          await sleepWithStop(initialDelayMs);
+        }
+      }
+
+      for (let attempt = 1; attempt <= maxSubmitAttempts; attempt++) {
+        const result = await pollFreshVerificationCode(step, state, mail, {
+          excludeCodes: [...rejectedCodes],
+          filterAfterTimestamp: nextFilterAfterTimestamp ?? undefined,
+          resendIntervalMs,
+          lastResendAt,
+          onResendRequestedAt: updateFilterAfterTimestampForStep7,
+        });
+        lastResendAt = Number(result?.lastResendAt) || lastResendAt;
+
+        throwIfStopped();
+        await addLog(`步骤 ${step}:已获取${getVerificationCodeLabel(step)}验证码:${result.code}`);
+        if (beforeSubmit) {
+          await beforeSubmit(result, {
+            attempt,
+            rejectedCodes: new Set(rejectedCodes),
+            filterAfterTimestamp: nextFilterAfterTimestamp ?? undefined,
+            lastResendAt,
+          });
+        }
+        throwIfStopped();
+        const submitResult = await submitVerificationCode(step, result.code);
+
+        if (submitResult.invalidCode) {
+          rejectedCodes.add(result.code);
+          await addLog(`步骤 ${step}:验证码被页面拒绝:${submitResult.errorText || result.code}`, 'warn');
+
+          if (attempt >= maxSubmitAttempts) {
+            throw new Error(`步骤 ${step}:验证码连续失败,已达到 ${maxSubmitAttempts} 次重试上限。`);
+          }
+
+          const remainingBeforeResendMs = resendIntervalMs > 0 && lastResendAt > 0
+            ? Math.max(0, resendIntervalMs - (Date.now() - lastResendAt))
+            : 0;
+          if (remainingBeforeResendMs > 0) {
+            await addLog(
+              `步骤 ${step}:提交失败后距离下次重新发送验证码还差 ${Math.ceil(remainingBeforeResendMs / 1000)} 秒,先继续刷新邮箱(${attempt + 1}/${maxSubmitAttempts})...`,
+              'warn'
+            );
+            continue;
+          }
+
+          lastResendAt = await requestVerificationCodeResend(step);
+          await updateFilterAfterTimestampForStep7(lastResendAt);
+          await addLog(`步骤 ${step}:提交失败后已请求新验证码(${attempt + 1}/${maxSubmitAttempts})...`, 'warn');
+          continue;
+        }
+
+        await setState({
+          lastEmailTimestamp: result.emailTimestamp,
+          [stateKey]: result.code,
+        });
+
+        await completeStepFromBackground(step, {
+          emailTimestamp: result.emailTimestamp,
+          code: result.code,
+        });
+        return;
+      }
+    }
+
+    return {
+      confirmCustomVerificationStepBypass,
+      getVerificationCodeLabel,
+      getVerificationCodeStateKey,
+      getVerificationPollPayload,
+      pollFreshVerificationCode,
+      pollFreshVerificationCodeWithResendInterval,
+      requestVerificationCodeResend,
+      resolveVerificationStep,
+      submitVerificationCode,
+    };
+  }
+
+  return {
+    createVerificationFlowHelpers,
+  };
+});

+ 7 - 3
content/activation-utils.js

@@ -38,12 +38,16 @@
   }
 
   function isRecoverableStep9AuthFailure(statusText) {
-    const text = String(statusText || '').trim();
-    if (!/认证失败:\s*/i.test(text)) {
+    const text = String(statusText || '').replace(/\s+/g, ' ').trim();
+    if (!text) {
       return false;
     }
 
-    return /timeout waiting for oauth callback|status code 5\d{2}|bad gateway|gateway timeout|temporarily unavailable/i.test(text);
+    if (/oauth flow is not pending/i.test(text)) {
+      return true;
+    }
+
+    return /(?:认证失败|回调 URL 提交失败):\s*/i.test(text);
   }
 
   return {

+ 186 - 91
content/signup-page.js

@@ -206,6 +206,15 @@ async function resendVerificationCode(step, timeout = 45000) {
 
   while (Date.now() - start < timeout) {
     throwIfStopped();
+
+    // Check for 405 error page and recover by clicking "Try again"
+    if (is405MethodNotAllowedPage()) {
+      await handle405ResendError(step, timeout - (Date.now() - start));
+      // After recovery, loop back to find the resend button again
+      loggedWaiting = false;
+      continue;
+    }
+
     action = findResendVerificationCodeTrigger({ allowDisabled: true });
 
     if (action && isActionEnabled(action)) {
@@ -213,6 +222,15 @@ async function resendVerificationCode(step, timeout = 45000) {
       await humanPause(350, 900);
       simulateClick(action);
       await sleep(1200);
+
+      // After clicking resend, check if 405 error appeared
+      if (is405MethodNotAllowedPage()) {
+        log(`步骤 ${step}:点击重新发送后出现 405 错误,正在恢复...`, 'warn');
+        await handle405ResendError(step, timeout - (Date.now() - start));
+        loggedWaiting = false;
+        continue;
+      }
+
       return {
         resent: true,
         buttonText: getActionText(action),
@@ -230,6 +248,43 @@ async function resendVerificationCode(step, timeout = 45000) {
   throw new Error('无法点击重新发送验证码按钮。URL: ' + location.href);
 }
 
+function is405MethodNotAllowedPage() {
+  const pageText = document.body?.textContent || '';
+  return /405\s+Method\s+Not\s+Allowed/i.test(pageText)
+    || /Route\s+Error.*405/i.test(pageText);
+}
+
+async function handle405ResendError(step, remainingTimeout = 30000) {
+  const start = Date.now();
+  let retryCount = 0;
+
+  while (Date.now() - start < remainingTimeout) {
+    throwIfStopped();
+
+    if (!is405MethodNotAllowedPage()) {
+      // Page recovered — back to verification page
+      log(`步骤 ${step}:405 错误已恢复,页面已返回验证码页面。`);
+      return;
+    }
+
+    const retryBtn = getAuthRetryButton();
+    if (retryBtn) {
+      retryCount++;
+      log(`步骤 ${step}:检测到 405 错误页面,正在点击"Try again"(第 ${retryCount} 次)...`, 'warn');
+      await humanPause(300, 800);
+      simulateClick(retryBtn);
+
+      // Wait 3 seconds before checking again
+      await sleep(3000);
+      continue;
+    }
+
+    await sleep(500);
+  }
+
+  throw new Error(`步骤 ${step}:405 错误恢复超时,无法返回验证码页面。URL: ${location.href}`);
+}
+
 // ============================================================
 // Signup Entry Helpers
 // ============================================================
@@ -720,38 +775,6 @@ function getStep5ErrorText() {
   return messages.find((text) => STEP5_SUBMIT_ERROR_PATTERN.test(text)) || '';
 }
 
-async function waitForStep5SubmitOutcome(timeout = 15000) {
-  const start = Date.now();
-
-  while (Date.now() - start < timeout) {
-    throwIfStopped();
-
-    const errorText = getStep5ErrorText();
-    if (errorText) {
-      return { invalidProfile: true, errorText };
-    }
-
-    if (isAddPhonePageReady()) {
-      return { success: true, addPhonePage: true };
-    }
-
-    if (isStep8Ready()) {
-      return { success: true };
-    }
-
-    await sleep(150);
-  }
-
-  const errorText = getStep5ErrorText();
-  if (errorText) {
-    return { invalidProfile: true, errorText };
-  }
-
-  return {
-    invalidProfile: true,
-    errorText: '提交后未进入下一阶段,请检查生日是否真正被页面接受。',
-  };
-}
 
 function isSignupPasswordPage() {
   return /\/create-account\/password(?:[/?#]|$)/i.test(location.pathname || '');
@@ -913,13 +936,6 @@ function inspectLoginAuthState() {
     };
   }
 
-  if (oauthConsentPage) {
-    return {
-      ...baseState,
-      state: 'oauth_consent_page',
-    };
-  }
-
   if (passwordInput || switchTrigger) {
     return {
       ...baseState,
@@ -965,7 +981,8 @@ function serializeLoginAuthState(snapshot) {
 }
 
 function getLoginAuthStateLabel(snapshot) {
-  switch (snapshot?.state) {
+  const state = snapshot?.state === 'oauth_consent_page' ? 'unknown' : snapshot?.state;
+  switch (state) {
     case 'verification_page':
       return '登录验证码页';
     case 'password_page':
@@ -985,11 +1002,11 @@ function getLoginAuthStateLabel(snapshot) {
 
 async function waitForKnownLoginAuthState(timeout = 15000) {
   const start = Date.now();
-  let snapshot = inspectLoginAuthState();
+  let snapshot = normalizeStep6Snapshot(inspectLoginAuthState());
 
   while (Date.now() - start < timeout) {
     throwIfStopped();
-    snapshot = inspectLoginAuthState();
+    snapshot = normalizeStep6Snapshot(inspectLoginAuthState());
     if (snapshot.state !== 'unknown') {
       return snapshot;
     }
@@ -1041,7 +1058,19 @@ function createStep6RecoverableResult(reason, snapshot, options = {}) {
   };
 }
 
+function normalizeStep6Snapshot(snapshot) {
+  if (snapshot?.state !== 'oauth_consent_page') {
+    return snapshot;
+  }
+
+  return {
+    ...snapshot,
+    state: 'unknown',
+  };
+}
+
 function throwForStep6FatalState(snapshot) {
+  snapshot = normalizeStep6Snapshot(snapshot);
   switch (snapshot?.state) {
     case 'oauth_consent_page':
       throw new Error(`当前页面已进入 OAuth 授权页,未经过登录验证码页,无法完成步骤 6。URL: ${snapshot.url}`);
@@ -1258,28 +1287,55 @@ async function fillVerificationCode(step, payload) {
   }
 
   // Find code input — could be a single input or multiple separate inputs
+  // Retry with 405 error recovery if needed
+  const maxRetries = 3;
   let codeInput = null;
-  try {
-    codeInput = await waitForElement(VERIFICATION_CODE_INPUT_SELECTOR, 10000);
-  } catch {
-    // Check for multiple single-digit inputs (common pattern)
-    const singleInputs = document.querySelectorAll('input[maxlength="1"]');
-    if (singleInputs.length >= 6) {
-      log(`步骤 ${step}:发现分开的单字符验证码输入框,正在逐个填写...`);
-      for (let i = 0; i < 6 && i < singleInputs.length; i++) {
-        fillInput(singleInputs[i], code[i]);
-        await sleep(100);
+
+  for (let retry = 0; retry <= maxRetries; retry++) {
+    throwIfStopped();
+
+    // Before looking for input, check if page is in 405 error state
+    if (is405MethodNotAllowedPage()) {
+      log(`步骤 ${step}:检测到 405 错误页面,正在恢复...`, 'warn');
+      await handle405ResendError(step, 30000);
+      continue;
+    }
+
+    try {
+      codeInput = await waitForElement(VERIFICATION_CODE_INPUT_SELECTOR, 10000);
+      break; // Found it
+    } catch {
+      // Check for multiple single-digit inputs (common pattern)
+      const singleInputs = document.querySelectorAll('input[maxlength="1"]');
+      if (singleInputs.length >= 6) {
+        log(`步骤 ${step}:发现分开的单字符验证码输入框,正在逐个填写...`);
+        for (let i = 0; i < 6 && i < singleInputs.length; i++) {
+          fillInput(singleInputs[i], code[i]);
+          await sleep(100);
+        }
+        const outcome = await waitForVerificationSubmitOutcome(step);
+        if (outcome.invalidCode) {
+          log(`步骤 ${step}:验证码被拒绝:${outcome.errorText}`, 'warn');
+        } else if (outcome.addPhonePage) {
+          log(`步骤 ${step}:验证码已通过,并已跳转到手机号页面。`, 'ok');
+        } else {
+          log(`步骤 ${step}:验证码已通过${outcome.assumed ? '(按成功推定)' : ''}。`, 'ok');
+        }
+        return outcome;
       }
-      const outcome = await waitForVerificationSubmitOutcome(step);
-      if (outcome.invalidCode) {
-        log(`步骤 ${step}:验证码被拒绝:${outcome.errorText}`, 'warn');
-      } else if (outcome.addPhonePage) {
-        log(`步骤 ${step}:验证码已通过,并已跳转到手机号页面。`, 'ok');
-      } else {
-        log(`步骤 ${step}:验证码已通过${outcome.assumed ? '(按成功推定)' : ''}。`, 'ok');
+
+      // No input found — check if it's a 405 error and can be recovered
+      if (is405MethodNotAllowedPage() && retry < maxRetries) {
+        log(`步骤 ${step}:未找到验证码输入框且页面出现 405 错误,正在恢复...`, 'warn');
+        await handle405ResendError(step, 30000);
+        continue;
       }
-      return outcome;
+
+      throw new Error('未找到验证码输入框。URL: ' + location.href);
     }
+  }
+
+  if (!codeInput) {
     throw new Error('未找到验证码输入框。URL: ' + location.href);
   }
 
@@ -1317,11 +1373,11 @@ async function fillVerificationCode(step, payload) {
 
 async function waitForStep6EmailSubmitTransition(emailSubmittedAt, timeout = 12000) {
   const start = Date.now();
-  let snapshot = inspectLoginAuthState();
+  let snapshot = normalizeStep6Snapshot(inspectLoginAuthState());
 
   while (Date.now() - start < timeout) {
     throwIfStopped();
-    snapshot = inspectLoginAuthState();
+    snapshot = normalizeStep6Snapshot(inspectLoginAuthState());
 
     if (snapshot.state === 'verification_page') {
       return {
@@ -1357,7 +1413,7 @@ async function waitForStep6EmailSubmitTransition(emailSubmittedAt, timeout = 120
     await sleep(250);
   }
 
-  snapshot = inspectLoginAuthState();
+  snapshot = normalizeStep6Snapshot(inspectLoginAuthState());
   if (snapshot.state === 'verification_page') {
     return {
       action: 'done',
@@ -1395,11 +1451,11 @@ async function waitForStep6EmailSubmitTransition(emailSubmittedAt, timeout = 120
 
 async function waitForStep6PasswordSubmitTransition(passwordSubmittedAt, timeout = 10000) {
   const start = Date.now();
-  let snapshot = inspectLoginAuthState();
+  let snapshot = normalizeStep6Snapshot(inspectLoginAuthState());
 
   while (Date.now() - start < timeout) {
     throwIfStopped();
-    snapshot = inspectLoginAuthState();
+    snapshot = normalizeStep6Snapshot(inspectLoginAuthState());
 
     if (snapshot.state === 'verification_page') {
       return {
@@ -1431,7 +1487,7 @@ async function waitForStep6PasswordSubmitTransition(passwordSubmittedAt, timeout
     await sleep(250);
   }
 
-  snapshot = inspectLoginAuthState();
+  snapshot = normalizeStep6Snapshot(inspectLoginAuthState());
   if (snapshot.state === 'verification_page') {
     return {
       action: 'done',
@@ -1469,11 +1525,11 @@ async function waitForStep6PasswordSubmitTransition(passwordSubmittedAt, timeout
 
 async function waitForStep6SwitchTransition(loginVerificationRequestedAt, timeout = 10000) {
   const start = Date.now();
-  let snapshot = inspectLoginAuthState();
+  let snapshot = normalizeStep6Snapshot(inspectLoginAuthState());
 
   while (Date.now() - start < timeout) {
     throwIfStopped();
-    snapshot = inspectLoginAuthState();
+    snapshot = normalizeStep6Snapshot(inspectLoginAuthState());
 
     if (snapshot.state === 'verification_page') {
       return createStep6SuccessResult(snapshot, {
@@ -1499,7 +1555,7 @@ async function waitForStep6SwitchTransition(loginVerificationRequestedAt, timeou
     await sleep(250);
   }
 
-  snapshot = inspectLoginAuthState();
+  snapshot = normalizeStep6Snapshot(inspectLoginAuthState());
   if (snapshot.state === 'verification_page') {
     return createStep6SuccessResult(snapshot, {
       via: 'switch_to_one_time_code_login',
@@ -1526,7 +1582,7 @@ async function waitForStep6SwitchTransition(loginVerificationRequestedAt, timeou
 async function step6SwitchToOneTimeCodeLogin(snapshot) {
   const switchTrigger = snapshot?.switchTrigger || findOneTimeCodeLoginTrigger();
   if (!switchTrigger || !isActionEnabled(switchTrigger)) {
-    return createStep6RecoverableResult('missing_one_time_code_trigger', inspectLoginAuthState(), {
+    return createStep6RecoverableResult('missing_one_time_code_trigger', normalizeStep6Snapshot(inspectLoginAuthState()), {
       message: '当前登录页没有可用的一次性验证码登录入口。',
     });
   }
@@ -1541,7 +1597,7 @@ async function step6SwitchToOneTimeCodeLogin(snapshot) {
 }
 
 async function step6LoginFromPasswordPage(payload, snapshot) {
-  const currentSnapshot = snapshot || inspectLoginAuthState();
+  const currentSnapshot = normalizeStep6Snapshot(snapshot || inspectLoginAuthState());
 
   if (currentSnapshot.passwordInput) {
     if (!payload.password) {
@@ -1571,7 +1627,7 @@ async function step6LoginFromPasswordPage(payload, snapshot) {
       return step6SwitchToOneTimeCodeLogin(transition.snapshot);
     }
 
-    return createStep6RecoverableResult('password_submit_unknown', inspectLoginAuthState(), {
+    return createStep6RecoverableResult('password_submit_unknown', normalizeStep6Snapshot(inspectLoginAuthState()), {
       message: '提交密码后未得到可用的下一步状态。',
     });
   }
@@ -1586,7 +1642,7 @@ async function step6LoginFromPasswordPage(payload, snapshot) {
 }
 
 async function step6LoginFromEmailPage(payload, snapshot) {
-  const currentSnapshot = snapshot || inspectLoginAuthState();
+  const currentSnapshot = normalizeStep6Snapshot(snapshot || inspectLoginAuthState());
   const emailInput = currentSnapshot.emailInput || getLoginEmailInput();
   if (!emailInput) {
     throw new Error('在登录页未找到邮箱输入框。URL: ' + location.href);
@@ -1618,7 +1674,7 @@ async function step6LoginFromEmailPage(payload, snapshot) {
     return step6LoginFromPasswordPage(payload, transition.snapshot);
   }
 
-  return createStep6RecoverableResult('email_submit_unknown', inspectLoginAuthState(), {
+  return createStep6RecoverableResult('email_submit_unknown', normalizeStep6Snapshot(inspectLoginAuthState()), {
     message: '提交邮箱后未得到可用的下一步状态。',
   });
 }
@@ -1629,7 +1685,7 @@ async function step6_login(payload) {
 
   log(`步骤 6:正在使用 ${email} 登录...`);
 
-  const snapshot = await waitForKnownLoginAuthState(15000);
+  const snapshot = normalizeStep6Snapshot(await waitForKnownLoginAuthState(15000));
 
   if (snapshot.state === 'verification_page') {
     log('步骤 6:登录验证码页面已就绪。', 'ok');
@@ -1839,6 +1895,17 @@ function getSerializableRect(el) {
 // Step 5: Fill Name & Birthday / Age
 // ============================================================
 
+function getStep5DirectCompletionPayload({ isAgeMode = false } = {}) {
+  const payload = {
+    skippedPostSubmitCheck: true,
+    directProceedToStep6: true,
+  };
+  if (isAgeMode) {
+    payload.ageMode = true;
+  }
+  return payload;
+}
+
 async function step5_fillNameBirthday(payload) {
   const { firstName, lastName, age, year, month, day } = payload;
   if (!firstName || !lastName) throw new Error('未提供姓名数据。');
@@ -2009,6 +2076,41 @@ async function step5_fillNameBirthday(payload) {
   } else {
     throw new Error('未找到生日或年龄输入项。URL: ' + location.href);
   }
+  // 韩国IP判断勾选框""I agree"
+  const allConsentCheckbox = Array.from(document.querySelectorAll('input[name="allCheckboxes"][type="checkbox"]'))
+    .find((el) => {
+      const checkboxLabel = el.closest('label');
+      const labelText = normalizeInlineText(checkboxLabel?.textContent || '');
+      return (!checkboxLabel || isVisibleElement(checkboxLabel))
+        && /I\s+agree\s+to\s+all\s+of\s+the\s+following/i.test(labelText);
+    }) || null;
+
+  if (allConsentCheckbox) {
+    if (!allConsentCheckbox.checked) {
+      const checkboxLabel = allConsentCheckbox.closest('label');
+      await humanPause(500, 1500);
+      if (checkboxLabel && isVisibleElement(checkboxLabel)) {
+        simulateClick(checkboxLabel);
+      } else {
+        simulateClick(allConsentCheckbox);
+      }
+      await sleep(250);
+
+      if (!allConsentCheckbox.checked) {
+        allConsentCheckbox.click();
+        await sleep(250);
+      }
+
+      if (!allConsentCheckbox.checked) {
+        throw new Error('未能勾选 “I agree to all of the following” 复选框。');
+      }
+
+      log('步骤 5:已勾选 “I agree to all of the following”。');
+    } else {
+      log('步骤 5:“I agree to all of the following” 已勾选,跳过。');
+    }
+  }
+
 
   // Click "完成帐户创建" button
   await sleep(500);
@@ -2020,28 +2122,21 @@ async function step5_fillNameBirthday(payload) {
 
   const isAgeMode = !birthdayMode && Boolean(ageInput);
   if (isAgeMode) {
-    log('步骤 5:当前为年龄输入模式,点击“继续”后将直接视为完成并进入步骤 6。', 'warn');
-    reportComplete(5, {
-      skippedPostSubmitCheck: true,
-      directProceedToStep6: true,
-    });
+    log('步骤 5:当前为年龄输入模式,点击“完成帐户创建”后将直接完成当前步骤。', 'warn');
   }
 
   await humanPause(500, 1300);
   simulateClick(completeBtn);
 
-  if (isAgeMode) {
-    log('步骤 5:年龄模式已点击“继续”,已跳过后续结果等待。', 'warn');
-    return;
-  }
+  const completionPayload = getStep5DirectCompletionPayload({ isAgeMode });
+  reportComplete(5, completionPayload);
 
-  log('步骤 5:已点击“完成帐户创建”,正在等待页面结果...');
-
-  const outcome = await waitForStep5SubmitOutcome();
-  if (outcome.invalidProfile) {
-    throw new Error(`步骤 5:${outcome.errorText}`);
+  if (isAgeMode) {
+    log('步骤 5:年龄模式已点击“完成帐户创建”,当前步骤直接完成,不再等待页面结果。', 'warn');
+    return completionPayload;
   }
 
-  log(`步骤 5:资料已通过。`, 'ok');
-  reportComplete(5, { addPhonePage: Boolean(outcome.addPhonePage) });
+  log('步骤 5:已点击“完成帐户创建”,当前步骤直接完成,不再等待页面结果。');
+  return completionPayload;
 }
+

+ 212 - 33
content/vps-panel.js

@@ -208,13 +208,7 @@ function getStatusBadgeEntries() {
     for (const candidate of candidates) {
       if (seen.has(candidate)) continue;
       seen.add(candidate);
-      entries.push({
-        element: candidate,
-        selector,
-        visible: isVisibleElement(candidate),
-        text: (candidate.textContent || '').replace(/\s+/g, ' ').trim(),
-        className: String(candidate.className || '').replace(/\s+/g, ' ').trim(),
-      });
+      entries.push(createStep9Entry(candidate, selector));
     }
   }
 
@@ -227,7 +221,8 @@ function summarizeStatusBadgeEntries(entries) {
     .map((entry, index) => {
       const text = entry.text || '(空文本)';
       const className = entry.className ? ` class=${getInlineTextSnippet(entry.className, 80)}` : '';
-      return `#${index + 1}="${getInlineTextSnippet(text, 80)}"${className}`;
+      const errorVisual = entry.errorVisualSummary ? ` error=${getInlineTextSnippet(entry.errorVisualSummary, 80)}` : '';
+      return `#${index + 1}="${getInlineTextSnippet(text, 80)}"${className}${errorVisual}`;
     })
     .join(' | ');
 }
@@ -242,6 +237,20 @@ function normalizeStep9StatusText(statusText) {
   return String(statusText || '').replace(/\s+/g, ' ').trim();
 }
 
+function isOAuthCallbackTimeoutFailure(statusText) {
+  return /认证失败:\s*(?:Timeout waiting for OAuth callback|timeout of \d+ms exceeded)/i.test(statusText || '');
+}
+
+function isStep9FailureText(statusText) {
+  const text = normalizeStep9StatusText(statusText);
+  if (!text) return false;
+  if (isOAuthCallbackTimeoutFailure(text)) return true;
+  if (typeof isRecoverableStep9AuthFailure === 'function' && isRecoverableStep9AuthFailure(text)) {
+    return true;
+  }
+  return /回调\s*url\s*提交失败|callback\s*url\s*submit\s*failed|oauth flow is not pending/i.test(text);
+}
+
 function isStep9SuccessStatus(statusText) {
   return STEP9_SUCCESS_STATUSES.has(normalizeStep9StatusText(statusText));
 }
@@ -251,37 +260,188 @@ function isStep9SuccessLikeStatus(statusText) {
   return /authentication successful|аутентификац.*успеш|认证成功/i.test(text);
 }
 
-function getStatusBadgeDiagnostics() {
-  const entries = getStatusBadgeEntries();
+function parseCssColorChannels(colorText) {
+  const text = String(colorText || '').trim().toLowerCase();
+  if (!text || text === 'transparent' || text === 'inherit' || text === 'initial' || text === 'unset') {
+    return null;
+  }
+
+  if (text.startsWith('#')) {
+    const hex = text.slice(1);
+    if (hex.length === 3 || hex.length === 4) {
+      const expanded = hex.split('').map((part) => part + part);
+      const [r, g, b, a = 'ff'] = expanded;
+      return {
+        r: Number.parseInt(r, 16),
+        g: Number.parseInt(g, 16),
+        b: Number.parseInt(b, 16),
+        a: Number.parseInt(a, 16) / 255,
+      };
+    }
+    if (hex.length === 6 || hex.length === 8) {
+      const parts = hex.match(/.{1,2}/g) || [];
+      const [r, g, b, a = 'ff'] = parts;
+      return {
+        r: Number.parseInt(r, 16),
+        g: Number.parseInt(g, 16),
+        b: Number.parseInt(b, 16),
+        a: Number.parseInt(a, 16) / 255,
+      };
+    }
+  }
+
+  if (text.startsWith('rgb')) {
+    const numericParts = text.match(/[\d.]+/g) || [];
+    if (numericParts.length >= 3) {
+      const [r, g, b, a = '1'] = numericParts.map(Number);
+      return { r, g, b, a };
+    }
+  }
+
+  return null;
+}
+
+function isReddishColor(colorText) {
+  const channels = parseCssColorChannels(colorText);
+  if (!channels) return false;
+  const { r, g, b, a = 1 } = channels;
+  if (a <= 0.05) return false;
+  return r >= 120 && r >= g + 35 && r >= b + 35;
+}
+
+function getStep9ErrorVisualSignals(element, className = '') {
+  const signals = [];
+  const normalizedClassName = String(className || '').replace(/\s+/g, ' ').trim();
+  if (/(?:error|danger|fail|destructive|text-red|text-danger|alert)/i.test(normalizedClassName)) {
+    signals.push(`class=${getInlineTextSnippet(normalizedClassName, 80)}`);
+  }
+
+  if (!element) {
+    return signals;
+  }
+
+  const style = window.getComputedStyle(element);
+  if (isReddishColor(style.color)) {
+    signals.push(`color=${style.color}`);
+  }
+  if (isReddishColor(style.borderColor)) {
+    signals.push(`border=${style.borderColor}`);
+  }
+  if (isReddishColor(style.backgroundColor)) {
+    signals.push(`background=${style.backgroundColor}`);
+  }
+
+  return signals;
+}
+
+function createStep9Entry(candidate, selector) {
+  const className = String(candidate?.className || '').replace(/\s+/g, ' ').trim();
+  const errorVisualSignals = getStep9ErrorVisualSignals(candidate, className);
+  return {
+    element: candidate,
+    selector,
+    visible: isVisibleElement(candidate),
+    text: normalizeStep9StatusText(candidate?.textContent || ''),
+    className,
+    errorVisualSignals,
+    errorVisualSummary: errorVisualSignals.join(', '),
+    hasErrorVisualSignal: errorVisualSignals.length > 0,
+  };
+}
+
+function getStep9PageErrorSelectors() {
+  return [
+    '[role="alert"]',
+    '[aria-live="assertive"]',
+    '[aria-live="polite"]',
+    '.alert',
+    '[class*="alert"]',
+    '[class*="error"]',
+    '[class*="danger"]',
+    '.text-danger',
+    '.text-red',
+  ];
+}
+
+function getStep9PageErrorEntries() {
+  const seen = new Set();
+  const entries = [];
+
+  for (const selector of getStep9PageErrorSelectors()) {
+    const candidates = document.querySelectorAll(selector);
+    for (const candidate of candidates) {
+      if (seen.has(candidate)) continue;
+      seen.add(candidate);
+      if (!isVisibleElement(candidate)) continue;
+
+      const entry = createStep9Entry(candidate, selector);
+      if (!isStep9FailureText(entry.text)) continue;
+      entries.push(entry);
+    }
+  }
+
+  return entries;
+}
+
+function buildStep9StatusDiagnostics(entries = [], pageErrorEntries = [], pageSnippet = '') {
   const visibleEntries = entries.filter((entry) => entry.visible);
-  const selectedEntry = visibleEntries[0] || null;
-  const selectedText = selectedEntry?.text || '';
   const successLikeEntries = visibleEntries.filter((entry) => isStep9SuccessLikeStatus(entry.text));
-  const exactSuccessEntries = visibleEntries.filter((entry) => isStep9SuccessStatus(entry.text));
+  const exactSuccessEntries = visibleEntries.filter((entry) => isStep9SuccessStatus(entry.text) && !entry.hasErrorVisualSignal);
+  const failureEntries = visibleEntries.filter((entry) => isStep9FailureText(entry.text));
+  const errorStyledEntries = visibleEntries.filter((entry) => entry.hasErrorVisualSignal);
+  const allFailureEntries = [...failureEntries, ...pageErrorEntries];
+  const decisiveFailureEntry = allFailureEntries[0] || null;
+  const selectedEntry = decisiveFailureEntry || exactSuccessEntries[0] || visibleEntries[0] || null;
+  const selectedText = selectedEntry?.text || '';
   const visibleSummary = summarizeStatusBadgeEntries(visibleEntries);
-  const pageSnippet = getPageTextSnippet();
+  const successLikeSummary = summarizeStatusBadgeEntries(successLikeEntries);
+  const exactSuccessSummary = summarizeStatusBadgeEntries(exactSuccessEntries);
+  const failureSummary = summarizeStatusBadgeEntries(failureEntries);
+  const pageErrorSummary = summarizeStatusBadgeEntries(pageErrorEntries);
+  const errorStyledSummary = summarizeStatusBadgeEntries(errorStyledEntries);
+  const extraFailureSuffix = pageErrorEntries.length ? `;额外错误提示:${pageErrorSummary}` : '';
+  const errorStyledSuffix = errorStyledEntries.length ? `;红色/错误样式徽标:${errorStyledSummary}` : '';
 
   return {
     selectedText,
+    exactSuccessText: exactSuccessEntries[0]?.text || '',
+    failureText: decisiveFailureEntry?.text || '',
     visibleCount: visibleEntries.length,
     visibleSummary,
     hasSuccessLikeVisibleBadge: successLikeEntries.length > 0,
     hasExactSuccessVisibleBadge: exactSuccessEntries.length > 0,
-    successLikeSummary: summarizeStatusBadgeEntries(successLikeEntries),
-    exactSuccessSummary: summarizeStatusBadgeEntries(exactSuccessEntries),
+    hasFailureVisibleBadge: allFailureEntries.length > 0,
+    hasErrorStyledVisibleBadge: errorStyledEntries.length > 0,
+    successLikeSummary,
+    exactSuccessSummary,
+    failureSummary,
+    pageErrorSummary,
+    errorStyledSummary,
     pageSnippet,
     signature: JSON.stringify({
       selectedText,
       visibleCount: visibleEntries.length,
       visibleSummary,
-      successLikeSummary: summarizeStatusBadgeEntries(successLikeEntries),
+      successLikeSummary,
+      exactSuccessSummary,
+      failureSummary,
+      pageErrorSummary,
+      errorStyledSummary,
     }),
     summary: selectedText
-      ? `当前选中徽标="${getInlineTextSnippet(selectedText, 80)}";可见徽标 ${visibleEntries.length} 个:${visibleSummary}`
-      : `当前未选中任何可见状态徽标;可见徽标 ${visibleEntries.length} 个:${visibleSummary};页面片段="${getInlineTextSnippet(pageSnippet, 120)}"`,
+      ? `当前聚焦状态="${getInlineTextSnippet(selectedText, 80)}";可见徽标 ${visibleEntries.length} 个:${visibleSummary}${extraFailureSuffix}${errorStyledSuffix}`
+      : `当前未选中任何可见状态徽标;可见徽标 ${visibleEntries.length} 个:${visibleSummary}${extraFailureSuffix}${errorStyledSuffix};页面片段="${getInlineTextSnippet(pageSnippet, 120)}"`,
   };
 }
 
+function getStatusBadgeDiagnostics() {
+  return buildStep9StatusDiagnostics(
+    getStatusBadgeEntries(),
+    getStep9PageErrorEntries(),
+    getPageTextSnippet()
+  );
+}
+
 function getStatusBadgeElement() {
   const visibleEntry = getStatusBadgeEntries().find((entry) => entry.visible);
   return visibleEntry ? visibleEntry.element : null;
@@ -292,20 +452,16 @@ function getStatusBadgeText() {
   return diagnostics.selectedText;
 }
 
-function isOAuthCallbackTimeoutFailure(statusText) {
-  return /认证失败:\s*Timeout waiting for OAuth callback/i.test(statusText || '');
-}
-
 async function waitForExactSuccessBadge(timeout = STEP9_SUCCESS_BADGE_TIMEOUT_MS) {
   const start = Date.now();
   let lastDiagnosticsSignature = '';
   let lastHeartbeatLoggedAt = 0;
   let lastSuccessLikeMismatchSignature = '';
+  let lastSuccessFailureConflictSignature = '';
 
   while (Date.now() - start < timeout) {
     throwIfStopped();
     const diagnostics = getStatusBadgeDiagnostics();
-    const statusText = diagnostics.selectedText;
     const elapsed = Date.now() - start;
 
     if (diagnostics.signature !== lastDiagnosticsSignature) {
@@ -324,36 +480,59 @@ async function waitForExactSuccessBadge(timeout = STEP9_SUCCESS_BADGE_TIMEOUT_MS
         selectedText: diagnostics.selectedText,
         successLikeSummary: diagnostics.successLikeSummary,
         visibleSummary: diagnostics.visibleSummary,
+        errorStyledSummary: diagnostics.errorStyledSummary,
       });
       if (mismatchSignature !== lastSuccessLikeMismatchSignature) {
         lastSuccessLikeMismatchSignature = mismatchSignature;
+        const errorStyledSuffix = diagnostics.hasErrorStyledVisibleBadge
+          ? `;错误样式徽标:${diagnostics.errorStyledSummary}`
+          : '';
         log(
-          `步骤 9:检测到“认证成功”相关徽标,但未命中精确条件。当前选中="${getInlineTextSnippet(diagnostics.selectedText || '(空)', 80)}";成功相关徽标:${diagnostics.successLikeSummary}`,
+          `步骤 9:检测到“认证成功”相关徽标,但未命中精确成功条件。当前聚焦="${getInlineTextSnippet(diagnostics.selectedText || '(空)', 80)}";成功相关徽标:${diagnostics.successLikeSummary}${errorStyledSuffix}`,
           'warn'
         );
         console.warn(LOG_PREFIX, '[Step 9] success-like badge detected without exact match', diagnostics);
       }
     }
 
-    if (isOAuthCallbackTimeoutFailure(statusText)) {
-      throw new Error(`STEP9_OAUTH_TIMEOUT::${statusText}`);
+    if (diagnostics.hasExactSuccessVisibleBadge && diagnostics.hasFailureVisibleBadge) {
+      const conflictSignature = JSON.stringify({
+        exactSuccessSummary: diagnostics.exactSuccessSummary,
+        failureSummary: diagnostics.failureSummary,
+        pageErrorSummary: diagnostics.pageErrorSummary,
+      });
+      if (conflictSignature !== lastSuccessFailureConflictSignature) {
+        lastSuccessFailureConflictSignature = conflictSignature;
+        const failureSummary = diagnostics.pageErrorSummary !== '无可见状态徽标'
+          ? diagnostics.pageErrorSummary
+          : diagnostics.failureSummary;
+        log(
+          `步骤 9:同时检测到成功徽标和失败提示,本轮不判定成功。成功徽标:${diagnostics.exactSuccessSummary};失败提示:${failureSummary}`,
+          'warn'
+        );
+        console.warn(LOG_PREFIX, '[Step 9] success badge is blocked by visible failure', diagnostics);
+      }
     }
-    if (typeof isRecoverableStep9AuthFailure === 'function' && isRecoverableStep9AuthFailure(statusText)) {
-      throw new Error(`STEP9_OAUTH_RETRY::${statusText}`);
+
+    if (diagnostics.failureText) {
+      if (isOAuthCallbackTimeoutFailure(diagnostics.failureText)) {
+        throw new Error(`STEP9_OAUTH_TIMEOUT::${diagnostics.failureText}`);
+      }
+      throw new Error(`STEP9_OAUTH_RETRY::${diagnostics.failureText}`);
     }
-    if (isStep9SuccessStatus(statusText)) {
-      return statusText;
+    if (diagnostics.exactSuccessText) {
+      return diagnostics.exactSuccessText;
     }
     await sleep(200);
   }
 
   const finalDiagnostics = getStatusBadgeDiagnostics();
-  const finalText = finalDiagnostics.selectedText;
+  const finalText = finalDiagnostics.failureText || finalDiagnostics.selectedText;
   const diagnosticsSuffix = ` 当前诊断:${finalDiagnostics.summary}`;
   if (isOAuthCallbackTimeoutFailure(finalText)) {
     throw new Error(`STEP9_OAUTH_TIMEOUT::${finalText}${diagnosticsSuffix}`);
   }
-  if (typeof isRecoverableStep9AuthFailure === 'function' && isRecoverableStep9AuthFailure(finalText)) {
+  if (isStep9FailureText(finalText)) {
     throw new Error(`STEP9_OAUTH_RETRY::${finalText}${diagnosticsSuffix}`);
   }
   throw new Error(finalText

+ 31 - 0
data/step-definitions.js

@@ -0,0 +1,31 @@
+(function attachStepDefinitions(root, factory) {
+  root.MultiPageStepDefinitions = factory();
+})(typeof self !== 'undefined' ? self : globalThis, function createStepDefinitionsModule() {
+  const STEP_DEFINITIONS = [
+    { id: 1, order: 10, key: 'open-chatgpt', title: '打开 ChatGPT 官网' },
+    { id: 2, order: 20, key: 'submit-signup-email', title: '注册并输入邮箱' },
+    { id: 3, order: 30, key: 'fill-password', title: '填写密码并继续' },
+    { id: 4, order: 40, key: 'fetch-signup-code', title: '获取注册验证码' },
+    { id: 5, order: 50, key: 'fill-profile', title: '填写姓名和生日' },
+    { id: 6, order: 60, key: 'oauth-login', title: '刷新 OAuth 并登录' },
+    { id: 7, order: 70, key: 'fetch-login-code', title: '获取登录验证码' },
+    { id: 8, order: 80, key: 'confirm-oauth', title: '自动确认 OAuth' },
+    { id: 9, order: 90, key: 'platform-verify', title: '平台回调验证' },
+  ];
+
+  function getSteps() {
+    return STEP_DEFINITIONS.map((step) => ({ ...step }));
+  }
+
+  function getStepById(id) {
+    const numericId = Number(id);
+    const match = STEP_DEFINITIONS.find((step) => step.id === numericId);
+    return match ? { ...match } : null;
+  }
+
+  return {
+    STEP_DEFINITIONS,
+    getStepById,
+    getSteps,
+  };
+});

+ 0 - 431
docs/Hotmail双模式适配开发方案及开发记录.md

@@ -1,431 +0,0 @@
-# Hotmail 鍙屾ā寮忛€傞厤寮€鍙戞柟妗?
-## 1. 鑳屾櫙涓庨棶棰樺垎鏋?
-褰撳墠浠撳簱鐨?Hotmail 鎺ョ爜璺緞宸茬粡鍥為€€鍒扳€滅涓夋柟閰嶇疆鈥濈増鏈紝鏍稿績鐗圭偣鏄細
-
-- 璐﹀彿姹犵户缁娇鐢?`閭 / 瀹㈡埛绔?ID / 鍒锋柊浠ょ墝` 浣滀负鎵归噺瀵煎叆鏍煎紡
-- 鎵╁睍褰撳墠娲昏穬閫昏緫鐩存帴璇锋眰绗笁鏂?HTTP 鎺ュ彛璇诲彇閭欢
-- 渚ц竟鏍忛澶栨毚闇蹭簡 `API 鍦板潃 / 鍝嶅簲绫诲瀷 / 鏀朵欢绠卞弬鏁?/ 鍨冨溇绠卞弬鏁癭 杩欑粍鍗忚缁嗚妭閰嶇疆
-
-杩欑増铏界劧鑳藉伐浣滐紝浣嗗凡缁忔毚闇插嚭涓や釜缁撴瀯鎬ч棶棰橈細
-
-1. **杩滅▼鍗忚鍜屾湰鍦板崗璁苟涓嶄竴鑷?*
-   - `#10` 鏄繙绋嬬涓夋柟鏈嶅姟妯″紡锛屾牳蹇冩槸涓€涓浐瀹氭枃妗f帴鍙?`https://apple.882263.xyz/api.html`
-   - `#42` 鏄湰鍦?helper 妯″紡锛屾牳蹇冩槸鏈湴 Python 鑴氭湰鏆撮湶 `/messages`銆乣/code` 绛夋帴鍙?   - 涓よ€呭彧鏄骇鍝佸叆鍙g浉浼硷紝涓嶆槸鍚屼竴鍗忚锛屼笉鑳界‖缁熶竴鎴愪竴濂椻€滈€氱敤 API 鍙傛暟閰嶇疆鍣ㄢ€?
-2. **褰撳墠 UI 鏆撮湶浜嗚繃澶氬崗璁粏鑺?*
-   - `鍝嶅簲绫诲瀷`
-   - `鏀朵欢绠卞弬鏁癭
-   - `鍨冨溇绠卞弬鏁癭
-   - 杩欎簺瀛楁瀵硅繙绋嬫彁渚涘晢鍗忚鏉ヨ鏄浐瀹氱殑锛屽鏈湴 helper 鍗忚鏉ヨ鍙堟牴鏈笉閫傜敤
-   - 鎶婅繖浜涚粏鑺傛毚闇茬粰鏈€缁堢敤鎴凤紝浼氭妸鐣岄潰鍙樻垚鈥滄帴鍙h皟璇曞櫒鈥濓紝澧炲姞閿欒閰嶇疆椋庨櫓
-
-3. **鐢ㄦ埛鐪熷疄闇€姹備笉鏄氦浜掓巿鏉冿紝鑰屾槸鎵归噺璐﹀彿姹?*
-   - 闇€瑕佺户缁敮鎸?`璐﹀彿----瀵嗙爜----瀹㈡埛绔疘D----鍒锋柊浠ょ墝`
-   - 闇€瑕佹敮鎸佸嚑鍗佷釜璐﹀彿鐨勬壒閲忓鍏ヤ笌鑷姩杞
-   - 鍥犳褰撳墠闃舵涓嶅簲鍒囧埌鈥滃井杞氦浜掓巿鏉冣€濅富璺嚎
-
-## 2. 宸查獙璇佸弬鑰冩潵婧?
-### 2.1 PR #10锛氳繙绋嬬涓夋柟鏈嶅姟妯″紡
-
-宸茬‘璁ょ壒寰侊細
-
-- 鎵╁睍鐩存帴璇锋眰杩滅▼鎺ュ彛
-- 鎺ュ彛鏂囨。宸插叕寮€锛歚https://apple.882263.xyz/api.html`
-- 鏍稿績鎺ュ彛鏄?`/api/mail-new`
-- 鎵╁睍浼犲叆锛?  - `client_id`
-  - `refresh_token`
-  - `email`
-  - `mailbox`
-  - `response_type`
-
-璁捐鐗圭偣锛?
-- 鍗忚鐢辨彁渚涘晢鍥哄畾锛屼笉鍙兘涓哄綋鍓嶉」鐩崟鐙慨鏀?- 鎵╁睍搴旇鍋氣€滃崗璁€傞厤鈥濓紝鑰屼笉鏄鐢ㄦ埛鎵嬪~涓€鍫嗗簳灞傚弬鏁?
-### 2.2 PR #42锛氭湰鍦?helper 妯″紡
-
-宸茬‘璁ょ壒寰侊細
-
-- 鎵╁睍璇锋眰鏈湴 helper锛歚http://127.0.0.1:17373`
-- helper 鑴氭湰浣嶄簬锛?  - `scripts/hotmail_helper.py`
-  - `start-hotmail-helper.bat`
-- 鎵╁睍浣跨敤鐨勬牳蹇冩帴鍙f槸锛?  - `/messages`
-  - `/code`
-
-璁捐鐗圭偣锛?
-- 鎵╁睍鍜岄偖绠卞崗璁В鑰?- 鏈湴鑴氭湰鎵挎媴 token 鍒锋柊銆佸彇淇°€侀獙璇佺爜绛涢€?- 鏇撮€傚悎鏈湴鐢ㄦ埛鑷帶鐜
-
-## 3. 鏈€缁堣璁″彇鑸?
-### 3.1 鐩爣鏂规
-
-Hotmail 鍖烘敼鎴?*鍙屾ā寮忛€傞厤**锛?
-- `杩滅▼鏈嶅姟`
-- `鏈湴鍔╂墜`
-
-涓ょ妯″紡缁х画鍏辩敤鍚屼竴濂楄处鍙锋睜涓庢壒閲忓鍏ユ牸寮忥細
-
-```text
-璐﹀彿----瀵嗙爜----瀹㈡埛绔疘D----鍒锋柊浠ょ墝
-```
-
-### 3.2 涓嶉噰鐢ㄧ殑鏂规
-
-#### 涓嶉噰鐢ㄢ€滃井杞氦浜掓巿鏉冣€濅富鏂规
-
-鍘熷洜锛?
-- 涓嶉€傚悎鍑犲崄涓处鍙风殑鎵归噺浣跨敤鍦烘櫙
-- 浼氱牬鍧忕幇鏈夋壒閲忓鍏ラ摼璺?- 涓庡綋鍓嶇敤鎴烽渶姹傚啿绐?
-#### 涓嶉噰鐢ㄢ€滈€氱敤 API 鍙傛暟閰嶇疆鍣ㄢ€?
-鍘熷洜锛?
-- 杩滅▼鍗忚鍜屾湰鍦板崗璁湰灏变笉鍚?- 缁х画淇濈暀 `鍝嶅簲绫诲瀷 / 鏀朵欢绠卞弬鏁?/ 鍨冨溇绠卞弬鏁癭 浼氬鍔犻厤缃鏉傚害
-- 杩欎簺瀛楁涓嶅簲鎴愪负鏈€缁堢敤鎴风殑甯歌閰嶇疆鍏ュ彛
-
-## 4. 鐩爣浜や簰璁捐
-
-### 4.1 Hotmail 鍖虹晫闈㈢粨鏋?
-鍦ㄧ幇鏈?`Hotmail 璐﹀彿姹燻 鍖哄潡涓柊澧烇細
-
-1. `Hotmail 妯″紡` 鎸夐挳缁?   - `杩滅▼鏈嶅姟`
-   - `鏈湴鍔╂墜`
-
-2. `鏈嶅姟鍦板潃` 杈撳叆妗?   - 褰撴ā寮忎负 `杩滅▼鏈嶅姟` 鏃舵樉绀鸿繙绋嬫湇鍔″湴鍧€
-   - 褰撴ā寮忎负 `鏈湴鍔╂墜` 鏃舵樉绀烘湰鍦板姪鎵嬪湴鍧€
-
-3. 淇濈暀璐﹀彿姹犺〃鍗?   - 閭
-   - 瀹㈡埛绔?ID
-   - 閭瀵嗙爜澶囨敞
-   - 鍒锋柊浠ょ墝
-   - 娣诲姞璐﹀彿
-
-4. 淇濈暀鎵归噺瀵煎叆
-   - 浠嶇劧鏀寔 `璐﹀彿----瀵嗙爜----瀹㈡埛绔疘D----鍒锋柊浠ょ墝`
-
-5. 淇濈暀璐﹀彿鎿嶄綔
-   - 浣跨敤姝よ处鍙?   - 鏍囪宸茬敤 / 鏈敤
-   - 鏍¢獙
-   - 澶嶅埗鏈€鏂伴獙璇佺爜
-   - 鍒犻櫎
-
-### 4.2 瑕佸垹闄ょ殑鏃?UI
-
-鍒犻櫎褰撳墠绗笁鏂归厤缃噷杩欏嚑涓粏椤癸細
-
-- `Hotmail API 鍦板潃`
-- `鍝嶅簲绫诲瀷`
-- `鏀朵欢绠卞弬鏁癭
-- `鍨冨溇绠卞弬鏁癭
-
-鍘熷洜锛?
-- 杩欎簺灞炰簬鍗忚缁嗚妭锛屼笉閫傚悎鏅€氫娇鐢ㄨ€?- 鍙屾ā寮忔柟妗堥噷鍙渶瑕侀厤缃€滄湇鍔″湴鍧€鈥?
-## 5. 鍚庡彴璁捐
-
-### 5.1 鎸佷箙鍖栭厤缃」
-
-鏂板骞朵娇鐢ㄤ互涓嬫寔涔呭寲瀛楁锛?
-- `hotmailServiceMode`
-  - 鍊硷細`remote` / `local`
-- `hotmailRemoteBaseUrl`
-  - 榛樿锛氳繙绋嬫湇鍔℃枃妗f墍鍦ㄥ煙鍚?- `hotmailLocalBaseUrl`
-  - 榛樿锛歚http://127.0.0.1:17373`
-
-鍒犻櫎褰撳墠浠呮湇鍔′簬绗笁鏂归€氱敤閰嶇疆鍣ㄧ殑瀛楁锛?
-- `hotmailApiUrl`
-- `hotmailApiResponseType`
-- `hotmailApiInboxMailbox`
-- `hotmailApiJunkMailbox`
-
-### 5.2 璇锋眰閫傞厤灞?
-鏂板缁熶竴鐨勬ā寮忓垎鍙戝眰锛屼絾**涓嶇粺涓€搴曞眰鍗忚**銆?
-#### 杩滅▼妯″紡
-
-鐩存帴鍏煎 PR #10 鏂囨。鍗忚锛?
-- 璋冪敤杩滅▼ `baseUrl + /api/mail-new`
-- 鎵╁睍绔繚鎸佺幇鏈夆€滄媺娑堟伅鍚庢湰鍦扮瓫楠岃瘉鐮佲€濈殑妯″紡
-
-#### 鏈湴妯″紡
-
-鐩存帴鍏煎 PR #42 helper 鍗忚锛?
-- `/messages`
-- `/code`
-
-鎵╁睍绔€昏緫锛?
-- `鏍¢獙` / `娴嬭瘯鏀朵俊` 璧?`/messages`
-- 楠岃瘉鐮佽疆璇紭鍏堣蛋 `/code`
-
-### 5.3 闇€瑕佹柊澧炴垨璋冩暣鐨勫悗鍙板嚱鏁?
-1. 閰嶇疆褰掍竴鍖?   - `normalizeHotmailServiceMode`
-   - `normalizeHotmailRemoteBaseUrl`
-   - `normalizeHotmailLocalBaseUrl`
-   - `getHotmailServiceSettings`
-
-2. 杩滅▼妯″紡閫傞厤
-   - `requestHotmailRemoteMailbox`
-   - `fetchHotmailRemoteMessages`
-
-3. 鏈湴妯″紡閫傞厤
-   - `requestHotmailLocalMessages`
-   - `requestHotmailLocalCode`
-   - `fetchHotmailLocalMessages`
-
-4. 缁熶竴鍒嗗彂鍏ュ彛
-   - `fetchHotmailMailboxMessages`
-   - `pollHotmailVerificationCode`
-   - 鏍规嵁妯″紡鍒嗘祦
-
-## 6. 鏈湴 helper 鏂囦欢绛栫暐
-
-鐩存帴寮曞叆骞堕€傞厤 PR #42 宸查獙璇佺殑鏈湴鑴氭湰鏂规锛?
-- `scripts/hotmail_helper.py`
-- `start-hotmail-helper.bat`
-
-寮曞叆鍘熷垯锛?
-- 浼樺厛淇濇寔鍗忚鍏煎锛屼笉鍋氭棤蹇呰閲嶆瀯
-- 鍙帴鍏ュ綋鍓嶆墿灞曢渶瑕佺敤鍒扮殑鎺ュ彛涓庡惎鍔ㄦ柟寮?- 涓嶅湪鏈疆鎵╁睍寮€鍙戜腑缁х画寮曞叆涓?Hotmail 妯″紡鍒囨崲鏃犲叧鐨勫ぇ瑙勬ā鐘舵€侀€昏緫
-
-## 7. 涓庣幇鏈夐€昏緫鐨勫吋瀹硅姹?
-### 7.1 蹇呴』淇濇寔涓嶅彉鐨勮涓?
-- `Mail = Hotmail` 鏃朵粛鐒剁敱璐﹀彿姹犲垎閰嶉偖绠?- `璐﹀彿----瀵嗙爜----瀹㈡埛绔疘D----鍒锋柊浠ょ墝` 鎵归噺瀵煎叆鏍煎紡蹇呴』缁х画鍙敤
-- `鏍¢獙` / `澶嶅埗鏈€鏂伴獙璇佺爜` / `Auto` 鐨勭敤鎴峰叆鍙d笉鍙?- Hotmail 璐﹀彿鐨?`used / status / lastError / lastAuthAt / lastUsedAt` 閫昏緫淇濇寔鐜版湁璇箟
-
-### 7.2 蹇呴』閬垮厤鐨勯棶棰?
-- 涓嶈兘鎶婅繙绋嬪拰鏈湴寮鸿鎶芥垚涓€濂楀亣缁熶竴鍗忚
-- 涓嶈兘鎶婄涓夋柟鏈嶅姟瑕佹眰鏀规垚 `/messages` `/code`
-- 涓嶈兘鍒犻櫎鎵归噺瀵煎叆 refresh token 鐨勮兘鍔?- 涓嶈兘鍙敼 UI锛屼笉鎺ュ悗鍙板垎鍙?- 涓嶈兘鍙帴鍚庡彴锛屼笉鍚屾 README 涓庤鏄?
-## 8. 鏂规鑷涓庡畬鏁存€у垎鏋?
-### 8.1 鏂规鏄惁绗﹀悎闇€姹?
-绗﹀悎銆?
-鍘熷洜锛?
-- 淇濈暀鎵归噺瀵煎叆
-- 涓嶅己杩敤鎴疯蛋寰蒋浜や簰鎺堟潈
-- 鍚屾椂鏀寔宸查獙璇佺殑杩滅▼涓庢湰鍦颁袱绉嶈矾寰?
-### 8.2 鏂规鏄惁瀹屾暣
-
-瀹屾暣銆?
-宸茬粡瑕嗙洊锛?
-- 閰嶇疆妯″瀷
-- 鐣岄潰鍙樻洿
-- 鍚庡彴鍒嗗彂
-- helper 寮曞叆
-- 鏂囨。鏇存柊
-- 鑷椤?
-### 8.3 鏂规鏄惁瀛樺湪鍐呴儴鍐茬獊
-
-褰撳墠鏃犳槑鏄惧啿绐併€?
-鍞竴闇€瑕佷弗鏍兼帶鍒剁殑鏄細
-
-- 杩滅▼妯″紡涓庢湰鍦版ā寮忚櫧鐒跺叆鍙g粺涓€锛屼絾鍗忚涓嶈兘娣风敤
-- 瀹炵幇鏃跺繀椤绘槑纭垎灞傦紝涓嶈鍋氭垚鈥滄牴鎹厤缃嫾涓嶅悓 query 鍙傛暟鈥濈殑鏉傜硡閫昏緫
-
-### 8.4 鏂规娼滃湪缂洪櫡
-
-1. 鏈湴 helper 鍙兘渚濊禆 Python 鐜
-   - 闇€瑕佸湪 README 涓槑纭鏄庡浣曞惎鍔?
-2. 杩滅▼鏈嶅姟鏂囨。鑻ユ湭鏉ュ彉鏇?   - 褰撳墠浠嶄緷璧栨湇鍔″晢鎺ュ彛绋冲畾鎬?
-3. 褰撳墠浠撳簱鏄棫椤圭洰
-   - Hotmail 閫昏緫鍛ㄨ竟宸叉湁杈冨鐘舵€佸瓧娈?   - 瀹炵幇鏃跺繀椤昏皑鎱庢鏌?`verify/test/poll/auto-run` 鍥涙潯閾炬槸鍚︾粺涓€鎸夋ā寮忓垎鍙?
-## 9. 寮€鍙戞竻鍗?
-### 闃舵 1锛氶厤缃ā鍨嬩笌鏂规钀界洏
-
-- 鏂板缓鏈柟妗堟枃妗?- 鏂板 Hotmail 鍙屾ā寮忔寔涔呭寲瀛楁
-- 鍒犻櫎鏃х殑绗笁鏂瑰崗璁粏椤瑰瓧娈?
-鑷锛?
-- 閰嶇疆椤瑰懡鍚嶆槸鍚︾粺涓€
-- 榛樿鍊兼槸鍚﹀畬鏁?- import/export 鏄惁浠嶅彲宸ヤ綔
-
-### 闃舵 2锛欻otmail UI 鏀归€?
-- 鏂板 `杩滅▼鏈嶅姟 / 鏈湴鍔╂墜` 妯″紡鎸夐挳
-- 鏂板 `鏈嶅姟鍦板潃` 杈撳叆妗?- 鍒犻櫎鏃х殑 `鍝嶅簲绫诲瀷 / 鏀朵欢绠卞弬鏁?/ 鍨冨溇绠卞弬鏁癭 杈撳叆妗?- 淇濈暀璐﹀彿姹犺〃鍗曚笌鎵归噺瀵煎叆
-
-鑷锛?
-- 鍒囨崲妯″紡鍚庢樉绀烘槸鍚︽纭?- 鑷姩淇濆瓨鏄惁浠嶆甯?- 涓嶅悓妯″紡鐨勫湴鍧€鏄惁涓嶄細浜掔浉瑕嗙洊
-
-### 闃舵 3锛氬悗鍙板弻妯″紡閫傞厤
-
-- 瀹炵幇杩滅▼妯″紡璇锋眰閫傞厤
-- 瀹炵幇鏈湴妯″紡 helper 璇锋眰閫傞厤
-- 淇敼 `fetchHotmailMailboxMessages`
-- 淇敼 `pollHotmailVerificationCode`
-- 鏍¢獙 `verify / test / auto-run` 涓夋潯閾?
-鑷锛?
-- 杩滅▼妯″紡鏄惁浠嶅吋瀹?`#10`
-- 鏈湴妯″紡鏄惁鍏煎 `#42`
-- 閿欒淇℃伅鏄惁浠嶄細鍐欏洖 `lastError`
-
-### 闃舵 4锛氬紩鍏ユ湰鍦?helper
-
-- 寮曞叆 `scripts/hotmail_helper.py`
-- 寮曞叆 `start-hotmail-helper.bat`
-- 妫€鏌ユ湰鍦板崗璁笌鎵╁睍璋冪敤涓€鑷?
-鑷锛?
-- helper 璺緞鏄惁姝g‘
-- 绔彛涓庢枃妗f槸鍚︿竴鑷?- 鎵╁睍璇锋眰璺緞鏄惁鍖归厤 helper
-
-### 闃舵 5锛氭枃妗f敹灏?
-- 鏇存柊 README 涓?Hotmail 浣跨敤璇存槑
-- 鏄庣‘杩滅▼妯″紡涓庢湰鍦版ā寮忕殑鍖哄埆
-- 鏄庣‘鏈湴 helper 鍚姩鏂瑰紡
-
-鑷锛?
-- README 鏄惁鍜岀湡瀹?UI 涓€鑷?- 鐢ㄦ埛鏄惁鑳界湅鏂囨。瀹屾垚閰嶇疆
-
-## 10. 瀹炴柦椤哄簭
-
-鏈疆寮€鍙戜弗鏍兼寜浠ヤ笅椤哄簭鎵ц锛?
-1. 鍏堝啓鏈柟妗堟枃妗?2. 鍐嶆敼閰嶇疆妯″瀷
-3. 鍐嶆敼 UI
-4. 鍐嶆敼鍚庡彴鍙屾ā寮忓垎鍙?5. 鍐嶅紩鍏ユ湰鍦?helper
-6. 鏈€鍚庣粺涓€鍋氶潤鎬佽嚜妫€骞舵彁閱掔敤鎴锋墜娴?
-## 11. 瀹為檯寮€鍙戣褰?
-### 11.1 宸插畬鎴愭敼鍔?
-鏈疆宸插疄闄呭畬鎴愪互涓嬪紑鍙戝唴瀹癸細
-
-1. Hotmail 閰嶇疆妯″瀷浠庘€滅涓夋柟閫氱敤缁嗛」閰嶇疆鈥濆垏鎹负鈥滃弻妯″紡 + 鍦板潃閰嶇疆鈥?   - 鍒犻櫎鍘熸湰闈㈠悜绗笁鏂圭粏椤圭殑鎬濊矾锛?     - `hotmailApiUrl`
-     - `hotmailApiResponseType`
-     - `hotmailApiInboxMailbox`
-     - `hotmailApiJunkMailbox`
-   - 鏀逛负锛?     - `hotmailServiceMode`
-     - `hotmailRemoteBaseUrl`
-     - `hotmailLocalBaseUrl`
-
-2. Sidepanel Hotmail 鍖哄凡鏀归€犳垚鍙屾ā寮?UI
-   - 鏂板 `杩滅▼鏈嶅姟 / 鏈湴鍔╂墜` 鎸夐挳缁?   - 鏂板杩滅▼鏈嶅姟鍦板潃杈撳叆妗?   - 鏂板鏈湴鍔╂墜鍦板潃杈撳叆妗?   - 淇濈暀鍘熸湁璐﹀彿姹犺〃鍗曞拰鎵归噺瀵煎叆鏍煎紡
-
-3. 鍚庡彴 Hotmail 璇锋眰閾惧凡鎸夋ā寮忓垎娴?   - 杩滅▼妯″紡锛?     - 鐩存帴鍏煎 `#10`
-     - 浣跨敤 `baseUrl + /api/mail-new`
-   - 鏈湴妯″紡锛?     - 鐩存帴鍏煎 `#42`
-     - 浣跨敤 `/messages`
-     - 浣跨敤 `/code`
-
-4. 鏈湴 helper 鏂囦欢宸茶惤鍦?   - `scripts/hotmail_helper.py`
-   - `start-hotmail-helper.bat`
-
-5. README 宸插悓姝ユ洿鏂颁负鍙屾ā寮忚鏄?
-### 11.2 瀹為檯淇敼鏂囦欢
-
-鏈疆瀹為檯鏀瑰姩鏂囦欢濡備笅锛?
-- `background.js`
-- `sidepanel/sidepanel.html`
-- `sidepanel/sidepanel.js`
-- `README.md`
-- `scripts/hotmail_helper.py`
-- `start-hotmail-helper.bat`
-
-鏈疆鏂板浣嗕笉涓€瀹氳繘鍏?Git 鐨勬枃妗o細
-
-- `docs/md/Hotmail鍙屾ā寮忛€傞厤寮€鍙戞柟妗?md`
-
-### 11.3 瀹為檯瀹炵幇涓庡師鏂规鐨勫樊寮?
-褰撳墠瀹炵幇涓庡師鏂规鐩告瘮锛屽瓨鍦ㄨ繖浜涘疄闄呭樊寮傦細
-
-1. 鏈湴 helper 娌℃湁鏁翠唤鐓ф惉 `#42`
-   - 褰撳墠鏄寜 `#42` 鐨勫崗璁€濊矾钀戒簡涓€涓畝鍖栫増
-   - 鐩爣鏄厛婊¤冻鎵╁睍鐜伴樁娈电殑 `/messages` 涓?`/code`
-   - 娌℃湁缁х画寮曞叆 `#42` 閲屾洿閲嶇殑澶辫触鐘舵€併€佹寔涔呭凡鐢ㄩ偖绠便€佸苟鍙戜繚鎶ょ瓑鏁村鎵╁睍閫昏緫
-
-2. 杩滅▼妯″紡鐩墠浠嶆部鐢ㄦ墿灞曠鏈湴绛涢獙璇佺爜
-   - 鍏煎 `#10` 鐨勫崟鎺ュ彛 `/api/mail-new`
-   - 鏈澶栧鎺?`mail-all`銆乣process-inbox`銆乣process-junk`
-
-3. 娴嬭瘯鏂囦欢鏈疆娌℃湁缁х画琛ラ綈
-   - 褰撳墠涓昏瀹屾垚鐨勬槸鏂规钀藉湴鍜屼富閾炬敼閫?   - 灏氭湭琛ラ拡瀵瑰弻妯″紡鐨勬渶灏忓洖褰掓祴璇曟々
-
-### 11.4 褰撳墠宸茬煡鏈畬鎴愰」
-
-浠ヤ笅鍐呭褰撳墠浠嶆湭瀹屾垚鎴栨湭楠岃瘉锛?
-1. 鏈ˉ Hotmail 鍙屾ā寮忓搴旂殑娴嬭瘯妗?   - 杩滅▼妯″紡楠岃瘉閫昏緫
-   - 鏈湴妯″紡 `/messages` 涓?`/code` 鍒嗗彂閫昏緫
-   - 妯″紡鍒囨崲鍚庣殑 UI 鐘舵€佸洖濉?
-2. 鏈 helper 鍋氳繍琛岀骇瀹炴祴
-   - 褰撳墠鍙畬鎴愪唬鐮佹帴鍏?   - 灏氭湭纭鏈満 Python 鐜涓?`imaplib + XOAUTH2 + login.live.com/oauth20_token.srf` 鏄惁鑳界ǔ瀹氬伐浣?
-3. 鏈獙璇佽繙绋嬫湇鍔″湴鍧€鐨勪笉鍚岃緭鍏ュ舰寮?   - 渚嬪鐢ㄦ埛濉細
-     - 鏍瑰煙鍚?     - `/api.html`
-     - `/api/mail-new`
-   - 鐩墠浠g爜鍋氫簡璺緞褰掍竴鍖栵紝浣嗛渶瑕佷汉宸ユ墜娴嬬‘璁?
-4. 鏈ˉ README 涓洿缁嗙殑鏈湴 helper 鍚姩鎺掗敊璇存槑
-   - 褰撳墠鍙啓浜嗗叆鍙h鏄?   - 杩樻病鍐欌€滃惎鍔ㄥけ璐ユ€庝箞鐪嬨€佺鍙e崰鐢ㄦ€庝箞鍔炪€丳ython 涓嶅瓨鍦ㄦ€庝箞鍔炩€?
-### 11.5 褰撳墠鎵嬫祴閲嶇偣
-
-鍚庣画浼樺厛鎵嬫祴浠ヤ笅鍐呭锛?
-1. 杩滅▼妯″紡
-   - `Mail = Hotmail`
-   - 妯″紡鍒囧埌 `杩滅▼鏈嶅姟`
-   - 鍦板潃濉?`https://apple.882263.xyz`
-   - 娴嬶細
-     - `鏍¢獙`
-     - `澶嶅埗鏈€鏂伴獙璇佺爜`
-     - `Auto`
-
-2. 鏈湴妯″紡
-   - 鍏堣繍琛?`start-hotmail-helper.bat`
-   - 妯″紡鍒囧埌 `鏈湴鍔╂墜`
-   - 鍦板潃淇濇寔 `http://127.0.0.1:17373`
-   - 娴嬶細
-     - `鏍¢獙`
-     - `澶嶅埗鏈€鏂伴獙璇佺爜`
-     - `Auto`
-
-3. 妯″紡鍒囨崲
-   - 浠?`杩滅▼鏈嶅姟` 鍒囧埌 `鏈湴鍔╂墜`
-   - 浠?`鏈湴鍔╂墜` 鍒囧埌 `杩滅▼鏈嶅姟`
-   - 鐪嬪湴鍧€鏄惁鍒嗗埆淇濈暀
-   - 鐪?Hotmail 璐﹀彿姹犳槸鍚︿笉鍙楀奖鍝?
-4. 鎵归噺瀵煎叆
-   - 瀵煎叆鏍煎紡锛?     - `璐﹀彿----瀵嗙爜----瀹㈡埛绔疘D----鍒锋柊浠ょ墝`
-   - 妫€鏌ユā寮忓垏鎹㈠悗瀵煎叆鑳藉姏鏄惁浠嶆甯?
-### 11.6 褰撳墠椋庨櫓缁撹
-
-褰撳墠鏈€澶ч闄╀笉鍦?UI锛岃€屽湪杩欎袱涓偣锛?
-1. 鏈湴 helper 鐨勭湡瀹炲彲杩愯鎬?   - 浠g爜宸茬粡鎺ヤ笂
-   - 浣嗘湭缁忓疄闄呰繍琛岄獙璇?
-2. 杩滅▼涓庢湰鍦扮殑杩斿洖缁撴瀯鍏煎鎬?   - 杩滅▼妯″紡璧?`#10` 椋庢牸
-   - 鏈湴妯″紡璧?`#42` 椋庢牸
-   - 铏界劧鍚庡彴宸茬粡鍋氫簡閫傞厤锛屼絾浠嶉渶瑕佸疄闄呰繑鍥炴牱鏈獙璇?
-### 11.7 褰撳墠闃舵缁撹
-
-褰撳墠鐘舵€佸彲浠ョ悊瑙d负锛?
-- 鏂规宸茶惤鐩?- 鍙屾ā寮忎富缁撴瀯宸插紑鍙戝畬鎴?- 杩滅▼ / 鏈湴涓ゆ潯鍗忚宸叉帴鍏?- 璐﹀彿姹犱笌鎵归噺瀵煎叆浠嶄繚鐣?- 浣嗗皻鏈粡杩囩湡瀹炶繍琛岄獙璇?
-鍥犳鏈疆寮€鍙戠粨鏋滃睘浜庯細
-
-- **浠g爜缁撴瀯宸插埌浣?*
-- **杩愯绋冲畾鎬у緟浜哄伐楠岃瘉**
-
-## 12. 本地 helper 运行说明补充
-
-### 12.1 启动命令
-
-Windows:
-
-```powershell
-.\start-hotmail-helper.bat
-```
-
-macOS:
-
-```bash
-chmod +x ./start-hotmail-helper.command
-./start-hotmail-helper.command
-```
-
-如果不使用启动脚本,也可以直接运行:
-
-```bash
-python scripts/hotmail_helper.py
-```
-
-或:
-
-```bash
-python3 scripts/hotmail_helper.py
-```
-
-### 12.2 启动成功标志
-
-启动成功后,终端应输出:
-
-```text
-Hotmail helper listening on http://127.0.0.1:17373
-```
-
-### 12.3 最小排错说明
-
-- 若提示 `Python 3 not found`,说明当前机器没有可用的 Python 3.10+。
-- 若 helper 已启动但扩展仍连不上,先确认 Hotmail 模式切到了 `本地助手`。
-- 再确认侧边栏中的本地助手地址与终端输出一致,默认应为 `http://127.0.0.1:17373`。
-- 若地址一致仍失败,再检查终端中是否已经打印异常,或是否有端口占用。

+ 189 - 0
docs/refactor/2026-04-16-architecture-refactor-plan.md

@@ -0,0 +1,189 @@
+# 2026-04-16 Architecture Refactor Plan
+
+## 目标
+
+在不破坏现有 1~9 步自动化流程、消息流、状态机和测试保护面的前提下,逐步拆分当前超大的主文件,降低后续开发和排障成本。
+
+## 当前事实
+
+- 这是一个无构建步骤的 Manifest V3 扩展。
+- `background.js` 是 classic service worker,当前依赖 `importScripts(...)`。
+- `sidepanel/sidepanel.html` 通过多个 `<script>` 直接加载脚本。
+- 运行态核心链路依赖:
+  - `chrome.storage.session` 保存流程态
+  - `chrome.storage.local` 保存配置态
+  - `chrome.runtime.sendMessage` 贯穿 sidepanel / background / content scripts
+  - `chrome.tabs` / `chrome.webNavigation` / `chrome.debugger` 支撑步骤执行
+- 测试不仅验证行为,还会直接从 `background.js` / `sidepanel.js` / `content/*.js` 按函数名提取源码执行。
+
+## 已确认基线
+
+- `bun test` 当前基线:`84 pass / 0 fail`
+- 关键大文件规模:
+  - `background.js`: 7481 行
+  - `sidepanel/sidepanel.js`: 4531 行
+  - `content/signup-page.js`: 1969 行
+
+## 当前结构压力点
+
+### `background.js`
+
+- 状态/配置、邮箱提供者、Tab 管理、消息路由、自动运行状态机、步骤 1~9 全部集中在单文件。
+- 文件内同时存在:
+  - 纯函数
+  - 网络请求
+  - 存储读写
+  - 浏览器 API 调度
+  - 步骤业务逻辑
+- 风险最高的区域不是“代码长”,而是“跨区域共享状态太多”。
+
+### `sidepanel/sidepanel.js`
+
+- 同时承担:
+  - DOM 查询
+  - 状态同步
+  - 配置读写
+  - 多个 provider 管理器
+  - 按钮事件绑定
+  - background 广播处理
+- Hotmail / LuckMail / iCloud 三块都已经是天然的 feature slice,但仍堆在一个文件里。
+
+## 重构硬约束
+
+### 约束 1:先拆“强内聚功能块”,后拆“流程主干”
+
+优先拆:
+
+- Hotmail 账号池 UI
+- LuckMail 管理 UI
+- iCloud 别名管理 UI
+- CPA / SUB2API 面板桥接层
+
+暂缓拆:
+
+- `executeStep1~9`
+- `autoRunLoop`
+- `handleMessage`
+- `resolveVerificationStep`
+
+原因:这些主干函数既是运行核心,也是当前测试抽取最密集的区域。
+
+### 约束 2:第一阶段尽量不改变被测试函数的“定义位置”
+
+由于现有测试会直接从源文件提取函数源码,第一阶段要避免大规模把被测函数从原文件移走。
+
+### 约束 3:运行边界不能变
+
+以下运行边界必须保持:
+
+- `background.js` 继续是 service worker 入口
+- `sidepanel/sidepanel.html` 继续是 sidepanel 入口
+- content script 注入方式不变
+- 所有 runtime message type 不变
+- storage key 不变
+
+## 目标结构
+
+### 背景层目标
+
+```txt
+background.js
+background/
+  state/
+    settings.js
+    session-state.js
+  runtime/
+    tabs.js
+    command-queue.js
+    logging.js
+    message-router.js
+  providers/
+    hotmail.js
+    luckmail.js
+    icloud.js
+    generated-email.js
+  steps/
+    step1.js
+    step2.js
+    step3.js
+    verification.js
+    step5.js
+    step6.js
+    step7.js
+    step8.js
+    step9.js
+  auto-run/
+    scheduler.js
+    loop.js
+```
+
+### Sidepanel 层目标
+
+```txt
+sidepanel/sidepanel.js
+sidepanel/
+  hotmail-manager.js
+  luckmail-manager.js
+  icloud-manager.js
+  auto-run-ui.js
+  runtime-listeners.js
+  update-service.js
+```
+
+## 分阶段执行顺序
+
+### Phase 1
+
+先拆 `sidepanel` 的强内聚管理区块。
+
+- 先拆 Hotmail manager
+- 再拆 iCloud manager
+- 再拆 LuckMail manager
+
+原因:
+
+- 不触碰主流程状态机
+- 风险主要局限在 sidepanel UI
+- 可以验证“多脚本 + 工厂上下文”的拆分模式
+
+### Phase 2
+
+拆 `background.js` 中和主流程相对解耦的桥接层。
+
+- CPA / SUB2API OAuth 桥接
+- 邮箱 provider 的 API 访问层
+- Cloudflare / Duck / iCloud 生成邮箱层
+
+### Phase 3
+
+拆 `background.js` 中的运行时基础设施。
+
+- tab registry
+- command queue
+- logging
+- storage helpers
+
+### Phase 4
+
+最后拆步骤执行器与 auto-run 主循环。
+
+- `executeStep1~9`
+- `resolveVerificationStep`
+- `autoRunLoop`
+
+## 第一阶段落地策略
+
+本轮先做:
+
+1. 新增 `sidepanel/hotmail-manager.js`
+2. 让 `sidepanel.js` 通过工厂方式接入 Hotmail manager
+3. 不改变 runtime message type
+4. 不改变 Hotmail 区块的 UI 行为
+5. 跑现有测试确认基线仍然成立
+
+## 验收标准
+
+- `bun test` 继续全绿
+- sidepanel Hotmail 区块行为无回归
+- `sidepanel.js` 不再直接承载 Hotmail 账号池的大段渲染和事件逻辑
+- 后续可以按同样模式继续拆 iCloud / LuckMail

+ 32 - 1
docs/仓库协作者AI分析PR与合并标准流程.md

@@ -4,6 +4,12 @@
 
 本文件用于给仓库协作者自己电脑上的 AI 一个固定、完整、可重复执行的 PR 处理流程。
 
+执行本流程时,AI 必须同时把下面三份根目录文档作为审查依据一起阅读:
+
+- [项目文件结构说明.md](c:/Users/projectf/Downloads/codex注册扩展/项目文件结构说明.md)
+- [项目完整链路说明.md](c:/Users/projectf/Downloads/codex注册扩展/项目完整链路说明.md)
+- [项目开发规范(AI协作).md](c:/Users/projectf/Downloads/codex注册扩展/项目开发规范(AI协作).md)
+
 下次只要告诉 AI:
 
 - 本仓库根目录
@@ -28,6 +34,14 @@
 12. 如果流程执行过程中,PR 的实时目标分支被别人改掉,或者不再是 `dev`,AI 必须停止当前合并流程,重新拉取信息后再决定下一步。
 13. 进入“阶段 2:分析与审查”时,AI 必须先给当前 PR 添加 `审查中` 标签,并在开始正式审查前确认该标签已经在线上生效。
 14. 审查结束后,AI 必须根据实际结论把 PR 标签从 `审查中` 切换为 `完成` 或 `等待修改`,并同时把受理人设置为自己;在确认线上状态已更新前,不允许声称审查已完成。
+15. 审查时必须检查该 PR 是否遵守 [项目开发规范(AI协作).md](c:/Users/projectf/Downloads/codex注册扩展/项目开发规范(AI协作).md),不能只检查功能是否可用。
+16. 如果 PR 功能本身没问题,但明显违反开发规范,例如:
+   - 把大段逻辑重新堆回主文件
+   - 没补测试或破坏测试边界
+   - 没同步文档
+   - 破坏共享步骤定义/注册表
+   - 引入可见乱码
+   则也必须判定为“需要修改后再继续”,不能直接给出可合并结论。
 
 ## 输入要求
 
@@ -127,6 +141,7 @@ gh pr edit <PR_NUMBER> --add-label "审查中" --repo <OWNER/REPO>
    - 这些改动是否合理
    - 是否存在 bug / 风险 / 逻辑冲突
    - PR 当前是否可直接合并,例如 `mergeable`、`mergeStateStatus`
+   - 是否遵守项目开发规范
 6. 如果发现问题,结论必须按严重级别排序,优先写真正会影响功能、合并或后续维护的问题。
 7. 如果没有发现明确问题,也要说明剩余风险,例如:
    - 未运行测试
@@ -136,6 +151,12 @@ gh pr edit <PR_NUMBER> --add-label "审查中" --repo <OWNER/REPO>
    - PR 仍然是打开状态
    - PR 不是 draft
    - `baseRefName` 仍然是 `dev`
+9. 审查输出中必须单独包含一节“开发规范符合性检查”,至少明确说明:
+   - 是否违反 [项目开发规范(AI协作).md](c:/Users/projectf/Downloads/codex注册扩展/项目开发规范(AI协作).md)
+   - 是否需要补测试
+   - 是否需要补文档
+   - 是否发现可见乱码或编码异常
+   - 是否存在把逻辑重新堆回主文件的情况
 
 ## 分析后评论规则
 
@@ -144,7 +165,7 @@ gh pr edit <PR_NUMBER> --add-label "审查中" --repo <OWNER/REPO>
 如果发现需要作者或维护者注意的问题,AI 必须自动发 PR 评论,并且评论格式必须固定如下:
 
 ```text
-(自动回复)AI分析结果(不一定完全正确,请仓库协作者确认无问题后再继续):
+(自动回复)AI分析结果(不一定完全正确,请进行确认,若无问题后请评论回复):
 
 <这里写正式评论内容>
 ```
@@ -160,6 +181,12 @@ gh pr edit <PR_NUMBER> --add-label "审查中" --repo <OWNER/REPO>
 7. 如果结论是“当前不能通过审查”,AI 必须明确写出“需要修改后再继续”,不能只写模糊提醒。
 8. 如果当前环境支持正式 PR review,且问题足以阻止合并,优先使用带“要求更改(Request changes)”语义的审查,而不是只留普通闲聊式评论。
 9. 行级评论是对总评论的补充,不得用零散行级评论替代总评论结论。
+10. 如果问题属于“开发规范不符合”,总评论中必须明确写出违反的是哪类规范,例如:
+   - 模块边界不符合
+   - 测试缺失
+   - 文档未同步
+   - 出现乱码
+   - 共享步骤定义未同步
 
 ### 没问题时
 
@@ -252,6 +279,10 @@ git merge --no-ff --no-commit origin/dev
 6. 要重新拉取一次实时 PR 元数据,确认该 PR 的 `baseRefName` 仍然是 `dev`;如果不是,停止后续合并并先反馈用户。
 7. 要确认 PR 当前已经回到可继续处理的状态,例如不再是 `draft`,且 `mergeable` / `mergeStateStatus` 没有出现新的阻塞。
 8. 默认不编译测试,最后提醒用户自行测试。
+9. 如果本地代修过程中涉及结构调整、功能链路变化或开发边界变化,必须同时检查并在必要时更新:
+   - [项目文件结构说明.md](c:/Users/projectf/Downloads/codex注册扩展/项目文件结构说明.md)
+   - [项目完整链路说明.md](c:/Users/projectf/Downloads/codex注册扩展/项目完整链路说明.md)
+   - [项目开发规范(AI协作).md](c:/Users/projectf/Downloads/codex注册扩展/项目开发规范(AI协作).md)
 
 ## 合并提交信息规则
 

+ 1 - 1
manifest.json

@@ -1,7 +1,7 @@
 {
   "manifest_version": 3,
   "name": "多页面自动化",
-  "version": "10.2.0",
+  "version": "10.4.0",
   "description": "用于自动执行多步骤 OAuth 注册流程",
   "permissions": [
     "sidePanel",

+ 41 - 0
scripts/hotmail_helper.py

@@ -2,8 +2,11 @@ import email
 import html
 import imaplib
 import json
+import os
 import re
+import threading
 import time
+import traceback
 from datetime import datetime, timezone
 from email.header import decode_header
 from email.utils import parseaddr, parsedate_to_datetime
@@ -59,6 +62,9 @@ IMAP_HOST = "outlook.office365.com"
 IMAP_PORT = 993
 REQUEST_TIMEOUT_SECONDS = 45
 FETCH_LIMIT_DEFAULT = 5
+BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
+ACCOUNT_LOG_PATH = os.path.join(BASE_DIR, "data", "account-run-history.txt")
+ACCOUNT_LOG_LOCK = threading.Lock()
 
 
 def json_response(handler, status, payload):
@@ -113,6 +119,24 @@ def log_info(message):
     print(f"[HotmailHelper] {message}", flush=True)
 
 
+def append_account_log(email_addr, password, status, recorded_at="", reason=""):
+    normalized_email = str(email_addr or "").strip()
+    normalized_password = str(password or "").strip()
+    normalized_status = str(status or "").strip().lower()
+    normalized_recorded_at = str(recorded_at or "").strip() or datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
+    normalized_reason = str(reason or "").strip().replace("\r", " ").replace("\n", " ")
+
+    if not normalized_email or not normalized_password or not normalized_status:
+        raise RuntimeError("Missing email/password/status for account log append")
+
+    os.makedirs(os.path.dirname(ACCOUNT_LOG_PATH), exist_ok=True)
+    line = f"{normalized_recorded_at}\t{normalized_email}\t{normalized_password}\t{normalized_status}\t{normalized_reason}\n"
+    with ACCOUNT_LOG_LOCK:
+        with open(ACCOUNT_LOG_PATH, "a", encoding="utf-8") as handle:
+            handle.write(line)
+    return ACCOUNT_LOG_PATH
+
+
 def try_refresh_access_token(endpoint, client_id, refresh_token):
     request_data = {
         "client_id": client_id,
@@ -600,6 +624,21 @@ class HotmailHelperHandler(BaseHTTPRequestHandler):
     def do_POST(self):
         try:
             payload = read_json_payload(self)
+
+            if self.path == "/append-account-log":
+                file_path = append_account_log(
+                    payload.get("email"),
+                    payload.get("password"),
+                    payload.get("status"),
+                    payload.get("recordedAt"),
+                    payload.get("reason"),
+                )
+                json_response(self, 200, {
+                    "ok": True,
+                    "filePath": file_path,
+                })
+                return
+
             email_addr = str(payload.get("email") or "").strip()
             client_id = str(payload.get("clientId") or "").strip()
             refresh_token = str(payload.get("refreshToken") or "").strip()
@@ -643,12 +682,14 @@ class HotmailHelperHandler(BaseHTTPRequestHandler):
 
             json_response(self, 404, {"ok": False, "error": f"Unsupported path: {self.path}"})
         except Exception as exc:
+            traceback.print_exc()
             json_response(self, 500, {"ok": False, "error": str(exc)})
 
 
 def main():
     server = ThreadingHTTPServer((HOST, PORT), HotmailHelperHandler)
     print(f"Hotmail helper listening on http://{HOST}:{PORT}", flush=True)
+    print(f"Account log file: {ACCOUNT_LOG_PATH}", flush=True)
     try:
         server.serve_forever()
     except KeyboardInterrupt:

+ 529 - 0
sidepanel/hotmail-manager.js

@@ -0,0 +1,529 @@
+(function attachSidepanelHotmailManager(globalScope) {
+  function createHotmailManager(context = {}) {
+    const {
+      state,
+      dom,
+      helpers,
+      runtime,
+      constants = {},
+      hotmailUtils = {},
+    } = context;
+
+    const expandedStorageKey = constants.expandedStorageKey || 'multipage-hotmail-list-expanded';
+    const displayTimeZone = constants.displayTimeZone || 'Asia/Shanghai';
+    const copyIcon = constants.copyIcon || '';
+
+    let actionInFlight = false;
+    let listExpanded = false;
+
+    function getHotmailAccountsByUsage(mode = 'all', currentState = state.getLatestState()) {
+      const accounts = helpers.getHotmailAccounts(currentState);
+      if (typeof hotmailUtils.filterHotmailAccountsByUsage === 'function') {
+        return hotmailUtils.filterHotmailAccountsByUsage(accounts, mode);
+      }
+      if (mode === 'used') {
+        return accounts.filter((account) => Boolean(account?.used));
+      }
+      return accounts.slice();
+    }
+
+    function getHotmailBulkActionText(mode, count) {
+      if (typeof hotmailUtils.getHotmailBulkActionLabel === 'function') {
+        return hotmailUtils.getHotmailBulkActionLabel(mode, count);
+      }
+      const normalizedCount = Number.isFinite(Number(count)) ? Math.max(0, Number(count)) : 0;
+      const prefix = mode === 'used' ? '清空已用' : '全部删除';
+      const suffix = normalizedCount > 0 ? `(${normalizedCount})` : '';
+      return `${prefix}${suffix}`;
+    }
+
+    function getHotmailListToggleText(expanded, count) {
+      if (typeof hotmailUtils.getHotmailListToggleLabel === 'function') {
+        return hotmailUtils.getHotmailListToggleLabel(expanded, count);
+      }
+      const normalizedCount = Number.isFinite(Number(count)) ? Math.max(0, Number(count)) : 0;
+      const suffix = normalizedCount > 0 ? `(${normalizedCount})` : '';
+      return `${expanded ? '收起列表' : '展开列表'}${suffix}`;
+    }
+
+    function updateHotmailListViewport() {
+      const count = helpers.getHotmailAccounts().length;
+      const usedCount = getHotmailAccountsByUsage('used').length;
+      if (dom.btnClearUsedHotmailAccounts) {
+        dom.btnClearUsedHotmailAccounts.textContent = getHotmailBulkActionText('used', usedCount);
+        dom.btnClearUsedHotmailAccounts.disabled = usedCount === 0;
+      }
+      if (dom.btnDeleteAllHotmailAccounts) {
+        dom.btnDeleteAllHotmailAccounts.textContent = getHotmailBulkActionText('all', count);
+        dom.btnDeleteAllHotmailAccounts.disabled = count === 0;
+      }
+      if (dom.btnToggleHotmailList) {
+        dom.btnToggleHotmailList.textContent = getHotmailListToggleText(listExpanded, count);
+        dom.btnToggleHotmailList.setAttribute('aria-expanded', String(listExpanded));
+        dom.btnToggleHotmailList.disabled = count === 0;
+      }
+      if (dom.hotmailListShell) {
+        dom.hotmailListShell.classList.toggle('is-expanded', listExpanded);
+        dom.hotmailListShell.classList.toggle('is-collapsed', !listExpanded);
+      }
+    }
+
+    function setHotmailListExpanded(expanded, options = {}) {
+      const { persist = true } = options;
+      listExpanded = Boolean(expanded);
+      updateHotmailListViewport();
+      if (persist) {
+        localStorage.setItem(expandedStorageKey, listExpanded ? '1' : '0');
+      }
+    }
+
+    function initHotmailListExpandedState() {
+      const saved = localStorage.getItem(expandedStorageKey);
+      setHotmailListExpanded(saved === '1', { persist: false });
+    }
+
+    function shouldClearCurrentHotmailSelectionLocally(account) {
+      if (typeof hotmailUtils.shouldClearHotmailCurrentSelection === 'function') {
+        return hotmailUtils.shouldClearHotmailCurrentSelection(account);
+      }
+      return Boolean(account) && account.used === true;
+    }
+
+    function upsertHotmailAccountListLocally(accounts, nextAccount) {
+      if (typeof hotmailUtils.upsertHotmailAccountInList === 'function') {
+        return hotmailUtils.upsertHotmailAccountInList(accounts, nextAccount);
+      }
+
+      const list = Array.isArray(accounts) ? accounts.slice() : [];
+      if (!nextAccount?.id) return list;
+
+      const existingIndex = list.findIndex((account) => account?.id === nextAccount.id);
+      if (existingIndex === -1) {
+        list.push(nextAccount);
+        return list;
+      }
+
+      list[existingIndex] = nextAccount;
+      return list;
+    }
+
+    function refreshHotmailSelectionUI() {
+      renderHotmailAccounts();
+      if (dom.selectMailProvider.value === 'hotmail-api') {
+        dom.inputEmail.value = helpers.getCurrentHotmailEmail();
+      }
+    }
+
+    function applyHotmailAccountMutation(account, options = {}) {
+      if (!account?.id) return;
+      const { preserveCurrentSelection = false } = options;
+
+      const latestState = state.getLatestState();
+      const nextState = {
+        hotmailAccounts: upsertHotmailAccountListLocally(helpers.getHotmailAccounts(), account),
+      };
+
+      if (!preserveCurrentSelection
+        && latestState?.currentHotmailAccountId === account.id
+        && shouldClearCurrentHotmailSelectionLocally(account)) {
+        nextState.currentHotmailAccountId = null;
+        if (dom.selectMailProvider.value === 'hotmail-api') {
+          nextState.email = null;
+        }
+      }
+
+      state.syncLatestState(nextState);
+      refreshHotmailSelectionUI();
+    }
+
+    function formatDateTime(timestamp) {
+      const value = Number(timestamp);
+      if (!Number.isFinite(value) || value <= 0) {
+        return '未使用';
+      }
+      return new Date(value).toLocaleString('zh-CN', {
+        hour12: false,
+        timeZone: displayTimeZone,
+      });
+    }
+
+    function getHotmailAvailabilityLabel(account) {
+      if (account.used) return '已用';
+      return '可分配';
+    }
+
+    function getHotmailStatusLabel(account) {
+      if (account.used) return '已用';
+
+      switch (account.status) {
+        case 'authorized':
+          return '可用';
+        case 'error':
+          return '异常';
+        default:
+          return '待校验';
+      }
+    }
+
+    function getHotmailStatusClass(account) {
+      if (account.used) return 'status-used';
+      return `status-${account.status || 'pending'}`;
+    }
+
+    function clearHotmailForm() {
+      dom.inputHotmailEmail.value = '';
+      dom.inputHotmailClientId.value = '';
+      dom.inputHotmailPassword.value = '';
+      dom.inputHotmailRefreshToken.value = '';
+    }
+
+    function renderHotmailAccounts() {
+      if (!dom.hotmailAccountsList) return;
+      const latestState = state.getLatestState();
+      const accounts = helpers.getHotmailAccounts();
+      const currentId = latestState?.currentHotmailAccountId || '';
+
+      if (!accounts.length) {
+        dom.hotmailAccountsList.innerHTML = '<div class="hotmail-empty">还没有 Hotmail 账号,先添加一条再校验。</div>';
+        updateHotmailListViewport();
+        return;
+      }
+
+      dom.hotmailAccountsList.innerHTML = accounts.map((account) => `
+        <div class="hotmail-account-item${account.id === currentId ? ' is-current' : ''}">
+          <div class="hotmail-account-top">
+            <div class="hotmail-account-title-row">
+              <div class="hotmail-account-email">${helpers.escapeHtml(account.email || '(未命名账号)')}</div>
+              <button
+                class="hotmail-copy-btn"
+                type="button"
+                data-account-action="copy-email"
+                data-account-id="${helpers.escapeHtml(account.id)}"
+                title="复制邮箱"
+                aria-label="复制邮箱 ${helpers.escapeHtml(account.email || '')}"
+              >${copyIcon}</button>
+            </div>
+            <span class="hotmail-status-chip ${helpers.escapeHtml(getHotmailStatusClass(account))}">${helpers.escapeHtml(getHotmailStatusLabel(account))}</span>
+          </div>
+          <div class="hotmail-account-meta">
+            <span>客户端 ID:${helpers.escapeHtml(account.clientId ? `${account.clientId.slice(0, 10)}...` : '未填写')}</span>
+            <span>刷新令牌:${account.refreshToken ? '已保存' : '未保存'}</span>
+            <span>分配状态: ${helpers.escapeHtml(getHotmailAvailabilityLabel(account))}</span>
+            <span>上次校验: ${helpers.escapeHtml(formatDateTime(account.lastAuthAt))}</span>
+            <span>上次使用: ${helpers.escapeHtml(formatDateTime(account.lastUsedAt))}</span>
+          </div>
+          ${account.lastError ? `<div class="hotmail-account-error">${helpers.escapeHtml(account.lastError)}</div>` : ''}
+          <div class="hotmail-account-actions">
+            <button class="btn btn-outline btn-sm" type="button" data-account-action="select" data-account-id="${helpers.escapeHtml(account.id)}">使用此账号</button>
+            <button class="btn btn-outline btn-sm" type="button" data-account-action="toggle-used" data-account-id="${helpers.escapeHtml(account.id)}">${account.used ? '标记未用' : '标记已用'}</button>
+            <button class="btn btn-primary btn-sm" type="button" data-account-action="verify" data-account-id="${helpers.escapeHtml(account.id)}">校验</button>
+            <button class="btn btn-outline btn-sm" type="button" data-account-action="test" data-account-id="${helpers.escapeHtml(account.id)}">复制最新验证码</button>
+            <button class="btn btn-ghost btn-sm" type="button" data-account-action="delete" data-account-id="${helpers.escapeHtml(account.id)}">删除</button>
+          </div>
+        </div>
+      `).join('');
+      updateHotmailListViewport();
+    }
+
+    async function deleteHotmailAccountsByMode(mode) {
+      const isUsedMode = mode === 'used';
+      const targetAccounts = getHotmailAccountsByUsage(isUsedMode ? 'used' : 'all');
+      if (!targetAccounts.length) {
+        helpers.showToast(isUsedMode ? '没有已用账号可清空。' : '没有可删除的 Hotmail 账号。', 'warn');
+        return;
+      }
+
+      const confirmed = await helpers.openConfirmModal({
+        title: isUsedMode ? '清空已用账号' : '全部删除账号',
+        message: isUsedMode
+          ? `确认删除当前 ${targetAccounts.length} 个已用 Hotmail 账号吗?`
+          : `确认删除全部 ${targetAccounts.length} 个 Hotmail 账号吗?`,
+        confirmLabel: isUsedMode ? '确认清空已用' : '确认全部删除',
+        confirmVariant: isUsedMode ? 'btn-outline' : 'btn-danger',
+      });
+      if (!confirmed) {
+        return;
+      }
+
+      const response = await runtime.sendMessage({
+        type: 'DELETE_HOTMAIL_ACCOUNTS',
+        source: 'sidepanel',
+        payload: { mode: isUsedMode ? 'used' : 'all' },
+      });
+
+      if (response?.error) {
+        throw new Error(response.error);
+      }
+
+      const latestState = state.getLatestState();
+      const targetIds = new Set(targetAccounts.map((account) => account.id));
+      const nextAccounts = isUsedMode
+        ? helpers.getHotmailAccounts().filter((account) => !targetIds.has(account.id))
+        : [];
+      const nextState = { hotmailAccounts: nextAccounts };
+      if (latestState?.currentHotmailAccountId && targetIds.has(latestState.currentHotmailAccountId)) {
+        nextState.currentHotmailAccountId = null;
+        if (dom.selectMailProvider.value === 'hotmail-api') {
+          nextState.email = null;
+        }
+      }
+      state.syncLatestState(nextState);
+      refreshHotmailSelectionUI();
+
+      helpers.showToast(
+        isUsedMode
+          ? `已清空 ${response.deletedCount || 0} 个已用 Hotmail 账号`
+          : `已删除全部 ${response.deletedCount || 0} 个 Hotmail 账号`,
+        'success',
+        2200
+      );
+    }
+
+    async function handleAddHotmailAccount() {
+      if (actionInFlight) return;
+
+      const email = dom.inputHotmailEmail.value.trim();
+      const clientId = dom.inputHotmailClientId.value.trim();
+      const refreshToken = dom.inputHotmailRefreshToken.value.trim();
+      if (!email) {
+        helpers.showToast('请先填写 Hotmail 邮箱。', 'warn');
+        return;
+      }
+      if (!clientId) {
+        helpers.showToast('请先填写微软应用客户端 ID。', 'warn');
+        return;
+      }
+      if (!refreshToken) {
+        helpers.showToast('请先填写刷新令牌(refresh token)。', 'warn');
+        return;
+      }
+
+      actionInFlight = true;
+      dom.btnAddHotmailAccount.disabled = true;
+
+      try {
+        const response = await runtime.sendMessage({
+          type: 'UPSERT_HOTMAIL_ACCOUNT',
+          source: 'sidepanel',
+          payload: {
+            email,
+            clientId,
+            password: dom.inputHotmailPassword.value,
+            refreshToken,
+          },
+        });
+
+        if (response?.error) {
+          throw new Error(response.error);
+        }
+
+        helpers.showToast(`已保存 Hotmail 账号 ${email}`, 'success', 1800);
+        clearHotmailForm();
+      } catch (err) {
+        helpers.showToast(`保存 Hotmail 账号失败:${err.message}`, 'error');
+      } finally {
+        actionInFlight = false;
+        dom.btnAddHotmailAccount.disabled = false;
+      }
+    }
+
+    async function handleImportHotmailAccounts() {
+      if (actionInFlight) return;
+      if (typeof hotmailUtils.parseHotmailImportText !== 'function') {
+        helpers.showToast('导入解析器未加载,请刷新扩展后重试。', 'error');
+        return;
+      }
+
+      const rawText = dom.inputHotmailImport.value.trim();
+      if (!rawText) {
+        helpers.showToast('请先粘贴账号导入内容。', 'warn');
+        return;
+      }
+
+      const parsedAccounts = hotmailUtils.parseHotmailImportText(rawText);
+      if (!parsedAccounts.length) {
+        helpers.showToast('没有解析到有效账号,请检查格式是否为 账号----密码----ID----Token。', 'error');
+        return;
+      }
+
+      actionInFlight = true;
+      dom.btnImportHotmailAccounts.disabled = true;
+
+      try {
+        for (const account of parsedAccounts) {
+          const response = await runtime.sendMessage({
+            type: 'UPSERT_HOTMAIL_ACCOUNT',
+            source: 'sidepanel',
+            payload: account,
+          });
+          if (response?.error) {
+            throw new Error(response.error);
+          }
+        }
+
+        dom.inputHotmailImport.value = '';
+        helpers.showToast(`已导入 ${parsedAccounts.length} 条 Hotmail 账号`, 'success', 2200);
+      } catch (err) {
+        helpers.showToast(`批量导入失败:${err.message}`, 'error');
+      } finally {
+        actionInFlight = false;
+        dom.btnImportHotmailAccounts.disabled = false;
+      }
+    }
+
+    async function handleAccountListClick(event) {
+      const actionButton = event.target.closest('[data-account-action]');
+      if (!actionButton || actionInFlight) {
+        return;
+      }
+
+      const accountId = actionButton.dataset.accountId;
+      const action = actionButton.dataset.accountAction;
+      if (!accountId || !action) {
+        return;
+      }
+
+      const targetAccount = helpers.getHotmailAccounts().find((account) => account.id === accountId) || null;
+
+      actionInFlight = true;
+      actionButton.disabled = true;
+
+      try {
+        if (action === 'copy-email') {
+          if (!targetAccount?.email) throw new Error('未找到可复制的邮箱地址。');
+          await helpers.copyTextToClipboard(targetAccount.email);
+          helpers.showToast(`已复制 ${targetAccount.email}`, 'success', 1800);
+        } else if (action === 'select') {
+          const response = await runtime.sendMessage({
+            type: 'SELECT_HOTMAIL_ACCOUNT',
+            source: 'sidepanel',
+            payload: { accountId },
+          });
+          if (response?.error) throw new Error(response.error);
+          state.syncLatestState({ currentHotmailAccountId: response.account.id });
+          applyHotmailAccountMutation(response.account, { preserveCurrentSelection: true });
+          helpers.showToast(`已切换当前 Hotmail 账号为 ${response.account.email}`, 'success', 1800);
+        } else if (action === 'toggle-used') {
+          if (!targetAccount) throw new Error('未找到目标 Hotmail 账号。');
+          const response = await runtime.sendMessage({
+            type: 'PATCH_HOTMAIL_ACCOUNT',
+            source: 'sidepanel',
+            payload: {
+              accountId,
+              updates: { used: !targetAccount.used },
+            },
+          });
+          if (response?.error) throw new Error(response.error);
+          applyHotmailAccountMutation(response.account);
+          helpers.showToast(`账号 ${response.account.email} 已${response.account.used ? '标记为已用' : '恢复为未用'}`, 'success', 2200);
+        } else if (action === 'verify') {
+          const response = await runtime.sendMessage({
+            type: 'VERIFY_HOTMAIL_ACCOUNT',
+            source: 'sidepanel',
+            payload: { accountId },
+          });
+          if (response?.error) throw new Error(response.error);
+          applyHotmailAccountMutation(response.account, { preserveCurrentSelection: true });
+          helpers.showToast(`账号 ${response.account.email} 校验通过`, 'success', 2200);
+        } else if (action === 'test') {
+          const response = await runtime.sendMessage({
+            type: 'TEST_HOTMAIL_ACCOUNT',
+            source: 'sidepanel',
+            payload: { accountId },
+          });
+          if (response?.error) throw new Error(response.error);
+          applyHotmailAccountMutation(response.account, { preserveCurrentSelection: true });
+          if (response.latestCode) {
+            await helpers.copyTextToClipboard(response.latestCode);
+            const mailbox = response.latestMailbox ? `(${response.latestMailbox})` : '';
+            helpers.showToast(`已复制最新验证码 ${response.latestCode}${mailbox}`, 'success', 2600);
+          } else if (response.latestSubject) {
+            const mailbox = response.latestMailbox ? `(${response.latestMailbox})` : '';
+            helpers.showToast(`最新邮件${mailbox}没有验证码:${response.latestSubject}`, 'warn', 3200);
+          } else {
+            helpers.showToast('当前没有可读取的最新邮件。', 'warn', 2600);
+          }
+        } else if (action === 'delete') {
+          const confirmed = await helpers.openConfirmModal({
+            title: '删除账号',
+            message: '确认删除这个 Hotmail 账号吗?对应 token 也会一起移除。',
+            confirmLabel: '确认删除',
+            confirmVariant: 'btn-danger',
+          });
+          if (!confirmed) {
+            return;
+          }
+          const response = await runtime.sendMessage({
+            type: 'DELETE_HOTMAIL_ACCOUNT',
+            source: 'sidepanel',
+            payload: { accountId },
+          });
+          if (response?.error) throw new Error(response.error);
+          helpers.showToast('Hotmail 账号已删除', 'success', 1800);
+        }
+      } catch (err) {
+        helpers.showToast(err.message, 'error');
+      } finally {
+        actionInFlight = false;
+        actionButton.disabled = false;
+      }
+    }
+
+    function bindHotmailEvents() {
+      dom.btnToggleHotmailList?.addEventListener('click', () => {
+        setHotmailListExpanded(!listExpanded);
+      });
+
+      dom.btnHotmailUsageGuide?.addEventListener('click', async () => {
+        await helpers.openConfirmModal({
+          title: '使用教程',
+          message: 'API对接模式会直接调用微软邮箱接口取件;本地助手模式仍走本地服务。两种模式继续共用同一套 Hotmail 账号池与导入格式。',
+          confirmLabel: '确定',
+          confirmVariant: 'btn-primary',
+        });
+      });
+
+      dom.btnClearUsedHotmailAccounts?.addEventListener('click', async () => {
+        if (actionInFlight) return;
+        actionInFlight = true;
+        dom.btnClearUsedHotmailAccounts.disabled = true;
+        try {
+          await deleteHotmailAccountsByMode('used');
+        } catch (err) {
+          helpers.showToast(err.message, 'error');
+        } finally {
+          actionInFlight = false;
+          updateHotmailListViewport();
+        }
+      });
+
+      dom.btnDeleteAllHotmailAccounts?.addEventListener('click', async () => {
+        if (actionInFlight) return;
+        actionInFlight = true;
+        dom.btnDeleteAllHotmailAccounts.disabled = true;
+        try {
+          await deleteHotmailAccountsByMode('all');
+        } catch (err) {
+          helpers.showToast(err.message, 'error');
+        } finally {
+          actionInFlight = false;
+          updateHotmailListViewport();
+        }
+      });
+
+      dom.btnAddHotmailAccount?.addEventListener('click', handleAddHotmailAccount);
+      dom.btnImportHotmailAccounts?.addEventListener('click', handleImportHotmailAccounts);
+      dom.hotmailAccountsList?.addEventListener('click', handleAccountListClick);
+    }
+
+    return {
+      bindHotmailEvents,
+      initHotmailListExpandedState,
+      renderHotmailAccounts,
+    };
+  }
+
+  globalScope.SidepanelHotmailManager = {
+    createHotmailManager,
+  };
+})(window);

+ 502 - 0
sidepanel/icloud-manager.js

@@ -0,0 +1,502 @@
+(function attachSidepanelIcloudManager(globalScope) {
+  function createIcloudManager(context = {}) {
+    const {
+      dom,
+      helpers,
+      runtime,
+    } = context;
+
+    let refreshQueued = false;
+    let renderedAliases = [];
+    let selectedEmails = new Set();
+    let searchTerm = '';
+    let filterMode = 'all';
+
+    function normalizeIcloudSearchText(value) {
+      return String(value || '').trim().toLowerCase();
+    }
+
+    function getFilteredIcloudAliases(aliases = renderedAliases) {
+      const normalizedSearchTerm = normalizeIcloudSearchText(searchTerm);
+      return (Array.isArray(aliases) ? aliases : []).filter((alias) => {
+        const matchesFilter = (() => {
+          switch (filterMode) {
+            case 'active': return Boolean(alias.active);
+            case 'used': return Boolean(alias.used);
+            case 'unused': return !alias.used;
+            case 'preserved': return Boolean(alias.preserved);
+            default: return true;
+          }
+        })();
+
+        if (!matchesFilter) return false;
+        if (!normalizedSearchTerm) return true;
+
+        const haystack = [
+          alias.email,
+          alias.label,
+          alias.note,
+          alias.used ? '已用 used' : '未用 unused',
+          alias.active ? '可用 active' : '不可用 inactive',
+          alias.preserved ? '保留 preserved' : '',
+        ].join(' ').toLowerCase();
+
+        return haystack.includes(normalizedSearchTerm);
+      });
+    }
+
+    function pruneIcloudSelection(aliases = renderedAliases) {
+      const existing = new Set((Array.isArray(aliases) ? aliases : []).map((alias) => alias.email));
+      selectedEmails = new Set([...selectedEmails].filter((email) => existing.has(email)));
+    }
+
+    function updateIcloudBulkUI(visibleAliases = getFilteredIcloudAliases()) {
+      if (!dom.checkboxIcloudSelectAll || !dom.icloudSelectionSummary) {
+        return;
+      }
+
+      const visibleEmails = visibleAliases.map((alias) => alias.email);
+      const selectedVisibleCount = visibleEmails.filter((email) => selectedEmails.has(email)).length;
+      const hasVisible = visibleEmails.length > 0;
+
+      dom.checkboxIcloudSelectAll.checked = hasVisible && selectedVisibleCount === visibleEmails.length;
+      dom.checkboxIcloudSelectAll.indeterminate = selectedVisibleCount > 0 && selectedVisibleCount < visibleEmails.length;
+      dom.checkboxIcloudSelectAll.disabled = !hasVisible;
+      dom.icloudSelectionSummary.textContent = `已选 ${selectedEmails.size} 个(当前显示 ${visibleEmails.length} 个)`;
+
+      const hasSelection = selectedEmails.size > 0;
+      if (dom.btnIcloudBulkUsed) dom.btnIcloudBulkUsed.disabled = !hasSelection;
+      if (dom.btnIcloudBulkUnused) dom.btnIcloudBulkUnused.disabled = !hasSelection;
+      if (dom.btnIcloudBulkPreserve) dom.btnIcloudBulkPreserve.disabled = !hasSelection;
+      if (dom.btnIcloudBulkUnpreserve) dom.btnIcloudBulkUnpreserve.disabled = !hasSelection;
+      if (dom.btnIcloudBulkDelete) dom.btnIcloudBulkDelete.disabled = !hasSelection;
+    }
+
+    function setIcloudLoadingState(loading, summary = '') {
+      if (dom.btnIcloudRefresh) dom.btnIcloudRefresh.disabled = loading;
+      if (dom.btnIcloudDeleteUsed) dom.btnIcloudDeleteUsed.disabled = loading;
+      if (dom.btnIcloudLoginDone) dom.btnIcloudLoginDone.disabled = loading;
+      if (dom.inputIcloudSearch) dom.inputIcloudSearch.disabled = loading;
+      if (dom.selectIcloudFilter) dom.selectIcloudFilter.disabled = loading;
+      if (dom.checkboxIcloudSelectAll) dom.checkboxIcloudSelectAll.disabled = loading || getFilteredIcloudAliases().length === 0;
+      if (dom.btnIcloudBulkUsed) dom.btnIcloudBulkUsed.disabled = loading || selectedEmails.size === 0;
+      if (dom.btnIcloudBulkUnused) dom.btnIcloudBulkUnused.disabled = loading || selectedEmails.size === 0;
+      if (dom.btnIcloudBulkPreserve) dom.btnIcloudBulkPreserve.disabled = loading || selectedEmails.size === 0;
+      if (dom.btnIcloudBulkUnpreserve) dom.btnIcloudBulkUnpreserve.disabled = loading || selectedEmails.size === 0;
+      if (dom.btnIcloudBulkDelete) dom.btnIcloudBulkDelete.disabled = loading || selectedEmails.size === 0;
+      if (summary && dom.icloudSummary) dom.icloudSummary.textContent = summary;
+    }
+
+    function showIcloudLoginHelp(payload = {}) {
+      if (!dom.icloudLoginHelp) return;
+      const loginUrl = String(payload.loginUrl || '').trim();
+      let host = 'icloud.com.cn / icloud.com';
+      if (loginUrl) {
+        try {
+          host = new URL(loginUrl).host;
+        } catch {
+          host = loginUrl;
+        }
+      }
+      if (dom.icloudLoginHelpTitle) dom.icloudLoginHelpTitle.textContent = '需要登录 iCloud';
+      if (dom.icloudLoginHelpText) dom.icloudLoginHelpText.textContent = `我已经为你打开 ${host}。请在那个页面完成登录,然后回到这里点击“我已登录”。`;
+      dom.icloudLoginHelp.style.display = 'flex';
+    }
+
+    function hideIcloudLoginHelp() {
+      if (dom.icloudLoginHelp) {
+        dom.icloudLoginHelp.style.display = 'none';
+      }
+    }
+
+    function renderIcloudAliases(aliases = []) {
+      if (!dom.icloudList || !dom.icloudSummary) return;
+
+      renderedAliases = Array.isArray(aliases) ? aliases : [];
+      pruneIcloudSelection(renderedAliases);
+      dom.icloudList.innerHTML = '';
+
+      if (!aliases.length) {
+        selectedEmails.clear();
+        dom.icloudList.innerHTML = '<div class="icloud-empty">未找到 iCloud Hide My Email 别名。</div>';
+        dom.icloudSummary.textContent = '加载你的 iCloud Hide My Email 别名以便在这里管理。';
+        if (dom.btnIcloudDeleteUsed) dom.btnIcloudDeleteUsed.disabled = true;
+        updateIcloudBulkUI([]);
+        return;
+      }
+
+      const usedCount = aliases.filter((alias) => alias.used).length;
+      const deletableUsedCount = aliases.filter((alias) => alias.used && !alias.preserved).length;
+      dom.icloudSummary.textContent = `已加载 ${aliases.length} 个别名,其中 ${usedCount} 个已标记为已用。`;
+      if (dom.btnIcloudDeleteUsed) dom.btnIcloudDeleteUsed.disabled = deletableUsedCount === 0;
+
+      const visibleAliases = getFilteredIcloudAliases(aliases);
+      if (!visibleAliases.length) {
+        dom.icloudList.innerHTML = '<div class="icloud-empty">没有匹配当前筛选条件的别名。</div>';
+        updateIcloudBulkUI([]);
+        return;
+      }
+
+      for (const alias of visibleAliases) {
+        const item = document.createElement('div');
+        item.className = 'icloud-item';
+        item.innerHTML = `
+          <input class="icloud-item-check" type="checkbox" data-action="select" ${selectedEmails.has(alias.email) ? 'checked' : ''} />
+          <div class="icloud-item-main">
+            <div class="icloud-item-email">${helpers.escapeHtml(alias.email)}</div>
+            <div class="icloud-item-meta">
+              ${alias.used ? '<span class="icloud-tag used">已用</span>' : ''}
+              ${!alias.used && alias.active ? '<span class="icloud-tag active">可用</span>' : ''}
+              ${alias.preserved ? '<span class="icloud-tag">保留</span>' : ''}
+              ${alias.label ? `<span class="icloud-tag">${helpers.escapeHtml(alias.label)}</span>` : ''}
+              ${alias.note ? `<span class="icloud-tag">${helpers.escapeHtml(alias.note)}</span>` : ''}
+            </div>
+          </div>
+          <div class="icloud-item-actions">
+            <button class="btn btn-outline btn-xs" type="button" data-action="toggle-used">${helpers.escapeHtml(alias.used ? '标记未用' : '标记已用')}</button>
+            <button class="btn btn-outline btn-xs" type="button" data-action="toggle-preserved">${helpers.escapeHtml(alias.preserved ? '取消保留' : '保留')}</button>
+            <button class="btn btn-outline btn-xs" type="button" data-action="delete">删除</button>
+          </div>
+        `;
+
+        item.querySelector('[data-action="select"]').addEventListener('change', (event) => {
+          if (event.target.checked) {
+            selectedEmails.add(alias.email);
+          } else {
+            selectedEmails.delete(alias.email);
+          }
+          updateIcloudBulkUI(visibleAliases);
+        });
+        item.querySelector('[data-action="toggle-used"]').addEventListener('click', async () => {
+          await setSingleIcloudAliasUsedState(alias, !alias.used);
+        });
+        item.querySelector('[data-action="toggle-preserved"]').addEventListener('click', async () => {
+          await setSingleIcloudAliasPreservedState(alias, !alias.preserved);
+        });
+        item.querySelector('[data-action="delete"]').addEventListener('click', async () => {
+          await deleteSingleIcloudAlias(alias);
+        });
+        dom.icloudList.appendChild(item);
+      }
+
+      updateIcloudBulkUI(visibleAliases);
+    }
+
+    async function refreshIcloudAliases(options = {}) {
+      const { silent = false } = options;
+      if (!dom.icloudSection || dom.icloudSection.style.display === 'none') {
+        return;
+      }
+
+      if (!silent) setIcloudLoadingState(true, '正在加载 iCloud 别名...');
+      try {
+        const response = await runtime.sendMessage({
+          type: 'LIST_ICLOUD_ALIASES',
+          source: 'sidepanel',
+          payload: {},
+        });
+        if (response?.error) throw new Error(response.error);
+        hideIcloudLoginHelp();
+        renderIcloudAliases(response?.aliases || []);
+      } catch (err) {
+        selectedEmails.clear();
+        if (dom.icloudList) {
+          dom.icloudList.innerHTML = '<div class="icloud-empty">无法加载 iCloud 别名。</div>';
+        }
+        if (dom.icloudSummary) {
+          dom.icloudSummary.textContent = err.message;
+        }
+        updateIcloudBulkUI([]);
+        if (!silent) helpers.showToast(`iCloud 别名加载失败:${err.message}`, 'error');
+      } finally {
+        setIcloudLoadingState(false);
+      }
+    }
+
+    function queueIcloudAliasRefresh() {
+      if (refreshQueued) return;
+      refreshQueued = true;
+      setTimeout(async () => {
+        refreshQueued = false;
+        await refreshIcloudAliases({ silent: true });
+      }, 150);
+    }
+
+    async function deleteSingleIcloudAlias(alias) {
+      const confirmed = await helpers.openConfirmModal({
+        title: '删除 iCloud 别名',
+        message: `确认删除 ${alias.email} 吗?此操作不可撤销。`,
+        confirmLabel: '确认删除',
+        confirmVariant: 'btn-danger',
+      });
+      if (!confirmed) {
+        return;
+      }
+
+      setIcloudLoadingState(true, `正在删除 ${alias.email} ...`);
+      try {
+        const response = await runtime.sendMessage({
+          type: 'DELETE_ICLOUD_ALIAS',
+          source: 'sidepanel',
+          payload: { email: alias.email, anonymousId: alias.anonymousId },
+        });
+        if (response?.error) throw new Error(response.error);
+        helpers.showToast(`已删除 ${alias.email}`, 'success', 2200);
+        await refreshIcloudAliases({ silent: true });
+      } catch (err) {
+        if (dom.icloudSummary) dom.icloudSummary.textContent = err.message;
+        helpers.showToast(`删除 iCloud 别名失败:${err.message}`, 'error');
+      } finally {
+        setIcloudLoadingState(false);
+      }
+    }
+
+    async function setSingleIcloudAliasUsedState(alias, used) {
+      setIcloudLoadingState(true, `正在更新 ${alias.email} 的使用状态...`);
+      try {
+        const response = await runtime.sendMessage({
+          type: 'SET_ICLOUD_ALIAS_USED_STATE',
+          source: 'sidepanel',
+          payload: { email: alias.email, used },
+        });
+        if (response?.error) throw new Error(response.error);
+        helpers.showToast(`${alias.email} 已${used ? '标记为已用' : '恢复为未用'}`, 'success', 2200);
+        await refreshIcloudAliases({ silent: true });
+      } catch (err) {
+        if (dom.icloudSummary) dom.icloudSummary.textContent = err.message;
+        helpers.showToast(`更新 iCloud 使用状态失败:${err.message}`, 'error');
+      } finally {
+        setIcloudLoadingState(false);
+      }
+    }
+
+    async function setSingleIcloudAliasPreservedState(alias, preserved) {
+      setIcloudLoadingState(true, `正在更新 ${alias.email} 的保留状态...`);
+      try {
+        const response = await runtime.sendMessage({
+          type: 'SET_ICLOUD_ALIAS_PRESERVED_STATE',
+          source: 'sidepanel',
+          payload: { email: alias.email, preserved },
+        });
+        if (response?.error) throw new Error(response.error);
+        helpers.showToast(`${alias.email} 已${preserved ? '设为保留' : '取消保留'}`, 'success', 2200);
+        await refreshIcloudAliases({ silent: true });
+      } catch (err) {
+        if (dom.icloudSummary) dom.icloudSummary.textContent = err.message;
+        helpers.showToast(`更新 iCloud 保留状态失败:${err.message}`, 'error');
+      } finally {
+        setIcloudLoadingState(false);
+      }
+    }
+
+    async function runBulkIcloudAction(action) {
+      const selectedAliases = renderedAliases.filter((alias) => selectedEmails.has(alias.email));
+      if (!selectedAliases.length) {
+        updateIcloudBulkUI();
+        return;
+      }
+
+      if (action === 'delete') {
+        const confirmed = await helpers.openConfirmModal({
+          title: '批量删除 iCloud 别名',
+          message: `确认删除选中的 ${selectedAliases.length} 个 iCloud 别名吗?此操作不可撤销。`,
+          confirmLabel: '确认删除',
+          confirmVariant: 'btn-danger',
+        });
+        if (!confirmed) {
+          return;
+        }
+      }
+
+      const actionLabelMap = {
+        used: '标记已用',
+        unused: '标记未用',
+        preserve: '保留',
+        unpreserve: '取消保留',
+        delete: '删除',
+      };
+      setIcloudLoadingState(true, `正在批量${actionLabelMap[action] || '处理'} iCloud 别名...`);
+
+      try {
+        for (const alias of selectedAliases) {
+          let response = null;
+          if (action === 'used' || action === 'unused') {
+            response = await runtime.sendMessage({
+              type: 'SET_ICLOUD_ALIAS_USED_STATE',
+              source: 'sidepanel',
+              payload: { email: alias.email, used: action === 'used' },
+            });
+          } else if (action === 'preserve' || action === 'unpreserve') {
+            response = await runtime.sendMessage({
+              type: 'SET_ICLOUD_ALIAS_PRESERVED_STATE',
+              source: 'sidepanel',
+              payload: { email: alias.email, preserved: action === 'preserve' },
+            });
+          } else if (action === 'delete') {
+            response = await runtime.sendMessage({
+              type: 'DELETE_ICLOUD_ALIAS',
+              source: 'sidepanel',
+              payload: { email: alias.email, anonymousId: alias.anonymousId },
+            });
+            selectedEmails.delete(alias.email);
+          }
+
+          if (response?.error) {
+            throw new Error(response.error);
+          }
+        }
+
+        helpers.showToast(`已批量${actionLabelMap[action] || '处理'} ${selectedAliases.length} 个 iCloud 别名`, 'success', 2400);
+        await refreshIcloudAliases({ silent: true });
+      } catch (err) {
+        if (dom.icloudSummary) dom.icloudSummary.textContent = err.message;
+        helpers.showToast(`批量处理 iCloud 别名失败:${err.message}`, 'error');
+      } finally {
+        setIcloudLoadingState(false);
+        updateIcloudBulkUI();
+      }
+    }
+
+    async function deleteUsedIcloudAliases() {
+      const confirmed = await helpers.openConfirmModal({
+        title: '删除已用 iCloud 别名',
+        message: '确认删除所有未保留的已用 iCloud 别名吗?此操作不可撤销。',
+        confirmLabel: '确认删除',
+        confirmVariant: 'btn-danger',
+      });
+      if (!confirmed) {
+        return;
+      }
+
+      setIcloudLoadingState(true, '正在删除已用 iCloud 别名...');
+      try {
+        const response = await runtime.sendMessage({
+          type: 'DELETE_USED_ICLOUD_ALIASES',
+          source: 'sidepanel',
+          payload: {},
+        });
+        if (response?.error) throw new Error(response.error);
+        const deleted = response?.deleted || [];
+        const skipped = response?.skipped || [];
+        helpers.showToast(`已删除 ${deleted.length} 个已用别名,跳过 ${skipped.length} 个`, skipped.length ? 'warn' : 'success', 2800);
+        await refreshIcloudAliases({ silent: true });
+      } catch (err) {
+        if (dom.icloudSummary) dom.icloudSummary.textContent = err.message;
+        helpers.showToast(`删除已用 iCloud 别名失败:${err.message}`, 'error');
+      } finally {
+        setIcloudLoadingState(false);
+      }
+    }
+
+    async function handleLoginDone() {
+      if (dom.btnIcloudLoginDone) {
+        dom.btnIcloudLoginDone.disabled = true;
+      }
+      try {
+        const response = await runtime.sendMessage({
+          type: 'CHECK_ICLOUD_SESSION',
+          source: 'sidepanel',
+          payload: {},
+        });
+        if (response?.error) {
+          throw new Error(response.error);
+        }
+        hideIcloudLoginHelp();
+        helpers.showToast('iCloud 会话已恢复,别名列表已刷新。', 'success', 2600);
+        await refreshIcloudAliases({ silent: true });
+      } catch (err) {
+        helpers.showToast(`看起来还没有登录完成:${err.message}`, 'warn', 4200);
+      } finally {
+        if (dom.btnIcloudLoginDone) {
+          dom.btnIcloudLoginDone.disabled = false;
+        }
+      }
+    }
+
+    function reset() {
+      selectedEmails.clear();
+      renderedAliases = [];
+      searchTerm = '';
+      filterMode = 'all';
+      refreshQueued = false;
+      if (dom.inputIcloudSearch) dom.inputIcloudSearch.value = '';
+      if (dom.selectIcloudFilter) dom.selectIcloudFilter.value = 'all';
+      if (dom.icloudList) dom.icloudList.innerHTML = '';
+      if (dom.icloudSummary) dom.icloudSummary.textContent = '加载你的 iCloud Hide My Email 别名以便在这里管理。';
+      updateIcloudBulkUI([]);
+      hideIcloudLoginHelp();
+    }
+
+    function hasDeletableUsedAliases() {
+      return renderedAliases.some((alias) => alias.used && !alias.preserved);
+    }
+
+    function bindIcloudEvents() {
+      dom.btnIcloudRefresh?.addEventListener('click', async () => {
+        await refreshIcloudAliases();
+      });
+
+      dom.btnIcloudDeleteUsed?.addEventListener('click', async () => {
+        await deleteUsedIcloudAliases();
+      });
+
+      dom.inputIcloudSearch?.addEventListener('input', () => {
+        searchTerm = dom.inputIcloudSearch.value || '';
+        renderIcloudAliases(renderedAliases);
+      });
+
+      dom.selectIcloudFilter?.addEventListener('change', () => {
+        filterMode = dom.selectIcloudFilter.value || 'all';
+        renderIcloudAliases(renderedAliases);
+      });
+
+      dom.checkboxIcloudSelectAll?.addEventListener('change', () => {
+        const visibleAliases = getFilteredIcloudAliases();
+        if (dom.checkboxIcloudSelectAll.checked) {
+          visibleAliases.forEach((alias) => selectedEmails.add(alias.email));
+        } else {
+          visibleAliases.forEach((alias) => selectedEmails.delete(alias.email));
+        }
+        renderIcloudAliases(renderedAliases);
+      });
+
+      dom.btnIcloudBulkUsed?.addEventListener('click', async () => {
+        await runBulkIcloudAction('used');
+      });
+
+      dom.btnIcloudBulkUnused?.addEventListener('click', async () => {
+        await runBulkIcloudAction('unused');
+      });
+
+      dom.btnIcloudBulkPreserve?.addEventListener('click', async () => {
+        await runBulkIcloudAction('preserve');
+      });
+
+      dom.btnIcloudBulkUnpreserve?.addEventListener('click', async () => {
+        await runBulkIcloudAction('unpreserve');
+      });
+
+      dom.btnIcloudBulkDelete?.addEventListener('click', async () => {
+        await runBulkIcloudAction('delete');
+      });
+
+      dom.btnIcloudLoginDone?.addEventListener('click', handleLoginDone);
+    }
+
+    return {
+      bindIcloudEvents,
+      hideIcloudLoginHelp,
+      hasDeletableUsedAliases,
+      queueIcloudAliasRefresh,
+      refreshIcloudAliases,
+      renderIcloudAliases,
+      reset,
+      showIcloudLoginHelp,
+      updateIcloudBulkUI,
+    };
+  }
+
+  globalScope.SidepanelIcloudManager = {
+    createIcloudManager,
+  };
+})(window);

+ 467 - 0
sidepanel/luckmail-manager.js

@@ -0,0 +1,467 @@
+(function attachSidepanelLuckmailManager(globalScope) {
+  function createLuckmailManager(context = {}) {
+    const {
+      dom,
+      helpers,
+      runtime,
+      constants = {},
+    } = context;
+
+    const copyIcon = constants.copyIcon || '';
+
+    let renderedPurchases = [];
+    let selectedPurchaseIds = new Set();
+    let searchTerm = '';
+    let filterMode = 'all';
+    let refreshQueued = false;
+
+    function normalizeLuckmailSearchText(value) {
+      return String(value || '').trim().toLowerCase();
+    }
+
+    function getFilteredLuckmailPurchases(purchases = renderedPurchases) {
+      const normalizedSearchTerm = normalizeLuckmailSearchText(searchTerm);
+      return (Array.isArray(purchases) ? purchases : []).filter((purchase) => {
+        const matchesFilter = (() => {
+          switch (filterMode) {
+            case 'reusable': return Boolean(purchase.reusable);
+            case 'used': return Boolean(purchase.used);
+            case 'unused': return !purchase.used;
+            case 'preserved': return Boolean(purchase.preserved);
+            case 'disabled': return Boolean(purchase.disabled);
+            default: return true;
+          }
+        })();
+
+        if (!matchesFilter) return false;
+        if (!normalizedSearchTerm) return true;
+
+        const haystack = [
+          purchase.email_address,
+          purchase.project_name,
+          purchase.tag_name,
+          purchase.used ? '已用 used' : '未用 unused',
+          purchase.preserved ? '保留 preserved' : '',
+          purchase.disabled ? '已禁用 disabled' : '',
+          purchase.reusable ? '可复用 reusable' : '',
+        ].join(' ').toLowerCase();
+
+        return haystack.includes(normalizedSearchTerm);
+      });
+    }
+
+    function pruneLuckmailSelection(purchases = renderedPurchases) {
+      const existingIds = new Set((Array.isArray(purchases) ? purchases : []).map((purchase) => String(purchase.id)));
+      selectedPurchaseIds = new Set([...selectedPurchaseIds].filter((id) => existingIds.has(id)));
+    }
+
+    function updateLuckmailBulkUI(visiblePurchases = getFilteredLuckmailPurchases()) {
+      if (!dom.checkboxLuckmailSelectAll || !dom.luckmailSelectionSummary) {
+        return;
+      }
+
+      const visibleIds = visiblePurchases.map((purchase) => String(purchase.id));
+      const selectedVisibleCount = visibleIds.filter((id) => selectedPurchaseIds.has(id)).length;
+      const hasVisible = visibleIds.length > 0;
+      const hasSelection = selectedPurchaseIds.size > 0;
+
+      dom.checkboxLuckmailSelectAll.checked = hasVisible && selectedVisibleCount === visibleIds.length;
+      dom.checkboxLuckmailSelectAll.indeterminate = selectedVisibleCount > 0 && selectedVisibleCount < visibleIds.length;
+      dom.checkboxLuckmailSelectAll.disabled = !hasVisible;
+      dom.luckmailSelectionSummary.textContent = `已选 ${selectedPurchaseIds.size} 个(当前显示 ${visibleIds.length} 个)`;
+
+      if (dom.btnLuckmailBulkUsed) dom.btnLuckmailBulkUsed.disabled = !hasSelection;
+      if (dom.btnLuckmailBulkUnused) dom.btnLuckmailBulkUnused.disabled = !hasSelection;
+      if (dom.btnLuckmailBulkPreserve) dom.btnLuckmailBulkPreserve.disabled = !hasSelection;
+      if (dom.btnLuckmailBulkUnpreserve) dom.btnLuckmailBulkUnpreserve.disabled = !hasSelection;
+      if (dom.btnLuckmailBulkDisable) dom.btnLuckmailBulkDisable.disabled = !hasSelection;
+      if (dom.btnLuckmailBulkEnable) dom.btnLuckmailBulkEnable.disabled = !hasSelection;
+    }
+
+    function setLuckmailLoadingState(loading, summary = '') {
+      if (dom.btnLuckmailRefresh) dom.btnLuckmailRefresh.disabled = loading;
+      if (dom.btnLuckmailDisableUsed) dom.btnLuckmailDisableUsed.disabled = loading;
+      if (dom.inputLuckmailSearch) dom.inputLuckmailSearch.disabled = loading;
+      if (dom.selectLuckmailFilter) dom.selectLuckmailFilter.disabled = loading;
+      if (dom.checkboxLuckmailSelectAll) dom.checkboxLuckmailSelectAll.disabled = loading || getFilteredLuckmailPurchases().length === 0;
+      if (dom.btnLuckmailBulkUsed) dom.btnLuckmailBulkUsed.disabled = loading || selectedPurchaseIds.size === 0;
+      if (dom.btnLuckmailBulkUnused) dom.btnLuckmailBulkUnused.disabled = loading || selectedPurchaseIds.size === 0;
+      if (dom.btnLuckmailBulkPreserve) dom.btnLuckmailBulkPreserve.disabled = loading || selectedPurchaseIds.size === 0;
+      if (dom.btnLuckmailBulkUnpreserve) dom.btnLuckmailBulkUnpreserve.disabled = loading || selectedPurchaseIds.size === 0;
+      if (dom.btnLuckmailBulkDisable) dom.btnLuckmailBulkDisable.disabled = loading || selectedPurchaseIds.size === 0;
+      if (dom.btnLuckmailBulkEnable) dom.btnLuckmailBulkEnable.disabled = loading || selectedPurchaseIds.size === 0;
+      if (summary && dom.luckmailSummary) {
+        dom.luckmailSummary.textContent = summary;
+      }
+    }
+
+    function renderLuckmailPurchases(purchases = renderedPurchases) {
+      if (!dom.luckmailList || !dom.luckmailSummary) return;
+
+      renderedPurchases = Array.isArray(purchases) ? purchases : [];
+      pruneLuckmailSelection(renderedPurchases);
+      dom.luckmailList.innerHTML = '';
+
+      if (!renderedPurchases.length) {
+        selectedPurchaseIds.clear();
+        dom.luckmailList.innerHTML = '<div class="luckmail-empty">未找到 openai 项目的 LuckMail 邮箱。</div>';
+        dom.luckmailSummary.textContent = '加载已购邮箱后可在这里管理 openai 项目的 LuckMail 邮箱。';
+        if (dom.btnLuckmailDisableUsed) dom.btnLuckmailDisableUsed.disabled = true;
+        updateLuckmailBulkUI([]);
+        return;
+      }
+
+      const usedCount = renderedPurchases.filter((purchase) => purchase.used).length;
+      const reusableCount = renderedPurchases.filter((purchase) => purchase.reusable).length;
+      const disableUsedCount = renderedPurchases.filter((purchase) => purchase.used && !purchase.preserved && !purchase.disabled).length;
+      dom.luckmailSummary.textContent = `已加载 ${renderedPurchases.length} 个 openai 邮箱,其中 ${reusableCount} 个可复用,${usedCount} 个已本地标记为已用。`;
+      if (dom.btnLuckmailDisableUsed) {
+        dom.btnLuckmailDisableUsed.textContent = `禁用已用${disableUsedCount > 0 ? `(${disableUsedCount})` : ''}`;
+        dom.btnLuckmailDisableUsed.disabled = disableUsedCount === 0;
+      }
+
+      const visiblePurchases = getFilteredLuckmailPurchases(renderedPurchases);
+      if (!visiblePurchases.length) {
+        dom.luckmailList.innerHTML = '<div class="luckmail-empty">没有匹配当前筛选条件的 LuckMail 邮箱。</div>';
+        updateLuckmailBulkUI([]);
+        return;
+      }
+
+      for (const purchase of visiblePurchases) {
+        const purchaseId = String(purchase.id);
+        const item = document.createElement('div');
+        item.className = `luckmail-item${purchase.current ? ' is-current' : ''}`;
+        item.innerHTML = `
+          <input class="luckmail-item-check" type="checkbox" data-action="select" ${selectedPurchaseIds.has(purchaseId) ? 'checked' : ''} />
+          <div class="luckmail-item-main">
+            <div class="luckmail-item-email-row">
+              <div class="luckmail-item-email">${helpers.escapeHtml(purchase.email_address || '(未知邮箱)')}</div>
+              <button
+                class="hotmail-copy-btn"
+                type="button"
+                data-action="copy-email"
+                title="复制邮箱"
+                aria-label="复制邮箱 ${helpers.escapeHtml(purchase.email_address || '')}"
+              >${copyIcon}</button>
+            </div>
+            <div class="luckmail-item-meta">
+              <span class="luckmail-tag">${helpers.escapeHtml(helpers.normalizeLuckmailProjectName(purchase.project_name) || 'openai')}</span>
+              ${purchase.reusable ? '<span class="luckmail-tag active">可复用</span>' : ''}
+              ${purchase.current ? '<span class="luckmail-tag current">当前</span>' : ''}
+              ${purchase.used ? '<span class="luckmail-tag used">已用</span>' : ''}
+              ${purchase.preserved ? '<span class="luckmail-tag">保留</span>' : ''}
+              ${purchase.disabled ? '<span class="luckmail-tag disabled">已禁用</span>' : ''}
+              ${purchase.tag_name && normalizeLuckmailSearchText(purchase.tag_name) !== normalizeLuckmailSearchText(helpers.getLuckmailPreserveTagName())
+                ? `<span class="luckmail-tag">${helpers.escapeHtml(purchase.tag_name)}</span>`
+                : ''}
+            </div>
+            <div class="luckmail-item-details">
+              <span>ID:${helpers.escapeHtml(String(purchase.id || ''))}</span>
+              <span>保修至:${helpers.escapeHtml(helpers.formatLuckmailDateTime(purchase.warranty_until))}</span>
+            </div>
+          </div>
+          <div class="luckmail-item-actions">
+            <button class="btn btn-outline btn-xs" type="button" data-action="use">使用此邮箱</button>
+            <button class="btn btn-outline btn-xs" type="button" data-action="toggle-used">${helpers.escapeHtml(purchase.used ? '标记未用' : '标记已用')}</button>
+            <button class="btn btn-outline btn-xs" type="button" data-action="toggle-preserved">${helpers.escapeHtml(purchase.preserved ? '取消保留' : '保留')}</button>
+            <button class="btn btn-outline btn-xs" type="button" data-action="toggle-disabled">${helpers.escapeHtml(purchase.disabled ? '启用' : '禁用')}</button>
+          </div>
+        `;
+
+        item.querySelector('[data-action="select"]').addEventListener('change', (event) => {
+          if (event.target.checked) {
+            selectedPurchaseIds.add(purchaseId);
+          } else {
+            selectedPurchaseIds.delete(purchaseId);
+          }
+          updateLuckmailBulkUI(visiblePurchases);
+        });
+        item.querySelector('[data-action="copy-email"]').addEventListener('click', async () => {
+          await helpers.copyTextToClipboard(purchase.email_address || '');
+          helpers.showToast('邮箱已复制', 'success', 1600);
+        });
+        item.querySelector('[data-action="use"]').addEventListener('click', async () => {
+          await selectSingleLuckmailPurchase(purchase);
+        });
+        item.querySelector('[data-action="toggle-used"]').addEventListener('click', async () => {
+          await setSingleLuckmailPurchaseUsedState(purchase, !purchase.used);
+        });
+        item.querySelector('[data-action="toggle-preserved"]').addEventListener('click', async () => {
+          await setSingleLuckmailPurchasePreservedState(purchase, !purchase.preserved);
+        });
+        item.querySelector('[data-action="toggle-disabled"]').addEventListener('click', async () => {
+          await setSingleLuckmailPurchaseDisabledState(purchase, !purchase.disabled);
+        });
+        dom.luckmailList.appendChild(item);
+      }
+
+      updateLuckmailBulkUI(visiblePurchases);
+    }
+
+    async function refreshLuckmailPurchases(options = {}) {
+      const { silent = false } = options;
+      if (!dom.luckmailSection || dom.luckmailSection.style.display === 'none') {
+        return;
+      }
+
+      if (!silent) setLuckmailLoadingState(true, '正在加载 LuckMail openai 邮箱...');
+      try {
+        const response = await runtime.sendMessage({
+          type: 'LIST_LUCKMAIL_PURCHASES',
+          source: 'sidepanel',
+          payload: {},
+        });
+        if (response?.error) throw new Error(response.error);
+        renderLuckmailPurchases(response?.purchases || []);
+      } catch (err) {
+        selectedPurchaseIds.clear();
+        if (dom.luckmailList) {
+          dom.luckmailList.innerHTML = '<div class="luckmail-empty">无法加载 LuckMail 邮箱列表。</div>';
+        }
+        if (dom.luckmailSummary) {
+          dom.luckmailSummary.textContent = err.message;
+        }
+        updateLuckmailBulkUI([]);
+        if (!silent) {
+          helpers.showToast(`LuckMail 邮箱列表加载失败:${err.message}`, 'error');
+        }
+      } finally {
+        setLuckmailLoadingState(false);
+      }
+    }
+
+    function queueLuckmailPurchaseRefresh() {
+      if (refreshQueued) return;
+      refreshQueued = true;
+      setTimeout(async () => {
+        refreshQueued = false;
+        await refreshLuckmailPurchases({ silent: true });
+      }, 150);
+    }
+
+    async function selectSingleLuckmailPurchase(purchase) {
+      setLuckmailLoadingState(true, `正在切换到 ${purchase.email_address} ...`);
+      try {
+        const response = await runtime.sendMessage({
+          type: 'SELECT_LUCKMAIL_PURCHASE',
+          source: 'sidepanel',
+          payload: { purchaseId: purchase.id },
+        });
+        if (response?.error) throw new Error(response.error);
+        dom.inputEmail.value = response?.purchase?.email_address || purchase.email_address || '';
+        helpers.showToast(`已切换当前 LuckMail 邮箱为 ${purchase.email_address}`, 'success', 2200);
+        await refreshLuckmailPurchases({ silent: true });
+      } catch (err) {
+        if (dom.luckmailSummary) dom.luckmailSummary.textContent = err.message;
+        helpers.showToast(`切换 LuckMail 邮箱失败:${err.message}`, 'error');
+      } finally {
+        setLuckmailLoadingState(false);
+      }
+    }
+
+    async function setSingleLuckmailPurchaseUsedState(purchase, used) {
+      setLuckmailLoadingState(true, `正在更新 ${purchase.email_address} 的已用状态...`);
+      try {
+        const response = await runtime.sendMessage({
+          type: 'SET_LUCKMAIL_PURCHASE_USED_STATE',
+          source: 'sidepanel',
+          payload: { purchaseId: purchase.id, used },
+        });
+        if (response?.error) throw new Error(response.error);
+        helpers.showToast(`${purchase.email_address} 已${used ? '标记为已用' : '恢复为未用'}`, 'success', 2200);
+        await refreshLuckmailPurchases({ silent: true });
+      } catch (err) {
+        if (dom.luckmailSummary) dom.luckmailSummary.textContent = err.message;
+        helpers.showToast(`更新 LuckMail 已用状态失败:${err.message}`, 'error');
+      } finally {
+        setLuckmailLoadingState(false);
+      }
+    }
+
+    async function setSingleLuckmailPurchasePreservedState(purchase, preserved) {
+      setLuckmailLoadingState(true, `正在更新 ${purchase.email_address} 的保留状态...`);
+      try {
+        const response = await runtime.sendMessage({
+          type: 'SET_LUCKMAIL_PURCHASE_PRESERVED_STATE',
+          source: 'sidepanel',
+          payload: { purchaseId: purchase.id, preserved },
+        });
+        if (response?.error) throw new Error(response.error);
+        helpers.showToast(`${purchase.email_address} 已${preserved ? '设为保留' : '取消保留'}`, 'success', 2200);
+        await refreshLuckmailPurchases({ silent: true });
+      } catch (err) {
+        if (dom.luckmailSummary) dom.luckmailSummary.textContent = err.message;
+        helpers.showToast(`更新 LuckMail 保留状态失败:${err.message}`, 'error');
+      } finally {
+        setLuckmailLoadingState(false);
+      }
+    }
+
+    async function setSingleLuckmailPurchaseDisabledState(purchase, disabled) {
+      setLuckmailLoadingState(true, `正在${disabled ? '禁用' : '启用'} ${purchase.email_address} ...`);
+      try {
+        const response = await runtime.sendMessage({
+          type: 'SET_LUCKMAIL_PURCHASE_DISABLED_STATE',
+          source: 'sidepanel',
+          payload: { purchaseId: purchase.id, disabled },
+        });
+        if (response?.error) throw new Error(response.error);
+        helpers.showToast(`${purchase.email_address} 已${disabled ? '禁用' : '启用'}`, 'success', 2200);
+        await refreshLuckmailPurchases({ silent: true });
+      } catch (err) {
+        if (dom.luckmailSummary) dom.luckmailSummary.textContent = err.message;
+        helpers.showToast(`更新 LuckMail 禁用状态失败:${err.message}`, 'error');
+      } finally {
+        setLuckmailLoadingState(false);
+      }
+    }
+
+    async function runBulkLuckmailAction(action) {
+      const selectedIds = renderedPurchases
+        .filter((purchase) => selectedPurchaseIds.has(String(purchase.id)))
+        .map((purchase) => purchase.id);
+      if (!selectedIds.length) {
+        updateLuckmailBulkUI();
+        return;
+      }
+
+      const actionLabelMap = {
+        used: '标记已用',
+        unused: '标记未用',
+        preserve: '保留',
+        unpreserve: '取消保留',
+        disable: '禁用',
+        enable: '启用',
+      };
+
+      setLuckmailLoadingState(true, `正在批量${actionLabelMap[action] || '处理'} LuckMail 邮箱...`);
+      try {
+        const response = await runtime.sendMessage({
+          type: 'BATCH_UPDATE_LUCKMAIL_PURCHASES',
+          source: 'sidepanel',
+          payload: { action, ids: selectedIds },
+        });
+        if (response?.error) throw new Error(response.error);
+        helpers.showToast(`已批量${actionLabelMap[action] || '处理'} ${selectedIds.length} 个 LuckMail 邮箱`, 'success', 2400);
+        await refreshLuckmailPurchases({ silent: true });
+      } catch (err) {
+        if (dom.luckmailSummary) dom.luckmailSummary.textContent = err.message;
+        helpers.showToast(`批量处理 LuckMail 邮箱失败:${err.message}`, 'error');
+      } finally {
+        setLuckmailLoadingState(false);
+        updateLuckmailBulkUI();
+      }
+    }
+
+    async function disableUsedLuckmailPurchases() {
+      const confirmed = await helpers.openConfirmModal({
+        title: '禁用已用 LuckMail 邮箱',
+        message: '确认禁用所有本地已用且未保留的 openai LuckMail 邮箱吗?',
+        confirmLabel: '确认禁用',
+        confirmVariant: 'btn-danger',
+      });
+      if (!confirmed) {
+        return;
+      }
+
+      setLuckmailLoadingState(true, '正在禁用已用 LuckMail 邮箱...');
+      try {
+        const response = await runtime.sendMessage({
+          type: 'DISABLE_USED_LUCKMAIL_PURCHASES',
+          source: 'sidepanel',
+          payload: {},
+        });
+        if (response?.error) throw new Error(response.error);
+        const disabledCount = Array.isArray(response?.disabledIds) ? response.disabledIds.length : 0;
+        helpers.showToast(`已禁用 ${disabledCount} 个 LuckMail 邮箱`, disabledCount > 0 ? 'success' : 'info', 2400);
+        await refreshLuckmailPurchases({ silent: true });
+      } catch (err) {
+        if (dom.luckmailSummary) dom.luckmailSummary.textContent = err.message;
+        helpers.showToast(`禁用已用 LuckMail 邮箱失败:${err.message}`, 'error');
+      } finally {
+        setLuckmailLoadingState(false);
+      }
+    }
+
+    function reset() {
+      renderedPurchases = [];
+      selectedPurchaseIds.clear();
+      searchTerm = '';
+      filterMode = 'all';
+      refreshQueued = false;
+      if (dom.inputLuckmailSearch) dom.inputLuckmailSearch.value = '';
+      if (dom.selectLuckmailFilter) dom.selectLuckmailFilter.value = 'all';
+      if (dom.luckmailList) dom.luckmailList.innerHTML = '';
+      if (dom.luckmailSummary) dom.luckmailSummary.textContent = '加载已购邮箱后可在这里管理 openai 项目的 LuckMail 邮箱。';
+      if (dom.btnLuckmailDisableUsed) dom.btnLuckmailDisableUsed.disabled = true;
+      updateLuckmailBulkUI([]);
+    }
+
+    function bindLuckmailEvents() {
+      dom.inputLuckmailSearch?.addEventListener('input', (event) => {
+        searchTerm = event.target.value || '';
+        renderLuckmailPurchases(renderedPurchases);
+      });
+
+      dom.selectLuckmailFilter?.addEventListener('change', (event) => {
+        filterMode = String(event.target.value || 'all').trim() || 'all';
+        renderLuckmailPurchases(renderedPurchases);
+      });
+
+      dom.checkboxLuckmailSelectAll?.addEventListener('change', () => {
+        const visiblePurchases = getFilteredLuckmailPurchases();
+        if (dom.checkboxLuckmailSelectAll.checked) {
+          visiblePurchases.forEach((purchase) => selectedPurchaseIds.add(String(purchase.id)));
+        } else {
+          visiblePurchases.forEach((purchase) => selectedPurchaseIds.delete(String(purchase.id)));
+        }
+        renderLuckmailPurchases(renderedPurchases);
+      });
+
+      dom.btnLuckmailRefresh?.addEventListener('click', async () => {
+        await refreshLuckmailPurchases();
+      });
+
+      dom.btnLuckmailDisableUsed?.addEventListener('click', async () => {
+        await disableUsedLuckmailPurchases();
+      });
+
+      dom.btnLuckmailBulkUsed?.addEventListener('click', async () => {
+        await runBulkLuckmailAction('used');
+      });
+
+      dom.btnLuckmailBulkUnused?.addEventListener('click', async () => {
+        await runBulkLuckmailAction('unused');
+      });
+
+      dom.btnLuckmailBulkPreserve?.addEventListener('click', async () => {
+        await runBulkLuckmailAction('preserve');
+      });
+
+      dom.btnLuckmailBulkUnpreserve?.addEventListener('click', async () => {
+        await runBulkLuckmailAction('unpreserve');
+      });
+
+      dom.btnLuckmailBulkDisable?.addEventListener('click', async () => {
+        await runBulkLuckmailAction('disable');
+      });
+
+      dom.btnLuckmailBulkEnable?.addEventListener('click', async () => {
+        await runBulkLuckmailAction('enable');
+      });
+    }
+
+    return {
+      bindLuckmailEvents,
+      disableUsedLuckmailPurchases,
+      queueLuckmailPurchaseRefresh,
+      refreshLuckmailPurchases,
+      renderLuckmailPurchases,
+      reset,
+    };
+  }
+
+  globalScope.SidepanelLuckmailManager = {
+    createLuckmailManager,
+  };
+})(window);

+ 182 - 0
sidepanel/sidepanel.css

@@ -1657,6 +1657,188 @@ header {
   margin-bottom: 6px;
 }
 
+.account-run-history-strip {
+  margin-bottom: 8px;
+  padding: 10px 12px;
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+  background:
+    linear-gradient(180deg,
+      color-mix(in srgb, var(--bg-base) 72%, var(--blue-soft)),
+      color-mix(in srgb, var(--bg-surface) 92%, var(--bg-base))
+    );
+  border: 1px solid var(--border);
+  border-radius: var(--radius-md);
+  box-shadow: var(--shadow-sm);
+}
+
+.account-run-history-head {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: 10px;
+}
+
+.account-run-history-copy {
+  min-width: 0;
+  display: flex;
+  flex-direction: column;
+  gap: 3px;
+}
+
+.account-run-history-title {
+  font-size: 12px;
+  font-weight: 700;
+  letter-spacing: -0.01em;
+  color: var(--text-primary);
+}
+
+.account-run-history-meta {
+  font-size: 11px;
+  color: var(--text-secondary);
+  line-height: 1.45;
+}
+
+.account-run-history-stats {
+  display: flex;
+  align-items: center;
+  justify-content: flex-end;
+  gap: 6px;
+  flex-wrap: wrap;
+  flex-shrink: 0;
+}
+
+.account-run-history-stat {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  padding: 2px 8px;
+  border-radius: 999px;
+  border: 1px solid var(--border-subtle);
+  background: color-mix(in srgb, var(--bg-base) 86%, transparent);
+  font-size: 11px;
+  font-weight: 700;
+  color: var(--text-secondary);
+}
+
+.account-run-history-stat strong {
+  font-family: 'JetBrains Mono', 'Consolas', monospace;
+  font-size: 10px;
+}
+
+.account-run-history-stat.is-success {
+  color: var(--green);
+  background: var(--green-soft);
+  border-color: color-mix(in srgb, var(--green) 16%, var(--border));
+}
+
+.account-run-history-stat.is-failed {
+  color: var(--red);
+  background: var(--red-soft);
+  border-color: color-mix(in srgb, var(--red) 16%, var(--border));
+}
+
+.account-run-history-stat.is-stopped {
+  color: var(--cyan);
+  background: color-mix(in srgb, var(--cyan) 12%, var(--bg-base));
+  border-color: color-mix(in srgb, var(--cyan) 16%, var(--border));
+}
+
+.account-run-history-list {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+}
+
+.account-run-history-item {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) auto;
+  gap: 10px;
+  align-items: center;
+  padding: 8px 9px;
+  border: 1px solid var(--border-subtle);
+  border-radius: var(--radius-sm);
+  background: color-mix(in srgb, var(--bg-base) 90%, transparent);
+}
+
+.account-run-history-item-main {
+  min-width: 0;
+  display: flex;
+  flex-direction: column;
+  gap: 3px;
+}
+
+.account-run-history-item-email {
+  min-width: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  color: var(--text-primary);
+  font-size: 12px;
+  font-weight: 600;
+}
+
+.account-run-history-item-detail {
+  min-width: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  color: var(--text-secondary);
+  font-size: 11px;
+  line-height: 1.4;
+}
+
+.account-run-history-item-side {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  flex-shrink: 0;
+}
+
+.account-run-history-item-status {
+  display: inline-flex;
+  align-items: center;
+  padding: 2px 7px;
+  border-radius: 999px;
+  font-size: 11px;
+  font-weight: 700;
+  background: var(--bg-elevated);
+  color: var(--text-secondary);
+}
+
+.account-run-history-item-time {
+  color: var(--text-muted);
+  font-size: 11px;
+}
+
+.account-run-history-item.is-success {
+  border-color: color-mix(in srgb, var(--green) 14%, var(--border-subtle));
+}
+
+.account-run-history-item.is-success .account-run-history-item-status {
+  background: var(--green-soft);
+  color: var(--green);
+}
+
+.account-run-history-item.is-failed {
+  border-color: color-mix(in srgb, var(--red) 14%, var(--border-subtle));
+}
+
+.account-run-history-item.is-failed .account-run-history-item-status {
+  background: var(--red-soft);
+  color: var(--red);
+}
+
+.account-run-history-item.is-stopped {
+  border-color: color-mix(in srgb, var(--cyan) 14%, var(--border-subtle));
+}
+
+.account-run-history-item.is-stopped .account-run-history-item-status {
+  background: color-mix(in srgb, var(--cyan) 12%, var(--bg-base));
+  color: var(--cyan);
+}
+
 #log-area {
   background: var(--bg-surface);
   border: 1px solid var(--border);

+ 31 - 47
sidepanel/sidepanel.html

@@ -288,6 +288,22 @@
           </div>
         </div>
       </div>
+      <div class="data-row">
+        <span class="data-label">本地留档</span>
+        <div class="data-inline">
+          <label class="toggle-switch" for="input-account-run-history-text-enabled">
+            <input type="checkbox" id="input-account-run-history-text-enabled" />
+            <span class="toggle-switch-track" aria-hidden="true">
+              <span class="toggle-switch-thumb"></span>
+            </span>
+          </label>
+          <span class="data-value">同步写入账号运行历史 txt</span>
+        </div>
+      </div>
+      <div class="data-row" id="row-account-run-history-helper-base-url" style="display:none;">
+        <span class="data-label">留档服务</span>
+        <input type="text" id="input-account-run-history-helper-base-url" class="data-input mono" placeholder="http://127.0.0.1:17373" />
+      </div>
       <div class="data-row">
       <span class="data-label">OAuth</span>
       <span id="display-oauth-url" class="data-value mono truncate">等待中...</span>
@@ -526,53 +542,7 @@
       <span class="section-label">流程</span>
       <span id="steps-progress" class="steps-progress">0 / 9</span>
     </div>
-    <div class="steps-list">
-      <div class="step-row" data-step="1">
-        <div class="step-indicator" data-step="1"><span class="step-num">1</span></div>
-        <button class="step-btn" data-step="1">打开 ChatGPT 官网</button>
-        <span class="step-status" data-step="1"></span>
-      </div>
-      <div class="step-row" data-step="2">
-        <div class="step-indicator" data-step="2"><span class="step-num">2</span></div>
-        <button class="step-btn" data-step="2">注册并输入邮箱</button>
-        <span class="step-status" data-step="2"></span>
-      </div>
-      <div class="step-row" data-step="3">
-        <div class="step-indicator" data-step="3"><span class="step-num">3</span></div>
-        <button class="step-btn" data-step="3">填写密码并继续</button>
-        <span class="step-status" data-step="3"></span>
-      </div>
-      <div class="step-row" data-step="4">
-        <div class="step-indicator" data-step="4"><span class="step-num">4</span></div>
-        <button class="step-btn" data-step="4">获取注册验证码</button>
-        <span class="step-status" data-step="4"></span>
-      </div>
-      <div class="step-row" data-step="5">
-        <div class="step-indicator" data-step="5"><span class="step-num">5</span></div>
-        <button class="step-btn" data-step="5">填写姓名和生日</button>
-        <span class="step-status" data-step="5"></span>
-      </div>
-      <div class="step-row" data-step="6">
-        <div class="step-indicator" data-step="6"><span class="step-num">6</span></div>
-        <button class="step-btn" data-step="6">刷新 OAuth 并登录</button>
-        <span class="step-status" data-step="6"></span>
-      </div>
-      <div class="step-row" data-step="7">
-        <div class="step-indicator" data-step="7"><span class="step-num">7</span></div>
-        <button class="step-btn" data-step="7">获取登录验证码</button>
-        <span class="step-status" data-step="7"></span>
-      </div>
-      <div class="step-row" data-step="8">
-        <div class="step-indicator" data-step="8"><span class="step-num">8</span></div>
-        <button class="step-btn" data-step="8">自动确认 OAuth</button>
-        <span class="step-status" data-step="8"></span>
-      </div>
-      <div class="step-row" data-step="9">
-        <div class="step-indicator" data-step="9"><span class="step-num">9</span></div>
-        <button class="step-btn" data-step="9">平台回调验证</button>
-        <span class="step-status" data-step="9"></span>
-      </div>
-    </div>
+    <div class="steps-list"></div>
   </section>
 
   <section id="log-section">
@@ -580,6 +550,16 @@
       <span class="section-label">日志</span>
       <button id="btn-clear-log" class="btn btn-ghost btn-xs" title="清空日志">清空</button>
     </div>
+    <div id="account-run-history-strip" class="account-run-history-strip" hidden>
+      <div class="account-run-history-head">
+        <div class="account-run-history-copy">
+          <span class="account-run-history-title">账号运行历史</span>
+          <span id="account-run-history-meta" class="account-run-history-meta">最近运行记录</span>
+        </div>
+        <div id="account-run-history-stats" class="account-run-history-stats"></div>
+      </div>
+      <div id="account-run-history-list" class="account-run-history-list"></div>
+    </div>
     <div id="log-area"></div>
   </section>
 
@@ -610,7 +590,11 @@
   <script src="../icloud-utils.js"></script>
   <script src="../hotmail-utils.js"></script>
   <script src="../luckmail-utils.js"></script>
+  <script src="../data/step-definitions.js"></script>
   <script src="update-service.js"></script>
+  <script src="hotmail-manager.js"></script>
+  <script src="icloud-manager.js"></script>
+  <script src="luckmail-manager.js"></script>
   <script src="sidepanel.js"></script>
 </body>
 

Разница между файлами не показана из-за своего большого размера
+ 243 - 950
sidepanel/sidepanel.js


+ 10 - 0
tests/activation-utils.test.js

@@ -71,6 +71,16 @@ test('isRecoverableStep9AuthFailure matches timeout and CPA auth failure statuse
     true
   );
 
+  assert.equal(
+    isRecoverableStep9AuthFailure('认证失败: timeout of 30000ms exceeded'),
+    true
+  );
+
+  assert.equal(
+    isRecoverableStep9AuthFailure('回调 URL 提交失败: oauth flow is not pending'),
+    true
+  );
+
   assert.equal(
     isRecoverableStep9AuthFailure('认证成功!'),
     false

+ 78 - 48
tests/auto-run-fresh-attempt-reset.test.js

@@ -1,9 +1,10 @@
 const assert = require('assert');
 const fs = require('fs');
 
-const source = fs.readFileSync('background.js', 'utf8');
+const helperSource = fs.readFileSync('background.js', 'utf8');
+const autoRunModuleSource = fs.readFileSync('background/auto-run-controller.js', 'utf8');
 
-function extractFunction(name) {
+function extractFunction(source, name) {
   const markers = [`async function ${name}(`, `function ${name}(`];
   const start = markers
     .map(marker => source.indexOf(marker))
@@ -50,30 +51,25 @@ function extractFunction(name) {
   return source.slice(start, end);
 }
 
-const bundle = [
-  extractFunction('clearStopRequest'),
-  extractFunction('throwIfStopped'),
-  extractFunction('isStopError'),
-  extractFunction('isStepDoneStatus'),
-  extractFunction('isRestartCurrentAttemptError'),
-  extractFunction('getFirstUnfinishedStep'),
-  extractFunction('hasSavedProgress'),
-  extractFunction('getRunningSteps'),
-  extractFunction('getAutoRunStatusPayload'),
-  extractFunction('createAutoRunRoundSummary'),
-  extractFunction('normalizeAutoRunRoundSummary'),
-  extractFunction('buildAutoRunRoundSummaries'),
-  extractFunction('serializeAutoRunRoundSummaries'),
-  extractFunction('getAutoRunRoundRetryCount'),
-  extractFunction('formatAutoRunFailureReasons'),
-  extractFunction('logAutoRunFinalSummary'),
-  extractFunction('waitBetweenAutoRunRounds'),
-  extractFunction('autoRunLoop'),
+const helperBundle = [
+  extractFunction(helperSource, 'clearStopRequest'),
+  extractFunction(helperSource, 'throwIfStopped'),
+  extractFunction(helperSource, 'isStopError'),
+  extractFunction(helperSource, 'isStepDoneStatus'),
+  extractFunction(helperSource, 'isRestartCurrentAttemptError'),
+  extractFunction(helperSource, 'getFirstUnfinishedStep'),
+  extractFunction(helperSource, 'hasSavedProgress'),
+  extractFunction(helperSource, 'getRunningSteps'),
+  extractFunction(helperSource, 'getAutoRunStatusPayload'),
 ].join('\n');
 
-const api = new Function(`
+const api = new Function('autoRunModuleSource', `
+const self = {};
 const STOP_ERROR_MESSAGE = 'Flow stopped.';
 const AUTO_RUN_MAX_RETRIES_PER_ROUND = 3;
+const AUTO_RUN_RETRY_DELAY_MS = 3000;
+const AUTO_RUN_TIMER_KIND_BETWEEN_ROUNDS = 'between_rounds';
+const AUTO_RUN_TIMER_KIND_BEFORE_RETRY = 'before_retry';
 const DEFAULT_STATE = {
   stepStatuses: {
     1: 'pending',
@@ -89,10 +85,6 @@ const DEFAULT_STATE = {
 };
 
 let stopRequested = false;
-let autoRunActive = false;
-let autoRunCurrentRun = 0;
-let autoRunTotalRuns = 1;
-let autoRunAttemptRun = 0;
 let runCalls = 0;
 
 const logs = [];
@@ -189,21 +181,10 @@ function cancelPendingCommands() {}
 function normalizeAutoRunFallbackThreadIntervalMinutes(value) {
   return Math.max(0, Math.floor(Number(value) || 0));
 }
-function buildAutoRunRoundSummaries(totalRuns, rawSummaries = []) {
-  return Array.from({ length: totalRuns }, (_, index) => ({
-    round: index + 1,
-    status: rawSummaries[index]?.status || 'pending',
-    attempts: rawSummaries[index]?.attempts || 0,
-    failureReasons: [...(rawSummaries[index]?.failureReasons || [])],
-    finalFailureReason: rawSummaries[index]?.finalFailureReason || '',
-  }));
-}
-function serializeAutoRunRoundSummaries(totalRuns, roundSummaries = []) {
-  return buildAutoRunRoundSummaries(totalRuns, roundSummaries);
-}
-async function logAutoRunFinalSummary() {}
-async function waitBetweenAutoRunRounds() {}
-
+async function persistAutoRunTimerPlan() {}
+async function launchAutoRunTimerPlan() { return false; }
+function getPendingAutoRunTimerPlan() { return null; }
+function getErrorMessage(error) { return error?.message || String(error || ''); }
 const chrome = {
   runtime: {
     sendMessage() {
@@ -245,24 +226,73 @@ async function runAutoSequenceFromStep() {
   };
 }
 
-${bundle}
+${helperBundle}
+${autoRunModuleSource}
+
+const runtime = {
+  state: {
+    autoRunActive: false,
+    autoRunCurrentRun: 0,
+    autoRunTotalRuns: 1,
+    autoRunAttemptRun: 0,
+  },
+  get() {
+    return { ...this.state };
+  },
+  set(updates = {}) {
+    this.state = { ...this.state, ...updates };
+  },
+};
+
+const controller = self.MultiPageBackgroundAutoRunController.createAutoRunController({
+  addLog,
+  AUTO_RUN_MAX_RETRIES_PER_ROUND,
+  AUTO_RUN_RETRY_DELAY_MS,
+  AUTO_RUN_TIMER_KIND_BEFORE_RETRY,
+  AUTO_RUN_TIMER_KIND_BETWEEN_ROUNDS,
+  broadcastAutoRunStatus,
+  broadcastStopToContentScripts,
+  cancelPendingCommands,
+  clearStopRequest,
+  getAutoRunStatusPayload,
+  getErrorMessage,
+  getFirstUnfinishedStep,
+  getPendingAutoRunTimerPlan,
+  getRunningSteps,
+  getState,
+  getStopRequested: () => stopRequested,
+  hasSavedProgress,
+  isRestartCurrentAttemptError,
+  isStopError,
+  launchAutoRunTimerPlan,
+  normalizeAutoRunFallbackThreadIntervalMinutes,
+  persistAutoRunTimerPlan,
+  resetState,
+  runAutoSequenceFromStep,
+  runtime,
+  setState,
+  sleepWithStop,
+  waitForRunningStepsToFinish,
+  throwIfStopped,
+  chrome,
+});
 
 return {
-  autoRunLoop,
+  autoRunLoop: controller.autoRunLoop,
   snapshot() {
     return {
       runCalls,
-      autoRunActive,
-      autoRunCurrentRun,
-      autoRunTotalRuns,
-      autoRunAttemptRun,
+      autoRunActive: runtime.state.autoRunActive,
+      autoRunCurrentRun: runtime.state.autoRunCurrentRun,
+      autoRunTotalRuns: runtime.state.autoRunTotalRuns,
+      autoRunAttemptRun: runtime.state.autoRunAttemptRun,
       currentState,
       logs,
       broadcasts,
     };
   },
 };
-`)();
+`)(autoRunModuleSource);
 
 (async () => {
   await api.autoRunLoop(2, { autoRunSkipFailures: false, mode: 'restart' });

+ 197 - 0
tests/auto-run-step6-restart.test.js

@@ -0,0 +1,197 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+
+const source = fs.readFileSync('background.js', 'utf8');
+
+function extractFunction(name) {
+  const markers = [`async function ${name}(`, `function ${name}(`];
+  const start = markers
+    .map((marker) => source.indexOf(marker))
+    .find((index) => index >= 0);
+  if (start < 0) {
+    throw new Error(`missing function ${name}`);
+  }
+
+  let parenDepth = 0;
+  let signatureEnded = false;
+  let braceStart = -1;
+  for (let i = start; i < source.length; i += 1) {
+    const ch = source[i];
+    if (ch === '(') {
+      parenDepth += 1;
+    } else if (ch === ')') {
+      parenDepth -= 1;
+      if (parenDepth === 0) {
+        signatureEnded = true;
+      }
+    } else if (ch === '{' && signatureEnded) {
+      braceStart = i;
+      break;
+    }
+  }
+
+  if (braceStart < 0) {
+    throw new Error(`missing body for function ${name}`);
+  }
+
+  let depth = 0;
+  let end = braceStart;
+  for (; end < source.length; end += 1) {
+    const ch = source[end];
+    if (ch === '{') depth += 1;
+    if (ch === '}') {
+      depth -= 1;
+      if (depth === 0) {
+        end += 1;
+        break;
+      }
+    }
+  }
+
+  return source.slice(start, end);
+}
+
+const bundle = [
+  extractFunction('isAddPhoneAuthUrl'),
+  extractFunction('isAddPhoneAuthState'),
+  extractFunction('getPostStep6AutoRestartDecision'),
+  extractFunction('runAutoSequenceFromStep'),
+].join('\n');
+
+function createHarness(options = {}) {
+  const {
+    startStep = 6,
+    failureStep = 9,
+    failureBudget = 1,
+    failureMessage = '认证失败: Request failed with status code 502',
+    authState = { state: 'password_page', url: 'https://auth.openai.com/log-in' },
+  } = options;
+
+  return new Function(`
+const AUTO_STEP_DELAYS = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 0, 9: 0 };
+const LOG_PREFIX = '[test]';
+const chrome = {
+  tabs: {
+    update: async () => {},
+  },
+};
+
+let remainingFailures = ${JSON.stringify(failureBudget)};
+const events = {
+  steps: [],
+  logs: [],
+  invalidations: [],
+};
+
+async function addLog(message, level = 'info') {
+  events.logs.push({ message, level });
+}
+
+async function ensureAutoEmailReady() {}
+async function broadcastAutoRunStatus() {}
+async function getState() {
+  return {
+    stepStatuses: { 3: 'completed' },
+    mailProvider: '163',
+  };
+}
+function isStepDoneStatus(status) {
+  return status === 'completed' || status === 'manual_completed' || status === 'skipped';
+}
+async function executeStepAndWait(step) {
+  events.steps.push(step);
+  if (step === ${JSON.stringify(failureStep)} && remainingFailures > 0) {
+    remainingFailures -= 1;
+    throw new Error(${JSON.stringify(failureMessage)});
+  }
+}
+async function getTabId() {
+  return 1;
+}
+function shouldSkipLoginVerificationForCpaCallback() {
+  return false;
+}
+async function invalidateDownstreamAfterStepRestart(step, options = {}) {
+  events.invalidations.push({ step, options });
+}
+function getLoginAuthStateLabel(state) {
+  return state || 'unknown';
+}
+function getErrorMessage(error) {
+  return error?.message || String(error || '');
+}
+async function getLoginAuthStateFromContent() {
+  return ${JSON.stringify(authState)};
+}
+
+${bundle}
+
+return {
+  async run() {
+    await runAutoSequenceFromStep(${JSON.stringify(startStep)}, {
+      targetRun: 1,
+      totalRuns: 1,
+      attemptRuns: 1,
+      continued: false,
+    });
+    return events;
+  },
+  async runAndCaptureError() {
+    try {
+      await runAutoSequenceFromStep(${JSON.stringify(startStep)}, {
+        targetRun: 1,
+        totalRuns: 1,
+        attemptRuns: 1,
+        continued: false,
+      });
+      return null;
+    } catch (error) {
+      return { error, events };
+    }
+  },
+};
+`)();
+}
+
+test('auto-run keeps restarting from step 6 after post-login failures without a hard cap', async () => {
+  const harness = createHarness({
+    failureStep: 9,
+    failureBudget: 6,
+    failureMessage: '认证失败: Request failed with status code 502',
+    authState: { state: 'password_page', url: 'https://auth.openai.com/log-in' },
+  });
+
+  const events = await harness.run();
+
+  assert.equal(events.invalidations.length, 6);
+  assert.deepStrictEqual(
+    events.steps,
+    [
+      6, 7, 8, 9,
+      6, 7, 8, 9,
+      6, 7, 8, 9,
+      6, 7, 8, 9,
+      6, 7, 8, 9,
+      6, 7, 8, 9,
+      6, 7, 8, 9,
+    ]
+  );
+  assert.ok(events.logs.some(({ message }) => /回到步骤 6 重新开始授权流程/.test(message)));
+});
+
+test('auto-run stops restarting once add-phone is detected', async () => {
+  const harness = createHarness({
+    failureStep: 6,
+    failureBudget: 1,
+    failureMessage: '当前页面已进入手机号页。URL: https://auth.openai.com/add-phone',
+    authState: { state: 'add_phone_page', url: 'https://auth.openai.com/add-phone' },
+  });
+
+  const result = await harness.runAndCaptureError();
+
+  assert.ok(result?.error);
+  assert.equal(result.events.invalidations.length, 0);
+  assert.deepStrictEqual(result.events.steps, [6]);
+  assert.ok(result.events.logs.some(({ message }) => /进入 add-phone/.test(message)));
+});

+ 103 - 0
tests/background-account-history-settings.test.js

@@ -0,0 +1,103 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+
+const source = fs.readFileSync('background.js', 'utf8');
+
+function extractFunction(name) {
+  const markers = [`async function ${name}(`, `function ${name}(`];
+  const start = markers
+    .map((marker) => source.indexOf(marker))
+    .find((index) => index >= 0);
+  if (start < 0) {
+    throw new Error(`missing function ${name}`);
+  }
+
+  let parenDepth = 0;
+  let signatureEnded = false;
+  let braceStart = -1;
+  for (let i = start; i < source.length; i += 1) {
+    const ch = source[i];
+    if (ch === '(') {
+      parenDepth += 1;
+    } else if (ch === ')') {
+      parenDepth -= 1;
+      if (parenDepth === 0) {
+        signatureEnded = true;
+      }
+    } else if (ch === '{' && signatureEnded) {
+      braceStart = i;
+      break;
+    }
+  }
+
+  let depth = 0;
+  let end = braceStart;
+  for (; end < source.length; end += 1) {
+    const ch = source[end];
+    if (ch === '{') depth += 1;
+    if (ch === '}') {
+      depth -= 1;
+      if (depth === 0) {
+        end += 1;
+        break;
+      }
+    }
+  }
+
+  return source.slice(start, end);
+}
+
+test('background account history settings are normalized independently from hotmail service mode', () => {
+  const bundle = [
+    extractFunction('normalizeHotmailLocalBaseUrl'),
+    extractFunction('normalizeAccountRunHistoryHelperBaseUrl'),
+    extractFunction('normalizePersistentSettingValue'),
+  ].join('\n');
+
+  const api = new Function(`
+const DEFAULT_HOTMAIL_LOCAL_BASE_URL = 'http://127.0.0.1:17373';
+const DEFAULT_ACCOUNT_RUN_HISTORY_HELPER_BASE_URL = DEFAULT_HOTMAIL_LOCAL_BASE_URL;
+const DEFAULT_HOTMAIL_REMOTE_BASE_URL = '';
+const HOTMAIL_SERVICE_MODE_REMOTE = 'remote';
+const HOTMAIL_SERVICE_MODE_LOCAL = 'local';
+const PERSISTED_SETTING_DEFAULTS = {
+  autoStepDelaySeconds: null,
+  mailProvider: '163',
+};
+function normalizePanelMode(value) { return value === 'sub2api' ? 'sub2api' : 'cpa'; }
+function normalizeLocalCpaStep9Mode(value) { return value === 'bypass' ? 'bypass' : 'submit'; }
+function normalizeCpaCallbackMode(value) { return value === 'step6' ? 'step6' : 'step8'; }
+function normalizeAutoRunFallbackThreadIntervalMinutes(value) { return Number(value) || 0; }
+function normalizeAutoRunDelayMinutes(value) { return Number(value) || 30; }
+function normalizeAutoStepDelaySeconds(value) { return value == null || value === '' ? null : Number(value); }
+function normalizeMailProvider(value) { return String(value || '').trim().toLowerCase() || '163'; }
+function normalizeMail2925Mode(value) { return String(value || '').trim().toLowerCase() === 'receive' ? 'receive' : 'provide'; }
+function normalizeEmailGenerator(value) { return String(value || '').trim().toLowerCase() || 'duck'; }
+function normalizeIcloudHost(value) { const normalized = String(value || '').trim().toLowerCase(); return normalized === 'icloud.com' || normalized === 'icloud.com.cn' ? normalized : ''; }
+function normalizeHotmailServiceMode(value) { return String(value || '').trim().toLowerCase() === 'remote' ? 'remote' : 'local'; }
+function normalizeHotmailRemoteBaseUrl(value) { return String(value || '').trim(); }
+function normalizeCloudflareDomain(value) { return String(value || '').trim(); }
+function normalizeCloudflareDomains(value) { return Array.isArray(value) ? value : []; }
+function normalizeCloudflareTempEmailBaseUrl(value) { return String(value || '').trim(); }
+function normalizeCloudflareTempEmailReceiveMailbox(value) { return String(value || '').trim().toLowerCase(); }
+function normalizeCloudflareTempEmailDomain(value) { return String(value || '').trim(); }
+function normalizeCloudflareTempEmailDomains(value) { return Array.isArray(value) ? value : []; }
+function normalizeHotmailAccounts(value) { return Array.isArray(value) ? value : []; }
+${bundle}
+return {
+  normalizeAccountRunHistoryHelperBaseUrl,
+  normalizePersistentSettingValue,
+};
+  `)();
+
+  assert.equal(api.normalizePersistentSettingValue('accountRunHistoryTextEnabled', 1), true);
+  assert.equal(
+    api.normalizePersistentSettingValue('accountRunHistoryHelperBaseUrl', 'http://127.0.0.1:17373/append-account-log'),
+    'http://127.0.0.1:17373'
+  );
+  assert.equal(
+    api.normalizeAccountRunHistoryHelperBaseUrl(''),
+    'http://127.0.0.1:17373'
+  );
+});

+ 76 - 0
tests/background-account-run-history-module.test.js

@@ -0,0 +1,76 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+
+test('background imports account run history module', () => {
+  const source = fs.readFileSync('background.js', 'utf8');
+  assert.match(source, /background\/account-run-history\.js/);
+});
+
+test('account run history module exposes a factory', () => {
+  const source = fs.readFileSync('background/account-run-history.js', 'utf8');
+  const globalScope = {};
+
+  const api = new Function('self', `${source}; return self.MultiPageBackgroundAccountRunHistory;`)(globalScope);
+
+  assert.equal(typeof api?.createAccountRunHistoryHelpers, 'function');
+});
+
+test('account run history helper normalizes records and persists without helper upload when local helper is disabled', async () => {
+  const source = fs.readFileSync('background/account-run-history.js', 'utf8');
+  const globalScope = {};
+  const api = new Function('self', `${source}; return self.MultiPageBackgroundAccountRunHistory;`)(globalScope);
+
+  let storedHistory = [{ email: 'old@example.com', password: 'old-pass', status: 'success', recordedAt: '2026-04-17T00:00:00.000Z' }];
+  let fetchCalled = false;
+  global.fetch = async () => {
+    fetchCalled = true;
+    throw new Error('should not call fetch');
+  };
+
+  const helpers = api.createAccountRunHistoryHelpers({
+    ACCOUNT_RUN_HISTORY_STORAGE_KEY: 'accountRunHistory',
+    addLog: async () => {},
+    buildLocalHelperEndpoint: (baseUrl, path) => `${baseUrl}${path}`,
+    chrome: {
+      storage: {
+        local: {
+          get: async () => ({ accountRunHistory: storedHistory }),
+          set: async (payload) => {
+            storedHistory = payload.accountRunHistory;
+          },
+        },
+      },
+    },
+    getErrorMessage: (error) => error?.message || String(error || ''),
+    getState: async () => ({
+      email: ' latest@example.com ',
+      password: ' secret ',
+      accountRunHistoryTextEnabled: false,
+      accountRunHistoryHelperBaseUrl: '',
+    }),
+    normalizeAccountRunHistoryHelperBaseUrl: (value) => String(value || '').trim(),
+  });
+
+  const record = helpers.buildAccountRunHistoryRecord(
+    { email: ' latest@example.com ', password: ' secret ' },
+    ' FAILED ',
+    ' reason '
+  );
+  assert.deepStrictEqual(record, {
+    email: 'latest@example.com',
+    password: 'secret',
+    status: 'failed',
+    recordedAt: record.recordedAt,
+    reason: 'reason',
+  });
+
+  const appended = await helpers.appendAccountRunRecord('failed', null, 'boom');
+  assert.equal(appended.email, 'latest@example.com');
+  assert.equal(appended.status, 'failed');
+  assert.equal(storedHistory.length, 2);
+  assert.equal(storedHistory[1].reason, 'boom');
+  assert.equal(fetchCalled, false);
+  assert.equal(helpers.shouldAppendAccountRunTextFile({ accountRunHistoryTextEnabled: false, accountRunHistoryHelperBaseUrl: 'http://127.0.0.1:17373' }), false);
+  assert.equal(helpers.shouldAppendAccountRunTextFile({ accountRunHistoryTextEnabled: true, accountRunHistoryHelperBaseUrl: 'http://127.0.0.1:17373' }), true);
+});

+ 17 - 0
tests/background-auto-run-module.test.js

@@ -0,0 +1,17 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+
+test('background imports auto-run controller module', () => {
+  const source = fs.readFileSync('background.js', 'utf8');
+  assert.match(source, /background\/auto-run-controller\.js/);
+});
+
+test('auto-run controller module exposes a factory', () => {
+  const source = fs.readFileSync('background/auto-run-controller.js', 'utf8');
+  const globalScope = {};
+
+  const api = new Function('self', `${source}; return self.MultiPageBackgroundAutoRunController;`)(globalScope);
+
+  assert.equal(typeof api?.createAutoRunController, 'function');
+});

+ 17 - 0
tests/background-generated-email-module.test.js

@@ -0,0 +1,17 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+
+test('background imports generated email helper module', () => {
+  const source = fs.readFileSync('background.js', 'utf8');
+  assert.match(source, /importScripts\([\s\S]*'background\/generated-email-helpers\.js'/);
+});
+
+test('generated email helper module exposes a factory', () => {
+  const source = fs.readFileSync('background/generated-email-helpers.js', 'utf8');
+  const globalScope = {};
+
+  const api = new Function('self', `${source}; return self.MultiPageGeneratedEmailHelpers;`)(globalScope);
+
+  assert.equal(typeof api?.createGeneratedEmailHelpers, 'function');
+});

+ 17 - 0
tests/background-logging-status-module.test.js

@@ -0,0 +1,17 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+
+test('background imports logging/status module', () => {
+  const source = fs.readFileSync('background.js', 'utf8');
+  assert.match(source, /background\/logging-status\.js/);
+});
+
+test('logging/status module exposes a factory', () => {
+  const source = fs.readFileSync('background/logging-status.js', 'utf8');
+  const globalScope = {};
+
+  const api = new Function('self', `${source}; return self.MultiPageBackgroundLoggingStatus;`)(globalScope);
+
+  assert.equal(typeof api?.createLoggingStatus, 'function');
+});

+ 17 - 0
tests/background-message-router-module.test.js

@@ -0,0 +1,17 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+
+test('background imports message router module', () => {
+  const source = fs.readFileSync('background.js', 'utf8');
+  assert.match(source, /background\/message-router\.js/);
+});
+
+test('message router module exposes a factory', () => {
+  const source = fs.readFileSync('background/message-router.js', 'utf8');
+  const globalScope = {};
+
+  const api = new Function('self', `${source}; return self.MultiPageBackgroundMessageRouter;`)(globalScope);
+
+  assert.equal(typeof api?.createMessageRouter, 'function');
+});

+ 127 - 0
tests/background-message-router-step2-skip.test.js

@@ -0,0 +1,127 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+
+const source = fs.readFileSync('background/message-router.js', 'utf8');
+const globalScope = {};
+const api = new Function('self', `${source}; return self.MultiPageBackgroundMessageRouter;`)(globalScope);
+
+function createRouter(overrides = {}) {
+  const events = {
+    logs: [],
+    stepStatuses: [],
+    emailStates: [],
+  };
+
+  const router = api.createMessageRouter({
+    addLog: async (message, level) => {
+      events.logs.push({ message, level });
+    },
+    appendAccountRunRecord: async () => null,
+    batchUpdateLuckmailPurchases: async () => {},
+    buildLocalhostCleanupPrefix: () => '',
+    buildLuckmailSessionSettingsPayload: () => ({}),
+    buildPersistentSettingsPayload: () => ({}),
+    broadcastDataUpdate: () => {},
+    cancelScheduledAutoRun: async () => {},
+    checkIcloudSession: async () => {},
+    clearAutoRunTimerAlarm: async () => {},
+    clearLuckmailRuntimeState: async () => {},
+    clearStopRequest: () => {},
+    closeLocalhostCallbackTabs: async () => {},
+    closeTabsByUrlPrefix: async () => {},
+    deleteHotmailAccount: async () => {},
+    deleteHotmailAccounts: async () => {},
+    deleteIcloudAlias: async () => {},
+    deleteUsedIcloudAliases: async () => {},
+    disableUsedLuckmailPurchases: async () => {},
+    doesStepUseCompletionSignal: () => false,
+    ensureManualInteractionAllowed: async () => ({}),
+    executeStep: async () => {},
+    executeStepViaCompletionSignal: async () => {},
+    exportSettingsBundle: async () => ({}),
+    fetchGeneratedEmail: async () => '',
+    finalizeIcloudAliasAfterSuccessfulFlow: async () => {},
+    findHotmailAccount: async () => null,
+    flushCommand: async () => {},
+    getCurrentLuckmailPurchase: () => null,
+    getPendingAutoRunTimerPlan: () => null,
+    getSourceLabel: () => '',
+    getState: async () => overrides.state || { stepStatuses: { 3: 'pending' } },
+    getStopRequested: () => false,
+    handleAutoRunLoopUnhandledError: async () => {},
+    importSettingsBundle: async () => {},
+    invalidateDownstreamAfterStepRestart: async () => {},
+    isAutoRunLockedState: () => false,
+    isHotmailProvider: () => false,
+    isLocalhostOAuthCallbackUrl: () => true,
+    isLuckmailProvider: () => false,
+    isStopError: () => false,
+    launchAutoRunTimerPlan: async () => {},
+    listIcloudAliases: async () => [],
+    listLuckmailPurchasesForManagement: async () => [],
+    normalizeHotmailAccounts: (items) => items,
+    normalizeRunCount: (value) => value,
+    AUTO_RUN_TIMER_KIND_SCHEDULED_START: 'scheduled',
+    notifyStepComplete: () => {},
+    notifyStepError: () => {},
+    patchHotmailAccount: async () => {},
+    registerTab: async () => {},
+    requestStop: async () => {},
+    resetState: async () => {},
+    resumeAutoRun: async () => {},
+    scheduleAutoRun: async () => {},
+    selectLuckmailPurchase: async () => {},
+    setCurrentHotmailAccount: async () => {},
+    setEmailState: async (email) => {
+      events.emailStates.push(email);
+    },
+    setEmailStateSilently: async () => {},
+    setIcloudAliasPreservedState: async () => {},
+    setIcloudAliasUsedState: async () => {},
+    setLuckmailPurchaseDisabledState: async () => {},
+    setLuckmailPurchasePreservedState: async () => {},
+    setLuckmailPurchaseUsedState: async () => {},
+    setPersistentSettings: async () => {},
+    setState: async () => {},
+    setStepStatus: async (step, status) => {
+      events.stepStatuses.push({ step, status });
+    },
+    skipAutoRunCountdown: async () => false,
+    skipStep: async () => {},
+    startAutoRunLoop: async () => {},
+    syncHotmailAccounts: async () => {},
+    testHotmailAccountMailAccess: async () => {},
+    upsertHotmailAccount: async () => {},
+    verifyHotmailAccount: async () => {},
+  });
+
+  return { router, events };
+}
+
+test('message router skips step 3 when step 2 lands on verification page', async () => {
+  const { router, events } = createRouter({
+    state: { stepStatuses: { 3: 'pending' } },
+  });
+
+  await router.handleStepData(2, {
+    email: 'user@example.com',
+    skippedPasswordStep: true,
+  });
+
+  assert.deepStrictEqual(events.emailStates, ['user@example.com']);
+  assert.deepStrictEqual(events.stepStatuses, [{ step: 3, status: 'skipped' }]);
+  assert.equal(events.logs[0]?.message, '步骤 2:提交邮箱后页面直接进入邮箱验证码页,已自动跳过步骤 3。');
+});
+
+test('message router does not overwrite a completed step 3 when step 2 is replayed', async () => {
+  const { router, events } = createRouter({
+    state: { stepStatuses: { 3: 'completed' } },
+  });
+
+  await router.handleStepData(2, {
+    skippedPasswordStep: true,
+  });
+
+  assert.deepStrictEqual(events.stepStatuses, []);
+});

+ 17 - 0
tests/background-navigation-utils-module.test.js

@@ -0,0 +1,17 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+
+test('background imports navigation utils module', () => {
+  const source = fs.readFileSync('background.js', 'utf8');
+  assert.match(source, /background\/navigation-utils\.js/);
+});
+
+test('navigation utils module exposes a factory', () => {
+  const source = fs.readFileSync('background/navigation-utils.js', 'utf8');
+  const globalScope = {};
+
+  const api = new Function('self', `${source}; return self.MultiPageBackgroundNavigationUtils;`)(globalScope);
+
+  assert.equal(typeof api?.createNavigationUtils, 'function');
+});

+ 17 - 0
tests/background-panel-bridge-module.test.js

@@ -0,0 +1,17 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+
+test('background imports panel bridge module', () => {
+  const source = fs.readFileSync('background.js', 'utf8');
+  assert.match(source, /importScripts\([\s\S]*'background\/panel-bridge\.js'/);
+});
+
+test('panel bridge module exposes a factory', () => {
+  const source = fs.readFileSync('background/panel-bridge.js', 'utf8');
+  const globalScope = {};
+
+  const api = new Function('self', `${source}; return self.MultiPageBackgroundPanelBridge;`)(globalScope);
+
+  assert.equal(typeof api?.createPanelBridge, 'function');
+});

+ 17 - 0
tests/background-signup-flow-module.test.js

@@ -0,0 +1,17 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+
+test('background imports signup flow helper module', () => {
+  const source = fs.readFileSync('background.js', 'utf8');
+  assert.match(source, /importScripts\([\s\S]*'background\/signup-flow-helpers\.js'/);
+});
+
+test('signup flow helper module exposes a factory', () => {
+  const source = fs.readFileSync('background/signup-flow-helpers.js', 'utf8');
+  const globalScope = {};
+
+  const api = new Function('self', `${source}; return self.MultiPageSignupFlowHelpers;`)(globalScope);
+
+  assert.equal(typeof api?.createSignupFlowHelpers, 'function');
+});

+ 134 - 0
tests/background-signup-step2-branching.test.js

@@ -0,0 +1,134 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+
+const step2Source = fs.readFileSync('background/steps/submit-signup-email.js', 'utf8');
+const step2GlobalScope = {};
+const step2Api = new Function('self', `${step2Source}; return self.MultiPageBackgroundStep2;`)(step2GlobalScope);
+
+const signupFlowSource = fs.readFileSync('background/signup-flow-helpers.js', 'utf8');
+const signupFlowGlobalScope = {};
+const signupFlowApi = new Function('self', `${signupFlowSource}; return self.MultiPageSignupFlowHelpers;`)(signupFlowGlobalScope);
+
+test('step 2 completes with password step skipped when landing on email verification page', async () => {
+  const completedPayloads = [];
+
+  const executor = step2Api.createStep2Executor({
+    addLog: async () => {},
+    chrome: { tabs: { update: async () => {} } },
+    completeStepFromBackground: async (step, payload) => {
+      completedPayloads.push({ step, payload });
+    },
+    ensureContentScriptReadyOnTab: async () => {},
+    ensureSignupEntryPageReady: async () => ({ tabId: 11 }),
+    ensureSignupPostEmailPageReadyInTab: async () => ({
+      state: 'verification_page',
+      url: 'https://auth.openai.com/email-verification',
+    }),
+    getTabId: async () => 11,
+    isTabAlive: async () => true,
+    resolveSignupEmailForFlow: async () => 'user@example.com',
+    sendToContentScriptResilient: async () => ({ submitted: true }),
+    SIGNUP_PAGE_INJECT_FILES: [],
+  });
+
+  await executor.executeStep2({ email: 'user@example.com' });
+
+  assert.deepStrictEqual(completedPayloads, [
+    {
+      step: 2,
+      payload: {
+        email: 'user@example.com',
+        nextSignupState: 'verification_page',
+        nextSignupUrl: 'https://auth.openai.com/email-verification',
+        skippedPasswordStep: true,
+      },
+    },
+  ]);
+});
+
+test('step 2 keeps password flow when landing on password page', async () => {
+  const completedPayloads = [];
+
+  const executor = step2Api.createStep2Executor({
+    addLog: async () => {},
+    chrome: { tabs: { update: async () => {} } },
+    completeStepFromBackground: async (step, payload) => {
+      completedPayloads.push({ step, payload });
+    },
+    ensureContentScriptReadyOnTab: async () => {},
+    ensureSignupEntryPageReady: async () => ({ tabId: 12 }),
+    ensureSignupPostEmailPageReadyInTab: async () => ({
+      state: 'password_page',
+      url: 'https://auth.openai.com/create-account/password',
+    }),
+    getTabId: async () => 12,
+    isTabAlive: async () => true,
+    resolveSignupEmailForFlow: async () => 'user@example.com',
+    sendToContentScriptResilient: async () => ({ submitted: true }),
+    SIGNUP_PAGE_INJECT_FILES: [],
+  });
+
+  await executor.executeStep2({ email: 'user@example.com' });
+
+  assert.deepStrictEqual(completedPayloads, [
+    {
+      step: 2,
+      payload: {
+        email: 'user@example.com',
+        nextSignupState: 'password_page',
+        nextSignupUrl: 'https://auth.openai.com/create-account/password',
+        skippedPasswordStep: false,
+      },
+    },
+  ]);
+});
+
+test('signup flow helper recognizes email verification page as post-email landing page', async () => {
+  let ensureCalls = 0;
+  let passwordReadyChecks = 0;
+
+  const helpers = signupFlowApi.createSignupFlowHelpers({
+    buildGeneratedAliasEmail: () => '',
+    chrome: {
+      tabs: {
+        get: async () => ({
+          id: 21,
+          url: 'https://auth.openai.com/email-verification',
+        }),
+      },
+    },
+    ensureContentScriptReadyOnTab: async () => {
+      ensureCalls += 1;
+    },
+    ensureHotmailAccountForFlow: async () => ({}),
+    ensureLuckmailPurchaseForFlow: async () => ({}),
+    isGeneratedAliasProvider: () => false,
+    isHotmailProvider: () => false,
+    isLuckmailProvider: () => false,
+    isSignupEmailVerificationPageUrl: (url) => /\/email-verification(?:[/?#]|$)/i.test(url || ''),
+    isSignupPasswordPageUrl: (url) => /\/create-account\/password(?:[/?#]|$)/i.test(url || ''),
+    reuseOrCreateTab: async () => 21,
+    sendToContentScriptResilient: async () => {
+      passwordReadyChecks += 1;
+      return {};
+    },
+    setEmailState: async () => {},
+    SIGNUP_ENTRY_URL: 'https://chatgpt.com/',
+    SIGNUP_PAGE_INJECT_FILES: [],
+    waitForTabUrlMatch: async () => ({
+      id: 21,
+      url: 'https://auth.openai.com/email-verification',
+    }),
+  });
+
+  const result = await helpers.ensureSignupPostEmailPageReadyInTab(21, 2);
+
+  assert.deepStrictEqual(result, {
+    ready: true,
+    state: 'verification_page',
+    url: 'https://auth.openai.com/email-verification',
+  });
+  assert.equal(ensureCalls, 1);
+  assert.equal(passwordReadyChecks, 0);
+});

+ 21 - 0
tests/background-step-modules.test.js

@@ -0,0 +1,21 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+
+test('background imports step 1~9 modules', () => {
+  const source = fs.readFileSync('background.js', 'utf8');
+
+  [
+    'background/steps/open-chatgpt.js',
+    'background/steps/submit-signup-email.js',
+    'background/steps/fill-password.js',
+    'background/steps/fetch-signup-code.js',
+    'background/steps/fill-profile.js',
+    'background/steps/oauth-login.js',
+    'background/steps/fetch-login-code.js',
+    'background/steps/confirm-oauth.js',
+    'background/steps/platform-verify.js',
+  ].forEach((path) => {
+    assert.match(source, new RegExp(path.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
+  });
+});

+ 11 - 0
tests/background-step-registry.test.js

@@ -0,0 +1,11 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+
+test('background imports step registry and shared step definitions', () => {
+  const source = fs.readFileSync('background.js', 'utf8');
+  assert.match(source, /background\/steps\/registry\.js/);
+  assert.match(source, /data\/step-definitions\.js/);
+  assert.match(source, /MultiPageStepDefinitions\?\.getSteps/);
+  assert.match(source, /stepRegistry\.executeStep\(step,\s*state\)/);
+});

+ 47 - 0
tests/background-step5-submit-short-circuit.test.js

@@ -0,0 +1,47 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+
+const source = fs.readFileSync('background/steps/fill-profile.js', 'utf8');
+const globalScope = {};
+const api = new Function('self', `${source}; return self.MultiPageBackgroundStep5;`)(globalScope);
+
+test('step 5 forwards generated profile data and relies on completion signal flow', async () => {
+  const events = {
+    logs: [],
+    messages: [],
+  };
+
+  const executor = api.createStep5Executor({
+    addLog: async (message, level) => {
+      events.logs.push({ message, level: level || 'info' });
+    },
+    generateRandomBirthday: () => ({ year: 2003, month: 6, day: 19 }),
+    generateRandomName: () => ({ firstName: 'Test', lastName: 'User' }),
+    sendToContentScript: async (source, message) => {
+      events.messages.push({ source, message });
+      return { accepted: true };
+    },
+  });
+
+  await executor.executeStep5();
+
+  assert.deepStrictEqual(events.messages, [
+    {
+      source: 'signup-page',
+      message: {
+        type: 'EXECUTE_STEP',
+        step: 5,
+        source: 'background',
+        payload: {
+          firstName: 'Test',
+          lastName: 'User',
+          year: 2003,
+          month: 6,
+          day: 19,
+        },
+      },
+    },
+  ]);
+  assert.ok(events.logs.some(({ message }) => /已生成姓名 Test User/.test(message)));
+});

+ 103 - 0
tests/background-step6-retry-limit.test.js

@@ -0,0 +1,103 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+
+const source = fs.readFileSync('background/steps/oauth-login.js', 'utf8');
+const globalScope = {};
+const api = new Function('self', `${source}; return self.MultiPageBackgroundStep6;`)(globalScope);
+
+test('step 6 retries up to configured limit and then fails', async () => {
+  const events = {
+    cleanupCalls: 0,
+    refreshCalls: 0,
+    sendCalls: 0,
+    completed: 0,
+  };
+
+  const executor = api.createStep6Executor({
+    addLog: async () => {},
+    completeStepFromBackground: async () => {
+      events.completed += 1;
+    },
+    getErrorMessage: (error) => error?.message || String(error || ''),
+    getLoginAuthStateLabel: (state) => state || 'unknown',
+    getState: async () => ({ email: 'user@example.com', password: 'secret' }),
+    isStep6RecoverableResult: (result) => result?.step6Outcome === 'recoverable',
+    isStep6SuccessResult: (result) => result?.step6Outcome === 'success',
+    refreshOAuthUrlBeforeStep6: async () => {
+      events.refreshCalls += 1;
+      return `https://oauth.example/${events.refreshCalls}`;
+    },
+    reuseOrCreateTab: async () => {},
+    runPreStep6CookieCleanup: async () => {
+      events.cleanupCalls += 1;
+    },
+    sendToContentScriptResilient: async () => {
+      events.sendCalls += 1;
+      return {
+        step6Outcome: 'recoverable',
+        state: 'email_page',
+        message: '当前仍停留在邮箱页',
+      };
+    },
+    shouldSkipLoginVerificationForCpaCallback: () => false,
+    skipLoginVerificationStepsForCpaCallback: async () => {},
+    STEP6_MAX_ATTEMPTS: 3,
+    throwIfStopped: () => {},
+  });
+
+  await assert.rejects(
+    () => executor.executeStep6({ email: 'user@example.com', password: 'secret' }),
+    /已重试 2 次,仍未成功/
+  );
+
+  assert.equal(events.cleanupCalls, 1);
+  assert.equal(events.refreshCalls, 3);
+  assert.equal(events.sendCalls, 3);
+  assert.equal(events.completed, 0);
+});
+
+test('step 6 can skip pre-login cleanup during step 7 recovery replay', async () => {
+  const events = {
+    cleanupCalls: 0,
+    completedPayloads: [],
+  };
+
+  const executor = api.createStep6Executor({
+    addLog: async () => {},
+    completeStepFromBackground: async (step, payload) => {
+      events.completedPayloads.push({ step, payload });
+    },
+    getErrorMessage: (error) => error?.message || String(error || ''),
+    getLoginAuthStateLabel: (state) => state || 'unknown',
+    getState: async () => ({ email: 'user@example.com', password: 'secret' }),
+    isStep6RecoverableResult: () => false,
+    isStep6SuccessResult: (result) => result?.step6Outcome === 'success',
+    refreshOAuthUrlBeforeStep6: async () => 'https://oauth.example/latest',
+    reuseOrCreateTab: async () => {},
+    runPreStep6CookieCleanup: async () => {
+      events.cleanupCalls += 1;
+    },
+    sendToContentScriptResilient: async () => ({
+      step6Outcome: 'success',
+      loginVerificationRequestedAt: 123,
+    }),
+    shouldSkipLoginVerificationForCpaCallback: () => false,
+    skipLoginVerificationStepsForCpaCallback: async () => {},
+    STEP6_MAX_ATTEMPTS: 3,
+    throwIfStopped: () => {},
+  });
+
+  await executor.executeStep6(
+    { email: 'user@example.com', password: 'secret' },
+    { skipPreLoginCleanup: true }
+  );
+
+  assert.equal(events.cleanupCalls, 0);
+  assert.deepStrictEqual(events.completedPayloads, [
+    {
+      step: 6,
+      payload: { loginVerificationRequestedAt: 123 },
+    },
+  ]);
+});

+ 127 - 0
tests/background-step7-recovery.test.js

@@ -0,0 +1,127 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+
+const source = fs.readFileSync('background/steps/fetch-login-code.js', 'utf8');
+const globalScope = {};
+const api = new Function('self', `${source}; return self.MultiPageBackgroundStep7;`)(globalScope);
+
+test('step 7 refreshes CPA oauth via step 6 replay before submitting verification code', async () => {
+  const calls = {
+    ensureReady: 0,
+    executeStep6: [],
+    sleep: [],
+    resolveOptions: null,
+  };
+
+  const executor = api.createStep7Executor({
+    addLog: async () => {},
+    chrome: {
+      tabs: {
+        update: async () => {},
+      },
+    },
+    CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
+    confirmCustomVerificationStepBypass: async () => {},
+    ensureStep7VerificationPageReady: async () => {
+      calls.ensureReady += 1;
+      return { state: 'verification_page' };
+    },
+    executeStep6: async (_state, options = {}) => {
+      calls.executeStep6.push(options);
+    },
+    getMailConfig: () => ({
+      provider: 'qq',
+      label: 'QQ 邮箱',
+      source: 'mail-qq',
+      url: 'https://mail.qq.com',
+      navigateOnReuse: false,
+    }),
+    getPanelMode: () => 'cpa',
+    getState: async () => ({ email: 'user@example.com', password: 'secret' }),
+    getTabId: async (sourceName) => (sourceName === 'signup-page' ? 1 : 2),
+    HOTMAIL_PROVIDER: 'hotmail-api',
+    isTabAlive: async () => true,
+    isVerificationMailPollingError: () => false,
+    LUCKMAIL_PROVIDER: 'luckmail-api',
+    resolveVerificationStep: async (_step, _state, _mail, options) => {
+      calls.resolveOptions = options;
+      await options.beforeSubmit({ code: '654321' });
+    },
+    reuseOrCreateTab: async () => {},
+    setState: async () => {},
+    setStepStatus: async () => {},
+    shouldSkipLoginVerificationForCpaCallback: () => false,
+    shouldUseCustomRegistrationEmail: () => false,
+    sleepWithStop: async (ms) => {
+      calls.sleep.push(ms);
+    },
+    STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000,
+    STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS: 8,
+    throwIfStopped: () => {},
+  });
+
+  await executor.executeStep7({
+    email: 'user@example.com',
+    password: 'secret',
+    oauthUrl: 'https://oauth.example/latest',
+  });
+
+  assert.equal(typeof calls.resolveOptions.beforeSubmit, 'function');
+  assert.equal(calls.ensureReady, 2);
+  assert.deepStrictEqual(calls.executeStep6, [{ skipPreLoginCleanup: true }]);
+  assert.deepStrictEqual(calls.sleep, [1200]);
+  assert.equal(calls.resolveOptions.resendIntervalMs, 25000);
+});
+
+test('step 7 disables resend interval for 2925 mailbox polling', async () => {
+  let capturedOptions = null;
+
+  const executor = api.createStep7Executor({
+    addLog: async () => {},
+    chrome: {
+      tabs: {
+        update: async () => {},
+      },
+    },
+    CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
+    confirmCustomVerificationStepBypass: async () => {},
+    ensureStep7VerificationPageReady: async () => ({ state: 'verification_page' }),
+    executeStep6: async () => {},
+    getMailConfig: () => ({
+      provider: '2925',
+      label: '2925 邮箱',
+      source: 'mail-2925',
+      url: 'https://2925.com',
+      navigateOnReuse: false,
+    }),
+    getPanelMode: () => 'sub2api',
+    getState: async () => ({ email: 'user@example.com', password: 'secret' }),
+    getTabId: async (sourceName) => (sourceName === 'signup-page' ? 1 : 2),
+    HOTMAIL_PROVIDER: 'hotmail-api',
+    isTabAlive: async () => true,
+    isVerificationMailPollingError: () => false,
+    LUCKMAIL_PROVIDER: 'luckmail-api',
+    resolveVerificationStep: async (_step, _state, _mail, options) => {
+      capturedOptions = options;
+    },
+    reuseOrCreateTab: async () => {},
+    setState: async () => {},
+    setStepStatus: async () => {},
+    shouldSkipLoginVerificationForCpaCallback: () => false,
+    shouldUseCustomRegistrationEmail: () => false,
+    sleepWithStop: async () => {},
+    STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000,
+    STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS: 8,
+    throwIfStopped: () => {},
+  });
+
+  await executor.executeStep7({
+    email: 'user@example.com',
+    password: 'secret',
+    oauthUrl: 'https://oauth.example/latest',
+  });
+
+  assert.equal(capturedOptions.resendIntervalMs, 0);
+  assert.equal(capturedOptions.beforeSubmit, undefined);
+});

+ 59 - 0
tests/background-tab-runtime-module.test.js

@@ -0,0 +1,59 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+
+test('background imports tab runtime module', () => {
+  const source = fs.readFileSync('background.js', 'utf8');
+  assert.match(source, /background\/tab-runtime\.js/);
+});
+
+test('tab runtime module exposes a factory', () => {
+  const source = fs.readFileSync('background/tab-runtime.js', 'utf8');
+  const globalScope = {};
+
+  const api = new Function('self', `${source}; return self.MultiPageBackgroundTabRuntime;`)(globalScope);
+
+  assert.equal(typeof api?.createTabRuntime, 'function');
+});
+
+test('tab runtime waitForTabComplete waits until tab status becomes complete', async () => {
+  const source = fs.readFileSync('background/tab-runtime.js', 'utf8');
+  const globalScope = {};
+  const api = new Function('self', `${source}; return self.MultiPageBackgroundTabRuntime;`)(globalScope);
+
+  let getCalls = 0;
+  const runtime = api.createTabRuntime({
+    LOG_PREFIX: '[test]',
+    addLog: async () => {},
+    buildLocalhostCleanupPrefix: () => '',
+    chrome: {
+      tabs: {
+        get: async () => {
+          getCalls += 1;
+          return {
+            id: 9,
+            url: 'https://example.com',
+            status: getCalls >= 3 ? 'complete' : 'loading',
+          };
+        },
+        query: async () => [],
+      },
+    },
+    getSourceLabel: (source) => source || 'unknown',
+    getState: async () => ({ tabRegistry: {}, sourceLastUrls: {} }),
+    matchesSourceUrlFamily: () => false,
+    normalizeLocalCpaStep9Mode: () => 'submit',
+    parseUrlSafely: () => null,
+    registerTab: async () => {},
+    setState: async () => {},
+    shouldBypassStep9ForLocalCpa: () => false,
+  });
+
+  const result = await runtime.waitForTabComplete(9, {
+    timeoutMs: 2000,
+    retryDelayMs: 1,
+  });
+
+  assert.equal(result?.status, 'complete');
+  assert.equal(getCalls, 3);
+});

+ 17 - 0
tests/background-verification-flow-module.test.js

@@ -0,0 +1,17 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+
+test('background imports verification flow module', () => {
+  const source = fs.readFileSync('background.js', 'utf8');
+  assert.match(source, /background\/verification-flow\.js/);
+});
+
+test('verification flow module exposes a factory', () => {
+  const source = fs.readFileSync('background/verification-flow.js', 'utf8');
+  const globalScope = {};
+
+  const api = new Function('self', `${source}; return self.MultiPageBackgroundVerificationFlow;`)(globalScope);
+
+  assert.equal(typeof api?.createVerificationFlowHelpers, 'function');
+});

+ 118 - 0
tests/sidepanel-account-run-history.test.js

@@ -0,0 +1,118 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+
+const sidepanelSource = fs.readFileSync('sidepanel/sidepanel.js', 'utf8');
+
+function extractFunction(name) {
+  const markers = [`async function ${name}(`, `function ${name}(`];
+  const start = markers
+    .map((marker) => sidepanelSource.indexOf(marker))
+    .find((index) => index >= 0);
+  if (start < 0) {
+    throw new Error(`missing function ${name}`);
+  }
+
+  let parenDepth = 0;
+  let signatureEnded = false;
+  let braceStart = -1;
+  for (let i = start; i < sidepanelSource.length; i += 1) {
+    const ch = sidepanelSource[i];
+    if (ch === '(') {
+      parenDepth += 1;
+    } else if (ch === ')') {
+      parenDepth -= 1;
+      if (parenDepth === 0) {
+        signatureEnded = true;
+      }
+    } else if (ch === '{' && signatureEnded) {
+      braceStart = i;
+      break;
+    }
+  }
+
+  let depth = 0;
+  let end = braceStart;
+  for (; end < sidepanelSource.length; end += 1) {
+    const ch = sidepanelSource[end];
+    if (ch === '{') depth += 1;
+    if (ch === '}') {
+      depth -= 1;
+      if (depth === 0) {
+        end += 1;
+        break;
+      }
+    }
+  }
+
+  return sidepanelSource.slice(start, end);
+}
+
+test('sidepanel html contains account run history strip under log header', () => {
+  const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8');
+
+  assert.match(html, /id="account-run-history-strip"/);
+  assert.match(html, /id="account-run-history-meta"/);
+  assert.match(html, /id="account-run-history-stats"/);
+  assert.match(html, /id="account-run-history-list"/);
+  assert.match(html, /id="input-account-run-history-text-enabled"/);
+  assert.match(html, /id="input-account-run-history-helper-base-url"/);
+});
+
+test('sidepanel account run history helpers classify statuses and summarize counts', () => {
+  const bundle = [
+    extractFunction('normalizeAccountRunHistoryHelperBaseUrlValue'),
+    extractFunction('parseAccountRunStatus'),
+    extractFunction('summarizeAccountRunHistory'),
+    extractFunction('buildAccountRunHistoryDetailText'),
+  ].join('\n');
+
+  const api = new Function(`
+const DEFAULT_ACCOUNT_RUN_HISTORY_HELPER_BASE_URL = 'http://127.0.0.1:17373';
+${bundle}
+return { normalizeAccountRunHistoryHelperBaseUrlValue, parseAccountRunStatus, summarizeAccountRunHistory, buildAccountRunHistoryDetailText };
+`)();
+
+  assert.deepStrictEqual(api.parseAccountRunStatus('step7_failed'), {
+    kind: 'failed',
+    label: '步7失败',
+  });
+  assert.deepStrictEqual(api.parseAccountRunStatus('step4_stopped'), {
+    kind: 'stopped',
+    label: '步4停止',
+  });
+  assert.deepStrictEqual(api.parseAccountRunStatus('success'), {
+    kind: 'success',
+    label: '成功',
+  });
+
+  assert.deepStrictEqual(api.summarizeAccountRunHistory([
+    { status: 'success' },
+    { status: 'step7_failed' },
+    { status: 'stopped' },
+    { status: 'step2_failed' },
+  ]), {
+    total: 4,
+    success: 1,
+    failed: 2,
+    stopped: 1,
+    other: 0,
+  });
+
+  assert.equal(
+    api.buildAccountRunHistoryDetailText({ status: 'success', reason: '' }),
+    '流程已完成并写入本地记录'
+  );
+  assert.equal(
+    api.buildAccountRunHistoryDetailText({ status: 'step7_failed', reason: '' }),
+    '流程执行失败,已保留当前账号快照'
+  );
+  assert.equal(
+    api.buildAccountRunHistoryDetailText({ status: 'stopped', reason: '手动停止' }),
+    '手动停止'
+  );
+  assert.equal(
+    api.normalizeAccountRunHistoryHelperBaseUrlValue('http://127.0.0.1:17373/append-account-log'),
+    'http://127.0.0.1:17373'
+  );
+});

+ 79 - 0
tests/sidepanel-hotmail-manager.test.js

@@ -0,0 +1,79 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+
+test('sidepanel loads hotmail manager before sidepanel bootstrap', () => {
+  const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8');
+  const hotmailManagerIndex = html.indexOf('<script src="hotmail-manager.js"></script>');
+  const sidepanelIndex = html.indexOf('<script src="sidepanel.js"></script>');
+
+  assert.notEqual(hotmailManagerIndex, -1);
+  assert.notEqual(sidepanelIndex, -1);
+  assert.ok(hotmailManagerIndex < sidepanelIndex);
+});
+
+test('hotmail manager exposes a factory and renders empty state', () => {
+  const source = fs.readFileSync('sidepanel/hotmail-manager.js', 'utf8');
+  const windowObject = {};
+  const localStorageMock = {
+    getItem() {
+      return null;
+    },
+    setItem() {},
+  };
+
+  const api = new Function('window', 'localStorage', `${source}; return window.SidepanelHotmailManager;`)(
+    windowObject,
+    localStorageMock
+  );
+
+  assert.equal(typeof api?.createHotmailManager, 'function');
+
+  const hotmailAccountsList = { innerHTML: '' };
+  const toggleButton = {
+    textContent: '',
+    disabled: false,
+    setAttribute() {},
+  };
+  const noopClassList = { toggle() {} };
+
+  const manager = api.createHotmailManager({
+    state: {
+      getLatestState: () => ({ currentHotmailAccountId: null }),
+      syncLatestState() {},
+    },
+    dom: {
+      btnClearUsedHotmailAccounts: { textContent: '', disabled: false },
+      btnDeleteAllHotmailAccounts: { textContent: '', disabled: false },
+      btnToggleHotmailList: toggleButton,
+      hotmailAccountsList,
+      hotmailListShell: { classList: noopClassList },
+      selectMailProvider: { value: 'hotmail-api' },
+      inputEmail: { value: '' },
+    },
+    helpers: {
+      getHotmailAccounts: () => [],
+      getCurrentHotmailEmail: () => '',
+      escapeHtml: (value) => String(value || ''),
+      showToast() {},
+      openConfirmModal: async () => true,
+      copyTextToClipboard: async () => {},
+    },
+    runtime: {
+      sendMessage: async () => ({}),
+    },
+    constants: {
+      copyIcon: '',
+      displayTimeZone: 'Asia/Shanghai',
+      expandedStorageKey: 'multipage-hotmail-list-expanded',
+    },
+    hotmailUtils: {},
+  });
+
+  assert.equal(typeof manager.renderHotmailAccounts, 'function');
+  assert.equal(typeof manager.bindHotmailEvents, 'function');
+  assert.equal(typeof manager.initHotmailListExpandedState, 'function');
+
+  manager.renderHotmailAccounts();
+  assert.match(hotmailAccountsList.innerHTML, /还没有 Hotmail 账号/);
+});

+ 61 - 0
tests/sidepanel-icloud-manager.test.js

@@ -0,0 +1,61 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+
+test('sidepanel loads icloud manager before sidepanel bootstrap', () => {
+  const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8');
+  const icloudManagerIndex = html.indexOf('<script src="icloud-manager.js"></script>');
+  const sidepanelIndex = html.indexOf('<script src="sidepanel.js"></script>');
+
+  assert.notEqual(icloudManagerIndex, -1);
+  assert.notEqual(sidepanelIndex, -1);
+  assert.ok(icloudManagerIndex < sidepanelIndex);
+});
+
+test('icloud manager exposes a factory and renders empty state', () => {
+  const source = fs.readFileSync('sidepanel/icloud-manager.js', 'utf8');
+  const windowObject = {};
+
+  const api = new Function('window', `${source}; return window.SidepanelIcloudManager;`)(windowObject);
+
+  assert.equal(typeof api?.createIcloudManager, 'function');
+
+  const manager = api.createIcloudManager({
+    dom: {
+      btnIcloudBulkDelete: { disabled: false },
+      btnIcloudBulkPreserve: { disabled: false },
+      btnIcloudBulkUnpreserve: { disabled: false },
+      btnIcloudBulkUnused: { disabled: false },
+      btnIcloudBulkUsed: { disabled: false },
+      btnIcloudDeleteUsed: { disabled: false },
+      btnIcloudLoginDone: { disabled: false },
+      btnIcloudRefresh: { disabled: false },
+      checkboxIcloudSelectAll: { checked: false, indeterminate: false, disabled: false },
+      icloudList: { innerHTML: '' },
+      icloudLoginHelp: { style: { display: 'none' } },
+      icloudLoginHelpText: { textContent: '' },
+      icloudLoginHelpTitle: { textContent: '' },
+      icloudSection: { style: { display: '' } },
+      icloudSelectionSummary: { textContent: '' },
+      icloudSummary: { textContent: '' },
+      inputIcloudSearch: { value: '', disabled: false },
+      selectIcloudFilter: { value: 'all', disabled: false },
+    },
+    helpers: {
+      escapeHtml: (value) => String(value || ''),
+      openConfirmModal: async () => true,
+      showToast() {},
+    },
+    runtime: {
+      sendMessage: async () => ({ aliases: [] }),
+    },
+  });
+
+  assert.equal(typeof manager.renderIcloudAliases, 'function');
+  assert.equal(typeof manager.refreshIcloudAliases, 'function');
+  assert.equal(typeof manager.queueIcloudAliasRefresh, 'function');
+  assert.equal(typeof manager.reset, 'function');
+
+  manager.renderIcloudAliases([]);
+  assert.equal(manager.hasDeletableUsedAliases(), false);
+});

+ 66 - 0
tests/sidepanel-luckmail-manager.test.js

@@ -0,0 +1,66 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+
+test('sidepanel loads luckmail manager before sidepanel bootstrap', () => {
+  const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8');
+  const luckmailManagerIndex = html.indexOf('<script src="luckmail-manager.js"></script>');
+  const sidepanelIndex = html.indexOf('<script src="sidepanel.js"></script>');
+
+  assert.notEqual(luckmailManagerIndex, -1);
+  assert.notEqual(sidepanelIndex, -1);
+  assert.ok(luckmailManagerIndex < sidepanelIndex);
+});
+
+test('luckmail manager exposes a factory and renders empty state', () => {
+  const source = fs.readFileSync('sidepanel/luckmail-manager.js', 'utf8');
+  const windowObject = {};
+
+  const api = new Function('window', `${source}; return window.SidepanelLuckmailManager;`)(windowObject);
+
+  assert.equal(typeof api?.createLuckmailManager, 'function');
+
+  const manager = api.createLuckmailManager({
+    dom: {
+      btnLuckmailBulkDisable: { disabled: false },
+      btnLuckmailBulkEnable: { disabled: false },
+      btnLuckmailBulkPreserve: { disabled: false },
+      btnLuckmailBulkUnpreserve: { disabled: false },
+      btnLuckmailBulkUnused: { disabled: false },
+      btnLuckmailBulkUsed: { disabled: false },
+      btnLuckmailDisableUsed: { disabled: false, textContent: '' },
+      btnLuckmailRefresh: { disabled: false },
+      checkboxLuckmailSelectAll: { checked: false, indeterminate: false, disabled: false },
+      inputEmail: { value: '' },
+      inputLuckmailSearch: { value: '', disabled: false },
+      luckmailList: { innerHTML: '' },
+      luckmailSection: { style: { display: '' } },
+      luckmailSelectionSummary: { textContent: '' },
+      luckmailSummary: { textContent: '' },
+      selectLuckmailFilter: { value: 'all', disabled: false },
+    },
+    helpers: {
+      copyTextToClipboard: async () => {},
+      escapeHtml: (value) => String(value || ''),
+      formatLuckmailDateTime: (value) => String(value || ''),
+      getLuckmailPreserveTagName: () => '保留',
+      normalizeLuckmailProjectName: (value) => String(value || '').trim().toLowerCase(),
+      openConfirmModal: async () => true,
+      showToast() {},
+    },
+    runtime: {
+      sendMessage: async () => ({ purchases: [] }),
+    },
+    constants: {
+      copyIcon: '',
+    },
+  });
+
+  assert.equal(typeof manager.renderLuckmailPurchases, 'function');
+  assert.equal(typeof manager.refreshLuckmailPurchases, 'function');
+  assert.equal(typeof manager.queueLuckmailPurchaseRefresh, 'function');
+  assert.equal(typeof manager.reset, 'function');
+
+  manager.renderLuckmailPurchases([]);
+  manager.reset();
+});

+ 52 - 48
tests/signup-page-tab-cleanup.test.js

@@ -1,37 +1,31 @@
 const assert = require('assert');
 const fs = require('fs');
 
-const source = fs.readFileSync('background.js', 'utf8');
+const helperSource = fs.readFileSync('background.js', 'utf8');
+const tabRuntimeSource = fs.readFileSync('background/tab-runtime.js', 'utf8');
 
-function extractFunction(name) {
+function extractFunction(source, name) {
   const markers = [`async function ${name}(`, `function ${name}(`];
   const start = markers
     .map(marker => source.indexOf(marker))
     .find(index => index >= 0);
-  if (start < 0) {
-    throw new Error(`missing function ${name}`);
-  }
+  if (start < 0) throw new Error(`missing function ${name}`);
 
   let parenDepth = 0;
   let signatureEnded = false;
   let braceStart = -1;
   for (let i = start; i < source.length; i += 1) {
     const ch = source[i];
-    if (ch === '(') {
-      parenDepth += 1;
-    } else if (ch === ')') {
+    if (ch === '(') parenDepth += 1;
+    else if (ch === ')') {
       parenDepth -= 1;
-      if (parenDepth === 0) {
-        signatureEnded = true;
-      }
+      if (parenDepth === 0) signatureEnded = true;
     } else if (ch === '{' && signatureEnded) {
       braceStart = i;
       break;
     }
   }
-  if (braceStart < 0) {
-    throw new Error(`missing body for function ${name}`);
-  }
+  if (braceStart < 0) throw new Error(`missing body for function ${name}`);
 
   let depth = 0;
   let end = braceStart;
@@ -50,16 +44,15 @@ function extractFunction(name) {
   return source.slice(start, end);
 }
 
-const bundle = [
-  extractFunction('getTabRegistry'),
-  extractFunction('parseUrlSafely'),
-  extractFunction('isSignupPageHost'),
-  extractFunction('isSignupEntryHost'),
-  extractFunction('matchesSourceUrlFamily'),
-  extractFunction('closeConflictingTabsForSource'),
+const helperBundle = [
+  extractFunction(helperSource, 'parseUrlSafely'),
+  extractFunction(helperSource, 'isSignupPageHost'),
+  extractFunction(helperSource, 'isSignupEntryHost'),
+  extractFunction(helperSource, 'matchesSourceUrlFamily'),
 ].join('\n');
 
-const api = new Function(`
+const api = new Function('tabRuntimeSource', `
+const self = {};
 let currentState = {
   sourceLastUrls: {},
   tabRegistry: {},
@@ -96,11 +89,38 @@ function getSourceLabel(source) {
   return source;
 }
 
-${bundle}
+function isLocalhostOAuthCallbackUrl() {
+  return false;
+}
+
+function isRetryableContentScriptTransportError() {
+  return false;
+}
+
+function throwIfStopped() {}
+const LOG_PREFIX = '[test:bg]';
+const STOP_ERROR_MESSAGE = 'Flow stopped.';
+
+${helperBundle}
+${tabRuntimeSource}
+
+const runtime = self.MultiPageBackgroundTabRuntime.createTabRuntime({
+  addLog,
+  chrome,
+  getSourceLabel,
+  getState,
+  isLocalhostOAuthCallbackUrl,
+  isRetryableContentScriptTransportError,
+  LOG_PREFIX,
+  matchesSourceUrlFamily,
+  setState,
+  STOP_ERROR_MESSAGE,
+  throwIfStopped,
+});
 
 return {
   matchesSourceUrlFamily,
-  closeConflictingTabsForSource,
+  closeConflictingTabsForSource: runtime.closeConflictingTabsForSource,
   reset({ tabs, state }) {
     currentTabs = tabs;
     removedBatches.length = 0;
@@ -120,7 +140,7 @@ return {
     };
   },
 };
-`)();
+`)(tabRuntimeSource);
 
 (async () => {
   assert.strictEqual(
@@ -156,19 +176,11 @@ return {
   });
 
   let snapshot = api.snapshot();
-  assert.deepStrictEqual(
-    snapshot.removedBatches,
-    [[1, 2]],
-    'opening auth page should clean up stale ChatGPT entry tabs'
-  );
-  assert.deepStrictEqual(
-    snapshot.currentTabs,
-    [
-      { id: 3, url: 'https://auth.openai.com/authorize?client_id=test' },
-      { id: 4, url: 'https://example.com/' },
-    ],
-    'non-signup tabs and excluded current tab should remain'
-  );
+  assert.deepStrictEqual(snapshot.removedBatches, [[1, 2]]);
+  assert.deepStrictEqual(snapshot.currentTabs, [
+    { id: 3, url: 'https://auth.openai.com/authorize?client_id=test' },
+    { id: 4, url: 'https://example.com/' },
+  ]);
 
   api.reset({
     tabs: [
@@ -188,16 +200,8 @@ return {
   await api.closeConflictingTabsForSource('signup-page', 'https://chatgpt.com/');
 
   snapshot = api.snapshot();
-  assert.deepStrictEqual(
-    snapshot.removedBatches,
-    [[11, 12]],
-    'opening ChatGPT entry should remove older signup-family tabs'
-  );
-  assert.strictEqual(
-    snapshot.currentState.tabRegistry['signup-page'],
-    null,
-    'registry should be cleared when the tracked signup tab is removed'
-  );
+  assert.deepStrictEqual(snapshot.removedBatches, [[11, 12]]);
+  assert.strictEqual(snapshot.currentState.tabRegistry['signup-page'], null);
 
   console.log('signup page tab cleanup tests passed');
 })().catch((error) => {

+ 42 - 0
tests/step-definitions-module.test.js

@@ -0,0 +1,42 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+
+test('step definitions module exposes ordered shared step metadata', () => {
+  const source = fs.readFileSync('data/step-definitions.js', 'utf8');
+  const globalScope = {};
+
+  const api = new Function('self', `${source}; return self.MultiPageStepDefinitions;`)(globalScope);
+  const steps = api.getSteps();
+
+  assert.equal(Array.isArray(steps), true);
+  assert.equal(steps.length >= 9, true);
+  assert.deepStrictEqual(
+    steps.map((step) => step.order),
+    steps.map((step) => step.order).slice().sort((left, right) => left - right)
+  );
+  assert.deepStrictEqual(
+    steps.map((step) => step.key),
+    [
+      'open-chatgpt',
+      'submit-signup-email',
+      'fill-password',
+      'fetch-signup-code',
+      'fill-profile',
+      'oauth-login',
+      'fetch-login-code',
+      'confirm-oauth',
+      'platform-verify',
+    ]
+  );
+});
+
+test('sidepanel html loads shared step definitions before sidepanel bootstrap', () => {
+  const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8');
+  const definitionsIndex = html.indexOf('<script src="../data/step-definitions.js"></script>');
+  const sidepanelIndex = html.indexOf('<script src="sidepanel.js"></script>');
+
+  assert.notEqual(definitionsIndex, -1);
+  assert.notEqual(sidepanelIndex, -1);
+  assert.ok(definitionsIndex < sidepanelIndex);
+});

+ 217 - 0
tests/step5-direct-complete.test.js

@@ -0,0 +1,217 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+
+const source = fs.readFileSync('content/signup-page.js', 'utf8');
+
+function extractFunction(name) {
+  const markers = [`async function ${name}(`, `function ${name}(`];
+  const start = markers
+    .map((marker) => source.indexOf(marker))
+    .find((index) => index >= 0);
+  if (start < 0) {
+    throw new Error(`missing function ${name}`);
+  }
+
+  let parenDepth = 0;
+  let signatureEnded = false;
+  let braceStart = -1;
+  for (let i = start; i < source.length; i += 1) {
+    const ch = source[i];
+    if (ch === '(') {
+      parenDepth += 1;
+    } else if (ch === ')') {
+      parenDepth -= 1;
+      if (parenDepth === 0) {
+        signatureEnded = true;
+      }
+    } else if (ch === '{' && signatureEnded) {
+      braceStart = i;
+      break;
+    }
+  }
+  if (braceStart < 0) {
+    throw new Error(`missing body for function ${name}`);
+  }
+
+  let depth = 0;
+  let end = braceStart;
+  for (; end < source.length; end += 1) {
+    const ch = source[end];
+    if (ch === '{') depth += 1;
+    if (ch === '}') {
+      depth -= 1;
+      if (depth === 0) {
+        end += 1;
+        break;
+      }
+    }
+  }
+
+  return source.slice(start, end);
+}
+
+test('step 5 clicks submit and completes immediately on birthday page', async () => {
+  const step5Source = extractFunction('step5_fillNameBirthday');
+  assert.ok(
+    !step5Source.includes('waitForStep5SubmitOutcome('),
+    'Step 5 提交后不应再等待页面结果'
+  );
+
+  const api = new Function(`
+const logs = [];
+const completions = [];
+const clicks = [];
+const selectedBirthday = {};
+
+const nameInput = { value: '', hidden: false };
+const hiddenBirthday = {
+  value: '',
+  hidden: false,
+  dispatchEvent() {},
+};
+const completeButton = {
+  tagName: 'BUTTON',
+  textContent: '完成帐户创建',
+  hidden: false,
+};
+
+const birthdaySelects = {
+  '年': { label: '年', button: { hidden: false }, nativeSelect: {} },
+  '月': { label: '月', button: { hidden: false }, nativeSelect: {} },
+  '天': { label: '天', button: { hidden: false }, nativeSelect: {} },
+};
+
+const document = {
+  querySelector(selector) {
+    switch (selector) {
+      case '[role="spinbutton"][data-type="year"]':
+      case '[role="spinbutton"][data-type="month"]':
+      case '[role="spinbutton"][data-type="day"]':
+      case 'input[name="age"]':
+        return null;
+      case 'input[name="birthday"]':
+        return hiddenBirthday;
+      case 'button[type="submit"]':
+        return completeButton;
+      default:
+        return null;
+    }
+  },
+  querySelectorAll(selector) {
+    if (selector === 'input[name="allCheckboxes"][type="checkbox"]') {
+      return [];
+    }
+    return [];
+  },
+  execCommand() {},
+};
+
+const location = {
+  href: 'https://auth.openai.com/u/signup/profile',
+};
+
+function Event(type, init = {}) {
+  this.type = type;
+  this.bubbles = Boolean(init.bubbles);
+}
+
+function log(message, level = 'info') {
+  logs.push({ message, level });
+}
+
+async function waitForElement() {
+  return nameInput;
+}
+
+async function humanPause() {}
+async function sleep() {}
+
+function fillInput(input, value) {
+  input.value = value;
+}
+
+function findBirthdayReactAriaSelect(label) {
+  return birthdaySelects[label] || null;
+}
+
+function isVisibleElement(el) {
+  return Boolean(el) && !el.hidden;
+}
+
+async function setReactAriaBirthdaySelect(select, value) {
+  selectedBirthday[select.label] = String(value).padStart(select.label === '年' ? 4 : 2, '0');
+  if (selectedBirthday['年'] && selectedBirthday['月'] && selectedBirthday['天']) {
+    hiddenBirthday.value = \`\${selectedBirthday['年']}-\${selectedBirthday['月']}-\${selectedBirthday['天']}\`;
+  }
+}
+
+async function waitForElementByText() {
+  throw new Error('waitForElementByText should not run in this test');
+}
+
+function simulateClick(el) {
+  clicks.push(el.textContent || el.tagName || 'element');
+}
+
+function reportComplete(step, payload) {
+  completions.push({ step, payload });
+}
+
+function normalizeInlineText(text) {
+  return text;
+}
+
+${extractFunction('getStep5DirectCompletionPayload')}
+${extractFunction('step5_fillNameBirthday')}
+
+return {
+  async run(payload) {
+    return step5_fillNameBirthday(payload);
+  },
+  snapshot() {
+    return {
+      logs,
+      completions,
+      clicks,
+      nameValue: nameInput.value,
+      birthdayValue: hiddenBirthday.value,
+    };
+  },
+};
+`)();
+
+  const result = await api.run({
+    firstName: 'Test',
+    lastName: 'User',
+    year: 2003,
+    month: 6,
+    day: 19,
+  });
+
+  const snapshot = api.snapshot();
+  assert.deepStrictEqual(
+    result,
+    {
+      skippedPostSubmitCheck: true,
+      directProceedToStep6: true,
+    },
+    '生日模式点击提交后应直接返回完成载荷'
+  );
+  assert.deepStrictEqual(snapshot.completions, [
+    {
+      step: 5,
+      payload: {
+        skippedPostSubmitCheck: true,
+        directProceedToStep6: true,
+      },
+    },
+  ]);
+  assert.deepStrictEqual(snapshot.clicks, ['完成帐户创建']);
+  assert.equal(snapshot.nameValue, 'Test User');
+  assert.equal(snapshot.birthdayValue, '2003-06-19');
+  assert.ok(
+    snapshot.logs.some(({ message }) => /不再等待页面结果/.test(message)),
+    '日志应明确说明 Step 5 已直接完成'
+  );
+});

+ 149 - 0
tests/step6-login-state.test.js

@@ -0,0 +1,149 @@
+const assert = require('assert');
+const fs = require('fs');
+
+const source = fs.readFileSync('content/signup-page.js', 'utf8');
+
+function extractFunction(name) {
+  const markers = [`async function ${name}(`, `function ${name}(`];
+  const start = markers
+    .map((marker) => source.indexOf(marker))
+    .find((index) => index >= 0);
+  if (start < 0) {
+    throw new Error(`missing function ${name}`);
+  }
+
+  let parenDepth = 0;
+  let signatureEnded = false;
+  let braceStart = -1;
+  for (let i = start; i < source.length; i += 1) {
+    const ch = source[i];
+    if (ch === '(') {
+      parenDepth += 1;
+    } else if (ch === ')') {
+      parenDepth -= 1;
+      if (parenDepth === 0) {
+        signatureEnded = true;
+      }
+    } else if (ch === '{' && signatureEnded) {
+      braceStart = i;
+      break;
+    }
+  }
+  if (braceStart < 0) {
+    throw new Error(`missing body for function ${name}`);
+  }
+
+  let depth = 0;
+  let end = braceStart;
+  for (; end < source.length; end += 1) {
+    const ch = source[end];
+    if (ch === '{') depth += 1;
+    if (ch === '}') {
+      depth -= 1;
+      if (depth === 0) {
+        end += 1;
+        break;
+      }
+    }
+  }
+
+  return source.slice(start, end);
+}
+
+const bundle = [
+  extractFunction('inspectLoginAuthState'),
+  extractFunction('normalizeStep6Snapshot'),
+].join('\n');
+
+function createApi(overrides = {}) {
+  return new Function(`
+const location = {
+  href: ${JSON.stringify(overrides.href || 'https://auth.openai.com/log-in')},
+  pathname: ${JSON.stringify(overrides.pathname || '/log-in')},
+};
+
+function getLoginTimeoutErrorPageState() {
+  return ${JSON.stringify(overrides.retryState || null)};
+}
+
+function getVerificationCodeTarget() {
+  return ${JSON.stringify(overrides.verificationTarget || null)};
+}
+
+function getLoginPasswordInput() {
+  return ${JSON.stringify(overrides.passwordInput || null)};
+}
+
+function getLoginEmailInput() {
+  return ${JSON.stringify(overrides.emailInput || null)};
+}
+
+function findOneTimeCodeLoginTrigger() {
+  return ${JSON.stringify(overrides.switchTrigger || null)};
+}
+
+function getLoginSubmitButton() {
+  return ${JSON.stringify(overrides.submitButton || null)};
+}
+
+function isVerificationPageStillVisible() {
+  return ${JSON.stringify(Boolean(overrides.verificationVisible))};
+}
+
+function isAddPhonePageReady() {
+  return ${JSON.stringify(Boolean(overrides.addPhonePage))};
+}
+
+function isStep8Ready() {
+  return ${JSON.stringify(Boolean(overrides.consentReady))};
+}
+
+function isOAuthConsentPage() {
+  return ${JSON.stringify(Boolean(overrides.oauthConsentPage))};
+}
+
+${bundle}
+
+return {
+  inspectLoginAuthState,
+  normalizeStep6Snapshot,
+};
+`)();
+}
+
+{
+  const api = createApi({
+    emailInput: { id: 'email' },
+    submitButton: { id: 'submit' },
+    oauthConsentPage: true,
+    consentReady: true,
+  });
+
+  const snapshot = api.inspectLoginAuthState();
+  assert.strictEqual(
+    snapshot.state,
+    'email_page',
+    '第六步在 /log-in 页应优先识别为邮箱页'
+  );
+}
+
+{
+  const api = createApi({
+    oauthConsentPage: true,
+    consentReady: true,
+  });
+
+  const snapshot = api.normalizeStep6Snapshot({
+    state: 'oauth_consent_page',
+    url: 'https://auth.openai.com/authorize',
+  });
+
+  assert.strictEqual(snapshot.state, 'unknown', '第六步应忽略 oauth_consent_page 状态');
+}
+
+assert.ok(
+  !extractFunction('inspectLoginAuthState').includes("state: 'oauth_consent_page'"),
+  'inspectLoginAuthState 不应再产出 oauth_consent_page 状态'
+);
+
+console.log('step6 login state tests passed');

+ 99 - 22
tests/step8-stop-cleanup.test.js

@@ -1,9 +1,10 @@
 const assert = require('assert');
 const fs = require('fs');
 
-const source = fs.readFileSync('background.js', 'utf8');
+const helperSource = fs.readFileSync('background.js', 'utf8');
+const step8ModuleSource = fs.readFileSync('background/steps/confirm-oauth.js', 'utf8');
 
-function extractFunction(name) {
+function extractFunction(source, name) {
   const markers = [`async function ${name}(`, `function ${name}(`];
   const start = markers
     .map(marker => source.indexOf(marker))
@@ -50,16 +51,16 @@ function extractFunction(name) {
   return source.slice(start, end);
 }
 
-const bundle = [
-  extractFunction('throwIfStopped'),
-  extractFunction('cleanupStep8NavigationListeners'),
-  extractFunction('rejectPendingStep8'),
-  extractFunction('throwIfStep8SettledOrStopped'),
-  extractFunction('requestStop'),
-  extractFunction('executeStep8'),
+const helperBundle = [
+  extractFunction(helperSource, 'throwIfStopped'),
+  extractFunction(helperSource, 'cleanupStep8NavigationListeners'),
+  extractFunction(helperSource, 'rejectPendingStep8'),
+  extractFunction(helperSource, 'throwIfStep8SettledOrStopped'),
+  extractFunction(helperSource, 'requestStop'),
 ].join('\n');
 
-const api = new Function(`
+const api = new Function('step8ModuleSource', `
+const self = {};
 let stopRequested = false;
 const STOP_ERROR_MESSAGE = '流程已被用户停止。';
 let webNavListener = null;
@@ -71,6 +72,13 @@ let autoRunCurrentRun = 2;
 let autoRunTotalRuns = 3;
 let autoRunAttemptRun = 4;
 const AUTO_RUN_TIMER_KIND_SCHEDULED_START = 'scheduled_start';
+const STEP8_CLICK_RETRY_DELAY_MS = 500;
+const STEP8_MAX_ROUNDS = 5;
+const STEP8_READY_WAIT_TIMEOUT_MS = 30000;
+const STEP8_STRATEGIES = [
+  { mode: 'content', strategy: 'requestSubmit', label: 'form.requestSubmit' },
+  { mode: 'debugger', label: 'debugger click' },
+];
 
 const added = {
   beforeNavigate: 0,
@@ -89,28 +97,28 @@ let resolveTabId = null;
 const chrome = {
   webNavigation: {
     onBeforeNavigate: {
-      addListener(listener) {
+      addListener() {
         added.beforeNavigate += 1;
       },
-      removeListener(listener) {
+      removeListener() {
         removed.beforeNavigate += 1;
       },
     },
     onCommitted: {
-      addListener(listener) {
+      addListener() {
         added.committed += 1;
       },
-      removeListener(listener) {
+      removeListener() {
         removed.committed += 1;
       },
     },
   },
   tabs: {
     onUpdated: {
-      addListener(listener) {
+      addListener() {
         added.tabUpdated += 1;
       },
-      removeListener(listener) {
+      removeListener() {
         removed.tabUpdated += 1;
       },
     },
@@ -137,6 +145,7 @@ function isAutoRunScheduledState() {
 }
 function getStep8CallbackUrlFromNavigation() { return ''; }
 function getStep8CallbackUrlFromTabUpdate() { return ''; }
+function getStep8EffectLabel() { return 'no_effect'; }
 async function completeStepFromBackground() {}
 async function getTabId() {
   return await new Promise((resolve) => {
@@ -149,18 +158,86 @@ async function reuseOrCreateTab() {
 async function isTabAlive() {
   return true;
 }
-async function sendToContentScript(source, message) {
-  sentMessages.push({ source, type: message.type });
+async function ensureStep8SignupPageReady() {}
+async function prepareStep8DebuggerClick() {
+  sentMessages.push({ source: 'signup-page', type: 'STEP8_FIND_AND_CLICK' });
   return { rect: { centerX: 10, centerY: 20 } };
 }
+async function triggerStep8ContentStrategy() {
+  sentMessages.push({ source: 'signup-page', type: 'STEP8_TRIGGER_CONTINUE' });
+}
+async function waitForStep8ClickEffect() {
+  return { progressed: false, reason: 'no_effect' };
+}
+async function waitForStep8Ready() {
+  return { consentReady: true, url: 'https://example.com/consent' };
+}
+async function reloadStep8ConsentPage() {}
+async function sleepWithStop() {}
 async function clickWithDebugger() {
   clickCount += 1;
 }
 
-${bundle}
+function setWebNavListener(listener) {
+  webNavListener = listener;
+}
+function getWebNavListener() {
+  return webNavListener;
+}
+function setWebNavCommittedListener(listener) {
+  webNavCommittedListener = listener;
+}
+function getWebNavCommittedListener() {
+  return webNavCommittedListener;
+}
+function setStep8TabUpdatedListener(listener) {
+  step8TabUpdatedListener = listener;
+}
+function getStep8TabUpdatedListener() {
+  return step8TabUpdatedListener;
+}
+function setStep8PendingReject(handler) {
+  step8PendingReject = handler;
+}
+
+${helperBundle}
+${step8ModuleSource}
+
+const executor = self.MultiPageBackgroundStep8.createStep8Executor({
+  addLog,
+  chrome,
+  cleanupStep8NavigationListeners,
+  clickWithDebugger,
+  completeStepFromBackground,
+  ensureStep8SignupPageReady,
+  getStep8CallbackUrlFromNavigation,
+  getStep8CallbackUrlFromTabUpdate,
+  getStep8EffectLabel,
+  getTabId,
+  getWebNavCommittedListener,
+  getWebNavListener,
+  getStep8TabUpdatedListener,
+  isTabAlive,
+  prepareStep8DebuggerClick,
+  reloadStep8ConsentPage,
+  reuseOrCreateTab,
+  setStep8PendingReject,
+  setStep8TabUpdatedListener,
+  setWebNavCommittedListener,
+  setWebNavListener,
+  sleepWithStop,
+  STEP8_CLICK_RETRY_DELAY_MS,
+  STEP8_MAX_ROUNDS,
+  STEP8_READY_WAIT_TIMEOUT_MS,
+  STEP8_STRATEGIES,
+  throwIfStep8SettledOrStopped,
+  triggerStep8ContentStrategy,
+  waitForStep8ClickEffect,
+  waitForStep8Ready,
+});
 
 return {
-  executeStep8,
+  executeStep8: executor.executeStep8,
   requestStop,
   resolveTabId(tabId) {
     if (!resolveTabId) {
@@ -183,7 +260,7 @@ return {
     };
   },
 };
-`)();
+`)(step8ModuleSource);
 
 (async () => {
   const step8Promise = api.executeStep8({ oauthUrl: 'https://example.com/oauth' });
@@ -205,7 +282,7 @@ return {
     { beforeNavigate: 0, committed: 0, tabUpdated: 0 },
     'Stop 先发生时,不应再注册 Step 8 监听'
   );
-  assert.strictEqual(state.sentMessages.length, 0, 'Stop 后不应再发送 STEP8_FIND_AND_CLICK 命令');
+  assert.strictEqual(state.sentMessages.length, 0, 'Stop 后不应再发送 Step 8 执行动作');
   assert.strictEqual(state.clickCount, 0, 'Stop 后不应再触发 debugger 点击');
   assert.strictEqual(state.webNavListener, null, 'Stop 后 onBeforeNavigate 引用应为空');
   assert.strictEqual(state.webNavCommittedListener, null, 'Stop 后 onCommitted 引用应为空');

+ 62 - 80
tests/step9-localhost-cleanup-scope.test.js

@@ -1,37 +1,31 @@
 const assert = require('assert');
 const fs = require('fs');
 
-const source = fs.readFileSync('background.js', 'utf8');
+const helperSource = fs.readFileSync('background.js', 'utf8');
+const tabRuntimeSource = fs.readFileSync('background/tab-runtime.js', 'utf8');
 
-function extractFunction(name) {
+function extractFunction(source, name) {
   const markers = [`async function ${name}(`, `function ${name}(`];
   const start = markers
     .map(marker => source.indexOf(marker))
     .find(index => index >= 0);
-  if (start < 0) {
-    throw new Error(`missing function ${name}`);
-  }
+  if (start < 0) throw new Error(`missing function ${name}`);
 
   let parenDepth = 0;
   let signatureEnded = false;
   let braceStart = -1;
   for (let i = start; i < source.length; i++) {
     const ch = source[i];
-    if (ch === '(') {
-      parenDepth += 1;
-    } else if (ch === ')') {
+    if (ch === '(') parenDepth += 1;
+    else if (ch === ')') {
       parenDepth -= 1;
-      if (parenDepth === 0) {
-        signatureEnded = true;
-      }
+      if (parenDepth === 0) signatureEnded = true;
     } else if (ch === '{' && signatureEnded) {
       braceStart = i;
       break;
     }
   }
-  if (braceStart < 0) {
-    throw new Error(`missing body for function ${name}`);
-  }
+  if (braceStart < 0) throw new Error(`missing body for function ${name}`);
 
   let depth = 0;
   let end = braceStart;
@@ -50,25 +44,21 @@ function extractFunction(name) {
   return source.slice(start, end);
 }
 
-const bundle = [
-  extractFunction('getTabRegistry'),
-  extractFunction('normalizeEmailGenerator'),
-  extractFunction('normalizeMail2925Mode'),
-  extractFunction('getMail2925Mode'),
-  extractFunction('parseUrlSafely'),
-  extractFunction('isHotmailProvider'),
-  extractFunction('isCustomMailProvider'),
-  extractFunction('isGeneratedAliasProvider'),
-  extractFunction('shouldUseCustomRegistrationEmail'),
-  extractFunction('isLocalhostOAuthCallbackUrl'),
-  extractFunction('isLocalhostOAuthCallbackTabMatch'),
-  extractFunction('closeLocalhostCallbackTabs'),
-  extractFunction('buildLocalhostCleanupPrefix'),
-  extractFunction('closeTabsByUrlPrefix'),
-  extractFunction('handleStepData'),
+const helperBundle = [
+  extractFunction(helperSource, 'normalizeEmailGenerator'),
+  extractFunction(helperSource, 'normalizeMail2925Mode'),
+  extractFunction(helperSource, 'getMail2925Mode'),
+  extractFunction(helperSource, 'parseUrlSafely'),
+  extractFunction(helperSource, 'isHotmailProvider'),
+  extractFunction(helperSource, 'isCustomMailProvider'),
+  extractFunction(helperSource, 'isGeneratedAliasProvider'),
+  extractFunction(helperSource, 'shouldUseCustomRegistrationEmail'),
+  extractFunction(helperSource, 'isLocalhostOAuthCallbackUrl'),
+  extractFunction(helperSource, 'handleStepData'),
 ].join('\n');
 
-const api = new Function(`
+const api = new Function('tabRuntimeSource', `
+const self = {};
 const HOTMAIL_PROVIDER = 'hotmail-api';
 const CLOUDFLARE_TEMP_EMAIL_PROVIDER = 'cloudflare-temp-email';
 const CLOUDFLARE_TEMP_EMAIL_GENERATOR = 'cloudflare-temp-email';
@@ -114,34 +104,50 @@ async function setEmailStateSilently(email) {
   currentState = { ...currentState, email };
 }
 
-function isHotmailProvider() {
-  return false;
-}
-
 function isLuckmailProvider() {
   return false;
 }
 
 async function patchHotmailAccount() {}
-
 async function clearLuckmailRuntimeState() {}
-
-function shouldUseCustomRegistrationEmail() {
-  return false;
-}
-
 function broadcastDataUpdate() {}
-
 async function addLog(message) {
   logMessages.push(message);
 }
-
 async function finalizeIcloudAliasAfterSuccessfulFlow() {}
-function shouldUseCustomRegistrationEmail() {
+function matchesSourceUrlFamily() {
   return false;
 }
-
-${bundle}
+function getSourceLabel(source) {
+  return source;
+}
+function isRetryableContentScriptTransportError() {
+  return false;
+}
+function throwIfStopped() {}
+const LOG_PREFIX = '[test:bg]';
+const STOP_ERROR_MESSAGE = 'Flow stopped.';
+
+${helperBundle}
+${tabRuntimeSource}
+
+const tabRuntime = self.MultiPageBackgroundTabRuntime.createTabRuntime({
+  addLog,
+  chrome,
+  getSourceLabel,
+  getState,
+  isLocalhostOAuthCallbackUrl,
+  isRetryableContentScriptTransportError,
+  LOG_PREFIX,
+  matchesSourceUrlFamily,
+  setState,
+  STOP_ERROR_MESSAGE,
+  throwIfStopped,
+});
+const closeLocalhostCallbackTabs = tabRuntime.closeLocalhostCallbackTabs;
+const isLocalhostOAuthCallbackTabMatch = tabRuntime.isLocalhostOAuthCallbackTabMatch;
+const buildLocalhostCleanupPrefix = tabRuntime.buildLocalhostCleanupPrefix;
+const closeTabsByUrlPrefix = tabRuntime.closeTabsByUrlPrefix;
 
 return {
   handleStepData,
@@ -163,27 +169,15 @@ return {
     };
   },
 };
-`)();
+`)(tabRuntimeSource);
 
 (async () => {
   const codexCallbackUrl = 'http://127.0.0.1:8317/codex/callback?code=abc&state=xyz';
   const authCallbackUrl = 'http://localhost:1455/auth/callback?code=def&state=uvw';
 
-  assert.strictEqual(
-    api.isLocalhostOAuthCallbackTabMatch(codexCallbackUrl, codexCallbackUrl),
-    true,
-    '真实 callback 页应命中清理规则'
-  );
-  assert.strictEqual(
-    api.isLocalhostOAuthCallbackTabMatch(codexCallbackUrl, authCallbackUrl),
-    false,
-    '/codex/callback 不应误伤 /auth/callback'
-  );
-  assert.strictEqual(
-    api.isLocalhostOAuthCallbackTabMatch(authCallbackUrl, codexCallbackUrl),
-    false,
-    '/auth/callback 不应误伤 /codex/callback'
-  );
+  assert.strictEqual(api.isLocalhostOAuthCallbackTabMatch(codexCallbackUrl, codexCallbackUrl), true);
+  assert.strictEqual(api.isLocalhostOAuthCallbackTabMatch(codexCallbackUrl, authCallbackUrl), false);
+  assert.strictEqual(api.isLocalhostOAuthCallbackTabMatch(authCallbackUrl, codexCallbackUrl), false);
 
   api.reset({
     tabs: [
@@ -200,21 +194,9 @@ return {
 
   await api.handleStepData(9, { localhostUrl: codexCallbackUrl });
   let snapshot = api.snapshot();
-  assert.deepStrictEqual(
-    snapshot.removedBatches,
-    [[1], [2]],
-    'handleStepData(9) 应先关闭当前 callback 页,再按同源首段路径清理残留页'
-  );
-  assert.strictEqual(
-    snapshot.currentState.tabRegistry['signup-page'],
-    null,
-    '关闭 callback 页后应同步清理 signup-page 的 tabRegistry'
-  );
-  assert.deepStrictEqual(
-    snapshot.currentState.tabRegistry['vps-panel'],
-    { tabId: 99, ready: true },
-    '不相关的 tabRegistry 项不应被误清理'
-  );
+  assert.deepStrictEqual(snapshot.removedBatches, [[1], [2]]);
+  assert.strictEqual(snapshot.currentState.tabRegistry['signup-page'], null);
+  assert.deepStrictEqual(snapshot.currentState.tabRegistry['vps-panel'], { tabId: 99, ready: true });
 
   api.reset({
     tabs: [
@@ -227,9 +209,9 @@ return {
 
   const closedCount = await api.closeLocalhostCallbackTabs(authCallbackUrl);
   snapshot = api.snapshot();
-  assert.strictEqual(closedCount, 1, 'auth callback 也应只关闭当前命中的 callback 页');
-  assert.deepStrictEqual(snapshot.removedBatches, [[4]], '不应按 /auth 前缀批量清理页面');
-  assert.strictEqual(snapshot.logMessages.length, 1, '发生清理时应记录一条日志');
+  assert.strictEqual(closedCount, 1);
+  assert.deepStrictEqual(snapshot.removedBatches, [[4]]);
+  assert.strictEqual(snapshot.logMessages.length, 1);
 
   console.log('step9 localhost cleanup scope tests passed');
 })().catch((error) => {

+ 126 - 0
tests/step9-status-diagnostics.test.js

@@ -0,0 +1,126 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+
+const source = fs.readFileSync('content/vps-panel.js', 'utf8');
+
+function extractFunction(name) {
+  const markers = [`async function ${name}(`, `function ${name}(`];
+  const start = markers
+    .map((marker) => source.indexOf(marker))
+    .find((index) => index >= 0);
+  if (start < 0) {
+    throw new Error(`missing function ${name}`);
+  }
+
+  let parenDepth = 0;
+  let signatureEnded = false;
+  let braceStart = -1;
+  for (let i = start; i < source.length; i += 1) {
+    const ch = source[i];
+    if (ch === '(') {
+      parenDepth += 1;
+    } else if (ch === ')') {
+      parenDepth -= 1;
+      if (parenDepth === 0) {
+        signatureEnded = true;
+      }
+    } else if (ch === '{' && signatureEnded) {
+      braceStart = i;
+      break;
+    }
+  }
+
+  if (braceStart < 0) {
+    throw new Error(`missing body for function ${name}`);
+  }
+
+  let depth = 0;
+  let end = braceStart;
+  for (; end < source.length; end += 1) {
+    const ch = source[end];
+    if (ch === '{') depth += 1;
+    if (ch === '}') {
+      depth -= 1;
+      if (depth === 0) {
+        end += 1;
+        break;
+      }
+    }
+  }
+
+  return source.slice(start, end);
+}
+
+const bundle = [
+  "const STEP9_SUCCESS_STATUSES = new Set(['Authentication successful!', 'Аутентификация успешна!', '认证成功!']);",
+  extractFunction('getInlineTextSnippet'),
+  extractFunction('summarizeStatusBadgeEntries'),
+  extractFunction('normalizeStep9StatusText'),
+  extractFunction('isOAuthCallbackTimeoutFailure'),
+  extractFunction('isStep9FailureText'),
+  extractFunction('isStep9SuccessStatus'),
+  extractFunction('isStep9SuccessLikeStatus'),
+  extractFunction('buildStep9StatusDiagnostics'),
+].join('\n');
+
+function createApi() {
+  return new Function(`
+function isRecoverableStep9AuthFailure(text) {
+  return /(?:认证失败|回调 URL 提交失败):\\s*/i.test(String(text || '').trim())
+    || /oauth flow is not pending/i.test(String(text || '').trim());
+}
+
+${bundle}
+
+return {
+  buildStep9StatusDiagnostics,
+};
+`)();
+}
+
+test('step 9 does not treat red success badges as exact success', () => {
+  const api = createApi();
+  const diagnostics = api.buildStep9StatusDiagnostics([
+    {
+      visible: true,
+      text: '认证成功!',
+      className: 'status-badge text-danger',
+      hasErrorVisualSignal: true,
+      errorVisualSummary: 'color=rgb(220, 38, 38)',
+    },
+  ], [], 'page');
+
+  assert.equal(diagnostics.hasSuccessLikeVisibleBadge, true);
+  assert.equal(diagnostics.hasExactSuccessVisibleBadge, false);
+  assert.equal(diagnostics.hasErrorStyledVisibleBadge, true);
+});
+
+test('step 9 keeps failure state dominant when success badge and error banner coexist', () => {
+  const api = createApi();
+  const diagnostics = api.buildStep9StatusDiagnostics(
+    [
+      {
+        visible: true,
+        text: '认证成功!',
+        className: 'status-badge',
+        hasErrorVisualSignal: false,
+        errorVisualSummary: '',
+      },
+    ],
+    [
+      {
+        visible: true,
+        text: '回调 URL 提交失败: oauth flow is not pending',
+        className: 'alert alert-danger',
+        hasErrorVisualSignal: true,
+        errorVisualSummary: 'color=rgb(220, 38, 38)',
+      },
+    ],
+    'page'
+  );
+
+  assert.equal(diagnostics.hasExactSuccessVisibleBadge, true);
+  assert.equal(diagnostics.hasFailureVisibleBadge, true);
+  assert.equal(diagnostics.failureText, '回调 URL 提交失败: oauth flow is not pending');
+});

+ 110 - 0
tests/verification-flow-polling.test.js

@@ -0,0 +1,110 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+
+const source = fs.readFileSync('background/verification-flow.js', 'utf8');
+const globalScope = {};
+const api = new Function('self', `${source}; return self.MultiPageBackgroundVerificationFlow;`)(globalScope);
+
+test('verification flow extends 2925 polling window', () => {
+  const helpers = api.createVerificationFlowHelpers({
+    addLog: async () => {},
+    chrome: { tabs: { update: async () => {} } },
+    CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
+    completeStepFromBackground: async () => {},
+    confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
+    getHotmailVerificationPollConfig: () => ({}),
+    getHotmailVerificationRequestTimestamp: () => 0,
+    getState: async () => ({}),
+    getTabId: async () => 1,
+    HOTMAIL_PROVIDER: 'hotmail-api',
+    isStopError: () => false,
+    LUCKMAIL_PROVIDER: 'luckmail-api',
+    MAIL_2925_VERIFICATION_INTERVAL_MS: 15000,
+    MAIL_2925_VERIFICATION_MAX_ATTEMPTS: 15,
+    pollCloudflareTempEmailVerificationCode: async () => ({}),
+    pollHotmailVerificationCode: async () => ({}),
+    pollLuckmailVerificationCode: async () => ({}),
+    sendToContentScript: async () => ({}),
+    sendToMailContentScriptResilient: async () => ({}),
+    setState: async () => {},
+    setStepStatus: async () => {},
+    sleepWithStop: async () => {},
+    throwIfStopped: () => {},
+    VERIFICATION_POLL_MAX_ROUNDS: 5,
+  });
+
+  const step4Payload = helpers.getVerificationPollPayload(4, { email: 'user@example.com', mailProvider: '2925' });
+  const step7Payload = helpers.getVerificationPollPayload(7, { email: 'user@example.com', mailProvider: '2925' });
+
+  assert.equal(step4Payload.maxAttempts, 15);
+  assert.equal(step4Payload.intervalMs, 15000);
+  assert.equal(step7Payload.maxAttempts, 15);
+  assert.equal(step7Payload.intervalMs, 15000);
+});
+
+test('verification flow runs beforeSubmit hook before filling the code', async () => {
+  const events = [];
+
+  const helpers = api.createVerificationFlowHelpers({
+    addLog: async () => {},
+    chrome: {
+      tabs: {
+        update: async () => {},
+      },
+    },
+    CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
+    completeStepFromBackground: async (_step, payload) => {
+      events.push(['complete', payload.code]);
+    },
+    confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
+    getHotmailVerificationPollConfig: () => ({}),
+    getHotmailVerificationRequestTimestamp: () => 0,
+    getState: async () => ({}),
+    getTabId: async () => 1,
+    HOTMAIL_PROVIDER: 'hotmail-api',
+    isStopError: () => false,
+    LUCKMAIL_PROVIDER: 'luckmail-api',
+    MAIL_2925_VERIFICATION_INTERVAL_MS: 15000,
+    MAIL_2925_VERIFICATION_MAX_ATTEMPTS: 15,
+    pollCloudflareTempEmailVerificationCode: async () => ({}),
+    pollHotmailVerificationCode: async () => ({}),
+    pollLuckmailVerificationCode: async () => ({}),
+    sendToContentScript: async (_source, message) => {
+      if (message.type === 'FILL_CODE') {
+        events.push(['submit', message.payload.code]);
+        return {};
+      }
+      return {};
+    },
+    sendToMailContentScriptResilient: async () => ({
+      code: '654321',
+      emailTimestamp: 123,
+    }),
+    setState: async (payload) => {
+      events.push(['state', payload.lastLoginCode || payload.lastSignupCode]);
+    },
+    setStepStatus: async () => {},
+    sleepWithStop: async () => {},
+    throwIfStopped: () => {},
+    VERIFICATION_POLL_MAX_ROUNDS: 5,
+  });
+
+  await helpers.resolveVerificationStep(
+    7,
+    { email: 'user@example.com', lastLoginCode: null },
+    { provider: 'qq', label: 'QQ 邮箱' },
+    {
+      beforeSubmit: async (result) => {
+        events.push(['beforeSubmit', result.code]);
+      },
+    }
+  );
+
+  assert.deepStrictEqual(events, [
+    ['beforeSubmit', '654321'],
+    ['submit', '654321'],
+    ['state', '654321'],
+    ['complete', '654321'],
+  ]);
+});

+ 79 - 51
tests/verification-stop-propagation.test.js

@@ -1,9 +1,10 @@
 const assert = require('assert');
 const fs = require('fs');
 
-const source = fs.readFileSync('background.js', 'utf8');
+const helperSource = fs.readFileSync('background.js', 'utf8');
+const verificationFlowSource = fs.readFileSync('background/verification-flow.js', 'utf8');
 
-function extractFunction(name) {
+function extractFunction(source, name) {
   const markers = [`async function ${name}(`, `function ${name}(`];
   const start = markers
     .map(marker => source.indexOf(marker))
@@ -51,13 +52,13 @@ function extractFunction(name) {
 }
 
 async function testPollFreshVerificationCodeRethrowsStop() {
-  const bundle = [
-    extractFunction('isStopError'),
-    extractFunction('throwIfStopped'),
-    extractFunction('pollFreshVerificationCode'),
+  const helperBundle = [
+    extractFunction(helperSource, 'isStopError'),
+    extractFunction(helperSource, 'throwIfStopped'),
   ].join('\n');
 
-const api = new Function(`
+const api = new Function('verificationFlowSource', `
+const self = {};
 let stopRequested = false;
 const STOP_ERROR_MESSAGE = '流程已被用户停止。';
 const HOTMAIL_PROVIDER = 'hotmail-api';
@@ -76,14 +77,8 @@ async function pollHotmailVerificationCode() {
 async function pollLuckmailVerificationCode() {
   throw new Error('luckmail path should not run in this test');
 }
-function getVerificationCodeStateKey(step) {
-  return step === 4 ? 'lastSignupCode' : 'lastLoginCode';
-}
-function getVerificationPollPayload(step, state, overrides = {}) {
-  return {
-    filterAfterTimestamp: 123,
-    ...overrides,
-  };
+async function pollCloudflareTempEmailVerificationCode() {
+  throw new Error('cloudflare path should not run in this test');
 }
 async function sendToMailContentScriptResilient() {
   throw new Error(STOP_ERROR_MESSAGE);
@@ -95,15 +90,41 @@ async function addLog(message, level) {
   logs.push({ message, level });
 }
 
-${bundle}
+${helperBundle}
+${verificationFlowSource}
+
+const helpers = self.MultiPageBackgroundVerificationFlow.createVerificationFlowHelpers({
+  addLog,
+  chrome: {},
+  CLOUDFLARE_TEMP_EMAIL_PROVIDER,
+  completeStepFromBackground: async () => {},
+  confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
+  getHotmailVerificationPollConfig,
+  getHotmailVerificationRequestTimestamp: () => 123,
+  getState: async () => ({}),
+  getTabId: async () => null,
+  HOTMAIL_PROVIDER,
+  isStopError,
+  LUCKMAIL_PROVIDER,
+  pollCloudflareTempEmailVerificationCode,
+  pollHotmailVerificationCode,
+  pollLuckmailVerificationCode,
+  sendToContentScript: async () => ({}),
+  sendToMailContentScriptResilient,
+  setState: async () => {},
+  setStepStatus: async () => {},
+  sleepWithStop: async () => {},
+  throwIfStopped,
+  VERIFICATION_POLL_MAX_ROUNDS,
+});
 
 return {
-  pollFreshVerificationCode,
+  pollFreshVerificationCode: helpers.pollFreshVerificationCode,
   snapshot() {
     return { logs, resendCalls };
   },
 };
-`)();
+`)(verificationFlowSource);
 
   let error = null;
   try {
@@ -119,56 +140,63 @@ return {
 }
 
 async function testResolveVerificationStepRethrowsStopFromFreshRequest() {
-  const bundle = [
-    extractFunction('isStopError'),
-    extractFunction('resolveVerificationStep'),
+  const helperBundle = [
+    extractFunction(helperSource, 'isStopError'),
   ].join('\n');
 
-const api = new Function(`
+const api = new Function('verificationFlowSource', `
+const self = {};
 const STOP_ERROR_MESSAGE = '流程已被用户停止。';
 const HOTMAIL_PROVIDER = 'hotmail-api';
 const LUCKMAIL_PROVIDER = 'luckmail-api';
 const CLOUDFLARE_TEMP_EMAIL_PROVIDER = 'cloudflare-temp-email';
 const logs = [];
 let pollCalls = 0;
-
-function getVerificationCodeStateKey(step) {
-  return step === 4 ? 'lastSignupCode' : 'lastLoginCode';
-}
-function getHotmailVerificationPollConfig() {
-  return {};
-}
-function getVerificationCodeLabel(step) {
-  return step === 4 ? '注册' : '登录';
-}
-function isStep7RestartFromStep6Error() {
-  return false;
-}
-async function requestVerificationCodeResend() {
-  throw new Error(STOP_ERROR_MESSAGE);
-}
 async function addLog(message, level) {
   logs.push({ message, level });
 }
-async function pollFreshVerificationCode() {
-  pollCalls += 1;
-  return { code: '123456', emailTimestamp: Date.now() };
-}
-async function submitVerificationCode() {
-  throw new Error('submit should not run in this test');
-}
-async function setState() {}
-async function completeStepFromBackground() {}
-
-${bundle}
+const chrome = { tabs: { async update() {} } };
+
+${helperBundle}
+${verificationFlowSource}
+
+const helpers = self.MultiPageBackgroundVerificationFlow.createVerificationFlowHelpers({
+  addLog,
+  chrome,
+  CLOUDFLARE_TEMP_EMAIL_PROVIDER,
+  completeStepFromBackground: async () => {},
+  confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
+  getHotmailVerificationPollConfig: () => ({}),
+  getHotmailVerificationRequestTimestamp: () => 123,
+  getState: async () => ({}),
+  getTabId: async () => 1,
+  HOTMAIL_PROVIDER,
+  isStopError,
+  LUCKMAIL_PROVIDER,
+  pollCloudflareTempEmailVerificationCode: async () => ({}),
+  pollHotmailVerificationCode: async () => ({}),
+  pollLuckmailVerificationCode: async () => ({}),
+  sendToContentScript: async () => {
+    throw new Error(STOP_ERROR_MESSAGE);
+  },
+  sendToMailContentScriptResilient: async () => {
+    pollCalls += 1;
+    return {};
+  },
+  setState: async () => {},
+  setStepStatus: async () => {},
+  sleepWithStop: async () => {},
+  throwIfStopped: () => {},
+  VERIFICATION_POLL_MAX_ROUNDS: 5,
+});
 
 return {
-  resolveVerificationStep,
+  resolveVerificationStep: helpers.resolveVerificationStep,
   snapshot() {
     return { logs, pollCalls };
   },
 };
-`)();
+`)(verificationFlowSource);
 
   let error = null;
   try {

+ 421 - 0
项目完整链路说明.md

@@ -0,0 +1,421 @@
+# 项目完整链路说明
+
+本文档面向 AI 与开发者,目标是让阅读者在最短时间内理解“项目做什么、怎么跑、数据怎么流、功能链路怎么串”,从而在新增功能时不漏逻辑、不误改边界。
+
+使用建议:
+
+1. 先阅读 [项目文件结构说明.md](c:/Users/projectf/Downloads/codex注册扩展/项目文件结构说明.md)
+2. 再阅读本文
+3. 最后阅读 [项目开发规范(AI协作).md](c:/Users/projectf/Downloads/codex注册扩展/项目开发规范(AI协作).md)
+
+## 1. 项目目标
+
+这是一个 Chrome 扩展,用于自动执行一整套 OpenAI / ChatGPT OAuth 注册与登录流程。
+
+它的核心价值不是“打开一个页面点几个按钮”,而是把下面这些环节串成一条完整可恢复的自动化链路:
+
+- 生成或选取注册邮箱
+- 打开 ChatGPT / OpenAI 注册入口
+- 提交邮箱和密码
+- 轮询注册验证码
+- 填写姓名和生日
+- 刷新 OAuth 链接并登录
+- 轮询登录验证码
+- 自动确认 OAuth 同意页
+- 把 localhost 回调提交到 CPA 或 SUB2API
+
+## 2. 核心运行参与者
+
+### 2.1 Sidepanel
+
+[sidepanel/sidepanel.html](c:/Users/projectf/Downloads/codex注册扩展/sidepanel/sidepanel.html) + [sidepanel/sidepanel.js](c:/Users/projectf/Downloads/codex注册扩展/sidepanel/sidepanel.js)
+
+职责:
+
+- 展示配置与步骤状态
+- 接收用户输入
+- 向后台发送命令
+- 接收后台广播并更新 UI
+- 动态渲染步骤列表
+- 在日志区标题下方汇总展示最近账号运行历史
+
+### 2.2 Background Service Worker
+
+[background.js](c:/Users/projectf/Downloads/codex注册扩展/background.js)
+
+职责:
+
+- 扩展后台入口
+- 装配所有模块
+- 统一承接 runtime message
+- 协调步骤执行
+- 管理状态、自动运行、标签页与内容脚本通信
+
+### 2.3 Content Scripts
+
+[content](c:/Users/projectf/Downloads/codex注册扩展/content)
+
+职责:
+
+- 在目标网页上执行 DOM 交互
+- 读取邮件内容或页面状态
+- 将步骤成功/失败状态上报给后台
+
+### 2.4 Helper / Utils / Provider Logic
+
+分布在根目录和 `background/` 下。
+
+职责:
+
+- 抽离第三方邮箱 provider 的纯逻辑
+- 抽离邮件匹配与验证码提取
+- 抽离共享验证码流程、自动运行流程和运行时基础设施
+
+## 3. 入口与装配关系
+
+### 3.1 扩展入口
+
+[manifest.json](c:/Users/projectf/Downloads/codex注册扩展/manifest.json) 声明:
+
+- `background.service_worker = background.js`
+- `side_panel.default_path = sidepanel/sidepanel.html`
+- 多组内容脚本自动注入规则
+
+### 3.2 背景层装配
+
+[background.js](c:/Users/projectf/Downloads/codex注册扩展/background.js) 通过 `importScripts(...)` 依次加载:
+
+- 共享数据与纯工具
+- provider 纯逻辑
+- 后台桥接层
+- 后台共享流程层
+- 后台运行时与消息路由层
+- 步骤执行模块
+
+因此 `background.js` 现在更像:
+
+- 常量定义中心
+- 模块依赖装配器
+- 极少量保留函数
+- Chrome 事件挂接入口
+
+### 3.3 步骤注册
+
+[data/step-definitions.js](c:/Users/projectf/Downloads/codex注册扩展/data/step-definitions.js) 提供共享步骤元数据。  
+[background/steps/registry.js](c:/Users/projectf/Downloads/codex注册扩展/background/steps/registry.js) 负责把“步骤元数据”映射到“步骤执行器”。
+
+这意味着:
+
+- 步骤顺序靠 `order`
+- 步骤文件名靠语义
+- 新增步骤时不需要重命名后续文件
+
+## 4. 状态与存储链路
+
+### 4.1 `chrome.storage.session`
+
+保存运行态:
+
+- 当前步骤状态
+- OAuth 链接
+- 当前邮箱 / 密码
+- localhost 回调地址
+- 自动运行轮次信息
+- 标签注册表
+- 最近打开的来源地址
+- LuckMail 当前运行时选择
+
+### 4.2 `chrome.storage.local`
+
+保存持久配置与账号运行历史:
+
+- CPA / SUB2API 配置
+- 邮箱 provider 配置
+- Hotmail 账号池
+- Cloudflare / Temp Email 设置
+- iCloud 相关偏好
+- LuckMail API 配置
+- 自动运行默认配置
+- 账号运行历史 `accountRunHistory`
+- 账号运行历史 txt 留档开关与 helper 地址
+
+当启用了独立的账号运行历史 txt 留档配置时,账号运行历史会通过 [scripts/hotmail_helper.py](c:/Users/projectf/Downloads/codex注册扩展/scripts/hotmail_helper.py) 追加写入 `data/account-run-history.txt` 文本文件,便于离线留档。
+这条配置链路独立于 `mailProvider` 和 Hotmail 的接码模式。
+
+### 4.3 状态广播
+
+后台通过 runtime message 向 sidepanel 广播:
+
+- `LOG_ENTRY`
+- `STEP_STATUS_CHANGED`
+- `DATA_UPDATED`
+- `AUTO_RUN_STATUS`
+- `ICLOUD_LOGIN_REQUIRED`
+- `ICLOUD_ALIASES_CHANGED`
+
+## 5. 内容脚本通信链路
+
+### 5.1 READY 机制
+
+[content/utils.js](c:/Users/projectf/Downloads/codex注册扩展/content/utils.js) 在脚本加载后会发送 `CONTENT_SCRIPT_READY`。
+
+后台收到后会:
+
+- 注册当前来源对应的 tab
+- 标记 ready
+- 冲刷排队命令
+
+### 5.2 队列与重试
+
+[background/tab-runtime.js](c:/Users/projectf/Downloads/codex注册扩展/background/tab-runtime.js) 负责:
+
+- `queueCommand`
+- `flushCommand`
+- `sendTabMessageWithTimeout`
+- `sendToContentScriptResilient`
+- `sendToMailContentScriptResilient`
+
+这保证了:
+
+- 页面切换导致脚本暂时失联时,后台不会立刻误判彻底失败
+- 邮箱页或注册页能在注入恢复后继续执行
+
+## 6. 手动步骤完整链路
+
+### Step 1
+
+文件:
+
+- [background/steps/open-chatgpt.js](c:/Users/projectf/Downloads/codex注册扩展/background/steps/open-chatgpt.js)
+- [content/signup-page.js](c:/Users/projectf/Downloads/codex注册扩展/content/signup-page.js)
+
+流程:
+
+1. 后台打开 ChatGPT 官网
+2. 等待注册入口页内容脚本就绪
+3. 标记 Step 1 完成
+
+### Step 2
+
+文件:
+
+- [background/steps/submit-signup-email.js](c:/Users/projectf/Downloads/codex注册扩展/background/steps/submit-signup-email.js)
+
+流程:
+
+1. 解析本轮应使用的邮箱
+2. 打开或复用注册页
+3. 点击注册入口并提交邮箱
+4. 等待邮箱提交后的真实落地页
+5. 如果进入密码页,则继续执行 Step 3
+6. 如果直接进入邮箱验证码页,则自动跳过 Step 3 并进入 Step 4
+
+### Step 3
+
+文件:
+
+- [background/steps/fill-password.js](c:/Users/projectf/Downloads/codex注册扩展/background/steps/fill-password.js)
+
+流程:
+
+1. 生成或读取密码
+2. 更新运行态密码
+3. 记录账号快照
+4. 让内容脚本填写密码并继续
+
+### Step 4 / Step 7
+
+文件:
+
+- [background/steps/fetch-signup-code.js](c:/Users/projectf/Downloads/codex注册扩展/background/steps/fetch-signup-code.js)
+- [background/steps/fetch-login-code.js](c:/Users/projectf/Downloads/codex注册扩展/background/steps/fetch-login-code.js)
+- [background/verification-flow.js](c:/Users/projectf/Downloads/codex注册扩展/background/verification-flow.js)
+
+这两步共享验证码主流程:
+
+1. 确定 provider
+2. 必要时重发验证码
+3. 轮询邮箱或 API
+4. 提取验证码
+5. 回填页面
+6. 若页面拒绝,则重试或回退
+
+补充行为:
+
+- `2925` provider 会拉长单轮轮询窗口,并关闭 Step 4 / 7 的自动重发间隔,减少因邮件延迟过早判负。
+- CPA 模式下,Step 7 在真正回填登录验证码前,会先刷新最新 OAuth 地址并快速重走一次 Step 6,降低验证码等待过久导致 OAuth 过期的概率。
+
+### Step 5
+
+文件:
+
+- [background/steps/fill-profile.js](c:/Users/projectf/Downloads/codex注册扩展/background/steps/fill-profile.js)
+
+流程:
+
+1. 生成随机姓名和生日
+2. 内容脚本填写资料并点击“完成帐户创建”
+3. 点击后立即上报 Step 5 完成,不再等待页面结果
+4. 自动运行在进入 Step 6 前仅额外等待当前页面加载完成,不再在 Step 5 阶段接管 ChatGPT 跳转或 onboarding 跳过
+
+### Step 6
+
+文件:
+
+- [background/steps/oauth-login.js](c:/Users/projectf/Downloads/codex注册扩展/background/steps/oauth-login.js)
+
+流程:
+
+1. 清理登录前 Cookie
+2. 通过 CPA / SUB2API 刷新 OAuth 地址
+3. 打开最新 OAuth 链接
+4. 登录
+5. 确保真正进入验证码页
+6. 如果未进入验证码页,则按可恢复逻辑最多重试 3 次
+7. 自动运行一旦进入步骤 6 之后的链路,若后续步骤报错且认证页未进入 `https://auth.openai.com/add-phone`,则统一回到步骤 6 重新开始授权流程
+
+### Step 8
+
+文件:
+
+- [background/steps/confirm-oauth.js](c:/Users/projectf/Downloads/codex注册扩展/background/steps/confirm-oauth.js)
+
+流程:
+
+1. 监听 localhost callback
+2. 准备 OAuth 同意页
+3. 尝试多轮点击“继续”
+4. 一旦捕获 localhost callback,写入状态并完成步骤
+
+### Step 9
+
+文件:
+
+- [background/steps/platform-verify.js](c:/Users/projectf/Downloads/codex注册扩展/background/steps/platform-verify.js)
+
+流程:
+
+1. 校验 localhost callback 是否有效
+2. 判断是 CPA 还是 SUB2API
+3. 打开相应后台
+4. 提交回调地址
+5. 仅当出现精确成功徽标,且该徽标不是红色/错误态、页面上也没有同时可见的失败提示时,才判定成功
+6. 识别 `认证失败:*`、`认证失败: timeout of 30000ms exceeded`、`回调 URL 提交失败: oauth flow is not pending` 等失败提示并立即报错
+7. 完成平台侧验证
+8. 追加账号运行历史成功记录
+9. 做成功后的清理与标记
+
+## 7. 邮箱与 provider 链路
+
+### 7.1 生成邮箱
+
+文件:
+
+- [background/generated-email-helpers.js](c:/Users/projectf/Downloads/codex注册扩展/background/generated-email-helpers.js)
+
+支持:
+
+- Duck
+- Cloudflare
+- Cloudflare Temp Email
+- iCloud 隐私邮箱
+
+### 7.2 Hotmail
+
+组成:
+
+- [hotmail-utils.js](c:/Users/projectf/Downloads/codex注册扩展/hotmail-utils.js)
+- [microsoft-email.js](c:/Users/projectf/Downloads/codex注册扩展/microsoft-email.js)
+- [scripts/hotmail_helper.py](c:/Users/projectf/Downloads/codex注册扩展/scripts/hotmail_helper.py)
+
+模式:
+
+- API 对接
+- 本地 helper
+
+补充:
+
+- 本地 helper 除了收信与验证码读取,还提供账号运行历史文本追加接口。
+- 账号运行历史 txt 留档由独立配置控制,不再绑定 Hotmail 的本地助手模式。
+
+### 7.3 LuckMail
+
+组成:
+
+- [luckmail-utils.js](c:/Users/projectf/Downloads/codex注册扩展/luckmail-utils.js)
+- LuckMail 相关后台领域逻辑仍在 [background.js](c:/Users/projectf/Downloads/codex注册扩展/background.js)
+
+### 7.4 iCloud
+
+组成:
+
+- [icloud-utils.js](c:/Users/projectf/Downloads/codex注册扩展/icloud-utils.js)
+- [content/icloud-mail.js](c:/Users/projectf/Downloads/codex注册扩展/content/icloud-mail.js)
+
+## 8. 自动运行完整链路
+
+文件:
+
+- [background/auto-run-controller.js](c:/Users/projectf/Downloads/codex注册扩展/background/auto-run-controller.js)
+
+流程:
+
+1. 读取总轮数与模式
+2. 计算是否从中断点继续
+3. 每轮执行前重置必要运行态
+4. 执行 `runAutoSequenceFromStep`
+   - 步骤 6 内部仍保留登录态恢复的有限重试
+   - 一旦进入步骤 6~9,遇到报错且认证流程未进入 `add-phone`,则自动回到步骤 6 无限重开,直到成功、手动停止或命中 `add-phone`
+5. 如果失败,根据设置决定:
+   - 立即停止
+   - 当前轮重试
+   - 下一轮继续
+6. 当前轮最终失败或被停止时,追加账号运行历史记录
+7. 如果配置了线程间隔,则挂计时计划
+8. 所有轮次结束后输出汇总
+
+## 9. 新增功能时最容易漏掉的地方
+
+### 新增步骤
+
+必须同时检查:
+
+1. [data/step-definitions.js](c:/Users/projectf/Downloads/codex注册扩展/data/step-definitions.js)
+2. [background/steps](c:/Users/projectf/Downloads/codex注册扩展/background/steps)
+3. [background/steps/registry.js](c:/Users/projectf/Downloads/codex注册扩展/background/steps/registry.js)
+4. 自动运行链路是否需要纳入
+5. Step 状态传播和侧边栏展示是否需要适配
+6. 测试是否要补
+
+### 新增 provider
+
+必须同时检查:
+
+1. provider 纯工具
+2. 后台 provider 调度分支
+3. 侧边栏配置项
+4. 动态邮箱生成逻辑
+5. Step 4 / 7 的验证码流
+6. 成功收尾逻辑
+
+### 新增配置项
+
+必须同时检查:
+
+1. `PERSISTED_SETTING_DEFAULTS`
+2. `normalizePersistentSettingValue`
+3. 导入导出逻辑
+4. sidepanel 表单与状态恢复
+5. 是否错误挂靠到无关 provider / manager 配置下
+6. 结构文档 / 开发规范是否需要更新
+
+## 10. 文档联动规则
+
+修改下列内容时,必须同步更新文档:
+
+- 文件结构变更  
+  更新 [项目文件结构说明.md](c:/Users/projectf/Downloads/codex注册扩展/项目文件结构说明.md)
+- 运行链路变更  
+  更新 [项目完整链路说明.md](c:/Users/projectf/Downloads/codex注册扩展/项目完整链路说明.md)
+- 规范、边界、步骤接入方式变更  
+  更新 [项目开发规范(AI协作).md](c:/Users/projectf/Downloads/codex注册扩展/项目开发规范(AI协作).md)

+ 192 - 0
项目开发规范(AI协作).md

@@ -0,0 +1,192 @@
+# 项目开发规范(AI协作)
+
+本文档是面向 AI 与开发者的项目开发规范。
+
+阅读顺序要求:
+
+1. [项目文件结构说明.md](c:/Users/projectf/Downloads/codex注册扩展/项目文件结构说明.md)
+2. [项目完整链路说明.md](c:/Users/projectf/Downloads/codex注册扩展/项目完整链路说明.md)
+3. 当前文件
+
+原则:
+
+- 目标是“让项目更清晰、更可维护、更可测试”,不是单纯把代码拆碎。
+- 重构优先考虑稳定性、职责边界与可理解性。
+- 任何新增功能都必须沿现有分层接入,禁止重新堆回巨石文件。
+
+## 1. 架构原则
+
+### 1.1 背景层原则
+
+- [background.js](c:/Users/projectf/Downloads/codex注册扩展/background.js) 应尽量保持为入口壳、装配层和少量保留函数。
+- 业务流程优先放到:
+  - `background/steps/`
+  - `background/*.js` 的共享模块
+- 不要把新 provider、大段自动运行逻辑、大段消息分发逻辑直接写回 `background.js`。
+
+### 1.2 步骤原则
+
+- 每个步骤必须有清晰边界。
+- 步骤文件应优先使用语义化名称,不再使用 `stepX.js` 命名。
+- 步骤顺序统一由:
+  - [data/step-definitions.js](c:/Users/projectf/Downloads/codex注册扩展/data/step-definitions.js)
+  - [background/steps/registry.js](c:/Users/projectf/Downloads/codex注册扩展/background/steps/registry.js)
+ 共同管理。
+
+### 1.3 前后端步骤定义共享原则
+
+- 任何步骤标题、顺序、key 变更,必须优先改 [data/step-definitions.js](c:/Users/projectf/Downloads/codex注册扩展/data/step-definitions.js)
+- 不允许只改 sidepanel 文案而不改共享定义
+- 不允许只改 registry 而不改共享定义
+
+## 2. 模块边界规则
+
+### 2.1 可以继续增长的文件
+
+允许增长,但必须保持边界清晰:
+
+- provider 领域实现文件
+- 某个单独步骤文件
+- 某个单独 manager 文件
+
+### 2.2 不应该继续膨胀的文件
+
+- [background.js](c:/Users/projectf/Downloads/codex注册扩展/background.js)
+- [sidepanel/sidepanel.js](c:/Users/projectf/Downloads/codex注册扩展/sidepanel/sidepanel.js)
+
+如果在这两个文件里新增了大段逻辑,应优先判断是否应该下沉到模块。
+
+## 3. 新增功能接入规范
+
+### 3.1 新增步骤
+
+必须同步检查:
+
+1. 新增步骤文件到 `background/steps/`
+2. 更新 [data/step-definitions.js](c:/Users/projectf/Downloads/codex注册扩展/data/step-definitions.js)
+3. 更新 [background/steps/registry.js](c:/Users/projectf/Downloads/codex注册扩展/background/steps/registry.js)
+4. 检查 sidepanel 动态步骤渲染是否已自动覆盖
+5. 检查 auto-run 是否需要纳入此步骤
+6. 检查状态流、回退流、日志流是否完整
+7. 补测试
+
+### 3.2 新增 provider
+
+必须同步检查:
+
+1. 是否有纯工具模块
+2. 是否需要 background provider 调度逻辑
+3. 是否需要 sidepanel 配置项
+4. 是否需要 Step 4 / 7 验证码链路接入
+5. 是否需要成功收尾逻辑
+6. 是否需要 README 与完整链路文档更新
+
+### 3.3 新增配置项
+
+必须同步检查:
+
+1. 默认值
+2. 归一化
+3. 导入导出
+4. state restore
+5. sidepanel UI
+6. 是否挂在正确的职责域下
+7. 文档
+
+## 4. 测试规范
+
+### 4.1 原则
+
+- 任何结构性重构都必须伴随测试迁移或新增。
+- 优先测试:
+  - 模块是否接入
+  - 核心纯函数是否仍可验证
+  - 回退/停止/异常传播是否仍正确
+
+### 4.2 不允许的做法
+
+- 修改结构后不补测试
+- 只跑局部测试,不跑全量回归
+- 为了通过测试而破坏实际运行边界
+
+### 4.3 最低要求
+
+完成一次结构性改动后,至少执行:
+
+```bash
+bun test
+```
+
+## 5. 文档更新规范
+
+### 5.1 必须更新文档的场景
+
+- 文件新增/删除/重命名  
+  更新 [项目文件结构说明.md](c:/Users/projectf/Downloads/codex注册扩展/项目文件结构说明.md)
+- 功能链路变化  
+  更新 [项目完整链路说明.md](c:/Users/projectf/Downloads/codex注册扩展/项目完整链路说明.md)
+- 开发流程、边界、约束变化  
+  更新当前文件
+
+### 5.2 文档更新要求
+
+- 不能只改代码不改文档
+- 不能只改文档标题不改正文细节
+- 不能让结构文档漏文件
+- 不能让链路文档落后于真实实现
+
+## 6. 命名规范
+
+### 6.1 文件命名
+
+- 步骤文件使用语义化名称
+- 工具文件按职责命名
+- 不要再新增 `misc.js`、`temp.js`、`new.js`、`helper2.js` 这种模糊文件名
+
+### 6.2 key 命名
+
+- 步骤 key 使用短语义英文 kebab-case
+- message type 保持稳定,新增时优先语义化大写常量风格
+
+## 7. 代码风格与实现要求
+
+- 优先复用现有模块,不重复发明一套新流程
+- 共享逻辑先提公共层,再让步骤层调用
+- 代码新增后应尽量减少主文件体积,而不是只做“形式拆分”
+- 观测、留档、日志、导出这类横切能力必须优先挂在独立配置域下,不能借某个 provider 的业务模式开关隐式控制
+- 保留少量兼容型薄包装是允许的,但必须有明确目的:
+  - 运行时装配
+  - 测试迁移过渡
+- 如果某个薄包装已经没有存在意义,应在后续重构中清掉
+
+## 8. AI 开发时的自检清单
+
+每次修改后至少自问:
+
+1. 我这次新增逻辑是不是应该下沉到模块?
+2. 我有没有破坏共享步骤定义?
+3. 我有没有漏掉 auto-run / sidepanel / message-router 其中之一?
+4. 我有没有补或迁移测试?
+5. 我有没有更新三份根目录文档?
+6. 我新增或修改的文件是否有可见乱码?
+
+## 9. 完成标准
+
+当满足以下条件时,可以视为一次合格开发完成:
+
+- 代码职责边界清晰
+- 新旧功能链路完整
+- 全量测试通过
+- 三份根目录文档已同步
+- 没有可见乱码
+
+## 10. 特别要求
+
+以后每次开发,如果影响到项目结构、功能链路或开发边界:
+
+- 必须同步检查并在必要时更新:
+  - [项目文件结构说明.md](c:/Users/projectf/Downloads/codex注册扩展/项目文件结构说明.md)
+  - [项目完整链路说明.md](c:/Users/projectf/Downloads/codex注册扩展/项目完整链路说明.md)
+  - [项目开发规范(AI协作).md](c:/Users/projectf/Downloads/codex注册扩展/项目开发规范(AI协作).md)
+
+这是硬要求,不是建议。

+ 169 - 0
项目文件结构说明.md

@@ -0,0 +1,169 @@
+# 项目文件结构说明
+
+本文档列出当前仓库中所有“非忽略文件”,并说明每个文件的作用。
+
+不纳入本清单的忽略目录:
+
+- `.github/`
+- `_metadata/`
+- `docs/md/`
+- `.vscode/`
+- `.git/`
+
+更新规则:
+
+- 新增、删除、重命名非忽略文件后,必须同步更新本文件。
+- 如果文件职责发生明显变化,也必须同步更新本文件中的说明。
+
+## 根目录
+
+- `.gitignore`:定义仓库忽略规则,当前忽略 `docs/md/`、`.github/`、`_metadata/`、`.vscode/` 等目录。
+- `LICENSE`:项目许可证文件。
+- `README.md`:面向使用者的项目介绍、安装说明、能力清单与操作指引。
+- `background.js`:扩展后台 Service Worker 入口壳,负责模块装配、初始化、全局常量、少量保留的领域函数与运行入口。
+- `cloudflare-temp-email-utils.js`:Cloudflare Temp Email 相关的纯工具函数,负责 URL、域名、邮件内容与 MIME 数据归一化。
+- `hotmail-utils.js`:Hotmail 账号与验证码提取相关的纯工具函数,负责账号筛选、验证码匹配、第三方接口数据归一化。
+- `icloud-utils.js`:iCloud 隐私邮箱相关的纯工具函数,负责 host、别名列表、保留状态、已用状态等归一化。
+- `luckmail-utils.js`:LuckMail 相关的纯工具函数,负责邮箱购买记录、标签、邮件 cursor、验证码匹配等归一化。
+- `manifest.json`:Chrome 扩展清单,声明权限、背景脚本、侧边栏、内容脚本与规则集。
+- `microsoft-email.js`:Microsoft Graph / Outlook 邮件读取辅助模块,负责刷新令牌换 token、邮箱夹轮询和验证码提取。
+- `package.json`:仓库最小 Node 包配置,目前主要提供测试脚本定义。
+- `rules.json`:静态 DNR 规则,主要处理 iCloud 相关请求头。
+- `start-hotmail-helper.bat`:Windows 下启动本地 helper 的脚本;当前既服务于 Hotmail 本地收信,也服务于账号运行历史文本留档。
+- `start-hotmail-helper.command`:macOS 下启动本地 helper 的脚本;当前既服务于 Hotmail 本地收信,也服务于账号运行历史文本留档。
+- `开发者AI开发与PR提交流程.md`:仓库现有的 AI 开发与 PR 提交流程说明。
+- `项目文件结构说明.md`:当前文件,维护整个仓库非忽略文件的结构与职责索引。
+- `项目完整链路说明.md`:面向 AI/开发者的完整功能链路说明,用于快速理解系统整体运行过程。
+- `项目开发规范(AI协作).md`:面向 AI/开发者的项目开发规范、约束与变更检查清单。
+
+## `background/`
+
+- `background/account-run-history.js`:账号运行历史模块,负责将成功/失败/停止结果持久化到 `chrome.storage.local`,并在启用独立的本地 txt 留档配置后追加写入文本日志。
+- `background/auto-run-controller.js`:自动运行主控制器,封装多轮执行、重试、轮次摘要、线程间隔与倒计时恢复逻辑。
+- `background/generated-email-helpers.js`:生成邮箱辅助层,封装 Duck、Cloudflare、Cloudflare Temp Email、iCloud 隐私邮箱的获取逻辑。
+- `background/logging-status.js`:后台日志、步骤状态、错误信息和若干状态判断的公共工具层。
+- `background/message-router.js`:后台消息路由层,负责处理 `chrome.runtime.onMessage` 进入的所有业务消息。
+- `background/navigation-utils.js`:导航与 URL 判断工具层,负责 callback、入口页、CPA/SUB2API 地址、步骤跳转相关判断。
+- `background/panel-bridge.js`:CPA / SUB2API 面板桥接层,封装 OAuth 地址获取所需的页面打开、脚本注入和通信。
+- `background/signup-flow-helpers.js`:注册页辅助层,负责打开注册入口、等待密码页以及解析当前流程所用邮箱。
+- `background/tab-runtime.js`:标签页与内容脚本运行时基础设施,封装标签注册、冲突清理、消息超时、注入重试与队列。
+- `background/verification-flow.js`:注册/登录验证码共享流程层,封装重发、轮询、提交、失败回退、自定义邮箱跳过以及 2925 长轮询参数。
+
+## `background/steps/`
+
+- `background/steps/confirm-oauth.js`:步骤 8 实现,负责 OAuth 同意页按钮定位、点击、localhost 回调监听与回调完成。
+- `background/steps/fetch-login-code.js`:步骤 7 实现,负责登录验证码阶段的邮箱轮询与回退控制。
+- `background/steps/fetch-signup-code.js`:步骤 4 实现,负责注册验证码阶段的页面准备与验证码流程入口。
+- `background/steps/fill-password.js`:步骤 3 实现,负责密码生成、保存、回填与提交。
+- `background/steps/fill-profile.js`:步骤 5 实现,负责姓名、生日填写并把资料提交给注册页内容脚本。
+- `background/steps/oauth-login.js`:步骤 6 实现,负责刷新 OAuth 链接、登录和确保进入验证码页。
+- `background/steps/open-chatgpt.js`:步骤 1 实现,负责打开 ChatGPT 官网并确认入口就绪。
+- `background/steps/platform-verify.js`:步骤 9 实现,负责 CPA / SUB2API 回调验证。
+- `background/steps/registry.js`:步骤注册表工厂,负责用稳定的步骤元数据映射到执行器。
+- `background/steps/submit-signup-email.js`:步骤 2 实现,负责注册入口点击、邮箱提交与提交后落地页分支判断。
+
+## `content/`
+
+- `content/activation-utils.js`:内容脚本通用激活策略工具,负责按钮点击方式判断与 Step 9 可恢复失败文案判断。
+- `content/duck-mail.js`:DuckDuckGo Email Protection 页面脚本,负责生成或读取 `@duck.com` 地址。
+- `content/gmail-mail.js`:Gmail 邮箱轮询脚本,负责在 Gmail 页面中匹配验证码邮件。
+- `content/icloud-mail.js`:iCloud 邮箱页面脚本,负责在 iCloud Mail 页面中读取邮件详情和验证码。
+- `content/inbucket-mail.js`:Inbucket 邮箱轮询脚本,负责在 Inbucket 页面中读取/删除验证码邮件。
+- `content/mail-163.js`:163 / 163 VIP 邮箱轮询脚本,负责网页邮箱验证码读取和邮件清理。
+- `content/mail-2925.js`:2925 邮箱页面脚本,负责 2925 邮箱收件轮询与验证码匹配。
+- `content/qq-mail.js`:QQ 邮箱轮询脚本,负责网页邮箱验证码读取。
+- `content/signup-page.js`:注册/登录/授权主内容脚本,负责 OpenAI / ChatGPT 页面上的步骤执行。
+- `content/sub2api-panel.js`:SUB2API 后台内容脚本,负责获取 OAuth 地址和提交 localhost 回调。
+- `content/utils.js`:内容脚本公共工具层,负责日志、READY/COMPLETE/ERROR 上报、元素等待、输入与点击。
+- `content/vps-panel.js`:CPA 面板内容脚本,负责获取 OAuth 地址、提交回调 URL,并基于精确成功徽标与错误态做 Step 9 判定。
+
+## `data/`
+
+- `data/names.js`:随机姓名、生日等测试数据源。
+- `data/step-definitions.js`:共享步骤元数据,前后台共同使用,用于动态渲染和步骤注册。
+
+## `docs/`
+
+- `docs/Hotmail双模式适配开发方案及开发记录.md`:Hotmail 双模式接入的历史方案和开发记录。
+- `docs/仓库协作者AI分析PR与合并标准流程.md`:仓库协作者进行 AI 分析 PR 与合并时的流程说明。
+- `docs/images/交流群.jpg`:README 中展示的交流群图片资源。
+- `docs/images/十轮自动.png`:README 中展示的自动运行效果图。
+- `docs/images/微信.png`:README 中展示的微信收款码图片。
+- `docs/images/支付宝.jpg`:README 中展示的支付宝收款码图片。
+- `docs/refactor/2026-04-16-architecture-refactor-plan.md`:本轮重构阶段的结构设计与迁移计划记录。
+- `docs/superpowers/plans/2026-04-10-hotmail-oauth-mail-pool.md`:Hotmail OAuth 邮箱池实现计划文档。
+- `docs/superpowers/specs/2026-04-10-hotmail-oauth-design.md`:Hotmail OAuth + Graph Mail 的设计规格文档。
+
+## `icons/`
+
+- `icons/icon128.png`:扩展 128 像素图标。
+- `icons/icon16.png`:扩展 16 像素图标。
+- `icons/icon48.png`:扩展 48 像素图标。
+
+## `scripts/`
+
+- `scripts/hotmail_helper.py`:本地 helper 服务,负责通过本地接口协助 Hotmail 获取邮件和验证码,并提供账号运行历史文本追加接口。
+
+## `sidepanel/`
+
+- `sidepanel/hotmail-manager.js`:侧边栏 Hotmail 账号池管理器,负责列表、导入、验证、测试收信和批量操作。
+- `sidepanel/icloud-manager.js`:侧边栏 iCloud 隐私邮箱管理器,负责列表、筛选、保留、删除和批量操作。
+- `sidepanel/luckmail-manager.js`:侧边栏 LuckMail 管理器,负责邮箱列表、筛选、启停、保留与批量操作。
+- `sidepanel/sidepanel.css`:侧边栏样式文件。
+- `sidepanel/sidepanel.html`:侧边栏页面结构,当前步骤列表已改为动态容器,并在日志区标题下方预留账号运行历史摘要条带。
+- `sidepanel/sidepanel.js`:侧边栏主入口脚本,负责 UI 状态同步、动态步骤渲染、按钮交互、账号运行历史摘要渲染、独立 txt 留档配置与广播接收。
+- `sidepanel/update-service.js`:侧边栏更新检查服务,负责 GitHub Releases 查询与版本展示。
+
+## `tests/`
+
+- `tests/activation-utils.test.js`:测试内容脚本激活策略与 Step 9 可恢复错误判断。
+- `tests/auto-run-fresh-attempt-reset.test.js`:测试自动运行在新一轮开始前会重置旧运行时上下文。
+- `tests/auto-run-step6-restart.test.js`:测试自动运行在步骤 6 之后遇错时会回到步骤 6 重开,并在命中 add-phone 时停止重开。
+- `tests/auto-step-random-delay.test.js`:测试自动运行步间延迟与旧配置键兼容解析。
+- `tests/background-auto-run-module.test.js`:测试自动运行控制器模块已接入且导出工厂。
+- `tests/background-account-run-history-module.test.js`:测试账号运行历史模块已接入、导出工厂,并能在不启用本地 helper 时正确持久化记录。
+- `tests/background-account-history-settings.test.js`:测试账号运行历史的独立配置项归一化,不再复用 Hotmail 模式作为启停条件。
+- `tests/background-generated-email-module.test.js`:测试生成邮箱辅助模块已接入且导出工厂。
+- `tests/background-icloud.test.js`:测试 iCloud 相关后台纯函数与别名收尾逻辑。
+- `tests/background-icloud-mail-provider.test.js`:测试 iCloud 邮箱 provider 配置解析。
+- `tests/background-logging-status-module.test.js`:测试日志/状态模块已接入且导出工厂。
+- `tests/background-luckmail.test.js`:测试 LuckMail 相关后台逻辑,如购买、复用、标记已用与重置。
+- `tests/background-message-router-module.test.js`:测试消息路由模块已接入且导出工厂。
+- `tests/background-message-router-step2-skip.test.js`:测试步骤 2 直接落到验证码页时的跳步与状态保护逻辑。
+- `tests/background-navigation-utils-module.test.js`:测试导航工具模块已接入且导出工厂。
+- `tests/background-panel-bridge-module.test.js`:测试面板桥接模块已接入且导出工厂。
+- `tests/background-signup-flow-module.test.js`:测试注册页辅助模块已接入且导出工厂。
+- `tests/background-step-modules.test.js`:测试步骤模块文件都已由后台入口加载。
+- `tests/background-step5-submit-short-circuit.test.js`:测试 Step 5 会把生成好的资料直接转发给内容脚本,并依赖完成信号收尾。
+- `tests/background-step6-retry-limit.test.js`:测试 Step 6 的有限重试上限,以及 Step 7 回放时可跳过登录前 Cookie 清理。
+- `tests/background-step7-recovery.test.js`:测试 Step 7 在 CPA 模式下会先回放 Step 6 再提交登录验证码,并为 2925 关闭重发间隔。
+- `tests/background-step-registry.test.js`:测试后台步骤注册表和共享步骤定义已接入。
+- `tests/background-tab-runtime-module.test.js`:测试标签运行时模块已接入且导出工厂。
+- `tests/background-verification-flow-module.test.js`:测试验证码流程模块已接入且导出工厂。
+- `tests/cloudflare-temp-email-provider.test.js`:测试 Cloudflare Temp Email provider 的轮询与目标邮箱选择逻辑。
+- `tests/cloudflare-temp-email-utils.test.js`:测试 Cloudflare Temp Email 工具层的 URL、域名、邮件解析逻辑。
+- `tests/hotmail-api-mode.test.js`:测试 Hotmail API 模式相关文案和集成方式。
+- `tests/hotmail-cors-headers.test.js`:测试 Microsoft token 请求相关 DNR 配置。
+- `tests/hotmail-utils.test.js`:测试 Hotmail 工具层的账号筛选、验证码提取和消息匹配逻辑。
+- `tests/icloud-mail-content.test.js`:测试 iCloud Mail 内容脚本读取邮件正文和选中状态逻辑。
+- `tests/icloud-utils.test.js`:测试 iCloud 工具层的 host、别名列表、保留状态与筛选逻辑。
+- `tests/luckmail-utils.test.js`:测试 LuckMail 工具层的购买记录、邮件、游标和验证码匹配逻辑。
+- `tests/microsoft-email.test.js`:测试 Microsoft 邮件拉取与验证码提取逻辑。
+- `tests/sidepanel-hotmail-manager.test.js`:测试侧边栏 Hotmail 管理器模块接线与空态渲染。
+- `tests/sidepanel-account-run-history.test.js`:测试侧边栏日志区下方的账号运行历史条带结构、独立留档配置控件与状态分类摘要逻辑。
+- `tests/sidepanel-icloud-manager.test.js`:测试侧边栏 iCloud 管理器模块接线与空态渲染。
+- `tests/sidepanel-icloud-provider.test.js`:测试侧边栏 iCloud 登录地址解析逻辑。
+- `tests/sidepanel-luckmail-manager.test.js`:测试侧边栏 LuckMail 管理器模块接线与空态渲染。
+- `tests/signup-page-tab-cleanup.test.js`:测试注册页来源标签的冲突清理逻辑。
+- `tests/step-definitions-module.test.js`:测试共享步骤定义模块及侧边栏脚本加载顺序。
+- `tests/step5-direct-complete.test.js`:测试 Step 5 在资料页点击提交后立即完成当前步骤。
+- `tests/step6-login-state.test.js`:测试 Step 6 登录状态判断逻辑。
+- `tests/step8-callback-handling.test.js`:测试 Step 8 回调地址捕获逻辑。
+- `tests/step8-debugger-stop.test.js`:测试 Step 8 调试器点击在 Stop 场景下的中止行为。
+- `tests/step8-state-timeout-retry.test.js`:测试 Step 8 通信错误是否可判定为可重试。
+- `tests/step8-stop-cleanup.test.js`:测试 Step 8 在 Stop 后能正确清理监听器与挂起状态。
+- `tests/step9-cpa-mode.test.js`:测试本地 CPA Step 9 策略判断。
+- `tests/step9-localhost-cleanup-scope.test.js`:测试 Step 9 仅清理精确命中的 localhost callback 和路径前缀残留页。
+- `tests/step9-status-diagnostics.test.js`:测试 Step 9 精确成功判定、红色错误态过滤,以及成功徽标与失败提示并存时的优先级。
+- `tests/verification-stop-propagation.test.js`:测试验证码流程在 Stop 场景下的错误传播与不中途降级。
+- `tests/verification-flow-polling.test.js`:测试 2925 长轮询参数,以及验证码提交流程中的 `beforeSubmit` 钩子执行顺序。

Некоторые файлы не были показаны из-за большого количества измененных файлов