diff --git a/background.js b/background.js index c989833..b7624a0 100644 --- a/background.js +++ b/background.js @@ -2133,30 +2133,78 @@ async function setCurrentHotmailAccount(accountId, options = {}) { return account; } -async function ensureHotmailAccountForFlow(options = {}) { - const { allowAllocate = true, markUsed = false, preferredAccountId = null } = options; - const state = await getState(); - const accounts = normalizeHotmailAccounts(state.hotmailAccounts); - const isAccountAllocatable = (candidate) => Boolean(candidate) +function isAuthorizedHotmailRunAccount(candidate) { + return Boolean(candidate) && candidate.status === 'authorized' && !candidate.used && Boolean(candidate.refreshToken); +} + +function isPendingHotmailVerificationCandidate(candidate) { + return Boolean(candidate) + && candidate.status === 'pending' + && !candidate.used + && Boolean(candidate.refreshToken); +} + +function compareHotmailAccountAllocationPriority(left, right) { + const leftUsedAt = Number(left?.lastUsedAt) || 0; + const rightUsedAt = Number(right?.lastUsedAt) || 0; + if (leftUsedAt !== rightUsedAt) { + return leftUsedAt - rightUsedAt; + } + + return String(left?.email || '').localeCompare(String(right?.email || '')); +} + +function pickPendingHotmailAccountForVerification(accounts, options = {}) { + const excludeIds = new Set((options.excludeIds || []).filter(Boolean)); + const candidates = normalizeHotmailAccounts(accounts) + .filter((candidate) => isPendingHotmailVerificationCandidate(candidate) && !excludeIds.has(candidate.id)); + if (!candidates.length) { + return null; + } + + const preferredAccountId = String(options.preferredAccountId || '').trim(); + if (preferredAccountId) { + const preferredCandidate = candidates.find((candidate) => candidate.id === preferredAccountId); + if (preferredCandidate) { + return preferredCandidate; + } + } + + return candidates + .slice() + .sort(compareHotmailAccountAllocationPriority)[0] || null; +} + +async function ensureHotmailAccountForFlow(options = {}) { + const { + allowAllocate = true, + markUsed = false, + preferredAccountId = null, + excludeIds = [], + } = options; + const state = await getState(); + const accounts = normalizeHotmailAccounts(state.hotmailAccounts); + const excludedAccountIds = new Set((excludeIds || []).filter(Boolean)); + const availableAccounts = accounts.filter((candidate) => isAuthorizedHotmailRunAccount(candidate) && !excludedAccountIds.has(candidate.id)); let account = null; - if (preferredAccountId) { + if (preferredAccountId && !excludedAccountIds.has(preferredAccountId)) { account = findHotmailAccount(accounts, preferredAccountId); } - if (!account && state.currentHotmailAccountId) { + if ((!account || !isAuthorizedHotmailRunAccount(account)) && state.currentHotmailAccountId && !excludedAccountIds.has(state.currentHotmailAccountId)) { account = findHotmailAccount(accounts, state.currentHotmailAccountId); } - if ((!account || !isAccountAllocatable(account)) && allowAllocate) { - account = pickHotmailAccountForRun(accounts, {}); + if ((!account || !isAuthorizedHotmailRunAccount(account)) && allowAllocate) { + account = availableAccounts.length ? pickHotmailAccountForRun(availableAccounts, {}) : null; } if (!account) { throw new Error('没有可用的 Hotmail 账号。请先在侧边栏添加至少一个带刷新令牌(refresh token)的账号。'); } - if (!isAccountAllocatable(account)) { + if (!isAuthorizedHotmailRunAccount(account)) { throw new Error(`Hotmail 账号 ${account.email || account.id} 尚未就绪,无法读取邮件。`); } @@ -2494,6 +2542,100 @@ async function verifyHotmailAccount(accountId) { }; } +async function ensureHotmailMailboxReadyForAutoRunRound(options = {}) { + const { + targetRun = 0, + totalRuns = 0, + attemptRun = 1, + } = options; + const state = await getState(); + if (!isHotmailProvider(state)) { + return null; + } + + const buildRoundLabel = () => { + if (targetRun > 0 && totalRuns > 0) { + return `第 ${targetRun}/${totalRuns} 轮`; + } + return '当前轮'; + }; + const exhaustedAccountIds = new Set(); + let preferredAccountId = state.currentHotmailAccountId || null; + let lastError = null; + + while (true) { + throwIfStopped(); + const latestState = await getState(); + const latestAccounts = normalizeHotmailAccounts(latestState.hotmailAccounts); + const remainingAuthorizedAccounts = latestAccounts + .filter((candidate) => isAuthorizedHotmailRunAccount(candidate) && !exhaustedAccountIds.has(candidate.id)); + const remainingPendingAccounts = latestAccounts + .filter((candidate) => isPendingHotmailVerificationCandidate(candidate) && !exhaustedAccountIds.has(candidate.id)); + if (!remainingAuthorizedAccounts.length && !remainingPendingAccounts.length) { + if (lastError) { + throw new Error(`自动运行${buildRoundLabel()}开始前未找到可通过校验的 Hotmail 账号:${lastError.message}`); + } + throw new Error('没有可用的 Hotmail 账号。请先在侧边栏添加至少一个带刷新令牌(refresh token)的账号。'); + } + + let account = null; + if (remainingAuthorizedAccounts.length) { + account = await ensureHotmailAccountForFlow({ + allowAllocate: true, + markUsed: false, + preferredAccountId, + excludeIds: [...exhaustedAccountIds], + }); + } else { + const pendingAccount = pickPendingHotmailAccountForVerification(latestAccounts, { + preferredAccountId, + excludeIds: [...exhaustedAccountIds], + }); + if (!pendingAccount) { + throw new Error('没有可用的 Hotmail 账号。请先在侧边栏添加至少一个带刷新令牌(refresh token)的账号。'); + } + account = await setCurrentHotmailAccount(pendingAccount.id, { + markUsed: false, + syncEmail: true, + }); + await addLog( + `自动运行${buildRoundLabel()}开始前未找到已校验 Hotmail 账号,正在尝试校验待校验账号 ${account.email}。`, + 'warn' + ); + } + + try { + await addLog( + `自动运行${buildRoundLabel()}第 ${attemptRun} 次尝试开始前,正在校验 Hotmail 账号 ${account.email} 的邮箱可用性。`, + 'info' + ); + const result = await verifyHotmailAccount(account.id); + await addLog( + `自动运行${buildRoundLabel()}开始前已校验 Hotmail 账号 ${result.account?.email || account.email},INBOX 当前 ${result.messageCount} 封邮件。`, + 'ok' + ); + return result.account; + } catch (error) { + lastError = error; + exhaustedAccountIds.add(account.id); + preferredAccountId = null; + const latestErrorMessage = error?.message || '未知错误'; + await addLog( + `自动运行${buildRoundLabel()}开始前校验 Hotmail 账号 ${account.email} 失败:${latestErrorMessage}`, + 'warn' + ); + const nextState = await getState(); + const hasRemainingAccounts = normalizeHotmailAccounts(nextState.hotmailAccounts) + .some((candidate) => ( + isAuthorizedHotmailRunAccount(candidate) || isPendingHotmailVerificationCandidate(candidate) + ) && !exhaustedAccountIds.has(candidate.id)); + if (hasRemainingAccounts) { + await addLog(`自动运行${buildRoundLabel()}开始前将切换下一个 Hotmail 账号并重试。`, 'warn'); + } + } + } +} + async function testHotmailAccountMailAccess(accountId) { const state = await getState(); const account = findHotmailAccount(state.hotmailAccounts, accountId); @@ -7650,6 +7792,7 @@ const autoRunController = self.MultiPageBackgroundAutoRunController?.createAutoR cancelPendingCommands, clearStopRequest: () => clearStopRequest(), createAutoRunSessionId: () => createAutoRunSessionId(), + ensureHotmailMailboxReadyForAutoRunRound: (...args) => ensureHotmailMailboxReadyForAutoRunRound(...args), getAutoRunStatusPayload, getErrorMessage, getFirstUnfinishedStep, diff --git a/background/auto-run-controller.js b/background/auto-run-controller.js index 37705a9..e1a6d92 100644 --- a/background/auto-run-controller.js +++ b/background/auto-run-controller.js @@ -14,6 +14,7 @@ cancelPendingCommands, clearStopRequest, createAutoRunSessionId, + ensureHotmailMailboxReadyForAutoRunRound, getAutoRunStatusPayload, getErrorMessage, getFirstUnfinishedStep, @@ -439,6 +440,15 @@ sessionId, }); + if (!useExistingProgress && startStep === 1 && typeof ensureHotmailMailboxReadyForAutoRunRound === 'function') { + await ensureHotmailMailboxReadyForAutoRunRound({ + targetRun, + totalRuns, + attemptRun, + sessionId, + }); + } + await runAutoSequenceFromStep(startStep, { targetRun, totalRuns, diff --git a/docs/使用教程.md b/docs/使用教程.md index 4926491..0512821 100644 --- a/docs/使用教程.md +++ b/docs/使用教程.md @@ -1,6 +1,6 @@ # Codex 注册扩展相关项目、更新、邮箱、PayPal 与 Clash Verge 配置教程 -本教程用于说明相关项目地址、扩展更新方式、`Cloudflare Temp Email`、`iCloud 隐私邮箱` 与 `QQ 邮箱` 的使用方法、`PayPal` 注册绑卡流程,以及 [Clash Verge](https://github.com/clash-verge-rev/clash-verge-rev) 的 `🔁 非港轮询` 配置方法。 +本教程用于说明相关项目地址、扩展更新方式、`Cloudflare Temp Email`、`iCloud 隐私邮箱` 与 `QQ 邮箱` 的使用方法、`PayPal` 注册绑卡流程、节点检测与纯净度检查,以及 [Clash Verge](https://github.com/clash-verge-rev/clash-verge-rev) 的 `🔁 非港轮询` 配置方法。 ## 适用场景 @@ -9,7 +9,9 @@ - 需要把 `Cloudflare Temp Email` 用作 `邮箱生成` 或 `邮箱服务` - 需要使用 `iCloud+` 的 `隐藏邮件地址` 作为隐私邮箱 - 需要临时切换 `QQ 邮箱` 地址继续使用 +- 需要使用 `网易邮箱`、`网易邮箱大师` 注册多个邮箱或替身邮箱 - 需要注册并使用 `PayPal` 个人账户 +- 需要检查当前节点的出口 IP、纯净度、泄露情况和访问速度 - 需要在 `Clash Verge` 中启用 `🔁 非港轮询` ## 准备内容 @@ -21,6 +23,7 @@ - 一个已开通 `iCloud+` 的 Apple ID - 一个用于接收转发邮件的邮箱 - 一个可正常登录的 `QQ 邮箱` +- 手机端已安装 `网易邮箱` 或 `网易邮箱大师` - 一个可正常接收短信的手机号 - 一张可在线支付的借记卡或信用卡 - 如需部署 `cpa`,部署环境必须可以访问 `OpenAI` @@ -163,7 +166,7 @@ `iCloud 邮件` 主地址创建说明:[Create a primary email address for iCloud Mail](https://support.apple.com/is-is/guide/icloud/mmdd8d1c5c/icloud) `隐藏邮件地址` 创建与转发说明:[Create and edit Hide My Email addresses on iCloud.com](https://support.apple.com/lv-lv/guide/icloud/mm1a876f7aed/icloud) -### 第五部分:`QQ 邮箱`切换邮箱使用教程 +### 第五部分:邮箱方法一:`QQ 邮箱`切换邮箱使用教程 1. 登录 `QQ 邮箱` 先登录你当前正在使用的 `QQ 邮箱` 账号。 @@ -182,7 +185,41 @@ 这两个邮箱地址使用完成后,可以直接删除。 删除后再次创建新的英文邮箱和 `Foxmail` 邮箱,即可重复注册使用。 -### 第六部分:`PayPal` 注册与绑卡使用教程 +### 第六部分:邮箱方法二:`网易邮箱` 注册与替身邮箱使用教程 + +1. 安装手机端 App + 在手机上下载安装 `网易邮箱` 或 `网易邮箱大师`。 + 打开后,先使用一个手机号登录。 + +2. 进入注册邮箱入口 + 点击右下角的 `我`。 + 找到并点击 `注册邮箱`。 + +3. 选择 `网易免费邮箱` + 在注册邮箱页面中选择 `网易免费邮箱`。 + 进入后,可以使用同一个手机号分别注册 `163`、`126` 和 `yeah` 邮箱。 + +4. 控制注册节奏 + 同一个手机号理论上可以注册多个网易邮箱账号。 + `163`、`126` 和 `yeah` 加起来大约可以注册 `15` 个。 + 不要短时间连续注册,连续注册太快容易触发频繁提示或限制。 + 更稳妥的做法是注册一个后暂停一会儿,或者换一个网络环境再继续。 + +5. 添加 `替身邮箱` + 注册完成后,回到 `注册邮箱` 入口附近。 + 点击旁边的 `替身邮箱`。 + 进入后,每个网易邮箱账号可以添加 `2` 个替身邮箱。 + +6. 计算可用邮箱数量 + 如果一个手机号注册了约 `15` 个网易邮箱账号,每个账号再添加 `2` 个替身邮箱,那么总共大约可以得到 `45` 个可用邮箱地址。 + 这些邮箱地址后续可以用于其他需要独立邮箱的注册或接收邮件场景。 + +7. 注意域名和注册环境 + `163`、`126` 和 `yeah` 这类网易邮箱域名通常比较常见。 + 只要网络环境不要太差,并且不要连续高频注册,一般不容易马上触发额外手机号验证。 + 建议注册一个后换节奏再继续,不要一口气连续创建。 + +### 第七部分:`PayPal` 注册与绑卡使用教程 1. 打开注册页面 打开 [https://www.paypal.com/signin](https://www.paypal.com/signin)。 @@ -234,14 +271,14 @@ 常见情况是上传身份证件。 `PayPal` 官方帮助中心说明,通常会在 `2 个工作日` 内审核,但某些情况可能更久。 -### 第七部分:0元试用 ChatGPT Plus 教程 +### 第八部分:0元试用 ChatGPT Plus 教程 本部分说明如何在已登录 ChatGPT 的状态下,通过脚本快速生成 Plus 支付链接,然后选择 PayPal 支付完成0元试用订阅。 #### 准备工作 1. 已有一个登录状态的 ChatGPT 账户。 -2. 一个可用的 PayPal 账户(参考第六部分进行注册和绑卡)。 +2. 一个可用的 PayPal 账户(参考第七部分进行注册和绑卡)。 3. 能够接收生成的账单地址的真实地址或虚拟地址。 4. Chrome 浏览器(推荐使用地址补全功能)。 @@ -389,7 +426,41 @@ - **PayPal 登录后页面无法继续跳转** 稍等片刻让页面加载完毕。如果长时间未响应,检查浏览器是否有弹窗被隐藏,或尝试刷新页面。 -### 第八部分:配置 [Clash Verge](https://github.com/clash-verge-rev/clash-verge-rev) 的 `🔁 非港轮询` +### 第九部分:节点检测与纯净度检查网站 + +本部分用于检查当前代理节点的出口 IP、归属地、风险标签、泄露情况和网站访问速度。 +建议每次切换节点后都重新打开这些网站检查一次。 + +1. 使用 [IP111](https://ip111.cn/) 检查出口 IP + `IP111` 会展示当前访问网站时识别到的 IP 信息。 + 它适合用来快速确认当前节点是否已经生效,以及国内外访问看到的出口 IP 是否一致。 + 如果你切换节点后页面仍显示原来的 IP,说明代理可能没有切换成功,或者浏览器还有缓存/分流规则没有生效。 + +2. 使用 [IPData](https://ipdata.co/) 查看 IP 资料和风险信息 + `IPData` 偏向 IP 数据查询。 + 它可以查看 IP 的国家、地区、运营商、组织、ASN、时区、货币等基础信息。 + 它也会展示威胁和风险相关信息,例如是否像代理、VPN、Tor、数据中心 IP、匿名 IP,或者是否存在滥用记录。 + 适合用来判断节点的基础身份和风险标签。 + +3. 使用 [IPPure](https://ippure.com/) 检查 IP 纯净度 + `IPPure` 更偏向一键检测 IP 纯净度。 + 它会显示 IP 位置、风险检测、代理/VPN/黑名单等信息。 + 页面中还包含浏览器指纹、出口地图、`WebRTC` 泄露、`DNS` 泄露等检测项。 + 适合用来判断节点是否干净,以及浏览器是否暴露了真实网络环境。 + +4. 使用 [TCPTest 网站测速](https://www.tcptest.cn/?cckey=e62f31db) 检查访问速度 + `TCPTest` 主要用于网站测速和连通性检测。 + 它可以测试 `Ping`、`TCPing`、`HTTP` 速度、`DNS` 解析、路由追踪和 `MTR`。 + 如果某个网站访问慢、打不开,或者想比较不同节点访问同一网站的速度,可以用它测试连接耗时、状态码、解析耗时和线路表现。 + 这类测速结果不等于 IP 纯净度,但可以帮助判断节点访问目标网站是否稳定。 + +5. 推荐检查顺序 + 先打开 [IP111](https://ip111.cn/) 确认出口 IP 是否切换成功。 + 再打开 [IPData](https://ipdata.co/) 查看 IP 归属、ASN 和风险标签。 + 接着打开 [IPPure](https://ippure.com/) 检查纯净度、黑名单、代理识别和泄露情况。 + 最后用 [TCPTest 网站测速](https://www.tcptest.cn/?cckey=e62f31db) 测试目标网站的访问速度和连通性。 + +### 第十部分:配置 [Clash Verge](https://github.com/clash-verge-rev/clash-verge-rev) 的 `🔁 非港轮询` #### 第一步:添加扩展脚本 @@ -497,6 +568,48 @@ function main(config, profileName) { 4. 确认右上角的 `代理模式`(`Mode`)已经设置为 `规则模式`(`Rule`)。 5. 确认左侧 `设置` 中的 `系统代理`(`System Proxy`)已经开启。 ![2026 04 25 003703](https://apikey.qzz.io/content-assets/library/2026/04/20260424-163745--2026-04-25-003703--21e892504b82.png) + +### 第十一部分:订阅节点与自建推荐 + +如果你还没有订阅节点或想要寻找稳定、便宜的科学上网方式,可以参考以下三种方案获取。 + +#### 方法一:获取日常免费节点(85LA) + +1. 打开 [85LA 科学上网免费节点](https://www.85la.com/internet-access)。 +2. 网站每天会更新包含数百个测试通过的高速节点订阅,支持 `v2ray`、`Clash`、`SING-BOX` 等主流客户端。 +3. 复制最新日期的订阅链接,直接导入你的客户端即可免费使用。 + +#### 方法二:选择限时免费与高性价比机场推荐(二毛博客) + +1. 打开 [2026年翻墙机场推荐评测](https://www.ermao.net/posts/vpn/)。 +2. 该页面整理了数十家机场的短期与长期流量包价格,其中像 `ssone`、`flybit` 等很多机场在注册后均赠送限时免费试用(如送 1 天 1G/2G 流量等)。 +3. 可以根据网页中的评测挑选符合你需求与预算的低价机场来保障长期稳定。 + +#### 方法三:十分钟自建 Cloudflare 永久免费节点 + +如果你不想花钱,且希望获得一个长期稳定的私人免费节点,可以利用 `Cloudflare Pages` 快速搭建。 + +1. **第一步:注册免费域名** + 访问诸如 [DNS.HE](https://www.dnshe.com/) 或 [DigitalPlat](https://digitalplat.org/) 注册,并获取一个永久免费的域名(例如结尾是 `ccwu.cc` 或 `us.ci` 的域名)。 + +2. **第二步:托管域名至 Cloudflare** + 登录 [Cloudflare 后台](https://www.cloudflare.com/),将刚刚申请好的免费域名添加进去进行托管,并根据提示修改域名的 NS 记录直到激活显示。 + +3. **第三步:创建 KV 命名空间** + 在 Cloudflare 后台侧边栏找到 `存储和数据库` -> `Worker KV`,点击 `创建命名空间`,可以随意命名(主要是为了稍后绑定缓存数据)。 + +4. **第四步:部署 Pages 节点服务** + - 展开侧边栏 `Workers 和 Pages`,点击 `创建`,然后选择 `Pages` 标签页中的 `上传资产`。 + - 下载由 `cmliu` 开源的 [EdgeTunnel 资源库](https://github.com/cmliu/edgetunnel/archive/refs/heads/main.zip) 原程序压缩包(`main.zip`)。 + - 将 `main.zip` 上传并部署。 + - 部署完成后点击 `继续处理站点`,进入项目的 `设置` -> `环境变量` -> `添加变量`,变量名填入 `ADMIN`,值设为你想要的后台登录管理员密码,点击 `保存`。 + - 在同一个 `设置` 页中选择 `绑定` -> `添加` -> `KV 命名空间`,变量名称统一填写 `KV`,并选中你在第三步创建好的那个命名空间进行保存。 + - 返回 `部署` 选项卡重新上传 `main.zip` 进行覆盖部署即可使得配置生效。 + - 返回项目的 `自定义域` 标签卡,添加一条你的免费域名(如 `node.ccwu.cc`),并按要求去 DNS 面板配置 CNAME 指向分配的 `pages.dev` 后激活。 + +5. **第五步:获取订阅与后台管理** + 直接在浏览器访问你的自定义域名并加上后台路径(如 `https://node.ccwu.cc/admin`),通过第四步设置的密码登录后台。在后台即可复制你的专属 VLESS / Trojan 节点订阅地址,也可导入到各路客户端中直接使用。 + ## 常见问题 ### 为什么第十步显示认证成功,但没有认证文件? @@ -511,6 +624,16 @@ function main(config, profileName) { 可以。你在 `账号管理` 中创建英文邮箱和 `Foxmail` 邮箱,使用后直接删除,再重复创建即可继续使用。 +### `网易邮箱` 一个手机号大约可以注册多少个邮箱? + +按目前使用经验,同一个手机号可以分别注册 `163`、`126` 和 `yeah` 邮箱,加起来大约 `15` 个账号。 +每个账号还可以添加 `2` 个 `替身邮箱`,所以总共大约可以得到 `45` 个可用邮箱地址。 + +### `网易邮箱` 注册时提示频繁怎么办? + +先停止连续注册,隔一段时间再继续。 +短时间连续注册容易触发频繁提示或限制,建议注册一个后暂停一会儿,或者换一个网络环境再继续。 + ### `iCloud 隐私邮箱` 插件刷新后没有邮箱怎么办? 先确认你已经在网页中登录 `iCloud`,并且已经在 `隐藏邮件地址` 里手动创建过隐私邮箱。 @@ -538,6 +661,18 @@ function main(config, profileName) { 请先确认脚本已经完整粘贴并保存,然后回到 `代理` 页面重新查看。若仍未出现,请继续确认当前订阅可用、`代理模式` 为 `规则模式`,并且 `系统代理` 已开启。 +### 网站测速结果好,是否代表节点纯净? + +不代表。 +`TCPTest` 主要看连接速度、解析耗时、状态码和线路稳定性。 +节点纯净度还需要结合 [IPData](https://ipdata.co/) 和 [IPPure](https://ippure.com/) 的风险标签、代理识别、黑名单、`DNS` 泄露和 `WebRTC` 泄露结果一起判断。 + +### 切换节点后为什么检测网站结果没变? + +先确认 [Clash Verge](https://github.com/clash-verge-rev/clash-verge-rev) 已经切换到目标节点或目标策略组。 +然后刷新检测网站,必要时关闭浏览器重新打开。 +如果 [IP111](https://ip111.cn/) 仍显示旧 IP,说明当前浏览器流量可能没有走新节点,或者分流规则没有命中。 + ## 注意事项 - 不论使用哪种方式更新扩展,更新完成后都必须在浏览器的 `扩展程序管理` 页面重新加载一次该扩展。 @@ -548,9 +683,14 @@ function main(config, profileName) { - 如果条件允许,建议使用非日常主力 Apple ID 配置 `iCloud 隐私邮箱`,避免影响平时常用账号。 - 如需部署 `cpa`,请先确认部署环境可以访问 `OpenAI`,否则可能出现第十步认证成功但没有认证文件的问题。 - `QQ 邮箱`切换邮箱时,建议在使用完成后及时删除,再重新创建,避免混淆当前正在使用的邮箱地址。 +- `网易邮箱` 注册时不要短时间连续创建多个账号,否则容易触发频繁提示或限制。 +- `网易邮箱` 的 `替身邮箱` 是在单个邮箱账号下添加的,注册完成主邮箱后再进入 `替身邮箱` 页面添加。 - 中国大陆居民注册 `PayPal` 个人账户时,姓名请按身份证上的中文姓名填写,不要使用拼音。 - `PayPal` 官方说明,绑定新卡时可能会向发卡行发送最高 `1 USD` 或等值货币的临时授权验证,所以完全没有余额的卡也可能因为授权失败而无法绑定。 - 为了控制风险,更稳妥的做法是使用单独管理、余额较低的借记卡,并在绑卡后及时检查 `钱包` 页面和右上角通知。 +- 检查节点时,先确认出口 IP,再看风险标签和泄露检测,最后再看网站测速。 +- [TCPTest 网站测速](https://www.tcptest.cn/?cckey=e62f31db) 主要用于判断访问速度和连通性,不要单独用它判断节点纯净度。 +- [IPPure](https://ippure.com/) 如果提示 `WebRTC` 或 `DNS` 泄露,请优先检查浏览器和代理客户端的相关设置。 - 使用 `git pull` 更新扩展是最方便、最推荐的方式。 - 在 `Clash Verge` 中粘贴脚本时,请先清空旧内容,再完整粘贴新代码并保存。 diff --git a/manifest.json b/manifest.json index f1e2895..b6cb432 100644 --- a/manifest.json +++ b/manifest.json @@ -1,8 +1,8 @@ { "manifest_version": 3, "name": "codex-oauth-automation-extension", - "version": "1.4", - "version_name": "Ultra1.4", + "version": "2.0", + "version_name": "Ultra2.0", "description": "用于自动执行多步骤 OAuth 注册流程", "permissions": [ "sidePanel", diff --git a/scripts/hotmail_helper.py b/scripts/hotmail_helper.py index 0a21d10..806ecad 100644 --- a/scripts/hotmail_helper.py +++ b/scripts/hotmail_helper.py @@ -76,8 +76,11 @@ def json_response(handler, status, payload): 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) + try: + handler.end_headers() + handler.wfile.write(body) + except (BrokenPipeError, ConnectionResetError) as exc: + log_info(f"response aborted by client status={status} detail={compact_text(exc)}") def read_json_payload(handler): @@ -120,6 +123,45 @@ def log_info(message): print(f"[HotmailHelper] {message}", flush=True) +def get_proxy_debug_context(): + names = ["all_proxy", "http_proxy", "https_proxy", "ALL_PROXY", "HTTP_PROXY", "HTTPS_PROXY"] + parts = [] + for name in names: + value = str(os.environ.get(name) or "").strip() + if value: + parts.append(f"{name}={value}") + return ",".join(parts) if parts else "direct" + + +def classify_token_refresh_failure(result): + detail = str(result.get("error") or "").strip().lower() + if "invalid_grant" in detail or "aadsts70000" in detail: + return "invalid_grant" + if "proxy authentication required" in detail: + return "proxy_auth_failed" + if "connection refused" in detail: + return "proxy_connect_failed" if get_proxy_debug_context() != "direct" else "connection_refused" + if "eof occurred in violation of protocol" in detail or "wrong version number" in detail: + return "proxy_tls_failed" if get_proxy_debug_context() != "direct" else "tls_failed" + if "timed out" in detail or "timeout" in detail: + return "network_timeout" + return "request_failed" + + +def log_token_refresh_failure_diagnosis(result): + category = classify_token_refresh_failure(result) + message = ( + "token refresh diagnosis " + f"endpoint={result['endpoint']} " + f"category={category}" + ) + if category.startswith("proxy_"): + message += f" proxy={get_proxy_debug_context()}" + elif category == "invalid_grant": + message += " hint=refresh_token_or_scope_invalid" + log_info(message) + + def append_account_log(email_addr, password, status, recorded_at="", reason=""): normalized_email = str(email_addr or "").strip() normalized_password = str(password or "").strip() @@ -320,6 +362,7 @@ def refresh_access_token(client_id, refresh_token, strategy_names=None): f"elapsedMs={result['elapsed_ms']} " f"detail={result['error']}" ) + log_token_refresh_failure_diagnosis(result) details = " | ".join( f"{item['endpoint']}({item['status']}): {item['error']}" @@ -532,12 +575,12 @@ def normalize_outlook_message(message, mailbox): 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" - ) + query = urlencode({ + "$top": max(1, min(int(top or FETCH_LIMIT_DEFAULT), 30)), + "$select": "id,internetMessageId,subject,from,bodyPreview,receivedDateTime", + "$orderby": "receivedDateTime desc", + }) + url = f"{GRAPH_API_ORIGIN}/v1.0/me/mailFolders/{mailbox_id}/messages?{query}" try: _, payload = get_json(url, headers={ "Accept": "application/json", @@ -555,12 +598,12 @@ def fetch_graph_messages(access_token, mailbox="INBOX", top=FETCH_LIMIT_DEFAULT) 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" - ) + query = urlencode({ + "$top": max(1, min(int(top or FETCH_LIMIT_DEFAULT), 30)), + "$select": "Id,Subject,From,BodyPreview,ReceivedDateTime", + "$orderby": "ReceivedDateTime desc", + }) + url = f"{OUTLOOK_API_ORIGIN}/api/v2.0/me/mailfolders/{mailbox_id}/messages?{query}" try: _, payload = get_json(url, headers={ "Accept": "application/json", diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js index 4d416d3..52779b8 100644 --- a/sidepanel/sidepanel.js +++ b/sidepanel/sidepanel.js @@ -747,7 +747,7 @@ const MAIL_PROVIDER_LOGIN_CONFIGS = { const IP_PROXY_SERVICE_LOGIN_CONFIGS = { '711proxy': { label: '711Proxy', - url: 'https://www.711proxy.com/', + url: 'https://www.711proxy.com/signup?code=AD2497', buttonLabel: '登录', }, }; diff --git a/tests/auto-run-hotmail-preflight.test.js b/tests/auto-run-hotmail-preflight.test.js new file mode 100644 index 0000000..c62fa2c --- /dev/null +++ b/tests/auto-run-hotmail-preflight.test.js @@ -0,0 +1,164 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +const source = fs.readFileSync('background/auto-run-controller.js', 'utf8'); +const globalScope = {}; +const api = new Function('self', `${source}; return self.MultiPageBackgroundAutoRunController;`)(globalScope); + +test('auto-run controller verifies hotmail mailbox before each fresh attempt starts', async () => { + const events = { + order: [], + preflightCalls: [], + runCalls: 0, + }; + + let currentState = { + stepStatuses: {}, + vpsUrl: 'https://example.com/vps', + vpsPassword: 'secret', + customPassword: '', + autoRunSkipFailures: false, + autoRunFallbackThreadIntervalMinutes: 0, + autoRunDelayEnabled: false, + autoRunDelayMinutes: 30, + autoStepDelaySeconds: null, + mailProvider: 'hotmail-api', + emailGenerator: 'duck', + gmailBaseEmail: '', + mail2925BaseEmail: '', + emailPrefix: 'demo', + inbucketHost: '', + inbucketMailbox: '', + cloudflareDomain: '', + cloudflareDomains: [], + tabRegistry: {}, + sourceLastUrls: {}, + autoRunRoundSummaries: [], + }; + + const runtime = { + state: { + autoRunActive: false, + autoRunCurrentRun: 0, + autoRunTotalRuns: 1, + autoRunAttemptRun: 0, + autoRunSessionId: 0, + }, + get() { + return { ...this.state }; + }, + set(updates = {}) { + this.state = { ...this.state, ...updates }; + }, + }; + + let sessionSeed = 0; + + const controller = api.createAutoRunController({ + addLog: async () => {}, + appendAccountRunRecord: async () => null, + AUTO_RUN_MAX_RETRIES_PER_ROUND: 3, + AUTO_RUN_RETRY_DELAY_MS: 3000, + AUTO_RUN_TIMER_KIND_BEFORE_RETRY: 'before_retry', + AUTO_RUN_TIMER_KIND_BETWEEN_ROUNDS: 'between_rounds', + broadcastAutoRunStatus: async (phase, payload = {}) => { + currentState = { + ...currentState, + autoRunning: ['scheduled', 'running', 'waiting_step', 'waiting_email', 'retrying', 'waiting_interval'].includes(phase), + autoRunPhase: phase, + autoRunCurrentRun: payload.currentRun ?? runtime.state.autoRunCurrentRun, + autoRunTotalRuns: payload.totalRuns ?? runtime.state.autoRunTotalRuns, + autoRunAttemptRun: payload.attemptRun ?? runtime.state.autoRunAttemptRun, + autoRunSessionId: payload.sessionId ?? runtime.state.autoRunSessionId, + }; + }, + broadcastStopToContentScripts: async () => {}, + cancelPendingCommands: () => {}, + clearStopRequest: () => {}, + createAutoRunSessionId: () => { + sessionSeed += 1; + return sessionSeed; + }, + ensureHotmailMailboxReadyForAutoRunRound: async (payload = {}) => { + events.order.push('preflight'); + events.preflightCalls.push({ ...payload }); + }, + getAutoRunStatusPayload: (phase, payload = {}) => ({ + autoRunning: ['scheduled', 'running', 'waiting_step', 'waiting_email', 'retrying', 'waiting_interval'].includes(phase), + autoRunPhase: phase, + autoRunCurrentRun: payload.currentRun ?? 0, + autoRunTotalRuns: payload.totalRuns ?? 1, + autoRunAttemptRun: payload.attemptRun ?? 0, + autoRunSessionId: payload.sessionId ?? 0, + }), + getErrorMessage: (error) => error?.message || String(error || ''), + getFirstUnfinishedStep: () => 1, + getPendingAutoRunTimerPlan: () => null, + getRunningSteps: () => [], + getState: async () => ({ + ...currentState, + stepStatuses: { ...(currentState.stepStatuses || {}) }, + tabRegistry: { ...(currentState.tabRegistry || {}) }, + sourceLastUrls: { ...(currentState.sourceLastUrls || {}) }, + }), + getStopRequested: () => false, + hasSavedProgress: () => false, + isAddPhoneAuthFailure: () => false, + isRestartCurrentAttemptError: () => false, + isSignupUserAlreadyExistsFailure: () => false, + isStopError: (error) => (error?.message || String(error || '')) === '流程已被用户停止。', + launchAutoRunTimerPlan: async () => false, + normalizeAutoRunFallbackThreadIntervalMinutes: (value) => Math.max(0, Math.floor(Number(value) || 0)), + persistAutoRunTimerPlan: async () => ({}), + resetState: async () => { + currentState = { + ...currentState, + stepStatuses: {}, + tabRegistry: {}, + sourceLastUrls: {}, + }; + }, + runAutoSequenceFromStep: async () => { + events.order.push('run'); + events.runCalls += 1; + }, + runtime, + setState: async (updates = {}) => { + currentState = { + ...currentState, + ...updates, + stepStatuses: updates.stepStatuses ? { ...updates.stepStatuses } : currentState.stepStatuses, + tabRegistry: updates.tabRegistry ? { ...updates.tabRegistry } : currentState.tabRegistry, + sourceLastUrls: updates.sourceLastUrls ? { ...updates.sourceLastUrls } : currentState.sourceLastUrls, + }; + }, + sleepWithStop: async () => {}, + throwIfAutoRunSessionStopped: (sessionId) => { + if (sessionId && sessionId !== runtime.state.autoRunSessionId) { + throw new Error('流程已被用户停止。'); + } + }, + waitForRunningStepsToFinish: async () => currentState, + chrome: { + runtime: { + sendMessage() { + return Promise.resolve(); + }, + }, + }, + }); + + await controller.autoRunLoop(1, { + autoRunSkipFailures: false, + mode: 'restart', + }); + + assert.equal(events.runCalls, 1); + assert.equal(events.preflightCalls.length, 1); + assert.deepEqual(events.order, ['preflight', 'run']); + assert.match( + JSON.stringify(events.preflightCalls[0]), + /"targetRun":1/ + ); +}); diff --git a/tests/hotmail-auto-run-preflight.test.js b/tests/hotmail-auto-run-preflight.test.js new file mode 100644 index 0000000..ce7a585 --- /dev/null +++ b/tests/hotmail-auto-run-preflight.test.js @@ -0,0 +1,314 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +const { pickHotmailAccountForRun } = require('../hotmail-utils.js'); + +const source = fs.readFileSync('background.js', 'utf8'); + +function extractFunction(name) { + const markers = [`async function ${name}(`, `function ${name}(`]; + const start = markers + .map((marker) => source.indexOf(marker)) + .find((index) => index >= 0); + if (start < 0) { + return ''; + } + + let parenDepth = 0; + let signatureEnded = false; + let braceStart = -1; + for (let index = start; index < source.length; index += 1) { + const ch = source[index]; + if (ch === '(') { + parenDepth += 1; + } else if (ch === ')') { + parenDepth -= 1; + if (parenDepth === 0) { + signatureEnded = true; + } + } else if (ch === '{' && signatureEnded) { + braceStart = index; + break; + } + } + + if (braceStart < 0) { + return ''; + } + + let depth = 0; + let end = braceStart; + for (; end < source.length; end += 1) { + const ch = source[end]; + if (ch === '{') depth += 1; + if (ch === '}') { + depth -= 1; + if (depth === 0) { + end += 1; + break; + } + } + } + + return source.slice(start, end); +} + +const isAuthorizedHotmailRunAccountSource = extractFunction('isAuthorizedHotmailRunAccount'); +const isPendingHotmailVerificationCandidateSource = extractFunction('isPendingHotmailVerificationCandidate'); +const compareHotmailAccountAllocationPrioritySource = extractFunction('compareHotmailAccountAllocationPriority'); +const pickPendingHotmailAccountForVerificationSource = extractFunction('pickPendingHotmailAccountForVerification'); +const ensureHotmailAccountForFlowSource = extractFunction('ensureHotmailAccountForFlow'); +const ensureHotmailMailboxReadyForAutoRunRoundSource = extractFunction('ensureHotmailMailboxReadyForAutoRunRound'); + +function createHotmailPreflightApi(initialState, verifyImpl = async () => ({ account: null, messageCount: 0 })) { + const factory = new Function('deps', ` +let currentState = JSON.parse(JSON.stringify(deps.initialState)); +const getState = async () => ({ + ...currentState, + hotmailAccounts: Array.isArray(currentState.hotmailAccounts) + ? currentState.hotmailAccounts.map((account) => ({ ...account })) + : [], +}); +const normalizeHotmailAccounts = (accounts) => Array.isArray(accounts) + ? accounts.map((account) => ({ ...account })) + : []; +const findHotmailAccount = (accounts, accountId) => normalizeHotmailAccounts(accounts) + .find((account) => account.id === accountId) || null; +const setCurrentHotmailAccount = async (accountId) => { + const state = await getState(); + const account = findHotmailAccount(state.hotmailAccounts, accountId); + if (!account) { + throw new Error('missing Hotmail account'); + } + currentState = { + ...currentState, + currentHotmailAccountId: accountId, + }; + return account; +}; +const pickHotmailAccountForRun = deps.pickHotmailAccountForRun; +const verifyHotmailAccount = async (accountId) => deps.verifyHotmailAccount(accountId, async () => getState()); +const isHotmailProvider = (stateOrProvider) => { + const provider = typeof stateOrProvider === 'string' + ? stateOrProvider + : stateOrProvider?.mailProvider; + return provider === 'hotmail-api'; +}; +const addLog = async (message, level = 'info') => { + deps.logs.push({ message, level }); +}; +const throwIfStopped = () => {}; +${isAuthorizedHotmailRunAccountSource} +${isPendingHotmailVerificationCandidateSource} +${compareHotmailAccountAllocationPrioritySource} +${pickPendingHotmailAccountForVerificationSource} +${ensureHotmailAccountForFlowSource} +${ensureHotmailMailboxReadyForAutoRunRoundSource} +return { + ensureHotmailAccountForFlow, + ensureHotmailMailboxReadyForAutoRunRound: typeof ensureHotmailMailboxReadyForAutoRunRound === 'function' + ? ensureHotmailMailboxReadyForAutoRunRound + : undefined, + getState, +}; + `); + + const logs = []; + return { + api: factory({ + initialState, + logs, + pickHotmailAccountForRun, + verifyHotmailAccount: verifyImpl, + }), + logs, + }; +} + +test('ensureHotmailAccountForFlow skips excluded current hotmail account when allocating a fresh account', async () => { + const { api } = createHotmailPreflightApi({ + mailProvider: 'hotmail-api', + currentHotmailAccountId: 'primary', + hotmailAccounts: [ + { + id: 'primary', + email: 'primary@hotmail.com', + status: 'authorized', + refreshToken: 'rt-primary', + used: false, + lastUsedAt: 1, + }, + { + id: 'backup', + email: 'backup@hotmail.com', + status: 'authorized', + refreshToken: 'rt-backup', + used: false, + lastUsedAt: 2, + }, + ], + }); + + const account = await api.ensureHotmailAccountForFlow({ + allowAllocate: true, + markUsed: false, + excludeIds: ['primary'], + }); + + assert.equal(account.id, 'backup'); +}); + +test('ensureHotmailMailboxReadyForAutoRunRound switches to another hotmail account after a verification failure', async () => { + const verifyCalls = []; + const { api, logs } = createHotmailPreflightApi({ + mailProvider: 'hotmail-api', + currentHotmailAccountId: 'primary', + hotmailAccounts: [ + { + id: 'primary', + email: 'primary@hotmail.com', + status: 'authorized', + refreshToken: 'rt-primary', + used: false, + lastUsedAt: 1, + }, + { + id: 'backup', + email: 'backup@hotmail.com', + status: 'authorized', + refreshToken: 'rt-backup', + used: false, + lastUsedAt: 2, + }, + ], + }, async (accountId, getState) => { + verifyCalls.push(accountId); + const state = await getState(); + const account = state.hotmailAccounts.find((item) => item.id === accountId); + if (accountId === 'primary') { + throw new Error('INBOX unavailable'); + } + return { + account, + messageCount: 4, + }; + }); + + assert.equal(typeof api.ensureHotmailMailboxReadyForAutoRunRound, 'function'); + const account = await Promise.race([ + api.ensureHotmailMailboxReadyForAutoRunRound({ + targetRun: 1, + totalRuns: 3, + attemptRun: 1, + }), + new Promise((_, reject) => { + setTimeout(() => reject(new Error('Hotmail auto-run preflight timed out')), 200); + }), + ]); + + const state = await api.getState(); + + assert.equal(account.id, 'backup'); + assert.equal(state.currentHotmailAccountId, 'backup'); + assert.deepEqual(verifyCalls, ['primary', 'backup']); + assert.ok(logs.some(({ message }) => /切换下一个 Hotmail 账号/.test(message))); +}); + +test('ensureHotmailMailboxReadyForAutoRunRound verifies pending hotmail accounts when no authorized account exists yet', async () => { + const verifyCalls = []; + const { api } = createHotmailPreflightApi({ + mailProvider: 'hotmail-api', + currentHotmailAccountId: null, + hotmailAccounts: [ + { + id: 'pending-1', + email: 'pending-1@hotmail.com', + status: 'pending', + refreshToken: 'rt-pending-1', + used: false, + lastUsedAt: 0, + }, + ], + }, async (accountId, getState) => { + verifyCalls.push(accountId); + const state = await getState(); + const account = state.hotmailAccounts.find((item) => item.id === accountId); + return { + account: { + ...account, + status: 'authorized', + }, + messageCount: 2, + }; + }); + + const account = await api.ensureHotmailMailboxReadyForAutoRunRound({ + targetRun: 1, + totalRuns: 1, + attemptRun: 1, + }); + const state = await api.getState(); + + assert.equal(account.id, 'pending-1'); + assert.equal(state.currentHotmailAccountId, 'pending-1'); + assert.deepEqual(verifyCalls, ['pending-1']); +}); + +test('ensureHotmailMailboxReadyForAutoRunRound falls back to pending hotmail accounts after authorized accounts fail', async () => { + const verifyCalls = []; + const { api, logs } = createHotmailPreflightApi({ + mailProvider: 'hotmail-api', + currentHotmailAccountId: 'authorized-primary', + hotmailAccounts: [ + { + id: 'authorized-primary', + email: 'authorized-primary@hotmail.com', + status: 'authorized', + refreshToken: 'rt-authorized-primary', + used: false, + lastUsedAt: 1, + }, + { + id: 'pending-backup', + email: 'pending-backup@hotmail.com', + status: 'pending', + refreshToken: 'rt-pending-backup', + used: false, + lastUsedAt: 2, + }, + ], + }, async (accountId, getState) => { + verifyCalls.push(accountId); + const state = await getState(); + const account = state.hotmailAccounts.find((item) => item.id === accountId); + if (accountId === 'authorized-primary') { + throw new Error('INBOX unavailable'); + } + return { + account: { + ...account, + status: 'authorized', + }, + messageCount: 3, + }; + }); + + const account = await Promise.race([ + api.ensureHotmailMailboxReadyForAutoRunRound({ + targetRun: 1, + totalRuns: 2, + attemptRun: 1, + }), + new Promise((_, reject) => { + setTimeout(() => reject(new Error('Hotmail auto-run pending fallback timed out')), 200); + }), + ]); + + const state = await api.getState(); + + assert.equal(account.id, 'pending-backup'); + assert.equal(state.currentHotmailAccountId, 'pending-backup'); + assert.deepEqual(verifyCalls, ['authorized-primary', 'pending-backup']); + assert.ok(logs.some(({ message }) => /待校验|未校验/.test(message))); +}); diff --git a/tests/hotmail_helper_logging_test.py b/tests/hotmail_helper_logging_test.py new file mode 100644 index 0000000..dade9e4 --- /dev/null +++ b/tests/hotmail_helper_logging_test.py @@ -0,0 +1,73 @@ +import importlib.util +import io +import unittest +from contextlib import redirect_stdout +from pathlib import Path +from unittest import mock + + +def load_hotmail_helper(): + module_path = Path(__file__).resolve().parents[1] / "scripts" / "hotmail_helper.py" + spec = importlib.util.spec_from_file_location("hotmail_helper", module_path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +hotmail_helper = load_hotmail_helper() + + +class HotmailHelperLoggingTest(unittest.TestCase): + def test_refresh_access_token_logs_invalid_grant_and_direct_connection_refused_separately(self): + failures = [ + { + "ok": False, + "endpoint": "entra-common-delegated", + "url": "https://login.microsoftonline.com/common/oauth2/v2.0/token", + "status": 400, + "error": '{"error":"invalid_grant","error_description":"AADSTS70000"}', + "elapsed_ms": 101, + }, + { + "ok": False, + "endpoint": "entra-consumers-delegated", + "url": "https://login.microsoftonline.com/consumers/oauth2/v2.0/token", + "status": None, + "error": "Token request failed: ", + "elapsed_ms": 88, + }, + ] + + with mock.patch.object(hotmail_helper, "try_refresh_access_token", side_effect=failures): + output = io.StringIO() + with redirect_stdout(output): + with self.assertRaises(RuntimeError): + hotmail_helper.refresh_access_token( + "client-id-demo", + "refresh-token-demo", + ["entra-common-delegated", "entra-consumers-delegated"], + ) + + rendered = output.getvalue() + self.assertIn("category=invalid_grant", rendered) + self.assertIn("category=connection_refused", rendered) + + def test_graph_and_outlook_message_urls_are_encoded(self): + captured_urls = [] + + def fake_get_json(url, headers=None): + captured_urls.append(url) + return 200, {"value": []} + + with mock.patch.object(hotmail_helper, "get_json", side_effect=fake_get_json): + hotmail_helper.fetch_graph_messages("access-token-demo", mailbox="INBOX", top=5) + hotmail_helper.fetch_outlook_api_messages("access-token-demo", mailbox="INBOX", top=5) + + self.assertEqual(len(captured_urls), 2) + self.assertTrue(all(" " not in url for url in captured_urls)) + self.assertIn("%24orderby=receivedDateTime+desc", captured_urls[0]) + self.assertIn("%24orderby=ReceivedDateTime+desc", captured_urls[1]) + + +if __name__ == "__main__": + unittest.main()