diff --git a/background.js b/background.js index b7fb329..52014b9 100644 --- a/background.js +++ b/background.js @@ -492,6 +492,10 @@ const PERSISTED_SETTING_DEFAULTS = { hotmailServiceMode: HOTMAIL_SERVICE_MODE_LOCAL, hotmailRemoteBaseUrl: DEFAULT_HOTMAIL_REMOTE_BASE_URL, hotmailLocalBaseUrl: DEFAULT_HOTMAIL_LOCAL_BASE_URL, + luckmailApiKey: '', + luckmailBaseUrl: DEFAULT_LUCKMAIL_BASE_URL, + luckmailEmailType: DEFAULT_LUCKMAIL_EMAIL_TYPE, + luckmailDomain: '', cloudflareDomain: '', cloudflareDomains: [], cloudflareTempEmailBaseUrl: '', @@ -1486,6 +1490,14 @@ function normalizePersistentSettingValue(key, value) { return normalizeHotmailRemoteBaseUrl(value); case 'hotmailLocalBaseUrl': return normalizeHotmailLocalBaseUrl(value); + case 'luckmailApiKey': + return String(value || ''); + case 'luckmailBaseUrl': + return normalizeLuckmailBaseUrl(value); + case 'luckmailEmailType': + return normalizeLuckmailEmailType(value); + case 'luckmailDomain': + return String(value || '').trim(); case 'cloudflareDomain': return normalizeCloudflareDomain(value); case 'cloudflareDomains': diff --git a/background/verification-flow.js b/background/verification-flow.js index b184a32..1a8dfea 100644 --- a/background/verification-flow.js +++ b/background/verification-flow.js @@ -697,6 +697,68 @@ throw lastError || new Error(`步骤 ${step}:无法获取新的${getVerificationCodeLabel(step)}验证码。`); } + function shouldRequestLuckmailResendBeforeRetry(error) { + const message = String(error?.message || error || ''); + if (!message) { + return true; + } + + return !/没有可用 token|token 对应邮箱与当前邮箱不一致/i.test(message); + } + + async function pollLuckmailVerificationCodeWithResend(step, state, pollOverrides = {}) { + const { + onResendRequestedAt, + maxRounds: _ignoredMaxRounds, + maxResendRequests: _ignoredMaxResendRequests, + ...cleanPollOverrides + } = pollOverrides; + const basePayload = { + ...getVerificationPollPayload(step, state), + ...cleanPollOverrides, + }; + const maxAttempts = Math.max(1, Number(basePayload.maxAttempts) || 1); + const intervalMs = Math.max(15000, Number(basePayload.intervalMs) || 15000); + let lastError = null; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + throwIfStopped(); + try { + return await pollLuckmailVerificationCode(step, state, { + ...basePayload, + maxAttempts: 1, + intervalMs, + }); + } catch (err) { + if (isStopError(err)) { + throw err; + } + + lastError = err; + const canRetry = attempt < maxAttempts; + if (!canRetry) { + break; + } + + if (shouldRequestLuckmailResendBeforeRetry(err)) { + try { + await requestVerificationCodeResend(step, pollOverrides); + } catch (resendError) { + if (isStopError(resendError)) { + throw resendError; + } + await addLog(`步骤 ${step}:LuckMail 点击重新发送验证码失败:${resendError.message},仍将在 ${Math.ceil(intervalMs / 1000)} 秒后继续轮询 /code 接口。`, 'warn'); + } + } + + await addLog(`步骤 ${step}:LuckMail 暂未获取到新的${getVerificationCodeLabel(step)}验证码,等待 ${Math.ceil(intervalMs / 1000)} 秒后继续轮询 /code 接口(${attempt + 1}/${maxAttempts})...`, 'warn'); + await sleepWithStop(intervalMs); + } + } + + throw lastError || new Error(`步骤 ${step}:无法获取新的${getVerificationCodeLabel(step)}验证码。`); + } + async function pollFreshVerificationCode(step, state, mail, pollOverrides = {}) { const { onResendRequestedAt, @@ -719,7 +781,11 @@ ...getVerificationPollPayload(step, state), ...cleanPollOverrides, }, cleanPollOverrides, `轮询${getVerificationCodeLabel(step)}验证码邮箱`); - return pollLuckmailVerificationCode(step, state, timedPoll.payload); + return pollLuckmailVerificationCodeWithResend(step, state, { + ...cleanPollOverrides, + ...timedPoll.payload, + onResendRequestedAt, + }); } if (mail.provider === CLOUDFLARE_TEMP_EMAIL_PROVIDER) { const timedPoll = await applyMailPollingTimeBudget(step, { diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js index bd7f742..abe4767 100644 --- a/sidepanel/sidepanel.js +++ b/sidepanel/sidepanel.js @@ -1570,6 +1570,12 @@ async function settlePendingSettingsBeforeImport() { await waitForSettingsSaveIdle(); } +async function persistCurrentSettingsForAction() { + clearTimeout(settingsAutoSaveTimer); + await waitForSettingsSaveIdle(); + await saveSettings({ silent: true, force: true }); +} + function downloadTextFile(content, fileName, mimeType = 'application/json;charset=utf-8') { const blob = new Blob([content], { type: mimeType }); const objectUrl = URL.createObjectURL(blob); @@ -3656,10 +3662,10 @@ function scheduleSettingsAutoSave() { } async function saveSettings(options = {}) { - const { silent = false } = options; + const { silent = false, force = false } = options; clearTimeout(settingsAutoSaveTimer); - if (!settingsDirty && !settingsSaveInFlight && silent) { + if (!force && !settingsDirty && !settingsSaveInFlight && silent) { return; } @@ -6253,6 +6259,7 @@ stepsList?.addEventListener('click', async (event) => { if (!(await maybeTakeoverAutoRun(`执行步骤 ${step}`))) { return; } + await persistCurrentSettingsForAction(); if (step === 3) { if (inputPassword.value !== (latestState?.customPassword || '')) { await chrome.runtime.sendMessage({ @@ -6494,8 +6501,8 @@ async function startAutoRunFromCurrentSettings() { console.warn('Failed to refresh contribution content hint before auto run:', error); } - if (typeof settingsDirty !== 'undefined' && settingsDirty && typeof saveSettings === 'function') { - await saveSettings({ silent: true }); + if (typeof persistCurrentSettingsForAction === 'function') { + await persistCurrentSettingsForAction(); } const customEmailPoolEnabled = typeof usesCustomEmailPoolGenerator === 'function' diff --git a/tests/background-luckmail.test.js b/tests/background-luckmail.test.js index 4c6d1df..bccf0c8 100644 --- a/tests/background-luckmail.test.js +++ b/tests/background-luckmail.test.js @@ -437,6 +437,58 @@ return { assert.equal(snapshot.tokenCodeCalls, 2); }); +test('buildPersistentSettingsPayload keeps LuckMail config fields for storage.local persistence', () => { + const bundle = [ + extractFunction('normalizePersistentSettingValue'), + extractFunction('buildPersistentSettingsPayload'), + ].join('\n'); + + const factory = new Function(` +const DEFAULT_LUCKMAIL_BASE_URL = 'https://mails.luckyous.com'; +const DEFAULT_LUCKMAIL_EMAIL_TYPE = 'ms_graph'; +const PERSISTED_SETTING_DEFAULTS = { + luckmailApiKey: '', + luckmailBaseUrl: DEFAULT_LUCKMAIL_BASE_URL, + luckmailEmailType: DEFAULT_LUCKMAIL_EMAIL_TYPE, + luckmailDomain: '', +}; +const PERSISTED_SETTING_KEYS = Object.keys(PERSISTED_SETTING_DEFAULTS); +function normalizeLuckmailBaseUrl(value) { + const normalized = String(value || '').trim() || DEFAULT_LUCKMAIL_BASE_URL; + return normalized.replace(/\\/$/, ''); +} +function normalizeLuckmailEmailType(value) { + return ['self_built', 'ms_imap', 'ms_graph', 'google_variant'].includes(String(value || '').trim()) + ? String(value || '').trim() + : DEFAULT_LUCKMAIL_EMAIL_TYPE; +} +function resolveLegacyAutoStepDelaySeconds() { + return undefined; +} + +${bundle} + +return { + buildPersistentSettingsPayload, +}; +`); + + const api = factory(); + const payload = api.buildPersistentSettingsPayload({ + luckmailApiKey: 'sk-live-demo', + luckmailBaseUrl: 'https://demo.example.com/', + luckmailEmailType: 'ms_imap', + luckmailDomain: ' outlook.com ', + }); + + assert.deepStrictEqual(payload, { + luckmailApiKey: 'sk-live-demo', + luckmailBaseUrl: 'https://demo.example.com', + luckmailEmailType: 'ms_imap', + luckmailDomain: 'outlook.com', + }); +}); + test('listLuckmailPurchasesByProject only keeps openai purchases', async () => { const bundle = extractFunction('listLuckmailPurchasesByProject'); diff --git a/tests/sidepanel-auto-run-content-refresh.test.js b/tests/sidepanel-auto-run-content-refresh.test.js index 5258fcf..6e9b5a1 100644 --- a/tests/sidepanel-auto-run-content-refresh.test.js +++ b/tests/sidepanel-auto-run-content-refresh.test.js @@ -85,6 +85,9 @@ const console = { events.push({ type: 'warn', args }); }, }; +async function persistCurrentSettingsForAction() { + events.push({ type: 'sync-settings' }); +} function getRunCountValue() { return ${Math.max(1, Number(runCount) || 1)}; } function normalizeAutoRunThreadIntervalMinutes(value) { return Number(value) || 0; } function shouldOfferAutoModeChoice() { return false; } @@ -135,9 +138,9 @@ test('startAutoRunFromCurrentSettings refreshes contribution content hint before assert.equal(result, true); assert.deepEqual( api.getEvents().map((entry) => entry.type), - ['refresh', 'send'] + ['refresh', 'sync-settings', 'send'] ); - assert.equal(api.getEvents()[1].message.type, 'AUTO_RUN'); + assert.equal(api.getEvents()[2].message.type, 'AUTO_RUN'); }); test('startAutoRunFromCurrentSettings continues auto run when contribution content refresh fails', async () => { @@ -151,10 +154,10 @@ test('startAutoRunFromCurrentSettings continues auto run when contribution conte assert.equal(result, true); assert.deepEqual( events.map((entry) => entry.type), - ['refresh', 'warn', 'send'] + ['refresh', 'warn', 'sync-settings', 'send'] ); assert.match(String(events[1].args[0]), /Failed to refresh contribution content hint before auto run/); - assert.equal(events[2].message.type, 'AUTO_RUN'); + assert.equal(events[3].message.type, 'AUTO_RUN'); }); test('startAutoRunFromCurrentSettings does not block auto run when contribution content has updates', async () => { @@ -170,7 +173,7 @@ test('startAutoRunFromCurrentSettings does not block auto run when contribution assert.equal(result, true); assert.deepEqual( api.getEvents().map((entry) => entry.type), - ['refresh', 'send'] + ['refresh', 'sync-settings', 'send'] ); }); @@ -187,10 +190,10 @@ test('startAutoRunFromCurrentSettings shows Plus risk warning before starting mo assert.equal(result, true); assert.deepEqual( events.map((entry) => entry.type), - ['refresh', 'plus-risk-modal', 'send'] + ['refresh', 'sync-settings', 'plus-risk-modal', 'send'] ); - assert.equal(events[1].totalRuns, 4); - assert.equal(events[2].message.payload.totalRuns, 4); + assert.equal(events[2].totalRuns, 4); + assert.equal(events[3].message.payload.totalRuns, 4); }); test('startAutoRunFromCurrentSettings aborts when Plus risk warning is declined', async () => { @@ -206,7 +209,7 @@ test('startAutoRunFromCurrentSettings aborts when Plus risk warning is declined' assert.equal(result, false); assert.deepEqual( api.getEvents().map((entry) => entry.type), - ['refresh', 'plus-risk-modal'] + ['refresh', 'sync-settings', 'plus-risk-modal'] ); }); @@ -224,7 +227,58 @@ test('startAutoRunFromCurrentSettings aborts when Plus contribution prompt opens assert.equal(result, false); assert.deepEqual( api.getEvents().map((entry) => entry.type), - ['refresh', 'plus-contribution-modal'] + ['refresh', 'sync-settings', 'plus-contribution-modal'] ); - assert.equal(api.getEvents()[1].plusModeEnabled, true); + assert.equal(api.getEvents()[2].plusModeEnabled, true); +}); + +test('persistCurrentSettingsForAction forces a silent save even when settings are not marked dirty', async () => { + const bundle = [ + extractFunction('waitForSettingsSaveIdle'), + extractFunction('saveSettings'), + extractFunction('persistCurrentSettingsForAction'), + ].join('\n'); + + const api = new Function(` +let settingsAutoSaveTimer = 123; +let clearedTimer = null; +let settingsSaveInFlight = false; +let settingsDirty = false; +let settingsSaveRevision = 0; +const saveCalls = []; +function clearTimeout(value) { + clearedTimer = value; +} +function updateSaveButtonState() {} +function collectSettingsPayload() { + return { luckmailApiKey: 'autofilled-key' }; +} +function syncLatestState() {} +function updatePanelModeUI() {} +function updateMailProviderUI() {} +function updateButtonStates() {} +function markSettingsDirty() {} +function applySettingsState() {} +const chrome = { + runtime: { + async sendMessage(message) { + saveCalls.push(message.payload); + return { state: { luckmailApiKey: message.payload.luckmailApiKey } }; + }, + }, +}; +${bundle} +return { + persistCurrentSettingsForAction, + getSnapshot() { + return { clearedTimer, saveCalls }; + }, +}; +`)(); + + await api.persistCurrentSettingsForAction(); + const snapshot = api.getSnapshot(); + + assert.equal(snapshot.clearedTimer, 123); + assert.deepStrictEqual(snapshot.saveCalls, [{ luckmailApiKey: 'autofilled-key' }]); }); diff --git a/tests/verification-flow-polling.test.js b/tests/verification-flow-polling.test.js index 7f1b696..3c6d176 100644 --- a/tests/verification-flow-polling.test.js +++ b/tests/verification-flow-polling.test.js @@ -1030,6 +1030,75 @@ test('verification flow waits during resend cooldown instead of tight-looping', assert.ok(sleepCalls[0] >= 1000); }); +test('verification flow clicks resend before waiting for the next LuckMail /code retry', async () => { + const events = []; + 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 (_step, _state, payload) => { + pollCalls += 1; + events.push(['poll', payload.maxAttempts, payload.intervalMs]); + if (pollCalls === 1) { + throw new Error('步骤 4:LuckMail /code 接口暂未返回新的验证码。'); + } + return { + code: '654321', + emailTimestamp: 123, + }; + }, + sendToContentScript: async (_source, message) => { + if (message.type === 'RESEND_VERIFICATION_CODE') { + events.push(['resend', message.step]); + } + return {}; + }, + sendToMailContentScriptResilient: async () => ({}), + setState: async () => {}, + setStepStatus: async () => {}, + sleepWithStop: async (ms) => { + events.push(['sleep', ms]); + }, + throwIfStopped: () => {}, + VERIFICATION_POLL_MAX_ROUNDS: 5, + }); + + const result = await helpers.pollFreshVerificationCode( + 4, + { + email: 'user@example.com', + lastSignupCode: null, + }, + { provider: 'luckmail-api', label: 'LuckMail(API 购邮)' }, + { + resendIntervalMs: 15000, + } + ); + + assert.equal(result.code, '654321'); + assert.deepStrictEqual(events, [ + ['poll', 1, 15000], + ['resend', 4], + ['sleep', 15000], + ['poll', 1, 15000], + ]); +}); + test('verification flow notifies onResendRequestedAt when resend is triggered', async () => { const resendRequestedAtCalls = []; const stateUpdates = []; diff --git a/项目完整链路说明.md b/项目完整链路说明.md index e25f8c4..96bfe81 100644 --- a/项目完整链路说明.md +++ b/项目完整链路说明.md @@ -350,6 +350,7 @@ IP 代理模块在同步、切换、Change、出口探测和自动运行成功 - `2925` 在执行自动登录后,如果登录页因为跳转或重载导致原内容脚本通信中断,后台不会立刻判失败;而是会等待当前标签页重新加载完成、重新确认内容脚本就绪后,再继续确认是否已经进入收件箱。这段登录恢复窗口当前按 2 分钟控制。 - 普通邮箱仍会携带 `filterAfterTimestamp` 做时间窗筛选;`2925` 在 Step 4 / Step 8 会固定使用“步骤开始时间向前回看 10 分钟”的时间窗,不再做“新旧邮件快照差集”比较,而是每次刷新后直接遍历当前列表中落在该固定时间窗内的匹配邮件。 - 自动重新发送验证码次数现在使用 sidepanel 里的单一“验证码重发”配置;普通邮箱仍按 25 秒间隔节流,Hotmail / 2925 不走这个 25 秒间隔。Step 4 若启用先请求新验证码,会先消耗一次当前步骤的自动重发次数。 +- `LuckMail` 在 `/code` 接口暂未拿到新验证码时,会先点击一次认证页“重新发送验证码”,再等待 15 秒后继续轮询 `/code`,避免只空等不触发新邮件。 - 验证码提交重试上限当前为 15 次;页面明确拒绝验证码时,会在上限内继续拉取新验证码并重提。 - `2925` 内容脚本会把每一封实际打开检测的邮件立即删除;同一验证码步骤启动后,试过的验证码会按“步骤 ID + 启动时间”隔离缓存,不会在本次步骤里重复提交;如果再次遇到相同验证码,对应邮件也会在读取后立即删除,避免后续反复打开。 - `2925` 在 provide 模式下仍保持宽松匹配:只要邮件内容命中 ChatGPT / OpenAI 验证码过滤条件,就会尝试该邮件。