From e46f861045feed2ae4ae5577d0cd212ac34ef7c3 Mon Sep 17 00:00:00 2001 From: QLHazyCoder <2825305047@qq.com> Date: Mon, 13 Apr 2026 16:28:01 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B0=86hotmail=E6=94=AF=E6=8C=81=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E5=92=8C=E8=BF=9C=E7=A8=8B=E4=B8=A4=E7=A7=8D=EF=BC=8C?= =?UTF-8?q?=E8=BF=9C=E7=A8=8B=E4=B8=8D=E4=B8=80=E5=AE=9A=E5=90=88=E9=80=82?= =?UTF-8?q?=EF=BC=8C=E5=8F=AF=E8=83=BD=E8=80=83=E8=99=91=E8=BF=9C=E7=A8=8B?= =?UTF-8?q?=E9=9C=80=E8=A6=81=E8=87=AA=E8=A1=8C=E4=BF=AE=E6=94=B9=E9=80=82?= =?UTF-8?q?=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 64 +- background.js | 352 ++++++++-- .../hotmail_helper.cpython-313.pyc | Bin 0 -> 36695 bytes scripts/hotmail_helper.py | 661 ++++++++++++++++++ sidepanel/sidepanel.html | 29 +- sidepanel/sidepanel.js | 73 +- start-hotmail-helper.bat | 19 + start-hotmail-helper.command | 16 + 8 files changed, 1119 insertions(+), 95 deletions(-) create mode 100644 scripts/__pycache__/hotmail_helper.cpython-313.pyc create mode 100644 scripts/hotmail_helper.py create mode 100644 start-hotmail-helper.bat create mode 100644 start-hotmail-helper.command diff --git a/README.md b/README.md index fc07358..2033aef 100644 --- a/README.md +++ b/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:///m//` @@ -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` diff --git a/background.js b/background.js index 37c4684..cb4a879 100644 --- a/background.js +++ b/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; +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; + } + + 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; } - return String(rawValue).trim(); } -function normalizeHotmailMailApiMailbox(rawValue, fallback) { - if (rawValue === undefined || rawValue === null) { - return fallback; - } - 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 邮箱' }; diff --git a/scripts/__pycache__/hotmail_helper.cpython-313.pyc b/scripts/__pycache__/hotmail_helper.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4718d47a7a8b3efe2743bc7e56ca847b9c016c66 GIT binary patch literal 36695 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 Q1Fi9&H(@uKR@OrOfAI!{H2?qr literal 0 HcmV?d00001 diff --git a/scripts/hotmail_helper.py b/scripts/hotmail_helper.py new file mode 100644 index 0000000..8d218fa --- /dev/null +++ b/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() diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html index d6d8cde..6f545b2 100644 --- a/sidepanel/sidepanel.html +++ b/sidepanel/sidepanel.html @@ -130,7 +130,7 @@
邮箱服务 +
+ 接码模式 +
+ + +
-
- 响应类型 - +
+ 远程服务 +
-
- 收件箱参数 - -
-
- 垃圾箱参数 - +
邮箱 diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js index 5f67896..3fcd8f0 100644 --- a/sidepanel/sidepanel.js +++ b/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(); diff --git a/start-hotmail-helper.bat b/start-hotmail-helper.bat new file mode 100644 index 0000000..607a34e --- /dev/null +++ b/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 diff --git a/start-hotmail-helper.command b/start-hotmail-helper.command new file mode 100644 index 0000000..cd0009c --- /dev/null +++ b/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..."