瀏覽代碼

将hotmail支持本地和远程两种,远程不一定合适,可能考虑远程需要自行修改适配

QLHazyCoder 4 月之前
父節點
當前提交
e46f861045
共有 8 個文件被更改,包括 1118 次插入94 次删除
  1. 56 8
      README.md
  2. 293 57
      background.js
  3. 二進制
      scripts/__pycache__/hotmail_helper.cpython-313.pyc
  4. 661 0
      scripts/hotmail_helper.py
  5. 14 15
      sidepanel/sidepanel.html
  6. 59 14
      sidepanel/sidepanel.js
  7. 19 0
      start-hotmail-helper.bat
  8. 16 0
      start-hotmail-helper.command

+ 56 - 8
README.md

@@ -52,7 +52,7 @@
 - 支持自定义密码;留空时自动生成强密码
 - 自动显示当前使用中的密码,便于后续保存
 - 自动获取注册验证码与登录验证码
-- 支持 `Hotmail`:直接使用 `邮箱 + 客户端 ID + 刷新令牌(refresh token)` 请求第三方邮件 API 读取最新邮件
+- 支持 `Hotmail`:继续使用 `邮箱 + 客户端 ID + 刷新令牌(refresh token)`,并可在远程服务与本地助手两种模式间切换
 - 支持 `QQ Mail`、`163 Mail`、`Inbucket mailbox`
 - 支持从 DuckDuckGo Email Protection 自动生成新的 `@duck.com` 地址
 - 支持基于 Cloudflare 自定义域名自动生成随机邮箱前缀
@@ -137,7 +137,7 @@ Step 1 和 Step 9 都依赖这个地址。
 
 说明:
 
-- `Hotmail` 通过侧边栏里的 Hotmail 账号池选择账号,并请求第三方邮件 API
+- `Hotmail` 通过侧边栏里的 Hotmail 账号池选择账号,可切换为远程服务模式或本地助手模式
 - `QQ`、`163`、`163 VIP` 用于直接轮询网页邮箱
 - `Inbucket` 通过你在侧边栏里配置的 host 访问 `mailbox` 页面:`https://<your-inbucket-host>/m/<mailbox>/`
 
@@ -147,10 +147,9 @@ Step 1 和 Step 9 都依赖这个地址。
 
 可配置项:
 
-- `Hotmail API 地址`
-- `响应类型`
-- `收件箱参数`
-- `垃圾箱参数`
+- `接码模式`
+- `远程服务地址`
+- `本地助手地址`
 
 每条账号支持保存:
 
@@ -161,12 +160,61 @@ Step 1 和 Step 9 都依赖这个地址。
 
 使用方式:
 
-- 先配置 Hotmail API 相关设置
+- 先选择 Hotmail 接码模式
+- 远程模式下填写远程服务地址(默认兼容 `https://apple.882263.xyz`)
+- 本地模式下填写本地助手地址(默认 `http://127.0.0.1:17373`)
+- Windows 运行仓库根目录的 `start-hotmail-helper.bat`
+- macOS 运行仓库根目录的 `start-hotmail-helper.command`
+- 本地 helper 当前仅依赖 Python 标准库,无需额外安装第三方 Python 包
 - 再新增账号
 - 点击 `校验`
 - 校验通过后,可点击 `测试收信`
 - Auto 模式每轮会自动选用一个可用账号
 
+#### 本地 helper 启动命令
+
+Windows:
+
+```powershell
+.\start-hotmail-helper.bat
+```
+
+macOS:
+
+```bash
+chmod +x ./start-hotmail-helper.command
+./start-hotmail-helper.command
+```
+
+如果你不想走启动脚本,也可以直接运行 Python 程序本体:
+
+```bash
+python scripts/hotmail_helper.py
+```
+
+如果你的环境里命令是 `python3`:
+
+```bash
+python3 scripts/hotmail_helper.py
+```
+
+#### 启动成功标志
+
+本地 helper 启动成功后,终端会输出:
+
+```text
+Hotmail helper listening on http://127.0.0.1:17373
+```
+
+看到这行再回到扩展里点 `校验` 或 `复制最新验证码`。
+
+#### 最小排错说明
+
+- 如果提示 `Python 3 not found`,先安装 Python 3.10+
+- 如果 helper 已启动但扩展仍报连接失败,先确认模式切到了 `本地助手`
+- 确认本地助手地址与终端输出一致,默认应为 `http://127.0.0.1:17373`
+- 如果地址一致仍失败,再检查是否有端口占用或终端里是否已经抛出异常
+
 ### `Mailbox`
 
 仅当 `Mail = Inbucket` 时显示。
@@ -414,7 +462,7 @@ Cloudflare 模式下,插件不会再调用 Cloudflare API 创建路由。
 
 支持:
 
-- `Hotmail`(第三方邮件 API
+- `Hotmail`(远程服务 / 本地助手
 - `content/qq-mail.js`
 - `content/mail-163.js`
 - `content/inbucket-mail.js`

+ 293 - 57
background.js

@@ -38,10 +38,11 @@ const AUTO_RUN_ALARM_NAME = 'scheduled-auto-run';
 const AUTO_RUN_DELAY_MIN_MINUTES = 1;
 const AUTO_RUN_DELAY_MAX_MINUTES = 1440;
 const DEFAULT_LOCAL_CPA_STEP9_MODE = 'submit';
-const DEFAULT_HOTMAIL_MAIL_API_URL = 'https://apple.882263.xyz/api/mail-new';
-const DEFAULT_HOTMAIL_MAIL_API_RESPONSE_TYPE = 'json';
-const DEFAULT_HOTMAIL_MAIL_API_INBOX_MAILBOX = 'INBOX';
-const DEFAULT_HOTMAIL_MAIL_API_JUNK_MAILBOX = 'Junk';
+const HOTMAIL_SERVICE_MODE_REMOTE = 'remote';
+const HOTMAIL_SERVICE_MODE_LOCAL = 'local';
+const DEFAULT_HOTMAIL_REMOTE_BASE_URL = '';
+const DEFAULT_HOTMAIL_LOCAL_BASE_URL = 'http://127.0.0.1:17373';
+const HOTMAIL_LOCAL_HELPER_TIMEOUT_MS = 45000;
 
 initializeSessionStorageAccess();
 
@@ -66,10 +67,9 @@ const PERSISTED_SETTING_DEFAULTS = {
   emailGenerator: 'duck', // 注册邮箱生成方式:duck / cloudflare。
   inbucketHost: '', // 仅当 mailProvider 为 inbucket 时填写 Inbucket 地址,其他情况保持为空。
   inbucketMailbox: '', // 仅当 mailProvider 为 inbucket 时填写邮箱名,其他情况保持为空。
-  hotmailApiUrl: DEFAULT_HOTMAIL_MAIL_API_URL, // Hotmail 第三方邮件 API 地址。
-  hotmailApiResponseType: DEFAULT_HOTMAIL_MAIL_API_RESPONSE_TYPE, // Hotmail 第三方邮件 API 的 response_type 参数。
-  hotmailApiInboxMailbox: DEFAULT_HOTMAIL_MAIL_API_INBOX_MAILBOX, // Hotmail API 收件箱参数值。
-  hotmailApiJunkMailbox: DEFAULT_HOTMAIL_MAIL_API_JUNK_MAILBOX, // Hotmail API 垃圾箱参数值。
+  hotmailServiceMode: HOTMAIL_SERVICE_MODE_REMOTE, // Hotmail 服务模式:远程服务 / 本地助手。
+  hotmailRemoteBaseUrl: DEFAULT_HOTMAIL_REMOTE_BASE_URL, // Hotmail 远程服务地址。
+  hotmailLocalBaseUrl: DEFAULT_HOTMAIL_LOCAL_BASE_URL, // Hotmail 本地 helper 地址。
   cloudflareDomain: '', // 仅当 emailGenerator=cloudflare 时填写自定义域名。
   cloudflareDomains: [], // Cloudflare 可选域名列表。
   hotmailAccounts: [],
@@ -197,56 +197,64 @@ function normalizeCloudflareDomains(values) {
   return normalizedDomains;
 }
 
-function normalizeHotmailMailApiUrl(rawValue = '') {
+function normalizeHotmailServiceMode(rawValue = '') {
+  return String(rawValue || '').trim().toLowerCase() === HOTMAIL_SERVICE_MODE_LOCAL
+    ? HOTMAIL_SERVICE_MODE_LOCAL
+    : HOTMAIL_SERVICE_MODE_REMOTE;
+}
+
+function normalizeHotmailRemoteBaseUrl(rawValue = '') {
   const value = String(rawValue || '').trim();
-  if (!value) return DEFAULT_HOTMAIL_MAIL_API_URL;
+  if (!value) return DEFAULT_HOTMAIL_REMOTE_BASE_URL;
 
   try {
     const parsed = new URL(value);
     if (!['http:', 'https:'].includes(parsed.protocol)) {
-      return DEFAULT_HOTMAIL_MAIL_API_URL;
+      return DEFAULT_HOTMAIL_REMOTE_BASE_URL;
     }
-    return parsed.toString();
+
+    if (parsed.pathname.endsWith('/api/mail-new') || parsed.pathname.endsWith('/api/mail-all') || parsed.pathname === '/api.html') {
+      parsed.pathname = '';
+      parsed.search = '';
+      parsed.hash = '';
+    }
+
+    return parsed.toString().replace(/\/$/, '');
   } catch {
-    return DEFAULT_HOTMAIL_MAIL_API_URL;
+    return DEFAULT_HOTMAIL_REMOTE_BASE_URL;
   }
 }
 
-function normalizeHotmailMailApiResponseType(rawValue) {
-  if (rawValue === undefined || rawValue === null) {
-    return DEFAULT_HOTMAIL_MAIL_API_RESPONSE_TYPE;
-  }
-  return String(rawValue).trim();
-}
+function normalizeHotmailLocalBaseUrl(rawValue = '') {
+  const value = String(rawValue || '').trim();
+  if (!value) return DEFAULT_HOTMAIL_LOCAL_BASE_URL;
+
+  try {
+    const parsed = new URL(value);
+    if (!['http:', 'https:'].includes(parsed.protocol)) {
+      return DEFAULT_HOTMAIL_LOCAL_BASE_URL;
+    }
 
-function normalizeHotmailMailApiMailbox(rawValue, fallback) {
-  if (rawValue === undefined || rawValue === null) {
-    return fallback;
+    if (['/messages', '/code', '/clear', '/token'].includes(parsed.pathname)) {
+      parsed.pathname = '';
+      parsed.search = '';
+      parsed.hash = '';
+    }
+
+    return parsed.toString().replace(/\/$/, '');
+  } catch {
+    return DEFAULT_HOTMAIL_LOCAL_BASE_URL;
   }
-  const value = String(rawValue).trim();
-  return value || fallback;
 }
 
-function getHotmailMailApiSettings(state = {}) {
+function getHotmailServiceSettings(state = {}) {
   return {
-    apiUrl: normalizeHotmailMailApiUrl(state.hotmailApiUrl),
-    responseType: normalizeHotmailMailApiResponseType(state.hotmailApiResponseType),
-    inboxMailbox: normalizeHotmailMailApiMailbox(state.hotmailApiInboxMailbox, DEFAULT_HOTMAIL_MAIL_API_INBOX_MAILBOX),
-    junkMailbox: normalizeHotmailMailApiMailbox(state.hotmailApiJunkMailbox, DEFAULT_HOTMAIL_MAIL_API_JUNK_MAILBOX),
+    mode: normalizeHotmailServiceMode(state.hotmailServiceMode),
+    remoteBaseUrl: normalizeHotmailRemoteBaseUrl(state.hotmailRemoteBaseUrl),
+    localBaseUrl: normalizeHotmailLocalBaseUrl(state.hotmailLocalBaseUrl),
   };
 }
 
-function resolveHotmailMailApiMailbox(mailbox, settings = {}) {
-  const normalized = String(mailbox || '').trim().toLowerCase();
-  if (normalized === 'junk' || normalized === 'junk email' || normalized === 'junkemail') {
-    return normalizeHotmailMailApiMailbox(settings.junkMailbox, DEFAULT_HOTMAIL_MAIL_API_JUNK_MAILBOX);
-  }
-  if (normalized === 'inbox' || !normalized) {
-    return normalizeHotmailMailApiMailbox(settings.inboxMailbox, DEFAULT_HOTMAIL_MAIL_API_INBOX_MAILBOX);
-  }
-  return String(mailbox || '').trim();
-}
-
 function normalizePersistentSettingValue(key, value) {
   switch (key) {
     case 'panelMode':
@@ -280,14 +288,12 @@ function normalizePersistentSettingValue(key, value) {
       return String(value || '').trim();
     case 'inbucketMailbox':
       return String(value || '').trim();
-    case 'hotmailApiUrl':
-      return normalizeHotmailMailApiUrl(value);
-    case 'hotmailApiResponseType':
-      return normalizeHotmailMailApiResponseType(value);
-    case 'hotmailApiInboxMailbox':
-      return normalizeHotmailMailApiMailbox(value, DEFAULT_HOTMAIL_MAIL_API_INBOX_MAILBOX);
-    case 'hotmailApiJunkMailbox':
-      return normalizeHotmailMailApiMailbox(value, DEFAULT_HOTMAIL_MAIL_API_JUNK_MAILBOX);
+    case 'hotmailServiceMode':
+      return normalizeHotmailServiceMode(value);
+    case 'hotmailRemoteBaseUrl':
+      return normalizeHotmailRemoteBaseUrl(value);
+    case 'hotmailLocalBaseUrl':
+      return normalizeHotmailLocalBaseUrl(value);
     case 'cloudflareDomain':
       return normalizeCloudflareDomain(value);
     case 'cloudflareDomains':
@@ -710,7 +716,17 @@ async function ensureHotmailAccountForFlow(options = {}) {
   return setCurrentHotmailAccount(account.id, { markUsed, syncEmail: true });
 }
 
-async function requestHotmailMailApi(account, mailbox = 'INBOX') {
+function buildHotmailRemoteEndpoint(baseUrl, path) {
+  const normalizedBaseUrl = normalizeHotmailRemoteBaseUrl(baseUrl);
+  return new URL(path, `${normalizedBaseUrl}/`).toString();
+}
+
+function buildHotmailLocalEndpoint(baseUrl, path) {
+  const normalizedBaseUrl = normalizeHotmailLocalBaseUrl(baseUrl);
+  return new URL(path, `${normalizedBaseUrl}/`).toString();
+}
+
+async function requestHotmailRemoteMailbox(account, mailbox = 'INBOX') {
   if (!account?.email) {
     throw new Error('Hotmail 账号缺少邮箱地址。');
   }
@@ -721,14 +737,14 @@ async function requestHotmailMailApi(account, mailbox = 'INBOX') {
     throw new Error(`Hotmail 账号 ${account.email || account.id} 缺少刷新令牌(refresh token)。`);
   }
 
-  const hotmailApiSettings = getHotmailMailApiSettings(await getState());
+  const serviceSettings = getHotmailServiceSettings(await getState());
   const url = buildHotmailMailApiLatestUrl({
-    apiUrl: hotmailApiSettings.apiUrl,
+    apiUrl: buildHotmailRemoteEndpoint(serviceSettings.remoteBaseUrl, '/api/mail-new'),
     clientId: account.clientId,
     email: account.email,
     refreshToken: account.refreshToken,
-    mailbox: resolveHotmailMailApiMailbox(mailbox, hotmailApiSettings),
-    responseType: hotmailApiSettings.responseType,
+    mailbox,
+    responseType: 'json',
   });
   const { timeoutMs } = getHotmailMailApiRequestConfig();
   const controller = new AbortController();
@@ -791,13 +807,13 @@ function buildHotmailMailApiFailureAccount(account, errorMessage) {
   });
 }
 
-async function fetchHotmailMailboxMessages(account, mailboxes = HOTMAIL_MAILBOXES) {
+async function fetchHotmailMailboxMessagesFromRemoteService(account, mailboxes = HOTMAIL_MAILBOXES) {
   let workingAccount = normalizeHotmailAccount(account);
   const mailboxResults = [];
 
   try {
     for (const mailbox of mailboxes) {
-      const result = await requestHotmailMailApi(workingAccount, mailbox);
+      const result = await requestHotmailRemoteMailbox(workingAccount, mailbox);
       workingAccount = applyHotmailApiResultToAccount(workingAccount, result);
       mailboxResults.push({
         mailbox,
@@ -819,6 +835,221 @@ async function fetchHotmailMailboxMessages(account, mailboxes = HOTMAIL_MAILBOXE
   };
 }
 
+async function requestHotmailLocalMessages(account, mailboxes = HOTMAIL_MAILBOXES) {
+  if (!account?.email) {
+    throw new Error('Hotmail 账号缺少邮箱地址。');
+  }
+  if (!account?.clientId) {
+    throw new Error(`Hotmail 账号 ${account.email || account.id} 缺少客户端 ID。`);
+  }
+  if (!account?.refreshToken) {
+    throw new Error(`Hotmail 账号 ${account.email || account.id} 缺少刷新令牌(refresh token)。`);
+  }
+
+  const serviceSettings = getHotmailServiceSettings(await getState());
+  const { timeoutMs } = getHotmailMailApiRequestConfig();
+  const requestTimeoutMs = Math.max(timeoutMs, HOTMAIL_LOCAL_HELPER_TIMEOUT_MS);
+  const controller = new AbortController();
+  const timeoutId = setTimeout(() => controller.abort(new Error('timeout')), requestTimeoutMs);
+
+  let response;
+  try {
+    response = await fetch(buildHotmailLocalEndpoint(serviceSettings.localBaseUrl, '/messages'), {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json',
+        Accept: 'application/json',
+      },
+      body: JSON.stringify({
+        email: account.email,
+        clientId: account.clientId,
+        refreshToken: account.refreshToken,
+        mailboxes,
+        top: 5,
+      }),
+      signal: controller.signal,
+    });
+  } catch (err) {
+    if (err?.name === 'AbortError') {
+      throw new Error(`Hotmail 本地助手请求超时(>${Math.round(requestTimeoutMs / 1000)} 秒)`);
+    }
+    throw new Error(`Hotmail 本地助手请求失败:${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 || payload?.ok === false) {
+    const errorText = payload?.error || payload?.message || text || `HTTP ${response.status}`;
+    throw new Error(`Hotmail 本地助手返回失败:${errorText}`);
+  }
+
+  const rawMessages = Array.isArray(payload?.messages) ? payload.messages : [];
+  const normalizedMessages = normalizeHotmailMailApiMessages(rawMessages).map((message, index) => ({
+    ...message,
+    mailbox: rawMessages[index]?.mailbox || 'INBOX',
+    receivedTimestamp: Number(rawMessages[index]?.receivedTimestamp || 0) || 0,
+  }));
+  const mailboxResults = Array.isArray(payload?.mailboxResults)
+    ? payload.mailboxResults.map((item) => ({
+      mailbox: String(item?.mailbox || 'INBOX'),
+      count: Number(item?.count || 0),
+      messages: normalizedMessages.filter((message) => String(message.mailbox || 'INBOX') === String(item?.mailbox || 'INBOX')),
+    }))
+    : mailboxes.map((mailbox) => ({
+      mailbox,
+      count: normalizedMessages.filter((message) => String(message.mailbox || 'INBOX') === mailbox).length,
+      messages: normalizedMessages.filter((message) => String(message.mailbox || 'INBOX') === mailbox),
+    }));
+
+  const nextAccount = applyHotmailApiResultToAccount(account, {
+    nextRefreshToken: String(payload?.nextRefreshToken || '').trim(),
+  });
+  const savedAccount = await upsertHotmailAccount(nextAccount);
+  return {
+    account: savedAccount,
+    mailboxResults,
+    messages: normalizedMessages,
+  };
+}
+
+async function requestHotmailLocalCode(account, pollPayload = {}) {
+  if (!account?.email) {
+    throw new Error('Hotmail 账号缺少邮箱地址。');
+  }
+  if (!account?.clientId) {
+    throw new Error(`Hotmail 账号 ${account.email || account.id} 缺少客户端 ID。`);
+  }
+  if (!account?.refreshToken) {
+    throw new Error(`Hotmail 账号 ${account.email || account.id} 缺少刷新令牌(refresh token)。`);
+  }
+
+  const serviceSettings = getHotmailServiceSettings(await getState());
+  const { timeoutMs } = getHotmailMailApiRequestConfig();
+  const requestTimeoutMs = Math.max(timeoutMs, HOTMAIL_LOCAL_HELPER_TIMEOUT_MS);
+  const controller = new AbortController();
+  const timeoutId = setTimeout(() => controller.abort(new Error('timeout')), requestTimeoutMs);
+
+  let response;
+  try {
+    response = await fetch(buildHotmailLocalEndpoint(serviceSettings.localBaseUrl, '/code'), {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json',
+        Accept: 'application/json',
+      },
+      body: JSON.stringify({
+        email: account.email,
+        clientId: account.clientId,
+        refreshToken: account.refreshToken,
+        mailboxes: HOTMAIL_MAILBOXES,
+        top: 5,
+        senderFilters: pollPayload.senderFilters || [],
+        subjectFilters: pollPayload.subjectFilters || [],
+        excludeCodes: pollPayload.excludeCodes || [],
+        filterAfterTimestamp: Number(pollPayload.filterAfterTimestamp || 0) || 0,
+      }),
+      signal: controller.signal,
+    });
+  } catch (err) {
+    if (err?.name === 'AbortError') {
+      throw new Error(`Hotmail 本地助手请求超时(>${Math.round(requestTimeoutMs / 1000)} 秒)`);
+    }
+    throw new Error(`Hotmail 本地助手请求失败:${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 || payload?.ok === false) {
+    const errorText = payload?.error || payload?.message || text || `HTTP ${response.status}`;
+    throw new Error(`Hotmail 本地助手返回失败:${errorText}`);
+  }
+
+  const normalizedMessage = payload?.message
+    ? {
+      ...normalizeHotmailMailApiMessages([payload.message])[0],
+      mailbox: payload?.message?.mailbox || 'INBOX',
+      receivedTimestamp: Number(payload?.message?.receivedTimestamp || 0) || 0,
+    }
+    : null;
+  const nextAccount = applyHotmailApiResultToAccount(account, {
+    nextRefreshToken: String(payload?.nextRefreshToken || '').trim(),
+  });
+  const savedAccount = await upsertHotmailAccount(nextAccount);
+  return {
+    account: savedAccount,
+    code: String(payload?.code || ''),
+    message: normalizedMessage,
+    usedTimeFallback: Boolean(payload?.usedTimeFallback),
+    selectionSource: String(payload?.selectionSource || ''),
+  };
+}
+
+async function pollHotmailVerificationCodeViaLocalHelper(step, account, pollPayload = {}) {
+  const maxAttempts = Number(pollPayload.maxAttempts) || 5;
+  const intervalMs = Number(pollPayload.intervalMs) || 3000;
+  let workingAccount = account;
+  let lastError = null;
+
+  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
+    throwIfStopped();
+    try {
+      await addLog(`步骤 ${step}:正在通过本地助手轮询 Hotmail 验证码(${attempt}/${maxAttempts})...`, 'info');
+      const fetchResult = await requestHotmailLocalCode(workingAccount, pollPayload);
+      workingAccount = fetchResult.account;
+
+      if (fetchResult.code) {
+        const mailboxLabel = fetchResult.message?.mailbox || 'INBOX';
+        if (fetchResult.usedTimeFallback) {
+          await addLog(`步骤 ${step}:本地助手使用时间回退后命中 Hotmail ${mailboxLabel} 验证码。`, 'warn');
+        }
+        await addLog(`步骤 ${step}:已通过本地助手在 Hotmail ${mailboxLabel} 中找到验证码:${fetchResult.code}`, 'ok');
+        return {
+          ok: true,
+          code: fetchResult.code,
+          emailTimestamp: fetchResult.message?.receivedTimestamp || Date.now(),
+          mailId: fetchResult.message?.id || '',
+        };
+      }
+
+      lastError = new Error(`步骤 ${step}:本地助手暂未返回匹配验证码(${attempt}/${maxAttempts})。`);
+      await addLog(lastError.message, attempt === maxAttempts ? 'warn' : 'info');
+    } catch (err) {
+      lastError = err;
+      await addLog(`步骤 ${step}:本地助手轮询 Hotmail 失败:${err.message}`, 'warn');
+    }
+
+    if (attempt < maxAttempts) {
+      await sleepWithStop(intervalMs);
+    }
+  }
+
+  throw lastError || new Error(`步骤 ${step}:本地助手未返回新的匹配验证码。`);
+}
+
+async function fetchHotmailMailboxMessages(account, mailboxes = HOTMAIL_MAILBOXES) {
+  const serviceSettings = getHotmailServiceSettings(await getState());
+  if (serviceSettings.mode === HOTMAIL_SERVICE_MODE_LOCAL) {
+    return requestHotmailLocalMessages(account, mailboxes);
+  }
+  return fetchHotmailMailboxMessagesFromRemoteService(account, mailboxes);
+}
+
 async function verifyHotmailAccount(accountId) {
   const state = await getState();
   const account = findHotmailAccount(state.hotmailAccounts, accountId);
@@ -866,6 +1097,11 @@ async function pollHotmailVerificationCode(step, state, pollPayload = {}) {
   });
   await addLog(`步骤 ${step}:当前使用 Hotmail 账号 ${account.email} 轮询收件箱。`, 'info');
 
+  const serviceSettings = getHotmailServiceSettings(state);
+  if (serviceSettings.mode === HOTMAIL_SERVICE_MODE_LOCAL) {
+    return pollHotmailVerificationCodeViaLocalHelper(step, account, pollPayload);
+  }
+
   const maxAttempts = Number(pollPayload.maxAttempts) || 5;
   const intervalMs = Number(pollPayload.intervalMs) || 3000;
   let lastError = null;
@@ -1755,7 +1991,7 @@ function getSourceLabel(source) {
     'mail-163': '163 邮箱',
     'inbucket-mail': 'Inbucket 邮箱',
     'duck-mail': 'Duck 邮箱',
-    'hotmail-api': 'Hotmail(第三方邮件 API)',
+    'hotmail-api': 'Hotmail(远程/本地)',
   };
   return labels[source] || source || '未知来源';
 }
@@ -3619,7 +3855,7 @@ async function executeStep3(state) {
 function getMailConfig(state) {
   const provider = state.mailProvider || 'qq';
   if (provider === HOTMAIL_PROVIDER) {
-    return { provider: HOTMAIL_PROVIDER, label: 'Hotmail(第三方邮件 API)' };
+    return { provider: HOTMAIL_PROVIDER, label: 'Hotmail(远程/本地)' };
   }
   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 邮箱' };

二進制
scripts/__pycache__/hotmail_helper.cpython-313.pyc


+ 661 - 0
scripts/hotmail_helper.py

@@ -0,0 +1,661 @@
+import email
+import html
+import imaplib
+import json
+import re
+import time
+from datetime import datetime, timezone
+from email.header import decode_header
+from email.utils import parseaddr, parsedate_to_datetime
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from urllib.error import HTTPError, URLError
+from urllib.parse import urlencode
+from urllib.request import Request, urlopen
+
+
+HOST = "127.0.0.1"
+PORT = 17373
+LIVE_TOKEN_URL = "https://login.live.com/oauth20_token.srf"
+ENTRA_COMMON_TOKEN_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/token"
+ENTRA_CONSUMERS_TOKEN_URL = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token"
+GRAPH_API_ORIGIN = "https://graph.microsoft.com"
+OUTLOOK_API_ORIGIN = "https://outlook.office.com"
+GRAPH_SCOPES = "offline_access https://graph.microsoft.com/Mail.Read https://graph.microsoft.com/User.Read"
+GRAPH_DEFAULT_SCOPE = "https://graph.microsoft.com/.default"
+TOKEN_ENDPOINTS = {
+    "live": {
+        "name": "live",
+        "url": LIVE_TOKEN_URL,
+        "extra_data": {},
+    },
+    "entra-consumers-delegated": {
+        "name": "entra-consumers-delegated",
+        "url": ENTRA_CONSUMERS_TOKEN_URL,
+        "extra_data": {
+            "scope": GRAPH_SCOPES,
+        },
+    },
+    "entra-common-delegated": {
+        "name": "entra-common-delegated",
+        "url": ENTRA_COMMON_TOKEN_URL,
+        "extra_data": {
+            "scope": GRAPH_SCOPES,
+        },
+    },
+    "entra-common-default": {
+        "name": "entra-common-default",
+        "url": ENTRA_COMMON_TOKEN_URL,
+        "extra_data": {
+            "scope": GRAPH_DEFAULT_SCOPE,
+        },
+    },
+    "entra-common-outlook": {
+        "name": "entra-common-outlook",
+        "url": ENTRA_COMMON_TOKEN_URL,
+        "extra_data": {},
+    },
+}
+IMAP_HOST = "outlook.office365.com"
+IMAP_PORT = 993
+REQUEST_TIMEOUT_SECONDS = 45
+FETCH_LIMIT_DEFAULT = 5
+
+
+def json_response(handler, status, payload):
+    body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
+    handler.send_response(status)
+    handler.send_header("Content-Type", "application/json; charset=utf-8")
+    handler.send_header("Content-Length", str(len(body)))
+    handler.send_header("Access-Control-Allow-Origin", "*")
+    handler.send_header("Access-Control-Allow-Headers", "Content-Type")
+    handler.send_header("Access-Control-Allow-Methods", "POST, OPTIONS")
+    handler.end_headers()
+    handler.wfile.write(body)
+
+
+def read_json_payload(handler):
+    length = int(handler.headers.get("Content-Length", "0") or 0)
+    raw = handler.rfile.read(length) if length > 0 else b"{}"
+    try:
+        return json.loads(raw.decode("utf-8"))
+    except Exception as exc:
+        raise RuntimeError(f"Invalid JSON payload: {exc}") from exc
+
+
+def post_form(url, data):
+    encoded = urlencode(data).encode("utf-8")
+    request = Request(url, data=encoded, headers={"Content-Type": "application/x-www-form-urlencoded"})
+    with urlopen(request, timeout=REQUEST_TIMEOUT_SECONDS) as response:
+        return json.loads(response.read().decode("utf-8"))
+
+
+def get_json(url, headers=None):
+    request = Request(url, headers=headers or {})
+    with urlopen(request, timeout=REQUEST_TIMEOUT_SECONDS) as response:
+        return response.getcode(), json.loads(response.read().decode("utf-8"))
+
+
+def mask_secret(value, keep=6):
+    raw = str(value or "")
+    if not raw:
+        return ""
+    if len(raw) <= keep:
+        return "*" * len(raw)
+    return raw[:keep] + "..." + raw[-keep:]
+
+
+def compact_text(value, limit=400):
+    text = str(value or "").replace("\r", " ").replace("\n", " ").strip()
+    return text[:limit]
+
+
+def log_info(message):
+    print(f"[HotmailHelper] {message}", flush=True)
+
+
+def try_refresh_access_token(endpoint, client_id, refresh_token):
+    request_data = {
+        "client_id": client_id,
+        "refresh_token": refresh_token,
+        "grant_type": "refresh_token",
+        **(endpoint.get("extra_data") or {}),
+    }
+    started_at = time.monotonic()
+    try:
+        payload = post_form(endpoint["url"], request_data)
+    except HTTPError as exc:
+        detail = exc.read().decode("utf-8", errors="ignore")
+        return {
+            "ok": False,
+            "endpoint": endpoint["name"],
+            "url": endpoint["url"],
+            "status": getattr(exc, "code", None),
+            "error": compact_text(detail or str(exc)),
+            "elapsed_ms": int((time.monotonic() - started_at) * 1000),
+        }
+    except URLError as exc:
+        return {
+            "ok": False,
+            "endpoint": endpoint["name"],
+            "url": endpoint["url"],
+            "status": None,
+            "error": compact_text(f"Token request failed: {exc}"),
+            "elapsed_ms": int((time.monotonic() - started_at) * 1000),
+        }
+
+    access_token = str(payload.get("access_token") or "").strip()
+    if not access_token:
+        return {
+            "ok": False,
+            "endpoint": endpoint["name"],
+            "url": endpoint["url"],
+            "status": 200,
+            "error": compact_text(payload.get("error_description") or payload.get("error") or json.dumps(payload, ensure_ascii=False)),
+            "elapsed_ms": int((time.monotonic() - started_at) * 1000),
+        }
+
+    return {
+        "ok": True,
+        "endpoint": endpoint["name"],
+        "url": endpoint["url"],
+        "elapsed_ms": int((time.monotonic() - started_at) * 1000),
+        "payload": {
+            "access_token": access_token,
+            "next_refresh_token": str(payload.get("refresh_token") or "").strip(),
+        },
+    }
+
+
+def refresh_access_token(client_id, refresh_token, strategy_names=None):
+    errors = []
+    selected_endpoints = [
+        TOKEN_ENDPOINTS[name]
+        for name in (strategy_names or ["live", "entra-consumers-delegated", "entra-common-delegated"])
+        if name in TOKEN_ENDPOINTS
+    ]
+    log_info(
+        "token refresh start "
+        f"clientId={mask_secret(client_id)} "
+        f"refreshToken={mask_secret(refresh_token)} "
+        f"strategies={[item['name'] for item in selected_endpoints]}"
+    )
+
+    for endpoint in selected_endpoints:
+        result = try_refresh_access_token(endpoint, client_id, refresh_token)
+        if result["ok"]:
+            log_info(
+                "token refresh success "
+                f"endpoint={result['endpoint']} "
+                f"elapsedMs={result['elapsed_ms']}"
+            )
+            return {
+                "access_token": result["payload"]["access_token"],
+                "next_refresh_token": result["payload"]["next_refresh_token"],
+                "token_endpoint": result["endpoint"],
+                "token_url": result["url"],
+            }
+
+        errors.append(result)
+        log_info(
+            "token refresh failed "
+            f"endpoint={result['endpoint']} "
+            f"status={result['status']} "
+            f"elapsedMs={result['elapsed_ms']} "
+            f"detail={result['error']}"
+        )
+
+    details = " | ".join(
+        f"{item['endpoint']}({item['status']}): {item['error']}"
+        for item in errors
+    )
+    raise RuntimeError(f"Token refresh failed on all endpoints: {details}")
+
+
+def build_xoauth2(email_addr, access_token):
+    return f"user={email_addr}\x01auth=Bearer {access_token}\x01\x01".encode("utf-8")
+
+
+def open_mailbox(email_addr, access_token):
+    client = imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT, timeout=REQUEST_TIMEOUT_SECONDS)
+    client.authenticate("XOAUTH2", lambda _: build_xoauth2(email_addr, access_token))
+    return client
+
+
+def decode_mime_header(value):
+    if not value:
+        return ""
+    parts = []
+    for chunk, charset in decode_header(value):
+        if isinstance(chunk, bytes):
+            parts.append(chunk.decode(charset or "utf-8", errors="ignore"))
+        else:
+            parts.append(str(chunk))
+    return "".join(parts).strip()
+
+
+def extract_text_part(message):
+    if message.is_multipart():
+        for part in message.walk():
+            if part.get_content_maintype() == "multipart":
+                continue
+            if "attachment" in str(part.get("Content-Disposition") or "").lower():
+                continue
+            payload = part.get_payload(decode=True) or b""
+            charset = part.get_content_charset() or "utf-8"
+            text = payload.decode(charset, errors="ignore").strip()
+            if part.get_content_type() == "text/plain" and text:
+                return text
+            if part.get_content_type() == "text/html" and text:
+                return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", html.unescape(text))).strip()
+        return ""
+
+    payload = message.get_payload(decode=True) or b""
+    charset = message.get_content_charset() or "utf-8"
+    text = payload.decode(charset, errors="ignore").strip()
+    if message.get_content_type() == "text/html":
+        return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", html.unescape(text))).strip()
+    return text
+
+
+def mailbox_candidates(mailbox):
+    normalized = str(mailbox or "INBOX").strip().lower()
+    if normalized in {"junk", "junk email", "junk e-mail", "junkemail"}:
+        return ["Junk", "Junk Email", "Junk E-Mail"]
+    return ["INBOX"]
+
+
+def normalize_mailbox_label(mailbox):
+    normalized = str(mailbox or "INBOX").strip().lower()
+    if normalized in {"junk", "junk email", "junk e-mail", "junkemail"}:
+        return "Junk"
+    return "INBOX"
+
+
+def normalize_mailbox_id(mailbox):
+    normalized = str(mailbox or "INBOX").strip().lower()
+    if normalized in {"junk", "junk email", "junk e-mail", "junkemail"}:
+        return "junkemail"
+    return "inbox"
+
+
+def select_mailbox(client, mailbox):
+    for candidate in mailbox_candidates(mailbox):
+        status, _ = client.select(candidate)
+        if status == "OK":
+            return candidate
+    raise RuntimeError(f"Mailbox not found: {mailbox}")
+
+
+def to_timestamp_ms(raw_date):
+    if not raw_date:
+        return 0
+    try:
+        parsed = parsedate_to_datetime(raw_date)
+        if parsed.tzinfo is None:
+            parsed = parsed.replace(tzinfo=timezone.utc)
+        return int(parsed.timestamp() * 1000)
+    except Exception:
+        return 0
+
+
+def to_iso_string(timestamp_ms):
+    if not timestamp_ms:
+        return ""
+    return datetime.fromtimestamp(timestamp_ms / 1000, tz=timezone.utc).isoformat().replace("+00:00", "Z")
+
+
+def normalize_message(message_id, raw_bytes, mailbox):
+    parsed = email.message_from_bytes(raw_bytes)
+    sender_name, sender_addr = parseaddr(parsed.get("From", ""))
+    subject = decode_mime_header(parsed.get("Subject", ""))
+    body = extract_text_part(parsed)
+    timestamp_ms = to_timestamp_ms(parsed.get("Date"))
+    return {
+        "id": str(message_id),
+        "mailbox": mailbox,
+        "subject": subject,
+        "from": {
+            "emailAddress": {
+                "address": sender_addr.strip(),
+                "name": sender_name.strip(),
+            }
+        },
+        "bodyPreview": body[:500],
+        "receivedDateTime": to_iso_string(timestamp_ms),
+        "receivedTimestamp": timestamp_ms,
+    }
+
+
+def fetch_messages(email_addr, access_token, mailbox="INBOX", top=FETCH_LIMIT_DEFAULT):
+    client = None
+    logical_mailbox = normalize_mailbox_label(mailbox)
+    try:
+        client = open_mailbox(email_addr, access_token)
+        select_mailbox(client, mailbox)
+        status, data = client.search(None, "ALL")
+        if status != "OK" or not data or not data[0]:
+            return {"mailbox": logical_mailbox, "messages": [], "count": 0}
+
+        message_ids = data[0].split()
+        selected_ids = list(reversed(message_ids[-max(1, min(int(top or FETCH_LIMIT_DEFAULT), 30)):]))
+        messages = []
+        for message_id in selected_ids:
+            fetch_status, fetch_data = client.fetch(message_id, "(RFC822)")
+            if fetch_status != "OK" or not fetch_data:
+                continue
+            raw_bytes = b""
+            for item in fetch_data:
+                if isinstance(item, tuple) and len(item) >= 2:
+                    raw_bytes = item[1]
+                    break
+            if not raw_bytes:
+                continue
+            messages.append(normalize_message(message_id.decode("utf-8", errors="ignore"), raw_bytes, logical_mailbox))
+        return {"mailbox": logical_mailbox, "messages": messages, "count": len(messages)}
+    finally:
+        if client is not None:
+            try:
+                client.logout()
+            except Exception:
+                pass
+
+
+def fetch_messages_for_mailboxes(email_addr, access_token, mailboxes, top):
+    mailbox_results = []
+    all_messages = []
+    for mailbox in mailboxes or ["INBOX"]:
+        result = fetch_messages(email_addr, access_token, mailbox=mailbox, top=top)
+        mailbox_results.append(result)
+        all_messages.extend(result["messages"])
+    all_messages.sort(key=lambda item: int(item.get("receivedTimestamp") or 0), reverse=True)
+    return {"mailboxResults": mailbox_results, "messages": all_messages}
+
+
+def normalize_graph_message(message, mailbox):
+    sender = message.get("from", {}) or {}
+    email_addr = sender.get("emailAddress", {}) if isinstance(sender, dict) else {}
+    received = str(message.get("receivedDateTime") or "").strip()
+    return {
+        "id": str(message.get("id") or message.get("internetMessageId") or "").strip(),
+        "mailbox": mailbox,
+        "subject": str(message.get("subject") or "").strip(),
+        "from": {
+            "emailAddress": {
+                "address": str(email_addr.get("address") or "").strip(),
+                "name": str(email_addr.get("name") or "").strip(),
+            }
+        },
+        "bodyPreview": str(message.get("bodyPreview") or "").strip(),
+        "receivedDateTime": received,
+        "receivedTimestamp": int(datetime.fromisoformat(received.replace("Z", "+00:00")).timestamp() * 1000) if received else 0,
+    }
+
+
+def normalize_outlook_message(message, mailbox):
+    sender = message.get("From", {}) or message.get("from", {}) or {}
+    email_addr = sender.get("EmailAddress", {}) if isinstance(sender, dict) else {}
+    if isinstance(sender, dict) and not email_addr:
+        email_addr = sender.get("emailAddress", {}) if isinstance(sender, dict) else {}
+    received = str(message.get("ReceivedDateTime") or message.get("receivedDateTime") or "").strip()
+    return {
+        "id": str(message.get("Id") or message.get("id") or "").strip(),
+        "mailbox": mailbox,
+        "subject": str(message.get("Subject") or message.get("subject") or "").strip(),
+        "from": {
+            "emailAddress": {
+                "address": str(email_addr.get("Address") or email_addr.get("address") or "").strip(),
+                "name": str(email_addr.get("Name") or email_addr.get("name") or "").strip(),
+            }
+        },
+        "bodyPreview": str(message.get("BodyPreview") or message.get("bodyPreview") or "").strip(),
+        "receivedDateTime": received,
+        "receivedTimestamp": int(datetime.fromisoformat(received.replace("Z", "+00:00")).timestamp() * 1000) if received else 0,
+    }
+
+
+def fetch_graph_messages(access_token, mailbox="INBOX", top=FETCH_LIMIT_DEFAULT):
+    mailbox_id = normalize_mailbox_id(mailbox)
+    url = (
+        f"{GRAPH_API_ORIGIN}/v1.0/me/mailFolders/{mailbox_id}/messages"
+        f"?$top={max(1, min(int(top or FETCH_LIMIT_DEFAULT), 30))}"
+        f"&$select=id,internetMessageId,subject,from,bodyPreview,receivedDateTime"
+        f"&$orderby=receivedDateTime desc"
+    )
+    try:
+        _, payload = get_json(url, headers={
+            "Accept": "application/json",
+            "Authorization": f"Bearer {access_token}",
+        })
+    except HTTPError as exc:
+        detail = exc.read().decode("utf-8", errors="ignore")
+        raise RuntimeError(f"Graph request failed: {detail or exc}") from exc
+    except URLError as exc:
+        raise RuntimeError(f"Graph request failed: {exc}") from exc
+
+    messages = [normalize_graph_message(item, normalize_mailbox_label(mailbox)) for item in (payload.get("value") or [])]
+    return {"mailbox": normalize_mailbox_label(mailbox), "messages": messages, "count": len(messages)}
+
+
+def fetch_outlook_api_messages(access_token, mailbox="INBOX", top=FETCH_LIMIT_DEFAULT):
+    mailbox_id = normalize_mailbox_id(mailbox)
+    url = (
+        f"{OUTLOOK_API_ORIGIN}/api/v2.0/me/mailfolders/{mailbox_id}/messages"
+        f"?$top={max(1, min(int(top or FETCH_LIMIT_DEFAULT), 30))}"
+        f"&$select=Id,Subject,From,BodyPreview,ReceivedDateTime"
+        f"&$orderby=ReceivedDateTime desc"
+    )
+    try:
+        _, payload = get_json(url, headers={
+            "Accept": "application/json",
+            "Authorization": f"Bearer {access_token}",
+        })
+    except HTTPError as exc:
+        detail = exc.read().decode("utf-8", errors="ignore")
+        raise RuntimeError(f"Outlook API request failed: {detail or exc}") from exc
+    except URLError as exc:
+        raise RuntimeError(f"Outlook API request failed: {exc}") from exc
+
+    messages = [normalize_outlook_message(item, normalize_mailbox_label(mailbox)) for item in (payload.get("value") or [])]
+    return {"mailbox": normalize_mailbox_label(mailbox), "messages": messages, "count": len(messages)}
+
+
+def collect_imap_messages(email_addr, client_id, refresh_token, mailboxes, top):
+    token_payload = refresh_access_token(client_id, refresh_token, [
+        "live",
+        "entra-consumers-delegated",
+        "entra-common-delegated",
+    ])
+    result = fetch_messages_for_mailboxes(email_addr, token_payload["access_token"], mailboxes, top)
+    result["transport"] = "imap"
+    result["token_payload"] = token_payload
+    return result
+
+
+def collect_graph_messages(email_addr, client_id, refresh_token, mailboxes, top):
+    token_payload = refresh_access_token(client_id, refresh_token, [
+        "entra-common-delegated",
+        "entra-consumers-delegated",
+        "entra-common-default",
+    ])
+    mailbox_results = [fetch_graph_messages(token_payload["access_token"], mailbox=mailbox, top=top) for mailbox in mailboxes]
+    messages = []
+    for item in mailbox_results:
+        messages.extend(item["messages"])
+    messages.sort(key=lambda item: int(item.get("receivedTimestamp") or 0), reverse=True)
+    return {
+        "transport": "graph",
+        "token_payload": token_payload,
+        "mailboxResults": mailbox_results,
+        "messages": messages,
+    }
+
+
+def collect_outlook_messages(email_addr, client_id, refresh_token, mailboxes, top):
+    token_payload = refresh_access_token(client_id, refresh_token, [
+        "entra-common-outlook",
+        "entra-common-delegated",
+    ])
+    mailbox_results = [fetch_outlook_api_messages(token_payload["access_token"], mailbox=mailbox, top=top) for mailbox in mailboxes]
+    messages = []
+    for item in mailbox_results:
+        messages.extend(item["messages"])
+    messages.sort(key=lambda item: int(item.get("receivedTimestamp") or 0), reverse=True)
+    return {
+        "transport": "outlook",
+        "token_payload": token_payload,
+        "mailboxResults": mailbox_results,
+        "messages": messages,
+    }
+
+
+def collect_messages(email_addr, client_id, refresh_token, mailboxes, top):
+    errors = []
+    collectors = [
+        ("imap", collect_imap_messages),
+        ("graph", collect_graph_messages),
+        ("outlook", collect_outlook_messages),
+    ]
+
+    for transport_name, collector in collectors:
+        try:
+            log_info(f"message collection start transport={transport_name}")
+            result = collector(email_addr, client_id, refresh_token, mailboxes, top)
+            log_info(
+                f"message collection success transport={transport_name} "
+                f"tokenEndpoint={result['token_payload'].get('token_endpoint', '')}"
+            )
+            return result
+        except Exception as exc:
+            message = compact_text(str(exc), 600)
+            errors.append(f"{transport_name}: {message}")
+            log_info(f"message collection failed transport={transport_name} detail={message}")
+
+    raise RuntimeError(f"Message collection failed on all transports: {' | '.join(errors)}")
+
+
+def extract_code(text):
+    source = str(text or "")
+    patterns = [
+        r"(?:代码为|验证码[^0-9]*?)[\s::]*(\d{6})",
+        r"code(?:\s+is|[\s:])+(\d{6})",
+        r"\b(\d{6})\b",
+    ]
+    for pattern in patterns:
+        match = re.search(pattern, source, flags=re.IGNORECASE)
+        if match:
+            return match.group(1)
+    return ""
+
+
+def select_latest_code(messages, sender_filters, subject_filters, exclude_codes, filter_after_timestamp):
+    sender_keywords = [str(item).strip().lower() for item in sender_filters or [] if str(item).strip()]
+    subject_keywords = [str(item).strip().lower() for item in subject_filters or [] if str(item).strip()]
+    excluded = {str(item).strip() for item in exclude_codes or [] if str(item).strip()}
+
+    def match_message(message, apply_time_filter):
+        timestamp = int(message.get("receivedTimestamp") or 0)
+        if apply_time_filter and filter_after_timestamp and timestamp and timestamp < int(filter_after_timestamp):
+            return None
+
+        sender = str(message.get("from", {}).get("emailAddress", {}).get("address", "")).lower()
+        subject = str(message.get("subject", ""))
+        preview = str(message.get("bodyPreview", ""))
+        combined = " ".join([sender, subject.lower(), preview.lower()])
+        code = extract_code(" ".join([subject, preview, sender]))
+        if not code or code in excluded:
+            return None
+
+        sender_ok = not sender_keywords or any(keyword in combined for keyword in sender_keywords)
+        subject_ok = not subject_keywords or any(keyword in combined for keyword in subject_keywords)
+        if not sender_ok and not subject_ok:
+            return None
+
+        return {"code": code, "message": message}
+
+    for use_time_fallback in [False, True]:
+        matched = []
+        for message in messages:
+            result = match_message(message, apply_time_filter=not use_time_fallback)
+            if result:
+                matched.append(result)
+        if matched:
+            matched.sort(key=lambda item: int(item["message"].get("receivedTimestamp") or 0), reverse=True)
+            best = matched[0]
+            return {
+                "code": best["code"],
+                "message": best["message"],
+                "usedTimeFallback": use_time_fallback,
+            }
+    return {"code": "", "message": None, "usedTimeFallback": False}
+
+
+class HotmailHelperHandler(BaseHTTPRequestHandler):
+    def do_OPTIONS(self):
+        self.send_response(204)
+        self.send_header("Access-Control-Allow-Origin", "*")
+        self.send_header("Access-Control-Allow-Headers", "Content-Type")
+        self.send_header("Access-Control-Allow-Methods", "POST, OPTIONS")
+        self.end_headers()
+
+    def do_POST(self):
+        try:
+            payload = read_json_payload(self)
+            email_addr = str(payload.get("email") or "").strip()
+            client_id = str(payload.get("clientId") or "").strip()
+            refresh_token = str(payload.get("refreshToken") or "").strip()
+            if not email_addr or not client_id or not refresh_token:
+                raise RuntimeError("Missing email/clientId/refreshToken")
+
+            top = max(1, min(int(payload.get("top") or FETCH_LIMIT_DEFAULT), 30))
+            mailboxes = payload.get("mailboxes") if isinstance(payload.get("mailboxes"), list) else [payload.get("mailbox") or "INBOX"]
+
+            if self.path == "/messages":
+                result = collect_messages(email_addr, client_id, refresh_token, mailboxes, top)
+                json_response(self, 200, {
+                    "ok": True,
+                    "messages": result["messages"],
+                    "mailboxResults": result["mailboxResults"],
+                    "nextRefreshToken": result["token_payload"].get("next_refresh_token") or "",
+                    "tokenEndpoint": result["token_payload"].get("token_endpoint") or "",
+                    "transport": result.get("transport") or "",
+                })
+                return
+
+            if self.path == "/code":
+                result = collect_messages(email_addr, client_id, refresh_token, mailboxes, top)
+                selected = select_latest_code(
+                    result["messages"],
+                    payload.get("senderFilters") or [],
+                    payload.get("subjectFilters") or [],
+                    payload.get("excludeCodes") or [],
+                    int(payload.get("filterAfterTimestamp") or 0),
+                )
+                json_response(self, 200, {
+                    "ok": True,
+                    "code": selected["code"],
+                    "message": selected["message"],
+                    "usedTimeFallback": selected["usedTimeFallback"],
+                    "nextRefreshToken": result["token_payload"].get("next_refresh_token") or "",
+                    "tokenEndpoint": result["token_payload"].get("token_endpoint") or "",
+                    "transport": result.get("transport") or "",
+                })
+                return
+
+            json_response(self, 404, {"ok": False, "error": f"Unsupported path: {self.path}"})
+        except Exception as exc:
+            json_response(self, 500, {"ok": False, "error": str(exc)})
+
+
+def main():
+    server = ThreadingHTTPServer((HOST, PORT), HotmailHelperHandler)
+    print(f"Hotmail helper listening on http://{HOST}:{PORT}", flush=True)
+    try:
+        server.serve_forever()
+    except KeyboardInterrupt:
+        pass
+    finally:
+        server.server_close()
+
+
+if __name__ == "__main__":
+    main()

+ 14 - 15
sidepanel/sidepanel.html

@@ -130,7 +130,7 @@
       <div class="data-row">
         <span class="data-label">邮箱服务</span>
         <select id="select-mail-provider" class="data-select">
-          <option value="hotmail-api">Hotmail(第三方邮件 API)</option>
+          <option value="hotmail-api">Hotmail(远程/本地)</option>
           <option value="163">163 邮箱 (mail.163.com)</option>
           <option value="163-vip">163 VIP 邮箱 (webmail.vip.163.com)</option>
           <option value="qq">QQ 邮箱 (wx.mail.qq.com)</option>
@@ -205,7 +205,7 @@
       <div class="section-mini-header">
         <div class="section-mini-copy">
           <span class="section-label">Hotmail 账号池</span>
-          <span class="section-hint">先配置 API,再维护账号池并校验</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>
@@ -213,21 +213,20 @@
           <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">API 地址</span>
-        <input type="text" id="input-hotmail-api-url" class="data-input mono" placeholder="https://apple.882263.xyz/api/mail-new" />
-      </div>
-      <div class="data-row">
-        <span class="data-label">响应类型</span>
-        <input type="text" id="input-hotmail-api-response-type" class="data-input mono" placeholder="json;留空则不传 response_type" />
+      <div class="data-row" id="row-hotmail-service-mode">
+        <span class="data-label">接码模式</span>
+        <div id="hotmail-service-mode-group" class="choice-group" role="group" aria-label="Hotmail 接码模式">
+          <button type="button" class="choice-btn" data-hotmail-service-mode="remote">远程服务</button>
+          <button type="button" class="choice-btn" data-hotmail-service-mode="local">本地助手</button>
+        </div>
       </div>
-      <div class="data-row">
-        <span class="data-label">收件箱参数</span>
-        <input type="text" id="input-hotmail-api-inbox-mailbox" class="data-input mono" placeholder="INBOX" />
+      <div class="data-row" id="row-hotmail-remote-base-url">
+        <span class="data-label">远程服务</span>
+        <input type="text" id="input-hotmail-remote-base-url" class="data-input mono" placeholder="请输入远程服务地址" />
       </div>
-      <div class="data-row">
-        <span class="data-label">垃圾箱参数</span>
-        <input type="text" id="input-hotmail-api-junk-mailbox" class="data-input mono" placeholder="Junk" />
+      <div class="data-row" id="row-hotmail-local-base-url" style="display:none;">
+        <span class="data-label">本地助手</span>
+        <input type="text" id="input-hotmail-local-base-url" class="data-input mono" placeholder="http://127.0.0.1:17373" />
       </div>
       <div class="data-row">
         <span class="data-label">邮箱</span>

+ 59 - 14
sidepanel/sidepanel.js

@@ -60,10 +60,12 @@ const selectMailProvider = document.getElementById('select-mail-provider');
 const rowEmailGenerator = document.getElementById('row-email-generator');
 const selectEmailGenerator = document.getElementById('select-email-generator');
 const hotmailSection = document.getElementById('hotmail-section');
-const inputHotmailApiUrl = document.getElementById('input-hotmail-api-url');
-const inputHotmailApiResponseType = document.getElementById('input-hotmail-api-response-type');
-const inputHotmailApiInboxMailbox = document.getElementById('input-hotmail-api-inbox-mailbox');
-const inputHotmailApiJunkMailbox = document.getElementById('input-hotmail-api-junk-mailbox');
+const rowHotmailServiceMode = document.getElementById('row-hotmail-service-mode');
+const hotmailServiceModeButtons = Array.from(document.querySelectorAll('[data-hotmail-service-mode]'));
+const rowHotmailRemoteBaseUrl = document.getElementById('row-hotmail-remote-base-url');
+const inputHotmailRemoteBaseUrl = document.getElementById('input-hotmail-remote-base-url');
+const rowHotmailLocalBaseUrl = document.getElementById('row-hotmail-local-base-url');
+const inputHotmailLocalBaseUrl = document.getElementById('input-hotmail-local-base-url');
 const inputHotmailEmail = document.getElementById('input-hotmail-email');
 const inputHotmailClientId = document.getElementById('input-hotmail-client-id');
 const inputHotmailPassword = document.getElementById('input-hotmail-password');
@@ -112,6 +114,8 @@ const AUTO_DELAY_MIN_MINUTES = 1;
 const AUTO_DELAY_MAX_MINUTES = 1440;
 const AUTO_DELAY_DEFAULT_MINUTES = 30;
 const DEFAULT_LOCAL_CPA_STEP9_MODE = 'submit';
+const HOTMAIL_SERVICE_MODE_REMOTE = 'remote';
+const HOTMAIL_SERVICE_MODE_LOCAL = 'local';
 
 let latestState = null;
 let currentAutoRun = {
@@ -601,10 +605,9 @@ function collectSettingsPayload() {
     emailGenerator: selectEmailGenerator.value,
     inbucketHost: inputInbucketHost.value.trim(),
     inbucketMailbox: inputInbucketMailbox.value.trim(),
-    hotmailApiUrl: inputHotmailApiUrl.value.trim(),
-    hotmailApiResponseType: inputHotmailApiResponseType.value.trim(),
-    hotmailApiInboxMailbox: inputHotmailApiInboxMailbox.value.trim(),
-    hotmailApiJunkMailbox: inputHotmailApiJunkMailbox.value.trim(),
+    hotmailServiceMode: getSelectedHotmailServiceMode(),
+    hotmailRemoteBaseUrl: inputHotmailRemoteBaseUrl.value.trim(),
+    hotmailLocalBaseUrl: inputHotmailLocalBaseUrl.value.trim(),
     cloudflareDomain: selectedCloudflareDomain,
     cloudflareDomains: domains,
     autoRunSkipFailures: inputAutoSkipFailures.checked,
@@ -619,6 +622,12 @@ function normalizeLocalCpaStep9Mode(value = '') {
     : DEFAULT_LOCAL_CPA_STEP9_MODE;
 }
 
+function normalizeHotmailServiceMode(value = '') {
+  return String(value || '').trim().toLowerCase() === HOTMAIL_SERVICE_MODE_LOCAL
+    ? HOTMAIL_SERVICE_MODE_LOCAL
+    : HOTMAIL_SERVICE_MODE_REMOTE;
+}
+
 function getSelectedLocalCpaStep9Mode() {
   const activeButton = localCpaStep9ModeButtons.find((button) => button.classList.contains('is-active'));
   return normalizeLocalCpaStep9Mode(activeButton?.dataset.localCpaStep9Mode);
@@ -633,6 +642,20 @@ function setLocalCpaStep9Mode(mode) {
   });
 }
 
+function getSelectedHotmailServiceMode() {
+  const activeButton = hotmailServiceModeButtons.find((button) => button.classList.contains('is-active'));
+  return normalizeHotmailServiceMode(activeButton?.dataset.hotmailServiceMode);
+}
+
+function setHotmailServiceMode(mode) {
+  const resolvedMode = normalizeHotmailServiceMode(mode);
+  hotmailServiceModeButtons.forEach((button) => {
+    const active = button.dataset.hotmailServiceMode === resolvedMode;
+    button.classList.toggle('is-active', active);
+    button.setAttribute('aria-pressed', String(active));
+  });
+}
+
 function setSettingsCardLocked(locked) {
   if (!settingsCard) {
     return;
@@ -850,10 +873,9 @@ function applySettingsState(state) {
   selectEmailGenerator.value = state?.emailGenerator || 'duck';
   inputInbucketHost.value = state?.inbucketHost || '';
   inputInbucketMailbox.value = state?.inbucketMailbox || '';
-  inputHotmailApiUrl.value = state?.hotmailApiUrl || '';
-  inputHotmailApiResponseType.value = state?.hotmailApiResponseType ?? '';
-  inputHotmailApiInboxMailbox.value = state?.hotmailApiInboxMailbox || '';
-  inputHotmailApiJunkMailbox.value = state?.hotmailApiJunkMailbox || '';
+  setHotmailServiceMode(state?.hotmailServiceMode);
+  inputHotmailRemoteBaseUrl.value = state?.hotmailRemoteBaseUrl || '';
+  inputHotmailLocalBaseUrl.value = state?.hotmailLocalBaseUrl || '';
   renderCloudflareDomainOptions(state?.cloudflareDomain || '');
   setCloudflareDomainEditMode(false, { clearInput: true });
   inputAutoSkipFailures.checked = Boolean(state?.autoRunSkipFailures);
@@ -1160,6 +1182,7 @@ function renderHotmailAccounts() {
 function updateMailProviderUI() {
   const useInbucket = selectMailProvider.value === 'inbucket';
   const useHotmail = selectMailProvider.value === 'hotmail-api';
+  const hotmailServiceMode = getSelectedHotmailServiceMode();
   const useEmailGenerator = !useHotmail;
   rowInbucketHost.style.display = useInbucket ? '' : 'none';
   rowInbucketMailbox.style.display = useInbucket ? '' : 'none';
@@ -1179,6 +1202,15 @@ function updateMailProviderUI() {
   if (hotmailSection) {
     hotmailSection.style.display = useHotmail ? '' : 'none';
   }
+  if (rowHotmailServiceMode) {
+    rowHotmailServiceMode.style.display = useHotmail ? '' : 'none';
+  }
+  if (rowHotmailRemoteBaseUrl) {
+    rowHotmailRemoteBaseUrl.style.display = useHotmail && hotmailServiceMode === HOTMAIL_SERVICE_MODE_REMOTE ? '' : 'none';
+  }
+  if (rowHotmailLocalBaseUrl) {
+    rowHotmailLocalBaseUrl.style.display = useHotmail && hotmailServiceMode === HOTMAIL_SERVICE_MODE_LOCAL ? '' : 'none';
+  }
   selectEmailGenerator.disabled = useHotmail;
   btnFetchEmail.hidden = useHotmail;
   inputEmail.readOnly = useHotmail;
@@ -1189,7 +1221,7 @@ function updateMailProviderUI() {
   }
   if (autoHintText) {
     autoHintText.textContent = useHotmail
-      ? '请先校验并选择一个 Hotmail 账号'
+      ? `请先校验并选择一个 Hotmail 账号(当前:${hotmailServiceMode === HOTMAIL_SERVICE_MODE_LOCAL ? '本地助手' : '远程服务'})`
       : '先自动获取邮箱,或手动粘贴邮箱后再继续';
   }
   if (useHotmail) {
@@ -2003,6 +2035,19 @@ localCpaStep9ModeButtons.forEach((button) => {
   });
 });
 
+hotmailServiceModeButtons.forEach((button) => {
+  button.addEventListener('click', () => {
+    const nextMode = button.dataset.hotmailServiceMode;
+    if (getSelectedHotmailServiceMode() === normalizeHotmailServiceMode(nextMode)) {
+      return;
+    }
+    setHotmailServiceMode(nextMode);
+    updateMailProviderUI();
+    markSettingsDirty(true);
+    saveSettings({ silent: true }).catch(() => { });
+  });
+});
+
 btnSaveSettings.addEventListener('click', async () => {
   if (!settingsDirty) {
     showToast('配置已是最新', 'info', 1400);
@@ -2216,7 +2261,7 @@ inputVpsPassword.addEventListener('blur', () => {
   saveSettings({ silent: true }).catch(() => { });
 });
 
-[inputHotmailApiUrl, inputHotmailApiResponseType, inputHotmailApiInboxMailbox, inputHotmailApiJunkMailbox].forEach((input) => {
+[inputHotmailRemoteBaseUrl, inputHotmailLocalBaseUrl].forEach((input) => {
   input?.addEventListener('input', () => {
     markSettingsDirty(true);
     scheduleSettingsAutoSave();

+ 19 - 0
start-hotmail-helper.bat

@@ -0,0 +1,19 @@
+@echo off
+setlocal
+
+cd /d "%~dp0"
+
+where py >nul 2>nul
+if %errorlevel%==0 (
+  py -3 scripts\hotmail_helper.py
+  goto :eof
+)
+
+where python >nul 2>nul
+if %errorlevel%==0 (
+  python scripts\hotmail_helper.py
+  goto :eof
+)
+
+echo Python 3 not found. Please install Python 3.10+ and try again.
+pause

+ 16 - 0
start-hotmail-helper.command

@@ -0,0 +1,16 @@
+#!/bin/bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+cd "$SCRIPT_DIR"
+
+if command -v python3 >/dev/null 2>&1; then
+  exec python3 scripts/hotmail_helper.py
+fi
+
+if command -v python >/dev/null 2>&1; then
+  exec python scripts/hotmail_helper.py
+fi
+
+echo "Python 3 not found. Please install Python 3.10+ and try again."
+read -r -p "Press Enter to exit..."