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

feat: add hotmail mail api account pool flow

baiyuechu 4 месяцев назад
Родитель
Сommit
8a9d69d325

+ 31 - 5
README.md

@@ -2,7 +2,7 @@
 
 一个用于批量跑通 ChatGPT OAuth 注册/登录流程的 Chrome 扩展。
 
-当前版本基于侧边栏控制,支持单步执行、整套自动执行、停止当前流程、保存常用配置,以及通过 DuckDuckGo / QQ / 163 / Inbucket mailbox 协助获取验证码。
+当前版本基于侧边栏控制,支持单步执行、整套自动执行、停止当前流程、保存常用配置,以及通过 DuckDuckGo / QQ / 163 / Inbucket / Hotmail API 协助获取验证码。
 
 ## 当前能力
 
@@ -12,6 +12,7 @@
 - 支持自定义密码;留空时自动生成强密码
 - 自动显示当前使用中的密码,便于后续保存
 - 自动获取注册验证码与登录验证码
+- 支持 `Hotmail API`:直接使用 `email + client_id + refresh_token` 请求第三方接口读取最新邮件
 - 支持 `QQ Mail`、`163 Mail`、`Inbucket mailbox`
 - 支持从 DuckDuckGo Email Protection 自动生成新的 `@duck.com` 地址
 - Step 5 同时兼容两种页面:
@@ -54,17 +55,37 @@ Step 1 和 Step 9 都依赖这个地址。
 
 ### `Mail`
 
-支持种验证码来源:
+支持种验证码来源:
 
+- `Hotmail API`
 - `163 Mail`
 - `QQ Mail`
 - `Inbucket`
 
 说明:
 
+- `Hotmail API` 通过侧边栏里的 Hotmail 账号池选择账号,并请求 `https://apple.882263.xyz/api/mail-new`
 - `QQ` 和 `163` 用于直接轮询网页邮箱
 - `Inbucket` 通过你在侧边栏里配置的 host 访问 `mailbox` 页面:`https://<your-inbucket-host>/m/<mailbox>/`
 
+### `Hotmail 账号池`
+
+仅当 `Mail = Hotmail API` 时使用。
+
+每条账号支持保存:
+
+- `email`
+- `clientId`
+- `refreshToken`
+- 可选邮箱密码备注
+
+使用方式:
+
+- 先新增账号
+- 点击 `校验`
+- 校验通过后,可点击 `测试收信`
+- Auto 模式每轮会自动选用一个可用账号
+
 ### `Mailbox`
 
 仅当 `Mail = Inbucket` 时显示。
@@ -109,6 +130,7 @@ Step 3 使用的注册邮箱。
 
 注意:
 
+- 当 `Mail = Hotmail API` 时,这个输入框由账号池自动同步当前账号邮箱
 - 当前 `Auto` 按钮只负责 DuckDuckGo 地址获取
 - 如果你使用 Inbucket,它只是验证码收件箱,不会自动生成 Inbucket 地址
 
@@ -156,9 +178,10 @@ Step 3 使用的注册邮箱。
 
 1. Step 1 获取 CPA OAuth 链接
 2. Step 2 打开 OpenAI 注册页
-3. 尝试自动获取 Duck 邮箱
-4. 如果 Duck 自动获取失败,暂停并等待你在侧边栏填写邮箱后点击 `Continue`
-5. 继续执行 Step 3 ~ Step 9
+3. 根据 `Mail` 选择邮箱来源
+4. `Hotmail API` 会从账号池自动分配一个可用账号
+5. Duck 模式下会优先尝试自动获取邮箱;失败时暂停等待手动填写
+6. 继续执行 Step 3 ~ Step 9
 
 也就是说:
 
@@ -207,6 +230,7 @@ Step 3 使用的注册邮箱。
 
 支持:
 
+- `Hotmail API`(第三方最新邮件接口)
 - `content/qq-mail.js`
 - `content/mail-163.js`
 - `content/inbucket-mail.js`
@@ -318,6 +342,7 @@ Step 3 使用的注册邮箱。
 - 邮箱服务
 - Inbucket 主机
 - Inbucket 邮箱名
+- Hotmail 账号池与对应 token
 - 兜底开关
 
 特点:
@@ -337,6 +362,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 API 相关通用 helper
 content/duck-mail.js       Duck 邮箱自动获取
 content/qq-mail.js         QQ 邮箱验证码轮询
 content/mail-163.js        163 邮箱验证码轮询

+ 652 - 52
background.js

@@ -1,9 +1,30 @@
 // background.js — Service Worker: orchestration, state, tab management, message routing
 
-importScripts('data/names.js');
+importScripts('data/names.js', 'hotmail-utils.js', 'content/activation-utils.js');
+
+const {
+  buildHotmailMailApiLatestUrl,
+  extractVerificationCodeFromMessage,
+  filterHotmailAccountsByUsage,
+  getLatestHotmailMessage,
+  getHotmailMailApiRequestConfig,
+  getHotmailVerificationPollConfig,
+  getHotmailVerificationRequestTimestamp,
+  normalizeHotmailMailApiMessages,
+  pickHotmailAccountForRun,
+  pickVerificationMessage,
+  pickVerificationMessageWithFallback,
+  pickVerificationMessageWithTimeFallback,
+  shouldClearHotmailCurrentSelection,
+} = self.HotmailUtils;
+const {
+  isRecoverableStep9AuthFailure,
+} = self.MultiPageActivationUtils;
 
 const LOG_PREFIX = '[MultiPage:bg]';
 const DUCK_AUTOFILL_URL = 'https://duckduckgo.com/email/settings/autofill';
+const HOTMAIL_PROVIDER = 'hotmail-api';
+const HOTMAIL_MAILBOXES = ['INBOX', 'Junk'];
 const STOP_ERROR_MESSAGE = '流程已被用户停止。';
 const HUMAN_STEP_DELAY_MIN = 700;
 const HUMAN_STEP_DELAY_MAX = 2200;
@@ -23,6 +44,7 @@ const PERSISTED_SETTING_DEFAULTS = {
   mailProvider: '163',
   inbucketHost: '',
   inbucketMailbox: '',
+  hotmailAccounts: [],
 };
 
 const PERSISTED_SETTING_KEYS = Object.keys(PERSISTED_SETTING_DEFAULTS);
@@ -38,10 +60,13 @@ const DEFAULT_STATE = {
   password: null,
   accounts: [], // { email, password, createdAt }
   lastEmailTimestamp: null,
+  signupVerificationRequestedAt: null,
+  loginVerificationRequestedAt: null,
   lastSignupCode: null,
   lastLoginCode: null,
   localhostUrl: null,
   flowStartTime: null,
+  currentHotmailAccountId: null,
   tabRegistry: {},
   sourceLastUrls: {},
   logs: [],
@@ -59,6 +84,7 @@ async function getPersistedSettings() {
     ...PERSISTED_SETTING_DEFAULTS,
     ...stored,
     autoRunSkipFailures: Boolean(stored.autoRunSkipFailures ?? PERSISTED_SETTING_DEFAULTS.autoRunSkipFailures),
+    hotmailAccounts: normalizeHotmailAccounts(stored.hotmailAccounts),
   };
 }
 
@@ -92,9 +118,13 @@ async function setPersistentSettings(updates) {
   const persistedUpdates = {};
   for (const key of PERSISTED_SETTING_KEYS) {
     if (updates[key] !== undefined) {
-      persistedUpdates[key] = key === 'autoRunSkipFailures'
-        ? Boolean(updates[key])
-        : updates[key];
+      if (key === 'autoRunSkipFailures') {
+        persistedUpdates[key] = Boolean(updates[key]);
+      } else if (key === 'hotmailAccounts') {
+        persistedUpdates[key] = normalizeHotmailAccounts(updates[key]);
+      } else {
+        persistedUpdates[key] = updates[key];
+      }
     }
   }
 
@@ -174,6 +204,408 @@ function generatePassword() {
   return pw.split('').sort(() => Math.random() - 0.5).join('');
 }
 
+function normalizeHotmailAccount(account = {}) {
+  return {
+    id: String(account.id || crypto.randomUUID()),
+    email: String(account.email || '').trim(),
+    password: String(account.password || ''),
+    clientId: String(account.clientId || '').trim(),
+    accessToken: String(account.accessToken || ''),
+    refreshToken: String(account.refreshToken || ''),
+    expiresAt: Number.isFinite(Number(account.expiresAt)) ? Number(account.expiresAt) : 0,
+    status: String(account.status || (account.refreshToken ? 'authorized' : 'pending')),
+    enabled: account.enabled !== undefined ? Boolean(account.enabled) : true,
+    used: Boolean(account.used),
+    lastUsedAt: Number.isFinite(Number(account.lastUsedAt)) ? Number(account.lastUsedAt) : 0,
+    lastAuthAt: Number.isFinite(Number(account.lastAuthAt)) ? Number(account.lastAuthAt) : 0,
+    lastError: String(account.lastError || ''),
+  };
+}
+
+function normalizeHotmailAccounts(accounts) {
+  if (!Array.isArray(accounts)) return [];
+
+  const deduped = new Map();
+  for (const account of accounts) {
+    const normalized = normalizeHotmailAccount(account);
+    if (!normalized.email && !normalized.id) continue;
+    deduped.set(normalized.id, normalized);
+  }
+  return [...deduped.values()];
+}
+
+function findHotmailAccount(accounts, accountId) {
+  return normalizeHotmailAccounts(accounts).find((account) => account.id === accountId) || null;
+}
+
+function isHotmailProvider(stateOrProvider) {
+  const provider = typeof stateOrProvider === 'string'
+    ? stateOrProvider
+    : stateOrProvider?.mailProvider;
+  return provider === HOTMAIL_PROVIDER;
+}
+
+async function syncHotmailAccounts(accounts) {
+  const normalized = normalizeHotmailAccounts(accounts);
+  await setPersistentSettings({ hotmailAccounts: normalized });
+  await setState({ hotmailAccounts: normalized });
+  broadcastDataUpdate({ hotmailAccounts: normalized });
+  return normalized;
+}
+
+async function upsertHotmailAccount(input) {
+  const state = await getState();
+  const accounts = normalizeHotmailAccounts(state.hotmailAccounts);
+  const normalizedEmail = String(input?.email || '').trim().toLowerCase();
+  const existing = input?.id
+    ? findHotmailAccount(accounts, input.id)
+    : accounts.find((account) => account.email.toLowerCase() === normalizedEmail) || null;
+  const normalized = normalizeHotmailAccount({
+    ...(existing || {}),
+    ...input,
+    id: input?.id || existing?.id || crypto.randomUUID(),
+  });
+
+  const nextAccounts = existing
+    ? accounts.map((account) => (account.id === normalized.id ? normalized : account))
+    : [...accounts, normalized];
+
+  await syncHotmailAccounts(nextAccounts);
+  return normalized;
+}
+
+async function deleteHotmailAccount(accountId) {
+  const state = await getState();
+  const accounts = normalizeHotmailAccounts(state.hotmailAccounts);
+  const nextAccounts = accounts.filter((account) => account.id !== accountId);
+  await syncHotmailAccounts(nextAccounts);
+
+  if (state.currentHotmailAccountId === accountId) {
+    await setState({ currentHotmailAccountId: null });
+    if (isHotmailProvider(state)) {
+      await setEmailState(null);
+    }
+    broadcastDataUpdate({ currentHotmailAccountId: null });
+  }
+}
+
+async function deleteHotmailAccounts(mode = 'all') {
+  const state = await getState();
+  const accounts = normalizeHotmailAccounts(state.hotmailAccounts);
+  const targets = filterHotmailAccountsByUsage(accounts, mode);
+  const targetIds = new Set(targets.map((account) => account.id));
+  const nextAccounts = mode === 'used'
+    ? accounts.filter((account) => !targetIds.has(account.id))
+    : [];
+
+  await syncHotmailAccounts(nextAccounts);
+
+  if (state.currentHotmailAccountId && targetIds.has(state.currentHotmailAccountId)) {
+    await setState({ currentHotmailAccountId: null });
+    if (isHotmailProvider(state)) {
+      await setEmailState(null);
+    }
+    broadcastDataUpdate({ currentHotmailAccountId: null });
+  }
+
+  return {
+    deletedCount: targets.length,
+    remainingCount: nextAccounts.length,
+  };
+}
+
+async function patchHotmailAccount(accountId, updates = {}) {
+  const state = await getState();
+  const accounts = normalizeHotmailAccounts(state.hotmailAccounts);
+  const account = findHotmailAccount(accounts, accountId);
+  if (!account) {
+    throw new Error('未找到对应的 Hotmail 账号。');
+  }
+
+  const nextAccount = normalizeHotmailAccount({
+    ...account,
+    ...updates,
+    id: account.id,
+  });
+
+  await syncHotmailAccounts(accounts.map((item) => (item.id === account.id ? nextAccount : item)));
+
+  if (state.currentHotmailAccountId === account.id && shouldClearHotmailCurrentSelection(nextAccount)) {
+    await setState({ currentHotmailAccountId: null });
+    broadcastDataUpdate({ currentHotmailAccountId: null });
+    if (isHotmailProvider(state)) {
+      await setEmailState(null);
+    }
+  }
+
+  return nextAccount;
+}
+
+async function setCurrentHotmailAccount(accountId, options = {}) {
+  const { markUsed = false, syncEmail = true } = options;
+  const state = await getState();
+  const accounts = normalizeHotmailAccounts(state.hotmailAccounts);
+  const account = findHotmailAccount(accounts, accountId);
+  if (!account) {
+    throw new Error('未找到对应的 Hotmail 账号。');
+  }
+
+  if (markUsed) {
+    account.lastUsedAt = Date.now();
+    await syncHotmailAccounts(accounts.map((item) => (item.id === account.id ? account : item)));
+  }
+
+  await setState({ currentHotmailAccountId: account.id });
+  broadcastDataUpdate({ currentHotmailAccountId: account.id });
+  if (syncEmail) {
+    await setEmailState(account.email || null);
+  }
+  return account;
+}
+
+async function ensureHotmailAccountForFlow(options = {}) {
+  const { allowAllocate = true, markUsed = false, preferredAccountId = null } = options;
+  const state = await getState();
+  const accounts = normalizeHotmailAccounts(state.hotmailAccounts);
+  const isAccountAllocatable = (candidate) => Boolean(candidate)
+    && candidate.status === 'authorized'
+    && !candidate.used
+    && Boolean(candidate.refreshToken);
+
+  let account = null;
+  if (preferredAccountId) {
+    account = findHotmailAccount(accounts, preferredAccountId);
+  }
+  if (!account && state.currentHotmailAccountId) {
+    account = findHotmailAccount(accounts, state.currentHotmailAccountId);
+  }
+  if ((!account || !isAccountAllocatable(account)) && allowAllocate) {
+    account = pickHotmailAccountForRun(accounts, {});
+  }
+
+  if (!account) {
+    throw new Error('没有可用的 Hotmail 账号。请先在侧边栏添加至少一个带 refresh token 的账号。');
+  }
+  if (!isAccountAllocatable(account)) {
+    throw new Error(`Hotmail 账号 ${account.email || account.id} 尚未就绪,无法读取邮件。`);
+  }
+
+  return setCurrentHotmailAccount(account.id, { markUsed, syncEmail: true });
+}
+
+async function requestHotmailMailApi(account, mailbox = 'INBOX') {
+  if (!account?.email) {
+    throw new Error('Hotmail 账号缺少邮箱地址。');
+  }
+  if (!account?.clientId) {
+    throw new Error(`Hotmail 账号 ${account.email || account.id} 缺少 client ID。`);
+  }
+  if (!account?.refreshToken) {
+    throw new Error(`Hotmail 账号 ${account.email || account.id} 缺少 refresh token。`);
+  }
+
+  const url = buildHotmailMailApiLatestUrl({
+    clientId: account.clientId,
+    email: account.email,
+    refreshToken: account.refreshToken,
+    mailbox,
+    responseType: 'json',
+  });
+  const { timeoutMs } = getHotmailMailApiRequestConfig();
+  const controller = new AbortController();
+  const timeoutId = setTimeout(() => controller.abort(new Error('timeout')), timeoutMs);
+
+  let response;
+  try {
+    response = await fetch(url, { method: 'GET', signal: controller.signal });
+  } catch (err) {
+    if (err?.name === 'AbortError') {
+      throw new Error(`Hotmail API 请求超时(>${Math.round(timeoutMs / 1000)} 秒):${mailbox}`);
+    }
+    throw new Error(`Hotmail API 请求失败:${err.message}`);
+  } finally {
+    clearTimeout(timeoutId);
+  }
+
+  const text = await response.text();
+  let payload = {};
+  try {
+    payload = text ? JSON.parse(text) : {};
+  } catch {
+    payload = { raw: text };
+  }
+
+  if (!response.ok) {
+    const errorText = payload?.message || payload?.error || payload?.msg || text || `HTTP ${response.status}`;
+    throw new Error(`Hotmail API 请求失败:${errorText}`);
+  }
+
+  if (payload && payload.success === false) {
+    const errorText = payload?.message || payload?.msg || payload?.error || '未知错误';
+    throw new Error(`Hotmail API 返回失败:${errorText}`);
+  }
+
+  return {
+    mailbox,
+    payload,
+    messages: normalizeHotmailMailApiMessages(payload?.data),
+    nextRefreshToken: String(payload?.new_refresh_token || payload?.newRefreshToken || '').trim(),
+  };
+}
+
+function applyHotmailApiResultToAccount(account, apiResult) {
+  const nextRefreshToken = String(apiResult?.nextRefreshToken || '').trim();
+  return {
+    ...account,
+    accessToken: '',
+    expiresAt: 0,
+    refreshToken: nextRefreshToken || account.refreshToken,
+    status: 'authorized',
+    lastAuthAt: Date.now(),
+    lastError: '',
+  };
+}
+
+async function fetchHotmailMailboxMessages(account, mailboxes = HOTMAIL_MAILBOXES) {
+  let workingAccount = normalizeHotmailAccount(account);
+  const mailboxResults = [];
+
+  for (const mailbox of mailboxes) {
+    const result = await requestHotmailMailApi(workingAccount, mailbox);
+    workingAccount = applyHotmailApiResultToAccount(workingAccount, result);
+    mailboxResults.push({
+      mailbox,
+      count: result.messages.length,
+      messages: result.messages.map((message) => ({ ...message, mailbox })),
+    });
+  }
+
+  const savedAccount = await upsertHotmailAccount(workingAccount);
+  return {
+    account: savedAccount,
+    mailboxResults,
+    messages: mailboxResults.flatMap((item) => item.messages),
+  };
+}
+
+async function verifyHotmailAccount(accountId) {
+  const state = await getState();
+  const account = findHotmailAccount(state.hotmailAccounts, accountId);
+  if (!account) {
+    throw new Error('未找到需要校验的 Hotmail 账号。');
+  }
+
+  const result = await fetchHotmailMailboxMessages(account, ['INBOX']);
+  return {
+    account: result.account,
+    messageCount: result.mailboxResults[0]?.count || 0,
+  };
+}
+
+async function testHotmailAccountMailAccess(accountId) {
+  const state = await getState();
+  const account = findHotmailAccount(state.hotmailAccounts, accountId);
+  if (!account) {
+    throw new Error('未找到需要测试的 Hotmail 账号。');
+  }
+
+  const result = await fetchHotmailMailboxMessages(account, HOTMAIL_MAILBOXES);
+  const latestMessage = getLatestHotmailMessage(result.messages);
+  const latestCode = latestMessage ? extractVerificationCodeFromMessage(latestMessage) : null;
+
+  return {
+    account: result.account,
+    accountId: result.account.id,
+    email: result.account.email,
+    messageCount: result.messages.length,
+    latestSubject: latestMessage?.subject || '',
+    latestMailbox: latestMessage?.mailbox || '',
+    latestCode: latestCode || '',
+    inboxCount: result.mailboxResults.find((item) => item.mailbox === 'INBOX')?.count || 0,
+    junkCount: result.mailboxResults.find((item) => item.mailbox === 'Junk')?.count || 0,
+  };
+}
+
+async function pollHotmailVerificationCode(step, state, pollPayload = {}) {
+  await addLog(`步骤 ${step}:正在确定 Hotmail 收信账号...`, 'info');
+  let account = await ensureHotmailAccountForFlow({
+    allowAllocate: true,
+    markUsed: false,
+    preferredAccountId: state.currentHotmailAccountId || null,
+  });
+  await addLog(`步骤 ${step}:当前使用 Hotmail 账号 ${account.email} 轮询收件箱。`, 'info');
+
+  const maxAttempts = Number(pollPayload.maxAttempts) || 5;
+  const intervalMs = Number(pollPayload.intervalMs) || 3000;
+  let lastError = null;
+
+  function summarizeMessagesForLog(messages) {
+    return (messages || [])
+      .slice()
+      .sort((left, right) => {
+        const leftTime = Date.parse(left.receivedDateTime || '') || 0;
+        const rightTime = Date.parse(right.receivedDateTime || '') || 0;
+        return rightTime - leftTime;
+      })
+      .slice(0, 3)
+      .map((message) => {
+        const receivedAt = message?.receivedDateTime || 'unknown-time';
+        const sender = message?.from?.emailAddress?.address || 'unknown';
+        const subject = message?.subject || '(no subject)';
+        const preview = String(message?.bodyPreview || '').replace(/\s+/g, ' ').trim().slice(0, 80);
+        return `[${message.mailbox || 'INBOX'}] ${receivedAt} | ${sender} | ${subject} | ${preview}`;
+      })
+      .join(' || ');
+  }
+
+  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
+    throwIfStopped();
+    try {
+      await addLog(`步骤 ${step}:正在轮询 Hotmail 邮件(${attempt}/${maxAttempts})...`, 'info');
+      const fetchResult = await fetchHotmailMailboxMessages(account, HOTMAIL_MAILBOXES);
+      account = fetchResult.account;
+      const matchResult = pickVerificationMessageWithTimeFallback(fetchResult.messages, {
+        afterTimestamp: pollPayload.filterAfterTimestamp || 0,
+        senderFilters: pollPayload.senderFilters || [],
+        subjectFilters: pollPayload.subjectFilters || [],
+        excludeCodes: pollPayload.excludeCodes || [],
+      });
+      const match = matchResult.match;
+
+      if (match?.code) {
+        const mailboxLabel = match.message?.mailbox || 'INBOX';
+        if (matchResult.usedRelaxedFilters) {
+          const fallbackLabel = matchResult.usedTimeFallback ? '宽松匹配 + 时间回退' : '宽松匹配';
+          await addLog(`步骤 ${step}:严格规则未命中,已改用 ${fallbackLabel} 并命中 Hotmail ${mailboxLabel} 验证码。`, 'warn');
+        }
+        await addLog(`步骤 ${step}:已在 Hotmail ${mailboxLabel} 中找到验证码:${match.code}`, 'ok');
+        return {
+          ok: true,
+          code: match.code,
+          emailTimestamp: match.receivedAt || Date.now(),
+          mailId: match.message?.id || '',
+        };
+      }
+
+      lastError = new Error(`步骤 ${step}:暂未在 Hotmail 收件箱中找到匹配验证码(${attempt}/${maxAttempts})。`);
+      await addLog(lastError.message, attempt === maxAttempts ? 'warn' : 'info');
+      const mailSummary = summarizeMessagesForLog(fetchResult.messages);
+      if (mailSummary) {
+        await addLog(`步骤 ${step}:最近邮件样本:${mailSummary}`, 'info');
+      }
+    } catch (err) {
+      lastError = err;
+      await addLog(`步骤 ${step}:Hotmail 收件箱轮询失败:${err.message}`, 'warn');
+    }
+
+    if (attempt < maxAttempts) {
+      await sleepWithStop(intervalMs);
+    }
+  }
+
+  throw lastError || new Error(`步骤 ${step}:未在 Hotmail 收件箱中找到新的匹配验证码。`);
+}
+
 // ============================================================
 // Tab Registry
 // ============================================================
@@ -614,6 +1046,7 @@ function getSourceLabel(source) {
     'mail-163': '163 邮箱',
     'inbucket-mail': 'Inbucket 邮箱',
     'duck-mail': 'Duck 邮箱',
+    'hotmail-api': 'Hotmail API',
   };
   return labels[source] || source || '未知来源';
 }
@@ -650,7 +1083,7 @@ function getErrorMessage(error) {
 
 function isVerificationMailPollingError(error) {
   const message = getErrorMessage(error);
-  return /未在 .*邮箱中找到新的匹配邮件|邮箱轮询结束,但未获取到验证码|无法获取新的(?:注册|登录)验证码|页面未能重新就绪|页面通信异常|did not respond in \d+s/i.test(message);
+  return /未在 .*邮箱中找到新的匹配邮件|未在 Hotmail 收件箱中找到新的匹配验证码|邮箱轮询结束,但未获取到验证码|无法获取新的(?:注册|登录)验证码|页面未能重新就绪|页面通信异常|did not respond in \d+s/i.test(message);
 }
 
 function isRestartCurrentAttemptError(error) {
@@ -658,7 +1091,13 @@ function isRestartCurrentAttemptError(error) {
   return /当前邮箱已存在,需要重新开始新一轮/.test(message);
 }
 
-function isStep9OAuthTimeoutError(error) {
+function isStep9RecoverableAuthError(error) {
+  const message = String(typeof error === 'string' ? error : error?.message || '');
+  return /STEP9_OAUTH_RETRY::/i.test(message)
+    || isRecoverableStep9AuthFailure(message);
+}
+
+function isLegacyStep9RecoverableAuthError(error) {
   const message = String(typeof error === 'string' ? error : error?.message || '');
   return /STEP9_OAUTH_TIMEOUT::|认证失败:\s*Timeout waiting for OAuth callback/i.test(message);
 }
@@ -687,6 +1126,8 @@ function getDownstreamStateResets(step) {
       flowStartTime: null,
       password: null,
       lastEmailTimestamp: null,
+      signupVerificationRequestedAt: null,
+      loginVerificationRequestedAt: null,
       lastSignupCode: null,
       lastLoginCode: null,
       localhostUrl: null,
@@ -696,6 +1137,8 @@ function getDownstreamStateResets(step) {
     return {
       password: null,
       lastEmailTimestamp: null,
+      signupVerificationRequestedAt: null,
+      loginVerificationRequestedAt: null,
       lastSignupCode: null,
       lastLoginCode: null,
       localhostUrl: null,
@@ -704,6 +1147,8 @@ function getDownstreamStateResets(step) {
   if (step === 3 || step === 4) {
     return {
       lastEmailTimestamp: null,
+      signupVerificationRequestedAt: null,
+      loginVerificationRequestedAt: null,
       lastSignupCode: null,
       lastLoginCode: null,
       localhostUrl: null,
@@ -712,6 +1157,7 @@ function getDownstreamStateResets(step) {
   if (step === 5 || step === 6 || step === 7) {
     return {
       lastLoginCode: null,
+      loginVerificationRequestedAt: null,
       localhostUrl: null,
     };
   }
@@ -1067,6 +1513,63 @@ async function handleMessage(message, sender) {
       return { ok: true };
     }
 
+    case 'UPSERT_HOTMAIL_ACCOUNT': {
+      const account = await upsertHotmailAccount(message.payload || {});
+      return { ok: true, account };
+    }
+
+    case 'DELETE_HOTMAIL_ACCOUNT': {
+      await deleteHotmailAccount(String(message.payload?.accountId || ''));
+      return { ok: true };
+    }
+
+    case 'DELETE_HOTMAIL_ACCOUNTS': {
+      const result = await deleteHotmailAccounts(String(message.payload?.mode || 'all'));
+      return { ok: true, ...result };
+    }
+
+    case 'SELECT_HOTMAIL_ACCOUNT': {
+      const account = await setCurrentHotmailAccount(String(message.payload?.accountId || ''), {
+        markUsed: false,
+        syncEmail: true,
+      });
+      return { ok: true, account };
+    }
+
+    case 'PATCH_HOTMAIL_ACCOUNT': {
+      const account = await patchHotmailAccount(
+        String(message.payload?.accountId || ''),
+        message.payload?.updates || {}
+      );
+      return { ok: true, account };
+    }
+
+    case 'VERIFY_HOTMAIL_ACCOUNT':
+    case 'AUTHORIZE_HOTMAIL_ACCOUNT': {
+      const accountId = String(message.payload?.accountId || '');
+      try {
+        const result = await verifyHotmailAccount(accountId);
+        await setCurrentHotmailAccount(result.account.id, { markUsed: false, syncEmail: true });
+        await addLog(`Hotmail 账号 ${result.account.email} 校验通过,可直接用于收信。`, 'ok');
+        return { ok: true, account: result.account, messageCount: result.messageCount };
+      } catch (err) {
+        const state = await getState();
+        const accounts = normalizeHotmailAccounts(state.hotmailAccounts);
+        const target = findHotmailAccount(accounts, accountId);
+        if (target) {
+          target.status = 'error';
+          target.lastError = err.message;
+          await syncHotmailAccounts(accounts.map((item) => (item.id === target.id ? target : item)));
+        }
+        throw err;
+      }
+    }
+
+    case 'TEST_HOTMAIL_ACCOUNT': {
+      const result = await testHotmailAccountMailAccess(String(message.payload?.accountId || ''));
+      return { ok: true, ...result };
+    }
+
     // Side panel data updates
     case 'SAVE_EMAIL': {
       const state = await getState();
@@ -1114,9 +1617,29 @@ async function handleStepData(step, payload) {
       break;
     case 3:
       if (payload.email) await setEmailState(payload.email);
+      if (payload.signupVerificationRequestedAt) {
+        await setState({ signupVerificationRequestedAt: payload.signupVerificationRequestedAt });
+      }
+      if (payload.loginVerificationRequestedAt) {
+        await setState({ loginVerificationRequestedAt: payload.loginVerificationRequestedAt });
+      }
+      break;
+    case 6:
+      if (payload.loginVerificationRequestedAt) {
+        await setState({ loginVerificationRequestedAt: payload.loginVerificationRequestedAt });
+      }
       break;
     case 4:
-      if (payload.emailTimestamp) await setState({ lastEmailTimestamp: payload.emailTimestamp });
+      await setState({
+        lastEmailTimestamp: payload.emailTimestamp || null,
+        signupVerificationRequestedAt: null,
+      });
+      break;
+    case 7:
+      await setState({
+        lastEmailTimestamp: payload.emailTimestamp || null,
+        loginVerificationRequestedAt: null,
+      });
       break;
     case 8:
       if (payload.localhostUrl) {
@@ -1125,6 +1648,14 @@ async function handleStepData(step, payload) {
       }
       break;
     case 9: {
+      const latestState = await getState();
+      if (latestState.currentHotmailAccountId && isHotmailProvider(latestState)) {
+        await patchHotmailAccount(latestState.currentHotmailAccountId, {
+          used: true,
+          lastUsedAt: Date.now(),
+        });
+        await addLog('当前 Hotmail 账号已自动标记为已用。', 'ok');
+      }
       const localhostPrefix = buildLocalhostCleanupPrefix(payload.localhostUrl);
       if (localhostPrefix) {
         await closeTabsByUrlPrefix(localhostPrefix);
@@ -1352,6 +1883,16 @@ async function resumeAutoRunIfWaitingForEmail(options = {}) {
 
 async function ensureAutoEmailReady(targetRun, totalRuns, attemptRuns) {
   const currentState = await getState();
+  if (isHotmailProvider(currentState)) {
+    const account = await ensureHotmailAccountForFlow({
+      allowAllocate: true,
+      markUsed: true,
+      preferredAccountId: null,
+    });
+    await addLog(`=== 目标 ${targetRun}/${totalRuns} 轮:已分配 Hotmail 账号 ${account.email}(第 ${attemptRuns} 次尝试)===`, 'ok');
+    return account.email;
+  }
+
   if (currentState.email) {
     return currentState.email;
   }
@@ -1430,14 +1971,23 @@ async function runAutoSequenceFromStep(startStep, context = {}) {
       await executeStepAndWait(step, AUTO_STEP_DELAYS[step]);
       step += 1;
     } catch (err) {
-      if (step === 9 && isStep9OAuthTimeoutError(err) && step9RestartAttempts < maxStep9RestartAttempts) {
+      const latestState = await getState();
+      const currentMail = getMailConfig(latestState);
+      const shouldRetryStep9 = step === 9
+        && (
+          isLegacyStep9RecoverableAuthError(err)
+          || (currentMail.provider === HOTMAIL_PROVIDER && isStep9RecoverableAuthError(err))
+        )
+        && step9RestartAttempts < maxStep9RestartAttempts;
+
+      if (shouldRetryStep9) {
         step9RestartAttempts += 1;
         await addLog(
-          `步骤 9:检测到 OAuth callback 超时,正在回到步骤 6 重新开始授权流程(${step9RestartAttempts}/${maxStep9RestartAttempts})...`,
+          `步骤 9:检测到 CPA 认证失败,正在回到步骤 6 重新开始授权流程(${step9RestartAttempts}/${maxStep9RestartAttempts})...`,
           'warn'
         );
         await invalidateDownstreamAfterStepRestart(6, {
-          logLabel: `步骤 9 超时后准备回到步骤 6 重试(${step9RestartAttempts}/${maxStep9RestartAttempts})`,
+          logLabel: `步骤 9 认证失败后准备回到步骤 6 重试(${step9RestartAttempts}/${maxStep9RestartAttempts})`,
         });
         step = 6;
         continue;
@@ -1696,7 +2246,7 @@ async function executeStep1(state) {
   }
   await addLog('步骤 1:正在打开 CPA 面板...');
   await reuseOrCreateTab('vps-panel', state.vpsUrl, {
-    inject: ['content/utils.js', 'content/vps-panel.js'],
+    inject: ['content/activation-utils.js', 'content/utils.js', 'content/vps-panel.js'],
     reloadIfSameUrl: true,
   });
 
@@ -1732,26 +2282,39 @@ async function executeStep2(state) {
 // ============================================================
 
 async function executeStep3(state) {
-  if (!state.email) {
+  let resolvedEmail = state.email;
+  if (isHotmailProvider(state)) {
+    const account = await ensureHotmailAccountForFlow({
+      allowAllocate: true,
+      markUsed: true,
+      preferredAccountId: state.currentHotmailAccountId || null,
+    });
+    resolvedEmail = account.email;
+  }
+
+  if (!resolvedEmail) {
     throw new Error('缺少邮箱地址,请先在侧边栏粘贴邮箱。');
   }
 
   const password = state.customPassword || generatePassword();
+  if (resolvedEmail !== state.email) {
+    await setEmailState(resolvedEmail);
+  }
   await setPasswordState(password);
 
   // Save account record
   const accounts = state.accounts || [];
-  accounts.push({ email: state.email, password, createdAt: new Date().toISOString() });
+  accounts.push({ email: resolvedEmail, password, createdAt: new Date().toISOString() });
   await setState({ accounts });
 
   await addLog(
-    `步骤 3:正在填写邮箱 ${state.email},密码为${state.customPassword ? '自定义' : '自动生成'}(${password.length} 位)`
+    `步骤 3:正在填写邮箱 ${resolvedEmail},密码为${state.customPassword ? '自定义' : '自动生成'}(${password.length} 位)`
   );
   await sendToContentScript('signup-page', {
     type: 'EXECUTE_STEP',
     step: 3,
     source: 'background',
-    payload: { email: state.email, password },
+    payload: { email: resolvedEmail, password },
   });
 }
 
@@ -1761,6 +2324,9 @@ async function executeStep3(state) {
 
 function getMailConfig(state) {
   const provider = state.mailProvider || 'qq';
+  if (provider === HOTMAIL_PROVIDER) {
+    return { provider: HOTMAIL_PROVIDER, label: 'Hotmail API' };
+  }
   if (provider === '163') {
     return { source: 'mail-163', url: 'https://mail.163.com/js6/main.jsp?df=mail163_letter#module=mbox.ListModule%7C%7B%22fid%22%3A1%2C%22order%22%3A%22date%22%2C%22desc%22%3Atrue%7D', label: '163 邮箱' };
   }
@@ -1778,7 +2344,7 @@ function getMailConfig(state) {
       url: `${host}/m/${encodeURIComponent(mailbox)}/`,
       label: `Inbucket 邮箱(${mailbox})`,
       navigateOnReuse: true,
-      inject: ['content/utils.js', 'content/inbucket-mail.js'],
+      inject: ['content/activation-utils.js', 'content/utils.js', 'content/inbucket-mail.js'],
       injectSource: 'inbucket-mail',
     };
   }
@@ -1810,7 +2376,7 @@ function getVerificationCodeLabel(step) {
 function getVerificationPollPayload(step, state, overrides = {}) {
   if (step === 4) {
     return {
-      filterAfterTimestamp: state.flowStartTime || 0,
+      filterAfterTimestamp: getHotmailVerificationRequestTimestamp(4, state),
       senderFilters: ['openai', 'noreply', 'verify', 'auth', 'duckduckgo', 'forward'],
       subjectFilters: ['verify', 'verification', 'code', '楠岃瘉', 'confirm'],
       targetEmail: state.email,
@@ -1821,7 +2387,7 @@ function getVerificationPollPayload(step, state, overrides = {}) {
   }
 
   return {
-    filterAfterTimestamp: state.lastEmailTimestamp || state.flowStartTime || 0,
+    filterAfterTimestamp: getHotmailVerificationRequestTimestamp(7, state),
     senderFilters: ['openai', 'noreply', 'verify', 'auth', 'chatgpt', 'duckduckgo', 'forward'],
     subjectFilters: ['verify', 'verification', 'code', '楠岃瘉', 'confirm', 'login'],
     targetEmail: state.email,
@@ -1855,6 +2421,15 @@ async function requestVerificationCodeResend(step) {
 }
 
 async function pollFreshVerificationCode(step, state, mail, pollOverrides = {}) {
+  if (mail.provider === HOTMAIL_PROVIDER) {
+    const hotmailPollConfig = getHotmailVerificationPollConfig(step);
+    return pollHotmailVerificationCode(step, state, {
+      ...getVerificationPollPayload(step, state),
+      ...hotmailPollConfig,
+      ...pollOverrides,
+    });
+  }
+
   const stateKey = getVerificationCodeStateKey(step);
   const rejectedCodes = new Set();
   if (state[stateKey]) {
@@ -1943,12 +2518,18 @@ async function submitVerificationCode(step, code) {
 async function resolveVerificationStep(step, state, mail, options = {}) {
   const stateKey = getVerificationCodeStateKey(step);
   const rejectedCodes = new Set();
-  if (state[stateKey]) {
+  const hotmailPollConfig = mail.provider === HOTMAIL_PROVIDER
+    ? getHotmailVerificationPollConfig(step)
+    : null;
+  const ignorePersistedLastCode = Boolean(hotmailPollConfig?.ignorePersistedLastCode);
+  if (state[stateKey] && !ignorePersistedLastCode) {
     rejectedCodes.add(state[stateKey]);
   }
 
   const nextFilterAfterTimestamp = options.filterAfterTimestamp ?? null;
-  const requestFreshCodeFirst = Boolean(options.requestFreshCodeFirst);
+  const requestFreshCodeFirst = options.requestFreshCodeFirst !== undefined
+    ? Boolean(options.requestFreshCodeFirst)
+    : (hotmailPollConfig?.requestFreshCodeFirst ?? false);
   const maxSubmitAttempts = 3;
 
   if (requestFreshCodeFirst) {
@@ -1960,6 +2541,14 @@ async function resolveVerificationStep(step, state, mail, options = {}) {
     }
   }
 
+  if (mail.provider === HOTMAIL_PROVIDER) {
+    const initialDelayMs = Number(options.initialDelayMs ?? hotmailPollConfig.initialDelayMs) || 0;
+    if (initialDelayMs > 0) {
+      await addLog(`步骤 ${step}:等待 ${Math.round(initialDelayMs / 1000)} 秒,让 Hotmail 验证码邮件先到达...`, 'info');
+      await sleepWithStop(initialDelayMs);
+    }
+  }
+
   for (let attempt = 1; attempt <= maxSubmitAttempts; attempt++) {
     const result = await pollFreshVerificationCode(step, state, mail, {
       excludeCodes: [...rejectedCodes],
@@ -2024,35 +2613,42 @@ async function executeStep4(state) {
   if (prepareResult && prepareResult.error) {
     throw new Error(prepareResult.error);
   }
+  if (prepareResult?.verificationRequestedAt) {
+    await setState({ loginVerificationRequestedAt: prepareResult.verificationRequestedAt });
+  }
   if (prepareResult?.alreadyVerified) {
     await completeStepFromBackground(4, {});
     return;
   }
 
-  await addLog(`步骤 4:正在打开${mail.label}...`);
-
-  // For mail tabs, only create if not alive — don't navigate (preserves login session)
-  const alive = await isTabAlive(mail.source);
-  if (alive) {
-    if (mail.navigateOnReuse) {
+  if (mail.provider === HOTMAIL_PROVIDER) {
+    await addLog(`步骤 4:正在通过 ${mail.label} 轮询验证码...`);
+  } else {
+    await addLog(`步骤 4:正在打开${mail.label}...`);
+
+    // For mail tabs, only create if not alive — don't navigate (preserves login session)
+    const alive = await isTabAlive(mail.source);
+    if (alive) {
+      if (mail.navigateOnReuse) {
+        await reuseOrCreateTab(mail.source, mail.url, {
+          inject: mail.inject,
+          injectSource: mail.injectSource,
+        });
+      } else {
+        const tabId = await getTabId(mail.source);
+        await chrome.tabs.update(tabId, { active: true });
+      }
+    } else {
       await reuseOrCreateTab(mail.source, mail.url, {
         inject: mail.inject,
         injectSource: mail.injectSource,
       });
-    } else {
-      const tabId = await getTabId(mail.source);
-      await chrome.tabs.update(tabId, { active: true });
     }
-  } else {
-    await reuseOrCreateTab(mail.source, mail.url, {
-      inject: mail.inject,
-      injectSource: mail.injectSource,
-    });
   }
 
   await resolveVerificationStep(4, state, mail, {
-    filterAfterTimestamp: stepStartedAt,
-    requestFreshCodeFirst: true,
+    filterAfterTimestamp: mail.provider === HOTMAIL_PROVIDER ? undefined : stepStartedAt,
+    requestFreshCodeFirst: mail.provider === HOTMAIL_PROVIDER ? false : true,
   });
   return;
 }
@@ -2148,29 +2744,33 @@ async function runStep7Attempt(state) {
     throw new Error(prepareResult.error);
   }
 
-  await addLog(`步骤 7:正在打开${mail.label}...`);
-
-  const alive = await isTabAlive(mail.source);
-  if (alive) {
-    if (mail.navigateOnReuse) {
+  if (mail.provider === HOTMAIL_PROVIDER) {
+    await addLog(`步骤 7:正在通过 ${mail.label} 轮询验证码...`);
+  } else {
+    await addLog(`步骤 7:正在打开${mail.label}...`);
+
+    const alive = await isTabAlive(mail.source);
+    if (alive) {
+      if (mail.navigateOnReuse) {
+        await reuseOrCreateTab(mail.source, mail.url, {
+          inject: mail.inject,
+          injectSource: mail.injectSource,
+        });
+      } else {
+        const tabId = await getTabId(mail.source);
+        await chrome.tabs.update(tabId, { active: true });
+      }
+    } else {
       await reuseOrCreateTab(mail.source, mail.url, {
         inject: mail.inject,
         injectSource: mail.injectSource,
       });
-    } else {
-      const tabId = await getTabId(mail.source);
-      await chrome.tabs.update(tabId, { active: true });
     }
-  } else {
-    await reuseOrCreateTab(mail.source, mail.url, {
-      inject: mail.inject,
-      injectSource: mail.injectSource,
-    });
   }
 
   await resolveVerificationStep(7, state, mail, {
-    filterAfterTimestamp: stepStartedAt,
-    requestFreshCodeFirst: true,
+    filterAfterTimestamp: mail.provider === HOTMAIL_PROVIDER ? undefined : stepStartedAt,
+    requestFreshCodeFirst: mail.provider === HOTMAIL_PROVIDER ? false : true,
   });
 }
 
@@ -2345,7 +2945,7 @@ async function executeStep9(state) {
   // Inject scripts directly and wait for them to be ready
   await chrome.scripting.executeScript({
     target: { tabId },
-    files: ['content/utils.js', 'content/vps-panel.js'],
+    files: ['content/activation-utils.js', 'content/utils.js', 'content/vps-panel.js'],
   });
   await new Promise(r => setTimeout(r, 1000));
 

+ 48 - 0
content/activation-utils.js

@@ -0,0 +1,48 @@
+(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) {
+    return /认证失败:\s*/i.test(String(statusText || '').trim());
+  }
+
+  return {
+    getActivationStrategy,
+    isRecoverableStep9AuthFailure,
+  };
+});

+ 7 - 5
content/signup-page.js

@@ -215,9 +215,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) {
@@ -351,15 +352,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;
@@ -292,9 +294,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) || ''}"`);
 }
 
 /**

+ 13 - 0
content/vps-panel.js

@@ -25,6 +25,10 @@
 
 console.log('[MultiPage:vps-panel] Content script loaded on', location.href);
 
+const {
+  isRecoverableStep9AuthFailure,
+} = self.MultiPageActivationUtils || {};
+
 // Listen for commands from Background
 chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
   if (message.type === 'EXECUTE_STEP') {
@@ -111,6 +115,12 @@ async function waitForExactSuccessBadge(timeout = 30000) {
     if (statusText === '认证成功!') {
       return statusText;
     }
+    if (isOAuthCallbackTimeoutFailure(statusText)) {
+      throw new Error(`STEP9_OAUTH_TIMEOUT::${statusText}`);
+    }
+    if (typeof isRecoverableStep9AuthFailure === 'function' && isRecoverableStep9AuthFailure(statusText)) {
+      throw new Error(`STEP9_OAUTH_RETRY::${statusText}`);
+    }
     await sleep(200);
   }
 
@@ -118,6 +128,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`.

+ 392 - 0
hotmail-utils.js

@@ -0,0 +1,392 @@
+(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_MAIL_API_URL = 'https://apple.882263.xyz/api/mail-new';
+
+  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);
+    if (strictMatch) {
+      return {
+        match: strictMatch,
+        usedRelaxedFilters: false,
+        usedTimeFallback: false,
+      };
+    }
+
+    const relaxedMatch = pickVerificationMessage(messages, {
+      afterTimestamp: filters.afterTimestamp,
+      excludeCodes: filters.excludeCodes,
+      senderFilters: [],
+      subjectFilters: [],
+    });
+
+    return {
+      match: relaxedMatch || null,
+      usedRelaxedFilters: Boolean(relaxedMatch),
+      usedTimeFallback: false,
+    };
+
+    /* c8 ignore start */
+  }
+
+  function pickVerificationMessageWithTimeFallback(messages, filters = {}) {
+    const strictOrRelaxedResult = pickVerificationMessageWithFallback(messages, filters);
+    if (strictOrRelaxedResult.match) {
+      return strictOrRelaxedResult;
+    }
+
+    const timeFallbackMatch = pickVerificationMessage(messages, {
+      afterTimestamp: 0,
+      excludeCodes: filters.excludeCodes,
+      senderFilters: [],
+      subjectFilters: [],
+    });
+
+    return {
+      match: timeFallbackMatch || null,
+      usedRelaxedFilters: Boolean(timeFallbackMatch),
+      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 buildHotmailMailApiLatestUrl(options) {
+    const url = new URL(HOTMAIL_MAIL_API_URL);
+    url.searchParams.set('refresh_token', String(options?.refreshToken || ''));
+    url.searchParams.set('client_id', String(options?.clientId || ''));
+    url.searchParams.set('email', String(options?.email || ''));
+    url.searchParams.set('mailbox', String(options?.mailbox || 'INBOX'));
+    url.searchParams.set('response_type', String(options?.responseType || 'json'));
+    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 getHotmailMailApiRequestConfig() {
+    return {
+      timeoutMs: 15000,
+    };
+  }
+
+  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 {
+    buildHotmailMailApiLatestUrl,
+    extractVerificationCodeFromMessage,
+    filterHotmailAccountsByUsage,
+    extractVerificationCode,
+    getLatestHotmailMessage,
+    getHotmailBulkActionLabel,
+    getHotmailListToggleLabel,
+    getHotmailMailApiRequestConfig,
+    getHotmailVerificationPollConfig,
+    getHotmailVerificationRequestTimestamp,
+    isAuthorizedHotmailAccount,
+    normalizeHotmailMailApiMessages,
+    normalizeTimestamp,
+    parseHotmailImportText,
+    pickHotmailAccountForRun,
+    pickVerificationMessage,
+    pickVerificationMessageWithFallback,
+    pickVerificationMessageWithTimeFallback,
+    shouldClearHotmailCurrentSelection,
+    upsertHotmailAccountInList,
+  };
+});

+ 4 - 4
manifest.json

@@ -28,7 +28,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"
     },
     {
@@ -36,7 +36,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"
     },
@@ -44,7 +44,7 @@
       "matches": [
         "https://mail.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"
     },
@@ -52,7 +52,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

@@ -55,6 +55,7 @@
       <div class="data-row">
         <span class="data-label">邮箱服务</span>
         <select id="select-mail-provider" class="data-select">
+          <option value="hotmail-api">Hotmail API(refresh_token)</option>
           <option value="163">163 邮箱 (mail.163.com)</option>
           <option value="qq">QQ 邮箱 (wx.mail.qq.com)</option>
           <option value="inbucket">Inbucket(自定义主机)</option>
@@ -101,6 +102,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">Client ID</span>
+        <input type="text" id="input-hotmail-client-id" class="data-input mono" placeholder="Microsoft app client 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">Refresh</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----Token&#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>
@@ -190,6 +234,7 @@
   </div>
 
   <div id="toast-container"></div>
+  <script src="../hotmail-utils.js"></script>
   <script src="sidepanel.js"></script>
 </body>
 </html>

+ 584 - 20
sidepanel/sidepanel.js

@@ -30,6 +30,19 @@ const btnClearLog = document.getElementById('btn-clear-log');
 const inputVpsUrl = document.getElementById('input-vps-url');
 const inputVpsPassword = document.getElementById('input-vps-password');
 const selectMailProvider = document.getElementById('select-mail-provider');
+const 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');
@@ -43,6 +56,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',
@@ -69,9 +83,19 @@ let settingsSaveInFlight = false;
 let settingsAutoSaveTimer = null;
 let modalChoiceResolver = null;
 let currentModalActions = [];
+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
@@ -478,10 +502,241 @@ function syncPasswordField(state) {
   inputPassword.value = state.customPassword || state.password || '';
 }
 
+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>Client ID: ${escapeHtml(account.clientId ? `${account.clientId.slice(0, 10)}...` : '未填写')}</span>
+        <span>Refresh: ${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';
+  if (hotmailSection) {
+    hotmailSection.style.display = useHotmail ? '' : 'none';
+  }
+  btnFetchEmail.hidden = useHotmail;
+  inputEmail.readOnly = useHotmail;
+  inputEmail.placeholder = useHotmail ? '由 Hotmail 账号池自动分配' : '粘贴 DuckDuckGo 邮箱';
+  if (autoHintText) {
+    autoHintText.textContent = useHotmail
+      ? '请先校验并选择一个 Hotmail 账号'
+      : '先自动获取 Duck 邮箱,或手动粘贴邮箱后再继续';
+  }
+  if (useHotmail) {
+    const currentAccount = getCurrentHotmailAccount();
+    inputEmail.value = currentAccount?.email || latestState?.email || '';
+  }
+  renderHotmailAccounts();
 }
 
 // ============================================================
@@ -680,6 +935,70 @@ async function fetchDuckEmail(options = {}) {
   }
 }
 
+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() {
   const isHidden = inputPassword.type === 'password';
   btnTogglePassword.innerHTML = isHidden ? EYE_OPEN_ICON : EYE_CLOSED_ICON;
@@ -754,18 +1073,25 @@ document.querySelectorAll('.step-btn').forEach(btn => {
           });
           syncLatestState({ customPassword: inputPassword.value });
         }
-        let email = inputEmail.value.trim();
-        if (!email) {
-          try {
-            email = await fetchDuckEmail({ 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 fetchDuckEmail({ 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 } });
@@ -780,9 +1106,234 @@ document.querySelectorAll('.step-btn').forEach(btn => {
 });
 
 btnFetchEmail.addEventListener('click', async () => {
+  if (selectMailProvider.value === 'hotmail-api') {
+    return;
+  }
   await fetchDuckEmail().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('请先填写 Microsoft client 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();
@@ -869,7 +1420,7 @@ btnReset.addEventListener('click', async () => {
   }
 
   await chrome.runtime.sendMessage({ type: 'RESET', source: 'sidepanel' });
-  syncLatestState({ stepStatuses: STEP_DEFAULT_STATUSES });
+  syncLatestState({ stepStatuses: STEP_DEFAULT_STATUSES, currentHotmailAccountId: null, email: null });
   syncAutoRunState({ autoRunning: false, autoRunPhase: 'idle', autoRunCurrentRun: 0, autoRunTotalRuns: 1, autoRunAttemptRun: 0 });
   displayOauthUrl.textContent = '等待中...';
   displayOauthUrl.classList.remove('has-value');
@@ -887,6 +1438,7 @@ btnReset.addEventListener('click', async () => {
   updateStopButtonState(false);
   updateButtonStates();
   updateProgressCounter();
+  renderHotmailAccounts();
 });
 
 // Clear log
@@ -896,6 +1448,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 } });
@@ -1014,24 +1569,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 || '';
+        }
       }
       break;
     }
@@ -1083,6 +1646,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
+  );
+});

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

@@ -0,0 +1,468 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+
+const {
+  buildHotmailMailApiLatestUrl,
+  extractVerificationCodeFromMessage,
+  filterHotmailAccountsByUsage,
+  extractVerificationCode,
+  getLatestHotmailMessage,
+  getHotmailBulkActionLabel,
+  getHotmailListToggleLabel,
+  getHotmailMailApiRequestConfig,
+  getHotmailVerificationPollConfig,
+  getHotmailVerificationRequestTimestamp,
+  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 returns relaxed match when strict filters miss but recent code exists', () => {
+  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.message.id, 'login-mail');
+  assert.equal(result.match.code, '555666');
+  assert.equal(result.usedRelaxedFilters, true);
+  assert.equal(result.usedTimeFallback, false);
+});
+
+test('pickVerificationMessageWithTimeFallback can ignore afterTimestamp when latest code mail is just outside flow window', () => {
+  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, true);
+  assert.equal(result.usedTimeFallback, true);
+});
+
+test('buildHotmailMailApiLatestUrl includes email, client id, refresh token, and mailbox', () => {
+  const url = new URL(buildHotmailMailApiLatestUrl({
+    clientId: 'client-123',
+    email: 'user@hotmail.com',
+    refreshToken: 'refresh-token-xyz',
+    mailbox: 'Junk',
+  }));
+
+  assert.equal(url.origin + url.pathname, 'https://apple.882263.xyz/api/mail-new');
+  assert.equal(url.searchParams.get('client_id'), 'client-123');
+  assert.equal(url.searchParams.get('email'), 'user@hotmail.com');
+  assert.equal(url.searchParams.get('refresh_token'), 'refresh-token-xyz');
+  assert.equal(url.searchParams.get('mailbox'), 'Junk');
+  assert.equal(url.searchParams.get('response_type'), 'json');
+});
+
+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('getHotmailMailApiRequestConfig defines a finite timeout for mailbox polling', () => {
+  assert.deepEqual(getHotmailMailApiRequestConfig(), {
+    timeoutMs: 15000,
+  });
+});
+
+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',
+    },
+  ]);
+});