Merge PR #10: add Hotmail Graph account pool flow
This commit is contained in:
@@ -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
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,
|
||||
};
|
||||
});
|
||||
@@ -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
@@ -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) || ''}"`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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`.
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
.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;
|
||||
|
||||
@@ -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 Graph(refresh_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 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
@@ -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();
|
||||
|
||||
@@ -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