From c3423b21ab72319c6dbe13fd4d4ce6e569f38832 Mon Sep 17 00:00:00 2001 From: QLHazyCoder <2825305047@qq.com> Date: Tue, 12 May 2026 03:49:22 +0800 Subject: [PATCH] Fix add-email preserve identity email state flow --- background.js | 75 +++++++++- background/cloudmail-provider.js | 14 +- background/generated-email-helpers.js | 39 ++++- background/registration-email-state.js | 41 ++++++ background/signup-flow-helpers.js | 12 +- .../background-generated-email-module.test.js | 130 ++++++++++++++++- tests/background-icloud-preserve.test.js | 136 ++++++++++++++++++ ...ackground-registration-email-state.test.js | 77 ++++++++++ .../background-signup-step2-branching.test.js | 66 +++++++++ tests/cloudmail-provider.test.js | 74 ++++++++++ 项目完整链路说明.md | 13 +- 项目文件结构说明.md | 19 +-- 12 files changed, 670 insertions(+), 26 deletions(-) create mode 100644 tests/background-icloud-preserve.test.js diff --git a/background.js b/background.js index 184d27e..c084731 100644 --- a/background.js +++ b/background.js @@ -255,6 +255,24 @@ function getRegistrationEmailBaseline(state = {}, options = {}) { return preferredEmail || currentState.current || currentState.previous || fallbackEmail || ''; } +function buildFlowRegistrationEmailStateUpdates(state = {}, options = {}) { + if (registrationEmailStateHelpers?.buildFlowRegistrationEmailStateUpdates) { + return registrationEmailStateHelpers.buildFlowRegistrationEmailStateUpdates(state, options); + } + return buildRegistrationEmailStateUpdates(state, options); +} + +function getPreservedPhoneIdentity(state = {}) { + if (registrationEmailStateHelpers?.getPreservedPhoneIdentity) { + return registrationEmailStateHelpers.getPreservedPhoneIdentity(state); + } + return null; +} + +function statePatchHasChanges(state = {}, patch = {}) { + return Object.keys(patch).some((key) => JSON.stringify(state?.[key] ?? null) !== JSON.stringify(patch[key] ?? null)); +} + const LOG_PREFIX = '[MultiPage:bg]'; const DUCK_AUTOFILL_URL = 'https://duckduckgo.com/email/settings/autofill'; const ICLOUD_SETUP_URLS = [ @@ -2234,6 +2252,7 @@ const cloudMailProvider = self.MultiPageBackgroundCloudMailProvider.createCloudM normalizeCloudMailDomain, normalizeCloudMailDomains, normalizeCloudMailMailApiMessages, + persistRegistrationEmailState, pickVerificationMessageWithTimeFallback, setEmailState, setPersistentSettings, @@ -3009,6 +3028,44 @@ async function setEmailState(email, options = {}) { } } +async function persistRegistrationEmailState(state = null, email, options = {}) { + const currentState = state && typeof state === 'object' && !Array.isArray(state) + ? state + : await getState(); + const normalizedEmail = String(email || '').trim() || null; + const currentEmail = String(currentState?.email || '').trim() || null; + if (!Boolean(options?.preserveAccountIdentity)) { + if (normalizedEmail === currentEmail) { + return; + } + await setEmailState(normalizedEmail, options); + return; + } + + const updates = normalizedEmail === currentEmail + ? (() => { + const preservedPhoneIdentity = getPreservedPhoneIdentity(currentState); + return preservedPhoneIdentity + ? { + phoneNumber: '', + ...preservedPhoneIdentity, + } + : {}; + })() + : buildFlowRegistrationEmailStateUpdates(currentState, { + currentEmail: normalizedEmail, + preservePrevious: Boolean(options?.preservePrevious), + preserveAccountIdentity: true, + source: options?.source || '', + }); + + if (!Object.keys(updates).length || !statePatchHasChanges(currentState, updates)) { + return; + } + await setState(updates); + broadcastDataUpdate(updates); +} + async function setSignupPhoneStateSilently(phoneNumber) { const normalizedPhoneNumber = String(phoneNumber || '').trim(); const currentState = await getState(); @@ -6645,6 +6702,16 @@ async function fetchIcloudHideMyEmail(options = {}) { throwIfStopped(); const generateNew = Boolean(options?.generateNew); const preferredHost = String(options?.hostPreference || options?.preferredHost || '').trim(); + const persistSelectedIcloudEmail = async (email) => { + if (typeof persistRegistrationEmailState === 'function') { + await persistRegistrationEmailState(options?.state || null, email, { + source: options?.source || '', + preserveAccountIdentity: Boolean(options?.preserveAccountIdentity), + }); + return; + } + await setEmailState(email, options?.source ? { source: options.source } : {}); + }; await addLog('iCloud:正在加载别名列表并校验当前浏览器登录状态...', 'info'); const { serviceUrl, setupUrl } = await resolveIcloudPremiumMailService( @@ -6664,7 +6731,7 @@ async function fetchIcloudHideMyEmail(options = {}) { if (!generateNew) { const reusableAlias = pickReusableIcloudAlias(existingAliases); if (reusableAlias) { - await setEmailState(reusableAlias.email); + await persistSelectedIcloudEmail(reusableAlias.email); await addLog(`iCloud:复用未使用别名 ${reusableAlias.email}`, 'ok'); broadcastIcloudAliasesChanged({ reason: 'selected', email: reusableAlias.email }); return reusableAlias.email; @@ -6790,7 +6857,7 @@ async function fetchIcloudHideMyEmail(options = {}) { } } - await setEmailState(alias); + await persistSelectedIcloudEmail(alias); await addLog(`iCloud:已创建并保留新别名 ${alias}`, 'ok'); broadcastIcloudAliasesChanged({ reason: 'created', email: alias }); return alias; @@ -6801,7 +6868,7 @@ async function fetchIcloudHideMyEmail(options = {}) { const reusableAlias = pickReusableIcloudAlias(existingAliases); if (reusableAlias) { - await setEmailState(reusableAlias.email); + await persistSelectedIcloudEmail(reusableAlias.email); await addLog( `iCloud:当前网络/上下文波动,暂无法创建新别名,已临时回退复用 ${reusableAlias.email}。`, 'warn' @@ -9833,6 +9900,7 @@ const generatedEmailHelpers = self.MultiPageGeneratedEmailHelpers?.createGenerat normalizeCloudflareTempEmailAddress, normalizeEmailGenerator, isGeneratedAliasProvider, + persistRegistrationEmailState, reuseOrCreateTab, sendToContentScript, setEmailState, @@ -11148,6 +11216,7 @@ const signupFlowHelpers = self.MultiPageSignupFlowHelpers?.createSignupFlowHelpe isLuckmailProvider, isSignupPasswordPageUrl, isTabAlive, + persistRegistrationEmailState, reuseOrCreateTab, sendToContentScriptResilient, setEmailState, diff --git a/background/cloudmail-provider.js b/background/cloudmail-provider.js index 89c5f41..175d8f7 100644 --- a/background/cloudmail-provider.js +++ b/background/cloudmail-provider.js @@ -17,6 +17,7 @@ normalizeCloudMailDomain, normalizeCloudMailDomains, normalizeCloudMailMailApiMessages, + persistRegistrationEmailState = null, pickVerificationMessageWithTimeFallback, setEmailState = async () => {}, setPersistentSettings = async () => {}, @@ -24,6 +25,14 @@ throwIfStopped = () => {}, } = deps; + async function persistResolvedEmailState(state = null, email, options = {}) { + if (typeof persistRegistrationEmailState === 'function') { + await persistRegistrationEmailState(state, email, options); + return; + } + await setEmailState(email, options); + } + function getCloudMailConfig(state = {}) { return { baseUrl: normalizeCloudMailBaseUrl(state.cloudMailBaseUrl), @@ -185,7 +194,10 @@ throw err; } } - await setEmailState(address, { source: 'generated:cloudmail' }); + await persistResolvedEmailState(latestState, address, { + source: 'generated:cloudmail', + preserveAccountIdentity: Boolean(options?.preserveAccountIdentity), + }); await addLog(`Cloud Mail:已生成 ${address}`, 'ok'); return address; } diff --git a/background/generated-email-helpers.js b/background/generated-email-helpers.js index 4b5c1f4..81367d5 100644 --- a/background/generated-email-helpers.js +++ b/background/generated-email-helpers.js @@ -22,12 +22,21 @@ normalizeCloudflareTempEmailAddress, normalizeEmailGenerator, isGeneratedAliasProvider, + persistRegistrationEmailState = null, reuseOrCreateTab, sendToContentScript, setEmailState, throwIfStopped, } = deps; + async function persistResolvedEmailState(state = null, email, options = {}) { + if (typeof persistRegistrationEmailState === 'function') { + await persistRegistrationEmailState(state, email, options); + return; + } + await setEmailState(email, options); + } + function generateCloudflareAliasLocalPart() { const letters = 'abcdefghijklmnopqrstuvwxyz'; const digits = '0123456789'; @@ -60,7 +69,10 @@ const localPart = String(options.localPart || '').trim().toLowerCase() || generateCloudflareAliasLocalPart(); const aliasEmail = `${localPart}@${domain}`; - await setEmailState(aliasEmail, { source: 'generated:cloudflare' }); + await persistResolvedEmailState(latestState, aliasEmail, { + source: 'generated:cloudflare', + preserveAccountIdentity: Boolean(options?.preserveAccountIdentity), + }); await addLog(`Cloudflare 邮箱:已生成 ${aliasEmail}`, 'ok'); return aliasEmail; } @@ -162,7 +174,10 @@ throw new Error('Cloudflare Temp Email 未返回可用邮箱地址。'); } - await setEmailState(address, { source: 'generated:cloudflare-temp-email' }); + await persistResolvedEmailState(latestState, address, { + source: 'generated:cloudflare-temp-email', + preserveAccountIdentity: Boolean(options?.preserveAccountIdentity), + }); await addLog(`Cloudflare Temp Email:已生成 ${address}`, 'ok'); return address; } @@ -176,6 +191,7 @@ const { generateNew = true, baselineEmail = '', + state = null, } = options; await addLog(`Duck 邮箱:正在打开自动填充设置(${generateNew ? '生成新地址' : '复用当前地址'})...`); @@ -197,7 +213,10 @@ throw new Error('未返回 Duck 邮箱地址。'); } - await setEmailState(result.email, { source: 'generated:duck' }); + await persistResolvedEmailState(state, result.email, { + source: 'generated:duck', + preserveAccountIdentity: Boolean(options?.preserveAccountIdentity), + }); await addLog(`Duck 邮箱:${result.generated ? '已生成' : '已读取'} ${result.email}`, 'ok'); return result.email; } @@ -215,7 +234,10 @@ ); } - await setEmailState(email, { source: 'generated:custom-pool' }); + await persistResolvedEmailState(latestState, email, { + source: 'generated:custom-pool', + preserveAccountIdentity: Boolean(options?.preserveAccountIdentity), + }); await addLog(`自定义邮箱池:已取用 ${email}`, 'ok'); return email; } @@ -254,7 +276,10 @@ } const email = buildGeneratedAliasEmail(mergedState); - await setEmailState(email, { source: `generated:${provider || 'alias'}` }); + await persistResolvedEmailState(mergedState, email, { + source: `generated:${provider || 'alias'}`, + preserveAccountIdentity: Boolean(options?.preserveAccountIdentity), + }); await addLog(`${provider === 'gmail' ? 'Gmail +tag' : '2925'}:已生成 ${email}`, 'ok'); return email; } @@ -297,6 +322,9 @@ const stateFetchMode = String(mergedState.icloudFetchMode || '').trim().toLowerCase(); const icloudOptions = { generateNew: Boolean(options.generateNew) || stateFetchMode === 'always_new', + preserveAccountIdentity: Boolean(options?.preserveAccountIdentity), + source: 'generated:icloud', + state: mergedState, }; if (mergedState.icloudHostPreference !== undefined) { icloudOptions.hostPreference = mergedState.icloudHostPreference; @@ -325,6 +353,7 @@ ).trim(); return fetchDuckEmail({ ...options, + state: mergedState, baselineEmail: resolvedDuckBaselineEmail, }); } diff --git a/background/registration-email-state.js b/background/registration-email-state.js index 1df80bb..af0a266 100644 --- a/background/registration-email-state.js +++ b/background/registration-email-state.js @@ -65,6 +65,45 @@ }; } + function getPreservedPhoneIdentity(state = {}) { + const accountIdentifierType = String(state?.accountIdentifierType || '').trim().toLowerCase(); + const signupPhoneNumber = normalizeEmailValue( + state?.signupPhoneNumber + || (accountIdentifierType === 'phone' ? state?.accountIdentifier : '') + || state?.signupPhoneCompletedActivation?.phoneNumber + || state?.signupPhoneActivation?.phoneNumber + || '' + ); + if (accountIdentifierType !== 'phone' && !signupPhoneNumber) { + return null; + } + return { + accountIdentifierType: 'phone', + accountIdentifier: signupPhoneNumber || normalizeEmailValue(state?.accountIdentifier), + signupPhoneNumber, + signupPhoneActivation: state?.signupPhoneActivation || null, + signupPhoneCompletedActivation: state?.signupPhoneCompletedActivation || null, + signupPhoneVerificationRequestedAt: state?.signupPhoneVerificationRequestedAt ?? null, + signupPhoneVerificationPurpose: String(state?.signupPhoneVerificationPurpose || '').trim(), + }; + } + + function buildFlowRegistrationEmailStateUpdates(state = {}, options = {}) { + const registrationEmailUpdates = buildRegistrationEmailStateUpdates(state, options); + if (!Boolean(options?.preserveAccountIdentity)) { + return registrationEmailUpdates; + } + const preservedPhoneIdentity = getPreservedPhoneIdentity(state); + if (!preservedPhoneIdentity) { + return registrationEmailUpdates; + } + return { + ...registrationEmailUpdates, + phoneNumber: '', + ...preservedPhoneIdentity, + }; + } + function getRegistrationEmailBaseline(state = {}, options = {}) { const currentState = getRegistrationEmailState(state); const preferredEmail = normalizeEmailValue(options.preferredEmail); @@ -74,9 +113,11 @@ return { DEFAULT_REGISTRATION_EMAIL_STATE: cloneDefaultRegistrationEmailState(), + buildFlowRegistrationEmailStateUpdates, buildRegistrationEmailStateUpdates, getRegistrationEmailBaseline, getRegistrationEmailState, + getPreservedPhoneIdentity, normalizeRegistrationEmailState, }; } diff --git a/background/signup-flow-helpers.js b/background/signup-flow-helpers.js index f6cbe06..0009105 100644 --- a/background/signup-flow-helpers.js +++ b/background/signup-flow-helpers.js @@ -20,6 +20,7 @@ isSignupPasswordPageUrl, isSignupPhoneVerificationPageUrl = null, isSignupProfilePageUrl = null, + persistRegistrationEmailState = null, reuseOrCreateTab, sendToContentScriptResilient, setEmailState, @@ -328,8 +329,17 @@ if (resolvedEmail === state.email && !options?.preserveAccountIdentity) { return; } - const preservedPhoneIdentity = getPreservedPhoneIdentityForEmailResolution(state, options); const generatedEmailAlreadyPersisted = Boolean(options?.generatedEmailAlreadyPersisted); + if (typeof persistRegistrationEmailState === 'function') { + if (!generatedEmailAlreadyPersisted) { + await persistRegistrationEmailState(state, resolvedEmail, { + source: 'flow', + preserveAccountIdentity: Boolean(options?.preserveAccountIdentity), + }); + } + return; + } + const preservedPhoneIdentity = getPreservedPhoneIdentityForEmailResolution(state, options); if (preservedPhoneIdentity && typeof setState === 'function') { if (!generatedEmailAlreadyPersisted && resolvedEmail !== state.email) { await setEmailState(resolvedEmail, { source: 'flow' }); diff --git a/tests/background-generated-email-module.test.js b/tests/background-generated-email-module.test.js index 4f1e8bb..b6a55dc 100644 --- a/tests/background-generated-email-module.test.js +++ b/tests/background-generated-email-module.test.js @@ -212,6 +212,71 @@ test('generated email helper prefers current UI email over preserved runtime bas }); }); +test('generated email helper preserves phone identity through the shared persistence helper during Duck add-email generation', async () => { + const api = loadGeneratedEmailHelpersApi(); + const persistCalls = []; + + const helpers = api.createGeneratedEmailHelpers({ + addLog: async () => {}, + buildGeneratedAliasEmail: () => { + throw new Error('should not build alias'); + }, + buildCloudflareTempEmailHeaders: () => ({}), + CLOUDFLARE_TEMP_EMAIL_GENERATOR: 'cloudflare-temp-email', + DUCK_AUTOFILL_URL: 'https://duckduckgo.com/email', + fetch: async () => ({ ok: true, text: async () => '{}' }), + fetchIcloudHideMyEmail: async () => { + throw new Error('should not use icloud generator'); + }, + getCloudflareTempEmailAddressFromResponse: () => '', + getCloudflareTempEmailConfig: () => ({ baseUrl: '', adminAuth: '', domain: '' }), + getRegistrationEmailBaseline: () => '', + getState: async () => ({}), + ensureMail2925AccountForFlow: async () => { + throw new Error('should not allocate 2925 account'); + }, + joinCloudflareTempEmailUrl: () => '', + normalizeCloudflareDomain: () => '', + normalizeCloudflareTempEmailAddress: () => '', + normalizeEmailGenerator: (value) => String(value || '').trim().toLowerCase(), + isGeneratedAliasProvider: () => false, + persistRegistrationEmailState: async (state, email, options) => { + persistCalls.push({ state, email, options }); + }, + reuseOrCreateTab: async () => {}, + sendToContentScript: async () => ({ email: 'fresh@duck.com', generated: true }), + setEmailState: async () => { + throw new Error('preserveAccountIdentity should use shared persistence helper'); + }, + throwIfStopped: () => {}, + }); + + const state = { + email: '', + emailGenerator: 'duck', + mailProvider: 'gmail', + accountIdentifierType: 'phone', + accountIdentifier: '+447780579093', + signupPhoneNumber: '+447780579093', + signupPhoneCompletedActivation: { + activationId: 'done-1', + phoneNumber: '+447780579093', + }, + }; + const email = await helpers.fetchGeneratedEmail(state, { + generator: 'duck', + preserveAccountIdentity: true, + }); + + assert.equal(email, 'fresh@duck.com'); + assert.equal(persistCalls.length, 1); + assert.equal(persistCalls[0].email, 'fresh@duck.com'); + assert.equal(persistCalls[0].options.source, 'generated:duck'); + assert.equal(persistCalls[0].options.preserveAccountIdentity, true); + assert.equal(persistCalls[0].state.accountIdentifierType, 'phone'); + assert.equal(persistCalls[0].state.signupPhoneNumber, '+447780579093'); +}); + test('generated email helper can read the requested address from custom email pool', async () => { const api = loadGeneratedEmailHelpersApi(); const events = []; @@ -545,5 +610,68 @@ test('generated email helper honors iCloud always-new fetch mode', async () => { }); assert.equal(email, 'fresh@icloud.example.com'); - assert.deepEqual(icloudOptions, [{ generateNew: true }]); + assert.equal(icloudOptions.length, 1); + assert.equal(icloudOptions[0].generateNew, true); + assert.equal(icloudOptions[0].preserveAccountIdentity, false); + assert.equal(icloudOptions[0].source, 'generated:icloud'); + assert.equal(icloudOptions[0].state.emailGenerator, 'icloud'); +}); + +test('generated email helper forwards preserve identity context to the iCloud generator', async () => { + const api = loadGeneratedEmailHelpersApi(); + const icloudOptions = []; + + const helpers = api.createGeneratedEmailHelpers({ + addLog: async () => {}, + buildGeneratedAliasEmail: () => { + throw new Error('should not build managed alias'); + }, + buildCloudflareTempEmailHeaders: () => ({}), + CLOUDFLARE_TEMP_EMAIL_GENERATOR: 'cloudflare-temp-email', + DUCK_AUTOFILL_URL: 'https://duckduckgo.com/email', + fetch: async () => ({ ok: true, text: async () => '{}' }), + fetchIcloudHideMyEmail: async (options) => { + icloudOptions.push(options); + return 'fresh@icloud.example.com'; + }, + getCloudflareTempEmailAddressFromResponse: () => '', + getCloudflareTempEmailConfig: () => ({ baseUrl: '', adminAuth: '', domain: '' }), + getState: async () => ({}), + ensureMail2925AccountForFlow: async () => { + throw new Error('should not allocate mail2925 account'); + }, + joinCloudflareTempEmailUrl: () => '', + normalizeCloudflareDomain: () => '', + normalizeCloudflareTempEmailAddress: () => '', + normalizeEmailGenerator: (value) => String(value || '').trim().toLowerCase(), + isGeneratedAliasProvider: () => false, + reuseOrCreateTab: async () => {}, + sendToContentScript: async () => { + throw new Error('should not use duck generator'); + }, + setEmailState: async () => {}, + throwIfStopped: () => {}, + }); + + const state = { + emailGenerator: 'icloud', + icloudFetchMode: 'always_new', + mailProvider: 'gmail', + accountIdentifierType: 'phone', + accountIdentifier: '+447780579093', + signupPhoneNumber: '+447780579093', + }; + const email = await helpers.fetchGeneratedEmail(state, { + generator: 'icloud', + preserveAccountIdentity: true, + }); + + assert.equal(email, 'fresh@icloud.example.com'); + assert.equal(icloudOptions.length, 1); + assert.equal(icloudOptions[0].generateNew, true); + assert.equal(icloudOptions[0].preserveAccountIdentity, true); + assert.equal(icloudOptions[0].source, 'generated:icloud'); + assert.equal(icloudOptions[0].state.accountIdentifierType, 'phone'); + assert.equal(icloudOptions[0].state.signupPhoneNumber, '+447780579093'); + assert.equal(icloudOptions[0].state.emailGenerator, 'icloud'); }); diff --git a/tests/background-icloud-preserve.test.js b/tests/background-icloud-preserve.test.js new file mode 100644 index 0000000..0f6645e --- /dev/null +++ b/tests/background-icloud-preserve.test.js @@ -0,0 +1,136 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +function extractFunction(name) { + const source = fs.readFileSync('background.js', 'utf8'); + const markers = [`async function ${name}(`, `function ${name}(`]; + const start = markers + .map((marker) => source.indexOf(marker)) + .find((index) => index >= 0); + if (start < 0) { + throw new Error(`Unable to find function ${name}`); + } + + let parenDepth = 0; + let signatureEnded = false; + let braceStart = -1; + for (let i = start; i < source.length; i += 1) { + const char = source[i]; + if (char === '(') { + parenDepth += 1; + } else if (char === ')') { + parenDepth -= 1; + if (parenDepth === 0) { + signatureEnded = true; + } + } else if (char === '{' && signatureEnded) { + braceStart = i; + break; + } + } + if (braceStart < 0) { + throw new Error(`Unable to extract function ${name}`); + } + + let depth = 0; + let end = braceStart; + for (; end < source.length; end += 1) { + const char = source[end]; + if (char === '{') { + depth += 1; + } else if (char === '}') { + depth -= 1; + if (depth === 0) { + return source.slice(start, end + 1); + } + } + } + throw new Error(`Unable to extract function ${name}`); +} + +test('fetchIcloudHideMyEmail uses shared persistence helper for preserve-account-identity reuse flow', async () => { + const bundle = extractFunction('fetchIcloudHideMyEmail'); + + const api = new Function(` +const persistCalls = []; +const broadcasts = []; +const ICLOUD_REQUEST_TIMEOUT_MS = 15000; +const ICLOUD_WRITE_MAX_ATTEMPTS = 2; + +function withIcloudLoginHelp(_label, action) { + return action(); +} +function throwIfStopped() {} +async function addLog() {} +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 listIcloudAliases() { + return [ + { email: 'reuse@icloud.com', active: true, used: false, preserved: false }, + ]; +} +function pickReusableIcloudAlias(aliases) { + return aliases[0] || null; +} +async function icloudRequest() { + throw new Error('should not create a new alias'); +} +function getIcloudAliasLabel() { + return 'MultiPage 2026-04-26'; +} +async function persistRegistrationEmailState(state, email, options) { + persistCalls.push({ state, email, options }); +} +async function setEmailState() { + throw new Error('preserve flow should use shared persistence helper'); +} +function broadcastIcloudAliasesChanged(payload) { + broadcasts.push(payload); +} +function findIcloudAliasByEmail() { + return null; +} +function shouldStopIcloudAutoFetchRetries() { + return true; +} +${bundle} +return { + fetchIcloudHideMyEmail, + readPersistCalls: () => persistCalls, + readBroadcasts: () => broadcasts, +}; +`)(); + + const state = { + accountIdentifierType: 'phone', + accountIdentifier: '+447780579093', + signupPhoneNumber: '+447780579093', + }; + const email = await api.fetchIcloudHideMyEmail({ + generateNew: false, + preserveAccountIdentity: true, + source: 'generated:icloud', + state, + }); + + assert.equal(email, 'reuse@icloud.com'); + assert.deepStrictEqual(api.readPersistCalls(), [ + { + state, + email: 'reuse@icloud.com', + options: { + source: 'generated:icloud', + preserveAccountIdentity: true, + }, + }, + ]); + assert.deepStrictEqual(api.readBroadcasts(), [{ reason: 'selected', email: 'reuse@icloud.com' }]); +}); diff --git a/tests/background-registration-email-state.test.js b/tests/background-registration-email-state.test.js index 84fc6ee..02b58ba 100644 --- a/tests/background-registration-email-state.test.js +++ b/tests/background-registration-email-state.test.js @@ -56,3 +56,80 @@ test('registration email baseline prefers the current UI email over preserved ru assert.equal(baseline, 'visible@duck.com'); }); + +test('flow registration email state can preserve phone identity while updating the runtime email', () => { + const api = loadRegistrationEmailStateApi(); + const helpers = api.createRegistrationEmailStateHelpers(); + + const updates = helpers.buildFlowRegistrationEmailStateUpdates({ + email: '', + accountIdentifierType: 'phone', + accountIdentifier: '+447780579093', + signupPhoneNumber: '+447780579093', + signupPhoneActivation: { + activationId: 'active-1', + phoneNumber: '+447780579093', + }, + signupPhoneCompletedActivation: { + activationId: 'done-1', + phoneNumber: '+447780579093', + }, + signupPhoneVerificationRequestedAt: 12345, + signupPhoneVerificationPurpose: 'login', + }, { + currentEmail: 'fresh@example.com', + preserveAccountIdentity: true, + source: 'flow', + }); + + assert.deepStrictEqual(updates, { + email: 'fresh@example.com', + registrationEmailState: { + current: 'fresh@example.com', + previous: 'fresh@example.com', + source: 'flow', + updatedAt: updates.registrationEmailState.updatedAt, + }, + phoneNumber: '', + accountIdentifierType: 'phone', + accountIdentifier: '+447780579093', + signupPhoneNumber: '+447780579093', + signupPhoneActivation: { + activationId: 'active-1', + phoneNumber: '+447780579093', + }, + signupPhoneCompletedActivation: { + activationId: 'done-1', + phoneNumber: '+447780579093', + }, + signupPhoneVerificationRequestedAt: 12345, + signupPhoneVerificationPurpose: 'login', + }); + assert.ok(updates.registrationEmailState.updatedAt > 0); +}); + +test('flow registration email state falls back to email identity updates when no phone identity exists', () => { + const api = loadRegistrationEmailStateApi(); + const helpers = api.createRegistrationEmailStateHelpers(); + + const updates = helpers.buildFlowRegistrationEmailStateUpdates({ + email: '', + accountIdentifierType: 'email', + accountIdentifier: 'old@example.com', + }, { + currentEmail: 'fresh@example.com', + preserveAccountIdentity: true, + source: 'flow', + }); + + assert.deepStrictEqual(updates, { + email: 'fresh@example.com', + registrationEmailState: { + current: 'fresh@example.com', + previous: 'fresh@example.com', + source: 'flow', + updatedAt: updates.registrationEmailState.updatedAt, + }, + }); + assert.ok(updates.registrationEmailState.updatedAt > 0); +}); diff --git a/tests/background-signup-step2-branching.test.js b/tests/background-signup-step2-branching.test.js index da22890..7a3b663 100644 --- a/tests/background-signup-step2-branching.test.js +++ b/tests/background-signup-step2-branching.test.js @@ -789,6 +789,72 @@ test('signup flow helper can generate an email on demand when add-email starts f ]); }); +test('signup flow helper delegates preserved phone identity email sync to the shared persistence helper when reusing an existing email', async () => { + const persistCalls = []; + let setEmailCalls = 0; + let setStateCalls = 0; + + const helpers = signupFlowApi.createSignupFlowHelpers({ + buildGeneratedAliasEmail: () => 'demo+saved@gmail.com', + chrome: { tabs: { get: async () => ({ id: 21, url: 'https://auth.openai.com/create-account/password' }) } }, + ensureContentScriptReadyOnTab: async () => {}, + ensureHotmailAccountForFlow: async () => ({}), + ensureLuckmailPurchaseForFlow: async () => ({}), + fetchGeneratedEmail: async () => { + throw new Error('should not generate a new email'); + }, + isGeneratedAliasProvider: () => true, + isReusableGeneratedAliasEmail: (_state, email) => email === 'demo+saved@gmail.com', + isHotmailProvider: () => false, + isLuckmailProvider: () => false, + isSignupEmailVerificationPageUrl: () => false, + isSignupPasswordPageUrl: () => true, + persistRegistrationEmailState: async (state, email, options) => { + persistCalls.push({ state, email, options }); + }, + reuseOrCreateTab: async () => 21, + sendToContentScriptResilient: async () => ({}), + setEmailState: async () => { + setEmailCalls += 1; + }, + setState: async () => { + setStateCalls += 1; + }, + SIGNUP_ENTRY_URL: 'https://chatgpt.com/', + SIGNUP_PAGE_INJECT_FILES: [], + waitForTabUrlMatch: async () => null, + }); + + const state = { + mailProvider: 'gmail', + email: 'demo+saved@gmail.com', + accountIdentifierType: 'phone', + accountIdentifier: '+447780579093', + signupPhoneNumber: '+447780579093', + signupPhoneCompletedActivation: { + activationId: 'signup-completed', + phoneNumber: '+447780579093', + }, + }; + const email = await helpers.resolveSignupEmailForFlow(state, { + preserveAccountIdentity: true, + }); + + assert.equal(email, 'demo+saved@gmail.com'); + assert.equal(setEmailCalls, 0); + assert.equal(setStateCalls, 0); + assert.deepStrictEqual(persistCalls, [ + { + state, + email: 'demo+saved@gmail.com', + options: { + source: 'flow', + preserveAccountIdentity: true, + }, + }, + ]); +}); + test('signup flow helper finalizes step 3 submit by reusing signup verification preparation', async () => { let ensureCalls = 0; const messages = []; diff --git a/tests/cloudmail-provider.test.js b/tests/cloudmail-provider.test.js index 190b4f7..57445c5 100644 --- a/tests/cloudmail-provider.test.js +++ b/tests/cloudmail-provider.test.js @@ -154,3 +154,77 @@ test('pollCloudMailVerificationCode ignores stale receive mailbox when generator assert.equal(result.code, '246810'); assert.deepEqual(api.snapshot().listCalls, ['generated@example.com']); }); + +test('fetchCloudMailAddress preserves phone identity through the shared persistence helper', async () => { + const persistCalls = []; + const setEmailCalls = []; + const api = globalThis.MultiPageBackgroundCloudMailProvider.createCloudMailProvider({ + addLog: async () => {}, + buildCloudMailHeaders: () => ({}), + CLOUD_MAIL_DEFAULT_PAGE_SIZE: 20, + CLOUD_MAIL_GENERATOR: 'cloudmail', + CLOUD_MAIL_PROVIDER: 'cloudmail', + fetchImpl: async (url) => { + if (String(url).includes('/api/public/addUser')) { + return { + ok: true, + text: async () => JSON.stringify({ code: 200 }), + }; + } + return { + ok: true, + text: async () => JSON.stringify({ code: 200, data: { token: 'token' } }), + }; + }, + getCloudMailTokenFromResponse: () => 'token', + getState: async () => ({}), + joinCloudMailUrl: (baseUrl, path) => `${baseUrl}${path}`, + normalizeCloudMailAddress: (value) => String(value || '').trim().toLowerCase(), + normalizeCloudMailBaseUrl: (value) => String(value || '').trim(), + normalizeCloudMailDomain: (value) => String(value || '').trim(), + normalizeCloudMailDomains: (values) => values || [], + normalizeCloudMailMailApiMessages: () => [], + persistRegistrationEmailState: async (state, email, options) => { + persistCalls.push({ state, email, options }); + }, + pickVerificationMessageWithTimeFallback: () => ({ + match: null, + usedRelaxedFilters: false, + usedTimeFallback: false, + }), + setEmailState: async (email) => { + setEmailCalls.push(email); + }, + setPersistentSettings: async () => {}, + sleepWithStop: async () => {}, + throwIfStopped: () => {}, + }); + + const state = { + cloudMailBaseUrl: 'https://mail.example.com', + cloudMailAdminEmail: 'admin@example.com', + cloudMailAdminPassword: 'secret', + cloudMailToken: 'token', + cloudMailDomain: 'example.com', + accountIdentifierType: 'phone', + accountIdentifier: '+447780579093', + signupPhoneNumber: '+447780579093', + }; + const email = await api.fetchCloudMailAddress(state, { + localPart: 'fresh', + preserveAccountIdentity: true, + }); + + assert.equal(email, 'fresh@example.com'); + assert.deepStrictEqual(setEmailCalls, []); + assert.deepStrictEqual(persistCalls, [ + { + state, + email: 'fresh@example.com', + options: { + source: 'generated:cloudmail', + preserveAccountIdentity: true, + }, + }, + ]); +}); diff --git a/项目完整链路说明.md b/项目完整链路说明.md index 72e7230..ac1c021 100644 --- a/项目完整链路说明.md +++ b/项目完整链路说明.md @@ -153,8 +153,8 @@ - 注册邮箱运行态 `registrationEmailState = { current, previous, source, updatedAt }` - 当前手机号注册运行态 `signupPhoneNumber / signupPhoneActivation / signupPhoneCompletedActivation` - 侧栏“注册手机号”输入框只同步运行态 `signupPhoneNumber / accountIdentifierType / accountIdentifier`,不属于持久配置,也不参与配置导入导出 -- 邮箱身份写入时会把 `accountIdentifierType / accountIdentifier` 改回邮箱,并清理旧的 `signupPhone*` 注册手机号运行态,避免后续 Step 4 / Step 8 被旧手机号污染而误走短信分支 -- 邮箱写入统一通过 `setEmailState*` 同步更新 `email` 与 `registrationEmailState`;如果当前邮箱因 Step 8 恢复被清空,但还需要保留上一份 Duck / add-email 对比基线,则只清空 `current`,保留 `previous` +- 普通邮箱身份写入会把 `accountIdentifierType / accountIdentifier` 改回邮箱,并清理旧的 `signupPhone*` 注册手机号运行态,避免后续 Step 4 / Step 8 被旧手机号污染而误走短信分支;但 Step 8 `add-email` 这类明确要求保留手机号身份的链路除外 +- 邮箱写入统一通过 `setEmailState*` 或共享注册邮箱状态持久化 helper 同步更新 `email` 与 `registrationEmailState`;如果当前邮箱因 Step 8 恢复被清空,但还需要保留上一份 Duck / add-email 对比基线,则只清空 `current`,保留 `previous`;如果当前链路显式要求 `preserveAccountIdentity`,则在写入邮箱的同时保留 `signupPhone* / accountIdentifier*` - 本轮联动自检重点: 1. Step 2 一旦自动取到手机号,必须立刻广播并回填侧栏运行态手机号。 2. sidepanel 重新加载后,必须从当前 state 恢复“注册手机号”文本框,而不是只恢复邮箱。 @@ -469,8 +469,8 @@ IP 代理模块在同步、切换、Change、出口探测和自动运行成功 1. 先确认当前认证页真实状态,而不是只看 `accountIdentifierType`。 2. 当前是 `add-email`: - - 调用 `resolveSignupEmailForFlow` 按需获取或生成本轮绑定邮箱;手机号注册不会在 Step 2 提前生成邮箱,只有真实进入 `add-email` 时才占用邮箱资源 - - 如果邮箱生成器已经在生成阶段写回运行态,这里不会再重复覆盖 `registrationEmailState`,但会保留手机号身份 + - 调用 `resolveSignupEmailForFlow` 按需获取或生成本轮绑定邮箱;手机号注册不会在 Step 2 提前生成邮箱,只有真实进入 `add-email` 时才占用邮箱资源;这里会显式打开 `preserveAccountIdentity` + - 如果邮箱生成器已经在生成阶段写回运行态,这里不会再重复覆盖 `registrationEmailState`;无论是复用已有邮箱,还是 Duck / Cloudflare / Cloud Mail / iCloud / 自定义邮箱池这类现场生成的新邮箱,都会走共享注册邮箱状态持久化规则保留手机号身份 - 内容脚本发送 `SUBMIT_ADD_EMAIL` 填写邮箱并提交 - 页面进入邮箱验证码页后,用绑定邮箱作为 `step8VerificationTargetEmail` 3. 当前是邮箱验证码页: @@ -653,7 +653,7 @@ Plus 模式可见步骤: - UI 文案输出 - `background/registration-email-state.js` - 统一维护当前注册邮箱、上一份比较基线邮箱、来源与更新时间,并提供 Duck 生成前解析比较基线、Step 8 清空当前邮箱但保留历史基线的共享工具。 + 统一维护当前注册邮箱、上一份比较基线邮箱、来源与更新时间,并提供 Duck 生成前解析比较基线、Step 8 清空当前邮箱但保留历史基线,以及 Step 8 `add-email` 写入邮箱时保留手机号身份的共享工具。 - `background/generated-email-helpers.js` 在原有 Duck / Cloudflare / iCloud / Cloudflare Temp Email 生成链路之外,新增 Gmail / 2925 的共享生成接入;Duck 生成前会按“侧栏当前可见邮箱 -> `registrationEmailState.current` -> `registrationEmailState.previous`”的顺序解析比较基线。 @@ -678,7 +678,7 @@ Plus 模式可见步骤: 补充: -- 所有生成器在返回邮箱后都会统一调用 `setEmailState(..., { source })` 回写运行态,保证 `email` 与 `registrationEmailState` 同步更新。 +- 所有生成器在返回邮箱后都会统一调用共享注册邮箱状态持久化入口回写运行态:普通邮箱流等价于 `setEmailState(..., { source })`,而 Step 8 `add-email` 的手机号身份流会在更新 `email / registrationEmailState` 的同时保留 `signupPhone* / accountIdentifier*`,避免 sidepanel 短暂看到手机号被清空。 - Duck 生成链路不会再直接拿“生成前瞬间读到的输入框值”当唯一基线,而是优先使用 sidepanel 当前可见邮箱,再回退到共享的注册邮箱运行态基线,避免生成前后地址对比失效。 ### 7.1.1 Cloud Mail @@ -841,6 +841,7 @@ Plus 模式可见步骤: - `icloudFetchMode` 决定生成注册邮箱时复用已有 Hide My Email 别名,还是始终创建新别名。 - `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`。 +- Step 8 `add-email` 里无论 iCloud 最终是复用未使用别名,还是新建并保留 Hide My Email 别名,选中的邮箱都会先走共享注册邮箱状态持久化;当当前账号主身份仍是手机号时,这一步不会先广播清空手机号再恢复。 Hide My Email 获取与管理链路: diff --git a/项目文件结构说明.md b/项目文件结构说明.md index 44c13a4..015105f 100644 --- a/项目文件结构说明.md +++ b/项目文件结构说明.md @@ -48,19 +48,19 @@ - `background/account-run-history.js`:账号记录模块,负责以统一账号标识维护最新记录;邮箱注册以邮箱为主身份,手机号注册以手机号为主身份,同一轮后续绑定的手机号或邮箱会并入同一条记录并覆盖旧占位状态;模块统一归一化成功/失败/停止三态并落地到 `chrome.storage.local`,兼容 phone-only 与 email+phone 组合记录、空密码,并按默认本地 helper 地址自动尝试把完整快照同步到本地 helper;若 helper 未启动,则静默跳过。 - `background/auto-run-controller.js`:自动运行主控制器,封装多轮执行、重试、轮次摘要、线程间隔与倒计时恢复逻辑;当前自动流程会绑定 `autoRunSessionId`,手动停止后旧的倒计时计划、旧重试链路和旧恢复入口不会再复活已失效的自动运行;fresh-attempt reset 时会额外保留 Plus 模式与 PayPal 配置、`gmailBaseEmail`、`mail2925BaseEmail` 与当前 2925 账号选择,避免自动流程重置后丢失关键持久配置。 - `background/contribution-oauth.js`:贡献模式的公开 OAuth 流程模块,负责调用 `apikey.qzz.io` 的公开贡献接口、保存贡献会话运行态、在主 10 步流程里为步骤 7 提供贡献登录地址、在步骤 9/10 衔接 callback 捕获与兼容提交 `/oauth/api/submit-callback`,并把真实服务端状态映射回 sidepanel 运行态。 -- `background/cloudmail-provider.js`:Cloud Mail / SkyMail 后台 provider 模块,负责 Token 获取、邮箱创建、邮件列表读取、验证码轮询和生成/转发收件目标邮箱选择;新生成的 Cloud Mail 地址也会统一回写带来源标记的注册邮箱运行态。 -- `background/generated-email-helpers.js`:生成邮箱辅助层,除了 Duck / Cloudflare / iCloud / Cloudflare Temp Email,也统一承接 Gmail / 2925 的别名邮箱生成入口与自定义邮箱池读取;当 provider 为 2925 且 `mail2925Mode = provide` 时,会先确保当前账号池里已有可用账号,再基于该账号生成别名邮箱;若 `mail2925Mode = receive`,则会回退到普通邮箱生成链路;当生成方式为 iCloud 且 `icloudFetchMode = always_new` 时,会强制跳过未用别名复用并新建别名;当生成方式为 `custom-pool` 时,会按当前目标轮次读取邮箱池中的对应邮箱;Duck 生成前会优先结合 `registrationEmailState` 与侧栏当前可见邮箱解析对比基线,避免重复拿到旧地址;所有生成结果都会统一写回带 `source` 标记的注册邮箱运行态。 +- `background/cloudmail-provider.js`:Cloud Mail / SkyMail 后台 provider 模块,负责 Token 获取、邮箱创建、邮件列表读取、验证码轮询和生成/转发收件目标邮箱选择;新生成的 Cloud Mail 地址会统一走共享注册邮箱状态持久化,既回写带来源标记的注册邮箱运行态,也能在 Step 8 `add-email` 的手机号身份链路中保留 `signupPhone* / accountIdentifier*`。 +- `background/generated-email-helpers.js`:生成邮箱辅助层,除了 Duck / Cloudflare / iCloud / Cloudflare Temp Email,也统一承接 Gmail / 2925 的别名邮箱生成入口与自定义邮箱池读取;当 provider 为 2925 且 `mail2925Mode = provide` 时,会先确保当前账号池里已有可用账号,再基于该账号生成别名邮箱;若 `mail2925Mode = receive`,则会回退到普通邮箱生成链路;当生成方式为 iCloud 且 `icloudFetchMode = always_new` 时,会强制跳过未用别名复用并新建别名;当生成方式为 `custom-pool` 时,会按当前目标轮次读取邮箱池中的对应邮箱;Duck 生成前会优先结合 `registrationEmailState` 与侧栏当前可见邮箱解析对比基线,避免重复拿到旧地址;所有生成结果都会统一走共享注册邮箱状态持久化,普通邮箱流仍切回邮箱身份,Step 8 `add-email` 的手机号身份流则保留当前手机号身份。 - `background/ip-proxy-core.js`:IP 代理核心模块,负责代理条目解析、账号/接口代理池运行态、PAC 应用与清除、代理鉴权回填、出口探测、失败时的目标站点 fail-close 防漏规则,以及自动运行成功阈值后的代理切换支撑。 - `background/ip-proxy-provider-711proxy.js`:711Proxy provider 规则模块,负责从账号串中识别和写回 `region / session / sessTime` 等参数,并在固定账号模式下把侧栏配置转换为最终生效的代理账号。 - `background/logging-status.js`:后台日志、步骤状态、错误信息和若干状态判断的公共工具层;日志条目统一写入结构化 `step / stepKey`,sidepanel 只读取该元数据渲染步骤标签,不再从日志正文解析步骤号;当前额外承接 `add-phone / 手机号页` 这类认证 fatal 错误的共享判定,并会把 Step 2 的“手机号输入模式未切成功”与真正的 auth `add-phone` 页面区分开,避免自动运行误停机。 - `background/mail-2925-session.js`:2925 会话模块,负责 2925 账号池持久化、当前账号切换、cookie 清理登出、自动登录、命中“子邮箱已达上限邮箱”后的 24 小时禁用与自动切号。 - `background/message-router.js`:后台消息路由层,负责处理 `chrome.runtime.onMessage` 进入的所有业务消息;`LOG / STEP_COMPLETE / STEP_ERROR` 会把消息里的真实 `step` 继续传给结构化日志,跳过登录验证码这类派生日志也按当前步骤 key 写入;当前额外接入 PayPal 账号池的新增与切换、2925 账号池的新增、导入、切换、登录、禁用与删除消息,以及 IP 代理的同步、切换、Change 与出口检测消息;手动改邮箱、手动取邮箱等入口也统一经这里落到 `setEmailState`,同步维护注册邮箱运行态;当侧栏关闭 `oauthFlowTimeoutEnabled` 时,会立即清空已存在的 OAuth 总预算 deadline。 -- `background/registration-email-state.js`:注册邮箱运行态共享模块,负责维护 `registrationEmailState = { current, previous, source, updatedAt }`,并提供“当前邮箱清空时保留上一比较基线”“Duck 生成前解析比较基线”等统一工具。 +- `background/registration-email-state.js`:注册邮箱运行态共享模块,负责维护 `registrationEmailState = { current, previous, source, updatedAt }`,并提供“当前邮箱清空时保留上一比较基线”“Duck 生成前解析比较基线”“Step 8 `add-email` 写入邮箱时按需保留手机号身份”的统一工具。 - `background/navigation-utils.js`:导航与 URL 判断工具层,负责 callback、入口页、CPA / SUB2API / Codex2API 地址归一化、来源标签页家族判断与步骤跳转相关判断。 - `background/paypal-account-store.js`:PayPal 账号池持久化模块,负责保存账号列表、切换当前账号,并把当前选中账号同步回兼容字段 `paypalEmail / paypalPassword`。 - `background/phone-verification-flow.js`:手机号验证码共享流程模块,负责复用 HeroSMS / 5sim / NexSMS 的取号、复用、轮询、重发、换号与收尾能力;既承接 OAuth 后置 `add-phone / phone-verification` 页面,也承接手机号注册 Step 4 和 Step 8 中真实出现的 `phone-verification` 页面,并保持 `signupPhone*` 运行态与 `currentPhoneActivation` 隔离;后置 add-phone 成功后会把已绑定手机号写入运行态 `phoneNumber`,供账号记录并入同一轮;提交后置手机号验证码时会一并透传可复用的资料页 payload,便于认证页偶发跳回资料页时继续补齐;该流程由调用方传入当前可见步骤号,手机号日志不会再固定显示普通模式 Step 9。 - `background/panel-bridge.js`:来源桥接层;CPA / SUB2API 继续封装页面打开、脚本注入和通信,Codex2API 则直接通过后台协议生成 OAuth 地址。 -- `background/signup-flow-helpers.js`:注册页辅助层,负责打开注册入口、Step 2 打开入口页后的加载完成与额外 3 秒稳定等待、等待密码页以及解析当前流程所用邮箱;在 Gmail 与 `2925 + provide` 模式下会优先复用已经存在且仍兼容的完整注册邮箱,只有不兼容或为空时才重新生成;当 provider 为 2925 且启用了 provide 模式下的号池时,会先确保账号池中已选中可用账号;若邮箱生成器在生成阶段已经写回运行态,这里不会再重复覆盖 `registrationEmailState`,但仍会按需保留手机号身份。 +- `background/signup-flow-helpers.js`:注册页辅助层,负责打开注册入口、Step 2 打开入口页后的加载完成与额外 3 秒稳定等待、等待密码页以及解析当前流程所用邮箱;在 Gmail 与 `2925 + provide` 模式下会优先复用已经存在且仍兼容的完整注册邮箱,只有不兼容或为空时才重新生成;当 provider 为 2925 且启用了 provide 模式下的号池时,会先确保账号池中已选中可用账号;若邮箱生成器在生成阶段已经写回运行态,这里不会再重复覆盖 `registrationEmailState`,但仍会在 Step 8 `add-email` 的手机号身份场景下通过共享持久化按需保留手机号身份。 - `background/tab-runtime.js`:标签页与内容脚本运行时基础设施,封装标签注册、冲突清理、消息超时、注入重试与队列;内容脚本恢复等待日志支持 `logStep / logStepKey`,用于保持 Plus 复用步骤的日志标签正确;当前等待标签完成、等待标签完成并短暂稳定、注入后的短暂延迟和内容脚本重试等待都已做 Stop 感知,避免用户停止后后台还继续等待并恢复执行。 - `background/verification-flow.js`:注册/登录验证码共享流程层,封装重发、轮询、提交、失败回退、自定义邮箱跳过、共享验证码自动重发次数配置、Cloud Mail API 轮询以及 2925 长轮询参数;日志步骤号使用 `completionStep`,因此 Plus 登录验证码会显示可见第 11 步而不是内部复用的 Step 8;当前验证码提交重试上限为 15 次,2925 每次重发验证码之间都会固定跑完一轮 15 次邮箱刷新轮询;若 `mail2925Mode = receive`,会额外把目标注册邮箱传给 2925 内容脚本做弱匹配;若登录验证码提交后页面转入 `add-phone / 手机号页`,则会把“后续需要手机号验证”的结果继续传给 OAuth 确认步骤,而不是误判为普通失败;若登录验证码提交后通信中断但认证页已进入 OAuth 同意页或手机号验证页,会按真实页面状态继续后续流程;若 2925 轮询命中“子邮箱已达上限邮箱”,会转入 2925 会话模块执行“记录时间、禁用 24 小时、切下一个账号并重新登录”,然后直接结束当前尝试。 @@ -69,7 +69,7 @@ - `background/steps/wait-registration-success.js`:步骤 6 实现,负责注册资料提交后等待 20 秒,让注册成功状态和页面跳转稳定;默认不清理 cookies,只有侧栏第六步 `清 Cookies` 开关开启时才会在等待结束后清理 ChatGPT / OpenAI 相关 cookies。 - `background/steps/confirm-oauth.js`:步骤 9 实现,负责 OAuth 同意页按钮定位、点击、localhost 回调监听与回调完成;等待 callback 期间会动态检查 OAuth 总预算开关,关闭后仅保留本地回调等待超时。 - `background/steps/create-plus-checkout.js`:Plus 模式第 6 步实现,负责在已登录 ChatGPT 页面创建 PayPal / GoPay checkout session,或在 GPC 模式下读取 accessToken 并通过 `https://gpc.qlhazycoder.top/api/gp/tasks` 创建队列任务,记录 checkout / GPC task 运行态。 -- `background/steps/fetch-login-code.js`:步骤 8 实现,负责按真实认证页状态分发登录后续流程:邮箱验证码页走邮箱轮询、验证码回填与回退控制;真实 `phone-verification` 页才复用手机号验证码共享流程;手机号注册登录后若进入 `add-email`,会先生成/解析邮箱并提交绑定,再进入邮箱验证码轮询;邮箱分支对非 2925 provider 会固定当前验证码页显示邮箱作为本次 Step 8 的目标邮箱,当 provider 为 2925 时,会在轮询前先确保当前 2925 账号已自动登录;命中 `email_in_use` 时会仅清空当前邮箱并保留上一轮比较基线,再在当前 Step 8 内重开 `add-email` 获取新邮箱,`max_check_attempts` 也只重启当前步骤恢复链。 +- `background/steps/fetch-login-code.js`:步骤 8 实现,负责按真实认证页状态分发登录后续流程:邮箱验证码页走邮箱轮询、验证码回填与回退控制;真实 `phone-verification` 页才复用手机号验证码共享流程;手机号注册登录后若进入 `add-email`,会先按共享邮箱状态持久化规则生成/解析邮箱并提交绑定,在不丢失当前手机号身份的前提下再进入邮箱验证码轮询;邮箱分支对非 2925 provider 会固定当前验证码页显示邮箱作为本次 Step 8 的目标邮箱,当 provider 为 2925 时,会在轮询前先确保当前 2925 账号已自动登录;命中 `email_in_use` 时会仅清空当前邮箱并保留上一轮比较基线,再在当前 Step 8 内重开 `add-email` 获取新邮箱,`max_check_attempts` 也只重启当前步骤恢复链。 - `background/steps/fetch-signup-code.js`:步骤 4 实现,负责注册验证码阶段的页面准备与验证码流程入口;邮箱注册继续走邮箱验证码,手机号注册改走短信验证码;当 provider 为 2925 时,会在邮箱轮询前先确保当前 2925 账号已自动登录。 - `background/steps/fill-plus-checkout.js`:Plus 模式第 7 步实现,负责驱动 PayPal / GoPay checkout 页面选择付款方式、填写账单地址、提交订阅并等待跳转;GPC 模式则轮询队列任务,按远端 `api_waiting_for` 提交 OTP / PIN,并在任务失败、过期或取消时清理运行态。 - `background/steps/fill-password.js`:步骤 3 实现,负责密码生成、保存、回填与提交;当前已改为身份中立,手机号注册遇到无密码页时会按页面状态自动跳过。 @@ -188,7 +188,8 @@ - `tests/background-account-history-settings.test.js`:测试账号运行历史的独立配置项归一化,不再复用 Hotmail 模式作为启停条件。 - `tests/background-contribution-mode.test.js`:测试贡献模式的后台运行态与公开 OAuth 接入,覆盖 `contributionMode` 只存在于运行态、`SET_CONTRIBUTION_MODE / POLL_CONTRIBUTION_STATUS` 消息接入、reset 保留贡献运行态,以及 callback 自动提交在“无需手动提交”场景下的兼容处理。 - `tests/background-custom-email-pool.test.js`:测试后台对自定义邮箱池生成方式,以及自定义邮箱服务号池的归一化、标签文案和按轮次取邮箱逻辑。 -- `tests/background-generated-email-module.test.js`:测试生成邮箱辅助模块已接入且导出工厂,并覆盖 Duck 对比基线优先级、2925 receive 模式回退普通邮箱生成、自定义邮箱池按轮次取值、2925 provide 模式账号池预热,以及 iCloud `always_new` 传参。 +- `tests/background-generated-email-module.test.js`:测试生成邮箱辅助模块已接入且导出工厂,并覆盖 Duck 对比基线优先级、2925 receive 模式回退普通邮箱生成、自定义邮箱池按轮次取值、2925 provide 模式账号池预热,以及 Duck / iCloud 在保留手机号身份场景下的共享持久化传参。 +- `tests/background-icloud-preserve.test.js`:测试 iCloud Hide My Email 在 Step 8 `add-email` 的保留手机号身份复用路径里,会调用共享注册邮箱状态持久化,而不是回退到直接 `setEmailState`。 - `tests/background-icloud.test.js`:测试 iCloud 相关后台纯函数与别名收尾逻辑。 - `tests/background-icloud-login-help.test.js`:测试 iCloud 登录判定、页面上下文回退、别名缓存回退和保留异常恢复的关键源码约束。 - `tests/background-icloud-mail-provider.test.js`:测试 iCloud 邮箱 provider 配置解析,并覆盖转发收码模式复用共享 provider 配置。 @@ -203,10 +204,10 @@ - `tests/background-panel-bridge-module.test.js`:测试面板桥接模块已接入且导出工厂。 - `tests/background-paypal-account-store-module.test.js`:测试 PayPal 账号池后台模块已接入、导出工厂,并覆盖当前账号选择会同步回兼容字段 `paypalEmail / paypalPassword`。 - `tests/background-platform-verify-codex2api.test.js`:测试 Codex2API 新来源在步骤 10 走协议式 callback 交换,不依赖后台页面注入。 -- `tests/background-registration-email-state.test.js`:测试注册邮箱运行态共享模块对 `registrationEmailState` 的维护,覆盖清空当前邮箱时保留上一比较基线,以及 Duck 生成前优先使用侧栏当前可见邮箱作为基线。 +- `tests/background-registration-email-state.test.js`:测试注册邮箱运行态共享模块对 `registrationEmailState` 的维护,覆盖清空当前邮箱时保留上一比较基线、Duck 生成前优先使用侧栏当前可见邮箱作为基线,以及流程邮箱写入时保留手机号身份的共享纯函数。 - `tests/background-signup-flow-module.test.js`:测试注册页辅助模块已接入且导出工厂。 - `tests/background-skip-step-linking.test.js`:测试手动跳过步骤 1 时,会级联跳过步骤 2~5,并仅跳过其中未完成且未运行的步骤。 -- `tests/background-signup-step2-branching.test.js`:测试在 Gmail / 2925 模式下,已有兼容别名邮箱时应直接复用,不应再次重生成,并覆盖生成阶段已落库邮箱不会被流程层重复覆盖。 +- `tests/background-signup-step2-branching.test.js`:测试在 Gmail / 2925 模式下,已有兼容别名邮箱时应直接复用,不应再次重生成,并覆盖生成阶段已落库邮箱不会被流程层重复覆盖,以及 Step 8 `add-email` 复用已有邮箱时会委托共享注册邮箱状态持久化保留手机号身份。 - `tests/background-step-modules.test.js`:测试步骤模块文件都已由后台入口加载。 - `tests/background-step5-submit-short-circuit.test.js`:测试步骤 5 会把生成好的资料直接转发给内容脚本,并依赖完成信号收尾。 - `tests/background-step6-retry-limit.test.js`:测试步骤 6 的注册成功等待、可选 cookies 清理开关,以及步骤 7 的有限重试上限与 `add-phone` 命中后的立即跳出行为。 @@ -223,7 +224,7 @@ - `tests/plus-checkout-billing-tab-resolution.test.js`:测试 Plus 第 7 步可直接接管当前 checkout 标签页,并把 PayPal、账单地址、Google 地址推荐和订阅按钮操作路由到对应 Stripe iframe;当前也覆盖 GPC 队列任务轮询、本地 OTP 自动读取、手动 OTP 输入、PIN 提交与任务终止分支。 - `tests/cloudflare-temp-email-provider.test.js`:测试 Cloudflare Temp Email provider 的轮询与目标邮箱选择逻辑。 - `tests/cloudflare-temp-email-utils.test.js`:测试 Cloudflare Temp Email 工具层的 URL、域名、邮件解析逻辑。 -- `tests/cloudmail-provider.test.js`:测试 Cloud Mail provider 的验证码轮询和生成/转发收件目标邮箱选择逻辑。 +- `tests/cloudmail-provider.test.js`:测试 Cloud Mail provider 的验证码轮询和生成/转发收件目标邮箱选择逻辑,并覆盖生成邮箱时对共享注册邮箱状态持久化的接入。 - `tests/cloudmail-utils.test.js`:测试 Cloud Mail 工具层的 URL、域名、鉴权头、Token 与邮件响应归一化逻辑。 - `tests/content-utils.test.js`:测试内容脚本公共工具层对 `mail.126.com` 等网页邮箱来源的识别逻辑。 - `tests/duck-mail-content.test.js`:测试 Duck 页面脚本会等待页面现有地址或显式基线出现后再比较新地址,并要求新地址稳定渲染后才判定生成成功。