diff --git a/background/steps/fetch-login-code.js b/background/steps/fetch-login-code.js index 0af5cd4..9f59517 100644 --- a/background/steps/fetch-login-code.js +++ b/background/steps/fetch-login-code.js @@ -27,6 +27,7 @@ reuseOrCreateTab, setState, shouldUseCustomRegistrationEmail, + sleepWithStop, STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS, STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS, throwIfStopped, @@ -167,10 +168,26 @@ }); } - async function runStep8Attempt(state) { + function getStep8ResendIntervalMs(state = {}) { + const mail = getMailConfig(state); + if (mail?.provider === LUCKMAIL_PROVIDER) { + return 15000; + } + if (mail?.provider === HOTMAIL_PROVIDER || mail?.provider === '2925') { + return 0; + } + return Math.max(0, Number(STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS) || 0); + } + + async function runStep8Attempt(state, runtime = {}) { const visibleStep = getVisibleStep(state, 8); const mail = getMailConfig(state); if (mail.error) throw new Error(mail.error); + const stateLastResendAt = Number(state?.loginVerificationRequestedAt) || 0; + let latestResendAt = Math.max(0, Number(runtime?.stickyLastResendAt) || 0, stateLastResendAt); + const notifyResendRequestedAt = typeof runtime?.onResendRequestedAt === 'function' + ? runtime.onResendRequestedAt + : null; const stepStartedAt = Date.now(); const verificationFilterAfterTimestamp = mail.provider === '2925' @@ -267,6 +284,16 @@ disableTimeBudgetCap: mail.provider === '2925', getRemainingTimeMs: getStep8RemainingTimeResolver(state?.oauthUrl || '', visibleStep), requestFreshCodeFirst: false, + lastResendAt: latestResendAt, + onResendRequestedAt: async (requestedAt) => { + const numericRequestedAt = Number(requestedAt) || 0; + if (numericRequestedAt > 0) { + latestResendAt = Math.max(latestResendAt, numericRequestedAt); + } + if (notifyResendRequestedAt) { + await notifyResendRequestedAt(latestResendAt); + } + }, targetEmail: fixedTargetEmail, resendIntervalMs: mail.provider === LUCKMAIL_PROVIDER ? 15000 @@ -274,6 +301,9 @@ ? 0 : STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS), }); + return { + lastResendAt: latestResendAt, + }; } function isStep8RestartStep7Error(error) { @@ -285,10 +315,22 @@ let currentState = state; let mailPollingAttempt = 1; let lastMailPollingError = null; + let stickyLastResendAt = Number(state?.loginVerificationRequestedAt) || 0; while (true) { try { - await runStep8Attempt(currentState); + const result = await runStep8Attempt(currentState, { + stickyLastResendAt, + onResendRequestedAt: async (requestedAt) => { + const numericRequestedAt = Number(requestedAt) || 0; + if (numericRequestedAt > 0) { + stickyLastResendAt = Math.max(stickyLastResendAt, numericRequestedAt); + } + }, + }); + if (Number(result?.lastResendAt) > 0) { + stickyLastResendAt = Math.max(stickyLastResendAt, Number(result.lastResendAt) || 0); + } return; } catch (err) { const visibleStep = getVisibleStep(currentState, 8); @@ -323,7 +365,29 @@ `步骤 ${visibleStep}:认证页仍保持在验证码页,将在当前链路直接重试(${mailPollingAttempt}/${STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS}),不回到步骤 ${authLoginStep}。`, 'warn' ); - currentState = await getState(); + const latestState = await getState(); + const latestStateResendAt = Number(latestState?.loginVerificationRequestedAt) || 0; + if (latestStateResendAt > 0) { + stickyLastResendAt = Math.max(stickyLastResendAt, latestStateResendAt); + } + currentState = latestState; + if (stickyLastResendAt > 0 && (!latestStateResendAt || latestStateResendAt < stickyLastResendAt)) { + currentState = { + ...latestState, + loginVerificationRequestedAt: stickyLastResendAt, + }; + } + const resendIntervalMs = getStep8ResendIntervalMs(currentState); + const remainingBeforeRetryMs = stickyLastResendAt > 0 && resendIntervalMs > 0 + ? Math.max(0, resendIntervalMs - (Date.now() - stickyLastResendAt)) + : 0; + if (remainingBeforeRetryMs > 0 && typeof sleepWithStop === 'function') { + await addLog( + `步骤 ${visibleStep}:上轮已触发重发验证码,为避免重复重发,先等待 ${Math.ceil(remainingBeforeRetryMs / 1000)} 秒后继续当前链路重试。`, + 'info' + ); + await sleepWithStop(Math.min(remainingBeforeRetryMs, 3000)); + } continue; } await addLog( diff --git a/background/verification-flow.js b/background/verification-flow.js index dcade7a..f41d9fa 100644 --- a/background/verification-flow.js +++ b/background/verification-flow.js @@ -920,8 +920,18 @@ const maxSubmitAttempts = mail.provider === LUCKMAIL_PROVIDER ? 3 : 15; const resendIntervalMs = Math.max(0, Number(options.resendIntervalMs) || 0); let lastResendAt = Number(options.lastResendAt) || 0; + const externalOnResendRequestedAt = typeof options.onResendRequestedAt === 'function' + ? options.onResendRequestedAt + : null; - const updateFilterAfterTimestampForVerificationStep = async (_requestedAt) => { + const updateFilterAfterTimestampForVerificationStep = async (requestedAt) => { + if (externalOnResendRequestedAt) { + try { + await externalOnResendRequestedAt(requestedAt); + } catch (_) { + // Keep resend flow best-effort; state sync callback failures should not break verification. + } + } return nextFilterAfterTimestamp; }; diff --git a/tests/background-step7-recovery.test.js b/tests/background-step7-recovery.test.js index ef92903..30ef3db 100644 --- a/tests/background-step7-recovery.test.js +++ b/tests/background-step7-recovery.test.js @@ -310,6 +310,78 @@ test('step 8 retries in-place when polling fails but auth page still stays on ve assert.equal(events.ensureCalls >= 3, true); }); +test('step 8 keeps resend cooldown timestamp across in-place retries to avoid repeated resend storms', async () => { + const events = { + resolveCalls: 0, + resolveLastResendAts: [], + sleepMs: [], + }; + const realDateNow = Date.now; + Date.now = () => 230000; + + const executor = api.createStep8Executor({ + addLog: async () => {}, + chrome: { + tabs: { + update: async () => {}, + }, + }, + CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', + completeStepFromBackground: async () => {}, + confirmCustomVerificationStepBypass: async () => {}, + ensureStep8VerificationPageReady: async () => ({ state: 'verification_page', displayedEmail: 'user@example.com' }), + rerunStep7ForStep8Recovery: async () => {}, + getOAuthFlowRemainingMs: async () => 9000, + getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs) => Math.min(defaultTimeoutMs, 9000), + getMailConfig: () => ({ + provider: 'qq', + label: 'QQ 邮箱', + source: 'mail-qq', + url: 'https://mail.qq.com', + navigateOnReuse: false, + }), + getState: async () => ({ email: 'user@example.com', password: 'secret', loginVerificationRequestedAt: null }), + getTabId: async (sourceName) => (sourceName === 'signup-page' ? 1 : 2), + HOTMAIL_PROVIDER: 'hotmail-api', + isTabAlive: async () => true, + isVerificationMailPollingError: (error) => /页面通信异常|did not respond/i.test(String(error?.message || error || '')), + LUCKMAIL_PROVIDER: 'luckmail-api', + resolveVerificationStep: async (_step, _state, _mail, options) => { + events.resolveCalls += 1; + events.resolveLastResendAts.push(Number(options?.lastResendAt) || 0); + if (events.resolveCalls === 1) { + await options.onResendRequestedAt(222000); + throw new Error('步骤 8:页面通信异常 did not respond in 1s'); + } + }, + reuseOrCreateTab: async () => {}, + setState: async () => {}, + setStepStatus: async () => {}, + shouldUseCustomRegistrationEmail: () => false, + sleepWithStop: async (ms) => { + events.sleepMs.push(ms); + }, + STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000, + STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS: 8, + throwIfStopped: () => {}, + }); + + try { + await executor.executeStep8({ + email: 'user@example.com', + password: 'secret', + oauthUrl: 'https://oauth.example/latest', + }); + } finally { + Date.now = realDateNow; + } + + assert.equal(events.resolveCalls, 2); + assert.deepStrictEqual(events.resolveLastResendAts, [0, 222000]); + assert.equal(events.sleepMs.length >= 1, true); + assert.equal(events.sleepMs[0], 3000); +}); + test('step 8 completes when polling fails but recovery probe shows oauth consent page', async () => { const events = { ensureCalls: 0, diff --git a/tests/verification-flow-polling.test.js b/tests/verification-flow-polling.test.js index 2be3c66..7f1b696 100644 --- a/tests/verification-flow-polling.test.js +++ b/tests/verification-flow-polling.test.js @@ -1030,6 +1030,77 @@ test('verification flow waits during resend cooldown instead of tight-looping', assert.ok(sleepCalls[0] >= 1000); }); +test('verification flow notifies onResendRequestedAt when resend is triggered', async () => { + const resendRequestedAtCalls = []; + const stateUpdates = []; + let pollCalls = 0; + + const helpers = api.createVerificationFlowHelpers({ + addLog: async () => {}, + chrome: { tabs: { update: async () => {} } }, + CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', + completeStepFromBackground: async () => {}, + confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), + getHotmailVerificationPollConfig: () => ({}), + getHotmailVerificationRequestTimestamp: () => 0, + getState: async () => ({}), + getTabId: async () => 1, + HOTMAIL_PROVIDER: 'hotmail-api', + isStopError: () => false, + LUCKMAIL_PROVIDER: 'luckmail-api', + MAIL_2925_VERIFICATION_INTERVAL_MS: 15000, + MAIL_2925_VERIFICATION_MAX_ATTEMPTS: 15, + pollCloudflareTempEmailVerificationCode: async () => ({}), + pollHotmailVerificationCode: async () => ({}), + pollLuckmailVerificationCode: async () => ({}), + sendToContentScript: async (_source, message) => { + if (message.type === 'RESEND_VERIFICATION_CODE') { + return { resent: true }; + } + return {}; + }, + sendToMailContentScriptResilient: async (_mail, message) => { + if (message.type !== 'POLL_EMAIL') { + return {}; + } + pollCalls += 1; + return pollCalls === 1 + ? {} + : { code: '654321', emailTimestamp: 123 }; + }, + setState: async (payload) => { + stateUpdates.push(payload); + }, + setStepStatus: async () => {}, + sleepWithStop: async () => {}, + throwIfStopped: () => {}, + VERIFICATION_POLL_MAX_ROUNDS: 5, + }); + + await helpers.resolveVerificationStep( + 8, + { + email: 'user@example.com', + lastLoginCode: null, + }, + { provider: 'qq', label: 'QQ 邮箱' }, + { + maxResendRequests: 1, + resendIntervalMs: 25000, + onResendRequestedAt: async (requestedAt) => { + resendRequestedAtCalls.push(Number(requestedAt) || 0); + }, + } + ); + + assert.equal(resendRequestedAtCalls.length >= 1, true); + assert.equal(resendRequestedAtCalls[0] > 0, true); + assert.equal( + stateUpdates.some((payload) => Number(payload?.loginVerificationRequestedAt) > 0), + true + ); +}); + test('verification flow uses resilient signup-page transport when submitting verification code', async () => { const resilientCalls = [];