Explorar el Código

merge: integrate hotmail邮箱支持 into dev

QLHazyCoder hace 4 meses
padre
commit
25cf718c3e

+ 34 - 6
README.md

@@ -2,7 +2,7 @@
 
 一个用于批量跑通 ChatGPT OAuth 注册/登录流程的 Chrome 扩展。
 
-当前版本基于侧边栏控制,支持单步执行、整套自动执行、停止当前流程、保存常用配置,以及通过 DuckDuckGo / QQ / 163 / Inbucket mailbox 协助获取验证码。
+当前版本基于侧边栏控制,支持单步执行、整套自动执行、停止当前流程、保存常用配置,以及通过 DuckDuckGo / QQ / 163 / Inbucket / Hotmail 协助获取验证码。
 
 ## 最新版本测试结果
 
@@ -42,6 +42,7 @@
 - 支持自定义密码;留空时自动生成强密码
 - 自动显示当前使用中的密码,便于后续保存
 - 自动获取注册验证码与登录验证码
+- 支持 `Hotmail`:直接使用 `邮箱 + 客户端 ID + 刷新令牌(refresh token)` 刷新微软令牌,并通过 Microsoft Graph 读取最新邮件
 - 支持 `QQ Mail`、`163 Mail`、`Inbucket mailbox`
 - 支持从 DuckDuckGo Email Protection 自动生成新的 `@duck.com` 地址
 - 支持基于 Cloudflare 自定义域名自动生成随机邮箱前缀
@@ -86,17 +87,37 @@ Step 1 和 Step 9 都依赖这个地址。
 
 ### `Mail`
 
-支持种验证码来源:
+支持种验证码来源:
 
+- `Hotmail`
 - `163 Mail`
 - `QQ Mail`
 - `Inbucket`
 
 说明:
 
+- `Hotmail` 通过侧边栏里的 Hotmail 账号池选择账号,并直接访问 Microsoft Graph 邮件接口
 - `QQ` 和 `163` 用于直接轮询网页邮箱
 - `Inbucket` 通过你在侧边栏里配置的 host 访问 `mailbox` 页面:`https://<your-inbucket-host>/m/<mailbox>/`
 
+### `Hotmail 账号池`
+
+仅当 `Mail = Hotmail` 时使用。
+
+每条账号支持保存:
+
+- `email`
+- `clientId`
+- `refreshToken`
+- 可选邮箱密码备注
+
+使用方式:
+
+- 先新增账号
+- 点击 `校验`
+- 校验通过后,可点击 `测试收信`
+- Auto 模式每轮会自动选用一个可用账号
+
 ### `Mailbox`
 
 仅当 `Mail = Inbucket` 时显示。
@@ -144,6 +165,8 @@ Step 3 使用的注册邮箱。
 - 若 `邮箱生成 = Cloudflare`,插件里只需要维护 `CF 域名`
 - `CF 域名` 支持保存多个,并通过下拉框切换当前要生成的域名
 - Cloudflare 侧的转发规则、Catch-all、路由目标邮箱等,都需要你自己提前在 Cloudflare 后台配置好
+- 当 `Mail = Hotmail` 时,这个输入框由账号池自动同步当前账号邮箱
+- 当前 `Auto` 按钮只负责 DuckDuckGo 地址获取
 - 如果你使用 Inbucket,它只是验证码收件箱,不会自动生成 Inbucket 地址
 
 ### `邮箱生成 = Cloudflare` 时的配置
@@ -230,9 +253,11 @@ Cloudflare 模式下,插件不会再调用 Cloudflare API 创建路由。
 
 1. Step 1 获取 CPA OAuth 链接
 2. Step 2 打开 OpenAI 注册页
-3. 按当前“邮箱生成”配置尝试自动获取邮箱(Duck 或 Cloudflare)
-4. 如果自动获取失败,暂停并等待你在侧边栏填写邮箱后点击 `Continue`
-5. 继续执行 Step 3 ~ Step 9
+3. 根据 `Mail` 选择邮箱来源
+4. 如果 `Mail = Hotmail`,会从账号池自动分配一个可用账号
+5. 如果不是 Hotmail,则按当前“邮箱生成”配置尝试自动获取邮箱(Duck 或 Cloudflare)
+6. 如果自动获取失败,暂停并等待你在侧边栏填写邮箱后点击 `Continue`
+7. 继续执行 Step 3 ~ Step 9
 
 也就是说:
 
@@ -277,10 +302,11 @@ Cloudflare 模式下,插件不会再调用 Cloudflare API 创建路由。
 
 根据 `Mail` 配置,轮询邮箱并提取 6 位验证码。
 
-进入邮箱轮询前,脚本会先确认认证页是否已经进入验证码页面;如果密码页出现 `糟糕,出错了 / Operation timed out` 并带有 `重试` 按钮,会先自动点击 `重试`、回到密码页重新提交,再继续等待验证码页面。
+进入邮箱轮询前,脚本会先确认认证页是否已经进入验证码页面;如果密码页出现 `糟糕,出错了 / 操作超时(Operation timed out` 并带有 `重试` 按钮,会先自动点击 `重试`、回到密码页重新提交,再继续等待验证码页面。
 
 支持:
 
+- `Hotmail`(Microsoft Graph 邮件接口)
 - `content/qq-mail.js`
 - `content/mail-163.js`
 - `content/inbucket-mail.js`
@@ -403,6 +429,7 @@ Cloudflare 模式下,插件不会再调用 Cloudflare API 创建路由。
 - 邮箱服务
 - Inbucket 主机
 - Inbucket 邮箱名
+- Hotmail 账号池与对应令牌
 - 兜底开关
 
 特点:
@@ -422,6 +449,7 @@ data/names.js              随机姓名、生日数据
 content/utils.js           通用工具:等待元素、点击、日志、停止控制
 content/vps-panel.js       CPA 面板步骤:Step 1 / Step 9
 content/signup-page.js     OpenAI 注册/登录页步骤:Step 2 / 3 / 5 / 6 / 8
+hotmail-utils.js           Hotmail 收信相关通用辅助
 content/duck-mail.js       Duck 邮箱自动获取
 content/qq-mail.js         QQ 邮箱验证码轮询
 content/mail-163.js        163 邮箱验证码轮询

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 790 - 16
background.js


+ 53 - 0
content/activation-utils.js

@@ -0,0 +1,53 @@
+(function activationUtilsModule(root, factory) {
+  if (typeof module !== 'undefined' && module.exports) {
+    module.exports = factory();
+    return;
+  }
+
+  root.MultiPageActivationUtils = factory();
+})(typeof self !== 'undefined' ? self : globalThis, function createActivationUtils() {
+  function normalizeTagName(tagName) {
+    return String(tagName || '').trim().toLowerCase();
+  }
+
+  function normalizeType(type) {
+    return String(type || '').trim().toLowerCase();
+  }
+
+  function normalizePathname(pathname) {
+    return String(pathname || '').trim().toLowerCase();
+  }
+
+  function getActivationStrategy(target = {}) {
+    const tagName = normalizeTagName(target.tagName);
+    const type = normalizeType(target.type);
+    const pathname = normalizePathname(target.pathname);
+    const hasForm = Boolean(target.hasForm);
+    const isEmailVerificationRoute = /\/email-verification(?:[/?#]|$)/i.test(pathname);
+    const isSubmitButton = hasForm
+      && (
+        (tagName === 'button' && (!type || type === 'submit'))
+        || (tagName === 'input' && type === 'submit')
+      );
+
+    if (isSubmitButton && isEmailVerificationRoute) {
+      return { method: 'requestSubmit' };
+    }
+
+    return { method: 'click' };
+  }
+
+  function isRecoverableStep9AuthFailure(statusText) {
+    const text = String(statusText || '').trim();
+    if (!/认证失败:\s*/i.test(text)) {
+      return false;
+    }
+
+    return /timeout waiting for oauth callback|status code 5\d{2}|bad gateway|gateway timeout|temporarily unavailable/i.test(text);
+  }
+
+  return {
+    getActivationStrategy,
+    isRecoverableStep9AuthFailure,
+  };
+});

+ 7 - 5
content/signup-page.js

@@ -233,9 +233,10 @@ async function prepareLoginCodeFlow(timeout = 15000) {
       loggedPasswordPage = false;
       log('步骤 7:检测到密码页,正在切换到一次性验证码登录...');
       await humanPause(350, 900);
+      const verificationRequestedAt = Date.now();
       simulateClick(switchTrigger);
       await sleep(1200);
-      continue;
+      return { ready: true, mode: 'verification_switch', verificationRequestedAt };
     }
 
     if (passwordInput && !loggedPasswordPage) {
@@ -372,15 +373,16 @@ async function step3_fillEmailPassword(payload) {
   fillInput(passwordInput, payload.password);
   log('步骤 3:密码已填写');
 
+  const submitBtn = document.querySelector('button[type="submit"]')
+    || await waitForElementByText('button', /continue|sign\s*up|submit|注册|创建|create/i, 5000).catch(() => null);
+
   // Report complete BEFORE submit, because submit causes page navigation
   // which kills the content script connection
-  reportComplete(3, { email });
+  const signupVerificationRequestedAt = submitBtn ? Date.now() : null;
+  reportComplete(3, { email, signupVerificationRequestedAt });
 
   // 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);

+ 30 - 3
content/utils.js

@@ -1,5 +1,7 @@
 // content/utils.js — Shared utilities for all content scripts
 
+const getActivationStrategy = self.MultiPageActivationUtils?.getActivationStrategy;
+
 const SCRIPT_SOURCE = (() => {
   if (window.__MULTIPAGE_SOURCE) return window.__MULTIPAGE_SOURCE;
   const url = location.href;
@@ -340,9 +342,34 @@ function reportError(step, errorMessage) {
  */
 function simulateClick(el) {
   throwIfStopped();
-  el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
-  console.log(LOG_PREFIX, `已点击: ${el.tagName} ${el.textContent?.slice(0, 30) || ''}`);
-  log(`已点击 [${el.tagName}] "${el.textContent?.trim().slice(0, 30) || ''}"`);
+  if (!el) {
+    throw new Error('无法点击空元素。');
+  }
+
+  const form = el.form || el.closest?.('form') || null;
+  const strategy = typeof getActivationStrategy === 'function'
+    ? getActivationStrategy({
+      tagName: el.tagName,
+      type: el.getAttribute?.('type') || el.type || '',
+      hasForm: Boolean(form),
+      pathname: location.pathname || '',
+    })
+    : { method: 'click' };
+
+  let method = strategy.method || 'click';
+
+  if (method === 'requestSubmit' && form && typeof form.requestSubmit === 'function') {
+    form.requestSubmit(el);
+  } else if (typeof el.click === 'function') {
+    method = 'click';
+    el.click();
+  } else {
+    method = 'dispatchEvent';
+    el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
+  }
+
+  console.log(LOG_PREFIX, `已点击(${method}): ${el.tagName} ${el.textContent?.slice(0, 30) || ''}`);
+  log(`已点击(${method}) [${el.tagName}] "${el.textContent?.trim().slice(0, 30) || ''}"`);
 }
 
 /**

+ 12 - 0
content/vps-panel.js

@@ -26,6 +26,9 @@
 console.log('[MultiPage:vps-panel] Content script loaded on', location.href);
 
 const VPS_PANEL_LISTENER_SENTINEL = 'data-multipage-vps-panel-listener';
+const {
+  isRecoverableStep9AuthFailure,
+} = self.MultiPageActivationUtils || {};
 
 if (document.documentElement.getAttribute(VPS_PANEL_LISTENER_SENTINEL) !== '1') {
   document.documentElement.setAttribute(VPS_PANEL_LISTENER_SENTINEL, '1');
@@ -208,6 +211,12 @@ async function waitForExactSuccessBadge(timeout = 30000) {
   while (Date.now() - start < timeout) {
     throwIfStopped();
     const statusText = getStatusBadgeText();
+    if (isOAuthCallbackTimeoutFailure(statusText)) {
+      throw new Error(`STEP9_OAUTH_TIMEOUT::${statusText}`);
+    }
+    if (typeof isRecoverableStep9AuthFailure === 'function' && isRecoverableStep9AuthFailure(statusText)) {
+      throw new Error(`STEP9_OAUTH_RETRY::${statusText}`);
+    }
     if (statusText === '认证成功!') {
       return statusText;
     }
@@ -218,6 +227,9 @@ async function waitForExactSuccessBadge(timeout = 30000) {
   if (isOAuthCallbackTimeoutFailure(finalText)) {
     throw new Error(`STEP9_OAUTH_TIMEOUT::${finalText}`);
   }
+  if (typeof isRecoverableStep9AuthFailure === 'function' && isRecoverableStep9AuthFailure(finalText)) {
+    throw new Error(`STEP9_OAUTH_RETRY::${finalText}`);
+  }
   throw new Error(finalText
     ? `CPA 面板状态不是“认证成功!”,当前为“${finalText}”。`
     : 'CPA 面板长时间未出现“认证成功!”状态徽标。');

+ 271 - 0
docs/superpowers/plans/2026-04-10-hotmail-oauth-mail-pool.md

@@ -0,0 +1,271 @@
+# Hotmail OAuth Mail Pool Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add a Hotmail account pool with Microsoft OAuth authorization and Microsoft Graph mail polling, then wire it into the existing automation flow as a new mail provider.
+
+**Architecture:** Keep the existing 1~9 step orchestrator in `background.js`, add a new `hotmail-api` provider path, and extend the side panel to manage Hotmail accounts. Preserve QQ, 163, and Inbucket behavior while introducing account allocation, token management, and Graph-based verification-code retrieval.
+
+**Tech Stack:** Chrome Extension MV3, plain JavaScript, `chrome.identity.launchWebAuthFlow`, `fetch`, `chrome.storage.local`, `chrome.storage.session`
+
+---
+
+### Task 1: Extend State Model for Hotmail Accounts
+
+**Files:**
+- Modify: `background.js`
+- Modify: `README.md`
+
+- [ ] **Step 1: Write the failing test**
+
+Document the expected account shape and provider behavior in code comments or development notes before implementation:
+
+```js
+// Expected local storage shape:
+// hotmailAccounts: [{ id, email, password, clientId, accessToken, refreshToken, expiresAt, status, lastUsedAt, lastAuthAt, lastError }]
+// mailProvider accepts 'hotmail-api'
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run a manual smoke check by loading the current extension and confirming there is no `hotmail-api` provider and no persisted Hotmail account state.
+
+Expected: The provider does not exist yet and account state is absent.
+
+- [ ] **Step 3: Write minimal implementation**
+
+Add new persisted keys and runtime state helpers in `background.js`:
+
+- `hotmailAccounts`
+- `currentHotmailAccountId`
+- helper functions to read, write, upsert, delete, and mark account status
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Reload the extension and confirm Hotmail account state can be read and written through background message handlers.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add background.js README.md
+git commit -m "feat: add hotmail account state model"
+```
+
+### Task 2: Add Hotmail Account Pool UI
+
+**Files:**
+- Modify: `sidepanel/sidepanel.html`
+- Modify: `sidepanel/sidepanel.css`
+- Modify: `sidepanel/sidepanel.js`
+
+- [ ] **Step 1: Write the failing test**
+
+Describe the expected UI state:
+
+- provider selector includes `hotmail-api`
+- Hotmail account section renders a list
+- user can add, authorize, test, and delete accounts
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Reload the current extension.
+
+Expected: No Hotmail provider option and no account section.
+
+- [ ] **Step 3: Write minimal implementation**
+
+Add:
+
+- a new provider option
+- a Hotmail accounts management section
+- side panel event handlers for add, authorize, test, delete, and select current account status
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Reload the extension and verify:
+
+- the new provider is visible
+- the section appears only when selected
+- account rows render correctly from stored state
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add sidepanel/sidepanel.html sidepanel/sidepanel.css sidepanel/sidepanel.js
+git commit -m "feat: add hotmail account pool panel"
+```
+
+### Task 3: Implement Microsoft OAuth Authorization
+
+**Files:**
+- Modify: `background.js`
+- Modify: `manifest.json`
+
+- [ ] **Step 1: Write the failing test**
+
+Define the expected authorization flow in a focused helper-oriented checklist:
+
+- PKCE code verifier/challenge can be created
+- auth URL includes client ID, redirect URI, state, scope, and challenge
+- callback code is parsed and validated
+- token response updates the account record
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Trigger the new `Authorize` action from the side panel.
+
+Expected: The action is not implemented yet and fails.
+
+- [ ] **Step 3: Write minimal implementation**
+
+Add background handlers and helpers for:
+
+- PKCE generation
+- OAuth URL creation
+- `chrome.identity.getRedirectURL()`
+- `chrome.identity.launchWebAuthFlow`
+- code exchange via Microsoft token endpoint
+- account token persistence and error reporting
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Manually authorize a Hotmail account and confirm:
+
+- the login flow opens
+- tokens are saved to the target account
+- account status becomes `authorized`
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add background.js manifest.json
+git commit -m "feat: add microsoft oauth authorization"
+```
+
+### Task 4: Implement Token Refresh and Graph Mail Polling
+
+**Files:**
+- Modify: `background.js`
+
+- [ ] **Step 1: Write the failing test**
+
+Define the expected helper behavior:
+
+- expired access token refreshes with `refresh_token`
+- Graph mail fetch returns recent inbox messages
+- filtering returns the newest matching verification code after the requested timestamp
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Use the side panel `Test Mail Access` action before implementing the Graph path.
+
+Expected: The action fails because Graph mail polling does not exist yet.
+
+- [ ] **Step 3: Write minimal implementation**
+
+Add helpers in `background.js` for:
+
+- token freshness check
+- refresh-token grant request
+- Graph inbox fetch
+- mail filtering
+- verification code extraction
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Authorize a Hotmail account and verify the test action can fetch mailbox data and surface a success or meaningful “no matching mail” response.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add background.js
+git commit -m "feat: add graph mail polling"
+```
+
+### Task 5: Wire Hotmail Provider into Step 3, Step 4, Step 7, and Auto Run
+
+**Files:**
+- Modify: `background.js`
+- Modify: `sidepanel/sidepanel.js`
+- Modify: `README.md`
+
+- [ ] **Step 1: Write the failing test**
+
+Define the expected run behavior:
+
+- Auto mode chooses a fresh authorized Hotmail account for each new run
+- Step 3 uses the selected account email and password
+- Step 4 and Step 7 read verification mail through Graph instead of mailbox tabs
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Select `hotmail-api` and run a manual or auto flow.
+
+Expected: The flow cannot yet allocate an account or fetch verification codes from Graph.
+
+- [ ] **Step 3: Write minimal implementation**
+
+Update:
+
+- provider branching in `getMailConfig()` or a replacement provider resolver
+- account allocation at fresh run start
+- Step 3 account-backed credentials
+- Step 4 and Step 7 Graph polling path
+- auto-run preconditions for Hotmail accounts
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run:
+
+1. manual Step 3 + Step 4 on a selected account
+2. manual Step 6 + Step 7 on the same account
+3. one full Auto run with `hotmail-api`
+
+Expected: no mailbox tab is opened for Hotmail, and verification codes come from Graph.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add background.js sidepanel/sidepanel.js README.md
+git commit -m "feat: integrate hotmail api provider into automation flow"
+```
+
+### Task 6: Regression Verification and Cleanup
+
+**Files:**
+- Modify: `README.md`
+
+- [ ] **Step 1: Write the failing test**
+
+List the required regression checks:
+
+- QQ provider still opens QQ mail tab
+- 163 provider still opens 163 mail tab
+- Inbucket provider still opens mailbox page
+- Hotmail provider uses API path only
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Manually inspect pre-change behavior expectations against the new code before cleanup.
+
+Expected: Any missing provider branch or broken selector is identified.
+
+- [ ] **Step 3: Write minimal implementation**
+
+Clean up labels, update README usage instructions, and ensure all branches show accurate UI copy.
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Reload the extension and perform:
+
+- provider switch smoke test
+- account add/delete smoke test
+- one OAuth authorization smoke test
+- one mail access smoke test
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add README.md
+git commit -m "docs: document hotmail oauth mail provider"
+```

+ 197 - 0
docs/superpowers/specs/2026-04-10-hotmail-oauth-design.md

@@ -0,0 +1,197 @@
+# Hotmail OAuth + Graph Mail Design
+
+## Goal
+
+Replace the existing DuckDuckGo plus webmail polling flow with a Hotmail account pool that:
+
+- authorizes each Hotmail account inside the extension via Microsoft OAuth
+- stores per-account tokens locally
+- selects a fresh account for each automated run
+- fetches verification emails through Microsoft Graph instead of mailbox page DOM polling
+
+The existing 1~9 step flow should remain intact wherever possible. The new work should be isolated to account selection, authorization, and email retrieval.
+
+## Existing Constraints
+
+- The project is a Manifest V3 Chrome extension with no build step.
+- Runtime orchestration lives in `background.js`.
+- The side panel is plain HTML/CSS/JS.
+- Step 4 and Step 7 currently depend on provider-specific content scripts for QQ, 163, and Inbucket mailbox polling.
+- Auto mode already supports retries, pauses, and restoring session state.
+
+## Design Summary
+
+The new Hotmail path introduces three focused subsystems:
+
+1. `hotmail-account-pool`
+   Maintains a reusable list of Hotmail accounts and their OAuth credentials.
+2. `microsoft-oauth`
+   Handles Microsoft authorization code flow with PKCE through `chrome.identity.launchWebAuthFlow`.
+3. `hotmail-graph-mail`
+   Reads inbox messages from Microsoft Graph and extracts verification codes for Step 4 and Step 7.
+
+The main flow continues to use the existing step state machine in `background.js`.
+
+## Architecture
+
+### 1. Account Pool
+
+Persist a new `hotmailAccounts` array in `chrome.storage.local`.
+
+Each account record stores:
+
+- `id`
+- `email`
+- `password`
+- `clientId`
+- `accessToken`
+- `refreshToken`
+- `expiresAt`
+- `status`
+- `lastUsedAt`
+- `lastAuthAt`
+- `lastError`
+
+The current run stores `currentHotmailAccountId` in `chrome.storage.session`.
+
+When `mailProvider = hotmail-api`, Auto mode must allocate one account at the start of a fresh run, write its email into the existing `email` runtime field, and reuse the same account through Step 3, Step 4, Step 6, and Step 7.
+
+### 2. OAuth Flow
+
+Each account is authorized separately from the side panel.
+
+Flow:
+
+1. User clicks `Authorize` on an account row.
+2. Background generates PKCE verifier/challenge and a random `state`.
+3. Background launches Microsoft sign-in via `chrome.identity.launchWebAuthFlow`.
+4. Microsoft redirects back to the extension redirect URL.
+5. Background validates `state`, exchanges `code` for tokens, and updates the account record.
+
+The extension requests delegated scopes only:
+
+- `openid`
+- `profile`
+- `offline_access`
+- `https://graph.microsoft.com/Mail.Read`
+- `https://graph.microsoft.com/User.Read`
+
+The design assumes one shared `clientId` across accounts is valid, while `refreshToken` remains per account.
+
+### 3. Graph Mail Retrieval
+
+Hotmail mail retrieval runs inside background logic and does not require a mail tab or content script.
+
+The provider performs:
+
+1. Resolve the current Hotmail account from session state.
+2. Refresh the token if missing or near expiry.
+3. Call Microsoft Graph to fetch recent inbox messages.
+4. Filter by sender, subject, and time window.
+5. Extract a 6-digit verification code from message metadata or preview/body.
+6. Return the code to the existing Step 4 or Step 7 submission flow.
+
+The first iteration should prefer stable fields such as:
+
+- `from.emailAddress.address`
+- `subject`
+- `receivedDateTime`
+- `bodyPreview`
+
+Full HTML body parsing is explicitly deferred unless needed.
+
+## Integration with Existing Steps
+
+### Step 3
+
+Step 3 keeps filling the OpenAI page in the same way, but when `mailProvider = hotmail-api`, the email comes from the selected account pool entry instead of the manual email box or Duck address.
+
+### Step 4 and Step 7
+
+The existing retry and resend behavior stays in place, but the provider path changes:
+
+- old providers: `qq`, `163`, `inbucket`
+- new provider: `hotmail-api`
+
+The orchestration layer should branch before opening any mailbox tab. For `hotmail-api`, it calls a background helper instead of `sendToMailContentScriptResilient`.
+
+### Auto Run
+
+Auto run changes:
+
+- remove the Duck auto-fetch dependency when `hotmail-api` is selected
+- allocate a fresh Hotmail account at the start of a new run
+- fail early if no authorized account is available
+- preserve the existing retry and skip-failure semantics
+
+## Side Panel Changes
+
+The current single email entry remains for backward compatibility, but a new account-pool section is added when `hotmail-api` is selected.
+
+New UI capabilities:
+
+- list Hotmail accounts
+- add an account
+- delete an account
+- authorize an account
+- test mail access for an account
+- show status and last error
+
+The existing provider selector gains a `hotmail-api` option.
+
+## Error Handling
+
+The new path must surface actionable errors:
+
+- no Hotmail account available
+- missing `clientId`
+- OAuth denied or cancelled
+- token exchange failed
+- refresh token invalid
+- Graph mail read failed
+- no matching verification mail found in the time window
+
+Account-level failures should update the account record `status` and `lastError` without corrupting unrelated accounts.
+
+## Security and Storage Tradeoffs
+
+- Tokens and account passwords are stored in `chrome.storage.local` for operator convenience.
+- This is acceptable for the current operator-managed extension model, but it increases the trust requirement of the local browser profile.
+- No secret should be hard-coded in the repository.
+
+## Testing Strategy
+
+Because the project has no automated test harness today, implementation should carve out pure helper functions where possible and validate them with focused runtime checks.
+
+The minimum verification surface:
+
+- account selection logic
+- PKCE helper generation
+- OAuth callback parsing and state validation
+- token refresh request/response handling
+- Graph message filtering and code extraction
+- Step 4 and Step 7 provider branch behavior
+- Auto run allocation of a fresh account per run
+
+## Deferred Work
+
+The first version intentionally excludes:
+
+- bulk import/export UX
+- bulk authorize all accounts
+- advanced HTML message parsing
+- mailbox delete/move/archive behavior
+- background local service or server proxy
+- removal of existing QQ/163/Inbucket support
+
+## Implementation Boundary
+
+Modify only the minimum set of files needed to add the new provider while keeping current providers operational:
+
+- `manifest.json`
+- `background.js`
+- `sidepanel/sidepanel.html`
+- `sidepanel/sidepanel.css`
+- `sidepanel/sidepanel.js`
+
+If helper extraction becomes necessary, prefer adding small new files under `content/` or the repo root only if they clearly reduce complexity in `background.js`.

+ 402 - 0
hotmail-utils.js

@@ -0,0 +1,402 @@
+(function hotmailUtilsModule(root, factory) {
+  if (typeof module !== 'undefined' && module.exports) {
+    module.exports = factory();
+    return;
+  }
+
+  root.HotmailUtils = factory();
+})(typeof self !== 'undefined' ? self : globalThis, function createHotmailUtils() {
+  const HOTMAIL_MICROSOFT_TOKEN_URL = 'https://login.microsoftonline.com/consumers/oauth2/v2.0/token';
+  const HOTMAIL_GRAPH_API_ORIGIN = 'https://graph.microsoft.com';
+  const HOTMAIL_GRAPH_PAGE_SIZE = 10;
+  const HOTMAIL_GRAPH_MESSAGE_FIELDS = [
+    'id',
+    'internetMessageId',
+    'subject',
+    'from',
+    'bodyPreview',
+    'receivedDateTime',
+  ];
+  const HOTMAIL_GRAPH_SCOPES = [
+    'offline_access',
+    'https://graph.microsoft.com/Mail.Read',
+    'https://graph.microsoft.com/User.Read',
+  ];
+
+  function normalizeText(value) {
+    return String(value || '')
+      .replace(/\s+/g, ' ')
+      .trim()
+      .toLowerCase();
+  }
+
+  function normalizeTimestamp(value) {
+    if (!value) return 0;
+    if (typeof value === 'number' && Number.isFinite(value)) {
+      return value > 0 ? value : 0;
+    }
+
+    const timestamp = Date.parse(value);
+    return Number.isFinite(timestamp) ? timestamp : 0;
+  }
+
+  function extractVerificationCode(text) {
+    const source = String(text || '');
+    const matchCn = source.match(/(?:代码为|验证码[^0-9]*?)[\s::]*(\d{6})/i);
+    if (matchCn) return matchCn[1];
+
+    const matchEn = source.match(/code(?:\s+is|[\s:])+(\d{6})/i);
+    if (matchEn) return matchEn[1];
+
+    const matchStandalone = source.match(/\b(\d{6})\b/);
+    return matchStandalone ? matchStandalone[1] : null;
+  }
+
+  function extractVerificationCodeFromMessage(message = {}) {
+    const sender = firstNonEmptyString([
+      message?.from?.emailAddress?.address,
+      message?.sender,
+      message?.from,
+    ]);
+    const subject = firstNonEmptyString([message?.subject]);
+    const preview = firstNonEmptyString([message?.bodyPreview, message?.preview, message?.text]);
+    return extractVerificationCode([subject, preview, sender].filter(Boolean).join(' '));
+  }
+
+  function getLatestHotmailMessage(messages) {
+    return (Array.isArray(messages) ? messages : [])
+      .slice()
+      .sort((left, right) => {
+        const leftTime = normalizeTimestamp(left?.receivedDateTime);
+        const rightTime = normalizeTimestamp(right?.receivedDateTime);
+        return rightTime - leftTime;
+      })[0] || null;
+  }
+
+  function getHotmailListToggleLabel(expanded, count = 0) {
+    const normalizedCount = Number.isFinite(Number(count)) ? Math.max(0, Number(count)) : 0;
+    const suffix = normalizedCount > 0 ? `(${normalizedCount})` : '';
+    return `${expanded ? '收起列表' : '展开列表'}${suffix}`;
+  }
+
+  function filterHotmailAccountsByUsage(accounts, mode = 'all') {
+    const list = Array.isArray(accounts) ? accounts.slice() : [];
+    if (mode === 'used') {
+      return list.filter((account) => Boolean(account?.used));
+    }
+    return list;
+  }
+
+  function getHotmailBulkActionLabel(mode = 'all', count = 0) {
+    const normalizedCount = Number.isFinite(Number(count)) ? Math.max(0, Number(count)) : 0;
+    const prefix = mode === 'used' ? '清空已用' : '全部删除';
+    const suffix = normalizedCount > 0 ? `(${normalizedCount})` : '';
+    return `${prefix}${suffix}`;
+  }
+
+  function isAuthorizedHotmailAccount(account) {
+    return Boolean(account)
+      && account.status === 'authorized'
+      && !account.used
+      && Boolean(account.refreshToken);
+  }
+
+  function shouldClearHotmailCurrentSelection(account) {
+    return Boolean(account) && account.used === true;
+  }
+
+  function upsertHotmailAccountInList(accounts, nextAccount) {
+    const list = Array.isArray(accounts) ? accounts.slice() : [];
+    if (!nextAccount?.id) return list;
+
+    const existingIndex = list.findIndex((account) => account?.id === nextAccount.id);
+    if (existingIndex === -1) {
+      list.push(nextAccount);
+      return list;
+    }
+
+    list[existingIndex] = nextAccount;
+    return list;
+  }
+
+  function pickHotmailAccountForRun(accounts, options = {}) {
+    const candidates = Array.isArray(accounts) ? accounts.filter(isAuthorizedHotmailAccount) : [];
+    if (!candidates.length) return null;
+
+    const excludeIds = new Set((options.excludeIds || []).filter(Boolean));
+    const filtered = candidates.filter((account) => !excludeIds.has(account.id));
+    const pool = filtered.length ? filtered : candidates;
+
+    return pool
+      .slice()
+      .sort((left, right) => {
+        const leftUsedAt = normalizeTimestamp(left.lastUsedAt);
+        const rightUsedAt = normalizeTimestamp(right.lastUsedAt);
+        if (leftUsedAt !== rightUsedAt) {
+          return leftUsedAt - rightUsedAt;
+        }
+
+        return String(left.email || '').localeCompare(String(right.email || ''));
+      })[0] || null;
+  }
+
+  function messageMatchesFilters(message, filters = {}) {
+    const senderFilters = (filters.senderFilters || []).map(normalizeText).filter(Boolean);
+    const subjectFilters = (filters.subjectFilters || []).map(normalizeText).filter(Boolean);
+    const afterTimestamp = normalizeTimestamp(filters.afterTimestamp);
+    const receivedAt = normalizeTimestamp(message?.receivedDateTime);
+    if (afterTimestamp && receivedAt && receivedAt < afterTimestamp) {
+      return null;
+    }
+
+    const sender = normalizeText(message?.from?.emailAddress?.address);
+    const subject = normalizeText(message?.subject);
+    const preview = String(message?.bodyPreview || '');
+    const combinedText = [subject, sender, preview].filter(Boolean).join(' ');
+    const code = extractVerificationCode(combinedText);
+    const excludedCodes = new Set((filters.excludeCodes || []).filter(Boolean));
+    if (code && excludedCodes.has(code)) {
+      return null;
+    }
+
+    const senderMatch = senderFilters.length === 0
+      ? true
+      : senderFilters.some((item) => sender.includes(item) || normalizeText(preview).includes(item));
+    const subjectMatch = subjectFilters.length === 0
+      ? true
+      : subjectFilters.some((item) => subject.includes(item) || normalizeText(preview).includes(item));
+
+    if (!senderMatch && !subjectMatch) {
+      return null;
+    }
+
+    if (!code) {
+      return null;
+    }
+
+    return {
+      code,
+      message,
+      receivedAt,
+    };
+  }
+
+  function pickVerificationMessage(messages, filters = {}) {
+    const matches = (Array.isArray(messages) ? messages : [])
+      .map((message) => messageMatchesFilters(message, filters))
+      .filter(Boolean)
+      .sort((left, right) => right.receivedAt - left.receivedAt);
+
+    return matches[0] || null;
+  }
+
+  function pickVerificationMessageWithFallback(messages, filters = {}) {
+    const strictMatch = pickVerificationMessage(messages, filters);
+    return {
+      match: strictMatch || null,
+      usedRelaxedFilters: false,
+      usedTimeFallback: false,
+    };
+  }
+
+  function pickVerificationMessageWithTimeFallback(messages, filters = {}) {
+    const strictOrRelaxedResult = pickVerificationMessageWithFallback(messages, filters);
+    if (strictOrRelaxedResult.match) {
+      return strictOrRelaxedResult;
+    }
+
+    const timeFallbackMatch = pickVerificationMessage(messages, {
+      afterTimestamp: 0,
+      excludeCodes: filters.excludeCodes,
+      senderFilters: filters.senderFilters,
+      subjectFilters: filters.subjectFilters,
+    });
+
+    return {
+      match: timeFallbackMatch || null,
+      usedRelaxedFilters: false,
+      usedTimeFallback: Boolean(timeFallbackMatch),
+    };
+    /* c8 ignore stop */
+  }
+
+  function firstNonEmptyString(values) {
+    for (const value of values) {
+      if (value === undefined || value === null) continue;
+      const normalized = String(value).trim();
+      if (normalized) return normalized;
+    }
+    return '';
+  }
+
+  function normalizeMailAddress(rawValue) {
+    if (!rawValue) return '';
+    if (typeof rawValue === 'string') {
+      return rawValue.trim();
+    }
+    if (typeof rawValue === 'object') {
+      return firstNonEmptyString([
+        rawValue.emailAddress?.address,
+        rawValue.address,
+        rawValue.email,
+        rawValue.sender,
+        rawValue.from,
+      ]);
+    }
+    return '';
+  }
+
+  function stripHtmlTags(text) {
+    return String(text || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
+  }
+
+  function normalizeHotmailMailApiMessage(message = {}) {
+    return {
+      id: firstNonEmptyString([message.id, message.message_id, message.messageId, message.internetMessageId]),
+      subject: firstNonEmptyString([message.subject, message.title]),
+      from: {
+        emailAddress: {
+          address: normalizeMailAddress(
+            message.from_email
+            || message.sender_email
+            || message.from
+            || message.sender
+            || message.emailAddress
+          ),
+        },
+      },
+      bodyPreview: firstNonEmptyString([
+        message.bodyPreview,
+        message.preview,
+        message.snippet,
+        message.text,
+        message.body,
+        stripHtmlTags(message.html || message.content || ''),
+      ]),
+      receivedDateTime: firstNonEmptyString([
+        message.receivedDateTime,
+        message.received_at,
+        message.receivedAt,
+        message.date,
+        message.created_at,
+        message.time,
+      ]),
+    };
+  }
+
+  function normalizeHotmailMailApiMessages(messages) {
+    const list = Array.isArray(messages)
+      ? messages
+      : (messages ? [messages] : []);
+    return list.map((message) => normalizeHotmailMailApiMessage(message));
+  }
+
+  function normalizeHotmailMailboxId(mailbox = 'INBOX') {
+    const normalized = normalizeText(mailbox);
+    if (normalized === 'junk' || normalized === 'junk email' || normalized === 'junkemail') {
+      return 'junkemail';
+    }
+    return 'inbox';
+  }
+
+  function buildHotmailGraphMessagesUrl(options) {
+    const folderId = normalizeHotmailMailboxId(options?.mailbox);
+    const url = new URL(`${HOTMAIL_GRAPH_API_ORIGIN}/v1.0/me/mailFolders/${folderId}/messages`);
+    url.searchParams.set('$top', String(options?.top || HOTMAIL_GRAPH_PAGE_SIZE));
+    url.searchParams.set('$select', (options?.selectFields || HOTMAIL_GRAPH_MESSAGE_FIELDS).join(','));
+    url.searchParams.set('$orderby', String(options?.orderBy || 'receivedDateTime desc'));
+    return url.toString();
+  }
+
+  function getHotmailVerificationPollConfig(step) {
+    if (step === 4 || step === 7) {
+      return {
+        initialDelayMs: 5000,
+        maxAttempts: 12,
+        intervalMs: 5000,
+        requestFreshCodeFirst: false,
+        ignorePersistedLastCode: true,
+      };
+    }
+
+    return {
+      initialDelayMs: 5000,
+      maxAttempts: 8,
+      intervalMs: 4000,
+      requestFreshCodeFirst: false,
+      ignorePersistedLastCode: true,
+    };
+  }
+
+  function getHotmailVerificationRequestTimestamp(step, state = {}, options = {}) {
+    const bufferMs = Number(options.bufferMs) || 15_000;
+    const signupRequestedAt = normalizeTimestamp(state.signupVerificationRequestedAt);
+    const loginRequestedAt = normalizeTimestamp(state.loginVerificationRequestedAt);
+    const lastEmailTimestamp = normalizeTimestamp(state.lastEmailTimestamp);
+    const flowStartTime = normalizeTimestamp(state.flowStartTime);
+
+    if (step === 4 && signupRequestedAt) {
+      return Math.max(0, signupRequestedAt - bufferMs);
+    }
+
+    if (step === 7 && loginRequestedAt) {
+      return Math.max(0, loginRequestedAt - bufferMs);
+    }
+
+    return step === 7
+      ? (lastEmailTimestamp || flowStartTime || 0)
+      : (flowStartTime || 0);
+  }
+
+  function getHotmailGraphRequestConfig() {
+    return {
+      timeoutMs: 15000,
+      pageSize: HOTMAIL_GRAPH_PAGE_SIZE,
+      scopes: HOTMAIL_GRAPH_SCOPES.slice(),
+      tokenUrl: HOTMAIL_MICROSOFT_TOKEN_URL,
+      messageFields: HOTMAIL_GRAPH_MESSAGE_FIELDS.slice(),
+    };
+  }
+
+  function parseHotmailImportText(rawText) {
+    const lines = String(rawText || '')
+      .split(/\r?\n/)
+      .map((line) => line.trim())
+      .filter(Boolean);
+
+    return lines
+      .filter((line, index) => !(index === 0 && /^账号----密码----ID----Token$/i.test(line)))
+      .map((line) => line.split('----').map((part) => part.trim()))
+      .filter((parts) => parts.length >= 4 && parts[0] && parts[2])
+      .map(([email, password, clientId, refreshToken]) => ({
+        email,
+        password,
+        clientId,
+        refreshToken,
+      }));
+  }
+
+  return {
+    buildHotmailGraphMessagesUrl,
+    extractVerificationCodeFromMessage,
+    filterHotmailAccountsByUsage,
+    extractVerificationCode,
+    getLatestHotmailMessage,
+    getHotmailBulkActionLabel,
+    getHotmailListToggleLabel,
+    getHotmailGraphRequestConfig,
+    getHotmailVerificationPollConfig,
+    getHotmailVerificationRequestTimestamp,
+    normalizeHotmailMailboxId,
+    isAuthorizedHotmailAccount,
+    normalizeHotmailMailApiMessages,
+    normalizeTimestamp,
+    parseHotmailImportText,
+    pickHotmailAccountForRun,
+    pickVerificationMessage,
+    pickVerificationMessageWithFallback,
+    pickVerificationMessageWithTimeFallback,
+    shouldClearHotmailCurrentSelection,
+    upsertHotmailAccountInList,
+  };
+});

+ 4 - 4
manifest.json

@@ -29,7 +29,7 @@
         "https://auth.openai.com/*",
         "https://accounts.openai.com/*"
       ],
-      "js": ["content/utils.js", "content/signup-page.js"],
+      "js": ["content/activation-utils.js", "content/utils.js", "content/signup-page.js"],
       "run_at": "document_idle"
     },
     {
@@ -37,7 +37,7 @@
         "https://mail.qq.com/*",
         "https://wx.mail.qq.com/*"
       ],
-      "js": ["content/utils.js", "content/qq-mail.js"],
+      "js": ["content/activation-utils.js", "content/utils.js", "content/qq-mail.js"],
       "all_frames": true,
       "run_at": "document_idle"
     },
@@ -47,7 +47,7 @@
         "https://*.mail.163.com/*",
         "https://webmail.vip.163.com/*"
       ],
-      "js": ["content/utils.js", "content/mail-163.js"],
+      "js": ["content/activation-utils.js", "content/utils.js", "content/mail-163.js"],
       "all_frames": true,
       "run_at": "document_idle"
     },
@@ -55,7 +55,7 @@
       "matches": [
         "https://duckduckgo.com/email/settings/autofill*"
       ],
-      "js": ["content/utils.js", "content/duck-mail.js"],
+      "js": ["content/activation-utils.js", "content/utils.js", "content/duck-mail.js"],
       "run_at": "document_idle"
     }
   ],

+ 7 - 0
package.json

@@ -0,0 +1,7 @@
+{
+  "name": "codex-oauth-automation-extension",
+  "private": true,
+  "scripts": {
+    "test": "node --test tests/*.test.js"
+  }
+}

+ 227 - 0
sidepanel/sidepanel.css

@@ -238,6 +238,33 @@ header {
   box-shadow: var(--shadow-sm);
 }
 
+.section-mini-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 8px;
+}
+
+.section-mini-copy {
+  min-width: 0;
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+}
+
+.section-mini-actions {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  flex-wrap: wrap;
+  justify-content: flex-end;
+}
+
+.section-hint {
+  color: var(--text-muted);
+  font-size: 12px;
+}
+
 .data-row {
   display: flex;
   align-items: center;
@@ -309,6 +336,23 @@ header {
 .data-input::placeholder { color: var(--text-muted); }
 .data-input:focus { border-color: var(--blue); box-shadow: 0 0 0 3px var(--blue-soft); }
 
+.data-textarea {
+  width: 100%;
+  min-height: 88px;
+  resize: vertical;
+  padding: 8px 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: 12px;
+  outline: none;
+  transition: border-color var(--transition), box-shadow var(--transition);
+}
+.data-textarea::placeholder { color: var(--text-muted); }
+.data-textarea:focus { border-color: var(--blue); box-shadow: 0 0 0 3px var(--blue-soft); }
+
 .data-input-with-icon {
   padding-right: 38px;
 }
@@ -340,6 +384,189 @@ header {
   flex-shrink: 0;
 }
 
+.hotmail-card {
+  margin-top: 10px;
+}
+
+.hotmail-actions-row .data-label {
+  visibility: hidden;
+}
+
+.hotmail-import-row {
+  align-items: flex-start;
+}
+
+.hotmail-import-box {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+
+.hotmail-list-shell {
+  border: 1px solid var(--border-subtle);
+  border-radius: var(--radius-sm);
+  background: color-mix(in srgb, var(--bg-base) 82%, transparent);
+  overflow-y: auto;
+  overflow-x: hidden;
+  padding: 2px;
+  transition: max-height var(--transition), border-color var(--transition), box-shadow var(--transition);
+}
+
+.hotmail-list-shell.is-collapsed {
+  max-height: 236px;
+}
+
+.hotmail-list-shell.is-expanded {
+  max-height: 440px;
+}
+
+.hotmail-list-shell::-webkit-scrollbar {
+  width: 8px;
+}
+
+.hotmail-list-shell::-webkit-scrollbar-track {
+  background: transparent;
+}
+
+.hotmail-list-shell::-webkit-scrollbar-thumb {
+  background: var(--border);
+  border-radius: 999px;
+}
+
+.hotmail-list-shell::-webkit-scrollbar-thumb:hover {
+  background: var(--text-muted);
+}
+
+.hotmail-accounts-list {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+  padding-right: 4px;
+}
+
+.hotmail-account-item {
+  border: 1px solid var(--border);
+  background: var(--bg-base);
+  border-radius: var(--radius-sm);
+  padding: 10px;
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+
+.hotmail-account-item.is-current {
+  border-color: var(--blue);
+  box-shadow: 0 0 0 1px var(--blue-glow);
+}
+
+.hotmail-account-top {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 8px;
+}
+
+.hotmail-account-title-row {
+  min-width: 0;
+  display: flex;
+  align-items: center;
+  gap: 6px;
+}
+
+.hotmail-account-email {
+  font-weight: 600;
+  color: var(--text-primary);
+  word-break: break-all;
+}
+
+.hotmail-copy-btn {
+  width: 24px;
+  height: 24px;
+  flex: 0 0 auto;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  border: none;
+  border-radius: 999px;
+  background: transparent;
+  color: var(--text-muted);
+  cursor: pointer;
+  transition: background var(--transition), color var(--transition), transform var(--transition);
+}
+
+.hotmail-copy-btn:hover {
+  background: var(--bg-hover);
+  color: var(--text-primary);
+}
+
+.hotmail-copy-btn:active {
+  transform: scale(0.96);
+}
+
+.hotmail-status-chip {
+  padding: 2px 8px;
+  border-radius: 999px;
+  font-size: 11px;
+  font-weight: 700;
+  text-transform: uppercase;
+  letter-spacing: 0.02em;
+}
+
+.hotmail-status-chip.status-authorized {
+  background: var(--green-soft);
+  color: var(--green);
+}
+
+.hotmail-status-chip.status-used {
+  background: var(--orange-soft);
+  color: var(--orange);
+}
+
+.hotmail-status-chip.status-disabled {
+  background: var(--bg-surface);
+  color: var(--text-secondary);
+}
+
+.hotmail-status-chip.status-pending {
+  background: var(--orange-soft);
+  color: var(--orange);
+}
+
+.hotmail-status-chip.status-error {
+  background: var(--red-soft);
+  color: var(--red);
+}
+
+.hotmail-account-meta {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 6px 10px;
+  color: var(--text-secondary);
+  font-size: 12px;
+}
+
+.hotmail-account-error {
+  font-size: 12px;
+  color: var(--red);
+  word-break: break-word;
+}
+
+.hotmail-account-actions {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 6px;
+}
+
+.hotmail-empty {
+  color: var(--text-muted);
+  font-size: 12px;
+  border: 1px dashed var(--border);
+  border-radius: var(--radius-sm);
+  padding: 10px;
+  text-align: center;
+}
+
 .data-select {
   flex: 1;
   padding: 7px 10px;

+ 45 - 0
sidepanel/sidepanel.html

@@ -91,6 +91,7 @@
       <div class="data-row">
         <span class="data-label">邮箱服务</span>
         <select id="select-mail-provider" class="data-select">
+          <option value="hotmail-api">Hotmail(微软 Graph)</option>
           <option value="163">163 邮箱 (mail.163.com)</option>
           <option value="163-vip">163 VIP 邮箱 (webmail.vip.163.com)</option>
           <option value="qq">QQ 邮箱 (wx.mail.qq.com)</option>
@@ -158,6 +159,49 @@
         <span id="display-localhost-url" class="data-value mono truncate">等待中...</span>
       </div>
     </div>
+    <div id="hotmail-section" class="data-card hotmail-card" style="display:none;">
+      <div class="section-mini-header">
+        <div class="section-mini-copy">
+          <span class="section-label">Hotmail 账号池</span>
+          <span class="section-hint">每轮自动分配一个可用账号</span>
+        </div>
+        <div class="section-mini-actions">
+          <button id="btn-clear-used-hotmail-accounts" class="btn btn-ghost btn-xs" type="button">清空已用</button>
+          <button id="btn-delete-all-hotmail-accounts" class="btn btn-ghost btn-xs" type="button">全部删除</button>
+          <button id="btn-toggle-hotmail-list" class="btn btn-ghost btn-xs" type="button" aria-expanded="false">展开列表</button>
+        </div>
+      </div>
+      <div class="data-row">
+        <span class="data-label">邮箱</span>
+        <input type="text" id="input-hotmail-email" class="data-input" placeholder="name@hotmail.com" />
+      </div>
+      <div class="data-row">
+        <span class="data-label">客户端 ID</span>
+        <input type="text" id="input-hotmail-client-id" class="data-input mono" placeholder="微软应用客户端 ID" />
+      </div>
+      <div class="data-row">
+        <span class="data-label">邮箱密码</span>
+        <input type="password" id="input-hotmail-password" class="data-input" placeholder="可选,仅用于记录" />
+      </div>
+      <div class="data-row">
+        <span class="data-label">刷新令牌</span>
+        <input type="password" id="input-hotmail-refresh-token" class="data-input mono" placeholder="必填,粘贴刷新令牌(refresh token)" />
+      </div>
+      <div class="data-row hotmail-actions-row">
+        <span class="data-label"></span>
+        <button id="btn-add-hotmail-account" class="btn btn-primary btn-sm" type="button">添加账号</button>
+      </div>
+      <div class="data-row hotmail-import-row">
+        <span class="data-label">批量导入</span>
+        <div class="hotmail-import-box">
+          <textarea id="input-hotmail-import" class="data-textarea mono" placeholder="账号----密码----客户端ID----刷新令牌&#10;name@hotmail.com----password----client-id----refresh-token"></textarea>
+          <button id="btn-import-hotmail-accounts" class="btn btn-outline btn-sm" type="button">导入</button>
+        </div>
+      </div>
+      <div id="hotmail-list-shell" class="hotmail-list-shell is-collapsed">
+        <div id="hotmail-accounts-list" class="hotmail-accounts-list"></div>
+      </div>
+    </div>
     <div id="status-bar" class="status-bar">
       <div class="status-dot"></div>
       <span id="display-status">就绪</span>
@@ -265,6 +309,7 @@
   </div>
 
   <div id="toast-container"></div>
+  <script src="../hotmail-utils.js"></script>
   <script src="sidepanel.js"></script>
 </body>
 

+ 594 - 22
sidepanel/sidepanel.js

@@ -37,6 +37,19 @@ const inputVpsUrl = document.getElementById('input-vps-url');
 const inputVpsPassword = document.getElementById('input-vps-password');
 const selectMailProvider = document.getElementById('select-mail-provider');
 const selectEmailGenerator = document.getElementById('select-email-generator');
+const hotmailSection = document.getElementById('hotmail-section');
+const inputHotmailEmail = document.getElementById('input-hotmail-email');
+const inputHotmailClientId = document.getElementById('input-hotmail-client-id');
+const inputHotmailPassword = document.getElementById('input-hotmail-password');
+const inputHotmailRefreshToken = document.getElementById('input-hotmail-refresh-token');
+const inputHotmailImport = document.getElementById('input-hotmail-import');
+const btnAddHotmailAccount = document.getElementById('btn-add-hotmail-account');
+const btnImportHotmailAccounts = document.getElementById('btn-import-hotmail-accounts');
+const btnClearUsedHotmailAccounts = document.getElementById('btn-clear-used-hotmail-accounts');
+const btnDeleteAllHotmailAccounts = document.getElementById('btn-delete-all-hotmail-accounts');
+const btnToggleHotmailList = document.getElementById('btn-toggle-hotmail-list');
+const hotmailListShell = document.getElementById('hotmail-list-shell');
+const hotmailAccountsList = document.getElementById('hotmail-accounts-list');
 const rowInbucketHost = document.getElementById('row-inbucket-host');
 const inputInbucketHost = document.getElementById('input-inbucket-host');
 const rowInbucketMailbox = document.getElementById('row-inbucket-mailbox');
@@ -56,6 +69,7 @@ const btnAutoStartClose = document.getElementById('btn-auto-start-close');
 const btnAutoStartCancel = document.getElementById('btn-auto-start-cancel');
 const btnAutoStartRestart = document.getElementById('btn-auto-start-restart');
 const btnAutoStartContinue = document.getElementById('btn-auto-start-continue');
+const autoHintText = document.querySelector('.auto-hint');
 const STEP_DEFAULT_STATUSES = {
   1: 'pending',
   2: 'pending',
@@ -88,9 +102,19 @@ let cloudflareDomainEditMode = false;
 let modalChoiceResolver = null;
 let currentModalActions = [];
 let scheduledCountdownTimer = null;
+let hotmailActionInFlight = false;
+let hotmailListExpanded = false;
 
 const EYE_OPEN_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z"/><circle cx="12" cy="12" r="3"/></svg>';
 const EYE_CLOSED_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17.94 17.94A10.94 10.94 0 0 1 12 19C5 19 1 12 1 12a21.77 21.77 0 0 1 5.06-6.94"/><path d="M9.9 4.24A10.94 10.94 0 0 1 12 5c7 0 11 7 11 7a21.86 21.86 0 0 1-2.16 3.19"/><path d="M1 1l22 22"/><path d="M14.12 14.12a3 3 0 1 1-4.24-4.24"/></svg>';
+const COPY_ICON = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
+const parseHotmailImportText = window.HotmailUtils?.parseHotmailImportText;
+const shouldClearHotmailCurrentSelection = window.HotmailUtils?.shouldClearHotmailCurrentSelection;
+const upsertHotmailAccountInList = window.HotmailUtils?.upsertHotmailAccountInList;
+const filterHotmailAccountsByUsage = window.HotmailUtils?.filterHotmailAccountsByUsage;
+const getHotmailBulkActionLabel = window.HotmailUtils?.getHotmailBulkActionLabel;
+const getHotmailListToggleLabel = window.HotmailUtils?.getHotmailListToggleLabel;
+const HOTMAIL_LIST_EXPANDED_STORAGE_KEY = 'multipage-hotmail-list-expanded';
 
 // ============================================================
 // Toast Notifications
@@ -707,12 +731,227 @@ function getEmailGeneratorUiCopy() {
   };
 }
 
+function getHotmailAccounts(state = latestState) {
+  return Array.isArray(state?.hotmailAccounts) ? state.hotmailAccounts : [];
+}
+
+function getCurrentHotmailAccount(state = latestState) {
+  const currentId = state?.currentHotmailAccountId;
+  return getHotmailAccounts(state).find((account) => account.id === currentId) || null;
+}
+
+function getHotmailAccountsByUsage(mode = 'all', state = latestState) {
+  const accounts = getHotmailAccounts(state);
+  if (typeof filterHotmailAccountsByUsage === 'function') {
+    return filterHotmailAccountsByUsage(accounts, mode);
+  }
+  if (mode === 'used') {
+    return accounts.filter((account) => Boolean(account?.used));
+  }
+  return accounts.slice();
+}
+
+function getHotmailBulkActionText(mode, count) {
+  if (typeof getHotmailBulkActionLabel === 'function') {
+    return getHotmailBulkActionLabel(mode, count);
+  }
+  const normalizedCount = Number.isFinite(Number(count)) ? Math.max(0, Number(count)) : 0;
+  const prefix = mode === 'used' ? '清空已用' : '全部删除';
+  const suffix = normalizedCount > 0 ? `(${normalizedCount})` : '';
+  return `${prefix}${suffix}`;
+}
+
+function getHotmailListToggleText(expanded, count) {
+  if (typeof getHotmailListToggleLabel === 'function') {
+    return getHotmailListToggleLabel(expanded, count);
+  }
+  const normalizedCount = Number.isFinite(Number(count)) ? Math.max(0, Number(count)) : 0;
+  const suffix = normalizedCount > 0 ? `(${normalizedCount})` : '';
+  return `${expanded ? '收起列表' : '展开列表'}${suffix}`;
+}
+
+function updateHotmailListViewport() {
+  const count = getHotmailAccounts().length;
+  const usedCount = getHotmailAccountsByUsage('used').length;
+  if (btnClearUsedHotmailAccounts) {
+    btnClearUsedHotmailAccounts.textContent = getHotmailBulkActionText('used', usedCount);
+    btnClearUsedHotmailAccounts.disabled = usedCount === 0;
+  }
+  if (btnDeleteAllHotmailAccounts) {
+    btnDeleteAllHotmailAccounts.textContent = getHotmailBulkActionText('all', count);
+    btnDeleteAllHotmailAccounts.disabled = count === 0;
+  }
+  if (btnToggleHotmailList) {
+    btnToggleHotmailList.textContent = getHotmailListToggleText(hotmailListExpanded, count);
+    btnToggleHotmailList.setAttribute('aria-expanded', String(hotmailListExpanded));
+    btnToggleHotmailList.disabled = count === 0;
+  }
+  if (hotmailListShell) {
+    hotmailListShell.classList.toggle('is-expanded', hotmailListExpanded);
+    hotmailListShell.classList.toggle('is-collapsed', !hotmailListExpanded);
+  }
+}
+
+function setHotmailListExpanded(expanded, options = {}) {
+  const { persist = true } = options;
+  hotmailListExpanded = Boolean(expanded);
+  updateHotmailListViewport();
+  if (persist) {
+    localStorage.setItem(HOTMAIL_LIST_EXPANDED_STORAGE_KEY, hotmailListExpanded ? '1' : '0');
+  }
+}
+
+function initHotmailListExpandedState() {
+  const saved = localStorage.getItem(HOTMAIL_LIST_EXPANDED_STORAGE_KEY);
+  setHotmailListExpanded(saved === '1', { persist: false });
+}
+
+function shouldClearCurrentHotmailSelectionLocally(account) {
+  if (typeof shouldClearHotmailCurrentSelection === 'function') {
+    return shouldClearHotmailCurrentSelection(account);
+  }
+  return Boolean(account) && account.used === true;
+}
+
+function upsertHotmailAccountListLocally(accounts, nextAccount) {
+  if (typeof upsertHotmailAccountInList === 'function') {
+    return upsertHotmailAccountInList(accounts, nextAccount);
+  }
+
+  const list = Array.isArray(accounts) ? accounts.slice() : [];
+  if (!nextAccount?.id) return list;
+
+  const existingIndex = list.findIndex((account) => account?.id === nextAccount.id);
+  if (existingIndex === -1) {
+    list.push(nextAccount);
+    return list;
+  }
+
+  list[existingIndex] = nextAccount;
+  return list;
+}
+
+function refreshHotmailSelectionUI() {
+  renderHotmailAccounts();
+  if (selectMailProvider.value === 'hotmail-api') {
+    const currentAccount = getCurrentHotmailAccount();
+    inputEmail.value = currentAccount?.email || latestState?.email || '';
+  }
+}
+
+function applyHotmailAccountMutation(account, options = {}) {
+  if (!account?.id) return;
+  const { preserveCurrentSelection = false } = options;
+
+  const nextState = {
+    hotmailAccounts: upsertHotmailAccountListLocally(getHotmailAccounts(), account),
+  };
+
+  if (!preserveCurrentSelection
+    && latestState?.currentHotmailAccountId === account.id
+    && shouldClearCurrentHotmailSelectionLocally(account)) {
+    nextState.currentHotmailAccountId = null;
+    if (selectMailProvider.value === 'hotmail-api') {
+      nextState.email = null;
+    }
+  }
+
+  syncLatestState(nextState);
+  refreshHotmailSelectionUI();
+}
+
+function formatDateTime(timestamp) {
+  const value = Number(timestamp);
+  if (!Number.isFinite(value) || value <= 0) {
+    return '未使用';
+  }
+  return new Date(value).toLocaleString('zh-CN', { hour12: false });
+}
+
+function getHotmailAvailabilityLabel(account) {
+  if (account.used) return '已用';
+  return '可分配';
+}
+
+function getHotmailStatusLabel(account) {
+  if (account.used) return '已用';
+
+  switch (account.status) {
+    case 'authorized':
+      return '可用';
+    case 'error':
+      return '异常';
+    default:
+      return '待校验';
+  }
+}
+
+function getHotmailStatusClass(account) {
+  if (account.used) return 'status-used';
+  return `status-${account.status || 'pending'}`;
+}
+
+function clearHotmailForm() {
+  inputHotmailEmail.value = '';
+  inputHotmailClientId.value = '';
+  inputHotmailPassword.value = '';
+  inputHotmailRefreshToken.value = '';
+}
+
+function renderHotmailAccounts() {
+  if (!hotmailAccountsList) return;
+  const accounts = getHotmailAccounts();
+  const currentId = latestState?.currentHotmailAccountId || '';
+
+  if (!accounts.length) {
+    hotmailAccountsList.innerHTML = '<div class="hotmail-empty">还没有 Hotmail 账号,先添加一条再校验。</div>';
+    updateHotmailListViewport();
+    return;
+  }
+
+  hotmailAccountsList.innerHTML = accounts.map((account) => `
+    <div class="hotmail-account-item${account.id === currentId ? ' is-current' : ''}">
+      <div class="hotmail-account-top">
+        <div class="hotmail-account-title-row">
+          <div class="hotmail-account-email">${escapeHtml(account.email || '(未命名账号)')}</div>
+          <button
+            class="hotmail-copy-btn"
+            type="button"
+            data-account-action="copy-email"
+            data-account-id="${escapeHtml(account.id)}"
+            title="复制邮箱"
+            aria-label="复制邮箱 ${escapeHtml(account.email || '')}"
+          >${COPY_ICON}</button>
+        </div>
+        <span class="hotmail-status-chip ${escapeHtml(getHotmailStatusClass(account))}">${escapeHtml(getHotmailStatusLabel(account))}</span>
+      </div>
+      <div class="hotmail-account-meta">
+        <span>客户端 ID:${escapeHtml(account.clientId ? `${account.clientId.slice(0, 10)}...` : '未填写')}</span>
+        <span>刷新令牌:${account.refreshToken ? '已保存' : '未保存'}</span>
+        <span>分配状态: ${escapeHtml(getHotmailAvailabilityLabel(account))}</span>
+        <span>上次校验: ${escapeHtml(formatDateTime(account.lastAuthAt))}</span>
+        <span>上次使用: ${escapeHtml(formatDateTime(account.lastUsedAt))}</span>
+      </div>
+      ${account.lastError ? `<div class="hotmail-account-error">${escapeHtml(account.lastError)}</div>` : ''}
+      <div class="hotmail-account-actions">
+        <button class="btn btn-outline btn-sm" type="button" data-account-action="select" data-account-id="${escapeHtml(account.id)}">使用此账号</button>
+        <button class="btn btn-outline btn-sm" type="button" data-account-action="toggle-used" data-account-id="${escapeHtml(account.id)}">${account.used ? '标记未用' : '标记已用'}</button>
+        <button class="btn btn-primary btn-sm" type="button" data-account-action="verify" data-account-id="${escapeHtml(account.id)}">校验</button>
+        <button class="btn btn-outline btn-sm" type="button" data-account-action="test" data-account-id="${escapeHtml(account.id)}">复制最新验证码</button>
+        <button class="btn btn-ghost btn-sm" type="button" data-account-action="delete" data-account-id="${escapeHtml(account.id)}">删除</button>
+      </div>
+    </div>
+  `).join('');
+  updateHotmailListViewport();
+}
+
 function updateMailProviderUI() {
   const useInbucket = selectMailProvider.value === 'inbucket';
+  const useHotmail = selectMailProvider.value === 'hotmail-api';
   rowInbucketHost.style.display = useInbucket ? '' : 'none';
   rowInbucketMailbox.style.display = useInbucket ? '' : 'none';
   const useCloudflare = selectEmailGenerator.value === 'cloudflare';
-  rowCfDomain.style.display = useCloudflare ? '' : 'none';
+  rowCfDomain.style.display = !useHotmail && useCloudflare ? '' : 'none';
   const { domains } = getCloudflareDomainsFromState();
   if (useCloudflare) {
     setCloudflareDomainEditMode(cloudflareDomainEditMode || domains.length === 0, { clearInput: false });
@@ -720,11 +959,27 @@ function updateMailProviderUI() {
     setCloudflareDomainEditMode(false, { clearInput: false });
   }
 
+  if (hotmailSection) {
+    hotmailSection.style.display = useHotmail ? '' : 'none';
+  }
+  selectEmailGenerator.disabled = useHotmail;
+  btnFetchEmail.hidden = useHotmail;
+  inputEmail.readOnly = useHotmail;
   const uiCopy = getEmailGeneratorUiCopy();
-  inputEmail.placeholder = uiCopy.placeholder;
+  inputEmail.placeholder = useHotmail ? '由 Hotmail 账号池自动分配' : uiCopy.placeholder;
   if (!btnFetchEmail.disabled) {
     btnFetchEmail.textContent = uiCopy.buttonLabel;
   }
+  if (autoHintText) {
+    autoHintText.textContent = useHotmail
+      ? '请先校验并选择一个 Hotmail 账号'
+      : '先自动获取邮箱,或手动粘贴邮箱后再继续';
+  }
+  if (useHotmail) {
+    const currentAccount = getCurrentHotmailAccount();
+    inputEmail.value = currentAccount?.email || latestState?.email || '';
+  }
+  renderHotmailAccounts();
 }
 
 async function saveCloudflareDomainSettings(domains, activeDomain, options = {}) {
@@ -981,6 +1236,70 @@ function syncToggleButtonLabel(button, input, labels) {
   button.title = isHidden ? labels.show : labels.hide;
 }
 
+async function copyTextToClipboard(text) {
+  const value = String(text || '').trim();
+  if (!value) {
+    throw new Error('没有可复制的内容。');
+  }
+  if (!navigator.clipboard?.writeText) {
+    throw new Error('当前环境不支持剪贴板复制。');
+  }
+  await navigator.clipboard.writeText(value);
+}
+
+async function deleteHotmailAccountsByMode(mode) {
+  const isUsedMode = mode === 'used';
+  const targetAccounts = getHotmailAccountsByUsage(isUsedMode ? 'used' : 'all');
+  if (!targetAccounts.length) {
+    showToast(isUsedMode ? '没有已用账号可清空。' : '没有可删除的 Hotmail 账号。', 'warn');
+    return;
+  }
+
+  const confirmed = await openConfirmModal({
+    title: isUsedMode ? '清空已用账号' : '全部删除账号',
+    message: isUsedMode
+      ? `确认删除当前 ${targetAccounts.length} 个已用 Hotmail 账号吗?`
+      : `确认删除全部 ${targetAccounts.length} 个 Hotmail 账号吗?`,
+    confirmLabel: isUsedMode ? '确认清空已用' : '确认全部删除',
+    confirmVariant: isUsedMode ? 'btn-outline' : 'btn-danger',
+  });
+  if (!confirmed) {
+    return;
+  }
+
+  const response = await chrome.runtime.sendMessage({
+    type: 'DELETE_HOTMAIL_ACCOUNTS',
+    source: 'sidepanel',
+    payload: { mode: isUsedMode ? 'used' : 'all' },
+  });
+
+  if (response?.error) {
+    throw new Error(response.error);
+  }
+
+  const targetIds = new Set(targetAccounts.map((account) => account.id));
+  const nextAccounts = isUsedMode
+    ? getHotmailAccounts().filter((account) => !targetIds.has(account.id))
+    : [];
+  const nextState = { hotmailAccounts: nextAccounts };
+  if (latestState?.currentHotmailAccountId && targetIds.has(latestState.currentHotmailAccountId)) {
+    nextState.currentHotmailAccountId = null;
+    if (selectMailProvider.value === 'hotmail-api') {
+      nextState.email = null;
+    }
+  }
+  syncLatestState(nextState);
+  refreshHotmailSelectionUI();
+
+  showToast(
+    isUsedMode
+      ? `已清空 ${response.deletedCount || 0} 个已用 Hotmail 账号`
+      : `已删除全部 ${response.deletedCount || 0} 个 Hotmail 账号`,
+    'success',
+    2200
+  );
+}
+
 function syncPasswordToggleLabel() {
   syncToggleButtonLabel(btnTogglePassword, inputPassword, {
     show: '显示密码',
@@ -1060,17 +1379,25 @@ document.querySelectorAll('.step-btn').forEach(btn => {
           syncLatestState({ customPassword: inputPassword.value });
         }
         let email = inputEmail.value.trim();
-        if (!email) {
-          try {
-            email = await fetchGeneratedEmail({ showFailureToast: false });
-          } catch (err) {
-            showToast(`自动获取失败:${err.message},请手动粘贴邮箱后重试。`, 'warn');
-            return;
+        if (selectMailProvider.value === 'hotmail-api') {
+          const response = await chrome.runtime.sendMessage({ type: 'EXECUTE_STEP', source: 'sidepanel', payload: { step } });
+          if (response?.error) {
+            throw new Error(response.error);
+          }
+        } else {
+          let email = inputEmail.value.trim();
+          if (!email) {
+            try {
+              email = await fetchGeneratedEmail({ showFailureToast: false });
+            } catch (err) {
+              showToast(`自动获取失败:${err.message},请手动粘贴邮箱后重试。`, 'warn');
+              return;
+            }
+          }
+          const response = await chrome.runtime.sendMessage({ type: 'EXECUTE_STEP', source: 'sidepanel', payload: { step, email } });
+          if (response?.error) {
+            throw new Error(response.error);
           }
-        }
-        const response = await chrome.runtime.sendMessage({ type: 'EXECUTE_STEP', source: 'sidepanel', payload: { step, email } });
-        if (response?.error) {
-          throw new Error(response.error);
         }
       } else {
         const response = await chrome.runtime.sendMessage({ type: 'EXECUTE_STEP', source: 'sidepanel', payload: { step } });
@@ -1085,9 +1412,234 @@ document.querySelectorAll('.step-btn').forEach(btn => {
 });
 
 btnFetchEmail.addEventListener('click', async () => {
+  if (selectMailProvider.value === 'hotmail-api') {
+    return;
+  }
   await fetchGeneratedEmail().catch(() => { });
 });
 
+btnToggleHotmailList?.addEventListener('click', () => {
+  setHotmailListExpanded(!hotmailListExpanded);
+});
+
+btnClearUsedHotmailAccounts?.addEventListener('click', async () => {
+  if (hotmailActionInFlight) return;
+  hotmailActionInFlight = true;
+  btnClearUsedHotmailAccounts.disabled = true;
+  try {
+    await deleteHotmailAccountsByMode('used');
+  } catch (err) {
+    showToast(err.message, 'error');
+  } finally {
+    hotmailActionInFlight = false;
+    updateHotmailListViewport();
+  }
+});
+
+btnDeleteAllHotmailAccounts?.addEventListener('click', async () => {
+  if (hotmailActionInFlight) return;
+  hotmailActionInFlight = true;
+  btnDeleteAllHotmailAccounts.disabled = true;
+  try {
+    await deleteHotmailAccountsByMode('all');
+  } catch (err) {
+    showToast(err.message, 'error');
+  } finally {
+    hotmailActionInFlight = false;
+    updateHotmailListViewport();
+  }
+});
+
+btnAddHotmailAccount?.addEventListener('click', async () => {
+  if (hotmailActionInFlight) return;
+
+  const email = inputHotmailEmail.value.trim();
+  const clientId = inputHotmailClientId.value.trim();
+  const refreshToken = inputHotmailRefreshToken.value.trim();
+  if (!email) {
+    showToast('请先填写 Hotmail 邮箱。', 'warn');
+    return;
+  }
+  if (!clientId) {
+    showToast('请先填写微软应用客户端 ID。', 'warn');
+    return;
+  }
+  if (!refreshToken) {
+    showToast('请先填写刷新令牌(refresh token)。', 'warn');
+    return;
+  }
+
+  hotmailActionInFlight = true;
+  btnAddHotmailAccount.disabled = true;
+
+  try {
+    const response = await chrome.runtime.sendMessage({
+      type: 'UPSERT_HOTMAIL_ACCOUNT',
+      source: 'sidepanel',
+      payload: {
+        email,
+        clientId,
+        password: inputHotmailPassword.value,
+        refreshToken,
+      },
+    });
+
+    if (response?.error) {
+      throw new Error(response.error);
+    }
+
+    showToast(`已保存 Hotmail 账号 ${email}`, 'success', 1800);
+    clearHotmailForm();
+  } catch (err) {
+    showToast(`保存 Hotmail 账号失败:${err.message}`, 'error');
+  } finally {
+    hotmailActionInFlight = false;
+    btnAddHotmailAccount.disabled = false;
+  }
+});
+
+btnImportHotmailAccounts?.addEventListener('click', async () => {
+  if (hotmailActionInFlight) return;
+  if (typeof parseHotmailImportText !== 'function') {
+    showToast('导入解析器未加载,请刷新扩展后重试。', 'error');
+    return;
+  }
+
+  const rawText = inputHotmailImport.value.trim();
+  if (!rawText) {
+    showToast('请先粘贴账号导入内容。', 'warn');
+    return;
+  }
+
+  const parsedAccounts = parseHotmailImportText(rawText);
+  if (!parsedAccounts.length) {
+    showToast('没有解析到有效账号,请检查格式是否为 账号----密码----ID----Token。', 'error');
+    return;
+  }
+
+  hotmailActionInFlight = true;
+  btnImportHotmailAccounts.disabled = true;
+
+  try {
+    for (const account of parsedAccounts) {
+      const response = await chrome.runtime.sendMessage({
+        type: 'UPSERT_HOTMAIL_ACCOUNT',
+        source: 'sidepanel',
+        payload: account,
+      });
+      if (response?.error) {
+        throw new Error(response.error);
+      }
+    }
+
+    inputHotmailImport.value = '';
+    showToast(`已导入 ${parsedAccounts.length} 条 Hotmail 账号`, 'success', 2200);
+  } catch (err) {
+    showToast(`批量导入失败:${err.message}`, 'error');
+  } finally {
+    hotmailActionInFlight = false;
+    btnImportHotmailAccounts.disabled = false;
+  }
+});
+
+hotmailAccountsList?.addEventListener('click', async (event) => {
+  const actionButton = event.target.closest('[data-account-action]');
+  if (!actionButton || hotmailActionInFlight) {
+    return;
+  }
+
+  const accountId = actionButton.dataset.accountId;
+  const action = actionButton.dataset.accountAction;
+  if (!accountId || !action) {
+    return;
+  }
+
+  const targetAccount = getHotmailAccounts().find((account) => account.id === accountId) || null;
+
+  hotmailActionInFlight = true;
+  actionButton.disabled = true;
+
+  try {
+    if (action === 'copy-email') {
+      if (!targetAccount?.email) throw new Error('未找到可复制的邮箱地址。');
+      await copyTextToClipboard(targetAccount.email);
+      showToast(`已复制 ${targetAccount.email}`, 'success', 1800);
+    } else if (action === 'select') {
+      const response = await chrome.runtime.sendMessage({
+        type: 'SELECT_HOTMAIL_ACCOUNT',
+        source: 'sidepanel',
+        payload: { accountId },
+      });
+      if (response?.error) throw new Error(response.error);
+      syncLatestState({ currentHotmailAccountId: response.account.id });
+      applyHotmailAccountMutation(response.account, { preserveCurrentSelection: true });
+      showToast(`已切换当前 Hotmail 账号为 ${response.account.email}`, 'success', 1800);
+    } else if (action === 'toggle-used') {
+      if (!targetAccount) throw new Error('未找到目标 Hotmail 账号。');
+      const response = await chrome.runtime.sendMessage({
+        type: 'PATCH_HOTMAIL_ACCOUNT',
+        source: 'sidepanel',
+        payload: {
+          accountId,
+          updates: { used: !targetAccount.used },
+        },
+      });
+      if (response?.error) throw new Error(response.error);
+      applyHotmailAccountMutation(response.account);
+      showToast(`账号 ${response.account.email} 已${response.account.used ? '标记为已用' : '恢复为未用'}`, 'success', 2200);
+    } else if (action === 'verify') {
+      const response = await chrome.runtime.sendMessage({
+        type: 'VERIFY_HOTMAIL_ACCOUNT',
+        source: 'sidepanel',
+        payload: { accountId },
+      });
+      if (response?.error) throw new Error(response.error);
+      applyHotmailAccountMutation(response.account, { preserveCurrentSelection: true });
+      showToast(`账号 ${response.account.email} 校验通过`, 'success', 2200);
+    } else if (action === 'test') {
+      const response = await chrome.runtime.sendMessage({
+        type: 'TEST_HOTMAIL_ACCOUNT',
+        source: 'sidepanel',
+        payload: { accountId },
+      });
+      if (response?.error) throw new Error(response.error);
+      applyHotmailAccountMutation(response.account, { preserveCurrentSelection: true });
+      if (response.latestCode) {
+        await copyTextToClipboard(response.latestCode);
+        const mailbox = response.latestMailbox ? `(${response.latestMailbox})` : '';
+        showToast(`已复制最新验证码 ${response.latestCode}${mailbox}`, 'success', 2600);
+      } else if (response.latestSubject) {
+        const mailbox = response.latestMailbox ? `(${response.latestMailbox})` : '';
+        showToast(`最新邮件${mailbox}没有验证码:${response.latestSubject}`, 'warn', 3200);
+      } else {
+        showToast('当前没有可读取的最新邮件。', 'warn', 2600);
+      }
+    } else if (action === 'delete') {
+      const confirmed = await openConfirmModal({
+        title: '删除账号',
+        message: '确认删除这个 Hotmail 账号吗?对应 token 也会一起移除。',
+        confirmLabel: '确认删除',
+        confirmVariant: 'btn-danger',
+      });
+      if (!confirmed) {
+        return;
+      }
+      const response = await chrome.runtime.sendMessage({
+        type: 'DELETE_HOTMAIL_ACCOUNT',
+        source: 'sidepanel',
+        payload: { accountId },
+      });
+      if (response?.error) throw new Error(response.error);
+      showToast('Hotmail 账号已删除', 'success', 1800);
+    }
+  } catch (err) {
+    showToast(err.message, 'error');
+  } finally {
+    hotmailActionInFlight = false;
+    actionButton.disabled = false;
+  }
+});
+
 btnTogglePassword.addEventListener('click', () => {
   inputPassword.type = inputPassword.type === 'password' ? 'text' : 'password';
   syncPasswordToggleLabel();
@@ -1208,8 +1760,15 @@ btnReset.addEventListener('click', async () => {
   }
 
   await chrome.runtime.sendMessage({ type: 'RESET', source: 'sidepanel' });
-  syncLatestState({ stepStatuses: STEP_DEFAULT_STATUSES });
-  syncAutoRunState({ autoRunning: false, autoRunPhase: 'idle', autoRunCurrentRun: 0, autoRunTotalRuns: 1, autoRunAttemptRun: 0, scheduledAutoRunAt: null });
+  syncLatestState({ stepStatuses: STEP_DEFAULT_STATUSES, currentHotmailAccountId: null, email: null });
+  syncAutoRunState({
+    autoRunning: false,
+    autoRunPhase: 'idle',
+    autoRunCurrentRun: 0,
+    autoRunTotalRuns: 1,
+    autoRunAttemptRun: 0,
+    scheduledAutoRunAt: null,
+  });
   displayOauthUrl.textContent = '等待中...';
   displayOauthUrl.classList.remove('has-value');
   displayLocalhostUrl.textContent = '等待中...';
@@ -1226,6 +1785,7 @@ btnReset.addEventListener('click', async () => {
   updateStopButtonState(false);
   updateButtonStates();
   updateProgressCounter();
+  renderHotmailAccounts();
 });
 
 // Clear log
@@ -1235,6 +1795,9 @@ btnClearLog.addEventListener('click', () => {
 
 // Save settings on change
 inputEmail.addEventListener('change', async () => {
+  if (selectMailProvider.value === 'hotmail-api') {
+    return;
+  }
   const email = inputEmail.value.trim();
   if (email) {
     await chrome.runtime.sendMessage({ type: 'SAVE_EMAIL', source: 'sidepanel', payload: { email } });
@@ -1411,24 +1974,32 @@ chrome.runtime.onMessage.addListener((message) => {
       applyAutoRunStatus(currentAutoRun);
       updateProgressCounter();
       updateButtonStates();
+      renderHotmailAccounts();
       break;
     }
 
     case 'DATA_UPDATED': {
       syncLatestState(message.payload);
-      if (message.payload.email) {
-        inputEmail.value = message.payload.email;
+      if (message.payload.email !== undefined) {
+        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.oauthUrl !== undefined) {
+        displayOauthUrl.textContent = message.payload.oauthUrl || '等待中...';
+        displayOauthUrl.classList.toggle('has-value', Boolean(message.payload.oauthUrl));
+      }
+      if (message.payload.localhostUrl !== undefined) {
+        displayLocalhostUrl.textContent = message.payload.localhostUrl || '等待中...';
+        displayLocalhostUrl.classList.toggle('has-value', Boolean(message.payload.localhostUrl));
       }
-      if (message.payload.localhostUrl) {
-        displayLocalhostUrl.textContent = message.payload.localhostUrl;
-        displayLocalhostUrl.classList.add('has-value');
+      if (message.payload.currentHotmailAccountId !== undefined || message.payload.hotmailAccounts !== undefined) {
+        renderHotmailAccounts();
+        if (selectMailProvider.value === 'hotmail-api') {
+          const currentAccount = getCurrentHotmailAccount();
+          inputEmail.value = currentAccount?.email || latestState?.email || '';
+        }
       }
       if (message.payload.autoRunDelayEnabled !== undefined) {
         inputAutoDelayEnabled.checked = Boolean(message.payload.autoRunDelayEnabled);
@@ -1488,6 +2059,7 @@ btnTheme.addEventListener('click', () => {
 
 initializeManualStepActions();
 initTheme();
+initHotmailListExpandedState();
 updateSaveButtonState();
 restoreState().then(() => {
   syncPasswordToggleLabel();

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

@@ -0,0 +1,83 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+
+const {
+  getActivationStrategy,
+  isRecoverableStep9AuthFailure,
+} = require('../content/activation-utils.js');
+
+test('getActivationStrategy prefers requestSubmit for submit buttons inside forms', () => {
+  assert.deepEqual(
+    getActivationStrategy({
+      tagName: 'button',
+      type: 'submit',
+      hasForm: true,
+      pathname: '/email-verification',
+    }),
+    { method: 'requestSubmit' }
+  );
+});
+
+test('getActivationStrategy uses native click for non-submit actions', () => {
+  assert.deepEqual(
+    getActivationStrategy({
+      tagName: 'button',
+      type: 'button',
+      hasForm: true,
+    }),
+    { method: 'click' }
+  );
+
+  assert.deepEqual(
+    getActivationStrategy({
+      tagName: 'a',
+      type: '',
+      hasForm: false,
+    }),
+    { method: 'click' }
+  );
+});
+
+test('getActivationStrategy only uses requestSubmit on email verification routes', () => {
+  assert.deepEqual(
+    getActivationStrategy({
+      tagName: 'button',
+      type: 'submit',
+      hasForm: true,
+      pathname: '/u/signup/details',
+    }),
+    { method: 'click' }
+  );
+
+  assert.deepEqual(
+    getActivationStrategy({
+      tagName: 'button',
+      type: 'submit',
+      hasForm: true,
+      pathname: '/email-verification',
+    }),
+    { method: 'requestSubmit' }
+  );
+});
+
+test('isRecoverableStep9AuthFailure matches timeout and CPA auth failure statuses', () => {
+  assert.equal(
+    isRecoverableStep9AuthFailure('认证失败: Timeout waiting for OAuth callback'),
+    true
+  );
+
+  assert.equal(
+    isRecoverableStep9AuthFailure('认证失败: Request failed with status code 502'),
+    true
+  );
+
+  assert.equal(
+    isRecoverableStep9AuthFailure('认证成功!'),
+    false
+  );
+
+  assert.equal(
+    isRecoverableStep9AuthFailure('等待中'),
+    false
+  );
+});

+ 485 - 0
tests/hotmail-utils.test.js

@@ -0,0 +1,485 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+
+const {
+  buildHotmailGraphMessagesUrl,
+  extractVerificationCodeFromMessage,
+  filterHotmailAccountsByUsage,
+  extractVerificationCode,
+  getLatestHotmailMessage,
+  getHotmailBulkActionLabel,
+  getHotmailListToggleLabel,
+  getHotmailGraphRequestConfig,
+  getHotmailVerificationPollConfig,
+  getHotmailVerificationRequestTimestamp,
+  normalizeHotmailMailboxId,
+  normalizeHotmailMailApiMessages,
+  parseHotmailImportText,
+  pickHotmailAccountForRun,
+  pickVerificationMessage,
+  pickVerificationMessageWithFallback,
+  pickVerificationMessageWithTimeFallback,
+  shouldClearHotmailCurrentSelection,
+  upsertHotmailAccountInList,
+} = require('../hotmail-utils.js');
+
+test('pickHotmailAccountForRun prefers authorized account with oldest lastUsedAt', () => {
+  const now = Date.UTC(2026, 3, 10, 10, 0, 0);
+  const accounts = [
+    {
+      id: 'recent',
+      email: 'recent@hotmail.com',
+      status: 'authorized',
+      refreshToken: 'rt-recent',
+      lastUsedAt: now - 1_000,
+    },
+    {
+      id: 'oldest',
+      email: 'oldest@hotmail.com',
+      status: 'authorized',
+      refreshToken: 'rt-oldest',
+      lastUsedAt: now - 50_000,
+    },
+    {
+      id: 'pending',
+      email: 'pending@hotmail.com',
+      status: 'pending',
+      refreshToken: '',
+      lastUsedAt: 0,
+    },
+  ];
+
+  const selected = pickHotmailAccountForRun(accounts, { now });
+
+  assert.equal(selected.id, 'oldest');
+});
+
+test('pickHotmailAccountForRun skips used accounts but ignores legacy enabled flag', () => {
+  const accounts = [
+    {
+      id: 'disabled',
+      email: 'disabled@hotmail.com',
+      status: 'authorized',
+      refreshToken: 'rt-disabled',
+      enabled: false,
+      used: false,
+      lastUsedAt: 1,
+    },
+    {
+      id: 'used',
+      email: 'used@hotmail.com',
+      status: 'authorized',
+      refreshToken: 'rt-used',
+      enabled: true,
+      used: true,
+      lastUsedAt: 0,
+    },
+    {
+      id: 'available',
+      email: 'available@hotmail.com',
+      status: 'authorized',
+      refreshToken: 'rt-available',
+      enabled: true,
+      used: false,
+      lastUsedAt: 2,
+    },
+  ];
+
+  const selected = pickHotmailAccountForRun(accounts, {});
+
+  assert.equal(selected.id, 'disabled');
+});
+
+test('pickHotmailAccountForRun returns null for used-only pools', () => {
+  assert.equal(
+    pickHotmailAccountForRun([{
+      id: 'used-only',
+      email: 'used-only@hotmail.com',
+      status: 'authorized',
+      refreshToken: 'rt-used-only',
+      used: true,
+      lastUsedAt: 0,
+    }], {}),
+    null
+  );
+});
+
+test('pickHotmailAccountForRun falls back to never-used authorized account first', () => {
+  const accounts = [
+    {
+      id: 'used',
+      email: 'used@hotmail.com',
+      status: 'authorized',
+      refreshToken: 'rt-used',
+      lastUsedAt: Date.UTC(2026, 3, 10, 9, 0, 0),
+    },
+    {
+      id: 'fresh',
+      email: 'fresh@hotmail.com',
+      status: 'authorized',
+      refreshToken: 'rt-fresh',
+      lastUsedAt: 0,
+    },
+  ];
+
+  const selected = pickHotmailAccountForRun(accounts, { now: Date.UTC(2026, 3, 10, 10, 0, 0) });
+
+  assert.equal(selected.id, 'fresh');
+});
+
+test('upsertHotmailAccountInList replaces matching account state by id', () => {
+  const accounts = [
+    {
+      id: 'active',
+      email: 'active@hotmail.com',
+      status: 'authorized',
+      used: false,
+    },
+    {
+      id: 'other',
+      email: 'other@hotmail.com',
+      status: 'authorized',
+      used: false,
+    },
+  ];
+
+  const nextAccounts = upsertHotmailAccountInList(accounts, {
+    id: 'active',
+    email: 'active@hotmail.com',
+    status: 'authorized',
+    used: true,
+  });
+
+  assert.deepEqual(nextAccounts, [
+    {
+      id: 'active',
+      email: 'active@hotmail.com',
+      status: 'authorized',
+      used: true,
+    },
+    {
+      id: 'other',
+      email: 'other@hotmail.com',
+      status: 'authorized',
+      used: false,
+    },
+  ]);
+});
+
+test('shouldClearHotmailCurrentSelection returns true only when account becomes used', () => {
+  assert.equal(shouldClearHotmailCurrentSelection({
+    id: 'used',
+    used: true,
+  }), true);
+
+  assert.equal(shouldClearHotmailCurrentSelection({
+    id: 'available',
+    used: false,
+  }), false);
+});
+
+test('extractVerificationCode returns first six-digit code from multilingual mail text', () => {
+  assert.equal(extractVerificationCode('你的 ChatGPT 验证码为 370794,请勿泄露。'), '370794');
+  assert.equal(extractVerificationCode('Your verification code is 654321.'), '654321');
+  assert.equal(extractVerificationCode('No code here'), null);
+});
+
+test('extractVerificationCodeFromMessage reads code from the latest message subject or preview', () => {
+  assert.equal(
+    extractVerificationCodeFromMessage({
+      subject: '你的 ChatGPT 代码为 192742',
+      bodyPreview: 'OpenAI 验证邮件',
+      from: { emailAddress: { address: 'noreply@openai.com' } },
+    }),
+    '192742'
+  );
+
+  assert.equal(
+    extractVerificationCodeFromMessage({
+      subject: 'OpenAI security message',
+      bodyPreview: 'Your verification code is 654321.',
+      from: { emailAddress: { address: 'noreply@openai.com' } },
+    }),
+    '654321'
+  );
+});
+
+test('getHotmailListToggleLabel reflects expanded state and account count', () => {
+  assert.equal(getHotmailListToggleLabel(false, 0), '展开列表');
+  assert.equal(getHotmailListToggleLabel(false, 7), '展开列表(7)');
+  assert.equal(getHotmailListToggleLabel(true, 7), '收起列表(7)');
+});
+
+test('filterHotmailAccountsByUsage can pick only used accounts or return all accounts', () => {
+  const accounts = [
+    { id: 'used-1', email: 'used-1@hotmail.com', used: true },
+    { id: 'fresh-1', email: 'fresh-1@hotmail.com', used: false },
+    { id: 'used-2', email: 'used-2@hotmail.com', used: true },
+  ];
+
+  assert.deepEqual(
+    filterHotmailAccountsByUsage(accounts, 'used').map((account) => account.id),
+    ['used-1', 'used-2']
+  );
+
+  assert.deepEqual(
+    filterHotmailAccountsByUsage(accounts, 'all').map((account) => account.id),
+    ['used-1', 'fresh-1', 'used-2']
+  );
+});
+
+test('getHotmailBulkActionLabel reflects action type and count', () => {
+  assert.equal(getHotmailBulkActionLabel('used', 0), '清空已用');
+  assert.equal(getHotmailBulkActionLabel('used', 3), '清空已用(3)');
+  assert.equal(getHotmailBulkActionLabel('all', 5), '全部删除(5)');
+});
+
+test('getLatestHotmailMessage picks the newest received mail', () => {
+  const latest = getLatestHotmailMessage([
+    {
+      id: 'older',
+      subject: 'older',
+      receivedDateTime: '2026-04-11T00:01:00.000Z',
+    },
+    {
+      id: 'newest',
+      subject: 'newest',
+      receivedDateTime: '2026-04-11T00:05:00.000Z',
+    },
+    {
+      id: 'middle',
+      subject: 'middle',
+      receivedDateTime: '2026-04-11T00:03:00.000Z',
+    },
+  ]);
+
+  assert.equal(latest.id, 'newest');
+});
+
+test('pickVerificationMessage filters by time, sender, subject, and excluded codes', () => {
+  const messages = [
+    {
+      id: 'old-mail',
+      subject: 'Your code is 111111',
+      from: { emailAddress: { address: 'noreply@openai.com' } },
+      bodyPreview: '111111',
+      receivedDateTime: '2026-04-10T09:00:00.000Z',
+    },
+    {
+      id: 'wrong-sender',
+      subject: 'Your code is 222222',
+      from: { emailAddress: { address: 'noreply@example.com' } },
+      bodyPreview: '222222',
+      receivedDateTime: '2026-04-10T10:01:00.000Z',
+    },
+    {
+      id: 'good-mail',
+      subject: 'ChatGPT verification code 333333',
+      from: { emailAddress: { address: 'noreply@openai.com' } },
+      bodyPreview: 'Use 333333 to continue',
+      receivedDateTime: '2026-04-10T10:02:00.000Z',
+    },
+    {
+      id: 'excluded-mail',
+      subject: 'ChatGPT verification code 444444',
+      from: { emailAddress: { address: 'noreply@openai.com' } },
+      bodyPreview: 'Use 444444 to continue',
+      receivedDateTime: '2026-04-10T10:03:00.000Z',
+    },
+  ];
+
+  const match = pickVerificationMessage(messages, {
+    afterTimestamp: Date.UTC(2026, 3, 10, 10, 0, 0),
+    senderFilters: ['openai', 'noreply'],
+    subjectFilters: ['verification', 'code', 'chatgpt'],
+    excludeCodes: ['444444'],
+  });
+
+  assert.equal(match.message.id, 'good-mail');
+  assert.equal(match.code, '333333');
+});
+
+test('pickVerificationMessageWithFallback no longer matches arbitrary recent mails when filters miss', () => {
+  const messages = [
+    {
+      id: 'login-mail',
+      subject: 'Use this security code to continue 555666',
+      from: { emailAddress: { address: 'account-security@openai.com' } },
+      bodyPreview: 'Your one-time security code is 555666',
+      receivedDateTime: '2026-04-10T10:05:00.000Z',
+    },
+  ];
+
+  const result = pickVerificationMessageWithFallback(messages, {
+    afterTimestamp: Date.UTC(2026, 3, 10, 10, 0, 0),
+    senderFilters: ['noreply'],
+    subjectFilters: ['verification'],
+    excludeCodes: [],
+  });
+
+  assert.equal(result.match, null);
+  assert.equal(result.usedRelaxedFilters, false);
+  assert.equal(result.usedTimeFallback, false);
+});
+
+test('pickVerificationMessageWithTimeFallback can ignore afterTimestamp while keeping sender and subject filters', () => {
+  const messages = [
+    {
+      id: 'slightly-old-mail',
+      subject: '你的 ChatGPT 代码为 141735',
+      from: { emailAddress: { address: 'unknown' } },
+      bodyPreview: 'OpenAI logo ...',
+      receivedDateTime: '2026-04-10T10:00:02.000Z',
+    },
+  ];
+
+  const result = pickVerificationMessageWithTimeFallback(messages, {
+    afterTimestamp: Date.UTC(2026, 3, 10, 10, 0, 10),
+    senderFilters: ['openai', 'noreply'],
+    subjectFilters: ['verify', 'verification', 'code'],
+    excludeCodes: [],
+  });
+
+  assert.equal(result.match.message.id, 'slightly-old-mail');
+  assert.equal(result.match.code, '141735');
+  assert.equal(result.usedRelaxedFilters, false);
+  assert.equal(result.usedTimeFallback, true);
+});
+
+test('buildHotmailGraphMessagesUrl targets the official Microsoft Graph mailbox endpoint', () => {
+  const url = new URL(buildHotmailGraphMessagesUrl({
+    mailbox: 'Junk',
+  }));
+
+  assert.equal(url.origin + url.pathname, 'https://graph.microsoft.com/v1.0/me/mailFolders/junkemail/messages');
+  assert.equal(url.searchParams.get('$top'), '10');
+  assert.equal(url.searchParams.get('$orderby'), 'receivedDateTime desc');
+  assert.match(url.searchParams.get('$select'), /subject/);
+  assert.match(url.searchParams.get('$select'), /receivedDateTime/);
+});
+
+test('normalizeHotmailMailboxId maps supported mailbox labels to Graph folder ids', () => {
+  assert.equal(normalizeHotmailMailboxId('INBOX'), 'inbox');
+  assert.equal(normalizeHotmailMailboxId('Junk'), 'junkemail');
+  assert.equal(normalizeHotmailMailboxId('junkemail'), 'junkemail');
+});
+
+test('normalizeHotmailMailApiMessages maps third-party payload fields into verification message shape', () => {
+  const messages = normalizeHotmailMailApiMessages([
+    {
+      id: 'mail-1',
+      from: 'noreply@openai.com',
+      subject: 'ChatGPT verification code',
+      text: 'Use 135790 to continue',
+      date: '2026-04-10T10:02:00.000Z',
+    },
+    {
+      message_id: 'mail-2',
+      sender_email: 'alerts@example.com',
+      title: 'Ignored',
+      body: 'No code here',
+      received_at: '2026-04-10T10:03:00.000Z',
+    },
+  ]);
+
+  assert.deepEqual(messages, [
+    {
+      id: 'mail-1',
+      subject: 'ChatGPT verification code',
+      from: { emailAddress: { address: 'noreply@openai.com' } },
+      bodyPreview: 'Use 135790 to continue',
+      receivedDateTime: '2026-04-10T10:02:00.000Z',
+    },
+    {
+      id: 'mail-2',
+      subject: 'Ignored',
+      from: { emailAddress: { address: 'alerts@example.com' } },
+      bodyPreview: 'No code here',
+      receivedDateTime: '2026-04-10T10:03:00.000Z',
+    },
+  ]);
+});
+
+test('getHotmailVerificationPollConfig gives Hotmail a slower initial wait and longer polling window', () => {
+  assert.deepEqual(getHotmailVerificationPollConfig(4), {
+    initialDelayMs: 5000,
+    maxAttempts: 12,
+    intervalMs: 5000,
+    requestFreshCodeFirst: false,
+    ignorePersistedLastCode: true,
+  });
+
+  assert.deepEqual(getHotmailVerificationPollConfig(7), {
+    initialDelayMs: 5000,
+    maxAttempts: 12,
+    intervalMs: 5000,
+    requestFreshCodeFirst: false,
+    ignorePersistedLastCode: true,
+  });
+});
+
+test('getHotmailVerificationRequestTimestamp prefers actual request timestamps with a safety buffer', () => {
+  const signupRequestedAt = Date.UTC(2026, 3, 10, 12, 0, 30);
+  const loginRequestedAt = Date.UTC(2026, 3, 10, 12, 5, 45);
+
+  assert.equal(
+    getHotmailVerificationRequestTimestamp(4, {
+      signupVerificationRequestedAt: signupRequestedAt,
+      flowStartTime: signupRequestedAt - 60_000,
+    }),
+    signupRequestedAt - 15_000
+  );
+
+  assert.equal(
+    getHotmailVerificationRequestTimestamp(7, {
+      loginVerificationRequestedAt: loginRequestedAt,
+      lastEmailTimestamp: loginRequestedAt - 120_000,
+      flowStartTime: loginRequestedAt - 300_000,
+    }),
+    loginRequestedAt - 15_000
+  );
+});
+
+test('getHotmailGraphRequestConfig defines Microsoft Graph request defaults', () => {
+  assert.deepEqual(getHotmailGraphRequestConfig(), {
+    timeoutMs: 15000,
+    pageSize: 10,
+    scopes: [
+      'offline_access',
+      'https://graph.microsoft.com/Mail.Read',
+      'https://graph.microsoft.com/User.Read',
+    ],
+    tokenUrl: 'https://login.microsoftonline.com/consumers/oauth2/v2.0/token',
+    messageFields: [
+      'id',
+      'internetMessageId',
+      'subject',
+      'from',
+      'bodyPreview',
+      'receivedDateTime',
+    ],
+  });
+});
+
+test('parseHotmailImportText parses account lines in email----password----clientId----token format', () => {
+  const parsed = parseHotmailImportText(`
+账号----密码----ID----Token
+JohnRodriguez5425@hotmail.com----nb4ta1OK----9e5f94bc-e8a4-4e73-b8be-63364c29d753----refresh-token-1
+alice@hotmail.com----pass-2----client-2----refresh-token-2
+  `.trim());
+
+  assert.deepEqual(parsed, [
+    {
+      email: 'JohnRodriguez5425@hotmail.com',
+      password: 'nb4ta1OK',
+      clientId: '9e5f94bc-e8a4-4e73-b8be-63364c29d753',
+      refreshToken: 'refresh-token-1',
+    },
+    {
+      email: 'alice@hotmail.com',
+      password: 'pass-2',
+      clientId: 'client-2',
+      refreshToken: 'refresh-token-2',
+    },
+  ]);
+});

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio