merge: integrate hotmail邮箱支持 into dev
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
一个用于批量跑通 ChatGPT OAuth 注册/登录流程的 Chrome 扩展。
|
一个用于批量跑通 ChatGPT OAuth 注册/登录流程的 Chrome 扩展。
|
||||||
|
|
||||||
当前版本基于侧边栏控制,支持单步执行、整套自动执行、停止当前流程、保存常用配置,以及通过 DuckDuckGo / QQ / 163 / Inbucket mailbox 协助获取验证码。
|
当前版本基于侧边栏控制,支持单步执行、整套自动执行、停止当前流程、保存常用配置,以及通过 DuckDuckGo / QQ / 163 / Inbucket / Hotmail 协助获取验证码。
|
||||||
|
|
||||||
## 最新版本测试结果
|
## 最新版本测试结果
|
||||||
|
|
||||||
@@ -42,6 +42,7 @@
|
|||||||
- 支持自定义密码;留空时自动生成强密码
|
- 支持自定义密码;留空时自动生成强密码
|
||||||
- 自动显示当前使用中的密码,便于后续保存
|
- 自动显示当前使用中的密码,便于后续保存
|
||||||
- 自动获取注册验证码与登录验证码
|
- 自动获取注册验证码与登录验证码
|
||||||
|
- 支持 `Hotmail`:直接使用 `邮箱 + 客户端 ID + 刷新令牌(refresh token)` 刷新微软令牌,并通过 Microsoft Graph 读取最新邮件
|
||||||
- 支持 `QQ Mail`、`163 Mail`、`Inbucket mailbox`
|
- 支持 `QQ Mail`、`163 Mail`、`Inbucket mailbox`
|
||||||
- 支持从 DuckDuckGo Email Protection 自动生成新的 `@duck.com` 地址
|
- 支持从 DuckDuckGo Email Protection 自动生成新的 `@duck.com` 地址
|
||||||
- 支持基于 Cloudflare 自定义域名自动生成随机邮箱前缀
|
- 支持基于 Cloudflare 自定义域名自动生成随机邮箱前缀
|
||||||
@@ -86,17 +87,37 @@ Step 1 和 Step 9 都依赖这个地址。
|
|||||||
|
|
||||||
### `Mail`
|
### `Mail`
|
||||||
|
|
||||||
支持三种验证码来源:
|
支持四种验证码来源:
|
||||||
|
|
||||||
|
- `Hotmail`
|
||||||
- `163 Mail`
|
- `163 Mail`
|
||||||
- `QQ Mail`
|
- `QQ Mail`
|
||||||
- `Inbucket`
|
- `Inbucket`
|
||||||
|
|
||||||
说明:
|
说明:
|
||||||
|
|
||||||
|
- `Hotmail` 通过侧边栏里的 Hotmail 账号池选择账号,并直接访问 Microsoft Graph 邮件接口
|
||||||
- `QQ` 和 `163` 用于直接轮询网页邮箱
|
- `QQ` 和 `163` 用于直接轮询网页邮箱
|
||||||
- `Inbucket` 通过你在侧边栏里配置的 host 访问 `mailbox` 页面:`https://<your-inbucket-host>/m/<mailbox>/`
|
- `Inbucket` 通过你在侧边栏里配置的 host 访问 `mailbox` 页面:`https://<your-inbucket-host>/m/<mailbox>/`
|
||||||
|
|
||||||
|
### `Hotmail 账号池`
|
||||||
|
|
||||||
|
仅当 `Mail = Hotmail` 时使用。
|
||||||
|
|
||||||
|
每条账号支持保存:
|
||||||
|
|
||||||
|
- `email`
|
||||||
|
- `clientId`
|
||||||
|
- `refreshToken`
|
||||||
|
- 可选邮箱密码备注
|
||||||
|
|
||||||
|
使用方式:
|
||||||
|
|
||||||
|
- 先新增账号
|
||||||
|
- 点击 `校验`
|
||||||
|
- 校验通过后,可点击 `测试收信`
|
||||||
|
- Auto 模式每轮会自动选用一个可用账号
|
||||||
|
|
||||||
### `Mailbox`
|
### `Mailbox`
|
||||||
|
|
||||||
仅当 `Mail = Inbucket` 时显示。
|
仅当 `Mail = Inbucket` 时显示。
|
||||||
@@ -144,6 +165,8 @@ Step 3 使用的注册邮箱。
|
|||||||
- 若 `邮箱生成 = Cloudflare`,插件里只需要维护 `CF 域名`
|
- 若 `邮箱生成 = Cloudflare`,插件里只需要维护 `CF 域名`
|
||||||
- `CF 域名` 支持保存多个,并通过下拉框切换当前要生成的域名
|
- `CF 域名` 支持保存多个,并通过下拉框切换当前要生成的域名
|
||||||
- Cloudflare 侧的转发规则、Catch-all、路由目标邮箱等,都需要你自己提前在 Cloudflare 后台配置好
|
- Cloudflare 侧的转发规则、Catch-all、路由目标邮箱等,都需要你自己提前在 Cloudflare 后台配置好
|
||||||
|
- 当 `Mail = Hotmail` 时,这个输入框由账号池自动同步当前账号邮箱
|
||||||
|
- 当前 `Auto` 按钮只负责 DuckDuckGo 地址获取
|
||||||
- 如果你使用 Inbucket,它只是验证码收件箱,不会自动生成 Inbucket 地址
|
- 如果你使用 Inbucket,它只是验证码收件箱,不会自动生成 Inbucket 地址
|
||||||
|
|
||||||
### `邮箱生成 = Cloudflare` 时的配置
|
### `邮箱生成 = Cloudflare` 时的配置
|
||||||
@@ -230,9 +253,11 @@ Cloudflare 模式下,插件不会再调用 Cloudflare API 创建路由。
|
|||||||
|
|
||||||
1. Step 1 获取 CPA OAuth 链接
|
1. Step 1 获取 CPA OAuth 链接
|
||||||
2. Step 2 打开 OpenAI 注册页
|
2. Step 2 打开 OpenAI 注册页
|
||||||
3. 按当前“邮箱生成”配置尝试自动获取邮箱(Duck 或 Cloudflare)
|
3. 根据 `Mail` 选择邮箱来源
|
||||||
4. 如果自动获取失败,暂停并等待你在侧边栏填写邮箱后点击 `Continue`
|
4. 如果 `Mail = Hotmail`,会从账号池自动分配一个可用账号
|
||||||
5. 继续执行 Step 3 ~ Step 9
|
5. 如果不是 Hotmail,则按当前“邮箱生成”配置尝试自动获取邮箱(Duck 或 Cloudflare)
|
||||||
|
6. 如果自动获取失败,暂停并等待你在侧边栏填写邮箱后点击 `Continue`
|
||||||
|
7. 继续执行 Step 3 ~ Step 9
|
||||||
|
|
||||||
也就是说:
|
也就是说:
|
||||||
|
|
||||||
@@ -277,10 +302,11 @@ Cloudflare 模式下,插件不会再调用 Cloudflare API 创建路由。
|
|||||||
|
|
||||||
根据 `Mail` 配置,轮询邮箱并提取 6 位验证码。
|
根据 `Mail` 配置,轮询邮箱并提取 6 位验证码。
|
||||||
|
|
||||||
进入邮箱轮询前,脚本会先确认认证页是否已经进入验证码页面;如果密码页出现 `糟糕,出错了 / Operation timed out` 并带有 `重试` 按钮,会先自动点击 `重试`、回到密码页重新提交,再继续等待验证码页面。
|
进入邮箱轮询前,脚本会先确认认证页是否已经进入验证码页面;如果密码页出现 `糟糕,出错了 / 操作超时(Operation timed out)` 并带有 `重试` 按钮,会先自动点击 `重试`、回到密码页重新提交,再继续等待验证码页面。
|
||||||
|
|
||||||
支持:
|
支持:
|
||||||
|
|
||||||
|
- `Hotmail`(Microsoft Graph 邮件接口)
|
||||||
- `content/qq-mail.js`
|
- `content/qq-mail.js`
|
||||||
- `content/mail-163.js`
|
- `content/mail-163.js`
|
||||||
- `content/inbucket-mail.js`
|
- `content/inbucket-mail.js`
|
||||||
@@ -403,6 +429,7 @@ Cloudflare 模式下,插件不会再调用 Cloudflare API 创建路由。
|
|||||||
- 邮箱服务
|
- 邮箱服务
|
||||||
- Inbucket 主机
|
- Inbucket 主机
|
||||||
- Inbucket 邮箱名
|
- Inbucket 邮箱名
|
||||||
|
- Hotmail 账号池与对应令牌
|
||||||
- 兜底开关
|
- 兜底开关
|
||||||
|
|
||||||
特点:
|
特点:
|
||||||
@@ -422,6 +449,7 @@ data/names.js 随机姓名、生日数据
|
|||||||
content/utils.js 通用工具:等待元素、点击、日志、停止控制
|
content/utils.js 通用工具:等待元素、点击、日志、停止控制
|
||||||
content/vps-panel.js CPA 面板步骤:Step 1 / Step 9
|
content/vps-panel.js CPA 面板步骤:Step 1 / Step 9
|
||||||
content/signup-page.js OpenAI 注册/登录页步骤:Step 2 / 3 / 5 / 6 / 8
|
content/signup-page.js OpenAI 注册/登录页步骤:Step 2 / 3 / 5 / 6 / 8
|
||||||
|
hotmail-utils.js Hotmail 收信相关通用辅助
|
||||||
content/duck-mail.js Duck 邮箱自动获取
|
content/duck-mail.js Duck 邮箱自动获取
|
||||||
content/qq-mail.js QQ 邮箱验证码轮询
|
content/qq-mail.js QQ 邮箱验证码轮询
|
||||||
content/mail-163.js 163 邮箱验证码轮询
|
content/mail-163.js 163 邮箱验证码轮询
|
||||||
|
|||||||
+859
-48
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
|||||||
|
(function activationUtilsModule(root, factory) {
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = factory();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
root.MultiPageActivationUtils = factory();
|
||||||
|
})(typeof self !== 'undefined' ? self : globalThis, function createActivationUtils() {
|
||||||
|
function normalizeTagName(tagName) {
|
||||||
|
return String(tagName || '').trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeType(type) {
|
||||||
|
return String(type || '').trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePathname(pathname) {
|
||||||
|
return String(pathname || '').trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActivationStrategy(target = {}) {
|
||||||
|
const tagName = normalizeTagName(target.tagName);
|
||||||
|
const type = normalizeType(target.type);
|
||||||
|
const pathname = normalizePathname(target.pathname);
|
||||||
|
const hasForm = Boolean(target.hasForm);
|
||||||
|
const isEmailVerificationRoute = /\/email-verification(?:[/?#]|$)/i.test(pathname);
|
||||||
|
const isSubmitButton = hasForm
|
||||||
|
&& (
|
||||||
|
(tagName === 'button' && (!type || type === 'submit'))
|
||||||
|
|| (tagName === 'input' && type === 'submit')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isSubmitButton && isEmailVerificationRoute) {
|
||||||
|
return { method: 'requestSubmit' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { method: 'click' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecoverableStep9AuthFailure(statusText) {
|
||||||
|
const text = String(statusText || '').trim();
|
||||||
|
if (!/认证失败:\s*/i.test(text)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return /timeout waiting for oauth callback|status code 5\d{2}|bad gateway|gateway timeout|temporarily unavailable/i.test(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
getActivationStrategy,
|
||||||
|
isRecoverableStep9AuthFailure,
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -233,9 +233,10 @@ async function prepareLoginCodeFlow(timeout = 15000) {
|
|||||||
loggedPasswordPage = false;
|
loggedPasswordPage = false;
|
||||||
log('步骤 7:检测到密码页,正在切换到一次性验证码登录...');
|
log('步骤 7:检测到密码页,正在切换到一次性验证码登录...');
|
||||||
await humanPause(350, 900);
|
await humanPause(350, 900);
|
||||||
|
const verificationRequestedAt = Date.now();
|
||||||
simulateClick(switchTrigger);
|
simulateClick(switchTrigger);
|
||||||
await sleep(1200);
|
await sleep(1200);
|
||||||
continue;
|
return { ready: true, mode: 'verification_switch', verificationRequestedAt };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (passwordInput && !loggedPasswordPage) {
|
if (passwordInput && !loggedPasswordPage) {
|
||||||
@@ -372,15 +373,16 @@ async function step3_fillEmailPassword(payload) {
|
|||||||
fillInput(passwordInput, payload.password);
|
fillInput(passwordInput, payload.password);
|
||||||
log('步骤 3:密码已填写');
|
log('步骤 3:密码已填写');
|
||||||
|
|
||||||
// Report complete BEFORE submit, because submit causes page navigation
|
|
||||||
// which kills the content script connection
|
|
||||||
reportComplete(3, { email });
|
|
||||||
|
|
||||||
// Submit the form (page will navigate away after this)
|
|
||||||
await sleep(500);
|
|
||||||
const submitBtn = document.querySelector('button[type="submit"]')
|
const submitBtn = document.querySelector('button[type="submit"]')
|
||||||
|| await waitForElementByText('button', /continue|sign\s*up|submit|注册|创建|create/i, 5000).catch(() => null);
|
|| await waitForElementByText('button', /continue|sign\s*up|submit|注册|创建|create/i, 5000).catch(() => null);
|
||||||
|
|
||||||
|
// Report complete BEFORE submit, because submit causes page navigation
|
||||||
|
// which kills the content script connection
|
||||||
|
const signupVerificationRequestedAt = submitBtn ? Date.now() : null;
|
||||||
|
reportComplete(3, { email, signupVerificationRequestedAt });
|
||||||
|
|
||||||
|
// Submit the form (page will navigate away after this)
|
||||||
|
await sleep(500);
|
||||||
if (submitBtn) {
|
if (submitBtn) {
|
||||||
await humanPause(500, 1300);
|
await humanPause(500, 1300);
|
||||||
simulateClick(submitBtn);
|
simulateClick(submitBtn);
|
||||||
|
|||||||
+30
-3
@@ -1,5 +1,7 @@
|
|||||||
// content/utils.js — Shared utilities for all content scripts
|
// content/utils.js — Shared utilities for all content scripts
|
||||||
|
|
||||||
|
const getActivationStrategy = self.MultiPageActivationUtils?.getActivationStrategy;
|
||||||
|
|
||||||
const SCRIPT_SOURCE = (() => {
|
const SCRIPT_SOURCE = (() => {
|
||||||
if (window.__MULTIPAGE_SOURCE) return window.__MULTIPAGE_SOURCE;
|
if (window.__MULTIPAGE_SOURCE) return window.__MULTIPAGE_SOURCE;
|
||||||
const url = location.href;
|
const url = location.href;
|
||||||
@@ -340,9 +342,34 @@ function reportError(step, errorMessage) {
|
|||||||
*/
|
*/
|
||||||
function simulateClick(el) {
|
function simulateClick(el) {
|
||||||
throwIfStopped();
|
throwIfStopped();
|
||||||
el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
if (!el) {
|
||||||
console.log(LOG_PREFIX, `已点击: ${el.tagName} ${el.textContent?.slice(0, 30) || ''}`);
|
throw new Error('无法点击空元素。');
|
||||||
log(`已点击 [${el.tagName}] "${el.textContent?.trim().slice(0, 30) || ''}"`);
|
}
|
||||||
|
|
||||||
|
const form = el.form || el.closest?.('form') || null;
|
||||||
|
const strategy = typeof getActivationStrategy === 'function'
|
||||||
|
? getActivationStrategy({
|
||||||
|
tagName: el.tagName,
|
||||||
|
type: el.getAttribute?.('type') || el.type || '',
|
||||||
|
hasForm: Boolean(form),
|
||||||
|
pathname: location.pathname || '',
|
||||||
|
})
|
||||||
|
: { method: 'click' };
|
||||||
|
|
||||||
|
let method = strategy.method || 'click';
|
||||||
|
|
||||||
|
if (method === 'requestSubmit' && form && typeof form.requestSubmit === 'function') {
|
||||||
|
form.requestSubmit(el);
|
||||||
|
} else if (typeof el.click === 'function') {
|
||||||
|
method = 'click';
|
||||||
|
el.click();
|
||||||
|
} else {
|
||||||
|
method = 'dispatchEvent';
|
||||||
|
el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(LOG_PREFIX, `已点击(${method}): ${el.tagName} ${el.textContent?.slice(0, 30) || ''}`);
|
||||||
|
log(`已点击(${method}) [${el.tagName}] "${el.textContent?.trim().slice(0, 30) || ''}"`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -26,6 +26,9 @@
|
|||||||
console.log('[MultiPage:vps-panel] Content script loaded on', location.href);
|
console.log('[MultiPage:vps-panel] Content script loaded on', location.href);
|
||||||
|
|
||||||
const VPS_PANEL_LISTENER_SENTINEL = 'data-multipage-vps-panel-listener';
|
const VPS_PANEL_LISTENER_SENTINEL = 'data-multipage-vps-panel-listener';
|
||||||
|
const {
|
||||||
|
isRecoverableStep9AuthFailure,
|
||||||
|
} = self.MultiPageActivationUtils || {};
|
||||||
|
|
||||||
if (document.documentElement.getAttribute(VPS_PANEL_LISTENER_SENTINEL) !== '1') {
|
if (document.documentElement.getAttribute(VPS_PANEL_LISTENER_SENTINEL) !== '1') {
|
||||||
document.documentElement.setAttribute(VPS_PANEL_LISTENER_SENTINEL, '1');
|
document.documentElement.setAttribute(VPS_PANEL_LISTENER_SENTINEL, '1');
|
||||||
@@ -208,6 +211,12 @@ async function waitForExactSuccessBadge(timeout = 30000) {
|
|||||||
while (Date.now() - start < timeout) {
|
while (Date.now() - start < timeout) {
|
||||||
throwIfStopped();
|
throwIfStopped();
|
||||||
const statusText = getStatusBadgeText();
|
const statusText = getStatusBadgeText();
|
||||||
|
if (isOAuthCallbackTimeoutFailure(statusText)) {
|
||||||
|
throw new Error(`STEP9_OAUTH_TIMEOUT::${statusText}`);
|
||||||
|
}
|
||||||
|
if (typeof isRecoverableStep9AuthFailure === 'function' && isRecoverableStep9AuthFailure(statusText)) {
|
||||||
|
throw new Error(`STEP9_OAUTH_RETRY::${statusText}`);
|
||||||
|
}
|
||||||
if (statusText === '认证成功!') {
|
if (statusText === '认证成功!') {
|
||||||
return statusText;
|
return statusText;
|
||||||
}
|
}
|
||||||
@@ -218,6 +227,9 @@ async function waitForExactSuccessBadge(timeout = 30000) {
|
|||||||
if (isOAuthCallbackTimeoutFailure(finalText)) {
|
if (isOAuthCallbackTimeoutFailure(finalText)) {
|
||||||
throw new Error(`STEP9_OAUTH_TIMEOUT::${finalText}`);
|
throw new Error(`STEP9_OAUTH_TIMEOUT::${finalText}`);
|
||||||
}
|
}
|
||||||
|
if (typeof isRecoverableStep9AuthFailure === 'function' && isRecoverableStep9AuthFailure(finalText)) {
|
||||||
|
throw new Error(`STEP9_OAUTH_RETRY::${finalText}`);
|
||||||
|
}
|
||||||
throw new Error(finalText
|
throw new Error(finalText
|
||||||
? `CPA 面板状态不是“认证成功!”,当前为“${finalText}”。`
|
? `CPA 面板状态不是“认证成功!”,当前为“${finalText}”。`
|
||||||
: 'CPA 面板长时间未出现“认证成功!”状态徽标。');
|
: 'CPA 面板长时间未出现“认证成功!”状态徽标。');
|
||||||
|
|||||||
@@ -0,0 +1,271 @@
|
|||||||
|
# Hotmail OAuth Mail Pool Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Add a Hotmail account pool with Microsoft OAuth authorization and Microsoft Graph mail polling, then wire it into the existing automation flow as a new mail provider.
|
||||||
|
|
||||||
|
**Architecture:** Keep the existing 1~9 step orchestrator in `background.js`, add a new `hotmail-api` provider path, and extend the side panel to manage Hotmail accounts. Preserve QQ, 163, and Inbucket behavior while introducing account allocation, token management, and Graph-based verification-code retrieval.
|
||||||
|
|
||||||
|
**Tech Stack:** Chrome Extension MV3, plain JavaScript, `chrome.identity.launchWebAuthFlow`, `fetch`, `chrome.storage.local`, `chrome.storage.session`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Extend State Model for Hotmail Accounts
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `background.js`
|
||||||
|
- Modify: `README.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Document the expected account shape and provider behavior in code comments or development notes before implementation:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Expected local storage shape:
|
||||||
|
// hotmailAccounts: [{ id, email, password, clientId, accessToken, refreshToken, expiresAt, status, lastUsedAt, lastAuthAt, lastError }]
|
||||||
|
// mailProvider accepts 'hotmail-api'
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run a manual smoke check by loading the current extension and confirming there is no `hotmail-api` provider and no persisted Hotmail account state.
|
||||||
|
|
||||||
|
Expected: The provider does not exist yet and account state is absent.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Add new persisted keys and runtime state helpers in `background.js`:
|
||||||
|
|
||||||
|
- `hotmailAccounts`
|
||||||
|
- `currentHotmailAccountId`
|
||||||
|
- helper functions to read, write, upsert, delete, and mark account status
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Reload the extension and confirm Hotmail account state can be read and written through background message handlers.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add background.js README.md
|
||||||
|
git commit -m "feat: add hotmail account state model"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 2: Add Hotmail Account Pool UI
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `sidepanel/sidepanel.html`
|
||||||
|
- Modify: `sidepanel/sidepanel.css`
|
||||||
|
- Modify: `sidepanel/sidepanel.js`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Describe the expected UI state:
|
||||||
|
|
||||||
|
- provider selector includes `hotmail-api`
|
||||||
|
- Hotmail account section renders a list
|
||||||
|
- user can add, authorize, test, and delete accounts
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Reload the current extension.
|
||||||
|
|
||||||
|
Expected: No Hotmail provider option and no account section.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Add:
|
||||||
|
|
||||||
|
- a new provider option
|
||||||
|
- a Hotmail accounts management section
|
||||||
|
- side panel event handlers for add, authorize, test, delete, and select current account status
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Reload the extension and verify:
|
||||||
|
|
||||||
|
- the new provider is visible
|
||||||
|
- the section appears only when selected
|
||||||
|
- account rows render correctly from stored state
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add sidepanel/sidepanel.html sidepanel/sidepanel.css sidepanel/sidepanel.js
|
||||||
|
git commit -m "feat: add hotmail account pool panel"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 3: Implement Microsoft OAuth Authorization
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `background.js`
|
||||||
|
- Modify: `manifest.json`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Define the expected authorization flow in a focused helper-oriented checklist:
|
||||||
|
|
||||||
|
- PKCE code verifier/challenge can be created
|
||||||
|
- auth URL includes client ID, redirect URI, state, scope, and challenge
|
||||||
|
- callback code is parsed and validated
|
||||||
|
- token response updates the account record
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Trigger the new `Authorize` action from the side panel.
|
||||||
|
|
||||||
|
Expected: The action is not implemented yet and fails.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Add background handlers and helpers for:
|
||||||
|
|
||||||
|
- PKCE generation
|
||||||
|
- OAuth URL creation
|
||||||
|
- `chrome.identity.getRedirectURL()`
|
||||||
|
- `chrome.identity.launchWebAuthFlow`
|
||||||
|
- code exchange via Microsoft token endpoint
|
||||||
|
- account token persistence and error reporting
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Manually authorize a Hotmail account and confirm:
|
||||||
|
|
||||||
|
- the login flow opens
|
||||||
|
- tokens are saved to the target account
|
||||||
|
- account status becomes `authorized`
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add background.js manifest.json
|
||||||
|
git commit -m "feat: add microsoft oauth authorization"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 4: Implement Token Refresh and Graph Mail Polling
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `background.js`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Define the expected helper behavior:
|
||||||
|
|
||||||
|
- expired access token refreshes with `refresh_token`
|
||||||
|
- Graph mail fetch returns recent inbox messages
|
||||||
|
- filtering returns the newest matching verification code after the requested timestamp
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Use the side panel `Test Mail Access` action before implementing the Graph path.
|
||||||
|
|
||||||
|
Expected: The action fails because Graph mail polling does not exist yet.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Add helpers in `background.js` for:
|
||||||
|
|
||||||
|
- token freshness check
|
||||||
|
- refresh-token grant request
|
||||||
|
- Graph inbox fetch
|
||||||
|
- mail filtering
|
||||||
|
- verification code extraction
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Authorize a Hotmail account and verify the test action can fetch mailbox data and surface a success or meaningful “no matching mail” response.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add background.js
|
||||||
|
git commit -m "feat: add graph mail polling"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 5: Wire Hotmail Provider into Step 3, Step 4, Step 7, and Auto Run
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `background.js`
|
||||||
|
- Modify: `sidepanel/sidepanel.js`
|
||||||
|
- Modify: `README.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Define the expected run behavior:
|
||||||
|
|
||||||
|
- Auto mode chooses a fresh authorized Hotmail account for each new run
|
||||||
|
- Step 3 uses the selected account email and password
|
||||||
|
- Step 4 and Step 7 read verification mail through Graph instead of mailbox tabs
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Select `hotmail-api` and run a manual or auto flow.
|
||||||
|
|
||||||
|
Expected: The flow cannot yet allocate an account or fetch verification codes from Graph.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Update:
|
||||||
|
|
||||||
|
- provider branching in `getMailConfig()` or a replacement provider resolver
|
||||||
|
- account allocation at fresh run start
|
||||||
|
- Step 3 account-backed credentials
|
||||||
|
- Step 4 and Step 7 Graph polling path
|
||||||
|
- auto-run preconditions for Hotmail accounts
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
1. manual Step 3 + Step 4 on a selected account
|
||||||
|
2. manual Step 6 + Step 7 on the same account
|
||||||
|
3. one full Auto run with `hotmail-api`
|
||||||
|
|
||||||
|
Expected: no mailbox tab is opened for Hotmail, and verification codes come from Graph.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add background.js sidepanel/sidepanel.js README.md
|
||||||
|
git commit -m "feat: integrate hotmail api provider into automation flow"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 6: Regression Verification and Cleanup
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `README.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
List the required regression checks:
|
||||||
|
|
||||||
|
- QQ provider still opens QQ mail tab
|
||||||
|
- 163 provider still opens 163 mail tab
|
||||||
|
- Inbucket provider still opens mailbox page
|
||||||
|
- Hotmail provider uses API path only
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Manually inspect pre-change behavior expectations against the new code before cleanup.
|
||||||
|
|
||||||
|
Expected: Any missing provider branch or broken selector is identified.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Clean up labels, update README usage instructions, and ensure all branches show accurate UI copy.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Reload the extension and perform:
|
||||||
|
|
||||||
|
- provider switch smoke test
|
||||||
|
- account add/delete smoke test
|
||||||
|
- one OAuth authorization smoke test
|
||||||
|
- one mail access smoke test
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add README.md
|
||||||
|
git commit -m "docs: document hotmail oauth mail provider"
|
||||||
|
```
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
# Hotmail OAuth + Graph Mail Design
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Replace the existing DuckDuckGo plus webmail polling flow with a Hotmail account pool that:
|
||||||
|
|
||||||
|
- authorizes each Hotmail account inside the extension via Microsoft OAuth
|
||||||
|
- stores per-account tokens locally
|
||||||
|
- selects a fresh account for each automated run
|
||||||
|
- fetches verification emails through Microsoft Graph instead of mailbox page DOM polling
|
||||||
|
|
||||||
|
The existing 1~9 step flow should remain intact wherever possible. The new work should be isolated to account selection, authorization, and email retrieval.
|
||||||
|
|
||||||
|
## Existing Constraints
|
||||||
|
|
||||||
|
- The project is a Manifest V3 Chrome extension with no build step.
|
||||||
|
- Runtime orchestration lives in `background.js`.
|
||||||
|
- The side panel is plain HTML/CSS/JS.
|
||||||
|
- Step 4 and Step 7 currently depend on provider-specific content scripts for QQ, 163, and Inbucket mailbox polling.
|
||||||
|
- Auto mode already supports retries, pauses, and restoring session state.
|
||||||
|
|
||||||
|
## Design Summary
|
||||||
|
|
||||||
|
The new Hotmail path introduces three focused subsystems:
|
||||||
|
|
||||||
|
1. `hotmail-account-pool`
|
||||||
|
Maintains a reusable list of Hotmail accounts and their OAuth credentials.
|
||||||
|
2. `microsoft-oauth`
|
||||||
|
Handles Microsoft authorization code flow with PKCE through `chrome.identity.launchWebAuthFlow`.
|
||||||
|
3. `hotmail-graph-mail`
|
||||||
|
Reads inbox messages from Microsoft Graph and extracts verification codes for Step 4 and Step 7.
|
||||||
|
|
||||||
|
The main flow continues to use the existing step state machine in `background.js`.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### 1. Account Pool
|
||||||
|
|
||||||
|
Persist a new `hotmailAccounts` array in `chrome.storage.local`.
|
||||||
|
|
||||||
|
Each account record stores:
|
||||||
|
|
||||||
|
- `id`
|
||||||
|
- `email`
|
||||||
|
- `password`
|
||||||
|
- `clientId`
|
||||||
|
- `accessToken`
|
||||||
|
- `refreshToken`
|
||||||
|
- `expiresAt`
|
||||||
|
- `status`
|
||||||
|
- `lastUsedAt`
|
||||||
|
- `lastAuthAt`
|
||||||
|
- `lastError`
|
||||||
|
|
||||||
|
The current run stores `currentHotmailAccountId` in `chrome.storage.session`.
|
||||||
|
|
||||||
|
When `mailProvider = hotmail-api`, Auto mode must allocate one account at the start of a fresh run, write its email into the existing `email` runtime field, and reuse the same account through Step 3, Step 4, Step 6, and Step 7.
|
||||||
|
|
||||||
|
### 2. OAuth Flow
|
||||||
|
|
||||||
|
Each account is authorized separately from the side panel.
|
||||||
|
|
||||||
|
Flow:
|
||||||
|
|
||||||
|
1. User clicks `Authorize` on an account row.
|
||||||
|
2. Background generates PKCE verifier/challenge and a random `state`.
|
||||||
|
3. Background launches Microsoft sign-in via `chrome.identity.launchWebAuthFlow`.
|
||||||
|
4. Microsoft redirects back to the extension redirect URL.
|
||||||
|
5. Background validates `state`, exchanges `code` for tokens, and updates the account record.
|
||||||
|
|
||||||
|
The extension requests delegated scopes only:
|
||||||
|
|
||||||
|
- `openid`
|
||||||
|
- `profile`
|
||||||
|
- `offline_access`
|
||||||
|
- `https://graph.microsoft.com/Mail.Read`
|
||||||
|
- `https://graph.microsoft.com/User.Read`
|
||||||
|
|
||||||
|
The design assumes one shared `clientId` across accounts is valid, while `refreshToken` remains per account.
|
||||||
|
|
||||||
|
### 3. Graph Mail Retrieval
|
||||||
|
|
||||||
|
Hotmail mail retrieval runs inside background logic and does not require a mail tab or content script.
|
||||||
|
|
||||||
|
The provider performs:
|
||||||
|
|
||||||
|
1. Resolve the current Hotmail account from session state.
|
||||||
|
2. Refresh the token if missing or near expiry.
|
||||||
|
3. Call Microsoft Graph to fetch recent inbox messages.
|
||||||
|
4. Filter by sender, subject, and time window.
|
||||||
|
5. Extract a 6-digit verification code from message metadata or preview/body.
|
||||||
|
6. Return the code to the existing Step 4 or Step 7 submission flow.
|
||||||
|
|
||||||
|
The first iteration should prefer stable fields such as:
|
||||||
|
|
||||||
|
- `from.emailAddress.address`
|
||||||
|
- `subject`
|
||||||
|
- `receivedDateTime`
|
||||||
|
- `bodyPreview`
|
||||||
|
|
||||||
|
Full HTML body parsing is explicitly deferred unless needed.
|
||||||
|
|
||||||
|
## Integration with Existing Steps
|
||||||
|
|
||||||
|
### Step 3
|
||||||
|
|
||||||
|
Step 3 keeps filling the OpenAI page in the same way, but when `mailProvider = hotmail-api`, the email comes from the selected account pool entry instead of the manual email box or Duck address.
|
||||||
|
|
||||||
|
### Step 4 and Step 7
|
||||||
|
|
||||||
|
The existing retry and resend behavior stays in place, but the provider path changes:
|
||||||
|
|
||||||
|
- old providers: `qq`, `163`, `inbucket`
|
||||||
|
- new provider: `hotmail-api`
|
||||||
|
|
||||||
|
The orchestration layer should branch before opening any mailbox tab. For `hotmail-api`, it calls a background helper instead of `sendToMailContentScriptResilient`.
|
||||||
|
|
||||||
|
### Auto Run
|
||||||
|
|
||||||
|
Auto run changes:
|
||||||
|
|
||||||
|
- remove the Duck auto-fetch dependency when `hotmail-api` is selected
|
||||||
|
- allocate a fresh Hotmail account at the start of a new run
|
||||||
|
- fail early if no authorized account is available
|
||||||
|
- preserve the existing retry and skip-failure semantics
|
||||||
|
|
||||||
|
## Side Panel Changes
|
||||||
|
|
||||||
|
The current single email entry remains for backward compatibility, but a new account-pool section is added when `hotmail-api` is selected.
|
||||||
|
|
||||||
|
New UI capabilities:
|
||||||
|
|
||||||
|
- list Hotmail accounts
|
||||||
|
- add an account
|
||||||
|
- delete an account
|
||||||
|
- authorize an account
|
||||||
|
- test mail access for an account
|
||||||
|
- show status and last error
|
||||||
|
|
||||||
|
The existing provider selector gains a `hotmail-api` option.
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
The new path must surface actionable errors:
|
||||||
|
|
||||||
|
- no Hotmail account available
|
||||||
|
- missing `clientId`
|
||||||
|
- OAuth denied or cancelled
|
||||||
|
- token exchange failed
|
||||||
|
- refresh token invalid
|
||||||
|
- Graph mail read failed
|
||||||
|
- no matching verification mail found in the time window
|
||||||
|
|
||||||
|
Account-level failures should update the account record `status` and `lastError` without corrupting unrelated accounts.
|
||||||
|
|
||||||
|
## Security and Storage Tradeoffs
|
||||||
|
|
||||||
|
- Tokens and account passwords are stored in `chrome.storage.local` for operator convenience.
|
||||||
|
- This is acceptable for the current operator-managed extension model, but it increases the trust requirement of the local browser profile.
|
||||||
|
- No secret should be hard-coded in the repository.
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
Because the project has no automated test harness today, implementation should carve out pure helper functions where possible and validate them with focused runtime checks.
|
||||||
|
|
||||||
|
The minimum verification surface:
|
||||||
|
|
||||||
|
- account selection logic
|
||||||
|
- PKCE helper generation
|
||||||
|
- OAuth callback parsing and state validation
|
||||||
|
- token refresh request/response handling
|
||||||
|
- Graph message filtering and code extraction
|
||||||
|
- Step 4 and Step 7 provider branch behavior
|
||||||
|
- Auto run allocation of a fresh account per run
|
||||||
|
|
||||||
|
## Deferred Work
|
||||||
|
|
||||||
|
The first version intentionally excludes:
|
||||||
|
|
||||||
|
- bulk import/export UX
|
||||||
|
- bulk authorize all accounts
|
||||||
|
- advanced HTML message parsing
|
||||||
|
- mailbox delete/move/archive behavior
|
||||||
|
- background local service or server proxy
|
||||||
|
- removal of existing QQ/163/Inbucket support
|
||||||
|
|
||||||
|
## Implementation Boundary
|
||||||
|
|
||||||
|
Modify only the minimum set of files needed to add the new provider while keeping current providers operational:
|
||||||
|
|
||||||
|
- `manifest.json`
|
||||||
|
- `background.js`
|
||||||
|
- `sidepanel/sidepanel.html`
|
||||||
|
- `sidepanel/sidepanel.css`
|
||||||
|
- `sidepanel/sidepanel.js`
|
||||||
|
|
||||||
|
If helper extraction becomes necessary, prefer adding small new files under `content/` or the repo root only if they clearly reduce complexity in `background.js`.
|
||||||
@@ -0,0 +1,402 @@
|
|||||||
|
(function hotmailUtilsModule(root, factory) {
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = factory();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
root.HotmailUtils = factory();
|
||||||
|
})(typeof self !== 'undefined' ? self : globalThis, function createHotmailUtils() {
|
||||||
|
const HOTMAIL_MICROSOFT_TOKEN_URL = 'https://login.microsoftonline.com/consumers/oauth2/v2.0/token';
|
||||||
|
const HOTMAIL_GRAPH_API_ORIGIN = 'https://graph.microsoft.com';
|
||||||
|
const HOTMAIL_GRAPH_PAGE_SIZE = 10;
|
||||||
|
const HOTMAIL_GRAPH_MESSAGE_FIELDS = [
|
||||||
|
'id',
|
||||||
|
'internetMessageId',
|
||||||
|
'subject',
|
||||||
|
'from',
|
||||||
|
'bodyPreview',
|
||||||
|
'receivedDateTime',
|
||||||
|
];
|
||||||
|
const HOTMAIL_GRAPH_SCOPES = [
|
||||||
|
'offline_access',
|
||||||
|
'https://graph.microsoft.com/Mail.Read',
|
||||||
|
'https://graph.microsoft.com/User.Read',
|
||||||
|
];
|
||||||
|
|
||||||
|
function normalizeText(value) {
|
||||||
|
return String(value || '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTimestamp(value) {
|
||||||
|
if (!value) return 0;
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
|
return value > 0 ? value : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = Date.parse(value);
|
||||||
|
return Number.isFinite(timestamp) ? timestamp : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractVerificationCode(text) {
|
||||||
|
const source = String(text || '');
|
||||||
|
const matchCn = source.match(/(?:代码为|验证码[^0-9]*?)[\s::]*(\d{6})/i);
|
||||||
|
if (matchCn) return matchCn[1];
|
||||||
|
|
||||||
|
const matchEn = source.match(/code(?:\s+is|[\s:])+(\d{6})/i);
|
||||||
|
if (matchEn) return matchEn[1];
|
||||||
|
|
||||||
|
const matchStandalone = source.match(/\b(\d{6})\b/);
|
||||||
|
return matchStandalone ? matchStandalone[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractVerificationCodeFromMessage(message = {}) {
|
||||||
|
const sender = firstNonEmptyString([
|
||||||
|
message?.from?.emailAddress?.address,
|
||||||
|
message?.sender,
|
||||||
|
message?.from,
|
||||||
|
]);
|
||||||
|
const subject = firstNonEmptyString([message?.subject]);
|
||||||
|
const preview = firstNonEmptyString([message?.bodyPreview, message?.preview, message?.text]);
|
||||||
|
return extractVerificationCode([subject, preview, sender].filter(Boolean).join(' '));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLatestHotmailMessage(messages) {
|
||||||
|
return (Array.isArray(messages) ? messages : [])
|
||||||
|
.slice()
|
||||||
|
.sort((left, right) => {
|
||||||
|
const leftTime = normalizeTimestamp(left?.receivedDateTime);
|
||||||
|
const rightTime = normalizeTimestamp(right?.receivedDateTime);
|
||||||
|
return rightTime - leftTime;
|
||||||
|
})[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHotmailListToggleLabel(expanded, count = 0) {
|
||||||
|
const normalizedCount = Number.isFinite(Number(count)) ? Math.max(0, Number(count)) : 0;
|
||||||
|
const suffix = normalizedCount > 0 ? `(${normalizedCount})` : '';
|
||||||
|
return `${expanded ? '收起列表' : '展开列表'}${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterHotmailAccountsByUsage(accounts, mode = 'all') {
|
||||||
|
const list = Array.isArray(accounts) ? accounts.slice() : [];
|
||||||
|
if (mode === 'used') {
|
||||||
|
return list.filter((account) => Boolean(account?.used));
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHotmailBulkActionLabel(mode = 'all', count = 0) {
|
||||||
|
const normalizedCount = Number.isFinite(Number(count)) ? Math.max(0, Number(count)) : 0;
|
||||||
|
const prefix = mode === 'used' ? '清空已用' : '全部删除';
|
||||||
|
const suffix = normalizedCount > 0 ? `(${normalizedCount})` : '';
|
||||||
|
return `${prefix}${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAuthorizedHotmailAccount(account) {
|
||||||
|
return Boolean(account)
|
||||||
|
&& account.status === 'authorized'
|
||||||
|
&& !account.used
|
||||||
|
&& Boolean(account.refreshToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldClearHotmailCurrentSelection(account) {
|
||||||
|
return Boolean(account) && account.used === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertHotmailAccountInList(accounts, nextAccount) {
|
||||||
|
const list = Array.isArray(accounts) ? accounts.slice() : [];
|
||||||
|
if (!nextAccount?.id) return list;
|
||||||
|
|
||||||
|
const existingIndex = list.findIndex((account) => account?.id === nextAccount.id);
|
||||||
|
if (existingIndex === -1) {
|
||||||
|
list.push(nextAccount);
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
list[existingIndex] = nextAccount;
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickHotmailAccountForRun(accounts, options = {}) {
|
||||||
|
const candidates = Array.isArray(accounts) ? accounts.filter(isAuthorizedHotmailAccount) : [];
|
||||||
|
if (!candidates.length) return null;
|
||||||
|
|
||||||
|
const excludeIds = new Set((options.excludeIds || []).filter(Boolean));
|
||||||
|
const filtered = candidates.filter((account) => !excludeIds.has(account.id));
|
||||||
|
const pool = filtered.length ? filtered : candidates;
|
||||||
|
|
||||||
|
return pool
|
||||||
|
.slice()
|
||||||
|
.sort((left, right) => {
|
||||||
|
const leftUsedAt = normalizeTimestamp(left.lastUsedAt);
|
||||||
|
const rightUsedAt = normalizeTimestamp(right.lastUsedAt);
|
||||||
|
if (leftUsedAt !== rightUsedAt) {
|
||||||
|
return leftUsedAt - rightUsedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(left.email || '').localeCompare(String(right.email || ''));
|
||||||
|
})[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageMatchesFilters(message, filters = {}) {
|
||||||
|
const senderFilters = (filters.senderFilters || []).map(normalizeText).filter(Boolean);
|
||||||
|
const subjectFilters = (filters.subjectFilters || []).map(normalizeText).filter(Boolean);
|
||||||
|
const afterTimestamp = normalizeTimestamp(filters.afterTimestamp);
|
||||||
|
const receivedAt = normalizeTimestamp(message?.receivedDateTime);
|
||||||
|
if (afterTimestamp && receivedAt && receivedAt < afterTimestamp) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sender = normalizeText(message?.from?.emailAddress?.address);
|
||||||
|
const subject = normalizeText(message?.subject);
|
||||||
|
const preview = String(message?.bodyPreview || '');
|
||||||
|
const combinedText = [subject, sender, preview].filter(Boolean).join(' ');
|
||||||
|
const code = extractVerificationCode(combinedText);
|
||||||
|
const excludedCodes = new Set((filters.excludeCodes || []).filter(Boolean));
|
||||||
|
if (code && excludedCodes.has(code)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const senderMatch = senderFilters.length === 0
|
||||||
|
? true
|
||||||
|
: senderFilters.some((item) => sender.includes(item) || normalizeText(preview).includes(item));
|
||||||
|
const subjectMatch = subjectFilters.length === 0
|
||||||
|
? true
|
||||||
|
: subjectFilters.some((item) => subject.includes(item) || normalizeText(preview).includes(item));
|
||||||
|
|
||||||
|
if (!senderMatch && !subjectMatch) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
receivedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickVerificationMessage(messages, filters = {}) {
|
||||||
|
const matches = (Array.isArray(messages) ? messages : [])
|
||||||
|
.map((message) => messageMatchesFilters(message, filters))
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((left, right) => right.receivedAt - left.receivedAt);
|
||||||
|
|
||||||
|
return matches[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickVerificationMessageWithFallback(messages, filters = {}) {
|
||||||
|
const strictMatch = pickVerificationMessage(messages, filters);
|
||||||
|
return {
|
||||||
|
match: strictMatch || null,
|
||||||
|
usedRelaxedFilters: false,
|
||||||
|
usedTimeFallback: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickVerificationMessageWithTimeFallback(messages, filters = {}) {
|
||||||
|
const strictOrRelaxedResult = pickVerificationMessageWithFallback(messages, filters);
|
||||||
|
if (strictOrRelaxedResult.match) {
|
||||||
|
return strictOrRelaxedResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeFallbackMatch = pickVerificationMessage(messages, {
|
||||||
|
afterTimestamp: 0,
|
||||||
|
excludeCodes: filters.excludeCodes,
|
||||||
|
senderFilters: filters.senderFilters,
|
||||||
|
subjectFilters: filters.subjectFilters,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
match: timeFallbackMatch || null,
|
||||||
|
usedRelaxedFilters: false,
|
||||||
|
usedTimeFallback: Boolean(timeFallbackMatch),
|
||||||
|
};
|
||||||
|
/* c8 ignore stop */
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstNonEmptyString(values) {
|
||||||
|
for (const value of values) {
|
||||||
|
if (value === undefined || value === null) continue;
|
||||||
|
const normalized = String(value).trim();
|
||||||
|
if (normalized) return normalized;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMailAddress(rawValue) {
|
||||||
|
if (!rawValue) return '';
|
||||||
|
if (typeof rawValue === 'string') {
|
||||||
|
return rawValue.trim();
|
||||||
|
}
|
||||||
|
if (typeof rawValue === 'object') {
|
||||||
|
return firstNonEmptyString([
|
||||||
|
rawValue.emailAddress?.address,
|
||||||
|
rawValue.address,
|
||||||
|
rawValue.email,
|
||||||
|
rawValue.sender,
|
||||||
|
rawValue.from,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripHtmlTags(text) {
|
||||||
|
return String(text || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHotmailMailApiMessage(message = {}) {
|
||||||
|
return {
|
||||||
|
id: firstNonEmptyString([message.id, message.message_id, message.messageId, message.internetMessageId]),
|
||||||
|
subject: firstNonEmptyString([message.subject, message.title]),
|
||||||
|
from: {
|
||||||
|
emailAddress: {
|
||||||
|
address: normalizeMailAddress(
|
||||||
|
message.from_email
|
||||||
|
|| message.sender_email
|
||||||
|
|| message.from
|
||||||
|
|| message.sender
|
||||||
|
|| message.emailAddress
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
bodyPreview: firstNonEmptyString([
|
||||||
|
message.bodyPreview,
|
||||||
|
message.preview,
|
||||||
|
message.snippet,
|
||||||
|
message.text,
|
||||||
|
message.body,
|
||||||
|
stripHtmlTags(message.html || message.content || ''),
|
||||||
|
]),
|
||||||
|
receivedDateTime: firstNonEmptyString([
|
||||||
|
message.receivedDateTime,
|
||||||
|
message.received_at,
|
||||||
|
message.receivedAt,
|
||||||
|
message.date,
|
||||||
|
message.created_at,
|
||||||
|
message.time,
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHotmailMailApiMessages(messages) {
|
||||||
|
const list = Array.isArray(messages)
|
||||||
|
? messages
|
||||||
|
: (messages ? [messages] : []);
|
||||||
|
return list.map((message) => normalizeHotmailMailApiMessage(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHotmailMailboxId(mailbox = 'INBOX') {
|
||||||
|
const normalized = normalizeText(mailbox);
|
||||||
|
if (normalized === 'junk' || normalized === 'junk email' || normalized === 'junkemail') {
|
||||||
|
return 'junkemail';
|
||||||
|
}
|
||||||
|
return 'inbox';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildHotmailGraphMessagesUrl(options) {
|
||||||
|
const folderId = normalizeHotmailMailboxId(options?.mailbox);
|
||||||
|
const url = new URL(`${HOTMAIL_GRAPH_API_ORIGIN}/v1.0/me/mailFolders/${folderId}/messages`);
|
||||||
|
url.searchParams.set('$top', String(options?.top || HOTMAIL_GRAPH_PAGE_SIZE));
|
||||||
|
url.searchParams.set('$select', (options?.selectFields || HOTMAIL_GRAPH_MESSAGE_FIELDS).join(','));
|
||||||
|
url.searchParams.set('$orderby', String(options?.orderBy || 'receivedDateTime desc'));
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHotmailVerificationPollConfig(step) {
|
||||||
|
if (step === 4 || step === 7) {
|
||||||
|
return {
|
||||||
|
initialDelayMs: 5000,
|
||||||
|
maxAttempts: 12,
|
||||||
|
intervalMs: 5000,
|
||||||
|
requestFreshCodeFirst: false,
|
||||||
|
ignorePersistedLastCode: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
initialDelayMs: 5000,
|
||||||
|
maxAttempts: 8,
|
||||||
|
intervalMs: 4000,
|
||||||
|
requestFreshCodeFirst: false,
|
||||||
|
ignorePersistedLastCode: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHotmailVerificationRequestTimestamp(step, state = {}, options = {}) {
|
||||||
|
const bufferMs = Number(options.bufferMs) || 15_000;
|
||||||
|
const signupRequestedAt = normalizeTimestamp(state.signupVerificationRequestedAt);
|
||||||
|
const loginRequestedAt = normalizeTimestamp(state.loginVerificationRequestedAt);
|
||||||
|
const lastEmailTimestamp = normalizeTimestamp(state.lastEmailTimestamp);
|
||||||
|
const flowStartTime = normalizeTimestamp(state.flowStartTime);
|
||||||
|
|
||||||
|
if (step === 4 && signupRequestedAt) {
|
||||||
|
return Math.max(0, signupRequestedAt - bufferMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step === 7 && loginRequestedAt) {
|
||||||
|
return Math.max(0, loginRequestedAt - bufferMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
return step === 7
|
||||||
|
? (lastEmailTimestamp || flowStartTime || 0)
|
||||||
|
: (flowStartTime || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHotmailGraphRequestConfig() {
|
||||||
|
return {
|
||||||
|
timeoutMs: 15000,
|
||||||
|
pageSize: HOTMAIL_GRAPH_PAGE_SIZE,
|
||||||
|
scopes: HOTMAIL_GRAPH_SCOPES.slice(),
|
||||||
|
tokenUrl: HOTMAIL_MICROSOFT_TOKEN_URL,
|
||||||
|
messageFields: HOTMAIL_GRAPH_MESSAGE_FIELDS.slice(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHotmailImportText(rawText) {
|
||||||
|
const lines = String(rawText || '')
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
return lines
|
||||||
|
.filter((line, index) => !(index === 0 && /^账号----密码----ID----Token$/i.test(line)))
|
||||||
|
.map((line) => line.split('----').map((part) => part.trim()))
|
||||||
|
.filter((parts) => parts.length >= 4 && parts[0] && parts[2])
|
||||||
|
.map(([email, password, clientId, refreshToken]) => ({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
clientId,
|
||||||
|
refreshToken,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
buildHotmailGraphMessagesUrl,
|
||||||
|
extractVerificationCodeFromMessage,
|
||||||
|
filterHotmailAccountsByUsage,
|
||||||
|
extractVerificationCode,
|
||||||
|
getLatestHotmailMessage,
|
||||||
|
getHotmailBulkActionLabel,
|
||||||
|
getHotmailListToggleLabel,
|
||||||
|
getHotmailGraphRequestConfig,
|
||||||
|
getHotmailVerificationPollConfig,
|
||||||
|
getHotmailVerificationRequestTimestamp,
|
||||||
|
normalizeHotmailMailboxId,
|
||||||
|
isAuthorizedHotmailAccount,
|
||||||
|
normalizeHotmailMailApiMessages,
|
||||||
|
normalizeTimestamp,
|
||||||
|
parseHotmailImportText,
|
||||||
|
pickHotmailAccountForRun,
|
||||||
|
pickVerificationMessage,
|
||||||
|
pickVerificationMessageWithFallback,
|
||||||
|
pickVerificationMessageWithTimeFallback,
|
||||||
|
shouldClearHotmailCurrentSelection,
|
||||||
|
upsertHotmailAccountInList,
|
||||||
|
};
|
||||||
|
});
|
||||||
+4
-4
@@ -29,7 +29,7 @@
|
|||||||
"https://auth.openai.com/*",
|
"https://auth.openai.com/*",
|
||||||
"https://accounts.openai.com/*"
|
"https://accounts.openai.com/*"
|
||||||
],
|
],
|
||||||
"js": ["content/utils.js", "content/signup-page.js"],
|
"js": ["content/activation-utils.js", "content/utils.js", "content/signup-page.js"],
|
||||||
"run_at": "document_idle"
|
"run_at": "document_idle"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
"https://mail.qq.com/*",
|
"https://mail.qq.com/*",
|
||||||
"https://wx.mail.qq.com/*"
|
"https://wx.mail.qq.com/*"
|
||||||
],
|
],
|
||||||
"js": ["content/utils.js", "content/qq-mail.js"],
|
"js": ["content/activation-utils.js", "content/utils.js", "content/qq-mail.js"],
|
||||||
"all_frames": true,
|
"all_frames": true,
|
||||||
"run_at": "document_idle"
|
"run_at": "document_idle"
|
||||||
},
|
},
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
"https://*.mail.163.com/*",
|
"https://*.mail.163.com/*",
|
||||||
"https://webmail.vip.163.com/*"
|
"https://webmail.vip.163.com/*"
|
||||||
],
|
],
|
||||||
"js": ["content/utils.js", "content/mail-163.js"],
|
"js": ["content/activation-utils.js", "content/utils.js", "content/mail-163.js"],
|
||||||
"all_frames": true,
|
"all_frames": true,
|
||||||
"run_at": "document_idle"
|
"run_at": "document_idle"
|
||||||
},
|
},
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
"matches": [
|
"matches": [
|
||||||
"https://duckduckgo.com/email/settings/autofill*"
|
"https://duckduckgo.com/email/settings/autofill*"
|
||||||
],
|
],
|
||||||
"js": ["content/utils.js", "content/duck-mail.js"],
|
"js": ["content/activation-utils.js", "content/utils.js", "content/duck-mail.js"],
|
||||||
"run_at": "document_idle"
|
"run_at": "document_idle"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"name": "codex-oauth-automation-extension",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"test": "node --test tests/*.test.js"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -238,6 +238,33 @@ header {
|
|||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.section-mini-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-mini-copy {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-mini-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-hint {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.data-row {
|
.data-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -309,6 +336,23 @@ header {
|
|||||||
.data-input::placeholder { color: var(--text-muted); }
|
.data-input::placeholder { color: var(--text-muted); }
|
||||||
.data-input:focus { border-color: var(--blue); box-shadow: 0 0 0 3px var(--blue-soft); }
|
.data-input:focus { border-color: var(--blue); box-shadow: 0 0 0 3px var(--blue-soft); }
|
||||||
|
|
||||||
|
.data-textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 88px;
|
||||||
|
resize: vertical;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: var(--bg-base);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color var(--transition), box-shadow var(--transition);
|
||||||
|
}
|
||||||
|
.data-textarea::placeholder { color: var(--text-muted); }
|
||||||
|
.data-textarea:focus { border-color: var(--blue); box-shadow: 0 0 0 3px var(--blue-soft); }
|
||||||
|
|
||||||
.data-input-with-icon {
|
.data-input-with-icon {
|
||||||
padding-right: 38px;
|
padding-right: 38px;
|
||||||
}
|
}
|
||||||
@@ -340,6 +384,189 @@ header {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hotmail-card {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hotmail-actions-row .data-label {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hotmail-import-row {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hotmail-import-box {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hotmail-list-shell {
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: color-mix(in srgb, var(--bg-base) 82%, transparent);
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding: 2px;
|
||||||
|
transition: max-height var(--transition), border-color var(--transition), box-shadow var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hotmail-list-shell.is-collapsed {
|
||||||
|
max-height: 236px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hotmail-list-shell.is-expanded {
|
||||||
|
max-height: 440px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hotmail-list-shell::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hotmail-list-shell::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hotmail-list-shell::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hotmail-list-shell::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hotmail-accounts-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hotmail-account-item {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-base);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hotmail-account-item.is-current {
|
||||||
|
border-color: var(--blue);
|
||||||
|
box-shadow: 0 0 0 1px var(--blue-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hotmail-account-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hotmail-account-title-row {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hotmail-account-email {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hotmail-copy-btn {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--transition), color var(--transition), transform var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hotmail-copy-btn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hotmail-copy-btn:active {
|
||||||
|
transform: scale(0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hotmail-status-chip {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hotmail-status-chip.status-authorized {
|
||||||
|
background: var(--green-soft);
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hotmail-status-chip.status-used {
|
||||||
|
background: var(--orange-soft);
|
||||||
|
color: var(--orange);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hotmail-status-chip.status-disabled {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hotmail-status-chip.status-pending {
|
||||||
|
background: var(--orange-soft);
|
||||||
|
color: var(--orange);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hotmail-status-chip.status-error {
|
||||||
|
background: var(--red-soft);
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hotmail-account-meta {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 6px 10px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hotmail-account-error {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--red);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hotmail-account-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hotmail-empty {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 10px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.data-select {
|
.data-select {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 7px 10px;
|
padding: 7px 10px;
|
||||||
|
|||||||
@@ -91,6 +91,7 @@
|
|||||||
<div class="data-row">
|
<div class="data-row">
|
||||||
<span class="data-label">邮箱服务</span>
|
<span class="data-label">邮箱服务</span>
|
||||||
<select id="select-mail-provider" class="data-select">
|
<select id="select-mail-provider" class="data-select">
|
||||||
|
<option value="hotmail-api">Hotmail(微软 Graph)</option>
|
||||||
<option value="163">163 邮箱 (mail.163.com)</option>
|
<option value="163">163 邮箱 (mail.163.com)</option>
|
||||||
<option value="163-vip">163 VIP 邮箱 (webmail.vip.163.com)</option>
|
<option value="163-vip">163 VIP 邮箱 (webmail.vip.163.com)</option>
|
||||||
<option value="qq">QQ 邮箱 (wx.mail.qq.com)</option>
|
<option value="qq">QQ 邮箱 (wx.mail.qq.com)</option>
|
||||||
@@ -158,6 +159,49 @@
|
|||||||
<span id="display-localhost-url" class="data-value mono truncate">等待中...</span>
|
<span id="display-localhost-url" class="data-value mono truncate">等待中...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="hotmail-section" class="data-card hotmail-card" style="display:none;">
|
||||||
|
<div class="section-mini-header">
|
||||||
|
<div class="section-mini-copy">
|
||||||
|
<span class="section-label">Hotmail 账号池</span>
|
||||||
|
<span class="section-hint">每轮自动分配一个可用账号</span>
|
||||||
|
</div>
|
||||||
|
<div class="section-mini-actions">
|
||||||
|
<button id="btn-clear-used-hotmail-accounts" class="btn btn-ghost btn-xs" type="button">清空已用</button>
|
||||||
|
<button id="btn-delete-all-hotmail-accounts" class="btn btn-ghost btn-xs" type="button">全部删除</button>
|
||||||
|
<button id="btn-toggle-hotmail-list" class="btn btn-ghost btn-xs" type="button" aria-expanded="false">展开列表</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="data-row">
|
||||||
|
<span class="data-label">邮箱</span>
|
||||||
|
<input type="text" id="input-hotmail-email" class="data-input" placeholder="name@hotmail.com" />
|
||||||
|
</div>
|
||||||
|
<div class="data-row">
|
||||||
|
<span class="data-label">客户端 ID</span>
|
||||||
|
<input type="text" id="input-hotmail-client-id" class="data-input mono" placeholder="微软应用客户端 ID" />
|
||||||
|
</div>
|
||||||
|
<div class="data-row">
|
||||||
|
<span class="data-label">邮箱密码</span>
|
||||||
|
<input type="password" id="input-hotmail-password" class="data-input" placeholder="可选,仅用于记录" />
|
||||||
|
</div>
|
||||||
|
<div class="data-row">
|
||||||
|
<span class="data-label">刷新令牌</span>
|
||||||
|
<input type="password" id="input-hotmail-refresh-token" class="data-input mono" placeholder="必填,粘贴刷新令牌(refresh token)" />
|
||||||
|
</div>
|
||||||
|
<div class="data-row hotmail-actions-row">
|
||||||
|
<span class="data-label"></span>
|
||||||
|
<button id="btn-add-hotmail-account" class="btn btn-primary btn-sm" type="button">添加账号</button>
|
||||||
|
</div>
|
||||||
|
<div class="data-row hotmail-import-row">
|
||||||
|
<span class="data-label">批量导入</span>
|
||||||
|
<div class="hotmail-import-box">
|
||||||
|
<textarea id="input-hotmail-import" class="data-textarea mono" placeholder="账号----密码----客户端ID----刷新令牌 name@hotmail.com----password----client-id----refresh-token"></textarea>
|
||||||
|
<button id="btn-import-hotmail-accounts" class="btn btn-outline btn-sm" type="button">导入</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="hotmail-list-shell" class="hotmail-list-shell is-collapsed">
|
||||||
|
<div id="hotmail-accounts-list" class="hotmail-accounts-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div id="status-bar" class="status-bar">
|
<div id="status-bar" class="status-bar">
|
||||||
<div class="status-dot"></div>
|
<div class="status-dot"></div>
|
||||||
<span id="display-status">就绪</span>
|
<span id="display-status">就绪</span>
|
||||||
@@ -265,6 +309,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="toast-container"></div>
|
<div id="toast-container"></div>
|
||||||
|
<script src="../hotmail-utils.js"></script>
|
||||||
<script src="sidepanel.js"></script>
|
<script src="sidepanel.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
+594
-22
@@ -37,6 +37,19 @@ const inputVpsUrl = document.getElementById('input-vps-url');
|
|||||||
const inputVpsPassword = document.getElementById('input-vps-password');
|
const inputVpsPassword = document.getElementById('input-vps-password');
|
||||||
const selectMailProvider = document.getElementById('select-mail-provider');
|
const selectMailProvider = document.getElementById('select-mail-provider');
|
||||||
const selectEmailGenerator = document.getElementById('select-email-generator');
|
const selectEmailGenerator = document.getElementById('select-email-generator');
|
||||||
|
const hotmailSection = document.getElementById('hotmail-section');
|
||||||
|
const inputHotmailEmail = document.getElementById('input-hotmail-email');
|
||||||
|
const inputHotmailClientId = document.getElementById('input-hotmail-client-id');
|
||||||
|
const inputHotmailPassword = document.getElementById('input-hotmail-password');
|
||||||
|
const inputHotmailRefreshToken = document.getElementById('input-hotmail-refresh-token');
|
||||||
|
const inputHotmailImport = document.getElementById('input-hotmail-import');
|
||||||
|
const btnAddHotmailAccount = document.getElementById('btn-add-hotmail-account');
|
||||||
|
const btnImportHotmailAccounts = document.getElementById('btn-import-hotmail-accounts');
|
||||||
|
const btnClearUsedHotmailAccounts = document.getElementById('btn-clear-used-hotmail-accounts');
|
||||||
|
const btnDeleteAllHotmailAccounts = document.getElementById('btn-delete-all-hotmail-accounts');
|
||||||
|
const btnToggleHotmailList = document.getElementById('btn-toggle-hotmail-list');
|
||||||
|
const hotmailListShell = document.getElementById('hotmail-list-shell');
|
||||||
|
const hotmailAccountsList = document.getElementById('hotmail-accounts-list');
|
||||||
const rowInbucketHost = document.getElementById('row-inbucket-host');
|
const rowInbucketHost = document.getElementById('row-inbucket-host');
|
||||||
const inputInbucketHost = document.getElementById('input-inbucket-host');
|
const inputInbucketHost = document.getElementById('input-inbucket-host');
|
||||||
const rowInbucketMailbox = document.getElementById('row-inbucket-mailbox');
|
const rowInbucketMailbox = document.getElementById('row-inbucket-mailbox');
|
||||||
@@ -56,6 +69,7 @@ const btnAutoStartClose = document.getElementById('btn-auto-start-close');
|
|||||||
const btnAutoStartCancel = document.getElementById('btn-auto-start-cancel');
|
const btnAutoStartCancel = document.getElementById('btn-auto-start-cancel');
|
||||||
const btnAutoStartRestart = document.getElementById('btn-auto-start-restart');
|
const btnAutoStartRestart = document.getElementById('btn-auto-start-restart');
|
||||||
const btnAutoStartContinue = document.getElementById('btn-auto-start-continue');
|
const btnAutoStartContinue = document.getElementById('btn-auto-start-continue');
|
||||||
|
const autoHintText = document.querySelector('.auto-hint');
|
||||||
const STEP_DEFAULT_STATUSES = {
|
const STEP_DEFAULT_STATUSES = {
|
||||||
1: 'pending',
|
1: 'pending',
|
||||||
2: 'pending',
|
2: 'pending',
|
||||||
@@ -88,9 +102,19 @@ let cloudflareDomainEditMode = false;
|
|||||||
let modalChoiceResolver = null;
|
let modalChoiceResolver = null;
|
||||||
let currentModalActions = [];
|
let currentModalActions = [];
|
||||||
let scheduledCountdownTimer = null;
|
let scheduledCountdownTimer = null;
|
||||||
|
let hotmailActionInFlight = false;
|
||||||
|
let hotmailListExpanded = false;
|
||||||
|
|
||||||
const EYE_OPEN_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z"/><circle cx="12" cy="12" r="3"/></svg>';
|
const EYE_OPEN_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z"/><circle cx="12" cy="12" r="3"/></svg>';
|
||||||
const EYE_CLOSED_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17.94 17.94A10.94 10.94 0 0 1 12 19C5 19 1 12 1 12a21.77 21.77 0 0 1 5.06-6.94"/><path d="M9.9 4.24A10.94 10.94 0 0 1 12 5c7 0 11 7 11 7a21.86 21.86 0 0 1-2.16 3.19"/><path d="M1 1l22 22"/><path d="M14.12 14.12a3 3 0 1 1-4.24-4.24"/></svg>';
|
const EYE_CLOSED_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17.94 17.94A10.94 10.94 0 0 1 12 19C5 19 1 12 1 12a21.77 21.77 0 0 1 5.06-6.94"/><path d="M9.9 4.24A10.94 10.94 0 0 1 12 5c7 0 11 7 11 7a21.86 21.86 0 0 1-2.16 3.19"/><path d="M1 1l22 22"/><path d="M14.12 14.12a3 3 0 1 1-4.24-4.24"/></svg>';
|
||||||
|
const COPY_ICON = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
|
||||||
|
const parseHotmailImportText = window.HotmailUtils?.parseHotmailImportText;
|
||||||
|
const shouldClearHotmailCurrentSelection = window.HotmailUtils?.shouldClearHotmailCurrentSelection;
|
||||||
|
const upsertHotmailAccountInList = window.HotmailUtils?.upsertHotmailAccountInList;
|
||||||
|
const filterHotmailAccountsByUsage = window.HotmailUtils?.filterHotmailAccountsByUsage;
|
||||||
|
const getHotmailBulkActionLabel = window.HotmailUtils?.getHotmailBulkActionLabel;
|
||||||
|
const getHotmailListToggleLabel = window.HotmailUtils?.getHotmailListToggleLabel;
|
||||||
|
const HOTMAIL_LIST_EXPANDED_STORAGE_KEY = 'multipage-hotmail-list-expanded';
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Toast Notifications
|
// Toast Notifications
|
||||||
@@ -707,12 +731,227 @@ function getEmailGeneratorUiCopy() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getHotmailAccounts(state = latestState) {
|
||||||
|
return Array.isArray(state?.hotmailAccounts) ? state.hotmailAccounts : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentHotmailAccount(state = latestState) {
|
||||||
|
const currentId = state?.currentHotmailAccountId;
|
||||||
|
return getHotmailAccounts(state).find((account) => account.id === currentId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHotmailAccountsByUsage(mode = 'all', state = latestState) {
|
||||||
|
const accounts = getHotmailAccounts(state);
|
||||||
|
if (typeof filterHotmailAccountsByUsage === 'function') {
|
||||||
|
return filterHotmailAccountsByUsage(accounts, mode);
|
||||||
|
}
|
||||||
|
if (mode === 'used') {
|
||||||
|
return accounts.filter((account) => Boolean(account?.used));
|
||||||
|
}
|
||||||
|
return accounts.slice();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHotmailBulkActionText(mode, count) {
|
||||||
|
if (typeof getHotmailBulkActionLabel === 'function') {
|
||||||
|
return getHotmailBulkActionLabel(mode, count);
|
||||||
|
}
|
||||||
|
const normalizedCount = Number.isFinite(Number(count)) ? Math.max(0, Number(count)) : 0;
|
||||||
|
const prefix = mode === 'used' ? '清空已用' : '全部删除';
|
||||||
|
const suffix = normalizedCount > 0 ? `(${normalizedCount})` : '';
|
||||||
|
return `${prefix}${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHotmailListToggleText(expanded, count) {
|
||||||
|
if (typeof getHotmailListToggleLabel === 'function') {
|
||||||
|
return getHotmailListToggleLabel(expanded, count);
|
||||||
|
}
|
||||||
|
const normalizedCount = Number.isFinite(Number(count)) ? Math.max(0, Number(count)) : 0;
|
||||||
|
const suffix = normalizedCount > 0 ? `(${normalizedCount})` : '';
|
||||||
|
return `${expanded ? '收起列表' : '展开列表'}${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateHotmailListViewport() {
|
||||||
|
const count = getHotmailAccounts().length;
|
||||||
|
const usedCount = getHotmailAccountsByUsage('used').length;
|
||||||
|
if (btnClearUsedHotmailAccounts) {
|
||||||
|
btnClearUsedHotmailAccounts.textContent = getHotmailBulkActionText('used', usedCount);
|
||||||
|
btnClearUsedHotmailAccounts.disabled = usedCount === 0;
|
||||||
|
}
|
||||||
|
if (btnDeleteAllHotmailAccounts) {
|
||||||
|
btnDeleteAllHotmailAccounts.textContent = getHotmailBulkActionText('all', count);
|
||||||
|
btnDeleteAllHotmailAccounts.disabled = count === 0;
|
||||||
|
}
|
||||||
|
if (btnToggleHotmailList) {
|
||||||
|
btnToggleHotmailList.textContent = getHotmailListToggleText(hotmailListExpanded, count);
|
||||||
|
btnToggleHotmailList.setAttribute('aria-expanded', String(hotmailListExpanded));
|
||||||
|
btnToggleHotmailList.disabled = count === 0;
|
||||||
|
}
|
||||||
|
if (hotmailListShell) {
|
||||||
|
hotmailListShell.classList.toggle('is-expanded', hotmailListExpanded);
|
||||||
|
hotmailListShell.classList.toggle('is-collapsed', !hotmailListExpanded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setHotmailListExpanded(expanded, options = {}) {
|
||||||
|
const { persist = true } = options;
|
||||||
|
hotmailListExpanded = Boolean(expanded);
|
||||||
|
updateHotmailListViewport();
|
||||||
|
if (persist) {
|
||||||
|
localStorage.setItem(HOTMAIL_LIST_EXPANDED_STORAGE_KEY, hotmailListExpanded ? '1' : '0');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initHotmailListExpandedState() {
|
||||||
|
const saved = localStorage.getItem(HOTMAIL_LIST_EXPANDED_STORAGE_KEY);
|
||||||
|
setHotmailListExpanded(saved === '1', { persist: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldClearCurrentHotmailSelectionLocally(account) {
|
||||||
|
if (typeof shouldClearHotmailCurrentSelection === 'function') {
|
||||||
|
return shouldClearHotmailCurrentSelection(account);
|
||||||
|
}
|
||||||
|
return Boolean(account) && account.used === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertHotmailAccountListLocally(accounts, nextAccount) {
|
||||||
|
if (typeof upsertHotmailAccountInList === 'function') {
|
||||||
|
return upsertHotmailAccountInList(accounts, nextAccount);
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = Array.isArray(accounts) ? accounts.slice() : [];
|
||||||
|
if (!nextAccount?.id) return list;
|
||||||
|
|
||||||
|
const existingIndex = list.findIndex((account) => account?.id === nextAccount.id);
|
||||||
|
if (existingIndex === -1) {
|
||||||
|
list.push(nextAccount);
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
list[existingIndex] = nextAccount;
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshHotmailSelectionUI() {
|
||||||
|
renderHotmailAccounts();
|
||||||
|
if (selectMailProvider.value === 'hotmail-api') {
|
||||||
|
const currentAccount = getCurrentHotmailAccount();
|
||||||
|
inputEmail.value = currentAccount?.email || latestState?.email || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyHotmailAccountMutation(account, options = {}) {
|
||||||
|
if (!account?.id) return;
|
||||||
|
const { preserveCurrentSelection = false } = options;
|
||||||
|
|
||||||
|
const nextState = {
|
||||||
|
hotmailAccounts: upsertHotmailAccountListLocally(getHotmailAccounts(), account),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!preserveCurrentSelection
|
||||||
|
&& latestState?.currentHotmailAccountId === account.id
|
||||||
|
&& shouldClearCurrentHotmailSelectionLocally(account)) {
|
||||||
|
nextState.currentHotmailAccountId = null;
|
||||||
|
if (selectMailProvider.value === 'hotmail-api') {
|
||||||
|
nextState.email = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
syncLatestState(nextState);
|
||||||
|
refreshHotmailSelectionUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(timestamp) {
|
||||||
|
const value = Number(timestamp);
|
||||||
|
if (!Number.isFinite(value) || value <= 0) {
|
||||||
|
return '未使用';
|
||||||
|
}
|
||||||
|
return new Date(value).toLocaleString('zh-CN', { hour12: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHotmailAvailabilityLabel(account) {
|
||||||
|
if (account.used) return '已用';
|
||||||
|
return '可分配';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHotmailStatusLabel(account) {
|
||||||
|
if (account.used) return '已用';
|
||||||
|
|
||||||
|
switch (account.status) {
|
||||||
|
case 'authorized':
|
||||||
|
return '可用';
|
||||||
|
case 'error':
|
||||||
|
return '异常';
|
||||||
|
default:
|
||||||
|
return '待校验';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHotmailStatusClass(account) {
|
||||||
|
if (account.used) return 'status-used';
|
||||||
|
return `status-${account.status || 'pending'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearHotmailForm() {
|
||||||
|
inputHotmailEmail.value = '';
|
||||||
|
inputHotmailClientId.value = '';
|
||||||
|
inputHotmailPassword.value = '';
|
||||||
|
inputHotmailRefreshToken.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHotmailAccounts() {
|
||||||
|
if (!hotmailAccountsList) return;
|
||||||
|
const accounts = getHotmailAccounts();
|
||||||
|
const currentId = latestState?.currentHotmailAccountId || '';
|
||||||
|
|
||||||
|
if (!accounts.length) {
|
||||||
|
hotmailAccountsList.innerHTML = '<div class="hotmail-empty">还没有 Hotmail 账号,先添加一条再校验。</div>';
|
||||||
|
updateHotmailListViewport();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
hotmailAccountsList.innerHTML = accounts.map((account) => `
|
||||||
|
<div class="hotmail-account-item${account.id === currentId ? ' is-current' : ''}">
|
||||||
|
<div class="hotmail-account-top">
|
||||||
|
<div class="hotmail-account-title-row">
|
||||||
|
<div class="hotmail-account-email">${escapeHtml(account.email || '(未命名账号)')}</div>
|
||||||
|
<button
|
||||||
|
class="hotmail-copy-btn"
|
||||||
|
type="button"
|
||||||
|
data-account-action="copy-email"
|
||||||
|
data-account-id="${escapeHtml(account.id)}"
|
||||||
|
title="复制邮箱"
|
||||||
|
aria-label="复制邮箱 ${escapeHtml(account.email || '')}"
|
||||||
|
>${COPY_ICON}</button>
|
||||||
|
</div>
|
||||||
|
<span class="hotmail-status-chip ${escapeHtml(getHotmailStatusClass(account))}">${escapeHtml(getHotmailStatusLabel(account))}</span>
|
||||||
|
</div>
|
||||||
|
<div class="hotmail-account-meta">
|
||||||
|
<span>客户端 ID:${escapeHtml(account.clientId ? `${account.clientId.slice(0, 10)}...` : '未填写')}</span>
|
||||||
|
<span>刷新令牌:${account.refreshToken ? '已保存' : '未保存'}</span>
|
||||||
|
<span>分配状态: ${escapeHtml(getHotmailAvailabilityLabel(account))}</span>
|
||||||
|
<span>上次校验: ${escapeHtml(formatDateTime(account.lastAuthAt))}</span>
|
||||||
|
<span>上次使用: ${escapeHtml(formatDateTime(account.lastUsedAt))}</span>
|
||||||
|
</div>
|
||||||
|
${account.lastError ? `<div class="hotmail-account-error">${escapeHtml(account.lastError)}</div>` : ''}
|
||||||
|
<div class="hotmail-account-actions">
|
||||||
|
<button class="btn btn-outline btn-sm" type="button" data-account-action="select" data-account-id="${escapeHtml(account.id)}">使用此账号</button>
|
||||||
|
<button class="btn btn-outline btn-sm" type="button" data-account-action="toggle-used" data-account-id="${escapeHtml(account.id)}">${account.used ? '标记未用' : '标记已用'}</button>
|
||||||
|
<button class="btn btn-primary btn-sm" type="button" data-account-action="verify" data-account-id="${escapeHtml(account.id)}">校验</button>
|
||||||
|
<button class="btn btn-outline btn-sm" type="button" data-account-action="test" data-account-id="${escapeHtml(account.id)}">复制最新验证码</button>
|
||||||
|
<button class="btn btn-ghost btn-sm" type="button" data-account-action="delete" data-account-id="${escapeHtml(account.id)}">删除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
updateHotmailListViewport();
|
||||||
|
}
|
||||||
|
|
||||||
function updateMailProviderUI() {
|
function updateMailProviderUI() {
|
||||||
const useInbucket = selectMailProvider.value === 'inbucket';
|
const useInbucket = selectMailProvider.value === 'inbucket';
|
||||||
|
const useHotmail = selectMailProvider.value === 'hotmail-api';
|
||||||
rowInbucketHost.style.display = useInbucket ? '' : 'none';
|
rowInbucketHost.style.display = useInbucket ? '' : 'none';
|
||||||
rowInbucketMailbox.style.display = useInbucket ? '' : 'none';
|
rowInbucketMailbox.style.display = useInbucket ? '' : 'none';
|
||||||
const useCloudflare = selectEmailGenerator.value === 'cloudflare';
|
const useCloudflare = selectEmailGenerator.value === 'cloudflare';
|
||||||
rowCfDomain.style.display = useCloudflare ? '' : 'none';
|
rowCfDomain.style.display = !useHotmail && useCloudflare ? '' : 'none';
|
||||||
const { domains } = getCloudflareDomainsFromState();
|
const { domains } = getCloudflareDomainsFromState();
|
||||||
if (useCloudflare) {
|
if (useCloudflare) {
|
||||||
setCloudflareDomainEditMode(cloudflareDomainEditMode || domains.length === 0, { clearInput: false });
|
setCloudflareDomainEditMode(cloudflareDomainEditMode || domains.length === 0, { clearInput: false });
|
||||||
@@ -720,11 +959,27 @@ function updateMailProviderUI() {
|
|||||||
setCloudflareDomainEditMode(false, { clearInput: false });
|
setCloudflareDomainEditMode(false, { clearInput: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hotmailSection) {
|
||||||
|
hotmailSection.style.display = useHotmail ? '' : 'none';
|
||||||
|
}
|
||||||
|
selectEmailGenerator.disabled = useHotmail;
|
||||||
|
btnFetchEmail.hidden = useHotmail;
|
||||||
|
inputEmail.readOnly = useHotmail;
|
||||||
const uiCopy = getEmailGeneratorUiCopy();
|
const uiCopy = getEmailGeneratorUiCopy();
|
||||||
inputEmail.placeholder = uiCopy.placeholder;
|
inputEmail.placeholder = useHotmail ? '由 Hotmail 账号池自动分配' : uiCopy.placeholder;
|
||||||
if (!btnFetchEmail.disabled) {
|
if (!btnFetchEmail.disabled) {
|
||||||
btnFetchEmail.textContent = uiCopy.buttonLabel;
|
btnFetchEmail.textContent = uiCopy.buttonLabel;
|
||||||
}
|
}
|
||||||
|
if (autoHintText) {
|
||||||
|
autoHintText.textContent = useHotmail
|
||||||
|
? '请先校验并选择一个 Hotmail 账号'
|
||||||
|
: '先自动获取邮箱,或手动粘贴邮箱后再继续';
|
||||||
|
}
|
||||||
|
if (useHotmail) {
|
||||||
|
const currentAccount = getCurrentHotmailAccount();
|
||||||
|
inputEmail.value = currentAccount?.email || latestState?.email || '';
|
||||||
|
}
|
||||||
|
renderHotmailAccounts();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveCloudflareDomainSettings(domains, activeDomain, options = {}) {
|
async function saveCloudflareDomainSettings(domains, activeDomain, options = {}) {
|
||||||
@@ -981,6 +1236,70 @@ function syncToggleButtonLabel(button, input, labels) {
|
|||||||
button.title = isHidden ? labels.show : labels.hide;
|
button.title = isHidden ? labels.show : labels.hide;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function copyTextToClipboard(text) {
|
||||||
|
const value = String(text || '').trim();
|
||||||
|
if (!value) {
|
||||||
|
throw new Error('没有可复制的内容。');
|
||||||
|
}
|
||||||
|
if (!navigator.clipboard?.writeText) {
|
||||||
|
throw new Error('当前环境不支持剪贴板复制。');
|
||||||
|
}
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteHotmailAccountsByMode(mode) {
|
||||||
|
const isUsedMode = mode === 'used';
|
||||||
|
const targetAccounts = getHotmailAccountsByUsage(isUsedMode ? 'used' : 'all');
|
||||||
|
if (!targetAccounts.length) {
|
||||||
|
showToast(isUsedMode ? '没有已用账号可清空。' : '没有可删除的 Hotmail 账号。', 'warn');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmed = await openConfirmModal({
|
||||||
|
title: isUsedMode ? '清空已用账号' : '全部删除账号',
|
||||||
|
message: isUsedMode
|
||||||
|
? `确认删除当前 ${targetAccounts.length} 个已用 Hotmail 账号吗?`
|
||||||
|
: `确认删除全部 ${targetAccounts.length} 个 Hotmail 账号吗?`,
|
||||||
|
confirmLabel: isUsedMode ? '确认清空已用' : '确认全部删除',
|
||||||
|
confirmVariant: isUsedMode ? 'btn-outline' : 'btn-danger',
|
||||||
|
});
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await chrome.runtime.sendMessage({
|
||||||
|
type: 'DELETE_HOTMAIL_ACCOUNTS',
|
||||||
|
source: 'sidepanel',
|
||||||
|
payload: { mode: isUsedMode ? 'used' : 'all' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response?.error) {
|
||||||
|
throw new Error(response.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetIds = new Set(targetAccounts.map((account) => account.id));
|
||||||
|
const nextAccounts = isUsedMode
|
||||||
|
? getHotmailAccounts().filter((account) => !targetIds.has(account.id))
|
||||||
|
: [];
|
||||||
|
const nextState = { hotmailAccounts: nextAccounts };
|
||||||
|
if (latestState?.currentHotmailAccountId && targetIds.has(latestState.currentHotmailAccountId)) {
|
||||||
|
nextState.currentHotmailAccountId = null;
|
||||||
|
if (selectMailProvider.value === 'hotmail-api') {
|
||||||
|
nextState.email = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
syncLatestState(nextState);
|
||||||
|
refreshHotmailSelectionUI();
|
||||||
|
|
||||||
|
showToast(
|
||||||
|
isUsedMode
|
||||||
|
? `已清空 ${response.deletedCount || 0} 个已用 Hotmail 账号`
|
||||||
|
: `已删除全部 ${response.deletedCount || 0} 个 Hotmail 账号`,
|
||||||
|
'success',
|
||||||
|
2200
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function syncPasswordToggleLabel() {
|
function syncPasswordToggleLabel() {
|
||||||
syncToggleButtonLabel(btnTogglePassword, inputPassword, {
|
syncToggleButtonLabel(btnTogglePassword, inputPassword, {
|
||||||
show: '显示密码',
|
show: '显示密码',
|
||||||
@@ -1060,17 +1379,25 @@ document.querySelectorAll('.step-btn').forEach(btn => {
|
|||||||
syncLatestState({ customPassword: inputPassword.value });
|
syncLatestState({ customPassword: inputPassword.value });
|
||||||
}
|
}
|
||||||
let email = inputEmail.value.trim();
|
let email = inputEmail.value.trim();
|
||||||
if (!email) {
|
if (selectMailProvider.value === 'hotmail-api') {
|
||||||
try {
|
const response = await chrome.runtime.sendMessage({ type: 'EXECUTE_STEP', source: 'sidepanel', payload: { step } });
|
||||||
email = await fetchGeneratedEmail({ showFailureToast: false });
|
if (response?.error) {
|
||||||
} catch (err) {
|
throw new Error(response.error);
|
||||||
showToast(`自动获取失败:${err.message},请手动粘贴邮箱后重试。`, 'warn');
|
}
|
||||||
return;
|
} else {
|
||||||
|
let email = inputEmail.value.trim();
|
||||||
|
if (!email) {
|
||||||
|
try {
|
||||||
|
email = await fetchGeneratedEmail({ showFailureToast: false });
|
||||||
|
} catch (err) {
|
||||||
|
showToast(`自动获取失败:${err.message},请手动粘贴邮箱后重试。`, 'warn');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const response = await chrome.runtime.sendMessage({ type: 'EXECUTE_STEP', source: 'sidepanel', payload: { step, email } });
|
||||||
|
if (response?.error) {
|
||||||
|
throw new Error(response.error);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
const response = await chrome.runtime.sendMessage({ type: 'EXECUTE_STEP', source: 'sidepanel', payload: { step, email } });
|
|
||||||
if (response?.error) {
|
|
||||||
throw new Error(response.error);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const response = await chrome.runtime.sendMessage({ type: 'EXECUTE_STEP', source: 'sidepanel', payload: { step } });
|
const response = await chrome.runtime.sendMessage({ type: 'EXECUTE_STEP', source: 'sidepanel', payload: { step } });
|
||||||
@@ -1085,9 +1412,234 @@ document.querySelectorAll('.step-btn').forEach(btn => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
btnFetchEmail.addEventListener('click', async () => {
|
btnFetchEmail.addEventListener('click', async () => {
|
||||||
|
if (selectMailProvider.value === 'hotmail-api') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
await fetchGeneratedEmail().catch(() => { });
|
await fetchGeneratedEmail().catch(() => { });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
btnToggleHotmailList?.addEventListener('click', () => {
|
||||||
|
setHotmailListExpanded(!hotmailListExpanded);
|
||||||
|
});
|
||||||
|
|
||||||
|
btnClearUsedHotmailAccounts?.addEventListener('click', async () => {
|
||||||
|
if (hotmailActionInFlight) return;
|
||||||
|
hotmailActionInFlight = true;
|
||||||
|
btnClearUsedHotmailAccounts.disabled = true;
|
||||||
|
try {
|
||||||
|
await deleteHotmailAccountsByMode('used');
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message, 'error');
|
||||||
|
} finally {
|
||||||
|
hotmailActionInFlight = false;
|
||||||
|
updateHotmailListViewport();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
btnDeleteAllHotmailAccounts?.addEventListener('click', async () => {
|
||||||
|
if (hotmailActionInFlight) return;
|
||||||
|
hotmailActionInFlight = true;
|
||||||
|
btnDeleteAllHotmailAccounts.disabled = true;
|
||||||
|
try {
|
||||||
|
await deleteHotmailAccountsByMode('all');
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message, 'error');
|
||||||
|
} finally {
|
||||||
|
hotmailActionInFlight = false;
|
||||||
|
updateHotmailListViewport();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
btnAddHotmailAccount?.addEventListener('click', async () => {
|
||||||
|
if (hotmailActionInFlight) return;
|
||||||
|
|
||||||
|
const email = inputHotmailEmail.value.trim();
|
||||||
|
const clientId = inputHotmailClientId.value.trim();
|
||||||
|
const refreshToken = inputHotmailRefreshToken.value.trim();
|
||||||
|
if (!email) {
|
||||||
|
showToast('请先填写 Hotmail 邮箱。', 'warn');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!clientId) {
|
||||||
|
showToast('请先填写微软应用客户端 ID。', 'warn');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!refreshToken) {
|
||||||
|
showToast('请先填写刷新令牌(refresh token)。', 'warn');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
hotmailActionInFlight = true;
|
||||||
|
btnAddHotmailAccount.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await chrome.runtime.sendMessage({
|
||||||
|
type: 'UPSERT_HOTMAIL_ACCOUNT',
|
||||||
|
source: 'sidepanel',
|
||||||
|
payload: {
|
||||||
|
email,
|
||||||
|
clientId,
|
||||||
|
password: inputHotmailPassword.value,
|
||||||
|
refreshToken,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response?.error) {
|
||||||
|
throw new Error(response.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast(`已保存 Hotmail 账号 ${email}`, 'success', 1800);
|
||||||
|
clearHotmailForm();
|
||||||
|
} catch (err) {
|
||||||
|
showToast(`保存 Hotmail 账号失败:${err.message}`, 'error');
|
||||||
|
} finally {
|
||||||
|
hotmailActionInFlight = false;
|
||||||
|
btnAddHotmailAccount.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
btnImportHotmailAccounts?.addEventListener('click', async () => {
|
||||||
|
if (hotmailActionInFlight) return;
|
||||||
|
if (typeof parseHotmailImportText !== 'function') {
|
||||||
|
showToast('导入解析器未加载,请刷新扩展后重试。', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawText = inputHotmailImport.value.trim();
|
||||||
|
if (!rawText) {
|
||||||
|
showToast('请先粘贴账号导入内容。', 'warn');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedAccounts = parseHotmailImportText(rawText);
|
||||||
|
if (!parsedAccounts.length) {
|
||||||
|
showToast('没有解析到有效账号,请检查格式是否为 账号----密码----ID----Token。', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
hotmailActionInFlight = true;
|
||||||
|
btnImportHotmailAccounts.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const account of parsedAccounts) {
|
||||||
|
const response = await chrome.runtime.sendMessage({
|
||||||
|
type: 'UPSERT_HOTMAIL_ACCOUNT',
|
||||||
|
source: 'sidepanel',
|
||||||
|
payload: account,
|
||||||
|
});
|
||||||
|
if (response?.error) {
|
||||||
|
throw new Error(response.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inputHotmailImport.value = '';
|
||||||
|
showToast(`已导入 ${parsedAccounts.length} 条 Hotmail 账号`, 'success', 2200);
|
||||||
|
} catch (err) {
|
||||||
|
showToast(`批量导入失败:${err.message}`, 'error');
|
||||||
|
} finally {
|
||||||
|
hotmailActionInFlight = false;
|
||||||
|
btnImportHotmailAccounts.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
hotmailAccountsList?.addEventListener('click', async (event) => {
|
||||||
|
const actionButton = event.target.closest('[data-account-action]');
|
||||||
|
if (!actionButton || hotmailActionInFlight) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountId = actionButton.dataset.accountId;
|
||||||
|
const action = actionButton.dataset.accountAction;
|
||||||
|
if (!accountId || !action) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetAccount = getHotmailAccounts().find((account) => account.id === accountId) || null;
|
||||||
|
|
||||||
|
hotmailActionInFlight = true;
|
||||||
|
actionButton.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (action === 'copy-email') {
|
||||||
|
if (!targetAccount?.email) throw new Error('未找到可复制的邮箱地址。');
|
||||||
|
await copyTextToClipboard(targetAccount.email);
|
||||||
|
showToast(`已复制 ${targetAccount.email}`, 'success', 1800);
|
||||||
|
} else if (action === 'select') {
|
||||||
|
const response = await chrome.runtime.sendMessage({
|
||||||
|
type: 'SELECT_HOTMAIL_ACCOUNT',
|
||||||
|
source: 'sidepanel',
|
||||||
|
payload: { accountId },
|
||||||
|
});
|
||||||
|
if (response?.error) throw new Error(response.error);
|
||||||
|
syncLatestState({ currentHotmailAccountId: response.account.id });
|
||||||
|
applyHotmailAccountMutation(response.account, { preserveCurrentSelection: true });
|
||||||
|
showToast(`已切换当前 Hotmail 账号为 ${response.account.email}`, 'success', 1800);
|
||||||
|
} else if (action === 'toggle-used') {
|
||||||
|
if (!targetAccount) throw new Error('未找到目标 Hotmail 账号。');
|
||||||
|
const response = await chrome.runtime.sendMessage({
|
||||||
|
type: 'PATCH_HOTMAIL_ACCOUNT',
|
||||||
|
source: 'sidepanel',
|
||||||
|
payload: {
|
||||||
|
accountId,
|
||||||
|
updates: { used: !targetAccount.used },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (response?.error) throw new Error(response.error);
|
||||||
|
applyHotmailAccountMutation(response.account);
|
||||||
|
showToast(`账号 ${response.account.email} 已${response.account.used ? '标记为已用' : '恢复为未用'}`, 'success', 2200);
|
||||||
|
} else if (action === 'verify') {
|
||||||
|
const response = await chrome.runtime.sendMessage({
|
||||||
|
type: 'VERIFY_HOTMAIL_ACCOUNT',
|
||||||
|
source: 'sidepanel',
|
||||||
|
payload: { accountId },
|
||||||
|
});
|
||||||
|
if (response?.error) throw new Error(response.error);
|
||||||
|
applyHotmailAccountMutation(response.account, { preserveCurrentSelection: true });
|
||||||
|
showToast(`账号 ${response.account.email} 校验通过`, 'success', 2200);
|
||||||
|
} else if (action === 'test') {
|
||||||
|
const response = await chrome.runtime.sendMessage({
|
||||||
|
type: 'TEST_HOTMAIL_ACCOUNT',
|
||||||
|
source: 'sidepanel',
|
||||||
|
payload: { accountId },
|
||||||
|
});
|
||||||
|
if (response?.error) throw new Error(response.error);
|
||||||
|
applyHotmailAccountMutation(response.account, { preserveCurrentSelection: true });
|
||||||
|
if (response.latestCode) {
|
||||||
|
await copyTextToClipboard(response.latestCode);
|
||||||
|
const mailbox = response.latestMailbox ? `(${response.latestMailbox})` : '';
|
||||||
|
showToast(`已复制最新验证码 ${response.latestCode}${mailbox}`, 'success', 2600);
|
||||||
|
} else if (response.latestSubject) {
|
||||||
|
const mailbox = response.latestMailbox ? `(${response.latestMailbox})` : '';
|
||||||
|
showToast(`最新邮件${mailbox}没有验证码:${response.latestSubject}`, 'warn', 3200);
|
||||||
|
} else {
|
||||||
|
showToast('当前没有可读取的最新邮件。', 'warn', 2600);
|
||||||
|
}
|
||||||
|
} else if (action === 'delete') {
|
||||||
|
const confirmed = await openConfirmModal({
|
||||||
|
title: '删除账号',
|
||||||
|
message: '确认删除这个 Hotmail 账号吗?对应 token 也会一起移除。',
|
||||||
|
confirmLabel: '确认删除',
|
||||||
|
confirmVariant: 'btn-danger',
|
||||||
|
});
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const response = await chrome.runtime.sendMessage({
|
||||||
|
type: 'DELETE_HOTMAIL_ACCOUNT',
|
||||||
|
source: 'sidepanel',
|
||||||
|
payload: { accountId },
|
||||||
|
});
|
||||||
|
if (response?.error) throw new Error(response.error);
|
||||||
|
showToast('Hotmail 账号已删除', 'success', 1800);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message, 'error');
|
||||||
|
} finally {
|
||||||
|
hotmailActionInFlight = false;
|
||||||
|
actionButton.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
btnTogglePassword.addEventListener('click', () => {
|
btnTogglePassword.addEventListener('click', () => {
|
||||||
inputPassword.type = inputPassword.type === 'password' ? 'text' : 'password';
|
inputPassword.type = inputPassword.type === 'password' ? 'text' : 'password';
|
||||||
syncPasswordToggleLabel();
|
syncPasswordToggleLabel();
|
||||||
@@ -1208,8 +1760,15 @@ btnReset.addEventListener('click', async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await chrome.runtime.sendMessage({ type: 'RESET', source: 'sidepanel' });
|
await chrome.runtime.sendMessage({ type: 'RESET', source: 'sidepanel' });
|
||||||
syncLatestState({ stepStatuses: STEP_DEFAULT_STATUSES });
|
syncLatestState({ stepStatuses: STEP_DEFAULT_STATUSES, currentHotmailAccountId: null, email: null });
|
||||||
syncAutoRunState({ autoRunning: false, autoRunPhase: 'idle', autoRunCurrentRun: 0, autoRunTotalRuns: 1, autoRunAttemptRun: 0, scheduledAutoRunAt: null });
|
syncAutoRunState({
|
||||||
|
autoRunning: false,
|
||||||
|
autoRunPhase: 'idle',
|
||||||
|
autoRunCurrentRun: 0,
|
||||||
|
autoRunTotalRuns: 1,
|
||||||
|
autoRunAttemptRun: 0,
|
||||||
|
scheduledAutoRunAt: null,
|
||||||
|
});
|
||||||
displayOauthUrl.textContent = '等待中...';
|
displayOauthUrl.textContent = '等待中...';
|
||||||
displayOauthUrl.classList.remove('has-value');
|
displayOauthUrl.classList.remove('has-value');
|
||||||
displayLocalhostUrl.textContent = '等待中...';
|
displayLocalhostUrl.textContent = '等待中...';
|
||||||
@@ -1226,6 +1785,7 @@ btnReset.addEventListener('click', async () => {
|
|||||||
updateStopButtonState(false);
|
updateStopButtonState(false);
|
||||||
updateButtonStates();
|
updateButtonStates();
|
||||||
updateProgressCounter();
|
updateProgressCounter();
|
||||||
|
renderHotmailAccounts();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clear log
|
// Clear log
|
||||||
@@ -1235,6 +1795,9 @@ btnClearLog.addEventListener('click', () => {
|
|||||||
|
|
||||||
// Save settings on change
|
// Save settings on change
|
||||||
inputEmail.addEventListener('change', async () => {
|
inputEmail.addEventListener('change', async () => {
|
||||||
|
if (selectMailProvider.value === 'hotmail-api') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const email = inputEmail.value.trim();
|
const email = inputEmail.value.trim();
|
||||||
if (email) {
|
if (email) {
|
||||||
await chrome.runtime.sendMessage({ type: 'SAVE_EMAIL', source: 'sidepanel', payload: { email } });
|
await chrome.runtime.sendMessage({ type: 'SAVE_EMAIL', source: 'sidepanel', payload: { email } });
|
||||||
@@ -1411,24 +1974,32 @@ chrome.runtime.onMessage.addListener((message) => {
|
|||||||
applyAutoRunStatus(currentAutoRun);
|
applyAutoRunStatus(currentAutoRun);
|
||||||
updateProgressCounter();
|
updateProgressCounter();
|
||||||
updateButtonStates();
|
updateButtonStates();
|
||||||
|
renderHotmailAccounts();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'DATA_UPDATED': {
|
case 'DATA_UPDATED': {
|
||||||
syncLatestState(message.payload);
|
syncLatestState(message.payload);
|
||||||
if (message.payload.email) {
|
if (message.payload.email !== undefined) {
|
||||||
inputEmail.value = message.payload.email;
|
inputEmail.value = message.payload.email || '';
|
||||||
}
|
}
|
||||||
if (message.payload.password !== undefined) {
|
if (message.payload.password !== undefined) {
|
||||||
inputPassword.value = message.payload.password || '';
|
inputPassword.value = message.payload.password || '';
|
||||||
}
|
}
|
||||||
if (message.payload.oauthUrl) {
|
if (message.payload.oauthUrl !== undefined) {
|
||||||
displayOauthUrl.textContent = message.payload.oauthUrl;
|
displayOauthUrl.textContent = message.payload.oauthUrl || '等待中...';
|
||||||
displayOauthUrl.classList.add('has-value');
|
displayOauthUrl.classList.toggle('has-value', Boolean(message.payload.oauthUrl));
|
||||||
}
|
}
|
||||||
if (message.payload.localhostUrl) {
|
if (message.payload.localhostUrl !== undefined) {
|
||||||
displayLocalhostUrl.textContent = message.payload.localhostUrl;
|
displayLocalhostUrl.textContent = message.payload.localhostUrl || '等待中...';
|
||||||
displayLocalhostUrl.classList.add('has-value');
|
displayLocalhostUrl.classList.toggle('has-value', Boolean(message.payload.localhostUrl));
|
||||||
|
}
|
||||||
|
if (message.payload.currentHotmailAccountId !== undefined || message.payload.hotmailAccounts !== undefined) {
|
||||||
|
renderHotmailAccounts();
|
||||||
|
if (selectMailProvider.value === 'hotmail-api') {
|
||||||
|
const currentAccount = getCurrentHotmailAccount();
|
||||||
|
inputEmail.value = currentAccount?.email || latestState?.email || '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (message.payload.autoRunDelayEnabled !== undefined) {
|
if (message.payload.autoRunDelayEnabled !== undefined) {
|
||||||
inputAutoDelayEnabled.checked = Boolean(message.payload.autoRunDelayEnabled);
|
inputAutoDelayEnabled.checked = Boolean(message.payload.autoRunDelayEnabled);
|
||||||
@@ -1488,6 +2059,7 @@ btnTheme.addEventListener('click', () => {
|
|||||||
|
|
||||||
initializeManualStepActions();
|
initializeManualStepActions();
|
||||||
initTheme();
|
initTheme();
|
||||||
|
initHotmailListExpandedState();
|
||||||
updateSaveButtonState();
|
updateSaveButtonState();
|
||||||
restoreState().then(() => {
|
restoreState().then(() => {
|
||||||
syncPasswordToggleLabel();
|
syncPasswordToggleLabel();
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const {
|
||||||
|
getActivationStrategy,
|
||||||
|
isRecoverableStep9AuthFailure,
|
||||||
|
} = require('../content/activation-utils.js');
|
||||||
|
|
||||||
|
test('getActivationStrategy prefers requestSubmit for submit buttons inside forms', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
getActivationStrategy({
|
||||||
|
tagName: 'button',
|
||||||
|
type: 'submit',
|
||||||
|
hasForm: true,
|
||||||
|
pathname: '/email-verification',
|
||||||
|
}),
|
||||||
|
{ method: 'requestSubmit' }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getActivationStrategy uses native click for non-submit actions', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
getActivationStrategy({
|
||||||
|
tagName: 'button',
|
||||||
|
type: 'button',
|
||||||
|
hasForm: true,
|
||||||
|
}),
|
||||||
|
{ method: 'click' }
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
getActivationStrategy({
|
||||||
|
tagName: 'a',
|
||||||
|
type: '',
|
||||||
|
hasForm: false,
|
||||||
|
}),
|
||||||
|
{ method: 'click' }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getActivationStrategy only uses requestSubmit on email verification routes', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
getActivationStrategy({
|
||||||
|
tagName: 'button',
|
||||||
|
type: 'submit',
|
||||||
|
hasForm: true,
|
||||||
|
pathname: '/u/signup/details',
|
||||||
|
}),
|
||||||
|
{ method: 'click' }
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
getActivationStrategy({
|
||||||
|
tagName: 'button',
|
||||||
|
type: 'submit',
|
||||||
|
hasForm: true,
|
||||||
|
pathname: '/email-verification',
|
||||||
|
}),
|
||||||
|
{ method: 'requestSubmit' }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isRecoverableStep9AuthFailure matches timeout and CPA auth failure statuses', () => {
|
||||||
|
assert.equal(
|
||||||
|
isRecoverableStep9AuthFailure('认证失败: Timeout waiting for OAuth callback'),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
isRecoverableStep9AuthFailure('认证失败: Request failed with status code 502'),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
isRecoverableStep9AuthFailure('认证成功!'),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
isRecoverableStep9AuthFailure('等待中'),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,485 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const {
|
||||||
|
buildHotmailGraphMessagesUrl,
|
||||||
|
extractVerificationCodeFromMessage,
|
||||||
|
filterHotmailAccountsByUsage,
|
||||||
|
extractVerificationCode,
|
||||||
|
getLatestHotmailMessage,
|
||||||
|
getHotmailBulkActionLabel,
|
||||||
|
getHotmailListToggleLabel,
|
||||||
|
getHotmailGraphRequestConfig,
|
||||||
|
getHotmailVerificationPollConfig,
|
||||||
|
getHotmailVerificationRequestTimestamp,
|
||||||
|
normalizeHotmailMailboxId,
|
||||||
|
normalizeHotmailMailApiMessages,
|
||||||
|
parseHotmailImportText,
|
||||||
|
pickHotmailAccountForRun,
|
||||||
|
pickVerificationMessage,
|
||||||
|
pickVerificationMessageWithFallback,
|
||||||
|
pickVerificationMessageWithTimeFallback,
|
||||||
|
shouldClearHotmailCurrentSelection,
|
||||||
|
upsertHotmailAccountInList,
|
||||||
|
} = require('../hotmail-utils.js');
|
||||||
|
|
||||||
|
test('pickHotmailAccountForRun prefers authorized account with oldest lastUsedAt', () => {
|
||||||
|
const now = Date.UTC(2026, 3, 10, 10, 0, 0);
|
||||||
|
const accounts = [
|
||||||
|
{
|
||||||
|
id: 'recent',
|
||||||
|
email: 'recent@hotmail.com',
|
||||||
|
status: 'authorized',
|
||||||
|
refreshToken: 'rt-recent',
|
||||||
|
lastUsedAt: now - 1_000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'oldest',
|
||||||
|
email: 'oldest@hotmail.com',
|
||||||
|
status: 'authorized',
|
||||||
|
refreshToken: 'rt-oldest',
|
||||||
|
lastUsedAt: now - 50_000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pending',
|
||||||
|
email: 'pending@hotmail.com',
|
||||||
|
status: 'pending',
|
||||||
|
refreshToken: '',
|
||||||
|
lastUsedAt: 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const selected = pickHotmailAccountForRun(accounts, { now });
|
||||||
|
|
||||||
|
assert.equal(selected.id, 'oldest');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pickHotmailAccountForRun skips used accounts but ignores legacy enabled flag', () => {
|
||||||
|
const accounts = [
|
||||||
|
{
|
||||||
|
id: 'disabled',
|
||||||
|
email: 'disabled@hotmail.com',
|
||||||
|
status: 'authorized',
|
||||||
|
refreshToken: 'rt-disabled',
|
||||||
|
enabled: false,
|
||||||
|
used: false,
|
||||||
|
lastUsedAt: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'used',
|
||||||
|
email: 'used@hotmail.com',
|
||||||
|
status: 'authorized',
|
||||||
|
refreshToken: 'rt-used',
|
||||||
|
enabled: true,
|
||||||
|
used: true,
|
||||||
|
lastUsedAt: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'available',
|
||||||
|
email: 'available@hotmail.com',
|
||||||
|
status: 'authorized',
|
||||||
|
refreshToken: 'rt-available',
|
||||||
|
enabled: true,
|
||||||
|
used: false,
|
||||||
|
lastUsedAt: 2,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const selected = pickHotmailAccountForRun(accounts, {});
|
||||||
|
|
||||||
|
assert.equal(selected.id, 'disabled');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pickHotmailAccountForRun returns null for used-only pools', () => {
|
||||||
|
assert.equal(
|
||||||
|
pickHotmailAccountForRun([{
|
||||||
|
id: 'used-only',
|
||||||
|
email: 'used-only@hotmail.com',
|
||||||
|
status: 'authorized',
|
||||||
|
refreshToken: 'rt-used-only',
|
||||||
|
used: true,
|
||||||
|
lastUsedAt: 0,
|
||||||
|
}], {}),
|
||||||
|
null
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pickHotmailAccountForRun falls back to never-used authorized account first', () => {
|
||||||
|
const accounts = [
|
||||||
|
{
|
||||||
|
id: 'used',
|
||||||
|
email: 'used@hotmail.com',
|
||||||
|
status: 'authorized',
|
||||||
|
refreshToken: 'rt-used',
|
||||||
|
lastUsedAt: Date.UTC(2026, 3, 10, 9, 0, 0),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fresh',
|
||||||
|
email: 'fresh@hotmail.com',
|
||||||
|
status: 'authorized',
|
||||||
|
refreshToken: 'rt-fresh',
|
||||||
|
lastUsedAt: 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const selected = pickHotmailAccountForRun(accounts, { now: Date.UTC(2026, 3, 10, 10, 0, 0) });
|
||||||
|
|
||||||
|
assert.equal(selected.id, 'fresh');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('upsertHotmailAccountInList replaces matching account state by id', () => {
|
||||||
|
const accounts = [
|
||||||
|
{
|
||||||
|
id: 'active',
|
||||||
|
email: 'active@hotmail.com',
|
||||||
|
status: 'authorized',
|
||||||
|
used: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'other',
|
||||||
|
email: 'other@hotmail.com',
|
||||||
|
status: 'authorized',
|
||||||
|
used: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const nextAccounts = upsertHotmailAccountInList(accounts, {
|
||||||
|
id: 'active',
|
||||||
|
email: 'active@hotmail.com',
|
||||||
|
status: 'authorized',
|
||||||
|
used: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(nextAccounts, [
|
||||||
|
{
|
||||||
|
id: 'active',
|
||||||
|
email: 'active@hotmail.com',
|
||||||
|
status: 'authorized',
|
||||||
|
used: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'other',
|
||||||
|
email: 'other@hotmail.com',
|
||||||
|
status: 'authorized',
|
||||||
|
used: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shouldClearHotmailCurrentSelection returns true only when account becomes used', () => {
|
||||||
|
assert.equal(shouldClearHotmailCurrentSelection({
|
||||||
|
id: 'used',
|
||||||
|
used: true,
|
||||||
|
}), true);
|
||||||
|
|
||||||
|
assert.equal(shouldClearHotmailCurrentSelection({
|
||||||
|
id: 'available',
|
||||||
|
used: false,
|
||||||
|
}), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extractVerificationCode returns first six-digit code from multilingual mail text', () => {
|
||||||
|
assert.equal(extractVerificationCode('你的 ChatGPT 验证码为 370794,请勿泄露。'), '370794');
|
||||||
|
assert.equal(extractVerificationCode('Your verification code is 654321.'), '654321');
|
||||||
|
assert.equal(extractVerificationCode('No code here'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extractVerificationCodeFromMessage reads code from the latest message subject or preview', () => {
|
||||||
|
assert.equal(
|
||||||
|
extractVerificationCodeFromMessage({
|
||||||
|
subject: '你的 ChatGPT 代码为 192742',
|
||||||
|
bodyPreview: 'OpenAI 验证邮件',
|
||||||
|
from: { emailAddress: { address: 'noreply@openai.com' } },
|
||||||
|
}),
|
||||||
|
'192742'
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
extractVerificationCodeFromMessage({
|
||||||
|
subject: 'OpenAI security message',
|
||||||
|
bodyPreview: 'Your verification code is 654321.',
|
||||||
|
from: { emailAddress: { address: 'noreply@openai.com' } },
|
||||||
|
}),
|
||||||
|
'654321'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getHotmailListToggleLabel reflects expanded state and account count', () => {
|
||||||
|
assert.equal(getHotmailListToggleLabel(false, 0), '展开列表');
|
||||||
|
assert.equal(getHotmailListToggleLabel(false, 7), '展开列表(7)');
|
||||||
|
assert.equal(getHotmailListToggleLabel(true, 7), '收起列表(7)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filterHotmailAccountsByUsage can pick only used accounts or return all accounts', () => {
|
||||||
|
const accounts = [
|
||||||
|
{ id: 'used-1', email: 'used-1@hotmail.com', used: true },
|
||||||
|
{ id: 'fresh-1', email: 'fresh-1@hotmail.com', used: false },
|
||||||
|
{ id: 'used-2', email: 'used-2@hotmail.com', used: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
filterHotmailAccountsByUsage(accounts, 'used').map((account) => account.id),
|
||||||
|
['used-1', 'used-2']
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
filterHotmailAccountsByUsage(accounts, 'all').map((account) => account.id),
|
||||||
|
['used-1', 'fresh-1', 'used-2']
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getHotmailBulkActionLabel reflects action type and count', () => {
|
||||||
|
assert.equal(getHotmailBulkActionLabel('used', 0), '清空已用');
|
||||||
|
assert.equal(getHotmailBulkActionLabel('used', 3), '清空已用(3)');
|
||||||
|
assert.equal(getHotmailBulkActionLabel('all', 5), '全部删除(5)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getLatestHotmailMessage picks the newest received mail', () => {
|
||||||
|
const latest = getLatestHotmailMessage([
|
||||||
|
{
|
||||||
|
id: 'older',
|
||||||
|
subject: 'older',
|
||||||
|
receivedDateTime: '2026-04-11T00:01:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'newest',
|
||||||
|
subject: 'newest',
|
||||||
|
receivedDateTime: '2026-04-11T00:05:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'middle',
|
||||||
|
subject: 'middle',
|
||||||
|
receivedDateTime: '2026-04-11T00:03:00.000Z',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(latest.id, 'newest');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pickVerificationMessage filters by time, sender, subject, and excluded codes', () => {
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
id: 'old-mail',
|
||||||
|
subject: 'Your code is 111111',
|
||||||
|
from: { emailAddress: { address: 'noreply@openai.com' } },
|
||||||
|
bodyPreview: '111111',
|
||||||
|
receivedDateTime: '2026-04-10T09:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'wrong-sender',
|
||||||
|
subject: 'Your code is 222222',
|
||||||
|
from: { emailAddress: { address: 'noreply@example.com' } },
|
||||||
|
bodyPreview: '222222',
|
||||||
|
receivedDateTime: '2026-04-10T10:01:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'good-mail',
|
||||||
|
subject: 'ChatGPT verification code 333333',
|
||||||
|
from: { emailAddress: { address: 'noreply@openai.com' } },
|
||||||
|
bodyPreview: 'Use 333333 to continue',
|
||||||
|
receivedDateTime: '2026-04-10T10:02:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'excluded-mail',
|
||||||
|
subject: 'ChatGPT verification code 444444',
|
||||||
|
from: { emailAddress: { address: 'noreply@openai.com' } },
|
||||||
|
bodyPreview: 'Use 444444 to continue',
|
||||||
|
receivedDateTime: '2026-04-10T10:03:00.000Z',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const match = pickVerificationMessage(messages, {
|
||||||
|
afterTimestamp: Date.UTC(2026, 3, 10, 10, 0, 0),
|
||||||
|
senderFilters: ['openai', 'noreply'],
|
||||||
|
subjectFilters: ['verification', 'code', 'chatgpt'],
|
||||||
|
excludeCodes: ['444444'],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(match.message.id, 'good-mail');
|
||||||
|
assert.equal(match.code, '333333');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pickVerificationMessageWithFallback no longer matches arbitrary recent mails when filters miss', () => {
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
id: 'login-mail',
|
||||||
|
subject: 'Use this security code to continue 555666',
|
||||||
|
from: { emailAddress: { address: 'account-security@openai.com' } },
|
||||||
|
bodyPreview: 'Your one-time security code is 555666',
|
||||||
|
receivedDateTime: '2026-04-10T10:05:00.000Z',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = pickVerificationMessageWithFallback(messages, {
|
||||||
|
afterTimestamp: Date.UTC(2026, 3, 10, 10, 0, 0),
|
||||||
|
senderFilters: ['noreply'],
|
||||||
|
subjectFilters: ['verification'],
|
||||||
|
excludeCodes: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.match, null);
|
||||||
|
assert.equal(result.usedRelaxedFilters, false);
|
||||||
|
assert.equal(result.usedTimeFallback, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pickVerificationMessageWithTimeFallback can ignore afterTimestamp while keeping sender and subject filters', () => {
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
id: 'slightly-old-mail',
|
||||||
|
subject: '你的 ChatGPT 代码为 141735',
|
||||||
|
from: { emailAddress: { address: 'unknown' } },
|
||||||
|
bodyPreview: 'OpenAI logo ...',
|
||||||
|
receivedDateTime: '2026-04-10T10:00:02.000Z',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = pickVerificationMessageWithTimeFallback(messages, {
|
||||||
|
afterTimestamp: Date.UTC(2026, 3, 10, 10, 0, 10),
|
||||||
|
senderFilters: ['openai', 'noreply'],
|
||||||
|
subjectFilters: ['verify', 'verification', 'code'],
|
||||||
|
excludeCodes: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.match.message.id, 'slightly-old-mail');
|
||||||
|
assert.equal(result.match.code, '141735');
|
||||||
|
assert.equal(result.usedRelaxedFilters, false);
|
||||||
|
assert.equal(result.usedTimeFallback, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildHotmailGraphMessagesUrl targets the official Microsoft Graph mailbox endpoint', () => {
|
||||||
|
const url = new URL(buildHotmailGraphMessagesUrl({
|
||||||
|
mailbox: 'Junk',
|
||||||
|
}));
|
||||||
|
|
||||||
|
assert.equal(url.origin + url.pathname, 'https://graph.microsoft.com/v1.0/me/mailFolders/junkemail/messages');
|
||||||
|
assert.equal(url.searchParams.get('$top'), '10');
|
||||||
|
assert.equal(url.searchParams.get('$orderby'), 'receivedDateTime desc');
|
||||||
|
assert.match(url.searchParams.get('$select'), /subject/);
|
||||||
|
assert.match(url.searchParams.get('$select'), /receivedDateTime/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeHotmailMailboxId maps supported mailbox labels to Graph folder ids', () => {
|
||||||
|
assert.equal(normalizeHotmailMailboxId('INBOX'), 'inbox');
|
||||||
|
assert.equal(normalizeHotmailMailboxId('Junk'), 'junkemail');
|
||||||
|
assert.equal(normalizeHotmailMailboxId('junkemail'), 'junkemail');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeHotmailMailApiMessages maps third-party payload fields into verification message shape', () => {
|
||||||
|
const messages = normalizeHotmailMailApiMessages([
|
||||||
|
{
|
||||||
|
id: 'mail-1',
|
||||||
|
from: 'noreply@openai.com',
|
||||||
|
subject: 'ChatGPT verification code',
|
||||||
|
text: 'Use 135790 to continue',
|
||||||
|
date: '2026-04-10T10:02:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message_id: 'mail-2',
|
||||||
|
sender_email: 'alerts@example.com',
|
||||||
|
title: 'Ignored',
|
||||||
|
body: 'No code here',
|
||||||
|
received_at: '2026-04-10T10:03:00.000Z',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.deepEqual(messages, [
|
||||||
|
{
|
||||||
|
id: 'mail-1',
|
||||||
|
subject: 'ChatGPT verification code',
|
||||||
|
from: { emailAddress: { address: 'noreply@openai.com' } },
|
||||||
|
bodyPreview: 'Use 135790 to continue',
|
||||||
|
receivedDateTime: '2026-04-10T10:02:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mail-2',
|
||||||
|
subject: 'Ignored',
|
||||||
|
from: { emailAddress: { address: 'alerts@example.com' } },
|
||||||
|
bodyPreview: 'No code here',
|
||||||
|
receivedDateTime: '2026-04-10T10:03:00.000Z',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getHotmailVerificationPollConfig gives Hotmail a slower initial wait and longer polling window', () => {
|
||||||
|
assert.deepEqual(getHotmailVerificationPollConfig(4), {
|
||||||
|
initialDelayMs: 5000,
|
||||||
|
maxAttempts: 12,
|
||||||
|
intervalMs: 5000,
|
||||||
|
requestFreshCodeFirst: false,
|
||||||
|
ignorePersistedLastCode: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(getHotmailVerificationPollConfig(7), {
|
||||||
|
initialDelayMs: 5000,
|
||||||
|
maxAttempts: 12,
|
||||||
|
intervalMs: 5000,
|
||||||
|
requestFreshCodeFirst: false,
|
||||||
|
ignorePersistedLastCode: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getHotmailVerificationRequestTimestamp prefers actual request timestamps with a safety buffer', () => {
|
||||||
|
const signupRequestedAt = Date.UTC(2026, 3, 10, 12, 0, 30);
|
||||||
|
const loginRequestedAt = Date.UTC(2026, 3, 10, 12, 5, 45);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
getHotmailVerificationRequestTimestamp(4, {
|
||||||
|
signupVerificationRequestedAt: signupRequestedAt,
|
||||||
|
flowStartTime: signupRequestedAt - 60_000,
|
||||||
|
}),
|
||||||
|
signupRequestedAt - 15_000
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
getHotmailVerificationRequestTimestamp(7, {
|
||||||
|
loginVerificationRequestedAt: loginRequestedAt,
|
||||||
|
lastEmailTimestamp: loginRequestedAt - 120_000,
|
||||||
|
flowStartTime: loginRequestedAt - 300_000,
|
||||||
|
}),
|
||||||
|
loginRequestedAt - 15_000
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getHotmailGraphRequestConfig defines Microsoft Graph request defaults', () => {
|
||||||
|
assert.deepEqual(getHotmailGraphRequestConfig(), {
|
||||||
|
timeoutMs: 15000,
|
||||||
|
pageSize: 10,
|
||||||
|
scopes: [
|
||||||
|
'offline_access',
|
||||||
|
'https://graph.microsoft.com/Mail.Read',
|
||||||
|
'https://graph.microsoft.com/User.Read',
|
||||||
|
],
|
||||||
|
tokenUrl: 'https://login.microsoftonline.com/consumers/oauth2/v2.0/token',
|
||||||
|
messageFields: [
|
||||||
|
'id',
|
||||||
|
'internetMessageId',
|
||||||
|
'subject',
|
||||||
|
'from',
|
||||||
|
'bodyPreview',
|
||||||
|
'receivedDateTime',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseHotmailImportText parses account lines in email----password----clientId----token format', () => {
|
||||||
|
const parsed = parseHotmailImportText(`
|
||||||
|
账号----密码----ID----Token
|
||||||
|
JohnRodriguez5425@hotmail.com----nb4ta1OK----9e5f94bc-e8a4-4e73-b8be-63364c29d753----refresh-token-1
|
||||||
|
alice@hotmail.com----pass-2----client-2----refresh-token-2
|
||||||
|
`.trim());
|
||||||
|
|
||||||
|
assert.deepEqual(parsed, [
|
||||||
|
{
|
||||||
|
email: 'JohnRodriguez5425@hotmail.com',
|
||||||
|
password: 'nb4ta1OK',
|
||||||
|
clientId: '9e5f94bc-e8a4-4e73-b8be-63364c29d753',
|
||||||
|
refreshToken: 'refresh-token-1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
email: 'alice@hotmail.com',
|
||||||
|
password: 'pass-2',
|
||||||
|
clientId: 'client-2',
|
||||||
|
refreshToken: 'refresh-token-2',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user