Merge PR #10: add Hotmail Graph account pool flow

This commit is contained in:
QLHazyCoder
2026-04-11 13:50:30 +08:00
16 changed files with 3299 additions and 89 deletions
+31 -5
View File
@@ -2,7 +2,7 @@
一个用于批量跑通 ChatGPT OAuth 注册/登录流程的 Chrome 扩展。
当前版本基于侧边栏控制,支持单步执行、整套自动执行、停止当前流程、保存常用配置,以及通过 DuckDuckGo / QQ / 163 / Inbucket mailbox 协助获取验证码。
当前版本基于侧边栏控制,支持单步执行、整套自动执行、停止当前流程、保存常用配置,以及通过 DuckDuckGo / QQ / 163 / Inbucket / Hotmail API 协助获取验证码。
## 最新版本测试结果
@@ -42,6 +42,7 @@
- 支持自定义密码;留空时自动生成强密码
- 自动显示当前使用中的密码,便于后续保存
- 自动获取注册验证码与登录验证码
- 支持 `Hotmail API`:直接使用 `email + client_id + refresh_token` 刷新 Microsoft token,并通过 Microsoft Graph 读取最新邮件
- 支持 `QQ Mail``163 Mail``Inbucket mailbox`
- 支持从 DuckDuckGo Email Protection 自动生成新的 `@duck.com` 地址
- Step 5 同时兼容两种页面:
@@ -84,17 +85,37 @@ Step 1 和 Step 9 都依赖这个地址。
### `Mail`
支持种验证码来源:
支持种验证码来源:
- `Hotmail API`
- `163 Mail`
- `QQ Mail`
- `Inbucket`
说明:
- `Hotmail API` 通过侧边栏里的 Hotmail 账号池选择账号,并直接访问 Microsoft Graph 邮件接口
- `QQ``163` 用于直接轮询网页邮箱
- `Inbucket` 通过你在侧边栏里配置的 host 访问 `mailbox` 页面:`https://<your-inbucket-host>/m/<mailbox>/`
### `Hotmail 账号池`
仅当 `Mail = Hotmail API` 时使用。
每条账号支持保存:
- `email`
- `clientId`
- `refreshToken`
- 可选邮箱密码备注
使用方式:
- 先新增账号
- 点击 `校验`
- 校验通过后,可点击 `测试收信`
- Auto 模式每轮会自动选用一个可用账号
### `Mailbox`
仅当 `Mail = Inbucket` 时显示。
@@ -139,6 +160,7 @@ Step 3 使用的注册邮箱。
注意:
-`Mail = Hotmail API` 时,这个输入框由账号池自动同步当前账号邮箱
- 当前 `Auto` 按钮只负责 DuckDuckGo 地址获取
- 如果你使用 Inbucket,它只是验证码收件箱,不会自动生成 Inbucket 地址
@@ -186,9 +208,10 @@ Step 3 使用的注册邮箱。
1. Step 1 获取 CPA OAuth 链接
2. Step 2 打开 OpenAI 注册页
3. 尝试自动获取 Duck 邮箱
4. 如果 Duck 自动获取失败,暂停并等待你在侧边栏填写邮箱后点击 `Continue`
5. 继续执行 Step 3 ~ Step 9
3. 根据 `Mail` 选择邮箱来源
4. `Hotmail API` 会从账号池自动分配一个可用账号
5. Duck 模式下会优先尝试自动获取邮箱;失败时暂停等待手动填写
6. 继续执行 Step 3 ~ Step 9
也就是说:
@@ -237,6 +260,7 @@ Step 3 使用的注册邮箱。
支持:
- `Hotmail API`Microsoft Graph 邮件接口)
- `content/qq-mail.js`
- `content/mail-163.js`
- `content/inbucket-mail.js`
@@ -359,6 +383,7 @@ Step 3 使用的注册邮箱。
- 邮箱服务
- Inbucket 主机
- Inbucket 邮箱名
- Hotmail 账号池与对应 token
- 兜底开关
特点:
@@ -378,6 +403,7 @@ data/names.js 随机姓名、生日数据
content/utils.js 通用工具:等待元素、点击、日志、停止控制
content/vps-panel.js CPA 面板步骤:Step 1 / Step 9
content/signup-page.js OpenAI 注册/登录页步骤:Step 2 / 3 / 5 / 6 / 8
hotmail-utils.js Hotmail API 相关通用 helper
content/duck-mail.js Duck 邮箱自动获取
content/qq-mail.js QQ 邮箱验证码轮询
content/mail-163.js 163 邮箱验证码轮询
+859 -50
View File
File diff suppressed because it is too large Load Diff
+53
View File
@@ -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,
};
});
+9 -7
View File
@@ -215,9 +215,10 @@ async function prepareLoginCodeFlow(timeout = 15000) {
loggedPasswordPage = false;
log('步骤 7:检测到密码页,正在切换到一次性验证码登录...');
await humanPause(350, 900);
const verificationRequestedAt = Date.now();
simulateClick(switchTrigger);
await sleep(1200);
continue;
return { ready: true, mode: 'verification_switch', verificationRequestedAt };
}
if (passwordInput && !loggedPasswordPage) {
@@ -351,15 +352,16 @@ async function step3_fillEmailPassword(payload) {
fillInput(passwordInput, payload.password);
log('步骤 3:密码已填写');
// 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"]')
|| 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) {
await humanPause(500, 1300);
simulateClick(submitBtn);
+30 -3
View File
@@ -1,5 +1,7 @@
// content/utils.js — Shared utilities for all content scripts
const getActivationStrategy = self.MultiPageActivationUtils?.getActivationStrategy;
const SCRIPT_SOURCE = (() => {
if (window.__MULTIPAGE_SOURCE) return window.__MULTIPAGE_SOURCE;
const url = location.href;
@@ -305,9 +307,34 @@ function reportError(step, errorMessage) {
*/
function simulateClick(el) {
throwIfStopped();
el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
console.log(LOG_PREFIX, `已点击: ${el.tagName} ${el.textContent?.slice(0, 30) || ''}`);
log(`已点击 [${el.tagName}] "${el.textContent?.trim().slice(0, 30) || ''}"`);
if (!el) {
throw new Error('无法点击空元素。');
}
const form = el.form || el.closest?.('form') || null;
const strategy = typeof getActivationStrategy === 'function'
? getActivationStrategy({
tagName: el.tagName,
type: el.getAttribute?.('type') || el.type || '',
hasForm: Boolean(form),
pathname: location.pathname || '',
})
: { method: 'click' };
let method = strategy.method || 'click';
if (method === 'requestSubmit' && form && typeof form.requestSubmit === 'function') {
form.requestSubmit(el);
} else if (typeof el.click === 'function') {
method = 'click';
el.click();
} else {
method = 'dispatchEvent';
el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
}
console.log(LOG_PREFIX, `已点击(${method}): ${el.tagName} ${el.textContent?.slice(0, 30) || ''}`);
log(`已点击(${method}) [${el.tagName}] "${el.textContent?.trim().slice(0, 30) || ''}"`);
}
/**
+12
View File
@@ -26,6 +26,9 @@
console.log('[MultiPage:vps-panel] Content script loaded on', location.href);
const VPS_PANEL_LISTENER_SENTINEL = 'data-multipage-vps-panel-listener';
const {
isRecoverableStep9AuthFailure,
} = self.MultiPageActivationUtils || {};
if (document.documentElement.getAttribute(VPS_PANEL_LISTENER_SENTINEL) !== '1') {
document.documentElement.setAttribute(VPS_PANEL_LISTENER_SENTINEL, '1');
@@ -137,6 +140,12 @@ async function waitForExactSuccessBadge(timeout = 30000) {
while (Date.now() - start < timeout) {
throwIfStopped();
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 === '认证成功!') {
return statusText;
}
@@ -147,6 +156,9 @@ async function waitForExactSuccessBadge(timeout = 30000) {
if (isOAuthCallbackTimeoutFailure(finalText)) {
throw new Error(`STEP9_OAUTH_TIMEOUT::${finalText}`);
}
if (typeof isRecoverableStep9AuthFailure === 'function' && isRecoverableStep9AuthFailure(finalText)) {
throw new Error(`STEP9_OAUTH_RETRY::${finalText}`);
}
throw new Error(finalText
? `CPA 面板状态不是“认证成功!”,当前为“${finalText}”。`
: 'CPA 面板长时间未出现“认证成功!”状态徽标。');
@@ -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`.
+402
View File
@@ -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
View File
@@ -28,7 +28,7 @@
"https://auth.openai.com/*",
"https://accounts.openai.com/*"
],
"js": ["content/utils.js", "content/signup-page.js"],
"js": ["content/activation-utils.js", "content/utils.js", "content/signup-page.js"],
"run_at": "document_idle"
},
{
@@ -36,7 +36,7 @@
"https://mail.qq.com/*",
"https://wx.mail.qq.com/*"
],
"js": ["content/utils.js", "content/qq-mail.js"],
"js": ["content/activation-utils.js", "content/utils.js", "content/qq-mail.js"],
"all_frames": true,
"run_at": "document_idle"
},
@@ -46,7 +46,7 @@
"https://*.mail.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,
"run_at": "document_idle"
},
@@ -54,7 +54,7 @@
"matches": [
"https://duckduckgo.com/email/settings/autofill*"
],
"js": ["content/utils.js", "content/duck-mail.js"],
"js": ["content/activation-utils.js", "content/utils.js", "content/duck-mail.js"],
"run_at": "document_idle"
}
],
+7
View File
@@ -0,0 +1,7 @@
{
"name": "codex-oauth-automation-extension",
"private": true,
"scripts": {
"test": "node --test tests/*.test.js"
}
}
+227
View File
@@ -238,6 +238,33 @@ header {
box-shadow: var(--shadow-sm);
}
.section-mini-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.section-mini-copy {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.section-mini-actions {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
justify-content: flex-end;
}
.section-hint {
color: var(--text-muted);
font-size: 12px;
}
.data-row {
display: flex;
align-items: center;
@@ -309,6 +336,23 @@ header {
.data-input::placeholder { color: var(--text-muted); }
.data-input:focus { border-color: var(--blue); box-shadow: 0 0 0 3px var(--blue-soft); }
.data-textarea {
width: 100%;
min-height: 88px;
resize: vertical;
padding: 8px 10px;
background: var(--bg-base);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
outline: none;
transition: border-color var(--transition), box-shadow var(--transition);
}
.data-textarea::placeholder { color: var(--text-muted); }
.data-textarea:focus { border-color: var(--blue); box-shadow: 0 0 0 3px var(--blue-soft); }
.data-input-with-icon {
padding-right: 38px;
}
@@ -340,6 +384,189 @@ header {
flex-shrink: 0;
}
.hotmail-card {
margin-top: 10px;
}
.hotmail-actions-row .data-label {
visibility: hidden;
}
.hotmail-import-row {
align-items: flex-start;
}
.hotmail-import-box {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
.hotmail-list-shell {
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--bg-base) 82%, transparent);
overflow-y: auto;
overflow-x: hidden;
padding: 2px;
transition: max-height var(--transition), border-color var(--transition), box-shadow var(--transition);
}
.hotmail-list-shell.is-collapsed {
max-height: 236px;
}
.hotmail-list-shell.is-expanded {
max-height: 440px;
}
.hotmail-list-shell::-webkit-scrollbar {
width: 8px;
}
.hotmail-list-shell::-webkit-scrollbar-track {
background: transparent;
}
.hotmail-list-shell::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 999px;
}
.hotmail-list-shell::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
.hotmail-accounts-list {
display: flex;
flex-direction: column;
gap: 8px;
padding-right: 4px;
}
.hotmail-account-item {
border: 1px solid var(--border);
background: var(--bg-base);
border-radius: var(--radius-sm);
padding: 10px;
display: flex;
flex-direction: column;
gap: 8px;
}
.hotmail-account-item.is-current {
border-color: var(--blue);
box-shadow: 0 0 0 1px var(--blue-glow);
}
.hotmail-account-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.hotmail-account-title-row {
min-width: 0;
display: flex;
align-items: center;
gap: 6px;
}
.hotmail-account-email {
font-weight: 600;
color: var(--text-primary);
word-break: break-all;
}
.hotmail-copy-btn {
width: 24px;
height: 24px;
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 999px;
background: transparent;
color: var(--text-muted);
cursor: pointer;
transition: background var(--transition), color var(--transition), transform var(--transition);
}
.hotmail-copy-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.hotmail-copy-btn:active {
transform: scale(0.96);
}
.hotmail-status-chip {
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.hotmail-status-chip.status-authorized {
background: var(--green-soft);
color: var(--green);
}
.hotmail-status-chip.status-used {
background: var(--orange-soft);
color: var(--orange);
}
.hotmail-status-chip.status-disabled {
background: var(--bg-surface);
color: var(--text-secondary);
}
.hotmail-status-chip.status-pending {
background: var(--orange-soft);
color: var(--orange);
}
.hotmail-status-chip.status-error {
background: var(--red-soft);
color: var(--red);
}
.hotmail-account-meta {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px 10px;
color: var(--text-secondary);
font-size: 12px;
}
.hotmail-account-error {
font-size: 12px;
color: var(--red);
word-break: break-word;
}
.hotmail-account-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.hotmail-empty {
color: var(--text-muted);
font-size: 12px;
border: 1px dashed var(--border);
border-radius: var(--radius-sm);
padding: 10px;
text-align: center;
}
.data-select {
flex: 1;
padding: 7px 10px;
+45
View File
@@ -58,6 +58,7 @@
<div class="data-row">
<span class="data-label">邮箱服务</span>
<select id="select-mail-provider" class="data-select">
<option value="hotmail-api">Hotmail Graphrefresh_token</option>
<option value="163">163 邮箱 (mail.163.com)</option>
<option value="163-vip">163 VIP 邮箱 (webmail.vip.163.com)</option>
<option value="qq">QQ 邮箱 (wx.mail.qq.com)</option>
@@ -105,6 +106,49 @@
<span id="display-localhost-url" class="data-value mono truncate">等待中...</span>
</div>
</div>
<div id="hotmail-section" class="data-card hotmail-card" style="display:none;">
<div class="section-mini-header">
<div class="section-mini-copy">
<span class="section-label">Hotmail 账号池</span>
<span class="section-hint">每轮自动分配一个可用账号</span>
</div>
<div class="section-mini-actions">
<button id="btn-clear-used-hotmail-accounts" class="btn btn-ghost btn-xs" type="button">清空已用</button>
<button id="btn-delete-all-hotmail-accounts" class="btn btn-ghost btn-xs" type="button">全部删除</button>
<button id="btn-toggle-hotmail-list" class="btn btn-ghost btn-xs" type="button" aria-expanded="false">展开列表</button>
</div>
</div>
<div class="data-row">
<span class="data-label">邮箱</span>
<input type="text" id="input-hotmail-email" class="data-input" placeholder="name@hotmail.com" />
</div>
<div class="data-row">
<span class="data-label">Client ID</span>
<input type="text" id="input-hotmail-client-id" class="data-input mono" placeholder="Microsoft app client id" />
</div>
<div class="data-row">
<span class="data-label">邮箱密码</span>
<input type="password" id="input-hotmail-password" class="data-input" placeholder="可选,仅用于记录" />
</div>
<div class="data-row">
<span class="data-label">Refresh</span>
<input type="password" id="input-hotmail-refresh-token" class="data-input mono" placeholder="必填,粘贴 refresh_token" />
</div>
<div class="data-row hotmail-actions-row">
<span class="data-label"></span>
<button id="btn-add-hotmail-account" class="btn btn-primary btn-sm" type="button">添加账号</button>
</div>
<div class="data-row hotmail-import-row">
<span class="data-label">批量导入</span>
<div class="hotmail-import-box">
<textarea id="input-hotmail-import" class="data-textarea mono" placeholder="账号----密码----ID----Token&#10;name@hotmail.com----password----client-id----refresh-token"></textarea>
<button id="btn-import-hotmail-accounts" class="btn btn-outline btn-sm" type="button">导入</button>
</div>
</div>
<div id="hotmail-list-shell" class="hotmail-list-shell is-collapsed">
<div id="hotmail-accounts-list" class="hotmail-accounts-list"></div>
</div>
</div>
<div id="status-bar" class="status-bar">
<div class="status-dot"></div>
<span id="display-status">就绪</span>
@@ -194,6 +238,7 @@
</div>
<div id="toast-container"></div>
<script src="../hotmail-utils.js"></script>
<script src="sidepanel.js"></script>
</body>
</html>
+584 -20
View File
@@ -31,6 +31,19 @@ const btnClearLog = document.getElementById('btn-clear-log');
const inputVpsUrl = document.getElementById('input-vps-url');
const inputVpsPassword = document.getElementById('input-vps-password');
const selectMailProvider = document.getElementById('select-mail-provider');
const hotmailSection = document.getElementById('hotmail-section');
const inputHotmailEmail = document.getElementById('input-hotmail-email');
const inputHotmailClientId = document.getElementById('input-hotmail-client-id');
const inputHotmailPassword = document.getElementById('input-hotmail-password');
const inputHotmailRefreshToken = document.getElementById('input-hotmail-refresh-token');
const inputHotmailImport = document.getElementById('input-hotmail-import');
const btnAddHotmailAccount = document.getElementById('btn-add-hotmail-account');
const btnImportHotmailAccounts = document.getElementById('btn-import-hotmail-accounts');
const btnClearUsedHotmailAccounts = document.getElementById('btn-clear-used-hotmail-accounts');
const btnDeleteAllHotmailAccounts = document.getElementById('btn-delete-all-hotmail-accounts');
const btnToggleHotmailList = document.getElementById('btn-toggle-hotmail-list');
const hotmailListShell = document.getElementById('hotmail-list-shell');
const hotmailAccountsList = document.getElementById('hotmail-accounts-list');
const rowInbucketHost = document.getElementById('row-inbucket-host');
const inputInbucketHost = document.getElementById('input-inbucket-host');
const rowInbucketMailbox = document.getElementById('row-inbucket-mailbox');
@@ -44,6 +57,7 @@ const btnAutoStartClose = document.getElementById('btn-auto-start-close');
const btnAutoStartCancel = document.getElementById('btn-auto-start-cancel');
const btnAutoStartRestart = document.getElementById('btn-auto-start-restart');
const btnAutoStartContinue = document.getElementById('btn-auto-start-continue');
const autoHintText = document.querySelector('.auto-hint');
const STEP_DEFAULT_STATUSES = {
1: 'pending',
2: 'pending',
@@ -70,9 +84,19 @@ let settingsSaveInFlight = false;
let settingsAutoSaveTimer = null;
let modalChoiceResolver = null;
let currentModalActions = [];
let hotmailActionInFlight = false;
let hotmailListExpanded = false;
const EYE_OPEN_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z"/><circle cx="12" cy="12" r="3"/></svg>';
const EYE_CLOSED_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17.94 17.94A10.94 10.94 0 0 1 12 19C5 19 1 12 1 12a21.77 21.77 0 0 1 5.06-6.94"/><path d="M9.9 4.24A10.94 10.94 0 0 1 12 5c7 0 11 7 11 7a21.86 21.86 0 0 1-2.16 3.19"/><path d="M1 1l22 22"/><path d="M14.12 14.12a3 3 0 1 1-4.24-4.24"/></svg>';
const COPY_ICON = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
const parseHotmailImportText = window.HotmailUtils?.parseHotmailImportText;
const shouldClearHotmailCurrentSelection = window.HotmailUtils?.shouldClearHotmailCurrentSelection;
const upsertHotmailAccountInList = window.HotmailUtils?.upsertHotmailAccountInList;
const filterHotmailAccountsByUsage = window.HotmailUtils?.filterHotmailAccountsByUsage;
const getHotmailBulkActionLabel = window.HotmailUtils?.getHotmailBulkActionLabel;
const getHotmailListToggleLabel = window.HotmailUtils?.getHotmailListToggleLabel;
const HOTMAIL_LIST_EXPANDED_STORAGE_KEY = 'multipage-hotmail-list-expanded';
// ============================================================
// Toast Notifications
@@ -479,10 +503,241 @@ function syncPasswordField(state) {
inputPassword.value = state.customPassword || state.password || '';
}
function getHotmailAccounts(state = latestState) {
return Array.isArray(state?.hotmailAccounts) ? state.hotmailAccounts : [];
}
function getCurrentHotmailAccount(state = latestState) {
const currentId = state?.currentHotmailAccountId;
return getHotmailAccounts(state).find((account) => account.id === currentId) || null;
}
function getHotmailAccountsByUsage(mode = 'all', state = latestState) {
const accounts = getHotmailAccounts(state);
if (typeof filterHotmailAccountsByUsage === 'function') {
return filterHotmailAccountsByUsage(accounts, mode);
}
if (mode === 'used') {
return accounts.filter((account) => Boolean(account?.used));
}
return accounts.slice();
}
function getHotmailBulkActionText(mode, count) {
if (typeof getHotmailBulkActionLabel === 'function') {
return getHotmailBulkActionLabel(mode, count);
}
const normalizedCount = Number.isFinite(Number(count)) ? Math.max(0, Number(count)) : 0;
const prefix = mode === 'used' ? '清空已用' : '全部删除';
const suffix = normalizedCount > 0 ? `${normalizedCount}` : '';
return `${prefix}${suffix}`;
}
function getHotmailListToggleText(expanded, count) {
if (typeof getHotmailListToggleLabel === 'function') {
return getHotmailListToggleLabel(expanded, count);
}
const normalizedCount = Number.isFinite(Number(count)) ? Math.max(0, Number(count)) : 0;
const suffix = normalizedCount > 0 ? `${normalizedCount}` : '';
return `${expanded ? '收起列表' : '展开列表'}${suffix}`;
}
function updateHotmailListViewport() {
const count = getHotmailAccounts().length;
const usedCount = getHotmailAccountsByUsage('used').length;
if (btnClearUsedHotmailAccounts) {
btnClearUsedHotmailAccounts.textContent = getHotmailBulkActionText('used', usedCount);
btnClearUsedHotmailAccounts.disabled = usedCount === 0;
}
if (btnDeleteAllHotmailAccounts) {
btnDeleteAllHotmailAccounts.textContent = getHotmailBulkActionText('all', count);
btnDeleteAllHotmailAccounts.disabled = count === 0;
}
if (btnToggleHotmailList) {
btnToggleHotmailList.textContent = getHotmailListToggleText(hotmailListExpanded, count);
btnToggleHotmailList.setAttribute('aria-expanded', String(hotmailListExpanded));
btnToggleHotmailList.disabled = count === 0;
}
if (hotmailListShell) {
hotmailListShell.classList.toggle('is-expanded', hotmailListExpanded);
hotmailListShell.classList.toggle('is-collapsed', !hotmailListExpanded);
}
}
function setHotmailListExpanded(expanded, options = {}) {
const { persist = true } = options;
hotmailListExpanded = Boolean(expanded);
updateHotmailListViewport();
if (persist) {
localStorage.setItem(HOTMAIL_LIST_EXPANDED_STORAGE_KEY, hotmailListExpanded ? '1' : '0');
}
}
function initHotmailListExpandedState() {
const saved = localStorage.getItem(HOTMAIL_LIST_EXPANDED_STORAGE_KEY);
setHotmailListExpanded(saved === '1', { persist: false });
}
function shouldClearCurrentHotmailSelectionLocally(account) {
if (typeof shouldClearHotmailCurrentSelection === 'function') {
return shouldClearHotmailCurrentSelection(account);
}
return Boolean(account) && account.used === true;
}
function upsertHotmailAccountListLocally(accounts, nextAccount) {
if (typeof upsertHotmailAccountInList === 'function') {
return upsertHotmailAccountInList(accounts, nextAccount);
}
const list = Array.isArray(accounts) ? accounts.slice() : [];
if (!nextAccount?.id) return list;
const existingIndex = list.findIndex((account) => account?.id === nextAccount.id);
if (existingIndex === -1) {
list.push(nextAccount);
return list;
}
list[existingIndex] = nextAccount;
return list;
}
function refreshHotmailSelectionUI() {
renderHotmailAccounts();
if (selectMailProvider.value === 'hotmail-api') {
const currentAccount = getCurrentHotmailAccount();
inputEmail.value = currentAccount?.email || latestState?.email || '';
}
}
function applyHotmailAccountMutation(account, options = {}) {
if (!account?.id) return;
const { preserveCurrentSelection = false } = options;
const nextState = {
hotmailAccounts: upsertHotmailAccountListLocally(getHotmailAccounts(), account),
};
if (!preserveCurrentSelection
&& latestState?.currentHotmailAccountId === account.id
&& shouldClearCurrentHotmailSelectionLocally(account)) {
nextState.currentHotmailAccountId = null;
if (selectMailProvider.value === 'hotmail-api') {
nextState.email = null;
}
}
syncLatestState(nextState);
refreshHotmailSelectionUI();
}
function formatDateTime(timestamp) {
const value = Number(timestamp);
if (!Number.isFinite(value) || value <= 0) {
return '未使用';
}
return new Date(value).toLocaleString('zh-CN', { hour12: false });
}
function getHotmailAvailabilityLabel(account) {
if (account.used) return '已用';
return '可分配';
}
function getHotmailStatusLabel(account) {
if (account.used) return '已用';
switch (account.status) {
case 'authorized':
return '可用';
case 'error':
return '异常';
default:
return '待校验';
}
}
function getHotmailStatusClass(account) {
if (account.used) return 'status-used';
return `status-${account.status || 'pending'}`;
}
function clearHotmailForm() {
inputHotmailEmail.value = '';
inputHotmailClientId.value = '';
inputHotmailPassword.value = '';
inputHotmailRefreshToken.value = '';
}
function renderHotmailAccounts() {
if (!hotmailAccountsList) return;
const accounts = getHotmailAccounts();
const currentId = latestState?.currentHotmailAccountId || '';
if (!accounts.length) {
hotmailAccountsList.innerHTML = '<div class="hotmail-empty">还没有 Hotmail 账号,先添加一条再校验。</div>';
updateHotmailListViewport();
return;
}
hotmailAccountsList.innerHTML = accounts.map((account) => `
<div class="hotmail-account-item${account.id === currentId ? ' is-current' : ''}">
<div class="hotmail-account-top">
<div class="hotmail-account-title-row">
<div class="hotmail-account-email">${escapeHtml(account.email || '(未命名账号)')}</div>
<button
class="hotmail-copy-btn"
type="button"
data-account-action="copy-email"
data-account-id="${escapeHtml(account.id)}"
title="复制邮箱"
aria-label="复制邮箱 ${escapeHtml(account.email || '')}"
>${COPY_ICON}</button>
</div>
<span class="hotmail-status-chip ${escapeHtml(getHotmailStatusClass(account))}">${escapeHtml(getHotmailStatusLabel(account))}</span>
</div>
<div class="hotmail-account-meta">
<span>Client ID: ${escapeHtml(account.clientId ? `${account.clientId.slice(0, 10)}...` : '未填写')}</span>
<span>Refresh: ${account.refreshToken ? '已保存' : '未保存'}</span>
<span>分配状态: ${escapeHtml(getHotmailAvailabilityLabel(account))}</span>
<span>上次校验: ${escapeHtml(formatDateTime(account.lastAuthAt))}</span>
<span>上次使用: ${escapeHtml(formatDateTime(account.lastUsedAt))}</span>
</div>
${account.lastError ? `<div class="hotmail-account-error">${escapeHtml(account.lastError)}</div>` : ''}
<div class="hotmail-account-actions">
<button class="btn btn-outline btn-sm" type="button" data-account-action="select" data-account-id="${escapeHtml(account.id)}">使用此账号</button>
<button class="btn btn-outline btn-sm" type="button" data-account-action="toggle-used" data-account-id="${escapeHtml(account.id)}">${account.used ? '标记未用' : '标记已用'}</button>
<button class="btn btn-primary btn-sm" type="button" data-account-action="verify" data-account-id="${escapeHtml(account.id)}">校验</button>
<button class="btn btn-outline btn-sm" type="button" data-account-action="test" data-account-id="${escapeHtml(account.id)}">复制最新验证码</button>
<button class="btn btn-ghost btn-sm" type="button" data-account-action="delete" data-account-id="${escapeHtml(account.id)}">删除</button>
</div>
</div>
`).join('');
updateHotmailListViewport();
}
function updateMailProviderUI() {
const useInbucket = selectMailProvider.value === 'inbucket';
const useHotmail = selectMailProvider.value === 'hotmail-api';
rowInbucketHost.style.display = useInbucket ? '' : 'none';
rowInbucketMailbox.style.display = useInbucket ? '' : 'none';
if (hotmailSection) {
hotmailSection.style.display = useHotmail ? '' : 'none';
}
btnFetchEmail.hidden = useHotmail;
inputEmail.readOnly = useHotmail;
inputEmail.placeholder = useHotmail ? '由 Hotmail 账号池自动分配' : '粘贴 DuckDuckGo 邮箱';
if (autoHintText) {
autoHintText.textContent = useHotmail
? '请先校验并选择一个 Hotmail 账号'
: '先自动获取 Duck 邮箱,或手动粘贴邮箱后再继续';
}
if (useHotmail) {
const currentAccount = getCurrentHotmailAccount();
inputEmail.value = currentAccount?.email || latestState?.email || '';
}
renderHotmailAccounts();
}
// ============================================================
@@ -690,6 +945,70 @@ function syncToggleButtonLabel(button, input, labels) {
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() {
syncToggleButtonLabel(btnTogglePassword, inputPassword, {
show: '显示密码',
@@ -771,18 +1090,25 @@ document.querySelectorAll('.step-btn').forEach(btn => {
});
syncLatestState({ customPassword: inputPassword.value });
}
let email = inputEmail.value.trim();
if (!email) {
try {
email = await fetchDuckEmail({ showFailureToast: false });
} catch (err) {
showToast(`自动获取失败:${err.message},请手动粘贴邮箱后重试。`, 'warn');
return;
if (selectMailProvider.value === 'hotmail-api') {
const response = await chrome.runtime.sendMessage({ type: 'EXECUTE_STEP', source: 'sidepanel', payload: { step } });
if (response?.error) {
throw new Error(response.error);
}
} else {
let email = inputEmail.value.trim();
if (!email) {
try {
email = await fetchDuckEmail({ showFailureToast: false });
} catch (err) {
showToast(`自动获取失败:${err.message},请手动粘贴邮箱后重试。`, 'warn');
return;
}
}
const response = await chrome.runtime.sendMessage({ type: 'EXECUTE_STEP', source: 'sidepanel', payload: { step, email } });
if (response?.error) {
throw new Error(response.error);
}
}
const response = await chrome.runtime.sendMessage({ type: 'EXECUTE_STEP', source: 'sidepanel', payload: { step, email } });
if (response?.error) {
throw new Error(response.error);
}
} else {
const response = await chrome.runtime.sendMessage({ type: 'EXECUTE_STEP', source: 'sidepanel', payload: { step } });
@@ -797,9 +1123,234 @@ document.querySelectorAll('.step-btn').forEach(btn => {
});
btnFetchEmail.addEventListener('click', async () => {
if (selectMailProvider.value === 'hotmail-api') {
return;
}
await fetchDuckEmail().catch(() => {});
});
btnToggleHotmailList?.addEventListener('click', () => {
setHotmailListExpanded(!hotmailListExpanded);
});
btnClearUsedHotmailAccounts?.addEventListener('click', async () => {
if (hotmailActionInFlight) return;
hotmailActionInFlight = true;
btnClearUsedHotmailAccounts.disabled = true;
try {
await deleteHotmailAccountsByMode('used');
} catch (err) {
showToast(err.message, 'error');
} finally {
hotmailActionInFlight = false;
updateHotmailListViewport();
}
});
btnDeleteAllHotmailAccounts?.addEventListener('click', async () => {
if (hotmailActionInFlight) return;
hotmailActionInFlight = true;
btnDeleteAllHotmailAccounts.disabled = true;
try {
await deleteHotmailAccountsByMode('all');
} catch (err) {
showToast(err.message, 'error');
} finally {
hotmailActionInFlight = false;
updateHotmailListViewport();
}
});
btnAddHotmailAccount?.addEventListener('click', async () => {
if (hotmailActionInFlight) return;
const email = inputHotmailEmail.value.trim();
const clientId = inputHotmailClientId.value.trim();
const refreshToken = inputHotmailRefreshToken.value.trim();
if (!email) {
showToast('请先填写 Hotmail 邮箱。', 'warn');
return;
}
if (!clientId) {
showToast('请先填写 Microsoft client ID。', 'warn');
return;
}
if (!refreshToken) {
showToast('请先填写 refresh token。', 'warn');
return;
}
hotmailActionInFlight = true;
btnAddHotmailAccount.disabled = true;
try {
const response = await chrome.runtime.sendMessage({
type: 'UPSERT_HOTMAIL_ACCOUNT',
source: 'sidepanel',
payload: {
email,
clientId,
password: inputHotmailPassword.value,
refreshToken,
},
});
if (response?.error) {
throw new Error(response.error);
}
showToast(`已保存 Hotmail 账号 ${email}`, 'success', 1800);
clearHotmailForm();
} catch (err) {
showToast(`保存 Hotmail 账号失败:${err.message}`, 'error');
} finally {
hotmailActionInFlight = false;
btnAddHotmailAccount.disabled = false;
}
});
btnImportHotmailAccounts?.addEventListener('click', async () => {
if (hotmailActionInFlight) return;
if (typeof parseHotmailImportText !== 'function') {
showToast('导入解析器未加载,请刷新扩展后重试。', 'error');
return;
}
const rawText = inputHotmailImport.value.trim();
if (!rawText) {
showToast('请先粘贴账号导入内容。', 'warn');
return;
}
const parsedAccounts = parseHotmailImportText(rawText);
if (!parsedAccounts.length) {
showToast('没有解析到有效账号,请检查格式是否为 账号----密码----ID----Token。', 'error');
return;
}
hotmailActionInFlight = true;
btnImportHotmailAccounts.disabled = true;
try {
for (const account of parsedAccounts) {
const response = await chrome.runtime.sendMessage({
type: 'UPSERT_HOTMAIL_ACCOUNT',
source: 'sidepanel',
payload: account,
});
if (response?.error) {
throw new Error(response.error);
}
}
inputHotmailImport.value = '';
showToast(`已导入 ${parsedAccounts.length} 条 Hotmail 账号`, 'success', 2200);
} catch (err) {
showToast(`批量导入失败:${err.message}`, 'error');
} finally {
hotmailActionInFlight = false;
btnImportHotmailAccounts.disabled = false;
}
});
hotmailAccountsList?.addEventListener('click', async (event) => {
const actionButton = event.target.closest('[data-account-action]');
if (!actionButton || hotmailActionInFlight) {
return;
}
const accountId = actionButton.dataset.accountId;
const action = actionButton.dataset.accountAction;
if (!accountId || !action) {
return;
}
const targetAccount = getHotmailAccounts().find((account) => account.id === accountId) || null;
hotmailActionInFlight = true;
actionButton.disabled = true;
try {
if (action === 'copy-email') {
if (!targetAccount?.email) throw new Error('未找到可复制的邮箱地址。');
await copyTextToClipboard(targetAccount.email);
showToast(`已复制 ${targetAccount.email}`, 'success', 1800);
} else if (action === 'select') {
const response = await chrome.runtime.sendMessage({
type: 'SELECT_HOTMAIL_ACCOUNT',
source: 'sidepanel',
payload: { accountId },
});
if (response?.error) throw new Error(response.error);
syncLatestState({ currentHotmailAccountId: response.account.id });
applyHotmailAccountMutation(response.account, { preserveCurrentSelection: true });
showToast(`已切换当前 Hotmail 账号为 ${response.account.email}`, 'success', 1800);
} else if (action === 'toggle-used') {
if (!targetAccount) throw new Error('未找到目标 Hotmail 账号。');
const response = await chrome.runtime.sendMessage({
type: 'PATCH_HOTMAIL_ACCOUNT',
source: 'sidepanel',
payload: {
accountId,
updates: { used: !targetAccount.used },
},
});
if (response?.error) throw new Error(response.error);
applyHotmailAccountMutation(response.account);
showToast(`账号 ${response.account.email}${response.account.used ? '标记为已用' : '恢复为未用'}`, 'success', 2200);
} else if (action === 'verify') {
const response = await chrome.runtime.sendMessage({
type: 'VERIFY_HOTMAIL_ACCOUNT',
source: 'sidepanel',
payload: { accountId },
});
if (response?.error) throw new Error(response.error);
applyHotmailAccountMutation(response.account, { preserveCurrentSelection: true });
showToast(`账号 ${response.account.email} 校验通过`, 'success', 2200);
} else if (action === 'test') {
const response = await chrome.runtime.sendMessage({
type: 'TEST_HOTMAIL_ACCOUNT',
source: 'sidepanel',
payload: { accountId },
});
if (response?.error) throw new Error(response.error);
applyHotmailAccountMutation(response.account, { preserveCurrentSelection: true });
if (response.latestCode) {
await copyTextToClipboard(response.latestCode);
const mailbox = response.latestMailbox ? `${response.latestMailbox}` : '';
showToast(`已复制最新验证码 ${response.latestCode}${mailbox}`, 'success', 2600);
} else if (response.latestSubject) {
const mailbox = response.latestMailbox ? `${response.latestMailbox}` : '';
showToast(`最新邮件${mailbox}没有验证码:${response.latestSubject}`, 'warn', 3200);
} else {
showToast('当前没有可读取的最新邮件。', 'warn', 2600);
}
} else if (action === 'delete') {
const confirmed = await openConfirmModal({
title: '删除账号',
message: '确认删除这个 Hotmail 账号吗?对应 token 也会一起移除。',
confirmLabel: '确认删除',
confirmVariant: 'btn-danger',
});
if (!confirmed) {
return;
}
const response = await chrome.runtime.sendMessage({
type: 'DELETE_HOTMAIL_ACCOUNT',
source: 'sidepanel',
payload: { accountId },
});
if (response?.error) throw new Error(response.error);
showToast('Hotmail 账号已删除', 'success', 1800);
}
} catch (err) {
showToast(err.message, 'error');
} finally {
hotmailActionInFlight = false;
actionButton.disabled = false;
}
});
btnTogglePassword.addEventListener('click', () => {
inputPassword.type = inputPassword.type === 'password' ? 'text' : 'password';
syncPasswordToggleLabel();
@@ -891,7 +1442,7 @@ btnReset.addEventListener('click', async () => {
}
await chrome.runtime.sendMessage({ type: 'RESET', source: 'sidepanel' });
syncLatestState({ stepStatuses: STEP_DEFAULT_STATUSES });
syncLatestState({ stepStatuses: STEP_DEFAULT_STATUSES, currentHotmailAccountId: null, email: null });
syncAutoRunState({ autoRunning: false, autoRunPhase: 'idle', autoRunCurrentRun: 0, autoRunTotalRuns: 1, autoRunAttemptRun: 0 });
displayOauthUrl.textContent = '等待中...';
displayOauthUrl.classList.remove('has-value');
@@ -909,6 +1460,7 @@ btnReset.addEventListener('click', async () => {
updateStopButtonState(false);
updateButtonStates();
updateProgressCounter();
renderHotmailAccounts();
});
// Clear log
@@ -918,6 +1470,9 @@ btnClearLog.addEventListener('click', () => {
// Save settings on change
inputEmail.addEventListener('change', async () => {
if (selectMailProvider.value === 'hotmail-api') {
return;
}
const email = inputEmail.value.trim();
if (email) {
await chrome.runtime.sendMessage({ type: 'SAVE_EMAIL', source: 'sidepanel', payload: { email } });
@@ -1036,24 +1591,32 @@ chrome.runtime.onMessage.addListener((message) => {
applyAutoRunStatus(currentAutoRun);
updateProgressCounter();
updateButtonStates();
renderHotmailAccounts();
break;
}
case 'DATA_UPDATED': {
syncLatestState(message.payload);
if (message.payload.email) {
inputEmail.value = message.payload.email;
if (message.payload.email !== undefined) {
inputEmail.value = message.payload.email || '';
}
if (message.payload.password !== undefined) {
inputPassword.value = message.payload.password || '';
}
if (message.payload.oauthUrl) {
displayOauthUrl.textContent = message.payload.oauthUrl;
displayOauthUrl.classList.add('has-value');
if (message.payload.oauthUrl !== undefined) {
displayOauthUrl.textContent = message.payload.oauthUrl || '等待中...';
displayOauthUrl.classList.toggle('has-value', Boolean(message.payload.oauthUrl));
}
if (message.payload.localhostUrl) {
displayLocalhostUrl.textContent = message.payload.localhostUrl;
displayLocalhostUrl.classList.add('has-value');
if (message.payload.localhostUrl !== undefined) {
displayLocalhostUrl.textContent = message.payload.localhostUrl || '等待中...';
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 || '';
}
}
break;
}
@@ -1105,6 +1668,7 @@ btnTheme.addEventListener('click', () => {
initializeManualStepActions();
initTheme();
initHotmailListExpandedState();
updateSaveButtonState();
restoreState().then(() => {
syncPasswordToggleLabel();
+83
View File
@@ -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
);
});
+485
View File
@@ -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',
},
]);
});