feat: verify hotmail mailbox before auto-run attempts

This commit is contained in:
EmptyDust
2026-04-28 10:30:56 +08:00
parent 5c5296afec
commit 0ac0e65e6c
4 changed files with 641 additions and 10 deletions
+153 -10
View File
@@ -2121,30 +2121,78 @@ async function setCurrentHotmailAccount(accountId, options = {}) {
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)
function isAuthorizedHotmailRunAccount(candidate) {
return Boolean(candidate)
&& candidate.status === 'authorized'
&& !candidate.used
&& Boolean(candidate.refreshToken);
}
function isPendingHotmailVerificationCandidate(candidate) {
return Boolean(candidate)
&& candidate.status === 'pending'
&& !candidate.used
&& Boolean(candidate.refreshToken);
}
function compareHotmailAccountAllocationPriority(left, right) {
const leftUsedAt = Number(left?.lastUsedAt) || 0;
const rightUsedAt = Number(right?.lastUsedAt) || 0;
if (leftUsedAt !== rightUsedAt) {
return leftUsedAt - rightUsedAt;
}
return String(left?.email || '').localeCompare(String(right?.email || ''));
}
function pickPendingHotmailAccountForVerification(accounts, options = {}) {
const excludeIds = new Set((options.excludeIds || []).filter(Boolean));
const candidates = normalizeHotmailAccounts(accounts)
.filter((candidate) => isPendingHotmailVerificationCandidate(candidate) && !excludeIds.has(candidate.id));
if (!candidates.length) {
return null;
}
const preferredAccountId = String(options.preferredAccountId || '').trim();
if (preferredAccountId) {
const preferredCandidate = candidates.find((candidate) => candidate.id === preferredAccountId);
if (preferredCandidate) {
return preferredCandidate;
}
}
return candidates
.slice()
.sort(compareHotmailAccountAllocationPriority)[0] || null;
}
async function ensureHotmailAccountForFlow(options = {}) {
const {
allowAllocate = true,
markUsed = false,
preferredAccountId = null,
excludeIds = [],
} = options;
const state = await getState();
const accounts = normalizeHotmailAccounts(state.hotmailAccounts);
const excludedAccountIds = new Set((excludeIds || []).filter(Boolean));
const availableAccounts = accounts.filter((candidate) => isAuthorizedHotmailRunAccount(candidate) && !excludedAccountIds.has(candidate.id));
let account = null;
if (preferredAccountId) {
if (preferredAccountId && !excludedAccountIds.has(preferredAccountId)) {
account = findHotmailAccount(accounts, preferredAccountId);
}
if (!account && state.currentHotmailAccountId) {
if ((!account || !isAuthorizedHotmailRunAccount(account)) && state.currentHotmailAccountId && !excludedAccountIds.has(state.currentHotmailAccountId)) {
account = findHotmailAccount(accounts, state.currentHotmailAccountId);
}
if ((!account || !isAccountAllocatable(account)) && allowAllocate) {
account = pickHotmailAccountForRun(accounts, {});
if ((!account || !isAuthorizedHotmailRunAccount(account)) && allowAllocate) {
account = availableAccounts.length ? pickHotmailAccountForRun(availableAccounts, {}) : null;
}
if (!account) {
throw new Error('没有可用的 Hotmail 账号。请先在侧边栏添加至少一个带刷新令牌(refresh token)的账号。');
}
if (!isAccountAllocatable(account)) {
if (!isAuthorizedHotmailRunAccount(account)) {
throw new Error(`Hotmail 账号 ${account.email || account.id} 尚未就绪,无法读取邮件。`);
}
@@ -2482,6 +2530,100 @@ async function verifyHotmailAccount(accountId) {
};
}
async function ensureHotmailMailboxReadyForAutoRunRound(options = {}) {
const {
targetRun = 0,
totalRuns = 0,
attemptRun = 1,
} = options;
const state = await getState();
if (!isHotmailProvider(state)) {
return null;
}
const buildRoundLabel = () => {
if (targetRun > 0 && totalRuns > 0) {
return `${targetRun}/${totalRuns}`;
}
return '当前轮';
};
const exhaustedAccountIds = new Set();
let preferredAccountId = state.currentHotmailAccountId || null;
let lastError = null;
while (true) {
throwIfStopped();
const latestState = await getState();
const latestAccounts = normalizeHotmailAccounts(latestState.hotmailAccounts);
const remainingAuthorizedAccounts = latestAccounts
.filter((candidate) => isAuthorizedHotmailRunAccount(candidate) && !exhaustedAccountIds.has(candidate.id));
const remainingPendingAccounts = latestAccounts
.filter((candidate) => isPendingHotmailVerificationCandidate(candidate) && !exhaustedAccountIds.has(candidate.id));
if (!remainingAuthorizedAccounts.length && !remainingPendingAccounts.length) {
if (lastError) {
throw new Error(`自动运行${buildRoundLabel()}开始前未找到可通过校验的 Hotmail 账号:${lastError.message}`);
}
throw new Error('没有可用的 Hotmail 账号。请先在侧边栏添加至少一个带刷新令牌(refresh token)的账号。');
}
let account = null;
if (remainingAuthorizedAccounts.length) {
account = await ensureHotmailAccountForFlow({
allowAllocate: true,
markUsed: false,
preferredAccountId,
excludeIds: [...exhaustedAccountIds],
});
} else {
const pendingAccount = pickPendingHotmailAccountForVerification(latestAccounts, {
preferredAccountId,
excludeIds: [...exhaustedAccountIds],
});
if (!pendingAccount) {
throw new Error('没有可用的 Hotmail 账号。请先在侧边栏添加至少一个带刷新令牌(refresh token)的账号。');
}
account = await setCurrentHotmailAccount(pendingAccount.id, {
markUsed: false,
syncEmail: true,
});
await addLog(
`自动运行${buildRoundLabel()}开始前未找到已校验 Hotmail 账号,正在尝试校验待校验账号 ${account.email}`,
'warn'
);
}
try {
await addLog(
`自动运行${buildRoundLabel()}${attemptRun} 次尝试开始前,正在校验 Hotmail 账号 ${account.email} 的邮箱可用性。`,
'info'
);
const result = await verifyHotmailAccount(account.id);
await addLog(
`自动运行${buildRoundLabel()}开始前已校验 Hotmail 账号 ${result.account?.email || account.email}INBOX 当前 ${result.messageCount} 封邮件。`,
'ok'
);
return result.account;
} catch (error) {
lastError = error;
exhaustedAccountIds.add(account.id);
preferredAccountId = null;
const latestErrorMessage = error?.message || '未知错误';
await addLog(
`自动运行${buildRoundLabel()}开始前校验 Hotmail 账号 ${account.email} 失败:${latestErrorMessage}`,
'warn'
);
const nextState = await getState();
const hasRemainingAccounts = normalizeHotmailAccounts(nextState.hotmailAccounts)
.some((candidate) => (
isAuthorizedHotmailRunAccount(candidate) || isPendingHotmailVerificationCandidate(candidate)
) && !exhaustedAccountIds.has(candidate.id));
if (hasRemainingAccounts) {
await addLog(`自动运行${buildRoundLabel()}开始前将切换下一个 Hotmail 账号并重试。`, 'warn');
}
}
}
}
async function testHotmailAccountMailAccess(accountId) {
const state = await getState();
const account = findHotmailAccount(state.hotmailAccounts, accountId);
@@ -7638,6 +7780,7 @@ const autoRunController = self.MultiPageBackgroundAutoRunController?.createAutoR
cancelPendingCommands,
clearStopRequest: () => clearStopRequest(),
createAutoRunSessionId: () => createAutoRunSessionId(),
ensureHotmailMailboxReadyForAutoRunRound: (...args) => ensureHotmailMailboxReadyForAutoRunRound(...args),
getAutoRunStatusPayload,
getErrorMessage,
getFirstUnfinishedStep,
+10
View File
@@ -14,6 +14,7 @@
cancelPendingCommands,
clearStopRequest,
createAutoRunSessionId,
ensureHotmailMailboxReadyForAutoRunRound,
getAutoRunStatusPayload,
getErrorMessage,
getFirstUnfinishedStep,
@@ -439,6 +440,15 @@
sessionId,
});
if (!useExistingProgress && startStep === 1 && typeof ensureHotmailMailboxReadyForAutoRunRound === 'function') {
await ensureHotmailMailboxReadyForAutoRunRound({
targetRun,
totalRuns,
attemptRun,
sessionId,
});
}
await runAutoSequenceFromStep(startStep, {
targetRun,
totalRuns,
+164
View File
@@ -0,0 +1,164 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const source = fs.readFileSync('background/auto-run-controller.js', 'utf8');
const globalScope = {};
const api = new Function('self', `${source}; return self.MultiPageBackgroundAutoRunController;`)(globalScope);
test('auto-run controller verifies hotmail mailbox before each fresh attempt starts', async () => {
const events = {
order: [],
preflightCalls: [],
runCalls: 0,
};
let currentState = {
stepStatuses: {},
vpsUrl: 'https://example.com/vps',
vpsPassword: 'secret',
customPassword: '',
autoRunSkipFailures: false,
autoRunFallbackThreadIntervalMinutes: 0,
autoRunDelayEnabled: false,
autoRunDelayMinutes: 30,
autoStepDelaySeconds: null,
mailProvider: 'hotmail-api',
emailGenerator: 'duck',
gmailBaseEmail: '',
mail2925BaseEmail: '',
emailPrefix: 'demo',
inbucketHost: '',
inbucketMailbox: '',
cloudflareDomain: '',
cloudflareDomains: [],
tabRegistry: {},
sourceLastUrls: {},
autoRunRoundSummaries: [],
};
const runtime = {
state: {
autoRunActive: false,
autoRunCurrentRun: 0,
autoRunTotalRuns: 1,
autoRunAttemptRun: 0,
autoRunSessionId: 0,
},
get() {
return { ...this.state };
},
set(updates = {}) {
this.state = { ...this.state, ...updates };
},
};
let sessionSeed = 0;
const controller = api.createAutoRunController({
addLog: async () => {},
appendAccountRunRecord: async () => null,
AUTO_RUN_MAX_RETRIES_PER_ROUND: 3,
AUTO_RUN_RETRY_DELAY_MS: 3000,
AUTO_RUN_TIMER_KIND_BEFORE_RETRY: 'before_retry',
AUTO_RUN_TIMER_KIND_BETWEEN_ROUNDS: 'between_rounds',
broadcastAutoRunStatus: async (phase, payload = {}) => {
currentState = {
...currentState,
autoRunning: ['scheduled', 'running', 'waiting_step', 'waiting_email', 'retrying', 'waiting_interval'].includes(phase),
autoRunPhase: phase,
autoRunCurrentRun: payload.currentRun ?? runtime.state.autoRunCurrentRun,
autoRunTotalRuns: payload.totalRuns ?? runtime.state.autoRunTotalRuns,
autoRunAttemptRun: payload.attemptRun ?? runtime.state.autoRunAttemptRun,
autoRunSessionId: payload.sessionId ?? runtime.state.autoRunSessionId,
};
},
broadcastStopToContentScripts: async () => {},
cancelPendingCommands: () => {},
clearStopRequest: () => {},
createAutoRunSessionId: () => {
sessionSeed += 1;
return sessionSeed;
},
ensureHotmailMailboxReadyForAutoRunRound: async (payload = {}) => {
events.order.push('preflight');
events.preflightCalls.push({ ...payload });
},
getAutoRunStatusPayload: (phase, payload = {}) => ({
autoRunning: ['scheduled', 'running', 'waiting_step', 'waiting_email', 'retrying', 'waiting_interval'].includes(phase),
autoRunPhase: phase,
autoRunCurrentRun: payload.currentRun ?? 0,
autoRunTotalRuns: payload.totalRuns ?? 1,
autoRunAttemptRun: payload.attemptRun ?? 0,
autoRunSessionId: payload.sessionId ?? 0,
}),
getErrorMessage: (error) => error?.message || String(error || ''),
getFirstUnfinishedStep: () => 1,
getPendingAutoRunTimerPlan: () => null,
getRunningSteps: () => [],
getState: async () => ({
...currentState,
stepStatuses: { ...(currentState.stepStatuses || {}) },
tabRegistry: { ...(currentState.tabRegistry || {}) },
sourceLastUrls: { ...(currentState.sourceLastUrls || {}) },
}),
getStopRequested: () => false,
hasSavedProgress: () => false,
isAddPhoneAuthFailure: () => false,
isRestartCurrentAttemptError: () => false,
isSignupUserAlreadyExistsFailure: () => false,
isStopError: (error) => (error?.message || String(error || '')) === '流程已被用户停止。',
launchAutoRunTimerPlan: async () => false,
normalizeAutoRunFallbackThreadIntervalMinutes: (value) => Math.max(0, Math.floor(Number(value) || 0)),
persistAutoRunTimerPlan: async () => ({}),
resetState: async () => {
currentState = {
...currentState,
stepStatuses: {},
tabRegistry: {},
sourceLastUrls: {},
};
},
runAutoSequenceFromStep: async () => {
events.order.push('run');
events.runCalls += 1;
},
runtime,
setState: async (updates = {}) => {
currentState = {
...currentState,
...updates,
stepStatuses: updates.stepStatuses ? { ...updates.stepStatuses } : currentState.stepStatuses,
tabRegistry: updates.tabRegistry ? { ...updates.tabRegistry } : currentState.tabRegistry,
sourceLastUrls: updates.sourceLastUrls ? { ...updates.sourceLastUrls } : currentState.sourceLastUrls,
};
},
sleepWithStop: async () => {},
throwIfAutoRunSessionStopped: (sessionId) => {
if (sessionId && sessionId !== runtime.state.autoRunSessionId) {
throw new Error('流程已被用户停止。');
}
},
waitForRunningStepsToFinish: async () => currentState,
chrome: {
runtime: {
sendMessage() {
return Promise.resolve();
},
},
},
});
await controller.autoRunLoop(1, {
autoRunSkipFailures: false,
mode: 'restart',
});
assert.equal(events.runCalls, 1);
assert.equal(events.preflightCalls.length, 1);
assert.deepEqual(events.order, ['preflight', 'run']);
assert.match(
JSON.stringify(events.preflightCalls[0]),
/"targetRun":1/
);
});
+314
View File
@@ -0,0 +1,314 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const { pickHotmailAccountForRun } = require('../hotmail-utils.js');
const source = fs.readFileSync('background.js', 'utf8');
function extractFunction(name) {
const markers = [`async function ${name}(`, `function ${name}(`];
const start = markers
.map((marker) => source.indexOf(marker))
.find((index) => index >= 0);
if (start < 0) {
return '';
}
let parenDepth = 0;
let signatureEnded = false;
let braceStart = -1;
for (let index = start; index < source.length; index += 1) {
const ch = source[index];
if (ch === '(') {
parenDepth += 1;
} else if (ch === ')') {
parenDepth -= 1;
if (parenDepth === 0) {
signatureEnded = true;
}
} else if (ch === '{' && signatureEnded) {
braceStart = index;
break;
}
}
if (braceStart < 0) {
return '';
}
let depth = 0;
let end = braceStart;
for (; end < source.length; end += 1) {
const ch = source[end];
if (ch === '{') depth += 1;
if (ch === '}') {
depth -= 1;
if (depth === 0) {
end += 1;
break;
}
}
}
return source.slice(start, end);
}
const isAuthorizedHotmailRunAccountSource = extractFunction('isAuthorizedHotmailRunAccount');
const isPendingHotmailVerificationCandidateSource = extractFunction('isPendingHotmailVerificationCandidate');
const compareHotmailAccountAllocationPrioritySource = extractFunction('compareHotmailAccountAllocationPriority');
const pickPendingHotmailAccountForVerificationSource = extractFunction('pickPendingHotmailAccountForVerification');
const ensureHotmailAccountForFlowSource = extractFunction('ensureHotmailAccountForFlow');
const ensureHotmailMailboxReadyForAutoRunRoundSource = extractFunction('ensureHotmailMailboxReadyForAutoRunRound');
function createHotmailPreflightApi(initialState, verifyImpl = async () => ({ account: null, messageCount: 0 })) {
const factory = new Function('deps', `
let currentState = JSON.parse(JSON.stringify(deps.initialState));
const getState = async () => ({
...currentState,
hotmailAccounts: Array.isArray(currentState.hotmailAccounts)
? currentState.hotmailAccounts.map((account) => ({ ...account }))
: [],
});
const normalizeHotmailAccounts = (accounts) => Array.isArray(accounts)
? accounts.map((account) => ({ ...account }))
: [];
const findHotmailAccount = (accounts, accountId) => normalizeHotmailAccounts(accounts)
.find((account) => account.id === accountId) || null;
const setCurrentHotmailAccount = async (accountId) => {
const state = await getState();
const account = findHotmailAccount(state.hotmailAccounts, accountId);
if (!account) {
throw new Error('missing Hotmail account');
}
currentState = {
...currentState,
currentHotmailAccountId: accountId,
};
return account;
};
const pickHotmailAccountForRun = deps.pickHotmailAccountForRun;
const verifyHotmailAccount = async (accountId) => deps.verifyHotmailAccount(accountId, async () => getState());
const isHotmailProvider = (stateOrProvider) => {
const provider = typeof stateOrProvider === 'string'
? stateOrProvider
: stateOrProvider?.mailProvider;
return provider === 'hotmail-api';
};
const addLog = async (message, level = 'info') => {
deps.logs.push({ message, level });
};
const throwIfStopped = () => {};
${isAuthorizedHotmailRunAccountSource}
${isPendingHotmailVerificationCandidateSource}
${compareHotmailAccountAllocationPrioritySource}
${pickPendingHotmailAccountForVerificationSource}
${ensureHotmailAccountForFlowSource}
${ensureHotmailMailboxReadyForAutoRunRoundSource}
return {
ensureHotmailAccountForFlow,
ensureHotmailMailboxReadyForAutoRunRound: typeof ensureHotmailMailboxReadyForAutoRunRound === 'function'
? ensureHotmailMailboxReadyForAutoRunRound
: undefined,
getState,
};
`);
const logs = [];
return {
api: factory({
initialState,
logs,
pickHotmailAccountForRun,
verifyHotmailAccount: verifyImpl,
}),
logs,
};
}
test('ensureHotmailAccountForFlow skips excluded current hotmail account when allocating a fresh account', async () => {
const { api } = createHotmailPreflightApi({
mailProvider: 'hotmail-api',
currentHotmailAccountId: 'primary',
hotmailAccounts: [
{
id: 'primary',
email: 'primary@hotmail.com',
status: 'authorized',
refreshToken: 'rt-primary',
used: false,
lastUsedAt: 1,
},
{
id: 'backup',
email: 'backup@hotmail.com',
status: 'authorized',
refreshToken: 'rt-backup',
used: false,
lastUsedAt: 2,
},
],
});
const account = await api.ensureHotmailAccountForFlow({
allowAllocate: true,
markUsed: false,
excludeIds: ['primary'],
});
assert.equal(account.id, 'backup');
});
test('ensureHotmailMailboxReadyForAutoRunRound switches to another hotmail account after a verification failure', async () => {
const verifyCalls = [];
const { api, logs } = createHotmailPreflightApi({
mailProvider: 'hotmail-api',
currentHotmailAccountId: 'primary',
hotmailAccounts: [
{
id: 'primary',
email: 'primary@hotmail.com',
status: 'authorized',
refreshToken: 'rt-primary',
used: false,
lastUsedAt: 1,
},
{
id: 'backup',
email: 'backup@hotmail.com',
status: 'authorized',
refreshToken: 'rt-backup',
used: false,
lastUsedAt: 2,
},
],
}, async (accountId, getState) => {
verifyCalls.push(accountId);
const state = await getState();
const account = state.hotmailAccounts.find((item) => item.id === accountId);
if (accountId === 'primary') {
throw new Error('INBOX unavailable');
}
return {
account,
messageCount: 4,
};
});
assert.equal(typeof api.ensureHotmailMailboxReadyForAutoRunRound, 'function');
const account = await Promise.race([
api.ensureHotmailMailboxReadyForAutoRunRound({
targetRun: 1,
totalRuns: 3,
attemptRun: 1,
}),
new Promise((_, reject) => {
setTimeout(() => reject(new Error('Hotmail auto-run preflight timed out')), 200);
}),
]);
const state = await api.getState();
assert.equal(account.id, 'backup');
assert.equal(state.currentHotmailAccountId, 'backup');
assert.deepEqual(verifyCalls, ['primary', 'backup']);
assert.ok(logs.some(({ message }) => /切换下一个 Hotmail 账号/.test(message)));
});
test('ensureHotmailMailboxReadyForAutoRunRound verifies pending hotmail accounts when no authorized account exists yet', async () => {
const verifyCalls = [];
const { api } = createHotmailPreflightApi({
mailProvider: 'hotmail-api',
currentHotmailAccountId: null,
hotmailAccounts: [
{
id: 'pending-1',
email: 'pending-1@hotmail.com',
status: 'pending',
refreshToken: 'rt-pending-1',
used: false,
lastUsedAt: 0,
},
],
}, async (accountId, getState) => {
verifyCalls.push(accountId);
const state = await getState();
const account = state.hotmailAccounts.find((item) => item.id === accountId);
return {
account: {
...account,
status: 'authorized',
},
messageCount: 2,
};
});
const account = await api.ensureHotmailMailboxReadyForAutoRunRound({
targetRun: 1,
totalRuns: 1,
attemptRun: 1,
});
const state = await api.getState();
assert.equal(account.id, 'pending-1');
assert.equal(state.currentHotmailAccountId, 'pending-1');
assert.deepEqual(verifyCalls, ['pending-1']);
});
test('ensureHotmailMailboxReadyForAutoRunRound falls back to pending hotmail accounts after authorized accounts fail', async () => {
const verifyCalls = [];
const { api, logs } = createHotmailPreflightApi({
mailProvider: 'hotmail-api',
currentHotmailAccountId: 'authorized-primary',
hotmailAccounts: [
{
id: 'authorized-primary',
email: 'authorized-primary@hotmail.com',
status: 'authorized',
refreshToken: 'rt-authorized-primary',
used: false,
lastUsedAt: 1,
},
{
id: 'pending-backup',
email: 'pending-backup@hotmail.com',
status: 'pending',
refreshToken: 'rt-pending-backup',
used: false,
lastUsedAt: 2,
},
],
}, async (accountId, getState) => {
verifyCalls.push(accountId);
const state = await getState();
const account = state.hotmailAccounts.find((item) => item.id === accountId);
if (accountId === 'authorized-primary') {
throw new Error('INBOX unavailable');
}
return {
account: {
...account,
status: 'authorized',
},
messageCount: 3,
};
});
const account = await Promise.race([
api.ensureHotmailMailboxReadyForAutoRunRound({
targetRun: 1,
totalRuns: 2,
attemptRun: 1,
}),
new Promise((_, reject) => {
setTimeout(() => reject(new Error('Hotmail auto-run pending fallback timed out')), 200);
}),
]);
const state = await api.getState();
assert.equal(account.id, 'pending-backup');
assert.equal(state.currentHotmailAccountId, 'pending-backup');
assert.deepEqual(verifyCalls, ['authorized-primary', 'pending-backup']);
assert.ok(logs.some(({ message }) => /待校验|未校验/.test(message)));
});