From ccd93cf5c1c599cd8d918fdc318645ec5fcfa709 Mon Sep 17 00:00:00 2001 From: Codex Assistant Date: Sun, 26 Apr 2026 13:49:31 +0800 Subject: [PATCH] Improve iCloud Hide My Email request resilience --- background.js | 320 ++++++++++++++++++++++++++++---- tests/background-icloud.test.js | 203 ++++++++++++++++++++ 2 files changed, 483 insertions(+), 40 deletions(-) diff --git a/background.js b/background.js index 1e605b4..4baf5db 100644 --- a/background.js +++ b/background.js @@ -138,6 +138,10 @@ const ICLOUD_LOGIN_URLS = [ 'https://www.icloud.com.cn/', 'https://www.icloud.com/', ]; +const ICLOUD_REQUEST_TIMEOUT_MS = 15000; +const ICLOUD_LIST_MAX_ATTEMPTS = 3; +const ICLOUD_WRITE_MAX_ATTEMPTS = 2; +const ICLOUD_RETRY_DELAYS_MS = [1000, 2500, 5000]; const ICLOUD_PROVIDER = 'icloud'; const GMAIL_PROVIDER = 'gmail'; const GMAIL_ALIAS_GENERATOR = 'gmail-alias'; @@ -3387,6 +3391,7 @@ function isIcloudLoginRequiredError(error) { } let lastIcloudLoginPromptAt = 0; +const activeIcloudRequestControllers = new Set(); async function openIcloudLoginPage(preferredUrl) { const tabs = await chrome.tabs.query({ @@ -3457,29 +3462,155 @@ async function withIcloudLoginHelp(actionLabel, action) { } } +function getIcloudRequestTargetLabel(rawUrl) { + try { + const parsed = new URL(rawUrl); + return `${parsed.host}${parsed.pathname}`; + } catch { + return String(rawUrl || '').trim(); + } +} + +function getIcloudRetryDelay(attemptIndex) { + if (attemptIndex <= 0) return ICLOUD_RETRY_DELAYS_MS[0]; + return ICLOUD_RETRY_DELAYS_MS[Math.min(attemptIndex - 1, ICLOUD_RETRY_DELAYS_MS.length - 1)]; +} + +function isIcloudRetryableStatus(status) { + return [408, 429, 500, 502, 503, 504].includes(Number(status)); +} + +function isIcloudRetryableError(error) { + const status = Number(error?.status || error?.responseStatus || 0); + if (status && isIcloudRetryableStatus(status)) { + return true; + } + if (error?.timedOut || error?.networkFailure) { + return true; + } + + const message = getErrorMessage(error).toLowerCase(); + return message.includes('failed to fetch') + || message.includes('networkerror') + || message.includes('network error') + || message.includes('fetch failed') + || message.includes('timed out') + || message.includes('timeout') + || (error?.name === 'AbortError' && !stopRequested); +} + +function abortActiveIcloudRequests() { + for (const controller of [...activeIcloudRequestControllers]) { + try { + controller.abort(); + } catch {} + } + activeIcloudRequestControllers.clear(); +} + async function icloudRequest(method, url, options = {}) { - const { data } = options; - let response; - try { - response = await fetch(url, { - method, - credentials: 'include', - headers: data !== undefined ? { 'Content-Type': 'application/json' } : undefined, - body: data !== undefined ? JSON.stringify(data) : undefined, - }); - } catch (err) { - throw new Error(`iCloud 请求失败:${method} ${url},${err.message}`); + const { + data, + timeoutMs = ICLOUD_REQUEST_TIMEOUT_MS, + maxAttempts = 1, + retryLabel = '', + logRetries = false, + } = options; + + let lastError = null; + const totalAttempts = Math.max(1, Number(maxAttempts) || 1); + + for (let attempt = 1; attempt <= totalAttempts; attempt += 1) { + throwIfStopped(); + + const controller = new AbortController(); + let response = null; + let timeoutTriggered = false; + let timeoutId = null; + activeIcloudRequestControllers.add(controller); + + try { + timeoutId = setTimeout(() => { + timeoutTriggered = true; + try { + controller.abort(); + } catch {} + }, Math.max(1000, Number(timeoutMs) || ICLOUD_REQUEST_TIMEOUT_MS)); + + response = await fetch(url, { + method, + credentials: 'include', + headers: data !== undefined ? { 'Content-Type': 'application/json' } : undefined, + body: data !== undefined ? JSON.stringify(data) : undefined, + signal: controller.signal, + }); + + if (!response.ok) { + let responseText = ''; + try { + responseText = normalizeText(await response.text()).slice(0, 240); + } catch {} + + const error = new Error( + responseText + ? `iCloud 请求失败:${method} ${url},status ${response.status},body: ${responseText}` + : `iCloud 请求失败:${method} ${url},status ${response.status}` + ); + error.status = response.status; + throw error; + } + + const rawText = await response.text(); + if (!rawText) { + return {}; + } + + try { + return JSON.parse(rawText); + } catch (err) { + throw new Error(`iCloud 返回的 JSON 无法解析:${method} ${url},${err.message}`); + } + } catch (err) { + if (stopRequested) { + throw new Error(STOP_ERROR_MESSAGE); + } + + let requestError = err; + if (timeoutTriggered || err?.name === 'AbortError') { + requestError = new Error(`iCloud 请求超时:${method} ${url},${timeoutMs}ms`); + requestError.name = 'IcloudTimeoutError'; + requestError.timedOut = true; + } else if (!requestError?.status) { + const message = getErrorMessage(requestError); + if (/failed to fetch|networkerror|network error|fetch failed/i.test(message)) { + requestError.networkFailure = true; + } + } + + lastError = requestError; + const shouldRetry = attempt < totalAttempts && isIcloudRetryableError(requestError); + if (!shouldRetry) { + throw requestError; + } + + if (logRetries) { + const delayMs = getIcloudRetryDelay(attempt); + await addLog( + `iCloud:${retryLabel || getIcloudRequestTargetLabel(url)} 第 ${attempt}/${totalAttempts} 次失败:${getErrorMessage(requestError)},${Math.round(delayMs / 1000)} 秒后重试...`, + 'warn' + ); + } + + await sleepWithStop(getIcloudRetryDelay(attempt)); + } finally { + if (timeoutId) { + clearTimeout(timeoutId); + } + activeIcloudRequestControllers.delete(controller); + } } - if (!response.ok) { - throw new Error(`iCloud 请求失败:${method} ${url},status ${response.status}`); - } - - try { - return await response.json(); - } catch (err) { - throw new Error(`iCloud 返回的 JSON 无法解析:${method} ${url},${err.message}`); - } + throw lastError || new Error(`iCloud 请求失败:${method} ${url}`); } async function validateIcloudSession(setupUrl) { @@ -3538,15 +3669,59 @@ async function checkIcloudSession(options = {}) { }); } +async function loadNormalizedIcloudAliases(options = {}) { + const { + resolveOptions = {}, + serviceUrl: initialServiceUrl = '', + silent = false, + } = options; + + let serviceUrl = String(initialServiceUrl || '').trim().replace(/\/$/, ''); + let lastError = null; + + for (let endpointAttempt = 1; endpointAttempt <= 2; endpointAttempt += 1) { + throwIfStopped(); + + if (!serviceUrl) { + const resolved = await resolveIcloudPremiumMailService(resolveOptions); + serviceUrl = resolved.serviceUrl; + } + + try { + if (!silent) { + await addLog(`iCloud:正在从 ${new URL(serviceUrl).host} 加载 Hide My Email 别名列表...`, 'info'); + } + const response = await icloudRequest('GET', `${serviceUrl}/v2/hme/list`, { + timeoutMs: ICLOUD_REQUEST_TIMEOUT_MS, + maxAttempts: ICLOUD_LIST_MAX_ATTEMPTS, + retryLabel: '加载 iCloud 别名列表', + logRetries: true, + }); + const state = await getState(); + return { + serviceUrl, + aliases: normalizeIcloudAliasList(response, { + usedEmails: getEffectiveUsedEmails(state), + preservedEmails: getPreservedAliasMap(state), + }), + }; + } catch (err) { + lastError = err; + if (endpointAttempt >= 2 || !isIcloudRetryableError(err)) { + throw err; + } + await addLog(`iCloud:${new URL(serviceUrl).host} 别名列表请求失败,正在刷新服务节点后重试...`, 'warn'); + serviceUrl = ''; + } + } + + throw lastError || new Error('加载 iCloud 别名列表失败。'); +} + async function listIcloudAliases(options = {}) { return withIcloudLoginHelp('加载 iCloud 隐私邮箱列表', async () => { - const { serviceUrl } = await resolveIcloudPremiumMailService(options); - const response = await icloudRequest('GET', `${serviceUrl}/v2/hme/list`); - const state = await getState(); - return normalizeIcloudAliasList(response, { - usedEmails: getEffectiveUsedEmails(state), - preservedEmails: getPreservedAliasMap(state), - }); + const { aliases } = await loadNormalizedIcloudAliases({ resolveOptions: options }); + return aliases; }); } @@ -3638,13 +3813,14 @@ async function fetchIcloudHideMyEmail(options = {}) { const { serviceUrl, setupUrl } = await resolveIcloudPremiumMailService(); await addLog(`iCloud:已通过 ${new URL(setupUrl).host} 验证会话`, 'ok'); + await addLog(`iCloud:当前 Hide My Email 服务节点 ${new URL(serviceUrl).host}`, 'info'); - const existingAliasesResponse = await icloudRequest('GET', `${serviceUrl}/v2/hme/list`); - const state = await getState(); - const existingAliases = normalizeIcloudAliasList(existingAliasesResponse, { - usedEmails: getEffectiveUsedEmails(state), - preservedEmails: getPreservedAliasMap(state), + let activeServiceUrl = serviceUrl; + const { aliases: existingAliases, serviceUrl: listServiceUrl } = await loadNormalizedIcloudAliases({ + serviceUrl: activeServiceUrl, + silent: true, }); + activeServiceUrl = listServiceUrl || activeServiceUrl; if (!generateNew) { const reusableAlias = pickReusableIcloudAlias(existingAliases); @@ -3659,19 +3835,82 @@ async function fetchIcloudHideMyEmail(options = {}) { } await addLog('iCloud:没有可复用别名,开始生成新的 Hide My Email 地址...', 'warn'); + await addLog(`iCloud:正在向 ${new URL(activeServiceUrl).host} 请求新的 Hide My Email 候选地址...`, 'info'); + + let generated = null; + try { + generated = await icloudRequest('POST', `${activeServiceUrl}/v1/hme/generate`, { + timeoutMs: ICLOUD_REQUEST_TIMEOUT_MS, + maxAttempts: ICLOUD_WRITE_MAX_ATTEMPTS, + retryLabel: '生成 Hide My Email 地址', + logRetries: true, + }); + } catch (err) { + if (!isIcloudRetryableError(err)) { + throw err; + } + await addLog('iCloud:生成候选别名失败,正在刷新服务节点后再试一次...', 'warn'); + const refreshedService = await resolveIcloudPremiumMailService(); + activeServiceUrl = refreshedService.serviceUrl; + generated = await icloudRequest('POST', `${activeServiceUrl}/v1/hme/generate`, { + timeoutMs: ICLOUD_REQUEST_TIMEOUT_MS, + maxAttempts: ICLOUD_WRITE_MAX_ATTEMPTS, + retryLabel: '生成 Hide My Email 地址', + logRetries: true, + }); + } - const generated = await icloudRequest('POST', `${serviceUrl}/v1/hme/generate`); if (!generated?.success || !generated?.result?.hme) { throw new Error(generated?.error?.errorMessage || 'iCloud 隐私邮箱生成失败。'); } - const reserved = await icloudRequest('POST', `${serviceUrl}/v1/hme/reserve`, { - data: { - hme: generated.result.hme, - label: getIcloudAliasLabel(), - note: 'Generated through Multi-Page Automation', - }, - }); + const generatedAlias = String(generated.result.hme || '').trim().toLowerCase(); + await addLog(`iCloud:已生成候选别名 ${generatedAlias},正在保留...`, 'info'); + + let reserved = null; + try { + reserved = await icloudRequest('POST', `${activeServiceUrl}/v1/hme/reserve`, { + data: { + hme: generatedAlias, + label: getIcloudAliasLabel(), + note: 'Generated through Multi-Page Automation', + }, + timeoutMs: ICLOUD_REQUEST_TIMEOUT_MS, + maxAttempts: 1, + }); + } catch (err) { + if (!isIcloudRetryableError(err)) { + throw err; + } + + await addLog(`iCloud:保留 ${generatedAlias} 失败,正在回查别名列表确认是否已成功保留...`, 'warn'); + const { aliases: aliasesAfterReserveFailure, serviceUrl: refreshedListServiceUrl } = await loadNormalizedIcloudAliases({ + serviceUrl: activeServiceUrl, + silent: true, + }); + activeServiceUrl = refreshedListServiceUrl || activeServiceUrl; + + const alreadyReservedAlias = findIcloudAliasByEmail(aliasesAfterReserveFailure, generatedAlias); + if (alreadyReservedAlias) { + await setEmailState(alreadyReservedAlias.email); + await addLog(`iCloud:已在列表中确认 ${alreadyReservedAlias.email},按保留成功处理。`, 'ok'); + broadcastIcloudAliasesChanged({ reason: 'created', email: alreadyReservedAlias.email }); + return alreadyReservedAlias.email; + } + + await addLog(`iCloud:列表中尚未出现 ${generatedAlias},正在刷新服务节点后重试保留一次...`, 'warn'); + const refreshedService = await resolveIcloudPremiumMailService(); + activeServiceUrl = refreshedService.serviceUrl; + reserved = await icloudRequest('POST', `${activeServiceUrl}/v1/hme/reserve`, { + data: { + hme: generatedAlias, + label: getIcloudAliasLabel(), + note: 'Generated through Multi-Page Automation', + }, + timeoutMs: ICLOUD_REQUEST_TIMEOUT_MS, + maxAttempts: 1, + }); + } if (!reserved?.success || !reserved?.result?.hme?.hme) { throw new Error(reserved?.error?.errorMessage || 'iCloud 隐私邮箱保留失败。'); @@ -5475,6 +5714,7 @@ async function requestStop(options = {}) { stopRequested = true; clearCurrentAutoRunSessionId(); cancelPendingCommands(); + abortActiveIcloudRequests(); cleanupStep8NavigationListeners(); rejectPendingStep8(new Error(STOP_ERROR_MESSAGE)); diff --git a/tests/background-icloud.test.js b/tests/background-icloud.test.js index d8e8338..686db94 100644 --- a/tests/background-icloud.test.js +++ b/tests/background-icloud.test.js @@ -277,3 +277,206 @@ test('finalizeIcloudAliasAfterSuccessfulFlow ignores non-icloud flows', async () assert.deepEqual(result, { handled: false, deleted: false }); assert.equal(api.calls.setUsed.length, 0); }); + +test('icloudRequest retries retryable network failures and then succeeds', async () => { + const bundle = [ + extractFunction('getIcloudRequestTargetLabel'), + extractFunction('getIcloudRetryDelay'), + extractFunction('isIcloudRetryableStatus'), + extractFunction('isIcloudRetryableError'), + extractFunction('icloudRequest'), + ].join('\n'); + + const api = new Function(` +const ICLOUD_REQUEST_TIMEOUT_MS = 15000; +const ICLOUD_RETRY_DELAYS_MS = [1, 1, 1]; +const activeIcloudRequestControllers = new Set(); +let stopRequested = false; +const logs = []; +let fetchCalls = 0; + +function throwIfStopped() { + if (stopRequested) { + throw new Error('流程已被用户停止。'); + } +} +async function sleepWithStop() {} +async function addLog(message, level) { + logs.push({ message, level }); +} +function getErrorMessage(error) { + return String(typeof error === 'string' ? error : error?.message || ''); +} +function normalizeText(value) { + return String(value || '').replace(/\\s+/g, ' ').trim(); +} +async function fetch() { + fetchCalls += 1; + if (fetchCalls === 1) { + throw new Error('Failed to fetch'); + } + return { + ok: true, + text: async () => JSON.stringify({ success: true }), + }; +} +${bundle} +return { + icloudRequest, + readFetchCalls: () => fetchCalls, + readLogs: () => logs, +}; +`)(); + + const result = await api.icloudRequest('GET', 'https://p67-maildomainws.icloud.com/v2/hme/list', { + maxAttempts: 2, + logRetries: true, + retryLabel: '加载 iCloud 别名列表', + }); + + assert.deepEqual(result, { success: true }); + assert.equal(api.readFetchCalls(), 2); + assert.equal(api.readLogs().length, 1); +}); + +test('icloudRequest does not retry non-retryable 403 responses', async () => { + const bundle = [ + extractFunction('getIcloudRequestTargetLabel'), + extractFunction('getIcloudRetryDelay'), + extractFunction('isIcloudRetryableStatus'), + extractFunction('isIcloudRetryableError'), + extractFunction('icloudRequest'), + ].join('\n'); + + const api = new Function(` +const ICLOUD_REQUEST_TIMEOUT_MS = 15000; +const ICLOUD_RETRY_DELAYS_MS = [1, 1, 1]; +const activeIcloudRequestControllers = new Set(); +let stopRequested = false; +let fetchCalls = 0; + +function throwIfStopped() { + if (stopRequested) { + throw new Error('流程已被用户停止。'); + } +} +async function sleepWithStop() {} +async function addLog() {} +function getErrorMessage(error) { + return String(typeof error === 'string' ? error : error?.message || ''); +} +function normalizeText(value) { + return String(value || '').replace(/\\s+/g, ' ').trim(); +} +async function fetch() { + fetchCalls += 1; + return { + ok: false, + status: 403, + text: async () => 'forbidden', + }; +} +${bundle} +return { + icloudRequest, + readFetchCalls: () => fetchCalls, +}; +`)(); + + await assert.rejects( + api.icloudRequest('GET', 'https://p67-maildomainws.icloud.com/v2/hme/list', { + maxAttempts: 3, + logRetries: true, + }), + /status 403/i + ); + assert.equal(api.readFetchCalls(), 1); +}); + +test('fetchIcloudHideMyEmail treats reserve network failure as success when alias appears in the refreshed list', async () => { + const bundle = [ + extractFunction('getIcloudRequestTargetLabel'), + extractFunction('getIcloudRetryDelay'), + extractFunction('isIcloudRetryableStatus'), + extractFunction('isIcloudRetryableError'), + extractFunction('fetchIcloudHideMyEmail'), + ].join('\n'); + + const api = new Function(` +const logs = []; +const selectedEmails = []; +const broadcasts = []; +let listCallCount = 0; +const ICLOUD_REQUEST_TIMEOUT_MS = 15000; +const ICLOUD_WRITE_MAX_ATTEMPTS = 2; + +function withIcloudLoginHelp(_label, action) { + return action(); +} +function throwIfStopped() {} +async function addLog(message, level = 'info') { + logs.push({ message, level }); +} +async function resolveIcloudPremiumMailService() { + return { + serviceUrl: 'https://p67-maildomainws.icloud.com', + setupUrl: 'https://setup.icloud.com/setup/ws/1', + }; +} +function getErrorMessage(error) { + return String(typeof error === 'string' ? error : error?.message || ''); +} +async function loadNormalizedIcloudAliases() { + listCallCount += 1; + if (listCallCount === 1) { + return { aliases: [], serviceUrl: 'https://p67-maildomainws.icloud.com' }; + } + return { + aliases: [ + { email: 'fresh@icloud.com', active: true, used: false, preserved: false }, + ], + serviceUrl: 'https://p67-maildomainws.icloud.com', + }; +} +function pickReusableIcloudAlias() { + return null; +} +async function icloudRequest(method, url) { + if (method === 'POST' && /\\/v1\\/hme\\/generate$/.test(url)) { + return { success: true, result: { hme: 'fresh@icloud.com' } }; + } + if (method === 'POST' && /\\/v1\\/hme\\/reserve$/.test(url)) { + const error = new Error('Failed to fetch'); + error.networkFailure = true; + throw error; + } + throw new Error('unexpected request'); +} +function getIcloudAliasLabel() { + return 'MultiPage 2026-04-26'; +} +async function setEmailState(email) { + selectedEmails.push(email); +} +function broadcastIcloudAliasesChanged(payload) { + broadcasts.push(payload); +} +function findIcloudAliasByEmail(aliases, email) { + return (aliases || []).find((alias) => String(alias.email || '').toLowerCase() === String(email || '').toLowerCase()) || null; +} +${bundle} +return { + fetchIcloudHideMyEmail, + readLogs: () => logs, + readSelectedEmails: () => selectedEmails, + readBroadcasts: () => broadcasts, +}; +`)(); + + const email = await api.fetchIcloudHideMyEmail({ generateNew: true }); + + assert.equal(email, 'fresh@icloud.com'); + assert.deepEqual(api.readSelectedEmails(), ['fresh@icloud.com']); + assert.deepEqual(api.readBroadcasts(), [{ reason: 'created', email: 'fresh@icloud.com' }]); + assert.match(api.readLogs().map((entry) => entry.message).join('\n'), /按保留成功处理/); +});