Przeglądaj źródła

Improve A4Sky flow resilience and add-phone cooldown

chendeben 4 miesięcy temu
rodzic
commit
bb7356f178

+ 5 - 1
background.js

@@ -3599,6 +3599,8 @@ function matchesSourceUrlFamily(source, candidateUrl, referenceUrl) {
       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 'mail-phplife':
+      return candidate.hostname === 'mail.phplife.net';
     case 'vps-panel':
       return Boolean(reference) && candidate.origin === reference.origin && candidate.pathname === reference.pathname;
     case 'sub2api-panel':
@@ -3764,6 +3766,7 @@ function getSourceLabel(source) {
     'qq-mail': 'QQ 邮箱',
     'mail-163': '163 邮箱',
     'mail-2925': '2925 邮箱',
+    'mail-phplife': 'A4Sky 邮箱(mail.phplife.net)',
     'inbucket-mail': 'Inbucket 邮箱',
     'duck-mail': 'Duck 邮箱',
     'hotmail-api': 'Hotmail(API对接/本地助手)',
@@ -5766,6 +5769,7 @@ const step3Executor = self.MultiPageBackgroundStep3?.createStep3Executor({
   SIGNUP_PAGE_INJECT_FILES,
 });
 const step4Executor = self.MultiPageBackgroundStep4?.createStep4Executor({
+  A4SKY_PROVIDER,
   addLog,
   chrome,
   completeStepFromBackground,
@@ -6057,7 +6061,7 @@ function getMailConfig(state) {
       source: 'mail-phplife',
       url: 'https://mail.phplife.net/?_task=mail&_mbox=INBOX',
       label: 'A4Sky 邮箱(mail.phplife.net)',
-      navigateOnReuse: true,
+      navigateOnReuse: false,
       inject: ['content/activation-utils.js', 'content/utils.js', 'content/phplife-mail.js'],
       injectSource: 'mail-phplife',
     };

+ 91 - 10
background/auto-run-controller.js

@@ -12,6 +12,7 @@
       broadcastAutoRunStatus,
       broadcastStopToContentScripts,
       cancelPendingCommands,
+      chooseAddPhonePauseMinutes,
       clearStopRequest,
       createAutoRunSessionId,
       getAutoRunStatusPayload,
@@ -36,6 +37,42 @@
       waitForRunningStepsToFinish,
     } = deps;
 
+    function getAddPhonePauseMinutes() {
+      const candidate = Number(
+        typeof chooseAddPhonePauseMinutes === 'function'
+          ? chooseAddPhonePauseMinutes()
+          : (30 + Math.floor(Math.random() * 31))
+      );
+      if (!Number.isFinite(candidate)) {
+        return 30;
+      }
+      return Math.min(60, Math.max(30, Math.floor(candidate)));
+    }
+
+    function buildFreshAttemptPreservedTabRuntime(prevState = {}) {
+      const provider = String(prevState?.mailProvider || '').trim().toLowerCase();
+      if (provider !== 'a4sky') {
+        return {
+          tabRegistry: {},
+          sourceLastUrls: {},
+        };
+      }
+
+      const nextTabRegistry = {};
+      const nextSourceLastUrls = {};
+      if (prevState?.tabRegistry?.['mail-phplife']) {
+        nextTabRegistry['mail-phplife'] = { ...prevState.tabRegistry['mail-phplife'] };
+      }
+      if (prevState?.sourceLastUrls?.['mail-phplife']) {
+        nextSourceLastUrls['mail-phplife'] = prevState.sourceLastUrls['mail-phplife'];
+      }
+
+      return {
+        tabRegistry: nextTabRegistry,
+        sourceLastUrls: nextSourceLastUrls,
+      };
+    }
+
     function createAutoRunRoundSummary(round) {
       return {
         round,
@@ -154,35 +191,44 @@
     }
 
     async function waitBetweenAutoRunRounds(targetRun, totalRuns, roundSummary, options = {}) {
-      const { autoRunSkipFailures = false, roundSummaries = [] } = options;
+      const {
+        autoRunSkipFailures = false,
+        roundSummaries = [],
+        forceDelayMinutes = null,
+        countdownTitle = '线程间隔中',
+        countdownNote = '',
+      } = options;
       if (totalRuns <= 1 || targetRun >= totalRuns) {
         return false;
       }
 
-      const fallbackThreadIntervalMinutes = normalizeAutoRunFallbackThreadIntervalMinutes(
+      const configuredDelayMinutes = normalizeAutoRunFallbackThreadIntervalMinutes(
         (await getState()).autoRunFallbackThreadIntervalMinutes
       );
-      if (fallbackThreadIntervalMinutes <= 0) {
+      const resolvedDelayMinutes = Number.isFinite(Number(forceDelayMinutes))
+        ? Math.max(0, Math.floor(Number(forceDelayMinutes)))
+        : configuredDelayMinutes;
+      if (resolvedDelayMinutes <= 0) {
         return false;
       }
 
       const currentRuntime = runtime.get();
       const statusLabel = roundSummary?.status === 'failed' ? '失败' : '完成';
       await addLog(
-        `线程间隔:第 ${targetRun}/${totalRuns} 轮已${statusLabel},等待 ${fallbackThreadIntervalMinutes} 分钟后开始下一轮。`,
+        `线程间隔:第 ${targetRun}/${totalRuns} 轮已${statusLabel},等待 ${resolvedDelayMinutes} 分钟后开始下一轮。`,
         'info'
       );
       await persistAutoRunTimerPlan({
         kind: AUTO_RUN_TIMER_KIND_BETWEEN_ROUNDS,
-        fireAt: Date.now() + fallbackThreadIntervalMinutes * 60 * 1000,
+        fireAt: Date.now() + resolvedDelayMinutes * 60 * 1000,
         currentRun: targetRun,
         totalRuns,
         attemptRun: currentRuntime.autoRunAttemptRun,
         autoRunSessionId: currentRuntime.autoRunSessionId,
         autoRunSkipFailures,
         roundSummaries,
-        countdownTitle: '线程间隔中',
-        countdownNote: `第 ${Math.min(targetRun + 1, totalRuns)}/${totalRuns} 轮即将开始`,
+        countdownTitle,
+        countdownNote: countdownNote || `第 ${Math.min(targetRun + 1, totalRuns)}/${totalRuns} 轮即将开始`,
       }, {
         autoRunSkipFailures,
         autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries),
@@ -360,6 +406,7 @@
 
           if (!useExistingProgress) {
             const prevState = await getState();
+            const preservedTabRuntime = buildFreshAttemptPreservedTabRuntime(prevState);
             const keepSettings = {
               vpsUrl: prevState.vpsUrl,
               vpsPassword: prevState.vpsPassword,
@@ -380,8 +427,8 @@
               cloudflareDomains: prevState.cloudflareDomains,
               autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries),
               autoRunSessionId: sessionId,
-              tabRegistry: {},
-              sourceLastUrls: {},
+              tabRegistry: preservedTabRuntime.tabRegistry,
+              sourceLastUrls: preservedTabRuntime.sourceLastUrls,
               ...getAutoRunStatusPayload('running', { currentRun: targetRun, totalRuns, attemptRun, sessionId }),
             };
             await resetState();
@@ -473,8 +520,42 @@
               await appendRoundRecordIfNeeded('failed', reason);
               cancelPendingCommands('当前轮因认证流程进入 add-phone 已终止。');
               await broadcastStopToContentScripts();
+              if (targetRun < totalRuns) {
+                const pauseMinutes = getAddPhonePauseMinutes();
+                await addLog(
+                  `第 ${targetRun}/${totalRuns} 轮触发 add-phone/手机号页,当前轮记为失败,等待 ${pauseMinutes} 分钟后继续下一轮。`,
+                  'warn'
+                );
+                try {
+                  const parkedForNextRound = await waitBetweenAutoRunRounds(targetRun, totalRuns, roundSummary, {
+                    autoRunSkipFailures,
+                    roundSummaries,
+                    forceDelayMinutes: pauseMinutes,
+                    countdownTitle: '手机号冷却中',
+                    countdownNote: `第 ${Math.min(targetRun + 1, totalRuns)}/${totalRuns} 轮将在手机号冷却后开始`,
+                  });
+                  if (parkedForNextRound) {
+                    parkedByTimer = true;
+                    break;
+                  }
+                } catch (sleepError) {
+                  if (isStopError(sleepError)) {
+                    stoppedEarly = true;
+                    await addLog(`第 ${targetRun}/${totalRuns} 轮已被用户停止`, 'warn');
+                    await broadcastAutoRunStatus('stopped', {
+                      currentRun: targetRun,
+                      totalRuns,
+                      attemptRun,
+                      sessionId: 0,
+                    });
+                    break;
+                  }
+                  throw sleepError;
+                }
+              }
+
               await addLog(
-                `第 ${targetRun}/${totalRuns} 轮触发 add-phone/手机号页,当前自动运行将立即停止,不再重试或进入下一轮。`,
+                `第 ${targetRun}/${totalRuns} 轮触发 add-phone/手机号页,当前自动运行将停止。`,
                 'warn'
               );
               stoppedEarly = true;

+ 1 - 1
background/signup-flow-helpers.js

@@ -173,7 +173,7 @@
           prepareLogLabel: '步骤 3 收尾',
         },
       }, {
-        timeoutMs: 30000,
+        timeoutMs: 65000,
         retryDelayMs: 700,
         logMessage: `步骤 ${step}:密码已提交,正在确认是否进入下一页面,必要时自动恢复重试页...`,
       });

+ 2 - 1
background/steps/fetch-signup-code.js

@@ -3,6 +3,7 @@
 })(typeof self !== 'undefined' ? self : globalThis, function createBackgroundStep4Module() {
   function createStep4Executor(deps = {}) {
     const {
+      A4SKY_PROVIDER,
       addLog,
       chrome,
       completeStepFromBackground,
@@ -92,7 +93,7 @@
 
       await resolveVerificationStep(4, state, mail, {
         filterAfterTimestamp: stepStartedAt,
-        requestFreshCodeFirst: mail.provider === HOTMAIL_PROVIDER ? false : true,
+        requestFreshCodeFirst: mail.provider === HOTMAIL_PROVIDER || mail.provider === A4SKY_PROVIDER ? false : true,
         resendIntervalMs: (mail.provider === HOTMAIL_PROVIDER || mail.provider === '2925')
           ? 0
           : STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS,

+ 57 - 39
background/steps/submit-signup-email.js

@@ -18,51 +18,69 @@
 
     async function executeStep2(state) {
       const resolvedEmail = await resolveSignupEmailForFlow(state);
+      const maxAttempts = 2;
+      let lastError = null;
 
-      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:注册入口页内容脚本未就绪,正在等待页面恢复...',
-        });
-      }
+      for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
+        let signupTabId = await getTabId('signup-page');
+        if (!signupTabId || !(await isTabAlive('signup-page'))) {
+          await addLog('步骤 2:未发现可用的注册页标签,正在重新打开 ChatGPT 官网...', 'warn');
+          signupTabId = (await ensureSignupEntryPageReady(2)).tabId;
+        } else if (attempt > 1) {
+          await addLog(`步骤 2:第 ${attempt}/${maxAttempts} 次尝试,正在重新打开注册入口后重试邮箱提交...`, '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:官网注册入口正在切换,等待页面恢复后继续输入邮箱...',
-      });
+        try {
+          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?.error) {
+            throw new Error(step2Result.error);
+          }
 
-      if (!step2Result?.alreadyOnPasswordPage) {
-        await addLog(`步骤 2:邮箱 ${resolvedEmail} 已提交,正在等待页面加载并确认下一步入口...`);
-      }
+          if (!step2Result?.alreadyOnPasswordPage) {
+            await addLog(`步骤 2:邮箱 ${resolvedEmail} 已提交,正在等待页面加载并确认下一步入口...`);
+          }
 
-      const landingResult = await ensureSignupPostEmailPageReadyInTab(signupTabId, 2, {
-        skipUrlWait: Boolean(step2Result?.alreadyOnPasswordPage),
-      });
+          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;
+        } catch (error) {
+          lastError = error;
+          if (attempt >= maxAttempts) {
+            throw error;
+          }
+          await addLog(`步骤 2:当前尝试未能稳定进入下一页面,准备重新打开注册入口后重试。原因:${error?.message || error}`, 'warn');
+        }
+      }
 
-      await completeStepFromBackground(2, {
-        email: resolvedEmail,
-        nextSignupState: landingResult?.state || 'password_page',
-        nextSignupUrl: landingResult?.url || step2Result?.url || '',
-        skippedPasswordStep: landingResult?.state === 'verification_page',
-      });
+      throw lastError || new Error('步骤 2:邮箱提交流程失败。');
     }
 
     return { executeStep2 };

+ 96 - 0
background/tab-runtime.js

@@ -118,6 +118,30 @@
       await setState({ sourceLastUrls });
     }
 
+    async function findReusableTabsForSource(source, referenceUrl, options = {}) {
+      const { excludeTabIds = [] } = options;
+      const excluded = new Set(excludeTabIds.filter((id) => Number.isInteger(id)));
+      const tabs = await chrome.tabs.query({});
+
+      return tabs
+        .filter((tab) => Number.isInteger(tab.id) && !excluded.has(tab.id))
+        .filter((tab) => matchesSourceUrlFamily(source, tab.url, referenceUrl));
+    }
+
+    function sortReusableTabsByPriority(tabs = [], referenceUrl = '') {
+      return [...tabs].sort((left, right) => {
+        const leftExact = left?.url === referenceUrl ? 1 : 0;
+        const rightExact = right?.url === referenceUrl ? 1 : 0;
+        if (leftExact !== rightExact) return rightExact - leftExact;
+
+        const leftActive = left?.active ? 1 : 0;
+        const rightActive = right?.active ? 1 : 0;
+        if (leftActive !== rightActive) return rightActive - leftActive;
+
+        return Number(left?.id || 0) - Number(right?.id || 0);
+      });
+    }
+
     async function closeConflictingTabsForSource(source, currentUrl, options = {}) {
       const { excludeTabIds = [] } = options;
       const excluded = new Set(excludeTabIds.filter((id) => Number.isInteger(id)));
@@ -532,6 +556,78 @@
         return tabId;
       }
 
+      const reusableTabs = sortReusableTabsByPriority(
+        await findReusableTabsForSource(source, url),
+        url
+      );
+      if (reusableTabs.length) {
+        const [keeper] = reusableTabs;
+        await registerTab(source, keeper.id);
+        await closeConflictingTabsForSource(source, url, { excludeTabIds: [keeper.id] });
+
+        const currentTab = await chrome.tabs.get(keeper.id);
+        const sameUrl = currentTab.url === url;
+        const shouldReloadOnReuse = sameUrl && options.reloadIfSameUrl;
+        const registry = await getTabRegistry();
+
+        if (sameUrl) {
+          await chrome.tabs.update(keeper.id, { active: true });
+          if (shouldReloadOnReuse) {
+            if (registry[source]) registry[source].ready = false;
+            await setState({ tabRegistry: registry });
+            await chrome.tabs.reload(keeper.id);
+            await waitForTabUpdateComplete(keeper.id);
+          }
+
+          if (options.inject) {
+            if (registry[source]) registry[source].ready = false;
+            await setState({ tabRegistry: registry });
+            if (options.injectSource) {
+              await chrome.scripting.executeScript({
+                target: { tabId: keeper.id },
+                func: (injectedSource) => {
+                  window.__MULTIPAGE_SOURCE = injectedSource;
+                },
+                args: [options.injectSource],
+              });
+            }
+            await chrome.scripting.executeScript({
+              target: { tabId: keeper.id },
+              files: options.inject,
+            });
+            await sleepOrStop(500);
+          }
+
+          await rememberSourceLastUrl(source, url);
+          return keeper.id;
+        }
+
+        if (registry[source]) registry[source].ready = false;
+        await setState({ tabRegistry: registry });
+        await chrome.tabs.update(keeper.id, { url, active: true });
+        await waitForTabUpdateComplete(keeper.id);
+
+        if (options.inject) {
+          if (options.injectSource) {
+            await chrome.scripting.executeScript({
+              target: { tabId: keeper.id },
+              func: (injectedSource) => {
+                window.__MULTIPAGE_SOURCE = injectedSource;
+              },
+              args: [options.injectSource],
+            });
+          }
+          await chrome.scripting.executeScript({
+            target: { tabId: keeper.id },
+            files: options.inject,
+          });
+        }
+
+        await sleepOrStop(500);
+        await rememberSourceLastUrl(source, url);
+        return keeper.id;
+      }
+
       await closeConflictingTabsForSource(source, url);
       const tab = await chrome.tabs.create({ url, active: true });
 

+ 1 - 1
content/phplife-mail.js

@@ -344,7 +344,7 @@ function shouldOpenRowForCodeDetection(details = {}) {
     return false;
   }
 
-  return /your\s+temporary\s+chatgpt\s+login\s+code/i.test(subject);
+  return /your\s+temporary\s+chatgpt\s+login\s+code|(?:你(?:的)?|您的)?\s*临时\s*chatgpt\s*登录代?码/i.test(subject);
 }
 
 function getCurrentMessageUid() {

+ 229 - 0
tests/auto-run-a4sky-mail-tab-reuse.test.js

@@ -0,0 +1,229 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+
+const helperSource = fs.readFileSync('background.js', 'utf8');
+const autoRunModuleSource = fs.readFileSync('background/auto-run-controller.js', 'utf8');
+
+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}`);
+
+  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 helperBundle = [
+  extractFunction(helperSource, 'clearStopRequest'),
+  extractFunction(helperSource, 'normalizeAutoRunSessionId'),
+  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');
+
+test('auto-run fresh attempt preserves A4Sky mailbox tab context', async () => {
+  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 STEP_IDS = [1,2,3,4,5,6,7,8,9,10];
+const DEFAULT_STATE = {
+  stepStatuses: { 1:'pending',2:'pending',3:'pending',4:'pending',5:'pending',6:'pending',7:'pending',8:'pending',9:'pending',10:'pending' },
+};
+let stopRequested = false;
+let runCalls = 0;
+let autoRunSessionId = 0;
+let autoRunSessionSeed = 1000;
+let currentState = {
+  ...DEFAULT_STATE,
+  stepStatuses: { ...DEFAULT_STATE.stepStatuses },
+  vpsUrl: 'https://example.com/vps',
+  vpsPassword: 'secret',
+  customPassword: '',
+  autoRunSkipFailures: false,
+  autoRunFallbackThreadIntervalMinutes: 0,
+  autoRunDelayEnabled: false,
+  autoRunDelayMinutes: 30,
+  autoStepDelaySeconds: null,
+  mailProvider: 'a4sky',
+  emailGenerator: 'duck',
+  gmailBaseEmail: '',
+  mail2925BaseEmail: '',
+  emailPrefix: '',
+  inbucketHost: '',
+  inbucketMailbox: '',
+  cloudflareDomain: '',
+  cloudflareDomains: [],
+  tabRegistry: {},
+  sourceLastUrls: {},
+};
+async function getState() {
+  return {
+    ...currentState,
+    stepStatuses: { ...(currentState.stepStatuses || {}) },
+    tabRegistry: { ...(currentState.tabRegistry || {}) },
+    sourceLastUrls: { ...(currentState.sourceLastUrls || {}) },
+  };
+}
+async function setState(updates) {
+  currentState = {
+    ...currentState,
+    ...updates,
+    stepStatuses: updates.stepStatuses ? { ...updates.stepStatuses } : currentState.stepStatuses,
+    tabRegistry: updates.tabRegistry ? { ...updates.tabRegistry } : currentState.tabRegistry,
+    sourceLastUrls: updates.sourceLastUrls ? { ...updates.sourceLastUrls } : currentState.sourceLastUrls,
+  };
+}
+async function resetState() {
+  const prev = await getState();
+  currentState = {
+    ...DEFAULT_STATE,
+    stepStatuses: { ...DEFAULT_STATE.stepStatuses },
+    vpsUrl: prev.vpsUrl,
+    vpsPassword: prev.vpsPassword,
+    customPassword: prev.customPassword,
+    autoRunSkipFailures: prev.autoRunSkipFailures,
+    autoRunFallbackThreadIntervalMinutes: prev.autoRunFallbackThreadIntervalMinutes,
+    autoRunDelayEnabled: prev.autoRunDelayEnabled,
+    autoRunDelayMinutes: prev.autoRunDelayMinutes,
+    autoStepDelaySeconds: prev.autoStepDelaySeconds,
+    mailProvider: prev.mailProvider,
+    emailGenerator: prev.emailGenerator,
+    gmailBaseEmail: prev.gmailBaseEmail,
+    mail2925BaseEmail: prev.mail2925BaseEmail,
+    emailPrefix: prev.emailPrefix,
+    inbucketHost: prev.inbucketHost,
+    inbucketMailbox: prev.inbucketMailbox,
+    cloudflareDomain: prev.cloudflareDomain,
+    cloudflareDomains: [...(prev.cloudflareDomains || [])],
+    tabRegistry: { ...(prev.tabRegistry || {}) },
+    sourceLastUrls: { ...(prev.sourceLastUrls || {}) },
+  };
+}
+async function addLog() {}
+async function broadcastAutoRunStatus(phase, payload = {}) {
+  await setState({ ...getAutoRunStatusPayload(phase, payload) });
+}
+async function sleepWithStop() {}
+async function waitForRunningStepsToFinish() { return getState(); }
+async function broadcastStopToContentScripts() {}
+function cancelPendingCommands() {}
+function normalizeAutoRunFallbackThreadIntervalMinutes(value) { return Math.max(0, Math.floor(Number(value) || 0)); }
+async function persistAutoRunTimerPlan() {}
+async function launchAutoRunTimerPlan() { return false; }
+function getPendingAutoRunTimerPlan() { return null; }
+function getErrorMessage(error) { return error?.message || String(error || ''); }
+function createAutoRunSessionId() { autoRunSessionSeed += 1; autoRunSessionId = autoRunSessionSeed; return autoRunSessionId; }
+function throwIfAutoRunSessionStopped(sessionId) { if (sessionId && sessionId !== autoRunSessionId) throw new Error(STOP_ERROR_MESSAGE); throwIfStopped(); }
+const chrome = { runtime: { sendMessage() { return Promise.resolve(); } } };
+function getStopRequested() { return false; }
+async function runAutoSequenceFromStep() {
+  runCalls += 1;
+  const state = await getState();
+  if (runCalls === 2) {
+    if (state.tabRegistry['mail-phplife']?.tabId !== 77) {
+      throw new Error('fresh auto-run attempt did not preserve mail-phplife tab id');
+    }
+    if (state.sourceLastUrls['mail-phplife'] !== 'https://mail.phplife.net/?_task=mail&_mbox=INBOX') {
+      throw new Error('fresh auto-run attempt did not preserve mail-phplife sourceLastUrl');
+    }
+  }
+  currentState = {
+    ...currentState,
+    stepStatuses: { 1:'completed',2:'completed',3:'completed',4:'completed',5:'completed',6:'completed',7:'completed',8:'completed',9:'completed',10:'completed' },
+    tabRegistry: { 'mail-phplife': { tabId: 77, ready: true } },
+    sourceLastUrls: { 'mail-phplife': 'https://mail.phplife.net/?_task=mail&_mbox=INBOX' },
+  };
+}
+${helperBundle}
+${autoRunModuleSource}
+const runtime = {
+  state: { autoRunActive:false, autoRunCurrentRun:0, autoRunTotalRuns:1, autoRunAttemptRun:0, autoRunSessionId:0 },
+  get() { return { ...this.state }; },
+  set(updates) { this.state = { ...this.state, ...updates }; },
+};
+const controller = self.MultiPageBackgroundAutoRunController.createAutoRunController({
+  addLog,
+  appendAccountRunRecord: async () => null,
+  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,
+  createAutoRunSessionId,
+  getAutoRunStatusPayload,
+  getErrorMessage,
+  getFirstUnfinishedStep,
+  getPendingAutoRunTimerPlan,
+  getRunningSteps,
+  getState,
+  hasSavedProgress,
+  isAddPhoneAuthFailure: () => false,
+  isRestartCurrentAttemptError,
+  isStopError,
+  getStopRequested,
+  launchAutoRunTimerPlan,
+  normalizeAutoRunFallbackThreadIntervalMinutes,
+  persistAutoRunTimerPlan,
+  resetState,
+  runAutoSequenceFromStep,
+  runtime,
+  setState,
+  sleepWithStop,
+  throwIfAutoRunSessionStopped,
+  waitForRunningStepsToFinish,
+  chrome,
+});
+return {
+  autoRunLoop: controller.autoRunLoop,
+  snapshot() { return { currentState, runCalls }; },
+};
+  `)(autoRunModuleSource);
+
+  await api.autoRunLoop(2, { autoRunSkipFailures: false, mode: 'restart' });
+  const snapshot = api.snapshot();
+  assert.equal(snapshot.runCalls, 2);
+  assert.equal(snapshot.currentState.tabRegistry['mail-phplife']?.tabId, 77);
+  assert.equal(snapshot.currentState.sourceLastUrls['mail-phplife'], 'https://mail.phplife.net/?_task=mail&_mbox=INBOX');
+});

+ 179 - 0
tests/auto-run-add-phone-stop.test.js

@@ -165,3 +165,182 @@ test('auto-run controller does not retry add-phone failures even when auto retry
   assert.equal(runtime.state.autoRunActive, false);
   assert.equal(runtime.state.autoRunSessionId, 0);
 });
+
+test('auto-run controller parks 30~60 minutes and continues next round after add-phone when more runs remain', async () => {
+  const events = {
+    logs: [],
+    broadcasts: [],
+    accountRecords: [],
+    timerPlans: [],
+    runCalls: 0,
+  };
+
+  let currentState = {
+    stepStatuses: {},
+    vpsUrl: 'https://example.com/vps',
+    vpsPassword: 'secret',
+    customPassword: '',
+    autoRunSkipFailures: false,
+    autoRunFallbackThreadIntervalMinutes: 0,
+    autoRunDelayEnabled: false,
+    autoRunDelayMinutes: 30,
+    autoStepDelaySeconds: null,
+    mailProvider: '163',
+    emailGenerator: 'duck',
+    gmailBaseEmail: '',
+    mail2925BaseEmail: '',
+    emailPrefix: 'demo',
+    inbucketHost: '',
+    inbucketMailbox: '',
+    cloudflareDomain: '',
+    cloudflareDomains: [],
+    tabRegistry: {},
+    sourceLastUrls: {},
+    autoRunRoundSummaries: [],
+  };
+
+  const runtime = {
+    state: {
+      autoRunActive: false,
+      autoRunCurrentRun: 0,
+      autoRunTotalRuns: 1,
+      autoRunAttemptRun: 0,
+      autoRunSessionId: 0,
+    },
+    get() {
+      return { ...this.state };
+    },
+    set(updates = {}) {
+      this.state = { ...this.state, ...updates };
+    },
+  };
+
+  let sessionSeed = 100;
+  const broadcastAutoRunStatus = async (phase, payload = {}, extraState = {}) => {
+    events.broadcasts.push({ phase, ...payload });
+    currentState = {
+      ...currentState,
+      ...extraState,
+      autoRunning: ['scheduled', 'running', 'waiting_step', 'waiting_email', 'retrying', 'waiting_interval'].includes(phase),
+      autoRunPhase: phase,
+      autoRunCurrentRun: payload.currentRun ?? runtime.state.autoRunCurrentRun,
+      autoRunTotalRuns: payload.totalRuns ?? runtime.state.autoRunTotalRuns,
+      autoRunAttemptRun: payload.attemptRun ?? runtime.state.autoRunAttemptRun,
+      autoRunSessionId: payload.sessionId ?? runtime.state.autoRunSessionId,
+    };
+  };
+
+  const controller = api.createAutoRunController({
+    addLog: async (message, level = 'info') => {
+      events.logs.push({ message, level });
+    },
+    appendAccountRunRecord: async (status, _state, reason) => {
+      events.accountRecords.push({ status, reason });
+      return { status, reason };
+    },
+    AUTO_RUN_MAX_RETRIES_PER_ROUND: 3,
+    AUTO_RUN_RETRY_DELAY_MS: 3000,
+    AUTO_RUN_TIMER_KIND_BEFORE_RETRY: 'before_retry',
+    AUTO_RUN_TIMER_KIND_BETWEEN_ROUNDS: 'between_rounds',
+    broadcastAutoRunStatus,
+    broadcastStopToContentScripts: async () => {},
+    cancelPendingCommands: () => {},
+    chooseAddPhonePauseMinutes: () => 30,
+    clearStopRequest: () => {},
+    createAutoRunSessionId: () => {
+      sessionSeed += 1;
+      return sessionSeed;
+    },
+    getAutoRunStatusPayload: (phase, payload = {}) => ({
+      autoRunning: ['scheduled', 'running', 'waiting_step', 'waiting_email', 'retrying', 'waiting_interval'].includes(phase),
+      autoRunPhase: phase,
+      autoRunCurrentRun: payload.currentRun ?? 0,
+      autoRunTotalRuns: payload.totalRuns ?? 1,
+      autoRunAttemptRun: payload.attemptRun ?? 0,
+      autoRunSessionId: payload.sessionId ?? 0,
+    }),
+    getErrorMessage: (error) => error?.message || String(error || ''),
+    getFirstUnfinishedStep: () => 1,
+    getPendingAutoRunTimerPlan: () => null,
+    getRunningSteps: () => [],
+    getState: async () => ({
+      ...currentState,
+      stepStatuses: { ...(currentState.stepStatuses || {}) },
+      tabRegistry: { ...(currentState.tabRegistry || {}) },
+      sourceLastUrls: { ...(currentState.sourceLastUrls || {}) },
+    }),
+    getStopRequested: () => false,
+    hasSavedProgress: () => false,
+    isAddPhoneAuthFailure: (error) => /add-phone|手机号页面|手机号页|手机号码|手机号/i.test(error?.message || String(error || '')),
+    isRestartCurrentAttemptError: () => false,
+    isStopError: (error) => (error?.message || String(error || '')) === '流程已被用户停止。',
+    launchAutoRunTimerPlan: async () => false,
+    normalizeAutoRunFallbackThreadIntervalMinutes: (value) => Math.max(0, Math.floor(Number(value) || 0)),
+    persistAutoRunTimerPlan: async (plan, extraState = {}) => {
+      events.timerPlans.push({ plan, extraState });
+      await broadcastAutoRunStatus('waiting_interval', {
+        currentRun: plan.currentRun,
+        totalRuns: plan.totalRuns,
+        attemptRun: plan.attemptRun,
+        sessionId: plan.autoRunSessionId,
+      }, {
+        ...extraState,
+        autoRunTimerPlan: plan,
+      });
+      return plan;
+    },
+    resetState: async () => {
+      currentState = {
+        ...currentState,
+        stepStatuses: {},
+        tabRegistry: {},
+        sourceLastUrls: {},
+      };
+    },
+    runAutoSequenceFromStep: async () => {
+      events.runCalls += 1;
+      throw new Error('步骤 8:验证码提交后页面进入手机号页面,当前流程无法继续自动授权。 URL: https://auth.openai.com/add-phone');
+    },
+    runtime,
+    setState: async (updates = {}) => {
+      currentState = {
+        ...currentState,
+        ...updates,
+        stepStatuses: updates.stepStatuses ? { ...updates.stepStatuses } : currentState.stepStatuses,
+        tabRegistry: updates.tabRegistry ? { ...updates.tabRegistry } : currentState.tabRegistry,
+        sourceLastUrls: updates.sourceLastUrls ? { ...updates.sourceLastUrls } : currentState.sourceLastUrls,
+      };
+    },
+    sleepWithStop: async () => {},
+    throwIfAutoRunSessionStopped: (sessionId) => {
+      if (sessionId && sessionId !== runtime.state.autoRunSessionId) {
+        throw new Error('流程已被用户停止。');
+      }
+    },
+    waitForRunningStepsToFinish: async () => currentState,
+    chrome: {
+      runtime: {
+        sendMessage() {
+          return Promise.resolve();
+        },
+      },
+    },
+  });
+  await controller.autoRunLoop(2, {
+    autoRunSkipFailures: false,
+    mode: 'restart',
+  });
+
+  assert.equal(events.runCalls, 1, 'add-phone should stop current round immediately without retrying the same round');
+  assert.equal(events.accountRecords.length, 1, 'failed round should still be recorded');
+  assert.equal(events.accountRecords[0].status, 'failed');
+  assert.equal(events.timerPlans.length, 1, 'should schedule a delayed continue timer');
+  assert.equal(events.timerPlans[0].plan.kind, 'between_rounds');
+  assert.equal(events.timerPlans[0].plan.currentRun, 1);
+  assert.equal(events.timerPlans[0].plan.totalRuns, 2);
+  assert.equal(events.timerPlans[0].plan.countdownTitle, '手机号冷却中');
+  assert.match(events.logs.find(({ message }) => /等待 30 分钟后继续下一轮/.test(message))?.message || '', /等待 30 分钟后继续下一轮/);
+  assert.equal(runtime.state.autoRunActive, false);
+  assert.equal(events.broadcasts.some(({ phase }) => phase === 'retrying'), false, 'should not retry same round after add-phone');
+  assert.equal(events.broadcasts.some(({ phase }) => phase === 'waiting_interval'), true, 'should enter waiting_interval phase');
+});

+ 40 - 0
tests/background-icloud-mail-provider.test.js

@@ -143,3 +143,43 @@ return { getMailConfig };
     navigateOnReuse: true,
   });
 });
+
+test('getMailConfig keeps existing A4Sky mailbox tab instead of reopening it', () => {
+  const bundle = extractFunction('getMailConfig');
+  const api = new Function(`
+const ICLOUD_PROVIDER = 'icloud';
+const GMAIL_PROVIDER = 'gmail';
+const A4SKY_PROVIDER = 'a4sky';
+const HOTMAIL_PROVIDER = 'hotmail-api';
+const LUCKMAIL_PROVIDER = 'luckmail-api';
+const CLOUDFLARE_TEMP_EMAIL_PROVIDER = 'cloudflare-temp-email';
+function normalizeIcloudHost(value = '') {
+  const normalized = String(value || '').trim().toLowerCase();
+  return normalized === 'icloud.com' || normalized === 'icloud.com.cn' ? normalized : '';
+}
+function normalizeInbucketOrigin(value) { return String(value || '').trim(); }
+function getConfiguredIcloudHostPreference() {
+  return '';
+}
+function getIcloudLoginUrlForHost(host) {
+  return host === 'icloud.com.cn' ? 'https://www.icloud.com.cn/' : 'https://www.icloud.com/';
+}
+function getIcloudMailUrlForHost(host) {
+  return host === 'icloud.com.cn' ? 'https://www.icloud.com.cn/mail/' : 'https://www.icloud.com/mail/';
+}
+${bundle}
+return { getMailConfig };
+`)();
+
+  assert.deepEqual(api.getMailConfig({
+    mailProvider: 'a4sky',
+  }), {
+    provider: 'a4sky',
+    source: 'mail-phplife',
+    url: 'https://mail.phplife.net/?_task=mail&_mbox=INBOX',
+    label: 'A4Sky 邮箱(mail.phplife.net)',
+    navigateOnReuse: false,
+    inject: ['content/activation-utils.js', 'content/utils.js', 'content/phplife-mail.js'],
+    injectSource: 'mail-phplife',
+  });
+});

+ 59 - 1
tests/background-signup-step2-branching.test.js

@@ -84,6 +84,61 @@ test('step 2 keeps password flow when landing on password page', async () => {
   ]);
 });
 
+test('step 2 retries once after post-email landing check fails', async () => {
+  const completedPayloads = [];
+  let entryReadyCalls = 0;
+  let sendCalls = 0;
+  let landingCalls = 0;
+
+  const executor = step2Api.createStep2Executor({
+    addLog: async () => {},
+    chrome: { tabs: { update: async () => {} } },
+    completeStepFromBackground: async (step, payload) => {
+      completedPayloads.push({ step, payload });
+    },
+    ensureContentScriptReadyOnTab: async () => {},
+    ensureSignupEntryPageReady: async () => {
+      entryReadyCalls += 1;
+      return { tabId: 13 };
+    },
+    ensureSignupPostEmailPageReadyInTab: async () => {
+      landingCalls += 1;
+      if (landingCalls === 1) {
+        throw new Error('等待邮箱提交后的页面跳转超时,请检查页面是否仍停留在邮箱输入页。');
+      }
+      return {
+        state: 'password_page',
+        url: 'https://auth.openai.com/create-account/password',
+      };
+    },
+    getTabId: async () => 13,
+    isTabAlive: async () => true,
+    resolveSignupEmailForFlow: async () => 'user@example.com',
+    sendToContentScriptResilient: async () => {
+      sendCalls += 1;
+      return { submitted: true };
+    },
+    SIGNUP_PAGE_INJECT_FILES: [],
+  });
+
+  await executor.executeStep2({ email: 'user@example.com' });
+
+  assert.equal(entryReadyCalls, 1);
+  assert.equal(sendCalls, 2);
+  assert.equal(landingCalls, 2);
+  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;
@@ -175,6 +230,7 @@ test('signup flow helper reuses existing managed alias email when it is still co
 test('signup flow helper finalizes step 3 submit by reusing signup verification preparation', async () => {
   let ensureCalls = 0;
   const messages = [];
+  const sendOptions = [];
 
   const helpers = signupFlowApi.createSignupFlowHelpers({
     buildGeneratedAliasEmail: () => '',
@@ -192,8 +248,9 @@ test('signup flow helper finalizes step 3 submit by reusing signup verification
     isSignupEmailVerificationPageUrl: () => false,
     isSignupPasswordPageUrl: () => true,
     reuseOrCreateTab: async () => 31,
-    sendToContentScriptResilient: async (_source, message) => {
+    sendToContentScriptResilient: async (_source, message, options) => {
       messages.push({ type: 'send', message });
+      sendOptions.push(options);
       return { ready: true, retried: 1 };
     },
     setEmailState: async () => {},
@@ -216,4 +273,5 @@ test('signup flow helper finalizes step 3 submit by reusing signup verification
       prepareLogLabel: '步骤 3 收尾',
     },
   });
+  assert.equal(sendOptions[0]?.timeoutMs, 65000);
 });

+ 47 - 0
tests/background-step4-a4sky-no-prefetch.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/fetch-signup-code.js', 'utf8');
+const globalScope = {};
+const api = new Function('self', `${source}; return self.MultiPageBackgroundStep4;`)(globalScope);
+
+test('step 4 does not request a fresh code first for A4Sky mailbox', async () => {
+  let capturedOptions = null;
+
+  const executor = api.createStep4Executor({
+    A4SKY_PROVIDER: 'a4sky',
+    addLog: async () => {},
+    chrome: { tabs: { update: async () => {} } },
+    completeStepFromBackground: async () => {},
+    confirmCustomVerificationStepBypass: async () => {},
+    getMailConfig: () => ({
+      provider: 'a4sky',
+      label: 'A4Sky 邮箱(mail.phplife.net)',
+      source: 'mail-phplife',
+      url: 'https://mail.phplife.net/?_task=mail&_mbox=INBOX',
+      navigateOnReuse: false,
+    }),
+    getTabId: async (sourceName) => (sourceName === 'signup-page' ? 1 : 2),
+    HOTMAIL_PROVIDER: 'hotmail-api',
+    isTabAlive: async () => true,
+    LUCKMAIL_PROVIDER: 'luckmail-api',
+    CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
+    resolveVerificationStep: async (_step, _state, _mail, options) => {
+      capturedOptions = options;
+    },
+    reuseOrCreateTab: async () => {},
+    sendToContentScriptResilient: async () => ({ ready: true }),
+    shouldUseCustomRegistrationEmail: () => false,
+    STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000,
+    throwIfStopped: () => {},
+  });
+
+  await executor.executeStep4({
+    email: 'n20260419100609@a4sky.com',
+    password: 'Secret123!',
+  });
+
+  assert.equal(capturedOptions.requestFreshCodeFirst, false);
+  assert.equal(capturedOptions.resendIntervalMs, 25000);
+});

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

@@ -98,3 +98,65 @@ test('tab runtime waitForTabComplete aborts promptly when stop is requested', as
     /Flow stopped\./
   );
 });
+
+test('tab runtime reuses an existing matching tab when registry was cleared', async () => {
+  const source = fs.readFileSync('background/tab-runtime.js', 'utf8');
+  const globalScope = {};
+  const api = new Function('self', `${source}; return self.MultiPageBackgroundTabRuntime;`)(globalScope);
+
+  const tabs = [
+    {
+      id: 21,
+      url: 'https://mail.phplife.net/?_task=mail&_mbox=INBOX',
+      status: 'complete',
+      active: false,
+    },
+  ];
+  let state = { tabRegistry: {}, sourceLastUrls: {} };
+  let createCalls = 0;
+
+  const runtime = api.createTabRuntime({
+    LOG_PREFIX: '[test]',
+    addLog: async () => {},
+    chrome: {
+      tabs: {
+        get: async (tabId) => {
+          const tab = tabs.find((item) => item.id === tabId);
+          if (!tab) {
+            throw new Error('tab not found');
+          }
+          return { ...tab };
+        },
+        query: async () => tabs.map((tab) => ({ ...tab })),
+        update: async (tabId, updates) => {
+          const tab = tabs.find((item) => item.id === tabId);
+          Object.assign(tab, updates);
+          return { ...tab };
+        },
+        create: async () => {
+          createCalls += 1;
+          throw new Error('should not create a new tab');
+        },
+        remove: async () => {},
+      },
+    },
+    getSourceLabel: (sourceName) => sourceName || 'unknown',
+    getState: async () => state,
+    matchesSourceUrlFamily: (sourceName, candidateUrl, referenceUrl) => (
+      sourceName === 'mail-phplife' && candidateUrl === referenceUrl
+    ),
+    setState: async (updates) => {
+      state = { ...state, ...updates };
+    },
+    throwIfStopped: () => {},
+  });
+
+  const tabId = await runtime.reuseOrCreateTab(
+    'mail-phplife',
+    'https://mail.phplife.net/?_task=mail&_mbox=INBOX'
+  );
+
+  assert.equal(tabId, 21);
+  assert.equal(createCalls, 0);
+  assert.equal(state.tabRegistry['mail-phplife']?.tabId, 21);
+});

+ 70 - 0
tests/phplife-mail-content.test.js

@@ -242,3 +242,73 @@ return {
   assert.equal(result.code, '998877');
   assert.equal(api.getOpenedCount(), 1);
 });
+
+test('phplife Chinese temporary login title also opens detail to extract verification code', async () => {
+  const bundle = [
+    extractFunction('normalizeText'),
+    extractFunction('normalizeMinuteTimestamp'),
+    extractFunction('shouldOpenRowForCodeDetection'),
+    extractFunction('selectCandidateCode'),
+    extractFunction('matchesCurrentMessage'),
+    extractFunction('scoreRowCandidate'),
+    extractFunction('tryOpenRowsAndRead'),
+  ].join('\n');
+
+  const api = new Function(`${bundle}
+let opened = 0;
+const seenCodes = new Set();
+function throwIfStopped() {}
+function getMessageListRows() {
+  return [{ id: 'row-1' }];
+}
+function getRowDetails() {
+  return {
+    row: { id: 'row-1' },
+    subject: '你的临时 ChatGPT 登录代码',
+    from: 'noreply@tm.openai.com',
+    to: 'n2026041807@a4sky.com',
+    dateText: '今天 12:30',
+    emailTimestamp: Date.now(),
+    codes: [],
+    combinedText: '你的临时 ChatGPT 登录代码 noreply@tm.openai.com n2026041807@a4sky.com',
+  };
+}
+function matchesMailFilters() {
+  return true;
+}
+function getTargetEmailMatchState() {
+  return { matches: true, hasExplicitEmail: true };
+}
+function log() {}
+function getCurrentMessageUid() {
+  return '';
+}
+function openMessageRow() {
+  opened += 1;
+}
+async function sleep() {}
+async function waitForPreviewLoaded() {
+  return {
+    subject: '你的临时 ChatGPT 登录代码',
+    combinedText: '你的临时 ChatGPT 登录代码 112233 noreply@tm.openai.com n2026041807@a4sky.com',
+    emailTimestamp: Date.now(),
+    codes: ['112233'],
+  };
+}
+return {
+  tryOpenRowsAndRead,
+  getOpenedCount() {
+    return opened;
+  },
+};
+`)();
+
+  const result = await api.tryOpenRowsAndRead(4, {
+    senderFilters: ['openai'],
+    subjectFilters: ['login', 'code', '登录', '代码'],
+    targetEmail: 'n2026041807@a4sky.com',
+  }, new Set(), 0);
+
+  assert.equal(result.code, '112233');
+  assert.equal(api.getOpenedCount(), 1);
+});

+ 5 - 0
tests/signup-page-tab-cleanup.test.js

@@ -153,6 +153,11 @@ return {
     true,
     'signup-page family should include legacy chat.openai.com'
   );
+  assert.strictEqual(
+    api.matchesSourceUrlFamily('mail-phplife', 'https://mail.phplife.net/?_task=mail&_mbox=INBOX', 'https://mail.phplife.net/?_task=mail&_mbox=INBOX'),
+    true,
+    'mail-phplife family should include mail.phplife.net'
+  );
 
   api.reset({
     tabs: [