feat: add hotmail mail api 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 协助获取验证码。
|
||||
|
||||
## 当前能力
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
- 支持自定义密码;留空时自动生成强密码
|
||||
- 自动显示当前使用中的密码,便于后续保存
|
||||
- 自动获取注册验证码与登录验证码
|
||||
- 支持 `Hotmail API`:直接使用 `email + client_id + refresh_token` 请求第三方接口读取最新邮件
|
||||
- 支持 `QQ Mail`、`163 Mail`、`Inbucket mailbox`
|
||||
- 支持从 DuckDuckGo Email Protection 自动生成新的 `@duck.com` 地址
|
||||
- Step 5 同时兼容两种页面:
|
||||
@@ -54,17 +55,37 @@ Step 1 和 Step 9 都依赖这个地址。
|
||||
|
||||
### `Mail`
|
||||
|
||||
支持三种验证码来源:
|
||||
支持四种验证码来源:
|
||||
|
||||
- `Hotmail API`
|
||||
- `163 Mail`
|
||||
- `QQ Mail`
|
||||
- `Inbucket`
|
||||
|
||||
说明:
|
||||
|
||||
- `Hotmail API` 通过侧边栏里的 Hotmail 账号池选择账号,并请求 `https://apple.882263.xyz/api/mail-new`
|
||||
- `QQ` 和 `163` 用于直接轮询网页邮箱
|
||||
- `Inbucket` 通过你在侧边栏里配置的 host 访问 `mailbox` 页面:`https://<your-inbucket-host>/m/<mailbox>/`
|
||||
|
||||
### `Hotmail 账号池`
|
||||
|
||||
仅当 `Mail = Hotmail API` 时使用。
|
||||
|
||||
每条账号支持保存:
|
||||
|
||||
- `email`
|
||||
- `clientId`
|
||||
- `refreshToken`
|
||||
- 可选邮箱密码备注
|
||||
|
||||
使用方式:
|
||||
|
||||
- 先新增账号
|
||||
- 点击 `校验`
|
||||
- 校验通过后,可点击 `测试收信`
|
||||
- Auto 模式每轮会自动选用一个可用账号
|
||||
|
||||
### `Mailbox`
|
||||
|
||||
仅当 `Mail = Inbucket` 时显示。
|
||||
@@ -109,6 +130,7 @@ Step 3 使用的注册邮箱。
|
||||
|
||||
注意:
|
||||
|
||||
- 当 `Mail = Hotmail API` 时,这个输入框由账号池自动同步当前账号邮箱
|
||||
- 当前 `Auto` 按钮只负责 DuckDuckGo 地址获取
|
||||
- 如果你使用 Inbucket,它只是验证码收件箱,不会自动生成 Inbucket 地址
|
||||
|
||||
@@ -156,9 +178,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
|
||||
|
||||
也就是说:
|
||||
|
||||
@@ -207,6 +230,7 @@ Step 3 使用的注册邮箱。
|
||||
|
||||
支持:
|
||||
|
||||
- `Hotmail API`(第三方最新邮件接口)
|
||||
- `content/qq-mail.js`
|
||||
- `content/mail-163.js`
|
||||
- `content/inbucket-mail.js`
|
||||
@@ -318,6 +342,7 @@ Step 3 使用的注册邮箱。
|
||||
- 邮箱服务
|
||||
- Inbucket 主机
|
||||
- Inbucket 邮箱名
|
||||
- Hotmail 账号池与对应 token
|
||||
- 兜底开关
|
||||
|
||||
特点:
|
||||
@@ -337,6 +362,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 邮箱验证码轮询
|
||||
|
||||
+650
-50
@@ -1,9 +1,30 @@
|
||||
// background.js — Service Worker: orchestration, state, tab management, message routing
|
||||
|
||||
importScripts('data/names.js');
|
||||
importScripts('data/names.js', 'hotmail-utils.js', 'content/activation-utils.js');
|
||||
|
||||
const {
|
||||
buildHotmailMailApiLatestUrl,
|
||||
extractVerificationCodeFromMessage,
|
||||
filterHotmailAccountsByUsage,
|
||||
getLatestHotmailMessage,
|
||||
getHotmailMailApiRequestConfig,
|
||||
getHotmailVerificationPollConfig,
|
||||
getHotmailVerificationRequestTimestamp,
|
||||
normalizeHotmailMailApiMessages,
|
||||
pickHotmailAccountForRun,
|
||||
pickVerificationMessage,
|
||||
pickVerificationMessageWithFallback,
|
||||
pickVerificationMessageWithTimeFallback,
|
||||
shouldClearHotmailCurrentSelection,
|
||||
} = self.HotmailUtils;
|
||||
const {
|
||||
isRecoverableStep9AuthFailure,
|
||||
} = self.MultiPageActivationUtils;
|
||||
|
||||
const LOG_PREFIX = '[MultiPage:bg]';
|
||||
const DUCK_AUTOFILL_URL = 'https://duckduckgo.com/email/settings/autofill';
|
||||
const HOTMAIL_PROVIDER = 'hotmail-api';
|
||||
const HOTMAIL_MAILBOXES = ['INBOX', 'Junk'];
|
||||
const STOP_ERROR_MESSAGE = '流程已被用户停止。';
|
||||
const HUMAN_STEP_DELAY_MIN = 700;
|
||||
const HUMAN_STEP_DELAY_MAX = 2200;
|
||||
@@ -23,6 +44,7 @@ const PERSISTED_SETTING_DEFAULTS = {
|
||||
mailProvider: '163',
|
||||
inbucketHost: '',
|
||||
inbucketMailbox: '',
|
||||
hotmailAccounts: [],
|
||||
};
|
||||
|
||||
const PERSISTED_SETTING_KEYS = Object.keys(PERSISTED_SETTING_DEFAULTS);
|
||||
@@ -38,10 +60,13 @@ const DEFAULT_STATE = {
|
||||
password: null,
|
||||
accounts: [], // { email, password, createdAt }
|
||||
lastEmailTimestamp: null,
|
||||
signupVerificationRequestedAt: null,
|
||||
loginVerificationRequestedAt: null,
|
||||
lastSignupCode: null,
|
||||
lastLoginCode: null,
|
||||
localhostUrl: null,
|
||||
flowStartTime: null,
|
||||
currentHotmailAccountId: null,
|
||||
tabRegistry: {},
|
||||
sourceLastUrls: {},
|
||||
logs: [],
|
||||
@@ -59,6 +84,7 @@ async function getPersistedSettings() {
|
||||
...PERSISTED_SETTING_DEFAULTS,
|
||||
...stored,
|
||||
autoRunSkipFailures: Boolean(stored.autoRunSkipFailures ?? PERSISTED_SETTING_DEFAULTS.autoRunSkipFailures),
|
||||
hotmailAccounts: normalizeHotmailAccounts(stored.hotmailAccounts),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -92,9 +118,13 @@ async function setPersistentSettings(updates) {
|
||||
const persistedUpdates = {};
|
||||
for (const key of PERSISTED_SETTING_KEYS) {
|
||||
if (updates[key] !== undefined) {
|
||||
persistedUpdates[key] = key === 'autoRunSkipFailures'
|
||||
? Boolean(updates[key])
|
||||
: updates[key];
|
||||
if (key === 'autoRunSkipFailures') {
|
||||
persistedUpdates[key] = Boolean(updates[key]);
|
||||
} else if (key === 'hotmailAccounts') {
|
||||
persistedUpdates[key] = normalizeHotmailAccounts(updates[key]);
|
||||
} else {
|
||||
persistedUpdates[key] = updates[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,6 +204,408 @@ function generatePassword() {
|
||||
return pw.split('').sort(() => Math.random() - 0.5).join('');
|
||||
}
|
||||
|
||||
function normalizeHotmailAccount(account = {}) {
|
||||
return {
|
||||
id: String(account.id || crypto.randomUUID()),
|
||||
email: String(account.email || '').trim(),
|
||||
password: String(account.password || ''),
|
||||
clientId: String(account.clientId || '').trim(),
|
||||
accessToken: String(account.accessToken || ''),
|
||||
refreshToken: String(account.refreshToken || ''),
|
||||
expiresAt: Number.isFinite(Number(account.expiresAt)) ? Number(account.expiresAt) : 0,
|
||||
status: String(account.status || (account.refreshToken ? 'authorized' : 'pending')),
|
||||
enabled: account.enabled !== undefined ? Boolean(account.enabled) : true,
|
||||
used: Boolean(account.used),
|
||||
lastUsedAt: Number.isFinite(Number(account.lastUsedAt)) ? Number(account.lastUsedAt) : 0,
|
||||
lastAuthAt: Number.isFinite(Number(account.lastAuthAt)) ? Number(account.lastAuthAt) : 0,
|
||||
lastError: String(account.lastError || ''),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeHotmailAccounts(accounts) {
|
||||
if (!Array.isArray(accounts)) return [];
|
||||
|
||||
const deduped = new Map();
|
||||
for (const account of accounts) {
|
||||
const normalized = normalizeHotmailAccount(account);
|
||||
if (!normalized.email && !normalized.id) continue;
|
||||
deduped.set(normalized.id, normalized);
|
||||
}
|
||||
return [...deduped.values()];
|
||||
}
|
||||
|
||||
function findHotmailAccount(accounts, accountId) {
|
||||
return normalizeHotmailAccounts(accounts).find((account) => account.id === accountId) || null;
|
||||
}
|
||||
|
||||
function isHotmailProvider(stateOrProvider) {
|
||||
const provider = typeof stateOrProvider === 'string'
|
||||
? stateOrProvider
|
||||
: stateOrProvider?.mailProvider;
|
||||
return provider === HOTMAIL_PROVIDER;
|
||||
}
|
||||
|
||||
async function syncHotmailAccounts(accounts) {
|
||||
const normalized = normalizeHotmailAccounts(accounts);
|
||||
await setPersistentSettings({ hotmailAccounts: normalized });
|
||||
await setState({ hotmailAccounts: normalized });
|
||||
broadcastDataUpdate({ hotmailAccounts: normalized });
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async function upsertHotmailAccount(input) {
|
||||
const state = await getState();
|
||||
const accounts = normalizeHotmailAccounts(state.hotmailAccounts);
|
||||
const normalizedEmail = String(input?.email || '').trim().toLowerCase();
|
||||
const existing = input?.id
|
||||
? findHotmailAccount(accounts, input.id)
|
||||
: accounts.find((account) => account.email.toLowerCase() === normalizedEmail) || null;
|
||||
const normalized = normalizeHotmailAccount({
|
||||
...(existing || {}),
|
||||
...input,
|
||||
id: input?.id || existing?.id || crypto.randomUUID(),
|
||||
});
|
||||
|
||||
const nextAccounts = existing
|
||||
? accounts.map((account) => (account.id === normalized.id ? normalized : account))
|
||||
: [...accounts, normalized];
|
||||
|
||||
await syncHotmailAccounts(nextAccounts);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async function deleteHotmailAccount(accountId) {
|
||||
const state = await getState();
|
||||
const accounts = normalizeHotmailAccounts(state.hotmailAccounts);
|
||||
const nextAccounts = accounts.filter((account) => account.id !== accountId);
|
||||
await syncHotmailAccounts(nextAccounts);
|
||||
|
||||
if (state.currentHotmailAccountId === accountId) {
|
||||
await setState({ currentHotmailAccountId: null });
|
||||
if (isHotmailProvider(state)) {
|
||||
await setEmailState(null);
|
||||
}
|
||||
broadcastDataUpdate({ currentHotmailAccountId: null });
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteHotmailAccounts(mode = 'all') {
|
||||
const state = await getState();
|
||||
const accounts = normalizeHotmailAccounts(state.hotmailAccounts);
|
||||
const targets = filterHotmailAccountsByUsage(accounts, mode);
|
||||
const targetIds = new Set(targets.map((account) => account.id));
|
||||
const nextAccounts = mode === 'used'
|
||||
? accounts.filter((account) => !targetIds.has(account.id))
|
||||
: [];
|
||||
|
||||
await syncHotmailAccounts(nextAccounts);
|
||||
|
||||
if (state.currentHotmailAccountId && targetIds.has(state.currentHotmailAccountId)) {
|
||||
await setState({ currentHotmailAccountId: null });
|
||||
if (isHotmailProvider(state)) {
|
||||
await setEmailState(null);
|
||||
}
|
||||
broadcastDataUpdate({ currentHotmailAccountId: null });
|
||||
}
|
||||
|
||||
return {
|
||||
deletedCount: targets.length,
|
||||
remainingCount: nextAccounts.length,
|
||||
};
|
||||
}
|
||||
|
||||
async function patchHotmailAccount(accountId, updates = {}) {
|
||||
const state = await getState();
|
||||
const accounts = normalizeHotmailAccounts(state.hotmailAccounts);
|
||||
const account = findHotmailAccount(accounts, accountId);
|
||||
if (!account) {
|
||||
throw new Error('未找到对应的 Hotmail 账号。');
|
||||
}
|
||||
|
||||
const nextAccount = normalizeHotmailAccount({
|
||||
...account,
|
||||
...updates,
|
||||
id: account.id,
|
||||
});
|
||||
|
||||
await syncHotmailAccounts(accounts.map((item) => (item.id === account.id ? nextAccount : item)));
|
||||
|
||||
if (state.currentHotmailAccountId === account.id && shouldClearHotmailCurrentSelection(nextAccount)) {
|
||||
await setState({ currentHotmailAccountId: null });
|
||||
broadcastDataUpdate({ currentHotmailAccountId: null });
|
||||
if (isHotmailProvider(state)) {
|
||||
await setEmailState(null);
|
||||
}
|
||||
}
|
||||
|
||||
return nextAccount;
|
||||
}
|
||||
|
||||
async function setCurrentHotmailAccount(accountId, options = {}) {
|
||||
const { markUsed = false, syncEmail = true } = options;
|
||||
const state = await getState();
|
||||
const accounts = normalizeHotmailAccounts(state.hotmailAccounts);
|
||||
const account = findHotmailAccount(accounts, accountId);
|
||||
if (!account) {
|
||||
throw new Error('未找到对应的 Hotmail 账号。');
|
||||
}
|
||||
|
||||
if (markUsed) {
|
||||
account.lastUsedAt = Date.now();
|
||||
await syncHotmailAccounts(accounts.map((item) => (item.id === account.id ? account : item)));
|
||||
}
|
||||
|
||||
await setState({ currentHotmailAccountId: account.id });
|
||||
broadcastDataUpdate({ currentHotmailAccountId: account.id });
|
||||
if (syncEmail) {
|
||||
await setEmailState(account.email || null);
|
||||
}
|
||||
return account;
|
||||
}
|
||||
|
||||
async function ensureHotmailAccountForFlow(options = {}) {
|
||||
const { allowAllocate = true, markUsed = false, preferredAccountId = null } = options;
|
||||
const state = await getState();
|
||||
const accounts = normalizeHotmailAccounts(state.hotmailAccounts);
|
||||
const isAccountAllocatable = (candidate) => Boolean(candidate)
|
||||
&& candidate.status === 'authorized'
|
||||
&& !candidate.used
|
||||
&& Boolean(candidate.refreshToken);
|
||||
|
||||
let account = null;
|
||||
if (preferredAccountId) {
|
||||
account = findHotmailAccount(accounts, preferredAccountId);
|
||||
}
|
||||
if (!account && state.currentHotmailAccountId) {
|
||||
account = findHotmailAccount(accounts, state.currentHotmailAccountId);
|
||||
}
|
||||
if ((!account || !isAccountAllocatable(account)) && allowAllocate) {
|
||||
account = pickHotmailAccountForRun(accounts, {});
|
||||
}
|
||||
|
||||
if (!account) {
|
||||
throw new Error('没有可用的 Hotmail 账号。请先在侧边栏添加至少一个带 refresh token 的账号。');
|
||||
}
|
||||
if (!isAccountAllocatable(account)) {
|
||||
throw new Error(`Hotmail 账号 ${account.email || account.id} 尚未就绪,无法读取邮件。`);
|
||||
}
|
||||
|
||||
return setCurrentHotmailAccount(account.id, { markUsed, syncEmail: true });
|
||||
}
|
||||
|
||||
async function requestHotmailMailApi(account, mailbox = 'INBOX') {
|
||||
if (!account?.email) {
|
||||
throw new Error('Hotmail 账号缺少邮箱地址。');
|
||||
}
|
||||
if (!account?.clientId) {
|
||||
throw new Error(`Hotmail 账号 ${account.email || account.id} 缺少 client ID。`);
|
||||
}
|
||||
if (!account?.refreshToken) {
|
||||
throw new Error(`Hotmail 账号 ${account.email || account.id} 缺少 refresh token。`);
|
||||
}
|
||||
|
||||
const url = buildHotmailMailApiLatestUrl({
|
||||
clientId: account.clientId,
|
||||
email: account.email,
|
||||
refreshToken: account.refreshToken,
|
||||
mailbox,
|
||||
responseType: 'json',
|
||||
});
|
||||
const { timeoutMs } = getHotmailMailApiRequestConfig();
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(new Error('timeout')), timeoutMs);
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(url, { method: 'GET', signal: controller.signal });
|
||||
} catch (err) {
|
||||
if (err?.name === 'AbortError') {
|
||||
throw new Error(`Hotmail API 请求超时(>${Math.round(timeoutMs / 1000)} 秒):${mailbox}`);
|
||||
}
|
||||
throw new Error(`Hotmail API 请求失败:${err.message}`);
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
let payload = {};
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : {};
|
||||
} catch {
|
||||
payload = { raw: text };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = payload?.message || payload?.error || payload?.msg || text || `HTTP ${response.status}`;
|
||||
throw new Error(`Hotmail API 请求失败:${errorText}`);
|
||||
}
|
||||
|
||||
if (payload && payload.success === false) {
|
||||
const errorText = payload?.message || payload?.msg || payload?.error || '未知错误';
|
||||
throw new Error(`Hotmail API 返回失败:${errorText}`);
|
||||
}
|
||||
|
||||
return {
|
||||
mailbox,
|
||||
payload,
|
||||
messages: normalizeHotmailMailApiMessages(payload?.data),
|
||||
nextRefreshToken: String(payload?.new_refresh_token || payload?.newRefreshToken || '').trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function applyHotmailApiResultToAccount(account, apiResult) {
|
||||
const nextRefreshToken = String(apiResult?.nextRefreshToken || '').trim();
|
||||
return {
|
||||
...account,
|
||||
accessToken: '',
|
||||
expiresAt: 0,
|
||||
refreshToken: nextRefreshToken || account.refreshToken,
|
||||
status: 'authorized',
|
||||
lastAuthAt: Date.now(),
|
||||
lastError: '',
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchHotmailMailboxMessages(account, mailboxes = HOTMAIL_MAILBOXES) {
|
||||
let workingAccount = normalizeHotmailAccount(account);
|
||||
const mailboxResults = [];
|
||||
|
||||
for (const mailbox of mailboxes) {
|
||||
const result = await requestHotmailMailApi(workingAccount, mailbox);
|
||||
workingAccount = applyHotmailApiResultToAccount(workingAccount, result);
|
||||
mailboxResults.push({
|
||||
mailbox,
|
||||
count: result.messages.length,
|
||||
messages: result.messages.map((message) => ({ ...message, mailbox })),
|
||||
});
|
||||
}
|
||||
|
||||
const savedAccount = await upsertHotmailAccount(workingAccount);
|
||||
return {
|
||||
account: savedAccount,
|
||||
mailboxResults,
|
||||
messages: mailboxResults.flatMap((item) => item.messages),
|
||||
};
|
||||
}
|
||||
|
||||
async function verifyHotmailAccount(accountId) {
|
||||
const state = await getState();
|
||||
const account = findHotmailAccount(state.hotmailAccounts, accountId);
|
||||
if (!account) {
|
||||
throw new Error('未找到需要校验的 Hotmail 账号。');
|
||||
}
|
||||
|
||||
const result = await fetchHotmailMailboxMessages(account, ['INBOX']);
|
||||
return {
|
||||
account: result.account,
|
||||
messageCount: result.mailboxResults[0]?.count || 0,
|
||||
};
|
||||
}
|
||||
|
||||
async function testHotmailAccountMailAccess(accountId) {
|
||||
const state = await getState();
|
||||
const account = findHotmailAccount(state.hotmailAccounts, accountId);
|
||||
if (!account) {
|
||||
throw new Error('未找到需要测试的 Hotmail 账号。');
|
||||
}
|
||||
|
||||
const result = await fetchHotmailMailboxMessages(account, HOTMAIL_MAILBOXES);
|
||||
const latestMessage = getLatestHotmailMessage(result.messages);
|
||||
const latestCode = latestMessage ? extractVerificationCodeFromMessage(latestMessage) : null;
|
||||
|
||||
return {
|
||||
account: result.account,
|
||||
accountId: result.account.id,
|
||||
email: result.account.email,
|
||||
messageCount: result.messages.length,
|
||||
latestSubject: latestMessage?.subject || '',
|
||||
latestMailbox: latestMessage?.mailbox || '',
|
||||
latestCode: latestCode || '',
|
||||
inboxCount: result.mailboxResults.find((item) => item.mailbox === 'INBOX')?.count || 0,
|
||||
junkCount: result.mailboxResults.find((item) => item.mailbox === 'Junk')?.count || 0,
|
||||
};
|
||||
}
|
||||
|
||||
async function pollHotmailVerificationCode(step, state, pollPayload = {}) {
|
||||
await addLog(`步骤 ${step}:正在确定 Hotmail 收信账号...`, 'info');
|
||||
let account = await ensureHotmailAccountForFlow({
|
||||
allowAllocate: true,
|
||||
markUsed: false,
|
||||
preferredAccountId: state.currentHotmailAccountId || null,
|
||||
});
|
||||
await addLog(`步骤 ${step}:当前使用 Hotmail 账号 ${account.email} 轮询收件箱。`, 'info');
|
||||
|
||||
const maxAttempts = Number(pollPayload.maxAttempts) || 5;
|
||||
const intervalMs = Number(pollPayload.intervalMs) || 3000;
|
||||
let lastError = null;
|
||||
|
||||
function summarizeMessagesForLog(messages) {
|
||||
return (messages || [])
|
||||
.slice()
|
||||
.sort((left, right) => {
|
||||
const leftTime = Date.parse(left.receivedDateTime || '') || 0;
|
||||
const rightTime = Date.parse(right.receivedDateTime || '') || 0;
|
||||
return rightTime - leftTime;
|
||||
})
|
||||
.slice(0, 3)
|
||||
.map((message) => {
|
||||
const receivedAt = message?.receivedDateTime || 'unknown-time';
|
||||
const sender = message?.from?.emailAddress?.address || 'unknown';
|
||||
const subject = message?.subject || '(no subject)';
|
||||
const preview = String(message?.bodyPreview || '').replace(/\s+/g, ' ').trim().slice(0, 80);
|
||||
return `[${message.mailbox || 'INBOX'}] ${receivedAt} | ${sender} | ${subject} | ${preview}`;
|
||||
})
|
||||
.join(' || ');
|
||||
}
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
throwIfStopped();
|
||||
try {
|
||||
await addLog(`步骤 ${step}:正在轮询 Hotmail 邮件(${attempt}/${maxAttempts})...`, 'info');
|
||||
const fetchResult = await fetchHotmailMailboxMessages(account, HOTMAIL_MAILBOXES);
|
||||
account = fetchResult.account;
|
||||
const matchResult = pickVerificationMessageWithTimeFallback(fetchResult.messages, {
|
||||
afterTimestamp: pollPayload.filterAfterTimestamp || 0,
|
||||
senderFilters: pollPayload.senderFilters || [],
|
||||
subjectFilters: pollPayload.subjectFilters || [],
|
||||
excludeCodes: pollPayload.excludeCodes || [],
|
||||
});
|
||||
const match = matchResult.match;
|
||||
|
||||
if (match?.code) {
|
||||
const mailboxLabel = match.message?.mailbox || 'INBOX';
|
||||
if (matchResult.usedRelaxedFilters) {
|
||||
const fallbackLabel = matchResult.usedTimeFallback ? '宽松匹配 + 时间回退' : '宽松匹配';
|
||||
await addLog(`步骤 ${step}:严格规则未命中,已改用 ${fallbackLabel} 并命中 Hotmail ${mailboxLabel} 验证码。`, 'warn');
|
||||
}
|
||||
await addLog(`步骤 ${step}:已在 Hotmail ${mailboxLabel} 中找到验证码:${match.code}`, 'ok');
|
||||
return {
|
||||
ok: true,
|
||||
code: match.code,
|
||||
emailTimestamp: match.receivedAt || Date.now(),
|
||||
mailId: match.message?.id || '',
|
||||
};
|
||||
}
|
||||
|
||||
lastError = new Error(`步骤 ${step}:暂未在 Hotmail 收件箱中找到匹配验证码(${attempt}/${maxAttempts})。`);
|
||||
await addLog(lastError.message, attempt === maxAttempts ? 'warn' : 'info');
|
||||
const mailSummary = summarizeMessagesForLog(fetchResult.messages);
|
||||
if (mailSummary) {
|
||||
await addLog(`步骤 ${step}:最近邮件样本:${mailSummary}`, 'info');
|
||||
}
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
await addLog(`步骤 ${step}:Hotmail 收件箱轮询失败:${err.message}`, 'warn');
|
||||
}
|
||||
|
||||
if (attempt < maxAttempts) {
|
||||
await sleepWithStop(intervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error(`步骤 ${step}:未在 Hotmail 收件箱中找到新的匹配验证码。`);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Tab Registry
|
||||
// ============================================================
|
||||
@@ -614,6 +1046,7 @@ function getSourceLabel(source) {
|
||||
'mail-163': '163 邮箱',
|
||||
'inbucket-mail': 'Inbucket 邮箱',
|
||||
'duck-mail': 'Duck 邮箱',
|
||||
'hotmail-api': 'Hotmail API',
|
||||
};
|
||||
return labels[source] || source || '未知来源';
|
||||
}
|
||||
@@ -650,7 +1083,7 @@ function getErrorMessage(error) {
|
||||
|
||||
function isVerificationMailPollingError(error) {
|
||||
const message = getErrorMessage(error);
|
||||
return /未在 .*邮箱中找到新的匹配邮件|邮箱轮询结束,但未获取到验证码|无法获取新的(?:注册|登录)验证码|页面未能重新就绪|页面通信异常|did not respond in \d+s/i.test(message);
|
||||
return /未在 .*邮箱中找到新的匹配邮件|未在 Hotmail 收件箱中找到新的匹配验证码|邮箱轮询结束,但未获取到验证码|无法获取新的(?:注册|登录)验证码|页面未能重新就绪|页面通信异常|did not respond in \d+s/i.test(message);
|
||||
}
|
||||
|
||||
function isRestartCurrentAttemptError(error) {
|
||||
@@ -658,7 +1091,13 @@ function isRestartCurrentAttemptError(error) {
|
||||
return /当前邮箱已存在,需要重新开始新一轮/.test(message);
|
||||
}
|
||||
|
||||
function isStep9OAuthTimeoutError(error) {
|
||||
function isStep9RecoverableAuthError(error) {
|
||||
const message = String(typeof error === 'string' ? error : error?.message || '');
|
||||
return /STEP9_OAUTH_RETRY::/i.test(message)
|
||||
|| isRecoverableStep9AuthFailure(message);
|
||||
}
|
||||
|
||||
function isLegacyStep9RecoverableAuthError(error) {
|
||||
const message = String(typeof error === 'string' ? error : error?.message || '');
|
||||
return /STEP9_OAUTH_TIMEOUT::|认证失败:\s*Timeout waiting for OAuth callback/i.test(message);
|
||||
}
|
||||
@@ -687,6 +1126,8 @@ function getDownstreamStateResets(step) {
|
||||
flowStartTime: null,
|
||||
password: null,
|
||||
lastEmailTimestamp: null,
|
||||
signupVerificationRequestedAt: null,
|
||||
loginVerificationRequestedAt: null,
|
||||
lastSignupCode: null,
|
||||
lastLoginCode: null,
|
||||
localhostUrl: null,
|
||||
@@ -696,6 +1137,8 @@ function getDownstreamStateResets(step) {
|
||||
return {
|
||||
password: null,
|
||||
lastEmailTimestamp: null,
|
||||
signupVerificationRequestedAt: null,
|
||||
loginVerificationRequestedAt: null,
|
||||
lastSignupCode: null,
|
||||
lastLoginCode: null,
|
||||
localhostUrl: null,
|
||||
@@ -704,6 +1147,8 @@ function getDownstreamStateResets(step) {
|
||||
if (step === 3 || step === 4) {
|
||||
return {
|
||||
lastEmailTimestamp: null,
|
||||
signupVerificationRequestedAt: null,
|
||||
loginVerificationRequestedAt: null,
|
||||
lastSignupCode: null,
|
||||
lastLoginCode: null,
|
||||
localhostUrl: null,
|
||||
@@ -712,6 +1157,7 @@ function getDownstreamStateResets(step) {
|
||||
if (step === 5 || step === 6 || step === 7) {
|
||||
return {
|
||||
lastLoginCode: null,
|
||||
loginVerificationRequestedAt: null,
|
||||
localhostUrl: null,
|
||||
};
|
||||
}
|
||||
@@ -1067,6 +1513,63 @@ async function handleMessage(message, sender) {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
case 'UPSERT_HOTMAIL_ACCOUNT': {
|
||||
const account = await upsertHotmailAccount(message.payload || {});
|
||||
return { ok: true, account };
|
||||
}
|
||||
|
||||
case 'DELETE_HOTMAIL_ACCOUNT': {
|
||||
await deleteHotmailAccount(String(message.payload?.accountId || ''));
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
case 'DELETE_HOTMAIL_ACCOUNTS': {
|
||||
const result = await deleteHotmailAccounts(String(message.payload?.mode || 'all'));
|
||||
return { ok: true, ...result };
|
||||
}
|
||||
|
||||
case 'SELECT_HOTMAIL_ACCOUNT': {
|
||||
const account = await setCurrentHotmailAccount(String(message.payload?.accountId || ''), {
|
||||
markUsed: false,
|
||||
syncEmail: true,
|
||||
});
|
||||
return { ok: true, account };
|
||||
}
|
||||
|
||||
case 'PATCH_HOTMAIL_ACCOUNT': {
|
||||
const account = await patchHotmailAccount(
|
||||
String(message.payload?.accountId || ''),
|
||||
message.payload?.updates || {}
|
||||
);
|
||||
return { ok: true, account };
|
||||
}
|
||||
|
||||
case 'VERIFY_HOTMAIL_ACCOUNT':
|
||||
case 'AUTHORIZE_HOTMAIL_ACCOUNT': {
|
||||
const accountId = String(message.payload?.accountId || '');
|
||||
try {
|
||||
const result = await verifyHotmailAccount(accountId);
|
||||
await setCurrentHotmailAccount(result.account.id, { markUsed: false, syncEmail: true });
|
||||
await addLog(`Hotmail 账号 ${result.account.email} 校验通过,可直接用于收信。`, 'ok');
|
||||
return { ok: true, account: result.account, messageCount: result.messageCount };
|
||||
} catch (err) {
|
||||
const state = await getState();
|
||||
const accounts = normalizeHotmailAccounts(state.hotmailAccounts);
|
||||
const target = findHotmailAccount(accounts, accountId);
|
||||
if (target) {
|
||||
target.status = 'error';
|
||||
target.lastError = err.message;
|
||||
await syncHotmailAccounts(accounts.map((item) => (item.id === target.id ? target : item)));
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
case 'TEST_HOTMAIL_ACCOUNT': {
|
||||
const result = await testHotmailAccountMailAccess(String(message.payload?.accountId || ''));
|
||||
return { ok: true, ...result };
|
||||
}
|
||||
|
||||
// Side panel data updates
|
||||
case 'SAVE_EMAIL': {
|
||||
const state = await getState();
|
||||
@@ -1114,9 +1617,29 @@ async function handleStepData(step, payload) {
|
||||
break;
|
||||
case 3:
|
||||
if (payload.email) await setEmailState(payload.email);
|
||||
if (payload.signupVerificationRequestedAt) {
|
||||
await setState({ signupVerificationRequestedAt: payload.signupVerificationRequestedAt });
|
||||
}
|
||||
if (payload.loginVerificationRequestedAt) {
|
||||
await setState({ loginVerificationRequestedAt: payload.loginVerificationRequestedAt });
|
||||
}
|
||||
break;
|
||||
case 6:
|
||||
if (payload.loginVerificationRequestedAt) {
|
||||
await setState({ loginVerificationRequestedAt: payload.loginVerificationRequestedAt });
|
||||
}
|
||||
break;
|
||||
case 4:
|
||||
if (payload.emailTimestamp) await setState({ lastEmailTimestamp: payload.emailTimestamp });
|
||||
await setState({
|
||||
lastEmailTimestamp: payload.emailTimestamp || null,
|
||||
signupVerificationRequestedAt: null,
|
||||
});
|
||||
break;
|
||||
case 7:
|
||||
await setState({
|
||||
lastEmailTimestamp: payload.emailTimestamp || null,
|
||||
loginVerificationRequestedAt: null,
|
||||
});
|
||||
break;
|
||||
case 8:
|
||||
if (payload.localhostUrl) {
|
||||
@@ -1125,6 +1648,14 @@ async function handleStepData(step, payload) {
|
||||
}
|
||||
break;
|
||||
case 9: {
|
||||
const latestState = await getState();
|
||||
if (latestState.currentHotmailAccountId && isHotmailProvider(latestState)) {
|
||||
await patchHotmailAccount(latestState.currentHotmailAccountId, {
|
||||
used: true,
|
||||
lastUsedAt: Date.now(),
|
||||
});
|
||||
await addLog('当前 Hotmail 账号已自动标记为已用。', 'ok');
|
||||
}
|
||||
const localhostPrefix = buildLocalhostCleanupPrefix(payload.localhostUrl);
|
||||
if (localhostPrefix) {
|
||||
await closeTabsByUrlPrefix(localhostPrefix);
|
||||
@@ -1352,6 +1883,16 @@ async function resumeAutoRunIfWaitingForEmail(options = {}) {
|
||||
|
||||
async function ensureAutoEmailReady(targetRun, totalRuns, attemptRuns) {
|
||||
const currentState = await getState();
|
||||
if (isHotmailProvider(currentState)) {
|
||||
const account = await ensureHotmailAccountForFlow({
|
||||
allowAllocate: true,
|
||||
markUsed: true,
|
||||
preferredAccountId: null,
|
||||
});
|
||||
await addLog(`=== 目标 ${targetRun}/${totalRuns} 轮:已分配 Hotmail 账号 ${account.email}(第 ${attemptRuns} 次尝试)===`, 'ok');
|
||||
return account.email;
|
||||
}
|
||||
|
||||
if (currentState.email) {
|
||||
return currentState.email;
|
||||
}
|
||||
@@ -1430,14 +1971,23 @@ async function runAutoSequenceFromStep(startStep, context = {}) {
|
||||
await executeStepAndWait(step, AUTO_STEP_DELAYS[step]);
|
||||
step += 1;
|
||||
} catch (err) {
|
||||
if (step === 9 && isStep9OAuthTimeoutError(err) && step9RestartAttempts < maxStep9RestartAttempts) {
|
||||
const latestState = await getState();
|
||||
const currentMail = getMailConfig(latestState);
|
||||
const shouldRetryStep9 = step === 9
|
||||
&& (
|
||||
isLegacyStep9RecoverableAuthError(err)
|
||||
|| (currentMail.provider === HOTMAIL_PROVIDER && isStep9RecoverableAuthError(err))
|
||||
)
|
||||
&& step9RestartAttempts < maxStep9RestartAttempts;
|
||||
|
||||
if (shouldRetryStep9) {
|
||||
step9RestartAttempts += 1;
|
||||
await addLog(
|
||||
`步骤 9:检测到 OAuth callback 超时,正在回到步骤 6 重新开始授权流程(${step9RestartAttempts}/${maxStep9RestartAttempts})...`,
|
||||
`步骤 9:检测到 CPA 认证失败,正在回到步骤 6 重新开始授权流程(${step9RestartAttempts}/${maxStep9RestartAttempts})...`,
|
||||
'warn'
|
||||
);
|
||||
await invalidateDownstreamAfterStepRestart(6, {
|
||||
logLabel: `步骤 9 超时后准备回到步骤 6 重试(${step9RestartAttempts}/${maxStep9RestartAttempts})`,
|
||||
logLabel: `步骤 9 认证失败后准备回到步骤 6 重试(${step9RestartAttempts}/${maxStep9RestartAttempts})`,
|
||||
});
|
||||
step = 6;
|
||||
continue;
|
||||
@@ -1696,7 +2246,7 @@ async function executeStep1(state) {
|
||||
}
|
||||
await addLog('步骤 1:正在打开 CPA 面板...');
|
||||
await reuseOrCreateTab('vps-panel', state.vpsUrl, {
|
||||
inject: ['content/utils.js', 'content/vps-panel.js'],
|
||||
inject: ['content/activation-utils.js', 'content/utils.js', 'content/vps-panel.js'],
|
||||
reloadIfSameUrl: true,
|
||||
});
|
||||
|
||||
@@ -1732,26 +2282,39 @@ async function executeStep2(state) {
|
||||
// ============================================================
|
||||
|
||||
async function executeStep3(state) {
|
||||
if (!state.email) {
|
||||
let resolvedEmail = state.email;
|
||||
if (isHotmailProvider(state)) {
|
||||
const account = await ensureHotmailAccountForFlow({
|
||||
allowAllocate: true,
|
||||
markUsed: true,
|
||||
preferredAccountId: state.currentHotmailAccountId || null,
|
||||
});
|
||||
resolvedEmail = account.email;
|
||||
}
|
||||
|
||||
if (!resolvedEmail) {
|
||||
throw new Error('缺少邮箱地址,请先在侧边栏粘贴邮箱。');
|
||||
}
|
||||
|
||||
const password = state.customPassword || generatePassword();
|
||||
if (resolvedEmail !== state.email) {
|
||||
await setEmailState(resolvedEmail);
|
||||
}
|
||||
await setPasswordState(password);
|
||||
|
||||
// Save account record
|
||||
const accounts = state.accounts || [];
|
||||
accounts.push({ email: state.email, password, createdAt: new Date().toISOString() });
|
||||
accounts.push({ email: resolvedEmail, password, createdAt: new Date().toISOString() });
|
||||
await setState({ accounts });
|
||||
|
||||
await addLog(
|
||||
`步骤 3:正在填写邮箱 ${state.email},密码为${state.customPassword ? '自定义' : '自动生成'}(${password.length} 位)`
|
||||
`步骤 3:正在填写邮箱 ${resolvedEmail},密码为${state.customPassword ? '自定义' : '自动生成'}(${password.length} 位)`
|
||||
);
|
||||
await sendToContentScript('signup-page', {
|
||||
type: 'EXECUTE_STEP',
|
||||
step: 3,
|
||||
source: 'background',
|
||||
payload: { email: state.email, password },
|
||||
payload: { email: resolvedEmail, password },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1761,6 +2324,9 @@ async function executeStep3(state) {
|
||||
|
||||
function getMailConfig(state) {
|
||||
const provider = state.mailProvider || 'qq';
|
||||
if (provider === HOTMAIL_PROVIDER) {
|
||||
return { provider: HOTMAIL_PROVIDER, label: 'Hotmail API' };
|
||||
}
|
||||
if (provider === '163') {
|
||||
return { source: 'mail-163', url: 'https://mail.163.com/js6/main.jsp?df=mail163_letter#module=mbox.ListModule%7C%7B%22fid%22%3A1%2C%22order%22%3A%22date%22%2C%22desc%22%3Atrue%7D', label: '163 邮箱' };
|
||||
}
|
||||
@@ -1778,7 +2344,7 @@ function getMailConfig(state) {
|
||||
url: `${host}/m/${encodeURIComponent(mailbox)}/`,
|
||||
label: `Inbucket 邮箱(${mailbox})`,
|
||||
navigateOnReuse: true,
|
||||
inject: ['content/utils.js', 'content/inbucket-mail.js'],
|
||||
inject: ['content/activation-utils.js', 'content/utils.js', 'content/inbucket-mail.js'],
|
||||
injectSource: 'inbucket-mail',
|
||||
};
|
||||
}
|
||||
@@ -1810,7 +2376,7 @@ function getVerificationCodeLabel(step) {
|
||||
function getVerificationPollPayload(step, state, overrides = {}) {
|
||||
if (step === 4) {
|
||||
return {
|
||||
filterAfterTimestamp: state.flowStartTime || 0,
|
||||
filterAfterTimestamp: getHotmailVerificationRequestTimestamp(4, state),
|
||||
senderFilters: ['openai', 'noreply', 'verify', 'auth', 'duckduckgo', 'forward'],
|
||||
subjectFilters: ['verify', 'verification', 'code', '楠岃瘉', 'confirm'],
|
||||
targetEmail: state.email,
|
||||
@@ -1821,7 +2387,7 @@ function getVerificationPollPayload(step, state, overrides = {}) {
|
||||
}
|
||||
|
||||
return {
|
||||
filterAfterTimestamp: state.lastEmailTimestamp || state.flowStartTime || 0,
|
||||
filterAfterTimestamp: getHotmailVerificationRequestTimestamp(7, state),
|
||||
senderFilters: ['openai', 'noreply', 'verify', 'auth', 'chatgpt', 'duckduckgo', 'forward'],
|
||||
subjectFilters: ['verify', 'verification', 'code', '楠岃瘉', 'confirm', 'login'],
|
||||
targetEmail: state.email,
|
||||
@@ -1855,6 +2421,15 @@ async function requestVerificationCodeResend(step) {
|
||||
}
|
||||
|
||||
async function pollFreshVerificationCode(step, state, mail, pollOverrides = {}) {
|
||||
if (mail.provider === HOTMAIL_PROVIDER) {
|
||||
const hotmailPollConfig = getHotmailVerificationPollConfig(step);
|
||||
return pollHotmailVerificationCode(step, state, {
|
||||
...getVerificationPollPayload(step, state),
|
||||
...hotmailPollConfig,
|
||||
...pollOverrides,
|
||||
});
|
||||
}
|
||||
|
||||
const stateKey = getVerificationCodeStateKey(step);
|
||||
const rejectedCodes = new Set();
|
||||
if (state[stateKey]) {
|
||||
@@ -1943,12 +2518,18 @@ async function submitVerificationCode(step, code) {
|
||||
async function resolveVerificationStep(step, state, mail, options = {}) {
|
||||
const stateKey = getVerificationCodeStateKey(step);
|
||||
const rejectedCodes = new Set();
|
||||
if (state[stateKey]) {
|
||||
const hotmailPollConfig = mail.provider === HOTMAIL_PROVIDER
|
||||
? getHotmailVerificationPollConfig(step)
|
||||
: null;
|
||||
const ignorePersistedLastCode = Boolean(hotmailPollConfig?.ignorePersistedLastCode);
|
||||
if (state[stateKey] && !ignorePersistedLastCode) {
|
||||
rejectedCodes.add(state[stateKey]);
|
||||
}
|
||||
|
||||
const nextFilterAfterTimestamp = options.filterAfterTimestamp ?? null;
|
||||
const requestFreshCodeFirst = Boolean(options.requestFreshCodeFirst);
|
||||
const requestFreshCodeFirst = options.requestFreshCodeFirst !== undefined
|
||||
? Boolean(options.requestFreshCodeFirst)
|
||||
: (hotmailPollConfig?.requestFreshCodeFirst ?? false);
|
||||
const maxSubmitAttempts = 3;
|
||||
|
||||
if (requestFreshCodeFirst) {
|
||||
@@ -1960,6 +2541,14 @@ async function resolveVerificationStep(step, state, mail, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
if (mail.provider === HOTMAIL_PROVIDER) {
|
||||
const initialDelayMs = Number(options.initialDelayMs ?? hotmailPollConfig.initialDelayMs) || 0;
|
||||
if (initialDelayMs > 0) {
|
||||
await addLog(`步骤 ${step}:等待 ${Math.round(initialDelayMs / 1000)} 秒,让 Hotmail 验证码邮件先到达...`, 'info');
|
||||
await sleepWithStop(initialDelayMs);
|
||||
}
|
||||
}
|
||||
|
||||
for (let attempt = 1; attempt <= maxSubmitAttempts; attempt++) {
|
||||
const result = await pollFreshVerificationCode(step, state, mail, {
|
||||
excludeCodes: [...rejectedCodes],
|
||||
@@ -2024,35 +2613,42 @@ async function executeStep4(state) {
|
||||
if (prepareResult && prepareResult.error) {
|
||||
throw new Error(prepareResult.error);
|
||||
}
|
||||
if (prepareResult?.verificationRequestedAt) {
|
||||
await setState({ loginVerificationRequestedAt: prepareResult.verificationRequestedAt });
|
||||
}
|
||||
if (prepareResult?.alreadyVerified) {
|
||||
await completeStepFromBackground(4, {});
|
||||
return;
|
||||
}
|
||||
|
||||
await addLog(`步骤 4:正在打开${mail.label}...`);
|
||||
if (mail.provider === HOTMAIL_PROVIDER) {
|
||||
await addLog(`步骤 4:正在通过 ${mail.label} 轮询验证码...`);
|
||||
} else {
|
||||
await addLog(`步骤 4:正在打开${mail.label}...`);
|
||||
|
||||
// For mail tabs, only create if not alive — don't navigate (preserves login session)
|
||||
const alive = await isTabAlive(mail.source);
|
||||
if (alive) {
|
||||
if (mail.navigateOnReuse) {
|
||||
// For mail tabs, only create if not alive — don't navigate (preserves login session)
|
||||
const alive = await isTabAlive(mail.source);
|
||||
if (alive) {
|
||||
if (mail.navigateOnReuse) {
|
||||
await reuseOrCreateTab(mail.source, mail.url, {
|
||||
inject: mail.inject,
|
||||
injectSource: mail.injectSource,
|
||||
});
|
||||
} else {
|
||||
const tabId = await getTabId(mail.source);
|
||||
await chrome.tabs.update(tabId, { active: true });
|
||||
}
|
||||
} else {
|
||||
await reuseOrCreateTab(mail.source, mail.url, {
|
||||
inject: mail.inject,
|
||||
injectSource: mail.injectSource,
|
||||
});
|
||||
} else {
|
||||
const tabId = await getTabId(mail.source);
|
||||
await chrome.tabs.update(tabId, { active: true });
|
||||
}
|
||||
} else {
|
||||
await reuseOrCreateTab(mail.source, mail.url, {
|
||||
inject: mail.inject,
|
||||
injectSource: mail.injectSource,
|
||||
});
|
||||
}
|
||||
|
||||
await resolveVerificationStep(4, state, mail, {
|
||||
filterAfterTimestamp: stepStartedAt,
|
||||
requestFreshCodeFirst: true,
|
||||
filterAfterTimestamp: mail.provider === HOTMAIL_PROVIDER ? undefined : stepStartedAt,
|
||||
requestFreshCodeFirst: mail.provider === HOTMAIL_PROVIDER ? false : true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -2148,29 +2744,33 @@ async function runStep7Attempt(state) {
|
||||
throw new Error(prepareResult.error);
|
||||
}
|
||||
|
||||
await addLog(`步骤 7:正在打开${mail.label}...`);
|
||||
if (mail.provider === HOTMAIL_PROVIDER) {
|
||||
await addLog(`步骤 7:正在通过 ${mail.label} 轮询验证码...`);
|
||||
} else {
|
||||
await addLog(`步骤 7:正在打开${mail.label}...`);
|
||||
|
||||
const alive = await isTabAlive(mail.source);
|
||||
if (alive) {
|
||||
if (mail.navigateOnReuse) {
|
||||
const alive = await isTabAlive(mail.source);
|
||||
if (alive) {
|
||||
if (mail.navigateOnReuse) {
|
||||
await reuseOrCreateTab(mail.source, mail.url, {
|
||||
inject: mail.inject,
|
||||
injectSource: mail.injectSource,
|
||||
});
|
||||
} else {
|
||||
const tabId = await getTabId(mail.source);
|
||||
await chrome.tabs.update(tabId, { active: true });
|
||||
}
|
||||
} else {
|
||||
await reuseOrCreateTab(mail.source, mail.url, {
|
||||
inject: mail.inject,
|
||||
injectSource: mail.injectSource,
|
||||
});
|
||||
} else {
|
||||
const tabId = await getTabId(mail.source);
|
||||
await chrome.tabs.update(tabId, { active: true });
|
||||
}
|
||||
} else {
|
||||
await reuseOrCreateTab(mail.source, mail.url, {
|
||||
inject: mail.inject,
|
||||
injectSource: mail.injectSource,
|
||||
});
|
||||
}
|
||||
|
||||
await resolveVerificationStep(7, state, mail, {
|
||||
filterAfterTimestamp: stepStartedAt,
|
||||
requestFreshCodeFirst: true,
|
||||
filterAfterTimestamp: mail.provider === HOTMAIL_PROVIDER ? undefined : stepStartedAt,
|
||||
requestFreshCodeFirst: mail.provider === HOTMAIL_PROVIDER ? false : true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2345,7 +2945,7 @@ async function executeStep9(state) {
|
||||
// Inject scripts directly and wait for them to be ready
|
||||
await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
files: ['content/utils.js', 'content/vps-panel.js'],
|
||||
files: ['content/activation-utils.js', 'content/utils.js', 'content/vps-panel.js'],
|
||||
});
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
(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) {
|
||||
return /认证失败:\s*/i.test(String(statusText || '').trim());
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -292,9 +294,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) || ''}"`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -25,6 +25,10 @@
|
||||
|
||||
console.log('[MultiPage:vps-panel] Content script loaded on', location.href);
|
||||
|
||||
const {
|
||||
isRecoverableStep9AuthFailure,
|
||||
} = self.MultiPageActivationUtils || {};
|
||||
|
||||
// Listen for commands from Background
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (message.type === 'EXECUTE_STEP') {
|
||||
@@ -111,6 +115,12 @@ async function waitForExactSuccessBadge(timeout = 30000) {
|
||||
if (statusText === '认证成功!') {
|
||||
return statusText;
|
||||
}
|
||||
if (isOAuthCallbackTimeoutFailure(statusText)) {
|
||||
throw new Error(`STEP9_OAUTH_TIMEOUT::${statusText}`);
|
||||
}
|
||||
if (typeof isRecoverableStep9AuthFailure === 'function' && isRecoverableStep9AuthFailure(statusText)) {
|
||||
throw new Error(`STEP9_OAUTH_RETRY::${statusText}`);
|
||||
}
|
||||
await sleep(200);
|
||||
}
|
||||
|
||||
@@ -118,6 +128,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,392 @@
|
||||
(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_MAIL_API_URL = 'https://apple.882263.xyz/api/mail-new';
|
||||
|
||||
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);
|
||||
if (strictMatch) {
|
||||
return {
|
||||
match: strictMatch,
|
||||
usedRelaxedFilters: false,
|
||||
usedTimeFallback: false,
|
||||
};
|
||||
}
|
||||
|
||||
const relaxedMatch = pickVerificationMessage(messages, {
|
||||
afterTimestamp: filters.afterTimestamp,
|
||||
excludeCodes: filters.excludeCodes,
|
||||
senderFilters: [],
|
||||
subjectFilters: [],
|
||||
});
|
||||
|
||||
return {
|
||||
match: relaxedMatch || null,
|
||||
usedRelaxedFilters: Boolean(relaxedMatch),
|
||||
usedTimeFallback: false,
|
||||
};
|
||||
|
||||
/* c8 ignore start */
|
||||
}
|
||||
|
||||
function pickVerificationMessageWithTimeFallback(messages, filters = {}) {
|
||||
const strictOrRelaxedResult = pickVerificationMessageWithFallback(messages, filters);
|
||||
if (strictOrRelaxedResult.match) {
|
||||
return strictOrRelaxedResult;
|
||||
}
|
||||
|
||||
const timeFallbackMatch = pickVerificationMessage(messages, {
|
||||
afterTimestamp: 0,
|
||||
excludeCodes: filters.excludeCodes,
|
||||
senderFilters: [],
|
||||
subjectFilters: [],
|
||||
});
|
||||
|
||||
return {
|
||||
match: timeFallbackMatch || null,
|
||||
usedRelaxedFilters: Boolean(timeFallbackMatch),
|
||||
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 buildHotmailMailApiLatestUrl(options) {
|
||||
const url = new URL(HOTMAIL_MAIL_API_URL);
|
||||
url.searchParams.set('refresh_token', String(options?.refreshToken || ''));
|
||||
url.searchParams.set('client_id', String(options?.clientId || ''));
|
||||
url.searchParams.set('email', String(options?.email || ''));
|
||||
url.searchParams.set('mailbox', String(options?.mailbox || 'INBOX'));
|
||||
url.searchParams.set('response_type', String(options?.responseType || 'json'));
|
||||
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 getHotmailMailApiRequestConfig() {
|
||||
return {
|
||||
timeoutMs: 15000,
|
||||
};
|
||||
}
|
||||
|
||||
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 {
|
||||
buildHotmailMailApiLatestUrl,
|
||||
extractVerificationCodeFromMessage,
|
||||
filterHotmailAccountsByUsage,
|
||||
extractVerificationCode,
|
||||
getLatestHotmailMessage,
|
||||
getHotmailBulkActionLabel,
|
||||
getHotmailListToggleLabel,
|
||||
getHotmailMailApiRequestConfig,
|
||||
getHotmailVerificationPollConfig,
|
||||
getHotmailVerificationRequestTimestamp,
|
||||
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"
|
||||
},
|
||||
@@ -44,7 +44,7 @@
|
||||
"matches": [
|
||||
"https://mail.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"
|
||||
},
|
||||
@@ -52,7 +52,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;
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
<div class="data-row">
|
||||
<span class="data-label">邮箱服务</span>
|
||||
<select id="select-mail-provider" class="data-select">
|
||||
<option value="hotmail-api">Hotmail API(refresh_token)</option>
|
||||
<option value="163">163 邮箱 (mail.163.com)</option>
|
||||
<option value="qq">QQ 邮箱 (wx.mail.qq.com)</option>
|
||||
<option value="inbucket">Inbucket(自定义主机)</option>
|
||||
@@ -101,6 +102,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>
|
||||
@@ -190,6 +234,7 @@
|
||||
</div>
|
||||
|
||||
<div id="toast-container"></div>
|
||||
<script src="../hotmail-utils.js"></script>
|
||||
<script src="sidepanel.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+584
-20
@@ -30,6 +30,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');
|
||||
@@ -43,6 +56,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',
|
||||
@@ -69,9 +83,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
|
||||
@@ -478,10 +502,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();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -680,6 +935,70 @@ async function fetchDuckEmail(options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
const isHidden = inputPassword.type === 'password';
|
||||
btnTogglePassword.innerHTML = isHidden ? EYE_OPEN_ICON : EYE_CLOSED_ICON;
|
||||
@@ -754,18 +1073,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 } });
|
||||
@@ -780,9 +1106,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();
|
||||
@@ -869,7 +1420,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');
|
||||
@@ -887,6 +1438,7 @@ btnReset.addEventListener('click', async () => {
|
||||
updateStopButtonState(false);
|
||||
updateButtonStates();
|
||||
updateProgressCounter();
|
||||
renderHotmailAccounts();
|
||||
});
|
||||
|
||||
// Clear log
|
||||
@@ -896,6 +1448,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 } });
|
||||
@@ -1014,24 +1569,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;
|
||||
}
|
||||
@@ -1083,6 +1646,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,468 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const {
|
||||
buildHotmailMailApiLatestUrl,
|
||||
extractVerificationCodeFromMessage,
|
||||
filterHotmailAccountsByUsage,
|
||||
extractVerificationCode,
|
||||
getLatestHotmailMessage,
|
||||
getHotmailBulkActionLabel,
|
||||
getHotmailListToggleLabel,
|
||||
getHotmailMailApiRequestConfig,
|
||||
getHotmailVerificationPollConfig,
|
||||
getHotmailVerificationRequestTimestamp,
|
||||
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 returns relaxed match when strict filters miss but recent code exists', () => {
|
||||
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.message.id, 'login-mail');
|
||||
assert.equal(result.match.code, '555666');
|
||||
assert.equal(result.usedRelaxedFilters, true);
|
||||
assert.equal(result.usedTimeFallback, false);
|
||||
});
|
||||
|
||||
test('pickVerificationMessageWithTimeFallback can ignore afterTimestamp when latest code mail is just outside flow window', () => {
|
||||
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, true);
|
||||
assert.equal(result.usedTimeFallback, true);
|
||||
});
|
||||
|
||||
test('buildHotmailMailApiLatestUrl includes email, client id, refresh token, and mailbox', () => {
|
||||
const url = new URL(buildHotmailMailApiLatestUrl({
|
||||
clientId: 'client-123',
|
||||
email: 'user@hotmail.com',
|
||||
refreshToken: 'refresh-token-xyz',
|
||||
mailbox: 'Junk',
|
||||
}));
|
||||
|
||||
assert.equal(url.origin + url.pathname, 'https://apple.882263.xyz/api/mail-new');
|
||||
assert.equal(url.searchParams.get('client_id'), 'client-123');
|
||||
assert.equal(url.searchParams.get('email'), 'user@hotmail.com');
|
||||
assert.equal(url.searchParams.get('refresh_token'), 'refresh-token-xyz');
|
||||
assert.equal(url.searchParams.get('mailbox'), 'Junk');
|
||||
assert.equal(url.searchParams.get('response_type'), 'json');
|
||||
});
|
||||
|
||||
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('getHotmailMailApiRequestConfig defines a finite timeout for mailbox polling', () => {
|
||||
assert.deepEqual(getHotmailMailApiRequestConfig(), {
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
});
|
||||
|
||||
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