diff --git a/background.js b/background.js index e62b730..8e9e345 100644 --- a/background.js +++ b/background.js @@ -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, diff --git a/background/auto-run-controller.js b/background/auto-run-controller.js index 37705a9..e1a6d92 100644 --- a/background/auto-run-controller.js +++ b/background/auto-run-controller.js @@ -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, diff --git a/tests/auto-run-hotmail-preflight.test.js b/tests/auto-run-hotmail-preflight.test.js new file mode 100644 index 0000000..c62fa2c --- /dev/null +++ b/tests/auto-run-hotmail-preflight.test.js @@ -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/ + ); +}); diff --git a/tests/hotmail-auto-run-preflight.test.js b/tests/hotmail-auto-run-preflight.test.js new file mode 100644 index 0000000..ce7a585 --- /dev/null +++ b/tests/hotmail-auto-run-preflight.test.js @@ -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))); +});