Просмотр исходного кода

feat: add Gmail API polling and verification fixes

chendeben 4 месяцев назад
Родитель
Сommit
4e2e7f2cdd
15 измененных файлов с 5614 добавлено и 4880 удалено
  1. 161 161
      README.md
  2. 1492 1170
      background.js
  3. 0 113
      content/duck-mail.js
  4. 301 0
      content/gmail-mail.js
  5. 258 258
      content/inbucket-mail.js
  6. 299 299
      content/mail-163.js
  7. 193 193
      content/qq-mail.js
  8. 766 703
      content/signup-page.js
  9. 331 331
      content/utils.js
  10. 187 187
      content/vps-panel.js
  11. 38 38
      data/names.js
  12. 54 61
      manifest.json
  13. 669 669
      sidepanel/sidepanel.css
  14. 208 168
      sidepanel/sidepanel.html
  15. 657 529
      sidepanel/sidepanel.js

+ 161 - 161
README.md

@@ -1,161 +1,161 @@
-# Auto Register GPT Duck
-
-批量自动完成 ChatGPT OAuth 注册/登录流程的 Chrome 扩展。
-
-通过侧边栏控制,支持单步执行、多轮自动运行、中途停止,以及多种验证码接收方式。
-
-## 当前能力
-
-- 从 VPS 面板自动获取 OpenAI OAuth 授权链接
-- 自动打开 OpenAI 注册页并点击 Sign up
-- 自动填写邮箱与密码(支持自定义或自动生成强密码)
-- 自动获取注册验证码与登录验证码
-- 验证码来源支持:
-  - **Temp Email API**(推荐)— 通过 HTTP API 直接轮询,无需打开邮件页面
-  - QQ Mail / 163 Mail / Inbucket mailbox
-- DuckDuckGo Email Protection:
-  - 首次运行自动提取 access_token 并保存
-  - 后续运行直接调用 API 生成 `@duck.com` 地址,无需打开页面
-- Step 5 兼容 birthday 和 age 两种页面
-- Step 7 自动检测电话验证页面,跳过当前账号继续下一轮
-- Step 8 通过 content script 直接点击"继续"按钮,无需 debugger,不会触发浏览器"被控制调试"提示
-- 支持 Auto 多轮运行 + 中途 Stop
-
-## 环境要求
-
-- Chrome 浏览器,开启扩展开发者模式
-- 你自己的 VPS 管理面板(页面结构需与脚本适配)
-- 至少准备一种验证码接收方式:
-  - Temp Email API(推荐)
-  - DuckDuckGo `@duck.com` + QQ / 163 / Inbucket 转发
-
-## 安装
-
-1. 打开 `chrome://extensions/`
-2. 开启"开发者模式"
-3. 点击"加载已解压的扩展程序"
-4. 选择本项目目录
-5. 打开扩展侧边栏
-
-## 侧边栏配置
-
-### VPS
-
-管理面板 OAuth 页面地址:
-
-```
-http(s)://<your-host>/management.html#/oauth
-```
-
-Step 1 和 Step 9 都依赖这个地址。
-
-### Mail
-
-验证码来源,支持四种:
-
-| 选项 | 说明 |
-|------|------|
-| **Temp Email API** | 通过 HTTP API 直接轮询邮件,粘贴完整 URL(含 JWT)即可,无需打开邮件页面 |
-| 163 Mail | 轮询 163 网页邮箱 |
-| QQ Mail | 轮询 QQ 网页邮箱 |
-| Inbucket | 通过自定义 host + mailbox 轮询 |
-
-#### Temp Email API(推荐)
-
-选择后会出现 API URL 输入框,粘贴完整地址即可:
-
-```
-https://temp-email-api.xxx.cc/?jwt=eyJhbGci...
-```
-
-API 需要支持以下端点:
-- `GET /api/mails?limit=N&offset=0`(Authorization: Bearer JWT)
-- 返回格式:`{ results: [{ id, source, raw, created_at }] }`
-
-#### Inbucket
-
-需要填写 host 和 mailbox 名称,脚本会打开 `https://<host>/m/<mailbox>/` 轮询。
-
-### Email
-
-Step 3 使用的注册邮箱:
-
-- 手动粘贴
-- 点击 Auto — 通过 DuckDuckGo API 自动生成 `@duck.com` 地址
-
-首次使用 Auto 需要打开 DuckDuckGo 页面提取 token,之后直接调 API 生成。
-
-### Password
-
-- 留空:自动生成强密码
-- 手动输入:使用自定义密码
-- 可通过 Show/Hide 切换显示
-
-## 工作流(9 步)
-
-| 步骤 | 名称 | 说明 |
-|------|------|------|
-| 1 | Get OAuth Link | 从 VPS 面板获取 OAuth 授权链接 |
-| 2 | Open Signup | 打开授权链接,点击注册 |
-| 3 | Fill Email/Password | 填写邮箱和密码,提交注册 |
-| 4 | Get Signup Code | 轮询邮箱获取注册验证码并填入 |
-| 5 | Fill Name/Birthday | 随机生成姓名和生日/年龄并填写 |
-| 6 | Login via OAuth | 用注册账号登录 OAuth |
-| 7 | Get Login Code | 轮询邮箱获取登录验证码并填入 |
-| 8 | OAuth Auto Confirm | 点击 OAuth 同意页"继续",捕获 localhost 回调 |
-| 9 | VPS Verify | 回到 VPS 面板提交回调 URL,等待认证成功 |
-
-### Auto 模式
-
-点击 Auto 后自动执行完整流程,支持多轮运行。每轮:
-
-1. 获取 OAuth 链接 + 打开注册页
-2. 自动生成 Duck 邮箱(API 方式,无需开页面)
-3. 执行 Step 3 ~ Step 9
-4. 失败自动跳到下一轮,手动 Stop 才会终止
-
-遇到电话验证页面会自动跳过当前账号。
-
-## Duck 邮箱自动获取
-
-采用两阶段策略:
-
-1. **首次运行**:打开 DuckDuckGo Email Protection 设置页,通过 content script 调用 dashboard API 提取 `access_token`,保存到持久化存储
-2. **后续运行**:直接调用 `quack.duckduckgo.com/api/email/addresses` 生成新地址,不再打开页面
-
-token 失效时会自动回退到页面方式重新提取。
-
-## 停止机制
-
-- 侧边栏点击 Stop
-- Background 广播 STOP_FLOW
-- 各 content script 在等待、轮询、sleep 中尽量中断
-
-## 项目结构
-
-```
-background.js              后台主控,编排 1~9 步、Tab 复用、状态管理
-manifest.json              扩展清单
-data/names.js              随机姓名、生日数据
-content/utils.js           通用工具:等待元素、点击、日志、停止控制
-content/vps-panel.js       VPS 面板步骤:Step 1 / Step 9
-content/signup-page.js     OpenAI 注册/登录页步骤:Step 2 / 3 / 5 / 6 / 8
-content/duck-mail.js       Duck 邮箱获取 + token 提取
-content/qq-mail.js         QQ 邮箱验证码轮询
-content/mail-163.js        163 邮箱验证码轮询
-content/inbucket-mail.js   Inbucket mailbox 验证码轮询
-sidepanel/                 侧边栏 UI
-```
-
-## 状态与数据
-
-- 运行时状态使用 `chrome.storage.session`
-- VPS 地址、邮件配置、Duck token 等持久化到 `chrome.storage.local`
-- 所有数据仅存在本地浏览器中,无外部上报
-
-## 调试建议
-
-- 侧边栏 Console 区域查看实时日志
-- Service Worker 控制台查看 background 日志
-- 目标页面的 DevTools Console 查看 content script 日志
-- 某一步频繁失败时,优先检查页面选择器是否匹配
+# Auto Register GPT Duck
+
+批量自动完成 ChatGPT OAuth 注册/登录流程的 Chrome 扩展。
+
+通过侧边栏控制,支持单步执行、多轮自动运行、中途停止,以及多种验证码接收方式。
+
+## 当前能力
+
+- 从 VPS 面板自动获取 OpenAI OAuth 授权链接
+- 自动打开 OpenAI 注册页并点击 Sign up
+- 自动填写邮箱与密码(支持自定义或自动生成强密码)
+- 自动获取注册验证码与登录验证码
+- 验证码来源支持:
+  - **Temp Email API**(推荐)— 通过 HTTP API 直接轮询,无需打开邮件页面
+  - QQ Mail / 163 Mail / Inbucket mailbox
+- DuckDuckGo Email Protection:
+  - 首次运行自动提取 access_token 并保存
+  - 后续运行直接调用 API 生成 `@duck.com` 地址,无需打开页面
+- Step 5 兼容 birthday 和 age 两种页面
+- Step 7 自动检测电话验证页面,跳过当前账号继续下一轮
+- Step 8 通过 content script 直接点击"继续"按钮,无需 debugger,不会触发浏览器"被控制调试"提示
+- 支持 Auto 多轮运行 + 中途 Stop
+
+## 环境要求
+
+- Chrome 浏览器,开启扩展开发者模式
+- 你自己的 VPS 管理面板(页面结构需与脚本适配)
+- 至少准备一种验证码接收方式:
+  - Temp Email API(推荐)
+  - DuckDuckGo `@duck.com` + QQ / 163 / Inbucket 转发
+
+## 安装
+
+1. 打开 `chrome://extensions/`
+2. 开启"开发者模式"
+3. 点击"加载已解压的扩展程序"
+4. 选择本项目目录
+5. 打开扩展侧边栏
+
+## 侧边栏配置
+
+### VPS
+
+管理面板 OAuth 页面地址:
+
+```
+http(s)://<your-host>/management.html#/oauth
+```
+
+Step 1 和 Step 9 都依赖这个地址。
+
+### Mail
+
+验证码来源,支持四种:
+
+| 选项 | 说明 |
+|------|------|
+| **Temp Email API** | 通过 HTTP API 直接轮询邮件,粘贴完整 URL(含 JWT)即可,无需打开邮件页面 |
+| 163 Mail | 轮询 163 网页邮箱 |
+| QQ Mail | 轮询 QQ 网页邮箱 |
+| Inbucket | 通过自定义 host + mailbox 轮询 |
+
+#### Temp Email API(推荐)
+
+选择后会出现 API URL 输入框,粘贴完整地址即可:
+
+```
+https://temp-email-api.xxx.cc/?jwt=eyJhbGci...
+```
+
+API 需要支持以下端点:
+- `GET /api/mails?limit=N&offset=0`(Authorization: Bearer JWT)
+- 返回格式:`{ results: [{ id, source, raw, created_at }] }`
+
+#### Inbucket
+
+需要填写 host 和 mailbox 名称,脚本会打开 `https://<host>/m/<mailbox>/` 轮询。
+
+### Email
+
+Step 3 使用的注册邮箱:
+
+- 手动粘贴
+- 点击 Auto — 通过 DuckDuckGo API 自动生成 `@duck.com` 地址
+
+首次使用 Auto 需要打开 DuckDuckGo 页面提取 token,之后直接调 API 生成。
+
+### Password
+
+- 留空:自动生成强密码
+- 手动输入:使用自定义密码
+- 可通过 Show/Hide 切换显示
+
+## 工作流(9 步)
+
+| 步骤 | 名称 | 说明 |
+|------|------|------|
+| 1 | Get OAuth Link | 从 VPS 面板获取 OAuth 授权链接 |
+| 2 | Open Signup | 打开授权链接,点击注册 |
+| 3 | Fill Email/Password | 填写邮箱和密码,提交注册 |
+| 4 | Get Signup Code | 轮询邮箱获取注册验证码并填入 |
+| 5 | Fill Name/Birthday | 随机生成姓名和生日/年龄并填写 |
+| 6 | Login via OAuth | 用注册账号登录 OAuth |
+| 7 | Get Login Code | 轮询邮箱获取登录验证码并填入 |
+| 8 | OAuth Auto Confirm | 点击 OAuth 同意页"继续",捕获 localhost 回调 |
+| 9 | VPS Verify | 回到 VPS 面板提交回调 URL,等待认证成功 |
+
+### Auto 模式
+
+点击 Auto 后自动执行完整流程,支持多轮运行。每轮:
+
+1. 获取 OAuth 链接 + 打开注册页
+2. 自动生成 Duck 邮箱(API 方式,无需开页面)
+3. 执行 Step 3 ~ Step 9
+4. 失败自动跳到下一轮,手动 Stop 才会终止
+
+遇到电话验证页面会自动跳过当前账号。
+
+## Duck 邮箱自动获取
+
+采用两阶段策略:
+
+1. **首次运行**:打开 DuckDuckGo Email Protection 设置页,通过 content script 调用 dashboard API 提取 `access_token`,保存到持久化存储
+2. **后续运行**:直接调用 `quack.duckduckgo.com/api/email/addresses` 生成新地址,不再打开页面
+
+token 失效时会自动回退到页面方式重新提取。
+
+## 停止机制
+
+- 侧边栏点击 Stop
+- Background 广播 STOP_FLOW
+- 各 content script 在等待、轮询、sleep 中尽量中断
+
+## 项目结构
+
+```
+background.js              后台主控,编排 1~9 步、Tab 复用、状态管理
+manifest.json              扩展清单
+data/names.js              随机姓名、生日数据
+content/utils.js           通用工具:等待元素、点击、日志、停止控制
+content/vps-panel.js       VPS 面板步骤:Step 1 / Step 9
+content/signup-page.js     OpenAI 注册/登录页步骤:Step 2 / 3 / 5 / 6 / 8
+content/duck-mail.js       Duck 邮箱获取 + token 提取
+content/qq-mail.js         QQ 邮箱验证码轮询
+content/mail-163.js        163 邮箱验证码轮询
+content/inbucket-mail.js   Inbucket mailbox 验证码轮询
+sidepanel/                 侧边栏 UI
+```
+
+## 状态与数据
+
+- 运行时状态使用 `chrome.storage.session`
+- VPS 地址、邮件配置、Duck token 等持久化到 `chrome.storage.local`
+- 所有数据仅存在本地浏览器中,无外部上报
+
+## 调试建议
+
+- 侧边栏 Console 区域查看实时日志
+- Service Worker 控制台查看 background 日志
+- 目标页面的 DevTools Console 查看 content script 日志
+- 某一步频繁失败时,优先检查页面选择器是否匹配

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


+ 0 - 113
content/duck-mail.js

@@ -1,113 +0,0 @@
-// content/duck-mail.js — Content script for DuckDuckGo Email Protection autofill settings
-
-console.log('[MultiPage:duck-mail] Content script loaded on', location.href);
-
-chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
-  if (message.type === 'FETCH_DUCK_EMAIL') {
-    resetStopState();
-    fetchDuckEmail(message.payload).then(result => {
-      sendResponse(result);
-    }).catch(err => {
-      if (isStopError(err)) {
-        log('Duck Mail: Stopped by user.', 'warn');
-        sendResponse({ stopped: true, error: err.message });
-        return;
-      }
-      sendResponse({ error: err.message });
-    });
-    return true;
-  }
-
-  if (message.type === 'EXTRACT_DUCK_TOKEN') {
-    extractDuckToken().then(result => {
-      sendResponse(result);
-    }).catch(err => {
-      sendResponse({ error: err.message });
-    });
-    return true;
-  }
-});
-
-async function extractDuckToken() {
-  log('Duck Mail: Extracting access token...');
-
-  // The DuckDuckGo email app stores userData in a React state.
-  // We can intercept it by reading the __NEXT_DATA__ or by calling their API
-  // with cookies that the browser already has.
-  // Strategy: call the dashboard API endpoint which returns user info including token.
-
-  // Strategy 1: Try to find token in page's fetch calls by hooking into the app state
-  // The app calls quack.duckduckgo.com/api/email/dashboard with credentials
-  try {
-    const resp = await fetch('https://quack.duckduckgo.com/api/email/dashboard', {
-      credentials: 'include',
-    });
-    if (resp.ok) {
-      const data = await resp.json();
-      // Response has { user: { access_token, username, ... } }
-      const token = data?.user?.access_token;
-      const username = data?.user?.username;
-      if (token) {
-        log(`Duck Mail: Token extracted for ${username}`, 'ok');
-        return { token, username };
-      }
-    }
-  } catch (e) {
-    log(`Duck Mail: Dashboard API failed: ${e.message}`, 'warn');
-  }
-
-  throw new Error('Could not extract DuckDuckGo access token. Make sure you are logged in.');
-}
-
-async function fetchDuckEmail(payload = {}) {
-  const { generateNew = true } = payload;
-
-  log(`Duck Mail: ${generateNew ? 'Generating' : 'Reading'} private address...`);
-
-  await waitForElement(
-    'input.AutofillSettingsPanel__PrivateDuckAddressValue, button.AutofillSettingsPanel__GeneratorButton',
-    15000
-  );
-
-  const getAddressInput = () => document.querySelector('input.AutofillSettingsPanel__PrivateDuckAddressValue');
-  const getGeneratorButton = () => document.querySelector('button.AutofillSettingsPanel__GeneratorButton')
-    || Array.from(document.querySelectorAll('button')).find(btn => /generate private duck address/i.test(btn.textContent || ''));
-  const readEmail = () => {
-    const value = getAddressInput()?.value?.trim() || '';
-    return value.includes('@duck.com') ? value : '';
-  };
-
-  const waitForEmailValue = async (previousValue = '') => {
-    for (let i = 0; i < 100; i++) {
-      const nextValue = readEmail();
-      if (nextValue && nextValue !== previousValue) {
-        return nextValue;
-      }
-      await sleep(150);
-    }
-    throw new Error('Timed out waiting for Duck address to appear.');
-  };
-
-  const currentEmail = readEmail();
-  if (currentEmail && !generateNew) {
-    log(`Duck Mail: Found existing address ${currentEmail}`);
-    return { email: currentEmail, generated: false };
-  }
-
-  await humanPause(500, 1300);
-  const generatorButton = getGeneratorButton();
-  if (!generatorButton) {
-    if (currentEmail) {
-      log(`Duck Mail: Reusing existing address ${currentEmail}`, 'warn');
-      return { email: currentEmail, generated: false };
-    }
-    throw new Error('Could not find "Generate Private Duck Address" button.');
-  }
-
-  generatorButton.click();
-  log('Duck Mail: Clicked "Generate Private Duck Address"');
-
-  const nextEmail = await waitForEmailValue(currentEmail);
-  log(`Duck Mail: Ready address ${nextEmail}`, 'ok');
-  return { email: nextEmail, generated: true };
-}

+ 301 - 0
content/gmail-mail.js

@@ -0,0 +1,301 @@
+// content/gmail-mail.js — Content script for Gmail inbox polling (steps 4, 7)
+// Injected on: mail.google.com
+
+const GMAIL_PREFIX = '[MultiPage:gmail-mail]';
+const isTopFrame = window === window.top;
+
+console.log(GMAIL_PREFIX, 'Content script loaded on', location.href, 'frame:', isTopFrame ? 'top' : 'child');
+
+if (isTopFrame) {
+  reportReady();
+}
+
+chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
+  if (message.type === 'POLL_EMAIL') {
+    if (!isTopFrame) {
+      sendResponse({ ok: false, reason: 'wrong-frame' });
+      return;
+    }
+    resetStopState();
+    handlePollEmail(message.step, message.payload).then(result => {
+      sendResponse(result);
+    }).catch(err => {
+      if (isStopError(err)) {
+        log(`Step ${message.step}: Stopped by user.`, 'warn');
+        sendResponse({ stopped: true, error: err.message });
+        return;
+      }
+      reportError(message.step, err.message);
+      sendResponse({ error: err.message });
+    });
+    return true;
+  }
+});
+
+function getInboxRows() {
+  return Array.from(document.querySelectorAll('tr.zA'));
+}
+
+function getRowId(row, index = 0) {
+  return row.getAttribute('data-legacy-message-id')
+    || row.getAttribute('data-legacy-thread-id')
+    || row.dataset.threadPermId
+    || row.id
+    || `gmail-row-${index}`;
+}
+
+function getRowText(row) {
+  return [
+    row.getAttribute('aria-label') || '',
+    row.textContent || '',
+    row.querySelector('[email]')?.getAttribute('email') || '',
+    row.querySelector('[data-hovercard-id]')?.getAttribute('data-hovercard-id') || '',
+    row.querySelector('.yP')?.getAttribute('email') || '',
+    row.querySelector('.bA4 span')?.textContent || '',
+    row.querySelector('.bog')?.textContent || '',
+    row.querySelector('.y2')?.textContent || '',
+  ].join(' ').replace(/\s+/g, ' ').trim();
+}
+
+function extractVerificationCode(text) {
+  const matchCn = text.match(/(?:代码为|验证码[^0-9]*?)[\s::]*(\d{6})/);
+  if (matchCn) return matchCn[1];
+
+  const matchEn = text.match(/code[:\s]+is[:\s]+(\d{6})|code[:\s]+(\d{6})/i);
+  if (matchEn) return matchEn[1] || matchEn[2];
+
+  const match6 = text.match(/\b(\d{6})\b/);
+  if (match6) return match6[1];
+
+  return null;
+}
+
+function snapshotInboxState() {
+  const snapshot = new Map();
+  getInboxRows().forEach((row, index) => {
+    const id = getRowId(row, index);
+    snapshot.set(id, {
+      text: getRowText(row),
+      unread: isUnreadRow(row),
+    });
+  });
+  return snapshot;
+}
+
+async function applyTargetSearch(targetEmail) {
+  if (!targetEmail) return;
+
+  const searchInput = document.querySelector('input[placeholder*="Search mail" i], input[aria-label*="Search mail" i], input[placeholder*="搜索邮件"], input[aria-label*="搜索邮件"]');
+  if (!searchInput) return;
+
+  const query = `to:${targetEmail}`;
+  if ((searchInput.value || '').trim() === query) return;
+
+  fillInput(searchInput, query);
+  await sleep(200);
+  searchInput.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', bubbles: true }));
+  searchInput.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', code: 'Enter', bubbles: true }));
+  await sleep(1500);
+  log(`Applied Gmail search filter: ${query}`);
+}
+
+function getOpenedMessageText() {
+  const emailAttrs = Array.from(document.querySelectorAll('[email]'))
+    .map(el => el.getAttribute('email') || '')
+    .join(' ');
+  const hovercardIds = Array.from(document.querySelectorAll('[data-hovercard-id]'))
+    .map(el => el.getAttribute('data-hovercard-id') || '')
+    .join(' ');
+  const bodyText = document.body?.innerText || '';
+  return `${bodyText}\n${emailAttrs}\n${hovercardIds}`.toLowerCase();
+}
+
+async function openRowAndReadMessageText(row) {
+  const clickable = row.querySelector('.bog, .y6, td, div[role="link"]') || row;
+  simulateClick(clickable);
+
+  const start = Date.now();
+  while (Date.now() - start < 12000) {
+    const opened = document.querySelector('h2.hP, div[role="main"] .ii.gt');
+    if (opened) {
+      await sleep(800);
+      return getOpenedMessageText();
+    }
+    await sleep(200);
+  }
+
+  return '';
+}
+
+async function returnToInboxView(targetEmail = '') {
+  history.back();
+  const start = Date.now();
+  while (Date.now() - start < 10000) {
+    if (getInboxRows().length > 0) {
+      await sleep(400);
+      if (targetEmail) {
+        await applyTargetSearch(targetEmail);
+      }
+      return;
+    }
+    await sleep(200);
+  }
+}
+
+async function refreshInbox() {
+  const refreshBtn = document.querySelector('div[role="button"][data-tooltip="Refresh"], div[role="button"][aria-label*="Refresh"], div[role="button"][aria-label*="刷新"]');
+  if (refreshBtn) {
+    simulateClick(refreshBtn);
+    console.log(GMAIL_PREFIX, 'Clicked refresh button');
+    await sleep(1000);
+    return;
+  }
+
+  const inboxLink = Array.from(document.querySelectorAll('a[title], a[aria-label], div[role="link"]'))
+    .find(el => /inbox|收件箱/i.test((el.getAttribute('title') || '') + ' ' + (el.getAttribute('aria-label') || '') + ' ' + (el.textContent || '')));
+  if (inboxLink) {
+    simulateClick(inboxLink);
+    console.log(GMAIL_PREFIX, 'Clicked inbox link');
+    await sleep(1000);
+  }
+}
+
+function normalizeGmailAlias(email) {
+  const lower = String(email || '').trim().toLowerCase();
+  const match = lower.match(/^([^@+]+)(?:\+([^@]+))?@gmail\.com$/i);
+  if (!match) return { full: lower, base: lower, plus: '' };
+  return {
+    full: lower,
+    base: `${match[1]}@gmail.com`,
+    plus: match[2] || '',
+  };
+}
+
+function rowMatchesTargetEmail(combinedText, targetEmail) {
+  if (!targetEmail) return true;
+  const text = String(combinedText || '').toLowerCase();
+  const target = normalizeGmailAlias(targetEmail);
+  if (text.includes(target.full)) return true;
+  if (target.plus && text.includes(`+${target.plus}`)) return true;
+  return false;
+}
+
+function isUnreadRow(row) {
+  return row?.classList?.contains('zE');
+}
+
+async function handlePollEmail(step, payload) {
+  const { senderFilters, subjectFilters, maxAttempts, intervalMs, usedMailIds, usedCodes, targetEmail } = payload;
+  const usedMailIdSet = new Set((usedMailIds || []).map(String));
+  const usedCodeSet = new Set(usedCodes || []);
+
+  log(`Step ${step}: Starting email poll on Gmail (max ${maxAttempts} attempts, every ${intervalMs / 1000}s)`);
+
+  try {
+    await waitForElement('tr.zA, div[role="main"] table', 15000);
+    log(`Step ${step}: Gmail inbox loaded`);
+  } catch {
+    throw new Error('Gmail inbox did not load. Please open https://mail.google.com/ and ensure you are logged in.');
+  }
+
+  await applyTargetSearch(targetEmail);
+
+  const existingInboxState = snapshotInboxState();
+  log(`Step ${step}: Snapshotted ${existingInboxState.size} existing Gmail emails`);
+
+  const FALLBACK_AFTER = 6;
+
+  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
+    log(`Polling Gmail... attempt ${attempt}/${maxAttempts}`);
+
+    if (attempt > 1) {
+      await refreshInbox();
+      await sleep(1200);
+    }
+
+    const rows = getInboxRows();
+    const useFallback = attempt > FALLBACK_AFTER;
+
+    for (let index = 0; index < rows.length; index++) {
+      const row = rows[index];
+      const mailId = String(getRowId(row, index));
+      const previousState = existingInboxState.get(mailId);
+      const isNewRow = !previousState;
+      if (usedMailIdSet.has(mailId)) continue;
+
+      const combinedText = getRowText(row);
+      const lower = combinedText.toLowerCase();
+      const targetMatch = rowMatchesTargetEmail(combinedText, targetEmail);
+      const senderMatch = senderFilters.some(f => lower.includes(f.toLowerCase()));
+      const subjectMatch = subjectFilters.some(f => lower.includes(f.toLowerCase()));
+      const code = extractVerificationCode(combinedText);
+      const unread = isUnreadRow(row);
+      const rowStateChanged = Boolean(previousState) && (
+        previousState.text !== combinedText
+        || (!previousState.unread && unread)
+      );
+
+      if (!useFallback && !isNewRow && !rowStateChanged) continue;
+
+      if (rowStateChanged) {
+        log(`Step ${step}: Gmail thread updated for ${mailId}, re-checking row`, 'info');
+      }
+
+      if ((senderMatch || subjectMatch || code) && code && (targetMatch || isNewRow)) {
+        if (usedCodeSet.has(code)) {
+          log(`Step ${step}: Skipping already-used code ${code} (mailId: ${mailId})`);
+          continue;
+        }
+        const source = targetMatch
+          ? (useFallback && previousState ? 'fallback-first-match' : (isNewRow ? 'new' : 'updated-thread-match'))
+          : (isNewRow ? 'new-row-match' : 'updated-thread-match');
+        log(`Step ${step}: Code found: ${code} (${source}, row: ${combinedText.slice(0, 60)})`, 'ok');
+        return { ok: true, code, emailTimestamp: Date.now(), mailId };
+      }
+
+      if (code && !targetMatch && useFallback) {
+        const openedMessageText = await openRowAndReadMessageText(row);
+        const detailMatched = rowMatchesTargetEmail(openedMessageText, targetEmail);
+
+        if (detailMatched) {
+          if (usedCodeSet.has(code)) {
+            log(`Step ${step}: Skipping already-used code ${code} (mailId: ${mailId})`);
+            await returnToInboxView(targetEmail);
+            continue;
+          }
+          const source = useFallback && previousState ? 'fallback-opened-match' : 'opened-match';
+          log(`Step ${step}: Code found: ${code} (${source}, target alias matched in opened email)`, 'ok');
+          return { ok: true, code, emailTimestamp: Date.now(), mailId };
+        }
+
+        // As a last resort in fallback mode, trust unread OpenAI verification emails
+        // that appeared near the top of the filtered result set.
+        if (unread && (senderMatch || subjectMatch)) {
+          if (usedCodeSet.has(code)) {
+            log(`Step ${step}: Skipping already-used code ${code} (mailId: ${mailId})`);
+            await returnToInboxView(targetEmail);
+            continue;
+          }
+          log(`Step ${step}: Code found: ${code} (fallback-unread-match, alias hidden in Gmail detail)`, 'ok');
+          return { ok: true, code, emailTimestamp: Date.now(), mailId };
+        }
+
+        await returnToInboxView(targetEmail);
+        log(`Step ${step}: Skipping Gmail code ${code} because neither row nor opened email matched target alias ${targetEmail}`, 'info');
+      }
+    }
+
+    if (attempt === FALLBACK_AFTER + 1) {
+      log(`Step ${step}: No new Gmail emails after ${FALLBACK_AFTER} attempts, falling back to first matching email`, 'warn');
+    }
+
+    if (attempt < maxAttempts) {
+      await sleep(intervalMs);
+    }
+  }
+
+  throw new Error(
+    `No matching verification email found on Gmail after ${(maxAttempts * intervalMs / 1000).toFixed(0)}s. ` +
+    'Check Gmail manually and make sure the inbox tab is open.'
+  );
+}

+ 258 - 258
content/inbucket-mail.js

@@ -1,258 +1,258 @@
-// content/inbucket-mail.js — Content script for Inbucket polling (steps 4, 7)
-// Injected dynamically on the configured Inbucket host
-//
-// Supported page:
-// - /m/<mailbox>/
-
-const INBUCKET_PREFIX = '[MultiPage:inbucket-mail]';
-const isTopFrame = window === window.top;
-const SEEN_MAIL_IDS_KEY = 'seenInbucketMailIds';
-
-console.log(INBUCKET_PREFIX, 'Content script loaded on', location.href, 'frame:', isTopFrame ? 'top' : 'child');
-
-if (!isTopFrame) {
-  console.log(INBUCKET_PREFIX, 'Skipping child frame');
-} else {
-
-let seenMailIds = new Set();
-
-async function loadSeenMailIds() {
-  try {
-    const data = await chrome.storage.session.get(SEEN_MAIL_IDS_KEY);
-    if (Array.isArray(data[SEEN_MAIL_IDS_KEY])) {
-      seenMailIds = new Set(data[SEEN_MAIL_IDS_KEY]);
-      console.log(INBUCKET_PREFIX, `Loaded ${seenMailIds.size} previously seen mail ids`);
-    }
-  } catch (err) {
-    console.warn(INBUCKET_PREFIX, 'Session storage unavailable, using in-memory seen mail ids:', err?.message || err);
-  }
-}
-
-async function persistSeenMailIds() {
-  try {
-    await chrome.storage.session.set({ [SEEN_MAIL_IDS_KEY]: [...seenMailIds] });
-  } catch (err) {
-    console.warn(INBUCKET_PREFIX, 'Could not persist seen mail ids, continuing in-memory only:', err?.message || err);
-  }
-}
-
-loadSeenMailIds();
-
-chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
-  if (message.type === 'POLL_EMAIL') {
-    resetStopState();
-    handlePollEmail(message.step, message.payload).then(result => {
-      sendResponse(result);
-    }).catch(err => {
-      if (isStopError(err)) {
-        log(`Step ${message.step}: Stopped by user.`, 'warn');
-        sendResponse({ stopped: true, error: err.message });
-        return;
-      }
-      reportError(message.step, err.message);
-      sendResponse({ error: err.message });
-    });
-    return true;
-  }
-});
-
-function normalizeText(value) {
-  return (value || '').replace(/\s+/g, ' ').trim().toLowerCase();
-}
-
-function extractVerificationCode(text) {
-  const matchCn = text.match(/(?:代码为|验证码[^0-9]*?)[\s::]*(\d{6})/);
-  if (matchCn) return matchCn[1];
-
-  const matchEn = text.match(/code[:\s]+is[:\s]+(\d{6})|code[:\s]+(\d{6})/i);
-  if (matchEn) return matchEn[1] || matchEn[2];
-
-  const match6 = text.match(/\b(\d{6})\b/);
-  if (match6) return match6[1];
-
-  return null;
-}
-
-function rowMatchesFilters(mail, senderFilters, subjectFilters, targetEmail) {
-  const sender = normalizeText(mail.sender);
-  const subject = normalizeText(mail.subject);
-  const mailbox = normalizeText(mail.mailbox);
-  const combined = normalizeText(mail.combinedText);
-  const targetLocal = normalizeText((targetEmail || '').split('@')[0]);
-
-  const senderMatch = senderFilters.some(f => sender.includes(f.toLowerCase()) || combined.includes(f.toLowerCase()));
-  const subjectMatch = subjectFilters.some(f => subject.includes(f.toLowerCase()) || combined.includes(f.toLowerCase()));
-  const mailboxMatch = Boolean(targetLocal) && mailbox.includes(targetLocal);
-  const forwardedDuck = /duckduckgo|forward(?:ed)?\s*by/i.test(mail.combinedText);
-  const code = extractVerificationCode(mail.combinedText);
-  const keywordMatch = /openai|chatgpt|verify|verification|confirm|login|验证码|代码/.test(combined);
-
-  if (mailboxMatch) return { matched: true, mailboxMatch, code };
-  if (senderMatch || subjectMatch) return { matched: true, mailboxMatch: false, code };
-  if (code && (forwardedDuck || keywordMatch)) return { matched: true, mailboxMatch: false, code };
-
-  return { matched: false, mailboxMatch: false, code };
-}
-
-function findMailboxEntries() {
-  return document.querySelectorAll('.message-list-entry');
-}
-
-function getMailboxEntryId(entry, index = 0) {
-  const explicitId = entry.getAttribute('data-id') || entry.dataset?.id || '';
-  if (explicitId) return explicitId;
-
-  const subject = entry.querySelector('.subject')?.textContent?.trim() || '';
-  const sender = entry.querySelector('.from')?.textContent?.trim() || '';
-  const dateText = entry.querySelector('.date')?.textContent?.trim() || '';
-
-  return `mailbox:${index}:${normalizeText(subject)}|${normalizeText(sender)}|${normalizeText(dateText)}`;
-}
-
-function parseMailboxEntry(entry, index = 0) {
-  const subject = entry.querySelector('.subject')?.textContent?.trim() || '';
-  const sender = entry.querySelector('.from')?.textContent?.trim() || '';
-  const dateText = entry.querySelector('.date')?.textContent?.trim() || '';
-  const combinedText = [subject, sender, dateText].filter(Boolean).join(' ');
-
-  return {
-    entry,
-    dateText,
-    sender,
-    mailbox: '',
-    subject,
-    unread: entry.classList.contains('unseen'),
-    combinedText,
-    mailId: getMailboxEntryId(entry, index),
-  };
-}
-
-function getCurrentMailboxIds() {
-  const ids = new Set();
-  Array.from(findMailboxEntries()).forEach((entry, index) => {
-    ids.add(getMailboxEntryId(entry, index));
-  });
-  return ids;
-}
-
-async function refreshMailbox() {
-  const refreshButton = document.querySelector('button[alt="Refresh Mailbox"]');
-  if (!refreshButton) return;
-
-  simulateClick(refreshButton);
-  await sleep(800);
-}
-
-async function openMailboxEntry(entry) {
-  simulateClick(entry);
-
-  for (let i = 0; i < 20; i++) {
-    if (entry.classList.contains('selected') || document.querySelector('.message-header, .message-body, .button-bar')) {
-      return;
-    }
-    await sleep(150);
-  }
-}
-
-async function deleteCurrentMailboxMessage(step) {
-  try {
-    const deleteButton = await waitForElement('.button-bar button.danger', 5000);
-    simulateClick(deleteButton);
-    log(`Step ${step}: Deleted mailbox message`, 'ok');
-    await sleep(1200);
-  } catch (err) {
-    log(`Step ${step}: Failed to delete mailbox message: ${err.message}`, 'warn');
-  }
-}
-
-async function handleMailboxPollEmail(step, payload) {
-  const {
-    senderFilters = [],
-    subjectFilters = [],
-    maxAttempts = 20,
-    intervalMs = 3000,
-  } = payload || {};
-
-  log(`Step ${step}: Starting email poll on Inbucket mailbox page (max ${maxAttempts} attempts)`);
-
-  try {
-    await waitForElement('.message-list, .message-list-entry', 15000);
-    log(`Step ${step}: Mailbox page loaded`);
-  } catch {
-    throw new Error('Inbucket mailbox page did not load. Make sure /m/<mailbox>/ is open.');
-  }
-
-  const existingMailIds = getCurrentMailboxIds();
-  log(`Step ${step}: Snapshotted ${existingMailIds.size} existing mailbox messages`);
-
-  const FALLBACK_AFTER = 3;
-
-  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
-    log(`Polling Inbucket mailbox... attempt ${attempt}/${maxAttempts}`);
-
-    if (attempt > 1) {
-      await refreshMailbox();
-    }
-
-    const entries = Array.from(findMailboxEntries()).map(parseMailboxEntry);
-    const useFallback = attempt > FALLBACK_AFTER;
-    const candidates = [];
-
-    for (const mail of entries) {
-      if (!mail.unread) continue;
-      if (seenMailIds.has(mail.mailId)) continue;
-      if (!useFallback && existingMailIds.has(mail.mailId)) continue;
-
-      const match = rowMatchesFilters(mail, senderFilters, subjectFilters, '');
-      if (!match.matched) continue;
-
-      candidates.push({ ...mail, code: match.code });
-    }
-
-    for (const mail of candidates) {
-      const code = mail.code || extractVerificationCode(mail.combinedText);
-      if (!code) continue;
-
-      await openMailboxEntry(mail.entry);
-      await deleteCurrentMailboxMessage(step);
-
-      seenMailIds.add(mail.mailId);
-      await persistSeenMailIds();
-
-      const source = existingMailIds.has(mail.mailId) ? 'fallback' : 'new';
-      log(
-        `Step ${step}: Code found: ${code} (${source}, sender: ${mail.sender || 'unknown'}, subject: ${(mail.subject || '').slice(0, 60)})`,
-        'ok'
-      );
-
-      return {
-        ok: true,
-        code,
-        emailTimestamp: Date.now(),
-        mailId: mail.mailId,
-      };
-    }
-
-    if (attempt === FALLBACK_AFTER + 1) {
-      log(`Step ${step}: No new mailbox messages yet, falling back to older matching messages`, 'warn');
-    }
-
-    if (attempt < maxAttempts) {
-      await sleep(intervalMs);
-    }
-  }
-
-  throw new Error(
-    `No matching verification email found in Inbucket mailbox after ${(maxAttempts * intervalMs / 1000).toFixed(0)}s. ` +
-    'Check the mailbox page manually.'
-  );
-}
-
-async function handlePollEmail(step, payload) {
-  if (!location.pathname.startsWith('/m/')) {
-    throw new Error('Inbucket now only supports mailbox pages like /m/<mailbox>/.');
-  }
-  return handleMailboxPollEmail(step, payload);
-}
-
-} // end of isTopFrame else block
+// content/inbucket-mail.js — Content script for Inbucket polling (steps 4, 7)
+// Injected dynamically on the configured Inbucket host
+//
+// Supported page:
+// - /m/<mailbox>/
+
+const INBUCKET_PREFIX = '[MultiPage:inbucket-mail]';
+const isTopFrame = window === window.top;
+const SEEN_MAIL_IDS_KEY = 'seenInbucketMailIds';
+
+console.log(INBUCKET_PREFIX, 'Content script loaded on', location.href, 'frame:', isTopFrame ? 'top' : 'child');
+
+if (!isTopFrame) {
+  console.log(INBUCKET_PREFIX, 'Skipping child frame');
+} else {
+
+let seenMailIds = new Set();
+
+async function loadSeenMailIds() {
+  try {
+    const data = await chrome.storage.session.get(SEEN_MAIL_IDS_KEY);
+    if (Array.isArray(data[SEEN_MAIL_IDS_KEY])) {
+      seenMailIds = new Set(data[SEEN_MAIL_IDS_KEY]);
+      console.log(INBUCKET_PREFIX, `Loaded ${seenMailIds.size} previously seen mail ids`);
+    }
+  } catch (err) {
+    console.warn(INBUCKET_PREFIX, 'Session storage unavailable, using in-memory seen mail ids:', err?.message || err);
+  }
+}
+
+async function persistSeenMailIds() {
+  try {
+    await chrome.storage.session.set({ [SEEN_MAIL_IDS_KEY]: [...seenMailIds] });
+  } catch (err) {
+    console.warn(INBUCKET_PREFIX, 'Could not persist seen mail ids, continuing in-memory only:', err?.message || err);
+  }
+}
+
+loadSeenMailIds();
+
+chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
+  if (message.type === 'POLL_EMAIL') {
+    resetStopState();
+    handlePollEmail(message.step, message.payload).then(result => {
+      sendResponse(result);
+    }).catch(err => {
+      if (isStopError(err)) {
+        log(`Step ${message.step}: Stopped by user.`, 'warn');
+        sendResponse({ stopped: true, error: err.message });
+        return;
+      }
+      reportError(message.step, err.message);
+      sendResponse({ error: err.message });
+    });
+    return true;
+  }
+});
+
+function normalizeText(value) {
+  return (value || '').replace(/\s+/g, ' ').trim().toLowerCase();
+}
+
+function extractVerificationCode(text) {
+  const matchCn = text.match(/(?:代码为|验证码[^0-9]*?)[\s::]*(\d{6})/);
+  if (matchCn) return matchCn[1];
+
+  const matchEn = text.match(/code[:\s]+is[:\s]+(\d{6})|code[:\s]+(\d{6})/i);
+  if (matchEn) return matchEn[1] || matchEn[2];
+
+  const match6 = text.match(/\b(\d{6})\b/);
+  if (match6) return match6[1];
+
+  return null;
+}
+
+function rowMatchesFilters(mail, senderFilters, subjectFilters, targetEmail) {
+  const sender = normalizeText(mail.sender);
+  const subject = normalizeText(mail.subject);
+  const mailbox = normalizeText(mail.mailbox);
+  const combined = normalizeText(mail.combinedText);
+  const targetLocal = normalizeText((targetEmail || '').split('@')[0]);
+
+  const senderMatch = senderFilters.some(f => sender.includes(f.toLowerCase()) || combined.includes(f.toLowerCase()));
+  const subjectMatch = subjectFilters.some(f => subject.includes(f.toLowerCase()) || combined.includes(f.toLowerCase()));
+  const mailboxMatch = Boolean(targetLocal) && mailbox.includes(targetLocal);
+  const forwardedDuck = /duckduckgo|forward(?:ed)?\s*by/i.test(mail.combinedText);
+  const code = extractVerificationCode(mail.combinedText);
+  const keywordMatch = /openai|chatgpt|verify|verification|confirm|login|验证码|代码/.test(combined);
+
+  if (mailboxMatch) return { matched: true, mailboxMatch, code };
+  if (senderMatch || subjectMatch) return { matched: true, mailboxMatch: false, code };
+  if (code && (forwardedDuck || keywordMatch)) return { matched: true, mailboxMatch: false, code };
+
+  return { matched: false, mailboxMatch: false, code };
+}
+
+function findMailboxEntries() {
+  return document.querySelectorAll('.message-list-entry');
+}
+
+function getMailboxEntryId(entry, index = 0) {
+  const explicitId = entry.getAttribute('data-id') || entry.dataset?.id || '';
+  if (explicitId) return explicitId;
+
+  const subject = entry.querySelector('.subject')?.textContent?.trim() || '';
+  const sender = entry.querySelector('.from')?.textContent?.trim() || '';
+  const dateText = entry.querySelector('.date')?.textContent?.trim() || '';
+
+  return `mailbox:${index}:${normalizeText(subject)}|${normalizeText(sender)}|${normalizeText(dateText)}`;
+}
+
+function parseMailboxEntry(entry, index = 0) {
+  const subject = entry.querySelector('.subject')?.textContent?.trim() || '';
+  const sender = entry.querySelector('.from')?.textContent?.trim() || '';
+  const dateText = entry.querySelector('.date')?.textContent?.trim() || '';
+  const combinedText = [subject, sender, dateText].filter(Boolean).join(' ');
+
+  return {
+    entry,
+    dateText,
+    sender,
+    mailbox: '',
+    subject,
+    unread: entry.classList.contains('unseen'),
+    combinedText,
+    mailId: getMailboxEntryId(entry, index),
+  };
+}
+
+function getCurrentMailboxIds() {
+  const ids = new Set();
+  Array.from(findMailboxEntries()).forEach((entry, index) => {
+    ids.add(getMailboxEntryId(entry, index));
+  });
+  return ids;
+}
+
+async function refreshMailbox() {
+  const refreshButton = document.querySelector('button[alt="Refresh Mailbox"]');
+  if (!refreshButton) return;
+
+  simulateClick(refreshButton);
+  await sleep(800);
+}
+
+async function openMailboxEntry(entry) {
+  simulateClick(entry);
+
+  for (let i = 0; i < 20; i++) {
+    if (entry.classList.contains('selected') || document.querySelector('.message-header, .message-body, .button-bar')) {
+      return;
+    }
+    await sleep(150);
+  }
+}
+
+async function deleteCurrentMailboxMessage(step) {
+  try {
+    const deleteButton = await waitForElement('.button-bar button.danger', 5000);
+    simulateClick(deleteButton);
+    log(`Step ${step}: Deleted mailbox message`, 'ok');
+    await sleep(1200);
+  } catch (err) {
+    log(`Step ${step}: Failed to delete mailbox message: ${err.message}`, 'warn');
+  }
+}
+
+async function handleMailboxPollEmail(step, payload) {
+  const {
+    senderFilters = [],
+    subjectFilters = [],
+    maxAttempts = 20,
+    intervalMs = 3000,
+  } = payload || {};
+
+  log(`Step ${step}: Starting email poll on Inbucket mailbox page (max ${maxAttempts} attempts)`);
+
+  try {
+    await waitForElement('.message-list, .message-list-entry', 15000);
+    log(`Step ${step}: Mailbox page loaded`);
+  } catch {
+    throw new Error('Inbucket mailbox page did not load. Make sure /m/<mailbox>/ is open.');
+  }
+
+  const existingMailIds = getCurrentMailboxIds();
+  log(`Step ${step}: Snapshotted ${existingMailIds.size} existing mailbox messages`);
+
+  const FALLBACK_AFTER = 3;
+
+  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
+    log(`Polling Inbucket mailbox... attempt ${attempt}/${maxAttempts}`);
+
+    if (attempt > 1) {
+      await refreshMailbox();
+    }
+
+    const entries = Array.from(findMailboxEntries()).map(parseMailboxEntry);
+    const useFallback = attempt > FALLBACK_AFTER;
+    const candidates = [];
+
+    for (const mail of entries) {
+      if (!mail.unread) continue;
+      if (seenMailIds.has(mail.mailId)) continue;
+      if (!useFallback && existingMailIds.has(mail.mailId)) continue;
+
+      const match = rowMatchesFilters(mail, senderFilters, subjectFilters, '');
+      if (!match.matched) continue;
+
+      candidates.push({ ...mail, code: match.code });
+    }
+
+    for (const mail of candidates) {
+      const code = mail.code || extractVerificationCode(mail.combinedText);
+      if (!code) continue;
+
+      await openMailboxEntry(mail.entry);
+      await deleteCurrentMailboxMessage(step);
+
+      seenMailIds.add(mail.mailId);
+      await persistSeenMailIds();
+
+      const source = existingMailIds.has(mail.mailId) ? 'fallback' : 'new';
+      log(
+        `Step ${step}: Code found: ${code} (${source}, sender: ${mail.sender || 'unknown'}, subject: ${(mail.subject || '').slice(0, 60)})`,
+        'ok'
+      );
+
+      return {
+        ok: true,
+        code,
+        emailTimestamp: Date.now(),
+        mailId: mail.mailId,
+      };
+    }
+
+    if (attempt === FALLBACK_AFTER + 1) {
+      log(`Step ${step}: No new mailbox messages yet, falling back to older matching messages`, 'warn');
+    }
+
+    if (attempt < maxAttempts) {
+      await sleep(intervalMs);
+    }
+  }
+
+  throw new Error(
+    `No matching verification email found in Inbucket mailbox after ${(maxAttempts * intervalMs / 1000).toFixed(0)}s. ` +
+    'Check the mailbox page manually.'
+  );
+}
+
+async function handlePollEmail(step, payload) {
+  if (!location.pathname.startsWith('/m/')) {
+    throw new Error('Inbucket now only supports mailbox pages like /m/<mailbox>/.');
+  }
+  return handleMailboxPollEmail(step, payload);
+}
+
+} // end of isTopFrame else block

+ 299 - 299
content/mail-163.js

@@ -1,299 +1,299 @@
-// content/mail-163.js — Content script for 163 Mail (steps 4, 7)
-// Injected on: mail.163.com
-//
-// DOM structure:
-// Mail item: div[sign="letter"] with aria-label="你的 ChatGPT 代码为 479637 发件人 : OpenAI ..."
-// Sender: .nui-user (e.g., "OpenAI")
-// Subject: span.da0 (e.g., "你的 ChatGPT 代码为 479637")
-// Right-click menu: .nui-menu → .nui-menu-item with text "删除邮件"
-
-const MAIL163_PREFIX = '[MultiPage:mail-163]';
-const isTopFrame = window === window.top;
-
-console.log(MAIL163_PREFIX, 'Content script loaded on', location.href, 'frame:', isTopFrame ? 'top' : 'child');
-
-// Only operate in the top frame
-if (!isTopFrame) {
-  console.log(MAIL163_PREFIX, 'Skipping child frame');
-} else {
-
-// Track codes we've already seen — persisted in chrome.storage.session to survive script re-injection
-let seenCodes = new Set();
-
-async function loadSeenCodes() {
-  try {
-    const data = await chrome.storage.session.get('seenCodes');
-    if (data.seenCodes && Array.isArray(data.seenCodes)) {
-      seenCodes = new Set(data.seenCodes);
-      console.log(MAIL163_PREFIX, `Loaded ${seenCodes.size} previously seen codes`);
-    }
-  } catch (err) {
-    console.warn(MAIL163_PREFIX, 'Session storage unavailable, using in-memory seen codes:', err?.message || err);
-  }
-}
-
-// Load previously seen codes on startup
-loadSeenCodes();
-
-async function persistSeenCodes() {
-  try {
-    await chrome.storage.session.set({ seenCodes: [...seenCodes] });
-  } catch (err) {
-    console.warn(MAIL163_PREFIX, 'Could not persist seen codes, continuing in-memory only:', err?.message || err);
-  }
-}
-
-// ============================================================
-// Message Handler (top frame only)
-// ============================================================
-
-chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
-  if (message.type === 'POLL_EMAIL') {
-    resetStopState();
-    handlePollEmail(message.step, message.payload).then(result => {
-      sendResponse(result);
-    }).catch(err => {
-      if (isStopError(err)) {
-        log(`Step ${message.step}: Stopped by user.`, 'warn');
-        sendResponse({ stopped: true, error: err.message });
-        return;
-      }
-      reportError(message.step, err.message);
-      sendResponse({ error: err.message });
-    });
-    return true;
-  }
-});
-
-// ============================================================
-// Find mail items
-// ============================================================
-
-function parseEmailDate(item) {
-  const aria = item.getAttribute('aria-label') || '';
-  const m = aria.match(/(\d{4})[年/-](\d{1,2})[月/-](\d{1,2})[日]?\s*(\d{1,2}):(\d{2})/);
-  if (m) return new Date(+m[1], m[2] - 1, +m[3], +m[4], +m[5]).getTime();
-  const d = aria.match(/(\d{4})[年/-](\d{1,2})[月/-](\d{1,2})/);
-  if (d) return new Date(+d[1], d[2] - 1, +d[3]).getTime();
-  return 0;
-}
-
-function findMailItems() {
-  return document.querySelectorAll('div[sign="letter"]');
-}
-
-function getCurrentMailIds() {
-  const ids = new Set();
-  findMailItems().forEach(item => {
-    const id = item.getAttribute('id') || '';
-    if (id) ids.add(id);
-  });
-  return ids;
-}
-
-// ============================================================
-// Email Polling
-// ============================================================
-
-async function handlePollEmail(step, payload) {
-  const { senderFilters, subjectFilters, maxAttempts, intervalMs, filterAfterTimestamp = 0 } = payload;
-
-  log(`Step ${step}: Starting email poll on 163 Mail (max ${maxAttempts} attempts)`);
-
-  // Click inbox in sidebar to ensure we're in inbox view
-  log(`Step ${step}: Waiting for sidebar...`);
-  try {
-    const inboxLink = await waitForElement('.nui-tree-item-text[title="收件箱"]', 5000);
-    inboxLink.click();
-    log(`Step ${step}: Clicked inbox`);
-  } catch {
-    log(`Step ${step}: Inbox link not found, proceeding...`, 'warn');
-  }
-
-  // Wait for mail list container to appear (page loaded check, inbox can be empty)
-  log(`Step ${step}: Waiting for mail list...`);
-  try {
-    await waitForElement('.nui-tree-item-text[title="收件箱"], .mail-list, div[sign="letter"]', 10000);
-    log(`Step ${step}: Mail page loaded`);
-  } catch {
-    log(`Step ${step}: Mail page may not be fully loaded, proceeding to poll anyway...`, 'warn');
-  }
-
-  // Snapshot existing mail IDs (may be empty if inbox is empty)
-  const existingMailIds = getCurrentMailIds();
-  log(`Step ${step}: Snapshotted ${existingMailIds.size} existing emails`);
-
-  const FALLBACK_AFTER = 3;
-
-  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
-    log(`Polling 163 Mail... attempt ${attempt}/${maxAttempts}`);
-
-    await refreshInbox();
-    await sleep(1000);
-
-    const allItems = findMailItems();
-    const useFallback = attempt > FALLBACK_AFTER;
-
-    for (const item of allItems) {
-      const id = item.getAttribute('id') || '';
-
-      if (!useFallback && existingMailIds.has(id)) continue;
-
-      const senderEl = item.querySelector('.nui-user');
-      const sender = senderEl ? senderEl.textContent.toLowerCase() : '';
-
-      const subjectEl = item.querySelector('span.da0');
-      const subject = subjectEl ? subjectEl.textContent : '';
-
-      const ariaLabel = (item.getAttribute('aria-label') || '').toLowerCase();
-
-      const senderMatch = senderFilters.some(f => sender.includes(f.toLowerCase()) || ariaLabel.includes(f.toLowerCase()));
-      const subjectMatch = subjectFilters.some(f => subject.toLowerCase().includes(f.toLowerCase()) || ariaLabel.includes(f.toLowerCase()));
-
-      if ((senderMatch || subjectMatch) && filterAfterTimestamp > 0) {
-        const emailTime = parseEmailDate(item);
-        if (emailTime > 0 && emailTime < filterAfterTimestamp - 60000) {
-          log(`Step ${step}: Skipping old email (date: ${new Date(emailTime).toLocaleString()})`, 'info');
-          continue;
-        }
-      }
-
-      if (senderMatch || subjectMatch) {
-        const code = extractVerificationCode(subject + ' ' + ariaLabel);
-        if (code && !seenCodes.has(code)) {
-          seenCodes.add(code);
-          persistSeenCodes();
-          const source = useFallback && existingMailIds.has(id) ? 'fallback' : 'new';
-          log(`Step ${step}: Code found: ${code} (${source}, subject: ${subject.slice(0, 40)})`, 'ok');
-
-          // Delete this email via right-click menu, WAIT for it to finish before returning
-          await deleteEmail(item, step);
-          // Extra wait to ensure deletion is processed
-          await sleep(1000);
-
-          return { ok: true, code, emailTimestamp: Date.now(), mailId: id };
-        } else if (code && seenCodes.has(code)) {
-          log(`Step ${step}: Skipping already-seen code: ${code}`, 'info');
-        }
-      }
-    }
-
-    if (attempt === FALLBACK_AFTER + 1) {
-      log(`Step ${step}: No new emails after ${FALLBACK_AFTER} attempts, falling back to first match`, 'warn');
-    }
-
-    if (attempt < maxAttempts) {
-      await sleep(intervalMs);
-    }
-  }
-
-  throw new Error(
-    `No new matching email found on 163 Mail after ${(maxAttempts * intervalMs / 1000).toFixed(0)}s. ` +
-    'Check inbox manually.'
-  );
-}
-
-// ============================================================
-// Delete Email via Right-Click Menu
-// ============================================================
-
-async function deleteEmail(item, step) {
-  try {
-    log(`Step ${step}: Deleting email...`);
-
-    // Strategy 1: Click the trash icon inside the mail item
-    // Each mail item has: <b class="nui-ico nui-ico-delete" title="删除邮件" sign="trash">
-    // These icons appear on hover, so we trigger mouseover first
-    item.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
-    item.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
-    await sleep(300);
-
-    const trashIcon = item.querySelector('[sign="trash"], .nui-ico-delete, [title="删除邮件"]');
-    if (trashIcon) {
-      trashIcon.click();
-      log(`Step ${step}: Clicked trash icon`, 'ok');
-      await sleep(1500);
-
-      // Check if item disappeared (confirm deletion)
-      const stillExists = document.getElementById(item.id);
-      if (!stillExists || stillExists.style.display === 'none') {
-        log(`Step ${step}: Email deleted successfully`);
-      } else {
-        log(`Step ${step}: Email may not have been deleted, item still visible`, 'warn');
-      }
-      return;
-    }
-
-    // Strategy 2: Select checkbox then click toolbar delete button
-    log(`Step ${step}: Trash icon not found, trying checkbox + toolbar delete...`);
-    const checkbox = item.querySelector('[sign="checkbox"], .nui-chk');
-    if (checkbox) {
-      checkbox.click();
-      await sleep(300);
-
-      // Click toolbar delete button
-      const toolbarBtns = document.querySelectorAll('.nui-btn .nui-btn-text');
-      for (const btn of toolbarBtns) {
-        if (btn.textContent.replace(/\s/g, '').includes('删除')) {
-          btn.closest('.nui-btn').click();
-          log(`Step ${step}: Clicked toolbar delete`, 'ok');
-          await sleep(1500);
-          return;
-        }
-      }
-    }
-
-    log(`Step ${step}: Could not delete email (no delete button found)`, 'warn');
-  } catch (err) {
-    log(`Step ${step}: Failed to delete email: ${err.message}`, 'warn');
-  }
-}
-
-// ============================================================
-// Inbox Refresh
-// ============================================================
-
-async function refreshInbox() {
-  // Try toolbar "刷 新" button
-  const toolbarBtns = document.querySelectorAll('.nui-btn .nui-btn-text');
-  for (const btn of toolbarBtns) {
-    if (btn.textContent.replace(/\s/g, '') === '刷新') {
-      btn.closest('.nui-btn').click();
-      console.log(MAIL163_PREFIX, 'Clicked "刷新" button');
-      await sleep(800);
-      return;
-    }
-  }
-
-  // Fallback: click sidebar "收 信"
-  const shouXinBtns = document.querySelectorAll('.ra0');
-  for (const btn of shouXinBtns) {
-    if (btn.textContent.replace(/\s/g, '').includes('收信')) {
-      btn.click();
-      console.log(MAIL163_PREFIX, 'Clicked "收信" button');
-      await sleep(800);
-      return;
-    }
-  }
-
-  console.log(MAIL163_PREFIX, 'Could not find refresh button');
-}
-
-// ============================================================
-// Verification Code Extraction
-// ============================================================
-
-function extractVerificationCode(text) {
-  const matchCn = text.match(/(?:代码为|验证码[^0-9]*?)[\s::]*(\d{6})/);
-  if (matchCn) return matchCn[1];
-
-  const matchEn = text.match(/code[:\s]+is[:\s]+(\d{6})|code[:\s]+(\d{6})/i);
-  if (matchEn) return matchEn[1] || matchEn[2];
-
-  const match6 = text.match(/\b(\d{6})\b/);
-  if (match6) return match6[1];
-
-  return null;
-}
-
-} // end of isTopFrame else block
+// content/mail-163.js — Content script for 163 Mail (steps 4, 7)
+// Injected on: mail.163.com
+//
+// DOM structure:
+// Mail item: div[sign="letter"] with aria-label="你的 ChatGPT 代码为 479637 发件人 : OpenAI ..."
+// Sender: .nui-user (e.g., "OpenAI")
+// Subject: span.da0 (e.g., "你的 ChatGPT 代码为 479637")
+// Right-click menu: .nui-menu → .nui-menu-item with text "删除邮件"
+
+const MAIL163_PREFIX = '[MultiPage:mail-163]';
+const isTopFrame = window === window.top;
+
+console.log(MAIL163_PREFIX, 'Content script loaded on', location.href, 'frame:', isTopFrame ? 'top' : 'child');
+
+// Only operate in the top frame
+if (!isTopFrame) {
+  console.log(MAIL163_PREFIX, 'Skipping child frame');
+} else {
+
+// Track codes we've already seen — persisted in chrome.storage.session to survive script re-injection
+let seenCodes = new Set();
+
+async function loadSeenCodes() {
+  try {
+    const data = await chrome.storage.session.get('seenCodes');
+    if (data.seenCodes && Array.isArray(data.seenCodes)) {
+      seenCodes = new Set(data.seenCodes);
+      console.log(MAIL163_PREFIX, `Loaded ${seenCodes.size} previously seen codes`);
+    }
+  } catch (err) {
+    console.warn(MAIL163_PREFIX, 'Session storage unavailable, using in-memory seen codes:', err?.message || err);
+  }
+}
+
+// Load previously seen codes on startup
+loadSeenCodes();
+
+async function persistSeenCodes() {
+  try {
+    await chrome.storage.session.set({ seenCodes: [...seenCodes] });
+  } catch (err) {
+    console.warn(MAIL163_PREFIX, 'Could not persist seen codes, continuing in-memory only:', err?.message || err);
+  }
+}
+
+// ============================================================
+// Message Handler (top frame only)
+// ============================================================
+
+chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
+  if (message.type === 'POLL_EMAIL') {
+    resetStopState();
+    handlePollEmail(message.step, message.payload).then(result => {
+      sendResponse(result);
+    }).catch(err => {
+      if (isStopError(err)) {
+        log(`Step ${message.step}: Stopped by user.`, 'warn');
+        sendResponse({ stopped: true, error: err.message });
+        return;
+      }
+      reportError(message.step, err.message);
+      sendResponse({ error: err.message });
+    });
+    return true;
+  }
+});
+
+// ============================================================
+// Find mail items
+// ============================================================
+
+function parseEmailDate(item) {
+  const aria = item.getAttribute('aria-label') || '';
+  const m = aria.match(/(\d{4})[年/-](\d{1,2})[月/-](\d{1,2})[日]?\s*(\d{1,2}):(\d{2})/);
+  if (m) return new Date(+m[1], m[2] - 1, +m[3], +m[4], +m[5]).getTime();
+  const d = aria.match(/(\d{4})[年/-](\d{1,2})[月/-](\d{1,2})/);
+  if (d) return new Date(+d[1], d[2] - 1, +d[3]).getTime();
+  return 0;
+}
+
+function findMailItems() {
+  return document.querySelectorAll('div[sign="letter"]');
+}
+
+function getCurrentMailIds() {
+  const ids = new Set();
+  findMailItems().forEach(item => {
+    const id = item.getAttribute('id') || '';
+    if (id) ids.add(id);
+  });
+  return ids;
+}
+
+// ============================================================
+// Email Polling
+// ============================================================
+
+async function handlePollEmail(step, payload) {
+  const { senderFilters, subjectFilters, maxAttempts, intervalMs, filterAfterTimestamp = 0 } = payload;
+
+  log(`Step ${step}: Starting email poll on 163 Mail (max ${maxAttempts} attempts)`);
+
+  // Click inbox in sidebar to ensure we're in inbox view
+  log(`Step ${step}: Waiting for sidebar...`);
+  try {
+    const inboxLink = await waitForElement('.nui-tree-item-text[title="收件箱"]', 5000);
+    inboxLink.click();
+    log(`Step ${step}: Clicked inbox`);
+  } catch {
+    log(`Step ${step}: Inbox link not found, proceeding...`, 'warn');
+  }
+
+  // Wait for mail list container to appear (page loaded check, inbox can be empty)
+  log(`Step ${step}: Waiting for mail list...`);
+  try {
+    await waitForElement('.nui-tree-item-text[title="收件箱"], .mail-list, div[sign="letter"]', 10000);
+    log(`Step ${step}: Mail page loaded`);
+  } catch {
+    log(`Step ${step}: Mail page may not be fully loaded, proceeding to poll anyway...`, 'warn');
+  }
+
+  // Snapshot existing mail IDs (may be empty if inbox is empty)
+  const existingMailIds = getCurrentMailIds();
+  log(`Step ${step}: Snapshotted ${existingMailIds.size} existing emails`);
+
+  const FALLBACK_AFTER = 3;
+
+  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
+    log(`Polling 163 Mail... attempt ${attempt}/${maxAttempts}`);
+
+    await refreshInbox();
+    await sleep(1000);
+
+    const allItems = findMailItems();
+    const useFallback = attempt > FALLBACK_AFTER;
+
+    for (const item of allItems) {
+      const id = item.getAttribute('id') || '';
+
+      if (!useFallback && existingMailIds.has(id)) continue;
+
+      const senderEl = item.querySelector('.nui-user');
+      const sender = senderEl ? senderEl.textContent.toLowerCase() : '';
+
+      const subjectEl = item.querySelector('span.da0');
+      const subject = subjectEl ? subjectEl.textContent : '';
+
+      const ariaLabel = (item.getAttribute('aria-label') || '').toLowerCase();
+
+      const senderMatch = senderFilters.some(f => sender.includes(f.toLowerCase()) || ariaLabel.includes(f.toLowerCase()));
+      const subjectMatch = subjectFilters.some(f => subject.toLowerCase().includes(f.toLowerCase()) || ariaLabel.includes(f.toLowerCase()));
+
+      if ((senderMatch || subjectMatch) && filterAfterTimestamp > 0) {
+        const emailTime = parseEmailDate(item);
+        if (emailTime > 0 && emailTime < filterAfterTimestamp - 60000) {
+          log(`Step ${step}: Skipping old email (date: ${new Date(emailTime).toLocaleString()})`, 'info');
+          continue;
+        }
+      }
+
+      if (senderMatch || subjectMatch) {
+        const code = extractVerificationCode(subject + ' ' + ariaLabel);
+        if (code && !seenCodes.has(code)) {
+          seenCodes.add(code);
+          persistSeenCodes();
+          const source = useFallback && existingMailIds.has(id) ? 'fallback' : 'new';
+          log(`Step ${step}: Code found: ${code} (${source}, subject: ${subject.slice(0, 40)})`, 'ok');
+
+          // Delete this email via right-click menu, WAIT for it to finish before returning
+          await deleteEmail(item, step);
+          // Extra wait to ensure deletion is processed
+          await sleep(1000);
+
+          return { ok: true, code, emailTimestamp: Date.now(), mailId: id };
+        } else if (code && seenCodes.has(code)) {
+          log(`Step ${step}: Skipping already-seen code: ${code}`, 'info');
+        }
+      }
+    }
+
+    if (attempt === FALLBACK_AFTER + 1) {
+      log(`Step ${step}: No new emails after ${FALLBACK_AFTER} attempts, falling back to first match`, 'warn');
+    }
+
+    if (attempt < maxAttempts) {
+      await sleep(intervalMs);
+    }
+  }
+
+  throw new Error(
+    `No new matching email found on 163 Mail after ${(maxAttempts * intervalMs / 1000).toFixed(0)}s. ` +
+    'Check inbox manually.'
+  );
+}
+
+// ============================================================
+// Delete Email via Right-Click Menu
+// ============================================================
+
+async function deleteEmail(item, step) {
+  try {
+    log(`Step ${step}: Deleting email...`);
+
+    // Strategy 1: Click the trash icon inside the mail item
+    // Each mail item has: <b class="nui-ico nui-ico-delete" title="删除邮件" sign="trash">
+    // These icons appear on hover, so we trigger mouseover first
+    item.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
+    item.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
+    await sleep(300);
+
+    const trashIcon = item.querySelector('[sign="trash"], .nui-ico-delete, [title="删除邮件"]');
+    if (trashIcon) {
+      trashIcon.click();
+      log(`Step ${step}: Clicked trash icon`, 'ok');
+      await sleep(1500);
+
+      // Check if item disappeared (confirm deletion)
+      const stillExists = document.getElementById(item.id);
+      if (!stillExists || stillExists.style.display === 'none') {
+        log(`Step ${step}: Email deleted successfully`);
+      } else {
+        log(`Step ${step}: Email may not have been deleted, item still visible`, 'warn');
+      }
+      return;
+    }
+
+    // Strategy 2: Select checkbox then click toolbar delete button
+    log(`Step ${step}: Trash icon not found, trying checkbox + toolbar delete...`);
+    const checkbox = item.querySelector('[sign="checkbox"], .nui-chk');
+    if (checkbox) {
+      checkbox.click();
+      await sleep(300);
+
+      // Click toolbar delete button
+      const toolbarBtns = document.querySelectorAll('.nui-btn .nui-btn-text');
+      for (const btn of toolbarBtns) {
+        if (btn.textContent.replace(/\s/g, '').includes('删除')) {
+          btn.closest('.nui-btn').click();
+          log(`Step ${step}: Clicked toolbar delete`, 'ok');
+          await sleep(1500);
+          return;
+        }
+      }
+    }
+
+    log(`Step ${step}: Could not delete email (no delete button found)`, 'warn');
+  } catch (err) {
+    log(`Step ${step}: Failed to delete email: ${err.message}`, 'warn');
+  }
+}
+
+// ============================================================
+// Inbox Refresh
+// ============================================================
+
+async function refreshInbox() {
+  // Try toolbar "刷 新" button
+  const toolbarBtns = document.querySelectorAll('.nui-btn .nui-btn-text');
+  for (const btn of toolbarBtns) {
+    if (btn.textContent.replace(/\s/g, '') === '刷新') {
+      btn.closest('.nui-btn').click();
+      console.log(MAIL163_PREFIX, 'Clicked "刷新" button');
+      await sleep(800);
+      return;
+    }
+  }
+
+  // Fallback: click sidebar "收 信"
+  const shouXinBtns = document.querySelectorAll('.ra0');
+  for (const btn of shouXinBtns) {
+    if (btn.textContent.replace(/\s/g, '').includes('收信')) {
+      btn.click();
+      console.log(MAIL163_PREFIX, 'Clicked "收信" button');
+      await sleep(800);
+      return;
+    }
+  }
+
+  console.log(MAIL163_PREFIX, 'Could not find refresh button');
+}
+
+// ============================================================
+// Verification Code Extraction
+// ============================================================
+
+function extractVerificationCode(text) {
+  const matchCn = text.match(/(?:代码为|验证码[^0-9]*?)[\s::]*(\d{6})/);
+  if (matchCn) return matchCn[1];
+
+  const matchEn = text.match(/code[:\s]+is[:\s]+(\d{6})|code[:\s]+(\d{6})/i);
+  if (matchEn) return matchEn[1] || matchEn[2];
+
+  const match6 = text.match(/\b(\d{6})\b/);
+  if (match6) return match6[1];
+
+  return null;
+}
+
+} // end of isTopFrame else block

+ 193 - 193
content/qq-mail.js

@@ -1,193 +1,193 @@
-// content/qq-mail.js — Content script for QQ Mail (steps 4, 7)
-// Injected on: mail.qq.com, wx.mail.qq.com
-// NOTE: all_frames: true
-//
-// Strategy for avoiding stale codes:
-// 1. On poll start, snapshot all existing mail IDs as "old"
-// 2. On each poll cycle, refresh inbox and look for NEW items (not in snapshot)
-// 3. Only extract codes from NEW items that match sender/subject filters
-
-const QQ_MAIL_PREFIX = '[MultiPage:qq-mail]';
-const isTopFrame = window === window.top;
-
-console.log(QQ_MAIL_PREFIX, 'Content script loaded on', location.href, 'frame:', isTopFrame ? 'top' : 'child');
-
-// ============================================================
-// Message Handler
-// ============================================================
-
-chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
-  if (message.type === 'POLL_EMAIL') {
-    if (!isTopFrame) {
-      sendResponse({ ok: false, reason: 'wrong-frame' });
-      return;
-    }
-    resetStopState();
-    handlePollEmail(message.step, message.payload).then(result => {
-      sendResponse(result);
-    }).catch(err => {
-      if (isStopError(err)) {
-        log(`Step ${message.step}: Stopped by user.`, 'warn');
-        sendResponse({ stopped: true, error: err.message });
-        return;
-      }
-      reportError(message.step, err.message);
-      sendResponse({ error: err.message });
-    });
-    return true; // async response
-  }
-});
-
-// ============================================================
-// Get all current mail IDs from the list
-// ============================================================
-
-function getCurrentMailIds() {
-  const ids = new Set();
-  document.querySelectorAll('.mail-list-page-item[data-mailid]').forEach(item => {
-    ids.add(item.getAttribute('data-mailid'));
-  });
-  return ids;
-}
-
-// ============================================================
-// Email Polling
-// ============================================================
-
-async function handlePollEmail(step, payload) {
-  const { senderFilters, subjectFilters, maxAttempts, intervalMs, usedMailIds, usedCodes } = payload;
-
-  // Build sets of already-used mail IDs and codes to skip during fallback
-  const usedMailIdSet = new Set(usedMailIds || []);
-  const usedCodeSet = new Set(usedCodes || []);
-
-  log(`Step ${step}: Starting email poll (max ${maxAttempts} attempts, every ${intervalMs / 1000}s)`);
-
-  // Wait for mail list to load
-  try {
-    await waitForElement('.mail-list-page-item', 10000);
-    log(`Step ${step}: Mail list loaded`);
-  } catch {
-    throw new Error('Mail list did not load. Make sure QQ Mail inbox is open.');
-  }
-
-  // Step 1: Snapshot existing mail IDs BEFORE we start waiting for new email
-  const existingMailIds = getCurrentMailIds();
-  log(`Step ${step}: Snapshotted ${existingMailIds.size} existing emails as "old"`);
-
-  // Fallback after just 3 attempts (~10s). In practice, the email is usually
-  // already in the list but has the same mailid (page was already open).
-  const FALLBACK_AFTER = 3;
-
-  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
-    log(`Polling QQ Mail... attempt ${attempt}/${maxAttempts}`);
-
-    // Refresh inbox (skip on first attempt, list is fresh)
-    if (attempt > 1) {
-      await refreshInbox();
-      await sleep(800);
-    }
-
-    const allItems = document.querySelectorAll('.mail-list-page-item[data-mailid]');
-    const useFallback = attempt > FALLBACK_AFTER;
-
-    // Phase 1 (attempt 1~3): only look at NEW emails (not in snapshot)
-    // Phase 2 (attempt 4+): fallback to first matching email in list (but skip used ones)
-    for (const item of allItems) {
-      const mailId = item.getAttribute('data-mailid');
-
-      if (!useFallback && existingMailIds.has(mailId)) continue;
-
-      // Skip mail IDs that have already been used in previous runs
-      if (usedMailIdSet.has(mailId)) continue;
-
-      const sender = (item.querySelector('.cmp-account-nick')?.textContent || '').toLowerCase();
-      const subject = (item.querySelector('.mail-subject')?.textContent || '').toLowerCase();
-      const digest = item.querySelector('.mail-digest')?.textContent || '';
-
-      const senderMatch = senderFilters.some(f => sender.includes(f.toLowerCase()));
-      const subjectMatch = subjectFilters.some(f => subject.includes(f.toLowerCase()));
-
-      if (senderMatch || subjectMatch) {
-        const code = extractVerificationCode(subject + ' ' + digest);
-        if (code) {
-          // Skip codes that have already been used
-          if (usedCodeSet.has(code)) {
-            log(`Step ${step}: Skipping already-used code ${code} (mailId: ${mailId})`);
-            continue;
-          }
-          const source = useFallback && existingMailIds.has(mailId) ? 'fallback-first-match' : 'new';
-          log(`Step ${step}: Code found: ${code} (${source}, subject: ${subject.slice(0, 40)})`, 'ok');
-          return { ok: true, code, emailTimestamp: Date.now(), mailId };
-        }
-      }
-    }
-
-    if (attempt === FALLBACK_AFTER + 1) {
-      log(`Step ${step}: No new emails after ${FALLBACK_AFTER} attempts, falling back to first matching email`, 'warn');
-    }
-
-    if (attempt < maxAttempts) {
-      await sleep(intervalMs);
-    }
-  }
-
-  throw new Error(
-    `No new matching email found after ${(maxAttempts * intervalMs / 1000).toFixed(0)}s. ` +
-    'Check QQ Mail manually. Email may be delayed or in spam folder.'
-  );
-}
-
-// ============================================================
-// Inbox Refresh
-// ============================================================
-
-async function refreshInbox() {
-  // Try multiple strategies to refresh the mail list
-
-  // Strategy 1: Click any visible refresh button
-  const refreshBtn = document.querySelector('[class*="refresh"], [title*="刷新"]');
-  if (refreshBtn) {
-    simulateClick(refreshBtn);
-    console.log(QQ_MAIL_PREFIX, 'Clicked refresh button');
-    await sleep(500);
-    return;
-  }
-
-  // Strategy 2: Click inbox in sidebar to reload list
-  const sidebarInbox = document.querySelector('a[href*="inbox"], [class*="folder-item"][class*="inbox"], [title="收件箱"]');
-  if (sidebarInbox) {
-    simulateClick(sidebarInbox);
-    console.log(QQ_MAIL_PREFIX, 'Clicked sidebar inbox');
-    await sleep(500);
-    return;
-  }
-
-  // Strategy 3: Click the folder name in toolbar
-  const folderName = document.querySelector('.toolbar-folder-name');
-  if (folderName) {
-    simulateClick(folderName);
-    console.log(QQ_MAIL_PREFIX, 'Clicked toolbar folder name');
-    await sleep(500);
-  }
-}
-
-// ============================================================
-// Verification Code Extraction
-// ============================================================
-
-function extractVerificationCode(text) {
-  // Pattern 1: Chinese format "代码为 370794" or "验证码...370794"
-  const matchCn = text.match(/(?:代码为|验证码[^0-9]*?)[\s::]*(\d{6})/);
-  if (matchCn) return matchCn[1];
-
-  // Pattern 2: English format "code is 370794" or "code: 370794"
-  const matchEn = text.match(/code[:\s]+is[:\s]+(\d{6})|code[:\s]+(\d{6})/i);
-  if (matchEn) return matchEn[1] || matchEn[2];
-
-  // Pattern 3: standalone 6-digit number (first occurrence)
-  const match6 = text.match(/\b(\d{6})\b/);
-  if (match6) return match6[1];
-
-  return null;
-}
+// content/qq-mail.js — Content script for QQ Mail (steps 4, 7)
+// Injected on: mail.qq.com, wx.mail.qq.com
+// NOTE: all_frames: true
+//
+// Strategy for avoiding stale codes:
+// 1. On poll start, snapshot all existing mail IDs as "old"
+// 2. On each poll cycle, refresh inbox and look for NEW items (not in snapshot)
+// 3. Only extract codes from NEW items that match sender/subject filters
+
+const QQ_MAIL_PREFIX = '[MultiPage:qq-mail]';
+const isTopFrame = window === window.top;
+
+console.log(QQ_MAIL_PREFIX, 'Content script loaded on', location.href, 'frame:', isTopFrame ? 'top' : 'child');
+
+// ============================================================
+// Message Handler
+// ============================================================
+
+chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
+  if (message.type === 'POLL_EMAIL') {
+    if (!isTopFrame) {
+      sendResponse({ ok: false, reason: 'wrong-frame' });
+      return;
+    }
+    resetStopState();
+    handlePollEmail(message.step, message.payload).then(result => {
+      sendResponse(result);
+    }).catch(err => {
+      if (isStopError(err)) {
+        log(`Step ${message.step}: Stopped by user.`, 'warn');
+        sendResponse({ stopped: true, error: err.message });
+        return;
+      }
+      reportError(message.step, err.message);
+      sendResponse({ error: err.message });
+    });
+    return true; // async response
+  }
+});
+
+// ============================================================
+// Get all current mail IDs from the list
+// ============================================================
+
+function getCurrentMailIds() {
+  const ids = new Set();
+  document.querySelectorAll('.mail-list-page-item[data-mailid]').forEach(item => {
+    ids.add(item.getAttribute('data-mailid'));
+  });
+  return ids;
+}
+
+// ============================================================
+// Email Polling
+// ============================================================
+
+async function handlePollEmail(step, payload) {
+  const { senderFilters, subjectFilters, maxAttempts, intervalMs, usedMailIds, usedCodes } = payload;
+
+  // Build sets of already-used mail IDs and codes to skip during fallback
+  const usedMailIdSet = new Set(usedMailIds || []);
+  const usedCodeSet = new Set(usedCodes || []);
+
+  log(`Step ${step}: Starting email poll (max ${maxAttempts} attempts, every ${intervalMs / 1000}s)`);
+
+  // Wait for mail list to load
+  try {
+    await waitForElement('.mail-list-page-item', 10000);
+    log(`Step ${step}: Mail list loaded`);
+  } catch {
+    throw new Error('Mail list did not load. Make sure QQ Mail inbox is open.');
+  }
+
+  // Step 1: Snapshot existing mail IDs BEFORE we start waiting for new email
+  const existingMailIds = getCurrentMailIds();
+  log(`Step ${step}: Snapshotted ${existingMailIds.size} existing emails as "old"`);
+
+  // Fallback after just 3 attempts (~10s). In practice, the email is usually
+  // already in the list but has the same mailid (page was already open).
+  const FALLBACK_AFTER = 3;
+
+  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
+    log(`Polling QQ Mail... attempt ${attempt}/${maxAttempts}`);
+
+    // Refresh inbox (skip on first attempt, list is fresh)
+    if (attempt > 1) {
+      await refreshInbox();
+      await sleep(800);
+    }
+
+    const allItems = document.querySelectorAll('.mail-list-page-item[data-mailid]');
+    const useFallback = attempt > FALLBACK_AFTER;
+
+    // Phase 1 (attempt 1~3): only look at NEW emails (not in snapshot)
+    // Phase 2 (attempt 4+): fallback to first matching email in list (but skip used ones)
+    for (const item of allItems) {
+      const mailId = item.getAttribute('data-mailid');
+
+      if (!useFallback && existingMailIds.has(mailId)) continue;
+
+      // Skip mail IDs that have already been used in previous runs
+      if (usedMailIdSet.has(mailId)) continue;
+
+      const sender = (item.querySelector('.cmp-account-nick')?.textContent || '').toLowerCase();
+      const subject = (item.querySelector('.mail-subject')?.textContent || '').toLowerCase();
+      const digest = item.querySelector('.mail-digest')?.textContent || '';
+
+      const senderMatch = senderFilters.some(f => sender.includes(f.toLowerCase()));
+      const subjectMatch = subjectFilters.some(f => subject.includes(f.toLowerCase()));
+
+      if (senderMatch || subjectMatch) {
+        const code = extractVerificationCode(subject + ' ' + digest);
+        if (code) {
+          // Skip codes that have already been used
+          if (usedCodeSet.has(code)) {
+            log(`Step ${step}: Skipping already-used code ${code} (mailId: ${mailId})`);
+            continue;
+          }
+          const source = useFallback && existingMailIds.has(mailId) ? 'fallback-first-match' : 'new';
+          log(`Step ${step}: Code found: ${code} (${source}, subject: ${subject.slice(0, 40)})`, 'ok');
+          return { ok: true, code, emailTimestamp: Date.now(), mailId };
+        }
+      }
+    }
+
+    if (attempt === FALLBACK_AFTER + 1) {
+      log(`Step ${step}: No new emails after ${FALLBACK_AFTER} attempts, falling back to first matching email`, 'warn');
+    }
+
+    if (attempt < maxAttempts) {
+      await sleep(intervalMs);
+    }
+  }
+
+  throw new Error(
+    `No new matching email found after ${(maxAttempts * intervalMs / 1000).toFixed(0)}s. ` +
+    'Check QQ Mail manually. Email may be delayed or in spam folder.'
+  );
+}
+
+// ============================================================
+// Inbox Refresh
+// ============================================================
+
+async function refreshInbox() {
+  // Try multiple strategies to refresh the mail list
+
+  // Strategy 1: Click any visible refresh button
+  const refreshBtn = document.querySelector('[class*="refresh"], [title*="刷新"]');
+  if (refreshBtn) {
+    simulateClick(refreshBtn);
+    console.log(QQ_MAIL_PREFIX, 'Clicked refresh button');
+    await sleep(500);
+    return;
+  }
+
+  // Strategy 2: Click inbox in sidebar to reload list
+  const sidebarInbox = document.querySelector('a[href*="inbox"], [class*="folder-item"][class*="inbox"], [title="收件箱"]');
+  if (sidebarInbox) {
+    simulateClick(sidebarInbox);
+    console.log(QQ_MAIL_PREFIX, 'Clicked sidebar inbox');
+    await sleep(500);
+    return;
+  }
+
+  // Strategy 3: Click the folder name in toolbar
+  const folderName = document.querySelector('.toolbar-folder-name');
+  if (folderName) {
+    simulateClick(folderName);
+    console.log(QQ_MAIL_PREFIX, 'Clicked toolbar folder name');
+    await sleep(500);
+  }
+}
+
+// ============================================================
+// Verification Code Extraction
+// ============================================================
+
+function extractVerificationCode(text) {
+  // Pattern 1: Chinese format "代码为 370794" or "验证码...370794"
+  const matchCn = text.match(/(?:代码为|验证码[^0-9]*?)[\s::]*(\d{6})/);
+  if (matchCn) return matchCn[1];
+
+  // Pattern 2: English format "code is 370794" or "code: 370794"
+  const matchEn = text.match(/code[:\s]+is[:\s]+(\d{6})|code[:\s]+(\d{6})/i);
+  if (matchEn) return matchEn[1] || matchEn[2];
+
+  // Pattern 3: standalone 6-digit number (first occurrence)
+  const match6 = text.match(/\b(\d{6})\b/);
+  if (match6) return match6[1];
+
+  return null;
+}

+ 766 - 703
content/signup-page.js

@@ -1,245 +1,305 @@
-// content/signup-page.js — Content script for OpenAI auth pages (steps 2, 3, 4-receive, 5)
-// Injected on: auth0.openai.com, auth.openai.com, accounts.openai.com
-
-console.log('[MultiPage:signup-page] Content script loaded on', location.href);
-
-// Listen for commands from Background
-chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
-  if (message.type === 'EXECUTE_STEP' || message.type === 'FILL_CODE' || message.type === 'STEP8_FIND_AND_CLICK' || message.type === 'CLICK_RESEND_EMAIL' || message.type === 'HANDLE_ABOUT_YOU') {
-    resetStopState();
-    handleCommand(message).then((result) => {
-      sendResponse({ ok: true, ...(result || {}) });
-    }).catch(err => {
-      if (isStopError(err)) {
-        log(`Step ${message.step || 8}: Stopped by user.`, 'warn');
-        sendResponse({ stopped: true, error: err.message });
-        return;
-      }
-
-      if (message.type === 'STEP8_FIND_AND_CLICK') {
-        log(`Step 8: ${err.message}`, 'error');
-        sendResponse({ error: err.message });
-        return;
-      }
-
-      reportError(message.step, err.message);
-      sendResponse({ error: err.message });
-    });
-    return true;
-  }
-});
-
-async function handleCommand(message) {
-  switch (message.type) {
-    case 'EXECUTE_STEP':
-      switch (message.step) {
-        case 2: return await step2_clickRegister();
-        case 3: return await step3_fillEmailPassword(message.payload);
-        case 5: return await step5_fillNameBirthday(message.payload);
-        case 6: return await step6_login(message.payload);
-        case 8: return await step8_findAndClick();
-        default: throw new Error(`signup-page.js does not handle step ${message.step}`);
-      }
-    case 'FILL_CODE':
-      // Step 4 = signup code, Step 7 = login code (same handler)
-      return await fillVerificationCode(message.step, message.payload);
-    case 'CLICK_RESEND_EMAIL':
-      return await clickResendEmail(message.step);
-    case 'STEP8_FIND_AND_CLICK':
-      return await step8_findAndClick();
-    case 'HANDLE_ABOUT_YOU':
-      return await handleAboutYouPage(message.payload);
-  }
-}
-
-// ============================================================
-// Step 2: Click Register
-// ============================================================
-
+// content/signup-page.js — Content script for OpenAI auth pages (steps 2, 3, 4-receive, 5)
+// Injected on: auth0.openai.com, auth.openai.com, accounts.openai.com
+
+console.log('[MultiPage:signup-page] Content script loaded on', location.href);
+
+// Listen for commands from Background
+chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
+  if (message.type === 'EXECUTE_STEP' || message.type === 'FILL_CODE' || message.type === 'STEP8_FIND_AND_CLICK' || message.type === 'CLICK_RESEND_EMAIL' || message.type === 'HANDLE_ABOUT_YOU') {
+    resetStopState();
+    handleCommand(message).then((result) => {
+      sendResponse({ ok: true, ...(result || {}) });
+    }).catch(err => {
+      if (isStopError(err)) {
+        log(`Step ${message.step || 8}: Stopped by user.`, 'warn');
+        sendResponse({ stopped: true, error: err.message });
+        return;
+      }
+
+      if (message.type === 'STEP8_FIND_AND_CLICK') {
+        log(`Step 8: ${err.message}`, 'error');
+        sendResponse({ error: err.message });
+        return;
+      }
+
+      reportError(message.step, err.message);
+      sendResponse({ error: err.message });
+    });
+    return true;
+  }
+});
+
+async function handleCommand(message) {
+  switch (message.type) {
+    case 'EXECUTE_STEP':
+      switch (message.step) {
+        case 2: return await step2_clickRegister();
+        case 3: return await step3_fillEmailPassword(message.payload);
+        case 5: return await step5_fillNameBirthday(message.payload);
+        case 6: return await step6_login(message.payload);
+        case 8: return await step8_findAndClick();
+        default: throw new Error(`signup-page.js does not handle step ${message.step}`);
+      }
+    case 'FILL_CODE':
+      // Step 4 = signup code, Step 7 = login code (same handler)
+      return await fillVerificationCode(message.step, message.payload);
+    case 'CLICK_RESEND_EMAIL':
+      return await clickResendEmail(message.step);
+    case 'STEP8_FIND_AND_CLICK':
+      return await step8_findAndClick();
+    case 'HANDLE_ABOUT_YOU':
+      return await handleAboutYouPage(message.payload);
+  }
+}
+
+// ============================================================
+// Step 2: Click Register
+// ============================================================
+
 async function step2_clickRegister() {
   log('Step 2: Looking for Register/Sign up button...');
-
-  let registerBtn = null;
-  try {
-    registerBtn = await waitForElementByText(
-      'a, button, [role="button"], [role="link"]',
-      /sign\s*up|register|create\s*account|注册/i,
-      10000
-    );
-  } catch {
-    // Some pages may have a direct link
-    try {
-      registerBtn = await waitForElement('a[href*="signup"], a[href*="register"]', 5000);
-    } catch {
-      throw new Error(
-        'Could not find Register/Sign up button. ' +
-        'Check auth page DOM in DevTools. URL: ' + location.href
-      );
-    }
+
+  let registerBtn = null;
+  try {
+    registerBtn = await waitForElementByText(
+      'a, button, [role="button"], [role="link"]',
+      /sign\s*up|register|create\s*account|注册/i,
+      10000
+    );
+  } catch {
+    // Some pages may have a direct link
+    try {
+      registerBtn = await waitForElement('a[href*="signup"], a[href*="register"]', 5000);
+    } catch {
+      throw new Error(
+        'Could not find Register/Sign up button. ' +
+        'Check auth page DOM in DevTools. URL: ' + location.href
+      );
+    }
   }
 
   await humanPause(450, 1200);
-  reportComplete(2);
   simulateClick(registerBtn);
   log('Step 2: Clicked Register button');
-}
-
-// ============================================================
-// Step 3: Fill Email & Password
-// ============================================================
 
-async function step3_fillEmailPassword(payload) {
-  const { email } = payload;
-  if (!email) throw new Error('No email provided. Paste email in Side Panel first.');
-
-  log(`Step 3: Filling email: ${email}`);
-
-  // Find email input
-  let emailInput = null;
+  // Don't mark step 2 complete until the signup form is actually reachable.
+  // This avoids auto-run racing ahead while a human-verification challenge
+  // or page transition is still in progress.
   try {
-    emailInput = await waitForElement(
+    await waitForElement(
       'input[type="email"], input[name="email"], input[name="username"], input[id*="email"], input[placeholder*="email"], input[placeholder*="Email"]',
-      10000
+      30000
     );
+    log('Step 2: Signup form is ready after human verification/page transition');
   } catch {
-    throw new Error('Could not find email input field on signup page. URL: ' + location.href);
-  }
-
-  await humanPause(500, 1400);
-  fillInput(emailInput, email);
-  log('Step 3: Email filled');
-
-  // Check if password field is on the same page
-  let passwordInput = document.querySelector('input[type="password"]');
-
-  if (!passwordInput) {
-    // Need to submit email first to get to password page
-    log('Step 3: No password field yet, submitting email first...');
-    const submitBtn = document.querySelector('button[type="submit"]')
-      || await waitForElementByText('button', /continue|next|submit|继续|下一步/i, 5000).catch(() => null);
-
-    if (submitBtn) {
-      await humanPause(400, 1100);
-      simulateClick(submitBtn);
-      log('Step 3: Submitted email, waiting for password field...');
-      await sleep(2000);
-    }
-
-    try {
-      passwordInput = await waitForElement('input[type="password"]', 10000);
-    } catch {
-      throw new Error('Could not find password input after submitting email. URL: ' + location.href);
+    // Fallback: some auth pages may delay rendering, but URL/path has already moved on.
+    if (/signup|register|email|continue/i.test(location.href)) {
+      log('Step 2: Signup page transition detected, proceeding', 'warn');
+    } else {
+      throw new Error('Register clicked, but signup form did not become ready after 30s. Human verification may still be pending. URL: ' + location.href);
     }
   }
 
-  if (!payload.password) throw new Error('No password provided. Step 3 requires a generated password.');
-  await humanPause(600, 1500);
-  fillInput(passwordInput, payload.password);
-  log('Step 3: Password filled');
-
-  // Report complete BEFORE submit, because submit causes page navigation
-  // which kills the content script connection
-  reportComplete(3, { email });
-
-  // Submit the form (page will navigate away after this)
-  await sleep(500);
-  const submitBtn = document.querySelector('button[type="submit"]')
-    || await waitForElementByText('button', /continue|sign\s*up|submit|注册|创建|create/i, 5000).catch(() => null);
-
-  if (submitBtn) {
-    await humanPause(500, 1300);
-    simulateClick(submitBtn);
-    log('Step 3: Form submitted');
-  }
+  reportComplete(2);
 }
-
-// ============================================================
-// Click "重新发送电子邮件" (used before step 4 and step 7 polling)
-// ============================================================
-
-async function clickResendEmail(step) {
-  log(`Step ${step}: Looking for "重新发送电子邮件" button...`);
-
-  let resendBtn = null;
-  try {
-    resendBtn = await waitForElementByText(
-      'a, button, [role="button"], [role="link"], span',
-      /重新发送电子邮件|resend\s*email/i,
-      10000
-    );
-  } catch {
-    log(`Step ${step}: "重新发送电子邮件" button not found, skipping`, 'warn');
-    return;
-  }
-
-  // Prevent parent form POST submission (Remix/React Router route without action)
-  const parentForm = resendBtn.closest('form');
-  const blockSubmit = (e) => e.preventDefault();
-  if (parentForm) parentForm.addEventListener('submit', blockSubmit, { once: true });
-
-  await humanPause(400, 1000);
-  resendBtn.click();
-  log(`Step ${step}: Clicked "重新发送电子邮件"`, 'ok');
-  await sleep(2000);
-
-  if (parentForm) parentForm.removeEventListener('submit', blockSubmit);
+
+// ============================================================
+// Step 3: Fill Email & Password
+// ============================================================
+
+async function step3_fillEmailPassword(payload) {
+  const { email } = payload;
+  if (!email) throw new Error('No email provided. Paste email in Side Panel first.');
+
+  log(`Step 3: Filling email: ${email}`);
+
+  // Find email input
+  let emailInput = null;
+  try {
+    emailInput = await waitForElement(
+      'input[type="email"], input[name="email"], input[name="username"], input[id*="email"], input[placeholder*="email"], input[placeholder*="Email"]',
+      10000
+    );
+  } catch {
+    throw new Error('Could not find email input field on signup page. URL: ' + location.href);
+  }
+
+  await humanPause(500, 1400);
+  fillInput(emailInput, email);
+  log('Step 3: Email filled');
+
+  // Check if password field is on the same page
+  let passwordInput = document.querySelector('input[type="password"]');
+
+  if (!passwordInput) {
+    // Need to submit email first to get to password page
+    log('Step 3: No password field yet, submitting email first...');
+    const submitBtn = document.querySelector('button[type="submit"]')
+      || await waitForElementByText('button', /continue|next|submit|继续|下一步/i, 5000).catch(() => null);
+
+    if (submitBtn) {
+      await humanPause(400, 1100);
+      simulateClick(submitBtn);
+      log('Step 3: Submitted email, waiting for password field...');
+      await sleep(2000);
+    }
+
+    try {
+      passwordInput = await waitForElement('input[type="password"]', 10000);
+    } catch {
+      throw new Error('Could not find password input after submitting email. URL: ' + location.href);
+    }
+  }
+
+  if (!payload.password) throw new Error('No password provided. Step 3 requires a generated password.');
+  await humanPause(600, 1500);
+  fillInput(passwordInput, payload.password);
+  log('Step 3: Password filled');
+
+  // Report complete BEFORE submit, because submit causes page navigation
+  // which kills the content script connection
+  reportComplete(3, { email });
+
+  // Submit the form (page will navigate away after this)
+  await sleep(500);
+  const submitBtn = document.querySelector('button[type="submit"]')
+    || await waitForElementByText('button', /continue|sign\s*up|submit|注册|创建|create/i, 5000).catch(() => null);
+
+  if (submitBtn) {
+    await humanPause(500, 1300);
+    simulateClick(submitBtn);
+    log('Step 3: Form submitted');
+  }
+}
+
+// ============================================================
+// Click "重新发送电子邮件" (used before step 4 and step 7 polling)
+// ============================================================
+
+async function clickResendEmail(step) {
+  log(`Step ${step}: Looking for "重新发送电子邮件" button...`);
+
+  let resendBtn = null;
+  try {
+    resendBtn = await waitForElementByText(
+      'a, button, [role="button"], [role="link"], span',
+      /重新发送电子邮件|resend\s*email/i,
+      10000
+    );
+  } catch {
+    log(`Step ${step}: "重新发送电子邮件" button not found, skipping`, 'warn');
+    return;
+  }
+
+  // Prevent parent form POST submission (Remix/React Router route without action)
+  const parentForm = resendBtn.closest('form');
+  const blockSubmit = (e) => e.preventDefault();
+  if (parentForm) parentForm.addEventListener('submit', blockSubmit, { once: true });
+
+  await humanPause(400, 1000);
+  resendBtn.click();
+  log(`Step ${step}: Clicked "重新发送电子邮件"`, 'ok');
+  await sleep(2000);
+
+  if (parentForm) parentForm.removeEventListener('submit', blockSubmit);
+}
+
+// ============================================================
+// Fill Verification Code (used by step 4 and step 7)
+// ============================================================
+
+async function fillVerificationCode(step, payload) {
+  const { code } = payload;
+  if (!code) throw new Error('No verification code provided.');
+
+  log(`Step ${step}: Filling verification code: ${code}`);
+
+  // Find code input — could be a single input or multiple separate inputs
+  let codeInput = null;
+  try {
+    codeInput = await waitForElement(
+      'input[name="code"], input[name="otp"], input[type="text"][maxlength="6"], input[aria-label*="code"], input[placeholder*="code"], input[placeholder*="Code"], input[inputmode="numeric"]',
+      10000
+    );
+  } catch {
+    // Check for multiple single-digit inputs (common pattern)
+    const singleInputs = document.querySelectorAll('input[maxlength="1"]');
+    if (singleInputs.length >= 6) {
+      log(`Step ${step}: Found single-digit code inputs, filling individually...`);
+      for (let i = 0; i < 6 && i < singleInputs.length; i++) {
+        fillInput(singleInputs[i], code[i]);
+        await sleep(100);
+      }
+      await sleep(1000);
+      // Verify page navigated away from verification page
+      await verifyCodeAccepted(step);
+      reportComplete(step);
+      return;
+    }
+    throw new Error('Could not find verification code input. URL: ' + location.href);
+  }
+
+  fillInput(codeInput, code);
+  log(`Step ${step}: Code filled`);
+
+  // Submit
+  await sleep(500);
+  const submitBtn = document.querySelector('button[type="submit"]')
+    || await waitForElementByText('button', /verify|confirm|submit|continue|确认|验证|继续/i, 5000).catch(() => null);
+
+  if (submitBtn) {
+    await humanPause(450, 1200);
+    simulateClick(submitBtn);
+    log(`Step ${step}: Verification submitted`);
+  }
+
+  // Wait and verify the page actually moved past the verification page
+  await verifyCodeAccepted(step);
+  reportComplete(step);
+}
+
+function hasVisibleVerificationInputs() {
+  const singleInputs = Array.from(document.querySelectorAll('input[maxlength="1"]'))
+    .filter(isElementVisible);
+  if (singleInputs.length >= 6) return true;
+
+  const selectors = [
+    'input[name="code"]',
+    'input[name="otp"]',
+    'input[type="text"][maxlength="6"]',
+    'input[aria-label*="code" i]',
+    'input[placeholder*="code" i]',
+    'input[inputmode="numeric"]',
+  ];
+  return selectors.some(selector => Array.from(document.querySelectorAll(selector)).some(isElementVisible));
 }
 
-// ============================================================
-// Fill Verification Code (used by step 4 and step 7)
-// ============================================================
-
-async function fillVerificationCode(step, payload) {
-  const { code } = payload;
-  if (!code) throw new Error('No verification code provided.');
-
-  log(`Step ${step}: Filling verification code: ${code}`);
-
-  // Find code input — could be a single input or multiple separate inputs
-  let codeInput = null;
-  try {
-    codeInput = await waitForElement(
-      'input[name="code"], input[name="otp"], input[type="text"][maxlength="6"], input[aria-label*="code"], input[placeholder*="code"], input[placeholder*="Code"], input[inputmode="numeric"]',
-      10000
-    );
-  } catch {
-    // Check for multiple single-digit inputs (common pattern)
-    const singleInputs = document.querySelectorAll('input[maxlength="1"]');
-    if (singleInputs.length >= 6) {
-      log(`Step ${step}: Found single-digit code inputs, filling individually...`);
-      for (let i = 0; i < 6 && i < singleInputs.length; i++) {
-        fillInput(singleInputs[i], code[i]);
-        await sleep(100);
-      }
-      await sleep(1000);
-      // Verify page navigated away from verification page
-      await verifyCodeAccepted(step);
-      reportComplete(step);
-      return;
-    }
-    throw new Error('Could not find verification code input. URL: ' + location.href);
-  }
-
-  fillInput(codeInput, code);
-  log(`Step ${step}: Code filled`);
-
-  // Submit
-  await sleep(500);
-  const submitBtn = document.querySelector('button[type="submit"]')
-    || await waitForElementByText('button', /verify|confirm|submit|continue|确认|验证|继续/i, 5000).catch(() => null);
-
-  if (submitBtn) {
-    await humanPause(450, 1200);
-    simulateClick(submitBtn);
-    log(`Step ${step}: Verification submitted`);
+function hasPostVerificationSignals() {
+  const currentPath = location.pathname;
+  if (currentPath.includes('/about-you')) return true;
+
+  const nextStepSelectors = [
+    'input[name="name"]',
+    'input[autocomplete="name"]',
+    'input[name="birthday"]',
+    '[role="spinbutton"][data-type="year"]',
+    'button[data-testid*="continue"]',
+  ];
+  if (nextStepSelectors.some(selector => Array.from(document.querySelectorAll(selector)).some(isElementVisible))) {
+    return true;
   }
 
-  // Wait and verify the page actually moved past the verification page
-  await verifyCodeAccepted(step);
-  reportComplete(step);
+  const pageText = (document.body?.innerText || '').slice(0, 4000).toLowerCase();
+  return (
+    pageText.includes('使用 chatgpt 登录到 codex')
+    || pageText.includes('continue to codex')
+    || pageText.includes('consent')
+    || pageText.includes('生日')
+    || pageText.includes('full name')
+  );
 }
 
-async function verifyCodeAccepted(step, timeout = 8000) {
+async function verifyCodeAccepted(step, timeout = 15000) {
   const start = Date.now();
   const verificationPaths = ['/email-verification', '/verify', '/otp'];
 
@@ -247,491 +307,494 @@ async function verifyCodeAccepted(step, timeout = 8000) {
     throwIfStopped();
     const currentPath = location.pathname;
     const stillOnVerification = verificationPaths.some(p => currentPath.includes(p));
-    if (!stillOnVerification) {
+    const verificationInputsVisible = hasVisibleVerificationInputs();
+    const postVerificationSignals = hasPostVerificationSignals();
+
+    if (!stillOnVerification || (!verificationInputsVisible && postVerificationSignals)) {
       log(`Step ${step}: Verification code accepted, page navigated to ${currentPath}`);
       return;
     }
 
     // Check for error messages on the page (wrong code)
-    const errorEl = document.querySelector('[class*="error"], [class*="Error"], [role="alert"]');
-    if (errorEl) {
-      const errorText = (errorEl.textContent || '').trim();
-      if (errorText && errorText.length < 200) {
-        throw new Error(`Verification code rejected: ${errorText}. URL: ${location.href}`);
-      }
-    }
-
-    await sleep(500);
-  }
-
-  // Still on verification page after timeout — code was likely wrong
-  throw new Error(`Verification code ${step === 4 ? 'signup' : 'login'} was not accepted (page did not navigate). URL: ${location.href}`);
-}
-
-// ============================================================
-// Step 6: Login with registered account (on OAuth auth page)
-// ============================================================
-
-async function step6_login(payload) {
-  const { email, password } = payload;
-  if (!email) throw new Error('No email provided for login.');
-
-  log(`Step 6: Logging in with ${email}...`);
-
-  // Wait for email input on the auth page
-  let emailInput = null;
-  try {
-    emailInput = await waitForElement(
-      'input[type="email"], input[name="email"], input[name="username"], input[id*="email"], input[placeholder*="email" i], input[placeholder*="Email"]',
-      15000
-    );
-  } catch {
-    throw new Error('Could not find email input on login page. URL: ' + location.href);
-  }
-
-  await humanPause(500, 1400);
-  fillInput(emailInput, email);
-  log('Step 6: Email filled');
-
-  // Submit email
-  await sleep(500);
-  const submitBtn1 = document.querySelector('button[type="submit"]')
-    || await waitForElementByText('button', /continue|next|submit|继续|下一步/i, 5000).catch(() => null);
-  if (submitBtn1) {
-    await humanPause(400, 1100);
-    simulateClick(submitBtn1);
-    log('Step 6: Submitted email');
-  }
-
-  const passwordInput = await waitForLoginPasswordField();
-  if (passwordInput) {
-    log('Step 6: Password field found, filling password...');
-    await humanPause(550, 1450);
-    fillInput(passwordInput, password);
-
-    await sleep(500);
-    const submitBtn2 = document.querySelector('button[type="submit"]')
-      || await waitForElementByText('button', /continue|log\s*in|submit|sign\s*in|登录|继续/i, 5000).catch(() => null);
-    // Report complete BEFORE submit in case page navigates
-    reportComplete(6, { needsOTP: true });
-
-    if (submitBtn2) {
-      await humanPause(450, 1200);
-      simulateClick(submitBtn2);
-      log('Step 6: Submitted password, may need verification code (step 7)');
-    }
-    return;
-  }
-
-  // No password field — OTP flow
-  log('Step 6: No password field. OTP flow or auto-redirect.');
-  reportComplete(6, { needsOTP: true });
-}
-
-async function waitForLoginPasswordField(timeout = 25000) {
-  const start = Date.now();
-
-  while (Date.now() - start < timeout) {
-    throwIfStopped();
-
-    const passwordInput = findVisiblePasswordInput();
-    if (passwordInput) {
-      return passwordInput;
-    }
-
-    await sleep(250);
-  }
-
-  log(`Step 6: Password field did not appear within ${Math.round(timeout / 1000)}s.`, 'warn');
-  return null;
-}
-
-function findVisiblePasswordInput() {
-  const inputs = document.querySelectorAll('input[type="password"]');
-  for (const input of inputs) {
-    if (isElementVisible(input)) {
-      return input;
-    }
-  }
-  return null;
-}
-
-function isElementVisible(el) {
-  if (!el) return false;
-  const style = window.getComputedStyle(el);
-  if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
-    return false;
-  }
-  const rect = el.getBoundingClientRect();
-  return rect.width > 0 && rect.height > 0;
-}
-
-// ============================================================
-// Step 8: Find "继续" on OAuth consent page for debugger click
-// ============================================================
-// After login + verification, page shows:
-// "使用 ChatGPT 登录到 Codex" with a "继续" submit button.
-// Background performs the actual click through the debugger Input API.
-
-async function step8_findAndClick() {
-  log('Step 8: Looking for OAuth consent "继续" button...');
-
-  const continueBtn = await findContinueButton();
-  await waitForButtonEnabled(continueBtn);
-
-  await humanPause(350, 900);
-  continueBtn.scrollIntoView({ behavior: 'smooth', block: 'center' });
-  continueBtn.focus();
-  await sleep(250);
-
-  // Click using native .click() — more reliable than synthetic MouseEvent for trusted forms
-  continueBtn.click();
-  log('Step 8: Clicked "继续" button via .click()', 'ok');
-
-  // Fallback: if the button is inside a form, also try submitting the form directly
-  await sleep(2000);
-  const form = continueBtn.closest('form');
-  if (form && location.href.includes('authorize')) {
-    log('Step 8: Also submitting parent form as fallback...');
-    form.requestSubmit ? form.requestSubmit(continueBtn) : form.submit();
-  }
-
-  return {
-    clicked: true,
-    buttonText: (continueBtn.textContent || '').trim(),
-    url: location.href,
-  };
-}
-
-async function findContinueButton() {
-  try {
-    return await waitForElement(
-      'button[type="submit"][data-dd-action-name="Continue"], button[type="submit"]._primary_3rdp0_107',
-      10000
-    );
-  } catch {
-    try {
-      return await waitForElementByText('button', /继续|Continue/, 5000);
-    } catch {
-      throw new Error('Could not find "继续" button on OAuth consent page. URL: ' + location.href);
-    }
-  }
-}
-
-async function waitForButtonEnabled(button, timeout = 8000) {
-  const start = Date.now();
-  while (Date.now() - start < timeout) {
-    throwIfStopped();
-    if (isButtonEnabled(button)) return;
-    await sleep(150);
-  }
-  throw new Error('"继续" button stayed disabled for too long. URL: ' + location.href);
-}
-
-function isButtonEnabled(button) {
-  return Boolean(button)
-    && !button.disabled
-    && button.getAttribute('aria-disabled') !== 'true';
-}
-
-function getSerializableRect(el) {
-  const rect = el.getBoundingClientRect();
-  if (!rect.width || !rect.height) {
-    throw new Error('"继续" button has no clickable size after scrolling. URL: ' + location.href);
-  }
-
-  return {
-    left: rect.left,
-    top: rect.top,
-    width: rect.width,
-    height: rect.height,
-    centerX: rect.left + (rect.width / 2),
-    centerY: rect.top + (rect.height / 2),
-  };
-}
-
-// ============================================================
-// Handle /about-you page (appears after login if birthday was missing)
-// ============================================================
-
-async function handleAboutYouPage(payload) {
-  if (!location.href.includes('/about-you')) {
-    return { handled: false };
-  }
-
-  log('Detected /about-you page, filling birthday info...');
-
-  const { year, month, day, fullName } = payload || {};
-  if (!year || !month || !day) {
-    throw new Error('No birthday data available for about-you page.');
-  }
-
-  // Wait for the page to fully load
-  await sleep(1000);
-
-  // Fill name if present and empty
-  const nameInput = document.querySelector('input[name="name"], input[placeholder*="全名"], input[autocomplete="name"]');
-  if (nameInput && !nameInput.value && fullName) {
-    fillInput(nameInput, fullName);
-    log('About-you: Name filled');
-    await humanPause(300, 800);
-  }
-
-  // Fill birthday using the shared helper
-  await fillBirthdayFields(year, month, day, 'About-you');
-
-  // Click continue/submit button
-  await sleep(500);
-  const submitBtn = document.querySelector('button[type="submit"]')
-    || await waitForElementByText('button', /continue|继续|完成|done|agree|submit/i, 5000).catch(() => null);
-  if (submitBtn) {
-    await humanPause(500, 1300);
-    simulateClick(submitBtn);
-    log('About-you: Submitted form');
-  }
-
-  return { handled: true };
-}
-
-// ============================================================
-// Shared birthday filling helper (used by Step 5 and about-you)
-// ============================================================
-
-async function fillBirthdayFields(year, month, day, logPrefix) {
-  const prefix = logPrefix || 'Birthday';
-
-  // Strategy 1: React Aria DateField spinbuttons
-  const yearSpinner = document.querySelector('[role="spinbutton"][data-type="year"]');
-  const monthSpinner = document.querySelector('[role="spinbutton"][data-type="month"]');
-  const daySpinner = document.querySelector('[role="spinbutton"][data-type="day"]');
-
-  if (yearSpinner && monthSpinner && daySpinner) {
-    log(`${prefix}: Filling spinbutton date fields...`);
-
-    async function setSpinButton(el, value) {
-      el.focus();
-      await sleep(100);
-      document.execCommand('selectAll', false, null);
-      await sleep(50);
-
-      const valueStr = String(value);
-      for (const char of valueStr) {
-        el.dispatchEvent(new KeyboardEvent('keydown', { key: char, code: `Digit${char}`, bubbles: true }));
-        el.dispatchEvent(new KeyboardEvent('keypress', { key: char, code: `Digit${char}`, bubbles: true }));
-        el.dispatchEvent(new InputEvent('beforeinput', { inputType: 'insertText', data: char, bubbles: true }));
-        el.dispatchEvent(new InputEvent('input', { inputType: 'insertText', data: char, bubbles: true }));
-        await sleep(50);
-      }
-
-      el.dispatchEvent(new KeyboardEvent('keyup', { key: 'Tab', code: 'Tab', bubbles: true }));
-      el.blur();
-      await sleep(100);
-    }
-
-    await humanPause(450, 1100);
-    await setSpinButton(yearSpinner, year);
-    await humanPause(250, 650);
-    await setSpinButton(monthSpinner, String(month).padStart(2, '0'));
-    await humanPause(250, 650);
-    await setSpinButton(daySpinner, String(day).padStart(2, '0'));
-    log(`${prefix}: Spinbutton date filled: ${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`);
-    return true;
-  }
-
-  // Strategy 2: Select dropdowns (month/day/year)
-  const selects = document.querySelectorAll('select');
-  if (selects.length >= 2) {
-    let monthSelect = null, daySelect = null, yearSelect = null;
-    for (const sel of selects) {
-      const name = (sel.name || sel.id || sel.getAttribute('aria-label') || '').toLowerCase();
-      const opts = Array.from(sel.options).map(o => o.value);
-      if (name.includes('month') || name.includes('mm')) {
-        monthSelect = sel;
-      } else if (name.includes('day') || name.includes('dd')) {
-        daySelect = sel;
-      } else if (name.includes('year') || name.includes('yyyy')) {
-        yearSelect = sel;
-      } else {
-        // Heuristic: identify by option count and values
-        const numericOpts = opts.filter(v => /^\d+$/.test(v));
-        if (!monthSelect && numericOpts.length >= 12 && numericOpts.length <= 13) {
-          monthSelect = sel;
-        } else if (!daySelect && numericOpts.length >= 28 && numericOpts.length <= 32) {
-          daySelect = sel;
-        } else if (!yearSelect && numericOpts.some(v => Number(v) > 1900 && Number(v) < 2100)) {
-          yearSelect = sel;
-        }
-      }
-    }
-
-    if (monthSelect || daySelect || yearSelect) {
-      log(`${prefix}: Filling select dropdown date fields...`);
-      if (monthSelect) {
-        setSelectValue(monthSelect, String(month));
-        await humanPause(200, 500);
-      }
-      if (daySelect) {
-        setSelectValue(daySelect, String(day));
-        await humanPause(200, 500);
-      }
-      if (yearSelect) {
-        setSelectValue(yearSelect, String(year));
-        await humanPause(200, 500);
-      }
-      log(`${prefix}: Select date filled: ${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`);
-      return true;
-    }
-  }
-
-  // Strategy 3: input[type="date"]
-  const dateInput = document.querySelector('input[type="date"]');
-  if (dateInput) {
-    const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
-    const nativeSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
-    nativeSetter.call(dateInput, dateStr);
-    dateInput.dispatchEvent(new Event('input', { bubbles: true }));
-    dateInput.dispatchEvent(new Event('change', { bubbles: true }));
-    log(`${prefix}: Date input filled: ${dateStr}`);
-    return true;
-  }
-
-  // Strategy 4: Hidden input[name="birthday"] (fallback)
-  const hiddenBirthday = document.querySelector('input[name="birthday"]');
-  if (hiddenBirthday) {
-    const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
-    hiddenBirthday.value = dateStr;
-    hiddenBirthday.dispatchEvent(new Event('change', { bubbles: true }));
-    log(`${prefix}: Hidden birthday input set: ${dateStr}`);
-    return true;
-  }
-
-  return false;
-}
-
-function setSelectValue(selectEl, value) {
-  // Try exact match first, then try padded value
-  const candidates = [value, value.padStart(2, '0')];
-  for (const v of candidates) {
-    const option = Array.from(selectEl.options).find(o => o.value === v || o.textContent.trim() === v);
-    if (option) {
-      selectEl.value = option.value;
-      selectEl.dispatchEvent(new Event('change', { bubbles: true }));
-      selectEl.dispatchEvent(new Event('input', { bubbles: true }));
-      return;
-    }
-  }
-  // Last resort: set by index if value is numeric
-  const numVal = Number(value);
-  if (!isNaN(numVal) && numVal > 0 && numVal < selectEl.options.length) {
-    selectEl.selectedIndex = numVal;
-    selectEl.dispatchEvent(new Event('change', { bubbles: true }));
-    selectEl.dispatchEvent(new Event('input', { bubbles: true }));
-  }
-}
-
-// ============================================================
-// Step 5: Fill Name & Birthday / Age
-// ============================================================
-
-async function step5_fillNameBirthday(payload) {
-  const { firstName, lastName, age, year, month, day } = payload;
-  if (!firstName || !lastName) throw new Error('No name data provided.');
-
-  const resolvedAge = age ?? (year ? new Date().getFullYear() - Number(year) : null);
-  const hasBirthdayData = [year, month, day].every(value => value != null && !Number.isNaN(Number(value)));
-  if (!hasBirthdayData && (resolvedAge == null || Number.isNaN(Number(resolvedAge)))) {
-    throw new Error('No birthday or age data provided.');
-  }
-
-  const fullName = `${firstName} ${lastName}`;
-  log(`Step 5: Filling name: ${fullName}`);
-
-  // Actual DOM structure:
-  // - Full name: <input name="name" placeholder="全名" type="text">
-  // - Birthday: React Aria DateField or hidden input[name="birthday"]
-  // - Age: <input name="age" type="text|number">
-
-  // --- Full Name (single field, not first+last) ---
-  let nameInput = null;
-  try {
-    nameInput = await waitForElement(
-      'input[name="name"], input[placeholder*="全名"], input[autocomplete="name"]',
-      10000
-    );
-  } catch {
-    throw new Error('Could not find name input. URL: ' + location.href);
-  }
-  await humanPause(500, 1300);
-  fillInput(nameInput, fullName);
-  log(`Step 5: Name filled: ${fullName}`);
-
-  // Detect birthday/age input type with polling
-  let ageInput = null;
-  let hasBirthdayUI = false;
-
-  for (let i = 0; i < 100; i++) {
-    ageInput = document.querySelector('input[name="age"]');
-    // Some pages include a hidden birthday input even though the real UI is "age".
-    // In that case we must prioritize filling age to satisfy required validation.
-    if (ageInput) break;
-
-    // Check for any supported birthday UI (spinbuttons, selects, date input, hidden)
-    const yearSpinner = document.querySelector('[role="spinbutton"][data-type="year"]');
-    const monthSpinner = document.querySelector('[role="spinbutton"][data-type="month"]');
-    const daySpinner = document.querySelector('[role="spinbutton"][data-type="day"]');
-    const hiddenBirthday = document.querySelector('input[name="birthday"]');
-    const dateInput = document.querySelector('input[type="date"]');
-    const selects = document.querySelectorAll('select');
-
-    if ((yearSpinner && monthSpinner && daySpinner) || hiddenBirthday || dateInput || selects.length >= 2) {
-      hasBirthdayUI = true;
-      break;
-    }
-    await sleep(100);
-  }
-
-  if (hasBirthdayUI) {
-    if (!hasBirthdayData) {
-      throw new Error('Birthday field detected, but no birthday data provided.');
-    }
-
-    // Use shared helper that tries spinbuttons → selects → date input → hidden input
-    const filled = await fillBirthdayFields(year, month, day, 'Step 5');
-    if (!filled) {
-      log('Step 5: Warning - could not fill any visible birthday control', 'warn');
-    }
-  } else if (ageInput) {
-    if (resolvedAge == null || Number.isNaN(Number(resolvedAge))) {
-      throw new Error('Age field detected, but no age data provided.');
-    }
-    await humanPause(500, 1300);
-    fillInput(ageInput, String(resolvedAge));
-    log(`Step 5: Age filled: ${resolvedAge}`);
-
-    // Some age-mode pages still submit a hidden birthday field.
-    // Keep it aligned with generated data so backend validation won't reject.
-    const hiddenBirthday = document.querySelector('input[name="birthday"]');
-    if (hiddenBirthday && hasBirthdayData) {
-      const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
-      hiddenBirthday.value = dateStr;
-      hiddenBirthday.dispatchEvent(new Event('change', { bubbles: true }));
-      log(`Step 5: Hidden birthday input set (age mode): ${dateStr}`);
-    }
-  } else {
-    throw new Error('Could not find birthday or age input. URL: ' + location.href);
-  }
-
-  // Click "完成帐户创建" button
-  await sleep(500);
-  const completeBtn = document.querySelector('button[type="submit"]')
-    || await waitForElementByText('button', /完成|create|continue|finish|done|agree/i, 5000).catch(() => null);
-
-  // Report complete BEFORE submit (page navigates to add-phone after this)
-  reportComplete(5);
-
-  if (completeBtn) {
-    await humanPause(500, 1300);
-    simulateClick(completeBtn);
-    log('Step 5: Clicked "完成帐户创建"');
-  }
-}
+    const errorEl = document.querySelector('[class*="error"], [class*="Error"], [role="alert"]');
+    if (errorEl) {
+      const errorText = (errorEl.textContent || '').trim();
+      if (errorText && errorText.length < 200) {
+        throw new Error(`Verification code rejected: ${errorText}. URL: ${location.href}`);
+      }
+    }
+
+    await sleep(500);
+  }
+
+  // Still on verification page after timeout — code was likely wrong
+  throw new Error(`Verification code ${step === 4 ? 'signup' : 'login'} was not accepted (page did not navigate). URL: ${location.href}`);
+}
+
+// ============================================================
+// Step 6: Login with registered account (on OAuth auth page)
+// ============================================================
+
+async function step6_login(payload) {
+  const { email, password } = payload;
+  if (!email) throw new Error('No email provided for login.');
+
+  log(`Step 6: Logging in with ${email}...`);
+
+  // Wait for email input on the auth page
+  let emailInput = null;
+  try {
+    emailInput = await waitForElement(
+      'input[type="email"], input[name="email"], input[name="username"], input[id*="email"], input[placeholder*="email" i], input[placeholder*="Email"]',
+      15000
+    );
+  } catch {
+    throw new Error('Could not find email input on login page. URL: ' + location.href);
+  }
+
+  await humanPause(500, 1400);
+  fillInput(emailInput, email);
+  log('Step 6: Email filled');
+
+  // Submit email
+  await sleep(500);
+  const submitBtn1 = document.querySelector('button[type="submit"]')
+    || await waitForElementByText('button', /continue|next|submit|继续|下一步/i, 5000).catch(() => null);
+  if (submitBtn1) {
+    await humanPause(400, 1100);
+    simulateClick(submitBtn1);
+    log('Step 6: Submitted email');
+  }
+
+  const passwordInput = await waitForLoginPasswordField();
+  if (passwordInput) {
+    log('Step 6: Password field found, filling password...');
+    await humanPause(550, 1450);
+    fillInput(passwordInput, password);
+
+    await sleep(500);
+    const submitBtn2 = document.querySelector('button[type="submit"]')
+      || await waitForElementByText('button', /continue|log\s*in|submit|sign\s*in|登录|继续/i, 5000).catch(() => null);
+    // Report complete BEFORE submit in case page navigates
+    reportComplete(6, { needsOTP: true });
+
+    if (submitBtn2) {
+      await humanPause(450, 1200);
+      simulateClick(submitBtn2);
+      log('Step 6: Submitted password, may need verification code (step 7)');
+    }
+    return;
+  }
+
+  // No password field — OTP flow
+  log('Step 6: No password field. OTP flow or auto-redirect.');
+  reportComplete(6, { needsOTP: true });
+}
+
+async function waitForLoginPasswordField(timeout = 25000) {
+  const start = Date.now();
+
+  while (Date.now() - start < timeout) {
+    throwIfStopped();
+
+    const passwordInput = findVisiblePasswordInput();
+    if (passwordInput) {
+      return passwordInput;
+    }
+
+    await sleep(250);
+  }
+
+  log(`Step 6: Password field did not appear within ${Math.round(timeout / 1000)}s.`, 'warn');
+  return null;
+}
+
+function findVisiblePasswordInput() {
+  const inputs = document.querySelectorAll('input[type="password"]');
+  for (const input of inputs) {
+    if (isElementVisible(input)) {
+      return input;
+    }
+  }
+  return null;
+}
+
+function isElementVisible(el) {
+  if (!el) return false;
+  const style = window.getComputedStyle(el);
+  if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
+    return false;
+  }
+  const rect = el.getBoundingClientRect();
+  return rect.width > 0 && rect.height > 0;
+}
+
+// ============================================================
+// Step 8: Find "继续" on OAuth consent page for debugger click
+// ============================================================
+// After login + verification, page shows:
+// "使用 ChatGPT 登录到 Codex" with a "继续" submit button.
+// Background performs the actual click through the debugger Input API.
+
+async function step8_findAndClick() {
+  log('Step 8: Looking for OAuth consent "继续" button...');
+
+  const continueBtn = await findContinueButton();
+  await waitForButtonEnabled(continueBtn);
+
+  await humanPause(350, 900);
+  continueBtn.scrollIntoView({ behavior: 'smooth', block: 'center' });
+  continueBtn.focus();
+  await sleep(250);
+
+  // Click using native .click() — more reliable than synthetic MouseEvent for trusted forms
+  continueBtn.click();
+  log('Step 8: Clicked "继续" button via .click()', 'ok');
+
+  // Fallback: if the button is inside a form, also try submitting the form directly
+  await sleep(2000);
+  const form = continueBtn.closest('form');
+  if (form && location.href.includes('authorize')) {
+    log('Step 8: Also submitting parent form as fallback...');
+    form.requestSubmit ? form.requestSubmit(continueBtn) : form.submit();
+  }
+
+  return {
+    clicked: true,
+    buttonText: (continueBtn.textContent || '').trim(),
+    url: location.href,
+  };
+}
+
+async function findContinueButton() {
+  try {
+    return await waitForElement(
+      'button[type="submit"][data-dd-action-name="Continue"], button[type="submit"]._primary_3rdp0_107',
+      10000
+    );
+  } catch {
+    try {
+      return await waitForElementByText('button', /继续|Continue/, 5000);
+    } catch {
+      throw new Error('Could not find "继续" button on OAuth consent page. URL: ' + location.href);
+    }
+  }
+}
+
+async function waitForButtonEnabled(button, timeout = 8000) {
+  const start = Date.now();
+  while (Date.now() - start < timeout) {
+    throwIfStopped();
+    if (isButtonEnabled(button)) return;
+    await sleep(150);
+  }
+  throw new Error('"继续" button stayed disabled for too long. URL: ' + location.href);
+}
+
+function isButtonEnabled(button) {
+  return Boolean(button)
+    && !button.disabled
+    && button.getAttribute('aria-disabled') !== 'true';
+}
+
+function getSerializableRect(el) {
+  const rect = el.getBoundingClientRect();
+  if (!rect.width || !rect.height) {
+    throw new Error('"继续" button has no clickable size after scrolling. URL: ' + location.href);
+  }
+
+  return {
+    left: rect.left,
+    top: rect.top,
+    width: rect.width,
+    height: rect.height,
+    centerX: rect.left + (rect.width / 2),
+    centerY: rect.top + (rect.height / 2),
+  };
+}
+
+// ============================================================
+// Handle /about-you page (appears after login if birthday was missing)
+// ============================================================
+
+async function handleAboutYouPage(payload) {
+  if (!location.href.includes('/about-you')) {
+    return { handled: false };
+  }
+
+  log('Detected /about-you page, filling birthday info...');
+
+  const { year, month, day, fullName } = payload || {};
+  if (!year || !month || !day) {
+    throw new Error('No birthday data available for about-you page.');
+  }
+
+  // Wait for the page to fully load
+  await sleep(1000);
+
+  // Fill name if present and empty
+  const nameInput = document.querySelector('input[name="name"], input[placeholder*="全名"], input[autocomplete="name"]');
+  if (nameInput && !nameInput.value && fullName) {
+    fillInput(nameInput, fullName);
+    log('About-you: Name filled');
+    await humanPause(300, 800);
+  }
+
+  // Fill birthday using the shared helper
+  await fillBirthdayFields(year, month, day, 'About-you');
+
+  // Click continue/submit button
+  await sleep(500);
+  const submitBtn = document.querySelector('button[type="submit"]')
+    || await waitForElementByText('button', /continue|继续|完成|done|agree|submit/i, 5000).catch(() => null);
+  if (submitBtn) {
+    await humanPause(500, 1300);
+    simulateClick(submitBtn);
+    log('About-you: Submitted form');
+  }
+
+  return { handled: true };
+}
+
+// ============================================================
+// Shared birthday filling helper (used by Step 5 and about-you)
+// ============================================================
+
+async function fillBirthdayFields(year, month, day, logPrefix) {
+  const prefix = logPrefix || 'Birthday';
+
+  // Strategy 1: React Aria DateField spinbuttons
+  const yearSpinner = document.querySelector('[role="spinbutton"][data-type="year"]');
+  const monthSpinner = document.querySelector('[role="spinbutton"][data-type="month"]');
+  const daySpinner = document.querySelector('[role="spinbutton"][data-type="day"]');
+
+  if (yearSpinner && monthSpinner && daySpinner) {
+    log(`${prefix}: Filling spinbutton date fields...`);
+
+    async function setSpinButton(el, value) {
+      el.focus();
+      await sleep(100);
+      document.execCommand('selectAll', false, null);
+      await sleep(50);
+
+      const valueStr = String(value);
+      for (const char of valueStr) {
+        el.dispatchEvent(new KeyboardEvent('keydown', { key: char, code: `Digit${char}`, bubbles: true }));
+        el.dispatchEvent(new KeyboardEvent('keypress', { key: char, code: `Digit${char}`, bubbles: true }));
+        el.dispatchEvent(new InputEvent('beforeinput', { inputType: 'insertText', data: char, bubbles: true }));
+        el.dispatchEvent(new InputEvent('input', { inputType: 'insertText', data: char, bubbles: true }));
+        await sleep(50);
+      }
+
+      el.dispatchEvent(new KeyboardEvent('keyup', { key: 'Tab', code: 'Tab', bubbles: true }));
+      el.blur();
+      await sleep(100);
+    }
+
+    await humanPause(450, 1100);
+    await setSpinButton(yearSpinner, year);
+    await humanPause(250, 650);
+    await setSpinButton(monthSpinner, String(month).padStart(2, '0'));
+    await humanPause(250, 650);
+    await setSpinButton(daySpinner, String(day).padStart(2, '0'));
+    log(`${prefix}: Spinbutton date filled: ${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`);
+    return true;
+  }
+
+  // Strategy 2: Select dropdowns (month/day/year)
+  const selects = document.querySelectorAll('select');
+  if (selects.length >= 2) {
+    let monthSelect = null, daySelect = null, yearSelect = null;
+    for (const sel of selects) {
+      const name = (sel.name || sel.id || sel.getAttribute('aria-label') || '').toLowerCase();
+      const opts = Array.from(sel.options).map(o => o.value);
+      if (name.includes('month') || name.includes('mm')) {
+        monthSelect = sel;
+      } else if (name.includes('day') || name.includes('dd')) {
+        daySelect = sel;
+      } else if (name.includes('year') || name.includes('yyyy')) {
+        yearSelect = sel;
+      } else {
+        // Heuristic: identify by option count and values
+        const numericOpts = opts.filter(v => /^\d+$/.test(v));
+        if (!monthSelect && numericOpts.length >= 12 && numericOpts.length <= 13) {
+          monthSelect = sel;
+        } else if (!daySelect && numericOpts.length >= 28 && numericOpts.length <= 32) {
+          daySelect = sel;
+        } else if (!yearSelect && numericOpts.some(v => Number(v) > 1900 && Number(v) < 2100)) {
+          yearSelect = sel;
+        }
+      }
+    }
+
+    if (monthSelect || daySelect || yearSelect) {
+      log(`${prefix}: Filling select dropdown date fields...`);
+      if (monthSelect) {
+        setSelectValue(monthSelect, String(month));
+        await humanPause(200, 500);
+      }
+      if (daySelect) {
+        setSelectValue(daySelect, String(day));
+        await humanPause(200, 500);
+      }
+      if (yearSelect) {
+        setSelectValue(yearSelect, String(year));
+        await humanPause(200, 500);
+      }
+      log(`${prefix}: Select date filled: ${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`);
+      return true;
+    }
+  }
+
+  // Strategy 3: input[type="date"]
+  const dateInput = document.querySelector('input[type="date"]');
+  if (dateInput) {
+    const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
+    const nativeSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
+    nativeSetter.call(dateInput, dateStr);
+    dateInput.dispatchEvent(new Event('input', { bubbles: true }));
+    dateInput.dispatchEvent(new Event('change', { bubbles: true }));
+    log(`${prefix}: Date input filled: ${dateStr}`);
+    return true;
+  }
+
+  // Strategy 4: Hidden input[name="birthday"] (fallback)
+  const hiddenBirthday = document.querySelector('input[name="birthday"]');
+  if (hiddenBirthday) {
+    const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
+    hiddenBirthday.value = dateStr;
+    hiddenBirthday.dispatchEvent(new Event('change', { bubbles: true }));
+    log(`${prefix}: Hidden birthday input set: ${dateStr}`);
+    return true;
+  }
+
+  return false;
+}
+
+function setSelectValue(selectEl, value) {
+  // Try exact match first, then try padded value
+  const candidates = [value, value.padStart(2, '0')];
+  for (const v of candidates) {
+    const option = Array.from(selectEl.options).find(o => o.value === v || o.textContent.trim() === v);
+    if (option) {
+      selectEl.value = option.value;
+      selectEl.dispatchEvent(new Event('change', { bubbles: true }));
+      selectEl.dispatchEvent(new Event('input', { bubbles: true }));
+      return;
+    }
+  }
+  // Last resort: set by index if value is numeric
+  const numVal = Number(value);
+  if (!isNaN(numVal) && numVal > 0 && numVal < selectEl.options.length) {
+    selectEl.selectedIndex = numVal;
+    selectEl.dispatchEvent(new Event('change', { bubbles: true }));
+    selectEl.dispatchEvent(new Event('input', { bubbles: true }));
+  }
+}
+
+// ============================================================
+// Step 5: Fill Name & Birthday / Age
+// ============================================================
+
+async function step5_fillNameBirthday(payload) {
+  const { firstName, lastName, age, year, month, day } = payload;
+  if (!firstName || !lastName) throw new Error('No name data provided.');
+
+  const resolvedAge = age ?? (year ? new Date().getFullYear() - Number(year) : null);
+  const hasBirthdayData = [year, month, day].every(value => value != null && !Number.isNaN(Number(value)));
+  if (!hasBirthdayData && (resolvedAge == null || Number.isNaN(Number(resolvedAge)))) {
+    throw new Error('No birthday or age data provided.');
+  }
+
+  const fullName = `${firstName} ${lastName}`;
+  log(`Step 5: Filling name: ${fullName}`);
+
+  // Actual DOM structure:
+  // - Full name: <input name="name" placeholder="全名" type="text">
+  // - Birthday: React Aria DateField or hidden input[name="birthday"]
+  // - Age: <input name="age" type="text|number">
+
+  // --- Full Name (single field, not first+last) ---
+  let nameInput = null;
+  try {
+    nameInput = await waitForElement(
+      'input[name="name"], input[placeholder*="全名"], input[autocomplete="name"]',
+      10000
+    );
+  } catch {
+    throw new Error('Could not find name input. URL: ' + location.href);
+  }
+  await humanPause(500, 1300);
+  fillInput(nameInput, fullName);
+  log(`Step 5: Name filled: ${fullName}`);
+
+  // Detect birthday/age input type with polling
+  let ageInput = null;
+  let hasBirthdayUI = false;
+
+  for (let i = 0; i < 100; i++) {
+    ageInput = document.querySelector('input[name="age"]');
+    // Some pages include a hidden birthday input even though the real UI is "age".
+    // In that case we must prioritize filling age to satisfy required validation.
+    if (ageInput) break;
+
+    // Check for any supported birthday UI (spinbuttons, selects, date input, hidden)
+    const yearSpinner = document.querySelector('[role="spinbutton"][data-type="year"]');
+    const monthSpinner = document.querySelector('[role="spinbutton"][data-type="month"]');
+    const daySpinner = document.querySelector('[role="spinbutton"][data-type="day"]');
+    const hiddenBirthday = document.querySelector('input[name="birthday"]');
+    const dateInput = document.querySelector('input[type="date"]');
+    const selects = document.querySelectorAll('select');
+
+    if ((yearSpinner && monthSpinner && daySpinner) || hiddenBirthday || dateInput || selects.length >= 2) {
+      hasBirthdayUI = true;
+      break;
+    }
+    await sleep(100);
+  }
+
+  if (hasBirthdayUI) {
+    if (!hasBirthdayData) {
+      throw new Error('Birthday field detected, but no birthday data provided.');
+    }
+
+    // Use shared helper that tries spinbuttons → selects → date input → hidden input
+    const filled = await fillBirthdayFields(year, month, day, 'Step 5');
+    if (!filled) {
+      log('Step 5: Warning - could not fill any visible birthday control', 'warn');
+    }
+  } else if (ageInput) {
+    if (resolvedAge == null || Number.isNaN(Number(resolvedAge))) {
+      throw new Error('Age field detected, but no age data provided.');
+    }
+    await humanPause(500, 1300);
+    fillInput(ageInput, String(resolvedAge));
+    log(`Step 5: Age filled: ${resolvedAge}`);
+
+    // Some age-mode pages still submit a hidden birthday field.
+    // Keep it aligned with generated data so backend validation won't reject.
+    const hiddenBirthday = document.querySelector('input[name="birthday"]');
+    if (hiddenBirthday && hasBirthdayData) {
+      const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
+      hiddenBirthday.value = dateStr;
+      hiddenBirthday.dispatchEvent(new Event('change', { bubbles: true }));
+      log(`Step 5: Hidden birthday input set (age mode): ${dateStr}`);
+    }
+  } else {
+    throw new Error('Could not find birthday or age input. URL: ' + location.href);
+  }
+
+  // Click "完成帐户创建" button
+  await sleep(500);
+  const completeBtn = document.querySelector('button[type="submit"]')
+    || await waitForElementByText('button', /完成|create|continue|finish|done|agree/i, 5000).catch(() => null);
+
+  // Report complete BEFORE submit (page navigates to add-phone after this)
+  reportComplete(5);
+
+  if (completeBtn) {
+    await humanPause(500, 1300);
+    simulateClick(completeBtn);
+    log('Step 5: Clicked "完成帐户创建"');
+  }
+}

+ 331 - 331
content/utils.js

@@ -1,335 +1,335 @@
-// content/utils.js — Shared utilities for all content scripts
-
-const SCRIPT_SOURCE = (() => {
-  if (window.__MULTIPAGE_SOURCE) return window.__MULTIPAGE_SOURCE;
-  const url = location.href;
+// content/utils.js — Shared utilities for all content scripts
+
+const SCRIPT_SOURCE = (() => {
+  if (window.__MULTIPAGE_SOURCE) return window.__MULTIPAGE_SOURCE;
+  const url = location.href;
   if (url.includes('auth0.openai.com') || url.includes('auth.openai.com') || url.includes('accounts.openai.com')) return 'signup-page';
   if (url.includes('mail.qq.com')) return 'qq-mail';
   if (url.includes('mail.163.com')) return 'mail-163';
-  if (url.includes('duckduckgo.com/email/settings/autofill')) return 'duck-mail';
+  if (url.includes('mail.google.com')) return 'gmail-mail';
   if (url.includes('chatgpt.com')) return 'chatgpt';
-  // VPS panel — detected dynamically since URL is configurable
-  return 'vps-panel';
-})();
-
-const LOG_PREFIX = `[MultiPage:${SCRIPT_SOURCE}]`;
-const STOP_ERROR_MESSAGE = 'Flow stopped by user.';
-let flowStopped = false;
-
-chrome.runtime.onMessage.addListener((message) => {
-  if (message.type === 'STOP_FLOW') {
-    flowStopped = true;
-    console.warn(LOG_PREFIX, STOP_ERROR_MESSAGE);
-  }
-});
-
-function resetStopState() {
-  flowStopped = false;
-}
-
-function isStopError(error) {
-  const message = typeof error === 'string' ? error : error?.message;
-  return message === STOP_ERROR_MESSAGE;
-}
-
-function throwIfStopped() {
-  if (flowStopped) {
-    throw new Error(STOP_ERROR_MESSAGE);
-  }
-}
-
-/**
- * Wait for a DOM element to appear.
- * @param {string} selector - CSS selector
- * @param {number} timeout - Max wait time in ms (default 10000)
- * @returns {Promise<Element>}
- */
-function waitForElement(selector, timeout = 10000) {
-  return new Promise((resolve, reject) => {
-    throwIfStopped();
-
-    const existing = document.querySelector(selector);
-    if (existing) {
-      console.log(LOG_PREFIX, `Found immediately: ${selector}`);
-      log(`Found element: ${selector}`);
-      resolve(existing);
-      return;
-    }
-
-    console.log(LOG_PREFIX, `Waiting for: ${selector} (timeout: ${timeout}ms)`);
-    log(`Waiting for selector: ${selector}...`);
-
-    let settled = false;
-    let stopTimer = null;
-    const cleanup = () => {
-      if (settled) return;
-      settled = true;
-      observer.disconnect();
-      clearTimeout(timer);
-      clearTimeout(stopTimer);
-    };
-
-    const observer = new MutationObserver(() => {
-      if (flowStopped) {
-        cleanup();
-        reject(new Error(STOP_ERROR_MESSAGE));
-        return;
-      }
-      const el = document.querySelector(selector);
-      if (el) {
-        cleanup();
-        console.log(LOG_PREFIX, `Found after wait: ${selector}`);
-        log(`Found element: ${selector}`);
-        resolve(el);
-      }
-    });
-
-    observer.observe(document.body || document.documentElement, {
-      childList: true,
-      subtree: true,
-    });
-
-    const timer = setTimeout(() => {
-      cleanup();
-      const msg = `Timeout waiting for ${selector} after ${timeout}ms on ${location.href}`;
-      console.error(LOG_PREFIX, msg);
-      reject(new Error(msg));
-    }, timeout);
-
-    const pollStop = () => {
-      if (settled) return;
-      if (flowStopped) {
-        cleanup();
-        reject(new Error(STOP_ERROR_MESSAGE));
-        return;
-      }
-      stopTimer = setTimeout(pollStop, 100);
-    };
-    pollStop();
-  });
-}
-
-/**
- * Wait for an element matching a text pattern among multiple candidates.
- * @param {string} containerSelector - Selector for candidate elements
- * @param {RegExp} textPattern - Regex to match against textContent
- * @param {number} timeout - Max wait time in ms
- * @returns {Promise<Element>}
- */
-function waitForElementByText(containerSelector, textPattern, timeout = 10000) {
-  return new Promise((resolve, reject) => {
-    throwIfStopped();
-
-    function search() {
-      const candidates = document.querySelectorAll(containerSelector);
-      for (const el of candidates) {
-        if (textPattern.test(el.textContent)) {
-          return el;
-        }
-      }
-      return null;
-    }
-
-    const existing = search();
-    if (existing) {
-      console.log(LOG_PREFIX, `Found by text immediately: ${containerSelector} matching ${textPattern}`);
-      log(`Found element by text: ${textPattern}`);
-      resolve(existing);
-      return;
-    }
-
-    console.log(LOG_PREFIX, `Waiting for text match: ${containerSelector} / ${textPattern}`);
-    log(`Waiting for element with text: ${textPattern}...`);
-
-    let settled = false;
-    let stopTimer = null;
-    const cleanup = () => {
-      if (settled) return;
-      settled = true;
-      observer.disconnect();
-      clearTimeout(timer);
-      clearTimeout(stopTimer);
-    };
-
-    const observer = new MutationObserver(() => {
-      if (flowStopped) {
-        cleanup();
-        reject(new Error(STOP_ERROR_MESSAGE));
-        return;
-      }
-      const el = search();
-      if (el) {
-        cleanup();
-        console.log(LOG_PREFIX, `Found by text after wait: ${textPattern}`);
-        log(`Found element by text: ${textPattern}`);
-        resolve(el);
-      }
-    });
-
-    observer.observe(document.body || document.documentElement, {
-      childList: true,
-      subtree: true,
-    });
-
-    const timer = setTimeout(() => {
-      cleanup();
-      const msg = `Timeout waiting for text "${textPattern}" in "${containerSelector}" after ${timeout}ms on ${location.href}`;
-      console.error(LOG_PREFIX, msg);
-      reject(new Error(msg));
-    }, timeout);
-
-    const pollStop = () => {
-      if (settled) return;
-      if (flowStopped) {
-        cleanup();
-        reject(new Error(STOP_ERROR_MESSAGE));
-        return;
-      }
-      stopTimer = setTimeout(pollStop, 100);
-    };
-    pollStop();
-  });
-}
-
-/**
- * React-compatible form filling.
- * Sets value via native setter and dispatches input + change events.
- * @param {HTMLInputElement} el
- * @param {string} value
- */
-function fillInput(el, value) {
-  throwIfStopped();
-  const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
-    window.HTMLInputElement.prototype,
-    'value'
-  ).set;
-  nativeInputValueSetter.call(el, value);
-  el.dispatchEvent(new Event('input', { bubbles: true }));
-  el.dispatchEvent(new Event('change', { bubbles: true }));
-  console.log(LOG_PREFIX, `Filled input ${el.name || el.id || el.type} with: ${value}`);
-  log(`Filled input [${el.name || el.id || el.type || 'unknown'}]`);
-}
-
-/**
- * Fill a select element by setting its value and triggering change.
- * @param {HTMLSelectElement} el
- * @param {string} value
- */
-function fillSelect(el, value) {
-  throwIfStopped();
-  el.value = value;
-  el.dispatchEvent(new Event('change', { bubbles: true }));
-  console.log(LOG_PREFIX, `Selected value ${value} in ${el.name || el.id}`);
-  log(`Selected [${el.name || el.id || 'unknown'}] = ${value}`);
-}
-
-/**
- * Send a log message to Side Panel via Background.
- * @param {string} message
- * @param {string} level - 'info' | 'ok' | 'warn' | 'error'
- */
-function log(message, level = 'info') {
-  chrome.runtime.sendMessage({
-    type: 'LOG',
-    source: SCRIPT_SOURCE,
-    step: null,
-    payload: { message, level, timestamp: Date.now() },
-    error: null,
-  });
-}
-
-/**
- * Report that this content script is loaded and ready.
- */
-function reportReady() {
-  console.log(LOG_PREFIX, 'Content script ready');
-  chrome.runtime.sendMessage({
-    type: 'CONTENT_SCRIPT_READY',
-    source: SCRIPT_SOURCE,
-    step: null,
-    payload: {},
-    error: null,
-  });
-}
-
-/**
- * Report step completion.
- * @param {number} step
- * @param {Object} data - Step output data
- */
-function reportComplete(step, data = {}) {
-  console.log(LOG_PREFIX, `Step ${step} completed`, data);
-  log(`Step ${step} completed successfully`, 'ok');
-  chrome.runtime.sendMessage({
-    type: 'STEP_COMPLETE',
-    source: SCRIPT_SOURCE,
-    step,
-    payload: data,
-    error: null,
-  });
-}
-
-/**
- * Report step error.
- * @param {number} step
- * @param {string} errorMessage
- */
-function reportError(step, errorMessage) {
-  console.error(LOG_PREFIX, `Step ${step} failed: ${errorMessage}`);
-  log(`Step ${step} failed: ${errorMessage}`, 'error');
-  chrome.runtime.sendMessage({
-    type: 'STEP_ERROR',
-    source: SCRIPT_SOURCE,
-    step,
-    payload: {},
-    error: errorMessage,
-  });
-}
-
-/**
- * Simulate a click with proper event dispatching.
- * @param {Element} el
- */
-function simulateClick(el) {
-  throwIfStopped();
-  el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
-  console.log(LOG_PREFIX, `Clicked: ${el.tagName} ${el.textContent?.slice(0, 30) || ''}`);
-  log(`Clicked [${el.tagName}] "${el.textContent?.trim().slice(0, 30) || ''}"`);
-}
-
-/**
- * Wait a specified number of milliseconds.
- * @param {number} ms
- * @returns {Promise<void>}
- */
-function sleep(ms) {
-  return new Promise((resolve, reject) => {
-    const start = Date.now();
-
-    function tick() {
-      if (flowStopped) {
-        reject(new Error(STOP_ERROR_MESSAGE));
-        return;
-      }
-      if (Date.now() - start >= ms) {
-        resolve();
-        return;
-      }
-      setTimeout(tick, Math.min(100, Math.max(25, ms - (Date.now() - start))));
-    }
-
-    tick();
-  });
-}
-
-async function humanPause(min = 250, max = 850) {
-  const duration = Math.floor(Math.random() * (max - min + 1)) + min;
-  await sleep(duration);
-}
-
-// Auto-report ready on load
-// Skip ready signal from child iframes of mail pages to avoid overwriting the top frame's registration
-const _isMailChildFrame = (SCRIPT_SOURCE === 'qq-mail' || SCRIPT_SOURCE === 'mail-163' || SCRIPT_SOURCE === 'inbucket-mail') && window !== window.top;
-if (!_isMailChildFrame) {
-  reportReady();
-}
+  // VPS panel — detected dynamically since URL is configurable
+  return 'vps-panel';
+})();
+
+const LOG_PREFIX = `[MultiPage:${SCRIPT_SOURCE}]`;
+const STOP_ERROR_MESSAGE = 'Flow stopped by user.';
+let flowStopped = false;
+
+chrome.runtime.onMessage.addListener((message) => {
+  if (message.type === 'STOP_FLOW') {
+    flowStopped = true;
+    console.warn(LOG_PREFIX, STOP_ERROR_MESSAGE);
+  }
+});
+
+function resetStopState() {
+  flowStopped = false;
+}
+
+function isStopError(error) {
+  const message = typeof error === 'string' ? error : error?.message;
+  return message === STOP_ERROR_MESSAGE;
+}
+
+function throwIfStopped() {
+  if (flowStopped) {
+    throw new Error(STOP_ERROR_MESSAGE);
+  }
+}
+
+/**
+ * Wait for a DOM element to appear.
+ * @param {string} selector - CSS selector
+ * @param {number} timeout - Max wait time in ms (default 10000)
+ * @returns {Promise<Element>}
+ */
+function waitForElement(selector, timeout = 10000) {
+  return new Promise((resolve, reject) => {
+    throwIfStopped();
+
+    const existing = document.querySelector(selector);
+    if (existing) {
+      console.log(LOG_PREFIX, `Found immediately: ${selector}`);
+      log(`Found element: ${selector}`);
+      resolve(existing);
+      return;
+    }
+
+    console.log(LOG_PREFIX, `Waiting for: ${selector} (timeout: ${timeout}ms)`);
+    log(`Waiting for selector: ${selector}...`);
+
+    let settled = false;
+    let stopTimer = null;
+    const cleanup = () => {
+      if (settled) return;
+      settled = true;
+      observer.disconnect();
+      clearTimeout(timer);
+      clearTimeout(stopTimer);
+    };
+
+    const observer = new MutationObserver(() => {
+      if (flowStopped) {
+        cleanup();
+        reject(new Error(STOP_ERROR_MESSAGE));
+        return;
+      }
+      const el = document.querySelector(selector);
+      if (el) {
+        cleanup();
+        console.log(LOG_PREFIX, `Found after wait: ${selector}`);
+        log(`Found element: ${selector}`);
+        resolve(el);
+      }
+    });
+
+    observer.observe(document.body || document.documentElement, {
+      childList: true,
+      subtree: true,
+    });
+
+    const timer = setTimeout(() => {
+      cleanup();
+      const msg = `Timeout waiting for ${selector} after ${timeout}ms on ${location.href}`;
+      console.error(LOG_PREFIX, msg);
+      reject(new Error(msg));
+    }, timeout);
+
+    const pollStop = () => {
+      if (settled) return;
+      if (flowStopped) {
+        cleanup();
+        reject(new Error(STOP_ERROR_MESSAGE));
+        return;
+      }
+      stopTimer = setTimeout(pollStop, 100);
+    };
+    pollStop();
+  });
+}
+
+/**
+ * Wait for an element matching a text pattern among multiple candidates.
+ * @param {string} containerSelector - Selector for candidate elements
+ * @param {RegExp} textPattern - Regex to match against textContent
+ * @param {number} timeout - Max wait time in ms
+ * @returns {Promise<Element>}
+ */
+function waitForElementByText(containerSelector, textPattern, timeout = 10000) {
+  return new Promise((resolve, reject) => {
+    throwIfStopped();
+
+    function search() {
+      const candidates = document.querySelectorAll(containerSelector);
+      for (const el of candidates) {
+        if (textPattern.test(el.textContent)) {
+          return el;
+        }
+      }
+      return null;
+    }
+
+    const existing = search();
+    if (existing) {
+      console.log(LOG_PREFIX, `Found by text immediately: ${containerSelector} matching ${textPattern}`);
+      log(`Found element by text: ${textPattern}`);
+      resolve(existing);
+      return;
+    }
+
+    console.log(LOG_PREFIX, `Waiting for text match: ${containerSelector} / ${textPattern}`);
+    log(`Waiting for element with text: ${textPattern}...`);
+
+    let settled = false;
+    let stopTimer = null;
+    const cleanup = () => {
+      if (settled) return;
+      settled = true;
+      observer.disconnect();
+      clearTimeout(timer);
+      clearTimeout(stopTimer);
+    };
+
+    const observer = new MutationObserver(() => {
+      if (flowStopped) {
+        cleanup();
+        reject(new Error(STOP_ERROR_MESSAGE));
+        return;
+      }
+      const el = search();
+      if (el) {
+        cleanup();
+        console.log(LOG_PREFIX, `Found by text after wait: ${textPattern}`);
+        log(`Found element by text: ${textPattern}`);
+        resolve(el);
+      }
+    });
+
+    observer.observe(document.body || document.documentElement, {
+      childList: true,
+      subtree: true,
+    });
+
+    const timer = setTimeout(() => {
+      cleanup();
+      const msg = `Timeout waiting for text "${textPattern}" in "${containerSelector}" after ${timeout}ms on ${location.href}`;
+      console.error(LOG_PREFIX, msg);
+      reject(new Error(msg));
+    }, timeout);
+
+    const pollStop = () => {
+      if (settled) return;
+      if (flowStopped) {
+        cleanup();
+        reject(new Error(STOP_ERROR_MESSAGE));
+        return;
+      }
+      stopTimer = setTimeout(pollStop, 100);
+    };
+    pollStop();
+  });
+}
+
+/**
+ * React-compatible form filling.
+ * Sets value via native setter and dispatches input + change events.
+ * @param {HTMLInputElement} el
+ * @param {string} value
+ */
+function fillInput(el, value) {
+  throwIfStopped();
+  const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
+    window.HTMLInputElement.prototype,
+    'value'
+  ).set;
+  nativeInputValueSetter.call(el, value);
+  el.dispatchEvent(new Event('input', { bubbles: true }));
+  el.dispatchEvent(new Event('change', { bubbles: true }));
+  console.log(LOG_PREFIX, `Filled input ${el.name || el.id || el.type} with: ${value}`);
+  log(`Filled input [${el.name || el.id || el.type || 'unknown'}]`);
+}
+
+/**
+ * Fill a select element by setting its value and triggering change.
+ * @param {HTMLSelectElement} el
+ * @param {string} value
+ */
+function fillSelect(el, value) {
+  throwIfStopped();
+  el.value = value;
+  el.dispatchEvent(new Event('change', { bubbles: true }));
+  console.log(LOG_PREFIX, `Selected value ${value} in ${el.name || el.id}`);
+  log(`Selected [${el.name || el.id || 'unknown'}] = ${value}`);
+}
+
+/**
+ * Send a log message to Side Panel via Background.
+ * @param {string} message
+ * @param {string} level - 'info' | 'ok' | 'warn' | 'error'
+ */
+function log(message, level = 'info') {
+  chrome.runtime.sendMessage({
+    type: 'LOG',
+    source: SCRIPT_SOURCE,
+    step: null,
+    payload: { message, level, timestamp: Date.now() },
+    error: null,
+  });
+}
+
+/**
+ * Report that this content script is loaded and ready.
+ */
+function reportReady() {
+  console.log(LOG_PREFIX, 'Content script ready');
+  chrome.runtime.sendMessage({
+    type: 'CONTENT_SCRIPT_READY',
+    source: SCRIPT_SOURCE,
+    step: null,
+    payload: {},
+    error: null,
+  });
+}
+
+/**
+ * Report step completion.
+ * @param {number} step
+ * @param {Object} data - Step output data
+ */
+function reportComplete(step, data = {}) {
+  console.log(LOG_PREFIX, `Step ${step} completed`, data);
+  log(`Step ${step} completed successfully`, 'ok');
+  chrome.runtime.sendMessage({
+    type: 'STEP_COMPLETE',
+    source: SCRIPT_SOURCE,
+    step,
+    payload: data,
+    error: null,
+  });
+}
+
+/**
+ * Report step error.
+ * @param {number} step
+ * @param {string} errorMessage
+ */
+function reportError(step, errorMessage) {
+  console.error(LOG_PREFIX, `Step ${step} failed: ${errorMessage}`);
+  log(`Step ${step} failed: ${errorMessage}`, 'error');
+  chrome.runtime.sendMessage({
+    type: 'STEP_ERROR',
+    source: SCRIPT_SOURCE,
+    step,
+    payload: {},
+    error: errorMessage,
+  });
+}
+
+/**
+ * Simulate a click with proper event dispatching.
+ * @param {Element} el
+ */
+function simulateClick(el) {
+  throwIfStopped();
+  el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
+  console.log(LOG_PREFIX, `Clicked: ${el.tagName} ${el.textContent?.slice(0, 30) || ''}`);
+  log(`Clicked [${el.tagName}] "${el.textContent?.trim().slice(0, 30) || ''}"`);
+}
+
+/**
+ * Wait a specified number of milliseconds.
+ * @param {number} ms
+ * @returns {Promise<void>}
+ */
+function sleep(ms) {
+  return new Promise((resolve, reject) => {
+    const start = Date.now();
+
+    function tick() {
+      if (flowStopped) {
+        reject(new Error(STOP_ERROR_MESSAGE));
+        return;
+      }
+      if (Date.now() - start >= ms) {
+        resolve();
+        return;
+      }
+      setTimeout(tick, Math.min(100, Math.max(25, ms - (Date.now() - start))));
+    }
+
+    tick();
+  });
+}
+
+async function humanPause(min = 250, max = 850) {
+  const duration = Math.floor(Math.random() * (max - min + 1)) + min;
+  await sleep(duration);
+}
+
+// Auto-report ready on load
+// Skip ready signal from child iframes of mail pages to avoid overwriting the top frame's registration
+const _isMailChildFrame = (SCRIPT_SOURCE === 'qq-mail' || SCRIPT_SOURCE === 'mail-163' || SCRIPT_SOURCE === 'inbucket-mail') && window !== window.top;
+if (!_isMailChildFrame) {
+  reportReady();
+}

+ 187 - 187
content/vps-panel.js

@@ -1,187 +1,187 @@
-// content/vps-panel.js — Content script for VPS panel (steps 1, 9)
-// Injected on: VPS panel (user-configured URL)
-//
-// Actual DOM structure (after login click):
-// <div class="card">
-//   <div class="card-header">
-//     <span class="OAuthPage-module__cardTitle___yFaP0">Codex OAuth</span>
-//     <button class="btn btn-primary"><span>登录</span></button>
-//   </div>
-//   <div class="OAuthPage-module__cardContent___1sXLA">
-//     <div class="OAuthPage-module__authUrlBox___Iu1d4">
-//       <div class="OAuthPage-module__authUrlLabel___mYFJB">授权链接:</div>
-//       <div class="OAuthPage-module__authUrlValue___axvUJ">https://auth.openai.com/...</div>
-//       <div class="OAuthPage-module__authUrlActions___venPj">
-//         <button class="btn btn-secondary btn-sm"><span>复制链接</span></button>
-//         <button class="btn btn-secondary btn-sm"><span>打开链接</span></button>
-//       </div>
-//     </div>
-//     <div class="OAuthPage-module__callbackSection___8kA31">
-//       <input class="input" placeholder="http://localhost:1455/auth/callback?code=...&state=...">
-//       <button class="btn btn-secondary btn-sm"><span>提交回调 URL</span></button>
-//     </div>
-//   </div>
-// </div>
-
-console.log('[MultiPage:vps-panel] Content script loaded on', location.href);
-
-// Listen for commands from Background
-chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
-  if (message.type === 'EXECUTE_STEP') {
-    resetStopState();
-    handleStep(message.step, message.payload).then(() => {
-      sendResponse({ ok: true });
-    }).catch(err => {
-      if (isStopError(err)) {
-        log(`Step ${message.step}: Stopped by user.`, 'warn');
-        sendResponse({ stopped: true, error: err.message });
-        return;
-      }
-      reportError(message.step, err.message);
-      sendResponse({ error: err.message });
-    });
-    return true;
-  }
-});
-
-async function handleStep(step, payload) {
-  switch (step) {
-    case 1: return await step1_getOAuthLink();
-    case 9: return await step9_vpsVerify(payload);
-    default:
-      throw new Error(`vps-panel.js does not handle step ${step}`);
-  }
-}
-
-// ============================================================
-// Step 1: Get OAuth Link
-// ============================================================
-
-async function step1_getOAuthLink() {
-  log('Step 1: Waiting for VPS panel to load (auto-login may take a moment)...');
-
-  // The page may start at #/login and auto-redirect to #/oauth.
-  // Wait for the Codex OAuth card to appear (up to 30s for auto-login + redirect).
-  let loginBtn = null;
-  try {
-    // Wait for any card-header containing "Codex" to appear
-    const header = await waitForElementByText('.card-header', /codex/i, 30000);
-    loginBtn = header.querySelector('button.btn.btn-primary, button.btn');
-    log('Step 1: Found Codex OAuth card');
-  } catch {
-    throw new Error(
-      'Codex OAuth card did not appear after 30s. Page may still be loading or not logged in. ' +
-      'Current URL: ' + location.href
-    );
-  }
-
-  if (!loginBtn) {
-    throw new Error('Found Codex OAuth card but no login button inside it. URL: ' + location.href);
-  }
-
-  // Check if button is disabled (already clicked / loading)
-  if (loginBtn.disabled) {
-    log('Step 1: Login button is disabled (already loading), waiting for auth URL...');
-  } else {
-    await humanPause(500, 1400);
-    simulateClick(loginBtn);
-    log('Step 1: Clicked login button, waiting for auth URL...');
-  }
-
-  // Wait for the auth URL to appear in the specific div
-  let authUrlEl = null;
-  try {
-    authUrlEl = await waitForElement('[class*="authUrlValue"]', 15000);
-  } catch {
-    throw new Error(
-      'Auth URL did not appear after clicking login. ' +
-      'Check if VPS panel is logged in and Codex service is running. URL: ' + location.href
-    );
-  }
-
-  const oauthUrl = (authUrlEl.textContent || '').trim();
-  if (!oauthUrl || !oauthUrl.startsWith('http')) {
-    throw new Error(`Invalid OAuth URL found: "${oauthUrl.slice(0, 50)}". Expected URL starting with http.`);
-  }
-
-  log(`Step 1: OAuth URL obtained: ${oauthUrl.slice(0, 80)}...`, 'ok');
-  reportComplete(1, { oauthUrl });
-}
-
-// ============================================================
-// Step 9: VPS Verify — paste localhost URL and submit
-// ============================================================
-
-async function step9_vpsVerify(payload) {
-  // Get localhostUrl from payload (passed directly by background) or fallback to state
-  let localhostUrl = payload?.localhostUrl;
-  if (!localhostUrl) {
-    log('Step 9: localhostUrl not in payload, fetching from state...');
-    const state = await chrome.runtime.sendMessage({ type: 'GET_STATE' });
-    localhostUrl = state.localhostUrl;
-  }
-  if (!localhostUrl) {
-    throw new Error('No localhost URL found. Complete step 8 first.');
-  }
-  log(`Step 9: Got localhostUrl: ${localhostUrl.slice(0, 60)}...`);
-
-  log('Step 9: Looking for callback URL input...');
-
-  // Find the callback URL input
-  // Actual DOM: <input class="input" placeholder="http://localhost:1455/auth/callback?code=...&state=...">
-  let urlInput = null;
-  try {
-    urlInput = await waitForElement('[class*="callbackSection"] input.input', 10000);
-  } catch {
-    try {
-      urlInput = await waitForElement('input[placeholder*="localhost"]', 5000);
-    } catch {
-      throw new Error('Could not find callback URL input on VPS panel. URL: ' + location.href);
-    }
-  }
-
-  await humanPause(600, 1500);
-  fillInput(urlInput, localhostUrl);
-  log(`Step 9: Filled callback URL: ${localhostUrl.slice(0, 80)}...`);
-
-  // Find and click submit callback button (supports both Chinese and English UI)
-  let submitBtn = null;
-  try {
-    submitBtn = await waitForElementByText(
-      '[class*="callbackActions"] button, [class*="callbackSection"] button',
-      /提交|submit/i,
-      5000
-    );
-  } catch {
-    try {
-      submitBtn = await waitForElementByText('button.btn', /提交回调|submit.*callback/i, 5000);
-    } catch {
-      // Last resort: find the button next to the input in callbackSection
-      const section = document.querySelector('[class*="callbackSection"]');
-      submitBtn = section?.querySelector('button');
-      if (!submitBtn) {
-        throw new Error('Could not find submit callback button. URL: ' + location.href);
-      }
-    }
-  }
-
-  await humanPause(450, 1200);
-  simulateClick(submitBtn);
-  log('Step 9: Clicked submit callback button, waiting for authentication result...');
-
-  // Wait for success status (supports both Chinese and English)
-  try {
-    await waitForElementByText('.status-badge, [class*="status"]', /认证成功|auth.*success|verified|active/i, 30000);
-    log('Step 9: Authentication successful!', 'ok');
-  } catch {
-    const statusEl = document.querySelector('.status-badge, [class*="status"]');
-    const statusText = statusEl ? statusEl.textContent : 'unknown';
-    if (/成功|success|verified|active/i.test(statusText)) {
-      log('Step 9: Authentication successful!', 'ok');
-    } else {
-      log(`Step 9: Status after submit: "${statusText}". May still be processing.`, 'warn');
-    }
-  }
-
-  reportComplete(9);
-}
+// content/vps-panel.js — Content script for VPS panel (steps 1, 9)
+// Injected on: VPS panel (user-configured URL)
+//
+// Actual DOM structure (after login click):
+// <div class="card">
+//   <div class="card-header">
+//     <span class="OAuthPage-module__cardTitle___yFaP0">Codex OAuth</span>
+//     <button class="btn btn-primary"><span>登录</span></button>
+//   </div>
+//   <div class="OAuthPage-module__cardContent___1sXLA">
+//     <div class="OAuthPage-module__authUrlBox___Iu1d4">
+//       <div class="OAuthPage-module__authUrlLabel___mYFJB">授权链接:</div>
+//       <div class="OAuthPage-module__authUrlValue___axvUJ">https://auth.openai.com/...</div>
+//       <div class="OAuthPage-module__authUrlActions___venPj">
+//         <button class="btn btn-secondary btn-sm"><span>复制链接</span></button>
+//         <button class="btn btn-secondary btn-sm"><span>打开链接</span></button>
+//       </div>
+//     </div>
+//     <div class="OAuthPage-module__callbackSection___8kA31">
+//       <input class="input" placeholder="http://localhost:1455/auth/callback?code=...&state=...">
+//       <button class="btn btn-secondary btn-sm"><span>提交回调 URL</span></button>
+//     </div>
+//   </div>
+// </div>
+
+console.log('[MultiPage:vps-panel] Content script loaded on', location.href);
+
+// Listen for commands from Background
+chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
+  if (message.type === 'EXECUTE_STEP') {
+    resetStopState();
+    handleStep(message.step, message.payload).then(() => {
+      sendResponse({ ok: true });
+    }).catch(err => {
+      if (isStopError(err)) {
+        log(`Step ${message.step}: Stopped by user.`, 'warn');
+        sendResponse({ stopped: true, error: err.message });
+        return;
+      }
+      reportError(message.step, err.message);
+      sendResponse({ error: err.message });
+    });
+    return true;
+  }
+});
+
+async function handleStep(step, payload) {
+  switch (step) {
+    case 1: return await step1_getOAuthLink();
+    case 9: return await step9_vpsVerify(payload);
+    default:
+      throw new Error(`vps-panel.js does not handle step ${step}`);
+  }
+}
+
+// ============================================================
+// Step 1: Get OAuth Link
+// ============================================================
+
+async function step1_getOAuthLink() {
+  log('Step 1: Waiting for VPS panel to load (auto-login may take a moment)...');
+
+  // The page may start at #/login and auto-redirect to #/oauth.
+  // Wait for the Codex OAuth card to appear (up to 30s for auto-login + redirect).
+  let loginBtn = null;
+  try {
+    // Wait for any card-header containing "Codex" to appear
+    const header = await waitForElementByText('.card-header', /codex/i, 30000);
+    loginBtn = header.querySelector('button.btn.btn-primary, button.btn');
+    log('Step 1: Found Codex OAuth card');
+  } catch {
+    throw new Error(
+      'Codex OAuth card did not appear after 30s. Page may still be loading or not logged in. ' +
+      'Current URL: ' + location.href
+    );
+  }
+
+  if (!loginBtn) {
+    throw new Error('Found Codex OAuth card but no login button inside it. URL: ' + location.href);
+  }
+
+  // Check if button is disabled (already clicked / loading)
+  if (loginBtn.disabled) {
+    log('Step 1: Login button is disabled (already loading), waiting for auth URL...');
+  } else {
+    await humanPause(500, 1400);
+    simulateClick(loginBtn);
+    log('Step 1: Clicked login button, waiting for auth URL...');
+  }
+
+  // Wait for the auth URL to appear in the specific div
+  let authUrlEl = null;
+  try {
+    authUrlEl = await waitForElement('[class*="authUrlValue"]', 15000);
+  } catch {
+    throw new Error(
+      'Auth URL did not appear after clicking login. ' +
+      'Check if VPS panel is logged in and Codex service is running. URL: ' + location.href
+    );
+  }
+
+  const oauthUrl = (authUrlEl.textContent || '').trim();
+  if (!oauthUrl || !oauthUrl.startsWith('http')) {
+    throw new Error(`Invalid OAuth URL found: "${oauthUrl.slice(0, 50)}". Expected URL starting with http.`);
+  }
+
+  log(`Step 1: OAuth URL obtained: ${oauthUrl.slice(0, 80)}...`, 'ok');
+  reportComplete(1, { oauthUrl });
+}
+
+// ============================================================
+// Step 9: VPS Verify — paste localhost URL and submit
+// ============================================================
+
+async function step9_vpsVerify(payload) {
+  // Get localhostUrl from payload (passed directly by background) or fallback to state
+  let localhostUrl = payload?.localhostUrl;
+  if (!localhostUrl) {
+    log('Step 9: localhostUrl not in payload, fetching from state...');
+    const state = await chrome.runtime.sendMessage({ type: 'GET_STATE' });
+    localhostUrl = state.localhostUrl;
+  }
+  if (!localhostUrl) {
+    throw new Error('No localhost URL found. Complete step 8 first.');
+  }
+  log(`Step 9: Got localhostUrl: ${localhostUrl.slice(0, 60)}...`);
+
+  log('Step 9: Looking for callback URL input...');
+
+  // Find the callback URL input
+  // Actual DOM: <input class="input" placeholder="http://localhost:1455/auth/callback?code=...&state=...">
+  let urlInput = null;
+  try {
+    urlInput = await waitForElement('[class*="callbackSection"] input.input', 10000);
+  } catch {
+    try {
+      urlInput = await waitForElement('input[placeholder*="localhost"]', 5000);
+    } catch {
+      throw new Error('Could not find callback URL input on VPS panel. URL: ' + location.href);
+    }
+  }
+
+  await humanPause(600, 1500);
+  fillInput(urlInput, localhostUrl);
+  log(`Step 9: Filled callback URL: ${localhostUrl.slice(0, 80)}...`);
+
+  // Find and click submit callback button (supports both Chinese and English UI)
+  let submitBtn = null;
+  try {
+    submitBtn = await waitForElementByText(
+      '[class*="callbackActions"] button, [class*="callbackSection"] button',
+      /提交|submit/i,
+      5000
+    );
+  } catch {
+    try {
+      submitBtn = await waitForElementByText('button.btn', /提交回调|submit.*callback/i, 5000);
+    } catch {
+      // Last resort: find the button next to the input in callbackSection
+      const section = document.querySelector('[class*="callbackSection"]');
+      submitBtn = section?.querySelector('button');
+      if (!submitBtn) {
+        throw new Error('Could not find submit callback button. URL: ' + location.href);
+      }
+    }
+  }
+
+  await humanPause(450, 1200);
+  simulateClick(submitBtn);
+  log('Step 9: Clicked submit callback button, waiting for authentication result...');
+
+  // Wait for success status (supports both Chinese and English)
+  try {
+    await waitForElementByText('.status-badge, [class*="status"]', /认证成功|auth.*success|verified|active/i, 30000);
+    log('Step 9: Authentication successful!', 'ok');
+  } catch {
+    const statusEl = document.querySelector('.status-badge, [class*="status"]');
+    const statusText = statusEl ? statusEl.textContent : 'unknown';
+    if (/成功|success|verified|active/i.test(statusText)) {
+      log('Step 9: Authentication successful!', 'ok');
+    } else {
+      log(`Step 9: Status after submit: "${statusText}". May still be processing.`, 'warn');
+    }
+  }
+
+  reportComplete(9);
+}

+ 38 - 38
data/names.js

@@ -1,38 +1,38 @@
-// data/names.js — English name lists for random generation
-
-const FIRST_NAMES = [
-  'James', 'John', 'Robert', 'Michael', 'William', 'David', 'Richard', 'Joseph', 'Thomas', 'Christopher',
-  'Mary', 'Patricia', 'Jennifer', 'Linda', 'Barbara', 'Elizabeth', 'Susan', 'Jessica', 'Sarah', 'Karen',
-  'Daniel', 'Matthew', 'Anthony', 'Mark', 'Donald', 'Steven', 'Andrew', 'Paul', 'Joshua', 'Kenneth',
-  'Emma', 'Olivia', 'Ava', 'Isabella', 'Sophia', 'Mia', 'Charlotte', 'Amelia', 'Harper', 'Evelyn',
-];
-
-const LAST_NAMES = [
-  'Smith', 'Johnson', 'Williams', 'Brown', 'Jones', 'Garcia', 'Miller', 'Davis', 'Rodriguez', 'Martinez',
-  'Hernandez', 'Lopez', 'Gonzalez', 'Wilson', 'Anderson', 'Thomas', 'Taylor', 'Moore', 'Jackson', 'Martin',
-  'Lee', 'Perez', 'Thompson', 'White', 'Harris', 'Sanchez', 'Clark', 'Ramirez', 'Lewis', 'Robinson',
-];
-
-/**
- * Generate a random full name.
- * @returns {{ firstName: string, lastName: string }}
- */
-function generateRandomName() {
-  const firstName = FIRST_NAMES[Math.floor(Math.random() * FIRST_NAMES.length)];
-  const lastName = LAST_NAMES[Math.floor(Math.random() * LAST_NAMES.length)];
-  return { firstName, lastName };
-}
-
-/**
- * Generate a random birthday (age 19-25).
- * @returns {{ year: number, month: number, day: number }}
- */
-function generateRandomBirthday() {
-  const currentYear = new Date().getFullYear();
-  const age = 19 + Math.floor(Math.random() * 7); // 19 to 25
-  const year = currentYear - age;
-  const month = 1 + Math.floor(Math.random() * 12); // 1 to 12
-  const maxDay = new Date(year, month, 0).getDate(); // days in that month
-  const day = 1 + Math.floor(Math.random() * maxDay);
-  return { year, month, day };
-}
+// data/names.js — English name lists for random generation
+
+const FIRST_NAMES = [
+  'James', 'John', 'Robert', 'Michael', 'William', 'David', 'Richard', 'Joseph', 'Thomas', 'Christopher',
+  'Mary', 'Patricia', 'Jennifer', 'Linda', 'Barbara', 'Elizabeth', 'Susan', 'Jessica', 'Sarah', 'Karen',
+  'Daniel', 'Matthew', 'Anthony', 'Mark', 'Donald', 'Steven', 'Andrew', 'Paul', 'Joshua', 'Kenneth',
+  'Emma', 'Olivia', 'Ava', 'Isabella', 'Sophia', 'Mia', 'Charlotte', 'Amelia', 'Harper', 'Evelyn',
+];
+
+const LAST_NAMES = [
+  'Smith', 'Johnson', 'Williams', 'Brown', 'Jones', 'Garcia', 'Miller', 'Davis', 'Rodriguez', 'Martinez',
+  'Hernandez', 'Lopez', 'Gonzalez', 'Wilson', 'Anderson', 'Thomas', 'Taylor', 'Moore', 'Jackson', 'Martin',
+  'Lee', 'Perez', 'Thompson', 'White', 'Harris', 'Sanchez', 'Clark', 'Ramirez', 'Lewis', 'Robinson',
+];
+
+/**
+ * Generate a random full name.
+ * @returns {{ firstName: string, lastName: string }}
+ */
+function generateRandomName() {
+  const firstName = FIRST_NAMES[Math.floor(Math.random() * FIRST_NAMES.length)];
+  const lastName = LAST_NAMES[Math.floor(Math.random() * LAST_NAMES.length)];
+  return { firstName, lastName };
+}
+
+/**
+ * Generate a random birthday (age 19-25).
+ * @returns {{ year: number, month: number, day: number }}
+ */
+function generateRandomBirthday() {
+  const currentYear = new Date().getFullYear();
+  const age = 19 + Math.floor(Math.random() * 7); // 19 to 25
+  const year = currentYear - age;
+  const month = 1 + Math.floor(Math.random() * 12); // 1 to 12
+  const maxDay = new Date(year, month, 0).getDate(); // days in that month
+  const day = 1 + Math.floor(Math.random() * maxDay);
+  return { year, month, day };
+}

+ 54 - 61
manifest.json

@@ -1,44 +1,44 @@
-{
-  "manifest_version": 3,
-  "name": "Multi-Page Automation",
-  "version": "1.1.0",
-  "description": "Automates multi-step OAuth registration workflow",
-  "permissions": [
-    "sidePanel",
-    "tabs",
-    "webNavigation",
-    "storage",
-    "scripting",
-    "activeTab"
-  ],
-  "host_permissions": [
-    "<all_urls>"
-  ],
-  "background": {
-    "service_worker": "background.js"
-  },
-  "side_panel": {
-    "default_path": "sidepanel/sidepanel.html"
-  },
-  "content_scripts": [
-    {
-      "matches": [
-        "https://auth0.openai.com/*",
-        "https://auth.openai.com/*",
-        "https://accounts.openai.com/*"
-      ],
-      "js": ["content/utils.js", "content/signup-page.js"],
-      "run_at": "document_idle"
-    },
-    {
-      "matches": [
-        "https://mail.qq.com/*",
-        "https://wx.mail.qq.com/*"
-      ],
-      "js": ["content/utils.js", "content/qq-mail.js"],
-      "all_frames": true,
-      "run_at": "document_idle"
-    },
+{
+  "manifest_version": 3,
+  "name": "Multi-Page Automation",
+  "version": "1.1.0",
+  "description": "Automates multi-step OAuth registration workflow",
+  "permissions": [
+    "sidePanel",
+    "tabs",
+    "webNavigation",
+    "storage",
+    "scripting",
+    "activeTab"
+  ],
+  "host_permissions": [
+    "<all_urls>"
+  ],
+  "background": {
+    "service_worker": "background.js"
+  },
+  "side_panel": {
+    "default_path": "sidepanel/sidepanel.html"
+  },
+  "content_scripts": [
+    {
+      "matches": [
+        "https://auth0.openai.com/*",
+        "https://auth.openai.com/*",
+        "https://accounts.openai.com/*"
+      ],
+      "js": ["content/utils.js", "content/signup-page.js"],
+      "run_at": "document_idle"
+    },
+    {
+      "matches": [
+        "https://mail.qq.com/*",
+        "https://wx.mail.qq.com/*"
+      ],
+      "js": ["content/utils.js", "content/qq-mail.js"],
+      "all_frames": true,
+      "run_at": "document_idle"
+    },
     {
       "matches": [
         "https://mail.163.com/*"
@@ -46,25 +46,18 @@
       "js": ["content/utils.js", "content/mail-163.js"],
       "all_frames": true,
       "run_at": "document_idle"
-    },
-    {
-      "matches": [
-        "https://duckduckgo.com/email/settings/autofill*"
-      ],
-      "js": ["content/utils.js", "content/duck-mail.js"],
-      "run_at": "document_idle"
     }
   ],
-  "action": {
-    "default_icon": {
-      "16": "icons/icon16.png",
-      "48": "icons/icon48.png",
-      "128": "icons/icon128.png"
-    }
-  },
-  "icons": {
-    "16": "icons/icon16.png",
-    "48": "icons/icon48.png",
-    "128": "icons/icon128.png"
-  }
-}
+  "action": {
+    "default_icon": {
+      "16": "icons/icon16.png",
+      "48": "icons/icon48.png",
+      "128": "icons/icon128.png"
+    }
+  },
+  "icons": {
+    "16": "icons/icon16.png",
+    "48": "icons/icon48.png",
+    "128": "icons/icon128.png"
+  }
+}

+ 669 - 669
sidepanel/sidepanel.css

@@ -1,669 +1,669 @@
-/* ============================================================
-   MultiPage Automation — Side Panel
-   Design: Swiss Modernism + Developer Tool
-   Font: Inter (UI) + JetBrains Mono (code)
-   Themes: Light (default) + Dark (toggle)
-   ============================================================ */
-
-/* ---- Light Theme (default) ---- */
-:root {
-  --bg-base: #ffffff;
-  --bg-surface: #f7f8fa;
-  --bg-elevated: #eef0f4;
-  --bg-hover: #e4e7ec;
-  --bg-active: #dce0e8;
-
-  --border: #d8dce3;
-  --border-subtle: #e8ecf1;
-
-  --text-primary: #1a1d24;
-  --text-secondary: #5c6370;
-  --text-muted: #9ca3af;
-
-  --blue: #2563eb;
-  --blue-soft: rgba(37, 99, 235, 0.08);
-  --blue-glow: rgba(37, 99, 235, 0.12);
-  --green: #16a34a;
-  --green-soft: rgba(22, 163, 74, 0.08);
-  --orange: #ea580c;
-  --orange-soft: rgba(234, 88, 12, 0.08);
-  --red: #dc2626;
-  --red-soft: rgba(220, 38, 38, 0.08);
-  --cyan: #0891b2;
-  --purple: #7c3aed;
-
-  --shadow-sm: 0 1px 2px rgba(0,0,0,0.04);
-  --shadow-md: 0 2px 6px rgba(0,0,0,0.06);
-
-  --radius-sm: 6px;
-  --radius-md: 8px;
-  --transition: 150ms ease;
-}
-
-/* ---- Dark Theme ---- */
-[data-theme="dark"] {
-  --bg-base: #0f1117;
-  --bg-surface: #181a21;
-  --bg-elevated: #21242d;
-  --bg-hover: #2a2e38;
-  --bg-active: #323844;
-
-  --border: #2a2e38;
-  --border-subtle: #21242d;
-
-  --text-primary: #e4e6eb;
-  --text-secondary: #8b919e;
-  --text-muted: #565c6a;
-
-  --blue: #3b82f6;
-  --blue-soft: rgba(59, 130, 246, 0.12);
-  --blue-glow: rgba(59, 130, 246, 0.18);
-  --green: #22c55e;
-  --green-soft: rgba(34, 197, 94, 0.12);
-  --orange: #f97316;
-  --orange-soft: rgba(249, 115, 22, 0.12);
-  --red: #ef4444;
-  --red-soft: rgba(239, 68, 68, 0.12);
-  --cyan: #06b6d4;
-  --purple: #a78bfa;
-
-  --shadow-sm: 0 1px 2px rgba(0,0,0,0.2);
-  --shadow-md: 0 2px 8px rgba(0,0,0,0.3);
-}
-
-* { margin: 0; padding: 0; box-sizing: border-box; }
-
-body {
-  font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
-  font-size: 14px;
-  color: var(--text-primary);
-  background: var(--bg-base);
-  padding: 12px;
-  width: 100%;
-  min-height: 100vh;
-  -webkit-font-smoothing: antialiased;
-  transition: background var(--transition), color var(--transition);
-}
-
-/* ============================================================
-   Header
-   ============================================================ */
-
-header {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  margin-bottom: 14px;
-  padding-bottom: 12px;
-  border-bottom: 1px solid var(--border-subtle);
-}
-
-.header-left {
-  display: flex;
-  align-items: center;
-  gap: 8px;
-}
-
-.header-left svg { color: var(--blue); }
-
-.header-left h1 {
-  font-size: 16px;
-  font-weight: 700;
-  letter-spacing: -0.02em;
-  color: var(--text-primary);
-}
-
-.header-btns {
-  display: flex;
-  align-items: center;
-  gap: 4px;
-}
-
-/* ============================================================
-   Theme Toggle
-   ============================================================ */
-
-.theme-toggle {
-  width: 30px;
-  height: 30px;
-  border: none;
-  background: transparent;
-  color: var(--text-muted);
-  border-radius: var(--radius-sm);
-  cursor: pointer;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  transition: all var(--transition);
-}
-.theme-toggle:hover { background: var(--bg-hover); color: var(--text-primary); }
-.theme-toggle .icon-moon { display: block; }
-.theme-toggle .icon-sun { display: none; }
-[data-theme="dark"] .theme-toggle .icon-moon { display: none; }
-[data-theme="dark"] .theme-toggle .icon-sun { display: block; }
-
-/* ============================================================
-   Buttons
-   ============================================================ */
-
-.btn {
-  display: inline-flex;
-  align-items: center;
-  gap: 6px;
-  padding: 7px 14px;
-  font-family: inherit;
-  font-size: 13px;
-  font-weight: 600;
-  border: 1px solid transparent;
-  border-radius: var(--radius-sm);
-  cursor: pointer;
-  transition: all var(--transition);
-  white-space: nowrap;
-}
-
-.btn-primary {
-  background: var(--blue);
-  color: #fff;
-}
-.btn-primary:hover { opacity: 0.9; box-shadow: 0 2px 8px var(--blue-glow); }
-
-.btn-success {
-  background: var(--green);
-  color: #fff;
-}
-.btn-success:hover { opacity: 0.9; box-shadow: 0 2px 8px var(--green-soft); }
-
-.btn-danger {
-  background: var(--red);
-  color: #fff;
-}
-.btn-danger:hover { opacity: 0.9; box-shadow: 0 2px 8px var(--red-soft); }
-
-.run-group {
-  display: flex;
-  align-items: center;
-  gap: 4px;
-}
-
-.run-count-input {
-  width: 42px;
-  padding: 6px 4px;
-  text-align: center;
-  background: var(--bg-base);
-  border: 1px solid var(--border);
-  border-radius: var(--radius-sm);
-  color: var(--text-primary);
-  font-family: 'JetBrains Mono', monospace;
-  font-size: 13px;
-  font-weight: 600;
-  outline: none;
-}
-.run-count-input:focus { border-color: var(--blue); }
-.run-count-input::-webkit-inner-spin-button { opacity: 0.5; }
-.btn-success:disabled, .btn-primary:disabled, .btn-danger:disabled { background: var(--bg-elevated); color: var(--text-muted); cursor: not-allowed; box-shadow: none; }
-.run-count-input:disabled { opacity: 0.5; cursor: not-allowed; }
-
-.btn-ghost {
-  background: transparent;
-  color: var(--text-secondary);
-  padding: 6px;
-  border-radius: var(--radius-sm);
-}
-.btn-ghost:hover { background: var(--bg-hover); color: var(--text-primary); }
-
-.btn-outline {
-  background: transparent;
-  border: 1px solid var(--border);
-  color: var(--text-secondary);
-}
-.btn-outline:hover { border-color: var(--blue); color: var(--blue); background: var(--blue-soft); }
-
-.btn-sm { padding: 5px 12px; font-size: 12px; }
-.btn-xs { padding: 4px 10px; font-size: 11px; }
-
-/* ============================================================
-   Data Card
-   ============================================================ */
-
-#data-section { margin-bottom: 14px; }
-
-.data-card {
-  background: var(--bg-surface);
-  border: 1px solid var(--border);
-  border-radius: var(--radius-md);
-  padding: 12px 14px;
-  display: flex;
-  flex-direction: column;
-  gap: 9px;
-  box-shadow: var(--shadow-sm);
-}
-
-.data-row {
-  display: flex;
-  align-items: center;
-  gap: 8px;
-}
-
-.data-inline {
-  display: flex;
-  align-items: center;
-  gap: 8px;
-  flex: 1;
-  min-width: 0;
-}
-
-.data-label {
-  width: 56px;
-  font-size: 11px;
-  font-weight: 700;
-  color: var(--text-muted);
-  text-transform: uppercase;
-  letter-spacing: 0.06em;
-  flex-shrink: 0;
-}
-
-.data-value {
-  font-size: 13px;
-  color: var(--text-muted);
-  min-width: 0;
-}
-
-.data-value.has-value { color: var(--text-primary); }
-
-.mono {
-  font-family: 'JetBrains Mono', 'Consolas', monospace;
-  font-size: 12px;
-}
-
-.truncate {
-  overflow: hidden;
-  text-overflow: ellipsis;
-  white-space: nowrap;
-}
-
-.data-input {
-  flex: 1;
-  padding: 7px 10px;
-  background: var(--bg-base);
-  border: 1px solid var(--border);
-  border-radius: var(--radius-sm);
-  color: var(--text-primary);
-  font-family: 'JetBrains Mono', monospace;
-  font-size: 13px;
-  outline: none;
-  transition: border-color var(--transition), box-shadow var(--transition);
-  min-width: 0;
-}
-.data-input::placeholder { color: var(--text-muted); }
-.data-input:focus { border-color: var(--blue); box-shadow: 0 0 0 3px var(--blue-soft); }
-
-#btn-fetch-email {
-  padding-inline: 10px;
-}
-
-#btn-toggle-password {
-  min-width: 58px;
-  padding-inline: 10px;
-}
-
-.data-select {
-  flex: 1;
-  padding: 7px 10px;
-  background: var(--bg-base);
-  border: 1px solid var(--border);
-  border-radius: var(--radius-sm);
-  color: var(--text-primary);
-  font-family: inherit;
-  font-size: 13px;
-  outline: none;
-  cursor: pointer;
-  transition: border-color var(--transition);
-  min-width: 0;
-}
-.data-select:focus { border-color: var(--blue); box-shadow: 0 0 0 3px var(--blue-soft); }
-[data-theme="dark"] .data-select { color-scheme: dark; }
-
-/* Status Bar */
-.status-bar {
-  display: flex;
-  align-items: center;
-  gap: 8px;
-  margin-top: 8px;
-  padding: 8px 12px;
-  background: var(--bg-surface);
-  border: 1px solid var(--border);
-  border-radius: var(--radius-sm);
-  font-size: 13px;
-  font-weight: 500;
-  color: var(--text-secondary);
-  box-shadow: var(--shadow-sm);
-}
-
-.status-dot {
-  width: 8px;
-  height: 8px;
-  border-radius: 50%;
-  background: var(--text-muted);
-  flex-shrink: 0;
-  transition: background var(--transition);
-}
-
-.status-bar.running .status-dot {
-  background: var(--orange);
-  animation: pulse 1.5s ease-in-out infinite;
-}
-.status-bar.running { color: var(--orange); }
-
-.status-bar.completed .status-dot { background: var(--green); }
-.status-bar.completed { color: var(--green); }
-
-.status-bar.failed .status-dot { background: var(--red); }
-.status-bar.failed { color: var(--red); }
-
-.status-bar.stopped .status-dot { background: var(--cyan); }
-.status-bar.stopped { color: var(--cyan); }
-
-@keyframes pulse {
-  0%, 100% { opacity: 1; transform: scale(1); }
-  50% { opacity: 0.4; transform: scale(0.85); }
-}
-
-/* Auto Continue Bar */
-.auto-continue-bar {
-  margin-top: 8px;
-  padding: 8px 10px;
-  background: var(--orange-soft);
-  border: 1px solid rgba(234, 88, 12, 0.2);
-  border-radius: var(--radius-sm);
-  display: flex;
-  align-items: center;
-  gap: 8px;
-}
-.auto-continue-bar svg { color: var(--orange); flex-shrink: 0; }
-.auto-hint { font-size: 13px; color: var(--orange); flex: 1; font-weight: 500; }
-
-/* ============================================================
-   Steps Section
-   ============================================================ */
-
-#steps-section { margin-bottom: 14px; }
-
-.steps-header {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  margin-bottom: 8px;
-}
-
-.section-label {
-  font-size: 11px;
-  font-weight: 700;
-  color: var(--text-muted);
-  text-transform: uppercase;
-  letter-spacing: 0.08em;
-}
-
-.steps-progress {
-  font-family: 'JetBrains Mono', monospace;
-  font-size: 11px;
-  font-weight: 600;
-  color: var(--text-muted);
-  background: var(--bg-surface);
-  padding: 2px 8px;
-  border-radius: 10px;
-  border: 1px solid var(--border);
-}
-
-.steps-list {
-  display: flex;
-  flex-direction: column;
-  gap: 5px;
-}
-
-.step-row {
-  display: flex;
-  align-items: center;
-  gap: 8px;
-  padding: 1px 0;
-  transition: opacity var(--transition);
-}
-
-/* Step Number Indicator */
-.step-indicator {
-  width: 26px;
-  height: 26px;
-  border-radius: 50%;
-  background: var(--bg-surface);
-  border: 1.5px solid var(--border);
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  flex-shrink: 0;
-  transition: all var(--transition);
-}
-
-.step-num {
-  font-size: 11px;
-  font-weight: 700;
-  color: var(--text-muted);
-  transition: color var(--transition);
-}
-
-.step-row.running .step-indicator { border-color: var(--orange); background: var(--orange-soft); }
-.step-row.running .step-num { color: var(--orange); }
-.step-row.running .step-indicator { animation: pulse 1.5s ease-in-out infinite; }
-
-.step-row.completed .step-indicator { border-color: var(--green); background: var(--green-soft); }
-.step-row.completed .step-num { color: var(--green); }
-
-.step-row.failed .step-indicator { border-color: var(--red); background: var(--red-soft); }
-.step-row.failed .step-num { color: var(--red); }
-
-.step-row.stopped .step-indicator { border-color: var(--cyan); background: rgba(8, 145, 178, 0.08); }
-.step-row.stopped .step-num { color: var(--cyan); }
-
-/* Step Button */
-.step-btn {
-  flex: 1;
-  padding: 8px 12px;
-  font-family: inherit;
-  font-size: 13px;
-  font-weight: 500;
-  color: var(--text-primary);
-  background: var(--bg-surface);
-  border: 1px solid var(--border);
-  border-radius: var(--radius-sm);
-  cursor: pointer;
-  text-align: left;
-  transition: all var(--transition);
-  box-shadow: var(--shadow-sm);
-}
-.step-btn:hover:not(:disabled) { background: var(--bg-hover); border-color: var(--blue); }
-.step-btn:disabled { color: var(--text-muted); background: var(--bg-base); border-color: var(--border-subtle); cursor: not-allowed; opacity: 0.45; box-shadow: none; }
-
-.step-row.running .step-btn { border-color: var(--orange); color: var(--orange); }
-.step-row.completed .step-btn { border-color: var(--border-subtle); color: var(--text-secondary); opacity: 0.7; }
-.step-row.failed .step-btn { border-color: var(--red); color: var(--red); }
-.step-row.stopped .step-btn { border-color: var(--cyan); color: var(--cyan); }
-
-.step-status {
-  width: 20px;
-  text-align: center;
-  font-size: 14px;
-  font-weight: 700;
-  flex-shrink: 0;
-}
-.step-row.completed .step-status { color: var(--green); }
-.step-row.failed .step-status { color: var(--red); }
-.step-row.stopped .step-status { color: var(--cyan); }
-
-/* ============================================================
-   Log / Console Section
-   ============================================================ */
-
-.log-header {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  margin-bottom: 6px;
-}
-
-#log-area {
-  background: var(--bg-surface);
-  border: 1px solid var(--border);
-  border-radius: var(--radius-md);
-  padding: 10px 12px;
-  height: 220px;
-  overflow-y: auto;
-  font-family: 'JetBrains Mono', 'Consolas', monospace;
-  font-size: 12px;
-  line-height: 1.7;
-  color: var(--text-secondary);
-  box-shadow: var(--shadow-sm);
-}
-
-#log-area::-webkit-scrollbar { width: 5px; }
-#log-area::-webkit-scrollbar-track { background: transparent; }
-#log-area::-webkit-scrollbar-thumb { background: var(--bg-elevated); border-radius: 4px; }
-#log-area::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
-
-.log-line { padding: 2.5px 0; }
-.log-line + .log-line { border-top: 1px solid var(--border-subtle); }
-
-.log-time { color: var(--text-muted); }
-
-.log-level { font-weight: 700; margin: 0 2px; }
-.log-level-info { color: var(--blue); }
-.log-level-ok { color: var(--green); }
-.log-level-warn { color: var(--orange); }
-.log-level-error { color: var(--red); }
-
-.log-step-tag {
-  display: inline-block;
-  padding: 1px 5px;
-  border-radius: 3px;
-  font-weight: 700;
-  font-size: 11px;
-  margin-right: 3px;
-}
-.log-step-tag.step-1 { color: var(--cyan); background: rgba(8, 145, 178, 0.08); }
-.log-step-tag.step-2 { color: var(--purple); background: rgba(124, 58, 237, 0.08); }
-.log-step-tag.step-3 { color: #b45309; background: rgba(180, 83, 9, 0.06); }
-[data-theme="dark"] .log-step-tag.step-3 { color: #fbbf24; background: rgba(251, 191, 36, 0.1); }
-.log-step-tag.step-4 { color: var(--orange); background: var(--orange-soft); }
-.log-step-tag.step-5 { color: var(--green); background: var(--green-soft); }
-.log-step-tag.step-6 { color: var(--cyan); background: rgba(8, 145, 178, 0.08); }
-.log-step-tag.step-7 { color: var(--orange); background: var(--orange-soft); }
-.log-step-tag.step-8 { color: var(--purple); background: rgba(124, 58, 237, 0.08); }
-.log-step-tag.step-9 { color: var(--green); background: var(--green-soft); }
-
-.log-msg { color: var(--text-secondary); }
-.log-line.log-ok .log-msg { color: var(--green); font-weight: 500; }
-.log-line.log-error .log-msg { color: var(--red); font-weight: 500; }
-.log-line.log-warn .log-msg { color: var(--orange); }
-
-/* ============================================================
-   Animations
-   ============================================================ */
-
-@keyframes fadeIn {
-  from { opacity: 0; transform: translateY(3px); }
-  to { opacity: 1; transform: translateY(0); }
-}
-
-.log-line { animation: fadeIn 120ms ease-out; }
-
-/* ============================================================
-   Toast Notifications
-   ============================================================ */
-
-#toast-container {
-  position: fixed;
-  top: 12px;
-  left: 12px;
-  right: 12px;
-  z-index: 1000;
-  display: flex;
-  flex-direction: column;
-  gap: 6px;
-  pointer-events: none;
-}
-
-.toast {
-  display: flex;
-  align-items: center;
-  gap: 8px;
-  padding: 10px 14px;
-  border-radius: var(--radius-md);
-  font-size: 13px;
-  font-weight: 500;
-  box-shadow: var(--shadow-md), 0 4px 12px rgba(0,0,0,0.1);
-  pointer-events: auto;
-  animation: toastIn 250ms ease-out;
-  border: 1px solid;
-}
-
-.toast.toast-exit {
-  animation: toastOut 200ms ease-in forwards;
-}
-
-.toast-error {
-  background: var(--red-soft);
-  border-color: var(--red);
-  color: var(--red);
-}
-
-.toast-warn {
-  background: var(--orange-soft);
-  border-color: var(--orange);
-  color: var(--orange);
-}
-
-.toast-success {
-  background: var(--green-soft);
-  border-color: var(--green);
-  color: var(--green);
-}
-
-.toast-info {
-  background: var(--blue-soft);
-  border-color: var(--blue);
-  color: var(--blue);
-}
-
-.toast svg {
-  flex-shrink: 0;
-}
-
-.toast-msg {
-  flex: 1;
-}
-
-.toast-close {
-  background: none;
-  border: none;
-  color: inherit;
-  opacity: 0.6;
-  cursor: pointer;
-  padding: 2px;
-  font-size: 16px;
-  line-height: 1;
-}
-.toast-close:hover { opacity: 1; }
-
-@keyframes toastIn {
-  from { opacity: 0; transform: translateY(-10px) scale(0.96); }
-  to { opacity: 1; transform: translateY(0) scale(1); }
-}
-
-@keyframes toastOut {
-  from { opacity: 1; transform: translateY(0) scale(1); }
-  to { opacity: 0; transform: translateY(-10px) scale(0.96); }
-}
-
-@media (prefers-reduced-motion: reduce) {
-  *, *::before, *::after {
-    animation-duration: 0.01ms !important;
-    transition-duration: 0.01ms !important;
-  }
-}
+/* ============================================================
+   MultiPage Automation — Side Panel
+   Design: Swiss Modernism + Developer Tool
+   Font: Inter (UI) + JetBrains Mono (code)
+   Themes: Light (default) + Dark (toggle)
+   ============================================================ */
+
+/* ---- Light Theme (default) ---- */
+:root {
+  --bg-base: #ffffff;
+  --bg-surface: #f7f8fa;
+  --bg-elevated: #eef0f4;
+  --bg-hover: #e4e7ec;
+  --bg-active: #dce0e8;
+
+  --border: #d8dce3;
+  --border-subtle: #e8ecf1;
+
+  --text-primary: #1a1d24;
+  --text-secondary: #5c6370;
+  --text-muted: #9ca3af;
+
+  --blue: #2563eb;
+  --blue-soft: rgba(37, 99, 235, 0.08);
+  --blue-glow: rgba(37, 99, 235, 0.12);
+  --green: #16a34a;
+  --green-soft: rgba(22, 163, 74, 0.08);
+  --orange: #ea580c;
+  --orange-soft: rgba(234, 88, 12, 0.08);
+  --red: #dc2626;
+  --red-soft: rgba(220, 38, 38, 0.08);
+  --cyan: #0891b2;
+  --purple: #7c3aed;
+
+  --shadow-sm: 0 1px 2px rgba(0,0,0,0.04);
+  --shadow-md: 0 2px 6px rgba(0,0,0,0.06);
+
+  --radius-sm: 6px;
+  --radius-md: 8px;
+  --transition: 150ms ease;
+}
+
+/* ---- Dark Theme ---- */
+[data-theme="dark"] {
+  --bg-base: #0f1117;
+  --bg-surface: #181a21;
+  --bg-elevated: #21242d;
+  --bg-hover: #2a2e38;
+  --bg-active: #323844;
+
+  --border: #2a2e38;
+  --border-subtle: #21242d;
+
+  --text-primary: #e4e6eb;
+  --text-secondary: #8b919e;
+  --text-muted: #565c6a;
+
+  --blue: #3b82f6;
+  --blue-soft: rgba(59, 130, 246, 0.12);
+  --blue-glow: rgba(59, 130, 246, 0.18);
+  --green: #22c55e;
+  --green-soft: rgba(34, 197, 94, 0.12);
+  --orange: #f97316;
+  --orange-soft: rgba(249, 115, 22, 0.12);
+  --red: #ef4444;
+  --red-soft: rgba(239, 68, 68, 0.12);
+  --cyan: #06b6d4;
+  --purple: #a78bfa;
+
+  --shadow-sm: 0 1px 2px rgba(0,0,0,0.2);
+  --shadow-md: 0 2px 8px rgba(0,0,0,0.3);
+}
+
+* { margin: 0; padding: 0; box-sizing: border-box; }
+
+body {
+  font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
+  font-size: 14px;
+  color: var(--text-primary);
+  background: var(--bg-base);
+  padding: 12px;
+  width: 100%;
+  min-height: 100vh;
+  -webkit-font-smoothing: antialiased;
+  transition: background var(--transition), color var(--transition);
+}
+
+/* ============================================================
+   Header
+   ============================================================ */
+
+header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 14px;
+  padding-bottom: 12px;
+  border-bottom: 1px solid var(--border-subtle);
+}
+
+.header-left {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.header-left svg { color: var(--blue); }
+
+.header-left h1 {
+  font-size: 16px;
+  font-weight: 700;
+  letter-spacing: -0.02em;
+  color: var(--text-primary);
+}
+
+.header-btns {
+  display: flex;
+  align-items: center;
+  gap: 4px;
+}
+
+/* ============================================================
+   Theme Toggle
+   ============================================================ */
+
+.theme-toggle {
+  width: 30px;
+  height: 30px;
+  border: none;
+  background: transparent;
+  color: var(--text-muted);
+  border-radius: var(--radius-sm);
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  transition: all var(--transition);
+}
+.theme-toggle:hover { background: var(--bg-hover); color: var(--text-primary); }
+.theme-toggle .icon-moon { display: block; }
+.theme-toggle .icon-sun { display: none; }
+[data-theme="dark"] .theme-toggle .icon-moon { display: none; }
+[data-theme="dark"] .theme-toggle .icon-sun { display: block; }
+
+/* ============================================================
+   Buttons
+   ============================================================ */
+
+.btn {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  padding: 7px 14px;
+  font-family: inherit;
+  font-size: 13px;
+  font-weight: 600;
+  border: 1px solid transparent;
+  border-radius: var(--radius-sm);
+  cursor: pointer;
+  transition: all var(--transition);
+  white-space: nowrap;
+}
+
+.btn-primary {
+  background: var(--blue);
+  color: #fff;
+}
+.btn-primary:hover { opacity: 0.9; box-shadow: 0 2px 8px var(--blue-glow); }
+
+.btn-success {
+  background: var(--green);
+  color: #fff;
+}
+.btn-success:hover { opacity: 0.9; box-shadow: 0 2px 8px var(--green-soft); }
+
+.btn-danger {
+  background: var(--red);
+  color: #fff;
+}
+.btn-danger:hover { opacity: 0.9; box-shadow: 0 2px 8px var(--red-soft); }
+
+.run-group {
+  display: flex;
+  align-items: center;
+  gap: 4px;
+}
+
+.run-count-input {
+  width: 42px;
+  padding: 6px 4px;
+  text-align: center;
+  background: var(--bg-base);
+  border: 1px solid var(--border);
+  border-radius: var(--radius-sm);
+  color: var(--text-primary);
+  font-family: 'JetBrains Mono', monospace;
+  font-size: 13px;
+  font-weight: 600;
+  outline: none;
+}
+.run-count-input:focus { border-color: var(--blue); }
+.run-count-input::-webkit-inner-spin-button { opacity: 0.5; }
+.btn-success:disabled, .btn-primary:disabled, .btn-danger:disabled { background: var(--bg-elevated); color: var(--text-muted); cursor: not-allowed; box-shadow: none; }
+.run-count-input:disabled { opacity: 0.5; cursor: not-allowed; }
+
+.btn-ghost {
+  background: transparent;
+  color: var(--text-secondary);
+  padding: 6px;
+  border-radius: var(--radius-sm);
+}
+.btn-ghost:hover { background: var(--bg-hover); color: var(--text-primary); }
+
+.btn-outline {
+  background: transparent;
+  border: 1px solid var(--border);
+  color: var(--text-secondary);
+}
+.btn-outline:hover { border-color: var(--blue); color: var(--blue); background: var(--blue-soft); }
+
+.btn-sm { padding: 5px 12px; font-size: 12px; }
+.btn-xs { padding: 4px 10px; font-size: 11px; }
+
+/* ============================================================
+   Data Card
+   ============================================================ */
+
+#data-section { margin-bottom: 14px; }
+
+.data-card {
+  background: var(--bg-surface);
+  border: 1px solid var(--border);
+  border-radius: var(--radius-md);
+  padding: 12px 14px;
+  display: flex;
+  flex-direction: column;
+  gap: 9px;
+  box-shadow: var(--shadow-sm);
+}
+
+.data-row {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.data-inline {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  flex: 1;
+  min-width: 0;
+}
+
+.data-label {
+  width: 56px;
+  font-size: 11px;
+  font-weight: 700;
+  color: var(--text-muted);
+  text-transform: uppercase;
+  letter-spacing: 0.06em;
+  flex-shrink: 0;
+}
+
+.data-value {
+  font-size: 13px;
+  color: var(--text-muted);
+  min-width: 0;
+}
+
+.data-value.has-value { color: var(--text-primary); }
+
+.mono {
+  font-family: 'JetBrains Mono', 'Consolas', monospace;
+  font-size: 12px;
+}
+
+.truncate {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.data-input {
+  flex: 1;
+  padding: 7px 10px;
+  background: var(--bg-base);
+  border: 1px solid var(--border);
+  border-radius: var(--radius-sm);
+  color: var(--text-primary);
+  font-family: 'JetBrains Mono', monospace;
+  font-size: 13px;
+  outline: none;
+  transition: border-color var(--transition), box-shadow var(--transition);
+  min-width: 0;
+}
+.data-input::placeholder { color: var(--text-muted); }
+.data-input:focus { border-color: var(--blue); box-shadow: 0 0 0 3px var(--blue-soft); }
+
+#btn-fetch-email {
+  padding-inline: 10px;
+}
+
+#btn-toggle-password {
+  min-width: 58px;
+  padding-inline: 10px;
+}
+
+.data-select {
+  flex: 1;
+  padding: 7px 10px;
+  background: var(--bg-base);
+  border: 1px solid var(--border);
+  border-radius: var(--radius-sm);
+  color: var(--text-primary);
+  font-family: inherit;
+  font-size: 13px;
+  outline: none;
+  cursor: pointer;
+  transition: border-color var(--transition);
+  min-width: 0;
+}
+.data-select:focus { border-color: var(--blue); box-shadow: 0 0 0 3px var(--blue-soft); }
+[data-theme="dark"] .data-select { color-scheme: dark; }
+
+/* Status Bar */
+.status-bar {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin-top: 8px;
+  padding: 8px 12px;
+  background: var(--bg-surface);
+  border: 1px solid var(--border);
+  border-radius: var(--radius-sm);
+  font-size: 13px;
+  font-weight: 500;
+  color: var(--text-secondary);
+  box-shadow: var(--shadow-sm);
+}
+
+.status-dot {
+  width: 8px;
+  height: 8px;
+  border-radius: 50%;
+  background: var(--text-muted);
+  flex-shrink: 0;
+  transition: background var(--transition);
+}
+
+.status-bar.running .status-dot {
+  background: var(--orange);
+  animation: pulse 1.5s ease-in-out infinite;
+}
+.status-bar.running { color: var(--orange); }
+
+.status-bar.completed .status-dot { background: var(--green); }
+.status-bar.completed { color: var(--green); }
+
+.status-bar.failed .status-dot { background: var(--red); }
+.status-bar.failed { color: var(--red); }
+
+.status-bar.stopped .status-dot { background: var(--cyan); }
+.status-bar.stopped { color: var(--cyan); }
+
+@keyframes pulse {
+  0%, 100% { opacity: 1; transform: scale(1); }
+  50% { opacity: 0.4; transform: scale(0.85); }
+}
+
+/* Auto Continue Bar */
+.auto-continue-bar {
+  margin-top: 8px;
+  padding: 8px 10px;
+  background: var(--orange-soft);
+  border: 1px solid rgba(234, 88, 12, 0.2);
+  border-radius: var(--radius-sm);
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+.auto-continue-bar svg { color: var(--orange); flex-shrink: 0; }
+.auto-hint { font-size: 13px; color: var(--orange); flex: 1; font-weight: 500; }
+
+/* ============================================================
+   Steps Section
+   ============================================================ */
+
+#steps-section { margin-bottom: 14px; }
+
+.steps-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 8px;
+}
+
+.section-label {
+  font-size: 11px;
+  font-weight: 700;
+  color: var(--text-muted);
+  text-transform: uppercase;
+  letter-spacing: 0.08em;
+}
+
+.steps-progress {
+  font-family: 'JetBrains Mono', monospace;
+  font-size: 11px;
+  font-weight: 600;
+  color: var(--text-muted);
+  background: var(--bg-surface);
+  padding: 2px 8px;
+  border-radius: 10px;
+  border: 1px solid var(--border);
+}
+
+.steps-list {
+  display: flex;
+  flex-direction: column;
+  gap: 5px;
+}
+
+.step-row {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 1px 0;
+  transition: opacity var(--transition);
+}
+
+/* Step Number Indicator */
+.step-indicator {
+  width: 26px;
+  height: 26px;
+  border-radius: 50%;
+  background: var(--bg-surface);
+  border: 1.5px solid var(--border);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+  transition: all var(--transition);
+}
+
+.step-num {
+  font-size: 11px;
+  font-weight: 700;
+  color: var(--text-muted);
+  transition: color var(--transition);
+}
+
+.step-row.running .step-indicator { border-color: var(--orange); background: var(--orange-soft); }
+.step-row.running .step-num { color: var(--orange); }
+.step-row.running .step-indicator { animation: pulse 1.5s ease-in-out infinite; }
+
+.step-row.completed .step-indicator { border-color: var(--green); background: var(--green-soft); }
+.step-row.completed .step-num { color: var(--green); }
+
+.step-row.failed .step-indicator { border-color: var(--red); background: var(--red-soft); }
+.step-row.failed .step-num { color: var(--red); }
+
+.step-row.stopped .step-indicator { border-color: var(--cyan); background: rgba(8, 145, 178, 0.08); }
+.step-row.stopped .step-num { color: var(--cyan); }
+
+/* Step Button */
+.step-btn {
+  flex: 1;
+  padding: 8px 12px;
+  font-family: inherit;
+  font-size: 13px;
+  font-weight: 500;
+  color: var(--text-primary);
+  background: var(--bg-surface);
+  border: 1px solid var(--border);
+  border-radius: var(--radius-sm);
+  cursor: pointer;
+  text-align: left;
+  transition: all var(--transition);
+  box-shadow: var(--shadow-sm);
+}
+.step-btn:hover:not(:disabled) { background: var(--bg-hover); border-color: var(--blue); }
+.step-btn:disabled { color: var(--text-muted); background: var(--bg-base); border-color: var(--border-subtle); cursor: not-allowed; opacity: 0.45; box-shadow: none; }
+
+.step-row.running .step-btn { border-color: var(--orange); color: var(--orange); }
+.step-row.completed .step-btn { border-color: var(--border-subtle); color: var(--text-secondary); opacity: 0.7; }
+.step-row.failed .step-btn { border-color: var(--red); color: var(--red); }
+.step-row.stopped .step-btn { border-color: var(--cyan); color: var(--cyan); }
+
+.step-status {
+  width: 20px;
+  text-align: center;
+  font-size: 14px;
+  font-weight: 700;
+  flex-shrink: 0;
+}
+.step-row.completed .step-status { color: var(--green); }
+.step-row.failed .step-status { color: var(--red); }
+.step-row.stopped .step-status { color: var(--cyan); }
+
+/* ============================================================
+   Log / Console Section
+   ============================================================ */
+
+.log-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 6px;
+}
+
+#log-area {
+  background: var(--bg-surface);
+  border: 1px solid var(--border);
+  border-radius: var(--radius-md);
+  padding: 10px 12px;
+  height: 220px;
+  overflow-y: auto;
+  font-family: 'JetBrains Mono', 'Consolas', monospace;
+  font-size: 12px;
+  line-height: 1.7;
+  color: var(--text-secondary);
+  box-shadow: var(--shadow-sm);
+}
+
+#log-area::-webkit-scrollbar { width: 5px; }
+#log-area::-webkit-scrollbar-track { background: transparent; }
+#log-area::-webkit-scrollbar-thumb { background: var(--bg-elevated); border-radius: 4px; }
+#log-area::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
+
+.log-line { padding: 2.5px 0; }
+.log-line + .log-line { border-top: 1px solid var(--border-subtle); }
+
+.log-time { color: var(--text-muted); }
+
+.log-level { font-weight: 700; margin: 0 2px; }
+.log-level-info { color: var(--blue); }
+.log-level-ok { color: var(--green); }
+.log-level-warn { color: var(--orange); }
+.log-level-error { color: var(--red); }
+
+.log-step-tag {
+  display: inline-block;
+  padding: 1px 5px;
+  border-radius: 3px;
+  font-weight: 700;
+  font-size: 11px;
+  margin-right: 3px;
+}
+.log-step-tag.step-1 { color: var(--cyan); background: rgba(8, 145, 178, 0.08); }
+.log-step-tag.step-2 { color: var(--purple); background: rgba(124, 58, 237, 0.08); }
+.log-step-tag.step-3 { color: #b45309; background: rgba(180, 83, 9, 0.06); }
+[data-theme="dark"] .log-step-tag.step-3 { color: #fbbf24; background: rgba(251, 191, 36, 0.1); }
+.log-step-tag.step-4 { color: var(--orange); background: var(--orange-soft); }
+.log-step-tag.step-5 { color: var(--green); background: var(--green-soft); }
+.log-step-tag.step-6 { color: var(--cyan); background: rgba(8, 145, 178, 0.08); }
+.log-step-tag.step-7 { color: var(--orange); background: var(--orange-soft); }
+.log-step-tag.step-8 { color: var(--purple); background: rgba(124, 58, 237, 0.08); }
+.log-step-tag.step-9 { color: var(--green); background: var(--green-soft); }
+
+.log-msg { color: var(--text-secondary); }
+.log-line.log-ok .log-msg { color: var(--green); font-weight: 500; }
+.log-line.log-error .log-msg { color: var(--red); font-weight: 500; }
+.log-line.log-warn .log-msg { color: var(--orange); }
+
+/* ============================================================
+   Animations
+   ============================================================ */
+
+@keyframes fadeIn {
+  from { opacity: 0; transform: translateY(3px); }
+  to { opacity: 1; transform: translateY(0); }
+}
+
+.log-line { animation: fadeIn 120ms ease-out; }
+
+/* ============================================================
+   Toast Notifications
+   ============================================================ */
+
+#toast-container {
+  position: fixed;
+  top: 12px;
+  left: 12px;
+  right: 12px;
+  z-index: 1000;
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+  pointer-events: none;
+}
+
+.toast {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 10px 14px;
+  border-radius: var(--radius-md);
+  font-size: 13px;
+  font-weight: 500;
+  box-shadow: var(--shadow-md), 0 4px 12px rgba(0,0,0,0.1);
+  pointer-events: auto;
+  animation: toastIn 250ms ease-out;
+  border: 1px solid;
+}
+
+.toast.toast-exit {
+  animation: toastOut 200ms ease-in forwards;
+}
+
+.toast-error {
+  background: var(--red-soft);
+  border-color: var(--red);
+  color: var(--red);
+}
+
+.toast-warn {
+  background: var(--orange-soft);
+  border-color: var(--orange);
+  color: var(--orange);
+}
+
+.toast-success {
+  background: var(--green-soft);
+  border-color: var(--green);
+  color: var(--green);
+}
+
+.toast-info {
+  background: var(--blue-soft);
+  border-color: var(--blue);
+  color: var(--blue);
+}
+
+.toast svg {
+  flex-shrink: 0;
+}
+
+.toast-msg {
+  flex: 1;
+}
+
+.toast-close {
+  background: none;
+  border: none;
+  color: inherit;
+  opacity: 0.6;
+  cursor: pointer;
+  padding: 2px;
+  font-size: 16px;
+  line-height: 1;
+}
+.toast-close:hover { opacity: 1; }
+
+@keyframes toastIn {
+  from { opacity: 0; transform: translateY(-10px) scale(0.96); }
+  to { opacity: 1; transform: translateY(0) scale(1); }
+}
+
+@keyframes toastOut {
+  from { opacity: 1; transform: translateY(0) scale(1); }
+  to { opacity: 0; transform: translateY(-10px) scale(0.96); }
+}
+
+@media (prefers-reduced-motion: reduce) {
+  *, *::before, *::after {
+    animation-duration: 0.01ms !important;
+    transition-duration: 0.01ms !important;
+  }
+}

+ 208 - 168
sidepanel/sidepanel.html

@@ -1,175 +1,215 @@
-<!DOCTYPE html>
-<html lang="zh-CN">
-<head>
-  <meta charset="UTF-8">
-  <meta name="viewport" content="width=device-width, initial-scale=1.0">
-  <title>Multi-Page Automation</title>
-  <link rel="preconnect" href="https://fonts.googleapis.com">
-  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
-  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
-  <link rel="stylesheet" href="sidepanel.css">
-</head>
-<body>
-  <header>
-    <div class="header-left">
-      <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
-        <path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/>
-      </svg>
-      <h1>MultiPage</h1>
-    </div>
-    <div class="header-btns">
-      <div class="run-group">
-        <input type="number" id="input-run-count" class="run-count-input" value="10000" min="1" max="99999" title="Number of runs" />
-        <button id="btn-auto-run" class="btn btn-success" title="Run all steps automatically">
-          <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>
-          Auto
-        </button>
-        <button id="btn-stop" class="btn btn-danger" title="Stop current flow" disabled>Stop</button>
-      </div>
-      <button id="btn-reset" class="btn btn-ghost" title="Reset all steps">
-        <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
-          <polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/>
-        </svg>
-      </button>
-      <button id="btn-theme" class="theme-toggle" title="Toggle theme">
-        <svg class="icon-moon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
-          <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
-        </svg>
-        <svg class="icon-sun" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
-          <circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
-        </svg>
-      </button>
-    </div>
-  </header>
-
-  <section id="data-section">
-    <div class="data-card">
-      <div class="data-row">
-        <span class="data-label">VPS</span>
-        <input type="password" id="input-vps-url" class="data-input" placeholder="http://ip:port/management.html#/oauth" />
-      </div>
-      <div class="data-row">
-        <span class="data-label">Mail</span>
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  <title>Multi-Page Automation</title>
+  <link rel="preconnect" href="https://fonts.googleapis.com">
+  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
+  <link rel="stylesheet" href="sidepanel.css">
+</head>
+<body>
+  <header>
+    <div class="header-left">
+      <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+        <path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/>
+      </svg>
+      <h1>MultiPage</h1>
+    </div>
+    <div class="header-btns">
+      <div class="run-group">
+        <input type="number" id="input-run-count" class="run-count-input" value="10000" min="1" max="99999" title="Number of runs" />
+        <button id="btn-auto-run" class="btn btn-success" title="Run all steps automatically">
+          <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>
+          Auto
+        </button>
+        <button id="btn-stop" class="btn btn-danger" title="Stop current flow" disabled>Stop</button>
+      </div>
+      <button id="btn-reset" class="btn btn-ghost" title="Reset all steps">
+        <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+          <polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/>
+        </svg>
+      </button>
+      <button id="btn-theme" class="theme-toggle" title="Toggle theme">
+        <svg class="icon-moon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+          <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
+        </svg>
+        <svg class="icon-sun" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+          <circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
+        </svg>
+      </button>
+    </div>
+  </header>
+
+  <section id="data-section">
+    <div class="data-card">
+      <div class="data-row">
+        <span class="data-label">VPS</span>
+        <input type="password" id="input-vps-url" class="data-input" placeholder="http://ip:port/management.html#/oauth" />
+      </div>
+      <div class="data-row">
+        <span class="data-label">Mail</span>
         <select id="select-mail-provider" class="data-select">
           <option value="temp-api">Temp Email API</option>
+          <option value="gmail">Gmail Alias (Gmail API)</option>
           <option value="163">163 Mail (mail.163.com)</option>
           <option value="qq">QQ Mail (wx.mail.qq.com)</option>
           <option value="inbucket">Inbucket (custom host)</option>
         </select>
       </div>
-      <div class="data-row" id="row-temp-api-url" style="display:none;">
-        <span class="data-label">API URL</span>
-        <input type="text" id="input-temp-api-url" class="data-input" placeholder="https://temp-email-api.xxx.cc/?jwt=..." />
-      </div>
-      <div class="data-row" id="row-inbucket-host" style="display:none;">
-        <span class="data-label">Inbucket</span>
-        <input type="text" id="input-inbucket-host" class="data-input" placeholder="your-inbucket-host or https://your-inbucket-host" />
-      </div>
-      <div class="data-row" id="row-inbucket-mailbox" style="display:none;">
-        <span class="data-label">Mailbox</span>
-        <input type="text" id="input-inbucket-mailbox" class="data-input" placeholder="e.g. zju2001" />
-      </div>
-      <div class="data-row">
-        <span class="data-label">Duck Token</span>
-        <input type="text" id="input-duck-token" class="data-input" placeholder="Paste DuckDuckGo access_token (auto-extracted if empty)" />
-      </div>
-      <div class="data-row">
-        <span class="data-label">Email</span>
-        <div class="data-inline">
-          <input type="text" id="input-email" class="data-input" placeholder="Paste DuckDuckGo email" />
-          <button id="btn-fetch-email" class="btn btn-outline btn-sm" type="button">Auto</button>
-        </div>
-      </div>
-      <div class="data-row">
-        <span class="data-label">Password</span>
-        <div class="data-inline">
-          <input type="password" id="input-password" class="data-input" placeholder="Leave blank to auto-generate" />
-          <button id="btn-toggle-password" class="btn btn-outline btn-sm" type="button">Show</button>
-        </div>
-      </div>
-      <div class="data-row">
-        <span class="data-label">OAuth</span>
-        <span id="display-oauth-url" class="data-value mono truncate">Waiting...</span>
-      </div>
-      <div class="data-row">
-        <span class="data-label">Callback</span>
-        <span id="display-localhost-url" class="data-value mono truncate">Waiting...</span>
-      </div>
-    </div>
-    <div id="status-bar" class="status-bar">
-      <div class="status-dot"></div>
-      <span id="display-status">Ready</span>
-    </div>
-    <div id="auto-continue-bar" class="auto-continue-bar" style="display:none;">
-      <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
-      <span class="auto-hint">Use Auto to fetch Duck email, or paste manually, then continue</span>
-      <button id="btn-auto-continue" class="btn btn-primary btn-sm">Continue</button>
-    </div>
-  </section>
-
-  <section id="steps-section">
-    <div class="steps-header">
-      <span class="section-label">Workflow</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">Get OAuth Link</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">Open Signup</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">Fill Email / Password</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">Get Signup Code</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">Fill Name / Birthday</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">Login via 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">Get Login Code</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 Auto Confirm</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">VPS Verify</button>
-        <span class="step-status" data-step="9"></span>
-      </div>
-    </div>
-  </section>
-
-  <section id="log-section">
-    <div class="log-header">
-      <span class="section-label">Console</span>
-      <button id="btn-clear-log" class="btn btn-ghost btn-xs" title="Clear log">Clear</button>
-    </div>
-    <div id="log-area"></div>
-  </section>
-
-  <div id="toast-container"></div>
-  <script src="sidepanel.js"></script>
-</body>
-</html>
+      <div class="data-row" id="row-gmail-base-email" style="display:none;">
+        <span class="data-label">Gmail</span>
+        <input type="text" id="input-gmail-base-email" class="data-input" placeholder="chen6485882@gmail.com" />
+      </div>
+      <div class="data-row" id="row-gmail-client-id" style="display:none;">
+        <span class="data-label">Client ID</span>
+        <input type="text" id="input-gmail-client-id" class="data-input" placeholder="Google OAuth client_id" />
+      </div>
+      <div class="data-row" id="row-gmail-client-secret" style="display:none;">
+        <span class="data-label">Client Secret</span>
+        <input type="password" id="input-gmail-client-secret" class="data-input" placeholder="Google OAuth client_secret" />
+      </div>
+      <div class="data-row" id="row-gmail-refresh-token" style="display:none;">
+        <span class="data-label">Refresh Token</span>
+        <input type="password" id="input-gmail-refresh-token" class="data-input" placeholder="Google OAuth refresh_token" />
+      </div>
+      <div class="data-row" id="row-gmail-api-token" style="display:none;">
+        <span class="data-label">Access Token</span>
+        <input type="password" id="input-gmail-api-token" class="data-input" placeholder="可选:手动 access token;若填 refresh_token 则可留空" />
+      </div>
+      <div class="data-row" id="row-temp-api-url" style="display:none;">
+        <span class="data-label">API URL</span>
+        <input type="text" id="input-temp-api-url" class="data-input" placeholder="https://temp-email-api.xxx.cc/?jwt=..." />
+      </div>
+      <div class="data-row" id="row-temp-api-mails" style="display:none;">
+        <span class="data-label">收件路径</span>
+        <select id="select-temp-api-mails-path" class="data-select">
+          <option value="admin">/admin/mails(管理端)</option>
+          <option value="api">/api/mails</option>
+        </select>
+      </div>
+      <div class="data-row" id="row-temp-api-admin-auth" style="display:none;">
+        <span class="data-label">x-admin-auth</span>
+        <input type="text" id="input-temp-api-admin-auth" class="data-input" placeholder="管理端必填,如 admin@evelab2026" />
+      </div>
+      <div class="data-row" id="row-temp-api-custom-auth" style="display:none;">
+        <span class="data-label">x-custom-auth</span>
+        <input type="text" id="input-temp-api-custom-auth" class="data-input" placeholder="可选,服务端若要求则填写" />
+      </div>
+      <div class="data-row" id="row-temp-api-fingerprint" style="display:none;">
+        <span class="data-label">x-fingerprint</span>
+        <input type="text" id="input-temp-api-fingerprint" class="data-input" placeholder="与浏览器请求一致(若服务端校验)" />
+      </div>
+      <div class="data-row" id="row-temp-api-user-token" style="display:none;">
+        <span class="data-label">x-user-token</span>
+        <input type="text" id="input-temp-api-user-token" class="data-input" placeholder="可选,服务端若要求则填写" />
+      </div>
+      <div class="data-row" id="row-inbucket-host" style="display:none;">
+        <span class="data-label">Inbucket</span>
+        <input type="text" id="input-inbucket-host" class="data-input" placeholder="your-inbucket-host or https://your-inbucket-host" />
+      </div>
+      <div class="data-row" id="row-inbucket-mailbox" style="display:none;">
+        <span class="data-label">Mailbox</span>
+        <input type="text" id="input-inbucket-mailbox" class="data-input" placeholder="e.g. zju2001" />
+      </div>
+      <div class="data-row">
+        <span class="data-label">Email</span>
+        <div class="data-inline">
+          <input type="text" id="input-email" class="data-input" placeholder="随机生成或手动粘贴邮箱" />
+          <button id="btn-fetch-email" class="btn btn-outline btn-sm" type="button">随机</button>
+        </div>
+      </div>
+      <div class="data-row">
+        <span class="data-label">Password</span>
+        <div class="data-inline">
+          <input type="password" id="input-password" class="data-input" placeholder="Leave blank to auto-generate" />
+          <button id="btn-toggle-password" class="btn btn-outline btn-sm" type="button">Show</button>
+        </div>
+      </div>
+      <div class="data-row">
+        <span class="data-label">OAuth</span>
+        <span id="display-oauth-url" class="data-value mono truncate">Waiting...</span>
+      </div>
+      <div class="data-row">
+        <span class="data-label">Callback</span>
+        <span id="display-localhost-url" class="data-value mono truncate">Waiting...</span>
+      </div>
+    </div>
+    <div id="status-bar" class="status-bar">
+      <div class="status-dot"></div>
+      <span id="display-status">Ready</span>
+    </div>
+    <div id="auto-continue-bar" class="auto-continue-bar" style="display:none;">
+      <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
+      <span class="auto-hint">点击「随机」生成当前来源邮箱或粘贴后继续</span>
+      <button id="btn-auto-continue" class="btn btn-primary btn-sm">Continue</button>
+    </div>
+  </section>
+
+  <section id="steps-section">
+    <div class="steps-header">
+      <span class="section-label">Workflow</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">Get OAuth Link</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">Open Signup</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">Fill Email / Password</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">Get Signup Code</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">Fill Name / Birthday</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">Login via 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">Get Login Code</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 Auto Confirm</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">VPS Verify</button>
+        <span class="step-status" data-step="9"></span>
+      </div>
+    </div>
+  </section>
+
+  <section id="log-section">
+    <div class="log-header">
+      <span class="section-label">Console</span>
+      <button id="btn-clear-log" class="btn btn-ghost btn-xs" title="Clear log">Clear</button>
+    </div>
+    <div id="log-area"></div>
+  </section>
+
+  <div id="toast-container"></div>
+  <script src="sidepanel.js"></script>
+</body>
+</html>

+ 657 - 529
sidepanel/sidepanel.js

@@ -1,421 +1,480 @@
-// sidepanel/sidepanel.js — Side Panel logic
-
-const STATUS_ICONS = {
-  pending: '',
-  running: '',
-  completed: '\u2713',  // ✓
-  failed: '\u2717',     // ✗
-  stopped: '\u25A0',    // ■
-};
-
-const logArea = document.getElementById('log-area');
-const displayOauthUrl = document.getElementById('display-oauth-url');
-const displayLocalhostUrl = document.getElementById('display-localhost-url');
-const displayStatus = document.getElementById('display-status');
-const statusBar = document.getElementById('status-bar');
-const inputEmail = document.getElementById('input-email');
-const inputPassword = document.getElementById('input-password');
-const btnFetchEmail = document.getElementById('btn-fetch-email');
-const btnTogglePassword = document.getElementById('btn-toggle-password');
-const btnStop = document.getElementById('btn-stop');
-const btnReset = document.getElementById('btn-reset');
-const stepsProgress = document.getElementById('steps-progress');
-const btnAutoRun = document.getElementById('btn-auto-run');
-const btnAutoContinue = document.getElementById('btn-auto-continue');
-const autoContinueBar = document.getElementById('auto-continue-bar');
-const btnClearLog = document.getElementById('btn-clear-log');
-const inputVpsUrl = document.getElementById('input-vps-url');
+// sidepanel/sidepanel.js — Side Panel logic
+
+const STATUS_ICONS = {
+  pending: '',
+  running: '',
+  completed: '\u2713',  // ✓
+  failed: '\u2717',     // ✗
+  stopped: '\u25A0',    // ■
+};
+
+const logArea = document.getElementById('log-area');
+const displayOauthUrl = document.getElementById('display-oauth-url');
+const displayLocalhostUrl = document.getElementById('display-localhost-url');
+const displayStatus = document.getElementById('display-status');
+const statusBar = document.getElementById('status-bar');
+const inputEmail = document.getElementById('input-email');
+const inputPassword = document.getElementById('input-password');
+const btnFetchEmail = document.getElementById('btn-fetch-email');
+const btnTogglePassword = document.getElementById('btn-toggle-password');
+const btnStop = document.getElementById('btn-stop');
+const btnReset = document.getElementById('btn-reset');
+const stepsProgress = document.getElementById('steps-progress');
+const btnAutoRun = document.getElementById('btn-auto-run');
+const btnAutoContinue = document.getElementById('btn-auto-continue');
+const autoContinueBar = document.getElementById('auto-continue-bar');
+const btnClearLog = document.getElementById('btn-clear-log');
+const inputVpsUrl = document.getElementById('input-vps-url');
 const selectMailProvider = document.getElementById('select-mail-provider');
+const rowGmailBaseEmail = document.getElementById('row-gmail-base-email');
+const inputGmailBaseEmail = document.getElementById('input-gmail-base-email');
+const rowGmailClientId = document.getElementById('row-gmail-client-id');
+const inputGmailClientId = document.getElementById('input-gmail-client-id');
+const rowGmailClientSecret = document.getElementById('row-gmail-client-secret');
+const inputGmailClientSecret = document.getElementById('input-gmail-client-secret');
+const rowGmailRefreshToken = document.getElementById('row-gmail-refresh-token');
+const inputGmailRefreshToken = document.getElementById('input-gmail-refresh-token');
+const rowGmailApiToken = document.getElementById('row-gmail-api-token');
+const inputGmailApiToken = document.getElementById('input-gmail-api-token');
 const rowTempApiUrl = document.getElementById('row-temp-api-url');
-const inputTempApiUrl = document.getElementById('input-temp-api-url');
-const rowInbucketHost = document.getElementById('row-inbucket-host');
-const inputInbucketHost = document.getElementById('input-inbucket-host');
-const rowInbucketMailbox = document.getElementById('row-inbucket-mailbox');
-const inputInbucketMailbox = document.getElementById('input-inbucket-mailbox');
-const inputDuckToken = document.getElementById('input-duck-token');
+const inputTempApiUrl = document.getElementById('input-temp-api-url');
+const rowInbucketHost = document.getElementById('row-inbucket-host');
+const inputInbucketHost = document.getElementById('input-inbucket-host');
+const rowInbucketMailbox = document.getElementById('row-inbucket-mailbox');
+const inputInbucketMailbox = document.getElementById('input-inbucket-mailbox');
+const rowTempApiMails = document.getElementById('row-temp-api-mails');
+const selectTempApiMailsPath = document.getElementById('select-temp-api-mails-path');
+const rowTempApiAdminAuth = document.getElementById('row-temp-api-admin-auth');
+const rowTempApiCustomAuth = document.getElementById('row-temp-api-custom-auth');
+const rowTempApiFingerprint = document.getElementById('row-temp-api-fingerprint');
+const rowTempApiUserToken = document.getElementById('row-temp-api-user-token');
+const inputTempApiAdminAuth = document.getElementById('input-temp-api-admin-auth');
+const inputTempApiCustomAuth = document.getElementById('input-temp-api-custom-auth');
+const inputTempApiFingerprint = document.getElementById('input-temp-api-fingerprint');
+const inputTempApiUserToken = document.getElementById('input-temp-api-user-token');
 const inputRunCount = document.getElementById('input-run-count');
-
-// ============================================================
-// Toast Notifications
-// ============================================================
-
-const toastContainer = document.getElementById('toast-container');
-
-const TOAST_ICONS = {
-  error: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>',
-  warn: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>',
-  success: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>',
-  info: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>',
-};
-
-function showToast(message, type = 'error', duration = 4000) {
-  const toast = document.createElement('div');
-  toast.className = `toast toast-${type}`;
-  toast.innerHTML = `${TOAST_ICONS[type] || ''}<span class="toast-msg">${escapeHtml(message)}</span><button class="toast-close">&times;</button>`;
-
-  toast.querySelector('.toast-close').addEventListener('click', () => dismissToast(toast));
-  toastContainer.appendChild(toast);
-
-  if (duration > 0) {
-    setTimeout(() => dismissToast(toast), duration);
-  }
-}
-
-function dismissToast(toast) {
-  if (!toast.parentNode) return;
-  toast.classList.add('toast-exit');
-  toast.addEventListener('animationend', () => toast.remove());
-}
-
-// ============================================================
-// State Restore on load
-// ============================================================
-
-async function restoreState() {
-  try {
-    const state = await chrome.runtime.sendMessage({ type: 'GET_STATE', source: 'sidepanel' });
-
-    if (state.oauthUrl) {
-      displayOauthUrl.textContent = state.oauthUrl;
-      displayOauthUrl.classList.add('has-value');
+
+// ============================================================
+// Toast Notifications
+// ============================================================
+
+const toastContainer = document.getElementById('toast-container');
+
+const TOAST_ICONS = {
+  error: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>',
+  warn: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>',
+  success: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>',
+  info: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>',
+};
+
+function showToast(message, type = 'error', duration = 4000) {
+  const toast = document.createElement('div');
+  toast.className = `toast toast-${type}`;
+  toast.innerHTML = `${TOAST_ICONS[type] || ''}<span class="toast-msg">${escapeHtml(message)}</span><button class="toast-close">&times;</button>`;
+
+  toast.querySelector('.toast-close').addEventListener('click', () => dismissToast(toast));
+  toastContainer.appendChild(toast);
+
+  if (duration > 0) {
+    setTimeout(() => dismissToast(toast), duration);
+  }
+}
+
+function dismissToast(toast) {
+  if (!toast.parentNode) return;
+  toast.classList.add('toast-exit');
+  toast.addEventListener('animationend', () => toast.remove());
+}
+
+// ============================================================
+// State Restore on load
+// ============================================================
+
+async function restoreState() {
+  try {
+    const state = await chrome.runtime.sendMessage({ type: 'GET_STATE', source: 'sidepanel' });
+
+    if (state.oauthUrl) {
+      displayOauthUrl.textContent = state.oauthUrl;
+      displayOauthUrl.classList.add('has-value');
+    }
+    if (state.localhostUrl) {
+      displayLocalhostUrl.textContent = state.localhostUrl;
+      displayLocalhostUrl.classList.add('has-value');
+    }
+    if (state.email) {
+      inputEmail.value = state.email;
+    }
+    syncPasswordField(state);
+    if (state.vpsUrl) {
+      inputVpsUrl.value = state.vpsUrl;
+    }
+    if (state.mailProvider) {
+      selectMailProvider.value = state.mailProvider;
     }
-    if (state.localhostUrl) {
-      displayLocalhostUrl.textContent = state.localhostUrl;
-      displayLocalhostUrl.classList.add('has-value');
+    if (state.gmailBaseEmail) {
+      inputGmailBaseEmail.value = state.gmailBaseEmail;
     }
-    if (state.email) {
-      inputEmail.value = state.email;
+    if (state.gmailApiClientId) {
+      inputGmailClientId.value = state.gmailApiClientId;
     }
-    syncPasswordField(state);
-    if (state.vpsUrl) {
-      inputVpsUrl.value = state.vpsUrl;
+    if (state.gmailApiClientSecret) {
+      inputGmailClientSecret.value = state.gmailApiClientSecret;
     }
-    if (state.mailProvider) {
-      selectMailProvider.value = state.mailProvider;
+    if (state.gmailApiRefreshToken) {
+      inputGmailRefreshToken.value = state.gmailApiRefreshToken;
+    }
+    if (state.gmailApiAccessToken) {
+      inputGmailApiToken.value = state.gmailApiAccessToken;
     }
     if (state.tempApiUrl) {
       inputTempApiUrl.value = state.tempApiUrl;
+    }
+    if (state.tempApiMailsPath) {
+      selectTempApiMailsPath.value = state.tempApiMailsPath;
+    }
+    if (state.tempApiAdminAuth) {
+      inputTempApiAdminAuth.value = state.tempApiAdminAuth;
     }
-    if (state.duckToken) {
-      inputDuckToken.value = state.duckToken;
+    if (state.tempApiCustomAuth) {
+      inputTempApiCustomAuth.value = state.tempApiCustomAuth;
     }
-    if (state.inbucketHost) {
-      inputInbucketHost.value = state.inbucketHost;
+    if (state.tempApiFingerprint) {
+      inputTempApiFingerprint.value = state.tempApiFingerprint;
     }
-    if (state.inbucketMailbox) {
-      inputInbucketMailbox.value = state.inbucketMailbox;
+    if (state.tempApiUserToken) {
+      inputTempApiUserToken.value = state.tempApiUserToken;
     }
-
-    if (state.stepStatuses) {
-      for (const [step, status] of Object.entries(state.stepStatuses)) {
-        updateStepUI(Number(step), status);
-      }
-    }
-
-    if (state.logs) {
-      for (const entry of state.logs) {
-        appendLog(entry);
-      }
-    }
-
-    updateStatusDisplay(state);
-    updateProgressCounter();
-    updateMailProviderUI();
-
-    // Sync temp API URL from UI to state (handles browser autocomplete / form restore)
-    const currentTempApiUrl = inputTempApiUrl.value.trim();
-    if (currentTempApiUrl && currentTempApiUrl !== (state.tempApiUrl || '')) {
-      await chrome.runtime.sendMessage({
-        type: 'SAVE_SETTING',
-        source: 'sidepanel',
-        payload: { tempApiUrl: currentTempApiUrl },
-      });
+    if (state.inbucketHost) {
+      inputInbucketHost.value = state.inbucketHost;
     }
-  } catch (err) {
-    console.error('Failed to restore state:', err);
-  }
-}
-
+    if (state.inbucketMailbox) {
+      inputInbucketMailbox.value = state.inbucketMailbox;
+    }
+
+    if (state.stepStatuses) {
+      for (const [step, status] of Object.entries(state.stepStatuses)) {
+        updateStepUI(Number(step), status);
+      }
+    }
+
+    if (state.logs) {
+      for (const entry of state.logs) {
+        appendLog(entry);
+      }
+    }
+
+    updateStatusDisplay(state);
+    updateProgressCounter();
+    updateMailProviderUI();
+
+    // Sync temp API URL from UI to state (handles browser autocomplete / form restore)
+    const currentTempApiUrl = inputTempApiUrl.value.trim();
+    if (currentTempApiUrl && currentTempApiUrl !== (state.tempApiUrl || '')) {
+      await chrome.runtime.sendMessage({
+        type: 'SAVE_SETTING',
+        source: 'sidepanel',
+        payload: { tempApiUrl: currentTempApiUrl },
+      });
+    }
+  } catch (err) {
+    console.error('Failed to restore state:', err);
+  }
+}
+
 function syncPasswordField(state) {
   inputPassword.value = state.customPassword || state.password || '';
 }
 
 function updateMailProviderUI() {
   const val = selectMailProvider.value;
-  rowTempApiUrl.style.display = val === 'temp-api' ? '' : 'none';
+  const isTemp = val === 'temp-api';
+  const isGmail = val === 'gmail';
+  rowGmailBaseEmail.style.display = isGmail ? '' : 'none';
+  rowGmailClientId.style.display = isGmail ? '' : 'none';
+  rowGmailClientSecret.style.display = isGmail ? '' : 'none';
+  rowGmailRefreshToken.style.display = isGmail ? '' : 'none';
+  rowGmailApiToken.style.display = isGmail ? '' : 'none';
+  rowTempApiUrl.style.display = isTemp ? '' : 'none';
+  rowTempApiMails.style.display = isTemp ? '' : 'none';
+  const adminPath = selectTempApiMailsPath.value === 'admin';
+  rowTempApiAdminAuth.style.display = isTemp && adminPath ? '' : 'none';
+  rowTempApiCustomAuth.style.display = isTemp && adminPath ? '' : 'none';
+  rowTempApiFingerprint.style.display = isTemp && adminPath ? '' : 'none';
+  rowTempApiUserToken.style.display = isTemp && adminPath ? '' : 'none';
   rowInbucketHost.style.display = val === 'inbucket' ? '' : 'none';
   rowInbucketMailbox.style.display = val === 'inbucket' ? '' : 'none';
 }
-
-// ============================================================
-// UI Updates
-// ============================================================
-
-function updateStepUI(step, status) {
-  const statusEl = document.querySelector(`.step-status[data-step="${step}"]`);
-  const row = document.querySelector(`.step-row[data-step="${step}"]`);
-
-  if (statusEl) statusEl.textContent = STATUS_ICONS[status] || '';
-  if (row) {
-    row.className = `step-row ${status}`;
-  }
-
-  updateButtonStates();
-  updateProgressCounter();
-}
-
-function updateProgressCounter() {
-  let completed = 0;
-  document.querySelectorAll('.step-row').forEach(row => {
-    if (row.classList.contains('completed')) completed++;
-  });
-  stepsProgress.textContent = `${completed} / 9`;
-}
-
-function updateButtonStates() {
-  const statuses = {};
-  document.querySelectorAll('.step-row').forEach(row => {
-    const step = Number(row.dataset.step);
-    if (row.classList.contains('completed')) statuses[step] = 'completed';
-    else if (row.classList.contains('running')) statuses[step] = 'running';
-    else if (row.classList.contains('failed')) statuses[step] = 'failed';
-    else if (row.classList.contains('stopped')) statuses[step] = 'stopped';
-    else statuses[step] = 'pending';
-  });
-
-  const anyRunning = Object.values(statuses).some(s => s === 'running');
-
-  for (let step = 1; step <= 9; step++) {
-    const btn = document.querySelector(`.step-btn[data-step="${step}"]`);
-    if (!btn) continue;
-
-    if (anyRunning) {
-      btn.disabled = true;
-    } else if (step === 1) {
-      btn.disabled = false;
-    } else {
-      const prevStatus = statuses[step - 1];
-      const currentStatus = statuses[step];
-      btn.disabled = !(prevStatus === 'completed' || currentStatus === 'failed' || currentStatus === 'completed' || currentStatus === 'stopped');
-    }
-  }
-
-  updateStopButtonState(anyRunning || autoContinueBar.style.display !== 'none');
-}
-
-function updateStopButtonState(active) {
-  btnStop.disabled = !active;
-}
-
-function updateStatusDisplay(state) {
-  if (!state || !state.stepStatuses) return;
-
-  statusBar.className = 'status-bar';
-
-  const running = Object.entries(state.stepStatuses).find(([, s]) => s === 'running');
-  if (running) {
-    displayStatus.textContent = `Step ${running[0]} running...`;
-    statusBar.classList.add('running');
-    return;
-  }
-
-  const failed = Object.entries(state.stepStatuses).find(([, s]) => s === 'failed');
-  if (failed) {
-    displayStatus.textContent = `Step ${failed[0]} failed`;
-    statusBar.classList.add('failed');
-    return;
-  }
-
-  const stopped = Object.entries(state.stepStatuses).find(([, s]) => s === 'stopped');
-  if (stopped) {
-    displayStatus.textContent = `Step ${stopped[0]} stopped`;
-    statusBar.classList.add('stopped');
-    return;
-  }
-
-  const lastCompleted = Object.entries(state.stepStatuses)
-    .filter(([, s]) => s === 'completed')
-    .map(([k]) => Number(k))
-    .sort((a, b) => b - a)[0];
-
-  if (lastCompleted === 9) {
-    displayStatus.textContent = 'All steps completed!';
-    statusBar.classList.add('completed');
-  } else if (lastCompleted) {
-    displayStatus.textContent = `Step ${lastCompleted} done`;
-  } else {
-    displayStatus.textContent = 'Ready';
-  }
-}
-
-function appendLog(entry) {
-  const time = new Date(entry.timestamp).toLocaleTimeString('en-US', { hour12: false });
-  const levelLabel = entry.level.toUpperCase();
-  const line = document.createElement('div');
-  line.className = `log-line log-${entry.level}`;
-
-  const stepMatch = entry.message.match(/Step (\d)/);
-  const stepNum = stepMatch ? stepMatch[1] : null;
-
-  let html = `<span class="log-time">${time}</span> `;
-  html += `<span class="log-level log-level-${entry.level}">${levelLabel}</span> `;
-  if (stepNum) {
-    html += `<span class="log-step-tag step-${stepNum}">S${stepNum}</span>`;
-  }
-  html += `<span class="log-msg">${escapeHtml(entry.message)}</span>`;
-
-  line.innerHTML = html;
-  logArea.appendChild(line);
-  logArea.scrollTop = logArea.scrollHeight;
-}
-
-function escapeHtml(text) {
-  const div = document.createElement('div');
-  div.textContent = text;
-  return div.innerHTML;
-}
-
-async function fetchDuckEmail() {
-  const defaultLabel = 'Auto';
-  btnFetchEmail.disabled = true;
-  btnFetchEmail.textContent = '...';
-
-  try {
-    const response = await chrome.runtime.sendMessage({
-      type: 'FETCH_DUCK_EMAIL',
-      source: 'sidepanel',
-      payload: { generateNew: true },
-    });
-
-    if (response?.error) {
-      throw new Error(response.error);
-    }
+
+// ============================================================
+// UI Updates
+// ============================================================
+
+function updateStepUI(step, status) {
+  const statusEl = document.querySelector(`.step-status[data-step="${step}"]`);
+  const row = document.querySelector(`.step-row[data-step="${step}"]`);
+
+  if (statusEl) statusEl.textContent = STATUS_ICONS[status] || '';
+  if (row) {
+    row.className = `step-row ${status}`;
+  }
+
+  updateButtonStates();
+  updateProgressCounter();
+}
+
+function updateProgressCounter() {
+  let completed = 0;
+  document.querySelectorAll('.step-row').forEach(row => {
+    if (row.classList.contains('completed')) completed++;
+  });
+  stepsProgress.textContent = `${completed} / 9`;
+}
+
+function updateButtonStates() {
+  const statuses = {};
+  document.querySelectorAll('.step-row').forEach(row => {
+    const step = Number(row.dataset.step);
+    if (row.classList.contains('completed')) statuses[step] = 'completed';
+    else if (row.classList.contains('running')) statuses[step] = 'running';
+    else if (row.classList.contains('failed')) statuses[step] = 'failed';
+    else if (row.classList.contains('stopped')) statuses[step] = 'stopped';
+    else statuses[step] = 'pending';
+  });
+
+  const anyRunning = Object.values(statuses).some(s => s === 'running');
+
+  for (let step = 1; step <= 9; step++) {
+    const btn = document.querySelector(`.step-btn[data-step="${step}"]`);
+    if (!btn) continue;
+
+    if (anyRunning) {
+      btn.disabled = true;
+    } else if (step === 1) {
+      btn.disabled = false;
+    } else {
+      const prevStatus = statuses[step - 1];
+      const currentStatus = statuses[step];
+      btn.disabled = !(prevStatus === 'completed' || currentStatus === 'failed' || currentStatus === 'completed' || currentStatus === 'stopped');
+    }
+  }
+
+  updateStopButtonState(anyRunning || autoContinueBar.style.display !== 'none');
+}
+
+function updateStopButtonState(active) {
+  btnStop.disabled = !active;
+}
+
+function updateStatusDisplay(state) {
+  if (!state || !state.stepStatuses) return;
+
+  statusBar.className = 'status-bar';
+
+  const running = Object.entries(state.stepStatuses).find(([, s]) => s === 'running');
+  if (running) {
+    displayStatus.textContent = `Step ${running[0]} running...`;
+    statusBar.classList.add('running');
+    return;
+  }
+
+  const failed = Object.entries(state.stepStatuses).find(([, s]) => s === 'failed');
+  if (failed) {
+    displayStatus.textContent = `Step ${failed[0]} failed`;
+    statusBar.classList.add('failed');
+    return;
+  }
+
+  const stopped = Object.entries(state.stepStatuses).find(([, s]) => s === 'stopped');
+  if (stopped) {
+    displayStatus.textContent = `Step ${stopped[0]} stopped`;
+    statusBar.classList.add('stopped');
+    return;
+  }
+
+  const lastCompleted = Object.entries(state.stepStatuses)
+    .filter(([, s]) => s === 'completed')
+    .map(([k]) => Number(k))
+    .sort((a, b) => b - a)[0];
+
+  if (lastCompleted === 9) {
+    displayStatus.textContent = 'All steps completed!';
+    statusBar.classList.add('completed');
+  } else if (lastCompleted) {
+    displayStatus.textContent = `Step ${lastCompleted} done`;
+  } else {
+    displayStatus.textContent = 'Ready';
+  }
+}
+
+function appendLog(entry) {
+  const time = new Date(entry.timestamp).toLocaleTimeString('en-US', { hour12: false });
+  const levelLabel = entry.level.toUpperCase();
+  const line = document.createElement('div');
+  line.className = `log-line log-${entry.level}`;
+
+  const stepMatch = entry.message.match(/Step (\d)/);
+  const stepNum = stepMatch ? stepMatch[1] : null;
+
+  let html = `<span class="log-time">${time}</span> `;
+  html += `<span class="log-level log-level-${entry.level}">${levelLabel}</span> `;
+  if (stepNum) {
+    html += `<span class="log-step-tag step-${stepNum}">S${stepNum}</span>`;
+  }
+  html += `<span class="log-msg">${escapeHtml(entry.message)}</span>`;
+
+  line.innerHTML = html;
+  logArea.appendChild(line);
+  logArea.scrollTop = logArea.scrollHeight;
+}
+
+function escapeHtml(text) {
+  const div = document.createElement('div');
+  div.textContent = text;
+  return div.innerHTML;
+}
+
+async function generateYibasiEmail() {
+  const defaultLabel = '随机';
+  btnFetchEmail.disabled = true;
+  btnFetchEmail.textContent = '...';
+
+  try {
+    const response = await chrome.runtime.sendMessage({
+      type: 'GENERATE_YIBASI_EMAIL',
+      source: 'sidepanel',
+      payload: {},
+    });
+
+    if (response?.error) {
+      throw new Error(response.error);
+    }
     if (!response?.email) {
-      throw new Error('Duck email was not returned.');
+      throw new Error('未生成邮箱。');
     }
 
     inputEmail.value = response.email;
-    showToast(`Fetched ${response.email}`, 'success', 2500);
+    showToast(`已生成 ${response.email}`, 'success', 2500);
     return response.email;
-  } catch (err) {
-    showToast(`Auto fetch failed: ${err.message}`, 'error');
-    throw err;
-  } finally {
-    btnFetchEmail.disabled = false;
-    btnFetchEmail.textContent = defaultLabel;
-  }
-}
-
-function syncPasswordToggleLabel() {
-  btnTogglePassword.textContent = inputPassword.type === 'password' ? 'Show' : 'Hide';
-}
-
-// ============================================================
-// Button Handlers
-// ============================================================
-
-document.querySelectorAll('.step-btn').forEach(btn => {
-  btn.addEventListener('click', async () => {
-    const step = Number(btn.dataset.step);
-    if (step === 3) {
+  } catch (err) {
+    showToast(`生成失败: ${err.message}`, 'error');
+    throw err;
+  } finally {
+    btnFetchEmail.disabled = false;
+    btnFetchEmail.textContent = defaultLabel;
+  }
+}
+
+function syncPasswordToggleLabel() {
+  btnTogglePassword.textContent = inputPassword.type === 'password' ? 'Show' : 'Hide';
+}
+
+// ============================================================
+// Button Handlers
+// ============================================================
+
+document.querySelectorAll('.step-btn').forEach(btn => {
+  btn.addEventListener('click', async () => {
+    const step = Number(btn.dataset.step);
+    if (step === 3) {
       const email = inputEmail.value.trim();
       if (!email) {
-        showToast('Please paste email address or use Auto first', 'warn');
+        showToast('请先点击「随机」生成当前来源邮箱或手动粘贴', 'warn');
         return;
       }
-      await chrome.runtime.sendMessage({ type: 'EXECUTE_STEP', source: 'sidepanel', payload: { step, email } });
-    } else {
-      await chrome.runtime.sendMessage({ type: 'EXECUTE_STEP', source: 'sidepanel', payload: { step } });
-    }
-  });
-});
-
-btnFetchEmail.addEventListener('click', async () => {
-  await fetchDuckEmail().catch(() => {});
-});
-
-btnTogglePassword.addEventListener('click', () => {
-  inputPassword.type = inputPassword.type === 'password' ? 'text' : 'password';
-  syncPasswordToggleLabel();
-});
-
-btnStop.addEventListener('click', async () => {
-  btnStop.disabled = true;
-  await chrome.runtime.sendMessage({ type: 'STOP_FLOW', source: 'sidepanel', payload: {} });
-  showToast('Stopping current flow...', 'warn', 2000);
-});
-
-// Auto Run
-btnAutoRun.addEventListener('click', async () => {
-  const totalRuns = parseInt(inputRunCount.value) || 1;
-  btnAutoRun.disabled = true;
-  inputRunCount.disabled = true;
-  btnAutoRun.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg> Running...';
-  await chrome.runtime.sendMessage({ type: 'AUTO_RUN', source: 'sidepanel', payload: { totalRuns } });
-});
-
-btnAutoContinue.addEventListener('click', async () => {
+      await chrome.runtime.sendMessage({ type: 'EXECUTE_STEP', source: 'sidepanel', payload: { step, email } });
+    } else {
+      await chrome.runtime.sendMessage({ type: 'EXECUTE_STEP', source: 'sidepanel', payload: { step } });
+    }
+  });
+});
+
+btnFetchEmail.addEventListener('click', async () => {
+  await generateYibasiEmail().catch(() => {});
+});
+
+btnTogglePassword.addEventListener('click', () => {
+  inputPassword.type = inputPassword.type === 'password' ? 'text' : 'password';
+  syncPasswordToggleLabel();
+});
+
+btnStop.addEventListener('click', async () => {
+  btnStop.disabled = true;
+  await chrome.runtime.sendMessage({ type: 'STOP_FLOW', source: 'sidepanel', payload: {} });
+  showToast('Stopping current flow...', 'warn', 2000);
+});
+
+// Auto Run
+btnAutoRun.addEventListener('click', async () => {
+  const totalRuns = parseInt(inputRunCount.value) || 1;
+  btnAutoRun.disabled = true;
+  inputRunCount.disabled = true;
+  btnAutoRun.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg> Running...';
+  await chrome.runtime.sendMessage({ type: 'AUTO_RUN', source: 'sidepanel', payload: { totalRuns } });
+});
+
+btnAutoContinue.addEventListener('click', async () => {
   const email = inputEmail.value.trim();
   if (!email) {
-    showToast('Please fetch or paste DuckDuckGo email first!', 'warn');
+    showToast('请先生成或粘贴邮箱', 'warn');
     return;
   }
-  autoContinueBar.style.display = 'none';
-  await chrome.runtime.sendMessage({ type: 'RESUME_AUTO_RUN', source: 'sidepanel', payload: { email } });
-});
-
-// Reset
-btnReset.addEventListener('click', async () => {
-  if (confirm('Reset all steps and data?')) {
-    await chrome.runtime.sendMessage({ type: 'RESET', source: 'sidepanel' });
-    displayOauthUrl.textContent = 'Waiting...';
-    displayOauthUrl.classList.remove('has-value');
-    displayLocalhostUrl.textContent = 'Waiting...';
-    displayLocalhostUrl.classList.remove('has-value');
-    inputEmail.value = '';
-    displayStatus.textContent = 'Ready';
-    statusBar.className = 'status-bar';
-    logArea.innerHTML = '';
-    document.querySelectorAll('.step-row').forEach(row => row.className = 'step-row');
-    document.querySelectorAll('.step-status').forEach(el => el.textContent = '');
-    btnAutoRun.disabled = false;
-    inputRunCount.disabled = false;
-    btnAutoRun.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg> Auto';
-    autoContinueBar.style.display = 'none';
-    updateStopButtonState(false);
-    updateButtonStates();
-    updateProgressCounter();
-  }
-});
-
-// Clear log
-btnClearLog.addEventListener('click', () => {
-  logArea.innerHTML = '';
-});
-
-// Save settings on change
-inputEmail.addEventListener('change', async () => {
-  const email = inputEmail.value.trim();
-  if (email) {
-    await chrome.runtime.sendMessage({ type: 'SAVE_EMAIL', source: 'sidepanel', payload: { email } });
-  }
-});
-
-inputVpsUrl.addEventListener('change', async () => {
-  const vpsUrl = inputVpsUrl.value.trim();
-  if (vpsUrl) {
-    await chrome.runtime.sendMessage({ type: 'SAVE_SETTING', source: 'sidepanel', payload: { vpsUrl } });
-  }
-});
-
-inputPassword.addEventListener('change', async () => {
-  await chrome.runtime.sendMessage({
-    type: 'SAVE_SETTING',
-    source: 'sidepanel',
-    payload: { customPassword: inputPassword.value },
-  });
-});
-
+  autoContinueBar.style.display = 'none';
+  await chrome.runtime.sendMessage({ type: 'RESUME_AUTO_RUN', source: 'sidepanel', payload: { email } });
+});
+
+// Reset
+btnReset.addEventListener('click', async () => {
+  if (confirm('Reset all steps and data?')) {
+    await chrome.runtime.sendMessage({ type: 'RESET', source: 'sidepanel' });
+    displayOauthUrl.textContent = 'Waiting...';
+    displayOauthUrl.classList.remove('has-value');
+    displayLocalhostUrl.textContent = 'Waiting...';
+    displayLocalhostUrl.classList.remove('has-value');
+    inputEmail.value = '';
+    displayStatus.textContent = 'Ready';
+    statusBar.className = 'status-bar';
+    logArea.innerHTML = '';
+    document.querySelectorAll('.step-row').forEach(row => row.className = 'step-row');
+    document.querySelectorAll('.step-status').forEach(el => el.textContent = '');
+    btnAutoRun.disabled = false;
+    inputRunCount.disabled = false;
+    btnAutoRun.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg> Auto';
+    autoContinueBar.style.display = 'none';
+    updateStopButtonState(false);
+    updateButtonStates();
+    updateProgressCounter();
+  }
+});
+
+// Clear log
+btnClearLog.addEventListener('click', () => {
+  logArea.innerHTML = '';
+});
+
+// Save settings on change
+inputEmail.addEventListener('change', async () => {
+  const email = inputEmail.value.trim();
+  if (email) {
+    await chrome.runtime.sendMessage({ type: 'SAVE_EMAIL', source: 'sidepanel', payload: { email } });
+  }
+});
+
+inputVpsUrl.addEventListener('change', async () => {
+  const vpsUrl = inputVpsUrl.value.trim();
+  if (vpsUrl) {
+    await chrome.runtime.sendMessage({ type: 'SAVE_SETTING', source: 'sidepanel', payload: { vpsUrl } });
+  }
+});
+
+inputPassword.addEventListener('change', async () => {
+  await chrome.runtime.sendMessage({
+    type: 'SAVE_SETTING',
+    source: 'sidepanel',
+    payload: { customPassword: inputPassword.value },
+  });
+});
+
 selectMailProvider.addEventListener('change', async () => {
   updateMailProviderUI();
   await chrome.runtime.sendMessage({
@@ -424,172 +483,241 @@ selectMailProvider.addEventListener('change', async () => {
   });
 });
 
-inputInbucketMailbox.addEventListener('change', async () => {
+inputGmailBaseEmail.addEventListener('change', async () => {
   await chrome.runtime.sendMessage({
     type: 'SAVE_SETTING',
     source: 'sidepanel',
-    payload: { inbucketMailbox: inputInbucketMailbox.value.trim() },
+    payload: { gmailBaseEmail: inputGmailBaseEmail.value.trim() },
   });
 });
 
-// Save on both 'change' (blur) and 'input' (immediate, e.g. paste)
 for (const evt of ['change', 'input']) {
-  inputTempApiUrl.addEventListener(evt, async () => {
+  inputGmailClientId.addEventListener(evt, async () => {
     await chrome.runtime.sendMessage({
       type: 'SAVE_SETTING',
       source: 'sidepanel',
-      payload: { tempApiUrl: inputTempApiUrl.value.trim() },
+      payload: { gmailApiClientId: inputGmailClientId.value.trim() },
     });
   });
-  inputDuckToken.addEventListener(evt, async () => {
+  inputGmailClientSecret.addEventListener(evt, async () => {
     await chrome.runtime.sendMessage({
       type: 'SAVE_SETTING',
       source: 'sidepanel',
-      payload: { duckToken: inputDuckToken.value.trim() },
+      payload: { gmailApiClientSecret: inputGmailClientSecret.value.trim() },
     });
   });
-}
-
-inputInbucketHost.addEventListener('change', async () => {
-  await chrome.runtime.sendMessage({
-    type: 'SAVE_SETTING',
-    source: 'sidepanel',
-    payload: { inbucketHost: inputInbucketHost.value.trim() },
+  inputGmailRefreshToken.addEventListener(evt, async () => {
+    await chrome.runtime.sendMessage({
+      type: 'SAVE_SETTING',
+      source: 'sidepanel',
+      payload: { gmailApiRefreshToken: inputGmailRefreshToken.value.trim() },
+    });
+  });
+  inputGmailApiToken.addEventListener(evt, async () => {
+    await chrome.runtime.sendMessage({
+      type: 'SAVE_SETTING',
+      source: 'sidepanel',
+      payload: { gmailApiAccessToken: inputGmailApiToken.value.trim() },
+    });
   });
-});
-
-// ============================================================
-// Listen for Background broadcasts
-// ============================================================
-
-chrome.runtime.onMessage.addListener((message) => {
-  switch (message.type) {
-    case 'LOG_ENTRY':
-      appendLog(message.payload);
-      if (message.payload.level === 'error') {
-        showToast(message.payload.message, 'error');
-      }
-      break;
-
-    case 'STEP_STATUS_CHANGED': {
-      const { step, status } = message.payload;
-      updateStepUI(step, status);
-      chrome.runtime.sendMessage({ type: 'GET_STATE', source: 'sidepanel' }).then(updateStatusDisplay);
-      if (status === 'completed') {
-        chrome.runtime.sendMessage({ type: 'GET_STATE', source: 'sidepanel' }).then(state => {
-          syncPasswordField(state);
-          if (state.oauthUrl) {
-            displayOauthUrl.textContent = state.oauthUrl;
-            displayOauthUrl.classList.add('has-value');
-          }
-          if (state.localhostUrl) {
-            displayLocalhostUrl.textContent = state.localhostUrl;
-            displayLocalhostUrl.classList.add('has-value');
-          }
-        });
-      }
-      break;
-    }
-
-    case 'AUTO_RUN_RESET': {
-      // Full UI reset for next run
-      displayOauthUrl.textContent = 'Waiting...';
-      displayOauthUrl.classList.remove('has-value');
-      displayLocalhostUrl.textContent = 'Waiting...';
-      displayLocalhostUrl.classList.remove('has-value');
-      inputEmail.value = '';
-      displayStatus.textContent = 'Ready';
-      statusBar.className = 'status-bar';
-      logArea.innerHTML = '';
-      document.querySelectorAll('.step-row').forEach(row => row.className = 'step-row');
-      document.querySelectorAll('.step-status').forEach(el => el.textContent = '');
-      updateStopButtonState(false);
-      updateProgressCounter();
-      break;
-    }
-
-    case 'DATA_UPDATED': {
-      if (message.payload.email) {
-        inputEmail.value = message.payload.email;
-      }
-      if (message.payload.password !== undefined) {
-        inputPassword.value = message.payload.password || '';
-      }
-      if (message.payload.oauthUrl) {
-        displayOauthUrl.textContent = message.payload.oauthUrl;
-        displayOauthUrl.classList.add('has-value');
-      }
-      if (message.payload.localhostUrl) {
-        displayLocalhostUrl.textContent = message.payload.localhostUrl;
-        displayLocalhostUrl.classList.add('has-value');
-      }
-      break;
-    }
-
-    case 'AUTO_RUN_STATUS': {
-      const { phase, currentRun, totalRuns, successCount } = message.payload;
-      const successLabel = successCount > 0 ? ` ok:${successCount}` : '';
-      switch (phase) {
-        case 'waiting_email':
-          autoContinueBar.style.display = 'flex';
-          btnAutoRun.innerHTML = `Paused${successLabel}`;
-          updateStopButtonState(true);
-          break;
-        case 'running':
-          btnAutoRun.innerHTML = `Running${successLabel}`;
-          updateStopButtonState(true);
-          break;
-        case 'complete':
-          btnAutoRun.disabled = false;
-          inputRunCount.disabled = false;
-          btnAutoRun.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg> Auto';
-          autoContinueBar.style.display = 'none';
-          updateStopButtonState(false);
-          break;
-        case 'stopped':
-          btnAutoRun.disabled = false;
-          inputRunCount.disabled = false;
-          btnAutoRun.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg> Auto';
-          autoContinueBar.style.display = 'none';
-          updateStopButtonState(false);
-          break;
-      }
-      break;
-    }
-  }
-});
-
-// ============================================================
-// Theme Toggle
-// ============================================================
-
-const btnTheme = document.getElementById('btn-theme');
-
-function setTheme(theme) {
-  document.documentElement.setAttribute('data-theme', theme);
-  localStorage.setItem('multipage-theme', theme);
 }
 
-function initTheme() {
-  const saved = localStorage.getItem('multipage-theme');
-  if (saved) {
-    setTheme(saved);
-  } else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
-    setTheme('dark');
-  }
+inputInbucketMailbox.addEventListener('change', async () => {
+  await chrome.runtime.sendMessage({
+    type: 'SAVE_SETTING',
+    source: 'sidepanel',
+    payload: { inbucketMailbox: inputInbucketMailbox.value.trim() },
+  });
+});
+
+// Save on both 'change' (blur) and 'input' (immediate, e.g. paste)
+for (const evt of ['change', 'input']) {
+  inputTempApiUrl.addEventListener(evt, async () => {
+    await chrome.runtime.sendMessage({
+      type: 'SAVE_SETTING',
+      source: 'sidepanel',
+      payload: { tempApiUrl: inputTempApiUrl.value.trim() },
+    });
+  });
+  inputTempApiAdminAuth.addEventListener(evt, async () => {
+    await chrome.runtime.sendMessage({
+      type: 'SAVE_SETTING',
+      source: 'sidepanel',
+      payload: { tempApiAdminAuth: inputTempApiAdminAuth.value.trim() },
+    });
+  });
+  inputTempApiCustomAuth.addEventListener(evt, async () => {
+    await chrome.runtime.sendMessage({
+      type: 'SAVE_SETTING',
+      source: 'sidepanel',
+      payload: { tempApiCustomAuth: inputTempApiCustomAuth.value.trim() },
+    });
+  });
+  inputTempApiFingerprint.addEventListener(evt, async () => {
+    await chrome.runtime.sendMessage({
+      type: 'SAVE_SETTING',
+      source: 'sidepanel',
+      payload: { tempApiFingerprint: inputTempApiFingerprint.value.trim() },
+    });
+  });
+  inputTempApiUserToken.addEventListener(evt, async () => {
+    await chrome.runtime.sendMessage({
+      type: 'SAVE_SETTING',
+      source: 'sidepanel',
+      payload: { tempApiUserToken: inputTempApiUserToken.value.trim() },
+    });
+  });
 }
-
-btnTheme.addEventListener('click', () => {
-  const current = document.documentElement.getAttribute('data-theme');
-  setTheme(current === 'dark' ? 'light' : 'dark');
-});
-
-// ============================================================
-// Init
-// ============================================================
-
-initTheme();
-restoreState().then(() => {
-  syncPasswordToggleLabel();
-  updateButtonStates();
-});
+
+selectTempApiMailsPath.addEventListener('change', async () => {
+  updateMailProviderUI();
+  await chrome.runtime.sendMessage({
+    type: 'SAVE_SETTING',
+    source: 'sidepanel',
+    payload: { tempApiMailsPath: selectTempApiMailsPath.value },
+  });
+});
+
+inputInbucketHost.addEventListener('change', async () => {
+  await chrome.runtime.sendMessage({
+    type: 'SAVE_SETTING',
+    source: 'sidepanel',
+    payload: { inbucketHost: inputInbucketHost.value.trim() },
+  });
+});
+
+// ============================================================
+// Listen for Background broadcasts
+// ============================================================
+
+chrome.runtime.onMessage.addListener((message) => {
+  switch (message.type) {
+    case 'LOG_ENTRY':
+      appendLog(message.payload);
+      if (message.payload.level === 'error') {
+        showToast(message.payload.message, 'error');
+      }
+      break;
+
+    case 'STEP_STATUS_CHANGED': {
+      const { step, status } = message.payload;
+      updateStepUI(step, status);
+      chrome.runtime.sendMessage({ type: 'GET_STATE', source: 'sidepanel' }).then(updateStatusDisplay);
+      if (status === 'completed') {
+        chrome.runtime.sendMessage({ type: 'GET_STATE', source: 'sidepanel' }).then(state => {
+          syncPasswordField(state);
+          if (state.oauthUrl) {
+            displayOauthUrl.textContent = state.oauthUrl;
+            displayOauthUrl.classList.add('has-value');
+          }
+          if (state.localhostUrl) {
+            displayLocalhostUrl.textContent = state.localhostUrl;
+            displayLocalhostUrl.classList.add('has-value');
+          }
+        });
+      }
+      break;
+    }
+
+    case 'AUTO_RUN_RESET': {
+      // Full UI reset for next run
+      displayOauthUrl.textContent = 'Waiting...';
+      displayOauthUrl.classList.remove('has-value');
+      displayLocalhostUrl.textContent = 'Waiting...';
+      displayLocalhostUrl.classList.remove('has-value');
+      inputEmail.value = '';
+      displayStatus.textContent = 'Ready';
+      statusBar.className = 'status-bar';
+      logArea.innerHTML = '';
+      document.querySelectorAll('.step-row').forEach(row => row.className = 'step-row');
+      document.querySelectorAll('.step-status').forEach(el => el.textContent = '');
+      updateStopButtonState(false);
+      updateProgressCounter();
+      break;
+    }
+
+    case 'DATA_UPDATED': {
+      if (message.payload.email) {
+        inputEmail.value = message.payload.email;
+      }
+      if (message.payload.password !== undefined) {
+        inputPassword.value = message.payload.password || '';
+      }
+      if (message.payload.oauthUrl) {
+        displayOauthUrl.textContent = message.payload.oauthUrl;
+        displayOauthUrl.classList.add('has-value');
+      }
+      if (message.payload.localhostUrl) {
+        displayLocalhostUrl.textContent = message.payload.localhostUrl;
+        displayLocalhostUrl.classList.add('has-value');
+      }
+      break;
+    }
+
+    case 'AUTO_RUN_STATUS': {
+      const { phase, currentRun, totalRuns, successCount } = message.payload;
+      const successLabel = successCount > 0 ? ` ok:${successCount}` : '';
+      switch (phase) {
+        case 'waiting_email':
+          autoContinueBar.style.display = 'flex';
+          btnAutoRun.innerHTML = `Paused${successLabel}`;
+          updateStopButtonState(true);
+          break;
+        case 'running':
+          btnAutoRun.innerHTML = `Running${successLabel}`;
+          updateStopButtonState(true);
+          break;
+        case 'complete':
+          btnAutoRun.disabled = false;
+          inputRunCount.disabled = false;
+          btnAutoRun.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg> Auto';
+          autoContinueBar.style.display = 'none';
+          updateStopButtonState(false);
+          break;
+        case 'stopped':
+          btnAutoRun.disabled = false;
+          inputRunCount.disabled = false;
+          btnAutoRun.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg> Auto';
+          autoContinueBar.style.display = 'none';
+          updateStopButtonState(false);
+          break;
+      }
+      break;
+    }
+  }
+});
+
+// ============================================================
+// Theme Toggle
+// ============================================================
+
+const btnTheme = document.getElementById('btn-theme');
+
+function setTheme(theme) {
+  document.documentElement.setAttribute('data-theme', theme);
+  localStorage.setItem('multipage-theme', theme);
+}
+
+function initTheme() {
+  const saved = localStorage.getItem('multipage-theme');
+  if (saved) {
+    setTheme(saved);
+  } else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
+    setTheme('dark');
+  }
+}
+
+btnTheme.addEventListener('click', () => {
+  const current = document.documentElement.getAttribute('data-theme');
+  setTheme(current === 'dark' ? 'light' : 'dark');
+});
+
+// ============================================================
+// Init
+// ============================================================
+
+initTheme();
+restoreState().then(() => {
+  syncPasswordToggleLabel();
+  updateButtonStates();
+});

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