diff --git a/background.js b/background.js index fbf13e0..96ce33c 100644 --- a/background.js +++ b/background.js @@ -142,6 +142,7 @@ const { getIcloudSetupUrlForHost, normalizeBooleanMap, normalizeIcloudAliasList, + normalizeIcloudAliasRecord, normalizeIcloudHost, pickReusableIcloudAlias, toNormalizedEmailSet, @@ -169,6 +170,18 @@ 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_TAB_URL_PATTERNS = [ + 'https://www.icloud.com/*', + 'https://www.icloud.com.cn/*', + 'https://setup.icloud.com/*', + 'https://setup.icloud.com.cn/*', + 'https://*.icloud.com/*', + 'https://*.icloud.com.cn/*', +]; +const ICLOUD_MAILDOMAINWS_CLIENT_BUILD_NUMBER = '2206Hotfix11'; +const ICLOUD_ALIAS_CACHE_MAX_AGE_MS = 6 * 60 * 60 * 1000; +const ICLOUD_TRANSIENT_RETRY_MAX_ATTEMPTS = 2; +const ICLOUD_TRANSIENT_RETRY_DELAY_MS = 1200; const ICLOUD_PROVIDER = 'icloud'; const GMAIL_PROVIDER = 'gmail'; const GMAIL_ALIAS_GENERATOR = 'gmail-alias'; @@ -231,7 +244,12 @@ const HERO_SMS_COUNTRY_ID = 52; const HERO_SMS_COUNTRY_LABEL = 'Thailand'; const DISPLAY_TIMEZONE = 'Asia/Shanghai'; const MICROSOFT_TOKEN_DNR_RULE_ID = 1001; -const PERSISTENT_ALIAS_STATE_KEYS = ['manualAliasUsage', 'preservedAliases']; +const PERSISTENT_ALIAS_STATE_KEYS = [ + 'manualAliasUsage', + 'preservedAliases', + 'icloudAliasCache', + 'icloudAliasCacheAt', +]; const ACCOUNT_RUN_HISTORY_STORAGE_KEY = 'accountRunHistory'; const CONTRIBUTION_RUNTIME_DEFAULTS = self.MultiPageBackgroundContributionOAuth?.RUNTIME_DEFAULTS || { contributionMode: false, @@ -441,6 +459,8 @@ const DEFAULT_STATE = { accountRunHistory: [], // 账号运行历史快照,实际持久化在 chrome.storage.local。 manualAliasUsage: {}, preservedAliases: {}, + icloudAliasCache: [], + icloudAliasCacheAt: 0, lastEmailTimestamp: null, // 最近一次获取到邮箱数据的运行时时间戳。 lastSignupCode: null, // 注册验证码,运行时由程序自动读取并写入。 lastLoginCode: null, // 登录验证码,运行时由程序自动读取并写入。 @@ -1237,15 +1257,24 @@ async function getPersistedSettings() { async function getPersistedAliasState() { try { const stored = await chrome.storage.local.get(PERSISTENT_ALIAS_STATE_KEYS); + const manualAliasUsage = normalizeBooleanMap(stored.manualAliasUsage); + const preservedAliases = normalizeBooleanMap(stored.preservedAliases); return { - manualAliasUsage: normalizeBooleanMap(stored.manualAliasUsage), - preservedAliases: normalizeBooleanMap(stored.preservedAliases), + manualAliasUsage, + preservedAliases, + icloudAliasCache: normalizeIcloudAliasCacheList(stored.icloudAliasCache, { + usedEmails: toNormalizedEmailSet(manualAliasUsage), + preservedEmails: toNormalizedEmailSet(preservedAliases), + }), + icloudAliasCacheAt: Math.max(0, Number(stored.icloudAliasCacheAt) || 0), }; } catch (err) { console.warn(LOG_PREFIX, 'Failed to read persisted iCloud alias state:', err?.message || err); return { manualAliasUsage: {}, preservedAliases: {}, + icloudAliasCache: [], + icloudAliasCacheAt: 0, }; } } @@ -1284,6 +1313,12 @@ async function setState(updates) { if (Object.prototype.hasOwnProperty.call(updates, 'preservedAliases')) { persistentAliasUpdates.preservedAliases = normalizeBooleanMap(updates.preservedAliases); } + if (Object.prototype.hasOwnProperty.call(updates, 'icloudAliasCache')) { + persistentAliasUpdates.icloudAliasCache = normalizeIcloudAliasCacheList(updates.icloudAliasCache); + } + if (Object.prototype.hasOwnProperty.call(updates, 'icloudAliasCacheAt')) { + persistentAliasUpdates.icloudAliasCacheAt = Math.max(0, Number(updates.icloudAliasCacheAt) || 0); + } if (Object.keys(persistentAliasUpdates).length > 0) { await chrome.storage.local.set(persistentAliasUpdates); } @@ -1549,6 +1584,80 @@ function getEffectiveUsedEmails(state) { return toNormalizedEmailSet(getManualAliasUsageMap(state)); } +function normalizeIcloudAliasCacheList(value = [], options = {}) { + const aliases = Array.isArray(value) ? value : []; + const usedEmails = toNormalizedEmailSet(options.usedEmails); + const preservedEmails = toNormalizedEmailSet(options.preservedEmails); + return aliases + .map((alias) => normalizeIcloudAliasRecord(alias, { usedEmails, preservedEmails })) + .filter(Boolean) + .sort((left, right) => { + if (left.active !== right.active) return left.active ? -1 : 1; + if (left.used !== right.used) return left.used ? 1 : -1; + return String(left.email).localeCompare(String(right.email)); + }); +} + +function getIcloudAliasCacheFromState(state, options = {}) { + const maxAgeMs = Math.max(0, Number(options.maxAgeMs) || ICLOUD_ALIAS_CACHE_MAX_AGE_MS); + const cachedAt = Number(state?.icloudAliasCacheAt || 0); + if (!Array.isArray(state?.icloudAliasCache) || state.icloudAliasCache.length <= 0) { + return []; + } + if (maxAgeMs > 0 && cachedAt > 0 && Date.now() - cachedAt > maxAgeMs) { + return []; + } + return normalizeIcloudAliasCacheList(state.icloudAliasCache, { + usedEmails: getEffectiveUsedEmails(state), + preservedEmails: getPreservedAliasMap(state), + }); +} + +function isLikelyIcloudAliasEmail(value = '') { + const email = String(value || '').trim().toLowerCase(); + if (!email || !email.includes('@')) { + return false; + } + return /@(icloud\.com|me\.com|mac\.com|privaterelay\.appleid\.com)$/.test(email); +} + +function buildIcloudAliasFallbackFromLocalState(state = {}) { + const manualAliasUsage = getManualAliasUsageMap(state); + const preservedAliases = getPreservedAliasMap(state); + const candidates = new Set(); + + for (const email of Object.keys(manualAliasUsage)) { + if (isLikelyIcloudAliasEmail(email)) { + candidates.add(String(email).trim().toLowerCase()); + } + } + for (const email of Object.keys(preservedAliases)) { + if (isLikelyIcloudAliasEmail(email)) { + candidates.add(String(email).trim().toLowerCase()); + } + } + + const currentEmail = String(state?.email || '').trim().toLowerCase(); + if (isLikelyIcloudAliasEmail(currentEmail)) { + candidates.add(currentEmail); + } + + if (!candidates.size) { + return []; + } + + const aliases = Array.from(candidates, (email) => ({ + hme: email, + email, + state: 'active', + active: true, + })); + return normalizeIcloudAliasCacheList(aliases, { + usedEmails: getEffectiveUsedEmails(state), + preservedEmails: preservedAliases, + }); +} + async function setIcloudAliasUsedState(payload = {}, options = {}) { const email = String(payload.email || '').trim().toLowerCase(); if (!email) { @@ -3438,10 +3547,7 @@ async function pollCloudflareTempEmailVerificationCode(step, state, pollPayload async function getOpenIcloudHostPreference() { try { const tabs = await chrome.tabs.query({ - url: [ - 'https://www.icloud.com/*', - 'https://www.icloud.com.cn/*', - ], + url: ICLOUD_TAB_URL_PATTERNS, }); const activeTab = tabs.find((tab) => tab.active); @@ -3464,9 +3570,9 @@ async function getPreferredIcloudLoginUrl(error = null, state = null) { return getIcloudLoginUrlForHost(configuredHost); } - const messageHint = getIcloudHostHintFromMessage(getErrorMessage(error)); - if (messageHint) { - return getIcloudLoginUrlForHost(messageHint); + const openHost = await getOpenIcloudHostPreference(); + if (openHost) { + return getIcloudLoginUrlForHost(openHost); } const savedHost = normalizeIcloudHost(currentState?.preferredIcloudHost); @@ -3474,9 +3580,9 @@ async function getPreferredIcloudLoginUrl(error = null, state = null) { return getIcloudLoginUrlForHost(savedHost); } - const openHost = await getOpenIcloudHostPreference(); - if (openHost) { - return getIcloudLoginUrlForHost(openHost); + const messageHint = getIcloudHostHintFromMessage(getErrorMessage(error)); + if (messageHint) { + return getIcloudLoginUrlForHost(messageHint); } return ICLOUD_LOGIN_URLS[0]; @@ -3497,36 +3603,111 @@ async function getPreferredIcloudSetupUrls(state = null, error = null) { function isIcloudLoginRequiredError(error) { const message = getErrorMessage(error).toLowerCase(); - return message.includes('could not validate icloud session') - || message.includes('hide my email service was unavailable') - || /\bstatus (401|403|409|421)\b/.test(message); + const hasAuthStatus401 = /\bstatus 401\b/.test(message); + const hasAuthStatus403 = /\bstatus 403\b/.test(message); + const hasTransientStatus = /\bstatus (409|421|429|5\d\d)\b/.test(message); + const hasTransientNetworkHint = message.includes('failed to fetch') + || message.includes('networkerror') + || message.includes('network request failed') + || message.includes('timeout') + || message.includes('timed out') + || message.includes('cors') + || message.includes('address space'); + const hasExplicitLoginHint = message.includes('please sign in') + || message.includes('sign in required') + || message.includes('not logged in') + || message.includes('login required') + || message.includes('re-authentication required') + || message.includes('unauthenticated') + || message.includes('authentication required') + || message.includes('需要先登录') + || message.includes('请先登录'); + const hasSelfPromptHint = message.includes('请先在新打开的 icloud 页面中完成登录') + || message.includes('请先在当前浏览器登录'); + const hasAuthStatusWithExplicitLoginHint = (hasAuthStatus401 || hasAuthStatus403) + && hasExplicitLoginHint; + + // Keep transient validate/network/cors errors out of login-required path. + if (message.includes('could not validate icloud session')) { + return false; + } + if (message.includes('page_context:')) { + return false; + } + if (hasSelfPromptHint) { + return false; + } + if (hasTransientStatus || hasTransientNetworkHint) { + return false; + } + + if (hasAuthStatusWithExplicitLoginHint) { + return true; + } + + if (hasExplicitLoginHint) { + return true; + } + + return false; +} + +function isIcloudTransientContextError(error) { + const message = getErrorMessage(error).toLowerCase(); + return /\bstatus (401|403|409|421|429|5\d\d)\b/.test(message) + || message.includes('could not validate icloud session') + || message.includes('page_context:') + || message.includes('failed to fetch') + || message.includes('networkerror') + || message.includes('network request failed') + || message.includes('cors') + || message.includes('address space') + || message.includes('timeout') + || message.includes('timed out'); } let lastIcloudLoginPromptAt = 0; const activeIcloudRequestControllers = new Set(); +let lastResolvedIcloudServiceUrl = ''; +const icloudTransientLogThrottle = new Map(); + +function shouldEmitIcloudTransientLog(key, windowMs = 1500) { + const normalizedKey = String(key || '').trim(); + if (!normalizedKey) { + return true; + } + const now = Date.now(); + const lastAt = Number(icloudTransientLogThrottle.get(normalizedKey) || 0); + if (now - lastAt < Math.max(200, Number(windowMs) || 1500)) { + return false; + } + icloudTransientLogThrottle.set(normalizedKey, now); + return true; +} async function openIcloudLoginPage(preferredUrl) { const tabs = await chrome.tabs.query({ - url: [ - 'https://www.icloud.com/*', - 'https://www.icloud.com.cn/*', - ], + url: ICLOUD_TAB_URL_PATTERNS, }); const preferredHost = new URL(preferredUrl).host; - const existing = tabs.find((tab) => { + const preferredIcloudHost = normalizeIcloudHost(preferredHost); + const existingSameHost = tabs.find((tab) => { try { - return new URL(tab.url).host === preferredHost; + return normalizeIcloudHost(new URL(tab.url).host) === preferredIcloudHost; } catch { return false; } }); + const existingAnyIcloudTab = tabs.find((tab) => Number.isInteger(tab?.id)); - if (existing?.id) { - await chrome.tabs.update(existing.id, { active: true }); - if (existing.url !== preferredUrl) { - await chrome.tabs.update(existing.id, { url: preferredUrl }); - } - return existing.id; + if (existingSameHost?.id) { + await chrome.tabs.update(existingSameHost.id, { active: true }); + return existingSameHost.id; + } + + if (existingAnyIcloudTab?.id) { + await chrome.tabs.update(existingAnyIcloudTab.id, { active: true }); + return existingAnyIcloudTab.id; } const created = await chrome.tabs.create({ url: preferredUrl, active: true }); @@ -3563,15 +3744,322 @@ async function promptIcloudLogin(error, actionLabel = 'iCloud 操作') { } async function withIcloudLoginHelp(actionLabel, action) { - try { - return await action(); - } catch (err) { - if (isIcloudLoginRequiredError(err)) { - await promptIcloudLogin(err, actionLabel); - throw new Error('请先在新打开的 iCloud 页面中完成登录,再回来点击“我已登录”。'); + const maxTransientAttempts = Math.max(1, Number(ICLOUD_TRANSIENT_RETRY_MAX_ATTEMPTS) || 1); + const retryDelayMs = Math.max(300, Number(ICLOUD_TRANSIENT_RETRY_DELAY_MS) || 1200); + for (let attempt = 1; attempt <= maxTransientAttempts; attempt += 1) { + try { + return await action(); + } catch (err) { + if (isIcloudLoginRequiredError(err)) { + await promptIcloudLogin(err, actionLabel); + throw new Error('请先在新打开的 iCloud 页面中完成登录,再回来点击“我已登录”。'); + } + if (isIcloudTransientContextError(err)) { + if (attempt < maxTransientAttempts) { + if (shouldEmitIcloudTransientLog(`${actionLabel}:retry:${attempt}/${maxTransientAttempts}`)) { + await addLog(`iCloud:${actionLabel}受网络/上下文波动影响,正在重试(${attempt}/${maxTransientAttempts})...`, 'warn'); + } + await new Promise((resolve) => setTimeout(resolve, retryDelayMs * attempt)); + continue; + } + if (shouldEmitIcloudTransientLog(`${actionLabel}:final`)) { + await addLog(`iCloud:${actionLabel}受网络/上下文波动影响:${getErrorMessage(err)}`, 'warn'); + } + const transientError = new Error('iCloud 别名加载受网络/上下文波动影响,请稍后重试。'); + transientError.code = 'ICLOUD_TRANSIENT_CONTEXT'; + transientError.cause = err; + throw transientError; + } + throw err; } - throw err; } + throw new Error('iCloud 操作失败:未知错误。'); +} + +function isIcloudApiUrl(url = '') { + const rawUrl = String(url || '').trim(); + if (!rawUrl) { + return false; + } + try { + const parsedUrl = new URL(rawUrl); + if (parsedUrl.protocol !== 'https:') { + return false; + } + const hostname = String(parsedUrl.hostname || '').trim().toLowerCase().replace(/\.$/, ''); + if (!hostname) { + return false; + } + return hostname === 'icloud.com' + || hostname.endsWith('.icloud.com') + || hostname === 'icloud.com.cn' + || hostname.endsWith('.icloud.com.cn'); + } catch { + return false; + } +} + +function normalizeIcloudServiceUrl(rawUrl = '') { + const value = String(rawUrl || '').trim(); + if (!value) { + return ''; + } + try { + const parsedUrl = new URL(value); + if ((parsedUrl.protocol === 'https:' && parsedUrl.port === '443') + || (parsedUrl.protocol === 'http:' && parsedUrl.port === '80')) { + parsedUrl.port = ''; + } + return parsedUrl.toString().replace(/\/$/, ''); + } catch { + return value.replace(/\/$/, ''); + } +} + +function rememberIcloudServiceUrl(rawUrl = '') { + const normalized = normalizeIcloudServiceUrl(rawUrl); + if (normalized) { + lastResolvedIcloudServiceUrl = normalized; + } + return normalized; +} + +function isIcloudMaildomainwsHost(rawHost = '') { + const host = String(rawHost || '').trim().toLowerCase().replace(/\.$/, ''); + if (!host) { + return false; + } + return host.endsWith('maildomainws.icloud.com') || host.endsWith('maildomainws.icloud.com.cn'); +} + +function appendIcloudClientQueryParams(rawUrl = '') { + const input = String(rawUrl || '').trim(); + if (!input) { + return ''; + } + try { + const parsed = new URL(input); + if (!isIcloudMaildomainwsHost(parsed.hostname)) { + return input; + } + + if (!parsed.searchParams.has('clientBuildNumber')) { + parsed.searchParams.set('clientBuildNumber', ICLOUD_MAILDOMAINWS_CLIENT_BUILD_NUMBER); + } + if (!parsed.searchParams.has('clientMasteringNumber')) { + parsed.searchParams.set('clientMasteringNumber', ICLOUD_MAILDOMAINWS_CLIENT_BUILD_NUMBER); + } + if (!parsed.searchParams.has('clientId')) { + parsed.searchParams.set('clientId', ''); + } + if (!parsed.searchParams.has('dsid')) { + parsed.searchParams.set('dsid', ''); + } + return parsed.toString(); + } catch { + return input; + } +} + +function isIcloudMailPageUrl(rawUrl = '') { + try { + const parsedUrl = new URL(String(rawUrl || '').trim()); + if (!normalizeIcloudHost(parsedUrl.hostname)) { + return false; + } + const pathname = String(parsedUrl.pathname || '').toLowerCase(); + return pathname === '/mail' || pathname.startsWith('/mail/'); + } catch { + return false; + } +} + +async function waitForIcloudMailTabReady(tabId, timeoutMs = 8000) { + if (!Number.isInteger(tabId)) { + return false; + } + const deadline = Date.now() + Math.max(500, Number(timeoutMs) || 8000); + while (Date.now() < deadline) { + try { + const tab = await chrome.tabs.get(tabId); + const status = String(tab?.status || ''); + if (isIcloudMailPageUrl(tab?.url) && status === 'complete') { + return true; + } + } catch { + return false; + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + return false; +} + +async function ensureIcloudMailContextTab(tabs = [], targetHost = '', preferredHost = '') { + const tabList = Array.isArray(tabs) ? tabs : []; + if (tabList.some((tab) => isIcloudMailPageUrl(tab?.url))) { + return tabList; + } + + const fallbackHost = targetHost + || preferredHost + || await getOpenIcloudHostPreference() + || 'icloud.com'; + const fallbackMailUrl = getIcloudMailUrlForHost(fallbackHost) || getIcloudMailUrlForHost('icloud.com'); + if (!fallbackMailUrl) { + return tabList; + } + + try { + const created = await chrome.tabs.create({ url: fallbackMailUrl, active: false }); + await waitForIcloudMailTabReady(created?.id, 9000); + } catch {} + + try { + return await chrome.tabs.query({ + url: ICLOUD_TAB_URL_PATTERNS, + }); + } catch { + return tabList; + } +} + +function shouldTryIcloudRequestPageContextFallback(url, status, errorMessage = '') { + if (!isIcloudApiUrl(url)) { + return false; + } + + const normalizedStatus = Number(status) || 0; + if (normalizedStatus === 401 + || normalizedStatus === 403 + || normalizedStatus === 409 + || normalizedStatus === 421 + || normalizedStatus === 429 + || normalizedStatus >= 500) { + return true; + } + + const message = String(errorMessage || '').toLowerCase(); + return message.includes('failed to fetch') + || message.includes('network request failed') + || message.includes('networkerror') + || message.includes('timed out') + || message.includes('timeout') + || message.includes('cors') + || message.includes('address space'); +} + +async function icloudRequestViaPageContext(method, url, options = {}) { + const { + data, + contentType = '', + } = options; + const state = await getState(); + const targetHost = normalizeIcloudHost(new URL(url).hostname); + const preferredHost = normalizeIcloudHost(state?.preferredIcloudHost); + + let tabs = await chrome.tabs.query({ + url: ICLOUD_TAB_URL_PATTERNS, + }); + tabs = await ensureIcloudMailContextTab(tabs, targetHost, preferredHost); + if (!tabs.length) { + throw new Error('page_context:no_icloud_tab'); + } + + const sortedTabs = [...tabs].sort((left, right) => { + const score = (tab) => { + let tabHost = ''; + try { + tabHost = normalizeIcloudHost(new URL(String(tab?.url || '')).hostname); + } catch {} + return (isIcloudMailPageUrl(tab?.url) ? 8 : 0) + + (tab?.active ? 4 : 0) + + (tabHost && tabHost === targetHost ? 2 : 0) + + (tabHost && tabHost === preferredHost ? 1 : 0); + }; + return score(right) - score(left); + }); + const mailTabs = sortedTabs.filter((tab) => isIcloudMailPageUrl(tab?.url)); + const candidateTabs = mailTabs.length ? mailTabs : sortedTabs; + + const errors = []; + for (const tab of candidateTabs) { + if (!Number.isInteger(tab?.id)) { + continue; + } + try { + const injections = await chrome.scripting.executeScript({ + target: { tabId: tab.id, allFrames: false }, + world: 'MAIN', + func: async (requestConfig) => { + const timeoutMs = 15000; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + try { + const headers = requestConfig.hasData + ? { 'Content-Type': requestConfig.contentType || 'application/json' } + : undefined; + const response = await fetch(requestConfig.url, { + method: requestConfig.method, + credentials: 'include', + cache: 'no-store', + mode: 'cors', + headers, + body: requestConfig.hasData ? JSON.stringify(requestConfig.data) : undefined, + signal: controller.signal, + }); + const text = await response.text(); + return { + ok: Boolean(response.ok), + status: Number(response.status) || 0, + text, + error: '', + }; + } catch (err) { + return { + ok: false, + status: 0, + text: '', + error: String(err?.message || err || 'unknown error'), + }; + } finally { + clearTimeout(timeoutId); + } + }, + args: [{ + method, + url, + hasData: data !== undefined, + data: data === undefined ? null : data, + contentType: contentType || '', + }], + }); + + const result = injections?.[0]?.result || null; + if (!result) { + throw new Error('empty result'); + } + if (!result.ok) { + if (result.status) { + throw new Error(`status ${result.status}`); + } + throw new Error(result.error || 'page context request failed'); + } + + if (!String(result.text || '').trim()) { + return {}; + } + + try { + return JSON.parse(result.text); + } catch (parseErr) { + throw new Error(`invalid json: ${getErrorMessage(parseErr)}`); + } + } catch (err) { + errors.push(`tab_${tab.id}:${getErrorMessage(err)}`); + } + } + + throw new Error(errors.length ? errors.join(' | ') : 'page_context:unknown'); } function getIcloudRequestTargetLabel(rawUrl) { @@ -3628,6 +4116,19 @@ async function icloudRequest(method, url, options = {}) { retryLabel = '', logRetries = false, } = options; + const requestUrl = appendIcloudClientQueryParams(url); + const requestContentType = (() => { + if (data === undefined) { + return ''; + } + try { + return isIcloudMaildomainwsHost(new URL(requestUrl).hostname) + ? 'text/plain;charset=UTF-8' + : 'application/json'; + } catch { + return 'application/json'; + } + })(); let lastError = null; const totalAttempts = Math.max(1, Number(maxAttempts) || 1); @@ -3649,10 +4150,10 @@ async function icloudRequest(method, url, options = {}) { } catch {} }, Math.max(1000, Number(timeoutMs) || ICLOUD_REQUEST_TIMEOUT_MS)); - response = await fetch(url, { + response = await fetch(requestUrl, { method, credentials: 'include', - headers: data !== undefined ? { 'Content-Type': 'application/json' } : undefined, + headers: requestContentType ? { 'Content-Type': requestContentType } : undefined, body: data !== undefined ? JSON.stringify(data) : undefined, signal: controller.signal, }); @@ -3665,8 +4166,8 @@ async function icloudRequest(method, url, options = {}) { const error = new Error( responseText - ? `iCloud 请求失败:${method} ${url},status ${response.status},body: ${responseText}` - : `iCloud 请求失败:${method} ${url},status ${response.status}` + ? `iCloud 请求失败:${method} ${requestUrl},status ${response.status},body: ${responseText}` + : `iCloud 请求失败:${method} ${requestUrl},status ${response.status}` ); error.status = response.status; throw error; @@ -3680,7 +4181,7 @@ async function icloudRequest(method, url, options = {}) { try { return JSON.parse(rawText); } catch (err) { - throw new Error(`iCloud 返回的 JSON 无法解析:${method} ${url},${err.message}`); + throw new Error(`iCloud 返回的 JSON 无法解析:${method} ${requestUrl},${err.message}`); } } catch (err) { if (stopRequested) { @@ -3699,6 +4200,31 @@ async function icloudRequest(method, url, options = {}) { } } + const directErrorMessage = getErrorMessage(requestError) + || `iCloud 请求失败:${method} ${requestUrl}`; + const shouldTryPageContext = shouldTryIcloudRequestPageContextFallback( + requestUrl, + Number(requestError?.status) || 0, + directErrorMessage + ); + if (shouldTryPageContext) { + try { + return await icloudRequestViaPageContext(method, requestUrl, { + data, + contentType: requestContentType || undefined, + }); + } catch (pageContextError) { + const pageContextMessage = getErrorMessage(pageContextError); + if (!pageContextMessage.includes('page_context:no_icloud_tab')) { + const mergedError = new Error(`${directErrorMessage} | page_context:${pageContextMessage}`); + if (requestError?.status) { + mergedError.status = requestError.status; + } + requestError = mergedError; + } + } + } + lastError = requestError; const shouldRetry = attempt < totalAttempts && isIcloudRetryableError(requestError); if (!shouldRetry) { @@ -3708,7 +4234,7 @@ async function icloudRequest(method, url, options = {}) { if (logRetries) { const delayMs = getIcloudRetryDelay(attempt); await addLog( - `iCloud:${retryLabel || getIcloudRequestTargetLabel(url)} 第 ${attempt}/${totalAttempts} 次失败:${getErrorMessage(requestError)},${Math.round(delayMs / 1000)} 秒后重试...`, + `iCloud:${retryLabel || getIcloudRequestTargetLabel(requestUrl)} 第 ${attempt}/${totalAttempts} 次失败:${getErrorMessage(requestError)},${Math.round(delayMs / 1000)} 秒后重试...`, 'warn' ); } @@ -3722,7 +4248,7 @@ async function icloudRequest(method, url, options = {}) { } } - throw lastError || new Error(`iCloud 请求失败:${method} ${url}`); + throw lastError || new Error(`iCloud 请求失败:${method} ${requestUrl}`); } async function validateIcloudSession(setupUrl) { @@ -3733,6 +4259,133 @@ async function validateIcloudSession(setupUrl) { return data; } +function shouldTryIcloudPageContextFallback(errors = []) { + const combinedMessage = String((errors || []).join(' | ')).toLowerCase(); + if (!combinedMessage) { + return false; + } + return combinedMessage.includes('status 401') + || combinedMessage.includes('status 403') + || combinedMessage.includes('status 421') + || combinedMessage.includes('networkerror') + || combinedMessage.includes('network request failed') + || combinedMessage.includes('failed to fetch') + || combinedMessage.includes('timed out') + || combinedMessage.includes('timeout') + || combinedMessage.includes('cors'); +} + +async function validateIcloudSessionViaPageContext(tabId, setupUrl) { + const host = new URL(setupUrl).host; + try { + const injections = await chrome.scripting.executeScript({ + target: { tabId, allFrames: false }, + world: 'MAIN', + func: async (targetSetupUrl) => { + const timeoutMs = 12000; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(`${targetSetupUrl}/validate`, { + method: 'POST', + credentials: 'include', + cache: 'no-store', + mode: 'cors', + signal: controller.signal, + }); + const text = await response.text(); + let data = null; + try { + data = text ? JSON.parse(text) : null; + } catch {} + return { + ok: Boolean(response.ok), + status: Number(response.status) || 0, + data, + error: '', + }; + } catch (err) { + return { + ok: false, + status: 0, + data: null, + error: String(err?.message || err || 'unknown error'), + }; + } finally { + clearTimeout(timeoutId); + } + }, + args: [setupUrl], + }); + + const result = injections?.[0]?.result || null; + if (result?.ok && result?.data?.webservices?.premiummailsettings?.url) { + return { + setupUrl, + serviceUrl: normalizeIcloudServiceUrl(result.data.webservices.premiummailsettings.url), + resolvedBy: 'page_context', + }; + } + + if (result?.status) { + throw new Error(`status ${result.status}`); + } + throw new Error(result?.error || 'page context validate failed'); + } catch (err) { + throw new Error(`${host}: ${getErrorMessage(err)}`); + } +} + +async function resolveIcloudPremiumMailServiceViaPageContext(setupUrls, state) { + const errors = []; + let tabs = []; + try { + tabs = await chrome.tabs.query({ + url: ICLOUD_TAB_URL_PATTERNS, + }); + } catch (err) { + errors.push(`page_context:query_tabs:${getErrorMessage(err)}`); + return { service: null, errors, noTab: false }; + } + + const preferredHost = normalizeIcloudHost(state?.preferredIcloudHost); + tabs = await ensureIcloudMailContextTab(tabs, preferredHost, preferredHost); + if (!tabs.length) { + return { service: null, errors: [], noTab: true }; + } + const sortedTabs = [...tabs].sort((left, right) => { + const leftActive = left?.active ? 1 : 0; + const rightActive = right?.active ? 1 : 0; + if (leftActive !== rightActive) return rightActive - leftActive; + const leftMail = isIcloudMailPageUrl(left?.url) ? 1 : 0; + const rightMail = isIcloudMailPageUrl(right?.url) ? 1 : 0; + if (leftMail !== rightMail) return rightMail - leftMail; + let leftHost = ''; + let rightHost = ''; + try { leftHost = normalizeIcloudHost(new URL(String(left?.url || '')).host); } catch {} + try { rightHost = normalizeIcloudHost(new URL(String(right?.url || '')).host); } catch {} + const leftPreferred = leftHost && leftHost === preferredHost ? 1 : 0; + const rightPreferred = rightHost && rightHost === preferredHost ? 1 : 0; + return rightPreferred - leftPreferred; + }); + + for (const tab of sortedTabs) { + if (!Number.isInteger(tab?.id)) { + continue; + } + for (const setupUrl of setupUrls) { + try { + const service = await validateIcloudSessionViaPageContext(tab.id, setupUrl); + return { service, errors }; + } catch (err) { + errors.push(`page_context:tab_${tab.id}:${getErrorMessage(err)}`); + } + } + } + + return { service: null, errors, noTab: false }; +} + async function resolveIcloudPremiumMailService(options = {}) { const errors = []; const state = await getState(); @@ -3753,13 +4406,35 @@ async function resolveIcloudPremiumMailService(options = {}) { } return { setupUrl, - serviceUrl: String(data.webservices.premiummailsettings.url || '').replace(/\/$/, ''), + serviceUrl: rememberIcloudServiceUrl(data.webservices.premiummailsettings.url), }; } catch (err) { errors.push(`${new URL(setupUrl).host}: ${getErrorMessage(err)}`); } } + if (shouldTryIcloudPageContextFallback(errors)) { + const { + service, + errors: pageContextErrors, + noTab: pageContextNoTab = false, + } = await resolveIcloudPremiumMailServiceViaPageContext(setupUrls, state); + if (service) { + const preferredIcloudHost = normalizeIcloudHost(new URL(service.setupUrl).host); + if (preferredIcloudHost && preferredIcloudHost !== normalizeIcloudHost(state.preferredIcloudHost)) { + await setState({ preferredIcloudHost }); + } + await addLog(`iCloud:后台会话校验失败,已切换页面上下文校验(${new URL(service.setupUrl).host})。`, 'warn'); + return { + ...service, + serviceUrl: rememberIcloudServiceUrl(service.serviceUrl), + }; + } + if (!pageContextNoTab && Array.isArray(pageContextErrors) && pageContextErrors.length) { + errors.push(...pageContextErrors); + } + } + throw new Error(errors.length ? `Could not validate iCloud session. ${errors.join(' | ')}` : 'Could not validate iCloud session. 请先在当前浏览器登录 icloud.com.cn 或 icloud.com。'); @@ -3831,10 +4506,49 @@ async function loadNormalizedIcloudAliases(options = {}) { } async function listIcloudAliases(options = {}) { - return withIcloudLoginHelp('加载 iCloud 隐私邮箱列表', async () => { - const { aliases } = await loadNormalizedIcloudAliases({ resolveOptions: options }); - return aliases; - }); + try { + return await withIcloudLoginHelp('加载 iCloud 隐私邮箱列表', async () => { + const { serviceUrl } = await resolveIcloudPremiumMailService(options); + const response = await icloudRequest('GET', `${serviceUrl}/v2/hme/list`); + const state = await getState(); + const aliases = normalizeIcloudAliasList(response, { + usedEmails: getEffectiveUsedEmails(state), + preservedEmails: getPreservedAliasMap(state), + }); + await setState({ + icloudAliasCache: normalizeIcloudAliasCacheList(aliases), + icloudAliasCacheAt: Date.now(), + }); + return aliases; + }); + } catch (err) { + const message = getErrorMessage(err); + const transientContextError = err?.code === 'ICLOUD_TRANSIENT_CONTEXT' + || message.includes('网络/上下文波动'); + if (!transientContextError) { + throw err; + } + const state = await getState(); + const freshCachedAliases = getIcloudAliasCacheFromState(state); + if (freshCachedAliases.length) { + await addLog(`iCloud:加载别名失败,已回退最近缓存(${freshCachedAliases.length} 条)。`, 'warn'); + return freshCachedAliases; + } + + const staleCachedAliases = getIcloudAliasCacheFromState(state, { maxAgeMs: 0 }); + if (staleCachedAliases.length) { + await addLog(`iCloud:加载别名失败,已回退历史缓存(${staleCachedAliases.length} 条)。`, 'warn'); + return staleCachedAliases; + } + + const localFallbackAliases = buildIcloudAliasFallbackFromLocalState(state); + if (localFallbackAliases.length) { + await addLog(`iCloud:加载别名失败,已回退本地别名记录(${localFallbackAliases.length} 条)。`, 'warn'); + return localFallbackAliases; + } + + throw err; + } } async function deleteIcloudAlias(payload) { @@ -3853,7 +4567,18 @@ async function deleteIcloudAlias(payload) { throw new Error(`缺少 ${alias.email} 的 anonymousId,请先刷新 iCloud 别名列表。`); } - const { serviceUrl } = await resolveIcloudPremiumMailService(); + let serviceUrl = ''; + try { + ({ serviceUrl } = await resolveIcloudPremiumMailService()); + } catch (resolveErr) { + const canFallbackToCachedService = isIcloudTransientContextError(resolveErr) + && Boolean(lastResolvedIcloudServiceUrl); + if (!canFallbackToCachedService) { + throw resolveErr; + } + serviceUrl = lastResolvedIcloudServiceUrl; + await addLog(`iCloud:会话校验暂时不可用,已回退最近可用服务节点 ${new URL(serviceUrl).host} 继续删除。`, 'warn'); + } try { const directDelete = await icloudRequest('POST', `${serviceUrl}/v1/hme/delete`, { @@ -3921,18 +4646,19 @@ async function fetchIcloudHideMyEmail(options = {}) { return withIcloudLoginHelp('获取 iCloud 隐私邮箱', async () => { throwIfStopped(); const generateNew = Boolean(options?.generateNew); - await addLog('iCloud:正在校验当前浏览器登录状态...', 'info'); + await addLog('iCloud:正在加载别名列表并校验当前浏览器登录状态...', 'info'); const { serviceUrl, setupUrl } = await resolveIcloudPremiumMailService(); await addLog(`iCloud:已通过 ${new URL(setupUrl).host} 验证会话`, 'ok'); await addLog(`iCloud:当前 Hide My Email 服务节点 ${new URL(serviceUrl).host}`, 'info'); let activeServiceUrl = serviceUrl; - const { aliases: existingAliases, serviceUrl: listServiceUrl } = await loadNormalizedIcloudAliases({ - serviceUrl: activeServiceUrl, - silent: true, - }); - activeServiceUrl = listServiceUrl || activeServiceUrl; + const existingAliases = await listIcloudAliases(); + const existingAliasEmailSet = new Set( + existingAliases + .map((aliasItem) => String(aliasItem?.email || '').trim().toLowerCase()) + .filter(Boolean) + ); if (!generateNew) { const reusableAlias = pickReusableIcloudAlias(existingAliases); @@ -3949,90 +4675,140 @@ 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; + 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, + }); } - 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, - }); - } - if (!generated?.success || !generated?.result?.hme) { - throw new Error(generated?.error?.errorMessage || 'iCloud 隐私邮箱生成失败。'); - } + if (!generated?.success || !generated?.result?.hme) { + throw new Error(generated?.error?.errorMessage || 'iCloud 隐私邮箱生成失败。'); + } - const generatedAlias = String(generated.result.hme || '').trim().toLowerCase(); - await addLog(`iCloud:已生成候选别名 ${generatedAlias},正在保留...`, 'info'); + const generatedHmeRaw = generated.result.hme; + const generatedAlias = String( + (typeof generatedHmeRaw === 'string' + ? generatedHmeRaw + : generatedHmeRaw?.hme + || generatedHmeRaw?.email + || generatedHmeRaw?.alias + || generatedHmeRaw?.address + || '') + ).trim().toLowerCase(); + if (!generatedAlias) { + throw new Error('iCloud 隐私邮箱生成失败:未返回可用别名。'); + } + 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, - }); + const reserveData = { + ...(generatedHmeRaw && typeof generatedHmeRaw === 'object' && !Array.isArray(generatedHmeRaw) + ? generatedHmeRaw + : {}), + hme: generatedAlias, + label: getIcloudAliasLabel(), + note: 'Generated through Multi-Page Automation', + }; + + let alias = ''; + try { + const reserved = await icloudRequest('POST', `${activeServiceUrl}/v1/hme/reserve`, { + data: reserveData, + timeoutMs: ICLOUD_REQUEST_TIMEOUT_MS, + maxAttempts: 1, + }); + + if (!reserved?.success || !reserved?.result?.hme?.hme) { + throw new Error(reserved?.error?.errorMessage || 'iCloud 隐私邮箱保留失败。'); + } + + alias = String(reserved.result.hme.hme || '').trim().toLowerCase(); + } catch (reserveErr) { + const reserveErrMessage = getErrorMessage(reserveErr); + const shouldTryListFallback = isIcloudRetryableError(reserveErr) + || /\bstatus (?:401|403|409)\b/i.test(reserveErrMessage) + || /failed to fetch/i.test(reserveErrMessage); + if (!shouldTryListFallback) { + throw reserveErr; + } + + await addLog('iCloud:保留别名返回鉴权/网络异常,正在回查别名列表确认是否已创建...', 'warn'); + const { aliases: aliasesAfterReserveFailure, serviceUrl: refreshedListServiceUrl } = await loadNormalizedIcloudAliases({ + serviceUrl: activeServiceUrl, + silent: true, + }); + activeServiceUrl = refreshedListServiceUrl || activeServiceUrl; + + let recoveredAlias = findIcloudAliasByEmail(aliasesAfterReserveFailure, generatedAlias); + if (!recoveredAlias) { + recoveredAlias = aliasesAfterReserveFailure.find( + (aliasItem) => !existingAliasEmailSet.has(String(aliasItem?.email || '').trim().toLowerCase()) + ) || null; + } + + if (recoveredAlias?.email) { + alias = String(recoveredAlias.email || '').trim().toLowerCase(); + await addLog(`iCloud:保留请求异常,但已在列表确认别名 ${alias},继续使用。`, 'warn'); + } else if (isIcloudRetryableError(reserveErr)) { + await addLog(`iCloud:列表中尚未出现 ${generatedAlias},正在刷新服务节点后重试保留一次...`, 'warn'); + const refreshedService = await resolveIcloudPremiumMailService(); + activeServiceUrl = refreshedService.serviceUrl; + const reservedRetry = await icloudRequest('POST', `${activeServiceUrl}/v1/hme/reserve`, { + data: reserveData, + timeoutMs: ICLOUD_REQUEST_TIMEOUT_MS, + maxAttempts: 1, + }); + if (!reservedRetry?.success || !reservedRetry?.result?.hme?.hme) { + throw new Error(reservedRetry?.error?.errorMessage || 'iCloud 隐私邮箱保留失败。'); + } + alias = String(reservedRetry.result.hme.hme || '').trim().toLowerCase(); + } else { + alias = generatedAlias; + await addLog(`iCloud:保留请求异常,已回退使用生成别名 ${alias}。`, 'warn'); + } + } + + await setEmailState(alias); + await addLog(`iCloud:已创建并保留新别名 ${alias}`, 'ok'); + broadcastIcloudAliasesChanged({ reason: 'created', email: alias }); + return alias; } catch (err) { - if (!isIcloudRetryableError(err)) { + if (!shouldStopIcloudAutoFetchRetries(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; + const reusableAlias = pickReusableIcloudAlias(existingAliases); + if (reusableAlias) { + await setEmailState(reusableAlias.email); + await addLog( + `iCloud:当前网络/上下文波动,暂无法创建新别名,已临时回退复用 ${reusableAlias.email}。`, + 'warn' + ); + broadcastIcloudAliasesChanged({ reason: 'selected', email: reusableAlias.email }); + return reusableAlias.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, - }); + throw new Error( + `iCloud 当前无法创建新别名:${getErrorMessage(err)}。请先确认 iCloud 页面已登录且网络可访问,再重试。` + ); } - - if (!reserved?.success || !reserved?.result?.hme?.hme) { - throw new Error(reserved?.error?.errorMessage || 'iCloud 隐私邮箱保留失败。'); - } - - const alias = String(reserved.result.hme.hme || '').trim().toLowerCase(); - await setEmailState(alias); - await addLog(`iCloud:已创建并保留新别名 ${alias}`, 'ok'); - broadcastIcloudAliasesChanged({ reason: 'created', email: alias }); - return alias; }); } @@ -4080,7 +4856,11 @@ async function finalizeIcloudAliasAfterSuccessfulFlow(state) { await addLog(`iCloud:流程成功后已自动删除 ${email}。`, 'ok'); return { handled: true, deleted: true }; } catch (err) { - await addLog(`iCloud:自动删除 ${email} 失败:${getErrorMessage(err)}`, 'warn'); + if (isIcloudTransientContextError(err)) { + await addLog(`iCloud:自动删除 ${email} 暂时跳过(网络/上下文波动),可稍后手动删除。`, 'info'); + } else { + await addLog(`iCloud:自动删除 ${email} 失败:${getErrorMessage(err)}`, 'warn'); + } return { handled: true, deleted: false }; } } @@ -6582,6 +7362,42 @@ async function resumeAutoRunIfWaitingForEmail(options = {}) { return false; } +function shouldStopIcloudAutoFetchRetries(error) { + if (!error) { + return false; + } + + if (error.code === 'ICLOUD_TRANSIENT_CONTEXT') { + return true; + } + + const message = getErrorMessage(error).toLowerCase(); + if (message.includes('请先在新打开的 icloud 页面中完成登录')) { + return true; + } + return message.includes('网络/上下文波动') + || message.includes('could not validate icloud session') + || message.includes('status 421') + || message.includes('failed to fetch') + || message.includes('network request failed') + || message.includes('networkerror') + || message.includes('cors') + || message.includes('address space') + || message.includes('timed out') + || message.includes('timeout'); +} + +function shouldStopEmailAutoFetchRetries(generator, error) { + if (generator === 'icloud' && shouldStopIcloudAutoFetchRetries(error)) { + return true; + } + const message = String(error?.message || ''); + if (generator === 'cloudflare' && /域名/.test(message)) { + return true; + } + return generator === CLOUDFLARE_TEMP_EMAIL_GENERATOR && /(服务地址|Admin Auth|域名)/.test(message); +} + async function ensureAutoEmailReady(targetRun, totalRuns, attemptRuns) { const currentState = await getState(); if (isHotmailProvider(currentState)) { @@ -6667,7 +7483,9 @@ async function ensureAutoEmailReady(targetRun, totalRuns, attemptRuns) { const generator = normalizeEmailGenerator(currentState.emailGenerator); const generatorLabel = getEmailGeneratorLabel(generator); let lastError = null; + let attemptedFetches = 0; for (let attempt = 1; attempt <= EMAIL_FETCH_MAX_ATTEMPTS; attempt++) { + attemptedFetches = attempt; try { if (attempt > 1) { await addLog(`${generatorLabel}:正在进行第 ${attempt}/${EMAIL_FETCH_MAX_ATTEMPTS} 次自动获取重试...`, 'warn'); @@ -6684,16 +7502,17 @@ async function ensureAutoEmailReady(targetRun, totalRuns, attemptRuns) { } catch (err) { lastError = err; await addLog(`${generatorLabel}自动获取失败(${attempt}/${EMAIL_FETCH_MAX_ATTEMPTS}):${err.message}`, 'warn'); - if ( - (generator === 'cloudflare' && /域名/.test(String(err.message || ''))) - || (generator === CLOUDFLARE_TEMP_EMAIL_GENERATOR && /(服务地址|Admin Auth|域名)/.test(String(err.message || ''))) - ) { + if (generator === 'icloud' && shouldStopIcloudAutoFetchRetries(err)) { + await addLog('iCloud:检测到会话/网络异常,本轮将停止重复重试。请先确认 iCloud 页面已登录,再点击“我已登录”或手动粘贴邮箱继续。', 'warn'); + } + if (shouldStopEmailAutoFetchRetries(generator, err)) { break; } } } - await addLog(`${generatorLabel}自动获取已连续失败 ${EMAIL_FETCH_MAX_ATTEMPTS} 次:${lastError?.message || '未知错误'}`, 'error'); + const totalAttempts = Math.max(1, attemptedFetches); + await addLog(`${generatorLabel}自动获取已连续失败 ${totalAttempts} 次:${lastError?.message || '未知错误'}`, 'error'); await addLog(`=== 目标 ${targetRun}/${totalRuns} 轮已暂停:请先自动获取邮箱或手动粘贴邮箱,然后继续 ===`, 'warn'); await broadcastAutoRunStatus('waiting_email', { currentRun: targetRun, @@ -6816,7 +7635,9 @@ async function ensureAutoEmailReady(targetRun, totalRuns, attemptRuns) { const generator = normalizeEmailGenerator(currentState.emailGenerator); const generatorLabel = getEmailGeneratorLabel(generator); let lastError = null; + let attemptedFetches = 0; for (let attempt = 1; attempt <= EMAIL_FETCH_MAX_ATTEMPTS; attempt++) { + attemptedFetches = attempt; try { if (attempt > 1) { await addLog(`${generatorLabel}:正在进行第 ${attempt}/${EMAIL_FETCH_MAX_ATTEMPTS} 次自动获取重试...`, 'warn'); @@ -6833,16 +7654,17 @@ async function ensureAutoEmailReady(targetRun, totalRuns, attemptRuns) { } catch (err) { lastError = err; await addLog(`${generatorLabel}自动获取失败(${attempt}/${EMAIL_FETCH_MAX_ATTEMPTS}):${err.message}`, 'warn'); - if ( - (generator === 'cloudflare' && /域名/.test(String(err.message || ''))) - || (generator === CLOUDFLARE_TEMP_EMAIL_GENERATOR && /(服务地址|Admin Auth|域名)/.test(String(err.message || ''))) - ) { + if (generator === 'icloud' && shouldStopIcloudAutoFetchRetries(err)) { + await addLog('iCloud:检测到会话/网络异常,本轮将停止重复重试。请先确认 iCloud 页面已登录,再点击“我已登录”或手动粘贴邮箱继续。', 'warn'); + } + if (shouldStopEmailAutoFetchRetries(generator, err)) { break; } } } - await addLog(`${generatorLabel}自动获取已连续失败 ${EMAIL_FETCH_MAX_ATTEMPTS} 次:${lastError?.message || '未知错误'}`, 'error'); + const totalAttempts = Math.max(1, attemptedFetches); + await addLog(`${generatorLabel}自动获取已连续失败 ${totalAttempts} 次:${lastError?.message || '未知错误'}`, 'error'); await addLog(`=== 目标 ${targetRun}/${totalRuns} 轮已暂停:请先自动获取邮箱或手动粘贴邮箱,然后继续 ===`, 'warn'); await broadcastAutoRunStatus('waiting_email', { currentRun: targetRun, diff --git a/icloud-utils.js b/icloud-utils.js index 35f1f14..0bd66fc 100644 --- a/icloud-utils.js +++ b/icloud-utils.js @@ -7,10 +7,17 @@ root.IcloudUtils = factory(); })(typeof self !== 'undefined' ? self : globalThis, function createIcloudUtils() { function normalizeIcloudHost(rawHost) { - const host = String(rawHost || '').trim().toLowerCase(); + let host = String(rawHost || '').trim().toLowerCase(); if (!host) return ''; - if (host === 'icloud.com' || host === 'www.icloud.com' || host === 'setup.icloud.com') return 'icloud.com'; - if (host === 'icloud.com.cn' || host === 'www.icloud.com.cn' || host === 'setup.icloud.com.cn') return 'icloud.com.cn'; + try { + if (host.includes('://')) { + host = new URL(host).hostname.toLowerCase(); + } + } catch {} + host = host.split('/')[0].split('?')[0].split('#')[0]; + host = host.replace(/:\d+$/, '').replace(/\.$/, ''); + if (host === 'icloud.com' || host.endsWith('.icloud.com')) return 'icloud.com'; + if (host === 'icloud.com.cn' || host.endsWith('.icloud.com.cn')) return 'icloud.com.cn'; return ''; } diff --git a/sidepanel/icloud-manager.js b/sidepanel/icloud-manager.js index 96c6fca..a7ebe18 100644 --- a/sidepanel/icloud-manager.js +++ b/sidepanel/icloud-manager.js @@ -388,6 +388,19 @@ } } + function isLikelyIcloudLoginRequiredMessage(message = '') { + const lower = String(message || '').toLowerCase(); + return lower.includes('请先在新打开的 icloud 页面中完成登录') + || lower.includes('请先在当前浏览器登录') + || lower.includes('需要先登录') + || lower.includes('请先登录') + || lower.includes('please sign in') + || lower.includes('sign in required') + || lower.includes('not logged in') + || lower.includes('authentication required') + || lower.includes('unauthenticated'); + } + async function handleLoginDone() { if (dom.btnIcloudLoginDone) { dom.btnIcloudLoginDone.disabled = true; @@ -405,7 +418,14 @@ helpers.showToast('iCloud 会话已恢复,别名列表已刷新。', 'success', 2600); await refreshIcloudAliases({ silent: true }); } catch (err) { - helpers.showToast(`看起来还没有登录完成:${err.message}`, 'warn', 4200); + const errorMessage = String(err?.message || '未知错误'); + if (isLikelyIcloudLoginRequiredMessage(errorMessage)) { + helpers.showToast(`看起来还没有登录完成:${errorMessage}`, 'warn', 4200); + return; + } + + await refreshIcloudAliases({ silent: true }).catch(() => { }); + helpers.showToast(`iCloud 会话校验失败(非登录态):${errorMessage}`, 'warn', 4200); } finally { if (dom.btnIcloudLoginDone) { dom.btnIcloudLoginDone.disabled = false; diff --git a/tests/background-icloud-login-help.test.js b/tests/background-icloud-login-help.test.js new file mode 100644 index 0000000..f2eeb2e --- /dev/null +++ b/tests/background-icloud-login-help.test.js @@ -0,0 +1,109 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +test('icloud login helper distinguishes auth-required errors from transient context errors', () => { + const source = fs.readFileSync('background.js', 'utf8'); + + assert.match( + source, + /function isIcloudLoginRequiredError\(error\) \{[\s\S]*hasAuthStatus401[\s\S]*status \(409\|421\|429\|5\\d\\d\)[\s\S]*could not validate icloud session[\s\S]*return false;/m, + 'login-required detection should only force login for auth failures and ignore transient 421/429/5xx statuses' + ); + + assert.match( + source, + /function isIcloudTransientContextError\(error\) \{[\s\S]*status \(401\|403\|409\|421\|429\|5\\d\\d\)[\s\S]*timeout[\s\S]*timed out/m, + 'transient context detection should treat 421/429/5xx and timeout-like network errors as retryable context failures' + ); + + assert.match( + source, + /if \(isIcloudTransientContextError\(err\)\) \{[\s\S]*iCloud 别名加载受网络\/上下文波动影响,请稍后重试。/m, + 'withIcloudLoginHelp should surface transient-context copy instead of forcing login prompt' + ); + + assert.match( + source, + /ICLOUD_TRANSIENT_RETRY_MAX_ATTEMPTS = 2/, + 'icloud transient context handling should retry at least once before failing' + ); + + assert.match( + source, + /function getIcloudAliasCacheFromState\(state, options = \{\}\)/, + 'icloud alias flow should expose cache lookup helper for transient fallback' + ); + + assert.match( + source, + /已回退最近缓存(\$\{[a-zA-Z0-9_]+\.length\} 条)/, + 'icloud alias listing should fallback to cached aliases when transient context errors occur' + ); + + assert.match( + source, + /PERSISTENT_ALIAS_STATE_KEYS = \[[\s\S]*'icloudAliasCache'[\s\S]*'icloudAliasCacheAt'[\s\S]*\]/m, + 'icloud alias cache should be persisted so transient fallback can survive restarts' + ); + + assert.match( + source, + /function shouldStopIcloudAutoFetchRetries\(error\) \{[\s\S]*网络\/上下文波动[\s\S]*status 421/m, + 'icloud auto-fetch retry guard should stop repeated retries for transient session/context failures' + ); + + assert.match( + source, + /async function validateIcloudSessionViaPageContext\(tabId, setupUrl\) \{[\s\S]*world:\s*'MAIN'[\s\S]*\/validate/m, + 'icloud service resolution should support page-context validate fallback when background validate keeps failing' + ); + + assert.match( + source, + /function isIcloudApiUrl\(url = ''\) \{[\s\S]*new URL\(rawUrl\)[\s\S]*hostname\.endsWith\('\.icloud\.com'\)[\s\S]*hostname\.endsWith\('\.icloud\.com\.cn'\)/m, + 'icloud api url detection should match icloud subdomains so maildomainws hosts can trigger page-context fallback' + ); + + assert.match( + source, + /function appendIcloudClientQueryParams\(rawUrl = ''\) \{[\s\S]*clientBuildNumber[\s\S]*clientMasteringNumber[\s\S]*clientId[\s\S]*dsid/m, + 'icloud maildomainws requests should include compatible client query params before sending requests' + ); + + assert.match( + source, + /function isIcloudMailPageUrl\(rawUrl = ''\) \{[\s\S]*pathname === '\/mail'[\s\S]*pathname\.startsWith\('\/mail\/'\)/m, + 'icloud page-context fallback should be able to identify mail pages as preferred execution context' + ); + + assert.match( + source, + /async function waitForIcloudMailTabReady\(tabId, timeoutMs = 8000\) \{[\s\S]*status === 'complete'/m, + 'icloud page-context fallback should wait for a created mail tab to finish loading before using it' + ); + + assert.match( + source, + /async function icloudRequestViaPageContext\(method, url, options = \{\}\) \{[\s\S]*ensureIcloudMailContextTab\([\s\S]*isIcloudMailPageUrl\(tab\?\.url\) \? 8 : 0[\s\S]*const mailTabs = sortedTabs\.filter/m, + 'icloud page-context requests should ensure a mail-context tab exists and prioritize it before other icloud pages' + ); + + assert.match( + source, + /async function fetchIcloudHideMyEmail\(options = \{\}\) \{[\s\S]*const existingAliases = await listIcloudAliases\(\);/m, + 'icloud auto-fetch should load aliases through listIcloudAliases so transient cache/local fallback applies before creation' + ); + + assert.match( + source, + /保留别名返回鉴权\/网络异常,正在回查别名列表确认是否已创建[\s\S]*保留请求异常,但已在列表确认别名/m, + 'icloud auto-fetch should attempt list-confirmation recovery when reserve returns auth/network errors' + ); + + assert.match( + source, + /当前网络\/上下文波动,暂无法创建新别名,已临时回退复用/, + 'icloud auto-fetch should fallback to reusable aliases when create-new fails due transient session/context issues' + ); +}); diff --git a/tests/background-icloud.test.js b/tests/background-icloud.test.js index 0ea7442..9d70ff2 100644 --- a/tests/background-icloud.test.js +++ b/tests/background-icloud.test.js @@ -323,6 +323,18 @@ function getErrorMessage(error) { function normalizeText(value) { return String(value || '').replace(/\\s+/g, ' ').trim(); } +function appendIcloudClientQueryParams(url) { + return String(url || ''); +} +function isIcloudMaildomainwsHost() { + return false; +} +function shouldTryIcloudRequestPageContextFallback() { + return false; +} +async function icloudRequestViaPageContext() { + throw new Error('not expected'); +} async function fetch() { fetchCalls += 1; if (fetchCalls === 1) { @@ -381,6 +393,18 @@ function getErrorMessage(error) { function normalizeText(value) { return String(value || '').replace(/\\s+/g, ' ').trim(); } +function appendIcloudClientQueryParams(url) { + return String(url || ''); +} +function isIcloudMaildomainwsHost() { + return false; +} +function shouldTryIcloudRequestPageContextFallback() { + return false; +} +async function icloudRequestViaPageContext() { + throw new Error('not expected'); +} async function fetch() { fetchCalls += 1; return { @@ -451,6 +475,10 @@ async function loadNormalizedIcloudAliases() { serviceUrl: 'https://p67-maildomainws.icloud.com', }; } +async function listIcloudAliases() { + const response = await loadNormalizedIcloudAliases(); + return response.aliases || []; +} function pickReusableIcloudAlias() { return null; } @@ -477,6 +505,9 @@ function broadcastIcloudAliasesChanged(payload) { function findIcloudAliasByEmail(aliases, email) { return (aliases || []).find((alias) => String(alias.email || '').toLowerCase() === String(email || '').toLowerCase()) || null; } +function shouldStopIcloudAutoFetchRetries() { + return true; +} ${bundle} return { fetchIcloudHideMyEmail, @@ -491,5 +522,5 @@ return { 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'), /按保留成功处理/); + assert.match(api.readLogs().map((entry) => entry.message).join('\n'), /已在列表确认别名/); }); diff --git a/tests/icloud-utils.test.js b/tests/icloud-utils.test.js index 8c745b8..0d10114 100644 --- a/tests/icloud-utils.test.js +++ b/tests/icloud-utils.test.js @@ -20,6 +20,8 @@ const { test('normalizeIcloudHost and host preference helpers resolve supported hosts', () => { assert.equal(normalizeIcloudHost('www.icloud.com'), 'icloud.com'); assert.equal(normalizeIcloudHost('setup.icloud.com.cn'), 'icloud.com.cn'); + assert.equal(normalizeIcloudHost('setup.icloud.com:443'), 'icloud.com'); + assert.equal(normalizeIcloudHost('https://setup.icloud.com.cn/setup/ws/1'), 'icloud.com.cn'); assert.equal(normalizeIcloudHost('example.com'), ''); assert.equal(getConfiguredIcloudHostPreference({ icloudHostPreference: 'icloud.com' }), 'icloud.com'); diff --git a/项目完整链路说明.md b/项目完整链路说明.md index 27120f0..65ce224 100644 --- a/项目完整链路说明.md +++ b/项目完整链路说明.md @@ -170,6 +170,7 @@ - Cloudflare / Temp Email 设置 - 接码开关,以及 HeroSMS 的 API Key 与默认国家设置 - iCloud 相关偏好:Host、获取策略、成功后自动删除、目标邮箱类型与转发收码邮箱 provider +- iCloud Hide My Email 别名缓存:`icloudAliasCache` / `icloudAliasCacheAt`,用于在 iCloud 会话或网络上下文短暂波动时回退展示最近可用列表 - LuckMail API 配置 - 自动运行默认配置 - 账号运行历史 `accountRunHistory`(以邮箱为主键,保存该邮箱最近一次状态:成功/失败/停止) @@ -745,11 +746,22 @@ Plus 模式可见步骤: - `icloudTargetMailboxType = icloud-inbox` 时,Step 4 / Step 8 直接打开 iCloud Mail 收件箱轮询验证码。 - `icloudTargetMailboxType = forward-mailbox` 时,注册邮箱仍由 iCloud Hide My Email 生成,但 Step 4 / Step 8 改为打开 `icloudForwardMailProvider` 指定的转发目标邮箱收码;当前支持 `qq / 163 / 163-vip / 126 / gmail`。 +Hide My Email 获取与管理链路: + +1. 后台优先通过 `setup.icloud.com*/setup/ws/1/validate` 解析 `premiummailsettings.url`,并记录最近可用服务节点。 +2. 如果后台请求受 401 / 403 / 409 / 421 / 429 / 5xx、CORS、超时或地址空间限制影响,会优先尝试在已打开或自动创建的 `www.icloud.com*/mail/` 页面主上下文中重放请求。 +3. `maildomainws` 请求会补充 `clientBuildNumber / clientMasteringNumber / clientId / dsid` 查询参数,写请求使用 `text/plain;charset=UTF-8`,以兼容 iCloud 网页端接口。 +4. 别名列表加载成功后会缓存到 `icloudAliasCache`;遇到短暂上下文波动时,列表展示会按“最近缓存 -> 历史缓存 -> 本地已用/保留记录”回退。 +5. 生成新别名后若 `reserve` 返回鉴权或网络异常,会先回查列表确认候选别名是否已经创建;必要时刷新服务节点重试一次,最后才进入可复用别名回退或暂停等待用户处理。 +6. 自动运行获取 iCloud 别名时,检测到会话或网络上下文异常后会停止重复消耗获取重试次数,转入等待邮箱状态,避免连续请求放大 iCloud 风控或上下文抖动。 + 维护约定: - iCloud 转发目标邮箱 provider 的 label、URL、source、inject 配置统一由 `mail-provider-utils.js` 输出。 - `background.js` 只根据 `icloudTargetMailboxType` 选择 iCloud Mail 收件箱或共享转发邮箱配置,不再在主文件内硬编码各 provider 地址。 - `sidepanel/sidepanel.js` 只负责展示、保存和回显目标邮箱类型 / 转发邮箱 provider,不重复定义 provider 业务清单。 +- iCloud 登录态提示必须区分“真实未登录”和“已登录但请求上下文波动”;不能把所有 401 / 403 / 421 都直接提示为未登录。 +- iCloud 别名缓存只用于短暂失败回退,不应改变最终的已用 / 保留状态来源;状态仍以 `manualAliasUsage`、`preservedAliases` 与最新线上列表合并后的结果为准。 ## 8. 自动运行完整链路 diff --git a/项目开发规范(AI协作).md b/项目开发规范(AI协作).md index 65f006c..4a09168 100644 --- a/项目开发规范(AI协作).md +++ b/项目开发规范(AI协作).md @@ -142,6 +142,13 @@ - 贡献模式的侧栏按钮、状态展示和轮询调度应优先收敛到独立 manager,例如 `sidepanel/contribution-mode.js` - 如果服务端当前返回“无需手动提交 callback”,扩展端必须把它当兼容成功态处理,不能简单按 HTTP 非 200 直接视为失败 +### 3.4.1 iCloud Hide My Email 维护补充 + +- iCloud Hide My Email 的会话校验、页面上下文回退、别名缓存回退和 `maildomainws` 兼容参数属于同一条链路;修改其中任一环时,必须同步检查列表加载、别名生成、保留、删除、自动运行等待邮箱和 sidepanel 登录提示。 +- iCloud 登录态判断不能只看单个 HTTP 状态码。`401 / 403 / 421 / 429 / 5xx` 可能来自后台请求上下文、CORS 或服务节点波动,必须结合错误文案、页面上下文回退结果和用户提示文案判断。 +- iCloud 别名缓存只能作为短暂失败回退,不允许替代线上列表成为最终状态来源;已用和保留状态仍然以 `manualAliasUsage`、`preservedAliases` 与最新线上列表合并后的结果为准。 +- 如果新增 iCloud 相关回退路径,必须补覆盖登录提示、缓存回退、自动运行停止重试和 reserve 异常恢复的测试,并同步更新 [项目完整链路说明.md](c:/Users/projectf/Downloads/codex注册扩展/项目完整链路说明.md)。 + ## 4. 测试规范 ### 4.1 原则 diff --git a/项目文件结构说明.md b/项目文件结构说明.md index 6122bdf..41ea51a 100644 --- a/项目文件结构说明.md +++ b/项目文件结构说明.md @@ -174,6 +174,7 @@ - `tests/background-custom-email-pool.test.js`:测试后台对自定义邮箱池生成方式,以及自定义邮箱服务号池的归一化、标签文案和按轮次取邮箱逻辑。 - `tests/background-generated-email-module.test.js`:测试生成邮箱辅助模块已接入且导出工厂,并覆盖 2925 receive 模式回退普通邮箱生成、自定义邮箱池按轮次取值、2925 provide 模式账号池预热,以及 iCloud `always_new` 传参。 - `tests/background-icloud.test.js`:测试 iCloud 相关后台纯函数与别名收尾逻辑。 +- `tests/background-icloud-login-help.test.js`:测试 iCloud 登录判定、页面上下文回退、别名缓存回退和保留异常恢复的关键源码约束。 - `tests/background-icloud-mail-provider.test.js`:测试 iCloud 邮箱 provider 配置解析,并覆盖转发收码模式复用共享 provider 配置。 - `tests/background-logging-status-module.test.js`:测试日志 / 状态模块已接入且导出工厂。 - `tests/background-luckmail.test.js`:测试 LuckMail 相关后台逻辑,如购买、复用、标记已用与重置。 @@ -194,6 +195,7 @@ - `tests/background-step-registry.test.js`:测试后台步骤注册表和共享步骤定义已接入。 - `tests/background-tab-runtime-module.test.js`:测试标签运行时模块已接入且导出工厂,并覆盖等待标签完成、等待标签稳定完成,以及等待过程中的 Stop 中断行为。 - `tests/background-verification-flow-module.test.js`:测试验证码流程模块已接入且导出工厂。 +- `tests/phone-auth-country-match.test.js`:测试认证页手机号国家选择在页面本地化时,仍能用 HeroSMS 的英文国家名匹配对应国家选项。 - `tests/phone-verification-flow.test.js`:测试手机号验证共享流程对 HeroSMS 的取号、复用、重发、换号与 Step 7 重开错误分支。 - `tests/plus-checkout-billing-tab-resolution.test.js`:测试 Plus 第 7 步可直接接管当前 checkout 标签页,并把 PayPal、账单地址、Google 地址推荐和订阅按钮操作路由到对应 Stripe iframe。 - `tests/cloudflare-temp-email-provider.test.js`:测试 Cloudflare Temp Email provider 的轮询与目标邮箱选择逻辑。