Improve iCloud Hide My Email request resilience

This commit is contained in:
Codex Assistant
2026-04-26 13:49:31 +08:00
parent 89644a0b30
commit ccd93cf5c1
2 changed files with 483 additions and 40 deletions
+280 -40
View File
@@ -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));
+203
View File
@@ -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'), /按保留成功处理/);
});