feat: add hotmail mail api account pool flow

This commit is contained in:
baiyuechu
2026-04-11 10:37:43 +08:00
parent ba128ed8d6
commit 8a9d69d325
16 changed files with 3059 additions and 89 deletions
+31 -5
View File
@@ -2,7 +2,7 @@
一个用于批量跑通 ChatGPT OAuth 注册/登录流程的 Chrome 扩展。
当前版本基于侧边栏控制,支持单步执行、整套自动执行、停止当前流程、保存常用配置,以及通过 DuckDuckGo / QQ / 163 / Inbucket mailbox 协助获取验证码。
当前版本基于侧边栏控制,支持单步执行、整套自动执行、停止当前流程、保存常用配置,以及通过 DuckDuckGo / QQ / 163 / Inbucket / Hotmail API 协助获取验证码。
## 当前能力
@@ -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
View File
@@ -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));
+48
View File
@@ -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,
};
});
+9 -7
View File
@@ -215,9 +215,10 @@ async function prepareLoginCodeFlow(timeout = 15000) {
loggedPasswordPage = false;
log('步骤 7:检测到密码页,正在切换到一次性验证码登录...');
await humanPause(350, 900);
const verificationRequestedAt = Date.now();
simulateClick(switchTrigger);
await sleep(1200);
continue;
return { ready: true, mode: 'verification_switch', verificationRequestedAt };
}
if (passwordInput && !loggedPasswordPage) {
@@ -351,15 +352,16 @@ async function step3_fillEmailPassword(payload) {
fillInput(passwordInput, payload.password);
log('步骤 3:密码已填写');
// Report complete BEFORE submit, because submit causes page navigation
// which kills the content script connection
reportComplete(3, { email });
// Submit the form (page will navigate away after this)
await sleep(500);
const submitBtn = document.querySelector('button[type="submit"]')
|| await waitForElementByText('button', /continue|sign\s*up|submit|注册|创建|create/i, 5000).catch(() => null);
// Report complete BEFORE submit, because submit causes page navigation
// which kills the content script connection
const signupVerificationRequestedAt = submitBtn ? Date.now() : null;
reportComplete(3, { email, signupVerificationRequestedAt });
// Submit the form (page will navigate away after this)
await sleep(500);
if (submitBtn) {
await humanPause(500, 1300);
simulateClick(submitBtn);
+30 -3
View File
@@ -1,5 +1,7 @@
// content/utils.js — Shared utilities for all content scripts
const getActivationStrategy = self.MultiPageActivationUtils?.getActivationStrategy;
const SCRIPT_SOURCE = (() => {
if (window.__MULTIPAGE_SOURCE) return window.__MULTIPAGE_SOURCE;
const url = location.href;
@@ -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) || ''}"`);
}
/**
+13
View File
@@ -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`.
+392
View File
@@ -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
View File
@@ -28,7 +28,7 @@
"https://auth.openai.com/*",
"https://accounts.openai.com/*"
],
"js": ["content/utils.js", "content/signup-page.js"],
"js": ["content/activation-utils.js", "content/utils.js", "content/signup-page.js"],
"run_at": "document_idle"
},
{
@@ -36,7 +36,7 @@
"https://mail.qq.com/*",
"https://wx.mail.qq.com/*"
],
"js": ["content/utils.js", "content/qq-mail.js"],
"js": ["content/activation-utils.js", "content/utils.js", "content/qq-mail.js"],
"all_frames": true,
"run_at": "document_idle"
},
@@ -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"
}
],
+7
View File
@@ -0,0 +1,7 @@
{
"name": "codex-oauth-automation-extension",
"private": true,
"scripts": {
"test": "node --test tests/*.test.js"
}
}
+227
View File
@@ -238,6 +238,33 @@ header {
box-shadow: var(--shadow-sm);
}
.section-mini-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.section-mini-copy {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.section-mini-actions {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
justify-content: flex-end;
}
.section-hint {
color: var(--text-muted);
font-size: 12px;
}
.data-row {
display: flex;
align-items: center;
@@ -309,6 +336,23 @@ header {
.data-input::placeholder { color: var(--text-muted); }
.data-input:focus { border-color: var(--blue); box-shadow: 0 0 0 3px var(--blue-soft); }
.data-textarea {
width: 100%;
min-height: 88px;
resize: vertical;
padding: 8px 10px;
background: var(--bg-base);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
outline: none;
transition: border-color var(--transition), box-shadow var(--transition);
}
.data-textarea::placeholder { color: var(--text-muted); }
.data-textarea:focus { border-color: var(--blue); box-shadow: 0 0 0 3px var(--blue-soft); }
.data-input-with-icon {
padding-right: 38px;
}
@@ -340,6 +384,189 @@ header {
flex-shrink: 0;
}
.hotmail-card {
margin-top: 10px;
}
.hotmail-actions-row .data-label {
visibility: hidden;
}
.hotmail-import-row {
align-items: flex-start;
}
.hotmail-import-box {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
.hotmail-list-shell {
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--bg-base) 82%, transparent);
overflow-y: auto;
overflow-x: hidden;
padding: 2px;
transition: max-height var(--transition), border-color var(--transition), box-shadow var(--transition);
}
.hotmail-list-shell.is-collapsed {
max-height: 236px;
}
.hotmail-list-shell.is-expanded {
max-height: 440px;
}
.hotmail-list-shell::-webkit-scrollbar {
width: 8px;
}
.hotmail-list-shell::-webkit-scrollbar-track {
background: transparent;
}
.hotmail-list-shell::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 999px;
}
.hotmail-list-shell::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
.hotmail-accounts-list {
display: flex;
flex-direction: column;
gap: 8px;
padding-right: 4px;
}
.hotmail-account-item {
border: 1px solid var(--border);
background: var(--bg-base);
border-radius: var(--radius-sm);
padding: 10px;
display: flex;
flex-direction: column;
gap: 8px;
}
.hotmail-account-item.is-current {
border-color: var(--blue);
box-shadow: 0 0 0 1px var(--blue-glow);
}
.hotmail-account-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.hotmail-account-title-row {
min-width: 0;
display: flex;
align-items: center;
gap: 6px;
}
.hotmail-account-email {
font-weight: 600;
color: var(--text-primary);
word-break: break-all;
}
.hotmail-copy-btn {
width: 24px;
height: 24px;
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 999px;
background: transparent;
color: var(--text-muted);
cursor: pointer;
transition: background var(--transition), color var(--transition), transform var(--transition);
}
.hotmail-copy-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.hotmail-copy-btn:active {
transform: scale(0.96);
}
.hotmail-status-chip {
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.hotmail-status-chip.status-authorized {
background: var(--green-soft);
color: var(--green);
}
.hotmail-status-chip.status-used {
background: var(--orange-soft);
color: var(--orange);
}
.hotmail-status-chip.status-disabled {
background: var(--bg-surface);
color: var(--text-secondary);
}
.hotmail-status-chip.status-pending {
background: var(--orange-soft);
color: var(--orange);
}
.hotmail-status-chip.status-error {
background: var(--red-soft);
color: var(--red);
}
.hotmail-account-meta {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px 10px;
color: var(--text-secondary);
font-size: 12px;
}
.hotmail-account-error {
font-size: 12px;
color: var(--red);
word-break: break-word;
}
.hotmail-account-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.hotmail-empty {
color: var(--text-muted);
font-size: 12px;
border: 1px dashed var(--border);
border-radius: var(--radius-sm);
padding: 10px;
text-align: center;
}
.data-select {
flex: 1;
padding: 7px 10px;
+45
View File
@@ -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 APIrefresh_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&#10;name@hotmail.com----password----client-id----refresh-token"></textarea>
<button id="btn-import-hotmail-accounts" class="btn btn-outline btn-sm" type="button">导入</button>
</div>
</div>
<div id="hotmail-list-shell" class="hotmail-list-shell is-collapsed">
<div id="hotmail-accounts-list" class="hotmail-accounts-list"></div>
</div>
</div>
<div id="status-bar" class="status-bar">
<div class="status-dot"></div>
<span id="display-status">就绪</span>
@@ -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
View File
@@ -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();
+83
View File
@@ -0,0 +1,83 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const {
getActivationStrategy,
isRecoverableStep9AuthFailure,
} = require('../content/activation-utils.js');
test('getActivationStrategy prefers requestSubmit for submit buttons inside forms', () => {
assert.deepEqual(
getActivationStrategy({
tagName: 'button',
type: 'submit',
hasForm: true,
pathname: '/email-verification',
}),
{ method: 'requestSubmit' }
);
});
test('getActivationStrategy uses native click for non-submit actions', () => {
assert.deepEqual(
getActivationStrategy({
tagName: 'button',
type: 'button',
hasForm: true,
}),
{ method: 'click' }
);
assert.deepEqual(
getActivationStrategy({
tagName: 'a',
type: '',
hasForm: false,
}),
{ method: 'click' }
);
});
test('getActivationStrategy only uses requestSubmit on email verification routes', () => {
assert.deepEqual(
getActivationStrategy({
tagName: 'button',
type: 'submit',
hasForm: true,
pathname: '/u/signup/details',
}),
{ method: 'click' }
);
assert.deepEqual(
getActivationStrategy({
tagName: 'button',
type: 'submit',
hasForm: true,
pathname: '/email-verification',
}),
{ method: 'requestSubmit' }
);
});
test('isRecoverableStep9AuthFailure matches timeout and CPA auth failure statuses', () => {
assert.equal(
isRecoverableStep9AuthFailure('认证失败: Timeout waiting for OAuth callback'),
true
);
assert.equal(
isRecoverableStep9AuthFailure('认证失败: Request failed with status code 502'),
true
);
assert.equal(
isRecoverableStep9AuthFailure('认证成功!'),
false
);
assert.equal(
isRecoverableStep9AuthFailure('等待中'),
false
);
});
+468
View File
@@ -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',
},
]);
});